没有不值得去解决的问题,也没有不值得去学习的技术!

为下一次 PHP 8.5 升级做准备:从两次验证失败到自动回滚工具完成

【图4:Codex 完成最终代码审查、138 项测试与 Git 提交】

作者:

在

OneinStack 生产运维实践

【图 3:OneinStack 原生方案与 oneinstack-custom 的完整对比表】

(1) OneinStack 生产定制分支正式公开:从 PHP 8.5 与 Nginx/OpenSSL 实战到 GitHub 持续维护

【图 1:阿里云 ECS 系统盘使用率超过 80% 的告警邮件】

(2) 阿里云 ECS 系统盘占用超过 80%:清理 PHP、Nginx、Redis 升级残留后从 81% 降至 42%

【图15:第二次生产切换成功,PHP 8.5.12、PHP-FPM、FastCGI 和 WordPress 全部验证通过】

(3) 紧急升级 PHP 8.5.12:一次耗时 2.5 小时的生产环境安全更新

【图4:Codex 完成最终代码审查、138 项测试与 Git 提交】

(4) 为下一次 PHP 8.5 升级做准备:从两次验证失败到自动回滚工具完成

上一次,我将服务器上的 PHP 从 8.5.9 升级到了 8.5.12。

虽然最终升级成功,但整个过程花了约两个半小时。期间还遇到了切换失败、回滚异常等问题,最后经过进一步检查和再次切换,才完成了生产部署。

对于偶尔才需要执行一次的 PHP 补丁升级来说,这个耗时还是有些长了。

因此,我决定在这次升级完成之后,继续完善自己的 OneinStack 定制仓库,希望下一次 PHP 8.5 发布补丁版本时,能够在半小时内完成升级。

这里的半小时是一个目标,并不代表现在已经实现。

这次主要完成的是升级工具的开发、验证和整理。

一、先确定升级方案:接受短暂停机

一开始,我考虑过让新旧 PHP-FPM 同时运行,然后通过 Nginx 平滑切换到新版本。

这种方式理论上可以缩短网站不可用的时间,但我仔细想了一下,感觉未必适合自己的服务器。

服务器上有多个网站。如果 PHP 请求转发配置分散在不同的 Nginx 文件中,那么为了切换版本,就可能需要检查和修改多个配置文件。

即使可以通过公共配置统一管理,也得先确认是否存在例外。否则,很容易发生部分网站完成切换、部分网站仍使用旧配置的问题。

而我真正想解决的,并不是必须做到网站完全不停机。

我的主要目标是缩短整个升级过程,并且在出错时能够可靠恢复。

因此,最终决定采用比较简单的方案:

新版 PHP 在隔离目录中提前编译、组装和验证。准备完成之后,停止旧 PHP-FPM,保留旧安装目录,将新版切换到 /usr/local/php,重新启动 PHP-FPM,再通过真实请求确认服务恢复。

如果新版启动或验证失败,则尝试恢复旧版。

整个流程不修改 Nginx 配置,也不需要重新加载 Nginx。

只要最终切换阶段的中断时间可以接受,这种方式就没有必要引入新旧 PHP-FPM 并行运行的额外复杂度。

二、先把升级前的准备工作工具化

这次首先完善了三个部分。

第一个是 PHP 配置迁移。

升级 PHP 时,我希望继续保留经过审核的生产配置,而不是让新版本的默认配置覆盖原有设置。同时,也需要检查旧配置中是否存在已经失效或不兼容的选项。

第二个是发行目录组装。

PHP 主程序安装到隔离目录之后,再将针对目标版本编译的 Imagick、LDAP 和 Redis 扩展加入发行目录。

组装过程中检查文件、配置、扩展和 SHA-256 校验值,最终生成一份可以独立验证的发行包。

第三个是真实请求健康检查。

PHP-FPM 服务显示正在运行,并不代表它已经能够正确处理 PHP 请求。

因此,我增加了 FastCGI 健康探针,要求实际请求成功,并核对 PHP 版本、扩展版本、执行用户和关键配置。

这些工具开发完成后,我开始使用前一次升级留下的 PHP 8.5.12 构建文件,在阿里云服务器上进行真实隔离验证。

结果很快发现了问题。

三、第一次失败:打包器不适配真实安装目录

本地测试通过之后,我将打包器放到服务器上,使用 Python 3.6.8 运行。

候选配置验证正常,但组装发行目录时失败了。

【图1:PHP 8.5.12 真实发行包组装失败,候选配置验证已通过】
【图1:PHP 8.5.12 真实发行包组装失败,候选配置验证已通过】

