在前两天的文章当中我们搭建好了本地的 K8s 开发环境,并且了解了 kubebuilder 的基本使用方法,今天就从我之前遇到的一个真实需求出发完整的写一个 Operator
在 K8s 运行的过程当中我们发现总是存在一些业务由于安全,可用性等各种各样的原因需要跑在一些独立的节点池上,这些节点池里面可能再划分一些小的节点池。
虽然我们可以使用 Taint
,Label
对节点进行划分,使用 nodeSelector
和 tolerations
让 Pod 跑在指定的节点上,但是这样主要会有两个问题:
v1.16
之后也可以使用 RuntimeClass
来简化 pod 的配置,但是 RuntimClass
并不和节点进行关联[^1]MVP
版本支持标签、污点即可节点池资源如下
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: ""
节点和节点池之间的映射如何建立?
我们可以利用 node-role.kubernetes.io/xxx=""
标签和节点池建立映射
xxx
和节点池的name
相对应
使用这个标签的好处是,使用 kubectl get no
可以很方便的看到节点属于哪个节点池
NAME STATUS ROLES AGE VERSION
kind-control-plane Ready,SchedulingDisabled control-plane,master 2d2h v1.20.2
Pod 和节点池之间的映射如何建立?
RuntimeClass
对象,当创建一个 NodePool 对象的时候我们就创建一个对应的 RuntimeClass
对象,然后在 Pod
中只需要加上 runtimeClassName: myclass
就可以了注: 对于 MVP 版本来说其实我们不需要使用自定义资源,只需要通过标签和 RuntimeClass 结合就能满足需求,但是这里为了展示一个完整的流程,我们使用了自定义资源
# 初始化项目
kubebuilder init --repo github.com/mohuishou/blog-code/k8s-operator/03-node-pool-operator --domain lailin.xyz --skip-go-version-check
# 创建 api
kubebuilder create api --group nodes --version v1 --kind NodePool
// NodePoolSpec 节点池
type NodePoolSpec struct {
// Taints 污点
Taints []v1.Taint `json:"taints,omitempty"`
// Labels 标签
Labels map[string]string `json:"labels,omitempty"`
}
我们实现 Reconcile
函数,req
会返回当前变更的对象的 Namespace
和Name
信息,有这两个信息,我们就可以获取到这个对象了,所以我们的操作就是
NodePool
对象NodePool
对象生成对应的 Label
查找是否已经存在对应的 Label
的 NodeNode
加上对应的 Taint
和 Label
NodePool
生成对应的 RuntimeClass
,查找是否已经存在对应的 RuntimeClass
func (r *NodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
_ = r.Log.WithValues("nodepool", req.NamespacedName)
// 获取对象
pool := &nodesv1.NodePool{}
if err := r.Get(ctx, req.NamespacedName, pool); err != nil {
return ctrl.Result{}, err
}
var nodes corev1.NodeList
// 查看是否存在对应的节点,如果存在那么就给这些节点加上数据
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
}
// 如果不存在创建一个新的
if runtimeClass.Name == "" {
err = r.Create(ctx, pool.RuntimeClass())
if err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
相信聪明的你已经发现上面的创建逻辑存在很多的问题
NodePool
对象更新,Node
是否更新对应的 Taint
和Label
NodePool
删除了一个 Label
或Taint
对应 Node
的Label
或Taint
是否需要删除,怎么删除?NodePool
对象更新,RuntimeClass
是否更新,如何更新我们 MVP
版本实现可以简单一些,我们约定,所有属于 NodePool
的节点 Tanit
和Label
信息都应该由 NodePool
管理,key 包含 kubernetes 标签污点除外
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
// 更新节点的标签和污点信息
+ 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
方法如下所示,主要是修改节点的标签和污点信息
// ApplyNode 生成 Node 结构,可以用于 Patch 数据
func (s *NodePoolSpec) ApplyNode(node corev1.Node) *corev1.Node {
// 除了节点池的标签之外,我们只保留 k8s 的相关标签
// 注意:这里的逻辑如果一个节点只能属于一个节点池
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
// 污点同理
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
将服务跑起来测试一下
首先我们准备一份 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
部署之后可以获取到节点的标签
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
和节点对应的标签信息都有了相应的变化
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: ""
我们可以直接使用 kubectl delete NodePool name
删除对应的对象,但是这样可以发现一个问题,就是 NodePool 创建的 RuntimeClass 以及其维护的 Node Taint Labels 等信息都没有被清理。
当我们想要再删除一个对象的时候,清理一写想要清理的信息时,我们就可以使用 Finalizers
特性,执行预删除的操作。
k8s 的资源对象当中存在一个 Finalizers
字段,这个字段是一个字符串列表,当执行删除资源对象操作的时候,k8s 会先更新 DeletionTimestamp
时间戳,然后会去检查 Finalizers
是否为空,如果为空才会执行删除逻辑。所以我们就可以利用这个特性执行一些预删除的操作。注意:预删除必须是幂等的
func (r *NodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
_ = r.Log.WithValues("nodepool", req.NamespacedName)
// ......
+ // 进入预删除流程
+ if !pool.DeletionTimestamp.IsZero() {
+ return ctrl.Result{}, r.nodeFinalizer(ctx, pool, nodes.Items)
+ }
+ // 如果删除时间戳为空说明现在不需要删除该数据,我们将 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
+ }
+ }
// ......
}
预删除的逻辑如下
// 节点预删除逻辑
func (r *NodePoolReconciler) nodeFinalizer(ctx context.Context, pool *nodesv1.NodePool, nodes []corev1.Node) error {
// 不为空就说明进入到预删除流程
for _, n := range nodes {
n := n
// 更新节点的标签和污点信息
err := r.Update(ctx, pool.Spec.CleanNode(n))
if err != nil {
return err
}
}
// 预删除执行完毕,移除 nodeFinalizer
pool.Finalizers = removeString(pool.Finalizers, nodeFinalizer)
return r.Client.Update(ctx, pool)
}
我们执行 kubectl delete NodePool master
然后再获取节点信息可以发现,除了 kubernetes
的标签其他 NodePool 附加的标签都已经被删除掉了
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: ""
我们上面使用 Finalizer
的时候只处理了 Node 的相关数据,没有处理 RuntimeClass,能不能用相同的方式进行处理呢?当然是可以的,但是不够优雅。
对于这种一一映射或者是附带创建出来的资源,更好的方式是在子资源的 OwnerReference 上加上对应的 id,这样我们删除对应的 NodePool 的时候所有 OwnerReference 是这个对象的对象都会被删除掉,就不用我们自己对这些逻辑进行处理了。
func (r *NodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
//...
// 如果不存在创建一个新的
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
}
// ...
}
在创建的时候使用 controllerutil.SetOwnerReference
设置一下 OwnerReference 即可,然后我们再试试删除就可以发现 RuntimeClass 也一并被删除了。
注意,RuntimeClass 是一个集群级别的资源,我们最开始创建的 NodePool 是 Namespace 级别的,直接运行会报错,因为 Cluster 级别的 OwnerReference 不允许是 Namespace 的资源。
这个需要在 api/v1/nodepool_types.go
添加一行注释,指定为 Cluster 级别
//+kubebuilder:object:root=true
+//+kubebuilder:resource:scope=Cluster
//+kubebuilder:subresource:status
// NodePool is the Schema for the nodepools API
type NodePool struct {
修改之后我们需要先执行 make uninstall
然后再执行 make install
回顾一下,这篇文章我们实现了一个 NodePool 的 Operator 用来控制节点以及对应的 RuntimeClass,除了基本的 CURD 之外我们还学习了预删除和 OwnerReference 的使用方式。之前在 kubectl delete 某个资源的时候有时候会卡住,这个其实是因为在执行预删除的操作,可能本来也比较慢,也有可能是预删除的时候返回了错误导致的。
下一篇我们一起来为我们的 Operator 加上 Event 和 Status。
[^1]: 容器运行时类(Runtime Class): https://kubernetes.io/zh/docs/concepts/containers/runtime-class/
[^2]: kubebuilder 进阶使用: https://zhuanlan.zhihu.com/p/144978395
[^3]: kubebuilder2.0学习笔记——搭建和使用 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/