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:

  1. Fecha todos os listeners, então o servidor não aceita novas conexões.
  2. Fecha as conexões em idle.
  3. Aguarda as conexões ativas ficarem em idle e depois as fecha. Se ctx expirar 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.NotifyContext retorna um context. Esse context é cancelado quando o processo recebe SIGINT (Ctrl+C) ou SIGTERM (comando enviado pelo Docker e Kubernetes).
  • ListenAndServe executa em sua própria goroutine. Depois que você chama Shutdown, ela retorna imediatamente http.ErrServerClosed. Esse é um erro normal, então filtramos ele.
  • O channel serverErr tem buffer de 1. Isso ajuda a capturar erros de inicialização, como quando a porta já está em uso. Assim, main nã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.
  • shutdownCtx usa context.Background() como base em vez de ctx. A razão é que ctx já foi cancelado, e se o usarmos, Shutdown retornará 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 ListenAndServe em main e Shutdown em outra goroutine. Como ListenAndServe retorna imediatamente quando Shutdown começa, main encerra enquanto Shutdown ainda aguarda requisições. O processo sai e as requisições ainda são interrompidas. A regra é que main deve aguardar Shutdown retornar.

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 BaseContext dessa 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 encaminhar SIGTERM para o processo Go. Use a forma exec CMD ["/app/server"] ou chame exec /app/server no 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.Listen na porta 0 deixa o sistema operacional escolher uma porta livre. Isso permite que os testes rodem em paralelo sem conflitos.
  • O channel started garante que Shutdown é chamado apenas quando a requisição está realmente dentro do handler.
  • O teste verifica três coisas: Shutdown retorna nil, 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 diretamente ctx de signal.NotifyContext faz Shutdown retornar context canceled imediatamente.
  • Tratar http.ErrServerClosed como um erro. Chamar log.Fatal com 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 envie SIGKILL.
  • Esquecer ReadHeaderTimeout. Um cliente lento pode manter a conexão ativa, e o shutdown sempre vai até o timeout.
  • Usar os.Exit ou log.Fatal após iniciar o shutdown. Ambos contornam todas as instruções defer, 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.

golanghttpgraceful-shutdownkubernetesconcurrency