Padrões práticos para implementar Graceful Shutdown em Go
(victoriametrics.com)- 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
SIGTERMeSIGINTcomos/signalousignal.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
preStopou falha no readiness probe http.Server.Shutdownbloqueia 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, incluindoSIGTERM,SIGQUIT,SIGILLeSIGTRAP - 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çãoSIGINT: enviado quando o usuário tenta interromper o processo comCtrl+Cno terminalSIGHUP: originalmente usado para desconexão do terminal, hoje também é frequentemente usado para recarregar configuração
- Sem tratamento específico, ao receber
SIGTERM,SIGINTouSIGHUP, o runtime do Go encerra a aplicação
os/signal e NotifyContext
signal.Notifyinstrui 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
selectedefaultao 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
- Internamente, o Go usa
signal.Notifypode ser chamado várias vezes para o mesmo sinal, e o Go enviará esse sinal para todos os canais registrados- Mesmo pressionando
Ctrl+Cvárias vezes, normalmente a segunda entrada não é automaticamente promovida aSIGKILL- A maioria dos shells bash ou Linux não faz essa promoção automática
- Para forçar o encerramento, é preciso enviar
SIGKILLexplicitamente comkill -9
- Para que, no desenvolvimento local, o segundo
Ctrl+Cforce o encerramento, é possível interromper o recebimento de sinais adicionais comsignal.Stoplogo após receber o primeiro sinal - A partir do Go 1.16,
signal.NotifyContextpermite vincular o tratamento de sinais a context cancellation- Mesmo depois de
ctx.Done(), ainda é preciso chamarstop()para que um segundoCtrl+Cpossa forçar o encerramento da aplicação
- Mesmo depois de
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
terminationGracePeriodSecondsnão for definido - Quando esse tempo expira, o Kubernetes envia
SIGKILLe interrompe a aplicação à forçaSIGKILLnã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/httpdo Go, é possível realizar Graceful Shutdown comhttp.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 paraTerminating - 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
sleepbreve no hookpreStoppara dar tempo ao balanceador de carga externo de reconhecer que o pod está sendo encerrado- O tempo gasto em
preStopconta dentro determinationGracePeriodSeconds
- O tempo gasto em
- 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
- Fazer um
- 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
isShuttingDowncomatomic.Boolpara fazer/healthzretornar 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
- O exemplo de configuração usa
Tratamento de requisições em andamento
- Crie um tempo limite com
context.WithTimeoutde acordo com o shutdown budget e passe-o paraserver.Shutdown(ctx) server.Shutdownretorna 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,
Shutdownsó 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
BaseContextdehttp.Server
- Os contexts customizáveis em um servidor HTTP são
BaseContexteConnContext- Para Graceful Shutdown,
BaseContexté mais apropriado, pois permite criar um context global cancelável aplicado ao servidor inteiro
- Para Graceful Shutdown,
- O Graceful Shutdown só é eficaz quando as funções respeitam o cancelamento do context
- Evite usos que ignoram o cancelamento, como
context.Background()etime.Sleep() time.Sleep(duration)pode ser substituído por umselectque aguardatime.After(duration)junto comctx.Done()
- Evite usos que ignoram o cancelamento, como
- Em versões antigas do Go,
time.Afterpodia vazar memória até o timer disparar- Esse problema foi corrigido no Go 1.23 ou superior
- Se a versão não for certa, é possível usar
time.NewTimer,Stope, quando necessário, verificar<-t.C - Issue relacionada: time: stop requiring Timer/Ticker.Stop for prompt GC
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.Closefecha 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
ECONNRESETousocket hang up - Handlers longos que não interagem com a rede podem continuar executando em background
- É possível usar
server.Close()depois queserver.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,
deferexecuta primeiro a última função registrada, o que combina bem com esse padrão
- Em Go,
- 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.NotifyContextpara receberSIGINTeSIGTERM - O endpoint
/healthzretorna HTTP 503 eShutting downquandoisShuttingDowné true; caso contrário, retornaOK - 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 oSIGTERM - 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.Shutdowncom o tempo limite de 15 segundos definido por_shutdownPeriod - Chamar
stopOngoingGracefully()para cancelar o context das operações em andamento - Se
Shutdownfalhar, aguardar um período de cancelamento forçado de 3 segundos definido por_shutdownHardPeriod
- Chamar
1 comentários
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 doSIGTERMà aplicação, tornando o tratamento do lado da aplicação muito mais simplespreStopé uma solução quase mágica para manter SLOs em deploys rolling de alta qualidadeAcho 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
/metricstí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 encerramentoSe 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
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
sshe dargrepnos arquivos de log. Não tenho muita certeza de que todo o esforço colocado nesse ecossistema tenha melhorado significativamente uptime, desempenho ou usabilidadePretendo 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
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.Fatalainda executadefer. Na verdade, não executalog.Fatal("fatal")chamaos.Exitinternamente, então encerra imediatamente e odefernão roda. Jápanic("fatal")mostra tantofatalquantoin deferSe, 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
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 uma aplicação cair por
sig inte ser morta comkill. Por exemplo, uma migração blue-green precisa de comportamento de encerramento graciosoPensando 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 ce alguns comandos de encerramento, é um programa mal escritoAcho 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
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
Stopque precisa ser chamado. Ela foi projetada para reunir tudo isso em um só lugar com uma API unificadahttps://pkg.go.dev/git.sr.ht/~mariusor/wrapper#example-Regis...
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
SIGTERMe a atualização de objetos comoPod.statusou endpoint slices. Pode haver uma pequena janela em que conexões ainda cheguem depois doSIGTERM, 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 processaSIGTERMmas demora para encerrar, ou nem consegue processarSIGTERMe ainda assim demora para encerrarAdotamos 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