Comment livrer un monorepo pnpm vers Docker sans un build de 30 minutes
La stratégie Docker-build pour un monorepo pnpm qui maintient les builds à froid sous 5 minutes et les builds incrémentaux sous 30 secondes. Le Dockerfile conscient du workspace, l'install pinned sur le lockfile et le piège de deploy qui nous a mordus au jour un.
Le premier déploiement d'un monorepo pnpm en production est quand l'équipe apprend ce que « Dockerfile court » signifie réellement. Un Dockerfile naïf reconstruit tout le lockfile à chaque run CI. Nous avons vu un timeout standard de 10 minutes de appleboy/ssh-action être dévoré par un build à froid. Voici le pattern sur lequel nous avons atterri, et le piège qui nous a mordus au jour un.
Le Dockerfile conscient du workspace
Deux règles de conception non négociables pour un Dockerfile de monorepo :
- Le contexte est la racine du monorepo. Le build-context doit être la racine du workspace, pas
apps/backendouapps/web. Les paquets se référencent entre eux à travers le workspace ; l'install doit voir toutes les déclarations lockfile et workspace. - Le lockfile est le contrat. Le même
pnpm-lock.yamlque le développeur a fait tourner en local est celui que la CI installe. Nous pinnons surfrozen-lockfile=trueen Docker et en CI pour cette raison.
Le pattern Dockerfile backend :
- Une stage builder qui utilise l'image complète
node:20-alpineet fait tournerpnpm install --frozen-lockfileune fois. - Une stage runner qui ne copie que
apps/backend/dist,node_modulespour le runtime etapps/backend/package.json. Pas de source, pas de fixtures de test, pas d'outillage de build.
Le pattern Dockerfile web (préoccupations séparées, autre stage) :
- Une stage builder qui compile Next.js avec
output: « standalone »et dont la sortie standalone contient déjà unnode_modulesavec seulement les dépendances runtime. - Une stage runner qui copie la sortie standalone et les assets publics, puis fait tourner
node server.js.
Le piège de deploy qui nous a mordus au jour un
Notre première tentative de déploiement était l'erreur classique : chaque app a son propre Dockerfile, chaque fichier Compose utilise le build-context pour l'app en question, et le fichier Compose de production référence un nom de projet différent du fichier dev. Deux process se sont terminés liés au même port hôte.
Le correctif :
- Un build-context par app. Le build-context Dockerfile est toujours la racine du monorepo. Les apps utilisent des fichiers
apps/web/Dockerfileetapps/backend/Dockerfile, mais le contexte est. - Nom de projet Compose explicite. Compose de production utilise
-p 2runbe-monorepopour namespacer les containers loin des autres apps qui peuvent se trouver sur le même hôte. Sans cela, deux fichiers Compose peuvent silencieusement réclamer le même port. - Le réseau external Compose. La production fait tourner le backend et le web sur un réseau partagé
fenix-shared-servicesaux côtés d'autres apps qui partagent les mêmes instances MongoDB et Redis. Dev local fait tourner son propre compose sur le réseauappavec le profil--profile fullqui démarre ses propres MongoDB et Redis.
Détection de dérive du lockfile
Deux façons dont la dérive du lockfile vous mord :
- Un développeur met à jour
pnpm-lock.yaml, la CI tourne contre l'ancien Dockerfile qui le copie depuis un emplacement fixe, et l'install échoue parce que le lockfile est plus récent que l'arbre de dépendances. - Un deuxième développeur fait tourner
pnpm installen local sans le frozen lockfile et obtient un lockfile légèrement différent de celui de la CI.
Nous verrouillons les deux côtés :
- Hook pre-commit fait tourner
pnpm install --frozen-lockfileet refuse le commit s'il échoue. - La CI utilise aussi frozen lockfile. Si le développeur a contourné le hook, la CI l'attrape avant le déploiement.
Builds à froid vs incrémentaux
Avec le Dockerfile conscient du workspace et le lockfile pinné, les temps de build à froid sont :
- Backend. ~3 minutes sur un runner 4-core. La plupart du temps est
pnpm installsur tout le workspace. - Web. ~4 minutes. Les builds Next.js dominent une fois
pnpm installcaché.
Les temps de build incrémentaux (cache-hit sur pnpm install) :
- Backend. ~25 secondes pour une mise à jour unique de dépendance, ~40 secondes pour un changement de source NestJS.
- Web. ~30 secondes pour un changement de source unique, ~90 secondes pour une mise à jour de dépendance.
La stratégie de cache CI qui rend cela fonctionnel :
- Cache
pnpm storebasé sur le hash du lockfile complet. Un changement dans un paquet arbitraire bump la clé de cache, mais les changements non liés touchent le cache. - Cache
node_modulespar app sur le lockfile plus le workspace-package.json. Deux apps avec le même lockfile partagent node_modules quand l'arbre de dépendances le permet.
Augmenter le command_timeout CI
Le command_timeout par défaut de appleboy/ssh-action est 10 minutes. Un build backend à froid avec un lockfile frais prend ~10 minutes sur un runner lent, ce qui est pile sur le seuil d'échec. Augmentez le timeout à 30 minutes pour chaque étape de déploiement qui implique un build Docker. Le script de déploiement qui timeout à 9 minutes 50 secondes est un script de déploiement que vous devrez débugger à 23h.
