如何完美实现 Go 服务的平滑升级

2024-08-27 20:12

本文主要是介绍如何完美实现 Go 服务的平滑升级,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

        Go 服务作为常驻进程,如何进行服务升级呢?你可能会觉得这还不简单,先将现有服务停止,再启动新的服务不就可以了。可是将现有服务停止时,如果它还在处理请求,那么这些请求该如何处理?另外,在现有服务已经退出但是新服务还没有启动期间,新的请求到达了又该如何处理? Go 服务升级并没有那么简单,我们需要实现一套平滑升级方案来保证升级过程是无损的。

1. 服务升级导致 502 状态码

        Go 服务升级会导致出现大量的 502 状态码,这一结论可以通过模拟服务升级流程来验证。假设 HTTP 请求的访问链路是客户端--网关--Go服务,即我们还需要搭建网关。基于 Go 语言实现的 HTTP 服务示例程序如下所示:

func mian(){server := &http.Server{Addr: "0.0.0.0:8080",}http.HandleFunc("/ping",func(w http.ResponseWriter,r *http.Request){duration := rand.Intn(1000)//模拟请求耗时time.Sleep(time.Millisecond * time.Durtation(duration))w.Write([]byte(r.URL.Path +">ping response"))})_= server.ListenAndServe()
}

        参考上面的代码,每一个请求都会随机休眠 0~1000ms。我们通过这种方式模拟了请求的正常响应时间。

         接下来使用 ab 压测工具模拟发起请求并升级 Go 服务。如何升级呢?我们可以通过简单的重启(升级和重启类似,只不过升级会替换可执行程序)来模拟。

//模拟并发请求
$ab -n 10000 -c 100 http://127.0.0.1/ping
//重启服务
$supervisorctl restart main

        在上面的命令中,我们通过 supervisorctl 命令重启了 Go 服务。补充一下,Go 服务是部署在物理机上的,为了避免 Go 服务异常退出,我们通常会使用成熟的进程管理工具,比如 supervisor。其中,supervisorctl 命令是 supervisor 提供的客户端命令。

        如何验证是否会出现瞬时的 502 错误呢? 可以查看 Nginx 的错误日志。这时候,你应该可以看到不少错误日志,这些错误日志可以分为两种,如下所示:

upstream prematurely closed connection while reading response header from upstream
connect( ) failed (111:Connection refused) while connecting to upstream

 

2. Go 语言信号处理框架

        为什么要先介绍信号呢?因为当我们需要将现有 Go 服务停止时,是通过给 Go 服务发送信号实现的,比如 Crtl+C 组合按键、supervisor 进程管理工具等。我们可以通过 kill 命令查看系统支持的所有信号,如下所示:

[root@pass ~]# kill -l1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX	

        需要注意的是,SIGKILL 信号是不能被捕获的,所以称该信号为强制退出信号。那么在 Go 语言中我们如何使用信号呢?可以参考下面的测试程序:

 

package mainimport ("fmt""os""os/signal""sync""syscall"
)func main() {c := make(chan os.Signal, 1)//相当于捕获信号signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)wg := sync.WaitGroup{}wg.Add(1)go func() {<-c //接收到信号fmt.Println("quit signal receive,quit")wg.Done()}()wg.Wait()
}

 

3. Go 服务平滑退出

        在第1节中提到,我们将 Go 服务升级引起的 502 问题拆解为两个独立的子问题。其中,第一个问题是:如何实现 Go 服务的平滑退出? 平滑退出的含义是在处理完所有正在处理的请求之后再退出。下面我们将以 HTTP 服务为例,介绍如何实现 Go 服务的平滑退出。

         其实,Go 语言本身就提供了平滑结束 HTTP 服务的方法。所以我们只需要监听退出信号,比如 SIGINT、SIGTERM 等信号,并且在接收到这些信号时调用对应的方法就可以了。参考下面的示例程序:

func main(){server := &http.Server {Addr:"0.0.0.0:8080",}exit := make(chan interface{},0)sig := make(chan os.Signal,2)//监听退出信号signal.Notify(sig,syscall.SIGINT,syscall.SIGTRM)//子协程,退出时阻塞式等待 HTTP 服务结束go func(){<-sigfmt.Println(time.Now(),"recv quit signal")_= server.Shutdown(context.Background())// 通知主协程,HTTP 服务已停止close(exit)}()//注册请求处理方法(方法阻塞 10 秒才返回响应结果),省略//启动 HTTP 服务err := server.ListenAndServe()if err != nil {fmt.Println(time.Now(),err)}//只有HTTP服务结束后,主协程才能退出<-exitfmt.Println(time.Now(),"main coroutine exit")
}

        在上面的代码中,方法 server.Shutdown 用于停止 HTTP 服务,该方法会一直阻塞直到所有监听的套接字都已经关闭,以及所有的 TCP 连接都已经关闭(当HTTP服务正在退出时,Go服务处理完 HTTP 请求后会立即关闭连接)。也就是说,当方法 server.Shutdown 返回时,说明 Go 服务已经处理完所有正在处理的请求了,这时候 Go 服务也就可以退出了。需要注意的是,当我们调用方法 server.Shutdown 停止 HTTP 服务时,方法 server.ListenAndServe 基本上会立即返回错误(错误信息 http:Server closed,这是因为监听的套接字被关闭了)。所以,为了避免主协程退出导致 Go 进程退出,我们使用了一个管道 exit,子协程可以通过管道 exit 通知主协程 HTTP 服务已经平滑结束。

 

4. 基于 gracehttp 的 Go 服务平滑升级

        3节已经实现了 Go 服务的平滑退出,想要实现 Go 服务平滑升级,还有一个问题需要解决:如何实现 Go 服务的无缝启动? 也就是说,在现有的 Go 服务退出之前,新的 Go 服务就需要启动,并且这时候新的 HTTP 请求应该由新的 Go 服务处理。下面将基于开源框架 gracehttp 讲解如何实现 Go 服务平滑升级。

        首先,这里其实有一个非常典型的问题需要解决:现有的 Go 进程已经绑定了 8080 端口,并且监听了套接字,这样一来当新的 Go 进程再次绑定 8080 端口并监听套接字时,就会产生错误 bind: address already in use。

        如何解决这一问题呢?我们可以让现有 Go 进程作为父进程来启动新的 Go 进程。难道父子进程就能同时绑定同一个端口号吗?当然不是,那为什么要这样做呢?这就需要了解一下系统调用 exec 了,该系统调用用于创建新的进程,所以,子进程并不需要再执行绑定端口号并监听套接字的操作了,只要获取到父进程套接字的文件描述符就可以了。如何获取呢?这方法就比较多了,比如父进程可以通过环境变量将套接字的文件描述符传递给子进程。

        这里推荐一个开源框架 gracehttp,其封装了平滑升级的相关逻辑,使用起来非常简单,可以参考官方示例程序,代码如下所示:

package main
import (......"github.com/facebookgo/grace/gracehttp"
)var now = time.Now()
func main(){gracehttp.Serve(	// 包装Go原生的HTTP服务&http.Server{Addr: ":8080",Handler:newHandler("Zero ")},)
}func newHandler(nae string) http.Handler {mux := http.NewServeMux()// HTTP 请求处理方法,可以根据请求参数休眠指定时间mux.HandleFunc("/sleep",func(w http.ResponseWriter,r *http.Request) {duration,_:=time.ParseDuration(r.FormValue("duration"))time.Sleep(duration)fmt.Fprintf(w,"%s started at %s slept for %d nanoseconds from pid %d.\n",name,now,duration.Nanoseconds(),os.Getpid(),)	})return mux
}

        在上面的代码中,我们只需要使用 gracehttp.Serve 将 Go 语言原生的 HTTP 服务包装一下,就能实现 Go 服务的平滑升级。需要说明的是,gracehttp 监听的是 SIGUSR2 信号,当接收到该信号之后, gracehttp 就会创建新的进程,等到新的进程启动后再平滑停止现有进程。编译并运行上面的程序,通过 curl 命令手动发起 HTTP 请求并重启 Go 服务,结果如下所示:

        由上面的输出结果可千,我们首先查询了 Go 服务的进程 ID 是 31057 ,随后通过 curl 命令发起了 HTTP 请求,之后再向 Go 服务发送了 SIGUSR2 信号。结果表明,该请求由进程 31057 处理了,最后再次查询了 Go 服务的进程 ID 是 31095,说明 Go 服务确实重启了。

        看到这里有些读者可能会有疑问,仅仅发起一个 HTTP 请求,就认为重启过程是平滑的吗?当然不是,严格的验证方案可以参考第1小节。我们在升级的过程中同时使用 ab 压测工具模拟并发请求,验证结果如下所示:

        由上面的输出结果可知,我们首先查询了 Go 服务的进程 ID 是 31185,随后通过 ab 压测工具发起了大量请求并向 Go 服务发送了 SIGUSR2 信号。再次查询 Go 服务的进程 ID,你会发现存在两个 Go 进程,这是因为新的 Go 进程已经启动了,但是老的 Go 进程还在处理请求没有退出。最后,稍等片刻再次查询 Go 服务的进程 ID,你会发现这时候只有一个 Go 进程了。

        那么在 Go 服务重启过程中,有没有引起一些 502 请求呢?可以查看网关 Nginx 的访问日志或错误日志,你会发现所有请求都正常返回了状态码 200,也就是说 gracehttp 确写着可以帮助我们实现 Go 服务的平滑升级。

        最后,简单看一下 gracehttp 框架的实现原理。首先,gracehttp 在启动 Go 服务的时候,需要判断是否应该绑定端口并监听套接字,其次,当 Go 服务作为子进程启动之后,还需要给父进程发送一个退出信号,而父进程退出也必须是平滑的。我们先简单看一下 gracehttp 启动 Go 服务的核心逻辑,代码如下所示;

func (a *app)run() error {//创建监听套接字if err := a.listen(); err != nil {return err}//启动Go服务a.serve()//给父进程发送退出信号if didInherit && ppid != 1 {if err := syscall.Kill(ppid,syscall.SIGTERM);err != nil {}}......
}

        在上面的代码中,方法 a.listen 用于创建并监听套接字,当然如果 Go 服务作为子进程启动,那么该 Go 服务不会再创建套接字,而是直接继承父进程的套接字。另外可以看到,Go 服务作为子进程启动后,通过系统调用 kill 给父进程发送一个退出信号。 

        当然,实现平滑升级的前提是能够接收并处理指定信号,gracehttp 自定义的信号处理函数如下所示:

func (a *app) signalHandler (wg *sync.WaitGroup){ch := make(chan os.Signal,10)signal.Notify(ch,syscall.SIGINT,syscall.SIGTERM,syscall.SIGUSR2)for {sig := <-chanswitch sig {case syscall.SIGINT,syscall.SIGTERM:// 平滑退出 Go 服务returncase syscall.SIGUSR2://创建新的进程,底层通过环境变量传递了其监听的套接字文件描述符if _,err := a.net.StartProcess();err != nil {a.errors <- err}}}
}

        参考上面的代码,gracehttp 总共监听了 3 个信号。其中,信号 syscall.SIGINT 和 syscall.SIGTERM 用于平滑退出 Go 服务,信号 syscall.SIGUSR2 用于启动新的 Go 服务,这 3 个信号的组合实现了 Go 服务的平滑升级。方法 a.net.StartProcess 用于创建新的进程,并通过环境变量将 Go 父进程监听的套接字文件描述符传递给子进程。

这篇关于如何完美实现 Go 服务的平滑升级的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1112658

相关文章

基于C++的UDP网络通信系统设计与实现详解

《基于C++的UDP网络通信系统设计与实现详解》在网络编程领域,UDP作为一种无连接的传输层协议,以其高效、低延迟的特性在实时性要求高的应用场景中占据重要地位,下面我们就来看看如何从零开始构建一个完整... 目录前言一、UDP服务器UdpServer.hpp1.1 基本框架设计1.2 初始化函数Init详解

Java中Map的五种遍历方式实现与对比

《Java中Map的五种遍历方式实现与对比》其实Map遍历藏着多种玩法,有的优雅简洁,有的性能拉满,今天咱们盘一盘这些进阶偏基础的遍历方式,告别重复又臃肿的代码,感兴趣的小伙伴可以了解下... 目录一、先搞懂:Map遍历的核心目标二、几种遍历方式的对比1. 传统EntrySet遍历(最通用)2. Lambd

springboot+redis实现订单过期(超时取消)功能的方法详解

《springboot+redis实现订单过期(超时取消)功能的方法详解》在SpringBoot中使用Redis实现订单过期(超时取消)功能,有多种成熟方案,本文为大家整理了几个详细方法,文中的示例代... 目录一、Redis键过期回调方案(推荐)1. 配置Redis监听器2. 监听键过期事件3. Redi

SpringBoot全局异常拦截与自定义错误页面实现过程解读

《SpringBoot全局异常拦截与自定义错误页面实现过程解读》本文介绍了SpringBoot中全局异常拦截与自定义错误页面的实现方法,包括异常的分类、SpringBoot默认异常处理机制、全局异常拦... 目录一、引言二、Spring Boot异常处理基础2.1 异常的分类2.2 Spring Boot默

基于SpringBoot实现分布式锁的三种方法

《基于SpringBoot实现分布式锁的三种方法》这篇文章主要为大家详细介绍了基于SpringBoot实现分布式锁的三种方法,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录一、基于Redis原生命令实现分布式锁1. 基础版Redis分布式锁2. 可重入锁实现二、使用Redisso

SpringBoo WebFlux+MongoDB实现非阻塞API过程

《SpringBooWebFlux+MongoDB实现非阻塞API过程》本文介绍了如何使用SpringBootWebFlux和MongoDB实现非阻塞API,通过响应式编程提高系统的吞吐量和响应性能... 目录一、引言二、响应式编程基础2.1 响应式编程概念2.2 响应式编程的优势2.3 响应式编程相关技术

C#实现将XML数据自动化地写入Excel文件

《C#实现将XML数据自动化地写入Excel文件》在现代企业级应用中,数据处理与报表生成是核心环节,本文将深入探讨如何利用C#和一款优秀的库,将XML数据自动化地写入Excel文件,有需要的小伙伴可以... 目录理解XML数据结构与Excel的对应关系引入高效工具:使用Spire.XLS for .NETC

Nginx更新SSL证书的实现步骤

《Nginx更新SSL证书的实现步骤》本文主要介绍了Nginx更新SSL证书的实现步骤,包括下载新证书、备份旧证书、配置新证书、验证配置及遇到问题时的解决方法,感兴趣的了解一下... 目录1 下载最新的SSL证书文件2 备份旧的SSL证书文件3 配置新证书4 验证配置5 遇到的http://www.cppc

Nginx之https证书配置实现

《Nginx之https证书配置实现》本文主要介绍了Nginx之https证书配置的实现示例,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起... 目录背景介绍为什么不能部署在 IIS 或 NAT 设备上?具体实现证书获取nginx配置扩展结果验证

SpringBoot整合 Quartz实现定时推送实战指南

《SpringBoot整合Quartz实现定时推送实战指南》文章介绍了SpringBoot中使用Quartz动态定时任务和任务持久化实现多条不确定结束时间并提前N分钟推送的方案,本文结合实例代码给大... 目录前言一、Quartz 是什么?1、核心定位:解决什么问题?2、Quartz 核心组件二、使用步骤1