最近在每课服务器端内存优化的时候更改了 fork () 函数执行的位置,然而修改之后却导致程序在运行时出现段异常。对于 Python 开发者来说,段异常这类底层的异常并不多见,因此调试过程也值得记录一下。

对于 Python 开发者来说,段异常是个很陌生的东西,平常基本不太遇到。最近在给程序调优的时候出现了段异常,导致程序被系统终止然后被 master respawn,然而 respawn 之后很快再次出现问题,一直这样循环。通过 Google 我们可以知道,是内存访问相关的问题。

‌‌寻找罪魁祸首

由于解释器被操作系统终止,因此自然也没有 traceback 输出,因此我们无法从日志中发现问题的起源。幸运的是,Python 3.3 之后我们有了 faulthandler 模块,它可以打印出 trackback 信息。只需要将以下两行代码加入程序中:

import faulthandler
faulthandler.enable()

于是程序输出了如下信息:

Fatal Python error: Segmentation fault
Current thread 0x00007ff0f5cc1ae8 (most recent call first):
File "/usr/local/lib/python3.7/ssl.py", line 388 in __new__
File "/usr/local/lib/python3.7/ssl.py", line 1211 in wrap_socket
File "/var/everyclass-server/.venv/lib/python3.7/site-packages/raven/utils/http.py", line 38 in connect
File "/usr/local/lib/python3.7/http/client.py", line 956 in send
File "/usr/local/lib/python3.7/http/client.py", line 1016 in _send_output
File "/usr/local/lib/python3.7/http/client.py", line 1224 in endheaders
File "/usr/local/lib/python3.7/http/client.py", line 1275 in _send_request
File "/usr/local/lib/python3.7/http/client.py", line 1229 in request
File "/usr/local/lib/python3.7/urllib/request.py", line 1317 in do_open
File "/var/everyclass-server/.venv/lib/python3.7/site-packages/raven/utils/http.py", line 46 in https_open
File "/usr/local/lib/python3.7/urllib/request.py", line 503 in _call_chain
File "/usr/local/lib/python3.7/urllib/request.py", line 543 in _open
File "/usr/local/lib/python3.7/urllib/request.py", line 525 in open
File "/var/everyclass-server/.venv/lib/python3.7/site-packages/raven/utils/http.py", line 66 in urlopen
File "/var/everyclass-server/.venv/lib/python3.7/site-packages/raven/transport/http.py", line 43 in send
File "/var/everyclass-server/.venv/lib/python3.7/site-packages/raven/transport/threaded.py", line 165 in send_sync
File "/var/everyclass-server/.venv/lib/python3.7/site-packages/raven/transport/threaded.py", line 145 in _target
File "/usr/local/lib/python3.7/threading.py", line 865 in run
File "/usr/local/lib/python3.7/threading.py", line 917 in _bootstrap_inner
File "/usr/local/lib/python3.7/threading.py", line 885 in _bootstrap

于是我们发现问题是有 Sentry 的 Python SDK(raven)引起的。考虑到 Python-only 的 SDK 不太可能触发段异常(除非触发解释器 bug),猜测系统的锅比较大。

有没有人有类似的问题呢?Google 了一下之后发现确实有人发生过类似的问题:https://github.com/getsentry/raven-python/issues/1003。底下的回复提到的原因是 Alpine Linux (我使用的 Docker 基础镜像)使用的 C 语言库 musl 和一般的 glibc 在栈的大小上存在差异。由于 Alpine 一般运行在计算能力比较弱的设备上(你从它只有几 MB 的体积就能看出来),因此在内存的分配上比较节省,而 Python 对操作系统环境的估计比较乐观,因此就出现了异常。

事实上,Python 的官方 Docker 镜像中确实提到了关于 Alpine 采用 musl 所可能导致的问题:

The main caveat to note is that it does use musl libc instead of glibc and friends, so certain software might run into issues depending on the depth of their libc requirements. However, most software doesn't have an issue with this, so this variant is usually a very safe choice. See this Hacker News comment thread (https://news.ycombinator.com/item?id=10782897) for more discussion of the issues that might arise and some pro/con comparisons of using Alpine-based images.

至于 workaround,网友大致提供了几种:

由于第一种方案显得过于 hack,因此我选择了第二种方案,遗憾的是,我没有成功。而且官方表示 Alpine & Python 段异常的问题已经解决了( https://github.com/docker-library/python/issues/211#issuecomment-353010939 ),所以我打算换一个基镜像来尝试一下是否是 Alpine 的问题。

‌‌

切换基镜像试试运气

在 Python 官方 Docker Hub 页面中,有两种 Linux 基镜像可选,一种基于 Alpine,一种基于 Debian。 使用 Alpine 很好理解,因为它足够轻量,适宜运行在容器中,在 Docker 社区中非常流行。而另一种使用 Debian 就有一点难以理解了。我试图寻找官方选择 Debian 而不是其他 Linux 发行版的理由,但发现目前没有相关的讨论。Anyway, we'll give Debian a shot.

把基镜像改为 Debian 并不是十分困难,基本上就是把 apk add 命令替换成 apt-get install 命令,然后替换个别包名。值得一提的是,我使用的基镜像是 python:3.7.1-slim-stretch,最终应用镜像大小为 163MB。作为对比,原先使用 Alpine 时,最终镜像大小为 100MB 左右,并没有差的太远。(以上两个数据为压缩后的,非 docker images ls 所展示的大小)

提示

Docker 镜像有两种大小:压缩的(compressed)和未压缩的(uncompressed)。 当你使用 docker images ls 的时候,你看到的是未压缩的大小。但在 Docker Hub,你看到的是压缩后的大小。通常这两者会有数倍的大小差异。

值得欣喜的是,在把基镜像从 Alpine 换到 Debian (Stretch Slim) 之后,确实没有再报段异常了。虽然镜像的体积增大了数倍,但 C 语言库引起的兼容性问题终于解决了。

‌‌

结论

通过将基镜像从 Alpine 切换到 Debian,我们成功解决了 musl 带来的兼容性问题。更重要的是,兼容性并不是切换到 Debian 带来的唯一福利。事实上,你的程序还可能出现一定幅度的性能提升(参见 Benchmarking Debian vs Alpine as a Base Docker Image)。

当然,如果执意使用 Alpine,或许也能解决问题。但是考虑到时间效率的平衡,以及未来可能继续出现的兼容性问题,我还是决定不折腾 Alpine 了。看来,一味追求镜像的体积小是会有风险的,尤其是你不可能对整个程序的所有代码面面俱到地掌握(这对于 Python 程序员来说不太可能)的情况下。