Por Que Seu Docker Build Demora Tanto? Guia Prático de Otimização
Já aconteceu com você? Muda uma linha de código, faz push, e fica olhando o CI rodar por 15 minutos. De novo. Já é a quinta vez hoje.
Se isso parece familiar, você não está sozinho. Build lento de Docker é daqueles problemas que todo mundo sofre mas ninguém resolve porque "é assim mesmo". Só que isso afeta muito mais do que parece: mata produtividade, alonga cada iteração, e transforma um hotfix de 5 minutos em uma hora de espera.
A boa notícia: a maioria dos builds lentos tem conserto. E não estou falando de melhorias marginais. Com as técnicas certas, dá pra reduzir o tempo de build em 80% a 95%. Aquele build de 15 minutos pode terminar em menos de um minuto.
Neste guia vamos ver por que os builds ficam lentos e como resolver passo a passo.
O Custo Real de Builds Lentos
Antes de entrar no técnico, vamos fazer as contas de por que isso importa.
Os Números
Imagina um time de 10 devs:
- Cada um faz 5 commits por dia
- Cada commit dispara um build de 15 minutos
- São 750 minutos de espera por dia — 12 horas e meia
- No ano (250 dias úteis): 3.125 horas — equivalente a 1,5 engenheiro trabalhando tempo integral só esperando build
Se você baixar o build pra 2 minutos, essas 3.125 horas viram 417. Basicamente você "contratou" um engenheiro extra sem gastar com recrutamento.
O Problema da Concentração
O que você faz enquanto espera o build? Slack? Twitter? 15 minutos é muito pra ficar olhando, mas pouco pra começar algo novo.
Estudos de produtividade dizem que depois de uma interrupção leva uns 23 minutos pra voltar a se concentrar. Quando o build termina, você já perdeu o fio da meada. Se isso acontece 5 vezes por dia... bom, lá se foi a manhã.
Como Funciona o Cache do Docker
A chave pra otimizar builds é entender o cache. Se você não entender isso, todo o resto é inútil.
A Mecânica do Build
Quando você roda docker build, o Docker processa o Dockerfile linha por linha. Mas não executa tudo do zero toda vez:
- Lê cada instrução (FROM, RUN, COPY, etc.)
- Verifica se tem essa camada cacheada de um build anterior
- Se o cache é válido, reutiliza. Se não, executa a instrução.
- Quando o cache é invalidado em uma camada, todas as seguintes são reconstruídas
O ponto 4 é o mais importante. O cache do Docker é uma cadeia. Se quebra no passo 3, os passos 4, 5, 6 e todos os outros rodam de novo, mesmo que não tenham mudado.
O Que Invalida o Cache?
| Instrução | Invalida quando... |
|---|---|
RUN | O comando muda |
COPY | O conteúdo ou permissões dos arquivos mudam |
ADD | Igual ao COPY, mais conteúdo de URLs |
ARG | O valor do argumento muda |
ENV | O valor da variável muda |
A mais problemática é COPY. Se você coloca COPY . . no início do Dockerfile, qualquer mudança em qualquer arquivo (até o README) invalida o cache de tudo que vem depois.
Os 7 Erros Mais Comuns em Dockerfiles
Builds lentos têm padrões. Evite esses e você já vai ver melhorias.
Erro 1: Copiar Tudo Antes de Instalar
# ❌ Errado: O erro clássico FROM node:20 WORKDIR /app COPY . . # Qualquer mudança quebra o cache RUN npm install # Reinstala TUDO toda vez RUN npm run build
Muda uma linha de código e npm install roda de novo. Em projetos com 500 dependências, isso são 3-5 minutos jogados fora.
# ✅ Certo: Copiar estrategicamente FROM node:20 WORKDIR /app COPY package*.json ./ # Só os arquivos de dependências RUN npm install # Vai pro cache se não mudar COPY . . # Depois o resto RUN npm run build
Se package.json não mudou, npm install usa o cache. Só copia o código novo e faz o build.
Erro 2: DevDependencies em Produção
# ❌ Errado: TypeScript, Jest, tudo vai pra produção FROM node:20 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build CMD ["node", "dist/server.js"]
A imagem final tem todas as devDependencies que nunca vão ser usadas.
# ✅ Certo: Multi-stage build FROM node:20 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-slim AS production WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY /app/dist ./dist CMD ["node", "dist/server.js"]
O build acontece no builder, produção só tem o necessário.
Erro 3: Não Usar .dockerignore
Sem .dockerignore, cada docker build manda node_modules, .git, e tudo pro daemon. São centenas de MB toda vez.
# .dockerignore - Crie isso
node_modules
.git
*.md
.env*
.vscode
coverage
dist
*.log
.DS_Store
node_modules especialmente é inútil mandar — você vai reinstalar de qualquer forma.
Erro 4: Imagens Base Pesadas
# ❌ Muito pesado FROM node:20 # 1.1GB FROM python:3.11 # 1.0GB
# ✅ Melhor FROM node:20-slim # 240MB FROM node:20-alpine # 140MB FROM python:3.11-slim # 150MB FROM python:3.11-alpine # 52MB
Cuidado com alpine — usa musl libc ao invés de glibc e alguns pacotes nativos podem falhar. Teste antes de ir pra produção.
Erro 5: apt-get Separado
# ❌ Camadas desnecessárias RUN apt-get update RUN apt-get install -y curl RUN apt-get install -y git RUN apt-get clean
# ✅ Tudo junto RUN apt-get update && \ apt-get install -y --no-install-recommends curl git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*
Menos camadas, e o clean tem que estar na mesma camada que o install pra realmente economizar espaço.
Erro 6: Não Usar BuildKit
BuildKit é o builder moderno do Docker, padrão desde a versão 23.0. Mas muitos sistemas ainda têm ele desabilitado.
export DOCKER_BUILDKIT=1 docker build . # Ou em daemon.json { "features": { "buildkit": true } }
Com BuildKit:
- Stages independentes rodam em paralelo
- O cache é mais inteligente
- Você pode passar secrets de forma segura
- Você pode usar cache mounts (isso é o que importa)
Erro 7: Não Fixar Versões
# ❌ Sempre baixa a última → nunca cacheia RUN curl -L https://example.com/latest/binary -o /usr/bin/binary
# ✅ Versão fixa → cacheia ARG BINARY_VERSION=1.2.3 RUN curl -L https://example.com/v${BINARY_VERSION}/binary -o /usr/bin/binary
Com versões explícitas, Docker pode reutilizar downloads anteriores.
BuildKit Cache Mounts — Aqui Está a Mágica
Tudo até aqui são boas práticas. Mas se você quer que o build fique realmente rápido, precisa de cache mounts.
Cache do Package Manager
# Node.js FROM node:20 WORKDIR /app COPY package*.json ./ RUN \ npm ci # Go FROM golang:1.21 WORKDIR /app COPY go.mod go.sum ./ RUN \ go mod download # Python FROM python:3.11 WORKDIR /app COPY requirements.txt ./ RUN \ pip install -r requirements.txt # Rust FROM rust:1.73 WORKDIR /app COPY Cargo.toml Cargo.lock ./ RUN \ cargo build --release
--mount=type=cache mantém esse diretório entre builds. Os pacotes que você já baixou são reutilizados. npm, pip, go modules — quase instantâneos.
Cache de Compilação
Algumas linguagens se beneficiam de caches do compilador:
# C/C++ com ccache FROM gcc:12 RUN apt-get update && apt-get install -y ccache ENV PATH="/usr/lib/ccache:${PATH}" WORKDIR /app COPY . . RUN \ make -j$(nproc)
Compartilhar Caches Entre Builds
Por padrão os cache mounts são isolados por build. Mas você pode compartilhar:
RUN \ npm ci
sharing=shared permite que builds concorrentes leiam e escrevam no mesmo cache.
Cache de Build
# Next.js RUN \ npm run build
Next.js guarda info de builds anteriores em .next/cache. Se você mantém isso, só recompila o que mudou.
Multi-Stage Bem Feito
Multi-stage não é só pra imagens menores. É pra paralelização e separação de cache.
O Padrão de Build Paralelo
# Stage 1: Dependências FROM node:20 AS deps WORKDIR /app COPY package*.json ./ RUN npm ci # Stage 2: Build (depois de deps) FROM deps AS builder COPY . . RUN npm run build # Stage 3: Testes (depois de deps, paralelo com build) FROM deps AS tester COPY . . RUN npm test # Stage 4: Lint (depois de deps, paralelo com build e testes) FROM deps AS linter COPY . . RUN npm run lint # Final: Produção FROM node:20-slim AS production WORKDIR /app COPY /app/dist ./dist COPY /app/node_modules ./node_modules CMD ["node", "dist/server.js"]
BuildKit roda builder, tester e linter em paralelo porque todos só dependem de deps. O que seria 6 minutos sequenciais pode virar 2 minutos paralelos.
Builds Condicionais
ARG BUILD_ENV=production FROM node:20 AS base WORKDIR /app COPY package*.json ./ RUN npm ci FROM base AS development COPY . . CMD ["npm", "run", "dev"] FROM base AS production-builder COPY . . RUN npm run build FROM node:20-slim AS production WORKDIR /app COPY /app/dist ./dist COPY /app/node_modules ./node_modules CMD ["node", "dist/server.js"]
Build assim:
docker build --target development -t myapp:dev . docker build --target production -t myapp:prod .
Cache no CI/CD
No local o cache se mantém. No CI cada job é um container novo. Precisa configurar cache externo.
GitHub Actions
name: Build on: push jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: myapp:latest cache-from: type=gha cache-to: type=gha,mode=max
type=gha usa o cache nativo do GitHub Actions.
GitLab CI
build: stage: build image: docker:24 services: - docker:dind variables: DOCKER_BUILDKIT: 1 script: - docker build --cache-from $CI_REGISTRY_IMAGE:cache --build-arg BUILDKIT_INLINE_CACHE=1 -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -t $CI_REGISTRY_IMAGE:cache . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - docker push $CI_REGISTRY_IMAGE:cache
--cache-from puxa camadas do registry, BUILDKIT_INLINE_CACHE=1 embute metadata de cache na imagem.
Serviço de Cache de Build
Pra times grandes, você pode usar serviços especializados:
docker buildx build \ --cache-from type=registry,ref=myregistry/myapp:buildcache \ --cache-to type=registry,ref=myregistry/myapp:buildcache,mode=max \ -t myapp:latest .
Estratégias por Linguagem
Node.js / JavaScript
FROM node:20-alpine AS deps WORKDIR /app # npm ci pra instalações determinísticas COPY package*.json ./ RUN \ npm ci --prefer-offline FROM deps AS builder COPY . . # Cache do Next.js RUN \ npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production # Só arquivos necessários COPY /app/public ./public COPY /app/.next/standalone ./ COPY /app/.next/static ./.next/static CMD ["node", "server.js"]
O .next/cache do Next.js guarda artefatos de build que aceleram os rebuilds.
Python
FROM python:3.11-slim AS builder # Dependências de build RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Virtual environment RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . RUN \ pip install --no-compile -r requirements.txt FROM python:3.11-slim AS runtime COPY /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" WORKDIR /app COPY . . CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]
O virtualenv é criado no builder e copiado pro runtime, sem ferramentas de build.
Go
Go produz binários estáticos excelentes:
FROM golang:1.21-alpine AS builder WORKDIR /app # Dependências primeiro (pra cache) COPY go.mod go.sum ./ RUN \ go mod download COPY . . RUN \ CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server . # Runtime mínimo - só o binário FROM scratch COPY /app/server /server COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ ENTRYPOINT ["/server"]
A imagem final pode ter menos de 20MB — só o binário e certificados TLS.
Rust
FROM rust:1.73 AS planner WORKDIR /app RUN cargo install cargo-chef COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM rust:1.73 AS cacher WORKDIR /app RUN cargo install cargo-chef COPY /app/recipe.json recipe.json RUN \ cargo chef cook --release --recipe-path recipe.json FROM rust:1.73 AS builder WORKDIR /app COPY /app/target target COPY /usr/local/cargo /usr/local/cargo COPY . . RUN cargo build --release FROM debian:bookworm-slim AS runtime COPY /app/target/release/myapp /usr/local/bin/ CMD ["myapp"]
cargo-chef compila dependências separadamente, melhorando muito o cache.
Medindo Performance
Você não pode otimizar o que não pode medir. Assim você perfila um build:
Timing com BuildKit
# Build com timing detalhado DOCKER_BUILDKIT=1 docker build --progress=plain . 2>&1 | tee build.log # Parsear tempos grep "DONE\|CACHED" build.log
O Comando docker history
docker history myapp:latest --format "{{.Size}}\t{{.CreatedBy}}"
Mostra o tamanho de cada camada e o comando. Útil pra identificar passos inchados.
Script de Benchmark
#!/bin/bash # benchmark-build.sh iterations=5 echo "Benchmarking Docker build..." # Limpar cache docker builder prune -f >/dev/null 2>&1 # Build frio (sem cache) start=$(date +%s.%N) docker build -t myapp:bench . >/dev/null 2>&1 end=$(date +%s.%N) cold_time=$(echo "$end - $start" | bc) echo "Build frio: ${cold_time}s" # Builds quentes total=0 for i in $(seq 1 $iterations); do start=$(date +%s.%N) docker build -t myapp:bench . >/dev/null 2>&1 end=$(date +%s.%N) duration=$(echo "$end - $start" | bc) total=$(echo "$total + $duration" | bc) echo "Build quente $i: ${duration}s" done avg=$(echo "scale=2; $total / $iterations" | bc) echo "Média build quente: ${avg}s"
Caso Real: De 14 Minutos para 47 Segundos
Aplicamos isso num monorepo React.
O Dockerfile Original
FROM node:18 WORKDIR /app COPY . . RUN npm install RUN npm run build CMD ["npx", "serve", "-s", "build"]
- Tempo: 14 minutos 23 segundos (média de 10 builds)
- Imagem: 1.8GB
Problemas:
COPY . .antes denpm install- Sem
.dockerignore - Imagem node completa (1.1GB)
- Sem multi-stage (devDependencies em produção)
Passo 1: .dockerignore + Ordem do COPY
FROM node:18 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build ...
# .dockerignore
node_modules
.git
build
coverage
- Tempo: 6 minutos 12 segundos (57% menos)
- Com cache: 2 minutos 8 segundos
Passo 2: Multi-stage
FROM node:18-slim AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM deps AS builder COPY . . RUN npm run build FROM node:18-slim AS runner WORKDIR /app RUN npm install -g serve COPY /app/build ./build EXPOSE 3000 CMD ["serve", "-s", "build"]
- Tempo: 4 minutos 45 segundos (67% menos que o original)
- Imagem: 450MB
Passo 3: Cache Mounts
FROM node:18-slim AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM deps AS builder COPY . . RUN npm run build FROM nginx:alpine AS runner COPY /app/build /usr/share/nginx/html EXPOSE 80
- Tempo: 1 minuto 52 segundos (87% menos que o original)
- Com cache: 47 segundos (97% menos!)
- Imagem: 42MB
Resultado Final
| Métrica | Antes | Depois | Melhoria |
|---|---|---|---|
| Build frio | 14m 23s | 1m 52s | 87% |
| Build com cache | 14m 23s | 47s | 97% |
| Tamanho imagem | 1.8GB | 42MB | 98% |
| Custo CI mensal | ~$180 | ~$12 | 93% |
Problemas Comuns
"O cache não funciona"
- CI efêmero: Cada job é um container novo → use cache de registry
- BuildKit desabilitado:
docker info | grep buildkit - ARG que muda: Se o valor de um ARG muda, invalida tudo que vem depois
- Comandos com tempo:
RUN datesempre invalida
# ❌ Invalida cache todo build RUN echo "Build: $(date)" > /build-info.txt # ✅ Usar build arg ARG BUILD_TIME RUN echo "Build: $BUILD_TIME" > /build-info.txt
"Multi-stage ficou mais lento"
Se você não tem BuildKit, os stages não paralelizam:
docker info | grep -i buildkit
"Cache mounts não funcionam no CI"
Mounts persistentes são locais. Use cache externa:
- name: Build run: | docker buildx build \ --cache-from type=gha \ --cache-to type=gha,mode=max \ -t myapp .
Checklist de Otimização
-
DOCKER_BUILDKIT=1configurado -
.dockerignoreexiste (node_modules, .git, etc.) - Dependências são copiadas primeiro, código depois
- Imagem base slim ou alpine
- Multi-stage separando build de runtime
-
--mount=type=cachepra package managers - Cache configurado no CI
- Comandos RUN combinados
- Dependências externas com versão fixa
- Tempos de build medidos
Conclusão
Otimizar Docker builds é daquelas coisas que você faz uma vez e dá valor pra sempre. As técnicas deste guia — ordem de camadas, multi-stage, cache mounts — podem baixar builds de 15 minutos pra menos de um minuto.
Builds rápidos não só economizam tempo:
- Feedback loops mais curtos
- Menor custo de CI
- Mais deploys por dia
- Devs mais felizes
Comece pelo fácil: .dockerignore, ordem do COPY, habilitar BuildKit. Depois adicione multi-stage e cache mounts. Meça cada mudança.
Você vai se surpreender com quanta velocidade estava escondida no seu Dockerfile.
Explore ferramentas relacionadas
Experimente estas ferramentas gratuitas do Pockit