Docker pour les devs - 16 - Docker et monorepo

Dockerfile par service, build context, code partage, .dockerignore, et un exemple concret de structure monorepo dockerisee.

  1. 01 Docker pour les devs - 00 - Pourquoi Docker change tout
  2. 02 Docker pour les devs - 01 - Containers vs VMs
  3. 03 Docker pour les devs - 02 - L'architecture de Docker
  4. 04 Docker pour les devs - 03 - Docker Desktop, Engine et alternatives
  5. 05 Docker pour les devs - 04 - Écrire un Dockerfile
  6. 06 Docker pour les devs - 05 - Layers et cache
  7. 07 Docker pour les devs - 06 - Le .dockerignore
  8. 08 Docker pour les devs - 07 - Multi-stage builds
  9. 09 Docker pour les devs - 08 - Choisir son image de base
  10. 10 Docker pour les devs - 09 - Docker Compose, les bases
  11. 11 Docker pour les devs - 10 - Docker Compose avance
  12. 12 Docker pour les devs - 11 - Networking Docker, les bases
  13. 13 Docker pour les devs - 12 - Networking Docker avance
  14. 14 Docker pour les devs - 13 - Volumes et persistance
  15. 15 Docker pour les devs - 14 - Variables d'environnement et secrets
  16. 16 Docker pour les devs - 15 - Permissions et utilisateurs
  17. 17 Docker pour les devs - 16 - Docker et monorepo
  18. 18 Docker pour les devs - 17 - Dev vs Prod
  19. 19 Docker pour les devs - 18 - ENTRYPOINT, CMD et scripts d'initialisation
  20. 20 Docker pour les devs - 19 - Debugger ses conteneurs
  21. 21 Docker pour les devs - 20 - Bases de donnees dans Docker
  22. 22 Docker pour les devs - 21 - Sauvegardes et restauration
  23. 23 Docker pour les devs - 22 - Conteneuriser un frontend
  24. 24 Docker pour les devs - 23 - Sécurité des conteneurs
  25. 25 Docker pour les devs - 24 - Optimisation des images
  26. 26 Docker pour les devs - 25 - Builds multi-platform
  27. 27 Docker pour les devs - 26 - Limiter les ressources de tes conteneurs
  28. 28 Docker pour les devs - 27 - Gerer les logs comme un adulte
  29. 29 Docker pour les devs - 28 - Healthchecks et restart policies
  30. 30 Docker pour les devs - 29 - Nettoyer Docker avant qu'il mange ton disque
  31. 31 Docker pour les devs - 30 - Registries et stratégie de tags
  32. 32 Docker pour les devs - 31 - Docker en CI/CD
  33. 33 Docker pour les devs - 32 - Au-dela de Compose
  34. 34 Docker pour les devs - 33 - Glossaire Docker de A a Z

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


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 .dockerignore est indispensable pour éviter les contextes de build énormes
  • Copie les package.json avant le code source pour profiter du cache Docker
  • Les bind mounts sur shared/src permettent le hot reload du code partage en dev

Navigation : Precedent : 15 - Permissions | Suivant : 17 - Dev vs Prod


Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.