在第 3 章中,你已经学习了如何使用 Docker Model Runner 在本地运行 AI models。你构建了一个 chatbot,它包含一个 React frontend、一个 Go backend,以及一个在后台持续运行、提供 OpenAI-compatible API 的 model。它在你的机器上运行得非常漂亮。
但请想一想,如果十个人同时尝试使用这个 chatbot,会发生什么?或者,当你需要发布一个新的 model version,同时又不能让 service down 掉时,会发生什么?再或者,当你的 teammate clone 了 repo,在自己的 laptop 上运行 docker compose up,然后花了接下来一个小时 debug 为什么 model container 在他的机器上一直 crash、而在你的机器上却不会,这又会发生什么?
这就是 Kubernetes 登场的地方。
Kubernetes 是一个 open-source platform,用于在一组机器组成的 cluster 上运行 containerized workloads。Docker Compose 会在 single host 上 orchestrate containers;而 Kubernetes 会在 many hosts 上 orchestrate containers,同时能够 restart crashed workloads、在 healthy replicas 之间 balance traffic、以 zero downtime rollout new versions,并对 resources 进行治理,确保没有单个 workload 会饿死其他 workloads。
具体到 ML workloads,Kubernetes 解决了一组 Docker Compose 原本就不是为之设计的问题。但我们也要坦诚地说:它确实引入了真实的 complexity。好消息是,你不需要 cloud account,也不需要 server rack,就可以学习它。从 Docker Desktop 4.38 开始,基于 kind 的 Kubernetes 已经直接内置其中。不需要 separate tools。不需要 scripts。只需要在 Docker Desktop settings 中打开一个 toggle。
本章中,你将学习以下内容:
- 为什么 ML 需要 Kubernetes,以及什么时候不应该使用 Kubernetes
- 在 Docker Desktop 中设置 Kubernetes
- 从 ML 视角理解 Kubernetes primitives
- 在 Kubernetes 上部署 Docker Model Runner
- Model containers 的 resource management 和 health probes
- 在 Kubernetes 上扩展 ML workloads
- Kubernetes 上的 ML ecosystem
Technical requirements
为了跟随本章 examples,请确保你的机器上已经完成如下设置:
Software:
Docker Desktop 4.38 或更高版本:从 4.38 开始,基于 kind 的 Kubernetes 作为 built-in feature 被提供。可以从以下地址下载:
https://www.docker.com/products/docker-desktop/
你可以打开 Docker Desktop,点击 gear icon,然后选择 About Docker Desktop 来验证版本。
Hardware:
- 至少 8 GB RAM 的机器,推荐 16 GB。运行 multi-node Kubernetes cluster,同时再运行 Docker Model Runner,会非常 memory-intensive。
- 至少 10 GB free disk space,用于 Kubernetes node images、model files 和 container images。
Knowledge prerequisites:
本章直接建立在第 3 章基础之上。你应该已经熟悉如何运行 Docker Compose applications,理解 multi-service configurations,并能使用 environment variables。不要求你具备任何 Kubernetes 预备知识。所有概念都会从头介绍。
本章代码示例可在以下位置获取:
https://github.com/PacktPublishing/Operational-AI-with-Docker/tree/main/chap-05
Why Kubernetes for ML, and when not to use it
在我们设置任何 cluster 或编写任何 manifest 之前,先回答一个你很可能已经在问的问题:你已经有一个 working Docker Compose stack。为什么还要引入这么多 complexity?
坦率的答案是:Kubernetes 解决 specific problems,并且只有当这些问题对你来说是真实问题时,它才值得承担额外 overhead。下面我们具体走一遍:Docker Compose 做不到什么,Kubernetes 能做到什么,以及它们之间的界线到底在哪里。
图 5.1:核心问题:为什么 Docker Compose 在 scale 上会撞墙,以及 Kubernetes 增加了什么。
What Docker Compose can't do
Docker Compose 的设计目标是 single-machine deployments。这不是批评,而是它的设计。在这个约束范围内,它是一个优秀工具。但一旦你的 requirements 跨过某些 thresholds,它的 hard limits 就会显现出来。让我说明一下这是什么意思。
假设你正在用 Docker Compose 在 production 中运行第 3 章的 chatbot。凌晨 3 点,一个 container crashed。Docker Compose 最终会 restart 它,但如果 host machine 本身 failed,那么一切都会 down 掉。Docker Compose 没有“把 workloads 移动到另一台机器”的概念。它无法把 containers 分布到多个 hosts 上。所有 services 都住在一台机器上,共享这台机器的 CPU 和 memory。
Scaling 呢?在 Docker Compose 中,你会运行:
docker compose up --scale backend=3
然后自己 wire up 一个 load balancer。它能工作,但完全是 manual 的。没有机制可以自动响应 traffic。并且,roll out 一个新的 model version 意味着停止 old container,再启动 new container。中间会有 gap。对于一个用户依赖的 production service 来说,这个 gap 就是问题。
The specific problems Kubernetes solves for ML workloads
Kubernetes 正是为这类场景而构建的。下面是它如何解决前面每个 limitation,并且这些能力如何具体适用于运行 ML inference:
Self-healing:Kubernetes 会持续监控每个 container。当一个 model Pod crash,或者因为某个 generation request 消耗了全部 memory 而停止响应 health checks 时,Kubernetes 会自动在 cluster 中任何可用 node 上替换它,不需要任何 manual intervention。
Multi-node scheduling:Kubernetes 会把 workloads 分布到一组机器上。你可以维护一组配有 GPU 的 nodes,然后让 Kubernetes scheduler 将 model containers 放到任何有 capacity 的 node 上,而不需要 hardcode 哪台机器运行哪个 model。
Automatic scaling:Horizontal Pod Autoscaler 会观察你的 inference service。当 load 增加时,它会添加 replicas;当 load 下降时,它会移除 replicas。你的 cluster 会响应 demand,而不是要求你提前预测 demand。
Zero-downtime deployments:Kubernetes rollout new versions 的方式是先启动 new Pods,并且只有在它们通过 health checks 之后,才会把 traffic 切过去。Old Pods 会一直运行,直到 new Pods 真正 ready。用户不会经历 interruption。
Resource governance:Kubernetes 会对每个 container enforce CPU 和 memory limits。一个 runaway model process 无法吃掉 node 上所有可用 memory,也无法驱逐共享该 node 的所有其他 workloads。
这些都是真实问题。但下面这一点,是大多数 Kubernetes tutorials 会跳过的部分。
When Kubernetes is the wrong choice
我们希望直接说明这一点,这也是为什么要特别提醒你注意它。
When to stay with Docker Compose
如果你正在 active experimentation 中,经常改变 architecture,反复试验 model selection,而且仍然在 figuring out 你到底要 build 什么,请继续使用 Docker Compose。
如果你运行的是一个低流量 internal tool,一台机器可以轻松承载,请继续使用 Docker Compose。
如果你的 team 很小,而 Kubernetes 的 operational overhead 会比它的 features 带来的帮助更拖慢你,请继续使用 Docker Compose。
Kubernetes 只有在它所解决的问题确实是你拥有的问题时,才值得承担其 complexity。只有当 requirements 需要时再引入它,不要提前引入。
第 3 章的 chatbot 实际上并不需要 Kubernetes。我们只是把它作为一个 familiar codebase,用来学习 migration patterns,而不是因为它真的需要一个 cluster。在你完成这些 examples 时,请始终记住这种坦诚。
现在,我们已经坦诚说明了 Kubernetes 什么时候适合、什么时候不适合。接下来,让我们从你已经熟悉的 Docker Compose,构建一张清晰的 mental map,映射到你即将在 Kubernetes 中使用的内容。每个 Docker Compose concept 都有一个 direct equivalent。这是 translation,不是 reinvention。
From Docker Compose to Kubernetes: the mental map
下面是你贯穿本章都会参考的 translation table。
| Docker Compose concept | Kubernetes equivalent | Purpose |
|---|---|---|
service: | Deployment + Service | 运行一个 containerized workload,并将其 expose 出来 |
ports: | Service(ClusterIP) | 为 internal traffic 提供 stable network endpoint |
environment: | ConfigMap / Secret | 将 configuration 传递给 containers |
volumes: | PersistentVolumeClaim | 将 durable storage attach 到 container |
depends_on: | Readiness probes / init containers | 控制 startup order |
restart: always | Deployment self-healing | 自动保持 workloads 运行 |
deploy.replicas: | Deployment 中的 spec.replicas | 运行多个 instances |
networks: | Services + NetworkPolicy | 控制 containers 如何通信 |
表 5.1:Docker Compose 与 Kubernetes concepts 的并排 mental map
你已经理解这些东西都是做什么的。你现在只是要学习它们的新名字和新的 YAML structure。从第 3 章的 Compose file 迁移到 Kubernetes manifests,几乎就是用这张表进行 find-and-replace。
在开始部署任何东西之前,你需要一个 Kubernetes cluster 来部署。过去,要在本地运行一个 Kubernetes cluster,意味着你需要单独安装 Minikube 或 kind command-line tool,编写 configuration scripts,并花时间 troubleshooting。现在 Docker Desktop 已经替你处理了这些。让我们把它打开。
Setting up Kubernetes in Docker Desktop
Docker Desktop 4.38 引入了 kind 作为内置 Kubernetes provisioner,这是相对于过去体验的一次巨大 quality-of-life improvement。你可能记得旧版 Docker Desktop Kubernetes 是一个 single-node cluster,启动慢,也无法模拟 multi-node behavior。Kind provisioner 用一个真正的 multi-node cluster 替代了它,这个 cluster 以 Docker containers 形式运行,并且不需要任何额外安装。
为什么 multi-node 重要?Single-node cluster 会把 control plane 和所有 workloads 都跑在同一台机器上。这对 basic experiments 可以工作,但它隐藏了一类只有在 real clusters 中才会出现的问题:Pod scheduling across nodes、node-level resource pressure,以及某个 node unavailable 时的 failure scenarios。Kind provisioner 让你可以在自己的 laptop 上拥有多个 worker nodes。
Docker Desktop 中基于 kind 的 Kubernetes 有两个 requirements。漏掉任何一个,settings panel 中都不会出现相应选项:
- 登录你的 Docker account。点击 Docker Desktop 右上角 icon。如果你看到 Sign In,现在就登录。如果没有账号,可以在以下地址免费注册:
https://hub.docker.com
2. 启用 containerd image store。导航到 Settings > General,找到 Use containerd for pulling and storing images。它应该被勾选。近期版本的 Docker Desktop 默认如此,但继续之前值得确认一下。
Why does kind require a Docker account
kind 本身完全不需要 Docker account。它会从 Docker Hub pull kindest/node image,这是一个 public image,可以匿名 pull。你可能会被 Docker Desktop 提示登录,这与 Docker Desktop 自身的 subscription 和 sign-in requirements 有关,但 kind 本身不需要 authentication 才能运行。
Step 1: Enable kind-based Kubernetes
打开 Docker Desktop,点击右上角的 gear icon,进入 Settings。
图 5.2:在 Docker Desktop dashboard 下点击 Settings
在左侧 sidebar 中点击 Kubernetes。
图 5.3:在 Docker Desktop 中启用 Kubernetes Settings tab
你会看到一个 Enable Kubernetes toggle,下面还有一个用于选择 provisioner 的 section。选择 kind。
图 5.4:在 Docker Desktop 中启用 Kubernetes settings tab,以配置 Kubernetes cluster
接下来,把 Kubernetes version 设置为你偏好的版本。这里我们选择的是 1.34.3。
图 5.5:选择 preferred Kubernetes version
你还会看到一个 worker nodes 数量控制项。将其设置为 2。这会给你一个 control-plane node 和两个 workers,足以让你在本章中观察真实的 multi-node scheduling behavior。
勾选 Enable Kubernetes,然后点击 Apply & Restart。
最后,点击 Install 来设置 multi-node Kubernetes cluster。
图 5.6:点击 Install 来设置 multi-node Kubernetes cluster
Docker Desktop 会下载 kind node images,并 bootstrap cluster。第一次运行需要几分钟。后续 reset 会使用 cached images,完成速度会快得多。你会在 Docker Desktop interface 中看到 progress indicator。
Step 2: Verify the cluster is ready
Docker Desktop restart 完成后,打开 terminal,运行:
kubectl get nodes
你会看到如下结果:
kubectl get nodes
NAME STATUS ROLES AGE VERSION
desktop-control-plane Ready control-plane 2m39s v1.34.3
desktop-worker Ready <none> 2m25s v1.34.3
三个 nodes,全部显示 Ready。Context 会自动设置为 docker-desktop。你可以用以下命令确认:
kubectl config current-context
你会看到如下结果:
docker-desktop
让我们拆解一下这意味着什么。kubectl config current-context command 会打印当前 kubectl 指向的 Kubernetes cluster 名称。docker-desktop 这个值告诉你:从现在开始,你运行的每一个 kubectl command 都会面向本地 Docker Desktop cluster,而不是某个 remote cluster。如果你之前用过 Kubernetes,并配置过其他 contexts,那么这个 command 就是用来确认你是否处在正确位置的方法。
你的 cluster 已经在运行了。现在确保本章中创建的 resources 不会与 cluster 中其他正在运行的东西发生 collision。
Step 3: Create a namespace
Kubernetes 使用 namespaces 隔离 resource groups。可以把 namespace 想象成一个 folder:它会把本章中的 resources 与 system components 或你可能已经在 cluster 上运行的其他内容分开。
创建一个名为 ai-app 的 namespace:
kubectl create namespace ai-app
你会看到如下结果:
namespace/ai-app created
验证它是否已创建:
kubectl get namespaces
你会看到类似结果:
NAME STATUS AGE
default Active 5m
kube-system Active 5m
kube-public Active 5m
kube-node-lease Active 5m
ai-app Active 3s
让我们拆解一下这些 namespaces 的作用。
kube-system namespace 包含 Kubernetes internal components,也就是 API server、scheduler 和 controller manager。default namespace 是当你没有指定 namespace 时,resources 默认落入的位置。ai-app namespace 是我们的:它把本章的工作 cleanly isolated,并且当你完成后,可以用一个 command 轻松删除。
现在 namespaces 已经准备好。开始部署 workloads 之前,我们退一步,确认 cluster 本身健康。
Checking cluster health visually
打开 Docker Desktop,点击左侧 sidebar 中的 Kubernetes。你会看到一个 live dashboard,它会一眼展示 running Pods、namespaces 和 cluster health。这是配合 kubectl commands 的一个 useful sanity check。
图 5.7:Kubernetes dashboard,显示 active cluster
现在,你已经有一个完全运行在 Docker Desktop 内部的 two-node Kubernetes cluster。不需要 cloud account。不需要 separate tools。没有安装额外工具。不过,在你把任何东西部署到它之前,需要理解 Kubernetes 用来描述 workloads 的六个 building blocks。下面我们走一遍它们,并把每一个映射回你已经熟悉的 Docker Compose。
Kubernetes primitives through an ML lens
Kubernetes 有一个很大的 API surface。官方文档列出了 50 多种 resource types。好消息是,部署 ML workloads 用到的只是其中一个小而聚焦的 subset。本节只介绍你实际会使用的 objects,并且把每一个都作为你已经在 Docker Compose 中用过的东西的 direct counterpart 来呈现。
不为理论而理论。只讲你需要的六个 objects,并用你已经理解的语言解释。
Pod: The atom of Kubernetes
Pod 是 Kubernetes 可以 schedule 和 run 的最小单元。它包含一个或多个 containers,这些 containers 共享一个 network interface 和 storage volumes。实践中,大多数 Pods 只包含一个 container:你的 model container、backend service,或 React frontend。
你很少会直接创建 Pods。Deployment 这样的 higher-level objects 会替你创建并管理它们。但你需要理解 Pods,因为当出问题时,你 inspect 的就是它们:你会读取 Pod logs、describe Pod events,并 exec into Pod containers 进行 debug。
Docker Compose equivalent:没有直接的一对一映射。Pod 大致对应于某个 service 中的一个 running container instance。
Deployment: The service definition
Deployment 是 Kubernetes 中与 docker-compose.yaml 里的 service: block 对应的东西。它声明要运行哪个 container image、保持多少 replicas alive,以及失败时该怎么做。Deployment controller 会持续 reconcile actual cluster state,使其朝着你声明的 desired state 收敛。Pod crash 了?Controller 会创建一个新的。你改变 replica count?Controller 会立即添加或移除 Pods。
Compose equivalent:Deployment 直接映射到 docker-compose.yaml 中的 service: block。
Service: Stable networking
Pods 是 ephemeral 的。每当 Kubernetes 替换它们时,它们都会获得新的 IP address。如果其他 containers 需要一个 stable address 来发送 requests,这就是问题。Kubernetes Service 会位于你的 Pods 前面,提供一个固定 DNS name 和 virtual IP,并把 traffic route 到当前 healthy 的 Pods。
在第 3 章中,你的 chatbot 的 Go backend 通过 Docker Compose service name 访问 Docker Model Runner:
http://model-runner:8080
在 Kubernetes 中,它会使用完全相同的 pattern:Service name 作为 hostname,并由 cluster 内部的 Kubernetes DNS 自动解析。概念完全相同,只是 YAML 看起来不同。
Compose equivalent:Service 对应于 Docker Compose 中用于 internal DNS 的 service name,也就是其他 services 用来访问彼此的名称。
ConfigMap: Non-sensitive configuration
ConfigMap 以 key-value pairs 的形式存储 configuration data,并且与 container image 分离。Environment variables、config files 和 command-line arguments 都属于 ConfigMaps。这可以让你的 images 保持 general-purpose,而 configuration 则保持 environment-specific。
Compose equivalent:ConfigMap 对应 Compose service 中的 environment section。
Secret: Sensitive configuration
Secret 类似 ConfigMap,但用于 sensitive data,例如 API keys、database passwords、tokens。Kubernetes 会对 Secret values 进行 encoded storage,并对其应用 access controls。永远不要把 credentials 放进 ConfigMap,也不要直接写进 manifest YAML file。第 6 章中我们会学习不要在 MCP configuration files 中 hardcode credentials。同样的原则也适用于这里,原因完全相同。
Never put real credentials in manifest files.
如果你把包含 API key 的 manifest file commit 到 version control,那么即使之后删除文件,这些 credentials 仍然会永久存在于 Git history 中。Attackers 会主动扫描 public repositories 中暴露的 secrets。一个 leaked GitHub token 或 database password 可能在几分钟内造成 compromised system。始终使用 Secrets,并通过 name 引用它们。
PersistentVolumeClaim: Durable storage
Model files 很大。一个 quantized 3B parameter model 大约 2 GB。当 Pod restart 时,它的 container filesystem 会被销毁。如果没有 durable storage,每次 Pod restart 都会从头重新下载 model。PersistentVolumeClaim(PVC)会向 cluster 请求一块 storage,这块 storage 独立于 Pod lifecycle 持续存在。
Compose equivalent:PVC 对应 docker-compose.yaml 中的 named volume,也就是那种在 container restarts 之间保持存在、不会被 docker compose down -v 清除的 volume。
这些就是你贯穿本章会使用的六个 objects。现在让它们工作起来。你已经有一个 running cluster,也知道 building blocks。现在进入 concrete part:把第 3 章的 chatbot 一块一块部署到 Kubernetes 上。
Deploying Docker Model Runner on Kubernetes
这是本章的核心实践部分。前面的所有 sections 都是 groundwork。现在你要应用它们。你会拿到第 3 章中为 chatbot 提供服务的同一个 Docker Model Runner,也就是 same image、same API、same endpoint,并把它作为 proper Kubernetes workload 部署。然后添加 Go backend 和 React frontend,最终让完整 chatbot stack 在 kind cluster 上运行。
最终 architecture 如下。图 5.8 展示了 target state:React frontend 和 Go backend 分别以 Deployments 和 Services 形式运行,并通过 Docker Model Runner Deployment 的 Service 与之通信,也就是第 3 章 Go backend 使用的同一个:
http://docker-model-runner:8080
现在它会由 Kubernetes DNS 在 cluster 内解析。Model files 存放在 PersistentVolume 上,因此能在 Pod restarts 后保留下来。Non-sensitive configuration 放入 ConfigMap;credentials 放入 Secret。
图 5.8:第 3 章 chatbot 迁移到 Kubernetes。每个 Compose service 都变成一个 Deployment 和 Service,model files 存放在 PersistentVolume 中,configuration 被拆分为 ConfigMap 和 Secret。
让我们一步一步构建。你必须从 storage 开始,因为如果不先设置 storage,每次 Pod restart 都会从头重新下载 model。相信我,你不会希望 Kubernetes 凌晨 3 点 reschedule 一个 Pod 时,还要等 10 分钟重新下载 model。
Step 1: Create the model storage
创建一个名为 storage.yaml 的文件:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: model-storage
namespace: ai-app
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
让我们拆解这个 file 做了什么:
apiVersion: v1line 标识这是一个 core Kubernetes resource。kind: PersistentVolumeClaimline 声明 resource type。
在 metadata 下:
name: model-storage给 PVC 一个名称,后面 Deployment 中会引用它。namespace: ai-app将它放入你前面创建的 namespace 中。
在 spec 下:
accessModes: ReadWriteOnce表示这个 volume 可以被 single node 以 read-write 方式 mount。这对 model files 来说是正确模式。storage: 5Gi请求 5 GB storage,足够大多数 quantized models。
应用它:
kubectl apply -f storage.yaml
你会看到如下结果:
persistentvolumeclaim/model-storage created
在你的 Docker Desktop kind cluster 上,Kubernetes 会使用某个 kind node 上的 local storage 自动满足这个 claim。在 cloud cluster 中,它会 provision 一个 managed disk。关键点是:你的 application manifest 在两种 environments 中完全相同。这种 portability 正是 Kubernetes 的承诺。
现在 application 已经有地方存储数据了。下一块 puzzle 是告诉它该如何 behave,以及它是谁——也就是 configuration 和 credentials。
Step 2: Store configuration and credentials
创建一个名为 config.yaml 的文件:
apiVersion: v1
kind: ConfigMap
metadata:
name: dmr-config
namespace: ai-app
data:
MODEL_NAME: "llama3.2:3B-Q4_K_M"
DMR_HOST: "0.0.0.0"
DMR_PORT: "12434"
---
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
namespace: ai-app
type: Opaque
stringData:
ANTHROPIC_API_KEY: "your-api-key-here"
让我们拆解这个 file 做了什么:
- ConfigMap 会在
data:下以 plain key-value pairs 存储 non-sensitive values:model name、host 和 port。 - Secret 会在
stringData:下存储 API key。Kubernetes 在持久化时会自动将其 base64-encode。 - 单独一行的三个 dashes(
---)用于在一个 YAML file 中分隔多个 resource definitions,这是本章 repository 中你会反复看到的常见 Kubernetes pattern。
应用它:
kubectl apply -f config.yaml
你会看到如下结果:
configmap/dmr-config created
secret/app-secrets created
Replace the placeholder key before applying.
stringData field 中包含 placeholder。运行 kubectl apply 之前,请用你的 actual API key 替换它。对于 production deployments,请使用 HashiCorp Vault 或 cloud provider 的 secret manager 等 secrets management solution,而不要把 credentials 存储在可能被误 commit 的 files 中。
Step 3: Deploy Docker Model Runner
创建一个名为 dmr-deployment.yaml 的文件:
apiVersion: apps/v1
kind: Deployment
metadata:
name: docker-model-runner
namespace: ai-app
spec:
replicas: 1
selector:
matchLabels:
app: docker-model-runner
template:
metadata:
labels:
app: docker-model-runner
spec:
initContainers:
- name: changeowner
image: busybox
command: ["sh", "-c", "chmod a+rwx /models"]
volumeMounts:
- name: model-files
mountPath: /models
containers:
- name: model-runner
image: docker/model-runner:latest
ports:
- containerPort: 12434
env:
- name: DMR_ORIGINS
value: "http://localhost:31245,http://localhost:12434"
envFrom:
- configMapRef:
name: dmr-config
volumeMounts:
- name: model-files
mountPath: /models
resources:
requests:
memory: "4Gi"
cpu: "1000m"
limits:
memory: "8Gi"
cpu: "4000m"
readinessProbe:
httpGet:
path: /v1/models
port: 12434
initialDelaySeconds: 60
periodSeconds: 10
failureThreshold: 6
livenessProbe:
httpGet:
path: /v1/models
port: 12434
initialDelaySeconds: 120
periodSeconds: 30
failureThreshold: 3
- name: model-init
image: curlimages/curl:8.14.1
envFrom:
- configMapRef:
name: dmr-config
command: ["/bin/sh", "-c"]
args:
- |
set -ex
MODEL_RUNNER=http://localhost:12434
echo "Pre-pulling models..."
if [ -n "$MODEL_NAME" ]; then
echo "Pulling model: $MODEL_NAME"
curl -d "{"from": "$MODEL_NAME"}" "$MODEL_RUNNER"/models/create
fi
echo "Model pre-pull complete"
tail -f /dev/null
volumeMounts:
- name: model-files
mountPath: /models
volumes:
- name: model-files
persistentVolumeClaim:
claimName: model-storage
让我们拆解这个 file 做了什么:
spec.selector.matchLabels 和 template.metadata.labels sections 必须都包含:
app: docker-model-runner
这就是 Deployment controller 识别自己拥有哪些 Pods 的方式。如果两者不匹配,Kubernetes 会用 validation error 拒绝 manifest。
envFrom: section 会把 dmr-config ConfigMap 中的每个 key-value pair 注入 container,作为 environment variable。这是 Docker Compose 中 env_file: 的直接 Kubernetes equivalent:clean、没有 duplication,并且 ConfigMap 可以独立于 Deployment 更新。
resources: section 是 Docker Compose 没有 direct equivalent 的东西。requests values 告诉 scheduler 这个 Pod 运行所需的 minimum resources——如果某个 node 无法提供至少 4Gi memory 和 1 CPU core,这个 Pod 就不会被放到该 node 上。limits values 则限制 container 可以消耗的最大资源。我们下一节会深入讨论这两者,因为第一次 ML deployment 中最常见的 mysterious failures,往往就是它们配置错导致的。
readinessProbe 和 livenessProbe sections 对 model containers 来说至关重要,它们值得专门讲解。现在先注意一点:initialDelaySeconds: 60 会给 model runner 完整 60 秒时间来开始加载 model,然后 Kubernetes 才会开始 health-checking 它。
volumeMounts section 会把名为 model-files 的 volume mount 到 container 内的 /models。底部的 volumes section 则把这个 name 映射到第 1 步创建的 model-storage PVC。
Deployment 本身到这里就覆盖完了。Puzzle 的下一块是 networking:cluster 中其他 Pods,以及最终 cluster 外部 clients,究竟如何找到并连接到 model runner?
Step 4: Expose the model runner with a service
将下面内容添加到 dmr-deployment.yaml 底部,并用三个 dashes 分隔:
---
apiVersion: v1
kind: Service
metadata:
name: docker-model-runner
namespace: ai-app
spec:
selector:
app: docker-model-runner
ports:
- port: 12434
targetPort: 12434
让我们拆解这个 Service 做了什么。
selector: app: docker-model-runner field 会匹配 Deployment 的 Pods 上的 label。Kubernetes 会将所有发往这个 Service 的 traffic,route 到当前带有该 label 且已经通过 readiness probe 的 Pods。
port: 12434 和 targetPort: 12434 fields 表示:到达 Service 12434 端口的 traffic,会被 forward 到 matching Pods 上的 12434 端口。
ai-app namespace 中的其他 services 可以通过以下地址访问 containerized Model Runner:
http://docker-model-runner:12434
在 Docker Desktop kind clusters 上,Go backend 被配置为使用 host DMR:
http://host.docker.internal:12434
请参见后面的 note。
Step 5: Deploy the full chatbot stack
本章 repository 包含 React frontend 和 Go backend 的完整 manifests。它们遵循与 Docker Model Runner deployment 相同的 pattern:每个组件各有一个 Deployment 和一个 Service。Directory structure 如下:
chap-05/
manifests/
storage.yaml # PVC for model files
config.yaml # ConfigMap and Secret
dmr-deployment.yaml # Model Runner Deployment + Service
backend-deployment.yaml # Go backend Deployment + Service
frontend-deployment.yaml # React frontend Deployment + Service
Three things to know before applying on Docker Desktop
第一,go-backend 和 react-frontend images 是本地构建的。它们不存在于任何 registry 中。当 tag 是 latest 时,Kubernetes 默认总是尝试从 registry pull images。请在 backend 和 frontend Deployments 中都添加:
imagePullPolicy: Never
这会告诉 Kubernetes 使用 local image cache。
第二,Docker Desktop 的 kind worker nodes 与 host Docker daemon 维护的是 separate image store。通过 docker build 构建出来的 image 并不会自动对 kind cluster 可见。应用 manifests 之前,你需要将它加载到 worker node 的 containerd store:
docker save go-backend:latest | docker exec -i desktop-worker ctr -n k8s.io images import -
docker save react-frontend:latest | docker exec -i desktop-worker ctr -n k8s.io images import -
第三,containerized Docker Model Runner 目前还不支持通过其 API pull model。Models 必须在 host 上用 docker model pull 管理。因此 Go backend 被配置为通过以下地址访问 host DMR:
http://host.docker.internal:12434/engines/v1
这个地址可以从 cluster 中每个 Pod 通过 Docker Desktop 的 host-gateway DNS 访问。请先 pull 你的 model:
docker model pull ai/smollm2:360M-Q4_K_M
用一个 command 应用所有内容:
kubectl apply -f manifests/
让我们拆解这个 command 做了什么。
kubectl apply command 会根据 manifest files 创建或更新 resources。
带 trailing slash 的 -f manifests/ argument 告诉 kubectl 处理 manifests/ directory 中的每个 YAML file。
Kubernetes 会读取每个 file,并开始 reconcile cluster,使其朝 declared state 收敛。
你会看到如下结果:
persistentvolumeclaim/model-storage created
configmap/dmr-config created
secret/app-secrets created
deployment.apps/docker-model-runner created
service/docker-model-runner created
deployment.apps/go-backend created
service/go-backend created
deployment.apps/react-frontend created
service/react-frontend created
现在观察 Deployments 达到 ready state:
kubectl get deployments -n ai-app --watch
你会看到类似下面的 output,并且它会随着 Pods 启动而 live update:
NAME READY UP-TO-DATE AVAILABLE AGE
go-backend 1/1 1 1 6s
react-frontend 1/1 1 1 6s
docker-model-runner 0/1 1 0 6s
docker-model-runner 1/1 1 1 78s
注意 go-backend 和 react-frontend 几秒内就达到 READY 1/1。而 docker-model-runner 需要超过一分钟。这是因为 model 正在加载到 memory 中。你配置的 readiness probe 正在按预期工作:它会在 model 真正 ready 之前阻止 traffic 进入该 Pod。按 Ctrl + C 停止 watching。
通过 port-forward 访问 application:
kubectl port-forward svc/react-frontend 3000:3000 -n ai-app
让我们拆解这个 command 做了什么。
kubectl port-forward command 会从你的 local machine 到 cluster 内部创建一个 tunnel。svc/react-frontend argument 指定要 forward requests 到哪个 Service,并开始 port-forwarding。3000:3000 argument 将 laptop 上的 3000 端口映射到 Service 的 3000 端口。当这个 command 正在运行时:
http://localhost:3000
会访问 Kubernetes 内部运行的 React frontend。
在 browser 中打开:
http://localhost:3000
Chatbot 正在运行——同一个第 3 章 application,现在由 Kubernetes 管理,并跨两个 worker nodes 运行。
那么刚刚发生了什么?你应用了五个 YAML files,然后获得了一个运行在 Docker Desktop 内部两个 worker nodes 上的 three-service application。Kubernetes scheduled 每个 container,为每个 Service 创建 internal DNS entries,使它们可以通过 name 互相访问,并且在 readiness probe 确认 model loaded 之前,阻止 traffic 流向 model runner。从 browser 看起来,它与第 3 章 Docker Compose version 完全相同,但底层 infrastructure 现在已经是 self-healing、distributed across nodes,并且 ready to scale。
你已经让 chatbot 运行在 Kubernetes 上了。但还有一件事:在 DMR manifest 中,我们故意略过了一个 critical piece——resource limits 和 health probe settings。把这些配错——大多数人在第一次 ML deployment 时都会配错——你的 Pods 会进入一个看起来完全 mysterious 的 crash loop。Container 本身并没有 crash。只是 Kubernetes 没有给它足够时间加载 model。下面我会展示到底发生了什么,以及如何避免。
Resource management and health probes for model containers
Model containers 的 resource profiles 与 typical web services 真正不同。一个 Node.js API 可能不到一秒就启动,并且 steady state 只使用 100 MB memory。一个 model container 可能需要 60 到 90 秒加载,并在回答第一个 request 之前就消耗 4 到 8 GB memory。Kubernetes default probe timings 是为前者校准的。如果不修改就应用到后者,会产生非常难以诊断的 failures。
本节覆盖最容易出问题的两个 configuration areas:resource requests and limits,以及 health probe timing。理解这两者,可以帮你省下几个小时痛苦 debug。不理解它们,你肯定会吃苦头——我保证。
Resource requests and limits
每个 ML Pod 都应该声明 resource requests 和 limits。下面是每个 field 精确控制的内容:
requests:Kubernetes 为这个 Pod 保证的 minimum resources。Scheduler 使用 requests 来决定哪个 node 可以 host 这个 Pod。如果 cluster 中没有任何 node 至少拥有这么多 available resources,那么 Pod 会无限期停留在 Pending state——不是 crashed,只是在等待不存在的 capacity。
limits:Container 被允许消耗的 maximum resources。如果 container 超过 memory limit,Kubernetes 会立即杀掉它,并且你会在 Pod events 中看到 OOMKilled。如果它超过 CPU limit,Kubernetes 会 throttle 它,但允许它继续运行。
对于 inference workloads,请根据 model actual loaded size 设置 memory requests。成功部署后,可以运行:
kubectl top pod -n ai-app
来测量这个值。用测量出的 number 作为 request,并把 limit 设置得更高一些,为 inference spikes 留出 headroom。
resources:
requests:
memory: "4Gi" # Minimum guaranteed set this to measured loaded
# size
cpu: "1000m" # 1 CPU core (1000 millicores = 1 core)
limits:
memory: "8Gi" # Hard ceiling exceeding this kills the container
cpu: "4000m" # 4 CPU cores maximum during inference
让我们拆解这些 values 的含义。cpu values 使用 millicores。1000m 正好等于一个 full CPU core。1000m 的 request 表示 scheduler 会寻找至少有一个 available core 的 node。4000m 的 limit 表示在 heavy inference generation 期间,model runner 可以 burst 到最多四个 cores;超过之后 Kubernetes 开始 throttle 它。如下图所示。
图 5.9:Resource requests 与 limits——Kubernetes 如何用它们进行 scheduling 和 enforcement。
What happens without resource limits
如果没有 limits,Kubernetes 会把所有 Pods 都当成同样 needy。一个正在加载 7B parameter file 的 model 会消耗 node 上所有 available memory,触发其他 workloads 被 evict。随后 Kubernetes 会把这些被 evict 的 Pods reschedule 到其他 nodes 上,并可能在那里引发同样问题。这种 cascading failure pattern 在第一次 deployments 中惊人地常见,而且如果你不知道应该去找什么,它很难诊断。始终为 model containers 设置 limits。
Health probes: The most common beginner mistake
Kubernetes 使用两种 probes 来判断 container health。它们之间的区别对 model containers 来说至关重要。
Liveness probe:判断 container 是否仍然 alive。如果这个 probe 反复失败,Kubernetes 会 kill container 并 restart 它。用它来检测一个 stuck 或 deadlocked process——也就是 process 还在运行,但已经不再 progress。
Readiness probe:判断 container 是否 ready to receive traffic。如果这个 probe 失败,Kubernetes 会把 Pod 从 Service 的 endpoint list 中移除。它会完全停止向这个 Pod 发送 requests,直到 probe 再次通过。用它在 startup 期间 hold traffic back,也可以在 Pod overloaded 时把它从 rotation 中移除。
Model containers 的特殊之处在于:process 启动的一瞬间,container 就是 alive 的;但它还没有 ready to serve inference requests,直到 model 完成加载到 memory 中,而这可能需要 60 秒或更久。
看一下这个 misconfiguration:
# DON'T DO THIS these default timings will kill your model container
livenessProbe:
httpGet:
path: /health
port: 12434
initialDelaySeconds: 10 # Too short model is still loading
periodSeconds: 5
failureThreshold: 3
让我们拆解这里出了什么问题。initialDelaySeconds: 10 告诉 Kubernetes 在 container start 之后等待 10 秒再开始 probe。此时 model 仍在 loading。Probe fire。它失败。接下来以 5 秒 interval 连续失败 3 次后,Kubernetes kill container 并重试。Container 再次开始加载 model。10 秒后 probe 再次 fire 并失败。最终你进入 CrashLoopBackOff。Model 实际上从未 crash。只是 Kubernetes 从来没有给它足够时间。
正确 configuration 看起来应该是这样:
# DO THIS timing matched to actual model load time
readinessProbe:
httpGet:
path: /v1/models # DMR's model list endpoint returns 200
port: 12434 # only when a model is actually loaded
initialDelaySeconds: 60 # Wait before starting probe cycle
periodSeconds: 10
failureThreshold: 6 # 60s of tolerance after initial delay
livenessProbe:
httpGet:
path: /health
port: 12434
initialDelaySeconds: 120 # Even more conservative for liveness
periodSeconds: 30
failureThreshold: 3
让我们拆解 key fields。initialDelaySeconds value 告诉 Kubernetes,在启动 probe cycle 之前要等待多久。这是大多数人最容易配错的 field。请把它设置为你的 model 实际加载所需时间;方法是 fresh deployment 后观察 kubectl logs,并记下 model reports ready 的 timestamp。periodSeconds value 设置初始 delay 之后多久 probe 一次。failureThreshold value 设置在采取 action 之前允许多少次 consecutive failures。
Readiness probe 使用 /v1/models,这是 Docker Model Runner 的 model list endpoint。只有当一个 model 真正 loaded 且 ready to serve requests 时,这个 endpoint 才会返回 HTTP 200。这是一个精确信号,比 generic /health endpoint 好得多,因为 generic endpoint 在 HTTP server 启动的那一刻就可能返回 200,而那时 model 还远没有 ready。
How do you measure your model's actual load time?
第一次部署 model runner 后,运行:
kubectl logs -n ai-app -l app=docker-model-runner --follow
观察 output,直到看到 model ready message,并记录从 container start 到 ready 的 elapsed time。加上 20 秒 buffer,并把结果用作 initialDelaySeconds。然后,用以下命令验证 probe results:
kubectl describe pod -l app=docker-model-runner -n ai-app
读取底部的 Events section。
Diagnosing probe failures
当一个 Pod 没有达到 Ready state 时,你的第一个工具应该是:
kubectl describe pod -l app=docker-model-runner -n ai-app
查看 output 底部的 Events section。Probe timing problem 看起来像这样:
Events:
Warning Unhealthy 5s kubelet Readiness probe failed:
HTTP probe failed with statuscode: 503
Warning Unhealthy 15s kubelet Readiness probe failed:
HTTP probe failed with statuscode: 503
Normal Killing 20s kubelet Container model-runner failed
liveness probe, will be restarted
503 status code 告诉你:HTTP server 正在运行,但 model 尚未加载。这是 probe timing problem,不是 code bug。增加 initialDelaySeconds 并 redeploy。
OOMKilled error 看起来不同:
Events:
Warning OOMKilling 2s kubelet Memory limit reached.
Killing container model-runner.
Memory limit: 4Gi, usage: 4Gi
这告诉你 memory limit 对你的 model 来说太低。增加 limits.memory 并 redeploy。下面是你之后会反复参考的 failure pattern quick reference:
| Symptom | Root cause | Fix |
|---|---|---|
Deployment 后立即 CrashLoopBackOff | Probes 在 model loaded 之前 fire | 增加 initialDelaySeconds,使其匹配 actual load time |
Pod 永远 stuck in Pending state | 没有 node 拥有足够 resources | 降低 resource requests,或向 cluster 添加 nodes |
Pod events 中出现 OOMKilled | Model 超过 memory limit | 增加 limits.memory,或使用更高 quantization 的 model |
| Pod ready,但 inference 返回 503 | Readiness path 在 model loaded 之前就成功 | 使用 /v1/models,不要使用 /health 作为 readiness probe |
| 每次 restart 都重新下载 model | 没有配置 PVC | 从 PersistentVolumeClaim mount model files |
表 5.2:常见 Kubernetes failure patterns 以及修复方法
Resource limits 设置正确,并且 health probes 被调到与 model actual behavior 匹配后,Pods 就能可靠启动,并且 traffic 只会在它们真正 ready to serve 时到达。现在让我们看看一个 Pod 不够时会发生什么。
Scaling ML workloads on Kubernetes
你的 chatbot 正在 Kubernetes 上运行,具备 resource governance、health checks,并且稳定。现在,每个 service 都只运行一个 replica。对于 low-traffic application 来说,这完全没问题。但当多个 users 同时访问 chatbot,或者某个 batch job 突然发送几百个 requests,会发生什么?
这正是本节要覆盖的内容。我们会从 manual scaling 开始,用它构建 mental model,然后进入 Horizontal Pod Autoscaler,也就是让 cluster 自动响应 demand、无需 human intervention 的组件。
Manual scaling
你可以用一个 command scale 任意 Deployment。让我们 scale Go backend:
kubectl scale deployment go-backend --replicas=3 -n ai-app
让我们拆解这个 command 做了什么。kubectl scale command 会更新指定 Deployment 中的 replicas field。deployment go-backend argument 标识要 scale 的对象。--replicas=3 flag 设置新的 desired count。-n ai-app flag 指定 namespace。Kubernetes 会立即开始创建新的 Pods,以匹配 desired state。
验证结果:
kubectl get pods -n ai-app -l app=go-backend
你会看到如下结果:
NAME READY STATUS RESTARTS AGE
go-backend-7d9f8b-xk2p1 1/1 Running 0 14m
go-backend-7d9f8b-nwq94 0/1 ContainerCreating 0 3s
go-backend-7d9f8b-mh7j3 0/1 ContainerCreating 0 3s
这个 command 之后会立即创建两个新 Pods。几秒钟内,它们会通过 readiness probes,并开始与 original Pod 一起接收 traffic。go-backend Service 会自动在三个 Pods 之间 distribute requests——你不需要在 frontend 或 Service 中修改任何东西。
Manual scaling 对 predictable load patterns 很有用。但对于 unpredictable workloads,你希望 cluster 能自己响应。这正是 Kubernetes 对 ML workloads 真正强大的地方:automatic scaling。Horizontal Pod Autoscaler(HPA)会观察你的 Deployments,并基于 real-time resource metrics 调整 replica counts,不需要 human in the loop。
图 5.10 展示了 HPA workflow:
图 5.10:Horizontal Pod Autoscaler flow
现在你已经看到 high-level scaling flow,接下来详细拆解 HPA 的每个部分,并为自己的 workload 设置一个。
The horizontal pod autoscaler
创建一个名为 hpa.yaml 的文件:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: backend-hpa
namespace: ai-app
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: go-backend
minReplicas: 1
maxReplicas: 5
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
让我们拆解这个 file 做了什么。scaleTargetRef section 告诉 HPA 要控制哪个 Deployment:ai-app namespace 中的 go-backend。minReplicas: 1 表示即使 traffic 为 0,也始终至少有一个 Pod running。maxReplicas: 5 为 cost control 限制 scale-out 上限。metrics section 定义 scaling trigger:当所有 backend Pods 的 average CPU utilization 超过 70% 时,HPA 添加 replica。当 utilization 降到 target 以下时,HPA 会在 cooldown period 后移除 replicas,以避免 flapping。
应用它:
kubectl apply -f hpa.yaml
检查其 status:
kubectl get hpa -n ai-app
你会看到如下结果:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
backend-hpa Deployment/go-backend 18%/70% 1 5 1
TARGETS column 展示当前 CPU utilization(18%)与 70% threshold 的对比。当你向 service 发送 load 时,观察这个 value 上升。当它越过 70% 时,REPLICAS 会自动增加。
到目前为止,我们设置的基本上就是你会用于任何 web application 的同一种 HPA pattern。不过,ML workloads 的行为有点不同,在将它带到 production 之前,有几件事值得理解。
ML-specific autoscaling considerations
CPU-based autoscaling 对像 Go backend 这样的 stateless services 很有效。对于 Docker Model Runner 本身,则有一些 nuances 需要理解:
Cold start latency:当 HPA 响应 traffic spike 并添加一个新的 DMR replica 时,这个 new Pod 必须先加载 model,随后才能通过 readiness probe。对于 large model,这可能需要 60 到 90 秒。等 new replica 开始服务 traffic 时,demand spike 可能已经过去。HPA 响应是正确的,只是它无法足够快地响应。
The mitigation:将 model runner 的 minReplicas 设置为 2。这会始终保留一个 warm second replica。第二个 replica 能在第三个 replica 加载时吸收 demand spikes,而不是让单个 replica 独自承受压力直到援军到达。
CPU is an indirect signal for inference:CPU 上升是因为 requests 正在被处理,但当 CPU pegged 时,requests 可能已经在 queueing。对于 inference services,request latency 或 queue depth 是更好的 scaling signals。这需要 custom metrics,会在第 7 章和第 9 章的 agent observability work 中覆盖。
就本章目的而言,CPU-based autoscaling 建立了 pattern。让我们通过一个 quick load test 验证它正在工作。打开两个 terminal windows 并运行:
# Terminal 1 watch the HPA respond
kubectl get hpa -n ai-app --watch
# Terminal 2 generate load against the backend
kubectl run load-generator \
--image=busybox \
--namespace=ai-app \
-- /bin/sh -c "while true; do \
wget -q -O- http://go-backend:8080/api/health; \
done"
让我们拆解 load generator command。kubectl run command 会从指定 image 创建一个 single Pod。--image=busybox argument 使用 minimal busybox image。-- 用于分隔 kubectl flags 与 Pod 要运行的 command。while true loop 会持续向 go-backend Service 发送 HTTP requests,使用的是它的 Kubernetes DNS name——这正是你从 cluster 内部调用它的方式。
观察 terminal 1 中 TARGETS value 上升。当它超过 70% 时,你会看到 REPLICAS 增加。停止 load generator,经过 cooldown period 之后,REPLICAS 会降回 1。
Delete the load generator when you're done
Load generator Pod 会无限运行,并消耗 cluster resources。用下面的 command 删除它:
kubectl delete pod load-generator -n ai-app
Cleaning up
当你完成本章 examples 后,用一个 command 清理所有东西:
kubectl delete namespace ai-app
让我们拆解这个 command 做了什么。kubectl delete namespace command 会删除该 namespace 以及其中的每个 resource——所有 Pods、Deployments、Services、ConfigMaps、Secrets、HPAs 和 PVCs。一个 command,本章中所有东西都消失。不需要逐个删除每种 resource type。
如果你希望为后续章节保留 cluster running,可以保持不动。如果你希望完全 reset cluster,打开 Docker Desktop,导航到 Settings > Kubernetes,点击 Reset Kubernetes Cluster。这会把 cluster 恢复到 clean initial state。
图 5.11:Kubernetes 上的 ML ecosystem,展示每个 tool 位于哪一层。
到目前为止,我们聚焦在 Kubernetes 上 end-to-end 运行一个 single model runner,从 PVCs 和 ConfigMaps,到 Deployments、Services 和 autoscaling。但 model runner 只是围绕 Kubernetes 成长起来的更大 ML ecosystem 中的一部分。让我们 zoom out,看一看其余 landscape。
The ML ecosystem on Kubernetes
你到目前为止在本章中构建的所有东西——deployments、health probes、autoscaler——都是 native Kubernetes。Vanilla manifests,没有 third-party tooling。这是刻意设计的。先理解 foundations,再在其上添加 frameworks,这是正确顺序。
但对于 serious ML workloads,Kubernetes alone 并不总是足够。运行一个 single chatbot inference service 是一回事。跨 16 个 GPUs 训练 distributed deep learning model,构建一个 reusable pipeline 来 ingest data、retrain model、evaluate it、并 promote it to production,或者在 single endpoint 后面服务 dozens of models 并做 traffic splitting,则是完全不同的挑战。这些任务需要在 Kubernetes 之上的 purpose-built ML tooling。
本节正是覆盖这些内容。本节讨论的四个 tools 是当今 Kubernetes ML ecosystem 中采用最广泛的工具。你不会在本章中构建 production pipelines,但你会理解每个 tool 解决什么问题、什么时候该使用它,以及如何让它运行在你贯穿本章一直使用的同一个 kind cluster 上。
可以把这些 tools 看作社区建立在你刚刚学过的 primitives 之上的 extensions。Deployments、Services、ConfigMaps——这一切仍然在底层。这些 tools 只是为那些否则需要写大量 boilerplate 的 patterns 提供 higher-level abstractions。
KubeRay – Distributed ML training and serving
当你尝试训练一个 model,而不仅仅是服务一个 model 时,会立刻遇到一个问题。训练一个 serious ML model,即使只是对一个 pre-trained LLM 用自己的 data 做 fine-tuning,也需要把 work 分布到多个 GPUs,甚至多台 machines 上。Standard Kubernetes Deployments 并不知道如何 coordinate 这些工作。它们管理的是 identical、stateless replicas。Distributed training job 完全不是这样:它有一个 head node 负责 orchestrate work,还有 worker nodes 处理 data shards,并且它们需要直接通信。
这就是 KubeRay 解决的问题。KubeRay 是一个 Kubernetes operator,也就是一个扩展 Kubernetes API 的 custom controller,专门为 Ray 这个由 major AI labs 使用的 distributed Python framework 构建。它向你的 cluster 添加三种 custom resource types:
RayCluster:用于 provision 一组 Ray workersRayJob:用于运行一个 training job,它会启动 cluster、执行任务,然后 cleanupRayService:用于用 Ray Serve 部署 models,并放在 stable Kubernetes endpoint 后面
在 kind cluster 上安装 KubeRay operator:
helm repo add kuberay https://ray-project.github.io/kuberay-helm/
helm repo update
helm install kuberay-operator kuberay/kuberay-operator \
--namespace kuberay-system \
--create-namespace
让我们拆解这些 commands 做了什么。helm repo add command 会把 KubeRay Helm chart repository 注册到本地,并给它一个 short name:kuberay。helm repo update command 会从所有已注册 repositories 中 fetch 最新 chart index。helm install command 会把 KubeRay operator 部署到名为 kuberay-system 的 namespace 中,如果该 namespace 不存在则创建它。Operator 会安装 CRDs 和 custom resource definitions,让你的 cluster 学会理解 RayCluster、RayJob 和 RayService objects。
验证 operator 正在运行:
kubectl get pods -n kuberay-system
你会看到如下结果:
NAME READY STATUS RESTARTS AGE
kuberay-operator-6fcbb94f64-8xpqt 1/1 Running 0 45s
Operator running 后,你可以提交一个 RayJob。下面是一个最小 example:一个 Python job,使用 Ray 计算一个 simple distributed sum,只是为了证明 cluster wiring 正常:
apiVersion: ray.io/v1
kind: RayJob
metadata:
name: hello-ray
namespace: ai-app
spec:
entrypoint: python -c "import ray; ray.init(); print(ray.cluster_resources())"
rayClusterSpec:
headGroupSpec:
rayStartParams:
dashboard-host: "0.0.0.0"
template:
spec:
containers:
- name: ray-head
image: rayproject/ray:2.9.0
resources:
requests:
cpu: "1"
memory: "2Gi"
workerGroupSpecs:
- replicas: 2
groupName: worker-group
rayStartParams: {}
template:
spec:
containers:
- name: ray-worker
image: rayproject/ray:2.9.0
resources:
requests:
cpu: "1"
memory: "2Gi"
应用它并观察 job 运行:
kubectl create ns ai-app
kubectl apply -f hello-ray.yaml
kubectl get rayjob hello-ray -n ai-app --watch
随着 job 通过 lifecycle,你会看到如下结果:
NAME JOB STATUS DEPLOYMENT STATUS RAY CLUSTER NAME START TIME END TIME AGE
hello-ray Initializing hello-ray-sh7dg 2026-03-15T07:06:22Z 56s
当 job 达到 COMPLETE 时,head 和 worker Pods 会被自动清理。这是 KubeRay 对 training jobs 最有用的 behaviors 之一——工作完成后不会留下 zombie Pods 继续消耗 resources。
When would you actually use KubeRay?
当你需要在自己的 dataset 上 fine-tune 一个 model、跨多个 GPUs 运行 distributed hyperparameter search,或者用 Ray Serve 部署 model 以获得 advanced traffic management 和 A/B testing 时,请使用 KubeRay。对于像本章 chatbot 这样只做 single model 的 pure inference serving,前面已经构建的 standard Deployment approach 更简单,也更合适。
Kubeflow – ML pipelines from experiment to production
KubeRay 处理 ML workloads 的 execution side。Kubeflow 处理 lifecycle side。这是不同问题。我们所说的是:想象你已经训练了一个 model,并且它运行良好。现在你想每周用 new data retrain 它,运行 evaluation 确认它比 production 中的 version 更好,然后才 promote 它。这个 sequence——ingest、preprocess、train、evaluate、register、deploy——就是 pipeline。而 pipelines 正是 Kubeflow 被构建出来要解决的问题。
Kubeflow 是一组 Kubernetes-native ML tools,围绕 Kubeflow Pipelines 构建。Kubeflow Pipelines 是一个用于构建和运行 reproducible、automated ML workflows 的 platform。Pipeline 中每个 step 都作为 Kubernetes 中一个 separate container 运行。Inputs 和 outputs 通过 shared artifact store 在 steps 之间流动。你使用 Kubeflow Pipelines SDK 用 Python 定义 pipeline,把它 compile 成 YAML,然后 Kubeflow scheduler 可以按需或按 schedule 运行它。
在 kind cluster 上安装 Kubeflow Pipelines。这里安装的是 standalone Pipelines component,而不是完整 Kubeflow platform,这对学习来说设置更快:
export PIPELINE_VERSION=2.15.0
kubectl apply -k "github.com/kubeflow/pipelines/manifests/kustomize/cluster-scoped-resources?ref=$PIPELINE_VERSION"
kubectl wait --for condition=established \
--timeout=60s crd/applications.app.k8s.io
kubectl apply -k "github.com/kubeflow/pipelines/manifests/kustomize/env/platform-agnostic?ref=$PIPELINE_VERSION"
让我们拆解这些 commands 做了什么:
exportcommand 会存储 Kubeflow Pipelines version,这样你不用重复输入。- 第一个
kubectl apply -kcommand 会安装 cluster-scoped resources,也就是 Kubeflow 所需的 CRDs 和 RBAC rules。 kubectl waitcommand 会暂停,直到 Application CRD 完全注册,防止下一步出现 race conditions。- 第二个
kubectl apply -kcommand 会在kubeflownamespace 中安装 Pipelines control plane 本身。
等待 Pipelines UI ready。第一次运行通常需要两到三分钟。
kubectl wait pods -l app=ml-pipeline-ui \
-n kubeflow --for=condition=Ready --timeout=180s
打开 Pipelines UI:
kubectl port-forward svc/ml-pipeline-ui 8080:80 -n kubeflow
访问:
http://localhost:8080
你会看到 Kubeflow Pipelines dashboard,这是一个 visual interface,用于创建、运行和跟踪 experiments。
图 5.12:在本地 kind cluster 上运行的 Kubeflow Pipelines dashboard——可以从 visual interface 创建 experiments、上传 pipelines,并跟踪 run history。
下面是一个使用 Kubeflow Pipelines SDK 定义的最小 two-step pipeline,只需要足够展示结构即可。保存为 pipeline.py:
from kfp import dsl
@dsl.component(base_image="python:3.11-slim")
def preprocess(data_path: str, output_path: str):
# In a real pipeline, this would clean and split your dataset
print(f"Preprocessing {data_path} -> {output_path}")
@dsl.component(base_image="python:3.11-slim")
def train(data_path: str, model_output: str):
# In a real pipeline, this would run your training loop
print(f"Training on {data_path}, saving to {model_output}")
@dsl.pipeline(name="simple-ml-pipeline")
def ml_pipeline(data_path: str = "/data/raw"):
preprocess_task = preprocess(
data_path=data_path,
output_path="/data/processed"
)
train_task = train(
data_path=preprocess_task.output,
model_output="/models/output"
)
if __name__ == "__main__":
from kfp import compiler
compiler.Compiler().compile(ml_pipeline, "pipeline.yaml")
让我们拆解这段 code 做了什么:
-
@dsl.componentdecorator 将 Python function 转换为 Kubeflow pipeline step,每个 step 都会作为 Kubernetes 中自己的 container 运行。 -
base_imageargument 指定使用哪个 container image。 -
@dsl.pipelinedecorator 声明整体 pipeline,并把 steps 连接起来:preprocess_task首先运行;train_task接收它的 output。
-
compiler.Compiler().compilecall 会把 Python pipeline definition 转换为可以上传到 Kubeflow UI 的 YAML file。
Compile pipeline:
pip install kfp
python pipeline.py
通过 http://localhost:8080 的 Kubeflow UI 上传 pipeline.yaml,创建一个 experiment,并 trigger 一个 run。你会看到两个 steps 分别作为 kubeflow namespace 中的 individual Pods 执行,inputs 和 outputs 会在 UI 的 run history 中自动 tracked。
When would you actually use Kubeflow?
当你需要 automate full model lifecycle 时,请使用 Kubeflow;不仅是 inference,而是 training、evaluation 和 promotion workflow。当你希望 reproducible experiments,并且需要 tracked inputs、outputs 和 parameters 时,它尤其有价值。对于一个 simple chatbot inference service,它是 overkill。对于一个每周基于 user behavior data retrain 的 production recommendation engine,它正是正确工具。
KServe – Production model serving
Docker Model Runner 为 single model 提供了 clean OpenAI-compatible API。这正是本章 chatbot 所需要的。但如果你想在一个 single endpoint 后面服务 10 个不同 models,在 A/B test 期间把 traffic 在 current model 和 candidate model 之间 split,或者在 off-peak hours 自动把 model replicas scale to zero 以节省 GPU costs,又该怎么办?
这就是 KServe 的 territory。KServe 是一个 Kubernetes-native model serving framework,建立在 Knative Serving 之上。它向你的 cluster 添加 InferenceService custom resource——一个 single YAML object,处理完整 serving lifecycle:pull model、根据 request volume scale Pods up and down(包括 scale-to-zero)、expose standardized prediction endpoint,并管理 canary deployments 用于 gradual model rollouts。
在 kind cluster 上安装 KServe:
# Install cert-manager (KServe dependency)
kubectl apply -f \
https://github.com/cert-manager/cert-manager/releases/download/v1.15.0/cert-manager.yaml
kubectl wait --for=condition=Ready pods --all \
-n cert-manager --timeout=120s
# Install KServe
kubectl apply -f \
https://github.com/kserve/kserve/releases/download/v0.13.0/kserve.yaml
让我们拆解这些 commands 做了什么:
- 第一个
kubectl apply会安装 cert-manager,KServe 使用它来管理 webhook endpoints 的 TLS certificates。 kubectl waitcommand 会暂停,直到所有 cert-manager Pods 都 healthy;跳过这个 wait 经常导致 KServe installation failures。- 第二个
kubectl apply会安装 KServe 本身,包括 InferenceService CRD 和 controller。
安装完成后,你可以用一个 single InferenceService manifest 部署 model。下面是一个 example,用来服务存储在 S3-compatible bucket 中的 scikit-learn model:
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: sklearn-iris
namespace: ai-app
spec:
predictor:
model:
modelFormat:
name: sklearn
storageUri: "gs://kfserving-examples/models/sklearn/1.0/model"
让我们拆解这个 manifest 做了什么。kind: InferenceService line 声明 KServe resource type。predictor section 定义 serving configuration。modelFormat name 告诉 KServe 使用哪个 runtime——sklearn、tensorflow、pytorch、xgboost 等都是 built in。storageUri 指向 model artifact 的位置。KServe 会 pull model、配置合适的 runtime container,并自动 expose prediction endpoint。
检查 InferenceService status:
kubectl get inferenceservice sklearn-iris -n ai-app
Model ready 后你会看到如下结果:
NAME URL READY PREV LATEST AGE
sklearn-iris http://sklearn-iris.ai-app.svc... True 100 45s
URL column 显示 prediction endpoint。你可以从 cluster 内部直接向它发送 inference requests,或者通过 ingress controller 将它 expose 到外部。
When would you actually use KServe?
当你需要从一个 consistent endpoint 服务 multiple model types,希望 scale-to-zero 以降低 off-peak hours 的 GPU costs,或者需要 canary deployments 将 traffic 从某个 model version 逐步切换到另一个时,请使用 KServe。对于本章 single-model chatbot,Docker Model Runner 是正确选择。只有当你在管理一组 models 时,KServe 的 complexity 才值得。
MLflow: Experiment tracking and model registry
KubeRay 运行 distributed training。Kubeflow 自动化 pipelines。KServe 服务 models。但它们都没有回答一个问题:production 中实际运行的是哪个 model version?它是用什么 training data 训练的?它与上周二你尝试过的另外三个 versions 相比如何?
这就是 experiment tracking 和 model registry problem,而这正是 MLflow 要解决的问题。
MLflow 是一个 open-source platform,用于管理完整 ML experiment lifecycle。它有四个可以独立使用、也可以配合使用的 components:
- Tracking:记录 training runs 中的 parameters、metrics 和 artifacts
- Models:一种 standardized format,用于将 models 与 dependencies 一起 package
- Model Registry:用于 versioning、staging,并通过 lifecycle(Staging → Production → Archived)promote models
- Projects:用于将 code package 成 reproducible runs
Tracking server 是大多数 teams 开始使用的部分。它给每个 training run 一个 permanent record:你使用了哪些 parameters、达到了哪些 metrics,以及产生了哪些 artifacts,全部可以通过 clean web UI 访问。当你的 model performance 两个月后在 production 中 degrade 时,你可以追溯到生成它的 exact training run 和 dataset。
在 kind cluster 上部署 MLflow Tracking server:
# Create a simple MLflow deployment with local artifact storage
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: mlflow
namespace: ai-app
spec:
replicas: 1
selector:
matchLabels:
app: mlflow
template:
metadata:
labels:
app: mlflow
spec:
containers:
- name: mlflow
image: ghcr.io/mlflow/mlflow:v2.15.0
command:
- mlflow
- server
- --host=0.0.0.0
- --port=5000
ports:
- containerPort: 5000
resources:
requests:
memory: "1Gi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: mlflow
namespace: ai-app
spec:
selector:
app: mlflow
ports:
- port: 5000
targetPort: 5000
EOF
让我们拆解这个 manifest 做了什么。Deployment 运行 official MLflow image,并使用 --host=0.0.0.0 在 5000 端口启动 MLflow tracking server,因此它可以接受 cluster 内部连接。Service 通过 name 将其 expose 为:
http://mlflow:5000
ai-app namespace 中任何 Pod 都可以访问它。这个 configuration 使用 in-container storage,不适合 production。Production 中,你会 mount 一个 PVC,并把 server 指向一个 S3-compatible bucket 作为 artifact storage。
访问 MLflow UI:
kubectl port-forward svc/mlflow 5000:5000 -n ai-app
导航到:
http://localhost:5000
你会看到 MLflow Experiments dashboard,现在它是空的,等待 training runs 记录 data。
图 5.13:在你的 kind cluster 上运行的 MLflow Tracking UI。Experiments、runs、parameters、metrics 和 model artifacts 会跨每次 training job 被 tracked。
下面是一个 Python training script 如何把 metrics 记录到这个 server:
import mlflow
# Point MLflow at the in-cluster tracking server
mlflow.set_tracking_uri("http://mlflow:5000")
mlflow.set_experiment("chatbot-model-fine-tune")
with mlflow.start_run():
# Log hyperparameters
mlflow.log_param("learning_rate", 0.001)
mlflow.log_param("batch_size", 32)
mlflow.log_param("epochs", 10)
# ... your training loop runs here ...
# Log final metrics
mlflow.log_metric("eval_loss", 0.42)
mlflow.log_metric("eval_accuracy", 0.91)
# Save the model artefact
mlflow.log_artifact("model.pkl")
让我们拆解这段 code 做了什么。mlflow.set_tracking_uri call 会将 MLflow client 指向 cluster 内的 Tracking server,使用的是它的 Service name。mlflow.set_experiment call 会把这个 run 归组到一个 named experiment 下。with mlflow.start_run() context manager 会打开一个 run——其中记录的一切都会与该 run 的 ID 关联。mlflow.log_param calls 记录 hyperparameters。mlflow.log_metric calls 记录 evaluation results。mlflow.log_artifact call 会将 trained model file 与该 run 的 metadata 一起存储。
无论是来自 Kubeflow pipeline step,还是 standalone RayJob,cluster 中任何 Pod 记录的每个 run 都会出现在同一个 MLflow UI 中,并带有完整 parameter 和 metric history,供你比较不同 runs。
When would you actually use MLflow?
当你开始运行不止一个 training experiment 时,就该使用 MLflow。没有它,你会很快丢失 track:哪个 run 产生了哪个 model file,以及哪些 parameters 让它有效。它可以自然集成到 KubeRay jobs 和 Kubeflow pipeline steps;每个 step 调用 mlflow.log_metric,每个 run 都会自动记录。Model Registry 在需要 gate production promotions 时尤其有价值:一个 model 必须先进入 Staging,才可以 promote 到 Production,并且每次 promotion 都会记录是谁做的、什么时候做的。
Choosing the right tool
所以 MCP 听起来像梦想一样,对吧?其实,让我重新表述一下。现在桌面上已经有四个 tools,你可能会问的问题是:我到底需要哪个?坦诚的答案是:它们解决 ML lifecycle 中不同的问题,而且大多数 teams 最后会组合使用它们。
| Tool | Problem it solves / Reach for it when ... |
|---|---|
| KubeRay | 你需要使用 Ray 做 distributed training 或 serving,例如跨多个 GPUs 运行 fine-tuning jobs、hyperparameter search,或用 Ray Serve 做 advanced model routing。 |
| Kubeflow Pipelines | 你希望自动化 full training lifecycle。可复现的 pipelines 能 ingest data、preprocess、train、evaluate 和 promote,并能按 schedule 或按需 trigger。 |
| KServe | 你在服务 multiple models,或者需要 scale-to-zero。可以从一个 single endpoint 管理一组不同 model types,或者在 off-peak hours 降低 GPU costs。 |
| MLflow | 你需要 track experiments 并 register models。当你运行不止一个 training experiment 时,MLflow 会让你不再丢失“什么有效以及为什么有效”的记录。 |
表 5.3:什么时候使用哪个 tool
继续阅读本书余下部分,并不需要这些 tools。你在本章早些时候构建的 Docker Model Runner deployment,就是第 7 章到第 9 章中的 agent architectures 所建立的基础。不过,理解这个 ecosystem 很重要,因为一旦你的 project 超过 single inference service 的范围,其中某个 tool 就会正是你需要的东西。
ML ecosystem 已经梳理完毕。让我们用一个 practical cleanup step 结束本章,就像开头一样,让你的 cluster 处在 future chapters 可用的 known state。
kubectl delete ns ai-app
Summary
Kubernetes 是一个平台,它把 Docker Compose 在 single machine 上做的事情扩展到 cluster:self-healing workloads、multi-node scheduling、automatic scaling 和 zero-downtime deployments。本章展示了如何在不离开 Docker Desktop 的情况下,在 Kubernetes 上运行 ML inference workloads。
Docker Desktop 内置的 kind provisioner 给了你一个 three-node cluster:一个 control plane 和两个 workers,完全作为 Docker containers 在你的 laptop 上运行。不需要 cloud account,不需要 separate installation,不需要 scripts。只需要在 Settings > Kubernetes 中打开一个 toggle,等待两分钟,你就拥有了一个真实的 multi-node cluster。kubectl tool 已经自动配置好,而 Docker account 是唯一 prerequisite。
你学习了如何将每个 Docker Compose concept 翻译成 Kubernetes equivalent:service: 变成 Deployment 和 Service pair,environment: 变成 ConfigMap 或 Secret,volumes: 变成 PersistentVolumeClaim,depends_on: 变成 readiness probes 和 init containers。对 ML deployments 重要的六个 primitives——Pods、Deployments、Services、ConfigMaps、Secrets 和 PersistentVolumeClaims——现在都已经有了对应到你原本熟悉内容的 direct mappings。
你把第 3 章 chatbot 迁移到了 Kubernetes,并用一个 kubectl apply command 应用了完整 stack:model storage、configuration、Docker Model Runner、Go backend 和 React frontend。你看到 model runner 比其他 services 更久才达到 Ready,并且准确理解了原因:/v1/models 上的 readiness probe 会在 model 真正 loaded 之前挡住 traffic。Port-forward 给你带来了与第 3 章相同的 browser experience,只是现在它运行在两个 worker nodes 上。
你学习了如何为 model containers 配置 resource requests 和 limits,这是第一次部署时最常见的 mysterious failures 来源;也学习了如何将 health probe timing 调到匹配 actual model load times,而不是使用为 stateless web services 校准的 default values。覆盖 CrashLoopBackOff、OOMKilled、Pending pods 和 probe misconfigurations 的 troubleshooting table,在你前几次部署新 model 时值得一直打开。
在 scaling 方面,你添加了 Horizontal Pod Autoscaler,看到当 CPU 超过 70% 时 cluster 自动添加 replicas,并理解了 inference workloads 特有的 cold start challenge,以及为什么 warm replicas 是 practical mitigation。
最后,你梳理了运行在 Kubernetes 之上的 broader ML ecosystem:KubeRay 用于基于 Ray 的 distributed training and serving;Kubeflow Pipelines 用于将 full model lifecycle 从 ingest 到 production 自动化;KServe 用于 multi-model serving,并提供 scale-to-zero 和 canary deployments;MLflow 用于 experiment tracking 和 model registry。每个 tool 都解决 ML problem 的不同层。现在,你知道什么时候该选择哪一个。
在第 6 章中,你将学习如何通过 Model Context Protocol 将 AI models 与 external tools 和 data 集成。你将探索拥有 300+ verified servers 的 Docker MCP Catalog,使用 Docker MCP Toolkit 在本地管理 secrets 和 connections,并运行 Docker MCP Gateway,让你的 AI 能够真正访问 GitHub repositories、databases 和 web search,而不需要 custom integration code。