Aller au contenu principal

Tester l'image Docker de prod avec des e2e en CI

Photo d'Emmanuel BALLERY, fondateur de x10
Emmanuel BALLERY
CTO freelance & Architecte logiciel
calendar_today 07/06/2026
schedule 15 min lecture
Un pipeline d'intégration continue valide un container Docker avant son déploiement en production

Votre pipeline CI est vert : tests unitaires, tests fonctionnels, analyse statique. Vous déployez. Et la production tombe sur une erreur que personne n'avait vue venir : une extension PHP absente de l'image, un composer install --no-dev qui a retiré un bundle encore référencé, un entrypoint qui échoue sur un volume en lecture seule. Le problème est structurel : le container que vous déployez n'est pas ce que vous avez testé. Vos tests valident du code ; la production exécute un artefact.

Sur une application que je maintiens pour un grand opérateur télécom, déployée sur OpenShift, nous avons éliminé cette classe d'incidents en exécutant la suite de tests end-to-end Playwright directement contre l'image Docker de production, dans GitLab CI, avant toute publication. Si la suite échoue, l'image n'est ni publiée, ni déployée. Voici le retour d'expérience complet : l'architecture, les choix de conception, et les pièges très concrets rencontrés en route.

Pourquoi tester l'artefact et pas seulement le code

Entre le code source validé par la CI et le container qui tourne en production, il y a une chaîne de transformations entière :

Chacun de ces maillons peut casser sans qu'aucun test unitaire ne le détecte, puisque les tests unitaires tournent sur le code source, avec les dépendances de dev, dans un environnement qui ne ressemble pas à la production. Tester l'image elle-même, c'est tester le produit fini — exactement ce qui sera poussé sur le cluster.

Le contexte

L'application est un back-office métier en Symfony 6.4 / PHP 8.4, rendu côté serveur (Twig, HTMX, quelques îlots React), avec MySQL et Redis. Le pipeline GitLab CI suit une chaîne CI/CD classique :

  1. build — installation des dépendances PHP et build des assets front ;
  2. test — tests unitaires et fonctionnels, lint, analyse statique ;
  3. package-build — construction de l'image Docker de production, poussée sous un tag snapshot propre au pipeline ;
  4. package-testc'est ici que vivent les e2e ;
  5. package-publish — promotion du snapshot en tag de release ;
  6. deploy — déploiement automatique en staging, puis production en validation manuelle.

Le placement du job e2e n'est pas un détail : il s'exécute après la construction de l'image mais avant sa publication. Un échec bloque mécaniquement tout ce qui suit — l'image défectueuse n'atteint jamais le registre de release, donc jamais staging, donc jamais la production.

L'architecture du job : l'image de prod comme service GitLab CI

GitLab CI permet d'attacher des services à un job : des containers démarrés à côté du container principal, joignables par leur alias réseau. C'est le mécanisme qu'on utilise habituellement pour fournir un MySQL à des tests fonctionnels. L'idée centrale du montage est de l'utiliser pour démarrer l'image de production fraîchement construite, à côté de ses dépendances :

e2e:
  stage: package-test

  # le job tourne dans l'image Playwright officielle :
  # Node + Chromium + dépendances système déjà installés
  image: mcr.microsoft.com/playwright:v1.60.0-noble

  services:

    - name: mysql:8.0
      alias: mysql
      # base jetable : la durabilité est inutile, on optimise la vitesse
      command:
        - "--skip-log-bin"
        - "--innodb-flush-log-at-trx-commit=0"
        - "--innodb-doublewrite=0"
        - "--sync-binlog=0"

    - name: redis:7
      alias: redis

    # capture les emails sortants pour les assertions (API HTTP sur :8025)
    - name: axllent/mailpit:latest
      alias: mailpit

    # l'image construite par CE pipeline (tag snapshot),
    # PAS la dernière release publiée
    - name: $SNAPSHOT_IMAGE
      alias: web
      entrypoint: [ "bash", "-c" ]
      command:
        - >
          cd /var/www/app &&
          until php bin/console doctrine:database:create -e test -n --if-not-exists;
            do echo "waiting for mysql…"; sleep 2; done &&
          php bin/console doctrine:migrations:migrate -e test -n --allow-no-migration &&
          exec supervisord -c /etc/supervisor/conf.d/app.conf

  variables:
    APP_ENV: test
    MAILER_DSN: smtp://mailpit:1025
    E2E_BASE_URL: http://web:8080
    MAILPIT_URL: http://mailpit:8025

  before_script:
    - npm -C e2e/ ci
    # attend la fin des migrations puis charge le jeu de données
    - node e2e/ci/load-seed.mjs

  script:
    - npm -C e2e/ test

  artifacts:
    when: always
    paths: [ 'e2e/playwright-report/', 'e2e/test-results/' ]
    reports:
      junit: e2e/reports/junit.xml

Le job lui-même tourne dans l'image Playwright officielle, qui embarque Node, Chromium et toutes les dépendances système du navigateur. Rien n'est installé au runtime — une contrainte dure dans notre cas, car le runner GitLab tourne sur Kubernetes avec un utilisateur non-root qui n'a pas le droit d'installer quoi que ce soit. Seule vigilance : le tag de l'image Playwright doit rester aligné sur la version de @playwright/test du package-lock.json, sous peine de décalage navigateur/runner.

Le piège du tag : tester l'image du pipeline courant

Premier piège, et il est sournois. Notre première version référençait le tag de release de l'image (:master). Or ce tag n'est promu qu'après l'étape de test… Le job testait donc l'image du pipeline précédent : toujours verte, forcément — elle avait déjà été validée. Une suite e2e qui teste la mauvaise image est pire qu'inutile : elle donne un faux sentiment de sécurité. La correction consiste à référencer le tag snapshot poussé par le package-build du pipeline en cours.

Rendre l'image de production démarrable en environnement de test

L'image de prod ne contient ni les dépendances de dev, ni les outils de fixtures. Pour qu'elle puisse démarrer en APP_ENV=test, trois conditions doivent être réunies.

1. Les bundles de dev ne doivent exister qu'en dev

Si un bundle de fixtures (Alice, par exemple) est déclaré pour les environnements dev et test, le kernel Symfony refusera de démarrer en test dans l'image de prod, puisque le package n'est pas dans vendor/. La solution est de restreindre ces bundles à dev uniquement :

return [
    // …
    Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true],
    Nelmio\Alice\Bridge\Symfony\NelmioAliceBundle::class => ['dev' => true],
    Fidry\AliceDataFixtures\Bridge\Symfony\FidryAliceDataFixturesBundle::class => ['dev' => true],
    Hautelook\AliceBundle\HautelookAliceBundle::class => ['dev' => true],
];

C'est le déblocage clé de tout le montage : l'environnement test devient un environnement que l'image de production sait exécuter, avec son vendor/ sans dépendances de dev.

2. Neutraliser les dépendances externes

L'application parle à un annuaire d'entreprise (authentification OIDC), à une passerelle SMS, à un stockage S3. Aucun de ces services n'est joignable — ni souhaitable — depuis un job CI. En environnement de test, chaque pont vers l'extérieur est remplacé par une implémentation factice, en surchargeant simplement l'alias de l'interface dans un bloc when@test :

when@test:
    services:
        # chaque interface pointe vers son implémentation factice
        App\Bridge\Directory\DirectoryInterface: '@App\Bridge\Directory\FakeDirectory'
        App\Bridge\Sms\SmsInterface: '@App\Bridge\Sms\FakeSms'
        App\Bridge\Storage\StorageInterface: '@App\Bridge\Storage\FakeStorage'

Même logique pour l'authentification : en test, un fallback http_basic avec des identifiants factices remplace le flux OIDC complet. Côté Playwright, les httpCredentials sont envoyés préventivement — aucun test ne contient d'étape de login, et les specs qui vérifient les droits d'un autre profil changent simplement d'identifiants avec test.use().

3. Adapter l'entrypoint

L'entrypoint de production fait plus que servir l'application : dump des variables d'environnement, installation des assets, migrations, commandes métier de synchronisation. En CI, la majorité de ces étapes est inutile ou impossible. Le service GitLab surcharge donc l'entrypoint pour ne garder que l'essentiel : attendre MySQL, créer le schéma via les migrations, puis lancer le serveur — comme dans l'extrait YAML plus haut. On teste bien les binaires, la configuration et le code de l'image ; seul le bootstrap est adapté au contexte.

