2022-02
Argo
是Kubernetes上最受欢迎的工作流引擎,已经有大量的用户群体与软件生态。围绕着Workflow这个关键词,我们来一起初步了解Argo
。
Workflow engine for Kubernetes
官方的介绍分为四点(前两点描述的是基本原理,后两者描述的是特定应用的价值):
Argo
的工作流对标传统的CICD有很多亮点,但如果谈论其核心价值,主要集中在两点:
我们就以一个经典的CICD Workflow的发展历程来看:
第二与第三阶段的区分并不清晰,我个人会从 配置是否集中化 这个特点进行分析。
目前很多大公司的CICD仍处于第二阶段,但它们沉淀出了不少类似于Argo
工作流的能力。我们可以从以下三点进行思考:
Argo
体现出的价值,有不少类似的方案可以替代;Argo
项目的用户在社区中日趋增长,这其实体现出了一个趋势 - 互联网进入精耕细作的阶段。
在野蛮生长阶段遇到瓶颈时,公司会趋向于用扩增大量的人力或机器资源来解决问题;而在精耕细作阶段,随着Kubenetes为代表的基础平台能力的标准化,整个生态提供了丰富的能力集,技术人员更应重视遵循规范,把时间投入到合理的方向,来快速地迭代业务。
这时,以Argo
为代表的工作流引擎,能帮助整个开发体系落地自动化的规范,自然越来越受到欢迎。
最近有好几个朋友和我聊到Go语言里的接口interface相关的使用方法,发现了一个常见的误区。今天,我分享一下我的思考,希望能给大家带来启发。
// 接口定义
type Order interface {
Pay() error
}
// 实现1
func orderImpl1 struct{
Id int64
}
func (order *orderImpl1)Pay() error {
return nil
}
// 实现2
func orderImpl2 struct{}
func (order *orderImpl2)Pay() error {
return nil
}
这是一个很常见的接口与实现的示例。
在代码中,我们经常会对抽象进行断言,来获取更详细的信息,例如:
func Foo() {
// 在这里是一个接口
var order Order
// 断言是orderImpl1,并打印其中内容
if o,ok := order.(orderImpl1); ok {
fmt.Println(o.Id)
}
}
这段代码很清晰,让我们层层递进,思考一下这段代码背后的真正逻辑:程序要使用 接口背后的具体实现(orderImpl1中的Id字段)。
这种做法,就和接口所要解决的问题背道而驰了:接口是为了屏蔽具体的实现细节,而这里的代码又回退成了具体实现。所以,这个现象的真正问题是:接口抽象得不够完全。
这个解法很直接,我们增加一个接口方法即可,如:
type Order interface {
Pay() error
GetId() int64
}
但是,如果要区分具体实现,即orderImpl2没有Id字段,我们最好采用一个error字段进行区分:
type Order interface {
Pay() error
GetId() (int64, error)
}
上面GetId
这个方法,只是一个具体动作,按DDD的说法,这是一个贫血的模型。我们真正要关注的是 - 获取Id后真正的业务逻辑,将其封装成一个方法。
比如说,我们要获取这个Id后,想要根据这个Id取消这个订单,那么完全可以封装到一个Cancel()
函数中;
又比如说,我们仅仅想要打印具体实现的内部信息,那么完全可以实现一个Debug() string
方法,将想要的内容都拼成字符串返回出来。
今天讲的这个case在业务开发中非常常见,它是一种惯性思维解决问题的产物。我们无需苛求所有抽象都要到位,但心里一定要有明确的解决方案。
今天我们来看CNCF中另一款持续交付的项目 - Flux
。相对于Argo
,Flux
的应用范围不广,但它的功能更加简洁、使用起来也更为便捷。
gitops-toolkit
Flux
的核心实现非常清晰,主要分为两块:
这里有一个秀英语单词的技巧,在软件系统里经常会将定制化这个词,Customize用Kustomize代替。
官方的核心概念如下:https://fluxcd.io/docs/concepts/
GitOps is a way of managing your infrastructure and applications so that whole system is described declaratively and version controlled (most likely in a Git repository), and having an automated process that ensures that the deployed environment matches the state specified in a repository.
A Source defines the origin of a repository containing the desired state of the system and the requirements to obtain it (e.g. credentials, version selectors).
Reconciliation refers to ensuring that a given state (e.g. application running in the cluster, infrastructure) matches a desired state declaratively defined somewhere (e.g. a Git repository).
CICD相关软件目前的格局还不是很清晰,建议大家多花时间在选型上,尽可能地符合自己的业务场景,而不建议做过多的二次开发。Flux
是一个非常轻量级的CD项目,对接起来很方便,很适合无历史包袱的研发团队快速落地。
Go
语言的Goroutine特性广受好评,让初学者也能快速地实现并发。但随着不断地学习与深入,有很多开发者都陷入了对goroutine
、channel
、context
、select
等并发机制的迷惑中。
那么,我将自顶向下地介绍这部分的知识,帮助大家形成体系。具体的代码以下面这段为例:
// parent goroutine
func Foo() {
go SubFoo()
}
// children goroutine
func SubFoo() {
}
这里的Foo()
为父Goroutine,内部开启了一个子Goroutine - SubFoo()
。
父Goroutine 与 子Goroutine 最重要的交集 - 是两者的生命周期管理。包括三种:
这个生命周期的关系,体现了一种控制流的思想。
注意,这个时候不要去关注具体的数据或代码实现,初学者容易绕晕。
两个Goroutine互不影响的代码很简单,如同示例。
不过我们要注意一点,如果子goroutine需要context这个入参,尽量新建。这点我们看第二个例子就清楚了。
下面是一个最常见的用法,也就是利用了context:
// parent goroutine
func Foo() {
ctx, cancel := context.WithCancel(context.Background())
// 退出前执行,表示parent执行完了
defer cancel()
go SubFoo(ctx)
}
// children goroutine
func SubFoo(ctx context.Context) {
select {
case <-ctx.Done():
// parent完成后,就退出
return
}
}
当然,context并不是唯一的解法,我们也可以自建一个channel用来通知关闭。但综合考虑整个Go语言的生态,更建议大家尽可能地使用context,这里不扩散了。
延伸 - 如果1个parent要终止多个children时,context的这种方式依然适用。
这部分的逻辑也比较直观:
// parent goroutine
func Foo() {
var ch = make(chan struct{})
go SubFoo(ch)
select {
// 获取通知并退出
case <-ch:
return
}
}
// children goroutine
func SubFoo(ch chan<- struct{}) {
// 通知parent的channel
ch <- struct{}{}
}
如果1个parent产生了n个children时,又会有以下两种情况:
其中,前者的最常用的解决方案如下:
// parent goroutine
func Foo() {
var wg = new(sync.WaitGroup)
wg.Add(3)
go SubFoo(wg)
go SubFoo(wg)
go SubFoo(wg)
wg.Wait()
}
// children goroutine
func SubFoo(wg *sync.WaitGroup) {
defer wg.Done()
}
关于这两个延伸情况更多的解法,就留给大家自己去思考了,它们有不止一种解法。
从生命周期入手,我们能快速地形成代码的基本结构:
但在实际的开发场景中,parent和children的处理逻辑会有很多复杂的情况,导致我们很难像示例那样写出优雅的select
等方法,我们会在下期继续分析,但不会影响我们今天梳理出的框架。
通过上一篇,我们通过生命周期管理了解了父子进程的大致模型。
今天,我们将更进一步,分析优雅的Goroutine的核心语法 - select。
我们看一个官方的例子:
package main
import "fmt"
func fibonacci(c, quit chan int) {
x, y := 0, 1
for {
select {
case c <- x:
x, y = y, x+y
case <-quit:
fmt.Println("quit")
return
}
}
}
func main() {
c := make(chan int)
quit := make(chan int)
go func() {
for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
quit <- 0
}()
fibonacci(c, quit)
}
代码很长,我们聚焦于select这块,它实现了两个功能:
这时,如果你深入去理解这两个channel的用法,容易陷入对select理解的误区;而我们应该从更高的维度,去看这两个case中获取到数据后的操作,才能真正掌握。
我们要注意到,在case里代码运行的过程中,整个goroutine都是忙碌的(除非调用panic,return,os.Exit()等函数退出)。
以上面的代码为例,如果x, y = y, x+y
函数的处理耗时,远大于x
这个通道中塞入数据的速度,那么这个x
的写入处,将长期处于排队的阻塞状态。这时,不适合采用select这种模式。
所以说,select适合IO密集型逻辑,而不适合计算密集型。也就是说,select中的每个case,应尽量花费少的时间。IO密集型常指文件、网络等操作,它消耗的CPU很少、更多的时间在等待返回。
Go 的 select这个关键词,可以结合网络模型中的select进行理解。
这时,如果我们的父子进程里,就是有那么一长段的业务逻辑,那代码该怎么写呢?我们来看看下面这一段:
// children goroutine
func SubFoo(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-dataCh:
LongLogic()
}
}
}
func LongLogic() {
// 如1累加到10000000
}
由于LongLogic()
会花费很长的运行时间,所以当外部的context取消了,也就是父Goroutine发出通知可以结束了,这个子Goroutine是无法快速触发到<-ctx.Done()
的,因为它还在跑LongLogic()
里的代码。也就是说,子进程生命周期结束的时间点延长到LongLogic()
之后了。
所以,根本原因在于违背了我们上面说的原则,即在select的case/default里包含了计算密集型任务。
case里包含长逻辑不代表程序一定有问题,但或多或少地不符合select+channel的设计理念。
这时,我们再来写个长进程处理,整个代码结构如下:
func SubFoo(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-dataCh:
LongLogic()
case <-dataCh2:
LongLogic()
}
}
}
这时,dataCh和dataCh2会产生竞争,也就是两个通道的 写长期阻塞、读都在等待LongLogic执行完成。给channel加个buffer可以减轻这个问题,但无法根治,运行一段时间依旧阻塞。
有了上面代码的基础,改造思路比较直观了,将LongLogic
异步化:
// children goroutine
func SubFoo(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-dataCh:
go LongLogic()
case <-finishedCh:
fmt.Println("LongLogic finished")
}
}
}
func LongLogic() {
time.Sleep(time.Minute)
finishedCh <- struct{}{}
}
我们要注意一个点,如果LongLogic()
是一段需要CPU密集计算的代码,比如计算1累加到10000,它是没有办法通过channel等其余方式突然中止的。它具备一定的原子性 - 要么不跑,要么跑完,没有Channel的插手的地方。
而如果硬要中断LongLogic()
,那就是杀掉整个进程。
今天的内容是围绕着select这个关键词展开的,我们记住select代码块设计的核心要领 - IO密集型。Go语言的goroutine特性,更多地是为了解决IO密集型程序的问题所设计的编程语言,对计算密集型的任务较其它语言很难体现出其价值。
落到具体实践上,就是让每个case中代码的运行时间尽可能地短,快速回到for循环里的select去继续监听各个case中的channel。
上面这段代码比较粗糙,在具体工程中会遇到很多问题,比如无脑地开启了大量的LongLogic()
协程。我们会放在最后一讲再来细谈。