Opdemo
Kubernetes Operator 快速入门教程(Kubernetes Operator 101)
Install / Use
/learn @cnych/OpdemoREADME
opdemo
Kubernetes Operator 快速入门教程(Kubernetes Operator 101)
在 Kubernetes 的监控方案中我们经常会使用到一个Promethues Operator的项目,该项目可以让我们更加方便的去使用 Prometheus,而不需要直接去使用最原始的一些资源对象,比如 Pod、Deployment,随着 Prometheus Operator 项目的成功,CoreOS 公司开源了一个比较厉害的工具:Operator Framework,该工具可以让开发人员更加容易的开发 Operator 应用。
在本篇文章中我们会为大家介绍一个简单示例来演示如何使用 Operator Framework 框架来开发一个 Operator 应用。
Kubernetes Operator
Operator 是由 CoreOS 开发的,用来扩展 Kubernetes API,特定的应用程序控制器,它用来创建、配置和管理复杂的有状态应用,如数据库、缓存和监控系统。Operator 基于 Kubernetes 的资源和控制器概念之上构建,但同时又包含了应用程序特定的领域知识。创建Operator 的关键是CRD(自定义资源)的设计。
Kubernetes 1.7 版本以来就引入了自定义控制器的概念,该功能可以让开发人员扩展添加新功能,更新现有的功能,并且可以自动执行一些管理任务,这些自定义的控制器就像 Kubernetes 原生的组件一样,Operator 直接使用 Kubernetes API进行开发,也就是说他们可以根据这些控制器内部编写的自定义规则来监控集群、更改 Pods/Services、对正在运行的应用进行扩缩容。
Operator Framework
Operator Framework 同样也是 CoreOS 开源的一个用于快速开发 Operator 的工具包,该框架包含两个主要的部分:
- Operator SDK: 无需了解复杂的 Kubernetes API 特性,即可让你根据你自己的专业知识构建一个 Operator 应用。
- Operator Lifecycle Manager OLM: 帮助你安装、更新和管理跨集群的运行中的所有 Operator(以及他们的相关服务)

