Graceful shutdown: encerrando serviços com elegância

1. Por que o Graceful Shutdown é Essencial?

Quando um servidor Go é encerrado abruptamente — seja por um kill -9, um crash ou um deploy mal planejado — as consequências podem ser graves. Conexões abertas são cortadas no meio de uma requisição, dados em trânsito são perdidos e o estado do sistema pode ficar inconsistente.

Imagine um serviço de pagamentos: se o servidor for derrubado enquanto processa uma transação, o cliente pode ser debitado sem receber a confirmação, ou pior, o banco de dados pode ficar com um registro parcial. Bancos de dados, filas como RabbitMQ ou Kafka, e caches como Redis sofrem com encerramentos abruptos.

Para o usuário final, um downtime planejado com graceful shutdown é invisível — as requisições em andamento são concluídas e novas conexões são rejeitadas de forma educada. Já um shutdown forçado gera erros 500, timeouts e frustração.

2. Sinais do Sistema Operacional e Captura em Go

O sistema operacional envia sinais para notificar processos sobre eventos. Os mais relevantes para graceful shutdown são:

  • SIGINT (Ctrl+C): interrupção do terminal
  • SIGTERM: término solicitado (comum em Kubernetes e systemd)
  • SIGHUP: hang up (reinicialização de configuração)

Em Go, capturamos esses sinais com o pacote os/signal:

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        sig := <-sigCh
        log.Printf("Sinal recebido: %v. Iniciando shutdown...", sig)
        cancel()
    }()

    // Seu serviço aqui
    <-ctx.Done()
    log.Println("Serviço encerrado com elegância")
}

O canal sigCh recebe os sinais, e ao capturá-los, cancelamos o contexto, propagando o cancelamento para todas as goroutines que o escutam.

3. Estrutura Básica de um Shutdown Controlado

O padrão mais comum combina signal.Notify com http.Server.Shutdown(). Veja um exemplo completo:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(2 * time.Second) // Simula processamento longo
        w.Write([]byte("OK"))
    })

    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    // Goroutine para iniciar o servidor
    go func() {
        log.Println("Servidor iniciado na porta :8080")
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Erro no servidor: %v", err)
        }
    }()

    // Canal de sinais
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("Servidor está sendo desligado...")

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Erro ao desligar servidor: %v", err)
    }

    log.Println("Servidor encerrado com sucesso")
}

O laço principal com select aguarda o sinal, e Shutdown() bloqueia até que todas as requisições ativas sejam concluídas ou o timeout expire.

4. Gerenciando Múltiplos Serviços (HTTP, gRPC, Workers)

Em sistemas reais, você pode ter um servidor HTTP, um servidor gRPC e workers consumindo filas simultaneamente. O sync.WaitGroup é essencial para coordenar o shutdown:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"
)

func startHTTPServer(ctx context.Context, wg *sync.WaitGroup) {
    defer wg.Done()

    server := &http.Server{Addr: ":8080"}
    go func() {
        <-ctx.Done()
        log.Println("Desligando servidor HTTP...")
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        server.Shutdown(shutdownCtx)
    }()

    log.Println("Servidor HTTP iniciado")
    if err := server.ListenAndServe(); err != http.ErrServerClosed {
        log.Printf("Erro HTTP: %v", err)
    }
}

func startWorker(ctx context.Context, wg *sync.WaitGroup) {
    defer wg.Done()

    for {
        select {
        case <-ctx.Done():
            log.Println("Worker encerrando...")
            return
        default:
            // Processa trabalho
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    var wg sync.WaitGroup
    wg.Add(2)

    go startHTTPServer(ctx, &wg)
    go startWorker(ctx, &wg)

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("Iniciando shutdown de todos os serviços...")
    cancel() // Propaga cancelamento para todos

    wg.Wait() // Aguarda todos os serviços encerrarem
    log.Println("Todos os serviços encerrados")
}

A ordem importa: pare frontends (HTTP/gRPC) antes de backends (workers, bancos), e consumidores antes de produtores para evitar acúmulo de dados não processados.

5. Timeouts e Deadline para Evitar Shutdowns Infinitos

Um erro comum é um shutdown que nunca termina porque uma requisição ficou presa. Use context.WithTimeout para limitar a espera:

shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := server.Shutdown(shutdownCtx); err != nil {
    log.Printf("Shutdown forçado após timeout: %v", err)
    server.Close() // Fecha conexões restantes abruptamente
}

A diferença entre Shutdown() e Close() é crítica:
- Shutdown(): espera requisições ativas terminarem, depois fecha
- Close(): fecha imediatamente, abandonando requisições

Configure ReadTimeout e WriteTimeout no servidor para evitar requisições infinitas:

server := &http.Server{
    Addr:         ":8080",
    ReadTimeout:  10 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  60 * time.Second,
}

6. Graceful Shutdown em Workers e Goroutines Assíncronas

Workers que processam filas precisam drenar seu trabalho antes de encerrar:

type WorkerPool struct {
    jobs    chan Job
    done    chan struct{}
    wg      sync.WaitGroup
}

func (wp *WorkerPool) Start(numWorkers int) {
    for i := 0; i < numWorkers; i++ {
        wp.wg.Add(1)
        go wp.worker()
    }
}

func (wp *WorkerPool) worker() {
    defer wp.wg.Done()
    for {
        select {
        case job, ok := <-wp.jobs:
            if !ok {
                return // Canal fechado, encerra worker
            }
            job.Process()
        case <-wp.done:
            return // Sinal de shutdown
        }
    }
}

func (wp *WorkerPool) Shutdown() {
    close(wp.jobs)   // Para de aceitar novos jobs
    close(wp.done)   // Notifica workers
    wp.wg.Wait()     // Aguarda workers terminarem
}

Use sync.Cond ou variáveis atômicas para notificações mais complexas, como drenagem parcial antes do shutdown completo.

7. Testando o Graceful Shutdown

Testes unitários podem simular sinais:

func TestGracefulShutdown(t *testing.T) {
    // Configura servidor em background
    server := &http.Server{Addr: ":0"}
    // ... configura handlers

    go server.ListenAndServe()

    // Simula SIGTERM
    syscall.Kill(syscall.Getpid(), syscall.SIGTERM)

    // Aguarda shutdown
    time.Sleep(100 * time.Millisecond)

    // Verifica que servidor fechou
    if err := server.ListenAndServe(); err != http.ErrServerClosed {
        t.Errorf("Esperava servidor fechado, got: %v", err)
    }
}

Para testes de integração, use ferramentas como timeout do sistema e scripts de stress (ex: hey ou wrk) para enviar requisições enquanto o servidor é encerrado, verificando se todas foram completadas.

8. Boas Práticas e Armadilhas Comuns

  • Múltiplas chamadas a signal.Notify: cada chamada adiciona um ouvinte. Use signal.Reset se precisar reconfigurar.
  • Logging detalhado: registre cada etapa do shutdown (início, serviços encerrados, conclusão) para depuração.
  • recover em goroutines: garanta que um panic não interrompa o shutdown:
go func() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Recovered de panic: %v", r)
        }
    }()
    // Código do worker
}()
  • Ordem de shutdown: pare serviços que recebem requisições primeiro, depois os que processam dados.
  • Timeout global: sempre tenha um timeout máximo para todo o shutdown (ex: 30 segundos) para evitar bloqueios eternos.

Referências