謹防猴子補丁以及Python中排查技巧

背景

前兩天晚上線上系統突發故障,在立馬打開線上錯誤日誌之後,卻只能得到一堆毫無意義的程序調用棧(traceback)的輸出,於是團隊成員陷入漫長而又抓瞎的問題排查過程中。問題很幸運地得到了解決,但是我一直想不明白爲什麼日誌裏打印的調用棧毫無意義,按照經驗,它應該打印的是異常產生過程中的調用棧纔是。在經過後續的源碼分析和排查之後,我才發現其實是因爲項目中一個老舊的代碼使用了猴子補丁導致,這也是這篇文章想要討論的內容。

什麼是猴子補丁

猴子補丁是一種用來在運行時修改(增加、變更、刪除等)系統軟件行爲的編程方式。在動態語言裏有廣泛的猴子補丁應用的影子,比如 Ruby 的打開類的特性支持運行時擴展類的定義甚至替換方法的實現,Python 的方法或者函數由於可以在運行時進行替換而使得猴子補丁的應用非常方便,其他像 JavaScript 語言同樣可以應用猴子補丁。

猴子補丁是把雙刃劍

猴子補丁以其靈活性,可以實現補丁代碼和應用代碼的完全分離,同時使得應用代碼在調用方式上保持調用方式始終不變。
從應用代碼的角度來看,它調用的就是某個模塊的原始定義的方法或者函數;而從被調用的方法或者函數的角度來看,猴子補丁的存在對它是透明的存在,以下展示一個 Python 語言的 Demo:

我們從一個極簡例子開始,向這個美好的世界問好:

def greet():
    print("Hello World!")

if __name__ == "__main__":
    greet()

假如執行以上腳本,得到的結果是:

$ python demo.py
Hello World!

這個很簡單,接下來假如打一個猴子補丁:我們擴充原來的 greet 的行爲,現在除了打印信息,還要打印下當前的時間:

from datetime import datetime
def greet():
    print("Hello World!")

# monkey patch
original_greet = greet
def greet_with_time():
    original_greet()
    print(datetime.now())
greet = greet_with_time  # replace the implementation
# monkey patch

if __name__ == "__main__":
    greet() # 這裏的調用和原來沒有變化

運行它,得到的結果是:

$ python demo.py
Hello World!
2019-09-21 23:40:42.575782

我們得到了預期的結果!
從代碼分析,我們添加了一個新的函數 greet_with_time,其會調用原來的 greet 函數,然後打印當前時間,最後將 greet 函數通過將函數賦值給變量的方式完成對 greet 函數的替換。而對於最後的 greet 函數的調用,卻無需任何改動,以此達到了同樣還是調用 greet 函數,行爲卻大相徑庭的目的。
上面的 demo 只是限於篇幅簡化了代碼,真實項目裏的猴子補丁代碼總是在另外的模塊或者文件裏。想象在一個複雜的大型工程裏,如果你的代碼裏猴子補丁氾濫,可想對於系統的行爲分析以及問題排查,將是一種災難性的挑戰。

現在對猴子補丁有了一定的瞭解之後,我們再來看看我在實際項目中遇到的例子。

一堆毫無意義的堆棧信息

我在本地重現了我開頭提到的我們所遇到的異常,以下是和線上環境一致的堆棧信息:

2019-09-19 17:30:11.103|CRITICAL|138:140147476383488|log.py:282|log.log|Task command.celery.crontab_task.some_task[538ddb72-89b0-45fe-811e-107202dc665b] INTERNAL ERROR: AttributeError("'long' object has no attribute 'insert'",)
Traceback (most recent call last):
  File "/usr/local/bin/celery", line 10, in <module>
    sys.exit(main())
  File "/usr/local/lib/python2.7/dist-packages/celery/__main__.py", line 30, in main
    main()
  ...... 限於篇幅,這裏省略很多無意義的內容
  File "/usr/local/lib/python2.7/dist-packages/celery/worker/job.py", line 384, in on_success
    return self.on_failure(ret_value)
  File "/usr/local/lib/python2.7/dist-packages/celery/worker/job.py", line 443, in on_failure
    self._log_error(exc_info, send_failed_event=send_failed_event)
  File "/usr/local/lib/python2.7/dist-packages/celery/worker/job.py", line 511, in _log_error
    'internal': internal}})
  File "/usr/local/lib/python2.7/dist-packages/celery/utils/log.py", line 282, in log
    return Logger.log(self, *args, **kwargs)
