Vous êtes de garde. Le tableau de bord est rouge. Les utilisateurs disent « le site est en panne », et la seule chose que Nginx vous renvoie est un petit 502 ou 504 suffisant. L’équipe backend jure qu’il n’y a rien de changé. L’hôte Docker a l’air « correct ». Et pourtant la production est clairement en panne.
C’est là que les gens perdent des heures à regarder les mauvais logs. L’astuce est ennuyeuse : consignez les bons champs upstream, prouvez quelle étape a échoué, puis réparez la chose réellement cassée. Pas « redémarrer tout », la chose spécifique.
Un modèle mental : ce que signifient vraiment 502 et 504 dans Docker + Nginx
Commencez par la discipline : un 502/504 est rarement « un problème Nginx ». Habituellement, Nginx est le messager coincé entre un client et un upstream (votre appli) et il a des justificatifs.
502 Bad Gateway : Nginx n’a pas pu obtenir une réponse valide de l’upstream
En pratique, avec Docker, un 502 signifie souvent l’un de ces cas :
- Échec de connexion : Nginx n’a pas pu se connecter à l’IP:port de l’upstream (conteneur arrêté, mauvais port, mauvais réseau, règles de pare-feu, DNS pointant vers une IP obsolète).
- Upstream fermé prématurément : Nginx s’est connecté, a envoyé la requête, et l’upstream a fermé avant d’envoyer une réponse HTTP correcte (crash, OOM kill, bug applicatif, incompatibilité proxy protocol, mismatch TLS).
- Incompatibilité de protocole : Nginx attend HTTP mais l’upstream parle HTTPS, gRPC, FastCGI ou TCP brut ; ou attend HTTP/1.1 keepalive alors que l’upstream ne peut pas le gérer.
504 Gateway Timeout : Nginx s’est connecté mais n’a pas reçu de réponse à temps
Un 504 est généralement plus lent et plus sournois : Nginx a établi la connexion avec l’upstream, mais n’a pas reçu la réponse (ou les en-têtes) dans les délais configurés. Ce n’est pas toujours « l’appli est lente ». Cela peut aussi être :
- Upstream surchargé : pool de threads épuisé, pool DB saturé, boucle d’événements bloquée, ou CPU throttlé dans des cgroups.
- Ralentissements réseau : perte de paquets, épuisement de conntrack, bizarreries du bridge Docker sous charge, ou mismatch MTU qui affecte seulement les grosses réponses.
- Timeouts désynchronisés avec la réalité : Nginx attend une réponse en 60s, mais l’appli a légitimement besoin de 120s pour certaines charges, et vous n’aviez pas l’intention de proxyfier ces requêtes via Nginx.
Une autre idée de cadrage : Nginx a trois horloges pendant le proxying — le temps de connexion, le temps jusqu’au premier octet (en-têtes), et le temps pour finir la lecture de la réponse. Si vous ne consignez pas ces trois valeurs, vous déboguez à l’aveugle.
Idée paraphrasée de Werner Vogels (CTO Amazon) : « You build it, you run it » signifie posséder la réalité opérationnelle, pas seulement livrer du code.
Petite blague n°1 : Un 502, c’est Nginx qui dit « J’ai essayé d’appeler votre appli, mais elle est passée sur la messagerie. »
Feuille de route de diagnostic rapide (vérifier premier/deuxième/troisième)
C’est l’ordre qui trouve rapidement le goulot d’étranglement sans transformer votre canal d’incident en groupe de thérapie.
Premier : prouver quelle étape échoue
- Client → Nginx : Est-ce que Nginx reçoit les requêtes ? Vérifiez les access logs et la corrélation
$request_id. - Nginx → upstream : Est-ce une connexion qui échoue (502) ou un timeout (504) ? Cherchez « connect() failed » vs « upstream timed out ».
- Upstream → ses dépendances : DB, cache, queue, autres services HTTP. Vous n’avez pas besoin du tracing complet pour confirmer l’évidence : les timeouts des dépendances montent quand les 504 montent.
Deuxième : capturer les bons timestamps et timings upstream
- Ajoutez (ou vérifiez) dans l’access log Nginx les champs :
$upstream_addr,$upstream_status,$upstream_connect_time,$upstream_header_time,$upstream_response_time,$request_time. - Dans Docker, confirmez que les redémarrages/OOM des conteneurs correspondent aux pics de 502.
- Confirmez si les erreurs sont par instance upstream (un conteneur défectueux) ou systémiques (tous les conteneurs lents).
Troisième : décider s’il faut ajuster les timeouts ou corriger l’upstream
- Si
$upstream_connect_timeest élevé ou absent : corrigez le réseau, le discovery de service, les ports, la santé du conteneur, la capacité. - Si
$upstream_header_timeest élevé : l’upstream met du temps à commencer à répondre ; vérifiez la latence de l’application et ses dépendances. - Si les en-têtes arrivent vite mais que
$upstream_response_timeest énorme : le streaming de la réponse est lent ; vérifiez la taille des payloads, le buffering, les clients lents, et les limites de débit.
Les timeouts ne sont pas une stratégie de performance. Ce sont un contrat. Ne les changez qu’après avoir compris ce que vous signez.
Obtenir les bons logs : Nginx, Docker et l’application
Journal d’erreurs Nginx : où commence la vérité
Si vous ne regardez que les access logs Nginx, vous verrez les codes de statut mais pas le pourquoi. Le journal d’erreurs contient le mode de défaillance upstream : connection refused, no route to host, upstream prematurely closed, upstream timed out, resolver failure.
Dans un conteneur, assurez-vous que Nginx écrit les error logs sur stdout/stderr ou sur un volume monté. S’il écrit dans /var/log/nginx/error.log à l’intérieur d’un conteneur sans volume, vous pouvez toujours le lire via docker exec, mais l’ergonomie en incident est mauvaise.
Access log Nginx : où vous apprenez des patterns
Les access logs sont l’endroit idéal pour répondre aux questions comme : « Est-ce tous les endpoints ou un seul ? » et « Est-ce une instance upstream ? » Mais seulement si vous consignez les champs upstream.
Position subjective : loggez en JSON. Les humains peuvent toujours le lire, et les machines le lisent sans douleur. Si vous ne pouvez pas changer le format aujourd’hui, ajoutez au moins les variables de timing upstream à votre format existant.
Logs Docker : le conteneur ment à moins que vous regardiez
Des rafales de 502 qui coïncident avec des redémarrages de conteneurs ne sont pas un mystère. Ce sont une timeline. Docker vous dit quand un conteneur a redémarré, s’il a été OOM-killé, et si les health checks échouent.
Logs applicatifs : confirmer que l’upstream a reçu la requête
Les logs de votre appli doivent répondre : la requête est-elle arrivée, quel chemin, quelle latence, quelle erreur. Si vous pouvez ajouter un header d’ID de requête (par ex. X-Request-ID) depuis Nginx vers l’upstream et le logger, vous arrêterez les disputes et commencerez à réparer.
Petite blague n°2 : « Upstream timed out » est la version opérationnelle de « je vous recontacte » de ce fournisseur qui ne le fait jamais.
Tâches pratiques (commandes, signification des sorties, décision)
Voici les tâches que j’exécute réellement quand Nginx dans Docker commence à renvoyer des 502/504. Chaque tâche inclut : commande, ce que signifie une sortie typique, et la décision suivante.
Task 1: Confirm Nginx is the component returning 502/504
cr0x@server:~$ curl -sS -D- -o /dev/null http://127.0.0.1/health
HTTP/1.1 502 Bad Gateway
Server: nginx/1.25.3
Date: Sat, 03 Jan 2026 10:12:01 GMT
Content-Type: text/html
Content-Length: 157
Connection: keep-alive
What it means: The response is coming from Nginx (Server header). Not from the app.
Decision: Go to Nginx error logs next; don’t waste time hunting in app logs yet.
Task 2: Read Nginx error logs from the container
cr0x@server:~$ docker logs --tail=200 -f nginx
2026/01/03 10:11:58 [error] 28#28: *194 connect() failed (111: Connection refused) while connecting to upstream, client: 10.0.2.15, server: _, request: "GET /health HTTP/1.1", upstream: "http://172.19.0.5:8080/health", host: "example.internal"
What it means: Nginx tried to connect to the upstream IP:port and got Connection refused. The container exists on the network, but nothing is listening on that port (or it’s not ready).
Decision: Check upstream container status, port mapping, and whether the app process is listening.
Task 3: Identify which upstream Nginx is trying to reach
cr0x@server:~$ docker exec -it nginx nginx -T 2>/dev/null | sed -n '1,180p'
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
events { worker_connections 1024; }
http {
upstream app_upstream {
server app:8080;
keepalive 32;
}
server {
listen 80;
location / {
proxy_set_header Host $host;
proxy_set_header X-Request-ID $request_id;
proxy_pass http://app_upstream;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
}
}
What it means: Upstream uses the Docker DNS name app on port 8080, with keepalive enabled.
Decision: Validate Docker DNS resolution and confirm the app listens on 8080 inside its container. Also keep keepalive in mind; it can amplify certain app bugs.
Task 4: Validate DNS inside the Nginx container
cr0x@server:~$ docker exec -it nginx getent hosts app
172.19.0.5 app
What it means: Docker’s embedded DNS resolves app to an IP. If this fails, Nginx may be resolving at startup only or not at all (depending on config).
Decision: If DNS fails: fix container networks, service name, or resolver configuration. If DNS works: move to connectivity and listening checks.
Task 5: Test connectivity from Nginx to upstream port
cr0x@server:~$ docker exec -it nginx bash -lc 'nc -vz -w2 app 8080'
nc: connect to app (172.19.0.5) port 8080 (tcp) failed: Connection refused
What it means: IP is reachable, but nothing is accepting connections on 8080.
Decision: Check the app container: is it running, listening on the right interface, and not crash-looping?
Task 6: Inspect upstream container health and restarts
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
NAMES STATUS PORTS
nginx Up 2 hours 0.0.0.0:80->80/tcp
app Restarting (1) 12 seconds ago
What it means: The app is restart-looping. Nginx is innocent; it can’t talk to something that can’t stay up.
Decision: Pull app logs and look for crash cause (config error, missing secret, OOM, migrations, dependency refusal).
Task 7: Look for OOM kills (classic 502 generator)
cr0x@server:~$ docker inspect app --format '{{json .State}}'
{"Status":"restarting","Running":false,"Paused":false,"Restarting":true,"OOMKilled":true,"Dead":false,"Pid":0,"ExitCode":137,"Error":"","StartedAt":"2026-01-03T10:11:42.020785322Z","FinishedAt":"2026-01-03T10:11:52.901123812Z","Health":null}
What it means: OOMKilled:true and exit code 137. The kernel killed the process. Nginx is just reporting the aftermath.
Decision: Add memory, lower workload, fix memory leak, or set sane limits and autoscaling. Also consider reducing Nginx buffering for huge responses only if you understand the tradeoffs.
Task 8: Correlate 502/504 spikes with container restarts in the Docker event stream
cr0x@server:~$ docker events --since 30m --filter container=app
2026-01-03T10:02:11.000000000Z container die 1f2a3b4c5d (exitCode=137, image=app:prod, name=app)
2026-01-03T10:02:12.000000000Z container start 1f2a3b4c5d (image=app:prod, name=app)
2026-01-03T10:11:52.000000000Z container die 1f2a3b4c5d (exitCode=137, image=app:prod, name=app)
2026-01-03T10:11:53.000000000Z container start 1f2a3b4c5d (image=app:prod, name=app)
What it means: The app died twice in 30 minutes. If your 502s align with these timestamps, you’ve got causality, not vibes.
Decision: Focus on why the app dies. Don’t tune Nginx timeouts; that’s not the problem.
Task 9: If it’s 504, log timings and verify where time is spent
cr0x@server:~$ docker exec -it nginx awk 'NR==1{print; exit}' /var/log/nginx/access.log
10.0.2.15 - - [03/Jan/2026:10:14:09 +0000] "GET /api/report HTTP/1.1" 504 564 "-" "curl/8.5.0" rt=60.001 uct=0.001 uht=60.000 urt=60.000 ua="172.19.0.5:8080" us="504"
What it means: uct (connect) is fast, but uht (time to headers) hit 60s, matching proxy_read_timeout. The upstream accepted the connection but didn’t produce headers in time.
Decision: This is upstream slowness or deadlock, not networking. Check app latency, dependency calls, worker exhaustion, and DB.
Task 10: Confirm Nginx timeout configuration that actually triggered the 504
cr0x@server:~$ docker exec -it nginx nginx -T 2>/dev/null | grep -R --line-number -E 'proxy_(connect|read|send)_timeout|send_timeout' -
69: proxy_connect_timeout 5s;
70: proxy_read_timeout 60s;
71: proxy_send_timeout 60s;
What it means: Nginx will give upstream 60s to respond (headers count). That’s your contract today.
Decision: If 60s is too low for a legitimate endpoint, split routes: raise timeout only for that location, or redesign the endpoint (async job + polling). Avoid global timeout inflation.
Task 11: Validate upstream app is listening on the expected port inside its container
cr0x@server:~$ docker exec -it app bash -lc 'ss -lntp | head -n 5'
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("app",pid=1,fd=7))
What it means: The app is listening on 0.0.0.0:8080, which is correct for container networking.
Decision: If it were listening only on 127.0.0.1, Nginx in another container would get connection refused. Fix the app bind address.
Task 12: Confirm the Nginx container and app container share a Docker network
cr0x@server:~$ docker inspect nginx --format '{{json .NetworkSettings.Networks}}'
{"frontend":{"IPAMConfig":null,"Links":null,"Aliases":["nginx"],"NetworkID":"8c1d...","EndpointID":"4a2b...","Gateway":"172.19.0.1","IPAddress":"172.19.0.3","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"MacAddress":"02:42:ac:13:00:03","DriverOpts":null}}
cr0x@server:~$ docker inspect app --format '{{json .NetworkSettings.Networks}}'
{"frontend":{"IPAMConfig":null,"Links":null,"Aliases":["app"],"NetworkID":"8c1d...","EndpointID":"9f8e...","Gateway":"172.19.0.1","IPAddress":"172.19.0.5","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"MacAddress":"02:42:ac:13:00:05","DriverOpts":null}}
What it means: Both are on the frontend network. Connectivity should be possible.
Decision: If networks differ, fix Compose networks or connect containers to the same user-defined network. Don’t use legacy --link.
Task 13: Check Docker embedded DNS behavior and Nginx name resolution pitfalls
cr0x@server:~$ docker exec -it nginx cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0
What it means: Docker DNS is in use. Nginx resolves upstream names depending on how you configured it. If you put a hostname in an upstream { server app:8080; } block, Nginx typically resolves it at startup and caches it.
Decision: If containers churn and IPs change, either reload Nginx when upstream IPs change (common in Compose) or use dynamic resolution patterns (e.g., resolver 127.0.0.11 plus variables in proxy_pass) with care.
Task 14: Detect upstream keepalive reuse issues (stale connections)
cr0x@server:~$ docker logs --tail=200 nginx | grep -E 'upstream prematurely closed|recv\(\) failed|reset by peer' | head
2026/01/03 10:20:31 [error] 28#28: *722 upstream prematurely closed connection while reading response header from upstream, client: 10.0.2.15, server: _, request: "GET /api HTTP/1.1", upstream: "http://172.19.0.5:8080/api", host: "example.internal"
What it means: The upstream closed the connection unexpectedly while Nginx waited for headers. This can be app crashes, but it can also be keepalive + upstream idle timeout mismatches.
Decision: Compare Nginx keepalive settings with upstream server idle timeouts. Consider disabling upstream keepalive temporarily to test if errors stop; then fix properly (align timeouts, tune keepalive_requests, etc.).
Task 15: Check host-level pressure that makes everything “randomly” slow
cr0x@server:~$ uptime
10:24:02 up 41 days, 4:11, 2 users, load average: 18.42, 17.90, 16.55
What it means: High load average can indicate CPU saturation, runnable queue backlog, or blocked I/O. In container land, this can manifest as 504s because the upstream can’t schedule.
Decision: Check CPU and memory next; if the host is saturated, no amount of Nginx timeout tuning will “fix” it.
Task 16: See per-container CPU/memory pressure in real time
cr0x@server:~$ docker stats --no-stream
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O
a1b2c3d4e5f6 nginx 2.15% 78.2MiB / 512MiB 15.27% 1.2GB / 1.1GB 12.3MB / 8.1MB
1f2a3b4c5d6e app 380.44% 1.95GiB / 2.00GiB 97.50% 900MB / 1.3GB 1.1GB / 220MB
What it means: The app is pegging CPU and nearly OOM. Expect latency and restarts. This directly produces 504 (slow) and 502 (crash).
Decision: Add capacity, fix memory usage, add caching, reduce concurrency, or fix the query. But do one thing at a time.
Task 17: Verify connection tracking exhaustion (a sneaky 502/504 source under load)
cr0x@server:~$ sudo sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
net.netfilter.nf_conntrack_count = 262119
net.netfilter.nf_conntrack_max = 262144
What it means: You’re near conntrack max. New connections can fail or stall; Nginx sees connect errors or timeouts.
Decision: Increase conntrack max (with memory awareness), reduce connection churn (keepalive, pooling), or scale out. Also check for connection leaks.
Task 18: Validate that Nginx is logging upstream timings (or fix it)
cr0x@server:~$ docker exec -it nginx grep -R --line-number 'log_format' /etc/nginx/nginx.conf /etc/nginx/conf.d 2>/dev/null
/etc/nginx/nginx.conf:15:log_format upstream_timing '$remote_addr - $request_id [$time_local] '
/etc/nginx/nginx.conf:16: '"$request" $status rt=$request_time uct=$upstream_connect_time '
/etc/nginx/nginx.conf:17: 'uht=$upstream_header_time urt=$upstream_response_time ua="$upstream_addr" us="$upstream_status"';
What it means: You have the key timing variables. Good. Now use them.
Decision: If missing, add them and reload Nginx. Without upstream timing, you’ll misdiagnose 504s.
Task 19: Reload Nginx safely after config changes
cr0x@server:~$ docker exec -it nginx nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
cr0x@server:~$ docker exec -it nginx nginx -s reload
What it means: Syntax is valid; reload will apply changes without dropping existing connections (in most typical setups).
Decision: Prefer reload over container restarts in the middle of an incident unless the process is wedged.
Task 20: Prove the upstream is slow using direct curls from the Nginx network namespace
cr0x@server:~$ docker exec -it nginx bash -lc 'time curl -sS -o /dev/null -w "status=%{http_code} ttfb=%{time_starttransfer} total=%{time_total}\n" http://app:8080/api/report'
status=200 ttfb=59.842 total=59.997
real 1m0.010s
user 0m0.005s
sys 0m0.010s
What it means: The upstream itself takes ~60s to first byte and total. Nginx’s 60s proxy_read_timeout is right on the edge; a little jitter causes 504.
Decision: Fix the upstream performance or redesign the endpoint. Raising timeouts may stop the bleeding but can also pile up connections and increase blast radius.
Erreurs courantes : symptôme → cause racine → correction
Cette section existe parce que la plupart des « erreurs upstream Nginx » sont auto-infligées. En voici les récurrentes dans les environnements conteneurisés.
1) Symptom: 502 with “connect() failed (111: Connection refused)”
- Root cause: Upstream container is restarting/crashed; app is listening on a different port; app binds to
127.0.0.1inside container. - Fix: Confirm
ss -lntpin the app container, fix bind address to0.0.0.0, fix port in Nginx/Compose, and add health checks so Nginx doesn’t route to dead containers.
2) Symptom: 502 with “no live upstreams”
- Root cause: All upstream servers marked down by Nginx (failed checks or max_fails), or upstream name failed to resolve at startup.
- Fix: Ensure Nginx can resolve the service name at startup; reload Nginx after network changes; validate upstream entries. If you’re doing blue/green, don’t leave Nginx pointing at the retired name.
3) Symptom: 504 with “upstream timed out (110: Connection timed out) while reading response header”
- Root cause: Upstream is slow to produce headers; thread pool or event loop is blocked; DB queries are slow; upstream is CPU-throttled.
- Fix: Log
$upstream_header_time. If it’s high, optimize upstream and dependencies. Raiseproxy_read_timeoutonly for endpoints that genuinely require it.
4) Symptom: 502 with “upstream prematurely closed connection”
- Root cause: App crashes mid-request; upstream keepalive idle timeout shorter than Nginx reuse window; buggy proxy protocol/TLS mismatch.
- Fix: Check app logs for crashes. Temporarily disable upstream keepalive to validate. Align timeouts and consider limiting keepalive reuse via
keepalive_requestson the upstream.
5) Symptom: 502 only during deploys
- Root cause: Containers stop before new ones are ready; no readiness gate; Nginx resolves to an IP for a container that just got replaced.
- Fix: Add readiness endpoints and health checks. In Compose, stagger restarts and reload Nginx if you rely on name resolution at startup. Prefer a stable service VIP (in orchestrators) or a proxy that does dynamic resolution properly.
6) Symptom: 504s spike but app logs look “fine”
- Root cause: Requests never reach the app (stuck in Nginx queue, conntrack exhaustion, SYN backlog issues, or network stalls). Or the app is dropping logs under pressure.
- Fix: Compare Nginx access logs with app request logs using request IDs. Check conntrack and host saturation. Confirm app logging isn’t buffered to death.
7) Symptom: Random 502s under load, disappears when you scale up
- Root cause: File descriptor exhaustion, ephemeral port exhaustion, NAT table pressure, or a slow-loris style client causing resource contention.
- Fix: Check
ulimit -nand open files, tune worker connections, enforce sane client timeouts, and keep an eye on conntrack.
8) Symptom: 502 after enabling HTTP/2 or TLS changes
- Root cause: Misconfigured upstream protocol expectations (proxying to HTTPS upstream without
proxy_sslsettings, or speaking HTTP to a TLS port). - Fix: Validate upstream scheme and ports, test directly with curl from inside the Nginx container, and ensure the upstream is actually HTTP where you think it is.
Trois mini-récits d’entreprise depuis le terrain
Mini-récit 1 : L’incident causé par une mauvaise hypothèse
Une entreprise exécutait un simple stack Docker Compose : reverse proxy Nginx, un conteneur API Node.js, et un conteneur Redis. Un lundi, ils ont commencé à voir un mur propre de 502. Leur première hypothèse, universelle et erronée, était : « Nginx ne peut pas résoudre le nom upstream. » Ils ont donc modifié la config Nginx pour hardcoder l’IP upstream qu’ils voyaient dans docker inspect. Ça a « marché » pendant dix minutes.
Puis ça a planté à nouveau. Fort. Parce que le conteneur API était en crash-loop ; chaque redémarrage prenait une nouvelle IP, et leur « correction » épinglait Nginx à l’adresse d’hier. Le journal d’erreurs racontait l’histoire tout du long : connect() failed (111: Connection refused). Ce n’était pas le DNS. C’était un port sans process à l’écoute.
Ils ont finalement regardé docker inspect et remarqué OOMKilled:true. Le conteneur API avait une limite mémoire trop basse, et une nouvelle fonctionnalité créait un cache mémoire plus grand sous un certain pattern de requêtes. Sous charge, le kernel le tuait. Nginx n’était pas cassé ; il routait de façon consistante vers un service qui n’était pas constamment vivant.
La correction fut ennuyeuse : réduire l’empreinte mémoire, augmenter la limite mémoire du conteneur pour correspondre aux pics réalistes, et ajouter un endpoint de readiness pour que le proxy n’envoie pas de trafic avant que l’appli soit prête. Ils ont aussi cessé de hardcoder les IPs des conteneurs — parce que c’est comme transformer un incident simple en passe-temps récurrent.
Mini-récit 2 : L’optimisation qui s’est retournée contre eux
Une autre organisation avait une initiative de performance : « réduire la latence en activant keepalive partout. » Quelqu’un a ajouté keepalive 128; dans le bloc upstream Nginx. Ils ont aussi augmenté worker_connections. Sur le papier, ça semblait du gain gratuit.
Deux semaines plus tard, des 502 intermittents sont apparus : « upstream prematurely closed connection. » Ils étaient suffisamment rares pour éviter une surveillance basique, mais assez fréquents pour agacer les clients. Le service backend était une appli Java avec un serveur embarqué ayant un timeout idle inférieur à la fenêtre de réutilisation des connexions de Nginx. Nginx réutilisait une connexion que l’upstream avait déjà fermée. Parfois la course tournait en faveur de Nginx, parfois non.
La première réponse de l’équipe fut classique : augmenter les timeouts. Ça réduisit la fréquence du symptôme… et augmenta l’utilisation des ressources. Il y avait maintenant plus de connexions upstream inactives qui consommaient des file descriptors et de la mémoire. Sous charge, le proxy a commencé à peiner, et la latence a augmenté.
La correction n’était pas « plus de keepalive ». La correction fut d’aligner le comportement keepalive de bout en bout : réduire le keepalive upstream de Nginx, aligner les idle timeouts, et limiter la réutilisation avec keepalive_requests. Ils ont aussi ajouté des logs de timing upstream, pour que les échecs futurs montrent si le coût venait du connect ou de l’attente d’en-tête. L’optimisation redevint un outil contrôlé au lieu d’une superstition.
Mini-récit 3 : La pratique ennuyeuse mais correcte qui a sauvé la mise
Une équipe de services financiers avait une politique presque comiquement peu glamour : chaque reverse proxy doit logger les timings upstream et les codes d’état upstream, et chaque requête doit porter un request ID. C’était appliqué en revue de code. Personne n’aimait ça. Puis ils ont cessé de râler.
Pendant une release trimestrielle, ils ont vu une vague de 504 sur un seul endpoint. Le de garde a extrait les access logs Nginx et filtré par chemin. Le format de ligne incluait uct, uht, et urt, plus upstream_status. En quelques minutes, ils ont trouvé un motif : le temps de connexion était bas, le temps jusqu’à l’en-tête montait en flèche, et le statut upstream était absent sur certaines requêtes — signifiant que Nginx n’avait jamais reçu d’en-têtes.
Ils ont pivotté : pas un problème réseau, pas un problème de port. Un pool de threads applicatif était en cause. En utilisant le request ID, ils ont recoupé les requêtes échouées dans les logs applicatifs et vu qu’elles bloquaient toutes sur un appel à un service en aval. Ce service en aval appliquait un rate limit après un changement de config.
L’incident fut résolu sans redémarrages aléatoires : ils ont rollbacké la config en aval, ajouté un backoff côté client, et ajusté les timeouts Nginx seulement pour un autre endpoint qui streameait légitimement des données. Voilà à quoi ressemble le « ennuyeux » quand ça marche : isolation rapide, causalité propre, dommages collatéraux minimaux.
Listes de contrôle / plan étape par étape
Checklist A: When you see a 502
- Lire les error logs Nginx : cherchez
connect() failed,no live upstreams,prematurely closed, erreurs de resolver. - Depuis le conteneur Nginx, tester
getent hostsetncvers l’hôte:port upstream. - Vérifier l’état du conteneur upstream : restarting, exited, unhealthy, OOM killed.
- Vérifier si l’appli écoute sur le port et l’interface attendus (
0.0.0.0). - Si c’est intermittent, investiguer un mismatch keepalive ou un churn de déploiement.
Checklist B: When you see a 504
- Confirmer que c’est un read timeout : le journal d’erreurs devrait dire « while reading response header » ou les access logs montrent un
uhtélevé. - Inspecter les timings des access logs :
uctélevé → problème de connexion ;uhtélevé → latence first-byte upstream ;urtélevé → streaming lent. - Curl directement l’upstream depuis le conteneur Nginx et mesurer le TTFB.
- Vérifier la saturation CPU/mémoire de l’upstream et la latence des dépendances (DB, cache, autres services HTTP).
- Ce n’est qu’après avoir identifié la cause racine : ajuster
proxy_read_timeoutpour cette route si nécessaire.
Checklist C: Logging setup that pays rent
- Les access logs incluent : request ID, upstream addr, upstream status, connect time, header time, response time, request time.
- Les error logs vont sur stdout/stderr (friendly container) ou sur un volume monté avec rotation.
- Transmettre le request ID à l’upstream et le logger aussi là-bas.
- Suivre les redémarrages/OOM des conteneurs et corréler avec les rafales de 502.
Step-by-step plan: fix without flailing
- Geler les changements : arrêter les déploiements et les modifications de config jusqu’à ce que vous isoliez le mode de défaillance.
- Collecter des preuves : error logs Nginx, un échantillon d’access logs avec timings, docker events, état des conteneurs.
- Classer la défaillance :
- Connect refused/no route → réseau/port/cycle de vie du conteneur.
- Upstream timed out reading headers → latence upstream / blocage de dépendance.
- Premature close/reset → crashs, mismatch keepalive, mismatch de protocole.
- Choisir une intervention : scaler l’upstream, rollback, augmenter la limite mémoire, corriger le port, ajuster le timeout pour un emplacement spécifique. Une seule chose.
- Vérifier : confirmer que le taux d’erreur baisse et que la distribution de latence s’améliore, pas seulement un curl heureux.
- Prévenir pour l’avenir : ajouter des logs, des health checks, et des alertes sur les percentiles de timing upstream et les redémarrages de conteneurs.
Faits intéressants et contexte historique
- Nginx a commencé comme solution C10k : il a été construit pour gérer de nombreuses connexions concurrentes efficacement, ce qui explique pourquoi il est souvent le premier choix pour un reverse proxy.
- 502 vs 504 est un vocabulaire de passerelle HTTP : ces codes existent parce que les gateways/proxies avaient besoin d’un moyen de dire « le prochain saut a échoué » sans prétendre que le serveur d’origine a répondu.
- Le DNS embarqué de Docker (127.0.0.11) est un choix de conception : il fournit la découverte de service sur les réseaux définis par l’utilisateur, mais il ne corrige pas magiquement la façon dont chaque appli met en cache le DNS.
- Nginx résout les noms upstream différemment selon la config : les hostnames dans les blocs
upstreamsont généralement résolus au démarrage, ce qui surprend pendant le churn des conteneurs. - Keepalive est plus ancien que le hype microservices : les connexions persistantes existent depuis des décennies ; elles sont excellentes jusqu’à ce que des timeouts idle mismatched transforment une « optimisation » en échecs intermittents.
- Les 504s corrèlent souvent avec de la mise en file d’attente, pas seulement du « code lent » : quand les pools de workers se remplissent, la latence peut sauter sans aucun changement de code.
- Les OOM kills se déguisent en problèmes réseau : du point de vue de Nginx, un upstream qui crash ressemble à des connexions refusées ou des fermetures prématurées, pas à « manque de mémoire ».
- Conntrack exhaustion est un classique moderne : le NAT et le tracking stateful du firewall peuvent devenir le goulot bien avant que le CPU n’atteigne 100%.
- Les valeurs par défaut de timeout sont des artefacts culturels : de nombreuses stacks héritent de timeouts à 60s d’anciennes hypothèses sur les requêtes web, même quand les charges ont évolué vers des APIs longues et du streaming.
FAQ
1) Why do I get 502 in Docker but not when running the app directly on the host?
In Docker you add at least one more network hop and often change bind behavior. The app might be listening on 127.0.0.1 inside the container, which works locally but is unreachable from Nginx in another container. Confirm with ss -lntp inside the container.
2) How do I tell if a 504 is Nginx timing out or the upstream returning 504?
Check $upstream_status in access logs. If Nginx generated the 504 because it timed out, upstream status may be empty or different. Also read the Nginx error log: it will say “upstream timed out … while reading response header.”
3) Should I just increase proxy_read_timeout to stop 504s?
Only if you’re sure the endpoint is supposed to take that long and you’re okay with tying up proxy connections longer. Otherwise you’re hiding a capacity problem and increasing blast radius. Prefer fixing upstream latency or moving long jobs to async workflows.
4) My upstream is a service name in Compose. Why does Nginx sometimes hit the wrong IP after a redeploy?
Nginx often resolves upstream names at startup and keeps the IP. If the container is replaced and gets a new IP, Nginx may keep using the old one until reload. Solutions: reload Nginx on deploy, use a more dynamic resolution approach carefully, or use an orchestrator/service VIP that stays stable.
5) Why do I see “upstream prematurely closed connection” without any app crash logs?
It can be keepalive reuse against an upstream that closes idle connections, or a proxy/protocol mismatch. Test by disabling upstream keepalive temporarily and see if the symptom disappears. Also verify your app logs aren’t dropping messages under pressure.
6) Can a slow client cause upstream timeouts?
Yes. If you’re buffering responses or streaming large payloads, slow clients can keep connections open and consume worker capacity, indirectly causing upstream queues and 504s. Log request time vs upstream time to separate “upstream slow” from “client slow.”
7) How do I differentiate connect-time problems from application latency?
Use upstream timing fields. High or failing $upstream_connect_time points to network/port/service availability. High $upstream_header_time points to upstream processing or dependency stalls.
8) Does enabling Nginx upstream keepalive always help?
No. It reduces connection setup overhead, but it can expose bugs and mismatch idle timeouts, producing intermittent 502s. Use it intentionally: align timeouts, monitor errors, and tune reuse limits.
9) I’m using multiple upstream containers. How do I see if only one instance is bad?
Log $upstream_addr and group errors by it. If one IP shows most failures, you’ve got a “one bad replica” issue—often bad config, uneven load, or a noisy neighbor on the host.
10) What’s the minimum logging change that makes upstream debugging sane?
Add $request_id, $upstream_addr, $upstream_status, and the three upstream timing values (connect, header, response) to access logs. And keep the error log accessible.
Conclusion : prochaines étapes pour éviter une récurrence
Si vous ne retenez qu’une chose : le débogage des 502/504 est une question de timing et de topologie. Vous ne le réparez pas en devinant. Vous le réparez en journalisant correctement le saut upstream et en prouvant où la requête meurt.
Faites ensuite :
- Mettez à jour le format des access logs Nginx pour inclure les timings upstream, l’adresse upstream et le statut upstream. Si vous ne les loggez pas, vous choisissez des incidents plus lents.
- Rendez les error logs Nginx faciles d’accès dans Docker (stdout/stderr ou volume monté). Pendant une panne, « où sont les logs ? » n’est pas une chasse au trésor amusante.
- Implémentez des request IDs de bout en bout et loggez-les dans l’appli. La corrélation bat le débat.
- Ajoutez des health checks et des gates de readiness pour que les déploiements ne fabriquent pas des 502.
- Arrêtez de traiter les timeouts comme une solution. Utilisez-les comme signal. Si vous les augmentez, faites-le par route, avec intention, et avec monitoring.
Puis organisez un game day : tuez intentionnellement le conteneur upstream, ralentissez-le, et voyez si vos logs disent la vérité en moins de cinq minutes. Si ce n’est pas le cas, c’est votre vrai bug.