Python:谨防 Post 打爆 /tmp

前言

我们有个独立部署的文件传输服务,主要是通过 Flask 实现,对外提供的功能主要是接收客户端传输的文件,并将其转发至 RabbitMQ。

有次收到了磁盘告警:

本来这种告警没什么好特殊的,登录机器删除下文件就好了,然而这次似乎不是那么简单,因为这个增长有点神奇...

正常来说,磁盘空间的增长是一个斜斜的曲线,慢慢地、越来越大,然而这货,是个连续大波浪.. 这时候就需要好好分析下!

故障回顾

空间有释放,也就意味着有某个程序在清理着文件,而在刚才也交代过,这个机器只部署了一个服务,那这个表现极有可能是程序有关系,即时我们都知道代码并没涉及到 /tmp

打开错误日志发现程序在疯狂的报错:

Traceback (most recent call last):
  File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1475, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1461, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/root/server/api/data_interface.py", line 56, in profile_upload
    args = utils.get_args()
  File "/root/server/api/utils.py", line 1376, in get_args
    args = dict([(k, v) for k, v in request.values.items()])
  File "/usr/local/lib/python2.7/site-packages/werkzeug/local.py", line 343, in __getattr__
    return getattr(self._get_current_object(), name)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/utils.py", line 73, in __get__
    value = self.func(obj)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/wrappers.py", line 499, in values
    for d in self.args, self.form:
  File "/usr/local/lib/python2.7/site-packages/werkzeug/utils.py", line 73, in __get__
    value = self.func(obj)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/wrappers.py", line 492, in form
    self._load_form_data()
  File "/usr/local/lib/python2.7/site-packages/flask/wrappers.py", line 165, in _load_form_data
    RequestBase._load_form_data(self)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/wrappers.py", line 361, in _load_form_data
    mimetype, content_length, options)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/formparser.py", line 195, in parse
    content_length, options)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/formparser.py", line 100, in wrapper
    return f(self, stream, *args, **kwargs)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/formparser.py", line 212, in _parse_multipart
    form, files = parser.parse(stream, boundary, content_length)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/formparser.py", line 522, in parse
    return self.cls(form), self.cls(files)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/datastructures.py", line 382, in __init__
    for key, value in mapping or ():
  File "/usr/local/lib/python2.7/site-packages/werkzeug/formparser.py", line 520, in <genexpr>
    form = (p[1] for p in formstream if p[0] == 'form')
  File "/usr/local/lib/python2.7/site-packages/werkzeug/formparser.py", line 496, in parse_parts
    _write(ell)
IOError: [Errno 28] No space left on device

这个报错让我们有点摸不着头脑了。我们扫了一遍代码,确保是没有写到 /tmp 目录,而且我们只是一个文件转发服务,要爆也是内存爆,怎么可能是空间爆???

仔细看了报错,似乎写 /tmp 的不是我们的代码,我们可以看到很多的篇幅都是出现在 werkzeug/formparser.py

而这部分可能需要我们先稍微了解下 WSGI 协议: https://www.cnblogs.com/wilbe...

Post 数据处理

正如文章所述,我们能够在 Flask 聚焦于业务逻辑,而无需分心处理接受HTTP请求、解析HTTP请求、发送HTTP响应等等,全得益于 WSGI 帮我们屏蔽了太多的细节。

我们知道 requests 库在 Post 的时候,允许我们将数据通过 payload(form)files 的形式提交数据,

详细可看文档: https://2.python-requests.org...

而不管哪种方式的提交,都会变成 HTTP 报文的 body 一部分,传输到服务端,而 WSGI 也合理地处置它:

Flask 通过 _load_form_data 从客户端提交的数据中,也就是 environ['wsgi.input'] 分离出 formfiles ,将其设置到 Flask.request 对应的 multi dicts 里,譬如这些:

werkzeug/formparser.py 是这一环节的主力,可以简单看看源码(篇幅略长,已提取需要的函数):

# werkzeug/formparser.py

113 class FormDataParser(object):
114     def __init__(self, stream_factory=None, charset='utf-8',
115                  errors='replace', max_form_memory_size=None,
116                  max_content_length=None, cls=None,
117                  silent=True):
118         if stream_factory is None:
119             stream_factory = default_stream_factory
...   ... (省略其他)

202     @exhaust_stream
203     def _parse_multipart(self, stream, mimetype, content_length, options):
204         parser = MultiPartParser(self.stream_factory, self.charset, self.errors,
205                                  max_form_memory_size=self.max_form_memory_size,
206                                  cls=self.cls)
207         boundary = options.get('boundary')
208         if boundary is None:
209             raise ValueError('Missing boundary')
210         if isinstance(boundary, text_type):
211             boundary = boundary.encode('ascii')
212         form, files = parser.parse(stream, boundary, content_length)
213         return stream, form, files

...   ... (省略其他)


285 class MultiPartParser(object):
  
287     def __init__(self, stream_factory=None, charset='utf-8', errors='replace',
288                  max_form_memory_size=None, cls=None, buffer_size=64 * 1024):
289         self.stream_factory = stream_factory

...   ... (省略其他)

347     def start_file_streaming(self, filename, headers, total_content_length):
348         if isinstance(filename, bytes):
349             filename = filename.decode(self.charset, self.errors)
350         filename = self._fix_ie_filename(filename)
351         content_type = headers.get('content-type')
352         try:
353             content_length = int(headers['content-length'])
354         except (KeyError, ValueError):
355             content_length = 0
356         container = self.stream_factory(total_content_length, content_type,
357                                         filename, content_length)
358         return filename, container

...   ... (省略其他)

473     def parse_parts(self, file, boundary, content_length):
474         """Generate ``('file', (name, val))`` and
475         ``('form', (name, val))`` parts.
476         """
477         in_memory = 0
478
479         for ellt, ell in self.parse_lines(file, boundary, content_length):
480             if ellt == _begin_file:
481                 headers, name, filename = ell
482                 is_file = True
483                 guard_memory = False
484                 filename, container = self.start_file_streaming(
485                     filename, headers, content_length)
486                 _write = container.write
487
488             elif ellt == _begin_form:
489                 headers, name = ell
490                 is_file = False
491                 container = []
492                 _write = container.append
493                 guard_memory = self.max_form_memory_size is not None
494
495             elif ellt == _cont:
496                 _write(ell)
497                 # if we write into memory and there is a memory size limit we
498                 # count the number of bytes in memory and raise an exception if
499                 # there is too much data in memory.
500                 if guard_memory:
501                     in_memory += len(ell)
502                     if in_memory > self.max_form_memory_size:
503                         self.in_memory_threshold_reached(in_memory)
504
505             elif ellt == _end:
506                 if is_file:
507                     container.seek(0)
508                     yield ('file',
509                            (name, FileStorage(container, filename, name,
510                                               headers=headers)))
511                 else:
512                     part_charset = self.get_part_charset(headers)
513                     yield ('form',
514                            (name, b''.join(container).decode(
515                                part_charset, self.errors)))
516
517     def parse(self, file, boundary, content_length):
518         formstream, filestream = tee(
519             self.parse_parts(file, boundary, content_length), 2)
520         form = (p[1] for p in formstream if p[0] == 'form')
521         files = (p[1] for p in filestream if p[0] == 'file')
522         return self.cls(form), self.cls(files)

依次调用 FormDataParser._parse_multipartMultiPartParser.parseparse_partsparse_lines

在客户端请求的头部中,有一个属性值得关注:

这个 boundary 的值是变化的、用来切割请求体中的 Content-Disposition 数据的,格式如下:

parse_lines函数需要将上面的数据,根据规则,处理变成以下的格式:

Generate parts of
``('begin_form', (headers, name))``
``('begin_file', (headers, name, filename))``
``('cont', bytestring)``
``('end', None)``

Always obeys the grammar
parts = ( begin_form cont* end |
          begin_file cont* end 
        )*

然后 parse_parts 就能根据第一个元素知道拿到的数据是什么,是头部还是真实的数据。头部类型将决定临时数据的处理方式,如果头部是:

  • _begin_form ("begin_form") :

    • container 是 []
    • _write 是 container.append
  • _begin_file ("begin_file"):

    • container 是 default_stream_factory 函数创建的容器;
    • _write 是 start_file_streaming

如此看来,如果是表单数据, parse_parts 会倾向于直接在内存处理,那如果通过文件流方式,处理的方式会如何呢?

来看下 default_stream_factory 创建了什么容器:

# werkzeug/formparser.py

from tempfile import TemporaryFile
def default_stream_factory(total_content_length, filename, content_type,
                            content_length=None):
     """The stream factory that is used per default."""
     if total_content_length > 1024 * 500:
         return TemporaryFile('wb+')
     return BytesIO()

即使是特殊处理,还要再根据大小细分下: 1024 * 500 = 500k ,超过这个的话,就会触发的临时文件机制了;

就是这样层层折腾后,form 和 files 的数据分开,并妥善安置好了:

凶手浮现

看到上面的关于临时数据处理,看到 500k 的限制,再看下我们的文件大小分布:

我震惊了,小于 500k 的比例只有 2.75%,emmmm....这样相当于几乎所有数据都是走的临时文件方式的。

虽然看到 TemporaryFile 大概也能猜到七七八八是用到 /tmp 了,至于实现这里就不赘述了,感兴趣的童鞋可以去看下: tempfile.py

我们又翻查下故障前后的文件上传日志,仿佛看到了元凶....45m 的日志..

而我们的 /tmp 空间:

:~$ df -h
Filesystem      Size  Used Avail Use% Mounted on
...
/dev/sda8       2.0G  7.3M  1.9G   1% /tmp

这样问题大致就清楚了,我们的 /tmp 空间爆就是因为在接受用户数据时候,采用了 file 的提交方式,上传的文件太大、并发又较多,再加上 /tmp 又囊中羞涩... 自然就原地爆炸啦 ~~

在限制了文件的上传大小之后,业务果然就恢复了正常~

额外验证:临时文件触发机制

虽然我们已经找到故障根因,但是较真的我还是想要做个对比测试:

Case1: 在上传类型一样时,500k 大小会不会触发 tmp 文件的创建?

Case2: 在大小(> 500k)一样的时候,以 form 类型提交会不会触发 tmp 文件的创建?

在开始实验前,我们会发现,临时文件创删速度之快非尔等凡胎肉眼能跟上!怎么办?

官人莫怕,山人自由妙招!

当当当! inotify 登场!没有了解的童鞋可以先去了解和安装下了: https://man.linuxde.net/inoti...

我们可以通过这个工具来监控 /tmp 的变化:

~$ inotifywait -mrq --timefmt '%d/%m/%y/%H:%M' --format '%T %w %f %e' -e modify,delete,create --exclude '/tmp/[^t]' /tmp

PS: 大部分参数含义在上面的链接或者 man 手册可以查看,为了避免被其他临时文件干扰,通过正则过滤下: /tmp/[^t]

// 测试输出效果
28/01/20/20:22 ./ tmpfgAJT_ CREATE
28/01/20/20:22 ./ tmpfgAJT_ DELETE
28/01/20/20:22 ./ tmpfgAJT_ MODIFY
28/01/20/20:22 ./ tmpfgAJT_ MODIFY
28/01/20/20:22 ./ tmpfgAJT_ MODIFY
28/01/20/20:22 ./ tmpfgAJT_ MODIFY
28/01/20/20:22 ./ tmpfgAJT_ MODIFY

除了上面的工具,我们还需要准备其他东西,比如不同大小的文件:

~$ ls -l *20200128195500.log.gz
-rw-r--r-- 1 root root  515735 Jan 28 20:44 trace-eq_500k-0-20200128195500.log.gz
-rw-r--r-- 1 root root  511696 Jan 28 20:35 trace-lt_500k-0-20200128195500.log.gz

还有上传脚本:

# file_upload.py

import requests
import sys

log_path = sys.argv[1]

ret = requests.post(
      'http://localhost:20021/api/upload', 
      files={                                # 这里是 file 类型
          'test': open(log_path, 'rb')
      }
)

测试 case1,测试方法:依次上传两个文件,看 /tmp 的 inotifywait 有无输出:

限制值:1024 x 500 = 512000

文件:trace-eq_500k-0-20200128195500.log.gz
大小:515735 > 500k
命令:python file_upload.py trace-eq_500k-0-20200128195500.log.gz
inotifywait 结果:
29/01/20/00:17 /tmp/ tmpYTG8Na CREATE
29/01/20/00:17 /tmp/ tmpYTG8Na DELETE
29/01/20/00:17 /tmp/ tmpYTG8Na MODIFY
29/01/20/00:17 /tmp/ tmpYTG8Na MODIFY
29/01/20/00:17 /tmp/ tmpYTG8Na MODIFY
29/01/20/00:17 /tmp/ tmpYTG8Na MODIFY
29/01/20/00:17 /tmp/ tmpYTG8Na MODIFY
29/01/20/00:17 /tmp/ tmpYTG8Na MODIFY
... (省略剩余 117 行 tmpYTG8Na MODIFY)


文件:trace-lt_500k-0-20200128195500.log.gz
大小:511696 < 500k
命令:python file_upload.py trace-lt_500k-0-20200128195500.log.gz
inotifywait 结果:
(无输出)

测试 case2,测试方法:直接修改上传类型为 form,用 trace-eq_500k-0-20200128195500.log.gz 上传一次,看 /tmp 的 inotifywait 有无输出:

# form_upload.py

import requests
import sys

log_path = sys.argv[1]

ret = requests.post(
      'http://localhost:20021/api/upload', 
      data={                                # 这里是 form 类型
          'test': open(log_path, 'rb')
      }
)
限制值:1024 x 500 = 512000

文件:trace-eq_500k-0-20200128195500.log.gz
大小:515735 > 500k
命令:python file_upload.py trace-eq_500k-0-20200128195500.log.gz
inotifywait 结果:
(无输出,但是从服务端的代码: flask -> request.form 已经看到数据了)

结论

经过上面的测试,我们已经能够石锤以上的结论:

  1. 如果是通过 file 形式上传,那么超过 500k 的文件将会征用 /tmp 用来临时存放数据,直到数据处理完会自动清理(可以通过环境变量 TMPDIRTEMPTMP 修改);
  2. 如果是通过 form 形式上传,不管是多大都会读到内存,因为会使用列表作为载体,不过小心内存泄漏和 payload 过大哦;
  3. 两者的读写效率我盲猜会有较大差距,有兴趣的童鞋可以测试下;

搞清楚这些,我们也能对症下药思考如何改进了,甚至还能在后续的开发时,提前规避这些坑 ~

另外,建议在不缺空间的情况下, /tmp 稍微给大点吧..毕竟很多程序都是默认这个来当临时空间, 1T 的硬盘,给个 1G 空间真是太寒酸了~

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章