Um laboratório prático de GitOps, Canary Releases e promoção controlada entre ambientes usando EKS Auto Mode, Argo Workflows, ECR e CloudWatch.
Este projeto nasceu de um desafio técnico: montar, do zero, um cluster Kubernetes na AWS onde uma aplicação Java fosse implantada de forma gradual e controlada, em duas frentes ao mesmo tempo:
- Dentro de cada shard: rollout Canary da aplicação.
- Entre shards: promoção controlada, com aprovação manual ou, como desafio extra, decisão baseada em um alarme do CloudWatch.
Neste artigo conto como cheguei à arquitetura final, quais decisões tomei, quais alternativas descartei e, principalmente, quais problemas reais apareceram quando coloquei tudo para rodar em uma conta AWS.
Se você só quer subir o ambiente, vá direto para TESTE-END-TO-END.md. Se quer entender o raciocínio por trás da arquitetura, recomendo seguir na ordem.
Visão geral
O objetivo era construir uma solução que combinasse:
- Amazon EKS com EKS Auto Mode;
- pelo menos dois NodePools fisicamente isolados, tratados como shards;
- aplicação Java/Spring Boot publicada no Amazon ECR;
- ArgoCD para GitOps;
- Argo Rollouts para Canary dentro de cada shard;
- Argo Workflows para build, publicação da imagem e atualização do Git;
- ApplicationSet para controlar as duas shards;
- aprovação manual entre shards.
Arquitetura
A arquitetura final ficou organizada em quatro blocos principais:
flowchart LR
G[GitHub] --> W[Argo Workflows]
W -->|Build| K[Kaniko]
K -->|Push| ECR[Amazon ECR]
W -->|Atualiza values.yaml| G
G --> A[ArgoCD ApplicationSet]
A --> S1[Shard 1]
A --> S2[Shard 2]
S1 --> R1[Argo Rollouts]
S2 --> R2[Argo Rollouts]
R1 --> N1[NodePool shard-1]
R2 --> N2[NodePool shard-2]
CW[CloudWatch Alarm] -. decisão opcional .-> W
O fluxo de um deploy é:
- Um
git pushaltera algo emapps/. - O Argo Workflow detecta a mudança.
- O Maven gera o artefato da aplicação.
- O Kaniko cria a imagem e publica no ECR.
- O pipeline atualiza
apps/springboot/values.yamlcom a nova tag e fazgit push. - O ArgoCD detecta a alteração.
- A
shard-1sincroniza automaticamente e inicia o Canary. - Após a aprovação do Canary , a shard-1 chega a 100%.
- A
shard-2fica aguardando aprovação manual ou pode ser promovida automaticamente pelo fluxo opcional baseado no CloudWatch.
Separação do Terraform
O Terraform foi dividido em duas camadas com states independentes:
terraform/
├── infra/
│ ├── VPC
│ ├── IAM
│ ├── EKS
│ └── ECR
│
└── platform/
├── NodeClasses
├── NodePools
├── ArgoCD
├── Argo Rollouts
├── Argo Workflows
└── recursos Kubernetes
A camada infra utiliza o provider AWS e cria a infraestrutura base. A camada platform utiliza aws, kubernetes, helm, kubectl e tls.
A separação foi intencional: um erro em um manifest Kubernetes não deve colocar o cluster inteiro em risco dentro de um único terraform apply. Primeiro a infraestrutura sobe e é validada; depois a plataforma é instalada.
Importante: os dois states também facilitam o troubleshooting. Se houver problema no Kubernetes, não é necessário reaplicar ou alterar a infraestrutura AWS.
O desafio
Em resumo: um cluster EKS com pelo menos dois NodePools isolados fisicamente entre si ("shards"), uma aplicação Java/Spring Boot buildada e publicada num repositório ECR próprio, e um deploy gradual em dois níveis — um Canary dentro de cada shard, e uma promoção controlada (com aprovação manual) de uma shard para outra, orquestrada por um ApplicationSet do ArgoCD.
Tudo em IaC, com Helm para empacotar o que fosse instalado via chart.
Isolando as shards de verdade
flowchart TB
NP1["NodePool shard-1"] --> NC1["NodeClass shard-1"]
NP2["NodePool shard-2"] --> NC2["NodeClass shard-2"]
P1["Pods com toleration shard-1"] --> NP1
P2["Pods com toleration shard-2"] --> NP2
X["Pod sem toleration"] -. bloqueado .-> NP1
X -. bloqueado .-> NP2
Cada shard é um par NodeClass+NodePool (terraform/platform/nodeclasses.tf, nodepools.tf). O ponto que me interessava resolver direito era: um nodeSelector sozinho garante que o pod da aplicação vá pro node certo, mas não impede que outros pods (sem esse seletor) também caiam ali — não é isolamento, é só direcionamento.
Resolvi isso com um taint (shard=shard-1:NoSchedule / shard=shard-2:NoSchedule): só pods com a toleration correspondente conseguem ser agendados nesses nodes.
O Rollout da aplicação já declara essa toleration.
Uma limitação que só descobri ao tentar implementar "no máximo 3 EC2 por shard": o Karpenter (base do Auto Mode) não tem um campo nativo de contagem máxima de instâncias — os limites de um NodePool são sempre por soma de recursos (limits.cpu/limits.memory).
Contornei isso calculando o limite de CPU/memória com base no maior tipo de instância aceito, multiplicado pelo teto desejado (locals.tf) — assim, mesmo que o Karpenter só consiga capacidade do tipo maior do conjunto, o teto nunca é ultrapassado.
Funciona, mas é uma aproximação, não uma trava exata por contagem.
O deploy gradual em dois níveis
flowchart LR
G[Git commit] --> S1["Shard 1"]
S1 --> C["Canary 50%"]
C --> A1["Aprovação"]
A1 --> P1["100%"]
P1 --> A2["Aprovação entre shards"]
A2 --> S2["Shard 2"]
Dentro da shard: Canary deploy com Argo Rollouts
Troquei o Deployment padrão por um Rollout (CRD do Argo Rollouts), com os passos do Canary definidos em apps/springboot/values.yaml:
canary:
steps:
- setWeight: 50
- pause: {}
- setWeight: 100
O detalhe importante aqui é o pause: {} sem duration. Pensei inicialmente em pausas cronometradas (promove sozinho depois de X segundos), mas o requisito era aprovação manual — então o rollout fica parado em 50% indefinidamente até alguém rodar kubectl argo rollouts promote springboot -n shard-1 (ou o plugin equivalente).
Entre shards: um único ApplicationSet, aprovação manual
Esse foi o ponto onde apanhei mais. O requisito pedia um ApplicationSet controlando o deploy nas duas shards — não dois recursos separados. Minha primeira tentativa foi um único generator list com os dois shards como elementos. Não funciona: o override de template no ArgoCD é aplicado por generator, não por elemento dentro da lista — então dava para variar tudo entre as shards, menos o syncPolicy (que era exatamente o que eu precisava diferenciar, para shard-1 sincronizar sozinha e shard-2 esperar aprovação).
A solução foi usar dois generators list dentro do mesmo ApplicationSet (um por shard, cada um com um único elemento) — aí sim cada generator pode ter seu próprio template override. O generator da shard-1 tem um template com syncPolicy.automated; o da shard-2 não tem template nenhum, herdando o syncPolicy do template top-level (sem automated) — fica OutOfSync até um argocd app sync springboot-shard-2 manual.
Achei que bastaria sobrescrever só o syncPolicy no generator da shard-1, mas a validação do CRD ApplicationSet exige o schema inteiro do template assim que qualquer campo dele existe — metadata/project/ destination viram "Required value" nesse ponto, mesmo que o controller faça merge depois. Precisei copiar o template inteiro, não só o campo que mudava. Foi o tipo de erro que só aparece rodando terraform apply de verdade (spec.generators[0].list.template.spec.destination: Required value), não em nenhuma validação estática.
Um efeito colateral curioso que vale registrar: como as duas Applications apontam pro mesmo targetRevision: HEAD do mesmo repositório, assim que o pipeline dá git push, a Application da shard-2 já aparece OutOfSync — mesmo com o Canary da shard-1 ainda no meio do caminho. Isso é só comparação (o repo-server do ArgoCD vendo que o Git mudou), não um deploy: sem syncPolicy.automated nesse generator, nada é de fato aplicado até alguém confirmar.
Automatizando o build com Argo Workflows
Toda vez que algo muda em apps/, quero que a imagem seja buildada, publicada no ECR e o values.yaml atualizado — sem eu precisar rodar nada manualmente a maior parte do tempo. Decidi fazer isso com um WorkflowTemplate do Argo Workflows em vez de GitHub Actions: assim tudo roda dentro do cluster, sem precisar configurar OIDC nem secrets do lado do GitHub.
O pipeline vive num único WorkflowTemplate (terraform/platform/argoworkflows-template.tf), organizado como um dag: — falo mais sobre por que DAG (e não uma lista sequencial de steps:) na seção do Desafio Extra, onde isso realmente importa. Nessa primeira metade do pipeline (as quatro tarefas que buildam e publicam a imagem), o fluxo é linear:
Os quatro passos, na ordem em que o DAG os executa:
-
clone-repo— clona o repositório e captura o SHA curto do commit (git rev-parse --short HEAD), usado como tag da imagem daqui para frente. -
maven-build—mvn clean packagedentro deapps/, gerando o.jarque oDockerfilesó copia (não builda a app de novo). -
kaniko-build-push— buildaapps/Dockerfilecom Kaniko (sem Docker-in-Docker — não tem daemon Docker disponível dentro de um pod) e dá push no ECR, autenticando via IRSA (explico logo abaixo). -
update-values— atualizaimage.repository/image.tagemapps/springboot/values.yamle dágit commit+push. É esse push que aApplicationda shard-1 (syncPolicy.automated) detecta e sincroniza sozinha, disparando o Canary descrito na seção anterior.
Pra autenticar no ECR sem guardar nenhuma credencial estática no cluster, usei IRSA (IAM Roles for Service Accounts): registrei o OIDC issuer nativo do próprio cluster EKS como um IAM OIDC Identity Provider, e criei uma role IAM com permissão só de push no repositório específico, confiada apenas à ServiceAccount usada pelo Workflow (restrita pelo sub do token — não é "qualquer pod do cluster consegue"). O Kaniko detecta sozinho que o destino é ECR e usa as credenciais temporárias que o webhook do EKS injeta automaticamente — nenhum docker login explícito em lugar nenhum.
Disparo automático: preferi polling a webhook
Pra não depender de rodar argo submit manualmente toda vez, criei um CronWorkflow que faz polling no Git periodicamente (git ls-remote), compara com o último SHA processado, e dispara um novo build só se algo realmente mudou. Cheguei a considerar Argo Events (webhook do GitHub) — seria instantâneo — mas decidi por polling: não expõe nenhum endpoint novo à internet, é sempre o cluster puxando informação do GitHub, nunca o GitHub entrando no cluster. Pra uma demo, o atraso do intervalo de polling é irrelevante; o trade-off só compensaria trocar se algum dia precisar de disparo instantâneo de verdade.
Um bug real que apareceu enquanto testava esse poller: o próprio commit que o pipeline faz no values.yaml (bump de tag) era detectado como "mudança nova" no polling seguinte, disparando outro build, que fazia outro commit, que disparava outro poll — um loop de auto-disparo (vi dezenas de git-triggered-build-* rodando sozinhas por horas). A correção foi filtrar explicitamente: só considero "mudança de verdade" se o diff entre os dois SHAs tiver algum arquivo sob apps/ além do próprio apps/springboot/values.yaml.
Os perrengues que mais me custaram tempo
Vale registrar os que não eram óbvios de antemão:
Nodes que nunca se registravam no cluster. Depois de aplicar a infra, o pod do Argo Workflows ficava Pending para sempre — o Karpenter criava o NodeClaim, a EC2 subia, mas o node nunca aparecia no cluster (Registered=Unknown, "Node not registered with cluster"). Rastreei até a VPC: as subnets públicas não tinham map_public_ip_on_launch = true, e o endpoint do cluster é só público (endpointPrivateAccess = false) — um node sem IP público numa subnet pública tem rota de saída pro Internet Gateway, mas nenhum jeito de completar a conexão de volta pro control plane. Corrigi isso direto no módulo da VPC (terraform/infra/vpc.tf).
StorageClass que o Auto Mode não cria sozinho. O WorkflowTemplate precisa de um PVC para compartilhar o clone do repositório entre os passos (cada passo do Argo Workflows roda num pod separado — um emptyDir não sobrevive entre eles). A StorageClass gp2 que já vem em qualquer cluster EKS usa um provisioner legado que não roda no Auto Mode — o volume ficava preso para sempre em Pending. O Auto Mode usa um provisioner próprio (ebs.csi.eks.amazonaws.com, com "eks" no meio), e — ao contrário do que a documentação de "block storage capability" sugere — não cria nenhuma StorageClass sozinho; precisei criar a minha (terraform/platform/storageclass.tf).
O botão "Resume" não está onde parece. Testando o gate manual de aprovação, não achava o botão de resumir um Workflow suspenso — ele fica na barra do topo da tela do workflow (ao lado de Retry/Terminate), não dentro do card do node suspenso. Fica a dica para quem for repetir: clique no node primeiro para esse botão aparecer.
Decisões (trade-offs)
Nem tudo que fica funcionando é a escolha "certa" em produção — algumas coisas eu decidi deliberadamente para manter o escopo de uma demo/teste técnico:
-
Sem progressão automática cronometrada nem no Canary (
pause: { duration: ... }) nem entre shards (o ArgoCD tem um recurso nativo para isso,Progressive Syncs/RollingSync) — o requisito pedia aprovação manual explícita nos dois níveis, então não usei nenhum dos dois de propósito.
Como rodar você mesmo
Este resumo cobre só os comandos essenciais. O passo a passo completo — com todos os comandos, o que esperar em cada etapa, e como reconhecer/ resolver os problemas mais comuns (incluindo os perrengues que descrevi acima) — está em TESTE-END-TO-END.md.
Pré-requisitos: Terraform >= 1.4.4, AWS CLI configurado, Helm >= 3.8 (usado internamente pelo provider), permissões IAM para criar VPC/EKS/IAM Roles/ECR, kubectl + plugin do Argo Rollouts, opcionalmente o Argo Workflows CLI e o ArgoCD CLI, e um Personal Access Token do GitHub (escopo repo) pro Argo Workflow conseguir dar git push de volta no repositório.
# Etapa 1 — infraestrutura AWS (VPC, IAM, cluster EKS, ECR)
cd terraform/infra
terraform init
terraform apply -var-file=environment/dev/terraform.tfvars
# Etapa 2 — NodePools, ArgoCD, Argo Rollouts, Argo Workflows, Project e ApplicationSet
cd ../platform
cp environment/dev/secrets.tfvars.example environment/dev/secrets.tfvars
# edite environment/dev/secrets.tfvars com seu github_username/github_token
terraform init
terraform apply \
-var-file=environment/dev/terraform.tfvars \
-var-file=environment/dev/secrets.tfvars
Como destruir
Ordem inversa: platform primeiro (o cluster precisa continuar de pé pro Terraform remover graciosamente os recursos Kubernetes/Helm), infra depois:
cd terraform/platform
terraform destroy \
-var-file=environment/dev/terraform.tfvars \
-var-file=environment/dev/secrets.tfvars
cd ../infra
terraform destroy -var-file=environment/dev/terraform.tfvars
Principais aprendizados
Depois de colocar tudo para funcionar, alguns pontos ficaram especialmente claros:
- EKS Auto Mode reduz bastante o número de componentes que precisamos administrar, mas não elimina a necessidade de entender rede, storage, IAM e RBAC.
- Taint + toleration é fundamental quando o objetivo é isolamento de scheduling, e não apenas direcionamento.
-
ApplicationSet é poderoso, mas o comportamento de
templatepor generator pode ser surpreendente. - Argo Rollouts resolve muito bem o Canary dentro de uma shard; a promoção entre shards é outro problema e precisa ser modelada separadamente.
-
GitOps cria efeitos colaterais interessantes: uma alteração no Git pode deixar várias Applications
OutOfSync, mesmo quando apenas uma delas está autorizada a sincronizar automaticamente. - Pipelines dentro do cluster eliminam a necessidade de configurar OIDC no GitHub para este cenário, mas trazem responsabilidades adicionais de RBAC, storage e segurança.
- Em um ambiente real, exposição pública, NAT único e autenticação simplificada não seriam escolhas aceitáveis.
Próximo desafio: Automatizar a promoção entre shards usando um alarme do CloudWatch como critério de decisão em vez de um clique manual
Automatizar com CloudWatch Alarm depois que a shard-1 chegasse a 100%, o pipeline devia consultar um alarme do CloudWatch — se OK, promove a shard-2 sozinho; se ALARM/INSUFFICIENT_DATA, faz rollback da shard-1.
Em vez de escolher entre "sempre manual" ou "sempre automático" na hora de aplicar o Terraform, resolvi deixar isso como um toggle em tempo de execução, lido direto do values.yaml:
# apps/springboot/values.yaml
promotion:
automaticApproval: false # true = automático (CloudWatch) / false = manual (suspend)
cloudWatchAlarmName: "eks-automode-dev-springboot-shard1-health"
Trocar de modo vira só um commit — nenhum terraform apply novo. Pra isso funcionar, precisei reestruturar o template principal do steps: sequencial que eu tinha para um dag:: a partir do momento em que a shard-1 fica Healthy, o fluxo se ramifica (checa o alarme OU espera aprovação manual, dependendo do toggle) e depois converge de volta num único passo de promoção.
A parte mais delicada foi a lógica de convergência: como só uma das duas ramificações realmente roda (a outra fica Skipped, dependendo do when), o depends do passo de convergência precisou considerar as duas possibilidades — Succeeded ou Skipped — pros dois lados. Registrei isso explicitamente no código como um ponto de atenção: a lógica segue a documentação do Argo Workflows, mas eu não validei os três cenários (automático+OK, automático+ALARM, manual) rodando de ponta a ponta ainda.
Pra testar sem depender de nenhuma métrica de verdade publicada, criei um alarme de teste (cloudwatch-alarm.tf, treat_missing_data = "breaching" — ou seja, sem dado nenhum ele já bloqueia por padrão, nunca promove "por acidente") e forço o estado manualmente:
aws cloudwatch set-alarm-state \
--alarm-name eks-automode-dev-springboot-shard1-health \
--state-value OK \
--state-reason "teste manual" \
--region us-east-1
Conclusão
Este laboratório começou com um desafio relativamente simples: como fazer uma nova versão de uma aplicação chegar ao ambiente de forma gradual, controlada e reversível?
A resposta acabou envolvendo muito mais do que apenas um Deployment no Kubernetes.
A solução construída combina EKS Auto Mode, NodePools isolados, taints e tolerations, ArgoCD, Argo Rollouts, Argo Workflows, Amazon ECR, IRSA e CloudWatch para criar um fluxo de entrega progressiva em duas dimensões:
NOVA VERSÃO
│
▼
┌─────────────┐
│ Shard 1 │
└──────┬──────┘
│
Canary 50%
│
▼
Aprovação
│
▼
100%
│
▼
┌─────────────────┐
│ Gate de promoção│
│ Manual / CW │
└────────┬────────┘
│
▼
┌─────────────┐
│ Shard 2 │
└─────────────┘
Quanto mais componentes automatizamos, mais importante passa a ser entender as fronteiras entre eles.
Kubernetes, AWS, GitOps, IAM, storage, observabilidade e CI/CD não funcionam como peças completamente independentes. Uma decisão tomada em uma camada pode produzir um efeito inesperado em outra.
Este projeto foi construído como laboratório e algumas decisões foram tomadas deliberadamente para manter o escopo controlado.
O principal resultado
No final, o objetivo não era simplesmente criar mais um cluster EKS.
Era experimentar uma forma diferente de pensar sobre deploy:
Deploy tradicional
│
▼
Nova versão
│
▼
Todo ambiente
versus:
Progressive Delivery
│
▼
Pequeno grupo
│
▼
Observabilidade
│
▼
Decisão
│
▼
Mais usuários
│
▼
Decisão
│
▼
Todo ambiente
Esse modelo reduz o blast radius de uma nova versão e cria pontos explícitos onde podemos observar, validar e decidir antes de continuar a promoção.
Foi justamente essa combinação de isolamento + GitOps + progressive delivery + observabilidade que tornou o laboratório interessante.
O código completo está no repositório do projeto; o passo a passo operacional detalhado (comandos exatos, o que esperar em cada tela) está em TESTE-END-TO-END.md.
Se você reproduzir o laboratório, encontrar algum comportamento diferente ou tiver uma abordagem melhor para a promoção entre shards, ficarei muito interessado em conhecer a solução.
Top comments (0)