构建从 MLflow 到 Android 的自动化模型交付管道并集成 ESLint 规范化工具链


最初的模型交付流程混乱不堪。机器学习团队训练完一个新模型,导出一个 .tflite 文件,然后通过即时通讯工具扔给Android团队。版本控制依赖于文件名,例如 model_v3_final_final.tflite,回滚则是一场灾难。更糟糕的是,当线上出现问题时,我们无法准确追溯到某个App版本具体打包的是哪个模型的哪个训练批次。这种纯手动的交接方式在项目初期还能勉强维持,但随着模型迭代速度加快,它成了整个开发流程中最脆弱、最容易出错的环节。

我们必须建立一个自动化的、可追溯的、可靠的模型交付管道。目标很明确:

  1. 集中式模型注册与版本管理:所有模型必须有一个单一可信源。
  2. 按环境隔离模型:能够清晰区分 StagingProduction 环境的模型。
  3. 构建时自动拉取:Android应用在构建时,应自动获取指定环境的最新、最稳定的模型版本。
  4. 工具链质量保障:用于实现自动化的脚本或工具,其本身的代码质量必须得到保证。

技术选型上,我们很快确定了核心组件。MLflow 因其开源、轻量和完善的模型注册(Model Registry)功能,成为模型管理的不二之选。Android端自然是使用 Gradle 来编排构建流程。而连接这两者的“胶水层”——自动化脚本,我们决定采用团队技术栈中更为熟悉的 Node.js。在真实项目中,CI/CD的辅助脚本常常因为“能跑就行”而被忽略了代码质量,最终演变成难以维护的定时炸弹。因此,一个非协商项是:所有Node.js工具链代码必须通过 ESLint 的严格检查。

第一步:搭建生产级的 MLflow 服务

一个常见的错误是直接使用 MLflow 默认的、基于本地文件系统的后端。这在本地实验时很方便,但在团队协作和CI/CD环境中是不可行的。我们需要一个集中式的服务。我们使用 Docker Compose 搭建一个更健壮的 MLflow 实例,它包含三个关键部分:

  • MLflow Tracking Server: 核心服务。
  • PostgreSQL: 作为后端存储,用于保存实验元数据、模型版本等信息,比默认的SQLite更稳定。
  • MinIO (S3兼容对象存储): 作为模型文件、数据集等大文件的制品库(Artifact Store)。
# docker-compose.yml
version: "3.8"

services:
  postgres:
    image: postgres:13
    container_name: mlflow_postgres
    environment:
      - POSTGRES_USER=mlflow
      - POSTGRES_PASSWORD=mlflow
      - POSTGRES_DB=mlflowdb
    ports:
      - "5432:5432"
    volumes:
      - mlflow_postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mlflow"]
      interval: 5s
      timeout: 5s
      retries: 5

  minio:
    image: minio/minio:RELEASE.2022-03-17T06-34-49Z
    container_name: mlflow_minio
    command: server /data --console-address ":9001"
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      - MINIO_ROOT_USER=minioadmin
      - MINIO_ROOT_PASSWORD=minioadmin
    volumes:
      - mlflow_minio_data:/data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 5s
      timeout: 2s
      retries: 5

  mlflow:
    image: python:3.9-slim
    container_name: mlflow_server
    command: >
      bash -c "pip install mlflow boto3 psycopg2-binary &&
      mlflow server
      --backend-store-uri postgresql://mlflow:mlflow@postgres:5432/mlflowdb
      --default-artifact-root s3://mlflow/
      --host 0.0.0.0"
    ports:
      - "5000:5000"
    environment:
      - AWS_ACCESS_KEY_ID=minioadmin
      - AWS_SECRET_ACCESS_KEY=minioadmin
      - MLFLOW_S3_ENDPOINT_URL=http://minio:9000
    depends_on:
      postgres:
        condition: service_healthy
      minio:
        condition: service_healthy

volumes:
  mlflow_postgres_data:
  mlflow_minio_data:

启动后,我们还需要手动在MinIO(通过 http://localhost:9001 访问)中创建一个名为 mlflow 的 bucket。至此,我们有了一个可靠的MLflow服务。

接下来,模拟一次模型训练与注册。这个Python脚本的核心在于,它不仅记录了实验,更重要的是将训练好的 .tflite 文件作为制品(artifact)上传,并将其注册到Model Registry中。

# train_and_register.py
import mlflow
import tensorflow as tf
import numpy as np
import os

# 配置MLflow客户端
os.environ['MLFLOW_TRACKING_URI'] = 'http://localhost:5000'
os.environ['MLFLOW_S3_ENDPOINT_URL'] = 'http://localhost:9000'
os.environ['AWS_ACCESS_KEY_ID'] = 'minioadmin'
os.environ['AWS_SECRET_ACCESS_KEY'] = 'minioadmin'

MODEL_NAME = "android-image-classifier"
TFLITE_MODEL_PATH = "model.tflite"

def create_and_convert_model():
    # 创建一个简单的Keras模型用于演示
    model = tf.keras.models.Sequential([
        tf.keras.layers.InputLayer(input_shape=(28, 28)),
        tf.keras.layers.Flatten(),
        tf.keras.layers.Dense(10, activation=tf.nn.softmax)
    ])
    model.compile(optimizer='adam',
                  loss='sparse_categorical_crossentropy',
                  metrics=['accuracy'])
    
    # 转换为 TFLite 格式
    converter = tf.lite.TFLiteConverter.from_keras_model(model)
    tflite_model = converter.convert()
    
    with open(TFLITE_MODEL_PATH, 'wb') as f:
        f.write(tflite_model)
    print(f"Model converted and saved to {TFLITE_MODEL_PATH}")

def main():
    create_and_convert_model()
    
    mlflow.set_experiment("Android Models")

    with mlflow.start_run(run_name="initial_training_run") as run:
        # 记录参数
        mlflow.log_param("epochs", 5)
        mlflow.log_param("optimizer", "adam")
        
        # 记录指标
        mlflow.log_metric("accuracy", 0.95)
        
        # 将TFLite模型作为制品记录
        mlflow.log_artifact(TFLITE_MODEL_PATH, artifact_path="model")
        
        run_id = run.info.run_id
        print(f"Run ID: {run_id}")

        # 将本次运行的模型注册到Model Registry
        model_uri = f"runs:/{run_id}/model"
        mlflow.register_model(model_uri, MODEL_NAME)
        print(f"Model '{MODEL_NAME}' registered.")

if __name__ == "__main__":
    main()

运行此脚本后,我们可以在MLflow的UI(http://localhost:5000)中看到名为 android-image-classifier 的模型。我们可以手动将其版本1提升(promote)到 StagingProduction 阶段。这是后续自动化脚本拉取模型的依据。

第二步:构建高质量的 Node.js 交付脚本

这是连接MLflow和Android构建的关键。这个脚本需要足够健壮,能够处理网络错误、模型不存在等异常情况。首先,是项目的初始化和ESLint配置。

mkdir model-delivery-tool
cd model-delivery-tool
npm init -y
npm install axios yargs @mlflow/[email protected] --save
npm install eslint eslint-plugin-promise eslint-plugin-import --save-dev

ESLint的配置是保障脚本质量的第一道防线。在CI/CD环境中运行的脚本,我们特别关注几个点:必须处理Promise的拒绝状态,避免使用同步API,以及强制代码风格一致性。

// .eslintrc.js
module.exports = {
  env: {
    es2021: true,
    node: true,
  },
  extends: [
    'eslint:recommended',
  ],
  parserOptions: {
    ecmaVersion: 12,
    sourceType: 'module',
  },
  plugins: ['promise', 'import'],
  rules: {
    'no-console': 'off', // CI脚本中需要console输出日志
    'semi': ['error', 'always'],
    'quotes': ['error', 'single'],
    'promise/catch-or-return': 'error',
    'promise/always-return': 'error',
    'no-async-promise-executor': 'error',
    'no-await-in-loop': 'warn',
    'import/no-unresolved': ['error', { commonjs: true }],
  },
};

接下来是核心的交付脚本 fetch-model.js。这个脚本通过MLflow的JavaScript客户端,获取指定模型在特定阶段(如Production)的最新版本信息,然后解析出其在S3存储中的路径,最后下载到指定位置。

// fetch-model.js
const { MlflowServiceClient } = require('@mlflow/mlflow/build/js/mlflow_service');
const axios = require('axios');
const path = require('path');
const fs = require('fs/promises');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

// 从环境变量读取配置,这是在CI环境中配置的最佳实践
const MLFLOW_TRACKING_URI = process.env.MLFLOW_TRACKING_URI;
const MLFLOW_S3_ENDPOINT_URL = process.env.MLFLOW_S3_ENDPOINT_URL; // MinIO 的外部访问地址
const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY;
const S3_SECRET_KEY = process.env.S3_SECRET_KEY;

/**
 * 日志记录器,方便在CI日志中区分脚本输出
 * @param {string} message - 日志信息
 */
const log = (message) => console.log(`[ModelDelivery] ${message}`);

/**
 * 主执行函数
 * @param {string} modelName - MLflow中注册的模型名称
 * @param {string} modelStage - 模型的阶段 (e.g., 'Production', 'Staging')
 * @param {string} outputDir - 模型文件输出目录
 */
async function main(modelName, modelStage, outputDir) {
  if (!MLFLOW_TRACKING_URI || !MLFLOW_S3_ENDPOINT_URL) {
    throw new Error('MLFLOW_TRACKING_URI and MLFLOW_S3_ENDPOINT_URL must be set in environment variables.');
  }

  log(`Connecting to MLflow at ${MLFLOW_TRACKING_URI}...`);
  const client = new MlflowServiceClient({
    serviceUrl: MLFLOW_TRACKING_URI.replace(/^http:\/\//, ''), // JS client 需要移除 http://
  });

  try {
    log(`Fetching latest version of model '${modelName}' in stage '${modelStage}'...`);
    
    // 1. 获取模型最新版本
    const response = await client.getLatestVersions({
      name: modelName,
      stages: [modelStage],
    });

    if (!response.model_versions || response.model_versions.length === 0) {
      throw new Error(`No model version found for '${modelName}' in stage '${modelStage}'.`);
    }

    const latestVersion = response.model_versions[0];
    const { version, run_id, source } = latestVersion;
    log(`Found version: ${version}, from Run ID: ${run_id}`);
    log(`Artifact source URI: ${source}`);

    // source 的格式是 s3://<bucket>/<run_id>/artifacts/model
    // 我们需要下载的是 model.tflite, 路径是 s3://<bucket>/<run_id>/artifacts/model/model.tflite
    // 这里硬编码了模型文件名,更好的做法是在MLflow中用tag标记主文件名
    const modelFileName = 'model.tflite';
    const artifactPath = new URL(source).pathname;
    const downloadUrl = `${MLFLOW_S3_ENDPOINT_URL}${artifactPath}/${modelFileName}`;

    log(`Downloading model from ${downloadUrl}`);

    // 2. 下载模型文件
    // 在真实项目中,这里应该使用 AWS S3 SDK 并传入认证信息
    // 为简化示例,假设MinIO的bucket是公开可读的,或者使用预签名URL
    // 此处我们直接用axios,并假设没有复杂的认证
    const modelResponse = await axios({
      method: 'get',
      url: downloadUrl,
      responseType: 'arraybuffer',
    });

    // 3. 写入文件到Android项目目录
    const outputPath = path.join(outputDir, modelFileName);
    await fs.mkdir(outputDir, { recursive: true });
    await fs.writeFile(outputPath, modelResponse.data);
    log(`Model successfully saved to ${outputPath}`);
    
    // 4. (可选) 写入版本信息文件,用于App内展示或调试
    const versionInfo = {
        modelName,
        modelVersion: version,
        modelStage,
        mlflowRunId: run_id,
        downloadTimestamp: new Date().toISOString(),
    };
    const versionInfoPath = path.join(outputDir, 'model_version.json');
    await fs.writeFile(versionInfoPath, JSON.stringify(versionInfo, null, 2));
    log(`Model version info saved to ${versionInfoPath}`);

  } catch (error) {
    console.error('[ModelDelivery] FATAL: Failed to fetch model.');
    if (error.response) {
      console.error(`Status: ${error.response.status}`);
      console.error(`Data: ${error.response.data}`);
    } else {
      console.error(error.message);
    }
    // 抛出错误,让CI/CD过程失败
    process.exit(1);
  }
}

// 使用 yargs 解析命令行参数
const argv = yargs(hideBin(process.argv))
  .option('name', {
    alias: 'n',
    type: 'string',
    description: 'Name of the model in MLflow Model Registry',
    demandOption: true,
  })
  .option('stage', {
    alias: 's',
    type: 'string',
    description: 'Stage of the model to fetch',
    default: 'Production',
  })
  .option('output', {
    alias: 'o',
    type: 'string',
    description: 'Output directory for the model file',
    demandOption: true,
  })
  .help()
  .argv;

main(argv.name, argv.stage, argv.output).catch(() => process.exit(1));

这个脚本包含了参数解析、错误处理和清晰的日志输出,完全符合一个生产级工具的要求。我们可以在 package.json 中添加一个脚本来运行它,并先进行lint检查。

// package.json (部分)
"scripts": {
  "lint": "eslint ./**/*.js",
  "fetch-model": "node fetch-model.js"
}

第三步:集成到 Android Gradle 构建流程

目标是让Android项目在构建时,自动执行上述Node.js脚本。这可以通过在 build.gradle.kts (或 build.gradle) 中定义一个自定义任务来实现。

// app/build.gradle.kts

// ... (android block)

// 自定义任务,用于下载ML模型
tasks.register("downloadMlModel", Exec::class.java) {
    group = "MLOps"
    description = "Downloads the latest ML model from MLflow for a given stage."

    // 通过项目属性-P来传递模型阶段,例如 ./gradlew assembleRelease -PmodelStage=Production
    val modelStage = project.properties["modelStage"]?.toString() ?: "Staging"
    
    // Android项目根目录
    val projectRoot = rootDir
    // Node.js工具链的路径
    val toolDir = File(projectRoot, "../model-delivery-tool") 
    // 模型输出到Android module的assets目录
    val outputDir = File(projectDir, "src/main/assets/ml_models")

    // 设置工作目录
    workingDir = toolDir

    // 定义要执行的命令
    commandLine(
        "node",
        "fetch-model.js",
        "--name", "android-image-classifier",
        "--stage", modelStage,
        "--output", outputDir.absolutePath
    )

    // 配置环境变量,传递给Node.js脚本
    // 在真实的CI环境中,这些值应该来自CI/CD的Secrets
    environment("MLFLOW_TRACKING_URI", "http://192.168.1.100:5000") // 替换为你的MLflow服务器IP
    environment("MLFLOW_S3_ENDPOINT_URL", "http://192.168.1.100:9000") // 替换为你的MinIO IP
    // environment("S3_ACCESS_KEY", "minioadmin") // 如果需要
    // environment("S3_SECRET_KEY", "minioadmin") // 如果需要
}

// 让 preBuild 任务依赖我们的下载任务
// 这样在任何构建开始前,都会先执行模型下载
tasks.named("preBuild") {
    dependsOn("downloadMlModel")
}

这里的关键点:

  1. Exec 类型任务: 它允许执行任意外部命令,非常适合调用我们的Node.js脚本。
  2. 项目属性 (-PmodelStage): 允许我们在命令行动态指定要拉取哪个环境的模型,这对于构建不同环境的包(如测试包用Staging模型,正式包用Production模型)至关重要。
  3. 任务依赖: 通过 dependsOn("downloadMlModel"),我们确保了在编译Java/Kotlin代码或打包资源之前,模型文件就已经被正确地放置到了assets目录中。
  4. 环境变量: 这是向子进程传递配置的安全方式,避免了将敏感信息硬编码在构建脚本里。

第四步:整合到 CI/CD 流水线

最后一步是将所有环节串联起来,形成一个完整的自动化流程。下面是一个使用GitHub Actions的示例工作流程,它展示了整个流程。

graph TD
    A[Git Push on main] --> B{Checkout Code};
    B --> C[Setup Node.js & Java];
    C --> D[Install Tooling Dependencies];
    D --> E[Lint Tooling Script];
    E --> F[Build Release APK];
    F --> G[Upload APK Artifact];
    
    subgraph "Build Release APK"
        direction LR
        F1(Run ./gradlew assembleRelease) --> F2(Gradle triggers 'preBuild');
        F2 --> F3('downloadMlModel' task runs);
        F3 --> F4(Node script fetches model);
        F4 --> F5(Model placed in assets);
        F5 --> F6(App compilation continues);
    end

对应的 workflow.yml 文件片段如下:

# .github/workflows/android-ci.yml
name: Android CI with Model Delivery

on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout repository
      uses: actions/checkout@v3
      with:
        # 我们需要同时checkout两个项目
        repository: 'my-org/android-app'
        path: 'android-app'
    
    - name: Checkout model delivery tool
      uses: actions/checkout@v3
      with:
        repository: 'my-org/model-delivery-tool'
        path: 'model-delivery-tool'

    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'

    - name: Set up Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '16'
        cache: 'npm'
        cache-dependency-path: 'model-delivery-tool/package-lock.json'

    - name: Install Node.js dependencies for tooling
      run: |
        cd model-delivery-tool
        npm ci

    - name: Lint model delivery script
      run: |
        cd model-delivery-tool
        npm run lint

    - name: Build Release APK with Production Model
      run: |
        cd android-app
        chmod +x ./gradlew
        # 从GitHub Secrets中获取MLflow服务器地址
        ./gradlew assembleRelease -PmodelStage=Production
      env:
        MLFLOW_TRACKING_URI: ${{ secrets.MLFLOW_TRACKING_URI }}
        MLFLOW_S3_ENDPOINT_URL: ${{ secrets.MLFLOW_S3_ENDPOINT_URL }}

    - name: Upload Release APK
      uses: actions/upload-artifact@v3
      with:
        name: release-apk
        path: android-app/app/build/outputs/apk/release/app-release.apk

这个CI流程实现了我们的全部目标:

  • 每次向 main 分支推送代码,都会触发构建。
  • 首先对交付脚本进行 lint 检查,不通过则直接失败,保证了工具链的质量。
  • 执行Gradle构建命令,并传入 Production 阶段。
  • downloadMlModel 任务被触发,Node.js脚本从环境变量获取服务器地址,拉取生产模型。
  • 构建成功后,将包含最新生产模型的APK作为制品上传。

当前方案的局限性与未来展望

这套流程解决了最初的手动交付混乱问题,但它并非完美。

首先,构建过程强依赖MLflow服务的可用性。如果MLflow服务或网络出现故障,Android的CI/CD会构建失败。未来的一个优化方向是在CI/CD和MLflow之间引入一个缓存层或代理,比如将下载的模型缓存到CI平台的缓存中,只有当MLflow中有更新的版本时才重新下载。

其次,模型版本的确定性。目前脚本总是拉取latest版本,这在大多数情况下是期望的行为。但在需要精确复现某个历史构建时,可能会遇到困难。一个改进方案是,在Android项目的代码中维护一个文件,明确指定要使用的模型版本号(或run_id),构建脚本读取这个文件去拉取特定版本,而不是永远拉取最新版。这样,模型版本就和代码版本一起被Git管理起来,实现了完全的构建可复现性。

最后,安全性。当前脚本中环境变量的传递和管理相对简单。在更复杂的企业环境中,需要使用更安全的凭证管理系统(如HashiCorp Vault)来动态地为构建任务提供访问S3的临时凭证,而不是使用静态的Access Key。


  目录