Golang中的自动伸缩和自防御设计

时间:2024-11-30 08:32:49

Raygun服务由许多活动组件构成,每个组件用于特定的任务。其中一个模块是用Golang编写的,负责对iOS崩溃报告进行处理。简而言之,它接受本机iOS崩溃报告,查找相关的dSYM文件,并生成开发者可以阅读并理解的堆栈跟踪信息。

dSYM-worker进程的操作非常简单,它通过Redis队列接收作业并执行,然后不断重复。这个dSYN-worker进程在一台机器上运行,作业处理的速率及负载相对合理,但仍有一些情况需要运维人员随时待命维护:

  • 负载飙升。每隔一段时间,通常是在周末,用户更多地使用iOS设备,iOS崩溃报告的数量可能会远远超过预设的数量。这种场景下,需要手动启动更多dSYM-worker进程来处理任务并降低平均负载。每个新启动的进程作为消费者消费任务队列,它使用Golang     Redis队列库分布式并发地完成多个作业。

  • 不响应(进程假死)。也就是说,进程仍然在运行,但是没有执行任何操作。大多数情况下,可能是由于死锁造成的。糟糕的是,从监控上看它仍在运行(但没有消费队列中的任务),因此只有当队列达到阈值时才会发出警报。这种情况下,则需要手动终止假死进程,并启动一个新的进程。(也可能需要启动更多的进程,为了加速消费队列中的任务)。

  • 意外终止。进程崩溃并完全关闭。这种情况从未发生在dSYM-worker上,但在更新代码并发布时还是有可能发生。一旦发生,监控会发出进程已死的警报,需要再次手动启动该进程。

在深夜或凌晨处理这些问题非常痛苦,不论是对运维还是相关的负责人。以上这些人工操作理论上都应当是自动化的,因此有了以下的这些实践。

原理(Theory)

我们需要某种自动伸缩的能力来处理变化的、突增的负载,以某种方式检测并重启无响应的、假死的进程。是时候制定一个计划来解决问题了。

最初想法非常简单。我们使用Golang Redis队列库在单个进程中将多个Consumer关联到队列。通过添加多个Consumer,可以一次性完成更多的工作,这有助于实现自动伸缩。此外,如果每个Consumer都跟踪它们上一次完成工作的时间,那么就可以定期检查其是否已经长时间没有运行。这可以用来实现对无响应的Consumer的简单检测。于是开始着手研究这个计划的可行性。

Golang中的自动伸缩和自防御设计

没过多久就发现这种策略不会奏效——至少对Golang不起作用。每个Consumer都在Golang Redis队列库中的goroutine中进行管理。如果检测到一个反应迟钝的Consumer,那么就需要关闭它;但事实证明,这并不是简单地关闭一个goroutine。为了结束goroutine,通常要等到它完成工作,或者使用Channels或其他一些机制来打破循环。但如果Consumer陷入死循环,很难控制goroutine关闭。即使有,也意味着需要修改Golang Redis队列库。因此这个策略变得越来越复杂,于是不得不思考其他的解决方案。

紧接着的下一个想法是编写一个新的程序,生成并管理几个Worker进程。每个Worker进程仍然可以将单个Consumer关联到队列上,但是运行的进程越多,意味着一次完成的工作就越多。Golang具有启动和关闭子进程的能力,因此这对自动伸缩有很大帮助。不同的进程之间有不同的通信方式,因此Worker进程可以告诉Master进程它们上一次完成任务是什么时候。如果Master进程发现某个Worker已经很长时间没有反馈了,那么就会触发响应迟钝和死亡检测——稍后将对此进行更多介绍。

Golang中的自动伸缩和自防御设计

另一种方法是建立hivemind。单个Worker进程可以同时处理作业,并且生成和管理其他Worker进程。如果检测到一个无响应或假死进程,则另一个正在运行的进程可以负责启动一个新进程。总的来说,它们可以确保始终有大量的进程在运行以处理队列中的任务。最终采用了第一种方法——Master-Worker方法。

Golang中的自动伸缩和自防御设计

自动伸缩(Autoscaling)

Master进程首先针对一个goroutine自旋,该goroutine定时检查Worker进程的数量。然后这个goroutine根据实际的进程数量来启动或停止Worker进程,确保负载均匀。所需Worker进程的计算非常简单,一般可以根据队列的当前长度,队列的生产/消费速率来计算。队列越长,或生产作业的速度越快,对应的Worker就越多。以下是对主要goroutine的简单介绍:

funcwatch() {

procs:= make(map[int]*Worker)

for {

// 检查每个Worker的健康状况

checkProcesses(&procs)

queueLength, rate:=queueStats()

// 计算所需的Worker进程数,根据实际情况增减

desiredWorkerCount:=calculateDesiredWorkerCount(queueLength, rate, len(procs))

if len(procs) !=desiredWorkerCount {

manageWorkers(&procs, desiredWorkerCount)

}

time.Sleep(30000*time.Millisecond)

}

}

Master进程需要跟踪它启动的子进程。这有助于自动伸缩,并定期检查每个子进程的健康状态。最简单的方法是使用一个Map,以整型键作为键值(keys),以及一个Worker结构(Worker struct)的实例作为值。这个Map的长度用于确定在添加Worker时使用的下一个键(keys),以及在删除Worker时删除哪个键。

funcmanageWorkers(procs*map[int]*Worker, desiredWorkerCountint) {

currentCount:= len(*procs)

ifcurrentCount < desiredWorkerCount {

// 添加Workers:

forcurrentCount < desiredWorkerCount {

StartWorker(procs, currentCount)

currentCount++

}

} elseifcurrentCount > desiredWorkerCount {

// 移除Workers:

forcurrentCount > desiredWorkerCount {

StopWorker(procs, currentCount-1)

currentCount--

}

}

}

Golang为进程管理提供了一个os/exec包。Master进程使用这个包生成新的Worker进程。Master进程和Worker进程被部署到相同的文件夹中,因此”./dSYM-worker"可以用来启动Worker。但是,Master进程作为守护进程运行时将不起作用。下面StartWorker函数中的第一行是如何获得正在运行的进程的工作目录。这样就可以创建Worker可执行文件的完整路径来可靠地运行它。一旦进程运行,将从Worker结构创建一个对象并将其存储在Map中。

funcStartWorker(procs*map[int]*Worker, indexint) {

dir, err:=filepath.Abs(filepath.Dir(os.Args[0]))

iferr!=nil {

// 错误及异常处理逻辑

}

cmd:=exec.Command(dir+"/dSYM-worker")

// 此处执行实际业务逻辑并将结果存入Worker对象

// 例如检索标准的输入和输出管道等,后面将对此进行详细解释

cmd.Start()

worker:=NewWorker(cmd, index)

(*procs)[index] = worker

}

确定作业负载所需的Worker数量,然后启动/停止Worker以满足,这是在本例中自动伸缩的实现。接下来将介绍如何阻止Worker进程假死。

Golang进程间通信(Inter process communication)

如上所述,检测一个Worker进程是否有响应,简单方法是由每个Worker报告上一次完成作业的时间。如果Master进程发现某个Worker时间周期太长,则可以认为它没有响应,并将其替换为新的Worker。要实现这一点,Worker进程需要以某种方式与Master进程通信,以便在它完成任务时保存并通知Master当前时间。实现这一点的方式有很多:

  • 读取和写入文件

  • 设置本地队列系统,如Redis或RabbitMQ

  • 使用Golang rpc包

  • 通过本地网络连接传输数据

  • 利用共享内存

  • 设置命名管道(named pipes)

在我们的例子中,所传输的只是时间戳,而不是重要的客户数据,因此上述这些方式大多数都有些杀鸡用牛刀了。因此最终采取了简单的解决方案——通过Worker进程的标准输出管道进行通信。

通过exec.Command启动新进程后,进程的标准输出管道可通过如下方式得到:

stdoutPipe, err:=cmd.StdoutPipe()

一旦有了标准的输出管道,就可以运行goroutine并发地监听它。在goroutine中,可以使用扫描器从管道读取数据,如下所示。在Worker进程将数据写入标准输出管道时,将执行scanner.Text()并调用之后的业务代码。

scanner:=bufio.NewScanner(stdoutPipe)

forscanner.Scan() {

line:=scanner.Text()

// 此处可处理读取到的具体业务数据

}

无响应监测(Unresponsivenessdetection)

现在进程间通信已经就绪,可以使用它来实现对无响应的Worker进程进行检测。更新现有的Worker逻辑,使之在完成工作时使用Golang fmt包打印出当前时间。而时间戳将被扫描器捕获,并使用与打印时相同的格式解析时间。然后将time对象设置到相关Worker对象的LastJob字段中用于跟踪。

t, err:=time.Parse(time.RFC3339, line)

iferr!=nil {

// 此处为错误异常处理逻辑

} else {

worker.LastJob = t

}

