Skip to main content

Dependencias Entre Jobs

Ya conseguimos generar algún tipo de dependencia hasta ahora utilizando diferentes stages, pero todos los jobs están ejecutándose en paralelo y cuando queremos que un job se ejecute después de otro acabamos cambiando a otro stage.

Dependencia entre jobs en GitLab CI funciona con base en etapas (stages) y en claves como needs y dependencies.

Needs

Con needs creamos dependencia directa entre jobs, independientemente del stage en que se encuentra.

Permite que jobs de etapas diferentes se ejecuten fuera del orden de stages si es necesario, pero sólo después de que los jobs especificados terminen. ¿Qué quiero decir con esto?

stages:
- build
- test

job_a:
stage: build
script: echo "Build"

job_b:
stage: test
script: echo "Test"
# Es una lista de jobs que necesitan terminar para que éste se ejecute.
needs: [job_a] # job_b depende de job_a, pero puede comenzar justo después de que job_a termine, sin esperar el stage entero

Con esto conseguimos ganar velocidad en el pipeline. Una cosa muy importante es que no es posible que un job del stage anterior dependa de un job del stage posterior.

stages:
- build
- test

job_a:
stage: build # stage 1
needs: [job_b] # ¿Estará dependiendo de algo que no ha comenzado?
script: echo "Build"

job_b:
stage: test # stage 2
script: echo "Test"

Incluso si el flujo fuese posible, como este ejemplo abajo no podemos ejecutar esto, pues la regla es clara en GitLab CI "A job can only need jobs from earlier or the same stage".

stages:
- check
- build
- test

job_a:
stage: check # stage 1
script: echo "check"

job_b:
stage: build # stage 2
needs: [job_d]
script: echo "build"

job_c:
stage: test # stage 3
script: echo "Test"

job_d:
stage: test # stage 4
needs: [job_a] # En teoría este job se ejecutaría justo después del job_a, antes del job_b que depende de éste.
script: echo "Test"

Otro detalle que es importante para el rendimiento es poner un needs vacío (needs: []) para iniciar. Esto garantiza que se ejecute justo al inicio del pipeline.

Al usar needs en GitLab CI se crean dependencias explícitas entre jobs. El parámetro artifacts: dentro de needs controla si el job actual hará o no la descarga de los artefactos del job dependiente. Cuando no necesites el artefacto desactívalo para ayudar a ganar velocidad. El valor por defecto es artifacts: true (u omitido, que es el default) → descarga los artefactos.

stages:
- build
- test

job_a:
stage: build
script: echo "Build"
artifact:
#... push de un archivo por ejemplo

job_b:
stage: test
script: echo "Test"
needs:
- job: job_a
artifacts: false # No hará el pull de los artefactos del job_a

Dependencies

Documentación Dependencies

Sólo para que conste además de needs existe dependencies.

  • Se usaba sólo para descargar artifacts de otros jobs, sin controlar ejecución.
  • No influenciaba en el orden de los jobs ni liberaba paralelismo.
  • Sustituido por needs, que hace todo esto y mejor.
  • Dependencies sólo coge artifacts de jobs de etapas anteriores y no permite ejecución paralela, ya que todos los jobs de un stage anterior necesitan terminar.
  • Needs es más flexible: puedes usar para coger artifacts de jobs en cualquier stage anterior o del mismo stage, y permite paralelismo, ejecutando los jobs tan pronto como la dependencia necesaria sea concluida.

Si el propio GitLab enfatiza que se use needs es mejor utilizar porque es así como algo comienza a ser depreciado. En la propia documentación oficial tenemos la siguiente frase.

"To fetch artifacts from a job in the same stage, you must use needs:artifacts. You should not combine dependencies with needs in the same job. "


Ahora vamos a poner algunos needs en nuestro pipeline del proyecto.

Sólo recordando éste es nuestro stage.

stages:
- check # Análisis que no necesitan de la carpeta build/
- build # build + image build
- deploy # aún falso

Vamos a hacer dos needs aquí. El job build dentro del stage build puede iniciar junto con los checks aunque en el stage posterior para que podamos ganar velocidad y para eso usamos needs: []. En la creación de imagen vamos a esperar a que el job build finalice.

Tenemos esto para nuestro stage de build.

.rules-only-main-mr: # EN ESTE MOMENTO VOY A MANTENER ESTA REGLA SÓLO PARA QUE SEA FÁCIL DE ENTENDER, PERO CAMBIAREMOS MÁS ADELANTE.
rules:
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
when: always

build:
stage: build
needs: [] # No depende de jobs de etapas anteriores, entonces se ejecuta enseguida
extends: [.rules-only-main-mr]
script:
- node --version
- npm --version
- npm ci
- npm run build
artifacts:
when: on_success
expire_in: "1 hour"
paths:
- build/

image-build:
stage: build
needs: [build] # Depende sólo del job de build arriba.
extends: [.rules-only-main-mr] # Atención aquí... Explicado abajo
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--no-push
--verbosity info

Si no tuviese la misma rule en image-build que la rule de build, tendremos problemas. Este job sería lanzado sin el job del cual depende y tendremos este error.

alt text

Mira el job build siendo ejecutado al mismo tiempo que los jobs del stage de check.

alt text

Ahora vamos a arreglar algunas cosas en el momento de build para que utilice la carpeta build generada por el job anterior y vamos a poner en un archivo separado llamado Dockerfile.release. En los pipelines usaremos este Dockerfile.release y localmente podemos utilizar el Dockerfile.

La diferencia es que utilizando las instrucciones del archivo Dockerfile él hará build del proyecto usando un contenedor y después, ya con los archivos generados por ese contenedor, hará el build de otro utilizando archivos del primero. En nuestro estudio necesitamos coger la carpeta build generada por el proceso de build y por eso otro Dockerfile.release fue disponibilizado para aprovechar el artefacto generado por el build.

Crea un Dockerfile.release en la raíz del proyecto con el siguiente contenido.

#Dockerfile.release
FROM node:22-alpine

RUN npm install -g serve

WORKDIR /app

COPY build/ ./build

EXPOSE 3000

CMD ["serve", "-s", "build", "-l", "3000"]

Aún vamos a mejorar esto en el futuro para ganar rendimiento.

Utilizando Kaniko, podríamos hacer el build y al mismo tiempo hacer el push en un único comando, pero vamos a separar esas responsabilidades para crear más dependencias entre los jobs y explorar los conceptos del estudio.

Para hacer build de la imagen no necesitamos acceso a Docker Hub, pero para hacer el push sí. Tanto en development (branch develop) como en production (branch main) usaremos el artefacto generado por el build, pero haremos el push con tags diferentes.

  • La branch develop generará la imagen con la etiqueta latest
  • La branch main generará la imagen con etiqueta stable
  • Solamente debe hacerse el push de la imagen si el merge request es aceptado.

Vamos a usar la etiqueta latest en dev y stable en prod. ¡No es para hacer en la vida real!

El flujo será el siguiente:

  • En el merge request vamos a ejecutar todo el stage de check.
  • Al aceptar el merge vamos a ejecutar todo el stage de build y en el futuro de deploy.
  • La branch main sólo debe aceptar el merge request viniendo de develop: Esto debe ser una política de GitLab, no del pipeline. Usar el CI para bloquear merges erróneos funciona, pero tiene limitaciones y riesgos.
    • La persona ya abrió el merge request, tal vez ya inició revisión o incluso aprobó. Sólo falla en el pipeline.
    • Alguien con permiso puede ignorar fallos, deshabilitar el job o forzar el merge.
    • Sólo impide que el pipeline pase, pero no evita el error en el origen, que es el propio MR.

Para ese último caso ¿cómo debemos actuar? Utilizando Merge request branch workflow en settings > merge requests. Pero ese recurso está disponible por ahora solamente en el plan de pago de GitLab. Con ese recurso podemos definir reglas para abrir el merge request. Generalmente empresas grandes suelen pagar para usar GitLab pues tiene otras ventajas.

alt text

Como no vamos por ese camino, vamos a mantener las protecciones activadas para las branches develop y main para que solamente maintainers del repositorio puedan aceptar un merge request teniendo estas branches como target. "Grandes poderes, grandes responsabilidades".

