Patrice Ferlet
Patrice Ferlet
Créateur de ce blog.
Publié le juin 14, 2015 Temps de lecture: 13 min

Me suivre sur Mastodon

Rendre homogène une équipe de dev avec docker et docker-compose

Quand on veut bosser avec Docker sur des projets plus ou moins complexes, il existe un outil qui permet de vous soulager de pas mal de contraintes: le bien nommé “docker-compose” anciennement nommé “fig”. Bien plus qu’un “makefile pour docker” il peut entrer dans votre projet de développement en équipe.

Docker a vraiment un potentiel plus qu’intéressant. En ce qui concerne son intégration dans les système de “cloud”, pour déployer des services ou encore pour tester une installation ponctuellement c’est déjà un outil merveilleux. Mais j’ai l’impression que son utilisation dans un pole de développement n’est pas encore très répandu. Peut-être parce que beaucoup (trop) d’équipes de développement travaillent encore sur Windows et que Docker n’est pas encore un produit prêt à l’emploi sur cet OS (quoique maintenant… il existe Docker Toolbox).

En ce qui me concerne, je travaille avec une équipe qui a:

  • soit un pc sur Linux
  • soit une VM pour lancer les services

Du coup, on a put utiliser Docker dans nos projets.

La problématique du prérequi

C’est tout bête. Nous devons travailler par exemple sur une application AngularJS qui interroge une API REST. Il nous faut NodeJS pour installer des outils tels que gulp, karma, protractor, des plugins gulp, bower, etc… Nous devions donc installer tout ça avec les bonnes versions puis démarrer chacun de notre coté le projet (phase d’init d’installation).

Imaginez un peu, il faut installer “node” dans la version demandée, donc utiliser “nvm” - puis toucher les ~/.bashrc, ensuite installer “globalement” des outils tels que “bower”, ou “gulp” - ce qui du coup impose une écriture avec les droits administrateurs. Vous allez me dire que ça se fait en 3 ou 4 commandes mais ce n’est pas si évident en pratique.

En clair, on passait beaucoup de temps à initialiser le projet sans compter les soucis à régler sur certains postes, entre les soucis de version et la capacité des ingénieurs à installer les prérequis (sans compter que certains développeurs ne sont pas forcément à l’aise avec Linux).

Autre problème, on passait souvent d’un projet à l’autre, parfois 3 à 4 fois dans la journée. Les projets n’ont pas forcément les mêmes prérequis. Le pire étant qu’une installation globale va masquer une dépendance manquante dans un autre projet, et on se pose pas mal de questions quand Jenkins nous balance une erreur sur des tests qui passent super bien sur nos postes.

Donc, en bref, c’est pas si évident d’installer et d’isoler les développements sur un poste. Mais quand j’entend “isolement”, je ne peux m’empêcher de penser “virtualisation”, “para-virtualisation” et “Docker”. Et pour cause.

Docker va nous abstraire de ces soucis, surtout en l’alliant à Docker-Compose. Tout va être géré via cet outil.

L’image Docker - la base

On utilise notre exemple: un dev AngularJS utilisant bower et gulp.

On part du principe que, par défaut, on utilise la cible “gulp serve” qui va lancer un service http pour voir notre projet angular tourner. On a produit un Dockerfile tout simple qui suffit à notre démarrage de projet (dans le monde réel, il est un peu plus fourni)

FROM node:10.3
MAINTAINER Patrice FERLET <metal3d@...>

VOLUMES ["/project"]

RUN npm install -g bower karma gulp

WORKDIR /project
CMD ["gulp","serve"]

Du coup, on peut “builder” l’image et la fournir aux développeurs… Là encore, trois solutions:

  • on fourni l’image buildée sur un serveur
  • on utilise un registry privé
  • chaque dev build l’image

Le premier point est lourd, le développeur qui gère la création du Dockerfile va devoir builder l’image puis la pousser sur un serveur. Si la connexion est lente, que le serveur est loin ou que quelqu’un met le pied sur le câble LAN, vous imaginez l’angoisse. Bref..

Le second est intelligent et rapide, mais… chaque poste doit avoir le certificat du registre installé, il faut gérer le registre, penser à sauvegarder, etc. Donc du boulot en trop.

Le troisième point est sympa, on pourrait donc fournir notre fichier dans le dépot du projet. Mais le souci c’est que les options de démarrage d’un conteneur ne sont pas forcément faciles à trouver - on documenterai autant qu’on veut, on aurait toujours un membre de l’équipe pour nous péter à la gueule un “ça marche pas ça m’énerve”.

