关于clusterip和nodeport
我们知道k8s的service一般都会有clusterIP和port,每个cluster-ip:port
的组合对应着一个服务,kube-proxy(这里以iptables为例)为这个服务生成一组iptables规则,分发到对应的后端和后端端口。比如:
访问 10.178.4.10:443
NAT to 10.177.10.3:6443
这里clusterIP是由k8s自行从一个段中分配的(可以通过apiserver的启动参数: --service-cluster-ip-range
自定义),用户也可以创建service时显式指定。 需要注意的是service一旦创建成功,cluster-IP就无法被修改(inmutable),并且不允许有重复的clusterIP,因为这会带来访问时的异常。
另外,service的类型如果是nodePort,k8s会选择一个port(默认范围是30000 - 32767,范围可以通过apiserver的启动参数: --service-node-port-range
自定义),之后每个node上都会打开这个端口进行转发。用户可以动态修改service描述文件中的nodePort的值,node上的iptables规则会相应地进行更新。
不过,由于机器上的端口是不可以重复监听的,所以nodePort也是不能重复的。
本文主要探究clusterIP和nodePort分配的实现
clusterIP、nodeport的管理模块
从kubernetes\pkg\registry\core\service\storage\rest.go
中我们可以看到一个initClusterIP
和一个initNodePorts
, 显然这两个方法是用来在创建service时分配cluster-IP和nodePort的。他们同属于service这个对象的REST
, REST
是每个k8s中对象的接口服务。initClusterIP
和initNodePorts
都是在REST
的Create
接口中调用的。
因为nodePort是允许动态修改的,所以这个文件中后面还有对nodePort的update、release函数,这几个函数都需要通过参数:nodePortOp *portallocator.PortAllocationOperation
进行端口的分配或删除。
clusterIP由于不可变,只有在service的Delete方法中,会通过releaseAllocatedResources
调用allocator进行release:
if helper.IsServiceIPSet(svc) {
allocator := rs.getAllocatorByClusterIP(svc)
allocator.Release(net.ParseIP(svc.Spec.ClusterIP))
}
可见不论是cluster-ip还是nodeport,apiserver都构建了一个allocator,用来分配和维护IP池/port池。
代码入口
在kubernetes\pkg\registry\core\rest\storage_core.go
中我们可以看到在构建一个REST
时执行了如下代码:
// IPALLOCATOR
serviceClusterIPAllocator, err := ipallocator.NewAllocatorCIDRRange(&serviceClusterIPRange, func(max int, rangeSpec string) (allocator.Interface, error) {
mem := allocator.NewAllocationMap(max, rangeSpec)
// TODO etcdallocator package to return a storage interface via the storageFactory
etcd, err := serviceallocator.NewEtcd(mem, "/ranges/serviceips", api.Resource("serviceipallocations"), serviceStorageConfig)
if err != nil {
return nil, err
}
serviceClusterIPRegistry = etcd
return etcd, nil
})
// 这里还有一个ipv6的allocator,需要打开apiserver的featureGate:IPv6DualStack才会使用,这里略过
// PORTALLOCATOR
serviceNodePortAllocator, err := portallocator.NewPortAllocatorCustom(c.ServiceNodePortRange, func(max int, rangeSpec string) (allocator.Interface, error) {
mem := allocator.NewAllocationMap(max, rangeSpec)
// TODO etcdallocator package to return a storage interface via the storageFactory
etcd, err := serviceallocator.NewEtcd(mem, "/ranges/servicenodeports", api.Resource("servicenodeportallocations"), serviceStorageConfig)
if err != nil {
return nil, err
}
serviceNodePortRegistry = etcd
return etcd, nil
})
上述两块代码分别构建了clusterIP和nodeport的allocator。 对应的是两个struct: Range
和PortAllocator
,并且将数据记录到etcd中进行记录。
另外,这里还封装了对应的serviceClusterIPRegistry
和serviceNodePortRegistry
,他们会作为参数构建出一个controller:mastercontroller。可以在NewBootstrapController
函数中看到。我们跟进这个controller的Start方法,可以看到它反复地执行clusterIP的repair和nodePort的repair。这就是apiserver中对clusterIP和nodePort的另一部分管理——巡检维护。
repairClusterIPs := servicecontroller.NewRepair(c.ServiceClusterIPInterval, c.ServiceClient, c.EventClient, &c.ServiceClusterIPRange, c.ServiceClusterIPRegistry, &c.SecondaryServiceClusterIPRange, c.SecondaryServiceClusterIPRegistry)
repairNodePorts := portallocatorcontroller.NewRepair(c.ServiceNodePortInterval, c.ServiceClient, c.EventClient, c.ServiceNodePortRange, c.ServiceNodePortRegistry)
// run all of the controllers once prior to returning from Start.
if err := repairClusterIPs.RunOnce(); err != nil {
// If we fail to repair cluster IPs apiserver is useless. We should restart and retry.
klog.Fatalf("Unable to perform initial IP allocation check: %v", err)
}
if err := repairNodePorts.RunOnce(); err != nil {
// If we fail to repair node ports apiserver is useless. We should restart and retry.
klog.Fatalf("Unable to perform initial service nodePort check: %v", err)
}
c.runner = async.NewRunner(c.RunKubernetesNamespaces, c.RunKubernetesService, repairClusterIPs.RunUntil, repairNodePorts.RunUntil)
c.runner.Start()
到这里我们可以整理一下大致的实现结构:
不管对于cluster-ip和nodePort, apiserver的管理模式都是:
- 生成一个allocator
- 将ip池和port池注册到etcd中,在allocator中包含了对相关etcd的操作接口,
- 调用allocator或allocator的封装,在REST的Create、Update、Delete方法里进行ip和port的管理。
- 将allocator传递到master-controller中,进行定时的repair。
最终就是两部分:
- 一套资源操作接口
- 一个数据自动化校验的定时器
数据结构
我们通过etcdctl命令,在etcd节点上检查相应的ip池和port池内容:
./etcdctl -C http://10.176.10.57:2379 get /registry/ranges/serviceips
{"kind":"RangeAllocation","apiVersion":"v1","metadata":{"creationTimestamp":null},"range":"10.0.0.0/16","data":"BAAABAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAACAAAAAAAAAAAACAAAAAAAAAAAAAIAAAAAAAAAAAAAAAACAAAAAAIAAAAAAAAAAAAAAAAAEAABACAAAAAgAIAAAQAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAEAAAAAACAAAAAAAAAAAAAAgAAAAAA..."}
// 由于这里ip-range配的比较大,所以内容太多,只贴一部分
从这个数据内容大概可以猜出这数据是以位图的方式进行存储。(在pkg\registry\core\service\allocator\bitmap.go
中我们就可以看到具体的实现逻辑) 位图记录了IP/port的偏移量,每当分配IP时,从位图中找到一个空闲位,根据该位的偏移地址,和ip/port的起始地址进行计算,可以得到要分配的IP地址或port
资源分配逻辑
具体的资源分配逻辑(以ipallocator为例):
- ipallocator根据用户是否主动填了clusterIP来决定要调用Allocate还是AllocateNext,前者会先判断指定的IP是否在range中
- 调用storage(etcd)的Allocate或AllocateNext
- storage中调用bitmap的Allocate或AllocateNext
- bitmap检查位图,通过检查位数值并设置,实现位图上的分配,并返回偏移量;
- bitmap分配成功后,storage会去调用tryUpdate方法,先拉取最新的etcd数据,对比resourceversion,如果发现etcd数据发生了更新,那么会同步最新的数据到bitmap,然后再次调用bitmap的分配方法,最后更新到etcd中,并把偏移量返回到上层
- 更新到etcd成功后,ipallocator会根据返回的偏移量计算出合的IP地址。
定时自检逻辑
首先:为什么要自检?原因有二:
- 对ip池或port池的动态扩缩。通常我们动态修改
service-cluster-ip-range
或service-node-port-range
并重启apiserver后,etcd中的IP池/port池会被重新生成,通过自检,我们才能将IP池/port池的使用记录进行rebuild -
etcd的操作延迟导致一致性问题。比如
- etcd中丢失了某个clusterIP的创建提交, 或者是该clusterIP对应的service资源将要删除但还没有提交删除到etcd,导致有clusterIP没有在etcdIP池中记录
- etcd中丢失了某个clusterIP的删除提交,或者是该clusterIP对应的service将要创建但还没有提交创建到etcd,导致etcdIP池中有残余的clusterIP
我们以clusterip的repair(见pkg\registry\core\service\ipallocator\controller\repair.go
)为例。整理一下是如何进行自检的。
首先明确几个概念:
- snapshot,指的是从storae(etcd)获取到的数据
- stored,是我们基于snapshot临时生成的一个无后端存储的allocator
- rebuilt,是我们基于当前service-cluster-ip-range生成的另一个临时的无后端存储的allocator
- leaks,是repair对象长期维护的一个map,记录了前几次自检统计到的泄漏的IP,以及他们的检查状态。
在生成上述三个对象后,开始自检流程:
- list所有的service,针对其中需要且有clusterip的service进行步骤2的检查
-
在rebuilt中将该clusterip进行分配
- 如果分配不成功我们认为这个clusterIP已经是一个非法的IP了,进行报警,如果错失败是因为rebuilt满了,就报错并退出本次自检
-
如果成功,我们检查一下在
stored
中是否记录了这个IP:- 如果有记录,我们认为一切正常,将记录从
stored
中清理,并从leaks中删除(也可能leaks中根本没有该IP的记录) - 如果没有记录,我们认为这个IP在数据库中被遗漏了,应该分配,进行报警。(后面会进行分配)
- 如果有记录,我们认为一切正常,将记录从
-
对所有service进行步骤2的检查后,我们遍历
stored
,注意,此时正常的IP(service中使用了,且数据库中标记分配了的IP)已经从stored
中排除,所以此时stored
里的数据应该包含了:记录在etcd的ip池中,但不存在于service中的clusterIP,这些应当被视为泄漏的IP;以及不属于service-cluster-ip-range
但存在于service中的clusterIP。 对于这些IP,我们检查其是否存在于leaks
- 如果不存在,将其加入
leaks
中,且检查状态记录为1
,并尝试在rebuilt
中进行分配, - 如果存在且计数>0,将其检查状态计数-1,并尝试在
rebuilt
中进行分配 - 其他情况下,我们就不再尝试将IP在`rebuilt中进行分配了,我们认为这个IP可以不要了
- 如果不存在,将其加入
- 一通操作后,我们将
rebuilt
数据保存到etcd中。下次在进行repair时,stored
将会是本次的rebuilt
数据。
我们总结一下,对于etcd中记录的不同的IP,我们做了如下的运维:
etcd中,当前service使用的所有clusterip里:
- 属于当前
service-cluster-ip-range
的ip,都记录在了rebuilt
中; - 不属于当前
service-cluster-ip-range
的ip,都记录到了leaks
中,它们最终会被从etcd的IP池里删除
etcd中,可能由于读写延迟、分布式操作等原因,导致存在着不属于当前service使用的clusterIP(举个例子:创建service时,先写了etcd的ip池数据,之后要写入service数据前,进行了本次repair),在这些IP中:
- 如果IP同样存在于
leaks
,IP将在本次repair中被清理; - 如果IP不存在于
leaks
,IP将记录到leaks
中,并在rebuilt
尝试分配该IP
etcd中,由于读写延迟、分布式操作等原因,可能缺少了某些当前service正在使用的clusterIP,在这些IP中:
- 如果IP属于当前
service-cluster-ip-range
,那么IP将会被从leaks
中删除,并且登记在rebuilt
里 - 如果不属于当前
service-cluster-ip-range
,这些IP会被报警提示,但不会记录到ip池(但仍然记录在使用他们的service的数据中)
于是,repair后,rebuilt
中记录的IP即包括:
- 当前
service-cluster-ip-range
下的所有被service引用的clusterIP - 一小部分“etcd中残留的、不属于任何service的、属于
service-cluster-ip-range
范围内的IP”,这部分IP在下一次repair时,如果仍然没有对应的service被创建出来,就会被释放——不再记录到etcd中。
nodePort的自检流程与clusterIP基本一致。