O que Todo Dev Deve Saber sobre Docker Multi-stage Builds: Reduzindo Imagens de GB para MB na Prática Já leu

O Problema: Por Que Nossas Imagens Docker São Tão Grandes? Quando você constrói uma imagem Docker tradicional, está incluindo tudo na camada final: dependências de compilação, ferramentas de build, arquivos temporários, caches de gerenciadores de pacotes. Uma aplicação Go simples que deveria pesar 10 MB pode facilmente ocupar 500 MB ou mais. Uma aplicação Node.js fica ainda pior — você herda todas as dependências do , ferramentas de build como webpack, e tudo que foi instalado durante o desenvolvimento. O impacto é real: imagens gigantescas significam mais tempo para fazer push/pull em registries, mais espaço em disco nos servidores, deploys mais lentos e custos maiores de armazenamento em plataformas cloud. Se você trabalha em ambientes com restrições de banda ou com centenas de deploys por dia, esse problema é um gargalo significativo. Entendendo Multi-stage Builds: O Conceito Multi-stage builds permitem usar múltiplos em um único Dockerfile. Cada estágio é independente e você copia apenas os artefatos necessários do estágio anterior

O Problema: Por Que Nossas Imagens Docker São Tão Grandes?

Quando você constrói uma imagem Docker tradicional, está incluindo tudo na camada final: dependências de compilação, ferramentas de build, arquivos temporários, caches de gerenciadores de pacotes. Uma aplicação Go simples que deveria pesar 10 MB pode facilmente ocupar 500 MB ou mais. Uma aplicação Node.js fica ainda pior — você herda todas as dependências do node_modules, ferramentas de build como webpack, e tudo que foi instalado durante o desenvolvimento.

O impacto é real: imagens gigantescas significam mais tempo para fazer push/pull em registries, mais espaço em disco nos servidores, deploys mais lentos e custos maiores de armazenamento em plataformas cloud. Se você trabalha em ambientes com restrições de banda ou com centenas de deploys por dia, esse problema é um gargalo significativo.

Entendendo Multi-stage Builds: O Conceito

Multi-stage builds permitem usar múltiplos FROM em um único Dockerfile. Cada estágio é independente e você copia apenas os artefatos necessários do estágio anterior para o próximo. A imagem final contém apenas o estágio final — tudo que ficou nas etapas anteriores é descartado.

A ideia é separar o ambiente de build (pesado, com compiladores e ferramentas) do ambiente de runtime (leve, apenas o necessário para executar). Um paralelo na engenharia tradicional: você não distribui toda a fábrica junto com o produto final, apenas o produto.

Como Funciona Internamente

Quando Docker executa um multi-stage build, ele cria uma imagem intermediária para cada estágio. As camadas dos estágios anteriores não aparecem na imagem final — apenas as camadas do último FROM são preservadas. Você controla explicitamente o que passa de um estágio para outro via COPY --from=<stage>.

O resultado é uma redução dramática de tamanho porque você deixa para trás compiladores, dependências de desenvolvimento e outros artefatos intermediários. Uma aplicação Go compilada pode ir de 800 MB para 15 MB; uma aplicação Python empacotada de 1.2 GB para 200 MB.

Exemplo Prático 1: Aplicação Go

Sem Multi-stage (O Problema)

FROM golang:1.21-alpine

WORKDIR /app
COPY . .

RUN go build -o myapp main.go

EXPOSE 8080
CMD ["./myapp"]

Se você fizer o build: docker build -t myapp:v1 ., a imagem terá aproximadamente 400 MB porque herda toda a imagem base golang:1.21-alpine (que inclui compilador, ferramentas de build, git, etc).

Com Multi-stage (A Solução)

# Estágio 1: Build
FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY . .

RUN go build -o myapp main.go

# Estágio 2: Runtime
FROM alpine:latest

WORKDIR /app
COPY --from=builder /app/myapp .

EXPOSE 8080
CMD ["./myapp"]

Agora a imagem final terá apenas ~15 MB. O estágio builder é descartado, levando consigo o compilador Go e todas as dependências de build. Apenas o binário compilado (myapp) é copiado para a imagem final baseada em alpine:latest (uma imagem mínima de ~7 MB).