Et c’est là que “docker-compose” entrera en jeu. Mais voyons d’abord ce qu’il se passe sans cet outil.

Sans docker-compose, c’est un peu plus compliqué

Sans docker-compose, un membre de l’équipe de développement va devoir builder l’image et lancer l’init dans le bon ordre.

# En premier lieu
docker build -t jeanjacques/imageprojet docker/.

# Ensuite il va intialiser npm et bower via un conteneur temporaire
docker run -it --rm -v /rep/vers/projet jeanjaques/imageprojet npm install
docker run -it --rm -v /rep/vers/projet jeanjaques/imageprojet bower install

# et enfin créer un conteneur qui lance, par défaut, "gulp serve"
docker run -it --name projet1 -v /rep/vers/projet jeanjaques/imageprojet

Le développeur coupe le conteneur, et le lendemain il veut relancer les tests (comme tous les jours):

docker run -it --rm -v /rep/vers/projet jeanjaques/imageprojet gulp test

Vous le voyez, plein de commandes compliquées avec pas mal d’options, il faut qu’il pense à réutiliser l’image de base, et comprendre les mécanismes de docker. En clair, le confort est pas si évident.

Au pire, on fournirai un “Makefile” qui fait tout ça, mais justement il existe un autre outil spécialisé pour docker qui ressemble à un makefile, c’est ce fameux “docker-compose” que je vous rabache depuis le début du billet.

Et Docker-compose arrive sur son cheval blanc

Le prérequis, demander à l’équipe d’installer “docker-compose” en suivant cette page de doc - c’est relativement rapide et simple.

Docker-compose va donc être bien plus pratique car il évite au développeur de réfléchir à “Docker en lui même”. Il ne va finalement que préfixer les commandes habituelles d’un projet “node - gulp” par “docker-compose run [nom du projet]” ou utiliser “docker-compose up”. Voyez plutôt (le chien de Mickey):

Voici un exemple de fichier docker-compose.yml:

site:
    build: ./docker/
    ports:
    - "3000:3000"
    volumes:
    - ./:/project

Je précise, “build” donne le répertoire où se trouve le Dockerfile pour builder localement l’image, et “volumes” permet ici de monter le répertoire courrant dans un répertoire définis.

Vous avouerez que c’est pas si long à taper hein. Voyons comment un développeur va démarrer son projet:

# initialise le projet
docker-compose run --rm site npm install 
docker-compose run --rm site bower install 

# pour lancer le "gulp serve" de base
docker-compose up


# pour lancer les tests
docker-compose run --rm site gulp test

La première fois que vous lancez une commande docker-compose, l’image se build. Si une mise à jour est nécessaire, il suffit de faire:

docker-compose build

C’est incroyablement simple, facile à comprendre et ça ne demande pas trop de configuration.

En gros, dans nos projets, nous avons au minimum:

  • un ficher docker-compose.yml
  • un répertoire docker avec le Dockerfile et les dépendances (script entrypoint, des truc à déposer si besoin dans l’image…)

Comme tout est versionné en tant que fichier texte (pour le yaml et le Dockerfile), c’est vraiment pratique à gérer.

Et le pompon sur la cerise: Jenkins peut utiliser la commande docker-compose pour lancer les tests lui-même. Si ça c’est pas géant !

Les soucis à régler

Il y a des soucis à régler. Ici je vous ai donné un Docker file et un docker-compose.yml très simples. Or il ne faut pas oublier que Docker va exécuter les commandes en “root”. Et lors de la génération de fichiers, sur votre répertoire de travail, ce sera bien “root” qui est utilisé. “Bower” va vous engueuler, et vous allez vous battre avec les droits localement sur votre poste. Je vais vous donner quelques trucs qui permettent de ne pas avoir ce genre de soucis.

Souci numéro 1: mapper les UID et GID à l’utilisateur hôte

[EDIT] Cette solution de création d’utilisateur + entrypoint qui fait un “sudo” est valable pour les anciennes version de de docker et docker-compose. Merci de prendre en compte la solution qui utilise “user” dans le fichier yaml.

Solution pour les nouvelles versions

Depuis peu, il est possible de forcer les identifiants utilisateur d’un compte qui se trouve dans le conteneur.

Par exemple:

docker run -it --rm --user 1000:1000 busybox

Cela induit que l’utilisateur qui va lancer le premier processus dans le conteneur écrira avec les identifiant user:group à 1000:1000. Dans “compose”, il suffit d’utiliser la directive “user”:

site:
    build: ./docker/
    ports:
    - "3000:3000"
    volumes:
    - ./:/project
    user: "1000:1000"

Vous allez me dire “ouais… et si nos développeurs ont pas le même uid sur leurs postes”, ne vous inquiétez pas j’ai une solution.

Docker-compose a désormais la capacité de faire de l’héritage de définitions.

L’idée et d’utiliser cette capacité en générant un fichier “de base” qui va être dynamiquement renseigné. On va y placer les uid et gid de l’utilisateur en cours (le développeur) avec un Makefile.

On commence par un “POC”, on crée un fichier “.docker-base.yaml” (je mets un “.” devant le nom pour faire un fichier caché):

mother:
    user: "1000:1000"
    environment:
        USER: root

Pour être clair, je laisse “$USER” à “root” pour garder le nom d’utilisateur malgré le changement de “uid” et “gid”. Sans cela, vous aurez un message “I have no name” dans le prompt shell… c’est juste gênant à la lecture, pas à l’utilisation. Bref…

Il arrive aussi parfois que j’ai besoin d’assigner la variable “HOME”, car en changean les uid:gid et le nom d’utilisateur, certains services ne retrouvent plus le chemin du dossier utilisateur. Donc je le place dans “/tmp” ou dans le volume par défaut, au choix et selon le projet.

Dans mon yaml de service maintenant, je vais hériter de ce fichier et utiliser “mother”:

site:
    build: ./docker/
    ports:
    - "3000:3000"
    volumes:
    - ./:/project
    extends:
        file: .docker-base.yaml
        service: mother

Désormais, le service “site” hérite de “mother” depuis le fichier “.docker-base.yaml”.

Si vous lancez “docker-compose up” et bien désormais, c’est un utilisateur “root” ayant les uid et gid à 1000 qui va lancer les processus (gulp, etc.) Pratique n’est-ce pas ?

Bon, et bien on va utiliser un makefile qui va générer ce fichier “de base” et lancer les services:

UID:=$(shell id -u)
GID:=$(shell id -g)
CC:=docker-compose

serve: _docker-base
    $(CC) run --rm gulp run

init: _docker-base
    $(CC) run --rm npm install
    $(CC) run --rm bower install

test: _docker-base
    $(CC) gulp test
    
_docker-base:
    @echo "mother:" > .docker-base.yaml
    @echo "    user: $(UID):$(GID)" >> .docker-base.yaml
    @echo "    environment:" >> .docker-base.yaml
    @echo "        USER: root" >> .docker-base.yaml

Je vous explique, ne paniquez pas.

On commence par la dernière directive de Makefile, “_docker-base”. Cette directive va créer un fichier “.docker-base.yaml” qui contient notre service de base, le service “mother”. Dans ce service, on défini l’environnement et les identifiants à utiliser qui correspondent à $UID et $GID (définis dans l’entête du Makefile).

J’ai ensuite 3 directives: serve, init et test. Ces trois directives dépendent de “_docker-base”. Donc chaque fois que je vais lancer une tâche “make”, le fichier sera généré avec les bons UID et GID, et docker-compose exécutera mes conteneurs en mappant tout ça bien gentillement.

Cette solution marche à condition que vos postes aient bien des versions récentes de docker et docker-compose. Sans cela, ça ne marchera pas.

Solution pour les anciennes versions

Pour éviter que tout soit écrit par root, on va demander à Docker d’utiliser un compte utilisateur local. On va l’appeler “dev”.

L’idée est de créer un utilisateur dans le conteneur qui ait les mêmes IDs que celui du compte développeur sur l’hôte. Par défaut, avec un peu de bol, vous avez un UID et GID vallant “1000”. Donc je modifie le Dockerfile:

FROM node:10.3
MAINTAINER Patrice FERLET <metal3d@...>

VOLUMES ["/project"]

RUN npm install -g bower karma gulp

# création d'un utilisateur normal
RUN useradd -u 1000 -g 1000 dev

USER dev
WORKDIR /project
CMD ["gulp","serve"]

Mais ça peut ne pas suffir… malheureusement.

Mon PC a deux comptes, et celui que j’utilise pour développer a un UID et GUID “1002”, ce qui pose un souci. Pour résoudre ça, j’ai ajouté un “entrypoint” au conteneur (dans le répertoire docker):

