Por que uma imagem Docker pesa dez vezes mais que o app

Um serviço Node com poucas centenas de kilobytes de código-fonte vira uma imagem com mais de 1 GB. Esse é o resultado normal de um build que funciona, não um erro em uma linha específica. O peso vem de uma imagem base que carrega um sistema operacional completo, uma toolchain de build que o app em execução nunca chama, e um cache do gerenciador de pacotes que ninguém mandou o build apagar.

Você paga por isso a cada deploy: o docker pull fica mais lento, e um scanner de vulnerabilidades tem várias centenas de pacotes a mais para reportar. O armazenamento também se acumula em cada tag que você já publicou. Corrigir isso significa reescrever o Dockerfile, não o app.

Qual imagem base escolher: alpine, slim ou distroless

A linha FROM define o ponto de partida. Escolher mal aqui e nada mais no arquivo compensa a diferença.

Imagem baseTamanho aproximadoO que contém
node:261,77 GBDebian completo, compiladores, vários runtimes de linguagens, documentação
node:26-slim371 MBDebian enxuto, sem toolchain de build
node:26-alpine247 MBmusl libc, apk, cerca de 50 pacotes
gcr.io/distroless/nodejs22-debian12212 MBSó o runtime do Node, sem shell, sem gerenciador de pacotes, cerca de 10 pacotes

O padrão se repete fora do Node. debian:bookworm-slim fica em torno de 74 MB contra 77 MB do ubuntu:22.04. alpine:latest fica perto de 7 MB. gcr.io/distroless/static-debian12, feita para um binário Go ou Rust compilado estaticamente que não precisa de nada além dos certificados CA, chega perto de 2 MB.

O Alpine chega a esse tamanho trocando a glibc pela musl libc, e essa troca cobra um preço: módulos nativos compilados contra a glibc (alguns pacotes npm, a maioria dos pacotes pip com extensões em C) travam com segfault ou lançam erros de símbolo ausente sob musl. Uma tag -slim evita o risco e ainda assim reduz a imagem em dois terços. Migre para o Alpine só depois de confirmar que suas dependências toleram a troca.

O distroless vai além. Sem shell, sem gerenciador de pacotes, nada que um atacante possa executar depois de conseguir execução de código, nem um sh para você. Isso vira um custo real na primeira vez que um container se comporta mal em produção.

A ordem das camadas, o .dockerignore e a mecânica de um build multi-stage estão em Boas Práticas de Dockerfile: Builds Menores e Mais Rápidas. Aqui parte-se do princípio de que essa separação já existe e o foco é no que um stage de build sozinho não resolve.

Como manter os caches do gerenciador de pacotes fora da imagem

Um build multi-stage mantém o compilador fora do stage de runtime. Ele não faz nada pelo cache do próprio gerenciador de pacotes, que fica na camada onde a instalação rodou: inofensivo em um stage de build que você descarta, peso morto no stage final se ele também instalar algo.

Os cache mounts do BuildKit resolvem isso sem nenhum passo de limpeza:

1
2
3
4
5
6
7
8
9
# syntax=docker/dockerfile:1
FROM node:26-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build

--mount=type=cache dá a esse RUN um diretório que persiste entre builds e nunca é gravado em uma camada. O cache do npm continua acelerando o próximo build; nada disso chega à imagem. No Python é RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt, no Go --mount=type=cache,target=/root/.cache/go-build.

Sem o BuildKit, diga ao instalador para não fazer cache algum: pip install --no-cache-dir, ou npm ci && npm cache clean --force. As duas partes vão no mesmo RUN. Apagar um arquivo em uma camada posterior o esconde atrás de um whiteout marker e deixa os bytes na camada de baixo, então a versão dividida em dois RUN acaba enviando o cache do mesmo jeito. Um serviço construído pelo Docker Compose com build: . roda o mesmo Dockerfile no mesmo builder, então os cache mounts funcionam sem mudanças.

Como remover símbolos de debug e arquivos que o app nunca lê

Dependências de build são só metade do problema. Dependências de runtime trazem arquivos que nunca são lidos em produção: suítes de teste, documentação .md, caches .pyc compilados, source maps.

No Python, pule o cache do pip e o cache de bytecode na camada que instala:

1
2
3
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-compile -r requirements.txt \
    && find /usr/local/lib -name '__pycache__' -exec rm -rf {} +

Um binário compilado carrega informações de debug que a produção nunca lê. Uma flag no stage de build as remove:

1
2
# Go: remove a tabela de símbolos e as informações de debug DWARF na hora do build
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
1
2
# C/C++/Rust: remove os símbolos de um binário já compilado
RUN strip /app/binary

-ldflags="-s -w" normalmente tira 20-30% de um binário Go. Nada que você note em uma CLI pequena, bytes reais em qualquer coisa com um grafo de dependências grande. Símbolos removidos só custam algo se você conectar um debugger exatamente ao binário rodando em produção. Isso não dá para fazer em um container sem shell para se conectar.

Como descobrir qual camada está deixando a imagem grande

O Docker registrou para onde foram os bytes. Basta ler:

1
docker history myapp:latest

Uma linha por camada, com a instrução que a criou e seu tamanho. Adicione --no-trunc quando várias linhas RUN se parecerem na visão truncada.

Isso aponta a instrução cara, não os arquivos caros dentro dela. O dive faz a segunda metade:

1
dive myapp:latest

Ele abre uma interface de terminal sobre as camadas, colore os arquivos adicionados, modificados e excluídos, e reporta uma pontuação de eficiência junto com o total de bytes desperdiçados: espaço ocupado por arquivos que uma camada posterior sobrescreveu ou apagou mas que nunca saíram da imagem. CI=true dive myapp:latest roda o mesmo check de forma não interativa e sai com código diferente de zero quando a imagem não atinge os limites definidos em um arquivo .dive-ci:

1
2
3
4
rules:
  lowestEfficiency: 0.95
  highestWastedBytes: 20MB
  highestUserWastedPercent: 0.10

Uma imagem que cresceu em silêncio deixa de ser algo que um colega percebe semanas depois e vira um check que falha no pull request que causou o problema.

Quando reduzir a imagem não vale o esforço

Um script que você constrói uma vez e roda na sua própria máquina não precisa de uma base distroless nem de um binário sem símbolos. A imagem nunca sai do seu disco, e o trabalho custa mais do que o gigabyte economizado. O mesmo vale para a imagem de um job de CI de vida curta, reconstruída do zero a cada execução: tirar 200 MB de algo que é baixado uma vez e descartado não traz ganho nenhum.

O trade-off pesa mais na ponta pequena. Uma imagem distroless ou scratch não tem shell, então docker exec -it myapp sh em um container que está se comportando mal falha de cara. Você depura a partir de logs e de uma reprodução local, ou mantém uma variante de debug reservada exatamente para isso (o distroless publica tags -debug com uma shell busybox). O Alpine abre mão de menos dessa comodidade e te dá o risco do musl em troca. Nenhum desses custos é escondido, e é por isso que “use sempre a menor base possível” é um conselho pior do que escolher a base serviço por serviço, de acordo com o que cada serviço realmente precisa.

Por onde começar em uma imagem que você já tem

Rode docker history no que você entrega hoje, antes de mudar qualquer coisa. A camada maior costuma ser uma correção óbvia assim que tem um nome ao lado. Depois rode o dive na mesma imagem com os limites acima. Uma execução que falha na primeira tentativa te dá um número a bater e mostra, arquivo por arquivo, onde está o desperdício — em vez de você ter que adivinhar o que costuma inchar uma imagem.

Artigos relacionados