Le dev dans les nuages

blog-le-ci-cd-avec-gitlab-ci-img

Blog : le CI/CD avec Gitlab CI


Introduction

deployment components Pour ce quatrième article sur le blog nous allons aborder la CI/CD avec Gitlab CI.

Ayant déjà travaillé un peu avec Jenkins pour faire du pipeline déclenché par webhook gitlab, je me suis posé la question de l'outil à utiliser pour la CI/CD, et j'ai choisi Gitlab CI.

Les avantages que j'ai vu avec Gitlab CI :

  • ayant déjà un serveur Gitlab, je n'avais rien besoin d'installer (mis à part un runner, et un peu de configuration)
  • naturellement bien intégré à Gitlab (pas besoin de webhook)
  • bonne intégration avec Docker
  • nouvelle techno à apprendre

Je tiens à dire que le blog saltycrane et notamment l'article Next.js GitLab CI/CD Docker multi-stage example m'ont beaucoup inspiré pour cette partie.

Ci-dessous un schéma qui représente les stages du pipeline CI/CD opérés par Gitlab CI : pipeline

Modèle de branche

branching strategy Le modèle de branche est basique avec deux branches :

  • la branche "dev" qui est la branche de travail pour le développement et de la rédaction de nouveaux articles. La branche dev est la branche par défaut dans Gitlab
  • la branche "production" qui représente l'état du blog en production

Les étapes :

  1. on crée la branche "dev" à partir de la branche "production"
  2. on développe ou on rédige un article sur la branche dev, puis on push le change et le pipeline de CI/CD se déclenche
  3. quand les étapes build, test, release et deploy sont terminées avec succès, la dernière étape du pipeline consiste à merge "dev" dans "production". La branche dev est maintenue après le merge

Gitlab CI

Gitlab CI runner

Pour utiliser Gitlab CI il faut disposer à minima d'un runner. Le runner est le composant qui va prendre en charge l'exécution du pipeline. Plusieurs types de runner existent et il est possible de tager un runner pour pouvoir ensuite le sélectionner dans les stages du pipeline. J'ai donc choisi d'installer un runner linux avec executor Docker (cf doc officielle) car mon application est conteneurisée, et c'est bien plus pratique d'utiliser du Docker 😉.

Variables

Les variables permettent de stocker des données sensibles en dehors du fichier de pipeline .gitlab-ci.yml, et se trouvent au niveau du projet dans Settings > CI/CD > Variables.

Key Description
AWS_ACCESS_KEY_ID Nécessaire pour l'accès programmatique à AWS
AWS_DEFAULT_REGION Région du compte AWS utilisé
AWS_ECR_REGISTRY Identifiant du registry ECR
AWS_SECRET_ACCESS_KEY Nécessaire pour l'accès programmatique à AWS
CI_PRIVATE_KEY Clé privée du runner
CI_PUBLIC_KEY Clé publique du runner
GITLAB_PUBLIC_KEY Clé publique de gitlab

Pipeline

Le pipeline est décri dans le fichier ".gitlab-ci.yml" qui doit être à la racine du projet. Un pipeline est composé de stages qui sont composés de job.

variables:
  # enable docker buildkit. Used with `BUILDKIT_INLINE_CACHE=1` below
  DOCKER_BUILDKIT: 1
  # DOCKER_TLS_CERTDIR: "/certs"
  IMAGE_TEST: $CI_REGISTRY_IMAGE/test:latest
  # IMAGE_CYPRESS: $CI_REGISTRY_IMAGE/cypress:latest
  IMAGE_DEPLOY: $CI_REGISTRY_IMAGE/deploy:latest

stages:
  - build
  - test
  - release
  - deploy

.dockerbase:
  image: docker:latest
  tags:
    - shared
  only:
    - dev
  services:
    - docker:dind
  before_script:
    - docker --version
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

.alpinebase:
  image: alpine:latest
  tags:
    - shared
  only:
    - dev
  before_script:
    - cat /etc/os-release

build:buildapp:
  extends: .dockerbase
  tags:
    - shared
  stage: build
  only:
    - dev
  script:
    - docker build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from $IMAGE_TEST --target app-builder -t $IMAGE_TEST .
    - docker push $IMAGE_TEST

test:eslint:
  extends: .dockerbase
  tags:
    - shared
  stage: test
  needs: ["build:buildapp"]
  script:
    - docker run $IMAGE_TEST npm run lint

release:buildimage:
  extends: .dockerbase
  tags:
    - shared
  stage: release
  only:
    - dev
  needs: ["test:eslint"]
  script:
    - docker build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from $IMAGE_DEPLOY --cache-from $IMAGE_TEST -t $IMAGE_DEPLOY .
    - docker push $IMAGE_DEPLOY