alt text

Aquí lo que vamos a necesitar en el stage de build para comenzar.

# Estamos cambiando ahora la regla anterior que se llamaba .rules-only-main-mr
.rules-merged-accepted:
rules:
- if: '$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "push"'
variables:
TAG: latest # Si es develop la etiqueta de la imagen será latest
when: always
- if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
variables:
TAG: stable # Si es producción la etiqueta de la imagen será stable
when: always

# Necesitaremos del build para generar la carpeta build que será disponibilizada como artefacto.
build:
stage: build
needs: [] # Pero podemos adelantar el proceso para que se ejecute más rápido.
extends: [.rules-merged-accepted] # Aprovechando la regla y economizando código.
script:
- npm ci
- npm run build
artifacts:
when: on_success
expire_in: "1 hour"
paths:
- build/

build-image:
stage: build
needs: [build] # Sólo para recordar, por defecto descarga los artefactos.
extends: [.rules-merged-accepted] # Más economía de código
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug # ATENCIÓN VAMOS A DEJAR ESTAS IMÁGENES DEBUG MÁS ADELANTE
entrypoint: [""] # ATENCIÓN VAMOS A EXPLICAR POR QUÉ DESACTIVAMOS EL ENTRYPOINT MÁS ADELANTE
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
# Kaniko utiliza este archivo para hacer login en el registry en caso de push
- 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
# No vamos a hacer el push, sólo generar la imagen y subir en el artifact con el nombre image.tar
artifacts:
paths:
- image.tar
expire_in: 1 hour

# El proceso del push es igual al del build la diferencia es que sube.
# Kaniko no permite usar la image.tar como hicimos arriba. Rehace el build completamente. Ya vamos a solucionar eso.
push-image:
extends: [build-image] # ¡Economizando código!
needs:
- job: build-image
artifacts: false # No necesitaremos el artefacto
script:
- >
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile.release"
--destination "docker.io/${DOCKER_USERNAME}/${CI_PROJECT_NAME}:${TAG}"
artifacts: {}

Acortamos el pipeline aprovechando los conceptos de extend, mejoramos la rule para crear imagen solamente si el merge es aceptado para branches específicas, guardamos el artefacto (image.tar), pero el push no se está haciendo de esa imagen pues kaniko no consigue hacer eso, necesita reconstruir la imagen.

Podríamos ejecutar el push-image en paralelo pues no tiene sentido esperar lo que no necesitamos, al final kaniko no está usando la imagen que está en el artefacto. Hicimos eso sólo para ilustrar una idea de dependencia.

push-image:
...
needs: # Podríamos eliminar todo este bloque.
- job: build-image
artifacts: false

Pero lo que estamos queriendo hacer el push de la imagen generada y para eso tenemos otras herramientas capaces de hacer eso (crane y skopeo). Ya hablaremos de eso.

Imágenes DEBUG

Una imagen como kaniko y crane que vamos a usar más abajo poseen el entrypoint siendo el propio command line de la herramienta. Para reducir la imagen todo es removido, inclusive el shell. Pero GitLab necesita de un shell para ejecutar el script. Por eso optamos por imágenes generalmente con la etiqueta debug que incluyen un shell dentro, pero aún así el entrypoint es el command line de la herramienta y por eso desactivamos el entrypoint usando (entrypoint: [""]) para alterar para el entrypoint para el shell por defecto de la imagen. Lo que nos importa en la imagen es lo que nos ofrece instalado.

De esa forma conseguimos utilizar el before_script, script y after_script.


Usando Crane

Sabiendo que Kaniko no hace como queremos, vamos a ajustar para hacer el push del propio artefacto image.tar y para eso podemos usar crane o incluso skopeo.

Vamos a ajustar ese pipeline para usar crane.

.rules-merged-accepted:
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

build:
stage: build
needs: []
extends: [.rules-merged-accepted]
script:
- node --version
- npm --version
- npm ci
- 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 # Debug....
entrypoint: [""] # Poniendo a cero el 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}

Ahora sí estamos haciendo el push del image.tar.