📢 转载信息
原文链接:https://www.kdnuggets.com/6-docker-tricks-to-simplify-your-data-science-reproducibility
原文作者:Nahla Davies
Image by Editor
# 介绍
可复现性失败通常以枯燥的方式发生:编译时使用了错误的 glibc、基础镜像悄无声息地发生了变化,或者某个笔记本电脑能运行是因为六个月前安装了一个偶然的系统库。
Docker 可以阻止这一切,但前提是你必须将容器视为一个可复现的制品,而不是一个可丢弃的包装器。
以下技巧关注的是真正困扰数据科学团队的痛点:依赖漂移、非确定性构建、中央处理器(CPU)和图形处理器(GPU)不匹配、镜像中的隐藏状态,以及无法重建的“在我机器上可行”的运行命令。
# 1. 在字节级别锁定基础镜像
基础镜像看起来很稳定,直到它们悄悄地不再稳定。标签会移动,上游镜像会因安全补丁而重建,分发点的版本更新也会毫无预警地到来。几周后重新构建相同的 Dockerfile 可能会产生不同的文件系统,即使所有应用程序依赖项都已固定。这种情况足以改变数值行为、破坏编译好的轮子(wheels)或使先前的结果失效。
修复方法简单而彻底:通过摘要锁定基础镜像。摘要会固定确切的镜像字节,而不是一个移动的标签。这使得操作系统(OS)层面的重建具有确定性,而这正是大多数“什么都没变但一切都坏了”的故事的真正起点。
FROM python:slim@sha256:REPLACE_WITH_REAL_DIGEST
在探索阶段,人类可读的标签仍然很有用,但一旦环境经过验证,就应将其解析为摘要并冻结。当结果受到质疑时,你不再需要为一个模糊的时间快照辩护,而是指向一个可以无歧义地重建、检查和重运行的精确根文件系统。
# 2. 使操作系统包具有确定性并将其保留在一个层中
许多机器学习和数据工具的失败都与操作系统级别有关:libgomp、libstdc++、openssl、build-essential、git、curl、区域设置、用于 Matplotlib 的字体,以及数十种其他库。在不同层中不一致地安装它们会造成构建之间难以调试的差异。
在单个 RUN 步骤中明确安装操作系统包,并在同一步骤中清理 apt 元数据。这可以减少漂移,使差异(diffs)清晰可见,并防止镜像携带隐藏的缓存状态。
RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ git \ curl \ ca-certificates \ libgomp1 \ && rm -rf /var/lib/apt/lists/*
单一层还可以改善缓存行为。环境变成了一个单一的、可审计的决策点,而不是一连串增量更改,后者没人愿意去阅读。
# 3. 分离依赖项层,以免代码更改重建整个世界
当迭代变得痛苦时,可复现性就会消亡。如果每次编辑 Notebook 都触发对依赖项的完全重新安装,人们就会停止重建,容器就不再是事实来源了。
将 Dockerfile 结构化,使依赖项层保持稳定,而代码层保持易变。首先只复制依赖项清单,然后安装,最后再复制项目的其余部分。
WORKDIR /app # 1) 先放依赖项清单 COPY pyproject.toml poetry.lock /app/ RUN pip install --no-cache-dir poetry \ && poetry config virtualenvs.create false \ && poetry install --no-interaction --no-ansi # 2) 然后才复制你的代码 COPY . /app
这种模式同时提高了可复现性和开发速度。每个人都重建相同的环境层,而实验可以在不改变环境的情况下进行迭代。你的容器将成为一个一致的平台,而不是一个移动的目标。
# 4. 优先使用锁定文件而非宽松的要求
只固定顶层包的 requirements.txt 仍然会让传递性依赖项自由变动。这通常是“版本相同,结果不同”问题的根源。科学 Python 栈对细微的依赖项变化非常敏感,尤其是在编译的轮子和数值内核方面。
使用捕获完整依赖图的锁定文件:Poetry 锁定、uv 锁定、pip-tools 编译的需求文件,或 Conda 显式导出。从锁定文件安装,而不是从手动编辑的列表中安装。
如果你使用 pip-tools,工作流程很直接:
- 维护 requirements.in
- 使用哈希生成完全固定的 requirements.txt
- 在 Docker 中精确安装该文件
COPY requirements.txt /app/ RUN pip install --no-cache-dir -r requirements.txt
基于哈希的安装使供应链变化可见,并减少了“拉取了不同的轮子”的模糊性。
# 5. 使用 ENTRYPOINT 将执行编码为制品的一部分
如果一个容器需要一个 200 个字符的 docker run 命令才能复现结果,那么它就不是可复现的。Shell 历史记录不是一个构建的制品。
定义清晰的 ENTRYPOINT 和默认的 CMD,以便容器记录其运行方式。这样,你可以在不重新发明整个命令的情况下覆盖参数。
COPY scripts/train.py /app/scripts/train.py ENTRYPOINT ["python", "-u", "/app/scripts/train.py"] CMD ["--config", "/app/configs/default.yaml"]
现在“如何运行”已经嵌入其中了。团队成员可以使用不同的配置或种子重新运行训练,同时仍使用相同的入口路径和默认设置。CI 可以在没有定制胶水代码的情况下执行镜像。六个月后,你可以运行相同的镜像并获得相同的行为,而无需重建部落知识。
# 6. 使硬件和 GPU 假设明确化
硬件差异并非理论上的。CPU 向量化、MKL/OpenBLAS 线程化和 GPU 驱动兼容性都可能改变结果或性能,足以影响训练动态。Docker 并不会消除这些差异。它可能会隐藏它们,直到它们导致令人困惑的分歧。
为了实现 CPU 确定性,请设置线程默认值,使运行结果不随核心数量变化:
ENV OMP_NUM_THREADS=1 \ MKL_NUM_THREADS=1 \ OPENBLAS_NUM_THREADS=1
对于 GPU 工作,请使用与你的框架对齐的 CUDA 基础镜像并清晰记录下来。避免使用模糊的“最新”CUDA 标签。如果你发布了一个 PyTorch GPU 镜像,那么 CUDA 运行时选择就是实验的一部分,而不是实现细节。
此外,在用法文档中明确运行时要求。一个在缺少 GPU 时会默默在 CPU 上运行的可复现镜像可能会浪费数小时并产生无法比较的结果。当使用错误的硬件路径时,应明确报错。
# 总结
Docker 可复现性并非关于“拥有一个容器”。它是关于在每一层上冻结可能发生漂移的环境,然后使执行和状态处理变得无聊地可预测。不可变的基线可以阻止 OS 方面的意外情况。稳定的依赖项层保持迭代速度足够快,使人们愿意重建。将所有这些部分组合起来,可复现性就不再是你对他人做出的承诺,而变成了你可以通过单个镜像标签和单个命令就能证明的东西。
Nahla Davies 是一位软件开发人员和技术作家。在全身心投入技术写作之前,她曾负责一家体验式品牌组织(客户包括三星、时代华纳、奈飞和索尼)的首席程序员等引人入胜的工作。
🚀 想要体验更好更全面的AI调用?
欢迎使用青云聚合API,约为官网价格的十分之一,支持300+全球最新模型,以及全球各种生图生视频模型,无需翻墙高速稳定,文档丰富,小白也可以简单操作。
评论区