ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

云控制器管理器(CCM)深度解析:从拆分原理到生产部署避坑指南

云控制器管理器(CCM)深度解析:从拆分原理到生产部署避坑指南

如果你维护过一套跨公有云部署的Kubernetes集群,大概率会吐槽控制平面里那些“云厂商专属配置”又难改又难读。今天要聊的CCM,也就是云控制器管理器(Cloud Controller Manager),就是专门来救这个命的组件。CCM解决的正是Kubernetes与云厂商API之间的解耦问题:它把树内(in-tree)云厂商逻辑从核心控制器中剥离出来,让每个云厂商可以独立迭代自己的适配代码,同时让集群核心保持“云中立”。

这篇文章不打算念文档,而是会从“为什么必须拆分”的历史背景、CCM手底下的三条核心链路、生产环境部署配置,再到真实自研过程中踩过的那些云厂商API的坑,一层层把CCM说清楚。想搞懂云原生集群内部是如何与某家云厂商协同工作的读者,这期内容应该值得放进收藏夹慢慢看。

1. CCM诞生记:从核心控制器里“拔”出来的云厂商适配层

1.1 早期K8S的“云厂商绑定”困局:kube-controller-manager承担了太多

在Kubernetes 1.6之前,kube-controller-manager的职责远不止今天这么纯粹。它不仅要管节点、管Service、管Endpoint,还要直接对接云厂商的SDK,为节点打上可用区标签、创建负载均衡器、维护安全组规则、同步路由表。代码里充斥着cloudprovider/providers/aws、azure、gce、openstack这类目录,每加一个云厂商能力,无论多小的改动,都要改Kubernetes核心仓库代码,等CI、等社区review、等版本发布。当你决定从云A切到云B时,升级K8S版本往往不是最痛的部分,最痛的是要去移植那一堆耦合在控制器里的LoadBalancer实现,还要应对某云SDK升级带来的数据结构不兼容。

这种“树内(in-tree)”模式的迭代效率极低。云厂商的API变化频繁,可核心仓库的发布节奏却是由社区控制的,两边完全不在一个节拍上。比如某云厂商想支持一个自定义的新LB类型,光是把PR合进主干,就可能经历几个月。更麻烦的是,由于控制器进程里集成了太多云厂商SDK,一旦某个云厂商的SDK引发panic,整个kube-controller-manager会跟着一起崩掉,最终导致所有集群的节点状态维护、Service健康检查、甚至扩缩容全部瘫痪。也就是说,云厂商的适配代码质量和稳定性,直接绑架了整个控制平面的可用性。

1.2 分离的哲学:给云厂商一个“插槽”,而不是改引擎

CCM的拆分思路,本质上就是在控制平面中留出“插槽”,让每个云厂商把自己的适配代码做成独立进程运行在集群内。你不再需要改造kube-controller-manager,而是部署一个独立的CCM二进制文件,这个文件内部才包含各家云厂商的SDK逻辑。核心控制面通过统一的接口去调用外部云服务,把“如何操作云资源”的细节完全交还给云厂商自己的进程。这样一来,树内代码移出主仓,Kubernetes核心稳定性和云厂商功能迭代速度都得到了大幅提升。

“树外(out-of-tree)”的好处还不止于此。由于CCM是以普通Pod(通常是Deployment形式)运行的,它能够享受Kubernetes自身的滚动更新、故障重启、资源限制等标准机制。核心组件遭受供应链攻击或者异常API调用影响的范围也被显著缩小——如果某云厂商SDK出错,最多只是CCM这个Pod重启,而不会拖垮整个控制平面。理解这个背景之后,再看CCM内部的工作链路就会顺理成章。

2. CCM手里的三张牌:节点生命周期、Service负载均衡与路由回填

2.1 节点生命周期控制器:云上Node的“生死簿”如何同步?

