# fastfn 第一部分：我遇到了一个问题（并把 Lua 引入了我的生活）


## 第一章：那份痛楚，或者说"为什么一个 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 的故事。

## 第二章：一段简短、略有偏颇的 CGI 历史

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

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

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

## 第三章：我遇到了一个问题……并把 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 解析？十二行：

```lua {title="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 了。

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

而它非常适合这份工作。运行时小，代码小，延迟小，整个网关——路由、会话解析、请求编组、响应解组、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 行。我马上会引用它的大部分。

### 路由表是一个 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 查找然后分发。没有"框架"。有的是一张哈希表和一套约定。

## 第四章：身披 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 一侧小到可以完整引用。这是那个发送请求并解析响应的客户端：

```lua {title="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`，所以我自己实现它：

```lua {title="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 的笔记本上测量的粗略计时：

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

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

## 第五章：多语言运行时，或者说"没有亚马逊的 Lambda"

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

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

```python {title="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 控制台又在过它"糟心的一周"时，这是真正有用的。

### 守护进程内部的一个 worker 池

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

```python {title="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 引入生活的那一刻。我当时没领会到的是，它会以多快的速度殖民控制平面的每一个角落。我会着手用 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 都免费得到同样的视图。发现入口就只是这个：

```lua {title="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 行），一个围绕 `stat`、`opendir`、`readdir` 等等的小 FFI 封装。它存在是因为 LuaJIT 的 FFI 让我能跳过 LuaFileSystem C 模块、直接调用 libc，从而保持 OpenResty 镜像精简：

```lua {title="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` 来剪掉 `.git`、`node_modules`、`__pycache__` 和 `.fastfn`。它返回一个排好序的目录列表，`routes.lua` 和 `watchdog.lua` 都依赖它。这让我想到：如果路由是磁盘上的一棵树，那么 OpenAPI 规范就是那同一棵树的一个投影，而仪表盘不过是它之上的一个 UI。

### OpenAPI，免费

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

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

```lua {title="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` 来归一化每函数的调用策略——哪些方法被允许，哪些路由被保留：

```lua {title="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` 之舞：

```lua {title="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，并在任何文件变化时调度一次去抖动的重载：

```lua {title="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.at` 当 cron 用

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

```lua {title="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 来说就足以充当调度器了。*

#### 一个具体例子：一个每小时运行的 Telegram AI 摘要

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

```json {title="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`](https://github.com/misaelzapata/fastfn/tree/main/examples/functions/node/telegram-ai-reply) 是一个 webhook（`POST` 处理器，没有 schedule——Telegram 在一条消息到达时调用它），[`telegram-send`](https://github.com/misaelzapata/fastfn/tree/main/examples/functions/node/telegram-send) 是一个库式函数，你可以从别的处理器调用它来发送一条消息（默认 `dry_run`，这是我在第二个本该静默的 bot 之后学会添加的那种安全细节）。三个函数，三种生命周期——webhook、库调用、cron——而它们全都不过是 `functions/` 里的文件。唯一让这个摘要"成为一个 cron"的，就是那三行配置。

我喜欢这个形态，因为函数的"部署模型"和它的"调用模型"在同一个地方。如果一个队友想知道"这个怎么运行的？"，他打开 `fn.config.json`，看到 `schedule.every_seconds = 3600`，就知道了。不用去一台他没有 ssh 权限的服务器上的某个 cron 文件里翻找。

#### 配置与令牌：`fn.env.json` 与 `is_secret` 标志

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

```json {title="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-me`、`changeme`、`<changeme>` 和 `replace-me` 都当作"未设置"的哨兵：

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

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

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

```lua {title="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 行：

```lua {title="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。

### 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 行）在仪表盘编辑器和磁盘之间来回传送函数源码。

### 诚实的取舍

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

### 配置优先级：flag > env > config > 默认

这是那种听起来显而易见、却在我用过的一半 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 对等网络都将得到它们所需要的篇幅。函数是第一部分。服务是第二部分。

{{< admonition type="tip" title="继续阅读" open=true >}}
第二部分——[**fastfn 第二部分：当一个函数还不够时**]({{< ref "/posts/fastfn-services-when-functions-arent-enough" >}})——从本文结束之处接续：服务、workloads、Firecracker microVM，以及那个让一个函数能在 `postgres.internal` 触及一个 Postgres VM、却从不在主机上暴露端口的 vsock 网络。
{{< /admonition >}}

