返回 2026-06-17
⚙️ 工程

生产环境调试Debugging on Prod

idiallo.com·2026-06-16

重新设计十年老博客并清理大量冗余代码后,遭遇了仅在生成环境中触发的 500 错误。这类仅在 Prod 环境出现的 Bug 通常与特定的服务器配置、缓存或并发请求有关。作者详细记录了从复现问题到排查底层代码与部署环境差异的完整调试过程。解决此类问题的关键在于建立环境等价性测试并利用精准的日志进行追踪。

Ibrahim Diallo

最糟糕的 bug 是那种只发生在 prod(生产环境)的 bug,而且只在 prod 上发生。如果你在过去几周访问过这个博客,可能会遇到一个大大的 500 错误。

我已经用了 10 年同样的设计,想要点新鲜感。但是谁能在重新设计的同时不改善一下底层代码呢?我删了一大堆东西:从没用过的旧模板、post.new7.old.php、一堆没用的 CSS。我必须这么做。

我部署了第一个版本,所有页面都运行得很好。但随后我有些飘飘然了。我决定使用 GitHub Copilot 来进一步改善底层代码。起初我很警惕,逐行审查生成的代码。这些代码其实都不复杂,只是重构函数之类的操作。但慢慢地,我变懒了。我让 AI 自行更新了弃用的函数。

下一次部署时,网站返回了 500 错误。我检查了日志,什么也没发现。没有错误。我查看了正在运行的进程,注意到有几个 PHP 进程的 CPU 占用率固定在 100%。我回退了代码,但服务器仍然卡住。我重启了 Web 服务器,重启了 PHP-FPM,都无济于事。唯一有效的办法是重启整个机器。

我在自己的机器上运行了相同的代码,运行得很好。这时我才注意到,我在 prod 上运行的是旧版本的 PHP:prod 是 PHP 8.3,而本地是 PHP 8.4。我心想,没问题,于是升级了 prod,但这当然没能解决任何问题。我等到了夜里,重新部署了出错的代码,并逐行调试,直到我发现 Copilot 竟然自作主张地“更新”了我使用的 Markdown 库中的代码。如果你对 Markdown 有所了解,你就会知道它很复杂。这个特定的改动在解析 Markdown 时导致了无限递归。我根本不想通读所有那些代码去搞清楚它到底是怎么出错的,所以我直接把它还原了。

我重新部署了,问题似乎解决了。然后我收到了一封电子邮件:“你的网站挂了,”一位读者在半夜写道。当我的美国读者还在熟睡时,欧洲读者不知为何却起个大早在读我的博客(真的非常感谢你们)。

所以,直接在生产环境上进行实时调试是行不通的。我再次回退到了旧代码。但是,在我修复了 Markdown 问题之后,网站怎么还是会出错呢?更糟的是,它在本地依然运行良好。为了以防万一,我把那个非常老旧的 Markdown 库升级成了更酷、更现代的库:Parsedown。

这也没有解决问题。我一部署,整个网站就崩溃了,甚至包括那些根本不用 Markdown 的页面。这下彻底惹怒我了。你要怎么调试一个只在 prod 上出错的网站呢?我还有几手绝活。

首先,我写了一个 bash 脚本,以便在网站的不同版本之间快速切换。它真正要做的,只是在 "latest" 文件夹和我随意选择的另一个文件夹之间切换符号链接。

> ln -s /path/to/latest/working/version current
> ln -s /path/to/selected/version current

因为我运行的是 PHP,而且每个请求的生命周期都很短,所以我可以切换到出错的版本进行调试,然后几乎瞬间切换回正常的版本。我又不是有几百万读者在疯狂请求我的服务器。

这个方法奏效了,但速度很慢,而且它会把内部信息暴露给成千上万抓取我网站的 RSS 阅读器。每天有 3 万到 6 万次 RSS 阅读器请求访问这个网站。我可承担不起将调试代码暴露给这么大流量的风险。

所以我采用了第二种方法:这是一种更好的在生产环境进行实时调试的方法,既不会破坏 URL,也不会给毫无防备的 RSS 阅读器抛出 500 错误。如果同时运行两个版本的网站会怎样?访问常规域名,你会得到最新可用的版本;访问自定义子域名,你会得到出错的版本。

我通过创建一个新的 Apache 配置来实现这一点,该配置指向最新(出错的)路径。这样一来,我就有充足的时间直接在生产环境中调试问题,而不会干扰正常的访问流量。

我最终找到了根本原因。这是一连串因素叠加导致的故障。在本地,我直接运行 PHP;而在生产环境,我运行的是 PHP-FPM。为什么会有这种差异?因为生产环境上的 Apache 运行 HTTP/2,这需要 SSL 连接,而我在本地不需要;并且通过 HTTP/2 提供 PHP 服务需要用到 PHP-FPM。PHP-FPM 本质上是 PHP 实例的进程管理器。这解释了两种环境配置的差异,但并不是导致该 bug 的真正原因。

真正的问题出在我的缓存机制上。当页面从缓存中读取时,我设置了以下 header:

X-FROM-CACHE: 1

这只是一个自定义 header。当页面不是来自缓存时,我将该值设置为 0。下面是设置 header 的代码:

public function process() {
    foreach ($this->header as $key => $value) {
        if (!empty($value)) {
            header("$key: $value");
        } else {
            header("$key");
        }
    }
}

那么,这里会出什么问题呢?当页面不是从缓存提供时,$value 被设置为 0。你现在看出来了吧?在 PHP 中,`0 == empty()` 的求值结果为 true。因此,每当页面不是从缓存提供,或者在部署后清空缓存后第一次访问该页面时,实际执行的是以下代码:

header("X-FROM-CACHE");

这是一个无效的 header。那么为什么在生产环境会失败,而在本地却没事呢?因为 Apache 会静默忽略无效的 header,但 PHP-FPM 不会。它会抛出 500 错误:

malformed header from script 'index.php': Bad header:
Error parsing script headers
AH01075: Error dispatching request to :

Header 需要遵循互联网标准(RFC 9110)中定义的键值对规则。移除该条件判断并始终使用 `header("$key: $value")` 就解决了问题。

这个博客引擎运行在我在本地拥有的多台机器上。我以前从来不用担心环境配置问题,因为 Apache 和 PHP 都具有很高的容错性。Rasmus Lerdorf 曾在一次演讲中说过,当你不知道自己在做什么时,PHP 反而能运行得更好。设置 header 的条件判断是有其用处的。例如,如果你想指定某个页面为 404,你可以返回:

header("HTTP/1.0 404 Not Found");

但在我的场景中并不需要这样做。虽然 Copilot 提供了一些帮助,但这也提醒了我,对待 LLM 生成的代码必须仔细审查。这进一步坚定了我的一个信念:我永远无法成为一名 10x 工程师,因为我生成的代码越多,需要审查的代码也就越多。而且我越是信任它,它就越有可能在背后给我捅刀子。

需要完整排版与评论请前往来源站点阅读。