Você constrói assim: docker build -t myapp:v1 . e obtém uma imagem pronta para produção, leve e segura.

Exemplo Prático 2: Aplicação Node.js com Build

Sem Multi-stage

FROM node:18-alpine

WORKDIR /app
COPY package*.json ./

RUN npm install

COPY . .

RUN npm run build

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

Imagem final: ~800 MB (todos os devDependencies, cache do npm, ferramentas de build).

Com Multi-stage

# Estágio 1: Dependências e Build
FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./

RUN npm ci --only=production && \
    npm ci && \
    npm run build

# Estágio 2: Runtime
FROM node:18-alpine

WORKDIR /app

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

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

Imagem final: ~180 MB. A diferença aqui é menor porque Node.js exige o runtime mesmo em produção, mas ainda eliminamos devDependencies e artefatos de build temporários.

Versão ainda mais otimizada (usando distroless):

FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./

RUN npm ci --only=production && \
    npm ci && \
    npm run build

# Usar distroless node para reduzir ainda mais
FROM gcr.io/distroless/nodejs18-debian11

WORKDIR /app

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

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

Com distroless: ~150 MB (não inclui shell, apt, ou qualquer ferrramenta desnecessária).

Boas Práticas e Otimizações Avançadas

Nomenclatura de Estágios

Use nomes descritivos em vez de índices numéricos. Fica mais legível e manutenível:

FROM golang:1.21-alpine AS builder
# ... build steps ...

FROM alpine:latest AS runtime
# ... runtime setup ...

Quando você copia, faz: COPY --from=builder em vez de COPY --from=0.

Otimizando Layers em Cada Estágio

Combine comandos RUN quando possível para reduzir camadas:

# Ruim: múltiplas camadas
RUN apt-get update
RUN apt-get install -y curl git
RUN apt-get clean

# Bom: uma camada
RUN apt-get update && \
    apt-get install -y curl git && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

Cada RUN cria uma camada. Mesmo que você delete arquivos em um RUN subsequente, as camadas anteriores ocupam espaço. Por isso combine e limpe na mesma instrução.

Selecionando a Imagem Base Correta

Para runtime, escolha a menor imagem que satisfaz suas necessidades:

  • alpine: ~7 MB, minimalista, sem muitas ferramentas
  • slim: ~150 MB para linguagens como Python/Node, mais compatível
  • distroless: ~70-100 MB, otimizado para produção, sem shell
  • scratch: ~0 MB, apenas para binários estáticos (Go)
# Para Go: pode usar scratch se compilar estaticamente
FROM scratch
COPY --from=builder /app/myapp .
CMD ["./myapp"]

Uma aplicação Go em scratch pode ter apenas ~20 MB se tiver assets estáticos, ou até menos com apenas o binário.

Cacheamento Inteligente

Ordene as instruções do mais estável para o menos estável. Mudanças em package.json invalidam o cache, mas mudanças no código-fonte não deveriam invalidar a instalação de dependências:

FROM node:18-alpine AS builder

WORKDIR /app

# Copiar dependências primeiro (muda raramente)
COPY package*.json ./
RUN npm ci

# Copiar código depois (muda frequentemente)
COPY . .
RUN npm run build

Assim, se você apenas mudar o código, o Docker reutiliza a camada de npm ci do cache.

Conclusão

Multi-stage builds são fundamentais para containerização profissional. Você reduz imagens de gigabytes para megabytes — e isso não é apenas um número, é a diferença entre deploys rápidos e lentos, entre sistemas escaláveis e sobrecarregados. A técnica é simples: use múltiplos FROM, construa em um estágio pesado e copie apenas o necessário para um estágio leve de runtime.

O segundo aprendizado crítico é que essa redução não sacrifica funcionalidade — seu aplicativo roda exatamente igual, mas mais rápido, com menos consumo de recursos e menor superfície de ataque (menos ferramentas = menos vulnerabilidades).

Por fim, combine multi-stage com imagens base apropriadas (alpine, distroless, scratch) e boas práticas de cache para alcançar o máximo de eficiência. Aplique isso hoje e você verá o impacto imediato em seus pipelines de CI/CD.

Referências


Artigos relacionados