编辑
2026-01-19
个人笔记
00

目录

一次简单的代码重构,却引发了架构涅槃
第一部分:引言 —— 一语点醒梦中人
第二部分:现场还原 —— 我们是如何一步步掉进“指令式”陷阱的
1. 孤儿容器 (The Orphan):断电后的资源黑洞
2. 僵尸容器 (The Zombie):同步等待的代价
3. 半成品 (The Corrupted):网络不是完美的
4. 脑裂 (Split-Brain):两个世界的冲突
第三部分:重构之路 —— 任务并没有消失,只是“换人”了
1. 代码的减法:从过程到终态
2. 后知后觉的“顿悟”:责任的转移
第四部分:灵魂拷问 —— 我们还要造多少个轮子?
1. 物理层的诅咒:分布式三大难题 (The 3 Problems)
2. 认知层的幻觉:八大谬误 (The 8 Fallacies)
3. 科学家的视角:用控制论对抗“熵增”
4. 工程师的取舍:CAP 定理下的理性选择
结语

一次简单的代码重构,却引发了架构涅槃

—— 为什么我们不能把 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)上,这段代码运行完美。但我忽略了一个致命事实:生产环境是一个充满混沌的分布式环境。当这套代码跑在几十台节点组成的集群上时,物理世界的残酷开始显现:

1. 孤儿容器 (The Orphan):断电后的资源黑洞

有一次,Java 服务所在的物理机因为电压波动重启了。

后果:由于 containerId 还在 Java 内存里,还没来得及写入数据库。服务重启后,Java 程序完全“失忆”。但在集群的某个角落,那个容器已经跑起来了。它成了一个**“孤儿”**,默默地消耗着昂贵的 GPU 资源,却无人认领,也无法回收。我的系统产生了一笔“资源坏账”。

2. 僵尸容器 (The Zombie):同步等待的代价

运维同学在半夜发布了一个补丁,重启了我的 Java 服务。此时,我的代码正运行到 Thread.sleep(5000),等待一个刚创建的容器启动。

后果:线程被强制中断,上下文丢失。容器在那边启动成功了,在那儿痴痴地等待文件上传。但它的“保姆”(Java 线程)已经挂了。这个容器永远等不到主人的下一步指令,变成了一个空转的**“僵尸”**。

3. 半成品 (The Corrupted):网络不是完美的

在传输 500MB 模型文件的过程中,机房网络抖动了一下。

后果:Java 端抛出异常,任务标记失败。但容器里留下了一个只有 200MB 的损坏文件。如果我的重试逻辑不够严谨,复用了这个容器,训练脚本读取坏文件运行,会产生极其隐蔽的 Bug。

4. 脑裂 (Split-Brain):两个世界的冲突

这是最让我头疼的问题。Java 数据库显示任务状态是 RUNNING,但实际上容器因为 OOM(内存溢出)已经被 K8s 杀掉了。

后果:为了解决这个问题,我不得不引入大量的轮询(Polling)代码,每秒去问 K8s:“它还在吗?”。即便如此,状态依然存在延迟。数据库认为它是活的,物理世界它是死的——这种状态不一致,在分布式领域被称为“脑裂”。

第三部分:重构之路 —— 任务并没有消失,只是“换人”了

展示如何用 K8s 的原生能力替代手写轮子,实现从 How 到 What 的转变。

听从了面试官的建议,我开始对代码进行大刀阔斧的减法

1. 代码的减法:从过程到终态

我删除了所有处理 sleep、upload、retry 的 Java 胶水代码。