None

從這個堆棧信息看,它打印的實際上是調用了 Logger.log 函數的堆棧,其中根本沒有任何代碼看到有 .insert 相關字眼,其與 AttributeError("'long' object has no attribute 'insert'",) 根本毫無關係,這樣的堆棧信息,有和沒有基本一個樣。於是乎,我接着通過編輯器通過源碼進行了更多的探索。

首先還是藉助上面的堆棧去分析到底哪裏出了問題,所以我先看了 celery/worker/job.py:504-511 處的代碼:

        context = {
            'hostname': self.hostname,
            'id': self.id,
            'name': self.name,
            'exc': exception,
            'traceback': traceback,
            'args': sargs,
            'kwargs': skwargs,
            'description': description,
        }

        logger.log(severity, format.strip(), context,
                   exc_info=exc_info,
                   extra={'data': {'id': self.id,
                                   'name': self.name,
                                   'args': sargs,
                                   'kwargs': skwargs,
                                   'hostname': self.hostname,
                                   'internal': internal}})

這裏調用了 logger.log 方法(logger 的來源在 Celery 的代碼裏可分析,但是不是這篇文章的重點,故此不展開)並且通過 context 對象傳入了兩個重要的信息:exceptiontraceback。在對 logger.log 源碼的進一步閱讀中,我確認了這塊日誌打印的核心依賴於對 traceback.print_exception 函數的調用

    def formatException(self, ei):
        """
        Format and return the specified exception information as a string.

        This default implementation just uses
        traceback.print_exception()
        """
        sio = io.StringIO()
        tb = ei[2]
        traceback.print_exception(ei[0], ei[1], tb, None, sio)

於是乎,我回到了 celery/worker/job.py:504-511 處的代碼,在 logger.log 前面插入了兩種打印錯誤堆棧信息的代碼:

        # context  = ...

        ################################################################
        import traceback as _traceback
        # Method 1: like what logger.log does
        _traceback.print_exception(*exc_info)

        # Method 2: use `format_exception` instead
        print(''.join(_traceback.format_exception(*exc_info)))
        ################################################################

        logger.log(....

重新啓動 celery 後,執行異步任務後,得到的第一種錯誤堆棧和前面我貼出來的堆棧信息是完全一致的,這個倒也好理解,畢竟這裏的 print_exception 函數的調用就是 logger.log 裏的核心實現。而 format_exception 的調用給了我真正有意義的錯誤堆棧信息:

Traceback (most recent call last):
  File "/usr/local/lib/python2.7/dist-packages/celery/app/trace.py", line 283, in trace_task
    uuid, retval, SUCCESS, request=task_request,
  File "/usr/local/lib/python2.7/dist-packages/celery/backends/base.py", line 271, in store_result
    request=request, **kwargs)
  File "/usr/local/lib/python2.7/dist-packages/celery/backends/base.py", line 505, in _store_result
    self.set(self.get_key_for_task(task_id), self.encode(meta))
  File "/usr/local/lib/python2.7/dist-packages/celery/backends/redis.py", line 161, in set
    return self.ensure(self._set, (key, value), **retry_policy)
  File "/usr/local/lib/python2.7/dist-packages/celery/backends/redis.py", line 150, in ensure
    **retry_policy
  File "/usr/local/lib/python2.7/dist-packages/kombu/utils/__init__.py", line 246, in retry_over_time
    return fun(*args, **kwargs)
  File "/usr/local/lib/python2.7/dist-packages/celery/backends/redis.py", line 170, in _set
    pipe.execute()
  File "/usr/local/lib/python2.7/dist-packages/redis/client.py", line 2879, in execute
    return execute(conn, stack, raise_on_error)
  File "/usr/local/lib/python2.7/dist-packages/redis/client.py", line 2785, in _execute_transaction
    response.insert(i, e)
AttributeError: 'long' object has no attribute 'insert'

好傢伙,這下就清晰了,原來這個代碼的異常真正出處是這裏!
但是問題就來了,爲什麼print_exceptionformat_exception給出的堆棧信息不一樣呢?我充滿疑問地去查找了官方文檔,但是困惑更重了:

traceback.format_exception(etype, value, tb[, limit])
Format a stack trace and the exception information. The arguments have the same meaning as the corresponding arguments to print_exception(). The return value is a list of strings, each ending in a newline and some containing internal newlines. When these lines are concatenated and printed, exactly the same text is printed as does print_exception().

重點在最後一句,Python 官方文檔說了,兩個函數輸出的錯誤堆棧是一樣(exactly the same text)的!

揪出猴子補丁

其實,問題的真正排查過程耗費了我好多時間,我一直沒有往猴子補丁上想,最後倒是在出門赴朋友的飯約的地鐵上靈機一動,用手機翻看了公司 GitLab 上的項目代碼,一下找到了元兇。

def _patch_print_exception():
    import traceback

    def custom_print_exception(etype, value, tb, limit=None, file=None):
        exc_info = sys.exc_info()
        stack = traceback.extract_stack()
        # ... omit other source codes 

    traceback.print_exception = custom_print_exception

從補丁代碼看,補丁直接覆蓋了原版的代碼,並且實現上也直接粗暴地無視了傳入的幾個異常信息參數!所以纔會出現這麼大的烏龍,出現毫無關係的異常堆棧信息!(╯‵□′)╯︵┻━┻

排查猴子補丁的技巧

猴子補丁這類編程技巧固然會利弊共存,使用上必然需要額外慎重,但也並非需要敬而遠之,重點是掌握必要的排查技巧,以下我針對這次的教訓又去找下一些可能有幫助的方法:

1. 通過函數或方法自身屬性檢查方法或者函數的信息

衆所周知,Python 的所有對象都有一堆內置的屬性,函數也不例外,以我項目中的例子:

# django shell
In [1]: traceback.print_exception.func_code
Out[1]: <code object custom_print_exception at 0x109e9f030, file "/Users/boy/work_area/project/project-source/lib/common/logger.py", line 295>

一看就知道,這個函數的真實代碼其實就是項目中的補丁代碼!

2. 藉助 inspect 包來檢查

Python 自身提供的工具包非常多,inspect 自然也是利器之一,其可以用來對幾乎所有類型做運行時的檢查,還是以我的實際例子:

# django shell
In [1]: import inspect
In [2]: inspect.getfile(traceback.print_exception)
Out[2]: '/Users/boy/work_area/project/project-source/lib/common/logger.py'

In [3]: inspect.getsource(traceback.print_exception)
Out[3]: '\tdef custom_print_exception(etype, value, tb, limit=None, file=None): ......\n'

In [4]: print inspect.getsource(traceback.print_exception)
Out[4]: def custom_print_exception(etype, value, tb, limit=None, file=None):disable=redefined-builtin
            if file is None:
                file = sys.stderr
            exc_info = sys.exc_info()
            stack = traceback.extract_stack()
            ...

總之,如果遇上代碼行爲與預期不符卻又無法和官方文檔或者官方源碼對應,那麼可能就是依賴的方法或者函數被打了猴子補丁,而最快速確認猴子補丁的方式,就是第一時間檢查所調用的函數或者方法的實際定義,即應用上述方法即可!

題外話

做 Ruby 開發時,我也遇到過猴子補丁的陷阱,Ruby 裏也有類似的方法:

file, line = A.new.method(:foo).source_location
puts "Method foo is defined in #{file}, line #{line}"
# => "Method foo is defined in temp.rb, line 2"

參考鏈接

  1. Wikipedia: Monkey patch
  2. Python's official document: traceback.format_exception
  3. Python's official document: inspect
  4. How can I get source code of a method dynamically and also which file is this method locate in
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章