团队接手了一个遗留的机器学习预测服务,其 CI 流程的混乱程度令人发指。每一次构建都像是对耐心和运气的双重考验。问题主要集中在三个方面:首先,构建镜像依赖一个庞大的 Dockerfile,每次代码的微小改动都会触发漫长的、无缓存的构建阶段,CI/CD 流水线平均耗时超过15分钟。其次,模型文件(通常几百MB)被随意存放在对象存储中,版本管理依赖手动更新配置文件中的 URL,导致代码与模型版本完全脱钩,复现特定时间的预测行为几乎成了一件不可能的任务。最后,测试覆盖严重不足,单元测试无法验证模型与代码的集成逻辑,而集成测试的缺失,意味着我们只能祈祷线上不出问题。
这个工作流的脆弱性在生产环境中是不可接受的。我们的目标是建立一个全新的、现代化的 CI 工作流,它必须满足以下几个硬性指标:
- 高速构建: CI 执行时间必须被压缩到5分钟以内。
- 可复现性: 任何一次 Git 提交都必须能够精确地、自动化地复现其对应的服务状态,包括代码、依赖和模型文件。
- 可靠性: 在镜像推送到仓库之前,必须有自动化测试来验证代码和当前版本模型的集成正确性。
为了达成这个目标,我们放弃了传统的 Dockerfile 模式,并引入了一套新的技术栈:使用 Jib 进行无 Docker 守护进程的镜像构建,利用 DVC (Data Version Control) 来管理模型文件的版本,并通过一套严谨的集成测试来保障交付质量。
技术选型与架构设计
在真实项目中,技术选型从来不是为了追逐潮流,而是为了解决具体问题。
为何选择 Jib 而非 Dockerfile?
在 CI 环境中,docker build 的主要痛点在于其对 Docker 守护进程的依赖以及不够智能的分层缓存机制。
- 无守护进程: CI Runner 通常本身就是容器,在容器里跑 Docker (Docker-in-Docker) 会引入复杂的配置和潜在的安全风险。Jib 是一个纯粹的 Java 工具,直接在 JVM 中构建镜像并将其推送到远端仓库,完全绕过了 Docker daemon。
- 智能分层:
Dockerfile的分层是指令式的。例如,COPY app.jar /app/这一层,只要app.jar的内容有任何变化,这一层以及之后的所有层缓存都会失效。Jib 则能理解项目结构,它会自动将项目拆分为多个更细粒度的层:依赖库(很少变动)、资源文件(可能变动)、业务代码(频繁变动)。这样,即使业务代码变动,那些庞大且稳定的依赖库层依然可以被复用,极大地加速了构建过程。
为何选择 DVC?
机器学习项目中,代码和数据是不可分割的整体。将模型文件(或其他大型数据文件)直接存入 Git 是不现实的。Git LFS 是一个选项,但它在处理超大文件和复杂数据管道时显得力不从心。
DVC 提供了一种 git-like 的体验来管理数据。它不会将数据文件本身存入 Git 仓库,而是存入一个轻量的 .dvc 元数据文件。这个文件包含了数据的哈希值和存储位置等信息。数据文件本身则被推送到一个远端的存储(如 S3, GCS, 或一个简单的 SSH 服务器)。
这种机制的核心优势在于:
- 版本对齐:
.dvc文件随代码一同提交。当你git checkout到某个历史提交时,对应的.dvc文件也被检出。此时只需运行dvc pull,DVC 就会根据元数据文件拉取正确版本的数据。代码和数据实现了完美的版本同步。 - 存储解耦: Git 仓库保持轻量,而庞大的数据文件则由专门的存储后端负责。
将 Jib 和 DVC 结合,我们的 CI 流程蓝图变得清晰起来:
graph TD
A[开发者 git push] --> B{GitLab CI Pipeline 触发};
B --> C[Stage: Setup];
C --> D[1. checkout 代码];
D --> E[2. dvc pull 拉取模型];
E --> F[Stage: Test];
F --> G[执行集成测试];
G -- 测试通过 --> H[Stage: Build];
G -- 测试失败 --> I[Pipeline 失败];
H --> J[./gradlew jib 构建并推送镜像];
J --> K{镜像仓库};
这个流程确保了只有在代码和数据集成测试通过后,才会构建一个包含了正确模型版本的、可部署的镜像。
详细实现步骤
我们以一个基于 Gradle 的 Kotlin Spring Boot 项目为例,来展示整个工作流的搭建过程。
1. 项目基础结构与依赖配置
首先,我们需要在 build.gradle.kts 文件中配置 Jib 插件和测试依赖。
// build.gradle.kts
import com.google.cloud.tools.jib.gradle.JibTask
plugins {
id("org.springframework.boot") version "3.1.5"
id("io.spring.dependency-management") version "1.1.3"
kotlin("jvm") version "1.9.10"
kotlin("plugin.spring") version "1.9.10"
id("com.google.cloud.tools.jib") version "3.4.0" // Jib 插件
}
group = "com.example.ml"
version = "0.0.1-SNAPSHOT"
java {
sourceCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// 假设我们有一个用于模型推理的库
implementation("ai.djl:api:0.24.0")
implementation("ai.djl:basicdataset:0.24.0")
runtimeOnly("ai.djl.pytorch:pytorch-engine:0.24.0")
runtimeOnly("ai.djl.pytorch:pytorch-native-cpu:1.13.1")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "17"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
// Jib 插件的核心配置
jib {
// 从环境变量读取镜像仓库的认证信息,这是 CI/CD 的最佳实践
from {
image = "eclipse-temurin:17-jre-jammy"
}
to {
image = "your-registry/ml-service:${project.version}"
// 在 CI 环境中,我们通常使用 CI 预定义变量来配置认证
// 例如 GitLab CI, 可以通过 CI_REGISTRY_USER, CI_REGISTRY_PASSWORD, CI_REGISTRY
auth {
username = System.getenv("CI_REGISTRY_USER")
password = System.getenv("CI_REGISTRY_PASSWORD")
}
}
container {
// 设置 JVM 启动参数
jvmFlags = listOf("-Xms512m", "-Xmx1024m", "-Djava.security.egd=file:/dev/./urandom")
ports = listOf("8080")
// 设置容器启动时区
environment = mapOf("TZ" to "Asia/Shanghai")
// 设置工作目录
workingDirectory = "/app"
}
// 这是将 DVC 管理的模型文件包含进镜像的关键
extraDirectories {
// 将项目根目录下的 model 文件夹复制到镜像的 /app/model 路径下
paths {
path {
setFrom(file("model"))
into = "/app/model"
}
}
}
}
配置解读:
-
jib.from和jib.to: 定义了基础镜像和目标镜像。在 CI 中,目标镜像的 tag 通常会包含 commit SHA 或者 pipeline ID 以保证唯一性。 -
jib.to.auth: 从环境变量中读取用户名和密码,避免了硬编码凭证的风险。 -
jib.extraDirectories: 这是连接 DVC 和 Jib 的桥梁。我们告诉 Jib,在构建时,需要将项目根目录下的model文件夹及其内容,一同打包到镜像的/app/model目录中。在 CI 流程中,dvc pull命令会确保这个model文件夹里存放的是当前 commit 对应的正确模型文件。
2. DVC 初始化与模型版本管理
现在,我们在项目根目录下初始化 DVC 并添加我们的模型文件。
# 1. 初始化 DVC
dvc init
# 2. 创建存放模型的文件夹
mkdir model
# 3. 将一个模拟的模型文件放入其中 (实际项目中可能是.pkl, .pt, .onnx等)
echo "fake model data" > model/predictor.pkl
# 4. 使用 DVC 跟踪 model 文件夹
# DVC 会计算文件夹内容的哈希值,并创建一个 model.dvc 文件
dvc add model
# 5. 配置远端存储。这里为了演示方便,使用本地目录作为远端
# 在生产环境中,这通常是 S3, GCS, Azure Blob Storage 或 SSH 服务器
mkdir -p /tmp/dvc-remote
dvc remote add -d local-remote /tmp/dvc-remote
# 6. 将数据推送到远端存储
dvc push
# 7. 查看 Git 状态
git status
执行后,你会看到 Git 的状态如下:
- 新增文件:
.dvc/config,.dvc/plots,.dvcignore,.dvc/,model.dvc - 被忽略的文件:
model/(DVC 会自动将其添加到.gitignore)
model.dvc 文件的内容类似这样,它是一个文本文件,记录了数据的元信息:
# model.dvc
outs:
- md5: c2a81f337a6b726410193496660138d6.dir
path: model
这个 .dvc 文件必须提交到 Git。现在,代码和数据(的元数据)就绑定在了一起。
3. 编写依赖于模型的集成测试
集成测试的目的是验证服务在加载了特定模型后,能否正确处理输入并返回预期的输出。这层测试是连接代码逻辑和数据正确性的关键防线。
// src/test/kotlin/com/example/ml/service/PredictionIntegrationTest.kt
package com.example.ml.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpStatus
// 使用随机端口进行测试,避免 CI 环境中的端口冲突
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PredictionIntegrationTest {
@Autowired
private lateinit var restTemplate: TestRestTemplate
// 假设我们的服务有一个 ModelLoader 组件,它会在启动时加载 /app/model/predictor.pkl
// 这个测试需要确保模型文件存在且可被加载
@Test
fun `service should load model and return successful prediction`() {
// 模拟一个 API 请求
val request = mapOf("features" to listOf(1.0, 2.0, 3.0))
val response = restTemplate.postForEntity("/predict", request, Map::class.java)
assertNotNull(response)
assertEquals(HttpStatus.OK, response.statusCode)
assertNotNull(response.body)
// 进一步断言预测结果的结构或值
val body = response.body!!
assertEquals(true, body.containsKey("prediction_id"))
assertEquals(0.987, body["score"]) // 假设我们知道这个模型的预期输出
}
@Test
fun `health check endpoint should be up`() {
val response = restTemplate.getForEntity("/actuator/health", String::class.java)
assertEquals(HttpStatus.OK, response.statusCode)
}
}
测试要点:
- 这个测试启动了一个完整的 Spring Boot 应用上下文,因此它能够模拟真实的服务运行环境。
- 它依赖于模型文件能够被成功加载。如果
dvc pull失败,或者拉取了错误版本的模型,导致服务启动失败或预测逻辑异常,这个测试就会失败,从而阻止有问题的镜像被构建和部署。
4. 组装 GitLab CI 工作流
现在,我们将所有部分串联起来,编写 .gitlab-ci.yml 文件。这个文件是整个自动化流程的指挥中心。
# .gitlab-ci.yml
# 使用一个包含 Java 和 Python (用于 DVC) 的基础镜像
# 在真实项目中,最好是构建一个自定义的 CI 镜像来固化环境
image: openjdk:17-slim
variables:
# Gradle 守护进程在 CI 中通常被禁用,以保证每次构建都是干净的
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
# 配置 DVC 的缓存目录,以便 GitLab CI 的 cache 功能可以缓存它
DVC_CACHE_DIR: "$CI_PROJECT_DIR/.dvc/cache"
# 定义 CI/CD 的缓存策略
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .gradle/wrapper
- .gradle/caches
- $DVC_CACHE_DIR # 缓存 DVC 数据
# 在所有 Job 执行前运行的脚本
before_script:
# 安装 DVC。在自定义 CI 镜像中,这一步可以省略
- apt-get update && apt-get install -y python3 python3-pip
- pip3 install dvc
stages:
- setup
- test
- build
# 准备阶段:拉取 DVC 管理的模型文件
setup-data:
stage: setup
script:
- echo "Setting up DVC remote..."
# 从 GitLab CI/CD 变量中配置 DVC 远端认证信息
# dvc remote modify local-remote ...
- echo "Pulling data with DVC..."
# -c $DVC_CACHE_DIR 指定了缓存目录
- dvc pull -f -c $DVC_CACHE_DIR
- echo "Data setup complete. Model files are now in place."
- ls -l model/
artifacts:
# 将拉取到的 model 文件夹作为 artifact 传递给后续阶段
# 这是一个兜底策略,更优的方式是依赖 cache
paths:
- model/
# 测试阶段:运行集成测试
integration-test:
stage: test
needs: ["setup-data"] # 依赖于 setup-data 阶段成功
script:
- echo "Running integration tests..."
# Gradle test 任务会自动找到并运行所有测试
- ./gradlew test
- echo "Integration tests passed."
# 构建阶段:使用 Jib 构建并推送镜像
build-image:
stage: build
needs: ["integration-test"] # 依赖于 integration-test 阶段成功
# 仅在 main 分支上触发构建
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- echo "Building and pushing container image with Jib..."
# DOCKER_CONFIG 变量可以让 Jib 找到认证信息
# 我们使用 GitLab CI 提供的预定义变量登录到 GitLab 的容器镜像仓库
- ./gradlew jib -Djib.to.image=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- echo "Image successfully pushed to $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
CI 配置文件解读:
-
cache: 这是性能优化的核心。我们同时缓存了 Gradle 的依赖 (.gradle/caches) 和 DVC 的数据 (.dvc/cache)。当 CI Runner 再次运行同一分支的任务时,它可以直接从缓存中恢复大部分数据,dvc pull和 Gradle 依赖下载会变得非常快。 -
setup-datastage: 这个阶段的唯一任务就是dvc pull。它确保了在任何测试或构建任务运行之前,模型文件已经准备就绪。 -
integration-teststage: 运行gradlew test。由于setup-data已经完成,测试代码可以加载到正确的模型文件。 -
build-imagestage: 只有在测试通过后才会执行。它运行jib任务,Jib 会将业务代码、依赖以及model/文件夹(由dvc pull准备)一同打包成一个可部署的镜像,并用 commit SHA 作为标签推送到仓库。这里利用了 GitLab CI 提供的CI_REGISTRY_IMAGE和CI_COMMIT_SHORT_SHA等预定义变量,实现了完全的自动化。
局限性与未来展望
这套基于 Jib 和 DVC 的工作流极大地提升了我们团队的开发效率和交付质量。构建时间从超过15分钟稳定地降低到了3-4分钟,并且我们获得了端到端的版本可复现性。但这套方案并非银弹,它依然存在一些需要注意的局限性和可以优化的方向。
首先,DVC 缓存可能会无限制增长。CI Runner 上的缓存空间是有限的,如果模型版本迭代非常频繁,.dvc/cache 目录会变得异常庞大。需要在 CI 流程中定期或有条件地触发 dvc gc (garbage collection) 来清理不再被任何 Git 分支或标签引用的旧数据。
其次,对于非常庞大的模型文件(数GB甚至数十GB),即使有缓存,首次 dvc pull 的时间和网络开销依然是巨大的。在某些场景下,可能需要更复杂的策略,比如预先将模型数据烘焙到 CI Runner 的镜像中,或者使用专门的数据卷挂载,但这会增加基础设施的复杂性。
最后,当前的工作流止步于镜像构建和推送。一个更完整的 MLOps 流程应该延伸到部署阶段。未来的迭代方向是整合 GitOps 工具,如 ArgoCD。ArgoCD 可以监听镜像仓库的变化,当 build-image 阶段成功推送一个新的、带有 commit SHA 标签的镜像后,ArgoCD 可以自动地将这个新镜像版本部署到 Kubernetes 集群中,从而实现从代码提交到服务上线的全自动化闭环。