Containers Avançado
Internals, Build & Otimização
Um aprofundamento em como containers realmente funcionam por dentro — OverlayFS, Copy-on-Write, namespaces e cgroups — e como aplicar isso na prática: builds multi-stage e cache avançado com BuildKit, drivers de rede e ECS Service Connect, Task Definitions e Capacity Providers, e técnicas de redução de imagem com lifecycle policies e scanning no ECR.
Overlay Filesystem & Copy-on-Write
Como o Docker armazena dados: OverlayFS
O kernel Linux expõe uma abstração chamada Union Mount — empilhar múltiplos diretórios em uma única visão coesa. O Docker usa a implementação moderna disso: OverlayFS (overlay2 driver).
Quando você faz docker run, o Docker monta quatro camadas:
- lowerdir → camadas da imagem (read-only, compartilhadas entre containers)
- upperdir → camada de escrita exclusiva deste container (read-write)
- workdir → diretório de trabalho interno do OverlayFS (bookkeeping)
- merged → o que o processo dentro do container enxerga (union das anteriores)
/var/lib/docker/overlay2/
├── <layer-sha>/
│ ├── diff/ ← conteúdo real da camada
│ ├── link ← symlink curto (otimização de kernel)
│ └── lower ← referência à camada inferior
└── <container-id>/
├── diff/ ← upperdir (writes do container)
├── merged/ ← mount point final
└── work/ ← workdir do OverlayFS
Copy-on-Write (CoW)
Arquivos das camadas inferiores são compartilhados entre todos os containers. Só quando um container modifica um arquivo é que uma cópia privada é criada no upperdir dele.
# 3 containers rodando a mesma imagem nginx (200MB)
# Uso real em disco: ~200MB + delta de cada container
# SEM CoW seria: 3 × 200MB = 600MB
Quando o container escreve em /etc/nginx/nginx.conf: OverlayFS intercepta a syscall, copia o arquivo para o upperdir do container, a modificação acontece só na cópia privada, e a imagem original fica intacta.
Por que isso importa em produção
Qualquer escrita no upperdir some quando o container morre. No ECS Fargate, o upperdir existe só enquanto a Task existe. Para persistência: EFS mount ou S3 via código.
# Nunca confie no upperdir para dados persistentes
# Logs, uploads, checkpoints LangGraph → sempre em volume
VOLUME ["/app/checkpoints"]
Namespaces & cgroups: o que realmente isola containers
Containers não são VMs — são processos isolados
Um container é literalmente um processo Linux com ilusões de isolamento criadas pelo kernel. Dois mecanismos fazem isso: namespaces (visibilidade) e cgroups (recursos).
Namespaces — isolamento de visibilidade
| Namespace | O que isola | Exemplo |
|---|---|---|
pid | Árvore de processos | PID 1 dentro ≠ PID 1 do host |
net | Stack de rede | Interface eth0 própria, rotas próprias |
mnt | Sistema de arquivos | O que o container enxerga como / |
uts | Hostname | Container tem seu próprio hostname |
ipc | IPC (semáforos, filas) | Não acessa IPC do host |
user | UIDs/GIDs | UID 0 pode não ser root no host |
cgroup | Visão dos cgroups | Enxerga só seus próprios limites |
time | Clock do sistema | Pode ter clock offset diferente |
# Inspecionar os namespaces de um processo
ls -la /proc/<pid>/ns/
# Entrar no namespace de um container manualmente
nsenter -t <pid> --net --pid -- bash
cgroups — isolamento de recursos
docker run --memory=512m --cpus=0.5 minha-app
# O Docker cria cgroups em:
/sys/fs/cgroup/memory/docker/<container-id>/memory.limit_in_bytes
/sys/fs/cgroup/cpu/docker/<container-id>/cpu.cfs_quota_us
{
"containerDefinitions": [{
"memory": 512, // hard limit via cgroup → OOM kill se ultrapassar
"memoryReservation": 256, // soft limit para o scheduler do ECS
"cpu": 512 // 512 units = 0.5 vCPU
}]
}
O processo PID 1 e por que importa
# ❌ Ruim: shell vira PID 1, não repassa SIGTERM
CMD python app.py
# ✅ Bom: python vira PID 1 diretamente (exec form)
CMD ["python", "app.py"]
# ✅ Melhor: tini como init mínimo
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["python", "app.py"]
Se o container não responde ao SIGTERM, o ECS espera o stopTimeout (padrão 30s) e manda SIGKILL — fatal para checkpoints em progresso. Use linuxParameters.initProcessEnabled: true na Task Definition para habilitar init process via Fargate sem precisar do tini no Dockerfile.
Multi-stage builds
O problema que multi-stage resolve
Imagens de produção carregam peso desnecessário: compiladores, ferramentas de build, arquivos de teste, dependências de dev. Multi-stage separa o ambiente de build do artefato final.
# ── Stage 1: Builder ──────────────────────────────────
FROM python:3.12 AS builder
WORKDIR /build
RUN pip install poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt --without dev -o requirements.txt
# ── Stage 2: Runtime ──────────────────────────────────
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /build/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
RUN useradd -r -u 1001 appuser
USER appuser
CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0"]
Imagem vai de ~1.2GB para ~180MB. Cold start no ECS Fargate pode ser 2–3 minutos mais rápido.
Padrão para Go — imagem scratch
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 → binário estático, sem dependência de libc
RUN CGO_ENABLED=0 GOOS=linux go build -o /app ./cmd/server
# Imagem final: SCRATCH — literalmente vazia
FROM scratch
COPY --from=builder /app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]
Imagem resultante: ~8MB. Nenhuma shell, nenhum utilitário, só o binário.
Targets nomeados — builds condicionais
FROM python:3.12-slim AS base
RUN pip install -r requirements.txt
FROM base AS dev
RUN pip install pytest pytest-asyncio
CMD ["pytest"]
FROM base AS production
COPY src/ ./src/
CMD ["uvicorn", "src.main:app"]
# CI roda testes
docker build --target dev -t app:test .
docker run app:test
# Deploy usa produção
docker build --target production -t app:prod .
BuildKit: cache avançado e paralelismo
BuildKit — o novo engine de build
BuildKit (padrão desde Docker 23) traz: DAG paralelo (stages independentes buildam em paralelo), cache mounts persistentes entre builds, secret mounts que nunca ficam em layers, e SSH forwarding para repos privados.
# Habilitar em versões antigas do Docker
DOCKER_BUILDKIT=1 docker build .
Cache mounts — o recurso mais subutilizado
# syntax=docker/dockerfile:1
# ❌ pip baixa tudo do zero a cada build
RUN pip install -r requirements.txt
# ✅ cache mount: pip cache persiste no host entre builds
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# npm
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline
# cargo (Rust)
RUN --mount=type=cache,target=/usr/local/cargo/registry \
cargo build --release
Rebuild após mudar só o código (sem mudar deps) vai de 3 min para 8s.
Secret mounts — nunca exponha credentials em layers
# ❌ NUNCA — o token fica gravado na layer para sempre
RUN pip install git+https://token@github.com/org/pkg.git
# ✅ Secret mount: disponível só durante o RUN, nunca gravado
RUN --mount=type=secret,id=gh_token \
pip install git+https://$(cat /run/secrets/gh_token)@github.com/org/pkg.git
docker build --secret id=gh_token,src=~/.github_token .
Cache no CI — ECR como cache backend
docker buildx build \
--cache-to type=registry,ref=<account>.dkr.ecr.<region>.amazonaws.com/cache:buildcache,mode=max \
--cache-from type=registry,ref=<account>.dkr.ecr.<region>.amazonaws.com/cache:buildcache \
--push -t <account>.dkr.ecr.<region>.amazonaws.com/app:latest .
Exporta cache de todos os stages, não só do final. Crítico para multi-stage builds em CI.
.dockerignore — o que a maioria esquece
.git
.github
**/__pycache__
**/*.pyc
**/.pytest_cache
.env*
tests/
docs/
.venv
node_modules
O contexto de build é enviado ao daemon antes do build começar. Um .git de 500MB enviado toda vez é um desperdício visível no output "Sending build context".
Drivers de rede: bridge, host, overlay
Como o Docker cria redes
Quando o Docker inicia, ele cria uma interface virtual docker0 no host — um switch virtual Layer 2. Containers se conectam via veth pairs (virtual ethernet).
Host:
├── eth0 (interface física, IP: 192.168.1.10)
├── docker0 (bridge virtual, IP: 172.17.0.1)
│ ├── veth3a4f → container1 (eth0: 172.17.0.2)
│ └── veth8b2c → container2 (eth0: 172.17.0.3)
└── iptables rules (NAT: container → internet)
Bridge network — default vs user-defined
docker network create minha-rede
docker run --name redis --network minha-rede redis
docker run --network minha-rede app # usa "redis" como hostname — DNS interno
| Default bridge | User-defined bridge | |
|---|---|---|
| DNS por nome | ❌ | ✅ |
| Isolamento | Todos juntos | Isolado por rede |
| Inspeção | Limitada | docker network inspect |
Sem DNS por nome e sem isolamento real. Sempre crie redes nomeadas.
awsvpc — o modo de rede do ECS/Fargate
Cada Task ECS recebe um ENI próprio, com IP privado da VPC. Security Groups por Task, VPC Flow Logs por Task, funciona com Service Connect e Cloud Map.
{
"networkMode": "awsvpc",
"networkConfiguration": {
"awsvpcConfiguration": {
"subnets": ["subnet-xxx"],
"securityGroups": ["sg-xxx"],
"assignPublicIp": "DISABLED"
}
}
}
Service Mesh — além do networking básico
Para mTLS entre serviços, circuit breaking, rate limiting e tracing distribuído no nível de rede — entra o service mesh. No ECS: AWS App Mesh (Envoy sidecar) ou ECS Service Connect. No K8s: Istio, Linkerd, Cilium.
Request → [Envoy sidecar] → [App container]
↓
xDS control plane (App Mesh / Istio)
(rotas, retries, timeouts, mTLS)
ECS Service Connect na prática
Service Connect vs Service Discovery (Cloud Map)
| Cloud Map | Service Connect | |
|---|---|---|
| DNS | ✅ (Route 53) | ✅ (localhost-like) |
| Load balancing | Client-side | Envoy proxy |
| Retry automático | ❌ | ✅ |
| Timeout config | ❌ | ✅ |
| Métricas p50/p99 | ❌ | ✅ CloudWatch |
| mTLS | ❌ | Em roadmap |
Configuração de Service Connect
{
"serviceConnectConfiguration": {
"enabled": true,
"namespace": "meu-cluster.local",
"services": [{
"portName": "http",
"clientAliases": [{
"port": 8080,
"dnsName": "agente-orchestrator"
}]
}]
}
}
# Agente A chama agente B — sem descoberta de IP manual
import httpx
# "agente-orchestrator" resolve via Envoy local
response = httpx.post(
"http://agente-orchestrator:8080/invoke",
json=payload,
timeout=30.0
)
Retry em 503, timeout configurável, métricas de latência p50/p99 no CloudWatch — sem nenhum código extra.
gRPC entre agentes — appProtocol crítico
{
"portMappings": [{
"name": "grpc",
"containerPort": 50051,
"protocol": "tcp",
"appProtocol": "grpc" // request-level LB para HTTP/2 — crítico
}]
}
Task Definitions em profundidade
Task Definition é imutável e versionada
Cada register-task-definition cria uma nova revisão. Você nunca edita — só cria novas. Isso garante auditoria e rollback trivial.
# Rollback imediato para revisão anterior
aws ecs update-service \
--cluster prod \
--service agente-orchestrator \
--task-definition agente-orchestrator:42
Anatomy completa
{
"family": "agente-orchestrator",
"taskRoleArn": "arn:aws:iam::xxx:role/AgentTaskRole",
"executionRoleArn": "arn:aws:iam::xxx:role/ECSExecutionRole",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "1024", "memory": "2048",
"containerDefinitions": [{
"name": "orchestrator",
"image": "xxx.dkr.ecr.region.amazonaws.com/orchestrator:sha-abc123",
"essential": true,
"portMappings": [{ "name": "http", "containerPort": 8080, "appProtocol": "http" }],
"environment": [
{ "name": "ENVIRONMENT", "value": "production" }
],
"secrets": [
{ "name": "ANTHROPIC_API_KEY", "valueFrom": "arn:aws:secretsmanager:...:anthropic-key" },
{ "name": "REDIS_URL", "valueFrom": "arn:aws:secretsmanager:...:redis-url" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/agente-orchestrator",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
},
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"],
"interval": 30, "timeout": 5, "retries": 3, "startPeriod": 60
},
"stopTimeout": 120,
"linuxParameters": { "initProcessEnabled": true }
}]
}
taskRole vs executionRole — confusão clássica
| Role | Quem usa | Para quê |
|---|---|---|
| executionRole | ECS Agent (setup) | Pull ECR, buscar secrets, enviar logs para CloudWatch |
| taskRole | Código no container (runtime) | S3, DynamoDB, Bedrock, SQS — assinar requests SigV4 |
ECS Agent → executionRole → [pull ECR, fetch secrets] → container inicia
Container → taskRole → [S3.getObject, sqs.sendMessage, bedrock.invoke]
stopTimeout — crítico para graceful shutdown
O ECS envia SIGTERM e aguarda stopTimeout segundos antes de SIGKILL. Para agentes com checkpointing você precisa de tempo para: (1) parar de aceitar novos requests — drain do ALB leva ~30s; (2) finalizar checkpoint no Redis/DynamoDB; (3) commitar transações abertas. Defina stopTimeout maior que o deregistration_delay do target group.
Capacity Providers: Fargate vs EC2 Auto Scaling
Fargate vs FARGATE_SPOT
{
"capacityProviderStrategy": [
{
"capacityProvider": "FARGATE", // on-demand
"weight": 1,
"base": 2 // mínimo 2 tasks garantidas aqui
},
{
"capacityProvider": "FARGATE_SPOT", // até 70% mais barato
"weight": 4,
"base": 0
}
]
}
Agentes com estado crítico sem checkpoint, serviços com SLA rígido, e tasks com stopTimeout alto que não conseguem salvar estado em 2 min — o aviso de interrupção do SPOT é só 2 min via SIGTERM.
EC2 Auto Scaling com Managed Scaling
{
"autoScalingGroupProvider": {
"autoScalingGroupArn": "arn:aws:autoscaling:...",
"managedScaling": {
"status": "ENABLED",
"targetCapacity": 80, // 80% utilizado, 20% de folga para novas tasks
"minimumScalingStepSize": 1,
"maximumScalingStepSize": 10
},
"managedTerminationProtection": "ENABLED" // nunca mata instância com task rodando
}
}
Service Auto Scaling — por CPU
aws application-autoscaling put-scaling-policy \
--service-namespace ecs \
--resource-id service/prod/agente-orchestrator \
--scalable-dimension ecs:service:DesiredCount \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 60.0,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "ECSServiceAverageCPUUtilization"
},
"ScaleInCooldown": 300,
"ScaleOutCooldown": 60
}'
Service Auto Scaling — por SQS queue depth
Para agentes triggerados por filas — escala proporcionalmente ao backlog, não à CPU:
{
"CustomizedMetricSpecification": {
"MetricName": "ApproximateNumberOfMessagesVisible",
"Namespace": "AWS/SQS",
"Dimensions": [{ "Name": "QueueName", "Value": "agent-tasks" }],
"Statistic": "Average"
},
"TargetValue": 10 // ~10 mensagens por task rodando
}
Reduzindo tamanho de imagem: do básico ao extremo
Por que tamanho importa em produção
- Pull time: nova Task precisa baixar a imagem antes de iniciar. 1GB = minutos de cold start.
- Superfície de ataque: cada binário desnecessário é um vetor de ataque em potencial.
- Scan de vulnerabilidades: menos packages = menos CVEs para gerenciar.
Hierarquia de base images
| Imagem | Tamanho | Quando usar |
|---|---|---|
ubuntu:22.04 | ~80MB | Raramente em produção |
python:3.12 | ~1.0GB | Só em desenvolvimento local |
python:3.12-slim | ~130MB | ✅ Ideal para Python em produção |
python:3.12-alpine | ~55MB | ⚠️ Cuidado com musl libc |
distroless/python3 | ~50MB | ✅ Produção com segurança máxima |
scratch | 0MB | Só para binários estáticos (Go) |
Alpine: a armadilha do musl libc
Alpine usa musl ao invés de glibc. Muitas libs Python com extensões C (numpy, pandas, cryptography) são compiladas contra glibc. Em Alpine, você precisa compilar do zero — o que anula o ganho de tamanho.
# ❌ Alpine + numpy = dor de cabeça
FROM python:3.12-alpine
RUN pip install numpy # pode falhar ou demorar muito (compila C)
# ✅ slim é o melhor custo-benefício para Python
FROM python:3.12-slim
RUN pip install numpy # wheels pré-compilados, funciona sempre
Distroless — o padrão de produção seguro
Sem shell, sem package manager, sem utilitários de sistema. Um atacante que invade o container não tem /bin/bash para trabalhar.
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt
COPY src/ ./src/
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /install /usr/local
COPY --from=builder /app/src /app/src
WORKDIR /app
CMD ["src/main.py"]
Técnicas de redução de camadas
# ❌ Cada RUN cria uma camada — apt cache fica gravado
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# ✅ Uma camada, cache limpo
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt
Resultado típico de otimização
| Estratégia | Tamanho |
|---|---|
python:3.12 base sem cuidado | ~1.4GB |
python:3.12-slim + multi-stage | ~180MB |
slim + multi-stage + --no-cache-dir | ~140MB |
| distroless + multi-stage | ~95MB |
A diferença de 1.4GB vs 95MB pode ser 3–4 minutos a mais para uma nova Task escalar — crítico quando o Capacity Provider precisa provisionar nova capacidade.
ECR: lifecycle policies e image scanning
Lifecycle policies — evitar acumular imagens
{
"rules": [
{
"rulePriority": 1,
"description": "Manter últimas 10 imagens tagged com sha-",
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["sha-"],
"countType": "imageCountMoreThan",
"countNumber": 10
},
"action": { "type": "expire" }
},
{
"rulePriority": 2,
"description": "Manter latest, main, staging",
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["latest", "main", "staging"],
"countType": "imageCountMoreThan",
"countNumber": 3
},
"action": { "type": "expire" }
},
{
"rulePriority": 3,
"description": "Remover untagged após 7 dias",
"selection": {
"tagStatus": "untagged",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 7
},
"action": { "type": "expire" }
}
]
}
Estratégia de tagging — nunca use só :latest em produção
# ❌ :latest é ambíguo — qual versão exatamente?
docker push myrepo/app:latest
# ✅ Tag imutável com SHA do commit
GIT_SHA=$(git rev-parse --short HEAD)
docker build -t myrepo/app:sha-${GIT_SHA} .
docker push myrepo/app:sha-${GIT_SHA}
# Atualiza :latest também (para referência humana)
docker tag myrepo/app:sha-${GIT_SHA} myrepo/app:latest
docker push myrepo/app:latest
Na Task Definition do ECS, sempre referencie pela tag SHA — não por :latest. Isso garante rollback determinístico: agente-orchestrator:42 sempre aponta para o mesmo binário.
ECR Image Scanning
# Scan automático no push
aws ecr put-image-scanning-configuration \
--repository-name minha-app \
--image-scanning-configuration scanOnPush=true
# Consultar resultados
aws ecr describe-image-scan-findings \
--repository-name minha-app \
--image-id imageTag=sha-abc123
Enhanced scanning (via Inspector V2): análise contínua, não só no push. Detecta CVEs em packages do OS e em libs Python/Node. Integra com EventBridge para alertas automáticos quando novas CVEs aparecem em imagens já deployadas.