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

整件事始于一个在 2026 年说出口几乎令人难堪的抱怨:我想往磁盘上扔一个 Python 文件,让它成为一个 HTTP 端点。就这样。不要 Dockerfile。除非我想要,否则也不要 requirements.txt。没有 app = FastAPI() 样板代码,没有 uvicorn 调用,没有那种留下十七个配置文件、让我接下来三个下午都在删除的"创建项目"向导。而且——这是它开始有了主见的部分——我想要一个合理的冷启动。不是 Lambda 那种冷,早上的第一个请求感觉像是带了额外步骤的 404。要温乎乎的。人类尺度的。

我厌倦了现代 Web 框架的形态。它们很美,能扩展,有生态系统,同时它们也要求你在第一条路由用 {"hello": "world"} 响应之前,先在脑中编译出它们那个世界的一整套心智模型。对于一个用完即弃的内部工具来说,这是一笔用注意力支付的税。我已经支付这笔税十年了。我想停下来。

我想要的另一件事——也是悄悄驱动着故事其余部分的那个特性——是默认多语言。不是"用不同语言写的微服务通过 gRPC 对话"那种多语言。而是同一棵 URL 树里可以有 get.users.pypost.orders.jsget.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 的故事。

在我讲清楚 fastfn 是什么之前,我得先谈谈它跟什么押韵。

起初有 CGI。在 serverless 成为一个品牌之前,Common Gateway Interface 就已经是 serverless 了。你把一个脚本放进 /cgi-bin/,Web 服务器对每一个请求 forkexec 它,它从环境变量和 stdin 读取请求,把响应写到 stdout,然后退出。操作系统负责清理。每个请求都是一个进程。每个进程都是一个存在 40 毫秒然后死去的宇宙。

这很美妙。这也是一桩性能罪行。

text

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 风格的持久化池存在的意义正是为了把它摊销掉。

FastCGI 的发明正是为了修复这个。事后看来这个想法几乎是显而易见的:不要在每个请求之后杀死处理器。保持一小撮处理器进程活着,让 Web 服务器通过 Unix socket 与它们对话,并对请求做帧封装,这样你就能干净地复用。Web 服务器是前端;处理器池是后端;它们之间流动的是一串带长度前缀的记录。

text

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 的东西。风衣的事稍后再说。

这就是标题说得通的部分。

网关——那个终结 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 解析?十二行:

openresty/lua/fastfn/http/gateway.lua:70-82

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 了。

关于 Lua 的一点说明
Lua 是一门奇怪的语言。它从 1 开始索引。它只有一种数据结构(表),并且在你眯起眼睛之前都没有真正的模块系统。它的标准库简直是近乎激进的极简。这些在这里都没有真正造成伤害——网关很短,热路径留在 LuaJIT 里,而 Lua 没有的那些东西(一个庞大的运行时、一个包生态系统、一套复杂的类型系统)恰恰是你在 nginx 内部的请求热路径里不想要的东西。

而它非常适合这份工作。运行时小,代码小,延迟小,整个网关——路由、会话解析、请求编组、响应解组、OpenAPI 端点、提供 Swagger UI——都活在 openresty/lua/fastfn/ 下的一小撮 Lua 文件里。http/ 子树里恰好有你会从一个真正的网关期待的那些模块:gateway.luaassets.luacatalog.luaopenapi_endpoint.luaswagger_ui.luareload.lua。主 gateway.lua 有 1341 行;实现线缆协议的整个 client.lua 有 110 行。我马上会引用它的大部分。

让 Lua 网关快起来的诀窍并不聪明。诀窍在于路由表不住在数据库里,不住在 Redis 里,甚至不住在每个 worker 的内存里。它住在 ngx.shared.fn_cache 里,这是一个 nginx 共享内存区,每个 worker 进程都可读。当 fastfn dev 启动时,Lua 遍历函数目录,构建一个索引——"GET /hello/functions/get.hello.pypython 运行时"——并把它塞进 shared dict。文件变化时,一段 reload Lua 代码块重建它。请求在 access_by_lua 阶段做一次 shared-dict 查找然后分发。没有"框架"。有的是一张哈希表和一套约定。

现在讲协议。这就是我意外重建了 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,写入请求,读取响应。

帧格式刻意做得很无聊:

text

  4 bytes            N bytes
  ---------------    ----------------------------------------
  | big-endian  |    |                                      |
  | uint32 len  |    |  JSON payload (request or response)  |
  ---------------    ----------------------------------------

四字节的大端长度。然后是那么多字节的 JSON。这就是整个协议。它就是 FastCGI 的记录帧封装,简化为单一记录类型,记录体是 JSON 而不是 FastCGI 的键值二进制编码。如果 FastCGI 披上一件风衣、试图冒充一个现代 REST API,它就会这么打扮。

线缆的 Lua 一侧小到可以完整引用。这是那个发送请求并解析响应的客户端:

openresty/lua/fastfn/core/client.lua:22-106

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,所以我自己实现它:

