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

相关文章

Java实现检查多个时间段是否有重合

《Java实现检查多个时间段是否有重合》这篇文章主要为大家详细介绍了如何使用Java实现检查多个时间段是否有重合,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录流程概述步骤详解China编程步骤1:定义时间段类步骤2:添加时间段步骤3:检查时间段是否有重合步骤4:输出结果示例代码结语作

使用C++实现链表元素的反转

《使用C++实现链表元素的反转》反转链表是链表操作中一个经典的问题,也是面试中常见的考题,本文将从思路到实现一步步地讲解如何实现链表的反转,帮助初学者理解这一操作,我们将使用C++代码演示具体实现,同... 目录问题定义思路分析代码实现带头节点的链表代码讲解其他实现方式时间和空间复杂度分析总结问题定义给定

Java覆盖第三方jar包中的某一个类的实现方法

《Java覆盖第三方jar包中的某一个类的实现方法》在我们日常的开发中,经常需要使用第三方的jar包,有时候我们会发现第三方的jar包中的某一个类有问题,或者我们需要定制化修改其中的逻辑,那么应该如何... 目录一、需求描述二、示例描述三、操作步骤四、验证结果五、实现原理一、需求描述需求描述如下:需要在

如何使用Java实现请求deepseek

《如何使用Java实现请求deepseek》这篇文章主要为大家详细介绍了如何使用Java实现请求deepseek功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录1.deepseek的api创建2.Java实现请求deepseek2.1 pom文件2.2 json转化文件2.2

python使用fastapi实现多语言国际化的操作指南

《python使用fastapi实现多语言国际化的操作指南》本文介绍了使用Python和FastAPI实现多语言国际化的操作指南,包括多语言架构技术栈、翻译管理、前端本地化、语言切换机制以及常见陷阱和... 目录多语言国际化实现指南项目多语言架构技术栈目录结构翻译工作流1. 翻译数据存储2. 翻译生成脚本

Android 悬浮窗开发示例((动态权限请求 | 前台服务和通知 | 悬浮窗创建 )

《Android悬浮窗开发示例((动态权限请求|前台服务和通知|悬浮窗创建)》本文介绍了Android悬浮窗的实现效果,包括动态权限请求、前台服务和通知的使用,悬浮窗权限需要动态申请并引导... 目录一、悬浮窗 动态权限请求1、动态请求权限2、悬浮窗权限说明3、检查动态权限4、申请动态权限5、权限设置完毕后

如何通过Python实现一个消息队列

《如何通过Python实现一个消息队列》这篇文章主要为大家详细介绍了如何通过Python实现一个简单的消息队列,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录如何通过 python 实现消息队列如何把 http 请求放在队列中执行1. 使用 queue.Queue 和 reque

Python如何实现PDF隐私信息检测

《Python如何实现PDF隐私信息检测》随着越来越多的个人信息以电子形式存储和传输,确保这些信息的安全至关重要,本文将介绍如何使用Python检测PDF文件中的隐私信息,需要的可以参考下... 目录项目背景技术栈代码解析功能说明运行结php果在当今,数据隐私保护变得尤为重要。随着越来越多的个人信息以电子形

使用 sql-research-assistant进行 SQL 数据库研究的实战指南(代码实现演示)

《使用sql-research-assistant进行SQL数据库研究的实战指南(代码实现演示)》本文介绍了sql-research-assistant工具,该工具基于LangChain框架,集... 目录技术背景介绍核心原理解析代码实现演示安装和配置项目集成LangSmith 配置(可选)启动服务应用场景

使用Python快速实现链接转word文档

《使用Python快速实现链接转word文档》这篇文章主要为大家详细介绍了如何使用Python快速实现链接转word文档功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 演示代码展示from newspaper import Articlefrom docx import