AI Infra实战11:模型部署Pipeline,CI/CD自动化
本篇目标
设计完整的模型发布Pipeline:从模型训练完成到线上服务更新的全自动化流程。
学完本篇你将掌握:
- 模型CI/CD vs 代码CI/CD的核心差异
- 完整的模型发布Pipeline设计
- 模型质量门禁
- 灰度发布和回滚策略
- GitLab CI完整配置
模型CI/CD vs 代码CI/CD
| 维度 | 代码CI/CD | 模型CI/CD |
|---|---|---|
| 触发方式 | 代码push/MR合并 | MLflow版本变更 |
| 构建产物 | Docker镜像 | 模型文件(存S3) |
| 测试方式 | 单元测试、集成测试 | 性能测试(延迟、吞吐量) |
| 产物大小 | 几百MB | 几GB-几十GB |
| 发布速度 | 秒级 | 分钟级(下载模型+加载GPU) |
| 回滚方式 | 改镜像tag | 切MLflow版本+重新下载 |
代码CI/CD与模型CI/CD在触发、产物、发布速度上的对比
完整流程
MLflow标记Staging → 触发Pipeline → 性能测试 → 灰度10% → 观察 → 全量 → 标记Production ↓ 不通过 ↓ 异常 拒绝上线 自动回滚验证到全量的四阶段主流程,以及不通过时的拒绝上线和自动回滚分支
Pipeline各阶段
阶段1:获取模型信息
从MLflow读取Staging版本的配置参数。
阶段2:质量门禁
| 检查项 | 通过条件 |
|---|---|
| 模型能加载 | 不报错 |
| P99延迟 | < 3000ms |
| 吞吐量(20并发) | > 10 req/s |
| 显存占用 | < 90% |
| 输出合理 | 不是乱码 |
阶段3:灰度发布
部署Canary Pod(10%流量),观察5分钟。
阶段4:全量发布
验证通过后更新生产Deployment,MLflow标记Production。
性能测试详解:怎么测一个还没上线的模型
核心问题
模型还没部署到生产,怎么做压测?
答案:在CI中自动部署一个临时的测试实例,专门用来跑压测。测试完就删掉。
完整流程
Pipeline的test阶段内部: 1. 自动创建测试Pod(独立namespace,不接生产流量) ↓ 2. 等待测试Pod就绪(模型下载+加载,约2-3分钟) ↓ 3. 对测试Pod跑压测(延迟、吞吐量) ↓ 4. 判断结果:通过 → 继续下一阶段 不通过 → Pipeline失败,拒绝上线 ↓ 5. 删掉测试Pod,释放GPUCI中临时测试实例的生命周期,独立命名空间、不接生产流量、测完释放GPU
测试实例的设计要点
性能测试沿用微服务发布中"先测试环境验证再上生产"的思路,但有几个模型场景特有的约束需要在Pipeline里明确:
- 测试GPU来源:使用集群预留的测试GPU,或在测试阶段临时扩一个GPU节点,测完释放。
- 流量隔离:测试实例部署在独立namespace(如ai-test),生产Service的selector不会选中它,因此不承接线上流量。
- 生命周期:由CI脚本负责创建和删除,测试结束后kubectl delete释放GPU,避免测试实例长期占卡。
- 模型来源:initContainer从对象存储下载Staging版本的模型权重,与生产实例使用同一份产物。
- 全程自动:创建、就绪等待、压测、判定、清理都写在CI脚本里,不需要人工介入。
GitLab CI配置
stages:-validate-test-deploy-canary-verify-deploy-full-rollbackvariables:MLFLOW_URI:"http://mlflow:5000"MODEL_NAME:"qwen2-inference-service"NAMESPACE:"ai-inference"DEPLOYMENT:"vllm-service"# 获取Staging模型信息validate:stage:validatescript:-pip install mlflow-q-|python3 << 'EOF' from mlflow import MlflowClient import json, os client = MlflowClient(os.environ["MLFLOW_URI"]) models = client.get_latest_versions(os.environ["MODEL_NAME"], stages=["Staging"]) if not models: print("没有Staging版本") exit(1) model = models[0] run = client.get_run(model.run_id) config = { "version": model.version, "model_name": run.data.params.get("model_name", ""), "max_model_len": run.data.params.get("max_model_len", "4096"), "gpu_memory_utilization": run.data.params.get("gpu_memory_utilization", "0.9"), } with open("model_config.json", "w") as f: json.dump(config, f) print(f"Staging模型: v{model.version} - {config['model_name']}") EOFartifacts:paths:-model_config.json# 性能测试(含临时测试实例的创建和销毁)test:performance:stage:testneeds:[validate]script:# ========== 第1步:部署临时测试实例 ==========-|echo "=== 部署测试实例 ===" MODEL_FILE=$(cat model_config.json | python3 -c "import json,sys;print(json.load(sys.stdin)['model_name'])") MAX_LEN=$(cat model_config.json | python3 -c "import json,sys;print(json.load(sys.stdin)['max_model_len'])")kubectl-n ai-test apply-f-<< YAMLapiVersion:apps/v1kind:Deploymentmetadata:name:vllm-testnamespace:ai-testspec:replicas:1selector:matchLabels:app:vllm-testtemplate:metadata:labels:app:vllm-testspec:initContainers:-name:download-modelimage:amazon/aws-clicommand:["sh","-c","aws s3 cp s3://models/${MODEL_FILE}/ /models/ --recursive"]volumeMounts:-name:model-volmountPath:/modelscontainers:-name:vllmimage:vllm/vllm-openai:latestcommand:["vllm","serve","/models/${MODEL_FILE}","--host=0.0.0.0","--port=8000","--max-model-len=${MAX_LEN}"]ports:-containerPort:8000resources:limits:nvidia.com/gpu:1volumeMounts:-name:model-volmountPath:/modelsvolumes:-name:model-volemptyDir:{}---apiVersion:v1kind:Servicemetadata:name:vllm-testnamespace:ai-testspec:selector:app:vllm-testports:-port:8000YAML# ========== 第2步:等待测试实例就绪 ==========-|echo "=== 等待测试实例就绪(最多5分钟)===" kubectl -n ai-test wait --for=condition=ready pod -l app=vllm-test --timeout=300s echo "测试实例就绪"# ========== 第3步:跑压测 ==========-|echo "=== 延迟测试(10个串行请求)===" total=0 for i in $(seq 1 10); do start=$(date +%s%N) curl -s http://vllm-test.ai-test:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"test","messages":[{"role":"user","content":"hi"}],"max_tokens":20}' > /dev/null end=$(date +%s%N) ms=$(( (end - start) / 1000000 )) total=$((total + ms)) echo " 请求$i: ${ms}ms" done avg=$((total / 10)) echo " 平均延迟: ${avg}ms"# ========== 第4步:判断结果 ==========-|if [ $avg -gt 3000 ]; then echo "延迟超标: ${avg}ms > 3000ms,拒绝上线" kubectl -n ai-test delete deployment vllm-test kubectl -n ai-test delete service vllm-test exit 1 fi echo "延迟达标: ${avg}ms < 3000ms"echo "=== 吞吐量测试(20并发)===" start=$(date +%s%N) for i in $(seq 1 20); do curl-s http://vllm-test.ai-test:8000/v1/chat/completions \-H "Content-Type:application/json" \-d '{"model":"test","messages":[{"role":"user","content":"hello"}],"max_tokens":20}'>/dev/null & done wait end=$(date +%s%N) elapsed=$(( (end-start) / 1000000 )) qps=$((20000 / elapsed))echo " 吞吐量:${qps}req/s" if[$qps-lt 10]; thenecho "吞吐量不足:${qps}< 10 req/s,拒绝上线" kubectl-n ai-test delete deployment vllm-test kubectl-n ai-test delete service vllm-test exit 1 fiecho "吞吐量达标:${qps}req/s"# ========== 第5步:清理测试实例 ==========-|echo "=== 清理测试实例 ===" kubectl -n ai-test delete deployment vllm-test kubectl -n ai-test delete service vllm-test echo "测试完成,GPU已释放"# 灰度部署deploy:canary:stage:deploy-canaryneeds:[test:performance]script:-|echo "=== 部署Canary(10%流量)===" kubectl -n $NAMESPACE apply -f canary-deployment.yaml echo "Canary部署完成"environment:name:production-canary# 灰度验证verify:canary:stage:verifyneeds:[deploy:canary]script:-|echo "=== 观察5分钟 ===" sleep 300 # 查询canary的成功请求速率(vLLM暴露 vllm:request_success_total) # 失败率建议结合网关或应用层的HTTP状态码统计,指标名以实际vLLM版本的 /metrics 为准 SUCCESS_RATE=$(curl -s "http://prometheus:9090/api/v1/query?query=sum(rate(vllm:request_success_total{track='canary'}[5m]))") echo "Canary成功请求速率: ${SUCCESS_RATE}" # 异常则回滚 kubectl -n $NAMESPACE delete deployment ${DEPLOYMENT}-canary || true# 全量部署(手动确认)deploy:full:stage:deploy-fullneeds:[verify:canary]script:-|kubectl -n $NAMESPACE set env deployment/${DEPLOYMENT} \ MODEL_VERSION=$(cat model_config.json | python3 -c "import json,sys;print(json.load(sys.stdin)['version'])") kubectl -n $NAMESPACE rollout status deployment/${DEPLOYMENT} --timeout=600s kubectl -n $NAMESPACE delete deployment ${DEPLOYMENT}-canary || true echo "全量部署完成"when:manual# 回滚rollback:stage:rollbackscript:-kubectl-n $NAMESPACE rollout undo deployment/${DEPLOYMENT}-echo "回滚完成"when:manualallow_failure:true灰度部署详解:怎么让只有10%的流量到新模型
核心问题
灰度发布时,怎么控制只有一部分流量走新模型,大部分流量还走旧模型?
第07篇用KServe在真实GPU环境验证过一次灰度:给InferenceService设置canaryTrafficPercent,KServe自动把流量按10比90切到新旧两个Revision,并支持回滚和推广。那是平台已经封装好的能力。这一篇换个角度,看不依赖KServe时,怎么用更基础的K8s原语自己实现灰度,理解背后的流量分配原理。下面给两种方式。
方式一:Canary Deployment(最常用,不需要额外组件)
原理:利用K8s Service的负载均衡:多个Pod共享一个Service,流量按Pod数量比例自然分配。
Service按Pod数量分流,10个旧版本Pod加1个新版本Pod,新版本约拿9%流量
生产环境现状: Deployment: vllm-service(10个Pod,跑v1模型) Service: vllm-svc → 负载均衡到这10个Pod 灰度操作: 新增 Deployment: vllm-service-canary(1个Pod,跑v3模型) 让这个canary Pod也被同一个Service选中 结果: Service后面有11个Pod(10个v1 + 1个v3) 流量自然分配:v3拿到约 1/11 ≈ 9% 的流量关键配置:
# 生产Deployment(已有,10副本)apiVersion:apps/v1kind:Deploymentmetadata:name:vllm-servicespec:replicas:10selector:matchLabels:app:vllmtemplate:metadata:labels:app:vllm# ← Service通过这个label选Podversion:v1# ← 区分版本(用于监控区分)---# Canary Deployment(CI自动创建,1副本)apiVersion:apps/v1kind:Deploymentmetadata:name:vllm-service-canaryspec:replicas:1selector:matchLabels:app:vllmversion:v3template:metadata:labels:app:vllm# ← 同样的label,也会被Service选中version:v3---# Service(不需要改,自动选中所有 app=vllm 的Pod)apiVersion:v1kind:Servicemetadata:name:vllm-svcspec:selector:app:vllm# ← 选中v1的10个Pod + v3的1个Podports:-port:8000流量比例:10个旧Pod + 1个新Pod = 新模型拿到约9%流量。
方式二:Istio精确流量控制(需要Service Mesh)
如果集群装了Istio,可以精确控制任意百分比:
apiVersion:networking.istio.io/v1beta1kind:VirtualServicemetadata:name:vllm-vsspec:hosts:-vllm-svchttp:-route:-destination:host:vllm-svcsubset:stableweight:90# 90%走旧模型-destination:host:vllm-svcsubset:canaryweight:10# 10%走新模型两种方式对比
| 维度 | Canary Deployment | Istio |
|---|---|---|
| 精确度 | 按Pod数比例(粗略) | 任意百分比(精确) |
| 依赖 | 只需要K8s | 需要安装Istio |
| 复杂度 | 低(CI里kubectl apply就行) | 高 |
| 适合 | 大多数场景 | 需要精确控制时 |
建议:没有Istio就用Canary Deployment,够用了。
灰度期间怎么观察
部署canary后观察5-10分钟,对比新旧模型的指标:
| 指标 | v1(stable) | v3(canary) | 判定 |
|---|---|---|---|
| P99延迟 | 2.1s | 1.8s | 更好 |
| 错误率 | 0.1% | 0.2% | 在可接受范围 |
| GPU显存 | 12GB | 6GB | 更省 |
用Prometheus区分新旧版本的指标(通过version label):
vLLM暴露的是成功请求计数vllm:request_success_total。给新旧版本的Pod打上不同的 version label 后,可以分别观察两版的请求速率;失败率建议结合网关或应用层的 HTTP 状态码统计。具体指标名以所用 vLLM 版本的/metrics输出为准。
# canary(v3)的成功请求速率 sum(rate(vllm:request_success_total{version="v3"}[5m])) # stable(v1)的成功请求速率 sum(rate(vllm:request_success_total{version="v1"}[5m]))灰度失败怎么回滚
# 直接删掉canary Deploymentkubectl delete deployment vllm-service-canarycanary Pod没了 → Service只剩v1的Pod → 流量自动100%回到旧模型。不需要改Service配置。
灰度成功怎么全量
# 1. 更新生产Deployment的模型版本(触发滚动更新)kubectlsetenvdeployment/vllm-serviceMODEL_VERSION=v3# 2. 等待所有旧Pod滚动更新成v3kubectl rollout status deployment/vllm-service# 3. 删掉canary(生产Deployment已经全部是v3了)kubectl delete deployment vllm-service-canary灰度实践中的几个关键点
- 比例精度:Service按Pod数量分流是粗粒度的,10个旧Pod加1个canary约等于9%,多数场景够用。需要精确到任意百分比时用Istio的weight,或按第07篇KServe的canaryTrafficPercent控制。
- 调整比例:想走5%到20%再到100%的阶梯,可以调canary的replicas数量,或调Istio/KServe的权重。
- 对存量的影响:canary是新增的Deployment,不改动原有Pod,灰度期间旧模型不受影响。
- 用户感知:如果新旧模型回答风格差异明显,被分到canary的少量用户可能察觉,需要在验证阶段一并评估输出质量,而不只看延迟和错误率。
- 观察时长:看基本指标5到10分钟即可,核心服务建议观察30分钟到1小时,覆盖一个较完整的流量波峰。
质量门禁设计
| 级别 | 检查项 | 不通过的后果 |
|---|---|---|
| P0 | 模型能加载 | 阻断,不上线 |
| P0 | 请求不报错 | 阻断 |
| P1 | P99延迟 < SLO | 阻断 |
| P1 | 吞吐量 > 基线80% | 阻断 |
| P2 | 显存 < 90% | 告警但不阻断 |
实践边界
本篇是模型部署Pipeline的设计方案,整理自MLflow、GitLab CI和K8s的常规用法,不新增云资源,成本为0。需要说明验证情况:
- 其中的灰度、回滚能力,第07篇已用KServe在真实GPU环境验证过(canaryTrafficPercent按10比90切分、回滚、推广到100%)。
- 本篇的GitLab CI配置、临时测试实例和质量门禁,是可落地的设计模板,尚未在完整CI环境端到端跑通,落地时需结合具体的MLflow地址、对象存储和GPU配额调整参数。
- vLLM的指标名以所用版本的 /metrics 输出为准,不同版本可能有差异。
不把设计模板写成已上线的生产流水线,是为了让读者清楚哪些是已验证的、哪些是待落地的。
小结
- 模型CI/CD多了质量门禁:延迟和吞吐量必须达标
- 四阶段流程:验证→测试→灰度→全量
- 灰度+自动回滚:异常时自动切回旧版本
- 底层仍是熟悉的工具链:GitLab CI加K8s滚动更新,核心增量是模型产物管理和上线前的性能门禁
下一篇预告
AI Infra实战12:GPU成本优化实践
参考链接
- GitLab CI/CD文档
- K8s Canary Deployment
- MLflow Model Registry API
- 模型发布最佳实践