【Python】—日誌模塊logging詳解 多進程日誌記錄

1、 問題描述

項目中,使用RotatingFileHandler根據日誌文件大小來切分日誌。設置文件的MaxBytes1GBbackupCount大小爲5。

經查看,發現日誌文件的大小均小於10MB,且每個回滾日誌文件的寫入時間也都比較接近。

2、 分析

日誌文件過小,猜測是代碼有問題,或者是文件內容有丟失;日誌寫入時間接近猜測是同時寫入的問題。

經檢查,代碼沒有問題,排除此原因。考慮當前使用gunicorn的多進程啓動程序,多半是多個進程同時寫入當個文件造成日誌文件丟失。

logging模塊是線程安全的,但並不是進程安全的。

如何解決此問題呢?首先先過一遍Pythonlogging模塊在處理日誌回滾的具體實現方法。

2.1 logging模塊實現日誌回滾

loggingRotatingFileHandler類和TimedRotatingFileHandler類分別實現按照日誌文件大小和日誌文件時間來切分文件,均繼承自BaseRotatingHandler類。

BaseRotatingHandler類中實現了文件切分的觸發和執行,具體過程如下:

def emit(self, record):
    """
        Emit a record.
        Output the record to the file, catering for rollover as described
        in doRollover().
        """
    try:
        if self.shouldRollover(record):
            self.doRollover()
        logging.FileHandler.emit(self, record)
    except Exception:
        self.handleError(record)

具體的執行過程shouldRollover(record)doRollover()函數則在RotatingFileHandler類和TimedRotatingFileHandler類中實現。

RotatingFileHandler類爲例,doRollover()函數流程如下:

def doRollover(self):
    if self.stream:
        self.stream.close()
        self.stream = None
    if self.backupCount > 0:
        for i in range(self.backupCount - 1, 0, -1): # 從backupCount,依次到1
            sfn = self.rotation_filename("%s.%d" % (self.baseFilename, i))
            dfn = self.rotation_filename("%s.%d" % (self.baseFilename,
                                                        i + 1))
            if os.path.exists(sfn):
                if os.path.exists(dfn):
                    os.remove(dfn)
                os.rename(sfn, dfn) # 實現將xx.log.i->xx.log.i+1
        dfn = self.rotation_filename(self.baseFilename + ".1")
        # ---------start-----------
        if os.path.exists(dfn): # 判斷如果xx.log.1存在,則刪除xx.log.1
            os.remove(dfn)
        self.rotate(self.baseFilename, dfn) # 將xx.log->xx.log.1
        # ----------end------------
    if not self.delay:
        self.stream = self._open() # 執行新的xx.log

分析如上過程,整個步驟是:

  1. 當前正在處理的日誌文件名爲self.baseFilename,該值self.baseFilename = os.path.abspath(filename)是設置的日誌文件的絕對路徑,假設baseFilenameerror.log
  2. 當進行文件回滾的時候,會依次將error.log.i重命名爲error.log.i+1
  3. 判斷error.log.1是否存在,若存在,則刪除,將當前日誌文件error.log重命名爲error.log.1
  4. self.stream重新指向新建error.log文件。

當程序啓動多進程時,每個進程都會執行doRollover過程,若有多個進程進入臨界區,則會導致dfn被刪除多次等多種混亂操作。

2.2 多進程日誌安全輸出到同一文件方案

相應的解決方法:

  1. 將日誌發送到同一個進程中,由該進程負責輸出到文件中(使用QueueQueueHandler將所有日誌事件發送至一個進程中)
  2. 對日誌輸出加鎖,每個進程在執行日誌輸出時先獲得鎖(用多處理模塊中的Lock類來序列化對進程的文件訪問)
  3. 讓所有進程都將日誌記錄至一個SocketHandler,然後用一個實現了套接字服務器的單獨進程一邊從套接字中讀取一邊將日誌記錄至文件(Python手冊中提供)

3、解決方案

3.1 使用ConcurrentRotatingFileHandler

該方法就屬於加鎖方案。

ConcurrentLogHandler 可以在多進程環境下安全的將日誌寫入到同一個文件,並且可以在日誌文件達到特定大小時,分割日誌文件(支持按文件大小分割)。但ConcurrentLogHandler 不支持按時間分割日誌文件的方式。

ConcurrentLogHandler 模塊使用文件鎖定,以便多個進程同時記錄到單個文件,而不會破壞日誌事件。該模塊提供與RotatingFileHandler類似的文件循環方案。