我不再在 Java 里写 while 循环去盯着容器,而是直接定义一个 YAML 对象:“我要一个运行着训练脚本、带着配置文件的 Job”。

  • 文件流传输 -> InitContainer (原子性保障)
    • 旧方案:主程序启动后,Java 远程拷贝文件。容易出现“半成品”。
    • 新方案:定义一个 InitContainer 专门负责下载数据。利用 K8s 的 Pod 生命周期机制——Init 容器不成功,主容器绝不启动。这保证了原子性:要么全成,要么全败,绝无中间态。
  • 运行时修改 -> ConfigMap/Env (不可变基础设施)
    • 旧方案:容器运行中通过 exec 修改配置。
    • 新方案:在 Pod 启动前,将配置注入为环境变量。容器启动即“完全体”。这遵循了不可变基础设施 (Immutable Infrastructure) 的原则:容器像子弹一样,打出去就不能改,要改就换一颗新的。
  • 人工轮询 -> Job Controller (托管的生命周期)
    • 旧方案:Java 死循环轮询状态。
    • 新方案:提交一个 K8s Job 资源,然后 Java 服务就可以“撒手不管”(Fire and Forget)。K8s 的 Job Controller 会自动处理重试、节点宕机迁移。

2. 后知后觉的“顿悟”:责任的转移

当几千行 Java 调度代码被缩减为几十行配置生成代码时,我突然意识到了一件事:

那些棘手的问题(网络中断、节点宕机、文件损坏)并没有消失。 物理世界依然混乱,网线依然会断。

但是,谁在负责处理这些混乱?

  • 以前:是我的 Java 业务代码。我被迫把“业务逻辑”(训练模型)和“基础设施逻辑”(重试、探活、传输)强耦合在一起。
  • 现在:是 Kubernetes 控制平面。

我终于明白了面试官那句话的含义:并不是我的 Java 代码写得烂,而是这些“脏活累活”压根就不该由业务代码来做。 通过声明式 API,我把处理分布式复杂性的责任,从应用层(Application Layer) 彻底解耦交还给了 基础设施层(Infrastructure Layer)

这一刻,我才开始真正思考一个更深层的问题:既然这些问题是无法消除的,那么在计算机科学中,它们到底被如何定义?我们到底还要造多少个轮子去对抗它们?

第四部分:灵魂拷问 —— 我们还要造多少个轮子?

—— 从计算机科学(CS)学生与研究者的视角,探讨分布式系统的本质规律。

带着重构后的思考,我不再局限于具体的代码实现,而是试图站在计算机科学的高度,去寻找一个系统性的答案。我发现,所有的这些“翻车现场”,在理论界早有定论。

1. 物理层的诅咒:分布式三大难题 (The 3 Problems)

如果我们跳出具体的业务,将之前的故障抽象一下,会发现所有的分布式系统都面临着三个无法回避的物理公理。这就好比你在造飞机,却试图忽略重力。

  • 部分失败 (Partial Failure):这是分布式系统与单机系统最本质的区别。在单机里,进程要么活要么死。但在分布式系统中,节点会处于“既死又活”的叠加态(例如:我的“孤儿容器”——网络断了,但进程还在跑)。
  • 不可靠的时钟 (Unreliable Clocks):分布式系统中没有全局唯一的物理时钟,我们无法简单地通过时间戳来判断事件的先后顺序。
  • 共识难题 (Consensus):在上述两个前提下,让多个节点对“当前状态”达成一致,在数学上是极难的。这直接导致了我的“脑裂”现象。

2. 认知层的幻觉:八大谬误 (The 8 Fallacies)

为什么我们会在代码中犯下这些错误?因为我们习惯了单机编程的安逸。Sun Microsystems 的 Peter Deutsch 早在 1994 年就总结了 “分布式计算的八大谬误”

