机器学习模型服务化:从Notebook到生产环境的落地实践
1. 项目概述:当模型走出Jupyter,开始在真实世界里“上班”
“From Notebook to Production: Running ML in the Real World (Part 4)”——这个标题本身就像一句暗号,专为那些刚把模型在Jupyter里跑通、正对着
model.predict()
输出的几个漂亮数字暗自欣喜,却突然被运维同事一句“那它怎么每天自动跑?出错了谁看?数据变了会不会崩?”问得哑口无言的人而设。它不是讲怎么调参、怎么画ROC曲线,而是直指机器学习项目生命周期中最沉默也最致命的一环:
从实验性代码到可持续服务的跨越
。我带过十几支跨职能ML团队,亲眼见过太多项目死在Part 3之后——模型AUC 0.92,上线后第一周因上游数据字段名悄悄加了个下划线就全量返回NaN;特征工程脚本本地跑得飞起,部署到生产环境发现缺失值填充逻辑和训练时用的根本不是同一套规则;甚至有团队把整个
pandas
DataFrame塞进Flask的全局变量里当缓存,结果并发一上来内存直接爆表。Part 4,就是专门来拆解这些“看似细小、实则致命”的落地断点。它面向的不是纯算法研究员,而是那个必须同时听懂数据科学家说的“我们用了XGBoost做特征重要性分析”,又得跟SRE确认“这个API的P99延迟能不能压到200ms以内”的角色——可以是MLOps工程师、全栈数据工程师,也可以是主动扛起交付责任的资深数据科学家。它不承诺“一键上线”,但会告诉你每一步踩下去,地面是实心混凝土还是薄冰。
2. 内容整体设计与思路拆解:为什么“运行”比“训练”更难?
2.1 核心矛盾:研究范式与工程范式的天然撕裂
一个典型的Jupyter Notebook,本质是
探索性、单次、状态依赖强
的工作流。你加载数据、清洗、建模、评估,所有操作都发生在同一个Python进程里,变量名
df_train
、
scaler
、
model
像老朋友一样随时可调用。而生产环境要求的是
可重复、可监控、可隔离、可伸缩
的服务。这种根本性差异,决定了Part 4的设计绝不能是“把Notebook代码复制粘贴进Flask”。我见过最典型的失败案例,是某电商团队把包含
%matplotlib inline
和
pd.read_csv('data/train.csv')
的Notebook直接用
nbconvert
转成Python脚本,再扔进Airflow调度——结果每次调度都重新下载GB级训练数据,特征计算逻辑散落在十几个
# TODO: refactor
注释里,模型版本完全靠文件名
model_v2_20240515.pkl
人工管理。Part 4的底层设计逻辑,就是用
明确的边界、契约化的接口、自动化的流水线
去缝合这道撕裂。核心思路分三层:
-
数据层隔离 :训练时用的原始数据快照(snapshot),和推理时用的实时/近实时数据流,必须物理或逻辑隔离。我们不会让线上服务去读训练用的HDFS路径,而是通过预计算好的特征存储(Feature Store)或标准化的数据API提供输入。这解决了“数据漂移”问题的第一道防线——至少输入格式是受控的。
-
模型层契约化 :模型不再是
.pkl文件,而是封装成符合 Model Interface Contract 的组件。这个契约强制定义了三件事:输入数据的Schema(字段名、类型、是否允许空值)、输出的结构(预测值、置信度、解释性分数)、以及健康检查端点(/healthz)。哪怕你用PyTorch训练,只要实现了这个契约,下游服务就只认接口,不关心内部实现。这直接规避了“模型更新后API参数名变了导致前端崩溃”的经典事故。 -
运行时层容器化+声明式编排 :拒绝“在我机器上能跑就行”。所有推理服务必须打包成Docker镜像,镜像内固化Python版本、依赖库精确版本(
pip freeze > requirements.txt生成的)、甚至CUDA驱动版本。部署不再靠ssh上去pip install,而是用Kubernetes的Deployment YAML声明:“需要3个副本,每个内存限制2Gi,CPU请求0.5核,就绪探针访问/healthz”。这种声明式管理,让扩容、回滚、故障恢复变成几行命令的事,而不是深夜打电话叫人爬起来手动重启。
提示:很多团队卡在第一步“数据层隔离”,不是技术不行,而是组织惯性。建议从最小可行契约开始:先强制要求所有线上服务的输入JSON Schema必须在Git仓库中定义并版本化,哪怕最初只有
{"user_id": "string", "item_id": "string"}两行。契约的存在本身,就在倒逼数据团队提供稳定接口。
2.2 方案选型背后的血泪教训:为什么不用纯Serverless?
看到“Real World”,很多人第一反应是AWS Lambda或Cloud Functions——事件驱动、自动扩缩、按需付费,听起来完美。但我在金融风控场景踩过深坑:一个实时反欺诈模型,要求端到端P99延迟<150ms。Lambda冷启动平均300ms,加上模型加载(XGBoost模型约80MB,解压+反序列化耗时),实际P99轻松突破500ms,触发业务SLA告警。更致命的是,Lambda的执行内存上限(10GB)和超时限制(15分钟)对大模型推理极其不友好。我们曾有个NLP模型,单次推理需2GB内存,但输入文本长度波动极大,Lambda在处理长文本时频繁OOM,错误日志里全是
Process exited before completing request
。最终方案是折中:用Kubernetes管理的长期运行的gRPC服务(保证低延迟、高内存),但用KEDA(Kubernetes Event-driven Autoscaling)监听Kafka Topic,实现“类Serverless”的弹性——流量低谷时缩容到1个Pod,高峰时自动扩到10个。这样既保住了性能底线,又没浪费资源。选型的核心逻辑从来不是“新技术”,而是
匹配业务SLA的约束条件求解
:你的延迟容忍是多少?数据吞吐峰值多大?模型体积多大?运维团队熟悉什么?Part 4的所有技术选型,都建立在这个务实基础上。
2.3 影响范围远超技术栈:它重构的是协作语言
Part 4的真正威力,往往在技术实现之外。当团队开始实践这套流程,协作模式会悄然改变。以前数据科学家提需求:“我要用户最近7天的点击行为”,数据工程师可能回:“行,我写个SQL给你导出CSV”。现在,这个需求会变成:“请在Feature Store中注册一个名为
user_7d_click_count
的特征,定义其计算逻辑为
COUNT(*) FROM clicks WHERE event_time >= NOW() - INTERVAL '7 days' GROUP BY user_id
,并确保其SLA为T+1小时更新”。前者是模糊的、一次性的、责任不清的;后者是精确的、可复用的、有明确Owner和SLA的。我亲眼见证一个团队实施Part 4后,跨部门会议时间减少了40%——因为大部分“数据口径不一致”、“模型结果对不上”的扯皮,被提前锁死在Feature Store的Schema定义和模型契约里。技术方案的价值,最终要落回到它如何让不同角色用同一种语言对话。Part 4不是给工程师加活,而是给整个数据价值链装上校准器。
3. 核心细节解析与实操要点:把“运行”二字拆解成可触摸的零件
3.1 模型服务化:不止是Flask,更是生命周期管理
把模型包装成API,远不止写个
@app.route('/predict')
那么简单。核心在于
将模型加载、预处理、推理、后处理、监控埋点全部纳入一个可控的生命周期
。我们采用分层架构:
-
底层:模型加载器(Model Loader)
这是第一个防错关卡。它不直接joblib.load(),而是封装成SafeModelLoader类:class SafeModelLoader: def __init__(self, model_path: str, expected_checksum: str): self.model_path = model_path self.expected_checksum = expected_checksum # 模型文件MD5,来自CI/CD流水线 def load(self) -> Any: # 1. 校验文件完整性 actual_checksum = hashlib.md5(open(self.model_path, "rb").read()).hexdigest() if actual_checksum != self.expected_checksum: raise RuntimeError(f"Model checksum mismatch! Expected {self.expected_checksum}, got {actual_checksum}") # 2. 加载并验证接口契约 model = joblib.load(self.model_path) if not hasattr(model, 'predict') or not callable(getattr(model, 'predict')): raise RuntimeError("Model missing required 'predict' method") # 3. 预热:执行一次空预测,触发JIT编译(对PyTorch/TensorFlow尤其重要) try: dummy_input = self._get_dummy_input() # 从模型元数据中读取 _ = model.predict(dummy_input) except Exception as e: raise RuntimeError(f"Model pre-warm failed: {e}") return model这段代码背后是三次血泪教训:一次是模型文件传输损坏导致线上预测全错;一次是模型作者更新了接口但没通知下游;一次是PyTorch模型首次预测慢到超时。
SafeModelLoader把这三道坎全挡住了。 -
中层:推理管道(Inference Pipeline)
predict()方法只是冰山一角。真实世界需要完整的管道:class InferencePipeline: def __init__(self, model_loader: SafeModelLoader, preprocessor: Preprocessor, postprocessor: Postprocessor): self.model = model_loader.load() self.preprocessor = preprocessor self.postprocessor = postprocessor def run(self, raw_input: Dict) -> Dict: # 步骤1:输入校验(契约第一道防线) validation_errors = self._validate_input(raw_input) if validation_errors: raise ValidationError(f"Input validation failed: {validation_errors}") # 步骤2:预处理(标准化、编码、特征工程) processed_input = self.preprocessor.transform(raw_input) # 步骤3:模型推理(带超时保护) try: with timeout(5): # 硬性超时5秒 raw_prediction = self.model.predict(processed_input) except TimeoutError: raise ModelTimeoutError("Model inference timed out") # 步骤4:后处理(概率归一化、阈值决策、可解释性计算) final_output = self.postprocessor.process(raw_prediction) # 步骤5:埋点监控(关键!) self._emit_metrics(raw_input, final_output) return final_output注意
timeout(5)装饰器——这是防止模型卡死拖垮整个服务的保险丝。_emit_metrics会发送inference_latency_ms,prediction_success_rate,input_data_size_bytes等指标到Prometheus,这些不是锦上添花,而是故障定位的唯一线索。 -
顶层:服务框架(Service Framework)
我们弃用裸Flask,选用 FastAPI + Uvicorn 组合。原因很实在:FastAPI的Pydantic模型自动校验,省去90%的手动输入检查代码;其异步支持让I/O密集型预处理(如调用外部API获取用户画像)不阻塞主线程;OpenAPI文档自动生成,让前端工程师不用猜参数。一个典型路由:@app.post("/v1/predict", response_model=PredictionResponse) async def predict(request: PredictionRequest): try: # PredictionRequest是Pydantic模型,自动校验字段类型、必填项 result = pipeline.run(request.dict()) return PredictionResponse(**result) except ValidationError as e: logger.warning(f"Validation error: {e}") raise HTTPException(status_code=400, detail=str(e)) except ModelTimeoutError as e: logger.error(f"Model timeout: {e}") raise HTTPException(status_code=504, detail="Model inference timeout")这里
PredictionRequest和PredictionResponse是严格定义的Pydantic模型,它们就是模型契约的代码化身。任何违反契约的请求,在进入pipeline.run()之前就被拦截,返回清晰的400错误,而不是让模型报一堆晦涩的KeyError。
注意:模型加载必须在Uvicorn worker启动时完成,而非每次请求时。我们通过
on_startup事件钩子实现:@app.on_event("startup") async def startup_event(): global pipeline pipeline = InferencePipeline( model_loader=SafeModelLoader("/models/model.pkl", os.getenv("MODEL_CHECKSUM")), preprocessor=StandardScalerPreprocessor(), # 实例化一次,复用 postprocessor=BinaryClassificationPostprocessor(threshold=0.5) )
3.2 特征一致性:训练与推理的“双胞胎”陷阱
最大的线上事故,往往源于训练时用的特征和推理时用的特征长得像,但细节不同。比如训练时用
pandas.fillna(0)
,推理时用
sklearn.Imputer(strategy='constant', fill_value=0)
,数值上一样,但
fillna(0)
对字符串列会报错,而
Imputer
会静默跳过——模型在训练时“看到”了字符串列,推理时却“看不到”,维度直接错乱。Part 4的解决方案是
特征工厂(Feature Factory)
:一个独立的、版本化的Python包,里面只放特征计算逻辑。
-
特征定义即代码 :每个特征是一个函数,带明确签名和文档:
# features/user_features.py """User-level features computed from user profile table.""" def user_age_days(user_profile: pd.Series) -> float: """ Calculate user's age in days since registration. Input: user_profile Series with 'registration_date' (datetime64) column. Output: float, days since registration. Returns 0 if registration_date is NaT. """ if pd.isna(user_profile['registration_date']): return 0.0 return (pd.Timestamp.now() - user_profile['registration_date']).days def user_total_spent(user_profile: pd.Series) -> float: """..."""这些函数在训练和推理时, 必须使用完全相同的源码版本 。我们通过
pip install git+https://gitlab.com/our-org/features@v1.2.0在训练环境和生产环境统一安装。 -
特征向量构建器(Feature Vector Builder) :训练时,用
FeatureVectorBuilder批量计算所有特征:builder = FeatureVectorBuilder( feature_functions=[user_age_days, user_total_spent, ...], input_table="user_profiles" ) X_train = builder.build(df_users_train) # 返回标准DataFrame推理时,用 完全相同的
builder实例 (注意:不是重写逻辑):# 在服务启动时初始化 feature_builder = FeatureVectorBuilder( feature_functions=[user_age_days, user_total_spent, ...], input_table="user_profiles" # 这里指向实时API或缓存 ) # 在predict()中调用 user_features = feature_builder.build_single(user_profile_dict)关键点在于
build_single()方法——它接收单条记录(字典),内部调用相同的user_age_days函数,确保逻辑100%一致。我们甚至在CI/CD中加入测试:用相同输入,对比build_single()和build()对单条记录的输出,必须完全相等。 -
特征存储(Feature Store)的务实用法 :不要一上来就搞复杂Feature Store。从最简单的开始:用Redis缓存高频、低变更的特征(如用户基础属性),用PostgreSQL存需要事务保证的特征(如用户实时余额)。关键是要有 统一的读取SDK :
# sdk/feature_store.py def get_user_features(user_id: str) -> Dict[str, Any]: # 1. 先查Redis(毫秒级) redis_key = f"user:{user_id}" cached = redis_client.hgetall(redis_key) if cached: return {k.decode(): json.loads(v.decode()) for k, v in cached.items()} # 2. Redis未命中,查PostgreSQL(百毫秒级) row = db.execute("SELECT * FROM user_features WHERE user_id = %s", (user_id,)) if row: # 写入Redis缓存,设置TTL redis_client.hset(redis_key, mapping={k: json.dumps(v) for k, v in row.items()}) redis_client.expire(redis_key, 3600) # 1小时 return dict(row) raise FeatureNotFoundError(f"User {user_id} not found")这个SDK屏蔽了底层存储差异,上层服务只认
get_user_features(),未来换掉Redis或加ClickHouse,只需改SDK内部实现。
3.3 监控与可观测性:没有监控的模型服务就是定时炸弹
上线不是终点,而是监控的起点。Part 4的监控体系分三层,缺一不可:
-
基础设施层(Infrastructure) :CPU、内存、网络IO、磁盘空间。这是Kubernetes自带的,用
kubectl top pods或Grafana看。重点看 内存RSS (Resident Set Size),不是VSS(Virtual Memory Size)——很多模型加载后VSS很大但RSS稳定,这才是真实占用。 -
服务层(Service) :HTTP状态码分布、请求延迟(P50/P90/P99)、QPS、错误率。用Prometheus抓取FastAPI的
/metrics端点(需集成prometheus-fastapi-instrumentator)。关键看http_request_duration_seconds_bucket直方图。我们设置告警规则:rate(http_request_duration_seconds_bucket{le="0.2"}[5m]) / rate(http_request_duration_seconds_count[5m]) < 0.95——意味着过去5分钟,超过95%的请求应在200ms内完成,否则告警。 -
模型层(Model) :这才是Part 4的灵魂。必须监控:
-
数据漂移(Data Drift)
:输入特征的统计分布是否变化?用Evidently AI库,每1000次请求采样一次输入,计算KS检验p值。当
user_age_days的分布从“集中在20-35岁”突然变成“大量60岁以上”,p值<0.01,立刻告警。 -
概念漂移(Concept Drift)
:模型预测结果的分布是否异常?比如分类模型,正常时
class_A_prob均值0.6,突然连续1小时均值降到0.2,可能意味着业务逻辑变了(如促销活动结束)。 -
性能衰减(Performance Decay)
:在线A/B测试或影子模式(Shadow Mode)下,新模型vs旧模型的准确率差值。我们用
/shadow_predict端点,将10%流量同时发给新旧模型,对比结果。
所有这些指标,都通过一个统一的
ModelMonitor类上报:class ModelMonitor: def __init__(self, model_name: str): self.model_name = model_name self.data_drift_detector = DataDriftDetector() def on_inference(self, input_data: Dict, prediction: Dict, latency_ms: float): # 1. 上报基础指标 self._emit_service_metrics(latency_ms, prediction) # 2. 检测数据漂移(采样) if random.random() < 0.001: # 0.1%采样率 self.data_drift_detector.add_sample(input_data) drift_report = self.data_drift_detector.get_report() if drift_report.has_drift(): alert_slack(f"⚠️ {self.model_name} DATA DRIFT DETECTED: {drift_report.details()}") # 3. 记录预测结果用于后续分析 self._log_prediction_to_clickhouse(input_data, prediction, latency_ms)这个类在
InferencePipeline.run()末尾被调用,确保每次推理都有迹可循。没有这层监控,你永远不知道模型是“安静地失效”,还是“热闹地崩溃”。 -
数据漂移(Data Drift)
:输入特征的统计分布是否变化?用Evidently AI库,每1000次请求采样一次输入,计算KS检验p值。当
4. 实操过程与核心环节实现:从零搭建一个可落地的推理服务
4.1 环境准备:用Docker构建可重现的基石
一切始于一个
Dockerfile
,它必须精确到每一个字节。以下是我们生产环境的标准模板(已脱敏):
# 使用官方Python基础镜像,版本锁定
FROM python:3.9.18-slim-bookworm
# 设置工作目录
WORKDIR /app
# 复制requirements.txt并安装系统依赖(Debian系)
COPY requirements.txt .
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender-dev \
&& rm -rf /var/lib/apt/lists/*
# 安装Python依赖(--no-cache-dir避免缓存污染,-c constraints.txt确保版本收敛)
COPY constraints.txt .
RUN pip install --no-cache-dir -c constraints.txt -r requirements.txt
# 复制应用代码(注意:.dockerignore排除.git、__pycache__、local_test_data等)
COPY . .
# 创建非root用户(安全基线)
RUN addgroup -g 1001 -f appgroup && adduser -S appuser -u 1001
# 切换到非root用户
USER appuser
# 健康检查(简单有效:检查端口是否监听)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8000/healthz || exit 1
# 启动命令(Uvicorn,生产配置)
CMD ["uvicorn", "main:app", "--host", "0.0.0.0:8000", "--port", "8000", "--workers", "4", "--limit-concurrency", "100", "--timeout-keep-alive", "5"]
关键细节解析:
-
python:3.9.18-slim-bookworm:选择slim变体减少攻击面,bookworm(Debian 12)比bullseye更新,安全补丁更及时。 绝不使用latest标签 ,那是生产环境的毒药。 -
apt-get install部分:libglib2.0-0等是opencv-python-headless等库的底层依赖,漏掉会导致ImportError: libglib-2.0.so.0: cannot open shared object file。 -
constraints.txt:比requirements.txt更严格。它固定所有传递依赖的版本,例如:
这样即使numpy==1.24.3 pandas==1.5.3 scikit-learn==1.2.2 xgboost==1.7.5pip install -r requirements.txt里只写xgboost>=1.7.0,最终安装的也是1.7.5,杜绝“在我机器上好使”的悲剧。 -
HEALTHCHECK:Kubernetes的存活探针(liveness probe)和就绪探针(readiness probe)都依赖它。--start-period=5s给Uvicorn留足启动时间,避免刚启动就被K8s杀掉。
构建与推送命令(CI/CD中执行):
# 构建时打上Git Commit SHA作为镜像Tag,确保可追溯
IMAGE_TAG=$(git rev-parse --short HEAD)
docker build -t registry.example.com/ml-models/recommender:${IMAGE_TAG} .
# 推送
docker push registry.example.com/ml-models/recommender:${IMAGE_TAG}
这个
IMAGE_TAG
会成为后续Kubernetes Deployment的
image
字段值,也是模型版本的唯一标识。
4.2 Kubernetes部署:用声明式YAML定义服务生命
一个健壮的Kubernetes Deployment YAML,远不止
replicas: 3
这么简单。以下是核心片段(
deployment.yaml
):
apiVersion: apps/v1
kind: Deployment
metadata:
name: recommender-service
labels:
app: recommender
spec:
replicas: 3
selector:
matchLabels:
app: recommender
template:
metadata:
labels:
app: recommender
annotations:
# 注解:记录部署信息,方便审计
deployment.kubernetes.io/revision: "1"
git-commit: "a1b2c3d" # CI/CD注入
spec:
# 强制使用非root用户
securityContext:
runAsNonRoot: true
runAsUser: 1001
fsGroup: 1001
# 资源限制(硬性保障,防止单Pod吃光节点资源)
containers:
- name: recommender
image: registry.example.com/ml-models/recommender:a1b2c3d
imagePullPolicy: Always # 确保拉取最新镜像
# 资源请求与限制(根据压测结果设定)
resources:
requests:
memory: "2Gi"
cpu: "500m"
limits:
memory: "4Gi"
cpu: "1000m"
# 环境变量(敏感信息用Secret挂载)
env:
- name: MODEL_PATH
value: "/models/model.pkl"
- name: MODEL_CHECKSUM
value: "d41d8cd98f00b204e9800998ecf8427e" # MD5 of model.pkl
- name: FEATURE_STORE_URL
value: "redis://redis-feature-store:6379"
# 挂载模型文件和配置
volumeMounts:
- name: model-volume
mountPath: /models
- name: config-volume
mountPath: /app/config
# 存活探针(Liveness Probe):服务挂了就重启
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 60 # 给模型加载留足时间
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
# 就绪探针(Readiness Probe):服务好了才接入流量
readinessProbe:
httpGet:
path: /readyz
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
# 启动探针(Startup Probe):K8s 1.18+特性,解决长启动时间问题
startupProbe:
httpGet:
path: /healthz
port: 8000
failureThreshold: 30 # 允许最多300秒启动(30*10s)
periodSeconds: 10
volumes:
- name: model-volume
persistentVolumeClaim:
claimName: model-pvc # 指向预先创建的PVC,存储模型文件
- name: config-volume
configMap:
name: recommender-config # 配置中心,如超时时间、特征权重
# Pod反亲和性:避免所有副本调度到同一节点
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- recommender
topologyKey: "kubernetes.io/hostname"
这个YAML的每一行都是经验之谈:
-
securityContext:强制非root,是云原生安全基线,K8s集群策略(PodSecurityPolicy或PodSecurityAdmission)会拒绝不合规的Pod。 -
resources.limits.memory: "4Gi":如果模型加载后RSS超过4Gi,K8s会OOMKilled该Pod。这个值必须通过kubectl top pods压测后确定,不能拍脑袋。 -
startupProbe:模型加载(尤其是大模型)可能耗时几十秒。没有它,K8s会在initialDelaySeconds后就开始执行livenessProbe,导致还没加载完就被反复重启。failureThreshold: 30意味着允许最长300秒启动,足够从容。 -
podAntiAffinity:避免单点故障。如果节点宕机,3个副本不会全军覆没。
部署命令(CI/CD中执行):
# 应用YAML(K8s会自动diff并滚动更新)
kubectl apply -f deployment.yaml
# 查看滚动更新状态
kubectl rollout status deployment/recommender-service
# 查看Pod日志(实时)
kubectl logs -l app=recommender --follow
4.3 CI/CD流水线:自动化是可靠性的唯一来源
手工部署是不可持续的。我们用GitLab CI构建端到端流水线,核心阶段如下:
# .gitlab-ci.yml
stages:
- test
- build
- deploy
# 阶段1:测试(保障质量底线)
test:
stage: test
image: python:3.9
script:
- pip install pytest pytest-cov
- pytest tests/ --cov=src/ --cov-report=html
artifacts:
paths:
- htmlcov/
# 阶段2:构建(生成镜像)
build:
stage: build
image: docker:20.10.16
services:
- docker:20.10.16-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- |
IMAGE_TAG=${CI_COMMIT_SHORT_SHA}
docker build -t $CI_REGISTRY_IMAGE:$IMAGE_TAG .
docker push $CI_REGISTRY_IMAGE:$IMAGE_TAG
variables:
DOCKER_DRIVER: overlay2
# 阶段3:部署(推送到K8s)
deploy-prod:
stage: deploy
image: bitnami/kubectl:1.27
before_script:
- mkdir -p ~/.kube
- echo "$KUBE_CONFIG" | base64 -d > ~/.kube/config
script:
- |
# 替换YAML中的镜像Tag
sed -i "s|image:.*|image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA|g" k8s/deployment.yaml
# 应用到生产集群
kubectl apply -f k8s/deployment.yaml
environment:
name: production
url: https://recommender.example.com
only:
- main # 仅main分支触发生产部署
这个流水线的关键设计:
-
测试先行
:
test阶段失败,后续build和deploy直接跳过。我们要求单元测试覆盖率≥80%,且必须包含特征工厂函数的测试(如test_user_age_days()验证NaT输入返回0)。 -
镜像Tag与Git Commit绑定
:
$CI_COMMIT_SHORT_SHA确保每次部署的镜像都能100%追溯到具体代码,回滚就是kubectl set image deployment/recommender-service recommender=registry.example.com/...:old-sha。 -
Kubeconfig安全注入
:
$KUBE_CONFIG是Base64编码的K8s配置文件,存储在GitLab CI Variables中,避免明文泄露。 -
环境隔离
:
only: - main保证只有合并到main分支的代码才能上生产,开发分支的PR只会触发测试和构建,但不部署。
实操心得:CI/CD流水线本身必须版本化!
.gitlab-ci.yml文件和k8s/deployment.yaml都放在Git仓库里,和代码一起评审、一起提交。我见过太多团队把部署脚本存在个人电脑上,结果“紧急修复”后忘了同步,导致下次部署用的还是旧脚本。
4.4 影子模式(Shadow Mode):零风险上线新模型的终极武器
上线新模型最怕什么?不是性能差,而是 不可预知的副作用 ——比如新模型对某个用户群体的预测偏差,导致推荐内容完全偏离业务目标。影子模式就是让新模型“默默旁观”,不参与实际决策,只记录它的预测,与线上旧模型对比。
实现步骤:
-
修改服务代码,增加
/shadow_predict端点 :@app.post("/v1/shadow_predict", response_model=ShadowPredictionResponse) async def shadow_predict(request: PredictionRequest): # 1. 用旧模型(主模型)做真实预测 primary_result = primary_pipeline.run(request.dict()) # 2. 用新模型(影子模型)做预测(不返回给用户) shadow_result = shadow_pipeline.run(request.dict()) # 3. 记录对比日志到专用Topic(如Kafka topic: shadow-comparison) log_payload = { "request_id": str(uuid.uuid4()), "timestamp": datetime.utcnow().isoformat(), "input": request.dict(), "primary_prediction": primary_result, "shadow_prediction": shadow_result, "diff_score": abs(primary_result["score"] - shadow_result["score"]) } kafka_producer.send("shadow-comparison", value=log_payload) # 4. 只返回主模型结果(用户无感知) return ShadowPredictionResponse(**primary_result) -
配置流量镜像 :在K8s Ingress或Service Mesh(如Istio)中,将10%的生产流量复制一份,发往
/shadow_predict端点。关键点是 复制(mirror)而非分流(split) ,主流量仍走/v1/predict,确保用户体验0影响。 -
构建对比分析Dashboard :用Grafana连接Kafka消费
shadow-comparisonTopic,创建仪表盘:-
表格:展示
diff_score > 0.3的Top 10请求(高分歧样本) -
折线图:
avg(diff_score)随时间变化趋势 -
散点图:
primary_scorevsshadow_score,观察是否整体右移(新模型更激进)或左移(更保守)
-
表格:展示
-
制定上线决策规则 :我们约定:
更多推荐



所有评论(0)