Docker multi-stage build: imagens menores

Uma imagem Docker de desenvolvimento com todas as dependências de build pode ter 1 GB+. Com multi-stage build, a imagem final de produção tem apenas o que precisa para executar, geralmente 50-150 MB. Menos bytes = pull mais rápido, boot mais rápido, menos surface de ataque e menos custo de armazenamento no registry.

Multi-stage build para Node.js

Separar build de produção em estágios:

dockerfile
# Dockerfile — Node.js com TypeScript

# ── Estágio 1: build ──────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app

# Instalar dependências (separado do COPY . para aproveitar cache):
COPY package*.json ./
RUN npm ci

# Copiar código e compilar TypeScript:
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build

# ── Estágio 2: produção ───────────────────────────────────
FROM node:22-alpine AS production
WORKDIR /app

# Apenas dependências de produção:
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

# Copiar apenas o build compilado do estágio anterior:
COPY --from=builder /app/dist ./dist

# Usuário não-root:
RUN addgroup -S app && adduser -S app -G app
USER app

EXPOSE 3000
CMD ["node", "dist/index.js"]

# Comparar tamanho:
# docker build -t minha-api . && docker images minha-api
# Sem multi-stage:  ~600 MB
# Com multi-stage:  ~120 MB

Multi-stage para Python com Poetry

Imagem Python mínima para APIs FastAPI/Flask:

dockerfile
# Dockerfile — Python com Poetry e FastAPI

# ── Estágio 1: builder ────────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /app

RUN pip install poetry
ENV POETRY_VIRTUALENVS_IN_PROJECT=true

COPY pyproject.toml poetry.lock ./
# Instalar apenas dependências de produção:
RUN poetry install --only=main --no-root

COPY . .

# ── Estágio 2: produção ───────────────────────────────────
FROM python:3.12-slim AS production
WORKDIR /app

# Copiar apenas o virtualenv com dependências instaladas:
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src ./src

# Ativar venv:
ENV PATH="/app/.venv/bin:$PATH"

# Usuário não-root:
RUN useradd --create-home app
USER app

EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

# Alternativa com imagem distroless (ainda menor):
FROM gcr.io/distroless/python3-debian12 AS production
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src ./src
ENV PATH="/app/.venv/bin:$PATH"
CMD ["/app/.venv/bin/uvicorn", "src.main:app", "--host", "0.0.0.0"]

Cache de layers do Docker para builds rápidos

Organizar o Dockerfile para aproveitar o cache ao máximo:

dockerfile
# Regra: o que muda menos frequentemente vai primeiro

# ❌ Antipadrão — COPY . . antes de instalar deps invalida cache sempre:
COPY . .
RUN npm ci          # reexecutado a cada mudança de código!

# ✓ Correto — instalar deps antes de copiar código:
COPY package*.json ./
RUN npm ci          # cacheado enquanto package.json não mudar

COPY . .            # só este layer é invalidado ao mudar código
RUN npm run build

# BuildKit e cache de mount (Docker 23.0+):
# Cache de npm entre builds (não precisa re-baixar packages):
RUN --mount=type=cache,target=/root/.npm \
    npm ci --prefer-offline

# Cache de pip entre builds:
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# Habilitar BuildKit:
# DOCKER_BUILDKIT=1 docker build .
# ou no /etc/docker/daemon.json:
# { "features": { "buildkit": true } }

# Build com cache exportado para CI/CD:
docker buildx build \
  --cache-from type=registry,ref=ghcr.io/usuario/api:cache \
  --cache-to   type=registry,ref=ghcr.io/usuario/api:cache,mode=max \
  -t ghcr.io/usuario/api:latest --push .

Imagens base: alpine vs slim vs distroless

Escolher a imagem base certa para cada caso:

