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.
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 à:
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.
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)
/etc/default/docker
pour
y modifier la ligne suivante:
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.
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.
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'à:
prod-ecs.tar
,prod-ecs.tmp
,prod-ecs.tmp
en prod-ecs
,dmw-bot
contenant les scripts d'intégration
continue et de mise en production,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.
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):