Errores upstream de Nginx en Docker: depura 502/504 con los logs correctos

¿Te fue útil?

Estás de guardia. El panel está en rojo. Los usuarios dicen “el sitio está caído” y lo único que Nginx te devuelve es un pequeño y soberbio 502 o 504. Tu equipo de backend jura que no cambiaron nada. El host de Docker parece “bien”. Y, aun así, producción no está bien en absoluto.

Aquí es donde la gente pierde horas mirando los logs equivocados. El truco es aburrido: registra los campos upstream correctos, demuestra qué salto falló y arregla la cosa concreta que está rota. No “reinicia todo”. Lo específico y roto.

Un modelo mental: qué significan realmente 502 y 504 en Docker + Nginx

Empieza con disciplina: un 502/504 rara vez es “un problema de Nginx”. Normalmente es Nginx haciendo de mensajero que se quedó atascado entre un cliente y un upstream (tu app) y que tiene evidencia.

502 Bad Gateway: Nginx no pudo obtener una respuesta válida del upstream

En la práctica, con Docker, un 502 a menudo significa una de estas:

  • Fallo de conexión: Nginx no pudo conectar al IP:puerto del upstream (contenedor caído, puerto equivocado, red equivocada, reglas de firewall, DNS apuntando a un IP obsoleto).
  • Upstream cerró prematuramente: Nginx se conectó, envió la solicitud y el upstream cerró antes de enviar una respuesta HTTP válida (crash, OOM kill, bug en la aplicación, desajuste de proxy protocol, desajuste TLS).
  • Desajuste de protocolo: Nginx espera HTTP pero el upstream habla HTTPS, gRPC, FastCGI o TCP puro; o espera HTTP/1.1 keepalive pero el upstream no lo soporta.

504 Gateway Timeout: Nginx se conectó pero no obtuvo respuesta a tiempo

Un 504 suele ser más lento y molesto: Nginx estableció la conexión con el upstream, pero no recibió la respuesta (o las cabeceras) dentro de los timeouts configurados. Eso no siempre significa “la app es lenta”. También puede ser:

  • Upstream sobrecargado: pool de hilos agotado, pool de DB agotado, bucle de eventos bloqueado o CPU limitada por cgroups.
  • Estancamientos de red: pérdida de paquetes, agotamiento de conntrack, rarezas del bridge de Docker bajo carga o un desajuste de MTU que solo afecta respuestas grandes.
  • Los timeouts no reflejan la realidad: Nginx espera respuesta en 60s, pero la app necesita legítimamente 120s para algunos trabajos, y quizá nunca quisiste proxyar eso vía Nginx.

Un marco adicional: Nginx tiene tres relojes durante el proxy: tiempo de conexión, tiempo hasta el primer byte (cabeceras) y tiempo para terminar de leer la respuesta. Si no registras los tres, estás depurando a ciegas.

Idea parafraseada de Werner Vogels (CTO de Amazon): “You build it, you run it” trata de asumir la realidad operacional, no solo de enviar código.

Broma breve #1: Un 502 es Nginx diciendo “intenté llamar a tu app, pero fue directo al buzón de voz”.

Guía rápida de diagnóstico (verifica primero/segundo/tercero)

Este es el orden que encuentra el cuello de botella rápido, sin transformar tu canal de incidentes en un grupo terapéutico.

Primero: demuestra qué salto está fallando

  1. Cliente → Nginx: ¿Nginx recibe las solicitudes? Revisa los access logs y la correlación con $request_id.
  2. Nginx → upstream: ¿falla la conexión (502) o da timeout (504)? Busca “connect() failed” vs “upstream timed out”.
  3. Upstream → sus dependencias: DB, cache, cola, otros servicios HTTP. No necesitas trazado completo para confirmar lo obvio: los timeouts en dependencias suben cuando suben los 504.

Segundo: captura las marcas temporales y los tiempos upstream correctos

  • Añade (o confirma) en el access log de Nginx los campos: $upstream_addr, $upstream_status, $upstream_connect_time, $upstream_header_time, $upstream_response_time, $request_time.
  • En Docker, confirma que reinicios/OOM de contenedores coinciden con ráfagas de 502.
  • Confirma si los errores son por instancia upstream (un contenedor malo) o sistémicos (todos los contenedores lentos).

Tercero: decide si arreglas timeouts o arreglas el upstream

  • Si $upstream_connect_time es alto o está ausente: arregla la red, el descubrimiento de servicio, puertos, salud de contenedores o capacidad.
  • Si $upstream_header_time es alto: el upstream tarda en empezar a responder; revisa latencia de la app y dependencias.
  • Si las cabeceras llegan rápido pero $upstream_response_time es enorme: la transmisión de la respuesta es lenta; revisa tamaño de payload, buffering, clientes lentos y límites de tasa.

Los timeouts no son una estrategia de rendimiento. Son un contrato. Cámbialos solo cuando sepas lo que estás firmando.

Obtén los logs correctos: Nginx, Docker y la aplicación

Log de errores de Nginx: donde empieza la verdad

Si solo revisas los access logs de Nginx, verás códigos de estado pero no el porqué. El log de error contiene el modo de fallo upstream: connection refused, no route to host, upstream prematurely closed, upstream timed out, resolver failure.

En un contenedor, asegúrate de que Nginx escriba los logs de error a stdout/stderr o a un volumen montado. Si los escribe en /var/log/nginx/error.log dentro de un contenedor sin volumen, aún puedes leerlos con docker exec, pero no te gustará la ergonomía durante un incidente.

Access log de Nginx: donde aprendes patrones

Los access logs son el mejor lugar para responder preguntas como: “¿Son todos los endpoints o uno?” y “¿Es una instancia upstream?” Pero solo si registras los campos upstream.

Postura de opinión: registra en JSON. Los humanos lo leen y las máquinas lo procesan sin problema. Si hoy no puedes cambiar el formato, al menos añade las variables de tiempos upstream a tu formato actual.

Logs de Docker: el contenedor miente a menos que mires

Ráfagas de 502 que coinciden con reinicios de contenedores no son un misterio. Son una línea temporal. Docker te dice cuándo un contenedor reinició, cuándo fue OOM-killed y si las health checks fallan.

Logs de la aplicación: confirma que el upstream recibió la petición

Los logs de tu app deberían responder: ¿la petición llegó?, ¿qué ruta?, ¿qué latencia?, ¿qué error?. Si puedes añadir un header de request ID (por ejemplo, X-Request-ID) desde Nginx hacia el upstream y registrarlo, dejarás de discutir y empezarás a arreglar.

Broma breve #2: “Upstream timed out” es la versión operacional de “te responderé” de ese proveedor que nunca lo hace.

Tareas prácticas (comandos, significado de la salida, decisión)

Estas son las tareas que realmente ejecuto cuando Nginx en Docker empieza a tirar 502/504. Cada tarea incluye: comando, qué significa una salida típica y la decisión que tomas a continuación.

Task 1: Confirma que Nginx es el componente que devuelve 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

Qué significa: La respuesta viene de Nginx (Server header). No de la app.

Decisión: Ve al log de errores de Nginx; no pierdas tiempo buscando en los logs de la app todavía.

Task 2: Lee los logs de error de Nginx desde el contenedor

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"

Qué significa: Nginx intentó conectar al IP:puerto del upstream y recibió Connection refused. El contenedor existe en la red, pero nada está escuchando ese puerto (o no está listo).

Decisión: Revisa el estado del contenedor upstream, el mapeo de puertos y si el proceso de la app está escuchando.

Task 3: Identifica a qué upstream intenta llegar Nginx

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;
        }
    }
}

Qué significa: El upstream usa el nombre DNS de Docker app en el puerto 8080, con keepalive habilitado.

Decisión: Valida la resolución DNS de Docker y confirma que la app escucha en 8080 dentro de su contenedor. Ten en cuenta el keepalive; puede amplificar ciertos bugs de la app.

Task 4: Valida DNS dentro del contenedor Nginx

cr0x@server:~$ docker exec -it nginx getent hosts app
172.19.0.5     app

