C
发布于 2026/09/03 · 阅读 24

Zeroserve:一个可用 eBPF 编写脚本的零配置 Web 服务器

  • #Web服务器
  • #eBPF
  • #HTTPS
  • #性能
  • #反向代理
Zeroserve:一个可用 eBPF 编写脚本的零配置 Web 服务器

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_respondzs_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/sp99
zeroserve36,6815.4 ms
nginx31,2267.8 ms
Caddy12,83022 ms

在单核上,zeroserve 服务小文件比 nginx 快约 17%,尾部延迟更小。HTML 页面、小型 JSON、CSS——这正是 zeroserve 优化的场景。

大型静态文件(100 KB):

服务器req/s吞吐量p99
zeroserve8,000782 MB/s22 ms
nginx7,600773 MB/s28 ms
Caddy6,084590 MB/s44 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/sp99
zeroserve eBPF (10 ms)43,7095.1 ms
zeroserve eBPF (2 ms 默认)31,3346.7 ms
nginx Lua (header_filter)28,6538.4 ms

全动态 JSON 响应:

引擎req/sp99
zeroserve eBPF (10 ms)46,9454.5 ms
nginx Lua (content_by_lua)41,2316.4 ms
zeroserve eBPF (2 ms 默认)32,3936.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 128proxy_http_version 1.1 并清除 Connection 头部),Caddy 则默认复用连接。每个代理在单核上终止 TLS,并转发到一个共享的明文后端,该后端是一个独立的 2 核服务器,自身能维持 100k req/s,因此测量结果隔离了代理自身的开销。

代理小型(174 B)响应:

代理req/sp50p99
zeroserve26,4863.3 ms8 ms
nginx21,7614.2 ms10.5 ms
Caddy7,68310.3 ms33 ms

zeroserve 的 io_uring 连接池代理在此领先,比 nginx 快约 22%(26.5k 对比 21.8k),大约是 Caddy 的 3.4 倍。对于典型的代理工作负载——转发 API 调用、小型 JSON、应用服务器的 HTML——zeroserve 终止 TLS 并将请求传送到后端的速度比参考实现更快。

大响应体则使天平回摆。代理 100 KB 响应:

代理req/s吞吐量
nginx5,882585 MB/s
Caddy4,285406 MB/s
zeroserve3,631380 MB/s
24 阅读0 评论0 点赞

评论

登录 / 注册即可发布评论!
暂无评论,成为第一个发表评论的用户吧。