#!/bin/bash

# force le changement d'id utilisateur
if [ ! -z $_UID ] && [ $_UID != "1000" ]; then
    su -c 'usermod -u $_UID dev'
fi

# change l'id de groupe de l'utilisateur
if [ ! -z $_GID ] && [ $_GID != "1000" ]; then
    su -c 'groupmod -g $_GID dev'
fi

exec $@

Et par conséquent, le Dockerfile:

FROM node:10.3
MAINTAINER Patrice FERLET <metal3d@...>

VOLUMES ["/project"]

RUN npm install -g bower karma gulp

# création d'un utilisateur normal
RUN useradd -u 1000 -g 1000 dev

# je supprime le mot de passe root pour que
# la commande "su" ne bloque pas
RUN usermod -p "" root

ADD entrypoint.sh /entrypoint.sh

USER dev
WORKDIR /project
ENTRYPOINT ["sh", "/entrypoint.sh"]
CMD ["gulp","serve"]

EDIT: j’ai ajouté la solution Makefile

Ensuite deux solutions, soit un script qui lance docker-compose avec les variables d’environnement:

#!/bin/bash
# fichier: ./start.sh

UID=$(id -u)
GID=$(id -g)

action=$1
shift

docker-compose $action -e _UID=${UID} -e _GID=${GID} $@

Et donc, pour lancer mes commandes:

./start.sh up
./start.sh run gulp serve
./start.sh run gulp test

Soit passer par un Makefile, c’est encore plus joli:

UID:=$(shell id -u)
GID:=$(shell id -g)
CC:=docker-compose

serve:
    $(CC) run -e _UID=$(UID) -e _GID=$(GID) gulp run

init:
    $(CC) run -e _UID=$(UID) -e _GID=$(GID) npm install
    $(CC) run -e _UID=$(UID) -e _GID=$(GID) bower install

test:
    $(CC) run -e _UID=$(UID) -e _GID=$(GID) gulp test

Ce qui donne à l’utilisateur:

  • make ou make serve -> lance le serveur
  • make init -> initialise les dépendances npm et bower
  • make test -> qui lance les tests…

A vous de faire votre sauce…

Mais pour résumer: à chaque fois, l’id et le gid sont passés à la commande docker-compose, l’entrypoint vérifie sur l’id et le gid sont présents et différent de l’id de base, et le change au besoin. C’est rapide et ça fait gagner du temps.

Souci numéro 2: l’utilisation de git et SSH

Il se peut que vous ayez besoin de git dans votre conteneur, par exemple nous avons deux projets angular dont un doit récupérer le build de l’autre via bower. Dans le fichier “bower.json”, nous avons une url de dépendance sous la forme “git”, et ce dépot est privé. Or, pour s’y connecter et permettre la récupération de code, il faut un compte SSH valide.

Il est inconçevable de devoir créer une clef à chaque conteneur et la poser dans notre compte github, du coup, voilà comment procéder.

Il suffit, bêtement, de “monter ~/.ssh en volume”, donc dans le fichier “docker-compose.yml”:

site:
    build: ./docker/
    ports:
    - "3000:3000"
    volumes:
    - ./:/project
    - ~/.ssh:/home/dev/.ssh:ro

Le “:ro” final dit clairement de le monter en mode “read only” (lecture seule) pour éviter un malencontreux “pétage de répertoire” qui va vous rendre dingue. Autant dire que le risque serait minime, mais on bosse sérieusement et on préfère faire attention.

Du coup, l’appel à “bower install” utilise la clef SSH du poste client et tout se passe bien.

En bref

En clair, on a commencé, chez mon client, à travailler de cette manière. Le résultat est que nous ne passons plus des plombes à installer base de développpement, à nous battre avec les versions des outils, tout le monde a, quelque soit sa distribution, un services standard pour exécuter les commandes. On appelle ça “l’homogènéité” :)

Nous avons par exemple créé des conteneurs pour:

  • Python Google App Engine
  • Gulp + protractor + karma + node + etc.

Récupérer le code depuis GitHub et lancer la commande “docker-compose up” est la seule chose à faire pour démarrer les projets. Certains projets ont un makefile partagé avec le conteneur - les cibles permettant de faire des packages, des builds, de simplifier des appels. Par exemple, de faire: docker-compose run site make init qui lance tout la phase de préparation (npm install, bower clean cache, bower install, etc.)

comments powered by Disqus