Rolling deploy seguro: por que o seu provavelmente não é

Trocar contêiner sem downtime parece simples — pull nova imagem, mata velho, sobe novo. Funciona até a primeira sexta-feira 17h. Os 6 detalhes que separam rolling deploy real de teatro.

Equipe HeroCtl··13 min

Toda equipe de engenharia que opera contêiner em produção, mais cedo ou mais tarde, escreve uma frase parecida no canal de status: "deploy concluído, sem downtime". A frase é otimista. Em pelo menos metade dos casos que a gente já auditou — em scripts caseiros, em painéis self-hosted populares, em tutoriais oficiais que viraram referência —, o que aconteceu de fato foi uma janela de 5 a 30 segundos em que o balanceador retornou 502, alguns uploads cortaram pela metade, e ninguém percebeu porque o monitoramento amostra a cada minuto.

Rolling deploy parece o problema mais simples de orquestração: você tem N contêineres rodando uma versão antiga, quer ter N contêineres rodando uma versão nova, e quer que o app continue respondendo durante a troca. A receita conceitual cabe em três linhas. Substituir um contêiner velho por um novo, um a um, mantendo o tráfego sempre direcionado a quem está vivo. O que torna isso difícil não é a estratégia — é o conjunto de seis detalhes que precisam estar certos ao mesmo tempo. Cada um sozinho parece detalhe de implementação. Os seis juntos são a diferença entre "deploy sem downtime de verdade" e "deploy que parece sem downtime na sexta-feira de manhã, mas tem trinta segundos de erro no meio do dia 17h".

Esse post mapeia os seis. No final tem uma receita em formato de spec, uma comparação honesta de quem implementa o quê, e uma seção de testes que você pode rodar no seu sistema atual pra descobrir se está mal — antes que seu cliente descubra primeiro.

Detalhe 1 — Health check antes de promover o novo contêiner

O erro mais comum em rolling deploy caseiro é confiar no estado running que o runtime de contêiner reporta. Você sobe um contêiner novo, o Docker (ou equivalente) marca ele como running em milissegundos, e o seu script considera isso prova de que pode matar o velho. Mata o velho. O novo, internamente, ainda está iniciando — esperando conexão com banco, carregando cache em memória, baixando configuração de feature flag, abrindo pool de threads. Durante esse intervalo, o balanceador roteia tráfego pra um processo que ainda não está pronto a receber e devolve 502 ou 503.

A janela é curta — geralmente entre 5 e 30 segundos por contêiner —, e por isso ela engana monitoramento. Se sua métrica de erro é amostrada a cada minuto e você troca cinco contêineres em sequência, cada um com 10 segundos de "está running mas não está pronto", o pico nem sempre cai exatamente sobre uma janela de coleta. Você fica com a impressão estatística de que tudo correu bem.

O que rolling deve fazer, em vez disso, é separar dois conceitos: "o processo está rodando" e "o processo está pronto a servir tráfego". Rodando é estado de runtime; pronto é resposta afirmativa de um endpoint que o próprio app expõe — /healthz, /readyz, ou equivalente. O orquestrador faz HTTP GET nesse endpoint do contêiner novo, espera receber 200, espera essa resposta sustentada por um período (min_healthy_time, tipicamente 10 segundos), e só então remove o contêiner velho do balanceador.

O min_healthy_time é o detalhe que muita gente pula. Ele existe porque um único 200 não significa nada — pode ser que o app respondeu antes de fechar uma conexão crítica, e vai começar a falhar no segundo seguinte. Esperar 10 segundos consecutivos de respostas saudáveis filtra esses falsos positivos sem alongar o deploy de forma absurda.

O Watchtower clássico — script popular pra atualizar contêineres a partir de novas tags de imagem — não faz nada disso. Ele faz pull, para o velho, sobe o novo. Coolify e Dokploy implementam parcialmente, dependendo da configuração da aplicação e do tipo de health check que você habilitou. Um orquestrador de cluster sério trata isso como requisito mínimo.

Detalhe 2 — Connection draining e graceful shutdown

