去年,我们的机器学习团队开发了一个欺诈检测模型,该模型在Jupyter笔记本中运行得非常完美,精确度和召回率都非常好。大家都对此感到非常兴奋——直到我们尝试将其部署到生产环境中。

这个模型依赖于某个特定版本的scikit-learn,而这个版本与生产环境中的Python环境不兼容。特征工程流程需要使用针对OpenBLAS编译的NumPy库,但部署服务器上使用的却是MKL库。还有一个预处理步骤所依赖的系统库仅存在于数据科学家的MacBook上,而在Ubuntu部署环境中并不存在。

经过三周的调试,我们终于让这个模型在生产环境中正常运行了。整整三周的时间,才为一个从技术上已经完成的模型解决这些兼容性问题。

正是这次经历促使我决定将我们的整个机器学习运维流程完全容器化。并不是因为Docker在机器学习领域很流行,而是因为另一种做法(手动调整环境、编写会在操作系统更新后出问题的安装脚本、祈祷训练环境中可行的方案在生产环境中也能正常运行)所耗费的时间,实际上比模型开发本身还要多。

在本教程中,你将学习如何使用多阶段构建来设计训练和部署容器,如何使用MLflow设置实验跟踪功能,如何使用DVC为训练数据添加版本控制信息,如何配置用于训练的GPU直通技术,以及如何将所有这些内容整合到一个包含配置文件的Compose文件中。这些都是我们在三个团队中实际运行容器化机器学习流程一年后积累的经验。

先决条件

  • Docker Engine 24.0及以上版本或Docker Desktop 4.20及以上版本,并且需要Compose v2.22.0及以上版本。

  • 如果需要进行GPU训练,你需要在主机上安装NVIDIA容器工具包以及相应的GPU驱动程序。运行nvidia-smi可以验证你的GPU是否可用,而运行docker compose version则可以检查Compose的版本信息。

  • 假设读者已经熟悉Python语言、基本的Docker概念以及机器学习的开发流程(包括训练、评估和部署等环节)。

目录

MLOps生命周期:容器在其中的角色

如果你曾经构建过机器学习模型,你就知道这个过程包含许多阶段。但如果你具有软件工程背景(或者你主要使用笔记本进行数据科学工作),了解MLOps流程的完整构成以及Docker在各个阶段中的具体作用会非常有帮助。

MLOps流程是由一系列相互依赖的阶段组成的:

  1. 数据采集与验证。原始数据来自数据库、API或文件系统。你需要对这些数据进行清洗和验证,然后将其存储成模型能够使用的格式。

  2. 特征工程。你需要将原始数据转换成模型可以学习的特征。这可能简单到只是对数值进行标准化处理,也可能复杂到需要生成嵌入向量。

  3. 实验跟踪。你需要记录每次训练的配置信息(超参数、数据版本、代码版本)以及训练结果(准确率、损失值、评估指标),这样就可以对比不同的实验结果并复现最优方案。

  4. 模型训练。模型会根据这些特征进行学习。这一阶段通常需要大量的计算资源,因此往往需要使用GPU。

  5. 模型评估。你需要用测试数据来评估训练好的模型的性能,判断它是否适合部署。

  6. 模型打包与部署。你需要将训练好的模型封装成API格式,这样其他系统就可以向它发送数据并获取预测结果。

  7. 监控与管理。你需要在生产环境中持续监控模型的运行情况,以便及时发现数据漂移或性能下降等问题。

每个阶段都有不同的计算需求。模型训练可能需要GPU和数TB的内存;而模型部署则要求低延迟和横向扩展能力;特征工程阶段可能还需要使用Spark或Dask等分布式处理工具。

我们的方法发生了变化:不再将整个流程打包成一个庞大的容器镜像,而是将每个阶段单独封装成独立的容器,并通过明确的接口进行交互。

可以把这种架构理解为应用于机器学习基础设施的微服务。每个容器只负责完成一项特定的任务,并且能够高效地完成这项任务;它们之间通过规范的接口进行通信——模型结果存储在注册系统中,评估指标被记录到MLflow平台上,数据版本信息则保存在对象存储系统中。

