Graceful shutdown para servidor HTTP Go
1. Visão geral
Quando você faz deploy de uma nova versão ou reduz a escala, o seu processo recebe SIGTERM. Se o servidor sair imediatamente, as requisições sendo processadas são interrompidas no meio. O cliente recebe um erro connection reset, e os pedidos podem ser registrados apenas parcialmente.
Este artigo o guia para desligar um servidor HTTP Go da forma correta. O servidor parará de aceitar novas conexões, aguardará as requisições em execução finalizarem dentro de um tempo limite, e depois fechará os recursos na ordem correta. Todos os exemplos usam apenas a biblioteca padrão e requerem Go 1.22 ou superior.
2. O problema ao desligar o servidor abruptamente
Vamos começar com a forma mais comum de escrever:
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) // simula uma consulta lenta ao DB
fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Execute o servidor e chame curl localhost:8080/orders/42. Enquanto a requisição está esperando, você pressiona Ctrl+C no terminal do servidor. O resultado no cliente:
curl: (52) Empty reply from server
O Go não instala automaticamente um handler para SIGINT ou SIGTERM. Quando recebe o sinal, o runtime encerra o processo imediatamente. Nenhuma instrução defer é executada e nenhuma requisição é aguardada.
3. Graceful shutdown com http.Server.Shutdown
http.Server tem um método Shutdown(ctx). Esse método faz três coisas em ordem:
- Fecha todos os listeners, então o servidor não aceita novas conexões.
- Fecha as conexões em idle.
- Aguarda as conexões ativas ficarem em idle e depois as fecha. Se
ctxexpirar antes, o método retorna o erro do context.
Para usar esse método, você precisa capturar o sinal do sistema operacional por conta própria.
3.1. Capturando sinais com 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() // receber Ctrl+C novamente vai encerrar o processo imediatamente
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")
}
Pontos-chave no código:
signal.NotifyContextretorna um context. Esse context é cancelado quando o processo recebeSIGINT(Ctrl+C) ouSIGTERM(comando enviado pelo Docker e Kubernetes).ListenAndServeexecuta em sua própria goroutine. Depois que você chamaShutdown, ela retorna imediatamentehttp.ErrServerClosed. Esse é um erro normal, então filtramos ele.- O channel
serverErrtem buffer de 1. Isso ajuda a capturar erros de inicialização, como quando a porta já está em uso. Assim,mainnão fica preso aguardando um sinal que nunca chegará. stop()é chamado assim que o sinal é recebido. Essa função restaura o comportamento padrão, então se você pressionar Ctrl+C novamente, o processo sairá imediatamente. Isso é muito útil quando o shutdown fica travado.shutdownCtxusacontext.Background()como base em vez dectx. A razão é quectxjá foi cancelado, e se o usarmos,Shutdownretornará um erro imediatamente.- Se após 10 segundos ainda houver requisições,
srv.Close()força o encerramento de todas as conexões.
Repetindo o teste da seção 2: chame curl, depois pressione Ctrl+C enquanto a requisição está esperando. O log do servidor:
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
O cliente recebe a resposta completa:
order 42
O servidor aguarda os cerca de 2 segundos restantes da requisição antes de sair.
Nota: Um erro muito comum é inverter os papéis das duas goroutines. Algumas pessoas chamam
ListenAndServeemmaineShutdownem outra goroutine. ComoListenAndServeretorna imediatamente quandoShutdowncomeça,mainencerra enquantoShutdownainda aguarda requisições. O processo sai e as requisições ainda são interrompidas. A regra é quemaindeve aguardarShutdownretornar.
4. O que Shutdown não trata automaticamente
4.1. Conexões sequestradas: WebSocket, HTTP/1.1 upgrade
Shutdown não rastreia conexões que foram sequestradas, como WebSocket. O servidor também não as aguarda. Para fechar essas conexões, você registra um callback usando 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()
}
}
// em main:
hub := &Hub{conns: make(map[*websocketConn]struct{})}
srv.RegisterOnShutdown(hub.CloseAll)
websocketConn aqui representa o tipo de conexão da biblioteca WebSocket que você usa. O callback é executado em sua própria goroutine assim que Shutdown começa. Shutdown não aguarda esse callback terminar, portanto, se você precisa aguardar, sincronize você mesmo usando sync.WaitGroup.
4.2. Handlers de longa duração: streaming, long polling
Um handler que faz streaming infinito de dados, como Server-Sent Events, nunca ficará em idle. Nesses casos, Shutdown sempre aguardará até expirar o timeout. Você pode usar BaseContext para fazer com que todo request context seja cancelado quando o servidor começar a desligar:
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 // servidor está desligando ou cliente desconectou
case t := <-ticker.C:
fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
w.(http.Flusher).Flush()
}
}
})
Cada request context é criado a partir de appCtx. Quando Shutdown é executado, cancelApp é chamado e todo r.Context() é cancelado. O handler de streaming vê Done() e retorna. Depois disso, Shutdown completa mais cedo.
Nota: Você não deve aplicar esse padrão a todos os handlers. Se você cancelar o context de uma requisição que está escrevendo um pedido no BD, o comando de escrita em si será cancelado. O objetivo de “aguardar a requisição terminar” não será mais alcançado. Use
BaseContextdessa forma apenas quando todos os handlers puderem parar no meio de forma segura.
4.3. Goroutines em background e recursos secundários
Shutdown gerencia apenas conexões HTTP. Workers que enviam emails, consumers Kafka e pools de conexão DB são sua responsabilidade. A ordem de fechamento deve ser inversa à de inicialização:
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("http shutdown: %v", err)
}
cancelWorkers() // notificar goroutine em background para parar
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)
}
O servidor HTTP é desligado primeiro porque os handlers ainda precisam do BD. O BD é fechado por último. Todo o processo usa o mesmo shutdownCtx, então o tempo total não ultrapassa o orçamento de 10 segundos.
5. Executando em Kubernetes
Quando um pod é deletado, o Kubernetes faz duas coisas em paralelo: envia SIGTERM para o container e remove o pod da lista de endpoints do Service. Remover os endpoints leva alguns segundos para se propagar ao kube-proxy e ingress. Se o servidor fechar o listener imediatamente ao receber SIGTERM, requisições ainda sendo roteadas para o pod serão rejeitadas.
A solução é fazer o readiness probe reportar um erro primeiro, aguardar um pouco e depois chamar Shutdown:
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)
})
// depois de receber o sinal:
<-ctx.Done()
shuttingDown.Store(true)
time.Sleep(5 * time.Second) // aguarde o load balancer remover o pod
srv.Shutdown(shutdownCtx)
Você também precisa configurar terminationGracePeriodSeconds maior que a soma do tempo de espera mais o timeout de shutdown. Para este exemplo, é 5 + 10 = 15 segundos:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: order-service
image: order-service:1.4.0
readinessProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 2
Quando o período de grace expira, o Kubernetes envia SIGKILL. Esse sinal não pode ser capturado, e toda sua lógica de shutdown será ignorada.
Nota: Se você executar o binário através de um script de shell, por exemplo
CMD ./start.sh, o shell é PID 1 e pode não encaminharSIGTERMpara o processo Go. Use a forma execCMD ["/app/server"]ou chameexec /app/serverno final do script.
6. Testando o comportamento de shutdown
Você pode escrever um teste para confirmar que Shutdown aguarda requisições em execução serem concluídas. Este teste precisa apenas do 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 // assegure que a requisição está realmente no handler
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")
}
}
Pontos-chave no teste:
net.Listenna porta0deixa o sistema operacional escolher uma porta livre. Isso permite que os testes rodem em paralelo sem conflitos.- O channel
startedgarante queShutdowné chamado apenas quando a requisição está realmente dentro do handler. - O teste verifica três coisas:
Shutdownretornanil, o cliente recebe o corpo completo, e novas conexões são rejeitadas.
go test -v -run TestShutdown
=== RUN TestShutdownWaitsForInFlightRequest
--- PASS: TestShutdownWaitsForInFlightRequest (0.50s)
PASS
O tempo de execução é de cerca de 0,5 segundos, exatamente o tempo que o handler dorme. Isso mostra que Shutdown aguardou a requisição.
7. Erros comuns
- Usar um context já cancelado para
Shutdown. Passar diretamentectxdesignal.NotifyContextfazShutdownretornarcontext canceledimediatamente. - Tratar
http.ErrServerClosedcomo um erro. Chamarlog.Fatalcom esse erro sairá do processo antes de o shutdown terminar. - Não definir timeout. Usar
context.Background()sem deadline significa que um handler travado manterá o processo indefinidamente, até que o Kubernetes envieSIGKILL. - Esquecer
ReadHeaderTimeout. Um cliente lento pode manter a conexão ativa, e o shutdown sempre vai até o timeout. - Usar
os.Exitoulog.Fatalapós iniciar o shutdown. Ambos contornam todas as instruçõesdefer, então BD e arquivos não serão fechados.
8. Conclusão
Graceful shutdown em Go requer três componentes: signal.NotifyContext para capturar SIGTERM, srv.Shutdown com um context com timeout, e main deve aguardar Shutdown retornar. A partir dessa base, você adiciona as partes que Shutdown não trata automaticamente. Esses são RegisterOnShutdown para WebSocket, BaseContext para handlers de streaming, e fechar workers e BD na ordem inversa da inicialização. Ao executar em Kubernetes, faça o readiness probe reportar um erro e aguarde alguns segundos antes de fechar o listener, além de definir terminationGracePeriodSeconds grande o suficiente. Por fim, mantenha um teste como o da seção 6 para detectar cedo se alguém acidentalmente quebrar o fluxo de shutdown.