华为云容器引擎 CCE:Kubernetes 生产集群搭建与运维
托管 K8s 把控制平面变成"别人的服务",代价是把看得见的复杂度换成了看不见的耦合。这篇的重点是分清:哪些故障你还能自己查,哪些只能等。
#托管的分界线:复杂度没有消失,只是换了位置
从自建 Kubernetes 集群迁到华为云 CCE(Cloud Container Engine,云容器引擎)之后,第一周的感觉通常是"轻松":安装集群、维护 etcd、轮换证书这些体力活全都消失了。第二周开始会冒出一个新的不安——kubectl 还能用,但你不太确定它在跟谁说话;出了事,也不知道自己到底能查到哪一层。
这个不安是对的。托管没有消灭复杂度,它只是把复杂度从一个地方搬到另一个地方:把"看得见的运维负担"换成"看不见的耦合边界"。理解这条边界在哪里、边界两侧各自负责什么,是使用 CCE 的第一步。
#CCE 为什么基于 Kubernetes
Kubernetes 能成为托管容器平台的基础,源于它自身的分层设计:声明式 API 与控制器循环把"期望状态"与"实际状态"之间的调和过程标准化,控制面(Control Plane)与数据面(Data Plane)天然可分。CCE 正是沿着这条分界线做托管——控制面由华为云维护,用户拥有的是数据面(工作节点)与工作负载的完整操作权。
要准确理解托管集群,先记住 Kubernetes 集群里两组角色的分工:
| 层次 | 组件 | 职责 |
|---|---|---|
| 控制面 | etcd、kube-apiserver、kube-scheduler、kube-controller-manager | 状态存储、API 入口、调度决策、期望状态调和 |
| 数据面 | kubelet、容器运行时、kube-proxy、CNI 插件 | 在节点上拉起并守护容器、维护网络规则 |
etcd 是控制面的地基:所有对象的期望状态都落在其中,apiserver 是对外唯一的读写入口。自建集群里,这一层从安装、高可用到证书轮换都由你负责;在 CCE 中,这一层由平台托管(通常以多可用区部署保证高可用),你拿到手的是一组经过鉴权的 API 地址,而不是那几台机器。
#自建与托管:你能查到哪里为止
把"能查多深"列成对照,托管的分界线会非常清晰:
| 层次 | 自建集群 | 托管集群(CCE 这类) |
|---|---|---|
| 控制平面(apiserver/etcd/scheduler) | 你装、你修、你能看日志 | 厂商维护;你能看指标,通常看不到主机 |
| 数据面节点 | 你装 kubelet、你修 | 你管节点池,节点上的问题你能进去排查 |
| 网络插件 | 你选、你调 | 通常平台集成,可调项是子集 |
| 版本生命周期 | 你自己决定 | 由平台的受支持版本决定 |
| 故障时能查的深度 | 全栈 | 到控制平面入口为止 |
最后一行是托管最本质的代价:你能查的东西变少了,而且减少的部分恰好是最难自建的那部分。这不是说托管不好——对绝大多数团队而言,自己维护 etcd 的风险远大于"少看到一层日志"的不便。但要接受它,并在架构上为"查不到"留出余量:客户端侧要有足够的可观测性,不能把全部诊断希望寄托在 apiserver 日志上。
#节点池:把机器变成可丢弃的单位
节点池是托管集群里最重要的抽象之一。它的价值不在"批量管理",而在于把节点从"资产"变成了"规格":一个节点池是一份节点模板——机型、镜像、磁盘、标签与污点、期望数量——节点只是这份规格的实例化结果,随时可以整批销毁重建。
#按负载特征分池,而不是按机型分池
用节点池的关键是:按负载特征分池,而不是按机器型号分池。
| 池的划分依据 | 例子 | 好处 |
|---|---|---|
| 工作负载类型 | 通用 / GPU / 内存密集 | 调度约束清晰,扩缩容互不干扰 |
| 稳定性等级 | 在线服务 / 批处理 / 抢占式 | 故障与回收的影响面被隔离 |
| 生命周期 | 长期 / 临时 | 升级时可以整池替换 |
第三行值得单独强调:"整池替换"是滚动升级真正能落地的前提。如果一个池里混着不同年代、不同配置的节点,升级就退化成逐个且各不相同的维护动作;如果池内是同类节点,升级可以做成"新池建好、流量切过去、旧池销毁"——从"修机器"变成"换机器"。
这也是云上最该被用起来的特性:把机器当成可以随时丢弃的东西,前提是任何一台机器上都没有不可重建的状态。日志、缓存这类易失数据要落在集群外的持久化位置,或能在启动时重建,而不是藏在某个节点的本地磁盘里。
#用 kubectl 核对节点池的划分结果
节点池的标签通常由平台自动打上。升级、扩缩容或排障前,先确认节点确实落在预期的池里,再动手。下面命令用来查看节点标签、按池过滤节点并核对节点上的污点:
kubectl get nodes --show-labels # 查看全部节点标签,找节点池标识(如 nodepool=general)
kubectl get nodes -l nodepool=general -o wide # 按池过滤节点,确认该池规模与就绪状态
kubectl describe node <node-name> | grep -A5 Taints # 查看节点污点,确认调度约束注意:这里的 nodepool=general 只是示例标签,实际标签名与取值以 CCE 控制台或对应版本文档为准。
#可用性预算:你允许自己同时弄坏几台
Kubernetes 里有一组机制专门用来表达"现在可以坏几台",理解它们之间的关系比记住名字有用:
| 机制 | 表达什么 | 用错时会发生什么 |
|---|---|---|
| 副本数 | 需要几个实例 | 单副本服务在节点维护时直接中断 |
| 反亲和/拓扑分布 | 这些实例别挤在一起 | 三副本全落在同一台机器上 |
| PodDisruptionBudget(PDB) | 自愿中断时最多坏几个 | 配得太严,节点永远驱逐不干净 |
| 就绪探针 | 什么时候算能接流量 | 探针太松,把坏实例也接进流量 |
其中反亲和与 PDB 是一对互补机制:反亲和解决"物理上会不会一起坏",PDB 解决"运维动作会不会一次坏太多"。两者缺一,高可用就是靠运气。
#反亲和:物理上别挤在一起
把实例尽量打散到不同节点,用 podAntiAffinity 表达。下面是一段常见的反亲和配置,让 web 的每个副本尽量落在不同主机上:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3 # 三副本,配合反亲和打散到不同节点
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels: { app: web }
topologyKey: kubernetes.io/hostname更细的控制可以用 topologySpreadConstraints 按区域(region/zone)做拓扑分布,替代"尽量不挤一台"的软约束,具体字段以所用 Kubernetes 版本为准。
#PDB:自愿中断时最多坏几个
PDB 约束的是"自愿中断"(节点排空、集群升级、主动驱逐),而不是节点宕机这类非自愿中断——后者 Kubernetes 无能为力,只能靠副本数与反亲和兜底。下面是一份三副本服务的 PDB,要求任意时刻至少 2 个副本可用:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web
spec:
minAvailable: 2 # 三副本、至少 2 个可用,即一次自愿中断最多坏 1 个
selector:
matchLabels: { app: web }PDB 最容易配错的方向是"太严"。看起来保守,实际会出现一个很具体的现象:节点排空(drain)卡住不动,因为驱逐会被 PDB 拦住。此时人容易做两件事——加大 --timeout(没用),或者直接把 PDB 删掉(危险)。正确的做法是理解"排空是自愿中断",然后按业务窗口安排它:先确认 PDB 当前允许驱逐几个,是扩容副本、调整 PDB 还是等待业务低峰,都应在排空前想清楚。
排空节点并在排空前后核对 PDB 状态:
kubectl get pdb # 排空前查看各 PDB 允许的可用副本余量
kubectl describe pdb web # 查看 web 这条 PDB 的详细状态
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data # 排空节点,容忍 DaemonSet、清理 emptyDir
kubectl get events --sort-by=.lastTimestamp | grep -i disruption # 排空被卡住时,看是哪条 PDB 在拦截如果 drain 长时间不结束,最后一条命令能看到类似 "Disruption budget...prevents..." 的事件,据此判断是扩容副本、还是把维护窗口移到业务低峰,而不是强行删 PDB。
#托管集群的网络,边界在哪
网络是托管集群里最容易出现"我觉得是这样"的地方。三条常见边界,每一条都直接决定排查"包到底有没有到"的路径。
#overlay 与 underlay:Pod 的 IP 在哪个路由域
容器网络模型的差异,本质是"Pod 的 IP 和节点 IP 是否处于同一路由域":
- overlay 思路(如容器隧道网络):在物理网络上叠加一层虚拟网络,节点间的 Pod 流量经过封装(如 VXLAN 隧道)转发。Pod IP 只在集群内可见,集群外无法直接访问;Pod 访问外部时通常由节点做 SNAT。代价是每跳多一层封装开销,换来的好处是 IP 规划灵活、与底层 VPC 解耦。
- underlay 思路(如 VPC 网络、云原生网络 2.0):Pod 直接使用 VPC 内的网段(或为 Pod 挂接弹性网卡),Pod IP 在 VPC 路由域内可见,集群外只要安全组放行就能直接访问,SNAT 场景少,性能更接近裸机。代价是 IP 规划必须与 VPC 一致,地址空间更紧。
CCE 具体提供哪几种网络模式、参数如何配置,随版本演进,以官方文档对应版本为准。但排查逻辑是通用的:先明确所在模式的转发路径,再决定该去节点上看隧道封装,还是去 VPC 路由表里看路由。
#负载均衡的双层健康检查错配
LoadBalancer 类型的 Service 背后是厂商的负载均衡实例(如 ELB),这个实例有它自己的健康检查与会话保持策略,与 kubelet 的存活/就绪探针是两套独立系统。于是会出现一个经典错配:Kubernetes 认为健康、LB 认为不健康(或相反)。
任何"两层健康检查"的系统,错配是迟早的事。排查时先确认"谁在判死",再去看被判定的一方:LB 判死,先查它的健康检查端口/路径是否与容器监听一致;Pod 判死,再回到探针配置。
#NetworkPolicy 的默认行为必须实测
NetworkPolicy 是白名单式的:匹配到的流量按规则放行或拒绝,未匹配的流量默认怎么走,各实现略有差异(有的默认允许,有的默认拒绝)。上线前必须实际验证一次,而不是按理解假设。验证方法可以是在目标 Pod 里跑一个探测容器,分别测放行与不放行的两个方向,确认策略生效:
kubectl get networkpolicy -A # 查看集群中已定义的网络策略
kubectl describe networkpolicy <policy-name> # 查看某条策略的具体规则
kubectl run probe --rm -it --image=busybox --restart=Never -- sh -c "wget -qO- http://web.default.svc:80 && echo REACHABLE || echo BLOCKED" # 起临时 Pod 做连通性探测注意:探测镜像与命令按你集群的镜像仓库可用性调整;REACHABLE/BLOCKED 只是示例输出,真实结果取决于策略配置。
#服务访问不通时的分层验证
Service 访问不通,按从下往上的顺序逐层确认:端点是否就绪、目标端口是否监听、网络策略是否放行、负载均衡是否健康。下面命令覆盖前三层:
kubectl get svc <service-name> # 第 1 层:Service 是否解析到后端
kubectl get endpoints <service-name> # 第 1 层:端点是否就绪
kubectl get pods -l app=<app-name> -o wide # 第 2 层:Pod 就绪状态与所在节点
kubectl get networkpolicy -n <namespace> # 第 3 层:网络策略是否放行来源云负载均衡那一层通常看不到实例内部细节,确认前三层无误后仍不通,再结合平台侧控制台(监听器、健康检查、安全组)判断。
#能自己查的与只能等的:故障排查的分层
把故障按"你能不能自己往下查"分一下类,心里会踏实很多:
| 现象 | 你能不能自己往下查 |
|---|---|
| Pod 起不来、反复重启 | 能。kubectl describe、容器日志、事件 |
| 节点 NotReady | 部分能。节点本身能进,kubelet 日志能看到一部分 |
| Service 访问不通 | 能查到网络策略与端点,但云负载均衡那一层可能看不到 |
| apiserver 报 5xx / 限流 | 基本只能等。能看到的是客户端侧的错误率与延迟 |
| 调度不上去 | 能。看事件里的调度失败原因,通常是资源或污点 |
| 存储挂载失败 | 部分能。CSI 侧日志可见性取决于平台 |
Pod 侧的排查命令如下。先看描述与事件,再按需取日志,不要一上来就重启:
kubectl describe pod <pod-name> # 查看 Pod 状态与最近事件(镜像拉取失败、探针失败都会体现)
kubectl logs <pod-name> --previous -f # 查看容器日志;--previous 看上一实例日志,适合 CrashLoopBackOff
kubectl get events --sort-by=.lastTimestamp # 按时间排序查看命名空间事件,定位调度失败或驱逐原因
kubectl describe pod <pod-name> | grep -A10 Events # 调度失败时快速定位 Unschedulable 事件对"只能等"的两类(控制面故障、依赖平台侧组件的故障),正确的应对方式不是想办法"看到",而是在设计阶段就不依赖它:
- 给 apiserver 的调用方(controller、operator、CI)加退避与降级,控制面抖动时客户端能自保。
- 关键路径不要每次都实时查询 apiserver(比如用缓存或影子配置),避免把控制平面当数据面用。
- 在客户端侧建立独立的可观测性:错误率、延迟、队列积压,这些指标不依赖控制面也能看到。
#成本:节点池的浪费长什么样
托管集群的账单浪费通常不是"节点太大",而是三种结构性的:
- 池的碎片:多个小池,每个都有闲置余量。池越多,余量被重复预留的次数越多。
- requests 与 limit 的差距:调度按 requests,实际用 limit 附近的量很少。requests 虚高是云上最普遍的隐性浪费。
- 缩容不下来的负载:没有用 HPA,或者用了但伸缩指标选错了——例如按 CPU 伸缩一个 IO 密集的服务,永远不触发。
第一条和第三条都能靠"先把池合并、再按负载分池"解决;第二条需要的是基于实际用量的 requests 校准,而不是一次调完就完事——它需要一个定期校准的机制:用监控数据统计每个工作负载的真实用量分布,把 requests 收敛到贴近实际水位,同时保留一定的突发余量。
HPA 的正确姿势是先确认指标与业务信号匹配,再配置伸缩范围。下面命令为 deployment web 配置基于 CPU 的自动伸缩:
kubectl autoscale deployment web --cpu-percent=70 --min=2 --max=10 # 目标 CPU 70%,副本范围 2-10
kubectl get hpa # 查看 HPA 当前状态
kubectl describe hpa web # 查看触发原因与最近伸缩动作CPU 指标只对 CPU 敏感型服务有效。IO 密集、连接数敏感或排队型服务,应改用平台支持的自定义指标(如 QPS、连接数、队列深度),具体以官方文档对应版本支持的指标源为准。指标与业务信号不匹配,HPA 要么从不触发,要么持续抖动,成本与稳定性两头吃亏。
#边界:这篇没写什么
- 集群创建的具体步骤与控制台参数:界面与选项随版本变化,以官方文档对应版本为准。
- CSI/CNI 各实现的对比:差异大且演进快,结论很容易过期。
- Service Mesh 与 Ingress 细节:那是构建在集群之上的另一层,独立成篇更清楚。
- 集群规模数字:"多大的集群该用托管"取决于团队的自维护能力,不是节点数。
一句话收束:托管把控制平面变成了服务,代价是你的诊断能力在某个深度上被截断。承认这条边界、并围绕它设计客户端侧的可观测性,比假装它不存在要安全得多。
相关阅读
Related留言讨论
0 条还没有留言,来说点什么吧。