从训练到上线,只需一次git push

一、为什么ML部署这么难?

如果说训练模型是数据科学的“从0到1”,那么把模型部署到生产环境、稳定地跑起来并提供服务,就是“从1到100”的鸿沟。我见过太多团队在Jupyter Notebook里跑出了漂亮的准确率,却在部署阶段陷入泥潭:环境不一致、依赖冲突、版本管理混乱、回滚困难……

核心痛点在于

  • 环境一致性:本地训练用的Python 3.9 + scikit-learn 1.2.3,生产环境却可能是Python 3.11 + scikit-learn 1.3.0,模型加载直接报错
  • 交付流程碎片化:手动打包模型文件 → scp上传服务器 → 手动重启服务 → 发现端口冲突
  • 难以回滚:新模型上线后效果反而变差,想切回旧版本却发现找不到上一次的镜像
  • 缺乏可观测性:线上模型出问题了,不知道是推理延迟飙升还是内存泄漏

解决思路很简单:把模型当作应用来交付。用Docker封装环境,用Kubernetes编排部署,用GitLab CI串联整个流程。三个工具配合,实现“代码提交即部署”的自动化流水线。正如《Machine Learning Platform Engineering》中所说,Docker、Kubernetes和CI/CD是ML平台的基础设施,它们虽然不是ML专用的,但却是规模化运行ML系统的必需品。

二、整体架构:一次git push之后发生了什么?

先看一张宏观图,理解各个组件如何协作:

┌─────────────────────────────────────────────────────────────────┐
│  开发者 git push → GitLab触发Pipeline                           │
└─────────────────────────────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  Stage 1: Build & Test                                        │
│  ├── 运行单元测试 / 模型验证                                    │
│  ├── 构建Docker镜像(包含模型 + FastAPI服务)                   │
│  └── 推送到私有镜像仓库(如Docker Hub / Harbor)                │
└─────────────────────────────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  Stage 2: Deploy to K8s (Test环境)                            │
│  ├── 使用Helm更新values.yaml中的镜像Tag                         │
│  └── helm upgrade --install 部署到测试集群                     │
└─────────────────────────────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  Stage 3: 人工审批 (Production)                               │
│  └── 测试环境验证通过 → 手动触发生产发布                        │
└─────────────────────────────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  Stage 4: Deploy to K8s (Production环境)                      │
│  └── 使用生产环境values部署,滚动更新保证零停机                  │
└─────────────────────────────────────────────────────────────────┘

这套架构在业界有成熟的参考实践。一个典型的MLOps项目会使用GitLab CI完成代码构建、Docker镜像打包,再通过Kubernetes YAML清单或Helm Chart完成自动化部署。整个过程的核心原则是:每次部署都基于同一个镜像,只是不同环境使用不同的配置

三、Docker:给AI模型装上“移动集装箱”

Docker解决的是环境一致性问题。把模型文件、依赖库、服务代码全部打包进镜像,在任何有Docker的机器上都能跑出相同结果。

3.1 项目目录结构

ml-model-deploy/
├── model/
│   └── iris_model.pkl          # 训练好的模型文件
├── app/
│   ├── __init__.py
│   └── main.py                 # FastAPI服务入口
├── requirements.txt            # Python依赖
├── Dockerfile                  # 镜像构建文件
├── kubernetes/
│   ├── deployment.yaml         # K8s Deployment
│   ├── service.yaml            # K8s Service
│   └── ingress.yaml            # K8s Ingress(可选)
├── helm-chart/                 # Helm Chart(更优雅的方式)
│   ├── Chart.yaml
│   ├── values.yaml
│   └── templates/
│       ├── deployment.yaml
│       └── service.yaml
└── .gitlab-ci.yml              # GitLab CI流水线定义

3.2 FastAPI模型服务代码

# app/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import joblib
import numpy as np
import os
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

app = FastAPI(title="Iris Classifier API", version="1.0.0")

