Como criar imagens Docker leves usando multistage builds e minimizar camadas
Docker & Containers · Por Kevin Marcondes · Publicado em · Atualizado em · 10 min de leitura
Este guia mostra, na prática, como criar imagens Docker leves usando multistage builds e minimizar camadas. Você verá exemplos de Dockerfile para Node.js e Go, técnicas de cache com BuildKit, .dockerignore, distroless e como medir tamanho e tempo de build para validar ganhos.
Pré‑requisitos e cenário de teste (Node.js)
Você precisa do Docker instalado e funcionando. Se ainda não tem, siga este guia: instalar Docker no Linux. Vamos criar um app Node.js simples para medir antes e depois do multistage.
Usaremos também um .dockerignore para evitar copiar arquivos desnecessários. Cada etapa vem comentada para você reproduzir e adaptar ao seu projeto.
- Docker instalado e usuário com permissão para executar containers
- Conexão à internet para baixar bases de imagens
- Editor de texto e terminal
mkdir -p nodeapp && cd nodeapp
cat > package.json <<'EOF'
{
"name": "nodeapp",
"private": true,
"type": "module",
"scripts": { "start": "node server.js" }
}
EOF
cat > server.js <<'EOF'
import http from 'node:http'
const port = process.env.PORT || 3000
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('ok')
})
server.listen(port, () => console.log(`listening on ${port}`))
EOF
cat > .dockerignore <<'EOF'
node_modules
npm-cache
.git
Dockerfile*
.dockerignore
*.log
EOF
Camadas, cache e BuildKit em poucas palavras
Cada instrução no Dockerfile cria uma camada. Menos camadas bem projetadas ajudam no cache e no pull. O cache evita refazer passos quando nada mudou. O BuildKit melhora paralelismo, cache granular e mounts de cache por ferramenta.
Vamos ligar o BuildKit para aproveitar cache avançado e uma saída de build mais clara.
- COPY de arquivos estáveis antes de arquivos voláteis aumenta o cache hit
- Agrupar comandos relacionados reduz camadas
- Mounts de cache do BuildKit aceleram npm, pip e go
# Ative o BuildKit no shell atual
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
# Opcional: usar builder moderno
docker buildx create --use --name fastbuilder 2>/dev/null || docker buildx use fastbuilder
Dockerfile ingênuo (sem multistage) para comparar
Vamos criar um Dockerfile básico que copia tudo e instala dependências. É funcional, mas maior que o necessário. Depois, comparamos com a versão multistage.
Se você está começando com Dockerfile, este outro guia dá o contexto inicial: criar seu primeiro Dockerfile.
- Base oficial completa aumenta o tamanho final
- COPY . . leva arquivos desnecessários sem .dockerignore bem feito
- Instalar devDependencies por engano infla a imagem
cat > Dockerfile.naive <<'EOF'
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
EOF
# Build inicial
docker build -f Dockerfile.naive -t nodeapp:naive .
# Ver o tamanho
docker images nodeapp:naive
Multistage build enxuto para Node.js (com distroless)
Agora, um Dockerfile multistage. Primeiro resolvemos dependências em uma imagem de build. Depois copiamos apenas o necessário para uma imagem final mínima baseada em distroless, sem shell.
Imagens distroless reduzem superfície de ataque e tamanho, porém não permitem shell interativo. Use logs e probes de saúde para diagnosticar.
- Separar deps de runtime evita levar ferramentas de build
- distroless elimina shell e pacote gerenciador do runtime
- COPY seletivo garante só o essencial no estágio final
cat > Dockerfile.multi <<'EOF'
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
RUN corepack enable
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Se usar TypeScript/Vite/etc., descomente sua etapa de build
# RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=build /app /app
EXPOSE 3000
# distroless executa "node" como entrypoint; indique o script
CMD ["server.js"]
EOF
# Build otimizado
docker build -f Dockerfile.multi -t nodeapp:multi .
# Checar tamanho
docker images | grep nodeapp
Exemplo extra: binário Go em imagem mínima
Para linguagens compiladas, o ganho é ainda maior. Construímos o binário em uma imagem de build e copiamos apenas o executável para uma base mínima distroless estática.
Rodar como usuário não‑root é boa prática. Ajuste GOOS/GOARCH conforme o alvo do seu deploy.
- CGO desativado gera binário estático e portátil
- Use cache de módulos para acelerar builds repetidos
- ENTRYPOINT direto no binário simplifica
mkdir -p ../goapp && cd ../goapp
cat > go.mod <<'EOF'
module example.com/goapp
go 1.22
EOF
cat > main.go <<'EOF'
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
http.ListenAndServe(":8080", nil)
}
EOF
cat > Dockerfile.go <<'EOF'
# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod .
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/app
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/app /app/app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/app"]
EOF
# Build e ver tamanho
docker build -f Dockerfile.go -t goapp:distroless .
docker images goapp:distroless
cd -
Técnicas para minimizar camadas e arquivos
Além do multistage, combine comandos e ignore arquivos no contexto do build. Com BuildKit, mounts de cache aceleram gerenciadores de pacotes. A tabela resume quando usar cada técnica.
Se sua CI/CD usa GitHub Actions, integre cache de build para ganhos consistentes: pipeline de deploy automático.
- Agrupe RUN com && e limpeza no mesmo layer
- Use .dockerignore para não enviar lixo ao daemon
- Coloque COPY package*.json antes do COPY . para cache
- Cache de npm/pip/go acelera builds repetidos
| Técnica | Quando usar | Observações |
|---|---|---|
| .dockerignore | Sempre | Reduz contexto de build e invalidações desnecessárias |
| RUN combinado | Base Debian/Alpine | Limpeza no mesmo RUN evita camadas com cache de pacotes |
| Mount de cache (BuildKit) | npm/pip/go | Reaproveita caches entre builds sem ir para imagem final |
| distroless/scratch | Produção | Menor superfície de ataque; sem shell, use logs e probes |
| Usuário não‑root | Sempre | Reduz impacto de vulnerabilidades no runtime |
# Exemplo de RUN combinado (Debian/Ubuntu base)
cat > Dockerfile.snippets <<'EOF'
FROM debian:stable-slim AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# BuildKit cache de npm
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# .dockerignore típico
# node_modules\n.git\nDockerfile*\n.dockerignore\n*.log\nbuild\ndist\ncoverage\n
EOF
Como medir tamanho, camadas e ganhos de cache
Vamos comparar builds e inspecionar camadas. Os números variam por máquina, rede e cache local. Use sem cache, depois com cache, para perceber a diferença.
Você pode gerenciar containers e ver métricas com interface usando o Portainer: como usar o Portainer.
- Meça sem cache para linha de base
- Repita com cache para ver a diferença
- Use docker history para ver camadas e tamanhos
# Na pasta nodeapp
cd nodeapp
# Linha de base (sem cache)
/usr/bin/time -f "naive sem cache: %E" docker build --no-cache -f Dockerfile.naive -t nodeapp:naive .
/usr/bin/time -f "multi sem cache: %E" docker build --no-cache -f Dockerfile.multi -t nodeapp:multi .
docker images | grep nodeapp
# Exemplo (pode variar):
# nodeapp multi 29MB
# nodeapp naive 180MB
# Com cache (repetir build)
/usr/bin/time -f "naive com cache: %E" docker build -f Dockerfile.naive -t nodeapp:naive .
/usr/bin/time -f "multi com cache: %E" docker build -f Dockerfile.multi -t nodeapp:multi .
# Inspecione camadas
docker history nodeapp:naive | head -n 10
docker history nodeapp:multi | head -n 10
Como confirmar que funcionou e solucionar problemas
Suba os containers e faça uma requisição HTTP. Lembre: imagens distroless não têm shell. Use logs, healthchecks e variáveis de ambiente para debug.
Se algo falhar, verifique portas, permissões de arquivo e variáveis de ambiente. Ajuste o COPY e o .dockerignore para incluir apenas o necessário.
- Erro EACCES? Avalie permissões e usuário não‑root
- Sem shell no distroless: use docker logs e probes
- Build quebrando? Confirme ordem de COPY e cache
- Porta errada? Confira EXPOSE e mapeamento -p
# Node (porta 3000)
docker run -d --rm -p 3000:3000 --name nodeapp nodeapp:multi
curl -sf http://localhost:3000 && echo "\nOK"
docker logs nodeapp | tail -n +1
# Go (porta 8080)
docker run -d --rm -p 8080:8080 --name goapp goapp:distroless
curl -sf http://localhost:8080 && echo "\nOK"
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
Checklist rápido antes de commitar
Antes de versionar seu Dockerfile, valide estes pontos. Eles evitam builds lentos e imagens pesadas no futuro. Para aprofundar em infraestrutura de execução, veja também este comparativo: Docker vs VMs para HA.
- .dockerignore cobre node_modules, logs, diretórios de build
- COPY package*.json antes do COPY .
- Usa multistage e imagem final mínima (slim/distroless)
- Sem ferramentas de build no estágio final
- Executa como usuário não‑root, quando possível
- BuildKit ativado e caches funcionando
Perguntas frequentes
Por que multistage builds geram imagens menores?
Porque você separa a fase de build (com compiladores e ferramentas) do runtime. No estágio final, copia apenas os artefatos necessários. Assim, bibliotecas e caches temporários do build não vão para a imagem que roda em produção.
Quando usar distroless em vez de alpine ou slim?
Use distroless quando quiser reduzir superfície de ataque e tamanho, e não precisar de shell no runtime. É ideal para serviços com observabilidade e healthchecks configurados. Se você precisa de depuração interativa no container, prefira alpine ou slim.
Alpine sempre deixa a imagem menor?
Muitas vezes, sim, mas nem sempre. Em Node.js e Python, diferenças de bibliotecas e compatibilidade podem afetar tamanho e performance. Teste alpine e slim. Compare build time, tamanho e comportamento em produção antes de decidir.
Como usar cache de dependências com BuildKit?
Adicione no Dockerfile instruções com RUN usando --mount=type=cache apontando para o diretório de cache da ferramenta (npm, pip, go). Mantenha COPY de arquivos de manifesto (package.json, go.mod) antes do COPY do código para maximizar acertos de cache.
Posso usar scratch como imagem final?
Sim, para binários estáticos (como Go com CGO desativado). A imagem scratch é vazia e muito pequena. Porém, você não terá shell, certificados ou timezones. Se precisar de certificados TLS, copie-os no build ou use uma distroless estática que já inclua o necessário.
Como inspecionar camadas e entender o que pesa?
Use docker history para ver camadas e tamanhos. Combine com docker system df -v para entender uso de cache local. Reorganize o Dockerfile para maximizar cache e remova arquivos gerados que não são necessários no estágio final.
Quer revisar seus Dockerfiles e pipelines?
Se você deseja ajuda prática para reduzir o tamanho das suas imagens e acelerar builds com multistage e BuildKit, podemos conversar sobre o seu caso.
Falar sobre otimização de buildsVeja também
- tutorial: implementar Canary releases com Traefik em Docker Compose usando headers e weight routing para deploys graduais de aplicações PHP
- Tutorial: configurar HAProxy com Let's Encrypt e healthchecks para balanceamento de tráfego entre containers Docker em VPS (passo a passo)
- Tutorial prático: implementar mTLS entre Nginx e microserviços usando smallstep CA em Docker Compose
- Tutorial: configurar cgroup v2 e systemd ResourceControl para limitar CPU, memória e I/O de containers Docker no Debian