重学Kubernetes - 读张磊的《深度剖析Kubernetes》

本文有 17808 字,大约需要 44 分钟可以读完, 创建于 2019-08-24

云计算从十年前的风起云涌却叫好不叫座的局面慢慢地变成了无人刻意提起的境地,这一切的幕后游戏规则改变者某种程度上来说 都是因为Kubernetes这个伟大的开源项目以及依托于它的云原生运动 (参考前文)) 的蓬勃发展而激发。

购买专栏的缘起

极客时间也在刚刚开始大规模推广不久就推出了自身Kubernetes贡献者张磊的《深度剖析Kubernetes》课程;作为一个资深付费用户, 我并没有在第一时间购买该课程,一部分原因是出于谨慎(显然之前是过分谨慎了才有遗珠之恨),一部分原因则是出于人性的懒惰: 总觉得这样的内容应该网上一找一大把,官方文档又这么丰富,为什么还要专门购买?

然而一次偶然听到的InfoQ大会上张磊做的关于Kubernetes本质和演进的演讲勾起了我内心的兴趣;我不禁想能够将这个庞大的开源项目做条分缕析、深入浅出地介绍的明明白白的技术人开的专栏必然不简单。 于是才又“精明”地收藏了这个专栏并在节日活动的时候果断购入,然后依照自己的节奏慢慢消化吸收。

原作者的内容分为几个部分来阐述的,这里也就着作者本身的顺序简单地记录下自学过程中的一些重要要素和思考。

Kuberenetes的背景和容器技术的基本发端

第一个部分是关于历史背景的讲述,这方面的故事在好多地方都流传甚广了。简单来说Kubernetes这个项目其实是Google内部自己已经做了很多年的分布式系统开发经验并经历了多次迭代之后的结晶,开源给整个业界使用的。

然而这里面有个很偶然的因素便是Docker这个容器打包项目的流行和壮大,因为从Google本身而言,它并没有动力将自己强大的云计算基础设施开放出来给其它的云平台竞争对手使用。 徐飞在他的技术于商业案例解析的专栏中甚至提到,Google一开始可能是藏着掖着故意不开放自己的容器管理技术, 而仅仅开放了GFS、BigTable这些大数据技术,导致开源社区在基于虚拟机的方案上走了很多弯路。

商业公司上的开源与不开源的抉择其实不太容易为外人所知;但是回顾过去十数年的云计算发展历史,Kubernetes无疑是站在了正确的赛道上。

### 容器打包技术为什么成为颠覆者 早期的云计算技术其实都是围绕着虚拟机技术而展开的,即便是操作系统层面的虚拟化技术在十几年前就已经出现了。 Docker容器技术的出现初看起来没有太大技术本质上的创新,不过是用分层的文件系统打包应用程序,然后交给一个容器运行时后台去调度和执行。

它的流行其实是因为软件工程上的便利:传统的方式下软件工程师需要面对的是一系列复杂的软件运行时相关环境,包括采用什么样的操作系统,程序怎么样启动,如何和系统服务/系统调用打交道,如何将软件的价值交付给用户。

容器技术则提倡开发者应该专注于业务逻辑本身,只需要将自己的应用程序写好就行了,其它怎么运行的事情统统交给容器引擎来完成就行。 这样的思想不光成就了程序员,也成就了一系列服务于云技术底层的技术公司。

Docker容器技术和其它既有技术的区别

Docker技术虽然脱胎于旧有的容器技术,它本身却和老的基于LXC的容器技术有着显著的区别

  • LXC技术的基本侧重点是提供系统级容器,容器隔离的沙箱内提供给开发者的是一个看起来像一个完整的操作系统的运行环境,可谓麻雀虽小五脏俱全,用户可以像使用一个Linux服务器一样使用这个容器,可以启动多个应用服务,实现服务监控等
  • Docker技术则主要提供应用程序容器,并且提倡一个容器一个应用程序的概念。某种程度上看一个Docker容器就是一个进程; 进程的上下文环境需要保持尽可能的简单,仅仅提供程序运行本身必要的运行时库和可执行文件等;多余的东西可以一概不要。

当然讨论到Kubernetes和云原生的语境的时候,我们已经很少去刻意关照古老的LXC容器了; 大部分情况下我们说到容器的时候,基本就是在说应用程序容器

容器的本质是对进程的隔离和限制

容器技术本身提供的机制是基于内核提供的名字空间namespace和控制组cgroups的概念,它本身的实现也不是完美无缺的; 尤其是我们关心安全的时候,就会发现就所有同一宿主上运行的容器必须共享内核这一限制就会带来潜在的风险;而新型的容器技术可能会弥补这一缺陷。

由于是基于系统调用层面的虚拟隔离,容器技术有着相比虚拟机高很多的性能。

容器镜像格式的本质

具体就Docker容器所使用的aufs文件系统来说,其镜像其实是通过分层的方式来组织的,任何改变容器的方法必须要通过修改文件系统的挂载方式来实现。 简单的来说,容器的aufs文件系统通过层层叠加的方式描述软件和它自身的依赖,可以看作是三层组成

  • 只读层存放一些不希望被程序修改的内容,比如一个busybox环境或者ubuntu文件系统的可执行文件
  • init对应于一些配置文件,类似/etc/等,没有修改的时候为空
  • 用户指定的可读写层,用于用户添加自己的内容,但是如果用户删除了某些文件,容器镜像只会将其设置为隐藏而不会真正的删除对应的文件