# 加载模型(在容器启动时完成)
MODEL_PATH = os.getenv("MODEL_PATH", "/app/model/iris_model.pkl")
try:
    model = joblib.load(MODEL_PATH)
    logger.info(f"Model loaded successfully from {MODEL_PATH}")
except Exception as e:
    logger.error(f"Failed to load model: {e}")
    model = None

class IrisFeatures(BaseModel):
    sepal_length: float
    sepal_width: float
    petal_length: float
    petal_width: float

class PredictionResponse(BaseModel):
    prediction: int
    probabilities: list
    model_version: str

@app.get("/health")
async def health_check():
    """K8s存活探针和就绪探针"""
    return {"status": "healthy", "model_loaded": model is not None}

@app.post("/predict", response_model=PredictionResponse)
async def predict(features: IrisFeatures):
    if model is None:
        raise HTTPException(status_code=503, detail="Model not loaded")
    
    data = np.array([[features.sepal_length, features.sepal_width,
                      features.petal_length, features.petal_width]])
    
    try:
        pred = model.predict(data)[0]
        proba = model.predict_proba(data)[0].tolist()
        return PredictionResponse(
            prediction=int(pred),
            probabilities=proba,
            model_version=os.getenv("MODEL_VERSION", "v1")
        )
    except Exception as e:
        logger.error(f"Prediction failed: {e}")
        raise HTTPException(status_code=500, detail=str(e))

3.3 Dockerfile:分阶段构建,缩小镜像体积

# Dockerfile
FROM python:3.11-slim AS builder

WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -t /deps

FROM python:3.11-slim

WORKDIR /app

# 从builder阶段复制依赖
COPY --from=builder /deps /usr/local/lib/python3.11/site-packages

# 复制应用代码和模型
COPY app/ ./app/
COPY model/ ./model/

# 创建非root用户运行服务,安全加固
RUN useradd -m -u 1000 mluser && chown -R mluser:mluser /app
USER mluser

ENV MODEL_PATH=/app/model/iris_model.pkl
ENV MODEL_VERSION=v1

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

requirements.txt示例:

fastapi==0.104.1
uvicorn[standard]==0.24.0
joblib==1.3.2
numpy==1.26.2
scikit-learn==1.3.2
pydantic==2.5.0

3.4 本地测试Docker镜像

# 构建镜像
docker build -t my-registry/iris-classifier:v1.0 .

# 运行容器
docker run -d -p 8000:8000 --name iris-test my-registry/iris-classifier:v1.0

# 测试健康检查
curl http://localhost:8000/health

# 测试预测接口
curl -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '{"sepal_length":5.1,"sepal_width":3.5,"petal_length":1.4,"petal_width":0.2}'

四、Kubernetes:让模型服务永不掉线

Docker解决了“跑起来”的问题,Kubernetes解决的是“高可用地跑”的问题:自动重启、滚动更新、水平扩缩容、服务发现。

4.1 Deployment清单(定义Pod模板)

# kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: iris-classifier
  namespace: ml-prod
  labels:
    app: iris-classifier
spec:
  replicas: 3                    # 保持3个副本运行
  strategy:
    type: RollingUpdate          # 滚动更新,零停机
    rollingUpdate:
      maxSurge: 1                # 更新时最多新增1个Pod
      maxUnavailable: 0          # 更新时不允许Pod不可用
  selector:
    matchLabels:
      app: iris-classifier
  template:
    metadata:
      labels:
        app: iris-classifier
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/path: "/metrics"
        prometheus.io/port: "8000"
    spec:
      containers:
      - name: classifier
        image: my-registry/iris-classifier:v1.0   # 会被CI动态替换
        ports:
        - containerPort: 8000
        env:
        - name: MODEL_VERSION
          value: "v1.0"
        - name: LOG_LEVEL
          value: "INFO"
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:           # 存活探针:容器挂了就重启
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 10
          periodSeconds: 10
        readinessProbe:          # 就绪探针:流量只发给就绪的Pod
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 5
          periodSeconds: 5

4.2 Service清单(暴露服务)

