Merge fix/docs-portal-fase2-contenido-narrativo into main
This commit is contained in:
commit
949788cda7
7 files changed
+490
-80
No files matched your search
@@ -1,20 +1,9 @@
|
||||
# Notas sueltas / aprendizajes
|
||||
|
||||
> **TODO (fase 2+):** esto es un scratchpad estructurado, no un
|
||||
> playbook — va creciendo con notas cortas a medida que aparecen. Las
|
||||
> entradas de abajo son semillas reales extraídas de incidentes ya
|
||||
> documentados; expandir cada una con más contexto en fases siguientes.
|
||||
|
||||
## Qué haría distinto
|
||||
|
||||
- **Fijar `TUNNEL_TRANSPORT_PROTOCOL=http2` desde el día uno**, no
|
||||
después de un incidente — QUIC/UDP sobre NAT doméstico fue un problema
|
||||
predecible en retrospectiva. Ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
|
||||
- **Separar siempre "chequeo de disponibilidad" de "migraciones"** en
|
||||
init containers distintos, y que el chequeo use el mínimo de
|
||||
parámetros posible (host/puerto/usuario/db) en vez de reusar una URI
|
||||
de conexión pensada para la app. Confirmado como decisión correcta en
|
||||
el [incidente de crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
|
||||
> Esto es un scratchpad estructurado, no un playbook — sigue creciendo
|
||||
> con notas cortas a medida que aparecen. Cada entrada está extraída de
|
||||
> un incidente o hallazgo real ya documentado en otra parte del sitio,
|
||||
> no es especulación.
|
||||
|
||||
## Gotchas de Medusa v2
|
||||
|
||||
@@ -26,8 +15,70 @@
|
||||
del array `images` — hay que setearlo explícito desde el Admin si el
|
||||
frontend lo usa como imagen hero en listados.
|
||||
|
||||
## TODO
|
||||
## MinIO / S3 provider
|
||||
|
||||
- Notas sobre MinIO / S3 provider.
|
||||
- Notas sobre el subscriber `revalidate-storefront.ts` y sus límites.
|
||||
- Qué automatizaría si volviera a montar este lab desde cero.
|
||||
- Medusa sube imágenes de producto a MinIO (`minio-svc:9000`, bucket
|
||||
`ari-products`) usando el provider S3-compatible estándar — MinIO no
|
||||
sabe que no es AWS real, y Medusa tampoco necesita saberlo.
|
||||
- La URL pública que termina viendo el navegador **no** es la interna
|
||||
(`minio-svc:9000`): es `S3_FILE_URL=https://media.cruzcloud.net/ari-products`,
|
||||
un dominio propio proxied por NPM hacia el mismo MinIO. Esto tiene
|
||||
una consecuencia directa que ya rompió producción una vez: si
|
||||
`media.cruzcloud.net` deja de rutear bien (mismo patrón de
|
||||
`forward_host` roto que el resto del sitio), **todas** las imágenes
|
||||
de producto del storefront se caen aunque MinIO y Medusa estén
|
||||
perfectamente sanos — porque el navegador nunca habla con MinIO
|
||||
directo, solo con esa URL pública. Ver [Red y exposición](../arquitectura/red-y-exposicion.md).
|
||||
|
||||
## Subscriber `revalidate-storefront.ts` y sus límites
|
||||
|
||||
- Escucha 6 eventos de Medusa (`product.created/updated/deleted` +
|
||||
los 3 equivalentes de `product-variant`) y por cada uno hace un
|
||||
único `POST` a `${STOREFRONT_URL}/api/revalidate` con un header
|
||||
secreto (`x-revalidate-secret`) — esa ruta del frontend llama
|
||||
`revalidateTag("medusa-products")` de Next.js.
|
||||
- **Se degrada solo, no tira el evento entero**: si
|
||||
`STOREFRONT_URL`/`REVALIDATE_SECRET` no están configurados, o el
|
||||
`fetch` falla, solo loguea un `warn` y sigue — un fallo de red hacia
|
||||
el frontend nunca revierte ni bloquea la operación real en Medusa
|
||||
(crear/editar un producto). Mismo criterio de "degradar, no romper"
|
||||
que se repite en el resto del storefront (`searchWithFacets`
|
||||
opcional, `/api/search` cayendo a `{}` si Meilisearch no responde).
|
||||
- **Límite real, todavía no un problema**: no hay debounce ni
|
||||
deduplicación — si una operación dispara varios eventos juntos (ej.
|
||||
editar un producto con 3 variantes a la vez), son varios `POST`
|
||||
independientes al mismo endpoint. `revalidateTag` de Next.js es
|
||||
barato e idempotente, así que hoy no se nota, pero si el catálogo
|
||||
creciera mucho y las ediciones masivas se volvieran frecuentes, sería
|
||||
el primer lugar donde agregar algún tipo de agrupación.
|
||||
|
||||
## Qué haría distinto si volviera a montar este lab desde cero
|
||||
|
||||
- **Fijar `TUNNEL_TRANSPORT_PROTOCOL=http2` desde el día uno** (ver
|
||||
[ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)) y
|
||||
**ajustar el MTU de `flannel.1` desde el primer `deploy-lab.sh`** —
|
||||
ambos terminaron siendo incidentes reales
|
||||
(504 intermitente, blackhole cross-node) que en retrospectiva eran
|
||||
predecibles: red doméstica con NAT agresivo + Docker-in-Docker
|
||||
multi-nodo son una combinación conocida para tener problemas de MTU
|
||||
y de transporte UDP. Hoy los dos ya están automatizados
|
||||
(`fix_flannel_mtu` en `deploy-lab.sh`, la variable de entorno del
|
||||
túnel), pero se aprendieron reactivamente, no se anticiparon.
|
||||
- **Usar `git merge-base --is-ancestor` para la promoción segura del
|
||||
CI desde el primer pipeline**, no después de ver la condición de
|
||||
carrera en vivo (dos pipelines corriendo en serie sobre el mismo
|
||||
commit, el segundo pisando o saltándose la promoción del primero).
|
||||
El patrón correcto no es complicado una vez que se conoce — el costo
|
||||
fue no anticiparlo.
|
||||
- **Separar siempre "chequeo de disponibilidad" de "migraciones"** en
|
||||
init containers distintos, y que el chequeo use el mínimo de
|
||||
parámetros posible (host/puerto/usuario/db) en vez de reusar una URI
|
||||
de conexión pensada para la app. Confirmado como decisión correcta en
|
||||
el [incidente de crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
|
||||
- **No escribir un script que regenere credenciales completas en cada
|
||||
corrida** (`create-commerce-secrets.sh` rota JWT/COOKIE/passwords de
|
||||
Postgres/Redis/MinIO enteros cada vez que se ejecuta, incluso si solo
|
||||
hacía falta agregar una clave nueva) — un script de setup inicial y
|
||||
uno de "agregar una clave a un secret ya vivo" deberían haber sido
|
||||
dos herramientas distintas desde el principio, no una sola que hay
|
||||
que saber cuándo NO correr completa.
|
||||
@@ -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)
|
||||
@@ -1,33 +1,67 @@
|
||||
# ADR 0001 — Por qué k3d (y no k3s directo, minikube, o un cluster cloud)
|
||||
|
||||
> **TODO (fase 2+):** completar contexto/opciones/consecuencias con
|
||||
> criterio real (recursos del NAS ZimaOS, necesidad de multi-nodo para
|
||||
> reproducir el blackhole de MTU documentado en `known-issues.md`,
|
||||
> costo cero vs. cluster gestionado).
|
||||
|
||||
**Estado:** aceptada (placeholder — pendiente de redactar)
|
||||
**Fecha:** TODO
|
||||
**Estado:** aceptada
|
||||
**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
|
||||
|
||||
## Contexto
|
||||
|
||||
TODO
|
||||
El lab corre sobre un NAS ZimaOS doméstico, sin presupuesto para un
|
||||
cluster gestionado y con recursos modestos y compartidos con otras
|
||||
apps del NAS (Gitea, Nginx Proxy Manager, este mismo portal). El
|
||||
propósito del lab es doble: servir de portafolio técnico real y de
|
||||
material de estudio para el hijo del usuario (7mo semestre de
|
||||
Ingeniería de Sistemas) — ver [guía del estudiante](../guia-estudiante/README.md).
|
||||
Para que ambos objetivos tengan sentido, el cluster necesitaba
|
||||
comportarse como un Kubernetes real y multi-nodo, no como una caja de
|
||||
arena de un solo proceso: patrones como afinidad de pods, StatefulSets
|
||||
con almacenamiento persistente por nodo, y tráfico cross-node debían
|
||||
poder reproducirse y depurarse de verdad, no simularse.
|
||||
|
||||
También pesó la restricción del propio sistema operativo: `/` en
|
||||
ZimaOS es un `squashfs` de solo lectura y `/usr` es un overlay
|
||||
igualmente inmutable — instalar un binario nativo como `k3s` que
|
||||
espera escribir en `/usr/local/bin`, `/etc/rancher`, etc. de forma
|
||||
persistente no encaja bien con ese filesystem sin trabajo extra de
|
||||
por medio.
|
||||
|
||||
## Opciones consideradas
|
||||
|
||||
| Opción | A favor | En contra |
|
||||
|---|---|---|
|
||||
| k3d | | |
|
||||
| k3s directo (sin Docker) | | |
|
||||
| minikube | | |
|
||||
| Cluster gestionado (cloud) | | |
|
||||
| **k3d** | Multi-nodo real corriendo como contenedores Docker (server + agent), se crea/destruye en segundos, no toca el filesystem inmutable de ZimaOS más allá de `/DATA` | Al ser Docker-in-Docker, hereda las capas de red de Docker — un blackhole de MTU/PMTUD real apareció por esto (ver Consecuencias) |
|
||||
| k3s directo (sin Docker) | Sin la capa extra de red de Docker-in-Docker, más cercano a un nodo "real" | Necesita instalar servicios systemd y escribir en rutas de sistema (`/usr/local/bin`, `/etc/rancher`) que en ZimaOS son de solo lectura o requieren workarounds — solo `/DATA` y `/etc` quedan escribibles |
|
||||
| minikube | Instalación simple, muy documentado, ideal para aprender lo básico | Single-node por defecto — no permite reproducir tráfico cross-node, afinidad de pods, ni el tipo de incidentes de infraestructura real que este lab busca documentar como aprendizaje |
|
||||
| Cluster gestionado (cloud) | Cero mantenimiento de infraestructura, SLA real | Costo recurrente que este lab no tiene presupuestado; además reduce el valor de aprendizaje — gran parte de lo documentado en `playbooks/` (MTU, DNS, límites de recursos) es justamente lo que un proveedor gestionado abstrae |
|
||||
|
||||
## Decisión
|
||||
|
||||
TODO
|
||||
k3d, multi-nodo (hoy: 1 server + 1 agent, `k3d-lab-cluster-server-0` /
|
||||
`k3d-lab-cluster-agent-0`), corriendo como contenedores Docker sobre
|
||||
el propio NAS.
|
||||
|
||||
## Consecuencias
|
||||
|
||||
TODO — mencionar explícitamente el blackhole de red cross-node
|
||||
(MTU/PMTUD) como consecuencia real y documentada de correr multi-nodo
|
||||
sobre Docker-in-Docker sin ajuste de MTU (ver
|
||||
[playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)).
|
||||
- **Se ganó** un cluster real y gratuito, reproducible con un solo
|
||||
script (`scripts/deploy-lab.sh`), y suficientemente parecido a un
|
||||
Kubernetes de producción como para que los incidentes documentados
|
||||
en `playbooks/` sean transferibles a un entorno cloud real.
|
||||
- **Se pagó** con un blackhole de red cross-node real: cualquier pod
|
||||
agendado en un nodo distinto al de `commerce-postgres-0`
|
||||
(StatefulSet, fijo a un nodo) fallaba de forma consistente al hablar
|
||||
con Postgres — diagnosticado hasta el bit exacto (payload interno
|
||||
1420 bytes OK, 1450 bytes blackhole total, sin ICMP "frag needed")
|
||||
y resuelto ajustando el MTU real de la interfaz `flannel.1` a 1400
|
||||
en cada nodo. Ver [known-issues.md](https://gitea.cruzcloud.net/devops/apps-registry/src/branch/main/docs/known-issues.md)
|
||||
y el [playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)
|
||||
para el diagnóstico completo. El fix no persiste solo — está
|
||||
automatizado en `scripts/deploy-lab.sh` (función `fix_flannel_mtu`),
|
||||
se reaplica cada vez que el cluster arranca o se recrea.
|
||||
- **Deuda aceptada a propósito**: el `podAffinity` que fuerza a
|
||||
`medusa-deploy` a compartir nodo con Postgres se dejó como red de
|
||||
contención extra después del fix de fondo — no es estrictamente
|
||||
necesario, pero es barato mantenerlo.
|
||||
- **Para revertir esta decisión** haría falta migrar a un cluster
|
||||
gestionado o a `k3s` nativo, lo que implicaría asumir el costo
|
||||
recurrente (cloud) o resolver las restricciones de filesystem
|
||||
inmutable de ZimaOS (k3s nativo) — ninguna de las dos se justifica
|
||||
hoy con el tamaño real del lab.
|
||||
@@ -1,30 +1,80 @@
|
||||
# ADR 0002 — Por qué Argo CD (y no `kubectl apply` manual o un script de CI)
|
||||
|
||||
> **TODO (fase 2+):** completar con el criterio real detrás de elegir
|
||||
> reconciliación continua (`selfHeal`) sobre despliegue imperativo desde
|
||||
> CI.
|
||||
|
||||
**Estado:** aceptada (placeholder — pendiente de redactar)
|
||||
**Fecha:** TODO
|
||||
**Estado:** aceptada
|
||||
**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
|
||||
|
||||
## Contexto
|
||||
|
||||
TODO
|
||||
Desde el principio el lab iba a tener más de un repo Git (hoy:
|
||||
`apps-registry` para las Applications y los manifiestos reales de los
|
||||
workloads, `platform-infra` para la gobernanza de cada namespace,
|
||||
`scripts` para bootstrap/operación) y más de una app corriendo en el
|
||||
cluster (ecommerce, docs-portal, nginx, cada una con su propia
|
||||
gobernanza). Se necesitaba una forma de mantener el estado del
|
||||
cluster sincronizado con lo declarado en Git que escalara a "agregar
|
||||
una app nueva" sin volverse un proceso manual que dependiera de que
|
||||
una sola persona recordara qué `kubectl apply` correr y en qué orden.
|
||||
|
||||
También importaba la trazabilidad: en un lab que sirve de portafolio,
|
||||
poder señalar "este commit es exactamente lo que está corriendo en
|
||||
producción" (y por qué) tiene valor por sí mismo — no solo operativo.
|
||||
|
||||
## Opciones consideradas
|
||||
|
||||
| Opción | A favor | En contra |
|
||||
|---|---|---|
|
||||
| Argo CD (GitOps, pull-based) | | |
|
||||
| `kubectl apply` manual | | |
|
||||
| `kubectl apply` desde el pipeline de CI (push-based) | | |
|
||||
| **Argo CD (GitOps, pull-based)** | Reconciliación continua: un proceso dentro del cluster compara el estado real contra Git y corrige la diferencia solo, sin que nadie tenga que acordarse de aplicar nada. Patrón App of Apps nativo — agregar una app nueva es un archivo YAML más en `apps/`, no un paso manual nuevo | Componente extra que mantener sano (su propio namespace, su propio consumo de recursos) |
|
||||
| `kubectl apply` manual | Cero infraestructura extra, control total en cada comando | Sin detección de drift — si alguien edita algo a mano y lo olvida, nadie se entera hasta que rompe; no escala más allá de un solo operador que recuerde el estado completo de memoria |
|
||||
| `kubectl apply` desde el pipeline de CI (push-based) | No agrega un componente nuevo al cluster, reutiliza el CI que ya existe para construir imágenes | El pipeline de CI necesitaría credenciales de escritura amplias sobre el cluster — más superficie de riesgo si esas credenciales se filtran. Push-based además significa que nada vuelve a comparar el estado después de aplicado: un drift posterior (alguien con `kubectl` directo) queda invisible hasta el siguiente deploy |
|
||||
|
||||
## Decisión
|
||||
|
||||
TODO
|
||||
Argo CD, patrón App of Apps: una Application raíz
|
||||
(`apps-registry/application.yaml`) vigila la carpeta `apps/`, y cada
|
||||
archivo ahí dentro es una Application hija que apunta a un workload
|
||||
real. Toda Application se declara con el mismo `syncPolicy`:
|
||||
|
||||
```yaml
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
```
|
||||
|
||||
(configuración real de `apps/ecommerce-app.yaml`, repetida en cada
|
||||
Application del repo)
|
||||
|
||||
## Consecuencias
|
||||
|
||||
TODO — mencionar el patrón App of Apps (`application.yaml` +
|
||||
`apps/*.yaml`) y cómo `selfHeal:true` cambia el modelo mental de
|
||||
"quién puede tocar el cluster".
|
||||
- **`selfHeal: true` cambia el modelo mental de quién puede tocar el
|
||||
cluster de verdad.** No es solo que Argo CD detecte diferencias y
|
||||
las muestre como `OutOfSync` — las revierte activamente. Si alguien
|
||||
corre `kubectl scale` o `kubectl edit` directo contra un recurso
|
||||
gestionado, en el siguiente ciclo de reconciliación (poll periódico,
|
||||
o inmediato si el commit llegó por webhook) Argo CD lo vuelve a
|
||||
dejar exactamente como dice Git. Git deja de ser "la intención" y
|
||||
pasa a ser literalmente la única fuente de la que el cluster puede
|
||||
aprender su propio estado — ver [flujo GitOps](../arquitectura/gitops-flujo.md).
|
||||
- **`prune: true`** significa que borrar un recurso del repo (por
|
||||
ejemplo, quitar `meilisearch.yaml` de una `kustomization.yaml`) lo
|
||||
borra también del cluster en el siguiente sync, sin un paso manual
|
||||
de limpieza — confirmado en la práctica en varias fases de este lab
|
||||
(ver los ADRs de rediseño del storefront y de Meilisearch).
|
||||
- **El rol de CI se redujo a "construir, publicar imagen, y dejar un
|
||||
commit en Git"** — nunca aplica nada directo al cluster. Esto evitó
|
||||
tener que darle a CI credenciales de cluster, y dejó un problema real
|
||||
documentado (la condición de carrera de promoción cuando dos
|
||||
pipelines corren en serie sobre el mismo commit) resoluble
|
||||
comparando contra Git (`git merge-base --is-ancestor`) en vez de
|
||||
contra el estado del cluster.
|
||||
- **Se paga con un componente más que mantener sano**: Argo CD corre
|
||||
en su propio namespace, con su propio consumo de recursos y su
|
||||
propia superficie de fallo — un Argo CD caído no rompe lo que ya
|
||||
está corriendo (el cluster sigue con el último estado aplicado),
|
||||
pero sí detiene la sincronización de cambios nuevos.
|
||||
- **Para revertir esta decisión** haría falta volver a un modelo
|
||||
push-based (CI aplicando directo) o puramente manual, perdiendo la
|
||||
reconciliación continua y aceptando que un drift manual pueda pasar
|
||||
desapercibido.
|
||||
Reference in new issue
Block a user