Cache
O cache
serve pra salvar arquivos ou diretórios temporariamente entre execuções de pipeline. Assim, evita que jobs tenham que baixar/instalar tudo de novo. É uma forma de armazenar arquivos temporários entre jobs e pipelines, como:
- Dependências (ex: node_modules, .m2, venv, etc.)
- Builds intermediários
- Resultados de testes (em alguns casos)
Não confunda com artifacts, que são usados para passar arquivos entre jobs no mesmo pipeline. O cache pode ser reaproveitado entre pipelines diferentes
(por branch ou tag, se configurado assim).
O cache não é pra entrega de build final, mas pra acelerar o processo.
Vamos analisar o que podemos aproveitar de cache nos jobs.
.check:
stage: check
before_script: # Todos os jobs abaixo que extendem esse template fazem o processo de npm ci
- npm ci
artifacts:
when: always
expire_in: "3 months"
.rules-only-mr-main-develop:
rules:
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
when: always
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"'
when: always
lint-test:
extends:
- .check
- .rules-only-mr-main-develop
# Só este job tem uma imagem diferente pois o comando lint precisa de algumas libs que não tem no nnode:22-alpine que é a imagem default
image: node:22-slim
script:
- npm run lint
artifacts:
reports:
codequality: gl-codequality.json
unit-test:
extends:
- .check
- .rules-only-mr-main-develop
script:
- npm test
artifacts:
reports:
junit: reports/junit.xml
vulnerability-test:
extends: [.check,.rules-only-mr-main-develop]
script:
- npm audit --audit-level=high --json > vulnerability-report.json
artifacts:
paths:
- vulnerability-report.json
Todos os jobs estão rodando a mesma coisa, inclusive o próprio job em outro stage de build também roda esse comando. A diferença esta que isso esta dentro de script depois de dois comandos˜
build:
stage: build
needs: []
extends: [.rules-merged-accepted]
script:
- node --version
- npm --version
- npm ci
- npm run build
....
Podemos alterar para a proposta abaixo. Agora todos eles tem o mesmo before_script que é instalar as dependências.
build:
stage: build
needs: []
extends: [.rules-merged-accepted]
before_script:
- npm ci
script:
- npm run build
Mesmo que alteremos os códigos do projeto, se as dependências de libs são as mesmas e não sofreram nenhuma atualização o npm-ci irá instalar sempre os mesmos módulos. Podemos salvar tudo isso temporariamente como um cache para ganhar velocidade (e outras vantagens) e somente quando uma diferença for encontrada nas dependências mudamos o cache.
Dá forma que vou fazer aqui é uma mera decisão de projeto.
Fazendo um review do .gitlab-ci.yml
default:
tags:
- general
image: node:22-alpine
stages:
- check
- build
- deploy
include:
- 'cicd/globals.yaml' # Vamos incluir todos os templates que podem ser globais a todos os jobs aqui. Precisa vir antes do include dos jobs.
- 'cicd/**/*.yaml'
Dentro de globals vamos colocar os templates que podemos reaproveitar em qualquer um dos jobs de qualquer stage.
# cicd/globals.yaml
.setup-node-deps: # template que usaremos agora.
before_script:
- echo "Usando npm ci com cache inteligente"
- npm ci
cache: # Lembrando que cache pode ser sobreescrito em qualquer um dos jobs se necessário.
key:
files:
- package-lock.json
paths:
- node_modules/ # Itens que vamos fazer o cache
.rules-only-mr-main-develop: # Regra para merge request tanto em main quanto develop
rules:
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
when: always
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"'
when: always
.rules-merged-accepted: # Regra para commit na main ou develop
rules:
- if: '$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "push"'
variables:
TAG: latest
when: always
- if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
variables:
TAG: stable
when: always
Vemos que em .setup-node-deps temos o bloco cache e vamos nos concentrar nesse bloco que pode ser definido em qualquer um dos jobs. Foi definido como template para que possamos fazer o extend em vários jobs e já pegar o bloco before_script e cache.
Documentação oficial sobre cache
A sintaxe básica é essa.
###...
cache:
key: nome-do-cache
paths:
- caminho/para/cachear
###...
Key
- Se mudar a key muda o cache.
- Pode ser fixo, por branch, por arquivo, etc.
Poderíamos pensar em ter um cache diferente por branch com o exemplo abaixo,
cache:
key: "$CI_COMMIT_REF_NAME"
paths:
- caminho/para/cachear
Mas o que fizemos é ter um cache diferente baseado em um hash de um arquivo. Quando o arquivo apontado mudar seu conjunto de bits o hash irá mudar, logo será criado um novo cache, caso contrário aproveitamos o mesmo que anteriormente foi cacheado.
Para esse projeto em nodejs podemos aproveitar usar o arquivo package-lock.json, pois ele registra exatamente quais versões de cada pacote (e suas subdependências) estão na pasta node_module que é o que queremos cachear.
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull-push # padrão: pull-push
when: on_success # padrão: on_success | quando aplicar? Valores permitidos: on_success, on_failure e always
untracked: false # padrão: false | cacheia todos arquivos não versionados se for true (tipo `.gitignore`)
Para fazer o controle de uso do cache temos o policy
.
pull-push
: baixa e salva (padrão) caso não for declarado.pull
: só baixa, não atualizapush
: só salva, não baixa
O armazenamento do cache depende muito de como foi configurado o runner.
No caso de um runner docker local, como eu estou fazendo aqui, para cada cache é criado um docker volume que é montado no job quando ele o requisita.
Geralmente esses volumes começam com runner-
.
Aqui um exemplo de docker volumes que foram criados anteriormente.
docker volume ls --format '{{.Name}}' | grep '^runner-'
runner-jyvyfkmfg-project-69186599-concurrent-0-cache-3c3f060a0374fc8bc39395164f415a70
runner-jyvyfkmfg-project-69186599-concurrent-0-cache-c33bcaa1fd2c77edfc3893b41966cea8
runner-jyvyfkmfg-project-69186599-concurrent-1-cache-3c3f060a0374fc8bc39395164f415a70
runner-jyvyfkmfg-project-69186599-concurrent-1-cache-c33bcaa1fd2c77edfc3893b41966cea8
É bom criar um cronjob para excluir esses volumes semanalmente ou mensalmente dependendo da necessidade. Segue um script básico para essa propostas.
#!/bin/bash
echo "Iniciando limpeza dos volumes docker runner-..."
volumes=$(docker volume ls --format '{{.Name}}' | grep '^runner-')
if [ -z "$volumes" ]; then
echo "Nenhum volume runner- encontrado para deletar."
exit 0
fi
for vol in $volumes; do
echo "Tentando remover volume: $vol"
docker volume rm "$vol" 2>/dev/null && echo "Removido: $vol" || echo "Não foi possível remover: $vol (talvez esteja em uso)"
done
echo "Limpeza finalizada."
Podemos também ter esse cache armazenado em outro lugar (s3, gcs, azure) caso configurado no config.toml
[runners.cache]
MaxUploadedArchiveSize = 0
# Nada aqui esta configurado
[runners.cache.s3]
[runners.cache.gcs]
[runners.cache.azure]
Se estiver usando um runner do GitLab provalmente ele estará guardando o cache dentro de algum desses storages, geralmente com tempo de vida de 7 dias. Se o cache não for acessado durante esse tempo de vida então é eliminado para salvar espaço.
O cache não é compartilhado entre diferentes projetos (repositórios). Se tempos um projeto A e um projeto B idênticos eles possuem caches diferentes mesmo que os hashes dos arquivos sejam o mesmo e a key seja idêntica.
No caso do runner local via Docker, esses volumes que começam com runner- são volumes Docker criados dinamicamente pelo GitLab Runner pra persistir o cache entre jobs. Não existe TTL automático configurado pelo GitLab Runner nativo. Esses volumes ficam lá até você removê-los manualmente ou até o Docker limpar volumes órfãos (via docker volume prune). Por isso coloquei o script acima de varredura para a limpeza.
Vamos começar uma melhoria separando o linter dos outros testes para que ele venha na frente e crie o cache antes dos outros testes. Na verdade eu considero que o linter não é um teste de fato e sim um pré-check antes de qualquer outra coisa.
Vamos fazer a seguinte modificação no .gitlab-ci.yml.
stages:
- pre-check # adicionado
- check
- build
- deploy
Para manter a estrutura do projeto, vamos criar o linter em cicd/pre-check/pre-check.yaml e manter a estrutura do nosso projeto. Se necessário crie a pasta.
#cicd/pre-check/pre-check.yaml
lint-test:
stage: pre-check # Novo stage
extends:
- .setup-node-deps # Extends necessarios
- .rules-only-mr-main-develop
image: node:22-slim
script:
- npm run lint
artifacts:
reports:
codequality: gl-codequality.json
Agora para cicd/check/check.yaml podemos reaproveitar tudo e aliminar o linter daqui.
.check: # Template para stage check
stage: check
extends:
- .setup-node-deps
- .rules-only-mr-main-develop
artifacts:
when: always
expire_in: "3 months"
unit-test:
extends: [.check]
script:
- npm test
artifacts:
reports:
junit: reports/junit.xml
vulnerability-test:
extends: [.check]
script:
- npm audit --audit-level=high --json > vulnerability-report.json
artifacts:
paths:
- vulnerability-report.json
O build só acontecerá caso o merge request for aceito e um push acontecer. Não é necessaŕio refazer todos os testes se já foi aprovado antes. Vejo em muitas empresas esse recheck acontecer de forma que o pipeline para develop ou main seja completo novamente. Particulamente eu não acho necessário, se chegou em develop então a única coisa que temos que fazer é preparar para o deploy, mas isso é uma questão de opinião.
A opinião acima tem muito a ver com a permissão que damos aos mantenedores do repositório. Geralmente quando eu sou o responsável, ninguém nem mesmo os mantenedores, podem forçar um push direto para a branch develop se esta tem um ambiente de deploy. Se isso for permitido, é necessário que o pipeline corra em todos os jobs.
build:
stage: build
#needs: [] # Removido, pois é o inicio desse worksflow só de deploy.
extends:
- .setup-node-deps
- .rules-merged-accepted
script:
- npm run build
artifacts:
when: on_success
expire_in: "1 hour"
paths:
- build/
build-image:
stage: build
needs: [build]
extends: [.rules-merged-accepted]
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
variables:
DOCKER_CONFIG: "/kaniko/.docker"
before_script:
- mkdir -p /kaniko/.docker # Cria o diretório do Docker config
# as variáveis DOCKER_USERNAME E DOCKER_TOKEN devem ser definidas no repositório
- echo "{\"auths\":{\"https://index.docker.io/v1/\":{\"username\":\"$DOCKER_USERNAME\",\"password\":\"$DOCKER_TOKEN\"}}}" > /kaniko/.docker/config.json
script:
- >
/kaniko/executor \
--context "${CI_PROJECT_DIR}" \
--dockerfile "${CI_PROJECT_DIR}/Dockerfile.release" \
--tarPath "image.tar" \
--destination "learn-gitlab-app:latest" \
--no-push
artifacts:
paths:
- image.tar
expire_in: 1 hour
push-image:
stage: build
needs: [build-image]
extends: [.rules-merged-accepted]
image:
name: gcr.io/go-containerregistry/crane:debug
entrypoint: [""]
variables:
REGISTRY: docker.io
script:
- crane auth login $REGISTRY -u $DOCKER_USERNAME -p $DOCKER_TOKEN
- crane push image.tar docker.io/${DOCKER_USERNAME}/${CI_PROJECT_NAME}:${TAG}
Ao fazer o push pro repositório temos logo no merge request nossos checks.
No linter temos o seguinte log.
...
$ echo "Usando npm ci com cache inteligente"
Usando npm ci com cache inteligente
$ npm ci
added 477 packages, and audited 478 packages in 19s
162 packages are looking for funding
run `npm fund` for details
1 moderate severity vulnerability
To address all issues, run:
npm audit fix
Run `npm audit` for details.
$ npm run lint
> [email protected] lint
> eslint -f json -o gl-codequality.json .
Saving cache for successful job
00:06
Creating cache 0_package-lock-704695cbac4cd3d8bf2f2d21eab7ba69acb76ff6-2-non_protected...
node_modules/: found 15696 matching artifact files and directories
No URL provided, cache will not be uploaded to shared cache server. Cache will be stored only locally.
Created cache
Uploading artifacts for successful job # Como não tinha cache antes criou o cache
00:02
Uploading artifacts...
gl-codequality.json: found 1 matching artifact files and directories
Uploading artifacts as "codequality" to coordinator... 201 Created id=10068991800 responseStatus=201 Created token=eyJraWQiO
Cleaning up project directory and file based variables
00:01
Job succeeded
Já no caso de um job posterior (unit-test por exemplo) temos.
Running on runner-jyvyfkmfg-project-69186599-concurrent-0 via d9e6ad7b6e46...
Getting source from Git repository
00:06
Fetching changes with git depth set to 20...
Reinitialized existing Git repository in /builds/puziol/learn-gitlab-app/.git/
Created fresh repository.
Checking out 2a677f3d as detached HEAD (ref is refs/merge-requests/24/head)...
Removing gl-codequality.json
Removing node_modules/
Skipping Git submodules setup
Restoring cache # Reaproveitou o cache
00:15
Checking cache for 0_package-lock-704695cbac4cd3d8bf2f2d21eab7ba69acb76ff6-2-non_protected...
No URL provided, cache will not be downloaded from shared cache server. Instead a local version of cache will be extracted.
Successfully extracted cache
Executing "step_script" stage of the job script
00:40
Using docker image sha256:97c5ed51c64a35c1695315012fd56021ad6b3135a30b6a82a84b414fd6f65851 for node:22-alpine with digest node@sha256:152270cd4bd094d216a84cbc3c5eb1791afb05af00b811e2f0f04bdc6c473602 ...
$ echo "Usando npm ci com cache inteligente"
Usando npm ci com cache inteligente
$ npm ci
added 477 packages, and audited 478 packages in 33s
162 packages are looking for funding
run `npm fund` for details
1 moderate severity vulnerability
To address all issues, run:
npm audit fix
Run `npm audit` for details.
$ npm test
> [email protected] test
> vitest
RUN v3.1.2 /builds/puziol/learn-gitlab-app
✓ src/App.test.jsx > an always true assertion > should be equal to 2 2ms
✓ src/App.test.jsx > App > renders the App component 70ms
✓ src/App.test.jsx > App > shows the GitLab logo 10ms
Test Files 1 passed (1)
Tests 3 passed (3)
Start at 08:27:13
Duration 2.38s (transform 167ms, setup 278ms, collect 104ms, tests 84ms, environment 1.05s, prepare 302ms)
JUNIT report written to /builds/puziol/learn-gitlab-app/reports/junit.xml
HTML Report is generated
You can run npx vite preview --outDir reports/html to see the test results.
Saving cache for successful job
00:11
Creating cache 0_package-lock-704695cbac4cd3d8bf2f2d21eab7ba69acb76ff6-2-non_protected... # GEROU OUTRO CACHE
node_modules/: found 15699 matching artifact files and directories # VEJA QUE aqui node_modules tem uma quantidade de arquivo diferente do linter que era de 15696
No URL provided, cache will not be uploaded to shared cache server. Cache will be stored only locally.
Created cache
Uploading artifacts for successful job
00:03
Uploading artifacts...
reports/junit.xml: found 1 matching artifact files and directories
Uploading artifacts as "junit" to coordinator... 201 Created id=10068991801 responseStatus=201 Created token=eyJraWQiO
Cleaning up project directory and file based variables
00:01
Job succeeded
Vamos entender por que isso aconteceu. Geramos dois caches diferentes para o mesmo comando npm-ci. Temos a pasta node_modules mas o comando npm-ci irá rodar, porém muito mais rápido, mas evita fazer download de pacotes.
O que aconteceu que mesmo rodando na outra imagens mais pacotes foram instalados adicionando pacote ao cache e criando um novo cache.
E isso irá se repetir, toda vez que o npm-ci rodar e encontrar diferença atualiza o cache e no final sempre ficaremos atualizando o cache ao invés de conseguir manter um cache fixo. Se todas a imagens fossem exatamente as mesmas não teriamos problema.
Um ótima estratégia que funciona para tudo é manter o cache sempre por job. Teremos mais caches com certeza, mas evitaremos esse tipo de contratempo e funcionará para qualquer cenário.
Ao aceitar o merge request temos outro pipeline se forma e o build também aproveitará o cache, nesse caso deveria aproveitar o cache do unit test que refez o cache.
Esperamos que o cache seja usado....
#....
Restoring cache
00:02
Checking cache for 0_package-lock-704695cbac4cd3d8bf2f2d21eab7ba69acb76ff6-2-protected...
No URL provided, cache will not be downloaded from shared cache server. Instead a local version of cache will be extracted.
WARNING: Cache file does not exist
Failed to extract cache # MAS NÃO FOI!
Executing "step_script" stage of the job script
00:21
Using docker image sha256:97c5ed51c64a35c1695315012fd56021ad6b3135a30b6a82a84b414fd6f65851 for node:22-alpine with digest node@sha256:152270cd4bd094d216a84cbc3c5eb1791afb05af00b811e2f0f04bdc6c473602 ...
$ echo "Usando npm ci com cache inteligente"
Usando npm ci com cache inteligente
$ npm ci
added 477 packages, and audited 478 packages in 17s
162 packages are looking for funding
run `npm fund` for details
1 moderate severity vulnerability
To address all issues, run:
npm audit fix
Run `npm audit` for details.
$ npm run build
> [email protected] build
> vite build
vite v6.3.2 building for production...
transforming...
✓ 31 modules transformed.
rendering chunks...
computing gzip size...
build/index.html 0.47 kB │ gzip: 0.30 kB
build/assets/react-CHdo91hT.svg 4.13 kB │ gzip: 2.05 kB
build/assets/index-n_ryQ3BS.css 1.39 kB │ gzip: 0.71 kB
build/assets/index-BcKvuBhg.js 147.40 kB │ gzip: 47.66 kB
✓ built in 1.15s
Saving cache for successful job
00:06
Creating cache 0_package-lock-704695cbac4cd3d8bf2f2d21eab7ba69acb76ff6-2-protected...
node_modules/: found 15697 matching artifact files and directories
No URL provided, cache will not be uploaded to shared cache server. Cache will be stored only locally.
Created cache
Uploading artifacts for successful job
00:02
Uploading artifacts...
build/: found 7 matching artifact files and directories
Uploading artifacts as "archive" to coordinator... 201 Created id=10068999747 responseStatus=201 Created token=eyJraWQiO
Cleaning up project directory and file based variables
00:01
Job succeeded
Se observar o log verá que ele não encontrou o cache pois considerou chaves diferetes. Mas como se as chaves são geradadas para a partir do hash do mesmo arquivo que não teve modificação?
No pipeline 1 (de merge request) o unit test que regerou o cache temos:
Creating cache 0_package-lock-704695cbac4cd3d8bf2f2d21eab7ba69acb76ff6-2-**non_protected**
E no pipeline 2 temos:
Checking cache for 0_package-lock-704695cbac4cd3d8bf2f2d21eab7ba69acb76ff6-2-**protected**
O sufixo da chave (-protected vs -non_protected) é diferente.
O GitLab automaticamente adiciona esse sufixo ao cache:key se você estiver usando um cache scoped com protected: true|false, ou se estiver em um job que roda em branch/tag protegida ou não protegida.
- MR vindo de fork ou branch não protegida? ➝ -non_protected
- Branch protegida (main, develop)? ➝ -protected
Isso evita que jobs em MRs ou forks escrevam no cache usado em branches protegidas, por segurança.
No nosso caso o build esta acontecendo em um branch develop que esta protegida.
Caso queira permitir é necessário uma configuração específica no repositório ou grupo no GitLab.
Desmarque a opção Use separate caches for protected branches
e rodando o pipeline novamente conseguiremos que o build também use o cache.
Vamos resolver o problema desses caches colocando um cache por job assim ganhamos velocidades nos futuros pipelines.
.setup-node-deps:
before_script:
- echo "Usando npm ci com cache inteligente"
- npm ci
cache:
key:
# Combina nome do job com hash do package-lock.json
prefix: "${CI_JOB_NAME}-"
files:
- package-lock.json
paths:
- node_modules/