Ir para o conteúdo
Infra

DevOps para Devs: Docker, CI/CD, Kubernetes e AWS em Produção

Marcos Soares
Atualizado em 
12 minutos de leitura
Ilustração 3D de pipeline vertical com containers Docker, pods Kubernetes e nuvem AWS representando DevOps em produção
Ouça este artigo
0:00DevOps para Devs: Docker, CI/CD, Kubernetes e AWS em Produção--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

O problema real

Você escreve o código, abre o PR, merge na main e... algo acontece. Minutos depois, a feature está em produção. Ou não está, porque o pipeline quebrou e ninguém do time de desenvolvimento sabe ler o log do Kubernetes.

Esse post não é sobre transformar você em SRE. É sobre cobrir o mínimo de infraestrutura que permite a um dev backend diagnosticar problemas, propor melhorias no pipeline e conversar de igual para igual com o time de plataforma. Vou cobrir Docker, CI/CD com GitHub Actions, Kubernetes e os serviços AWS mais comuns em stacks de produção.

Se você já trabalha com Node.js e TypeScript no backend, como discuti no guia de engenharia backend, este post é a continuação natural: o que acontece com seu código depois que ele sai do editor.

Docker: do Dockerfile ao container em produção

A maioria dos devs sabe rodar docker build e docker run. O problema aparece quando a imagem pesa 1.2 GB, demora 8 minutos para buildar e o container roda como root. Vamos resolver os três.

Multi-stage build para Node.js

A ideia do multi-stage é separar o ambiente de build (onde você precisa de devDependencies, compilador TypeScript, etc.) do ambiente de runtime (onde só precisa do JavaScript compilado e das dependências de produção).

Dockerfile
# Estágio 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
 
# Estágio 2: runtime
FROM node:20-alpine AS runner
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]

Três coisas importam aqui. Primeiro, node:20-alpine em vez de node:20: a imagem base cai de ~900 MB para ~130 MB. Segundo, npm ci --omit=dev no estágio de runtime garante que ferramentas como typescript, jest e eslint não vão para produção. Terceiro, USER appuser evita rodar o processo como root, o que é um vetor de ataque trivial em caso de RCE.

.dockerignore que funciona

Sem um .dockerignore adequado, o COPY envia node_modules, .git e artefatos de teste para o daemon. Isso torna o build lento e a imagem desnecessariamente grande.

Text
node_modules
.git
.env*
dist
coverage
*.md
docker-compose*.yml
.github

Testando localmente antes de subir

Bash
# Build com tag versionada
docker build -t minha-api:1.0.0 .
 
# Rodar com variáveis de ambiente
docker run -d \
  --name api-local \
  -p 3000:3000 \
  -e DATABASE_URL="postgresql://user:[email protected]:5432/mydb" \
  minha-api:1.0.0
 
# Verificar logs
docker logs -f api-local
 
# Inspecionar tamanho final
docker images minha-api:1.0.0 --format "{{.Size}}"

O host.docker.internal resolve para o host da máquina no Docker Desktop (macOS e Windows). Em Linux, você precisa adicionar --add-host=host.docker.internal:host-gateway ou usar a rede host.

CI/CD com GitHub Actions

Se você já leu o post sobre CI/CD para Next.js na Vercel, viu o fluxo para apps frontend. Para backend em container, o pipeline muda: precisamos buildar a imagem Docker, rodar testes dentro dela (ou antes) e fazer push para um registry.

Pipeline completo para API Node.js

YAML
name: CI/CD Backend
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm test
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
 
  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

O job test sobe um Postgres real como service container. Isso é melhor que mockar o banco para testes de integração, porque valida queries, migrations e constraints reais. Se você usa PgBouncer em produção, pode adicionar um service de pooler também.

O job build-and-push só roda na main (por causa do if), e tageia a imagem com latest e com o SHA do commit. Tagear com SHA é o que permite rollback preciso no Kubernetes.

Kubernetes: o mínimo viável

Kubernetes é complexo. Não vou fingir que dá para cobrir tudo em 500 palavras. O que vou mostrar é o conjunto mínimo de recursos que coloca sua API no ar com health checks, autoscaling e zero-downtime deploys.

Deployment + Service + HPA

YAML
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: minha-api
  labels:
    app: minha-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: minha-api
  template:
    metadata:
      labels:
        app: minha-api
    spec:
      containers:
        - name: api
          image: ghcr.io/seu-usuario/seu-repo:latest
          ports:
            - containerPort: 3000
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: api-secrets
                  key: database-url
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 15
            periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
  name: minha-api-svc
spec:
  selector:
    app: minha-api
  ports:
    - port: 80
      targetPort: 3000
  type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: minha-api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: minha-api
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

Alguns detalhes que importam:

