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:

  1. Ferme tous les écouteurs, le serveur n’accepte donc pas de nouvelles connexions.
  2. Ferme les connexions au repos.
  3. Attend que les connexions actives redeviennent au repos puis les ferme. Si ctx expire 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.NotifyContext retourne un contexte. Ce contexte est annulé lorsque le processus reçoit SIGINT (Ctrl+C) ou SIGTERM (la commande que Docker et Kubernetes envoient).
  • ListenAndServe s’exécute dans une goroutine séparée. Après avoir appelé Shutdown, il retourne immédiatement http.ErrServerClosed. C’est une erreur normale, nous la filtrons donc.
  • Le canal serverErr a 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, main n’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é.
  • shutdownCtx utilise context.Background() comme racine, pas ctx. La raison en est que ctx a déjà été annulé, si nous l’utilisons, Shutdown retournera 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 ListenAndServe dans main et Shutdown dans une autre goroutine. Parce que ListenAndServe retourne immédiatement lorsque Shutdown commence, main se terminera pendant que Shutdown attend toujours les requêtes. Le processus se termine et les requêtes sont toujours interrompues. La règle est que main doit attendre que Shutdown retourne.

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 BaseContext de 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érer SIGTERM au processus Go. Utilisez la forme exec CMD ["/app/server"] ou appelez exec /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.Listen sur le port 0 pour 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 started s’assure que Shutdown n’est appelé que lorsque la requête est réellement dans le gestionnaire.
  • Le test vérifie trois choses: Shutdown retourne nil, 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 le ctx de signal.NotifyContext fait que Shutdown retourne context canceled immédiatement.
  • Traiter http.ErrServerClosed comme une erreur. Appeler log.Fatal avec 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 envoie SIGKILL.
  • 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.Exit ou log.Fatal après avoir commencé l’arrêt. Les deux ignorent tous les defer, 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.

golanghttpgraceful-shutdownkubernetesconcurrency