Kun

Kun

IT学徒、技术民工、斜杠青年,机器人爱好者、摄影爱好 PS、PR、LR、达芬奇潜在学习者


共 212 篇文章


​ k8s源码

Kubernetes Project Layout设计

Kubernetes项目由Go语言编写。Go语言官方对项目的结构设计没有强制要求,早期的Go语言开发者都喜欢将包文件代码放置在项目的src/目录下,如nsqio开源项目,开发者喜欢将入口文件放入apps/目录。不同开发者的喜好不同,这导致开源项目的结构设计没有统一标准。

后来Go语言社区提出Standard Go Project Layout方案,以对Go语言项目目录结构进行划分。目前该标准已经成为众多Go语言开源项目的选择。

根据Standard Go Project Layout方案,我们对标一下Kubernetes的Project Layout设计

由于Kubernetes项目全球开发者众多,这导致早期的代码包较多,尤其是kube-apiserver项目,其内部所引用的代码包特别多。随着Kubernetes系统版本的迭代,逐渐将部分包进行了合并,其中staging/目录为核心包暂存目录,该目录下的核心包多以软连接的方式链接到vendor/k8s.io目录。

Kubernetes系统组件较多,各组件的代码入口main结构设计风格高度一致

从代码入口main结构来看,各组件的目录结构、文件命名都保持高度一致。假设需要新增一个组件,我们甚至可以复制原有的组件代码,只需简单修改一下就可以将其运行起来。每个组件的初始化过程也非常类似

main结构中定义了进程运行的周期,包括从进程启动、运行到退出的过程。以kube-apiserver组件为例

(1)rand.Seed:组件中的全局随机数生成对象。

(2)app.NewCommand:实例化命令行参数。通过flags对命令行参数进行解析并存储至Options对象中。

(3)logs.InitLogs:实例化日志对象,用于日志管理。

(4)command.Execute:组件进程运行的逻辑。运行前通过Complete函数填充默认参数,通过Validate函数验证所有参数,最后通过Run函数持久运行。只有当进程收到退出信号时,进程才会退出。

Kubernetes其他组件的cmd设计与之类似,故不再重复描述,后续章节会针对每个组件详细描述其启动过程。

Kubenetes构建过程

构建过程是指“编译器”读取Go语言代码文件,经过大量的处理流程,最终产生一个二进制文件的过程;也就是将人类可读的代码转化成计算机可执行的二进制代码的过程。

手动构建Kubernetes二进制文件是一件非常麻烦的事情,尤其是对于较为复杂的Kubernetes大型程序来说。Kubernetes官方专门提供了一套编译工具,使构建过程变得更容易。

Kubernetes构建方式可以分为3种,分别是本地环境构建、容器环境构建、Bazel环境构建

首先,将Kubernetes源码通过Go语言工具下载下来,并切换至Kubernetes 1.14代码版本,命令示例如下

$ go get -d k8s.io/kubernetes
$ cd $GOPATH/src/k8s.io/kubernetes
$ git checkout -b release-1.14 remotes/origin/release-1.14

注意:构建Kubernetes 1.14版本,需要使用Go 1.12或更高版本。不同的Kubernetes版本对应的Go语言版本也不同。

从cloc代码统计命令的输出可以看到,Kubernetes 1.14拥有大约357万行代码,其中Go语言代码占303万行,这是非常庞大的代码量。当然,其中也包含通过代码生成器生成的Go语言代码文件

本地环境构建

执行make或make all命令,会编译Kubernetes的所有组件,组件二进制文件输出的相对路径是_output/bin/。如果我们需要对Makefile的执行过程进行调试,可以在make命令后面加-n参数,输出但不执行所有执行命令,这样可以展示更详细的构建过程。假设我们想单独构建某一个组件,如kubectl组件,则需要指定WHAT参数,命令示例如下

$ make WHAT=cmd/kubectl

一切都始于Makefile

Go语言开发者习惯于手动执行go build(构建)和go test(单元测试)命令,因为Go语言为开发者提供了便捷的工具。但在一些生产环境或复杂的大型项目中,这是一种不好的开发习惯,而在实际的Go语言开发项目中使用Makefile是好的约束规范。

Makefile是一个非常有用的自动化工具,可以用来构建和测试Go语言应用程序。Makefile还适用于大多数编程语言,如C++等。在Kubernetes的源码根目录中,有两个与Makefile相关的文件,分别介绍如下。

● Makefile:顶层Makefile文件,描述了整个项目所有代码文件的编译顺序、编译规则及编译后的二进制输出等。

● Makefile.generated_files:描述了代码生成的逻辑。

通过make help命令,可以展示出所有可用的构建选项,从构建到测试的选项都有。首先,看一下make all命令在Makefile中的定义

若要在Kubernetes的Makefile文件中定义,其步骤为:

第1步,执行generated_files命令(在Makefile中称其为目标),用于代码生成(Code Generation);

第2步,通过调用hack/make-rules/build.sh脚本开始执行构建操作,其中的$(WHAT)参数表示要指定构建的Kubernetes组件名称,不指定该参数则默认构建Kubernetes的所有组件。

本地构建过程

通过调用hack/make-rules/build.sh脚本开始构建组件,传入要构建的组件名称,不指定组件名称则构建所有组件。hack/make-rules/build.sh代码示例如下:

kube::golang::build_binaries "$@"

build_binaries接收构建的组件名称,设置构建所需的环境及一些编译时所需的Go flags选项,然后通过go install构建组件

go install "${build_args[@]}" "$@"

在go install命令执行完成后,二进制输出的目录为_output/bin/。通过make all命令构建所有组件,二进制输出如下(只展示了核心组件)

最后,可以使用make clean命令来清理构建环境。

容器环境构建

通过容器(Docker)进行Kubernetes构建也非常简单,Kubernetes提供了两种容器环境下的构建方式:make release和make quick-release,它们之间的区别如下

● make release:构建所有的目标平台(Darwin、Linux、Windows),构建过程会比较久,并同时执行单元测试过程。

● make quick-release:快速构建,只构建当前平台,并略过单元测试过程。

make quick-release与make release相比多了两个变量,即KUBERELEASERUNTESTS和KUBEFASTBUILD。KUBERELEASERUNTESTS变量,将其设为n则跳过运行单元测试;KUBEFASTBUILD变量,将其设为true则跳过跨平台交叉编译。通过这两个变量可以实现快速构建,最终执行build/release.sh脚本,运行容器环境构建。

在容器环境构建过程中,有多个容器镜像参与其中,分别介绍如下。

● build容器(kube-cross):即构建容器,在该容器中会对代码文件执行构建操作,完成后其会被删除。

● data容器:即存储容器,用于存放构建过程中所需的所有文件。

● rsync容器:即同步容器,用于在容器和主机之间传输数据,完成后其会被删除。

Kubernetes容器环境构建过程

kube::build::verify_prereqs:进行构建环境的配置及验证。该过程会检查本机是否安装了Docker容器环境,而对于Darwin平台,该过程会检查本机是否安装了docker-machine环境。

kube::build::build_image:根据Dockerfile文件构建容器镜像。Dockerfile文件来源于build/build-image/Dockerfile,

构建容器镜像的流程如下

● 通过mkdir命令创建构建镜像的文件夹(即_output/images/…)。

● 通过cp命令复制构建镜像所需的相关文件,如Dockerfile文件和rsyncd同步脚本等。

● 通过kube::build::docker_build函数,构建容器镜像。

● 通过kube::build::ensuredatacontainer函数,运行存储容器并挂载Volume。

● 通过kube::build::synctocontainer函数,运行同步容器并挂载存储容器的Volume,然后通过rsync命令同步Kubernetes源码到存储容器的Volume。

kube::build::runbuildcommand make cross:此时,容器构建环境已经准备好,下面开始运行构建容器并在构建容器内部执行构建Kubernetes源码的操作

kube::build::copy_output:使用同步容器,将编译后的代码文件复制到主机上。

kube::release::packagetarballs:进行打包,将二进制文件打包到output目录中。

最终,代码文件以tar.gz压缩包的形式输出至_output/release-tars文件夹。

Bazel环境构建

Bazel是Google公司开源的一个自动化软件构建和测试工具。Bazel使用分布式缓存和增量构建方法,使构建更加快速。其支持构建任务,包括运行编译器和链接器以生成可执行程序和库。Bazel与Make、Gradle及Maven等构建工具类似,但Bazel在构建速度、可扩展性、灵活性及跨语言和对不同平台的支持上更加出色。Bazel具有如下特性。

● 支持多语言:Bazel支持Java、Objective-C和C++等主流语言,并可以扩展支持任意的其他编程语言。

● 高级别的构建语言:项目以BUILD语言进行描述。BUILD是一种简洁的文本格式,可描述多个小而互相关联的库、二进制程序和测试程序组成的项目。

● 支持多平台:相同的工具和BUILD文件可以为不同架构或平台构建软件。

● 再现性:在BUILD文件中,必须明确为每个库、测试程序、二进制文件指定其直接依赖。在修改源码文件后,Bazel使用这个依赖信息就可以知道哪些东西必须重新构建,哪些任务可以并行执行。这意味着所有的构建都是以增量的形式构建的并能够每次都生成相同的结果。

● 可扩展性强:Bazel可以处理大型程序的构建;在Google公司内,一个二进制程序通常有超过100KB的源码文件,在代码文件没有被改动的情况下,构建过程大约需要200ms。

● 构建速度快:支持增量编译。对依赖关系进行了优化,从而支持并发执行。

但是如此优秀的Bazel为什么在开源软件中流行不起来,只能在Google内部大量使用呢?一方面是因为接入Bazel较为复杂;另一方面在于Google内部的代码库非常庞大(约有数百万行),Bazel支持多语言的构建系统为所有项目构建代码,将源码统一存放,使用统一的持续集成来运行所有的单元测试,因此在构建过程中性能问题是最关键的问题,而大部分公司很少遇到像Google这样进行大规模编译的性能问题。

比较有趣的是,在Go语言社区内有时也会争论Go语言项目是否应该使用go build/install或bazel build。目前Kubernetes已经支持使用Bazel进行构建和测试了,但尚未将Bazel作为默认的构建工具。

bazel常用make操作

● make bazel-build:构建所有二进制文件。

● make bazel-test:运行所有单元测试。

● make bazel-test-integration:运行所有集成测试。

● make bazel-release:在容器中进行构建。

单独构建kubectl

除根据Makefile中定义的make bazel操作外,我们也可以直接使用bazel命令,来对单独组件进行构建,

上述代码中的//cmd/kubectl/…在Bazel中被称为标记,用于指定需要构建的包名。若执行构建命令后输出如上信息,则表示构建成功,Bazel将构建后的二进制文件输出到根目录下的bazel-bin目录中。kubectl二进制文件的相对路径为bazel-bin/cmd/kubectl/。注意:Bazel目前不支持CGO的交叉编译。

注意:Bazel目前不支持CGO的交叉编译。

更新BUILD文件

每当开发者对Kubernetes代码进行更新迭代、添加或删除Go语言文件代码,以及更改Go import时,都必须更新各个包下的BUILD和BUILD.bazel文件,更新操作可通过运行hack/update-bazel.sh脚本自动完成

$ ./hack/update-bazel.sh

Bazel工作原理

Kubernetes源码的根目录下有一个WORKSPACE(工作区)文件,用于指定当前目录是Bazel的一个工作区域,该文件一般存放在项目根目录下。另外,项目中包含一个或多个BUILD文件,用于告诉Bazel如何进行构建。

Bazel工作原理大致分为3部分。

(1)加载与Target相关的BUILD文件。

(2)分析BUILD文件的内容,生成Action Graph。

(3)执行Action Graph,最后产出Outputs。

ABAC资源的BUILD文件内容如下

● load:需要使用哪个.bzl规则来编译当前Target。

● go_library:设置构建规则。

■ name:当前Target构建后的名称。

■ src:当前Target下被构建的源码文件。

■ deps:当前Target构建时依赖的静态库名称。

在Kubernetes项目代码中,BUILD文件可通过执行hack/update-bazel.sh脚本来自动生成。Bazel第一次构建时须生成Bazel Cache,时间较长,再次构建时无须再生成Bazel Cache,Bazel Cache有利于大大提高构建速度。注意:Bazel目前并非完全支持Kubernetes代码生成器,当前只有openapi-gen和go-bindata是支持的。

Scheduler

Kubernetes scheduler独立运作与其他主要组件之外(例如API Server),它连接API Server,watch观察,如果有PodSpec.NodeName为空的Pod出现,则开始工作,通过一定得筛选算法,筛选出合适的Node之后,向API Server发起一个绑定指示,申请将Pod与筛选出的Node进行绑定

scheduler的设计分为3个主要代码层级:

  • cmd/kube-scheduler/scheduler.go: 这里的main()函数即是scheduler的入口,它会读取指定的命令行参数,初始化调度器框架,开始工作
  • pkg/scheduler/scheduler.go: 调度器框架的整体代码,框架本身所有的运行、调度逻辑全部在这里
  • pkg/scheduler/core/generic_scheduler.go: 上面是框架本身的所有调度逻辑,包括算法,而这一层,是调度器实际工作时使用的算法,默认情况下,并不是所有列举出的算法都在被实际使用,参考位于文件中的Schedule()函数

调度器通过 kubernetes 的 watch 机制来发现集群中新创建且尚未被调度到 Node 上的 Pod。调度器会将发现的每一个未调度的 Pod 调度到一个合适的 Node 上来运行。

对每一个新创建的 Pod 或者是未被调度的 Pod,kube-scheduler 会选择一个最优的 Node 去运行这个 Pod。然而,Pod 内的每一个容器对资源都有不同的需求,而且 Pod 本身也有不同的资源需求。因此,Pod 在被调度到 Node 上之前,根据这些特定的资源调度需求,需要对集群中的 Node 进行一次过滤。 在一个集群中,满足一个 Pod 调度请求的所有 Node 称之为可调度节点。如果没有任何一个 Node 能满足 Pod 的资源请求,那么这个 Pod 将一直停留在未调度状态(Pending)直到调度器能够找到合适的 Node。 调度器先在集群中找到一个 Pod 的所有可调度节点,然后根据一系列函数对这些可调度节点打分,然后选出其中得分最高的 Node 来运行 Pod。之后,调度器将这个调度决定通知给 kube-apiserver,这个过程叫做绑定。 在做调度决定时需要考虑的因素包括:单独和整体的资源请求、硬件/软件/策略限制、亲和以及反亲和要求、数据局域性、负载间的干扰等等。

kube-scheduler 给一个 pod 做调度选择包含两个步骤:

1.过滤(断言) 2.打分(优先级)

过滤阶段会将所有满足 Pod 调度需求的 Node 选出来。例如,PodFitsResources 过滤函数会检查候选 Node 的可用资源能否满足 Pod 的资源请求。在过滤之后,得出一个 Node 列表,里面包含了所有可调度节点;通常情况下,这个 Node 列表包含不止一个 Node,如果这个列表是空的,代表这个 Pod 不可调度。 在打分阶段,调度器会为 Pod 从所有可调度节点中选取一个最合适的 Node。根据当前启用的打分规则,调度器会给每一个可调度节点进行打分。 最后,kube-scheduler 会将 Pod 调度到得分最高的 Node 上。如果存在多个得分最高的 Node,kube-scheduler 会从中随机选取一个。 实现过滤和打分的手段主要有固定节点调度和节点标签调度、亲和和反亲和、污点和容忍度。如果容器对资源进行了请求(例如CPU、内存),那么节点当前可用内存和CPU也是调度需要考虑的。

1.固定节点调度NodeName nodeName 是节点选择约束的最简单方法,但是由于其自身限制,通常不使用它。nodeName 是 PodSpec 的一个字段。如果它不为空,调度器将忽略 pod,并且运行在它指定节点上的 kubelet 进程尝试运行该 pod。因此,如果 nodeName 在 PodSpec 中指定了,则它的优先级最高。 使用 nodeName 的方式选择节点存在一些限制: 如果指定的节点不存在,pod将会一直处于Pending状态。 如果指定的节点没有资源来容纳 pod,pod 将会调度失败并且其原因将显示为,比如 OutOfmemory 或 OutOfcpu。 云环境中的节点名称并非总是可预测或稳定的。

2.节点标签调度NodeSelector nodeSelector 是 PodSpec 的一个字段。 它包含键值对的映射。为了使 pod 可以在某个节点上运行,该节点的标签中必须包含这里的每个键值对。最常见的用法的是一对键值对。 我们现在k8s-node2节点添加一个标签,如果需要覆盖已有标签可以添加--overwrite参数

3.pod亲和性与反亲和性

节点亲和性nodeAffinity,nodeAffinity允许我们指定一些Pod在Node间调度的约束 nodeAffinity 支持两种形式: requiredDuringSchedulingIgnoredDuringExecution #硬限制, 同nodeSelector preferredDuringSchedulingIgnoredDuringExecution #软限制)

可以认为前一种是必须满足,如果不满足则不进行调度,后一种是倾向满足,不满足的情况下会调度到不符合条件的Node节点上 IgnoreDuringExecution表示如果在Pod运行期间Node的标签发生变化,导致亲和性策略不能满足,则继续运行当前的Pod

标签判断的操作符除了使用In之外,还可以使用NotIn、Exists、DoesNotExist、Gt、Lt 操作符, 也可以使用 NotIn 、 DoesNotExist 来实现反亲和性,也可以通过node taints来实现,上述例子pod调度节点必须满足nodetype是testworker或者testnode,同时下面软性条件是disktype必须是ssd,所以最后调度到了k8s-node1这个节点上了。 1)如果同时指定 nodeSelector 和 nodeAffinity ,两者同时满足才会被调度。 2)如果指定多个nodeSelectorTerms,则只要满足其中一个条件,就会被调度到相应的节点上。 3)如果指定多个matchExpressions,则所有的条件都必须满足,才会调度到对应的节点。必须是ssd,所以最后调度到了k8s-node1这个节点上了。

pod亲和性podAffinity、反亲和性podAntiAffinity,pod亲和性主要是通过在已经运行的pod上的标签来定制调度策略,注意这里说的是pod标签不是node标签 因为Node没有命名空间,Pod有命名空间,这样就允许管理员在配置的时候指定这个亲和性策略适用于哪个命名空间,可以通过topologyKey来指定,同时topologyKey不能为空,一般将其限制为kubernetes.io/hostname 同nodeAffinity,pod亲和性和反亲和性也有两种类型: requiredDuringSchedulingIgnoredDuringExecution,硬性要求,必须精确匹配 preferredDuringSchedulingIgnoredDuringExecution,软性要求 pod亲和性和反亲和性需要大量的计算,会显著降低集群的调度速度,不建议在大于几百个节点的集群中使用。 pod反亲和性要求集群中的所有节点必须具有 topologyKey匹配的标签,否则可能会导致意外情况发生

cobra

github主页: https://github.com/spf13/cobra 主页的介绍是: Cobra是一个强大的用于创建现代化CLI命令行程序的库,用于生成应用程序和命令文件。众多高知名度的项目采用了它,例如我们熟悉的kubernetes和docker cobra创建的程序CLI遵循的模式是: APPNAME COMMAND ARG --FLAG,与常见的其他命令行程序一样,例如git: git clone URL --bare

安装

#最简单的安装方式,但毫无意外,事情并没有那么简单,我们的网络的问题,导致无法正常安装依赖,
go get -u github.com/spf13/cobra/cobra

#怎么办呢?先进入GOPATH中,手动安装报错缺失的两个依赖:
cd /Users/ywq/go/
mkdir -p src/golang.org/x
cd golang.org/x
git clone https://github.com/golang/text.git
git clone https://github.com/golang/sys.git

#然后执行:
go install github.com/spf13/cobra/cobra
matebook-x-pro:x ywq$ ls /Users/ywq/go/bin/cobra
/Users/ywq/go/bin/cobra
#安装完毕,记得把GOBIN加入PATH环境变量哦,否则无法直接运行cobra命令

Pod优先级调度

在kubernetes v1.8版本之后可以指定pod优先级(v1alpha1),若资源不足导致高优先级pod匹配失败,高优先级pod会转而将部分低优先级pod驱逐,以抢占低优先级pod的资源尽力保障自身能够调度成功,

pod优先级可以在调度的时候为高优先级的pod提供资源空间保障,若出现资源紧张的情况,则在其他约束规则允许的情况下,高优先级pod会抢占低优先级pod的资源。此功能在1.11版本以后默认开启,默认情况下pod的优先级是0,优先级值high is better

Controller

Controller通过watch apiServer,循环地观察监控着某些特定的资源对象,获取它们当前的状态,对它们进行对比、修正、收敛,来使这些对象的状态不断靠近、直至达成在它们的声明语义中所期望的目标状态,这即是controller的作用。再通俗点来说,就是使资源对象的status当前状态达到spec的期望状态。

controller会对不同的资源,分别初始化相应的controller,包含我们常见的deployment、statefulset、endpoint、pvc等等资源,controller种类有多达30余个。因此,在controller整个章节中,不会对它们逐一分析,只会抽取几个常见有代表性地进行深入

Deployment Controller

deployment controller更多的是对每个相应版本的replicaset副本数进行管理,而不涉及直接对pod的管理

Deployment的回滚、扩(缩)容、暂停、更新等操作,主要是通过修改rs来完成的。其中,rs的版本控制、replicas数量控制是其最核心也是难以理解的地方,但是只要记住99%的时间里deployment对应的活跃的rs只有一个,只有更新时才会出现2个rs,极少数情况下(短时间重复更新)才会出现2个以上的rs

滚动更新

Deployment更新策略分为滚动更新和一次性更新,更新方式其实都是类似,只是一个是分批式,一个是全量式,这里看下滚动更新的代码。

滚动更新过程中主要是通过调用reconcileNewReplicaSet函数对 newRS 扩容,调用 reconcileOldReplicaSets函数 对 oldRSs缩容,按照 maxSurgemaxUnavailable 的约束,计时器间隔1s反复执行、收敛、修正,最终达到期望状态,完成更新。

ReplicaSet Controller

在出现新版本的rs后,rsc按照以下步骤进行工作:

1.通过SatisfiedExpectations函数,发现expectations期望状态本地缓存中不存在此rs key,因此返回true,需要sync

2.通过manageReplicas管理pod,新增或删除

3.判断pod副本数是多了还是少了,多则要删,少则要增

4.增删之前创建expectations对象并设置add / del值

5.slowStartBatch新增 / 并发删除 pod

6.更新expection

expections缓存机制,在运行的pod副本数在向声明指定的副本数收敛之时,很好地避免了频繁的informer数据查询,以及可能随之而来的数据更新不及时的问题,这个机制设计巧妙贯穿整个rsc工作过程,也是不太易于理解之处。

StatefulSet Controller

sts的基本运行特性

创建

sts是有序的,pod副本有序串行地新建,pod名称为{stsname}-{0..N},从小序号的pod(名称为{stsname}-0)创建,一直到第n个副本的pod(名称为{sts_name}-n)

更新

sts的更新策略有2种:

  • RollingUpdateStatefulSetStrategyType,默认的滚动更新策略,此策略下,更新时pod根据序号反顺序更新,从最大序号的pod开始删除重建,更新至序号最小的pod。更新过程中,始终保持pod数量等于指定副本数,即每删除一个pod,才会再创建一个。同时可以指定一个partition参数,指定这个参数后,只有序号大于等于partition的pod才会被更新,序号小于partition参数的pod不会被更新,例如有5个副本,partition设置为2,那么在更新sts时,0和1号pod不会更新,2 3 4号pod则会更新重建;此时继续将partition缩减为0,则0 1号pod也会更新重建。默认partition为0,即所有的pod都会更新。这个参数一般不会使用,但可用在发布时动态更新递减partition的值,来实现滚动灰度发布。
  • OnDeleteStatefulSetStrategyType, 此策略下controller不会对pod做任何操作,由手动删除pod来触发新pod的创建

删除

删除sts时,可以指定级联模式的参数--cascade=true,默认为true,意思是删除sts会同时删除它所管理的pod。设置为false时,删除sts不会影响pod的运行,且sts重建后依然能与此前的pod关联起来(这种方式可能会产生孤儿pod)。

updateStatefulSet函数总结

  1. 每个循环的周期中,最多操作一个pod
  2. 根据sts.spec.replicas对比现有pod的序号,对pod进行划分,一部分划为合法(保留/重建),一部分划为非法(删除)
  3. 对pods进行划分,一部分划入current(old) set阵营,另一部分划入update(new) set阵营
  4. 更新过程中,无论是删减、还是新建,都保持pod数量固定,有序地递增、递减
  5. 最终保证所有的pod都归属于update revision
如果你觉得我的文章对你有帮助的话,希望可以推荐和交流一下。欢迎關注和 Star 本博客或者关注我的 Github