2.2 环境编排与可复现依赖:把"在我机器上能跑"变成"在任何人机器上能跑" 读者读完这一节,应该能一句话说出他学到了什么:隔离式沙箱真正的价值不在"能跑",而在"锁死了 CUDA、Python、框架版本后,半年后、在别人机器上依然能跑"——而实现它的工具链是 lock 文件、固定索引、容器内固定版本,三者缺一不可。 我敢说,这一节是整套教程里最该被贴在工位上的一节。因为"环境不一致"吃掉的时间,比写模型代码本身还多。一个新同学入职,照着 README 装环境,跑出来 、 、 ——这些报错 90% 不是代码问题,是环境与代码被分开了。沙箱存在的全部意义,就是把"代码 + 它的环境"焊死在一起。否则你只是把"乱装的环境"搬进了容器,并没有解决复现。
读者读完这一节,应该能一句话说出他学到了什么:隔离式沙箱真正的价值不在"能跑",而在"锁死了 CUDA、Python、框架版本后,半年后、在别人机器上依然能跑"——而实现它的工具链是 lock 文件、固定索引、容器内固定版本,三者缺一不可。
我敢说,这一节是整套教程里最该被贴在工位上的一节。因为"环境不一致"吃掉的时间,比写模型代码本身还多。一个新同学入职,照着 README 装环境,跑出来 CUDA error: no kernel image is available、Torch not compiled with CUDA enabled、segmentation fault——这些报错 90% 不是代码问题,是环境与代码被分开了。沙箱存在的全部意义,就是把"代码 + 它的环境"焊死在一起。否则你只是把"乱装的环境"搬进了容器,并没有解决复现。
可复现的反面叫"浮动版本"。只要有一处版本没锁,复现就失败。大模型沙箱里需要锁死的至少有四层:
任何一层浮动,都可能让"昨天还能跑的训练"今天挂掉。去年一个团队因为 transformers 升了一个小版本,默认 padding 行为变了,批量训练静默地喂错了数据,模型指标掉了两个点,排查了两周。这种"看不见的破坏"比直接报错更可怕,而锁版本是唯一的疫苗。
第一步,在 Dockerfile 里固定 Python 版本,并使用虚拟环境,避免污染系统 Python:
FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04 RUN apt-get update && apt-get install -y --no-install-recommends \ python3.10 python3.10-venv python3-pip && rm -rf /var/lib/apt/lists/* RUN python3.10 -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH"
注意这里显式装 python3.10 而不是 python3。Ubuntu 22.04 默认是 3.10,但 24.04 默认是 3.12——如果你写 python3,镜像一换基础系统,解释器版本就漂了。PATH 指向 venv,后续 pip 都装进隔离环境,宿主机完全不碰。这一步是"容器内固定 Python 版本"的关键,它保证无论宿主机是 3.8 还是 3.12,容器内永远是 3.10。
PyTorch 的安装命令里暗藏陷阱。官方常给 pip install torch 这种不带版本的写法,装到的会是"当前最新",它的 CUDA 构建版本可能和你基础镜像的 CUDA 不匹配,于是出现经典的 Torch not compiled with CUDA enabled。
正确做法是去 PyTorch 官网复制带具体版本与 CUDA 标签的安装命令,并写进 lock 文件。例如锁定 PyTorch 2.1.0 + CUDA 12.1:
# requirements.lock(节选) torch==2.1.0 torchvision==0.16.0 --extra-index-url https://download.pytorch.org/whl/cu121
这里 cu121 表示这个 wheel 是用 CUDA 12.1 编译的,必须和你基础镜像的 CUDA 12.1 对齐。装错 CUDA 标签(比如用 cu118 去配 CUDA 12.1 镜像),运行时就会报 kernel 不匹配,典型报错就是 CUDA error: no kernel image is available for CUDA arch (8.9) 之类。PyTorch 还有 CPU-only 的 wheel,如果误装了那个,就会出现 Torch not compiled with CUDA enabled——所以务必从官网的 CUDA 专区复制命令,而不是随手 pip install torch。
基础镜像的 CUDA 大版本,必须和 PyTorch wheel 的
cuXXX标签一致。这是 GPU 沙箱最常见的翻车点。
requirements.txt 常用于"声明依赖",但要做到可复现,强烈建议额外维护一个精确锁文件,记录每一个包解析后的确切版本,甚至哈希。做法是用 pip freeze 在已验证可用的镜像里导出:
# 在已构建好的沙箱里执行,导出精确版本 pip freeze > requirements.lock
之后 CI 与同事都从 requirements.lock 安装,保证装上的是一模一样的版本组合。注意 requirements.txt 里可以给一个范围(如 transformers>=4.35),方便日常升级;而 requirements.lock 是发布/协作的"真相来源",禁止手写、只从验证过的环境导出。
进阶一点,可以用 pip-compile(来自 pip-tools)生成带哈希的 lock,进一步防止依赖被投毒:
pip-compile --generate-hashes requirements.in
它能把"我需要 transformers"展开成"transformers==4.35.0 + 它依赖的一长串精确版本 + 哈希",任何中间依赖被篡改都会校验失败。对涉及权重与代码的训练环境,这层防护值得有。这里有个工程取舍:lock 文件越严格(带哈希),安全性越高,但每次升级依赖的成本也越高。我的建议是:对外分发的镜像用带哈希 lock,内部日常开发可用普通 lock。
一个隐蔽陷阱:用户在宿主机执行 docker run 时,如果命令里写成 docker run img python train.py,而镜像内 PATH 没设置好,可能误调宿主机的 python。通过 2.2 第二节的 ENV PATH="/opt/venv/bin:$PATH" 可避免——但更稳健的是用 ENTRYPOINT 直接指镜像内解释器:
ENTRYPOINT ["/opt/venv/bin/python", "train.py"]
这样无论宿主机怎么调,跑的永远是容器里的 venv 解释器。原则一句话:容器里跑什么解释器,由镜像决定,不由宿主机决定。
环境搭好后,别只跑通就开心。请在沙箱里加一个自检脚本,作为"复现成功"的定义:
import torch, sys assert torch.cuda.is_available(), "CUDA 不可用,环境未复现" print("GPU:", torch.cuda.get_device_name(0)) print("CUDA:", torch.version.cuda, "Torch:", torch.__version__) print("Python:", sys.version.split()[0])
把这段放进镜像或一个 verify_env.py,每次构建后自动跑。当它在任何人的机器上都打印出相同的 GPU 名、CUDA 版本、Torch 版本,你就真正拿到了"可复现"的凭证。否则,所谓沙箱只是"这次能跑"。我见过团队把它接进 CI 的冒烟测试,每次合并都跑一遍,半年没再出现过"环境不对"的工单。
transformers 新版的 API 行为变化,训练静默出错。框架之上的库同样要锁,因为它们是框架之上真正跑你逻辑的那一层。latest 标签拉基础镜像。 半年后重建,CUDA 大版本悄变,连锁崩。必须锁具体版本号(2.1 已强调)。RUN pip install x 而不进 lock 文件。 这样别人无法看到/复现你装了什么,且层缓存难管理。统一走 lock 文件安装。pip install 装到的一定是 GPU 版。 如第三节所述,错标签会装到 CPU-only wheel,必须显式核对 torch.version.cuda。锁死不等于永远不升级。正确节奏是:在专门的分支上升级 lock(改 requirements.in → 重新 pip-compile → 在沙箱里跑通 verify_env.py 与冒烟测试)→ 验证通过再合并。这样每次升级都是"有记录的、可回滚的",而不是某天 pip install -U 之后的定时炸弹。
前面锁的是 CUDA 运行时(镜像层),但真正在宿主机上跑的是 NVIDIA 驱动。有个细节新手常忽略:驱动版本必须 ≥ 运行时版本对应的驱动门槛。CUDA 12.1 要求驱动版本不低于某个值(公开文档可查,建议结合官方发布说明核实具体数值)。如果宿主机驱动太旧,容器里 torch.cuda.is_available 会返回 False,报类似 CUDA driver version is insufficient。
好消息是 NVIDIA 提供前向兼容机制:较新的运行时可在较旧驱动上通过兼容包工作,但更稳妥的做法是统一团队驱动版本下限,写进沙箱 README。我建议把宿主机驱动 ≥ X 作为接入沙箱的硬性前提,和镜像 CUDA 版本一起锁死,否则复现性在驱动这一层又开了口子。
如果你用 TensorFlow,注意它的 GPU 支持对 CUDA/cuDNN 版本组合极其敏感,官方有一张严格的版本对应表,务必照表选基础镜像与 TF 版本,错一档就可能 Could not load dynamic library libcudart。JAX 则用 jaxlib 的 CUDA 构建变体(如 jax[cuda12]),同样要和基础镜像 CUDA 大版本对齐。
通用原则不变:框架、CUDA、Python 三者版本必须形成一条被官方验证过的组合,不要自己拍脑袋拼。把这条组合写进 requirements.lock 的注释,下次任何人重建都不会走偏。
环境复现解决同样的代码能跑,但科研/工程还想要同样的数据得到同样的结果。这超出纯环境范畴,但沙箱该顺手兜住:
这些是实验可复现的延伸,环境锁死是地基,数据加种子是上一层。沙箱的价值在这里从能跑升级到结果可信。
沙箱是容器内的环境,但开发者本地(如笔记本)也可能想跑同一套代码。最省事的做法是用 devcontainer:本地 IDE 直接以同一个 Docker 镜像打开,环境和 CI、队友完全一致。没有 devcontainer,也可以在本地 venv 里安装同一份 lock,靠它保证装到相同版本。
关键是:本地和容器必须共用同一份 lock 文件,而不是各写各的。一旦出现「容器里一份、本地一份」,复现性又裂开了。把 lock 当成唯一真相来源,无论在哪跑都从它安装。
再严的锁定也可能遇到「两个库要同一个依赖的不同版本」的冲突。这时用 pip check 能快速列出不兼容组合;用 pipdeptree 能画出依赖树,定位是谁引入了冲突版本。我在排查一个 tokenizers 与 transformers 版本打架的问题时,就是靠依赖树发现某个间接依赖被锁成了过旧版本,调整 lock 后解决。
这类问题提醒我们:lock 文件不是写完就完,升级任何一个包后都要跑一遍 pip check 确认整棵依赖树仍然自洽。
这一节是沙箱的"灵魂":分层(2.1)只让镜像高效,而锁定依赖才让它可信。当你能用一份 lock 文件 + 一个基础镜像,在任何机器上重建出行为一致的 GPU 环境,隔离式沙箱才真正从"个人技巧"变成"团队基础设施"。
但还有一个现实问题没解决:模型权重几个 GB、数据集几十 GB、训练日志要持久化——这些都不能塞进镜像层(2.1 已警告过)。下一节 2.3 讲挂载、端口与数据卷,决定这些数据"活"在哪、容器一关还在不在。
讲一个我亲历的事故。某次上线前夜,一位同事为了修一个小 bug,顺手在容器里 pip install -U transformers,本地验证通过就发布了。结果线上训练静默地改变了 tokenizer 的默认截断行为,三天后复盘才发现验证指标虚高。根因就是"依赖没锁,且生产环境用了和锁定环境不同的包"。
如果当时严格遵守本章:所有安装只能从 requirements.lock 来,且发布镜像必须由 CI 用同一份 lock 构建,这位同事的临时升级根本进不了生产。事故教会我一条铁律:生产环境禁止手动 pip install,所有变更必须走 lock + 重建 + 验证的闭环。锁定依赖的价值,往往在出事时才被真正理解。
对照下面八条,任一条不满足,复现就还有漏洞:
八条全过,你的环境才算真正可复现。复现性的敌人从来不是"不会配",而是"配了但没锁死"。