Qué significa: El DNS embebido de Docker resuelve app a un IP. Si esto falla, Nginx puede estar resolviendo solo al inicio o no resolver en absoluto (dependiendo de la configuración).

Decisión: Si DNS falla: arregla redes de contenedores, nombre del servicio o configuración del resolvedor. Si DNS funciona: pasa a verificar conectividad y listening.

Task 5: Prueba conectividad desde Nginx al puerto del upstream

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

Qué significa: El IP es alcanzable, pero nadie acepta conexiones en 8080.

Decisión: Revisa el contenedor de la app: ¿está corriendo, escucha en la interfaz correcta y no está en crash-loop?

Task 6: Inspecciona la salud y reinicios del contenedor upstream

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

Qué significa: La app está en bucle de reinicio. Nginx es inocente; no puede hablar con algo que no permanece en pie.

Decisión: Extrae los logs de la app y busca la causa del crash (error de configuración, secreto faltante, OOM, migraciones, dependencia que rechaza).

Task 7: Busca OOM kills (generador clásico de 502)

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}

Qué significa: OOMKilled:true y exit code 137. El kernel terminó el proceso. Nginx solo informa las consecuencias.

Decisión: Añade memoria, reduce la carga, arregla fugas de memoria o define límites sensatos y autoescalado. Considera también reducir el buffering de Nginx para respuestas enormes solo si entiendes las compensaciones.

Task 8: Correlaciona picos de 502/504 con reinicios de contenedores en el stream de eventos de Docker

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)

Qué significa: La app murió dos veces en 30 minutos. Si tus 502 coinciden con esos timestamps, tienes causalidad, no sensaciones.

Decisión: Enfócate en por qué la app muere. No ajustes timeouts de Nginx; ese no es el problema.

Task 9: Si es 504, registra los tiempos y verifica dónde se gasta el tiempo

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"

Qué significa: uct (connect) es rápido, pero uht (tiempo hasta cabeceras) llegó a 60s, coincidiendo con proxy_read_timeout. El upstream aceptó la conexión pero no produjo cabeceras a tiempo.

Decisión: Esto es lentitud o deadlock del upstream, no red. Revisa latencia de la app, llamadas a dependencias, agotamiento de workers y la DB.

Task 10: Confirma la configuración de timeouts de Nginx que realmente disparó el 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;

Qué significa: Nginx da 60s al upstream para responder (las cabeceras cuentan). Ese es tu contrato actual.

Decisión: Si 60s es poco para un endpoint legítimo, separa rutas: aumenta el timeout solo para esa ubicación, o rediseña el endpoint (trabajo asíncrono + polling). Evita inflar timeouts globalmente.

Task 11: Valida que la app upstream escucha en el puerto esperado dentro de su contenedor

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))

Qué significa: La app escucha en 0.0.0.0:8080, lo cual es correcto para la red de contenedores.

Decisión: Si escuchara solo en 127.0.0.1, Nginx en otro contenedor recibiría connection refused. Arregla la dirección de bind de la app.

Task 12: Confirma que los contenedores Nginx y app comparten una red de Docker

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}}

Qué significa: Ambos están en la red frontend. Debería ser posible la conectividad.

Decisión: Si las redes difieren, arregla las networks de Compose o conecta los contenedores a la misma red definida por el usuario. No uses el legado --link.

Task 13: Revisa el comportamiento del DNS embebido de Docker y trampas de resolución en Nginx

cr0x@server:~$ docker exec -it nginx cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0

Qué significa: El DNS de Docker está en uso. Nginx resuelve los nombres upstream dependiendo de cómo lo configures. Si pones un hostname en un bloque upstream { server app:8080; }, Nginx típicamente lo resuelve en el arranque y lo cachea.

Decisión: Si los contenedores churnean y cambian IPs, o recarga Nginx cuando cambian los IPs (común en Compose) o usa patrones de resolución dinámica (ej., resolver 127.0.0.11 más variables en proxy_pass) con cuidado.

Task 14: Detecta problemas de reutilización de keepalive en upstream (conexiones obsoletas)

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"

Qué significa: El upstream cerró la conexión inesperadamente mientras Nginx esperaba cabeceras. Esto puede ser crashes de la app, pero también desajustes de keepalive y timeouts de inactividad en el upstream.

Decisión: Compara los settings de keepalive de Nginx con los timeouts de inactividad del servidor upstream. Considera deshabilitar upstream keepalive temporalmente para probar si los errores cesan; luego arregla alineando timeouts o ajustando keepalive_requests, etc.

Task 15: Revisa la presión a nivel host que hace que todo sea “aleatoriamente” lento

cr0x@server:~$ uptime
 10:24:02 up 41 days,  4:11,  2 users,  load average: 18.42, 17.90, 16.55

Qué significa: Un load average alto puede indicar saturación de CPU, cola de procesos listos o I/O bloqueado. En contenedores, esto puede manifestarse como 504s porque el upstream no puede ser planificado.

Decisión: Revisa CPU y memoria; si el host está saturado, ningún ajuste de timeout de Nginx lo “arreglará”.

Task 16: Ver presión de CPU/memoria por contenedor en tiempo real

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

Qué significa: La app consume CPU al máximo y casi alcanza OOM. Espera latencia y reinicios. Esto produce directamente 504 (lento) y 502 (crash).

Decisión: Añade capacidad, arregla uso de memoria, añade caching, reduce concurrencia o arregla la consulta. Pero haz una cosa a la vez.

Task 17: Verifica agotamiento de connection tracking (una fuente sigilosa de 502/504 bajo carga)

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

Qué significa: Estás cerca del máximo de conntrack. Nuevas conexiones pueden fallar o estancarse; Nginx ve errores de conexión o timeouts.

Decisión: Incrementa conntrack max (teniendo en cuenta memoria), reduce churn de conexiones (keepalive, pooling) o escala horizontalmente. Revisa también fugas de conexiones.

Task 18: Valida que Nginx esté registrando tiempos upstream (o arréglalo)

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"';

Qué significa: Tienes las variables de tiempo clave. Bien. Úsalas.

Decisión: Si faltan, añádelas y recarga Nginx. Sin tiempos upstream, maldiagnosticarás los 504.

Task 19: Recarga Nginx de forma segura tras cambios de configuración

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

Qué significa: La sintaxis es válida; la recarga aplicará cambios sin cerrar conexiones existentes (en la mayoría de setups típicos).

Decisión: Prefiere recargar antes que reiniciar contenedores en medio de un incidente, salvo que el proceso esté totalmente bloqueado.

Task 20: Demuestra que el upstream es lento usando curls directos desde el namespace de red de Nginx

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

Qué significa: El upstream tarda ~60s en el primer byte y total. El proxy_read_timeout de Nginx está justo en el límite; un poco de jitter causa 504.

Decisión: Arregla el rendimiento del upstream o rediseña el endpoint. Aumentar timeouts puede detener la hemorragia, pero también acumular conexiones y aumentar el radio de daño.

Errores comunes: síntoma → causa raíz → solución

Esta sección existe porque la mayoría de “errores upstream de Nginx” son auto-infligidos. Aquí están los que siguen apareciendo en entornos contenedorizados.

1) Síntoma: 502 con “connect() failed (111: Connection refused)”

  • Causa raíz: El contenedor upstream está reiniciando/crasheando; la app escucha en otro puerto; la app se enlaza a 127.0.0.1 dentro del contenedor.
  • Solución: Confirma con ss -lntp en el contenedor de la app, fija la dirección de bind a 0.0.0.0, corrige el puerto en Nginx/Compose y añade health checks para que Nginx no enrute a contenedores muertos.

2) Síntoma: 502 con “no live upstreams”

  • Causa raíz: Todos los servidores upstream marcados como caídos por Nginx (checks fallidos o max_fails), o el nombre upstream no se resolvió en el arranque.
  • Solución: Asegura que Nginx pueda resolver el nombre del servicio al arrancar; recarga Nginx tras cambios de red; valida las entradas upstream. Si haces blue/green, no dejes Nginx apuntando al nombre retirado.

3) Síntoma: 504 con “upstream timed out (110: Connection timed out) while reading response header”

  • Causa raíz: El upstream es lento para producir cabeceras; pool de hilos o event loop bloqueado; consultas a DB lentas; upstream con CPU limitada.
  • Solución: Registra $upstream_header_time. Si es alto, optimiza el upstream y sus dependencias. Aumenta proxy_read_timeout solo para endpoints que realmente lo requieran.

4) Síntoma: 502 con “upstream prematurely closed connection”

  • Causa raíz: La app crashea a mitad de petición; timeout de inactividad del upstream más corto que la ventana de reutilización de Nginx; desajuste de proxy protocol/TLS.
  • Solución: Revisa logs de la app por crashes. Deshabilita temporalmente upstream keepalive para validar. Alinea timeouts y considera limitar la reutilización con keepalive_requests en el upstream.

5) Síntoma: 502 solo durante despliegues

  • Causa raíz: Los contenedores se detienen antes de que los nuevos estén listos; no hay readiness; Nginx resuelve a un IP de un contenedor que acaba de ser sustituido.
  • Solución: Añade endpoints de readiness y health checks. En Compose, escalona los reinicios y recarga Nginx si dependes de resolución de nombres al arranque. Prefiere un VIP de servicio estable (en orquestadores) o un proxy que haga resolución dinámica correctamente.

6) Síntoma: picos de 504 pero logs de la app parecen “bien”

  • Causa raíz: Las peticiones nunca llegan a la app (atascadas en la cola de Nginx, conntrack agotado, backlog de SYN o estancamientos de red). O la app deja de volcar logs bajo presión.
  • Solución: Compara access logs de Nginx con logs de la app usando request IDs. Revisa conntrack y saturación del host. Confirma que el logging de la app no está siendo bufferizado hasta la muerte.

7) Síntoma: 502 aleatorios bajo carga, desaparecen al escalar

  • Causa raíz: Agotamiento de descriptores de archivo, agotamiento de puertos efímeros, presión en la tabla NAT o clientes estilo slow-loris que crean contención de recursos.
  • Solución: Revisa ulimit -n y archivos abiertos, ajusta worker connections, aplica timeouts sensatos para clientes y vigila conntrack.

8) Síntoma: 502 tras activar HTTP/2 o cambiar TLS

  • Causa raíz: Expectativas de protocolo upstream mal configuradas (proxying a HTTPS upstream sin proxy_ssl, o hablar HTTP a un puerto TLS).
  • Solución: Valida el esquema y los puertos del upstream, prueba directamente con curl desde dentro del contenedor Nginx y asegúrate de que el upstream sea realmente HTTP donde crees que lo es.

Tres microhistorias corporativas del terreno

Microhistoria 1: El incidente causado por una suposición equivocada

Una empresa ejecutaba un stack simple con Docker Compose: Nginx reverse proxy, un contenedor API en Node.js y un contenedor Redis. Un lunes empezaron a ver una pared limpia de 502. La primera suposición del equipo fue universal y equivocada: “Nginx no puede resolver el nombre upstream”. Así que cambiaron la config de Nginx para fijar la IP upstream que vieron en docker inspect. “Funcionó” diez minutos.

Luego falló otra vez. Duro. Porque el contenedor de la API estaba en crash-loop; cada reinicio agarraba una IP nueva y su “arreglo” fijó a Nginx en la dirección de ayer. El log de errores contaba la historia todo el tiempo: connect() failed (111: Connection refused). Eso no es DNS. Es un puerto sin nadie escuchando.

Finalmente miraron docker inspect y notaron OOMKilled:true. El contenedor API tenía un límite de memoria pequeño y una nueva feature creó una caché en memoria mayor bajo cierto patrón de petición. Bajo carga, el kernel lo mató. Nginx no estaba roto; estaba enroutando consistentemente a un servicio que no permanecía vivo.

La solución real fue aburrida: reducir la huella de memoria, aumentar el límite del contenedor para niveles de pico realistas y añadir un endpoint de readiness para que el proxy no enviara tráfico antes de que la app estuviera lista. También dejaron de fijar IPs de contenedor—porque eso es convertir un incidente simple en un hobby recurrente.