Des données de test déterministes — sans outil de fixtures dans l'image

Les fixtures Alice étant désormais réservées au dev, comment peupler la base ? La réponse tient en deux temps :

Le script de régénération s'appuie sur la pile de test locale : il déroule les migrations dans une base dédiée, y charge les fixtures Alice (en dev, le seul environnement où le bundle existe), puis exporte les données seules :

#!/usr/bin/env bash
# Régénère fixtures/seed.sql, le dump data-only chargé
# par la pile de test locale et par le job CI.
set -euo pipefail

compose() { docker compose -f compose.test.yaml "$@"; }

scratch="seed_regen"
db_url="mysql://root:toor@mysql:3306/${scratch}?serverVersion=8.0"

# Alice n'existe qu'en dev : on y construit le schéma via les
# migrations, puis on charge les fixtures dans la base jetable
compose exec -T -e APP_ENV=dev -e DATABASE_URL="${db_url}" app bash -lc '
    php bin/console doctrine:database:drop -e dev -n --if-exists --force &&
    php bin/console doctrine:database:create -e dev -n &&
    php bin/console doctrine:migrations:migrate -e dev -n --allow-no-migration &&
    php bin/console hautelook:fixtures:load -e dev -n --purge-with-truncate
'

# export data-only et rejouable : pas de CREATE TABLE,
# des REPLACE plutôt que des INSERT
compose exec -T mysql mysqldump -uroot -ptoor \
    --no-create-info --complete-insert --replace \
    --single-transaction --skip-comments --skip-triggers \
    "${scratch}" > fixtures/seed.sql

# la base jetable ne survit pas à la régénération
compose exec -T -e APP_ENV=dev -e DATABASE_URL="${db_url}" \
    app php bin/console doctrine:database:drop -e dev -n --if-exists --force

Deux options de mysqldump font tout le travail : --no-create-info n'exporte que les lignes (le schéma reste l'affaire exclusive des migrations), et --replace rend le dump rejouable sur une base déjà peuplée. Le fichier produit est versionné avec le code : une revue de code montre exactement quelles données de test ont changé.

En CI, le job charge ce dump une fois les migrations terminées. Subtilité : l'image Playwright n'a pas de client mysql, et on ne peut rien y installer. Le chargement passe donc par un petit script Node avec le driver mysql2, qui réessaie jusqu'à ce que le schéma existe :

const sql = await readFile(seedPath, "utf8");

// L'app doit booter, attendre MySQL, puis dérouler toute la chaîne de
// migrations (~200 migrations, ~100 s) avant qu'une table n'existe :
// le budget de retry doit couvrir ce délai, pas seulement le warm-up DB.
const maxAttempts = 240;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
        const connection = await mysql.createConnection(config);
        await connection.query(sql);
        await connection.end();
        process.exit(0);
    } catch {
        // base pas prête ou schéma pas encore migré — on attend
        await new Promise((resolve) => setTimeout(resolve, 2000));
    }
}

Le commentaire sur le budget de retry est un piège vécu : dimensionné au départ pour un simple démarrage de MySQL, le délai d'attente expirait pendant la chaîne de migrations. Le temps à couvrir est celui du chemin complet — pull de l'image, boot, attente MySQL, migrations — pas celui du composant le plus rapide.

Le piège le plus inattendu : un TLD préchargé HSTS

Le plus beau piège du projet mérite sa propre section. Le service applicatif s'appelait initialement app — un alias naturel. Et tous les tests échouaient en CI avec des erreurs TLS incompréhensibles, alors que l'application répondait parfaitement en HTTP.

  services:
    - name: $SNAPSHOT_IMAGE
      # alias: app          # KO : http://app:8080 → erreur TLS dans Chromium
      alias: web            # OK : http://web:8080
  variables:
    E2E_BASE_URL: http://web:8080

Explication : .app est un TLD réel, possédé par Google, et inscrit en HSTS preload dans Chromium. Un hôte nommé app est donc automatiquement promu en HTTPS par le navigateur — qui tente alors une poignée de main TLS sur un port qui ne sert que du HTTP en clair. Renommer l'alias en web a suffi. Coût de diagnostic : bien supérieur au coût de la correction, comme souvent.

