Skip to content
~/rifqi_abrory

Pages

Home
About
Stack
Experience
Projects
Writing
Contact
All projects

Projects

Avrist PINTAR — Agent Portal
Avrist e-Application
Avrist Academy — LMS CMS
Avrist e-Payment
PRUHub
SIYAP

Articles

Angular signals for everyday admin pages
Typed reactive forms in Angular, without the any
Consistent API errors in ASP.NET Core with ProblemDetails
Passing CancellationToken all the way down in ASP.NET Core
Graceful shutdown for Go HTTP services
Idempotency keys: making payment APIs safe to retry
A spam-resistant contact form with Next.js Server Actions
Offline-friendly forms in React Native: drafts and a submit queue
Request validation and error handling in Spring Boot 3
Keyset pagination in SQL Server: when OFFSET gets slow

Actions

Switch to dark mode
Read in Bahasa Indonesia
Copy email address
All articles

Graceful shutdown for Go HTTP services

Stop accepting new requests, finish the ones in flight, then close the database: a small main.go pattern for containers and deploys.

3 min read

On this page

What happens during a deploy

Every deploy, scale-down, or container restart sends your process a signal, usually SIGTERM. A Go service that does not handle it simply dies: requests in progress are cut off, clients see connection resets, and a half-finished database write may be rolled back or, worse, partially applied by a later retry.

Graceful shutdown means three steps, in this order:

  1. Stop accepting new connections.
  2. Let in-flight requests finish, with a deadline.
  3. Close shared resources such as the database pool.

The standard library already has everything needed.

The pattern

package main

import (
	"context"
	"database/sql"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
	// ctx is cancelled on Ctrl+C (SIGINT) or SIGTERM from Docker/Kubernetes.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	if err := run(ctx); err != nil {
		slog.Error("server stopped with error", "err", err)
		os.Exit(1)
	}
}

func run(ctx context.Context) error {
	db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
	if err != nil {
		return err
	}
	defer db.Close() // 3. runs last, after the server has drained

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           routes(db),
		ReadHeaderTimeout: 5 * time.Second,
	}

	serverErr := make(chan error, 1)
	go func() {
		slog.Info("listening", "addr", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			serverErr <- err
		}
		close(serverErr)
	}()

	select {
	case err := <-serverErr:
		return err // failed to start, e.g. port already in use
	case <-ctx.Done():
		slog.Info("shutdown signal received")
	}

	// 1 + 2. Stop accepting connections and wait for active requests, at most 20 seconds.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()

	return srv.Shutdown(shutdownCtx)
}

A few details are easy to miss:

  • ListenAndServe returns http.ErrServerClosed as soon as Shutdown is called. That is the normal path, not an error.
  • The shutdown context is created from context.Background(), not from ctx. ctx is already cancelled at that point; using it would give the server zero time to drain.
  • defer db.Close() runs after Shutdown returns, so handlers never see a closed pool.

Let handlers notice too

Shutdown waits for handlers but does not interrupt them. Long handlers should respect the request context, which is cancelled when the client disconnects:

func listPolicies(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		rows, err := db.QueryContext(r.Context(),
			`SELECT number, status FROM policies ORDER BY updated_at DESC LIMIT 50`)
		if err != nil {
			http.Error(w, "could not load policies", http.StatusInternalServerError)
			return
		}
		defer rows.Close()
		// ... encode rows as JSON
	}
}

Using QueryContext(r.Context(), ...) instead of Query(...) means an abandoned request also stops its query.

Match the platform's grace period

Your timeout must be shorter than the time the platform waits before killing the process:

  • Docker docker stop waits 10 seconds by default (--time / stop_grace_period changes it).
  • Kubernetes waits terminationGracePeriodSeconds, 30 seconds by default.

A 20-second shutdown timeout fits Kubernetes, but not Docker's default. Set them deliberately.

Make sure the signal reaches Go

In a container, the signal goes to PID 1. If your Dockerfile uses the shell form, the shell is PID 1 and may not forward SIGTERM:

# Shell form: /bin/sh is PID 1, your app may never see SIGTERM.
CMD ./server

# Exec form: the Go binary is PID 1 and receives the signal directly.
CMD ["./server"]

Readiness during shutdown

Behind a load balancer, there is a short window where traffic is still routed to a pod that has started shutting down. A common addition is a readiness endpoint that starts returning 503 as soon as the signal arrives, followed by a brief pause before calling Shutdown, so the load balancer stops sending new requests first.

Summary

  • Use signal.NotifyContext to turn SIGTERM into context cancellation.
  • Call srv.Shutdown with a fresh timeout context; treat http.ErrServerClosed as success.
  • Close the database after the server has drained.
  • Use request contexts in handlers, exec-form CMD, and a timeout shorter than the platform's.