用 sitecustomize.py 给 Python 应用做无侵入运行时补丁

什么是 sitecustomize.py

Python 解释器启动时,site 模块会在初始化的最后阶段尝试 import sitecustomize。只要这个模块出现在 sys.path 能搜到的任意位置(通常放进 site-packages,或直接扔到工作目录、再用 PYTHONPATH 指过去),它就会被自动执行——早于你的主程序、早于任何第三方库真正干活。

这带来一个非常有用的能力:

不修改任何一行业务代码的前提下,往一个已经打好包、甚至被加密混淆过的 Python 应用里,注入你自己的运行时逻辑。

典型场景包括: - 修复被打包工具搞丢的 sys.path; - 替换某个第三方模块里的类 / 函数(比如授权校验、埋点、限流); - 给底层 HTTP 库统一注入超时、重试、代理配置; - 在进程最开始就装好日志 / 监控 / 采样钩子。

下面这段脚本把上述几件事都做了。我们逐块拆开看它的设计。

完整脚本

# sitecustomize.py
import builtins
import sys
import threading
import os

# =============================================================================
# 1. 强力双重防重入锁机制
# =============================================================================

# 锁 A(全局骨架锁):防止因为多次加载导致的多次 Hook
if not hasattr(builtins, "_original_import_backup"):
    # 备份最原始的系统导入方法
    builtins._original_import_backup = builtins.__import__

    # 锁 B(线程函数锁):防止解密过程中触发隐式导入造成的无限递归
    _import_recursion_lock = threading.local()

    print("[Patch] 正在注入终极防重入沙箱补丁...")

    # =============================================================================
    # 2. 自动修复路径
    # =============================================================================
    CRITICAL_PATHS = ["/opt/app", "/opt/app/service"]
    for path in CRITICAL_PATHS:
        if path not in sys.path:
            sys.path.insert(0, path)

    # =============================================================================
    # 3. 构造伪造的 PRO 版授权类
    # =============================================================================
    class MockLicenseInfo:
        def __init__(self, *args, **kwargs):
            pass

        def is_free(self):
            return False  # 核心:永远不触发免费版的 sys.exit(1)

        def get_type(self):
            return "PRO"

    # =============================================================================
    # 3.5 直连超时补丁(后端无关 + 自证后端)
    #     根因:取 115 直连的请求读超时默认 60s;115 偶发不响应时单请求卡满 60s,
    #     请求堆积 -> 占满 app 共享资源 -> 整个后端服务僵死(连 /api 都超时),只能重启。
    #     p115client 可用多种 HTTP 后端,默认 urllib3_future_request,也可能是 httpx_request。
    #     这里把这两个后端的 request 函数各包一层:① 首次调用时打印实际命中哪个后端;
    #     ② 强制注入读超时,卡住的请求 ~Ns 内快速失败 -> 不再雪崩僵死。
    #     可用环境变量 P115_READ_TIMEOUT 调整(秒),默认 8。
    # =============================================================================
    try:
        _P115_READ_TIMEOUT = float(os.environ.get("P115_READ_TIMEOUT", "8"))
    except Exception:
        _P115_READ_TIMEOUT = 8.0
    try:
        _P115_RETRIES = int(os.environ.get("P115_RETRIES", "0"))
    except Exception:
        _P115_RETRIES = 0
    _backend_logged = {}

    def _mk_httpx_timeout():
        from httpx import Timeout
        return Timeout(connect=5, read=_P115_READ_TIMEOUT,
                       write=_P115_READ_TIMEOUT, pool=5)

    def _mk_urllib3_timeout():
        from urllib3_future import Timeout
        return Timeout(connect=5, read=_P115_READ_TIMEOUT)

    # 每后端额外强制的 kwargs(retries 只对 urllib3 有意义;httpx 不认这个参数)
    def _extra_urllib3():
        # 直接用 int(实测 retries=0 即可让读超时 8s 后立刻失败,不再 ×4 重试)
        return {"retries": _P115_RETRIES}

    def _extra_none():
        return {}

    def _wrap_backend_request(modname, make_timeout, make_extra):
        try:
            module = builtins._original_import_backup(modname)
        except Exception as e:
            print("[Patch] 后端 %s 未安装/导入失败: %s" % (modname, e))
            return
        orig = getattr(module, "request", None)
        if orig is None or getattr(orig, "_p115_to_wrapped", False):
            return
        try:
            to = make_timeout()
            extra = make_extra()
        except Exception as e:
            print("[Patch] %s 构造 timeout/extra 失败: %s" % (modname, e))
            return

        def wrapped(*a, **kw):
            if not _backend_logged.get(modname):
                _backend_logged[modname] = True
                print("[Patch] >>> 115 HTTP 后端实际命中 = %s (强制 read=%ss, retries=%s) <<<"
                      % (modname, _P115_READ_TIMEOUT, _P115_RETRIES))
            kw["timeout"] = to   # 强制覆盖,防 app/p115client 自己传 60s 或 None
            for _k, _v in extra.items():
                kw[_k] = _v
            return orig(*a, **kw)

        wrapped._p115_to_wrapped = True
        try:
            module.request = wrapped
            print("[Patch] 已包裹后端 %s.request,注入读超时 %ss" % (modname, _P115_READ_TIMEOUT))
        except Exception as e:
            print("[Patch] 包裹 %s 失败: %s" % (modname, e))

    # =============================================================================
    # 4. 安全无感知的拦截核心
    # =============================================================================
    def patched_import(name, *args, **kwargs):
        # 如果当前线程已经在执行导入流(说明是内部解密触发的二次导入),直接放行走原版
        if getattr(_import_recursion_lock, "is_loading", False):
            return builtins._original_import_backup(name, *args, **kwargs)

        # 激活重入锁
        _import_recursion_lock.is_loading = True
        try:
            # 调用原生的导入,让底层安全地完成它的解密和加载工作
            module = builtins._original_import_backup(name, *args, **kwargs)
        finally:
            # 释放重入锁
            _import_recursion_lock.is_loading = False

        # 当模块安全加载完毕返回时,我们再悄悄修改属性
        if module:
            if hasattr(module, 'LicenseInfo'):
                try:
                    setattr(module, 'LicenseInfo', MockLicenseInfo)
                except:
                    pass
            if hasattr(module, 'check_donate_code'):
                try:
                    setattr(module, 'check_donate_code', lambda *args, **kwargs: None)
                except:
                    pass
        return module

    # 将安全的拦截器回写至内置内核
    builtins.__import__ = patched_import

    # 直连超时补丁:预加载并包裹两个候选 HTTP 后端的 request(谁被命中谁生效)。
    # 不依赖上面的 import hook(后端多为嵌套导入,会被防重入锁跳过)。
    _wrap_backend_request("urllib3_future_request", _mk_urllib3_timeout, _extra_urllib3)
    _wrap_backend_request("httpx_request", _mk_httpx_timeout, _extra_none)

    print("[Patch] 路径修复与安全拦截沙箱部署完毕,等待主程序拉起...")

