3 pontos por GN⁺ 2025-05-06 | 1 comentários | Compartilhar no WhatsApp
  • O Graceful Shutdown em aplicações Go é o procedimento de encerramento que bloqueia novas requisições, espera os trabalhos em andamento terminarem e depois libera recursos como conexões de banco de dados, locks de arquivo e listeners de rede
  • O tratamento de encerramento começa ao receber sinais de término como SIGTERM e SIGINT com os/signal ou signal.NotifyContext, disponível a partir do Go 1.16, substituindo o comportamento padrão de encerramento imediato
  • No Kubernetes, o encerramento precisa ser concluído dentro do grace period de 30 segundos por padrão, e é preciso garantir tempo para que a interrupção de tráfego seja propagada até balanceadores de carga externos por meio de atraso no preStop ou falha no readiness probe
  • http.Server.Shutdown bloqueia novas conexões e espera as requisições ativas terminarem, mas se o handler não respeitar context cancellation, podem surgir problemas como escrita parcial, perda de dados e transações abertas
  • Recursos importantes não devem ser liberados logo após o sinal de término, mas sim depois que as requisições acabarem ou o tempo limite expirar; encerrar em ordem inversa à inicialização ajuda a preservar as dependências entre componentes

Condições mínimas para Graceful Shutdown

  • Em geral, o Graceful Shutdown precisa atender a três condições
    • Não aceitar mais novas requisições ou mensagens em pontos de entrada como HTTP e pub/sub
    • Esperar as requisições já em andamento terminarem e, se demorarem demais, responder com um graceful error
    • Liberar recursos importantes como conexões de banco de dados, locks de arquivo e listeners de rede, além de executar a limpeza final
  • Conexões de saída para banco de dados ou cache não devem ser encerradas imediatamente na etapa de bloqueio de novas requisições
  • O foco está em servidores HTTP e aplicações em contêiner, mas os princípios centrais também se aplicam a outros tipos de aplicação

Tratamento de sinais de término

  • Em sistemas da família Unix, um sinal é uma interrupção de software que informa ao processo que uma determinada situação ocorreu
  • O processo pode registrar handlers para sinais específicos e, se não houver handler, segue o comportamento padrão
    • O comportamento padrão pode ser encerrar, pausar, continuar executando ou ignorar
    • Alguns sinais, como SIGKILL, não podem ser capturados nem ignorados e encerram o processo
  • O runtime do Go registra automaticamente vários signal handlers antes mesmo da execução da função main, incluindo SIGTERM, SIGQUIT, SIGILL e SIGTRAP
  • No Graceful Shutdown, há três sinais de término especialmente importantes
    • SIGTERM: forma padrão e educada de solicitar o encerramento de um processo; é o sinal enviado pelo Kubernetes antes do encerramento forçado da aplicação
    • SIGINT: enviado quando o usuário tenta interromper o processo com Ctrl+C no terminal
    • SIGHUP: originalmente usado para desconexão do terminal, hoje também é frequentemente usado para recarregar configuração
  • Sem tratamento específico, ao receber SIGTERM, SIGINT ou SIGHUP, o runtime do Go encerra a aplicação

os/signal e NotifyContext

  • signal.Notify instrui o runtime do Go a entregar sinais específicos em um canal, em vez de executar o comportamento padrão
  • É mais seguro criar o canal de sinais com buffer de tamanho 1
    • Internamente, o Go usa select e default ao enviar sinais ao canal
    • Se houver espaço no buffer, o sinal é entregue; se o buffer estiver cheio, o sinal é descartado
    • Em um canal sem buffer, o sinal pode se perder se não houver uma goroutine recebendo no momento
  • signal.Notify pode ser chamado várias vezes para o mesmo sinal, e o Go enviará esse sinal para todos os canais registrados
  • Mesmo pressionando Ctrl+C várias vezes, normalmente a segunda entrada não é automaticamente promovida a SIGKILL
    • A maioria dos shells bash ou Linux não faz essa promoção automática
    • Para forçar o encerramento, é preciso enviar SIGKILL explicitamente com kill -9
  • Para que, no desenvolvimento local, o segundo Ctrl+C force o encerramento, é possível interromper o recebimento de sinais adicionais com signal.Stop logo após receber o primeiro sinal
  • A partir do Go 1.16, signal.NotifyContext permite vincular o tratamento de sinais a context cancellation
    • Mesmo depois de ctx.Done(), ainda é preciso chamar stop() para que um segundo Ctrl+C possa forçar o encerramento da aplicação

