最新发布《数智时代的AI人才粮仓模型解读白皮书(2024版)》,立即领取! 了解详情
写点什么

nginx fastcgi 缓存设计缺陷导致的 502 错误

  • 2019-09-26
  • 本文字数:6205 字

    阅读完需:约 20 分钟

nginx fastcgi 缓存设计缺陷导致的 502 错误

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。


1  fastcgi_buffer_size 1k;
复制代码


再构造一个产生 warning 信息的 php 脚本。这个脚本接收一个 GET 参数 len 来控制 warning 信息的长度。error_log 函数会直接输出内容到 warning 信息。


1<?php2// 为稳定 php-fpm 输出数据的结构,特别强制触发 stdout 输出3ob_flush();4flush();56// 强制触发 stderr 输出7$msg = str_pad("e", $_GET["len"], "e");8error_log($msg);
复制代码


最后编写一个 shell 脚本来测试递增的 len 对应的 http status。这个脚本中使用 curl 命令来获取 http 请求的 status。


1#!/bin/bash  23# 缓存是1024字节,实验到4000应该足够了4for((i=0;i<=4000;i++));  5do  6    s=`curl -I -m 10 -o /dev/null -s -w %{http_code} "1.1.1.1/index.php?len=$i"` 7    printf "len=%4s  status= %3s \n" $i $s8done  
复制代码


运行结果如下。可以看出随着 len 的递增,http status 分为 200 和 502, 且是交叉分段分布的。


由于结果过长, … 简化表示后续递增的 len 值下,status 和前面一样。


 1len=   0  status= 200    //结果为 200 的 len 区间开始 2len=   1  status= 200  3... 4len= 935  status= 200  5len= 936  status= 502    //结果为 502 的 len 区间开始 6... 7len=1014  status= 502  8len=1015  status= 200    //结果为 200 的 len 区间开始  9...10len=1959  status= 200 11len=1960  status= 502    //结果为 502 的 len 区间开始12...13len=2038  status= 502  14len=2039  status= 200    //结果为 200 的 len 区间开始15...16len=2983  status= 200 17len=2984  status= 502    //结果为 502 的 len 区间开始18...19len=3062  status= 502 20len=3063  status= 200    //结果为 200 的 len 区间开始21...22len=4000  status= 200 
复制代码


从此输出可以观察到,随着 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 的形式进行封装的。


其结构如下:


 1typedef struct { 2    unsigned char version; 3    unsigned char type; 4    unsigned char requestIdB1; 5    unsigned char requestIdB0; 6    unsigned char contentLengthB1; 7    unsigned char contentLengthB0; 8    unsigned char paddingLength; 9    unsigned char reserved;10    unsigned char contentData[contentLength];11    unsigned char paddingData[paddingLength];12} 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 成员是枚举类型,本文涉及的几个类型如下:


1#define NGX_HTTP_FASTCGI_BEGIN_REQUEST  12#define NGX_HTTP_FASTCGI_ABORT_REQUEST  23#define NGX_HTTP_FASTCGI_END_REQUEST    3   // 结束一个请求4#define NGX_HTTP_FASTCGI_PARAMS         45#define NGX_HTTP_FASTCGI_STDIN          5   6#define NGX_HTTP_FASTCGI_STDOUT         6   // fastcgi 应用输出的 stdout 数据7#define NGX_HTTP_FASTCGI_STDERR         7   // fastcgi 应用输出的 stderr 数据8#define NGX_HTTP_FASTCGI_DATA           8
复制代码


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 字节。


 10000   02 00 00 00 45 00 04 40 20 d3 40 00 40 06 00 00   ....E..@ Ó@.@... 20010   7f 00 00 01 7f 00 00 01 23 28 dd 17 18 de 72 81   ........#(Ý..Þr. 30020   2d 15 13 4b 50 18 08 05 60 41 00 00 01 07 00 01   -..KP...`A...... 40030   03 a9 07 00 65 65 65 65 65 65 65 65 65 65 65 65   .©..eeeeeeeeeeee 50040   65 65 65 65 65 65 65 65 65 65 65 65 65 65 65 65   eeeeeeeeeeeeeeee 6...省略连续的e 703c0   65 65 65 65 65 65 65 65 65 65 65 65 65 65 65 65   eeeeeeeeeeeeeeee 803d0   65 65 65 65 65 65 65 65 65 65 65 65 0a 00 00 00   eeeeeeeeeeee.... 903e0   00 00 00 00 01 06 00 01 00 44 04 00 58 2d 50 6f   .........D..X-Po1003f0   77 65 72 65 64 2d 42 79 3a 20 50 48 50 2f 37 2e   wered-By: PHP/7.110400   30 2e 31 32 0d 0a 43 6f 6e 74 65 6e 74 2d 74 79   0.12..Content-ty120410   70 65 3a 20 74 65 78 74 2f 68 74 6d 6c 3b 20 63   pe: text/html; c130420   68 61 72 73 65 74 3d 55 54 46 2d 38 0d 0a 0d 0a   harset=UTF-8....140430   00 00 00 00 01 03 00 01 00 08 00 00 00 00 00 00   ................150440   00 65 65 65                                       .eee16
