背景
在业务快速增长中,前期只是验证模式是否可行,初期忽略程序发布重启带来的暂短停机影响。当模式实验成熟之后会逐渐放量,此时我们的发布停机带来的影响就会大很多。我们整个服务都是基于云,请求流量从 四层->七层->机器。
要想实现平滑重启大致有三种方案,一种是在流量调度的入口处理,一般的做法是 ApiGateway + CD ,发布的时候自动摘除机器,等待程序处理完现有请求再做发布处理,这样的好处就是程序不需要关心如何做平滑重启。
第二种就是程序自己完成平滑重启,保证在重启的时候 listen socket FD(文件描述符) 依然可以接受请求进来,只不过切换新老进程,但是这个方案需要程序自己去完成,有些技术栈可能实现起来不是很简单,有些语言无法控制到操作系统级别,实现起来会很麻烦。
第三种方案就是完全 docker,所有的东西交给 k8s 统一管理,我们正在小规模接入中。
问题
程序升级过程中,如何不影响正在处理的请求?
正在处理的请求怎么办?
新进来的请求怎么办?
正在处理的请求
等待处理完成后退出
go1.8就支持了,使用go1.8版本的shutdown方法进行优雅关闭
新来的请求怎么办?
Fork一个子进程,继承父进程的监听socket
子进程启动成功后,接收新的连接
父进程停止接收新的连接,等已有的请求处理完毕,退出
优雅重启成功
子进程如何继承父进程的文件句柄?(linux下)
通过os.Cmd对象中的ExtraFiles参数进行传递
文件句柄继承实例分析
package main import ( "flag" "fmt" "os" "os/exec" "time" ) var ( child *bool ) func init() { child = flag.Bool("child", false, "继承于父进程(internal use only)") flag.Parse() } func readFromParent() { //fd = 0,标准输出 //fd = 1,标准输入 //fd = 2,标准错误输出 //fd = 3, ==> ExtraFiles[0] //fd = 4, ==> ExtraFiles[1] //第一个参数文件句柄的下标,就是ExtraFiles[0], 第二个参数名字可以随便取 f := os.NewFile(3, "") count := 0 for { //格式化字符串 str := fmt.Sprintf("hello, i'child process, write: %d line n", count) count++ //写入到这个文件 _, err := f.WriteString(str) if err != nil { fmt.Printf("write string failed, err: %vn", err) time.Sleep(time.Second) continue } //每一秒写下文件 time.Sleep(time.Second) } } //启动子进程 func startChild(file *os.File) { args := []string{"-child"} //os.Args[0]是文件路径,带上-child选项 cmd := exec.Command(os.Args[0], args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr //放socket fd在第一个entry,只要把父进程传递过来的放在这里 cmd.ExtraFiles = []*os.File{file} //到main函数 err := cmd.Start() if err != nil { fmt.Printf("start child failed, err: %vn", err.Error()) return } } func main() { //表示已经是一个子进程了 if child != nil && *child == true { fmt.Printf("继承于父进程的文件句柄n") //子进程 readFromParent() return } //父进程的逻辑,打开文件句柄 file, err := os.OpenFile("./test.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0755) if err != nil { fmt.Printf("open file failed, err:%vn", err) return } //启动一个子进程,把文件句柄给子进程 startChild(file) fmt.Println("父进程退出") }
golang 程序平滑重启框架
与 java、net 等基于虚拟机的语言不同,golang 天然支持系统级别的调用,平滑重启处理起来很容易。从原理上讲,基于 linux fork 子进程的方式,启动新的代码,再切换 listen socket FD,原理固然不难,但是完全自己实现还是会有很多细节问题的。好在有比较成熟的开源库帮我们实现了。
具体参考:https://phpmianshi.com/?id=2050
程序自身完成平滑重启
进程在不关闭其所监听端口的情况下进行重启,并且重启的整个过程保证所有请求都能被正确处理。
主要步骤:
原进程(父进程)先
fork
一个子进程,同时让fork
出来的子进程继承父进程所监听的socket
。子进程完成初始化后,开始接收
socket
的请求。父进程停止接收新的请求,并将当下的请求处理完,等待连接空闲后,平滑退出。
信号(Signal)
服务的平滑重启,主要依赖进程接收的信号(实现进程间通信),这里简单的介绍Golang
中信号的处理:
发送信号
kill: 命令允许用户发送一个特定的信号给进程
raise: 库函数可以发送特定的信号给当前进程
在Linux下运行man kill
可以查看此命令的介绍和用法。
kill -- terminate or signal a process The kill utility sends a signal to the processes specified by the pid operands. Only the super-user may send signals to other users' processes.
常用信号类型
信号的默认行为:
term:信号终止进程
core:产生核心转储文件并退出
ignore:忽略信号
stop:信号停止进程
cont:信号恢复一个已停止的进程
信号 | 值 | 默认动作 | 说明 |
---|---|---|---|
SIGHUP | 1 | Term | HUP (hang up):终端控制进程结束(终端连接断开) |
SIGINT | 2 | Term | INT (interrupt):用户发送INTR字符(Ctrl+C)触发(强制进程结束) |
SIGQUIT | 3 | Core | QUIT (quit):用户发送QUIT字符(Ctrl+/)触发(进程结束) |
SIGKILL | 9 | Term | KILL (non-catchable, non-ignorable kill):无条件结束程序(不能被捕获、阻塞或忽略) |
SIGUSR1 | 30,10,16 | Term | 用户自定义信号1 |
SIGUSR2 | 31,12,17 | Term | 用户自定义信号2 |
SIGKILL | 15 | KILL (non-catchable, non-ignorable kill) | TERM (software termination signal):程序终止信号 |
信号接收测试
package main import ( "log" "os" "os/signal" "syscall" ) func main() { sigs := make(chan os.Signal) signal.Notify(sigs, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGUSR1, syscall.SIGUSR2) // 监听所有信号 log.Println("listen sig") signal.Notify(sigs) // 打印进程id log.Println("PID:", os.Getppid()) s := <-sigs log.Println("退出信号", s) }
go run main.go 执行 ctrl+c 2022/04/24 20:05:42 listen sig 2022/04/24 20:05:42 PID: 17924 2022/04/24 20:05:45 退出信号 interrupt
实现案例
demo:
package main import ( "context" "errors" "flag" "log" "net" "net/http" "os" "os/exec" "os/signal" "syscall" "time" ) var ( server *http.Server listener net.Listener graceful = flag.Bool("graceful", false, "listen on fd open 3 (internal use only)") ) func sleep(w http.ResponseWriter, r *http.Request) { duration, err := time.ParseDuration(r.FormValue("duration")) if err != nil { http.Error(w, err.Error(), 400) return } time.Sleep(duration) w.Write([]byte("Hello World")) } func main() { flag.Parse() http.HandleFunc("/sleep", sleep) server = &http.Server{Addr: ":5007"} var err error if *graceful { log.Print("main: Listening to existing file descriptor 3.") // cmd.ExtraFiles: If non-nil, entry i becomes file descriptor 3+i. // when we put socket FD at the first entry, it will always be 3(0+3) f := os.NewFile(3, "") listener, err = net.FileListener(f) } else { log.Print("main: Listening on a new file descriptor.") listener, err = net.Listen("tcp", server.Addr) } if err != nil { log.Fatalf("listener error: %v", err) } go func() { // server.Shutdown() stops Serve() immediately, thus server.Serve() should not be in main goroutine err = server.Serve(listener) log.Printf("server.Serve err: %v\n", err) }() signalHandler() log.Printf("signal end") } func reload() error { tl, ok := listener.(*net.TCPListener) if !ok { return errors.New("listener is not tcp listener") } f, err := tl.File() if err != nil { return err } args := []string{"-graceful"} cmd := exec.Command(os.Args[0], args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr // put socket FD at the first entry cmd.ExtraFiles = []*os.File{f} return cmd.Start() } func signalHandler() { ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR2) for { sig := <-ch log.Printf("signal: %v", sig) // timeout context for shutdown ctx, _ := context.WithTimeout(context.Background(), 100*time.Second) switch sig { case syscall.SIGINT, syscall.SIGTERM: // stop log.Printf("stop") signal.Stop(ch) server.Shutdown(ctx) log.Printf("graceful shutdown") return case syscall.SIGUSR2: // reload log.Printf("reload") err := reload() if err != nil { log.Fatalf("graceful restart error: %v", err) } server.Shutdown(ctx) log.Printf("graceful reload") return } } }
- 本文固定链接: https://phpmianshi.com/?id=2053
- 转载请注明: admin 于 PHP面试网 发表
《本文》有 0 条评论