openresty/lua/fastfn/core/client.lua:5-11

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 点排查生产事故时读起来很对的不对称。

一旦你有了线缆协议,整个请求生命周期就容易画出来了。这是一个热请求,附带我在一台 2024 年年中运行 Linux 的笔记本上测量的粗略计时:

text

  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

这些数字是粗略的——我没有为纯函数路径本身发布过端到端基准,所以请把这份逐行的毫秒拆解当作大致估计,而非金科玉律。即便确切数字会有漂移,形态是对的。冷启动是一个非常不同的话题,我会讲到。

线缆协议刻意做成与语言无关。Python 守护进程是一个实现。还有一个 Node 守护进程。也有通往 Rust、Go、PHP 和 Lua 处理器的路径。每个运行时守护进程都做出同样的承诺:打开一个 Unix socket,讲那个 4 字节长度 + JSON 的协议,当一个请求进来时,分发到一个具有 AWS Lambda 所闻名的那种签名的函数。

这是一个真实的处理器,来自多语言教程示例:

examples/functions/polyglot-tutorial/step-2/index.py:1-18

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

python

_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 控制台又在过它"糟心的一周"时,这是真正有用的。

Python 守护进程不只是在主线程里分发。它有一个 ThreadPoolExecutor 顶在一个处理器 worker 池前面,由一个槽位预算门控。这些旋钮全都是环境变量:

srv/fn/runtimes/python-daemon.py:38-40

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,并削减占用。获取超时是那个"我满负荷了,请退避"的信号。

第三章是我把 Lua 引入生活的那一刻。我当时没领会到的是,它会以多快的速度殖民控制平面的每一个角落。我会着手用 Lua 解决一个问题——“让我解析一个 cookie”——而到下午结束时,路由表、OpenAPI 生成器、限流器和管理仪表盘也全都是 Lua 了,跑在同一个 nginx worker 里,共享着同样的 ngx.shared.DICT 区,它们之间没有任何进程间的跳跃。

这是我列举 Lua 运行之处的那一章。Lua 不是什么秘密武器。它是一门带快速 JIT 的小语言,内嵌在一个已经把困难部分做好的 Web 服务器里,而它恰好像钥匙配锁一样契合这个问题。

Lua 赢下的第一个地方是路由。路由不住在 YAML 文件或配置对象里;它们住在文件系统里。core/routes.lua 在启动时遍历函数目录,从每个处理器文件读取元数据,并构建一个"GET /hello → Python → get.hello.py“的目录。然后它把目录缓存进 ngx.shared.fn_cache,这样每个 worker 都免费得到同样的视图。发现入口就只是这个:

openresty/lua/fastfn/core/routes.lua:2228-2237

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

core/routes.lua 是大块头——约 3,245 行——因为路由是每一项关切最终都会相遇之处:方法、保留前缀、冲突、来源排名。挨着它的是 core/fs.lua(约 392 行),一个围绕 statopendirreaddir 等等的小 FFI 封装。它存在是因为 LuaJIT 的 FFI 让我能跳过 LuaFileSystem C 模块、直接调用 libc,从而保持 OpenResty 镜像精简:

openresty/lua/fastfn/core/fs.lua:263-280

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 来剪掉 .gitnode_modules__pycache__.fastfn。它返回一个排好序的目录列表,routes.luawatchdog.lua 都依赖它。这让我想到:如果路由是磁盘上的一棵树,那么 OpenAPI 规范就是那同一棵树的一个投影,而仪表盘不过是它之上的一个 UI。

那个"同一棵树的投影"的想法变成了 core/openapi.lua,一个 1,309 行的模块,它接收 routes.lua 产出的目录并发出一个 OpenAPI 3 文档。处理器函数上没有装饰器。没有 @app.route("/hello")。处理器就只是 def handler(event): return {...},而规范是从路由树加上一薄层每函数元数据(fn.config.json,如果处理器肯费心写一个的话)生成的。

它的 HTTP 一侧是一个 101 行的 Lua 文件,把这一切接起来:

openresty/lua/fastfn/http/openapi_endpoint.lua:92-101

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 来归一化每函数的调用策略——哪些方法被允许,哪些路由被保留:

openresty/lua/fastfn/core/invoke_rules.lua:4-14

M.ALLOWED_METHODS = {
  GET = true,
  POST = true,
  PUT = true,
  PATCH = true,
  DELETE = true,
}
M.RESERVED_ROUTE_PREFIXES = {
  "/_fn",
  "/console",
}

这让我意识到,现代 API 工具有多少其实就是字符串匹配和允许列表。“规范"不是一份规范,它是一个视图。

让路由变廉价的那个共享内存诀窍,正是让每函数并发限制变廉价的同一个诀窍。core/limits.lua 有 133 行,其中大多是样板,而整个机制是在一个 ngx.shared.fn_conc 区上的 incr/decr 之舞:

openresty/lua/fastfn/core/limits.lua:22-39

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,并在任何文件变化时调度一次去抖动的重载:

openresty/lua/fastfn/core/watchdog.lua:268-284

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 毫秒的去抖动,一个共享的重载回调,路由表就地重建。保存一个文件,在我切回浏览器之前路由就已经上线了。

如果我能用 ngx.timer 调度一次重载,我就能调度任何东西。core/scheduler.lua(1,712 行)和 core/jobs.lua(993 行)一起实现了一个类 cron、感知重试、可选持久化的作业运行器,它活在 nginx worker 内部。没有单独的 cron 守护进程,没有 Celery,没有 RabbitMQ:

openresty/lua/fastfn/core/scheduler.lua:1659-1667

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 来说就足以充当调度器了。

调度器在你看到一个使用它的函数之前都是抽象的。仓库里最干净的一个是 examples/functions/node/telegram-ai-digest——一个 Node 函数,它从一个 Telegram 群拉取消息,用 OpenAI 把它们总结,并把摘要发回一个聊天。对本节而言让它有趣的是其配置里的 schedule 块:

examples/functions/node/telegram-ai-digest/fn.config.json

{
  "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 文件里翻找。

那个被调度的摘要函数没有两个密钥和一个标识符就没用:一个 Telegram bot 令牌、一个 OpenAI API 密钥,以及要发往的聊天 ID。这是每个 FaaS 教程里那个你要么对环境变量含糊其辞、要么贴上一段关于 Vault 的文字的节点。fastfn 两者都不做。它把配置放在函数旁边,放在一个叫 fn.env.json 的同级文件里,带着少量结构,让每个值的含义变得明确:

examples/functions/node/telegram-ai-digest/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-mechangeme<changeme>replace-me 都当作"未设置"的哨兵:

examples/functions/node/telegram-ai-digest/core.js:1-8

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.methodevent.query 等等并列。摘要的 core.js 用一个小小的回退来读取它们,优先用函数本地的 env,回退到守护进程在启动时列入允许列表的环境 process.env

examples/functions/node/telegram-ai-digest/core.js:86-88

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"变成了"我能在一分钟内读完、并交给一个队友的四个文件”。

Lua 最让我吃惊的地方是管理控制台。我并没有打算用 Lua 写一个 Web 应用。我以为我最终会接进一个小 SPA,多半是 Vue 或 Svelte,因为"大家都这么干”。然后我用一个下午写了 console/login_endpoint.lua,并意识到我不需要那个 SPA。

登录端点有 95 行,做了一个登录端点该做的一切:方法检查、在 ngx.shared.fn_cache 里限流、恒定时间比较、PBKDF2 密码验证、会话 cookie。这是限流的那一片:

openresty/lua/fastfn/console/login_endpoint.lua:38-46

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

openresty/lua/fastfn/console/dashboard_endpoint.lua:9-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。

我还添加了 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 行)在仪表盘编辑器和磁盘之间来回传送函数源码。

那么——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 是产品,不是脚手架。

这把我带到了我试图从这堆东西里抽出教训的部分。

每个 FaaS 都有同样的诱惑:把主机的环境变量传进处理器。这很方便。这也是密钥泄露的方式。如果你的 CI/CD 流水线为部署脚本导出了 AWS_SECRET_ACCESS_KEY,而你的 Python 守护进程把那个 env 继承进了每一个处理器,恭喜你,你刚刚把一个云端 root 凭证交给了机器上的每一个处理器。

修复是一个允许列表,而不是一个阻止列表。我是用那种无聊的方式发现这一点的:读别人的事故报告,然后决定我不想写我自己的。守护进程附带一个保守的、处理器能看见的环境 env 键列表(srv/fn/runtimes/python-daemon.py:59-103):

python

_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_ 开头的,都在处理器运行之前被清除。这是安全卫生,不是安全表演。

这是那种听起来显而易见、却在我用过的一半 CLI 里都搞错了的规则。我自己的 CLI 把它搞对了,是因为我第一次搞错了。来自 cli/cmd/run.go:64-73

go

// 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 里设置了一个环境变量,然后纳闷为什么容器一直从一个检入的文件里读取一个陈旧的值。

没有数据库。没有 registry。没有 etcd。路由是文件。配置是 fastfn.json。环境是 fn.env.json。函数本地的覆盖是 fn.config.json。依赖状态是 .fastfn-deps-state.json

这个决定是出于懒惰做出的,结果却成了一个特性。一个文件系统优先的 FaaS 可以用我已经拥有的工具来做版本控制、rsync、快照和 diff。你不需要一个"平台"来检视它。ls 就是管理 UI。grep 就是调试器。这跟 inetd 在 1986 年学到的是同一个教训:有时候控制平面应该是一个文本文件。

我着手构建"扔进一个 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 对等网络都将得到它们所需要的篇幅。函数是第一部分。服务是第二部分。

继续阅读
第二部分——fastfn 第二部分:当一个函数还不够时——从本文结束之处接续:服务、workloads、Firecracker microVM,以及那个让一个函数能在 postgres.internal 触及一个 Postgres VM、却从不在主机上暴露端口的 vsock 网络。

相关内容