docs(docs-portal): Fase 2 - contenido narrativo real de arquitectura/decisiones/aprendizajes

Reemplaza los placeholders "TODO (fase 2+)" por narrativa completa,
grounded en el codigo/manifiestos/incidentes reales del lab (no
generico):

- ADR 0001 (por que k3d) y ADR 0002 (por que Argo CD): pasan de
  template vacio a Contexto/Opciones/Decision/Consecuencias reales,
  apoyados en known-issues.md (blackhole MTU) y en la config real de
  ecommerce-app.yaml.
- vision-general.md: el "por que" de cada frontera de confianza del
  diagrama.
- gitops-flujo.md: ejemplo real con hashes de commit (Fase 3b de
  ADR 0006, v1.0.171 -> hotfix -> v1.0.172), selfHeal explicado con el
  syncPolicy real del repo.
- red-y-exposicion.md: diagrama de terminacion TLS real, por que todo
  va "proxied", patron de diagnostico "interno sano, publico roto".
- glosario.md: 9 terminos nuevos, cada uno con ejemplo real y
  verificado del repo (Namespace, ResourceQuota, LimitRange,
  NetworkPolicy, Deployment, Service, Ingress, StatefulSet, Init
  container).
- notas-sueltas.md: MinIO/S3, limites reales del subscriber
  revalidate-storefront.ts, que se automatizaria desde cero.

Validado con `docker build --target build` (mkdocs build --strict,
mismo paso que el pipeline real) -- cero links rotos, cero warnings.
This commit is contained in:
devops committed 2026-08-26 23:51:18 -05:00
1 parent b7443a034f
commit 056a7fb76a
7 files changed
+490 -80

No files matched your search