复制代码


此包中, 第一个 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 数据。


1X-Powered-By: PHP/7.0.122Content-type: text/html3charset=UTF-8
复制代码


非常重要的是,此 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 字节。


 10000   02 00 00 00 45 00 04 38 21 ed 40 00 40 06 00 00   ....E..8!í@.@... 20010   7f 00 00 01 7f 00 00 01 23 28 dd 6e e7 c9 4c 31   ........#(ÝnçÉL1 30020   52 32 46 48 50 18 08 05 d6 5a 00 00 01 07 00 01   R2FHP...ÖZ...... 40030   03 a8 00 00 65 65 65 65 65 65 65 65 65 65 65 65   .¨..eeeeeeeeeeee 50040   65 65 65 65 65 65 65 65 65 65 65 65 65 65 65 65   eeeeeeeeeeeeeeee 6...省略连续的e 703b0   65 65 65 65 65 65 65 65 65 65 65 65 65 65 65 65   eeeeeeeeeeeeeeee 803c0   65 65 65 65 65 65 65 65 65 65 65 65 65 65 65 65   eeeeeeeeeeeeeeee 903d0   65 65 65 65 65 65 65 65 65 65 65 0a 01 06 00 01   eeeeeeeeeee.....1003e0   00 44 04 00 58 2d 50 6f 77 65 72 65 64 2d 42 79   .D..X-Powered-By1103f0   3a 20 50 48 50 2f 37 2e 30 2e 31 32 0d 0a 43 6f   : PHP/7.0.12..Co120400   6e 74 65 6e 74 2d 74 79 70 65 3a 20 74 65 78 74   ntent-type: text130410   2f 68 74 6d 6c 3b 20 63 68 61 72 73 65 74 3d 55   /html; charset=U140420   54 46 2d 38 0d 0a 0d 0a 00 00 00 00 01 03 00 01   TF-8............150430   00 08 00 00 00 00 00 00 00 08 00 00               ............
复制代码


直接找到第 1025 字节,它位于 0x0420 行第 13 个位置。


0420   54 46 2d 38 0d 0a 0d 0a 00 00 00 00 01 03 00 01
复制代码


可以很容易的看出它是一个 FCGI_END_REQUEST 型 FCGI_Record 的开始字节。这个 FCGI_Record 的版本号是 1, 类型是 FCGI_END_REQUEST (0x03), 用来终止一个请求的处理。它本身不重要,重要的是,它前面的 stdout FCGI_Record 刚好接收完成。

6502 非完全连续分布的原因

抓包分析可知,php-fpm 返回的 stderr 数据确定是完整的,当它的长度大于 nginx 的缓存大小时,为什么在有些情况下没有触发 502 呢?


经过分析 nginx 的源码,找到了原因。


 1// src\http\modules\ngx_http_fastcgi_module.c 2 3if (u->buffer.pos == u->buffer.last) { 4 5    if (!f->fastcgi_stdout) { 6        u->buffer.pos = u->buffer.start; 7        u->buffer.last = u->buffer.pos; 8        f->large_stderr = 1; 9    }1011    return NGX_AGAIN;12}
复制代码


这里可以看到,当缓存已经充满 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。


1display_errors = Off;2log_errors = On;3error_log = "path/to/log";
复制代码