Workflow
Operator SDK 提供以下工作流来开发一个新的 Operator:
-
- 使用 SDK 创建一个新的 Operator 项目
-
- 通过添加自定义资源(CRD)定义新的资源 API
-
- 指定使用 SDK API 来 watch 的资源
-
- 定义 Operator 的协调(reconcile)逻辑
-
- 使用 Operator SDK 构建并生成 Operator 部署清单文件
Demo
我们平时在部署一个简单的 Webserver 到 Kubernetes 集群中的时候,都需要先编写一个 Deployment 的控制器,然后创建一个 Service 对象,通过 Pod 的 label 标签进行关联,最后通过 Ingress 或者 type=NodePort 类型的 Service 来暴露服务,每次都需要这样操作,是不是略显麻烦,我们就可以创建一个自定义的资源对象,通过我们的 CRD 来描述我们要部署的应用信息,比如镜像、服务端口、环境变量等等,然后创建我们的自定义类型的资源对象的时候,通过控制器去创建对应的 Deployment 和 Service,是不是就方便很多了,相当于我们用一个资源清单去描述了 Deployment 和 Service 要做的两件事情。
这里我们将创建一个名为 AppService 的 CRD 资源对象,然后定义如下的资源清单进行应用部署:
apiVersion: app.example.com/v1
kind: AppService
metadata:
name: nginx-app
spec:
size: 2
image: nginx:1.7.9
ports:
- port: 80
targetPort: 80
nodePort: 30002
通过这里的自定义的 AppService 资源对象去创建副本数为2的 Pod,然后通过 nodePort=30002 的端口去暴露服务,接下来我们就来一步一步的实现我们这里的这个简单的 Operator 应用。
开发环境
环境需求
要开发 Operator 自然 Kubernetes 集群是少不了的,还需要 Golang 的环境,这里的安装就不多说了,然后还需要一个 Go 语言的依赖管理工具包:dep,由于 Operator SDK 是使用的 dep 该工具包,所以需要我们提前安装好,可以查看资料:https://github.com/golang/dep,另外一个需要说明的是,由于 dep 去安装的时候需要去谷歌的网站拉取很多代码,所以正常情况下的话是会失败的,需要做什么工作大家应该清楚吧?要科学。
安装 operator-sdk
operator sdk 安装方法非常多,我们可以直接在 github 上面下载需要使用的版本,然后放置到 PATH 环境下面即可,当然也可以将源码 clone 到本地手动编译安装即可,如果你是 Mac,当然还可以使用常用的 brew 工具进行安装:
$ brew install operator-sdk
......
$ operator-sdk version
operator-sdk version: v0.7.0
$ go version
go version go1.11.4 darwin/amd64
我们这里使用的 sdk 版本是v0.7.0,其他安装方法可以参考文档:https://github.com/operator-framework/operator-sdk/blob/master/doc/user/install-operator-sdk.md
演示
创建新项目
环境准备好了,接下来就可以使用 operator-sdk 直接创建一个新的项目了,命令格式为: operator-sdk new <project-name>
按照上面我们预先定义的 CRD 资源清单,我们这里可以这样创建:
# 创建项目目录
$ mkdir -p operator-learning
# 设置项目目录为 GOPATH 路径
$ cd operator-learning && export GOPATH=$PWD
$ mkdir -p $GOPATH/src/github.com/cnych
$ cd $GOPATH/src/github.com/cnych
# 使用 sdk 创建一个名为 opdemo 的 operator 项目
$ operator-sdk new opdemo
......
# 该过程需要科学上网,需要花费很长时间,请耐心等待
......
$ cd opdemo && tree -L 2
.
├── Gopkg.lock
├── Gopkg.toml
├── build
│ ├── Dockerfile
│ ├── _output
│ └── bin
├── cmd
│ └── manager
├── deploy
│ ├── crds
│ ├── operator.yaml
│ ├── role.yaml
│ ├── role_binding.yaml
│ └── service_account.yaml
├── pkg
│ ├── apis
│ └── controller
├── vendor
│ ├── cloud.google.com
│ ├── contrib.go.opencensus.io
│ ├── github.com
│ ├── go.opencensus.io
│ ├── go.uber.org
│ ├── golang.org
│ ├── google.golang.org
│ ├── gopkg.in
│ ├── k8s.io
│ └── sigs.k8s.io
└── version
└── version.go
23 directories, 8 files
到这里一个全新的 Operator 项目就新建完成了。
项目结构
使用operator-sdk new命令创建新的 Operator 项目后,项目目录就包含了很多生成的文件夹和文件。
- Gopkg.toml Gopkg.lock — Go Dep 清单,用来描述当前 Operator 的依赖包。
- cmd - 包含 main.go 文件,使用 operator-sdk API 初始化和启动当前 Operator 的入口。
- deploy - 包含一组用于在 Kubernetes 集群上进行部署的通用的 Kubernetes 资源清单文件。
- pkg/apis - 包含定义的 API 和自定义资源(CRD)的目录树,这些文件允许 sdk 为 CRD 生成代码并注册对应的类型,以便正确解码自定义资源对象。
- pkg/controller - 用于编写所有的操作业务逻辑的地方
- vendor - golang vendor 文件夹,其中包含满足当前项目的所有外部依赖包,通过 go dep 管理该目录。
我们主要需要编写的是pkg目录下面的 api 定义以及对应的 controller 实现。
添加 API
接下来为我们的自定义资源添加一个新的 API,按照上面我们预定义的资源清单文件,在 Operator 相关根目录下面执行如下命令:
$ operator-sdk add api --api-version=app.example.com/v1 --kind=AppService
添加完成后,我们可以看到类似于下面的这样项目结构:

