1. 项目概述:当模型走出Jupyter,真正开始呼吸真实世界空气
“From Notebook to Production: Running ML in the Real World (Part 4)”——这个标题本身就像一句暗号,专为那些在Jupyter里调通了模型、画出了漂亮ROC曲线、却在部署时被生产环境一记闷棍打懵的工程师准备的。它不是讲怎么写loss函数,也不是教你怎么调参,而是直指那个被无数教程刻意绕开的灰色地带:模型从本地开发环境走向真实业务系统之间的那道深沟。我带过十几支AI落地团队,亲眼见过太多项目卡在Part 3之后——模型准确率98%,但上线后API响应延迟飙到8秒,日志里全是OOM错误;特征工程在pandas里跑得飞起,一上Kubernetes就因内存泄漏每小时重启三次;A/B测试方案设计得滴水不漏,结果发现线上流量根本没打到新模型服务,因为Ingress配置漏写了header路由规则。Part 4,就是专门来填这道沟的。它解决的是稳定性、可观测性、可维护性与业务耦合度这四个硬骨头。你不需要是SRE专家,但必须理解为什么一个pip install -r requirements.txt在本地能跑,在Docker里会失败;你不必手写Prometheus exporter,但得知道模型延迟突增500ms时,该先查GPU显存还是查Redis连接池。这篇文章面向所有已经能把模型训出来的从业者——数据科学家、ML工程师、后端开发,甚至技术决策者。它不承诺“一键上线”,但能让你在下次上线前,少踩73%的坑。核心关键词——ML部署、模型服务化、生产环境稳定性、可观测性、CI/CD for ML——每一个都对应着真实世界里凌晨三点的告警电话。
2. 整体架构设计:为什么不能直接把Notebook打包成Docker?
2.1 从“能跑”到“稳跑”的思维断层
很多团队的第一反应是:“既然Notebook里代码能跑,那就用jupyter nbconvert --to script转成.py,再塞进Docker镜像,暴露个Flask端口完事。”我试过,也帮客户救过这样的火。结果?一个看似简单的文本分类服务,在QPS 50时开始出现随机超时,日志里混着ResourceExhaustedError和ConnectionResetError。问题根源不在模型,而在整个架构设计的底层逻辑错位。Jupyter是一个交互式探索环境,它的生命周期是“打开→执行→查看→修改→再执行”,而生产服务是长时驻留、高并发、资源受限的进程。两者对资源管理、错误处理、状态保持的要求天差地别。比如,Notebook里你可能随手import pandas as pd; df = pd.read_csv('data.csv'),这在本地没问题,但在K8s里,如果CSV文件没挂载进容器,服务启动就失败;更糟的是,如果每次预测都重新读取这个1GB文件,内存会指数级增长。真正的生产架构必须回答三个问题:模型如何加载?特征如何计算?请求如何流转?这不是技术选型问题,而是责任边界问题——数据科学家负责模型逻辑,SRE负责基础设施,而ML工程师必须站在中间,定义清楚每一层的输入输出契约。我们最终采用的分层架构是:模型服务层(Model Serving) + 特征服务层(Feature Serving) + API网关层(API Gateway)。模型服务层只做一件事:接收标准化特征向量,返回预测结果。它不碰原始数据,不连数据库,不读文件。特征服务层则独立提供/features/user_id=123这样的接口,由它完成数据拉取、清洗、拼接、缓存。API网关层负责鉴权、限流、协议转换(如把HTTP JSON请求转成gRPC发给模型服务)。这种解耦让每个组件可以独立伸缩、独立升级、独立监控。当模型需要更新时,只需滚动更新模型服务Pod,特征服务完全不受影响;当用户画像数据源变更,只改特征服务,模型代码一行不动。这才是“稳跑”的基础。
2.2 工具链选型背后的血泪教训
工具不是越多越好,而是越少越稳。我们曾在一个金融风控项目里堆了Kubeflow、Seldon、MLflow、Airflow四套系统,结果运维成本远超模型收益。Part 4的核心原则是:用最薄的抽象,覆盖最关键的路径。模型服务层,我们放弃KFServing(现在叫Kserve),选择原生Triton Inference Server。为什么?第一,它原生支持TensorRT、ONNX Runtime、PyTorch、TensorFlow四大后端,模型导出后不用二次封装;第二,它的动态批处理(Dynamic Batching)功能实测将吞吐量提升3.2倍——同一块V100,QPS从120干到385;第三,它自带健康检查端点/v2/health/ready和指标端点/v2/metrics,和Prometheus无缝对接。特征服务层,我们没用Feast或Hopsworks,而是用Python FastAPI自建,原因很实在:业务特征逻辑复杂,涉及实时用户行为流与离线宽表Join,用DSL描述反而增加理解成本,而FastAPI的异步IO和类型提示让开发调试效率极高。API网关层,我们用Traefik而非Nginx,因为它能自动发现K8s Service,且Header路由规则写起来像写Python字典一样直观。这里有个关键细节:所有服务间通信强制使用gRPC而非REST。实测数据显示,在千兆内网环境下,gRPC序列化体积比JSON小62%,反序列化耗时低41%,这对延迟敏感的实时推荐场景至关重要。我们甚至为gRPC加了双向流支持,让客户端能持续推送用户行为事件,服务端实时更新session特征,这是REST根本做不到的。工具链的终极目标不是炫技,而是让“部署一次,稳定半年”成为可能。当你在凌晨收到告警,看到Prometheus图表上triton_inference_request_success_total指标平稳上升,而triton_inference_request_failure_total纹丝不动,那一刻你就懂了什么叫“选对工具”。
2.3 环境一致性:Docker不是万能解药
“在我机器上好好的!”——这是生产环境最常听到的遗言。Docker确实解决了部分问题,但远远不够。我们曾遇到一个经典案例:模型在本地Docker里预测准确率100%,一上测试环境就掉到92%。排查三天,发现是OpenBLAS库版本差异导致矩阵乘法数值误差累积。更隐蔽的是时区问题:Notebook里用pd.Timestamp.now()生成时间戳,本地是CST,K8s集群默认UTC,特征计算中时间窗口偏移了8小时。解决方案必须是端到端的:构建时锁定、运行时隔离、验证时闭环。构建时,我们用docker build --build-arg PYTHON_VERSION=3.9.16 --build-arg TORCH_VERSION=1.13.1+cu117明确指定所有依赖版本,并在Dockerfile里加入RUN pip install --no-cache-dir torch==${TORCH_VERSION} torchvision==0.14.1+cu117 -f https://download.pytorch.org/whl/torch_stable.html,确保二进制包来源唯一。运行时,我们禁用所有宿主机共享(--ipc=private --uts=private --pid=private),并强制设置时区ENV TZ=Asia/Shanghai && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone。验证时,我们构建了一个“黄金测试集”(Golden Dataset):一组固定输入样本和对应的、在开发环境已确认无误的输出结果。每次CI流水线构建完镜像,自动启动容器,用黄金测试集跑一遍,只有全部通过才允许推送到镜像仓库。这个流程看似繁琐,但它把“环境差异”这个玄学问题,转化成了可量化、可自动化的质量门禁。记住,生产环境的稳定性,始于构建镜像那一刻的确定性。
3. 核心细节解析:模型服务化中的五个致命细节
3.1 模型加载:别让初始化耗尽你的内存
模型服务启动慢,90%的原因在加载阶段。一个1.2GB的BERT-base模型,如果用torch.load()直接加载,会触发Python GC频繁扫描,导致启动时间长达47秒。更危险的是,如果多个Worker进程同时加载,内存峰值会瞬间翻倍,触发OOM Killer。正确做法是:预加载+进程复用+内存映射。Triton原生支持模型预热(Model Warmup),我们在config.pbtxt里配置:
dynamic_batching [ max_queue_delay_microseconds: 100000 default_queue_policy [ allow_timeout_override: true ] ]但这只是开始。真正关键的是模型加载策略。我们要求所有PyTorch模型必须导出为TorchScript格式(.pt),而非.pth权重文件。TorchScript是编译后的字节码,加载速度比Python解释器快3倍,且内存占用降低40%。对于TensorFlow模型,则必须用SavedModel格式,禁用tf.keras.models.load_model(),改用tf.saved_model.load(),后者支持延迟加载子图。还有一个隐藏技巧:利用Linux的mmap机制。我们在Dockerfile里添加RUN apt-get update && apt-get install -y libaio1,然后在Triton启动参数中加入--model-control-mode=explicit --load-model=my_model,让Triton用内存映射方式加载模型文件,避免一次性读入内存。实测一个8GB的推荐模型,加载时间从124秒压到19秒,内存峰值从14GB降到6.2GB。这些细节不会写在官方文档首页,但它们决定了你的服务是“随时可扩”,还是“扩一个挂一个”。
3.2 特征计算:实时性与一致性的平衡术
特征服务是生产ML系统的命脉,也是最容易出问题的环节。我们曾有一个电商搜索排序模型,线上A/B测试显示新模型CTR提升12%,但两周后发现老模型的订单转化率反而高3%。根因是特征漂移(Feature Drift):新模型依赖的实时用户点击流特征,在高峰期因Kafka消费者组rebalance,导致特征计算延迟超过5分钟,模型实际用的是过期特征。解决方案不是追求“绝对实时”,而是定义可接受的特征新鲜度SLA(Service Level Agreement)。我们为不同特征设定分级SLA:用户实时行为(点击、加购)SLA为30秒,用户画像(性别、地域)SLA为2小时,商品静态属性(类目、品牌)SLA为24小时。技术上,我们用Flink SQL实现特征计算,关键在于Watermark机制:
CREATE TABLE user_clicks ( user_id STRING, item_id STRING, ts AS PROCTIME() ) WITH ( 'connector' = 'kafka', 'topic' = 'user-clicks', 'properties.bootstrap.servers' = 'kafka:9092' ); -- 设置10秒乱序容忍,Watermark = event_time - 10s SELECT user_id, COUNT(*) AS click_count_1h, HOP_START(ts, INTERVAL '10' SECOND, INTERVAL '1' HOUR) AS w_start FROM user_clicks GROUP BY user_id, HOP(ts, INTERVAL '10' SECOND, INTERVAL '1' HOUR);这样,即使Kafka消息延迟,Flink也会在Watermark推进后触发窗口计算,保证特征产出的确定性。另一个致命细节是特征缓存穿透。当大量请求同时查询一个冷门用户ID,缓存未命中,所有请求直击下游MySQL,瞬间打垮数据库。我们采用“逻辑过期+互斥锁”双保险:缓存value中嵌入expire_time字段,应用层读取时先判断是否逻辑过期;若过期,则尝试获取分布式锁(Redis SETNX),只有一个请求能去DB加载,其他请求等待100ms后重试读缓存。这个方案将MySQL QPS峰值从12000压到230,且用户感知不到延迟。特征服务不是越快越好,而是越稳、越可预期越好。
3.3 请求处理:别让单个坏请求拖垮整台机器
生产环境最怕的不是高并发,而是长尾请求(Tail Latency)。一个恶意构造的超长文本输入,可能让BERT tokenizer卡死30秒,而Triton默认的request timeout是60秒,这意味着这30秒内,该GPU实例无法处理任何其他请求。我们必须在入口处就筑起防线。第一道防线是API网关层的请求整形(Request Shaping)。Traefik配置中,我们为每个模型服务设置独立的maxBodySize和timeout:
http: routers: ml-api: rule: "PathPrefix(`/v1/predict`)" middlewares: - "rate-limit" - "request-validation" service: ml-service middlewares: request-validation: headers: customResponseHeaders: X-Frame-Options: DENY allowedHosts: - "our-domain.com" hostsProxyAllowed: - "10.0.0.0/8" sslRedirect: true stsSeconds: 31536000 rate-limit: rateLimit: average: 100 period: 1s burst: 200第二道防线是模型服务层的输入校验熔断。我们在Triton的Python backend中,为每个模型编写initialize()和execute()钩子。execute()里第一行就是严格校验:
def execute(self, requests): responses = [] for request in requests: # 校验输入长度 input_text = request.get_input_tensor_by_name("INPUT_TEXT") if input_text.shape[0] > 512: # BERT最大长度 raise TritonModelException("Input text too long, max 512 tokens") # 校验数据类型 if not np.issubdtype(input_text.dtype, np.str_): raise TritonModelException("Input must be string array") # 校验batch size if request.size() > 32: raise TritonModelException("Batch size too large, max 32") # ... 正常推理逻辑一旦触发异常,Triton会立即返回400错误,且不消耗GPU资源。第三道防线是K8s的Pod资源硬限制。我们为每个模型服务Pod设置resources.limits.memory: 8Gi和resources.limits.nvidia.com/gpu: 1,并启用oomKillDisable: true,确保单个请求的内存泄漏不会影响其他Pod。这三层防线,让我们的P99延迟稳定在120ms以内,从未发生过因单个坏请求导致的服务雪崩。
3.4 日志与追踪:没有日志的系统等于黑盒
在Notebook里,print()就是你的日志系统。在生产环境,print()是定时炸弹。我们曾用logging.info()记录每个预测的输入,结果日志量每天暴涨2TB,ELK集群直接宕机。生产日志必须遵循结构化、分级、采样三原则。结构化:所有日志必须是JSON格式,包含trace_id、span_id、model_name、input_hash、latency_ms、status_code等字段。我们用OpenTelemetry Python SDK自动注入trace context,并在FastAPI中间件中统一注入:
@app.middleware("http") async def log_requests(request: Request, call_next): start_time = time.time() trace_id = request.headers.get('X-Trace-ID', str(uuid4())) response = await call_next(request) process_time = (time.time() - start_time) * 1000 logger.info({ "event": "request_processed", "trace_id": trace_id, "method": request.method, "path": request.url.path, "status_code": response.status_code, "process_time_ms": round(process_time, 2), "client_ip": request.client.host }) return response分级:INFO级别只记录成功请求摘要;WARN记录特征缺失、缓存未命中等可恢复异常;ERROR只记录模型加载失败、GPU不可用等致命错误。采样:对INFO日志,我们按trace_id % 100 == 0进行1%采样,确保可观测性与存储成本的平衡。最关键的是预测结果日志。我们不记录原始输入输出(隐私与存储双风险),而是记录input_hash = hashlib.md5(json.dumps(input).encode()).hexdigest()和output_summary = {"prob": max(pred), "class": argmax(pred)}。这样既能关联问题请求,又保护了用户数据。当线上出现bad case,运维同学只需提供trace_id,我们就能在Jaeger里完整回溯:API网关→特征服务→模型服务→GPU显存状态,整个链路清晰可见。没有日志的系统,就像没有仪表盘的飞机,你永远不知道它飞得多高,多快,或者即将坠毁。
3.5 模型更新:滚动发布不是“删旧上新”
模型更新是生产环境最高危操作。很多团队的做法是:“停掉旧服务,启动新服务”。这会造成数秒到数十秒的服务中断,对实时交易系统是灾难。真正的滚动发布(Rolling Update)必须满足零停机、可回滚、灰度可控。K8s原生滚动更新只能保证Pod数量,无法保证模型语义一致性。我们的方案是:双版本服务+流量染色+渐进式切流。首先,在K8s中部署两个Deployment:ml-model-v1和ml-model-v2,它们共享同一个Serviceml-model-svc。Service的selector设为app: ml-model,不区分版本。然后,我们用Istio的VirtualService实现基于Header的流量路由:
apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: name: ml-model-router spec: hosts: - ml-model-svc http: - match: - headers: x-model-version: exact: "v1" route: - destination: host: ml-model-svc subset: v1 - match: - headers: x-model-version: exact: "v2" route: - destination: host: ml-model-svc subset: v2 - route: # 默认路由,灰度用 - destination: host: ml-model-svc subset: v1 weight: 90 - destination: host: ml-model-svc subset: v2 weight: 10上线时,我们先将v2流量权重设为1%,观察指标:triton_inference_request_latency_microseconds_bucket{le="100000"}是否突增,triton_inference_request_success_total是否下跌。确认稳定后,每15分钟提升5%权重,直到100%。如果任一指标异常,立即切回100% v1。整个过程无需重启Pod,用户无感。更进一步,我们为每个模型版本生成唯一的model_signature(SHA256 of model files),并在Prometheus中暴露model_version_info{version="v2", signature="a1b2c3..."}指标。这样,当发现v2有问题,回滚不仅是切流量,更是切到已知稳定的签名版本。模型更新不再是赌局,而是可控的科学实验。
4. 实操全流程:从本地开发到K8s集群的12步落地清单
4.1 开发环境准备:让Notebook成为生产代码的起点
第一步,重构Notebook。这不是“重写”,而是“提取契约”。打开你的train.ipynb,删除所有plt.show()、df.head()、!pip install等探索性代码,只保留三部分:数据加载函数、模型定义类、训练循环主函数。例如:
# cell 1: 数据加载 def load_training_data(data_path: str) -> Tuple[np.ndarray, np.ndarray]: """Load and preprocess training data. Returns: (X_train, y_train) as numpy arrays. """ # ... 实际加载逻辑 # cell 2: 模型定义 class TextClassifier(nn.Module): def __init__(self, num_classes: int): super().__init__() self.bert = AutoModel.from_pretrained("bert-base-chinese") self.classifier = nn.Linear(768, num_classes) def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids, attention_mask) return self.classifier(outputs.pooler_output) # cell 3: 训练主函数 def train_model( model: nn.Module, train_loader: DataLoader, epochs: int = 3 ) -> nn.Module: # ... 训练逻辑 return model保存为model.py。然后新建train.py,调用这些函数:
if __name__ == "__main__": X, y = load_training_data("./data/train.csv") model = TextClassifier(num_classes=5) trained_model = train_model(model, create_dataloader(X, y)) # 导出为TorchScript scripted_model = torch.jit.script(trained_model) scripted_model.save("models/text_classifier_v1.pt")这个train.py就是你的生产训练脚本。它必须能在CI流水线里纯命令行运行,不依赖Jupyter。同时,创建requirements.txt,用pipreqs . --force生成,确保只包含真正用到的包。最后,用pre-commit配置Git hooks,强制每次提交前运行black代码格式化和pylint --errors-only静态检查。这一步的价值在于:把“我能跑通”变成“别人也能跑通”。当新同事第一天入职,git clone && pip install -r requirements.txt && python train.py就能复现你的结果,这就是专业性的起点。
4.2 模型服务化:Triton配置的魔鬼细节
第二步,为Triton准备模型仓库。Triton要求严格的目录结构:
models/ └── text_classifier/ ├── 1/ │ └── model.pt # TorchScript模型文件 ├── config.pbtxt └── README.mdconfig.pbtxt是核心,必须精确配置。以下是我们生产环境的真实配置:
name: "text_classifier" platform: "pytorch_libtorch" max_batch_size: 32 input [ { name: "INPUT_IDS" data_type: TYPE_INT32 dims: [ 512 ] }, { name: "ATTENTION_MASK" data_type: TYPE_INT32 dims: [ 512 ] } ] output [ { name: "OUTPUT_LOGITS" data_type: TYPE_FP32 dims: [ 5 ] } ] instance_group [ { count: 2 kind: KIND_GPU } ] dynamic_batching [ max_queue_delay_microseconds: 100000 default_queue_policy [ allow_timeout_override: true ] ] optimization { execution_accelerators [ { gpu_execution_accelerator: [ { name: "tensorrt" parameters: { "precision_mode": "FP16" } } ] } ] }关键点解析:max_batch_size: 32不是随便写的,它等于GPU显存能容纳的最大batch。我们用nvidia-smi监控显存,公式是max_batch_size ≈ (GPU_memory_GB * 0.8) / (model_size_GB + per_sample_overhead);instance_group.count: 2表示每个GPU启动2个模型实例,充分利用GPU并行能力;optimization.execution_accelerators开启TensorRT加速,实测FP16精度下推理速度提升2.1倍。配置完成后,用Triton的model-analyzer工具压测:
model-analyzer -f perf_analyzer_config.yml \ --model-repository ./models \ --export-path ./reports \ --perf-analyzer-path /opt/tritonserver/bin/perf_analyzerperf_analyzer_config.yml定义不同并发数下的测试:
concurrency: [1, 4, 16, 32, 64] duration: 60000 # 60秒生成报告后,找到Latency P95最低且Throughput最高的并发点,这就是你的最优max_batch_size。这个过程不能靠猜,必须用数据说话。
4.3 Docker镜像构建:最小化、安全化、可验证
第三步,构建生产级Docker镜像。我们不用FROM python:3.9,而是用FROM nvcr.io/nvidia/pytorch:23.04-py3,这是NVIDIA官方优化的PyTorch镜像,预装CUDA驱动和cuDNN,大小比通用镜像小40%。Dockerfile关键段落:
FROM nvcr.io/nvidia/pytorch:23.04-py3 # 创建非root用户,提升安全性 RUN groupadd -g 1001 -f app && useradd -r -u 1001 -g app app USER app # 复制模型和配置 COPY models/ /models/ WORKDIR /models # 安装Triton Server(从NVIDIA官网下载) RUN apt-get update && apt-get install -y wget && \ wget https://github.com/triton-inference-server/server/releases/download/v2.34.0/tritonserver2.34.0-jetpack5.1.tgz && \ tar -xzf tritonserver2.34.0-jetpack5.1.tgz && \ rm tritonserver2.34.0-jetpack5.1.tgz # 暴露端口 EXPOSE 8000 8001 8002 # 启动命令 ENTRYPOINT ["/opt/tritonserver/bin/tritonserver"] CMD ["--model-repository=/models", "--strict-model-config=false", "--log-verbose=1"]构建时,用docker build --no-cache --progress=plain -t our-registry/ml-text-classifier:v1 .。--no-cache确保每次都是全新构建,--progress=plain输出详细日志便于排查。构建完成后,立即用docker run --rm -it -p 8000:8000 our-registry/ml-text-classifier:v1本地验证。访问http://localhost:8000/v2/health/ready,返回{"ready":true}才算成功。这一步的产出是一个可验证、可审计、可复现的镜像,它是你交付给运维团队的唯一制品。
4.4 K8s部署:不只是kubectl apply
第四步,K8s部署不是kubectl apply -f deployment.yaml就完事。我们用Helm Chart管理所有模型服务,Chart.yaml定义元信息,values.yaml定义可配置参数:
# values.yaml replicaCount: 2 image: repository: our-registry/ml-text-classifier tag: v1 pullPolicy: IfNotPresent resources: limits: nvidia.com/gpu: 1 memory: 8Gi requests: nvidia.com/gpu: 1 memory: 6Gi service: type: ClusterIP port: 8000templates/deployment.yaml中,关键配置包括:
apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "ml-text-classifier.fullname" . }} spec: replicas: {{ .Values.replicaCount }} selector: matchLabels: {{- include "ml-text-classifier.selectorLabels" . | nindent 6 }} template: metadata: labels: {{- include "ml-text-classifier.selectorLabels" . | nindent 8 }} annotations: # 自动注入OpenTelemetry sidecar sidecar.istio.io/inject: "true" # 配置GPU调度 nvidia.com/gpu: "1" spec: serviceAccountName: {{ include "ml-text-classifier.serviceAccountName" . }} containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" ports: - containerPort: 8000 name: http-triton resources: {{- toYaml .Values.resources | nindent 10 }} # 健康检查 livenessProbe: httpGet: path: /v2/health/live port: 8000 initialDelaySeconds: 60 periodSeconds: 30 readinessProbe: httpGet: path: /v2/health/ready port: 8000 initialDelaySeconds: 30 periodSeconds: 10部署命令是helm upgrade --install ml-text-classifier ./charts/ml-text-classifier --namespace ml-prod --create-namespace -f values-prod.yaml。values-prod.yaml覆盖生产环境特定值,如resources.limits.memory: 12Gi。Helm的优势在于:一次定义,多环境部署。测试环境用values-test.yaml,资源限制宽松;生产环境用values-prod.yaml,启用了所有安全加固。更重要的是,Helm release状态可审计,helm history ml-text-classifier能查到每次发布的具体配置和时间,这是事故复盘的黄金线索。
4.5 CI/CD流水线:自动化不是为了炫技
第五步,搭建端到端CI/CD流水线。我们用GitLab CI,.gitlab-ci.yml定义:
stages: - test - build - deploy test: stage: test image: python:3.9 script: - pip install pytest pytest-cov - pytest tests/ --cov=model --cov-report=html artifacts: paths: [htmlcov/] 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: - | docker build --no-cache \ --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ --build-arg VCS_REF=$CI_COMMIT_SHORT_SHA \ -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG . docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG only: - tags deploy-prod: stage: deploy image: alpine:latest before_script: - apk add --no-cache curl script: - | curl -X POST "https://our-gitlab.com/api/v4/projects/123/pipeline" \ -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \ -d "ref=master" \ -d "variables[HELM_CHART_VERSION]=$CI_COMMIT_TAG" when: manual only: - master关键设计:测试阶段必须通过才能构建;构建阶段只对Git Tag触发,确保只有打标版本才进生产;部署阶段手动触发,由值班工程师确认后执行。流水线不是全自动的,而是“自动验证,人工决策”。每次部署,流水线自动生成Release Note,包含本次变更的git diff摘要、测试覆盖率变化、性能基准对比(如P95延迟从112ms→108ms)。这个Note会自动发到Slack #ml-deploy频道,所有相关方都能看到。CI/CD的终极目标不是快,而是可追溯、可审计、可信任。
5. 常见问题与排查技巧实录:那些凌晨三点教会我的事
5.1 GPU显存泄漏:看不见的内存杀手
现象:模型服务运行24小时后,nvidia-smi显示GPU显存占用从3.2GB缓慢爬升到7.8GB,最终OOM。triton_inference_request_success_total指标开始下跌。
排查思路:
- 首先排除模型代码问题。在Triton Python backend中,添加显存监控钩子:
import torch def execute(self, requests): # 记录执行前显存 pre_mem = torch.cuda.memory_allocated() / 1024**3 # ... 推理逻辑 # 记录执行后显存 post_mem = torch.cuda.memory_allocated() / 1024**3 logger.info(f"GPU mem before: {pre_mem:.2f}GB, after: {post_mem:.2f}GB")- 如果
post_mem > pre_mem,说明有张量未释放。常见原因是torch.no_grad()没包裹推理代码,或model.eval()没调用。 - 更隐蔽的是
torch.tensor()创建的临时张量。我们曾在一个tokenizer里用torch.tensor([1,2,3]),每次调用都创建新张量,GC来不及回收。解决方案是预分配:self._temp_tensor = torch.tensor([1,2,3], device='cuda'),在initialize()中创建,execute()中复用。
根治方案:在Dockerfile中启用CUDA内存池:
ENV CUDA_LAUNCH_BLOCKING=0 ENV PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128max_split_size_mb:128强制PyTorch将大块显存分割为128MB小块,避免内存碎片。实测后,显存占用稳定在3.4GB±0.1GB。
5.2 特征服务延迟突增:Kafka消费者的陷阱
现象:特征服务P95延迟从200ms飙升至8秒,kafka_consumer_records_lag_max指标暴涨。
排查思路:
- 登录Kafka Manager,看consumer group
feature-service-group的lag。如果lag>10000,说明消费不过来。 - 查看Flink JobManager日志,搜索
Checkpoint failed。我们曾发现checkpoint超时,原因是state backend(RocksDB)磁盘