Cache de dependências no CI: reutilizando node_modules, vendor, etc.
1. Por que o cache de dependências é crítico em pipelines CI?
Em ambientes de Integração Contínua (CI), cada execução de pipeline normalmente começa com um ambiente limpo. Isso significa que todas as dependências precisam ser baixadas e instaladas do zero — um processo que pode consumir de 2 a 10 minutos em projetos médios e até 30 minutos em monorepos complexos. O problema se agrava quando multiplicamos esse tempo por dezenas de execuções diárias, resultando em custos elevados de computação e desperdício de banda de rede.
É fundamental distinguir entre dois tipos de cache:
- Cache de dependências: armazena node_modules, vendor, .bundle — pastas gerenciadas por npm, Composer, Bundler, etc.
- Cache de build: armazena artefatos compilados, como bundles webpack ou binários compilados.
O cache de dependências foca em reutilizar bibliotecas que raramente mudam entre execuções. Enquanto isso, o cache de build lida com saídas geradas a partir do código-fonte — algo que Git versiona indiretamente via hashes de commit.
Cenários típicos incluem:
# Node.js (npm)
npm ci --cache .npm --prefer-offline
# PHP (Composer)
composer install --no-dev --prefer-dist --no-interaction
# Ruby (Bundler)
bundle install --path vendor/bundle --jobs 4 --retry 3
Cada comando acima pode ser otimizado com cache, reduzindo a instalação de 5 minutos para 10 segundos quando o cache é válido.
2. Estratégias de chaveamento de cache (cache keys) baseadas em Git
A escolha da chave de cache determina quando o cache será invalidado. Usar informações do Git permite um controle granular sobre essa decisão.
Usando git rev-parse HEAD~1 para cache incremental
# Chave baseada no commit anterior + hash do lockfile
CACHE_KEY=$(echo "$(git rev-parse HEAD~1):$(git hash-object package-lock.json)" | md5sum | cut -d' ' -f1)
Essa estratégia permite que o cache seja reutilizado mesmo após commits que não alteram dependências, desde que o hash do lockfile permaneça o mesmo. O acréscimo do commit anterior evita conflitos entre branches diferentes.
Combinando hash de package-lock.json com git log
# Chave que considera apenas mudanças no lockfile nos últimos 5 commits
LOCK_HASH=$(git log --oneline -5 -- package-lock.json | md5sum | cut -d' ' -f1)
CACHE_KEY="deps-${LOCK_HASH}"
Essa abordagem é mais conservadora: qualquer alteração no histórico recente do lockfile invalida o cache. É útil quando você quer garantir que dependências estejam sempre sincronizadas com alterações de configuração.
Chave com lockfile hash + branch name
# Evita contaminação entre branches
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
LOCK_HASH=$(git hash-object package-lock.json 2>/dev/null || echo "no-lock")
CACHE_KEY="${BRANCH_NAME}-${LOCK_HASH}"
Essa é a estratégia mais segura para repositórios com múltiplos branches ativos. Cada branch mantém seu próprio cache, evitando que dependências de uma feature branch poluam o cache da main branch.
3. Implementação de cache em pipelines CI com Git-aware fallback
Estrutura de pastas e versionamento
# Estrutura típica de cache
~/.cache/npm/
project/node_modules/
project/vendor/
project/.bundle/
Para versionar essas pastas no cache, utilizamos comandos Git específicos:
# Restaurar cache parcial usando git checkout-index
git checkout-index --prefix=/tmp/cache/ -a
cp -r /tmp/cache/node_modules ./node_modules
Script de fallback completo
#!/bin/bash
# restore_cache.sh
CACHE_FILE="/tmp/deps-cache.tar.gz"
LOCK_HASH=$(git hash-object package-lock.json 2>/dev/null || echo "no-lock")
CACHE_KEY="${CI_COMMIT_BRANCH}-${LOCK_HASH}"
# Tentar restaurar do cache
if [ -f "${CACHE_FILE}" ]; then
echo "Restaurando cache existente..."
tar -xzf "${CACHE_FILE}" -C .
# Verificar integridade com git hash-object
if [ "$(git hash-object node_modules/.cache-key 2>/dev/null)" = "$CACHE_KEY" ]; then
echo "Cache válido. Pulando instalação."
exit 0
fi
fi
# Fallback: instalar dependências e gerar novo cache
echo "Cache expirado ou ausente. Instalando dependências..."
npm ci --prefer-offline --no-audit --no-fund
composer install --no-dev --prefer-dist --no-interaction
# Gerar novo tarball com git archive (apenas arquivos versionados)
echo "Gerando novo cache..."
echo "$CACHE_KEY" > node_modules/.cache-key
tar -czf "${CACHE_FILE}" node_modules vendor .bundle
4. Gerenciando dependências de múltiplos projetos no mesmo repositório (monorepo)
Cache seletivo por workspace
# Detectar mudanças em workspaces específicos
CHANGED_PACKAGES=$(git diff --name-only HEAD~1 HEAD -- packages/ | cut -d'/' -f1-2 | sort -u)
for pkg in $CHANGED_PACKAGES; do
if [ -f "${pkg}/package-lock.json" ]; then
echo "Cache invalidado para: ${pkg}"
# Atualizar cache apenas desse workspace
(cd "${pkg}" && npm ci --cache .npm --prefer-offline)
fi
done
Compartilhando cache com git submodule ou git subtree
# Invalidar cache de um submódulo específico
SUB_CHANGES=$(git submodule status | grep '^+' | awk '{print $2}')
for sub in $SUB_CHANGES; do
(cd "submodules/${sub}" && git log --oneline -1 -- package-lock.json)
# Atualizar cache do submódulo
(cd "submodules/${sub}" && npm ci --prefer-offline)
done
Invalidar apenas o vendor de um módulo específico
# Usando git log para detectar mudanças em composer.json de um módulo
MODULE="packages/api"
if git log --oneline HEAD~5..HEAD -- "${MODULE}/composer.json" | grep -q .; then
echo "Mudanças detectadas em ${MODULE}. Invalidando cache..."
(cd "${MODULE}" && composer install --no-dev --prefer-dist)
fi
5. Git hooks e cache: evitando instalações desnecessárias
Pre-commit hook para verificar mudanças no lockfile
#!/bin/bash
# .git/hooks/pre-commit
# Verificar se package-lock.json foi alterado no staged
if git diff --cached --name-only --diff-filter=M | grep -q "package-lock.json"; then
echo "AVISO: package-lock.json foi modificado. Execute 'npm ci' localmente."
echo "Para forçar o commit, use --no-verify"
exit 1
fi
Post-commit hook para atualizar cache local
#!/bin/bash
# .git/hooks/post-commit
# Atualizar cache baseado no último commit
LAST_COMMIT=$(git rev-parse HEAD)
LOCK_HASH=$(git hash-object package-lock.json 2>/dev/null || echo "no-lock")
# Se o lockfile mudou, reinstalar dependências
if git diff --name-only HEAD~1..HEAD | grep -q "package-lock.json"; then
echo "Lockfile alterado. Reinstalando dependências..."
npm ci --prefer-offline
fi
Integração com git bisect para testar versões antigas
# Script para git bisect que reutiliza cache
#!/bin/bash
# .git/bisect/run
# Restaurar cache do commit atual
git checkout-index --prefix=/tmp/cache/ -a
cp -r /tmp/cache/node_modules ./node_modules 2>/dev/null || npm ci
# Executar testes
npm test
6. Boas práticas de segurança e consistência no cache
Checksum com git hash-object
# Gerar checksum do lockfile para validar cache
LOCK_CHECKSUM=$(git hash-object package-lock.json)
CACHE_CHECKSUM=$(cat node_modules/.checksum 2>/dev/null)
if [ "$LOCK_CHECKSUM" != "$CACHE_CHECKSUM" ]; then
echo "Cache corrompido ou desatualizado. Reinstalando..."
rm -rf node_modules
npm ci
echo "$LOCK_CHECKSUM" > node_modules/.checksum
fi
Limpeza automática de caches de branches deletadas
#!/bin/bash
# cleanup_ci_cache.sh
# Listar branches que já foram mergeadas e deletadas
MERGED_BRANCHES=$(git branch --merged origin/main | grep -v "main" | grep -v "\*")
for branch in $MERGED_BRANCHES; do
echo "Limpando cache da branch: ${branch}"
# Comando específico do seu CI para remover cache
# curl -X DELETE "https://api.ci.com/cache/${branch}"
done
Estratégia de cache por runner com validação Git
# Validar estado do repositório antes de usar cache
if git rev-parse --verify HEAD > /dev/null 2>&1; then
echo "Repositório em estado válido. Usando cache..."
# Restaurar cache
else
echo "Repositório inconsistente. Instalação limpa..."
git clean -fdx
npm ci
fi
7. Métricas e otimização: medindo o impacto do cache
Comparação de tempo com e sem cache
#!/bin/bash
# measure_cache_impact.sh
# Pipeline sem cache
echo "Executando pipeline sem cache..."
START=$(date +%s)
npm ci
END=$(date +%s)
echo "Sem cache: $((END - START)) segundos"
# Pipeline com cache
echo "Executando pipeline com cache..."
START=$(date +%s)
npm ci --prefer-offline --cache .npm
END=$(date +%s)
echo "Com cache: $((END - START)) segundos"
# Registrar estatísticas no Git
echo "$(git log --oneline -1): $(date +%Y-%m-%d): $((END - START))s" >> cache_stats.txt
Monitoramento de hits/misses
# Script para registrar hits e misses do cache
CACHE_HIT=false
if [ -f node_modules/.cache-key ]; then
PREV_KEY=$(cat node_modules/.cache-key)
CURRENT_KEY=$(git rev-parse HEAD)
if [ "$PREV_KEY" = "$CURRENT_KEY" ]; then
CACHE_HIT=true
fi
fi
# Registrar no log do Git
if $CACHE_HIT; then
echo "CACHE HIT: $(git show --no-patch --format='%H %s' HEAD)" >> cache_audit.log
else
echo "CACHE MISS: $(git show --no-patch --format='%H %s' HEAD)" >> cache_audit.log
fi
Quando desabilitar o cache
# Cenários de segurança: forçar instalação limpa
if [ "${CI_SECURITY_SCAN}" = "true" ]; then
echo "Scan de segurança ativo. Ignorando cache..."
git clean -fdx
npm ci --no-audit --no-fund
else
# Usar cache normalmente
./restore_cache.sh
fi
Referências
- Git - git-hash-object Documentation — Documentação oficial do comando
git hash-object, usado para gerar checksums de arquivos de lock no cache. - GitHub Actions - Caching dependencies to speed up workflows — Guia oficial do GitHub Actions sobre cache de dependências, com exemplos práticos para Node.js, PHP e Ruby.
- GitLab CI - Cache dependencies in GitLab CI/CD — Documentação oficial do GitLab sobre estratégias de cache, incluindo cache keys baseadas em branches e hashes.
- CircleCI - Caching Dependencies — Tutorial completo do CircleCI sobre cache de dependências, com exemplos de fallback e validação de cache.
- npm - npm ci command documentation — Documentação oficial do comando
npm ci, essencial para instalações rápidas e consistentes em CI com cache. - Composer - Optimizing for CI/CD — Guia do Composer para otimização em pipelines CI, incluindo cache de vendor e uso de
--prefer-dist. - Atlassian - Git performance tips for CI/CD pipelines — Tutorial avançado sobre otimização de Git em CI, com técnicas para cache incremental e validação de estado do repositório.