Problem: wdrożenie, które gubi żądania
Każde wdrożenie nowej wersji aplikacji oznacza restart procesu. Stara wersja musi się zakończyć, nowa wystartować. Pytanie brzmi: co dzieje się z żądaniami HTTP, które w momencie restartu są właśnie przetwarzane?
Jeśli proces zostanie zabity natychmiast, użytkownik wypełniający formularz zobaczy błąd, rozpoczęta transakcja w bazie danych zostanie przerwana, a plik wgrywany na serwer — ucięty w połowie. Przy jednym wdrożeniu dziennie i niewielkim ruchu może to umknąć uwadze. Przy częstych deploymentach i realnym obciążeniu takie błędy stają się widoczne w logach i, co gorsza, u użytkowników.
Rozwiązaniem jest graceful shutdown — łagodne zamykanie, w którym aplikacja przestaje przyjmować nowe żądania, ale pozwala dokończyć te już rozpoczęte.
Jak Docker zatrzymuje kontener
Zanim przejdziemy do kodu, warto zrozumieć mechanizm zatrzymywania. Gdy wykonujemy docker compose restart albo docker stop, Docker wysyła do procesu głównego w kontenerze sygnał SIGTERM. To uprzejma prośba: „zakończ pracę, kiedy będziesz gotowy". Następnie Docker czeka domyślnie 10 sekund. Jeśli proces w tym czasie się nie zakończy, otrzymuje SIGKILL — sygnał, którego nie da się obsłużyć ani zignorować. Proces ginie natychmiast, razem ze wszystkimi otwartymi połączeniami.
Aplikacja, która nie nasłuchuje na SIGTERM, po prostu ignoruje tę dziesięciosekundową szansę i za każdym razem kończy brutalnie. Warto też pamiętać o pułapce: jeśli w Dockerfile użyjemy formy CMD ./server zamiast CMD ["./server"], proces uruchomi się jako dziecko powłoki sh, która nie przekazuje sygnałów dalej — i nasza obsługa SIGTERM nigdy się nie wykona.
Implementacja w czystym Go
Standardowa biblioteka Go ma wszystko, czego potrzebujemy. Kluczowe elementy to pakiet os/signal oraz metoda Shutdown na http.Server:
package main
import (
"context"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Kontekst anulowany po otrzymaniu SIGINT lub SIGTERM
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("serwer: %v", err)
}
}()
<-ctx.Done() // czekamy na sygnał
// Dajemy 8 sekund na dokończenie aktywnych żądań
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 8*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("wymuszone zamknięcie: %v", err)
}
log.Println("serwer zakończył pracę")
}
Co tu się dzieje po kolei:
signal.NotifyContext(dostępne od Go 1.16) tworzy kontekst, który zostaje anulowany, gdy proces otrzyma wskazany sygnał. To wygodniejsza alternatywa dla ręcznego tworzenia kanałuchan os.Signal.server.Shutdownzamyka nasłuchujący socket — nowe połączenia przestają być przyjmowane — a następnie czeka, aż aktywne żądania zostaną obsłużone do końca.- Timeout 8 sekund jest celowo krótszy niż 10-sekundowy limit Dockera. Jeśli jakieś żądanie wisi zbyt długo, wolimy sami je przerwać i posprzątać, niż zostać zabici przez SIGKILL.
Sprzątanie zasobów
Zamknięcie serwera HTTP to zwykle nie wszystko. Po Shutdown warto domknąć pozostałe zasoby: pulę połączeń do bazy danych, klienta kolejki, otwarte pliki, flush logów. Kolejność ma znaczenie — najpierw przestajemy przyjmować ruch, potem zamykamy to, z czego handlery mogły korzystać:
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("wymuszone zamknięcie: %v", err)
}
db.Close() // pula połączeń do PostgreSQL
logFile.Sync() // dopisanie zbuforowanych logów
Gdybyśmy zamknęli bazę przed serwerem, żądania kończone w trakcie shutdownu dostałyby błędy połączenia — czyli dokładnie to, przed czym graceful shutdown miał nas chronić.
Współpraca z reverse proxy
W typowej architekturze — takiej jak nasza, gdzie Nginx stoi przed serwisami Go w sieci Dockera — graceful shutdown działa w tandemie z proxy. Gdy kontener przestaje przyjmować połączenia, Nginx dostaje błąd połączenia i może zwrócić 502. Dlatego przy wdrożeniach warto zachować kolejność: najpierw uruchomić nową wersję kontenera, a na końcu przeładować proxy, żeby wskazywało na świeżą instancję. Samo łagodne zamykanie minimalizuje okno, w którym cokolwiek może pójść nie tak — z kilku sekund błędów robi się zero lub niemal zero.
Czy to zawsze potrzebne?
Krótka odpowiedź: tak, bo koszt jest znikomy. Cała implementacja to kilkanaście linii kodu ze standardowej biblioteki, bez żadnych zewnętrznych zależności — w duchu tego, jak piszemy wszystkie nasze serwisy. Nawet prosta strona wizytówka z formularzem kontaktowym zyskuje: wysłanie e-maila przez SMTP potrafi trwać dwie–trzy sekundy i restart w złym momencie oznacza zgubioną wiadomość od potencjalnego klienta.
Graceful shutdown to jeden z tych mechanizmów, których nie widać, dopóki działają. Użytkownicy nie zauważają wdrożeń, logi nie zapełniają się przerwanymi żądaniami, a zespół może deployować w środku dnia bez nerwów. Dokładnie tak powinno wyglądać dobre operacyjne rzemiosło.