拆解四个关键技巧

技巧一:双重防重入锁

sitecustomize 有可能被多次加载(多进程、reload、被打包器重复触发),而脚本里又改写了 builtins.__import__ 这种全局对象。如果不加保护,就会出现「补丁打补丁」甚至无限递归。脚本用了两把锁来兜底:

threading.local() 而不是普通全局布尔量,是为了让每个线程各自独立计数,多线程并发导入时互不干扰。

技巧二:修复 sys.path

CRITICAL_PATHS = ["/opt/app", "/opt/app/service"]
for path in CRITICAL_PATHS:
    if path not in sys.path:
        sys.path.insert(0, path)

打包 / 容器化后的应用经常出现「本地跑得好好的,进了容器就 ModuleNotFoundError」,根因往往是工作目录或源码目录没进 sys.path。因为 sitecustomize 执行时机早于主程序,在这里 insert(0, ...) 把关键目录塞到搜索路径最前面,后续所有 import 都能正确定位。这是最朴素、也最常见的一类用途。

技巧三:import hook —— 加载后替换模块属性

这是整段脚本的核心。思路是:不阻止导入,只在导入完成后动手改

module = builtins._original_import_backup(name, *args, **kwargs)
...
if hasattr(module, 'LicenseInfo'):
    setattr(module, 'LicenseInfo', MockLicenseInfo)

先让原生 __import__ 把模块正常加载完(对被加密混淆的模块尤其重要,解密逻辑必须让它自己跑完),拿到 module 对象后,再检查它身上有没有我们关心的符号——例如授权信息类 LicenseInfo、捐赠码校验函数 check_donate_code——有就替换成我们的桩实现。