# kubernetes/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: iris-classifier-svc
  namespace: ml-prod
spec:
  selector:
    app: iris-classifier
  ports:
  - port: 80
    targetPort: 8000
  type: ClusterIP                 # 集群内部访问,Ingress对外暴露

4.3 用Helm统一管理配置

直接维护YAML文件的问题是:测试环境和生产环境用不同的镜像Tag、不同的副本数,需要反复改文件。Helm Chart通过模板化解决这个问题。

helm-chart/values.yaml

# 测试环境 values-test.yaml
replicaCount: 2
image:
  repository: my-registry/iris-classifier
  tag: v1.0
  pullPolicy: IfNotPresent
service:
  type: ClusterIP
  port: 80
resources:
  requests:
    memory: "256Mi"
    cpu: "250m"
  limits:
    memory: "512Mi"
    cpu: "500m"
# 生产环境 values-prod.yaml
replicaCount: 5
image:
  repository: my-registry/iris-classifier
  tag: v1.0
  pullPolicy: Always
service:
  type: ClusterIP
  port: 80
resources:
  requests:
    memory: "512Mi"
    cpu: "500m"
  limits:
    memory: "1Gi"
    cpu: "1000m"

部署命令:

# 部署到测试环境
helm upgrade --install iris-classifier ./helm-chart \
  -f helm-chart/values-test.yaml \
  --set image.tag=v1.0

# 部署到生产环境
helm upgrade --install iris-classifier ./helm-chart \
  -f helm-chart/values-prod.yaml \
  --set image.tag=v1.0

五、GitLab CI:串起完整交付流水线

最后一块拼图是自动化。每次代码提交,GitLab CI自动完成:测试→构建镜像→推送到仓库→更新Kubernetes部署。

5.1 配置GitLab CI变量

在GitLab项目 Settings → CI/CD → Variables中配置:

变量名 用途 示例值
DOCKER_REGISTRY 镜像仓库地址 docker.io/myusername
DOCKER_USERNAME 仓库用户名 myusername
DOCKER_TOKEN 仓库访问Token dckr_pat_xxxxx
K8S_NAMESPACE K8s命名空间 ml-prod

5.2 .gitlab-ci.yml完整定义

# .gitlab-ci.yml
stages:
  - test
  - build
  - deploy-test
  - deploy-prod

variables:
  IMAGE_NAME: ${DOCKER_REGISTRY}/iris-classifier
  IMAGE_TAG: ${CI_COMMIT_SHORT_SHA}      # 用commit hash作为镜像Tag

# 缓存pip依赖,加速构建
cache:
  paths:
    - .venv/

# ============ Stage 1: 测试 ============
test:
  stage: test
  image: python:3.11-slim
  script:
    - pip install -r requirements.txt
    - pip install pytest pytest-cov
    - pytest tests/ --cov=app --cov-report=term
  artifacts:
    paths:
      - .coverage
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
  only:
    - merge_requests
    - main

# ============ Stage 2: 构建镜像 ============
build:
  stage: build
  image: docker:latest
  services:
    - docker:dind           # Docker-in-Docker,用于构建镜像
  before_script:
    - docker login -u ${DOCKER_USERNAME} -p ${DOCKER_TOKEN} ${DOCKER_REGISTRY}
  script:
    # 构建并推送镜像
    - docker build -t ${IMAGE_NAME}:${IMAGE_TAG} .
    - docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${IMAGE_NAME}:latest
    - docker push ${IMAGE_NAME}:${IMAGE_TAG}
    - docker push ${IMAGE_NAME}:latest
  after_script:
    - docker logout
  only:
    - main                  # 只有main分支才构建镜像

