Nginx 内嵌 CPython 3.5→3.8 升级实录

Nginx 内嵌 CPython 3.5→3.8 升级实录

摘要: 我们的 Nginx 不是反向代理,而是进程内嵌入 CPython 跑业务逻辑。升级 Python 3.5→3.8 时,真正卡住的不是语法,而是 Nginx(HTTPS)+ CPython(ssl 模块)+ 硬件加密狗 SDK 在同一进程里对 OpenSSL 的版本拉扯。本文记录三轮踩坑、gdb 排障过程和最终解法。

关键词: Nginx、CPython、嵌入、OpenSSL、加密狗、ERP


一、背景:为什么 Nginx 里跑 Python

团餐 ERP 这类系统大量本地部署到客户内网,交付形态是整机/整包安装。我们的 Nginx 承担的是应用服务器:HTTP/HTTPS 进线后,核心逻辑在同一进程里由嵌入的 CPython 执行,而不是反代到 uwsgi/gunicorn。

选 CPython 而不是 OpenResty(Lua),原因很朴素:团队资产和技能栈都在 Python,业务脚本、对接方式、排障习惯都长在这条链上。我们用 BOOST_PYTHON_MODULE(erp_bridge) 把日志、应答、跨进程 FIFO 等能力注册进 Python,换 Lua 等于重写一层 FFI,成本比升级解释器大一个数量级。

客户环境不可控、拷贝成本低,核心脚本不能明文裸奔------所以进程里还叠了一层硬件加密狗 SDK做运行期绑定。这个加密狗是后文 OpenSSL 拉扯的第三方主角。

Python 3.5 在 2020 年停止维护,安全补丁没了,依赖链也站不住。升级到 3.8 是必然。


二、单进程里谁在做什么

升级之前先搞清楚当前系统长什么样。跑在线上的 nginx 可以看成一条固定流水线,后文所有的改动都是换心脏但不改站位:

flowchart TB subgraph proc["同一 nginx 进程"] ngx["Nginx core / http_ssl"] py["libpython3.8 + _ssl"] dog["硬件加密狗 SDK"] end osl["OpenSSL 1.1.x 单一语义基线"] ngx --> osl py --> osl dog --> osl

四块积木:

  1. TLS(Nginx http_ssl):对外 HTTPS,底层链 OpenSSL
  2. 硬件授权(加密狗 SDK):进程启动前就要就绪,失败直接拒绝启动;厂商库内部也走 TLS/密码学路径
  3. 嵌入 CPython:在 worker 侧用独立业务线程跑,不能让 Nginx 事件线程随便进解释器
  4. 链接与运行时 :上述全部编译进同一个可执行文件或同一套 rpath,运行时只能认一套 libcrypto/libssl

启动顺序:先授权 → 再进主循环 → worker 里起 Python → GIL 边界 → 同一条大链接钉死 OpenSSL

2.1 启动:先授权,再进主循环

c 复制代码
ngx_use_stderr = 0;
if (init_hardware_key(cycle) != NGX_OK) {
    return 1;
}
if (ngx_process == NGX_PROCESS_SINGLE) {
    ngx_single_process_cycle(cycle);
} else {
    ngx_master_process_cycle(cycle);
}

加密狗走在 master 启动路径上,和 worker 里起的 Python 业务线程处于同一地址空间的不同阶段------所以 ABI 不对时,崩溃堆栈很难一眼猜到是业务脚本的问题还是授权链的问题。

2.2 嵌入解释器:注册桥 → 设 home → 初始化 → 放开 GIL

cpp 复制代码
PyImport_AppendInittab("erp_bridge", PyInit_erp_bridge);
Py_SetPythonHome(python_home_wide);
if (embedded_runtime.start() != 0) { /* 内部即 Py_InitializeEx(0) */ }
if (!PyEval_ThreadsInitialized()) {
    PyEval_InitThreads();
}
PyObject *sys_path = PySys_GetObject("path");
PyList_Append(sys_path, PyUnicode_FromString(handlers_dir));
py_thread = PyEval_SaveThread();

几个要点:

  • PyImport_AppendInittab 必须在 Py_Initialize 之前调用,否则内置模块注册不上
  • Py_InitializeEx(0) 参数 0 表示不安装 Python 自己的信号处理器,避免和 Nginx master/worker 的信号逻辑抢钩子
  • PyEval_SaveThread() 释放主线程 GIL,把"日常进 Python"交给业务线程里的 PyGILState_Ensure/Release