該模塊的首要任務是保留您的日誌記錄,這意味着日誌文件將大於指定的最大大小(RotatingFileHandler是嚴格遵守最大文件大小),如果有多個腳本的實例同時運行並寫入同一個日誌文件,那麼所有腳本都應該使用ConcurrentLogHandler,不應該混合和匹配這這個類。

併發訪問通過使用文件鎖來處理,該文件鎖應確保日誌消息不會被丟棄或破壞。這意味着將爲寫入磁盤的每個日誌消息獲取並釋放文件鎖。(在Windows上,您可能還會遇到臨時情況,必須爲每個日誌消息打開和關閉日誌文件。)這可能會影響性能。在我的測試中,性能綽綽有餘,但是如果您需要大容量或低延遲的解決方案,建議您將其放在其他地方。

這個包捆綁了portalocker來處理文件鎖定。由於使用了portalocker模塊,該模塊當前僅支持“nt”“posix”平臺。

安裝:

pip install ConcurrentLogHandler

該模塊支持Python2.6及以後版本。

ConcurrentLogHandler的使用方法與其他handler類一致,如與RotatingFileHandler的使用方法一樣。

初始化函數及參數:

class ConcurrentRotatingFileHandler(BaseRotatingHandler):
    """
    Handler for logging to a set of files, which switches from one file to the
    next when the current file reaches a certain size. Multiple processes can
    write to the log file concurrently, but this may mean that the file will
    exceed the given size.
    """
    def __init__(self, filename, mode='a', maxBytes=0, backupCount=0,
                 encoding=None, debug=True, delay=0):

參數含義同Python內置RotatingFileHandler類相同,具體可參考上一篇博文。同樣繼承自BaseRotatingHandler類。

簡單的示例:

import logging
from cloghandler import ConcurrentRotatingFileHandler

logger = logging.getLogger()
rotateHandler = ConcurrentRotatingFileHandler('./logs/my_logfile.log', "a", 1024*1024, 5)
logger.addHandler(rotateHandler)
logger.setLevel(logging.DEBUG)

logger.info('This is a info message.')

爲了適應沒有ConcurrentRotatingFileHandler包的情況,增加回退使用RotatingFileHandler的代碼:

try:
    from cloghandler import ConcurrentRotatingFileHandler as RFHandler
except ImportError:
    from warning import warn
    warn('ConcurrentRotatingFileHandler package not installed, Using builtin log handler')
    from logging.handlers import RotatingFileHandler as RFHandler

運行後可以發現,會自動創建一個.lock文件,通過鎖的方式來安全的寫日誌文件。

備註: 該庫自2013年以後就沒有再更新,若有問題,可使用3.2小節中的concurrent-log-handler軟件包。


在非單獨使用python腳本的時候,注意使用方式:

# 不建議使用方式
from cloghandler import ConcurrentRotatingFileHandler

.......
'handlers':{
        "error_file": {
            "class": "ConcurrentRotatingFileHandler",
            "maxBytes": 100*1024*1024,
            "backupCount": 3,
# 建議寫完整
import cloghandler
'handlers':{
        "error_file": {
            "class": "cloghandler.ConcurrentRotatingFileHandler",
            "maxBytes": 100*1024*1024,
            "backupCount": 3,

否則,會出現如下錯誤:

Error: Unable to configure handler 'access_file': Cannot resolve 'ConcurrentRotatingFileHandler': No module named 'ConcurrentRotatingFileHandler'

3.2 concurrent-log-handler包

該模塊同樣也爲python的標準日誌記錄軟件提供了額外的日誌處理程序。即回將日誌事件寫入日誌文件,當文件達到一定大小時,該日誌文件將輪流輪轉,多個進程可以安全地寫入同一日誌文件,還可以將其進行壓縮(開啓)。WindowsPOSIX系統均受支持。

它可以看做是舊版本cloghandler的直接替代品,主需要將cloghandler更改爲concurrent_log_handler

其特徵及說明與cloghandler一致,具體可見3.1小節。

安裝

pip install concurrent-log-handler

若是從源碼安裝,則執行如下命令:

python setup.py install

使用示例

import logging
from concurrent_log_handler import ConcurrentRotatingFileHandler

logger = logging.getLogger()
rotateHandler = ConcurrentRotatingFileHandler('./logs/mylogfile.log', 'a', 512*1024, 5)
logger.addHandler(rotateHandler)
logger.setLevel(logging.DEBUG)

logger.info('This is a info message.')

同樣的,若要分發代碼,不確定是否都已安裝concurrent_log_handler軟件包時,使Python可以輕鬆的回退到內置的RotatingFileHandler。下面是示例:

import logging
try:
    from concurrent_log_handler import ConcurrentRotatingFileHandler as RFHandler
except ImportError:
    # 下面兩行可選
    from warnings import warn
    warn('concurrent_log_handler package not installed. Using builtin log handler')
    from logging.handlers import RotatingFileHandler as RFHandler

logger = logging.getLogger()
rotateHandler = RFHandler('./logs/mylogfile.log', 'a', 1024*1024, 5)
logger.addHandler(rotateHandler)

同樣的,建議直接導入concurrent_log_handler,使用concurrent_log_handler.ConcurrentRotatingFileHandler方式。

3.3 對日誌輸出加鎖

TimedRotatingFileHandlerdoRollover函數的主要部分如下:

def doRollover(self):
    ....
    dfn = self.rotation_filename(self.baseFilename + "." +
                                     time.strftime(self.suffix, timeTuple))
    # -------begin-------
    if os.path.exists(dfn): # 判斷如果存在dfn,則刪除
            os.remove(dfn)
    self.rotate(self.baseFilename, dfn) # 將當前日誌文件重命名爲dfn
    # --------end--------
    if self.backupCount > 0:
        for s in self.getFilesToDelete():
            os.remove(s)
    if not self.delay:
        self.stream = self._open()
    ....

修改思路:

判斷dfn文件是否已經存在,如果存在,表示已經被rename過了;如果不存在,則只允許一個進程去rename,其他進程需等待。

新建一個類繼承自TimeRotatingFileHandler,修改doRollover函數,只需處理上面代碼的註釋部分即可。如下:

class MPTimeRotatingFileHandler(TimeRotatingFileHandler):
    def doRollover(self):
        ....
        dfn = self.rotation_filename(self.baseFilename + "." +
                                     time.strftime(self.suffix, timeTuple))
        # ----modify start----
        if not os.path.exists(dfn):
            f = open(self.baseFilename, 'a')
            fcntl.lockf(f.fileno(), fcntl.LOCK_EX)
            if os.path.exists(self.baseFilename): # 判斷baseFilename是否存在
                self.rotate(self.baseFilename, dfn)
        # ----modify end-----
        if self.backupCount > 0:
        for s in self.getFilesToDelete():
            os.remove(s)
        ....

3.4 重寫FileHandler

logging.handlers.py中各類的繼承關係如下圖所示:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-F0JQY1Hb-1578305470334)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200106151225654.png)]

TimeRotatingFileHandler類就是繼承自該類,在FileHandler類中增加一些處理。

具體可參考以下博文:

  1. python logging日誌模塊以及多進程日誌 | doudou0o blog

  2. python多進程解決日誌錯亂問題_qq_20690231的博客-CSDN博客


Python官方手冊中,提供了多進程中日誌記錄至單個文件的方法。

logging是線程安全的,將單個進程中的多個線程日誌記錄至單個文件也是支持的。但將多個進程中的日誌記錄至單個文件中則不支持,因爲在Python中並沒有在多個進程中實現對單個文件訪問的序列化的標準方案。

將多個進程中日誌記錄至單個文件中,有以下幾個方案:

  1. 讓所有進程都將日誌記錄至一個 SocketHandler,然後用一個實現了套接字服務器的單獨進程一邊從套接字中讀取一邊將日誌記錄至文件。
  2. 使用 QueueQueueHandler 將所有的日誌事件發送至你的多進程應用的一個進程中。

3.5 單獨進程負責日誌事件

一個單獨監聽進程負責監聽其他進程的日誌事件,並根據自己的配置記錄。

示例:

import logging
import logging.handlers
import multiprocessing

from random import choice, random
import time

def listener_configurer():
    root = logging.getLogger()
    h = logging.handlers.RotatingFileHandler('test.log', 'a', 300,10) # rotate file設置的很小,以便於查看結果
    f = logging.Formatter('%(asctime)s %(processName)-10s %(name)s %(levelname)-8s %(message)s')
    h.setFormatter(f)
    root.addHandler(h)
   
def listenser_process(queue, configurer):
    configurer()
    while True:
        try:
            record = queue.get()
            if record is None:
                break
            logger = logging.getLogger(record.name)
            logger.handle(record)
        except Exception:
            import sys, traceback
            print('Whoops! Problem:', file=sys.stderr)
            trackback.print_exc(file=sys.stderr)

LEVELS = [logging.DEBUG, logging.INFO, logging.WARNING,
          logging.ERROR, logging.CRITICAL]

LOGGERS = ['a.b.c', 'd.e.f']

MESSAGES = [
    'Random message #1',
    'Random message #2',
    'Random message #3',
]

def worker_configurer(queue):
    h = logging.handlers.QueueHandler(queue)
    root = logging.getLogger()
    root.addHandler(h)
    root.setLevel(logging.DEBUG)
    
# 該循環僅記錄10個事件,這些事件具有隨機的介入延遲,然後終止
def worker_process(queue, configurer):
    configurer(queue)
    name = multiprocessing.current_process().name
    print('Worker started:%s'%name)
    for i in range(10):
        time.sleep(random())
        logger = logging.getLogger(choice(LOGGERS))
        level = choice(LEVELS)
        message = choice(MESSAGES)
        logger.log(level, message)
# 創建隊列,創建並啓動監聽器,創建十個工作進程並啓動它們,等待它們完成,然後將None發送到隊列以通知監聽器完成
def main():
    queue = multiprocessing.Queue(-1)
    listener = multiprocessing.Process(target=listener_process,
                                      args=(queue, listener_configurer))
    listener.start()
    workers = []
    for i in range(10):
        worker = multiprocessing.Process(target=worker_process,
                                        args=(queue, listener_configurer))
        workers.append(worker)
        worker.start()
    for w in workers:
        w.join()
    queue.put_nowait(None)
    listener.join()
    
if __name__ == '__main__':
    main()

使用主進程中一個單獨的線程記錄日誌

下面這段代碼展示瞭如何使用特定的日誌記錄配置,例如foo記錄器使用了特殊的處理程序,將foo子系統中所有的事件記錄至一個文件mplog-foo.log。在主進程(即使是在工作進程中產生的日誌事件)的日誌記錄機制中將直接使用恰當的配置。

import logging
import logging.config
import logging.handlers
from multiprocessing import Process, Queue
import random
import threading
import time

def logger_thread(q):
    while True:
        record = q.get()
        if record is None:
            break
        logger = logging.getLogger(record.name)
        logger.handle(record)
        
def worker_process(q):
    qh = logging.handlers.QueueHandler(q)
    root = logging.getLogger()
    root.setLevel(logging.DEBUG)
    root.addHandler(qh)
    levels = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR,
              logging.CRITICAL]
    loggers = ['foo', 'foo.bar', 'foo.bar.baz', 'spam', 'spam.ham', 'spam.ham.eggs']
    
    for i in range(100):
        lv1=l = random.choice(levles)
        logger = logging.getLogger(random.choice(loggers))
        logger.log(lvl, 'Message no. %d', i)