Tempo limite de encerramento e comportamento do Kubernetes

  • Depois de receber o sinal de término, o primeiro passo é entender quanto tempo real a aplicação tem para encerrar
  • No Kubernetes, o grace period padrão é de 30 segundos se terminationGracePeriodSeconds não for definido
  • Quando esse tempo expira, o Kubernetes envia SIGKILL e interrompe a aplicação à força
    • SIGKILL não pode ser capturado nem tratado
  • Toda a lógica de encerramento, incluindo o tratamento das requisições restantes e a liberação de recursos, precisa terminar dentro desse prazo
  • Considerando os 30 segundos padrão, é recomendável reservar cerca de 20% como margem de segurança e concluir todo o encerramento em até 25 segundos

Bloqueio de novas requisições e tratamento de readiness

  • Em net/http do Go, é possível realizar Graceful Shutdown com http.Server.Shutdown
    • Ele interrompe a aceitação de novas conexões
    • Espera as requisições ativas terminarem
    • Depois fecha as idle connections
  • Requisições já em andamento podem terminar normalmente e, após isso, a conexão entra em estado idle e é fechada
  • Clientes que tentarem abrir novas conexões durante o encerramento normalmente receberão erro connection refused, porque o listener já estará fechado
  • Em ambientes orquestrados com contêineres ou com balanceadores de carga externos, é importante não interromper imediatamente a aceitação de novas requisições
    • Mesmo depois que o pod é marcado para encerramento, ele ainda pode receber tráfego por um curto período
    • O kube-proxy, componente interno do Kubernetes, percebe rapidamente a mudança do estado do pod para Terminating
    • Balanceadores de carga externos usam seus próprios health checks, independentes do Kubernetes, então a propagação de estado leva tempo
  • Há duas formas de esperar a propagação do bloqueio de tráfego
    • Fazer um sleep breve no hook preStop para dar tempo ao balanceador de carga externo de reconhecer que o pod está sendo encerrado
      • O tempo gasto em preStop conta dentro de terminationGracePeriodSeconds
    • Fazer o readiness probe falhar no nível do código e aguardar um pouco
      • Isso funciona não só no Kubernetes, mas também em outros ambientes em que o balanceador precisa saber do estado de prontidão
  • O readiness probe verifica periodicamente se o contêiner está pronto para receber tráfego
    • O health check pode ser feito por requisição HTTP, conexão TCP ou execução de comando
    • Se o probe falhar, o Kubernetes remove o pod dos endpoints do service para que ele deixe de receber tráfego
  • Ao se preparar para encerrar, é possível usar algo como isShuttingDown com atomic.Bool para fazer /healthz retornar HTTP 503
  • Depois de mudar o estado de readiness para falha, é preciso esperar alguns segundos para a propagação da mudança
    • O exemplo de configuração usa periodSeconds: 5, e o exemplo do texto usa espera de 5 segundos
    • O tempo exato de espera depende da configuração do readiness probe

Tratamento de requisições em andamento

  • Crie um tempo limite com context.WithTimeout de acordo com o shutdown budget e passe-o para server.Shutdown(ctx)
  • server.Shutdown retorna em dois casos
    • Todas as conexões ativas foram fechadas e todos os handlers terminaram
    • O context fornecido expirou antes de os handlers terminarem, então o servidor desistiu de esperar
  • Em ambos os casos, Shutdown só retorna depois que o servidor deixa completamente de processar requisições
  • Os handlers precisam ser rápidos e context-aware
    • Caso contrário, podem ser interrompidos no meio do trabalho quando o tempo limite expirar
    • Isso pode causar escrita parcial, perda de dados, estado inconsistente, transações abertas e dados corrompidos
  • Há duas formas comuns de propagar o sinal de encerramento aos handlers
    • Injetar lógica de cancelamento no context de cada requisição via middleware
    • Fornecer um context global compartilhado por todas as conexões usando BaseContext de http.Server
  • Os contexts customizáveis em um servidor HTTP são BaseContext e ConnContext
    • Para Graceful Shutdown, BaseContext é mais apropriado, pois permite criar um context global cancelável aplicado ao servidor inteiro
  • O Graceful Shutdown só é eficaz quando as funções respeitam o cancelamento do context
    • Evite usos que ignoram o cancelamento, como context.Background() e time.Sleep()
    • time.Sleep(duration) pode ser substituído por um select que aguarda time.After(duration) junto com ctx.Done()
  • Em versões antigas do Go, time.After podia vazar memória até o timer disparar

Diferença entre Shutdown e Close

  • O mesmo princípio vale não só para servidores HTTP, mas também para serviços de terceiros
  • Em database/sql, DB.Close fecha as conexões com o banco, impede o início de novas queries e espera as queries em andamento terminarem
  • O ponto central é não aceitar novas requisições ou mensagens e dar tempo para que o trabalho existente termine dentro do grace period definido
  • server.Close() encerra imediatamente, sem esperar conexões em andamento
    • Handlers que estejam usando a rede receberão erros em leituras e escritas
    • O cliente pode receber imediatamente erros de conexão como ECONNRESET ou socket hang up
    • Handlers longos que não interagem com a rede podem continuar executando em background
  • É possível usar server.Close() depois que server.Shutdown() retornar erro, mas isso depende da estratégia de encerramento adotada
  • Propagar o sinal de encerramento por meio de context é uma abordagem mais confiável e mais graceful

Ordem de liberação de recursos importantes

  • Um erro comum é liberar recursos importantes assim que o sinal de término é recebido
  • Nesse momento, handlers e requisições in-flight ainda podem estar usando esses recursos, então a limpeza deve ser adiada até o timeout de shutdown expirar ou todas as requisições terminarem
  • Em muitos casos, o próprio encerramento do processo já faz o sistema operacional recuperar os recursos
    • A memória alocada pelo Go é liberada quando o processo termina
    • Os file descriptors são fechados pelo sistema operacional
    • Recursos de nível de sistema operacional, como process handles, também são recuperados
  • Em alguns casos, ainda é necessária uma limpeza explícita
    • Conexões de banco de dados devem ser fechadas corretamente, e transações abertas precisam de commit ou rollback
    • Filas de mensagens e brokers podem exigir flush de mensagens, commit de offset e notificação de encerramento do cliente
    • Serviços externos podem não detectar imediatamente a desconexão; fechar a conexão manualmente pode limpar mais rápido do que esperar o timeout do TCP
  • Uma boa regra é encerrar componentes em ordem inversa à inicialização
    • Em Go, defer executa primeiro a última função registrada, o que combina bem com esse padrão
  • Alguns componentes exigem uma rotina de shutdown própria, como no caso de um cache em memória que precisa gravar dados em disco

Fluxo do exemplo completo

  • O exemplo completo configura um root context com signal.NotifyContext para receber SIGINT e SIGTERM
  • O endpoint /healthz retorna HTTP 503 e Shutting down quando isShuttingDown é true; caso contrário, retorna OK
  • O handler de exemplo responde com Hello, world! após 2 segundos ou, se o context da requisição for cancelado, responde com HTTP request timeout
  • Em BaseContext, ongoingCtx é conectado para que requisições in-flight não sejam canceladas imediatamente após o SIGTERM
  • Ao receber o sinal de encerramento, o fluxo segue esta ordem
    • Chamar stop() para permitir o tratamento padrão de sinais adicionais
    • isShuttingDown.Store(true) para colocar o readiness em estado de falha
    • Esperar 5 segundos, definidos por _readinessDrainDelay, para a propagação do readiness check
    • Chamar server.Shutdown com o tempo limite de 15 segundos definido por _shutdownPeriod
    • Chamar stopOngoingGracefully() para cancelar o context das operações em andamento
    • Se Shutdown falhar, aguardar um período de cancelamento forçado de 3 segundos definido por _shutdownHardPeriod

1 comentários

 
GN⁺ 2025-05-06
Comentários do Hacker News
  • Já me dei mal porque, em algumas configurações, o Kubernetes demorava mais do que eu esperava para atualizar os IPs de destino do balanceador de carga. No meu caso, 90% do graceful shutdown era garantir que o tráfego fosse de fato drenado antes de encerrar o pod
    Ao colocar um sleep de 15 segundos no hook global preStop, a taxa de HTTP 503 caiu bastante, e isso ganhou tempo entre o início da remoção do registro no balanceador de carga e a entrega do SIGTERM à aplicação, tornando o tratamento do lado da aplicação muito mais simples

    • Sim. Um sleep no preStop é uma solução quase mágica para manter SLOs em deploys rolling de alta qualidade
      Acho que há duas coisas que o Kubernetes poderia melhorar. Os pods deveriam ser removidos dos Endpoints antes de iniciar a sequência de encerramento, e deveria haver uma opção de termination delay, como o termination grace. Além disso, o PDB deveria ter uma opção para permitir recriação antes da expulsão
  • Se você faz scrape de um endpoint /metrics típico do Prometheus a cada N segundos, surge um intervalo em que as métricas registradas entre o último scrape e o encerramento real do processo não são propagadas. Por isso, você pode ficar com uma impressão errada sobre a existência de erros durante a sequência de encerramento
    Se não tomar cuidado, também pode perder os logs dos últimos segundos antes do encerramento do serviço. Por exemplo, se um sidecar como Promtail ou Vector monitora o arquivo de log, e o serviço, ao iniciar, trunca o mesmo caminho e volta a escrever nele, cria-se uma condição de corrida em que logs do encerramento desaparecem

    • A stack de observabilidade parece meio absurda. Logs, métricas e traces têm cada um seu próprio banco de dados, sidecar e stack de visualização; as bibliotecas de integração por linguagem são todas diferentes; e os custos de cloud também são enormes
      Mesmo com tanto esforço, a maior parte dos dados é completamente ignorada, e os insights de negócio raramente são muito melhores do que a versão de favela de entrar no servidor via ssh e dar grep nos arquivos de log. Não tenho muita certeza de que todo o esforço colocado nesse ecossistema tenha melhorado significativamente uptime, desempenho ou usabilidade
    • Nos últimos mais de 8 anos lidando com aplicações Go de alta carga, tratei exatamente desses problemas em uma biblioteca de plataforma. Desenvolver e melhorar plataformas e deploys rolling em cada empresa virou quase um hobby
      Pretendo cobrir coisas como “sincronização de logs” e “aguardar até o ingress acompanhar o liveness handler”
      https://github.com/utrack/caisson-go/blob/main/caiapp/caiapp...
      https://github.com/utrack/caisson-go/tree/main/closer
      A documentação ainda é insuficiente e há partes faltando, mas planejo fazer o primeiro release quando voltar das férias. No fim, a ideia é virar uma meta-plataforma e uma biblioteca de plataforma de referência para lidar com infraestrutura comum de k8s/otel/grpc+http
    • Nunca entendi por que o Prometheus e ferramentas relacionadas usam um modelo pull. A maioria usa um modelo push
    • Fico curioso se alguém já viu uma solução confortável para esse problema. Se o intervalo de scrape é de 15 segundos, não dá para esperar 30 segundos só para tentar registrar as métricas duas vezes
      Por causa desse comportamento, nossos serviços ainda usam statsd, já que um modelo baseado em push não tem esse problema
  • Uma pequena armadilha que vejo com frequência é achar que chamar log.Fatal ainda executa defer. Na verdade, não executa
    log.Fatal("fatal") chama os.Exit internamente, então encerra imediatamente e o defer não roda. Já panic("fatal") mostra tanto fatal quanto in defer

  • Se, para um sistema distribuído funcionar corretamente, ele depende da premissa de que o cliente precisa encerrar graciosamente, uma hora ele inevitavelmente vai quebrar feio

    • Acredito tanto nisso que nem considero graceful shutdown no design. Componentes devem poder sofrer hard crash com segurança, até com frequência, e se uma proporção importante do sistema estiver funcionando como planejado, o sistema como um todo não deve sofrer impacto significativo
      A única forma de garantir que o sistema tolera hard crashes de componentes é fazer com que hard crashes sejam algo normal e constante. Glória ao Chaos Monkey
    • Há uma grande diferença entre graceful shutdown para ser gentil com clientes ou workflows e depender disso para que o sistema funcione
    • Na época dos servidores físicos antigos, usávamos STONITH para isso: https://smcleod.net/2015/07/delayed-serial-stonith/
    • Mesmo em situações recuperáveis, há bons motivos para fazer um encerramento normal não parecer um encerramento catastrófico
      Há uma grande diferença entre uma aplicação cair por sig int e ser morta com kill. Por exemplo, uma migração blue-green precisa de comportamento de encerramento gracioso
    • Sim. Ainda assim, só porque o software foi projetado para aguentar alguém puxando o plugue, não significa que você precise realmente puxar o plugue ao encerrar
      Pensando melhor, talvez precise. Pode ser a única forma de garantir que essa suposição é verdadeira. Algo no estilo do chaos monkey da Netflix de alguns anos atrás
  • Achei que o texto fosse abordar uma forma de uma nova instância do serviço receber da instância antiga o socket em escuta, reiniciando a aplicação sem derrubar nenhuma conexão de entrada
    No systemd, isso é relativamente simples de implementar, e o nginx dá suporte a isso há mais de 20 anos. Infelizmente, Kubernetes e Docker não oferecem suporte, pois assumem que isso é tratado pelo balanceador de carga ou pelo proxy reverso

  • Meu colega sempre dizia que, se um programa não consegue lidar de forma limpa com ctrl c e alguns comandos de encerramento, é um programa mal escrito

    • Ctrl-C é reservado para copiar para a área de transferência. Usá-lo para parar um programa é muito contraintuitivo e vai irritar os usuários
  • Acho que o Elixir lida com esse tipo de coisa de forma realmente inteligente. Não tenho tanta experiência, mas como ele é projetado para que pequenos processos da VM entrem em pânico, sejam encerrados e recriados, parece reduzir a necessidade de criar rotinas de graceful shutdown intencionalmente
    É porque essa característica já vem embutida na arquitetura da aplicação

    • Fico curioso para saber como isso elimina a necessidade de graceful shutdown tratada pelo autor
  • Criei uma pequena biblioteca para lidar com graceful shutdown no meu projeto: https://github.com/eberkund/graceful
    Normalmente há alguns serviços que precisam ser iniciados, e cada um costuma ter sua própria forma de iniciar e encerrar. Às vezes é preciso instanciar um objeto primeiro, às vezes há um contexto que se quer cancelar, e às vezes há um método Stop que precisa ser chamado. Ela foi projetada para reunir tudo isso em um só lugar com uma API unificada

  • Um pod em processo de encerramento, por definição, não está pronto. O serviço também marca o endpoint como terminating e not ready. Isso acontece quando ele passa para o estado Terminating, então não há necessidade de fazer o readiness check falhar de propósito
    Não sei exatamente a ordem entre o SIGTERM e a atualização de objetos como Pod.status ou endpoint slices. Pode haver uma pequena janela em que conexões ainda cheguem depois do SIGTERM, mas não é um intervalo grande “até o readiness check falhar”, como o texto sugere. Do ponto de vista de quem administra o cluster, essa janela minúscula não é muito importante. Basta não aceitar novas conexões, fechar as conexões existentes de forma graciosa e encerrar em um tempo razoavelmente rápido. Dito isso, metade dos apps com que lido processa SIGTERM mas demora para encerrar, ou nem consegue processar SIGTERM e ainda assim demora para encerrar

  • Adotamos o Google Wire em alguns projetos da JustWatch e isso mudou o jogo. Apesar de ser surpreendentemente pouco conhecido, ele ajuda a eliminar lógica de encerramento bagunçada no Kubernetes
    Como o Wire força uma injeção de dependências limpa, agora tudo é encerrado em uma ordem definida, em vez de uma ordem desconhecida
    https://go.dev/blog/wire
    https://github.com/google/wire