readinessProbe vs livenessProbe. A readiness diz ao Kubernetes se o pod pode receber tráfego. A liveness diz se o pod está vivo. Se a liveness falha, o kubelet reinicia o container. Se a readiness falha, o Service para de rotear tráfego para aquele pod, mas não o mata. Confundir os dois causa restarts desnecessários ou tráfego enviado para pods que ainda estão inicializando.

resources.requests vs resources.limits. O scheduler do Kubernetes usa requests para decidir em qual node colocar o pod. Os limits são o teto que o container pode consumir. Se você não definir requests, o scheduler não tem informação para distribuir pods de forma equilibrada. Se não definir limits, um pod com memory leak pode derrubar o node inteiro via OOM killer.

Aplicando e verificando

Bash
# Criar o secret (em produção, use Sealed Secrets ou External Secrets Operator)
kubectl create secret generic api-secrets \
  --from-literal=database-url='postgresql://user:pass@db-host:5432/prod'
 
# Aplicar os manifests
kubectl apply -f k8s/deployment.yaml
 
# Verificar rollout
kubectl rollout status deployment/minha-api
 
# Ver pods e seus estados
kubectl get pods -l app=minha-api -o wide
 
# Acompanhar logs de um pod específico
kubectl logs -f deployment/minha-api --tail=100

AWS: os serviços que aparecem em toda stack

Existem dezenas de serviços AWS. Na prática, a maioria das aplicações web usa um subconjunto previsível. A tabela abaixo compara as opções mais comuns para rodar containers.

ServiçoComplexidadeCusto (relativo)Quando faz sentido
ECS FargateMédiaMédioEquipes pequenas, sem expertise em K8s. Serverless containers.
EKSAltaAlto (control plane ~$75/mês + nodes)Já usa K8s, precisa de portabilidade multi-cloud ou ecossistema K8s (Helm, ArgoCD, Istio).
App RunnerBaixaMédio-altoProtótipos, APIs simples. Pouco controle sobre networking.
EC2 + Docker ComposeBaixaBaixoSide projects, MVPs. Não escala sem trabalho manual.

Para a maioria dos times de 3 a 15 devs com uma API backend, ECS Fargate é o ponto de equilíbrio. Você não gerencia nodes, o autoscaling é nativo e a integração com ALB, RDS e Secrets Manager é direta.

Se o time já opera Kubernetes e precisa de ferramentas como ArgoCD para GitOps ou Istio para service mesh, EKS faz sentido. Mas o overhead operacional é real: você precisa manter o cluster atualizado, gerenciar node groups e lidar com a complexidade de networking do VPC CNI plugin.

Infraestrutura básica com AWS CLI

Mesmo que você use Terraform ou CDK no dia a dia, entender os comandos da CLI ajuda a diagnosticar problemas rapidamente.

Bash
# Criar repositório ECR para suas imagens
aws ecr create-repository \
  --repository-name minha-api \
  --image-scanning-configuration scanOnPush=true
 
# Login no ECR
aws ecr get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin \
  123456789012.dkr.ecr.us-east-1.amazonaws.com
 
# Tag e push da imagem
docker tag minha-api:1.0.0 \
  123456789012.dkr.ecr.us-east-1.amazonaws.com/minha-api:1.0.0
 
docker push \
  123456789012.dkr.ecr.us-east-1.amazonaws.com/minha-api:1.0.0
 
# Verificar imagens no repositório
aws ecr describe-images \
  --repository-name minha-api \
  --query 'imageDetails[*].{Tag:imageTags[0],Size:imageSizeInBytes,Pushed:imagePushedAt}' \
  --output table

O scanOnPush=true ativa o scanner de vulnerabilidades do ECR. Ele usa o Clair (ou, mais recentemente, o Amazon Inspector) para analisar CVEs nas camadas da imagem. Não substitui ferramentas como Trivy ou Snyk, mas é uma primeira camada gratuita.

Conectando as peças: do push ao pod

O fluxo completo, do commit ao container rodando em produção, fica assim:

  1. Dev faz push para a main.
  2. GitHub Actions roda testes com Postgres real.
  3. Se os testes passam, builda a imagem Docker com multi-stage.
  4. Faz push da imagem para o registry (GHCR ou ECR).
  5. Atualiza o deployment no Kubernetes (via kubectl set image, Helm upgrade ou ArgoCD sync).
  6. Kubernetes faz rolling update: sobe pods novos, espera readiness, drena pods antigos.

O passo 5 é onde mora a decisão arquitetural. Em times menores, kubectl set image direto no pipeline funciona. Em times maiores, GitOps com ArgoCD é mais auditável: o pipeline atualiza o manifest no repositório de infra, e o ArgoCD detecta o drift e aplica. Discuti padrões arquiteturais similares no post sobre arquitetura hexagonal, e a separação de responsabilidades se aplica aqui também: o pipeline de CI não deveria ter credenciais de cluster.

Erros que eu vejo repetidamente

