Salut !!
Dans les différentes entreprises que j’ai faites, toutes n’étaient pas au même niveau d’avancée technologique… Rares sont celles qui avaient mis en place des procédures de tests et de déploiement automatique en fonction des pushs sur GitLab.
La seule qui commençait à mettre ça en place pour certains projets ne concernait pas ceux sur lesquels je travaillais.
Et de mon côté, je n’échappe pas à cette règle : mes projets persos n’ont pas d’intégration continue.
Je vais résoudre ça dans cet article en mettant en place des règles de CI/CD avec un projet basique Python qui devra être déployé automatiquement avec Docker dès que du code sera envoyé et validé sur GitLab, sur ma branche main
.
Prérequis
Pour les prérequis que je ne couvrirai pas dans cet article, j’ai déjà mis en place un runner GitLab que j’ai configuré, ainsi qu’un registry Docker privé.
Également, pour servir le projet en ligne, j’ai déjà un reverse proxy Apache et, bien sûr, j’ai configuré mon sous-domaine avec une entrée DNS correspondante sur OVH.
Créer un projet GitLab
Au niveau de GitLab, et pour la mise en place de mon exemple, je crée un projet GitLab basique.
Créer un projet Python Flask basique
Le projet GitLab créé plus haut va me servir à pousser le code de mon projet Python. Dans mon cas, et pour l’exemple, je vais faire un projet simple avec Python et le framework Flask.
Je vais donc initialiser mon projet en clonant le dépôt vide dans mon dossier de travail:
git clone git@gitlab.anthony-jacob.com:anthony.jacob/dummy-test-project.git
Je me rends ensuite dans le dossier et je crée un environnement virtuel Python:
cd dummy-test-project\
python -m venv .venv
J’active mon venv (sur Windows) avec la commande:
.venv\Scripts\activate.bat
J’installe Flask:
pip install Flask
J’en profite pour exclure les dossiers venv
et __pycache__
de mes fichiers à commit avec un fichier .gitignore
:
.venv
__pycache__
.pytest_cache
Ensuite, je crée le dossier app
, dans lequel j’ajoute un fichier vide __init__.py
et le fichier principal de mon application app.py
:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello_world():
return "<p>Hello, World!</p>"
Je teste ensuite l’application en local avec:
flask.exe --app app/app run
Tout roule pour l’instant
Pour préparer la suite, je vais aussi utiliser le server Waitress
. Je commence par l’installer, toujours dans mon venv:
pip install waitress
Et je le teste avec:
waitress-serve --host 127.0.0.1 --port 8181 app.app:app
Là encore, tout roule:
Créer une base de test Unitaire simple
Ici, je vais créer un test unitaire d’une fonction pour valider le code et, dans notre cas ici, ce test nous servira plus tard de validation avant le déploiement.
Je commence par installer pytest
:
pip install pytest
Dans le répertoire de mon projet, je crée un fichier de configuration pour pytest
(pytest.ini
) et un nouveau dossier tests
, dans lequel je crée le fichier principal de mon test test_app.py
:
[pytest]
pythonpath = .
import pytest
from app.app import app
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client
def test_hello_world(client):
"""Test the home page."""
response = client.get('/')
assert response.status_code == 200
assert b"Hello, World!" in response.data
Voilà de quoi tester l’unique fonction de ce projet de test.
Pour lancer les tests, je lance pytest
depuis le répertoire racine de mon projet:
C:\Users\antho\dev_workspace\dummy-test-project (develop -> origin)
(.venv) λ pytest.exe
==================================================================================== test session starts ====================================================================================
platform win32 -- Python 3.8.2, pytest-8.3.4, pluggy-1.5.0
rootdir: C:\Users\antho\dev_workspace\dummy-test-project
collected 1 item
tests\test_app.py . [100%]
===================================================================================== 1 passed in 0.82s =====================================================================================
Si je change le retour de ma fonction hello_world()
, mon test est bien en erreur:
C:\Users\antho\dev_workspace\dummy-test-project (develop -> origin)
(.venv) λ pytest.exe
==================================================================================== test session starts ====================================================================================
platform win32 -- Python 3.8.2, pytest-8.3.4, pluggy-1.5.0
rootdir: C:\Users\antho\dev_workspace\dummy-test-project
collected 1 item
tests\test_app.py F [100%]
========================================================================================= FAILURES ========================================================================================== _____________________________________________________________________________________ test_hello_world ______________________________________________________________________________________
client = <FlaskClient <Flask 'app.app'>>
def test_hello_world(client):
"""Test the home page."""
response = client.get('/')
assert response.status_code == 200
> assert b"Hello, World!" in response.data
E AssertionError: assert b'Hello, World!' in b'<p>Hello, World development!</p>'
E + where b'<p>Hello, World development!</p>' = <WrapperTestResponse 32 bytes [200 OK]>.data
tests\test_app.py:16: AssertionError
================================================================================== short test summary info ==================================================================================
FAILED tests/test_app.py::test_hello_world - AssertionError: assert b'Hello, World!' in b'<p>Hello, World development!</p>'
===================================================================================== 1 failed in 0.93s =====================================================================================
Exporter les dépendances Python
À ce stade, j’ai tout ce qu’il faut pour exporter la liste des dépendances Python de mon projet avec :
pip freeze > requirements.txt
Préparer l’image Docker
Le projet tourne, les tests sont en place, on peut maintenant s’attaquer à l’image Docker. Pour ça, je crée un fichier Dockerfile
dans un nouveau dossier docker
:
FROM python:latest
COPY ./app /app
COPY ./requirements.txt /app/requirements.txt
RUN pip install -r /app/requirements.txt
COPY docker/entrypoint.sh /entrypoint/
EXPOSE 8181
ENTRYPOINT [ "sh" , "/entrypoint/entrypoint.sh" ]
Et je lance waitress
dans mon entrypoint.sh
waitress-serve --host 0.0.0.0 --port 8181 app.app:app
Je vais tester mon image en local, donc je build l’image avec:
docker build -t anthonygj/dummy-test-project . -f docker/Dockerfile
pour finir, je la lance ensuite avec:
docker run -dit -p 8181:8181 --name dummy anthonygj/dummy-test-project
Tout est bon, mon container docker est lancé et mon application est bien servi sur le port 8181:
Commit initial sur le dépôt GitLab
Pour rappel, avec les étapes que j’ai effectuées, la structure de mon projet ressemble à ça:
Voilà une base que je peux pousser sur le dépôt GitLab avec :
git add .
git commit -m "initial commit"
git push origin main
Préparer l’hôte pour le déploiement
Pour déployer automatiquement mon projet depuis mon serveur, je vais indiquer à mon job GitLab de se connecter en SSH à mon serveur et déployer mon conteneur Docker.
Pour ça, je vais créer un utilisateur autodeploy
sur mon hôte, qui pourra se connecter uniquement en ssh. J’ajoute l’utilisateur sur le système :
sudo adduser --disabled-password --gecos "" autodeploy
je vais ensuite initialiser les répertoires ssh:
sudo mkdir -p /home/autodeploy/.ssh
sudo chmod 700 /home/autodeploy/.ssh
Puis, je génère une paire de clés RSA publique/privée:
sudo ssh-keygen -t rsa-sha2-512 -b 4096 -f /home/autodeploy/.ssh/id_rsa2_512
J’ajoute ensuite cette clé comme valide et autorisée pour que l’utilisateur autodeploy
puisse se connecter avec SSH:
sudo cat /home/autodeploy/.ssh/id_rsa2_512.pub | sudo tee -a /home/autodeploy/.ssh/authorized_keys
On n’oublie pas de définir les bonnes autorisations pour les fichiers:
sudo chmod 600 /home/autodeploy/.ssh/authorized_keys
sudo chown -R autodeploy:autodeploy /home/autodeploy/.ssh
J’ajoute ensuite l’utilisateur au groupe docker
pour qu’il puisse l’exécuter sans problème:
sudo usermod -aG docker autodeploy
Configuration de GitLab CI/CD
On attaque maintenant et enfin le cœur du sujet : la configuration de GitLab.
Pour rappel, dans cet article, mon but est, à chaque commit sur la branche de développement, de tester mon projet et, si les tests passent, de build mon image Docker, de la pousser sur mon registry Docker privé et de la déployer automatiquement dans mon environnement de « préprod ».
Je vais avoir besoin de définir quelques variables pour l’exécution de ma pipeline :
- SSH_KEY: la clé ssh créée ci dessus pour l’utilisateur
autodeploy
, au format base64 (avec la commandesudo cat /home/autodeploy/.ssh/id_rsa2_512 | base64 -w0
) - SSH_HOST: dans mon cas c’est l’IP local de mon serveur
192.168.0.100
- SSH_PORT: le port d’écoute de ssh sur mon serveur
1234
- SSH_USER: l’utilisateur que j’ai créé plus haut
autodeploy
GitLab prend par défaut le fichier de configuration .gitlab-ci.yml
:
Je crée ma branche de développement develop
:
git checkout -b develop
Puis, je crée un fichier .gitlab-ci.yml
à la racine du projet. Ci-dessous, mon fichier final après quelques itérations :
# For more information, see: https://docs.gitlab.com/ee/ci/yaml/index.html#stages
# predefined variables https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
stages:
- info
- test
- publish
- deploy
JobVariables:
stage: info
environment: all
script:
- echo "CI_COMMIT_AUTHOR $CI_COMMIT_AUTHOR"
- echo "CI_COMMIT_BRANCH $CI_COMMIT_BRANCH"
- echo "CI_COMMIT_DESCRIPTION $CI_COMMIT_DESCRIPTION"
- echo "CI_COMMIT_MESSAGE $CI_COMMIT_MESSAGE"
- echo "CI_COMMIT_REF_NAME $CI_COMMIT_REF_NAME"
- echo "CI_DEPLOY_PASSWORD $CI_DEPLOY_PASSWORD"
- echo "CI_DEPLOY_USER $CI_DEPLOY_USER"
- echo "CI_ENVIRONMENT_NAME $CI_ENVIRONMENT_NAME"
- echo "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"
- echo "CI_REGISTRY_PASSWORD $CI_REGISTRY_PASSWORD"
- echo "CI_REGISTRY_USER $CI_REGISTRY_USER"
- echo "CI_REGISTRY_IMAGE $CI_REGISTRY_IMAGE"
- echo "DOCKER_IMAGE_NAME $DOCKER_IMAGE_NAME"
- echo "CI_DEFAULT_BRANCH $CI_DEFAULT_BRANCH"
- echo "CI_PIPELINE_SOURCE $CI_PIPELINE_SOURCE"
- echo "{MAJOR}_{MINOR}_{REVISION} ${MAJOR}_${MINOR}_${REVISION}"
lint:
stage: test
image: registry.gitlab.com/pipeline-components/black:latest
script:
- black --check --verbose -- .
pytest:
stage: test
image: python:latest
before_script:
- pip install -r requirements.txt
script:
- pytest
publish-dev:
stage: publish
image: docker:cli
services:
- docker:dind
variables:
DOCKER_IMAGE_NAME: $CI_REGISTRY_IMAGE:develop-latest
before_script:
- echo "CI_REGISTRY_IMAGE $CI_REGISTRY_IMAGE"
- echo "DOCKER_IMAGE_NAME $DOCKER_IMAGE_NAME"
- echo "CI_REGISTRY $CI_REGISTRY"
- echo "DOCKER_HOST $DOCKER_HOST"
- echo "CI_COMMIT_BRANCH $CI_COMMIT_BRANCH"
- echo "CI_DEFAULT_BRANCH $CI_DEFAULT_BRANCH"
- docker info
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
rules:
- if: $CI_COMMIT_BRANCH == "develop"
exists:
- docker/Dockerfile
environment: Development
script:
- echo "Build Docker image..."
- docker build -t $DOCKER_IMAGE_NAME . -f docker/Dockerfile
- echo "Publish Docker image..."
- docker push $DOCKER_IMAGE_NAME
- echo "image successfully published."
deploy-dev:
stage: deploy
image: ubuntu:24.04
before_script:
- apt-get -yq update
- apt-get -yqq install ssh
- install -m 600 -D /dev/null ~/.ssh/id_rsa
- echo "$SSH_KEY" | base64 -d > ~/.ssh/id_rsa
- cat ~/.ssh/id_rsa
- ssh-keyscan -p $SSH_PORT -H $SSH_HOST > ~/.ssh/known_hosts
- cat ~/.ssh/known_hosts
script:
- ssh $SSH_USER@$SSH_HOST -p $SSH_PORT "/home/docker-infra/docker-testcicd/deploy.sh"
after_script:
- rm -rf ~/.ssh
rules:
- if: $CI_COMMIT_BRANCH == "develop"
environment: Development
Dans les grandes lignes, j’ai 4 phases:
- info: dans ce stage, j’affiche juste quelques variables de GitLab avec
JobVariables
. - test: dans ce stage, je teste l’appli avec
pytest
et je vérifie le format et l’indentation du code avecBlack
. - publish: dans ce stage, je me connecte à mon registry docker privé, je construis l’image de mon application et je la pousse sur le registry. Cette étape est uniquement exécutée si un fichier
Dockerfile
est présent ET si la branche estdevelop
. - deploy: dans ce stage, je me connecte à mon serveur en
ssh
pour exécuter un script sh de déploiement de mon image Docker.
Il ne me reste plus qu’à pousser mon code sur GitLab
git add .gitlab-ci.yml
git commit -m "test GitLab CICD"
git push origin develop
Et je peux admirer ma pipeline qui se déroule sans accroc (presque du premier coup…):
Pour enfin avoir le tant attendu projet déployé automatiquement:
Et bien sûr, si les étapes de pytest
ou de lint
échouent, il n’y a pas de nouvelle version déployée.
Voilà, encore une histoire de 5 minutes qui s’est transformée en quelques heures !
En tout cas, si tu passes par là, j’espère que ce contenu pourra t’être utile. N’hésite pas à me faire un retour. En attendant, je te dis à bientôt pour de prochaines aventures !