diff --git a/workloads/docs-portal/docs/devsecops/cosign.md b/workloads/docs-portal/docs/devsecops/cosign.md index 15ae440..1caaef6 100644 --- a/workloads/docs-portal/docs/devsecops/cosign.md +++ b/workloads/docs-portal/docs/devsecops/cosign.md @@ -79,51 +79,53 @@ localmente, sin tocar el transparency log público ## Qué NO se firma -Solo se firma el tag versionado (`ecommerce-frontend:v1.0.X`), no -`:latest`. `:latest` es un tag mutable — se re-apunta a una imagen -distinta en cada build — así que firmarlo no significa nada útil: la -firma quedaría asociada al digest de turno, y la siguiente build la -volvería a mover. Cualquier verificación real de firma debería apuntar -siempre a un tag de versión específico (o, mejor todavía, al digest -exacto). +No se firma `:latest` en ningún caso — es un tag mutable, se re-apunta a +una imagen distinta en cada build, así que firmarlo no significaría nada +útil. Y desde 2026-08-16 (ver más abajo) tampoco se firma directamente +por el tag versionado — se firma por **digest**, que es la única +referencia que realmente identifica una imagen de forma inequívoca. ## Verificar manualmente Con la llave pública del repo, cualquiera puede confirmar la firma de -una imagen sin necesitar acceso a nada privado: +una imagen sin necesitar acceso a nada privado. Apuntando al digest +exacto (lo que firma el pipeline hoy): ```bash cosign verify \ --key workloads/ecommerce/cosign.pub \ --insecure-ignore-tlog=true \ - gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.97 + gitea.cruzcloud.net/devops/ecommerce-frontend@sha256: ``` +También funciona apuntando a un tag — Cosign lo resuelve al digest que +tenga en ese momento antes de verificar —, pero por eso mismo es menos +preciso: si el tag fue reasignado después de la firma original, estarías +verificando una imagen distinta de la firmada, sin que nada te avise. + Si la imagen fue firmada por este pipeline, el comando termina con `exit 0` y muestra el detalle de la firma. Si no — sea porque nunca se firmó, porque la firmó otra llave, o porque la imagen fue modificada después — termina con `exit 1` y un error explícito. -## Limitaciones conocidas y mejoras futuras +## Decisiones ya resueltas (antes eran TODO) -Dos limitaciones identificadas al validar el pipeline end-to-end, -documentadas a propósito como TODO — ninguna de las dos se implementó -todavía. +Dos preguntas abiertas al validar el pipeline end-to-end por primera vez, +ambas ya cerradas — se documentan igual, porque el *por qué* de cada +decisión sigue siendo útil. -### 1. Se firma por tag, no por digest +### 1. Firma por digest, no por tag — implementado 2026-08-16 -Hoy el pipeline firma `ecommerce-frontend:v1.0.104` — un **tag**, no un -**digest** (`sha256:...`). Un tag es una etiqueta mutable: nada impide -que, después de firmada la imagen, alguien (o un bug en el propio -pipeline) vuelva a subir contenido distinto bajo el mismo tag -`v1.0.104`. La firma original seguiría "verificando" — porque Cosign, -al verificar por tag, resuelve el tag al digest que tenga *en ese -momento*, no al que tenía cuando se firmó. Si el tag fue reasignado, -se está verificando una imagen distinta de la que realmente se firmó, -sin que nada avise. +Hasta el 2026-08-15, el pipeline firmaba `ecommerce-frontend:v1.0.104` — +un **tag**, no un **digest** (`sha256:...`). Un tag es una etiqueta +mutable: nada impedía que, después de firmada la imagen, alguien (o un +bug en el propio pipeline) volviera a subir contenido distinto bajo el +mismo tag `v1.0.104`. La firma original seguiría "verificando" — porque +Cosign, al verificar por tag, resuelve el tag al digest que tenga *en +ese momento*, no al que tenía cuando se firmó. -Esto no es hipotético: el propio Cosign lo advierte en cada firma de -este pipeline (visto en los logs reales de cada run): +Esto no era hipotético: el propio Cosign lo advertía en cada firma de +este pipeline: ```text WARNING: Image reference gitea.cruzcloud.net/.../ecommerce-frontend:v1.0.102 @@ -131,20 +133,20 @@ uses a tag, not a digest, to identify the image to sign. This can lead you to sign a different image than the intended one. ``` -**TODO:** capturar el digest exacto que devuelve `docker push` (o -`docker buildx build --metadata-file`) y firmar/verificar contra ese -digest en vez del tag — +**Fix aplicado:** el pipeline ahora captura el digest real que devuelve +`docker push` (parseando `sha256:[a-f0-9]{64}` de la salida) y lo usa +tanto para firmar como para verificar — `gitea.cruzcloud.net/devops/ecommerce-frontend@sha256:...` en vez de -`:v1.0.104`. No implementado todavía; requiere ajustar el step de -build para exponer el digest como output y pasarlo a los steps de -firma y verificación. +`:v1.0.104` — en los tres pipelines (`ecommerce-frontend`, +`commerce-backend`, `docs-portal`). Esa advertencia de Cosign ya no +debería aparecer en los logs. -### 2. El transparency log (Rekor) está deshabilitado +### 2. El transparency log (Rekor) se mantiene deshabilitado — decisión, no pendiente Ya se explicó arriba, en la sección "Por qué `--tlog-upload=false`" de -esta misma página, la decisión consciente de no publicar en el -transparency log para este registry privado. Vale la pena nombrar en -simple qué es lo que se está dejando afuera: +esta misma página, la decisión de no publicar en el transparency log +para este registry privado. Vale la pena nombrar en simple qué es lo +que se está dejando afuera: Un **transparency log** (Rekor, el de Sigstore) es un registro público, append-only, criptográficamente verificable, de "quién firmó @@ -160,17 +162,18 @@ la generó quien tuviera la llave privada en el momento en que se verificó"* — pero no hay ningún registro externo e inmutable que demuestre *cuándo* se generó, ni una forma de detectar si alguien con acceso a la llave privada firmó algo por fuera del pipeline sin que -quede rastro. Para un lab personal con un registry privado, ese -trade-off es razonable (ver la advertencia más abajo en esta misma -página). Pero es una limitación real, no solo un detalle de -configuración — importa especialmente el día que este mismo patrón se -use en un contexto con más de una persona firmando, o con un registry -que deje de ser privado. +quede rastro. -**TODO:** si en algún momento el registry deja de ser exclusivamente -privado, o se suma más de una persona con acceso a la llave de firma, -reevaluar habilitar el transparency log público de Sigstore (o correr -uno privado propio) en vez de mantenerlo deshabilitado. +**Revisado explícitamente el 2026-08-16** (ya no es un TODO sin decidir, +es una elección consciente confirmada): se mantiene deshabilitado. +Motivos concretos para este lab — habilitarlo agregaría una llamada de +red saliente por build hacia `rekor.sigstore.dev`, atravesando el mismo +túnel de Cloudflare que ya tuvo un incidente de SNI/orden de reglas con +Cosign (ver el playbook de [realm HTTP y Cosign](../playbooks/incidente-cosign-realm-http-tunnel-2026-08.md)) — más +superficie de fallo por build, sin beneficio real, ya que el registry es +privado y no hay a quién auditar públicamente. Revisar esta decisión de +nuevo si el registry deja de ser exclusivamente privado, o si se suma +más de una persona con acceso a la llave de firma. ## Qué falta (a propósito, todavía) diff --git a/workloads/docs-portal/docs/devsecops/index.md b/workloads/docs-portal/docs/devsecops/index.md index 514aceb..ce778d5 100644 --- a/workloads/docs-portal/docs/devsecops/index.md +++ b/workloads/docs-portal/docs/devsecops/index.md @@ -105,9 +105,6 @@ flowchart TD - Admission controller en el cluster que verifique la firma de Cosign antes de dejar correr un Pod (ver [cosign.md](cosign.md)) — hoy la verificación es solo un smoke test dentro del propio pipeline. -- Firmar por digest en vez de por tag, y reevaluar el transparency log - de Sigstore — ver - [limitaciones conocidas de Cosign](cosign.md#limitaciones-conocidas-y-mejoras-futuras). - `commerce-backend` tiene dos CVE CRITICAL documentados como excepción puntual en `workloads/commerce-backend/.trivyignore` (Go stdlib embebido en el binario de esbuild, sin fix compatible con la versión diff --git a/workloads/docs-portal/docs/playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md index 447c467..1ee42dc 100644 --- a/workloads/docs-portal/docs/playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md +++ b/workloads/docs-portal/docs/playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md @@ -97,37 +97,32 @@ distintos: el `docker-compose.yml` de `gitea` y el script que crea job pesado, sin ningún límite de recursos, puede acaparar suficiente CPU/memoria como para dejar sin aire al resto del host. -## Fix propuesto (documentado, aplicación manual pendiente) +## Fix aplicado (2026-08-16) -1. **`scripts/deploy-lab.sh`** (rama `fix/gitea-runner-resource-limits`, - pendiente de mergear): agrega `--cpu-shares 512 --memory 8g - --memory-swap 10g` a la creación de `gitea-runner`. Solo afecta a la - próxima vez que se cree el contenedor (la función es idempotente), - no al que ya está corriendo. -2. **Fix inmediato en caliente** (sin recrear contenedores), a aplicar - manualmente por fuera de este repo: - - ```bash - docker update --cpu-shares 1024 gitea - docker update --cpu-shares 512 --memory 8g --memory-swap 10g gitea-runner - ``` - -3. **`docker-compose.yml` de Gitea** (fuera de este repo, acceso - root-only): subir `cpu_shares` de `gitea` de 90 a 1024 — pendiente - de aplicar manualmente, no se implementó todavía. -4. **Mover `gitea-runner` a k3d** como job/pod separado: evaluado como - posible mejora de fondo (aislamiento real vía Kubernetes en vez de - límites de Docker sueltos), pero queda **solo como recomendación - documentada** — no implementado en esta vuelta. +1. **`scripts/deploy-lab.sh`** (mergeado a `main`): agrega + `--cpu-shares 512 --memory 8g --memory-swap 10g` a la creación de + `gitea-runner`. Solo afecta a la próxima vez que se recree el + contenedor (la función es idempotente) — el runner queda con menos + prioridad relativa que Gitea a propósito. +2. **`docker-compose.yml` de Gitea** (fuera de este repo, en el host + ZimaOS — `/DATA/.casaos/apps/gitea/docker-compose.yml`, acceso + root-only): `cpu_shares` de `gitea` subido de 90 a **1024** + (el default de Docker), aplicado en caliente con + `docker compose up -d` y validado con `docker inspect`. +3. **Mover `gitea-runner` a k3d** como job/pod separado: sigue quedando + **solo como recomendación documentada**, no implementado — la + contención real ya se resolvió con los límites de arriba, así que + esto pasa a ser una mejora de aislamiento a futuro, no una urgencia. ## Validación -Con `gitea` y `gitea-runner` estables (sin reinicios) después del -incidente, se reintentó el mismo pipeline y corrió de punta a punta -sin interrupciones — pero eso confirma que el host se estabilizó -después del pico de carga, **no** que el fix de límites de recursos ya -esté aplicado (sigue pendiente, ver arriba). La correlación completa -carga-pesada → reinicio ya se había visto antes, en dos fechas -distintas (2026-07-30 y 2026-08-11) además de esta — no es un evento -aislado, es un patrón recurrente que ahora tiene una causa raíz -concreta y una propuesta de fix. +Con el fix aplicado: `docker inspect gitea --format +'{{.HostConfig.CpuShares}}'` confirma `1024`, el contenedor se recreó +limpio (`Started`, sin errores) y siguió sirviendo tráfico normal +(`200 OK` en `http://127.0.0.1:3002/` inmediatamente después). La +correlación completa carga-pesada → reinicio se había visto en tres +fechas distintas (2026-07-30, 2026-08-11 y el propio 2026-08-15 de este +incidente) antes de este fix — no era un evento aislado. Queda por +confirmar con el próximo pipeline pesado real que la contención no +vuelva a aparecer, pero la causa raíz (pesos de CPU desbalanceados y sin +techo de memoria en el runner) ya no está presente. diff --git a/workloads/docs-portal/docs/playbooks/incidente-cosign-realm-http-tunnel-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-cosign-realm-http-tunnel-2026-08.md index a2aa6f8..c7079e8 100644 --- a/workloads/docs-portal/docs/playbooks/incidente-cosign-realm-http-tunnel-2026-08.md +++ b/workloads/docs-portal/docs/playbooks/incidente-cosign-realm-http-tunnel-2026-08.md @@ -88,6 +88,6 @@ credenciales reales, de punta a punta. !!! note "Nota relacionada, no parte de este incidente" El mismo log de este fallo mostró, además del error de realm, el - warning esperado de Cosign sobre firmar por tag en vez de por - digest — ver - [limitaciones conocidas de Cosign](../devsecops/cosign.md#limitaciones-conocidas-y-mejoras-futuras). + warning de Cosign sobre firmar por tag en vez de por digest — ya + resuelto el 2026-08-16, ver + [decisiones ya resueltas de Cosign](../devsecops/cosign.md#decisiones-ya-resueltas-antes-eran-todo). diff --git a/workloads/docs-portal/docs/playbooks/incidente-mtu-blackhole-cross-node-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-mtu-blackhole-cross-node-2026-08.md new file mode 100644 index 0000000..1f999f1 --- /dev/null +++ b/workloads/docs-portal/docs/playbooks/incidente-mtu-blackhole-cross-node-2026-08.md @@ -0,0 +1,123 @@ +# 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](incidente-crashloop-medusa.md), 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: + +```bash +docker exec 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. diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml index 640f5b0..1889766 100644 --- a/workloads/docs-portal/mkdocs.yml +++ b/workloads/docs-portal/mkdocs.yml @@ -98,6 +98,7 @@ nav: - Docker-in-Docker y Semgrep: playbooks/incidente-docker-in-docker-semgrep-2026-08.md - Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md - Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md + - Blackhole MTU cross-node: playbooks/incidente-mtu-blackhole-cross-node-2026-08.md - Aprendizajes: - Notas sueltas: aprendizajes/notas-sueltas.md - Guía del estudiante: