Go 1.19中值得关注的几个变化

本文永久链接 – https://tonybai.com/2022/08/22/some-changes-in-go-1-19

我们知道Go团队在2015年重新规定了团队发布版本的节奏,将Go大版本的发布频率确定为每年两次,发布窗口定为每年的2月与8月。而实现自举的Go 1.5版本是这一个节奏下发布的第一个版本。一般来说,Go团队都会在这两个窗口的中间位置发布版本,不过这几年也有意外,比如承载着泛型落地责任的Go 1.18版本就延迟了一个月发布。

就在我们以为Go 1.19版本不会很快发布的时候,美国时间2022年8月2日,Go核心团队正式发布了Go 1.19版本,这个时间不仅在发布窗口内而且相对于惯例还提前了。为什么呢?很简单,Go 1.19是一个“小”版本,当然这里的“小”是相对于Go 1.18那样的“大”而言的。Go 1.19版本开发周期仅有2个月左右(3~5月初),这样Go团队压缩了添加到Go 1.19版本中的feature数量。

不过尽管如此,Go 1.19中依然有几个值得我们重点关注的变化点,在这篇文章中我就和大家一起来看一下。

一. 综述

在6月份(那时Go 1.19版本已经Freeze),我曾写过一篇《Go 1.19新特性前瞻》,简要介绍了当时基本确定的Go 1.19版本的一些新特性,现在来看,和Go 1.19版本正式版差别不大。

  • 泛型方面

考虑到Go 1.18泛型刚刚落地,Go 1.18版本中的泛型并不是完全版。但Go 1.19版本也没有急于实现泛型设计文档)中那些尚未实现的功能特性,而是将主要精力放在了修复Go 1.18中发现的泛型实现问题上了,目的是夯实Go泛型的底座,为Go 1.20以及后续版本实现完全版泛型奠定基础(详细内容可查看《Go 1.19新特性前瞻》一文)。

  • 其他语法方面

无,无,无!重要的事情说三遍。

这样,Go 1.19依旧保持了Go1兼容性承诺。

  • 正式在linux上支持龙芯架构(GOOS=linux, GOARCH=loong64)

这一点不得不提,因为这一变化都是国内龙芯团队贡献的。不过目前龙芯支持的linux kernel版本最低也是5.19,意味着龙芯在老版本linux上还无法使用Go。

  • go env支持CGO_CFLAGS, CGO_CPPFLAGS, CGO_CXXFLAGS, CGO_FFLAGS, CGO_LDFLAGS和GOGCCFLAGS

当你想设置全局的而非包级的CGO构建选项时,可以通过这些新加入的CGO相关环境变量进行,这样就可以避免在每个使用Cgo的Go源文件中使用cgo指示符来分别设置了。

目前这些用于CGO的go环境变量的默认值如下(以我的macos上的默认值为例):

CGO_CFLAGS="-g -O2"
CGO_CPPFLAGS=""
CGO_CXXFLAGS="-g -O2"
CGO_FFLAGS="-g -O2"
CGO_LDFLAGS="-g -O2"
GOGCCFLAGS="-fPIC -arch x86_64 -m64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -fdebug-prefix-map=/var/folders/cz/sbj5kg2d3m3c6j650z0qfm800000gn/T/go-build1672298076=/tmp/go-build -gno-record-gcc-switches -fno-common"

其他更具体的变化就不赘述了,大家可以移步《Go 1.19新特性前瞻》看看。

下面我们重点说说Go 1.19中的两个重要变化:新版Go内存模型文档与Go运行时引入Soft memory limit

二. 修订Go内存模型文档

记得当年初学Go的时候,所有Go官方文档中最难懂的一篇就属Go内存模型文档(如下图)这一篇了,相信很多gopher在初看这篇文档时一定有着和我相似的赶脚^_^。


图:老版Go内存模型文档

注:查看老版Go内存模型文档的方法:godoc -http=:6060 -goroot /Users/tonybai/.bin/go1.18.3,其中godoc已经不随着go安装包分发了,需要你单独安装,命令为:go install golang.org/x/tools/cmd/godoc。

那么,老版内存模型文档说的是啥呢?为什么要修订?搞清这两个问题,我们就大致知道新版内存模型文档的意义了。 我们先来看看什么是编程语言的内存模型。

1. 什么是内存模型?

提到内存模型,我们要从著名计算机科学家,2013年图灵奖得主Leslie Lamport在1979发表的名为《How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs》的论文说起。

在这篇文章中,Lamport给出了多处理器计算机在共享内存的情况下并发程序正确运行的条件,即多处理器要满足顺序一致性(sequentially consistent)

文中提到:一个高速运行的处理器不一定按照程序指定的顺序(代码顺序)执行。如果一个处理器的执行结果(可能是乱序执行)与按照程序指定的顺序(代码顺序)执行的结果一致,那么说这个处理器是有序的(sequential)

而对于一个共享内存的多处理器而言,只有满足下面条件,才能被认定是满足顺序一致性的,即具备保证并发程序正确运行的条件:

  • 任何一次执行的结果,都和所有处理器的操作按照某个顺序执行的结果一致;
  • 在“某个顺序执行”中单独看每个处理器,每个处理器也都是按照程序指定的顺序(代码顺序)执行的。

顺序一致性就是一个典型的共享内存、多处理器的内存模型,这个模型保证了所有的内存访问都是以原子方式和按程序顺序进行的。下面是一个共享内存的顺序一致性的抽象机器模型示意图,图来自于《A Tutorial Introduction to the ARM and POWER Relaxed Memory Models》

根据顺序一致性,上面图中的抽象机器具有下面特点:

  • 没有本地的重新排序:每个硬件线程按照程序指定的顺序执行指令,完成每条指令(包括对共享内存的任何读或写)后再开始下一条。
  • 每条写入指令对所有线程(包括进行写入的线程)都是同时可见的。

从程序员角度来看,顺序一致性的内存模型是再理想不过了。所有读写操作直面内存,没有缓存,一个处理器(或硬件线程)写入内存的值,其他处理器(或硬件线程)便可以观察到。借助硬件提供的顺序一致性(SC),我们可以实现“所写即所得”。

但是这样的机器真的存在吗?并没有,至少在量产的机器中并没有。为什么呢?因为顺序一致性不利于硬件和软件的性能优化。真实世界的共享内存的多处理器计算机的常见机器模型是这样的,也称为Total Store Ordering,TSO模型(图来自《A Tutorial Introduction to the ARM and POWER Relaxed Memory Models》):

我们看到,在这种机器下,所有处理器仍连接到单个共享内存,但每个处理器的写内存操作从写入共享内存变为了先写入本处理器的写缓存队列(write buffer),这样处理器无需因要等待写完成(write complete)而被阻塞,并且一个处理器上的读内存操作也会先查阅本处理器的写缓存队列(但不会查询其他处理器的写缓存队列)。写缓存队列的存在极大提升了处理器写内存操作的速度。

但也正是由于写缓存的存在,TSO模型无法满足顺序一致性,比如:“每条写入指令对所有线程(包括进行写入的线程)都是同时可见的”这一特性就无法满足,因为写入本地写缓存队列的数据在未真正写入共享内存前只对自己可见,对其他处理器(硬件线程)并不可见。

根据Lamport的理论,在不满足SC的多处理器机器上程序员没法开发出可以正确运行的并发程序(Data Race Free, DRF),那么怎么办呢?处理器提供同步指令给开发者。对开发者而言,有了同步指令的非SC机器,具备了SC机器的属性。只是这一切对开发人员不是自动的/透明的了,需要开发人员熟悉同步指令,并在适当场合,比如涉及数据竞争Data Race的场景下正确使用,这大大增加了开发人员的心智负担。

开发人员通常不会直面硬件,这时就要求高级编程语言对硬件提供的同步指令进行封装并提供给开发人员,这就是编程语言的同步原语。而编程语言使用哪种硬件同步指令,封装出何种行为的同步原语,怎么应用这些原语,错误的应用示例等都是需要向编程语言的使用者进行说明的。而这些都将是编程语言内存模型文档的一部分。

如今主流的编程语言的内存模型都是顺序一致性(SC)模型,它为开发人员提供了一种理想的SC机器(虽然实际中的机器并非SC的),程序是建构在这一模型之上的。但就像前面说的,开发人员要想实现出正确的并发程序,还必须了解编程语言封装后的同步原语以及他们的语义。只要程序员遵循并发程序的同步要求合理使用这些同步原语,那么编写出来的并发程序就能在非SC机器上跑出顺序一致性的效果

知道了编程语言内存模型的含义后,接下来,我们再来看看老版Go内存模型文档究竟表述了什么。

2. Go内存模型文档

按照上面的说明,Go内存模型文档描述的应该是要用Go写出一个正确的并发程序所要具备的条件

再具体点,就像老版内存模型文档开篇所说的那样:Go内存模型规定了一些条件,一旦满足这些条件,当在一个goroutine中读取一个变量时,Go可以保证它可以观察到不同goroutine中对同一变量的写入所产生的新值

接下来,内存模型文档就基于常规的happens-before定义给出了Go提供的各种同步操作及其语义,包括:

  • 如果一个包p导入了包q,那么q的init函数的完成发生在p的任何函数的开始之前。
  • 函数main.main的开始发生在所有init函数完成之后。
  • 启动一个新的goroutine的go语句发生在goroutine的执行开始之前。
  • 一个channel上的发送操作发生在该channel的对应接收操作完成之前。
  • 一个channel的关闭发生在一个返回零值的接收之前(因为该channel已经关闭)。
  • 一个无缓冲的channel的接收发生在该channel的发送操作完成之前。
  • 一个容量为C的channel上的第k个接收操作发生在该channel第k+C个发送操作完成之前。
  • 对于任何sync.Mutex或sync.RWMutex变量l,当n<m时,第n次l.Unlock调用发生在第m次调用l.Lock()返回之前。
  • once.Do(f)中的f()调用发生在对once.Do(f)的任何一次调用返回之前。

接下来,内存模型文档还定义了一些误用同步原语的例子。

那么新内存模型文档究竟更新了哪些内容呢?我们继续往下看。

3. 修订后的内存模型文档都有哪些变化


图:修订后的Go内存模型文档

负责更新内存模型文档的Russ Cox首先增加了Go内存模型的总体方法(overall approach)

Go的总体方法在C/C++和Java/Js之间,既不像C/C++那样将存在Data race的程序定义为违法的,让编译器以未定义行为处置它,即运行时表现出任意可能的行为;又不完全像Java/Js那样尽量明确Data Race情况下各种语义,将Data race带来的影响限制在最小,使程序更为可靠。

Go对于一些存在data Race的情况会输出race报告并终止程序,比如多goroutine在未使用同步手段下对map的并发读写。除此之外,Go对其他存数据竞争的场景有明确的语义,这让程序更可靠,也更容易调试。

其次,新版Go内存模型文档增补了对这些年sync包新增的API的说明,比如: mutex.TryLock、mutex.TryRLock等。而对于sync.Cond、Map、Pool、WaitGroup等文档没有逐一描述,而是建议看API文档。

在老版内存模型文档中,没有对sync/atom包进行说明,新版文档增加了对atom包以及runtime.SetFinalizer的说明。

最后,文档除了提供不正确同步的例子,还增加了对不正确编译的例子的说明。

另外这里顺便提一下:Go 1.19在atomic包中引入了一些新的原子类型,包括: Bool, Int32, Int64, Uint32, Uint64, Uintptr和Pointer。这些新类型让开发人员在使用atomic包是更为方便,比如下面是Go 1.18和Go 1.19使用Uint64类型原子变量的代码对比:

对比Uint64的两种作法:

// Go 1.18

var i uint64
atomic.AddUint64(&i, 1)
_ = atomic.LoadUint64(&i)

vs.

// Go 1.19
var i atomic.Uint64 // 默认值为0
i.Store(17) // 也可以通过Store设置初始值
i.Add(1)
_ = i.Load()

atomic包新增的Pointer,避免了开发人员在使用原子指针时自己使用unsafe.Pointer进行转型的麻烦。同时atomic.Pointer是一个泛型类型,如果我没记错,它是Go 1.18加入comparable预定义泛型类型之后,第一次在Go中引入基于泛型的标准库类型:

// $GOROOT/src/sync/atomic/type.go

// A Pointer is an atomic pointer of type *T. The zero value is a nil *T.
type Pointer[T any] struct {
    _ noCopy
    v unsafe.Pointer
}

// Load atomically loads and returns the value stored in x.
func (x *Pointer[T]) Load() *T { return (*T)(LoadPointer(&x.v)) }

// Store atomically stores val into x.
func (x *Pointer[T]) Store(val *T) { StorePointer(&x.v, unsafe.Pointer(val)) }

// Swap atomically stores new into x and returns the previous value.
func (x *Pointer[T]) Swap(new *T) (old *T) { return (*T)(SwapPointer(&x.v, unsafe.Pointer(new))) }

// CompareAndSwap executes the compare-and-swap operation for x.
func (x *Pointer[T]) CompareAndSwap(old, new *T) (swapped bool) {
    return CompareAndSwapPointer(&x.v, unsafe.Pointer(old), unsafe.Pointer(new))
}

此外,atomic包新增的Int64和Uint64类型还有一个特质,那就是Go保证其地址可以自动对齐到8字节上(即地址可以被64整除),即便在32位平台上亦是如此,这可是连原生int64和uint64也尚无法做到的

go101在推特上分享了一个基于atomic Int64和Uint64的tip。利用go 1.19新增的atomic.Int64/Uint64,我们可以用下面方法保证结构体中某个字段一定是8 byte对齐的,即该字段的地址可以被64整除。

import "sync/atomic"

type T struct {
    _ [0]atomic.Int64
    x uint64 // 保证x是8字节对齐的
}

前面的代码中,为何不用_ atomic.Int64呢,为何用一个空数组呢,这是因为空数组在go中不占空间,大家可以试试输出上面结构体T的size,看看是不是8。

三. 引入Soft memory limit

1. 唯一GC调优选项:GOGC

近几个大版本,Go GC并没有什么大的改动/优化。和其他带GC的编程语言相比,Go GC算是一个奇葩的存在了:对于开发者而言,Go 1.19版本之前,Go GC的调优参数仅有一个:GOGC(也可以通过runtime/debug.SetGCPercent调整)。

GOGC默认值为100,通过调整它的值,我们可以调整GC触发的时机。计算下一次触发GC的堆内存size的公式如下:

// Go 1.18版本之前
目标堆大小 = (1+GOGC/100) * live heap // live heap为上一次GC标记后的堆上的live object的总size

// Go 1.18版本及之后
目标堆大小 = live heap + (live heap + GC roots) * GOGC / 100

注:Go 1.18以后将GC roots(包括goroutine栈大小和全局变量中的指针对象大小)纳入目标堆大小的计算

以Go 1.18之前的版本为例,当GOGC=100(默认值)时,如果某一次GC后的live heap为10M,那么下一次GC开启的目标堆heap size为20M,即在两次GC之间,应用程序可以分配10M的新堆对象。

可以说GOGC控制着GC的运行频率。当GOGC值设置的较小时,GC运行的就频繁一些,参与GC工作的cpu的比重就多一些;当GOGC的值设置的较大时,GC运行的就不那么频繁,相应的参与GC工作的cpu的比重就小一些,但要承担内存分配接近资源上限的风险。

这样一来,摆在开发者面前的问题就是:GOGC的值很难选,这唯一的调优选项也就成为了摆设。

同时,Go runtime是不关心资源limit的,只是会按照应用的需求持续分配内存,并在自身内存池不足的情况下向OS申请新的内存资源,直到内存耗尽(或到达平台给应用分配的memory limit)而被oom killed!

为什么有了GC,Go应用还是会因耗尽系统memory资源而被oom killed呢?我们继续往下看。

2. Pacer的问题

上面的触发GC的目标堆大小计算公式,在Go runtime内部被称为pacer算法,pacer中文有翻译成“起搏器”的,有译成“配速器”的。不管译成啥,总而言之它是用来控制GC触发节奏的

不过pacer目前的算法是无法保证你的应用不被OOM killed的,举个例子(见下图):

在这个例子中:

  • 一开始live heap始终平稳,净增的heap object保持0,即新分配的heap object与被清扫掉的heap object相互抵消。
  • 后续在(1)处出现一次target heap的跃升(从h/2->h),原因显然是live heap object变多了,都在用,即便触发GC也无法清除。不过此时target heap(h)是小于hard memory limit的;
  • 程序继续执行,在(2)处,又出现一次target heap的跃升(从h->2h),而live heap object也变多了,稳定在h,此时,target heap变为2h,高于hard memory limit了;
  • 后续程序继续执行,当live heap object到达(3)时,实际Go的堆内存(包括未清理的)超过了hard memory limit,但由于尚未到达target heap(2h),GC没有被执行,因此应用被oom killed。

我们看到这个例子中,并非Go应用真正需要那么多内存(如果有GC及时清理,live heap object就在(3)的高度),而是Pacer算法导致了没能及时触发GC

那么如何尽可能的避免oom killed呢?我们接下来看一下Go社区给出了两个“民间偏方”。

3. Go社区的GC调优方案

这两个“偏方”, 一个是twitch游戏公司给出的memory ballast(内存压舱石),另外一个则是像uber这样的大厂采用的自动GC动态调优方案。当然这两个方案不光是要避免oom,更是为了优化GC,提高程序的执行效率。

下面我们分别简单介绍一下。先来说说twitch公司的memory ballast。twitch的Go服务运行在具有64G物理内存的VM上,通过观察运维人员发现,服务常驻的物理内存消耗仅为400多M,但Go GC的启动却十分频繁,这导致其服务响应的时间较长。twitch的工程师考虑充分利用内存,降低GC的启动频率,从而降低服务的响应延迟。

于是他们想到了一种方法,他们在服务的main函数初始化环节像下面这样声明了一个10G容量的大切片,并保证这个切片在程序退出前不被GC释放掉:

func main() {
    // Create a large heap allocation of 10 GiB
    ballast := make([]byte, 10<<30)

    // Application execution continues
    // ...

    runtime.Keepalive(ballast)
    // ... ...
}

这个切片由于太大,将在堆上分配并被runtime跟踪,但这个切片并不会给应用带去实质上的物理内存消耗,这得益于os对应用进程内存的延迟簿记:只有读写的内存才会导致缺页中断并由OS为之分配物理内存。从类似top的工具来看,这10个G的字节仅会记录在VIRT/VSZ(虚拟内存)上,而不会记录在RES/RSS(常驻内存)上。

这样一来,根据前面Pacer算法的原理,触发GC的下一个目标堆大小就至少为20G,在Go服务分配堆内存到20G之前GC都不会被触发,所有cpu资源都会被用来处理业务,这也与twitch的实测结果一致(GC次数下降99%)。

一旦到了20G,由于之前观测的结果是服务仅需400多M物理内存,大量heap object会被回收,Go服务的live heap会回到400多M,但重新计算目标堆内存时,由于前面那个“压舱石”的存在,目标堆内存已经会在至少20G的水位上,就这样GC次数少了,GC少了,worker goroutine参加“劳役”的时间就少了,cpu利用率高了,服务响应的延迟也下来了。

注:“劳役”是指worker goroutine在mallocgc内存时被runtime强制“劳役”:停下自己手头的工作,去辅助GC做heap live object的mark。

不过使用该方案的前提是你对你的Go服务的内存消耗情况(忙闲时)有着精确的了解,这样才能结合硬件资源情况设定合理的ballast值。

按照Soft memory limit proposal的说法,该方案的弊端如下:

  • 不能跨平台移植,据说Windows上不适用(压舱石的值会直接反映为应用的物理内存占用);
  • 不能保证随着Go运行时的演进而继续正常工作(比如:一旦pacer算法发生了巨大变化);
  • 开发者需要进行复杂的计算并估计运行时内存开销以选择适合的ballast大小。

接下来我们再来看看自动GC动态调优方案。

去年12月,uber在其官方博客分享了uber内部使用的半自动化Go GC调优方案,按uber的说法,这种方案实施后帮助uber节省了70K cpu核的算力。其背后的原理依旧是从Pacer的算法公式出发,改变原先Go服务生命周期全程保持GOGC值静态不变的作法,在每次GC时,依据容器的内存限制以及当前的live heap size动态计算并设置GOGC值,从而实现对内存不足oom-killed的保护,同时最大程度利用内存,改善Gc对cpu的占用率。

显然这种方案更为复杂,需要有一个专家团队来保证这种自动调优的参数的设置与方案的实现。

4. 引入Soft memory limit

其实Go GC pacer的问题还有很多, Go核心团队开发者Michael Knyszek提了一个pacer问题综述的issue,将这些问题做了汇总。但问题还需一个一个解决,在Go 1.19这个版本中,Michael Knyszek就带来了他的Soft memory limit的解决方案

这个方案在runtime/debug包中添加了一个名为SetMemoryLimit的函数以及GOMEMLIMIT环境变量,通过他们任意一个都可以设定Go应用的Memory limit。

一旦设定了Memory limit,当Go堆大小达到“Memory limit减去非堆内存后的值”时,一轮GC会被触发。即便你手动关闭了GC(GOGC=off),GC亦是会被触发。

通过原理我们可以看到,这个特性最直接解决的就是oom-killed这个问题!就像前面pacer问题示意图中的那个例子,如果我们设定了一个比hard memory limit小一些的soft memory limit的值,那么在(3)那个点便不会出现oom-killed,因为在那之前soft memory limit就会触发一次GC,将一些无用的堆内存回收掉了。

但我们也要注意:soft memory limit不保证不会出现oom-killed,这个也很好理解。如果live heap object到达limit了,说明你的应用内存资源真的不够了,是时候扩内存条资源了,这个是GC无论如何都无法解决的问题。

但如果一个Go应用的live heap object超过了soft memory limit但还尚未被kill,那么此时GC会被持续触发,但为了保证在这种情况下业务依然能继续进行,soft memory limit方案保证GC最多只会使用50%的CPU算力,以保证业务处理依然能够得到cpu资源。

对于GC触发频率高,要降低GC频率的情况,soft memory limit的方案就是关闭GC(GOGC=off),这样GC只有当堆内存到达soft memory limit值时才会触发,可以提升cpu利用率。不过有一种情况,Go官方的GC guide中不建议你这么做,那就是当你的Go程序与其他程序共享一些有限的内存时。这时只需保留内存限制并将其设置为一个较小的合理值即可,因为它可能有助于抑制不良的瞬时行为。

那么多大的值是合理的soft memory limit值呢?在Go服务独占容器资源时,一个好的经验法则是留下额外的5-10%的空间,以考虑Go运行时不知道的内存来源。uber在其博客中设定的limit为资源上限的70%,也是一个不错的经验值。

四. 小结

也许Go 1.19因开发周期的压缩给大家带来的惊喜并不多。不过特性虽少,却都很实用,比如上面的soft memory limit,一旦用好,便可以帮助大家解决大问题。

而拥有正常开发周期的Go 1.20已经处于积极的开发中,从目前里程碑中规划的功能和改进来看,Go泛型语法将得到进一步的补全,向着完整版迈进,就这一点就值得大家期待了!

五. 参考资料

  • Russ Cox内存模型系列 – https://research.swtch.com/mm
  • 关于Go内存模型的讨论 – https://github.com/golang/go/discussions/47141
  • How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs- https://www.microsoft.com/en-us/research/publication/make-multiprocessor-computer-correctly-executes-multiprocess-programs
  • A Tutorial Introduction to the ARM and POWER Relaxed Memory Models- https://www.cl.cam.ac.uk/~pes20/ppc-supplemental/test7.pdf
  • Weak Ordering – A New Definition- https://people.eecs.berkeley.edu/~kubitron/courses/cs258-S08/handouts/papers/adve-isca90.pdf
  • Foundations of the C++ Concurrency Memory Model – https://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf
  • Go GC pacer原理 – https://docs.google.com/document/d/1wmjrocXIWTr1JxU-3EQBI6BK6KgtiFArkG47XK73xIQ/edit

“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

使用Go开发Kubernetes Operator:基本结构

本文永久链接 – https://tonybai.com/2022/08/15/developing-kubernetes-operators-in-go-part1

注:文章首图基于《Kubernetes Operators Explained》修改

几年前,我还称Kubernetes为服务编排和容器调度领域的事实标准,如今K8s已经是这个领域的“霸主”,地位无可撼动。不过,虽然Kubernetes发展演化到今天已经变得非常复杂,但是Kubernetes最初的数据模型、应用模式与扩展方式却依然有效。并且像Operator这样的应用模式和扩展方式日益受到开发者与运维者的欢迎。

我们的平台内部存在有状态(stateful)的后端服务,对有状态的服务的部署和运维是k8s operator的拿手好戏,是时候来研究一下operator了。

一. Operator的优点

kubernetes operator的概念最初来自CoreOS – 一家被红帽(redhat)收购的容器技术公司。

CoreOS在引入Operator概念的同时,也给出了Operator的第一批参考实现:etcd operatorprometheus operator

注:etcd于2013年由CoreOS以开源形式发布;prometheus作为首款面向云原生服务的时序数据存储与监控系统,由SoundCloud公司于2012年以开源的形式发布。

下面是CoreOS对Operator这一概念的诠释:Operator在软件中代表了人类的运维操作知识,通过它可以可靠地管理一个应用程序


图:CoreOS对operator的诠释(截图来自CoreOS官方博客归档)

Operator出现的初衷就是用来解放运维人员的,如今Operator也越来越受到云原生运维开发人员的青睐。

那么operator好处究竟在哪里呢?下面示意图对使用Operator和不使用Operator进行了对比:

通过这张图,即便对operator不甚了解,你也能大致感受到operator的优点吧。

我们看到在使用operator的情况下,对有状态应用的伸缩操作(这里以伸缩操作为例,也可以是其他诸如版本升级等对于有状态应用来说的“复杂”操作),运维人员仅需一个简单的命令即可,运维人员也无需知道k8s内部对有状态应用的伸缩操作的原理是什么。

在没有使用operator的情况下,运维人员需要对有状态应用的伸缩的操作步骤有深刻的认知,并按顺序逐个执行一个命令序列中的命令并检查命令响应,遇到失败的情况时还需要进行重试,直到伸缩成功。

我们看到operator就好比一个内置于k8s中的经验丰富运维人员,时刻监控目标对象的状态,把复杂性留给自己,给运维人员一个简洁的交互接口,同时operator也能降低运维人员因个人原因导致的操作失误的概率。

不过,operator虽好,但开发门槛却不低。开发门槛至少体现在如下几个方面:

  • 对operator概念的理解是基于对k8s的理解的基础之上的,而k8s自从2014年开源以来,变的日益复杂,理解起来需要一定时间投入;
  • 从头手撸operator很verbose,几乎无人这么做,大多数开发者都会去学习相应的开发框架与工具,比如:kubebuilderoperator framework sdk等;
  • operator的能力也有高低之分,operator framework就提出了一个包含五个等级的operator能力模型(CAPABILITY MODEL),见下图。使用Go开发高能力等级的operator需要对client-go这个kubernetes官方go client库中的API有深入的了解。


图:operator能力模型(截图来自operator framework官网)

当然在这些门槛当中,对operator概念的理解既是基础也是前提,而理解operator的前提又是对kubernetes的诸多概念要有深入理解,尤其是resource、resource type、API、controller以及它们之间的关系。接下来我们就来快速介绍一下这些概念。

二. Kubernetes resource、resource type、API和controller介绍

Kubernetes发展到今天,其本质已经显现:

  • Kubernetes就是一个“数据库”(数据实际持久存储在etcd中);
  • 其API就是“sql语句”;
  • API设计采用基于resource的Restful风格, resource type是API的端点(endpoint);
  • 每一类resource(即Resource Type)是一张“表”,Resource Type的spec对应“表结构”信息(schema);
  • 每张“表”里的一行记录就是一个resource,即该表对应的Resource Type的一个实例(instance);
  • Kubernetes这个“数据库”内置了很多“表”,比如Pod、Deployment、DaemonSet、ReplicaSet等;

下面是一个Kubernetes API与resource关系的示意图:

我们看到resource type有两类,一类的namespace相关的(namespace-scoped),我们通过下面形式的API操作这类resource type的实例:

VERB /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE - 操作某特定namespace下面的resouce type中的resource实例集合
VERB /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME - 操作某特定namespace下面的resource type中的某个具体的resource实例

另外一类则是namespace无关,即cluster范围(cluster-scoped)的,我们通过下面形式的API对这类resource type的实例进行操作:

VERB /apis/GROUP/VERSION/RESOURCETYPE - 操作resouce type中的resource实例集合
VERB /apis/GROUP/VERSION/RESOURCETYPE/NAME - 操作resource type中的某个具体的resource实例

我们知道Kubernetes并非真的只是一个“数据库”,它是服务编排和容器调度的平台标准,它的基本调度单元是Pod(也是一个resource type),即一组容器的集合。那么Pod又是如何被创建、更新和删除的呢?这就离不开控制器(controller)了。每一类resource type都有自己对应的控制器(controller)。以pod这个resource type为例,它的controller为ReplicasSet的实例。

控制器的运行逻辑如下图所示:


图:控制器运行逻辑(引自《Kubernetes Operators Explained》一文)

控制器一旦启动,将尝试获得resource的当前状态(current state),并与存储在k8s中的resource的期望状态(desired state,即spec)做比对,如果不一致,controller就会调用相应API进行调整,尽力使得current state与期望状态达成一致。这个达成一致的过程被称为协调(reconciliation),协调过程的伪代码逻辑如下:

for {
    desired := getDesiredState()
    current := getCurrentState()
    makeChanges(desired, current)
}

注:k8s中有一个object的概念?那么object是什么呢?它类似于Java Object基类或Ruby中的Object超类。不仅resource type的实例resource是一个(is-a)object,resource type本身也是一个object,它是kubernetes concept的实例。

有了上面对k8s这些概念的初步理解,我们下面就来理解一下Operator究竟是什么!

三. Operator模式 = 操作对象(CRD) + 控制逻辑(controller)

如果让运维人员直面这些内置的resource type(如deployment、pod等),也就是前面“使用operator vs. 不使用operator”对比图中的第二种情况, 运维人员面临的情况将会很复杂,且操作易错。

那么如果不直面内置的resource type,那么我们如何自定义resource type呢, Kubernetes提供了Custom Resource Definition,CRD(在coreos刚提出operator概念的时候,crd的前身是Third Party Resource, TPR)可以用于自定义resource type。

根据前面我们对resource type理解,定义CRD相当于建立新“表”(resource type),一旦CRD建立,k8s会为我们自动生成对应CRD的API endpoint,我们就可以通过yaml或API来操作这个“表”。我们可以向“表”中“插入”数据,即基于CRD创建Custom Resource(CR),这就好比我们创建Deployment实例,向Deployment“表”中插入数据一样。

