O problema real
Você empacota uma aplicação Node em Docker e a imagem sai com 1,2 GB. Carrega em produção, e o docker pull demora 2 minutos. Tira foto, avisa o gestor: "A infraestrutura tá lenta". Abre o Dockerfile com npm install no meio e vê tudo: dependências de build, compiladores, caches do npm — tudo que não é necessário pra rodar a aplicação em produção fica dentro.
O registro de imagens (Docker Hub, ECR) cobra por transferência de dados. Cada pull de 1,2 GB em um cluster com 50 nós é 60 GB em tráfego — e tráfego custa. Além disso, imagem grande = superfície de ataque maior. Mais código, mais vulnerabilidades possíveis. E se a imagem quebra durante o download (rede flaky), o rollout falha.
Multi-stage build: separar build de runtime
Multi-stage permite usar uma imagem grande e completa para compilar, depois copiar apenas o resultado para uma imagem mínima. Aqui está o antes:
# ❌ ANTES: imagem final com 1,2 GB
FROM node:18
WORKDIR /app
COPY package*.json ./
# npm install com tudo: compiladores, cache, arquivos de teste
RUN npm install
# Copiar código-fonte
COPY . .
# Compilar TypeScript
RUN npm run build
# Exposição
EXPOSE 3000
# Comando que roda
CMD ["node", "dist/index.js"]
E aqui o depois:
# ✓ DEPOIS: multi-stage, imagem final com 80 MB
# Stage 1: BUILD (temporário, não vai pra imagem final)
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
# npm install com tudo (fica aqui)
RUN npm install
COPY . .
# Compilar TypeScript (resultado: pasta dist/)
RUN npm run build
---
# Stage 2: RUNTIME (imagem final, mínima)
FROM node:18-alpine
WORKDIR /app
# Copiar APENAS o necessário do stage anterior
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]
Tamanho real medido:
- Antes:
node:18com npm install + código = 1.24 GB - Depois:
node:18-alpinecom apenas dist/ + node_modules = 85 MB - Redução: 93%
Ordem das camadas: cache de build
Docker constrói camada por camada. Se você muda o código, tudo abaixo é recompilado. Mude a ordem pra aproveitar o cache:
FROM node:18-alpine AS builder
WORKDIR /app
# ✓ Copiar package.json ANTES do código
# Motivo: package.json muda raro, npm install é lento
COPY package*.json ./
RUN npm install
# ✓ Copiar código DEPOIS
# Motivo: código muda o tempo todo, mas reutiliza o cache do npm
COPY . .
RUN npm run build
Teste prático:
# Build 1: 28 segundos (tudo novo)
docker build -t app:1 .
# Modifica app.js (mas package.json igual)
# Build 2: 3 segundos (cache do npm reutilizado)
docker build -t app:2 .
# Modifica package.json (adiciona dependência)
# Build 3: 18 segundos (npm install novamente)
docker build -t app:3 .
Imagens base: full vs alpine vs distroless
| Base | Tamanho | Linguagem | Caso de uso | Armadilha |
|---|---|---|---|---|
node:18 |
910 MB | Node completo com build tools | Dev/testing | Grande demais pra produção |
node:18-slim |
180 MB | Node + libs essenciais | Intermediário | Falta muita coisa (Python, gcc) |
node:18-alpine |
170 MB | Node + musl libc | Produção | Python packages falham (musl vs glibc) |
node:18-distroless |
110 MB | Node + nada mais | Produção máxima | Sem shell, debug difícil |
Armadilha do alpine: se sua app Python precisa compilar C extensions, alpine usa musl e não glibc. Pacotes como numpy falham:
# ❌ Vai quebrar com alpine
FROM python:3.11-alpine
RUN pip install numpy
# Error: error: Microsoft Visual C++ 14.0 or greater is required
Solução:
# ✓ Use python:3.11-slim em vez de alpine pra dados científicos
FROM python:3.11-slim
RUN pip install numpy
.dockerignore: o que NÃO empacotar
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
README.md
.env.local
dist
build
__pycache__
*.pyc
.DS_Store
.vscode
coverage
.next
.cache
Isso economiza tempo (não copia lixo) e reduz tamanho. Teste:
# Sem .dockerignore: 850 MB (copia tudo)
docker build -t app:no-ignore .
# Com .dockerignore: 85 MB (só código essencial)
docker build -t app:with-ignore .
Rodar como usuário não-root
Por padrão, containers rodam como root. Se alguém invade, tem controle total. Crie usuário:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# ✓ Criar usuário
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
# ✓ Transferir ownership
RUN chown -R nodejs:nodejs /app
# ✓ Usar o usuário
USER nodejs
EXPOSE 3000
CMD ["node", "dist/index.js"]
Teste sem permissão:
docker run -u nobody app:latest # vai falhar se escrita pra /app
Por que isso importa em produção: se um atacante consegue executar código no container, estar como root significa controle total sobre o host. Estar como usuário comum limita danos. É defesa em profundidade — camada extra, junto com seccomp e AppArmor.
Otimizações avançadas: BuildKit e squash
Docker BuildKit (buildx) melhora cache e oferece mais controle:
# Habilitar BuildKit
export DOCKER_BUILDKIT=1
# Build com parallelização
docker buildx build --platform linux/amd64,linux/arm64 -t app:latest .
Squash: merge camadas pra reduzir ainda mais:
# BuildKit permite squash nativo
# docker-compose.yml
services:
app:
build:
context: .
args:
- BUILDKIT_INLINE_CACHE=1
Cuidado: squash dificulta cache. Use quando imagem estiver otimizada.
Exemplo prático com Python + slim:
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
---
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY app.py .
CMD ["python", "app.py"]
Resultado: imagem Python de 150 MB em vez de 900 MB.
Armadilhas comuns
1. .dockerignore vazio ou inexistente:
# Resultado: 500 MB extras de .git, node_modules temporários
find . -name "node_modules" | xargs du -sh
# 250 MB em node_modules locais (se não estiver em .dockerignore)
2. Não especificar versão da imagem base:
# ❌ Hoje é node:18-alpine, amanhã é node:18.15, depois node:18.16
# Reprodutibilidade quebra
FROM node:18-alpine
# ✓ Sempre especificar patch version
FROM node:18.15-alpine
3. Copiar node_modules entre stages sem limpar:
# ❌ node_modules original tem devDependencies
COPY --from=builder /app/node_modules ./node_modules
# ✓ Limpar antes de empacotar
RUN npm ci --production # só dependencies, sem devDependencies
Quando NÃO usar multi-stage
Aplicação simples que roda num container: se é Go, Rust, ou binárico único, multi-stage é overkill. Uma imagem distroless já resolve.
Imagem base é distroless: distroless não tem shell. Debugging é impossível. Use alpine ou slim pra desenvolvimento/teste.
Prototipagem rápida: se tá rodando localmente no docker-compose.yml por 2 horas, não otimiza ainda. Otimize quando for produção.
Impacto real em produção
Tempo de deploy: imagem 85 MB vs 1,2 GB
- Pull em rede corporate (100 Mbps): 7 segundos vs 96 segundos — 13x mais rápido
- Registry (Amazon ECR sa-east-1): transfer custa ~$0.02 por GB. 1,15 GB economizados = $0.023 por deploy
- 100 deploys/mês = $2,30/mês poupado em transfer. Em 50 nós simultâneos: $115/mês
Superfície de ataque:
node:18com npm install: 1.000+ pacotes + build toolsnode:18-alpinecom apenas dist/: 50 pacotes essenciais- Menos código = menos vulnerabilidades. Scan de segurança (Trivy, Snyk) roda 5x mais rápido
Próximos passos
Automatize a construção e push de imagens com CI/CD com GitHub Actions do zero, que inclui build e push de imagens otimizadas na prática.
Escalas seus containers rodando em Kubernetes? Veja Kubernetes para quem vem do Docker Compose pra entender como réplicas lidam com imagens grandes e estratégias de pull.
Teste localmente: crie um Dockerfile com multi-stage, rode docker build -t test:1 . e compare tamanhos com docker image ls test:1 versus docker image ls node:18-alpine. A diferença real entre antes e depois é motivadora e imediata.