Graceful Shutdown für HTTP-Server in Go

1. Überblick

Wenn Sie eine neue Version deployen oder herunterfahren, erhält Ihr Prozess SIGTERM. Wenn der Server sofort beendet wird, werden gerade verarbeitete Anfragen unterbrochen. Der Client erhält connection reset, und Bestellungen können nur zur Hälfte geschrieben werden.

Dieser Artikel zeigt dir, wie man einen Go HTTP-Server richtig herunterfährt. Der Server akzeptiert keine neuen Verbindungen mehr, wartet darauf, dass laufende Anfragen innerhalb einer begrenzten Zeit abgeschlossen werden, und schließt dann die Ressourcen in der richtigen Reihenfolge. Alle Beispiele verwenden nur die Standardbibliothek und benötigen Go 1.22 oder höher.

2. Das Problem bei abruptem Herunterfahren

Beginnen wir mit der vertrauten Schreibweise:

package main

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

func main() {
	http.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second) // Simulieren einer langsamen DB-Abfrage
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Starten Sie den Server und rufen Sie curl localhost:8080/orders/42 auf. Während die Anfrage wartet, drücken Sie Ctrl+C im Terminal des Servers. Das Ergebnis auf der Client-Seite:

curl: (52) Empty reply from server

Go installiert keinen Handler für SIGINT oder SIGTERM automatisch. Wenn die Laufzeit ein Signal empfängt, beendet sie den Prozess sofort. Keine defer wird ausgeführt und keine Anfrage wird abgewartet.

3. Graceful Shutdown mit http.Server.Shutdown

http.Server hat eine Shutdown(ctx) Methode. Diese Methode macht drei Dinge in der richtigen Reihenfolge:

  1. Schließt alle Listener, sodass der Server keine neuen Verbindungen akzeptiert.
  2. Schließt alle im Leerlauf befindlichen Verbindungen.
  3. Wartet darauf, dass aktive Verbindungen wieder in den Leerlauf zurückkehren, und schließt sie dann. Wenn ctx abläuft, bevor dies geschieht, gibt die Methode einen Fehler des Kontexts zurück.

Um diese Methode zu verwenden, müssen Sie Betriebssystemsignale selbst abfangen.

3.1. Signale mit signal.NotifyContext abfangen

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second)
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})

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

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

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

	select {
	case err := <-serverErr:
		log.Fatalf("server error: %v", err)
	case <-ctx.Done():
		log.Println("shutdown signal received")
	}
	stop() // Das zweite Ctrl+C wird den Prozess sofort beenden

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

	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("graceful shutdown failed: %v, forcing close", err)
		_ = srv.Close()
	}
	log.Println("server stopped")
}

Die Hauptpunkte im Code:

  • signal.NotifyContext gibt einen Kontext zurück. Dieser Kontext wird abgebrochen, wenn der Prozess SIGINT (Ctrl+C) oder SIGTERM (das Signal, das Docker und Kubernetes senden) empfängt.
  • ListenAndServe läuft in einer separaten Goroutine. Nachdem Sie Shutdown aufgerufen haben, gibt sie sofort http.ErrServerClosed zurück. Dies ist ein normaler Fehler, daher filtern wir ihn heraus.
  • Der serverErr-Channel hat einen Puffer von 1. Er hilft bei der Erfassung von Startfehlern, z.B. wenn der Port bereits belegt ist. Dadurch wird main nicht blockiert und wartet auf ein Signal, das nie kommt.
  • stop() wird aufgerufen, sobald das Signal empfangen wird. Diese Funktion stellt das Standardverhalten wieder her, sodass ein weiterer Druck auf Ctrl+C den Prozess sofort beendet. Dies ist sehr nützlich, wenn das Herunterfahren hängen bleibt.
  • shutdownCtx verwendet context.Background() als Basis, nicht ctx. Der Grund ist, dass ctx bereits abgebrochen wurde. Wenn wir es verwenden würden, würde Shutdown sofort einen Fehler zurückgeben.
  • Wenn nach 10 Sekunden noch Anfragen verarbeitet werden, srv.Close() erzwingt das Schließen aller Verbindungen.

