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

相关文章

python设置环境变量路径实现过程

《python设置环境变量路径实现过程》本文介绍设置Python路径的多种方法:临时设置(Windows用`set`,Linux/macOS用`export`)、永久设置(系统属性或shell配置文件... 目录设置python路径的方法临时设置环境变量(适用于当前会话)永久设置环境变量(Windows系统

Python对接支付宝支付之使用AliPay实现的详细操作指南

《Python对接支付宝支付之使用AliPay实现的详细操作指南》支付宝没有提供PythonSDK,但是强大的github就有提供python-alipay-sdk,封装里很多复杂操作,使用这个我们就... 目录一、引言二、准备工作2.1 支付宝开放平台入驻与应用创建2.2 密钥生成与配置2.3 安装ali

Spring Security 单点登录与自动登录机制的实现原理

《SpringSecurity单点登录与自动登录机制的实现原理》本文探讨SpringSecurity实现单点登录(SSO)与自动登录机制,涵盖JWT跨系统认证、RememberMe持久化Token... 目录一、核心概念解析1.1 单点登录(SSO)1.2 自动登录(Remember Me)二、代码分析三、

PyCharm中配置PyQt的实现步骤

《PyCharm中配置PyQt的实现步骤》PyCharm是JetBrains推出的一款强大的PythonIDE,结合PyQt可以进行pythion高效开发桌面GUI应用程序,本文就来介绍一下PyCha... 目录1. 安装China编程PyQt1.PyQt 核心组件2. 基础 PyQt 应用程序结构3. 使用 Q

Python实现批量提取BLF文件时间戳

《Python实现批量提取BLF文件时间戳》BLF(BinaryLoggingFormat)作为Vector公司推出的CAN总线数据记录格式,被广泛用于存储车辆通信数据,本文将使用Python轻松提取... 目录一、为什么需要批量处理 BLF 文件二、核心代码解析:从文件遍历到数据导出1. 环境准备与依赖库

linux下shell脚本启动jar包实现过程

《linux下shell脚本启动jar包实现过程》确保APP_NAME和LOG_FILE位于目录内,首次启动前需手动创建log文件夹,否则报错,此为个人经验,供参考,欢迎支持脚本之家... 目录linux下shell脚本启动jar包样例1样例2总结linux下shell脚本启动jar包样例1#!/bin

go动态限制并发数量的实现示例

《go动态限制并发数量的实现示例》本文主要介绍了Go并发控制方法,通过带缓冲通道和第三方库实现并发数量限制,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面... 目录带有缓冲大小的通道使用第三方库其他控制并发的方法因为go从语言层面支持并发,所以面试百分百会问到

Go语言并发之通知退出机制的实现

《Go语言并发之通知退出机制的实现》本文主要介绍了Go语言并发之通知退出机制的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧... 目录1、通知退出机制1.1 进程/main函数退出1.2 通过channel退出1.3 通过cont

Python实现PDF按页分割的技术指南

《Python实现PDF按页分割的技术指南》PDF文件处理是日常工作中的常见需求,特别是当我们需要将大型PDF文档拆分为多个部分时,下面我们就来看看如何使用Python创建一个灵活的PDF分割工具吧... 目录需求分析技术方案工具选择安装依赖完整代码实现使用说明基本用法示例命令输出示例技术亮点实际应用场景扩

java如何实现高并发场景下三级缓存的数据一致性

《java如何实现高并发场景下三级缓存的数据一致性》这篇文章主要为大家详细介绍了java如何实现高并发场景下三级缓存的数据一致性,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 下面代码是一个使用Java和Redisson实现的三级缓存服务,主要功能包括:1.缓存结构:本地缓存:使