回到定时扫描包含Worker进程的Map的goroutine程序中,现在可以比较每个Worker的当前时间和上一个作业时间。如果这个时间间隔太长,则终止该进程并重新启动新的Worker进程。

funcCheckWorker(procs*map[int]*Worker, worker*Worker, indexint) {

// 如果Worker进程响应时间间隔太长,则终止并重启新的进程

duration:=time.Now().Sub(worker.LastJob)

ifduration.Minutes() > 4 {

KillWorker(procs, index)

StartWorker(procs, index)

}

}

可以通过调用进程对象的Kill函数来杀死进程。这是由生成进程时获得的Command对象提供的。另一件需要做的事是从映射的Map中删除Worker对象。在终止异常的Worker之后,可以通过调用StartWorker函数来启动一个新的Worker。新Worker在映射的Map中使用的key与被终止的Worker进程相同——从而完成了Worker的替换逻辑。

funcKillWorker(procs*map[int]*Worker, indexint) {

worker:= (*procs)[index]

ifworker!=nil {

process:=worker.Command.Process

delete(*procs, index) // 从Map中删除无效Worker进程

err:=process.Kill() // 终止进程

iferr!=nil {

// 错误及异常处理

}

}

}

终止检测(Terminationdetection)

从技术上讲,对无响应进程的检测和解析还应包括意外终止的进程。假死进程将无法报告它们正在执行作业,因此最终它们将由于响应超时而被替换。针对这类进程,越早发现越好。

尝试1

当启动一个进程时,可以得到分配给它的pid。Golang os包有一个名为FindProcess的函数,它返回给定pid的一个进程对象。如果能定期检查跟踪的每个进程,那么就知道某个特定的Worker是否处于假死状态。但问题在于FindProcess函数总是会返回一些东西,即使给定的pid不存在任何进程(走进了死胡同☹)

尝试2

通过键入“kill – s 0 {pid}”,则不会向进程发送信号,但仍将执行错误检查。如果给定pid没有进程,则会发生错误。这可以用Golang快速实现,但不幸的是,在Golang中运行它不会产生任何错误。类似地,向不存在的进程发送0信号也不表示进程的存在。(另一个死胡同☹)

最终方案

幸运的是实际上已经有了一种机制来检测假死进程。还记得用来监听Worker进程的scanner程序吗?scanner监听的标准输出管道对象是名为ReadCloser的类型。顾名思义,可以关闭它,如果另一端的Worker进程以任何方式停止,就会关闭它。如果管道关闭,scanner将停止侦听并跳出循环。因此可以在循环逻辑之后,在代码中插入逻辑以捕获该Worker进程停止的信号。

现在需要确定的是Master进程的正常操作导致了Worker的关闭(例如杀死没有响应的Worker,或者由于负载减少而收缩),还是意外终止。在Master进程以任何原因杀死/停止一个Worker之前,它会从进程映射的Map中删除它。因此,如果scanner扫描程序发现某进程已经终止,但Worker进程的引用仍然登记在Map中,那表示它没有在Master进程的控制下关闭。如果是这样的话,需要进行替换。

if (*procs)[worker.Index] ==worker {

StartWorker(procs, worker.Index)

}

通过在终端中使用kill命令来终止Worker进程,可以很容易地测试它的功能。

优雅关闭(Gracefulshut down)

当第一次使用Golang构建自动伸缩行为的原型时,调用了上面列出的KillWorker函数,以便于在负载较低时终止部分进程。设想如果一个Worker正在处理作业时被KillWorker函数终止,那么该作业会发生什么?在作业完成之前,它将安全地保存在Redis的队列中。只有当Worker确认作业已完成时,它才会被移除。Master进程定期检查已假死的Redis连接,并将未响应的作业移回就绪队列。这都是由Golang Redis队列库管理的。

这意味着当Worker进程意外终止时,不会丢失任何作业。这也意味着手动关闭进程完全可以正常工作。然而这感觉有点low,意味着这些作业将被延迟执行。一种更好的解决方案是实现优雅关闭—即允许Worker进程完成当前正在处理的工作,然后自然退出。

步骤1 - Master进程通知Worker停止

To start off, we need away for the master process to tell a particular worker process to begingraceful shut down. I’ve read that a common way of doing this is to send an OSsignal such as ‘interrupt’ to the worker process, and then have the worker handlethose signals to perform graceful shut down. For now though, I preferred toleave the OS signals to their default behaviours, and instead have the masterprocess send “stop” through the standard in pipe of a worker process.

