Em aplicações de produção, especialmente servidores web, é crucial encerrar o processo de forma controlada, permitindo que requisições em andamento sejam concluídas e recursos sejam liberados corretamente. O graceful shutdown (encerramento gracioso) é uma técnica que garante que o programa finalize sua execução de maneira ordenada ao receber sinais de término do sistema operacional, como SIGINT (Ctrl+C) ou SIGTERM.

Nesta aula, você aprenderá a implementar graceful shutdown em Go, utilizando o pacote os/signal para capturar sinais, o http.Server para encerrar servidores HTTP de forma controlada e o pacote context para coordenar o tempo limite da finalização. Ao final, você terá um conhecimento sólido para construir aplicações robustas e resilientes.

Sinais (os/signal)

O sistema operacional envia sinais para os processos para indicar eventos, como interrupção ou término. Em Go, o pacote os/signal permite que você capture esses sinais e execute ações em resposta. Os sinais mais comuns são os.Interrupt (SIGINT, enviado ao pressionar Ctrl+C) e syscall.SIGTERM (enviado por gerenciadores de processos, como o Docker ou Kubernetes).

Para capturar sinais, você usa a função signal.Notify, que registra um canal para receber os sinais especificados. O programa então fica aguardando nesse canal. É importante usar um canal com buffer de tamanho 1 para não perder sinais que chegam enquanto o programa está ocupado. Veja um exemplo básico:

package main

import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    // Canal para receber os sinais
    sigs := make(chan os.Signal, 1)

    // Registra os sinais de interesse
    signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)

    fmt.Println("Aguardando sinal...")

    // Aguarda um sinal
    sig := <-sigs

    fmt.Printf("Sinal recebido: %v\n", sig)
    fmt.Println("Encerrando aplicação...")
}

Nesse exemplo, o programa fica bloqueado no recebimento do canal até que um dos sinais seja enviado. Depois, imprime qual sinal foi recebido e termina. Em uma aplicação real, você chamaria a função de encerramento gracioso nesse ponto.

Encerrando o servidor

Para encerrar um servidor HTTP de forma graciosa, o Go fornece o método Shutdown(ctx) no tipo http.Server. Esse método bloqueia até que todas as conexões ativas sejam concluídas ou até que o contexto seja cancelado (por exemplo, após um tempo limite). Diferente do método Close(), que encerra imediatamente todas as conexões, o Shutdown permite que as requisições em andamento terminem.

O método Shutdown não fecha conexões TLS ativas, mas você pode usar Close após um tempo limite se necessário. Aqui está um exemplo de como usar o Shutdown em resposta a um sinal:

package main

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

func main() {
    srv := &http.Server{
        Addr: ":8080",
        Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            w.Write([]byte("Hello, world!"))
        }),
    }

    // Inicia o servidor em uma goroutine
    go func() {
        log.Println("Servidor iniciado na porta 8080")
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Erro ao iniciar servidor: %v", err)
        }
    }()

    // Canal para sinais
    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)

    // Aguarda sinal
    sig := <-sigs
    log.Printf("Sinal recebido: %v", sig)

    // Cria contexto com tempo limite de 10 segundos
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    // Tenta encerrar graciosamente
    if err := srv.Shutdown(ctx); err != nil {
        log.Printf("Erro no shutdown: %v", err)
        // Se o shutdown falhar, força o fechamento
        srv.Close()
    }

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

Nesse código, quando um sinal é recebido, criamos um contexto com timeout de 10 segundos. O método Shutdown bloqueia até que todas as conexões sejam concluídas ou até o timeout. Se o timeout expirar, retorna um erro e podemos chamar Close() para forçar o encerramento. Isso garante que a aplicação não fique presa indefinidamente.

context na finalização

O pacote context é fundamental no graceful shutdown, pois permite definir um prazo para a finalização. Você pode usar context.WithTimeout ou context.WithCancel para controlar o tempo máximo que o shutdown pode levar. O contexto também pode ser propagado para outras partes da aplicação, como operações de banco de dados ou chamadas externas, permitindo que elas sejam canceladas quando o shutdown é iniciado.