release:releaseimage:
  extends: .dockerbase
  tags:
    - shared
  stage: release
  only:
    - dev
  needs: ["release:buildimage"]
  script:
    - docker pull $IMAGE_DEPLOY
    - apk add --no-cache curl jq python3 py3-pip
    - pip install awscli --break-system-packages
    - aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
    - aws configure set aws_secret_access_key AWS_SECRET_ACCESS_KEY
    - aws configure set region $AWS_DEFAULT_REGION
    - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login $AWS_ECR_REGISTRY -u AWS --password-stdin
    - docker tag $IMAGE_DEPLOY $AWS_ECR_REGISTRY:latest
    - docker push $AWS_ECR_REGISTRY:latest

deploy:deployaws:
  extends: .alpinebase
  tags:
    - shared
  stage: deploy
  only:
    - dev
  needs: ["release:releaseimage"]
  script:
    - apk add --no-cache curl jq python3 py3-pip
    - pip install awscli --break-system-packages
    - aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
    - aws configure set aws_secret_access_key AWS_SECRET_ACCESS_KEY
    - aws configure set region $AWS_DEFAULT_REGION
    - aws ecs update-service --cluster ECS-Cluster --service Service-ledevdanslesnuages --task-definition task-ledevdanslesnuages --force-new-deployment

deploy:gitmerge:
  extends: .alpinebase
  tags:
    - shared
  stage: deploy
  only:
    - dev
  needs: ["deploy:deployaws"]
  script:
    - apk add --no-cache git openssh-client
    - mkdir ~/.ssh
    - echo "$CI_PRIVATE_KEY" > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - echo "$CI_PUBLIC_KEY" > ~/.ssh/id_rsa.pub
    - echo "$GITLAB_PUBLIC_KEY" > ~/.ssh/known_hosts
    - git clone git@gitlab.blr:root/ledevdanslesnuages-blog.git
    - cd ledevdanslesnuages-blog/
    - git checkout production
    - git merge dev
    - git push
  • variables : section pour définir des variables d'environnement :
    • DOCKER_BUILDKIT: 1 : active le docker buildkit qui optimise les performances de build
    • IMAGE_TEST: $CI_REGISTRY_IMAGE/test:latest : correspond à l'image pour les tests sur le registry
    • IMAGE_DEPLOY: $CI_REGISTRY_IMAGE/deploy:latest : correspond à l'image pour le deploiement sur le registry
  • stages : liste les stages du pipeline. Cette liste sera représentée graphiquement dans l'interface Gitlab lors de l'exécution du pipeline
  • .dockerbase : définition de l'image de base Docker. Cette base servira pour les opérations Docker (pull, build, tag, push, login)
    • en service docker:dind : Docker in Docker (dind) permet comme son nom l'indique d'utiliser Docker dans Docker 😄
    • en before_script docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY : pour se connecter au registry Gitlab Container Registry
  • .alpinebase : définition de l'image de base alpine. Cette base servira pour les opérations non Docker
  • concernant les jobs qui suivent :
    • ils sont réalisés séquentiellement, c'est à dire que chaque job a comme prérequis le job précédent via la propriété "needs"
    • ils sont bloquants, ce qui signifie que si le job failed, l'exécution du pipeline s'arrête et son statut est "failed"
    • ils ont le tag "shared" qui correspond au tag du runner
    • la propriété "extends" permet d'utiliser l'image de base souhaitée
    • la propriété "only" permet de cibler uniquement la branche "dev"

pipeline stages

Stage Build

  • build:buildapp : dans ce job on build l'application en ciblant la target "app-builder" du Dockerfile. On parle ici de build au sens NextJS et SSG ("next export"). On a donc en sortie de ce build les fichiers statiques du blog à servir sur le serveur web.

Stage Test

  • test:eslint : dans ce job on vient tester l'application buildée. Au moment où cet article est rédigé, cette partie n'est pas aboutie car on vient faire un npm run lint alors que le lint est déjà fait implicitement lors du build à l'étape précédente. Il faudrait ici faire un vrai test end to end, typiquement avec Cypress. To be continued...

Stage Release

  • release:buildimage : ici on vient builder l'image Docker qui correspond à l'application finale sous nginx, et pousser l'image sur le Gitlab Container Registry
  • release:releaseimage : dans ce job on récupère l'image précédemment buildée, on la tag et la push dans le registry ECR

Stage Deploy

  • deploy:deployaws : ce job met à jour le service ECS avec la nouvelle image déposée dans ECR lors du job précédent. La mise à jour du service ECS va redéployer le container
  • deploy:gitmerge : dans la dernière étape du pipeline, on vient merger la branche "dev" dans "production" pour prendre en compte dans le repo Git l'état du blog

Le mot de la fin

Certaines parties de ce pipeline restent a compléter comme les tests (avec Cypress) ou à améliorer comme le temps d'exécution (le pipeline actuel dure ~5min), mais il est vraiment plaisant d'envoyer un nouvel article en faisant un push sur le repo Git, puis de voir le pipeline prendre le relais et mettre à jour le blog automatiquement.