Como implementar graceful shutdown em serviços backend

1. Fundamentos do Graceful Shutdown

Graceful shutdown é o processo de desligar um serviço de forma ordenada, permitindo que todas as operações em andamento sejam concluídas antes da interrupção final. Em sistemas de produção, onde milhares de requisições podem estar sendo processadas simultaneamente, um shutdown abrupto pode causar perda de dados, conexões órfãs e inconsistência de estado.

A diferença crucial está entre um kill -9 (SIGKILL), que mata o processo imediatamente sem chance de limpeza, e um kill simples (SIGTERM), que permite ao serviço executar rotinas de finalização. Sem graceful shutdown, transações bancárias podem ser perdidas, arquivos podem ficar corrompidos e conexões de banco de dados podem permanecer abertas.

2. Ciclo de Vida de um Serviço e Sinais do Sistema Operacional

Os sinais Unix mais relevantes para graceful shutdown são:

  • SIGTERM (15): Sinal padrão para solicitar desligamento gracioso
  • SIGINT (2): Gerado por Ctrl+C, comportamento similar ao SIGTERM
  • SIGHUP (1): Tradicionalmente usado para recarregar configurações

Cada linguagem oferece mecanismos para capturar esses sinais:

// Go: Captura de sinais com channel
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
# Python: Handler de sinal
import signal
def shutdown_handler(signum, frame):
    print("Iniciando shutdown gracioso...")
signal.signal(signal.SIGTERM, shutdown_handler)
// Node.js: Evento de processo
process.on('SIGTERM', () => {
  console.log('Sinal SIGTERM recebido');
  gracefulShutdown();
});

Ao receber múltiplos sinais simultaneamente, o sistema deve priorizar o tratamento ordenado, ignorando sinais duplicados até que o shutdown atual seja concluído.

3. Estrutura Básica de Implementação

A implementação de graceful shutdown requer três componentes essenciais:

  1. Signal Handler: Captura sinais do sistema operacional
  2. Contexto de Cancelamento: Propaga a intenção de parar para todas as partes do sistema
  3. Wait Group: Aguarda a conclusão de operações em andamento

Exemplo de estrutura em Go:

package main

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

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    var wg sync.WaitGroup

    // Inicializar recursos
    db := initializeDatabase()
    server := startHTTPServer(ctx, &wg)

    // Aguardar sinal de shutdown
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)

    <-sigChan
    cancel() // Propagar cancelamento

    // Aguardar conclusão com timeout
    wg.Wait()
    db.Close()
    server.Shutdown()
}

A ordem correta é: primeiro inicializar recursos, depois aguardar sinal, cancelar contexto, aguardar operações pendentes e finalmente fechar recursos na ordem inversa da inicialização.

4. Gerenciamento de Conexões de Rede

Para servidores HTTP, o shutdown gracioso envolve:

  1. Parar de aceitar novas requisições
  2. Aguardar conclusão das requisições ativas
  3. Aplicar timeout máximo (ex: 30 segundos)
# Exemplo em Go com timeout de 30 segundos
server := &http.Server{Addr: ":8080"}

// Em go routine separada
go func() {
    if err := server.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatal(err)
    }
}()

// No shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()

if err := server.Shutdown(shutdownCtx); err != nil {
    log.Printf("Shutdown forçado: %v", err)
}

Para WebSockets e streaming, é necessário notificar cada conexão ativa sobre o shutdown iminente, permitindo que os clientes reconectem a outro nó.

5. Finalização de Tarefas em Andamento

Workers que processam filas de mensagens ou realizam operações longas precisam ser interrompidos adequadamente:

// Go: Worker com contexto e wait group
func worker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan Job) {
    defer wg.Done()
    for {
        select {
        case job, ok := <-jobs:
            if !ok {
                return // Canal fechado, sem mais jobs
            }
            processJob(ctx, job)
        case <-ctx.Done():
            // Contexto cancelado, finalizar job atual e sair
            return
        }
    }
}

// No shutdown
close(jobsChan) // Não aceitar mais jobs
wg.Wait()       // Aguardar workers finalizarem

Para sistemas de mensageria como RabbitMQ ou Kafka, é essencial fazer o commit manual das mensagens processadas e rejeitar as não processadas antes de fechar a conexão.

6. Fechamento de Recursos Externos

A ordem de fechamento deve seguir a dependência inversa:

  1. Primeiro: Serviços de aplicação (workers, listeners)
  2. Segundo: Conexões de rede (HTTP, gRPC, WebSocket)
  3. Terceiro: Cache (Redis, Memcached)
  4. Quarto: Filas de mensagens (RabbitMQ, Kafka)
  5. Quinto: Banco de dados (com commit/rollback de transações pendentes)
// Python: Ordem de fechamento
async def shutdown():
    # 1. Parar workers
    await stop_workers()

    # 2. Fechar servidor HTTP
    await server.stop()

    # 3. Fechar Redis
    await redis.close()

    # 4. Fechar RabbitMQ
    await channel.close()
    await connection.close()

    # 5. Fechar banco de dados
    await db_pool.close()

Transações pendentes devem ser commitadas ou rollbacked, e locks distribuídos (como Redis locks ou ZooKeeper leases) precisam ser liberados para evitar deadlocks.

7. Health Checks e Monitoramento Durante o Shutdown

Durante o shutdown, os endpoints de health check devem refletir o estado atual:

// Go: Endpoint de health check com status de shutdown
var shuttingDown bool

func healthHandler(w http.ResponseWriter, r *http.Request) {
    if shuttingDown {
        w.WriteHeader(http.StatusServiceUnavailable)
        json.NewEncoder(w).Encode(map[string]string{
            "status": "shutting_down",
            "message": "Serviço está sendo desligado",
        })
        return
    }
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
}

Para Kubernetes, as probes de readiness devem falhar durante o shutdown, enquanto as probes de liveness podem permanecer bem-sucedidas até o desligamento completo. Logs estruturados com timestamps ajudam a rastrear o progresso:

{"level":"info","msg":"Iniciando shutdown gracioso","timestamp":"2024-01-15T10:30:00Z"}
{"level":"info","msg":"Parando workers...","timestamp":"2024-01-15T10:30:01Z"}
{"level":"info","msg":"Workers finalizados","timestamp":"2024-01-15T10:30:05Z"}
{"level":"info","msg":"Fechando conexões HTTP...","timestamp":"2024-01-15T10:30:06Z"}

8. Testes e Tratamento de Casos Extremos

Para testar graceful shutdown em ambiente de desenvolvimento:

# Envio programático de SIGTERM
kill -SIGTERM $(pgrep meu_servico)

# Em testes automatizados (Go)
func TestGracefulShutdown(t *testing.T) {
    server := startTestServer()

    // Enviar SIGTERM após 100ms
    go func() {
        time.Sleep(100 * time.Millisecond)
        syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
    }()

    // Aguardar shutdown completo
    select {
    case <-server.Done():
        // Sucesso
    case <-time.After(5 * time.Second):
        t.Error("Shutdown excedeu timeout de 5s")
    }
}

Estratégias para casos extremos:

  • Timeout forçado: Se o shutdown gracioso exceder 30 segundos, forçar shutdown com os.Exit(1)
  • Fallback: Em caso de falha no shutdown, reiniciar o processo automaticamente
  • Estado consistente: Salvar checkpoint do estado atual antes de desligar para recuperação posterior
// Go: Shutdown com fallback
shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

select {
case <-shutdownCtx.Done():
    log.Println("Timeout de shutdown atingido, forçando saída")
    os.Exit(1)
case <-done:
    log.Println("Shutdown gracioso concluído com sucesso")
}

A implementação de graceful shutdown é essencial para manter a integridade dos dados e a confiabilidade do sistema em produção. Cada componente deve ser tratado individualmente, respeitando suas dependências e garantindo que nenhum recurso seja deixado em estado inconsistente.

Referências