Par Frédéric Chyzak, Maxence Guesdon
mercredi 8 juin 2016

Ceci est un retour d'expérience sur la mise en place d'un processus d'intégration continue et de mise en production pour ECS.

Introduction

ECS (pour Encyclopedia of Combinatorial Structures) est une application en ligne développée par l'équipe Specfun (ex-Algorithms). Elle liste 1075 structures combinatoires pour lesquelles il est possible d'obtenir les fonctions génératrices, des approximations asymptotiques, ... Ces calculs algébriques sont faits dynamiquement à partir des paramètres saisis par l'utilisateur. Le serveur communique avec le système de calcul formel Maple et envoie les résultats au navigateur pour mettre à jour la page affichée.

ECS est développé en OCaml+Maple et utilise la bibliothèque DynaMoW développée par l'équipe et qui permet le développement d'applications de ce type: un serveur utilisant un ou plusieurs types de systèmes de calcul formel (Maple, Sage, ...) et permettant l'affichage de documents dynamiques dans le navigateur client. DynaMoW offre également des extensions de syntaxe OCaml facilitant l'utilisation des systèmes de calcul formel ainsi que la création de documents qui sont ensuite traduits vers du HTML.

La mise en place d'un processus d'intégration continue vise ici à:

Vérifier que les dépendances du logiciel sont correctes
Afin de pouvoir facilement lister ce qui doit être installé avant de compiler l'application, on veut que soit régulièrement testée l'installation sur une distribution vierge (Ubuntu ou Debian) des paquets de la distribution et des bibliothèques OCaml requis. Si un paquet ou une bibliothèque devient "ininstallable" (changement de nom, problème de dépendances, conflits, ...), cette procédure permettra de s'en rendre compte rapidement. L'intérêt est d'avoir une procédure à jour pour la compilation et l'installation d'ECS.
Vérifier la correction de la branche de développement
La branche de développement est régulièremet compilée et des tests sont lancés, ce afin de savoir au plus tôt qu'une modification dans le code pose problème.
Faciliter la mise en production
La mise en production prolonge la procédure de vérification de correction de la branche de développement et déploie l'application sur le serveur.
Choix techniques

Nous avons choisi d'utiliser la plateforme d'intégration continue offerte par l'INRIA, puisqu'elle est faite pour ça.

Comme notre application est un serveur web, elle nécessite de pouvoir s'y connecter depuis un navigateur si on veut qu'un humain la teste. Si elle tourne sur un esclave de la plateforme d'intégration, nous ne pouvons pas nous y connecter puisque ces machines ne sont pas directement accessibles1. Par ailleurs, nous voulions une procédure de déploiement simple.

Nous nous sommes donc orientés vers une conteneurisation basée sur docker. Cela nous permettra d'une part de récupérer les images créées par les procédures régulièrement lancées sur notre esclave si nous voulons faire des tests "à la main". D'autre part et surtout, le déploiement consistera à copier un conteneur, obtenu par l'une de ces procédures, sur la machine de production, un serveur en zone démilitarisée2.

Nous avons donc un unique esclave sur la plateforme d'intégration continue, une ubuntu 14.04, sur laquelle nous créons des images et des conteneurs docker.