初级工程师往往默认这些谬误是真理,而现实中它们全是谎言。对照我的旧代码,我几乎踩中了所有的坑:

  1. 网络是可靠的 (The network is reliable):假设文件流传输永远成功,导致了“半成品”文件。

  2. 延迟是零 (Latency is zero):假设容器启动是瞬间的,用简单的 sleep 等待,导致了“僵尸容器”。

  3. 带宽是无限的 (Bandwidth is infinite):并发传输大模型时,网卡带宽耗尽导致心跳包丢失。

  4. 网络是安全的 (The network is secure):内网环境也并非绝对安全,未加密的传输存在风险。

  5. 拓扑结构不会变 (Topology doesn't change):硬编码了容器 IP,结果 Pod 重启后 IP 漂移,连接失效。

  6. 只有一名管理员 (There is one administrator):你控制不了依赖的服务何时被运维重启。

  7. 传输成本是零 (Transport cost is zero):序列化与反序列化的 CPU 开销不可忽视。

  8. 网络是同构的 (The network is homogeneous):不同节点的硬件配置、操作系统版本可能完全不同。

结论:只要我们还在应用层试图通过简单的逻辑去掩盖这些谬误,我们就永远在造一个“漏气的轮子”。

3. 科学家的视角:用控制论对抗“熵增”

在物理学中,熵 (Entropy) 代表系统的混乱度。热力学第二定律告诉我们:在一个封闭系统中,熵总是自发增加的。

在分布式系统中,节点宕机、网络丢包、资源抢占,这些都是系统自发走向混乱(熵增)的表现。我的“保姆式”代码一旦异常中断,系统就留下了垃圾(孤儿、僵尸),这就是熵增。

Kubernetes 的核心哲学,在于引入了控制论 (Control Theory) 中的 负反馈调节 (Negative Feedback Loop)

K8s 的 Reconcile Loop(调和循环) 本质上是一个 PID 控制器。它不依赖单次指令的成功,而是通过无限循环:

  1. Observe(观察):现在的世界是什么样?
  2. Analyze(分析):期望的世界是什么样?
  3. Act(行动):修正误差。

公式表达为:通过不断行动,使 Error(t)=Desired_StateCurrent_StateError(t) = Desired\_State - Current\_State 趋近于零。

K8s 从来不保证每个操作一定成功(因为物理故障不可避免),但它承诺会通过不断的反馈调节,将系统的状态强行收敛到我们 YAML 中期望的有序状态。

拥抱不可靠性的哲学

"分布式系统的本质不是避免故障,而是优雅地处理必然发生的故障。"

—— 谷歌 SRE 核心理念

4. 工程师的取舍:CAP 定理下的理性选择

在理解了物理规律后,工程师必须做出现实的取舍。这便是著名的 CAP 定理。这也是为什么 K8s 的设计与我的旧代码有着本质的不同。

什么是 CAP?

  • C (Consistency) 一致性:在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
  • A (Availability) 可用性:在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(保证每个请求都能得到非错误的响应,但不保证数据最新)
  • P (Partition Tolerance) 分区容错性:以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在 C 和 A 之间做出选择。

残酷的现实:P 是不可选的

在分布式系统中,网络分区(网线断了、路由器挂了)是必然发生的物理现象。所以我们无法丢弃 P。我们只能在 CP(一致性优先) 和 AP(可用性优先) 之间做选择。

  • 我的旧方案 (AP 选择 —— 自欺欺人)

    • 我的 Java 代码追求 Docker API 的快速响应(Availability)。只要 API 返回 OK,我就立刻修改数据库状态为 RUNNING
    • 代价:我牺牲了 Consistency(一致性)。当容器在物理上启动失败,或者网络分区导致 Docker 没收到命令时,数据库依然显示成功。结果就是严重的 “脑裂”
  • K8s 的方案 (CP 选择 —— 实事求是)

    • Kubernetes 的核心存储 Etcd 是一个基于 Raft 算法的强一致性(CP)系统。
    • 表现:当集群发生分区,或者 K8s 控制平面无法确认资源状态时,它宁可让 Pod 处于 PENDINGUnknown 状态(暂时牺牲可用性),也绝不欺骗上层应用说“任务成功了”。哪怕这一刻 API 暂时不可用(写操作阻塞),它也要保证数据绝不出错。
    • 启示:它告诉我们,在不可靠的网络中,维护 “单一事实来源 (Single Source of Truth)” 远比一时的响应速度重要。

结语

拥抱 Kubernetes,不是为了追逐技术潮流,而是出于对计算机科学复杂性的敬畏。

我们不应该在用户态(业务代码)去重新发明内核态(基础设施)的轮子。将分布式治理的脏活累活归还给基础设施,让工程师回归到算法与业务创新的高地。