Skip to main content

Caché

El cache sirve para guardar archivos o directorios temporalmente entre ejecuciones de pipeline. Así, evita que jobs tengan que descargar/instalar todo de nuevo. Es una forma de almacenar archivos temporales entre jobs y pipelines, como:

  • Dependencias (ej: node_modules, .m2, venv, etc.)
  • Builds intermedios
  • Resultados de tests (en algunos casos)

No confundas con artifacts, que son usados para pasar archivos entre jobs en el mismo pipeline. El caché puede ser reaprovechado entre pipelines diferentes (por branch o etiqueta, si está configurado así).

El caché no es para entrega de build final, sino para acelerar el proceso.

Vamos a analizar lo que podemos aprovechar de caché en los jobs.

.check:
stage: check
before_script: # Todos los jobs abajo que extienden este template hacen el proceso 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ólo este job tiene una imagen diferente pues el comando lint necesita de algunas libs que no tiene en node:22-alpine que es la imagen por defecto
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 los jobs están ejecutando la misma cosa, inclusive el propio job en otro stage de build también ejecuta ese comando. La diferencia está que esto está dentro de script después de dos comandos

build:
stage: build
needs: []
extends: [.rules-merged-accepted]
script:
- node --version
- npm --version
- npm ci
- npm run build
....

Podemos alterar para la propuesta abajo. Ahora todos ellos tienen el mismo before_script que es instalar las dependencias.

build:
stage: build
needs: []
extends: [.rules-merged-accepted]
before_script:
- npm ci
script:
- npm run build

Aunque alteremos los códigos del proyecto, si las dependencias de libs son las mismas y no sufrieron ninguna actualización npm-ci instalará siempre los mismos módulos. Podemos guardar todo eso temporalmente como un caché para ganar velocidad (y otras ventajas) y solamente cuando una diferencia sea encontrada en las dependencias cambiamos el caché.

De la forma que voy a hacer aquí es una mera decisión de proyecto.

Haciendo un review del .gitlab-ci.yml

default:
tags:
- general
image: node:22-alpine

stages:
- check
- build
- deploy

include:
- 'cicd/globals.yaml' # Vamos a incluir todos los templates que pueden ser globales a todos los jobs aquí. Necesita venir antes del include de los jobs.
- 'cicd/**/*.yaml'

Dentro de globals vamos a poner los templates que podemos reaprovechar en cualquiera de los jobs de cualquier stage.

# cicd/globals.yaml
.setup-node-deps: # template que usaremos ahora.
before_script:
- echo "Usando npm ci con caché inteligente"
- npm ci
cache: # Recordando que caché puede ser sobrescrito en cualquiera de los jobs si es necesario.
key:
files:
- package-lock.json
paths:
- node_modules/ # Ítems que vamos a hacer el caché

.rules-only-mr-main-develop: # Regla para merge request tanto en main como 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: # Regla para commit en main o 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 en .setup-node-deps tenemos el bloque cache y vamos a concentrarnos en ese bloque que puede ser definido en cualquiera de los jobs. Fue definido como template para que podamos hacer el extend en varios jobs y ya coger el bloque before_script y cache.

Documentación oficial sobre cache

La sintaxis básica es ésta.

###...
cache:
key: nombre-del-cache
paths:
- ruta/para/cachear
###...

Key

  • Si cambia la key cambia el caché.
  • Puede ser fijo, por branch, por archivo, etc.

Podríamos pensar en tener un caché diferente por branch con el ejemplo abajo,

  cache:
key: "$CI_COMMIT_REF_NAME"
paths:
- ruta/para/cachear

Pero lo que hicimos es tener un caché diferente basado en un hash de un archivo. Cuando el archivo apuntado cambie su conjunto de bits el hash cambiará, luego será creado un nuevo caché, caso contrario aprovechamos el mismo que anteriormente fue cacheado.

Para este proyecto en nodejs podemos aprovechar usar el archivo package-lock.json, pues registra exactamente qué versiones de cada paquete (y sus subdependencias) están en la carpeta node_module que es lo que queremos cachear.

  cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull-push # por defecto: pull-push
