Le Déploiement Continu (CD pour “Continuous Deployment”) fait partie des pratiques DevOps.
Il vise à simplifier et accélérer le processus de déploiement ou de mise en production d’une application, en s’appuyant sur des outils d’automatisation.
Le terme a été introduit au début des années 2000, parallèlement aux méthodologies Agile. Le livre “Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation” a formalisé ses bases, bien que son titre mentionne la Livraison Continue (CD également, mais pour “Continuous Delivery”).
Plus généralement, on parle de CI/CD. Ici, CD représente à la fois la “Livraison Continue” et le “Déploiement Continu”.
C’est le bon moment pour définir plus précisément les trois termes d’intégration continue, livraison continue, déploiement continu, et leurs inter-relations.
.ipa
pour iOS,
.apk
ou .aab
pour Android).Le terme continu est un peu exagéré : un projet logiciel, même dans une très grande entreprise, n’est pas réellement intégré, livré ou déployé en continu. Continue signifie plutôt effectuer ces actions à chaque fois que du nouveau code est poussé vers le dépôt central de gestion de versions (Git, Mercurial, etc.).
Comment ces trois pratiques se situent-elles les unes par rapport aux autres ?
Il y a un chevauchement substantiel entre elles : le Déploiement Continu englobe la Livraison Continue, qui à son tour englobe l’Intégration Continue. Ce diagramme très simplifié représente leur étendue :
CI/CD vise à remplacer les procédures manuelles d’intégration, de livraison et de déploiement par des procédures automatisées. Cela est particulièrement bénéfique pour les grands projets, mais est intéressant même pour de petits projets personnels.
Afin d’automatiser les différentes étapes d’un CI/CD complet, nous allons utiliser des outils disponibles prêts à l’emploi. Il existe une très grande variété d’options. Beaucoup d’entre eux sont basés sur le cloud, mais d’autres peuvent être auto-hébergés.
Parmi eux, on peut citer :
Ces outils implémentent le concept de pipeline : une série d’étapes à compléter pour effectuer l’intégration, la livraison ou le déploiement.
Si l’une des étapes échoue, le pipeline ne passera pas à la suivante.
Voici un exemple de pipeline relativement complexe, mais indépendant des technologies.
Quelques explications sur les “labels” de ce diagramme :
Nous allons nous concentrer sur le déploiement continu pour commencer, bien que nous montrions au moins un exemple de processus qui s’inscrit dans la partie intégration continue.
À la fin de la journée, vous aurez — si tout va bien 🤞 — déployé une simple application web, via un processus entièrement automatisé.
Nous passerons par :
Selon le temps, nous pourrons également aborder beaucoup d’autres points, tels que :
ℹ️ Note : je suis ouvert à vos propositions, si vous voulez travailler sur une autre app, même si je préférerais (largement) qu’elle soit :
- en JavaScript ou plutôt TypeScript (intérêt : avoir une étape de build / compilation !)
- plutôt du backend (pour explorer le déploiement d’un conteneur pour l’app, et d’un autre pour la BDD, ainsi que la question des migrations de la BDD)
- d’un scope relativement restreint (pour ne pas passer tout votre temps à débugger du code complexe)
gitlab-ci-local
Comme alternative, ou plutôt en complément pour celles et
ceux qui auraient le temps, il est possible de faire
fonctionner le pipeline GitLab localement, avec un outil que
vous avez a priori déjà utilisé :
gitlab-ci-local
.
J’ai personnellement testé les deux options. Si vous avez
déjà installé gitlab-ci-local
, il peut servir
:
Un pipeline est composé d’étapes (stages) qui peuvent s’enchaîner de différentes façons. On rencontre aussi le terme jobs : dans la terminologie de GitLab, chaque stage du pipeline donne lieu à l’exécution d’un job.
GitLab CI propose deux méthodes d’écriture des fichiers
.gitlab-ci.yml
. La différence réside dans la
gestion des dépendances et l’exécution parallèle des jobs.
Attention, les exemples ci-dessous utilisent un certain ordre entre “test” et “build”, qui n’est pas celui que vous aurez à suivre.
stages:
- build
- test
build_job:
stage: build
script:
- echo "Building the project"
test_job:
stage: test
script:
- echo "Running tests"
build_job:
script:
- echo "Construction du projet"
test_job:
needs: [build_job]
script:
- echo "Exécution des tests"
other_job:
needs: [build_job]
script:
- echo "Autre tâche"
Le mot-clé needs
crée une dépendance.
test_job
et other_job
ne
s’exécuteront qu’après la fin de build_job
. En
revanche, ils s’exécuteront en parallèle.
Plus généralement, les jobs sans dépendances spécifiées ou appartenant au même stage peuvent s’exécuter en parallèle.
⚠️ Gardez bien à l’esprit ces deux approches. Pour ce qu’on souhaite faire aujourd’hui, l’une semble peut-être plus évidente. Il est probablement un peu tôt pour y réfléchir 😅. Et le passage de l’une à l’autre se fait rapidement !
Vous pourrez revenir à cette section plus tard, quand il vous semblera nécessaire de faire appel à des variables, plutôt que de “hardcoder” des informations dans vos fichiers
.gitlab-ci.yml
.
Dans GitLab CI/CD, les variables d’environnement sont essentielles pour gérer des informations sensibles ou spécifiques à l’environnement. Par exemple :
Vous pouvez les définir dans l’interface GitLab sous “CI / CD Settings”.
Il est d’usage d’activer l’option “Protect variable”
(active par défaut) pour que ces variables soient disponibles
uniquement dans les branches protégées (comme
main
ou dev
).
Cela assure la sécurité, car les branches non protégées n’auront pas accès à ces variables, prévenant ainsi les déploiements accidentels ou non autorisés.
Vous pouvez les déprotéger temporairement pour des tests sur d’autres branches (notamment pendant que vous expérimentez avec les pipelines).
Dans ce cas, assurez-vous de les protéger à nouveau après
vos tests pour la sécurité des branches principales
(main
ou dev
).
Pour chacune de ces étapes, on vous propose un objectif et des pistes d’exploration.
⚠️ TRÈS important : ne restez pas bloqué·e·s pendant des heures, surtout si un point explicité ci-dessous n’est pas clair ! L’idée est de ne pas (trop) vous tenir la main, et de vous laisser en autonomie. Mais il y a probablement des choses dont j’ai sous-estimé la complexité. Donc, si vos recherches ne vous mènent nulle part - ou simplement pour discuter d’un des points listés, demandez (à moi ou à vos camarades !).
ℹ️ Cette étape pourrait être dispensable… Mais si vous n’avez jamais mis en place de projet Node.js avec TypeScript, elle peut constituer un bon exercice. Mais je ne veux pas qu’on y passe trop de temps ! J’avais prévu, à la base, de vous fournir la base de l’application Node.js déjà configurée.
Objectif : Initialiser un projet Node.js avec Git et installer les dépendances.
Pistes d’exploration :
.gitignore
, et la gestion des
dépendances avec yarn
.⚠️ C’est une étape critique et un peu ardue.
Le build, dans le pipeline, peut être divisé en deux sous-étapes :
Les deux pourraient être rassemblées en une seule, mais cela peut mener à la construction d’une image Docker spécifique, ce qui est une tout autre affaire !
Pour ce qui s’agit de cette étape :
Objectif : Configurer le projet pour qu’il build avec succès.
Pistes d’exploration :
tsconfig.json
, notamment l’option
rootDir
(emplacement des fichiers source), et
l’option outDir
(pour éviter que les fichiers
.js
générés se retrouvent dans votre répertoire
src
).package.json
.Difficultés potentielles : les étapes
(stages) d’un pipeline GitLab CI sont indépendantes ;
l’étape de build TypeScript va avoir besoin des dépendances
listées dans votre package.json
; mais les
node_modules
ayant été installés à l’étape
précédente, comment y accéder dans celle-ci ? La réponse à
cette question servira pour plus tard.
👉 Une fois cette étape accomplie (compilation réussie), bravo, vous avez jeté les première bases 👏 ! Vous pouvez commencer à pousser des mises à jour du code depuis votre machine locale, et à vérifier qu’il compile toujours dans le pipeline.
Objectif : Créer un Dockerfile pour conteneuriser l’application.
Pistes d’exploration :
heroku login
!).Dans un workflow CI/CD, il est courant d’avoir deux environnements distincts : staging et production. Le staging est utilisé pour les tests et la validation avant le déploiement en production. Cela implique deux applications Heroku distinctes, chacune avec sa propre variable d’environnement pour le nom d’application.
Objectif : déployer vers staging ou production en fonction de la branche poussée.
Pistes d’exploration :
staging
si on est sur la branche
develop
, et vers production
si on
est sur main
ℹ️ Normalement, suivant l’ordre du pipeline d’exemple décrit plus haut, cette étape devrait intervenir bien avant le build ! Mais comme le focus du jour est sur le déploiement plutôt que sur l’intégration, je préfère qu’on garde cette étape pour celles et ceux qui auront de l’avance.
Ceci étant dit, si le compilateur TypeScript détecte beaucoup des erreurs qu’ESLint détecte, chacun a son utilité propre.
Il est essentiel de connaître ESLint, et de savoir l’installer… surtout si vous récupérez un jour une vieille codebase JavaScript. ESLint peut alors détecter des erreurs qui pourraient mener à des crashs de votre app en production (c’est du vécu !).
Ce n’est pas une partie du pipeline à proprement parler. C’est plutôt quelque chose destiné à être utilisé sur votre station de travail du développeur : les hooks de pré-commit peuvent nous empêcher de commettre et pousser du code cassé. cela évite de déclencher le pipeline GitLab CI “pour rien”.
package.json
de
l’application.ℹ️ Les tests feront l’objet d’un autre module que nous ferons ensemble à partir de février. Ce n’est donc pas le “focus” du jour, mais cela peut constituer une bonne préparation !
Ils ne sont pas des choses à faire dès aujourd’hui, rassurez-vous ! Mais des pistes si vous vous voulez creuser plus loin.
Ces points ne concernent pas nécessairement l’écriture de pipelines, mais d’autres aspect de la gestion d’une mise en production, et des bonnes pratiques.
Nous aborderons ces problématiques une prochaine fois.
ℹ️ À nouveau, il est possible d’explorer un autre projet.
Le projet exemple sur lequel nous allons travailler est une application e-commerce très simplifiée. C’est une application web de style traditionnel, c’est-à-dire qu’elle ne repose pas sur une architecture moderne telle qu’une API REST ou GraphQL et une SPA (Single Page Application) comme on pourrait en construire avec Angular, React ou Vue.js.
C’est une application Node.js construite sur le framework Express, qui servira des pages HTML.
node -v
. Si vous ne l’avez pas ou si votre
version est trop ancienne, vous pouvez l’installer en
utilisant nvm
(Linux, macOS) ou nvm-windows.yarn -v
. S’il ne l’est pas,
exécutez simplement npm i -g yarn
.L’URL publique du dépôt GitLab est celle-ci : https://gitlab.com/bhubert/ipi-cicd-example.
Erratum : Forkez le projet de préférence !
git clone https://gitlab.com/IDENTIFIANT/ipi-cicd-example.git
cd ipi-cicd-example
yarn
Le projet n’a été que très peu configuré. C’est à vous d’implémenter les fonctionnalités de base. L’objectif ici n’est pas de (ré)apprendre Node.js, mais d’ajouter des fonctionnalités très simples, et de vérifier si notre application se construit, localement et dans une pipeline CI/CD.
Pour commencer, les produits seront stockés dans un simple tableau d’objets, peuplé à partir d’un fichier JSON. Afin de garder les choses simples, nous ne sauvegarderons pas la liste mise à jour sur le disque lorsque nous ajoutons un nouveau produit, ce qui a une conséquence évidente : la liste des produits sera réinitialisée à chaque fois que nous redéployons l’application.
Nous introduirons la persistance des données plus tard si le temps le permet, mais comme Heroku est un moyen facile et pratique de déployer des applications, nous pourrions y arriver.
Docker est un outil de conteneurisation qui permet de packager une application et ses dépendances dans un conteneur isolé. Cela facilite le déploiement et la portabilité des applications. Docker s’applique à divers types d’applications, pas seulement aux applications web, mais aussi aux bases de données, aux applications en ligne de commande, etc.
Nécessite d’avoir installé Node.js
Commencez par vous placer dans un nouveau répertoire.
Lancez alors : npm i express
.
Créez un fichier index.js
avec ce contenu
:
const express = require("express");
const app = express();
const port = 3000;
.get("/", (req, res) => {
app.send("Hello World!");
res;
})
.listen(port, () => {
appconsole.log(`Example app listening on port ${port}`);
; })
Puis créez un fichier Dockerfile
:
# Image de base
FROM node:20-alpine
# Répertoire où seront copiés les fichiers,
# et d'où sera lancée l'application.
WORKDIR /app
# Copie du package.json et package-lock.json.
# Ces fichiers changeant a priori moins souvent que les fichiers de l'app,
# on les copie dès maintenant, afin de bénéficier de la mise en cache des
# "layers" constituant l'image (s'ils ne sont pas modifiés, seul le dernier COPY
# devra être ré-effectué au prochain build).
COPY package.json package-lock.json .
# Installation des dépendances
RUN npm install
# Copie de l'app (ici un seul fichier).
COPY index.js .
# Commande lancée au démarrage du conteneur
CMD node index.js
# Syntaxe alternative
# CMD ["node", "index.js"]
Construisez l’image Docker :
docker build -t express-hello .
Exécutez le conteneur :
docker run -d -p 3000:3000 express-hello
docker build -t mon-app .
: Construit une
image Docker à partir d’un Dockerfile
.docker tag mon-app:latest mon-app:v1.0
: Tag
l’image avec une nouvelle version.docker run -d -p 3000:3000 mon-app
: Lance le
conteneur.docker ps
: Liste les conteneurs actifs.docker ps -a
: Liste tous les
conteneurs.docker images
: Liste toutes les images.docker rm [CONTAINER_ID]
: Supprime un
conteneur spécifique.docker rmi [IMAGE_ID]
: Supprime une image
spécifique.docker push mon-app
: Envoie l’image sur
Docker Hub (nécessite un login préalable).Cette section sert de référence : elle décrit la configuration initiale du projet. Vous n’avez pas besoin de la suivre pour l’instant, puisque vous êtes déjà fourni avec les modules de base du projet.
Dans cette section, nous allons décrire comment configurer une application node js et express à partir de zéro, en utilisant TypeScript.
Le résultat des étapes suivantes vous sera fourni, mais il peut être utile comme référence.
Nous allons utiliser Yarn pour gérer les dépendances du projet, en partie parce qu’il est plus rapide que npm (surtout sur Windows).
Vous devez l’installer en utilisant
npm i -g yarn
.
Tout d’abord, évaluons ce dont nous aurons besoin :
dépendances de développement :
typescript
(compilateur TypeScript),eslint
(Linter),dépendances de l’application :
express
,morgan
,dotenv
:warning: Avant de commencer la
configuration, nous allons installer yarn s’il n’est
pas déjà installé : npm i -g yarn
.
Nous commençons par créer le dépôt git avec
git init
.
mkdir ipi-cicd-example
cd ipi-cicd-example
git init
git branch -m main
Nous téléchargeons ensuite le
fichier .gitignore
de GitHub pour les projets
Node.js (partie d’une large collection de
fichiers .gitignore
)
wget -O .gitignore https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
Puis nous initialisons le projet Node.js :
npm init -y
git add package.json .gitignore
git commit -m"Initialisation du projet Node.js avec package.json et .gitignore"
Nous procédons ensuite à l’installation des dépendances de
développement essentielles. Les packages préfixés par
@types/
fournissent les types TypeScript pour les
API standard de Node.js, Express.js et la bibliothèque de
logging Morgan respectivement.
yarn add --dev typescript ts-node nodemon @types/node @types/express @types/morgan
git add package.json yarn.lock
git commit -m"Installation des dépendances de développement de base"
Nous configurons ensuite le fichier
tsconfig.json
de TypeScript. Nous avons deux
options pour cela :
npx tsc --init
, qui le crée avec les
paramètres par défaut habituels.Bien que la seconde semble être une bonne idée, nous avons
eu des problèmes avec elle pour Node.js 20. Nous allons donc
initialiser tsconfig.json
avec
tsc --init
, avec quelques arguments :
src
,tsc
iront sous dist
,.json
depuis notre code TypeScript,npx tsc --init --rootDir src --outDir dist --resolveJsonModule --lib es2022
git add tsconfig.json
git commit -m"Configuration de TypeScript"
Nous installons ensuite les packages qui seront utilisés
par l’application elle-même (nous n’avons pas installé
@types/dotenv
car les types sont fournis avec
dotenv
lui-même).
yarn add express morgan dotenv
git add package.json yarn.lock
git commit -m"Installation des dépendances de base de l'application"
Nous créons une application serveur Express simple dans
src/index.ts
, avec le contenu suivant :
import express from "express";
import morgan from "morgan";
import dotenv from "dotenv";
// Charger les variables d'environnement depuis le fichier .env
.config();
dotenv
const app = express();
// Middlewares
.use(morgan("dev"));
app.use(express.urlencoded({ extended: true }));
app
// Routes
.get("/", (req, res) => {
app.send("Hello, world!");
res;
})
// Démarrer le serveur
const port = process.env.PORT || 3000;
.listen(port, () => {
appconsole.log(`Le serveur fonctionne sur le port ${port}`);
; })
Nous devons également configurer nos scripts
package.json
, pour faciliter le développement de
notre app. Nous avons précédemment installé
nodemon
et ts-node
.
nodemon surveille les fichiers sources de l’application et la redémarre lorsqu’un changement se produit. ts-node nous permet d’exécuter des programmes TypeScript sans avoir à les compiler manuellement avec tsc. Nous ajoutons cette ligne dans la section “scripts” de notre package.json (au-dessus de “test”) :
"dev": "nodemon --exec ts-node src/index.ts",
Nodemon utilisera ts-node
comme environnement
d’exécution, au lieu d’utiliser le node
“vanilla”.
Grâce à cela, nous pouvons démarrer notre application en
mode “watch” en lançant yarn dev
.
Nous traçons ces changements :
git add src/index.ts package.json
git commit -m"Écriture d'une app Express basique, ajout d'un script de démarrage"
À partir de là, nous sommes prêts à implémenter des fonctionnalités basiques !
Il reste cependant un peu de mise en place à faire :