0. 背景与动机:为什么要替换掉 Nginx?

在过去很长一段时间里,本站点的静态文件托管(Hugo)以及动态服务反代(SillyTavern)一直由 Nginx 承担。Nginx 久经考验、极其稳定,堪称业界标杆。但随着系统架构的演进和统一网关管理的考量,我们开始寻求更现代化的反向代理方案,并最终将目光投向了 Pingap

为什么选择 Pingap?

  1. 现代化的底层基石:Pingap 基于 Cloudflare 开源的 Pingora 框架构建,采用 Rust 编写。Pingora 在 Cloudflare 内部已经受了万亿级请求的考验。得益于 Rust 的内存安全特性,它从根本上规避了传统 C/C++ 网关常见的内存越界和崩溃风险。
  2. 更直观的配置模型:Nginx 的配置语法虽然强大,但指令的上下文和隐式执行顺序往往让人头疼。Pingap 采用了基于 TOML 的声明式配置,将 ServerLocationUpstreamPlugin 彻底解耦,配置的心智模型更加清晰,更利于后期的维护与扩展。
  3. 原生的高级特性支持:Pingap 对 HTTP/1.x、HTTP/2、WebSocket、gRPC-web、证书动态加载和插件化网关能力支持更直接。需要注意的是,HTTP/3/QUIC 这类能力应以当前官方 release 和文档为准,迁移生产入口时不要默认假设已经完整可用。

本次实战的核心目标先在隔离端口上把新代理跑起来并完整验证,再用一次原子切换无缝替换 Nginx,全程保留 Nginx 配置以便出现问题时能够秒级回滚。

通过这种“先验证后切换”的安全策略,我们最终实测的切换中断窗口仅为 174 ms,线上流量几乎无感。

2026-05-17 复核:我重新 SSH 到服务器核对了一遍真实状态。当前服务器仍运行 pingap 0.13.2pingap.service 使用 daemon 模式,Hugo 静态文件由 darkhttpd 承载。本文以下配置以服务器现状为准;新装环境可以再按最新 release 调整。


1. 起点:现状盘点

服务器:阿里云 ECS(Alibaba Cloud Linux 3 / RHEL 兼容),跑 Nginx 1.20.1,承载两个站点:

域名性质后端关键能力
cloudside.icyyan.comHugo 静态站点本地文件 /var/www/hugo-siteHTTPS、HTTP→HTTPS 重定向、静态资源 1 月缓存、Gzip
silly.icyyan.comSillyTavern 反代127.0.0.1:8000HTTPS、WebSocket、client_max_body_size 100M、长连接 proxy_read_timeout 86400s、X-Forwarded-*

证书来自两个不同来源:

  • cloudside:手动签发,放在 /etc/nginx/certs/cloudside.icyyan.com/
  • silly:Let’s Encrypt(certbot 自动续期),放在 /etc/letsencrypt/live/silly.icyyan.com/

第一件事是 不要乱动证书路径——直接复用,迁移完出问题可以一键切回 Nginx,证书原地一致,省去后续同步的麻烦。


2. Pingap 能不能替代 Nginx 做入口网关?

逐项对照之后,作为 HTTPS 入口、反向代理和路由网关是可行的;静态文件服务则要按站点形态选择 directory 插件或本地静态文件服务器。对应关系如下:

Nginx 概念Pingap 对应
server { listen 443 ssl; }[servers.https] + [certificates.X]
proxy_pass http://upstream;[upstreams.X] + [locations.X] upstream = "X"
root /var/www/... 静态文件directory 插件,或本地静态文件服务器 + Pingap 反代
return 301 https://$host$request_uri;[plugins.X] category = "redirect" http_to_https = true(默认行为需按当前版本验证)
proxy_set_header X-Real-IP ...enable_reverse_proxy_headers = true 自动注入 X-Real-IP / X-Forwarded-*
client_max_body_size 100M;location 级 client_max_body_size = "100mb"
proxy_read_timeout 86400s;upstream 级 read_timeout = "86400s"
WebSocket Upgrade原生支持,无需任何额外配置(pingora 底层透传)
多证书 SNIcertificates.X.domains = "..." 自动匹配