2.3 GIL:只在进 Python 的临界区持锁

Nginx 侧大量 C 代码,任何调用 Py* 的路径都应落在明确临界区内:

cpp 复制代码
GILGuard::GILGuard() {
    gil_token = PyGILState_Ensure();
}
GILGuard::~GILGuard() {
    PyGILState_Release(gil_token);
}

业务线程从队列取请求后,只在"加载模块/调用 Python 函数"这一段用 GILGuard 包住。

2.4 链接形态

Nginx 官方 configure 生成 objs/Makefile 后,我们用 build.sh 做二次修补:

sh 复制代码
./configure --with-http_ssl_module \
  --with-http_stub_status_module \
  --add-module=../ngx_rpc_module \
  --with-openssl=../third_party/openssl-1.1.1w \
  --add-module=../ngx_python_embed_module \
  --add-module=../nginx-upload-module

同一条链接命令里必须同时出现:Python 3.8 动态库硬件授权三件套OpenSSL 静态库pthread。缺一环就会"本地能编、现场偶崩"。


三、核心难点:三个主角抢一个 OpenSSL

最终链接进同一个 nginx 的,至少三类角色在动 OpenSSL:

  1. Nginx 的 HTTPShttp_ssl_module
  2. CPython 的 _ssl 扩展(与特定 OpenSSL ABI 绑定)
  3. 硬件加密狗厂商库hkey_* 系列接口,初始化、登录、心跳都可能触达 OpenSSL)

问题本质:动态链接器视角下只能容忍一套 libcrypto/libssl 语义。三方各自按不同版本编译,链接能过,运行期随机崩。

Nginx 侧通过 --with-openssl=../third_party/openssl-1.1.1w + 静态链接钉死版本,减少运行时解析到另一份 libssl.so 的概率。


四、升级路径与踩坑

4.1 CPython 3.5→3.8:C API 变化

嵌入升级绝不是换一个 python3 可执行文件。3.5→3.8 跨了三个大版本,C API 有几处必须改:

模块初始化:Py_InitModule3PyModule_Create

3.5 写法:

c 复制代码
PyMODINIT_FUNC initerp_bridge(void) {
    Py_InitModule3("erp_bridge", erp_methods, "ERP bridge module");
}

3.8 写法:

c 复制代码
static struct PyModuleDef erp_bridge_module = {
    PyModuleDef_HEAD_INIT,
    "erp_bridge",
    "ERP bridge module",
    -1,
    erp_methods
};

PyMODINIT_FUNC PyInit_erp_bridge(void) {
    return PyModule_Create(&erp_bridge_module);
}

变化点:模块定义结构体变成必填,初始化函数名必须加 PyInit_ 前缀且返回 PyObject*。漏改的表现:import erp_bridgeModuleNotFoundError,但模块文件明明在 sys.path 里。

字符串:PyString_FromStringPyUnicode_FromString

3.5 里 PyString_* 系列还能用(有兼容宏),3.8 直接去掉了。全文搜 PyString_ 替换成 PyUnicode_,否则编译报错。

线程初始化:PyEval_InitThreads 变为空操作

3.8 里 PyEval_InitThreads() 已经是 no-op(线程在 Py_Initialize 时自动初始化),调用不报错但也不做事。保留调用不影响运行,但不要依赖它来"确保线程就绪"。

Py_Finalize 热重载的坑

调试路径里如果反复 Py_Finalize 再重建解释器,和 worker 里仍在跑的业务线程叠在一起,很容易竞态。这类代码只适合本机排障,不应作为生产默认能力。

4.2 Nginx 嵌入层适配

多进程模型下要想清楚谁在哪个进程、哪条线程里碰解释器:

  • python_embed_worker_init 里创建 PythonWorker 并启动
  • python_embed_worker_exit 里置 g_running 为 false,优雅结束业务线程
  • worker_python.cpp 主循环只在"加载业务模块/调用 Python 函数"这一段用 GILGuard 包住

3.5→3.8 升级后,嵌入层 C 代码改动不大,主要就是模块初始化函数签名和字符串 API。真正卡住的是下一节。

4.3 OpenSSL 三方对齐:三轮踩坑

第一轮:各自链接,交错崩

现象: 启动 nginx 后,三种失败随机出现------

bash 复制代码
# 现象1:HTTPS 握手直接断
curl: (35) error:141A10F4:SSL routines:OPENSSL_internal:version_conflict

