使用 Node.js 操作 CRD

从来没想到自己会以别样的方式开始 Kubernetes 的正式学习和深入了解。

几个月前开始慢慢进入云原生的领域,倒也只是简简单单地了解 Kubernetes。直到上个月,需要对接 K8s 的 CRD 来进行一些操作。让我以一种特殊的切入点,开始了 K8s 的正式了解与学习。

Resource

从我片面的理解来总结,K8s 中最重要的东西便是「资源对象」了。Deployment 是一类资源,StatefulSet 是另一种资源。这是在 K8s 抽象出来的一层概念,并且服务于 K8s 的设计的。

一类资源可以看做一个表,或者一个 Schema,抑或一个类,描述了一个确定的数据结构。与此同时,我们可以添加若干的「对象」,类似于表记录或者类实例。资源的对象由 K8s 进行持久化存储(使用 etcd),并且由「控制器」来进行处理。

与数据表等不同的是,资源是更高级的一层抽象。它具有 Metadata、Spec、Status 等几个主要概念,对一个模型进行了概念化的区分,例如一个 Deployment 的资源对象如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80

上述的 yaml 文件很容易看出来,使我们部署一个 Nginx 应用的定义,使用 kubectl apply 即可安装部署。它包含了 metadataspec,便是告诉 Deployment 控制器,我要搞定这么一个 Deployment。

这就是以确定的数据结构来定义了一个资源,存储到了 K8s 的 etcd 中。同时因为抽象的缘故,似的 spec 定义了期望的状态,status 存储了实际的状态,而靠这个抽象,资源便在 K8s 中被完美地调度起来。

K8s 内置了一系列的资源,构成了 K8s 核心集群应用部署、资源管理等功能,例如 PodDeployment 等。同时 K8s 也内置了一系列的控制器,来监听资源对象的变动,并妥善处理 spec 与 status,使得集群内的资源得到正确的处理。

围绕着资源,以及如何处理资源这一抽象模型,使得 K8s 具备了简单但有效的工作模式,以及很好的扩展性。

K8s 在内置资源之外,提供了「定制资源」的能力。用户或开发者可以定义一套自己的数据结构(Custom Resource Definition, CRD)来为自己的控制器提供需要的数据结构。比如我想用 K8s 来编排虚拟机,那么我写一个 Vm 控制器,来监听 Vm CR 对象,如果有新增的 CR,控制器便创建一台新的 Vm,如果更改了一个旧的 Vm CR,控制器便更新一下 Vm 的信息。

这么做的好处有很多。首先可以利用 K8s 的庞大生态,例如 Dashboard,或者客户端进行直接管理,省去了自己造一套 UI 的麻烦,甚至可以直接用 kubectl 进行管理,如果再与一个外层的服务对接,可以直接利用 K8s client 库等。其次控制器的实现可以依赖 K8s 的 Reconcile 机制,来降低可靠性方面的心智负担与开发成本。

底层交互

K8s 中对外的一切出入口,都是 apiserver 这个组件,kubectl 与它交互,控制器也与它交互。apiserver 实现了一套基于 REST 风格的 API,来实现对所有交互的封装。

据此我们可以确定一些关键点:客户端与 K8s apiserver 的交互是通过 HTTP 协议进行;K8s 对 API 格式有一套完整的规范,例如 URL 是如何根据资源的一些信息生成的等。

这里以 CRD 资源为例,假设一个资源版本为 v1,资源 Kind 为 Vm,复数为 vms,Group 为 vm.example.com,并且 CRD 的 scope 为 cluster,那么得到的 url 为:

1
2
3
4
获取列表:GET /apis/vm.example.com/v1/vms
创建 Vm:POST /apis/vm.example.com/v1/vms
修改 Vm:PATCH /apis/vm.example.com/v1/vms/vm-name
删除 Vm:DELETE /apis/vm.example.com/v1/vms/vm-name

这熟悉的 RESTFul 风格,跃然眼前。不过这种接口还是命令式的,可以通过简单的封装使之变为声明式。

操作 CRD

基础调用

当然我们没必要刀耕火种地处理 URL 并且交互。这里使用到的是 K8s 官方的 JavaScript 客户端库

从最简单的操作开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { KubeConfig, CustomObjectsApi } from '@kubernetes/client-node'

const kc = new KubeConfig();
kc.loadFromDefault(); // 通过本地 ~/.kube/config current context 加载配置并使用。

const api = kc.makeApiClient(CustomObjectsApi);

// 如果 CRD 的 scope 为 Cluster,则使用对应的方法
await api.createClusterCustomObject(
'vm.example.com', // CRD 的 Group
'v1', // CRD 版本
'vms' // CRD 类型对应的复数形式(由 CRD 定义的)
{ // body
apiVersion: 'vm.example.com/v1',
kind: 'Vm',
metadata: {
name: 'my-first-vm'
},
spec: {
displayName: '我的小虚拟机',
cpu: 4,
memory: 1024 * 1024 * 1024 * 16,
}
}
);

上述代码假设了一个存在的 Vm CRD,并进行创建。需要注意的是方法名为 createClusterCustomObject 需要在 CRD Scope 为 Cluster 的时候调用,而 createNamespacedCustonObject 则是在 CRD Scope 为 Namespaced 的情况下调用,二者不可混淆。这是由于两种情况下生成的 URL 不同,Scope 为 Namespaced 的情况下,URL 中还包含了 Namespace 的信息,调用方法时也需要传入 Namespace:

1
2
3
4
5
6
7
8
9
10
11
12
await api.createNamespacedCustomObject(
'vm.example.com',
'v1',
'some-namespace',
'vms',
{
apiVersion: 'vm.example.com/v1',
kind: 'Vm',
metadata,
spec,
}
);

其它的情况调用方式基本类似,调用具体的方法即可。不过对于 Patch 修改 CR Object,还需要加一个参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const { body: { metadata } } = await api.getNamespacedCustomObject(...);

await api.patchNamespacedCustomObject(
'group',
'api version',
'namespace',
'plural',
'object-name',
{
apiVersion: `group/version`,
kind: 'kind',
metadata: {
...metadata,
name: 'object-name',
},
spec,
},
undefined,
undefined,
undefined,
{
headers: {
'Content-type': PatchUtils.PATCH_FORMAT_JSON_MERGE_PATCH,
},
}
);

对比其它调用有两处不同:

  1. 首先获取 CR Object,然后再放到请求 Body 中。这里主要是为了获取最新的 resourceVersion 字段,用于 K8s 请求的并发控制。具体可以参考官方的文档
  2. 添加了一个 Header,用于声明 Patch 的 Content Type。

Watch 机制

K8s 通过 HTTP 长链接实现了 List & Watch 机制,允许客户端程序开启一个长链接,首次连接时得到所有的对象列表,并且 apiserver 会持续推送变动的信息。使用 Kubectl Watch 一些信息的时候便是利用了这一特性。

在 JS Client 库中,很朴素地实现了 Watch,即创建长链接,并且在接收到新数据时触发一次回调。基本的使用示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
import { KubeConfig, Watch } from '@kubernetes/client-node'

const kc = new KubeConfig();
kc.loadFromDefault(); // 通过本地 ~/.kube/config current context 加载配置并使用。

const watch = new Watch(kc);
const req = await watch.watch(
'/apis/vm.example.com/v1/vms',
{}, // some parameters
(type, object) => console.log(type, object),
err => console.log(err),
);

watch 方法有四个参数:

  • URL:这便是一个资源的 List 地址,可以从上面的「底层交互」章节的知识得到。不幸的是,在 Kubernetes JS Client 中需要手动构造这个 URL。
  • 参数:这里会被解析为 QueryString 中的一系列参数,按需传入。
  • callback:这里传入的方法会在新的数据到达后执行,第一个参数是操作的类型(ADDED、MODIFIED、DELETED),第二个参数是实际变动后的对象。其实还有第三个参数,是第一、二个参数合并的对象,也是原始的数据包。Client 做了一层处理,解析成了两个参数。
  • done:最后一个参数是在连接中断的时候会执行的方法,如果使用 req.abort() 则 err 为 null,意外断开连接则会是具体的网络错误信息。

类型定义

不难发现,@kubernetes/node-client 库对于最终的 spec 的类型都是 object。这对于我们 TypeScript 用户很不友好呀。那么不妨将改类型进行定义,并且封装一个专有方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface VmSpec {
displayName: string;
cpu: number;
memory: number;
state: 'RUNNING';
}

interface VmStatus {
state: 'RUNNING' | 'STOPPED' | 'PAUSED';
}

interface VmCustomResource {
apiVersion: string;
kind: string;
metadata: {
name: string;
resourceVersion: string;
labels: Record<string, string>;
},
spec: VmSpec;
status: VmStatus;
}

function getVmObject(kind: string, name: string): VmCustomResource {
return ...;
}

但是有多个 CRD 存在的时候,这种写法略显笨拙。因为我们可以充分利用 TS 的自动类型推导。

以存在两个 CRD——Vm、Disk 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
interface VmSpec {
displayName: string;
cpu: number;
memory: number;
state: 'RUNNING';
}

interface VmStatus {
state: 'RUNNING' | 'STOPPED' | 'PAUSED';
}

interface Metadata {
name: string;
resourceVersion: string;
labels: Record<string, string>;
}

interface CustomResourceDefinitions {
Vm: {
spec: VmSpec;
status: VmStatus;
},
Disk: {
spec: {
size: number;
state: 'MOUNTED' | 'UNMOUNTED';
},
status: {
state: 'MOUNTED' | 'UNMOUNTED';
}
}
}

type CustomResourceKinds = keyof CustomResourceDefinitions;

export interface CustomResource<
T extends CustomResourceDefinitions[CustomResourceKinds]
> {
apiVersion: string;
kind: CustomResourceKinds;
metadata: Metadata;
spec: T['spec'];
status?: T['status'];
}

export type CustomResources<K extends CustomResourceKinds> = CustomResourceDefinitions[K];

function getCustomResource<T extends CustomResourceKinds>(kind: T, name: string): CustomResourceDefinitions<T> {
return ...;
}

这样在调用 getCustomResource() 方法时,便可以根据传入的 kind 自动推导出返回值的准确类型了。

另外,我还把一个用于 Golang 开发 Controller 时通过 Golang 类型定义生成 API 文档的库魔改了下,可以直接生成用于 TS 的类型定义,如果有需要的同学可以围观下。

项目地址为:https://github.com/microud/crd2typescript

这个库的目标是提供一个不限语言的类型生成方案。使用时只需要调整模板即可。不过还有一点细节没处理好,另外目前为止文档还没写好(逃

小结

探索 K8s 的过程不算顺利,但也充满乐趣。作为一个刚开始学习 K8s 的菜鸟,也希望从这篇博客开始,记录下自己的学习历程。自勉!