Introducción

La tarjeta de cada proyecto de mi sitio lleva un botón que dice Solicitar acceso. No enlaza a ningún demo corriendo, porque en el momento en que lo pulsas el demo no existe. No hay un servidor esperando, ni un contenedor ocioso, ni una URL que resuelva a nada. Lo que existe es una promesa: dame unos minutos y habrá uno — con su propio subdominio, su propio certificado, su propio entorno aislado — y veinte minutos más tarde no habrá nada otra vez.

El post anterior trató sobre lo que corre dentro de uno de esos entornos: un chatbot RAG sobre los posts de este blog. Este trata sobre la maquinaria que trae un demo a la existencia cuando alguien lo pide, le rutea el tráfico y después lo borra — la misma maquinaria que va a servir al siguiente demo, que ya estoy preparando.

Hay también una regla que me puse, y es la razón de que este post esté fechado ahora y no hace tres meses: no iba a escribir sobre esta infraestructura hasta que un demo real, pedido por una persona real a través del flujo real, se hubiera provisionado, servido y destruido solo. Una infraestructura que nunca cargó algo de verdad no es infraestructura: es un diagrama. El diagrama estuvo listo en junio. La primera provisión exitosa tardó bastante más.

El problema nunca fue “quiero demos en mi sitio”

Dices “quiero demos en vivo” y la solución parece obvia: despliégalos, déjalos arriba, enlázalos desde las tarjetas. Lo cual, para un portafolio de N proyectos, significa N servicios corriendo permanentemente para que —estadísticamente— nadie los esté mirando.

Y no es solo la factura: es que la forma está mal. Un demo es algo que alguien mira unos minutos y luego abandona. Y se acumula: cada demo que agregas es una cosa más corriendo para siempre que hay que parchar, monitorear y pagar — así que la respuesta racional a ese diseño es dejar de agregar demos, exactamente lo contrario de lo que yo quería.

Así que el objetivo real nunca fueron los demos. Era esto: los recursos deben existir mientras un humano los está mirando, y ni un minuto más. Los demos son la consecuencia visible; la regla es el proyecto. Con un corolario que terminó moldeando cada decisión: si nada puede quedar estacionado, entonces el teardown tiene que ser una garantía, no una intención. Todo lo que podría olvidarme de limpiar, eventualmente me lo voy a olvidar de limpiar — un martes, dentro de tres meses, pensando en otra cosa.

Las restricciones definieron el diseño

Igual que en el proyecto anterior, escribí las reglas antes de la primera línea de nada:

  • Nada estacionado. Cero cómputo por demo entre sesiones. No “escalado a cero”: borrado.
  • El teardown es borrar, no limpiar. El demo entero vive dentro de un contenedor desechable que destruyo con una sola llamada. No ordeno: tiro la caja.
  • Lo que deba persistir tiene que costar ~$0 ocioso. Un núcleo permanente pequeño es aceptable; una factura permanente no.
  • Sesiones acotadas, por varias vías independientes. Un tope duro, un timeout por inactividad y un kill switch — porque cualquier límite único está a un bug de distancia de no ser ningún límite.

De ahí salió el resto:

PiezaElección
Entorno por sesiónUn grupo de recursos efímero (rg-demo-<slot>), creado y borrado por demo
OrquestadorGitHub Actions con OIDC (workflows de provisión / teardown / reap)
Puerta de entradaUn gateway persistente: TLS, auth de sesión, ruteo slot → entorno
DireccionamientoUn pool de subdominios de un nivel, demo01…demo10.alexisalulema.com
EstadoTable Storage: una fila por slot
PuntualidadUn cron Worker de Cloudflare, cada minuto (el segundo intento — ya llegaremos)

Dos mitades: la centralita y lo desechable

Todo el diseño es una separación entre lo que tiene que sobrevivir y lo que no debe hacerlo.

La mitad permanente es deliberadamente diminuta: un gateway (un reverse proxy que termina TLS, valida la sesión y rutea un slot a su entorno), el entorno compartido que lo aloja, y una tabla con el pool — diez filas, de demo01 a demo10, cada una libre u ocupada. El gateway escala a cero, así que cuando nadie está conectado la mitad permanente son unas filas en una tabla y un contenedor dormido.

