Arquitectura ajolote: cuando regenerar sale más barato que tener réplicas

Cuando se cayeron los dos nodos primarios al mismo tiempo, no usé failover. Regeneré.

El 5 de junio de 2026, alrededor de las 03:14 UTC, se fue la luz en el departamento donde vivían ash (el server pesado: Postgres, Forgejo, n8n) y ine (la torre vieja: koa-auth, Stalwart admin). Más de 50 horas después seguían apagados. La luz volvió hasta el 10 de junio a las 13:55 CST. En el camino aprendí —de manera empírica— que el patrón de resiliencia que ya tenía no era HA, no era failover, y no necesitaba serlo. Le terminé poniendo nombre: arquitectura ajolote. Regeneración elástica desde el substrate.

Este post es la explicación que me hubiera gustado leer antes de empezar a operar mi stack personal con 47 sitios y unos 30 servicios distribuidos en siete nodos (atl, ash, ine, bob, koa-files, lar, elo). Va para founders y devs que llevan stacks no-triviales sin equipo SRE detrás.

El problema concreto

Setup al momento del corte:

47 sitios bajo koa-files/Caddy. Cuando ash + ine cayeron, no se cayó solo “Postgres y SSO”. Cayó la mitad del DNS interno: cada sitio con forward_auth empezó a devolver 502. Cada landing que dependía de un FastAPI corriendo en ash devolvió cert OK pero upstream muerto. Cada bot de WhatsApp sin n8n encoló mensajes que Meta iba a reintentar por 4-6 horas y luego tirar.

El backup era restic offsite en koa-files. El código estaba en Forgejo, que vivía en ash. La cadena se rompió en el peor punto posible: el servidor que tenía el código también era el que estaba caído.

Tres breakthroughs previos que no bastaban

Antes del corte ya había acumulado tres ideas sobre resiliencia, ninguna suficiente:

1. atl como standby (2026-06-05). La laptop puede hacer trabajo de cómputo durante el corte. Drené 52 items de macroplan en tres rondas usando atl mientras el depa estaba muerto. Conclusión: atl es tier compute esencial, no solo dev box. Limitación: nadie de afuera puede entrar a mi laptop con IP residencial cambiante. Cubre mi workflow, no a usuarios externos.

2. Distribución parcial (2026-06-06). Levanté koa-proxy en atl:4000 con ocho providers de LLM, validator v0.2, event bus. Funcionó para mí, pero seguía siendo “atl es Miguel, el resto sigue caído”.

3. Meta-layer (2026-06-03). Tres días antes del corte ya había acuñado el “meta-motor”: el workspace mismo es el sistema operativo. Cualquier herramienta (koa-todo, koa-decisions, koa-topics, koa-secrets) debe ser regenerable desde el workspace. Salió de explorar HyperCard, q/kdb+ y Emacs como modelos de “sistema vivo donde todo es navegable”.

Los tres prepararon el terreno conceptual. Lo que no tenía era el insight operativo de la fase 3.

El cuarto breakthrough: ajolote

El ajolote es una salamandra mexicana que regenera extremidades, médula espinal, partes del corazón. No es inmortal. Si lo tiras al agua sin oxígeno, se muere igual. Lo que tiene es que si pierde un brazo, el cuerpo lo re-crece desde el tejido. La identidad del animal no vive en el brazo: vive en el genoma.

Apliqué la metáfora al stack:

  1. No hay HA. No tengo réplicas vivas de Postgres en bob. No tengo un n8n shadow corriendo en lar listo para tomar el lugar. Mantener réplicas vivas para 30+ servicios es costo operativo permanente que no puedo absorber.

  2. No hay failover automático. No quiero que un health check con falso positivo (un reboot legítimo de 5 minutos) dispare un switch que después necesita reconcile manual de horas. El costo de pulsar “switch” manualmente es 2 minutos. El costo de un false-positive es horas.

  3. Regeneración deliberada. Cuando pierdes algo, lo re-creces desde el substrate. El substrate es la trinidad workspace + restic + Forgejo:

    • El workspace ~/koa/ está replicado entre atl y los nodos vivos vía koa-sync.sh.
    • El restic offsite en koa-files tiene los snapshots de Postgres, sqlite y volumes que el workspace no contiene.
    • Forgejo es source of truth para code que no está en el workspace local (porque vivía solo en un repo ash, por ejemplo).
  4. El substrate es la identidad del sistema. Los nodos son intercambiables. ash no es “el servidor de Postgres”. ash es el contenedor actual donde corre Postgres. Si ash muere, levanto Postgres en otro nodo a partir del pg_dump. La identidad de “mi Postgres” vive en los snapshots restic, no en el hierro.

El ajolote no es magia. No te salva de un apocalipsis donde pierdes substrate. Lo que hace es desacoplar “cuántos nodos sobreviven” de “qué tan rápido vuelvo a estar vivo”. Si tienes el substrate, regeneras. Si no, no había manera.

La receta concreta

El pattern probado cinco veces en la sesión nocturna del 7 al 8 de junio:

# 1. rsync del código del workspace al nodo destino. EXCLUIR .env siempre.
#    atl no tiene .env (solo .env.example) → --delete-after borraría el .env del destino.
ssh [email protected] 'mkdir -p ~/srv/<app>'
rsync -a \
  --exclude=".env" --exclude="data/db.sqlite" \
  --exclude=".git" --exclude=".venv" --exclude="__pycache__" \
  ~/koa/11_work/<app>/ [email protected]:srv/<app>/

# 2. venv + install local en el nodo destino.
ssh [email protected] 'cd ~/srv/<app> && python3 -m venv .venv && .venv/bin/pip install -r api/requirements.txt'

# 3. Sembrar .env con secrets. Si los reales solo viven en el nodo caído,
#    usar placeholders del .env.example y rotar después.

Luego un systemd user unit:

[Service]
WorkingDirectory=/home/inge/srv/<app>
EnvironmentFile=/home/inge/srv/<app>/.env
ExecStart=/home/inge/srv/<app>/.venv/bin/uvicorn api.main:app --host 127.0.0.1 --port 8211
Restart=on-failure

El detalle importante es --host 127.0.0.1. Los VPS tienen firewall puerto-específico: lar solo abre 8201/8205/8210/8220/8221/8231/8250/8275/8770; bob solo 22/80/443/8080/8765. Si bindeas a 0.0.0.0 en un puerto nuevo, simplemente no responde desde fuera. La salida es que koa-files corre Caddy en NetworkMode=host, lo que le permite llegar a 127.0.0.1:<puerto> del host sin pasar por firewall ni Tailscale.

Luego el switch de Caddy:

# Editar el Caddyfile canónico en koa-files.
ssh inge@koa-files
cd /srv/koa/services/caddy
sed -i 's|reverse_proxy 100.64.0.1:8221|reverse_proxy 127.0.0.1:8211|' Caddyfile
docker restart caddy   # NO caddy reload.

Este último paso es el detalle que me costó una hora la primera vez. sed -i cambia el inode del archivo. El container de Caddy tiene el Caddyfile montado como bind mount, y el bind mount sigue apuntando al inode original. caddy reload re-lee el archivo, pero el archivo dentro del container es el inode viejo. Solo docker restart caddy rebindea correctamente. Verificar con md5sum host vs container.

Caso real: ndns.club en 90 minutos

ndns.club es el sitio público de un bar/comedia que coopero con dos socios. Postgres con menú, reservas, eventos del Mundial 2026, signage. Cayó al mismo tiempo que ash. La gente intentando comprar boletos veía 502.

Lo que hicimos en 90 minutos:

  1. Encontrar el código. El backend de ndns.club (10,263 líneas FastAPI) no estaba en atl. Vivía solo en Forgejo, en inge/11_work.git/ndns/backend/. Forgejo estaba en ash, también caído. Restauré el repo desde el snapshot restic 6103506b (2026-06-05 03:00) — el último cron antes del corte. Ventana de pérdida potencial: ~14 minutos.

  2. Levantar Supabase. Recreé catrina-pg (Postgres 15.8) + catrina-postgrest (PostgREST v14.5) en koa-files desde el pg_dump del 2026-06-04 (RPO ~33h). 265 tablas con data real: 1754 bank_transactions, dashboard users, CFDIs. JWT_SECRET, ANON_KEY y SERVICE_ROLE_KEY originales restaurados desde supabase-project/.env, que afortunadamente tenía copia en el workspace.

  3. Levantar el FastAPI. rsync del backend desde /tmp (donde restauré el repo Forgejo) al nodo koa-files, venv, uvicorn en 127.0.0.1:8220 (puerto canónico ndns-menu), systemd user unit.

  4. Switch de Caddy. sed del Caddyfile para apuntar ndns.club y ndns.koa.dev a 127.0.0.1:8220. docker restart caddy.

  5. Bug subtle: 20 segundos de latencia por request. El handler hacía fetches paralelos a Supabase para cargar menú, cartelera, reservas y eventos. La variable SUPABASE_URL (distinta de SUPABASE_URL_INTERNAL) seguía apuntando a 100.64.0.1:54321, que era el ash caído. Cada fetch esperaba el timeout de 10 segundos antes de fallar. Fix: apuntar SUPABASE_URL a 127.0.0.1:8221. Latencia 20s → 200ms. Speedup de 100×, que no era performance work sino debug puro.

El sitio quedó vivo a las 02:47 UTC del 8 de junio. Reservas funcionando, menú visible, los partidos del Mundial 2026 listados (9 de fase de grupos + 32 de KO, 41 filas en total).

Lo que no repliqué: la mecánica financiera del bar (CFDIs, conciliación bancaria). Esa data vive en el Supabase regenerado, pero los dashboards que la muestran solo corren en atl, accesibles vía Tailscale. Es deliberado.

Tres reglas duras que no rompí (y por qué)

1. Nada financiero en dominios públicos. Tuve un incidente de unos 30 minutos: monté el build de hub-v2 (mi dashboard interno) en koa.dev pensando que era un landing público. hub-v2 incluye paneles de finanzas y secrets. Lo apagué cuando me di cuenta, puse maintenance, purgué Cloudflare. La regla feedback_finanzas_no_public existía antes pero no la había internalizado. Ahora sí: si dudas, Tailscale-only. Si necesitas exponerlo, prueba antes que no hay leak.

2. Firewall puerto-específico en VPS. lar y bob no son “Linux con todos los puertos abiertos”. Tienen reglas específicas. Cualquier puerto nuevo está bloqueado desde fuera. Solo koa-files con Caddy en NetworkMode=host te deja improvisar puertos altos como 127.0.0.1:<puerto> y rutear desde Caddy. Si tu plan de regeneración asume “puedo bindear 8888 y curlearlo de afuera”, revisa el firewall primero.

3. Caddy running vs Caddy disk. sed -i rompe el bind mount. caddy reload no rescata. docker restart caddy sí. Esta es la pata clásica de “edité el archivo, no se nota”. Cualquier sistema con bind mounts y editores que reescriben inodes tiene la misma trampa. Regla operativa: después de tocar config en disco, valida con md5sum host vs container que el contenido se haya propagado.

Cuándo el ajolote no aplica

No es bala de plata. Hay escenarios donde la regeneración deliberada es mala respuesta:

Para todo lo demás —que en mi experiencia es 90% de los stacks personales y de startups en etapa temprana— el ajolote gana en costo/beneficio. El costo recurrente de mantener réplicas vivas es alto. El costo recurrente de mantener el substrate sano (backups, sync, docs del pattern) es bajo.

Hacia self-healing

El siguiente paso es automatizar partes del ajolote sin caer en el anti-pattern del failover automático con false-positives.

Lo que estoy explorando:

Epílogo: lo que pasó el 10 de junio

La luz volvió al depa el 10 de junio a las 13:55 CST, cinco días después del corte. Lo que aprendí cuando el primary regresó:

1. La data sobrevivió, pero no estaba garantizado. El volumen de Docker de n8n quedó intacto en ash (corte de luz, no falla de hardware). Saqué backup completo antes de tocar nada: 28 workflows + 6 credentials + .n8n completo (475 MB, incluyendo encryptionKey) a ash:~/koa-backups/n8n-backup-20260610/ y copia offsite inmediata en koa-files:~/srv/ash-return/. Detalle previo identificado durante el corte: el volumen de n8n no estaba en la lista de paths que restic respalda. Si el disco hubiera muerto, esos 28 workflows se perdían. Gap P0 documentado.