Não definir health checks. Sem readinessProbe, o Kubernetes envia tráfego para pods que ainda estão conectando ao banco. Sem livenessProbe, pods travados ficam no ar indefinidamente. Se sua API usa connection pooling com PgBouncer, o health check deveria validar a conexão com o pool, não apenas retornar 200.

Secrets em variáveis de ambiente no CI. GitHub Actions secrets são criptografados em repouso, mas ficam visíveis em logs se você fizer echo por acidente. Use --add-mask para valores dinâmicos e nunca logue o output de comandos que manipulam credenciais.

Imagens com tag latest em produção. A tag latest é mutável. Se dois deploys acontecem em sequência rápida, o segundo pode sobrescrever a imagem antes do primeiro terminar o rollout. Use tags imutáveis: SHA do commit, semver, ou timestamp.

Ignorar resource limits. No Node.js especificamente, o V8 aloca heap de forma agressiva. Se o container tem limit de 512 Mi mas o V8 tenta alocar 1.5 GB (o default do --max-old-space-size varia por versão e memória disponível), o OOM killer do kernel mata o processo sem nem gerar um stack trace útil. Defina --max-old-space-size no CMD do Dockerfile para algo compatível com o limit do container. Se você quer entender melhor como o runtime do Node.js gerencia memória, o post sobre lacunas do JavaScript avançado cobre aspectos do V8 que afetam diretamente esse tipo de tuning.

Onde isso se encaixa na carreira

Existe uma tensão real entre "full-stack" e "especialista". Eu acho que dev backend não precisa saber operar um cluster Kubernetes em produção, mas precisa saber ler um manifest, entender por que o pod está em CrashLoopBackOff e propor um Dockerfile que não seja um desastre de segurança. Isso não é DevOps. É literacia operacional.

O mapa do dev web moderno posiciona infra como uma camada adjacente ao backend, e eu concordo com essa visão. Você não precisa dominar Terraform, mas precisa entender o suficiente para não ser um gargalo quando o deploy quebra às 18h de sexta-feira.

Se eu tivesse que priorizar o aprendizado, seria nesta ordem: Docker (porque é local e imediato), CI/CD (porque afeta todo PR que você abre), AWS/GCP básico (porque é onde roda) e Kubernetes (porque a complexidade só se justifica em escala real). Times com menos de 5 microserviços provavelmente estão melhor servidos com ECS Fargate ou até Railway do que com um cluster EKS.

FAQ

Preciso aprender Docker Compose se já uso Kubernetes?

Docker Compose continua útil para desenvolvimento local. Subir sua API, um Postgres, um Redis e um worker com docker compose up é mais rápido e simples que rodar Minikube ou Kind. Em produção, Compose não escala, mas localmente ele resolve. Use Compose para dev, Kubernetes para staging e produção.

GitHub Actions ou GitLab CI para pipelines de container?

Depende de onde seu código mora. Se está no GitHub, Actions tem a vantagem de integração nativa com GHCR e permissões granulares via OIDC. GitLab CI é mais maduro em funcionalidades de CD nativo (environments, review apps) e o registry é integrado ao projeto. Em termos de capacidade, são equivalentes. A escolha geralmente é organizacional, não técnica.

Qual a diferença entre ECS e EKS na prática do dia a dia?

ECS usa conceitos próprios (Task Definitions, Services, Clusters) que não são portáveis. Se amanhã você migrar para GCP, precisa reaprender tudo. EKS usa a API padrão do Kubernetes: seus manifests funcionam em qualquer cluster K8s. Em contrapartida, EKS exige mais conhecimento operacional: upgrades de cluster, gerenciamento de add-ons (CoreDNS, kube-proxy, VPC CNI) e node groups. Para a maioria dos times, ECS Fargate é menos trabalho. Para times que já investiram no ecossistema Kubernetes, EKS evita vendor lock-in.

Preciso de Terraform desde o início?

Não. Para um projeto com um serviço, um banco e um pipeline, clicar no console da AWS ou usar a CLI é mais rápido. Terraform (ou Pulumi, ou CDK) começa a se pagar quando você tem múltiplos ambientes (dev, staging, prod), precisa replicar infraestrutura entre regiões ou tem mais de uma pessoa alterando recursos. Adote IaC quando a dor de gerenciar manualmente superar a dor de aprender a ferramenta.

Como faço rollback se o deploy quebrar em produção?

No Kubernetes, kubectl rollout undo deployment/minha-api reverte para a revisão anterior. É por isso que tagear imagens com o SHA do commit importa: o rollback aponta para uma imagem específica e imutável. No ECS, você pode atualizar o service para apontar para a Task Definition anterior. Em ambos os casos, o rollback é rápido se suas imagens são imutáveis e se você não fez migration destrutiva no banco. Migrações de banco merecem um post inteiro, mas a regra de ouro é: nunca faça uma migration que quebre a versão anterior do código.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Guias de integração relacionados

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.