Pular para o conteúdo
CloudIniciante

Docker multi-stage: como reduzir o tamanho da imagem

Aprenda a usar Docker multi-stage para reduzir imagens de centenas de MB, com cache de camadas, .dockerignore, usuário não-root e escolha da imagem base.

Por Equipe IAUAI Estudos · 8 de agosto de 2026 · 9 min de leitura

Nesta página

Ao final deste artigo você vai conseguir pegar um Dockerfile "tudo em um" e transformá-lo em um build multi-stage, com imagem final bem menor, cache de camadas que funciona e um processo que não roda como root. Os exemplos usam Node com TypeScript, mas a ideia vale para qualquer linguagem com etapa de compilação.

O problema: a imagem carrega o que só o build usa

Um Dockerfile ingênuo instala dependências de desenvolvimento, compila o projeto e deixa tudo na imagem final: compiladores, devDependencies, cache do npm, código-fonte e arquivos de teste. Nada disso é necessário para executar a aplicação.

Isso custa em três lugares. O docker pull fica lento, o que pesa em cada deploy e em cada nó novo que entra no cluster. O tráfego de saída de registries e de redes em nuvem costuma ser cobrado, então pull grande em muitos nós vira dinheiro. E mais pacotes na imagem significam mais itens para o scanner de vulnerabilidades apontar.

Os tamanhos citados daqui para frente são ordens de grandeza. O número real depende do seu projeto, das dependências e da versão da imagem base. Meça no seu caso com docker image ls.

Multi-stage: compilar numa imagem, rodar em outra

O multi-stage deixa você usar uma imagem completa para compilar e depois copiar só o resultado para uma imagem enxuta. Primeiro, o ponto de partida:

# Antes: tudo na mesma imagem
FROM node:24

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

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

Agora o mesmo projeto com dois estágios:

# Estágio 1: build (descartado no final)
FROM node:24-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Estágio 2: dependências só de produção
FROM node:24-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

# Estágio 3: imagem final
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json ./

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

Repare que o separador entre estágios é apenas um novo FROM. Não existe linha com --- em Dockerfile. Cada FROM abre um estágio, e só o último vira a imagem publicada, a menos que você peça outro com --target.

O estágio deps existe para resolver um erro comum: copiar o node_modules do builder levaria junto as devDependencies (TypeScript, bundlers, ferramentas de teste). Reinstalar com npm ci --omit=dev num estágio separado deixa só o que a aplicação precisa em runtime. A flag antiga --production está obsoleta no npm atual.

Para conferir o resultado:

docker build -t app:multi .
docker image ls app

Ordem das camadas e cache de build

O Docker guarda cada instrução como uma camada e reaproveita a camada enquanto ela e tudo acima dela não mudaram. Quando uma camada muda, todas as seguintes são refeitas. Por isso o package.json entra antes do código:

COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

O código muda o tempo todo, mas as dependências mudam pouco. Com essa ordem, editar um arquivo .ts refaz só do COPY . . em diante, e o npm ci, que é a parte lenta, vem do cache.

Com BuildKit (o padrão do Docker atual) dá para ir além e guardar o cache do npm entre builds:

RUN --mount=type=cache,target=/root/.npm npm ci

Esse cache fica fora da imagem, então não aumenta o tamanho final e acelera instalações mesmo quando o package.json muda.

Escolhendo a imagem base

Base Característica Quando usar Cuidado
node:24 Debian completo com ferramentas de build Estágio de build, desenvolvimento Grande demais para runtime
node:24-slim Debian enxuto, glibc Escolha padrão para produção Sem compiladores para módulos nativos
node:24-alpine Alpine, musl libc, bem pequena Quando tamanho é prioridade Binários e módulos nativos pensados para glibc podem falhar
Distroless Sem shell nem gerenciador de pacotes Produção com foco em segurança Debug com docker exec não funciona

A diferença entre glibc e musl é a armadilha clássica do Alpine. Pacotes Python com extensões em C, como numpy ou pandas, historicamente não tinham wheels prontos para musl e exigiam compilar na instalação, o que quebrava ou demorava muito. Hoje já existem wheels musllinux para vários deles, mas o terreno ainda é irregular. Se sua aplicação depende de bibliotecas nativas, comece pela variante slim e só migre para Alpine depois de testar.

Quanto à versão: use uma versão em suporte. Imagens node:18 e anteriores chegaram ao fim da vida e não recebem mais correções de segurança. Confira a tabela de releases em nodejs.org/en/about/previous-releases e prefira uma versão LTS ativa ou em manutenção.