唯一需要注意:Pingap 不是 Nginx 那种 server { location { ... } } 嵌套模型。Location 是全局定义的路由规则,但每个 [servers.X] 需要通过 locations = [...] 显式绑定自己要使用的规则;请求进入某个 server 后,再按 location 的 hostpath 和权重匹配。


3. 安装

二进制是 musl 静态链接,挑这个版本是因为不依赖系统 glibc,跨发行版兼容性最稳。本文记录的服务器当前版本是 0.13.2

# 下载并安装(musl 版本,静态链接)
curl -L https://github.com/vicanso/pingap/releases/download/v0.13.2/pingap-linux-musl-x86.tar.gz \
  -o /tmp/pingap.tar.gz
tar -xzf /tmp/pingap.tar.gz -C /tmp
install -m 755 /tmp/pingap-linux-musl-x86 /usr/local/bin/pingap

# 准备配置目录和日志目录
mkdir -p /etc/pingap /var/log/pingap

pingap --version
# pingap 0.13.2

4. 配置:六个文件搞定

Pingap 的配置可以一个大文件,也可以拆成多个文件放在一个目录里(启动时 -c /etc/pingap 自动扫描合并)。强烈建议拆分,方便后续单独 reload 某一类配置。

最终方案里,Hugo 静态文件没有直接挂 directory 插件,而是由 darkhttpd 在服务器本机提供 9000 端口,Pingap 通过 127.0.0.1:9000 反代过去。实际 ss 看到 darkhttpd 监听的是 0.0.0.0:9000,所以应依赖云安全组/防火墙避免公网直接访问这个端口。选择这个方案,是因为当次迁移使用的 Pingap 版本在 Hugo 子目录 index.html 场景下有兼容问题,细节见 10.5。

4.1 /etc/pingap/basic.toml — 全局

[basic]
name = "pingap"
log_level = "info"
pid_file = "/run/pingap.pid"

4.2 /etc/pingap/certificates.toml — 证书

注意 domains 字段是 SNI 路由的关键,必须填上。

[certificates.cloudside]
domains = "cloudside.icyyan.com"
tls_cert = "/etc/nginx/certs/cloudside.icyyan.com/cloudside.icyyan.com.pem"
tls_key  = "/etc/nginx/certs/cloudside.icyyan.com/cloudside.icyyan.com.key"

[certificates.silly]
domains = "silly.icyyan.com"
tls_cert = "/etc/letsencrypt/live/silly.icyyan.com/fullchain.pem"
tls_key  = "/etc/letsencrypt/live/silly.icyyan.com/privkey.pem"

4.3 /etc/pingap/upstreams.toml — 后端

[upstreams.hugo_files]
addrs = ["127.0.0.1:9000"]

[upstreams.sillytavern]
addrs = ["127.0.0.1:8000"]
# WebSocket / 长连接:和 nginx 的 proxy_read_timeout / proxy_send_timeout 一一对应
read_timeout  = "86400s"
write_timeout = "86400s"
idle_timeout  = "86400s"

4.4 /etc/pingap/plugins.toml — 插件

# HTTP -> HTTPS 重定向
[plugins.httpToHttps]
category      = "redirect"
http_to_https = true

4.5 /etc/pingap/locations.toml — 路由规则

# Hugo 静态站点,HTTPS
[locations.cloudsideHttps]
host     = "cloudside.icyyan.com"
upstream = "hugo_files"

# SillyTavern 反代,HTTPS
[locations.sillyHttps]
host                         = "silly.icyyan.com"
upstream                     = "sillytavern"
client_max_body_size         = "100mb"
enable_reverse_proxy_headers = true  # 自动注入 X-Real-IP / X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host

# 所有 HTTP 进来一律 301/307 跳到 HTTPS
[locations.redirectHttp]
plugins = ["httpToHttps"]

