如何完美实现 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

相关文章

Kali Linux安装实现教程(亲测有效)

《KaliLinux安装实现教程(亲测有效)》:本文主要介绍KaliLinux安装实现教程(亲测有效),具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录一、下载二、安装总结一、下载1、点http://www.chinasem.cn击链接 Get Kali | Kal

C#使用MQTTnet实现服务端与客户端的通讯的示例

《C#使用MQTTnet实现服务端与客户端的通讯的示例》本文主要介绍了C#使用MQTTnet实现服务端与客户端的通讯的示例,包括协议特性、连接管理、QoS机制和安全策略,具有一定的参考价值,感兴趣的可... 目录一、MQTT 协议简介二、MQTT 协议核心特性三、MQTTNET 库的核心功能四、服务端(BR

SpringCloud整合MQ实现消息总线服务方式

《SpringCloud整合MQ实现消息总线服务方式》:本文主要介绍SpringCloud整合MQ实现消息总线服务方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐... 目录一、背景介绍二、方案实践三、升级版总结一、背景介绍每当修改配置文件内容,如果需要客户端也同步更新,

Dubbo之SPI机制的实现原理和优势分析

《Dubbo之SPI机制的实现原理和优势分析》:本文主要介绍Dubbo之SPI机制的实现原理和优势,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录Dubbo中SPI机制的实现原理和优势JDK 中的 SPI 机制解析Dubbo 中的 SPI 机制解析总结Dubbo中

使用Java实现Navicat密码的加密与解密的代码解析

《使用Java实现Navicat密码的加密与解密的代码解析》:本文主要介绍使用Java实现Navicat密码的加密与解密,通过本文,我们了解了如何利用Java语言实现对Navicat保存的数据库密... 目录一、背景介绍二、环境准备三、代码解析四、核心代码展示五、总结在日常开发过程中,我们有时需要处理各种软

Java 压缩包解压实现代码

《Java压缩包解压实现代码》Java标准库(JavaSE)提供了对ZIP格式的原生支持,通过java.util.zip包中的类来实现压缩和解压功能,本文将重点介绍如何使用Java来解压ZIP或RA... 目录一、解压压缩包1.zip解压代码实现:2.rar解压代码实现:3.调用解压方法:二、注意事项三、总

linux服务之NIS账户管理服务方式

《linux服务之NIS账户管理服务方式》:本文主要介绍linux服务之NIS账户管理服务方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录一、所需要的软件二、服务器配置1、安装 NIS 服务2、设定 NIS 的域名 (NIS domain name)3、修改主

NGINX 配置内网访问的实现步骤

《NGINX配置内网访问的实现步骤》本文主要介绍了NGINX配置内网访问的实现步骤,Nginx的geo模块限制域名访问权限,仅允许内网/办公室IP访问,具有一定的参考价值,感兴趣的可以了解一下... 目录需求1. geo 模块配置2. 访问控制判断3. 错误页面配置4. 一个完整的配置参考文档需求我们有一

Linux实现简易版Shell的代码详解

《Linux实现简易版Shell的代码详解》本篇文章,我们将一起踏上一段有趣的旅程,仿照CentOS–Bash的工作流程,实现一个功能虽然简单,但足以让你深刻理解Shell工作原理的迷你Sh... 目录一、程序流程分析二、代码实现1. 打印命令行提示符2. 获取用户输入的命令行3. 命令行解析4. 执行命令

基于MongoDB实现文件的分布式存储

《基于MongoDB实现文件的分布式存储》分布式文件存储的方案有很多,今天分享一个基于mongodb数据库来实现文件的存储,mongodb支持分布式部署,以此来实现文件的分布式存储,需要的朋友可以参考... 目录一、引言二、GridFS 原理剖析三、Spring Boot 集成 GridFS3.1 添加依赖