56

golang程序优雅关闭与重启

 5 years ago
source link: https://silsuer.github.io/2018/08/14/bingo-graceful-shutdown/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

golang程序优雅关闭与重启

何谓优雅

当线上代码有更新时,我们要首先关闭服务,然后再启动服务,如果访问量比较大,当关闭服务的时候,当前服务器很有可能有很多 连接,那么如果此时直接关闭服务,这些连接将全部断掉,影响用户体验,绝对称不上优雅

所以我们要想出一种可以平滑关闭或者重启程序的方式

是谓优雅。

思路

  1. 服务端启动时多开启一个协程用来监听关闭信号
  2. 当协程接收到关闭信号时,将拒绝接收新的连接,并处理好当前所有连接后断开
  3. 启动一个新的服务端进程来接管新的连接
  4. 关闭当前进程

实现

siluser/bingo 框架为例

关于这个框架的系列文章:

我使用了 tim1020/godaemon 这个包来实现平滑重启的功能(对于大部分项目来说,直接使用可以满足大部分需求,无需改造)

期望效果:

在控制台输入 bingo run daemon [start|restart|stop] 可以令服务器 启动|重启|停止

  1. 先看如何开启一个服务器 ( bingo run dev )

关于 bingo 命令的实现可以看我以前的博客: 仿照laravel-artisan实现简易go开发脚手架

因为是开发环境嘛,大体的思路就是吧 bingo run 命令转换成令 go run start.go 这种 shell 命令

所以 bingo run dev 就等于 go run start.go dev

//处理http.Server,使支持graceful stop/restart
func Graceful(s http.Server) error {
	// 设置一个环境变量
	os.Setenv("__GRACEFUL", "true")
	// 创建一个自定义的server
	srv = &server{
		cm:     newConnectionManager(),
		Server: s,
	}

	// 设置server的状态
	srv.ConnState = func(conn net.Conn, state http.ConnState) {
		switch state {
		case http.StateNew:
			srv.cm.add(1)
		case http.StateActive:
			srv.cm.rmIdleConns(conn.LocalAddr().String())
		case http.StateIdle:
			srv.cm.addIdleConns(conn.LocalAddr().String(), conn)
		case http.StateHijacked, http.StateClosed:
			srv.cm.done()
		}
	}
	l, err := srv.getListener()
	if err == nil {
		err = srv.Server.Serve(l)
	} else {
		fmt.Println(err)
	}
	return err
}

这样就可以启动一个服务器,并且在连接状态变化的时候可以监听到

  1. 以守护进程启动服务器

当使用 bingo run daemon 或者 bingo run daemon start 的时候,会触发 DaemonInit() 函数,内容如下:

func DaemonInit() {
	// 得到存放pid文件的路径
	dir, _ := os.Getwd()
	pidFile = dir + "/" + Env.Get("PID_FILE")
	if os.Getenv("__Daemon") != "true" { //master
		cmd := "start" //缺省为start
		if l := len(os.Args); l > 2 {
			cmd = os.Args[l-1]
		}
		switch cmd {
		case "start":
			if isRunning() {
				fmt.Printf("\n %c[0;48;34m%s%c[0m", 0x1B, "["+strconv.Itoa(pidVal)+"] Bingo is running", 0x1B)
			} else { //fork daemon进程
				if err := forkDaemon(); err != nil {
					fmt.Println(err)
				}
			}
		case "restart": //重启:
			if !isRunning() {
				fmt.Printf("\n %c[0;48;31m%s%c[0m", 0x1B, "[Warning]bingo not running", 0x1B)
				restart(pidVal)
			} else {
				fmt.Printf("\n %c[0;48;34m%s%c[0m", 0x1B, "["+strconv.Itoa(pidVal)+"] Bingo restart now", 0x1B)
				restart(pidVal)
			}
		case "stop": //停止
			if !isRunning() {
				fmt.Printf("\n %c[0;48;31m%s%c[0m", 0x1B, "[Warning]bingo not running", 0x1B)
			} else {
				syscall.Kill(pidVal, syscall.SIGTERM) //kill
			}
		case "-h":
			fmt.Println("Usage: " + appName + " start|restart|stop")
		default:   //其它不识别的参数
			return //返回至调用方
		}
		//主进程退出
		os.Exit(0)
	}
	go handleSignals()
}

首先要获取 pidFile 这个文件主要是存储令程序运行时候的进程 pid ,为什么要持久化 pid 呢?是为了让多次程序运行过程中,判定是否有相同程序启动等操作

之后要获取对应的操作 (start restart stop),一个一个说

case start :

