Python:谨防 Post 打爆 /tmp
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。
有次收到了磁盘告警:
本来这种告警没什么好特殊的,登录机器删除下文件就好了,然而这次似乎不是那么简单,因为这个增长有点神奇...
正常来说,磁盘空间的增长是一个斜斜的曲线,慢慢地、越来越大,然而这货,是个连续大波浪.. 这时候就需要好好分析下!
故障回顾
空间有释放,也就意味着有某个程序在清理着文件,而在刚才也交代过,这个机器只部署了一个服务,那这个表现极有可能是程序有关系,即时我们都知道代码并没涉及到 /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'] 分离出 form 和 files ,将其设置到 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_multipart 、 MultiPartParser.parse 、 parse_parts 和 parse_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 已经看到数据了)
结论
经过上面的测试,我们已经能够石锤以上的结论:
- 如果是通过 file 形式上传,那么超过 500k 的文件将会征用 /tmp 用来临时存放数据,直到数据处理完会自动清理(可以通过环境变量 TMPDIR 、 TEMP 、 TMP 修改);
- 如果是通过 form 形式上传,不管是多大都会读到内存,因为会使用列表作为载体,不过小心内存泄漏和 payload 过大哦;
- 两者的读写效率我盲猜会有较大差距,有兴趣的童鞋可以测试下;
搞清楚这些,我们也能对症下药思考如何改进了,甚至还能在后续的开发时,提前规避这些坑 ~
另外,建议在不缺空间的情况下, /tmp 稍微给大点吧..毕竟很多程序都是默认这个来当临时空间, 1T 的硬盘,给个 1G 空间真是太寒酸了~
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK