改了配置要不要重启:reload与restart的边界

上周给一个跑了三年的 Typecho 站换 PHP 版本,按老习惯直接 systemctl restart php8.2-fpm。结果监控上出现了一条三十秒的 502 长尾,恰好被一个搜索引擎爬虫撞上了八次,第二天那批新文章的抓取频次直接掉了一截。事后翻日志才发现,重启那一瞬间所有 worker 都被杀掉,排队里的请求一个都没能完成,而重建进程池、重新加载 OPcache、重新预热内存又花了小半分钟。

这件事让我把「重启」这件事重新捋了一遍。我们平时说的重启,其实混了三种完全不同的动作,代价差几十倍,混用会出事。

三种重启的代价分级

判断一个重启到底有多重,只有一个判据:要不要换掉正在跑的二进制文件,或者让内核接管资源。

第一种是 reload(重载配置)。进程本身不退出,只是收到信号后重新读一遍配置文件。老的 worker 处理完手上的请求自己退出,新的 worker 按新配置起来。对用户来说几乎无感,代价只有「新 worker 第一次处理请求时冷启动」这一下。Nginx 的 nginx -s reload、PHP-FPM 的 kill -USR2 $(cat /run/php/php8.2-fpm.pid) 都属于这一类。

第二种是 restart(重启服务)。进程全部退出再全部起来。配置能生效,二进制换了也能生效,但进程内存里的东西——OPcache 的字节码、APCu 的缓存、连接池里的连接、会话——全部清零。这正是前面那次 502 的来源。

第三种是 reboot(重启整机)。内核、磁盘、网卡、所有 systemd unit 按依赖顺序重新拉起。代价最大,但也只有在换内核、改启动参数、修文件系统、扩 LVM 这类场景才必须。

把这三者的适用边界记住,能避开大概七成的「为了改一行配置把站搞挂五分钟」。

reload 不生效的四种情况

reload 便宜,但它有个反作用:很多人以为 reload 万能,改完就 reload,然后发现没变化,再改一次,最后乱成一团。实际上有四种东西 reload 是改不动的。

换了二进制必须 restart。 编译了新版本的 Nginx 或者 apt 升级了 php-fpm 包之后,reload 只是让老 worker 退出、新 worker 从磁盘上加载新的二进制——Nginx 在这种情况下确实能平滑换掉 master,但 PHP-FPM 不行,FPM 的 master 进程本身就是老二进制,子进程都是 fork 出来的,reload 之后仍是老版本。所以 php -v 显示新版本、phpinfo() 里还是旧版本,八成就是只 reload 没 restart。

监听端口变了必须 restart。 把 80 改成 8080 这类改动,reload 会失败并在 error.log 里留下 bind() to 0.0.0.0:8080 failed (98: Address already in use) 之类的痕迹,但 master 进程不会退出,服务继续用老配置跑。这是最危险的一种「看起来没事」——线上一切正常,只有新配置没生效。

改了 worker_processes 之外的进程级参数,需要确认。 Nginx 的 worker 数量、FPM 的 pm.max_children,reload 后新 worker 会按新值来,这部分没问题;但 pm 模式从 dynamic 改成 static,FPM 会拒绝热切换,日志里会报错,必须 restart。

PHP 的 opcache.validate_timestamps=0 下,改代码不做 reload 就是不生效。 这是生产环境常见的优化,代价是每次发布代码都要显式 opcache_reset() 或者 reload FPM。忘了这一步,会出现「我明明改了模板,浏览器刷新十次还是旧的」的经典误会。

能优雅重启就不要硬重启

必须 restart 的时候,尽量挑「优雅」的方式,让在途请求处理完再退出。

systemd 的 systemctl reload-or-restart 会优先用 reload,unit 里没定义 ExecReload 才退化成 restart。nginx 和 php-fpm 的官方 unit 都定义了 ExecReload,所以这条命令在多数情况下就是安全的那个。

如果非要 restart 又不想掉请求,可以临时把上游摘掉再重启。Nginx 里最简单的方法是给 upstream 加 down 标记后 reload 一次,等十秒确认没有新请求进来,再重启后端,最后去掉标记再 reload 一次。双机的话更省事,直接在负载均衡或者 CDN 回源组里把这一台权重调成 0。

还有一种情况是单机单实例,没有任何缓冲。这时候能做的是提前做好三件事:把 FPM 的 request_terminate_timeout 设得比最慢请求略大,让重启时不会有请求卡在里面;把 process_control_timeout 设为 10 秒左右,给老 worker 收尾的时间;把重启安排在访问低谷,用日志统计出来的真实低谷,而不是拍脑袋的凌晨三点。

重启前必须做的五项检查

真正出事故的重启,往往不是重启本身,而是重启把一个原本藏着的状态暴露了。比如进程内存里有一份没落盘的队列,重启后丢了;比如某个服务在跑但没有 enable,重启后就没起来。所以重启前的检查清单比重启命令重要得多。

一、确认服务是 enable 的。 这是最容易被忽略的一条。用 systemctl list-units --type=service --state=running 拿到正在跑的服务,再和 systemctl list-unit-files --state=enabled 对一遍,差集就是「手动起过但开机不会起」的隐患:

comm -23 \
  <(systemctl list-units --type=service --state=running --no-legend | awk '{print $1}' | sort) \
  <(systemctl list-unit-files --state=enabled --no-legend | awk '{print $1}' | sort)

输出里出现的关键服务(数据库、队列、定时任务、反向代理)必须手动 enable,否则重启后就是一次「服务静默缺失」。

二、确认数据库没有长事务。 重启 MySQL 时如果有一个跑了二十分钟的批量更新没提交,InnoDB 需要在关闭过程中回滚它,回滚时间可能比事务本身还长,看起来就是「数据库起不来」。

SELECT trx_id, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
       trx_mysql_thread_id, LEFT(trx_query, 80) AS q
FROM information_schema.innodb_trx
ORDER BY trx_started;

有 age_sec 超过 60 的,先 KILL 或者等它跑完,再动重启。

三、确认磁盘和 inode 都有余量。 重启后日志、临时文件、binlog 都会重新开始写,本来只剩 1G 的分区很容易在启动阶段被写满,然后所有服务报 ENOSPC。df -hP 加 -P 是为了防止设备名太长导致输出折行,把数字错位;df -iP 看 inode,小文件多的站(缓存目录、会话目录)inode 先满的情况比空间满更常见。

四、确认没有依赖人工介入的进程。 ps aux | grep -v '^\[' | grep -c '' 之类的统计看不出什么,真正要看的是有没有前台跑着的脚本、有没有 tmux 里挂着的迁移任务、有没有手动 nohup 起的临时服务。这些重启后全没了,而你可能三天后才想起来。

五、留一条后路。 云主机先拍一个快照(十分钟的事,代价极低);物理机或者不支持快照的环境,至少把 /etc 用 tar 打一份到别的分区;同时保留一个当前已登录的 SSH 会话不要关,因为重启后 sshd 如果起不来,那个老会话是唯一的救生索。

重启后的四步验收

重启命令执行完不等于事情结束,验收才是关键。

第一步,看有没有 failed 的 unit。 systemctl --failed 输出为空才算过;有输出就先看 journalctl -u 服务名 -b -n 50。注意 -b 表示「本次启动」,重启后一定要带上,否则会看到上一次启动的旧日志,误判成当前故障。

第二步,从本机验证端口在听、能应答。 顺序是先 ss -lntp 看监听,再 curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/。走 127.0.0.1 是为了绕开 CDN 和防火墙,确认源站自己没问题。如果这一步就失败,不要去看 CDN,问题在本地。

第三步,验证绕开 CDN 的公网访问。 用 curl --resolve www.example.com:443:127.0.0.1 https://www.example.com/,这样 TLS 的 SNI 和 Host 头都是真实域名,请求却打在源站上。这一步能同时验证书是否正常加载、域名是否配置正确,比直接 curl 公网 IP 有信息量得多。

第四步,验证写操作。 很多人只验了首页 200,就以为一切正常,结果用户一留言就 500——因为数据库或者上传目录的权限在重启后变了(典型是 systemd 的 PrivateTmp 或者 ProtectSystem 让 /tmp 变成了新的空目录)。真正有效的验证是提交一条测试评论再删掉,或者用站点的健康检查接口跑一次完整的读加写。

那两个很容易踩的连带坑

坑一:/var/run/reboot-required 的存在与否,决定你该不该现在重启。 Debian/Ubuntu 装完内核和关键库之后会创建这个文件,needrestart 会告诉你哪些服务还在用老的共享库。cat /var/run/reboot-required.pkgs 能列出触发重启的包,有时候只是 libc 升级,重启只为让所有进程重新链接新库,这时候挑业务低峰做就行,不用紧张。

坑二:重启次数比 uptime 更能说明问题。 uptime 显示跑了 300 天,看着很稳,但可能只是没人动过;journalctl --list-boots 列出所有启动记录,如果中间有几次重启间隔很短,说明系统有过反复崩溃或者被云厂商迁移过。我在一台机器上就发现有两次相隔四十分钟的重启,查下去是内存不足触发了 OOM,而监控只看 uptime 完全没报警。

一次实操:把三十分钟的停机压到零

回到开头那次事故。改造之后我的流程是这样的:先把 Nginx 的 upstream 里给 PHP 加 down 并 reload,十秒后用 ss -tn state established '( sport = :9000 )' 确认没有到 FPM 的活动连接;然后 systemctl restart php8.2-fpm;重启后用 curl -s http://127.0.0.1/status?full(如果开了 FPM status)或者直接打首页十次,确认十次都 200 且耗时稳定;最后去掉 down 标记再 reload。

这样做的效果是请求在切换窗口内全部落在 503 维护页上(由 Nginx 直接返回,不经过后端),而不是 502 错误。503 加一个 Retry-After: 10 的头,对爬虫来说是「暂时繁忙」的信号,不会像 502 那样被判定为服务不稳定而降抓取频次——这正是那次事故里真正损失的东西,三十秒 502 换来两周的收录放缓。

另外,重启后第一件事不是看监控大盘,而是看 journalctl -p err -b 的条数。我在改造流程里加了一个对比:把本次启动的错误条数和历史中位数比,超过三倍就说明有新的东西在持续报错,通常是某个依赖没起来导致的连锁反应。这个动作十秒钟,抓到过一次 Redis 连不上导致的所有请求都在重试的问题。

把规则写成清单贴在墙上

最后总结成四条,都是被坑过之后才信的:

只改配置就 reload,别 restart;换了包或者改了进程级参数必须 restart,reload 会给你一个「看着没事其实没生效」的假象;整机 reboot 之前一定确认服务都 enable、磁盘有余量、有一条老 SSH 会话留着;重启后必须做写操作验收,只验首页 200 等于没验。

还有一句:任何需要「祈祷」的重启,都说明这个系统还不健康。如果你不知道重启后会发生什么,那就还没有资格重启它——先去做那次演练。

Last modification:October 24th, 2026 at 12:24 pm

Leave a Comment