fastfn 第二部分:当函数不够用时(服务、工作负载,以及我本不想写的 docker-compose)

1 第一章:那份隐痛(或者说,为什么一个函数当不了 Postgres)
在第一部分的结尾,fastfn 是个快乐的小东西。你把 get.users.py 放到磁盘上,网关会把它映射到一个 URL,一个 Python 守护进程会把它拾起来,于是你就有了一个 HTTP 端点,连 Dockerfile 都不用碰。它的优雅,是那种只解决了你一半问题的东西所特有的优雅。
因为接着我试着用它构建一个真正的应用。
而一旦你试图构建一个真正的应用,你的请求/响应原语就不再够用了。你需要状态。你需要一个数据库。你需要那个你同事已经写好的管理后台——它是一个 Next.js 应用,期望在 3000 端口上被 next start,而不是一个启动、计算、然后死掉的无状态 lambda。你需要一个 Redis 来存会话,或者一个 MinIO 来存 blob,又或者——在我的情况下——一个完整成型的 Flask 应用,它根本不可能挤过"一个文件一个 handler"那个针眼。
我盯着这个问题看了好一会儿。然后我说出了那句我一直竭力不想说的话:
函数对于请求/响应是一种绝佳的形态。它对于 Postgres 是一种糟糕的形态。
这让我意识到一件令人不安的事。对于我曾经跑过的每一个业余项目,对这份隐痛的答案都是同一个文件:docker-compose.yml。一个服务是我的应用,另一个是数据库,也许还有一个边车,它们都在一个隐式网络上通信——这个网络是 compose 凭空变出来的。这是一件精美的人体工学作品,我一直在用它。但如果 fastfn 要成为我的本地平台,而我要在 fastfn dev 旁边跑 docker-compose up,那我就会有两套 URL 空间、两套健康模型、两套 CORS 配置、两套认证面。网关之所以存在,整个理由就是要有一个地方了解我的 HTTP。一个并行的 compose 会把这一点彻底拆掉。
所以我面临一个选择。要么我接受 fastfn 处理我应用的一个切片,而别的东西处理其余部分。要么我扩展 fastfn,让那些长生命周期的形态——Postgres、Next.js、Flask——和函数一样活在同一个网关之后。同一个 fastfn.json、同一个健康端点、同一套 CORS、同一套认证,一个统一的 HTTP 面。
我并不打算去写 docker-compose。这篇文章讲的是我如何有意地写了半个 docker-compose。
本文主要围绕的提交是 firecracker-simple-images 分支上的 6a54c11:“Add simple native image apps and services”,2026 年 3 月 31 日,跨 26 个文件 2254 处插入。正是在那里,工作负载这个概念在 fastfn 里有了一等公民的归宿。在那之后的一切——5568b6c 中的 vsock 对等网络、6fd5fec 中的保温(keep-hot)默认值、fd8a6b5 中的防火墙 + 基准测试矩阵——都是同一抽象的演进。
2 第二章:“工作负载"这个词
CGI 时代对我想要的东西的叫法是 “daemon”。systemd 的叫法是 “unit”。Kubernetes 的叫法是 “Deployment” 和 “StatefulSet”。Heroku 的叫法是 “Procfile 条目”。docker-compose 的叫法,令人困惑地,是 “service”。我挑了 workload(工作负载)这个词作为一把伞,然后把它一分为二。
fastfn.json
┌──────────────────────────────────────────────┐
│ │
│ functions-dir: "functions" │
│ │
│ apps: ← public HTTP faces │
│ admin: { ... routes: ["/admin/*"] } │
│ │
│ services: ← private, injected │
│ mysql: { port: 3306, volume: "mysql-data" }│
│ │
└──────────────────────────────────────────────┘这个区分很小,但承重。一个 app 是有公开面的工作负载:它声明 routes,网关把这些路由暴露给外部世界。一个 service 是保持私有的工作负载:其他工作负载和函数可以触达它,但外部世界不能。Postgres 是一个 service。Next.js 管理后台是一个 app。在极端情况下,它们之间唯一的区别就是 routes 字段是否设置,以及网关是否对外宣告它们。
两者都活在同一个文件里,并且都由同一条代码路径校验(cli/internal/workloads/config.go:12-14):
// cli/internal/workloads/config.go:12-15
type Config struct {
Apps []AppSpec `json:"apps,omitempty"`
Services []ServiceSpec `json:"services,omitempty"`
}两个兄弟列表。不是两个世界。同一个归一化器遍历两者,同一个状态写入器持久化两者,同一个 Lua 网关在请求时从同一个 JSON 文件读取两者。
这让我意识到一件几乎微不足道但值得一说的事:一旦你有了一个懂得路由的网关,给它多一种目标类型,并不是一个新项目。它只是表里多一行。
3 第三章:一个工作负载的形态
我们来看看实际的形态。这是带一个 app 和一个 service 的最小 fastfn 配置,取自那个提交里的 README diff(README.md:178-202):
// fastfn.json
{
"functions-dir": "functions",
"public-base-url": "https://api.example.com",
"openapi-include-internal": false,
"apps": {
"admin": {
"image": "ghcr.io/acme/admin:latest",
"port": 3000,
"routes": ["/admin/*"]
}
},
"services": {
"mysql": {
"image": "mysql:8.4",
"port": 3306,
"volume": "mysql-data"
}
}
}有三样东西值得盯着看。第一,镜像来源。第二,端口。第三,路由(仅在 app 上)。其余一切都是可选的旋钮。
镜像来源很有意思,因为有三种有效的声明方式,而配置代码强制你恰好选其中一种(cli/internal/workloads/config.go:383-395):
// cli/internal/workloads/config.go:387-395
sourceCount := 0
for _, value := range []string{image, imageFile, dockerfile} {
if strings.TrimSpace(value) != "" {
sourceCount++
}
}
if sourceCount != 1 {
return fmt.Errorf(
"must set exactly one image source among image, image_file or dockerfile",
)
}所以你的三个选择是:
image:一个像mysql:8.4这样的注册表引用,或者磁盘上一个本地 Firecracker bundle 目录的路径。image_file:一个本地的 OCI 或 Docker 归档,在首次使用时转换为一个被缓存的 Firecracker bundle。dockerfile:一个指向 Dockerfile 的路径,fastfn 会通过 Docker Engine API 构建它,然后同样进行转换。
恰好选一个的规则是承重的。它说"一个工作负载,一个事实来源”。你不能把一个本地 bundle 和一个注册表镜像混在一起然后看哪个胜出。那条路通向你不想要的调试。
端口更简单:它是工作负载监听的容器端口,带一个校验,确保它落在 1..65535 之内。对于 app,路由是一个 URL 前缀数组。路由的默认形式既支持精确匹配(/admin)也支持尾部 glob 匹配(/admin/*)。有一个归一化器会修剪尾部斜杠并强制一个前导斜杠(cli/internal/workloads/config.go:941-953),这样你可能写路由的三种方式最终都变成一个规范字符串。
完整的 AppSpec 比这个最小集大,因为一旦你开始跑真正的应用,你就想要旋钮。这是整个表面(cli/internal/workloads/config.go:66-88):
// cli/internal/workloads/config.go:66-88
type AppSpec struct {
Name string `json:"name"`
ScopeDir string `json:"scope_dir,omitempty"`
Image string `json:"image,omitempty"`
ImageFile string `json:"image_file,omitempty"`
Dockerfile string `json:"dockerfile,omitempty"`
Context string `json:"context,omitempty"`
Port int `json:"port"`
Env map[string]string `json:"env,omitempty"`
Command []string `json:"command,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
User string `json:"user,omitempty"`
Volume *VolumeSpec `json:"volume,omitempty"`
Volumes []*VolumeSpec `json:"volumes,omitempty"`
Healthcheck HealthcheckSpec `json:"healthcheck,omitempty"`
Routes []string `json:"routes,omitempty"`
Replicas int `json:"replicas,omitempty"`
Ports []PortSpec `json:"ports,omitempty"`
Access AccessSpec `json:"access,omitempty"`
ProcessGroups []ProcessGroupSpec `json:"process_groups,omitempty"`
HA *HAConfig `json:"ha,omitempty"`
Lifecycle LifecycleSpec `json:"lifecycle,omitempty"`
}ServiceSpec 几乎完全相同(config.go:90-111),只是少了 Replicas;对 service 来说,是进程组来做副本计数。我曾短暂地动过心思,想把它们统一成一个带 IsPublic bool 的结构体。我很庆幸我没那么做。这两种形态有着微妙不同的校验器和微妙不同的默认值,而试图把它们折叠成一个类型,总是产出三元表达式,而第二个类型则产出清晰。
这份对称隐藏着一个微妙的不对称,它出现在 defaultAppLifecycle 与 defaultServiceLifecycle 中(config.go:658-672):
// cli/internal/workloads/config.go:658-672
func defaultAppLifecycle() LifecycleSpec {
return LifecycleSpec{
IdleAction: "run",
PauseAfterMS: 15000,
Prewarm: true,
}
}
func defaultServiceLifecycle() LifecycleSpec {
return LifecycleSpec{
IdleAction: "run",
PauseAfterMS: 0,
Prewarm: true,
}
}app 得到一个 15 秒的空闲计时器,如果它们哪天选择 pause,就能用上。service 得到的是零——因为你不会去暂停一个 Postgres。一个被暂停的 Postgres 叫"一次故障"。各处的默认策略都是保持运行、保持热、并在启动时预热。两个生命周期模型,一个配置文件,零遗憾。
4 第四章:三种后端,一种配置形态
这里就是工作负载抽象付房租的地方。同一段 JSON 节是要以三种不同方式运行的:
fastfn.json workload
│
├────────┬────────────┬──────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
docker native process Firecracker (future
native (no container) microVM on backends)
(fallback) Linux/KVM跑哪一个,一部分是平台决定,一部分是分支决定。在这个分支上(firecracker-simple-images),Firecracker 是 Linux/KVM 主机上的目标,而 docker_native.go 是回退管理器——注意文件顶部的 build tag(cli/internal/workloads/docker_native.go:1):
//go:build !linux
package workloads这一行干的活比它看上去要多。它说"在非 Linux 上,使用基于 Docker 的工作负载管理器实现"。在 Linux 上,Firecracker 管理器接管(firecracker_manager_linux.go)。两个实现都满足 process/runner.go 接线的同一个内部 nativeImageWorkloadManager 接口:
// cli/internal/process/runner.go (from the 6a54c11 diff)
type nativeImageWorkloadManager interface {
Start(context.Context) error
Stop(context.Context) error
StatePath() string
}三个方法。Start 把所有工作负载拉起来。Stop 把它们关掉。StatePath() 告诉系统其余部分 JSON 状态文件住在哪里,这样 Lua 那一侧就能找到它。就这样。Docker 管理器构建镜像、创建网络、启动容器、并开放发布的端口。Firecracker 管理器构建镜像、把它们转换成 bundle、引导微虚拟机、并把它们接到一个 vsock 对等网络上。机制不同,公开契约相同。
docker-native 管理器,老实说,就是我拒绝去写的那个 docker-compose。它为每个项目创建一个网络(cli/internal/workloads/docker_native.go:90-98):
// cli/internal/workloads/docker_native.go:90-98
networkName := "fastfn-" + shortHash(m.cfg.ProjectDir+m.cfg.StatePath)
_, err := m.cli.NetworkCreate(ctx, networkName, dockertypes.NetworkCreate{
CheckDuplicate: true,
Driver: "bridge",
})
if err != nil {
return fmt.Errorf("create docker network: %w", err)
}
m.networkName = networkName然后对于每个 service 和每个 app,它会用两个别名把容器接进来——裸名字和一个 <name>.internal 别名(docker_native.go:417-423)——这样网络内部的 app 可以触达 mysql.internal:3306,而主机触达 127.0.0.1:<published>。这个拆分是有意的:公开的 app 通过网关在一个稳定的 URL 上触达外部世界,私有的 service 通过内部别名互相触达,而主机为了调试触达已发布的端口。
一个 service 起来时大致是这个样子:
// cli/internal/workloads/docker_native.go:251-266 (condensed)
service := ServiceState{
Name: spec.Name,
Image: firstNonEmpty(spec.Image, spec.Dockerfile),
ImageDigest: digest,
Host: "127.0.0.1",
Port: hostPort,
InternalHost: spec.Name + ".internal",
InternalPort: spec.Port,
ContainerID: containerID,
Health: WorkloadHealth{Up: true, Reason: "ok"},
Volume: spec.Volume,
BaseEnv: cloneEnvMap(spec.Env),
}
service.URL = BuildServiceURL(spec.Name, service.Host, service.Port, spec.Env)
service.InternalURL = BuildServiceURL(spec.Name, service.InternalHost, service.InternalPort, spec.Env)
service.FunctionEnv = BuildFunctionServiceEnv(spec.Name, service, spec.Env)两点观察。第一,BuildServiceURL 检查 env 并推断出 scheme——如果它看到 MYSQL_USER 就构建一个 mysql:// URL,如果看到 POSTGRES_USER 就是 postgres://,如果看到 REDIS_* 就是 redis://,否则回退到 tcp://(state.go:143-157)。这是欢快的 URL 推断,意味着在百分之九十九的情况下我不必手敲 DATABASE_URL。第二,FunctionEnv 是项目里每个函数在请求时都会看到的那一袋变量。这就是那座桥:service 是私有的,但函数会拿到 SERVICE_MYSQL_HOST、SERVICE_MYSQL_PORT、SERVICE_MYSQL_URL,外加一个直接别名 MYSQL_HOST / MYSQL_PORT / MYSQL_URL,当名字没有歧义时(state.go:107-123,state.go:193-203)。
眯着眼看,这正是 docker-compose 用它的 links 和 depends_on 注入所做的事。只不过是用了更紧的 schema 和更锋利的命名约定。
5 第五章:Lua 如何找到一个工作负载
现在是有趣的部分。网关仍然是 OpenResty + Lua——和第一部分是同一个东西。一个位于 CLI 侧的长生命周期 Go 进程,如何告诉网关侧的 Lua 工作负载存在且已就绪?
通过磁盘上的一个 JSON 文件。就这样。
CLI 的工作负载管理器写入一个位于已知路径的状态文件。这个路径被作为一个环境变量导出给网关(process/runner.go,在 6a54c11 diff 中):
// cli/internal/process/runner.go (from 6a54c11)
if path := strings.TrimSpace(workloadMgr.StatePath()); path != "" {
baseEnv = append(baseEnv, "FN_IMAGE_WORKLOADS_STATE_PATH="+path)
}在 Lua 侧,image_workloads.lua 在每次请求时读取这个路径:
-- openresty/lua/fastfn/core/image_workloads.lua:22-48
local function state_path()
local path = tostring(os.getenv("FN_IMAGE_WORKLOADS_STATE_PATH") or "")
if path == "" then
return nil
end
return path
end
local function load_state()
local path = state_path()
if not path then
return { apps = {}, services = {} }
end
local parsed = read_json_file(path)
if type(parsed) ~= "table" then
return { apps = {}, services = {} }
end
parsed.apps = type(parsed.apps) == "table" and parsed.apps or {}
parsed.services = type(parsed.services) == "table" and parsed.services or {}
return parsed
end每次请求读一次 JSON。在生产环境你会用某个小 TTL 缓存它;在开发环境这恰恰是对的做法。健康状态、路由、内部主机名、生命周期状态——全在那个文件里。
路由是下一步。网关对函数已经做了一支长匹配舞步(第一部分讲过)。对于工作负载,它向 image_workloads.lua 索要那些路由能前缀匹配请求路径的候选项,然后按路由长度给它们打分(更长的路由胜出,因为 /admin/api/v1/users 比 /admin/* 更具体):
-- openresty/lua/fastfn/core/image_workloads.lua:123-151
function M.public_http_candidates(request_path)
local state = load_state()
local candidates = {}
for _, kind in ipairs({ "apps", "services" }) do
for _, workload_name in ipairs(sorted_keys(state[kind])) do
local workload = state[kind][workload_name]
for _, endpoint in ipairs(workload_public_endpoints(workload)) do
if tostring(endpoint.protocol or "http") == "http" then
local routes = type(endpoint.routes) == "table" and endpoint.routes or {}
for _, route in ipairs(routes) do
if route_matches(route, request_path) then
candidates[#candidates + 1] = {
kind = kind == "apps" and "app" or "service",
name = workload_name,
workload = workload,
endpoint = endpoint,
route = route,
route_length = #tostring(route),
}
end
end
end
end
end
end
return candidates
end有两个细节我喜欢。外层循环遍历 {"apps","services"} 而不是把它们拼接起来,这在同一路由不知怎么被声明两次时(它不应该,但防御性代码很便宜)保留了 app 高于 service 的优先级。而 route_length 随每个候选项一起带上,这样调用方就能挑出最长的匹配,这与每个理智的路由器解析重叠前缀的方式一致。
网关在请求时消费这个列表,并代理到胜出的端点(openresty/lua/fastfn/http/gateway.lua:950-969):
-- openresty/lua/fastfn/http/gateway.lua:950-969
local request_host, request_authority = request_host_values()
local matched_workload, matched_endpoint, workload_access_err = match_public_workload(
image_workloads.public_http_candidates(request_uri),
request_host,
request_authority,
request_client_ip()
)
if type(matched_workload) == "table" then
local app_resp, app_err = execute_public_workload_proxy(matched_workload, matched_endpoint)
if not app_resp then
local status = 502
if tostring(app_err or ""):find("unavailable", 1, true) then
status = 503
end
write_response(status, { ["Content-Type"] = "application/json" },
json_error("public workload proxy failed: " .. tostring(app_err)))
return
end
write_response(app_resp.status or 502, app_resp.headers or {}, app_resp.body or "")
return
end而这是真正的代理——HTTP 进,HTTP 出,在返回的路上剥掉逐跳(hop-by-hop)头部,这样就没有东西会让客户端困惑(gateway.lua:487-537):
-- openresty/lua/fastfn/http/gateway.lua:510-536
local resp, err = http_client.request({
url = string.format("http://%s:%d%s", host, port, ngx.var.request_uri or "/"),
method = ngx.req.get_method(),
headers = sanitize_app_request_headers(ngx.req.get_headers()),
body = body,
timeout_ms = 30000,
max_body_bytes = 10 * 1024 * 1024,
})
if not resp then
return nil, err
end
local filtered = {}
local drop = {
["connection"] = true,
["keep-alive"] = true,
["transfer-encoding"] = true,
["content-length"] = true,
["upgrade"] = true,
}
for k, v in pairs(resp.headers or {}) do
if not drop[tostring(k):lower()] then
filtered[k] = v
end
end
resp.headers = filtered
return resp十五兆字节的最大请求体。三十秒超时。逐跳头部被丢弃。这是你读过的最无聊的 HTTP 反向代理,而这正是你希望一个网关该有的样子。
聪明之处不在代理里。聪明之处在于解析函数的同一个 ngx 阶段,现在也解析工作负载,带着已知的优先级,从同一个 JSON 状态文件,使用同一套基于 host/CIDR 的防火墙规则。整个东西得以成为一个统一的 HTTP 面。
6 第六章:生命周期(或者说,把一张表变成一台状态机)
一旦工作负载存在,它们就得走过一段人生。启动中。健康。不健康。已停止。已暂停。已恢复。生命周期让运维这一面的叙事保持诚实。
┌────────────┐ start() ┌─────────────┐ healthcheck ok ┌─────────┐
│ declared │ ──────────▶ │ starting │ ─────────────────▶ │ healthy │
└────────────┘ └─────────────┘ └────┬────┘
│ │
│ healthcheck fails │ monitor tick
▼ │ reports failure
┌─────────────┐ ▼
│ unhealthy │ ◀──────────────── ┌─────────┐
└──────┬──────┘ │ flapping│
│ stop() └─────────┘
▼
┌───────────┐
│ stopped │
└───────────┘在 state.go 里,这些部件住在一小簇类型中(state.go:23-91):
// cli/internal/workloads/state.go:23-38
type WorkloadHealth struct {
Up bool `json:"up"`
Reason string `json:"reason,omitempty"`
}
type PublicEndpointState struct {
Name string `json:"name"`
Protocol string `json:"protocol,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
ContainerPort int `json:"container_port,omitempty"`
ListenPort int `json:"listen_port,omitempty"`
Routes []string `json:"routes,omitempty"`
AllowHosts []string `json:"allow_hosts,omitempty"`
AllowCIDRs []string `json:"allow_cidrs,omitempty"`
}健康信号是一个两字段的结构体——up,以及当它不为真时的一个 reason。每个工作负载都保有一个。docker-native 管理器跑着一个监控 goroutine,每两秒醒一次,检查每个容器,并在任何东西变化时更新状态文件(docker_native.go:174-211):
// cli/internal/workloads/docker_native.go:174-211 (condensed)
func (m *NativeManager) monitor() {
defer close(m.doneCh)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-m.stopCh:
return
case <-ticker.C:
changed := false
for _, item := range m.containers {
health := m.inspectHealth(item)
// ... update m.state.Apps or m.state.Services,
// setting changed = true when different
}
if changed {
_ = m.writeState()
}
}
}
}仅在变化时写入的行为很重要。Lua 那一侧在每次请求时读那个状态文件。如果 Go 那一侧无条件地每两秒往文件里灌,那每次请求都会看到一个新文件,每一次小小的 fs 读取都被浪费。只在某样东西真正动了的时候才写,能让文件在很长的区段里保持稳定,这正是你想从一个 IPC 机制那里得到的东西——往好里说,它是个 JSON 文件。
启动是有意串行的:service 先起来,然后是 app(docker_native.go:100-128)。app 启动时其环境里已经填好了 service 的 env——这正是配置文件那份干净的对称性把自己确立为一个依赖顺序的时刻。service 之所以存在是为了让 app 能用它们;因此 service 先启动。
CLI 的入口点在 dev 和 run 里是同一段无聊的两行。对于 dev,出自 6a54c11 diff:
// cli/cmd/dev.go (from 6a54c11 diff)
imageWorkloads, hasImageWorkloads, err := configuredImageWorkloads()
if err != nil {
devFatalf("Invalid apps/services config: %v", err)
return
}
// ...
if err := runNative(configuredProjectRoot(), absPath, imageWorkloads); err != nil {
devFatalf("Native dev failed: %v", err)
return
}
// ...
if hasImageWorkloads {
devFatal("apps/services are only supported in native mode for this branch; rerun with --native")
return
}最后那个分支很重要。在这个分支上,app 和 service 只在 --native 下工作;经典的 Docker 开发模式仍然是仅函数的。这是一个已知限制,在配置参考中有明确文档(docs/en/reference/fastfn-config.md:13)。在旧的、基于 Docker 的网关路径里混合这两种生命周期,是那种我决定推迟的、剃牦牛毛式的活儿。原生模式是这个特性向前的唯一诚实的路。
configuredImageWorkloads 这个 helper 是粘合剂。它从 viper 里读出 apps 和 services 键,并通过 workloads.NormalizeAppSpecs / NormalizeServiceSpecs 把它们归一化(cli/cmd/root.go:171-186):
// cli/cmd/root.go:171-186 (from 6a54c11 diff)
func configuredImageWorkloads() (workloads.Config, bool, error) {
var cfg workloads.Config
apps, appsSet, err := workloads.NormalizeAppSpecs(viper.Get("apps"))
if err != nil {
return cfg, false, err
}
services, servicesSet, err := workloads.NormalizeServiceSpecs(viper.Get("services"))
if err != nil {
return cfg, false, err
}
cfg.Apps = apps
cfg.Services = services
return cfg, appsSet || servicesSet, nil
}这就是全部接线。viper 读 fastfn.json,工作负载包归一化它,而原生 runner 拉起一个管理器,它的 StatePath() 被盖印进每个运行时守护进程和每个 OpenResty worker 的环境里。
7 第七章:Firecracker 的转折
到这个时候,我描述的一切都能在一台装了 Docker 的笔记本上工作,而我描述的一切都不需要 Firecracker。但整个分支叫 firecracker-simple-images,所以让我告诉你 Firecracker 在哪里登场,以及实际改变了什么。
在 Linux/KVM 主机上,管理器不是 docker_native.go。是 firecracker_manager_linux.go。同一个接口。同一个状态文件。底层的机制非常不同:OCI 镜像被转换成一个 Firecracker bundle(一个 vmlinux 内核加一个 rootfs.ext4),bundle 被缓存在 .fastfn/firecracker/images/ 下,而一个微虚拟机以一份最小内核配置引导。公开契约是相同的:工作负载在它声明的端口上监听,网关代理到它,状态文件报告健康。
不同之处——而这正是提交 5568b6c(“Add vsock peer networking for Firecracker workloads”)重要的地方——在于网关实际上如何触达一个 Firecracker 客户机。一个普通的 Docker 容器在一座桥上发布一个端口,而你拨 127.0.0.1:<hostport>。一个 Firecracker 微虚拟机默认没有这样的便利。于是这个提交引入了一个基于 vsock 的对等网络:一个客户机侧的 helper(cli/internal/firecrackerguest/main.go,在那个提交里有 455 行)负责终结 vsock,以及一个主机侧的 private_network.go 把这些部件缝合起来。网关仍然拨一个 InternalHost:InternalPort;那个 host/port 之下的管路是 vsock,而不是桥上的 TCP。抽象成立了。
后续的提交 6fd5fec(“Keep Firecracker image workloads hot by default”)是让这一切在实践中变得有用的那一个。没有它,一个刚引导起来的 Firecracker 工作负载在第一次请求时感觉很棒,但如果哪天被暂停就没那么棒了。有了保温(keep-hot)默认值,工作负载保持常驻,并在 fastfn dev / fastfn run 启动时被预热。文档把这叫做 “speed-first”(docs/en/reference/fastfn-config.md:302-307):idle_action 默认为 run,prewarm 默认为 true,对 app 和 service 都是如此。
真实的数字——而这些是从 docs/en/explanation/performance-benchmarks.md 里的基准测试矩阵复制粘贴过来的——是我唯一愿意把这其中任何东西称为完成的理由:
| Case | Source | Build/Pull | First OK | Hot p50 | Hot p95 | Hot p99 | Same PID |
|---|---|---|---|---|---|---|---|
Flask (flask-compose) | Dockerfile repo | 1168ms | 5017ms | 1.94ms | 3.05ms | 4.10ms | true |
Registry app (traefik/whoami:v1.10.2) | Registry image | 98ms | 2508ms | 1.26ms | 2.09ms | 2.28ms | true |
FastAPI + Postgres (fastapi-realworld) | Dockerfile repo + private service | 1202ms | 17036ms | 5.29ms | 7.02ms | 7.94ms | true |
Two equal postgres:16 services | Same OCI, same native 5432 | 1246ms | 22090ms | 10.92ms | 28.85ms | 32.58ms | true |
Rust + Postgres (rust-postgres) | Dockerfile repo + private service | 35139ms | 47602ms | 2.66ms | 3.86ms | 10.27ms | true |
一个二十个案例的矩阵中的五行(2026 年 4 月 1 日的快照;完整列表在 docs/en/explanation/performance-benchmarks.md:46-54)。我对这张表的解读是:
- 冷构建 + 预热是数秒,对一个 Rust 构建有时是数十秒。那不是免费的。但它只发生一次。
- 预热之后,热路径对轻量级 app 是低个位数毫秒,对有数据库支撑的仍然是个位到低双位数毫秒。
- 每一行里
same_firecracker_pid = true,这意味着热循环真的在复用同一个常驻微虚拟机。网关并没有在请求之间悄悄地重新生成 Firecracker。 - 两个相同的
postgres:16service 可以共享同一个原生 5432,只要它们的工作负载名字不同。私有网络是按工作负载划分的;端口是在其各自客户机内部按进程划分的。
那个诚实的注意事项——文档自己也承认,我也乐意重复(docs/en/explanation/performance-benchmarks.md:94-139)——是这个 20 案例的矩阵并非"二十个零改动的上游应用"。有些案例是上游原样。有些在上面加了一层基准测试覆盖。所有案例共享同一条 FastFN 运行时路径,但这套测试装置对它们当中的全部并非一个零接触的基准测试。我宁愿把这话大声说出来,也不愿给它上一层清漆。
而且没错,fd8a6b5(“Add image workload firewall and benchmark matrix”)就是那个既带来了公开端口上 allow_hosts / allow_cidrs 访问控制、又带来了产出这些数字的工具的提交。我会在后面的文章里讲防火墙——它本身就是一整套美学——但简短版本是:一个公开的 app 可以被锁到一个 host 白名单和/或一个 CIDR 白名单上,两者都展示在我在第五章引用的那个网关打分函数里。
8 第八章:函数遇见 Service(注入的故事)
回到我一带而过的一个细节。一个函数究竟是如何看到一个 service 的?
通过 env。当一个 service 引导起来时,管理器调用 BuildFunctionServiceEnv 并把结果藏进 service 的状态里(state.go:107-123):
// cli/internal/workloads/state.go:107-123
func BuildFunctionServiceEnv(serviceName string, service ServiceState, baseEnv map[string]string) map[string]string {
out := map[string]string{}
appendScopedServiceEnv(out, serviceName, baseEnv)
upper := serviceEnvToken(serviceName)
out["SERVICE_"+upper+"_HOST"] = service.Host
out["SERVICE_"+upper+"_PORT"] = fmt.Sprintf("%d", service.Port)
out["SERVICE_"+upper+"_URL"] = service.URL
out["SERVICE_"+upper+"_INTERNAL_HOST"] = service.InternalHost
out["SERVICE_"+upper+"_INTERNAL_PORT"] = fmt.Sprintf("%d", service.InternalPort)
if strings.TrimSpace(service.InternalURL) != "" {
out["SERVICE_"+upper+"_INTERNAL_URL"] = service.InternalURL
}
appendDirectServiceAlias(out, serviceName, service.Host, service.Port, service.URL)
return out
}在 Lua 侧,当一个函数即将被调用时,网关把所有 service 的 FunctionEnv 的并集拉进它传给运行时守护进程的事件信封里(openresty/lua/fastfn/http/gateway.lua:1169-1174):
-- openresty/lua/fastfn/http/gateway.lua:1171-1174
local service_env = image_workloads.function_env()
if next(service_env) ~= nil then
event.env = service_env
end这意味着一个 Python 函数可以直接这么写:
# a handler somewhere in functions/
import os
host = os.environ["SERVICE_MYSQL_HOST"]
port = int(os.environ["SERVICE_MYSQL_PORT"])
url = os.environ["SERVICE_MYSQL_URL"]……而且它能工作,无论背后的 MySQL 是 macOS 上的一个 Docker 容器还是 Linux 上的一个 Firecracker 微虚拟机。相同的变量名,相同的 URL scheme 推断,相同的代码。后端的改变是不可见的。
还有第二条路径。一个 app 需要在进程启动时就拿到 service 的 env,而不是在请求时——因为一个 Next.js 应用是在 next start 时读 process.env,而不是每次请求都读。于是 docker-native 管理器从所有 service 构建一个 appServiceEnv 映射,并把它作为每个 app 的容器 env 传进去(docker_native.go:110-122,282-294)。因此 app 看到的是同样的 SERVICE_* 变量,但作用域是经由 BuildAppServiceEnv 而不是 BuildFunctionServiceEnv——区别在于你用的是内部主机名(对 app 而言,它们活在同一个私有网络上)还是公开 host(对函数而言,它们活在主机上)。
service discovery fan-out
────────────────────────
┌──────────────┐ BuildFunctionServiceEnv
│ service │ ───────────────────────────▶ event.env
│ (e.g. mysql)│ │
└──────┬───────┘ ▼
│ ┌─────────────┐
│ │ function │
│ BuildAppServiceEnv └─────────────┘
└──────────────────────▶
container.Env
│
▼
┌──────────┐
│ app │
└──────────┘两个消费者。一个事实来源。看不到任何手写的 DATABASE_URL。
9 第九章:我真正带走的那些教训
回头看那份 2254 行的 diff,这是我会对开始之前的过去的自己说的话。
统一网关是整件事的要点。 把 HTTP 切成"fastfn 处理函数 URL"和"docker-compose 处理其他一切"的诱惑是巨大的,因为它是阻力最小的路。但 HTTP 面的每一次切分,都是 CORS、认证、可观测性或 OpenAPI 上未来的一个 bug。让网关保持单一。给它更多种类的目标,而不是更多的朋友。
两个生命周期模型可以共存,只要有一个配置文件来做编译。 函数是请求作用域的。工作负载是长生命周期的。这些是真正不同的形态,有不同的失败模式。把它们俩都藏在一个 fastfn.json 之后之所以行得通,是因为配置层把两种形态都编译成同一个"网关代理到的那个东西"的运行时表示。两个生命周期模型,一个配置文件,零遗憾。
恰好声明一次镜像来源。 image/image_file/dockerfile 上恰好选一个的规则,在第一天看起来很迂腐,在第两百天却把你从不可读的错误消息里救出来。你想要的是:有一种唯一的方式,让一个盯着 fastfn.json 看的工程师能判断出这个工作负载是从哪里来的。
一个 JSON 文件是你的 CLI 和你的网关之间一个相当好的 IPC 面,只要你小心。 只在变化时写、按请求读、并通过一个 env 变量导出路径——这与我的先验相反——是一种极其平静的方式,在一个长生命周期的 Go 进程和一个长生命周期的 OpenResty 进程之间搬运信息。存在一个未来,这会变成一个 unix socket 和一个订阅模型。但目前,这个文件是诚实的,而且用 cat 就能轻松调试。
按 service 本身是什么来给它命名。 我差点就没有在 SERVICE_<NAME>_HOST 这些之外自动生成 MYSQL_HOST / MYSQL_URL 别名。然后我写了我第一个真正的 handler,记起人类并不想敲 SERVICE_MYSQL_HOST。直接别名是一个伪装成命名约定的可用性特性。
基准测试让你保持诚实。 上面那张矩阵表是我相信自己文档里"常驻"和"热"这些字眼的唯一理由。same_firecracker_pid = true 意味着没有人在我的请求和我的 p95 之间偷偷重启 VM。这个检查之所以存在,是因为早期它不为真,而基准测试是唯一告诉我这一点的东西。度量那个性质,而不是那个意图。
还有,关于种种保留意见。 在这个分支上有很多我刻意没做的东西。app 和 service 只在 --native 下工作。Firecracker 工作负载只在 Linux/KVM 上工作。macOS 和 Windows 用户目前拿到的是 Docker 管理器而非 Firecracker,而那条路径在这个分支上,除了原生模式之外,根本不跑 image workload。滚动更新、蓝/绿、自动扩缩——全部缺席,而它们缺席是没问题的,因为这个阶段的要点是把抽象钉牢,而不是把运维钉牢。一个能干净地做三种后端的工作负载管理器,远比一个用 Kubernetes 曾经发布过的每一个特性来做一种后端的工作负载管理器更有价值。
接下来是什么?有两个方向我能清楚地看到。一个是把工作负载路径带进 Docker 模式的 fastfn dev,这样 macOS 用户不用 Firecracker 也能得到同样的人体工学。另一个是让防火墙更丰富——超越 allow_hosts 和 allow_cidrs,变成某种能表达"这个 service 只能从这个私有网络上这些特定的工作负载触达"的东西。这两个都感觉像是真正的新篇章,而不是脚注。
目前,这个标题很小、很无聊、也很正确:如果你需要在函数旁边放一个 Postgres,你编辑你的 fastfn.json,加上一个 services.postgres 节,你的函数就在它们的环境里拿到 SERVICE_POSTGRES_URL。就这样。
函数对于请求/响应是一种绝佳的形态。工作负载是对于其余一切的形态。而现在它们活在同一个配置文件、同一个网关、同一个健康端点、同一个基准测试矩阵里,带着同样的保温默认值。
我本不想去写 docker-compose。我最后写出来的东西,与我实际用到的那十分之一的 docker-compose 相押韵,别的什么也没有。说实话,这大概是我职业生涯里第一次,我构建出来的平台比我以为我会构建的更少。
第三部分见,我会把防火墙的故事、vsock 的管路,以及保温默认值逐一拆开细讲。在那之前:一个文件,一条路由,一个工作负载,一个网关。