docs(playbooks): documentar incidente medusa-deploy atascado en Init (2026-08)
Postmortem del incidente resuelto en e9c93bf: sintoma reportado como
CrashLoopBackOff que en realidad era Init:0/2 indefinido (0 restarts).
Documenta las hipotesis descartadas en orden (secret/DATABASE_URL,
NetworkPolicy, salud de Postgres, ResourceQuota, DNS, MTU/PMTUD
cross-node), la causa raiz (pg_isready no parseaba ?sslmode=disable en
la URI, exit 3 "no attempt"), el fix con flags explicitos, y la deuda
tecnica pendiente en la branch fix/medusa-wait-for-postgres.
This commit is contained in:
@@ -0,0 +1,212 @@
|
|||||||
|
# Incidente: medusa-deploy atascado sin levantar (2026-08)
|
||||||
|
|
||||||
|
**Ventana del incidente:** ~2026-08-08 (onset) → 2026-08-10 05:31 UTC (resuelto)
|
||||||
|
**Servicio afectado:** `medusa-deploy` en namespace `ecommerce`
|
||||||
|
**Impacto:** el rollout nuevo nunca llegaba a estar listo; el pod anterior
|
||||||
|
(`medusa-deploy-59878b956f-hf45b`) siguió sirviendo tráfico sin interrupción
|
||||||
|
durante todo el incidente (`maxUnavailable: 0`), por lo que no hubo downtime
|
||||||
|
de cara al usuario — el impacto fue "no se puede desplegar", no "el servicio
|
||||||
|
está caído".
|
||||||
|
|
||||||
|
## Síntoma inicial reportado vs. estado real
|
||||||
|
|
||||||
|
El incidente se reportó como **CrashLoopBackOff** ("sigue igual desde ayer,
|
||||||
|
más de 20-24 horas"). Al correr `kubectl get pods -n ecommerce -o wide` esa
|
||||||
|
descripción resultó **inexacta**: no había ningún pod en CrashLoopBackOff.
|
||||||
|
El estado real era:
|
||||||
|
|
||||||
|
```
|
||||||
|
commerce-postgres-0 1/1 Running 0 4d9h
|
||||||
|
medusa-deploy-59878b956f-hf45b 1/1 Running 0 21h (revisión vieja, sana)
|
||||||
|
medusa-deploy-9d8784d7b-g9jql 0/1 Init:0/2 0 3h56m (revisión nueva, atascada)
|
||||||
|
```
|
||||||
|
|
||||||
|
0 *restarts* en el pod nuevo — no estaba crasheando y reiniciando, estaba
|
||||||
|
**colgado indefinidamente en el primer init container** (`wait-for-postgres`),
|
||||||
|
sin timeout ni backoff que lo sacara de ese estado. Esta distinción importa:
|
||||||
|
un pod en `Init:0/2` con 0 restarts no aparece en las alertas típicas de
|
||||||
|
CrashLoopBackOff, lo que probablemente explica por qué pasó desapercibido
|
||||||
|
tanto tiempo.
|
||||||
|
|
||||||
|
**Lección:** verificar siempre el estado real con `kubectl get pods` antes de
|
||||||
|
asumir el tipo de falla que describe quien reporta el incidente. Un
|
||||||
|
`Init:X/Y` con 0 restarts y un `CrashLoopBackOff` requieren líneas de
|
||||||
|
investigación distintas.
|
||||||
|
|
||||||
|
## Hipótesis descartadas (en orden)
|
||||||
|
|
||||||
|
### 1. Credenciales / secret inválido o vacío
|
||||||
|
|
||||||
|
- `kubectl get secret -n ecommerce commerce-secrets` → la clave
|
||||||
|
`DATABASE_URL` existe.
|
||||||
|
- Se verificó longitud del valor (`~126` caracteres, no vacío ni truncado)
|
||||||
|
sin volcar el contenido en texto plano.
|
||||||
|
- Se probó el `DATABASE_URL` real contra Postgres con `pg_isready` ejecutado
|
||||||
|
**dentro del propio pod `commerce-postgres-0`**: `accepting connections`,
|
||||||
|
exit 0.
|
||||||
|
- **Descartada:** el secret existe, tiene el formato correcto y las
|
||||||
|
credenciales son válidas.
|
||||||
|
|
||||||
|
### 2. NetworkPolicy bloqueando tráfico
|
||||||
|
|
||||||
|
- `kubectl describe networkpolicy -n ecommerce ecommerce-network-security`
|
||||||
|
→ `PodSelector: <none>` (aplica a todos los pods), `Allowing ingress
|
||||||
|
traffic: To Port: <any>, From: NamespaceSelector: <none>` (permite todo el
|
||||||
|
ingress desde cualquier namespace), `Not affecting egress traffic`.
|
||||||
|
- **Descartada:** la policy es efectivamente allow-all para ingress y no
|
||||||
|
restringe egress. No podía estar bloqueando la conexión de `medusa` hacia
|
||||||
|
`postgres-svc`.
|
||||||
|
|
||||||
|
### 3. Salud de Postgres
|
||||||
|
|
||||||
|
- `kubectl exec -n ecommerce commerce-postgres-0 -- psql -U medusa -c
|
||||||
|
"SELECT count(*) FROM pg_stat_activity;"` → `8` conexiones activas,
|
||||||
|
respuesta inmediata.
|
||||||
|
- `kubectl get endpoints -n ecommerce postgres-svc` → apunta correctamente a
|
||||||
|
`10.42.0.98:5432`, la IP real del pod.
|
||||||
|
- **Descartada:** Postgres estaba sano, aceptando conexiones y con el
|
||||||
|
Endpoint del Service correctamente resuelto.
|
||||||
|
|
||||||
|
### 4. ResourceQuota / LimitRange del namespace
|
||||||
|
|
||||||
|
- `kubectl describe resourcequota -n ecommerce` → `pods: 7/12`,
|
||||||
|
`requests.cpu: 625m/3`, `requests.memory: 1504Mi/4Gi`, `limits.cpu:
|
||||||
|
4250m/8`, `limits.memory: 4480Mi/8Gi` — todo muy por debajo de los topes.
|
||||||
|
- `kubectl describe limitrange -n ecommerce` → rangos por-contenedor
|
||||||
|
(16Mi–1Gi mem, 10m–2 cpu) compatibles con los `resources` definidos en
|
||||||
|
`medusa.yaml`.
|
||||||
|
- Sin eventos de `exceeded quota` en el namespace.
|
||||||
|
- **Descartada:** no había presión de cuota ni un contenedor rechazado por
|
||||||
|
LimitRange.
|
||||||
|
|
||||||
|
### 5. DNS
|
||||||
|
|
||||||
|
- `kubectl exec` en el pod viejo (sano) → `getent hosts postgres-svc`
|
||||||
|
resolvía a `10.42.0.98` correctamente.
|
||||||
|
- CoreDNS (`kube-system`) → `Running`, sano.
|
||||||
|
- Repetido el mismo chequeo **dentro del propio init container atascado**
|
||||||
|
(`wait-for-postgres` del pod nuevo) → misma resolución correcta, y
|
||||||
|
`/etc/resolv.conf` con `nameserver 10.43.0.10` normal.
|
||||||
|
- **Descartada:** la resolución DNS funcionaba de forma idéntica en el pod
|
||||||
|
sano y en el pod atascado.
|
||||||
|
|
||||||
|
### 6. MTU/PMTUD cross-node (blackhole conocido, ver `docs/known-issues.md`)
|
||||||
|
|
||||||
|
- Existe un blackhole de red confirmado (186/186 fallos reproducidos) cuando
|
||||||
|
un pod que necesita hablar con `commerce-postgres-0` queda agendado en un
|
||||||
|
nodo distinto al de Postgres, mitigado hoy con `podAffinity` en
|
||||||
|
`medusa.yaml`.
|
||||||
|
- `kubectl get pods -o wide` mostró que **los tres pods relevantes
|
||||||
|
(`commerce-postgres-0`, el pod viejo y el pod nuevo) estaban en el mismo
|
||||||
|
nodo** (`k3d-lab-cluster-server-0`).
|
||||||
|
- **Descartada para este incidente puntual:** al no haber tráfico
|
||||||
|
cross-node involucrado, el blackhole de MTU no podía ser la causa. Sigue
|
||||||
|
siendo una condición latente real del cluster (ver sección de deuda
|
||||||
|
técnica).
|
||||||
|
|
||||||
|
## Causa raíz real
|
||||||
|
|
||||||
|
El init container `wait-for-postgres` ejecutaba:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
until pg_isready -d "$DATABASE_URL" -t 5; do ...
|
||||||
|
```
|
||||||
|
|
||||||
|
pasando la URI completa de `DATABASE_URL` a `pg_isready`. En algún momento
|
||||||
|
se agregó el query param `?sslmode=disable` al `DATABASE_URL` generado por
|
||||||
|
`create-commerce-secrets.sh` (repo `scripts`, branch
|
||||||
|
`fix/medusa-database-url-sslmode`, ya ejecutada contra el secret vivo del
|
||||||
|
cluster). Con ese query param, el parser de conninfo de `pg_isready` en la
|
||||||
|
imagen `postgres:17-alpine` no lograba interpretar la URI y devolvía:
|
||||||
|
|
||||||
|
```
|
||||||
|
postgres-svc:5432 - no attempt
|
||||||
|
```
|
||||||
|
|
||||||
|
`exit 3` — **`no attempt`** es un status propio de `pg_isready` que indica
|
||||||
|
que el cliente **ni siquiera intentó conectar** por un problema en los
|
||||||
|
parámetros de conexión (a diferencia de `no response`, que sí implica un
|
||||||
|
intento de conexión fallido). Esto se reprodujo de forma determinística
|
||||||
|
ejecutando el mismo comando **dentro del propio init container atascado**
|
||||||
|
(sin crear pods efímeros nuevos), y se aisló la causa probando la misma URI:
|
||||||
|
|
||||||
|
- con `pg_isready -h postgres-svc -p 5432 -U medusa -d medusa` (sin URI) →
|
||||||
|
`accepting connections`, exit 0.
|
||||||
|
- con la URI completa pero sin el `?sslmode=disable` → también exitosa.
|
||||||
|
- con la URI completa incluyendo `?sslmode=disable` → reproducía el fallo.
|
||||||
|
|
||||||
|
Como el `until` no tiene timeout ni backoff máximo, el init container
|
||||||
|
quedaba reintentando indefinidamente sin nunca fallar de forma visible
|
||||||
|
(de ahí que no apareciera como CrashLoopBackOff).
|
||||||
|
|
||||||
|
## Fix aplicado
|
||||||
|
|
||||||
|
`workloads/ecommerce/commerce/medusa.yaml`, init container
|
||||||
|
`wait-for-postgres`:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- until pg_isready -d "$DATABASE_URL" -t 5; do
|
||||||
|
+ until pg_isready -h postgres-svc -p 5432 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -t 5; do
|
||||||
|
```
|
||||||
|
|
||||||
|
`postgres-svc` y `5432` son fijos (el Service de Postgres); `POSTGRES_USER`
|
||||||
|
y `POSTGRES_DB` ya llegan al contenedor vía el mismo `envFrom:
|
||||||
|
secretRef: commerce-secrets`. El chequeo de disponibilidad deja de depender
|
||||||
|
por completo del formato de `DATABASE_URL`, incluyendo cualquier query
|
||||||
|
param futuro.
|
||||||
|
|
||||||
|
Commit `e9c93bf` en `main` de `apps-registry`, pusheado y sincronizado por
|
||||||
|
Argo CD (`ecommerce-app`, revisión `e9c93bfb82ec09b8ea2cc3468040ef9d72416542`,
|
||||||
|
sync a las `2026-08-10T05:29:32Z`). El pod atascado
|
||||||
|
`medusa-deploy-9d8784d7b-g9jql` fue reemplazado automáticamente por
|
||||||
|
`medusa-deploy-56c7c878b6-xgnkb`, que completó `wait-for-postgres` en
|
||||||
|
segundos y quedó `1/1 Running` sin intervención manual adicional.
|
||||||
|
|
||||||
|
## Lecciones aprendidas
|
||||||
|
|
||||||
|
**Separar el chequeo de disponibilidad (`wait-for-postgres`) de las
|
||||||
|
migraciones (`migrations`) en init containers distintos fue lo correcto** y
|
||||||
|
ya estaba así desde que se introdujo este init container
|
||||||
|
(commit `f4ad140`). Eso permitió aislar el fallo al primer init container
|
||||||
|
sin ambigüedad — la falla nunca llegó a tocar `migrations`. La lección no es
|
||||||
|
cambiar esa separación, sino mantenerla: cualquier chequeo de
|
||||||
|
disponibilidad debe usar el mínimo de parámetros necesarios (host/puerto/
|
||||||
|
usuario/db) en vez de reusar una URI de conexión pensada para la app,
|
||||||
|
que puede cambiar de formato por razones ajenas al chequeo.
|
||||||
|
|
||||||
|
**No fue necesario crear pods de diagnóstico efímeros, y no crearlos evitó
|
||||||
|
ruido adicional en el cluster.** Toda la reproducción y aislamiento de la
|
||||||
|
causa raíz (DNS, `pg_isready` con distintos argumentos, lectura de
|
||||||
|
`/etc/resolv.conf`) se hizo con `kubectl exec` sobre pods que ya existían
|
||||||
|
(el pod viejo sano, el pod nuevo atascado, y `commerce-postgres-0`). Crear
|
||||||
|
pods de prueba repetidos consume cuota (`ecommerce-quota` está en `pods:
|
||||||
|
7/12`, con margen pero no ilimitado) y puede introducir su propio ruido de
|
||||||
|
scheduling/red — como muestra `docs/known-issues.md`, el diagnóstico previo
|
||||||
|
del blackhole de MTU sí necesitó pods de prueba dedicados (186 intentos)
|
||||||
|
porque el fenómeno dependía del nodo de scheduling, algo que no se puede
|
||||||
|
observar desde un pod ya corriendo. La regla práctica: usar pods
|
||||||
|
existentes cuando el fallo es reproducible ahí, y reservar pods efímeros
|
||||||
|
para cuando la variable a probar (ej. nodo, imagen, versión) no se puede
|
||||||
|
cambiar en un pod ya desplegado — midiendo el impacto (cuota, ruido) de
|
||||||
|
cada corrida.
|
||||||
|
|
||||||
|
## Deuda técnica pendiente (no resuelta por este incidente)
|
||||||
|
|
||||||
|
El blackhole de red cross-node por MTU/PMTUD documentado en
|
||||||
|
`docs/known-issues.md` sigue sin arreglo de fondo (ajustar MTU a `1400` en
|
||||||
|
la red Docker/flannel del cluster k3d). El workaround actual es el
|
||||||
|
`podAffinity` en `medusa.yaml` que fija `medusa-deploy` al mismo nodo que
|
||||||
|
`commerce-postgres-0`.
|
||||||
|
|
||||||
|
Existe una branch abierta, `fix/medusa-wait-for-postgres`, que:
|
||||||
|
|
||||||
|
- Elimina ese `podAffinity`.
|
||||||
|
- Borra `docs/known-issues.md` por completo.
|
||||||
|
- **No incluye el fix de MTU real** (no toca configuración de flannel ni de
|
||||||
|
la red Docker del cluster).
|
||||||
|
|
||||||
|
Mergear esa branch tal como está day-1 reintroduciría el blackhole cross-node
|
||||||
|
sin red de contención y borraría la única documentación del problema. No se
|
||||||
|
tocó como parte de este incidente. Queda pendiente decidir si se cierra sin
|
||||||
|
mergear, o si se retoma agregando primero el fix real de MTU antes de poder
|
||||||
|
quitar el `podAffinity` con seguridad.
|
||||||
Reference in New Issue
Block a user