fastfn Parte 2: Quando uma Função Não Basta (Serviços, Workloads e o docker-compose Que Eu Não Queria Escrever)

1 Capítulo 1: A Dor (ou, Por Que uma Função Não Pode Ser um Postgres)
No fim da Parte 1, o fastfn era uma coisinha feliz. Você colocava get.users.py no disco, o gateway o mapeava para uma URL, um daemon Python o pegava, e você tinha um endpoint HTTP sem encostar num Dockerfile. Era elegante do jeito que as coisas são elegantes quando resolvem só metade do seu problema.
Porque então eu tentei construir um app de verdade com ele.
E no momento em que você tenta construir um app de verdade, suas primitivas de requisição/resposta deixam de bastar. Você precisa de estado. Precisa de um banco de dados. Precisa do painel de administração que seu colega já escreveu como um app Next.js que espera ser next start na porta 3000, não uma lambda sem estado que sobe, computa e morre. Você precisa de um Redis para sessões, ou um MinIO para blobs, ou — no meu caso — um app Flask totalmente formado que nunca caberia pelo buraco de agulha do “um handler por arquivo.”
Fiquei encarando isso por um tempo. Então eu disse aquilo que vinha me esforçando muito para não dizer:
Uma função é uma ótima forma para requisição/resposta. É uma forma terrível para um Postgres.
O que me fez perceber algo desconfortável. Para todo projeto paralelo que eu já tinha rodado, a resposta para essa dor tinha sido o mesmo arquivo: docker-compose.yml. Um serviço é o meu app, o outro é um banco de dados, talvez haja um sidecar, e todos conversam numa rede implícita que o compose conjurou do nada. É uma peça linda de ergonomia, e eu a uso o tempo todo. Mas se o fastfn fosse ser minha plataforma local, e eu fosse rodar docker-compose up ao lado de fastfn dev, eu teria dois espaços de URL, dois modelos de saúde, duas configurações de CORS, duas superfícies de autenticação. A razão inteira de o gateway existir era ter um lugar que soubesse do meu HTTP. Um compose paralelo teria desfeito isso.
Então eu tinha uma escolha. Ou aceito que o fastfn cuida de uma fatia do meu app e outra coisa cuida do resto. Ou estendo o fastfn para que as formas de longa duração — o Postgres, o Next.js, o Flask — vivam por trás do mesmo gateway que as funções. Mesmo fastfn.json, mesmo endpoint de saúde, mesmo CORS, mesma autenticação, uma superfície HTTP unificada.
Eu não me propus a escrever docker-compose. Este post é sobre como eu escrevi metade do docker-compose de propósito.
O commit do qual este post trata principalmente é 6a54c11 na branch firecracker-simple-images: “Add simple native image apps and services”, 31 mar 2026, 2254 inserções em 26 arquivos. É ali que a ideia de um workload ganhou um lar de primeira classe no fastfn. Tudo depois disso — a rede de pares por vsock em 5568b6c, os padrões keep-hot em 6fd5fec, a matriz de firewall + benchmark em fd8a6b5 — é uma evolução da mesma abstração.
2 Capítulo 2: A Palavra “Workload”
A palavra da era CGI para o que eu queria era “daemon.” A palavra do systemd é “unit.” As palavras do Kubernetes são “Deployment” e “StatefulSet.” A palavra do Heroku é “entrada de Procfile.” A palavra do docker-compose é, confusamente, “service.” Eu escolhi a palavra workload como guarda-chuva, e então a dividi em duas.
fastfn.json
┌──────────────────────────────────────────────┐
│ │
│ functions-dir: "functions" │
│ │
│ apps: ← public HTTP faces │
│ admin: { ... routes: ["/admin/*"] } │
│ │
│ services: ← private, injected │
│ mysql: { port: 3306, volume: "mysql-data" }│
│ │
└──────────────────────────────────────────────┘A distinção é pequena mas estruturalmente essencial. Um app é um workload que tem uma face pública: ele declara routes, e o gateway expõe essas rotas para o mundo externo. Um service é um workload que permanece privado: outros workloads e funções podem alcançá-lo, mas o mundo externo não. Um Postgres é um service. Um painel de administração Next.js é um app. No extremo, a única diferença entre eles é se o campo routes está definido e se o gateway os anuncia.
Ambos vivem no mesmo arquivo, e ambos são validados pelo mesmo caminho 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"`
}Duas listas irmãs. Não dois mundos. O mesmo normalizador percorre ambas, o mesmo escritor de estado persiste ambas, o mesmo gateway Lua lê ambas do mesmo arquivo JSON em tempo de requisição.
Isso me fez perceber algo quase trivial mas que vale dizer: uma vez que você tem um gateway que sabe rotear, dar a ele mais um tipo de alvo não é um projeto novo. É uma nova linha numa tabela.
3 Capítulo 3: A Forma de um Workload
Vamos olhar a forma de verdade. Aqui está a configuração mínima do fastfn com um app e um service, retirada do diff do README naquele 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"
}
}
}Três coisas merecem atenção. Primeiro, a origem da imagem. Segundo, a porta. Terceiro, as rotas (só em apps). Todo o resto é um ajuste opcional.
A origem da imagem é interessante porque há três formas válidas de declará-la, e o código de configuração impõe que você escolha exatamente uma delas (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",
)
}Então suas três escolhas são:
image: uma referência de registro comomysql:8.4, ou um caminho para um diretório de bundle Firecracker local no disco.image_file: um arquivo OCI ou Docker local, convertido em um bundle Firecracker em cache no primeiro uso.dockerfile: um caminho para um Dockerfile que o fastfn vai construir através da API do Docker Engine, e depois converter da mesma forma.
A regra do exatamente-um é estruturalmente essencial. Ela diz “um workload, uma fonte da verdade.” Você não pode misturar um bundle local com uma imagem de registro e ver qual ganha. Por esse caminho mora uma depuração que você não quer.
A porta é mais simples: é a porta do contêiner em que o workload escuta, com uma validação de que ela cai dentro de 1..65535. As rotas, para apps, são um array de prefixos de URL. A forma padrão de uma rota suporta tanto correspondência exata (/admin) quanto com glob no fim (/admin/*). Há um normalizador que apara barras finais e força uma barra inicial (cli/internal/workloads/config.go:941-953), de modo que as três formas em que você poderia escrever a rota se tornam todas uma única string canônica.
A AppSpec completa é maior que esse mínimo, porque uma vez que você começa a rodar apps de verdade você quer ajustes. Aqui está toda a superfície (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 é quase idêntica (config.go:90-111), menos Replicas; para services o process group faz a contagem de réplicas no lugar. Fiquei tentado, por um instante, a unificar as duas numa só struct com um IsPublic bool. Estou feliz por não ter feito. As duas formas têm validadores sutilmente diferentes e padrões sutilmente diferentes, e tentar dobrá-las num só tipo continuava produzindo ternários onde um segundo tipo produzia clareza.
A simetria esconde uma assimetria sutil que aparece em 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,
}
}Apps ganham um temporizador de ociosidade de 15 segundos que podem usar se algum dia optarem por pause. Services ganham zero — porque você não pausa um Postgres. Um Postgres pausado se chama “uma indisponibilidade.” A política padrão em todo lugar é continuar rodando, continuar quente, e pré-aquecer no boot. Dois modelos de ciclo de vida, um arquivo de configuração, zero arrependimentos.
4 Capítulo 4: Três Backends, Uma Forma de Configuração
Aqui é onde a abstração de workload paga seu aluguel. A mesma estrofe de JSON deve rodar de três formas diferentes:
fastfn.json workload
│
├────────┬────────────┬──────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
docker native process Firecracker (future
native (no container) microVM on backends)
(fallback) Linux/KVMQual delas roda é em parte uma decisão de plataforma e em parte uma decisão de branch. Nesta branch (firecracker-simple-images), o Firecracker é o alvo em hosts Linux/KVM, e docker_native.go é o gerenciador de fallback — note a build tag no topo do arquivo (cli/internal/workloads/docker_native.go:1):
//go:build !linux
package workloadsEssa única linha faz mais trabalho do que parece. Ela diz “em não-Linux, use a implementação do gerenciador de workloads apoiada em Docker.” Em Linux o gerenciador Firecracker assume (firecracker_manager_linux.go). Ambas as implementações satisfazem a mesma interface 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
}Três métodos. Start sobe todos os workloads. Stop os derruba. StatePath() diz ao resto do sistema onde vive o arquivo de estado JSON, para que o lado Lua possa encontrá-lo. É isso. O gerenciador Docker constrói imagens, cria uma rede, inicia contêineres e abre portas publicadas. O gerenciador Firecracker constrói imagens, as converte em bundles, inicializa microVMs e as anexa a uma rede de pares por vsock. Mecânicas diferentes, contrato público idêntico.
O gerenciador docker-native é, honestamente, o docker-compose que eu me recusei a escrever. Ele cria uma rede por projeto (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 = networkNameEntão, para cada service e cada app, ele anexa o contêiner com dois aliases — o nome puro e um alias <name>.internal (docker_native.go:417-423) — de modo que apps dentro da rede podem alcançar mysql.internal:3306 enquanto o host alcança 127.0.0.1:<published>. Essa separação é intencional: apps públicos alcançam o mundo externo através do gateway numa URL estável, services privados alcançam uns aos outros através do alias interno, e o host alcança portas publicadas para depuração.
Um service subindo se parece mais ou menos com isto:
// 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)Duas observações. Primeiro, BuildServiceURL inspeciona o env e descobre o esquema — se vê MYSQL_USER constrói uma URL mysql://, se vê POSTGRES_USER uma postgres://, se vê REDIS_* uma redis://, caso contrário cai para tcp:// (state.go:143-157). É inferência alegre de URL, e significa que eu não preciso digitar DATABASE_URL à mão em noventa e nove por cento dos casos. Segundo, FunctionEnv é o saco de variáveis que toda função no projeto verá em tempo de requisição. Essa é a ponte: o service é privado, mas funções recebem SERVICE_MYSQL_HOST, SERVICE_MYSQL_PORT, SERVICE_MYSQL_URL, mais um alias direto MYSQL_HOST / MYSQL_PORT / MYSQL_URL quando o nome é inequívoco (state.go:107-123, state.go:193-203).
O que é, se você apertar os olhos, exatamente o que o docker-compose faz com sua injeção de links e depends_on. Só que com um schema mais apertado e uma convenção de nomes mais afiada.
5 Capítulo 5: Como o Lua Encontra um Workload
Agora a parte divertida. O gateway ainda é OpenResty + Lua — a mesma coisa da Parte 1. Como um processo Go de longa duração do lado da CLI diz ao Lua do lado do gateway que workloads existem e estão no ar?
Através de um arquivo JSON no disco. É isso.
O gerenciador de workloads da CLI escreve num arquivo de estado num caminho conhecido. O caminho é exportado como uma variável de ambiente para o gateway (process/runner.go no diff 6a54c11):
// cli/internal/process/runner.go (from 6a54c11)
if path := strings.TrimSpace(workloadMgr.StatePath()); path != "" {
baseEnv = append(baseEnv, "FN_IMAGE_WORKLOADS_STATE_PATH="+path)
}No lado Lua, image_workloads.lua lê esse caminho em toda requisição:
-- 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
endLeitura de JSON por requisição. Em produção você faria cache disso por algum TTL pequeno; em dev é exatamente a coisa certa. O status de saúde, as rotas, os hostnames internos, o estado do ciclo de vida — tudo naquele arquivo.
O roteamento é o próximo passo. O gateway já faz uma dança de correspondência longa para funções (coberta na Parte 1). Para workloads, ele pede ao image_workloads.lua candidatos cujas rotas casam por prefixo com o caminho da requisição, e então os pontua pelo comprimento da rota (rotas mais longas vencem, porque /admin/api/v1/users é mais 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
endDois detalhes de que gosto. O loop externo itera {"apps","services"} em vez de concatená-los, o que preserva a prioridade de apps sobre services quando a mesma rota é de algum modo reivindicada duas vezes (o que não deveria, mas código defensivo é barato). E route_length segue junto com cada candidato para que quem chama possa escolher a correspondência mais longa, o que combina com a forma como todo roteador sensato resolve prefixos sobrepostos.
O gateway consome essa lista em tempo de requisição e faz proxy para o endpoint vencedor (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
endE aqui está o proxy de verdade — HTTP entra, HTTP sai, com os cabeçalhos hop-by-hop removidos na volta para que nada confunda o 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 respQuinze megabytes de corpo máximo. Timeout de trinta segundos. Cabeçalhos hop-by-hop descartados. Este é o proxy reverso HTTP mais chato que você já leu, o que é exatamente o que você quer que um gateway seja.
A parte esperta não está no proxy. A parte esperta é que a mesma fase ngx que resolve funções agora também resolve workloads, com uma prioridade conhecida, a partir do mesmo arquivo de estado JSON, usando as mesmas regras de firewall por host/CIDR. A coisa toda consegue ser uma superfície HTTP unificada.
6 Capítulo 6: Ciclo de Vida (ou, Transformando uma Tabela numa Máquina de Estados)
Uma vez que workloads existem, eles têm que passar por uma vida. Iniciando. Saudável. Insalubre. Parado. Pausado. Retomado. O ciclo de vida mantém a história operacional honesta.
┌────────────┐ start() ┌─────────────┐ healthcheck ok ┌─────────┐
│ declared │ ──────────▶ │ starting │ ─────────────────▶ │ healthy │
└────────────┘ └─────────────┘ └────┬────┘
│ │
│ healthcheck fails │ monitor tick
▼ │ reports failure
┌─────────────┐ ▼
│ unhealthy │ ◀──────────────── ┌─────────┐
└──────┬──────┘ │ flapping│
│ stop() └─────────┘
▼
┌───────────┐
│ stopped │
└───────────┘Em state.go, as peças vivem num pequeno aglomerado 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"`
}O sinal de saúde é uma struct de dois campos — up, e uma razão quando não está. Todo workload mantém uma. O gerenciador docker-native roda uma goroutine de monitoramento que acorda a cada dois segundos, inspeciona cada contêiner e atualiza o arquivo de estado quando qualquer coisa muda (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()
}
}
}
}O comportamento de escrever-na-mudança importa. O lado Lua lê aquele arquivo de estado em toda requisição. Se o lado Go spammasse o arquivo a cada dois segundos incondicionalmente, toda requisição veria um arquivo novo e cada pequena leitura de fs seria desperdiçada. Escrever só quando algo realmente se moveu mantém o arquivo estável por longos trechos, que é o tipo de coisa que você quer de um mecanismo de IPC que é, sendo generoso, um arquivo JSON.
A inicialização é serial de propósito: services sobem primeiro, depois apps (docker_native.go:100-128). Apps inicializam com o env do service já populado no seu ambiente — que é o momento em que a simetria limpa do arquivo de configuração se afirma como uma ordem de dependência. Services existem para que apps possam usá-los; portanto services inicializam primeiro.
O ponto de entrada da CLI é a mesma chata de duas linhas tanto em dev quanto em run. Para dev, do diff 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
}Aquela última ramificação é importante. Apps e services só funcionam com --native nesta branch; o modo de dev Docker clássico ainda é só funções. Essa é uma limitação conhecida, documentada explicitamente na referência de configuração (docs/en/reference/fastfn-config.md:13). Misturar os dois ciclos de vida no antigo caminho de gateway apoiado em Docker era o tipo de enrolação que eu decidi adiar. O modo nativo é o único caminho honesto adiante para esta funcionalidade.
O helper configuredImageWorkloads é a cola. Ele lê as chaves apps e services do viper e as normaliza atravé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
}Essa é toda a fiação. O viper lê fastfn.json, o pacote de workload o normaliza, e o runner nativo levanta um gerenciador cujo StatePath() é carimbado no ambiente de cada daemon de runtime e de cada worker OpenResty.
7 Capítulo 7: A Virada Firecracker
A esta altura tudo o que descrevi funciona num laptop com Docker, e nada do que descrevi precisa de Firecracker. Mas a branch inteira se chama firecracker-simple-images, então deixe-me dizer onde o Firecracker entra, e o que de fato mudou.
Num host Linux/KVM, o gerenciador não é docker_native.go. É firecracker_manager_linux.go. Mesma interface. Mesmo arquivo de estado. Mecânicas muito diferentes por baixo: a imagem OCI é convertida num bundle Firecracker (um kernel vmlinux mais um rootfs.ext4), o bundle é colocado em cache sob .fastfn/firecracker/images/, e um microVM inicializa com uma configuração de kernel mínima. O contrato público é idêntico: o workload escuta na sua porta declarada, o gateway faz proxy para ele, o arquivo de estado reporta a saúde.
O que é diferente — e é aqui que o commit 5568b6c (“Add vsock peer networking for Firecracker workloads”) importa — é como o gateway de fato alcança um convidado Firecracker. Um contêiner Docker comum publica uma porta numa bridge e você disca 127.0.0.1:<hostport>. Um microVM Firecracker não tem essa conveniência por padrão. Então o commit introduz uma rede de pares baseada em vsock: um helper do lado do convidado (cli/internal/firecrackerguest/main.go, 455 linhas naquele commit) que termina o vsock, e um private_network.go do lado do host que costura as peças. O gateway ainda disca um InternalHost:InternalPort; o encanamento sob aquele host/porta é vsock em vez de TCP numa bridge. A abstração se mantém.
O commit de acompanhamento 6fd5fec (“Keep Firecracker image workloads hot by default”) é o que tornou isto útil na prática. Sem ele, um workload Firecracker recém-inicializado se sentiria ótimo na primeira requisição e menos ótimo se algum dia fosse pausado. Com os padrões keep-hot, o workload permanece residente e é pré-aquecido na inicialização do fastfn dev / fastfn run. Os docs chamam isto de “speed-first” (docs/en/reference/fastfn-config.md:302-307): idle_action tem como padrão run, prewarm tem como padrão true, tanto para apps quanto para services.
Os números de verdade — e estes são copiados e colados da matriz de benchmark em docs/en/explanation/performance-benchmarks.md — são a única razão pela qual estou disposto a chamar qualquer coisa disto de pronto:
| 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 linhas de uma matriz de vinte casos (snapshot de 1º de abril de 2026; lista completa em docs/en/explanation/performance-benchmarks.md:46-54). A leitura que faço desta tabela é:
- Build a frio + pré-aquecimento são segundos, às vezes dezenas de segundos para um build em Rust. Isso não é de graça. Mas acontece uma vez.
- Depois do pré-aquecimento, o caminho quente é de baixos milissegundos de um dígito para apps leves e ainda de um dígito a baixos dois dígitos para os apoiados em banco de dados.
same_firecracker_pid = trueem cada linha, o que significa que o loop quente está realmente reutilizando o mesmo microVM residente. O gateway não está recriando silenciosamente o Firecracker entre requisições.- Dois services
postgres:16idênticos podem compartilhar a mesma 5432 nativa desde que seus nomes de workload sejam diferentes. A rede privada é por workload; a porta é por processo dentro do seu próprio convidado.
A ressalva honesta — que o próprio doc assume, e que estou feliz em repetir (docs/en/explanation/performance-benchmarks.md:94-139) — é que a matriz de 20 casos não é “vinte apps upstream com zero edições.” Alguns casos são upstream-como-estão. Alguns têm uma camada de benchmark por cima. Todos compartilham o mesmo caminho de runtime do FastFN, mas o harness não é um benchmark sem-toque para todos eles. Prefiro dizer isso em voz alta a envernizar.
E sim, fd8a6b5 (“Add image workload firewall and benchmark matrix”) é o commit que trouxe tanto o controle de acesso allow_hosts / allow_cidrs em portas públicas quanto a ferramentaria que produziu estes números. Vou cobrir o firewall num post posterior — é uma estética inteira em si mesma — mas a versão curta é: um app público pode ser travado a uma lista de hosts permitidos e/ou uma lista de CIDRs permitidos, ambas mostradas na função de pontuação do gateway que citei no Capítulo 5.
8 Capítulo 8: Funções Encontram Services (a História da Injeção)
De volta a um detalhe que passei por cima. Como exatamente uma função vê um service?
Através do env. Quando um service inicializa, o gerenciador chama BuildFunctionServiceEnv e guarda o resultado no estado do 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
}No lado Lua, quando uma função está prestes a ser invocada, o gateway puxa a união dos FunctionEnv de todos os services para o envelope de evento que passa ao 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
endO que significa que uma função Python pode simplesmente fazer:
# 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"]…e funciona, quer o MySQL por trás seja um contêiner Docker no macOS ou um microVM Firecracker no Linux. Mesmos nomes de variáveis, mesma inferência de esquema de URL, mesmo código. A mudança de backend é invisível.
Há um segundo caminho também. Um app precisa do env do service na inicialização do processo, não em tempo de requisição — porque um app Next.js lê process.env no next start, não por requisição. Então o gerenciador docker-native constrói um mapa appServiceEnv a partir de todos os services e o passa como o env de contêiner de todo app (docker_native.go:110-122, 282-294). O app portanto vê as mesmas variáveis SERVICE_*, mas escopadas através de BuildAppServiceEnv em vez de BuildFunctionServiceEnv — a diferença é se você usa o hostname interno (para apps, que vivem na mesma rede privada) ou o host público (para funções, que vivem no host).
service discovery fan-out
────────────────────────
┌──────────────┐ BuildFunctionServiceEnv
│ service │ ───────────────────────────▶ event.env
│ (e.g. mysql)│ │
└──────┬───────┘ ▼
│ ┌─────────────┐
│ │ function │
│ BuildAppServiceEnv └─────────────┘
└──────────────────────▶
container.Env
│
▼
┌──────────┐
│ app │
└──────────┘Dois consumidores. Uma fonte da verdade. Nenhum DATABASE_URL codificado à mão à vista.
9 Capítulo 9: As Lições Que de Fato Estou Levando
Olhando para trás, para o diff de 2254 linhas, aqui está o que eu diria ao meu eu passado antes de começar.
Unificar o gateway é o ponto inteiro. A tentação de dividir o HTTP em “o fastfn cuida das URLs de função” e “o docker-compose cuida de todo o resto” é enorme porque é o caminho de menor resistência. Mas toda divisão da superfície HTTP é um bug futuro em CORS, autenticação, observabilidade ou OpenAPI. Mantenha o gateway único. Dê a ele mais tipos de alvos, não mais amigos.
Dois modelos de ciclo de vida podem coexistir se um arquivo de configuração faz a compilação. Funções têm escopo de requisição. Workloads são de longa duração. Estes são genuinamente formas diferentes com modos de falha diferentes. Esconder ambos por trás de um fastfn.json funcionou porque a camada de configuração compila ambas as formas para a mesma representação de runtime de “coisa-para-a-qual-o-gateway-faz-proxy”. Dois modelos de ciclo de vida, um arquivo de configuração, zero arrependimentos.
Declare a origem da imagem exatamente uma vez. A regra do exatamente-um em image/image_file/dockerfile parece pedante no dia um e te salva de mensagens de erro ilegíveis no dia duzentos. Você quer que haja uma única forma de um engenheiro olhando para fastfn.json saber de onde este workload está vindo.
Um arquivo JSON é uma superfície de IPC perfeitamente boa entre sua CLI e seu gateway, se você for cuidadoso. Escrever só na mudança, ler por requisição, e exportar o caminho através de uma variável de ambiente é — contra minhas premissas — uma forma extremamente calma de mover informação entre um processo Go de longa duração e um processo OpenResty de longa duração. Existe um futuro em que isto vira um socket unix e um modelo de assinatura. Mas por ora, o arquivo é honesto e fácil de depurar com cat.
Nomeie services pelo que eles são. Eu quase não auto-gerei os aliases MYSQL_HOST / MYSQL_URL ao lado dos SERVICE_<NAME>_HOST. Então escrevi meu primeiro handler de verdade e lembrei que humanos não querem digitar SERVICE_MYSQL_HOST. Aliases diretos são uma funcionalidade de usabilidade fingindo ser uma convenção de nomes.
Benchmarks te mantêm honesto. A tabela da matriz acima é a única razão pela qual eu acredito nas palavras “residente” e “quente” nos meus próprios docs. same_firecracker_pid = true significa que ninguém está secretamente reiniciando a VM entre minha requisição e meu p95. Esse check existe porque no início, não era verdade, e o benchmark era a única coisa que me dizia. Meça a propriedade, não a intenção.
E uma palavra sobre ressalvas. Há muita coisa que deliberadamente não fiz nesta branch. Apps e services só funcionam com --native. Workloads Firecracker só funcionam em Linux/KVM. Usuários de macOS e Windows ganham o gerenciador Docker em vez de Firecracker por ora, e esse caminho não roda image workloads de jeito nenhum fora do modo nativo nesta branch. Rolling updates, blue/green, autoscaling — todos ausentes, e tudo bem estarem ausentes, porque o ponto desta fase era acertar a abstração, não as operações. Um gerenciador de workloads que faz três backends de forma limpa vale muito mais que um gerenciador de workloads que faz um backend com toda funcionalidade que o Kubernetes já lançou.
O que vem a seguir? Há duas direções que consigo ver com clareza. Uma é trazer o caminho de workload para o fastfn dev em modo Docker para que usuários de macOS ganhem a mesma ergonomia sem Firecracker. A outra é tornar o firewall mais rico — mais que allow_hosts e allow_cidrs, em algo que possa expressar “este service só é alcançável a partir destes workloads específicos nesta rede privada.” Ambas parecem capítulos genuinamente novos, não notas de rodapé.
Por ora, a manchete é pequena e chata e correta: se você precisa de um Postgres ao lado das suas funções, você edita seu fastfn.json, adiciona uma estrofe services.postgres, e suas funções ganham SERVICE_POSTGRES_URL no seu ambiente. É isso.
Funções são uma ótima forma para requisição/resposta. Workloads são a forma para tudo que não é. E agora eles vivem no mesmo arquivo de configuração, no mesmo gateway, no mesmo endpoint de saúde, na mesma matriz de benchmark, com os mesmos padrões keep-hot.
Eu não queria escrever docker-compose. Acabei escrevendo algo que rima com o décimo do docker-compose que eu de fato uso, e nada mais. O que, sendo honesto, é provavelmente a primeira vez na minha carreira em que construí menos plataforma do que pensei que construiria.
Vejo você na Parte 3, onde vou desmontar em detalhe a história do firewall, o encanamento de vsock e os padrões keep-hot. Até lá: um arquivo, uma rota, um workload, um gateway.