@@ -1,9 +1,9 @@
# Del commit al despliegue: flujo GitOps
> **TODO (fase 2+):** narrar cada paso con ejemplos reales tomados de
> `.gitea/workflows/build-medusa.yaml` y `build.yaml`. El diagrama de
> secuencia de abajo refleja el pipeline real (build → push imagen →
> promoción segura → commit del tag → sync de Argo CD).
El diagrama de abajo refleja el pipeline real (build → push imagen →
promoción segura → commit del tag → sync de Argo CD). Después del
diagrama hay un ejemplo con hashes de commit reales, tomado de un
cambio real hecho sobre este mismo repo.
## Secuencia: un commit hasta quedar corriendo en el cluster
@@ -42,9 +42,53 @@ sequenceDiagram
evita que un pipeline viejo sobreescriba el tag con una versión
anterior si hubo un push más reciente mientras corría.
## TODO
## Ejemplo real: un commit de principio a fin
- Ejemplo real con hashes de commit (tomar del historial de
`apps-registry`).
- Explicar `syncPolicy.automated.selfHeal` con un caso concreto (qué
pasa si alguien hace `kubectl edit` a mano).
Fase 3b del ADR 0006 (facetas del sidebar del catálogo, ver
`decisiones/0006-motor-busqueda-facetas.md`) recorrió el flujo
completo dos veces seguidas el mismo día:
| Paso | Commit / evento real |
|---|---|
| Feature | `83d448b` — `feat(ecommerce): facetas reales de Meilisearch en el sidebar del catalogo`, en `fix/adr0006-etapa2-fase3b-facetas-sidebar` |
| Merge a `main` | `6b5450f` |
| CI build+push | Gitea Actions task 258, imagen `gitea.cruzcloud.net/devops/ecommerce-frontend` |
| Release commit (CD) | `951682a` — `chore: release v1.0.171 [skip ci]`, bump automático del tag en `frontend/patches/deployment.yaml` |
| Sync de Argo CD | dos ciclos distintos: primero sincronizó contra `6b5450f` (el merge, todavía con el tag viejo), y en el ciclo siguiente detectó `951682a` y aplicó el tag nuevo |
| Rollout | `frontend-deploy` con 2/2 réplicas en `v1.0.171` |
El gate en vivo contra ese mismo cambio encontró un bug real
(`estimatedTotalHits` en 0 con `limit=0` en Meilisearch) — el fix
repitió el mismo ciclo completo: `884dbd1` → merge `ba3e6c7` → release
`v1.0.172` → sync → rollout. Dos vueltas completas del diagrama de
arriba en la misma sesión, cada una verificada contra el endpoint real
antes de darla por cerrada.
**Detalle no obvio:** Argo CD sincroniza el *merge commit* primero
(todavía con la imagen vieja, porque el bump de tag es un commit
*posterior* que hace el propio pipeline) y recién en un segundo ciclo
de poll detecta el commit de release. Son dos sincronizaciones
distintas, no una sola — hay que esperar ambas antes de asumir que el
pod nuevo está corriendo.
## `selfHeal`, con la configuración real
Todas las Applications de este repo comparten el mismo `syncPolicy`
(`apps/ecommerce-app.yaml`):
```yaml
syncPolicy:
automated:
prune: true
selfHeal: true
```
Con `selfHeal: true`, si alguien corre `kubectl scale deployment
frontend-deploy --replicas=1` directo contra el cluster (saltándose
Git por completo), Argo CD no se queda en mostrar `OutOfSync` — en el
siguiente ciclo de reconciliación vuelve a aplicar el manifiesto tal
como está en `main` y el Deployment queda otra vez en las réplicas
declaradas ahí. El cambio manual no se pierde silenciosamente: queda
un instante como drift visible en la UI de Argo CD, pero nunca se
vuelve el estado final. La única forma de que ese cambio persista es
que exista en Git — lo mismo que describe [ADR 0002](../decisiones/0002-por-que-argocd-vs-manual.md).
@@ -65,9 +65,142 @@ plantillas (a diferencia de Helm).
incluir y qué parches aplicarles, sin necesidad de un lenguaje de
templating separado.
## TODO
## Namespace
- Namespace, ResourceQuota, LimitRange, NetworkPolicy
- Ingress vs. Service vs. Deployment
- StatefulSet (por qué Postgres lo usa y el frontend no)
- Init container
**En una frase:** una partición lógica dentro del mismo cluster físico
— agrupa recursos relacionados y les pone un límite alrededor.
**Detalle:** este lab tiene un namespace por dominio de negocio
(`ecommerce`, `docs-portal`) más `argocd` para el propio Argo CD. Todo
lo que gobierna a un namespace (cuotas, políticas de red) vive en
`platform-infra`, separado de los manifiestos de la app — así una app
nueva no puede "olvidarse" de traer su propia gobernanza.
## ResourceQuota
**En una frase:** el techo total de recursos que un namespace entero
puede consumir, sumando todos sus pods.
**Detalle:** la cuota real de `ecommerce`
(`platform-infra/ecommerce-governance/resourcequota.yaml`):
```yaml
hard:
requests.cpu: "3"
requests.memory: 4Gi
limits.cpu: "8"
limits.memory: 8Gi
pods: "12"
persistentvolumeclaims: "6"
requests.storage: 80Gi
```
Si un Deployment nuevo pidiera más `requests.memory` del que queda
disponible bajo ese techo, Kubernetes rechaza el pod directamente — no
lo deja arrancar y luego fallar.
## LimitRange
**En una frase:** los valores por defecto (y los mínimos/máximos) que
aplican a un contenedor *individual* dentro de un namespace, para
cuando el manifiesto no los declara explícitos.
**Detalle:** complementa a `ResourceQuota` (que mira el namespace
entero) a nivel de un solo contenedor. La de `ecommerce`
(`limitrange.yaml`) pone `defaultRequest: 100m/128Mi`,
`default: 500m/512Mi` (límites si el contenedor no los declara), y un
rango permitido de `10m/16Mi` a `2 CPU/1Gi` por contenedor — evita
tanto el contenedor que no pide nada y termina compitiendo mal por
recursos, como el que pide de más y se come la cuota del namespace
entero él solo.
## NetworkPolicy
**En una frase:** un firewall a nivel de Kubernetes — qué tráfico de
red se permite entrar (o salir) de un pod.
**Detalle:** sin ninguna `NetworkPolicy`, todos los pods de un cluster
pueden hablarse entre sí libremente. La de `ecommerce`
(`networkpolicy.yaml`) usa `podSelector: {}` (aplica a todos los pods
del namespace) y permite ingress solo desde `namespaceSelector: {}`
(cualquier namespace del cluster, típicamente Traefik llegando desde
`kube-system` o el namespace de ingress) — bloquea tráfico externo al
cluster que intentara saltarse Traefik y pegarle directo a un Service
interno.
## Deployment
**En una frase:** el controlador para cargas de trabajo **sin
estado** — réplicas intercambiables entre sí, ninguna con identidad
propia.
**Detalle:** `frontend-deploy` es un Deployment de 2 réplicas: no
importa cuál de las dos atienda una petición, ambas sirven exactamente
lo mismo y ninguna guarda datos propios en disco. Si un pod muere,
Kubernetes crea otro cualquiera para reemplazarlo — no hay "el pod
original" que recuperar. Contrastar con **StatefulSet** abajo.
## Service
**En una frase:** un nombre de red estable que apunta a un grupo de
pods, aunque esos pods cambien.
**Detalle:** los pods son efímeros (mueren y se recrean con IP nueva
cada vez); un Service (ej. `frontend-svc`, `postgres-svc`) les da un
nombre DNS interno fijo (`frontend-svc.ecommerce.svc.cluster.local`,
o simplemente `frontend-svc` desde dentro del mismo namespace) que no
cambia aunque los pods de atrás sí. `postgres-svc` en este lab es
además un Service `clusterIP: None` ("headless") — necesario porque
`commerce-postgres` es un StatefulSet y cada pod necesita su propia
identidad de red estable, no una IP compartida balanceada entre
réplicas.
## Ingress
**En una frase:** la regla que dice "las peticiones a este dominio y
esta ruta van a este Service", para tráfico HTTP que entra al cluster.
**Detalle:** `ecommerce-ingress` enruta por *path*, no solo por
dominio: `shop.cruzcloud.net/api/catalog` va a `catalog-api-svc`, y
todo lo demás (`/`) va a `frontend-svc` — un solo objeto Ingress con
dos backends distintos según el prefijo de la URL. Es Traefik (el
Ingress Controller del cluster) quien lee estos objetos y hace el
enrutamiento real; el Ingress en sí es solo la declaración de la
regla, no el componente que la ejecuta.
## StatefulSet
**En una frase:** el controlador para cargas de trabajo **con
estado** — cada réplica tiene identidad propia y almacenamiento propio
que sobrevive a que el pod se reinicie.
**Detalle:** `commerce-postgres` es un StatefulSet, no un Deployment,
porque el frontend puede tener 2 réplicas idénticas sin datos propios,
pero Postgres no — sus datos tienen que vivir en un disco específico
que sobreviva un reinicio del pod y sepa exactamente cuál pod es
"el suyo". Cada pod de un StatefulSet obtiene su propio
`PersistentVolumeClaim` vía `volumeClaimTemplates` (acá,
`postgres-data`, 10Gi en `local-path`) y un nombre de red estable y
numerado (`commerce-postgres-0`), a diferencia de los nombres
aleatorios de un Deployment. Meilisearch, Redis y MinIO en este lab
siguen el mismo patrón por la misma razón — todos guardan datos en
disco que tienen que sobrevivir al pod.
## Init container
**En una frase:** un contenedor que corre **antes** que los
contenedores principales de un pod, y tiene que terminar bien (exit
0) para que el resto arranque.
**Detalle:** `medusa-deploy` usa dos, y a propósito están separados en
vez de ser uno solo: `wait-for-postgres` (`pg_isready` en loop, solo
espera a que Postgres acepte conexiones) y `migrations`
(`db:migrate`, corre las migraciones de verdad). Un incidente real
mostró por qué separarlos importa: cuando el chequeo de disponibilidad
reusaba la misma `DATABASE_URL` completa de la app (con
`?sslmode=disable` incluido), el parser de `pg_isready` no la entendía
y el pod quedaba atascado en `Init` — reportado como si fuera
`CrashLoopBackOff`, sin serlo. El fix fue que el chequeo de
disponibilidad use el mínimo de parámetros posible
(host/puerto/usuario/db), nunca la URI completa pensada para la app.
Ver el [playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
@@ -1,8 +1,7 @@
# Red y exposición
> **TODO (fase 2+):** explicar cada dominio, por qué está proxied vs
> DNS-only en Cloudflare, y el manejo de TLS extremo a extremo. Tabla
> de dominios abajo es real, pendiente de expandir con capturas/ejemplos.
Los 5 dominios de abajo son reales y comparten exactamente el mismo
camino de red — ninguno tiene un caso especial.
## Dominios activos
@@ -14,12 +13,63 @@
| `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) |
| `docs.cruzcloud.net` | Este portal | `docs` | Cloudflare (edge) |
## TODO
## Por qué todos van "proxied" (naranja), ninguno "DNS-only"
- Diagrama de TLS termination (dónde termina TLS realmente: en
Cloudflare, no en Traefik — el tráfico interno NPM→k3d es HTTP plano).
- Explicar por qué el transporte del túnel se fijó a HTTP/2 en vez de
QUIC (ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)).
- Patrón de diagnóstico "interno sano, público roto" (ver
[playbook de imágenes NPM](../playbooks/incidente-imagenes-npm.md)).
Un registro DNS-only en Cloudflare expondría directamente la IP real
del túnel — pero acá no hay IP pública fija que exponer: el NAS está
detrás de NAT residencial. El modo proxied es lo que hace que
Cloudflare Tunnel tenga sentido en primer lugar (el tráfico entra por
el edge de Cloudflare, nunca directo al origen), así que no hay ningún
dominio de este lab para el que DNS-only sea una opción real.
## Dónde termina TLS de verdad
```mermaid
flowchart LR
User(["Usuario"]) -->|HTTPS| CF["Cloudflare edge<br/>🔒 TLS termina acá"]
CF -->|túnel cifrado, HTTP/2| CFD["cloudflared"]
CFD -->|HTTP plano| NPM["Nginx Proxy Manager"]
NPM -->|HTTP plano| LB["k3d-proxy :90"]
LB -->|HTTP plano| TR["Traefik"]
TR -->|HTTP plano| SVC["Service interno"]
```
TLS termina en el **edge de Cloudflare**, no en Traefik ni en NPM. De
ahí hacia adentro (`cloudflared` → NPM → `k3d-proxy` → Traefik →
Service) todo el tráfico va en HTTP plano — seguro en este contexto
porque ese tramo entero vive dentro de la red local del NAS, nunca
sale a internet. Ningún componente interno necesita certificado propio
ni gestionar renovación de TLS.
## Por qué HTTP/2 y no QUIC en el túnel
`cloudflared` puede usar QUIC (UDP) o HTTP/2 (TCP) para sus conexiones
salientes hacia el edge. Este lab fuerza HTTP/2 explícitamente — no es
el default. Un incidente real de `504 Gateway Timeout` intermitente en
**todos** los subdominios (no solo uno) se rastreó hasta fallos
recurrentes del "control stream" de QUIC sobre la red doméstica del
NAS. Ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
para la decisión completa y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)
para el diagnóstico paso a paso.
## Patrón de diagnóstico: "interno sano, público roto"
Cuando un dominio público falla pero todo dentro del cluster responde
bien (pods `Running`, probes verdes, `kubectl exec` con `curl` al
Service funciona), la causa casi nunca está en Kubernetes — está en la
capa NPM. La forma rápida de confirmarlo es saltarse NPM a propósito:
`curl -H "Host: <dominio>" http://192.168.68.61:90/` habla directo con
`k3d-proxy`/Traefik sin pasar por NPM. Si eso funciona pero el dominio
público no, el problema está específicamente en el `forward_host`/
`forward_port` del proxy host en NPM (ej.: un scheme o un path colado
en `forward_host`, que rompe todo el enrutamiento).
Este patrón se repitió dos veces en este lab con la misma causa raíz
exacta: el [incidente de imágenes en `media.cruzcloud.net`](../playbooks/incidente-imagenes-npm.md)
(playbook completo) y un incidente posterior de `commerce.cruzcloud.net`
devolviendo 400 por el mismo tipo de `forward_host` roto (sin playbook
propio todavía). Una lección adicional de ese segundo incidente:
editar el `proxy_host` a mano en el sqlite de NPM **no** regenera el
`.conf` de nginx automáticamente — el cambio tiene que pasar por la
propia UI/API de NPM, o el `.conf` hay que parchearlo a mano y
recargar nginx.
@@ -1,10 +1,10 @@
# Visión general
> **TODO (fase 2+):** narrativa completa explicando cada capa del
> diagrama, con foco en el "por qué" de cada frontera de confianza. El
> diagrama de abajo es real (validado contra `docs/known-issues.md` y
> los playbooks de incidentes de `apps-registry`), pero el texto que lo
> acompaña es solo el esqueleto.
Este lab corre sobre un NAS ZimaOS doméstico, sin IP pública ni
presupuesto de nube — cada capa del diagrama de abajo existe por una
razón concreta de ese contexto, no por moda arquitectónica. Esta
página explica el "por qué" de cada frontera; el "cómo" vive en las
páginas enlazadas al final de cada sección.
## Flujo de una petición pública
@@ -40,4 +40,52 @@ flowchart LR
| Argo CD | Sincroniza el estado del cluster con Git (patrón App of Apps) | Namespace `argocd` |
| Gitea | Origen de verdad de los manifiestos y del código de las apps | Contenedor propio, expuesto vía túnel |
## Por qué cada frontera está donde está
**Cloudflare Tunnel, no port-forwarding.** El NAS vive detrás de NAT
residencial, sin IP pública fija. En vez de abrir puertos en el router
(superficie de ataque directa contra el hogar), `cloudflared` abre una
conexión **saliente** hacia Cloudflare y todo el tráfico público viaja
hacia adentro por esa misma conexión — el origen nunca expone un
puerto escuchando en internet. El transporte de esa conexión se fijó a
HTTP/2 en vez del QUIC por defecto tras un incidente real de
inestabilidad; ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
**Nginx Proxy Manager, fuera del cluster.** No todo lo que sirve
`*.cruzcloud.net` vive dentro de k3d — Gitea, por ejemplo, corre como
contenedor Docker aparte. NPM es el único punto de enrutamiento por
dominio que necesita conocer *ambos* mundos (cluster y no-cluster), así
que vive fuera de Kubernetes a propósito, no por descuido.
**`k3d-lab-cluster-serverlb`, la frontera de entrada al cluster.** Es
el load balancer que el propio k3d monta automáticamente para publicar
puertos del host hacia los nodos internos — no es un componente que
este lab configuró a mano, viene con la topología multi-nodo elegida
en [ADR 0001](../decisiones/0001-por-que-k3d.md).
**Traefik, enrutamiento por `Host` dentro del cluster.** Una vez el
tráfico entra al cluster por un único puerto (90/9443), Traefik es
quien decide a qué Service mandarlo según el header `Host` de cada
petición — así NPM solo necesita saber "todo `*.cruzcloud.net` va al
puerto 90 del cluster", sin tener que aprender un forward_host nuevo
cada vez que se agrega un servicio interno. Cuando esta capa falla en
silencio (`forward_host`/`forward_port` mal apuntado en NPM), el
síntoma es siempre el mismo: "internamente sano, públicamente roto" —
ver [Red y exposición](red-y-exposicion.md).
**Argo CD, el único escritor del cluster.** Ningún pipeline de CI ni
ninguna persona corre `kubectl apply` contra producción — Argo CD es
la única pieza con permisos de escritura, y reconstruye
automáticamente cualquier diferencia entre Git y el cluster real
(`selfHeal: true`). El porqué completo de esa elección está en
[ADR 0002](../decisiones/0002-por-que-argocd-vs-manual.md); el paso a
paso de un commit real hasta quedar corriendo, en
[Flujo GitOps](gitops-flujo.md).
**Gitea, autoalojado.** Además de alojar el código, Gitea corre el CI
(Gitea Actions) y el registry de imágenes en el mismo lugar — un solo
componente autoalojado en vez de depender de la disponibilidad y los
límites de un servicio externo gratuito para un lab que ya de por sí
no tiene presupuesto de nube.
Ver también: [Red y exposición](red-y-exposicion.md) · [Flujo GitOps](gitops-flujo.md) · [Glosario](glosario.md)