半夜被电话叫起来处理一次线上故障,原因说出来有点丢人:两周前有人手动kubectl edit改了一个线上 Deployment 的副本数,没留任何记录,后面一次版本发布把仓库里的清单重新 apply 回去,副本数被打回原样,流量高峰一来容量不够,链路上游跟着雪崩。这类事故在 K8s 集群里几乎每天都在以不同形式上演,根子不在某个人粗心,而在于我们用操作传统服务器的习惯,在操作一个声明式系统。K8s 声明式API这套东西,表面上只是让你写 YAML 而不是敲命令,实际上它改变的是整条交付链路的思考方式:你描述"我要什么",系统负责把现状掰回你要的样子。
这篇内容我打算把它拆透:从声明式与命令式的根本差异讲起,讲控制器循环为什么是整个机制的地基,讲apply背后到底发生了什么、字段归属和冲突是怎么产生的,再落到一套能直接抄走的目录结构、清单写法、多环境组织和排错路径。适合三类人看:刚上手 K8s、写 YAML 全靠复制粘贴的开发;负责发布系统和流水线、被"配置漂移"折磨过的运维;以及准备把交付流程往 GitOps 方向推、但还没想清楚边界在哪的团队负责人。文中所有集群和业务场景都是虚构代称,参数取值也只是常见实践的参考值,落地时按自己的实际情况调整。
1. 声明式API到底在解决什么问题
1.1 从"敲命令"到"描述状态"的思维切换
命令式操作的逻辑是:我告诉你第一步做什么、第二步做什么。装个软件包就是apt install,改个配置就是sed -i,扩个容就是kubectl scale --replicas=5。这套逻辑在单机上跑了很多年,很自然,但搬到分布式集群上就开始崩。因为命令是"一次性"的,它只对执行那一刻的状态负责,执行完之后发生了什么,下发命令的人不知道,系统也不记得。
声明式操作换了个方向:我不告诉你过程,我告诉你终态。清单里写replicas: 5,意思是"这个工作负载我期望有 5 个副本在跑",至于现在实际是 3 个还是 8 个、要新建还是销毁、走哪台节点、用哪个镜像层,都不是我要操心的事。控制面会自己比对,然后一步步往 5 靠。
这个切换带来的第一个直接好处是可重复。同一份清单,在测试集群和生产集群跑出来的语义是一样的,不需要维护两套脚本。第二个好处是可对比。清单是文本,能进版本库,能 diff,能 code review,谁改了哪一行、什么时候改的、为什么改,全都有迹可循。第三个好处是可收敛。手工改动、误删资源、节点漂移,控制器会持续把实际状态拉回期望状态,这套"自我修复"能力是命令式脚本永远给不了的。
我见过不少团队,嘴上说在用声明式,实际上是把一长串kubectl run、kubectl expose塞进 shell 脚本里按顺序执行。这只能叫"批量命令式",一旦中间某步失败,前面已经改掉的东西不会回滚,集群就停在了一个谁也没设计过的中间态。真正的声明式,判断标准很简单:把清单重复 apply 十次,结果应该完全一致,且第十次和第一次没有任何差别。如果你做不到这一点,说明清单里混进了随机值、时间戳或者外部依赖。
1.2 命令式与声明式的关键差异对照
很多人把这两者的差异理解成"写 YAML 还是敲命令",太浅了。真正的差异体现在失败处理、并发修改、审计追溯这些工程属性上。我把日常最容易踩到的几个维度整理成一张表,方便对照。
| 维度 | 命令式操作 | 声明式操作 |
|---|---|---|
| 输入 | 动作 + 参数(scale、edit、delete) | 完整的期望状态清单 |
| 幂等性 | 通常不幂等,重复执行结果不同 | 天然幂等,重复执行结果一致 |
| 失败处理 | 停在中途,需人工判断从哪继续 | 控制器持续重试,自动收敛 |
| 多人协作 | 后操作覆盖前操作,互相不可见 | 版本库合并,冲突在提交阶段暴露 |
| 审计能力 | 依赖审计日志逐条回溯 | 清单 diff 直接看出变更内容 |
| 漂移处理 | 无,手工改动会一直存在 | 控制器检测并纠正 |
| 回滚方式 | 反向执行命令,容易漏 | 切回上一个版本的清单 |
注意表里"回滚方式"这一行。命令式回滚最坑的地方在于它需要"反向操作",而反向操作往往不是原操作的简单取反。你手工加了三个标签,回滚时得记住加了哪三个、值是什么;你手工改了镜像版本,回滚时得记住原来那个 tag。声明式回滚就是把 git 里上一版清单重新 apply 一次,中间过程全部由控制面计算,不需要人记任何东西。
还有一个经常被忽略的点:声明式不排斥命令,但排斥"用命令做持久变更"。kubectl get、kubectl describe、kubectl logs这些只读操作,命令式用起来效率极高,完全没必要改成什么声明式。真正要守住的红线是写操作:凡是会改变集群状态的动作,都应该落到清单里、走版本控制。这条线一划清楚,很多争论就没意义了。
1.3 控制器循环:声明式API能成立的地基
清单写完了,谁去执行?答案是控制器。理解这一点,是理解整个声明式体系的关键。控制器的逻辑非常朴素,就是个死循环:读期望状态,读实际状态,比对,有差异就采取动作,然后 sleep 一小会儿,再来一遍。业内把这个叫 reconcile 循环,也叫调谐循环。
这个循环有两个重要特性。第一是电平触发(level-triggered)而非边沿触发(edge-triggered)。意思是控制器不关心"从 3 变成 5"这个事件本身,它只关心"现在实际是几,期望是几"。这带来的好处是容错性极强:哪怕控制器重启、漏掉了一百个事件、消息队列丢了一大半,只要它醒过来看一眼当前状态,照样能把事情做对。相比之下,事件驱动的系统一旦丢事件,状态就永久性地偏了。
第二是关注点分离。每种资源有自己专属的控制器,Deployment 控制器管副本和滚动更新,ReplicaSet 控制器管 Pod 数量,Node 控制器管节点心跳和驱逐,各自只处理自己那一摊,互相通过 API Server 里的对象"传纸条"。Deployment 不直接创建 Pod,它创建 ReplicaSet;ReplicaSet 不直接调度 Pod 到节点,它创建 Pod 对象,再由调度器挑节点。这一层层拆下来,每一层的逻辑都很简单,但组合起来能处理非常复杂的场景。
我在实际排查问题时有个习惯:看到任何"状态不对",先问一句"这件事归哪个控制器管"。Pod 一直 Pending,那是调度器的事,跟 Deployment 没关系;Deployment 显示READY 3/5但 Pod 都是 Running,大概率是就绪探针没过;Pod 被反复重建,可能是节点压力驱逐,也可能是存活探针太激进。把责任主体定位清楚,再去看对应控制器的日志和事件,效率比漫无目的地翻日志高一个量级。
2. 核心机制拆解:一份清单的完整生命周期
2.1 资源对象的固定骨架
任何一份 K8s 清单,无论多复杂,都逃不出五个顶层字段:apiVersion、kind、metadata、spec、status。前四个由你写,最后一个你只能读。这个约束不是随便定的,它把"人的意图"和"系统的观测"彻底分开了。
apiVersion加kind唯一确定一个资源类型,也决定了这份清单归哪个控制器管、走哪套校验规则。metadata里最重要的是name和namespace,前者在同一类型同一命名空间内唯一,后者决定资源归属哪个逻辑隔离域。labels和annotations也都放在这里,但两者定位完全不同:labels 是给选择器用的,要短、要稳定、要能被-l查询;annotations 是给工具和人看的,可以塞长文本、可以放 JSON、可以随时改,不参与筛选。
spec是期望状态,是整个清单的灵魂,也是各家资源差异最大的地方。status是实际状态,由控制器写入,你手改它会被立刻覆盖回去。这个"spec 归你、status 归系统"的约定,是所有声明式操作能安全进行的前提。
写清单时有个细节值得强调:命名空间要显式写在 metadata 里,别依赖-n参数。我见过太多人本地调试时靠kubectl apply -n staging -f .一次性指定命名空间,结果某天把这条命令复制到另一个环境,整批资源就落错地方了。清单自带 namespace,配合kubectl apply -f直接执行,是最不容易出错的写法。代价是同一份清单不能跨环境复用命名空间——这正是后面要用 Kustomize 或 Helm 解决的事。
2.2 从提交到落地:中间经过了什么
一份清单从本地文件变成集群里真实运行的容器,中间要穿过好几道关卡。搞清楚这条链路,排错时才知道该在哪一环下手。
第一环是客户端解析。kubectl 读文件,解析 YAML,做基础的格式校验。这一步报错通常很直白,比如 YAML 缩进错了、tab 和空格混用了、类型对不上(该写数字的地方写了字符串)。
第二环是认证与鉴权。请求带着身份信息到 API Server,先认证"你是谁",再鉴权"你能不能对这个资源做这个动作"。RBAC 配错的时候,报错信息是forbidden,会明确告诉你缺少哪个 verb 对哪个 resource。这个错误跟清单内容没关系,纯粹是权限问题,别在 YAML 里瞎改。
第三环是准入控制。这里分两步,先跑 Mutating Webhook,再跑 Validating Webhook。Mutating 的职责是"改":给没写 resources 的容器补上默认值,给没写 imagePullPolicy 的补成IfNotPresent或Always,给 Pod 注入 sidecar,打上各种默认标签。Validating 的职责是"拦":检查镜像仓库是否在白名单、是否写了 limits、标签是否符合规范,不合规直接拒掉。很多"我明明没写这个字段,为什么集群里有"的困惑,答案就在 Mutating 这一层。
第四环是持久化。通过所有校验后,对象被写入 etcd。写入时会带上resourceVersion,这是乐观锁的版本号,后面所有更新都要基于这个版本,版本对不上就返回 409 冲突。
第五环是控制器响应。API Server 只负责存,不负责干活。存进去之后,watch 到变化的控制器才开始动:调度器挑节点,kubelet 拉镜像起容器,各资源的控制器建下级对象。
这条链路里,90% 的"apply 成功但没生效"都出在第三环和第五环。第三环是清单被悄悄改了,你以为写的是 A,实际存的是 B;第五环是控制器有它自己的判断逻辑,比如资源配额不够、亲和性不满足、PVC 绑不上,它会一直卡在那儿,但 API Server 层面完全正常。
2.3 create、apply、patch 三种写入方式的真实区别
新手最容易混淆的就是这三个命令的分工。它们的定位完全不同,混用会带来很难查的问题。
kubectl create是"创建",对象已存在就报AlreadyExists。它不幂等,所以只适合一次性初始化场景,比如建命名空间、建初始的 Secret。生产环境的常规发布流程里,不应该出现 create。
kubectl apply是"创建或更新",是声明式工作流的主力。它的核心逻辑是三方合并:拿你的新清单、集群里的现有对象、以及上一次 apply 时存下来的那份快照,三方一起比对,算出该增删改哪些字段。这份"上一次的快照"就存在对象的一个特殊 annotation 里。
kubectl patch是"局部修改",只提交你想改的那部分。它有三种方言:strategic merge patch(kubectl 默认,认识列表的合并策略)、JSON merge patch(整体替换列表)、JSON patch(按路径精确操作,支持增删和条件判断)。patch 在自动化脚本里很常见,但用在人手上很容易出问题——因为你看到的对象是经过默认值填充和 Webhook 改写后的完整对象,而你 patch 的只是其中一小块,两者对不上时结果是反直觉的。
注意:
kubectl apply的客户端模式对last-applied-configuration这个注解有强依赖。如果有人用kubectl create建了对象,或者用kubectl replace覆盖过,这个注解就会缺失或过期,之后 apply 的合并基准就错了,可能出现"删不掉的字段"。这是线上最隐蔽的一类问题。
2.4 服务端字段管理与冲突的来龙去脉
客户端 apply 的三方合并在早期确实够用,但规模一大就露馅:注解体积膨胀、合并逻辑要靠客户端实现、多个工具(kubectl、Helm、各类 Operator)同时对同一对象操作时互相踩脚。于是服务端字段管理(Server-Side Apply,简称 SSA)被引入,从 1.16 的实验特性一路走到 1.22 前后转为稳定。
SSA 的思路是:把"谁管哪个字段"这件事交给 API Server 记账。每个对象多了一个managedFields列表,记录每个管理者(比如kubectl、helm、某个控制器)拥有哪些字段。当你提交一份清单时,你声明的字段就归你管,你没声明的字段不归你管,别人管的字段你去改就会触发冲突。
冲突报错长这样:
Apply failed with 1 conflict: conflict with "kubectl-client-side-apply": . spec.replicas这行信息的含义非常明确:spec.replicas这个字段当前归kubectl-client-side-apply管,你现在用另一种身份去改它,系统不敢擅自决定听谁的。处理方式有两种:要么承认对方的所有权,把字段从自己的清单里删掉;要么明确接管,加上--force-conflicts。
我更推荐前者。冲突本身是个信号,说明这个字段有两个"作者",这在一套健康的声明式流程里是不该出现的。真要用--force-conflicts,最好也是一次性的迁移动作,而不是塞进流水线的固定命令里。
用 SSA 的实操命令大概是这样:
# 预演,只做服务端校验和 diff,不落盘 kubectl apply --server-side --dry-run=server -f deploy/ # 正式提交 kubectl apply --server-side -f deploy/ # 查看某个字段当前归谁管 kubectl get deploy web-api -o jsonpath='{.metadata.managedFields}'--dry-run=server这个参数值得多用。它在服务端把整条链路跑一遍——鉴权、准入、默认值填充、字段合并——就是不写 etcd。很多"本地看着没问题、上线就报错"的情况,用 server 端 dry-run 就能提前发现。
2.5 别忽略默认值和列表合并策略
默认值填充这件事,坑比想象中深。你写了个 Deployment,没写spec.strategy,集群里会给你补上RollingUpdate和maxSurge: 25%, maxUnavailable: 25%;没写revisionHistoryLimit,会补成 10;没写terminationGracePeriodSeconds,会补成 30;容器的imagePullPolicy,如果 tag 是latest就补成Always,否则补成IfNotPresent。这些默认值大部分时候是合理的,但有几处会实实在在咬人。
imagePullPolicy就是典型。很多人本地推了latest镜像,以为集群会重新拉,结果节点上缓存的是旧镜像——因为某些情况下 tag 解析或配置改写让策略变成了IfNotPresent。我在清单里一律显式写死imagePullPolicy,同时强制要求镜像使用不可变的 digest 或语义化版本号,latest直接禁止进仓库。
列表合并策略是另一个隐蔽点。K8s 对不同类型的列表有不同的合并语义:有些列表是"原子"的,整体替换;有些是"按 key 合并"的,比如容器的env按name合并,ports按containerPort合并。这个行为由字段上的patchMergeKey和patchStrategy标记决定。原子列表的坑在于:你只想加一个环境变量,结果整段env被替换掉了;合并列表的坑在于:你想删一个环境变量,但因为合并语义是"有则改、无则加",删不掉,得显式写$patch: delete。
排查这类问题有个土办法但很有效:改完先kubectl diff -f 文件看一眼,diff 出来的内容跟你以为的一致,再 apply。kubectl diff会调用服务端 dry-run 并输出统一格式的差异,比事后kubectl describe猜要靠谱得多。
3. 从零搭一套能落地的声明式工作流
3.1 目录结构与命名约定
声明式工作流能不能长期维护,很大程度上取决于目录怎么组织。我踩过的最大坑是"按资源类型分目录"——所有 Deployment 放一个文件夹,所有 Service 放另一个,所有 ConfigMap 放第三个。刚开始觉得整齐,等到要改一个服务的完整配置时,得在三个目录之间来回跳,漏改一个就是线上事故。
后来统一改成按应用分目录、按环境分层的结构,长这样:
manifests/ ├── base/ │ ├── web-api/ │ │ ├── kustomization.yaml │ │ ├── deployment.yaml │ │ ├── service.yaml │ │ ├── configmap.yaml │ │ └── hpa.yaml │ └── worker/ │ ├── kustomization.yaml │ └── deployment.yaml └── overlays/ ├── staging/ │ ├── kustomization.yaml │ └── patch-replicas.yaml └── production/ ├── kustomization.yaml ├── patch-replicas.yaml └── patch-resources.yamlbase 里放"所有环境都一样"的部分:镜像名、端口、探针、标签规范。overlays 里放"不同环境不一样"的部分:副本数、资源配额、域名、日志级别。这个划分的判定标准很实用:如果一个字段在所有环境取值都一样,它就该在 base;只要有一个环境不一样,它就该被抽成 patch。
命名上我坚持三条:文件名等于资源类型加用途(deployment.yaml、service-headless.yaml);资源name用应用名-角色的格式(web-api、web-api-canary);标签至少包含app、component、env三个,选择器只认前两个。多出来的env标签不参与选择,只用于查询和监控聚合,这样同一个应用在不同环境里能被统一检索。
3.2 一份可以直接抄的 Deployment 清单
讲概念不如直接看东西。下面这份清单是我反复调整后的模板,去掉了业务特定的部分,保留的结构都是我认为必须显式声明的。
apiVersion: apps/v1 kind: Deployment metadata: name: web-api namespace: production labels: app: web-api component: backend env: production spec: replicas: 4 revisionHistoryLimit: 5 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: web-api component: backend template: metadata: labels: app: web-api component: backend env: production spec: terminationGracePeriodSeconds: 45 containers: - name: app image: registry.internal/web-api:1.8.3 imagePullPolicy: IfNotPresent ports: - name: http containerPort: 8080 env: - name: LOG_LEVEL value: info envFrom: - configMapRef: name: web-api-config resources: requests: cpu: 500m memory: 512Mi limits: cpu: 1000m memory: 1Gi startupProbe: httpGet: path: /healthz port: http failureThreshold: 30 periodSeconds: 2 readinessProbe: httpGet: path: /readyz port: http initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: httpGet: path: /healthz port: http periodSeconds: 20 failureThreshold: 3 lifecycle: preStop: exec: command: ["sh", "-c", "sleep 10"]几个参数的选择理由值得说清楚。
maxUnavailable: 0加maxSurge: 1,意思是滚动更新期间不允许任何 Pod 不可用,只能先起新的、再停旧的。这个组合适合对可用性敏感的在线服务,代价是更新期间会多占一份资源,集群容量得留够余量。批量任务类的服务反过来,可以放宽maxUnavailable换取更快的发布速度。
terminationGracePeriodSeconds: 45配合preStop里 sleep 10 秒,是处理"优雅下线"的标准手法。原因在于 Pod 从"被标记删除"到"真正从 Service Endpoints 里摘掉",中间有时间差,如果容器立刻退出,这段时间进来的请求会打到已经死掉的进程上,表现出来就是发布期间零星 502。preStop 里的 sleep 给了 kube-proxy 和负载均衡器足够的传播时间。这个数值怎么定?大致等于"端点摘除的收敛时间"加上一点余量,小集群十几秒够了,跨可用区的大集群可能要二十几秒。
startupProbe是给慢启动应用准备的。有了它,liveness 探针在启动阶段不会误杀容器,不需要把initialDelaySeconds调得特别大。参数上failureThreshold: 30乘periodSeconds: 2等于最长允许 60 秒启动,超过就重启。这个数要按实际冷启动时间留两到三倍余量。
资源配额那一段,requests 和 limits 的比例定在 1:2 左右,是我个人的经验值。requests 给太少,调度器会把 Pod 塞到已经过载的节点上;limits 给太紧,内存一超就是 OOMKill,CPU 一超就是被限流,延迟毛刺全出来了。这对数值还直接决定了 Pod 的 QoS 类别:requests 和 limits 完全相等是 Guaranteed,只设 requests 是 Burstable,都不设是 BestEffort。核心服务尽量往 Guaranteed 靠,节点被打满时被驱逐的优先级最低。
3.3 用 Kustomize 组织多环境的实操
Kustomize 已经内置在 kubectl 里,不需要额外装东西,这是它相比其他工具的最大优势。base 的kustomization.yaml大概长这样:
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: default commonLabels: team: platform resources: - deployment.yaml - service.yaml - configmap.yaml images: - name: registry.internal/web-api newTag: 1.8.3overlay 的写法是引用 base,再叠加差异:
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: production resources: - ../../base/web-api patches: - path: patch-replicas.yaml - path: patch-resources.yaml images: - name: registry.internal/web-api newTag: 1.8.3patch-replicas.yaml里只写要改的部分:
apiVersion: apps/v1 kind: Deployment metadata: name: web-api spec: replicas: 12渲染和预览的命令:
# 看最终渲染结果,不提交 kubectl kustomize overlays/production # 渲染后直接 diff kubectl diff -k overlays/production # 提交 kubectl apply -k overlays/productioncommonLabels这个字段在新版里已经改名成labels,带上了includeSelectors之类的控制项,老版本用commonLabels仍然能跑。这里有个历史坑需要提醒:早期commonLabels会同时改 Pod 模板的标签和 Deployment 的选择器,而选择器在 Deployment 创建后是不可变的,一旦你后来改了公共标签,apply 会直接报field is immutable。解决办法要么是删了重建(在线服务不可接受),要么是在 base 里就把选择器写死,别让它跟着公共标签走。我现在一律显式写selector.matchLabels,不让工具去推导。
多环境管理的另一个实践点:镜像 tag 只在一处定义。上面例子里,base 和 overlay 都写了images,实际生效的是 overlay 那份。团队规模大了以后,更稳的做法是把 tag 抽成 CI 注入的变量,通过kustomize edit set image在流水线里统一改,人只维护结构,不维护版本号。
3.4 声明式的删除、级联与 finalizer
删除这件事看起来简单,实际上声明式流程里最容易出意外。kubectl delete -f和kubectl delete -k都是按清单删,但删的粒度取决于清单里写了什么。如果你只删 Deployment 的清单文件,ReplicaSet 和 Pod 会被级联清理;如果清单里同时有 Service、ConfigMap,它们也会一起消失。这个"一起消失"有时候正是你要的,有时候会误伤 —— 比如 ConfigMap 被其他应用共享着用。
默认的级联策略是background,也就是删除请求立刻返回,后台慢慢清理下级对象。另外两种是foreground(等所有下级都删完才返回)和orphan(只删当前对象,下级变成孤儿保留下来)。日常用得最多的是默认值,需要确认清理完成时用 foreground:
kubectl delete -f deploy/ --cascade=foreground --timeout=120sfinalizer是另一个必须理解的概念。它本质上是对象上的一个字符串列表,只要列表非空,API Server 就不允许这个对象真正从 etcd 里消失。控制器的做法是:删除时看到 finalizer,先执行自己的清理逻辑(释放外部资源、删云上负载均衡、清理存储卷),清理完再把 finalizer 摘掉,对象这才真正删除。
这个机制保证了"删除"这个动作也是声明式、可重试的。但它的副作用是,一旦控制器挂了或者清理逻辑有 bug,对象会永久卡在Terminating状态。我遇到过一次,某个自定义资源的 finalizer 指向的控制器已经被下掉了,那批资源删了三天删不掉,最后只能手工 patch 把 finalizer 摘掉:
kubectl patch myresource sample -p '{"metadata":{"finalizers":null}}' --type=merge这个操作要谨慎,因为它跳过了所有清理逻辑,外部资源可能变成没人管的垃圾。执行前一定要确认那些外部资源是不是真的需要保留。
4. 常见问题与排查实录
4.1 apply 成功但行为不符合预期
这是最高频的一类问题。API Server 返回了configured,你以为万事大吉,实际效果却不对。排查顺序我固定成三步。
第一步,看存进去的到底是什么。kubectl get deploy web-api -o yaml拿到的才是权威版本,不是本地文件。重点比对三处:被 Webhook 注入的字段、被默认值填充的字段、你没写但被其他工具改过的字段。我见过一个典型案例,本地清单里写了resources.limits.memory: 1Gi,但集群里显示512Mi,查了半天发现是某个策略 Webhook 按命名空间配额上限做了截断。这类问题在本地怎么试都复现不了,只能看集群里的实际对象。
第二步,看事件。kubectl describe底部的 Events 段落信息密度极高,调度失败、拉镜像失败、探针失败、挂载失败,基本都会在这里留痕。事件默认只保留一小时,所以排查要趁早,或者提前把事件导出到日志系统。
第三步,看控制器日志。如果是自定义资源,对应的 Operator 日志里通常有完整的 reconcile 过程。标准资源没有这一层,就得靠kubectl get逐级往下看:Deployment 的 status 里conditions会告诉你滚动更新卡在哪一步,ReplicaSet 的 events 会告诉你为什么建不出 Pod。
4.2 滚动更新卡住不动的几种成因
kubectl rollout status一直转圈,是最让人焦虑的场景之一。成因大致可以归成四类,按出现频率排序。
新 Pod 起不来。镜像拉不到、配置缺字段、依赖的下游服务连不上,都会让新 Pod 卡在ImagePullBackOff、CrashLoopBackOff或者Pending。Pending还要再细分:资源不够、节点亲和性不满足、污点没容忍、PVC 绑不上,describe pod里的调度器事件会明说是哪一条。
就绪探针一直不过。Pod 是 Running 的,但 READY 一直是 0/1,滚动更新就永远推进不下去。这时候要手动进容器里 curl 一下探针路径,十有八九是探针路径写错了、端口对不上、或者应用启动依赖的初始化步骤没做完。探针设计上我有个原则:readiness 只判断"能不能接流量",不要在里面做重依赖检查。如果 readiness 里查数据库,数据库抖一下,全部 Pod 一起变 NotReady,流量直接归零,反而放大了故障。
PDB 挡住了驱逐。更新过程中需要驱逐旧 Pod,但 PodDisruptionBudget 的minAvailable设得太高,或者maxUnavailable设成 0,驱逐就被永久阻塞。这种情况describe里会有明确提示,写着 "Cannot evict pod as it would violate the pod's disruption budget"。
maxUnavailable 和 maxSurge 都是 0。这个组合是非法的,API Server 会直接拒绝,但如果是通过 patch 改出来的历史遗留状态,就可能出现更新完全推不动的情况。检查一下spec.strategy.rollingUpdate的两个值,至少有一个得大于 0。
4.3 last-applied-configuration 引发的诡异现象
这个注解带来的问题隐蔽性极高,值得单独拎出来讲。典型症状是:清单里删掉了一个字段,apply 之后集群里那个字段还在;或者清单里改了一个列表元素,apply 之后既没增也没删,变成了两个都存在的怪状态。
根因是客户端 apply 的三方合并依赖这个注解作为"上一次的意图"基准。如果注解丢失(比如对象是kubectl create建的,或者被kubectl replace覆盖过),合并就退化成了"按当前对象和你的清单做差",删字段这类操作就失效了。如果注解被截断(对象太大超过注解大小限制),合并基准就是残缺的,结果更不可预测。
处理方式分两种。短期可以手工补注解,把当前清单序列化后写回去:
kubectl annotate deploy web-api \ kubectl.kubernetes.io/last-applied-configuration="$(cat deployment.yaml | jq -c .)" \ --overwrite长期方案是全面切到服务端 apply,彻底不用这个注解。切换过程本身需要注意:先用kubectl apply --server-side把所有对象过一遍,让managedFields建立起来,之后再逐步清理旧注解。切换期间如果有其他工具还在用客户端 apply,会出现字段所有权打架,所以最好一次性把所有工具都切完。
4.4 版本冲突与并发写入
Operation cannot be fulfilled on deployments.apps "web-api": the object has been modified; please apply your changes to the latest version and try again——这个 409 报错说明乐观锁生效了。你的更新请求带着旧版本的resourceVersion,而对象在这期间被别人改过,API Server 拒绝覆盖。
这个机制是保护,不是障碍。它防止的正是"两个控制器同时改同一个对象,后写的静默覆盖前写的"这类数据丢失。正常流程里不该频繁出现,如果频繁出现,说明有多个写入方在抢同一个对象。
排查方法是看managedFields里都有谁。常见组合是:应用自己的发布流水线、集群级的 HPA、某个自动扩缩容的 Operator、以及人工的临时 patch。HPA 是最容易被忽略的一个,因为它会持续改spec.replicas,而你如果在清单里也写死了 replicas,两边就会一直打架。规范做法是:用了 HPA 的服务,清单里不写 replicas,或者写了也接受它被覆盖。
kubectl apply内部其实有重试逻辑,遇到 409 会重新拉取最新版本再算一次 diff,所以单次 apply 出现的 409 大多能自动消化。真正需要处理的是持续冲突,那基本就是两个"作者"在争同一个字段,得从流程上解决,而不是靠加重试。
4.5 常见问题速查表
把上面这些症状和处置方式整理成表,贴在排查手册里比翻文档快。
| 症状 | 大概率原因 | 首要排查动作 |
|---|---|---|
| apply 返回 configured 但字段没变 | Webhook 改写或默认值覆盖 | kubectl get -o yaml比对实际对象 |
| 删掉的字段 apply 后依然存在 | 客户端 apply 合并基准缺失 | 检查 last-applied-configuration 注解 |
| 报 conflict with 某管理者 | 字段所有权冲突 | 看 managedFields,决定接管还是让出 |
| 滚动更新一直 pending | 新 Pod 调度不上或探针不过 | describe pod看事件和 READY 状态 |
| 对象卡在 Terminating | finalizer 未被摘除 | 查 finalizer 归属,确认后手工摘除 |
| 频繁 409 冲突 | 多方并发写同一对象 | 检查是否有 HPA 或 Operator 同时介入 |
| 镜像明明推了却跑旧的 | 拉取策略或 tag 缓存 | 显式写 imagePullPolicy,改用 digest |
| 列表字段删不掉 | 合并策略是原子替换或按 key 合并 | 用 kubectl diff 预演,必要时写 $patch |
5. GitOps 与声明式API的落地边界
5.1 单一事实来源与漂移收敛
声明式做到极致,就是 GitOps:集群里的一切都由版本库里的文件定义,任何直接对集群的写操作都被视为违规。这个模型的价值不在于"用了什么工具",而在于它把"集群现状"和"期望状态"之间的关系变得可验证。
核心机制是漂移检测。做法是周期性拉取集群里的对象,跟仓库里渲染出来的清单做 diff,有差异就告警或自动同步。自动同步这个开关要谨慎开,因为它意味着任何手工改动都会被无差别覆盖 —— 包括故障时的紧急处置。我一般的建议是:生产环境默认只告警不自动同步,或者自动同步只对无状态的工作负载开放,有状态服务(数据库、消息队列)保持人工确认。
漂移的来源需要分类处理。有几种是"合理漂移":HPA 改的副本数、控制器写的 status、准入 Webhook 注入的字段,这些本来就不该被 Git 覆盖回去。做法是在 diff 的时候把这些字段排除掉,只比对真正属于"人应该管"的部分。剩下的是"不合理漂移":手工改的镜像版本、临时加的环境变量、误删的 Service,这些才是要收敛的目标。
注意:漂移检测工具默认配置下会把 status 字段也纳入比对,导致满屏误报。上线前一定要配好忽略规则,否则用不了两周就没人看告警了。
5.2 哪些东西适合声明式,哪些不适合
不是所有东西都该塞进版本库。我见过把数据库密码明文写进清单提交到仓库的,也见过把一次性运维脚本包装成 Job 清单长期留着的。判断标准其实只有一条:这个对象的值是否应该被稳定复现。
适合声明式的:工作负载本身(Deployment、StatefulSet、DaemonSet)、网络规则(Service、Ingress、NetworkPolicy)、配置结构(ConfigMap 的结构部分)、权限(RBAC)、调度约束、资源配额、PDB、HPA 的阈值规则。这些的共同点是"结构"重要,"当下取值"不重要或者由系统算。
不太适合的:密钥的明文值(应该走外部密钥管理系统,清单里只放引用)、频繁变动的业务数据(不属于 K8s 对象范畴)、一次性的运维任务(用 Job 也最好带上 TTL 自动清理)、时间戳、随机后缀这类非确定值。最后这条是硬约束,清单里一旦出现随机值,apply 就不幂等了,整个声明式流程的地基就塌了。
还有一个中间地带值得单独说:跨集群的差异化配置。不同集群的存储类名、镜像仓库地址、节点标签往往不一样,这类差异如果全部用 patch 表达,overlay 会膨胀得难以维护。我的做法是定义一组有限的"环境变量"层,在渲染阶段注入,清单本身保持干净。这样新增一个集群只需要提供一份变量表,不需要写一堆 patch。
5.3 从手工 apply 到流水线的迁移路径
如果一个团队现在还是手工 apply,想往规范的方向走,我建议分四步,不要一步到位。
第一步,把现网对象导出成清单。kubectl get -o yaml把所有对象拉下来,清掉 status、managedFields、resourceVersion 这些运行时字段,整理成初始的 base。这一步产出的是"当前真相",虽然不完美,但起点必须真实。
第二步,建立渲染和 diff 流程,先不接管发布。每次要改配置时,先改清单,渲染,kubectl diff,确认差异符合预期,再手工 apply。这一步的目的是让团队养成"改代码不改集群"的习惯,同时积累对 diff 输出的判断力。
第三步,把 apply 搬进流水线,加上审批和回滚。审批看的就是 diff 输出,回滚就是 revert 对应的提交。这一步之后,所有变更都有痕迹,事故复盘时能直接定位到哪次提交、谁审的。
第四步,上线漂移检测。这一步放到最后,因为它会暴露前面所有不规范的地方,容易引起抵触。等团队已经习惯了清单工作流,再上检测,告警量才可控。
这四步我在不同规模的团队里都走了一遍,最容易被跳过的是第二步。很多团队直接从手工 apply 跳到全自动流水线,结果一旦出问题,团队连 diff 该怎么看都不熟,排查效率极低。慢一点,把 diff 这一步练熟,后面的自动化才稳。
5.4 排查线上问题的经验总结
最后分享几条从实际事故里攒下来的习惯,跟具体工具无关,属于流程层面。
永远先看集群里的真实对象,不要相信本地的清单文件。本地是意图,集群是事实,两者之间的差距就是排查的入口。我养成的一个动作是:拿到任何告警,第一条命令就是kubectl get <资源> -o yaml -n <命名空间>,把真实状态完整打到屏幕上,而不是凭记忆判断。
变更前后各存一份对象快照。发布前kubectl get -o yaml > before.yaml,发布后再存一份 after,出问题时直接diff,比翻审计日志快得多。这个习惯在排查"发布后行为变了但说不清哪里变了"时会救你。
清单里每个显式字段都要能说出理由。写replicas: 12是因为压测出的 QPS 除以单实例容量,写maxUnavailable: 0是因为这个服务不能有秒级不可用,写memory: 1Gi是因为实测峰值是 700Mi 留了三成余量。说不出来理由的字段,往往是从别处抄来的,哪天就变成了隐患。
版本化的一切都别用 latest。镜像用 digest 或语义化版本,ConfigMap 变更靠内容哈希触发滚动更新,Helm chart 也带版本号。不可变的标识是声明式系统能够可靠复现的前提,任何"最新"语义都会让复现变成碰运气。
真要说这套东西最大的收获,倒不是技术层面的。是它逼迫团队把"我要什么"想清楚、写下来、被审一遍。很多线上问题的根源,其实在写清单的那一刻就埋下了——只是当时没人问一句"这个值为什么是它"。