Mesmo quando você ordena o roteador a parar de mandar conexões novas pro contêiner velho, ainda existem conexões em curso — uploads de arquivo, downloads grandes, requests longos que aguardam resposta de banco, websocket aberto, streaming de evento. Se você simplesmente envia SIGKILL (ou deixa o runtime mandar, depois de um timeout curto demais), todas essas conexões cortam pela metade. O usuário vê erro de rede no momento exato do deploy.

O fluxo correto tem quatro passos ordenados. Primeiro, você sinaliza ao roteador que o contêiner velho não deve mais receber conexões novas — isso geralmente é uma remoção do balanceador ou um drain do nó. Segundo, você envia SIGTERM ao processo. SIGTERM é um sinal capturável; o app pode tratar e iniciar shutdown gracioso. Terceiro, você espera. O timeout depende do perfil da aplicação — 30 a 60 segundos cobre a vasta maioria dos web apps; APIs com upload de arquivo grande podem precisar de 120 ou mais. Quarto, e só depois desse timeout, você manda SIGKILL pra o que ainda não terminou.

Existe uma armadilha conhecida nesse passo: o app precisa, ele mesmo, lidar com SIGTERM. Node, Rails, Django, Go HTTP server — todos têm middleware ou helpers pra isso, mas nenhum deles vem ligado por padrão em template básico. Se sua aplicação não captura SIGTERM, o sinal vira no-op e o orquestrador vai esperar o timeout cheio antes de matar com SIGKILL. O resultado é deploy lento e, ainda assim, conexões cortadas — porque o app só percebeu que ia morrer no instante do KILL.

Verifique o seu app. Especificamente: quando ele recebe SIGTERM, ele para de aceitar conexões novas, espera as conexões em curso terminarem, e só então fecha? Se a resposta é "não sei", o seu rolling deploy está quebrado nesse detalhe.

Detalhe 3 — Imagem anterior pré-puxada pra rollback rápido

Bug crítico em produção, três minutos depois do deploy. Você precisa voltar pra versão anterior agora. O comando pull da imagem antiga toma 30 a 60 segundos por nó — porque, claro, você "limpou" a velha pra economizar disco, ou o cache de imagem do nó já foi rotacionado. Multiplique por número de réplicas, some o tempo de orquestração da própria troca, e o seu incidente de cinco minutos virou quinze. Quinze minutos é a diferença entre "instabilidade momentânea" no postmortem e "incidente reportado pra cliente".

A correção é trivial e quase ninguém implementa: manter a imagem N-1 pré-puxada nos nós que rodavam ela. Rollback vira mudar a tag pointed-to e reiniciar — operação de aproximadamente 10 segundos por contêiner, dominada pelo health check do contêiner antigo voltando à vida.

A versão mais sofisticada é manter snapshot do estado completo do job — não só imagem, mas variáveis de ambiente, configurações de rede, secrets associados, recursos alocados. Rollback parcial (só imagem) cobre a maioria dos casos, mas não cobre regressão introduzida em uma feature flag ou em uma string de conexão. Snapshot total é o que separa rollback rápido de rollback completo.

Detalhe 4 — Detecção automática de falha e auto-revert

Cenário comum em script caseiro: o deploy sobe, o contêiner novo entra em crash-loop (volta a running, morre em 5 segundos, volta de novo, morre de novo). O sistema fica esperando alguém ver o problema e abortar manualmente. Se isso acontece às quatro da manhã e o alerta vai pra Slack que ninguém tá olhando, o downtime se prolonga até alguém acordar.

O que rolling deve fazer é definir um healthy_deadline — um teto absoluto de tempo dentro do qual o contêiner novo precisa entrar e permanecer em estado saudável. O nosso default é 300 segundos; cinco minutos cobre apps que demoram pra inicializar (apps Java pesados, apps com warm-up de cache) sem dar margem indefinida. Se passou o deadline e o contêiner não está saudável, o orquestrador reverte automaticamente pra versão anterior. Alerta o time depois, sem urgência — porque o sistema já se protegeu sozinho.

A implementação prática conta dois sinais combinados: contagem de restarts no contêiner novo (se passou de 3 em 60 segundos, é crash-loop) e tempo total decorrido sem health check positivo sustentado. Qualquer um dos dois disparando antes do min_healthy_time ser atingido aborta o deploy daquela réplica e dispara o revert.

Um detalhe sutil: auto-revert só faz sentido se o detalhe 3 (imagem anterior pré-puxada) está implementado. Reverter pra uma imagem que precisa ser baixada de novo durante o auto-revert anula o ganho.

Detalhe 5 — max_parallel: 1 em cluster multi-instance

Você tem cinco réplicas do mesmo serviço. Tentado de trocar todas as cinco ao mesmo tempo: deploy paralelo, espera todas ficarem saudáveis, pronto. Esse caminho tem três problemas. Primeiro, durante a janela em que todas estão sendo trocadas, todo o tráfego passa pela versão nova — se ela tem bug, 100% dos usuários sentem, sem fallback. Segundo, o pico de uso de recursos durante a troca é 2× (porque velhas e novas coexistem por instantes), o que pode estourar memória do nó e gerar OOM em cascata. Terceiro, você perde a oportunidade barata de detectar regressão antes de propagar.

Rolling deve trocar uma réplica por vez (max_parallel: 1) — ou, em clusters muito grandes, uma fração pequena (10-25%). Troca a primeira, espera ficar saudável, espera o min_healthy_time, troca a segunda, e assim por diante. A capacidade total do serviço fica mantida durante toda a janela de deploy: se cinco réplicas aguentavam o tráfego antes, quatro novas + uma velha (ou vice-versa) também aguentam.

O trade-off é tempo. Dez réplicas com 30 segundos cada de troca + min_healthy_time = cinco minutos de janela total de deploy. Não é rápido. Em troca, você ganha rollback barato: se a primeira réplica nova falhar, o orquestrador para de trocar as outras. Você fica com nove velhas + uma nova fracassada, descarta a fracassada, voltou ao estado anterior sem nenhum impacto em capacidade. O custo de deploy lento é pago pela segurança de não ter cluster inteiro com bug rodando antes que alguém perceba.

Existe um knob extra que ajuda: stagger, o intervalo entre trocas consecutivas. Default razoável é 30 segundos. Esse atraso permite que métricas e logs do contêiner recém-trocado sejam coletados e avaliados antes de partir pra próxima — janela mínima pra detectar bug que só aparece sob tráfego real.

Detalhe 6 — Pre-stop hooks pra long-running jobs

Esse detalhe afeta especificamente apps que têm worker assíncrono — Sidekiq, Celery, Resque, RQ, BullMQ, qualquer fila de job em background. O contêiner do worker pegou um job que demora 30 minutos pra processar (envio de e-mail em lote, geração de relatório, processamento de pagamento). No meio do processamento, vem o SIGTERM do deploy. Se o worker não tiver tratamento adequado, o job é perdido — fica em estado intermediário, ou volta pra fila e é processado em duplicata, ou simplesmente some.

O fluxo correto é mais elaborado que o do detalhe 2. Antes mesmo do SIGTERM, o orquestrador precisa executar um pre-stop hook que sinalize ao worker pra entrar em modo de drenagem: parar de aceitar jobs novos, mas terminar os que já pegou. O orquestrador então espera (com timeout configurável — 60 a 300 segundos é faixa normal) pela fila local drenar. Só depois manda SIGTERM pro processo.

A implementação varia. Apps mais sofisticados expõem um endpoint /pause ou /drain que coloca o worker em modo gracioso. Apps mais simples usam um arquivo de sentinela — pre-stop cria um arquivo, worker checa o arquivo a cada loop e para de pegar job novo se ele existir. Em ambos os casos, a chave é que o orquestrador precisa esperar a confirmação de que a fila local drenou antes de mandar SIGTERM.

