前言
我们有个独立部署的文件传输服务,主要是通过 Flask 实现,对外提供的功能主要是接收客户端传输的文件,并将其转发至 RabbitMQ。
有次收到了磁盘告警:
本来这种告警没什么好特殊的,登录机器删除下文件就好了,然而这次似乎不是那么简单,因为这个增长有点神奇...
正常来说,磁盘空间的增长是一个斜斜的曲线,慢慢地、越来越大,然而这货,是个连续大波浪.. 这时候就需要好好分析下!
故障回顾
空间有释放,也就意味着有某个程序在清理着文件,而在刚才也交代过,这个机器只部署了一个服务,那这个表现极有可能是程序有关系,即时我们都知道代码并没涉及到 /tmp。
打开错误日志发现程序在疯狂的报错:
代码语言:javascript复制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 是这一环节的主力,可以简单看看源码(篇幅略长,已提取需要的函数):
代码语言:javascript复制# 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 函数需要将上面的数据,根据规则,处理变成以下的格式:
代码语言:javascript复制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 创建了什么容器:
代码语言:javascript复制# 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 空间:
代码语言:javascript复制:~$ 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 的变化:
代码语言:javascript复制~$ 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
除了上面的工具,我们还需要准备其他东西,比如不同大小的文件:
代码语言:javascript复制~$ 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
还有上传脚本:
代码语言:javascript复制# 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 有无输出:
代码语言:javascript复制限制值: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 有无输出:
代码语言:javascript复制# 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')
}
)
代码语言:javascript复制限制值: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 空间真是太寒酸了~