添加控制器
上面我们添加自定义的 API,接下来可以添加对应的自定义 API 的具体实现 Controller,同样在项目根目录下面执行如下命令:
$ operator-sdk add controller --api-version=app.example.com/v1 --kind=AppService
这样整个 Operator 项目的脚手架就已经搭建完成了,接下来就是具体的实现了。
自定义 API
打开源文件pkg/apis/app/v1/appservice_types.go,需要我们根据我们的需求去自定义结构体 AppServiceSpec,我们最上面预定义的资源清单中就有 size、image、ports 这些属性,所有我们需要用到的属性都需要在这个结构体中进行定义:
type AppServiceSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "operator-sdk generate k8s" to regenerate code after modifying this file
// Add custom validation using kubebuilder tags: https://book.kubebuilder.io/beyond_basics/generating_crd.html
Size *int32 `json:"size"`
Image string `json:"image"`
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
Envs []corev1.EnvVar `json:"envs,omitempty"`
Ports []corev1.ServicePort `json:"ports,omitempty"`
}
代码中会涉及到一些包名的导入,由于包名较多,所以我们会使用一些别名进行区分,主要的包含下面几个:
import (
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
appv1 "github.com/cnych/opdemo/pkg/apis/app/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
这里的 resources、envs、ports 的定义都是直接引用的"k8s.io/api/core/v1"中定义的结构体,而且需要注意的是我们这里使用的是ServicePort,而不是像传统的 Pod 中定义的 ContanerPort,这是因为我们的资源清单中不仅要描述容器的 Port,还要描述 Service 的 Port。
然后一个比较重要的结构体AppServiceStatus用来描述资源的状态,当然我们可以根据需要去自定义状态的描述,我这里就偷懒直接使用 Deployment 的状态了:
type AppServiceStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
// Important: Run "operator-sdk generate k8s" to regenerate code after modifying this file
// Add custom validation using kubebuilder tags: https://book.kubebuilder.io/beyond_basics/generating_crd.html
appsv1.DeploymentStatus `json:",inline"`
}
定义完成后,在项目根目录下面执行如下命令:
$ operator-sdk generate k8s
改命令是用来根据我们自定义的 API 描述来自动生成一些代码,目录pkg/apis/app/v1/下面以zz_generated开头的文件就是自动生成的代码,里面的内容并不需要我们去手动编写。
这样我们就算完成了对自定义资源对象的 API 的声明。
实现业务逻辑
上面 API 描述声明完成了,接下来就需要我们来进行具体的业务逻辑实现了,编写具体的 controller 实现,打开源文件pkg/controller/appservice/appservice_controller.go,需要我们去更改的地方也不是很多,核心的就是Reconcile方法,该方法就是去不断的 watch 资源的状态,然后根据状态的不同去实现各种操作逻辑,核心代码如下:
func (r *ReconcileAppService) Reconcile(request reconcile.Request) (reconcile.Result, error) {
reqLogger := log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name)
reqLogger.Info("Reconciling AppService")
// Fetch the AppService instance
instance := &appv1.AppService{}
err := r.client.Get(context.TODO(), request.NamespacedName, instance)
if err != nil {
if errors.IsNotFound(err) {
// Request object not found, could have been deleted after reconcile request.
// Owned objects are automatically garbage collected. For additional cleanup logic use finalizers.
// Return and don't requeue
return reconcile.Result{}, nil
}
// Error reading the object - requeue the request.
return reconcile.Result{}, err
}
if instance.DeletionTimestamp != nil {
return reconcile.Result{}, err
}
// 如果不存在,则创建关联资源
// 如果存在,判断是否需要更新
// 如果需要更新,则直接更新
// 如果不需要更新,则正常返回
deploy := &appsv1.Deployment{}
if err := r.client.Get(context.TODO(), request.NamespacedName, deploy); err != nil && errors.IsNotFound(err) {
// 创建关联资源
// 1. 创建 Deploy
deploy := resources.NewDeploy(instance)
if err := r.client.Create(context.TODO(), deploy); err != nil {
return reconcile.Result{}, err
}
// 2. 创建 Service
service := resources.NewService(instance)
if err := r.client.Create(context.TODO(), service); err != nil {
return reconcile.Result{}, err
}
// 3. 关联 Annotations
data, _ := json.Marshal(instance.Spec)
if instance.Annotations != nil {
instance.Annotations["spec"] = string(data)
} else {
instance.Annotations = map[string]string{"spec": string(data)}
}
if err := r.client.Update(context.TODO(), instance); err != nil {
return reconcile.Result{}, nil
}
return reconcile.Result{}, nil
}
oldspec := appv1.AppServiceSpec{}
if err := json.Unmarshal([]byte(instance.Annotations["spec"]), oldspec); err != nil {
return reconcile.Result{}, err
}
if !reflect.DeepEqual(instance.Spec, oldspec) {
// 更新关联资源
newDeploy := resources.NewDeploy(instance)
oldDeploy := &appsv1.Deployment{}
if err := r.client.Get(context.TODO(), request.NamespacedName, oldDeploy); err != nil {
return reconcile.Result{}, err
}
oldDeploy.Spec = newDeploy.Spec
if err := r.client.Update(context.TODO(), oldDeploy); err != nil {
return reconcile.Result{}, err
}
newService := resources.NewService(instance)
oldService := &corev1.Service{}
if err := r.client.Get(context.TODO(), request.NamespacedName, oldService); err != nil {
return reconcile.Result{}, err
}
oldService.Spec = newService.Spec
if err := r.client.Update(context.TODO(), oldService); err != nil {
return reconcile.Result{}, err
}
return reconcile.Result{}, nil
}
return reconcile.Result{}, nil
}
上面就是业务逻辑实现的核心代码,逻辑很简单,