when: on_success # por defecto: on_success | ¿cuándo aplicar? Valores permitidos: on_success, on_failure y always
untracked: false # por defecto: false | cachea todos archivos no versionados si es true (tipo `.gitignore`)

Para hacer el control de uso del caché tenemos policy.

  • pull-push: descarga y guarda (por defecto) caso no sea declarado.
  • pull: sólo descarga, no actualiza
  • push: sólo guarda, no descarga

El almacenamiento del caché depende mucho de cómo fue configurado el runner.

En el caso de un runner docker local, como estoy haciendo aquí, para cada caché es creado un docker volume que es montado en el job cuando lo requisita.

Generalmente esos volúmenes comienzan con runner-.

Aquí un ejemplo de docker volumes que fueron creados 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

Es bueno crear un cronjob para excluir esos volúmenes semanalmente o mensualmente dependiendo de la necesidad. Sigue un script básico para esa propuesta.

#!/bin/bash

echo "Iniciando limpieza de los volúmenes docker runner-..."

volumes=$(docker volume ls --format '{{.Name}}' | grep '^runner-')

if [ -z "$volumes" ]; then
echo "Ningún volumen runner- encontrado para borrar."
exit 0
fi

for vol in $volumes; do
echo "Intentando eliminar volumen: $vol"
docker volume rm "$vol" 2>/dev/null && echo "Eliminado: $vol" || echo "No fue posible eliminar: $vol (tal vez esté en uso)"
done

echo "Limpieza finalizada."

Podemos también tener ese caché almacenado en otro lugar (s3, gcs, azure) si está configurado en config.toml

 [runners.cache]
MaxUploadedArchiveSize = 0
# Nada aquí está configurado
[runners.cache.s3]
[runners.cache.gcs]
[runners.cache.azure]

Si estás usando un runner de GitLab probablemente estará guardando el caché dentro de alguno de esos storages, generalmente con tiempo de vida de 7 días. Si el caché no es accedido durante ese tiempo de vida entonces es eliminado para ahorrar espacio.

El caché no es compartido entre diferentes proyectos (repositorios). Si tenemos un proyecto A y un proyecto B idénticos poseen cachés diferentes aunque los hashes de los archivos sean iguales y la key sea idéntica.

En el caso del runner local vía Docker, esos volúmenes que comienzan con runner- son volúmenes Docker creados dinámicamente por GitLab Runner para persistir el caché entre jobs. No existe TTL automático configurado por GitLab Runner nativo. Esos volúmenes quedan allí hasta que los elimines manualmente o hasta que Docker limpie volúmenes huérfanos (vía docker volume prune). Por eso puse el script arriba de barrido para la limpieza.

Vamos a comenzar una mejora separando el linter de los otros tests para que venga delante y cree el caché antes de los otros tests. En verdad yo considero que el linter no es un test de hecho sino un pre-check antes de cualquier otra cosa.

Vamos a hacer la siguiente modificación en .gitlab-ci.yml.

stages:
- pre-check # añadido
- check
- build
- deploy

Para mantener la estructura del proyecto, vamos a crear el linter en cicd/pre-check/pre-check.yaml y mantener la estructura de nuestro proyecto. Si es necesario crea la carpeta.

#cicd/pre-check/pre-check.yaml
lint-test:
stage: pre-check # Nuevo stage
extends:
- .setup-node-deps # Extends necesarios
- .rules-only-mr-main-develop
image: node:22-slim
script:
- npm run lint
artifacts:
reports:
codequality: gl-codequality.json

Ahora para cicd/check/check.yaml podemos reaprovechar todo y eliminar el linter de aquí.

.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

El build sólo ocurrirá si el merge request es aceptado y un push ocurre. No es necesario rehacer todos los tests si ya fue aprobado antes. Veo en muchas empresas ese recheck ocurrir de forma que el pipeline para develop o main sea completo nuevamente. Particularmente yo no lo encuentro necesario, si llegó a develop entonces la única cosa que tenemos que hacer es preparar para el deploy, pero esto es una cuestión de opinión.