4.6 /etc/pingap/servers.toml — 监听端口

这是验证阶段和切换阶段唯一需要改动的文件。 验证时用非标端口,避免和 Nginx 抢端口。

验证阶段(与 Nginx 共存):

[servers.http]
addr      = "0.0.0.0:8080"
locations = ["redirectHttp"]

[servers.https]
addr                 = "0.0.0.0:8443"
locations            = ["cloudsideHttps", "sillyHttps"]
global_certificates  = true   # 启用全局证书池,按 SNI 自动选择
enabled_h2           = true   # HTTP/2

切换阶段(接管标准端口):

[servers.http]
addr      = "0.0.0.0:80"
locations = ["redirectHttp"]

[servers.https]
addr                 = "0.0.0.0:443"
locations            = ["cloudsideHttps", "sillyHttps"]
global_certificates  = true
enabled_h2           = true

4.7 启动本地 Hugo 静态文件服务

因为上面的 hugo_files upstream 指向 127.0.0.1:9000,所以在验证 Pingap 前,先确保本机静态服务已经跑起来:

# /etc/systemd/system/hugo-static.service
[Unit]
Description=Hugo Static File Server
After=network.target

[Service]
Type=simple
ExecStart=/usr/sbin/darkhttpd /var/www/hugo-site --port 9000 --no-server-id
Restart=on-failure
RestartSec=2s

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now hugo-static
curl -I http://127.0.0.1:9000/

5. 验证:在 8080/8443 上把所有路径全跑一遍

启动前先验证配置语法:

pingap -c /etc/pingap -t
# 看到 "Validate config success" 才继续

后台启动:

pingap -c /etc/pingap -d --log=/var/log/pingap/pingap.log
ss -tlnp | grep pingap   # 确认监听了 8080 和 8443

跑 5 项验证(每一项都对应一个 nginx 时代的功能):

# 1. HTTPS + SNI -> Hugo 静态 200
curl -ksI --resolve cloudside.icyyan.com:8443:127.0.0.1 \
  https://cloudside.icyyan.com:8443/

# 2. HTTPS + SNI -> SillyTavern 反代(401 = 后端 Basic Auth,说明转发链路正常)
curl -ksI --resolve silly.icyyan.com:8443:127.0.0.1 \
  https://silly.icyyan.com:8443/

# 3. HTTP -> HTTPS 重定向 307
curl -sI --resolve cloudside.icyyan.com:8080:127.0.0.1 \
  http://cloudside.icyyan.com:8080/

# 4. SNI 证书匹配
echo | openssl s_client -connect 127.0.0.1:8443 \
  -servername cloudside.icyyan.com 2>/dev/null \
  | openssl x509 -noout -subject -dates

echo | openssl s_client -connect 127.0.0.1:8443 \
  -servername silly.icyyan.com 2>/dev/null \
  | openssl x509 -noout -subject -dates

# 5. WebSocket Upgrade 握手
curl -sk --resolve silly.icyyan.com:8443:127.0.0.1 -i -N --max-time 3 \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  https://silly.icyyan.com:8443/socket.io/?EIO=4\&transport=websocket

这一步是这次迁移的安全网。 任何一项不通就回头改配置,绝不带着问题去做切换。


6. systemd 服务化

# /etc/systemd/system/pingap.service
[Unit]
Description=Pingap Reverse Proxy
After=network.target
Wants=network.target

[Service]
Type=forking
PIDFile=/run/pingap.pid
ExecStartPre=/usr/local/bin/pingap -c /etc/pingap -t
ExecStart=/usr/local/bin/pingap -c /etc/pingap -d --log=/var/log/pingap/pingap.log
ExecReload=/bin/kill -HUP $MAINPID
KillMode=mixed
Restart=on-failure
RestartSec=2s
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target
systemctl daemon-reload

