◀ Retour au blog
DevOps

Configuration de pipelines Jenkins

Publié le 15 Jun 2024· 8 min de lecture
#Jenkins#Pipeline#CI/CD

Jenkins Pipeline : automatisation avancée

Jenkins reste une solution CI/CD populaire pour les organisations qui nécessitent un contrôle total sur leur infrastructure de build. Là où GitHub Actions ou GitLab CI imposent leur plateforme, Jenkins s'installe sur vos propres serveurs, se connecte à n'importe quel dépôt Git et s'étend grâce à un écosystème de plugins très large. Cette liberté a un prix : c'est à vous de maintenir le contrôleur, les agents et les plugins à jour.

Depuis Jenkins 2, la bonne façon de décrire un build n'est plus de cliquer dans l'interface, mais d'écrire un Jenkinsfile versionné à la racine du projet. Le pipeline évolue alors avec le code : une branche peut modifier sa propre chaîne de build, et chaque changement passe en revue comme n'importe quelle autre modification.

Déclaratif ou scripté ?

Jenkins propose deux syntaxes. Le pipeline scripté est du Groovy libre : puissant, mais difficile à relire et à valider. Le pipeline déclaratif impose une structure (pipeline, agent, stages, post) qui est vérifiée avant l'exécution. Pour une application PHP classique, le déclaratif couvre tous les besoins ; quand une logique complexe devient nécessaire, un bloc script { } ou une bibliothèque partagée permet d'en sortir ponctuellement.

Pipeline déclaratif

Voici un pipeline complet pour un projet Symfony : installation des dépendances, analyse statique et tests en parallèle, puis déploiement depuis la branche principale.

pipeline {
    agent {
        dockerfile {
            filename 'Dockerfile.ci'
        }
    }

    environment {
        APP_ENV = 'test'
        DATABASE_URL = credentials('database-url')
    }

    stages {
        stage('Install') {
            steps {
                sh 'composer install --prefer-dist'
            }
        }

        stage('Quality') {
            parallel {
                stage('PHPStan') {
                    steps {
                        sh 'vendor/bin/phpstan analyse src'
                    }
                }
                stage('CS Fixer') {
                    steps {
                        sh 'vendor/bin/php-cs-fixer fix --dry-run --diff'
                    }
                }
            }
        }

        stage('Test') {
            steps {
                sh 'php bin/phpunit --log-junit results.xml'
            }
            post {
                always {
                    junit 'results.xml'
                }
            }
        }

        stage('Deploy') {
            when {
                branch 'main'
            }
            steps {
                sh './deploy.sh production'
            }
        }
    }

    post {
        failure {
            slackSend channel: '#ci', message: "Build FAILED: ${env.JOB_NAME}"
        }
    }
}

Lecture du pipeline, bloc par bloc

  • agent { dockerfile { … } } : chaque build s'exécute dans un conteneur jetable créé à partir de l'image décrite dans le Dockerfile.ci du projet (détaillé plus bas). Le plugin Docker Pipeline est requis, et l'agent doit disposer de Docker.
  • environment : définit les variables visibles par toutes les étapes. credentials('database-url') récupère un secret stocké dans Jenkins ; pour un identifiant de type « Secret text », la variable contient directement la valeur, et Jenkins la masque dans les logs.
  • parallel : PHPStan et PHP-CS-Fixer ne dépendent pas l'un de l'autre, ils tournent donc en même temps. Le stage parent échoue si l'un des deux échoue.
  • junit : placé dans un post { always { } }, il publie le rapport de tests même quand des tests échouent. Jenkins affiche alors l'historique et les tests instables.
  • when { branch 'main' } : cette condition ne fonctionne que dans un job Multibranch Pipeline, où Jenkins connaît le nom de la branche. Dans un job Pipeline simple, le stage serait toujours ignoré.
  • post { failure { } } : la notification Slack ne part qu'en cas d'échec. slackSend est fourni par le plugin Slack Notification.

Une image de build avec Composer

L'image officielle php:8.3-cli ne contient ni Composer, ni git, ni l'extension zip. C'est pourquoi le pipeline s'appuie sur un Dockerfile.ci versionné avec le projet, qui ajoute ce dont le build a besoin :

FROM php:8.3-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends git unzip libzip-dev libicu-dev openssh-client \
    && docker-php-ext-install zip intl pdo_mysql \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

Avec dockerfile { filename 'Dockerfile.ci' }, Jenkins construit l'image, la met en cache sur l'agent et lance le build dedans. L'environnement de CI est ainsi décrit dans le dépôt, et non dans la configuration d'une machine.

Un pipeline plus robuste

Le premier exemple fonctionne, mais il manque plusieurs garde-fous qu'on ajoute systématiquement en production : une durée maximale, l'interdiction des builds concurrents sur la même branche, la rotation de l'historique, l'archivage des artefacts et une validation humaine avant la mise en production.

pipeline {
    agent {
        dockerfile {
            filename 'Dockerfile.ci'
        }
    }

    options {
        timeout(time: 30, unit: 'MINUTES')
        disableConcurrentBuilds()
        buildDiscarder(logRotator(numToKeepStr: '20'))
        timestamps()
    }

    environment {
        APP_ENV = 'test'
        COMPOSER_HOME = "${env.WORKSPACE}/.composer"
    }

    stages {
        stage('Install') {
            steps {
                sh 'composer install --prefer-dist --no-progress --no-interaction'
            }
        }

        stage('Package') {
            steps {
                sh 'mkdir -p dist && tar --exclude=./dist --exclude=./.git -czf dist/app.tar.gz .'
                archiveArtifacts artifacts: 'dist/app.tar.gz', fingerprint: true
            }
        }

        stage('Deploy production') {
            when {
                branch 'main'
                beforeInput true
            }
            input {
                message 'Déployer en production ?'
                ok 'Déployer'
                submitter 'release-managers'
            }
            steps {
                sshagent(credentials: ['deploy-ssh-key']) {
                    sh './deploy.sh production'
                }
            }
        }
    }

    post {
        always {
            cleanWs()
        }
    }
}

Quelques explications :

  • timeout interrompt un build bloqué (test qui attend un service indisponible, par exemple) au lieu de monopoliser un exécuteur pendant des heures.
  • disableConcurrentBuilds() évite que deux déploiements de la même branche se chevauchent.
  • COMPOSER_HOME pointe vers le workspace : Jenkins lance le conteneur avec l'UID de l'agent, dont le répertoire personnel n'est souvent pas accessible en écriture dans l'image.
  • input met le pipeline en pause jusqu'à validation par un membre du groupe release-managers. Avec beforeInput true, la condition de branche est évaluée avant la demande : les autres branches ne sont jamais bloquées.
  • sshagent (plugin SSH Agent) charge la clé de déploiement uniquement le temps de l'étape, sans jamais l'écrire dans le workspace.
  • cleanWs() (plugin Workspace Cleanup) supprime le workspace à la fin, pour que le build suivant reparte d'un état propre.

Pièges fréquents

  • Interpolation des secrets : écrire sh "mysql -p${DB_PASSWORD}" avec des guillemets doubles fait interpoler le secret par Groovy avant l'exécution, et Jenkins affiche un avertissement de sécurité. Utilisez des guillemets simples, sh 'mysql -p"$DB_PASSWORD"', pour laisser le shell lire la variable d'environnement.
  • Agent bloqué pendant un input : avec un agent global, l'exécuteur reste réservé pendant l'attente de validation, et le timeout global continue de s'écouler. Pour de longues attentes, déclarez agent none au niveau du pipeline et un agent par stage.
  • Plugins non maintenus : chaque plugin est une dépendance. Limitez-vous à ceux dont vous avez besoin et mettez-les à jour régulièrement, car ils sont une source fréquente de vulnérabilités.
  • Builds sur le contrôleur : configurez zéro exécuteur sur le nœud principal et faites tourner les builds sur des agents dédiés, pour protéger le contrôleur et ses secrets.

Valider le Jenkinsfile avant de pousser

Une faute de syntaxe dans un Jenkinsfile ne se découvre souvent qu'après le push. Jenkins expose un linter pour les pipelines déclaratifs, utilisable avec un jeton d'API :

curl -X POST --user "$JENKINS_USER:$JENKINS_TOKEN" \
  -F "jenkinsfile=<Jenkinsfile" \
  "$JENKINS_URL/pipeline-model-converter/validate"

La réponse indique si le fichier est valide ou détaille la ligne en erreur. Cette commande s'intègre facilement dans un hook Git ou dans l'éditeur.

Mutualiser avec les Shared Libraries

Quand plusieurs projets partagent les mêmes étapes (notification, déploiement, publication d'image), dupliquer le Jenkinsfile devient vite ingérable. Les Shared Libraries permettent de placer ce code commun dans un dépôt Git séparé, chargé avec @Library('nom-de-la-lib') _ en tête du Jenkinsfile. Chaque projet garde alors un pipeline court et lisible.

Bonnes pratiques Jenkins

  • Utiliser des agents Docker pour l'isolation
  • Paralléliser les stages indépendants
  • Stocker les credentials dans Jenkins Credentials
  • Configurer les notifications pour les échecs
  • Archiver les artefacts de build
  • Versionner le Jenkinsfile et l'image de build avec le code
  • Toujours définir un timeout et une rotation de l'historique

Quand ne pas choisir Jenkins

Si votre code est hébergé sur GitHub ou GitLab et que vous n'avez pas de contrainte d'hébergement particulière, les outils intégrés à ces plateformes demandent beaucoup moins de maintenance. Jenkins prend tout son sens lorsque les builds doivent rester sur votre réseau, accéder à des ressources internes, ou lorsque vous avez déjà une équipe capable d'administrer la plateforme dans la durée.