Wiederholtes Experimentieren aus Abschnitt 2: Rufen Sie curl auf, und drücken Sie dann Ctrl+C, während die Anfrage wartet. Das Serverlog:

2026/10/24 10:00:00 listening on :8080
^C2026/10/24 10:00:01 shutdown signal received
2026/10/24 10:00:03 server stopped

Die Client-Seite empfängt die vollständige Antwort:

order 42

Der Server wartet etwa 2 Sekunden auf die restliche Anfrage, bevor er beendet wird.

Hinweis: Ein sehr häufiger Fehler ist, die Rollen der beiden Goroutinen zu verwechseln. Einige Leute rufen ListenAndServe in main auf und Shutdown in einer separaten Goroutine. Da ListenAndServe sofort zurückkehrt, wenn Shutdown beginnt, beendet sich main, während Shutdown noch auf Anfragen wartet. Der Prozess beendet sich und Anfragen werden weiterhin unterbrochen. Die Regel ist, dass main warten muss, bis Shutdown zurückkehrt.

4. Was Shutdown nicht automatisch verarbeitet

4.1. Hijacked Verbindungen: WebSocket, HTTP/1.1 Upgrade

Shutdown verfolgt keine Verbindungen, die gekapert wurden, wie z.B. WebSocket. Der Server wartet auch nicht auf sie. Um diese Verbindungen zu schließen, registrieren Sie einen Callback mit RegisterOnShutdown:

type Hub struct {
	mu    sync.Mutex
	conns map[*websocketConn]struct{}
}

func (h *Hub) CloseAll() {
	h.mu.Lock()
	defer h.mu.Unlock()
	for c := range h.conns {
		c.SendCloseFrame("server shutting down")
		c.Close()
	}
}

// in main:
hub := &Hub{conns: make(map[*websocketConn]struct{})}
srv.RegisterOnShutdown(hub.CloseAll)

websocketConn stellt hier den Verbindungstyp der WebSocket-Bibliothek dar, die Sie verwenden. Der Callback wird in einer separaten Goroutine ausgeführt, sobald Shutdown beginnt. Shutdown wartet nicht auf diesen Callback, daher sollten Sie selbst mit sync.WaitGroup synchronisieren, wenn Sie warten müssen.

4.2. Lange laufende Handler: Streaming, Long-Polling

Ein Handler, der unendlich Daten streamt, z.B. Server-Sent Events, wird nie im Leerlauf sein. In diesem Fall wartet Shutdown immer bis zum Timeout. Sie können BaseContext verwenden, damit jeder Request-Context abgebrochen wird, wenn der Server zu herunterfahren beginnt:

appCtx, cancelApp := context.WithCancel(context.Background())

srv := &http.Server{
	Addr:    ":8080",
	Handler: mux,
	BaseContext: func(net.Listener) context.Context {
		return appCtx
	},
}
srv.RegisterOnShutdown(cancelApp)

mux.HandleFunc("GET /orders/stream", func(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/event-stream")
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-r.Context().Done():
			return // Server fährt herunter oder Client unterbrach die Verbindung
		case t := <-ticker.C:
			fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
			w.(http.Flusher).Flush()
		}
	}
})

Jeder Request-Context wird von appCtx aus erstellt. Wenn Shutdown läuft, wird cancelApp aufgerufen und alle r.Context() werden abgebrochen. Der Stream-Handler sieht Done() und kehrt zurück. Danach wird Shutdown früher abgeschlossen.

