DEV Community

Deploy Progressivo no Amazon EKS: Shards Isolados com ArgoCD e Argo Rollouts

Arquitetura de deploy progressivo no Amazon EKS com dois shards isolados, ArgoCD, Argo Rollouts, Argo Workflows, ECR e CloudWatch

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:

  1. Dentro de cada shard: rollout Canary da aplicação.
  2. 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 é:

  1. Um git push altera algo em apps/.
  2. O Argo Workflow detecta a mudança.
  3. O Maven gera o artefato da aplicação.
  4. O Kaniko cria a imagem e publica no ECR.
  5. O pipeline atualiza apps/springboot/values.yaml com a nova tag e faz git push.
  6. O ArgoCD detecta a alteração.
  7. A shard-1 sincroniza automaticamente e inicia o Canary.
  8. Após a aprovação do Canary , a shard-1 chega a 100%.
  9. A shard-2 fica 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
Enter fullscreen mode Exit fullscreen mode

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 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. maven-buildmvn clean package dentro de apps/, gerando o .jar que o Dockerfile só copia (não builda a app de novo).
  3. kaniko-build-push — builda apps/Dockerfile com 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).
  4. update-values — atualiza image.repository/image.tag em apps/springboot/values.yaml e dá git commit+push. É esse push que a Application da 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 template por 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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   │
              └─────────────┘
Enter fullscreen mode Exit fullscreen mode

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)