業界 | Dropbox力薦!我們如何應對Python桌面應用程式的崩潰
維護像Dropbox這樣的複雜桌面應用程式最大挑戰之一就是同時處理數億次的安裝,一個小小的錯誤就會影響到大量的使用者。
這些錯誤會攻擊程式,雖然應用程式大多數情況下都可以恢復,但有時也會導致程式終止。這樣的終止或“崩潰”對程式具有很高的破壞性:當Dropbox程式終止時,程式就無法同步了。為了確保我們的使用者可以不間斷的同步,我們會自動檢測並報告所有崩潰,同時採取措施重新啟動程式。
2016年,隨著逐步的過渡到Python 3,我們開始著手改進我們檢測和報告崩潰的方式。目前,對於我們的桌面團隊來說,我們的崩潰報告流程無論在報告的數量還是在質量上都是非常可靠的。在本文中,我們將深入探討我們是如何設計這個新系統的。
Python不會崩潰,真是這樣的嗎?
部分Dropbox程式是用Python編寫的,雖然Python是一種安全的高階語言,但它還是會崩潰。大多數出現在Python中的崩潰(即未處理的異常)很容易處理,但很多異常來自“底層“:非Python程式碼、直譯器程式碼本身中,或在Python的擴充套件中。這些“原始”的崩潰並不是什麼新鮮事:例如,幾十年來錯誤的記憶體操作一直困擾著開發者們。
隨著我們的應用程式變得越來越複雜,我們開始使用其他程式語言來構建我們的一些功能。在與作業系統整合時尤其如此,其中最簡單的路徑往往是使用平臺特定的工具和語言(例如,Windows上的COM和macOS上的Objective-C)。這增加了我們的程式碼庫中非Python程式碼的比例,這就不可避免的帶來懸空指標、記憶體錯誤、資料競爭和未經檢查的陣列訪問的風險,所有這些都可能導致Dropbox被暴力終結。結果就是,一個崩潰報告的堆疊軌跡中會包含Python,C ++,Objective-C和C多種程式碼!
早期的做法
幾年前,我們使用簡單的程序內崩潰檢測機制:訊號處理程式。我們能夠“捕獲”各種UNIX系統訊號,當遇到致命訊號(即SIGFPE)時,我們的訊號處理程式將嘗試以下操作:
● 捕獲每個執行緒的Python堆疊軌跡(使用faulthandler模組)
● 捕獲該執行緒的本機堆疊軌跡(通常使用libc的backtrace和backtrace_symbols函式)
然後,我們會將這些資料安全地上傳到Dropbox的伺服器。
雖然做到這些已經足矣,但有一些基本問題會影響程式的可靠性或限制其在除錯中的實用性:
● 如果問題發生在設定處理程式之前,那我們會收不到任何報告。這通常是由匯入庫錯誤或安裝錯誤引起的。這些基本的“啟動錯誤”是最嚴重的,因為它們導致使用者無法啟動應用程式,這是一個無法接受的狀況,因為這時我們根本無法捕捉這些錯誤。出現這樣問題時,我們的工程師只能通過客戶支援系統獲取相關報告。雖然我們構建了一個的錯誤對話方塊來幫助完成這一過程,但這仍然會使我們的團隊在干預啟動/早期程式碼方面增加了風險。
● 訊號處理程式穩定性不足。處理程式不僅負責捕獲狀態,還負責將其傳送到我們的伺服器上。隨著時間的推移,我們意識到儘管能夠成功地生成報告,但它仍有可能無法完成傳送。此外,特別嚴重的崩潰可能導致無法在崩潰時正確提取出狀態。例如,如果直譯器狀態本身就已經損壞了,則可能會阻止我們進行Python堆疊跟蹤,或者更糟糕,整個處理過程可能會破壞。
● 其中一個根本原因是訊號處理程式本身的特性導致的:幸運的是,Python的訊號模組考慮了大部分情況,而且還增加了一些限制。例如,訊號只能從主執行緒呼叫,並且可能無法同步執行。這種非同步性意味著一些最常見的SIGSEGV通常不會被Python困住!1
Crashpad大顯神通
通過在主程序外部提取報告器可以構建更可靠的崩潰報告機制。這很容易實現,因為Windows和MacOS都提供了系統工具來捕獲程序外的崩潰。Chromium專案開發了一個全面的崩潰捕獲/報告解決方案,該解決方案利用了可獨立使用的工具庫:Crashpad。
Crashpad作為一個小的幫助程式程序監視你的應用程式,當出現崩潰的訊號時,它就會捕獲有用的資訊,包括:
1.程序崩潰的原因和導致崩潰的執行緒;
2.所有執行緒的堆疊軌跡;
3.堆的部分內容;
4.開發人員新增到應用程式的額外註釋(可靈活使用)。
以上這些都是在minidump有效負載中捕獲的,它是一種最初微軟開發的在Windows上使用編寫格式,有點類似於Unix風格的核心轉儲。這種格式是開源的,並且有優秀的伺服器端工具(主要來自Google和Mozilla)來處理這些資料。
下圖概述了Crashpad的基本架構:
應用程式通過例項化一個程序內物件(稱為“客戶端”)來使用Crashpad,當檢測到崩潰時,該物件報告給程序外的幫助程式—稱為“處理程式”。
我們決定使用此庫來解決與程序內訊號處理程式相關的許多可靠性問題。這個選擇對我們來說很容易,因為Chromium是有史以來發布的最受歡迎的桌面應用程式之一。我們也對Windows的更復雜支援感到滿意,這是一個與UNIX完全不同的平臺。faulthandler(在當時)僅支援Windows平臺的崩潰,因為它非常依賴訊號,一個UNIX / POSIX平臺的概念。Crashpad利用結構化異常處理(或SEH)可以捕獲到更全面的致命Windows特定異常。
關於Linux的說明:儘管最近引入了Linux支援,但是當我們第一次部署時,Crashpad僅適用於Windows和MacOS,因此我們將庫的使用限制在這些平臺上。在Linux上,我們繼續使用程序內訊號處理程式,但我們將來會做進一步的改進。
符號化
與大多數已編譯的應用程式一樣,Dropbox將釋出版本傳送給使用者,釋出版本中啟用了多個編譯器進行優化,同時去除符號表示以減少二進位制儲存大小。這意味著Dropbox收集到的資訊幾乎是無用的,除非它可以“對映”回原始碼,這個過程就被稱為“符號化”。
為此我們為內部伺服器上的每個Dropbox構建保留符號。這是我們構建過程的核心部分,若符號生成失敗則被認為是構建失敗,我們不會使用這種無法被符號化的釋出版本。
當應用的崩潰報告中含有minidump(小儲存器轉儲檔案:可幫助確定計算機為什麼意外停止的最小的有用資訊集)時, 我們使用之前生成的符號來跟蹤應用裡每個堆疊內容並將其連結到原始碼中。使用開發框架系統庫時, 我們會遵循特定平臺的符號表示。此過程使我們的開發人員能夠快速定位到應用崩潰位置,判斷其是源自框架平臺還是第三方程式碼。
Microsoft維護所有 windows 版本的公共符號伺服器,以便對映涉及各版本功能的堆疊幀。不幸的是,Apple沒有類似的系統,但是Apple的平臺框架中包括了各版本的匹配符號。為了讓Dropbox支援各種版本, 我們使用測試虛擬機器快取各種 macOS框架(適用於各種作業系統版本)的符號(儘管我們仍然偶爾會遇到版本未包含的問題)。
挎鬥驗證
從數百萬次安裝中更改崩潰報告的基礎架構是一項冒險嘗試,但是我們需要這樣來驗證我們的新機制是否有效。同樣需要注意的是,並非所有終止都是應用崩潰(例如使用者關閉應用程式或應用自動更新就不屬於應用崩潰)。儘管如此,有一些終止情況仍然表明應用可能存在問題。因此,我們希望有一種方法能來記錄和判斷出哪種情況算是應用正常退出,哪種情況算是應用意外崩潰。 這也為我們提供一個基線,用來驗證我們的新崩潰報告構架是否捕獲了大部分應用崩潰情況。
為了解決這個問題, 我們建立了一個被稱為 " watchdog "(看門狗) 的 "sidecar" (挎鬥)過程。這是一個具有單一責任的小型 "配套" 程序 (類似於Crashpad):當桌面應用退出時, 它會捕獲其退出狀態, 以確定它是否 "成功" (即使用者或應用程式啟動的關閉而不是被強行終止)。因為我們希望它具有高度可靠性,所以該過程被設計的非常簡單。
我們讓應用程式在啟動時傳送事件來生成啟動事件,通過比較啟動和退出事件,可以測量退出監控的準確性。我們可以確保退出監控對絕大部分使用者是成功的 (請注意防火牆等其他程式會阻止它一直執行)。此外, 我們可以將此退出事件與來自Crashpad的崩潰報告進行匹配,以確保我們預計會引起崩潰的退出程式碼確實包括大多數使用者的崩潰情況。下圖顯示了我們的退出監控:
看門狗允許我們驗證崩潰報告是否正確
看門狗允許我們在單個圖中對崩潰和終止進行分類
我們用Rust編寫了看門狗程序,為什麼會選擇Rust呢:
1.Rust的安全設定使程式碼可靠性非常高。
2.與作業系統的抽象介面設計良好,屬於系統標準庫的一部分,並且在需要時可以通過FFI輕鬆擴充套件介面。
3.我們在開發Dropbox時很大一部分都使用了Rust,這讓Dropbox的搭建變得更加容易。
教Crashpad相容Python
Crashpad主要是為本機程式碼設計的,因為Chromium主要是用C ++編寫的。但是,Dropbox客戶端大多是用Python編寫的。由於Python是一種解釋型語言,因此我們收到的大多數本機崩潰報告往往如下所示:
0 _ctypes.cpython -35 m-darwin.so!_i_get + 0x4
1 _ctypes.cpython -35 m-darwin.so!_Simple_repr + 0x4a
2 libdropbox_python .3.5 .dylib!_PyObject_Str + 0x8e
3 libdropbox_python .3.5 .dylib!_PyFile_WriteObject + 0x79
4 libdropbox_python .3.5 .dylib!_builtin_print + 0x1dc
5 libdropbox_python .3.5 .dylib!_PyCFunction_Call + 0x7a
6 libdropbox_python .3.5 .dylib!_PyEval_EvalFrameEx + 0x5f12
7 libdropbox_python .3.5 .dylib!_fast_function + 0x19d
8 libdropbox_python .3.5 .dylib!_PyEval_EvalFrameEx + 0x5770
9 libdropbox_python .3.5 .dylib!__PyEval_EvalCodeWithName + 0xc9e
10 libdropbox_python .3.5 .dylib!_PyEval_EvalCodeEx + 0x24
11 libdropbox_python .3.5 .dylib!_function_call + 0x16f
12 libdropbox_python .3.5 .dylib!_PyObject_Call + 0x65
13 libdropbox_python .3.5 .dylib!_PyEval_EvalFrameEx + 0x666a
14 libdropbox_python .3.5 .dylib!__PyEval_EvalCodeWithName + 0xc9e
15 libdropbox_python .3.5 .dylib!_PyEval_EvalCodeEx + 0x24
16 libdropbox_python .3.5 .dylib!_function_call + 0x16f
17 libdropbox_python .3.5 .dylib!_PyObject_Call + 0x65
18 libdropbox_python .3.5 .dylib!_PyEval_EvalFrameEx + 0x666a
19 libdropbox_python .3.5 .dylib!__PyEval_EvalCodeWithName + 0xc9e
20 libdropbox_python .3.5 .dylib!_PyEval_EvalCodeEx + 0x24
21 libdropbox_python .3.5 .dylib!_function_call + 0x16f
22 libdropbox_python .3.5 .dylib!_PyObject_Call + 0x65
... on and on
這個堆疊跟蹤對於試圖發現崩潰原因的開發人員來說並不是很有幫助。雖然faulthandler包含了所有執行緒的Python堆疊幀,但預設情況下Crashpad並沒有此功能。為了讓這個報告變得有用,我們需要加入相關的Python狀態。 但是,由於Crashpad不是用Python編寫的並且在程序之外,我們無法訪問faulthandler本身,那我們要如何處理呢?
當崩潰程式暫停時,Crashpad可以讀取它的所有記憶體以捕獲程式狀態。 由於程式可能處於錯誤狀態,因此我們無法執行任何程式碼。接下來我們就需要:
1.弄清楚Python資料在記憶體中的結構佈局
2.遍歷相關資料結構以定位程式崩潰時正在執行的程式碼
3.儲存此資訊並將其安全地上傳到我們的伺服器
我們之所以會選擇 Crashpad,,部分原因是它的可定製性,它非常容易被擴充套件。因此,我們在 ProcessSnapshot 類中添加了程式碼來捕獲 Python堆疊, 並引入了我們自己的自定義小型轉儲 "流" (檔案格式符合,同時Crashpad本身支援) 來保留和報告此資訊。
Python 和執行緒本地儲存
首先, 我們需要知道去哪裡找它們。在CPython中,直譯器執行緒始終由本機執行緒支援。因此,在 Dropbox應用程式中, Python建立的每個本機執行緒都有一個關聯的 PyThreadState 結構。直譯器使用本機執行緒特定的儲存來建立此物件和本機執行緒之間的連線。由於Crashpad可以訪問受監視程序的記憶體,因此它可以讀取這個狀態並將其作為報告的一部分。
由於 Dropbox提供了CPython的自定義分支,因此我們可以有效地控制它的行為。這意味著我們不僅可以利用它改善Dropbox,而且可以依賴它, 因為我們知道它的可靠性非常高。
在Python中,特定於執行緒的儲存在不同平臺的實現方式不一樣:
● 在POSIX上,pthread_key_create 用於分配金鑰,而pthread_(get/set)specific用於互動
● 在Windows上,TlsAlloc 用於分配儲存線上程環境Block.aspx中可預測/記錄位置的執行緒本地“slots”
注意:我們為Crashpad提供了修復程式以使其隨時可用。
參見:
https://chromium-review.googlesource.com/c/crashpad/crashpad/+/717040
但是,所有平臺的共同點是特定於Python的狀態儲存在本機執行緒狀態的特定偏移量處。遺憾的是,這種偏移不是靜態的:它可以根據各種因素而改變。此偏移量在Python執行時的設定早期確定:這稱為特定於執行緒的儲存“金鑰”。此步驟為程序中的所有執行緒建立一個特定於執行緒的儲存的“插槽”,然後由Python用它來儲存其特定於執行緒的狀態。
因此,如果crashpad可以為程序例項檢索TSS“key”,它將能夠讀取任何給定執行緒的PyThreadState。
獲取執行緒本地儲存“金鑰”
我們考慮了多種方法,但最終選擇了一種受Crashpad本身啟發的方法。最後,我們修改了Python的fork【fork不知道怎麼翻譯】,用在二進位制的命名部分(即__DATA)中公開執行時狀態(包括TSS金鑰)。因此,Dropbox的所有例項現在都會以一種易於從Crashpad檢索它的方式公開Python執行時狀態。
● 這是通過使用Clang中的__attribute__和在Windows上使用__declspec實現的。
● 這在Crashpad中使用起來很簡單,因為它使用相同的技術允許客戶端向自己的程序添加註釋(請參閱CrashpadInfo)。
● 這也很好地與Python自己不斷髮展的直譯器的內部設計保持一致,因為它最近重組了自己,執行時狀態能夠整合到單個結構_PyRuntime。(在Python / pylifecycle.c中)。此結構包括TSS金鑰以及其他有趣的除錯工具。
注意:我們已將此更改作為拉取上傳到github,希望能對大眾有所裨益。
https://github.com/python/cpython/pull/4802/files
現在Crashpad可以確定TSS金鑰,它可以訪問每個執行緒的PyThreadState。下一步是解釋此狀態,提取相關資訊,並將其作為崩潰報告的一部分發送。
解析Python堆疊幀
在CPython中,“frames”是函式執行的單位,Python類似於本機堆疊幀。 PyThreadState將它們維護為PyFrameObjects的堆疊。執行緒狀態使用單個指標指向任何給定時間的最頂層幀。給定以上設定和TSS金鑰,我們可以從本機執行緒開始,找到PyThreadState,然後“遍歷堆疊”PyFrameObjects。
然而,事實比理論更加棘手一些。我們不能只是#include <Python.h>並呼叫相同的函式faulthandler:因為Crashpad的處理程式在一個單獨的程序中執行,它不能直接訪問這個狀態。相反,我們必須使用Crashpad的實用程式來進入崩潰程序的記憶體並維護我們自己的相關Python結構的“副本”來解釋原始資料。這是一個必然脆弱的解決方案,但我們通過引入自動化測試來確保對Python核心結構的任何更新也需要更新我們的Crashpad fork, 從而降低了持續維護的成本。
對於每一幀,我們的目標是將其解析為程式碼位置。每個PyFrameObject都有一個指向PyCodeObject的指標,包括有關函式名,檔名和行號的資訊(faulthandler利用相同的資訊)。
檔名和函式名稱儲存為Python字串。解碼Python字串可以相當複雜,因為它們構建在型別的層次結構上。為簡單起見,我們假設所有函式和檔名都是ASCII編碼的(就可以對映到簡單的PyASCIIObject)。
獲取行號稍微複雜一些。為了節省空間,Python能夠將每個位元組程式碼指令對映到Python源,同時將行號壓縮成一個表(PyCodeObject的co_lnotab)。
解碼此表的演算法是明確定義的,因此我們在Crashpad fork【fork】中重新實現了它。
演算法參照: https://github.com/python/cpython/blob/3df85404d4bf420db3362eeae1345f2cad948a71/Objects/lnotab_notes.txt
關於Python 3轉換的註釋:由於Python 2和3的實現略有不同,我們在轉換過程中保持對Crashpad fork中兩個版本的Python結構的支援。
堆疊框架重建
現在Crashpad的報告包含了所有Python堆疊幀,我們可以改進符號化。為此,我們修改了我們的伺服器基礎結構,以解析我們對minidump的擴充套件並提取這些堆疊。具體來說,我們擴充了崩潰管理系統Crashdash,以顯示本機崩潰報告的Python堆疊框架資訊(如果可用)。
這是通過再次“遍歷堆疊”來實現的,但這次,對於呼叫PyEval_EvalFrameEx的每個本機幀,我們從報告中“彈出”匹配的PyFrameObjectcapture。由於我們現在擁有每個幀的函式名,檔名和行號,現在我們可以顯示匹配的函式呼叫。因此,我們可以從上面提取基礎Python堆疊跟蹤:
file "ui/common/tray.py" , line 758 , in _do_segfault
file "dropbox/client/ui/cocoa/menu.py" , line 169 , in menuAction_
file "dropbox/gui.py" , line 274 , in guarantee_message_queue
file "dropbox/gui.py" , line 299 , in handle_exceptions
file "PyObjCTools/AppHelper.py" , line 303 , in runEventLoop
file "ui/cocoa/uikit.py" , line 256 , in mainloop
file "ui/cocoa/uikit.py" , line 929 , in mainloop
file "dropbox/client/main.py" , line 3263 , in run
file "dropbox/client/main.py" , line 6904 , in main_startup
file "dropbox/client/main.py" , line 7000 , in main
結語
有了這個系統,我們的開發人員就可以直接調查所有崩潰,無論是Python,C,C ++還是Objective-C。此外,我們為測量系統可靠性而引入的新監控使我們對應用程式正常執行的信心增加了。結果是為我們的桌面使用者提供了更穩定的應用程式。舉個例子:使用這個新系統,我們能夠執行Python 2到3的轉換,而不用擔心我們的使用者會受到負面影響。
原文釋出時間為:2018-11-26