La mitad desechable es todo lo demás. Cada sesión recibe su propio grupo de recursos con el demo completo adentro, y el teardown es un solo borrado de ese grupo. Esa es la parte que de verdad me importaba: no limpio recursos, borro el contenedor donde viven. No hay disco huérfano, ni definición de app olvidada, ni deriva de configuración, ni —lo más importante— un camino de limpieza parcial donde algo sobrevive porque un paso falló a mitad.

                      Persistente (≈$0 ocioso)
   ┌───────────────────────────────────────────────────┐
   │  Gateway (TLS · JWT de sesión · ruteo slot→env)   │
   │  Estado del pool  ·  cron del reaper (1/min)      │
   └───────────────────────────────────────────────────┘
                            │ rutea demoNN → entorno
        ┌───────────────────┼───────────────────┐
        ▼                   ▼                   ▼
   ┌─────────┐         ┌─────────┐         ┌─────────┐
   │rg-demo- │         │rg-demo- │         │ demo03  │   Efímeros
   │ demo01  │         │ demo02  │         │  libre  │   (creados y borrados
   │ (RAG)   │         │ (otro)  │         │         │    por sesión)
   └─────────┘         └─────────┘         └─────────┘

Escalar a cero habría estacionado la definición: la app sigue existiendo, su configuración sigue existiendo, y sigue siendo algo que poseo y en lo que tengo que pensar. Borrar el grupo de recursos es categórico de una forma en que una política de escalado nunca lo es.

Dos detalles que vale la pena dejar anotados. Los demos que no guardan estado por usuario pueden marcarse como compartibles, y entonces un segundo visitante se engancha al entorno que ya está corriendo en vez de esperar una provisión; los que sí tienen estado, como el chatbot, siempre reciben el suyo. Y cada demo responde en demoNN.alexisalulema.comun solo nivel de subdominio, que resultó ser arquitectónico y no cosmético: un certificado wildcard cubre demo01 gratis, mientras que un más prolijo demo01.demos.alexisalulema.com habría exigido un plan de certificado de pago, para siempre.

Cuando un campo cambia de significado, cada lector es un bug

Agregar esos entornos compartidos implicó cambiar lo que significa el valor de status de un slot, y mover la expiración a otro lado. Cambio limpio, dos archivos, una tarde.

El gateway —un servicio distinto, en otro contenedor, escrito en otro lenguaje— seguía ruteando solo cuando veía el valor viejo, y seguía leyendo un campo de expiración que se había mudado. Todos los demos, sin excepción, habrían devuelto 410 Gone. Lo cacé antes de que saliera, pero solo porque algo me hizo ir a releer el gateway.

Cambiar el significado de un campo de estado es un cambio incompatible de API para todos sus lectores — y “todos” incluye lectores que no viven en el código que estás editando, en lenguajes que tu compilador ni está mirando. Es el mejor argumento para renombrar un campo cuya semántica cambió: un rename convierte un cambio silencioso de comportamiento en una falla ruidosa en todos los consumidores a la vez.

Por qué aprueba un humano (y el bug que no vi venir)

Un endpoint en internet público que provisiona recursos en la nube a pedido es una factura esperando a que la escriba un desconocido. Así que hay una persona en el medio: una solicitud crea un ticket y me manda un correo con un botón Aprobar, y solo ese click dispara una provisión.

Lo cual produjo el bug más gracioso del proyecto. Los escáneres de correo hacen click en tus enlaces. Gmail —y cualquier producto corporativo de seguridad de correo— hace prefetch de las URLs de un mensaje para revisar si son maliciosas. Mi enlace de aprobación era un GET común. Así que los demos se estaban provisionando, fiel y automáticamente, gracias al escáner de seguridad cuyo trabajo era verificar que el correo sobre provisionar demos fuera seguro de abrir.

El arreglo es hoy política permanente en este código: el GET del enlace muestra una página de confirmación que no hace absolutamente nada, y la aprobación real es el POST detrás del botón de esa página. “Nunca pongas una acción que cambia estado detrás de un GET” es una regla que ya sabía en abstracto. Ahora la sé en el sentido revisa-la-lista-de-grupos-de-recursos, que resulta ser un tipo de saber distinto.

El reaper, o: best-effort no es una garantía

El primer diseño del teardown fue el obvio: un workflow agendado, cada cinco minutos, barriendo los demos vencidos. Gratis, simple, ya tenía las credenciales que necesitaba. Lo desplegué y seguí con otra cosa.

Hasta que un demo seguía vivo a los 25 minutos, contra un tope de 20.

El problema era el agendador. Los workflows agendados de GitHub Actions están throttleados: un cron */5 que yo había leído como “cada cinco minutos” estaba, según el historial de corridas, disparándose cada una o dos horas. Es comportamiento documentado: los schedule son best-effort. Y “best-effort” en una página de documentación significa “no es tu reaper” en producción.

Lo que lo hacía genuinamente peligroso es la forma de la falla. El acceso se cortaba perfectamente: el token de sesión expira a los veinte minutos, el gateway deja de rutear, y el visitante vive un demo que terminó exactamente a horario. Nada se veía roto desde ningún ángulo que un usuario o un health check pudieran observar — mientras el grupo de recursos seguía corriendo, con un piso de réplicas mayor que cero, hasta que un cron throttleado se dignaba a notarlo. La capa visible era impecable y el consumo real era unas seis veces lo que había diseñado. Esa es la peor clase de bug de infraestructura que existe: el que solo tiene un síntoma, y es dinero.

El rediseño reemplazó el agendador por un Worker de Cloudflare con cron cada minuto que hace exactamente una cosa: POST /api/demo/reap. El backend barre lo que siempre iba a barrer —sesiones vencidas, entornos vacíos, entornos inactivos— y despacha un teardown por slot. La latencia del teardown pasó de “entre cinco minutos y dos horas” a cosa de un minuto, y el Worker no cuesta nada.

Un timer que no controlas no es un timer, es una sugerencia. Si llegar tarde tiene un costo, la puntualidad tiene que ser la garantía de producto de alguien, no una comodidad best-effort que venía incluida con otra cosa que ya estabas usando.

Ese rediseño trajo también el timeout por inactividad, que tiene una sutileza que me gusta. Solo el gateway puede ver el tráfico de los demos —todo pasa por él, ya autenticado— así que estampa un lastActivity. Pero solo cuenta la actividad iniciada por el cliente. Un request, la apertura de un socket, un mensaje del cliente. Los datos que el servidor empuja hacia afuera, no. Si te equivocas ahí, un demo con streaming se mantiene vivo eternamente en nombre de un visitante que cerró la pestaña hace una hora, que es exactamente el escenario que el timeout existe para evitar.

El bug que destruía demos antes de que nadie los abriera

Liberar un slot reseteaba sus campos — pero no lastActivity. Así que un demo nuevo que reclamaba ese slot reciclado heredaba una marca de tiempo del inquilino anterior, potencialmente de horas atrás. El reaper hacía su aritmética, concluía correctamente que ese demo llevaba inactivo mucho más del límite, y lo borraba — en menos de dos minutos, antes de que el visitante lo hubiera abierto siquiera. Desde afuera: llega un correo de aprobación, haces click en el enlace, y te dice que el demo no está activo. El grupo de recursos ya no existe.

Cada pieza individual estaba bien. La matemática del reaper, el estampado, la liberación — cada una correcta en todo lo que manejaba explícitamente. El bug vivía en el espacio entre ellas: estado que era correcto cuando se escribió y estaba rancio para cuando se leyó.

Cuando promueves algo a estado persistente, la escritura es la parte fácil — audita el ciclo de vida completo. Reclamar, liberar, reusar. Un slot reciclado arrastra las pertenencias del inquilino anterior a menos que lo vacíes a propósito.

Y lo encontró un humano notando que los grupos de recursos desaparecían sospechosamente rápido, no un test. La suite estaba en verde todo el tiempo — como lo estaba, en el post anterior, mientras el chatbot saludaba en el idioma equivocado. Empiezo a pensar que eso es menos una coincidencia y más un patrón.

La infraestructura declarativa no ignora lo que no mencionas

Una tarde redesplegué el template compartido para agregarle un par de variables de entorno al gateway. Minutos después, todos los subdominios de demo fallaban con un 525 SSL handshake en el borde.

Los bindings de dominio personalizado —los hostnames adjuntos al gateway con su certificado— se crean con comandos de CLI, fuera del template, así que el template no los lista. Y un template declarativo no lee “no mencionado” como déjalo como está; lo lee como “esta propiedad debería estar vacía.” El redeploy borró obedientemente cada binding de hostname. Yo había construido un cómodo modelo mental donde el template era dueño de unas propiedades, mis comandos de CLI de otras, y todos nos llevábamos bien. El template no compartía ese modelo.

Cuando un recurso tiene dueño compartido entre un template declarativo y comandos imperativos, el declarativo gana en cada deploy. O metes la propiedad en el template, o aceptas un ritual documentado alrededor de cada deploy — el mío es que los cambios de variables de entorno van por un update puntual que no toca el ingress, y que cualquier redeploy real va seguido de rebindear los hostnames. Lo que no funciona es esperar que los dos se repartan el espacio de propiedades con educación.

”Empieza, no termina, muere”

Durante semanas la infraestructura venía provisionando mis dos cargas de prueba desechables sin quejarse. Entonces enchufé el chatbot RAG: el primer demo con tres contenedores en un pod, un reparto de recursos entre ellos, y un startup probe.

Falló al instante. Todas las veces. Y como el workflow de provisión borra el grupo de recursos cuando el despliegue falla —lo cual es correcto, y es la regla de “nada estacionado” haciendo su trabajo— la evidencia se borraba junto con él. Mi nota de ese día dice: empieza, no termina, muere.

Dos rechazos, encadenados, cada uno descubierto solo después de arreglar el anterior:

RechazoRealidad
Startup probe con failureThreshold: 40La plataforma lo topa en 30
Total de recursos inválidoYo había asumido 4 vCPU / 8 GiB disponibles; el techo real de este tipo de entorno es 2 vCPU / 4 GiB

Lo que los hizo diagnosticables fue un diferencial: los dos demos simples provisionan bien, este no — ¿qué tiene él que ellos no tengan? Un probe. Y después de eso, un reparto de recursos multi-contenedor. Tener una carga aburrida y conocida-buena al lado de la interesante resultó valer más que cualquier cantidad de lectura de logs.

Y el detalle que generaliza: el compilador del template no valida nada de esto. Construir el template lo verifica contra un esquema, y al esquema le parece perfecto un threshold de 40 y 4 vCPU. Esos son límites de runtime, aplicados por la validación de preflight de la plataforma cuando despliegas. “Compila” y “despliega” son afirmaciones distintas, y solo una de las dos significa algo.

Esa segunda fila es mi conexión favorita entre estos dos posts. El techo de 2 vCPU / 4 GiB es la restricción exacta contra la que el post anterior pelea durante toda su sección de performance: el modelo de medio billón de parámetros, el top-3 del retrieval, los chunks de 400 tokens, toda la disciplina de tratar el prompt como un presupuesto. Cada una de esas decisiones se remonta a un mensaje de error que recibí aquí, en la infraestructura. La plataforma no se limitó a alojar el demo. Lo diseñó.

Lo que deliberadamente no hice

  • Sin cluster. Un cluster es un recurso estacionado con una historia de administración adjunta, que es precisamente lo que este proyecto existe para evitar.
  • Sin autenticación dentro de cada demo. El gateway es dueño de eso, una sola vez. Un demo nuevo obtiene auth de sesión, TLS y expiración por el hecho de existir — no implementa nada.
  • Sin fijar digests de imagen. Cada provisión jala la imagen actual, que es lo que permite que publicar un post refresque el corpus del chatbot sin que nada en este sistema se entere.
  • Sin estado compartido entre un demo y la plataforma. Un demo nunca se entera de que es efímero, y puede morir a mitad de un request sin ceremonia. Eso es lo que hace que el teardown sea un delete y no una negociación.

Conclusión

En reposo, todo esto cuesta prácticamente nada: el gateway duerme en cero réplicas, el pool son diez filas de una tabla, el Worker del reaper es gratis. Cuando alguien pulsa el botón, cuesta un pod mientras un humano lo esté mirando de verdad.

Pero el número que más me gusta es el marginal. Agregar el siguiente demo cuesta cero recursos permanentes. Un proyecto nuevo se registra en unos pocos lugares, trae un template que describe sus propios contenedores, y hereda el pool, el gateway, la auth de sesión, la detección de inactividad y el reaper sin implementar ninguno. El costo del siguiente demo no es “un servicio más corriendo para siempre” — son unos minutos de cómputo el día que alguien de verdad quiera verlo. Esa es la diferencia entre un portafolio que me desincentiva a agregarle cosas y uno que no.

Dije al principio que no publicaría esto hasta que un demo real se hubiera provisionado para una persona real. Ya pasó, y lo que eso demostró no es que el diagrama estuviera bien. El diagrama estaba bien desde junio; es la parte fácil, un par de cajas y una flecha que dice “borra el grupo de recursos”. Lo que demostró la primera provisión real fue todo lo que el diagrama no dice. Cada problema de este post vivía en un hueco entre dos cosas que eran, cada una por su lado, correctas: un modelo de estado y un lector en otro contenedor, una expresión cron y las promesas reales de su agendador, la propiedad de un template y la de una CLI, un slot liberado y uno reclamado.

Son todos la misma falla, vestida distinto: verifiqué las piezas y di por sentadas las costuras. Que es, sospecho, mayormente lo que son los sistemas distribuidos: costuras, con piezas adjuntas.

El siguiente demo va a salir más barato que este, porque la parte cara ya está pagada. Ese es el retorno real de todo esto: no que el chatbot tenga dónde correr, sino que lo que construya después ya lo tiene, y no va a costar absolutamente nada hasta el momento en que alguien tenga la curiosidad de pulsar un botón.

Cosa que puedes hacer ahora mismo, en alexisalulema.com/projects. En algún lado se está creando un grupo de recursos con el nombre de tu sesión, y en veinte minutos no quedará rastro de que existió.