Python 在 MacOS 上崩溃了

最近为了科学上网,安装了一个 ShadowSocksR 的 Python 版,起初运行得好好的,某次系统软件升级后突然崩溃了,再也无法正常启动了。

[1] 44820 abort

问题分析

没想到 Python 这种动态脚本语言也能崩溃,太神奇了,于是使用二分 print 大法开始定位问题。

很快定位到是这行代码崩溃了:

from ctypes import CDLL

# ...
lib = CDLL(path)

而且可以发现 path/usr/lib/libcrypto.dylib,Google 一下就明白了,是 OpenSSL 的动态链接库版本不兼容导致的。

回顾了一下最近的变更,发现果然 Homebrew 安装了一个 OpenSSL 的依赖,并且升级了。

因为我的 Python 是通过 Homebrew 和 pyenv 安装的,这两种方式都会从源码编译安装,并且会依赖 Homebrew 提供的最新版的 OpenSSL,在 /usr/local/opt/openssl/lib/libcrypto.dylib

而编译出来的 Python 在执行的时候,会重新根据系统环境去查找这个依赖,结果就找到了系统的版本,在 /usr/lib/libcrypto.dylib

版本不符,一个加载就直接崩溃了。顺便感慨一下,Node.js 似乎做得更好些,即使不能加载,崩溃前也会多透露一些有用的信息,可以节约不少时间。

解决依赖问题

问题定位了,看上去应该就很简单了,继续 Google 一下,很快就找到了解决方案

添加环境变量到 ~/.zshrc

export DYLD_LIBRARY_PATH=/usr/local/opt/openssl/lib:$DYLD_LIBRARY_PATH

再次运行代码,还是崩溃了!

继续通过二分 print 大法排查,发现问题还在同一个地方!

我的内心也是崩溃的。

难道这个环境变量在 Python 中不支持?但是这是一个系统级的约定,Python 没道理越过它去重新实现一套吧。

抱着一丝希望,我在 Python 里把环境变量打出来了:

import os

print(os.environ)
print(os.environ['DYLD_LIBRARY_PATH'])

然后惊奇地发现,居然没有这个环境变量。于是我回到 Shell 再打了一遍,确实是存在的。

至此,我才发现强大的 Apple 居然还有这种能力:启动程序时强行篡改环境变量!

被 Apple 砸了一下

继续 Google 了一下,发现了 System Integrity Protection Guide: Runtime Protections

大概是为了安全起见,新版的 MacOS 会在启动被保护的进程的子进程时,把所有的动态链接相关的环境变量过滤掉,比如 DYLD_LIBRARY_PATH

这就中招了。

这里有几种解法:

  • 粗暴型

    直接用新版替换掉系统的版本,就再也不用担心路径问题了。但是这样可能会有其他的副作用,比如系统中可能存在其他工具就依赖了系统版本的动态库,那就乱套了。

    $ sudo rm /usr/lib/libcrypto.dylib
    $ sudo ln -s /usr/local/opt/openssl/lib/libcrypto.dylib /usr/lib/libcrypto.dylib
  • 混乱型

    将 Homebrew 的动态库链接到 /usr/local/lib,这样也可以被找到了,而且系统原来的版本还在。但是同样可能存在一些软件查找到不正确的版本而崩溃。

    $ sudo ln -s /usr/local/opt/openssl/lib/libcrypto.dylib /usr/local/lib/libcrypto.dylib
  • 克制型

    魔改本地代码。这种方式完全遵守 Apple 的规范,丝毫不侵犯系统。

    我在环境变量里加了一个自定义的前缀:

    export DYLD_LIBRARY_PATH=/usr/local/opt/openssl/lib:$DYLD_LIBRARY_PATH
    export KEEP_DYLD_LIBRARY_PATH=DYLD_LIBRARY_PATH

    然后在 Python 代码里手动更新环境变量:

    DYLD_LIBRARY_PATH = os.environ.get('KEEP_DYLD_LIBRARY_PATH')
    if os.environ.get('DYLD_LIBRARY_PATH') is None and DYLD_LIBRARY_PATH is not None:
      os.environ['DYLD_LIBRARY_PATH'] = DYLD_LIBRARY_PATH

    在加载 DLL 之前执行这段 patch,就可以正常加载了。

结语

一直忙碌在无聊的业务代码中,已经好久没有遇到这么有意思的问题了,感觉操作系统的水真的很深啊。


© 2020