Pular para o conteúdo principal

victorstein.dev

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:

  1. Parar de aceitar novas requisições;
  2. Terminar as que já estão em andamento;
  3. Fechar os recursos (banco de dados, filas, arquivos);
  4. 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 ctx passado 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.

# Referências

  1. Documentação http.Server.Shutdown
  2. Documentação signal.NotifyContext
  3. Docker - Shell and exec form
  4. Kubernetes - Termination of Pods