k8s apiserver对service的IP和nodeport的管理

关于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这个对象的RESTREST是每个k8s中对象的接口服务。initClusterIPinitNodePorts都是在RESTCreate接口中调用的。

因为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: RangePortAllocator,并且将数据记录到etcd中进行记录。

另外,这里还封装了对应的serviceClusterIPRegistryserviceNodePortRegistry,他们会作为参数构建出一个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的管理模式都是:

  1. 生成一个allocator
  2. 将ip池和port池注册到etcd中,在allocator中包含了对相关etcd的操作接口,
  3. 调用allocator或allocator的封装,在REST的Create、Update、Delete方法里进行ip和port的管理。
  4. 将allocator传递到master-controller中,进行定时的repair。

最终就是两部分:

  1. 一套资源操作接口
  2. 一个数据自动化校验的定时器

数据结构

我们通过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为例):

  1. ipallocator根据用户是否主动填了clusterIP来决定要调用Allocate还是AllocateNext,前者会先判断指定的IP是否在range中
  2. 调用storage(etcd)的Allocate或AllocateNext
  3. storage中调用bitmap的Allocate或AllocateNext
  4. bitmap检查位图,通过检查位数值并设置,实现位图上的分配,并返回偏移量;
  5. bitmap分配成功后,storage会去调用tryUpdate方法,先拉取最新的etcd数据,对比resourceversion,如果发现etcd数据发生了更新,那么会同步最新的数据到bitmap,然后再次调用bitmap的分配方法,最后更新到etcd中,并把偏移量返回到上层
  6. 更新到etcd成功后,ipallocator会根据返回的偏移量计算出合的IP地址。

定时自检逻辑

首先:为什么要自检?原因有二:

  1. 对ip池或port池的动态扩缩。通常我们动态修改service-cluster-ip-rangeservice-node-port-range并重启apiserver后,etcd中的IP池/port池会被重新生成,通过自检,我们才能将IP池/port池的使用记录进行rebuild
  2. etcd的操作延迟导致一致性问题。比如

    1. etcd中丢失了某个clusterIP的创建提交, 或者是该clusterIP对应的service资源将要删除但还没有提交删除到etcd,导致有clusterIP没有在etcdIP池中记录
    2. etcd中丢失了某个clusterIP的删除提交,或者是该clusterIP对应的service将要创建但还没有提交创建到etcd,导致etcdIP池中有残余的clusterIP

我们以clusterip的repair(见pkg\registry\core\service\ipallocator\controller\repair.go)为例。整理一下是如何进行自检的。

首先明确几个概念:

  1. snapshot,指的是从storae(etcd)获取到的数据
  2. stored,是我们基于snapshot临时生成的一个无后端存储的allocator
  3. rebuilt,是我们基于当前service-cluster-ip-range生成的另一个临时的无后端存储的allocator
  4. leaks,是repair对象长期维护的一个map,记录了前几次自检统计到的泄漏的IP,以及他们的检查状态。

在生成上述三个对象后,开始自检流程:

  1. list所有的service,针对其中需要且有clusterip的service进行步骤2的检查
  2. 在rebuilt中将该clusterip进行分配

    1. 如果分配不成功我们认为这个clusterIP已经是一个非法的IP了,进行报警,如果错失败是因为rebuilt满了,就报错并退出本次自检
    2. 如果成功,我们检查一下在stored中是否记录了这个IP:

      1. 如果有记录,我们认为一切正常,将记录从stored中清理,并从leaks中删除(也可能leaks中根本没有该IP的记录)
      2. 如果没有记录,我们认为这个IP在数据库中被遗漏了,应该分配,进行报警。(后面会进行分配)
  3. 对所有service进行步骤2的检查后,我们遍历stored,注意,此时正常的IP(service中使用了,且数据库中标记分配了的IP)已经从stored中排除,所以此时stored里的数据应该包含了:记录在etcd的ip池中,但不存在于service中的clusterIP,这些应当被视为泄漏的IP;以及不属于service-cluster-ip-range但存在于service中的clusterIP。 对于这些IP,我们检查其是否存在于leaks

    1. 如果不存在,将其加入leaks中,且检查状态记录为1,并尝试在rebuilt中进行分配,
    2. 如果存在且计数>0,将其检查状态计数-1,并尝试在rebuilt中进行分配
    3. 其他情况下,我们就不再尝试将IP在`rebuilt中进行分配了,我们认为这个IP可以不要了
  4. 一通操作后,我们将rebuilt数据保存到etcd中。下次在进行repair时,stored将会是本次的rebuilt数据。

我们总结一下,对于etcd中记录的不同的IP,我们做了如下的运维:

etcd中,当前service使用的所有clusterip里:

  1. 属于当前service-cluster-ip-range的ip,都记录在了rebuilt中;
  2. 不属于当前service-cluster-ip-range的ip,都记录到了leaks中,它们最终会被从etcd的IP池里删除

etcd中,可能由于读写延迟、分布式操作等原因,导致存在着不属于当前service使用的clusterIP(举个例子:创建service时,先写了etcd的ip池数据,之后要写入service数据前,进行了本次repair),在这些IP中:

  1. 如果IP同样存在于leaks,IP将在本次repair中被清理;
  2. 如果IP不存在于leaks,IP将记录到leaks中,并在rebuilt尝试分配该IP

etcd中,由于读写延迟、分布式操作等原因,可能缺少了某些当前service正在使用的clusterIP,在这些IP中:

  1. 如果IP属于当前service-cluster-ip-range,那么IP将会被从leaks中删除,并且登记在rebuilt
  2. 如果不属于当前service-cluster-ip-range,这些IP会被报警提示,但不会记录到ip池(但仍然记录在使用他们的service的数据中)

于是,repair后,rebuilt中记录的IP即包括:

  1. 当前service-cluster-ip-range下的所有被service引用的clusterIP
  2. 一小部分“etcd中残留的、不属于任何service的、属于service-cluster-ip-range范围内的IP”,这部分IP在下一次repair时,如果仍然没有对应的service被创建出来,就会被释放——不再记录到etcd中。

nodePort的自检流程与clusterIP基本一致。

你可能感兴趣的:(k8s)