Files
apps-registry/workloads/docs-portal/docs/playbooks/incidente-mtu-blackhole-cross-node-2026-08.md
devopsandClaude Sonnet 5 b0b832f142
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 36s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m18s
docs(docs-portal): reflejar fixes de MTU, gitea-runner y cosign digest/rekor
Nuevo playbook del blackhole MTU cross-node (diagnostico + fix + validacion).
Actualiza el playbook de contencion de recursos Gitea/runner de 'pendiente'
a 'aplicado'. Actualiza cosign.md: firma por digest ya implementada, Rekor
como decision tomada (no TODO). Corrige 2 links que quedaron rotos al
renombrar la seccion de limitaciones (validado con mkdocs build --strict).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011FqYP3Wf1W63Qmh7qgXAAT
2026-08-16 23:07:26 -05:00

6.2 KiB

Incidente: blackhole de red cross-node por MTU/PMTUD (2026-08)

Ventana del incidente Detectado ~2026-08, causa raíz confirmada y resuelto 2026-08-16
Servicio afectado Cualquier pod que necesite hablar con commerce-postgres-0 (StatefulSet, sin réplicas, fijo a un nodo) desde un pod agendado en otro nodo del cluster k3d
Impacto medusa-deploy quedaba en CrashLoopBackOff/Init si el scheduler lo mandaba a un nodo distinto al de Postgres — ver incidente de crashloop de Medusa, donde este mismo blackhole ya había aparecido como sospechoso sin confirmar

Síntoma

El handshake TCP (SYN/ACK) entre un pod y commerce-postgres-0 funciona perfecto cuando quedan en nodos distintos — nc -zv conecta sin problema. Pero el primer paquete de datos real de la sesión (protocolo Postgres, pg_isready incluido) nunca llega. La conexión se cuelga hasta hacer timeout, sin ningún error explícito de por medio.

Diagnóstico

Aislando el nodo con nodeName en pods de prueba: 100% de fallos (186/186 intentos) agendando cross-node; 100% de éxito en el mismo nodo. Reproducible al 100%, no es intermitente.

Un primer intento de diagnóstico con ping normal (sin bit DF) no mostró pérdida de paquetes hasta payloads de 2000 bytes — porque sin el bit DF el kernel simplemente fragmenta y todo "funciona", lo cual enmascaró el problema real durante un tiempo. La pista real solo aparece forzando el bit DF (ping -M do), que es exactamente lo que hace un socket TCP real con Path MTU Discovery activo — y ahí es donde Postgres (y cualquier TCP con payload grande) se cuelga en silencio.

!!! warning "Herramienta correcta importa" El ping de BusyBox (el que trae la imagen de nodo de k3s, rancher/k3s) no soporta -M do — no hay forma de reproducir esto con un kubectl exec directo a cualquier pod. Hizo falta un pod de debug con una imagen completa (nicolaka/netshoot, trae ping de iputils con soporte real de DF) agendado explícitamente en un nodo distinto al de Postgres.

Con esa herramienta, un sweep de payloads crecientes contra el pod de Postgres dio el límite exacto:

Payload interno Resultado
1420 bytes 0% de pérdida
1450 bytes 100% de pérdida, sin ningún ICMP "frag needed"
1472/1500 bytes 100% de pérdida

Ese límite coincide casi exactamente con el MTU que la propia interfaz flannel.1 (la interfaz VXLAN de flannel dentro de cada nodo) ya reportaba: 1450, calculado como eth0 del nodo (1500) menos 50 bytes de overhead VXLAN. La aritmética de flannel es correcta — pero en la práctica, el último tramo de red no entrega el paquete VXLAN a tamaño completo, y en vez de generar el ICMP "fragmentation needed" que permitiría a Path MTU Discovery ajustarse solo, el paquete se pierde en silencio. Eso es exactamente un blackhole de PMTUD: el mecanismo que debería avisar "bajá el tamaño" está roto, no solo el límite en sí.

Causa raíz

Un tramo de la red (probablemente el puente Docker externo que conecta los contenedores de los distintos nodos k3d entre sí) tiene una capacidad real menor a los 1500 bytes que todos los niveles superiores (Docker, flannel) asumen — y ese tramo descarta los paquetes que no entran, en vez de fragmentarlos o avisar por ICMP. flannel calcula su propio MTU correctamente a partir de lo que Docker le reporta, pero lo que Docker le reporta no refleja la capacidad real de punta a punta.

Fix aplicado

k3s (usado en este lab) embebe flannel — no lo corre como un DaemonSet/binario separado con su propio archivo de configuración editable como en un cluster "vanilla". Confirmado con k3s server --help: no existe un flag --flannel-mtu en esta versión. El único punto de ajuste real es la interfaz flannel.1 en vivo, dentro de cada contenedor de nodo:

docker exec <nodo> ip link set flannel.1 mtu 1400

1400 queda con margen de sobra debajo del límite real confirmado (1420 bueno / 1450 blackhole).

Este ajuste no persiste en ningún archivo — se pierde si el contenedor del nodo se reinicia. Automatizado en scripts/deploy-lab.sh, función fix_flannel_mtu(), llamada tanto en create_cluster() (cluster nuevo) como en start_cluster() (cluster existente que arranca) — espera hasta 20s a que la interfaz flannel.1 exista antes de ajustarla, por si el nodo todavía no terminó de inicializar su red.

Bajar el MTU de flannel.1 en el host solo afecta pods nuevos — la CNI de flannel asigna el MTU del veth de cada pod al crearlo, leyendo el flannel.1 del nodo en ese momento. Los pods que ya estaban corriendo se reciclaron con kubectl rollout restart sobre todo el namespace ecommerce (commerce-postgres, commerce-redis, commerce-minio, medusa-deploy, catalog-api-deploy, frontend-deploy) para que tomaran el MTU nuevo de inmediato, en vez de esperar a que se recrearan solos.

Validación

El mismo sweep de ping -M do después del fix ya no se cuelga en silencio — devuelve Frag needed and DF set (mtu = 1400), la respuesta ICMP correcta que le permite al kernel ajustar el tamaño de los paquetes solo. Path MTU Discovery, en otras palabras, ya funciona en vez de estar bloqueado.

Validación final con tráfico real, no solo ping: conexión TCP cross-node exitosa hacia el puerto 5432 de Postgres, y una transferencia de 50 KB (muy por encima del umbral que antes se colgaba) completada sin problema. shop.cruzcloud.net y commerce.cruzcloud.net/app respondiendo 200 después de reciclar todos los pods.

Sobre el podAffinity en medusa.yaml

El workaround que existía antes de este fix — podAffinity/requiredDuringSchedulingIgnoredDuringExecution forzando a medusa-deploy a agendarse siempre en el mismo nodo que commerce-postgres-0 — se deja tal cual está, a propósito. Ya no es necesario para evitar el blackhole, pero no tiene ningún costo real, y sirve como red de contención si el MTU real del entorno vuelve a cambiar en el futuro (por ejemplo, tras una actualización de ZimaOS o de Docker). No se debe interpretar como deuda pendiente de "limpiar" — es una decisión consciente.