Alternar tema

Otimização de Dockerfile: 5 técnicas para reduzir a imagem em 80%

Easton editorial illustration: observability control panel

A barra de progresso no terminal estava parada em “Pushing to registry” havia 30 minutos.

3,2 GB.

Era a primeira imagem Docker de uma aplicação Node.js. Eu tinha seguido um tutorial da internet passo a passo para escrever o Dockerfile. O build funcionou, mas eu não esperava aquele tamanho. Na manhã seguinte, um colega perguntou no Slack: “Sua imagem empacotou o sistema operacional inteiro? O disco do meu notebook está quase cheio.”

Seria a imagem base Ubuntu? O node_modules? Ou as ferramentas de compilação? O resultado era o mesmo: para um serviço de API simples, a imagem ficou 50 vezes maior que todo o código do projeto. Depois de estudar a documentação oficial e as melhores práticas do Docker, reduzi aquele monstro de 3,2 GB para 180 MB. Uma queda de 94%.

180 MB
Tamanho da imagem após a otimização
De 3,2 GB para 180 MB, uma redução de 94%

A seguir estão as cinco técnicas que mais funcionaram nesse processo. Além de mostrar como aplicá-las, explico por que elas funcionam — afinal, entender o princípio é muito mais útil do que decorar comandos.

Primeiro, entenda por que uma imagem Docker fica tão grande

Antes das técnicas de otimização, é preciso entender a origem do problema.

Imagens Docker são formadas por camadas. Cada instrução RUN, COPY ou ADD cria uma nova camada do sistema de arquivos. Essas camadas se acumulam até formar a imagem final. O ponto decisivo é: cada camada apenas adiciona conteúdo; ela não apaga dados de uma camada anterior.

Veja um exemplo. Você escreve isto no Dockerfile:

RUN apt-get update
RUN apt-get install -y build-essential
RUN rm -rf /var/lib/apt/lists/*

À primeira vista, a última linha remove o cache do apt. Na prática, porém, esse cache já ficou salvo permanentemente na segunda camada. A terceira apenas marca que “esses arquivos foram removidos”, mas os dados continuam ocupando espaço na imagem.

É como fotografar o quarto a cada etapa de uma mudança: mesmo depois de jogar o lixo fora, você ainda precisa levar as fotos em que ele aparece. Parece pouco eficiente, mas é assim que o mecanismo copy-on-write do Docker funciona.

Execute docker history em uma imagem sem otimização e verá que a soma das camadas é muito maior do que o espaço exigido pelos arquivos realmente necessários.

Outro ponto fácil de ignorar: a imagem base determina diretamente o tamanho mínimo que sua imagem pode alcançar. A ubuntu:20.04 sozinha ocupa 72 MB, enquanto a node:16 chega a 1,09 GB, pois se baseia em um sistema Debian completo e inclui várias ferramentas que talvez você nunca use.

Com isso em mente, o caminho da otimização fica claro: reduzir o número de camadas, escolher uma imagem base mais leve e fazer instalação e limpeza dentro da mesma camada.

Técnica 1: escolha a imagem base certa e comece com vantagem

Escolher a imagem base é como escolher a localização de um imóvel. Uma escolha ruim impõe um limite ao resultado, por melhor que seja a reforma depois.

Compare estes números:

  • node:16 → 1,09 GB
  • node:16-slim → 240 MB
  • node:16-alpine → 174 MB
  • alpine:latest → 5,6 MB

A diferença é evidente. Quando troquei node:16 por node:16-alpine, a imagem caiu diretamente de 1,2 GB para 400 MB, sem alterar uma linha do código da aplicação.

O que é Alpine Linux?

É uma distribuição Linux criada para ambientes de contêiner. Ela segue uma abordagem minimalista e mantém apenas os componentes essenciais. Usa musl libc no lugar da glibc tradicional e o gerenciador de pacotes apk no lugar do apt.

As vantagens são claras:

  • Tamanho reduzido (5 MB contra 72 MB do Ubuntu)
  • Mais segurança (superfície de ataque mínima)
  • Inicialização rápida

Mas também há armadilhas.

A armadilha de compatibilidade do Alpine

Como ele usa musl libc, alguns binários pré-compilados podem não funcionar. Isso já aconteceu comigo: o projeto dependia de um módulo nativo do Node.js escrito em C++, que falhou no Alpine com o erro “library not found”. Levei um bom tempo até descobrir que a causa era a libc.

Por isso, a recomendação prática é:

  1. Teste primeiro a variante Alpine (sufixo -alpine)
  2. Se houver incompatibilidade, use a variante -slim (baseada em Debian, mas com muitos pacotes removidos)
  3. Recorra à imagem padrão apenas se for necessário — na prática, isso é relativamente raro

A alteração no código é muito simples:

# Antes da otimização
FROM node:16

# Depois da otimização
FROM node:16-alpine

Uma única linha economizou 800 MB.

Como validar o resultado

Depois do build, execute:

docker images your-image-name

Confira a coluna SIZE. Se a imagem ainda estiver grande, a causa não é apenas a imagem base; continue com as próximas técnicas.

Técnica 2: combine instruções RUN para reduzir o número de camadas

Essa técnica é simples de entender, mas costuma ser esquecida na prática.

Como vimos, cada instrução RUN cria uma camada. E o ponto principal é: apagar arquivos só reduz o tamanho quando a remoção acontece na mesma camada em que eles foram criados.

Veja este exemplo incorreto:

# Forma incorreta (cria 3 camadas)
RUN apt-get update
RUN apt-get install -y python3 gcc
RUN rm -rf /var/lib/apt/lists/*

Desse modo, o cache do apt — normalmente com dezenas de megabytes — fica salvo na segunda camada. A exclusão na terceira apenas marca que “esses arquivos não existem mais”, mas os dados continuam dentro da imagem.

O correto é conectar os comandos com &&:

# Forma correta (cria apenas 1 camada)
RUN apt-get update && \
    apt-get install -y python3 gcc && \
    rm -rf /var/lib/apt/lists/*

Assim, a instalação e a limpeza acontecem na mesma camada, e os arquivos são realmente removidos.

Para que serve a barra invertida?

Observe o caractere \. Ele permite dividir um comando longo em várias linhas, facilitando a leitura e a manutenção. Sem isso, tudo ficaria amontoado em uma linha.

Como decidir o que deve ser combinado

Nem toda instrução RUN deve ser reunida. Uma regra simples ajuda:

  • Combine: instalação + limpeza; download + extração + exclusão do arquivo compactado
  • Não combine: operações sem relação lógica; etapas que mudam com frequência e invalidariam o cache de build

Por exemplo:

# Boa separação em camadas
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
RUN npm install
RUN npm run build

A instalação das dependências do sistema fica em uma camada, o npm install em outra — pois o package.json muda com frequência — e o build em uma terceira. Assim, ao alterar o package.json, a camada anterior com as dependências do sistema pode continuar no cache.

No meu teste, um projeto que tinha 12 instruções RUN passou a ter quatro após a otimização, e a imagem caiu de 520 MB para 320 MB.

Técnica 3: use build em múltiplos estágios e leve apenas o necessário

O build em múltiplos estágios (Multi-stage Build) é a ferramenta mais eficaz para reduzir uma imagem Docker. Nenhuma outra chega perto.

A ideia central é muito simples: separar compilação e execução.

Para compilar um programa em Go, por exemplo, você precisa de toda a toolchain da linguagem, que ocupa centenas de megabytes. O binário resultante, porém, talvez tenha apenas 10 MB. Incluir a toolchain do Go na imagem final seria puro desperdício.

O build em múltiplos estágios resolve exatamente isso. Você define vários estágios no mesmo Dockerfile: o primeiro constrói a aplicação, e o segundo copia apenas os artefatos gerados.

Veja um exemplo com Node.js:

# === Estágio de build ===
FROM node:16-alpine AS builder
WORKDIR /app

# Copia os arquivos de dependências
COPY package*.json ./
RUN npm install

# Copia o código-fonte e executa o build
COPY . .
RUN npm run build

# === Estágio de execução ===
FROM node:16-alpine
WORKDIR /app

# Copia apenas os arquivos necessários
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./

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

O ponto principal é COPY --from=builder. Ele copia arquivos do primeiro estágio (builder) para o segundo. A imagem final contém somente o segundo estágio; todos os artefatos intermediários do primeiro são descartados.

Quando usar build em múltiplos estágios

Alguns cenários típicos:

  1. Linguagens compiladas: Go, Rust e C++, que exigem compilador
  2. Projetos frontend: compilação de TypeScript e empacotamento com Webpack
  3. Dependência de ferramentas de build: projetos Python que usam gcc para compilar algumas bibliotecas

Meu projeto Node.js era um caso típico de TypeScript convertido em JavaScript. O código-fonte ocupava 400 MB, incluindo os pacotes @types dentro de node_modules, enquanto a pasta dist compilada tinha apenas 2 MB. Com o build em múltiplos estágios, o tamanho caiu de 400 MB para 220 MB.

Uma armadilha frequente

Algumas pessoas executam npm install também no estágio de execução, pensando que as dependências precisam ser instaladas de qualquer forma. Não faça isso sem restringir a instalação: as devDependencies também entram na imagem e ocupam espaço desnecessário.

O correto é usar npm install no estágio de build — incluindo as dependências de desenvolvimento necessárias para compilar — e depois copiar todo o node_modules para o estágio de execução. Uma opção mais precisa é:

# Estágio de build
RUN npm install

# Estágio de execução
RUN npm install --production

Assim, somente as dependências de produção são instaladas, reduzindo mais 30% a 40% do tamanho.

No início, o build em múltiplos estágios pode parecer confuso. Depois que você entende o conceito, o desenho fica elegante: é como arrumar a bagagem para uma viagem. Em casa — o estágio de build — você espalha e organiza tudo; ao embarcar — o estágio de execução — leva apenas o que cabe na mala e é realmente necessário.

Técnica 4: use .dockerignore para excluir arquivos desnecessários

O arquivo .dockerignore funciona de forma parecida com o .gitignore, mas muita gente o ignora.

Quando o Dockerfile contém COPY . ., o Docker envia todo o diretório ao Docker daemon como contexto de build. Se o projeto tiver centenas de megabytes em node_modules, histórico do .git, arquivos de teste e logs, tudo isso será enviado e copiado.

Mesmo que os arquivos não sejam usados no final, o build fica mais lento e você pode incluir por engano algo que não deveria entrar na imagem, como segredos de um arquivo .env.

A solução é criar um arquivo .dockerignore na raiz do projeto.

# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
README.md
.vscode
.idea
*.md
.DS_Store
coverage/
.pytest_cache/
__pycache__/
*.pyc
dist-local/

Princípios essenciais

Inclua estes itens:

  1. Diretórios de dependências existentes: node_modules, vendor, target etc. — o build instalará tudo novamente
  2. Configurações de ferramentas de desenvolvimento: .vscode, .idea, .editorconfig
  3. Arquivos relacionados ao Git: .git e .gitignore — a pasta .git costuma ocupar dezenas de megabytes
  4. Documentação: README, CHANGELOG, docs/
  5. Informações sensíveis: .env, credentials.json, *.pem

Em um projeto anterior, esqueci de adicionar .git. A cada build, 500 MB do histórico do repositório eram transferidos. Depois de incluir a pasta no .dockerignore, o build caiu de dois minutos para 30 segundos, e o tamanho da imagem também diminuiu.

Uma técnica prática

Se você não souber quais arquivos estão sendo copiados, faça um build e entre no contêiner:

docker run --rm -it your-image sh
ls -lah

Ao encontrar um arquivo que não deveria estar ali, adicione-o ao .dockerignore.

Parece simples, mas o efeito é imediato. Em projetos frontend, principalmente, dist, node_modules e .cache podem somar gigabytes com facilidade.

Técnica 5: limpe o cache dos gerenciadores de pacotes

Gerenciadores de pacotes como npm, pip, apt e apk deixam caches após a instalação. No desenvolvimento local, esses caches aceleram instalações futuras; dentro de uma imagem Docker, apenas ocupam espaço.

O problema é que muita gente sabe que deve limpar, mas faz isso da maneira errada.

A limpeza precisa acontecer na mesma instrução RUN

Vale repetir o ponto essencial: a limpeza deve ocorrer na mesma camada da instalação.

# ❌ Limpeza ineficaz
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*  # Esta linha não reduz a camada anterior

# ✅ Limpeza eficaz
RUN apt-get update && \
    apt-get install -y curl && \
    rm -rf /var/lib/apt/lists/*

Cada gerenciador de pacotes tem uma forma diferente de limpar o cache. Eis uma lista:

Node.js (npm/yarn)

# npm - forma tradicional
RUN npm install && \
    npm cache clean --force

# npm - forma mais simples (desativa o cache)
RUN npm install --no-cache

# yarn
RUN yarn install && \
    yarn cache clean

Python (pip)

# Forma mais direta: não gerar cache durante a instalação
RUN pip install --no-cache-dir -r requirements.txt

# Ou limpar depois da instalação
RUN pip install -r requirements.txt && \
    rm -rf ~/.cache/pip

Alpine (apk)

# O apk do Alpine oferece uma opção muito prática
RUN apk add --no-cache package-name

# Ou faça a limpeza manualmente
RUN apk add package-name && \
    rm -rf /var/cache/apk/*

Debian/Ubuntu (apt)

RUN apt-get update && \
    apt-get install -y package-name && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

Dados do teste

Testei um projeto Python com as mesmas dependências:

  • Sem limpar o cache: 450 MB
  • Limpando o cache: 320 MB
  • Com --no-cache-dir: 310 MB — o resultado mais limpo

A diferença foi de 140 MB por causa de um único parâmetro.

Ambiente de desenvolvimento vs. produção

Um detalhe importante: a imagem de produção deve instalar apenas os pacotes necessários, com --production ou --no-dev. Dependências de desenvolvimento normalmente representam de 30% a 50% do tamanho total.

# Node.js: instala apenas dependências de produção
RUN npm install --production

# Python: instala apenas os pacotes necessários (separados em requirements.txt)
RUN pip install --no-cache-dir -r requirements-prod.txt

Usadas em conjunto, essas cinco técnicas têm efeito multiplicativo, não apenas cumulativo. Foi assim que meu projeto de 3,2 GB chegou a 180 MB.

Caso completo: todo o processo de otimização de uma aplicação Node.js

Depois da teoria, vamos a um caso real. Esta é a evolução do Dockerfile de um serviço de API Express em que trabalhei.

Antes da otimização (1,2 GB)

FROM node:16
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

É simples e direto, mas a imagem fica enorme.

Primeira otimização: trocar pela imagem base Alpine (→ 400 MB, -67%)

FROM node:16-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

Uma linha alterada economizou 800 MB.

Segunda otimização: adicionar .dockerignore (→ 380 MB, -5%)

Crie o .dockerignore:

node_modules
.git
*.md
.env
coverage

A redução de tamanho parece pequena, mas o build ficou muito mais rápido.

Terceira otimização: build em múltiplos estágios (→ 220 MB, -42%)

# Estágio de build
FROM node:16-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Estágio de execução
FROM node:16-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]

Essa etapa teve o efeito mais significativo, pois descartou todos os arquivos intermediários do processo de build.

Quarta otimização: somente dependências de produção + limpeza de cache (→ 180 MB, -18%)

# Estágio de build
FROM node:16-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Estágio de execução
FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production --no-cache && \
    npm cache clean --force
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]

Versão final: 180 MB, uma redução de 85% em relação aos 1,2 GB iniciais.

Resumo da sequência de otimização

1,2 GB  (node:16 original)
  ↓ troca pelo Alpine
400 MB  (-67%)
  ↓ .dockerignore
380 MB  (-5%)
  ↓ build em múltiplos estágios
220 MB  (-42%)
  ↓ dependências de produção + limpeza
180 MB  (-18%)
────────────────
Redução total de 85%

Quais técnicas funcionaram melhor?

O caso mostra que:

  1. Imagem base Alpine: resultado imediato e adoção simples
  2. Build em múltiplos estágios: maior impacto, mas exige algum aprendizado
  3. Limpeza e dependências de produção: ajustes menores que se somam

Se você tiver pouco tempo, priorize as duas primeiras.

Conclusão

Estas são as cinco técnicas:

  1. Escolher uma imagem base Alpine — reduzir o tamanho desde a origem
  2. Combinar instruções RUN — instalar e limpar na mesma camada
  3. Usar build em múltiplos estágios — levar apenas os arquivos necessários em runtime
  4. Configurar o .dockerignore — excluir arquivos desnecessários e informações sensíveis
  5. Limpar caches dos gerenciadores de pacotes — usar opções como --no-cache

Elas não funcionam de forma isolada; o melhor resultado vem da combinação. Pela minha experiência, Alpine + build em múltiplos estágios resolvem 80% dos problemas de tamanho, e os 20% restantes vêm da limpeza e das exclusões.

Comece agora

Não espere um problema aparecer. Escolha um projeto existente e teste as cinco técnicas:

  1. Veja se é possível mudar para Alpine — provavelmente é
  2. Procure instalações e limpezas separadas no Dockerfile e, se houver, combine-as
  3. Adicione um build em múltiplos estágios — indispensável para projetos compilados
  4. Crie um arquivo .dockerignore
  5. Acrescente a opção --no-cache ao gerenciador de pacotes

Depois do build, compare os tamanhos com docker images e veja quanto foi economizado.

Próximos passos

Se quiser se aprofundar, pesquise:

  • Montagens de cache do Docker BuildKit
  • Imagens Distroless — criadas pelo Google e ainda menores que Alpine
  • Ferramentas de análise de segurança de imagens, como Trivy e Grype

Otimizar um Dockerfile não é uma tarefa pontual, mas um processo de melhoria contínua. Confira o tamanho da imagem a cada build e transforme isso em hábito para mantê-lo sob controle.

Que suas imagens fiquem cada vez mais leves.

Fluxo completo para otimizar um Dockerfile

5 técnicas para reduzir a imagem em 80%, chegando de 3,2 GB a 180 MB, uma redução de 94%

⏱️ Estimated time: 1 hr

  1. 1

    Step 1: Técnica 1: usar uma imagem base Alpine

    Vantagens da imagem base Alpine:
    • Tamanho reduzido (apenas 5 MB, enquanto o Ubuntu tem 200 MB)
    • Mais segurança (superfície de ataque mínima)
    • Gerenciador de pacotes simples (apk)
    • Adequada para produção

    Como usar Alpine:
    • Troque FROM ubuntu:20.04 por FROM alpine:latest
    • Use apk no lugar de apt-get para instalar pacotes: apk add --no-cache nodejs npm
    • A imagem cai de 200 MB para 5 MB, uma redução de 95%
  2. 2

    Step 2: Técnicas 2 e 3: combinar instruções RUN e usar build em múltiplos estágios

    Combinar instruções RUN:
    • Reúna várias instruções RUN em uma só para reduzir o número de camadas
    • Conecte os comandos com && e use \ para quebrar linhas e melhorar a leitura
    • Limpe o cache ao final:
    RUN apt-get update && \
    apt-get install -y nodejs npm && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

    Build em múltiplos estágios:
    • Use uma imagem completa para compilar no primeiro estágio
    • Use uma imagem mínima para executar no segundo estágio
    • Mantenha apenas os arquivos necessários em runtime, sem empacotar as ferramentas de compilação
    • O tamanho da imagem cai bastante
  3. 3

    Step 3: Técnicas 4 e 5: configurar o .dockerignore e limpar caches

    Configurar o .dockerignore:
    • Exclua node_modules, .git, .env, dist e outros arquivos desnecessários
    • Reduza o contexto de build e acelere a construção

    Limpar caches:
    • Use a opção --no-cache: apk add --no-cache
    • Limpe o cache do apt: apt-get clean && rm -rf /var/lib/apt/lists/*
    • Limpe o cache do npm: npm cache clean --force
    • Reduza o tamanho da imagem

FAQ

Quais são as 5 técnicas para otimizar um Dockerfile?
As 5 técnicas são:
1) Usar uma imagem base Alpine (de 200 MB no Ubuntu para 5 MB no Alpine, uma redução de 95%)
2) Combinar instruções RUN (menos camadas e uma imagem menor)
3) Usar build em múltiplos estágios (manter apenas os arquivos necessários em runtime, sem ferramentas de compilação)
4) Configurar o .dockerignore (excluir node_modules, .git e outros arquivos desnecessários)
5) Limpar caches (apt-get clean, npm cache clean etc.)

Resultados da otimização:
• A imagem de uma aplicação Node.js caiu de 3,2 GB para 180 MB (redução de 94%)
• A imagem caiu de 1,2 GB para 180 MB (redução de 85%)
• A implantação passou de 30 minutos para poucos minutos
• O tamanho da imagem diminuiu bastante
Por que a imagem base Alpine é melhor?
Vantagens da imagem base Alpine:
• Tamanho reduzido (apenas 5 MB, enquanto o Ubuntu tem 200 MB)
• Mais segurança (superfície de ataque mínima)
• Gerenciador de pacotes simples (apk)
• Adequada para produção

Como usar Alpine:
• Troque FROM ubuntu:20.04 por FROM alpine:latest
• Use apk no lugar de apt-get para instalar pacotes: apk add --no-cache nodejs npm
• A imagem cai de 200 MB para 5 MB, uma redução de 95%
Como combinar instruções RUN?
Para combinar instruções RUN:
• Reúna várias instruções RUN em uma só para reduzir o número de camadas
• Conecte os comandos com &&
• Use \ para quebrar linhas e melhorar a leitura
• Limpe o cache ao final:
RUN apt-get update && \
apt-get install -y nodejs npm && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

Isso reduz o número de camadas e o tamanho da imagem, além de melhorar a eficiência do build.
Quais são as melhores práticas para otimizar um Dockerfile?
Melhores práticas:
• Usar uma imagem base Alpine
• Combinar instruções RUN
• Usar build em múltiplos estágios
• Configurar o .dockerignore
• Limpar caches
• Atualizar regularmente a versão da imagem base

Essas técnicas não são isoladas e funcionam melhor em conjunto. Pela minha experiência, Alpine + build em múltiplos estágios resolvem 80% dos problemas de tamanho; os 20% restantes vêm da limpeza e das exclusões.

Otimizar um Dockerfile não é uma tarefa pontual, mas um processo de melhoria contínua. Confira o tamanho da imagem a cada build e transforme isso em hábito para mantê-lo sob controle.

14 min de leitura · Publicado em: 17 dez 2025 · Atualizado em: 4 set 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog