第 2 章 · 声明式 API:K8s 的第一性原理

一句话结论:K8s 最核心、也最反直觉的设计选择,是**「声明式(declarative)」而不是「命令式(imperative)」**——你告诉它「我要什么状态」(3 个副本、健康、可访问),而不是「你去做这些步骤」。这一个选择,决定了 K8s 后面几乎所有设计的样子。


1. 两种风格的 API,先分清

命令式:你一步步指挥系统「做什么动作」。

kubectl run web --image=myapp:v1      # 动作:跑一个容器
kubectl scale web --replicas=3        # 动作:扩到 3 个
kubectl delete pod web-abc            # 动作:删掉某个 pod

声明式:你描述「我要什么状态」,系统自己想办法达到。

# web.yaml:我要「3 个 web 副本」这个状态,至于怎么达到,你别管
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 3
  selector:
    matchLabels: { app: web }
  template:  # pod 模板
    metadata:
      labels: { app: web }
    spec:
      containers:
      - name: web
        image: myapp:v1
kubectl apply -f web.yaml    # 不是「执行动作」,而是「提交期望状态」

一句话区分:命令式是「给我把灯打开」,声明式是「让这间屋子保持『灯是亮的』这个状态」——后者在灯被风吹灭后,系统会自动再打开,因为它要「保持」那个状态。


2. 思考的分叉:为什么声明式更好?命令式不是更直接吗?

这是 K8s 设计里最值得反复咀嚼的决策。命令式确实更直观,但它在几个关键场景下会「露馅」:

场景一:出错了怎么办?

  • 命令式:kubectl scale web --replicas=3 执行到一半,API 服务器崩了。系统不知道「你到底想扩到几个」,因为命令式记录的是「动作」,不是「意图」。重启后,它无从恢复。
  • 声明式:replicas: 3 这个「意图」被持久保存。系统重启后,控制器一读,发现「实际 2 个,期望 3 个」,自己就补上了。意图不丢,恢复自动发生。

场景二:多人 / 多工具协作怎么办?

  • 命令式:A 用命令式「扩到 3」,B 用命令式「缩到 1」,C 用命令式「更新镜像」——三个动作互相不知道对方的意图,可能冲突。
  • 声明式:所有人提交的都是「期望状态」的描述,系统收敛到最新的那份描述。GitOps 之所以能火,本质就是「声明式状态可以存进 Git、可以 diff、可以 review」。

场景三:如何知道「当前对不对」?

  • 命令式:执行完就完了,没有一个「应该是什么」的基准可供持续比对。
  • 声明式:期望状态是持久化的,系统能持续地拿「实际」对比「期望」,偏差就是工作清单。

为什么 K8s 坚定选声明式:因为 K8s 要解决的核心问题(第 1 章)是「持续、自动地保持正确状态」。而「持续自动」的前提,是系统必须有一个持久化的、明确的「正确状态是什么」。命令式只留下「做过哪些动作」,没有「应该是怎样」——它天然不适合自愈。

类比:命令式像「给保姆一张任务清单:先买菜、再做饭、再洗碗」;声明式像「给保姆定一条规则:让家里始终保持『有饭、厨房干净』」。前者你走开后,清单做完就停了,出了岔子也没人管;后者保姆会持续盯着,饭凉了热、厨房脏了擦。K8s 要的是一个能「持续盯着」的保姆,所以它要声明式。


3. 声明式 API 的三个「配套设计」

选了声明式,不是改个 API 风格就完事,它连锁地要求三样东西:

  1. 期望状态要能被「描述」和「存储」——所以有了 YAML 对象模型(Deployment、Service…),每个对象就是一个「期望状态片段」。
  2. 期望状态要能被「持续比对」——所以有了控制循环(第 3 章),不停拿实际对比期望。
  3. 期望状态要能被「安全地并发修改」——所以有了 etcd 的乐观并发控制(第 4 章),防止两个人同时改一份状态互相覆盖。

你看,一个「声明式」的选择,牵出了后面三章的内容。 这正是分布式系统设计的魅力:顶层的一个决策,会像涟漪一样,决定下面每一层的样子。


4. 伪代码:两种 API 在「语义」上的本质差异