La opinión arriba tiene mucho que ver con el permiso que damos a los maintainers del repositorio. Generalmente cuando yo soy el responsable, nadie ni siquiera los maintainers, pueden forzar un push directo para la branch develop si ésta tiene un entorno de deploy. Si eso es permitido, es necesario que el pipeline corra en todos los jobs.

build:
stage: build
#needs: [] # Eliminado, pues es el inicio de este workflow sólo 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 # Crea el directorio del Docker config
# las variables DOCKER_USERNAME Y DOCKER_TOKEN deben ser definidas en el repositorio
- 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}

Al hacer el push al repositorio tenemos luego en el merge request nuestros checks.

alt text

En el linter tenemos el siguiente log.

...
$ echo "Usando npm ci con caché inteligente"
Usando npm ci con caché 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 no había caché antes creó el caché
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

Ya en el caso de un job posterior (unit-test por ejemplo) tenemos.

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 # Reaprovechó el caché
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 con caché inteligente"
Usando npm ci con caché 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... # GENERÓ OTRO CACHÉ
node_modules/: found 15699 matching artifact files and directories # MIRA QUE aquí node_modules tiene una cantidad de archivo diferente del 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 a entender por qué ocurrió eso. Generamos dos cachés diferentes para el mismo comando npm-ci. Tenemos la carpeta node_modules pero el comando npm-ci se ejecutará, pero mucho más rápido, evita hacer descarga de paquetes.

Lo que pasó es que aunque se ejecute en otras imágenes más paquetes fueron instalados añadiendo paquete al caché y creando un nuevo caché.

Y eso se repetirá, cada vez que npm-ci se ejecute y encuentre diferencia actualiza el caché y al final siempre estaremos actualizando el caché en vez de conseguir mantener un caché fijo. Si todas las imágenes fuesen exactamente las mismas no tendríamos problema.

Una excelente estrategia que funciona para todo es mantener el caché siempre por job. Tendremos más cachés con certeza, pero evitaremos ese tipo de contratiempo y funcionará para cualquier escenario.

Al aceptar el merge request tenemos otro pipeline de forma y el build también aprovechará el caché, en ese caso debería aprovechar el caché del unit test que rehízo el caché.

alt text

Esperamos que el caché sea 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 # PERO NO FUE!
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 con caché inteligente"
Usando npm ci con caché 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

Si observas el log verás que no encontró el caché pues consideró claves diferentes. ¿Pero cómo si las claves son generadas a partir del hash del mismo archivo que no tuvo modificación?

En el pipeline 1 (de merge request) el unit test que regeneró el caché tenemos:

Creating cache 0_package-lock-704695cbac4cd3d8bf2f2d21eab7ba69acb76ff6-2-**non_protected**

Y en el pipeline 2 tenemos:

Checking cache for 0_package-lock-704695cbac4cd3d8bf2f2d21eab7ba69acb76ff6-2-**protected**

El sufijo de la clave (-protected vs -non_protected) es diferente.

GitLab automáticamente añade ese sufijo al cache:key si estás usando un caché scoped con protected: true|false, o si estás en un job que se ejecuta en branch/etiqueta protegida o no protegida.

  • MR viniendo de fork o branch no protegida? ➝ -non_protected
  • Branch protegida (main, develop)? ➝ -protected

Esto evita que jobs en MRs o forks escriban en el caché usado en branches protegidas, por seguridad.

En nuestro caso el build está ocurriendo en una branch develop que está protegida.

Si quieres permitir es necesario una configuración específica en el repositorio o grupo en GitLab.

alt text

Desmarca la opción Use separate caches for protected branches y ejecutando el pipeline nuevamente conseguiremos que el build también use el caché.

Vamos a resolver el problema de esos cachés poniendo un caché por job así ganamos velocidades en los futuros pipelines.

.setup-node-deps:
before_script:
- echo "Usando npm ci con caché inteligente"
- npm ci
cache:
key:
# Combina nombre del job con hash del package-lock.json
prefix: "${CI_JOB_NAME}-"
files:
- package-lock.json
paths:
- node_modules/