和原生内置的resource type一样,光有存储对象状态的CR还不够,原生resource type有对应controller负责协调(reconciliation)实例的创建、伸缩与删除,CR也需要这样的“协调者”,即我们也需要定义一个controller来负责监听CR状态并管理CR创建、伸缩、删除以及保持期望状态(spec)与当前状态(current state)的一致。这个controller不再是面向原生Resource type的实例,而是面向CRD的实例CR的controller

有了自定义的操作对象类型(CRD),有了面向操作对象类型实例的controller,我们将其打包为一个概念:“Operator模式”,operator模式中的controller也被称为operator,它是在集群中对CR进行维护操作的主体。

四. 使用kubebuilder开发webserver operator

假设:此时你的本地开发环境已经具备访问实验用k8s环境的一切配置,通过kubectl工具可以任意操作k8s。

再深入浅出的概念讲解都不如一次实战对理解概念更有帮助,下面我们就来开发一个简单的Operator。

前面提过operator开发非常verbose,因此社区提供了开发工具和框架来帮助开发人员简化开发过程,目前主流的包括operator framework sdk和kubebuilder,前者是redhat开源并维护的一套工具,支持使用go、ansible、helm进行operator开发(其中只有go可以开发到能力级别5的operator,其他两种则不行);而kubebuilder则是kubernetes官方的一个sig(特别兴趣小组)维护的operator开发工具。目前基于operator framework sdk和go进行operator开发时,operator sdk底层使用的也是kubebuilder,所以这里我们就直接使用kubebuilder来开发operator。

按照operator能力模型,我们这个operator差不多处于2级这个层次,我们定义一个Webserver的resource type,它代表的是一个基于nginx的webserver集群,我们的operator支持创建webserver示例(一个nginx集群),支持nginx集群伸缩,支持集群中nginx的版本升级。

下面我们就用kubebuilder来实现这个operator!

1. 安装kubebuilder

这里我们采用源码构建方式安装,步骤如下:

$git clone git@github.com:kubernetes-sigs/kubebuilder.git
$cd kubebuilder
$make
$cd bin
$./kubebuilder version
Version: main.version{KubeBuilderVersion:"v3.5.0-101-g5c949c2e",
KubernetesVendor:"unknown",
GitCommit:"5c949c2e50ca8eec80d64878b88e1b2ee30bf0bc",
BuildDate:"2022-08-06T09:12:50Z", GoOs:"linux", GoArch:"amd64"}

然后将bin/kubebuilder拷贝到你的PATH环境变量中的某个路径下即可。

2. 创建webserver-operator工程

接下来,我们就可以使用kubebuilder创建webserver-operator工程了:

$mkdir webserver-operator
$cd webserver-operator
$kubebuilder init  --repo github.com/bigwhite/webserver-operator --project-name webserver-operator

Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.12.2
go: downloading k8s.io/client-go v0.24.2
go: downloading k8s.io/component-base v0.24.2
Update dependencies:
$ go mod tidy
Next: define a resource with:
kubebuilder create api

注:–repo指定go.mod中的module root path,你可以定义你自己的module root path。

3. 创建API,生成初始CRD

Operator包括CRD和controller,这里我们就来建立自己的CRD,即自定义的resource type,也就是API的endpoint,我们使用下面kubebuilder create命令来完成这个步骤:

$kubebuilder create api --version v1 --kind WebServer
Create Resource [y/n]
y
Create Controller [y/n]
y
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1/webserver_types.go
controllers/webserver_controller.go
Update dependencies:
$ go mod tidy
Running make:
$ make generate
mkdir -p /home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin
test -s /home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/controller-gen || GOBIN=/home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
/home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
Next: implement your new API and generate the manifests (e.g. CRDs,CRs) with:
$ make manifests

之后,我们执行make manifests来生成最终CRD对应的yaml文件:

$make manifests
/home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases

此刻,整个工程的目录文件布局如下:

$tree -F .
.
├── api/
│   └── v1/
│       ├── groupversion_info.go
│       ├── webserver_types.go
│       └── zz_generated.deepcopy.go
├── bin/
│   └── controller-gen*
├── config/
│   ├── crd/
│   │   ├── bases/
│   │   │   └── my.domain_webservers.yaml
│   │   ├── kustomization.yaml
│   │   ├── kustomizeconfig.yaml
│   │   └── patches/
│   │       ├── cainjection_in_webservers.yaml
│   │       └── webhook_in_webservers.yaml
│   ├── default/
│   │   ├── kustomization.yaml
│   │   ├── manager_auth_proxy_patch.yaml
│   │   └── manager_config_patch.yaml
│   ├── manager/
│   │   ├── controller_manager_config.yaml
│   │   ├── kustomization.yaml
│   │   └── manager.yaml
│   ├── prometheus/
│   │   ├── kustomization.yaml
│   │   └── monitor.yaml
│   ├── rbac/
│   │   ├── auth_proxy_client_clusterrole.yaml
│   │   ├── auth_proxy_role_binding.yaml
│   │   ├── auth_proxy_role.yaml
│   │   ├── auth_proxy_service.yaml
│   │   ├── kustomization.yaml
│   │   ├── leader_election_role_binding.yaml
│   │   ├── leader_election_role.yaml
│   │   ├── role_binding.yaml
│   │   ├── role.yaml
│   │   ├── service_account.yaml
│   │   ├── webserver_editor_role.yaml
│   │   └── webserver_viewer_role.yaml
│   └── samples/
│       └── _v1_webserver.yaml
├── controllers/
│   ├── suite_test.go
│   └── webserver_controller.go
├── Dockerfile
├── go.mod
├── go.sum
├── hack/
│   └── boilerplate.go.txt
├── main.go
├── Makefile
├── PROJECT
└── README.md

14 directories, 40 files

4. webserver-operator的基本结构

忽略我们此次不关心的诸如leader election、auth_proxy等,我将这个operator例子的主要部分整理到下面这张图中:

图中的各个部分就是使用kubebuilder生成的operator的基本结构

webserver operator主要由CRD和controller组成:

  • CRD

图中的左下角的框框就是上面生成的CRD yaml文件:config/crd/bases/my.domain_webservers.yaml。CRD与api/v1/webserver_types.go密切相关。我们在api/v1/webserver_types.go中为CRD定义spec相关字段,之后make manifests命令可以解析webserver_types.go中的变化并更新CRD的yaml文件。

  • controller

从图的右侧部分可以看出,controller自身就是作为一个deployment部署在k8s集群中运行的,它监视CRD的实例CR的运行状态,并在Reconcile方法中检查预期状态与当前状态是否一致,如果不一致,则执行相关操作。

  • 其它

图中左上角是有关controller的权限的设置,controller通过serviceaccount访问k8s API server,通过role.yaml和role_binding.yaml设置controller的角色和权限。

5. 为CRD spec添加字段(field)

为了实现Webserver operator的功能目标,我们需要为CRD spec添加一些状态字段。前面说过,CRD与api中的webserver_types.go文件是同步的,我们只需修改webserver_types.go文件即可。我们在WebServerSpec结构体中增加Replicas和Image两个字段,它们分别用于表示webserver实例的副本数量以及使用的容器镜像:

// api/v1/webserver_types.go

// WebServerSpec defines the desired state of WebServer
type WebServerSpec struct {
    // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
    // Important: Run "make" to regenerate code after modifying this file

    // The number of replicas that the webserver should have
    Replicas int `json:"replicas,omitempty"`

    // The container image of the webserver
    Image string `json:"image,omitempty"`

    // Foo is an example field of WebServer. Edit webserver_types.go to remove/update
    Foo string `json:"foo,omitempty"`
}

保存修改后,执行make manifests重新生成config/crd/bases/my.domain_webservers.yaml

$cat my.domain_webservers.yaml
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  annotations:
    controller-gen.kubebuilder.io/version: v0.9.2
  creationTimestamp: null
  name: webservers.my.domain
