Docker multi-stage build na prática: reduzindo a imagem de produção de 1 GB para 10 MB

“O envio da imagem falhou. Tempo limite excedido.”
Era uma tarde de sexta-feira no ano passado, e o pipeline de CI/CD estava todo vermelho. Olhei para aquela imagem de 980 MB de uma aplicação Go na tela e senti um aperto no peito. Um colega de operações se aproximou e suspirou: “Essa sua imagem é maior que o filme que baixei na hora do almoço.”
Depois disso, passei a usar multi-stage build.
10 MB. A mesma aplicação, com os mesmos recursos, teve sua imagem reduzida de 980 MB para 10 MB. O tamanho caiu 99%, e o envio pelo CI/CD passou de 3 minutos para 3 segundos.
Neste artigo, vou compartilhar técnicas práticas de multi-stage build, com modelos completos de Dockerfile para Go, Node.js e Python, além de 5 erros comuns que reuni depois de tropeçar neles. Se você quer transformar uma imagem de produção “inchada” em uma imagem realmente enxuta, continue lendo.
Por que sua imagem está tão inchada?
Para falar a verdade, a maioria das imagens Docker fica inchada pelos mesmos motivos.
Eu já escrevi um Dockerfile assim:
FROM ubuntu:20.04
RUN apt-get update && apt-get install -y golang
COPY . /app
WORKDIR /app
RUN go build -o myapp
CMD ["./myapp"]
Parece perfeitamente normal, certo? Mas bastou executar docker images: impressionantes 980 MB.
Onde estava o problema? Em poucas palavras: o que precisava ficar não ficou, e o que deveria sair não saiu.
Mais especificamente:
- Imagem base grande demais: a própria
ubuntu:20.04já tem 77 MB; depois de instalar a toolchain do Go, o total passa diretamente de 900 MB - Ferramentas de compilação remanescentes: ferramentas de build como gcc, make e git simplesmente não são necessárias no ambiente de produção
- Cache sem limpeza: todo o cache dos gerenciadores de pacotes apt/apk permanece nas camadas da imagem
- Dependências redundantes: dependências de desenvolvimento e frameworks de teste também acabam incluídos
É como sair para viajar levando mala, saco de dormir, barraca e utensílios de cozinha, quando na verdade você só vai ficar em um hotel. O multi-stage build faz com que você leve apenas o que realmente precisa, como roupas e itens de higiene, e deixe todo o resto em casa.
Segundo dados da documentação oficial do Docker, a imagem não otimizada de uma aplicação Go típica fica entre 800 MB e 1 GB, mas pode cair para 10 a 20 MB depois da otimização. A diferença é realmente impressionante.
O princípio central do multi-stage build
A ideia central do multi-stage build é simples: separar o ambiente de build do ambiente de execução.
Um Dockerfile tradicional coloca compilação, empacotamento e execução na mesma imagem. Já o multi-stage build permite definir várias instruções FROM, e cada uma delas inicia uma nova etapa de build.
Veja o exemplo mais simples:
# Primeira etapa: build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp
# Segunda etapa: execução
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]
A sintaxe principal se resume a duas linhas:
FROM ... AS builder: dá um nome à etapaCOPY --from=builder: copia arquivos da etapa builder
Na prática, o Docker executa cada etapa em sequência, mas a imagem final contém apenas o conteúdo da última. Todas aquelas ferramentas pesadas de compilação e os caches de dependências das etapas anteriores são descartados.
Segundo um tutorial da iximiuz Labs de 2026, a essência do multi-stage build é aproveitar o mecanismo de camadas do Docker: cada instrução FROM inicia um contexto de build independente. Você pode copiar arquivos de qualquer etapa para as seguintes, mas os arquivos que não forem copiados jamais entrarão na imagem final.
É como reformar uma casa: na primeira etapa, a equipe de obra chega com furadeira, martelo e serra; na segunda, você se muda levando apenas móveis e eletrodomésticos. Quando a equipe vai embora, leva suas ferramentas, e a casa fica somente com aquilo de que você precisa.
Na prática: modelos de multi-stage build para três linguagens
Go: de 980 MB para 10 MB
Go é uma das linguagens que mais se beneficiam do multi-stage build, pois pode ser compilada em um binário estático.
Dockerfile completo:
# Etapa de build
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Copie primeiro go.mod e go.sum para aproveitar o cache
COPY go.mod go.sum ./
RUN go mod download
# Copie o código-fonte e compile
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o myapp .
# Etapa de execução
FROM scratch
# Copie o binário da etapa builder
COPY --from=builder /app/myapp /myapp
# Copie os certificados de CA, caso sejam necessárias chamadas HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/myapp"]
Há algumas técnicas importantes aqui:
FROM scratch: uma imagem vazia, que começa com 0 byte e contém apenas o seu binárioCGO_ENABLED=0: desativa o CGO para gerar um binário totalmente estático- Certificados de CA: se a aplicação precisa chamar endpoints HTTPS, é indispensável copiar o arquivo de certificados
- Otimização do cache de dependências: copie go.mod/go.sum antes de executar go mod download; assim, alterações no código-fonte não obrigam o Docker a baixar as dependências novamente
Depois do build, a imagem fica com cerca de 10 MB. Em comparação com os 980 MB originais, é uma redução de 99%.
Se scratch parecer extremo demais — por não ter shell e dificultar a depuração —, você pode usar alpine:
FROM alpine:3.18
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
A imagem fica um pouco maior, com cerca de 15 MB, mas você ganha um ambiente no qual pode entrar com docker exec para depurar.
Node.js: de 900 MB para 120 MB
O multi-stage build para Node.js é um pouco mais complexo porque é preciso lidar com node_modules.
Dockerfile completo:
# Etapa de build
FROM node:18-alpine AS builder
WORKDIR /app
# Copie package.json
COPY package*.json ./
# Instale todas as dependências, inclusive devDependencies
RUN npm ci
# Copie o código-fonte
COPY . .
# Caso exista uma etapa de build, como a compilação de TypeScript
RUN npm run build
# Etapa de produção
FROM node:18-alpine
WORKDIR /app
# Defina a variável de ambiente do Node
NODE_ENV=production
# Instale somente as dependências de produção
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copie os artefatos do build
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
Pontos principais:
npm ci --only=production: instala apenasdependenciese ignoradevDependencies, reduzindo imediatamente o tamanho pela metadenpm cache clean --force: limpa o cache do npm para que ele não permaneça nas camadas da imagem- Separação entre build e execução: a compilação do TypeScript acontece na etapa builder; a imagem de produção contém apenas arquivos JS
Segundo testes da Oak Oliver Engineering, a imagem não otimizada de uma aplicação Express típica tem cerca de 900 MB e cai para aproximadamente 120 MB após o multi-stage build. Isso representa uma redução de cerca de 87%.
Python: de 300 MB para 100 MB
O caso do Python é um pouco diferente: não há uma etapa de compilação, mas as dependências podem ser enormes — numpy e pandas, por exemplo, chegam facilmente a centenas de MB.
Dockerfile completo:
# Etapa de build
FROM python:3.9-slim AS builder
WORKDIR /app
# Instale as dependências no diretório do usuário
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Etapa de produção
FROM python:3.9-alpine
WORKDIR /app
# Copie as dependências
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# Copie o código da aplicação
COPY . .
EXPOSE 8000
CMD ["python", "app.py"]
Aqui usamos pip install --user, que instala as dependências em /root/.local; depois, todo esse diretório é copiado para a imagem de produção.
Técnicas principais:
--no-cache-dir: por padrão, o pip mantém em cache os pacotes baixados; esse parâmetro evita que o cache fique na imagem- slim vs alpine: use
slimna etapa de build, por sua boa compatibilidade, ealpinena etapa de produção, pelo tamanho reduzido - Ambiente virtual: se as dependências forem complexas, considere usar venv em vez de
--user
Resultado do teste: em um projeto com FastAPI + SQLAlchemy, a imagem original tinha cerca de 300 MB e caiu para aproximadamente 100 MB com multi-stage build.
Escolha da imagem base: Alpine vs Distroless vs Slim
Escolher a imagem base da etapa de execução exige avaliar alguns trade-offs.
Organizei em uma tabela a comparação entre as três principais opções:
| Característica | Alpine | Distroless | Slim |
|---|---|---|---|
| Tamanho base | 3–5 MB | 20–65 MB | 50–100 MB |
| Segurança | Média | Muito alta | Média |
| Dificuldade de depuração | Baixa (tem shell) | Alta (não tem shell) | Baixa (tem shell) |
| Compatibilidade | Pode causar problemas (glibc) | Boa | Boa |
| Cenários indicados | Binários estáticos em Go | Alta exigência de segurança | Node.js/Python |
Alpine: tamanho mínimo, mas cuidado com glibc
O Alpine Linux usa musl libc em vez da glibc padrão. Isso não é um problema para Go, que permite compilação estática, mas pode afetar algumas dependências de Python e Node.js.
Eu já caí nessa armadilha: um projeto Python usava numpy e não conseguia rodar no Alpine, exibindo o erro ImportError: cannot import name 'random'. Depois de investigar bastante, descobri que a causa era a incompatibilidade entre musl e glibc.
Há duas soluções:
- Instalar
libc6-compat:apk add libc6-compat - Ou simplesmente trocar
alpineporslim
Distroless: referência em segurança, mas difícil de depurar
Distroless é uma família de imagens do Google cuja principal característica é não ter shell nem gerenciador de pacotes: há apenas o necessário para executar a aplicação.
Segundo uma análise do danieldemmel.me, o Distroless pode eliminar grande parte das vulnerabilidades CVE de alto risco, pois um invasor não consegue executar comandos por meio de um shell.
O preço disso é que, quando algo dá errado, você não consegue entrar com docker exec para consultar logs ou depurar. É preciso depender da saída de logs e do monitoramento.
Se você busca o máximo de segurança, Distroless é a melhor opção:
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /
ENTRYPOINT ["/myapp"]
Slim: uma escolha equilibrada
As imagens oficiais com o sufixo -slim, como node:18-slim e python:3.9-slim, oferecem um meio-termo entre Alpine e as imagens completas.
Elas são um pouco maiores que Alpine, mas têm boa compatibilidade e incluem um shell para depuração. Se você não quer lidar com problemas entre musl e glibc, slim é a opção mais tranquila.
Minha recomendação:
- Aplicações Go: dê preferência a
scratchoualpine - Node.js/Python: comece com
slime, depois de confirmar que tudo funciona, testealpine - Alta exigência de segurança: use
distroless, mas prepare antecipadamente uma estratégia de depuração
Guia para evitar armadilhas: 5 erros comuns e suas soluções
Depois de escrever tantos Dockerfiles, já caí em armadilhas suficientes para encher uma piscina. Separei as 5 mais comuns.
Erro 1: copiar tudo com COPY —from=0
Este é um erro comum para iniciantes: copiar todo o conteúdo diretamente da etapa anterior.
# Exemplo incorreto
FROM builder
COPY --from=0 /app /app
Isso copia todo o diretório da etapa builder, inclusive a toolchain do Go, o cache do npm e os arquivos temporários. A imagem volta a ficar enorme imediatamente.
A forma correta é copiar apenas os arquivos necessários.
# Exemplo correto
COPY --from=builder /app/myapp /myapp
COPY --from=builder /app/dist /dist
Erro 2: não limpar o cache
O cache dos gerenciadores de pacotes apt/apk permanece nas camadas da imagem, mesmo que você o exclua depois.
# Exemplo incorreto: o cache permanece na camada anterior
RUN apt-get update && apt-get install -y curl
RUN apt-get clean
A forma correta é executar a limpeza na mesma camada da instalação.
# Exemplo correto
RUN apt-get update && apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/*
Outra opção é usar o parâmetro --no-cache:
RUN apk add --no-cache curl
Erro 3: problemas de compatibilidade entre Alpine e glibc
Como vimos, Alpine usa musl libc, e algumas dependências de Python e Node.js não são compatíveis.
Erro típico:
ImportError: cannot import name 'random' from 'numpy.random'
A solução é instalar libc6-compat ou trocar para slim.
Erro 4: não definir um usuário sem privilégios de root
Por padrão, o contêiner é executado como usuário root, o que representa um risco de segurança considerável.
A prática recomendada é criar um usuário dedicado.
RUN adduser -D appuser
USER appuser
Assim, mesmo que o contêiner seja comprometido, o invasor terá apenas as permissões de um usuário comum.
Erro 5: ignorar o .dockerignore
O .dockerignore é a “lista de exclusão” do Dockerfile. Sem configurá-lo, COPY . . copia todo o diretório do projeto, inclusive .git, node_modules e arquivos de teste.
Crie um arquivo .dockerignore:
.git
.gitignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
*.md
.env
Isso reduz o tamanho do contexto de build e acelera a criação da imagem.
Conclusão
O multi-stage build é uma das técnicas mais práticas para reduzir imagens Docker.
A ideia central cabe em uma frase: as ferramentas de compilação ficam no ambiente de build; o ambiente de execução recebe apenas a aplicação.
Recapitulando os números:
- Go: 980 MB → 10 MB (redução de 99%)
- Node.js: 900 MB → 120 MB (redução de 87%)
- Python: 300 MB → 100 MB (redução de 67%)
Se você ainda não experimentou o multi-stage build, este é um bom momento. Escolha um projeto, reescreva o Dockerfile com base nos modelos acima e use docker images para comparar o tamanho antes e depois.
Você provavelmente terá uma boa surpresa — e, no mínimo, o envio pelo CI/CD não vai mais estourar o tempo limite.
Otimização de imagens Docker com multi-stage build
Processo completo para reduzir uma imagem Docker inchada ao menor tamanho possível
⏱️ Estimated time: 30 min
- 1
Step 1: Analise a composição da imagem atual
Use o comando `docker history` para verificar o tamanho de cada camada da imagem:
```bash
docker history your-image:tag
```
Identifique as camadas que ocupam mais espaço. Em geral, elas são:
• A própria imagem base
• Ferramentas de build e dependências de compilação
• Cache do gerenciador de pacotes - 2
Step 2: Escreva um Dockerfile multi-stage
Crie um Dockerfile com uma etapa de build e outra de execução:
```dockerfile
# Etapa de build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp .
# Etapa de execução
FROM alpine:3.18
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
```
Pontos-chave:
• Use AS para nomear a etapa
• Use COPY --from=builder para copiar apenas os arquivos necessários - 3
Step 3: Faça o build e compare o tamanho das imagens
Crie a nova imagem e compare a diferença de tamanho:
```bash
docker build -t myapp:optimized .
docker images | grep myapp
```
Compare os tamanhos antes e depois da otimização. - 4
Step 4: Verifique se a aplicação funciona corretamente
Execute o contêiner e teste a aplicação:
```bash
docker run -d -p 8080:8080 myapp:optimized
curl http://localhost:8080/health
```
Confirme que todos os recursos funcionam e que não faltam dependências. - 5
Step 5: Implante no ambiente de produção
Atualize o pipeline de CI/CD para usar a nova imagem:
• Envie a imagem para o registry
• Atualize o Kubernetes Deployment ou o docker-compose.yml
• Verifique se a implantação foi concluída com sucesso
FAQ
O multi-stage build afeta a velocidade do build?
Como escolher entre Alpine e Distroless?
Quais linguagens podem usar multi-stage build?
Como lidar com arquivos de configuração em um multi-stage build?
Quanto o multi-stage build pode reduzir o tamanho de uma imagem?
Que cuidados devo tomar ao usar FROM scratch?
11 min de leitura · Publicado em: 19 abr 2026 · Atualizado em: 4 set 2026
Guia prático Docker
Se você chegou pela busca, o caminho mais rápido é ir para o post anterior ou próximo desta série.
Anterior
Build multi-stage no Docker: imagens Go, Java e Rust de GB para MB
Aprenda a usar build multi-stage no Docker para reduzir imagens de Go, Java e Rust, organizar o cache e escolher entre Scratch, Distroless, Alpine e Slim.
Parte 5 de 34
Próximo
Como acelerar builds do Docker com cache: guia prático
Aprenda a usar o cache de camadas do Docker, configurar o .dockerignore e otimizar o Dockerfile para reduzir builds de 10 minutos para 30 segundos, com exemplos de cache do BuildKit.
Parte 7 de 34



Comentários
Entre com GitHub para comentar