咱們有個獨立部署的文件傳輸服務,主要是經過 Flask 實現,對外提供的功能主要是接收客戶端傳輸的文件,並將其轉發至 RabbitMQ。html
有次收到了磁盤告警:python
原本這種告警沒什麼好特殊的,登陸機器刪除下文件就行了,然而此次彷佛不是那麼簡單,由於這個增加有點神奇...linux
正常來講,磁盤空間的增加是一個斜斜的曲線,慢慢地、愈來愈大,然而這貨,是個連續大波浪.. 這時候就須要好好分析下!flask
空間有釋放,也就意味着有某個程序在清理着文件,而在剛纔也交代過,這個機器只部署了一個服務,那這個表現極有多是程序有關係,即時咱們都知道代碼並沒涉及到 /tmp。segmentfault
打開錯誤日誌發現程序在瘋狂的報錯:api
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.pyapp
而這部分可能須要咱們先稍微瞭解下 WSGI 協議:https://www.cnblogs.com/wilbe...。python2.7
正如文章所述,咱們可以在 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
轉載請註明來源: http://www.javashuo.com/article/p-brlkadwy-hk.html