Para builds reproduzíveis, fixe a tag com precisão (node:24.11-slim) ou, melhor ainda, pelo digest (node:24-slim@sha256:...). Uma tag solta como node:24-slim muda quando a imagem é republicada. Ferramentas como Renovate e Dependabot podem atualizar o digest por pull request.

.dockerignore: o que não vai para o contexto

Quando você roda docker build ., o Docker envia o diretório inteiro como contexto de build. Sem .dockerignore, vão junto o .git, o node_modules local e arquivos de ambiente. Isso deixa o build lento e pode vazar segredo para dentro de uma camada.

node_modules
npm-debug.log
.git
.gitignore
.env*
dist
build
coverage
.next
.cache
.vscode
.DS_Store
__pycache__
*.pyc
README.md

O arquivo se chama .dockerignore, fica na raiz do contexto, e usa a sintaxe de padrões parecida com a do .gitignore. Um bônus importante: ignorar node_modules evita que o módulo instalado na sua máquina (macOS, por exemplo) sobrescreva o instalado dentro do container (Linux) no COPY . ..

Rodar como usuário não-root

Por padrão o processo do container roda como root. Se alguém explorar uma falha na aplicação, começa com privilégio alto dentro do container. Rodar como usuário comum é uma camada barata de defesa em profundidade, que se soma a outras como perfis seccomp e filesystem somente leitura.

As imagens oficiais do Node já trazem um usuário node (uid 1000), então não precisa criar nada:

FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production

COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --chown=node:node package*.json ./

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

O --chown no COPY define o dono já na cópia. Isso evita o RUN chown -R, que duplicaria os arquivos numa camada nova e aumentaria a imagem. Para testar, abra um shell e confirme o usuário:

docker run --rm --entrypoint id app:multi

Exemplo em Python

O mesmo raciocínio funciona em Python: instalar dependências num estágio e copiar o resultado.

FROM python:3.13-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY app.py .
RUN useradd --create-home appuser
USER appuser
CMD ["python", "app.py"]

O --prefix=/install isola os pacotes numa pasta só, o que torna a cópia entre estágios limpa e previsível. O ganho aqui costuma ser menor do que em Node, porque a base Python já é enxuta. Ele aparece mais quando alguma dependência precisa de compilador (gcc, headers) só para instalar.

Armadilhas comuns

Contexto de build gigante por falta de .dockerignore. Rode docker build e observe a linha de transferência do contexto. Se ela mostra centenas de MB, algo está sobrando.

Copiar node_modules do builder sem limpar. Como vimos, traz as devDependencies. Use um estágio de dependências de produção.

Segredos na imagem. Um COPY .env . ou um ARG TOKEN usado em RUN deixa o valor no histórico das camadas. Para segredos de build, use RUN --mount=type=secret, que monta o valor só durante a instrução.

Confiar em latest. Além de quebrar a reprodutibilidade, esconde mudanças de versão maior da imagem base.

Quando multi-stage não vale a pena

Para um binário estático em Go ou Rust, o multi-stage ainda ajuda, mas o ganho principal vem de copiar o binário para scratch ou distroless. Para um protótipo que roda 2 horas no seu docker-compose.yml local, não perca tempo otimizando. Deixe para quando o código for para produção.

E tenha em mente que a imagem distroless não tem shell. Para investigar problemas, use uma variante de debug (:debug) em ambiente de teste ou docker debug e contêineres efêmeros no Kubernetes.

Verifique antes de publicar

Compare o tamanho antes e depois com docker image ls. Para ver o que cada camada pesa, use docker history app:multi. E rode um scanner como o Trivy (trivy image app:multi) para comparar a quantidade de vulnerabilidades entre a imagem antiga e a nova.

Próximos passos

Para automatizar o build e o push dessas imagens a cada commit, siga o CI/CD com GitHub Actions do zero. Se o destino é um cluster, o Kubernetes para quem vem do Docker Compose mostra como a imagem vira Deployment.

Quer aplicar isso na sua empresa? Marque uma conversa de 45 minutos em https://iauaicloud.com.br/consultoria

Aprenda jogando

Salto de Container

Pule entre containers que morrem e reiniciam sem ser reagendado pelo orquestrador.

Jogar Salto de Container

Teste seu conhecimento

Teste o que você aprendeu sobre Docker multi-stage

Pergunta 1 de 6

O que marca o início de um novo estágio em um Dockerfile multi-stage?

Um conteúdo prático por quinzena

Deixe o seu e-mail para receber um conteúdo prático de IA e cloud a cada quinzena. Sem spam, e você pede a exclusão quando quiser.

Continue lendo