FastAPI Hexagonal Code source

00. Docker Compose, le point de départ

Avant le cluster, la stack de développement — et elle en apprend plus qu'on ne croit sur la mise en route d'un système distribué.

TL;DR — Onze services en une commande, avec de vrais healthchecks et un graphe de depends_on qui séquence réellement le démarrage. C'est là que se règle l'essentiel des problèmes d'ordre — et ce qu'on résout ici se retrouve, sous une autre forme, dans les manifestes Kubernetes.

Une commande#

cp .env.template .env
docker compose up --build        # → http://localhost:8000/docs

Pour faire tourner l’API sur l’hôte, démarrer aussi RabbitMQ : le lifespan de l’API s’y connecte. Appliquer d’abord les migrations en connexion directe via le service migrate :

cp .env.template .env           # une seule fois ; adapter les valeurs
docker compose up -d postgres pgbouncer valkey rabbitmq mailpit
docker compose run --rm migrate
cd backend
uv sync
make run
ServiceImageRôle
postgrespostgres:17-alpineLa base
pgbounceredoburu/pgbouncer:v1.25.2-p0Pooler, mode transaction
rabbitmqrabbitmq:4-management-alpineBroker + console
valkeyvalkey/valkey:8-alpineCache, --appendonly yes
mailpitaxllent/mailpit:latestSMTP de test + webmail
prometheusprom/prometheus:v3.1.0Métriques de l'API
redisinsightredis/redisinsight:latestInspecter le cache
migrateimage du backendalembic upgrade head, puis s'arrête
apiimage du backendGunicorn / Uvicorn
workerimage du backendConsumer RabbitMQ
frontendimage du frontendPreact + serveur Vite (cible dev)

Le vrai sujet : l'ordre de démarrage#

Onze conteneurs qui démarrent en même temps, c'est onze façons d'échouer. L'API qui se connecte à une base pas encore prête, le worker qui déclare une file sur un broker en cours d'initialisation, les migrations qui partent avant que PostgreSQL n'accepte les connexions.

La réponse tient en deux mécanismes qui n'ont d'intérêt qu'ensemble.

1. Les healthchecks : « prêt » plutôt que « démarré »#

postgres:
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
    interval: 10s
    retries: 5

rabbitmq:
  healthcheck:
    test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
    interval: 15s
    retries: 5

valkey:
  healthcheck:
    test: ["CMD", "valkey-cli", "ping"]

Ces sondes ont une portée limitée : pg_isready vérifie l’acceptation des connexions, pas le schéma ni les droits applicatifs ; rabbitmq-diagnostics ping vérifie le processus Erlang et ne prouve pas qu’une file est disponible. Les dépendances Compose sont vérifiées au démarrage ; une panne ultérieure ne provoque pas automatiquement le redémarrage des services dépendants. Voir l’ordre de démarrage Compose.

2. depends_on avec condition#

C'est le point que la plupart des fichiers Compose ratent. Un depends_on nu ne fait qu'ordonner le lancement : Docker démarre le conteneur A avant le B, sans jamais vérifier que A fonctionne. Ici, chaque dépendance porte une condition :

api:
  depends_on:
    migrate:
      condition: service_completed_successfully   # le schéma est en place
    pgbouncer:
      condition: service_healthy                  # le pooler répond
    valkey:
      condition: service_healthy
    rabbitmq:
      condition: service_healthy
    mailpit:
      condition: service_started                  # démarré suffit
ConditionSignifieUtilisée pour
service_startedLe conteneur tourneMailpit — pas de sonde, et sa panne n'est pas bloquante
service_healthyLe healthcheck passepostgres, pgbouncer, valkey, rabbitmq, api
service_completed_successfullyLe conteneur s'est arrêté en succèsmigrate — une tâche, pas un service
La condition qui fait tout le travail service_completed_successfully sur migrate transforme un conteneur éphémère en barrière : ni l'API ni le worker ne démarrent tant que les migrations n'ont pas réussi. C'est exactement le rôle que joue le Job Alembic côté Kubernetes (ch. 01) — et deploy.sh le rend explicite avec un kubectl wait --for=condition=complete. Même problème, même solution, deux vocabulaires.

Le graphe de démarrage#

postgres valkey rabbitmq mailpit pgbouncer migrate api worker frontend tâche started healthy healthy completed successfully healthy Chaque flèche porte une condition : rien ne démarre « au cas où ».

Le worker est le plus contraint : il attend les migrations, le pooler, le broker, Mailpit… et l'API en bonne santé. Ce dernier lien n'est pas une nécessité technique — le worker ne parle jamais à l'API. C'est un choix de confort en développement : si l'API ne démarre pas, on veut le voir sans que les logs du worker ne défilent par-dessus.

Ce que Compose fait mieux que Kubernetes#

La comparaison n'est pas à sens unique, et il serait malhonnête de présenter le cluster comme un progrès sur toute la ligne.

Docker ComposeKubernetes (Kind)
Démarrerdocker compose upUn script, deux opérateurs à installer, des images à charger
Ordre de démarrageDéclaratif et garanti (depends_on + conditions)À reconstruire : wait, Jobs, initContainers
Rechargement à chaudUn bind-mount suffitRebuild + kind load + rollout restart
Lire les logsdocker compose logs -f apikubectl logs -f deployment/api, par pod
Haute disponibilité—Réplicas, failover, anti-affinité
Segmentation réseauRéseaux Docker, grossierNetworkPolicy par cible
Montée en chargeManuelleHPA

L'ordre de démarrage est le cas le plus frappant : Kubernetes n'a pas d'équivalent direct de depends_on: condition. Un pod démarre quand le scheduler le décide, et on rétablit la séquence avec des sondes de readiness, des initContainers, un Job barrière et des kubectl wait dans le script de déploiement. Ce que Compose exprime en trois lignes se répartit dans quatre mécanismes.

Les deux ne s'opposent pas, ils se succèdent Compose est l'environnement de développement : démarrage en secondes, boucle de retour immédiate, un seul fichier à lire. Kind est le banc d'essai des comportements d'exploitation : failover, drain, autoscaling, segmentation. Le premier sert tous les jours, le second sert quand on veut vérifier une hypothèse d'infrastructure.

Ce qu'on emporte vers le cluster#

Écrire le Compose d'abord n'est pas une étape jetable : les décisions qu'on y prend se retrouvent presque toutes, et c'est ce qui rend la traduction si rapide.

Décision prise dans ComposeCe qu'elle devient
Pooler en mode transactionLa même contrainte, et les mêmes trois réglages côté code (ch. 01)
migrate comme service éphémèreUn Job + un kubectl wait
Healthcheck HTTP de l'APIreadinessProbe et livenessProbe sur /health
Variables APP_*Une ConfigMap, plus les URLs interpolées (ch. 02)
Une image pour api / worker / migrateTrois workloads, une image (ch. 02)
Volumes nommésStockage géré par les opérateurs

La seule décision qui ne se transporte pas est justement l'ordre de démarrage : il faut le réexprimer autrement. Tout le reste est une traduction de vocabulaire.

À retenir#