docs(docs-portal): reflejar fixes de MTU, gitea-runner y cosign digest/rekor
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

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
This commit is contained in:
devopsandClaude Sonnet 5 committed 2026-08-16 23:07:26 -05:00
1 parent 86fcc88a1e
commit b0b832f142
6 files changed
+200 -81

No files matched your search

+48 -45
View File
@@ -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:<digest>
```
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)
@@ -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
@@ -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.
@@ -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).
@@ -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 <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.
+1
View File
@@ -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: