fastfn Parte 2: Cuando una Función No Es Suficiente (Servicios, Workloads y el docker-compose Que No Quería Escribir)

1 Capítulo 1: El Hartazgo (o, Por Qué una Función No Puede Ser un Postgres)
Al final de la Parte 1, fastfn era una cosita feliz. Soltabas get.users.py en disco, el gateway lo mapeaba a una URL, un daemon Python lo recogía, y tenías un endpoint HTTP sin tocar un Dockerfile. Era elegante de la manera en que las cosas son elegantes cuando solo resuelven la mitad de tu problema.
Porque entonces intenté construir una app real con ello.
Y en el momento en que intentas construir una app real, tus primitivos de request/response dejan de ser suficientes. Necesitas estado. Necesitas una base de datos. Necesitas el dashboard de administración que tu colega ya escribió como una app Next.js que espera correr con next start en el puerto 3000, no como un lambda sin estado que arranca, computa, y muere. Necesitas un Redis para sesiones, o un MinIO para blobs, o —en mi caso— una app Flask completamente formada que nunca iba a caber por el ojo de aguja de “un handler por archivo.”
Me quedé mirando esto por un rato. Luego dije lo que había estado esforzándome tanto por no decir:
Una función es una forma excelente para request/response. Es una forma pésima para un Postgres.
Lo cual me hizo darme cuenta de algo incómodo. En todos los proyectos personales que había tenido, la respuesta a este hartazgo siempre había sido el mismo archivo: docker-compose.yml. Un servicio es mi app, el otro es una base de datos, quizás hay un sidecar, y todos hablan en una red implícita que compose conjuró de la nada. Es una pieza hermosa de ergonomía, y la uso constantemente. Pero si fastfn iba a ser mi plataforma local, y yo iba a correr docker-compose up junto a fastfn dev, tendría dos espacios de URLs, dos modelos de salud, dos configuraciones de CORS, dos superficies de autenticación. El motivo mismo por el que existía el gateway era tener un lugar que conociera mi HTTP. Un compose paralelo habría deshecho eso.
Así que tenía que elegir. O acepto que fastfn maneja una porción de mi app y algo más maneja el resto. O extiendo fastfn para que las formas longevas —el Postgres, el Next.js, el Flask— vivan detrás del mismo gateway que las funciones. Mismo fastfn.json, mismo endpoint de salud, mismo CORS, misma auth, una superficie HTTP unificada.
No me propuse escribir docker-compose. Este post es sobre cómo escribí la mitad de docker-compose a propósito.
El commit sobre el que principalmente trata este post es 6a54c11 en la rama firecracker-simple-images: “Add simple native image apps and services”, 31 de marzo de 2026, 2254 inserciones en 26 archivos. Ahí es donde la idea de un workload encontró su lugar de primera clase en fastfn. Todo lo que vino después —la red de pares vsock en 5568b6c, los defaults keep-hot en 6fd5fec, la matriz firewall + benchmark en fd8a6b5— es una evolución de la misma abstracción.
2 Capítulo 2: La Palabra “Workload”
La palabra de la era CGI para lo que quería era “daemon.” La palabra de systemd es “unit.” Las palabras de Kubernetes son “Deployment” y “StatefulSet.” La palabra de Heroku es “entrada de Procfile.” La palabra de docker-compose es, confusamente, “service.” Elegí la palabra workload como paraguas, y luego la dividí en dos.
fastfn.json
┌──────────────────────────────────────────────┐
│ │
│ functions-dir: "functions" │
│ │
│ apps: ← public HTTP faces │
│ admin: { ... routes: ["/admin/*"] } │
│ │
│ services: ← private, injected │
│ mysql: { port: 3306, volume: "mysql-data" }│
│ │
└──────────────────────────────────────────────┘La distinción es pequeña pero significativa. Una app es un workload que tiene una cara pública: declara routes, y el gateway expone esas rutas al mundo exterior. Un service es un workload que se mantiene privado: otros workloads y funciones pueden alcanzarlo, pero el mundo exterior no puede. Un Postgres es un service. Un dashboard de administración Next.js es una app. En el extremo, la única diferencia entre ellos es si el campo routes está configurado y si el gateway los anuncia.
Ambos viven en el mismo archivo, y ambos son validados por el mismo camino de código (cli/internal/workloads/config.go:12-14):
// cli/internal/workloads/config.go:12-15
type Config struct {
Apps []AppSpec `json:"apps,omitempty"`
Services []ServiceSpec `json:"services,omitempty"`
}Dos listas hermanas. No dos mundos. El mismo normalizador recorre ambas, el mismo escritor de estado persiste ambas, el mismo gateway de Lua lee ambas del mismo archivo JSON en tiempo de petición.
Esto me hizo darme cuenta de algo casi trivial pero que vale la pena decir: una vez que tienes un gateway que sabe cómo enrutar, darle un nuevo tipo de destino no es un proyecto nuevo. Es una nueva fila en una tabla.
3 Capítulo 3: La Forma de un Workload
Veamos la forma real. Aquí está la configuración mínima de fastfn con una app y un service, tomada del diff del README en ese commit (README.md:178-202):
// fastfn.json
{
"functions-dir": "functions",
"public-base-url": "https://api.example.com",
"openapi-include-internal": false,
"apps": {
"admin": {
"image": "ghcr.io/acme/admin:latest",
"port": 3000,
"routes": ["/admin/*"]
}
},
"services": {
"mysql": {
"image": "mysql:8.4",
"port": 3306,
"volume": "mysql-data"
}
}
}Hay tres cosas que vale la pena mirar fijo. Primero, la fuente de imagen. Segundo, el puerto. Tercero, las rutas (solo en apps). Todo lo demás es un control opcional.
La fuente de imagen es interesante porque hay tres maneras válidas de declarar una, y el código de configuración impone que elijas exactamente una de ellas (cli/internal/workloads/config.go:383-395):
// cli/internal/workloads/config.go:387-395
sourceCount := 0
for _, value := range []string{image, imageFile, dockerfile} {
if strings.TrimSpace(value) != "" {
sourceCount++
}
}
if sourceCount != 1 {
return fmt.Errorf(
"must set exactly one image source among image, image_file or dockerfile",
)
}Entonces tus tres opciones son:
image: una referencia de registro comomysql:8.4, o un camino a un directorio de bundle Firecracker local en disco.image_file: un archivo OCI o Docker local, convertido a un bundle Firecracker cacheado en el primer uso.dockerfile: un camino a un Dockerfile que fastfn construirá a través de la API del Docker Engine, y luego igualmente convertirá.
La regla exactamente-uno es significativa. Dice “un workload, una fuente de verdad.” No puedes mezclar un bundle local con una imagen de registro y ver cuál gana. Por ese camino está la depuración que no quieres.
El puerto es más simple: es el puerto del contenedor en el que escucha el workload, con una validación de que cae dentro de 1..65535. Las rutas, para apps, son un array de prefijos de URL. La forma por defecto de una ruta soporta tanto coincidencia exacta (/admin) como glob de cola (/admin/*). Hay un normalizador que recorta las barras al final e impone una barra al comienzo (cli/internal/workloads/config.go:941-953), para que las tres maneras en que podrías escribir la ruta se conviertan en un string canónico.
El AppSpec completo es más grande que este mínimo, porque una vez que empiezas a correr apps reales quieres controles. Aquí está toda la superficie (cli/internal/workloads/config.go:66-88):
// cli/internal/workloads/config.go:66-88
type AppSpec struct {
Name string `json:"name"`
ScopeDir string `json:"scope_dir,omitempty"`
Image string `json:"image,omitempty"`
ImageFile string `json:"image_file,omitempty"`
Dockerfile string `json:"dockerfile,omitempty"`
Context string `json:"context,omitempty"`
Port int `json:"port"`
Env map[string]string `json:"env,omitempty"`
Command []string `json:"command,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
User string `json:"user,omitempty"`
Volume *VolumeSpec `json:"volume,omitempty"`
Volumes []*VolumeSpec `json:"volumes,omitempty"`
Healthcheck HealthcheckSpec `json:"healthcheck,omitempty"`
Routes []string `json:"routes,omitempty"`
Replicas int `json:"replicas,omitempty"`
Ports []PortSpec `json:"ports,omitempty"`
Access AccessSpec `json:"access,omitempty"`
ProcessGroups []ProcessGroupSpec `json:"process_groups,omitempty"`
HA *HAConfig `json:"ha,omitempty"`
Lifecycle LifecycleSpec `json:"lifecycle,omitempty"`
}ServiceSpec es casi idéntico (config.go:90-111), sin Replicas; para los services el grupo de procesos hace el conteo de réplicas. Por un momento estuve tentado de unificarlos en un struct con un IsPublic bool. Me alegra no haberlo hecho. Las dos formas tienen validadores sutilmente diferentes y defaults sutilmente diferentes, y tratar de doblarlos en un tipo seguía produciendo ternarios donde un segundo tipo producía claridad.
La simetría oculta una asimetría sutil que aparece en defaultAppLifecycle vs defaultServiceLifecycle (config.go:658-672):
// cli/internal/workloads/config.go:658-672
func defaultAppLifecycle() LifecycleSpec {
return LifecycleSpec{
IdleAction: "run",
PauseAfterMS: 15000,
Prewarm: true,
}
}
func defaultServiceLifecycle() LifecycleSpec {
return LifecycleSpec{
IdleAction: "run",
PauseAfterMS: 0,
Prewarm: true,
}
}Las apps obtienen un timer de inactividad de 15 segundos que pueden usar si alguna vez optan por pause. Los services obtienen cero —porque no pausas un Postgres. Un Postgres pausado se llama “una interrupción.” La política por defecto en todas partes es seguir corriendo, seguir caliente, y prewarm al arrancar. Dos modelos de ciclo de vida, un archivo de configuración, cero arrepentimientos.
4 Capítulo 4: Tres Backends, Una Forma de Configuración
Aquí es donde la abstracción de workload demuestra su valor. Se supone que el mismo bloque JSON corre de tres maneras diferentes:
fastfn.json workload
│
├────────┬────────────┬──────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
docker native process Firecracker (future
native (no container) microVM on backends)
(fallback) Linux/KVMCuál corre es en parte una decisión de plataforma y en parte una decisión de rama. En esta rama (firecracker-simple-images), Firecracker es el objetivo en hosts Linux/KVM, y docker_native.go es el manager de respaldo —nota el build tag en la parte superior del archivo (cli/internal/workloads/docker_native.go:1):
//go:build !linux
package workloadsEsa única línea hace más trabajo del que parece. Dice “en no-Linux, usa la implementación respaldada por Docker del manager de workloads.” En Linux el manager de Firecracker toma el control (firecracker_manager_linux.go). Ambas implementaciones satisfacen la misma interfaz interna nativeImageWorkloadManager que process/runner.go conecta:
// cli/internal/process/runner.go (from the 6a54c11 diff)
type nativeImageWorkloadManager interface {
Start(context.Context) error
Stop(context.Context) error
StatePath() string
}Tres métodos. Start levanta todos los workloads. Stop los baja. StatePath() le dice al resto del sistema dónde vive el archivo de estado JSON, para que el lado Lua pueda encontrarlo. Eso es todo. El manager Docker construye imágenes, crea una red, inicia contenedores, y abre puertos publicados. El manager Firecracker construye imágenes, las convierte en bundles, arranca microVMs, y las conecta a una red de pares vsock. Mecánicas diferentes, contrato público idéntico.
El manager docker-native es, honestamente, el docker-compose que me negué a escribir. Crea una red por proyecto (cli/internal/workloads/docker_native.go:90-98):
// cli/internal/workloads/docker_native.go:90-98
networkName := "fastfn-" + shortHash(m.cfg.ProjectDir+m.cfg.StatePath)
_, err := m.cli.NetworkCreate(ctx, networkName, dockertypes.NetworkCreate{
CheckDuplicate: true,
Driver: "bridge",
})
if err != nil {
return fmt.Errorf("create docker network: %w", err)
}
m.networkName = networkNameLuego para cada service y cada app adjunta el contenedor con dos alias —el nombre simple y un alias <nombre>.internal (docker_native.go:417-423)— para que las apps dentro de la red puedan alcanzar mysql.internal:3306 mientras el host alcanza 127.0.0.1:<publicado>. Esa división es intencional: las apps públicas alcanzan el mundo exterior a través del gateway en una URL estable, los services privados se alcanzan entre sí a través del alias interno, y el host alcanza los puertos publicados para depuración.
Un service arrancando se ve aproximadamente así:
// cli/internal/workloads/docker_native.go:251-266 (condensed)
service := ServiceState{
Name: spec.Name,
Image: firstNonEmpty(spec.Image, spec.Dockerfile),
ImageDigest: digest,
Host: "127.0.0.1",
Port: hostPort,
InternalHost: spec.Name + ".internal",
InternalPort: spec.Port,
ContainerID: containerID,
Health: WorkloadHealth{Up: true, Reason: "ok"},
Volume: spec.Volume,
BaseEnv: cloneEnvMap(spec.Env),
}
service.URL = BuildServiceURL(spec.Name, service.Host, service.Port, spec.Env)
service.InternalURL = BuildServiceURL(spec.Name, service.InternalHost, service.InternalPort, spec.Env)
service.FunctionEnv = BuildFunctionServiceEnv(spec.Name, service, spec.Env)Dos observaciones. Primero, BuildServiceURL inspecciona el env y descifra el esquema —si ve MYSQL_USER construye una URL mysql://, si ve POSTGRES_USER una postgres://, si ve REDIS_* una redis://, y de lo contrario recurre a tcp:// (state.go:143-157). Es una inferencia de URL despreocupada, y significa que no tengo que escribir DATABASE_URL a mano en el noventa y nueve por ciento de los casos. Segundo, FunctionEnv es la bolsa de variables que cada función del proyecto verá en tiempo de petición. Ese es el puente: el service es privado, pero las funciones obtienen SERVICE_MYSQL_HOST, SERVICE_MYSQL_PORT, SERVICE_MYSQL_URL, más un alias directo MYSQL_HOST / MYSQL_PORT / MYSQL_URL cuando el nombre es inequívoco (state.go:107-123, state.go:193-203).
Lo cual es, cuando lo miras de cerca, exactamente lo que docker-compose hace con sus links e inyección de depends_on. Solo con un esquema más ajustado y una convención de nombres más afilada.
5 Capítulo 5: Cómo Lua Encuentra un Workload
Ahora la parte divertida. El gateway sigue siendo OpenResty + Lua —la misma cosa de la Parte 1. ¿Cómo le dice un proceso Go longevo del lado del CLI a Lua del lado del gateway que los workloads existen y están activos?
A través de un archivo JSON en disco. Eso es todo.
El manager de workloads del CLI escribe en un archivo de estado en un camino conocido. El camino se exporta como una variable de entorno al gateway (process/runner.go en el diff de 6a54c11):
// cli/internal/process/runner.go (from 6a54c11)
if path := strings.TrimSpace(workloadMgr.StatePath()); path != "" {
baseEnv = append(baseEnv, "FN_IMAGE_WORKLOADS_STATE_PATH="+path)
}En el lado Lua, image_workloads.lua lee ese camino en cada petición:
-- openresty/lua/fastfn/core/image_workloads.lua:22-48
local function state_path()
local path = tostring(os.getenv("FN_IMAGE_WORKLOADS_STATE_PATH") or "")
if path == "" then
return nil
end
return path
end
local function load_state()
local path = state_path()
if not path then
return { apps = {}, services = {} }
end
local parsed = read_json_file(path)
if type(parsed) ~= "table" then
return { apps = {}, services = {} }
end
parsed.apps = type(parsed.apps) == "table" and parsed.apps or {}
parsed.services = type(parsed.services) == "table" and parsed.services or {}
return parsed
endLectura JSON por petición. En producción cachearías esto por algún TTL pequeño; en dev es exactamente lo correcto. El estado de salud, las rutas, los hostnames internos, el estado del ciclo de vida —todo en ese archivo.
El routing es el siguiente paso. El gateway ya hace un largo baile de coincidencias para las funciones (cubierto en la Parte 1). Para los workloads, le pregunta a image_workloads.lua por candidatos cuyas rutas coincidan como prefijo con el camino de la petición, luego los puntúa por longitud de ruta (las rutas más largas ganan, porque /admin/api/v1/users es más específico que /admin/*):
-- openresty/lua/fastfn/core/image_workloads.lua:123-151
function M.public_http_candidates(request_path)
local state = load_state()
local candidates = {}
for _, kind in ipairs({ "apps", "services" }) do
for _, workload_name in ipairs(sorted_keys(state[kind])) do
local workload = state[kind][workload_name]
for _, endpoint in ipairs(workload_public_endpoints(workload)) do
if tostring(endpoint.protocol or "http") == "http" then
local routes = type(endpoint.routes) == "table" and endpoint.routes or {}
for _, route in ipairs(routes) do
if route_matches(route, request_path) then
candidates[#candidates + 1] = {
kind = kind == "apps" and "app" or "service",
name = workload_name,
workload = workload,
endpoint = endpoint,
route = route,
route_length = #tostring(route),
}
end
end
end
end
end
end
return candidates
endDos detalles que me gustan. El loop externo itera {"apps","services"} en vez de concatenarlos, lo cual preserva la prioridad de las apps sobre los services cuando la misma ruta es reclamada dos veces (lo cual no debería ocurrir, pero el código defensivo es barato). Y route_length viaja con cada candidato para que el llamador pueda elegir la coincidencia más larga, lo cual coincide con la forma en que todo router sensato resuelve prefijos superpuestos.
El gateway consume esta lista en tiempo de petición y hace proxy al endpoint ganador (openresty/lua/fastfn/http/gateway.lua:950-969):
-- openresty/lua/fastfn/http/gateway.lua:950-969
local request_host, request_authority = request_host_values()
local matched_workload, matched_endpoint, workload_access_err = match_public_workload(
image_workloads.public_http_candidates(request_uri),
request_host,
request_authority,
request_client_ip()
)
if type(matched_workload) == "table" then
local app_resp, app_err = execute_public_workload_proxy(matched_workload, matched_endpoint)
if not app_resp then
local status = 502
if tostring(app_err or ""):find("unavailable", 1, true) then
status = 503
end
write_response(status, { ["Content-Type"] = "application/json" },
json_error("public workload proxy failed: " .. tostring(app_err)))
return
end
write_response(app_resp.status or 502, app_resp.headers or {}, app_resp.body or "")
return
endY aquí el proxy real —HTTP de entrada, HTTP de salida, con los headers hop-by-hop eliminados de vuelta para que nada confunda al cliente (gateway.lua:487-537):
-- openresty/lua/fastfn/http/gateway.lua:510-536
local resp, err = http_client.request({
url = string.format("http://%s:%d%s", host, port, ngx.var.request_uri or "/"),
method = ngx.req.get_method(),
headers = sanitize_app_request_headers(ngx.req.get_headers()),
body = body,
timeout_ms = 30000,
max_body_bytes = 10 * 1024 * 1024,
})
if not resp then
return nil, err
end
local filtered = {}
local drop = {
["connection"] = true,
["keep-alive"] = true,
["transfer-encoding"] = true,
["content-length"] = true,
["upgrade"] = true,
}
for k, v in pairs(resp.headers or {}) do
if not drop[tostring(k):lower()] then
filtered[k] = v
end
end
resp.headers = filtered
return respQuince megabytes de cuerpo máximo. Treinta segundos de timeout. Headers hop-by-hop eliminados. Este es el proxy HTTP inverso más aburrido que habrás leído nunca, que es exactamente lo que quieres que sea un gateway.
Lo inteligente no está en el proxy. Lo inteligente es que la misma fase ngx que resuelve funciones ahora también resuelve workloads, con una prioridad conocida, desde el mismo archivo de estado JSON, usando las mismas reglas de firewall de host/CIDR. Todo el conjunto pasa a ser una superficie HTTP unificada.
6 Capítulo 6: Ciclo de Vida (o, Convirtiendo una Tabla en una Máquina de Estados)
Una vez que los workloads existen, tienen que pasar por una vida. Arrancando. Saludable. No saludable. Detenido. Pausado. Reanudado. El ciclo de vida mantiene honesta la historia operacional.
┌────────────┐ start() ┌─────────────┐ healthcheck ok ┌─────────┐
│ declared │ ──────────▶ │ starting │ ─────────────────▶ │ healthy │
└────────────┘ └─────────────┘ └────┬────┘
│ │
│ healthcheck fails │ monitor tick
▼ │ reports failure
┌─────────────┐ ▼
│ unhealthy │ ◀──────────────── ┌─────────┐
└──────┬──────┘ │ flapping│
│ stop() └─────────┘
▼
┌───────────┐
│ stopped │
└───────────┘En state.go, las piezas viven en un pequeño cluster de tipos (state.go:23-91):
// cli/internal/workloads/state.go:23-38
type WorkloadHealth struct {
Up bool `json:"up"`
Reason string `json:"reason,omitempty"`
}
type PublicEndpointState struct {
Name string `json:"name"`
Protocol string `json:"protocol,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
ContainerPort int `json:"container_port,omitempty"`
ListenPort int `json:"listen_port,omitempty"`
Routes []string `json:"routes,omitempty"`
AllowHosts []string `json:"allow_hosts,omitempty"`
AllowCIDRs []string `json:"allow_cidrs,omitempty"`
}La señal de salud es un struct de dos campos —arriba, y una razón cuando no lo está. Cada workload mantiene una. El manager docker-native corre una goroutine de monitor que se despierta cada dos segundos, inspecciona cada contenedor, y actualiza el archivo de estado cuando algo cambió (docker_native.go:174-211):
// cli/internal/workloads/docker_native.go:174-211 (condensed)
func (m *NativeManager) monitor() {
defer close(m.doneCh)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-m.stopCh:
return
case <-ticker.C:
changed := false
for _, item := range m.containers {
health := m.inspectHealth(item)
// ... update m.state.Apps or m.state.Services,
// setting changed = true when different
}
if changed {
_ = m.writeState()
}
}
}
}El comportamiento de escribir-solo-cuando-cambia importa. El lado Lua lee ese archivo de estado en cada petición. Si el lado Go escribiera en el archivo cada dos segundos sin condición alguna, cada petición vería un archivo nuevo y cada pequeña lectura de fs sería en vano. Escribir solo cuando algo realmente se movió mantiene el archivo estable por largos períodos, que es lo que quieres de un mecanismo de IPC que es, con benevolencia, un archivo JSON.
El arranque es serial a propósito: los services suben primero, luego las apps (docker_native.go:100-128). Las apps arrancan con el env del service ya poblado en su entorno —que es el momento donde la simetría limpia del archivo de configuración se afirma como un orden de dependencias. Los services existen para que las apps puedan usarlos; por lo tanto los services arrancan primero.
El punto de entrada del CLI son las mismas dos líneas aburridas tanto en dev como en run. Para dev, del diff de 6a54c11:
// cli/cmd/dev.go (from 6a54c11 diff)
imageWorkloads, hasImageWorkloads, err := configuredImageWorkloads()
if err != nil {
devFatalf("Invalid apps/services config: %v", err)
return
}
// ...
if err := runNative(configuredProjectRoot(), absPath, imageWorkloads); err != nil {
devFatalf("Native dev failed: %v", err)
return
}
// ...
if hasImageWorkloads {
devFatal("apps/services are only supported in native mode for this branch; rerun with --native")
return
}Esa última rama es importante. Las apps y los services solo funcionan con --native en esta rama; el modo dev clásico de Docker sigue siendo solo para funciones. Es una limitación conocida, documentada explícitamente en la referencia de configuración (docs/en/reference/fastfn-config.md:13). Mezclar los dos ciclos de vida en el antiguo camino de gateway respaldado por Docker era el tipo de yak-shaving que decidí posponer. El modo nativo es el único camino honesto a seguir para esta funcionalidad.
El helper configuredImageWorkloads es el pegamento. Lee las claves apps y services de viper y las normaliza a través de workloads.NormalizeAppSpecs / NormalizeServiceSpecs (cli/cmd/root.go:171-186):
// cli/cmd/root.go:171-186 (from 6a54c11 diff)
func configuredImageWorkloads() (workloads.Config, bool, error) {
var cfg workloads.Config
apps, appsSet, err := workloads.NormalizeAppSpecs(viper.Get("apps"))
if err != nil {
return cfg, false, err
}
services, servicesSet, err := workloads.NormalizeServiceSpecs(viper.Get("services"))
if err != nil {
return cfg, false, err
}
cfg.Apps = apps
cfg.Services = services
return cfg, appsSet || servicesSet, nil
}Ese es todo el cableado. Viper lee fastfn.json, el paquete de workloads lo normaliza, y el runner nativo levanta un manager cuyo StatePath() queda estampado en el entorno de cada daemon de runtime y cada worker de OpenResty.
7 Capítulo 7: El Giro de Firecracker
En este punto todo lo que he descrito funciona en un laptop con Docker, y nada de lo que he descrito necesita Firecracker. Pero toda la rama se llama firecracker-simple-images, así que déjame contarte dónde entra Firecracker, y qué cambió realmente.
En un host Linux/KVM, el manager no es docker_native.go. Es firecracker_manager_linux.go. Misma interfaz. Mismo archivo de estado. Mecánicas muy diferentes por debajo: la imagen OCI se convierte en un bundle Firecracker (un kernel vmlinux más un rootfs.ext4), el bundle se cachea bajo .fastfn/firecracker/images/, y una microVM arranca con una configuración mínima del kernel. El contrato público es idéntico: el workload escucha en su puerto declarado, el gateway hace proxy a él, el archivo de estado reporta salud.
Lo que es diferente —y aquí es donde importa el commit 5568b6c (“Add vsock peer networking for Firecracker workloads”)— es cómo el gateway realmente alcanza a un guest Firecracker. Un contenedor Docker regular publica un puerto en un bridge y te conectas a 127.0.0.1:<puertohost>. Una microVM Firecracker no tiene esa conveniencia por defecto. Así que el commit introduce una red de pares basada en vsock: un helper del lado del guest (cli/internal/firecrackerguest/main.go, 455 líneas en ese commit) que termina vsock, y un private_network.go del lado del host que une las piezas. El gateway sigue conectándose a un InternalHost:InternalPort; la plomería por debajo de ese host/puerto es vsock en vez de TCP en un bridge. La abstracción se mantiene.
El commit de seguimiento 6fd5fec (“Keep Firecracker image workloads hot by default”) es el que hizo esto útil en la práctica. Sin él, un workload Firecracker recién arrancado se sentiría genial en la primera petición y menos genial si alguna vez se pausaba. Con los defaults keep-hot, el workload se mantiene residente y se precalienta al arrancar fastfn dev / fastfn run. Los docs llaman a esto “speed-first” (docs/en/reference/fastfn-config.md:302-307): idle_action por defecto es run, prewarm por defecto es true, tanto para apps como para services.
Los números reales —y estos están copiados de la matriz de benchmark en docs/en/explanation/performance-benchmarks.md— son la única razón por la que estoy dispuesto a llamar “terminado” a cualquiera de esto:
| Case | Source | Build/Pull | First OK | Hot p50 | Hot p95 | Hot p99 | Same PID |
|---|---|---|---|---|---|---|---|
Flask (flask-compose) | Dockerfile repo | 1168ms | 5017ms | 1.94ms | 3.05ms | 4.10ms | true |
Registry app (traefik/whoami:v1.10.2) | Registry image | 98ms | 2508ms | 1.26ms | 2.09ms | 2.28ms | true |
FastAPI + Postgres (fastapi-realworld) | Dockerfile repo + private service | 1202ms | 17036ms | 5.29ms | 7.02ms | 7.94ms | true |
Two equal postgres:16 services | Same OCI, same native 5432 | 1246ms | 22090ms | 10.92ms | 28.85ms | 32.58ms | true |
Rust + Postgres (rust-postgres) | Dockerfile repo + private service | 35139ms | 47602ms | 2.66ms | 3.86ms | 10.27ms | true |
Cinco filas de una matriz de veinte casos (snapshot del 1 de abril de 2026; lista completa en docs/en/explanation/performance-benchmarks.md:46-54). Lo que leo en esta tabla es:
- La construcción en frío + prewarm son segundos, a veces decenas de segundos para una construcción de Rust. Eso no es gratis. Pero ocurre una vez.
- Después del prewarm, el camino caliente son pocos milisegundos de un solo dígito para apps livianas y aún de un solo a bajo doble dígito para las respaldadas por BD.
same_firecracker_pid = trueen cada fila, lo que significa que el loop caliente realmente está reutilizando la misma microVM residente. El gateway no está secretamente reiniciando Firecracker entre mi petición y mi p95.- Dos services
postgres:16idénticos pueden compartir el mismo puerto nativo 5432 siempre que sus nombres de workload difieran. La red privada es por workload; el puerto es por proceso dentro de su propio guest.
La advertencia honesta —que el doc mismo reconoce, y me complace repetir (docs/en/explanation/performance-benchmarks.md:94-139)— es que la matriz de 20 casos no son “veinte apps upstream sin ediciones.” Algunos casos son upstream tal cual. Algunos tienen una capa de benchmark encima. Todos comparten el mismo camino de runtime de FastFN, pero el harness no es un benchmark sin tocar para todos ellos. Prefiero decirlo abiertamente a maquillarlo.
Y sí, fd8a6b5 (“Add image workload firewall and benchmark matrix”) es el commit que trajo tanto el control de acceso allow_hosts / allow_cidrs en los puertos públicos como las herramientas que produjeron estos números. Cubriré el firewall en un post posterior —es toda una estética en sí misma— pero la versión corta es: una app pública puede restringirse a una lista de hosts permitidos y/o una lista de CIDR permitidos, ambas mostradas en la función de puntuación del gateway que cité en el Capítulo 5.
8 Capítulo 8: Las Funciones Se Encuentran con los Services (La Historia de la Inyección)
Volviendo a un detalle que pasé por alto. ¿Cómo ve exactamente una función a un service?
A través del env. Cuando arranca un service, el manager llama a BuildFunctionServiceEnv y guarda el resultado en el estado del service (state.go:107-123):
// cli/internal/workloads/state.go:107-123
func BuildFunctionServiceEnv(serviceName string, service ServiceState, baseEnv map[string]string) map[string]string {
out := map[string]string{}
appendScopedServiceEnv(out, serviceName, baseEnv)
upper := serviceEnvToken(serviceName)
out["SERVICE_"+upper+"_HOST"] = service.Host
out["SERVICE_"+upper+"_PORT"] = fmt.Sprintf("%d", service.Port)
out["SERVICE_"+upper+"_URL"] = service.URL
out["SERVICE_"+upper+"_INTERNAL_HOST"] = service.InternalHost
out["SERVICE_"+upper+"_INTERNAL_PORT"] = fmt.Sprintf("%d", service.InternalPort)
if strings.TrimSpace(service.InternalURL) != "" {
out["SERVICE_"+upper+"_INTERNAL_URL"] = service.InternalURL
}
appendDirectServiceAlias(out, serviceName, service.Host, service.Port, service.URL)
return out
}En el lado Lua, cuando una función está a punto de ser invocada, el gateway extrae la unión de los FunctionEnv de todos los services al envelope de evento que pasa al daemon de runtime (openresty/lua/fastfn/http/gateway.lua:1169-1174):
-- openresty/lua/fastfn/http/gateway.lua:1171-1174
local service_env = image_workloads.function_env()
if next(service_env) ~= nil then
event.env = service_env
endLo que significa que una función Python simplemente puede hacer:
# a handler somewhere in functions/
import os
host = os.environ["SERVICE_MYSQL_HOST"]
port = int(os.environ["SERVICE_MYSQL_PORT"])
url = os.environ["SERVICE_MYSQL_URL"]…y funciona, ya sea que el MySQL de respaldo sea un contenedor Docker en macOS o una microVM Firecracker en Linux. Mismos nombres de variables, misma inferencia de esquema de URL, mismo código. El cambio de backend es invisible.
También hay un segundo camino. Una app necesita el env del service al iniciar el proceso, no en tiempo de petición —porque una app Next.js lee process.env en next start, no por petición. Entonces el manager docker-native construye un mapa appServiceEnv de todos los services y lo pasa como el env del contenedor de cada app (docker_native.go:110-122, 282-294). La app por lo tanto ve las mismas variables SERVICE_*, pero con scope a través de BuildAppServiceEnv en vez de BuildFunctionServiceEnv —la diferencia está en si usas el hostname interno (para apps, que viven en la misma red privada) o el host público (para funciones, que viven en el host).
service discovery fan-out
────────────────────────
┌──────────────┐ BuildFunctionServiceEnv
│ service │ ───────────────────────────▶ event.env
│ (e.g. mysql)│ │
└──────┬───────┘ ▼
│ ┌─────────────┐
│ │ function │
│ BuildAppServiceEnv └─────────────┘
└──────────────────────▶
container.Env
│
▼
┌──────────┐
│ app │
└──────────┘Dos consumidores. Una fuente de verdad. Sin DATABASE_URL escrito a mano a la vista.
9 Capítulo 9: Las Lecciones Que Realmente Me Llevo
Mirando hacia atrás al diff de 2254 líneas, esto es lo que le diría a mi yo del pasado antes de empezar.
Unificar el gateway es lo fundamental. La tentación de dividir HTTP en “fastfn maneja las URLs de funciones” y “docker-compose maneja todo lo demás” es enorme porque es el camino de menor resistencia. Pero cada división de la superficie HTTP es un bug futuro en CORS, auth, observabilidad, o OpenAPI. Mantén el gateway único. Dale más tipos de objetivos, no más amigos.
Dos modelos de ciclo de vida pueden coexistir si un archivo de configuración hace la compilación. Las funciones tienen scope de petición. Los workloads son longevos. Son formas genuinamente diferentes con modos de falla diferentes. Ocultarlos a ambos detrás de un fastfn.json funcionó porque la capa de configuración compila ambas formas hacia la misma representación de runtime “cosa-a-la-que-el-gateway-hace-proxy.” Dos modelos de ciclo de vida, un archivo de configuración, cero arrepentimientos.
Declara la fuente de imagen exactamente una vez. La regla exactamente-uno en image/image_file/dockerfile parece pedante el día uno y te salva de mensajes de error ilegibles el día doscientos. Quieres que haya una manera en que un ingeniero mirando fastfn.json pueda decir de dónde viene este workload.
Un archivo JSON es una superficie de IPC perfectamente válida entre tu CLI y tu gateway, si tienes cuidado. Escribir solo cuando hay cambio, leer por petición, y exportar el camino a través de una variable de entorno es —en contra de mis priors— una manera extremadamente tranquila de mover información entre un proceso Go longevo y un proceso OpenResty longevo. Hay un futuro donde esto se convierte en un unix socket y un modelo de suscripción. Pero por ahora, el archivo es honesto y fácil de depurar con cat.
Nombra los services por lo que son. Casi no auto-generé los alias MYSQL_HOST / MYSQL_URL junto a los SERVICE_<NOMBRE>_HOST. Luego escribí mi primer handler real y recordé que los humanos no quieren escribir SERVICE_MYSQL_HOST. Los alias directos son una funcionalidad de usabilidad disfrazada de convención de nombres.
Los benchmarks te obligan a ser honesto. La tabla de la matriz anterior es la única razón por la que creo las palabras “residente” y “caliente” en mi propia documentación. same_firecracker_pid = true significa que nadie está secretamente reiniciando la VM entre mi petición y mi p95. Ese chequeo existe porque al principio no era verdad, y el benchmark fue lo único que me lo dijo. Mide la propiedad, no la intención.
Y unas palabras sobre las salvedades. Hay mucho que deliberadamente no hice en esta rama. Las apps y los services solo funcionan con --native. Los workloads Firecracker solo funcionan en Linux/KVM. Los usuarios de macOS y Windows obtienen el manager Docker en vez de Firecracker por ahora, y ese camino no corre workloads de imagen en absoluto fuera del modo nativo en esta rama. Actualizaciones graduales, blue/green, autoscaling —todos ausentes, y está bien que estén ausentes, porque el objetivo de esta fase era clavar la abstracción, no las operaciones. Un manager de workloads que hace tres backends limpiamente vale mucho más que un manager de workloads que hace un backend con cada funcionalidad que Kubernetes alguna vez envió.
¿Qué sigue? Hay dos direcciones que puedo ver claramente. Una es llevar el camino de workload al modo Docker fastfn dev para que los usuarios de macOS obtengan la misma ergonomía sin Firecracker. La otra es hacer el firewall más rico —más allá de allow_hosts y allow_cidrs, hacia algo que pueda expresar “este service solo es alcanzable desde estos workloads específicos en esta red privada.” Ambas se sienten como capítulos genuinamente nuevos, no como notas al pie.
Por ahora, lo esencial es pequeño, aburrido y correcto: si necesitas un Postgres junto a tus funciones, editas tu fastfn.json, añades un bloque services.postgres, y tus funciones obtienen SERVICE_POSTGRES_URL en su entorno. Eso es todo.
Las funciones son una gran forma para request/response. Los workloads son la forma para todo lo que no lo es. Y ahora viven en el mismo archivo de configuración, el mismo gateway, el mismo endpoint de salud, la misma matriz de benchmark, con los mismos defaults keep-hot.
No quería escribir docker-compose. Terminé escribiendo algo que rima con la décima parte de docker-compose que realmente uso, y nada más. Lo cual, si soy honesto, es probablemente la primera vez en mi carrera que construí menos plataforma de la que pensé que construiría.
Nos vemos en la Parte 3, donde voy a desmontar en detalle la historia del firewall, la plomería vsock, y los defaults keep-hot. Hasta entonces: un archivo, una ruta, un workload, un gateway.