Une histoire d'erreurs 502 mystérieuses, de conditions de concurrence et de l'art de l'orchestration de conteneurs
Le déploiement du vendredi soir
Il s'agissait d'un déploiement de routine. Je travaillais sur la Civic Platform, un projet open source qui, parti d'une simple idée, s'était transformé en une architecture monolithique modulaire complète. Six conteneurs Docker fonctionnaient en harmonie : traefik, postgres, visapi, identity, bff et webclient.
J'ai lancé docker-compose up -d, j'ai vu les conteneurs démarrer et je me suis rendu dans le panneau d'administration. Tout semblait correct. Tous les voyants étaient au vert. Puis j'ai essayé de me connecter.
Et c'est là que l'erreur est apparue : une erreur qui vous glace le sang.
IOException: IDX20807: Unable to retrieve document from:
'https://idp.localhost:4430/.well-known/openid-configuration'.
HttpResponseMessage: 'StatusCode: 502,
ReasonPhrase: 'Bad Gateway',
Version: 1.1,
Content: System.Net.Http.HttpConnectionResponseContent,
Headers:
{
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Date: Fri, 16 Jan 2026 15:35:22 GMT
Content-Length: 11
}', HttpResponseMessage.Content: 'Bad Gateway'.
502 Bad Gateway. Le fournisseur d'identité était injoignable. Mais attendez… je voyais le conteneur en cours d'exécution ! Que se passait-il ?
L'enquête : comprendre le problème
Après des heures passées à éplucher les journaux, à vérifier les configurations réseau et à remettre en question mes choix de vie, j'ai enfin trouvé le coupable. Le problème n'était pas que le conteneur Identity n'avait pas démarré, mais qu'il avait démarré trop bien.
Je m'explique. Mon fichier docker-compose.yml contenait une chaîne de dépendances soigneusement conçue :
services:
postgres:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
traefik:
image: traefik:v3.0
# ... traefik configuration
identity:
build: ./src/Identity
depends_on:
postgres:
condition: service_healthy
traefik:
condition: service_started
visapi:
build: ./src/VisApi
depends_on:
postgres:
condition: service_healthy
traefik:
condition: service_started
identity:
condition: service_started # ⚠️ Problème ici !
webclient:
build: ./src/WebClient
depends_on:
traefik:
condition: service_started
bff:
build: ./src/Bff
depends_on:
identity:
condition: service_started # ⚠️ Problème ici !
visapi:
condition: service_started
postgres:
condition: service_healthy
traefik:
condition: service_started
webclient:
condition: service_started
Vous voyez le problème ? Regardez ces conditions service_started. Docker Compose faisait exactement ce que je lui avais demandé : il attendait que les conteneurs démarrent. Or, démarrer un conteneur ne signifie pas avoir une application prête à l'emploi.
La condition de concurrence : un voyage visuel
Pour comprendre ce qui se passait, j'ai cartographié l'intégralité de la séquence de démarrage. Le diagramme suivant illustre la condition de concurrence en action :
sequenceDiagram
autonumber
participant DC as Docker Compose
participant PG as PostgreSQL
participant TR as Traefik
participant ID as Identity
participant API as VisAPI
participant WC as WebClient
participant BFF as BFF
participant U as User
Note over DC,U: 🚀 Exécution de docker-compose up
DC->>PG: Conteneur de démarrage
activate PG
PG-->>DC: ✅ healthy (port 5432 prête)
deactivate PG
DC->>TR: Conteneur de démarrage
activate TR
TR-->>DC: ✅ service_started
deactivate TR
Note over DC,ID: PostgreSQL healthy + Traefik started → Start Identity
DC->>ID: Conteneur de démarrage
activate ID
ID-->>DC: ✅ service_started
Note right of ID: ⚠️ Le conteneur a démarré, <br/>mais l'application est toujours <br/>en cours d'initialisation...
rect rgb(255, 230, 230)
Note over ID: 🔄 Conteneur d'identité interne:
ID->>ID: 1. Initialisation du runtime .NET
ID->>ID: 2. configuration du conteneur DI
ID->>PG: 3. Migrations de bases de données
ID->>PG: 4. 🌱 ENRICHIR LES DONNÉES UTILISATEUR
Note over ID,PG: ⏳ Ce processus<br/> prend plusieurs secondes !
end
Note over DC,API: Identity "started" → Start VisAPI
DC->>API: Conteneur de démarrage
activate API
API-->>DC: ✅ service_started
deactivate API
DC->>WC: Conteneur de démarrage
activate WC
WC-->>DC: ✅ service_started
deactivate WC
Note over DC,BFF: All dependencies "started" → Start BFF
DC->>BFF: Conteneur de démarrage
activate BFF
BFF-->>DC: ✅ service_started
deactivate BFF
Note over U,BFF: 🌐 L'utilisateur ouvre le navigateur
U->>BFF: Login request
BFF->>ID: Redirect to Identity
rect rgb(255, 200, 200)
Note over ID,U: ❌ PROBLEM!
ID--xU: 🚫 La connexion a échoué!
Note over ID: Les utilisateurs n'ont pas encore été ajoutés <br/>à la base de données !
end
Note over ID: ...ensemencement toujours en cours...
ID->>PG: 🌱 Termine les données d'ensemencement
ID-->>ID: ✅ Application entièrement prête
deactivate ID
Note over DC,U: ⏰ Quelques secondes plus tard...
U->>BFF: Réessayer la demande de connexion
BFF->>ID: Redirection vers l'identité
ID->>PG: Vérifier l'utilisateur
PG-->>ID: ✅ L'utilisateur existe
ID-->>U: ✅ Connexion réussie
Le diagramme révèle la vérité : Docker Compose a constaté que le conteneur Identity avait démarré (le processus était en cours d'exécution), mais à l'intérieur de ce conteneur, l'application .NET était encore en cours d'initialisation, d'exécution des migrations et, surtout, d'insertion des données utilisateur dans la base de données. Lorsque le conteneur BFF a tenté de rediriger l'authentification vers Identity, personne n'était encore connecté.
Les deux types de « Ready »
Cette expérience m'a appris une leçon cruciale concernant l'orchestration des conteneurs : le mot « prêt » a deux significations très différentes :
- Container Ready — Le processus du conteneur est en cours d'exécution. C'est ce que vérifie
service_started. - Application Ready — L'application contenue dans le conteneur est entièrement initialisée et peut traiter les requêtes. Cela nécessite un health check correct (
service_healthy).
C'est dans l'écart entre ces deux états que se cachent les bugs — silencieux, intermittents et incroyablement frustrants à déboguer.
La solution : Mettre en œuvre des Health Checks
La correction a nécessité des modifications dans trois domaines : le code de l'application .NET, le Dockerfile et le fichier docker-compose.yml. Examinons-les un par un.
Étape 1 : Création d'un contrôle d'intégrité personnalisé
Voici l'élément clé : un health check permettant de savoir si l'amorçage de notre base de données est terminé. Le secret réside dans la méthode statique MarkAsSeeded() — notre processus d'initialisation l'appellera une fois terminé, faisant passer l'état de santé de « non sain » à « sain ».
public class IdentitySeededHealthCheck : IHealthCheck
{
private static readonly Lock _lock = new();
private static bool _isSeeded;
public static void MarkAsSeeded()
{
lock (_lock)
{
_isSeeded = true;
}
}
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context,
CancellationToken cancellationToken = new CancellationToken())
{
if (_isSeeded)
{
return Task.FromResult(HealthCheckResult.Healthy(
"Identity has been seeded successfully."));
}
return Task.FromResult(HealthCheckResult.Unhealthy(
"Identity seeding is still in progress."));
}
}
Étape 2 : Enregistrement du Health Check et configuration des endpoints
Nous devons enregistrer notre health check personnalisé et configurer les endpoints /health/ready et /health/live. Notez que nous avons deux points de terminaison : /health/live (pour vérifier l'état de fonctionnement – le processus est-il en cours d'exécution ?) et /health/ready (pour vérifier l'état de disponibilité – l'application est-elle prête à traiter les requêtes ?). Cette distinction est conforme aux conventions Kubernetes et constitue une bonne pratique, même en dehors des environnements K8s.
// Enregistrement du health check dans ConfigureServices
services.AddHealthChecks()
.AddCheck<IdentitySeededHealthCheck>("identity-seeded",
HealthStatus.Unhealthy,
tags: ["ready"]);
// Configuration des endpoints dans ConfigurePipeline
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false
});
Étape 3 : Signalement de la fin du seeding
Le seeder doit signaler une fois l'opération terminée. Voici comment le Worker appelle MarkAsSeeded() après avoir terminé toutes les opérations d'initialisation :
public class Worker : IHostedService
{
private readonly IServiceProvider _serviceProvider;
public Worker(IServiceProvider serviceProvider)
=> _serviceProvider = serviceProvider;
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<VisionnairesDbContext>();
// Exécution des migrations
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(cancellationToken);
if (pendingMigrations.Any())
{
await context.Database.MigrateAsync(cancellationToken);
}
// Enregistrement des applications et des utilisateurs
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
await RegisterApplicationsAsync(scope.ServiceProvider, configuration);
await RegisterScopesAsync(scope.ServiceProvider, configuration);
await SeedRolesAndUsersAsync(scope.ServiceProvider, configuration);
// ✅ Signal que l'initialisation est terminée
IdentitySeededHealthCheck.MarkAsSeeded();
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Pour l'API, on utilise un callback dans la méthode SeedAsync :
public async Task SeedAsync(Action? onSeeded = null, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Starting database seeding...");
try
{
await SeedCategoriesAsync(cancellationToken);
await SeedSubcategoriesAsync(cancellationToken);
_logger.LogInformation("Database seeding completed successfully.");
onSeeded?.Invoke(); // ✅ Appel du callback pour signaler la fin
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while seeding the database.");
throw;
}
}
Et dans Program.cs :
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<VisionnairesDbContext>();
db.Database.Migrate();
var seeder = scope.ServiceProvider.GetRequiredService<DatabaseSeeder>();
await seeder.SeedAsync(DatabaseSeededHealthCheck.MarkAsSeeded);
}
Étape 4 : Mise à jour du Dockerfile
Pour que Docker puisse effectuer des contrôles d'intégrité, nous avons besoin que curl soit disponible dans notre conteneur :
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER root
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
EXPOSE 8080
# ... reste du Dockerfile
Étape 5 : Le fichier docker-compose.yml final
Enfin, nous mettons à jour notre fichier docker-compose.yml avec les contrôles d'intégrité appropriés et les conditions service_healthy :
services:
postgres:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB -h 127.0.0.1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# ...
visapi:
build:
context: .
dockerfile: src/Visionnaires.API/Dockerfile
depends_on:
postgres:
condition: service_healthy
traefik:
condition: service_started
identity:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# ...
bff:
build:
context: .
dockerfile: src/Visionnaires.BFF/Dockerfile
depends_on:
identity:
condition: service_started
visapi:
condition: service_healthy # ✅ Attend que visapi soit vraiment prêt
postgres:
condition: service_healthy
traefik:
condition: service_started
webclient:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# ...
Comprendre les principaux changements
Analysons les modifications apportées à la configuration docker-compose :
- healthcheck.test : Utilise curl pour interroger le point de terminaison
/health/ready. Docker considère le conteneur comme sain uniquement si cette requête renvoie un statut HTTP positif. - healthcheck.interval : Fréquence des vérifications (10 secondes).
- healthcheck.timeout : Délai maximal d'attente d'une réponse (5 secondes).
- healthcheck.retries : Nombre d'échecs consécutifs avant de considérer le conteneur comme non sain.
- healthcheck.start_period : Période de grâce au démarrage pendant laquelle les échecs ne sont pas comptabilisés (30 secondes pour les applications .NET qui nécessitent plus de temps pour l'initialisation).
- condition: service_healthy : Le service dépendant attendra la réussite du test d'intégrité, et non le démarrage du conteneur.
Tester le Health Check
Une fois que tout est en place, vous pouvez tester les endpoints de santé. Pour l'API Visionnaires :
curl http://localhost:8080/health/ready
Lorsque l'application est entièrement prête, vous verrez :
{
"status": "Healthy",
"entries": {
"database-seeded": {
"status": "Healthy",
"description": "Database has been seeded successfully."
}
}
}
Pour le fournisseur d'identité :
curl http://localhost:8080/health/ready
{
"status": "Healthy",
"entries": {
"identity-seeded": {
"status": "Healthy",
"description": "Identity has been seeded successfully."
}
}
}
Si l'amorçage est encore en cours, vous obtiendrez une réponse Unhealthy :
{
"status": "Unhealthy",
"entries": {
"identity-seeded": {
"status": "Unhealthy",
"description": "Identity seeding is still in progress."
}
}
}
Leçons apprises
Cette expérience de débogage m'a permis de tirer plusieurs enseignements précieux :
- « Started » ≠ « Ready » — Ne présumez jamais qu'un conteneur est prêt simplement parce qu'il est en cours d'exécution. Mettez toujours en place des contrôles d'intégrité appropriés.
- Les contrôles d'intégrité doivent être pertinents — Un contrôle d'intégrité qui renvoie simplement le code 200 est inutile. Il doit vérifier que toutes les dépendances critiques et les étapes d'initialisation sont terminées.
- Distinguer l'état de fonctionnement de l'état de préparation — Votre application peut être en fonctionnement (processus en cours d'exécution) mais pas prête (initialisation en cours). Ce sont des états différents qui requièrent des points de terminaison différents.
- Consignez votre séquence de démarrage — Une journalisation détaillée pendant l'initialisation est essentielle pour le débogage des problèmes de synchronisation.
- Visualisez vos dépendances — Représenter la séquence de démarrage (comme avec le diagramme Mermaid) peut révéler des conditions de concurrence qui ne sont pas évidentes à la simple lecture du code.
Conclusion
Les contrôles d'intégrité peuvent paraître un détail, mais c'est ce qui fait la différence entre une application fiable et une autre qui plante mystérieusement à chaque nouveau déploiement. Les quelques heures que j'ai consacrées à la mise en place de contrôles d'intégrité adéquats m'ont épargné d'innombrables heures de débogage depuis.
Si vous utilisez des microservices dans Docker — surtout s'ils ont des dépendances au démarrage comme les migrations de base de données ou l'initialisation des données — faites-vous une faveur : mettez en place des contrôles d'intégrité avant d'en avoir besoin. Vous vous en féliciterez plus tard.
La plateforme Civic est open source et vous trouverez l'implémentation complète sur notre dépôt GitHub. Si vous avez rencontré des problèmes similaires ou si vous avez d'autres approches pour les contrôles d'intégrité des conteneurs, n'hésitez pas à les partager dans les commentaires.
Happy containerizing!