Kubernetes 上的 AI 服务——GPU 调度和资源隔离实战
Kubernetes 上的 AI 服务——GPU 调度和资源隔离实战
去年我们把一个推理服务往 K8s 上迁移,踩了将近两个月的坑才算稳定。
不是说 K8s 难,而是 AI 工作负载和普通的 Java 微服务根本不是一回事。一个普通服务挂了,重启一秒钟,用户基本无感。一个推理服务挂了,Pod 重新拉起要加载模型,光预热就要三分钟。GPU 分配不合理,两个任务抢同一块卡,两个都跑得奇慢。更别说滚动更新的时候,新 Pod 还没准备好,旧 Pod 就被杀掉了,请求直接 503。
这篇不是 K8s 入门,我假设你已经会基本操作。我讲的是 AI 工作负载特有的那些坑,以及我们是怎么一步步解决的。
为什么 AI 工作负载和普通服务不一样
先把问题说清楚,才知道为什么要专门讲这个。
GPU 是稀缺资源,不是弹性资源。普通服务的 CPU 和内存,K8s 可以超卖,node 上跑 120% 的 CPU 请求量也不是不行,顶多慢一点。GPU 不行,一块 A100 就是一块,你声明要用 1 块,它就分给你 1 块,别人拿不到。这意味着 GPU 调度的粒度和策略必须精细,否则要么资源浪费,要么任务排队。
模型加载时间长,健康检查必须特殊对待。一个 7B 参数模型,fp16 精度下大约占 14GB 显存,从磁盘加载到 GPU 显存,快的话 30 秒,慢的话 2 分钟以上。如果你的 readinessProbe 设置得和普通服务一样,Pod 刚起来还没加载完模型就被 K8s 判定为不健康,反复重启,永远起不来。
推理请求的资源消耗不均匀。一个 "你好" 和一个 "帮我写一篇 3000 字的技术文章" 消耗的 GPU 计算量相差 10 倍以上。普通服务的 HPA 基于 CPU 使用率扩容,AI 服务用这个指标基本失效,因为 GPU 利用率飙到 100% 的时候请求已经在超时了。
多租户隔离比普通服务要求更严格。一个团队的模型推理任务跑飞了,不能影响另一个团队的服务。这不只是资源限制的问题,还涉及到调度亲和性、命名空间隔离、网络策略等多个维度。
GPU 资源的基本声明方式
先从最基础的讲起。在 K8s 里使用 GPU,需要先安装 NVIDIA Device Plugin,它会把节点上的 GPU 暴露为扩展资源 nvidia.com/gpu。
最简单的声明方式:
resources:
limits:
nvidia.com/gpu: 1注意,GPU 资源只能写在 limits 里,不支持 requests(或者说 requests 必须等于 limits)。这和 CPU、内存不一样,CPU 你可以 request 0.5 核但 limit 2 核,GPU 不行,申请多少就独占多少。
这里有个我当时没注意到的细节:GPU 资源是以整卡为单位的。你不能申请 0.5 块 GPU。如果你的模型只需要 4GB 显存,但节点上只有 24GB 的卡,你要么独占这张卡(很浪费),要么用 MIG(Multi-Instance GPU)或者 MPS(Multi-Process Service)来做显存分片。
MIG 是 NVIDIA A100/A30/H100 系列支持的特性,可以把一块物理 GPU 切割成多个隔离的实例。比如 A100 80GB 可以切成 7 个 10GB 的 MIG 实例,每个实例有独立的计算单元和显存,隔离性好,但配置相对复杂。
配置 MIG 后,节点上会出现类似 nvidia.com/mig-1g.10gb 这样的资源名,Pod 声明时用对应的名字即可。
完整的 GPU 工作负载 Deployment 配置
下面是我们生产环境用的一个推理服务的 Deployment 配置,我把关键点都加了注释:
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-inference-service
namespace: ai-prod
labels:
app: llm-inference
team: ai-platform
model: qwen2-7b
spec:
replicas: 2
selector:
matchLabels:
app: llm-inference
strategy:
type: RollingUpdate
rollingUpdate:
# 关键:AI 服务的滚动更新策略必须保守
# maxSurge=1 先起一个新 Pod,等它完全 Ready 再杀旧 Pod
# maxUnavailable=0 保证整个过程中服务不中断
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: llm-inference
team: ai-platform
model: qwen2-7b
spec:
# 调度到有 GPU 的节点,同时避免同一模型的两个 Pod 调度到同一节点
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: accelerator
operator: In
values:
- nvidia-a100
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- llm-inference
topologyKey: kubernetes.io/hostname
# 容忍 GPU 节点上的 taint,防止普通 Pod 占用 GPU 节点
tolerations:
- key: "nvidia.com/gpu"
operator: "Exists"
effect: "NoSchedule"
# 模型文件通过 PVC 挂载,避免每次启动都重新下载
volumes:
- name: model-storage
persistentVolumeClaim:
claimName: qwen2-7b-model-pvc
- name: shm
emptyDir:
medium: Memory
# PyTorch DataLoader 需要共享内存,默认 64MB 不够
sizeLimit: "2Gi"
initContainers:
# 用 initContainer 做模型完整性校验,失败直接不启动
- name: model-checker
image: busybox:1.35
command: ['sh', '-c', 'test -f /models/config.json && echo "model ok" || exit 1']
volumeMounts:
- name: model-storage
mountPath: /models
containers:
- name: inference-server
image: registry.internal.com/ai/vllm-server:0.4.2
command: ["python", "-m", "vllm.entrypoints.openai.api_server"]
args:
- "--model=/models/qwen2-7b"
- "--host=0.0.0.0"
- "--port=8000"
- "--max-model-len=8192"
- "--gpu-memory-utilization=0.85"
- "--tensor-parallel-size=1"
ports:
- name: http
containerPort: 8000
protocol: TCP
resources:
requests:
cpu: "4"
memory: "16Gi"
limits:
cpu: "8"
memory: "32Gi"
# 声明需要 1 块 GPU
nvidia.com/gpu: "1"
volumeMounts:
- name: model-storage
mountPath: /models
readOnly: true
- name: shm
mountPath: /dev/shm
env:
- name: CUDA_VISIBLE_DEVICES
value: "0"
- name: NCCL_DEBUG
value: "WARN"
# 关键:startupProbe 给模型足够的加载时间
# failureThreshold=40,periodSeconds=15,最多等 600 秒(10分钟)
startupProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 40
successThreshold: 1
timeoutSeconds: 10
# 模型加载完成后,readinessProbe 频率可以高一些
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 3
successThreshold: 1
timeoutSeconds: 5
# livenessProbe 比较宽松,防止长时间推理被误杀
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 0
periodSeconds: 30
failureThreshold: 5
successThreshold: 1
timeoutSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: llm-inference-service
namespace: ai-prod
labels:
app: llm-inference
spec:
selector:
app: llm-inference
ports:
- name: http
protocol: TCP
port: 80
targetPort: 8000
type: ClusterIP
---
# HPA 基于自定义指标,不是 CPU 使用率
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: llm-inference-hpa
namespace: ai-prod
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: llm-inference-service
minReplicas: 1
maxReplicas: 4
metrics:
# 基于队列中等待的请求数扩容,比 CPU 指标更准确
- type: External
external:
metric:
name: llm_pending_requests
selector:
matchLabels:
service: llm-inference
target:
type: AverageValue
averageValue: "5"
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 1
periodSeconds: 120
# 缩容要非常保守,模型服务启动慢
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 300这个配置里有几个点我想展开说一下。
startupProbe 是救命稻草。 在引入 startupProbe 之前,我们一直用 initialDelaySeconds 来给模型加载争取时间,设成 120 秒。问题是,这个值是静态的。有时候节点负载高,模型加载要 3 分钟,120 秒不够,Pod 被重启,进入死循环。startupProbe 的 failureThreshold * periodSeconds 是最长等待时间,只要在这个时间内成功一次就进入正常探活,比 initialDelaySeconds 灵活多了。
GPU 节点的 taint 非常必要。 如果不给 GPU 节点打 taint,普通的 Java 服务可能被调度到 GPU 节点上,占用昂贵的节点资源,而真正需要 GPU 的推理服务反而没有节点可用。我们的做法是给所有 GPU 节点打上 nvidia.com/gpu=present:NoSchedule 的 taint,只有 toleration 里写了对应规则的 Pod 才能被调度上去。
PodAntiAffinity 防止 SPOF。 两个副本调度到同一个节点,节点挂了两个都挂,这是最基本的高可用要求。但对 AI 服务来说还有一个额外的考虑:同一节点上两个推理 Pod 会竞争 GPU 带宽,导致性能下降。
GPU 资源的多级隔离策略
单纯靠 Deployment 配置还不够,我们需要在命名空间层面做资源隔离。
# 为 AI 团队创建独立命名空间,并设置资源配额
apiVersion: v1
kind: Namespace
metadata:
name: ai-prod
labels:
team: ai-platform
---
# ResourceQuota 限制整个命名空间的 GPU 使用量
apiVersion: v1
kind: ResourceQuota
metadata:
name: ai-prod-quota
namespace: ai-prod
spec:
hard:
# 整个命名空间最多用 8 块 GPU
requests.nvidia.com/gpu: "8"
limits.nvidia.com/gpu: "8"
# CPU 和内存也做上限
requests.cpu: "64"
requests.memory: "256Gi"
limits.cpu: "128"
limits.memory: "512Gi"
# 限制 Pod 数量,防止 Pod 爆炸
pods: "50"
---
# LimitRange 确保每个 Pod 都声明了资源,防止有人忘写 limits
apiVersion: v1
kind: LimitRange
metadata:
name: ai-prod-limits
namespace: ai-prod
spec:
limits:
- type: Container
default:
cpu: "2"
memory: "8Gi"
defaultRequest:
cpu: "500m"
memory: "2Gi"
max:
cpu: "16"
memory: "64Gi"
nvidia.com/gpu: "4"
min:
cpu: "100m"
memory: "128Mi"ResourceQuota 和 LimitRange 配合使用,前者管命名空间总量,后者管单个容器上下限。我见过不少团队只做了其中一个,结果要么一个疯狂的 Pod 把整个配额吃光,要么有人写了个没有 limits 的 Pod 在节点上无限膨胀。
模型服务的滚动更新策略
这是我们踩得最深的坑。
普通服务滚动更新:旧 Pod 接着处理请求 → 新 Pod 起来通过 readinessProbe → 流量切到新 Pod → 旧 Pod 优雅关闭。整个过程可能十几秒。
AI 服务滚动更新:旧 Pod 接着处理请求 → 新 Pod 启动,开始加载模型(2-3分钟)→ 新 Pod 通过 readinessProbe → 流量切到新 Pod → 旧 Pod 收到 SIGTERM,正在处理的长请求怎么办?
这里有两个问题:
问题一:长推理请求被中断。 一个生成 2000 字文章的请求可能需要 30-60 秒。旧 Pod 收到 SIGTERM 后,默认 30 秒的 terminationGracePeriodSeconds 到了就强制 SIGKILL。用户的请求直接断掉。
解决方案:
spec:
template:
spec:
# 给足够的优雅退出时间,要比最长请求的预期时间更长
terminationGracePeriodSeconds: 300
containers:
- name: inference-server
lifecycle:
preStop:
exec:
# 收到 SIGTERM 后,先停止接收新请求,等待当前请求完成
command: ["/bin/sh", "-c", "curl -X POST localhost:8000/admin/drain && sleep 10"]同时,推理服务本身要实现 /admin/drain 接口,调用后停止从负载均衡摘除自己,等待队列中的请求处理完。vLLM 本身不支持这个,我们在前面包了一层 nginx,用 nginx 的 upstream_conf 动态摘除。
问题二:新旧版本并存时的 Prompt 兼容性。 如果我们升级了 Prompt 格式或者换了模型,滚动更新期间旧 Pod 用旧 Prompt,新 Pod 用新 Prompt,同一个用户的连续请求可能落到不同的 Pod,得到风格迥异的回复。
这个问题没有完美解法,我们的做法是在应用层用 session sticky,同一会话的请求固定打到同一个 Pod,直到该 Pod 退出。不优雅,但实用。
资源调度的进阶:优先级和抢占
不同的 AI 工作负载优先级不同。在线推理服务不能被离线训练任务抢走资源。K8s 的 PriorityClass 可以处理这个问题。
# 在线推理服务——高优先级
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: ai-inference-high
value: 1000000
globalDefault: false
description: "在线 AI 推理服务,不可被抢占"
---
# 离线训练任务——低优先级
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: ai-training-low
value: 100
globalDefault: false
description: "离线 AI 训练任务,可被高优任务抢占"
preemptionPolicy: Never
---
# 在 Deployment 中引用
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-inference-service
spec:
template:
spec:
priorityClassName: ai-inference-high
# ...其余配置preemptionPolicy: Never 的含义是这个优先级的 Pod 不会去抢占其他 Pod 的资源,只是在调度队列里比更低优先级的 Pod 先被考虑。而高优先级的 Pod(不设 preemptionPolicy 或设为 PreemptLowerPriority)在资源不足时会主动驱逐低优先级 Pod。
这意味着:半夜跑的离线训练任务,如果节点资源不够,会被白天的在线推理服务驱逐。训练任务需要能从断点续训,不然被驱逐就前功尽弃。这是另一个话题,以后单独写。
整体架构
监控和告警
K8s 上的 AI 服务,监控指标要比普通服务多几个维度。
# ServiceMonitor,让 Prometheus 抓取推理服务的自定义指标
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: llm-inference-monitor
namespace: ai-prod
spec:
selector:
matchLabels:
app: llm-inference
endpoints:
- port: http
path: /metrics
interval: 15s
# 抓取超时要设合理,推理服务的 metrics 端点可能比较慢
scrapeTimeout: 10s关键指标:
vllm:gpu_cache_usage_perc:KV Cache 使用率,超过 90% 说明显存快满了vllm:num_requests_waiting:等待队列长度,这是 HPA 的触发指标vllm:request_success_total/vllm:request_failure_total:成功/失败请求数container_gpu_utilization(DCGM Exporter 提供):GPU 计算利用率container_gpu_memory_used_bytes:GPU 显存使用量
告警规则中有几个是一定要设的:GPU 显存超 90% 报警(不是等 OOM),等待队列超过 20 触发扩容告警(HPA 可能跟不上),连续 3 次 health check 失败触发 PagerDuty。
总结
把 AI 服务跑在 K8s 上,和跑普通微服务的核心差异可以归纳为:
资源层面:GPU 是独占资源,要用 nodeAffinity + taint/toleration 精确调度,用 ResourceQuota 做命名空间隔离,用 PriorityClass 区分在线/离线优先级。
生命周期层面:startupProbe 替代 initialDelaySeconds,给足模型加载时间;terminationGracePeriodSeconds 要覆盖最长请求时间;preStop hook 做优雅摘除。
扩缩容层面:HPA 不能用 CPU 指标,要用队列深度或并发请求数等业务指标;缩容策略要比普通服务保守得多,因为启动成本太高。
这些经验是真金白银换来的。现在回头看,最关键的其实是 startupProbe 和 terminationGracePeriodSeconds 这两个参数,解决了我们 90% 的稳定性问题。其他的优化是锦上添花,这两个是必须要做的。
