跳过正文
  1. Posts/

从 Docker 到 Kubernetes:真正重要的是目标状态与资源抽象

·3350 字·7 分钟
目录

起因
#

在上一篇《从进程视角重新理解 Docker》中,我总结了 Docker 最核心的价值:它通过 namespace 和 cgroup 给进程构造了独立的视图和资源限制,并把这套运行环境固化成了可以直接分发的 artifact。

Docker 漂亮地解决了 “how to package and run a workload on one machine” 的问题。

但当我几年前开始负责公司的机器学习平台时,面对的是几十上百台机器、数以千计的训练和推理任务。这时候发现,仅仅依靠 Docker 已经完全不够用了。

如果系统里有 100 台机器,一个程序挂了谁来发现并重启?某台机器突然宕机,上面的 20 个容器该迁移到哪里?如何始终保持某个核心服务刚好有 3 个副本在跑?不同机器的 CPU 和 GPU 剩余配额各不相同,新来的任务该塞给谁?

如果此时还是通过 ssh machine-17 然后 docker run 来部署应用,那不过是把手工管理进程,变成了手工管理一个分布式系统

规模扩大之后,问题的性质发生了改变。我们需要一种全新的系统抽象来管理这堆动态的容器。这就自然引出了 K8s。

对于 K8s,大家往往容易迷失在浩如烟海的 API、YAML 配置和纷繁复杂的组件中。但几年以后再回头看,具体的命令和参数其实随时可以查。K8s 真正值得长期记住的,是它带来的架构层面的 big idea 和 engineering insight。

Desired State:在动态系统中,“指令”为什么不管用
#

如果你要维护一个分布式系统,最直观的传统思路是命令式(Imperative)的。

说白了就是描述“怎么做(how to do)”:

  • 在节点 A 启动进程
  • 如果挂了,执行重启脚本
  • 升级时,先把旧节点剔除,再加入新节点

这种模式在静态、小规模的环境下很有效。但它的致命缺陷是极其脆弱。如果节点 A 网络断了 5 分钟又恢复了,脚本执行到一半卡住了怎么办?如果由于不可抗力,原本启动成功的进程被操作系统的 OOM Killer 杀掉了怎么办?

K8s 给出的解法是声明式编程(Declarative Programming),它要求用户描述的是“目标状态(Desired State)”:我需要 replicas = 3,需要 image = v2,需要 cpu = 4。至于怎么达到这个状态,系统自己想办法。

为什么在一个动态、会不断发生失败的分布式系统里,“维护目标状态”比“执行一系列操作”是一个更强的 abstraction?

因为在分布式世界里,Failure 是常态,而不是异常。节点会宕机,网络会抖动,容器会随时暴毙。如果我们把系统的稳定性建立在“确保每一个动作都完美执行”或者“确保某一个具体实例永远存在”上,系统注定会崩溃。

而目标状态的抽象使得系统具备了极强的韧性:无论中间发生了多少混乱和失败,只要系统还在运行,它就会像物理学里的势能一样,不可阻挡地向着我们声明的目标状态滑落。

Reconciliation:一套分布式的控制循环
#

那么,系统如何保证现实最终向 Desired State 收敛?

K8s 的设计者引入了机器人和工业控制领域中非常经典的控制循环(Control Loop)机制,在 K8s 里通常被称为 Reconciliation(调谐)。

如果用一段极简的伪代码来表示,整个 K8s 的核心几乎都在跑着无数个这样的循环:

1while True:
2    desired = read_spec()
3    actual = observe_world()
4
5    if actual != desired:
6        reconcile(actual, desired)

假设我们设定 desired replicas = 3,而由于某台机器宕机,导致 actual replicas = 2。Controller 观察到差异,就会触发新建一个副本的逻辑。反之如果因为网络分区恢复,突然变成了 4 个,它就会删掉一个。

Reconciliation Loop

K8s 与其说是一个容器编排系统,不如从架构思想上把它理解成一套 distributed reconciliation system。

整个集群大量的 Controller(比如 ReplicaSet Controller、Node Controller),本质上都在反复执行:observe -> compare -> reconcile

这也是为什么 K8s 能够优雅地把 Failure 视为正常状态。机器挂掉导致 workload 消失,这只不过是 actual state 与 desired state 发生了 divergence 而已。Controller 发现之后,自然会去触发 reconcile 逻辑将目标状态恢复。

从管理机器,到管理资源
#

K8s 带来的另一个巨大的思想转变,是从 Machine-centric 走向了 Resource-centric。

传统部署思路往往是与具体物理机绑定的:“核心数据库跑在 server-1 上的 135 端口”,“特征提取任务跑在 gpu-machine-5 上”。这需要运维人员在脑海里(或者 Excel 表格里)维护每台机器的剩余容量。

K8s 希望开发者忘掉机器。在 K8s 的世界里,开发者只需要在 YAML 中声明 workload 的 Resource Requirement:

1resources:
2  requests:
3    cpu: "8"
4    memory: "32Gi"

这背后隐含的模型是:大量的物理机被抽象成了统一的 Cluster Resource Pool。

Resource Pool

既然我们不再指定具体运行在哪台机器上,那么谁来决定这件事?答案是 Scheduler。

不需要深入了解 K8s Scheduler 具体用了什么打分框架和插件,从 high-level 模型上看,它解决的本质问题是:这个 workload 应该放在哪里(Placement problem)?

Scheduler 过滤出满足条件的节点,并进行打分,最后把 workload 绑定到某个具体的 Node 上。在这个过程中,Placement 被从具体的 workload 业务逻辑中抽象了出来。 这不仅让管理员免去了人肉选机器的痛苦,更是整个集群资源利用率能够大幅提高的基础。

逻辑与物理的解耦:Pod 与 Service
#

了解了上面这些,我们就能很自然地理解 K8s 中一些独特概念的设计初衷。

为什么需要 Pod?
#

很多人被问到 Pod 时会背诵“Pod 是 K8s 最小部署单元”。但这并没有回答为什么 K8s 不直接调度 Docker 容器。

因为一个逻辑上的 workload 很多时候并不只有一个进程。比如主服务容器旁边,往往需要挂载一个负责收集日志的 sidecar 容器,或者一个做环境初始化的 init 容器。它们需要被一起调度,需要共享网络命名空间和 volume。

容器是 runtime abstraction,而 Pod 是 orchestration abstraction。 K8s 需要一个高于单个容器的调度和生命周期单位,这就是 Pod 存在的意义。

为什么需要 Service 和 Label?
#

既然 Pod 是 K8s 的调度单位,而我们在前面说过,Failure 是预期的,节点宕机会触发 reconciliation,导致旧 Pod 被销毁,新 Pod 在另一台机器上被创建。

这意味着 Pod 的 IP 地址是随时会变化的(ephemeral)。如果客户端硬编码了某个 Pod 的 IP,系统一旦发生故障迁移就会瘫痪。

为了解决这个问题,K8s 引入了 Label/Selector 和 Service。

1# Pod 的逻辑身份
2metadata:
3  labels:
4    app: inference
5    model: alpha

Service 和其他内部控制器,不再依赖诸如 pod-1837 这样的具体实例,而是只认准 selector: app=inference 这一逻辑身份(Logical Identity)。

只要新创建的 Pod 依然带有这个标签,流量就会自动路由过去。依赖逻辑身份,而不是依赖某个具体的物理实例,这是 K8s 中大量组件松耦合(loose coupling)能够成立的基础。

延伸思考:Control plane 与 Data plane 的边界
#

在深入理解了 K8s 大量使用控制循环之后,很多人(包括当年的我)会产生一个疑问:

既然 Controller 是在死循环里不断对比状态,这种操作通常是不追求极致实时的。那么 K8s 是不是不适合做对延迟极度敏感的在线业务(latency-sensitive workload)?

这里需要区分两个概念:Control plane latencyData plane latency

K8s 的 Scheduler、Controller Manager 和 API Server 属于控制面。它们确实不在已经启动并正常运行的 workload 的核心请求链路上。控制面的动作(比如节点宕机后新建一个 Pod)慢个几秒钟,通常是可以接受的。

但业务请求走的是数据面(Data plane)。真正需要谨慎对待的,是那些被放进了数据面请求必经之路(critical path)的基础设施抽象,比如 Service 实现(kube-proxy、iptables/IPVS)、CNI 网络插件,或者是 Service Mesh 数据面代理。

K8s 完全可以胜任一套强大的 control plane,同时让你的在线 workload 保持极其轻薄的 data plane。只要理清这两者的边界,就不会因为“控制循环慢”而全盘否定其在核心在线业务中的价值。

为什么当年做 ML Platform 必须理解 K8s
#

写到这里,我想回应一下本文开头的场景。为什么对于一个 ML Infra / ML Platform 工程师来说,理解 K8s 是非常自然的需求?

因为机器学习平台的底层,每天都在面对这些问题:海量的 training jobs 和 serving jobs、异构资源(CPU、各种型号的 GPU、RDMA、NVLink)、动态的资源申请与释放、多用户资源隔离以及排队策略。

这本质上是一个极其复杂的规模化和调度问题: 海量 Workload + 异构 Resource + 动态 Placement + 持续 Failure

K8s 提供的这套基础架构抽象:将业务抽象为资源需求,将集群抽象为资源池,通过 Scheduler 解决 Placement,依靠 Reconciliation 维持目标状态和故障自愈。这刚好成为机器学习平台继续向上构建的最坚固的基石。

几年以后,那些具体的 YAML 字段可能会遗忘,但这种面向目标状态的容错设计,以及将逻辑需求与物理资源解耦的系统抽象,依然是在构建任何大规模分布式系统时,最值得反复回味的工程思想。

相关文章

从进程视角重新理解 Docker

·3840 字·8 分钟
起因 # 第一次接触 Docker 是 2017 年,在深圳网警机房驻场做审核项目。机房完全没有外网,模型、依赖、运行环境全靠 U 盘拷贝 docker image 来传输。后来做模型转换系统,再到量化行业负责机器学习平台,和 Docker、K8s 打了不少交道。但距离上一次系统梳理 namespace、cgroup 这些底层机制,已经过去好几年了。