这种架构带来了以下优势:

  • 可以在昂贵的GPU上运行模型训练,在成本较低的CPU节点上部署模型服务。

  • 无需重建整个训练环境即可更新特征工程相关的代码。

  • 可以在容器注册系统中为每个阶段单独设置版本信息。

  • 让数据科学家和机器学习工程师专注于模型训练,而平台工程师则负责优化模型部署流程。

如何构建训练容器

大多数团队都是从构建训练容器开始着手工作的,但也是在这个环节里,很多团队会犯第一个错误。

人们很容易产生这样的冲动:想要使用所有可能的库、所有的CUDA版本以及所有的数据处理工具来构建一个庞大的镜像文件。我见过一些训练用镜像文件的体积超过了15GB,构建这些镜像需要20分钟,上传到服务器也需要10分钟;而只要有人添加了新的依赖项,整个构建过程就会崩溃。

其实有一种可行的方法:通过多阶段构建来将构建环境与运行时环境分开,并使用缓存机制来避免在每次构建时都重新下载包文件。

如果你还不熟悉这些概念的话,多阶段构建允许你使用一个Docker镜像来构建软件,而使用另一个体积较小的镜像来运行该软件。你只需要将构建阶段产生的最终结果复制到运行时环境中,而编译器、构建工具等在生产环境中并不需要的组件则可以留在构建阶段。

缓存机制的作用是让Docker在多次构建过程中保留某个目录的内容(比如pip的下载缓存),这样就不会重新下载那些没有发生变化的包文件。

以下就是用于训练任务的Dockerfile示例:


# 语法格式:docker/dockerfile:1.4
FROM nvidia/cuda:12.6.3-runtime-ubuntu22.04 AS base