经过检查,问题出在打包器对 PHP 安装目录结构的假设上。

最初的实现要求隔离安装目录中,必须已经存在 PHP 扩展目录,例如:

lib/php/extensions/no-debug-non-zts-20250925

但实际构建流程并不是这样。

PHP 主程序通过 make install 安装到隔离目录时,这个扩展目录不一定已经存在。Imagick、LDAP 和 Redis 是后续单独编译,再加入最终发行目录的。

因此,不存在扩展目录,并不能说明 PHP 主程序安装不完整。

原来的检查条件过于严格,反而拒绝了正常的安装结果。

后来将验证方式调整为读取隔离安装中的 php-config,确认它记录的正式扩展目录与目标 ABI 一致,然后在组装发行包时创建相应目录。

修改之后重新执行本地测试,再到服务器进行验证,发行包组装和完整性检查都顺利通过。

这也说明,本地模拟出来的目录结构即使能够通过测试,也未必与真实的 PHP 安装结果完全一致。

四、第二次失败:FastCGI 探针误判正常响应

接下来,我继续测试独立 PHP-FPM。

这次没有使用生产 Socket,而是在临时目录中创建独立配置,启动一个专门用于验证的 PHP-FPM 实例。

FPM 配置检查通过,进程也成功启动。

但健康探针一直返回失败:

Missing STDOUT stream end

最终,在 30 秒等待时间内连续尝试了 120 次,全部未通过。

【图2:独立 PHP-FPM 已启动,但 FastCGI 健康探针连续验证失败】
【图2:独立 PHP-FPM 已启动,但 FastCGI 健康探针连续验证失败】

起初还不能确定问题究竟出在 PHP-FPM,还是探针本身。

于是,我进一步检查了真实 FastCGI 响应的记录结构。

结果发现,PHP-FPM 已经正确执行了 PHP 脚本,返回了 JSON 数据。PHP 版本、执行用户、三个扩展和随机校验值也都符合预期。

真正的问题,是探针对于响应结束方式的判断过于严格。

实际收到的记录顺序是:

STDOUT → END_REQUEST

而原先的探针还要求在 END_REQUEST 之前必须出现一条额外的空 STDOUT 记录。

由于没有收到这条空记录,即使 PHP-FPM 已经成功处理请求,探针仍然将结果判定为失败。

修复这一判断条件之后,我又增加了相应的正反测试,继续检查响应内容、结束状态、版本、扩展以及配置是否正确。

再次使用真实 PHP-FPM 测试时,健康探针在第 2 次尝试通过。

这次测试没有切换生产 PHP,也没有重启生产 PHP-FPM。

相比单纯检查 Socket 是否存在,这种真实请求验证更有意义。但它仍然不能代替经由 Nginx 的网站功能检查。

五、实现短暂停机切换和自动回滚

完成前面的验证之后,我开始让 Codex 实现最终的切换脚本。

这部分最重要的不是减少几条命令,而是处理升级中可能出现的异常。

例如,旧服务无法正常停止时,不能继续移动安装目录。

如果新版 PHP-FPM 启动失败,应该尽可能恢复旧安装目录,并重新启动旧服务。

而如果回滚本身也失败,脚本就不能输出成功结果,而是必须保留现场和证据,明确要求人工恢复。

除此之外,还需要防止重复执行、并发执行、意外覆盖备份目录等问题。

最终实现了 php85_fpm_cutover.py,并增加了一组覆盖正常切换、自动回滚和故障注入的测试。

这里特意保留了一个限制:

默认执行模式不会真正切换生产 PHP。

即使需要执行完整预检和实际切换,也必须分别使用对应模式。正式切换还需要明确授权。

这样可以减少误操作导致网站中断的风险。

不过,到这个阶段为止,切换和回滚只是通过了本地模拟,还没有完成真实环境中的完整停启演练。

六、尝试 Docker 隔离演练,但没有继续强行推进

为了进一步验证切换脚本,我原本打算使用本机 Docker 创建一个完全独立的测试环境。

理想情况下,可以在容器内部准备两个 PHP 8.5 补丁版本,执行真实 PHP-FPM 停止、目录切换、重新启动和失败回滚,并测量停机时间。

这样既能测试真实进程,又不会影响阿里云服务器上的网站。

但这一步遇到了新的限制。

Codex 所在的执行会话无法访问本机的 Docker Socket:

/var/run/docker.sock

【图3:Codex 执行环境无法访问 Docker Socket,真实切换演练未开始】
【图3:Codex 执行环境无法访问 Docker Socket,真实切换演练未开始】