# 现象2:Python ssl 模块 import 失败
ImportError: /opt/erp/lib/libssl.so.1.0.0: version 'OPENSSL_1_1_0' not found

# 现象3:加密狗初始化报错
[error] hkey_init failed: HKEY_ERR_SSL_INIT (-1024)

三种错误不会同时出现,而是"修一个坏另一个"。

定位过程:

bash 复制代码
# 先看 nginx 链接了几份 OpenSSL
ldd objs/nginx | grep ssl
        libssl.so.1.0.0 => /lib/x86_64-linux-gnu/libssl.so.1.0.0
        libssl.so.1.1 => not found

问题清楚了:nginx 自身链到了系统里的 1.0.0,但 Python 3.8 编译时用的 1.1.x,运行时 import ssl 要求 OPENSSL_1_1_0 符号------在 1.0.0 里找不到。

加密狗 SDK 也类似:厂商编译时假设系统有 1.0.x,升级后 1.0.x 不在了,hkey_init 内部调 SSL_library_init()(1.0.x 的 API,1.1.x 里已移除),直接崩溃。

第一轮修复: Nginx 侧用 --with-openssl=../third_party/openssl-1.1.1w + 静态链接,把 Nginx 的 OpenSSL 钉死到 1.1.1w。

第二轮:Nginx 好了,加密狗又挂

现象: HTTPS 和 import ssl 都正常了,但 hkey_init 启动时 segfault:

bash 复制代码
# gdb 抓到的崩溃
Program received signal SIGSEGV, Segmentation fault.
0x00007f3b2c1a8e22 in SSL_CTX_new () from /opt/erp/third_party/libhkey_runtime.so
(gdb) bt
#0  0x00007f3b2c1a8e22 in SSL_CTX_new () from libhkey_runtime.so
#1  0x00007f3b2c1a3015 in hkey_ssl_init () from libhkey_runtime.so
#2  0x0000000000428e11 in init_hardware_key ()

根因: 厂商的 libhkey_runtime.so 内部静态链接了一份 OpenSSL 1.0.x 的 SSL_CTX_new,它的 SSL_METHOD 结构体布局和 1.1.x 完全不同。1.1.x 把 SSL_METHOD 变成了 opaque struct,内部字段位置全变了。厂商库拿着 1.0.x 的偏移量去读 1.1.x 的内存,不崩才怪。

第二轮修复: 联系厂商拿基于 OpenSSL 1.1.x 编译的新版 SDK(libhkey_runtime.so / libhkey_control.so / libhkey_auth.so)。

第三轮:定案------单宿主、单版本、尽量静态

最终 build.sh 拼出的链接行:

bash 复制代码
nginx-objs/nginx:
  -lpython3.8 -lboost_python38 -ljsoncpp -lerp_business
  -lhkey_runtime -lhkey_control -lhkey_auth
  ../third_party/openssl-1.1.1w/libssl.a
  ../third_party/openssl-1.1.1w/libcrypto.a
  -lpcre -lz -ldl -lpthread

原则:

  • OpenSSL 静态链接libssl.a/libcrypto.a 直接打进去,不依赖运行时 libssl.so
  • Python 动态链接-lpython3.8 保持动态,方便换小版本
  • 加密狗三件套动态链接 :厂商 .so 保持动态,方便厂商升级
  • pthread 必须在末尾 :否则链接器可能找不到 pthread_create 的引用(后文 4.4 详述)

验证 ldd 输出只剩一套 OpenSSL:

bash 复制代码
ldd objs/nginx | grep -i ssl
# 应该只剩 libssl.so.1.1 → /opt/erp/third_party/openssl-1.1.1w/libssl.so.1.1
# 不应出现 libssl.so.1.0.0

代价:OpenSSL 小版本升级会牵动整条重编链。但比起运行期随机崩,这个代价可以接受。

4.4 Makefile 手术:build.sh 做了什么

nginx 的 configure 产物不总完美,我们用 build.sh 做四次修补:

修补1:去掉 -Werror

自研模块有些 warning(废弃 API、类型转换),-Werror 会让编译直接失败:

sh 复制代码
sed -i 's/-Werror//' objs/Makefile

修补2:钉死 OpenSSL 头文件路径

configure 生成的 Makefile 里 OpenSSL include 路径可能指向系统目录,强制指向源码树:

