从 .pyc 头部到部署现场:Python 字节码缓存失效与排障指南
上周帮人排查一个挺典型的问题:测试环境改了业务判断逻辑,打包上线之后日志里跑的却还是三天前的分支,git log干干净净,cat源文件确实是新内容,唯独程序行为是旧的。翻了二十来分钟,最后ls -l看了一眼__pycache__里对应文件的时间戳——比.py还新。这就是 pyc 文件在背后起作用。很多人对它的认知停留在“Python 会自动生成的一个缓存”,但它其实有一套非常明确的失效判定规则,规则没被满足,旧字节码就继续生效,而且整个过程静默无提示。这篇东西我打算把.pyc从二进制头部一路拆到部署现场,讲清楚它的物理结构、import 系统什么时候决定复用它、什么情况下会踩坑,以及在分发和性能这两个方向上它到底能帮上多少忙。适合已经写过一段时间 Python、开始接触打包部署或者被“代码没生效”折磨过的读者,纯新手也能看懂前半部分。
1. 十六个字节的头部里,装着版本、时间与校验三件事
先把.pyc拆开看。它不是什么神秘格式,本质上就是一个固定长度的头部 + 一段 marshal 序列化后的 code object。头部长度在 Python 3.7 之后固定为 16 字节,在此之前的版本是 12 字节,这个差异本身就构成了一类跨版本事故,后面会单独说。
1.1 magic number:四个字节完成的版本隔离
文件最前面的 4 个字节是 magic number,其中前 2 字节是真正的版本标识,后 2 字节固定是\r\n。这个设计挺有意思——加回车换行是为了防止某些文本模式传输或编辑器把二进制文件当文本处理时“顺手”改坏,一旦\r\n对不上,加载器立刻就知道文件被污染了。
magic number 每个 Python 小版本都会变。3.8、3.9、3.10、3.11、3.12 各不相同,因为它们之间的字节码指令集、栈帧布局、异常表结构都有调整,marshal 出来的 code object 结构也不兼容。想拿当前解释器的 magic,最直接的办法是:
import importlib.util print(importlib.util.MAGIC_NUMBER.hex()) # 例如 6f0d0d0a注意imp.get_magic()这个老接口早就废弃了,别再往新代码里写。拿到 magic 之后和 pyc 文件头前 4 字节做一次字节比较,就能判断这个 pyc 是不是当前解释器能吃的。对不上会抛ImportError: bad magic number in 'xxx',如果你手上只有 pyc 没有源码,这个错误基本等于“这个文件废了”,只能找对应版本的 Python 重新编译。
1.2 PEP 552 定义的两种失效模式
紧接着 magic 的是 4 字节的 flags 字段,小端序的 uint32。这个字段是 PEP 552 引入的,决定了后面 8 个字节怎么解释:
| flags 值 | 模式 | 后 8 字节含义 |
|---|---|---|
0b00 | TIMESTAMP | 4 字节源文件 mtime + 4 字节源文件大小 |
0b01 | CHECKED_HASH | 8 字节源文件内容哈希 |
0b11 | UNCHECKED_HASH | 8 字节源文件内容哈希,但加载时不校验 |
时间戳模式是历史默认行为,兼容性最好,也是绝大多数人无意中在用的模式。它的判定逻辑非常朴素:拿源文件当前的st_mtime取整到秒,和头部记录的 mtime 比;再拿st_size和头部记录的大小比。两个都相等,就认为 pyc 有效。
哈希模式则是对源文件内容算一个 64 位的 SipHash-1-3 摘要(importlib.util.source_hash就是干这个的),内容变一个字节都会失配。CHECKED_HASH在加载时会重新读源文件算哈希,UNCHECKED_HASH则跳过校验直接信任——后者在“只发 pyc 不发源码”的分发场景里很常见,因为源文件根本不存在,没法算哈希。
提示:想在项目里统一改成哈希模式,
python -m compileall --invalidation-mode checked-hash src/就能一次性重编译。这个改动在部署环境里价值很高,原因在第四节会展开。
1.3 头部之后,marshal 出来的到底是个什么东西
16 字节之后的部分是marshal.dumps(code_object)的产物。code object 是 CPython 对“一段可执行代码”的完整描述,里面装着这些东西:
co_code:真正的字节码指令序列,一个字节一个操作码,或者操作码加参数co_consts:常量池,字符串、数字、嵌套的 code object 都在这里co_names:全局名字和属性名co_varnames/co_freevars/co_cellvars:局部变量、闭包变量co_flags:标记是否是生成器、协程、有没有*argsco_exceptiontable:3.11 之后单独的异常处理表,之前是混在字节码里的 SETUP_FINALLY 指令
marshal 格式本身是 CPython 的内部实现细节,官方文档明确写了“不保证跨版本兼容”,甚至在同一个版本的不同构建之间也可能有差异。这就解释了为什么 pyc 不能跨版本复用——不是加载器“不愿意”兼容,是底层数据结构真的对不上。你甚至可以在代码里手动marshal.loads一段字节,但没太大意义,dis模块才是正经的观察工具。
2. import 走到哪一步才决定“用旧的还是重编译”
理解了文件结构,下一个问题自然是:这套判定在 import 流程的哪个环节发生?搞不清这个,排查问题时只能靠猜。我把链路捋一遍,重点放在几个容易出错的判断点上。
2.1 从 import 到落盘的完整调用链
import foo触发后,先是sys.meta_path上的 finder 依次尝试。标准路径下最终落到FileFinder,它按loader_details里注册的后缀顺序去目录里找:先看foo.py,再看foo.pyc(sourceless),还可能有扩展模块后缀。
找到foo.py之后用的是SourceFileLoader,它的get_code方法才是关键。简化后的流程是这样的:
- 计算缓存路径。3.2 之后是
__pycache__/foo.cpython-311.pyc,其中cpython-311来自sys.implementation.cache_tag;如果开了优化级别,会变成foo.cpython-311.opt-1.pyc。 - 尝试读这个 pyc。读不到就直接编译源码。
- 读到了就调
_classify_pyc,校验 magic 和 flags 合法性。 - 通过之后按模式调
_validate_timestamp_pyc或_validate_hash_pyc。 - 校验通过,
marshal.loads反序列化拿到 code object,直接返回。 - 任何一步失败,退回到编译源码,编译完再尝试把新 pyc 写回缓存目录。
这里有个细节值得记住:第 6 步的写入失败是被静默吞掉的。只读文件系统、目录权限不足、磁盘满,都不会让 import 报错,程序照常跑,只是每次启动都重新编译一遍。这个“静默降级”在后面讲容器部署时会变成性能问题的隐藏来源。
2.2 六类会让缓存失效的条件
把上面流程拆细,能触发重新编译的条件其实就这些:
- pyc 文件不存在。最常见,首次运行、清了
__pycache__、换了解释器版本都会命中。 - magic number 不匹配。换了 Python 版本,或者被别的版本编译过。
- flags 字段不合法。文件被截断、被文本编辑器改过、传输过程损坏。
- 时间戳型下 mtime 不一致。源文件被
touch、被重新 checkout、被cp覆盖。 - 时间戳型下文件大小不一致。哪怕 mtime 相同,大小变了也会失效。
- 哈希型下内容哈希不一致。
反过来,__pycache__目录不存在本身不算失效条件——加载器会先尝试读,读不到就当没有,编译后再尝试mkdir创建。所以你在一个干净目录里跑 Python,第一次 import 之后__pycache__就自动出现了。
2.3 那些看起来该失效、实际上不会的场景
这里才是真正常踩的地方。时间戳模式的 mtime 精度是秒,因为头部里存的是 uint32 的整秒。如果某个构建脚本在同一个自然秒内完成“写源文件”和“生成 pyc”两个动作,随后又在这个秒内再次修改源文件,而修改前后文件大小恰好一样(比如把'A'改成'B',或者把True改成Fals…… 好吧后者大小变了,但把0改成1就很典型),那么加载器会认为 pyc 完全有效,继续加载旧字节码。
这不是理论上的可能。CI 里用脚本批量替换版本号、配置常量、开关标记,规模一大,同一秒内改多个文件的概率并不低。加上有些构建工具会刻意把 mtime 统一设置成某个固定值以保证可复现构建,那就更容易撞上。
另一个隐蔽场景是时间戳被“倒退”。从压缩包解压、tar还原、某些同步工具拉取文件,都可能让源文件的 mtime 变成一个很旧的值。加载器只比较“是否相等”,不比较“谁更新”,所以只要头部记录的 mtime 恰好等于这个旧值,旧 pyc 就会被认为有效——哪怕源文件内容已经完全变了。这类问题在产品环境里表现为“代码改了但没生效”,而且ls -l看时间戳一切正常,非常迷惑。
3. 手工摆弄 pyc:生成、观察与逆向的可行工具
日常开发基本不需要手动碰 pyc,但部署、排障、做分发包的时候,手上得有几把顺手的工具。这一节把我常用的几个整理一下,包括参数差异和能力边界。
3.1 py_compile 与 compileall 的分工
单个文件用py_compile:
import py_compile py_compile.compile( "app/service.py", cfile=None, # None 表示按默认规则放到 __pycache__ dfile="app/service.py", # 写进 code object 的原始路径,影响 traceback 显示 doraise=True, # 编译失败直接抛异常,默认是静默返回 None optimize=-1, # -1 跟随当前解释器,0/1/2 对应 -O 级别 invalidation_mode=py_compile.PycInvalidationMode.CHECKED_HASH, )doraise=True这个参数建议常开。默认行为是编译失败返回None不报错,在构建脚本里很容易漏掉语法错误,等到运行时才炸。
整个目录用compileall,我更常用它,因为参数覆盖了绝大多数构建需求:
python -m compileall -q -f -j 0 \ --invalidation-mode checked-hash \ -d /app \ src/-q压掉逐文件输出,-f强制重编译(不加的话它会跳过已经新鲜的 pyc,构建时你根本不确定哪些被跳过了),-j 0用满所有 CPU 核心并行,-d用来指定写进 code object 的路径前缀,这样 traceback 里显示的是部署后的绝对路径而不是构建机上的路径。
如果确实需要 pyc 和源文件放在同一目录(某些老工具链或特殊分发格式要求这样),加-b。这个选项会把 pyc 写到src/foo.pyc而不是src/__pycache__/foo.cpython-311.pyc。要提醒一句,-b是 legacy 布局,一旦源文件被删掉,这个位置的 pyc 反而能被正常导入,而__pycache__里的不行——原因在 4.3 节说。
3.2 用 dis 看字节码,比反编译更可靠
想确认某个 pyc 里到底是不是你想要的那版逻辑,反编译不一定靠谱(下一小节解释),但dis是一定准的。它读的是 code object,不做任何猜测:
python -m dis src/app/service.py输出是逐行的指令列表,左边是行号,中间是偏移量,右边是指令和参数。找关键字面量特别方便——如果新逻辑里有个'v2'的字符串常量,在LOAD_CONST那一行就能直接看到。要检查一个已有的 pyc 文件,可以这样:
import dis, marshal, importlib.util with open("src/__pycache__/service.cpython-311.pyc", "rb") as f: f.read(16) # 跳过头部 code = marshal.load(f) print(code.co_consts) # 常量池,字符串和数字都在这 dis.dis(code)co_consts里会嵌套子 code object(函数体、类体),需要递归看。写个小函数遍历一下就能把整个模块的常量全打出来,比对版本号、开关标记这类东西非常高效。
3.3 反编译工具的真实能力边界
反编译这块得实话实说:越新的 Python 版本,反编译越难,成功率越低。
大致的能力分布是这样的:3.8 及以前,成熟工具能恢复出接近原始结构的代码;3.9 到 3.10 开始吃力,控制流还原经常出错,需要人工修补;3.11 之后引入了自适应解释器、单独的异常表、更激进的指令融合,公开工具多数只能做到部分还原,遇到复杂控制流基本只能看个大概。
所以如果有人拿“pyc 能防住源码泄露”来劝你,可以先让他现场反编译一个 3.11 的 pyc 看看。能还原出函数名、字符串常量、大致调用关系,这已经足够暴露业务逻辑了。至于完整的、可运行的源码,反编译拿不到,但看懂你在干什么完全够了。这一点在第五节会展开。
4. 部署现场最常撞上的五个 pyc 坑
前面都是铺垫,这一节是真正会让人加班的部分。我按踩到的频率排个序,每个都给出完整的排查链路,而不是直接甩结论——因为排查过程本身比结论更有复用价值。
4.1 第一个坑:同一秒内改文件,pyc 静默陈旧
现象:改了代码,重启服务,行为没变。cat源文件是对的,ls -l时间戳也是刚才的,删了__pycache__重跑就正常了。
排查链路我是这么走的:先确认 pyc 路径,python -c "import foo; print(foo.__cached__)"直接告诉你加载器认为的缓存文件在哪。然后stat一下源文件和 pyc 的 mtime,精确到秒看。如果两个 mtime 是同一个整秒值,那基本就锁定了。再用xxd或者前面那段脚本读一下 pyc 头部第 8 到 12 字节,转成整数,和源文件的int(st_mtime)对比——相等就说明判定通过了。
修复方案有两个层次。临时的:python -m compileall -f强制重编译,或者直接删__pycache__。根本的:切换成CHECKED_HASH模式,让失效判定基于内容而不是时间。这个改动在构建流水线里做一次就行,之后内容变了一定失效,内容没变一定复用,逻辑上干净得多。
注意:
CHECKED_HASH模式在加载时会读一遍源文件算哈希,对超大项目会有一点额外 IO。实测下来,几千个模块的项目启动开销增加在几十毫秒量级,换取的是确定性,我认为非常值。
4.2 第二个坑:只读文件系统上的写入失败被吞掉
容器镜像里把代码目录设成只读(这是安全加固的常规操作),或者把 Python 装在一个只读的挂载点下,import一切正常,但每次启动都在重新编译。项目小的时候感觉不出来,模块上千、依赖一堆的时候,冷启动时间能差出好几秒。
怎么确认?在启动脚本里加一句日志:
import sys print("dont_write_bytecode =", sys.dont_write_bytecode)如果是True,说明要么环境变量PYTHONDONTWRITEBYTECODE=1被设了,要么启动时带了-B。这两个都会让 CPython 跳过所有写 pyc 的动作。很多人为了“保持目录干净”在 Dockerfile 里加了ENV PYTHONDONTWRITEBYTECODE=1,然后在性能敏感的服务上吃闷亏。
另一种情况是dont_write_bytecode为False但目录确实不可写。这时得看加载器抛出的OSError有没有被吞——CPython 在写缓存失败时确实会捕获并忽略,不会中断 import。要确认这一点,可以手动试写:
python -c " import pathlib p = pathlib.Path('src/__pycache__/probe') try: p.write_bytes(b'x'); p.unlink(); print('可写') except OSError as e: print('不可写:', e) "如果真的不可写又想保留编译收益,有两个办法。一是在构建镜像时就把 pyc 编译好并一起打进镜像(构建阶段目录是可写的),运行时只读也没关系,因为 pyc 已经在那了。二是用PYTHONPYCACHEPREFIX(3.8+)把缓存目录指到一个可写的临时路径,比如/tmp/pycache:
export PYTHONPYCACHEPREFIX=/tmp/pycache这样 pyc 不落在源码目录里,只读限制就不影响了,代价是容器重启后缓存丢失,第一次启动还是得编译。
4.3 第三个坑:删了 .py 只留pycache,导入直接失败
这个误解特别普遍:以为__pycache__里的 pyc 可以脱离源码独立使用。实际上不行。
__pycache__里的文件命名是foo.cpython-311.pyc,而FileFinder在查找模块foo时,检查的路径是foo.py、foo.pyc、foo.so这些。它根本不会去__pycache__目录里按 sourceless 的方式找。__pycache__里的 pyc 只作为“源码的加速缓存”存在,是SourceFileLoader内部使用的附属产物。
要让 pyc 独立可导入,必须把它放在源文件旁边,命名成foo.pyc,也就是 legacy 布局:
app/ foo.pyc <- 这个能被 sourceless 导入 __pycache__/ foo.cpython-311.pyc <- 这个不能所以做“只发字节码”的分发包,正确姿势是先用compileall -b生成同目录 pyc,再把.py全部删掉。顺序不能反——先删源码再编译是编不出来的。另外注意-b生成的 pyc 也是带完整头部的,magic 和当前解释器必须匹配,跨版本分发照样会bad magic number。
还有个细节:如果目录里同时存在foo.py和foo.pyc,源码优先,pyc 被当作缓存候选但不会优先于源码加载。所以别指望“放个旧 pyc 在旁边就能覆盖源码”——不成立。
4.4 第四个坑:优化级别和预编译产物对不上号
python -O运行时会去找foo.cpython-311.opt-1.pyc,-OO找opt-2。如果你在构建阶段只跑了默认的compileall(生成opt-0或者说无后缀的那个),运行时带了-O,那构建阶段的所有编译工作就白做了,运行时全部重新编译。
更麻烦的是行为不一致:-O会移除assert语句,把__debug__置为False。如果某段逻辑依赖assert做参数校验(虽然不该这么写,但现实里到处都是),在-O下这段校验就消失了。构建阶段用-O编译、运行时不带-O,或者反过来,都会造成“本地好好的、线上不对”的诡异现象。
处理方式很直接,构建时把所有可能用到的优化级别都生成一遍:
python -m compileall -q -f -o 0 -o 1 -o 2 src/代价是磁盘占用翻两三倍,但换来的是运行时无论带什么参数都不会重新编译。如果确定线上只用默认级别,那就只编-o 0,同时在部署脚本里显式检查启动命令里没有-O。
4.5 第五个坑:CI 缓存把不同版本的 pyc 串到一起
CI 里为了加速,经常缓存依赖目录和构建产物。如果缓存键没把 Python 版本算进去,很容易出现“上一个 job 用 3.10 编的 pyc 被 3.11 的 job 读到”的情况。
这时候的表现取决于场景。有源码的情况下,_classify_pyc发现 magic 不对,静默重编译,看不出问题,只是缓存白做了。但如果你的分发流程里有“只保留 pyc”的步骤,那就直接bad magic number起不来。
排查方法很简单,扫一遍所有 pyc 的 magic:
import importlib.util import pathlib import struct current = importlib.util.MAGIC_NUMBER bad = [] for p in pathlib.Path("src").rglob("*.pyc"): magic = p.read_bytes()[:4] if magic != current: bad.append((str(p), magic.hex())) print(f"不匹配 {len(bad)} 个") for path, m in bad[:20]: print(path, m)缓存键的修复就是老老实实把python --version的输出拼进去。我一般还会在构建脚本开头打个断言:如果发现__pycache__里有 magic 不匹配的文件,直接rm -rf掉再重新编译,比事后排查省事得多。
5. pyc 做分发保护的边界:防得住什么,防不住什么
字节码分发这个话题,每隔一段时间就有人问。我先把结论摆出来:pyc 不是加密,它只是把源码换了一种表示形式,可读性下降但信息量基本保留。把它当成“防止同事误改”的手段是合理的,当成“防止竞争对手抄袭”的手段是不合理的。
5.1 从 pyc 里能捞出多少信息
前面提过dis能直接读常量和指令。具体能拿到什么:所有字符串字面量(包括 SQL 语句、API 路径、错误提示、内置的密钥——如果硬编码了的话)、所有函数名和类名、属性名、模块名、常量数值、分支结构的大致形状。也就是说,业务逻辑的骨架是完全暴露的。
反编译工具在 3.8 及以前能恢复出接近原始的代码结构,人读起来毫无障碍;3.11 之后完整源码还原困难,但配合dis输出人工阅读,理解逻辑并不需要太长时间。所以任何“核心算法靠 pyc 保护”的方案,本质上是在赌对方不愿意花时间。
还有个更直接的风险点:如果代码里硬编码了凭据、内部地址、密钥,不论 pyc 还是源码,都是明文可提取的。这类东西该放配置中心或者环境变量,跟分发形式无关。
5.2 源码剥离的实操流程与取舍
如果目标只是“不让源码躺在服务器上被人随手cat”,pyc 分发是够用的,流程也不复杂:
# 1) 干净环境重新编译,确保不复用旧缓存 find src -name __pycache__ -type d -exec rm -rf {} + python -m compileall -q -f -b src/ # 2) 删掉源码,只留同目录的 pyc find src -name "*.py" -delete # 3) 确认没有漏网的源码 find src -name "*.py" | wc -l # 应该是 0三步之后src里只剩.pyc。这个包拿到同版本的 Python 环境里就能直接 import。
要提醒两个坑。第一,-b生成的 pyc 头部仍然是时间戳模式,源文件已经不存在了,加载器在校验阶段会怎么处理?答案是:sourceless 加载走的是SourcelessFileLoader,它只校验 magic 和 flags,不做时间戳或哈希校验,因为压根没有源文件可比。所以删源码不影响加载,但这也意味着你失去了任何一致性保障——pyc 和“它对应的源码”之间已经没有关联了,版本管理只能靠你自己。
第二,有些第三方库会检查module.__file__或inspect.getsource,源码删了之后这些调用会失败,抛OSError或者TypeError。上线前最好跑一遍完整的功能测试,别只测主流程。
5.3 真需要更强保护时的几条路
如果确实需要“即使拿到产物也难以理解逻辑”,pyc 这条路走不通,得换思路。常见的几条:
一是编译成机器码,把 Python 代码转成 C 再编译成扩展模块,或者直接编译成独立二进制。这类工具的好处是逻辑变成了机器指令,逆向门槛明显提高;代价是构建复杂度上去了,调试信息丢失,出问题定位困难,动态特性(比如运行时的getattr反射)也可能出状况。
二是把核心逻辑挪到服务端,客户端只保留调用协议。这个方案对架构侵入最大,但保护效果最好,而且能顺便做授权和限流。前提是你的业务形态允许这么做。
三是接受现实,把精力放在“代码质量”和“迭代速度”上而不是“防止被看”。这个结论听起来消极,但很多团队算过账之后发现,被抄走的成本远低于维护一套复杂保护方案的成本。
我个人的取舍是:内部服务不折腾,直接发源码;对外分发的工具包用 pyc 加一层“提高门槛”,但不指望它防住有心人;真正核心的、有商业价值的逻辑,放到服务端。
6. 一套可以固化进流水线的检查动作
工具和原理讲完了,最后落到“怎么让它不再成为问题”。我这几年形成的习惯是:与其在事故发生后排查,不如在构建阶段就把不确定性消掉。
6.1 构建阶段的三条硬规则
第一条,构建前必须清缓存。find . -name __pycache__ -type d -prune -exec rm -rf {} +这行加在构建脚本最前面。理由很简单:你永远不知道上一次构建留下了什么版本的 pyc,清掉是最省事的确定性来源。
第二条,编译时必须带-f。不加的话compileall会跳过“看起来新鲜”的文件,而复用判断本身就可能出错(见 4.1)。多花几秒强制重编译,换来的是“构建产物一定反映当前源码”这个保证。
第三条,编译后立刻校验。加一个构建后置步骤,扫描所有 pyc 的 magic 是否等于构建机 Python 的MAGIC_NUMBER,不匹配就让构建失败。这个检查只需要几行代码,但能拦住跨版本串味和传输损坏两类问题。
# build_check.py import importlib.util import pathlib import sys target = pathlib.Path(sys.argv[1]) magic = importlib.util.MAGIC_NUMBER bad = [str(p) for p in target.rglob("*.pyc") if p.read_bytes()[:4] != magic] if bad: print(f"发现问题 pyc {len(bad)} 个", file=sys.stderr) for p in bad[:10]: print(" " + p, file=sys.stderr) sys.exit(1) print("pyc 校验通过")6.2 运行时的一致性自检
上线之后还有一个检查值得做:确认服务实际加载的 pyc 是不是本次发布的版本。做法很简单,在服务的启动日志里打一行:
import myapp.core print("loaded from:", myapp.core.__cached__)__cached__是模块对象上的属性,指向加载器实际使用的 pyc 路径。配合发布系统记录的构建 ID,能快速判断“线上跑的是不是这次的包”。这个检查在回滚场景下特别有用——很多时候回滚了代码但没清缓存,实际跑的还是新版本的字节码,日志里看半天对不上。
如果想让这层保障更强,可以在构建时把 git commit 短哈希写进一个模块常量,启动时打日志。这样比对的是内容而不是文件路径,不会因为路径相同就误判。
6.3 版本控制与目录约定
.gitignore里加上__pycache__/和*.pyc是基本操作,但我想强调一下原因:把 pyc 提交进仓库不只是“仓库变大”这么简单。不同开发者机器上的 Python 版本不同,pyc 会在 pull 之后互相覆盖,导致一部分人拿到 magic 不匹配的文件(有源码时静默重编译,无源码时直接报错)。更糟的是,有些人本地跑的是-O,提交上来的是opt-1的产物,其他人拿到之后行为可能不一致。
对 Docker 构建,COPY是保留 mtime 的,这一点既是好事也是坑。好事是构建机上编译好的 pyc 在镜像里依然有效;坑是如果构建上下文里的源码 mtime 恰好和 pyc 头部记录一致而内容不同,就会加载到旧字节码。用CHECKED_HASH模式可以彻底规避这个风险,因为校验基于内容。
我现在的默认配置是:项目里统一用--invalidation-mode checked-hash,构建脚本强制重编译加校验,Docker 镜像里预编译好所有需要的优化级别,运行时目录设只读。这套组合下来,pyc 相关的问题基本绝迹了。
7. 最后分享两个小工具片段
平时排查用得最多的两个片段,贴在这以备不时之需。
一个是读 pyc 头部并判断新鲜度:
import struct from pathlib import Path def inspect_pyc(pyc_path: str, src_path: str | None = None): data = Path(pyc_path).read_bytes() magic, flags = data[:4], struct.unpack("<I", data[4:8])[0] print(f"magic={magic.hex()} flags={flags:#06b}") if flags & 0b01: print(f"hash={data[8:16].hex()} (hash 模式)") if src_path: from importlib.util import source_hash print(f"src hash={source_hash(Path(src_path).read_bytes()).hex()}") else: mtime, size = struct.unpack("<II", data[8:16]) print(f"mtime={mtime} size={size} (时间戳模式)") if src_path: st = Path(src_path).stat() print(f"src mtime={int(st.st_mtime)} src size={st.st_size}")跑一下,两边的数字摆在一起,是不是同一秒、大小有没有变,一目了然。
另一个是批量统计项目里 pyc 的分布,看看有没有历史遗留:
find . -name "*.pyc" -printf "%T@ %s %p\n" | sort -n | tail -20按修改时间排序取最近 20 个,能快速发现“某些 pyc 比源码还新”或者“某个目录下全是几个月前的尸体”这类情况。清缓存之前先看一眼,有时候能发现一些意料之外的东西,比如某个第三方库自带的 pyc。
上面这些是我在实际项目里反复用到的部分。pyc 这个机制本身设计得挺克制——规则简单、行为可预测,问题基本都出在“构建流程没管住它”而不是机制本身有缺陷。把编译时机、失效模式、分发形态这三件事定下来,剩下的就是照着执行,不需要每次出事再翻一遍文档。