Skip to content

ADR 0010 — Les images de déploiement ne compilent plus, elles consomment l'artefact du job build

Date : 05.08.2026 Statut : Accepté

Contexte

Le pipeline GitLab durait ~42 min (build 13m48 + test 19m32 + deploy 7m27, stages sériés).

Sur le job deploy, Dockerfile et Dockerfile-worker étaient tous deux multi-stage et faisaient chacun COPY ./src puis dotnet restore + dotnet publish — alors que le job build venait de compiler la solution en Release. Dockerfile enchaînait en plus un troisième build complet, celui déclenché par dotnet ef migrations bundle (ADR 0009). Mesuré sur un pipeline réel :

ÉtapeDurée
image web : restore68 s
image web : publish84 s
image web : build du bundle EF75 s
image worker : restore75 s
image worker : publish55 s

Soit ~6 min à recompiler trois fois le même code. Le COPY ./src précédant le restore, la couche de restore était invalidée par le moindre commit : le cache de couches Docker ne servait jamais.

Décision

Le job build produit tout ce qui est compilé ; les images ne font plus que copier.

  • build exécute dotnet publish --no-build pour src/Web et src/Worker vers publish/web et publish/worker, archivés dans app-publish.tar.gz.
  • Le job bundle, en parallèle de build (même stage), produit publish/efbundle dans app-efbundle.tar.gz. Il a d'abord vécu dans le script de build, où il coûtait 1 m 55 à 3 min de chemin critique ; il ne compile que Domain + Persistence et reste plus court que build. Ne vaut que si le runner accepte 2 jobs simultanés — sinon, le réintégrer dans build.
  • Dockerfile et Dockerfile-worker sont mono-stage (FROM aspnet:8.0-bookworm-slim@sha256:…) et se contentent de COPY publish/…. Tout le durcissement de l'ADR 0009 (user non-root 10001, ownership de /keys /Logs /wwwroot/Uploads, HEALTHCHECK, entrypoint migrate-then-run) est inchangé.
  • Le .dockerignore passe en liste blanche (* + !publish + !docker) : le contexte envoyé au démon Docker se réduit au publish et à docker/entrypoint.sh. Les secrets sont exclus par construction — il n'y a plus de liste d'exclusions à tenir à jour.
  • Les artefacts sont scindés : app-tests.tar.gz (bin/obj, pour les jobs de test) et app-publish.tar.gz (pour deploy), chacun compressé en gzip -1.

La disposition dans les images est inchangée : application Web à la racine / avec /wwwroot (les montages de server/docker-compose-application.yml en dépendent), worker dans /app.

Conséquences

  • ✅ ~6 min gagnées par pipeline, une seule compilation au lieu de trois.
  • ✅ Contexte Docker réduit du dépôt entier (+ bin/obj extraits de l'artefact) à publish/ + docker/.
  • Dockerfile et Dockerfile-worker ne sont plus construisibles depuis un simple checkout git : ils exigent publish/web, publish/worker et publish/efbundle. Pour tourner en local, utiliser Dockerfile-Local / docker-compose-local.yml, ou lancer les dotnet publish à la main d'abord.
  • ❌ Les assets frontend (gulp + Vue) doivent être générés avant le dotnet publish du job build, puisque c'est le publish qui embarque wwwroot/. L'ordre du script build est donc contraignant.
  • ⚠️ wwwroot/Uploads est exclu à l'archivage (tar --exclude) : dossier de données runtime monté par docker-compose, absent d'un checkout CI mais présent sur une machine de dev où il pèse plus d'1 Go. C'est ce que l'ancien .dockerignore garantissait.
  • ⚠️ Piège Windows : dotnet ef migrations bundle --no-build échoue en local avec un MSB3027 (« fichier verrouillé par .NET Host ») — le processus de découverte du DbContext lancé par EF tient Horizon.Persistence.dll pendant que MSBuild tente de la recopier. C'est propre à Windows ; vérifié sous Linux avec l'outil EF 8.0.29, --no-build produit un bundle identique au bit près.
  • ⚠️ --no-build n'évite que le build du projet de démarrage par l'outil : migrations bundle génère ensuite un projet temporaire dans /tmp qui ProjectReference src/Persistence, puis lance dotnet publish --runtime linux-x64 --no-self-contained dessus. Domain + Persistence sont donc recompilés (avec RID) à chaque bundle, quoi qu'il arrive — compter ~1 min, pas quelques secondes.
  • ⚠️ La sortie de ce publish interne est avalée par EF : en cas d'échec on ne lit que Build failed. Use --verbose to see errors. (pipeline du 05.08.2026, job build mort là-dessus sans autre trace). D'où le --verbose et le dotnet build-server shutdown qui le précèdent dans .gitlab-ci.yml : ce second graphe MSBuild démarre pendant que les nodes MSBuild + VBCSCompiler du build de la solution sont encore résidents, et un compilateur tué faute de mémoire (csc.dll exited with code 137) ressort exactement sous cette forme muette.

Ce qui a été mesuré et écarté

Cache NuGet GitLab : retiré le 06.08.2026. Il imposait NUGET_PACKAGES=$CI_PROJECT_DIR/.nuget/packages (le cache GitLab ne sait archiver que sous $CI_PROJECT_DIR). Bilan par job : 46 s d'extraction pour faire gagner 16 s sur dotnet restore (22 s sans cache, 6 s avec), et 2 m 43 de ré-archivage des 30 385 fichiers dès qu'un paquet bouge. Les 3 caches node_modules, eux, restent gagnants tant que les yarn install sont dans le job (12 s d'extraction + 2 m 32 d'archivage contre 3 m 34 gagnés) — ils deviennent inutiles avec docker/ci-build.Dockerfile, qui embarque les node_modules.

Le chiffre « 22 s sans cache » n'a pas tenu : sur le run du 06.08.2026, dotnet restore Horizon-CI.slnf a coûté 1 m 57 dans build et 2 m 19 à 2 m 53 dans chacun des 3 jobs de test, qui le refaisaient tous en entier. Ces derniers n'ont besoin que de coverlet.collector (chargé depuis le dossier de paquets, pas copié dans bin/) : ils restaurent désormais le seul projet de test. Si le restore de build reste à ~2 min, reconsidérer le cache NuGet — le bilan ci-dessus a été établi sur une mesure que le run suivant n'a pas reproduite.

Le runner rezippe un cache uniquement si son contenu a changé (runner 19.2). Le cache NuGet était archivé en 0,06 s, les node_modules en 2 m 32 : yarn et gulp/vite écrivent dans node_modules à chaque exécution, ce qui suffit à invalider l'archive.

Régression non élucidée : dotnet build Horizon-CI.slnf est passé de 51 s (26.06.2026, SDK 8.0.422) à 3 min (06.08.2026, SDK 8.0.423) sur le même runner, dont 21 s → 2 min pour Horizon.Persistence seul, alors que les étapes frontend n'ont bougé que de 20 %. Le code des migrations n'a grossi que de 8 % (851 000 → 925 000 lignes). Piste en cours : plafond mémoire du conteneur, csc partant en GC thrash sur cette compilation d'un seul tenant — d'où la sonde nproc / memory.max / memory.peak temporairement ajoutée au job build.

Ce que la sonde a répondu (run du 06.08.2026, 11h46) : le conteneur voit 16 CPU pour 6 GiB, et le pic mémoire du build vaut 6 442 450 944 octets, soit exactement la limite. ⚠ Ça ne suffit pas à conclure : memory.peak (cgroup v2) inclut le page cache, qui monte au plafond sur n'importe quel gros restore sans que ce soit pathologique. Le compteur qui tranche est memory.events (max = nombre de butées sur la limite, oom_kill = tués), ajouté à la sonde pour le run suivant. En attendant, la mitigation est posée dans les deux jobs qui compilent Persistence : -maxcpucount:4 (MSBuild dimensionne son parallélisme sur les CPU sans regarder la mémoire : 16 projets de front sur 6 GiB) et DOTNET_gcServer=0 (GC workstation : un tas par process au lieu d'un par cœur). L'exit 137 du bundle le 05.08 reste, lui, un OOM avéré.

Correction : le paragraphe ci-dessous parlait d'un conteneur limité à « ~4 cœurs ». C'est faux — nproc en renvoie 16. La lenteur des tests en CI ne vient donc pas d'un manque de cœurs visibles.

Le job test (19m32) est dominé par tests/Application : 969 tests, 13m43. L'hypothèse d'un surcoût de setup par test (la MapperConfiguration de 130 CreateMap reconstruite dans TestHelperBase à chaque test, xUnit instanciant la classe par test) a été mesurée et invalidée : le constructeur ne pèse que ~47 ms sur ~900 ms par test, et partager la MapperConfiguration en statique n'a donné aucun gain mesurable. Idem pour la libération des bases InMemory (EnsureDeleted au Dispose) : sans effet.

