4
\$\begingroup\$

I am trying to find out if there is any difference between two implementations that currently do the same thing - gracefully shutdown applications/servers, for example, when Ctrl+C is hit. Both work fine and are based on the documentation.

What a friend of mine says, is that Example 2 handles shutdown at the application level which shuts down all contexts throughout the application. However, Example 1 does it at the HTTP server level which doesn't necessarily shut down all contexts throughout the application. Since I am a beginner I cannot argue back and need your input on this, please.

Example 1

The signals are handled in the http.go file so the whole graceful shutdown has been handled in a single file.

cmd/app/main.go

package main

import (
    "context"
    "internal/http"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    // Bootstrap config, logger, etc

    http.Start()
}

internal/http/server.go

package http


import (
    "context"
    "github.com/prometheus/common/log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func Start() {
    log.Infof("starting HTTP server")

    srv := &http.Server{Addr: ":8080", Handler: nil}

    idle := make(chan struct{})

    go shutdown(srv, idle)

    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatalf("failed to start/close HTTP server [%v]", err)
    }

    <-idle

    log.Info("gracefully shutdown HTTP server")
}

func shutdown(srv *http.Server, idle chan<- struct{}) {
    sig := make(chan os.Signal, 1)

    signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)

    <-sig

    ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("shutdown HTTP server by interrupting idle connections [%v]", err)
    }

    close(idle)
}

Example 2

The signals are handled in the application's main.go file so the whole graceful shutdown has been split within two files. The only addition is this example uses the WithCancel context.

cmd/app/main.go

package main

import (
    "context"
    "internal/http"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    // Bootstrap config, logger, etc

    backgroundCtx := context.Background()
    withCancelCtx, cancel := context.WithCancel(backgroundCtx)
    go shutdown(cancel)

    http.Start(withCancelCtx)
}

func shutdown(cancel context.CancelFunc) {
    sig := make(chan os.Signal, 1)

    signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)

    <-sig

    cancel()
}

internal/http/server.go

package http

import (
    "context"
    "github.com/prometheus/common/log"
    "net/http"
    "time"
)

func Start(ctx context.Context) {
    log.Infof("starting HTTP server")

    srv := &http.Server{Addr: ":8080", Handler: nil}

    idle := make(chan struct{})

    go shutdown(ctx, srv, idle)

    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatalf("failed to start/close HTTP server [%v]", err)
    }

    <-idle

    log.Info("gracefully shutdown HTTP server")
}

func shutdown(ctx context.Context, srv *http.Server, idle chan struct{}) {
    <-ctx.Done()

    ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("shutdown HTTP server by interrupting idle connections [%v]", err)
    }

    close(idle)
}
\$\endgroup\$

2 Answers 2

1
\$\begingroup\$

So to be helpful to others who might be interested in the final outcome after many R&D days and suggestions from other forum users so on., this is what I ended up with. I hope it helps and comments are welcome.

cmd/myself/main.go

package main

import (
    "context"
    "net/http"
    "log"
    "os"
    "os/signal"
    "syscall"

    "myself/internal/app"
)

func main() {
    srv := &http.Server{
        Addr:    ":8080",
        Handler: router.New(),
    }

    ctx, cancel := context.WithCancel(context.Background())

    signalChan := make(chan os.Signal, 1)

    go handleSignal(signalChan, cancel)

    if err := app.New(srv).Start(ctx); err != nil {
        log.Fatal(err)
    }

    log.Info("shutdown complete")
}

func handleSignal(signalChan chan os.Signal, cancel context.CancelFunc) {
    // os.Interrupt: Ctrl-C
    // syscall.SIGTERM: kill PID, docker stop, docker down
    signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)

    sig := <-signalChan

    log.Infof("shutdown started with %v signal", sig)

    cancel()
}

internal/app/mysql.go

package app

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "time"
)

type App struct {
    server  *http.Server
}

func New(srv *http.Server) App {
    return App{
        server:  srv,
    }
}

func (a App) Start(ctx context.Context) error {
    shutdownChan := make(chan struct{})

    go handleShutdown(ctx, shutdownChan, a)

    if err := a.server.ListenAndServe(); err != http.ErrServerClosed {
        return fmt.Errorf("failed to start [%v]", err)
    }

    <-shutdownChan

    return nil
}

func handleShutdown(ctx context.Context, shutdownChan chan<- struct{}, a App) {
    <-ctx.Done()

    ctx, cancel := context.WithTimeout(context.Background(), 10 * time.Second)
    defer cancel()

    if err := a.server.Shutdown(ctx); err != nil {
        log.Infof("interrupted active connections [%v]", err)
    } else {
        log.Infof("served all active connections")
    }

    close(shutdownChan)
}

```
\$\endgroup\$
2
  • \$\begingroup\$ The shutdownChan is surprising to me. I would expect that ListenAndServe returns only after Shutdown is complete, but apparently this is not the case? \$\endgroup\$ Commented Apr 2, 2021 at 18:58
  • \$\begingroup\$ From godoc: When Shutdown is called, Serve, ListenAndServe, and ListenAndServeTLS immediately return ErrServerClosed. Make sure the program doesn't exit and waits instead for Shutdown to return. \$\endgroup\$ Commented Apr 2, 2021 at 19:11
0
\$\begingroup\$

There is no "magic" difference between solution 1 and 2. They do the same thing as of now. Both solution 1 and 2 react to Ctrl+C in the same way. The only difference is that by additionally using ctx in solution 2, you allow yet another possibility to trigger a graceful shutdown from software. For now, YAGNI, so I am reviewing the simpler solution 1.

The biggest mistake by far and the biggest lesson (for any language, not in any way Go specific): if you introduce a dependency such as "github.com/prometheus/common/log", make a cost-benefit calculation. Always. That is: if you need colorful logs, don't introduce 75 new packages to do that. One line turned a nice slim http server into a super-fragile behemoth. Specifically for Go 1.11+, you can see it here:

go mod init
cat go.mod
go build ./...
cat go.mod      # now populated 
go mod graph
   (prints one line per each module and version)

Also, I think that code becomes less complicated if you Shutdown in the main flow, and ListenAndServe in a goroutine? Take a look at my proposition:

package http // new line

import (
    "context"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    log "github.com/sirupsen/logrus" // resulting `go mod graph`  prints 7 modules
    // "log" // builtin without coloring - 0 modules
    //"github.com/prometheus/common/log"   // resulting `go mod graph`  prints 75 modules
)

func Start() {
    log.Infof("starting HTTP server")

    srv := &http.Server{Addr: ":8080", Handler: nil}

    go func() { // change
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("failed to start HTTP server: %v", err) // change
        }
    }()

    sig := make(chan os.Signal, 1)

    signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)

    <-sig

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)  // change
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("graceful shutdown of HTTP server: %v", err) // change
    }

    log.Info("gracefully shutdown HTTP server")
}

For cmd/app/main.go my only concern is to use fully qualified import path like "github.com/kubanczyk/myproj/internal/http" instead of import "internal/http". There are dirty tricks to make short "internal/http" work for a short while, but I wouldn't recommend that. It's only intended for built-ins.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.