Por exemplo, se sua aplicação possui goroutines que processam tarefas, você pode passar o contexto para elas e verificar ctx.Done() para saber quando deve parar. Veja um exemplo com um worker:

package main

import (
    "context"
    "fmt"
    "time"
)

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Worker finalizado")
            return
        default:
            fmt.Println("Trabalhando...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}

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

    time.Sleep(2 * time.Second)
    fmt.Println("Cancelando...")
    cancel()

    // Aguarda um pouco para o worker terminar
    time.Sleep(1 * time.Second)
}

No graceful shutdown, você pode combinar o contexto do servidor com um contexto global que é cancelado quando o sinal chega. Assim, todas as operações que dependem desse contexto são interrompidas de forma coordenada. O exemplo a seguir mostra como usar um contexto base que é cancelado no sinal, e passá-lo para o Shutdown com um timeout adicional:

// Contexto global que é cancelado no sinal
ctxGlobal, cancelGlobal := context.WithCancel(context.Background())
defer cancelGlobal()

// Captura de sinal
sig := <-sigs
cancelGlobal() // Cancela todas as operações que usam ctxGlobal

// Cria um contexto com timeout para o shutdown
ctxShutdown, cancelShutdown := context.WithTimeout(ctxGlobal, 10*time.Second)
defer cancelShutdown()

srv.Shutdown(ctxShutdown)

Dessa forma, se o Shutdown demorar muito, o timeout é acionado, e o Shutdown retorna. Note que o ctxGlobal já está cancelado, então o WithTimeout herda esse cancelamento.

Exemplos

Vamos consolidar o conhecimento com um exemplo completo de um servidor que implementa graceful shutdown com logs e tempo limite. Este exemplo inclui um endpoint que simula uma operação demorada, para que você possa ver o shutdown aguardando a conclusão da requisição.

package main

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

func main() {
    // Configura o servidor
    srv := &http.Server{
        Addr: ":8080",
        Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Simula uma operação que leva 5 segundos
            time.Sleep(5 * time.Second)
            w.Write([]byte("Requisição concluída"))
        }),
    }

    // Inicia o servidor em goroutine
    go func() {
        log.Println("Servidor iniciado na porta 8080")
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Erro ao iniciar servidor: %v", err)
        }
    }()

    // Canal de sinais
    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)

    // Aguarda sinal
    sig := <-sigs
    log.Printf("Sinal recebido: %v. Iniciando shutdown...", sig)

    // Contexto com timeout de 15 segundos (maior que o tempo da requisição)
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    // Executa shutdown
    if err := srv.Shutdown(ctx); err != nil {
        log.Printf("Erro no shutdown: %v", err)
        srv.Close()
    }

    log.Println("Servidor encerrado graciosamente")
}

Nesse exemplo, se você enviar uma requisição e, durante o processamento, pressionar Ctrl+C, o servidor aguardará a conclusão da requisição (5 segundos) antes de encerrar. O timeout de 15 segundos é suficiente. Se a requisição demorasse mais que 15 segundos, o shutdown seria forçado.

Outro exemplo útil é o uso de um contexto global para cancelar operações em andamento em goroutines. Suponha que você tenha um worker que processa mensagens de uma fila. Ao receber o sinal, você cancela o contexto e o worker para de processar novas mensagens e finaliza as atuais.

package main

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

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

    // Inicia 3 workers
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go worker(ctx, i, &wg)
    }

    // Canal de sinais
    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)

    // Aguarda sinal
    sig := <-sigs
    fmt.Printf("Sinal recebido: %v. Cancelando workers...\n", sig)
    cancel() // Cancela o contexto

    // Aguarda todos os workers terminarem
    wg.Wait()
    fmt.Println("Todos os workers finalizados")
}

func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
    defer wg.Done()
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d finalizado\n", id)
            return
        default:
            fmt.Printf("Worker %d processando...\n", id)
            time.Sleep(1 * time.Second)
        }
    }
}

Nesse exemplo, ao receber o sinal, o contexto é cancelado e os workers param de processar, saindo do loop. O sync.WaitGroup garante que o programa principal espere todos terminarem antes de encerrar.

Boas práticas e observações finais

Ao implementar graceful shutdown, considere as seguintes boas práticas:

  • Defina um tempo limite adequado: O timeout deve ser maior que o tempo máximo esperado para concluir as operações em andamento. Se for muito curto, você pode forçar o encerramento de requisições importantes.
  • Use http.ErrServerClosed: Ao verificar o erro do ListenAndServe, ignore http.ErrServerClosed, pois ele é retornado quando o Shutdown é chamado.
  • Propague o contexto: Passe o contexto de shutdown para todas as operações que podem ser canceladas, como chamadas a bancos de dados ou APIs externas. Use context.WithTimeout para cada operação individual, se necessário.
  • Feche recursos: Após o shutdown, feche conexões com bancos de dados, arquivos, etc., para evitar vazamentos.
  • Teste o shutdown: Simule sinais em testes para garantir que o encerramento funciona como esperado.

O graceful shutdown é um aspecto essencial para a confiabilidade de serviços em produção. Com as técnicas apresentadas, você pode garantir que sua aplicação encerre de forma limpa, minimizando impactos para os usuários.

Referências

Exercícios

  1. Escreva um programa Go que capture o sinal SIGINT e imprima "Sinal recebido" antes de encerrar. Use o pacote os/signal.

    ✓ Resposta:
    package main
    
    import (
        "fmt"
        "os"
        "os/signal"
    )
    
    func main() {
        sigs := make(chan os.Signal, 1)
        signal.Notify(sigs, os.Interrupt)
        <-sigs
        fmt.Println("Sinal recebido")
    }
    
  2. Considerando o código do exercício 1, adicione o tratamento do sinal SIGTERM. O que você precisa modificar?

    ✓ Resposta: Basta adicionar syscall.SIGTERM na chamada de signal.Notify. Ex.:
    signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
  3. Explique a diferença entre Server.Shutdown(ctx) e Server.Close().

    ✓ Resposta: Shutdown aguarda as conexões ativas terminarem, respeitando o contexto de timeout. Close fecha imediatamente todas as conexões, sem esperar.
  4. Escreva um programa que inicia um servidor HTTP na porta 8080 e implementa graceful shutdown com timeout de 5 segundos. Ao receber SIGTERM, o servidor deve tentar encerrar graciosamente e, se o timeout expirar, forçar o fechamento.

    ✓ Resposta:
    package main
    
    import (
        "context"
        "log"
        "net/http"
        "os"
        "os/signal"
        "syscall"
        "time"
    )
    
    func main() {
        srv := &http.Server{Addr: ":8080", Handler: http.DefaultServeMux}
    
        go func() {
            if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
                log.Fatalf("Erro: %v", err)
            }
        }()
    
        sigs := make(chan os.Signal, 1)
        signal.Notify(sigs, syscall.SIGTERM)
        <-sigs
    
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
    
        if err := srv.Shutdown(ctx); err != nil {
            log.Printf("Erro no shutdown: %v", err)
            srv.Close()
        }
        log.Println("Servidor encerrado")
    }
    
  5. Como você usaria um contexto global para cancelar goroutines em um graceful shutdown? Descreva a abordagem e dê um exemplo de código.

    ✓ Resposta: Crie um contexto base com context.WithCancel. Passe esse contexto para as goroutines. Ao receber o sinal, chame a função de cancelamento. As goroutines devem verificar <-ctx.Done() em um select para sair. Exemplo:
    ctx, cancel := context.WithCancel(context.Background())
    // ... inicia goroutines com ctx
    // ao receber sinal:
    cancel()
    // aguarde as goroutines terminarem