Un projet sur la plateforme d'intégration continue dispose d'une instance de Jenkins, censée faciliter l'automatisation des tâches à lancer régulièrement. Malheureusement, Jenkins est assez lourd d'utilisation (déconnexion au bout de trois battements de paupières d'inactivité, interface peu intuitive) et notre esclave n'était pas visible, pour une raison inconnue; un redémarrage de Jenkins n'y faisait rien.

L'impression générale a donc rapidement été que Jenkins allait plutôt nous compliquer la vie que nous la simplifier. Nous nous sommes donc rabattus pour l'instant sur une solution simple à base de scripts dont l'exécution est planifiée dans la crontab de notre esclave. De plus, renseignements pris, il était tout à fait possible que nos scripts envoient des messages en parlant directement au serveur SMTP interne, pour nous envoyer des rapports d'exécution. Lorsque nos besoins évolueront (suite de tests plus fournie, davantage de branches de développement, ...), nous réétudierons la possibilité d'utiliser Jenkins.

Images et conteneurs

Pour chaque distribution cible (Ubuntu 14.04 et Debian Jessie pour l'instant), nous avons un Makefile permettant de créer trois images docker. Ces images sont créées à partir d'un fichier Dockerfile, contenant les instructions à lancer pour enrichir une image.

La première image est une image de base de la distribution à laquelle sont ajoutés des paquets nécessaires pour nos développements en général (ssh, autoconf, ...) ainsi que le gestionnaire de paquets OCaml opam.

La création de la seconde image part de la première image pour y ajouter la version désirée d'OCaml et le dépôt opam de l'équipe.

La création de la troisième image part de la seconde pour y installer les bibliothèques OCaml nécessaires à la compilation de nos codes.

Cette façon de créer des images séparées nous permettra par la suite de mutualiser ce qui sera commun à plusieurs images, lorsque nous voudrons par exemple tester avec plusieurs versions d'OCaml ou utiliser la même image avec les dépendances nécessaires pour compiler différents outils de l'équipe. Le Makefile gère les dépendances entre ces images. Voici à quoi ressemble le Makefile en question pour Ubuntu:

DOCKER=docker
PREFIX=specfun-ubuntu-

IMAGES=$(PREFIX)opam \
	$(PREFIX)ocaml-4.02.3 \
	$(PREFIX)ocaml-4.02.3-dynamow-deps

all: images tests

# Images
images: $(IMAGES)

.PHONY: opam ocaml-4.02.3 dynamow-deps

opam: $(PREFIX)opam

$(PREFIX)opam: opam/Dockerfile
	$(DOCKER) build -t $@ opam/

ocaml-4.02.3: $(PREFIX)ocaml-4.02.3

$(PREFIX)ocaml-4.02.3: opam ocaml-4.02.3/Dockerfile
	$(DOCKER) build -t $@ ocaml-4.02.3/

ocaml-4.02.3-dynamow-deps: $(PREFIX)ocaml-4.02.3-dynamow-deps

$(PREFIX)ocaml-4.02.3-dynamow-deps: ocaml-4.02.3 ocaml-4.02.3-dynamow-deps/Dockerfile
	$(DOCKER) build -t $@ ocaml-4.02.3-dynamow-deps/

cleanimages:
	$(DOCKER) rmi $$($(DOCKER) images -a | grep specfun-ubuntu | cut -d" " -f 1)
	$(DOCKER) rmi $$($(DOCKER) images -f "dangling=true" | tail -n +2 | tr -s ' ' | cut  -d' ' -f 3)

cleancontainers:
	$(DOCKER) rm $$($(DOCKER) ps -aq)
Pour que nos conteneurs puissent accéder au réseau, il nous a fallu modifier le fichier /etc/default/docker pour y modifier la ligne suivante:
DOCKER_OPTS="--ip-masq=true --dns 8.8.8.8 --dns 172.21.8.87"
172.21.8.87 est le DNS; on l'obtient avec sudo nm-tool | tail -n 8. Il faut ensuite relancer le service docker avec sudo service docker restart. Plus d'informations sur ce problème ici.
Lancement des tests

Le lancement d'un test de compilation consiste à créer un conteneur docker à partir de la troisième image (celle contenant les bibliothèques OCaml nécessaires) et à exécuter une commande. Pour simplifier nos scripts, nous avons un seul script run_test.sh prenant en argument le nom de l'image et le répertoire où se trouve un fichier test.sh. Le script crée alors un conteneur docker sur cette image en montant le répertoire en question dans /test (option -v de docker run) et avec /test/test.sh comme commande à lancer dans le conteneur.

Par ailleurs, certaines parties de la compilation ou des tests nécessitent des appels à Maple. Ce dernier est donc installé sur notre esclave dans /maple et rendu visible dans le conteneur dans /maple. De plus, la licence de Maple dépend de l'adresse Mac de la machine d'exécution. Heureusement, l'option --mac-address=... de docker run permet de spécifier une adresse MAC pour le conteneur.

Enfin, une petite subtilité. Par défaut, le conteneur créé pour le test est détruit (option --rm) après l'exécution du test. Cependant, nous avons introduit un test sur une variable d'environnement CID (pour "container id"). Si cette variable est définie, le conteneur n'est pas détruit et se voit attribuer le nom contenu dans cette variable. Pour la mise en production, il suffit alors d'exécuter le test de compilation d'ECS et de ne pas supprimer le conteneur, en lui donnant un nom. Ce conteneur sera ensuite copié sur le serveur de production.

Notre script run_test.sh ressemble donc à ça:

#!/bin/bash
DIR=`cd ../$1 ; pwd`
IMAGE=$2

# Maple must be installed in /maple
MACADDRESS=aa:bb:cc:dd:ee:ff # the one given in the Maple license

if [ "${CID}" = "" ]; then\
  export RMOPT="--rm"; \
  export CID=test-${RANDOM}; \
else \
  (docker ps -a --filter=name=${CID} | grep ${CID}) && docker rm ${CID} || echo ; \
fi

docker run ${RMOPT} \
  -v ${DIR}:/test \
  -v /maple:/maple \
  --name ${CID} \
  --mac-address="${MACADDRESS}" \
  ${IMAGE} /test/test.sh

Un script OCaml appelé par cron se charge d'exécuter les différents tests. Les sorties sont mises dans un fichier de journal dont le nom contient la date et l'heure du lancement. Quand une commande échoue, le script récupère les N dernières lignes du journal et passe à la commande suivante. Lorsque tous les tests ont été exécutés, le script envoie un mail récapitulatif. Dans le sujet figurent les nombres de succès et d'échecs. Dans le corps du message on trouve les commandes lancées et, pour celles ayant échoué, les N lignes conservées, ainsi que le nom du fichier de journal complet pour aller voir de plus près.

L'envoi du courriel se fait en utilisant curl:

curl --url 'smtp://smtp.inria.fr:25' \
      --mail-from '...@...' --mail-rcpt '...@...' --upload-file body.txt

avec body.txt un fichier de la forme:

From: ...@...
To: ...@...
Subject: 1 success, 2 failures

bla bla bla
Mise en production

Notre serveur de production est une Debian Jessie en zone démilitarisée. Nos applications y sont exécutées dans des conteneurs docker. Un serveur apache joue le rôle de proxy entre les clients et les conteneurs, selon l'adresse des requêtes. Notre esclave peut se connecter par ssh à notre serveur de production (mais pas le contraire).

Comme évoqué plus haut, la mise en production consiste à, sur notre esclave, créer une image dans laquelle l'application ECS a été compilée. Cette image est obtenue en lançant le test de compilation d'ECS mais sans détruire l'image à la fin du test. Il ne reste ensuite qu'à:

  • sur l'esclave, exporter l'image dans un fichier prod-ecs.tar,
  • importer l'image sur la machine serveur sous le nom prod-ecs.tmp,
  • stopper et supprimer le conteneur sur la machine serveur,
  • sur la machine serveur, renommer l'image prod-ecs.tmp en prod-ecs,
  • copier sur le serveur les fichiers nécessaires au lancement de l'application: un fichier de configuration et un script de lancement de docker; ces deux fichiers n'ont pas vocation à être dans le dépôt des outils développés et sont gérés dans notre dépôt dmw-bot contenant les scripts d'intégration continue et de mise en production,
  • lancer sur le serveur le script qui lance notre application dans son conteneur, à partir de l'image prod-ecs.

Par la suite, nous utiliserons la même mécanique afin d'avoir toujours une ou plusieurs versions correspondant aux derniers développements, pour faire des tests.

Le script en question ressemble à ça:

HOST=<notre serveur>
REMOTE=specfun@${HOST}

# create the prod-ecs container with ecs compiled inside
export CID=prod-ecs
(cd ../../docker/debian ; make test-ecs)

# create docker container file
echo "exporting prod-ecs to prod-ecs.tar"
docker export --output="prod-ecs.tar" prod-ecs

echo "importing container to ${REMOTE} (prod-ecs.tmp)"
cat prod-ecs.tar | ssh ${REMOTE} "docker import - prod-ecs.tmp"

echo "stopping and deleting remote prod-ecs container, removing prod-ecs image"
ssh ${REMOTE} "docker stop prod-ecs; docker rm prod-ecs; docker rmi prod-ecs $(docker images -f 'dangling=true' | tail -n +2 | tr -s ' ' | cut  -d' ' -f 3)"

echo "renaming image prod-ecs.tmp to prod-ecs"
ssh ${REMOTE} "docker tag prod-ecs.tmp prod-ecs; docker rmi prod-ecs.tmp"

echo "copying config.json and run-ecs.sh to ${REMOTE}:prod-ecs/"
scp config.json run-ecs.sh ${REMOTE}:prod-ecs/

echo "launching ecs on ${REMOTE}"
ssh ${REMOTE} "sh prod-ecs/run-ecs.sh"

Le lancement du conteneur de notre application doit monter le répertoire où se trouve Maple (installé aussi sur notre serveur) ainsi que le répertoire où est placé le fichier de configuration et dans lequel sera également créé le fichier de journal de l'application, afin qu'il puisse être consulté pendant que le conteneur tourne ou même après son arrêt. Enfin, il est nécessaire de faire correspondre (map) des ports de l'hôte (notre serveur) et du conteneur, avec l'option -p. Ici les ports 10001 et 10002 de notre hôte sont mis en correspondance avec les ports 9080 et 9081 de notre conteneur. Ce qui nous donne:

#!/bin/bash

echo "launching ECS in docker"

# Maple must be installed in /maple
MACADDRESS=aa:bb:cc:dd:ee:ff # the one given in the Maple license

docker run \
  -d \
  -v /home/specfun/prod-ecs:/prod \
  -v /maple:/maple \
  -p 10001:9080 \
  -p 10002:9081 \
  --name prod-ecs \
  --mac-address="${MACADDRESS}" \

Attention, les images docker prennent un peu de place (environ 2 Go pour les nôtres). Il faut donc prévoir suffisamment d'espace disque, notamment dans /var/lib/docker où sont stockées les images.

Proxy web

Le serveur apache est configuré de façon à jouer le rôle de proxy, à la fois pour HTTP et websocket, entre le client et un conteneur pour certaines url.

Attention, notre application éxécutée dans le conteneur écoute les ports 9080 et 9081. Les requêtes HTTP qui viendront seront considérées comme venant non pas de localhost mais du serveur hôte (le proxy apache). Il convient donc de ne pas restreindre le bind à localhost (127.0.0.1, ce que l'on ferait si notre application tournait sur la même machine que le proxy) mais par exemple à toute adresse (0.0.0.0). De toutes façons, seules les requêtes passant par le proxy atteindront notre conteneur.

Dans notre cas, les requêtes arrivant avec des urls de la forme http://ecs.inria.fr/ seront envoyées à http://locahost:10001/ et celles de la forme ws://ecs.inria.fr/ws/ seront envoyées à ws://locahost:10002/3

Dans le fichier de configuration apache, on trouvera donc les directives suivantes dans un <VirtualHost>:

  ProxyPass /ws ws://localhost:10002
  ProxyPassReverse /ws ws://localhost:10002
  ProxyPass / http://localhost:10001/
  ProxyPassReverse / http://localhost:10001/

Enfin, il convient d'activer les modules proxy_wstunnel et proxy_http (et redémarrer apache bien sûr):

sudo a2enmod proxy_wstunnel
sudo a2enmod proxy_http
sudo service apache2 restart
Remerciements

Merci à Vincent Rouvreau, Marc Fuentes et Mathieu Dorbe pour leur aide.


1 Sauf à faire des cabrioles avec des tunnels SSH, mais le but est d'avoir quelque chose de simple.
2 Le serveur est en zone démilitarisée car le proxy du centre ne permet pas encore d'agir aussi comme proxy pour les websockets.
3 Les websockets utilisent le protocole HTTP pour établir une connexion qui est ensuite modifiée (upgraded) pour communiquer avec le protocole de websocket. Il est donc possible de n'avoir qu'un seul port et de passer en websocket selon l'url demandée. La bibliothèque OCaml utilisée côté serveur pour les websockets ne permettait pas, jusqu'à récemment, une telle mise à jour selon l'url demandée. C'est maintenant possible mais nous n'avons pas encore utilisé cette possibilité.

Mots-clés: