这一年多我给十来台 VPS 做重装和迁移,踩得最多的一个坑不是配置文件写错,而是「系统自带的那套默认值,在一次升级之后悄悄换了」。PHP 从 7.x 跳到 8.x、MySQL 从 5.7 跳到 8.4、Debian 从 11 跳到 13,每次大版本跨越都会带来几个失效点,而这些失效点在报错信息里几乎看不出来,只会表现为「网站突然打不开了」「SSH 连不上了」「图片全没了」。
这篇不讲怎么装新版本,专门讲升级完成、服务也起来了,但站点行为变了这一类问题。我会按 PHP、MySQL、Debian、Nginx、PHP 扩展与字体五条线,把每个失效点写成「现象 → 根因 → 三行命令定位 → 修法」的格式,最后给一个升级后三十分钟内可以跑完的验收清单。
一、先弄清为什么升级会「无声破坏」
升级造成的故障大致分四类,分清类别能省掉大量瞎猜:
- 默认值翻转:新版本把某个开关的默认状态改了,配置里没写就等于「改了」。典型是 PHP 8 的 error_reporting、MySQL 8.4 的认证插件默认值。
- 功能被移除:老的扩展、函数、协议支持被彻底删掉。PHP 8 移除了 mysql_* 系列函数,OpenSSH 10 移除了 DSA。
- 兼容层收窄:行为还保留,但前提变严。比如 MySQL 8 对 GROUP BY 的 ONLY_FULL_GROUP_BY 默认开启,以前能跑的 SQL 现在直接报错。
- 依赖件版本漂移:系统升级顺手带上了新版本的库,而扩展编译时链接的是旧库,表现为「模块加载失败」或「符号未定义」。
我的经验是:升级前先把当前状态快照下来,升级后逐项对比,比事后一处处试错快十倍。
# 升级前留一份运行时快照,出事了能立刻对比
mkdir -p /root/pre-upgrade
php -v > /root/pre-upgrade/php.txt
php -m > /root/pre-upgrade/php-modules.txt
php -i | grep -E 'Loaded Configuration|extension_dir' >> /root/pre-upgrade/php.txt
mysql --version > /root/pre-upgrade/mysql.txt
nginx -V > /root/pre-upgrade/nginx.txt 2>&1
dpkg -l > /root/pre-upgrade/packages.txt
cat /etc/os-release > /root/pre-upgrade/os.txt
ss -lntup > /root/pre-upgrade/listen.txt这份快照最值钱的地方不是「系统信息」,而是监听端口和已加载模块的清单。升级后如果少了某个监听端口,或者 PHP 模块列表少了一行,问题基本就锁定在那了。
二、PHP 8.x:五个最常见的静默失效
1. 致命错误不再输出到页面,白屏代替了报错
PHP 8 的默认 display_errors 在多数发行版包配置里是 Off,而 production 的 php.ini 一般还会配 error_reporting = E_ALL & ~E_DEPRECATED。升级后老主题里的 deprecated 用法被拦下,页面直接 500 白屏,浏览器什么也不显示。
定位方法:不要在前端猜,直接看 FPM 的 error log,并且临时把错误打到日志里。
# 找到实际的 php.ini 与 FPM 日志位置(不要凭记忆)
php --ini | grep 'Loaded Configuration'
grep -E '^(error_log|display_errors|error_reporting|log_errors)' /etc/php/*/fpm/php.ini
# 临时打开详细日志(改完 reload,不要 restart)
# 在 php.ini 或 pool 配置里加:
# php_admin_value[error_log] = /var/log/php-fpm/www-error.log
# php_admin_value[log_errors] = On
# php_admin_value[display_errors] = Off
systemctl reload php*-fpm
tail -f /var/log/php-fpm/www-error.log注意一个容易混的点:display_errors 只控制「输出到响应体」,log_errors 才控制「写进日志」。生产环境正确组合是 display_errors=Off、log_errors=On,而不是两个都关。很多「升级后白屏且毫无线索」的事故,就是两个都关了。
2. mysql_* 函数消失,老插件直接崩
PHP 7 就移除了 mysql_* 系列,但如果你的站点是从更老的代码迁过来的,或者装了一个多年没更新的插件,升级到 PHP 8 之后第一个撞上的就是 Call to undefined function mysql_connect()。
不要急着找「兼容包」。正确的做法是定位调用点,改成 PDO 或 mysqli。扫描一遍代码库就能知道工作量:
# 扫描老函数调用,按文件统计
grep -rEn '\bmysql_(connect|query|fetch_array|fetch_assoc|real_escape_string|num_rows)\s*\(' \
/var/www --include='*.php' | awk -F: '{print $1}' | sort | uniq -c | sort -rn | head -20如果命中的是第三方插件目录,优先级应该是「升级插件」> 「找替代插件」> 「自己改」。自己改第三方插件意味着下次更新会覆盖,属于给自己挖坑。
3. 动态属性弃用警告刷爆日志
PHP 8.2 起,未声明属性的动态赋值会触发 Deprecated。老主题与老插件里这种写法极多,日志一天能涨几百 MB,磁盘和 I/O 一起被拖垮。
# 统计 24 小时内出现最多的 Deprecated 类型
grep -oE 'Deprecated: [^:]+' /var/log/php-fpm/*.log | sort | uniq -c | sort -rn | head处理顺序:先在 php.ini 里把 E_DEPRECATED 从错误显示级别里排除(保命),再逐个修真正的代码问题(治本)。
; php.ini —— 先保命,不要让弃用警告写满日志
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
; 确认实际生效值(CLI 与 FPM 是两个 php.ini,别改错文件)
; php-fpm 用 php -i 看不出来,要写一个 phpinfo 页面或在 pool 里查4. 扩展目录变了,模块加载全失败
从 PHP 7.4 升到 8.x 时,/etc/php/7.4 变成 /etc/php/8.2,extension_dir 也跟着变。如果你手工在 php.ini 里写过绝对路径的 extension=,或者把老版本的 .so 拷贝过去,启动时会出现一行警告然后 502。
# 对比升级前后的模块清单,找差集
comm -13 <(sort /root/pre-upgrade/php-modules.txt) <(php -m | sort)
# 检查 extension_dir 指向的真实目录里有没有 .so
php -i | grep extension_dir
ls -l $(php -r 'echo ini_get("extension_dir");')正确的做法是用发行版的包管理器装扩展(apt install php8.2-redis),而不是拷 .so。拷贝的 .so 会链接到旧 ABI,加载时报 undefined symbol,而报错往往只在 FPM 启动日志里出现一次,很容易被忽略。
5. 短标签被彻底抛弃
<? 这种短标签从 PHP 7.0 起就不受默认支持,PHP 8 之后更没有任何兜底。表现非常有欺骗性:页面上直接显示出 PHP 源码文本,或者模板的一部分整块消失。
# 找出使用短标签的文件(注意排除 <?php 与 <?=)
grep -rln '<?[^p=x]' /var/www --include='*.php' | head -20
# 更精确:只匹配 <? 后面紧跟空格或换行的
grep -rEn '<\?(\s|$)' /var/www --include='*.php' | head -20如果文件数量很多,不要一个个改,用 perl 批量替换并保留备份,替换完立刻在测试环境跑一遍首页与关键页面。
三、MySQL 8.4:认证插件与 SQL 模式的连环坑
MySQL 8.4 是我今年遇到问题最多的一个版本,因为它同时做了两件事:把 caching_sha2_password 变成唯一默认,以及彻底移除 default_authentication_plugin 配置项。
1. 老客户端连不上:Plugin 'mysql_native_password' is not loaded
升级后,旧账号若仍是 mysql_native_password 插件,连接会直接失败。更麻烦的是,你在 my.cnf 里写 default_authentication_plugin=mysql_native_password 想回退,MySQL 8.4 会因为无法识别这个参数而拒绝启动——这就是典型的「默认值翻转 + 功能被移除」双重打击。
# 1) 先看账号用的是什么插件
mysql -uroot -p -e "SELECT user,host,plugin FROM mysql.user WHERE plugin IS NOT NULL;"
# 2) 把账号迁到 caching_sha2_password
mysql -uroot -p -e "ALTER USER 'wpuser'@'localhost' IDENTIFIED WITH caching_sha2_password BY '新密码';"
# 3) 如果某个客户端实在不支持 sha2,临时启用兼容插件(注意:装了才存在)
# my.cnf 里写 mysql_native_password=ON(8.4 写法),而不是 default_authentication_plugin
# 4) 重启后确认插件已加载
mysql -uroot -p -e "SELECT PLUGIN_NAME,PLUGIN_STATUS FROM information_schema.PLUGINS WHERE PLUGIN_NAME LIKE '%password%';"关键判断点:8.0 用 default_authentication_plugin,8.4 用 mysql_native_password=ON,两个写混了服务直接起不来。这一点在很多升级教程里都没写清楚。
2. ONLY_FULL_GROUP_BY 让老 SQL 全部报错
MySQL 5.7 起 sql_mode 默认包含 ONLY_FULL_GROUP_BY,8.x 继续保留。老站点的统计查询里常见 SELECT a, b, COUNT(*) FROM t GROUP BY a,升级后报 Expression #2 of SELECT list is not in GROUP BY clause。
# 查看当前 sql_mode
mysql -uroot -p -e "SELECT @@GLOBAL.sql_mode, @@SESSION.sql_mode;"
# 统计慢日志/错误日志里这类报错的条数,评估影响面
grep -c 'not in GROUP BY clause' /var/log/mysql/error.log我的建议是不要靠删掉 ONLY_FULL_GROUP_BY 来蒙混过关。删掉它只是把「不确定结果的查询」放回来了,一旦表结构变化,数据会静默出错。正确做法是给 GROUP BY 补全字段,或者用 ANY_VALUE() 明确表达「这一列取任意值都行」的意图。
3. 连接数与线程缓存被重置
升级时如果 my.cnf 被包管理器替换(提示 .dpkg-dist),自定义的 max_connections、innodb_buffer_pool_size 会全部回到默认值。表现是:站点在并发稍高时突然出现 Too many connections,而你以为参数还在。
# 升级后必查:配置是否被替换、关键参数是否生效
ls -l /etc/mysql/my.cnf* /etc/mysql/mysql.conf.d/ 2>/dev/null
mysql -uroot -p -e "SHOW VARIABLES WHERE Variable_name IN
('max_connections','innodb_buffer_pool_size','innodb_log_file_size',
'tmp_table_size','max_allowed_packet','character_set_server');"养成一个习惯:my.cnf 里每个自定义参数旁边写一行注释,记录「为什么是这个值」。半年后你(或接手的人)才有依据判断要不要恢复。
四、Debian 13 / OpenSSH 10:连不上服务器的三个新原因
1. DSA 密钥彻底不可用
Debian 13 带的 OpenSSH 9.8p1 之后,DSA 即使加兼容参数也不能用了;OpenSSH 10 更是把 DSA 代码整个删掉。如果你的备份脚本、监控探针、CI 里用的还是老 DSA 密钥,升级后这些自动化任务会静默失败,而人工登录(用新密钥)却一切正常,非常容易漏掉。
# 检查现有密钥类型
for k in ~/.ssh/id_*; do [ -f "$k" ] && ssh-keygen -l -f "$k"; done
# 检查脚本里有没有直接引用 dsa 密钥
grep -rn 'id_dsa\|ssh-dss' /etc/cron* /root/bin /opt 2>/dev/null
# 生成 Ed25519 并部署
ssh-keygen -t ed25519 -a 100 -C "ops@$(hostname)" -f ~/.ssh/id_ed25519
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@host部署顺序很重要:先把新公钥加进 authorized_keys 并验证能登录,再改 sshd 配置禁用旧算法。反过来做就是把自己锁在门外。
2. 旧的 moduli 文件导致密钥交换失败
OpenSSH 10 默认禁用了有限域 DH,并收紧了 /etc/ssh/moduli 的要求。如果这个文件是几年前的、或者被手工裁剪过,握手会在 kex 阶段失败,客户端只报一句 no matching key exchange method found。
# 服务端与客户端分别看支持的 kex 算法
ssh -Q kex
sshd -T 2>/dev/null | grep -i kexalgorithms
# 客户端调试(这一步能看到协商到哪一步断的)
ssh -vvv -o ConnectTimeout=8 user@host 2>&1 | grep -iE 'kex|offer|no matching'修复不需要改配置,重新生成 moduli 即可。这一步耗时几分钟(取决于 CPU),建议放在维护窗口做:
# 生成新的 moduli(约 5~10 分钟),先输出到临时文件再替换
ssh-keygen -M generate -O bits=3072 /tmp/moduli.candidates
ssh-keygen -M screen -f /tmp/moduli.candidates /tmp/moduli.new
cp /etc/ssh/moduli /etc/ssh/moduli.bak
install -m 644 /tmp/moduli.new /etc/ssh/moduli
systemctl reload ssh
# 保留当前会话不退出,另开一个窗口验证能登录3. socket 激活模式让 sshd 看起来「没运行」
较新的发行版把 ssh 做成了 socket 激活:ssh.socket 监听 22,ssh.service 只在有连接时才拉起。于是 systemctl status ssh 显示 inactive,很多人以为服务挂了开始乱重启。
systemctl status ssh.socket ssh.service
ss -lntp | grep ':22'
# 判断到底是哪种模式
systemctl is-enabled ssh.socket ssh.service理解这个模型之后还有一个好处:改 sshd 配置后 systemctl reload ssh 在 socket 模式下不一定生效,需要 systemctl restart ssh.socket 或直接重启对应实例。这一点在排查「改了配置没反应」时非常关键。
五、Nginx 与前端资源:升级后用户还在看旧版
Nginx 大版本升级最常见的问题不是配置语法,而是模块丢失。用发行版包升级时,原本通过第三方源装的 brotli、geoip2、headers-more 模块可能不再提供,配置里一引用就启动失败。
# 升级前后对比编译参数与模块
nginx -V 2>&1 | tr ' ' '\n' | grep -E 'with-|add-module' > /tmp/nginx-now.txt
diff <(grep -oE 'add-module=[^ ]+' /root/pre-upgrade/nginx.txt | sort) \
<(grep -oE 'add-module=[^ ]+' /tmp/nginx-now.txt | sort)
# 配置语法检查(务必在 reload 之前)
nginx -t前端资源方面,升级往往伴随版本号或文件名变化。如果静态资源 URL 没有带指纹(hash),用户的浏览器和 CDN 会继续用旧文件,表现是「样式错乱但清缓存就好了」。如果 URL 带指纹,问题会反过来:HTML 引用了新文件,而 CDN 上还没有,出现大片 404。
# 检查 HTML 里引用的资源是否全部可访问(把域名换成自己的)
curl -s https://www.example.com/ | grep -oE '(src|href)="[^"]+\.(css|js|woff2)"' \
| sed -E 's/.*="([^"]+)"/\1/' | while read -r u; do
case "$u" in http*) url="$u";; /*) url="https://www.example.com$u";; *) url="https://www.example.com/$u";; esac
code=$(curl -s -o /dev/null -w '%{http_code}' "$url"); echo "$code $url"
done | sort | uniq -c | sort -rn | head六、字体与中文渲染:一个容易被忽略的回归
服务器升级后如果做过图片水印、PDF 导出、OG 图片生成,很可能出现「中文全变成方块」。原因是 fontconfig 缓存与字体包版本变了,原来能匹配到的字体现在匹配不到。
# 列出系统识别到的中文字体
fc-list :lang=zh | head -10
# 看某个具体字体名能不能匹配
fc-match "Noto Sans CJK SC"
# 重建缓存(升级后建议做一次)
fc-cache -fv >/dev/null && fc-list :lang=zh | wc -l另外一个隐形问题是 ImageMagick 的策略文件在升级时被替换,原本允许的 PDF 读写又被禁掉了。表现是图片生成脚本突然报 not authorized,而代码一行没改。
# 检查 policy.xml 里是否禁用了你需要的格式
grep -nE 'rights="none"' /etc/ImageMagick-*/policy.xml | head
# 只放行必要格式,不要整体改成 rights="read|write" 全开七、升级后 30 分钟验收清单
下面这份清单我每次升级都跑一遍,按顺序执行,能在半小时内把 90% 的回归抓出来。每一条都对应一类前面讲的失效点。
第一步:服务与端口是否齐全
# 和升级前的快照对比监听端口,少的那个就是问题
ss -lntup | awk '{print $1,$5}' | sort > /tmp/listen-now.txt
diff /root/pre-upgrade/listen.txt /tmp/listen-now.txt | head -20
systemctl --failed第二步:运行时版本与模块
php -v | head -1
php -m | wc -l
comm -13 <(sort /root/pre-upgrade/php-modules.txt) <(php -m | sort) # 新增
comm -23 <(sort /root/pre-upgrade/php-modules.txt) <(php -m | sort) # 丢失,重点看这个
mysql --version
nginx -t第三步:页面实际渲染
# 状态码 + 关键标记,一次看清首页是否正常
curl -s -o /tmp/home.html -w 'code=%{http_code} ttfb=%{time_starttransfer} size=%{size_download}\n' \
https://www.example.com/
grep -c '<?php' /tmp/home.html # 必须 0:为 0 说明短标签没有泄漏
grep -c 'Fatal error\|Warning:' /tmp/home.html # 必须 0
# 数据库连接是否正常(用站点自己的账号,而不是 root)
mysql -u站点账号 -p -h127.0.0.1 -e 'SELECT 1;' 站点库 && echo DB_OK第四步:错误日志扫一遍
# 只看升级后新产生的错误,别被历史日志干扰
journalctl --since '30 minutes ago' -p err --no-pager | tail -40
tail -50 /var/log/nginx/error.log
tail -50 /var/log/php-fpm/*.log
tail -50 /var/log/mysql/error.log第五步:关键用户路径
首页能打开不代表没问题。至少手测这几条:搜索一次(验证数据库查询与字符集)、登录后台(验证 session 与 cookie)、发一条评论(验证写入与邮件)、上传一张图片(验证上传目录权限与 GD/Imagick)、打开一篇带代码块的文章(验证转义与高亮)。
# 后台登录页与搜索接口的最小连通性检查
curl -s -o /dev/null -w 'admin=%{http_code}\n' https://www.example.com/admin/
curl -s -o /dev/null -w 'sitemap=%{http_code}\n' https://www.example.com/sitemap.xml
curl -s 'https://www.example.com/?s=test' | grep -c '<article'八、回滚:先想清楚「退回到哪」
升级前如果没有准备回滚路径,出事时只能在原地修,风险高很多。我的做法是三件事一起做:
- 数据库做一份逻辑备份,并在升级完成后立刻单独恢复到一个临时库验证可用。备份没验证过等于没有备份。
- 代码目录做原子快照,用 releases 目录或 git tag,保证能一键切回上一版。
- 记下旧版本号,确认包管理器里旧包还能装回来(apt 的 cache 里是否有旧 .deb)。
# 升级前的三件套
mysqldump --single-transaction --quick --routines --triggers 站点库 | gzip > /root/backup/pre-upgrade-$(date +%F).sql.gz
# 验证备份能恢复(这一步最容易被跳过)
zcat /root/backup/pre-upgrade-$(date +%F).sql.gz | mysql 临时验证库
# 记住旧版本号
dpkg -l | grep -E 'php|mysql|nginx' > /root/pre-upgrade/pkg-versions.txt另外提醒一点:数据库大版本升级(如 5.7 → 8.0 → 8.4)之后,不能靠降级二进制回退,因为数据字典已经升过级了。这种情况只能从备份恢复。所以跨大版本的升级一定要在维护窗口做,并且备份要提前一天做好、验证过。
九、把经验固化成检查脚本
上面这些步骤手敲一次可以,每次升级都手敲一定会漏。我把它写成了一个脚本,升级后直接跑,输出一份带 OK/FAIL 的报告。
#!/usr/bin/env bash
# /root/bin/post-upgrade-check.sh —— 只读检查,不做任何修改
set -uo pipefail
fail=0
ck() { # ck 描述 命令...
local desc="$1"; shift
if "$@" >/dev/null 2>&1; then echo "[ OK ] $desc"; else echo "[FAIL] $desc"; fail=$((fail+1)); fi
}
ck "nginx 配置语法" nginx -t
ck "php-fpm 运行中" systemctl is-active --quiet php8.2-fpm
ck "mysql 可连接" mysqladmin ping --silent
ck "磁盘未超 85%" bash -c '[ $(df -P / | awk "NR==2{print \$5}" | tr -d %) -lt 85 ]'
ck "无 failed 单元" bash -c '[ -z "$(systemctl --failed --no-legend)" ]'
ck "首页返回 200" bash -c '[ "$(curl -s -o /dev/null -w %{http_code} https://www.example.com/)" = 200 ]'
echo "---- 摘要 ----"
echo "失败项: $fail"
exit $((fail>0))脚本放进 crontab 每天跑一次,结果写进日志文件,超过阈值再用邮件或机器人推给自己。升级后的问题往往不是立刻爆发的,而是几天后在某个低频路径上才出现,日常巡检能把这类问题提前暴露出来。
十、几条省时间的经验
最后说几条我自己总结的、不在任何文档里的经验:
- 升级前先跑一遍
nginx -t和php -l递归检查,把语法问题在重启之前挡掉。语法错误导致的 502 占了升级事故的一半以上。 - 不要一次性升级所有组件。先升 PHP,观察一天;再升数据库,再观察。两个一起升,出了问题连是哪边引起的都分不清。
- 把「哪个 php.ini 在生效」这件事搞清楚。CLI 和 FPM 用的不是同一个文件,很多「改了没生效」都是这个原因。
- 数据库账号改认证插件时,务必先在测试库用同样的客户端版本连一次。客户端库版本决定了 sha2 能不能握手成功。
- 升级窗口避开搜索引擎抓取高峰。如果升级期间返回大量 500,爬虫可能降低抓取频率,恢复起来要好几周。
升级这件事本身没有难点,难的是知道新版本改了哪些默认值。养成「升级前快照、升级后对比」的习惯,比背任何一份参数清单都管用。上面这份清单你可以直接抄走,改成自己站点的域名和路径就能用。