构建包含 DVC 数据依赖与集成测试的 Jib 高速 CI 工作流


团队接手了一个遗留的机器学习预测服务,其 CI 流程的混乱程度令人发指。每一次构建都像是对耐心和运气的双重考验。问题主要集中在三个方面:首先,构建镜像依赖一个庞大的 Dockerfile,每次代码的微小改动都会触发漫长的、无缓存的构建阶段,CI/CD 流水线平均耗时超过15分钟。其次,模型文件(通常几百MB)被随意存放在对象存储中,版本管理依赖手动更新配置文件中的 URL,导致代码与模型版本完全脱钩,复现特定时间的预测行为几乎成了一件不可能的任务。最后,测试覆盖严重不足,单元测试无法验证模型与代码的集成逻辑,而集成测试的缺失,意味着我们只能祈祷线上不出问题。

这个工作流的脆弱性在生产环境中是不可接受的。我们的目标是建立一个全新的、现代化的 CI 工作流,它必须满足以下几个硬性指标:

  1. 高速构建: CI 执行时间必须被压缩到5分钟以内。
  2. 可复现性: 任何一次 Git 提交都必须能够精确地、自动化地复现其对应的服务状态,包括代码、依赖和模型文件。
  3. 可靠性: 在镜像推送到仓库之前,必须有自动化测试来验证代码和当前版本模型的集成正确性。

为了达成这个目标,我们放弃了传统的 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.fromjib.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-data stage: 这个阶段的唯一任务就是 dvc pull。它确保了在任何测试或构建任务运行之前,模型文件已经准备就绪。
  • integration-test stage: 运行 gradlew test。由于 setup-data 已经完成,测试代码可以加载到正确的模型文件。
  • build-image stage: 只有在测试通过后才会执行。它运行 jib 任务,Jib 会将业务代码、依赖以及 model/ 文件夹(由dvc pull准备)一同打包成一个可部署的镜像,并用 commit SHA 作为标签推送到仓库。这里利用了 GitLab CI 提供的 CI_REGISTRY_IMAGECI_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 集群中,从而实现从代码提交到服务上线的全自动化闭环。


  目录