90/100 Dias de Golang - Graceful Shutdown
Table of Contents
#
Graceful Shutdown
No post anterior vimos context e no post do bot do discord usamos um channel para capturar o Ctrl+C. Hoje vamos juntar as duas coisas para resolver um problema que todo servidor em produção tem: como desligar a aplicação sem derrubar quem está usando ela?
Toda vez que fazemos um deploy, a versão antiga da aplicação precisa ser desligada. O Docker, o Kubernetes ou o systemd fazem isso enviando um sinal SIGTERM para o processo e, se ele não terminar depois de um tempo (no Docker são 10 segundos, no Kubernetes 30), mandam um SIGKILL, que mata o processo na hora.
Se a aplicação morre assim que recebe o SIGTERM, as requisições que estavam em andamento são cortadas no meio. O usuário recebe um erro, um pagamento pode ficar pela metade, um arquivo pode ficar corrompido… O graceful shutdown (desligamento gracioso) é fazer a aplicação:
- Parar de aceitar novas requisições;
- Terminar as que já estão em andamento;
- Fechar os recursos (banco de dados, filas, arquivos);
- E só então sair.
#
O problema
Vamos começar com um servidor simples, com um endpoint que demora 5 segundos para responder:
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func lentoHandler(w http.ResponseWriter, r *http.Request) {
log.Println("processando request...")
time.Sleep(5 * time.Second)
fmt.Fprintln(w, "pronto!")
log.Println("request finalizada")
}
func main() {
http.HandleFunc("/lento", lentoHandler)
log.Println("servidor rodando em :8090")
log.Fatal(http.ListenAndServe(":8090", nil))
}
Rode o servidor, faça uma requisição em outro terminal e, antes dela terminar, aperte Ctrl+C no servidor:
curl localhost:8090/lento
O log do servidor para no meio:
2025/12/13 09:54:58 servidor rodando em :8090
2025/12/13 09:54:59 processando request...
E o curl recebe:
curl: (52) Empty reply from server
A mensagem request finalizada nunca apareceu. O processo morreu com a requisição pela metade.
#
signal.NotifyContext
No bot do discord capturamos os sinais assim:
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
<-stop
Funciona, mas desde o Go 1.16 existe uma forma que conversa melhor com o resto do código, o signal.NotifyContext. Ele retorna um context que é cancelado quando o sinal chega:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done() // bloqueia até receber Ctrl+C ou SIGTERM
A vantagem é que esse ctx pode ser passado para qualquer função ou goroutine, e todas ficam sabendo que a aplicação está desligando, usando exatamente o que vimos no post de context.
A função stop desfaz o registro dos sinais. Vamos ver já já por que isso é útil.
#
http.Server.Shutdown
O http.ListenAndServe que usamos até agora não dá nenhum controle sobre o servidor. Para isso precisamos criar um http.Server explicitamente, que tem o método Shutdown:
func (srv *Server) Shutdown(ctx context.Context) error
Quando chamado, o Shutdown:
- Fecha os listeners, então novas conexões são recusadas;
- Fecha as conexões ociosas;
- Espera as requisições em andamento terminarem;
- Se o
ctxpassado for cancelado antes disso, desiste e retorna o erro do context.
Juntando tudo:
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func lentoHandler(w http.ResponseWriter, r *http.Request) {
log.Println("processando request...")
time.Sleep(5 * time.Second)
fmt.Fprintln(w, "pronto!")
log.Println("request finalizada")
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
mux := http.NewServeMux()
mux.HandleFunc("/lento", lentoHandler)
srv := &http.Server{
Addr: ":8090",
Handler: mux,
}
go func() {
log.Println("servidor rodando em :8090")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("erro no servidor: %v", err)
}
}()
<-ctx.Done()
stop()
log.Println("sinal recebido, desligando o servidor...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("erro no shutdown: %v", err)
}
log.Println("servidor desligado com sucesso")
}
Vamos por partes:
O servidor roda numa goroutine. O ListenAndServe bloqueia, então se ele ficasse no main não conseguiríamos esperar pelo sinal. Assim, o main fica livre para ficar parado no <-ctx.Done().
O http.ErrServerClosed. Quando o Shutdown é chamado, o ListenAndServe retorna imediatamente com o erro http.ErrServerClosed. Esse erro é esperado, não é uma falha, por isso ignoramos ele com o errors.Is (lembra do dia 35 sobre error handling?).
O stop() logo depois do sinal. Depois de chamar o stop(), os sinais voltam ao comportamento padrão. Então se o shutdown travar e você apertar Ctrl+C de novo, o processo morre na hora. Sem isso, o segundo Ctrl+C seria ignorado e você teria que esperar o timeout.
O timeout do shutdown. Não dá para esperar para sempre: se uma requisição travar, a aplicação nunca desligaria. Usamos um context.WithTimeout de 10 segundos. Esse valor deve ser menor que o tempo que o orquestrador espera antes do SIGKILL. Repare que ele é criado a partir de um context.Background() novo, e não do ctx dos sinais, porque esse já está cancelado.
Repetindo o teste, agora com Ctrl+C no meio da requisição:
2025/12/13 09:55:00 servidor rodando em :8090
2025/12/13 09:55:01 processando request...
2025/12/13 09:55:02 sinal recebido, desligando o servidor...
2025/12/13 09:55:06 request finalizada
2025/12/13 09:55:07 servidor desligado com sucesso
O sinal chegou às 09:55:02, mas o servidor esperou a requisição terminar às 09:55:06 antes de sair. O curl recebeu o pronto! normalmente, e uma nova requisição feita depois do sinal recebe Connection refused, porque o servidor já não aceita conexões.
#
Goroutines em background
Uma aplicação real normalmente não tem só o servidor HTTP. Pode ter um worker processando uma fila, um job que roda de tempos em tempos, uma conexão com o banco… Tudo isso também precisa ser desligado com calma.
Como o signal.NotifyContext nos dá um context, basta passar ele para o worker e usar um sync.WaitGroup (dia 18) para esperar ele terminar:
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
func lentoHandler(w http.ResponseWriter, r *http.Request) {
log.Println("processando request...")
time.Sleep(5 * time.Second)
fmt.Fprintln(w, "pronto!")
log.Println("request finalizada")
}
func worker(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("worker: terminando o job atual e saindo")
return
case <-ticker.C:
log.Println("worker: processando fila...")
}
}
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
var wg sync.WaitGroup
wg.Add(1)
go worker(ctx, &wg)
mux := http.NewServeMux()
mux.HandleFunc("/lento", lentoHandler)
srv := &http.Server{
Addr: ":8090",
Handler: mux,
}
go func() {
log.Println("servidor rodando em :8090")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("erro no servidor: %v", err)
}
}()
<-ctx.Done()
stop()
log.Println("sinal recebido, iniciando o graceful shutdown")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 1. para de aceitar requests e espera as que estão em andamento
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("erro no shutdown do servidor: %v", err)
}
// 2. espera os workers terminarem
wg.Wait()
// 3. fecha os recursos (banco, filas, arquivos...)
log.Println("fechando conexão com o banco")
log.Println("aplicação encerrada")
}
Saída com uma requisição em andamento:
2025/12/13 09:55:07 servidor rodando em :8090
2025/12/13 09:55:08 processando request...
2025/12/13 09:55:09 worker: processando fila...
2025/12/13 09:55:09 sinal recebido, iniciando o graceful shutdown
2025/12/13 09:55:09 worker: terminando o job atual e saindo
2025/12/13 09:55:13 request finalizada
2025/12/13 09:55:13 fechando conexão com o banco
2025/12/13 09:55:13 aplicação encerrada
A ordem importa: primeiro paramos de receber trabalho novo, depois esperamos o trabalho em andamento e, por último, fechamos os recursos. Se fechássemos o banco antes, a requisição que ainda estava rodando ia falhar ao tentar usar ele.
Aqui o wg.Wait() não tem timeout. Se o worker puder demorar muito, vale colocar o wg.Wait() numa goroutine e fazer um select entre ele e o shutdownCtx.Done(), igual fizemos no exemplo de WithTimeout do post anterior.
#
E no Gin?
O gin.Engine implementa a interface http.Handler, então é só passar ele como Handler do http.Server em vez de usar o r.Run():
r := gin.Default()
r.GET("/lento", func(c *gin.Context) {
time.Sleep(5 * time.Second)
c.String(http.StatusOK, "pronto!")
})
srv := &http.Server{
Addr: ":8090",
Handler: r,
}
O resto do código é exatamente igual.
#
Cuidado com o Docker
No dia 75 criamos um Dockerfile com:
CMD ["./api"]
Essa forma, com colchetes (chamada de exec form), faz o binário ser o processo principal do container, e ele recebe o SIGTERM direto do Docker. Já a forma sem colchetes:
CMD ./api
roda o binário dentro de um /bin/sh -c. Quem recebe o sinal é o shell, que não repassa para a aplicação. Resultado: o graceful shutdown nunca acontece, o Docker espera os 10 segundos e mata tudo com SIGKILL. Então, sempre use a forma com colchetes.