第八课 k8s源码学习和二次开发原理篇-KubeBuilder使用和Controller-runtime原理
tags:
- k8s
- 源码学习
categories:
- 源码学习
- 二次开发
文章目录
第一节 Operator初识
1.1 Operator介绍
- 基于 Kubernetes 平台,我们可以轻松的搭建一些简单的无状态应用,比如对于一些常见的 web apps 或是移动端后台程序,开发者甚至不用十分了解 Kubernetes 就可以利用 Deployment,Service 这些基本单元模型构建出自己的应用拓扑并暴露相应的服务。由于无状态应用的特性支持其在任意时刻进行部署、迁移、升级等操作,Kubernetes 现有的 ReplicaSets、Deployment、Services 等资源对象已经足够支撑起无状态应用对于自动扩缩容、实例间负载均衡等基本需求。
- 在管理简单的有状态应用时,我们可以利用社区原生的 StatefulSet 和 PV 模型来构建基础的应用拓扑,帮助实现相应的持久化存储,按顺序部署、顺序扩容、顺序滚动更新等特性。
- 而随着 Kubernetes 的蓬勃发展,在数据分析,机器学习等领域相继出现了一些场景更为复杂的分布式应用系统,也给社区和相关应用的开发运维人员提出了新的挑战:
- 不同场景下的分布式系统中通常维护了一套自身的模型定义规范,如何在 Kubernetes 平台中表达或兼容出应用原先的模型定义?
- 当应用系统发生扩缩容或升级时,如何保证当前已有实例服务的可用性?如何保证它们之间的可连通性?
- 如何去重新配置或定义复杂的分布式应用?是否需要大量的专业模板定义和复杂的命令操作?是否可以向无状态应用那样用一条 kubectl 命令就完成应用的更新?
- 如何备份和管理系统状态和应用数据?如何协调系统集群各成员间在不同生命周期的应用状态?
- 而所有的这些正是 Operator 希望解决的问题,本文我们将首先了解到 Operator 是什么,之后逐步了解到 Operator 的生态建设,Operator 的关键组件及其基本的工作原理,下面让我们来一探究竟吧。
1.2 Operator诞生和发展历程
- CoreOS 在 2016 年底提出了 Operator 的概念,当时的一段官方定义如下:
“An Operator represents human operational knowledge in software, to reliably manage an application.” - 对于普通的应用开发者或是大多数的应用 SRE 人员,在他们的日常开发运维工作中,都需要基于自身的应用背景和领域知识构建出相应的自动化任务满足业务应用的管理、监控、运维等需求。在这个过程中,Kubernetes 自身的基础模型元素已经无法支撑不同业务领域下复杂的自动化场景。
- 与此同时,在云原生的大背景下,生态系统是衡量一个平台成功与否的重要标准,而广大的应用开发者作为 Kubernetes 的最直接用户和服务推广者,他们的业务需求更是 Kubernetes 的生命线。于是,谷歌率先提出了
Third Party Resource
的概念,允许开发者根据业务需求以插件化形式扩展出相应的 K8s API 对象模型,同时提出了自定义 controller 的概念用于编写面向领域知识的业务控制逻辑,基于 Third Party Resource,Kubernetes 社区在 1.7 版本中提出了custom resources and controllers
的概念,(CRD+控制器)这正是** Operator 的核心概念**。 - 简单来看,Operator 定义了一组在 Kubernetes 集群中打包和部署复杂业务应用的方法,它可以方便地在不同集群中部署并在不同的客户间传播共享;同时 Operator 还提供了一套应用在运行时刻的监控管理方法,应用领域专家通过将业务关联的运维逻辑编写融入到 operator 自身控制器中,而一个运行中的 Operator 就像一个 7*24 不间断工作的优秀运维团队,它可以时刻监控应用自身状态和该应用在 Kubernetes 集群中的关注事件,并在毫秒级别基于期望终态做出对监听事件的处理,比如对应用的自动化容灾响应或是滚动升级等高级运维操作。
- 进一步讲,Operator 的设计和实现并不是千篇一律的,开发者可以根据自身业务需求,不断演进应用的自定义模型,同时面向具体的自动化场景在控制器中扩展相应的业务逻辑。很多 Operator 的出现都是起源于一些相对简单的部署和配置需求,并在后续演进中不断完善补充对复杂运维需求的自动化处理。
1.3 Operator的发展
- CoreOS 是最早的一批基于 Kubernetes 平台提供企业级容器服务解决方案的厂商之一,他们很敏锐地捕捉到了 TPR 和控制器模式对企业级应用开发者的重要价值;并很快基于 TPR 实现了历史上第一个 Operator:
etcd-operator
。它可以让用户通过短短的几条命令就快速的部署一个 etcd 集群,并且基于 kubectl 命令行一个普通的开发者就可以实现 etcd 集群滚动更新、灾备、备份恢复等复杂的运维操作,极大的降低了 etcd 集群的使用门槛,在很短的时间就成为当时 K8s 社区关注的焦点项目。 - 与此同时,Operator 以其插件化、自由化的模式特性,迅速吸引了大批的应用开发者,一时间很多市场上主流的分布式应用均出现了对应的 Operator 开源项目;而很多云厂商也迅速跟进,纷纷提出基于 Operator 进行应用上云的解决方案。Operator 在 Kubernetes 应用开发者中的热度大有星火燎原之势。
- 虽然 Operator 的出现受到了大量应用开发者的热捧,但是它的发展之路并不是一帆风顺的。对于谷歌团队而言,Controller 和控制器模式一直以来是作为其 API 体系内部实现的核心,从未暴露给终端应用开发者,Kubernetes 社区关注的焦点也更多的是集中在 PaaS 平台层面的核心能力。而 Operator 的出现打破了社区传统意义上的格局,对于谷歌团队而言,Controller 作为 Kubernetes 原生 API 的核心机制,应该交由系统内部的 Controller Manager 组件进行管理,并且遵从统一的设计开发模式,而不是像 Operator 那样交由应用开发者自由地进行 Controller 代码的编写。
- 另外 Operator 作为 Kubernetes 生态系统中与终端用户建立连接的桥梁,作为 Kubernetes 项目的设计和捐赠者,谷歌当然也不希望错失其中的主导权。同时 Brendan Burns 突然宣布加盟微软的消息,也进一步加剧了谷歌团队与 Operator 项目之间的矛盾。
- 于是,2017 年开始谷歌和 RedHat 开始在社区推广 Aggregated apiserver,应用开发者需要按照标准的社区规范编写一个自定义的 apiserver,同时定义自身应用的 API 模型;通过原生 apiserver 的配置修改,扩展 apiserver 会随着原生组件一同部署,并且限制自定义 API 在系统管理组件下进行统一管理。之后,谷歌和 RedHat 开始在社区大力推广使用聚合层扩展 Kubernetes API,同时建议废弃 TPR 相关功能。
- 然而,巨大的压力并没有让 Operator 昙花一现,就此消失。相反,**社区大量的 Operator 开发和使用者仍旧拥护着 Operator 清晰自由的设计理念,继续维护演进着自己的应用项目;**同时很多云服务提供商也并没有放弃 Operator,Operator 简洁的部署方式和易复制,自由开放的代码实现方式使其维护住了大量忠实粉丝。在用户的选择面前,强如谷歌,红帽这样的巨头也不得不做出退让。最终,TPR 并没有被彻底废弃,而是由
Custom Resource Definition
(简称 CRD)这个如今已经广为人知的资源模型范式代替。 - 2018 年初,RedHat 完成了对 CoreOS 的收购,并在几个月后发布了Operator Framework,通过提供 SDK 等管理工具的方式进一步降低了应用开发与 Kubernetes 底层 API 知识体系之间的依赖。至此,Operator 进一步巩固了其在 Kubernetes 应用开发领域的重要地位。
1.4 Operator 的社区与生态
- 一时间,基于不同种类的业务应用涌现了一大批优秀的开源 Operator 项目,我们可以找到其中很多的典型案例,例如对于运维要求较高的数据库集群,我们可以找到像 etcd、Mysql、PostgreSQL、Redis、Cassandra 等很多主流数据库应用对应的 Operator 项目,这些 Operator 的推出有效的简化了数据库应用在 Kubernetes 集群上的部署和运维工作;在监控方向,CoreOS 开发的 prometheus-operator 早日成为社区里的明星项目,Jaeger、FluentD、Grafana 等主流监控应用也或由官方或由开发者迅速推出相应的 Operator 并持续演进;在安全领域,Aqua、Twistlock、Sisdig 等各大容器安全厂商也不甘落后,通过 Operator 的形式简化了相对门槛较高的容器安全应用配置,另外社区中像 cert-manager、vault-operator 这些热门项目也在很多生产环境上得到了广泛应用。
- 可以说 Operator 在很短的时间就成为了分布式应用在 Kubernetes 集群中部署的事实标准,同时 Operator 应用如此广泛的覆盖面也使它超过了分布式应用这个原始的范畴,成为了整个 Kubernetes 云原生应用下一个重要存在。
- 随着 Operator 的持续发展,已有的社区共享模式已经渐渐不能满足广大开发者和 K8s 集群管理员的需求,如何快速寻找到业务需要的可用 Operator?如何给生态中大量的 Operator 定义一个统一的质量标准?这些都成为了刚刚完成收购的 RedHat 大佬们眼中亟需解决的问题。
- 于是我们看到 RedHat 在年初联合 AWS、谷歌、微软等大厂推出了 OperatorHub.io,希望其作为 Kubernetes 社区的延伸,向广大 operator 用户提供一个集中式的公共仓库,用户可以在仓库网站上轻松的搜索到自己业务应用对应的 Operator 并在向导页的指导下完成实例安装;同时,开发者还可以基于 Operator Framework 开发自己的 Operator 并上传分享至仓库中。
- Operator 开源生命周期流程图, 主要流程包括:
- 开发者首先使用 Operator SDK 创建一个 Operator 项目;
- 利用 SDK 我们可以生成 Operator 对应的脚手架代码,然后扩展相应业务模型和 API,最后实现业务逻辑完成一个 Operator 的代码编写;
- 参考社区测试指南进行业务逻辑的本地测试以及打包和发布格式的本地校验;
- 在完成测试后可以根据规定格式向社区提交PR,会有专人进行 review;
- 待社区审核通过完成 merge 后,终端用户就可以在 OperatorHub.io 页面上找到业务对应的 Operator;
- 用户可以在 OperatorHub.io 上找到业务 Operator 对应的说明文档和安装指南,通过简单的命令行操作即可在目标集群上完成 Operator 实例的安装;
- Operator 实例会根据配置创建所需的业务应用,OLM 和 Operator Metering 等组件可以帮助用户完成业务应用对应的运维和监控采集等管理操作。
第二节 kubebuilder介绍使用
2.1 kubebuilder介绍
- 在 Kubernetes 中开发 Operator 的时候,我们肯定需要使用到 CRD 以及对应的 Controller ,我们可以通过 CRD 定义业务相关的资源,并利用 controller 实现对应的业务逻辑,例如创建/删除 deployment,并根据资源变化做出相应的动作。但是如果全都去手动生成代码,然后再来编写业务代码显得有点麻烦,为此在社区中,为我们提供了基于 CRD 开发的框架,主要有 kubebuilder 以及 operator-sdk 两个框架,这两者大同小异,都是利用兴趣小组提供的
controller-runtime
项目实现的 Controller 逻辑,不同的是 CRD 资源的创建过程。
2.2 kubebuilder安装
- 我们先来简单介绍下 kubebuilder,kubebuilder 由 Kubernetes 特殊兴趣组(SIG) API Machinery 拥有和维护,能够帮助开发者创建 CRD 并生成 controller 脚手架,安装一下kubebuilder。
- 官方文档:https://book.kubebuilder.io/quick-start.html
- 中文手册: https://cloudnative.to/kubebuilder/quick-start.html
# 下载 kubebuilder 并解压
wget https://github.com/kubernetes-sigs/kubebuilder/releases/download/v2.3.1/kubebuilder_2.3.1_linux_amd64.tar.gz
# 将 kubebuilder 移动 PATH 路径中
sudo mv kubebuilder /usr/local/bin/kubebuilder
# 查看版本
kubebuilder version
2.3 kubebuilder项目创建
- 创建一个目录builder-demo,然后在里面运行
kubebuilder init
命令,初始化一个新项目。示例如下。
mkdir gitee.com/qnk8s/builder-demo
cd gitee.com/qnk8s/builder-demo
# 开启 go modules
export GO111MODULE=on
export GOPROXY=https://goproxy.cn
# 初始化项目 domian公司域名 owner作者 repo git地址
kubebuilder init --domain ydzs.io --owner qnhyn --repo gitee.com/qnk8s/builder-demo
- 新建一个 API,运行下面的命令,创建一个新的 API(组/版本)为 “webapp/v1”,并在上面创建新的 Kind(CRD) “Guestbook”。
- kubebuilder create api
# 创建CRD webapp/v1 Book
# y y
kubebuilder create api --group webapp --version v1 --kind Book
- 上面的命令会创建文件
api/v1/guestbook_types.go
,该文件中定义相关 API ,而针对于这一类型 (CRD) 的业务逻辑生成在controller/guestbook_controller.go
文件中。 - 可以根据自己的需求去修改资源对象的定义结构,修改
api/v1/guestbook_types.go
文件:** 修改完成后,make重新生成代码**。
// 修改结构体字段
type BookSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "make" to regenerate code after modifying this file
// Foo is an example field of Book. Edit Book_types.go to remove/update
Price int32 `json:"price"`
Title string `json:"title"`
}
- 在
controllers/book_controller.go
下Reconcile写一些业务逻辑。这里只是日志输出测试流程。
func (r *BookReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
_ = context.Background()
log := r.Log.WithValues("book", req.NamespacedName)
// your logic here
log.Info("book reconciling")
return ctrl.Result{
}, nil
}
2.4 kubebuilder项目运行测试
- 项目中
config/samples/webapp_v1_book.yaml
修改,想集群中注册CRD。
apiVersion: webapp.ydzs.io/v1
kind: Book
metadata:
name: book-sample
spec:
# Add fields here
price: 100
title: kubernetes
- Makefile文件中命令install可以安装CRD到本地集群中。
# 安装CRD到本地集群
make install
kubectl get crd
- 把控制器安装在集群中go run main.go, Makefile中可以看到命令make run。
# make run执行go run main.go控制器安装在集群中
make run
- 运行项目中
config/samples/webapp_v1_book.yaml
查看日志输出。
kubectl apply -f webapp_v1_book.yaml
kubectl delete -f webapp_v1_book.yaml
- Makefile中命令docker-build, 可以把当前控制器打包成一个docker镜像。
# docker-build 构建镜像
# docker-push 上传到镜像仓库
make docker-build docker-push IMG=<some-registry>/<project-name>:tag
# deploy 部署到k8s的pod中
make deploy IMG=<some-registry>/<project-name>:tag
# uninstall 删除CRD
make uninstall
第三节 controller-runtime原理之控制器
3.1 Controller 的实现
- controller-runtime(https://github.com/kubernetes-sigs/controller-runtime) 框架实际上是社区帮我们封装的一个控制器处理的框架,底层核心实现原理和我们前面去自定义一个 controller 控制器逻辑是一样的,只是在这个基础上新增了一些概念,开发者直接使用这个框架去开发控制器会更加简单方便。包括 kubebuilder、operator-sdk 这些框架其实都是在 controller-runtime 基础上做了一层封装,方便开发者快速生成项目的脚手架而已。下面我们就来分析下 controller-runtime 是如何实现的控制器处理。
- 首先我们还是去查看下控制器的定义以及控制器是如何启动的。控制器的定义结构体如下所示:
- 下载代码:https://github.com/kubernetes-sigs/controller-runtime
// pkg/internal/controller/controller.go
type Controller struct {
// Name 用于跟踪、记录和监控中控制器的唯一标识,必填字段
Name string
// 可以运行的最大并发 Reconciles 数量,默认值为1
MaxConcurrentReconciles int
// Reconciler 是一个可以随时调用对象的 Name/Namespace 的函数
// 确保系统的状态与对象中指定的状态一致,默认为 DefaultReconcileFunc 函数
Do reconcile.Reconciler
// 一旦控制器准备好启动,MakeQueue 就会为这个控制器构造工作队列
MakeQueue func() workqueue.RateLimitingInterface
// 队列通过监听来自 Infomer 的事件,添加对象键到队列中进行处理
// MakeQueue 属性就是来构造这个工作队列的
// 也就是前面我们讲解的工作队列,我们将通过这个工作队列来进行消费
Queue workqueue.RateLimitingInterface
// SetFields 用来将依赖关系注入到其他对象,比如 Sources、EventHandlers 以及 Predicates
SetFields func(i interface{
}) error
// 控制器同步信号量
mu sync.Mutex
// 允许测试减少 JitterPeriod,使其更快完成
JitterPeriod time.Duration
// 控制器是否已经启动
Started bool
// TODO(community): Consider initializing a logger with the Controller Name as the tag
// startWatches 维护了一个 sources、handlers 以及 predicates 列表以方便在控制器启动的时候启动
startWatches []watchDescription
// 日志记录
Log logr.Logger
}
- 上面的结构体就是 controller-runtime 中定义的控制器结构体,我们可以看到结构体中仍然有一个限速的工作队列,但是看上去没有资源对象的 Informer 或者 Indexer 的数据,实际上这里是通过下面的 startWatches 属性做了一层封装,该属性是一个 watchDescription 队列,一个 watchDescription 包含了所有需要 watch 的信息:
// pkg/internal/controller/controller.go
// watchDescription 包含所有启动 watch 操作所需的信息
type watchDescription struct {
src source.Source
handler handler.EventHandler
predicates []predicate.Predicate
}
- 整个控制器中最重要的两个函数是 Watch 与 Start,下面我们就来分析下他们是如何实现的。
3.2 Watch函数实现
// pkg/internal/controller/controller.go
func (c *Controller) Watch(src source.Source, evthdler handler.EventHandler, prct ...predicate.Predicate) error {
c.mu.Lock()
defer c.mu.Unlock()
// 注入 Cache 到参数中
if err := c.SetFields(src); err != nil {
return err
}
if err := c.SetFields(evthdler); err != nil {
return err
}
for _, pr := range prct {
if err := c.SetFields(pr); err != nil {
return err
}
}
// Controller 还没启动,把 watches 存放到本地然后返回
//
// 这些 watches 会被保存到控制器结构体中,直到调用 Start(...) 函数
if !c.Started {
c.startWatches = append(c.startWatches, watchDescription{
src: src, handler: evthdler, predicates: prct})
return nil
}
c.Log.Info("Starting EventSource", "source", src)
// 调用 src 的 Start 函数
return src.Start(evthdler, c.Queue, prct...)
}
- 上面的 Watch 函数可以看到最终是去调用的 Source 这个参数的 Start 函数,Source 是事件的源,如对资源对象的 Create、Update、Delete 操作,需要由
event.EventHandlers
将reconcile.Requests
入队列进行处理。- 使用 Kind 来处理来自集群的事件(如 Pod 创建、Pod 更新、Deployment 更新)。
- 使用 Channel 来处理来自集群外部的事件(如 GitHub Webhook 回调、轮询外部 URL)。
// pkg/source/source.go
type Source interface {
// Start 是一个内部函数
// 只应该由 Controller 调用,向 Informer 注册一个 EventHandler
// 将 reconcile.Request 放入队列
Start(handler.EventHandler, workqueue.RateLimitingInterface, ...predicate.Predicate) error
}
- 我们可以看到
source.Source
是一个接口,它是Controller.Watch
的一个参数,所以要看具体的如何实现的 Source.Start 函数,我们需要去看传入Controller.Watch
的参数,在 controller-runtime 中调用控制器的 Watch 函数的入口实际上位于pkg/builder/controller.go
文件中的doWatch()
函数:
// pkg/builder/controller.go
func (blder *Builder) doWatch() error {
// Reconcile type
src := &source.Kind{
Type: blder.forInput.object}
hdler := &handler.EnqueueRequestForObject{
}
allPredicates := append(blder.globalPredicates, blder.forInput.predicates...)
err := blder.ctrl.Watch(src, hdler, allPredicates...)
if err != nil {
return err
}
......
return nil
}
- 可以看到 Watch 的第一个参数是一个
source.Kind
的类型,该结构体就实现了上面的source.Source
接口:
// pkg/source/source.go
// Kind 用于提供来自集群内部的事件源,这些事件来自于 Watches(例如 Pod Create 事件)
type Kind struct {
// Type 是 watch 对象的类型,比如 &v1.Pod{}
Type runtime.Object
// cache 用于 watch 的 APIs 接口
cache cache.Cache
}
// 真正的 Start 函数实现
func (ks *Kind) Start(handler handler.EventHandler, queue workqueue.RateLimitingInterface,
prct ...predicate.Predicate) error {
// Type 在使用之前必须提前指定
if ks.Type == nil {
return fmt.Errorf("must specify Kind.Type")
}
// cache 也应该在调用 Start 之前被注入了
if ks.cache == nil {
return fmt.Errorf("must call CacheInto on Kind before calling Start")
}
// 从 Cache 中获取 Informer
// 并添加一个事件处理程序来添加队列
i, err := ks.cache.GetInformer(context.TODO(), ks.Type)
if err != nil {
if kindMatchErr, ok := err.(*meta.NoKindMatchError); ok {
log.Error(err, "if kind is a CRD, it should be installed before calling Start",
"kind", kindMatchErr.GroupKind