节点生命周期控制器(Node Lifecycle Controller)解决的核心问题只有一个:让集群对节点状态的认知,和云厂商底层的运行状态保持一致。Kubernetes中的节点对象、状态描述以及调度结果,本质上是一份集群的“缓存视图”,而云厂商API中的虚拟机列表才是“事实来源”。

举个例子,某个云节点由于底层故障被误杀或迁移时,Kubelet进程可能都挂了,集群怎么知道呢?节点生命周期控制器会周期性地调用云厂商API,根据ProviderID去比对节点是否存在。如果通过云API检查发现该节点已经不存在了,控制器就会在集群中标记unreachable或直接删除节点,避免调度器继续往这个节点上丢Pod。反过来,如果集群里新加入了一个云主机,控制器也会为它打上地域、可用区等标签,确保拓扑感知调度能正确运行。更重要的是,新节点注册时会自动带一个node.cloudprovider.kubernetes.io/uninitialized污点,直到CCM校验通过后才会移除,这个机制保证了云节点在没有被云厂商API“验明正身”之前,绝不会被分配工作负载。

这个链路好不好用,关键看稳定性和幂等性。云厂商的GetInstance接口偶尔会抖一下,返回超时或空对象的概率并不低。假如你写的CCM一遇到瞬时错误就立刻把节点状态置为NotReady,那就很糟了——一次API抖动可能触发大量Pod重新调度,整个集群跟着哆嗦。所以成熟的CCM实现普遍会在节点生命周期控制器里加入指数退避和连续失败判定,而不是单次失败立刻做出“生死判决”。

2.2 Service控制器:云负载均衡的“中间层翻译官”

服务控制器(Service Controller)负责的对象很单纯:只要Service的类型是LoadBalancer,它就会接管。Kubernetes本身不实现负载均衡,它只声明需求,而CCM负责翻译需求并回填结果到Service的status.loadBalancer.ingress字段。

当用户创建一个Type=LoadBalancer的Service时,Service Controller会拿到该Service的端口、协议、会话保持、健康检查等配置,然后调用云厂商SDK,创建一个云上负载均衡器(或监听器)。创建完成后,LB的IP或域名会写回status字段,Ingress(或外部客户端)就能拿到VIP对外提供服务。看似简单,但实际翻译过程相当复杂。比如Service端口的nodePort、targetPort、protocol三件套,要映射到云负载均衡的监听端口和健康检查路径;安全组规则也要同步放通后端节点端口,否则流量根本进不了节点。

如果后端节点集合发生变化,比如节点滚动升级或弹性伸缩,CCM会自动把新节点加入负载均衡后端组,同时摘除已移除的节点。如果CCM在删除Service时没有先把LB的监听器和后端关联一并清理干净,那些孤立的云资源就会持续产生费用。这也是Service Controller在实现时必须处理幂等删除的原因——重启后要能认出哪些LB是之前已经创建好的,而不是重复新建一份。

2.3 路由控制器:其实它比你想的更“冷门”

路由控制器(Route Controller)的作用,是把Kubernetes的Pod网段与云厂商的VPC路由表打通。在自建裸机集群中,Pod跨节点通信往往依赖overlay网络,例如Calico的VXLAN模式或Flannel的VXLAN模式,把流量封装进隧道传输。但在一些公有云环境里,用户更希望直接借助云厂商底层路由能力,让不同节点上的Pod IP可以直接路由,不经过隧道封装。

打个比方,VXLAN相当于在云上自己拉了一根网线,通信过程多一层封包解包,效率多少有些损耗。而路由控制器所做的,是把你集群里的每个节点CIDR写入云厂商的路由表,让云上数据面认为Pod IP本来就是这个VPC网段内的合法地址。这样流量路径更短、时延更低,也少了封装开销。但路由控制器通常依赖CNI网络插件做额外配置,例如要求节点网桥与云路由规则联动。实际生产中用得不算多,更多是作为网络性能优化手段。如果IaaS层能支持原生路由能力,它确实是一把值得考虑的优化利器。

3. 部署CCM前的关键设置:先让核心控制面“举手投降”

3.1 前置条件:KubeControllerManager和Kubelet都要加--cloud-provider=external

要让CCM真正接管云厂商逻辑,必须在两个地方向它“投降”:kube-controller-manager必须加--cloud-provider=external,集群中所有kubelet也必须加--cloud-provider=external。

为什么一定要external?这个参数的作用,是明确告诉Kubernetes核心控制面:你别再用内部那些默认云厂商逻辑了,现在统统交给外部程序。如果你在kubelet上漏掉了这个参数,就很容易出现一个奇怪故障:节点一直不去注册,或者注册时带上一个错误的自定义ProviderID。因为kubelet会默认尝试连接云厂商元数据服务获取实例信息,而在没有正确配置树外CCM的环境里,这种尝试往往会卡住,导致节点一直处于NotReady状态。

实际节点注册流程是这样的:kubelet启动后,会根据--provider-id或云厂商元数据获取实例ID,然后上报给apiserver。但如果kubelet带着internal默认值又没法访问云API,注册就会一直重试,错误日志刷个不停。配置了external参数之后,控制平面和kubelet都不会默认对接云厂商,节点注册时会带一个node.cloudprovider.kubernetes.io/uninitialized的污点,等待CCM启动并通过云API校验后把这个污点去除。如果没有CCM来移除这个污点,节点将永远无法调度Pod,这个细节对很多初次接触树外云提供商的团队来说,是最隐蔽的“坑”之一。

3.2 用Helm还是裸YAML?CCM的典型部署拓扑

既然CCM本身是普通Pod,它的部署方式就灵活许多。官方模板通常是:将CCM作为Deployment发布,指定2个副本,通过--leader-elect实现高可用。CCM进程内部会选出一个活跃Leader,只有它才实际调用云API去写状态,避免多个副本同时和云API交互导致冲突。当然,这要求必须给CCM授予选举相关的RBAC权限,否则多个副本会同时尝试成为Leader,产生端口争抢。

部署时建议把云厂商配置文件放到ConfigMap里,密钥则用Secret挂载。云厂商凭证不要直接写在YAML里,否则一不小心就会泄露到仓库。更稳的做法是,将该凭证以cloud-config参数传给CCM,让它读取标准的INI或YAML格式。实际生产中还建议给CCM的Pod添加hostNetwork: true,让它可以直连云厂商元数据服务或管理面API。注意这一条并非所有CCM都需要,但如果你的云厂商SDK依赖链路较长,比如需要访问内网DNS或元数据服务,采用hostNetwork能显著降低连接问题。

还有一个容易被忽略的点:CCM的启动参数里一定要显式指定--controllers=*,或者精确列出需要启用的控制器子集。不同云厂商的CCM支持能力不同,有的只实现了节点生命周期,有的只实现了负载均衡,还有的连路由控制器都做了。如果没指定控制器列表,CCM可能会因为缺失某一块能力而启动失败,或者疯狂重试一个未被支持的接口。

3.3 为什么必须给CCM配“精细化权限”?

很多刚接触CCM的团队会犯一个错误:图省事直接给CCM授予cluster-admin权限。虽然只是单个Pod的权限,但这种大权限会带来两个隐患:一是如果CCM进程被攻破,攻击者可以轻松拿到整个集群的控制权,等于把控制平面钥匙挂在了前门口;二是权限过大反而容易掩盖操作失误,例如Service的删除动作本来应该严格限定在指定对象上,如果权限不受限,一句误写的命令可能导致大量云资源被清空。

正规实践是给予最小化的RBAC权限。CCM通常需要:对节点的Get、List、Watch、Patch、Update权限;对Service的Get、List、Watch、Update权限;以及对应的事件写入权限,用来发布CloudProviderError事件。如果使用了路由控制器,可能还需要对Node的Patch权限以更新PodCIDR信息。这些权限应形成一个独立的ClusterRole,只绑定给CCM运行关联的ServiceAccount。如果你使用的是官方发行的CCM发行版,通常会在对应Helm Chart或部署YAML里写好最小RBAC清单,不要图省事直接删掉。

4. 自研CCM的踩坑现场:云API的最终一致性、不可变ID与孤儿资源

4.1 “最终一致性”API带来的连锁反应

实话实说,在自研CCM这件事上,我踩过的坑能绕集群一圈。第一个大坑是云厂商API的“最终一致性”问题。云厂商的核心接口(例如查询实例列表)通常以异步、分页方式提供数据,如果你从某次API返回内容中只看到部分节点状态,就以为节点全挂了,然后大量节点被强制判为NotReady,接着整个集群开始风暴式地重新调度Pod,实际影响是灾难性的。

这种状况,往往是因为CCM调用的列表查询带了分页偏移或缓存延迟,导致瞬时视图缺失。解决的办法需要两手抓:一方面在CCM中引入指数退避与多级缓存,不能拿某一次的列表结果就直接更新节点状态,最好是每隔一个周期对比云API快照和集群内节点状态差异,再执行变更;另一方面要设计合理的故障阈值判断,比如只有在多次连续调云API都失败后,才标记节点NotReady。单次瞬时的List接口抖动,完全不应该触发状态变更。

4.2 不可变的节点ID:千万不要在云实例ID上做“临时拼接”

第二个值得注意的坑,是节点ID元数据的使用。Kubernetes内部把节点与物理机映射的信息记录在spec.providerID里,CCM在做任何云API交互前,都要通过这个ProviderID找对具体的云实例。很多人误以为可以直接用hostname或ip-xxx拼接出ProviderID,结果一旦云实例发生迁移或者弹性伸缩重建,新实例ID就完全对不上了。

更麻烦的是,云厂商对重复ID的校验常常很宽容,你拿一个错误ID去查询,它可能返回上一次存在的对象,甚至返回空对象。此时CCM如果拿空对象乐观更新节点状态,会把一个健康节点误删。建议在CCM中维护一个映射层,将ProviderID与云实例ID做一一对应,绝不可反过来。并且在云实例信息不一致时,宁可怀疑数据有问题,也不要盲目更新集群状态。节点ID一旦失真,节点生命周期控制器的整个逻辑都会被带偏。

4.3 孤立的负载均衡:只删除LB是不够的

第三个实际高频问题出现在Service Controller的清理逻辑上。很多CCM删除Service时,仅仅调用了RemoveLoadBalancer接口,却把绑定在LB上的安全组、后端自建监听器和健康检查策略全部遗漏了。结果就是Service虽然删除了,但云资源账单上仍然会出现几项莫名其妙的“孤儿资源”。

更诡异的是,这种残留安全组可能还不影响流量——如果后端NodePort恰好同时在监听,旧的转发链甚至会“通透”一段时间,出现删了等于没删的效果,给运维排查造成很大迷惑。后来复盘总结,主要是因为云厂商API中负载均衡器和配套安全组是两个不同资源维度,CCM必须每一步都执行子资源的级联删除。即使云厂商SDK没有提供级联删除接口,也必须在CCM侧记录好关联资源ID,在删除LB后主动清理安全组、监听器和健康检查配置。否则一次重复部署就会残留一堆没人认领的资源。

4.4 其实很多团队并不需要自研CCM

写到这里,我想给大多数团队一个务实建议:如果你们只是在某一家公有云上托管集群,那么完全不需要自研CCM,优先用官方托管的Kubernetes组件自带的或官方维护的CCM就行。自研CCM通常只有在以下场景才值得考虑:你所在的多云环境需要统一抽象,云厂商的默认CCM无法满足特殊网络规划,或者你希望突破托管集群对控制平面的限制。否则,为了“一两个自定义标签”去趟自研CCM这趟浑水,性价比其实很低。

到了这一步,你应该已经能看懂CCM在集群中扮演什么角色、哪条链路容易出问题,以及真实生产环境里它是怎么工作的了。希望这一长串踩坑记录能帮你在未来少付一点学费。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进