Jump to Navigation

thrift-php暴内存深坑填坑

thrift-php暴内存深坑填坑

起因

项目中一次偶然的端口错误配置,导致thrift-php的客户端请求到了http的端口上,然后fpm进程内存超限异常退出。

至于如何查到这个问题,在一个大项目中定位也不容易。不过再此不多展开,多看nginx日志,php日志还是管用的。

复现

在更短的代码中复现问题能够更容易的定位问题。

拿git.apache.org/thrift.git/tutorial/php/PhpClient.php示例代码稍微改一下即可。

修改的部分:

use Thrift\Transport\TFramedTransport;   // 开始位置添加一行

 $socket = new TSocket('10.88.128.15', 8000);    // 在原$socket之后直接添加一行,这里的端口是http协议的

  $transport = new TFramedTransport($socket, true,true);  // 在原$transport之后直接添加一行

这时候执行就等着进程暴内存崩溃吧。php PhpClient.php:

PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 1213486192 bytes) in git.apache.org/thrift.git/lib/php/lib/Thrift/Transport/TSocket.php on line 278

此处图片

初步定位

TSocket.php:278行的代码:

$data = fread($this->handle_, $len);

此处图片

这行是个php内置函数,应该没有大问题,最有可能的问题是$len。输出一下就能确定。

另一个问题,当前函数是从哪个调用过来的,需要检查上层调用函数是谁,为什么传递了错误的$len参数过来。

    if ($len > 123456) {
        throw new \Exception('wtf');
    }

在278行之前,加这么一行,追溯到出现异常$len参数的整个调用栈。

此处图片

从抓图中能够看到首次出现是在TFrameTransport.php(110) readFrame() 函数中调用TTransport->readAll()时出现的。

查看代码,添加打印log,可以发现这个$len的来源是传输流最开始4个字节转为整数的值。

 $buf = $this->transport_->readAll(4);
 $val = unpack('N', $buf);
 $sz = $val[1];

  $this->rBuf_ = $this->transport_->readAll($sz);

$buf 变量的4个字节,是从:8000端口读取到的。我们知道:8000端口是HTTP的情况下,首先返回 HTTP 200 OK一行响应。也就是$buf == "HTTP",转换为$sz之后是一个比较大的数,换算一下约是1.2G。这就是导致php进程暴内存退出的原因了,在php-fpm进程上应该是一样的。

修复初步考虑

  • 判断 $buf 是否是 "HTTP"字符串
  • 判断如果 $sz 超过某个值则报错

由于之前对thrift协议并不太熟悉,总觉得需要再看下文档。在有了这个大概的指导思考之后,又翻了翻thrift的协议文档,果然发现这个坑的来历。

实际上thrift-rpc.md中说明了这一点,关于frame的大小的限制问题。可惜thrift-php代码中并未发现这个协议标准要求的frame大小的相关实现。后来搜索代码,发现thrift-go/thrift-java是实现了这个协议标准的。那么说thrift-php到底是忘记(填坑)实现这个协议标准了吗?

https://github.com/apache/thrift/blob/master/doc/specs/thrift-rpc.md#framed-vs-unframed-transport

参考实现

既然thrift-xxx已经实现了这个功能,不妨参考一下,所以需要看看其源代码。

为TFrameTransport类添加一个常量,以及一个变量,分别表示规范默认的frame最大长度,以及应用设置的frame最大长度。

const DEFAULT_MAX_LENGTH = 16384000; private $maxLength_ = TFramedTransport::DEFAULT_MAX_LENGTH;

以及在readFrame方法中,添加对读取到的长度变量的判断:

if ($sz < 0) {
    throw new TTransportException("Read a negative frame size ({$sz})!", TTransportException::CORRUPTED_FRAME);
}
if ($sz > $this->maxLength_) {
    throw new TTransportException("Frame size ({$sz}) larger than max length ({$this->maxLength_})!", TTransportException::CORRUPTED_FRAME);
}

这样TFrameTransport类的实现就符合thrift spec规范了。并且能够有效处理frame无效的情况。

其中,TTransportException::CORRUPTED_FRAME 定义为一个错误常量编号。

const CORRUPTED_FRAME = 5;

这个修复,能够防止thrift客户端连接到http服务端导致的暴内存导致php-fpm进程异常退出问题。

非TFrameTransport的问题

在处理上面TFrameTransport的问题时,想到可能其他*Transport会不会有问题,试了一下果然也是有同样问题的,比如TBufferedTransport这个类。

而在thrift spec规范中并没有这个类读取长度的限制,而且从原理上来说,也无法确定读取的最大长度限制,所以直接添加长度限制这种修复方式并不太适用,虽然有用但不是最好的处理方式。

碰到这个问题,差点没过去坎,太难找到一种很好的修复方法了。还好,在摸索了差不多2天,找到了自认为修复thrift-php这个问题的方法。请读者君继续阅读一下节。

针对TSocket的进一步的修复

再回头看最开始的问题,是php/fpm进程内存超限后异常退出。那么这一步修复的主要思路就出来,在读取数据长度大于php设置的内存上限之前就提前计算出来,并在预计会超过php内存上限提前报出错误,就不会产生进入到php核心代码之后出现的Fatal错误了。

以下是修复代码实现:TSocket.php

添加私有类成员变量:

private $memoryLimit_ = -1;

构造函数:__construct()中:

$this->memoryLimit_ = TSocket::return_bytes(ini_get('memory_limit'));

read()方法中:

    $curmem = memory_get_usage(true);
    if ($this->memoryLimit_ > 0 && ($curmem + $len) > $this->memoryLimit_) {
        throw new TTransportException('TSocket: Allowed memory size of '.$this->memoryLimit_.
                                      ' bytes will be exhausted (tried to allocate '.
                                      $len. ' bytes)');
    }

这种修复方法适用于所有的Transport,应该还是比较准确的,毕竟不会超出内存上限,并且能够友好的报告错误。

通过以上两个修复步骤,实现针对所遇问题的完整的修复。并能够在其他未知情况下做出的最好的处理,防止php进程超出内存上限而崩溃,并报告有效错误信息。

关于这个问题的深入考虑

  • 实际上server端并没有这么大的数据响应,fread为啥会导致暴内存?

https://github.com/php-src/php/blob/0eb3c377d49a331282b943dba165b4b9df56fad2/ext/standard/file.c#L1813

看这一行,会发现php首先按照参数分配内存,并不关心是否真有这么大的数据。

这种写法也有php实现的考虑吧,毕竟简单直接,速度快。

除了这种实现方式之外,还考虑到一种实现方式,采用分块读取的方式,分配一个比较小的读取buf char[5120],每读取一次把临时buf中的数据追加到要返回的buffer中,这要在多数情况下,由于服务端返回的数据长度也是有限的,也能够防止一些异常情况。

当然这种方式,对程序执行效率有影响,需要一次内存拷贝,以及多次的内存分配操作。

问题总是要修的,可能出现的问题一定会出现的,根据情况判断,尽量高效还没有bug更好。

在当前这个问题上,也不能说是php全错的,因为php就是提供了安全内存分配并报错的机制,这是php本身允许的。我的认为是问题出在thrift-php的实现上,代码实现并不够严谨。

  • 对于php设置为内存无限使用的情况,修复后的代码会如何执行?

对于最大frame size的规范的修复方式,这种假期没有影响,依旧如前的有效。

对于第二种修复方式,导致结果是,程序尽力在可用的内存资源范围内让程序不崩溃并完成任务。

比如,上面遇到的问题是要读取一个1.2G的数据,如果这时php设置的内存无限制,实现上也就只能够读取到大概600字节的数据(但瞬时内存的确会用掉1.2G),并且由后面的协议解析时发现这个错误。所以资源很重要,还是会影响程序的运行的。

  • 这过程中还尝试过的方法

使用ob_start, ob_end抓取输出,但是对Fatal信息不起作用。

使用register_shutdown_function功能,这个从逻辑上比较复杂,而且在命令行执行php和fcgi执行时实现并不相同,所以没有采用。

Category:

Add new comment

Plain text

  • No HTML tags allowed.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Lines and paragraphs break automatically.
CAPTCHA
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.
Image CAPTCHA
Enter the characters shown in the image.


Main menu 2

Story | by Dr. Radut