Todos os artigos
167 artigos · atualizado semanalmente Veja nossas Ferramentas
Todos os artigos
Tutoriais

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.

Como reduzir o tamanho de imagens Docker
COVER · Tutoriais

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-essential que 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 tem sh para entrar no container, então precisa de uma tag :debug separada 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.

RD
Autor
Rafael Duarte
Desenvolvedor backend com passagem por fintech e SaaS B2B — trabalhou em times que escalaram APIs de zero a milhões de requisições. Carrega cicatrizes de produção suficientes para ter opiniões fortes sobre ferramentas, padrões e decisões de arquitetura. Não é acadêmico: leu a RFC do UUID quando precisou escolher entre v4 e v7 para uma tabela de alta escrita.
Ver perfil