La mesure réelle : la suite consomme ~2600 s de CPU cumulées pour 923 tests (relevé TRX), ramenées à 232 s de wall-clock en local par le parallélisme xUnit (~11×). Le coût est donc intrinsèque au travail des tests, pas au setup ; coverlet ajoute +40 % par-dessus (326 s contre 232 s en local). En CI le 06.08.2026 : 412 s pour 969 tests, soit ~6,3× de parallélisme effectif sur 16 cœurs — xUnit ne sature pas l'hôte, ce qui est précisément ce qui rend le shard multi-processus rentable.

Chronologie du run du 06.08.2026 (staging, 11h46 → 12h14, 28 min bout à bout) : build 10 m 52 et bundle 4 m 14 démarrés à la même seconde (parallélisme du runner confirmé, le bundle est donc entièrement masqué) ; puis les 3 jobs de test en parallèle, test:application 11 m 03 portant seul le chemin critique (Domain 4 m 49 dont 0,9 s de tests, Web 5 m 47 dont 15 s) ; coverage 19 s ; 5 min 26 d'attente ; deploy 36 s. Décomposition du job build : 28 s de préparation, 35 s de before_script (installation Node), 15 s de restauration des caches node_modules, 1 m 20 d'assets frontend, 1 m 57 de dotnet restore, 2 m 45 de dotnet build, 18 s de publish, 55 s de tar, 1 m 35 de ré-archivage des caches node_modules (41 330 fichiers pour Metronic à eux seuls) et 53 s d'upload des 735 Mo d'artefacts. Décomposition de test:application : 1 m 10 de préparation et de téléchargement d'artefact, 14 s d'extraction, 2 m 19 de dotnet restore, 7 m 49 de dotnet test.

Capacité du runner portée à 8 jobs concurrents / 6 GiB par conteneur le 06.08.2026 (64 GiB sur l'hôte, ~12 GiB de marge annoncée). Le plafond par conteneur ne bouge pas : la mitigation mémoire du job build reste nécessaire. Ce qui change, c'est le CPU — il n'y a pas de quota CPU par conteneur, donc chacun voit les 16 cœurs de l'hôte et se croit seul : 8 jobs × 16 threads pour 16 cœurs dans le pire cas. D'où le plafond de threads xUnit sur les shards.

dotnet test -- xUnit.MaxParallelThreads=N ne fait rien avec xunit.runner.visualstudio 2.4.3 (la version du dépôt) : le nœud RunSettings xUnit est ignoré sans erreur ni avertissement. Vérifié le 06.08.2026 : en passant diagnosticMessages par RunSettings, aucune bannière xUnit n'apparaît, alors qu'avec un fichier xunit.runner.json posé à côté de l'assembly xUnit annonce bien parallel test collections = on, max threads = 3. C'est donc ce fichier que le job CI écrit dans le dossier de sortie. Le mesurer au chronomètre sur tests/Web ne tranche pas (10-12 s sans plafond contre 11-18 s avec maxParallelThreads=1) : la suite est trop courte et dominée par le démarrage.

Le vrai correctif est côté runner : poser un cpus dans config.toml. .NET lit le quota CPU du cgroup et dimensionne alors MSBuild et xUnit tout seuls — -maxcpucount:4 et le xunit.runner.json deviendraient inutiles, et tous les jobs en profiteraient, pas seulement ceux qu'on a pensé à annoter.

Les leviers restants sur ce job, par ordre d'intérêt :

  1. Paralléliser davantage (3 jobs par projet — fait ; shard de tests/Application sur 3 instances — fait le 06.08.2026, découpage vérifié sur la sortie réelle de --list-tests).
  2. Réduire le travail des tests eux-mêmes : les factories Bogus s'appellent en cascade et reconstruisent un Faker<T> complet par ligne générée (BookingFaker → Customer + User + 2 Passengers + 2 Accommodations + TravelOccurrence + 2 Sources, chacun refabriquant les siens). C'est le vrai gisement, mais il demande une refonte des 60 fichiers de tests/Helpers/Factories.
  3. Restreindre la collecte de couverture (par ex. aux seuls pipelines main).

Contributors

No contributors

Changelog

No recent changes