Kubernetes 1.7 版本以来就引入了自定义控制器 的概念,该功能可以让开发人员扩展添加新功能,更新现有的功能,并且可以自动执行一些管理任务。
Operator 是由 CoreOS 开发的,用来扩展 Kubernetes API 的控制器框架,它用来创建、配置和管理复杂的有状态应用,如数据库、缓存和监控系统。
Operator 基于 Kubernetes 的资源和控制器概念之上构建,但同时又包含了应用程序特定的领域知识。
这些自定义的控制器就像 Kubernetes 原生的组件一样 ,Operator 直接使用 Kubernetes API 进行开发,也就是说可以根据这些控制器内部编写的自定义规则来监控集群、更改 Pods/Services、对正在运行的应用进行扩缩容。
创建 Operator 的关键是 CRD(自定义资源)的设计。本文将通过虚拟需求,设计 CRD 并实现 CRD 的控制逻辑,以体验 Operator 的开发过程。
Operator Framework Operator Framework 是 CoreOS 开源的一个用于快速开发 Operator 的工具包,该框架包含两个主要的部分:
Operator SDK: 无需了解复杂的 Kubernetes API 特性,即可让你根据你自己的专业知识构建一个 Operator 应用。
Operator Lifecycle Manager OLM: 帮助你安装、更新和管理跨集群的运行中的所有 Operator(以及他们的相关服务)
工作流程 Operator SDK 提供以下工作流来开发一个新的 Operator:
使用 SDK 创建一个新的 Operator 项目
通过添加自定义资源(CRD)定义新的资源 API
指定使用 SDK API 来 watch 的资源
定义 Operator 的协调(reconcile)逻辑
使用 Operator SDK 构建并生成 Operator 部署清单文件
开发一个程序 部署一个简单的 Web 服务到 Kubernetes 集群中的时候,都需要:
编写一个 Deployment 的控制器;
创建一个 Service 对象,通过 Pod 的 label 标签进行关联;
通过 Ingress 或者 type=NodePort
类型的 Service 来暴露服务。
每次都需要这样操作,略显麻烦。
可以创建一个自定义的资源对象,通过自定义的 CRD 来描述要部署的应用信息,比如镜像、服务端口、环境变量等等。
创建自定义类型的资源对象的时候,通过控制器去创建对应的 Deployment 和 Service,是不是就方便很多了,相当于用一个资源清单去描述了 Deployment 和 Service 要做的两件事情。
CRD 的设计 1 2 3 4 5 6 7 8 9 10 11 apiVersion: paas.cmft.com/v1 kind: DemoApplication metadata: name: nginx-demo spec: replica: 2 image: nginx:latest ports: - port: 80 targetPort: 80 nodePort: 30002
通过这里的自定义的 DemoApplication
资源对象去创建副本数为 2 的 Pod,然后通过 nodePort=30002 的端口去暴露服务
环境准备 需要准备的环境:
Go 语言开发环境;
operator-sdk 工具;
Kubernetes 环境。
安装 operator-sdk:
安装命令 1 brew install operator-sdk
创建项目 执行以下命令创建项目脚手架:
生成项目框架 1 operator-sdk init --plugins=go/v4 --domain=paas.cmft.com --repo code-inc.cmft.com/CMHK/GRD-PAAS-PORTAL/demo-operator.git
--domain
:指定 API Group
--repo
:指定 Go 模块名
创建 API 默认新创建的项目只有一些框架文件,需要为自定义资源添加一个新的 API:
生成 API 1 operator-sdk create api --version v1beta1 --kind DemoApplication
输出如下:
调整 API 生成的 API 文件存放在 api/v1beta1/demoapplication_types.go
,需要根据需求去自定义结构体 DemoApplicationSpec
的结构,比如对应上面 Demo 的结构:
实现 CRD 参数 1 2 3 4 5 6 7 8 type DemoApplicationSpec struct { Image string `json:"image,omitempty"` Replica *int32 `json:"replica,omitempty"` Ports []corev1.ServicePort `json:"ports,omitempty"` }
修改完后,运行 make
命令重新生成一些代码。
增加业务逻辑 我们的业务逻辑需要在 internal/controller/demoapplication_controller.go
的 Reconcile
中添加:
Reconcile 的默认内容 1 2 3 4 5 6 7 func (r *DemoApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error ) { _ = log.FromContext(ctx) return ctrl.Result{}, nil }
Reconcile 方法 Reconcile
方法是什么?Reconcile
方法用于实现将 CR(Custom Resource)的实际状态变更为我们的期望状态。
比如:将 Deployment 的 replicas 值从 1 修改为 2,DeploymentController 的 Reconcile 方法就创建一个新的 Pod,来满足 replicas 的描述。
Reconcile
方法什么时候被调用?Reconcile 方法在每次 watch
的 CR 或资源变更时触发。
Reconcile 方法每次调用时都会传入 Request 变量,该变量由 Namespace/Name
组成,这个值用于从缓存中查询我们的对象。
Reconcile 方法根据处理结果不同可以返回不同的结果:
Reconcile 方法响应的含意 1 2 3 4 5 6 7 8 9 10 11 return ctrl.Result{}, errreturn ctrl.Result{Requeue: true }, nil return ctrl.Result{}, nil return ctrl.Result{RequeueAfter: nextRun.Sub(r.Now())}, nil
业务逻辑 业务逻辑主要是:
Watch
我们的自定义资源 DemoApplication
;
DemoApplication
不存在时直接返回;
DemoApplication
存在时,判断 Deployment
是否存在,不存在就创建;
如果已经存在就根据 spec
有没变化更新 Deployment
和 Service
。
获取自定义资源 获取自定义资源 1 2 3 4 5 6 7 8 9 application := &paascmftcomv1beta1.DemoApplication{} if err := r.Get(ctx, req.NamespacedName, application); err != nil { if apierrors.IsNotFound(err) { log.Info("DemoApplication resource not found. Ignoring since it must be deleted" ) return ctrl.Result{}, nil } log.Error(err, "Failed to get DemoApplication" ) return ctrl.Result{}, err }
维护 Deployment 和 Service 代码框架如下:
获取管理的 Deployment 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 found := &appsv1.Deployment{} err := r.Get(ctx, req.NamespacedName, found) if err != nil && apierrors.IsNotFound(err) { } else if err != nil { log.Error(err, "Failed to get Deployment" ) return ctrl.Result{}, err } oldSpec := paascmftcomv1beta1.DemoApplicationSpec{} if application.Annotations[AnnotationLastAppliedConfig] != "" { if err = json.Unmarshal([]byte (application.Annotations[AnnotationLastAppliedConfig]), &oldSpec); err != nil { return ctrl.Result{}, err } } if !reflect.DeepEqual(application.Spec, oldSpec) { } return ctrl.Result{}, nil
一般 Operator 都是用于管理特定业务的部署,Deployment 可以根据我们需要提供一个业务默认模板,这样只需要在 DemoApplication
开放一些可配置参数既可。
根据示例,我们创建 Deployment
的方法如下:
构造 Deployment 对接 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 func (r *DemoApplicationReconciler) deploymentForDemoApplication(application *paascmftcomv1beta1.DemoApplication) *appsv1.Deployment { labels := map [string ]string { "app" : application.Name, } containerPorts := []corev1.ContainerPort for _, port := range application.Spec.Ports { containerPorts = append (containerPorts, corev1.ContainerPort{ ContainerPort: port.TargetPort.IntVal, }) } return &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: application.Name, Namespace: application.Namespace, }, Spec: appsv1.DeploymentSpec{ Replicas: application.Spec.Replica, Selector: &metav1.LabelSelector{ MatchLabels: labels, }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: labels, }, Spec: corev1.PodSpec{ Containers: []corev1.Container{ { Name: application.Name, Image: application.Spec.Image, Ports: containerPorts, }, }, }, }, }, } }
类似的,Service
的创建逻辑如下:
构造 Service 对象 1 2 3 4 5 6 7 8 9 10 11 12 13 14 func (r *DemoApplicationReconciler) serviceForDemoApplication(application *paascmftcomv1beta1.DemoApplication) *corev1.Service { return &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: application.Name, Namespace: application.Namespace, }, Spec: corev1.ServiceSpec{ Ports: application.Spec.Ports, Selector: map [string ]string { "app" : application.Name, }, }, } }
整合后,创建 Deployment
和 Service
的代码如下:
创建 Deployment 和 Service 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 dep := r.deploymentForDemoApplication(application) log.Info("Creating a new Deployment" , "Deployment.Namespace" , dep.Namespace, "Deployment.Name" , dep.Name) if err := r.Create(ctx, dep); err != nil { log.Error(err, "Failed to create new Deployment" , "Deployment.Namespace" , dep.Namespace, "Deployment.Name" , dep.Name) return ctrl.Result{}, err } svc := r.serviceForDemoApplication(application) log.Info("Creating a new Service" , "Service.Namespace" , svc.Namespace, "Service.Name" , svc.Name) if err := r.Create(ctx, svc); err != nil { log.Error(err, "Failed to create new Service" , "Service.Namespace" , svc.Namespace, "Service.Name" , svc.Name) return ctrl.Result{}, err } cfg, _ := json.Marshal(application.Spec) if application.Annotations != nil { application.Annotations[AnnotationLastAppliedConfig] = string (cfg) } else { application.Annotations = map [string ]string { AnnotationLastAppliedConfig: string (cfg), } } if err := r.Update(ctx, application); err != nil { log.Error(err, "Failed to update DemoApplication" , "DemoApplication.Namespace" , application.Namespace, "DemoApplication.Name" , application.Name) return ctrl.Result{}, nil } return ctrl.Result{RequeueAfter: time.Minute}, nil
修改时和创建略有不同:
更新 Deployment 和 Service 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 newDep := r.deploymentForDemoApplication(application) oldDep := &appsv1.Deployment{} if err := r.Get(ctx, req.NamespacedName, oldDep); err != nil { return ctrl.Result{}, err } oldDep.Spec = newDep.Spec if err := r.Update(ctx, oldDep); err != nil { return ctrl.Result{}, err } newSvc := r.serviceForDemoApplication(application) oldSvc := &corev1.Service{} if err := r.Get(ctx, req.NamespacedName, oldSvc); err != nil { return ctrl.Result{}, err } oldSvc.Spec = newSvc.Spec if err := r.Update(ctx, oldSvc); err != nil { return ctrl.Result{}, err } cfg, _ := json.Marshal(application.Spec) if application.Annotations != nil { application.Annotations[AnnotationLastAppliedConfig] = string (cfg) } else { application.Annotations = map [string ]string { AnnotationLastAppliedConfig: string (cfg), } } if err := r.Update(ctx, application); err != nil { log.Error(err, "Failed to update DemoApplication" , "DemoApplication.Namespace" , application.Namespace, "DemoApplication.Name" , application.Name) return ctrl.Result{}, nil }
运行 本地运行
应用配置:
增加实验数据 1 kubectl apply -f config/samples/v1beta1_demoapplication.yaml
优化 现存的问题:
DemoApplication
删除时,对应的 Deployment
和 Service
不会跟着删除;
如果 Deployment
或 Service
被修改时不会自动恢复。
问题一 如果DemoApplication
删除时,对应的 Deployment
和 Service
不会跟着删除,那会导致Deployment
和 Service
资源变得无人管理。
就像 Deployment 和 Pod 的关系一样,如果 Deployment 被删除,但是 Pod 没被删除会非常难管理。
手动设置 OwnerReference 1 2 3 4 5 6 7 8 9 10 11 ObjectMeta: metav1.ObjectMeta{ Name: application.Name, Namespace: application.Namespace, OwnerReferences: []metav1.OwnerReference{ *metav1.NewControllerRef(application, schema.GroupVersionKind{ Group: paascmftcomv1beta1.GroupVersion.Group, Version: paascmftcomv1beta1.GroupVersion.Version, Kind: "DemoApplication" , }), }, },
更好的方法 controller-runtime
的 controllerutil
包提供了一个工具方法用于设置 Owner 引用。
使用工具方法 1 ctrl.SetControllerReference(application, dep, r.Scheme)
问题二 我们可以在 Reconcile
这个方法里对查询的到 Deployment
进行处理:
处理 Replicas 不一致问题 1 2 3 4 5 6 7 8 9 10 replicas := application.Spec.Replica if *replicas != *found.Spec.Replicas { found.Spec.Replicas = replicas if err := r.Update(ctx, found); err != nil { log.Error(err, "Failed to update Deployment" , "Deployment.Namespace" , found.Namespace, "Deployment.Name" , found.Name) return ctrl.Result{}, err } return ctrl.Result{Requeue: true }, nil }
不过如果只是这样写,并不会有效果。为什么?
Operator 里的资源 在 controller-runtime 包里,将资源区分为了两种类型:Primary Resources 和 Secondary Resources。
Primary Resources:主要资源,指的是控制器负责管理的资源,这里是 DemoApplication
;
Secondary Resources:次要资源,控制器也可能管理的资源,但主要是为了支持 DemoApplication
的实现,这里是 Deployment
和 Service
。
次要资源的修改会直接影响主要资源,所以我们的控制器必须要监控并保证次要资源和主要资源的一致。
这里相当于少了对资源的监控(Watch)动作:
注册 Reconciler 1 2 3 4 5 6 func (r *DemoApplicationReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&paascmftcomv1beta1.DemoApplication{}). Owns(&appsv1.Deployment{}). Complete(r) }
同时在 Reconcile
方法上加个以下注释:
配置权限注释
执行 make manifests
生成 RBAC 授权信息。
如果不配置这个,部署到集群中后会报权限不足的错误。本地启动因为是读取的 ~/.kube/config 配置,大多是集群管理员身份,可能不会发现这个问题。
总结 使用 Operator SDK 开发一个新的 Operator 可以按以下流程:
使用 SDK 创建一个新的 Operator 项目
通过添加自定义资源(CRD)定义新的资源 API
指定使用 SDK API 来 watch 的资源
定义 Operator 的协调(reconcile)逻辑
使用 Operator SDK 构建并生成 Operator 部署清单文件
当 CRD 需要创建其它资源来实现功能时,需要配置 OwnerReferences
,可以使用 ctrl.SetControllerReference
进行配置。
当需要 Watch 次要资源时,在 SetupWithManager
中设置 Owns
。
引用
Kubernetes Operator 快速入门教程
k8s operator controller 区别
Watching Secondary Resources Owned
by the Controller
Watching resources