1. 实验环境标准化:用 Docker 锁住一切

做可复现实验,第一道坎就是环境。

你想想看,一个实验跑完,过了三个月想复现,结果 Python 升了版本,某个库不兼容了,系统依赖也变了——这实验基本就废了。我早期做研究时就吃过这个亏,审稿人要求复现,我折腾了两周才把环境配回来,别提多狼狈了。

所以这一章,咱们就聊怎么用 Docker 容器化技术,把 Python 版本、系统依赖、第三方库版本全部锁定。说白了,就是把整个实验环境打包成一个“快照”,任何时候、任何机器上都能 100% 还原。

为什么非要用 Docker?

有人可能会问:我用 virtualenv 或者 conda 不也能锁定 Python 包吗?

嗯,这里要注意。虚拟环境只能管 Python 层面的依赖,系统底层的库它管不了。比如某个科学计算库依赖 libssl 的特定版本,换台机器系统不同,可能就装不上。Docker 连操作系统一起打包,这才是真正的“环境快照”。

我个人习惯把 Docker 镜像看作实验的“DNA”——它完整记录了实验运行所需的一切。你把这个镜像给别人,他 pull 下来就能跑,不需要任何额外配置。

核心原则:实验环境 = 操作系统 + 系统依赖 + Python 版本 + 第三方库版本。缺一个都不算“可复现”。

Dockerfile 怎么写才规范?

写 Dockerfile 其实有套路。我见过不少新手把 Dockerfile 写成“安装脚本大杂烩”,层数多、缓存利用率低、镜像体积大。下面是我总结的一个标准模板:

# 1. 选择基础镜像,锁定 Python 版本
FROM python:3.10-slim-bullseye

# 2. 设置工作目录
WORKDIR /workspace

# 3. 安装系统依赖(先装,利用 Docker 缓存)
RUN apt-get update && apt-get install -y \
    build-essential \
    libssl-dev \
    libffi-dev \
    && rm -rf /var/lib/apt/lists/*

# 4. 复制依赖文件(先复制 requirements.txt,利用缓存)
COPY requirements.txt .

# 5. 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt

# 6. 复制实验代码
COPY . .

# 7. 设置默认命令
CMD ["python", "run_experiment.py"]

这个模板有几个关键点:

  • 基础镜像选 slim 版本——体积小,安全漏洞少。我一般用 python:3.10-slim-bullseye,稳定且长期维护。
  • 系统依赖先装——因为系统依赖变动少,放在前面可以利用 Docker 的层缓存,每次改代码不用重装系统包。
  • requirements.txt 单独复制——这也是为了缓存。只改代码不改依赖时,pip install 这层不会重新执行。

小技巧:在 requirements.txt 里,每个包都锁定到精确版本号,比如 numpy==1.24.3,不要用 >= 或 ~=。我曾经因为一个包的小版本升级,导致实验结果偏差了 0.3%,排查了整整一天。

requirements.txt 的锁定策略

说到版本锁定,这里有个常见的坑:很多人直接 pip freeze > requirements.txt,然后就不管了。但 pip freeze 会列出所有间接依赖,换台机器可能因为系统不同,间接依赖的版本对不上。

我建议的做法是:

  1. 手动维护核心依赖列表,只写你直接 import 的包。
  2. 用 pip-compile(来自 pip-tools 包)生成锁定版本的完整列表。
  3. 把生成的 requirements.txt 提交到版本控制。

举个例子:

# requirements.in(手动维护)
numpy
scipy
scikit-learn
torch==2.0.1

# 运行 pip-compile requirements.in 生成 requirements.txt
# 输出如下(简化版):
numpy==1.24.3
scipy==1.10.1
scikit-learn==1.3.0
torch==2.0.1
torchvision==0.15.2
...

注意:不要直接提交 requirements.in 到生产环境。Docker 构建时只认 requirements.txt,确保它包含了所有间接依赖的精确版本。

构建与运行:两条命令搞定

环境配好了,怎么用?其实就两条命令:

# 构建镜像(在 Dockerfile 所在目录执行)
docker build -t my-experiment:v1.0 .

# 运行容器(挂载数据卷,方便调试)
docker run --rm -v $(pwd)/data:/workspace/data my-experiment:v1.0

--rm 参数会在容器退出后自动删除,避免残留容器占空间。-v 挂载数据卷,这样实验数据可以持久化,不会随容器删除而丢失。

我个人习惯在项目根目录放一个 Makefile,把这两条命令封装起来:

build:
    docker build -t my-experiment:v1.0 .

run:
    docker run --rm -v $(pwd)/data:/workspace/data my-experiment:v1.0

这样团队成员只需要 make build 和 make run,不用记 Docker 命令。

本章知识体系

下面这张图总结了环境标准化的核心流程:

实验环境标准化核心流程 输入:实验代码 + 依赖清单 Dockerfile 构建 FROM python:3.10-slim + 系统依赖 + pip install Docker 镜像(环境快照) docker run → 可复现实验 关键:版本锁定 + 层缓存优化 + 数据卷挂载 = 100% 可复现 任何机器上执行 docker run 都能得到完全一致的结果

避坑指南:我踩过的三个坑

这些年用 Docker 做实验环境,我踩过不少坑。挑三个最典型的分享给你:

坑 表现 解决方案
基础镜像版本漂移 今天 build 和三个月后 build 的镜像不一样 使用 python:3.10-slim-bullseye 这种带小版本号的镜像,不要用 python:3 这种浮动标签
pip 缓存污染 本地能跑,CI 上装不上 Dockerfile 里加 --no-cache-dir,避免使用本地 pip 缓存
时区问题 日志时间戳对不上 在 Dockerfile 里设置 ENV TZ=Asia/Shanghai,统一时区

我记得有一次,团队里有人用了 python:latest 作为基础镜像,结果两个月后 Python 3.11 发布,镜像自动升级,所有依赖都炸了。从那以后,我规定所有项目必须用精确到小版本的基础镜像标签。

总结一下

环境标准化这件事,说白了就是“把不确定性降到零”。Docker 给了我们一个工具,但怎么用好它,取决于你对细节的把控。

这一章的核心就三件事:

  • 写规范的 Dockerfile——分层清晰,缓存利用充分
  • 锁定所有依赖版本——从系统库到 Python 包,一个都不能漏
  • 用 Makefile 封装命令——降低团队使用门槛