需要说明的是,这不代表 ThinkPad 上的 Docker 服务本身不可用。

我后来确认,在正常的本地终端会话中,Docker Engine 可以访问。受到限制的是当时 Codex 所在的执行环境。

因此,这次没有创建测试容器,也没有完成真实切换和回滚计时。

其实还可以继续调整测试环境,让 Codex 在具备权限的执行方式下完成验证。

但考虑到这次主要目标是为下一次 PHP 补丁更新做准备,而不是立即部署新版本,我决定暂时停止继续投入。

对于这种低频维护任务,没有必要为了补齐一次演练,再花费大量时间处理测试环境问题。

七、最终完成 138 项测试并提交代码

虽然 Docker 演练没有完成,但已有工具和模拟测试仍然需要正式收尾。

于是,我让 Codex 对仓库中尚未提交的 PHP 8.5 相关修改进行最终审查,包括配置迁移、发行包、健康探针、切换脚本、回滚逻辑以及使用文档。

最终结果如下:

检查项目结果
自动化测试总数138
通过133
跳过5
失败0
Bash 语法检查109 个文件通过
PHP 语法检查通过
Python 3.6 兼容性静态检查通过
敏感文件检查通过
Git 差异检查通过

其中,5 项跳过的是仍需要额外真实构建输入的集成测试,并不代表执行失败。

新增切换代码的 Python 3.6.8 实际运行验证尚未完成,这一点也在最终结果中明确保留了说明。

【图4:Codex 完成最终代码审查、138 项测试与 Git 提交】
【图4:Codex 完成最终代码审查、138 项测试与 Git 提交】

代码最终提交到了 GitHub:

仓库: shuijingwan/oneinstack-custom

提交: 72c6dd14

提交说明: feat: 完成 PHP 8.5 补丁升级工具并明确验证边界

随后,我将提交推送到了 main 分支。

到这里,本轮开发算是结束了。

八、下一次升级能否控制在半小时内?

目前还不能确认。

这次已经将不少原本需要人工检查和操作的内容工具化,包括配置迁移、发行包组装、文件完整性验证、真实 FastCGI 健康检查,以及短暂停机切换和自动回滚。

其中,发行包组装和独立 PHP-FPM 健康检查已经在真实服务器上验证过。

但完整的生产切换、失败回滚以及升级全过程计时,还需要等下一次补丁更新时进一步验证。

尤其是 PHP 和扩展的编译时间,本身就可能占用升级过程中的大部分时间。仅仅提高切换速度,并不能保证整个升级过程一定在半小时内完成。

因此,下次升级时,我准备继续沿用这次确定的原则:

优先在不影响网站的情况下完成编译、配置检查和发行包准备,提前确认旧版本的恢复条件。所有必要准备通过后,再进入短暂停机切换阶段。

如果预检失败,就停止升级,而不是为了赶时间跳过检查。

如果切换失败,则按回滚流程恢复,并保留完整的操作证据。

至于 Nginx 平滑切换,现阶段仍然没有引入的必要。

写在最后

回顾这次开发,我觉得最值得记录的,并不是最终通过了多少项测试,而是两次真实验证发现的问题。

第一次是打包器对于 PHP 安装目录的假设不符合真实构建结果。

第二次是 FastCGI 探针对于响应结束记录的判断过于严格,把已经成功处理的请求误判为失败。

这两个问题都没有在最初的本地测试中暴露出来。

所以,自动化测试很有用,但不能完全替代真实环境验证。

另一方面,真实演练也不意味着每次都必须立即做到最完整。对于还没有新版本需要部署的维护任务,可以在风险、收益和时间成本之间作出取舍。

这次已经完成了工具开发和代码提交,接下来可以先放一放。

等下一次 PHP 8.5 补丁版本发布时,再看看这套工具能否真正将升级时间从两个多小时缩短到半小时以内。

紧急升级 PHP 8.5.12:一次耗时 2.5 小时的生产环境安全更新

关于作者 · 持续学习与技术实践

我是一名拥有 15 年以上开发经验的技术从业者,自 2013 年起持续运营个人技术博客,记录工作与个人项目中遇到的真实问题,以及解决问题过程中的探索、实践和思考。

除了技术写作,我也在持续开发和维护自己的独立项目,探索新技术和新工具在实际场景中的应用。我始终相信,没有不值得去解决的问题,也没有不值得去学习的技术。

如果这篇文章对你有所帮助,欢迎浏览博客中的其他文章,也欢迎交流技术经验与想法。

关于我  ·  GitHub  ·  邮件联系