fastfn Parte 1: Eu Tinha um Problema (e Introduzi Lua na Minha Vida)

1 Capítulo 1: A Dor, ou “Por Que um Hello World Tem Sete Arquivos?”
A coisa toda começou com uma reclamação que é quase constrangedor dizer em voz alta em 2026: eu queria largar um arquivo Python no disco e que ele fosse um endpoint HTTP. Só isso. Sem Dockerfile. Sem requirements.txt a menos que eu quisesse um. Sem boilerplate de app = FastAPI(), sem invocação de uvicorn, sem assistente de “criar um projeto” que deixa para trás dezessete arquivos de configuração que vou passar as próximas três tardes deletando. E — esta é a parte em que fiquei mais exigente — eu queria um cold start razoável. Não Lambda-cold, em que a primeira requisição da manhã parece um 404 com passos extras. Mornozinho. Em escala humana.
Eu estava cansado da forma dos frameworks web modernos. Eles são lindos, escalam, têm ecossistemas, e também pedem que você compile mentalmente um modelo mental inteiro do mundo deles antes que sua primeira rota responda com {"hello": "world"}. Para uma ferramenta interna descartável, isso é um imposto que você paga em atenção. Eu vinha pagando esse imposto havia uma década. Eu queria parar.
A outra coisa que eu queria — e este é o recurso que silenciosamente conduz o resto da história — era poliglota por padrão. Não poliglota no sentido de “microsserviços em linguagens diferentes conversando por gRPC”. Poliglota no sentido de que a mesma árvore de URLs pode ter get.users.py, post.orders.js e get.health.go, lado a lado na mesma pasta, atrás do mesmo gateway. Roteamento baseado em arquivos do Next.js para handlers baseados em arquivos, agnóstico de runtime. Esse era o sonho.
Então o alvo estava claro: uma coisa Function-as-a-Service, mas local-first, com uma CLI como interface principal, e uma árvore de arquivos como banco de dados. Eu o chamei de fastfn. O README descreve a ambição em uma linha: “Start with one file, a friendly CLI, and a route tree that can grow into a real API or SPA without a rewrite later” (README.md:8). O resto deste post é a história de como essa única frase se transformou em um gateway em Lua, um daemon Python persistente e um protocolo de fio com um prefixo de comprimento de 4 bytes. É uma história sobre descobrir, lentamente e com algum constrangimento, que eu estava reinventando o FastCGI.
2 Capítulo 2: Uma Breve, Ligeiramente Injusta História do CGI
Antes de chegar ao que fastfn é, tenho que falar sobre com o que ele rima.
2.1 CGI: o serverless original
No início havia o CGI. O Common Gateway Interface era serverless antes de serverless ser uma marca. Você colocava um script em /cgi-bin/, o servidor web fazia fork e exec dele a cada requisição, ele lia a requisição de variáveis de ambiente e do stdin, escrevia a resposta no stdout e saía. O SO limpava tudo. Toda requisição era um processo. Todo processo era um universo que existia por 40 milissegundos e então morria.
Isso é maravilhoso. Isso também é um crime de desempenho.
CGI request model (what dies for you every time)
client web server /cgi-bin/hello.pl
| HTTP GET /hello | .
| --------------------> | fork() .
| | exec("perl hello.pl") --> [ cold Perl VM ]
| | [ parse script ]
| | [ run handler ]
| | <-- stdout -- [ die ]
| <-- HTTP 200 -- | .
| | .Toda requisição paga pela criação do processo, aquecimento do interpretador, importação de bibliotecas e desmontagem. Numa máquina de 1996 isso doía. Numa máquina de 2026 ainda dói, só que de forma diferente: o Python cache-cold gasta uma fatia não trivial de tempo só importando sua própria biblioteca padrão antes que seu handler escreva um único byte. Eu não fiz o profiling disso de ponta a ponta especificamente para o fastfn, mas a ordem de grandeza é grande o suficiente para que o pool persistente no estilo FastCGI exista precisamente para amortizá-la.
2.2 FastCGI: a correção, e a forma que acabei copiando
O FastCGI foi inventado para corrigir exatamente isso. A ideia é quase óbvia em retrospecto: não mate o handler depois de cada requisição. Mantenha um pequeno pool de processos handler vivos, deixe o servidor web conversar com eles por um socket Unix, e enquadre as requisições para poder multiplexar de forma limpa. O servidor web é o front-end; o pool de handlers é o back-end; entre eles flui um fluxo de registros prefixados por comprimento.
FastCGI request model (what doesn't die for you)
client web server unix socket handler pool
| HTTP GET | | .
| -----------> | pack record | .
| | -------- [len|payload] ------------> [ already warm ]
| | | [ run handler ]
| | <------- [len|payload] ------------ [ sleep ]
| <--- HTTP -- | | .Os handlers são de vida longa. O interpretador está quente. O estado global do seu handler sobrevive entre requisições (para o bem e para o mal). O transporte é um socket entediante com registros enquadrados. Você troca o isolamento processo-por-requisição por algo mais próximo de uma chamada-pela-rede. É uma troca muito boa e é essencialmente o que os servidores WSGI modernos do Python, o PHP-FPM e — sejamos honestos — o pool de warm-start do AWS Lambda estão todos fazendo internamente.
Eu não me propus a construir um FastCGI. Me propus a construir uma coisa serverless em que você larga um arquivo e ele vira uma rota. Acontece que, uma vez que você quer warm starts e handlers poliglotas e uma árvore de rotas endereçável por sistema de arquivos, o espaço de design o afunila em direção a algo que se parece estranhamente com o FastCGI vestindo um sobretudo de JSON. Mais sobre o sobretudo depois.
3 Capítulo 3: Eu Tinha um Problema… e Introduzi Lua na Minha Vida
Aqui está a parte em que o título faz sentido.
O gateway — a peça que termina o HTTP, lê a requisição, descobre qual arquivo no disco deve tratá-la, e encaminha a chamada — é a peça mais importante e mais irritante de qualquer FaaS. Ele tem que ser rápido. Tem que fazer hot-reload quando você salva um arquivo. Tem que fazer roteamento, autenticação, cookies, CORS, OpenAPI, e tem que fazer tudo isso sem se tornar um processo Node de 30 MB com um cold start de 400 ms.
Tentei escrevê-lo em Go. Estava ok. Também era muita código para o que é essencialmente “pegue uma requisição, procure um arquivo, abra um socket, escreva, leia, escreva a resposta”. Então, uma noite, lembrei que o OpenResty — nginx com Lua embutido — já faz as partes difíceis (parsing de HTTP, TLS, epoll, memória compartilhada) e simplesmente me deixa programar a camada de política em uma linguagem de script com startup sub-milissegundo. Você não inicializa o OpenResty por requisição. O OpenResty inicializa uma vez, no início do processo, e então seu Lua roda dentro dos hooks de fase da requisição. Pense nisso como o servidor web convidando seu código para viver dentro do seu event loop como hóspede.
Então introduzi Lua na minha vida. Não foi tanto uma decisão quanto uma árvore interna de opções que continuava se ramificando: a cada terceira coisa que eu precisava, acabava sendo “ah, posso simplesmente fazer isso em Lua e funciona”. Descoberta de rotas? Lua percorrendo um diretório. Tabela de roteamento em memória compartilhada? ngx.shared.DICT. Hot reload ao salvar arquivo? Um pequeno timer em Lua que faz stat no diretório de funções e reconstrói uma tabela de rotas em um shared dict. Parsing de cookie de sessão? Doze linhas:
local function parse_cookies(cookie_header)
local cookies = {}
if type(cookie_header) ~= "string" or cookie_header == "" then
return cookies
end
for pair in cookie_header:gmatch("[^;]+") do
local k, v = pair:match("^%s*([^=]+)%s*=%s*(.-)%s*$")
if k and k ~= "" then
cookies[k] = v or ""
end
end
return cookies
endDoze linhas. Sem npm install cookie-parser. Sem go get github.com/gorilla/sessions. Sem dependência transitiva que foi abandonada em 2019 e agora é mantida por um bot. Doze linhas de Lua que rodam dentro da fase de requisição do nginx, e o gateway agora entende cookies de sessão.
E é perfeita para este trabalho. O runtime é pequeno, o código é pequeno, a latência é pequena, e o gateway inteiro — roteamento, parsing de sessão, marshalling da requisição, un-marshalling da resposta, endpoint OpenAPI, servir o Swagger UI — vive em um punhado de arquivos Lua sob openresty/lua/fastfn/. A subárvore http/ tem exatamente os módulos que você esperaria de um gateway de verdade: gateway.lua, assets.lua, catalog.lua, openapi_endpoint.lua, swagger_ui.lua, reload.lua. O gateway.lua principal tem 1341 linhas; o client.lua inteiro que implementa o protocolo de fio tem 110 linhas. Vou citar a maior parte dele em um minuto.
3.1 A tabela de roteamento é um shared dict
O truque que torna o gateway em Lua rápido não é esperto. É que a tabela de rotas não vive em um banco de dados, não vive no Redis, não vive nem mesmo na memória por worker. Ela vive em ngx.shared.fn_cache, uma zona de memória compartilhada do nginx legível por todo processo worker. Quando fastfn dev inicia, Lua percorre o diretório de funções, constrói um índice — “GET /hello → /functions/get.hello.py → runtime python” — e o enfia no shared dict. Em mudança de arquivo, um chunk de reload em Lua o reconstrói. As requisições fazem uma única consulta no shared dict na fase access_by_lua e então despacham. Não há “framework”. Há uma hash table e uma convenção.
4 Capítulo 4: FastCGI Vestindo um Sobretudo de JSON
Agora o protocolo. Esta é a parte em que reconstruí o FastCGI por acidente.
Quando o gateway em Lua decidiu que GET /hello deve ser servido pelo runtime Python, ele precisa levar a requisição até o daemon Python. O daemon Python é um processo de vida longa escutando em um socket Unix (padrão /tmp/fastfn/fn-python.sock, configurável via FN_PY_SOCKET, que você pode ver em srv/fn/runtimes/python-daemon.py:25). O gateway abre o socket, escreve a requisição e lê a resposta.
O formato do frame é deliberadamente entediante:
4 bytes N bytes
--------------- ----------------------------------------
| big-endian | | |
| uint32 len | | JSON payload (request or response) |
--------------- ----------------------------------------Quatro bytes de comprimento big-endian. Então essa quantidade de bytes de JSON. Esse é o protocolo inteiro. É o enquadramento de registros do FastCGI, simplificado para um único tipo de registro, com o corpo do registro sendo JSON em vez da codificação binária chave-valor do FastCGI. Se o FastCGI vestisse um sobretudo e tentasse se passar por uma API REST moderna, é assim que ele se vestiria.
O lado Lua do fio é pequeno o suficiente para ser citado por completo. Aqui está o cliente que envia uma requisição e faz o parse de uma resposta:
function M.call_unix(socket_uri, req_obj, timeout_ms)
local payload = cjson.encode(req_obj)
if not payload then
return nil, "invalid_request", "failed to encode request"
end
local connect_timeout = math.max(50, math.floor(timeout_ms * 0.2))
local io_timeout = math.max(50, timeout_ms)
local sock = ngx.socket.tcp()
sock:settimeouts(connect_timeout, io_timeout, io_timeout)
local ok, err = sock:connect(socket_uri)
if not ok then
if is_timeout(err) then
return nil, "timeout", "connect timeout"
end
return nil, "connect_error", tostring(err)
end
local sent, send_err = sock:send(pack_u32(#payload) .. payload)
if not sent then
sock:close()
if is_timeout(send_err) then
return nil, "timeout", "send timeout"
end
return nil, "send_error", tostring(send_err)
end
local header, header_err = sock:receive(4)
if not header then
sock:close()
return nil, "receive_error", tostring(header_err)
end
local body_len = unpack_u32(header)
if body_len <= 0 or body_len > 10 * 1024 * 1024 then
sock:close()
return nil, "invalid_response", "invalid frame length"
end
local body, body_err = sock:receive(body_len)
sock:close()
-- decode, validate, return
endA função manual pack_u32 é um bom lembrete de que o Lua 5.1 (que o OpenResty usa) não vem com string.pack, então eu a implemento eu mesmo:
local function pack_u32(n)
local b1 = math.floor(n / 16777216) % 256
local b2 = math.floor(n / 65536) % 256
local b3 = math.floor(n / 256) % 256
local b4 = n % 256
return string.char(b1, b2, b3, b4)
endO limite do frame é 10 MB (body_len > 10 * 1024 * 1024). O lado Python impõe um limite simétrico via FN_MAX_FRAME_BYTES, que tem padrão de 2 MB (srv/fn/runtimes/python-daemon.py:26). Sim, os dois padrões não batem. Isso é de propósito: o gateway é um pouco mais permissivo que o daemon para que um daemon mal configurado falhe no daemon, e não em uma confusa leitura pela metade no gateway. É o tipo de assimetria que parece errada em um code review e parece certa quando você está depurando um incidente em produção às 2 da manhã.
4.1 O ciclo de vida da requisição, com milissegundos aproximados
Uma vez que você tem o protocolo de fio, o ciclo de vida inteiro da requisição é fácil de desenhar. Aqui está uma requisição quente, com tempos aproximados que medi em um laptop de meados de 2024 rodando Linux:
t=0.00 ms client sends HTTP GET /hello
t=0.10 ms nginx accept, parse HTTP headers
t=0.20 ms Lua access_by_lua: route lookup in ngx.shared.fn_cache
t=0.30 ms Lua: cjson.encode(req_obj) -> JSON payload
t=0.40 ms Lua: ngx.socket.tcp():connect("/tmp/fastfn/fn-python.sock")
t=0.50 ms Lua: send [4B len][JSON]
t=0.60 ms Python daemon: recv 4 bytes, decode length
t=0.70 ms Python daemon: recv N bytes, json.loads
t=0.80 ms Python daemon: dispatch to handler from _HANDLER_CACHE
t=1.10 ms handler(event) returns {"status": 200, "body": "..."}
t=1.20 ms Python daemon: json.dumps, send [4B len][JSON]
t=1.30 ms Lua: recv header, recv body, cjson.decode
t=1.40 ms Lua content_by_lua: ngx.say(resp.body)
t=1.50 ms nginx flushes HTTP response to clientEsses números são aproximados — eu não publiquei um benchmark de ponta a ponta para o caminho puro de função por si só, então pegue o detalhamento de milissegundos por linha como estimativa, não como evangelho. A forma está certa mesmo que os números exatos variem. Cold starts são uma conversa muito diferente e vou chegar lá.
5 Capítulo 5: Runtimes Poliglotas, ou “Lambda Sem a Amazon”
O protocolo de fio é agnóstico de linguagem de propósito. O daemon Python é uma implementação. Há um daemon Node. Há um caminho para handlers em Rust, Go, PHP e Lua também. Todo daemon de runtime faz a mesma promessa: abrir um socket Unix, falar o protocolo de 4-bytes-de-comprimento + JSON, e quando uma requisição chega, despachar para uma função com a assinatura que o AWS Lambda tornou famosa.
Aqui está um handler de verdade, dos exemplos do tutorial poliglota:
import json
def handler(event):
query = event.get("query") or {}
name = query.get("name") or "friend"
return {
"status": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(
{
"step": 2,
"message": f"Hello {name} from Python.",
"runtime": "python",
"name": name,
}
),
}Esse é o contrato inteiro. def handler(event): return {...}. Forma-Lambda sem a Amazon. Forma-Cloudflare-Workers sem a Cloudflare. É também, se você apertar os olhos, apenas CGI com as variáveis-de-ambiente-e-stdin substituídas por um envelope JSON e um socket quente. A forma tem sido estável desde os anos 1990 porque é a forma certa: uma requisição é um dict, uma resposta é um dict, e o handler é uma função pura de um para o outro.
O daemon suporta três adaptadores de invocação explicitamente (srv/fn/runtimes/python-daemon.py:51-53):
_INVOKE_ADAPTER_NATIVE = "native"
_INVOKE_ADAPTER_AWS_LAMBDA = "aws-lambda"
_INVOKE_ADAPTER_CLOUDFLARE_WORKER = "cloudflare-worker"“native” é minha forma padrão. As outras duas existem para que você possa levantar um Lambda ou Cloudflare Worker existente para dentro do fastfn praticamente sem mudanças. Essa interoperabilidade não foi de graça; me custou um cuidadoso tradutor de formato de evento. Mas significa que você pode pegar código que você já tem rodando no serverless de outra pessoa e rodá-lo localmente com fastfn dev, o que é genuinamente útil quando o console AWS do seu empregador está passando por uma de suas semanas.
5.1 Um pool de workers dentro do daemon
O daemon Python não apenas despacha na thread principal. Ele tem um ThreadPoolExecutor na frente de um pool de workers handler, controlado por um orçamento de slots. Os botões são todos variáveis de ambiente:
RUNTIME_POOL_ACQUIRE_TIMEOUT_MS = int(os.environ.get("FN_PY_POOL_ACQUIRE_TIMEOUT_MS", "5000"))
RUNTIME_POOL_IDLE_TTL_MS = int(os.environ.get("FN_PY_POOL_IDLE_TTL_MS", "300000"))
RUNTIME_POOL_REAPER_INTERVAL_MS = int(os.environ.get("FN_PY_POOL_REAPER_INTERVAL_MS", "2000"))Então: timeout de aquisição de 5 segundos, TTL ocioso de 5 minutos, o reaper roda a cada 2 segundos. Esses são números de pool FastCGI vestidos com roupas Python. A thread reaper percorre o pool, mata workers ociosos além de seu TTL, e enxuga o footprint. O timeout de aquisição é o sinal de “estou na capacidade máxima, recue”.
6 Capítulo 6: Lua Até o Fundo
O Capítulo 3 foi o momento em que introduzi Lua na minha vida. O que eu não apreciei então é quão rapidamente ela colonizaria todo canto do plano de controle. Eu me propunha a resolver um problema em Lua — “deixe-me fazer o parse de um cookie” — e ao fim da tarde a tabela de roteamento, o gerador de OpenAPI, o limitador de taxa e o dashboard de admin eram todos Lua também, rodando no mesmo worker do nginx, compartilhando as mesmas zonas ngx.shared.DICT, sem nenhum salto interprocesso entre eles.
Este é o capítulo em que listo os lugares onde Lua roda. Lua não é uma arma secreta. É uma linguagem pequena com um JIT rápido, embutida em um servidor web que já faz as partes difíceis, e por acaso se encaixa neste problema como chave numa fechadura.
6.1 Roteamento e descoberta: a árvore de arquivos é o banco de dados
O primeiro lugar onde Lua venceu foi o roteamento. As rotas não vivem em um arquivo YAML ou um objeto de configuração; elas vivem no sistema de arquivos. core/routes.lua percorre o diretório de funções no boot, lê metadados de cada arquivo de handler, e constrói um catálogo de “GET /hello → Python → get.hello.py”. Ele então cacheia o catálogo em ngx.shared.fn_cache para que todo worker tenha a mesma visão de graça. O ponto de entrada da descoberta é só isto:
function M.discover_functions(force)
if not force then
local raw = CACHE:get("catalog:raw")
if raw then
local parsed = cjson.decode(raw)
if parsed then
return parsed
end
end
end
-- ... walk functions_root, populate catalog.runtimes / mapped_routes ...
endcore/routes.lua é o grandão — cerca de 3.245 linhas — porque o roteamento é onde toda preocupação eventualmente se encontra: métodos, prefixos reservados, conflitos, ranks de fonte. Ao lado dele fica core/fs.lua (cerca de 392 linhas), um pequeno wrapper FFI em torno de stat, opendir, readdir e amigos. Ele existe porque o FFI do LuaJIT me permite pular o módulo C LuaFileSystem e chamar a libc diretamente, mantendo a imagem do OpenResty enxuta:
function M.list_dirs_recursive(path, skip_fn)
local out = {}
if not M.is_dir(path) then
return out
end
local function walk(dir)
if type(skip_fn) == "function" and skip_fn(dir) then
return
end
out[#out + 1] = dir
for _, child in ipairs(M.list_dirs(dir)) do
walk(child)
end
end
walk(path)
table.sort(out)
return out
endEsse é o walk do sistema de arquivos. readdir no mundo Lua, com um skip_fn plugável para podar .git, node_modules, __pycache__ e .fastfn. Ele retorna uma lista ordenada de diretórios na qual tanto routes.lua quanto watchdog.lua se apoiam. O que me fez pensar: se as rotas são uma árvore no disco, então a spec OpenAPI é uma projeção dessa mesma árvore, e o dashboard é apenas uma UI sobre ela.
6.2 OpenAPI, de graça
Aquela ideia de “projeção da mesma árvore” se transformou em core/openapi.lua, um módulo de 1.309 linhas que pega o catálogo produzido por routes.lua e emite um documento OpenAPI 3. Não há decorators nas funções de handler. Não há @app.route("/hello"). O handler é apenas def handler(event): return {...}, e a spec é gerada a partir da árvore de rotas mais uma fina camada de metadados por função (fn.config.json, se o handler se deu ao trabalho de escrever um).
O lado HTTP disso é um arquivo Lua de 101 linhas que liga tudo:
local spec = openapi.build(catalog, {
server_url = server_url,
runtime_order = routes_mod.get_runtime_order(),
include_internal = env_bool("FN_OPENAPI_INCLUDE_INTERNAL", false),
invoke_meta_lookup = invoke_meta_lookup,
})
ngx.status = 200
ngx.header["Content-Type"] = "application/json"
ngx.say(cjson.encode(spec))Esse é o endpoint /openapi.json inteiro. Recarregue seu handler, acesse a URL, a spec o reflete. O Swagger UI (http/swagger_ui.lua, 119 linhas) serve uma página estática que aponta para aquele JSON e você tem docs interativas sem rodar um servidor de docs separado. O gerador de OpenAPI também se apoia em core/invoke_rules.lua para normalizar a política de invocação por função — quais métodos são permitidos, quais rotas são reservadas:
M.ALLOWED_METHODS = {
GET = true,
POST = true,
PUT = true,
PATCH = true,
DELETE = true,
}
M.RESERVED_ROUTE_PREFIXES = {
"/_fn",
"/console",
}O que me fez perceber quanto das ferramentas de API modernas é casamento de strings e allowlists. A “spec” não é uma spec, é uma visão.
6.3 Observabilidade e limites: taxa, concorrência, saúde
O truque de memória compartilhada que torna o roteamento barato é o mesmo truque que torna os limites de concorrência por função baratos. core/limits.lua tem 133 linhas, a maioria delas boilerplate, e o mecanismo inteiro é uma dança de incr/decr em uma zona ngx.shared.fn_conc:
function M.try_acquire(dict, fn_key, limit)
if not limit or limit <= 0 then
return true
end
local key = key_for(fn_key)
local current, err = dict:incr(key, 1, 0)
if not current then
return false, "counter_error:" .. tostring(err)
end
if current > limit then
dict:incr(key, -1, 0)
return false, "busy"
end
return true
endEssa função é a primitiva inteira de “não mais que N invocações em andamento da função X em todo o gateway”. dict:incr é atômico entre workers porque ngx.shared.DICT é memória compartilhada com um lock por baixo. Não há round trip ao Redis. Não há pod de admission-controller. É um contador na RAM, e ele está correto porque o nginx me dá ele correto.
Sobre os limites fica core/watchdog.lua (299 linhas), um watcher inotify do Linux escrito em LuaJIT FFI. Ele abre inotify_init1(IN_NONBLOCK | IN_CLOEXEC), percorre a árvore de funções, adiciona um watch em todo subdiretório, e agenda um reload com debounce sempre que um arquivo muda:
local ok_poll, poll_err = ngx.timer.every(poll_interval, function(premature)
if premature then
return
end
local changed, read_err = drain_events()
-- ...
if pending_since and (ngx.now() - pending_since) >= debounce_s then
pending_since = nil
schedule_reload()
end
end)Um debounce de 150 ms, um callback de reload compartilhado, e a tabela de roteamento se reconstrói no lugar. Salve um arquivo, e a rota está no ar antes de eu voltar para o navegador.
6.4 Agendamento in-process: ngx.timer.at como cron
Se eu posso agendar um reload com ngx.timer, posso agendar qualquer coisa. core/scheduler.lua (1.712 linhas) e core/jobs.lua (993 linhas) juntos implementam um executor de jobs ao estilo cron, ciente de retentativas, opcionalmente persistido, que vive dentro do worker do nginx. Sem daemon cron separado, sem Celery, sem RabbitMQ:
local ok, err = ngx.timer.every(interval, function(premature)
if premature then
return
end
local ok2, err2 = pcall(tick_once)
if not ok2 then
ngx.log(ngx.ERR, "scheduler tick failed: ", tostring(err2))
end
end)Esse único ngx.timer.every é o loop de tick. A cada tick ele percorre os agendamentos ativos, verifica as expressões cron contra o relógio de parede, e enfileira execuções via ngx.timer.at(0, ...). Retentativas com backoff exponencial saem da mesma primitiva. A persistência é um blob JSON escrito no disco a cada 15 segundos. É bastante código para uma ideia pequena: o event loop que eu já tenho é agendador suficiente para um FaaS de nó único.
6.4.1 Um exemplo concreto: um digest de IA do Telegram que roda a cada hora
O scheduler é abstrato até você ver uma função que o usa. O mais limpo no repositório é examples/functions/node/telegram-ai-digest — uma função Node que puxa mensagens de um grupo do Telegram, as resume com a OpenAI, e envia o digest de volta a um chat. O que a torna interessante para esta seção é o bloco schedule em sua config:
{
"timeout_ms": 30000,
"invoke": {
"summary": "Telegram AI digest: fetches group messages, summarizes with OpenAI, sends digest",
"methods": ["GET"],
"content_type": "application/json"
},
"schedule": {
"enabled": true,
"every_seconds": 3600,
"method": "GET"
}
}Essa é a interface inteira de “transforme isto em um cron job”: uma estrofe schedule de três linhas na própria config da função, ao lado do handler. Sem crontab externo, sem passo de registro separado, sem pipeline YAML. Quando o fastfn inicializa, o scheduler lê o fn.config.json de cada função, vê que esta tem um schedule.enabled = true, e a adiciona ao loop de tick com every_seconds = 3600. A cada hora, o scheduler emite uma requisição GET interna para a função como se um cliente a tivesse chamado — mesmo roteamento, mesmo handler, mesmo logging — e a função faz seu trabalho.
O mesmo padrão aparece nos exemplos irmãos: telegram-ai-reply é um webhook (handler POST, sem schedule — o Telegram o chama quando uma mensagem chega), telegram-send é uma função no estilo biblioteca que você pode invocar de outros handlers para enviar uma mensagem (dry_run por padrão, que é o tipo de detalhe de segurança que aprendi a adicionar depois do segundo bot que deveria ser silencioso). Três funções, três ciclos de vida — webhook, chamada de biblioteca, cron — e todos eles são apenas arquivos em functions/. A única coisa que torna o digest “um cron” são aquelas três linhas de config.
Gosto desta forma porque o “modelo de deploy” da função e seu “modelo de invocação” estão no mesmo lugar. Se um colega de equipe se pergunta “como isto roda?”, ele abre fn.config.json, vê schedule.every_seconds = 3600, e sabe. Sem caçar por um arquivo cron em um servidor ao qual ele não tem acesso ssh.
6.4.2 Config e tokens: fn.env.json e a flag is_secret
A função de digest agendada é inútil sem dois segredos e um identificador: um token de bot do Telegram, uma chave de API da OpenAI, e o ID do chat para o qual postar. Este é o ponto em todo tutorial de FaaS em que você ou enrola sobre variáveis de ambiente ou cola um parágrafo sobre o Vault. O fastfn não faz nenhum dos dois. Ele coloca a config ao lado da função, em um arquivo irmão chamado fn.env.json, com uma pequena quantidade de estrutura que torna o significado de cada valor explícito:
{
"TELEGRAM_BOT_TOKEN": {
"value": "<set-me>",
"is_secret": true
},
"TELEGRAM_CHAT_ID": {
"value": "<set-me>",
"is_secret": false
},
"OPENAI_API_KEY": {
"value": "<set-me>",
"is_secret": true
}
}Duas coisas a notar. Primeiro, toda chave é um objeto, não uma string nua — o valor vive em .value, e os metadados vivem ao lado dele. Segundo, aquele booleano is_secret é carga estrutural. O runtime o usa para decidir se um valor é mascarado nos logs e no dashboard de admin, se ele pode ser ecoado de volta por um endpoint _fn/ui_state, e se o botão “view” do dashboard tem permissão para revelá-lo em texto claro. TELEGRAM_CHAT_ID não é um segredo — é apenas um número, e você vai querer vê-lo na UI enquanto está depurando “por que meu digest não apareceu”. TELEGRAM_BOT_TOKEN é — e se você acidentalmente o vazar para os logs, a reação do Telegram é invalidar o token, então o scheduler pararia de funcionar silenciosamente até você perceber. A flag is_secret é a diferença inteira entre esses dois resultados.
O valor "<set-me>" também não é um bug — é um padrão que o repositório usa deliberadamente. O handler em core.js trata <set-me>, set-me, changeme, <changeme> e replace-me como sentinelas “não definidos”:
function isUnsetConfigValue(value) {
if (value === undefined || value === null) return true;
const s = String(value).trim();
if (!s) return true;
const l = s.toLowerCase();
return l === "<set-me>" || l === "set-me" || l === "changeme"
|| l === "<changeme>" || l === "replace-me";
}Isso significa que você pode commitar fn.env.json no git com placeholders "<set-me>", e a função falha de forma limpa em vez de fingir rodar com uma string vazia. Deploys reais substituem esses placeholders (no dashboard, através da API, ou editando o arquivo em um host controlado) por valores reais; entradas is_secret: true são mascaradas imediatamente ao salvar.
Dentro do handler, o runtime entrega esses valores em event.env, ao lado do event.method, event.query, etc. com escopo de requisição. O core.js do digest os lê com um pequeno fallback que prefere o env local da função, recorrendo ao process.env ambiente que o daemon colocou na allowlist no startup:
const botToken = chooseConfigValue(env.TELEGRAM_BOT_TOKEN, process.env.TELEGRAM_BOT_TOKEN);
const chatId = chooseConfigValue(env.TELEGRAM_CHAT_ID, process.env.TELEGRAM_CHAT_ID);
const apiKey = chooseConfigValue(env.OPENAI_API_KEY, process.env.OPENAI_API_KEY);Esta é a forma de config à qual eu sempre volto: um arquivo JSON ao lado do handler, um valor por segredo, uma flag is_secret explícita que dirige o mascaramento em toda superfície downstream, e um event.env ao nível da linguagem que torna a dependência do handler nesses valores trivialmente grepável. É, novamente, não uma ideia nova — é basicamente a mesma forma das config vars do Heroku ou de um par Secret/ConfigMap do Kubernetes. Mas vive na mesma pasta que o código que o usa, é versionável com placeholders seguros, e não requer um gerenciador de segredos externo para começar.
Coloque o scheduler, a config e o handler em um diretório, e o digest do Telegram passa de “um vago cron job em algum lugar” para “quatro arquivos que posso ler em um minuto e entregar a um colega de equipe”.
6.5 O dashboard é Lua
O lugar onde Lua mais me surpreendeu foi o console de admin. Eu não me propus a escrever uma aplicação web em Lua. Eu assumia que eventualmente conectaria um pequeno SPA, provavelmente Vue ou Svelte, porque “todo mundo faz isso”. Então escrevi console/login_endpoint.lua em uma tarde e percebi que não precisava do SPA.
O endpoint de login tem 95 linhas e faz tudo que um endpoint de login deve fazer: verificação de método, rate limit em ngx.shared.fn_cache, comparação em tempo constante, verificação de senha PBKDF2, cookie de sessão. Aqui está a fatia do rate-limit:
local client_ip = ngx.var.remote_addr or "unknown"
local rate_store = ngx.shared.fn_cache
if rate_store then
local fail_count = rate_store:get(login_rate_key(client_ip))
if LOGIN_MAX_ATTEMPTS > 0
and type(fail_count) == "number"
and fail_count >= LOGIN_MAX_ATTEMPTS then
guard.write_json(429, { error = "too many login attempts, try again later" })
return
end
endCinco segundos de matemática de shared-dict e eu tenho um lockout funcional. console/auth.lua (484 linhas) lida com o cookie de sessão, PBKDF2 com um mínimo de 100.000 iterações, segredos-de-arquivo opcionais para a senha do admin, e um TTL de 12 horas. console/guard.lua (387 linhas) é o middleware que todo endpoint do dashboard chama primeiro para impor autenticação, limites de corpo, CSRF e write-gates. console/data.lua é o grandão — cerca de 2.623 linhas — e é a “camada de serviço” de respaldo na qual os endpoints do dashboard delegam: listar funções, ler o código de uma função, definir código, listar versões, ler logs, ler agendamentos, agregar métricas do dashboard.
Os endpoints em si são minúsculos. O endpoint de métricas do dashboard tem 21 linhas:
local method = ngx.req.get_method()
if method ~= "GET" then
guard.write_json(405, { error = "method not allowed" })
return
end
-- Aggregate metrics from shared dicts or external systems
local metrics = console.get_dashboard_metrics()
guard.write_json(200, metrics or {
invocations = {},
errors = {},
latency = {}
})Ao redor dele vivem functions_endpoint.lua (34 linhas), logout_endpoint.lua (15 linhas), ui_state_endpoint.lua (50 linhas), secrets_endpoint.lua (70 linhas), packs.lua (77 linhas), ui.lua (66 linhas), e o mais gordinho invoke_endpoint.lua (526 linhas) que alimenta o console “invoque esta função a partir do navegador”. Não há aplicação web de admin separada. Não há npm run dev. Não há segundo sistema de build. Há Lua, renderizando HTML e JSON a partir do mesmo worker do nginx que serve o tráfego do gateway.
6.6 Lua como runtime, e um cliente HTTP muito minúsculo
Também adicionei core/lua_runtime.lua (399 linhas), que permite que arquivos Lua no diretório de funções sejam handlers — mesmo contrato handler(event) -> response que Python e Node, exceto que não há salto entre processos porque o handler roda no mesmo worker. Eu não publiquei um benchmark para este caminho, então não vou colocar um número de milissegundos nele. Para handlers que precisam alcançar para fora, core/http_client.lua (356 linhas) envolve ngx.socket.tcp com parsing de URL, keepalive e timeouts, para que os handlers chamem APIs upstream sem puxar luasocket para dentro da imagem. core/home.lua (234 linhas) serve a página de aterrissagem padrão em /, e http/function_code.lua / http/function_file_content.lua (50 e 76 linhas) fazem o ida-e-volta do código-fonte da função entre o editor do dashboard e o disco.
6.7 Os tradeoffs honestos
Então — Lua é magnífica aqui? Sim, com comprovantes. Os comprovantes são:
- O cold start é essencialmente de graça. O OpenResty inicializa uma vez. Meu código Lua é um conjunto de módulos carregados no init do worker. O arquivo
gateway.luatem 1.341 linhas; carregá-lo adiciona praticamente nada à latência da requisição porque os hooks de fase da requisição já estão JIT-compilados quando a primeira requisição chega. Não tenho um número limpo de “tempo de carregamento do módulo Lua” para citar aqui, então não vou colocar uma cifra em milissegundos — mas todop50de hot-path publicado no repositório, em todas as cargas, está nos milissegundos de um único dígito de ponta a ponta através deste gateway em Lua, e esse é o único número que importa para um usuário. - A memória compartilhada é quase de graça.
ngx.shared.DICTé uma hash table no nginx, protegida por um spinlock. Eu não pago por um container Redis para guardar contadores. - O módulo por preocupação é pequeno. Tirando o core de descoberta de roteamento, a maioria dos módulos do plano de controle está na faixa de 100–500 linhas.
limits.luatem 133 linhas.watchdog.luatem 299.login_endpoint.luatem 95. Esses são números à distância de leitura. Posso segurar qualquer um deles na cabeça.
E os custos, porque há custos:
- Esquisitices numéricas do Lua 5.1. O LuaJIT do OpenResty é Lua-5.1-ish com algumas extensões 5.2/5.3. Não há
string.packnativo, que é por que opack_u32no Capítulo 4 existe. A confusão entre inteiro-versus-double me mordeu duas vezes enquanto escrevia o limitador de taxa;ngx.shared.DICT:incrretorna números que são doubles, e tive que ter cuidado commath.floorem contadores que eu planejava comparar contra valorestonumber(env). - Disciplina de
pcallem todo lugar. Um erro não capturado em um callback dengx.timermata o timer silenciosamente e loga noerror.log. Todo timer que escrevo está embrulhado empcall, e o tick do scheduler, o callback do watchdog e o escritor de persistência seguem todos o mesmo padrão. Pule essa disciplina e você ganha um scheduler que misteriosamente para de fazer tick às 3 da manhã. - Tudo é uma string, até não ser. Shared dicts guardam strings e números, não tabelas, o que significa encode/decode de JSON na fronteira. Eu me apoio em
cjson.safepara que entrada ruim retornenil, errem vez de lançar, e ainda recebo o ocasionalcannot encode sparse arrayquando uma tabela tem buracos numéricos.
Nenhum desses é um impeditivo. Eles são o preço de escolher uma linguagem pequena com um JIT rápido embutido em um servidor web, e essa troca continua vencendo. O plano de controle inteiro — roteamento, OpenAPI, limites, watchdog, scheduler, console, auth — é um punhado de arquivos que posso ler em uma tarde. A maioria das preocupações é de 20 a 40 KB de Lua legível cada. O diretório core/ inteiro tem cerca de 304 KB no disco; o diretório console/ inteiro tem cerca de 156 KB. Isso é menos Lua, por uma margem ampla, do que a quantidade de JavaScript que um único SPA moderno entrega antes de chegar à primeira rota. E a Lua é o produto, não o andaime.
O que me traz à parte em que tento puxar as lições deste monte.
7 Capítulo 7: Lições que Só Esperava pela Metade
7.1 Sempre coloque o env do host em allowlist
Todo FaaS tem a mesma tentação: passar as variáveis de ambiente do host para dentro do handler. É conveniente. É também como segredos vazam. Se seu pipeline de CI/CD exporta AWS_SECRET_ACCESS_KEY para scripts de deploy, e seu daemon Python herda esse env para dentro de todo handler, parabéns, você acabou de entregar a todo handler na máquina uma credencial root da nuvem.
A correção é uma allowlist, não uma blocklist. Descobri isso do jeito chato: lendo relatórios de incidente de outras pessoas e decidindo que eu não queria escrever o meu próprio. O daemon vem com uma lista conservadora de chaves de env ambiente que os handlers podem ver (srv/fn/runtimes/python-daemon.py:59-103):
_ALLOWED_WORKER_ENV_KEYS = {
"PATH",
"HOME",
"USER",
"LOGNAME",
# ... language runtimes
"PYTHONHOME",
"PYTHONPATH",
"GOPATH",
"GOCACHE",
"CARGO_HOME",
"RUSTUP_HOME",
# ... CA bundles
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
# ... locale
"LANG",
"TZ",
}
_ALLOWED_WORKER_ENV_PREFIXES = ("LC_",)O comentário acima dela diz a parte silenciosa em voz alta: “User-defined secrets should come from fn.env.json or request-scoped event.env, not ambient host env.” A allowlist é a aplicação. Tudo que não está nesse conjunto, ou que começa com LC_, é limpo antes que o handler rode. É higiene de segurança, não teatro de segurança.
7.2 Precedência de config: flag > env > config > padrão
Este é o tipo de regra que soa óbvia e mesmo assim está errada em metade das CLIs que já usei. Minha própria CLI acerta porque errei na primeira vez. De cli/cmd/run.go:64-73:
// Resolve hot-reload: flag > env > config > default (true)
hotReload := true
if runHotReload {
// Explicit --hot-reload flag always wins
hotReload = true
} else if envVal := os.Getenv("FN_HOT_RELOAD"); envVal != "" {
hotReload = envVal != "0" && envVal != "false" && envVal != "off" && envVal != "no"
} else if viper.IsSet("hot-reload") {
hotReload = viper.GetBool("hot-reload")
}Note o comentário na linha 64. Ele não está lá para mim — está lá para a próxima pessoa que tentar “consertar” isto fazendo o arquivo de config ganhar do env. Todo sistema de config que já inverteu essa ordem causou um incidente em produção no qual alguém definiu uma variável de ambiente no CI, e então se perguntou por que o container continuava lendo um valor obsoleto de um arquivo commitado.
7.3 Tudo vive no sistema de arquivos
Sem banco de dados. Sem registry. Sem etcd. As rotas são arquivos. A config é fastfn.json. O env é fn.env.json. Os overrides locais da função são fn.config.json. O estado de dependência é .fastfn-deps-state.json.
Esta decisão foi tomada por preguiça e acabou sendo um recurso. Um FaaS filesystem-first pode ser versionado, rsyncado, snapshotado e diffado com ferramentas que eu já tenho. Você não precisa de uma “plataforma” para inspecioná-lo. ls é a UI de admin. grep é o depurador. Esta é a mesma lição que o inetd aprendeu em 1986: às vezes o plano de controle deveria ser um arquivo de texto.
8 Capítulo 8: A Moral da História (Parte 1)
Eu me propus a construir “largue um arquivo Python, ganhe uma URL”. Acabei reconstruindo o FastCGI de propósito, com um gateway em Lua porque o OpenResty é genuinamente a ferramenta certa para este trabalho, um protocolo de fio em JSON porque formatos de registro binário são um mau tradeoff para um sistema local-first em 2026, e um contrato de runtime poliglota porque def handler(event): return {...} é a abstração certa e tem sido desde que o Lambda foi lançado.
A coisa que eu não esperava é quantas das decisões de design já tinham sido pré-feitas para mim pela história. O custo de fork-por-requisição que matou o CGI é o mesmo custo que faz o serverless container-por-requisição parecer lento hoje. O pool persistente + socket enquadrado do FastCGI é a mesma forma na qual as plataformas serverless modernas convergem internamente. Os Cloudflare Workers são, em sua essência, FastCGI com um isolado v8 em vez de um processo. Os pools de warm-start do AWS Lambda são FastCGI com um API Gateway na frente. fastfn — pelo menos na forma que acabei de descrever — é FastCGI com frames JSON e um gateway OpenResty.
Vou fechar a Parte 1 com o único arrependimento que de fato tenho: eu poderia ter começado com Lua um ano antes. Toda vez que escrevi “só um gatewayzinho em Go” eu estava escrevendo uma versão pior do que o nginx já faz, com um cold start maior, mais código e menos observabilidade. A lição — e não é uma lição nova, que é a parte constrangedora — é que quando a forma do seu problema combina com uma peça de infraestrutura existente e bem-amada, você deveria simplesmente usar essa infraestrutura. O FastCGI me disse como o data plane deveria parecer em 1996. O OpenResty me disse como o plano de controle deveria parecer em 2010. O Lambda me disse como o contrato de handler deveria parecer em 2014. Tudo que a Parte 1 do fastfn fez foi escutar.
Há um segundo ramo desta história que este post deliberadamente não cobre: o que acontece quando uma função não é a forma certa — quando você precisa de um serviço de vida longa como um banco de dados, uma app Flask, ou um frontend Next.js vivendo dentro do mesmo gateway. Esse trabalho está atualmente em andamento em um branch de feature e ainda não foi publicado, então vou deixá-lo para a Parte 2, onde o isolamento de microVM Firecracker, a config apps / workloads, e a rede vsock entre peers todos ganham o espaço de que precisam. As funções são a Parte 1. Os serviços são a Parte 2.
postgres.internal sem nunca expor uma porta no host.