桩类 MockLicenseInfois_free() 恒返回 Falseget_type() 恒返回 "PRO",从而绕开原逻辑里「免费版就 sys.exit(1)」的分支;check_donate_code 被换成一个什么都不做的 lambda

这种「加载后打补丁」的模式比「拦截并伪造整个模块」要稳健得多: - 不需要理解目标模块的内部实现,只在它的公开属性上做最小改动; - 对混淆 / 加密代码友好,因为真正的加载、解密都交还给原生机制; - try/except 包住每次 setattr,即使目标符号是只读的也不会把整个进程带崩。

注意 __import__ hook 只能拦到「顶层由它触发的那一次导入」。很多库在函数内部才 import,或者用 C 扩展、importlib 直接加载,就绕过了这层钩子——这也正是下一个技巧存在的原因。

技巧四:monkey patch —— 给底层 HTTP 后端强制注入超时

脚本注释里记录了一个非常真实的线上事故:某网盘直连请求的读超时默认是 60s,对端偶发不响应时,单个请求会卡满 60s,请求不断堆积、占满共享资源,最终整个后端服务僵死,连健康检查接口都超时,只能重启

修复的思路不是去改业务代码里每一处请求调用,而是在最底层的 HTTP 后端函数上包一层

def _wrap_backend_request(modname, make_timeout, make_extra):
    module = builtins._original_import_backup(modname)
    orig = getattr(module, "request", None)
    if orig is None or getattr(orig, "_p115_to_wrapped", False):
        return
    ...
    def wrapped(*a, **kw):
        kw["timeout"] = to          # 强制覆盖调用方传进来的 60s / None
        for _k, _v in extra.items():
            kw[_k] = _v
        return orig(*a, **kw)
    wrapped._p115_to_wrapped = True
    module.request = wrapped

这里有几个值得学习的细节:

  1. 后端无关 + 自证后端。目标客户端可能用 urllib3_future 也可能用 httpx 作为 HTTP 后端,脚本把两个候选后端的 request 都包一层,谁被真正调用谁生效;wrapped 首次执行时打印一行日志,让你在运行时确认「究竟命中了哪个后端」,省掉大量猜测。

  2. 强制覆盖而非补默认值kw["timeout"] = to 是直接覆盖,哪怕业务代码或库自己传了 timeout=60None,也会被我们的短超时顶掉。这才是「兜底」——不给上游留后门。

  3. 幂等标记 _p115_to_wrapped。包好后在函数上打个属性标记,重复执行时看到标记就跳过,防止「包了又包」叠加多层。这和技巧一的骨架锁是同一种防御思想。

  4. 参数可调。读超时、重试次数都从环境变量读取(P115_READ_TIMEOUTP115_RETRIES),改行为不用改代码、重打镜像。

  5. 绕开自己的 import hook。注释特别说明这两个后端「多为嵌套导入,会被防重入锁跳过」,所以这里主动预加载并直接改,而不是指望技巧三的 __import__ 钩子去拦——很清楚地示范了两种 hook 各自的边界。

如何让 sitecustomize 生效

只要保证解释器能 import 到它,任选一种:

# 方式一:放进 site-packages(对该环境全局生效)
cp sitecustomize.py "$(python -c 'import site; print(site.getsitepackages()[0])')/"

# 方式二:放在任意目录,用 PYTHONPATH 指过去(推荐,隔离、可控)
export PYTHONPATH=/opt/patch:$PYTHONPATH
# /opt/patch/sitecustomize.py 就会被自动加载

想临时关掉所有此类自定义,可以用 python -S 启动(跳过 site 初始化),或设置 PYTHONNOUSERSITE。调试时若怀疑它没被加载,可以 python -c "import sitecustomize; print(sitecustomize.__file__)" 确认路径。

小结

sitecustomize.py 的价值在于它「早、且全局」的执行时机,让你能对一个无法(或不想)改动源码的 Python 应用做外科手术式的运行时补丁。本文这段脚本示范了一套可复用的组合拳:

需要提醒的是:这类技术是把双刃剑。用它做路径修复、超时兜底、监控注入是稳态运维的好帮手;但用它绕过授权校验则涉及软件授权与合规问题,请仅在自有 / 已获授权的环境里使用。理解它的原理,更多是为了在排障时看懂「为什么我的进程行为和源码对不上」。

关键词: 技术 Python