# ============ Stage 3: 部署到测试环境 ============
deploy-test:
  stage: deploy-test
  image: bitnami/kubectl:latest
  before_script:
    # 配置kubectl访问测试集群(通过GitLab变量传入kubeconfig)
    - mkdir -p $HOME/.kube
    - echo "$KUBECONFIG_TEST" | base64 -d > $HOME/.kube/config
  script:
    # 使用helm升级部署
    - helm upgrade --install iris-classifier ./helm-chart/
        --namespace ml-test
        --set image.tag=${IMAGE_TAG}
        --set replicaCount=2
        -f ./helm-chart/values-test.yaml
    # 等待部署完成
    - kubectl -n ml-test rollout status deployment/iris-classifier --timeout=120s
  environment:
    name: test
    url: https://test-iris.example.com
  only:
    - main
  when: manual            # 手动触发部署测试环境(可根据需求改为自动)

# ============ Stage 4: 部署到生产环境(需审批) ============
deploy-prod:
  stage: deploy-prod
  image: bitnami/kubectl:latest
  before_script:
    - mkdir -p $HOME/.kube
    - echo "$KUBECONFIG_PROD" | base64 -d > $HOME/.kube/config
  script:
    - helm upgrade --install iris-classifier ./helm-chart/
        --namespace ml-prod
        --set image.tag=${IMAGE_TAG}
        --set replicaCount=5
        -f ./helm-chart/values-prod.yaml
    - kubectl -n ml-prod rollout status deployment/iris-classifier --timeout=120s
  environment:
    name: production
    url: https://iris.example.com
  only:
    - main
  when: manual            # 生产环境发布必须人工确认

5.3 流水线执行效果

当你完成上述配置后,每次git push到main分支:

  1. 测试阶段自动运行,如果测试失败,流水线提前终止,防止坏代码进入后续阶段
  2. 构建阶段用Git commit SHA给镜像打Tag(如iris-classifier:a1b2c3d),保证每次提交对应唯一镜像
  3. 测试部署阶段将新镜像部署到测试环境,自动执行健康检查
  4. 生产部署需要手动点击"play"按钮触发,降低误操作风险

实际生产中,许多团队还会在构建阶段加入镜像安全扫描(如Trivy)和代码质量检查(如SonarQube)等环节,进一步提升交付质量。

六、进阶:从单模型到大规模模型管理

当模型数量从1个增长到100个时,这套模式仍然适用,但需要补充一些能力:

1. 模型版本管理:将训练好的模型文件存储在对象存储(如S3/MinIO)中,路径包含版本号(如s3://models/iris/v20260118/)。Kubeflow Pipeline在训练完成后自动将模型注册到Model Registry并打上版本标签。

2. 配置驱动的模型定义:用JSON/YAML文件描述每个模型的训练数据来源、流水线版本、超参数等信息,CI系统读取这些配置后触发对应的训练和部署任务。

3. GitOps持续同步:使用Argo CD这样的工具,它会持续监控Git仓库中部署配置的变化,自动将集群状态同步到Git中声明的状态。当你的Helm Chart配置(如镜像Tag)在Git中被更新时,Argo CD会在几分钟内完成实际的K8s部署,实现“Git是唯一真实来源”。

4. 模型监控与自动重训:部署Prometheus收集推理延迟、请求量等指标,配合Grafana构建监控大盘。当模型精度或数据分布发生漂移时,触发自动重训流水线。

七、写在最后

从“手动复制模型文件”到“一次git push全自动交付”,转变的核心不在于多写了一堆YAML文件,而在于将模型当作有生命周期的软件产物来对待。Docker解决了环境一致性的物理基础,Kubernetes提供了稳定运行的空间,CI/CD串联了从代码到服务的完整通道。

三个关键经验

  1. 镜像即部署单元:每次构建产生一个不可变的镜像,Tag用commit hash而非latest,保证可追溯
  2. 配置与代码分离:Helm values区分环境,同一镜像在不同环境使用不同配置
  3. 人工闸门保护生产:测试环境自动部署,生产环境需人工审批,多一道防线

这套实践我已经在多个AI项目中验证,无论你用的是scikit-learn、PyTorch还是TensorFlow,流程都是通用的。开始动手吧——从一个简单的模型服务开始,跑通整条流水线,再做扩展。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