// 命令式:记录「动作」(imperative)
type Command struct {
    Verb string // run / scale / delete ...
    Args map[string]string
}
func Exec(c Command) {
    // 执行完就忘,系统不保留「意图」
    do(c.Verb, c.Args)
}
 
// 声明式:记录「期望状态」(declarative)
type DesiredState struct {
    Kind     string            // Deployment
    Replicas int               // 3
    Image    string            // myapp:v1
}
func Apply(d DesiredState) {
    store.Persist(d)           // ① 先把「意图」持久化
    // ② 之后,由另一个「控制循环」持续地把实际推向这个期望
    // Apply 本身不直接干活,它只是「立下目标」
}

注意那个关键的语义差异:Apply 不是「做」,而是「立目标」。真正「做」的是控制循环。这个「目标」和「执行」的分离,是声明式的精髓——因为目标一旦被持久化、和「谁在执行、执行到哪一步」解耦,系统就获得了「崩溃可恢复、可并发、可对账」的能力。


5. 思维流重放:如果我是当年的设计者

上面是「结论 + 论证」,这一节换个视角——带你从一团乱麻出发,重走「当年的人是怎么从『直觉选命令式』一步步拐到声明式的」。重点不是结论,是中间那几次自我怀疑和反转。

第 1 步 · 直觉:要让用户「告诉系统跑什么」,最朴素的想法就是命令式——run、scale、delete,像 shell 一样,用户下一条、系统执行一条。直观、好实现、好理解。第一反应几乎一定是这个。

第 2 步 · 推演最坏情况:先别急着写代码,问自己一句——「如果系统在『执行到一半』时崩了,会发生什么?」用户下了 scale 3,系统起了 2 个就挂了。重启后,系统怎么知道「用户到底要几个」?——命令里只存了「动作」,没存「意图」。第一个裂缝:命令式不记录意图,崩了就断片。

第 3 步 · 试修补:那能不能加个「操作日志」,把每次动作记下来?——但日志记的是「过去做过什么」,不是「未来应该是什么」。系统要自愈,需要的是「目标」,不是「历史」。第二个裂缝:日志 ≠ 目标,修补不解决问题。

第 4 步 · 反转:换个方向想。我真正要的本质是「系统持续保持某个状态」,那干脆反过来——让用户直接描述「我要 3 个副本」,系统自己算「当前差多少、该怎么补」。这一反转,命令式里「崩溃断片」「意图丢失」的问题就全没了——因为「意图」被当成一等公民持久保存了。

第 5 步 · 权衡收敛:但声明式有代价——它需要「控制循环」持续比对,实现比命令式复杂。值不值?回到第 1 章的目标:我要做的是「持续、自动地保持正确状态」的系统,那「能自愈」是刚需,不是可选项。所以「复杂」这个代价,必须付。选声明式。

一句话复盘:命令式败在「只留下动作、留不下意图」,声明式赢在「把意图持久化、和『谁在执行』解耦」。而这一反转的触发点,不是「命令式哪里不好」,而是主动推演了一次「崩溃」——很多设计决策的分水岭,就在于「你有没有在动手前,先推演一次最坏情况」。


思考题

Q:声明式这么好,是不是意味着「命令式就一无是处、该被消灭」?如果某些场景下命令式反而更合适,是哪些场景?为什么?

参考答案:

命令式没有被消灭,它依然活在几个特定场景里:

  1. 一次性、临时、探查性操作:kubectl exec 进容器看日志、kubectl port-forward 临时转发端口、kubectl get 查状态——这些是「我要现在立刻做这个动作」,没有「持续保持」的语义,用声明式反而别扭。
  2. 调试和排障:你不可能用一份 YAML 去「声明」一次性的 kubectl logs。
  3. 对状态不敏感的一次性动作:删一个临时测试对象,用 kubectl delete 命令式更直接。

所以正确的认知是:命令式和声明式不是「对错」之争,而是「用途」之分——声明式适合「我要持续保持的状态」,命令式适合「我要现在立刻做的动作」。 K8s 的聪明之处,是把「业务负载」这种需要长期保持的东西交给声明式,而把「探查、调试」这种一次性交互留给命令式,两者各司其职。理解了这个分工,你就理解了为什么 kubectl run 后面永远跟着一句官方提醒「这只是一个快速测试,生产请用 apply -f」。