Go/Gin

Graceful Shutdown for Gin Servers in Containerized Deployments

5 min read by DebuggedIt

Quick answer

Every deployment, scale-down, and node drain sends your Go service a termination signal. If the process simply dies, in-flight requests are cut off, clients...

Every deployment, scale-down, and node drain sends your Go service a termination signal. If the process simply dies, in-flight requests are cut off, clients see connection resets, and database transactions are abandoned half-finished. A graceful shutdown lets the server stop taking new work, finish what it has started, and release resources cleanly. In Gin, the convenience method for starting the server does not give you this, so you need to wire it yourself.

Why router.Run Is Not Enough

Gin's Run method starts a server and blocks, but it gives you no handle to stop it gracefully. For production, create your own http.Server with the Gin engine as its handler. That gives you access to the Shutdown method and to timeouts that should be set anyway.

The standard library's Shutdown stops listening for new connections, closes idle connections, and waits for active requests to finish, until the context you pass expires. If the context expires first, it returns an error and you decide whether to force-close. Note that Shutdown does not wait for hijacked connections such as WebSockets, because it no longer tracks them. You must close or notify those yourself, for example with the server's registered shutdown hooks.

A Reliable Shutdown Skeleton

The following pattern listens for termination signals, shuts the server down with a deadline, and then releases other resources in a sensible order:

func main() {
    router := gin.New()
    router.Use(gin.Recovery())
    registerRoutes(router)

    srv := &http.Server{
        Addr:              ":8080",
        Handler:           router,
        ReadHeaderTimeout: 5 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    errCh := make(chan error, 1)
    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            errCh <- err
        }
    }()

    select {
    case err := <-errCh:
        log.Fatalf("server failed: %v", err)
    case <-ctx.Done():
        log.Println("shutdown signal received")
    }

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Printf("forced shutdown: %v", err)
    }

    // Only now close shared resources.
    db.Close()
}

Two details matter here. ListenAndServe returns http.ErrServerClosed after a normal Shutdown, which is not an error and should be ignored. And the shutdown context is created from a fresh background context, not the signal context, because the signal context is already canceled by the time you need to wait.

Order of Operations

Shut things down in the reverse order of how dependencies were started. Stop accepting requests first, wait for handlers to finish, then stop background workers, flush logs and metrics, and finally close the database pool and other clients. Closing the database before handlers finish turns graceful shutdown into a burst of errors.

If you run background goroutines, such as queue consumers or periodic jobs, give them a cancelable context and wait for them with a WaitGroup. Without that, the process may exit while a job is mid-write.

  • Pass the request context into database and HTTP calls so work stops promptly if clients disconnect.
  • Keep handler durations bounded, so draining completes within your shutdown deadline.
  • Log the start and end of shutdown with timing, which makes slow drains easy to spot.

Coordinating With Kubernetes and Load Balancers

An orchestrator sends the termination signal and, after a grace period, forcibly kills the process. In Kubernetes the default grace period is 30 seconds, configurable per pod, so your drain timeout must fit inside it with room to spare. Check the platform documentation for the exact sequence in your version.

There is also a timing problem: removing a pod from service endpoints and receiving the signal happen concurrently, and load balancers or proxies may keep sending new requests for a short time after shutdown begins. Two common mitigations help. First, flip your readiness endpoint to failing as soon as the signal arrives, so the pod is marked unready. Second, wait a few seconds before calling Shutdown, either with a short sleep in the application or a pre-stop hook, to let traffic stop arriving. The right delay depends on your infrastructure and should be tested rather than guessed.

var shuttingDown atomic.Bool

router.GET("/readyz", func(c *gin.Context) {
    if shuttingDown.Load() {
        c.Status(http.StatusServiceUnavailable)
        return
    }
    c.Status(http.StatusOK)
})

// after receiving the signal:
shuttingDown.Store(true)
time.Sleep(5 * time.Second)

Containers and the Signal Itself

Your Go process must actually receive the signal. If the container's entrypoint is a shell script that starts your binary without exec, the shell becomes the main process and may not forward the signal, so your handler never runs and the container is killed after the timeout. Use the exec form of the entrypoint, or call exec in the script, so the binary runs as the main process.

Frequently Asked Questions

What happens to long requests during shutdown?

Shutdown waits until they finish or the context expires. If the deadline passes, the call returns an error and remaining connections are cut when the process exits. Keep requests short or give streaming endpoints their own handling.

Does Shutdown handle WebSocket connections?

No. Hijacked connections are not tracked by Shutdown, so close them yourself, for example by notifying clients and registering a shutdown callback.

Should I handle SIGKILL?

It cannot be caught. That is why your drain must finish before the platform's grace period ends.

Do I need this for local development?

It is not essential, but handling interrupt signals the same way avoids surprises and lets you test the shutdown path before production.

Summary

Create your own http.Server around the Gin engine, listen for SIGTERM and SIGINT, call Shutdown with a bounded deadline, and close resources in reverse dependency order. Make readiness fail first and allow a short delay so load balancers stop sending traffic, and make sure the signal reaches your process by using the exec form of the entrypoint. Check the Go and orchestrator documentation for exact signal and grace period behavior in your environment.