一篇帶你kubebuilder 實(shí)戰(zhàn): CRUD
在前兩天的文章當(dāng)中我們搭建好了本地的 K8s 開發(fā)環(huán)境,并且了解了 kubebuilder 的基本使用方法,今天就從我之前遇到的一個(gè)真實(shí)需求出發(fā)完整的寫一個(gè) Operator
需求分析
背景
在 K8s 運(yùn)行的過程當(dāng)中我們發(fā)現(xiàn)總是存在一些業(yè)務(wù)由于安全,可用性等各種各樣的原因需要跑在一些獨(dú)立的節(jié)點(diǎn)池上,這些節(jié)點(diǎn)池里面可能再劃分一些小的節(jié)點(diǎn)池。
雖然我們可以使用 Taint,Label對(duì)節(jié)點(diǎn)進(jìn)行劃分,使用 nodeSelector 和 tolerations讓 Pod 跑在指定的節(jié)點(diǎn)上,但是這樣主要會(huì)有兩個(gè)問題:
- 一個(gè)是管理上不方便,在實(shí)際的使用過程中我們會(huì)發(fā)現(xiàn)存在錯(cuò)配漏配的情況 雖然在 v1.16 之后也可以使用 RuntimeClass來簡(jiǎn)化 pod 的配置,但是 RuntimClass 并不和節(jié)點(diǎn)進(jìn)行關(guān)聯(lián)[^1]
- 另一個(gè)就是拓展需求不好實(shí)現(xiàn),例如我們想要的某個(gè)節(jié)點(diǎn)屬于網(wǎng)段或者當(dāng)節(jié)點(diǎn)加入這個(gè)節(jié)點(diǎn)池自動(dòng)開墻等
需求
1.對(duì)應(yīng)用來說我們可以在創(chuàng)建或者更新應(yīng)用時(shí)便捷的選擇的對(duì)應(yīng)的節(jié)點(diǎn)池,默認(rèn)情況下不需要進(jìn)行選擇
2.對(duì)于節(jié)點(diǎn)池來說
- 一個(gè)節(jié)點(diǎn)池可能有多個(gè)節(jié)點(diǎn),并且一個(gè)節(jié)點(diǎn)也可能同時(shí)屬于多個(gè)節(jié)點(diǎn)池
- 不同節(jié)點(diǎn)池的標(biāo)簽、污點(diǎn)信息可能不同
- 后續(xù)可以支持不同節(jié)點(diǎn)池的機(jī)型、安全組或者防火墻策略不同等
- MVP 版本支持標(biāo)簽、污點(diǎn)即可
方案設(shè)計(jì)
節(jié)點(diǎn)池資源如下
- apiVersion: nodes.lailin.xyz/v1
- kind: NodePool
- metadata:
- name: test
- spec:
- taints:
- - key: node-pool.lailin.xyz
- value: test
- effect: NoSchedule
- labels:
- node-pool.lailin.xyz/test: ""
節(jié)點(diǎn)和節(jié)點(diǎn)池之間的映射如何建立?
- 我們可以利用 node-role.kubernetes.io/xxx=""標(biāo)簽和節(jié)點(diǎn)池建立映射
- xxx 和節(jié)點(diǎn)池的name相對(duì)應(yīng)
- 使用這個(gè)標(biāo)簽的好處是,使用 kubectl get no可以很方便的看到節(jié)點(diǎn)屬于哪個(gè)節(jié)點(diǎn)池
- NAME STATUS ROLES AGE VERSION
- kind-control-plane Ready,SchedulingDisabled control-plane,master 2d2h v1.20.2
Pod 和節(jié)點(diǎn)池之間的映射如何建立?
- 我們可以復(fù)用 RuntimeClass對(duì)象,當(dāng)創(chuàng)建一個(gè) NodePool 對(duì)象的時(shí)候我們就創(chuàng)建一個(gè)對(duì)應(yīng)的 RuntimeClass 對(duì)象,然后在 Pod 中只需要加上 runtimeClassName: myclass 就可以了
注: 對(duì)于 MVP 版本來說其實(shí)我們不需要使用自定義資源,只需要通過標(biāo)簽和 RuntimeClass 結(jié)合就能滿足需求,但是這里為了展示一個(gè)完整的流程,我們使用了自定義資源
開發(fā)
創(chuàng)建項(xiàng)目
- # 初始化項(xiàng)目
- kubebuilder init --repo github.com/mohuishou/blog-code/k8s-operator/03-node-pool-operator --domain lailin.xyz --skip-go-version-check
- # 創(chuàng)建 api
- kubebuilder create api --group nodes --version v1 --kind NodePool
定義對(duì)象
- // NodePoolSpec 節(jié)點(diǎn)池
- type NodePoolSpec struct {
- // Taints 污點(diǎn)
- Taints []v1.Taint `json:"taints,omitempty"`
- // Labels 標(biāo)簽
- Labels map[string]string `json:"labels,omitempty"`
- }
創(chuàng)建
我們實(shí)現(xiàn) Reconcile 函數(shù),req會(huì)返回當(dāng)前變更的對(duì)象的 Namespace和Name信息,有這兩個(gè)信息,我們就可以獲取到這個(gè)對(duì)象了,所以我們的操作就是
1.獲取 NodePool 對(duì)象
2.通過 NodePool 對(duì)象生成對(duì)應(yīng)的 Label 查找是否已經(jīng)存在對(duì)應(yīng)的 Label 的 Node
- 如果存在,就給對(duì)應(yīng)的 Node 加上對(duì)應(yīng)的 Taint 和 Label
- 如果不存在就跳過
3.通過 NodePool 生成對(duì)應(yīng)的 RuntimeClass ,查找是否已經(jīng)存在對(duì)應(yīng)的 RuntimeClass
- 如果不存在就新建
- 存在就跳過
- func (r *NodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
- _ = r.Log.WithValues("nodepool", req.NamespacedName)
- // 獲取對(duì)象
- pool := &nodesv1.NodePool{}
- if err := r.Get(ctx, req.NamespacedName, pool); err != nil {
- return ctrl.Result{}, err
- }
- var nodes corev1.NodeList
- // 查看是否存在對(duì)應(yīng)的節(jié)點(diǎn),如果存在那么就給這些節(jié)點(diǎn)加上數(shù)據(jù)
- err := r.List(ctx, &nodes, &client.ListOptions{LabelSelector: pool.NodeLabelSelector()})
- if client.IgnoreNotFound(err) != nil {
- return ctrl.Result{}, err
- }
- if len(nodes.Items) > 0 {
- r.Log.Info("find nodes, will merge data", "nodes", len(nodes.Items))
- for _, n := range nodes.Items {
- n := n
- err := r.Patch(ctx, pool.Spec.ApplyNode(n), client.Merge)
- if err != nil {
- return ctrl.Result{}, err
- }
- }
- }
- var runtimeClass v1beta1.RuntimeClass
- err = r.Get(ctx, client.ObjectKeyFromObject(pool.RuntimeClass()), &runtimeClass)
- if client.IgnoreNotFound(err) != nil {
- return ctrl.Result{}, err
- }
- // 如果不存在創(chuàng)建一個(gè)新的
- if runtimeClass.Name == "" {
- err = r.Create(ctx, pool.RuntimeClass())
- if err != nil {
- return ctrl.Result{}, err
- }
- }
- return ctrl.Result{}, nil
- }
更新
相信聰明的你已經(jīng)發(fā)現(xiàn)上面的創(chuàng)建邏輯存在很多的問題
1.如果 NodePool 對(duì)象更新,Node 是否更新對(duì)應(yīng)的 Taint 和Label
- 如果 NodePool 刪除了一個(gè) Label 或Taint對(duì)應(yīng) Node 的Label或Taint 是否需要?jiǎng)h除,怎么刪除?
2.如果 NodePool 對(duì)象更新,RuntimeClass是否更新,如何更新
我們 MVP 版本實(shí)現(xiàn)可以簡(jiǎn)單一些,我們約定,所有屬于 NodePool 的節(jié)點(diǎn) Tanit 和Label信息都應(yīng)該由 NodePool管理,key 包含 kubernetes 標(biāo)簽污點(diǎn)除外
- func (r *NodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
- // ....
- if len(nodes.Items) > 0 {
- r.Log.Info("find nodes, will merge data", "nodes", len(nodes.Items))
- for _, n := range nodes.Items {
- n := n
- // 更新節(jié)點(diǎn)的標(biāo)簽和污點(diǎn)信息
- + err := r.Update(ctx, pool.Spec.ApplyNode(n))
- - err := r.Patch(ctx, pool.Spec.ApplyNode(n), client.Merge)
- if err != nil {
- return ctrl.Result{}, err
- }
- }
- }
- //...
- // 如果存在則更新
- + err = r.Client.Patch(ctx, pool.RuntimeClass(), client.Merge)
- + if err != nil {
- + return ctrl.Result{}, err
- + }
- return ctrl.Result{}, err
- }
ApplyNode 方法如下所示,主要是修改節(jié)點(diǎn)的標(biāo)簽和污點(diǎn)信息
- // ApplyNode 生成 Node 結(jié)構(gòu),可以用于 Patch 數(shù)據(jù)
- func (s *NodePoolSpec) ApplyNode(node corev1.Node) *corev1.Node {
- // 除了節(jié)點(diǎn)池的標(biāo)簽之外,我們只保留 k8s 的相關(guān)標(biāo)簽
- // 注意:這里的邏輯如果一個(gè)節(jié)點(diǎn)只能屬于一個(gè)節(jié)點(diǎn)池
- nodeLabels := map[string]string{}
- for k, v := range node.Labels {
- if strings.Contains(k, "kubernetes") {
- nodeLabels[k] = v
- }
- }
- for k, v := range s.Labels {
- nodeLabels[k] = v
- }
- node.Labels = nodeLabels
- // 污點(diǎn)同理
- var taints []corev1.Taint
- for _, taint := range node.Spec.Taints {
- if strings.Contains(taint.Key, "kubernetes") {
- taints = append(taints, taint)
- }
- }
- node.Spec.Taints = append(taints, s.Taints...)
- return &node
- }
我們使用 make run將服務(wù)跑起來測(cè)試一下
首先我們準(zhǔn)備一份 NodePool 的 CRD,使用 kubectl apply -f config/samples/ 部署一下
- apiVersion: nodes.lailin.xyz/v1
- kind: NodePool
- metadata:
- name: master
- spec:
- taints:
- - key: node-pool.lailin.xyz
- value: master
- effect: NoSchedule
- labels:
- "node-pool.lailin.xyz/master": "8"
- "node-pool.lailin.xyz/test": "2"
- handler: runc
部署之后可以獲取到節(jié)點(diǎn)的標(biāo)簽
- labels:
- beta.kubernetes.io/arch: amd64
- beta.kubernetes.io/os: linux
- kubernetes.io/arch: amd64
- kubernetes.io/hostname: kind-control-plane
- kubernetes.io/os: linux
- node-pool.lailin.xyz/master: "8"
- node-pool.lailin.xyz/test: "2"
- node-role.kubernetes.io/control-plane: ""
- node-role.kubernetes.io/master: ""
以及 RuntimeClass
- apiVersion: node.k8s.io/v1
- handler: runc
- kind: RuntimeClass
- scheduling:
- nodeSelector:
- node-pool.lailin.xyz/master: "8"
- node-pool.lailin.xyz/test: "2"
- tolerations:
- - effect: NoSchedule
- key: node-pool.lailin.xyz
- operator: Equal
- value: master
我們更新一下 NodePool
- apiVersion: nodes.lailin.xyz/v1
- kind: NodePool
- metadata:
- name: master
- spec:
- taints:
- - key: node-pool.lailin.xyz
- value: master
- effect: NoSchedule
- labels:
- + "node-pool.lailin.xyz/master": "10"
- - "node-pool.lailin.xyz/master": "8"
- - "node-pool.lailin.xyz/test": "2"
- handler: runc
可以看到 RuntimeClass
- scheduling:
- nodeSelector:
- node-pool.lailin.xyz/master: "10"
- tolerations:
- - effect: NoSchedule
- key: node-pool.lailin.xyz
- operator: Equal
- value: master
和節(jié)點(diǎn)對(duì)應(yīng)的標(biāo)簽信息都有了相應(yīng)的變化
- labels:
- beta.kubernetes.io/arch: amd64
- beta.kubernetes.io/os: linux
- kubernetes.io/arch: amd64
- kubernetes.io/hostname: kind-control-plane
- kubernetes.io/os: linux
- node-pool.lailin.xyz/master: "10"
- node-role.kubernetes.io/control-plane: ""
- node-role.kubernetes.io/master: ""
預(yù)刪除: Finalizers
我們可以直接使用 kubectl delete NodePool name刪除對(duì)應(yīng)的對(duì)象,但是這樣可以發(fā)現(xiàn)一個(gè)問題,就是 NodePool 創(chuàng)建的 RuntimeClass 以及其維護(hù)的 Node Taint Labels 等信息都沒有被清理。
當(dāng)我們想要再刪除一個(gè)對(duì)象的時(shí)候,清理一寫想要清理的信息時(shí),我們就可以使用 Finalizers 特性,執(zhí)行預(yù)刪除的操作。
k8s 的資源對(duì)象當(dāng)中存在一個(gè) Finalizers字段,這個(gè)字段是一個(gè)字符串列表,當(dāng)執(zhí)行刪除資源對(duì)象操作的時(shí)候,k8s 會(huì)先更新 DeletionTimestamp 時(shí)間戳,然后會(huì)去檢查 Finalizers是否為空,如果為空才會(huì)執(zhí)行刪除邏輯。所以我們就可以利用這個(gè)特性執(zhí)行一些預(yù)刪除的操作。注意:預(yù)刪除必須是冪等的
- func (r *NodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
- _ = r.Log.WithValues("nodepool", req.NamespacedName)
- // ......
- + // 進(jìn)入預(yù)刪除流程
- + if !pool.DeletionTimestamp.IsZero() {
- + return ctrl.Result{}, r.nodeFinalizer(ctx, pool, nodes.Items)
- + }
- + // 如果刪除時(shí)間戳為空說明現(xiàn)在不需要?jiǎng)h除該數(shù)據(jù),我們將 nodeFinalizer 加入到資源中
- + if !containsString(pool.Finalizers, nodeFinalizer) {
- + pool.Finalizers = append(pool.Finalizers, nodeFinalizer)
- + if err := r.Client.Update(ctx, pool); err != nil {
- + return ctrl.Result{}, err
- + }
- + }
- // ......
- }
預(yù)刪除的邏輯如下
- // 節(jié)點(diǎn)預(yù)刪除邏輯
- func (r *NodePoolReconciler) nodeFinalizer(ctx context.Context, pool *nodesv1.NodePool, nodes []corev1.Node) error {
- // 不為空就說明進(jìn)入到預(yù)刪除流程
- for _, n := range nodes {
- n := n
- // 更新節(jié)點(diǎn)的標(biāo)簽和污點(diǎn)信息
- err := r.Update(ctx, pool.Spec.CleanNode(n))
- if err != nil {
- return err
- }
- }
- // 預(yù)刪除執(zhí)行完畢,移除 nodeFinalizer
- pool.Finalizers = removeString(pool.Finalizers, nodeFinalizer)
- return r.Client.Update(ctx, pool)
- }
我們執(zhí)行 kubectl delete NodePool master 然后再獲取節(jié)點(diǎn)信息可以發(fā)現(xiàn),除了 kubernetes 的標(biāo)簽其他 NodePool 附加的標(biāo)簽都已經(jīng)被刪除掉了
- labels:
- beta.kubernetes.io/arch: amd64
- beta.kubernetes.io/os: linux
- kubernetes.io/arch: amd64
- kubernetes.io/hostname: kind-control-plane
- kubernetes.io/os: linux
- node-role.kubernetes.io/control-plane: ""
- node-role.kubernetes.io/master: ""
OwnerReference
我們上面使用 Finalizer 的時(shí)候只處理了 Node 的相關(guān)數(shù)據(jù),沒有處理 RuntimeClass,能不能用相同的方式進(jìn)行處理呢?當(dāng)然是可以的,但是不夠優(yōu)雅。
對(duì)于這種一一映射或者是附帶創(chuàng)建出來的資源,更好的方式是在子資源的 OwnerReference 上加上對(duì)應(yīng)的 id,這樣我們刪除對(duì)應(yīng)的 NodePool 的時(shí)候所有 OwnerReference 是這個(gè)對(duì)象的對(duì)象都會(huì)被刪除掉,就不用我們自己對(duì)這些邏輯進(jìn)行處理了。
- func (r *NodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
- //...
- // 如果不存在創(chuàng)建一個(gè)新的
- if runtimeClass.Name == "" {
- + runtimeClass = pool.RuntimeClass()
- + err = ctrl.SetControllerReference(pool, runtimeClass, r.Scheme)
- + if err != nil {
- + return ctrl.Result{}, err
- + }
- + err = r.Create(ctx, runtimeClass)
- - err = r.Create(ctx, pool.RuntimeClass())
- return ctrl.Result{}, err
- }
- // ...
- }
在創(chuàng)建的時(shí)候使用 controllerutil.SetOwnerReference 設(shè)置一下 OwnerReference 即可,然后我們?cè)僭囋噭h除就可以發(fā)現(xiàn) RuntimeClass 也一并被刪除了。
注意,RuntimeClass 是一個(gè)集群級(jí)別的資源,我們最開始創(chuàng)建的 NodePool 是 Namespace 級(jí)別的,直接運(yùn)行會(huì)報(bào)錯(cuò),因?yàn)? Cluster 級(jí)別的 OwnerReference 不允許是 Namespace 的資源。
這個(gè)需要在 api/v1/nodepool_types.go 添加一行注釋,指定為 Cluster 級(jí)別
- //+kubebuilder:object:root=true
- +//+kubebuilder:resource:scope=Cluster
- //+kubebuilder:subresource:status
- // NodePool is the Schema for the nodepools API
- type NodePool struct {
修改之后我們需要先執(zhí)行 make uninstall 然后再執(zhí)行 make install
總結(jié)
回顧一下,這篇文章我們實(shí)現(xiàn)了一個(gè) NodePool 的 Operator 用來控制節(jié)點(diǎn)以及對(duì)應(yīng)的 RuntimeClass,除了基本的 CURD 之外我們還學(xué)習(xí)了預(yù)刪除和 OwnerReference 的使用方式。之前在 kubectl delete 某個(gè)資源的時(shí)候有時(shí)候會(huì)卡住,這個(gè)其實(shí)是因?yàn)樵趫?zhí)行預(yù)刪除的操作,可能本來也比較慢,也有可能是預(yù)刪除的時(shí)候返回了錯(cuò)誤導(dǎo)致的。
下一篇我們一起來為我們的 Operator 加上 Event 和 Status。
參考文獻(xiàn)
[^1]: 容器運(yùn)行時(shí)類(Runtime Class):
https://kubernetes.io/zh/docs/concepts/containers/runtime-class/
[^2]: kubebuilder 進(jìn)階使用:
https://zhuanlan.zhihu.com/p/144978395
[^3]: kubebuilder2.0學(xué)習(xí)筆記——搭建和使用
https://segmentfault.com/a/1190000020338350
[^4]: KiND - How I Wasted a Day Loading Local Docker Images:
https://iximiuz.com/en/posts/kubernetes-kind-load-docker-image/