# 系统依赖项(这些依赖项很少会发生变化)
RUN apt-get update & amp;& amp; apt-get install -y --no-install-recommends \
    python3.11 python3.11-venv python3-pip git curl & amp;& \
    rm -rf /var/lib/apt/lists/*

RUN python3.11 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# 其他依赖项(这些依赖项会偶尔发生变化)
COPY requirements-train.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements-train.txt

# 训练代码(这部分代码会经常发生变化)
COPY src/ /app/src/
COPY configs/ /app/configs/

WORKDIR /app
ENTRYPOINT ["python", "-m", "src.train"]

注意镜像各层的排列顺序。Docker是按照层次结构来构建镜像的,并且会为每一层生成缓存文件。如果某一层没有发生变化,Docker就会直接使用缓存的版本,而不会重新构建它。但问题在于:如果其中某一层发生了变化,Docker就会重新构建这一层以及其后所有的层。

因此,我们必须按照这些依赖项变化的频率来安排它们的顺序:

  1. 系统包放在最顶层(这些包几乎永远不会发生变化)。安装python3.11git确实需要一些时间,但只需要进行一次即可。

  2. Python依赖项放在中间层(当你添加或更新某个库时,这些依赖项就会发生变化)。当requirements-train.txt文件的内容发生变化时,这一层就需要被重新构建。

  3. 你的实际代码放在最底层

    (每次提交代码后,这部分代码都会发生变化)。因此,这一层也是最经常需要被重新构建的。

按照这样的顺序安排,当代码发生变化时,只有最底层的部分需要被重新构建,而整个镜像并不会被重新生成。如果把COPY src/这条指令放在pip install之前,那么每次代码修改都会导致所有Python包都被重新安装,而这正是我在很多机器学习相关的Dockerfile中经常看到的错误。

pip install命令中的--mount=type=cache,target=/root/.cache/pip这条指令告诉Docker在多次构建过程中保留pip的下载缓存。当你更新依赖项列表时,pip会先检查缓存文件,只有那些新添加或发生了变化的包才会被重新下载。对于一个包含数百个机器学习相关依赖项的项目来说(仅仅PyTorch就包含了数十个子包),这种做法每次构建都能节省5到10分钟的时间。

将训练环境的需求与部署环境的需求分开

训练环境需要一些在部署环境中并不需要的库。训练阶段会用到诸如MLflow这样的实验跟踪工具、pandas和polars这类数据处理库、用于调试的可视化工具,以及超参数调优框架。而部署环境则更需要轻量级的推理运行时环境、FastAPI这样的API框架、健康检查接口,以及尽可能低的开销。

最好为这两类需求分别编写配置文件:

# requirements-train.txt
torch==2.5.1
scikit-learn==1.6.1
mlflow==2.19.0
pandas==2.2.3
polars==1.20.0
dvc[s3]==3.59.1
optuna==4.2.0
matplotlib==3.10.0

# requirements-serve.txt
torch==2.5.1
scikit-learn==1.6.1
mlflow==2.19.0
fastapi==0.115.0
uvicorn[standard]==0.34.0
pydantic==2.10.0

实际上,这两类配置文件中重复的库并不多。torchscikit-learn被同时列出,是因为模型在推理阶段确实需要这些库。而训练配置文件中的其他库,大多只是会增加部署的复杂性或增加系统被攻击的风险。

CUDA版本与驱动程序的兼容性

如果忽视这一点,肯定会遇到问题:容器中运行的CUDA版本必须与主机上的GPU驱动程序版本相匹配。一般来说,主机的驱动程序版本应该等于或高于容器中的CUDA版本。例如,在Linux系统上,CUDA 12.6版本需要使用560.28+版本的驱动程序。

在选择基础镜像之前,请务必确认主机上安装的驱动程序版本:

# 在主机上运行以下命令
nvidia-smi
# 查看“Driver Version: 560.35.03”和“CUDA Version: 12.6”
# 注意:nvidia-smi显示的CUDA版本是驱动程序支持的最高版本,并非实际安装的版本

如果主机上的驱动程序版本是535.x系列,就不要使用cuda:12.6基础镜像。应该选择cuda:12.2版本,或者升级驱动程序。版本不匹配会导致诸如“CUDA错误:设备上没有可执行的内核映像”这样的错误,这类错误非常难以排查。

请为你的基础镜像指定具体的标签(而不是使用latest标签),并在README文件中明确说明最低所需的驱动程序版本。当在新硬件上进行部署时,检查驱动程序版本应该成为配置流程中的必要步骤。

如何使用MLflow进行实验跟踪

如果你曾经训练过模型,但后来会想“上周是哪些超参数让模型取得了那么好的效果呢?”,那么你就需要使用实验跟踪工具。如果没有这些工具,机器学习开发过程就会变成一堆杂乱无章的Jupyter笔记本、指标截图和Excel表格,根本无法有效管理。

MLflow是目前最受欢迎的开源实验跟踪工具。它会在每次训练过程中记录三类信息:参数设置(学习率、批量大小、训练周期数)、评估指标(准确率、损失值、F1分数)以及输出结果(训练好的模型文件、图表、评估报告)。所有这些数据都会被存储在数据库中,同时MLflow还提供了Web界面,可以让你直观地对比不同训练实验的结果。

将MLflow作为容器化服务来运行,意味着跟踪服务器是持久化的,并且可供整个团队共享,而不是仅在一个人的笔记本电脑上运行:

services:
  mlflow:
    image: ghcr.io/mlflow/mlflow:v2.19.0
    command: >>
      mlflow server
      --backend-store-uri postgresql://mlflow:secret@db/mlflow
      --default-artifact-root /mlflow/artifacts
      --host 0.0.0.0
    ports:
      - "5000:5000"
    volumes:
      - mlflow-artifacts:/mlflow/artifacts
    depends_on:
      db: { condition: service_healthy }

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: mlflow
      POSTGRES_USER: mlflow
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mlflow"]
      interval: 5s
      timeout: 2s
      retries: 5
      start_period: 10s
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  mlflow-artifacts:
  postgres-data:

让我来解释一下这里发生的事情。

mlflow服务负责运行MLflow跟踪服务器。它将实验元数据(参数、指标等)存储在Postgres数据库中,同时将模型文件、图表等结果保存到Docker容器中的卷里。
depends_on规则中的condition: service_healthy这一条件告诉Compose,在启动MLflow服务之前,必须确保Postgres数据库已经准备好接受连接。如果没有这个机制,MLflow在启动时会因为数据库尚未准备就绪而出现故障。
db服务负责运行Postgres数据库,并通过内置的pg_isready工具来检查数据库是否已准备好接受连接。start_period参数为Postgres提供了10秒的时间来进行初始化,之后才会开始统计失败次数。

你的训练代码可以通过设置一个环境变量来连接到MLflow服务:

import os
import mlflow

# 这个设置告诉MLflow将实验日志记录到哪里
# 在Docker Compose环境中运行时,“mlflow”会指向mlflow容器
os.environ["MLFLOW TRACKING_URI"] = "http://mlflow:5000"

# 示例:记录一次训练过程
with mlflow.start_run(run_name="fraud-detector-v2"):
    # 记录超参数信息
    mlflow.log_param("learning_rate", 0.001)
    mlflow.log_param("batch_size", 64)
    mlflow.log_param("epochs", 50)

    # ... 在这里训练模型 ...

    # 记录指标结果
    mlflow.log_metric("accuracy", 0.94)
    mlflow.log Metric("f1_score", 0.91)
    mlflow.log_metric("precision", 0.93)
    mlflow.log_metric("recall", 0.89)

    # 将训练好的模型保存为成果文件
    mlflow.sklearn.log_model(model, "model")
    # 对于PyTorch模型,可以使用:mlflow.pytorch.log_model(model, "model")

训练完成后,在浏览器中访问http://localhost:5000,你将会看到所有训练任务的列表,其中包含了各项参数和指标数据。点击任意一项任务,你可以查看详细信息、与其他任务进行比较,或者下载相应的模型文件。这样就再也不用纠结“我觉得是第7次实验的结果最好”这样的问题了。

关于YAML文件中密码的设置:对于本地开发来说,这种做法是可以接受的。但在测试环境和生产环境中,应使用Docker secrets机制,或者从持续集成环境获取相应的凭证。切勿将真实的数据库密码提交到代码仓库中。

如何使用DVC对训练数据进行版本控制

只有当人们能够重新生成模型所依赖的数据时,这些模型才能被可靠地复现。而Git本身无法解决这个问题,因为训练数据集的规模往往高达数吉字节甚至数太字节,而Git并不适合处理大型二进制文件。

DVC(数据版本控制工具)正好填补了这一空白。它的使用方式与Git类似,但它是专门用于管理数据的。其工作原理如下: instead of storing the entire 10GB训练数据集在Git中,DVC会生成一个小的文本文件(即`.dvc`文件),这个文件实际上只是一个指向原始数据的指针。真正的训练数据则存储在云存储服务中(如S3、Google Cloud Storage或Azure Blob)。当您检出某个特定的Git提交时,DVC会自动从远程存储位置下载与该提交对应的数据版本。


# 在项目中初始化DVC(只需执行一次)
dvc init

# 将训练数据添加到DVC的跟踪系统中
dvc add data/training_data.parquet
# 这会生成data/training_data.parquet.dvc文件(这个小文件就是指针文件)
# 同时,training_data.parquet会被添加到.gitignore文件中,从而避免被意外提交

# 将原始数据上传到远程存储位置
dvc push

# 将指针文件提交到Git仓库
git add data/training_data.parquet.dvc .gitignore
git commit -m "添加了v1版本的训练数据"

现在,您的Git仓库中只保存了指针文件,而原始数据则存储在S3上。当其他人或容器需要使用这些数据时,他们只需运行`dvc pull`命令,DVC就会自动从远程存储位置下载数据。


# 使用DVC进行版本控制
FROM nvidia/cuda:12.6.3-runtime-ubuntu22.04 AS base

RUN apt-get update && apt-get install -y --no-install-recommends \
    python3.11 python3.11-venv python3-pip git curl && \
    rm -rf /var/lib/apt/lists/*

RUN python3.11 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements-train.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements-train.txt

COPY src/ /app/src/
COPY configs/ /app/configs/

# 复制DVC相关的跟踪文件到目标目录
COPY data/*.dvc /app/data/
COPY .dvc/ /app/.dvc/

WORKDIR /app
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]

# 进入入口脚本,首先下载数据,然后开始训练过程
#!/bin/bash
set -e

echo "正在从远程存储位置下载训练数据..."
dvc pull data/

echo "训练即将开始..."
python -m src.train "$@"

为了让DVC能够从S3获取数据,容器必须拥有AWS的访问凭证。您可以在Compose配置文件中将这些凭证作为环境变量设置,或者直接将它们挂载到容器中。

training:
  build: { context: ., dockerfile: Dockerfile.train }
  environment:
    - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
    - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
    - AWS_DEFAULT_REGION=us-east-1

结合MLflow的实验日志记录功能,就可以形成一条完整的溯源链:该模型是使用这个版本的数据进行训练的(这一过程由DVC跟踪记录),使用了这些参数进行训练(这些参数被记录在MLflow中),最终得到了这些评估指标。

你可以通过查看Git提交记录并运行训练容器来重现任何过去的实验结果。

如何构建服务容器

“服务”指的是将训练好的模型封装成API形式,这样其他系统就可以向该API发送数据并获取预测结果。例如,一个欺诈检测模型可能会提供一个/predict端点,该端点接收交易数据并返回欺诈发生的概率。

服务容器与训练容器的优先级有所不同。训练容器的重点在于灵活性和强大的计算能力,而服务容器的重点则在于速度、体积小以及可靠性:

FROM python:3.11-slim AS serving

WORKDIR /app

# 安装curl工具用于健康检查
RUN apt-get update &;& apt-get install -y --no-install-recommends curl &&& \
    rm -rf /var/lib/apt/lists/*

COPY requirements-serve.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements-serve.txt

COPY src/serving/ /app/src/serving/

HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

EXPOSE 8000
CMD ["uvicorn", "src.serving.app:app", "--host", "0.0.0.0"]

如果你是初次接触这些内容,有几点需要了解:

uvicorn是一个轻量级的Python Web服务器,用于运行FastAPI应用程序。FastAPI是一种用于构建Python API的框架。将这两者结合使用,就可以将你的模型转换成一个能够响应HTTP请求的Web服务。

HEALTHCHECK命令会让Docker定期检查容器是否真的在正常运行,而不仅仅是处于运行状态。每隔30秒,Docker会向/health端点发送curl请求。如果连续三次检测失败,Docker就会将该容器标记为“不健康”。这一点非常重要,因为即使模型服务器正在运行,但可能还没有完成初始化工作(比如模型文件仍在下载中),你肯定不希望将数据发送到一个无法正常响应的服务器上。

start-period设置为60秒对于服务容器来说非常关键。模型的加载过程可能需要一定的时间,尤其是对于大型模型而言(从注册中心下载一个2GB大小的模型确实需要一些时间)。如果没有start-period设置,健康检查就会立即开始失败,并且这些失败次数会被计入重试次数限制中,从而导致编排系统在模型加载完成之前就终止容器的运行。因此,start-period为容器提供了初始化所需的缓冲时间。

请注意,这里我们使用的是python:3.11-slim版本,而不是NVIDIA CUDA镜像。大多数训练好的模型都可以在CPU上运行推理任务。如果您需要使用GPU进行推理(例如运行大型语言模型或进行实时视频处理),则应该使用CUDA基础镜像,但需要注意的是,这样做会使得服务容器变得更大。

如果想要跳过对curl的依赖,可以使用Python内置的urllib模块来进行健康检查:

HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

将模型与容器分离

这是本文中最重要的设计模式之一,也是初学者最容易犯错的地方。

人们往往会忍不住在构建Docker镜像时直接将训练好的模型文件(即包含模型权重数据的.pkl.pt.onnx文件)复制到镜像中。但千万不要这样做。一旦将模型文件嵌入到Docker镜像中,每次模型更新都需要重新构建并推送镜像。对于一个大小为2GB的模型来说,这意味着需要重新构建容器、将2GB大小的文件上传到注册中心,然后再进行部署——而实际上只有模型本身发生了变化,代码并没有任何改变。

正确的做法是让服务容器在启动时从模型注册平台(如MLflow)或云存储服务(如S3)中下载模型文件。这样,容器镜像的大小就可以保持较小且通用性更强。模型的更新只需要通过配置文件的修改来指明新的模型版本即可,而无需重新进行部署操作。

下面是一个采用了这种设计模式的完整服务应用示例,它使用了FastAPI框架。如果您之前使用过Flask,那么FastAPI的功能类似,但运行速度更快,并且还内置了请求验证机制:

import os
from contextlib import asynccontextmanager

import mlflow
from fastapi import FastAPI

# MODEL_URI指向MLflow注册中心中的特定模型版本
# 格式为:"models://",其中stage可以是Staging或Production
MODEL URI = os.environ.get("MODEL_URI", "models:/fraud-detector/production")
model = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    # 服务器启动时执行一次此代码
    global model
    print(f"正在从{MODEL_URI}加载模型...")
    model = mlflow.pyfunc.load_model(MODEL URI)
    print("模型加载成功。")
    yield
    # 服务器关闭时执行此代码
    print("正在关闭模型服务器。")


app = FastAPI(lifespan=lifespan)


@app.get("/health")
async def health():
    """用于供Docker的健康检查机制使用,以确认服务器是否已准备好运行。"""
    if model is None:
        return {"status": "loading"}, 503
    return {"status": "healthy"}


@app.post("/predict")
async def predict(features: dict):
    """接受以JSON格式传递的输入数据,并返回模型预测结果。"""
    import pandas as pd

    # 将输入数据转换为DataFrame格式(大多数sklearn/mlflow模型都要求这样处理)
    df = pd.DataFrame([features])
    prediction = model.predict(df)
    return {"prediction": prediction.tolist()}

当客户向/predict发送包含如下JSON数据的POST请求时:{"amount": 500, "merchant_category": "electronics", "hour": 23},模型会返回相应的预测结果。在模型加载过程中,/health端点会返回503状态码;而一旦模型加载完成,就会返回200状态码——这正是Docker的HEALTHCHECK命令所检测的内容。

要推广新的模型版本,只需更新MODEL_URI环境变量,然后重新启动容器即可。MLflow模型注册系统支持模型阶段的转换(测试阶段、生产阶段、归档阶段),因此你可以在MLflow用户界面中推进模型的升级流程,之后再将服务容器指向新版本。

为了实现模型更新的零停机时间目标,可以开发一个无需重启容器即可切换模型的功能:

@app.post("/admin/reload")
async def reload_model():
    global model
    model = mlflow.pyfunc.load_model(MODEL_URI)
    return {"status": "reloaded"}

如何配置用于训练的GPU直通功能

默认情况下,Docker容器无法访问主机上的GPU硬件。“GPU直通”功能意味着让容器能够使用主机的GPU,这样像PyTorch和TensorFlow这样的库就可以利用这些GPU来加速计算。

在主机上需要满足以下两个条件才能启用这一功能(这里指的是运行Docker的主机,而不是容器内部):

  1. NVIDIA GPU驱动程序已安装且能够正常使用。可以通过nvidia-smi命令进行验证。如果该命令能显示你的GPU信息,说明驱动程序安装成功。

  2. NVIDIA容器工具包也已安装。这个工具包是Docker与GPU驱动程序之间的桥梁。你可以从NVIDIA官方文档中下载并安装它,然后通过docker run --rm --gpus all nvidia/cuda:12.6.3-base-ubuntu22.04 nvidia-smi命令进行测试。如果命令输出中列出了你的GPU信息,说明工具包安装成功。

完成主机配置后,在Docker Compose中配置GPU直通功能的代码如下:

services:
  training:
    build: { context: ., dockerfile: Dockerfile.train }
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    volumes:
      - ./data:/app/data
    environment:
      - MLFLOW TRACKING_URI=http://mlflow:5000

deploy.resources.reservationsdevices这一配置表示:“这个容器需要使用所有可用的NVIDIA GPU。”在容器内部,PyTorch和TensorFlow会自动识别这些GPU并加以使用。你可以通过在训练脚本中添加print(torch.cuda.is_available())这条代码来验证这一功能,如果输出结果为True,说明配置成功。

如果你使用的是Compose v2.30.0或更高版本,也可以使用更简洁的gpus语法进行配置:

services:
  training:
    build: { context: ., dockerfile: Dockerfile.train }
    gpus: all
    volumes:
      - ./data:/app/data
    environment:
      - MLFLOW TRACKING_URI=http://mlflow:5000

在使用PyTorch的DistributedDataParallel等框架进行多GPU训练时,可以通过device_ids来指定使用哪些特定的GPU。当同时运行多个训练任务时,这一点尤为重要:

services:
  training-job-1:
    build: { context: ., dockerfile: Dockerfile.train }
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              device_ids: ["0", "1"]
              capabilities: [gpu]
    environment:
      - CUDA_VISIBLE_devices=0,1

  training-job-2:
    build: { context: ., dockerfile: Dockerfile.train }
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              device_ids: ["2", "3"]
              capabilities: [gpu]
    environment:
      - CUDA_VISIBLE_devices=0,1

需要注意的是,容器内部的CUDAVISIBLE_DEVICES所指定的设备是相对于Docker分配的设备而言的,而不是主机上的GPU索引。即使这两个容器使用的是不同的物理GPU,它们也会将自己对应的GPU视为设备0和设备1。

如何利用Compose配置文件来整合这些组件

如果你还不熟悉Compose配置文件的话:默认情况下,docker compose up会启动docker-compose.yml中定义的所有服务。但有时候你并不需要所有服务都同时运行。例如,你的MLflow服务器和API应该一直处于运行状态,而训练容器则只应在你需要进行模型训练时才被启动(而且训练容器需要GPU,而你的笔记本电脑可能并没有GPU)。

配置文件就可以解决这个问题。当你为某个服务添加profiles: ["train"]时,该服务在默认情况下就不会被docker compose up启动。只有当你通过docker compose --profile train明确指定要启动该配置文件时,它才会被运行。这意味着,虽然只有一个文件定义了你的整个ML基础设施,但你完全可以控制哪些服务会在什么时候运行。

以下是整合了本文中所有内容的完整docker-compose.yml文件:

services:
  # --- 始终处于运行状态的组件 ---
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: mlflow
      POSTGRES_USER: mlflow
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mlflow"]
      interval: 5s
      timeout: 2s
      retries: 5
      start_period: 10s
    volumes:
      - postgres-data:/var/lib/postgresql/data

  mlflow:
    image: ghcr.io/mlflow/mlflow:v2.19.0
    command: >
      mlflow server
      --backend-store-uri postgresql://mlflow:secret@db/mlflow
      --default-artifact-root /mlflow/artifacts
      --host 0.0.0.0
    ports:
      - "5000:5000"
    volumes:
      - mlflow-artifacts:/mlflow/artifacts
    depends_on:
      db: { condition: service_healthy }

  serving:
    build: { context: ., dockerfile: Dockerfile.serve }
    ports:
      - "8000:8000"
    environment:
      - MODEL_URI=models:/fraud-detector/production
      - MLFLOW TRACKING_URI=http://mlflow:5000
    depends_on:
      mlflow: { condition: service_started }

  # --- 根据需求启动的训练组件 ---
  training:
    build: { context: ., dockerfile: Dockerfile.train }
    profiles: ["train"]
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    volumes:
      - ./data:/app/data
      - ./configs:/app/configs
    environment:
      - MLFLOW TRACKING_URI=http://mlflow:5000
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
    depends_on:
      mlflow: { condition: service_started }

volumes:
  postgres-data:
  mlflow-artifacts:

使用该文件进行日常操作的工作流程如下:

# 第一步:启动基础设施(MLflow + Postgres + API服务)
# 使用-d参数可让所有进程在后台运行
docker compose up -d

# 第二步:打开MLflow用户界面查看之前的实验结果
open http://localhost:5000    # macOS系统
# xdg-open http://localhost:5000  # Linux系统

# 第三步:检查API服务是否正常运行
curl http://localhost:8000/health
# 应返回:“{"status":"healthy"}”

# 第四步:运行训练任务(通过DVC获取数据,将日志记录到MLflow系统中)
# 由于使用了--profile参数,因此仅会启动“训练”服务
docker compose --profile train run training

# 第五步:在localhost:5000处的MLflow用户界面中查看训练进度
# 如果你的训练代码包含了日志记录功能,那么你会看到各项指标实时更新

# 第六步:训练完成后,在MLflow用户界面中将模型推送到生产环境
# 点击该模型,进入“注册模型”页面,将阶段设置为“生产”

# 第七步:重新启动API服务容器,以便使用新的模型版本
docker compose restart serving

# 第八步:测试新模型
curl -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '{"amount": 500, "merchant_category": "electronics", "hour": 23}'

采用这种单文件配置方式,新的团队成员只需克隆代码仓库,运行docker compose up -d命令,就能在几分钟内让整个ML基础设施在本地正常运行。这些容器在部署到测试环境或生产环境时,只需要调整一些环境变量即可(例如数据库登录信息、模型URL地址、GPU资源配置等)。

可复现性:这才是关键所在

本文所介绍的所有方法都是为了实现一个目标:确保实验结果的可复现性。也就是说,无论使用哪个Git提交哈希值进行训练,构建出的容器、获取的数据以及最终得到的模型都应该是完全相同的。

以下是一些有助于实现这一目标的实践方法:

固定所有相关配置

请将基础镜像固定在特定的版本号上,而不仅仅是标签上;对于Python包,也要使用pip freeze > requirements.txt命令将其版本明确指定下来。在训练代码中要使用固定的随机种子,并将这些信息记录到MLflow系统中。

记录所有操作细节

每次训练运行时,都应将所使用的库版本(pip freeze)、Git提交哈希值、DVC数据版本、所有超参数以及所有的评估指标记录到MLflow系统中。你可以使用自动化脚本来完成这项工作:

import subprocess
import mlflow

with mlflow.start_run():
    # 自动记录环境配置信息
    pip_freeze = subprocess.check_output(["pip", "freeze")).decode()
    mlflow.log_text(pip_freeze, "pip_freeze.txt")

    git_hash = subprocess.check_output(["git", "rev-parse", "HEAD")).decode().strip()
    mlflow.log_param("git_commit", git_hash)

    # ...继续执行训练代码 ...

为所有内容指定版本号

使用Git来管理代码,用DVC来管理数据,通过MLflow记录实验过程,再用Docker的镜像版本号来区分不同的运行环境。这种组合方式能够形成一条完整、可追溯的链。当有利益相关者询问为什么某个模型会得出特定的预测结果时,你就可以追踪到具体的代码、数据和超参数。对于金融、医疗保健等受监管的行业来说,这种可追溯性是一项强制性的合规要求,而不仅仅是一种便利功能。

这种方法存在的局限性

这种方案对于在单台主机或小型集群上运行的中小型团队来说效果不错,但以下情况下会遇到问题:

大型数据集。不要将规模达数TB的数据集放入容器中。应使用对象存储服务(如S3、GCS),并在训练过程中以流式方式传输数据。虽然DVC可以处理版本控制问题,但数据本身仍应存储在Docker之外。

GPU驱动程序不兼容。容器的CUDA版本必须与主机上的驱动程序相匹配。请在与生产环境完全相同的硬件和驱动程序配置上进行测试,并在README文件中明确指定最低所需的驱动程序版本。

多节点训练。当需要将训练任务分布在多台机器上时,Compose就不再适用了。此时,使用Kubernetes结合Kubeflow或KServe才是实现分布式训练和自动扩展服务的标准方案。

大规模服务部署。单个容器使用uvicorn处理中等规模的流量尚可,但对于高吞吐量的推理任务来说,就需要负载均衡器、多个副本,以及专门的服务器框架(如NVIDIA Triton Inference Server或TensorFlow Serving)。虽然Compose可以通过`docker compose up --scale serving=3`命令创建多个副本,但它无法提供真正的路由管理、基于健康状况的负载均衡功能,也无法实现自动更新机制。

生产环境中的密码管理。在上述示例中,Compose文件使用明文密码进行本地开发,但在生产环境中应使用Docker Secrets、HashiCorp Vault或云服务提供商提供的秘密管理工具。切勿将敏感信息提交到代码仓库中。

总结

通过将机器学习运营流程容器化,原本依赖特定环境的脆弱模型就能被转化为可复现、可部署的应用程序。多阶段构建机制有助于保持镜像文件的体积小巧。MLflow能够帮助您跟踪实验过程并追踪模型的演变历程,而DVC则实现了代码与数据之间的紧密关联。GPU直通技术还能确保训练性能不受影响。一个包含所有配置信息的Compose文件就能将整个工作流程整合起来。

我之前提到过的那个欺诈检测模型?我们最终将其整个开发流程都容器化了。之后开发的另一个模型,从“开发完成”到“投入生产”,只用了两天时间,而不是原来的三周。其中大部分时间都被用来进行评估和审查工作,而非解决环境配置问题。

容器化本身并不会让模型变得更好,但它能消除基础设施带来的阻碍,让您能够专注于核心业务的发展。

尽管存在这些限制,但容器化的机器学习运营流程确实有效解决了机器学习项目中最常见的延迟问题——即开发环境和生产环境之间的不匹配。我们曾经为调试那个欺诈检测模型的部署花费了三周时间,而现在这种情况再也不会发生了。

如果您觉得这篇文章有帮助,可以在我的博客上找到我关于机器学习运营、容器化工作流程以及生产环境中的AI系统的更多文章。

Comments are closed.