在前面的文章中,我们为了使用 API 对象的能力,都需要编写一个对应的 YAML 文件交给 Kubernetes。这个 YAML 文件,正是 Kubernetes 声明式 API 所必须具备的一个要素。不过,只要用 YAML 文件代替命令行操作,就是声明式 API 了吗?
举个例子,Docker Swarm 的编排操作都是基于命令行的,例如:
1 | $ docker service create --name nginx --replicas 2 nginx |
其中,第一条 create 命令创建了两个容器,第二条 update 命令则把它们“滚动更新”成了一个新的镜像。
对于这种方式,就称为命令式命令行操作。
而在 Kubernetes 中,我们需要编写一个 Deployment 的 YAML 文件,并采用 kubectl create 命令就可以创建这两个 Nginx 的 Pod:
1 | apiVersion: apps/v1 |
而如果要更新这两个 Pod 所使用的 Nginx 镜像,则可以使用前面用过的 kubectl set image 或者 kubectl edit 命令,来直接修改 Kubernetes 中的 API 对象。但是,我们能不能通过修改本地的 YAML 文件来完成这个操作呢?这样我的改动就可以体现在这个本地的 YAML 文件里了。
当然可以。
比如,我们可以修改这个 YAML 文件里的 Pod 模版部分,把 Nginx 容器的镜像版本进行修改,如下所示:
1 | ... |
而接下来,就可以执行一句 kubectl replace 操作,来完成这个 Deployment 的更新:
1 | $ kubectl replace -f nginx.yaml |
可是,上面这种基于 YAML 文件的操作方式,是“声明式 API”吗?
并不是。
对于上面这种先 kubectl create,再 replace 的操作,称为命令式配置文件操作。
也就是说,它的处理方式,其实跟之前 Docker Swarm 的两句命令没什么本质上的区别,只不过把命令行里的参数写在配置文件里了而已。
那么,到底什么才是“声明式API”呢?
答案是,kubectl apply 命令。
无论是创建还是更新,都可以直接使用 kubectl apply 命令来完成。
可是,它跟 kubectl replace 命令有什么本质区别吗?
实际上,你可以简单地理解为:kubectl replace 的执行过程,是使用新的 YAML 文件中的 API 对象,替换原有的 API 对象;而 kubectl apply,则是对原有 API 对象的 PATCH 操作。
类似地,
kubectl set image和kubectl edit也是对已有的 API 对象的修改。
更进一步地,这意味着 kube-apiserver 在响应命令式请求(比如,kubectl replace)的时候,一次只能处理一个写请求,否则会有产生冲突的可能。而对于声明式请求(比如,kubectl apply),一次能处理多个写请求,并且具备 Merge 能力。
接下来,就以 Istio 项目为例,讲解一下声明式 API 在实际使用时重要意义。
Istio 项目,实际上就是一个基于 Kubernetes 项目的微服务治理框架,它的架构非常清晰,如下所示:
在这个架构图中,不难看出,Istio 最根本的组件,就是运行在每一个应用 Pod 里面的 Envoy 容器。
这个 Envoy 项目是一个高性能 C++ 网络代理,Istio 项目则把这个代理服务以 sidecar 容器的形式,运行在了每个被治理的应用 Pod 中。我们知道,Pod 里的所有容器都共享同一个 Network Namespace。所以,Envoy 容器就能够通过配置 Pod 里面的 iptables 规则,把整个 Pod 的进出流量接管下来。这时候,Istio 的控制层里的 Pilot 组件,就能够通过调用每个 Envoy 容器的 API,对这个 Envoy 代理进行配置,从而实现微服务治理。
我们一起来看一个例子。
假设这个 Istio 架构图中左边的 Pod 是已经在运行中的应用,而右边的 Pod 是我们刚刚上线的应用的新版本。这时候,Pod 通过调节这两个 Pod 里的 Envoy 容器的配置,从而将 90% 的流量分配给旧版本的应用,将 10% 的流量分配给新版本的应用,并且还在后续的发布过程中随时调整。这样,一个典型的“灰度发布”的场景就完成了。
更重要的是,在整个微服务治理的过程中,无论是对 Envoy 容器对部署,还是像上面这样对 Envoy 代理的配置,用户和应用都是完全“无感”的。
这时候,你可能会有所困惑:Istio 项目明明需要在每个 Pod 里面安装一个 Envoy 容器,又怎么能做到“无感”呢?
实际上,Istio 项目所使用的,是 Kubernetes 中的一个非常重要的功能,叫做 Dynamic Admission Control。
在 Kubernetes 项目中,当一个 Pod 或者如何一个 API 对象被提交给 APIServer 之后,总有一些“初始化”性质的工作需要在它们被 Kubernetes 项目正式处理之前进行。比如,自动为所有 Pod 加上某些标签(Labels)。
而这个“初始化”的实现,借助的是一个 Admission 的功能。它其实是 Kubernetes 项目里一组被称为 Admission Controller 的代码,可以选择性地被编译进 API Server 中,在 API 对象创建之后会被立刻调用到。
但这就意味着,如果你现在想要添加一些自己的规则到 Admission Controller,就会比较困难。因为,这要求重新编译并重启 API Server。显然,这种使用方法对 Istio 来说,影响太大了。
所以,Kubernetes 项目为我们额外提供了一种“热插拔”式的 Admission 机制,它就是 Dynamic Admission Control,也叫做:Initializer。
举个例子,比如我有如下所示的一个应用 Pod:
1 | apiVersion: v1 |
可以看到,这个 Pod 里面只有一个用户容器,叫做:myapp-container。
接下来,Istio 项目要做的就是在这个 Pod YAML 被提交给 Kubernetes 之后,在它对应的 API 对象里自动加上 Envoy 容器的配置,使这个对象变成下面这个样子:
1 | apiVersion: v1 |
那么,Istio 又是如何在用户完全不知情的前提下完成这个操作的呢?
Istio 要做的,就是编写一个用来为 Pod “自动注入” Envoy 容器的 Initializer。
首先,Istio 会将这个 Envoy 容器本身的定义,以 ConfigMap 的方式保存在 Kubernetes 当中。这个 ConfigMap 的定义如下所示:
1 | apiVersion: v1 |
不难想到,Initializer 要做的工作,就是把这部分 Envoy 相关的字段,自动添加到用户提交的 Pod 的 API 对象里。可是,用户提交的 Pod 里本来就有 containers 字段和 volumes 字段,所以 Kubernetes 在处理这样的更新请求时,就必须使用类似 git merge 这样的操作,才能将这两部分内容合并在一起。
所以说,在 Initializer 更新用户的 Pod 对象的时候,必须使用 PATCH API 来完成。而这种 PATCH API,正是声明式 API 最主要的能力。
接下来,Istio 将一个编写好的 Initializer,作为一个 Pod 部署在 Kubernetes 中。这个 Pod 的定义非常简单,如下所示:
1 | apiVersion: v1 |
可以看到,这个 envoy-initializer 使用的 envoy-initializer:0.0.1 镜像,就是一个事先编写好的“自定义控制器”。我们知道一个 Kubernetes 控制器,实际上就是一个“死循环”:它不断地获取“实际状态”,然后与“期望状态”做对比,并以此为依据决定下一步的操作。而 Initializer 控制器,不断获取到的“实际状态”,就是用户新创建的 Pod。而它的“期望状态”,则是:这个 Pod 里被添加了 Envoy 容器的定义。
用一段 Go 风格的代码来描述这个控制逻辑:
1 | for { |
- 如果这个 Pod 里面已经添加过 Envoy 容器,那么就进入下一个检查周期。
- 而如果还没有添加过,就要进行 initialize 操作了,即修改该 Pod 的 API 对象。
Istio 要往这个 Pod 里合并的字段,正是我们之前保存在 envoy-initializer 这个 ConfigMap 里面的数据。
所以,在 Initializer 控制器的工作逻辑里,它首先会从 APIServer 里面拿到这个 ConfigMap:然后把这个 ConfigMap 里面存储的 containers 和 volumes 字段直接添加进一个空的 Pod 对象里:1
2
3func doSomething(pod) {
cm := client.Get(ConfigMap, "envoy-initializer")
}现在,关键来了。Kubernetes 的 API 库,为我们提供了一个方法,让我们可以直接使用新旧两个 Pod 对象,生成一个 TwoWayMergePatch:1
2
3
4
5
6
7func doSomething(pod) {
cm := client.Get(ConfigMap, "envoy-initializer")
newPod := Pod{}
newPod.Spec.Containers = cm.Containers
newPod.Spec.Volumes = cm.Volumes
}有了这个 TwoWayMergePatch 之后,Initializer 的代码就可以使用这个 patch 的数据,调用 Kubernetes 的 Client,发起一个 PATCH 请求。1
2
3
4
5
6
7
8
9
10
11
12
13func doSomething(pod) {
cm := client.Get(ConfigMap, "envoy-initializer")
newPod := Pod{}
newPod.Spec.Containers = cm.Containers
newPod.Spec.Volumes = cm.Volumes
// 生成patch数据
patchBytes := strategicpatch.CreateTwoWayMergePatch(pod, newPod)
// 发起PATCH请求,修改这个pod对象
client.Patch(pod.Name, patchBytes)
}
这样,一个用户提交的 Pod 对象里,就会被自动加上 Envoy 容器相关的字段。
当然,Kubernetes 还允许你通过配置,来指定要对什么样的资源进行这个 Initialize 操作,比如下面这个例子:
1 | apiVersion: admissionregistration.k8s.io/v1alpha1 |
这个配置,就意味着 Kubernetes 要对所有的 Pod 进行这个 Initialize 操作,并且我们指定了负责这个操作的 Initializer,名叫:envoy-initializer。
而一旦这个 InitializerConfiguration 被创建,Kubernetes 就会把这个 Initializer 的名字,加在所有新创建的 Pod 的 Metadata 上,格式如下所示:
1 | apiVersion: v1 |
可以看到,每个新创建的 Pod,都会自动携带了 metadata.initializers.pending 的 Metadata 信息。这个 Metadata,正是接下来 Initializer 的控制器判断这个 Pod 有没有执行过自己所负责的初始化操作的重要依据(也就是前面伪代码中 isInitialized() 方法的含义)。
这也就意味着,当你在 Initializer 里完成了要做的操作后,一定要记得将这个 metadata.initializers.pending 标志清除掉。
此外,除了上面的配置方法,你还可以在具体的 Pod 的 Annotation 里添加一个如下所示的字段,从而声明要使用某个 Initializer:
1 | apiVersion: v1 |
以上,就是关于 Initializer 最基本的工作原理和使用方法了。此时我们可以深刻地体会到,Istio 项目的核心,就是由无数个在应用 Pod 中的 Envoy 容器组成的服务代理网络。
而这个机制得以实现的原理,正是借助了 Kubernetes 能够对 API 对象进行在线更新的能力,这也正是 Kubernetes“声明式 API”的独特之处:
- 首先,所谓“声明式”,指的就是我只需要提交一个定义好的 API 对象来“声明”,我所期望的状态是什么样子。
- 其次,“声明式 API”允许有多个 API 写端,以 PATCH 的方式对 API 对象进行修改,而无需关心本地原始 YAML 文件的内容。
- 最后,也是最重要的,有了上述两个能力,Kubernetes 项目才可以基于对 API 对象的增、删、改、查,在完全无需外界干预的情况下,完成对“实际状态”和“期望状态”的调谐过程。
而在使用 Initializer 的流程中,最核心的步骤,莫过于 Initializer“自定义控制器”的编写过程。它遵循的,正是标准的“Kubernetes 编程范式”,即:
如何使用控制器模式,同 Kubernetes 里 API 对象的“增、删、改、查”进行协作,进而完成用户业务逻辑的编写过程。