Hinweis: Wenden Sie diese Methode nicht auf alle Handler an. Wenn Sie den Kontext einer Anfrage abbrechen, die gerade eine Bestellung in die DB schreibt, wird der Schreibbefehl selbst abgebrochen. Das Ziel, “auf den Abschluss der Anfrage zu warten”, wird nicht erreicht. Verwenden Sie BaseContext auf diese Weise nur, wenn alle Handler sicher unterbrochen werden können.

4.3. Hintergrund-Goroutinen und Nebenressourcen

Shutdown verwaltet nur HTTP-Verbindungen. Worker, die E-Mails senden, Kafka-Consumer und DB-Verbindungspools sind Ihre Verantwortung. Die Reihenfolge zum Schließen sollte umgekehrt zur Reihenfolge der Initialisierung sein:

if err := srv.Shutdown(shutdownCtx); err != nil {
	log.Printf("http shutdown: %v", err)
}

cancelWorkers() // Sagen Sie den Hintergrund-Goroutinen, sie sollen stoppen
workersDone := make(chan struct{})
go func() { wg.Wait(); close(workersDone) }()

select {
case <-workersDone:
case <-shutdownCtx.Done():
	log.Println("workers did not stop in time")
}

if err := db.Close(); err != nil {
	log.Printf("db close: %v", err)
}

Der HTTP-Server wird zuerst heruntergefahren, weil Handler die DB noch benötigen. Die DB wird zuletzt geschlossen. Der gesamte Prozess verwendet denselben shutdownCtx, daher überschreitet die Gesamtzeit nicht das Budget von 10 Sekunden.

5. Ausführung auf Kubernetes

Wenn ein Pod gelöscht wird, macht Kubernetes zwei Dinge parallel: Es sendet SIGTERM an den Container und entfernt den Pod aus der Endpunktliste des Service. Das Entfernen der Endpunkte dauert einige Sekunden, um sich über kube-proxy und ingress auszubreiten. Wenn der Server den Listener sofort bei Empfang von SIGTERM schließt, werden Anfragen, die noch an den Pod geleitet werden, abgelehnt.

Der Weg, dies zu beheben, besteht darin, dass die Readiness Probe zuerst einen Fehler signalisiert, eine Weile wartet und dann Shutdown aufruft:

var shuttingDown atomic.Bool

mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
	if shuttingDown.Load() {
		http.Error(w, "shutting down", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})

// nach dem Empfangen des Signals:
<-ctx.Done()
shuttingDown.Store(true)
time.Sleep(5 * time.Second) // Warten Sie darauf, dass der Load Balancer den Pod entfernt
srv.Shutdown(shutdownCtx)

Sie müssen auch terminationGracePeriodSeconds größer setzen als die gesamte Wartezeit plus Shutdown-Timeout. In diesem Beispiel sind das 5 + 10 = 15 Sekunden:

spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: order-service
      image: order-service:1.4.0
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8080
        periodSeconds: 2

Nach Ablauf der Grace-Zeit sendet Kubernetes SIGKILL. Dieses Signal kann nicht abgefangen werden, und die gesamte Shutdown-Logik wird ignoriert.

Hinweis: Wenn Sie die Binärdatei über ein Shell-Skript ausführen, z.B. CMD ./start.sh, dann ist die Shell die PID 1 und leitet möglicherweise SIGTERM nicht an den Go-Prozess weiter. Verwenden Sie die Exec-Form CMD ["/app/server"] oder rufen Sie exec /app/server am Ende des Skripts auf.

6. Testen des Shutdown-Verhaltens

Sie können einen Test schreiben, der bestätigt, dass Shutdown auf den Abschluss laufender Anfragen wartet. Dieser Test benötigt nur das testing Paket:

package main

import (
	"context"
	"io"
	"net"
	"net/http"
	"testing"
	"time"
)