Microhistoria 2: La optimización que resultó contraproducente

Otra organización tuvo una iniciativa de rendimiento: “reducir latencia activando keepalive en todas partes”. Alguien añadió keepalive 128; en el bloque upstream de Nginx. También subieron worker_connections. En papel parecía velocidad gratis.

Dos semanas después aparecieron 502 intermitentes: “upstream prematurely closed connection.” Eran lo suficientemente raros para esquivar la monitorización básica, pero lo bastante frecuentes para molestar a clientes. El servicio backend era una app Java con un servidor embebido con un timeout idle más corto que la ventana de reutilización de conexiones de Nginx. Nginx reusaba conexiones que el upstream ya había cerrado. A veces la carrera se resolvía a favor de Nginx, otras no.

La primera respuesta del equipo fue clásica: aumentar timeouts. Eso redujo el síntoma… y aumentó el uso de recursos. Ahora había más conexiones upstream inactivas consumiendo descriptores y memoria. Bajo carga, el proxy empezó a fallar y la latencia subió.

La solución no fue “más keepalive”. Fue alinear el comportamiento keepalive extremo a extremo: reducir keepalive en Nginx, alinear timeouts de inactividad y limitar la reutilización con keepalive_requests. También añadieron logs de tiempos upstream, así futuros fallos mostrarían si el coste era tiempo de conexión o espera de cabeceras. La “optimización” pasó a ser una herramienta controlada en vez de una superstición.

Microhistoria 3: La práctica aburrida pero correcta que salvó el día

Un equipo de servicios financieros tenía una política casi cómicamente poco glamorosa: todo reverse proxy debía registrar tiempos upstream y códigos de estado upstream, y cada petición debía llevar un request ID. Se aplicaba en code review. La gente refunfuñaba. Luego dejaron de refunfuñar.

Durante una release vieron una oleada de 504 en un solo endpoint. El on-call sacó los access logs de Nginx y filtró por ruta. El formato incluía uct, uht y urt, más upstream_status. En minutos encontraron un patrón: el tiempo de conexión era bajo, el tiempo hasta cabeceras se disparaba y en algunas peticiones faltaba el upstream status—significando que Nginx nunca recibió cabeceras.

Pivotearon: no era red, ni puertos. Era el pool de threads de la aplicación. Con el request ID, casaron las peticiones fallidas en los logs de la app y vieron que todas quedaban atascadas en una llamada a un servicio downstream. Ese downstream estaba aplicando rate limiting tras un cambio de configuración.

El incidente se resolvió sin reinicios aleatorios: hicieron rollback del cambio downstream, añadieron backoff cliente y ajustaron timeouts de Nginx solo para otro endpoint que legítimamente hacía streaming de datos. Así se ve lo “aburrido” cuando funciona: aislamiento rápido, causalidad limpia y mínimo daño colateral.

Listas de verificación / plan paso a paso

Lista A: Cuando ves un 502

  1. Lee los logs de error de Nginx: busca connect() failed, no live upstreams, prematurely closed, errores del resolvedor.
  2. Desde el contenedor Nginx, prueba getent hosts y nc hacia host:puerto del upstream.
  3. Revisa el estado del contenedor upstream: reiniciando, exited, unhealthy, OOM killed.
  4. Comprueba si la app escucha en el puerto e interfaz esperados (0.0.0.0).
  5. Si es intermitente, investiga desajuste de keepalive o churn por despliegues.

Lista B: Cuando ves un 504

  1. Confirma que es un timeout de lectura: el log de errores debería decir “while reading response header” o los access logs muestran alto uht.
  2. Inspecciona los tiempos en access log: alto uct sugiere problema de conexión; alto uht sugiere latencia hasta primer byte; alto urt sugiere streaming lento.
  3. Haz curl directo al upstream desde dentro del contenedor Nginx y mide el TTFB.
  4. Revisa saturación de CPU/memoria del upstream y latencia de dependencias (DB, cache, otros servicios HTTP).
  5. Sólo tras conocer la causa raíz: ajusta proxy_read_timeout para esa ruta concreta si hace falta.

Lista C: Configuración de logs que paga dividendos

  1. Los access logs incluyen: request ID, upstream addr, upstream status, tiempo de conexión, tiempo hasta cabeceras, tiempo de respuesta, tiempo total de la petición.
  2. Los logs de error van a stdout/stderr (amigable para contenedores) o a un volumen montado con rotación.
  3. Pasa el request ID al upstream y regístralo también ahí.
  4. Haz seguimiento de reinicios/OOM de contenedores y córrelos con ráfagas de 502.

Plan paso a paso: arregla sin dar vueltas

  1. Congela cambios: detén despliegues y ediciones de config hasta aislar el modo de fallo.
  2. Recoge evidencia: logs de error de Nginx, un segmento de access logs con tiempos, eventos de Docker, estado de contenedores.
  3. Clasifica el fallo:
    • Connection refused/no route → red/puerto/vida del contenedor.
    • Upstream timed out reading headers → latencia del upstream/dependencias.
    • Premature close/reset → crashes, desajuste de keepalive, desajuste de protocolo.
  4. Elige una intervención: escalar upstream, rollback, aumentar límite de memoria, arreglar puerto, ajustar timeout para una ubicación específica. Una sola acción.
  5. Verifica: confirma que la tasa de errores baja y la distribución de latencia mejora, no solo que un curl devuelva bien.
  6. Prevención retroactiva: añade logs, health checks, alertas sobre percentiles de tiempos upstream y reinicios de contenedores.

Hechos interesantes y contexto histórico

  1. Nginx nació como solución C10k: fue diseñado para manejar muchas conexiones concurrentes eficientemente, por eso suele ser la primera elección como reverse proxy.
  2. 502 vs 504 es vocabulario de gateways HTTP: estos códigos existen porque los gateways/proxies necesitaban decir “el siguiente salto falló” sin fingir que el servidor de origen respondió.
  3. El DNS embebido de Docker (127.0.0.11) es una elección de diseño: proporciona descubrimiento de servicios en redes definidas por el usuario, pero no arregla mágicamente cómo cada app cachea DNS.
  4. Nginx resuelve nombres upstream de forma diferente según la configuración: los hostnames en bloques upstream suelen resolverse al inicio, lo que sorprende durante churn de contenedores.
  5. Keepalive es anterior al hype de microservicios: las conexiones persistentes existen desde hace décadas; son geniales hasta que timeouts de inactividad desajustados convierten una “optimización” en fallos intermitentes.
  6. Los 504 a menudo se correlacionan con encolamiento, no solo “código lento”: cuando los pools de workers se llenan, la latencia puede saltar sin ningún cambio de código.
  7. Los OOM kills se disfrazan de problemas de red: desde la perspectiva de Nginx, un upstream que crashea parece connection refused o premature close, no “sin memoria”.
  8. El agotamiento de conntrack es un clásico moderno: NAT y el tracking de firewall con estado pueden convertirse en cuello de botella mucho antes de que la CPU llegue al 100%.
  9. Los valores por defecto de timeout son artefactos culturales: muchas pilas heredan timeouts de 60s por suposiciones antiguas sobre peticiones web, aunque las cargas hayan cambiado a APIs de larga duración y streaming.

Preguntas frecuentes

1) ¿Por qué obtengo 502 en Docker pero no al ejecutar la app directamente en el host?

En Docker añades al menos un salto de red más y a menudo cambias el comportamiento de bind. La app puede estar escuchando en 127.0.0.1 dentro del contenedor, lo cual funciona localmente pero es inaccesible desde Nginx en otro contenedor. Confírmalo con ss -lntp dentro del contenedor.

2) ¿Cómo sé si un 504 es por timeout de Nginx o porque el upstream devolvió 504?

Revisa $upstream_status en los access logs. Si Nginx generó el 504 por timeout, el upstream status puede estar vacío o ser distinto. Lee también el log de errores de Nginx: dirá “upstream timed out … while reading response header”.

3) ¿Debería simplemente aumentar proxy_read_timeout para detener 504s?

Sólo si estás seguro de que el endpoint debe tardar tanto y aceptas mantener conexiones de proxy ocupadas más tiempo. Si no, estás ocultando un problema de capacidad y aumentando el radio de daño. Prefiere arreglar latencia upstream o mover tareas largas a flujos asíncronos.

4) Mi upstream es un nombre de servicio en Compose. ¿Por qué Nginx a veces golpea el IP equivocado tras un redeploy?

Nginx suele resolver nombres upstream al arranque y mantener el IP. Si el contenedor se reemplaza y recibe un IP nuevo, Nginx puede seguir usando el viejo hasta recargarse. Soluciones: recargar Nginx en deploy, usar resolución más dinámica con cuidado o usar un VIP estable del orquestador.

5) ¿Por qué veo “upstream prematurely closed connection” sin logs de crash en la app?

Puede ser reutilización de keepalive contra un upstream que cierra conexiones inactivas, o un desajuste de proxy/protocolo. Prueba deshabilitar keepalive upstream temporalmente y observa si el síntoma desaparece. Verifica también que los logs de la app no se pierdan bajo presión.

6) ¿Puede un cliente lento causar timeouts en el upstream?

Sí. Si haces buffering de respuestas o haces streaming de payloads grandes, clientes lentos pueden mantener conexiones abiertas y consumir capacidad de worker, provocando encolamiento y 504s indirectos. Registra tiempo de petición vs tiempo upstream para separar “upstream lento” de “cliente lento”.

7) ¿Cómo diferencio problemas de tiempo de conexión de latencia de aplicación?

Usa los campos de tiempo upstream. Alto o fallando $upstream_connect_time apunta a red/puerto/disponibilidad del servicio. Alto $upstream_header_time apunta a procesamiento del upstream o bloqueos en dependencias.

8) ¿Habilitar keepalive en upstream de Nginx siempre ayuda?

No. Reduce el overhead de establecer conexiones, pero puede exponer bugs y desajustes de timeouts que producen 502 intermitentes. Úsalo intencionalmente: alinea timeouts, monitoriza errores y ajusta los límites de reutilización.

9) Uso múltiples contenedores upstream. ¿Cómo veo si solo uno está mal?

Registra $upstream_addr y agrupa errores por él. Si una IP muestra la mayoría de fallos, tienes una réplica mala—suele ser config errónea, carga desigual o un vecino ruidoso en el host.

10) ¿Cuál es el cambio mínimo en logging que hace la depuración upstream manejable?

Añade $request_id, $upstream_addr, $upstream_status y las tres variables de tiempo upstream (connect, header, response) a los access logs. Y mantén el log de errores accesible.

Conclusión: pasos siguientes para evitar repeticiones

Si recuerdas una cosa: depurar 502/504 es un problema de tiempos y topología. No lo arreglas adivinando. Lo arreglas registrando correctamente el salto upstream y demostrando dónde muere la petición.

Haz lo siguiente:

  1. Actualiza el formato de access log de Nginx para incluir tiempos upstream, dirección upstream y estado upstream. Si no registras eso, eliges incidentes más lentos.
  2. Haz que los logs de error de Nginx sean fáciles de acceder en Docker (stdout/stderr o volumen montado). En una caída, “¿dónde están los logs?” no debe ser una búsqueda del tesoro.
  3. Implementa request IDs de extremo a extremo y regístralos en la app. Correlación vence a la discusión.
  4. Añade health checks y readiness gates para que los despliegues no fabriquen 502s.
  5. Deja de tratar timeouts como una solución. Úsalos como señal. Si los subes, hazlo por ruta, con intención y con monitorización.

Luego realiza un game day: mata intencionalmente el contenedor upstream, enlentece su respuesta y observa si tus logs cuentan la verdad en menos de cinco minutos. Si no lo hacen, ese es tu bug real.

← Anterior
WireGuard está lento: MTU, enrutamiento, CPU — Acelérelo sin adivinanzas
Siguiente →
Proxmox “backup storage not available on node”: por qué “shared” no es compartido

Deja un comentario