对于需要依赖于网络环境来交换镜像的应用场景而言,容器应用的开发者需要力保容器的镜像要能够足够的小,并且可以增量编译,因为容器格式本身也是用分层的思路来设计的。 这里一个很重要的点是需要选择一个比较小的基础镜像;而由于aufs的删除隐藏策略,删除一个臃肿的基础镜像里面不用的文件并不会使你的容器文件变小:他们只是变得不可访问了而已。

Kubernetes承担的角色

容器化的应用开发、测试、部署方式其实对底层的管理和调度平台提出了不少要求,因为一个基于云原生理念开发的微服务系统不可避免需要有很多个容器实例来组成,各种方方面面的需求最后被用编排这两个词来概括,大概包含了下面这些功能

  • 镜像管理和分发
  • 容器实例的启动、管理和状态监控
  • 多个服务实例之间的服务发现、负载均衡、扩展、发布和升级
  • 其它高级功能如日志、监控、熔断、限流等

基于Google自身从Borg到Omega再到开发Kubernetes系统 的长期的基础设施虚拟化管理经验(参加前面的笔记), Kubernetes项目本身采用了完全基于声明式API的做法来 构建一个极度强调灵活性的基础平台

  • 其主要系统都是基于Object来建模的,并且对各种Object提供了丰富的REST API
  • 内部各种Object之间使松散耦合的
  • 请求改变状态的指令使通过提交一个Object的目标状态给Kubernetes平台来实现的
  • Kubernetes会监控用户提交的请求,循环调用相应的对象控制器来尝试改变对象的状态,然后比较是否满足要求,如果未满足则会继续调度执行

Kubernetes使一个运维工具吗?

从其API设计上来看,毋宁说它是一个面向开发者的工具而不是一个面向运维人员的平台。 由于其内部各个部分都是面向REST API的,因此使用者需要提供一个对象的目标状态的对象声明文件; 而既有的一些命令行工具都不过是对这一过程的封装。

因而从运维人员的角度来看,Kubernetes的命令行工具非常难用,因为它本身不是为完成一个一个具体的管理任务的网络服务运维人员量身打造的。

集群搭建和部署

这一部分相对比较简单,尽管作者也花了很大力气来介绍kubeadm这个强大的工具。 其实对于简单学习而言最简单的部署方式是再自己的笔记本或者服务机上安装一个minikube的环境,模拟一个单节点的最小化的运行环境; 如果需要搭建一个具有多个节点的集群,大抵需要遵循如下的步骤

  • 准备节点机器,确认内核版本,网络可达性
  • 部署集群的master节点,它上面一般不建议部署应用程序容器,而仅仅用于集群自身的管理和维护
  • 部署网络插件,确保各个节点之间可以互相连通
  • 部署多个集群的worker节点,这里是真正地跑应用程序容器的地方
  • 部署容器存储插件,以便有状态的服务可以访问持久化存储设施

容器管理和编排

容器的管理和控制是Kubernetes最为核心的功能,也是初步了解Kubernetes最容易卡壳的地方,因为它提出了自身特有的一些抽象的概念。

POD

POD是一个不太容易让人理解的概念,它纯粹是Kubernetes抽象出来的一个概念以方便Kubernetes的调度控制器实现复杂的逻辑。 好在我们可以用一个很简单的操作系统中的概念来类比:

  • 如果将kubernetes自身看作是操作系统(它的确也是云环境下的操作系统),那么典型的Linux操作系统是由很多个进程组成的
  • Docker容器所运行的程序本质上是一个可运行的进程,上面已经探讨过
  • POD可以看作是逻辑上相关的一个进程组,这些进程之间相互由比较亲密的关系,适合于放在一起做调度;而进程组和进程组之间则关系没有那么紧密 当然这里的类比只是为了便于理解而并不是特别准确。

POD资源管理

从资源管理的角度来看,POD里面的所有的容器都会

  • 共享同一个网络空间,即它们之间的网络默认就是互通的,甚至可以使用Unix Domain Socket这样依赖于本地网络的机制。
  • 共享同样的磁盘存储,一个容器写入在某个路径的文件,共享同一个POD的其它容器也可以访问
  • 享有同样的生存周期,即Kubernetes平台会保证当需要杀掉一个容器的时候,共享同一个POD的其它容器也会被一并杀掉

特殊的Infra容器

为了实现上面的资源共享,Kubernetes平台其实提供了一个非常特殊的Infra容器并用它来串起来各个容器。 这个特殊的容器本身是用汇编语言写成的,本身只有100~200KB几乎不占用什么额外的资源。 进而一个POD生存周期控制和它的进出网络流量都是通过这个基础设施容器来实现的。

Projected Volume投射数据卷

Kubernetes里和POD紧密相关的一个重要的概念是Project Volume,它本身是为容器提供预先定义好的数据,这样当容器启动的时候,这些数据就可以直接使用了。

这样的投射数据卷有如下一些类型

  • ConfigMap常常用于保存一些文本的配置数据,里面不能包含二进制数据,一般用来保存非加密数据
  • Secret用于防止加密数据,其数据本身是放在Kubernetes自身的etcd数据库中的,一般放入用户就不能再获取到明文
  • Downward API可以将POD自身的一些信息暴露给容器内部的应用程序使用,比如宿主机名字,IP,POD的名字,IP地址,标签和标记信息等;这些信息必须是容器启动前就可以从POD里面拿到的,不能是动态信息
  • ServiceAccountToken则通过授权的方式允许容器里面的程序访问Kubernetes本身的API Server来获取平台自身的信息;而默认情况下POD里面的容器是无权访问底层平台的API信息的

POD健康检查

POD自身的运行状态是通过健康检查机制来实现的,用户需要提供POD健康检查对应的探针(可以是一个REST API也可以是一个命令行输出), 而Kubernetes平台会定期检查这个探针的返回状态来决定POD是否出于正常运行状态。 如果没有定义,则Kubernetes则会根据docker容器的运行命令的结果来判断POD是否出于健康状态。

如果POD的健康检查返回的是异常状态,默认Kubernetes会尝试再同一个节点上重启该POD;同时这个恢复机制的策略可以通过修改POD的SPEC声明来指定。

由于POD本身的自我复机制只能限定在启动的那个节点上,万一该节点发生故障,Kubernetes也不能将其移动到另一个节点上;要实现类似的功能,需要采用更为高级的Deployment控制器来完成。

控制器模式

控制器模式是Kubernetes平台最基本的编排模式,其思路等同于如下的伪代码

for {
    actual_state := fetch_actual_state_of_X()
    expected_state := fetch_expected_state_of_X()
    if actual_state == expected_state {
        do_nothing()
    } else {
        execute_orchestration()
    }
}

不同的控制器有不同的实现,但是它们都遵循如上的处理逻辑。实际的状态来自于当前集群上,对应的API对象的实际状态,而期望的状态则来自于用户提交的API声明。 任何时候用户想改变对象的状态,都不是直接提交命令完成,而是修改期望的对象状态,然后默默等待后台的控制器异步调度对应的预定义动作,完成对象更新。

至于控制器对象本身的YAML声明可以理解为包含两个部分

  • 控制器自身的定义,包括期望的状态
  • 被控制对象的期望状态

Deployment控制器

Deployment用于控制POD的容器镜像、标签、网络设置等信息;每次更新这些内容,Deployment的控制器就会被触发确保POD被调度到期望的状态。

水平扩展和多副本

可扩展性是云计算给应用程序开发者提供的最大的一个灵活性之一;我们可以按照不同的负载和扩展性要求组织不同的服务到不同的POD中,进而实现按照需要自动伸缩、按需付费的理想; 这一能力也是最早的云计算想法被提出的时候,一个合格的云平台就应该提供的能力。

本来扩展性支持水平和垂直两个方向的扩展要求,水平扩展支持增加新的服务实例来提高服务能力,而垂直扩展则是通过添加既有服务实例的资源的方式来提升容量。 随着云平台应用的逐步深入,人们发现水平扩展比垂直扩展更为方便和易用,因此Kubernetes本身提供了对水平扩展的灵活支持;支持的方法是通过ReplicaSet来完成的。

ReplicaSet和Deployment、POD之间有着层层递进的控制关系,是三个关系紧密的控制器

  • ReplicaSet通过其spec中定义的replicas=x声明,来控制POD对象,保证实例数目总是等于x
  • Deployment对象则通过控制ReplicaSet实现具体的水平缩放、滚动更新等动作

所谓的scale命令,其实是对ReplicaSet控制的一种封装,它虽然具有命令是控制的外壳,其内部却依然是通过上述的通用的控制器模式来实现的。

滚动更新

对于需要滚动更新的常见,Deployment其实会创建多个版本的ReplicaSet,并且根据期望的新旧版本的实例个数, 动态的创建、停止新旧版本的软件,实现服务的动态滚动部署。

StatefulSet有状态服务

Deployment控制器模式所能控制的POD默认是需要保持无状态的,即不同的副本关闭和重启对服务提供的业务逻辑而言是没有关联的;这也是云原生模式要求的。 然而现实实际往往没有这么美好,实际情况是往往我们还有很多有状态的服务,即不同的实例之间是不能无缝替换的。 对于这种情况,Kubernetes提供了StatefulSet这一对象来描述和管理。

其设计将状态分为两种

  • 拓扑状态,即某些服务实例需要按照特定的顺序来启动,它们的拓扑结构上产生的依赖和状态
  • 存储状态,即不同的服务实例需要不同的存储数据,比如数据库的主从关系等

拓扑状态

基于拓扑顺序而言的有状态应用而言,我们可以利用headless service的概念(即需要指定clusterIP=None),通过拿到service名字之后,直接用label选择规则就可以定位到对应的service POD实例的IP,而不需要经过virtual IP机制。

这一策略能行得通是因为POD在被创建的时候,每个POD都被赋予一个带编号的名字(比如web-1/web-2/等),并且这些编号从1开始依次增加,严格按照顺序创建。 销毁的时候,也需要按照顺利进行。

通过这种方式,Kubernetes可以为有状态的服务提供一个独一无二的DNS名字,这样服务使用方就可以根据名字来解析到对应的POD IP进行访问,即使是POD的IP地址可以在运行的时候被改变也不至于造成太大的影响。

存储状态

对于需要访问有状态存储的服务而言,Kubernetes通过如下的服务对象来支持

  • Persistent Volume Claim用于指定持久化卷访问声明,这是一个关于访问需求的描述对象
  • Persistent Volume持久化卷用于提供具体的持久化访问能力

PV和PVC的概念设计非常类似于应用程序开发中的接口和实现分离的具体实现;PV是具体的实现,而PVC则充当接口。 应用程序的POD声明需要绑定一个具体的PVC对象,Kubernetes平台就会自动完成POD需要的持久化卷和实际可用的持久化卷之间的匹配。

在完成对应的PVC绑定之后,PVC的名字就会和StatefulSet的名字绑定在一起完成统一编号,形成类似于<pvcname>-<satefulset>-1这样的名字。

当POD被删除的时候,PVC对应的存储卷里面的内容并不会丢失而是被持久化保存起来,这样下次POD再次被Statefulset创建起来的时候,它还可以访问到之前对应的那个PVC里面的内容。 它的工作原理如下

  1. 当POD被删除的时候,StatefulSet会留意到对应的POD停止了
  2. 控制器会重新尝试创建对应编号的POD,并且在创建之后,尝试重新关联相同编号的PVC

StatefulSet的设计思想

综合以上两种情况,可以看出来StatefulSet的核心思路是其本身是一种特殊的Deployment,它所控制的POD资源在运行调度的时候,每一个POD都会被单独编号;并且这些编号信心还会反应在其POD资源的名字和DNS信息上,作为POD的网络身份名字。

基于该编号信息,它就可以利用Kubernetes里面的Headless Service和PV、PVC的概念,实现对POD的拓扑状态和存储资源状态的维护和控制。

DaemonSet守护进程

DaemonSet是另外一个控制器,它的主要场景类似于普通Linux系统里面的守护进程,只不过它的作用域是一个一个独立的Kubernetes集群节点,即我们想控制一些POD的部署,让它确保在整个集群的每个节点上都有且仅有一个POD实例在运行的场景。

如果集群被扩容(即有新的节点加入进来),那么DaemonSet会立刻在这个新的节点上创建一个新的POD出来;而当某个节点被删除的时候,其上的POD也会被自动清理掉。

这种控制器的应用场景包含一些特殊的插件的Agent,比如网络或者存储插件,监控和日志等组件;它们基本的工作原理都是在一个节点上创建一个唯一的实例,完成网络处理、文件系统挂载、日志转发和监控等节点层面唯一的处理。

DaemonSet如何保证唯一

其控制器通过获取etcd数据库里面的Node列表,然后逐一遍历其中的节点并做相应的检查,即可判定是否需要创建或者删除POD。 如果没有,则新创建POD;如果多余一个,则执行删除即可。

删除的操作比较简单,通过现有的API调用即可;添加操作稍微麻烦一点。 旧版的API可以通过指定POD对象的nodeSelector将新的节点名字加上去;但是这样的操作多少有些不方便并且将要被废弃。

更好的方法是通过spec.nodeAffinity来实现,例如下面的例子

apiVersion: v1
kind: Pod
metadata:
  name: with-node-affinity
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: metadata.name
            operator: In
            values:
            - node-geektime

这里要求在每次调度的时候予以考虑如下的匹配条件,具体的条件是当其metadata.namenode-geektime的时候才需要运行POD。

污点标记

DaemonSet能在Node初始化完毕之前就启动,它所依赖的机制是一个spec.tolerations的声明,比如我们允许对应的节点是unschedulable的节点也可以允许调度;而默认情况下这样的节点上不会调度任何POD。

加上了这个标记,即使对应的Node不可用,Kubernetes也会尝试去调度对应的POD直到成功位为止。当然对应的节点如果发生故障的话,调度则会失败。

这样的机制很有用,是因为一些特殊的插件agent比如网络插件,必须要在节点还没有完全完成初始化的时候被先调度起来。利用这个技巧,我们甚至可以在集群的master节点上选择调度某个POD。

非长时间执行的服务

除了常见的微服务应用,Kubernetes也可以用来处理一些不是需要一直运行的任务,一般情况下人们称之为离线业务或者批处理业务。这些业务的执行特点是一次执行之后就退出了,如果我们仍然选择上述的控制器来管理, 就会引起循环的调用和推出,类似于滚动更新等高级功能就无法工作了。

早期Google自己的Borg系统就提出了两种业务形态,分别是

  • Long Running Service即长时间运行的服务
  • Batch Jobs即批处理作业;该形态自然也被加入到了Kubernetes中

Job

Job对应于专门的一次性执行的任务,它通过spec.template.spec.restartPolicy=Never来指定任务完成后不用重启。 Job控制器会在运行期的时候为实际的POD实例生成一个常常的UUID,保证对应的JOB和它的POD实现一一匹配。

当然上述的restartPolicy可以被设置为OnFailure或者Never,前者对应于失败的时候允许重新启动以便实现出错重试的逻辑。 同时需要谨慎使用该逻辑,因为如果程序有问题,我们不太会期望Job被重试太多次而浪费资源! 幸好Kubernetes自己已经给设置了一个最大的重试次数,没有指定的情况下会重试6次,我们可以通过指定一个新的值给backoffLimit来自定义重试次数。

离线作业还有一种情况是因为种种原因,POD容器里面定义的操作迟迟没有结束,为了避免占用太多的资源,Kubernetes允许我们设置spec.activateDeadlineSeconds来指定最长允许运行的时间;超过了之后,对应的Pod就会被终止。 因为超过时限而终止的POD的状态也可以在POD的状态里面看到。

并行作业和最小完成数

某些特殊的任务可以并行去完成,对应的控制参数是

  • spec.parallelism指定最多同时可以启动的POD的个数
  • spec.completions定义了Job完成时最少需要正常完毕的POD的个数

CronJob

CronJob用来描述需要定期执行的任务,它和Job的关系就如同Deployment和POD的关系一样,即CronJob是通过控制Job对象来完成定时任务的。 它所创建和删除Job的依据是API定义中传入的符合Unix Crontab格式的调度表达式。

持久化存储

前面部分探讨有状态服务部署的时候,以及简要涉猎了存储部分的概念,这一部分则深入探究这些概念背后的细节。 从概念上说,PV描述了实际运维人员负责创建并放置到Kubernetes集群里给各个POD使用的持久化存储数据卷实现,而PVC则相当于是存储需求的接口描述;这里的基本思想是面向对象式的接口和实现分离

但是一个棘手的问题是,有可能POD启动的时候,PVC的匹配没有成功,那么POD启动就会失败。如果此时运维人员及时发现了这一问题,并创建新的PV存储加入到平台中,怎么保证新创建的PV可以被正确的识别和匹配? 问题的答案在于系统内部的一个特殊的控制器:PersistentVolumeController。

PersistentVolumeController

该控制器会定期去查看每一个PVC,检查它是否已经处于绑定状态,如果不是则会查找所有的PV,找到一个匹配的并与PVC做绑定。 绑定的过程其实是通过填充spec.volumeName字段来完成的,从而只要Kubernetes能够找到这个PVC对象,它就可以将之分配给需要的POD来使用。

PV如何完成绑定

PV的所谓持久化,意味着挂载目录里面的内容不会因为容器的删除而被销毁清理,也不会跟某个具体的宿主机(节点)进行绑定,因为容器可能在下次重新调度的时候,在一个完全不同的宿主机上被创建出来。 因而持久化卷的实现往往需要依赖于一个远程通过网络访问的存储服务,比如NFS文件系统或者公有云服务提供商的远程磁盘等。

其准备阶段可以认为经历两个阶段的处理

  1. 将远程的存储服务挂载到宿主机上,这个阶段被成为Attach操作
  2. 第二个步骤类似于文件系统的格式化操作并挂载到具体的宿主机指定的挂载点上,以便POD能够访问

经过了这两个阶段,内部的kubelet只需要将对应的目录通过container接口的mount参数传递给docker容器就可以让POD里面的容器能够访问这个持久化的存储卷了。

反向的解绑和删除操作只需要理解为是以相反的顺序执行相反的操作即可,即先unmount然后再detach。

更具体地说,上述两个阶段也是由两个控制器来负责的

  1. AttachDetachController负责关于POD和宿主机的attach/detach情况的检查和操作,该控制器需要运行在master节点上,因为它自身是kube-controller-manager的一部分
  2. VolumeManagerReconciler作为Kubelet组件的一部分负责宿主机上的mount/unmount操作

Storage Class和PV创建

在大规模的生产线环境中,不同的微服务容器可能需要用到成千上万个PVC,从而意味着需要准备大量的PV。 在业务动态开发扩大的过程中,系统中的PVC可能会动态地发生变化,指望运维来人肉创建PV显然是难以为继的。 显然我们需要一种能够动态创建PV的机制,该机制的核心则依赖于名字为StorageClass的对象。

从对象的名字来看,它其实是一种PV创建方法的模板;它需要定义两个部分的信息

  1. PV本身的属性,如存储类型,卷的大小容量等信息
  2. 创建过程中所要用到的存储系统插件,如Ceph等。 有了这些信息,Kubernetes就可以依据用户声明中的PVC,找到对应的StorageClass,然后利用其中定义的插件来生成实际需要的PV。

PV/PVC机制是否是过度设计

探讨是否过度设计的问题,往往需要澄清清楚设计的目标然后才能判断是否有过度设计的嫌疑。 作为一个面向开源社区的项目,这样的分离主要期望的是能够支持形形色色的第三方存储系统的实现机制,并尽力对容器应用隐藏这些细节差异, 因此灵活性和可扩展性在这里成为被重点被关照的设计目标,从这个角度看就显然没有过度设计。

如果需要自己实现一个类似于本地磁盘访问一样的本地存储卷,甚至还要付出额外的努力来适配实现Kubernetes平台所要求的抽象分离设计要求;处于可用性的考虑我们不能将目录直接映射为PV,而是采用块设备的方式来对应PV。 由于这一本地块设备的挂载要早于POD调度,因此还要实现在使用本地磁盘卷之前先配置好磁盘或者块设备。

网络管理

Docker容器本身提供了基于Linux内核命名空间的网络栈隔离机制,而Kubernetes本身的网络管理功能则依托于容器本身提供的网络隔离能力之上。

Docker的网络栈

具体到网络栈隔离,我们通常需要考虑如下这些要素

  • 网卡设备
  • 本地回环设备即loopback
  • 路由表
  • iptable访问规则

Docker本身可以支持host模式直接使用宿主的网络服务,而不采用命名空间隔离技术。当然这样的缺憾是所有的网络处理其实都是由宿主机来完成的,没有办法完成精细化的网络管理和控制而且容易引起资源冲突; 这样做也由明显的性能上的优势,却不符合绝大部分虚拟化的环境中的网络资源隔离和管控需求。 Linux中我们可以通过网桥来实现虚拟网络交换的工作,其原理是依赖于工作在数据链路层的MAC地址学习而将不同的数据包发送到不同的网络端口上的。

veth虚拟网桥

具体到Docker的实现来说,它通过虚拟的veth设备来连接两个可以互联互通的容器;在创建好的使用网桥的容器中,我们可以看到的eth0其实就是网桥连通的一端。而连接的另一端则发生在宿主机上的名字为vethxxxx的虚拟网卡设备;brctl show可以看到虚拟网卡会被关联到具体的docker容器上;网桥设备则现实为docker0

这样任何的veth虚拟网卡都会变成是docker0网桥的从设备,所有的网络流量都会先发给docker0网桥,然后由网桥来决定将其转发到哪个容器对应的虚拟网卡上,即所有的这些从设备都变成网桥转发的一个端口

任何网络访问的请求都会处理大概如下的流程

  • 容器里面的对外访问都会被路由到docker0网桥上去-具体情况可以在容器内查看路由表
  • 根据IP地址判定目标地址是否是本地容器里的IP   - 如果是,则发送到docker0网桥,由网桥决定发送到哪个vethxxx设备,一旦发送完毕,数据包就会自然出现在对应容器的eth0网卡上   - 如果不是,则会根据宿主机的路由规则来决定下一跳的节点

跨主机通信

这样的机制保证了同一个节点上的容器之间的IP总是可以互相连通的,并且容器内的应用也可以访问宿主机上绑定的其它IP或者宿主机可以直接到达的目标地址。 然而如果需要跨越主机访问另外一个机器内的容器的IP的时候,Docker默认的桥接技术就无能为力了;需要借助容器编排平台的能力。

想要提供跨越主机的容器之间的互相访问,我们需要扩展上面的网桥的概念,创建一种虚拟的网桥将各个主机之间的容器都桥接起来,这种方法就像创建了一个覆盖在所有的容器网桥之上的一个虚拟网络,所以被成为Overlay Network。

Flannel

CoreOS公司推出了被成为Flannel的框架来解决跨容器主机通信的问题;其本身由几种不同的后端实现,分别是

  • VXLAN
  • host-gw
  • UDP是最早提出也最容易理解的方式,然而其性能也最差,现在依据几乎不用了

UDP

UDP的方式依赖于Flannel框架在宿主机上创建的工作在网络层的虚拟隧道设备flannel0,和重定向到这个隧道的路由表。当宿主机收到无法匹配到本机docker0的数据包的时候,它会根据flannel创建的路由重定向到这个名字为flannel0的隧道设备上。

同时在宿主机上还有一个守护进程flanneld负责监控收到的包,然后依据各个节点上的flanneld之间相互维护的子网关系,将其转发到对应的其它宿主机上的flanneld。 实现上不同的机器会处于不同的子网中,因此flanneld可以根据子网信息决定应该发送到哪个具体ide节点上。而子网和节点的对应关系则存储在etcd数据库中。

flanneld之间的通信是通过UDP协议来完成的,UDP的源地址是发送方的宿主机的地址,而目的地址则是接收方的宿主机的地址。这样利用宿主机之间的路由表就可以完成数据的收发。

性能问题

操作系统的运行概念上将程序的状态分为内核态和用户态,在这两个状态之间切换需要经历上下文切换,并且它们访问的内存空间是物理上隔离的;如果需要交换数据则必须要做内存的搬运。TUN隧道设备的特色是工作于用户空间,即使简单的考虑用户从容器内发送数据出去就需要经过

  • 容器到docker0的数据交换,从用户态到内核
  • docker0到flanneld的交换,从内核态到用户态
  • flanned到eth0的数据交换,又从用户态到内核态

算上接收方的方向处理,一次数据收发需要6次的数据切换,性能开销很大,而用户的角度来看可能只期望两次。 所以UDP方式的性能很差,很快人们就不再使用了。

VXLAN

Linux本身支持Virtual Extensible LAN的虚拟可扩展局域网技术;使用这种技术来完成封装和解封工作就可以有效地规避UDP隧道带来的不必要的数据切换拷贝开销。

VXLAN的实现方式要求工作于数据链路层,并且会在宿主机上设置一个特殊的设备作为隧道通信的两端,即VTEP(VXLAN Tunning End Point),它需要在数据链路层完成和上面的IP隧道类似的流程;主要的区别是所有的类似操作都在内核态完成

VETP设备的表现形态是flannel.1设备,它工作在内核态并且作用在数据链路层上。 flanneld则负责维护对应隧道的出口的VETH设备信息。 因为二层设备需要依赖于MAC地址,因此flanneld在宿主机上会依据节点的信息,通过ARP协议查询获取对应的MAC地址,然后将其对应关系保存下来。这样flannel.1就可以根据该信息查询到IP对应的MAC信息了。

Linux内核会把目的设备的MAC地址和目标容器的IP地址信息写入VXLAN的内部数据帧结构中,这部分信息会放在封装的外部数据帧中。同时此外部数据帧还包含VXLAN对应的头,里面包含由类似VNI的信息,然后将这些数据帧封装为一个UDP包发送出去。

同时为了使得该UDP包可以正确地发送出去,flanneld还负责维护了一个转发数据库FDB,里面放置了MAC地址和IP地址、设备名称(flannel.1)的对应信息,从而Linux内核可以依据该转发信息将UDP包发送到正确的主机上。

Kubernetes的CNI插件

Kuberenetes管理网络插件的方法是通过名字为CNI的接口来实现的。在CNI的术语里面,用于替代上述docker0的网桥名字被成为是cni0。 而Kubernetes里面的网络管理方式和上面的VXLAN模式几乎没有任何不同,只是docker0被换成了cni0而已。

默认情况下,Kubernetes为Flannel分配的子网范围是10.244.0.0/16,部署参数--pod-network-cidr=xxx.xxx.xxx.xxx/yy可以指定新的子网。

为什么要用不同的名字

如果是用docker命令行创建的容器,它的容器内的网桥还是会连接在docker0上,而使用POD的方式创建的容器则会具有cni0的接口,这样的不同是因为

  • Kubernetes没有也不希望直接绑定于Docker的网络管理模型,因而它不希望管理和控制cni0
  • Kubernetes自身的POD里面的infra容器有特殊的设计;当POD被创建之后,Infra容器总是被先创建,然后Kubernetes再调用CNI网络插件设置符合预期的网络空间

host-gw模式

上述的第三种模式的工作模式如下

  • 假设需要通信的节点之间在二层链路层是互通
  • 将每个flannel的子网的下一跳直接设置为该子网对应的宿主机的IP地址

从而达到该宿主机本身成了容器通信路径里面的网管的作用,并且这些子网和对应主机的关联信息依然保存再etcd中; flanneld则需要监控数据的变化,然后实时更新路由表。

实现host-gw模式的插件除了flannel之外还有利用BGP网关协议来实现互通性的Calico项目。 Calico的特色是不依赖上述的虚拟网桥设备的概念,而利用强大的边界网管协议,直接实现节点与节点之间的全息路由交换。

Service对象

Service对象其实就对应于常说的微服务应用,之所以我们需要这个概念是因为

  • POD是一个部署、调度期的概念,它的IP地址可能是不固定的,而服务使用方却希望有一个确定的访问点
  • 需要考虑扩展性,多个POD还可能被负载均衡器所控制

Kuberenetes通过使用Service对象来选择某些符合条件的POD运行期实例,并提供稳定的访问点(virtual IP)来提供外部应用程序对所抽象的应用服务的访问。 显然Service要能正常工作,Kuberenetes灵活的网络层功不可没。

查看Service的服务端点

具体哪些POD可以对外提供所声明的服务,是由Kubernetes运行时平台决定的,它需要保证对应的POD满足

  • 处于运行状态
  • 可用性检查探针检查通过

而当POD运行出现问题的时候,Kubernetes会立刻将其从Service的实例里面删除。

网络层如何工作

Service其实是依赖于iptables规则来实现Virtual IP的重定向的,即这个所谓的ClusterIP可以不真正存在(即使ping不同它代理的Service也照样可以正常工作)。

同时Kubernetes提供了默认的基于轮询的负载均衡实现,而每一个提供服务的POD都会得到一条对应的路由转发规则。为了保证多个POD实例得到服务的机会均等,对应的iptables规则里面会加上对应的probability设置来影响路由规则的命中率。

iptables数量的限制

由于Service的重定向需要依赖于大量的iptables规则的实时刷新和查询,当宿主机上由大量的POD需要被调度的时候, 宿主机的资源会被大量消耗在iptables的处理中,因而基于iptables规则的Service机制成为制约POD数量的一个主要因素之一。

IPVS模式通过在创建Service的时候在宿主机上创建虚拟网卡并分配service地址的方式来解决这个问题。 然后kubeproxy组件就会通过Linux提供的IPVS模块为IP地址设置成多个虚拟主机,然后主机之间通过轮询的方式作负载均衡。 通过将高负载的处理请求放入操作系统内核态,IPVS有效地规避了大量动态iptables规则的刷新带来的性能问题。

Service和DNS

Kubernetes会为Service和POD分配DNS名字;而Service从服务模式上可以分为

  • ClusterIP模式,对应的访问方式是基于Virtual IP
  • Headless模式,此时ClusterIP设置为None,DNS解析的时候会直接指向一个稳定的POD对应的IP 实际使用的过程中,需要根据应用服务的特征做合理的选择。

Kubernetes的调度和资源管理