ExecStartPre 加了配置语法预检——任何错配会让 systemd 直接拒绝启动,比起 pingap 起来后才报错友好得多。当前服务器就是这个 daemon 模式:systemctl status pingap 里能看到主进程命令为 /usr/local/bin/pingap -c /etc/pingap -d --log=/var/log/pingap/pingap.log


7. 切换:让中断窗口尽可能短

切换之前先把 servers.toml 的端口从 8080/8443 改成 80/443,并把验证阶段启动的 pingap 进程清掉,然后一次性 stop nginx + start pingap:

# 1. 清掉验证阶段的 pingap 进程
pkill -f /usr/local/bin/pingap

# 2. 改 servers.toml(80/443 版本),再 systemctl daemon-reload 一次(unit 没变也无害)
systemctl daemon-reload

# 3. 切换(中断窗口就是这两条命令的间隔)
systemctl stop nginx && systemctl start pingap

# 4. 等待 socket 出现
for i in 1 2 3 4 5; do
  ss -tln | grep -q ":443 " && break
  sleep 0.2
done

# 5. 立即验证
systemctl is-active nginx pingap
ss -tlnp | grep -E ":(80|443)\b"
curl -ksI --resolve cloudside.icyyan.com:443:127.0.0.1 https://cloudside.icyyan.com/

我这次实测从 nginx stop 到 pingap accept 总共 174 ms,外部观察基本无感。


8. 收尾

systemctl enable pingap     # 开机自启
systemctl disable nginx     # 保留 nginx 但不开机自启(方便回滚)

# 把验证阶段的旧目录、nginx 配置都打个备份保险
mv /root/pingap-old /root/pingap-old.bak.$(date +%Y%m%d) 2>/dev/null
cp -a /etc/nginx /root/nginx-backup.$(date +%Y%m%d)

刻意 卸载 Nginx:

  • nginx 的 conf 是已经验证过能 work 的"参考实现",留着便于以后写新 location 时对照
  • 一旦 pingap 出问题,回滚就是 systemctl stop pingap && systemctl start nginx,秒级

9. 回滚预案

写在 ~/rollback-pingap.sh,应急时一行命令搞定:

#!/bin/bash
set -e
systemctl stop pingap
systemctl start nginx
systemctl status nginx --no-pager

证书路径完全没改、nginx 配置原封不动,所以回滚是真正的"按下就回去"。


10. 踩坑:本次迁移真正花时间的部分

10.1 SNI 拦截 / ICP 备案

迁移完成后从浏览器访问域名,出现 ERR_CONNECTION_CLOSED,第一反应是 pingap 有问题。但一通排查后定位到根因和 pingap 完全无关:

症状对比

测试SNI结果
https://silly.icyyan.com/silly.icyyan.com❌ ERR_CONNECTION_CLOSED
https://120.xx.xx.xx/(直接 IP)无 / IP✅ TLS 握手成功(证书 CN 不匹配但能握手)

带 SNI 就被切,不带 SNI 就能握手 —— 经典的 SNI 阻断 模式。

根因:阿里云对未在阿里云 ICP 备案的域名会在境内入站方向做 SNI 检查,发现未备案的域名直接 TCP RST。境外 IP 出口因为路径不同绕过这层检查,所以从美国 IP 看 nginx access log 一切正常。这套机制和反代是 nginx 还是 pingap 毫无关系。

走过的弯路:

  • 误以为是 pingap 启用 HTTP/2 / TLS 1.3 / ECH 不兼容
  • 回滚到 nginx 后从我电脑测试发现同样的 Connection reset by peer,才意识到不是反代的问题

判别方法很简单:用 https://<IP>/ 直接访问,如果连证书警告页都打不开 → 端口 / 网络问题;如果能弹证书警告(CN 不匹配也行)→ 端口完全正常,问题在 SNI / 域名层面。

解决方案二选一:

  • 补 ICP 备案
  • 把域名挂 Cloudflare 真正代理(橙云),让阿里云那一段看到的 SNI 是 Cloudflare 的而不是你的

10.2 配置里 domains 字段不要忘

