1 摘要
看似正常的 php-fpm 请求处理,nginx 却返回 502,出错的原因是因为 php warning 信息触发了 nginx fastcgi 缓存上的缺陷。本文详细描述了此缺陷的复现方法,最后在第 7 部分给出了结论和改进的办法。
最后部分是基于本文内容,对 php 开发者提出的的编程小建议,它们能让大家写出更加健壮的服务。(本篇文章 PC 浏览器下阅读体验更佳)
2 现象概述
在 LNMP 架构的 Web 服务器中,nginx 会偶现 502 错误,且会有 too big header 的日志记录。 但是观察 php 程序的输出,它的 header 部分都不超过 1K,(nginx 的 fastcgi 缓存一般不小于 4K),肯定不是单纯地因为 header 过大。
发生此情况时,php 程序还会同时触发 warning 信息,这个 warning 信息可能与这个 502 情况有关。那么是否是 warning 信息过长导致 502 的呢?经过简单的实验观察发现,并非 warning 信息越长,越会触发 502,这里应该存在着更加复杂的规律。
3 寻找规律
这里我们从 warning 的长度这条线索出发,通过一个实验来统计 warning 信息长度和 502 之间的关联关系。
软件环境为 windows 10 x64 系统,php 7.0.12, nginx 1.11.5。nginx 缺陷相关的代码和其最新的 master 分支代码没有区别, 所以使用 1.11.5 即可。
为了易于分析,我们把 nginx 的 fastcgi 的缓存设置为 1K,正常服务器应该不小于 4K。
再构造一个产生 warning 信息的 php 脚本。这个脚本接收一个 GET 参数 len 来控制 warning 信息的长度。error_log 函数会直接输出内容到 warning 信息。
最后编写一个 shell 脚本来测试递增的 len 对应的 http status。这个脚本中使用 curl 命令来获取 http 请求的 status。
运行结果如下。可以看出随着 len 的递增,http status 分为 200 和 502, 且是交叉分段分布的。
由于结果过长, … 简化表示后续递增的 len 值下,status 和前面一样。
从此输出可以观察到,随着 warning 信息长度的变化,会不断有连续分布的 502 出现:
1014 - 936 + 1 = 79,2038 - 1960 + 1 = 79,3062 - 2984 + 1 = 79。
每一组连续的 502,个数都是 79 个。
1960 - 936 = 1024,2984 - 1960 = 1024。
相邻每组 502 对应 len 的差值稳定为 1024,这个值和 fastcgi 的缓存大小设置相等。
4fastcgi 通信协议
这里约定两种特殊的 C 语言简化表达方式:
第一种,当数据结构中两个相邻的成员,它们的名字除了结尾的 “B1”、“B0” 不同,其他部分都相同的时候,这意味着,这两个成员应该被当做一个两字节整数读取,其整数值为 B1 << 8 + B0,整数名字为成员的原始名字去除了 B1、B0 后缀的样子。这是一种多字节整数的简化表达方法。
第二种,允许 struct(C 语言结构)含有变长数组成员。
在 fastcgi 协议中,服务器和应用端传输的所有数据,都是以 FCGI_Record 的形式进行封装的。
其结构如下:
一个 FCGI_Record 的标准结构前面包含一些定长的成员,尾部则是变长的内容成员和对齐用字节成员。下面分别介绍各个成员的含义:
version : 表示 FastCGI 协议的版本号,目前是 FCGI_VERSION_1。
type :表示此 FCGI_Record 的类型,预示着此 FCGI_Record 的主要功能。下面是本文相关的几个类型。
requestId : 表示此 FCGI_Record 属于哪个 FastCGI 请求。
contentLength : 表示后面成员 contentData 的长度。
paddingLength : 表示后面成员 paddingData 的长度。
contentData : 字节数组,长度范围在 [0, 65535],根据 FCGI_Record 类型不同需要采用不同的解读方法。
paddingData : 字节数组,长度范围在 [0, 255],处理时忽略该内容。
其中 type 成员是枚举类型,本文涉及的几个类型如下:
nginx 对 fastcgi 应用传送过来的 stdout 和 stderr 数据进行解析时,是把零散传递的若干 FCGI_Record 的 拼接起来,当做字节流进行解析的。
5too big header 的触发原因
从源码观察到,nginx 触发 too big header ,是因为解析 FCGI_Record 数据流时,下层返回了 NGX_AGAIN ,这表明下层需要更多的数据进行解析。而此时缓存已经满了,nginx 只能以 502 响应退出,同时记录错误日志。
5.1 抓包分析 len=936
这里我们对比分析 len 为 935 和 936 的情况。len = 935 不会触发 502,但是 len = 936 则会触发。
这里把 len = 936 的 FCGI_Record 流数据进行 TCP 抓包,下面是核心 TCP 包内容,除去包头后,内容开始于 0x0020 行第 13 个字节,长度为 1048 字节。
此包中, 第一个 FCGI_Record 结构开始于 0x0020 行第 13 个字节,加粗下划线的即为开始的 4 个字节:
0020 2d 15 13 4b 50 18 08 05 60 41 00 00 01 07 00 01
0x01 表示 fastcgi 的协议号,为 1。0x07 表示此 FCGI_Record 的类型,为 FCGI_STDERR ,表明是 stderr 数据,contentData 长度为 0x03a9,paddingData 长度为 0x07,整个 FCGI_Record 的长度为 8 + 0x03a9 + 0x07,其中 8 为 FCGI_Record 的定长的头部长度 。
第二个 FCGI_Record 结构开始于 0x03e0 行第 5 个字节,加粗下划线的即为开始的 12 个字节:
03e0 00 00 00 00 01 06 00 01 00 44 04 00 58 2d 50 6f
0x01 表示 fastcgi 的协议号,为 1。0x06 表示此 FCGI_Record 的类型,为 FCGI_STDOUT ,表明是 stdout 数据。contentData 长度为 0x0044,paddingData 长度为 0x04,整个 FCGI_Record 的长度为 8 + 0x0044 + 0x04 = 80 。
可以直接观察到 contentData 内容为下列的 header 数据。
非常重要的是,此 contentData 内容结尾有两个换行符 0x0d 0x0a:
0420 68 61 72 73 65 74 3d 55 54 46 2d 38 0d 0a 0d 0a
这个序列表示响应的 header 部分已经全部发送完成。但是这个字符串的位置是 [1025, 1029) ,超出了 1024 的范围。
此 FCGI_Record 中还有长度为 4 字节的 paddingData 成员,作为字节对齐用,如下加黑下划线字符:
0430 00 00 00 00 01 03 00 01 00 08 00 00 00 00 00 00
5.2 nginx 区别处理 header 和 body
nginx 解析 header 时,是需要全部解析完成,再一次性发送给客户端的。
len = 936 的例子中,缓存的长度 (1024) 不足以接收到 header 结束标志 (0x0d 0x0a 0x0d 0x0a)。
而 nginx 处理 body 数据并非是全部接收完再发给 HTTP 客户端的,而是一面接收一面发送,这样,不论 nginx 的缓存有多大,它都可以成功发送任意大的 body 数据,(当然也要符合 nginx 的其他限定,比如最大响应时间)。
5.3 502 报错核心逻辑总结
网络模块先接收 stderr 数据到缓存中。
状态机模块解析完缓存中的 1024 字节时,只要还没有确认所有 header 数据已经接收完成,肯定会返回一个 NGX_AGAIN 给上层,要求继续进行网络传输。
网络模块接收到这继续传输的要求后,却发现缓存已满。
虽然实际是 stderr 数据挤占了 stdout 数据的空间,nginx 却只能认为 stdout 数据流带有的 header 数据过大,最后记录 too big header 日志后以 502 退出。
5.4 抓包分析 len=935
当 len = 935 时,抓包结果如下。因为字节对齐处理的原因,此 TCP 包中内容的长度并非是 1048 - 1 = 1047, 而是 1040 字节。
直接找到第 1025 字节,它位于 0x0420 行第 13 个位置。
可以很容易的看出它是一个 FCGI_END_REQUEST 型 FCGI_Record 的开始字节。这个 FCGI_Record 的版本号是 1, 类型是 FCGI_END_REQUEST (0x03), 用来终止一个请求的处理。它本身不重要,重要的是,它前面的 stdout FCGI_Record 刚好接收完成。
6502 非完全连续分布的原因
抓包分析可知,php-fpm 返回的 stderr 数据确定是完整的,当它的长度大于 nginx 的缓存大小时,为什么在有些情况下没有触发 502 呢?
经过分析 nginx 的源码,找到了原因。
这里可以看到,当缓存已经充满 stderr 数据,但尚未收到过 stdout 的数据( f->fastcgi_stdout 为 0)时,那么就会清空缓存,继续接收剩下的数据。当缓存大小为 1K 的时候,此逻辑处理就会形成一个 1024 间隔分布的触发器。这就是本实验中,502 分布具有 1024 间距的原因。
所以为了稳定复现 502,php 例子代码增加了 stdout 的强制输出代码。
7 结论
由于对 stderr 和 stdout 数据共用一个缓存空间,且为同级优先级时,stderr 数据在特定长度下,就会挤压 stdout 数据的空间,从而触发 too big header 日志,造成 HTTP 502 响应。 但是此时 fastcgi 应用很可能已经正常处理了这个请求。
这种 502 和 fastcgi 进程不足造成的 502 的区别是,前者这个请求已经被处理了,可能已经执行了某些写入操作,在实际应用中,重试请求需要考虑幂等处理。而后者的这个请求,根本没有被 fastcgi 应用接受,重试请求也谈不上需要幂等处理。
如果要解决这个缺陷,最好的方案是能够提高 stdout 数据的优先级。当解析 stdout 数据空间不足时,可以清空缓存中的 stderr 数据,这样 too big header 就是真实的 too big header 了。当然,采取独立缓存也是一个办法,但是这样可能对 nginx 的性能有些影响。
8php 开发小建议
程序员不在乎 warning,是因为 warning 不影响正常流程。本例中,warning 在某种情形下会变为 error,进而影响服务的表现。所以测试和上线回归时,一定要消除所有 warning 。
遇到 php warning 导致的 502 时,增大 nginx 的 fastcgi_buffer_size 设置只能缓解问题:在 warning 长度较小时,能够避免产生 502。当代码的某个循环中出现 warning 时,stderr 数据量可能会很大,502 会呈现间断分布的特征。这里要强调,nginx 不能解决所有问题,程序员自己消除代码中所有产出的 warning 才是正当的解决方案。
如有需求的话,下面 php.ini 的设置值 (也可以放在 php-fpm.conf 中),可以将错误输出导入指定的文件,而不是返回给 nginx。
工程上使用的 php-fpm,在产生一个合格的 HTTP 响应时,需要使用 fastcgi 协议和 nginx 通讯,有兴趣的同学可以自行阅读 fastcgi 协议规范、php-fpm 的源码、nginx 的相关模块的源码。
作者介绍:
比克(企业代号名),目前负责贝壳找房主站研发的相关工作。
本文转载自公众号贝壳产品技术(ID:gh_9afeb423f390)。
原文链接:
https://mp.weixin.qq.com/s/-3XBN1_ru2jF3P-rakOjKg
评论