sh 复制代码
sed -i "s|-I/usr/include/openssl|-I../third_party/openssl-1.1.1w/include|g" objs/Makefile

修补3:-lpthread 挪到链接行末尾

Python 3.8 要求 -lpthread,但 configure 把它放在链接行中间,导致后面的 -lpython3.8 找不到 pthread_create

sh 复制代码
# 原始链接行(简化):
# $(CC) -o nginx objs/nginx.o ... -lpthread -lpython3.8 -lssl -lcrypto
# pthread 在 python 前面,链接器从前到后扫,python 里的 pthread 符号找不到

# 修复:把 -lpthread 移到末尾
sed -i 's/-lpthread //' objs/Makefile
sed -i '$ s/$/ -lpthread/' objs/Makefile

修补4:补齐 Python 和加密狗的链接

configure 不会自动加我们的业务库,手动追加:

sh 复制代码
sed -i '$ s/$/ -L\/opt\/erp\/python3.8\/lib -lpython3.8 -lboost_python38/' objs/Makefile
sed -i '$ s/$/ -L\/opt\/erp\/hkey\/lib -lhkey_runtime -lhkey_control -lhkey_auth/' objs/Makefile

每次 configure 后跑一遍 build.sh,团队任何人都能一键复现构建,不用手工改 Makefile。

4.5 验收

上线前按三条做回归,缺一条不过:

TLS: 压测工具跑满握手与常见套件,证书链、会话复用、错误证书行为都要看。

Python 业务: handlers 下模块稳定完成"加载模块→调用入口函数"。Python 异常栈是否返回客户端由 debug_mode 配置项控制,生产默认关闭。

硬件授权: init_hardware_key 成功路径 + hkey_keep_alive 周期成功;若失败会 ngx_signal_process(..., "stop") 拉停进程------这是硬失败停机,必须在产品说明里写清。


五、经验总结

  1. 先画依赖图,数清楚 OpenSSL 被链接了几次ldd + nm -D 看符号来源,别猜
  2. 锁死版本矩阵:OpenSSL 1.1.1w + Python 3.8.18 + 厂商 SDK 版本,写进文档
  3. 三条链路分别验证再合并:TLS 单独过 → Python ssl 单独过 → 加密狗(含心跳)单独过 → 合并压测
  4. 能用静态就静态:同一进程里多份动态 OpenSSL 是定时炸弹
  5. build.sh 比 README 靠谱:手工改 Makefile 不可复现,脚本化才能团队共享

嵌入场景比跑独立 python 多出来的坑:GIL 与 Nginx 线程模型信号与 forkFIFO 跨 worker 应答 ------并发问题不止 GIL 一层。本地部署叠加代码加密与硬件授权时,hkey_* 全路径(含心跳)要和 Nginx、CPython 共用同一套 OpenSSL 语义,否则表现为"HTTPS 正常、业务偶发、授权随机掉"。

这件事的价值不在于证明 Nginx 能嵌 Python,而在于:当 TLS、解释器、厂商密码学库必须共居一室时,用版本矩阵 + 链接拓扑 + 分链路验证,把不可复现的崩溃变成可管理的工程问题。

相关推荐
vx-程序开发2 小时前
springboot课程管理系统-计算机毕业设计源码16731
spring boot·后端·课程设计
stark张宇2 小时前
支付宝 App 支付踩坑记:x509 证书不匹配 & 应用未上线问题解决
后端·支付宝
MacroZheng3 小时前
IDEA + 阿里 Qoder = 王炸!
java·人工智能·后端
鹏程十八少3 小时前
Android 无障碍服务失效,一次AccessibilityService“离奇死亡”的完整破案实录
前端·后端·面试
_Evan_Yao3 小时前
从 select 到 epoll,再到 Agent 循环:如何用 I/O 多路复用撑起千军万马?
java·数据库·人工智能·后端
长谷深风1113 小时前
SpringBoot开发秘籍【个人八股】
java·spring boot·后端·spring·八股
IT_陈寒3 小时前
SpringBoot这个事务回滚的坑我算是踩明白了
前端·人工智能·后端
JackSparrow4144 小时前
彻底理解Java NIO(二)C语言实现 I/O多路复用+Reactor模式 服务器详解
java·linux·c语言·后端·nio·reactor模式
代码地平线4 小时前
⭐️C++入门基础精讲(一):从发展历史到第一个程序
大数据·c++·后端·深度学习