Back

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:

  1. Lê cada instrução (FROM, RUN, COPY, etc.)
  2. Verifica se tem essa camada cacheada de um build anterior
  3. Se o cache é válido, reutiliza. Se não, executa a instrução.
  4. 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çãoInvalida quando...
RUNO comando muda
COPYO conteúdo ou permissões dos arquivos mudam
ADDIgual ao COPY, mais conteúdo de URLs
ARGO valor do argumento muda
ENVO 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 --from=builder /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 --mount=type=cache,target=/root/.npm \ npm ci # Go FROM golang:1.21 WORKDIR /app COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download # Python FROM python:3.11 WORKDIR /app COPY requirements.txt ./ RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txt # Rust FROM rust:1.73 WORKDIR /app COPY Cargo.toml Cargo.lock ./ RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ 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 --mount=type=cache,target=/root/.ccache \ make -j$(nproc)

Compartilhar Caches Entre Builds

Por padrão os cache mounts são isolados por build. Mas você pode compartilhar:

RUN --mount=type=cache,target=/root/.npm,id=npm-cache,sharing=shared \ npm ci

sharing=shared permite que builds concorrentes leiam e escrevam no mesmo cache.

Cache de Build

# Next.js RUN --mount=type=cache,target=/app/.next/cache \ 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 --from=builder /app/dist ./dist COPY --from=deps /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 --from=production-builder /app/dist ./dist COPY --from=production-builder /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 --mount=type=cache,target=/root/.npm \ npm ci --prefer-offline FROM deps AS builder COPY . . # Cache do Next.js RUN --mount=type=cache,target=/app/.next/cache \ npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production # Só arquivos necessários COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /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 --mount=type=cache,target=/root/.cache/pip \ pip install --no-compile -r requirements.txt FROM python:3.11-slim AS runtime COPY --from=builder /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 --mount=type=cache,target=/go/pkg/mod \ go mod download COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server . # Runtime mínimo - só o binário FROM scratch COPY --from=builder /app/server /server COPY --from=builder /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 --from=planner /app/recipe.json recipe.json RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ cargo chef cook --release --recipe-path recipe.json FROM rust:1.73 AS builder WORKDIR /app COPY --from=cacher /app/target target COPY --from=cacher /usr/local/cargo /usr/local/cargo COPY . . RUN cargo build --release FROM debian:bookworm-slim AS runtime COPY --from=builder /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 de npm 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 --from=builder /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 --mount=type=cache,target=/root/.npm npm ci FROM deps AS builder COPY . . RUN npm run build FROM nginx:alpine AS runner COPY --from=builder /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étricaAntesDepoisMelhoria
Build frio14m 23s1m 52s87%
Build com cache14m 23s47s97%
Tamanho imagem1.8GB42MB98%
Custo CI mensal~$180~$1293%

Problemas Comuns

"O cache não funciona"

  1. CI efêmero: Cada job é um container novo → use cache de registry
  2. BuildKit desabilitado: docker info | grep buildkit
  3. ARG que muda: Se o valor de um ARG muda, invalida tudo que vem depois
  4. Comandos com tempo: RUN date sempre 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=1 configurado
  • .dockerignore existe (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=cache pra 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.

dockerdevopsoptimizationcicdcontainersbuildkitperformance

Explore ferramentas relacionadas

Experimente estas ferramentas gratuitas do Pockit