Les pipelines Jenkins sont de plus en plus utilisés, et à juste titre : ils remplacent les jobs de type « freestyle », qui ne permettaient pas beaucoup de liberté et qui étaient constitués d’un enchaînement de « pre-build steps » et de « post-build steps » avec bien souvent de nombreuses lignes Bash entre les deux. De plus, dans de nombreuses intégrations continues on trouvait souvent plusieurs jobs chaînés pour construire un seul projet (dans le but d’avoir quelque chose de plus flexible).

Les Pipelines offrent une multitude d’avantages par rapport à un job freestyle : ils sont lisibles, versionnés, flexibles, réutilisables (via les librairies partagées, ou « shared libraries ») et se trouvent (en général) avec le code source de votre projet.

Cependant, un des défauts des Pipelines vient de leur processus de développement : ils sont difficilement testables et peuvent vite être chronophage si votre environnement de développement n’est pas approprié.

Dans cet article nous allons voir comment développer localement et sereinement un Pipeline Jenkins.

Pourquoi développer localement ?

Lorsque l’on débute le développement de Pipelines Jenkins, bien souvent, on se sert du Jenkins déjà à notre disposition. C’est à dire un Jenkins opérationnel et utilisé en production.

Tous les développeurs n’ont pas forcément les connaissances ou les ressources nécessaires pour disposer d’une machine avec un Jenkins dédié à notre utilisation.

Le workflow de développement pourrait se présenter comme suit :

  1. Dupliquer un projet actuel (les Pipelines étant versionnés avec le projet nous n’allons pas modifier l’historique d’un projet uniquement à des fins de test) ;
  2. Créer un nouveau repository pour ce projet ;
  3. Créer un nouveau job sur le Jenkins de production ;
  4. Développer une version basique du Pipeline ;
  5. Pousser un nouveau commit contenant le Pipeline ;
  6. Exécuter le job ;
  7. Constater que ça ne fonctionne pas ;
  8. Corriger le ou les problème(s) ;
  9. Pousser un nouveau commit contenant les modifications ;
  10. Exécuter le job ;
  11. Constater que ça ne fonctionne pas ;
  12. Corriger le ou les problème(s) ;

Vous vous rendez vite compte de la pénibilité de la chose.

Pour accélérer le cycle de développement il est possible d’utiliser la fonction « replay » qui permet de modifier directement le code du Pipeline dans Jenkins. Néanmoins cette fonctionnalité n’est disponible que pour les jobs de Pipelines simples (pas de multi-branches Pipelines donc) et n’est pas toujours utilisable (si une grosse erreur s’est produite vous ne pourrez pas rejouer le Pipeline).

Le replay offre un gain de temps appréciable mais il reste toujours les questions de l’utilisation d’un Jenkins de production (avec des files d’attentes, l’impossibilité d’ajouter des plugins en fonction de ses droits, etc.) et le temps passé avec les commits.

Configuration de base

Un Jenkins local

La façon la plus simple et rapide d’avoir un Jenkins local est d’utiliser Docker. Le compte officiel Jenkins sur Docker Hub met à disposition de nombreuses images dont un Jenkins complètement opérationnel.

Nous allons tout de suite passer par Docker Compose pour utiliser cette image afin d’avoir une configuration plus souple et versionnable :

version: '3'
services:
  jenkins:
    image: jenkins/jenkins:lts
    ports:
      - 8080:8080
    volumes:
      - .docker/jenkins_data:/var/jenkins_home
      - ${PROJECT_VOLUME:-.}:/home/project

Deux remarques par rapport à cette configuration :

  1. Le premier volume sert à persister les données de Jenkins, exposées dans l’image sur /var/jenkins_home (le répertoire .docker/jenkins_data est ignoré par Git) ;
  2. L’utilisation d’une variable d’environnement qui pointe vers le projet cible. Si la variable d’environnement n’est pas présente, la valeur par défaut sera le répertoire courant (cf. https://docs.docker.com/compose/compose-file/#variable-substitution). Cela permet d’avoir un Jenkins générique et non un Jenkins propre à un projet.

La commande pour exécuter Docker Compose se présente comme suit :

$ PROJECT_VOLUME=/path/to/my/project docker-compose up

Jenkins est alors accessible localement sur le port 8080.

Un repository Git local

Maintenant que nous avons un Jenkins tournant localement, occupons nous du problème du dépôt Git. Sans passer par une plate-forme en ligne telle que Github ou Gitlab il est tout à fait possible d’avoir un repository Git local. Cela accélère les modifications de nos pipelines car nous éliminons les latences réseau et cela nous évite également l’étape de création d’un nouveau projet (et aussi la possibilité que nous oublions de supprimer ce projet test une fois le développement terminé).

Rien de compliqué : dans le projet dont nous construisons l’intégration continue, supprimer le répertoire .git actuel puis :

$ git init

Dans la page de configuration du job Jenkins, à la place d’une URL Git nous y renseignons un chemin, en l’occurrence /home/project (cf. configuration du docker-compose.yaml un peu plus haut). Pour plus d’informations sur les URLs possibles de git clone la documentation officielle est claire et compréhensible : https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a.

Après ce commit initial votre workflow Git pourrait très bien ressembler au suivant :

$ git add Jenkinsfile
$ git commit -v --no-edit --amend

Si vous utilisez OhMyZsh! et le plugin Git c’est encore plus rapide :

$ ga Jenkinsfile
$ gcn!

Vous remarquerez qu’on ne pousse rien vers un repository distant, tout reste en local.

Pour des intégrations continues basiques ces quelques étapes pourraient suffire, mais c’est rarement le cas. Il est souvent nécessaire d’installer des outils de build propres au projet tels que Node ou PHP.

Une intégration continue avec Docker

Il y a deux philosophies pour installer les dépendances requises :

  • Les installer directement sur la machine du Jenkins ;
  • Utiliser Docker (Compose) pour construire le projet.

La première approche est la plus simple et la plus rapide mais a plusieurs inconvénients : il faut maintenir les paquets à jour (et mettre à jour certains paquets pourrait casser le build des projets existants), en fonction des technologies utilisées il est parfois compliqué de disposer de plusieurs versions en parallèle (PHP par exemple), la gestion des droits avec Jenkins est par moment difficile…

L’utilisation de Docker quant à elle nous évite ces inconvénients, le Jenkins est agnostique et permet de construire n’importe quel type de projet, il suffit « juste » d’installer et de configurer Docker. Elle n’est pas non plus dépourvue de désavantages puisque vous devrez passer du temps à écrire un fichier docker-compose.yaml et très probablement un Dockerfile.

Si le mot « juste » se retrouve entre guillemets ce n’est pas un hasard. Exécuter Docker sur un système complet est facile mais pour rappel notre Jenkins local tourne déjà dans Docker. Exécuter Docker dans Docker n’est pas évident la première fois.

Le but de cet article n’est pas de rentrer dans les détails explicatifs du « pourquoi c’est compliqué » et potentiellement « pourquoi c’est mal » (c’est pour du développement local donc pas très grave). Si vous voulez en savoir plus je vous conseille cet article : Using Docker-in-Docker for your CI or testing environment? Think twice.

Bien que l’article préconise le partage du socket Docker entre votre machine hôte et le conteneur Docker ce n’est pas ce que nous suivrons. La problématique des chemins des volumes s’avère pénible et nécessite de faire des adaptations spécifiques aux volumes déclarés (le socket étant celui de votre hôte tous les volumes montés par le conteneur Jenkins doivent être relatifs à votre machine et non pas au conteneur), chose que nous ne voulons pas. L’idée est de développer localement, puis de porter notre développement sur un système complet sans devoir à nouveau apporter des modifications.

Nous allons utiliser une image officielle, nommée « Dind » pour Docker in Docker :

version: '3'
services:
  dind:
    image: docker:dind
    privileged: true
    expose:
      - 2375
      - 2376
    volumes:
      - .docker/jenkins_data:/var/jenkins_home
  jenkins:
    image: jenkins/jenkins:lts
    environment:
      DOCKER_HOST: tcp://dind:2375
    ports:
      - 8080:8080
    links:
      - dind
    volumes:
      - .docker/jenkins_data:/var/jenkins_home
      - ${PROJECT_VOLUME:-.}:/home/project

Deux détails qui ont leur importance :

Il faut également installer Docker Compose sur le conteneur de Jenkins :

FROM jenkins/jenkins:lts

LABEL maintainer="Antoine Descamps <antoine.descamps@ineat-conseil.fr>"

USER root

RUN echo "deb http://ppa.launchpad.net/ansible/ansible/ubuntu trusty main" >> /etc/apt/sources.list
    && apt-key adv --keyserver https://keyserver.ubuntu.com:443 --recv-keys 93C4A3FD7BB9C367

RUN apt-get update

RUN curl -fsSL https://get.docker.com | bash - \
    && curl -L "https://github.com/docker/compose/releases/download/1.22.0/docker-compose-Linux-x86_64" -o /usr/local/bin/docker-compose \
    && chmod +x /usr/local/bin/docker-compose

RUN usermod -aG docker jenkins

USER jenkins

Nous avons maintenant une installation locale complète permettant de construire n’importe quel type de projet du moment que celui-ci dispose d’une configuration Docker.

Les noeuds Jenkins

Si le Jenkins cible (de production donc) est configuré en mode « master + agent » vous ne pourrez pas porter tel quel votre Pipeline puisqu’il faudra sélectionner le ou les nœud(s) sur le(s)quel(s) exécuter le script.

Un agent n’est rien d’autre qu’une nouvelle instance de Jenkins : ce qui, dans notre cas, se traduit par un nouveau service Docker Compose.

version: '3'

services:
  dind:
    image: docker:dind
    privileged: true
    expose:
      - 2375
      - 2376
    volumes:
      - .docker/jenkins_data:/var/jenkins_home
  jenkins:
    build: .docker
    environment:
      DOCKER_HOST: tcp://dind:2375
    ports:
      - 8080:8080
      - 50000:50000
    links:
      - dind
    volumes:
      - .docker/jenkins_data:/var/jenkins_home
      - ${PROJECT_VOLUME:-.}:/home/project
  node_php:
    image: jenkins/jnlp-slave
    environment:
      JENKINS_URL: http://jenkins:8080
      JENKINS_SECRET: 283b1438bde93ac95f6c8f6ae8b2e94342a1c00bafcddbb8c721412d1b435036
      JENKINS_AGENT_NAME: Node_PHP
      JENKINS_AGENT_WORKDIR: /home/jenkins
    restart: on-failure
    links:
      - jenkins
    depends_on:
      - jenkins

Peu de configuration supplémentaire grâce à la présence sur Docker hub d’une image slave de Jenkins, mais encore une fois deux points d’attention :

  • Le ou les agents doivent démarrer après Jenkins ;
  • Même en gérant les dépendances entre les services l’agent démarre plus vite que le master, d’où le paramètre restart: on-failure.

Pour information la valeur de JENKINS_SECRET est obtenue une fois le nœud déclaré dans l’interface de Jenkins, ne le copiez pas à partir de cet article.

Conclusion

Dans cet article nous avons vu, une fois les subtilités de configuration passées, qu’il était simple et rapide de monter un environnement local Jenkins correspondant à l’état de votre production. Grâce à Docker et Docker Compose il nous aura fallu moins d’une centaine de lignes pour profiter d’un gain de temps et d’une facilité de développement accrus.