Zeroserve:一个可用 eBPF 编写脚本的零配置 Web 服务器
- #Web服务器
- #eBPF
- #HTTPS
- #性能
- #反向代理
zeroserve 是一款小巧、快速、零配置的 HTTPS 服务器。你只需将一个网站的 tarball 交给它,它就能通过 HTTP/2 和 TLS 1.3 提供服务,支持热重载,并且内存占用极小。它的独特之处在于,你可以在 tarball 中放入 eBPF 程序,这些程序会在每次请求时以用户态沙盒中间件的形式运行——重写、认证、限流请求,或反向代理到后端,作为应用前端的网关。
简而言之:
- 快速:单核下,在大多数工作负载(小/大静态文件、脚本中间件、小响应代理,全部基于 HTTPS)中超越 nginx。
- 高效的 eBPF 脚本:脚本被 JIT 编译为本地代码,在用户态沙盒中运行,成本足够低,可在每次请求时执行。
- 程序即配置:你的 eBPF 程序就是整个配置,决定每个请求的处理方式。
- 全 io_uring:所有网络和磁盘操作都通过 io_uring 提交。
- 内置现代 TLS:TLS 1.3、HTTP/2、加密客户端问候(ECH)、SNI 证书选择和 JA4 指纹识别。
- 简单运维:从单个 tarball 提供整个站点,并通过 SIGHUP 热重载(包括 TLS 材料)。
它旨在成为 nginx 和 Caddy 的替代品,其设计赌注在于配置。那些服务器提供声明式配置语言——location 块、重写规则、map 指令、try_files——然后,当声明式语言达到极限时,再在侧面附加一个可选的脚本运行时(Lua 或 Caddy 的插件)。行为最终被分割成两层:指令悄悄发展出自己的控制流,加上你在脑中必须记住的、在请求生命周期某处运行的脚本。zeroserve 将其合并为一件事。没有配置文件。eBPF 程序就是配置——一个单一的、普通的、沙盒化的程序,它看到每个请求并决定发生了什么:路由、头部、认证、限流、代理。我希望整个请求路径都在一个可以自上而下阅读的程序中。
单个 tarball,原地服务
整个站点是一个单独的 tar 文件。zeroserve 在加载时建立索引——构建路径到字节范围的映射——然后通过向 tarball 本身发出字节范围读取来提供文件。没有任何内容被解压到磁盘。站点完全存在于那个单一文件中,因此没有文档根目录会被遗漏的 location 规则暴露,部署就是一次原子化的文件交换。
打包目录:
zeroserve --pack ./public > site.tar
zeroserve --addr 0.0.0.0:8080 site.tar
部署新版本就是“替换 tarball 并发送 SIGHUP”。重载会原子性地交换站点、脚本和 TLS 材料,在同一进程中,不中断任何连接:
killall -SIGHUP zeroserve
所有网络和磁盘 I/O 都通过 io_uring(借助 monoio 运行时)。每个实例都是一个单线程事件循环。这听起来像是一个限制,就单进程而言确实如此——但当你的扩展单元是“更多进程”时,这反而是正确的形态,这也是为什么多个实例可以在一台机器上愉快共存。
在用户态使用 eBPF 编写脚本
这是我觉得最有趣的部分。你放在 .zeroserve/scripts/ 下的任何 .c 文件,都会在打包时(通过 clang 和 llc)被编译为 eBPF 对象,并在每次请求时运行。eBPF 完全在用户态运行:zeroserve 将字节码加载到其自身普通无特权进程内的运行时(async-ebpf)中,因此内核的 BPF 子系统和 CAP_BPF 完全不参与。async-ebpf 将字节码 JIT 编译为本地机器码(它集成了 uBPF),因此你的“配置”以原生 x86-64 运行。指针笼(pointer cage)替代了内核验证器的工作,防止程序读取或写入不应访问的内存:JIT 编译代码中的每次内存访问都被掩码到程序自己的竞技场中,因此偶发的访问被限制在脚本自己的内存内。
脚本直接在 zeroserve 的单一事件循环上运行。为防止一个慢脚本阻塞所有其他连接,运行时是完全可抢占的:一个定时器可以中断正在执行的 JIT 编译本地代码,并将控制权交还给事件循环。
编程模型是一个脚本链,按文件名排序执行,共享一个每请求元数据映射。如果脚本调用 zs_respond 或 zs_reverse_proxy,则链短路。
以下是一个首先运行并丰富每个请求的脚本示例:
#include <zeroserve.h>
ZS_ENTRY zs_u64 entry(void) {
char peer[64];
if (zs_req_peer(peer, sizeof(peer)) <= 0)
zs_strcpy(peer, "unknown");
// 为 HTML 模板传递发布值
zs_meta_set(ZS_STR("visitor"), ZS_STR(peer));
// 为 *每个* 响应附加头部:静态文件、zs_respond、代理
zs_meta_set(ZS_STR("zs.response.header.x-served-by"), ZS_STR("zeroserve-ebpf"));
return 0;
}
它设置的元数据有两项功能:zs.response.header.* 下的键成为所有响应的响应头部;其他键则供给一个微型模板传递:HTML 文件中的 <zs-meta>visitor</zs-meta> 占位符会在输出时被替换。这样你就无需模板引擎即可获得动态感十足的静态页面。
脚本可调用的辅助函数非常丰富:
- 请求检查与修改:读取方法、路径、查询参数、头部和对等地址;重写 URI,或在响应发出前设置和移除头部。
- 加密与编码:SHA-256、HMAC-SHA256、base64、hex 和 getrandom。
- JSON:解析请求体、构建和修改文档树,并通过
zs_json_respond回复。 - 限流:基于从对等 IP 到 API 密钥的任何内容的每密钥令牌桶,状态在热重载后依然保持。
- AWS SigV4:用于与 S3 和其他 AWS 服务通信的签名 Authorization 头部和预签名 URL。
- OIDC 登录:完整的依赖方流程(授权码 + PKCE),通过密封的 XChaCha20-Poly1305 cookie 携带整个登录会话,因此你可以在服务器保持无状态的同时为静态网站设置“Google 登录”门控。
一个动态端点只是一个响应脚本:
ZS_ENTRY zs_u64 entry(void) {
char path[64];
zs_req_path(path, sizeof(path));
if (zs_strcmp(path, "/health") != 0)
return 0;
zs_meta_set(ZS_STR("zs.response.header.content-type"), ZS_STR("application/json"));
zs_respond(200, ZS_STR("{\"status\":\"ok\"}\n"));
return 0;
}
每个脚本在内存占用上限(默认 256 KB)下运行,运行时会对长时间运行的脚本进行时间切片并将其从执行器中移除,并抑制失控脚本,脚本之间甚至可以互相调用(zs_call),调用深度有上限。一个无限循环的脚本只会阻塞自己的请求——抢占定时器会中断它,服务器继续为其他所有请求服务。
底层的 TLS 故事比零配置框架所暗示的更完整:仅 TLS 1.3,由 BoringSSL 终止,原生支持加密客户端问候(因此真实 SNI 永不以明文出现)、来自目录的 SNI 证书选择、向脚本暴露 JA4 客户端指纹,以及一个透明的 ECH 中继模式,该模式逐字节地将无法解密的握手转发到真正的上游,从而使受保护名称隐藏在公开名称之后。在一个单一的零配置二进制文件中提供了如此多的传输安全性。
它有多快?
我在 8 核 Ryzen 7 3700X 上,通过 HTTPS 对 zeroserve 与 nginx 1.26 和 Caddy 2.11 进行了基准测试,每台服务器提供相同的内容和相同的自签名证书。由于 zeroserve 实例被设计为单线程,唯一公平的比较是按核心:我用 taskset 将每个服务器固定到一个 CPU(nginx 设为 worker_processes 1,Caddy 设为 GOMAXPROCS=1;zeroserve 已经是单线程),并从其他核心使用 wrk -t4 -c100 驱动负载,取三次 10 秒运行的中位数。wrk 使用 HTTP/1.1,因此这些是 HTTP/1.1-over-TLS-1.3 的数字,握手在长连接上摊销:即服务已经打开的 HTTPS 连接的稳态成本。
小型静态文件(174 B)——静态网站的基本功:
| 服务器 | req/s | p99 |
|---|---|---|
| zeroserve | 36,681 | 5.4 ms |
| nginx | 31,226 | 7.8 ms |
| Caddy | 12,830 | 22 ms |
在单核上,zeroserve 服务小文件比 nginx 快约 17%,尾部延迟更小。HTML 页面、小型 JSON、CSS——这正是 zeroserve 优化的场景。
大型静态文件(100 KB):
| 服务器 | req/s | 吞吐量 | p99 |
|---|---|---|---|
| zeroserve | 8,000 | 782 MB/s | 22 ms |
| nginx | 7,600 | 773 MB/s | 28 ms |
| Caddy | 6,084 | 590 MB/s | 44 ms |
三者在此接近,zeroserve 以单核约 780 MB/s 略微领先。nginx 处理大文件的通常王牌是 sendfile(),它将页面缓存中的文件页零拷贝拼接到套接字。但在 TLS 下,该路径未被使用:字节无论如何都必须在用户态加密(除非使用内核 TLS,而三者均未启用),因此每个服务器都受限于相同的加密-写入循环,而 zeroserve 的 io_uring 读写路径在此稍快。
eBPF vs Lua
脚本方面明显比较的是 nginx + LuaJIT(ngx_http_lua_module),这是在 Web 服务器内运行快速代码的常用方式。因此我为两种情况编写了等效的 Lua 代码,并进行了正面比较。
这里有一个调节旋钮非常重要。zeroserve 附带一个保守的默认值:它每 2 毫秒触发脚本抢占定时器。细粒度使其能够快速限制行为异常的脚本,但也会对每个行为正常的脚本造成负担——在默认值下,eBPF 在全动态响应上落后于 nginx Lua(约 32k req/s 对比 41k)。将 --preempt-timer-interval-ms 提升到 10,可以恢复约 40% 的脚本吞吐量,并扭转局面:
每次请求的头部注入中间件(脚本运行,静态文件仍被服务):
| 引擎 | req/s | p99 |
|---|---|---|
| zeroserve eBPF (10 ms) | 43,709 | 5.1 ms |
| zeroserve eBPF (2 ms 默认) | 31,334 | 6.7 ms |
| nginx Lua (header_filter) | 28,653 | 8.4 ms |
全动态 JSON 响应:
| 引擎 | req/s | p99 |
|---|---|---|
| zeroserve eBPF (10 ms) | 46,945 | 4.5 ms |
| nginx Lua (content_by_lua) | 41,231 | 6.4 ms |
| zeroserve eBPF (2 ms 默认) | 32,393 | 6.7 ms |
在 10 ms 间隔下,调优后的 eBPF 在两种情况下都胜出。在中间件案例中——一个塑造原本静态响应的脚本——它比 nginx Lua 快约 50%,尾部延迟更小。在全合成响应中,它也超过了 nginx 经过大量调优的 content_by_lua(47k 对比 41k)。两个引擎都编译为本地代码(LuaJIT 是跟踪 JIT;async-ebpf 通过 uBPF JIT 编译 eBPF),并且由于 TLS 加密是每次请求的共享成本,调优后的 eBPF 路径在吞吐量上领先。在 2 ms 默认值下,eBPF 保持了中间件的胜利,但放弃了合成响应的领先地位,因此我会在生产脚本中使用 10 ms。
作为反向代理
服务文件是工作的一半;另一半是代理到后端,这也是大多数人首先求助于 nginx 或 Caddy 的主要原因。zeroserve 通过脚本实现——zs_reverse_proxy("http://127.0.0.1:9000")——并维护一个上游连接池(每个后端最多 128 个,空闲 30 秒),在请求间复用。
要在这里实现公平比较需要小心:nginx 著名的默认行为是在每个请求后关闭上游连接,因此需要显式启用 keep-alive(keepalive 128、proxy_http_version 1.1 并清除 Connection 头部),Caddy 则默认复用连接。每个代理在单核上终止 TLS,并转发到一个共享的明文后端,该后端是一个独立的 2 核服务器,自身能维持 100k req/s,因此测量结果隔离了代理自身的开销。
代理小型(174 B)响应:
| 代理 | req/s | p50 | p99 |
|---|---|---|---|
| zeroserve | 26,486 | 3.3 ms | 8 ms |
| nginx | 21,761 | 4.2 ms | 10.5 ms |
| Caddy | 7,683 | 10.3 ms | 33 ms |
zeroserve 的 io_uring 连接池代理在此领先,比 nginx 快约 22%(26.5k 对比 21.8k),大约是 Caddy 的 3.4 倍。对于典型的代理工作负载——转发 API 调用、小型 JSON、应用服务器的 HTML——zeroserve 终止 TLS 并将请求传送到后端的速度比参考实现更快。
大响应体则使天平回摆。代理 100 KB 响应:
| 代理 | req/s | 吞吐量 |
|---|---|---|
| nginx | 5,882 | 585 MB/s |
| Caddy | 4,285 | 406 MB/s |
| zeroserve | 3,631 | 380 MB/s |
评论