Asserter sur les emails sortants

L'application envoie des notifications par email, et la suite doit pouvoir les vérifier. En environnement de test, le mailer Symfony est routé en synchrone (pas de worker Messenger en CI) et pointe vers Mailpit, démarré comme service. Les tests interrogent ensuite son API HTTP :

import { clearMail, waitForMail } from "../support/mailpit";

await clearMail();
// … action qui déclenche une notification …
const mail = await waitForMail((m) => m.Subject.includes("Nouveau service"));
expect(mail.Bcc?.map((a) => a.Address)).toContain("owner@example.com");

L'envoi d'email fait ainsi partie du périmètre testé, dans les conditions de l'image de production — y compris la configuration du transport et le rendu des templates.

Reproduire le même environnement en local

Un échec e2e en CI doit pouvoir être débogué confortablement. Un compose.test.yaml dédié reproduit le montage à l'identique : MySQL sur tmpfs avec la durabilité coupée, Redis sans persistance, Mailpit, le même dump SQL, le même APP_ENV=test et le même .env.test versionné. La pile est éphémère par construction : chaque docker compose up repart d'un état propre et déterministe, et Playwright se lance depuis la machine hôte contre l'application conteneurisée — y compris en mode --headed ou avec l'inspecteur pour rejouer un scénario pas à pas.

CI et local partagent ainsi la même définition de l'environnement de test ; seules quelques variables contextuelles (URL de base, DSN Mailpit) diffèrent. C'est ce qui évite la double maintenance — et les écarts qui finissent toujours par apparaître entre deux configurations parallèles.

Ce que ça change concrètement

La suite compte une cinquantaine de scénarios Playwright : smoke tests, contrôle d'accès par profil, rendu de toutes les pages d'administration, CRUD pilotés par des descripteurs déclaratifs, règles de validation, notifications email. Chaque pipeline la déroule contre l'image candidate, en quelques minutes. Cette couverture s'est construite progressivement — la même démarche que pour introduire des tests automatisés sur un projet existant : d'abord les smoke tests, puis les parcours critiques, puis le reste.

Conclusion

Tester l'image de production avant de la déployer ne demande ni outillage exotique ni infrastructure dédiée : des services GitLab CI, une image Playwright, un dump SQL versionné et trois aménagements dans la configuration Symfony. L'essentiel du travail est conceptuel — décider que l'unité testée est l'artefact, pas le code — et se loger au bon endroit du pipeline, entre la construction de l'image et sa publication.

La démarche relève de la même logique que la sécurisation d'une application web en production : déplacer la découverte des problèmes le plus en amont possible, là où ils coûtent le moins cher. Le retour sur investissement, lui, se mesure à chaque déploiement qui ne réserve aucune surprise. Sur une application métier critique, c'est exactement le genre d'ennui que l'on recherche.

Photo d'Emmanuel BALLERY, fondateur de x10

À propos de l'auteur

Emmanuel BALLERY est le fondateur de x10 solutions. Expert en architecture logicielle et passionné par la qualité du code (Software Craftsmanship), il aide les entreprises à transformer leur dette technique en actifs durables.

Voir plus arrow_forward

Questions fréquentes

Pourquoi ne pas simplement détecter ces problèmes en staging ? expand_more
Parce que le staging arrive après la publication de l'image : une image cassée y bloque la recette métier de toute l'équipe. En testant avant publication, la boucle de retour est plus courte et le staging reste disponible pour son vrai rôle.
Faut-il construire une image spéciale pour les tests ? expand_more
Non, et c'est tout l'intérêt : c'est exactement l'image de production, démarrée avec APP_ENV=test. Une image de test dédiée réintroduirait l'écart entre ce qui est testé et ce qui est déployé.
Combien de temps cela ajoute-t-il au pipeline ? expand_more
Quelques minutes : démarrage de l'image, migrations, chargement du jeu de données et une cinquantaine de scénarios Playwright. Très en deçà du coût d'un rollback en production ou d'un staging indisponible.
Cette approche est-elle limitée à Symfony et OpenShift ? expand_more
Non. Le principe — démarrer l'artefact final comme service CI et le tester au travers de HTTP avec un vrai navigateur — s'applique à toute application conteneurisée, quel que soit le framework ou l'orchestrateur cible.