func TestShutdownWaitsForInFlightRequest(t *testing.T) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}

	started := make(chan struct{})
	srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		close(started)
		time.Sleep(500 * time.Millisecond)
		io.WriteString(w, "order 42")
	})}
	go srv.Serve(ln)

	type result struct {
		body string
		err  error
	}
	resCh := make(chan result, 1)
	go func() {
		resp, err := http.Get("http://" + ln.Addr().String() + "/orders/42")
		if err != nil {
			resCh <- result{err: err}
			return
		}
		defer resp.Body.Close()
		b, err := io.ReadAll(resp.Body)
		resCh <- result{body: string(b), err: err}
	}()

	<-started // Stellen Sie sicher, dass die Anfrage den Handler erreicht hat
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		t.Fatalf("Shutdown returned error: %v", err)
	}

	res := <-resCh
	if res.err != nil {
		t.Fatalf("request failed: %v", res.err)
	}
	if res.body != "order 42" {
		t.Fatalf("got body %q, want %q", res.body, "order 42")
	}

	if _, err := http.Get("http://" + ln.Addr().String()); err == nil {
		t.Fatal("expected new connections to be refused after shutdown")
	}
}

Die Hauptpunkte im Test:

  • net.Listen auf Port 0 lässt das Betriebssystem einen freien Port auswählen. Dies ermöglicht es Tests, parallel zu laufen, ohne in Konflikt zu geraten.
  • Der started Channel stellt sicher, dass Shutdown nur aufgerufen wird, wenn die Anfrage tatsächlich im Handler angekommen ist.
  • Der Test überprüft drei Dinge: Shutdown gibt nil zurück, der Client empfängt den vollständigen Body und neue Verbindungen werden abgelehnt.
go test -v -run TestShutdown
=== RUN   TestShutdownWaitsForInFlightRequest
--- PASS: TestShutdownWaitsForInFlightRequest (0.50s)
PASS

Die Laufzeit beträgt etwa 0,5 Sekunden, genau wie die Schlafenszeit des Handlers. Dies zeigt, dass Shutdown auf die Anfrage gewartet hat.

7. Häufige Fehler

  • Einen bereits abgebrochenen Kontext für Shutdown verwenden. Die direkte Verwendung von ctx aus signal.NotifyContext führt dazu, dass Shutdown sofort context canceled zurückgibt.
  • http.ErrServerClosed als Fehler behandeln. Der Aufruf von log.Fatal mit diesem Fehler beendet den Prozess, bevor das Herunterfahren abgeschlossen ist.
  • Keinen Timeout setzen. Die Verwendung von context.Background() ohne Deadline bedeutet, dass ein hängender Handler den Prozess auf unbestimmte Zeit halten kann, bis Kubernetes SIGKILL sendet.
  • ReadHeaderTimeout vergessen. Ein langsamer Client kann eine Verbindung im aktiven Zustand halten, und das Herunterfahren dauert immer bis zum Timeout.
  • os.Exit oder log.Fatal nach dem Starten des Herunterfahrens verwenden. Beide ignorieren alle defer, sodass DB und Dateien nicht geschlossen werden.

8. Fazit

Graceful Shutdown in Go benötigt drei Komponenten: signal.NotifyContext zum Abfangen von SIGTERM, srv.Shutdown mit einem Kontext mit Timeout und main muss auf die Rückgabe von Shutdown warten. Von dieser Grundlage aus fügen Sie die Teile hinzu, die Shutdown nicht selbst verwaltet. Das sind RegisterOnShutdown für WebSocket, BaseContext für Streaming-Handler und das Schließen von Workern und DB in umgekehrter Reihenfolge der Initialisierung. Wenn Sie auf Kubernetes laufen, sorgen Sie dafür, dass die Readiness Probe einen Fehler signalisiert und warten Sie ein paar Sekunden, bevor Sie den Listener schließen, und setzen Sie terminationGracePeriodSeconds ausreichend groß. Abschließend halten Sie einen Test wie in Abschnitt 6 bereit, um frühzeitig zu erkennen, wenn jemand versehentlich die Shutdown-Logik durcheinander bringt.

golanghttpgraceful-shutdownkubernetesconcurrency