for __name__ == '__main__':
    q = Queue()
    d = {
        'version': 1,
        'formatters': {
            'detailed': {
                'class': 'logging.Formatter',
                'format': '%(asctime)s %(name)-15s %(levelname)-8s %(processName)-10s %(message)s'
            }
        },
        'handlers': {
            'console': {
                'class': 'logging.StreamHandler',
                'level': 'INFO',
            },
            'file': {
                'class': 'logging.FileHandler',
                'filename': 'mplog.log',
                'mode': 'w',
                'formatter': 'detailed',
            },
            'foofile': {
                'class': 'logging.FileHandler',
                'filename': 'mplog-foo.log',
                'mode': 'w',
                'formatter': 'detailed',
            },
            'errors': {
                'class': 'logging.FileHandler',
                'filename': 'mplog-errors.log',
                'mode': 'w',
                'level': 'ERROR',
                'formatter': 'detailed',
            },
        },
        'loggers': {
            'foo': {
                'handlers': ['foofile']
            }
        },
        'root': {
            'level': 'DEBUG',
            'handlers': ['console', 'file', 'errors']
        },
    }
    workers = []
    for i in range(5):
        wp = Process(target=worker_process, name='worker %d'%(i+1), args=(q,))
        workers.append(wp)
        wp.start()
    logging.config.dictConfig(d)
    lp = threading.Thread(target=logger_thread, args=(q,))
    lp.start()
    
    for wp in workers:
        wp.join()
    q.put(None)
    lp.join()

3.6 logging.SocketHandler的方案

具體實現參考如下博客:

Python中logging在多進程環境下打印日誌 - VictoKu - 博客園

4、參考文獻

用 Python 寫一個多進程兼容的 TimedRotatingFileHandler - piperck - 博客園

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章