Alternar tema

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

Easton editorial illustration: tradeoff balance table

“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:

  1. Imagem base grande demais: a própria ubuntu:20.04 já tem 77 MB; depois de instalar a toolchain do Go, o total passa diretamente de 900 MB
  2. 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
  3. Cache sem limpeza: todo o cache dos gerenciadores de pacotes apt/apk permanece nas camadas da imagem
  4. 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 à etapa
  • COPY --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:

  1. FROM scratch: uma imagem vazia, que começa com 0 byte e contém apenas o seu binário
  2. CGO_ENABLED=0: desativa o CGO para gerar um binário totalmente estático
  3. Certificados de CA: se a aplicação precisa chamar endpoints HTTPS, é indispensável copiar o arquivo de certificados
  4. 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:

  1. npm ci --only=production: instala apenas dependencies e ignora devDependencies, reduzindo imediatamente o tamanho pela metade
  2. npm cache clean --force: limpa o cache do npm para que ele não permaneça nas camadas da imagem
  3. 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:

  1. --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
  2. slim vs alpine: use slim na etapa de build, por sua boa compatibilidade, e alpine na etapa de produção, pelo tamanho reduzido
  3. 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ísticaAlpineDistrolessSlim
Tamanho base3–5 MB20–65 MB50–100 MB
SegurançaMédiaMuito altaMédia
Dificuldade de depuraçãoBaixa (tem shell)Alta (não tem shell)Baixa (tem shell)
CompatibilidadePode causar problemas (glibc)BoaBoa
Cenários indicadosBinários estáticos em GoAlta exigência de segurançaNode.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 alpine por slim

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 scratch ou alpine
  • Node.js/Python: comece com slim e, depois de confirmar que tudo funciona, teste alpine
  • 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. 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. 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. 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. 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. 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?
O multi-stage build pode aumentar o tempo de build, pois duas etapas precisam ser construídas. No entanto, a imagem final fica muito menor, acelerando bastante a implantação e a transferência. Em pipelines de CI/CD, o tempo total geralmente diminui.
Como escolher entre Alpine e Distroless?
Para aplicações Go compiladas estaticamente, dê preferência a Alpine ou scratch. Em Node.js e Python, comece com slim para confirmar a compatibilidade e depois teste Alpine. Para cenários com exigências de segurança muito altas, escolha Distroless, mas prepare antes uma estratégia de logs e monitoramento.
Quais linguagens podem usar multi-stage build?
Praticamente todas as linguagens de programação podem usá-lo. Os ganhos mais expressivos aparecem em Go, cuja imagem pode chegar a 10 MB; Node.js, com redução superior a 80%; Python, com redução superior a 60%; além de Rust, Java e outras linguagens com etapas de compilação ou gerenciamento de dependências.
Como lidar com arquivos de configuração em um multi-stage build?
Normalmente, os arquivos de configuração são montados separadamente na etapa de execução, e não incluídos na imagem. Você pode usar um volume Docker ou um Kubernetes ConfigMap. Se for indispensável incluí-los, basta usar COPY na etapa de execução.
Quanto o multi-stage build pode reduzir o tamanho de uma imagem?
Isso depende da linguagem e do tipo de aplicação. Em Go, a redução costuma ficar entre 90% e 99%, passando de 1 GB para 10 MB. Em Node.js, fica entre 70% e 90%; em Python, entre 50% e 70%. O essencial é manter apenas os arquivos indispensáveis à execução.
Que cuidados devo tomar ao usar FROM scratch?
scratch é uma imagem vazia: não tem shell, gerenciador de pacotes nem certificados de CA. Se a aplicação fizer chamadas HTTPS, será preciso copiar /etc/ssl/certs/ca-certificates.crt da etapa builder. Como a depuração é difícil, vale validar primeiro o funcionamento com alpine.

11 min de leitura · Publicado em: 19 abr 2026 · Atualizado em: 4 set 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog