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:
- Schließt alle Listener, sodass der Server keine neuen Verbindungen akzeptiert.
- Schließt alle im Leerlauf befindlichen Verbindungen.
- Wartet darauf, dass aktive Verbindungen wieder in den Leerlauf zurückkehren, und schließt sie dann. Wenn
ctxablä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.NotifyContextgibt einen Kontext zurück. Dieser Kontext wird abgebrochen, wenn der ProzessSIGINT(Ctrl+C) oderSIGTERM(das Signal, das Docker und Kubernetes senden) empfängt.ListenAndServeläuft in einer separaten Goroutine. Nachdem SieShutdownaufgerufen haben, gibt sie soforthttp.ErrServerClosedzurü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 wirdmainnicht 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.shutdownCtxverwendetcontext.Background()als Basis, nichtctx. Der Grund ist, dassctxbereits abgebrochen wurde. Wenn wir es verwenden würden, würdeShutdownsofort 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
ListenAndServeinmainauf undShutdownin einer separaten Goroutine. DaListenAndServesofort zurückkehrt, wennShutdownbeginnt, beendet sichmain, währendShutdownnoch auf Anfragen wartet. Der Prozess beendet sich und Anfragen werden weiterhin unterbrochen. Die Regel ist, dassmainwarten muss, bisShutdownzurü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
BaseContextauf 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öglicherweiseSIGTERMnicht an den Go-Prozess weiter. Verwenden Sie die Exec-FormCMD ["/app/server"]oder rufen Sieexec /app/serveram 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.Listenauf Port0lässt das Betriebssystem einen freien Port auswählen. Dies ermöglicht es Tests, parallel zu laufen, ohne in Konflikt zu geraten.- Der
startedChannel stellt sicher, dassShutdownnur aufgerufen wird, wenn die Anfrage tatsächlich im Handler angekommen ist. - Der Test überprüft drei Dinge:
Shutdowngibtnilzurü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
Shutdownverwenden. Die direkte Verwendung vonctxaussignal.NotifyContextführt dazu, dassShutdownsofortcontext canceledzurückgibt. http.ErrServerClosedals Fehler behandeln. Der Aufruf vonlog.Fatalmit 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 KubernetesSIGKILLsendet. ReadHeaderTimeoutvergessen. Ein langsamer Client kann eine Verbindung im aktiven Zustand halten, und das Herunterfahren dauert immer bis zum Timeout.os.Exitoderlog.Fatalnach dem Starten des Herunterfahrens verwenden. Beide ignorieren alledefer, 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.