spec:
  group: my.domain
  names:
    kind: WebServer
    listKind: WebServerList
    plural: webservers
    singular: webserver
  scope: Namespaced
  versions:
  - name: v1
    schema:
      openAPIV3Schema:
        description: WebServer is the Schema for the webservers API
        properties:
          apiVersion:
            description: 'APIVersion defines the versioned schema of this representation
              of an object. Servers should convert recognized schemas to the latest
              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
            type: string
          kind:
            description: 'Kind is a string value representing the REST resource this
              object represents. Servers may infer this from the endpoint the client
              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
            type: string
          metadata:
            type: object
          spec:
            description: WebServerSpec defines the desired state of WebServer
            properties:
              foo:
                description: Foo is an example field of WebServer. Edit webserver_types.go
                  to remove/update
                type: string
              image:
                description: The container image of the webserver
                type: string
              replicas:
                description: The number of replicas that the webserver should have
                type: integer
            type: object
          status:
            description: WebServerStatus defines the observed state of WebServer
            type: object
        type: object
    served: true
    storage: true
    subresources:
      status: {}

一旦定义完CRD,我们就可以将其安装到k8s中:

$make install
/home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s /home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/kustomize || { curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash -s -- 3.8.7 /home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin; }
{Version:kustomize/v3.8.7 GitCommit:ad092cc7a91c07fdf63a2e4b7f13fa588a39af4f BuildDate:2020-11-11T23:14:14Z GoOs:linux GoArch:amd64}
kustomize installed to /home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/kustomize
/home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/kustomize build config/crd | kubectl apply -f -
customresourcedefinition.apiextensions.k8s.io/webservers.my.domain created

检查安装情况:

$kubectl get crd|grep webservers
webservers.my.domain                                             2022-08-06T21:55:45Z

6. 修改role.yaml

在开始controller开发之前,我们先来为controller后续的运行“铺平道路”,即设置好相应权限。

我们在controller中会为CRD实例创建对应deployment和service,这样就要求controller有操作deployments和services的权限,这样就需要我们修改role.yaml,增加service account: controller-manager 操作deployments和services的权限:

// config/rbac/role.yaml
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  creationTimestamp: null
  name: manager-role
rules:
- apiGroups:
  - my.domain
  resources:
  - webservers
  verbs:
  - create
  - delete
  - get
  - list
  - patch
  - update
  - watch
- apiGroups:
  - my.domain
  resources:
  - webservers/finalizers
  verbs:
  - update
- apiGroups:
  - my.domain
  resources:
  - webservers/status
  verbs:
  - get
  - patch
  - update
- apiGroups:
  - apps
  resources:
  - deployments
  verbs:
  - create
  - delete
  - get
  - list
  - patch
  - update
  - watch
- apiGroups:
  - apps
  - ""
  resources:
  - services
  verbs:
  - create
  - delete
  - get
  - list
  - patch
  - update
  - watch

修改后的role.yaml先放在这里,后续与controller一并部署到k8s上。

7. 实现controller的Reconcile(协调)逻辑

kubebuilder为我们搭好了controller的代码架子,我们只需要在controllers/webserver_controller.go中实现WebServerReconciler的Reconcile方法即可。下面是Reconcile的一个简易流程图,结合这幅图理解代码就容易的多了:

下面是对应的Reconcile方法的代码:

// controllers/webserver_controller.go

func (r *WebServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := r.Log.WithValues("Webserver", req.NamespacedName)

    instance := &mydomainv1.WebServer{}
    err := r.Get(ctx, req.NamespacedName, instance)
    if err != nil {
        if errors.IsNotFound(err) {
            // Request object not found, could have been deleted after reconcile request.
            // Return and don't requeue
            log.Info("Webserver resource not found. Ignoring since object must be deleted")
            return ctrl.Result{}, nil
        }

        // Error reading the object - requeue the request.
        log.Error(err, "Failed to get Webserver")
        return ctrl.Result{RequeueAfter: time.Second * 5}, err
    }

    // Check if the webserver deployment already exists, if not, create a new one
    found := &appsv1.Deployment{}
    err = r.Get(ctx, types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, found)
    if err != nil && errors.IsNotFound(err) {
        // Define a new deployment
        dep := r.deploymentForWebserver(instance)
        log.Info("Creating a new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
        err = r.Create(ctx, dep)
        if err != nil {
            log.Error(err, "Failed to create new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
            return ctrl.Result{RequeueAfter: time.Second * 5}, err
        }
        // Deployment created successfully - return and requeue
        return ctrl.Result{Requeue: true}, nil
    } else if err != nil {
        log.Error(err, "Failed to get Deployment")
        return ctrl.Result{RequeueAfter: time.Second * 5}, err
    }

    // Ensure the deployment replicas and image are the same as the spec
    var replicas int32 = int32(instance.Spec.Replicas)
    image := instance.Spec.Image

    var needUpd bool
    if *found.Spec.Replicas != replicas {
        log.Info("Deployment spec.replicas change", "from", *found.Spec.Replicas, "to", replicas)
        found.Spec.Replicas = &replicas
        needUpd = true
    }

    if (*found).Spec.Template.Spec.Containers[0].Image != image {
        log.Info("Deployment spec.template.spec.container[0].image change", "from", (*found).Spec.Template.Spec.Containers[0].Image, "to", image)
        found.Spec.Template.Spec.Containers[0].Image = image
        needUpd = true
    }

    if needUpd {
        err = r.Update(ctx, found)
        if err != nil {
            log.Error(err, "Failed to update Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
            return ctrl.Result{RequeueAfter: time.Second * 5}, err
        }
        // Spec updated - return and requeue
        return ctrl.Result{Requeue: true}, nil
    }

    // Check if the webserver service already exists, if not, create a new one
    foundService := &corev1.Service{}
    err = r.Get(ctx, types.NamespacedName{Name: instance.Name + "-service", Namespace: instance.Namespace}, foundService)
    if err != nil && errors.IsNotFound(err) {
        // Define a new service
        srv := r.serviceForWebserver(instance)
        log.Info("Creating a new Service", "Service.Namespace", srv.Namespace, "Service.Name", srv.Name)
        err = r.Create(ctx, srv)
        if err != nil {
            log.Error(err, "Failed to create new Servie", "Service.Namespace", srv.Namespace, "Service.Name", srv.Name)
            return ctrl.Result{RequeueAfter: time.Second * 5}, err
        }
        // Service created successfully - return and requeue
        return ctrl.Result{Requeue: true}, nil
    } else if err != nil {
        log.Error(err, "Failed to get Service")
        return ctrl.Result{RequeueAfter: time.Second * 5}, err
    }

    // Tbd: Ensure the service state is the same as the spec, your homework

    // reconcile webserver operator in again 10 seconds
    return ctrl.Result{RequeueAfter: time.Second * 10}, nil
}

这里大家可能发现了:原来CRD的controller最终还是将CR翻译为k8s原生Resource,比如service、deployment等。CR的状态变化(比如这里的replicas、image等)最终都转换成了deployment等原生resource的update操作,这就是operator的精髓!理解到这一层,operator对大家来说就不再是什么密不可及的概念了。

有些朋友可能也会发现,上面流程图中似乎没有考虑CR实例被删除时对deployment、service的操作,的确如此。不过对于一个7×24小时运行于后台的服务来说,我们更多关注的是其变更、伸缩、升级等操作,删除是优先级最低的需求。

8. 构建controller image

controller代码写完后,我们就来构建controller的image。通过前文我们知道,这个controller其实就是运行在k8s中的一个deployment下的pod。我们需要构建其image并通过deployment部署到k8s中。

kubebuilder创建的operator工程中包含了Makefile,通过make docker-build即可构建controller image。docker-build使用golang builder image来构建controller源码,不过如果不对Dockerfile稍作修改,你很难编译过去,因为默认GOPROXY在国内无法访问。这里最简单的改造方式是使用vendor构建,下面是改造后的Dockerfile:

# Build the manager binary
FROM golang:1.18 as builder

ENV GOPROXY https://goproxy.cn
WORKDIR /workspace
# Copy the Go Modules manifests
COPY go.mod go.mod
COPY go.sum go.sum
COPY vendor/ vendor/
# cache deps before building and copying source so that we don't need to re-download as much
# and so that source changes don't invalidate our downloaded layer
#RUN go mod download

# Copy the go source
COPY main.go main.go
COPY api/ api/
COPY controllers/ controllers/

# Build
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=vendor -a -o manager main.go

# Use distroless as minimal base image to package the manager binary
# Refer to https://github.com/GoogleContainerTools/distroless for more details
#FROM gcr.io/distroless/static:nonroot
FROM katanomi/distroless-static:nonroot
WORKDIR /
COPY --from=builder /workspace/manager .
USER 65532:65532

ENTRYPOINT ["/manager"]

下面是构建的步骤:

$go mod vendor
$make docker-build

test -s /home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/controller-gen || GOBIN=/home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
/home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
/home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...
KUBEBUILDER_ASSETS="/home/tonybai/.local/share/kubebuilder-envtest/k8s/1.24.2-linux-amd64" go test ./... -coverprofile cover.out
?       github.com/bigwhite/webserver-operator    [no test files]
?       github.com/bigwhite/webserver-operator/api/v1    [no test files]
ok      github.com/bigwhite/webserver-operator/controllers    4.530s    coverage: 0.0% of statements
docker build -t bigwhite/webserver-controller:latest .
Sending build context to Docker daemon  47.51MB
Step 1/15 : FROM golang:1.18 as builder
 ---> 2d952adaec1e
Step 2/15 : ENV GOPROXY https://goproxy.cn
 ---> Using cache
 ---> db2b06a078e3
Step 3/15 : WORKDIR /workspace
 ---> Using cache
 ---> cc3c613c19c6
Step 4/15 : COPY go.mod go.mod
 ---> Using cache
 ---> 5fa5c0d89350
Step 5/15 : COPY go.sum go.sum
 ---> Using cache
 ---> 71669cd0fe8e
Step 6/15 : COPY vendor/ vendor/
 ---> Using cache
 ---> 502b280a0e67
Step 7/15 : COPY main.go main.go
 ---> Using cache
 ---> 0c59a69091bb
Step 8/15 : COPY api/ api/
 ---> Using cache
 ---> 2b81131c681f
Step 9/15 : COPY controllers/ controllers/
 ---> Using cache
 ---> e3fd48c88ccb
Step 10/15 : RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=vendor -a -o manager main.go
 ---> Using cache
 ---> 548ac10321a2
Step 11/15 : FROM katanomi/distroless-static:nonroot
 ---> 421f180b71d8
Step 12/15 : WORKDIR /
 ---> Running in ea7cb03027c0
Removing intermediate container ea7cb03027c0
 ---> 9d3c0ea19c3b
Step 13/15 : COPY --from=builder /workspace/manager .
 ---> a4387fe33ab7
Step 14/15 : USER 65532:65532
 ---> Running in 739a32d251b6
Removing intermediate container 739a32d251b6
 ---> 52ae8742f9c5
Step 15/15 : ENTRYPOINT ["/manager"]
 ---> Running in 897893b0c9df
Removing intermediate container 897893b0c9df
 ---> e375cc2adb08
Successfully built e375cc2adb08
Successfully tagged bigwhite/webserver-controller:latest

注:执行make命令之前,先将Makefile中的IMG变量初值改为IMG ?= bigwhite/webserver-controller:latest

构建成功后,执行make docker-push将image推送到镜像仓库中(这里使用了docker公司提供的公共仓库)。

9. 部署controller

之前我们已经通过make install将CRD安装到k8s中了,接下来再把controller部署到k8s上,我们的operator就算部署完毕了。执行make deploy即可实现部署:

$make deploy
test -s /home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/controller-gen || GOBIN=/home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
/home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s /home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/kustomize || { curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash -s -- 3.8.7 /home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin; }
cd config/manager && /home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/kustomize edit set image controller=bigwhite/webserver-controller:latest
/home/tonybai/test/go/operator/kubebuilder/webserver-operator/bin/kustomize build config/default | kubectl apply -f -
namespace/webserver-operator-system created
customresourcedefinition.apiextensions.k8s.io/webservers.my.domain unchanged
serviceaccount/webserver-operator-controller-manager created
role.rbac.authorization.k8s.io/webserver-operator-leader-election-role created
clusterrole.rbac.authorization.k8s.io/webserver-operator-manager-role created
clusterrole.rbac.authorization.k8s.io/webserver-operator-metrics-reader created
clusterrole.rbac.authorization.k8s.io/webserver-operator-proxy-role created
rolebinding.rbac.authorization.k8s.io/webserver-operator-leader-election-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/webserver-operator-manager-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/webserver-operator-proxy-rolebinding created
configmap/webserver-operator-manager-config created
service/webserver-operator-controller-manager-metrics-service created
deployment.apps/webserver-operator-controller-manager created

我们看到deploy不仅会安装controller、serviceaccount、role、rolebinding,它还会创建namespace,也会将crd安装一遍。也就是说deploy是一个完整的operator安装命令。

注:使用make undeploy可以完整卸载operator相关resource。

我们用kubectl logs查看一下controller的运行日志:

$kubectl logs -f deployment.apps/webserver-operator-controller-manager -n webserver-operator-system
1.6600280818476188e+09    INFO    controller-runtime.metrics    Metrics server is starting to listen    {"addr": "127.0.0.1:8080"}
1.6600280818478029e+09    INFO    setup    starting manager
1.6600280818480284e+09    INFO    Starting server    {"path": "/metrics", "kind": "metrics", "addr": "127.0.0.1:8080"}
1.660028081848097e+09    INFO    Starting server    {"kind": "health probe", "addr": "[::]:8081"}
I0809 06:54:41.848093       1 leaderelection.go:248] attempting to acquire leader lease webserver-operator-system/63e5a746.my.domain...
I0809 06:54:57.072336       1 leaderelection.go:258] successfully acquired lease webserver-operator-system/63e5a746.my.domain
1.6600280970724037e+09    DEBUG    events    Normal    {"object": {"kind":"Lease","namespace":"webserver-operator-system","name":"63e5a746.my.domain","uid":"e05aaeb5-4a3a-4272-b036-80d61f0b6788","apiVersion":"coordination.k8s.io/v1","resourceVersion":"5238800"}, "reason": "LeaderElection", "message": "webserver-operator-controller-manager-6f45bc88f7-ptxlc_0e960015-9fbe-466d-a6b1-ff31af63a797 became leader"}
1.6600280970724993e+09    INFO    Starting EventSource    {"controller": "webserver", "controllerGroup": "my.domain", "controllerKind": "WebServer", "source": "kind source: *v1.WebServer"}
1.6600280970725305e+09    INFO    Starting Controller    {"controller": "webserver", "controllerGroup": "my.domain", "controllerKind": "WebServer"}
1.660028097173026e+09    INFO    Starting workers    {"controller": "webserver", "controllerGroup": "my.domain", "controllerKind": "WebServer", "worker count": 1}

可以看到,controller已经成功启动,正在等待一个WebServer CR的相关事件(比如创建)!下面我们就来创建一个WebServer CR!

10. 创建WebServer CR

webserver-operator项目中有一个CR sample,位于config/samples下面,我们对其进行改造,添加我们在spec中加入的字段:

// config/samples/_v1_webserver.yaml 

apiVersion: my.domain/v1
kind: WebServer
metadata:
  name: webserver-sample
spec:
  # TODO(user): Add fields here
  image: nginx:1.23.1
  replicas: 3

我们通过kubectl创建该WebServer CR:

$cd config/samples
$kubectl apply -f _v1_webserver.yaml
webserver.my.domain/webserver-sample created

观察controller的日志:

1.6602084232243123e+09  INFO    controllers.WebServer   Creating a new Deployment   {"Webserver": "default/webserver-sample", "Deployment.Namespace": "default", "Deployment.Name": "webserver-sample"}
1.6602084233446114e+09  INFO    controllers.WebServer   Creating a new Service  {"Webserver": "default/webserver-sample", "Service.Namespace": "default", "Service.Name": "webserver-sample-service"}

我们看到当CR被创建后,controller监听到相关事件,创建了对应的Deployment和service,我们查看一下为CR创建的Deployment、三个Pod以及service:

$kubectl get service
NAME                       TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
kubernetes                 ClusterIP   172.26.0.1     <none>        443/TCP        22d
webserver-sample-service   NodePort    172.26.173.0   <none>        80:30010/TCP   2m58s

$kubectl get deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
webserver-sample   3/3     3            3           4m44s

$kubectl get pods
NAME                               READY   STATUS    RESTARTS   AGE
webserver-sample-bc698b9fb-8gq2h   1/1     Running   0          4m52s
webserver-sample-bc698b9fb-vk6gw   1/1     Running   0          4m52s
webserver-sample-bc698b9fb-xgrgb   1/1     Running   0          4m52s

我们访问一下该服务:

$curl http://192.168.10.182:30010
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

服务如预期返回响应!

11. 伸缩、变更版本和Service自愈

接下来我们来对CR做一些常见的运维操作。

  • 副本数由3变为4

我们将CR的replicas由3改为4,对容器实例做一次扩展操作:

// config/samples/_v1_webserver.yaml 

apiVersion: my.domain/v1
kind: WebServer
metadata:
  name: webserver-sample
spec:
  # TODO(user): Add fields here
  image: nginx:1.23.1
  replicas: 4

然后通过kubectl apply使之生效:

$kubectl apply -f _v1_webserver.yaml
webserver.my.domain/webserver-sample configured

上述命令执行后,我们观察到operator的controller日志如下:

1.660208962767797e+09   INFO    controllers.WebServer   Deployment spec.replicas change {"Webserver": "default/webserver-sample", "from": 3, "to": 4}

稍后,查看pod数量:

$kubectl get pods
NAME                               READY   STATUS    RESTARTS   AGE
webserver-sample-bc698b9fb-8gq2h   1/1     Running   0          9m41s
webserver-sample-bc698b9fb-v9gvg   1/1     Running   0          42s
webserver-sample-bc698b9fb-vk6gw   1/1     Running   0          9m41s
webserver-sample-bc698b9fb-xgrgb   1/1     Running   0          9m41s

webserver pod副本数量成功从3扩为4。

  • 变更webserver image版本

我们将CR的image的版本从nginx:1.23.1改为nginx:1.23.0,然后执行kubectl apply使之生效。

我们查看controller的响应日志如下:

1.6602090494113188e+09  INFO    controllers.WebServer   Deployment spec.template.spec.container[0].image change {"Webserver": "default/webserver-sample", "from": "nginx:1.23.1", "to": "nginx:1.23.0"}

controller会更新deployment,导致所辖pod进行滚动升级:

$kubectl get pods
NAME                               READY   STATUS              RESTARTS   AGE
webserver-sample-bc698b9fb-8gq2h   1/1     Running             0          10m
webserver-sample-bc698b9fb-vk6gw   1/1     Running             0          10m
webserver-sample-bc698b9fb-xgrgb   1/1     Running             0          10m
webserver-sample-ffcf549ff-g6whk   0/1     ContainerCreating   0          12s
webserver-sample-ffcf549ff-ngjz6   0/1     ContainerCreating   0          12s

耐心等一小会儿,最终的pod列表为:

$kubectl get pods
NAME                               READY   STATUS    RESTARTS   AGE
webserver-sample-ffcf549ff-g6whk   1/1     Running   0          6m22s
webserver-sample-ffcf549ff-m6z24   1/1     Running   0          3m12s
webserver-sample-ffcf549ff-ngjz6   1/1     Running   0          6m22s
webserver-sample-ffcf549ff-t7gvc   1/1     Running   0          4m16s
  • service自愈:恢复被无删除的Service

我们来一次“误操作”,将webserver-sample-service删除,看看controller能否帮助service自愈:

$kubectl delete service/webserver-sample-service
service "webserver-sample-service" deleted

查看controller日志:

1.6602096994710526e+09  INFO    controllers.WebServer   Creating a new Service  {"Webserver": "default/webserver-sample", "Service.Namespace": "default", "Service.Name": "webserver-sample-service"}

我们看到controller检测到了service被删除的状态,并重建了一个新service!

访问新建的service:

$curl http://192.168.10.182:30010
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

可以看到service在controller的帮助下完成了自愈!

五. 小结

本文对Kubernetes Operator的概念以及优点做了初步的介绍,并基于kubebuilder这个工具开发了一个具有2级能力的operator。当然这个operator离完善还有很远的距离,其主要目的还是帮助大家理解operator的概念以及实现套路。

相信你阅读完本文后,对operator,尤其是其基本结构会有一个较为清晰的了解,并具备开发简单operator的能力!

文中涉及的源码可以在这里下载 – https://github.com/bigwhite/experiments/tree/master/webserver-operator。

六. 参考资料

  • kubernetes operator 101, Part 1: Overview and key features – https://developers.redhat.com/articles/2021/06/11/kubernetes-operators-101-part-1-overview-and-key-features
  • Kubernetes Operators 101, Part 2: How operators work – https://developers.redhat.com/articles/2021/06/22/kubernetes-operators-101-part-2-how-operators-work
  • Operator SDK: Build Kubernetes Operators – https://developers.redhat.com/blog/2020/04/28/operator-sdk-build-kubernetes-operators-and-deploy-them-on-openshift
  • kubernetes doc: Custom Resources – https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/
  • kubernetes doc: Operator pattern – https://kubernetes.io/docs/concepts/extend-kubernetes/operator/
  • kubernetes doc: API concepts – https://kubernetes.io/docs/reference/using-api/api-concepts/
  • Introducing Operators: Putting Operational Knowledge into Software 第一篇有关operator的文章 by coreos – https://web.archive.org/web/20170129131616/https://coreos.com/blog/introducing-operators.html
  • CNCF Operator白皮书v1.0 – https://github.com/cncf/tag-app-delivery/blob/main/operator-whitepaper/v1/Operator-WhitePaper_v1-0.md
  • Best practices for building Kubernetes Operators and stateful apps – https://cloud.google.com/blog/products/containers-kubernetes/best-practices-for-building-kubernetes-operators-and-stateful-apps
  • A deep dive into Kubernetes controllers – https://docs.bitnami.com/tutorials/a-deep-dive-into-kubernetes-controllers
  • Kubernetes Operators Explained – https://blog.container-solutions.com/kubernetes-operators-explained
  • 书籍《Kubernetes Operator》 – https://book.douban.com/subject/34796009/
  • 书籍《Programming Kubernetes》 – https://book.douban.com/subject/35498478/
  • Operator SDK Reaches v1.0 – https://cloud.redhat.com/blog/operator-sdk-reaches-v1.0
  • What is the difference between kubebuilder and operator-sdk – https://github.com/operator-framework/operator-sdk/issues/1758
  • Kubernetes Operators in Depth – https://www.infoq.com/articles/kubernetes-operators-in-depth/
  • Get started using Kubernetes Operators – https://developer.ibm.com/learningpaths/kubernetes-operators/
  • Use Kubernetes operators to extend Kubernetes’ functionality – https://developer.ibm.com/learningpaths/kubernetes-operators/operators-extend-kubernetes/
  • memcached operator – https://github.com/operator-framework/operator-sdk-samples/tree/master/go/memcached-operator

“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats