Destino Dev

Blog

Acelerando builds Docker em CI usando cache BuildKit (export/import) no GitHub Actions

Por John Pires · 08 de março de 2026

Acelerando builds Docker em CI usando cache BuildKit (export/import) no GitHub Actions

1) Problema real que resolve

Contexto e impacto

Em pipelines de CI que constroem imagens Docker com frequência, o tempo gasto no passo de build pode dominar a duração total do pipeline. Isso afeta feedback loop, custo de runners e produtividade da equipe. A raiz do problema é que a maioria dos workflows de CI roda em runners efêmeros sem cache de camadas Docker entre execuções, forçando rebuilds completos mesmo para mudanças triviais.

O que o cache de BuildKit resolve

O BuildKit permite exportar e importar caches de camadas (layer cache) para fontes externas — por exemplo, um registry (ghcr/dockrhub), um diretório local ou o GitHub Actions cache — e reutilizar camadas em builds subsequentes. Exportar/importar cache reduz drasticamente passos repetitivos como apt/yum installs, compilação de dependências e installs de pacotes, entregando builds de minutos para segundos.

Problemas típicos que veremos neste artigo:

  • Builds que ignoram cache e refazem tudo
  • Uso incorreto de cache-from sem push do cache
  • Corridas concorrentes que sobrescrevem cache

2) Abordagem ingênua vs abordagem otimizada

Jeito errado (code-review): pipeline sem cache correto

O que vejo com frequência em projetos: um workflow simples que roda docker build (ou buildx) e faz push da imagem, mas sem exportar o cache. Às vezes usam --cache-from apontando para uma imagem que nunca é atualizada ou esquecem de habilitar buildx. Resultado: cache não é reaproveitado entre execuções.

# Exemplo de workflow ingênuo (trecho relevante)
- name: Build image
  run: |
    docker build -t myorg/myapp:${{ github.sha }} .
    docker push myorg/myapp:${{ github.sha }}

Contexto: esse bloco constrói e empurra a imagem, mas não usa BuildKit cache export/import. O que acontece: cada runner começa do zero, fazendo download e instalação de dependências novamente.

Falhas comuns nessa abordagem

Erros que costumo apontar em code review:

  1. Uso de docker build legacy sem buildx — sem suporte nativo a cache-to/registry.
  2. Uso de --cache-from apontando para imagens sem layers de cache ou imagem não existente.
  3. Esquecer de autenticar no registry antes de tentar usar cache baseado em registry.

Jeito certo (otimizado): export/import de cache com BuildKit

O padrão robusto é usar docker buildx junto com --cache-to e --cache-from apontando para um registro (por exemplo ghcr.io ou docker.io). Workflow estabelece um builder (setup-buildx), autentica no registry, usa build-push-action para exportar/importar cache em cada build e faz push da imagem.

Principais vantagens:

  • Reaproveitamento de camadas entre runs e mesmo entre branches
  • Redução substancial do tempo de build em etapas custosas
  • Compatibilidade com multi-arch quando necessário

3) Implementacao com codigo real

Requisitos práticos

Para o exemplo prático precisamos de:

  • GitHub Actions runner
  • Permissão de push para o registry usado (GITHUB_TOKEN com packages: write para ghcr)
  • docker/build-push-action (v4+) e docker/setup-buildx-action

Dockerfile real (boa prática para cache)

Contexto: um Dockerfile Node.js que separa dependências do código para maximizar cache de camadas. Isso reduz rebuilds quando apenas o código muda. O bloco abaixo é um exemplo realista (não um hello world).

# Dockerfile
# Etapa de dependências — raramente muda
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN apk add --no-cache python3 make g++ \
  && npm ci --production

# Etapa build — para apps que precisam compilar assets
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Etapa final
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

O que acontece: a camada de dependências é isolada. Se package.json não mudar, a etapa deps será cacheada e restaurada via BuildKit cache. Isso reduz tempo de build quando só o código muda.

Workflow GitHub Actions com cache export/import

Contexto: workflow funcional que autentica no GitHub Container Registry (ghcr), configura buildx e usa cache-to/cache-from com ref baseado no image tag. Esse pattern é seguro para pipelines sequenciais e pode ser extendido para concorrência com chaves únicas.

# .github/workflows/docker-build.yml
name: ci-build
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Build and push with cache
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: ghcr.io/myorg/myapp:${{ github.sha }}
          cache-from: type=registry,ref=ghcr.io/myorg/myapp:buildcache
          cache-to: type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max

O que acontece: buildx tenta importar cache de ghcr.io/myorg/myapp:buildcache e, ao final do build, atualiza/buildcache no registry. Em runs seguintes, esse cache é reutilizado para acelerar.

Comandos locais para reproduzir

Contexto: testar o mesmo comportamento localmente usando buildx para entender o que o CI faz. Assuma que você já está autenticado no registry.

# Teste local: importar cache e depois exportar
docker buildx create --name mybuilder --use
docker buildx build --cache-from=type=registry,ref=ghcr.io/myorg/myapp:buildcache \
  --cache-to=type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max \
  -t ghcr.io/myorg/myapp:test . --push

O que acontece: a primeira vez fará upload do cache. Execuções subsequentes reaproveitam camadas do cache remoto e são muito mais rápidas.

4) Testes e validacao

Como medir ganhos

Valide tempo de build com e sem cache. Ferramentas simples servem: cronometre execução do step build na Actions UI ou registre logs de tempo dentro do job. Métricas esperadas: redução de 60-95% no tempo para builds que reutilizam cache de dependências.

Testes que recomendo

  1. Executar push com cache vazio (primeiro build) e registrar tempo.
  2. Executar outro push sem alterar package.json e medir o tempo — deve cair substancialmente.
  3. Alterar package.json e confirmar que as camadas certas são invalidada e recontruídas.
  4. Executar builds concorrentes (pull requests) e observar comportamento do cache, testando estratégias para evitar race conditions.

Use logs detalhados em build-push-action adicionando build-args ou ativando progress=plain para depurar o que foi cacheado.

Exemplo de validação via logs

Contexto: como forçar logs mais verbosos para ver uso de cache.

# No step build, adicione build-args ou extra options
- name: Build and push with verbose
  uses: docker/build-push-action@v4
  with:
    context: .
    push: true
    tags: ghcr.io/myorg/myapp:${{ github.sha }}
    cache-from: type=registry,ref=ghcr.io/myorg/myapp:buildcache
    cache-to: type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max
    outputs: type=log

O que acontece: dependendo da versão da action, você verá detalhes no log sobre import/export de cache e quais etapas reaproveitaram cache.

5) Consideracoes de producao

Erros comuns, armadilhas e dicas práticas

Abaixo resumo armadilhas que vejo em code review e como corrigi-las:

  • Cache-from sem cache-to: apontar para um cache remoto que nunca foi populado. Resultado: cache não encontrado e build sem aceleração. Solução: sempre garantir que algum pipeline (ou tag) popula a referência cache-to antes de depender dela.
  • Permissões insuficientes: GITHUB_TOKEN precisa de permissions: packages: write para ghcr. Revisar secrets se usar docker hub.
  • Race conditions entre builds concorrentes: múltiplos jobs podem escrever no mesmo cache ref; considerar usar chaves por branch ou por commit, ou estratégias de mode=max que mesclam caches. Outra alternativa é usar um cache per-branch e um job que faça merge periódico.
  • Tamanho do cache: caches grandes podem incrementar custo e latência ao puxar. Limite o que entra no cache (ex.: não cachear diretórios enormes ou dados temporários).
  • Inline cache vs registry cache: inline (type=inline) injeta metadados no manifest da imagem — útil para registries que expõem o cache via image, mas requer push da imagem. Registry cache (type=registry) separa cache em referência explicita e costuma ser mais flexível.
Tip: prefira cache-to=type=registry,mode=max para ambientes CI onde vários runners devem compartilhar cache; use branch-specific refs para evitar sobrescrita entre branches.

Escalabilidade e segurança

Em grandes organizações, considere políticas para retenção de cache, limites de tamanho e controle de acesso a registries. Logs e métricas são cruciais: monitore hit-rate do cache (percentual de etapas que reaproveitam cache) e latência de builds. Isso ajuda priorizar otimizações no Dockerfile (por exemplo, priorizar instalar dependências antes de copiar código).

Privacidade: não armazene chaves ou secrets no cache. BuildKit suporta secret mounting (--secret) que não persiste em camadas, use isso para credenciais temporárias durante build.

Conclusao prática

Resumo direto

Use buildx com cache-to/cache-from apontando para um registry para reduzir dramaticamente tempos de build em CI. Corrija Dockerfiles para maximizar reutilização de camadas (separe dependências do código), configure autenticação correta e valide ganhos com testes controlados.

Checklist de adoção

  • Ativar docker/setup-buildx-action
  • Autenticar no registry (ex.: ghcr) com permissões de escrita
  • Configurar cache-from e cache-to com ref consistente (ex.: myapp:buildcache)
  • Ajustar Dockerfile para separar etapas mutáveis de imutáveis
  • Monitorar hit-rate e ajustar estratégia de ref/branch para evitar race conditions

Aplicando esse padrão você transforma builds que antes consumiam grande parte do tempo de CI em passos rápidos e previsíveis. Em code review, foque em: Dockerfile bomb-proof para cache, workflow com export/import do cache e validação de permissões e concorrência.

Este artigo mostrou o jeito errado (ignorar cache) e o jeito certo (BuildKit export/import), com exemplos práticos para GitHub Actions, Dockerfile e comandos locais para reproduzir. Adapte as refs e políticas de cache para o seu fluxo de trabalho e monitore o comportamento em produção.

Assuntos: Docker, github-actions, buildkit, ci, cache, DevOps

Leia também