Pular para o conteúdo
iauaiiauai — portal de tecnologia, IA e Cloud
CloudIniciante

Docker: imagens menores com multi-stage

Reduza sua imagem Docker de 1,2 GB para 80 MB com multi-stage. Dockerfile antes e depois, cache de layer, imagens base e .dockerignore com exemplos medidos.

Por Equipe iauai · 8 de agosto de 2026 · 11 min de leitura

Nesta página

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:18 com npm install + código = 1.24 GB
  • Depois: node:18-alpine com 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:18 com npm install: 1.000+ pacotes + build tools
  • node:18-alpine com 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.

Aprenda jogando

Salto de Container

Pule entre containers que morrem e reiniciam sem ser reagendado pelo orquestrador.

Jogar Salto de Container

Continue lendo