Sem isso, sua taxa de falha de jobs assíncronos durante deploy é diretamente proporcional ao tempo médio de processamento de job × número de workers trocados. Em apps que processam pagamento ou envio de e-mail, essa taxa de falha vira problema sério rápido.

A receita completa

Os seis detalhes se compõem numa especificação reconhecível. Em formato spec:

update:
  max_parallel: 1
  min_healthy_time: 10    # 10s sustentados saudável
  healthy_deadline: 300   # 5 min máx pra ficar saudável
  auto_revert: true       # se passar deadline, reverte
  stagger: 30             # 30s entre réplicas trocadas
tasks:
  - name: web
    healthcheck:
      path: /healthz
      interval: 5s
      timeout: 2s
      retries: 3
    lifecycle:
      pre_stop:
        timeout: 60         # 60s pra worker drenar
        command: ["/bin/sh", "-c", "kill -TERM 1; sleep 30"]

Essa configuração — em texto, em arquivo de configuração de orquestrador, ou implícita em código de deploy — é o mínimo viável de rolling deploy seguro. Cobrir os seis detalhes é o que diferencia uma orquestração séria de uma série de comandos docker enfileirados.

Quem implementa o quê (a versão honesta)

A grade abaixo cobre o ecossistema mais comum no nicho self-hosted/cluster pequeno. Não é exaustiva — há ferramentas excelentes que ficaram de fora —, mas é honesta sobre as que aparecem nas decisões de arquitetura típicas.

Kubernetes. Implementa todos os seis quando o manifesto está completo: readinessProbe cobre o detalhe 1, terminationGracePeriodSeconds + preStop cobre o 2 e o 6, imagePullPolicy + cache local cobre o 3, progressDeadlineSeconds + revisionHistoryLimit cobre o 4, maxUnavailable + maxSurge cobre o 5. O problema não é o que ele faz; é o tamanho do manifesto necessário pra fazer isso, e o número de campos cuja default não é o que você precisa.

Docker Swarm. Implementa rolling via docker service update. As primitivas existem (--update-parallelism, --update-delay, --update-failure-action), mas as defaults são agressivas demais — paralelismo padrão alto, sem health check obrigatório, e o comportamento de auto-revert é opt-in com um flag específico. Precisa ser tunado pra cada serviço; raramente é tunado.

Nomad. Implementa nativamente, com defaults sensatos. update block tem max_parallel, min_healthy_time, healthy_deadline, auto_revert, stagger — basicamente os mesmos campos da spec acima, porque essa nomenclatura não é coincidência: ela é a herança de boas práticas que os principais orquestradores convergiram.

HeroCtl. Implementa todos os seis detalhes nativamente, com a configuração praticamente idêntica à spec acima. A eleição de coordenador do plano de controle leva cerca de 7 segundos quando o nó líder cai, então mesmo um deploy executado em pleno momento de troca de coordenador é resiliente — o novo coordenador retoma o ciclo de health check do ponto onde estava. As defaults são max_parallel: 1, min_healthy_time: 10, healthy_deadline: 300, auto_revert: true. Se você submete um job sem configurar nada, é isso que pega.

Watchtower. Não. O Watchtower é uma ferramenta útil pra um caso específico — atualização automática de contêineres em ambiente onde você aceita downtime curto e perda de conexão como custo de não ter pipeline de deploy. Em produção séria, ele falha em cinco dos seis detalhes. Não é crítica ao projeto; é crítica ao uso dele em contexto errado.

Coolify e Dokploy. Implementam parcialmente. Health check existe mas precisa ser configurado por aplicação. Connection draining depende do app capturar SIGTERM (responsabilidade compartilhada). Auto-revert é manual em ambos. Pre-stop hook genérico não é primitivo de primeira classe. Pra single-server, é suficiente; pra cluster, é frágil.

Scripts caseiros. Aquela combinação de docker pull && docker stop && docker run em um shell script gerenciado por cron ou disparado por webhook do GitHub. Zero dos seis. Cobertura honesta do que isso é: um deploy com downtime curto, não um rolling deploy.

Os quatro padrões além de rolling

