17

Python:谨防 Post 打爆 /tmp

 4 years ago
source link: https://segmentfault.com/a/1190000021704884
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

前言

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

有次收到了磁盘告警:

uY7Fjqm.png!web

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

YJN7J37.png!web

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

故障回顾

空间有释放,也就意味着有某个程序在清理着文件,而在刚才也交代过,这个机器只部署了一个服务,那这个表现极有可能是程序有关系,即时我们都知道代码并没涉及到 /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 也合理地处置它:

uIfe2iI.png!web

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

UZVJVrQ.png!web

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

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

q6niii7.png!web

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

bQf67nv.png!web

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 的数据分开,并妥善安置好了:

vqEVB3i.png!web

凶手浮现

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

VZVVvuv.png!web

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

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

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

7rURVbQ.png!web

而我们的 /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 空间真是太寒酸了~


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK