Kubernetes 声明式编排:从控制器到调度与网络模型
Kubernetes 声明式编排:从控制器到调度与网络模型
本文不列 YAML 字段清单,而是回答四个问题:为什么声明式比脚本可靠、控制器到底在循环什么、Pod 为什么被调度到某台机器上、Service 的一个虚拟 IP 是怎么变成真实转发的。最后附上探针、优雅终止与滚动发布的正确配法。
声明式:你写期望,系统负责逼近
命令式(脚本):做 A、再做 B、再做 C
中途失败 → 状态停在半路,重跑可能重复执行,也可能漏掉
声明式(K8s):写下"我要 3 个副本、镜像 v2"
系统持续比较 期望(spec) 与 实际(status),不断消除差异
中途失败 → 下一轮循环会重新尝试,天然幂等、可自愈
这是理解 K8s 的第一性原理:它不是一个"部署工具",而是一组不断把现实拉向期望的控制循环。所有"神奇"的行为(自动重启、自动扩缩、自动重新调度)都是这个循环的副产品。
数据流:唯一真源与三级缓存
kubectl apply ─► kube-apiserver ─► etcd(唯一真源,带 resourceVersion)
│
│ watch 推送增量
▼
┌─────────────┴──────────────┐
│ │
controller-manager kube-scheduler
(Deployment→ReplicaSet (选节点,写 spec.nodeName)
→Pod 的层层收敛) │
│ ▼
│ kubelet(监听绑定到本机的 Pod)
│ │
│ ├─► CRI:拉起容器
│ ├─► CNI:分配 Pod IP
│ └─► CSI:挂载卷
▼
写回 status(Pod 处于 Pending/Running/Ready 等)
两个工程要点:
- Informer 的本地缓存。控制器不是每次去问 apiserver,而是 watch 增量后维护一份内存副本,业务逻辑读缓存。这让控制循环可以高频运行而不打爆 apiserver。
- 工作队列 + 限速。事件先入队,失败按指数退避重入,避免同一个对象出问题时把循环拖死。
控制器:调谐函数只做一件事
// 伪代码,所有控制器的心智模型都一样
for {
key := queue.Get() // 拿到"某个对象的名字"
obj := cache.Get(key) // 从 informer 缓存读最新状态
if obj == nil { continue } // 已被删除,忽略
desired := buildDesired(obj) // 期望状态
actual := observe(obj) // 实际状态
if !equal(desired, actual) {
reconcile(desired, actual) // 只做差异动作,必须幂等
queue.AddAfter(key, backoff) // 需要再次确认时延迟重入
}
}
自定义资源(CRD)+ 自定义控制器 = Operator。判断要不要写 Operator 的标准只有一条:这个中间件是否有"运维知识"需要固化(分片扩缩、主从切换、备份恢复)。只是部署它,用 StatefulSet + Helm 就够。
调度:先过滤,再打分
调度一个 Pod 分两步:
Filter(硬性条件,不满足直接排除节点)
├─ 资源:剩余可分配 CPU/内存 ≥ requests(注意:只看 requests)
├─ 亲和:nodeSelector / nodeAffinity / podAffinity
├─ 污点:节点有 taint,Pod 没有对应 toleration → 排除
└─ 卷:PV 的可用区、ReadWriteOnce 是否已被别的节点占用
Score(软性偏好,0~100 加权求和)
├─ LeastAllocated / BalancedAllocation:挑最空闲的节点
├─ ImageLocality:镜像已在节点上,启动更快
└─ InterPodAffinity / 拓扑分布:尽量打散或聚拢
Bind:把选中的节点写进 spec.nodeName,kubelet 接手
无可调度节点且优先级够高 → 抢占(驱逐低优先级 Pod)
必须记住的一条:requests 才是调度依据,limits 只影响运行时。
| 资源配置 | 调度阶段 | 运行时行为 |
|---|---|---|
| 只设 requests | 按 requests 预留 | CPU 可超用;内存可超用,节点压力大时被动 |
| requests = limits | 按该值预留 | Guaranteed:CPU 超限被节流(CFS quota),内存超限被 OOMKill |
| 都不设 | 按 0 预留,随便塞 | BestEffort:最先被驱逐,节点一紧张就死 |
QoS 与驱逐顺序直接相关:BestEffort → Burstable → Guaranteed。核心服务务必配成 Guaranteed,否则节点内存吃紧时它会先被牺牲。
探针:三个探针解决三个不同问题
startupProbe 启动慢的应用专用。成功前不执行 liveness/readiness,
避免"启动 30 秒的服务被 liveness 在第 10 秒杀掉"
livenessProbe 容器还活着吗?失败 → 重启容器
只放"重启真能修好"的检查:进程死锁、端口不通。
绝不能把依赖检查(数据库、下游)放进来——
依赖抖一下,全部 Pod 一起重启,灾难放大
readinessProbe 能接流量吗?失败 → 从 Service endpoints 摘除,
不重启。适合放"依赖就绪、热身完成、限流器初始化"
优雅终止:时序比配置更重要
kubectl delete / 滚动更新触发删除
│
├─(1) apiserver 标记 deletionTimestamp,Pod 变 Terminating
├─(2) EndpointSlice 控制器摘除端点(与 (3) 并行,且是异步的!)
├─(3) kubelet 执行 preStop hook
├─(4) preStop 结束 → 发送 SIGTERM
├─(5) 等待 terminationGracePeriodSeconds(默认 30s)
└─(6) 超时未退出 → SIGKILL
最常见的 502 来源:(2) 还没生效,流量还在进来,而 (4) 已经让进程退出
正确的 preStop:sleep 5~10s(等端点传播)→ 应用捕获 SIGTERM
→ 停止接新请求 → 处理完存量请求再退出
务实的做法:preStop sleep 短于 grace period,应用内再做 shutdown 等待
网络:一个虚拟 IP 如何变成转发
K8s 网络模型的三条约束:
1. 每个 Pod 有独立 IP,容器间直接通信
2. 同节点 Pod 通过 veth 对 + 网桥(或 eBPF)互通
3. 跨节点 Pod 不加 NAT 也能互通 → 由 CNI 实现
Flannel(VXLAN 封装)| Calico(BGP 路由)| Cilium(eBPF)
访问 Service 的流量路径(iptables 模式):
Pod ──► 10.96.0.10:80(ClusterIP,谁都没监听这个 IP)
└─► 命中 iptables 规则链 KUBE-SERVICES
└─► 随机选中一个 Endpoint IP:Port → DNAT
└─► 走 CNI 把包送到目标 Pod,
回包时再做反向 NAT 改回 ClusterIP
问题:Service 数量上万后 iptables 规则是线性增长的,
每次增删端点都要全量重写规则,延迟与 CPU 都顶不住
→ IPVS:哈希表查找,规则 O(1),大规模集群更稳
→ eBPF(Cilium):绕过 kube-proxy,在内核层面做负载均衡
几个容易混淆的点:
- Headless Service(
clusterIP: None)不分配虚拟 IP,DNS 直接返回所有 Pod IP,客户端自己选。StatefulSet 靠它拿到稳定的pod-0.svc域名。 - DNS 搜索域:容器内
/etc/resolv.conf带search <ns>.svc.cluster.local svc.cluster.local cluster.local。跨命名空间必须写全名,否则会解析到本命名空间的同名服务——这是一个非常隐蔽的"调错环境"故障。 - NetworkPolicy 只是声明,真正生效取决于 CNI 是否支持。默认策略是全部允许,要默认拒绝必须显式写一条空 PodSelector 的 deny。
发布与扩缩容:Readiness 决定成败
Deployment 滚动更新:
maxSurge=25% 最多超出期望副本多少(先建后删)
maxUnavailable=25% 最多有多少副本不可用(保守场景设 0)
新 ReplicaSet 起 Pod → 通过 readiness 才计入可用
→ 每通过一个,就删除一个旧 Pod → 直到全部替换
没有 readiness 探针 = Pod 一 Running 就接流量
→ 进程还没初始化完、连接池还没建好就开始报错
扩缩容的三个注意:
- HPA 是滞后的。基于 CPU 指标时,指标本身有采集周期,扩容后 Pod 还要冷启动。对突发流量必须预留最小副本数,或提前定时扩容。
- PDB(PodDisruptionBudget) 只约束自愿驱逐(节点维护、drain),不约束崩溃和驱逐风暴;设
minAvailable: 2能防止运维操作把服务打挂。 - 缩容会打断长连接。缩容前要配合优雅终止,并把长连接服务拆到独立的 Deployment。
存储:PV、PVC 与有状态副本
StorageClass(能力定义,如 ssd/多可用区)
│ 动态供给
▼
PVC(我要 20Gi 读写一次)──绑定──► PV(真实存储,CSI 驱动创建)
│
挂载到 Pod 的 volumeMounts
StatefulSet 才有:
- 稳定网络标识 web-0.web.default.svc
- 稳定存储 volumeClaimTemplates 给每个副本一个独立 PVC
- 有序创建/删除 0→1→2 启动,2→1→0 删除
坑点集中在这里:ReadWriteOnce 在多数 CSI 上意味着只能挂到一个节点,滚动更新时会因为旧 Pod 还没释放卷而卡在 ContainerCreating,需要 RollingUpdate 策略或改用支持多挂的存储。
常见坑
- 不设 requests:调度器以为节点很空,把几十个 Pod 塞上去,运行时互相抢 CPU,P99 集体爆炸。
- 用依赖检查做 liveness:数据库抖一下,全集群 Pod 一起重启,故障自我放大。
- grace period 与 preStop 配反了:preStop 里 sleep 30 秒而 grace 也是 30 秒,进程永远收不到 SIGTERM 就被 SIGKILL。
- 忽略 DNS 搜索域:同名服务跨命名空间解析错误,测试环境正常、生产串到别的服务。
- Service 没有 Endpoint:Selector 写错或 readiness 一直失败,表现为"连接被拒绝"而不是超时,容易被误判成网络问题。
- ConfigMap 更新了但应用没变:环境变量注入的配置不会热更新,挂载的文件也只是延迟同步,需配合滚动重启(校验和注解)。
- CPU limit 设太低:CFS 配额按 100ms 周期切分,限制过紧会导致持续节流,表现为"CPU 使用率不高但请求很慢"。
- 单节点承载过多同应用副本:没有配
topologySpreadConstraints或反亲和,一台机器挂掉就损失大部分副本。
总结
| 机制 | 本质 | 记住这一句 |
|---|---|---|
| 声明式 | spec 与 status 的持续收敛 | 失败会重试,所以逻辑必须幂等 |
| 控制器 | watch + 缓存 + 队列 + reconcile | Operator 的价值是固化运维知识 |
| 调度 | 过滤再打分,requests 是唯一依据 | limits 管运行时,requests 管落点 |
| 探针 | 三个探针三种语义 | liveness 只放"重启能修好"的检查 |
| 优雅终止 | 摘流量早于杀进程 | preStop 等到传播完成再退出 |
| Service | iptables/IPVS/eBPF 做 DNAT | 规模大了必须换 IPVS 或 eBPF |
| 发布 | readiness 决定可用性 | 滚动更新的质量取决于就绪判定 |
K8s 的复杂度不在 API 有多少字段,而在多级控制器各自收敛、时序彼此交错。排障时先问三个问题:对象期望状态是什么、谁负责把它变成现实、这一步的时序有没有和别的组件抢跑。大部分"玄学问题"都能落在这三问里。
- 点赞
- 收藏
- 关注作者
评论(0)