Entendendo Docker Multi-stage Builds
Um dos maiores desafios ao containerizar aplicações é o tamanho final da imagem Docker. Quando você constrói uma imagem de forma tradicional, todos os artefatos do processo de build — dependências de desenvolvimento, compiladores, ferramentas auxiliares — acabam sendo inclusos na imagem final. Isso torna a imagem desnecessariamente grande e aumenta o tempo de download, consumo de banda e até riscos de segurança.
Multi-stage builds resolvem exatamente isso. A ideia é usar múltiplos estágios (stages) em um único Dockerfile: em alguns você faz o build completo com todas as ferramentas necessárias, e apenas no estágio final você copia os artefatos realmente necessários para executar a aplicação. Pense nisso como usar um canteiro de obras para construir uma casa, mas depois remover o canteiro antes de entregar a propriedade.
Conceito Prático de Estágios
Cada estágio em um Dockerfile começa com FROM e recebe um alias opcional via AS. Você pode referenciar um estágio anterior usando COPY --from=nome-do-estagio. Os estágios anteriores ao final são descartados da imagem final, mantendo apenas a última camada. Isso é fundamental para reduzir o tamanho.
Veja um exemplo prático com uma aplicação Go:
# Estágio 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app .
# Estágio 2: Runtime
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/app .
EXPOSE 8080
CMD ["./app"]
Aqui, a imagem golang:1.21-alpine com todas as ferramentas de build fica apenas no estágio builder. A imagem final usa apenas alpine:3.18 (muito mais leve) e copia apenas o executável compilado. O resultado é uma imagem muitas vezes menor.
Vantagens e Casos de Uso
Multi-stage é especialmente útil em linguagens compiladas como Go, Rust, C# e Java. Porém, também funciona bem com JavaScript/Node quando você precisa fazer build de ativos (minificação, bundling). O padrão gera imagens menores, reduz a superfície de ataque removendo ferramentas desnecessárias e acelera deploys. Use múltiplos estágios sempre que seu processo de build for significativamente diferente do runtime.
Imagens Distroless: Minimalismo de Verdade
Distroless é um conceito criado pelo Google que vai além de Alpine. Enquanto Alpine reduz o tamanho usando uma distribuição Linux minimalista, distroless remove até o gerenciador de pacotes e ferramentas do shell. A imagem contém apenas sua aplicação e as bibliotecas C necessárias para executá-la. Literalmente: sem bash, sem apt, sem nada além do essencial.
A filosofia por trás é simples: se você não usa uma ferramenta em produção, por que tê-la? Menos código significa menos vulnerabilidades e menos superfície de ataque. Uma imagem distroless típica pesa entre 5MB a 50MB, enquanto uma Alpine com a mesma aplicação pode pesar 100MB+ e uma imagem tradicional pode chegar a 1GB.
Utilizando Imagens Distroless
O Google mantém imagens base distroless no Google Container Registry. Existem variantes para diferentes linguagens: base, nodejs, python, java, cc (C/C++). Para começar, você as importa normalmente no FROM dentro de um multi-stage.
Aqui está um exemplo com Node.js:
# Estágio 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Estágio 2: Runtime com Distroless
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["index.js"]
Neste exemplo, a imagem final contém apenas Node.js e suas dependências. Sem npm, sem git, sem ferramentas de sistema. Se você precisar debugar em produção, não conseguirá fazer exec e abrir um bash — e isso é exatamente o ponto. Força você a ter logs estruturados e monitoring adequados desde o início.
Quando e Por Que Usar Distroless
Use distroless em produção para aplicações que já estão maduras e bem testadas. Para desenvolvimento e CI/CD intermediário, Alpine ou até imagens maiores fazem sentido, pois você vai precisar de ferramentas para troubleshooting. Distroless é perfeito para microsserviços que rodam em Kubernetes e precisam ser rápidos de deployar e seguros.
Uma desvantagem: debugar é mais difícil. Se a aplicação crashar com um erro silencioso, você não terá shell para investigar. Por isso, logs são críticos. Também requer que sua aplicação funcione completamente em runtime — não há espaço para hacks do tipo "instalar um pacote na mão".
Boas Práticas em Dockerfile Avançado
Agora que você conhece multi-stage e distroless, precisa aprender a combiná-los com práticas sólidas. Um Dockerfile bem escrito não é apenas sobre tamanho; é sobre reprodutibilidade, segurança e performance.
Ordem de Camadas e Cache
Docker constrói imagens em camadas. Cada comando (RUN, COPY, ADD) cria uma camada. Se você mudar um arquivo, todas as camadas depois dele precisam ser reconstruídas. Por isso, a ordem importa. Coloque instruções que mudam raramente antes das que mudam frequentemente.
Exemplo inadequado:
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]
Se você alterar um arquivo .js, npm install será executado novamente — desperdício. Melhor assim:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["npm", "start"]
Aqui, se apenas .js mudar, a camada de npm ci é reutilizada do cache. Ganho de tempo significativo em builds iterativos.
Princípio do Menor Privilégio
Nunca execute sua aplicação como root. Crie um usuário específico:
FROM gcr.io/distroless/nodejs18-debian11
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY --chown=appuser:appuser --from=builder /app/node_modules ./node_modules
COPY --chown=appuser:appuser . .
USER appuser
EXPOSE 3000
CMD ["index.js"]
Isso reduz o risco se sua aplicação for comprometida. Um atacante não terá privilégios root para danificar o host. Com distroless, esse usuário ainda não terá acesso a shell, aumentando a segurança ainda mais.
Saúde da Aplicação e Healthcheck
Inclua HEALTHCHECK para que orquestradores como Kubernetes saibam se seu container está vivo e respondendo:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
CMD ["npm", "start"]
Assim, se a aplicação travar ou ficar irresponsiva, o container será automaticamente marcado como unhealthy. Em produção, isso força um restart ou remoção do container.
Segurança: Scanning e Imagens Conhecidas
Sempre use tags específicas, nunca latest. Escaneie suas imagens em busca de vulnerabilidades usando ferramentas como Trivy ou o próprio Docker Scout:
docker scout cves seu-app:1.0.0
Além disso, use apenas imagens base de fontes confiáveis. Distroless do Google e imagens oficiais de linguagens (no Docker Hub) são seguras. Verifique assinaturas se possível.
Exemplo Completo: Aplicação Python com FastAPI
Aqui está um exemplo real combinando tudo — multi-stage, distroless, boas práticas:
# Estágio 1: Builder
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Estágio 2: Runtime com Distroless
FROM gcr.io/distroless/python3.11-debian11
COPY --from=builder /root/.local /home/appuser/.local
COPY --chown=nobody:nogroup . /app
WORKDIR /app
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
USER nobody
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Este Dockerfile garante: imagem pequena (apenas Python + dependências), sem acesso a shell, sem root, execução direta da aplicação. Uma imagem assim pode pesar 200-300MB em vez de 1GB+ com uma abordagem tradicional.
Variáveis de Ambiente e Configuração
Exporte configurações relevantes mas sensíveis como ARG durante build e ENV em runtime:
FROM gcr.io/distroless/nodejs18-debian11
ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV
ENV LOG_LEVEL=info
COPY . /app
WORKDIR /app
CMD ["index.js"]
Isso permite que você customize a imagem sem reconstruir, passando --build-arg NODE_ENV=development ao fazer build. Em runtime, qualquer consumidor da imagem vê que LOG_LEVEL é configurável.
Conclusão
Você agora domina três pilares essenciais de Docker avançado. Primeiro: multi-stage builds eliminam artefatos desnecessários, reduzindo tamanho de imagem drasticamente e acelerando deploys. Segundo: distroless leva minimalismo ao extremo, removendo toda a complexidade desnecessária e aumentando segurança por simplicidade. Terceiro: boas práticas como ordem de layers, usuários não-root e healthchecks transformam seus containers em production-ready, confiáveis e fáceis de manter.
A combinação desses três conceitos — multi-stage + distroless + boas práticas — é o padrão adotado pelas maiores empresas em produção. Aplique-os gradualmente: comece com multi-stage em suas linguagens compiladas, depois migre para distroless em microsserviços críticos, e finalmente implemente healthchecks e scanning em seu pipeline CI/CD.