Multi-stage builds: imagens menores e mais seguras

1. O Problema das Imagens Monolíticas

1.1. Imagens inchadas: dependências de build vs. dependências de runtime

No desenvolvimento tradicional de Dockerfiles, é comum usar uma única imagem base que contém tudo o que é necessário para compilar e executar a aplicação. Isso resulta em imagens que carregam compiladores, bibliotecas de desenvolvimento, ferramentas de depuração e outros artefatos que são úteis apenas durante o processo de build, mas completamente desnecessários em produção.

# Dockerfile tradicional - Problema: tudo em uma única imagem
FROM node:18

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

EXPOSE 3000
CMD ["npm", "start"]

Essa abordagem gera imagens que podem facilmente ultrapassar 1GB, sendo que apenas uma fração desse conteúdo é realmente necessária para executar a aplicação.

1.2. Aumento da superfície de ataque

Cada biblioteca, ferramenta ou binário adicional na imagem representa um potencial vetor de ataque. Compiladores como gcc, interpretadores como bash, e utilitários como curl ou wget são frequentemente explorados em ataques. Uma imagem que contém apenas o essencial para rodar a aplicação reduz drasticamente a superfície de ataque.

1.3. Impacto no desempenho no Kubernetes

Em clusters Kubernetes, imagens maiores impactam diretamente:

  • Tempo de pull: nós precisam baixar a imagem inteira antes de iniciar o container
  • Armazenamento: cada nó armazena múltiplas versões de imagens
  • Escalabilidade: durante escalonamento horizontal, o tempo de inicialização aumenta proporcionalmente ao tamanho da imagem

2. Conceitos Fundamentais do Multi-stage Build

2.1. Estrutura básica: múltiplos blocos FROM

O multi-stage build permite usar múltiplas instruções FROM em um único Dockerfile. Cada FROM inicia um novo estágio, que pode usar uma imagem base diferente.

# Estrutura básica de multi-stage build
FROM imagem-base-build AS build-stage
# ... comandos de build

FROM imagem-base-producao AS final-stage
# ... apenas o necessário para execução

2.2. Copiando artefatos entre estágios com COPY --from=

O comando COPY --from=nome-do-estagio permite copiar arquivos específicos de um estágio anterior para o estágio atual, sem carregar todo o contexto do estágio de origem.

COPY --from=build-stage /app/dist /app

2.3. Estágio final: imagem de produção enxuta

O último estágio deve usar imagens base mínimas como alpine (5MB), slim ou as imagens distroless do Google (que não possuem shell, package manager ou qualquer utilitário).

3. Exemplo Prático: Aplicação Go

3.1. Estágio de build

# Dockerfile para aplicação Go com multi-stage build
FROM golang:1.21 AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server

3.2. Estágio final com imagem scratch

FROM scratch AS final

COPY --from=builder /app/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

A imagem scratch é o menor ponto de partida possível — um sistema de arquivos vazio. Para aplicações Go compiladas estaticamente, é a escolha ideal.

3.3. Comparação de tamanho

# Imagem tradicional (Go SDK completo)
REPOSITORY    TAG       SIZE
myapp         latest    812MB

# Imagem multi-stage (scratch)
REPOSITORY    TAG       SIZE
myapp         latest    12MB

# Redução de aproximadamente 98%

4. Exemplo Prático: Aplicação Node.js

4.1. Estágio de build com TypeScript

FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci

COPY src/ ./src/
RUN npm run build

4.2. Estágio de produção

FROM node:18-alpine AS production

WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

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

4.3. Imagens base slim no estágio final

Usar node:18-alpine no estágio final reduz a imagem para aproximadamente 120MB, contra mais de 900MB da imagem node:18 completa. A instrução USER node garante execução como não-root, aumentando a segurança.

5. Estratégias de Segurança com Multi-stage Builds

5.1. Remoção de ferramentas de desenvolvimento

No estágio final, não há compiladores (gcc, typescript), gerenciadores de pacotes (npm, apt), shells (bash, sh) ou debuggers (gdb, lldb). Isso elimina vetores de ataque comuns.

5.2. Execução como usuário não-root

FROM node:18-alpine

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

COPY --from=builder --chown=appuser:appgroup /app/dist ./dist

5.3. Uso de imagens distroless

As imagens distroless do Google contêm apenas a aplicação e suas dependências de runtime, sem sistema operacional completo:

FROM golang:1.21 AS builder
# ... build

FROM gcr.io/distroless/base-debian12 AS final
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

6. Integração com Kubernetes e CI/CD

6.1. Otimização de pull em clusters

Imagens menores reduzem o tempo de pull em clusters Kubernetes, especialmente durante:

  • Rolling updates
  • Escalonamento horizontal (HPA)
  • Recuperação de nós falhos
# Com multi-stage: pull em ~2 segundos
# Sem multi-stage: pull em ~15 segundos

6.2. Pipeline CI com multi-stage

# Exemplo de pipeline GitLab CI
build:
  stage: build
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

6.3. Estratégias de cache de camadas

Para acelerar builds, organize as camadas que mudam com menos frequência no início:

# Camadas de dependências (mudam raramente)
COPY package*.json ./
RUN npm ci

# Camadas de código (mudam frequentemente)
COPY . .
RUN npm run build

7. Boas Práticas e Armadilhas Comuns

7.1. Estágios organizados com nomes descritivos

FROM node:18 AS dependencies
FROM node:18 AS build
FROM node:18-alpine AS test
FROM node:18-alpine AS production

7.2. Cuidado com segredos

Nunca copie arquivos com credenciais para o estágio final:

# ERRADO: .env com senhas no estágio final
COPY --from=builder /app/.env ./

# CERTO: usar secrets do Docker BuildKit
RUN --mount=type=secret,id=api_key npm run build

7.3. Evitar estágios desnecessários

Mantenha o número de estágios entre 2 e 4. Estágios excessivos aumentam a complexidade sem benefício proporcional.

8. Conclusão e Próximos Passos

O multi-stage build é uma técnica essencial no ecossistema DevOps com Docker e Kubernetes. Ele entrega três benefícios fundamentais:

  • Redução drástica de tamanho: de centenas de MB para dezenas de MB
  • Aumento de segurança: eliminação de ferramentas e bibliotecas desnecessárias
  • Melhoria de performance: pull/push mais rápidos e inicialização mais ágil em clusters

Para se aprofundar, explore os próximos artigos da série sobre otimização de imagens com Docker Registry, segurança em containers e estratégias avançadas de deployment no Kubernetes.

Desafio prático: Pegue um Dockerfile existente em seu projeto e refatore-o para usar multi-stage builds. Meça a diferença de tamanho antes e depois.

Referências