◀ Retour au blog
Docker

Docker dans les pipelines CI/CD

Publié le 05 Apr 2024· 9 min de lecture
#Docker#CI/CD#DevOps

Docker et l'intégration continue

Docker est devenu incontournable dans les pipelines CI/CD modernes. Il garantit la cohérence entre les environnements de test et de production.

Le principe est simple : au lieu de reconstruire l'environnement sur chaque machine de CI (version de PHP, extensions, dépendances système), on construit une image une seule fois, on la teste, puis on déploie exactement cette image. Le fameux « ça marche sur ma machine » disparaît, car la machine fait désormais partie de l'artefact livré. Le pipeline devient aussi indépendant du fournisseur de CI : les mêmes commandes docker build et docker run fonctionnent sur GitHub Actions, GitLab CI ou Jenkins.

Un Dockerfile multi-étapes

Tout repose sur un Dockerfile multi-étapes (multi-stage) : une étape de base commune, une cible test qui contient les dépendances de développement, et une cible production allégée. Le pipeline choisit la cible avec l'option --target.

# syntax=docker/dockerfile:1
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache icu-libs libzip \
    && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev \
    && docker-php-ext-install intl zip pdo_mysql opcache \
    && apk del .build-deps
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/app

FROM base AS vendor
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-progress

FROM base AS test
ENV APP_ENV=test
COPY composer.json composer.lock ./
RUN composer install --no-scripts --no-autoloader --prefer-dist --no-progress
COPY . .
RUN composer dump-autoload

FROM base AS production
ENV APP_ENV=prod
COPY --from=vendor /var/www/app/vendor ./vendor
COPY . .
RUN composer dump-autoload --classmap-authoritative --no-dev \
    && php bin/console cache:warmup \
    && chown -R www-data:www-data var
USER www-data

L'ordre des instructions n'est pas anodin : composer.json et composer.lock sont copiés avant le reste du code. Tant que les dépendances ne changent pas, Docker réutilise la couche qui contient vendor/, et seule la copie du code source est refaite. C'est ce qui rend les builds suivants rapides.

Pensez aussi au fichier .dockerignore, qui évite d'envoyer au build des fichiers inutiles ou sensibles, et d'invalider le cache à chaque modification locale :

.git
vendor
var
node_modules
.env.local
.env.*.local

Pipeline GitHub Actions avec Docker

Voici une version minimale du pipeline : un job construit l'image de test et exécute PHPUnit dedans, puis, si les tests passent, un second job construit l'image de production et la pousse vers GitHub Container Registry.

name: CI/CD Pipeline
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build test image
        run: docker build --target test -t app:test .
      - name: Run tests
        run: docker run --rm app:test php bin/phpunit

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - name: Log in to GitHub Container Registry
        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
      - name: Build and push production image
        run: |
          IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
          docker build --target production -t "$IMAGE:$GITHUB_SHA" -t "$IMAGE:latest" .
          docker push --all-tags "$IMAGE"

Le nom de l'image est dérivé du dépôt (ghcr.io/proprietaire/depot), converti en minuscules car les registres refusent les majuscules. L'authentification utilise le GITHUB_TOKEN du workflow, qui a seulement besoin de la permission packages: write. Chaque image reçoit deux tags : le SHA du commit, qui indique précisément ce qui tourne en production et facilite le retour arrière, et latest. En revanche, chaque job reconstruit tout depuis zéro, car les runners ne conservent pas le cache Docker entre deux exécutions.

Optimisation du cache

Le cache des couches Docker est crucial pour la vitesse du pipeline :

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3
- name: Build with cache
  uses: docker/build-push-action@v5
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha stocke les couches dans le cache de GitHub Actions. Avec mode=max, les couches des étapes intermédiaires (comme vendor) sont aussi exportées, et pas seulement celles de l'image finale. Quand plusieurs builds partagent le même cache, donnez à chacun son propre scope pour qu'ils ne s'écrasent pas mutuellement.

Un pipeline complet et traçable

La version suivante reprend le même principe avec les actions Docker officielles : tags générés par docker/metadata-action, cache séparé par cible, tests exécutés avec leurs services grâce à Docker Compose, et pull requests testées sans rien publier.

name: CI/CD Pipeline
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - name: Build test image
        uses: docker/build-push-action@v6
        with:
          context: .
          target: test
          tags: app:test
          load: true
          cache-from: type=gha,scope=test
          cache-to: type=gha,scope=test,mode=max
      - name: Run tests
        run: docker compose -f compose.ci.yaml run --rm app php bin/phpunit
      - name: Clean up
        if: always()
        run: docker compose -f compose.ci.yaml down -v

  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha
            type=raw,value=latest,enable={{is_default_branch}}
      - uses: docker/build-push-action@v6
        with:
          context: .
          target: production
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha,scope=production
          cache-to: type=gha,scope=production,mode=max

Les points clés :

  • load: true charge l'image de test dans le démon Docker du runner pour pouvoir la lancer ensuite ; sans cette option, Buildx garde le résultat dans son propre cache.
  • docker/login-action s'authentifie sur GHCR avec le GITHUB_TOKEN du workflow : aucun jeton personnel à créer, il suffit d'accorder packages: write au job.
  • docker/metadata-action génère les tags : type=sha produit un tag du type sha-1a2b3c4, et latest n'est ajouté que sur la branche par défaut. Chaque image en production est ainsi reliée à un commit précis, et un retour arrière consiste à redéployer le tag précédent.
  • Le job de publication ne s'exécute que sur main : les pull requests sont testées, mais ne publient rien.

Tests en parallèle

  • Utilisez Docker Compose pour lancer les services de test (DB, Redis)
  • Parallélisez les suites de tests dans des conteneurs séparés
  • Nettoyez les ressources après chaque exécution

Le fichier compose.ci.yaml utilisé plus haut démarre la base de données à côté de l'image de test, et n'exécute les tests qu'une fois MySQL réellement prêt :

services:
  app:
    image: app:test
    depends_on:
      mysql:
        condition: service_healthy
    environment:
      DATABASE_URL: mysql://root:root@mysql:3306/test

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: test
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
      interval: 5s
      timeout: 5s
      retries: 10

La condition service_healthy est essentielle : un simple depends_on attend seulement que le conteneur démarre, pas que MySQL accepte les connexions. Pour paralléliser, une matrice GitHub Actions peut lancer plusieurs jobs avec la même image, chacun exécutant une suite PHPUnit différente (--testsuite unit, --testsuite integration). Le down -v final, exécuté même en cas d'échec grâce à if: always(), supprime conteneurs et volumes.

Pièges fréquents

  • Secrets dans l'image : ne copiez jamais un fichier .env.local ni une clé dans l'image, et ne passez pas de secret via ARG, qui reste visible dans l'historique. Les secrets de production sont injectés au démarrage du conteneur.
  • Image de test déployée : sans --target production, Docker construit la dernière étape du Dockerfile. Vérifiez que c'est bien celle que vous attendez.
  • Nom d'image en majuscules : les registres exigent des noms en minuscules, ce qui pose problème si le nom de l'organisation GitHub contient des majuscules.
  • Cache invalidé en permanence : un COPY . . placé trop tôt, ou l'absence de .dockerignore, fait reconstruire les dépendances à chaque commit.

En résumé

Un Dockerfile multi-étapes, un cache Buildx correctement configuré, des images taguées par commit et des services de test orchestrés par Compose : avec ces quatre éléments, le pipeline construit une seule fois ce qu'il teste et déploie.

Cette stratégie permet de réduire le temps de déploiement de 30 minutes à moins de 5 minutes.