16 - Docker et monorepo
Ce que tu vas apprendre
- Comment organiser les Dockerfiles dans un monorepo
- Le piège du build context
- Comment gerer le code partage entre services
- Le .dockerignore adapte au monorepo
- Un compose.yaml avec plusieurs builds
- Un exemple de structure complète et realiste
Prerequisites
- Avoir suivi l'article sur les permissions
- Avoir deja travaille avec un monorepo (meme basique)
La première fois que j'ai dockerise un monorepo, j'ai mis un Dockerfile dans chaque package. Et chaque build copiait la racine complète du monorepo pour acceder au code partage. Mon contexte de build faisait 2 Go. Le build prenait 10 minutes. Il y avait un meilleur moyen.
Un Dockerfile par service vs un Dockerfile a la racine
Deux approches existent :
Un Dockerfile par service (dans chaque package) :
monorepo/
packages/
api/
Dockerfile
src/
web/
Dockerfile
src/
shared/
src/
Le problème : le build context Docker ne peut pas remonter au-dessus du Dockerfile. Si le Dockerfile est dans packages/api/, il ne voit pas packages/shared/.
Un ou plusieurs Dockerfiles a la racine :
monorepo/
docker/
api.Dockerfile
web.Dockerfile
packages/
api/
web/
shared/
C'est l'approche que je recommande. Le build context est la racine du monorepo, et chaque Dockerfile peut acceder a tous les packages.
Le build context en monorepo
Le build context, c'est le répertoire envoye au daemon Docker pour construire l'image. Par défaut, c'est le répertoire ou se trouve le Dockerfile.
Dans Compose, tu le specifies explicitement :
yamlservices:
api:
build:
context: . # racine du monorepo
dockerfile: docker/api.Dockerfile
web:
build:
context: .
dockerfile: docker/web.Dockerfile
Le context: . envoie toute la racine au daemon. C'est la que le .dockerignore devient critique.
Le code partage
La plupart des monorepos ont un package shared ou common utilise par plusieurs services. En Docker, il faut le copier dans chaque image :
dockerfile# docker/api.Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
# Copier les fichiers de dependances
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/api/package.json ./packages/api/
COPY packages/shared/package.json ./packages/shared/
# Installer les dependances
RUN corepack enable && pnpm install --frozen-lockfile
# Copier le code source
COPY packages/api/ ./packages/api/
COPY packages/shared/ ./packages/shared/
# Build
RUN pnpm --filter api build
# Image finale
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/packages/api/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
L'ordre est important pour le cache Docker. Les fichiers package.json changent rarement : on les copie en premier pour que l'étape pnpm install soit cachee. Le code source change souvent : on le copie apres.
Avec pnpm et les workspaces, le --filter api ne build que le package api et ses dépendances internes.
Le .dockerignore adapte
Un monorepo contient beaucoup de choses inutiles pour le build Docker : les node_modules locaux, les dossiers .git, les tests, la documentation.
Sur paltemps.fr, mon .dockerignore a la racine ressemble a ca :
.git
.github
.vscode
**/node_modules
**/dist
**/coverage
**/.env
**/.env.*
**/README.md
**/CHANGELOG.md
**/*.test.ts
**/*.spec.ts
**/__tests__
docker-compose*.yaml
Sans ce fichier, le contexte envoye au daemon Docker contient tous les node_modules de tous les packages. Ca peut peser des gigaoctets et ralentir énormément le build.
Tu ne peux pas avoir un .dockerignore par service quand le context est la racine. C'est une limitation. La solution de contournement, c'est de nommer le fichier .dockerignore en fonction du Dockerfile :
docker/api.Dockerfile -> docker/api.Dockerfile.dockerignore
docker/web.Dockerfile -> docker/web.Dockerfile.dockerignore
Docker BuildKit (active par défaut depuis Docker 23) supporte cette convention.
Le compose.yaml complet
yamlservices:
api:
build:
context: .
dockerfile: docker/api.Dockerfile
target: development
ports:
- "3000:3000"
volumes:
- ./packages/api/src:/app/packages/api/src
- ./packages/shared/src:/app/packages/shared/src
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/myapp
depends_on:
db:
condition: service_healthy
web:
build:
context: .
dockerfile: docker/web.Dockerfile
target: development
ports:
- "3001:3000"
volumes:
- ./packages/web/src:/app/packages/web/src
- ./packages/shared/src:/app/packages/shared/src
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
Remarque le target: development qui pointe vers un stage spécifique du Dockerfile multi-stage. On approfondit ce pattern dans l'article dev vs prod.
Exemple de structure complète
Voici une structure de monorepo dockerise que j'ai utilisee en production :
monorepo/
docker/
api.Dockerfile
web.Dockerfile
worker.Dockerfile
packages/
api/
src/
package.json
tsconfig.json
web/
src/
package.json
tsconfig.json
worker/
src/
package.json
tsconfig.json
shared/
src/
types/
utils/
package.json
tsconfig.json
compose.yaml
compose.override.yaml
.dockerignore
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
tsconfig.base.json
Le pnpm-workspace.yaml :
yamlpackages:
- "packages/*"
Chaque Dockerfile suit le meme pattern : copier les dépendances, installer, copier le code, build. Seul le nom du package change.
Les pièges courants
Le contexte trop gros : sans .dockerignore, envoyer 2 Go de node_modules au daemon Docker a chaque build. Toujours vérifier avec docker build que le contexte est raisonnable.
Les dépendances croisees : si api depend de shared qui depend de utils, il faut copier toute la chaîne. Oublier un package intermediaire casse le build.
Le cache invalide : modifier un fichier dans shared invalide le cache de tous les services qui le copient. C'est normal, mais ca peut surprendre quand un changement anodin déclenché un rebuild complet.
Résumé
- Place les Dockerfiles a la racine (ou dans un dossier
docker/) pour acceder a tous les packages - Le build context doit etre la racine du monorepo dans Compose
- Un bon
.dockerignoreest indispensable pour éviter les contextes de build énormes - Copie les
package.jsonavant le code source pour profiter du cache Docker - Les bind mounts sur
shared/srcpermettent le hot reload du code partage en dev
Navigation : Precedent : 15 - Permissions | Suivant : 17 - Dev vs Prod
Sources
- Docker build context par Docker
- pnpm workspaces par pnpm
- Dockerizing a monorepo par Turborepo