Rolling não é a única estratégia. Conforme o requisito e o orçamento, três outras fazem sentido em contextos específicos.

Blue-green. Dois ambientes paralelos completos, cada um com a stack inteira. Deploy é subir o ambiente alternativo (verde) com a versão nova, validar em paralelo, e fazer switch via DNS ou balanceador — um único momento atômico em que o tráfego inteiro muda. Mais seguro que rolling porque você pode validar a versão nova com tráfego sintético antes de qualquer usuário real. Custa, em capacidade, 2× durante a janela de deploy. Recomendado pra apps onde o custo de bug em produção é alto e o custo de capacidade extra é baixo.

Canary. Manda 5% (ou 1%, ou qualquer fração pequena) do tráfego pra versão nova, monitora métricas chave por um período de observação (15 minutos a algumas horas), e escala gradualmente — 5%, 25%, 50%, 100%. Detecta regressão antes de afetar o usuário principal. Pré-requisito é ter métricas confiáveis com sensibilidade alta; sem isso, canary só atrasa o deploy sem ganhar segurança. Combina bem com rolling: rolling é o mecanismo, canary é a estratégia de promoção.

Rainbow. Várias versões coexistindo simultaneamente em produção, com tráfego roteado por chave de cliente ou tipo de tenant. Caso de uso raro, geralmente em B2B com requisitos de versão por contrato. Não é a primeira opção quase nunca.

Recreate. Para tudo, sobe novo. Downtime explícito e aceito. Aceitável pra apps internos com janela de manutenção ou pra ambientes de desenvolvimento. Surpreendentemente apropriado em casos específicos: deploy que envolve migração de banco que rompe schema, ou deploy de app cuja arquitetura não suporta duas versões coexistindo. Quando recreate é a escolha certa, é a escolha certa — não tem prêmio por fazer rolling de tudo.

Como detectar que o seu rolling está mal

Quatro testes diretos. Os dois primeiros são métricas que você pode olhar no monitoramento que já tem; os dois seguintes são experimentos que precisam ser feitos com intenção.

Taxa de 5xx durante a janela de deploy. Se sua taxa de 5xx em produção é estatisticamente diferente de zero durante a janela do deploy, está mal. "Estatisticamente diferente" significa: pega 30 deploys consecutivos, mede a taxa de 5xx no minuto que precede o deploy e no minuto do deploy. Se a média do segundo é maior, há erro real, e a janela está cortando conexão.

Latência p99 durante o deploy. Se p99 sobe 3× ou mais durante a janela de deploy, está mal. Spike de latência indica que requests estão sendo reiniciados internamente, ou que o balanceador está reaceitando conexões pra contêineres lentos pra responder.

Teste de crash forçado. Antes de um deploy programado, force o app no contêiner novo a falhar — chmod 000 no binário, ou variável de ambiente que faz process.exit(1) no startup. O sistema reverte automaticamente dentro do healthy_deadline? Se ficar travado esperando intervenção humana, o detalhe 4 está quebrado.

Deploy de sexta 17h com tráfego real. O teste social. Faça um deploy não-trivial em horário de pico, num dia em que ninguém da equipe está olhando ativamente. Se a métrica do seu app durante essa janela for indistinguível de uma janela aleatória, seu rolling deploy é seguro. Se foi necessária intervenção, ou se o canal de status registrou algo, não é.

FAQ

Watchtower é seguro pra produção? Pra produção pequena com tolerância explícita a downtime curto e ferramenta de fallback (rollback manual rápido), sim. Pra produção que tem cliente pagante com expectativa de SLA, não. Watchtower foi feito pra um problema diferente.

Health check em /healthz ou /readyz? A convenção que mais ajuda na prática: /healthz indica "o processo está vivo" (liveness — usado por orquestrador pra decidir se reinicia o contêiner) e /readyz indica "estou pronto a servir tráfego" (readiness — usado pelo orquestrador pra decidir se inclui o contêiner no balanceador). Pra rolling deploy, o que importa é o readiness; o orquestrador só promove um contêiner pro pool de tráfego quando o readiness retorna 200. Se você tem só um endpoint e ele responde 200 imediatamente após o processo subir, sua readiness não está medindo o que devia.

