故障复盘nginx监控

一次 DNS 抖动放倒全站:41 小时静默宕机复盘

2026-10-02 · PM Parker · 阅读约 8 分钟

昨晚我往博客推一篇新文章,验证链接的时候,返回的是 521。

521 是 Cloudflare 的黑话,翻译过来:我还活着,源站死了。

登上服务器,ps -C nginx,进程不存在;ss -tlnp,80 和 443 一个都没监听。systemd 日志写着:10 月 1 日早上 6 点 25 分起,它就再没起来过。

41 个小时。全站躺平了 41 个小时,没有任何人知道。

包括我。

更荒诞的是凶手的身份:不是博客的配置,是同一台服务器上另一个站点的一行静态反代。

定位:从 521 到真凶,journalctl 一击即中

521 的好处是方向明确。Cloudflare 活着,说明问题只剩两种:进程挂了,或者端口没开。这一查,两个都中了。

为什么起不来?journalctl 最后一屏写得明明白白:

Oct 01 06:25:12 systemd[1]: Stopping nginx.service...
Oct 01 06:25:13 nginx[3081690]: [emerg] host not found in upstream
    "tiles.basemaps.cartocdn.com" in /etc/nginx/sites-enabled/wen.pmparker.net:53
Oct 01 06:25:13 nginx[3081690]: nginx: configuration file test failed

翻译一下这条死亡链。10 月 1 日清晨,某个系统事件触发了 nginx 重启。触发者是谁我到现在没查清,可能是内核更新,可能是宿主机维护,日志里只留了一句"Stopping"。

重启之前 nginx 要做配置测试,测试时对"tiles.basemaps.cartocdn.com"这个地图瓦片 CDN 的公开域名做 DNS 解析。那一刻,解析失败。

[emerg] 是 nginx 的最高级别错误:不是这条规则降级,是整份配置测试失败,nginx 拒绝启动。

更糟的是这个失败的复利效应:此后每一次重启都会重演同一条死亡链,读配置、查 DNS、失败、不启动。清晨的一次 DNS 抖动,变成了永久性的循环卡死。

凶手机制:配置加载时解析,不是运行时解析

这行的坑藏在 nginx 的语义里,出事前的写法长这样:

location /geocdn/ {
    proxy_pass https://tiles.basemaps.cartocdn.com/;
    proxy_set_header Host tiles.basemaps.cartocdn.com;
}

proxy_pass 直接写域名(静态写法),这个域名的 DNS 解析发生在配置加载时,也就是启动和 reload 的那一瞬间。那一刻解析不出来,nginx 的选择不是"这条规则先跳过",而是整份配置报废。

修复后的写法:

location /geocdn/ {
    resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
    set $geocdn_host "tiles.basemaps.cartocdn.com";
    rewrite ^/geocdn/(.*)$ /$1 break;
    proxy_pass https://$geocdn_host;
    proxy_ssl_server_name on;
    proxy_ssl_name $geocdn_host;
    proxy_set_header Host $geocdn_host;
}

三个改动,各管一件事。resolver 声明"运行时去哪查 DNS";上游改成变量,解析从配置加载推迟到每个请求,查到的结果缓存 300 秒;rewrite 把 /geocdn/x 改写成 /x 再 break,保持"剥前缀转发"的老语义一寸不移。

一行写法的差距,是启动时生死和运行时降级的差距。DNS 再抖,最坏是单条请求 502,nginx 该活还活。

顺手扫了全服务器:同类的静态上游还有一处,翻译接口的反代,同一个雷。一起拆了。

排查路上的三个坑,比结论更值钱

坑一:grep 排雷扑空。我想扫全服务器还有几处同款静态上游,对着 sites-enabled 目录 grep,零命中。以为没有雷,差点收工。后来才发现它是个符号链接,grep -r 不跟随,真正的雷都藏在 sites-available 的真身里。在 nginx 里做全局排查,永远打 sites-available。

坑二:跨机器的 /dev/shm。我的部署通道要经过一台中转机。修复包明明传到了中转机的 /dev/shm,到目标机上解压却报"文件不存在",查了半天才发现:两台机器各自的 /dev/shm,是两个世界。多机操作时,"这个临时文件现在在哪台机器上",值得每次停一秒确认。

坑三:假自测。修完我在源站本地 curl http://127.0.0.1/ 验证,拿到一页 301 跳转,那是 HTTP 强跳 HTTPS 的配置,本地回环测试永远只能看到跳转页。我拿着跳转页做内容比对,差点得出"还没修好"的反结论。验证要打最终协议,或者 curl 加 -L 跟到底。

还有一个插曲。修完之后首页刷新出来还是旧内容,我一度怀疑是 nginx 的 open_file_cache 在供陈旧文件句柄,翻出配置一看确实开了。最后发现是 Cloudflare 对首页的边缘缓存,列表页是 DYNAMIC 不缓存,首页有缓存窗口。同一个 CDN,不同路径,两种行为。乱怀疑之前,先把"我以为的链路"和"真实的链路"逐段对齐。

为什么 41 小时没人知道

时间(10 月)事件
1 日 06:25:12系统事件触发 nginx 重启(触发者未查明)
1 日 06:25:13DNS 解析失败,配置检查 emerg,nginx 拒绝启动
1 日 ~ 2 日全站 521,无告警,无人发现
2 日 23:15推新文章做验证时撞见
2 日 23:40配置修复,完整重启,全站恢复 200

复盘到这一层,技术原因已经清楚了。但真正让我不舒服的是另外两层。

第二层:爆炸半径。这台服务器上一个 nginx 进程、一份总配置,跑着博客、观澜看板等三个站。观澜的地图反代抖了一下,博客陪葬。共享总配置意味着,爆炸半径不是由你最重要的站点决定的,是由同一份配置里最脆的那一行决定的。

第三层:发现方式。静态站没有心跳监控,没有宕机告警,它挂了和没挂,在外表上没有任何区别,Cloudflare 照常应答,只不过应答的是 521。这次能发现,是因为我半夜推文章必须验证链接,手工部署流程误打误撞当了 41 小时里第一次报丧人。

修完之后我做了两件事。两处静态反代全部改成 resolver 加变量的写法,然后完整重启(不是 reload,是 restart)验证干净拉起:瓦片代理 200,翻译代理返回真实响应,主站 200。第二,把 uptime 监控排上了日程。诚实地说,还没做完,这是下一个要还的债。

最后

三件事带走。

凡是"启动时依赖外部服务"的配置都是地雷。nginx 静态反代如此,任何服务的初始化依赖都如此:能推迟到运行时降级的,就不要留在启动时爆炸。

共享进程的托管架构里,爆炸半径由最脆的那份配置决定。给别人的域名做反代之前,先想清楚它抖一下,你会死几个站。

静态站最容易被省掉的就是监控,而它恰恰是唯一会报丧的东西。5 分钟知道和 41 小时知道,是两种完全不同的运营。

前一天我刚在博客里写过"核查要挂在事件上,不挂在记忆上"。这回把同一句话连本带利补给了自己。

你的服务器最长静默过多久?后来装报丧机制了吗,来聊聊。


← 返回博客列表 · 相关阅读:被平台更新一夜打回原形 · 防御性 API 集成