工程上使用的 php-fpm,在产生一个合格的 HTTP 响应时,需要使用 fastcgi 协议和 nginx 通讯,有兴趣的同学可以自行阅读 fastcgi 协议规范、php-fpm 的源码、nginx 的相关模块的源码。


作者介绍:


比克(企业代号名),目前负责贝壳找房主站研发的相关工作。


本文转载自公众号贝壳产品技术(ID:gh_9afeb423f390)。


原文链接:


https://mp.weixin.qq.com/s/-3XBN1_ru2jF3P-rakOjKg


2019-09-26 23:561060

评论

发布
暂无评论
发现更多内容

Authing 宣布推出云原生「多租户」身份解决方案

Authing

身份云 数字化转型 SaaS 多租户

技术干货| MongoDB时间序列集合

MongoDB中文社区

mongodb

直播可以使用 https 了,快来试试吧

CRMEB

初识DevOps

天翼云开发者社区

DevOps 运维 前端开发

实战攻略:企业如何一步步建立自己的数字孪生

WorkPlus

2022 开源之夏 | Curve 邀你与中国存储软件共成长,赢万元奖金

网易数帆

分布式 云原生 存储 Ceph curve

自助洗车怎么洗?来看看洗车教程

共享电单车厂家

自助洗车加盟 自助洗车怎么洗 自助洗车机使用

京东面试题:ElasticSearch深度分页解决方案

爱好编程进阶

Java 程序员 后端开发

Chrome Devtools调试小技巧

百度Geek说

后端

密钥管理系统-为你的天翼云资产上把“锁

天翼云开发者社区

数据 数据安全 密码管理

案例成果展 | 一朵“航空云”为国航APP核心业务保驾护航

York

云原生 敏捷实践 应用现代化

疫情期间,IT运维人员远程办公软件有哪些?

行云管家

远程办公 IT运维 服务器运维 居家办公 运维软件

全渠道CRM系统解决方案

低代码小观

低代码 CRM 客户关系管理 CRM系统 客户关系管理系统

【国产】分布式批量作业调度平台TASKCTL产品验证的几种方式

TASKCTL

程序员 DevOps 分布式 ETL任务 自动化运维

戴尔赋能科创小企业,共塑科创大时代

科创人

Nebula Graph|信息图谱在携程酒店的应用

NebulaGraph

图数据库 知识图谱 NebulaGraph

多家波卡生态项目招聘开发者,高薪职位等你来 Pick!

One Block Community

区块链 招聘 波卡生态

龙蜥开源内核追踪利器 Surftrace:协议包解析效率提升 10 倍! | 龙蜥技术

OpenAnolis小助手

Linux 网络协议 系统运维 龙蜥社区 Surftrace

突破疫情限制,WorkPlus助力企业打开远程高效办公新模式

WorkPlus

客户体验和客户服务的区别

龙国富

客户服务 客户体验管理

Klocwork 2022.1推出Kotlin分析引擎

龙智—DevSecOps解决方案

klocwork perforce

​对 Jenkins 和 CloudBees CI 的 UI 改进

龙智—DevSecOps解决方案

CloudBees

游戏美术和设计师的福音,Helix DAM 测试版来了!

龙智—DevSecOps解决方案

perforce Helix DAM

云计算平台与传统平台的区别是什么?怎么理解?

行云管家

云计算 云服务 IDC

架构训练 模块五

小马

「架构实战营」

最佳实践 | 用腾讯云AI文字识别从0到1实现通信行程卡识别

牵着蜗牛去散步

腾讯 文字识别 技术实践 腾讯云AI 疫情防控

2022年国内外好用的10大甘特图软件(团队使用)

PingCode

项目管理 Worktile 研发管理 甘特图 PingCode

关于数据一致性解决方案

穿过生命散发芬芳

数据一致性 5月月更

2022-05微软漏洞通告

火绒安全

微软 终端安全 安全漏洞

Spark离线开发框架设计与实现

百度Geek说

后端

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

阿里巴巴终端技术

flutter 钉钉 移动端 跨端框架 桌面端

nginx fastcgi 缓存设计缺陷导致的 502 错误_文化 & 方法_比克_InfoQ精选文章