bcd02716d5
Specs de los tres issues siguientes del bus, derivados de esta sesión: - 0002 media v2: chunking, mimetype, GC del object store, exponer en clientes. - 0003 descentralización/HA: cluster NATS magnus+homer (R1→R3), control plane SQLite→JetStream KV, quorum, failover. Tercer nodo = homer (141.94.69.66). - 0004 hardening: cierra los hallazgos de la auditoría red-team (report 0004): DoS pre-auth, fail-open, autorización por pertenencia, ACL NATS, TLS control plane.
196 lines
10 KiB
Markdown
196 lines
10 KiB
Markdown
---
|
|
issue: 0003
|
|
title: Descentralización / alta disponibilidad — cluster NATS + JetStream replicado + control plane sin SPOF
|
|
status: spec
|
|
created: 2026-06-07
|
|
domain: infra
|
|
scope: unibus (pkg/embeddednats, pkg/membership, pkg/blobstore, pkg/client, cmd/membershipd) + despliegue multi-nodo
|
|
depends_on: 0001 (la auth de cluster y de clientes va junto con el endurecimiento)
|
|
---
|
|
|
|
# Objetivo
|
|
|
|
Que la caída de un servidor **no deje el bus sin servicio**. Hoy unibus es un único
|
|
`membershipd` (con NATS embebido + SQLite local): si ese host muere, no hay bus.
|
|
Este issue lleva unibus a un modelo **descentralizado / alta disponibilidad** usando
|
|
las capacidades nativas de NATS: cluster multi-nodo, JetStream replicado (RAFT), y
|
|
el estado del control plane fuera de la SQLite local. **No es federación**
|
|
(multi-operador con dominios distintos); es eliminar el punto único de fallo dentro
|
|
de un único dominio administrativo controlado por nosotros.
|
|
|
|
# Requisito clave de quorum (decisión de infraestructura)
|
|
|
|
JetStream replica con RAFT, que necesita **mayoría (quorum)** para confirmar
|
|
escrituras. Las consecuencias son duras y hay que asumirlas desde el diseño:
|
|
|
|
| Nodos | Réplica | Tolera caída de | Nota |
|
|
|---|---|---|---|
|
|
| 1 | R1 | 0 | situación actual (SPOF) |
|
|
| 2 | R2 | **0** | si cae uno se pierde quorum: las escrituras se bloquean. NO sirve para HA |
|
|
| **3** | **R3** | **1** | mínimo real para "si un server cae, seguimos" |
|
|
| 5 | R5 | 2 | mayor tolerancia |
|
|
|
|
**Por tanto el objetivo del usuario ("si mi server falla, no nos quedamos sin
|
|
servicio") exige 3 nodos JetStream.** Servers disponibles hoy: **magnus** y
|
|
**homer** (ambos VPS OVH). El tercero está pendiente de conseguir.
|
|
|
|
| Nodo | IP pública | Estado | Notas |
|
|
|---|---|---|---|
|
|
| magnus | (en pass: `MAGNUS_ovh_ssh_ROOT`) | disponible, **cargado** | corre coolify, minio, postgres, authentik, portainer, dagu — revisar recursos antes |
|
|
| homer | `141.94.69.66` | disponible, vivo | creds en pass (`vps_ovhcloud_SSH_SERVER_HOMER_-_root`, `vps_SSH_SERVER_HOMER_dataherrero`); tenía coolify |
|
|
| nodo 3 | — | **pendiente** | conseguir un tercer VPS siempre-on, o reusar om/datardos si se liberan |
|
|
|
|
Preparación previa al deploy de cada nodo: alta del alias SSH + clave, integración en
|
|
la WireGuard, y revisar/aligerar la carga existente (coolify, etc.).
|
|
|
|
## Rollout R1 → R3: funcionar con 2 nodos hoy, HA con 3 mañana
|
|
|
|
No se "desactiva el quorum"; se controla el **número de réplicas** de cada stream/KV:
|
|
|
|
| Réplicas | Quorum | Tolera | Sirve con |
|
|
|---|---|---|---|
|
|
| R1 | ninguno (1 copia) | 0 caídas | 1-2 nodos, sin bloqueo |
|
|
| R3 | 2 de 3 | 1 caída | 3 nodos |
|
|
|
|
- **Fase actual (magnus + homer):** desplegar con streams/KV en **R1** (flag
|
|
`decentralized: off`). El bus funciona al 100% para operar, sin tolerancia a fallo
|
|
todavía. Opción: streams en **R2** para duplicar los datos en ambos nodos
|
|
(durabilidad/backup vivo), asumiendo que la escritura necesita los dos hasta el 3er
|
|
nodo.
|
|
- **Cuando entre el nodo 3:** escalar en caliente `nats stream update --replicas 3`
|
|
(idem KV/Object Store) + añadir el nodo al cluster + flag `decentralized: on`. **HA
|
|
real, sin downtime, sin reescritura, sin migrar datos.**
|
|
- **Aviso de 2 nodos:** NO montar el meta-group de JetStream con 2 nodos como si
|
|
fuera HA — su quorum es 2, y la caída de uno bloquea la gestión de streams. Con 2
|
|
servers, modelo recomendado: **magnus principal (R1) + homer 2º nodo/réplica**, y
|
|
escalar a R3 al tener el tercero.
|
|
|
|
Mientras solo haya 2 nodos: el **data plane efímero** (core-NATS, rooms `ModeNATS`)
|
|
sí tolera la caída de uno (los clientes reconectan al otro), pero las **rooms
|
|
persistentes y el control plane** (que necesitan quorum) no. El issue se despliega
|
|
de verdad cuando haya 3 nodos.
|
|
|
|
# Contexto — por qué hoy es un SPOF
|
|
|
|
- `pkg/embeddednats` arranca un NATS **standalone** (sin cluster).
|
|
- `pkg/membership` guarda rooms/members/room_keys/users en una **SQLite local** al
|
|
proceso.
|
|
- `pkg/blobstore` guarda los blobs en el **disco local** del proceso.
|
|
- El cliente (`pkg/client`) conecta a **una** URL de NATS y **una** de control plane.
|
|
|
|
Todo vive en un host. Ese host es el punto único de fallo.
|
|
|
|
# Diseño
|
|
|
|
## Pieza 1 — Cluster NATS (data plane replicado)
|
|
|
|
`pkg/embeddednats` gana opciones de cluster: `server.Options.Cluster` (nombre +
|
|
host/puerto de routes) y `Routes` (los otros nodos). Cada `membershipd` arranca su
|
|
NATS embebido en cluster con los demás. JetStream se habilita con `Replicas: 3` en
|
|
streams y KV. Auth entre nodos (routes) con credenciales propias (no las de
|
|
clientes), y TLS también en las routes (reusa la CA del issue 0001).
|
|
|
|
## Pieza 2 — Control plane sin estado local (SQLite → JetStream KV)
|
|
|
|
Es el corazón del issue. Hoy `pkg/membership.Store` es SQLite. Se introduce, por
|
|
**branch-by-abstraction**, una interfaz `Store` con dos implementaciones:
|
|
|
|
- `sqliteStore` — la actual (sigue siendo el default mientras el flag está off; útil
|
|
para un solo nodo / desarrollo).
|
|
- `jetstreamStore` — nueva: rooms, members, room_keys y users (la tabla del issue
|
|
0001) viven en **JetStream KV** (buckets replicados R3). Cualquier nodo lee/escribe
|
|
el mismo estado; RAFT garantiza consistencia. El HTTP control plane pasa a ser
|
|
efectivamente **stateless**: cualquier `membershipd` sirve cualquier request
|
|
porque el estado está en el KV replicado.
|
|
|
|
Flag `decentralized` (off → on). Migración inicial de datos SQLite → KV con un
|
|
comando `membershipd migrate-to-kv` (idempotente). Las claves de room siguen
|
|
selladas igual; solo cambia **dónde se guardan**, no el cifrado.
|
|
|
|
## Pieza 3 — Blobs replicados (object store → NATS Object Store)
|
|
|
|
`pkg/blobstore` gana una implementación sobre **NATS Object Store** (encima de
|
|
JetStream, replicado R3) además de la de disco local. Los blobs (ya ciphertext, E2E)
|
|
quedan disponibles desde cualquier nodo. Encaja con el GC del issue 0002.
|
|
|
|
## Pieza 4 — Cliente con failover
|
|
|
|
`pkg/client`: aceptar **lista** de seeds de NATS y **lista** de URLs de control
|
|
plane. `nats.go` ya hace reconnect/failover entre servidores del cluster nativamente
|
|
(`nats.Servers([...])`, `nats.MaxReconnects(-1)`). El control plane HTTP se prueba en
|
|
orden con reintento. Así, si un nodo cae, el cliente reconecta a otro de forma
|
|
transparente.
|
|
|
|
## Pieza 5 — Despliegue multi-nodo
|
|
|
|
3 nodos `membershipd`, cada uno con su NATS embebido en cluster, JetStream R3, mismo
|
|
`ca.crt`/credenciales de routes. systemd en cada VPS. Los clientes reciben la lista
|
|
de los 3 endpoints. Health/observabilidad por nodo (`/healthz` + métricas de
|
|
JetStream: líder RAFT, lag de réplica).
|
|
|
|
# Decisiones técnicas
|
|
|
|
| Decisión | Elegido | Alternativa | Razón |
|
|
|---|---|---|---|
|
|
| Nº de nodos de quorum | 3 (R3) | 2 (R2) | 2 no tolera caída de uno; 3 es el mínimo real de HA |
|
|
| Estado del control plane | JetStream KV replicado | SQLite replicada a mano / Postgres externo | KV ya viene con NATS, mismo RAFT que JetStream, cero infra extra |
|
|
| Migración del store | branch-by-abstraction (interfaz `Store`, dos impls, flag) | reescritura directa | master nunca se rompe; sqlite sigue para 1 nodo/dev |
|
|
| Blobs | NATS Object Store | disco compartido / S3 | replicado nativamente, sin dependencia externa |
|
|
| Failover de cliente | lista de seeds + reconnect nativo nats.go | balanceador externo | menos infra, nats.go ya lo hace |
|
|
| Federación multi-operador | **fuera de alcance** | — | no es el objetivo; es otra liga (trust entre dominios) |
|
|
|
|
# Fases (TBD, ramas `issue/0003x-*`)
|
|
|
|
1. **0003a — cluster NATS** — opciones de cluster/routes + TLS de routes en
|
|
`pkg/embeddednats`; arrancar 2-3 nodos locales en tests e2e y verificar que un
|
|
subject publicado en uno llega a un suscriptor en otro.
|
|
2. **0003b — interfaz Store + jetstreamStore (KV)** — abstraer `pkg/membership.Store`;
|
|
implementar rooms/members/room_keys/users sobre JetStream KV R3; tests de
|
|
consistencia. Flag `decentralized: off`.
|
|
3. **0003c — migrate-to-kv** — comando idempotente SQLite → KV + test de paridad
|
|
(mismo estado antes/después).
|
|
4. **0003d — blobs en Object Store** — impl `pkg/blobstore` sobre NATS Object Store
|
|
replicado.
|
|
5. **0003e — cliente failover** — lista de seeds + lista de ctrl-urls + reconnect;
|
|
test que mata el nodo al que está conectado y verifica que sigue operando.
|
|
6. **0003f — despliegue 3 nodos** (humano) — 3 VPS en cluster, JetStream R3, flag
|
|
`decentralized: on`. Chaos test real: matar un nodo en producción y comprobar que
|
|
el servicio sigue.
|
|
|
|
# Definition of Done (evidencia ejecutable)
|
|
|
|
- **Golden:** 3 nodos en cluster; un cliente publica en un nodo y otro cliente
|
|
suscrito a otro nodo lo recibe; crear room + invitar funciona desde cualquier nodo.
|
|
- **Edge:** un cliente conectado al nodo A; se **mata el nodo A**; el cliente
|
|
reconecta a B automáticamente y sigue publicando/recibiendo sin perder la sesión.
|
|
- **Error path (chaos):** matar 1 de 3 nodos → el control plane sigue aceptando
|
|
escrituras (quorum 2/3); matar 2 de 3 → las escrituras se bloquean (quorum perdido,
|
|
comportamiento esperado y documentado, no corrupción).
|
|
- `CGO_ENABLED=0 go test ./...` verde, incluido un test e2e multi-nodo en proceso.
|
|
|
|
# Riesgos y mitigaciones
|
|
|
|
| Riesgo | Mitigación |
|
|
|---|---|
|
|
| Solo 2 nodos disponibles → sin quorum real | prerequisito explícito de 3 nodos antes de 0003f; hasta entonces, despliegue queda en standalone |
|
|
| Latencia inter-VPS afecta RAFT | nodos en la misma región o con buena red; medir; R3 tolera latencias moderadas |
|
|
| Migración SQLite→KV pierde datos | comando idempotente + test de paridad + backup de la SQLite antes |
|
|
| Partición de red (split-brain) | RAFT lo previene: el lado sin quorum se bloquea para escritura, no diverge |
|
|
| Complejidad operativa de 3 nodos | observabilidad de JetStream (líder, lag) + `/healthz` por nodo + runbook en deploy/ |
|
|
|
|
# Orden recomendado respecto a otros issues
|
|
|
|
1. **0001 (seguridad)** primero: la auth de clientes (nkey) y la CA/TLS se reutilizan
|
|
para las routes del cluster. Desplegar descentralizado sin auth sería abrir varios
|
|
puntos públicos sin protección.
|
|
2. **0003 (este)** después: una vez el bus es seguro, replicarlo en 3 nodos.
|
|
3. **0002 (media v2)** es ortogonal; su object store encaja con la pieza 3 (blobs
|
|
replicados) cuando ambos estén.
|
|
|
|
# Fuera de alcance
|
|
|
|
- Federación entre operadores/dominios distintos (otra liga; requiere protocolo de
|
|
trust entre dominios).
|
|
- Multi-tenant / accounts de NATS por organización.
|
|
- Auto-escalado dinámico de nodos.
|