Kubernetes 已经是容器编排的事实标准,但大多数团队对它的理解停留在「会写 YAML、会用 kubectl」。真正把它用好,前提是理解它作为一个面向期望状态的控制系统的设计:组件如何分工、状态如何流动、故障如何自愈。这篇文章分上下两场:上半场拆架构——控制平面、数据平面与声明式模型;下半场讲实践——资源管理、探针、发布、调度约束与故障排查。读完你应该能回答三个问题:为什么 K8s 赢了?它的每个组件为什么长成那样?生产上有哪些坑是可以提前绕开的?
一、一张图看懂整体架构
Kubernetes 集群分成两个平面:控制平面负责「想」(维护期望状态),数据平面负责「做」(在节点上把容器跑起来)。所有组件之间的通信都经过 kube-apiserver,组件之间互相不直连:
flowchart TB
subgraph CP["控制平面 Control Plane"]
direction LR
API["kube-apiserver"]
ETCD[("etcd")]
SCH["kube-scheduler"]
CM["kube-controller-manager"]
API --> ETCD
end
subgraph NODE["工作节点 Worker Node(×N)"]
direction LR
KL["kubelet"]
KP["kube-proxy"]
RT["容器运行时 containerd"]
KL --> RT
end
CLI["kubectl / CI/CD / Dashboard"] -->|"提交期望状态"| API
SCH -->|"watch Pod,回写调度结果"| API
CM -->|"watch 对象变化,运行控制循环"| API
KL -->|"watch 本节点 Pod,上报状态"| API
KP -->|"watch Service,写内核转发规则"| API
KL -.->|"CRI / CNI / CSI"| RT
这里有一个值得停下来体会的设计决策:星型拓扑。apiserver 是唯一枢纽,etcd 是唯一状态源,其他所有组件——scheduler、controller-manager、kubelet、kube-proxy——都只和 apiserver 说话。这带来三个好处:
- 组件彻底解耦:scheduler 不需要知道 kubelet 的存在,它只是「写了个字段」;
- 单一事实来源:所有状态都在 etcd,任何组件崩溃重启后从 watch 断点续读即可,不丢状态;
- 安全与审计单点收口:认证、鉴权、准入、审计日志都在 apiserver 一处做完。
代价也很清楚:apiserver 和 etcd 是整个集群的命门。这就是为什么生产集群必须 apiserver 多副本 + 前置负载均衡、etcd 独立部署并定期备份——后面第九节会展开。
二、控制平面:每个组件为什么长成那样
kube-apiserver:唯一的读写入口
一条请求进入 apiserver 后,走的是一条严格的流水线:
客户端请求
→ 认证(Authentication:你是谁)
→ 鉴权(Authorization:你能不能做这件事)
→ 变更准入(Mutating Admission:按策略改写对象,如注入 sidecar)
→ 对象校验(Schema Validation:字段合法性与业务约束)
→ 校验准入(Validating Admission:最终拦截,Pod 安全、配额、策略)
→ 写入 etcd
每一步都可以扩展:认证支持客户端证书、ServiceAccount Token、OIDC 等;鉴权默认 RBAC;准入通过 webhook 挂接外部服务(OPA、Kyverno 这类策略引擎就是这么接进来的)。
apiserver 的第二件灵魂武器是 watch。组件不是轮询「全量拉一遍列表」,而是带着 resourceVersion 对 etcd 做增量订阅:只收上次之后的变化事件。客户端库里把 list + watch 封装成 Informer——启动时全量 list 一次建本地缓存,之后靠 watch 增量维护,业务逻辑只面对缓存和事件。整个 K8s 生态的高性能控制器都建在 Informer 机制上。
etcd:唯一的事实来源
etcd 是一个强一致的分布式键值存储,K8s 里所有对象的唯一持久化位置。几个生产上真正要紧的点:
- 写需要多数派确认(Raft 共识),所以节点数取奇数(3 或 5),两节点集群不比一节点可靠;
- MVCC 多版本:每个对象带着单调递增的
resourceVersion,watch 从某个版本之后续读,这是 list-watch 可靠性的地基; - 压缩与配额:历史版本靠 compaction 回收,backend 默认 2GB 配额——配额写满 apiserver 会退化为只读,这是真实发生过的事故模式;
- 生产建议:独立部署(不与其他负载混部)、SSD 磁盘、定期
etcdctl snapshot save备份并实际演练恢复。
controller-manager 与 scheduler:两类「脑」
两者都是「监听变化、做出反应」,但分工不同:
- kube-controller-manager 是几十个控制循环的集合:Node 生命周期、Deployment/ReplicaSet、Job、EndpointSlice……每个循环盯着一种对象的期望与实际之差;
- kube-scheduler 只做一件事:为每个还没绑定节点的新 Pod 选一个节点,然后把结果写进 Pod 的
spec.nodeName。
层级控制循环:Deployment → ReplicaSet → Pod
K8s 的工作负载是一层层叠出来的,每层控制器只管下一层的期望:
flowchart LR
U["kubectl edit:期望副本 3 → 5"] --> A["apiserver 持久化新的期望"]
A --> DC["Deployment 控制器:创建新 ReplicaSet"]
DC --> RC["ReplicaSet 控制器:对齐副本数,新建 Pod"]
RC --> S["scheduler:为新 Pod 绑定节点"]
S --> K["kubelet:通过 CRI 拉起容器"]
K --> W["实际状态收敛到期望,系统恢复静默"]
注意几个细节:每层对象通过 ownerReference 指向上一层,删除 Deployment 时垃圾回收器顺着这条链清掉 ReplicaSet 和 Pod;Deployment 的滚动更新是通过「新旧两代 ReplicaSet 并存、缩旧扩新」实现的,所以随时可以按代回滚。这种「每层只做一件事、循环对齐」的组合方式,正是 K8s 用有限代码覆盖无限场景的原因。
scheduler 的两阶段与扩展点
选节点分两个阶段:
- 过滤(Filter):删掉所有不满足硬条件的节点——资源不够、端口冲突、亲和性不满足、污点不容忍;
- 打分(Score):给剩下的节点打分排序——镜像已在本机、资源碎片更少、拓扑更均衡的节点得分更高。
过滤和打分都通过调度框架(Scheduling Framework)插件化:调度周期(Filter → Score → Reserve → Permit → Bind)拆成一串扩展点,自定义调度逻辑写成插件挂进去,不用 fork 源码。高优先级 Pod 资源不够时还有抢占(Preemption):按优先级踢掉低优先级 Pod 腾出资源。
三、数据平面:节点上的三件套
kubelet:Pod 的监护人
每个节点上的 kubelet 做三件事:watch 本节点的 Pod 对象;通过 CRI(容器运行时接口)驱动 containerd 拉起/销毁容器;持续执行探针检查、上报 Pod 状态。它还内置节点压力驱逐:磁盘或内存吃紧时,按 QoS 等级和优先级挑选 Pod 驱逐——这也是后面「资源必须配 requests」的底层原因之一。
kube-proxy:Service 的数据面实现
kube-proxy 不代理「Pod 之间的流量」,它做的是把 Service 的转发规则写进节点内核:每个节点的 iptables/IPVS 规则共同实现「访问 ClusterIP:Port 会被转到某个后端 Pod」。两种模式要分清:
- iptables 模式:规则线性匹配,Service 和后端数量一多,规则链变长,转发延迟上升,更新规则也更慢;
- IPVS 模式:内核哈希表 + 多种负载均衡算法(rr/lc/sh 等),大规模集群(Service 上千)首选。
更激进的路线是 Cilium 用 eBPF 直接接管数据面、完全绕开 kube-proxy,这部分留给专题后续的网络篇展开。
插件三角:CRI / CNI / CSI
运行时、网络、存储三类能力全部收敛成标准接口,K8s 本体保持一个「编排内核」:
| 接口 | 管什么 | 代表实现 |
|---|---|---|
| CRI | 容器运行时 | containerd、CRI-O |
| CNI | Pod 网络 | Flannel、Calico、Cilium |
| CSI | 块/文件存储 | 各云厂商与分布式存储驱动 |
「接口下沉、实现外置」是 K8s 能吸收整个基础设施生态而不把自己撑爆的关键设计——换存储不用改 K8s,换网络方案也不用改 K8s。
四、声明式 API 与控制循环:K8s 的灵魂
命令式与声明式的区别,一个例子就够:
- 命令式:「帮我启动 3 个副本」——命令执行完,系统就忘了这回事,进程挂了没人管;
- 声明式:「副本数应该是 3」——这个期望被持久化进 etcd,控制器持续观测实际状态并向期望收敛。
所有控制器共用同一个循环骨架:
for {
desired := 从本地缓存读期望状态() // Informer 的 list-watch 缓存
actual := 读实际状态() // 通常是子对象的现状
if diff := computeDiff(desired, actual); diff != nil {
apply(diff) // 只做增量对齐,天然幂等
}
}
这个循环有个重要性质:level-triggered 而非 edge-triggered。它对齐的是「当前状态」而不是「某个事件」——哪怕控制器宕机一天、错过一万个事件,醒来后照样按现状对齐。分布式系统里靠事件补偿非常脆弱,按状态收敛才是可靠的最终一致。
这套模式还是开放的:CRD + 自定义控制器 = Operator。你可以为自己的中间件定义一种新对象,把「怎么部署、怎么扩容、怎么故障自愈」的运维知识写成控制器代码——这是 K8s 从「容器编排器」进化为「云原生操作系统」的那一步。
五、网络模型:四条约定与 Service
K8s 对网络只约定四条基本原则,把实现留给 CNI 插件:
- 每个 Pod 有独立 IP,Pod 内容器共享网络栈;
- Pod 之间可以直接互通,不需要 NAT;
- 节点与 Pod 之间互通,也不需要 NAT;
- Pod 自己看到的 IP,就是别人看到的 IP。
正因为「Pod IP 直通无 NAT」,应用在容器里的行为和跑在虚拟机上几乎一致,迁移成本极低。跨节点怎么实现这四条,是 CNI 的事:Flannel 用 VXLAN 隧道(简单但多一层封包),Calico 用 BGP 路由(高性能、支持 NetworkPolicy),Cilium 用 eBPF(性能与可观测性最强)。
在此之上,Service 解决「Pod 会死、IP 会变」的问题:一组带标签的 Pod 背后是一个稳定的虚拟 IP。类型按暴露范围分:ClusterIP(集群内)、NodePort(节点端口)、LoadBalancer(对接云负载均衡)、ExternalName(DNS 别名);Headless Service(clusterIP: None)不分配虚拟 IP,直接经 DNS 解析到各 Pod——StatefulSet 的标准搭档。七层入口用 Ingress,社区正在向更表达、角色分离更清晰的 Gateway API 迁移。
六、存储抽象:PV、PVC 与 StorageClass
存储用三层解耦,把「用户要什么」和「管理员供什么」分开:
- PV:集群里一段实际存在的存储(卷);
- PVC:用户的申请单——要多大、什么访问模式;
- StorageClass:动态供给的模板——有人来申请时,按模板自动创建 PV。
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data
spec:
storageClassName: ssd # 用哪个模板供给
accessModes:
- ReadWriteOnce # RWO / ROX / RWX
resources:
requests:
storage: 50Gi
具体对接哪家存储由 CSI 驱动完成。注意访问模式的现实约束:ReadWriteOnce 是最常见的块存储模式,意味着「一个卷同时只能挂一个节点」——多副本应用挂同一 RWO 卷会调度失败;真要多副本共享,需要 RWX 的文件存储。StatefulSet 通过 volumeClaimTemplates 给每个副本独立生成一份 PVC,配合稳定的网络标识,这就是「有状态应用上 K8s」的地基。
七、资源管理与 QoS:调度与运行的交界
requests 和 limits 的语义必须刻进肌肉记忆:
- requests 是调度依据:调度器保证「节点剩余可分配量 ≥ 所有 Pod 的 requests 之和」;
- limits 是运行上限:内存 limit 落成 cgroup 硬限,超了就是 OOMKill;CPU limit 落成 CFS 配额,超了被节流(变慢但不会死)。
按资源配置情况,Pod 分三档 QoS:
| QoS | 条件 | 驱逐/OOM 时待遇 |
|---|---|---|
| Guaranteed | 所有资源 requests == limits | 最后被杀 |
| Burstable | 设置了部分或不相等 | 中间 |
| BestEffort | 什么都没设 | 最先被杀 |
两个高频生产坑:
- 内存 limit 给太紧 → 流量高峰 OOMKilled(退出码 137)→ CrashLoopBackOff 反复横跳。内存 limit 应基于压测峰值留 20%–30% 余量;
- CPU limit 造成的长尾延迟:limit 触发的 CFS 节流会先把 p99 延迟打爆,很多「服务变慢但没报错」的事故根因在这。业界常见做法是只设内存 limit、CPU 只留 requests——让突发流量可以借到空闲 CPU。
下面这份 Deployment 模板把本节的探针、资源、发布策略和第八节的打散约束一次配齐:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 滚动时最多多出 1 个 Pod
maxUnavailable: 0 # 滚动期间容量不缩水,线上服务建议 0
minReadySeconds: 10 # 新 Pod 就绪满 10s 才继续滚动
progressDeadlineSeconds: 600
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: app
image: registry.example.com/order-service:v1.8.2
ports:
- containerPort: 8080
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 1Gi # CPU 不设 limit,避免限流拖长尾
startupProbe: # 慢启动保护:它成功前不跑另外两个探针
httpGet: { path: /healthz, port: 8080 }
failureThreshold: 30
periodSeconds: 2
readinessProbe: # 决定是否接流量:失败摘除,不重启
httpGet: { path: /readyz, port: 8080 }
periodSeconds: 5
livenessProbe: # 失败重启容器:只判进程死活,别查外部依赖
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
securityContext:
runAsNonRoot: true
readOnlyRootFilesystem: true
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: order-service
探针三兄弟的分工值得单独说清:startupProbe 给慢启动应用留时间窗(启动 60 秒的 Java 应用全靠它);readinessProbe 决定接不接流量,失败只是摘除、不重启——发布期间靠它保证坏版本不接客;livenessProbe 失败会重启容器,所以只判断「进程本身是否死锁」,绝不要把数据库连通性检查放进来——数据库抖动 30 秒,全集群应用连环重启,会把一次依赖抖动放大成全网故障。
八、调度进阶:把 Pod 放对地方
默认调度是「有资源就放」,生产上还需要表达偏好与约束:
- 节点亲和性(nodeAffinity):
required是硬条件,preferred是加分项——GPU 任务绑 GPU 节点; - 反亲和(podAntiAffinity):同一服务的副本尽量不同节点,防止一挂全挂;
- 拓扑打散(topologySpreadConstraints):比反亲和更优雅的均匀分布——按 hostname/AZ 域控制副本偏差不超过
maxSkew,上面模板里已给出; - 污点与容忍(taints/tolerations):给节点「挂牌」——专用节点、故障节点,
NoSchedule拒绝、NoExecute连已在跑的都驱逐; - 优先级与抢占(PriorityClass):高优业务资源不足时自动抢占低优 Pod。
一条经验原则:硬约束越少越好。required 亲和、NoSchedule 污点都是调度失败的常见来源——「明明有节点却调度不上」十有八九是硬约束把候选集过滤光了。能用软约束(preferred / ScheduleAnyway)表达的,不要用硬约束。
九、生产集群检查清单
架构讲完,落到一张可以逐项打勾的清单:
控制平面
- etcd 3 或 5 节点、独立部署、SSD,
snapshot备份 + 恢复演练(没演练过的备份等于没有备份); - apiserver ≥ 2 副本 + 前置 LB;controller-manager/scheduler 天然选主,多副本即可;
- apiserver 与 etcd 的证书、版本升级窗口提前规划(K8s 一年三个小版本,连跳有风险)。
工作负载
- 所有容器必须配 requests;内存必设 limit,CPU limit 酌情(见第七节);
- 探针齐全:startup + readiness 必配,liveness 谨慎配且不查外部依赖;
- 发布参数
maxUnavailable: 0+minReadySeconds,配合就绪探针实现零中断滚动; - 每个「少一个副本就降级」的服务配 PDB(PodDisruptionBudget),节点维护时的主动驱逐会尊重它;
- HPA 按业务指标扩缩,注意默认缩容稳定窗口(防抖动),扩容阈值别贴着 CPU 水位线。
安全
- RBAC 最小权限:不为业务 ServiceAccount 开 ClusterRole,服务用不到 apiserver 就关掉 token 自动挂载;
- NetworkPolicy 默认拒绝、按需放行(注意 Flannel 不支持,Calico/Cilium 支持);
- 镜像来源可信 +
runAsNonRoot+ 只读根文件系统,有条件上准入策略引擎统一强制。
十、故障排查手册:从现象到根因
最后是实战中最常翻的排查表。通用心法:kubectl describe pod 的 Events 就是第一现场,90% 的问题在那里写着答案。
Pending——Pod 一直没被调度
describe 看 Events,常见四种:Insufficient cpu/memory(资源不足,加节点或调小 requests)、节点亲和性不满足、污点没容忍、PVC 一直 Pending(存储类配错或容量不足)。
ImagePullBackOff——镜像拉不下来
按序检查:镜像名与 tag 拼写(本地能跑不代表集群能拉)、私有仓库的 imagePullSecrets 是否配了、镜像仓库限流(Docker Hub 匿名配额)。
CrashLoopBackOff——容器反复崩溃重启
kubectl logs <pod> --previous 看上一次崩溃前的日志。退出码是关键线索:1 应用自身报错、137 被 SIGKILL(多半是 OOMKilled,也可能是人为 kubectl delete)、139 段错误。还有一种「假崩溃」:liveness 探针配得过激,应用只是响应慢就被反复重启——回头看第七节。
OOMKilled——内存超限被杀
kubectl top pod 对比实际用量与 limit 的差距,压测找到真实峰值再留余量;同时检查是否存在内存泄漏(用量随时间单调上涨)。
Service 不通——服务没挂但访问不到
按链路排查:先看后端在不在(kubectl get endpointslices -l kubernetes.io/service-name=<svc>,Endpoints 为空 = selector 错或 readiness 全挂)→ 再看 DNS(集群内域名解析是否走 CoreDNS)→ 最后看转发规则(iptables/IPVS 是否同步了 Service)。
# 一套顺手的排查命令
kubectl get events --sort-by=.lastTimestamp | tail -20
kubectl describe pod <pod> -n <ns>
kubectl logs <pod> --previous
kubectl get endpointslices -l kubernetes.io/service-name=<svc>
kubectl top pod --sort-by=memory
kubectl rollout history deployment/<name>
kubectl rollout undo deployment/<name> # 一键回滚到上一代 ReplicaSet
结语:K8s 专题开篇
回头看,Kubernetes 赢在两个字:收敛。它不承诺帮你启动任何东西,它只承诺——只要你把期望状态写下来,整个系统就会持续向那个状态收敛,进程挂了重启、节点死了迁移、配置漂了拉回。理解了这一点,apiserver 的流水线、控制循环的层级、调度器的两阶段、探针的分工,全都顺理成章。
这是 Kubernetes 深入实践专题的开篇。后续计划的路线:调度器与资源模型深入、CNI 与 eBPF 网络、StatefulSet 与有状态应用实战、可观测性三件套、Operator 与 CRD 扩展模式。如果你想优先看哪一篇,欢迎在评论区留言。
读者留言
COMMENTS 暂无还没有留言,来说第一句?