golang runner服务平滑重启——子进程处理

作者: adm 分类: go 发布时间: 2022-06-01 09:14

现在线上服务多实例在滚动升级时总是会出现部分任务失败,尽管有失败任务交由其他实例重试的策略,但是有时候滚动升级较快,分配到的新实例又要升级,则导致二次失败,任务就彻底失败了。因为上线时总要注意下上线并发度,盯一下盘,有时候甚至要手动等待下。

为了做好平滑重启升级(悠闲吃根雪糕,泡杯咖啡),因此学习了下golang平滑重启的主要做法。

golang的http服务的平滑重启已经有挺多相关组件,常用的endless支持原生的http、gin等,主要就是复用socket来保证热重启更新。
但是对于一个非http服务即runner服务,需要自己来实现平滑重启,来确保在水平扩缩容、滚动更新、主从切换时不会出错。
主要依赖就是三点

信号捕捉
context传递关闭信号给各个子模块
wait group确保各个模块能够平滑结束
主要使用如下面代码主函数

func main() {
   exitChan := make(chan os.Signal, 1)
   // 使用signal.Notify来捕捉退出信号,一般是使用term int信号来作为关闭信号,可以根据自己需要选择
   signal.Notify(exitChan, syscall.SIGINT, syscall.SIGTERM)
   // 准备好wg和ctx来记录服务执行逻辑状态
   wg := &sync.WaitGroup{}
   //  cancel用于取消,ctx用于通知
   ctx, cancel := context.WithCancel(context.Background())
   // 开启业务逻辑
   wg.Add(2)
   go FatherServer(ctx, wg,1)
   go FatherServer(ctx, wg,2)
   for {
      select{
         // 等待退出信号
         case <-exitChan:
         log.Println("get exit signal")
         // 告知业务处理函数该退出了
         cancel()
         // 等待业务处理函数全都退出
         wg.Wait()
         log.Println("exit main success")
         return
     }
   }
}

业务处理逻辑会需要时间,也有并发,这里用简单的time sleep来代替,fatherServer代表业务逻辑1和2,ChildServer代表实际执行业务逻辑的函数。

func FatherServer(ctx context.Context, wg *sync.WaitGroup, num int){
   defer wg.Done()
   fatherWg := &sync.WaitGroup{}
   i := 0
 for {
      select {
        case <-ctx.Done():
         log.Println("FatherServer wait exit")
         fatherWg.Wait()
         log.Println("FatherServer exit success")
         return
        case <-time.After(time.Second * 5):
         i++
         fatherWg.Add(1)
         go ChildServer(fatherWg, fmt.Sprintf("FatherServer num:%d-%d", num, i))
      }
   }
}
func ChildServer(wg *sync.WaitGroup, args string){
   defer wg.Done()
   fmt.Printf("ChildServer will process %sn", args)
   time.Sleep(time.Second * 3)
   fmt.Printf("ChildServer process success%sn", args)
}

运行后在ChildServer将要执行任务的时候退出

可以看到服务在处理完子任务1-3 2-3才退出服务,不会出现处理任务到一半的情况而中断。

实际应用中,FatherServer可能为接收消息队列的消息,不断调用ChildServer来处理,采用该方法,可以保证接收到的消息不会处理到一半而因为重启导致中断,在滚动升级、扩缩容时消息能够平滑的退出进入。

实际业务中有一次服务需要调用一个客户端来处理,因此处理逻辑中使用了exec.Command来调用,然而服务在滚动升级重启的时候发现调用客户端处理到一半的任务会被中断,但是服务已经做了等待各个任务执行后才会重启该实例。

使用一个执行脚本代替该逻辑

sleep 3
echo $1 > /tmp/hello.txt

业务处理逻辑为

func ChildServer(wg *sync.WaitGroup, args string){
   defer wg.Done()
   fmt.Printf("ChildServer will process %sn", args)
   ExecuteCmd(args)
   fmt.Printf("ChildServer process success%sn", args)
}
func ExecuteCmd(args string){
   cmd := exec.Command("/bin/bash", "./hello.sh", args)
   _, err := cmd.CombinedOutput()
   if err != nil{
      log.Println("err:", err)
   }
}

服务启动后使用kill pid在将要执行1-6的时候kill服务,发现等待任务完成后才退出,看起来实现了优雅重启

然而上线后发现不会等待子任务完成就退出了!

显示完成了任务1-2,然而查看/tmp/hello.txt发现里面是1-1的信息,也就是任务中断了。
查看日志可以发现调用脚本的cmd也收到退出信号,并且退出了,导致任务失败,但是我们的中断信号是发给main服务的,玩什么这里子进程也收到退出信号?
这是因为ctrl+c发送的退出信号是给进程组的,而由我们main服务调用发起的子进程属于该进程组,所以也收到了该信号,导致子进程不继续任务而退出了。
kill pid是仅对进程发信号,而使用kill -pid即可对该进程组发送信号。
线上服务使用supervisor托管,滚动升级重启的时候也是发信号给进程组,因此导致服务的子进程立刻退出了。
那么只要调起的进程有自己的编号即可不接收到该退出信号,能安稳完成自己的工作再退出了。
调用cmd时使用

func ExecuteCmd(args string){
   cmd := exec.Command("/bin/bash", "./hello.sh", args)
   // 拥有自己的进程组
   cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
   _, err := cmd.CombinedOutput()
   if err != nil{
      log.Println("err:", err)
   }
}

运行服务

子进程没有收到退出信号,父进程也是等待子进程完成后,再退出,至此实现了平滑重启!

之后上线点一下,灰度部分实例完成无误后,就可以等待数十个实例滚动更新重启,也不会报任务失败了,可以安心去茶水间拿点吃的泡杯咖啡回来静静等待就好了。

如果觉得我的文章对您有用,请随意赞赏。您的支持将鼓励我继续创作!