fastfn 第一部分:我遇到了一个问题(并把 Lua 引入了我的生活)

1 第一章:那份痛楚,或者说"为什么一个 Hello World 要七个文件?"
整件事始于一个在 2026 年说出口几乎令人难堪的抱怨:我想往磁盘上扔一个 Python 文件,让它成为一个 HTTP 端点。就这样。不要 Dockerfile。除非我想要,否则也不要 requirements.txt。没有 app = FastAPI() 样板代码,没有 uvicorn 调用,没有那种留下十七个配置文件、让我接下来三个下午都在删除的"创建项目"向导。而且——这是它开始有了主见的部分——我想要一个合理的冷启动。不是 Lambda 那种冷,早上的第一个请求感觉像是带了额外步骤的 404。要温乎乎的。人类尺度的。
我厌倦了现代 Web 框架的形态。它们很美,能扩展,有生态系统,同时它们也要求你在第一条路由用 {"hello": "world"} 响应之前,先在脑中编译出它们那个世界的一整套心智模型。对于一个用完即弃的内部工具来说,这是一笔用注意力支付的税。我已经支付这笔税十年了。我想停下来。
我想要的另一件事——也是悄悄驱动着故事其余部分的那个特性——是默认多语言。不是"用不同语言写的微服务通过 gRPC 对话"那种多语言。而是同一棵 URL 树里可以有 get.users.py、post.orders.js 和 get.health.go,彼此相邻地坐在同一个文件夹里,在同一个网关后面。把 Next.js 的基于文件的路由用于基于文件的处理器,与运行时无关。那就是梦想。
所以目标很清楚:一个 Function-as-a-Service 的东西,但本地优先,以 CLI 作为主要界面,以文件树作为数据库。我把它叫做 fastfn。README 用一行描述了这个抱负:“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)。本文的其余部分讲的是那一句话如何变成了一个 Lua 网关、一个持久化的 Python 守护进程,以及一个带 4 字节长度前缀的线缆协议的故事。这是一个关于慢慢地、带着几分难堪地发现自己正在重新发明 FastCGI 的故事。
2 第二章:一段简短、略有偏颇的 CGI 历史
在我讲清楚 fastfn 是什么之前,我得先谈谈它跟什么押韵。
2.1 CGI:最早的 serverless
起初有 CGI。在 serverless 成为一个品牌之前,Common Gateway Interface 就已经是 serverless 了。你把一个脚本放进 /cgi-bin/,Web 服务器对每一个请求 fork 并 exec 它,它从环境变量和 stdin 读取请求,把响应写到 stdout,然后退出。操作系统负责清理。每个请求都是一个进程。每个进程都是一个存在 40 毫秒然后死去的宇宙。
这很美妙。这也是一桩性能罪行。
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 -- | .
| | .每个请求都要为进程创建、解释器预热、库导入和拆解买单。在一台 1996 年的机器上这很痛。在一台 2026 年的机器上它依然痛,只是痛法不同:缓存冷的 Python 在你的处理器写下哪怕一个字节之前,会花上不小的一段时间仅仅导入它自己的标准库。我没有专门针对 fastfn 做端到端的性能剖析,但量级足够大,以至于 FastCGI 风格的持久化池存在的意义正是为了把它摊销掉。
2.2 FastCGI:那个修复,以及我最终照抄的形态
FastCGI 的发明正是为了修复这个。事后看来这个想法几乎是显而易见的:不要在每个请求之后杀死处理器。保持一小撮处理器进程活着,让 Web 服务器通过 Unix socket 与它们对话,并对请求做帧封装,这样你就能干净地复用。Web 服务器是前端;处理器池是后端;它们之间流动的是一串带长度前缀的记录。
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 -- | | .处理器是长寿的。解释器是热的。你处理器的全局状态在请求之间存活(有好有坏)。传输是一个无聊的 socket,带着帧封装的记录。你用每请求一进程的隔离性,换来了某种更接近"跨线缆调用"的东西。这是一笔非常划算的交易,而且本质上正是现代 Python WSGI 服务器、PHP-FPM 以及——咱们就实话实说吧——AWS Lambda 的热启动池在内部所做的事情。
我并没有打算去构建一个 FastCGI。我打算构建一个 serverless 的东西,你扔进一个文件,它就变成一条路由。结果是,一旦你想要热启动、多语言处理器,以及一棵可按文件系统寻址的路由树,设计空间就把你漏斗般地引向某种诡异地酷似身披 JSON 风衣的 FastCGI 的东西。风衣的事稍后再说。
3 第三章:我遇到了一个问题……并把 Lua 引入了我的生活
这就是标题说得通的部分。
网关——那个终结 HTTP、读取请求、弄清楚磁盘上哪个文件该处理它、并转发调用的部件——是任何 FaaS 中最重要也最恼人的部件。它必须快。它必须在你保存文件时热重载。它必须做路由、认证、cookie、CORS、OpenAPI,而且它必须在做这一切的同时不变成一个带 400 毫秒冷启动的 30 MB Node 进程。
我试过用 Go 来写它。还行。但对于本质上就是"接收一个请求,查一个文件,开一个 socket,写,读,写响应"的东西来说,那也是一大堆代码。后来某个晚上我想起 OpenResty——内嵌了 Lua 的 nginx——已经把困难的部分(HTTP 解析、TLS、epoll、共享内存)做好了,只是让我用一种亚毫秒级启动的脚本语言去编写策略层。你不是每个请求启动一次 OpenResty。OpenResty 在进程启动时启动一次,然后你的 Lua 就在请求阶段的钩子里运行。把它想成是 Web 服务器邀请你的代码作为客人住进它的事件循环里。
于是我把 Lua 引入了我的生活。这与其说是一个决定,不如说是一棵内部的选项之树,不断地分叉展开:我需要的每三件事里就有一件结果是"哦,我可以直接在 Lua 里做这件事,而且它能工作"。路由发现?Lua 遍历一个目录。共享内存的路由表?ngx.shared.DICT。文件保存时热重载?一个微小的 Lua 定时器,对函数目录做 stat,并在一个 shared dict 里重建路由表。会话 cookie 解析?十二行:
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
end十二行。没有 npm install cookie-parser。没有 go get github.com/gorilla/sessions。没有那种 2019 年被弃用、如今由一个 bot 维护的传递依赖。十二行运行在 nginx 请求阶段里的 Lua,网关现在就懂会话 cookie 了。
而它非常适合这份工作。运行时小,代码小,延迟小,整个网关——路由、会话解析、请求编组、响应解组、OpenAPI 端点、提供 Swagger UI——都活在 openresty/lua/fastfn/ 下的一小撮 Lua 文件里。http/ 子树里恰好有你会从一个真正的网关期待的那些模块:gateway.lua、assets.lua、catalog.lua、openapi_endpoint.lua、swagger_ui.lua、reload.lua。主 gateway.lua 有 1341 行;实现线缆协议的整个 client.lua 有 110 行。我马上会引用它的大部分。
3.1 路由表是一个 shared dict
让 Lua 网关快起来的诀窍并不聪明。诀窍在于路由表不住在数据库里,不住在 Redis 里,甚至不住在每个 worker 的内存里。它住在 ngx.shared.fn_cache 里,这是一个 nginx 共享内存区,每个 worker 进程都可读。当 fastfn dev 启动时,Lua 遍历函数目录,构建一个索引——"GET /hello → /functions/get.hello.py → python 运行时"——并把它塞进 shared dict。文件变化时,一段 reload Lua 代码块重建它。请求在 access_by_lua 阶段做一次 shared-dict 查找然后分发。没有"框架"。有的是一张哈希表和一套约定。
4 第四章:身披 JSON 风衣的 FastCGI
现在讲协议。这就是我意外重建了 FastCGI 的部分。
当 Lua 网关判定 GET /hello 应当由 Python 运行时来服务时,它需要把请求送到 Python 守护进程那里。Python 守护进程是一个长寿进程,监听在一个 Unix socket 上(默认 /tmp/fastfn/fn-python.sock,可通过 FN_PY_SOCKET 配置,你可以在 srv/fn/runtimes/python-daemon.py:25 看到)。网关打开 socket,写入请求,读取响应。
帧格式刻意做得很无聊:
4 bytes N bytes
--------------- ----------------------------------------
| big-endian | | |
| uint32 len | | JSON payload (request or response) |
--------------- ----------------------------------------四字节的大端长度。然后是那么多字节的 JSON。这就是整个协议。它就是 FastCGI 的记录帧封装,简化为单一记录类型,记录体是 JSON 而不是 FastCGI 的键值二进制编码。如果 FastCGI 披上一件风衣、试图冒充一个现代 REST API,它就会这么打扮。
线缆的 Lua 一侧小到可以完整引用。这是那个发送请求并解析响应的客户端:
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
end手写的 pack_u32 函数是一个很好的提醒:Lua 5.1(OpenResty 所用的版本)并不自带 string.pack,所以我自己实现它:
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)
end帧上限是 10 MB(body_len > 10 * 1024 * 1024)。Python 一侧通过 FN_MAX_FRAME_BYTES 施加一个对称的限制,其默认值是 2 MB(srv/fn/runtimes/python-daemon.py:26)。是的,两个默认值并不匹配。这是故意的:网关比守护进程稍微宽容一点,这样一个配置错误的守护进程会在守护进程处失败,而不是在网关处陷入一次令人困惑的半读。这是那种在 code review 里读起来不对、但在你凌晨 2 点排查生产事故时读起来很对的不对称。
4.1 请求生命周期,附带近似的毫秒数
一旦你有了线缆协议,整个请求生命周期就容易画出来了。这是一个热请求,附带我在一台 2024 年年中运行 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 client这些数字是粗略的——我没有为纯函数路径本身发布过端到端基准,所以请把这份逐行的毫秒拆解当作大致估计,而非金科玉律。即便确切数字会有漂移,形态是对的。冷启动是一个非常不同的话题,我会讲到。
5 第五章:多语言运行时,或者说"没有亚马逊的 Lambda"
线缆协议刻意做成与语言无关。Python 守护进程是一个实现。还有一个 Node 守护进程。也有通往 Rust、Go、PHP 和 Lua 处理器的路径。每个运行时守护进程都做出同样的承诺:打开一个 Unix socket,讲那个 4 字节长度 + JSON 的协议,当一个请求进来时,分发到一个具有 AWS Lambda 所闻名的那种签名的函数。
这是一个真实的处理器,来自多语言教程示例:
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,
}
),
}这就是整个契约。def handler(event): return {...}。没有亚马逊的 Lambda 形态。没有 Cloudflare 的 Cloudflare-Workers 形态。它也是,如果你眯起眼睛,不过是把环境变量和 stdin 换成了一个 JSON 信封和一个热 socket 的 CGI。这个形态自 1990 年代以来一直稳定,因为它就是对的形态:请求是一个字典,响应是一个字典,处理器是一个从前者到后者的纯函数。
守护进程显式支持三种 invoke 适配器(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” 是我的默认形态。另外两个存在是为了让你能把一个现有的 Lambda 或 Cloudflare Worker 几乎不做改动地搬进 fastfn。那种互操作性不是免费的;它花了我一个细心的事件形态转换器。但它意味着你可以拿起你已经跑在别人 serverless 上的代码,用 fastfn dev 在本地运行它,当你雇主的 AWS 控制台又在过它"糟心的一周"时,这是真正有用的。
5.1 守护进程内部的一个 worker 池
Python 守护进程不只是在主线程里分发。它有一个 ThreadPoolExecutor 顶在一个处理器 worker 池前面,由一个槽位预算门控。这些旋钮全都是环境变量:
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"))所以:获取超时 5 秒,空闲 TTL 5 分钟,回收器每 2 秒运行一次。这些是穿着 Python 衣服的 FastCGI 池数字。回收线程遍历池,杀掉超过其 TTL 的空闲 worker,并削减占用。获取超时是那个"我满负荷了,请退避"的信号。
6 第六章:从头到尾都是 Lua
第三章是我把 Lua 引入生活的那一刻。我当时没领会到的是,它会以多快的速度殖民控制平面的每一个角落。我会着手用 Lua 解决一个问题——“让我解析一个 cookie”——而到下午结束时,路由表、OpenAPI 生成器、限流器和管理仪表盘也全都是 Lua 了,跑在同一个 nginx worker 里,共享着同样的 ngx.shared.DICT 区,它们之间没有任何进程间的跳跃。
这是我列举 Lua 运行之处的那一章。Lua 不是什么秘密武器。它是一门带快速 JIT 的小语言,内嵌在一个已经把困难部分做好的 Web 服务器里,而它恰好像钥匙配锁一样契合这个问题。
6.1 路由与发现:文件树就是数据库
Lua 赢下的第一个地方是路由。路由不住在 YAML 文件或配置对象里;它们住在文件系统里。core/routes.lua 在启动时遍历函数目录,从每个处理器文件读取元数据,并构建一个"GET /hello → Python → get.hello.py“的目录。然后它把目录缓存进 ngx.shared.fn_cache,这样每个 worker 都免费得到同样的视图。发现入口就只是这个:
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 是大块头——约 3,245 行——因为路由是每一项关切最终都会相遇之处:方法、保留前缀、冲突、来源排名。挨着它的是 core/fs.lua(约 392 行),一个围绕 stat、opendir、readdir 等等的小 FFI 封装。它存在是因为 LuaJIT 的 FFI 让我能跳过 LuaFileSystem C 模块、直接调用 libc,从而保持 OpenResty 镜像精简:
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
end这就是文件系统遍历。Lua 世界的 readdir,带一个可插拔的 skip_fn 来剪掉 .git、node_modules、__pycache__ 和 .fastfn。它返回一个排好序的目录列表,routes.lua 和 watchdog.lua 都依赖它。这让我想到:如果路由是磁盘上的一棵树,那么 OpenAPI 规范就是那同一棵树的一个投影,而仪表盘不过是它之上的一个 UI。
6.2 OpenAPI,免费
那个"同一棵树的投影"的想法变成了 core/openapi.lua,一个 1,309 行的模块,它接收 routes.lua 产出的目录并发出一个 OpenAPI 3 文档。处理器函数上没有装饰器。没有 @app.route("/hello")。处理器就只是 def handler(event): return {...},而规范是从路由树加上一薄层每函数元数据(fn.config.json,如果处理器肯费心写一个的话)生成的。
它的 HTTP 一侧是一个 101 行的 Lua 文件,把这一切接起来:
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))这就是整个 /openapi.json 端点。重载你的处理器,访问那个 URL,规范就反映出它。Swagger UI(http/swagger_ui.lua,119 行)提供一个指向那份 JSON 的静态页面,于是你不用跑一个单独的文档服务器就有了交互式文档。OpenAPI 生成器还依赖 core/invoke_rules.lua 来归一化每函数的调用策略——哪些方法被允许,哪些路由被保留:
M.ALLOWED_METHODS = {
GET = true,
POST = true,
PUT = true,
PATCH = true,
DELETE = true,
}
M.RESERVED_ROUTE_PREFIXES = {
"/_fn",
"/console",
}这让我意识到,现代 API 工具有多少其实就是字符串匹配和允许列表。“规范"不是一份规范,它是一个视图。
6.3 可观测性与限制:速率、并发、健康
让路由变廉价的那个共享内存诀窍,正是让每函数并发限制变廉价的同一个诀窍。core/limits.lua 有 133 行,其中大多是样板,而整个机制是在一个 ngx.shared.fn_conc 区上的 incr/decr 之舞:
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
end那个函数就是整个"在整个网关范围内,函数 X 的在途调用不超过 N 个"的原语。dict:incr 在 worker 之间是原子的,因为 ngx.shared.DICT 是底下带一把锁的共享内存。没有 Redis 往返。没有准入控制器 pod。它是 RAM 里的一个计数器,而且它是正确的,因为 nginx 把它正确地交给了我。
在限制之上坐着 core/watchdog.lua(299 行),一个用 LuaJIT FFI 写的 Linux inotify 监视器。它打开 inotify_init1(IN_NONBLOCK | IN_CLOEXEC),遍历函数树,给每个子目录加上一个 watch,并在任何文件变化时调度一次去抖动的重载:
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)一个 150 毫秒的去抖动,一个共享的重载回调,路由表就地重建。保存一个文件,在我切回浏览器之前路由就已经上线了。
6.4 进程内调度:把 ngx.timer.at 当 cron 用
如果我能用 ngx.timer 调度一次重载,我就能调度任何东西。core/scheduler.lua(1,712 行)和 core/jobs.lua(993 行)一起实现了一个类 cron、感知重试、可选持久化的作业运行器,它活在 nginx worker 内部。没有单独的 cron 守护进程,没有 Celery,没有 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)那个单一的 ngx.timer.every 就是 tick 循环。每次 tick 它遍历活跃的调度,把 cron 表达式与挂钟时间对照,并通过 ngx.timer.at(0, ...) 把运行入队。带指数退避的重试从同一个原语里掉出来。持久化是每 15 秒写到磁盘的一个 JSON blob。对一个小想法而言这是相当多的代码:我已经拥有的那个事件循环,对一个单节点 FaaS 来说就足以充当调度器了。
6.4.1 一个具体例子:一个每小时运行的 Telegram AI 摘要
调度器在你看到一个使用它的函数之前都是抽象的。仓库里最干净的一个是 examples/functions/node/telegram-ai-digest——一个 Node 函数,它从一个 Telegram 群拉取消息,用 OpenAI 把它们总结,并把摘要发回一个聊天。对本节而言让它有趣的是其配置里的 schedule 块:
{
"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"
}
}那就是整个"把这个变成一个 cron job"的接口:函数自己的配置里、紧挨着处理器的一段三行 schedule 节。没有外部 crontab,没有单独的注册步骤,没有 YAML 流水线。当 fastfn 启动时,调度器读取每个函数的 fn.config.json,看到这个有一个 schedule.enabled = true,就以 every_seconds = 3600 把它加入 tick 循环。每隔一小时,调度器向函数发出一个内部 GET 请求,就好像一个客户端调用了它一样——同样的路由,同样的处理器,同样的日志——而函数完成它的工作。
同样的模式出现在那些兄弟示例里:telegram-ai-reply 是一个 webhook(POST 处理器,没有 schedule——Telegram 在一条消息到达时调用它),telegram-send 是一个库式函数,你可以从别的处理器调用它来发送一条消息(默认 dry_run,这是我在第二个本该静默的 bot 之后学会添加的那种安全细节)。三个函数,三种生命周期——webhook、库调用、cron——而它们全都不过是 functions/ 里的文件。唯一让这个摘要"成为一个 cron"的,就是那三行配置。
我喜欢这个形态,因为函数的"部署模型"和它的"调用模型"在同一个地方。如果一个队友想知道"这个怎么运行的?",他打开 fn.config.json,看到 schedule.every_seconds = 3600,就知道了。不用去一台他没有 ssh 权限的服务器上的某个 cron 文件里翻找。
6.4.2 配置与令牌:fn.env.json 与 is_secret 标志
那个被调度的摘要函数没有两个密钥和一个标识符就没用:一个 Telegram bot 令牌、一个 OpenAI API 密钥,以及要发往的聊天 ID。这是每个 FaaS 教程里那个你要么对环境变量含糊其辞、要么贴上一段关于 Vault 的文字的节点。fastfn 两者都不做。它把配置放在函数旁边,放在一个叫 fn.env.json 的同级文件里,带着少量结构,让每个值的含义变得明确:
{
"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
}
}有两件事要注意。第一,每个键都是一个对象,而不是一个裸字符串——值住在 .value 里,元数据住在它旁边。第二,那个 is_secret 布尔值是承重的。运行时用它来决定一个值是否在日志和管理仪表盘里被遮蔽,是否能通过一个 _fn/ui_state 端点被回显,以及仪表盘的"view"按钮是否被允许以明文揭示它。TELEGRAM_CHAT_ID 不是密钥——它只是一个数字,而当你在排查"为什么我的摘要没出现"时,你会想在 UI 里看到它。TELEGRAM_BOT_TOKEN 是密钥——而如果你不小心把它泄露到日志里,Telegram 的反应是让令牌失效,于是调度器会静默地停止工作,直到你注意到。is_secret 标志就是这两种结果之间的全部差别。
值 "<set-me>" 也不是一个 bug——它是仓库刻意使用的一种模式。core.js 里的处理器把 <set-me>、set-me、changeme、<changeme> 和 replace-me 都当作"未设置"的哨兵:
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";
}这意味着你可以把带 "<set-me>" 占位符的 fn.env.json 检入 git,而函数会干净地失败,而不是假装拿着一个空字符串在运行。真实的部署会把那些占位符(在仪表盘里、通过 API,或在一台受控主机上编辑文件)替换为实际的值;is_secret: true 的条目在保存时立即被遮蔽。
在处理器内部,运行时把这些值通过 event.env 交付,与请求作用域的 event.method、event.query 等等并列。摘要的 core.js 用一个小小的回退来读取它们,优先用函数本地的 env,回退到守护进程在启动时列入允许列表的环境 process.env:
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);这是我一再回归的那种配置形态:一个紧挨处理器的 JSON 文件,每个密钥一个值,一个显式的 is_secret 标志驱动着每一个下游界面的遮蔽,以及一个语言层面的 event.env,它让处理器对那些值的依赖变得可以轻松地 grep 出来。它,再说一次,不是一个新想法——它基本上跟 Heroku 的 config vars 或一对 Kubernetes 的 Secret/ConfigMap 是同一个形态。但它住在使用它的代码的同一个文件夹里,它带着安全的占位符可被版本控制,而且它不需要一个外部的密钥管理器就能上手。
把调度器、配置和处理器放进一个目录,Telegram 摘要就从"某处一个模糊的 cron job"变成了"我能在一分钟内读完、并交给一个队友的四个文件”。
6.5 仪表盘是 Lua
Lua 最让我吃惊的地方是管理控制台。我并没有打算用 Lua 写一个 Web 应用。我以为我最终会接进一个小 SPA,多半是 Vue 或 Svelte,因为"大家都这么干”。然后我用一个下午写了 console/login_endpoint.lua,并意识到我不需要那个 SPA。
登录端点有 95 行,做了一个登录端点该做的一切:方法检查、在 ngx.shared.fn_cache 里限流、恒定时间比较、PBKDF2 密码验证、会话 cookie。这是限流的那一片:
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
end五秒钟的 shared-dict 算术,我就有了一个能用的锁定。console/auth.lua(484 行)处理会话 cookie、最少 100,000 次迭代的 PBKDF2、用于管理员密码的可选从文件读取密钥,以及一个 12 小时的 TTL。console/guard.lua(387 行)是每个仪表盘端点都先调用、用以强制认证、请求体限制、CSRF 和写入门控的中间件。console/data.lua 是大块头——约 2,623 行——它是仪表盘端点委托给它的后备"服务层":列出函数、读取一个函数的代码、设置代码、列出版本、读取日志、读取调度、聚合仪表盘指标。
端点本身则很小。仪表盘指标端点有 21 行:
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 = {}
})围绕着它活着的是 functions_endpoint.lua(34 行)、logout_endpoint.lua(15 行)、ui_state_endpoint.lua(50 行)、secrets_endpoint.lua(70 行)、packs.lua(77 行)、ui.lua(66 行),以及更胖一点的 invoke_endpoint.lua(526 行),它驱动着"从浏览器调用这个函数"的控制台。没有单独的管理 Web 应用。没有 npm run dev。没有第二套构建系统。有的是 Lua,从服务网关流量的同一个 nginx worker 里渲染出 HTML 和 JSON。
6.6 Lua 作为运行时,以及一个非常微小的 HTTP 客户端
我还添加了 core/lua_runtime.lua(399 行),它让函数目录里的 Lua 文件成为处理器——和 Python 与 Node 一样的 handler(event) -> response 契约,只是没有跨进程的跳跃,因为处理器跑在同一个 worker 里。我没有为这条路径发布过基准,所以我不会给它安上一个毫秒数。对于需要向外伸手的处理器,core/http_client.lua(356 行)用 URL 解析、keepalive 和超时封装了 ngx.socket.tcp,于是处理器调用上游 API 时不用把 luasocket 拉进镜像。core/home.lua(234 行)在 / 提供默认落地页,而 http/function_code.lua / http/function_file_content.lua(50 和 76 行)在仪表盘编辑器和磁盘之间来回传送函数源码。
6.7 诚实的取舍
那么——Lua 在这里是不是了不起?是的,有凭有据。凭据是:
- 冷启动本质上是免费的。 OpenResty 启动一次。我的 Lua 代码是一组在 worker init 时加载的模块。
gateway.lua文件有 1,341 行;加载它对请求延迟几乎不增加什么,因为到第一个请求到达时请求阶段的钩子已经被 JIT 编译好了。我这里没有一个干净的"Lua 模块加载时间"数字可引用,所以我不会安上一个毫秒数字——但仓库里发布的每一个热路径p50,跨各种负载,端到端穿过这个 Lua 网关都是个位数毫秒,而那是唯一对一个用户重要的数字。 - 共享内存几乎免费。
ngx.shared.DICT是 nginx 里的一张哈希表,由一把自旋锁保护。我不用为持有计数器付一个 Redis 容器的钱。 - 每个关切一个模块,都很小。 路由发现核心除外,大多数控制平面模块都在 100–500 行的区间。
limits.lua是 133 行。watchdog.lua是 299。login_endpoint.lua是 95。这些是阅读距离内的数字。它们当中任何一个我都能装进脑子里。
还有代价,因为代价是有的:
- Lua 5.1 的数值怪癖。 OpenResty 的 LuaJIT 是带一些 5.2/5.3 扩展的 Lua-5.1-ish。没有原生的
string.pack,这就是第四章里那个pack_u32存在的原因。在写限流器时,整数对双精度的混淆咬过我两次;ngx.shared.DICT:incr返回的数字是双精度的,而我不得不在那些我打算拿来和tonumber(env)值比较的计数器上小心使用math.floor。 - 到处都要有
pcall纪律。 一个ngx.timer回调里未捕获的错误会静默地杀死定时器并记到error.log。我写的每一个定时器都裹在pcall里,而调度器 tick、watchdog 回调和持久化写入器都遵循同样的模式。跳过那份纪律,你就会得到一个在凌晨 3 点神秘地停止 tick 的调度器。 - 一切都是字符串,直到它不是。 Shared dict 存储字符串和数字,而不是表,这意味着在边界处做 JSON 编码/解码。我依靠
cjson.safe,让坏输入返回nil, err而不是抛出,而当一个表有数值上的空洞时,我仍然偶尔会得到cannot encode sparse array。
这些都不是致命问题。它们是选择一门带快速 JIT、内嵌在 Web 服务器里的小语言所要付的价钱,而这笔交易一直在赢。整个控制平面——路由、OpenAPI、限制、watchdog、调度器、控制台、认证——是我一个下午能读完的一小撮文件。大多数关切各自是 20 到 40 KB 的可读 Lua。整个 core/ 目录在磁盘上约 304 KB;整个 console/ 目录约 156 KB。这远远少于一个单一的现代 SPA 在抵达第一条路由之前就发出的 JavaScript 的量。而且那个 Lua 是产品,不是脚手架。
这把我带到了我试图从这堆东西里抽出教训的部分。
7 第七章:我只料到了一半的教训
7.1 永远把主机环境列入允许列表
每个 FaaS 都有同样的诱惑:把主机的环境变量传进处理器。这很方便。这也是密钥泄露的方式。如果你的 CI/CD 流水线为部署脚本导出了 AWS_SECRET_ACCESS_KEY,而你的 Python 守护进程把那个 env 继承进了每一个处理器,恭喜你,你刚刚把一个云端 root 凭证交给了机器上的每一个处理器。
修复是一个允许列表,而不是一个阻止列表。我是用那种无聊的方式发现这一点的:读别人的事故报告,然后决定我不想写我自己的。守护进程附带一个保守的、处理器能看见的环境 env 键列表(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_",)它上面的注释把那句悄悄话说出了声:“User-defined secrets should come from fn.env.json or request-scoped event.env, not ambient host env.” 允许列表就是那个强制执行。一切不在那个集合里的、或者以 LC_ 开头的,都在处理器运行之前被清除。这是安全卫生,不是安全表演。
7.2 配置优先级:flag > env > config > 默认
这是那种听起来显而易见、却在我用过的一半 CLI 里都搞错了的规则。我自己的 CLI 把它搞对了,是因为我第一次搞错了。来自 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")
}注意第 64 行的注释。它不是为我而写的——它是为下一个试图通过让配置文件压过 env 来"修复"这个的人而写的。每一个曾经把这个顺序颠倒过来的配置系统,都引发过一次生产事故:有人在 CI 里设置了一个环境变量,然后纳闷为什么容器一直从一个检入的文件里读取一个陈旧的值。
7.3 一切都住在文件系统里
没有数据库。没有 registry。没有 etcd。路由是文件。配置是 fastfn.json。环境是 fn.env.json。函数本地的覆盖是 fn.config.json。依赖状态是 .fastfn-deps-state.json。
这个决定是出于懒惰做出的,结果却成了一个特性。一个文件系统优先的 FaaS 可以用我已经拥有的工具来做版本控制、rsync、快照和 diff。你不需要一个"平台"来检视它。ls 就是管理 UI。grep 就是调试器。这跟 inetd 在 1986 年学到的是同一个教训:有时候控制平面应该是一个文本文件。
8 第八章:故事的寓意(第一部分)
我着手构建"扔进一个 Python 文件,得到一个 URL"。我最终是有意地重建了 FastCGI,用一个 Lua 网关因为 OpenResty 真真切切是这份工作的正确工具,用一个 JSON 线缆协议因为对一个 2026 年的本地优先系统而言,二进制记录格式是个糟糕的取舍,用一个多语言运行时契约因为 def handler(event): return {...} 是正确的抽象,而且自 Lambda 发布以来一直是。
我没料到的是,有多少设计决定早已被历史替我预先做好了。杀死 CGI 的每请求一 fork 的代价,正是今天让每请求一容器的 serverless 感觉慢的同一个代价。FastCGI 的持久化池 + 帧封装 socket,正是现代 serverless 平台在内部收敛到的同一个形态。Cloudflare Workers 骨子里就是用一个 v8 isolate 取代了进程的 FastCGI。AWS Lambda 的热启动池是前面摆了一个 API Gateway 的 FastCGI。fastfn——至少在我刚刚描述的形态里——就是带 JSON 帧和一个 OpenResty 网关的 FastCGI。
我会带着我确实拥有的那个唯一的遗憾来收尾第一部分:我本可以早一年就从 Lua 开始。每一次我写下"只是一个 Go 写的小网关",我都是在写一个 nginx 已经做了的东西的更差版本,带着更高的冷启动、更多的代码和更少的可观测性。教训——而且它不是一个新教训,这正是难堪之处——是当你问题的形态匹配上一块现存的、广受喜爱的基础设施时,你就该直接用那块基础设施。FastCGI 在 1996 年告诉了我数据平面该是什么样。OpenResty 在 2010 年告诉了我控制平面该是什么样。Lambda 在 2014 年告诉了我处理器契约该是什么样。fastfn 的第一部分所做的全部,不过是倾听。
这个故事有第二条支线,本文刻意不去覆盖:当一个函数不是正确的形态时会发生什么——当你需要一个长寿的服务,比如一个数据库、一个 Flask 应用,或者一个住在同一个网关里的 Next.js 前端。那项工作目前在一个 feature 分支上进行中、尚未发布,所以我把它留给第二部分,在那里 Firecracker microVM 隔离、apps / workloads 配置,以及 vsock 对等网络都将得到它们所需要的篇幅。函数是第一部分。服务是第二部分。
postgres.internal 触及一个 Postgres VM、却从不在主机上暴露端口的 vsock 网络。