2. Forgejo no levantó solo. Container Exited (255) al boot. Causa: race conocido entre Docker y Tailscale. Docker intenta bind a 100.64.0.1 (la IP de Tailscale) antes de que tailscaled tenga la IP asignada. Fix temporal: docker compose up -d para recrear el container con los puertos correctos. El runner reconectó tras restart. Fix raíz pendiente: dependencia systemd o restart-retry. Se va a repetir en cada boot hasta que se arregle.

3. El push masivo funcionó. 198 commits acumulados en el workspace durante los 5 días. koa-sync solo empuja branches plan/* automáticamente; los main de los submódulos se empujan a mano. Los hooks de frontmatter bloquearon varios commits — los fixes fueron reales, no bypass (excepto un KOA_SKIP_RELATED_CHECK=1 justificado en koa.cards, donde el índice de relaciones quedó stale post-outage).

4. La reconciliación de datos fue desigual. Tres casos distintos: - Mundial 2026: 41 filas (9 grupos + 32 KO) que se crearon en el regenerado de koa-files. Mergeadas a ash vía staging table sin preservar id, usando \copy client-side (el usuario postgres no era superuser, no podía usar COPY server-side). - Sociales (sociales.lat): la DB en ash estaba stale — el rsync de deploy excluye data/db.sqlite, así que el ash original solo tenía 3 events viejos. La DB regenerada en koa-files (111 events + 5 artists) era superset, así que se reemplazó completa con backup previo del ash. - Comedia (ndns.club): durante el corte el runbook mencionaba “93 eventos promovidos” en el regenerado, pero al volver ash tenía 135 draft / 4 live, igual que koa-files. Los 93 promovidos nunca persistieron — rollback silencioso por error en trigger de vault. Lección: el runbook no es evidencia; verificar contra DB.

5. Caddy revert ordenado. Flip del symlink a Caddyfile.normal (base = último Caddyfile pre-corte, Caddyfile.bak-d16-20260602-2250) más 3 “ajolote-keeps”: sociales.lat → 127.0.0.1:8204, koalabs.ai → 127.0.0.1:8205, koadro.com → 127.0.0.1:8212. Los tres son sitios donde tiene sentido dejar la versión regenerada en koa-files (VPS público estable) en lugar de migrar de vuelta. Validar siempre con caddy validate --adapter caddyfile antes del restart. Post-restart hay unos 2 minutos de timeouts transitorios (TLS warmup + Tailscale path) — reintentar antes de asumir falla.

6. Resultado final: smoke check de 25/25 hosts públicos OK. Cero pérdida de data. RTO total: 5 días para el primary; 50 horas para tener 30 de 47 sitios regenerados.

Pendientes que el incidente expuso y siguen abiertos: pg_dump de supabase-project (finanzas/todo/decisions/topics) sigue sin backup formal; fix raíz del race Docker/Tailscale en ash; rotación de placeholders en sociales, lacartelera, ndns-menu; UPS físico para evitar la próxima ronda. El ajolote regeneró bien; el primary también sobrevivió bien; lo que falta es cerrar el gap entre los dos.

Cierre

50 horas sin luz en el depa. 47 sitios afectados. Pasé de 7 vivos a 30 vivos en una sesión nocturna usando un pattern que probé cinco veces y que ahora tiene nombre y runbook. Cinco días después la luz volvió y la data primary estaba intacta — lo que confirmó que el ajolote y el primary no son competidores, son capas.

El insight que me pareció no trivial es que la resiliencia no se compró con redundancia. Se compró con substrate sano. Mientras el workspace esté replicado, los snapshots restic estén frescos y Forgejo tenga su pg_dump diario, cualquier nodo es desechable. Los nodos son contenedores temporales del sistema. El sistema vive en otro plano.

El ajolote regenera porque su identidad no vive en la extremidad.


¿Preguntas sobre el patrón o quieres esto para tu stack? [email protected]