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


{{< admonition type="info" title="Parte de una serie" open=true >}}
Esta es la Parte 2 del write-up de fastfn. La Parte 1 — [**fastfn Parte 1: Tenía un Problema (y Le Metí Lua a Mi Vida)**](/es/fastfn-lua-to-our-lives/) — cubre el lado de las funciones: el gateway de Lua, el protocolo JSON, los runtimes políglotas. Este post continúa donde termina la Parte 1 y recorre servicios, workloads, y el camino de microVM Firecracker.
{{< /admonition >}}

## 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.

## 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.

```text
   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`):

```go
// 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.

## 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`):

```json
// 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`):

```go
// 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 como `mysql: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`):

```go
// 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`):

```go
// 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.

## 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:

```text
   fastfn.json workload
            │
            ├────────┬────────────┬──────────────────┐
            │        │            │                  │
            ▼        ▼            ▼                  ▼
       docker   native process  Firecracker     (future
       native   (no container)  microVM on       backends)
       (fallback)                Linux/KVM
```

Cuá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
//go:build !linux

package workloads
```

Esa ú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:

```go
// 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`):

```go
// 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 = networkName
```

Luego 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í:

```go
// 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.

## 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):

```go
// 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:

```lua
-- 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
end
```

Lectura 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/*`):

```lua
-- 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
end
```

Dos 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`):

```lua
-- 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
end
```

Y 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`):

```lua
-- 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 resp
```

Quince 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.

## 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.

```text
    ┌────────────┐   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`):

```go
// 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`):

```go
// 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:

```go
// 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`):

```go
// 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.

## 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 = true` en 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:16` idé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.

## 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`):

```go
// 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`):

```lua
-- 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
end
```

Lo que significa que una función Python simplemente puede hacer:

```python
# 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).

```text
                        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.

## 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.