bash
# Comparação de tamanhos base:
# ubuntu:22.04           → 77 MB  (completo, muitas ferramentas)
# debian:bookworm-slim   → 74 MB  (slim = sem docs e extras)
# node:22                → 1.1 GB (node sobre ubuntu)
# node:22-slim           → 230 MB (node sobre debian-slim)
# node:22-alpine         → 58 MB  (node sobre Alpine Linux)
# gcr.io/distroless/nodejs22 → 102 MB (apenas Node.js, sem shell)

# Alpine Linux — mais compacto, usa musl libc:
# Vantagem: imagens muito menores
# Desvantagem: alguns pacotes nativos precisam ser recompilados
# Use quando: Node.js puro, Python sem extensões C complexas

# Slim — Debian sem extras:
# Vantagem: mais compatível que Alpine, ainda compacto
# Use quando: libs nativas (sharp, bcrypt, tensorflow)

# Distroless — sem shell, sem package manager:
# Vantagem: mínima surface de ataque (sem bash = sem exploração de shell)
# Desvantagem: difícil de debugar (exec -it não tem shell)
# Use quando: segurança é prioridade máxima

# Adicionar shell temporário para debug em distroless:
docker run --rm -it --entrypoint sh minha-api:debug  # usar tag com shell para dev
# docker run -it minha-api:prod (sem shell — correto para prod)

Verificar e reduzir tamanho de imagens

Ferramentas para analisar o que engorda as imagens:

bash
# Ver tamanho de imagens:
docker images --format "table {{.Repository}}	{{.Tag}}	{{.Size}}"

# Dive — inspecionar cada layer da imagem:
docker run --rm -it \
  -v /var/run/docker.sock:/var/run/docker.sock \
  wagoodman/dive minha-api:latest

# Ver o que está em cada layer:
docker history minha-api:latest

# Limpar imagens e layers não utilizados:
docker image prune -a          # remove imagens sem tag e intermediárias
docker system prune -a --volumes  # limpeza total (cuidado!)

# Verificar quais arquivos foram copiados:
docker run --rm minha-api:latest find / -name "*.ts" 2>/dev/null
# Se achar .ts: você copiou fonte TypeScript para produção

# .dockerignore — excluir arquivos desnecessários:
node_modules/
src/
*.ts
!dist/
.env*
.git/
tests/
*.test.js
coverage/

Não quer configurar manualmente?

Implante o VPS para Docker em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

Ligeiramente, você não pode executar docker exec e ter todas as ferramentas de build. Solução: manter uma tag de desenvolvimento (FROM ... AS dev) com todas as ferramentas. Para debug de produção específico: adicionar temporariamente um estágio debug com shell (FROM production AS debug; RUN apk add bash). Em Kubernetes: usar kubectl debug com imagem efêmera.

Alpine: imagens menores (~60 MB), mas alguns módulos nativos (sharp, bcrypt, canvas) precisam ser recompilados para musl libc, geralmente funciona mas pode dar surpresas. Debian Slim: ~230 MB, mais compatível com módulos binários. Regra prática: comece com Alpine e só troque para Slim se algum módulo nativo falhar.

Secrets em ARG ou ENV ficam visíveis em docker history, mesmo que você os "delete" em um RUN posterior: a layer com o secret existe. Solução: usar --mount=type=secret no RUN (BuildKit): RUN --mount=type=secret,id=token TOKEN=$(cat /run/secrets/token) npm install. O secret nunca aparece no histórico de layers.

Use --cache-from e --cache-to com o registry: build, push para :cache, próxima build usa esse cache. Alternativamente: GitHub Actions tem actions/cache para o cache do BuildKit local. Para Node.js: separar COPY package*.json e RUN npm ci em layers distintos, o cache de layers funciona se os arquivos não mudaram.

Mínima, Alpine usa musl libc em vez de glibc, que pode ser ligeiramente mais lento em algumas operações (especialmente malloc intensivo). Para aplicações web típicas: diferença < 1%, imperceptível. A diferença real é no tamanho da imagem e no tempo de pull/deploy.