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