kubelet组件是Kubernetes集群工作节点上最重要的组件进程,它负责管理和维护在这台主机上运行着的所有容器。本质上,它的工作可以归结为使得pod的运行状态(status)与它的期望值(spec)一致。目前,kubelet支持docker和rkt两种容器;而社区也在尝试使用C/S架构来支持更多container runtime与Kubernetes的结合。在很多类似的项目中,这种类型的组件一般会被命名为agent,但是kubelet这个名称则明显脱胎于Borg系统的borglet,两者的角色也是类似的。接下来,还是先kubelet的启动过程
(1) kubelet需要启动的主要进程是KubeletServer,它所需加载的重要属性包括kubelet本身的属性、接入的runtime容器(目前支持docker和rkt)所需的基础信息以及定义kubelet与整个集群进行交互所需的信息。在v1.2.0版本的代码中,一部分信息是KubeletServer结构体的属性,而余下的部分则存放在KubeletConfig中。社区会考虑在代码重构时将两部分信息均合并到KubeletServer里。(2) 进行如下一系列的初始化工作。
(3) 初始化工作完成后,实例化一个真正的kubelet进程。重点值得关注的有以下几点。
kubelet启动完成后通过事件收集器向APIServer发送一个kubelet已经启动的event,表明集群新加入了一个新的工作节点,kubelet将这一过程称为BirthCry,即“出生的啼哭”。并且开始进行容器和镜像的垃圾回收,对应的时间间隔分别为1分钟和5分钟。
(4) 根据Runonce的值选择运行仅一次kubelet进程或在后台持续运行kubelet进程,如果Runonce为true,则kubelet根据容器配置文件的内容创建pod后就退出;否则,将以goroutine的方式持续运行kubelet。另外,默认启用kubelet Server的功能,它将根据admin的配置创建HTTP Server或HTTPS Server,监听10250端口。同时,创建一个HTTP Server监听10255端口,用于heapster向kubelet收集统计信息。
在Kubernetes中真正负责容器操作的只有kubelet组件,它担负着一个工作节点上所有容器的司令官的角色。虽然整个过程看起来有些复杂与繁琐,但是本质上kubelet对工作节点上的两种更新做出相应的行为反馈,其一为pod spec的更新,其二为容器实际运行状态的更新。所以,kubelet(及上述提及的所有module)都是为了获取或同步两种更新所设计的。
kubelet使用我们第4章介绍过的cAdvisor(Container Advisor)作为抓取Docker容器和宿主机资源信息的工具。运行在宿主机上的cAdvisor后台服务通过暴露一个TCP端口对外提供一套REST API,客户端可以发起形如以下的HTTP请求。
http://<hostname>:<port>/api/<version>/<request>
cAdvisor主要负责收集工作节点上的容器信息及宿主机信息,下面将一一进行介绍。
● 容器信息
获取容器信息的URL形如:/api/{api version}/containers/。绝对容器名(absolute container name)与URL的对应关系如表所示。
绝对容器名/下包含整个宿主机上所有容器(包括Docker容器)的资源信息,而绝对容器名/docker下才包含所有Docker容器的资源信息。如果想获取特定Docker容器的资源信息,绝对容器名字段需要填入/docker/{container ID}。
● 宿主机信息
类似地,还可以访问URL:/api/{api version}/machine来获取宿主机的资源信息。返回的JSON结果则包括这台机器的CPU核心数、内存总容量、磁盘容量信息等。
相信曾经尝试过较大规模运行Docker容器的用户一定感受过大量垃圾容器和镜像给用户和系统带来的资源浪费和操作延时。所以作为一个容器云框架,能够保证容器运行环境的干净和简单是提高容器管理性能的不二法宝,在这一点上Kubernetes和Mesos都有着很先进的设计。kubelet垃圾回收机制主要涵盖两个方面:容器回收和镜像回收。目前支持的两种容器runtime(docker及rkt)分别实现了各自的细节逻辑。此处以docker为例进行说明。
● Docker容器的垃圾回收
我们知道,停止运行的容器仍会占据系统的磁盘空间且Docker daemon没有容器垃圾回收机制,如果系统一直保留已经停止运行的容器实例,久而久之磁盘空间就会被消耗殆尽。因此定期对系统中不再使用的容器进行回收的工作责无旁贷地落到了运行在工作节点上且直接与容器打交道的kubelet肩上。Docker容器回收策略主要涉及3个因素,
(1) 获取所有可以被kubelet垃圾回收的容器。调用一次Docker客户端API获取工作节点上所有由kubelet创建的容器信息,形成一个容器列表,这些容器可能处于不同的生命周期状态,包括正在运行的和已经停止运行的。注意,需要通过命名规则来判断容器是否由kubelet创建并维护,如果忽略了这一点可能会因为擅自删除某些容器而惹恼用户。
遍历该列表,过滤出所有可回收的容器。所谓可回收的容器必须同时满足两个条件:已经停止运行;创建时间距离现在达到预设的报废时间MinAge。
过滤出所有符合条件的可回收容器后,kubelet会将这些容器以所属的pod及容器名对为单位放到一个集合(evictUnits)中,并根据pod创建时间的早晚进行排序,创建时间越早的pod对应的容器越排在前面。注意,在创建evictUnits的过程中,需要解析容器及其对应的pod名字,解析失败的容器称为unidentifiedContainers。
(2) 根据垃圾回收策略回收镜像。
首先,删除unidentifiedContainers以及被删除的pod对应的容器。这部分容器的删除不需要考虑回收策略中MaxPerPodContainer和MaxContainers。如果podMaxPerpodContainer的值大于等于0,则遍历evictUnits中所有的pod,如果某个pod内的可回收容器数量大于MaxPerpodContainer,则删除多出的容器及其日志存储目录,其中创建时间较早的容器优先被删除。如果MaxContainers的值大于等于0且evictUnits中的容器总数也大于MaxContainers,则执行以下两步。
❏ 先逐一删除pod中的容器,直到每个pod内的可回收容器数=MaxContainers/evictUnits的大小,如果删除之后某个pod内的容器数<1,则置为1,目的是为每个pod尽量至少保留一个可回收容器。❏ 如果此时可回收容器的总数还是大于MaxContainers,则按创建时间的先后顺序删除容器,较早创建的容器优先被删除。
● Docker镜像的垃圾回收
与容器的垃圾回收机制的目的一样,Docker镜像垃圾回收机制主要是为了防止长时间未使用的镜像占据大量的磁盘空间,而且过多的镜像还会拖慢很多Docker请求处理的速度(因为要load的graph太大了)。Docker镜像回收策略主要涉及3个因素
在Kubernetes中,Docker镜像的垃圾回收步骤如下所示。
(1) 首先,调用cadvisor客户端API获取工作节点的文件系统信息,包括文件系统所在磁盘设备、挂载点、磁盘空间总容量(capacity)、磁盘空间使用量(usage)和等。如果capacity为0,返回错误,并记录下InvalidDiskCapacity的事件。
(2) 如果磁盘空间使用率百分比(usage*100/capacity)大于或等于预设的使用率上限HighThresholdPercent,则触发镜像的垃圾回收服务来释放磁盘空间,否则本轮检测结束,不进行任何回收工作。至于具体回收多少磁盘空间,使用以下公式计算:
amountToFree := usage - (int64(im.policy.LowThresholdPercent) * capacity / 100)
其实就是释放超出LowThresholdPercent的那部分磁盘空间。那么kubelet会选择删除哪些镜像来释放磁盘空间呢?
首先,获取镜像信息。参考当时的时间(Time.Now())kubelet会检调用Docker客户端查询工作节点上所有的Docker镜像和容器,获取每个Docker镜像是否正被容器使用、占用的磁盘空间大小等信息,生成一个系统当前存在的镜像列表imageRecords,该列表中记录着每个镜像的最早被检测到的时间、最后使用时间(如果正被使用则使用当前时间值)和镜像大小;删除imageRecords中不存在的镜像的记录。
然后,根据镜像最后使用时间的大小进行排序,时间戳值越小即最后使用时间越早的镜像越排在前面。如果最后使用时间相同,则按照最早被检测到的时间排序,时间戳越小排在越前面。最后,删除镜像。遍历imageRecords中的所有镜像,如果该镜像的最后使用时间小于执行第一步时的时间戳,且该镜像的存在时间大于MinAge,则删除该镜像,并且将删除Docker镜像计入释放的磁盘空间值,如果释放的空间总量大于等于前面公式计算得到的amountToFree值,则本轮镜像回收工作结束。否则,则记录一条失败事件,说明释放的空间未达到预期。
这些宿主机信息包括以下几点。
❏ 工作节点IP地址。
❏ 工作节点的机器信息,包括内核版本、操作系统版本、docker版本、kubelet监听的端口、工作节点上现有的容器镜像。
❏ 工作节点的磁盘使用情况——即是否有out of disk事件。
❏ 工作节点是否Ready。在node对象的状态字段更新工作节点状态,并且更新时间戳,则node controller就可以凭这些信息是否及时来判定一个工作节点是否健康。
❏ 工作节点是否可以被调度pod。
最后,kubelet再次调用APIServer API将上述更新持久化到etcd里。
Kubernetes基于service、endpoint等概念为用户提供了一种服务发现和反向代理服务,而kube-proxy正是这种服务的底层实现机制。kube-proxy支持TCP和UDP连接转发,默认情况下基于Round Robin算法将客户端流量转发到与service对应的一组后端pod。在服务发现的实现上,Kube-prxoy使用etcd的watch机制,监控集群中service和endpoint对象数据的动态变化,并且维护一个从service到endpoint的映射关系,从而保证了后端pod的IP变化不会对访问者造成影响。另外kube-proxy还支持session affinity(即会话保持或粘滞会话)。
kube-proxy主要有两种工作模式:userspace和iptables, v1.2.0版本中默认使用iptables模式,所以除非有特殊说明,否则本书均以iptables模式为主进行讲解。现在假设要创建以下这个service对象,该service暴露80端口对外提供服务,代理所有name=service-nginx的pod。
{
"kind": "Service",
"apiVersion": "v1",
"metadata": {
"name": "nginx-service",
"labels": {
"name": "service-nginx"
}
},
"spec": {
"selector": {
"name": "service-nginx"
},
"ports": [{
"port": 80
}]
}
}
创建成功后使用查看系统的service信息,可以看到新创建的service实例的cluster IP为10.0.210.167,它代理了两个pod(对应的pod ip为10.1.99.5:80和10.1.99.6:80)。
$ kubectl get services
NAME CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes 10.0.0.1 443/TCP 31d
nginx-service 10.0.210.167 80/TCP 9s
$ kubectl get endpoints
NAME ENDPOINTS AGE
kubernetes 10.211.55.15:6443 32d
nginx-service 10.1.99.5:80,10.1.99.6:80 2h
10.0.210.167是系统从预留的service-cluster-ip-range地址段中随机选出来的,以上service和endpoint配置保证当访问10.0.210.167:80时,就能够访问到被这个service代理的后端pod。这是怎么实现的呢?实际上,在iptables工作模式下的,kube-proxy将根据Kubernetes集群中的service与pod的配置在工作节点上维护如下iptables设置,此处我们省略与Kubernetes无关的链(Chain)和规则(Rule):
由于职责单一,kube-proxy相比前面介绍过的组件都要简单一些,大体包括如下几个步骤。
至此,整个启动过程就讲解完毕了。总的来说,iptables模式与userspace模式的kube-proxy相比,在效率和可靠性上都有较大的优势。前者的kube-proxy本质上只负责根据service和endpoint的更新来维护iptables规则,而转发则依赖于内核态的br_netfilter,而后者需要负责监听本地端口并完成流量转发到pod的全盘工作,可能会因为打开连接数限制等各种原因影响service的访问速度,也会在资源消耗上有着更显著的瓶颈。
前面已经介绍过,kube-proxy中工作的主要服务是proxier,而LoadBalancer只负责执行负载均衡算法来选择某个pod。默认情况下,proxier绑定在BindAddress上运行,并需要根据etcd上service对象的数据变化实时更新宿主机的防火墙规则/链。由于每个工作节点上都有一个kube-proxy在工作,所以无论在哪个节点上访问service的virtual IP比如11.1.1.88,都可以被转发到任意一个被代理pod上。可见,由proxier负责的维护service和iptables规则尤为重要。这个过程通过OnServiceUpdate方法实现,该方法的参数就是从etcd中获取的变更service对象列表,下面将分别分析userspace和iptables模式下的proxier的工作流程。
● userspace模式
(1) 遍历期望service对象列表,检查每个servie对象是否合法。维护了一个activeServices,用于记录service对象是否活跃。对于用户指定不为该service对象设置cluster IP的情况,则跳过后续检查。否则,在activeServices中标记该service处于活跃状态。由于可能存在多端口service,因此对Service对象的每个port,都检查该socket连接是否存在以及新旧连接是否相同;如果协议、cluster IP及其端口、nodePort、externalIPs、loadBalancerStatus以及sessionAffinityType中的任意一个不相同,则判定为新旧连接不相同。如果service与期望一致,则跳过后续检查。否则,则proxier在本地创建或者更新该service实例。如果该service存在,进行更新操作,即首先将旧的service关闭并停止,并创建新的service实例。否则,则直接进行创建工作
删除proxier维护的service状态信息表(serviceMap)中且不在activeServices记录里的service。
(2) 删除service实例的关键在于在宿主机上关闭通向旧的service的通道。对任何一个Kubernetes service(包括两个系统service)实例,kube-proxy都在其运行的宿主机上维护两条流量通道,分别对应于两条iptables链——KUBE-PORTALS-CONTAINER和KUBE-PORTALS-HOST。所以,这一步proxier就必须删除iptables的nat表中以上两个链上的与该service相关的所有规则。
说明 service状态信息表记录的数据包括:cluster IP/Port、proxyPort(kube-proxy为每个service分配的随机端口)、ProxySocket(TCP/UDP socket的抽象,一个ProxySocket就代表一个全双工的TCP/UDP连接)和Session Affinity等。
(3) 新建一个service实例。首先,根据service的协议(TCP/UDP)在本机上为其分配一个指定协议的端口。接着,启动一个goroutine监听该随机端口上的数据,并建立一条从上述端口到service endpoint的TCP/UDP连接。连接成功建立后,填充该service实例的各属性值并在service状态信息表中插入该service实例。然后,开始为这个service配置iptables,即根据该service实例的入口IP地址(包括私有和公有IP地址)、入口端口、proxier监听的IP地址、随机端口等信息,使用iptables在KUBE-PORTALS-CONTAINER和KUBE-PORTALS-HOST链上添加相应的IP数据包转发规则。最后,以service id(由service的namespace、service名称和service端口名组成)为key值,调用LB接口在本地添加一条记录Serice实例与service endpoint的映射关系。
● iptables模式
iptables模式下的proxier只负责在发现存在变更时更新iptables规则,而不再为每个service打开一个本地端口,所有流量转发到pod的工作将交由iptables来完成。OnServiceUpdate的具体工作步骤如下。
(1) 遍历期望service对象列表,检查每个service对象是否合法,并更新其维护的serviceMap,使其与期望列表保持同步(包括创建新的service、更新过时的service以及删除不再存在的service)。
(2) 更新iptables规则。注意,这个步骤通过一个名为syncProxyRules的方法完成,在这个方法中涉及了service及endpoint两部分更新对于iptables规则的调整。处于代码完整性和逻辑严密性的考虑,此处将两部分内容合并到此处进行讲解。具体步骤如下。
endpointHandler在选择后端时默认采用Round Robin算法,同时需要兼顾session affinity等要求。
● userspace模式
前面已经介绍过,当访问请求经过iptables转发至proxier之后,选择一个pod的工作就需要交给endpointsHandler。userspace模式下的endpointsHandler本质上是一个loadBalancer(LB),它不仅能够按照策略选择出一个service endpoint(后端pod),还需要能实时更新并维护service对应的endpoint实例信息。这两个过程分别对应loadBalancer的两个处理逻辑,即NextEndpoint和OnEndpointsUpdate。下面将逐一进行分析
● iptables模式
iptables模式下的endpointsHandler本质上由proxier担任。它不再处理具体的选取service后端endpoint的工作,而只负责跟进endpoint对应的iptables规则。
OnEndpointsUpdate方法接收到etcd中endpoint对象的更新列表后,更新其维护的endpointsMap,包括更新、创建和删除其中的service和endpoint对应记录。