min_healthy_time deve ser quanto? Faixa típica é 10 a 30 segundos. Mais curto que isso (3 segundos, 5 segundos) deixa passar falsos positivos — apps que respondem 200 logo no início mas começam a falhar quando o tráfego real chega. Mais longo que 60 segundos vira impedimento operacional sem ganho proporcional. Se sua aplicação tem warm-up complexo (cache em memória, conexão com terceiros lentos), o lugar pra cobrir isso é no health check em si — fazer ele só responder 200 depois do warm-up —, não inflar o min_healthy_time.

Como faço pre-stop em app Rails? Rails responde nativamente a SIGTERM com graceful shutdown desde a 5.x — o servidor para de aceitar conexão nova e termina as em curso. Pra Sidekiq, o sinal correto é SIGTSTP (pause workers), seguido por SIGTERM depois que Sidekiq.redis { |c| c.llen("queue:default") } zerar. Em prática, o pre-stop hook executa um script pequeno que manda SIGTSTP, faz polling de fila por até N segundos, e retorna — o orquestrador então faz o SIGTERM convencional.

Sticky sessions e rolling deploy se dão bem? Mal. Sticky session significa que sua arquitetura está delegando estado pro balanceador, e durante rolling esse estado é descartado quando a réplica que segurava a sessão é trocada. Resultado: usuário é deslogado, perde formulário pela metade, ou tem comportamento inconsistente. Se você precisa de sticky, isso é sintoma — refatore pra estado externo (Redis, banco) e o rolling deploy fica trivial.

Migração de banco no rolling deploy? A regra prática que evita 90% das dores: toda migração precisa ser compatível com a versão anterior do app durante a janela de deploy. Adicionar coluna nullable: tranquilo. Remover coluna: faça em duas releases (release N para de usar a coluna; release N+1 remove ela). Renomear coluna: idem, com coluna nova como cópia. Isso permite que velhas e novas réplicas coexistam, que é exatamente o que rolling pressupõe.

Posso testar rolling deploy localmente? Pode. Sobe três réplicas locais com docker-compose, simula um balanceador (nginx ou caddy) na frente, dispara hey ou wrk com tráfego sustentado, e executa um script de troca como o seu pipeline executaria em produção. Mede 5xx durante a janela. É um teste imperfeito (o tráfego é sintético, o nó é único, não há rede real entre nós), mas pega os bugs grosseiros nos detalhes 1, 2 e 3 antes deles vazarem pra produção.

Fechamento

Rolling deploy seguro não é um botão; é um conjunto de seis comportamentos coordenados, e a maioria das ferramentas que prometem "zero downtime" cobre três ou quatro deles. A diferença prática vira visível na sexta 17h, ou no incidente de quarta de manhã, ou no postmortem em que alguém pergunta por que três usuários reportaram erro durante o último deploy.

A receita está acima. Se a sua ferramenta atual cobre os seis, ótimo. Se não, ou você assume o custo de cobrir manualmente os faltantes, ou troca por algo que cobre nativamente.

HeroCtl cobre nativamente. Plano Community é gratuito permanente, sem limite de servidores ou jobs, com a configuração de rolling deploy descrita aqui como default. Plano Business adiciona SSO, RBAC, auditoria e suporte com SLA pra times com requisitos formais de plataforma. Plano Enterprise adiciona escrow de código-fonte e contrato de continuidade.

Pra começar:

curl -sSL https://get.heroctl.com/install.sh | sh

Se você quer ver os outros lados do assunto, leia Por que criamos o HeroCtl pro contexto de produto, e nos próximos posts vamos cobrir deploy de Docker em produção, do compose ao cluster e estratégias de backup de banco em cluster pras três da manhã.

A intenção continua a mesma: orquestração de contêineres, sem cerimônia — e sem teatro.

Faz parte do tema
#rolling-deploy#deploy#engenharia#zero-downtime#producao