infra / docker-compose
Migrations de base de données automatisées au démarrage
Explication
Ce que vous allez apprendre
- Comprendre pourquoi une migration ne doit jamais s'exécuter plusieurs fois en parallèle
- Isoler une migration dans un service Compose dédié avec
restart: "no" - Faire attendre les replicas de l'API qu'un job de migration se termine avec succès
- Ajouter un verrou consultatif Postgres comme filet de sécurité supplémentaire
- Préférer des migrations "forward-only" à un rollback automatique fragile
Dans quel contexte ?
L'équipe de boutique-api déploie une nouvelle version de l'API qui ajoute une colonne email_verified à la table users, pendant que le service api tourne avec deploy.replicas: 3. Si chacun des trois replicas tente d'exécuter la migration à son démarrage, la table risque une modification concurrente et potentiellement corrompue. Cette leçon isole la migration dans un service unique et fiable, exécuté une seule fois avant que les replicas ne démarrent.
Rappel du problème posé par le scaling
D'abord, souviens-toi de la leçon sur le scaling : un service peut tourner en plusieurs exemplaires identiques, les replicas. Une migration de base de données, comme ajouter une colonne à une table, ne doit jamais s'exécuter plusieurs fois en même temps.
Ce qui se passerait sans précaution
Si chaque replica de l'API lançait sa propre migration au démarrage, plusieurs modifications de schéma s'exécuteraient en parallèle, avec un risque réel de conflit ou de corruption des données. Il faut donc garantir qu'une seule exécution a lieu, avant que quiconque ne se connecte à la base.
Une première idée : isoler la migration dans son propre service
La solution consiste à créer un service Compose séparé, appelé migrate, qui ne fait qu'une chose : exécuter la commande de migration, comme npx prisma migrate deploy, puis s'arrêter. Ce service n'est pas destiné à tourner en continu comme l'API.
Empêcher ce job de redémarrer en boucle
Il faut penser à ajouter restart: "no" sur ce service. Sans cette précaution, un échec de migration ferait redémarrer le job indéfiniment, ce qui masquerait l'erreur réelle au lieu de la signaler clairement.
Faire attendre l'API que la migration ait réussi
Une fois ce job créé, il reste à empêcher l'API de démarrer avant que la migration ne soit terminée AVEC SUCCÈS. C'est exactement le rôle de depends_on avec condition: service_completed_successfully, vu en leçon sur les healthchecks, qui attend un code de sortie 0 plutôt qu'un simple démarrage.
| Étape | Service concerné | Condition d'attente |
|---|---|---|
| 1. Base prête | db | service_healthy |
| 2. Migration exécutée | migrate | service_completed_successfully |
| 3. API démarrée (x3 replicas) | api | Attend l'étape 2 |
Piège dangereux
Oublier restart: "no" sur le service migrate fait qu'un échec de migration (erreur SQL, contrainte violée) redémarre le job indéfiniment en boucle, masquant l'erreur réelle dans un flot de logs répétitifs au lieu de la signaler clairement une seule fois.
Un filet de sécurité supplémentaire
Même avec cette organisation, deux pipelines CI lancés par erreur en même temps pourraient déclencher deux migrations en parallèle. Un verrou consultatif Postgres, comme SELECT pg_advisory_lock(727272), empêche ce scénario : la seconde tentative attend que la première relâche le verrou avant de continuer.
Ce qu'il ne faut jamais faire en cas d'erreur
Il reste un piège fréquent à éviter : écrire des migrations qui suppriment des données sans possibilité de retour en arrière fiable. Mieux vaut toujours des migrations "forward-only" : en cas d'erreur, on écrit une nouvelle migration corrective plutôt que de tenter un rollback automatique, et on garde systématiquement une sauvegarde avant tout déploiement majeur.
Maintenant que la base de données est protégée à chaque déploiement, la dernière leçon de ce cours s'attaque à un autre défi de production : suivre ce qui se passe dans une dizaine de services à la fois, grâce aux logs centralisés.
Commandes & code
Migrations de base de données automatisées au démarrage
Sur un service scalé à plusieurs replicas, exécuter la migration dans CHAQUE replica est dangereux : il faut une exécution unique et fiable.
services:
migrate:
build: ./api
command: ["npx", "prisma", "migrate", "deploy"]
restart: "no" # job one-shot : ne doit jamais redémarrer en boucle
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: "postgres://user:pass@db:5432/app"
api:
build: ./api
depends_on:
migrate:
condition: service_completed_successfully # attend un exit code 0 de "migrate"
deploy:
replicas: 3 # les 3 replicas démarrent APRÈS la migration, jamais avant
environment:
DATABASE_URL: "postgres://user:pass@db:5432/app"
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: pass
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 5-- Verrou consultatif Postgres : protège contre une exécution CONCURRENTE
-- si "migrate" tourne quand même plusieurs fois en parallèle (ex: déploiements simultanés)
SELECT pg_advisory_lock(727272);
-- ... exécuter les migrations ...
SELECT pg_advisory_unlock(727272);#!/bin/sh
# ./api/scripts/migrate-with-lock.sh - wrapper utilisé comme "command" du service migrate
set -e
psql "$DATABASE_URL" -c "SELECT pg_advisory_lock(727272);"
npx prisma migrate deploy
STATUS=$?
psql "$DATABASE_URL" -c "SELECT pg_advisory_unlock(727272);"
exit $STATUS# Alternative Flyway (Java/SQL brut) : même pattern one-shot + service_completed_successfully
services:
migrate:
image: flyway/flyway:10
command: -url=jdbc:postgresql://db:5432/app -user=user -password=pass migrate
volumes:
- ./sql-migrations:/flyway/sql:ro
depends_on:
db:
condition: service_healthy
restart: "no"# Rollback : contrairement à "migrate deploy", il n'existe pas de rollback automatique fiable
# universel -> toujours écrire des migrations FORWARD-ONLY (nouvelle migration qui corrige,
# jamais de "undo" magique) et garder un backup avant tout déploiement majeur.
docker compose run --rm migrate npx prisma migrate status # audit avant un déploiementRésumé
service_completed_successfullygarantit que l'API ne démarre qu'après une migration réussie.restart: "no"sur le job de migration évite une boucle de crash s'il échoue.- Un verrou consultatif (
pg_advisory_lock) protège contre deux migrations concurrentes. - Préférer des migrations forward-only avec backup préalable à un rollback automatique fragile.
Exercices pratiques
Mission : une migration qui boucle en silence
Objectif : Isoler correctement un job de migration et anticiper une exécution concurrente accidentelle.
Contexte
Sur boutique-api, le service migrate échoue à cause d'une contrainte SQL violée en ajoutant la colonne email_verified, mais personne ne voit d'erreur claire dans les logs : le job semble juste tourner indéfiniment. Le service api tourne avec deploy.replicas: 3, et deux pipelines CI pourraient un jour se déclencher presque simultanément.
Corrige l'isolation du job de migration et sécurise-le contre une exécution concurrente.