首先使用 isRunning() 方法判断当前程序是否在运行,如何判断?就是从上面提到的 pidFile 中取出进程号

然后判断当前系统是否运行令这个进程,如果有,证明正在运行,返回 true ,反之返回 false

如果没有运行的话,调用 forkDaemon() 函数启动程序,这个函数是整个功能的核心

func forkDaemon() error {
	args := os.Args
	os.Setenv("__Daemon", "true")
	procAttr := &syscall.ProcAttr{
		Env:   os.Environ(),
		Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()},
	}
	pid, err := syscall.ForkExec(args[0], []string{args[0], "dev"}, procAttr)
	if err != nil {
		panic(err)
	}
	savePid(pid)
	fmt.Printf("\n %c[0;48;32m%s%c[0m", 0x1B, "["+strconv.Itoa(pid)+"] Bingo running...", 0x1B)
	fmt.Println()
	return nil
}

syscall 包不支持win系统,也就意味着如果想在 windows 上做开发的话,只能使用虚拟机或者 docker

这里的主要功能就是,使用 syscall.ForkExec()fork 一个进程出来

运行这个进程所执行的命令就是这里的参数(因为我们的原始命令是 go run start.go dev ,所以这里的 args[0] 实际上是 start.go 编译之后的二进制文件)

然后再把 fork 出来的进程号保存在 pidFile

所以最终运行的效果就是我们第一步时候说到的 bingo run dev 达到的效果

case restart :

这个比较简单,通过 pidFile 判定程序是否正在运行,如果正在运行,才会继续向下执行

函数体也比较简单,只有两行

syscall.Kill(pid, syscall.SIGHUP) //kill -HUP, daemon only时,会直接退出
forkDaemon()

第一行杀死这个进程 第二行开启一个新进程

case stop :

这里就一行代码,就是杀死这个进程

额外的想法

在开发过程中,每当有一丁点变动(比如更改来一丁点控制器),就需要再次执行一次 bingo run daemon restart 命令,让新的改动生效,十分麻烦

所以我又开发了 bingo run watch 命令,监听改动,自动重启server服务器

我使用了 github.com/fsnotify/fsnotify 包来实现监听

func startWatchServer(port string, handler http.Handler) {
	// 监听目录变化,如果有变化,重启服务
	// 守护进程开启服务,主进程阻塞不断扫描当前目录,有任何更新,向守护进程传递信号,守护进程重启服务
	// 开启一个协程运行服务
	// 监听目录变化,有变化运行 bingo run daemon restart
	f, err := fsnotify.NewWatcher()
	if err != nil {
		panic(err)
	}
	defer f.Close()
	dir, _ := os.Getwd()
	wdDir = dir
	fileWatcher = f
	f.Add(dir)

	done := make(chan bool)

	go func() {
		procAttr := &syscall.ProcAttr{
			Env:   os.Environ(),
			Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()},
		}
		_, err := syscall.ForkExec(os.Args[0], []string{os.Args[0], "daemon", "start"}, procAttr)
		if err != nil {
			fmt.Println(err)
		}
	}()

	go func() {
		for {
			select {
			case ev := <-f.Events:
				if ev.Op&fsnotify.Create == fsnotify.Create {
					fmt.Printf("\n %c[0;48;33m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]created file:"+ev.Name, 0x1B)
				}
				if ev.Op&fsnotify.Remove == fsnotify.Remove {
					fmt.Printf("\n %c[0;48;31m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]deleted file:"+ev.Name, 0x1B)
				}
				if ev.Op&fsnotify.Rename == fsnotify.Rename {
					fmt.Printf("\n %c[0;48;34m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]renamed file:"+ev.Name, 0x1B)
				} else {
					fmt.Printf("\n %c[0;48;32m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]modified file:"+ev.Name, 0x1B)
				}
				// 有变化,放入重启数组中
				restartSlice = append(restartSlice, 1)
			case err := <-f.Errors:
				fmt.Println("error:", err)
			}
		}
	}()

	// 准备重启守护进程
	go restartDaemonServer()

	<-done
}

首先按照 fsnotify 的文档,创建一个 watcher ,然后添加监听目录(这里只是监听目录下的文件,不能监听子目录)

然后开启两个协程:

  1. 监听文件变化,如果有文件变化,把变化的个数写入一个 slice 里,这是一个阻塞的 for 循环

  2. 每隔1s中查看一次记录文件变化的 slice , 如果有的话,就重启服务器,并重新设置监听目录,然后清空 slice ,否则跳过

    递归遍历子目录,达到监听整个工程目录的效果:

func listeningWatcherDir(dir string) {
	filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		dir, _ := os.Getwd()
		pidFile = dir + "/" + Env.Get("PID_FILE")
		fileWatcher.Add(path)
		
		// 这里不能监听 pidFile,否则每次重启都会导致pidFile有更新,会不断的触发重启功能
		fileWatcher.Remove(pidFile)
		return nil
	})
}

这里这个 slice 的作用也就是为了避免当一次保存更新了多个文件的时候,也重启了多次服务器

下面看看重启服务器的代码:

	go func() {
				// 执行重启命令
				cmd := exec.Command("bingo", "run", "daemon", "restart")
				stdout, err := cmd.StdoutPipe()
				if err != nil {
					fmt.Println(err)
				}
				defer stdout.Close()

				if err := cmd.Start(); err != nil {
					panic(err)
				}
				reader := bufio.NewReader(stdout)
				//实时循环读取输出流中的一行内容
				for {
					line, err2 := reader.ReadString('\n')
					if err2 != nil || io.EOF == err2 {
						break
					}
					fmt.Print(line)
				}

				if err := cmd.Wait(); err != nil {
					fmt.Println(err)
				}
				opBytes, _ := ioutil.ReadAll(stdout)
				fmt.Print(string(opBytes))

			}()

使用 exec.Command() 方法得到一个 cmd

调用 cmd.Stdoutput() 得到一个输出管道,命令打印出来的数据都会从这个管道流出来

然后使用 reader := bufio.NewReader(stdout) 从管道中读出数据

用一个阻塞的 for 循环,不断的从管道中读出数据,以 \n 为一行,一行一行的读

并打印在控制台里,达到输出的效果,如果这几行不写的话,在新的进程里的 fmt.Println() 方法打印出来的数据将无法显示在控制台上.

就酱,最后贴下项目链接 silsuer/bingo ,欢迎star,欢迎PR,欢迎提意见


Recommend

  • 26
    • segmentfault.com 5 years ago
    • Cache

    Go优雅重启Web server示例-讲解版

    本文参考 GRACEFULLY RESTARTING A GOLANG WEB SERVER 进行归纳和说明。 你也可以从

  • 39
    • studygolang.com 5 years ago
    • Cache

    优雅关闭的 Go Web 服务器

    在这篇博文里我想要给你们展示下,如何创建一个可以优雅关闭的 Go HTTP Web 服务器。通过这个方法可以让服务器在它真正关闭之前清理某些资源,( 例如 ) 想象下完成数据库事务或者一些其他长时间的操作。我们将会用到在我

  • 25
    • www.tuicool.com 5 years ago
    • Cache

    go优雅升级/重启工具调研

    对于一个常驻、高访问量的网络服务来说,升级/重启时,一个难以忽视的问题是避免对正在通信的客户端造成影响。因此大家一直在寻求一种优雅、零宕机的升级/重启方案( seamless reload/upgrade )。在工程师们的日常实践中,...

  • 11
    • studygolang.com 3 years ago
    • Cache

    Go 平滑重启(优雅重启)

    问题背景 生产环境重要且复杂,许多的操作需要在任何场景都要保证正常运行。 如果我们对线上服务进行更新的步骤如下: kill -9 那么将不可避免的出现以下两个问题: 未处理完的请求,...

  • 5
    • virtual.51cto.com 3 years ago
    • Cache

    如何优雅的关闭JVM?

    前言 1、基本概述 程序的启动很简单,启动的时候通常会做一些预加载资源的操作。但是有时候关闭的时候,启动的时候...

  • 4

    一文讲懂服务的优雅重启和更新 - kevwan的个人空间 - OSCHINA - 中文开源技术交流社区 在服务端程序更新或重启时,如果我们直接 kill -9 杀掉旧进程并启动新进程,会有以下几个问题: 旧的请求未处理完,如果服务端进程直接...

  • 2

    Go-Server如何优雅的启动(热重启) 假设平台修改了配置文件只能重启才生效,但又不能影响正在访问的用户该如何是好呢~ A:负载均衡不就好了,我们一个一个来更新~ B:...

  • 2

    V2EX  ›  Windows 求助, Win10 更新后自动重启如何关闭?  

  • 3

    优雅关机就是服务端关机命令发出后不是立即关机,而是等待当前还在处理的请求全部处理完毕后再退出程序,是一种对客户端友好的关机方式。而执行Ctrl+C关闭服务端时,会强制结束进程导致正在访问的请求出现问题。 Go 1.8版本之后...

  • 3

    历史性转变!美国首次重启关闭的核电站 李笑寅 发表于 2024年03月29日 07:28

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK