Rolling Deployments com tempo de inatividade zero no Kubernetes
Kubernetes permite empresas de grande porte operar sistemas robusto em escala. Porém, nem tudo é perfeito e em alguns casos pode acontecer comportamentos inesperados.
Motivação
Uma expectivativa rasoável seria que durante rolling deployment
não tivesse nenhum request failure
. Maaas, depois de alguns testes descobri que kubernetes vai mandar tráfego para terminating pods
, mesmo depois de começar o processo de shutdown
de um pod, o que envia um TERM
signal para os pods, Kubernetes ainda assim envia novos requests para o pod. Frustante, não? Isso acontece porque não existe uma orquestração entre o Kubernetes enviando um sinal TERM
e removendo o pod da lista de endpoints de serviço. Essas duas operações podem acontecer em qualquer sequência, causando um delay entre eles.
Um pod pode ser despejado por vários motivos. O processo de despejo começa quando o servidor API modifica o estado de um pod no etcd
para o estado Terminating
. O kubelet
do nó e o controlador de endpoints monitoram continuamente o estado do pod. Assim que percebem o estado de Termination
, eles iniciam o processo de despejo, ambas as operações são assíncronas:
Kubelet
executa o despejo(pod eviction)- O endpoint-controller lida com o processo de remoção do endpoint
Quando o kubelet reconhece que um pod deve ser encerrado, ele inicia uma sequência de shutdown
para cada contêiner no pod.
- Executa o pre-stop hook do contaier se tiver
- Envia um sinal
TERM
- Aguarda o encerramento do contêiner
Esse processo sequencial deve levar menos de 30 segundos (ou o valor em segundos especificado no campo spec.terminationGracePeriodSeconds). Se o contêiner ainda estiver em execução além desse tempo, o kubelet aguardará mais 2 segundos e, em seguida, matará o contêiner à força, enviando um sinal KILL
.
Para alcançarmos graceful shutdowns
é importante compreender a natureza assíncrona do processo de remoção de pod. Não podemos fazer suposições sobre qual dos processos de despejo será concluído primeiro. Se o processo de remoção do endpoint terminar antes dos contêineres receberem o sinal TERM
, nenhuma nova solicitação chegará enquanto os contêineres estiverem encerrando. No entanto, se os contêineres começarem a terminar antes da conclusão do processo de remoção do endpoint, os pods continuarão a receber requests. Nesse caso, os clientes receberão erros de Connection timeout
ou Connection refused
como respostas. Como a remoção do endpoint deve ser propagada para todos os nós do cluster antes de ser concluída, há uma alta probabilidade de que o processo de remoção do pod seja concluído primeiro.
Solução
Em primeiro lugar, devemos ter certeza de que o aplicativo termina normalmente quando o kubelet envia o sinal TERM para o contêiner. Felizmente, as estruturas de aplicativos da Web geralmente oferecem suporte
graceful shutdown
com pouca ou nenhuma configuração. Por exemplo, Spring Boot requer apenas 2 linhas de configuração e aplicativos Express.js precisam de 3 linhas de código JavaScript.
Em seguida, você deve verificar se seu aplicativo está realmente recebendo o sinal TERM
o que nem sempre é o caso. Uma mitigação comum é pausar o processo de remoção do pod para aguardar que o processo de remoção do endpoint se propague por todo o cluster Kubernetes. Afinal, não apenas os kube-proxies devem ser notificados, mas também outros componentes, como ingress controllers e load balancers. Para fazer isso, podemos usar duas configurações na especificação do pod: spec.lifecycle.preStop
e spec.terminationGracePeriodSeconds
.
O pre-stop hook é executado antes que os contêineres recebam o sinal TERM
, para que possamos usá-lo para obter um graceful shutdown
. Para mitigar o problema, adicionamos um comando sleep
no pre-stop hook. Isso atrasará o sinal TERM
e criará tempo para a propagação da remoção do ´endpoint´.
Por quanto tempo devemos esperaro pre-stop hook? Depende da latência da sua rede e dos nós. Pode ser necessário realizar alguns testes para descobrir esse valor. No entanto, várias fontes sugerem que um valor entre 5 a 10 segundos deve ser suficiente para a maioria dos casos.
Por exemplo, um atraso de 20 segundos com até 40 segundos de tempo de desligamento do aplicativo resulta na seguinte configuração:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: "{{APP_NAME}}"
lifecycle:
preStop:
exec:
command: ["/bin/sh","-c","sleep 20"]
Outro approach: Atrase o app shutdown
Em vez de fazer um fazer um sleeping
no pre-stop hook, podemos esperar no aplicativo por algum tempo até que nenhum outro request chegue. Application frameworks e runtimes expõem hooks para receber e manipular sinais de processo, por exemplo em aplicativos Express.js:
const server = app.listen(port)
process.on('SIGTERM', () => {
debug('SIGTERM signal received: closing HTTP server in 10 seconds')
const closeServer = () => server.close()
setTimeout(closeServer, 10_000)
})
Este mecanismo de mitigação é baseado na mesma ideia subjacente de esperar com a esperança de que, eventualmente, o Kubernetes não encaminhe novas solicitações para o pod.
Notas finais
Tudo isso é baseado em clusters Kubernetes usando kube-proxy e iptables. O Kubernetes está migrando para implementações CNI que usam eBPF em vez de iptables. Mas o comportamento descrito aqui também se aplica às soluções baseadas em eBPF (ou pelo menos parece aplicar-se a elas) e pode haver mais advertências que você deve ter em mente. Por exemplo, o Cilium parece ter um bug que faz com que as conexões existentes também falhem durante o encerramento do pod, dificultando o desligamento normal.