Graceful Shutdown em Go É Mais Difícil Do Que Você Pensa
"Só chame server.Shutdown()" é um conselho que funciona bem até você ter um consumidor Kafka no meio de um batch, uma transação de banco em progresso, e Kubernetes enviando SIGTERM enquanto seu readiness probe ainda retorna healthy.
Graceful shutdown parece simples. Não é. Aqui está tudo que pode dar errado e como lidar.
A Abordagem Ingênua
A maioria dos tutoriais mostra algo assim:
func main() {
server := &http.Server{Addr: ":8080", Handler: handler}
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
// Espera interrupção
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// Shutdown com timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(ctx)
}
Isso lida com o servidor HTTP. Mas serviços reais têm mais:
- Workers em background
- Consumidores Kafka/RabbitMQ
- Pools de conexão de banco
- Locks distribuídos
- Conexões de cache
- Reporters de métricas
O Problema de Timing do Kubernetes
Quando Kubernetes envia SIGTERM, várias coisas acontecem simultaneamente:
- Seu pod recebe SIGTERM
- Kubernetes remove o pod dos endpoints do Service
- Ingress controllers atualizam seus backends
- Caches de DNS de outros pods podem ainda apontar para você
O problema: passos 2-4 levam tempo. Se você desliga imediatamente no SIGTERM, requisições ainda sendo roteadas para você vão falhar.
func main() {
// ... setup ...
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// ERRADO: Shutdown imediato
// Requisições em voo de outros pods vão dar 502
// CERTO: Espere o tráfego drenar
log.Println("Sinal de shutdown recebido, esperando tráfego drenar...")
// Dê tempo ao Kubernetes para atualizar endpoints
time.Sleep(5 * time.Second)
// Agora inicie o graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
server.Shutdown(ctx)
}
A Dança do Readiness Probe
Seu readiness probe deve falhar ANTES de você parar de aceitar requisições:
type Server struct {
httpServer *http.Server
isReady atomic.Bool
}
func (s *Server) readinessHandler(w http.ResponseWriter, r *http.Request) {
if !s.isReady.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) Shutdown(ctx context.Context) error {
// Passo 1: Marca como não pronto (falha readiness probe)
s.isReady.Store(false)
log.Println("Marcado como não pronto")
// Passo 2: Espera Kubernetes notar e parar de enviar tráfego
time.Sleep(5 * time.Second)
log.Println("Período de drenagem completo")
// Passo 3: Agora desliga o servidor HTTP
return s.httpServer.Shutdown(ctx)
}
Coordenando Múltiplos Componentes
Serviços reais têm várias coisas para desligar, e a ordem importa.
O Padrão Orquestrador de Shutdown
type ShutdownManager struct {
components []ShutdownComponent
timeout time.Duration
}
type ShutdownComponent interface {
Name() string
Shutdown(ctx context.Context) error
Priority() int // Menor = desliga primeiro
}
func (m *ShutdownManager) Shutdown(ctx context.Context) error {
// Ordena por prioridade (usando pacote slices do Go 1.21+)
slices.SortFunc(m.components, func(a, b ShutdownComponent) int {
return cmp.Compare(a.Priority(), b.Priority())
})
ctx, cancel := context.WithTimeout(ctx, m.timeout)
defer cancel()
var errs []error
for _, c := range m.components {
log.Printf("Desligando %s...", c.Name())
start := time.Now()
if err := c.Shutdown(ctx); err != nil {
log.Printf("Erro desligando %s: %v", c.Name(), err)
errs = append(errs, fmt.Errorf("%s: %w", c.Name(), err))
} else {
log.Printf("Shutdown de %s completo (%v)", c.Name(), time.Since(start))
}
}
return errors.Join(errs...)
}
Ordem de Shutdown Importa
func main() {
manager := &ShutdownManager{
timeout: 30 * time.Second,
components: []ShutdownComponent{
// Prioridade 1: Para de aceitar novo trabalho
&ReadinessComponent{ready: &isReady},
// Prioridade 2: Período de drenagem
&DrainComponent{duration: 5 * time.Second},
// Prioridade 3: Para servidor HTTP (espera em voo)
&HTTPServerComponent{server: httpServer},
// Prioridade 4: Para workers em background
&WorkerPoolComponent{pool: workers},
// Prioridade 5: Para consumidores de mensagens
&KafkaConsumerComponent{consumer: kafkaConsumer},
// Prioridade 6: Flush de operações assíncronas
&MetricsFlushComponent{reporter: metrics},
// Prioridade 7: Fecha conexões (por último!)
&DatabaseComponent{pool: dbPool},
&RedisComponent{client: redis},
},
}
// ... signal handling ...
manager.Shutdown(context.Background())
}
Lidando com Consumidores Kafka
Consumidores Kafka são complicados porque você pode estar no meio de um batch quando o shutdown começa.
type KafkaConsumerComponent struct {
consumer *kafka.Consumer
handler MessageHandler
wg sync.WaitGroup
shutdown chan struct{}
batchSize int
}
func (c *KafkaConsumerComponent) Run(ctx context.Context) {
c.shutdown = make(chan struct{})
for {
select {
case <-c.shutdown:
return
case <-ctx.Done():
return
default:
// Busca batch
messages, err := c.consumer.FetchBatch(ctx, c.batchSize)
if err != nil {
continue
}
// Processa batch com rastreamento
c.wg.Add(1)
go func() {
defer c.wg.Done()
for _, msg := range messages {
select {
case <-c.shutdown:
// Shutdown requisitado no meio do batch
// Não commita, deixe rebalance lidar
return
default:
if err := c.handler.Handle(msg); err != nil {
// Trata erro...
}
}
}
// Só commita se processou o batch inteiro
c.consumer.Commit(messages)
}()
}
}
}
func (c *KafkaConsumerComponent) Shutdown(ctx context.Context) error {
// Sinaliza consumidor para parar
close(c.shutdown)
// Espera batches em voo com timeout
done := make(chan struct{})
go func() {
c.wg.Wait()
close(done)
}()
select {
case <-done:
log.Println("Todos os batches Kafka completados")
case <-ctx.Done():
log.Println("Timeout esperando batches Kafka")
}
return c.consumer.Close()
}
Transações de Banco em Progresso
Transações longas precisam de tratamento especial:
type TransactionManager struct {
db *sql.DB
activeTxns sync.Map // map[string]*ManagedTx
shutdown atomic.Bool
}
type ManagedTx struct {
tx *sql.Tx
id string
started time.Time
doneChan chan struct{}
}
func (m *TransactionManager) Begin(ctx context.Context) (*ManagedTx, error) {
if m.shutdown.Load() {
return nil, errors.New("shutdown em progresso, rejeitando novas transações")
}
tx, err := m.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
mtx := &ManagedTx{
tx: tx,
id: uuid.New().String(),
started: time.Now(),
doneChan: make(chan struct{}),
}
m.activeTxns.Store(mtx.id, mtx)
return mtx, nil
}
func (m *TransactionManager) Shutdown(ctx context.Context) error {
m.shutdown.Store(true)
log.Println("Rejeitando novas transações")
// Espera transações ativas
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// Timeout - loga transações restantes
m.activeTxns.Range(func(key, value any) bool {
mtx := value.(*ManagedTx)
log.Printf("Transação %s ainda ativa após %v", mtx.id, time.Since(mtx.started))
return true
})
return ctx.Err()
case <-ticker.C:
count := 0
m.activeTxns.Range(func(_, _ any) bool {
count++
return true
})
if count == 0 {
log.Println("Todas as transações completadas")
return m.db.Close()
}
log.Printf("Esperando %d transações ativas", count)
}
}
}
O Quadro Completo
func main() {
// Setup dos componentes...
// Orquestração de shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Inicia serviços
go httpServer.ListenAndServe()
go kafkaConsumer.Run(ctx)
go workers.Start()
// Espera sinal
sig := <-quit
log.Printf("Recebido %v, iniciando graceful shutdown", sig)
// Cria contexto de shutdown com budget total
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Executa sequência de shutdown
if err := shutdownManager.Shutdown(ctx); err != nil {
log.Printf("Shutdown completado com erros: %v", err)
os.Exit(1)
}
log.Println("Shutdown completo")
}
Pontos-Chave
-
Shutdown de HTTP sozinho não é suficiente. Você precisa coordenar todos os componentes.
-
Kubernetes precisa de tempo. Adicione um período de drenagem antes de parar o servidor HTTP.
-
Ordem importa. Pare de aceitar trabalho → drene em voo → feche conexões.
-
Rastreie trabalho em voo. Use WaitGroups ou similar para saber quando é seguro fechar recursos.
-
Defina deadlines. Use timeouts de context para não travar infinitamente.
-
Logue o shutdown. Quando coisas derem errado, você vai querer saber o que estava acontecendo.
-
Teste. Envie SIGTERM para seu serviço e verifique o comportamento.
Graceful shutdown é uma daquelas coisas que parecem simples até você realmente precisar que funcione de forma confiável. Acerte, e seus deploys se tornam invisíveis para os usuários.