记一例 nginx 故障分析
早上起来习惯性瞄一眼 home lab 的日志,看到我那个 WebSocket 服务凌晨被重启过:
|
1 2 |
[2026-07-28 06:48:02] || Server: Loaded 7 pinned rooms [2026-07-28 06:48:02] || Server: Starting server at wss://0.0.0.0:8765 |
重启就重启吧,本来没当回事。结果顺手打开本博客——打不开。又试了下这台机器上跑的其他站,也全打不开。好家伙,整台机器的 nginx 全躺了,而且看时间已经躺了三个多小时。
先看现场
第一件事是确认机器有没有重启过。uptime 一看,没有,机器好好的。那就是 nginx 单独出事了。systemctl restart nginx 下去,唰一下全恢复了。
服务是回来了,但这更让人不安:能 restart 成功,说明磁盘上的配置本来就是好的,那它三个小时前到底为什么起不来?翻 unit 日志:
|
1 2 3 4 5 6 7 8 9 |
$ sudo journalctl -u nginx.service --since "6 hour ago" --no-pager Jul 28 06:48:02 s systemd[1]: Stopping nginx.service - A high performance web server and a reverse proxy server... Jul 28 06:48:02 s systemd[1]: nginx.service: Deactivated successfully. Jul 28 06:48:02 s systemd[1]: Stopped nginx.service - A high performance web server and a reverse proxy server. Jul 28 06:48:02 s systemd[1]: nginx.service: Consumed 19min 12.963s CPU time, 117.6M memory peak, 0B memory swap peak. Jul 28 06:48:02 s systemd[1]: Starting nginx.service - A high performance web server and a reverse proxy server... Jul 28 06:48:02 s nginx[2766192]: 2026/07/28 06:48:02 [emerg] 2766192#2766192: host not found in upstream "bones7456.github.io" in /etc/nginx/sites-enabled/luy:75 Jul 28 10:00:10 s systemd[1]: Starting nginx.service - A high performance web server and a reverse proxy server... Jul 28 10:00:10 s systemd[1]: Started nginx.service - A high performance web server and a reverse proxy server. |
host not found in upstream。我博客有几个路径(比如这个)是反代到 GitHub Pages 的,nginx 起来的时候要解析 bones7456.github.io,那一刻没解析出来,直接 emerg 拒绝启动。
再一看,06:48 这个时间点太像 Debian 系的 cron.daily 窗口了(/etc/crontab 里是 6:25,加上 anacron 的随机延迟,另外 cron.weekly 是 6:47,更像),我当时第一反应是 certbot 续期把 nginx 重启崩了。结果把那五分钟的全量日志拉出来一看,跟 certbot 半毛钱关系没有。所以还是那句话:别猜,看日志。
1 秒的竞速
把 journalctl --since "06:45" --until "06:50" 全量捞出来,真凶一目了然:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
06:47:50 Starting apt-daily-upgrade.service <== unattended-upgrades 开跑 06:47:57 systemd[1]: Reexecuting requested from client PID ... (unit apt-daily-upgrade.service) systemd 255.4-1ubuntu8.16 running in system mode <== systemd 自己被升级了 06:48:02 ★ 满机器的服务被批量重启(needrestart 干的) Stopping named... <== named 被停掉,不再监听 127.0.0.1#53 Stopping nginx... <== nginx 被停掉 Starting nginx... (PID 2766192) <== nginx 再次启动 └─ [emerg] host not found in upstream "bones7456.github.io" <== nginx 报错失败了 nginx.service: Control process exited, code=exited, status=1/FAILURE 06:48:03 Starting named... (PID 2766508) named: listening on IPv4 interface lo, 127.0.0.1#53 <== named 启动,DNS 回来了 |
看明白了:systemd 被 apt 自动升级,触发 needrestart 把机器上几乎所有服务全重启一遍。我这台机器上跑着 BIND 做内网 DNS,于是 named 和 nginx 一起被拖下水。
nginx 在 06:48:02 查 DNS,named 在 06:48:03 才恢复监听。就差 1 秒,nginx 输了这场竞速,代价是三个多小时的全站宕机。
最扎心的是同一批重启里的对比:php-fpm、redis、mariadb,还有我自己那几个 Flask 服务,全都 Started 成功了。唯独 nginx 死了。为什么?因为只有 nginx 在启动阶段需要解析外部域名,别的服务都不需要。
为什么 After=nss-lookup.target 没救下它
这才是这次最值得说的地方。翻开 Ubuntu 自带的 nginx unit:
|
1 2 3 4 |
[Unit] Description=A high performance web server and a reverse proxy server After=network-online.target remote-fs.target nss-lookup.target Wants=network-online.target |
nss-lookup.target 是 systemd 专门用来表示”域名解析已就绪”的标准锚点,DNS 服务一般会用 Before=nss-lookup.target 把自己挂上去。也就是说,nginx 早就声明过”我要等 DNS 就绪再启动”——然而它还是死在了 DNS 上。
原因在于After= 的语义被普遍误解了:
它只在同一个 systemd job transaction 内部编排启动先后,并不检查目标服务的实时健康状态。
needrestart 是逐个执行 systemctl restart <unit> 的,nginx 和 named 属于两个互相独立的 transaction,彼此之间没有任何排序约束。更要命的是,nss-lookup.target 是个 passive target,named 停止时它并不会跟着 deactivate,全程保持 active——于是 nginx 一查”依赖满足了吗”,满足,启动,然后一头撞死。
所以结论挺反直觉的:在 restart 场景下,After= 基本等于安慰剂。指望靠加一行 After=named.service 来防这类问题,是防不住的。
两道防线
既然顺序编排靠不住,那就换思路:一道事后自愈,一道事前免疫。
防线一:让它自己重试
Ubuntu 的 nginx.service 默认没有 Restart=,意味着启动失败一次就永久 failed,没有任何重试。而这次 named 只用了 1 秒就恢复——只要能重试一次,整件事根本不会发生。
|
1 2 3 4 5 6 7 8 9 10 11 |
sudo mkdir -p /etc/systemd/system/nginx.service.d sudo tee /etc/systemd/system/nginx.service.d/override.conf <<EOF [Unit] StartLimitIntervalSec=600 StartLimitBurst=20 [Service] Restart=on-failure RestartSec=10 EOF sudo systemctl daemon-reload |
StartLimit 那两行不是可选的:systemd 全局默认是 10 秒内最多 5 次,配上 RestartSec=10 会立刻撞上限流然后彻底放弃。改成 10 分钟内 20 次,才扛得住像样的 DNS 故障。
这里补一个 drop-in 的合并规则,很多人会搞混:
1. 标量指令(Restart=、RestartSec=、Type=、PIDFile=…)是覆盖。
2. 列表指令(After=、Wants=、Environment=、ExecStartPre=…)是追加,不是覆盖。想清空得先写一行空赋值 After= 再写新值。
3. ExecStart= 是重灾区:Type=forking 下只允许一条,直接在 drop-in 里写会报错,必须先 ExecStart= 清空再写。
想看合并后到底生效了什么,别看文件,看这个:
|
1 2 |
systemctl cat nginx # 看拼了哪些文件 systemctl show nginx -p After -p Restart -p RestartUSec # 看最终生效值,配置项是 RestartSec 对应的生效值是 RestartSec |
还有个细节值得确认:这次失败的其实是 ExecStartPre 里的 nginx -t -q(日志里那句 Control process exited, code=exited, status=1/FAILURE,result 是 exit-code)。Restart=on-failure 是覆盖这种情况的,不用担心它管不到。
防线二:别让 nginx 在启动期解析域名
自愈只是兜底,根上的毛病是:一个 location 的 upstream 解析不了,整个 nginx 拒绝启动,机器上所有站点陪葬。nginx 的配置校验是 all-or-nothing 的,一个小站点的临时故障被放大成了全局故障。
解法是给 proxy_pass 用变量——只要 proxy_pass 里含变量,nginx 就不再在启动期做一次性解析,改成运行时按需查 resolver。这样 DNS 挂了 nginx 照样能起来,最坏只是那一个 location 返回 502。
但这里有个大坑,下面单独说。
变量化 proxy_pass 的那个坑
我原来的配置是这样的,注意 proxy_pass 后面带了路径:
|
1 2 3 4 5 6 |
location ^~ /data/shi/ { proxy_pass https://bones7456.github.io/china-dynasty-timeline/; proxy_ssl_server_name on; proxy_set_header Host bones7456.github.io; ... } |
如果你天真地把域名换成变量,写成 proxy_pass https://$gh_pages/china-dynasty-timeline/;,就掉坑里了。nginx 的规则是:
1. proxy_pass 不含变量且带 URI → nginx 做前缀替换,把 location 匹配掉的 /data/shi/ 换成 /china-dynasty-timeline/。
2. proxy_pass 含变量 → 前缀替换机制彻底失效,URI 被固定成你写的那个。
后果是这样的:
|
1 2 3 4 |
请求 /data/shi/ 原配置 → /china-dynasty-timeline/ ✓ 变量版 → /china-dynasty-timeline/ ✓ 请求 /data/shi/assets/app.js 原配置 → /china-dynasty-timeline/assets/app.js ✓ 变量版 → /china-dynasty-timeline/ ✗ |
首页看着还挺正常,所有子资源全部错位——CSS、JS、图片全挂。这种”打开一看好像没事”的故障最恶心。
正解是用 rewrite ... break 手动接管路径映射,配一个不带 URI 的 proxy_pass。nginx 文档明确写了:proxy_pass 不带 URI 时,若 URI 已被 rewrite 改写,传递的就是改写后的 URI——正是我们要的。
先在 server 块里放解析器和变量:
|
1 2 3 |
resolver 127.0.0.1 1.1.1.1 8.8.8.8 valid=300s ipv6=off; resolver_timeout 5s; set $gh_pages "bones7456.github.io"; |
然后 location 改成:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
location ^~ /data/shi/ { rewrite ^/data/shi/(.*)$ /china-dynasty-timeline/$1 break; proxy_pass https://$gh_pages; proxy_ssl_server_name on; proxy_ssl_name bones7456.github.io; proxy_set_header Host bones7456.github.io; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 避免 GitHub Pages 返回的跳转暴露 github.io 域名 proxy_redirect https://bones7456.github.io/china-dynasty-timeline/ /data/shi/; proxy_redirect https://bones7456.github.io/ /data/shi/; } |
实际只动了三处:加 rewrite ... break、proxy_pass 去掉路径换成变量、显式加 proxy_ssl_name(它默认取 $proxy_host,变量化后写死更稳)。proxy_redirect 是字面量匹配,不受影响。查询串也会自动带过去,/data/shi/a?b=1 照常工作。
几个坑
1. resolver 里写多个地址是轮询,不是主备。我这里反代的是公网域名,两个 DNS 都能解析,正好互为冗余;但如果你要反代本机 named 里那些内网 zone,就绝不能这么写——轮询到 1.1.1.1 直接解析失败。那种 location 得单独配 resolver 127.0.0.1;。
2. ipv6=off 是刻意加的。变量化之后每次请求都要重新解析,家宽 IPv6 到 GitHub 的连通性又不一定稳,少一个变数是一个。确认 IPv6 走得通再打开。
3. 别指望 After= 能防住 restart 场景,前面说透了。它在正常开机时有用,成本为零可以留着,但不能当防线。
4. nginx -t 通过不代表这次改对了。它只校验语法,rewrite 的路径映射对不对,它一个字都不会告诉你。
怎么验证真的修好了
改完一定要实测,光看配置”觉得对”没用。先验路径映射——必须测子路径,别只测首页:
|
1 2 3 |
curl -sI https://luy.li/data/shi/ | head -3 # 从页面里抓个真实静态资源来测,期望 200 而不是 404 curl -s https://luy.li/data/shi/ | grep -oE '(src|href)="[^"]+\.(js|css)"' | head -3 |
说点本质的
复盘下来,这次事故里有三个独立的问题,任意修掉一个都不会出事:nginx 没有真正可靠的 DNS 就绪依赖、配置在启动期硬依赖 DNS、失败之后没有任何重试。三个凑齐了,一次 1 秒的 DNS 抖动才放大成 3 小时的全站宕机。
而我最想留下的一条经验是:别把系统的健壮性寄托在”顺序”上。After=、Before=、network-online.target 这些东西给人一种”我已经处理好依赖了”的错觉,但它们描述的是编排意图,不是运行时的真实状态。真正靠得住的只有两种东西——失败了能自己重试(自愈),和压根不依赖那个东西(免疫)。分布式系统里讲了很多年的道理,放在单机 systemd 上一样成立。
最后再补一句题外话:这次是我早上”顺手看了眼日志”才发现的,不然还得挂更久。所以监控该上还是得上,healthchecks.io、UptimeRobot 之类的免费额度足够个人站用了。再优雅的配置优化,也不如有人(或者有个机器人)在你睡觉的时候盯着。
全文完。