如前面所述,POD是Kuberenetes提供的最基本的资源调度单元,因此所有和资源控制管理有关的属性都可以并且应该属于POD的字段;其中我们最熟悉也最重要的就是CPU和内存资源。

资源压缩

因为CPU资源不足的时候,POD只会因为饥饿而执行缓慢却不会直接退出,因此CPU资源属于可压缩的资源。 而内存则不同,当程序内存不足的时候,POD就会由于内存不足而直接被内核杀掉,所以内存属于不可压缩的资源。

POD里面可以由多个不同的容器组成,而资源消耗的最小单元是容器而不是POD,所以一个POD所占用的资源等价于所有容器的资源的总和

CPU资源

Kubernetes用逻辑抽象的方式来定义CPU,其单位是CPU的个数,数字1表示限制为1个CPU,至于这个具体的1的含义是如何解释,完全取决于宿主机的实现,因为CPU资源属于可压缩的资源。 同时我们可以将CPU资源限制设置为分数,Kubernetes的惯用法是用类似于500m来表示0.5个CPU。

内存资源

Kubernetes的内存资源因为其不可压缩性,只能用类似于绝对数量的单位来表示。比如1Ki表示1KB,而1Mi表示1MB, 基本上常见的表示内存容量的大写字母后面再加上小写字母i就表示对应的容量。

资源请求和限制

从设计上来说,Kubernetes允许用户设置资源的上限和请求值,一般上限(limit)需要大于请求(request)值。 这样当调度的时候,调度器会按照请求值进行计算,而传递给内核的cgroups的时候,实际设置的参数是上限。

这样的实际来源于Borg的动态资源边界定义

  • 作业提交时设置的资源边界不一定是调度器需要严格遵守的
  • 实际情况下,大多数作业实际使用的资源比它请求的资源限制要小

POD调度类型

考虑到POD可能由多个容器组成,容器里面可能会设置请求值和上限值,Kubernetes会给POD设定一个调度类型

  • 所有的容器都设置了请求和上限值,并且请求和上限值总是相等,POD就是guaranteed
  • 不满足上述条件,但是至少有一个容器设置了请求值,那么POD就是Burstable
  • 既没有设置请求值也没有设置上限值,POD是BestEffort

当宿主机上的资源不足的时候,Kubernetes会根据POD的类型做不同的调度回收策略。默认情况下,这个触发阈值为

memory.available<100Mi
nodefs.available<10%
nodefs.inodesFree<5%
imagefs.available<15%

当不得不回收POD资源的时候,根据POD的类型会先回收BestEffort类型的,然后查找Burstable中那些使用量超出请求量的POD优先进行回收,最后才是回收Guaranteed中的使用量超出限制量的POD。 因此对于重要的应用POD,我们最好设置它的requestlimit是相等的。

cpuset来提升性能

使用容器的时候,我们可以将某个容器绑定到特定的CPU核上来减少上下文切换次数,提高应用程序的性能。 如果要使用该特性,需要保证POD是Guaranteed类型的,然后设置limitsrequests里面的cpu属性是相同的整数值,而具体的整数值的含义是由Kubernetes调度器来负责分配的。

GPU和Device Plugin

除了常见的CPU和内存资源外,Kubernetes平台也提供了灵活的插件化机制来分配和管理特殊的资源,诸如GPU、FPGA等非常规资源。

GPU

对用户的诉求来说,支持GPU就相当于一旦再POD里面添加了需要的GPU个数信息,Kubernetes为用户创建的容器里面就自动出现对应的GPU设备和目录。 比如对于Nvidia的GPU来说,容器里需要由

  1. GPU设备,比如/dev/nvdia0/
  2. GPU驱动程序,如/usr/local/nvidia/*

Kubernetes里面并没有出现一个字段叫gpu,而是使用了一个抽象的叫做extended resource的字段来表述这类信息

containers:
- name: xxxx
  image: "xxxx.xxx/imagex..."
  resource:
    limites:
      nvidia.com/gpu:1

而提供GPU资源的宿主机上,我们需要利用Node对象的Status字段,使用Patch的方式对它进行更新,加上自定义的资源数目信息。 幸好这这似乎一个概念上的步骤,实际的方案中,会有Device插件来负责完成维护。

Device Plugin的工作原理

对任意一种Device,都需要有一个关联的插件来负责维护和监控可用资源的变化。该插件会通过gRPC协议和kubelet组件连接起来,通过ListAndWatchAPI来定期汇报Node上的设备的列表

kubelet组件在得到这个列表之后,就会直接在它发给API Server的心跳消息里面,用Extended Resource的方式来加上这些设备的数量。 这种处理机制是和具体设备特性无关的,因此不仅限于GPU,还适用于其它类型的设备。目前社区里已经有的插件还包括

  • FPGA
  • SRIOV
  • RDMA等

总结

作为资深的Kubernetes开发人员,张磊提供了一个百科全书式的深入浅出的介绍,既有深度探索实现机制的例子又有各种可选方案的理论特色的介绍。 课程的内容无论是广度还是深度上都远远超出了专栏本身的定价; 显然这不是一门浅尝辄止就可以掌握的课程,值得多次回顾以便更加深入地理解这一改变技术界的云平台管理工具。

Leave a Comment

Your email address will not be published. Required fields are marked *

Loading...