Arrêt gracieux pour serveur HTTP Go
1. Vue d’ensemble
Lors du déploiement d’une nouvelle version ou d’une réduction d’échelle, votre processus recevra SIGTERM. Si le serveur s’arrête immédiatement, les requêtes en cours de traitement sont interrompues à mi-chemin. Le client reçoit une erreur connection reset, et une commande pourrait n’être que partiellement enregistrée.
Cet article vous guide pour arrêter correctement un serveur HTTP Go. Le serveur cessera d’accepter les nouvelles connexions, attendra que les requêtes en cours se terminent dans un délai limité, puis fermera les ressources dans le bon ordre. Tous les exemples utilisent uniquement la bibliothèque standard et nécessitent Go 1.22 ou ultérieur.
2. Le problème quand arrêter le serveur brusquement
Commençons par la façon la plus courante d’écrire:
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) // simuler une requête DB lente
fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Lancez le serveur puis appelez curl localhost:8080/orders/42. Pendant que la requête est en attente, appuyez sur Ctrl+C dans le terminal du serveur. Le résultat côté client:
curl: (52) Empty reply from server
Go n’installe pas automatiquement de gestionnaire pour SIGINT ou SIGTERM. Lorsqu’il reçoit le signal, le runtime termine le processus immédiatement. Aucun defer n’est exécuté et aucune requête n’est attendue.
3. Arrêt gracieux avec http.Server.Shutdown
http.Server a une méthode Shutdown(ctx). Cette méthode fait trois choses dans l’ordre:
- Ferme tous les écouteurs, le serveur n’accepte donc pas de nouvelles connexions.
- Ferme les connexions au repos.
- Attend que les connexions actives redeviennent au repos puis les ferme. Si
ctxexpire avant, la méthode retourne l’erreur du contexte.
Pour utiliser cette méthode, vous devez attraper les signaux du système d’exploitation vous-même.
3.1. Attraper des signaux avec signal.NotifyContext
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() // recevoir Ctrl+C une deuxième fois tuera le processus immédiatement
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")
}
Points clés dans le code:
signal.NotifyContextretourne un contexte. Ce contexte est annulé lorsque le processus reçoitSIGINT(Ctrl+C) ouSIGTERM(la commande que Docker et Kubernetes envoient).ListenAndServes’exécute dans une goroutine séparée. Après avoir appeléShutdown, il retourne immédiatementhttp.ErrServerClosed. C’est une erreur normale, nous la filtrons donc.- Le canal
serverErra une mémoire tampon de 1. Il aide à détecter les erreurs de démarrage, par exemple si le port est déjà occupé. Grâce à cela,mainn’est pas bloqué en attendant un signal qui ne vient jamais. stop()est appelé immédiatement après la réception du signal. Cette fonction restaure le comportement par défaut, donc si vous appuyez à nouveau sur Ctrl+C, le processus s’arrête immédiatement. C’est très pratique quand l’arrêt est bloqué.shutdownCtxutilisecontext.Background()comme racine, pasctx. La raison en est quectxa déjà été annulé, si nous l’utilisons,Shutdownretournera une erreur immédiatement.- S’il reste des requêtes après 10 secondes,
srv.Close()ferme de force toutes les connexions.
Répétez le test de la section 2: appelez curl, puis appuyez sur Ctrl+C pendant que la requête est en attente. Journal du serveur:
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
Le client reçoit la réponse complète:
order 42
Le serveur attend environ 2 secondes supplémentaires de la requête, puis s’arrête.
Remarque: Une erreur très courante est d’intervertir les rôles des deux goroutines. Certaines personnes appellent
ListenAndServedansmainetShutdowndans une autre goroutine. Parce queListenAndServeretourne immédiatement lorsqueShutdowncommence,mainse terminera pendant queShutdownattend toujours les requêtes. Le processus se termine et les requêtes sont toujours interrompues. La règle est quemaindoit attendre queShutdownretourne.
4. Ce que Shutdown ne gère pas automatiquement
4.1. Connexions détournées: WebSocket, mise à niveau HTTP/1.1
Shutdown ne suit pas les connexions qui ont été détournées, par exemple WebSocket. Le serveur ne les attend pas non plus. Pour fermer ces connexions, vous enregistrez un rappel avec 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("serveur en fermeture")
c.Close()
}
}
// dans main:
hub := &Hub{conns: make(map[*websocketConn]struct{})}
srv.RegisterOnShutdown(hub.CloseAll)
websocketConn ici représente le type de connexion de la bibliothèque WebSocket que vous utilisez. Le rappel s’exécute dans une goroutine séparée dès que Shutdown commence. Shutdown n’attend pas la fin de ce rappel, donc si vous avez besoin d’attendre, synchronisez vous-même avec sync.WaitGroup.
4.2. Gestionnaires qui s’exécutent longtemps: streaming, long polling
Un gestionnaire qui diffuse des données infinies, par exemple Server-Sent Events, ne revient jamais au repos. Alors Shutdown attend toujours jusqu’à expiration du délai. Vous pouvez utiliser BaseContext pour que le contexte de chaque requête soit annulé au démarrage de l’arrêt du serveur:
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 // le serveur s'arrête ou le client se déconnecte
case t := <-ticker.C:
fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
w.(http.Flusher).Flush()
}
}
})
Chaque contexte de requête est créé à partir d’appCtx. Lorsque Shutdown s’exécute, cancelApp est appelé et chaque r.Context() est annulé. Le gestionnaire de flux voit Done() et retourne. Ensuite, Shutdown se termine rapidement.
Remarque: Vous ne devriez pas appliquer cela à chaque gestionnaire. Si vous annulez le contexte d’une requête en train d’écrire une commande dans la base de données, l’écriture elle-même sera annulée. L’objectif “d’attendre que la requête se termine” n’est plus atteint. N’utilisez
BaseContextde cette façon que lorsque tous les gestionnaires peuvent s’arrêter sans danger au milieu.
4.3. Goroutines de fond et ressources annexes
Shutdown gère uniquement les connexions HTTP. Les workers qui envoient des e-mails, les consommateurs Kafka et le pool de connexions de la base de données sont de votre responsabilité. L’ordre de fermeture doit être l’inverse de l’ordre d’initialisation:
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("http shutdown: %v", err)
}
cancelWorkers() // dire aux goroutines de fond d'arrêter
workersDone := make(chan struct{})
go func() { wg.Wait(); close(workersDone) }()
select {
case <-workersDone:
case <-shutdownCtx.Done():
log.Println("les workers ne se sont pas arrêtés à temps")
}
if err := db.Close(); err != nil {
log.Printf("db close: %v", err)
}
Le serveur HTTP s’arrête en premier car les gestionnaires ont toujours besoin de la base de données. La base de données est fermée en dernier. L’ensemble du processus utilise shutdownCtx, donc le temps total ne dépasse pas le budget de 10 secondes.
5. Exécution sur Kubernetes
Quand un pod est supprimé, Kubernetes fait deux choses en parallèle: envoyer SIGTERM au conteneur et supprimer le pod de la liste des points de terminaison du Service. La suppression du point de terminaison prend quelques secondes pour se propager à kube-proxy et ingress. Si le serveur ferme l’écouteur dès la réception de SIGTERM, les requêtes toujours routées vers le pod seront refusées.
La solution consiste à faire signaler à la sonde de disponibilité d’abord, attendre un peu, puis appeler Shutdown:
var shuttingDown atomic.Bool
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if shuttingDown.Load() {
http.Error(w, "arrêt en cours", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
// après avoir reçu le signal:
<-ctx.Done()
shuttingDown.Store(true)
time.Sleep(5 * time.Second) // attendre que le load balancer supprime le pod
srv.Shutdown(shutdownCtx)
Vous devez également configurer terminationGracePeriodSeconds plus grand que le temps d’attente total plus le délai d’arrêt. Pour cet exemple, c’est 5 + 10 = 15 secondes:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: order-service
image: order-service:1.4.0
readinessProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 2
Quand la période de grâce expire, Kubernetes envoie SIGKILL. Ce signal ne peut pas être intercepté et toute votre logique d’arrêt sera ignorée.
Remarque: Si vous exécutez le binaire via un script shell, par exemple
CMD ./start.sh, le shell est le PID 1 et peut ne pas transférerSIGTERMau processus Go. Utilisez la forme execCMD ["/app/server"]ou appelezexec /app/serverà la fin du script.
6. Tester le comportement d’arrêt
Vous pouvez écrire un test qui confirme que Shutdown attend que les requêtes en cours se terminent. Ce test n’a besoin que du package testing:
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 // s'assurer que la requête est entrée dans le gestionnaire
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
t.Fatalf("Shutdown a retourné une erreur: %v", err)
}
res := <-resCh
if res.err != nil {
t.Fatalf("la requête a échoué: %v", res.err)
}
if res.body != "order 42" {
t.Fatalf("corps reçu %q, attendu %q", res.body, "order 42")
}
if _, err := http.Get("http://" + ln.Addr().String()); err == nil {
t.Fatal("les nouvelles connexions devraient être refusées après l'arrêt")
}
}
Points clés du test:
net.Listensur le port0pour que le système d’exploitation choisisse le port libre. Ainsi les tests exécutés en parallèle ne se heurtent pas.- Le canal
starteds’assure queShutdownn’est appelé que lorsque la requête est réellement dans le gestionnaire. - Le test vérifie trois choses:
Shutdownretournenil, le client reçoit le corps complet et les nouvelles connexions sont refusées.
go test -v -run TestShutdown
=== RUN TestShutdownWaitsForInFlightRequest
--- PASS: TestShutdownWaitsForInFlightRequest (0.50s)
PASS
Le temps d’exécution est d’environ 0,5 seconde, exactement le temps d’attente du gestionnaire. Cela montre que Shutdown a attendu la requête.
7. Erreurs courantes
- Utiliser un contexte déjà annulé pour
Shutdown. Passer directement lectxdesignal.NotifyContextfait queShutdownretournecontext canceledimmédiatement. - Traiter
http.ErrServerClosedcomme une erreur. Appelerlog.Fatalavec cette erreur arrêtera le processus avant la fin de l’arrêt. - Ne pas définir de délai d’expiration. Utiliser
context.Background()sans deadline signifie qu’un gestionnaire qui se fige gardera le processus indéfiniment, jusqu’à ce que Kubernetes envoieSIGKILL. - Oublier
ReadHeaderTimeout. Un client lent peut garder la connexion active, et l’arrêt s’exécutera toujours jusqu’à expiration du délai. - Utiliser
os.Exitoulog.Fatalaprès avoir commencé l’arrêt. Les deux ignorent tous lesdefer, donc la base de données et les fichiers ne sont pas fermés.
8. Conclusion
L’arrêt gracieux dans Go nécessite trois composants: signal.NotifyContext pour capturer SIGTERM, srv.Shutdown avec un contexte à délai d’expiration, et main doit attendre que Shutdown retourne. À partir de cette base, vous ajoutez les parties que Shutdown ne gère pas automatiquement. Ce sont RegisterOnShutdown pour WebSocket, BaseContext pour les gestionnaires de streaming et la fermeture des workers et de la base de données dans l’ordre inverse de l’initialisation. Lors de l’exécution sur Kubernetes, faites signaler à la sonde de disponibilité d’abord et attendez quelques secondes avant de fermer l’écouteur, tout en définissant terminationGracePeriodSeconds suffisamment grand. Enfin, gardez un test comme dans la section 6 pour détecter rapidement si quelqu’un a accidentellement cassé la séquence d’arrêt.