[certificates.X] 必须填 domains = "...",否则 global_certificates = true 没法按 SNI 路由证书,会用默认证书或报握手错误。

10.3 静态文件服务不在 [locations],在 [plugins]

Pingap 没有 nginx 的 root 指令。静态文件是通过 category = "directory" 插件提供,再在 location 的 plugins = [...] 数组里挂上。这一点和 nginx 的心智模型差别比较大,第一次看 pingap 文档时容易卡住。

10.4 category = "redirect"http_to_https 默认行为要单独验证

当次迁移时,http_to_https 的表现更接近“统一跳转 HTTPS”的网关插件,而不是 Nginx 里随手写一条 return 301。如果某些路径不希望强制跳 HTTPS(比如 .well-known/acme-challenge 给 Let’s Encrypt 验证用),需要为这个路径单独建一个 location,匹配权重高于带 httpToHttps 插件的那条。新版本如果需要固定 301/302/307,也应按当前 redirect 插件文档验证状态码配置。

10.5 当时版本的 directory 插件对子目录 index.html 不稳定

这是本次迁移最大的功能陷阱。现象是:首页 / 正常,点进 /posts//tags/ 这类 Hugo 生成的目录页时返回 Not Found,即便对应目录下存在 index.html

这条结论严格限定在当次迁移环境和当时使用的 Pingap 版本。当前 Pingap 官方文档已经明确描述 directory 插件支持目录 index,所以新版本应重新实测,不能直接照搬这个坑。

当时为了降低迁移风险,我没有继续把生产入口压在这个行为上,而是改成本地跑一个轻量静态文件服务器,Pingap 只做统一 TLS 终止、SNI、HTTP 跳转和反代。最终配置就是前面 4.7 的 darkhttpd + upstream 模式:

[upstreams.hugo_files]
addrs = ["127.0.0.1:9000"]

[locations.cloudsideHttps]
host     = "cloudside.icyyan.com"
upstream = "hugo_files"

这样 pingap 负责 TLS 终止、SNI、HTTP→HTTPS,darkhttpd 负责正确提供目录 index.html,各司其职。需要注意:当前 hugo-static.service 没有额外绑定 localhost,ss -tlnp 显示它监听在 0.0.0.0:9000;如果安全组放行了 9000,就会被公网直接访问。生产上最好只在安全组里放行 80/443/22,或改用支持绑定 127.0.0.1 的静态文件服务。


11. 还没做但应该做

  • certbot 续期 hook:silly.icyyan.com 用 Let’s Encrypt,续期默认 reload nginx,现在 nginx 不在跑了。更稳的做法是在 certbot deploy hook 里执行 systemctl restart pingap,确保证书文件更新后新 TLS 配置一定被进程读到。
  • HTTP/3 / QUIC:不要在迁移文档里默认开启。Pingap 当前公开文档主要覆盖 HTTP/1.x、HTTP/2、gRPC-web、h2c;如果后续 release 明确支持 QUIC,再单独验证 UDP 443、安全组、回滚策略和客户端兼容性。
  • 指标 / 监控prometheus_metrics = "/metrics" 暴露给 Prometheus,或 webhook 接钉钉/企业微信告警
  • 管理面板[plugins.adminWebPage] 启用后可以在浏览器里改配置并热加载,但生产环境记得加 IP 白名单和强密码

一个总结

这次迁移的核心方法论是 “先建后切” —— 永远不要在生产端口上做实验。验证窗口越长越好,切换窗口越短越好;旧服务的配置和数据在切换后保留至少一个版本,回滚路径是肌肉记忆。

Pingap 的配置心智模型干净(upstream → location → server,配上 plugin 横切关注点),Rust 写的内存占用也确实小(~40MB resident,对比 nginx 多 worker 大约持平)。从这次站点入口迁移看,用 Pingap 替代 Nginx 承担 HTTPS 终止、反向代理和统一网关职责是可行的;静态文件这种细节能力则要结合版本和站点形态单独验证。