fastfn Parte 1: Tenía un Problema (y Le Metí Lua a Mi Vida)

1 Capítulo 1: El Hartazgo, o “¿Por Qué Un Hello World Son Siete Archivos?”
Todo empezó con una queja que da un poco de vergüenza decir en voz alta en 2026: quería soltar un archivo Python en disco y que ese archivo fuera un endpoint HTTP. Nada más. Sin Dockerfile. Sin requirements.txt a menos que yo quisiera uno. Sin el boilerplate de app = FastAPI(), sin invocar uvicorn, sin el wizard de “crear un proyecto” que te deja diecisiete archivos de configuración que vas a pasar las próximas tres tardes borrando. Y —aquí es donde se pone opinionado— quería un cold start razonable. No Lambda-cold, donde la primera petición de la mañana parece un 404 con pasos de más. Algo tibio. A escala humana.
Me había cansado de la forma de los frameworks web modernos. Son hermosos, escalan, tienen ecosistemas, y también te piden que compiles mentalmente un modelo completo de su mundo antes de que tu primera ruta responda con {"hello": "world"}. Para una herramienta interna desechable, eso es un impuesto que se paga en atención. Llevaba una década pagándolo. Quería parar.
Lo otro que quería —y esta es la funcionalidad que silenciosamente impulsa el resto de la historia— era políglotas por defecto. No políglotas como en “microservicios en distintos lenguajes comunicándose por gRPC.” Políglotas como en que el mismo árbol de URLs puede tener get.users.py, post.orders.js, y get.health.go, viviendo juntos en la misma carpeta, detrás del mismo gateway. Routing basado en archivos al estilo Next.js, pero agnóstico al runtime. Ese era el sueño.
El objetivo estaba claro: un Function-as-a-Service, pero local-first, con un CLI como interfaz principal y un árbol de archivos como base de datos. Lo llamé fastfn. El README describe la ambición en una sola línea: “Empieza con un archivo, un CLI amigable, y un árbol de rutas que puede crecer hasta convertirse en una API real o un SPA sin necesitar una reescritura después” (README.md:8). El resto de este post es la historia de cómo esa oración se convirtió en un gateway de Lua, un daemon de Python persistente, y un protocolo con un prefijo de longitud de 4 bytes. Es la historia de descubrir, lentamente y con cierta vergüenza, que estaba reinventando FastCGI.
2 Capítulo 2: Una Historia Breve y Levemente Injusta de CGI
Antes de llegar a lo que es fastfn, hay que hablar de con qué rima.
2.1 CGI: el serverless original
Al principio existía CGI. La Common Gateway Interface era serverless antes de que serverless fuera una marca. Ponías un script en /cgi-bin/, el servidor web hacía fork y exec en cada petición, el script leía la petición desde variables de entorno y stdin, escribía la respuesta en stdout, y terminaba. El sistema operativo limpiaba todo. Cada petición era un proceso. Cada proceso era un universo que existía por 40 milisegundos y luego moría.
Es maravilloso. También es un crimen de rendimiento.
CGI request model (what dies for you every time)
client web server /cgi-bin/hello.pl
| HTTP GET /hello | .
| --------------------> | fork() .
| | exec("perl hello.pl") --> [ cold Perl VM ]
| | [ parse script ]
| | [ run handler ]
| | <-- stdout -- [ die ]
| <-- HTTP 200 -- | .
| | .Cada petición paga por la creación del proceso, el calentamiento del intérprete, la importación de librerías, y el teardown. En una máquina de 1996 dolía. En una de 2026 sigue doliendo, solo que distinto: Python con caché fría gasta un porcentaje no trivial de tiempo simplemente importando su propia librería estándar antes de que tu handler escriba un solo byte. No he hecho profiling de esto end-to-end específicamente para fastfn, pero el orden de magnitud es suficientemente grande como para que el pool persistente al estilo FastCGI exista precisamente para amortizarlo.
2.2 FastCGI: el arreglo, y la forma que terminé copiando
FastCGI fue inventado exactamente para solucionar esto. La idea es casi obvia en retrospectiva: no mates el handler después de cada petición. Mantén un pool pequeño de procesos de handler vivos, deja que el servidor web se comunique con ellos por un Unix socket, y enmarca las peticiones para poder multiplexar limpiamente. El servidor web es el frontend; el pool de handlers es el backend; entre ellos fluye un stream de registros con prefijo de longitud.
FastCGI request model (what doesn't die for you)
client web server unix socket handler pool
| HTTP GET | | .
| -----------> | pack record | .
| | -------- [len|payload] ------------> [ already warm ]
| | | [ run handler ]
| | <------- [len|payload] ------------ [ sleep ]
| <--- HTTP -- | | .Los handlers son longevos. El intérprete está caliente. El estado global de tu handler sobrevive entre peticiones (para bien y para mal). El transporte es un socket aburrido con registros enmarcados. Cambias el aislamiento por proceso por algo más parecido a una llamada por red. Es un trato muy bueno y es esencialmente lo que los servidores Python WSGI modernos, PHP-FPM y —seamos honestos— el pool de warm-start de AWS Lambda están haciendo internamente.
No me propuse construir un FastCGI. Me propuse construir algo serverless donde sueltas un archivo y se convierte en una ruta. Resulta que una vez que quieres warm starts, handlers políglotas y un árbol de rutas direccionable por el sistema de archivos, el espacio de diseño te empuja hacia algo que parece FastCGI usando un trenchcoat de JSON. Más sobre el trenchcoat después.
3 Capítulo 3: Tenía un Problema… y Le Metí Lua a Mi Vida
Aquí es donde el título cobra sentido.
El gateway —la pieza que termina HTTP, lee la petición, determina qué archivo en disco debe manejarla, y reenvía la llamada— es la parte más importante y más irritante de cualquier FaaS. Tiene que ser rápido. Tiene que recargar cuando guardas un archivo. Tiene que hacer routing, auth, cookies, CORS, OpenAPI, y tiene que hacer todo eso sin convertirse en un proceso de Node de 30 MB con un cold start de 400 ms.
Intenté escribirlo en Go. Estuvo bien. También fue mucho código para lo que esencialmente es “toma una petición, busca un archivo, abre un socket, escribe, lee, escribe la respuesta.” Entonces una noche recordé que OpenResty —nginx con Lua embebido— ya hace las partes difíciles (parseo HTTP, TLS, epoll, memoria compartida) y simplemente me deja programar la capa de política en un lenguaje de scripting con startup de submilisegundo. No arrancas OpenResty por petición. OpenResty arranca una vez, al inicio del proceso, y luego tu Lua corre dentro de los hooks de fase de petición. Piénsalo como el servidor web invitando a tu código a vivir dentro de su event loop como huésped.
Así que le metí Lua a mi vida. No fue una decisión tanto como un árbol interno de opciones que seguía ramificándose: cada tercera cosa que necesitaba resultaba ser “oh, puedo hacer esto en Lua y funciona.” ¿Descubrimiento de rutas? Lua recorriendo un directorio. ¿Tabla de rutas en memoria compartida? ngx.shared.DICT. ¿Recarga caliente al guardar? Un pequeño timer en Lua que hace stat del directorio de funciones y reconstruye una tabla de rutas en un shared dict. ¿Parseo de cookies de sesión? Doce líneas:
local function parse_cookies(cookie_header)
local cookies = {}
if type(cookie_header) ~= "string" or cookie_header == "" then
return cookies
end
for pair in cookie_header:gmatch("[^;]+") do
local k, v = pair:match("^%s*([^=]+)%s*=%s*(.-)%s*$")
if k and k ~= "" then
cookies[k] = v or ""
end
end
return cookies
endDoce líneas. Sin npm install cookie-parser. Sin go get github.com/gorilla/sessions. Sin dependencias transitivas abandonadas en 2019 y ahora mantenidas por un bot. Doce líneas de Lua que corren dentro de la fase de petición de nginx, y el gateway ya entiende cookies de sesión.
Y es perfecto para este trabajo. El runtime es pequeño, el código es pequeño, la latencia es pequeña, y el gateway entero —routing, parseo de sesiones, marshalling de peticiones, un-marshalling de respuestas, endpoint de OpenAPI, servicio de Swagger UI— vive en un puñado de archivos Lua bajo openresty/lua/fastfn/. El subárbol http/ tiene exactamente los módulos que esperarías de un gateway real: gateway.lua, assets.lua, catalog.lua, openapi_endpoint.lua, swagger_ui.lua, reload.lua. El gateway.lua principal tiene 1341 líneas; el client.lua completo que implementa el protocolo de red tiene 110 líneas. Voy a citar la mayor parte de él en un momento.
3.1 La tabla de rutas es un shared dict
El truco que hace rápido al gateway de Lua no es inteligente. Es que la tabla de rutas no vive en una base de datos, no vive en Redis, ni siquiera vive en la memoria por worker. Vive en ngx.shared.fn_cache, una zona de memoria compartida de nginx legible por cada proceso worker. Cuando arranca fastfn dev, Lua recorre el directorio de funciones, construye un índice —"GET /hello → /functions/get.hello.py → runtime python"— y lo mete en el shared dict. Al cambiar un archivo, un chunk Lua de recarga lo reconstruye. Las peticiones hacen una sola búsqueda en el shared dict en la fase access_by_lua y luego despachan. No hay “framework.” Hay una tabla hash y una convención.
4 Capítulo 4: FastCGI con un Trenchcoat de JSON
Ahora el protocolo. Aquí es donde accidentalmente reconstruí FastCGI.
Cuando el gateway de Lua ha decidido que GET /hello debe ser servida por el runtime de Python, necesita llevar la petición al daemon de Python. El daemon de Python es un proceso longevo escuchando en un Unix socket (por defecto /tmp/fastfn/fn-python.sock, configurable via FN_PY_SOCKET, que puedes ver en srv/fn/runtimes/python-daemon.py:25). El gateway abre el socket, escribe la petición, y lee la respuesta.
El formato de trama es deliberadamente aburrido:
4 bytes N bytes
--------------- ----------------------------------------
| big-endian | | |
| uint32 len | | JSON payload (request or response) |
--------------- ----------------------------------------Cuatro bytes de longitud en big-endian. Luego esa cantidad de bytes de JSON. Ese es el protocolo entero. Es el enmarcado de registros de FastCGI, simplificado a un solo tipo de registro, con el cuerpo del registro siendo JSON en vez de la codificación binaria de clave-valor de FastCGI. Si FastCGI usara un trenchcoat e intentara pasar por una REST API moderna, así se vestiría.
El lado Lua del protocolo es suficientemente pequeño para citarlo completo. Aquí está el cliente que envía una petición y parsea una respuesta:
function M.call_unix(socket_uri, req_obj, timeout_ms)
local payload = cjson.encode(req_obj)
if not payload then
return nil, "invalid_request", "failed to encode request"
end
local connect_timeout = math.max(50, math.floor(timeout_ms * 0.2))
local io_timeout = math.max(50, timeout_ms)
local sock = ngx.socket.tcp()
sock:settimeouts(connect_timeout, io_timeout, io_timeout)
local ok, err = sock:connect(socket_uri)
if not ok then
if is_timeout(err) then
return nil, "timeout", "connect timeout"
end
return nil, "connect_error", tostring(err)
end
local sent, send_err = sock:send(pack_u32(#payload) .. payload)
if not sent then
sock:close()
if is_timeout(send_err) then
return nil, "timeout", "send timeout"
end
return nil, "send_error", tostring(send_err)
end
local header, header_err = sock:receive(4)
if not header then
sock:close()
return nil, "receive_error", tostring(header_err)
end
local body_len = unpack_u32(header)
if body_len <= 0 or body_len > 10 * 1024 * 1024 then
sock:close()
return nil, "invalid_response", "invalid frame length"
end
local body, body_err = sock:receive(body_len)
sock:close()
-- decode, validate, return
endLa función pack_u32 manual es un bonito recordatorio de que Lua 5.1 (que usa OpenResty) no trae string.pack, así que la implemento yo mismo:
local function pack_u32(n)
local b1 = math.floor(n / 16777216) % 256
local b2 = math.floor(n / 65536) % 256
local b3 = math.floor(n / 256) % 256
local b4 = n % 256
return string.char(b1, b2, b3, b4)
endEl tope de trama es 10 MB (body_len > 10 * 1024 * 1024). El lado Python impone un límite simétrico via FN_MAX_FRAME_BYTES, que por defecto es 2 MB (srv/fn/runtimes/python-daemon.py:26). Sí, los dos valores por defecto no coinciden. Eso es a propósito: el gateway es un poco más permisivo que el daemon para que un daemon mal configurado falle en el daemon, no en una lectura confusa a medias en el gateway. Es la clase de asimetría que se lee mal en una revisión de código y se lee bien cuando estás depurando un incidente en producción a las 2 AM.
4.1 El ciclo de vida de una petición, con milisegundos aproximados
Una vez que tienes el protocolo de red, el ciclo de vida completo de una petición es fácil de dibujar. Aquí una petición warm, con tiempos aproximados que medí en un laptop de mediados de 2024 corriendo Linux:
t=0.00 ms client sends HTTP GET /hello
t=0.10 ms nginx accept, parse HTTP headers
t=0.20 ms Lua access_by_lua: route lookup in ngx.shared.fn_cache
t=0.30 ms Lua: cjson.encode(req_obj) -> JSON payload
t=0.40 ms Lua: ngx.socket.tcp():connect("/tmp/fastfn/fn-python.sock")
t=0.50 ms Lua: send [4B len][JSON]
t=0.60 ms Python daemon: recv 4 bytes, decode length
t=0.70 ms Python daemon: recv N bytes, json.loads
t=0.80 ms Python daemon: dispatch to handler from _HANDLER_CACHE
t=1.10 ms handler(event) returns {"status": 200, "body": "..."}
t=1.20 ms Python daemon: json.dumps, send [4B len][JSON]
t=1.30 ms Lua: recv header, recv body, cjson.decode
t=1.40 ms Lua content_by_lua: ngx.say(resp.body)
t=1.50 ms nginx flushes HTTP response to clientEsos números son aproximados —no he publicado un benchmark end-to-end para el camino de función pura por sí solo, así que toma el desglose por línea en milisegundos como estimación, no como evangelio. La forma es correcta aunque los números exactos varíen. Los cold starts son una conversación muy diferente y llegaremos a eso.
5 Capítulo 5: Runtimes Políglotas, o “Lambda Sin Amazon”
El protocolo de red es agnóstico al lenguaje a propósito. El daemon de Python es una implementación. Hay un daemon de Node. Hay un camino para handlers en Rust, Go, PHP, y Lua también. Cada daemon de runtime hace la misma promesa: abre un Unix socket, habla el protocolo de 4 bytes de longitud + JSON, y cuando llega una petición, despacha a una función con la firma que AWS Lambda hizo famosa.
Aquí un handler real, de los ejemplos del tutorial políglotas:
import json
def handler(event):
query = event.get("query") or {}
name = query.get("name") or "friend"
return {
"status": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(
{
"step": 2,
"message": f"Hello {name} from Python.",
"runtime": "python",
"name": name,
}
),
}Ese es el contrato entero. def handler(event): return {...}. Forma Lambda sin Amazon. Forma Cloudflare Workers sin Cloudflare. También es, si lo miras con los ojos entrecerrados, simplemente CGI con las variables de entorno y stdin reemplazadas por un envelope JSON y un socket caliente. La forma ha sido estable desde los 90s porque es la forma correcta: una petición es un dict, una respuesta es un dict, y el handler es una función pura de uno al otro.
El daemon soporta tres adaptadores de invocación explícitamente (srv/fn/runtimes/python-daemon.py:51-53):
_INVOKE_ADAPTER_NATIVE = "native"
_INVOKE_ADAPTER_AWS_LAMBDA = "aws-lambda"
_INVOKE_ADAPTER_CLOUDFLARE_WORKER = "cloudflare-worker"“native” es mi forma por defecto. Los otros dos existen para que puedas levantar un Lambda o Cloudflare Worker existente en fastfn básicamente sin cambios. Esa interoperabilidad no fue gratis; me costó un cuidadoso traductor de forma de eventos. Pero significa que puedes tomar código que ya tienes corriendo en el serverless de alguien más y ejecutarlo localmente con fastfn dev, lo cual es genuinamente útil cuando la consola de AWS de tu empleador está teniendo una de sus semanas.
5.1 Un pool de workers dentro del daemon
El daemon de Python no solo despacha en el hilo principal. Tiene un ThreadPoolExecutor delante de un pool de workers de handlers, restringido por un presupuesto de slots. Los controles son todos variables de entorno:
RUNTIME_POOL_ACQUIRE_TIMEOUT_MS = int(os.environ.get("FN_PY_POOL_ACQUIRE_TIMEOUT_MS", "5000"))
RUNTIME_POOL_IDLE_TTL_MS = int(os.environ.get("FN_PY_POOL_IDLE_TTL_MS", "300000"))
RUNTIME_POOL_REAPER_INTERVAL_MS = int(os.environ.get("FN_PY_POOL_REAPER_INTERVAL_MS", "2000"))Entonces: timeout de adquisición de 5 segundos, TTL de inactividad de 5 minutos, el reaper corre cada 2 segundos. Son números de pool FastCGI vestidos con ropa de Python. El hilo reaper recorre el pool, mata workers inactivos que superaron su TTL, y reduce el footprint. El timeout de adquisición es la señal de “estoy al límite, retrocede.”
6 Capítulo 6: Lua Hasta el Fondo
El Capítulo 3 fue el momento en que le metí Lua a mi vida. Lo que no aprecié entonces es qué tan rápido colonizaría cada rincón del plano de control. Me propuse resolver un problema en Lua —“déjame parsear una cookie”— y al final de la tarde la tabla de rutas, el generador de OpenAPI, el rate limiter, y el dashboard de administración también eran Lua, corriendo en el mismo worker de nginx, compartiendo las mismas zonas de ngx.shared.DICT, sin ningún salto entre procesos.
Este es el capítulo donde listo los lugares donde corre Lua. Lua no es un arma secreta. Es un lenguaje pequeño con un JIT rápido, embebido en un servidor web que ya hace las partes difíciles, y resulta que encaja en este problema como una llave en una cerradura.
6.1 Routing y descubrimiento: el árbol de archivos es la base de datos
El primer lugar donde ganó Lua fue el routing. Las rutas no viven en un archivo YAML ni en un objeto de configuración; viven en el sistema de archivos. core/routes.lua recorre el directorio de funciones al arrancar, lee metadata de cada archivo de handler, y construye un catálogo de “GET /hello → Python → get.hello.py.” Luego cachea el catálogo en ngx.shared.fn_cache para que todos los workers tengan la misma vista de forma gratuita. El punto de entrada del descubrimiento es simplemente esto:
function M.discover_functions(force)
if not force then
local raw = CACHE:get("catalog:raw")
if raw then
local parsed = cjson.decode(raw)
if parsed then
return parsed
end
end
end
-- ... walk functions_root, populate catalog.runtimes / mapped_routes ...
endcore/routes.lua es el grande —aproximadamente 3,245 líneas— porque el routing es donde eventualmente se encuentran todas las preocupaciones: métodos, prefijos reservados, conflictos, rangos de fuente. Junto a él vive core/fs.lua (aproximadamente 392 líneas), un pequeño wrapper FFI alrededor de stat, opendir, readdir, y similares. Existe porque el FFI de LuaJIT me permite saltarme el módulo C de LuaFileSystem y llamar directamente a libc, manteniendo la imagen de OpenResty ligera:
function M.list_dirs_recursive(path, skip_fn)
local out = {}
if not M.is_dir(path) then
return out
end
local function walk(dir)
if type(skip_fn) == "function" and skip_fn(dir) then
return
end
out[#out + 1] = dir
for _, child in ipairs(M.list_dirs(dir)) do
walk(child)
end
end
walk(path)
table.sort(out)
return out
endEse es el recorrido del sistema de archivos. readdir en Lua-land, con una skip_fn enchufable para podar .git, node_modules, __pycache__, y .fastfn. Devuelve una lista ordenada de directorios que tanto routes.lua como watchdog.lua usan. Lo cual me hizo pensar: si las rutas son un árbol en disco, entonces la especificación OpenAPI es una proyección de ese mismo árbol, y el dashboard es solo una UI sobre él.
6.2 OpenAPI, gratis
Esa idea de “proyección del mismo árbol” se convirtió en core/openapi.lua, un módulo de 1,309 líneas que toma el catálogo producido por routes.lua y emite un documento OpenAPI 3. No hay decoradores en las funciones de handler. No hay @app.route("/hello"). El handler es simplemente def handler(event): return {...}, y la especificación se genera a partir del árbol de rutas más una capa delgada de metadata por función (fn.config.json, si el handler se molestó en escribir uno).
El lado HTTP de esto es un archivo Lua de 101 líneas que lo conecta todo:
local spec = openapi.build(catalog, {
server_url = server_url,
runtime_order = routes_mod.get_runtime_order(),
include_internal = env_bool("FN_OPENAPI_INCLUDE_INTERNAL", false),
invoke_meta_lookup = invoke_meta_lookup,
})
ngx.status = 200
ngx.header["Content-Type"] = "application/json"
ngx.say(cjson.encode(spec))Ese es el endpoint /openapi.json completo. Recarga tu handler, accede a la URL, la especificación lo refleja. Swagger UI (http/swagger_ui.lua, 119 líneas) sirve una página estática que apunta a ese JSON y tienes documentación interactiva sin correr un servidor de docs separado. El generador de OpenAPI también se apoya en core/invoke_rules.lua para normalizar la política de invocación por función —qué métodos están permitidos, qué rutas están reservadas:
M.ALLOWED_METHODS = {
GET = true,
POST = true,
PUT = true,
PATCH = true,
DELETE = true,
}
M.RESERVED_ROUTE_PREFIXES = {
"/_fn",
"/console",
}Lo cual me hizo darme cuenta de cuánto del tooling moderno de APIs es coincidencia de strings y listas de permitidos. La “especificación” no es una especificación, es una vista.
6.3 Observabilidad y límites: rate, concurrencia, salud
El truco de memoria compartida que hace barato el routing es el mismo truco que hace baratos los límites de concurrencia por función. core/limits.lua tiene 133 líneas, la mayoría boilerplate, y todo el mecanismo es una danza de incr/decr en una zona ngx.shared.fn_conc:
function M.try_acquire(dict, fn_key, limit)
if not limit or limit <= 0 then
return true
end
local key = key_for(fn_key)
local current, err = dict:incr(key, 1, 0)
if not current then
return false, "counter_error:" .. tostring(err)
end
if current > limit then
dict:incr(key, -1, 0)
return false, "busy"
end
return true
endEsa función es el primitivo completo de “no más de N invocaciones en vuelo de la función X en todo el gateway.” dict:incr es atómico entre workers porque ngx.shared.DICT es memoria compartida con un lock por debajo. No hay round trip a Redis. No hay pod de admission-controller. Es un contador en RAM, y es correcto porque nginx me lo da correcto.
Sobre los límites vive core/watchdog.lua (299 líneas), un watcher Linux inotify escrito en LuaJIT FFI. Abre inotify_init1(IN_NONBLOCK | IN_CLOEXEC), recorre el árbol de funciones, añade un watch en cada subdirectorio, y programa una recarga debounced cuando cambia un archivo:
local ok_poll, poll_err = ngx.timer.every(poll_interval, function(premature)
if premature then
return
end
local changed, read_err = drain_events()
-- ...
if pending_since and (ngx.now() - pending_since) >= debounce_s then
pending_since = nil
schedule_reload()
end
end)Un debounce de 150 ms, un callback de recarga compartido, y la tabla de rutas se reconstruye en su lugar. Guarda un archivo, la ruta está activa antes de que yo cambie al navegador.
6.4 Scheduling en proceso: ngx.timer.at como cron
Si puedo programar una recarga con ngx.timer, puedo programar cualquier cosa. core/scheduler.lua (1,712 líneas) y core/jobs.lua (993 líneas) juntos implementan un runner de jobs estilo cron, con reintentos y persistencia opcional, que vive dentro del worker de nginx. Sin daemon cron separado, sin Celery, sin RabbitMQ:
local ok, err = ngx.timer.every(interval, function(premature)
if premature then
return
end
local ok2, err2 = pcall(tick_once)
if not ok2 then
ngx.log(ngx.ERR, "scheduler tick failed: ", tostring(err2))
end
end)Ese único ngx.timer.every es el tick loop. En cada tick recorre los schedules activos, verifica las expresiones cron contra el reloj de pared, y encola runs via ngx.timer.at(0, ...). Los reintentos con backoff exponencial salen del mismo primitivo. La persistencia es un blob JSON escrito en disco cada 15 segundos. Es mucho código para una idea pequeña: el event loop que ya tengo es suficiente scheduler para un FaaS de un solo nodo.
6.4.1 Un ejemplo concreto: un digest de Telegram con IA que corre cada hora
El scheduler es abstracto hasta que ves una función que lo usa. El más limpio del repo es examples/functions/node/telegram-ai-digest —una función Node que obtiene mensajes de un grupo de Telegram, los resume con OpenAI, y envía el digest de vuelta al chat. Lo que lo hace interesante para esta sección es el bloque schedule en su configuración:
{
"timeout_ms": 30000,
"invoke": {
"summary": "Telegram AI digest: fetches group messages, summarizes with OpenAI, sends digest",
"methods": ["GET"],
"content_type": "application/json"
},
"schedule": {
"enabled": true,
"every_seconds": 3600,
"method": "GET"
}
}Esa es la interfaz completa de “convierte esto en un cron job”: un bloque schedule de tres líneas en la propia configuración de la función, junto al handler. Sin crontab externo, sin paso de registro separado, sin pipeline YAML. Cuando fastfn arranca, el scheduler lee el fn.config.json de cada función, ve que este tiene schedule.enabled = true, y lo añade al tick loop con every_seconds = 3600. Cada hora, el scheduler emite una petición GET interna a la función como si un cliente la hubiera llamado —mismo routing, mismo handler, mismo logging— y la función hace su trabajo.
El mismo patrón aparece en los ejemplos hermanos: telegram-ai-reply es un webhook (handler POST, sin schedule —Telegram lo llama cuando llega un mensaje), telegram-send es una función estilo librería que puedes invocar desde otros handlers para enviar un mensaje (dry_run por defecto, que es el tipo de detalle de seguridad que aprendí a añadir después del segundo bot que se suponía debía estar en silencio). Tres funciones, tres ciclos de vida —webhook, llamada de librería, cron— y todas son simplemente archivos en functions/. Lo único que hace que el digest sea “un cron” son esas tres líneas de configuración.
Me gusta esta forma porque el “modelo de despliegue” de la función y su “modelo de invocación” están en el mismo lugar. Si un compañero se pregunta “¿cómo corre esto?”, abre fn.config.json, ve schedule.every_seconds = 3600, lo sabe. Sin buscar en un archivo cron en un servidor al que no tiene acceso SSH.
6.4.2 Configuración y tokens: fn.env.json y el flag is_secret
La función del digest programado es inútil sin dos secretos y un identificador: un token de bot de Telegram, una API key de OpenAI, y el ID del chat al que publicar. Este es el punto en cada tutorial de FaaS donde o bien se hacen gestos vagos sobre variables de entorno o se pega un párrafo sobre Vault. fastfn no hace ninguna de las dos. Pone la configuración junto a la función, en un archivo hermano llamado fn.env.json, con un poco de estructura que hace explícito el significado de cada valor:
{
"TELEGRAM_BOT_TOKEN": {
"value": "<set-me>",
"is_secret": true
},
"TELEGRAM_CHAT_ID": {
"value": "<set-me>",
"is_secret": false
},
"OPENAI_API_KEY": {
"value": "<set-me>",
"is_secret": true
}
}Dos cosas a notar. Primero, cada clave es un objeto, no un string suelto —el valor vive en .value, y los metadatos viven junto a él. Segundo, ese booleano is_secret es significativo. El runtime lo usa para decidir si un valor se enmascara en logs y en el dashboard de administración, si puede ser devuelto por un endpoint _fn/ui_state, y si el botón “ver” del dashboard puede revelarlo en texto claro. TELEGRAM_CHAT_ID no es un secreto —es solo un número, y querrás verlo en la UI mientras depuras “¿por qué no apareció mi digest?”. TELEGRAM_BOT_TOKEN sí lo es —y si accidentalmente lo filtras en logs, la reacción de Telegram es invalidar el token, así que el scheduler dejaría de funcionar silenciosamente hasta que te des cuenta. El flag is_secret es toda la diferencia entre esos dos resultados.
El valor "<set-me>" tampoco es un bug —es un patrón que el repo usa deliberadamente. El handler en core.js trata <set-me>, set-me, changeme, <changeme>, y replace-me como centinelas de “no configurado”:
function isUnsetConfigValue(value) {
if (value === undefined || value === null) return true;
const s = String(value).trim();
if (!s) return true;
const l = s.toLowerCase();
return l === "<set-me>" || l === "set-me" || l === "changeme"
|| l === "<changeme>" || l === "replace-me";
}Eso significa que puedes hacer commit de fn.env.json en git con placeholders "<set-me>", y la función falla limpio en vez de pretender correr con un string vacío. Los despliegues reales reemplazan esos placeholders (en el dashboard, a través de la API, o editando el archivo en un host controlado) con valores reales; las entradas is_secret: true se enmascaran inmediatamente al guardar.
Dentro del handler, el runtime entrega estos valores en event.env, junto al event.method, event.query, etc. con scope de petición. El core.js del digest los lee con un pequeño fallback que prefiere el env local de la función, cayendo de vuelta al process.env del ambiente que el daemon puso en whitelist al arrancar:
const botToken = chooseConfigValue(env.TELEGRAM_BOT_TOKEN, process.env.TELEGRAM_BOT_TOKEN);
const chatId = chooseConfigValue(env.TELEGRAM_CHAT_ID, process.env.TELEGRAM_CHAT_ID);
const apiKey = chooseConfigValue(env.OPENAI_API_KEY, process.env.OPENAI_API_KEY);Esta es la forma de configuración a la que sigo volviendo: un archivo JSON junto al handler, un valor por secreto, un flag explícito is_secret que impulsa el enmascaramiento en cada superficie posterior, y un event.env a nivel de lenguaje que hace que la dependencia del handler en esos valores sea trivialmente buscable con grep. No es una idea nueva —es básicamente la misma forma que las config vars de Heroku o un par Secret/ConfigMap de Kubernetes. Pero vive en la misma carpeta que el código que lo usa, es versionable con placeholders seguros, y no requiere un gestor de secretos externo para empezar.
Pon el scheduler, la configuración, y el handler en un directorio, y el digest de Telegram pasa de “un vago cron job en algún lugar” a “cuatro archivos que puedo leer en un minuto y pasarle a un compañero.”
6.5 El dashboard es Lua
El lugar donde Lua más me sorprendió fue la consola de administración. No me propuse escribir una app web en Lua. Asumí que eventualmente conectaría un SPA pequeño, probablemente Vue o Svelte, porque “todo el mundo hace eso.” Entonces escribí console/login_endpoint.lua en una tarde y me di cuenta de que no necesitaba el SPA.
El endpoint de login tiene 95 líneas y hace todo lo que un endpoint de login debe hacer: verificación de método, rate limit en ngx.shared.fn_cache, comparación en tiempo constante, verificación de contraseña PBKDF2, cookie de sesión. Aquí el fragmento del rate limit:
local client_ip = ngx.var.remote_addr or "unknown"
local rate_store = ngx.shared.fn_cache
if rate_store then
local fail_count = rate_store:get(login_rate_key(client_ip))
if LOGIN_MAX_ATTEMPTS > 0
and type(fail_count) == "number"
and fail_count >= LOGIN_MAX_ATTEMPTS then
guard.write_json(429, { error = "too many login attempts, try again later" })
return
end
endCinco segundos de aritmética con shared dict y tengo un bloqueo funcional. console/auth.lua (484 líneas) maneja la cookie de sesión, PBKDF2 con un mínimo de 100,000 iteraciones, secretos opcionales desde archivo para la contraseña de admin, y un TTL de 12 horas. console/guard.lua (387 líneas) es el middleware que llama cada endpoint del dashboard primero para imponer auth, límites de body, CSRF, y gates de escritura. console/data.lua es el grande —aproximadamente 2,623 líneas— y es la “capa de servicio” de respaldo a la que delegan los endpoints del dashboard: listar funciones, leer el código de una función, escribir código, listar versiones, leer logs, leer schedules, agregar métricas del dashboard.
Los endpoints en sí son diminutos. El endpoint de métricas del dashboard tiene 21 líneas:
local method = ngx.req.get_method()
if method ~= "GET" then
guard.write_json(405, { error = "method not allowed" })
return
end
-- Aggregate metrics from shared dicts or external systems
local metrics = console.get_dashboard_metrics()
guard.write_json(200, metrics or {
invocations = {},
errors = {},
latency = {}
})Alrededor de eso viven functions_endpoint.lua (34 líneas), logout_endpoint.lua (15 líneas), ui_state_endpoint.lua (50 líneas), secrets_endpoint.lua (70 líneas), packs.lua (77 líneas), ui.lua (66 líneas), y el más grande invoke_endpoint.lua (526 líneas) que impulsa la consola de “invoca esta función desde el navegador.” No hay una app web de administración separada. No hay npm run dev. No hay un segundo sistema de build. Hay Lua, renderizando HTML y JSON desde el mismo worker de nginx que sirve el tráfico del gateway.
6.6 Lua como runtime, y un cliente HTTP muy pequeño
También añadí core/lua_runtime.lua (399 líneas), que deja que archivos Lua en el directorio de funciones sean handlers —mismo contrato handler(event) -> response que Python y Node, excepto que no hay salto entre procesos porque el handler corre en el mismo worker. No he publicado un benchmark para este camino, así que no voy a poner un número en milisegundos. Para handlers que necesitan llamar hacia afuera, core/http_client.lua (356 líneas) envuelve ngx.socket.tcp con parseo de URLs, keepalive, y timeouts, para que los handlers llamen a APIs upstream sin meter luasocket en la imagen. core/home.lua (234 líneas) sirve la página de aterrizaje por defecto en /, y http/function_code.lua / http/function_file_content.lua (50 y 76 líneas) hacen round-trip del código fuente de las funciones entre el editor del dashboard y el disco.
6.7 Los compromisos honestos
Entonces — ¿es Lua magnífico aquí? Sí, con comprobantes. Los comprobantes son:
- El cold start es esencialmente gratuito. OpenResty arranca una vez. Mi código Lua es un conjunto de módulos cargados en la inicialización del worker. El archivo
gateway.luatiene 1,341 líneas; cargarlo no añade prácticamente nada a la latencia de petición porque los hooks de fase de petición ya están compilados por JIT cuando llega la primera petición. No tengo un número limpio de “tiempo de carga del módulo Lua” que citar aquí, así que no voy a poner un número en milisegundos —pero cada p50 publicado del camino caliente en el repo, en todos los workloads, está en los milisegundos de un solo dígito end-to-end a través de este gateway de Lua, y ese es el único número que importa a un usuario. - La memoria compartida es casi gratuita.
ngx.shared.DICTes una tabla hash en nginx, protegida por un spinlock. No pago por un contenedor Redis para mantener contadores. - Un módulo por responsabilidad, y cada uno es pequeño. Dejando de lado el core de descubrimiento de routing, la mayoría de los módulos del plano de control están en el rango de 100–500 líneas.
limits.luatiene 133 líneas.watchdog.luatiene 299.login_endpoint.luatiene 95. Son números a distancia de lectura. Puedo mantener cualquiera de ellos en mi cabeza.
Y los costos, porque los hay:
- Peculiaridades numéricas de Lua 5.1. El LuaJIT de OpenResty es Lua-5.1-ish con algunas extensiones 5.2/5.3. No hay
string.packnativo, que es por qué existe elpack_u32del Capítulo 4. La confusión entero-vs-double me mordió dos veces mientras escribía el rate limiter;ngx.shared.DICT:incrdevuelve números que son doubles, y tuve que ser cuidadoso conmath.flooren contadores que planeaba comparar contra valorestonumber(env). - Disciplina de
pcallen todas partes. Un error no capturado en un callbackngx.timermata el timer silenciosamente y loggea enerror.log. Todo timer que escribo está envuelto enpcall, y el tick del scheduler, el callback del watchdog, y el escritor de persistencia siguen el mismo patrón. Sáltate esa disciplina y obtienes un scheduler que misteriosamente deja de tickear a las 3 AM. - Todo es un string, hasta que no lo es. Los shared dicts almacenan strings y números, no tablas, lo que significa encode/decode JSON en el límite. Me apoyo en
cjson.safepara que la entrada mala devuelvanil, erren vez de lanzar, y aún así obtengo el ocasionalcannot encode sparse arraycuando una tabla tiene huecos numéricos.
Ninguno de estos es un bloqueante. Son el precio de elegir un lenguaje pequeño con un JIT rápido embebido en un servidor web, y ese trato sigue ganando. Todo el plano de control —routing, OpenAPI, límites, watchdog, scheduler, consola, auth— es un puñado de archivos que puedo leer en una tarde. La mayoría de las preocupaciones son de 20 a 40 KB de Lua legible cada una. Todo el directorio core/ ocupa aproximadamente 304 KB en disco; todo el directorio console/ es aproximadamente 156 KB. Eso es menos Lua, por amplio margen, que la cantidad de JavaScript que un SPA moderno envía antes de llegar a la primera ruta. Y el Lua es el producto, no el andamiaje.
Lo cual me lleva a la parte donde intento extraer las lecciones de este montón.
7 Capítulo 7: Lecciones Que Solo Esperaba a Medias
7.1 Haz whitelist al env del host, siempre
Todo FaaS tiene la misma tentación: pasar las variables de entorno del host al handler. Es conveniente. También es como se filtran los secretos. Si tu pipeline CI/CD exporta AWS_SECRET_ACCESS_KEY para scripts de despliegue, y tu daemon Python hereda ese env en cada handler, enhorabuena, le has dado a cada handler en la máquina una credencial raíz de la nube.
El arreglo es una whitelist, no una blocklist. Descubrí esto de la manera aburrida: leyendo reportes de incidentes de otras personas y decidiendo que no quería escribir el mío propio. El daemon viene con una lista conservadora de claves de env del ambiente que los handlers pueden ver (srv/fn/runtimes/python-daemon.py:59-103):
_ALLOWED_WORKER_ENV_KEYS = {
"PATH",
"HOME",
"USER",
"LOGNAME",
# ... language runtimes
"PYTHONHOME",
"PYTHONPATH",
"GOPATH",
"GOCACHE",
"CARGO_HOME",
"RUSTUP_HOME",
# ... CA bundles
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
# ... locale
"LANG",
"TZ",
}
_ALLOWED_WORKER_ENV_PREFIXES = ("LC_",)El comentario encima de esto dice en voz alta lo que se suele callar: “Los secretos definidos por el usuario deben venir de fn.env.json o del event.env con scope de petición, no del env del host ambiente.” La whitelist es la aplicación. Todo lo que no esté en ese conjunto, o que comience con LC_, se elimina antes de que corra el handler. Es higiene de seguridad, no teatro de seguridad.
7.2 Precedencia de configuración: flag > env > config > default
Esta es la clase de regla que suena obvia y sin embargo es incorrecta en la mitad de los CLIs que he usado. Mi propio CLI lo hace bien porque lo hice mal la primera vez. De cli/cmd/run.go:64-73:
// Resolve hot-reload: flag > env > config > default (true)
hotReload := true
if runHotReload {
// Explicit --hot-reload flag always wins
hotReload = true
} else if envVal := os.Getenv("FN_HOT_RELOAD"); envVal != "" {
hotReload = envVal != "0" && envVal != "false" && envVal != "off" && envVal != "no"
} else if viper.IsSet("hot-reload") {
hotReload = viper.GetBool("hot-reload")
}Nota el comentario en la línea 64. No está ahí para mí —está para la próxima persona que intente “arreglar” esto haciendo que el archivo de configuración gane sobre el env. Todo sistema de configuración que alguna vez ha invertido este orden ha causado un incidente en producción en el que alguien estableció una variable de entorno en CI, y luego se preguntó por qué el contenedor seguía leyendo un valor obsoleto de un archivo commiteado.
7.3 Todo vive en el sistema de archivos
Sin base de datos. Sin registro. Sin etcd. Las rutas son archivos. La configuración es fastfn.json. El env es fn.env.json. Las sobrescrituras locales de funciones son fn.config.json. El estado de dependencias es .fastfn-deps-state.json.
Esta decisión se tomó por pereza y resultó ser una funcionalidad. Un FaaS filesystem-first puede ser versionado, sincronizado con rsync, snapshotted, y comparado con diff usando herramientas que ya tengo. No necesitas una “plataforma” para inspeccionarlo. ls es la UI de administración. grep es el depurador. Esta es la misma lección que aprendió inetd en 1986: a veces el plano de control debería ser un archivo de texto.
8 Capítulo 8: La Moraleja de la Historia (Parte 1)
Me propuse construir “suelta un archivo Python, obtén una URL.” Terminé reconstruyendo FastCGI a propósito, con un gateway de Lua porque OpenResty es genuinamente la herramienta correcta para este trabajo, un protocolo de red JSON porque los formatos de registro binarios son un mal tradeoff para un sistema local-first en 2026, y un contrato de runtime políglotas porque def handler(event): return {...} es la abstracción correcta y lo ha sido desde que Lambda fue lanzado.
Lo que no esperaba es cuántas de las decisiones de diseño fueron hechas de antemano por la historia. El costo de fork-por-petición que mató CGI es el mismo costo que hace que el serverless de contenedor-por-petición se sienta lento hoy. El pool persistente + socket enmarcado de FastCGI es la misma forma a la que convergen internamente las plataformas serverless modernas. Cloudflare Workers son, en su esencia, FastCGI con un aislado v8 en vez de un proceso. Los pools warm-start de AWS Lambda son FastCGI con un API Gateway enfrente. fastfn —al menos en la forma que acabo de describir— es FastCGI con tramas JSON y un gateway OpenResty.
Cerraré la Parte 1 con el único arrepentimiento que tengo: podría haber empezado con Lua un año antes. Cada vez que escribí “solo un pequeño gateway en Go” estaba escribiendo una versión peor de lo que nginx ya hace, con un cold start mayor, más código, y menos observabilidad. La lección —y no es una lección nueva, que es la parte vergonzosa— es que cuando la forma de tu problema coincide con una pieza de infraestructura existente y bien amada, deberías simplemente usar esa infraestructura. FastCGI me dijo cómo debería verse el plano de datos en 1996. OpenResty me dijo cómo debería verse el plano de control en 2010. Lambda me dijo cómo debería verse el contrato del handler en 2014. Todo lo que hizo la Parte 1 de fastfn fue escuchar.
Hay una segunda rama de esta historia que este post deliberadamente no cubre: qué pasa cuando una función no es la forma correcta —cuando necesitas un servicio longevo como una base de datos, una app Flask, o un frontend Next.js viviendo dentro del mismo gateway. Ese trabajo está actualmente en curso en una rama de funcionalidad y aún no está publicado, así que lo dejo para la Parte 2, donde el aislamiento de microVM Firecracker, la configuración de apps / workloads, y la red de pares vsock reciben el espacio que necesitan. Las funciones son la Parte 1. Los servicios son la Parte 2.
postgres.internal sin exponer un solo puerto en el host.