Python:謹防 Post 打爆 /tmp

前言

咱們有個獨立部署的文件傳輸服務,主要是經過 Flask 實現,對外提供的功能主要是接收客戶端傳輸的文件,並將其轉發至 RabbitMQ。html

有次收到了磁盤告警:
image.pngpython

原本這種告警沒什麼好特殊的,登陸機器刪除下文件就行了,然而此次彷佛不是那麼簡單,由於這個增加有點神奇...
image.pnglinux

正常來講,磁盤空間的增加是一個斜斜的曲線,慢慢地、愈來愈大,然而這貨,是個連續大波浪.. 這時候就須要好好分析下!flask

故障回顧

空間有釋放,也就意味着有某個程序在清理着文件,而在剛纔也交代過,這個機器只部署了一個服務,那這個表現極有多是程序有關係,即時咱們都知道代碼並沒涉及到 /tmpsegmentfault

打開錯誤日誌發現程序在瘋狂的報錯: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

Post 數據處理

正如文章所述,咱們可以在 Flask 聚焦於業務邏輯,而無需分心處理接受HTTP請求、解析HTTP請求、發送HTTP響應等等,全得益於 WSGI 幫咱們屏蔽了太多的細節。函數

咱們知道 requests 庫在 Post 的時候,容許咱們將數據經過 payload(form)files 的形式提交數據,
詳細可看文檔:https://2.python-requests.org...

而無論哪一種方式的提交,都會變成 HTTP 報文的 body 一部分,傳輸到服務端,而 WSGI 也合理地處置它:

image.png

Flask 經過 _load_form_data從客戶端提交的數據中,也就是 environ['wsgi.input'] 分離出 formfiles,將其設置到 Flask.request 對應的 multi dicts 裏,譬如這些:
image.png

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

在客戶端請求的頭部中,有一個屬性值得關注:
image.png

這個 boundary 的值是變化的、用來切割請求體中的 Content-Disposition 數據的,格式以下:
image.png
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 的數據分開,並妥善安置好了:

image.png

兇手浮現

看到上面的關於臨時數據處理,看到 500k 的限制,再看下咱們的文件大小分佈:
image.png

我震驚了,小於 500k 的比例只有 2.75%,emmmm....這樣至關於幾乎全部數據都是走的臨時文件方式的。

雖然看到 TemporaryFile 大概也能猜到七七八八是用到 /tmp 了,至於實現這裏就不贅述了,感興趣的童鞋能夠去看下:tempfile.py

咱們又翻查下故障先後的文件上傳日誌,彷彿看到了元兇....45m 的日誌..
image.png

而咱們的 /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 空間真是太寒酸了~

歡迎各位大神指點交流, QQ討論羣: 258498217
轉載請註明來源: http://www.javashuo.com/article/p-brlkadwy-hk.html

相關文章
相關標籤/搜索