我们有个独立部署的文件传输服务,主要是通过 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...。
正如文章所述,我们能够在 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") :
_begin_file ("begin_file"):
如此看来,如果是表单数据,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 已经看到数据了)
经过上面的测试,我们已经能够石锤以上的结论:
搞清楚这些,我们也能对症下药思考如何改进了,甚至还能在后续的开发时,提前规避这些坑 ~
另外,建议在不缺空间的情况下, /tmp 稍微给大点吧..毕竟很多程序都是默认这个来当临时空间, 1T 的硬盘,给个 1G 空间真是太寒酸了~
欢迎各位大神指点交流, QQ讨论群: 258498217
转载请注明来源: https://segmentfault.com/a/1190000021704884