Como reduzir o tamanho de imagens Docker
Saiba como reduzir o tamanho de imagens Docker, compare alternativas e evite os erros mais comuns antes de subir para produção.
Você abre o pipeline de CI e vê que o build da imagem está com 1.4 GB. O deploy demora porque cada nó do cluster precisa puxar isso do registry, o scanner de vulnerabilidades acusa 200 CVEs vindas de pacotes que você nunca usou, e a fatura do registry sobe todo mês. Na maioria dos casos o culpado não é a sua aplicação — são camadas de build tools, cache de gerenciadores de pacote e uma base image gorda que ninguém revisou desde o primeiro commit. Imagem grande não é só estética: é tempo de pull, superfície de ataque e custo.
Este artigo é sobre cortar esse peso de forma sistemática, sem quebrar a aplicação. Se a sua imagem também monta volumes em runtime, vale ler depois sobre volumes no Docker, porque o que entra na imagem e o que fica no volume são decisões separadas que muita gente mistura.
Por que imagem gorda dói (e não é só vaidade)
Três custos concretos:
- Tempo de pull e cold start. Cada réplica nova baixa a imagem inteira. Em autoscaling, isso vira latência de provisionamento na pior hora possível — quando o tráfego está subindo.
- Superfície de ataque. Cada binário no filesystem é uma CVE em potencial. Um
apt install build-essentialque sobrou no estágio final carrega compilador, headers e dezenas de libs que não têm motivo nenhum para existir em produção. - Custo de registry e banda. Layers grandes multiplicam por número de tags, ambientes e nós.
A boa notícia: dá para atacar tudo isso com técnicas que não exigem reescrever nada da aplicação.
Multi-stage build: separe construir de executar
A maior alavanca é o multi-stage build. A ideia é simples: um estágio compila/instala dependências com todas as ferramentas pesadas, e um estágio final copia só o artefato pronto. O compilador, o cache do npm, os headers de dev — nada disso sobrevive para a imagem final.
Antes, tudo num estágio só:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
Isso carrega o npm, o cache, devDependencies e a toolchain inteira do node:20 (~1.1 GB). Depois, com multi-stage:
# build stage
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production
# runtime stage
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
O estágio final não tem o código-fonte, nem devDependencies, nem o cache do npm. Para linguagens compiladas (Go, Rust) o ganho é ainda mais brutal: o estágio final pode ser um scratch ou distroless com um único binário estático.
Base image: alpine, slim ou distroless
A escolha da base é a segunda maior alavanca, e tem um caveat que derruba gente todo dia.
-slim(Debian enxuto): mantém glibc, remove docs e pacotes opcionais. Compatibilidade máxima, peso médio. É o default seguro.alpine: minúscula, mas usa musl libc em vez de glibc. Pacotes com binários nativos compilados para glibc podem quebrar de formas sutis — segfault, DNS resolvendo errado, performance estranha em libs que dependem de comportamento específico do alocador. Não é "alpine é ruim", é "alpine exige que você teste de verdade".distroless(Google): sem shell, sem package manager, só a runtime e o seu app. Superfície de ataque mínima e baseado em glibc, então sem o problema do musl. O custo é debug: você não temshpara entrar no container, então precisa de uma tag:debugseparada para investigação.
Minha recomendação prática: -slim para começar, distroless quando a imagem estabilizar e você quiser cortar a superfície de ataque. alpine só com suíte de testes que cobre o runtime real.
Layer caching e .dockerignore
O Docker cacheia por layer. Se você copia o código-fonte inteiro antes de instalar dependências, qualquer mudança numa linha invalida o cache do npm install e ele roda de novo todo build. Por isso o COPY package*.json vem antes do COPY . . — os manifestos mudam menos que o código.
O .dockerignore é o irmão esquecido. Sem ele, o COPY . . joga node_modules local, .git, logs e arquivos de IDE dentro do build context e, potencialmente, da imagem:
.git
node_modules
npm-debug.log
.env
*.md
dist
coverage
Isso reduz o build context (upload mais rápido para o daemon) e evita vazar segredos do .env para dentro da imagem.
Combine RUN e limpe na mesma camada
Cada RUN é uma layer. E layers são append-only: deletar um arquivo num RUN posterior não recupera o espaço — o arquivo continua na layer anterior, só fica "escondido". Por isso instalar e limpar precisa acontecer no mesmo RUN:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
Se o rm -rf /var/lib/apt/lists/* estivesse num RUN separado, o cache do apt continuaria pesando na layer anterior. O --no-install-recommends corta pacotes "sugeridos" que quase nunca são necessários.
Não leve a toolchain de build para produção
Esse é o erro que vejo mais. O estágio final não deveria ter compilador, headers de dev (-dev packages), nem ferramentas de teste. Se você precisa compilar uma extensão nativa, faça isso no estágio de build e copie só o resultado. Um sintoma clássico: python:3.12 final com gcc e build-essential instalados porque um pacote pip precisou compilar na hora. Solução: compilar wheels no build stage, instalar wheels prontos no runtime stage.
Perguntas frequentes
alpine sempre deixa a imagem menor?
Menor em disco, sim, quase sempre. Mas "menor" não é o único critério. O musl libc pode causar incompatibilidades sutis com binários nativos compilados para glibc, e em alguns casos o tempo de build sobe porque você recompila coisas que viriam prontas em Debian. Use alpine quando o ganho de tamanho justificar o custo de testar o runtime real.
Multi-stage build deixa o build mais lento?
Não significativamente, e muitas vezes deixa mais rápido por causa de cache melhor. Cada estágio é cacheado independentemente, então mudanças no código não invalidam o estágio de dependências. O ganho de pull e deploy compensa qualquer overhead marginal.
Distroless vale a pena se não dá para dar shell no container?
Para produção, sim. A ausência de shell e package manager é justamente o ponto: corta a superfície de ataque e remove ferramentas que um invasor usaria. Para debug, use a variante :debug da imagem, que inclui um busybox shell, só em ambiente de investigação.
Como meço o que está ocupando espaço na imagem?
docker history <imagem> mostra o tamanho de cada layer e o comando que a criou. Ferramentas como dive permitem navegar layer por layer e ver exatamente quais arquivos entraram em cada uma. Comece pela maior layer.
O que levar para o próximo build
Reduzir imagem Docker é disciplina, não mágica. A ordem de impacto costuma ser: multi-stage build primeiro (separa build de runtime), base image enxuta depois (-slim ou distroless), e então higiene de layer — .dockerignore, RUN combinados, limpeza na mesma camada e nenhuma toolchain de build no estágio final. Meça com docker history e dive antes e depois de cada mudança; otimizar no escuro é como tunar query sem EXPLAIN. O objetivo não é a imagem mais minúscula possível, é a menor imagem que ainda dá para operar e debugar com segurança.
- 01 Nubank Croma: o que vem no plano, quanto custa e pra quem realmente compensa O Nubank lançou o Croma, um plano de média renda entre o cartão gratuito e o Ultravioleta. Veja o que está incluído, o cashback real, os R$ 39 de mensalidade (e como zerar) e faça a conta antes de aderir.
- 02 Como criar layouts responsivos sem frameworks (CSS puro em 2026) Monte layouts responsivos com Flexbox, Grid, clamp() e container queries — sem Bootstrap nem Tailwind. Veja benefícios, limites e quando vale.