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
This commit is contained in:
1 parent
86fcc88a1e
commit
b0b832f142
6 files changed
+200
-81
No files matched your search
@@ -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
|
||||
|
||||
+25
-30
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user