—— 为什么我们不能把 Kubernetes 当作大号 Docker 用?
为什么写这篇文章
我实习中有一个产出描述是:负责开发强化学习训练部署服务:针对 K8s 环境下训练容器端口动态分配、多节点协调的问题,设计轮询等待 + 端口发现 + 配置注入的自动化流水线,实现训练任务一键部署,支撑 MLOps 平台核心功能。
对于这个描述,我只对面试官的一句话印象深刻: 如果你这样设计的话,那么这里 K8s 的引入没有意义,你把 K8s 当作 Docker 来用了。
那么,怎么才是像 K8s 一样用 K8s?
我们常说“代码不会骗人”,但在分布式环境下,“符合直觉”的代码往往是最大的谎言。
我的旧代码是一种典型的指令式编程 (Imperative Programming)。我像一个全职保姆一样,告诉机器每一步该怎么做:
java// 伪代码:我曾经以为完美的逻辑
public void runTask(Task task) {
// 第一步:先迈左脚
String containerId = dockerClient.create(image);
// 第二步:盯着它,没好不许走
while(status != RUNNING) {
Thread.sleep(5000);
}
// 第三步:把饭喂到嘴里
dockerClient.copyArchive(containerId, file);
// 第四步:咽下去
dockerClient.exec(containerId, "start.sh");
}
在我的开发机(Localhost)上,这段代码运行完美。但我忽略了一个致命事实:生产环境是一个充满混沌的分布式环境。当这套代码跑在几十台节点组成的集群上时,物理世界的残酷开始显现:
有一次,Java 服务所在的物理机因为电压波动重启了。
后果:由于 containerId 还在 Java 内存里,还没来得及写入数据库。服务重启后,Java 程序完全“失忆”。但在集群的某个角落,那个容器已经跑起来了。它成了一个**“孤儿”**,默默地消耗着昂贵的 GPU 资源,却无人认领,也无法回收。我的系统产生了一笔“资源坏账”。
运维同学在半夜发布了一个补丁,重启了我的 Java 服务。此时,我的代码正运行到 Thread.sleep(5000),等待一个刚创建的容器启动。
后果:线程被强制中断,上下文丢失。容器在那边启动成功了,在那儿痴痴地等待文件上传。但它的“保姆”(Java 线程)已经挂了。这个容器永远等不到主人的下一步指令,变成了一个空转的**“僵尸”**。
在传输 500MB 模型文件的过程中,机房网络抖动了一下。
后果:Java 端抛出异常,任务标记失败。但容器里留下了一个只有 200MB 的损坏文件。如果我的重试逻辑不够严谨,复用了这个容器,训练脚本读取坏文件运行,会产生极其隐蔽的 Bug。
这是最让我头疼的问题。Java 数据库显示任务状态是 RUNNING,但实际上容器因为 OOM(内存溢出)已经被 K8s 杀掉了。
后果:为了解决这个问题,我不得不引入大量的轮询(Polling)代码,每秒去问 K8s:“它还在吗?”。即便如此,状态依然存在延迟。数据库认为它是活的,物理世界它是死的——这种状态不一致,在分布式领域被称为“脑裂”。
展示如何用 K8s 的原生能力替代手写轮子,实现从 How 到 What 的转变。
听从了面试官的建议,我开始对代码进行大刀阔斧的减法。
我删除了所有处理 sleep、upload、retry 的 Java 胶水代码。
我不再在 Java 里写 while 循环去盯着容器,而是直接定义一个 YAML 对象:“我要一个运行着训练脚本、带着配置文件的 Job”。
InitContainer 专门负责下载数据。利用 K8s 的 Pod 生命周期机制——Init 容器不成功,主容器绝不启动。这保证了原子性:要么全成,要么全败,绝无中间态。exec 修改配置。Job 资源,然后 Java 服务就可以“撒手不管”(Fire and Forget)。K8s 的 Job Controller 会自动处理重试、节点宕机迁移。当几千行 Java 调度代码被缩减为几十行配置生成代码时,我突然意识到了一件事:
那些棘手的问题(网络中断、节点宕机、文件损坏)并没有消失。 物理世界依然混乱,网线依然会断。
但是,谁在负责处理这些混乱?
我终于明白了面试官那句话的含义:并不是我的 Java 代码写得烂,而是这些“脏活累活”压根就不该由业务代码来做。 通过声明式 API,我把处理分布式复杂性的责任,从应用层(Application Layer) 彻底解耦交还给了 基础设施层(Infrastructure Layer)。
这一刻,我才开始真正思考一个更深层的问题:既然这些问题是无法消除的,那么在计算机科学中,它们到底被如何定义?我们到底还要造多少个轮子去对抗它们?
—— 从计算机科学(CS)学生与研究者的视角,探讨分布式系统的本质规律。
带着重构后的思考,我不再局限于具体的代码实现,而是试图站在计算机科学的高度,去寻找一个系统性的答案。我发现,所有的这些“翻车现场”,在理论界早有定论。
如果我们跳出具体的业务,将之前的故障抽象一下,会发现所有的分布式系统都面临着三个无法回避的物理公理。这就好比你在造飞机,却试图忽略重力。
为什么我们会在代码中犯下这些错误?因为我们习惯了单机编程的安逸。Sun Microsystems 的 Peter Deutsch 早在 1994 年就总结了 “分布式计算的八大谬误”。
初级工程师往往默认这些谬误是真理,而现实中它们全是谎言。对照我的旧代码,我几乎踩中了所有的坑:
网络是可靠的 (The network is reliable):假设文件流传输永远成功,导致了“半成品”文件。
延迟是零 (Latency is zero):假设容器启动是瞬间的,用简单的 sleep 等待,导致了“僵尸容器”。
带宽是无限的 (Bandwidth is infinite):并发传输大模型时,网卡带宽耗尽导致心跳包丢失。
网络是安全的 (The network is secure):内网环境也并非绝对安全,未加密的传输存在风险。
拓扑结构不会变 (Topology doesn't change):硬编码了容器 IP,结果 Pod 重启后 IP 漂移,连接失效。
只有一名管理员 (There is one administrator):你控制不了依赖的服务何时被运维重启。
传输成本是零 (Transport cost is zero):序列化与反序列化的 CPU 开销不可忽视。
网络是同构的 (The network is homogeneous):不同节点的硬件配置、操作系统版本可能完全不同。
结论:只要我们还在应用层试图通过简单的逻辑去掩盖这些谬误,我们就永远在造一个“漏气的轮子”。
在物理学中,熵 (Entropy) 代表系统的混乱度。热力学第二定律告诉我们:在一个封闭系统中,熵总是自发增加的。
在分布式系统中,节点宕机、网络丢包、资源抢占,这些都是系统自发走向混乱(熵增)的表现。我的“保姆式”代码一旦异常中断,系统就留下了垃圾(孤儿、僵尸),这就是熵增。
Kubernetes 的核心哲学,在于引入了控制论 (Control Theory) 中的 负反馈调节 (Negative Feedback Loop)。
K8s 的 Reconcile Loop(调和循环) 本质上是一个 PID 控制器。它不依赖单次指令的成功,而是通过无限循环:
公式表达为:通过不断行动,使 趋近于零。
K8s 从来不保证每个操作一定成功(因为物理故障不可避免),但它承诺会通过不断的反馈调节,将系统的状态强行收敛到我们 YAML 中期望的有序状态。
拥抱不可靠性的哲学
"分布式系统的本质不是避免故障,而是优雅地处理必然发生的故障。"
—— 谷歌 SRE 核心理念
在理解了物理规律后,工程师必须做出现实的取舍。这便是著名的 CAP 定理。这也是为什么 K8s 的设计与我的旧代码有着本质的不同。
什么是 CAP?
残酷的现实:P 是不可选的
在分布式系统中,网络分区(网线断了、路由器挂了)是必然发生的物理现象。所以我们无法丢弃 P。我们只能在 CP(一致性优先) 和 AP(可用性优先) 之间做选择。
我的旧方案 (AP 选择 —— 自欺欺人)
RUNNING。K8s 的方案 (CP 选择 —— 实事求是)
PENDING 或 Unknown 状态(暂时牺牲可用性),也绝不欺骗上层应用说“任务成功了”。哪怕这一刻 API 暂时不可用(写操作阻塞),它也要保证数据绝不出错。拥抱 Kubernetes,不是为了追逐技术潮流,而是出于对计算机科学复杂性的敬畏。
我们不应该在用户态(业务代码)去重新发明内核态(基础设施)的轮子。将分布式治理的脏活累活归还给基础设施,让工程师回归到算法与业务创新的高地。