首先需要一种方法让Master进程告诉特定的Worker进程开始优雅地关闭。一种常见的方法是向Worker进程发送一个OS信号,比如“interrupt”,然后让Worker处理这些信号,执行优雅关闭。这里让Master进程通过Worker进程管道中的标准输出发送“stop”指令。

funcStopWorker(procs*map[int]*Worker, indexint) {

worker:= (*procs)[index]

stdinPipe:=worker.StdinPipe

_, err:= (*stdinPipe).Write([]byte("stop\n"))

iferr!=nil {

// 错误及异常处理

}

}

 

步骤2 - Worker进程优雅关闭

当Worker进程接收到“stop”消息时,它使用Golang Redis队列库来停止消费,并设置一个布尔字段来指示它已经准备好优雅地关闭。另一个布尔字段用于跟踪作业当前是否在进行中。只要其中一个布尔值为真,程序就会保持活动。直到它们都为false,则意味着没有作业要处理,可以被标记为优雅关闭,此时程序自然终止。

funcscan(consumer*Consumer) {

reader:=bufio.NewReader(os.Stdin)

for {

text, _:=reader.ReadString('\n')

ifstrings.Contains(text, "stop") {

stop(consumer)

}

}

}

 

步骤3 – Worker进程反馈Master已完成

在Master流程中需要从映射Map中删除已关闭Worker。可以在给Worker发送“stop”消息后执行此操作,但是如果上一个作业碰巧导致该Worker陷入一个意外的死循环,会发生什么?为了更好地理清这个问题,当一个Worker进程完成了它的上一个任务并关闭时,它将打印一个“stop”消息。就像时间戳一样,这个消息在之前设置的scanner程序中获取。当Master看到这条消息时,可以停止跟踪该Worker并将其从Map中删除。

// Worker进程:

funcstop(consumer*Consumer) {

consumer.queue.StopConsuming()

consumer.running = false

if !consumer.doingJob {

fmt.Println("stop")

}

}

// Master进程的扫描流程:

ifstrings.Contains(line, "stop") {

delete(*procs, worker.Index)

break

}

 

谁来监视监控者?

此时dSYM-worker进程可以自动伸缩以处理突增的负载,并具有针对意外终止和无响应性的自卫机制。但是Master进程本身是否存在单点故障的可能性呢?它比Worker进程简单得多,但是仍然有崩溃的危险。如果它发生故障,一切就又回到了老路上了。此时也可以设计一个自动重启Master进程的机制。

有几种方法可以确保Master进程在失败时重新启动。一种方法是在启动守护进程配置中使用“KeepAlive”选项。另一种选择是编写一个脚本来检查进程的健康状况,如果发生故障就启动它。类似的脚本可以通过Cron作业来实现,每5分钟左右运行一次。

最后创建了另一个Golang程序,它首先启动Master进程,然后在检测到终止时重新启动它。这是采用与Master进程类似方式实现的。总的来说,它小而美且运行至今情况良好。

孤立的Worker

如果Master进程通过中断信号被关闭,它关联的所有Worker进程也将自动被妥善关闭。这对于部署新版本非常方便,因为只需要通知顶层Master进程关闭即可。但是如果Master进程崩溃了,那就另当别论了。所有的Worker进程都在不停地进行作业,但却没有人监督它们。当一个新的Master进程启动时,会产生更多的Worker,如果这种情况持续发生而不进行任何清理,这时候就悲剧了。

这是一个需要处理的重要场景,有一个简单的解决方案。Golang中的os包提供了一个Getppid()的方法。它不接受任何参数,因此可以随时调用它并获得父进程的pid。如果父进程死亡,子进程将成为孤儿,函数将返回1(初始值)。因此,在Worker进程中,可以很容易地检测它是否是孤立的。当一个Worker第一次启动时,可以获取并保存其初始父进程的pid。然后,定期调用Getppid函数并将结果与初始父pid进行比较。如果父pid发生变化,则表示该Worker已成为孤儿,此时可以优雅关闭它。

ppid:=os.Getppid()

ifppid!=initialParentId {

stop(consumer) // 开始优雅关闭

}

 

结束语

本文介绍了如何在Golang中实现自动伸缩和自防御服务,到目前为止,它运行良好。

原文作者:Jason Fauchelle  译者:江玮

原文链接:https://raygun.com/blog/2016/03/golang-auto-scaling/

版权声明:本文版权归作者(译者)及公众号所有,欢迎转载,但未经作者(译者)同意必须保留此段声明,且在文章页面明显位置给出,本文链接如有问题,可留言咨询。