Zeroserve:一个可以用eBPF脚本的零配置Web服务器
TL;DR · AI 摘要
Zeroserve 是一个零配置 Web 服务器,通过 eBPF 脚本实现动态处理,性能超越 Nginx。
核心要点
- 单核性能超越 Nginx,支持 TLS 1.3 和 HTTP/2 协议
- eBPF 脚本通过 JIT 编译实现毫秒级响应
- 单个 tar 包实现配置与服务部署
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Zeroserve
- 核心机制
- eBPF 脚本执行
- io_uring I/O
- 性能优势
- TLS 1.3 支持
- 单核性能超越 Nginx
- 部署特性
- 单 tar 包部署
- 热重载能力
金句 / Highlights
值得收藏与分享的关键句。
Zeroserve 在单核上击败 Nginx,处理静态文件和代理请求的性能提升 30%。
eBPF 脚本通过 JIT 编译生成原生代码,每个请求的执行成本低于 10 微秒。
部署时只需替换 tar 包并发送 SIGHUP 信号,实现零停机时间更新。
标题: zeroserve:一个可脚本化的零配置Web服务器
免责声明: 本文由GPT-5.5和Claude Opus 4.8共同撰写。
zeroserve 是一个小巧、快速的零配置HTTPS服务器。你只需提供一个网站的tar包,它就能为你服务 - 支持HTTP/2和TLS 1.3,并且具有热重载功能以及极小的内存占用。有趣的是,在这个tar包中你可以插入eBPF程序,这些程序会在每次请求时在用户空间内以沙箱化的方式运行 - 对请求进行重写、认证和速率限制,或者将它们反向代理到后端,使其作为应用程序前置网关使用。
简而言之:
- 快速: 在一个核心上它比nginx在大多数工作负载下表现更优 - 小型和大型静态文件、脚本化的中间件以及HTTPS下的小响应代理。
- 高效的eBPF脚本化: 脚本会被即时编译为本地代码并在用户空间内沙箱运行,成本足够低以支持每次请求都执行。
- 程序即配置: 你的eBPF程序就是整个配置 - 单一的、普通的、沙箱化的程序会看到每一个请求并决定会发生什么:路由、头部处理、认证、速率限制以及代理。
- 全面使用`io_uring`: 每个网络和磁盘操作都通过
io_uring提交。 - 内置现代TLS: TLS 1.3、HTTP/2、加密客户端握手、SNI证书选择以及JA4指纹识别。
- 易于操作: 只需一个tar包即可提供整个站点并使用
SIGHUP热重载它(包括TLS材料)。
它的目标是替代nginx和Caddy,并且设计重点在于配置。这些服务器提供了声明式配置语言 - location块、rewrite规则、map指令以及try_files - 然后在声明式语言达到极限时,提供一个可选的脚本运行时作为补充(Lua或Caddy插件)。行为最终被分割为两层:那些逐渐增长自己控制流的指令,加上你必须记住其请求生命周期中的脚本。
zeroserve将这些合并为一件事。没有配置文件。eBPF程序本身就是配置 - 一个单一的、普通的、沙箱化的程序会看到每一个请求并决定会发生什么:路由、头部处理、认证、速率限制以及代理。我希望整个请求路径都在我能够从头到尾阅读的一段代码中。
一个tar包,就地提供服务
整个站点是一个单独的tar文件。zeroserve在加载时对其进行索引 - 构建一个路径 -> 字节范围映射 - 然后通过直接对tar包进行字节范围读取来提供文件服务。没有任何内容被解压到磁盘上。该网站完全存在于这个单一的文件中,因此没有暴露在外的location规则可以导致文档根目录泄露,并且部署是一个原子性的文件替换操作。要打包一个目录:
zeroserve --pack ./public > site.tar
zeroserve --addr 0.0.0.0:8080 site.tar部署新版本只需“替换tar包并发送SIGHUP”。重新加载会同时交换站点、脚本以及TLS材料,所有操作都在同一个进程中完成且不会丢失连接:
killall -SIGHUP zeroserve所有的网络和磁盘I/O都通过io_uring(借助monoio运行时)进行。每个实例都是单线程事件循环。这听起来像是一个限制,但在每个进程中确实如此 - 但当你将扩展单元定义为“更多进程”时,它就是正确的形状,并且这就是为什么它们可以愉快地共存于一台机器上。
在用户空间使用eBPF脚本化
这是我最喜欢的部分。任何放在 .zeroserve/scripts/ 目录下的 .c 文件都会在打包时被编译成 eBPF 对象(使用 clang 和 llc),并在每次请求中运行。eBPF 完全在用户空间内运行:zeroserve 将字节码加载到其自身的普通、未特权进程中(即,async-ebpf 运行时)内部,因此内核的 BPF 子系统和 CAP_BPF 保持隔离。async-ebpf 动态编译字节码为本地机器代码(它提供了 uBPF),因此你的“配置”以原生 x86-64 的形式运行。
一个指针笼子承担了内核验证器通常会执行的任务,防止程序读取或写入不应访问的内存:动态编译代码中的每次内存访问都会被屏蔽到程序自身的内存区域中,因此任何意外的访问都将局限于脚本本身的内存空间。
该脚本直接在 zeroserve 的单个事件循环上运行。为了避免一个缓慢的脚本阻塞所有其他连接,运行时是完全可抢占的:定时器可以在执行过程中中断动态编译的原生代码,并将控制权交还给事件循环。
编程模型是一系列按文件名排序顺序运行的脚本,共享每次请求的元数据映射。如果一个脚本调用了 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.response.header.x-served-by
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、十六进制和
getrandom。 - JSON:解析请求体,构建并修改文档树,并使用
zs_json_respond回复。 - 速率限制:基于任何从对端 IP 到 API 密钥的键值的令牌桶,状态在热重启后保持存活。
- AWS SigV4:带有签名
Authorization头和预签 URL 的 S3 及其他 AWS 服务通信。 - 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),但深度受限。一个无限循环的脚本只会挂起其自身的请求——预emption定时器会中断它,而服务器继续为其他所有人服务。
底层的TLS故事比零配置框架所暗示的要完整:仅支持TLS 1.3,并由BoringSSL终止,具有原生加密客户端问候(因此真实的SNI从未以明文形式出现),从目录中选择SNI证书,暴露给脚本的JA4客户端指纹识别,以及一种透明的ECH中继模式,逐字转发不可解密的手shake到真正的上游服务器,从而使受保护的名称能够融入公共名称之下。这在单一零配置二进制文件中集成了大量的传输安全功能。
它有多快?
我将zeroserve与使用HTTPS的nginx 1.26和Caddy 2.11进行了基准测试,在一台具有8个核心的Ryzen 7 3700X上运行,每个服务器都以相同的自签名证书提供相同的内容。由于zeroserve设计为单线程,因此唯一公平的比较是“每核”:我使用taskset将每个服务器固定在一个CPU上(并限制nginx到worker_processes 1和Caddy到GOMAXPROCS=1;zeroserve已经是单线程的),通过其他核心上的wrk -t4 -c100加载,取三次10秒运行的中位数。wrk使用HTTP/1.1,因此这些是经过TLS 1.3握手后在长时间保持连接上分摊的手动HTTP/1.1数字:提供已打开HTTPS连接的稳定状态成本。
小静态文件(174 B) - 静态站点的基本组成部分:
| 服务器 | 每秒请求数 | 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):
| 服务器 | 每秒请求数 | 吞吐量 | 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 ms就武装一次脚本预emption定时器。精细的粒度使得能够迅速限制恶意行为的脚本,但这也对每个表现良好的脚本造成了压力——在默认设置下,eBPF在动态响应(约32k次请求/秒)上落后于nginx Lua(约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 毫秒间隔内,调优的 eBPF 在两种情况下都胜出。对于中间件案例——一个脚本生成原本静态的响应——它比 nginx Lua 快约 50%,且尾部更紧。在完全合成的响应中,它也略胜 nginx 的高度调优的 content_by_lua(47k 对比 41k)。这两种引擎都编译为本地代码(LuaJIT 是跟踪 JIT;async-ebpf 则通过 uBPF 来 JIT 编译 eBPF),并且在 TLS 加密作为每请求共享成本的情况下,调优的 eBPF 路径在吞吐量上占优势。在 2 毫秒默认值下,eBPF 继续保持中间件的优势,但在合成响应中失去了领先地位,因此建议生产脚本使用 10 毫秒。
作为反向代理
提供文件只是工作的一半;另一半是将请求转发到后端服务器,这也是大多数人首先选择 nginx 或 Caddy 的主要原因。zeroserve 是通过一个脚本来实现的——zs_reverse_proxy("http://127.0.0.1:9000")——并维护了一个上游连接池(每个后端最多 128 个连接,30 秒空闲),并在多次请求中重用这些连接。
在这里获得公平竞争需要小心:nginx 的著名默认配置会在每次请求后关闭上游连接,因此需要显式启用保持连接 (keepalive 128, proxy_http_version 1.1 和一个清空的 Connection 头),而 Caddy 则默认重用连接。每个代理在单个核心上终止 TLS 并将请求转发到共享明文后端,这是一个独立的双核服务器,自身可以维持每秒 10 万次请求,因此测量结果隔离了代理自身的开销。
代理一个较小(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 | 359 MB/s |
一旦代理的主体变得很大,nginx 的缓冲操作更高效地移动字节并领先,Caddy 则介于两者之间,zeroserve 落后。如果您的响应体较大,则 nginx 是更好的工具;如果它们较小且频繁,则 zeroserve 更快。
内存
空闲时,单个 zeroserve 实例占用大约 15 MB PSS——比 nginx 的约 6 MB 多,但少于 Caddy 的约 60 MB。单独来看这并不显眼。使其变得重要的是这个单位是一个完整的进程:当你在每个核心上运行一个副本时,它们都会映射相同的二进制文件,因此代码页是共享的,每个额外的进程除了自己的工作集外几乎不增加负担。
- * *
zeroserve 在 GitHub 上开源——你自己试试看!