第 1 章 · 为什么需要编排系统

一句话结论:编排系统要解决的,不是「怎么跑一个容器」,而是「当容器数量从 1 个涨到 1 万个、机器从 1 台涨到 1 千台时,那些你再也无法靠手动 ssh 去处理的脏活累活」。它是在「规模」这个变量介入后,被逼出来的东西。


1. 先回到「单机时代」:一切都很好

假设你只有一台机器,跑一个 Web 服务。这时你的世界很简单:

docker run -d --name web -p 80:80 myapp:v1
  • 挂了?重启一下。
  • 要更新?docker stop + docker run 新版本。
  • 就一个容器,记都记得住它在哪。

这时候你不需要任何「编排」——因为「状态」就在你脑子里:一台机器、一个容器、一个端口。


2. 规模一变,问题像多米诺一样倒下来

现在业务涨了,你要跑 100 个服务、部署到 20 台机器。试着纯手动管理,你会发现下面这些问题一个接一个冒出来:

#问题手动有多痛苦
1调度:这个服务该放哪台机器?得自己记哪台机器还剩多少资源
2服务发现:别的服务怎么找到它?IP 写死在配置里,机器一换全废
3负载均衡:流量怎么分给 3 个副本?得自己配 nginx,副本数一变就得改
4自愈:某台机器半夜挂了怎么办?得靠告警 + 半夜爬起来手动迁移
5滚动更新:发新版不能停服,怎么滚?得写脚本,一个副本一个副本地换
6扩缩容:双十一流量翻 10 倍怎么办?手动加机器、加副本,全靠人肉
7配置/密钥管理:几十个服务的配置和密码怎么管?散落在各台机器的文件里,改一处漏一处

核心洞察:这些问题里,每一个单独拎出来都不难解决——写个脚本就能搞定「调度」,配个 nginx 就能搞定「负载均衡」。难的是「规模」让它们纠缠在一起,且必须「持续地、自动地」被解决,而不是「一次性、手动地」解决。

类比:一个人照看一棵盆栽,很简单。但让你照看一个 1000 棵树的果园——浇水、剪枝、除虫、换盆,你不可能一棵棵手动记「这棵上周浇过、那棵该剪了」,你必须有一套系统性的规则 + 自动化。编排系统,就是那套「果园管理系统」。


3. 思考的分叉:面对这些问题,有两条路

方向 A:脚本编排——把「调度 + 更新 + 负载均衡」写成一堆脚本,用 cron 或 CI 驱动。

  • 优点:起步快,门槛低,完全可控。
  • 致命伤:脚本是「命令式」的,它描述的是「步骤」,不是「目标」。一旦某一步执行到一半出错(机器挂了、网络抖了),脚本不知道「正确状态应该是什么」,无法自己恢复。而且脚本之间会互相踩——「更新脚本」和「自愈脚本」同时跑,可能打架。

方向 B:平台化编排——设计一个系统,持续地把「集群的实际状态」推向「你声明的期望状态」。

  • 优点:自愈、可扩展、可声明式管理。
  • 代价:前期设计成本高,你要从「写脚本」跳到「设计一个分布式系统」。

为什么 K8s(继承 Borg 的经验)坚定地走 B:

  • 因为 Google 用 Borg 验证过一个铁律——在大规模下,「靠人/脚本记住状态」是必崩的。机器会挂、网络会分区、脚本会半途中断,唯一可靠的做法是:系统内部始终维护一份「期望状态」,并有一个永不停止的循环去「校对」实际状态和期望状态的差距。
  • 这条路很贵,但它是「一次性投入、长期复用」;而脚本路线是「每次出问题都要再写一个补丁脚本」,规模越大越失控。

这一章埋下的钩子,就是整个 K8s 的两大基石——「声明式期望状态」(第 2 章)和「持续校对的控制循环」(第 3 章)。可以说,K8s 的整个设计,都是围绕「怎么可靠地把实际状态推向期望状态」这个命题展开的。


4. 伪代码:一个「naive 编排脚本」为什么注定失败

先看看方向 A(脚本)长什么样,再体会它缺什么:

// naive 的部署脚本:命令式,描述「步骤」
func deploy(service string, replicas int) {
    nodes := pickNodes(replicas)          // 步骤1:选机器
    for i, node := range nodes {
        ssh(node, "docker run "+service)  // 步骤2:逐台启动
    }
    updateNginx(nodes)                    // 步骤3:更新负载均衡配置
}

它缺了什么?缺「目标」。你问它:

  • 「第 2 步跑到第 3 台机器时,这台机器挂了,剩下 2 台没跑,怎么办?」——脚本不知道,它只会报错退出,然后那 2 台永远没跑。
  • 「某个副本半夜自己退出了,怎么发现?」——脚本不会「持续盯着」,它跑完就结束了。

而一个编排系统的核心骨架,是反过来的:

// 编排系统的骨架:声明式,描述「目标」
desired := State{service: "web", replicas: 3}   // 我要 3 个副本
 
for {                                            // 一个永不停止的循环
    actual := observe()                          // 观察:现在实际有几个副本在跑?
    if actual != desired {                       // 校对:和期望一样吗?
        reconcile(actual, desired)               // 不一样就动手,把实际推向期望
    }
    sleep(interval)
}

这个「观察 → 校对 → 动手」的无限循环,就是 K8s 的灵魂。它和脚本的本质区别,一句话说清:脚本是「跑一次就结束的步骤清单」,编排系统是「永不结束的目标守护者」。


思考题

Q:上面那个「无限循环」里,我故意写了一个 sleep(interval)——每隔一段时间轮询一次。但真实系统里,K8s 并没有「傻等」,它是靠「事件通知」即时感知变化的。这两种方式(轮询 vs 事件驱动)各有什么优缺点?为什么一个严肃的编排系统要选事件驱动?

参考答案:

  • 轮询(polling):简单,但有两个问题——① 延迟(变化发生了,要等到下一个周期才被发现);② 浪费(大部分轮询都「什么都没变」,白白查询)。
  • 事件驱动(watch):状态一变就立刻通知订阅者,延迟低、无空转。但它更复杂——需要一套「订阅 + 推送 + 断线重连 + 事件顺序保证」的机制。

一个严肃的编排系统选事件驱动,是因为规模:几千个节点、几万个 Pod,状态每时每刻都在变,靠轮询去「扫全量状态」的代价是 O(集群规模) 的浪费;而事件驱动只在「真的变了」时才动,代价是 O(变化量)。

但事件驱动也有代价——它需要可靠的事件源和「事件丢了怎么办」的兜底。所以真实系统(包括 K8s)其实是**「事件驱动为主 + 定期全量对账兜底」**的混合:平时靠 watch 快速响应,万一事件丢了,靠周期性的全量 reconcile 把漏掉的补回来。这个「事件驱动 + 兜底对账」的组合,正是第 4 章 etcd 的 watch 机制要承载的东西。