Dropbox力荐!我们如何应对Python桌面应用程序的崩溃
大数据文摘出品
编译:大写K、Ivy、fuma、Aileen
揭秘Crashpad系统如何帮助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的基本架构:
[图片上传失败...(image-c241ca-1543217691596)]
应用程序通过实例化一个进程内对象(称为“客户端”)来使用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的崩溃报告进行匹配,以确保我们预计会引起崩溃的退出代码确实包括大多数用户的崩溃情况。下图显示了我们的退出监控:
image看门狗允许我们验证崩溃报告是否正确
image看门狗允许我们在单个图中对崩溃和终止进行分类
我们用Rust编写了看门狗进程,为什么会选择Rust呢:
1.Rust的安全设置使代码可靠性非常高。
2.与操作系统的抽象接口设计良好,属于系统标准库的一部分,并且在需要时可以通过FFI轻松扩展接口。
3.我们在开发Dropbox时很大一部分都使用了Rust,这让Dropbox的搭建变得更加容易。
教Crashpad兼容Python
Crashpad主要是为本机代码设计的,因为Chromium主要是用C ++编写的。但是,Dropbox客户端大多是用Python编写的。由于Python是一种解释型语言,因此我们收到的大多数本机崩溃报告往往如下所示:
<pre style="max-width: 100%; background-image: none; background-color: initial;">
0 _ctypes.cpython-35m-darwin.so!_i_get + 0x4
1 _ctypes.cpython-35m-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
</pre>
这个堆栈跟踪对于试图发现崩溃原因的开发人员来说并不是很有帮助。虽然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】中重新实现了它。
关于Python 3转换的注释:由于Python 2和3的实现略有不同,我们在转换过程中保持对Crashpad fork中两个版本的Python结构的支持。
堆栈框架重建
现在Crashpad的报告包含了所有Python堆栈帧,我们可以改进符号化。为此,我们修改了我们的服务器基础结构,以解析我们对minidump的扩展并提取这些堆栈。具体来说,我们扩充了崩溃管理系统Crashdash,以显示本机崩溃报告的Python堆栈框架信息(如果可用)。
这是通过再次“遍历堆栈”来实现的,但这次,对于调用PyEval_EvalFrameEx的每个本机帧,我们从报告中“弹出”匹配的PyFrameObjectcapture。由于我们现在拥有每个帧的函数名,文件名和行号,现在我们可以显示匹配的函数调用。因此,我们可以从上面提取基础Python堆栈跟踪:
<pre style="max-width: 100%; background-image: none; background-color: initial;">
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
</pre>
结语
有了这个系统,我们的开发人员就可以直接调查所有崩溃,无论是Python,C,C ++还是Objective-C。此外,我们为测量系统可靠性而引入的新监控使我们对应用程序正常运行的信心增加了。结果是为我们的桌面用户提供了更稳定的应用程序。举个例子:使用这个新系统,我们能够执行Python 2到3的转换,而不用担心我们的用户会受到负面影响。
相关报道: