Déploiement d'applications et livraison continue
- Contexte et motivation
- Déployer les configurations spécifiques à chaque instance avec Docker
- Déploiement d’applications avec Ansible et Docker-Compose
- Optimisation de la taille des conteneurs et build multi-step
- Livraison continue avec Gitlab CI
Contexte et motivation
La livraison continue fait partie des pratiques CI/CD (pour Continuous Integration/Continous Delivery or Deployment) qui sont au coeur des méthodes agiles ou des pratiques DevOps. L'objectif de ces pratiques est de mettre en production les nouvelles fonctionnalités d'une application rapidement et de manière automatisée.
L'intégration continue se centre sur les développeurs en automatisant la validation l'intégration des modifications qu'ils effectuent dans la base de code de l'application. Avec la livraison continue, les nouvelles fonctionnalités sont testées et mises à disposition automatiquement dans un dépôt (par exemple un dépôt git) afin que les équipes d'exploitation puissent les déployer en production. Quant au déploiement continu, il automatise le déploiement en production des applications mises à disposition sur le dépôt.
Dans le cadre des applications déployées dans des conteneurs, le CI/CD s'étend à l'infrastructure elle-même via :
- La construction automatisée des images Docker
- La mise à disposition des configurations des services
- Le déploiement des services basés sur ces configurations
- La mise à jour des services et de leurs configurations
- La gestion des configurations (et des secrets...) interne au Swarm Docker
- ...
Un des problèmes à résoudre pour le déploiement d'application dans différents environnements est la possibilité de fournir des configurations spécifiques pour chacun d'entre eux. Idéalement, ces configurations devraient être versionnées et déployables en utilisant les mêmes outils que le code des applications elles-mêmes. Ce principe est appelé "configuration as code".
Dans ce chapitre, je vais aborder plusieurs techniques, outils et exemples permettant de mettre en place une livraison continue pour des infrastructures Docker Swarm. Je vais en particulier m'intéresser au concept de "configuration as code" et de la manière dont cette technique peut s'appliquer à Docker à travers la composition de fichier Docker Compose ou l'utilisation de variables d'environnement. J'aborderai également les outils Ansible et Gitlab CI.
Déployer les configurations spécifiques à chaque instance avec Docker
Docker propose plusieurs mécanismes qui simplifient le déploiement des configurations :
- La composition de fichier Docker Compose qui permet d'écraser ou de définir certaines directives de déploiement des piles applicatives
- Le support des variables et fichiers d'environnement qui permet de définir des valeurs spécifiques à chaque environnement
- Le support des contextes Docker qui permet de joindre un environnement Docker donné depuis une seule machine
- Les mécanismes de secrets et configurations qui permettent de spécifier des éléments de configuration directement dans un Swarm Docker pour les mettre à disposition des services déployés
Note : Ansible permet directement d'exécuter des actions spécifiques à certains environnements et ses fichiers peuvent être versionnés. Je vais donc me concentrer sur les mécanismes intégrés à Docker dans cette section.
Composition de fichiers de configuration de services avec docker-compose
Docker Compose permet de diviser la configuration des services en plusieurs fichiers qui seront chargés successivement lors de l’initialisation des conteneurs1. Cela permet, par exemple, d’avoir un fichier générique de déploiement d’application fonctionnant partout mais dans lequel certaines options sont manquantes et d’isoler les modifications nécessaires pour un déploiement « custom » dans un second fichier.
Par exemples, un premier fichier pourrait définir le service de base :
version: "3.7"
services:
webserver:
image: nginx:alpine
volumes:
- .:/application
- ./config/nginx-application.conf:/etc/nginx/sites-enabled/default
Et un autre indiquer les ports à utiliser pour ce service :
version: "3.7"
services:
webserver:
ports:
- "8001:80"
Docker Compose charge automatiquement deux fichiers de configuration si rien d’autre n’est précisé :
docker-compose.yml
: fichier chargé par défaut lors de l’exécution de la commandedocker-compose up
docker-compose.override.yml
: s’il existe, ce fichier sera charger automatiquement à la suite du fichierdocker-compose.yml
Note : le nom alternatif
compose.yml
est également reconnu automatiquement
Mais on peut également en ajouter d’autres, par exemple une configuration pour chaque environnement cible, par exemple :
- docker-compose.local.yml : pour un déploiement local
- docker-compose.swarm.yml : pour un déploiement sur un swarm docker
- docker-compose.staging.yml : pour un déploiement en QA
- docker-compose.prod.yml : pour un déploiement en production
- …
La composition doit être faite explicitement lors de l’appel à la commande docker-compose
avec l’option --file
(en raccourci -f
) pour lister les fichiers à charger et l’ordre dans lequel les charger :
docker-compose -f docker-compose.yml -f docker-compose.local.yml up -d
ou via la commande docker stack
et l’option --compose-file
(ou -c
en raccourci) sur le cluster
docker stack deploy --compose-file docker-compose.yml -c docker-compose.prod.yml <STACK_NAME>
Notes :
- Afin de pouvoir être composés, des fichiers Docker Compose doivent impérativement utiliser le même numéro de version
- Certaines directives des fichiers Docker Compose peuvent être étendues, d'autres ne peuvent être redéfinies2.
Docker et les variables d'environnement
Docker prend en compte les variables d’environnement. Celles-ci peuvent être passées aux commandes Docker ou via les fichiers Docker Compose soit via des variables individuelles, soit en utilisant des fichiers de variables d'environnement.
L'intérêt des variables d'environnement est de créer des fichiers de déploiement (Docker Compose, Dockerfile...) génériques et de définir les valeurs spécifiques à chaque déploiement sur la machine hôte.
Notes :
- La syntaxe est la même que pour les variables shell :
${<VAR_NAME>:-<DEFAULT_VALUE>}
- Si un fichier
.env
existe dans le répertoire courant, il est chargé automatiquement par Docker- Les variables d'environnement peuvent également être accédées dans les fichiers Dockerfile avec la syntaxe
$VAR_NAME
et passé via les options-e VAR_NAME=VAR_VALUE
ou--env-file /path/to/env-file
de la commande de construction d'image ou via le fichier Docker Compose.
Docker Secrets and Docker Config
Docker Swarm fournit deux mécanismes permettant de mettre à disposition des configurations partagées entre conteneurs :
- Pour les fichiers de configuration simple, ils peuvent être mis à disposition via la commande
docker config
3 et ensuite montés dans un service d'une manière similaire à n'importe quel volume ou fichier. - Pour les fichiers contenant des données confidentielles (clés, certificats X509 ou SSH, credentials...), il est préférable d'utiliser la commande
docker secret
4 qui permet de les stocker de manière sécurisée (ils sont chiffrés dans les journaux de RAFT et transmis vers les managers via une connexion TLS).
Voici un court exemple d'utilisation des configurations et secrets pour fournir les informations nécessaires à un proxy nginx :
version: "3.9"
# ...
services:
proxy:
image: nginx:latest
# ...
configs:
- source: nginx_default_site
target: /etc/nginx/sites/default.conf
mode: 0440
- source: nginx_shared_proxy
target: /etc/nginx/includes/proxy.conf
mode: 0440
secrets:
- source: ssl_cert
target: /etc/ssl/certs/proxy.pem
- source: ssl_interm
target: /etc/ssl/certs/proxy_interm.cer
- source: ssl_private_key
target: /etc/ssl/private/proxy.key
# ...
configs:
nginx_default_site:
file: ./config/nginx/sites/default.conf
nginx_shared_proxy:
external: true
secrets:
ssl_cert:
external: true
ssl_interm:
external: true
ssl_private_key:
external: true
# ...
Note : Les secrets et configs Docker sont une raison pour laquelle il peut être intéressant de déployer Docker Swarm même pour une infrastructure avec un seul noeud, plutôt que de se contenter de Docker Compose.
Exemples
Voici deux exemples qui illustrent l'utilisation de ces mécanismes pour le déploiement de piles applicatives.
Déploiement d'une pile Wordpress
L'exemple suivant combine variables définies dans l'environnement du shell qui exécute la commande de déploiement (par exemple via un fichier .env) et fichiers d'environnement dans un même fichier Docker Compose, afin de déployer une instance de Wordpress:
version: '3.7'
volumes:
dbdata:
wpdata:
networks:
net1:
services:
mysql:
image: mysql:${MYSQL_VERSION:-5.7}
env_file:
- db.env
networks:
- net1
volumes:
- type: volume
source: dbdata
target: /var/lib/mysql
pma:
image: phpmyadmin:${PMA_VERSION:-latest}
networks:
- net1
env_file:
- pma.env
wordpress:
image: wordpress:${WP_VERSION:-latest}
networks:
- net1
volumes:
- type: volume
source: wpdata
target: /var/www/html
env_file:
- wp.env
Le fichier d'environnement correspondant est :
# db.env
MYSQL_ROOT_PASSWORD=secret
MYSQL_DATABASE=wp1db
MYSQL_USER=wp1user
MYSQL_PASSWORD=wp1pass
Les variables passées peuvent également être utilisées dans les conteneurs eux-mêmes. Voici un exemple d'une telle utilisation dans l'entrypoint d'un service pour déployer la plateforme Moodle :
# entrypoint.sh
#!/bin/bash
# Configuration de Moodle à partir du template
echo "[$0]:creation du fichier de config."
sed \
-e "s,MOODLE_WWWROOT,${MOODLE_URL:-http://127.0.0.1:8000}," \
-e "s,MOODLE_LANG,${MOODLE_LANG:-fr_FR}," \
-e "s,MOODLE_DBTYPE,${MOODLE_DBTYPE:-pgsql}," \
-e "s,MOODLE_DB_HOST,${MOODLE_DB_HOST:-db}," \
-e "s,MOODLE_DB_NAME,${MOODLE_DB_NAME:-moodle}," \
-e "s,MOODLE_DB_USER,${MOODLE_DB_USER:-moodleuser}," \
-e "s,MOODLE_DB_PASSWORD,${MOODLE_DB_PASSWORD:-moodlepass}," \
-e "s,MOODLE_DB_TABLE_PREFIX,${MOODLE_DB_TABLE_PREFIX:-mdl_}," \
/tmp/config-dist.php > /var/www/html/config.php
exec "$@"
Cet exemple utilise sed afin de replacer des chaînes dans un template de configuration :
# config-dist.php
// ...
$CFG->lang = 'MOODLE_LANG';
$CFG->dbtype = 'MOODLE_DBTYPE'; // 'pgsql', 'mariadb', 'mysqli', 'auroramysql', 'sqlsrv' or 'oci'
$CFG->dblibrary = 'native'; // 'native' only at the moment
$CFG->dbhost = 'MOODLE_DB_HOST'; // eg 'localhost' or 'db.isp.com' or IP
$CFG->dbname = 'MOODLE_DB_NAME'; // database name, eg moodle
$CFG->dbuser = 'MOODLE_DB_USER'; // your database username
$CFG->dbpass = 'MOODLE_DB_PASSWORD'; // your database password
$CFG->prefix = 'MOODLE_DB_TABLE_PREFIX'; // prefix to use for all table names
// ...
Déploiement des piles Drupal pour le Portail
Dans la cadre du developpement du nouveau portail Drupal, j'ai été amené à résoudre plusieurs contraintes techniques liées au déploiement d'application. J'intègre cette problématique ici car sa résolution pourrait être appliquée à d'autres piles applicatives.
Les contraintes étaient :
- Le déploiement dans des environnements multiples
- Le déploiement sur des environnements d'exécution Docker différents
Déploiement dans des environnements multiples
La pile applicative du portail devait pouvoir être déployée dans des environnements de test (sur les machines locales des développeurs), des instances spécifiques à certains usages (test, formation, migration, démonstration...) et, bien entendu, la pré-production (QA) et la production. Chacun de ces environnements avait ses spécificités quant à la configuration de la pile applicative.
En particulier, la topologie de la pile (cluster avec 1 ou plusieurs noeuds), localisation de la base de donnée (dans la pile applicative ou sur un hôte distant), le montage de volume supplémentaires pour les migrations...
Déploiement sur des environnements d'exécution différents
Afin de répondre tant aux besoins des développeurs que de prestataires externes, les piles ont dû être rendues compatibles avec les environnements d'exécution Docker Compose, utilisé sur les machines locales sous MacOSX ou Windows, et Docker Swarm, utilisé sur les clusters distants ou sur les machines locales sous Linux.
Variables d'environnements et composition de piles Docker
J'ai implémenté la prise en compte de ces contraintes en utilisant 2 mécanismes : la composition de fichiers docker-compose de description des piles applicatives Docker (déjà abordée plus haut), et le mécanisme de variables d'environnement.
L'idée centrale était de décrire les spécificités de chaque déploiement dans un fichier de variables UNIX .env
situé à la racine.
En voici un exemple pour un déploiement du portail sur un cluster Docker Swarm local derrière un reverse proxy nginx-proxy :
# Environnement d'exécution Docker (valeurs autorisées : swarm ou compose)
MODE=swarm
# Topologie de l'environnement Docker (ici mono nœud, valeurs autorisées mono ou multi)
SWARM_TOPO=mono
# Fichier de description de plie Docker à utiliser, dans ce cas un environnement utilisant nginx-proxy
SWARM_ENV=nginx-proxy
# Variable spécifiques à l'installation de l'application
BRANCH=develop
PORTAL_MODE=dev
PORTAL_INSTANCE=dev
POSTGRES_DB=drupal
POSTGRES_USER=drupal
POSTGRES_PASSWORD=password
DB_HOST=database
DB_PORT=5432
DRUPAL_PASSWORD=password
# Variables spécifiques à nginx-proxy
PORTAL_VHOST=portal.docker.localhost
PORTAL_VPROTO=http
PORTAL_VPORT=80
J'ai créé pour la pile applicative un fichier docker-compose.yml de base reprenant les directives et options communes à tous les environnements.
version: '3.7'
services:
webserver:
image: nginx:latest
depends_on:
- "php-fpm"
command: ["/bin/wait-for-it.sh", "php-fpm:9000", "--", "nginx", "-g", "daemon off;"]
env_file:
- docker/nginx/nginx.env
- .env
working_dir: /var/www/portail-next
volumes:
- ./var/www/portail-next:/var/www/portail-next/
- ./docker/wait-for-it.sh:/bin/wait-for-it.sh
- ./docker/nginx/conf.d:/etc/nginx/conf.d
- ./docker/nginx/sites:/etc/nginx/sites-enabled
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
php-cli:
image: private-registry:5000/drupal_phpfpm81:latest
build:
context: ./docker/php-fpm
dockerfile: dockerfile
cache_from:
- private-registry:5000/drupal_phpfpm81:latest
env_file:
- docker/php-fpm/php-fpm.env
- .env
working_dir: /var/www/portail-next
volumes:
- ./docker/php-fpm/php-ini-overrides.ini:/etc/php/8.1/fpm/conf.d/99-overrides.ini
- ./var/www/portail-next:/var/www/portail-next/
- ./bin:/var/www/portail-next/bin
command: ["tail", "-f", "/dev/null"]
php-fpm:
image: private-registry:5000/drupal_phpfpm81:latest
build:
context: ./docker/php-fpm
dockerfile: dockerfile
cache_from:
- private-registry:5000/drupal_phpfpm81:latest
env_file:
- docker/php-fpm/php-fpm.env
- .env
working_dir: /var/www/portail-next
volumes:
- ./docker/php-fpm/php-ini-overrides.ini:/etc/php/8.1/fpm/conf.d/99-overrides.ini
- ./var/www/portail-next:/var/www/portail-next/
- ./bin:/var/www/portail-next/bin
Ensuite, pour chaque valeur d'environnement spécifique SWARM_ENV et chaque topologie SWARM_TOPO, j'ai fourni un fichier environments/<SWARM_ENV>/docker-compose.<SWARM_TOPO>.yml
contenant les directives et options spécifiques à chaque environnement.
# environments/prod/docker-compose.multi.yml
version: '3.7'
services:
webserver:
deploy:
mode: global
placement:
constraints:
- node.role!=manager
networks:
- portail_ntwrk
- proxy_public
volumes:
- /portail-data:/var/www/portail-next/web/sites/default/files/private
php-cli:
deploy:
replicas: 1
placement:
constraints:
- node.role==manager
networks:
- portail_ntwrk
volumes:
- /portail-data:/var/www/portail-next/web/sites/default/files/private
php-fpm:
deploy:
mode: global
placement:
constraints:
- node.role!=manager
networks:
- portail_ntwrk
volumes:
- /portail-data:/var/www/portail-next/web/sites/default/files/private
networks:
portail_ntwrk:
driver: overlay
attachable: true
proxy_public:
name: proxy_public
external: true
Enfin, j'ai créé une commande Bash (mais cela aurait pu être fait avec une commande ansible également) permettant de créer les piles applicatives, les démarrer et les arrêter en se basant sur les variables d'environnement définies pour sélectionner les bons fichiers de définition de pile.
En pratique, la personne devant déployer l'application n'a plus qu'à définir les variables correspondant à l'environnement sur lequel il ou elle effectue le déploiement dans un fichier nommé .env
et situé à la racine du répertoire de son instance du portail. Il lui suffit ensuite d'exécuter le script permettant la création de la pile et de lancer celle-ci.
Le cas de Docker Compose est un peu particulier, puisqu'une personnalisation du fichier docker-compose.override.yml est nécessaire. Ce fichier docker-compose.override.yml doit également se trouver à la racine du répertoire de l'application à déployer.
Généralisation
Bien que créé spécifiquement pour le portail, ce mécanisme peut être généralisé avec un minimum d'effort et intégrer à d'autres piles applicatives. Il pourrait également être transformé en un ensemble de tâches Ansible ou être appelé par le mécanisme de livraison continue.
Déploiement d’applications avec Ansible et Docker-Compose
Une des solutions pour automatiser les opérations de déploiement de conteneurs consiste à utiliser Ansible et Docker Compose.
Automatisation de déploiement d’application avec Ansible : un exemple avec Kanboard, une application PHP élémentaire
Un premier cas de déploiement a été décrit plus haut avec l'application Kanboard. Il s’agissait d’un cas élémentaire ne demandant aucune installation autre que le téléchargement de l’application et le déploiement des services de la stack. La seule particularité était la gestion des droits sur le répertoire des données de l’application.
Afin de pouvoir fonctionner, cette application demande des extensions particulières pour le service php-fpm.
Celles-ci sont fournies par le générateur de squelette de déploiement docker-compose
PhpDocker.io. Ce site permet de créer des stacks contenant la version de PHP, extensions et services tiers (bases de données, mail…) requis par une application PHP.
Fichier de configuration de services
Fichier docker-compose.yml :
version: "3.1"
services:
webserver:
image: nginx:alpine
container_name: kanboard-webserver
working_dir: /application
volumes:
- ./kanboard:/application
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
php-fpm:
build: docker/php-fpm
container_name: kanboard-php-fpm
working_dir: /application
volumes:
- ./kanboard:/application
- ./docker/php-fpm/php-ini-overrides.ini:/etc/php/7.4/fpm/conf.d/99-overrides.ini
Fichier docker-compose.local.yaml :
version: "3.1"
services:
webserver:
ports:
- "8001:80"
Fichier de construction de l’image php-fpm
FROM phpdockerio/php74-fpm:latest
WORKDIR "/application"
# Fix debconf warnings upon build
ARG DEBIAN_FRONTEND=noninteractive
# Install selected extensions and other stuff
RUN apt-get update \
&& apt-get -y --no-install-recommends install php7.4-sqlite3 php7.4-bcmath php7.4-gd php7.4-intl php7.4-ldap php7.4-xsl php-yaml \
&& apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*
# Install git
RUN apt-get update \
&& apt-get -y install git \
&& apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*
Playbook de déploiement avec Ansible
---
- name: "Deploying Kanboard application locally"
hosts: localhost
connection: local
tasks:
- name: Check if kanboard folder exists
stat:
path: ../kanboard
register: kanboard_folder
- name: Get Kanboard
git:
repo: https://github.com/kanboard/kanboard.git
dest: ../kanboard
when: kanboard_folder.stat.exists == false
- name: Check if kanboard db exists
stat:
path: ../kanboard/data
register: kanboard_data
- name: Change file ownership, group and permissions
file:
path: ../kanboard/data
state: directory
recurse: yes
mode: 0777
when: kanboard_data.stat.mode != 0777
- name: Check if docker-compose.local file exists
stat:
path: ../docker-compose.local.yml
register: local_compose
- name: Deploy application containers with local compose file
args:
chdir: ../
shell: docker-compose -f docker-compose.yml -f docker-compose.local.yml up -d
when: local_compose.stat.exists == true
- name: Deploy application containers with default compose file
args:
chdir: ../
shell: docker-compose up -d
when: local_compose.stat.exists == false
Ce fichier automatise toutes les tâches nécessaires pour déployer l’application :
- récupération du code de l’application depuis un dépôt distant
- modifications de droits d’accès sur le répertoire des données de kanboard
- déploiement de l’application via docker-compose
Le déploiement de l’application en local se fait via la commande ansible-playbook
:
ansible-playbook --connection=local playbooks/deploy.yml
Pour aller plus loin : gestion des droits d’accès pour un déploiement sur le cluster
Ce qui précède fourni le nécessaire pour déployer une application sur une machine locale. Toutefois, son déploiement sur le cluster Swarm demande une petite modification au niveau des droits d’accès sur les répertoires afin de correspondre aux ACL définies sur le cluster.
La modification est relativement simple : il suffit d’utiliser le groupe 'docker' pour l’accès aux fichiers. Toutefois, ce groupe n’existant pas dans notre conteneur php-fpm, une modification du fichier Dockerfile est nécessaire pour l’ajouter :
FROM phpdockerio/php74-fpm:latest
WORKDIR "/application"
# Fix debconf warnings upon build
ARG DEBIAN_FRONTEND=noninteractive
# Install selected extensions and other stuff
RUN apt-get update \
&& apt-get -y --no-install-recommends install php7.4-sqlite3 php7.4-bcmath php7.4-gd php7.4-intl php7.4-ldap php7.4-xsl php-yaml \
&& apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*
# Install git
RUN apt-get update \
&& apt-get -y install git \
&& apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*
# Ligne ajoutée pour créer le groupe docker dans le conteneur
# Attention le numéro du groupe dépend de la distribution
RUN groupadd --gid 999 docker
Il faudra également modifier le fichier de configuration du pool de php-fpm docker/php-fpm/php-ini-overrides.conf
afin d’utiliser le groupe 'docker' au lieu le groupe 'www-data' utilisé par défaut.
Pour cela, il suffit de remplacer
[www]
user = www-data
group = www-data
par
[www]
user = www-data
group = docker
Il reste encore à modifier de la tâche de modification des droits d’accès dans le playbook de déploiement :
- name: Change file ownership, group and permissions
file:
path: ../kanboard/data
state: directory
group: docker
recurse: yes
mode: 0775
when: kanboard_data.stat.gr_name != 'docker'
Déploiement sur le swarm
version: "3.1"
services:
redis:
image: redis:alpine
container_name: framemo-redis
deploy:
replicas: 1
placement:
constraints:
- node.role!=manager
webserver:
image: nginx:alpine
container_name: framemo-webserver
working_dir: /application
volumes:
- ./framemo:/application
- ./config/nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./wait-for-it.sh:/usr/local/bin/wait-for-it.ch
command: ["bash /usr/local/bin/wait-for-it.sh", "framemo-nodejs:3000", "--", "nginx", "-g", "daemon off;"]
deploy:
replicas: 1
placement:
constraints:
- node.role!=manager
depends_on:
- nodejs
nodejs:
image: dkm-webapps.sipr-dc.ucl.ac.be:5000/framemo_node:latest
build:
context: .
cache: dkm-webapps.sipr-dc.ucl.ac.be:5000/framemo_node:latest
container_name: framemo-nodejs
working_dir: /application
volumes:
- ./framemo:/application
- /application/node_modules
- ./config/framemo/config.js:/application/config.js
command: node /application/server.js --port 3000
deploy:
replicas: 1
placement:
constraints:
- node.role!=manager
Le script wait-for-it.sh
permet d’attendre qu’un service soit disponible avant d’exécuter une commande. C’est particulièrement important pour éviter une erreur lors du démarrage du service nginx. Ce script est disponible dans les fichiers techniques ou sur github.
- name: "Deploying framemo application on a swarm"
hosts: localhost
connection: local
tasks:
- name: Check if framemo folder exists
stat:
path: ../framemo
register: framemo_folder
- name: Get framemo
git:
repo: https://framagit.org/framasoft/framemo.git
dest: ../framemo
when: framemo_folder.stat.exists == false
- name: Check if docker-compose.swarm file exists
stat:
path: ../docker-compose.swarm.yml
register: swarm_compose
- name: Deploy application containers with swarm compose file
args:
chdir: ../
shell: docker stack deploy -c docker-compose.yml -c docker-compose.swarm.yml framemo
when: swarm_compose.stat.exists == true
Analyses de cas
Framemo, une application nodejs + redis
L’application Framemo est une version modifiée mise à disposition par le collectif Framasoft de l’application de post-it en ligne Scrumblr. Son code source est disponible via le service gitlab framagit.org.
Il s’agit d’une application Nodejs relativement simple utilisant un serveur Redis pour le stockage de ses données. Elle utilise également le gestionnaire de paquet npm pour son installation. J’ai décidé d’ajouter un proxy Nginx à la stack, mais ce n’est pas nécessaire. En effet, le service nodejs
de la stack pourrait servir de point d’entrée.
J’ai choisi cet exemple pour deux raisons :
- les exemples simples de déploiement d’application Nodejs avec docker-compose sont plutôt rares
- l’installation des paquets via le gestionnaire de paquet
npm
lors de la construction de l’image docker du micro-service de l’application
Les principales contraintes que je m’étais fixées pour cet exemple sont :
- utiliser des images disponibles sur le hub de Docker
- ne pas exécuter la commande
npm
sur la machine hôte, mais bien dans son conteneur - ne pas modifier les fichiers de l’application récupérés depuis gitlab
Configuration des services de l’application
Le fichier docker-compose.yml est le suivant :
version: "3.1"
services:
redis:
image: redis:alpine
container_name: framemo-redis
webserver:
image: nginx:alpine
container_name: framemo-webserver
working_dir: /application
volumes:
- ./framemo:/application
- ./config/nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- nodejs
nodejs:
image: framemo_node
build: .
container_name: framemo-nodejs
working_dir: /application
volumes:
- ./framemo:/application
- /application/node_modules
- ./config/framemo/config.js:/application/config.js
command: node /application/server.js --port 3000
Remarques :
- Les noms des conteneurs des services ont été fixés dans le fichier pour faciliter le déploiement. Dans le cas contraire,
docker-compose
attribue automatiquement comme nom le nom du répertoire suivi d'un "_" puis du non du service. Il est tout à fait possible de laisser docker le faire, mais dans ce cas des variables et des templates devront être utilisés pour les configurations des services et applications de la stack.- A part en ce qui concerne nodejs, cette stack utilise uniquement des images officielles disponibles depuis le hub de docker.
- Le port utilisé pour servir l'application Framemo a été modifié via
--port 3000
, mais le port par défaut de l’application (8080) aurait pu être utilisé.
Un second fichier permet de modifier le port exposé par le serveur web Nginx afin de ne pas avoir de collision sur le port 80 :
version: "3.1"
services:
webserver:
ports:
- "8002:80"
Playbook Ansible pour un déploiement local
Le playbook de déploiement via Ansible est assez simple dans le cas de cette application. Il est responsable des actions suivantes :
- récupérer le code de l’application depuis son dépôt gitlab
- lancement de la stack avec les fichiers de configuration
docker-compose
disponibles
Pour un déploiement local, le fichier est le suivant :
---- name: "Deploying framemo application locally" hosts: localhost connection: local tasks: - name: Check if framemo folder exists stat: path: ../framemo register: framemo_folder - name: Get framemo git: repo: https://framagit.org/framasoft/framemo.git dest: ../framemo when: framemo_folder.stat.exists == false - name: Check if docker-compose.local file exists stat: path: ../docker-compose.local.yml register: local_compose - name: Deploy application containers with local compose file args: chdir: ../ shell: docker-compose -f docker-compose.yml -f docker-compose.local.yml up -d when: local_compose.stat.exists == true - name: Deploy application containers with default compose file args: chdir: ../ shell: docker-compose up -d when: local_compose.stat.exists == false
Build de l’image pour nodejs
Un build de l’image utilisée pour Nodejs est nécessaire à la récupération des paquets npm nécessaires à notre application via la commande npm install
.
Les volumes montés pour le build par docker-compose
sont les suivants :
./framemo:/application
: le répertoire de l’application/application/node_modules
: pour récupérer les paquets installés parnpm
./config/framemo/config.js:/application/config.js
: pour personnaliser la configuration de l’application afin qu’elle puisse fonctionner dans la stack
Pour construire l’image, j’ai décidé d’utiliser une image node:alpine
sur laquelle sera exécutée l’installation des paquets via npm
.
Le fichier Dockerfile
correspondant est :
# On part d’une image disponible sur le hub docker
FROM node:alpine
# Le répertoire de travail est le volume de l’application déclaré
# dans le fichier docker-compose.yml
WORKDIR /application
# On copie le fichier conteant la liste des dépendances de l’application
# ATTENTION le chemin est relatif au fichier Dockerfile !
COPY ./framemo/package.json .
# Exécution de la commande d’installation des paquets, ceux-ci seront installés dans
# /application/node_modules
RUN npm install --quiet
# Copie des fichiers récupérés afin de les rendre disponibles hors de l’image docker
COPY . .
Notes :
-
Ce fichier est inspiré par un exemple trouvé en ligne, toutefois aucun de ces exemples ne répondait aux contraintes et aux spécificités de l’application Framemo
-
Le chemin pour intégrer le fichier
package.json
est relatif au fichierDockerfile
, j’ai décidé de placer celui-ci à la racine du projet de déploiement -
Pour permettre l’installation des paquets
npm
et leur accès sur le filesystem de l’hôte, il a fallu ajouter le montage/application/node_modules
au fichierdocker-compose.yml
Configuration de l’application et de nginx
Une modification de la configuration de l'application est nécessaire afin de pouvoir accéder au service Redis de la stack. Elle consiste à simplement remplacer dans une copie de la configuration originale la ligne
var redis_conf = argv.redis || '127.0.0.1:6379';
par
var redis_conf = argv.redis || 'framemo-redis:6379';
où framemo-redis
est le nom interne utilisé par docker pour le routage des requêtes réseau vers le service redis au sein de la stack.
L’application peut alors être lancée via la commande node /application/server.js --port 3000
dans le fichier docker-compose.yml
.
Le choix du port est arbitraire et correspond à celui indiqué dans la configuration du serveur web Nginx :
server {
listen 80 default;
client_max_body_size 108M;
access_log /var/log/nginx/framemo.access.log;
root /application;
location / {
proxy_pass http://framemo-nodejs:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
où framemo-nodejs
est le nom interne utilisé par docker pour le routage des requêtes réseau vers le service nodejs au sein de la stack.
Déploiement de l’application
Le déploiement de l’application en local se fait via la commande ansible-playbook
:
ansible-playbook --connection=local playbooks/deploy.yml
Dont le résultat est
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match
'all'
PLAY [Deploying framemo application locally] ***************************************************************************
TASK [Gathering Facts] *************************************************************************************************
ok: [localhost]
TASK [Check if framemo folder exists] **********************************************************************************
ok: [localhost]
TASK [Get framemo] *****************************************************************************************************
skipping: [localhost]
TASK [Check if docker-compose.local file exists] ***********************************************************************
ok: [localhost]
TASK [Deploy application containers with local compose file] ***********************************************************
changed: [localhost]
TASK [Deploy application containers with default compose file] *********************************************************
skipping: [localhost]
PLAY RECAP *************************************************************************************************************
localhost : ok=4 changed=1 unreachable=0 failed=0
Il suffit alors de se rendre sur l'url http://localhost:8002 pour accéder à l’application.
Autres applications
- Le CMS Grav
- Une application d’exemple basée sur Nodejs + Yarn
Optimisation de la taille des conteneurs et build multi-step
Les fichiers Dockerfile permettent de définir des recettes de construction d'image en plusieurs étapes. C'est en particulier utile lorsqu'une image est nécessaire pour construire une application, mais qu'une autre image sera utilisée pour exécuter l'application. C'est typiquement le cas des applications web basées sur Node.js (Angular, React...)
# Image Node.js 16 pour construire l'application React
FROM node:16-bullseye-slim as builder
RUN set -ex \
&& apt-get update && apt-get install -y git ca-certificates --no-install-recommends \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN set -ex \
&& mkdir -p /opt/fari \
&& git clone https://github.com/fariapp/fari-app.git /opt/fari \
&& cd /opt/fari \
&& npm install
RUN apt-mark auto '.*' > /dev/null \
&& find /usr/local -type f -executable -exec ldd '{}' ';' \
| awk '/=>/ { print $(NF-1) }' \
| sort -u \
| xargs -r dpkg-query --search \
| cut -d: -f1 \
| sort -u \
| xargs -r apt-mark manual \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false
RUN set -ex \
&& cd /opt/fari \
&& npm run build
# Image nginx permettant de servir l'application
FROM nginx:1.23.1
COPY --from=builder /opt/fari/dist /usr/share/nginx/html
Dans cet exemple, l'image node a laquelle j'ai attribué le nom builder
(via FROM ... as builder
) n'est utilisée que pour récupérer le code source de l'application depuis github et construire celle-ci. Une fois cette étape effectuée, les fichiers générés sont copiés dans une image basée sur nginx via COPY --from=builder
. A la fin du build, seule cette seconde image sera conservée. Dans le cas de cette application, l'image de build pèse 1.92Go alors que l'image finale ne pèse que 135Mo !
Un autre exemple est le fichier que j'utilise pour déployer la version web de ce brevet :
# Image Rust temporaire permettant de construire l'application à déployer
FROM rust:1.67 AS builder
RUN cargo install mdbook mdbook-toc mdbook-mermaid
WORKDIR /opt/src
COPY . .
RUN mdbook build
# Image nginx qui permettra de servir l'application construite
FROM nginx:latest
COPY --from=builder /opt/src/book/html /usr/share/nginx/html
Ici le builder est une image contenant l'environnement d'exécution du langage Rust et installant d'installer l'outil mdbook et ses modules supplémentaires. Mdbook exécutent ensuite la génération du site web à partir des fichiers markdown de mon brevet. Les fichiers générés sont alors de nouveau copiés dans un conteneur nginx.
Note :
- le backend buildkit, utilisé par défaut depuis la version 23 de Docker mais disponible sur les versions précédentes, permet un plus grand contrôle sur les build multi-step. Il est par exemple possible de générer l'image d'une étape donnée d'un fichier Dockerfile multi-step.
- le processus de build ne gardera que la dernière image générée par défaut. En cas de build fréquent basé sur une même image, il est conseillé de télécharger cette image via
docker image pull
afin d'éviter de gaspiller de la bande passante et du temps de téléchargement inutile. Les imagesrust:1.67
ounode:16-bullseye-slim
de mes deux exemples font chacune plus de 1Go.
Livraison continue avec Gitlab CI
La livraison continue est un mécanisme issu des pratiques DevOps et qui consiste à déployer une application lorsqu’une nouvelle version prête pour la production est disponible.
Ce mécanisme se distingue du déploiement continu par le fait que l’action est déclenchée ici à la demande des développeurs et non de manière automatique à chaque changement de la base de code de l’application. Le déploiement continu est généralement combiné avec l’intégration continue qui consiste, en gros, à valider les changements effectués sur une application sur base de tests automatisés. Le déploiement continu ne s’effectuera donc que si ces tests ont réussi.
La livraison continue permet donc d’avoir un plus grand contrôle sur le produit déployé et est plus sécurisant pour des applications critiques.
Dans la livraison continue, le déploiement d’une application peut être déclenché par plusieurs mécanismes comme l’exécution d’une commande depuis un outil d’automatisation ou sur base d’un événement comme le changement poussé sur une branche donnée dans le dépôt de son code source.
On distingue deux stratégies de déploiement pour mettre en place un mécanisme de livraison continue :
- Push-based : dans cette première stratégie, c’est une modification du code source d’une application qui va déclencher l’exécution des tâches de déploiement. C’est le mécanisme le plus souvent rencontré. Il est utilisé par Gitlab-CI, Jenkins, Travis CI… Le défaut de cette approche est que l’application ne sera déployée que si le code de l’application est modifié. Les changements sur les images Docker ne seront quant à eux pas pris en compte automatiquement.
- Pull-based : dans ce cas de figure, un agent (appelé opérateur) de l’environnement de déploiement va observer le dépôt du code source de l’application ainsi que le dépôt des images. Il déclenchera le déploiement s’il détecte une modification de l’un d’entre eux. C’est le fonctionnement mis en œuvre par GitOps, par exemple.
Afin de mettre en place ce mécanisme, j’ai choisi d’utiliser les outils suivants :
- Gitlab comme dépôt pour les fichiers de déploiement des applications et, lorsqu’il s’agit d’un développement interne, leur code source
- Gitlab-CI est le moteur d'exécution des tâches d'intégration continue, livraison continue et déploiement continu (CI/CD) de Gitlab
- Gitlab-Runner est l'outil qui permet l'exécution des taches de CI/CD sur les machines distantes
Note : Gitlab intègre un registre d'images Docker qui pourra être utilisé comme registry externe pour le Swarm. Le registre a été activé sur la Forge UCLouvain et sur le GItlab de SISG. Le mécanisme contient deux parties :
- Un service gitlab-runner doit être déployé sur la machine qui exécutera les tâches de déploiement
- Ce service doit être configuré dans les runners disponibles sur Gitlab. Un runner peut être soit
shared
pour l'ensemble des projets hébergés sur l'instance Gitlab, soit dédié à un groupe de projets ou à un projet spécifique.
L'enregistrement d'un runner se fait via l'interface de Gitlab, chaque runner se voit associé un token qui l'identifie et un autre qui permet au service gitlab-runner de s'enregistrer auprès de gitlab.
Les opérations à exécuter sont définies dans une pipeline. Les différentes tâches (jobs) seront exécutées par le runner. Les jobs d'une pipeline peuvent être traités en parallèles et exécutés par des runners différents (par exemple un runner chargé de construire l'application, un autre chargé de son déploiement). Les pipelines sont définis dans un fichier YAML nommé .gitlab-ci.yml
.
L'exécution d'une pipeline peut être déclenchée par différents événements :
- Opération push sur un dépôt qui peut-être systématique ou avec des conditions (filtrage par branche, push sur un tag...)
- A la main via un click dans l'interface de gitlab
- De manière planifiée via un scheduler intégré à gitlab
- Via l'API de gitlab
La mise en place du mécanisme est relativement simple et est détaillée dans la documentation de Gitlab. Toutefois, le nombre d'options disponibles pour les pipelines est assez élévé et la consultation d'exemples peut s'avérer rapidement nécessaire.
Dans le cadre d'une infrastructure Docker, l'idéal est de déployer les services gitlab-runner sous la forme de conteneurs Docker afin de lui donner facilement accès aux opérations sur les services déployés dans le Swarm.
Voici un exemple de fichier de déploiement de Gitlab Runner :
version: '3.9'
volumes:
config:
home:
cache:
services:
runner:
image: gitlab/gitlab-runner:${RUNNER_VERSION:-latest}
restart: always
env_file:
- formation-swarm.env
entrypoint: "bash"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- config:/etc/gitlab-runner
- home:/home/gitlab-runner
- cache:/cache
healthcheck:
test: "gitlab-runner verify --name ${COMPOSE_PROJECT_NAME} 2>&1 | grep -q alive"
start_period: 10s
interval: 10s
timeout: 10s
retries: 10
command: |
-c 'set -e
printf "\\nSetting configuration...\\n"
mkdir -p /etc/gitlab-runner
echo -e " log_level = \"warning\"\n concurrent = $${CONCURRENT}\n check_interval = $${CHECK_INTERVAL}\n\n [session_server]\n session_timeout = 3600 " > /etc/gitlab-runner/config.toml
printf "\\nRegistering runner...\\n"
gitlab-runner register --non-interactive --executor docker --docker-image docker:dind --locked=false --docker-privileged --run-untagged=${RUN_UNTAGGED:-false} --tag-list=${RUNNER_TAG_LIST:-deploy}
printf "\\nRunning runner...\\n"
gitlab-runner run --user=gitlab-runner --working-directory=/home/gitlab-runner'
Note :
- le runner doit s'exécuter avec un utilisateur qui a des droits suffisants pour accéder aux fichiers du répertoire de travail pour le déploiement (ici respectivement gitlab-runner et /home/gitlab-runner).
- le runner utilise une image "docker in docker"
docker:dind
afin de pouvoir exécuter des commandes docker depuis le conteneur lui-même
Et le fichier d'environnement correspondant qui définit les paramètres nécessaires à l'exécution du runner.
COMPOSE_PROJECT_NAME=fari.app-swarm
GITLAB_SERVER_URL=https://forge.uclouvain.be/
RUNNER_TOKEN=<TOKEN GENERE PAR GITLAB>
RUNNER_NAME=fari.app-swarm
API_URL=https://forge.uclouvain.be/api/v4
CI_SERVER_URL=https://forge.uclouvain.be/ci
REGISTRATION_TOKEN=<TOKEN GENERE PAR GITLAB>
CONCURRENT=-1
CHECK_INTERVAL=-1
DOCKER_VOLUMES=/var/run/docker.sock:/var/run/docker.sock
RUN_UNTAGGED=false
RUNNER_TAG_LIST=deploy
Les commandes qui seront exécutées par le runner sont définies dans un fichier .gitlab-ci.yml
situé à la racine du projet concerné. Il suffit alors de déclarer un pipeline dans le projet Gitlab et de configurer ses conditions d'exécution, sa fréquence d'exécution et des variables additionelles. Si ce n'est pas fait, le runner devra être exécuté à la main via "Run pipeline".
Note : Des exemples plus complets peuvent être trouvés en ligne, par exemple sur https://gitlab.actilis.net/formation/gitlab/deploy-runner
Chaque Pipeline se caractérise par des "étages" (stages) qui seront exécutés dans l'odre de leur déclaration. Par exemple :
stages:
- prebuild
- build
- depls
- postdeploy
On définit alors des jobs qui seront assignés à chaque étage
build-docker-image
stage: build
script:
- docker compose build
deploy-on-swarm
stage: deploy
script:
- docker stack deploy -c compose-swarm.yml fari
Il y a également des directives prédéfinies comme before_script
ou after_script
qui seront exécutées respectivement avant et après chaque exécution d'une commande script
. Chaque job peut lui-mème déclarer ses propres directives before_script
ou after_script
.
Gitlab CI constitue donc un outil extrêmement flexible et complet pour mettre en place la livraison continue avec Docker Swarm. Les pipelines et le runner sont relativement simples à mettre en place et à configurer.
« Share Compose configurations between files and projects » https://docs.docker.com/compose/extends/
« Share Compose configurations between files and projects » https://docs.docker.com/compose/extends/
« Store configuration data using Docker Configs » https://docs.docker.com/engine/swarm/configs/
« Manage sensitive data with Docker secrets » https://docs.docker.com/engine/swarm/secrets/