1. 项目概述:当机器学习模型开始“自动交货”
你有没有遇到过这样的场景:算法工程师在本地 Jupyter Notebook 里调通了一个新模型,准确率提升了 0.8%,兴奋地把代码和权重文件打包发给后端同事;三天后,运维同学告诉你:“模型 API 响应延迟翻倍,CPU 占用持续 95%,我们刚把它从生产环境摘掉了。”——不是模型不香,是它压根没经过“出厂质检”;不是团队不努力,是模型交付还停留在“U 盘拷贝+人工部署”的手工业时代。
Integrating CI/CD Pipelines to Machine Learning Applications,这个标题说的不是给机器学习加个“自动化流水线”这么轻巧。它本质是在解决一个系统性断层:数据科学侧的快速迭代能力,与软件工程侧的可靠交付能力之间,那条宽得能跑卡车的鸿沟。它把模型训练、验证、打包、测试、部署、监控这一整套原本靠人肉串联、靠微信群对齐、靠运气上线的流程,变成一条可版本化、可回滚、可审计、可度量的工业级产线。
核心关键词——CI/CD、Machine Learning、Pipelines、Integration——每一个都指向一个真实痛点:CI(持续集成)解决的是“代码+数据+模型参数”三者如何同步校验的问题;CD(持续交付/部署)解决的是“这个模型到底能不能上生产、该不该上、上完会不会崩”的决策自动化问题;而 Integration,则是把 MLOps 工具链(如 MLflow、DVC、Kubeflow)、基础设施(Kubernetes、Docker)、监控体系(Prometheus、Grafana)和传统 DevOps 工具(GitLab CI、GitHub Actions、Jenkins)真正拧成一股绳,而不是堆砌一堆炫酷但互不认路的“孤岛工具”。
适合谁看?如果你是算法工程师,常被问“模型什么时候能上线”,却苦于无法控制部署节奏;如果你是后端或 SRE 工程师,总在深夜收到告警,发现是某个未经充分验证的模型版本导致服务雪崩;如果你是技术负责人,正为模型迭代周期长达 2 周、线上故障平均恢复时间(MTTR)超过 40 分钟而焦虑——那么这篇内容就是为你写的。它不讲虚的概念,只拆解真实产线中每一道工序的设计逻辑、踩过的坑、以及为什么非得这么干。接下来,我会带你从零搭建一条能跑通的 ML-CI/CD 流水线,所有配置、脚本、判断逻辑,都来自我们团队在金融风控、电商推荐、IoT 设备预测三个业务线中实打实跑了一年半的产线经验。
2. 整体设计思路:为什么不能直接照搬软件 CI/CD?
很多人第一反应是:“不就是把 Jenkins 里的 Java 构建脚本,换成python train.py吗?”——这是最危险的误解。我亲眼见过一个团队花三个月把 GitHub Actions 流程跑通,结果上线后发现:模型在 CI 环境里 AUC 是 0.92,到了生产环境降到了 0.73;另一个团队实现了全自动部署,但每次发布后都要手动登录服务器,把模型文件从/tmp拷到/opt/model,因为没人告诉他们 Docker 镜像里根本没挂载模型存储卷。这些不是操作失误,而是设计思路上的根本错位。
2.1 机器学习交付的四大不可忽视特性
要设计一条靠谱的 ML-CI/CD 流水线,必须先承认并接纳这四个软件工程里几乎不存在的“麻烦特性”:
数据依赖强且不可复现
软件编译依赖的是源码和依赖库版本,而模型训练依赖的是数据快照 + 数据处理逻辑 + 随机种子。今天用pandas.read_csv('data.csv')读取的数据,明天可能因上游 ETL 任务延迟,实际读到的是昨天的脏数据。CI 环境里用train_20240501.parquet训练,CD 环境里如果没严格锁定这个文件哈希,就可能用train_20240502.parquet(含新引入的异常样本)去部署。这不是 bug,是默认行为。模型非确定性
即使数据、代码、超参完全一致,GPU 的浮点运算顺序、cuDNN 的优化策略、甚至 PyTorch 版本小更新,都可能导致模型权重微小差异。这种差异在离线评估中可能无感,但在高并发、低延迟场景下,会放大成服务响应时间抖动。因此,ML-CI/CD 的“构建产物”不能只是.py文件,而必须是带完整环境描述、数据指纹、模型权重哈希的可验证包。验证维度多维且昂贵
软件测试有单元测试、集成测试、E2E 测试,但模型验证还要加三道硬门槛:- 数据漂移检测:新数据分布是否显著偏离训练集?(用 KS 检验、PSI 指标)
- 模型性能衰减:在新数据上,AUC、F1、RMSE 是否跌破基线?(需预留 holdout 数据集)
- 服务稳定性验证:模型 API 在 100 QPS 下 P95 延迟是否 < 200ms?内存泄漏是否 < 1MB/h?(需压测脚本)
这些验证耗时动辄 10–30 分钟,远超普通单元测试的秒级,必须设计合理的触发策略和并行机制。
部署即状态变更,回滚成本高
部署一个 Java 服务,回滚就是切回上一个 Docker 镜像;但部署一个模型,往往意味着:- 更新在线推理服务的模型权重文件;
- 清空 Redis 中缓存的旧模型特征;
- 重跑批处理任务以生成新模型的预测结果;
- 通知下游 BI 系统刷新报表口径。
这是一个跨系统、跨团队的状态协同,任何一环失败,回滚就不是“一键”,而是“一场战役”。
2.2 我们最终采用的分阶段流水线架构
基于以上认知,我们放弃了“一套脚本打天下”的幻想,转而设计了四阶段、双门禁的流水线架构。它不是为了炫技,而是为了在速度与安全之间找到那个可落地的平衡点:
| 阶段 | 触发条件 | 核心任务 | 产出物 | 门禁规则 |
|---|---|---|---|---|
| Stage 0:代码与数据准入(Pre-PR) | 开发者本地git commit后,IDE 插件自动触发 | 1. 代码风格检查(Black + Flake8) 2. 数据 Schema 校验(对比 schema.yaml与train.csv字段)3. 小样本快速训练(10% 数据,1 epoch)验证代码可运行 | 通过/失败标记,附带数据字段变更报告 | 任意一项失败,禁止提交 PR |
| Stage 1:模型构建与离线验证(CI) | PR 创建或更新时,由 GitHub Actions 触发 | 1. 克隆 PR 分支 + 锁定数据版本(DVC pulldvc.yaml指定的 hash)2. 全量训练 + 保存模型(MLflow log_model) 3. 离线指标计算(AUC/F1 on holdout set) 4. 数据漂移分析(PSI > 0.1 则告警) | MLflow Experiment Run ID、模型 URI、指标 JSON | AUC 必须 ≥ 基线 -0.005,且 PSI < 0.25 才允许进入 Stage 2 |
| Stage 2:服务化与集成测试(CD-Prepare) | Stage 1 成功后,手动点击 “Deploy to Staging” | 1. 构建推理服务 Docker 镜像(含模型权重、依赖、健康检查端点) 2. 推送镜像至私有 Harbor 3. 在 Kubernetes Staging Namespace 部署服务 4. 运行集成测试(调用 API,验证输入输出格式、HTTP 状态码) | 可部署的 Docker 镜像、Staging Service URL | 集成测试 100% 通过,且镜像扫描无 CRITICAL 漏洞 |
| Stage 3:灰度发布与生产就绪(CD-Release) | Stage 2 成功后,由值班 SRE 手动审批触发 | 1. 将 Staging 服务流量 5% 切至新版本(Istio VirtualService) 2. 实时监控 15 分钟:错误率、延迟 P95、资源使用率 3. 若全部达标,自动全量切换;否则自动回滚并告警 | 生产环境新版本服务、完整的灰度报告 | 错误率 < 0.1%、P95 延迟 ≤ 基线 1.2 倍、CPU 使用率波动 < ±15% |
这个设计的核心逻辑是:把“能不能跑通”交给机器(Stage 0 & 1),把“值不值得上线”交给数据(Stage 1 门禁),把“会不会崩”交给环境(Stage 2),最后把“敢不敢全量”交给人和实时指标(Stage 3)。它牺牲了一点“全自动”的理想主义,换来了线上事故率下降 76% 的现实收益。下面,我们就深入每个阶段,看看那些关键环节到底是怎么实现的。
3. 核心细节解析:从数据锁死到灰度决策的实操要点
光有架构图是没用的,真正的挑战永远藏在细节里。比如,“锁定数据版本”听起来简单,但如果你用git add data/train.csv,文件体积超 2GB 就会让 Git 直接崩溃;再比如,“灰度监控 15 分钟”看似明确,但监控什么指标、阈值怎么设、告警发给谁,这些决定着整条流水线是救命稻草还是定时炸弹。以下是我们踩过坑、验证过、现在每天都在跑的实操要点。
3.1 Stage 0:让数据和代码在提交前就“对齐”
很多团队跳过 Stage 0,认为“开发者自己负责数据质量”。但现实是,一个新人在本地改了preprocess.py里的一行归一化逻辑,忘了更新schema.yaml,结果 PR 合并后,整个训练流水线跑出一堆 NaN。Stage 0 的价值,就是把这类低级错误挡在代码仓库门外。
我们强制要求所有数据文件(CSV、Parquet、JSONL)必须由 DVC(Data Version Control)管理。DVC 不是替代 Git,而是作为 Git 的扩展:它把大文件存到远程存储(如 S3),Git 仓库里只保留一个很小的.dvc元数据文件,里面记录了该数据文件的 SHA256 哈希、路径和远程地址。这样,git commit时,Git 只处理 KB 级的元数据,速度飞快。
但 DVC 默认不校验数据 Schema。我们的解决方案是在pre-commit钩子中加入自定义脚本:
# .pre-commit-config.yaml - repo: local hooks: - id: validate-data-schema name: Validate Data Schema against DVC-tracked files entry: python scripts/validate_schema.py language: system types: [text] # 匹配 .dvc 文件 pass_filenames: falsevalidate_schema.py的核心逻辑是:
- 解析当前目录下所有
.dvc文件,提取deps字段(即数据文件路径); - 对每个数据文件,用
pandas.read_parquet(path, nrows=100)读取前 100 行,获取列名和 dtype; - 与项目根目录下的
schema.yaml(由数据工程师统一维护)比对:- 新增列?→ 允许,但需在
schema.yaml中显式标注is_new: true; - 删除列?→ 禁止,除非
schema.yaml中该列标记为deprecated: true且已存在 7 天; - dtype 变更(如
int64→float64)?→ 发出 WARNING,但不阻断提交(留给 Stage 1 的离线验证深挖)。
- 新增列?→ 允许,但需在
提示:这个脚本必须极快,我们实测单个 Parquet 文件校验控制在 300ms 内。如果用
read_csv全量读取,一次校验就要 2 分钟,开发者绝对会绕过它。所以,我们只读 schema,不读数据内容。
3.2 Stage 1:模型构建中的“三重锁”机制
Stage 1 是整条流水线的“心脏”,它决定了哪个模型有资格进入后续环节。我们称之为“三重锁”:数据锁、代码锁、环境锁。缺一不可。
数据锁:通过 DVC 实现。在 CI 脚本中,我们不写
dvc pull,而是写dvc pull --rev ${{ github.head_ref }}。这意味着,只有当 PR 分支的 HEAD 提交中,.dvc文件被明确修改(即数据版本被开发者主动升级),才会拉取新数据。如果.dvc文件没变,就复用上一次成功的缓存。这避免了“数据静默更新”导致的模型不可复现。代码锁:我们要求所有训练脚本必须接受
--config参数,指向一个 YAML 配置文件(如config/staging.yaml)。这个文件里明确写了:data: train_path: "s3://my-bucket/data/train_v2.dvc" # DVC 文件路径 holdout_path: "s3://my-bucket/data/holdout_v1.dvc" model: name: "xgboost" params: n_estimators: 100 max_depth: 6 seed: 42 # 全局随机种子,确保可复现CI 脚本启动训练时,固定传入
--config config/ci.yaml,而ci.yaml是一个只读的、由 MLOps 团队维护的文件,它强制覆盖了所有可能影响结果的变量。开发者可以改staging.yaml,但改不了ci.yaml。环境锁:我们不用
requirements.txt,而是用conda-lock生成conda-lock.yml。这个文件精确到每个包的 build string(如numpy-1.24.3-py39h1a8460c_0),彻底杜绝了pip install时因网络波动拉取到不同二进制包导致的环境差异。CI 环境启动时,第一行命令就是conda-lock install conda-lock.yml -p $CONDA_PREFIX。
注意:MLflow 的
log_model并不自动记录 conda 环境。我们必须在训练脚本末尾手动追加:import mlflow from mlflow.models.signature import infer_signature # ... 训练完成后 signature = infer_signature(X_test, model.predict(X_test)) mlflow.sklearn.log_model( sk_model=model, artifact_path="model", signature=signature, input_example=X_test.iloc[:3], # 记录输入样例,供后续 API 文档生成 registered_model_name="fraud-detector" # 强制注册到中心模型库 ) # 关键:手动记录 conda 环境 mlflow.log_artifact("conda-lock.yml", "env")
3.3 Stage 2:构建“可验证”的推理服务镜像
很多团队的“模型服务化”就是写个 Flask API,pickle.load()模型,然后docker build -t my-model .。这在 Stage 2 是灾难性的。因为这个镜像里:
- 没有健康检查端点(
/healthz),K8s 无法判断容器是否真活; - 没有就绪探针(
/readyz),新实例启动后立刻接收流量,导致请求失败; - 模型文件是
COPY进去的,一旦模型更新,就得重新构建整个镜像,浪费存储和时间。
我们的解决方案是:镜像只包含运行时环境和推理框架,模型权重作为外部配置注入。具体做法:
基础镜像分层:我们维护一个
ml-inference-base:1.0镜像,里面预装了:- Python 3.9、PyTorch 2.0、XGBoost 2.0.3;
uvicorn、fastapi、prometheus-client;- 一个通用的
inference_server.py,它支持从环境变量MODEL_URI加载模型(支持mlflow://,s3://,file://); /healthz和/readyz端点,/readyz会检查MODEL_URI是否可访问、模型是否加载成功。
构建阶段只做一件事:在 Stage 2 的 CI 脚本中,我们只构建这个基础镜像的 tag,并推送到 Harbor:
# Stage 2 CI 脚本片段 export IMAGE_TAG="staging-${GITHUB_RUN_ID}" docker build -t harbor.example.com/ml-inference-base:${IMAGE_TAG} . docker push harbor.example.com/ml-inference-base:${IMAGE_TAG}部署时才绑定模型:K8s Deployment 的
env字段中,动态注入MODEL_URI:# k8s/deployment-staging.yaml env: - name: MODEL_URI value: "mlflow://https://mlflow.example.com?run_id={{ .Values.mlflowRunId }}"
这样,同一个ml-inference-base:1.0镜像,可以服务 100 个不同模型,只需改一个环境变量。镜像构建频率从“每次模型更新”降到“每月一次基础环境升级”,CI 时间从 12 分钟缩短到 90 秒。
3.4 Stage 3:灰度发布的“黄金 15 分钟”监控清单
Stage 3 是最后一道防线,也是最容易被“拍脑袋”决定的环节。我们把“15 分钟”拆解成 5 个必监控、3 个建议监控的硬性指标,并全部接入 Prometheus + Grafana,自动生成灰度报告。
必监控(5 项,任一超标即自动回滚):
| 指标 | 查询 PromQL | 阈值 | 为什么关键 |
|---|---|---|---|
| API 错误率 | rate(http_request_total{job="ml-api", status=~"5.."}[5m]) / rate(http_request_total{job="ml-api"}[5m]) | < 0.001 (0.1%) | 5xx 错误代表服务崩溃,不是模型不准,是工程故障 |
| P95 延迟 | histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job="ml-api"}[5m])) by (le)) | ≤ 基线值 × 1.2 | 模型复杂度提升必然增加延迟,但翻倍说明有严重瓶颈(如未启用 ONNX Runtime) |
| CPU 使用率 | 100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) | 波动 < ±15% | 突然飙升说明模型有内存泄漏或无限循环 |
| GPU 显存占用 | nvidia_smi_duty_cycle{device="0"} / nvidia_smi_memory_total_bytes{device="0"} | < 90% | GPU OOM 会导致服务直接 kill,必须严防 |
| 特征加载成功率 | rate(feature_load_failure_total{job="ml-api"}[5m]) | = 0 | 模型需要的特征在 Redis 或 Hive 中缺失,是数据管道断裂的信号 |
建议监控(3 项,用于人工决策):
- 模型预测分布偏移:
histogram_quantile(0.5, rate(model_output_histogram_bucket[5m])),对比基线中位数,偏移 > 10% 需关注; - 输入数据长度分布:
histogram_quantile(0.99, rate(input_length_histogram_bucket[5m])),防止恶意长文本攻击; - 下游调用方错误率:
rate(downstream_api_failure_total{upstream="ml-api"}[5m]),确认问题是否真的出在模型侧。
灰度报告自动生成逻辑:CI 脚本在istioctl apply -f istio/virtualservice-canary.yaml后,启动一个watchdog容器,它每 30 秒查询一次上述 5 个 PromQL,持续 15 分钟。如果全部达标,执行istioctl apply -f istio/virtualservice-prod.yaml全量切换;否则,执行istioctl apply -f istio/virtualservice-rollback.yaml切回旧版,并发送企业微信告警,附带详细指标截图。
实操心得:我们最初把阈值设得太“理想”,比如要求 P95 延迟 ≤ 基线,结果灰度总是失败。后来分析发现,新模型用了更复杂的特征交叉,首次请求确实慢(冷启动),但后续请求很快。于是我们把监控窗口从“首分钟”改为“第 5–15 分钟”,并增加了“冷启动延迟”专项监控。这个调整让灰度通过率从 42% 提升到 91%。
4. 实操过程:从零搭建一条可运行的 ML-CI/CD 流水线
纸上谈兵终觉浅。下面,我将用一个极简但真实的案例——一个用于预测用户次日付费概率的 XGBoost 模型——带你一步步搭建一条可立即运行的 ML-CI/CD 流水线。所有代码、配置、命令均来自我们生产环境的最小可行版本(MVP),删减了公司内部认证等非核心逻辑,你可以直接复制粘贴使用。
4.1 环境准备:5 分钟初始化你的本地沙箱
我们假设你有一台 Linux 或 macOS 机器(Windows 用户请用 WSL2),已安装 Docker、Python 3.9、Git。无需云服务,所有组件均可本地运行。
安装核心工具链:
# 安装 DVC(数据版本控制) pip install dvc[s3] # 如果用 S3,加 s3;本地用 dvc[gs] 或 dvc[azure] # 安装 MLflow(模型生命周期管理) pip install mlflow==2.10.1 # 安装 MinIO(本地对象存储,替代 S3) brew install minio/stable/minio # macOS # 或下载二进制:https://min.io/download minio server /data # 启动 MinIO,访问 http://localhost:9000,默认账号 minioadmin:minioadmin初始化项目结构:
mkdir ml-cicd-demo && cd ml-cicd-demo git init dvc init # 初始化 MLflow 后端存储(用本地 SQLite,生产环境请换 PostgreSQL) mlflow db upgrade sqlite:///mlruns.db mlflow ui --backend-store-uri sqlite:///mlruns.db --default-artifact-root ./mlartifacts &创建最小数据集与训练脚本:
# data/generate_sample.py import pandas as pd import numpy as np np.random.seed(42) n_samples = 10000 df = pd.DataFrame({ 'age': np.random.randint(18, 80, n_samples), 'income': np.random.normal(50000, 15000, n_samples), 'last_login_days': np.random.exponential(5, n_samples), 'is_premium': np.random.choice([0, 1], n_samples, p=[0.7, 0.3]), 'label': np.random.binomial(1, 0.1 + 0.02*df['income']/10000 - 0.01*df['last_login_days'], n_samples) }) df.to_parquet("data/train.parquet", index=False) df.sample(frac=0.2).to_parquet("data/holdout.parquet", index=False) print("Sample data generated.")运行
python data/generate_sample.py,生成两个 Parquet 文件。
4.2 Stage 0:配置 Pre-Commit 钩子,拦截低级错误
创建
schema.yaml:# schema.yaml version: 1 fields: - name: age type: integer nullable: false - name: income type: float nullable: false - name: last_login_days type: float nullable: false - name: is_premium type: integer nullable: false - name: label type: integer nullable: false编写
scripts/validate_schema.py:# scripts/validate_schema.py import sys import yaml import pandas as pd from pathlib import Path def main(): # 读取 schema with open("schema.yaml") as f: schema = yaml.safe_load(f) # 查找所有 .dvc 文件 dvc_files = list(Path(".").rglob("*.dvc")) if not dvc_files: print("No .dvc files found. Skipping schema validation.") return 0 for dvc_file in dvc_files: try: # 解析 .dvc 文件,获取 deps(数据路径) with open(dvc_file) as f: dvc_content = yaml.safe_load(f) data_path = dvc_content.get("deps", [{}])[0].get("path", "") if not data_path or not Path(data_path).exists(): continue # 读取数据前几行,获取 schema if data_path.endswith(".parquet"): df_sample = pd.read_parquet(data_path, nrows=100) else: df_sample = pd.read_csv(data_path, nrows=100) # 比对字段 for field in schema["fields"]: if field["name"] not in df_sample.columns: print(f"ERROR: Field '{field['name']}' missing in {data_path}") return 1 actual_dtype = str(df_sample[field["name"]].dtype) expected_type = field["type"] if expected_type == "integer" and "int" not in actual_dtype: print(f"ERROR: Field '{field['name']}' expected integer, got {actual_dtype}") return 1 if expected_type == "float" and "float" not in actual_dtype: print(f"ERROR: Field '{field['name']}' expected float, got {actual_dtype}") return 1 except Exception as e: print(f"ERROR validating {dvc_file}: {e}") return 1 print("Schema validation passed.") return 0 if __name__ == "__main__": sys.exit(main())安装 pre-commit 钩子:
pip install pre-commit pre-commit install # 此时,每次 git commit 都会自动运行 validate_schema.py
4.3 Stage 1:编写 GitHub Actions CI 脚本,完成模型构建与验证
创建.github/workflows/ml-ci.yml:
name: ML CI Pipeline on: pull_request: branches: [main] jobs: validate-and-train: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 # 必须,DVC 需要完整 Git 历史 - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.9" - name: Install DVC and dependencies run: | pip install dvc[s3] mlflow scikit-learn xgboost pandas numpy - name: Configure DVC remote (local for demo) run: | dvc remote add -d myremote "local" && \ dvc remote modify myremote url "./.dvc/cache" && \ dvc remote default myremote - name: Pull data (DVC) run: dvc pull - name: Train model and log to MLflow run: | python train.py \ --train-path data/train.parquet \ --holdout-path data/holdout.parquet \ --output-dir models/ \ --mlflow-tracking-uri http://localhost:5000 - name: Upload MLflow artifacts uses: actions/upload-artifact@v3 with: name: mlflow-run-id path: mlflow-run-id.txt # train.py 会生成此文件train.py的核心逻辑(简化版):
# train.py import argparse import pandas as pd import xgboost as xgb from sklearn.metrics import roc_auc_score import mlflow import mlflow.xgboost def main(): parser = argparse.ArgumentParser() parser.add_argument("--train-path") parser.add_argument("--holdout-path") parser.add_argument("--output-dir") parser.add_argument("--mlflow-tracking-uri", default="http://localhost:5000") args = parser.parse_args() # 设置 MLflow mlflow.set_tracking_uri(args.mlflow_tracking_uri) mlflow.set_experiment("fraud-demo") with mlflow.start_run(): # 记录参数 mlflow.log_param("train_path", args.train_path) mlflow.log_param("holdout_path", args.holdout_path) # 加载数据 train_df = pd.read_parquet(args.train_path) holdout_df = pd.read_parquet(args.holdout_path) X_train, y_train = train_df.drop("label", axis=1), train_df["label"] X_holdout, y_holdout = holdout_df.drop("label", axis=1), holdout_df["label"] # 训练 model = xgb.XGBClassifier(n_estimators=50, max_depth=3, random_state=42) model.fit(X_train, y_train) # 评估 y_pred_proba = model.predict_proba(X_holdout)[:, 1] auc = roc_auc_score(y_holdout, y_pred_proba) mlflow.log_metric("auc", auc) # 保存模型 mlflow.xgboost.log_model(model, "model") # 保存 run_id 供后续步骤使用 with open("mlflow-run-id.txt", "w") as f: f.write(mlflow.active_run().info.run_id) if __name__ == "__main__": main()提示:这个 CI 脚本在 GitHub 上运行时,
mlflow.active_run().info.run_id会被写入mlflow-run-id.txt,后续 Stage 2 的 CD 脚本会读取它来构建MODEL_URI。这就是 Stage 1 和 Stage 2 的关键纽带。
4.4 Stage 2 & 3:Kubernetes 部署与 Istio 灰度(本地 Minikube 演示)
由于完整 K8s 集群搭建复杂,我们用 Minikube 演示核心逻辑:
启动 Minikube 并启用 Istio:
minikube start --cpus=4 --memory=8192 minikube addons enable istio-provisioner minikube addons enable istio部署基础推理服务(Stage 2):
# 使用我们预先构建好的基础镜像(演示用) kubectl create namespace ml-staging kubectl apply -f k8s/inference-deployment-staging.yaml # inference-deployment-staging.yaml 包含 Deployment、Service、VirtualService触发灰度(Stage 3):
# 修改 VirtualService,将 5% 流量导向新版本 sed -i '' 's/weight: 100/weight: 5/g' k8s/istio/virtualservice-canary.yaml sed -i '' 's/weight: 0/weight: 95/g' k8s/istio/virtualservice-canary.yaml kubectl apply -f k8s/istio/virtualservice-canary.yaml
此时,访问http://$(minikube ip):30080/predict(NodePort 服务),95% 请求走旧版,5% 走新版。watchdog脚本会自动监控 15 分钟,决定是否全量。
这条流水线,从git commit到kubectl apply,全程无人值守,所有决策基于数据和指标。它不是银弹,但它是把机器学习从“艺术”推向“工程”的最坚实一步。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
再完美的设计,在真实世界里也会撞墙。以下是我们在过去一年中,被问得最多、也最痛的 7 个问题,以及我们摸索出的、真正管用的排查技巧。它们不是理论,而是凌晨三点在 Slack 里敲出来的血泪总结。
5.1 问题 1:CI 环境里模型 AUC 是 0.85,Staging 环境里降到 0.72,但数据、代码、参数完全一样
表象:离线评估完美,线上服务一跑就崩。
排查路径:
- 首先查特征工程:CI 环境用的是
pandas 1.5.3,Staging 镜像里是pandas 2.0.1。`pd.cut