Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0134a4dd0b | ||
|
|
3fe63591ec | ||
|
|
c6cf394c26 | ||
|
|
7a09202c27 | ||
|
|
a902dc0a26 | ||
|
|
04dc6bd8bc | ||
|
|
60072cfcbe | ||
|
|
c9b376c4e6 | ||
|
|
cea41ce428 | ||
|
|
381e6ab6ab | ||
|
|
e9c93bfb82 | ||
|
|
f25b10a2f9 | ||
|
|
7a83ed61b9 | ||
|
|
987ef04b0d |
@@ -0,0 +1,44 @@
|
||||
# Known issues
|
||||
|
||||
## Blackhole de red cross-node en el cluster k3d (MTU/PMTUD, flannel VXLAN)
|
||||
|
||||
**Estado:** workaround aplicado, fix definitivo pendiente.
|
||||
|
||||
**Síntoma:** cualquier pod que necesite hablar con `commerce-postgres-0`
|
||||
(StatefulSet, sin réplicas, fijo a un nodo) falla de forma consistente y
|
||||
reproducible al 100% cuando queda agendado en un nodo **distinto** al de
|
||||
Postgres. El handshake TCP (SYN/ACK, `nc`, `ping`) funciona perfecto entre
|
||||
nodos, pero el primer paquete de datos real de la sesión (protocolo Postgres,
|
||||
`pg_isready` incluido) nunca llega — se cuelga hasta hacer timeout.
|
||||
|
||||
**Diagnóstico:**
|
||||
|
||||
- Confirmado aislando el nodo con `nodeName` en pods de prueba: 100% de
|
||||
fallos (186/186 intentos) agendando cross-node (`agent-0` → Postgres en
|
||||
`server-0`); 100% de éxito agendando en el mismo nodo.
|
||||
- MTU dentro de los pods (interfaz `flannel.1`/VXLAN) es 1450 en ambos nodos
|
||||
— consistente, no es un mismatch obvio a nivel flannel.
|
||||
- `ping` sin `-M do` (permite fragmentación) no muestra pérdida de paquetes
|
||||
hasta payloads de 2000 bytes entre los contenedores Docker de los nodos.
|
||||
- Encaja con un blackhole de PMTU Discovery: el bridge Docker externo que
|
||||
conecta los contenedores de los nodos k3d probablemente tiene un MTU real
|
||||
menor a 1500, y los paquetes TCP con el bit DF puesto (como los de la
|
||||
sesión de Postgres) se pierden en vez de fragmentarse o generar el ICMP
|
||||
"fragmentation needed" que permitiría el ajuste automático.
|
||||
|
||||
**Workaround temporal (aplicado en `workloads/ecommerce/commerce/medusa.yaml`):**
|
||||
|
||||
`podAffinity` con `requiredDuringSchedulingIgnoredDuringExecution` sobre
|
||||
`medusa-deploy`, forzando que sus pods se agenden siempre en el mismo nodo
|
||||
que `commerce-postgres-0` (`matchLabels: app: commerce-postgres`,
|
||||
`topologyKey: kubernetes.io/hostname`). Esto evita el tráfico cross-node
|
||||
entre Medusa y Postgres, pero no resuelve el problema de fondo — cualquier
|
||||
otro workload que necesite cruzar nodos para hablar con Postgres (u otro
|
||||
servicio) puede pisar el mismo blackhole.
|
||||
|
||||
**TODO — fix definitivo pendiente:**
|
||||
|
||||
Ajustar el MTU del cluster k3d a un valor seguro (`1400`) de forma
|
||||
consistente en la red Docker del cluster y en flannel, para eliminar el
|
||||
blackhole de raíz y poder quitar el `podAffinity` (o dejarlo como
|
||||
optimización, no como requisito de disponibilidad).
|
||||
@@ -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.
|
||||
@@ -25,6 +25,16 @@ module.exports = defineConfig({
|
||||
jwtSecret: required("JWT_SECRET"),
|
||||
cookieSecret: required("COOKIE_SECRET"),
|
||||
},
|
||||
// Todos los Ingress del lab terminan en Traefik por HTTP plano
|
||||
// (entrypoint "web", sin TLS); Cloudflare es quien atiende HTTPS de
|
||||
// cara al navegador. Con `secure: true` (el default en producción),
|
||||
// express-session nunca emite Set-Cookie porque no detecta la
|
||||
// conexión como HTTPS, y el dashboard admin queda con 401 en
|
||||
// /admin/users/me pese a loguear bien. La API con Bearer token no se
|
||||
// ve afectada por esto.
|
||||
cookieOptions: {
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
|
||||
admin: {
|
||||
|
||||
@@ -9,6 +9,13 @@ import { getProductBySlug, getProducts } from "@/lib/headless/client";
|
||||
|
||||
interface ProductPageProps { params: Promise<{ slug: string }> }
|
||||
|
||||
// Sin esto, la página queda estática para siempre con los datos del build
|
||||
// (que usa el catálogo mock porque HEADLESS_PROVIDER/MEDUSA_* no existen en
|
||||
// el contexto del Docker build). El listado sí se refresca porque su ruta
|
||||
// es dinámica por searchParams; esta ruta necesita el revalidate explícito
|
||||
// para que el runtime (que sí tiene las env vars de Medusa) la regenere.
|
||||
export const revalidate = 60;
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const products = await getProducts();
|
||||
return products.map((product) => ({ slug: product.slug }));
|
||||
|
||||
@@ -20,6 +20,14 @@ spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry-secret
|
||||
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
app: commerce-postgres
|
||||
topologyKey: kubernetes.io/hostname
|
||||
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: postgres:17-alpine
|
||||
@@ -28,7 +36,7 @@ spec:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
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
|
||||
echo "postgres no disponible aun, reintentando..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
@@ -16,9 +16,19 @@ spec:
|
||||
- name: gitea-registry-secret
|
||||
containers:
|
||||
- name: web
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.77
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.86
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
- name: HEADLESS_PROVIDER
|
||||
value: medusa
|
||||
- name: MEDUSA_BACKEND_URL
|
||||
value: http://medusa-svc:9000
|
||||
- name: MEDUSA_REGION_ID
|
||||
value: reg_01KZT86WPAH7A7ZZRDSX3350V2
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: commerce-storefront
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
@@ -258,6 +258,14 @@ function backendUrl(): string {
|
||||
return value.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function regionId(): string {
|
||||
const value = process.env.MEDUSA_REGION_ID;
|
||||
if (!value) {
|
||||
throw new Error("MEDUSA_REGION_ID no está configurado.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function medusaFetch<T>(
|
||||
path: string,
|
||||
searchParams?: URLSearchParams,
|
||||
@@ -515,7 +523,10 @@ async function fetchProducts(
|
||||
): Promise<Product[]> {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
currency_code: "cop",
|
||||
// El validador de /store/products no acepta currency_code (400
|
||||
// "Unrecognized fields"); el precio calculado solo se resuelve con
|
||||
// region_id.
|
||||
region_id: regionId(),
|
||||
fields:
|
||||
"+metadata,+images,+tags,+categories,+collection,+variants.inventory_quantity,+variants.calculated_price,+variants.options",
|
||||
});
|
||||
@@ -543,7 +554,10 @@ export const medusaCatalogProvider: CatalogProvider = {
|
||||
const params = new URLSearchParams({
|
||||
handle: slug,
|
||||
limit: "1",
|
||||
currency_code: "cop",
|
||||
// El validador de /store/products no acepta currency_code (400
|
||||
// "Unrecognized fields"); el precio calculado solo se resuelve con
|
||||
// region_id.
|
||||
region_id: regionId(),
|
||||
fields:
|
||||
"+metadata,+images,+tags,+categories,+collection,+variants.inventory_quantity,+variants.calculated_price,+variants.options",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user