# 24|管理进程:如何设计完善的运行命令? 你好,我是轩脉刃。 在[第13章](https://time.geekbang.org/column/article/426765)我们引入命令行的时候,将Web启动方式改成了一个命令行。但是当时只完成了一个最简单的启动Web服务的命令,这节课,我们要做的是完善这个Web服务运行命令,让Web服务的运行有完整的启动、停止、重启、查询的进程管理功能。 这套完整的进程管理功能,能让应用管理者非常方便地通过一套命令来统一管控一个应用,降低应用管理者的管理成本,后续也能为实现应用自动化部署到远端服务的工具提供了基础。下面我们来具体看下如何设计这套命令并且实现它吧。 ### 运行命令的设计 首先照惯例需要设计一下运行命令,一级命令为 app,二级命令设计如下: * `./hade app start` 二级命令,启动一个app服务 * `./hade app state` 二级命令,获取启动的app的信息 * `./hade app stop` 二级命令,停止已经启动的app服务 * `./hade app restart` 二级命令,重新启动一个app服务 这四个二级命令,有app服务的启动、停止、重启、查询,基本上已经把一个app服务启动的状态变更都包含了,能基本满足后面我们对于一个应用的管理需求。下面来讨论下每个命令的功能和设计。 ### 启动命令 首先是start这个命令,写在framework/command/app.go中。我们先分析下参数。 想要启动app服务,至少需要一个参数,就是**启动服务的监听地址**。如何获取呢?首先可以直接从默认配置获取,另外因为这是一个控制台命令,也一定可以直接从命令行获取。除了这两种方式,我们回顾下之前的配置项获取方法,还有环境变量和配置项。 所以总结起来,环境变量这个参数我们设计为有四个方式可以获取,一个是直接从命令行参数获取address参数,二是从环境变量ADDRESS中获取,然后是从配置文件中获取配置项app.address,最后如果以上三个方式都没有设置,就使用默认值:8888。关键的代码逻辑如下: ```go if appAddress == "" { envService := container.MustMake(contract.EnvKey).(contract.Env) if envService.Get("ADDRESS") != "" { appAddress = envService.Get("ADDRESS") } else { configService := container.MustMake(contract.ConfigKey).(contract.Config) if configService.IsExist("app.address") { appAddress = configService.GetString("app.address") } else { appAddress = ":8888" } } } ``` 除了监听地址的参数,回忆之前cron命令运行的时候,启动app服务,我们是有两种启动方式的,一种是启动后直接挂在控制台,这种启动方式适合调试开发使用;而另外一种,以守护进程daemon的方式启动,直接挂载在后台。所以,对于这两种启动方式,我们也需要有一个参数daemon,标记是使用哪种方式启动。 有了appAddress、daemon这两个参数,我们顺着继续想**启动服务时需要的记录文件**。 不管是使用挂载方式,还是daemon方式启动进程,都能获取到一个进程PID,启动app服务的时候,要将这个PID记录在一个文件中,这里我们就存储在 app/storage/runtime/app.pid 文件中。在运行时候,需要保证这个目录和文件是存在的。 同时也会产生日志,日志存放在app/storage/log/app.log中,所以我们要确认这个目录是否存在。 关于app.pid和app.log对应的代码: ```go appService := container.MustMake(contract.AppKey).(contract.App) pidFolder := appService.RuntimeFolder() if !util.Exists(pidFolder) { if err := os.MkdirAll(pidFolder, os.ModePerm); err != nil { return err } } serverPidFile := filepath.Join(pidFolder, "app.pid") logFolder := appService.LogFolder() if !util.Exists(logFolder) { if err := os.MkdirAll(logFolder, os.ModePerm); err != nil { return err } } // 应用日志 serverLogFile := filepath.Join(logFolder, "app.log") currentFolder := util.GetExecDirectory() ``` 好到这里,准备工作都做好了,我们看看Web服务的启动,逻辑和之前设计的基本上没有什么区别,使用net/http来启动一个Web服务。 **重点是启动的时候注意设置优雅关闭机制**。先使用[第六章](https://time.geekbang.org/column/article/421354)实现的优雅关闭机制:开启一个Goroutine启动服务,主Goroutine监听信号,当获取到信号之后,等待所有请求都结束或者超过最长等待时长,就结束信号。当然,这里的最长等待时长可以设置为配置项,从app.close\_wait配置项中获取,如果没有配置项,我们默认使用5s的最长等待时长。 启动相关代码: ```go // 启动AppServer, 这个函数会将当前goroutine阻塞 func startAppServe(server *http.Server, c framework.Container) error { // 这个goroutine是启动服务的goroutine go func() { server.ListenAndServe() }() // 当前的goroutine等待信号量 quit := make(chan os.Signal) // 监控信号:SIGINT, SIGTERM, SIGQUIT signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) // 这里会阻塞当前goroutine等待信号 <-quit // 调用Server.Shutdown graceful结束 closeWait := 5 configService := c.MustMake(contract.ConfigKey).(contract.Config) if configService.IsExist("app.close_wait") { closeWait = configService.GetInt("app.close_wait") } timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(closeWait)*time.Second) defer cancel() if err := server.Shutdown(timeoutCtx); err != nil { return err } return nil } ``` 但是这里还出现了一个问题,挂在控制台的启动,比较简单,直接调用封装好的 startAppServe 就行了。但daemon方式如何启动呢?它是不能直接在主进程中调用startAppServe方法的,会把主进程给阻塞挂起来了,怎么办呢? 这个其实在[第十四章](https://time.geekbang.org/column/article/427090)定时任务中有说到,我们可以使用和定时任务一样的实现机制,使用开源库 [go-daemon](https://github.com/sevlyar/go-daemon)。比较重要,所以这里再啰嗦一下,**理解go-daemon库的使用,要理解最核心的daemon.Context结构**。 在我们框架这个需求中,daemon方式启动命令为 `./hade app start --daemon=true` 。所以在daemon.Context结构中的Args参数填写如下: ```go // 创建一个Context cntxt := &daemon.Context{ ... // 子进程的参数,按照这个参数设置,子进程的命令为 ./hade app start --daemon=true Args: []string{"", "app", "start", "--daemon=true"}, } // 启动子进程,d不为空表示当前是父进程,d为空表示当前是子进程 d, err := cntxt.Reborn() if d != nil { // 父进程直接打印启动成功信息,不做任何操作 fmt.Println("app启动成功,pid:", d.Pid) fmt.Println("日志文件:", serverLogFile) return nil } ... ``` 有的同学对这个启动子进程的Reborn可能有些疑惑。 我们把Reborn理解成fork,当调用这个函数的时候,父进程会继续往下走,但是返回值d不为空,它的信息是子进程的进程号等信息。而子进程会重新运行对应的命令,再次进入到Reborn函数的时候,返回的d就为nil。所以**在Reborn的后面,我们让父进程直接return,而让子进程继续往后进行操作,这样就达到了fork一个子进程的效果了**。 理解了这一点,对应的代码就很简单了: ```go // daemon 模式 if appDaemon { // 创建一个Context cntxt := &daemon.Context{ // 设置pid文件 PidFileName: serverPidFile, PidFilePerm: 0664, // 设置日志文件 LogFileName: serverLogFile, LogFilePerm: 0640, // 设置工作路径 WorkDir: currentFolder, // 设置所有设置文件的mask,默认为750 Umask: 027, // 子进程的参数,按照这个参数设置,子进程的命令为 ./hade app start --daemon=true Args: []string{"", "app", "start", "--daemon=true"}, } // 启动子进程,d不为空表示当前是父进程,d为空表示当前是子进程 d, err := cntxt.Reborn() if err != nil { return err } if d != nil { // 父进程直接打印启动成功信息,不做任何操作 fmt.Println("app启动成功,pid:", d.Pid) fmt.Println("日志文件:", serverLogFile) return nil } defer cntxt.Release() // 子进程执行真正的app启动操作 fmt.Println("deamon started") gspt.SetProcTitle("hade app") if err := startAppServe(server, container); err != nil { fmt.Println(err) } return nil } ``` 到这里服务的进程启动成功,最后还有一点细节,对于启动的进程,我们一般都希望能自定义它的进程名称。 这里可以使用一个第三方库 [gspt](https://github.com/erikdubbelboer/gspt)。它使用MIT协议,虽然star数不多,但是我个人亲测是功能齐全且有效的。在Golang中没有现成的设置进程名称的方法,只能调用C的设置进程名称的方法 setproctitle。所以这个库使用的方式是,使用cgo从Go中调用C的方法来实现进程名称的修改。 它的使用非常简单,就是一个函数SetProcTitle方法: ```go gspt.SetProcTitle("hade app") ``` 现在,进程的启动就基本完成了。当然最后还有非常重要的关闭逻辑也记得加上。 好了,以上我们讨论了start的关键设计,再回头梳理一遍这个命令的实现步骤: * 从四个方式获取参数appAddress * 获取参数daemon * 确认runtime目录和PID文件存在 * 确认log目录的log文件存在 * 判断是否是daemon方式。如果是,就使用go-daemon来启动一个子进程;如果不是,直接进行后续调用 * 使用gspt来设置当前进程名称 * 启动app服务 具体的实现步骤相信你已经很清楚了,完整代码我们写在 [framework/command/app.go](https://github.com/gohade/coredemo/blob/geekbang/24/framework/command/app.go)中了。 ### 获取进程 已经完成了启动进程的命令,那么第二个获取进程PID的命令就非常简单了。因为启动命令的时候创建了一个PID文件,app/storage/runtime/app.pid,读取这个文件就可以获取到进程的PID信息了。 但是这里我们可以更谨慎一些加一步,获取到PID之后,去操作系统中查询这个PID的进程是否存在,存在的话,就确定这个PID是可行的。 如何根据PID查询一个进程是否存在呢?常用的比如Linux的ps和grep命令,基本上都是通过Linux的其他命令来检查输出,**但最为可靠的方式是直接使用信号对接要查询的进程:通过给进程发送信号来检测,这个信号就是信号0**。 给进程发送信号0之后什么都不会操作,如果进程存在,不返回错误信息;如果进程不存在,会返回不存在进程的错误信息。在Golang中,我们可以用os库的Process结构来发送信号。 代码在 framework/util/exec.go 中,逻辑也很清晰,先用os.FindProcess来获取这个PID对应的进程,然后给进程发送signal 0, 如果返回nil,代表进程存在,否则进程不存在。 ```go // CheckProcessExist 检查进程pid是否存在,如果存在的话,返回true func CheckProcessExist(pid int) bool { // 查询这个pid process, err := os.FindProcess(pid) if err != nil { return false } // 给进程发送signal 0, 如果返回nil,代表进程存在, 否则进程不存在 err = process.Signal(syscall.Signal(0)) if err != nil { return false } return true } ``` 这个关键函数实现之后,其他的就很容易了。 这里我们也简单说一下进程获取的具体步骤:获取PID文件内容之后,做判断,如果有PID文件且有内容就继续,否则返回无进程;然后: * 将内容转换为PID的int类型,转换失败视为无进程; * **使用signal 0 确认这个进程是否存在,存在返回结果有进程,不存在返回结构无进程**。 具体代码如下,存放在 framework/command/app.go文件中: ```go // 获取启动的app的pid var appStateCommand = &cobra.Command{ Use: "state", Short: "获取启动的app的pid", RunE: func(c *cobra.Command, args []string) error { container := c.GetContainer() appService := container.MustMake(contract.AppKey).(contract.App) // 获取pid serverPidFile := filepath.Join(appService.RuntimeFolder(), "app.pid") content, err := ioutil.ReadFile(serverPidFile) if err != nil { return err } if content != nil && len(content) > 0 { pid, err := strconv.Atoi(string(content)) if err != nil { return err } if util.CheckProcessExist(pid) { fmt.Println("app服务已经启动, pid:", pid) return nil } } fmt.Println("没有app服务存在") return nil }, } ``` ### 停止命令 命令的启动和获取完成了,就到了第三个停止命令了。既然有了进程号,需要停止一个进程,我们还是可以使用第六章说的信号量方法,回顾下当时说的四个关闭信号: ![](https://static001.geekbang.org/resource/image/e9/50/e93163afd641b744b2b3f8faf46f4e50.jpg?wh=1920x1080) 由于启动进程监听了SIGINT、SIGQUIT、SIGTERM 这三个信号,所以我们在这三个信号中选取一个发送给PID所在的进程即可,这里就选择更符合“关闭”语义的SIGTERM信号。 同样实现步骤也很清晰,获取PID文件内容之后,判断如果有PID文件且有内容再继续,否则什么都不做,之后就是: * 将内容转换为PID的int类型,转换失败则什么都不做 * 直接给这个PID进程发送SIGTERM信号 * 将PID文件内容清空 对应代码同样在framework/command/app.go中: ```go // 停止一个已经启动的app服务 var appStopCommand = &cobra.Command{ Use: "stop", Short: "停止一个已经启动的app服务", RunE: func(c *cobra.Command, args []string) error { container := c.GetContainer() appService := container.MustMake(contract.AppKey).(contract.App) // GetPid serverPidFile := filepath.Join(appService.RuntimeFolder(), "app.pid") content, err := ioutil.ReadFile(serverPidFile) if err != nil { return err } if content != nil && len(content) != 0 { pid, err := strconv.Atoi(string(content)) if err != nil { return err } // 发送SIGTERM命令 if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { return err } if err := ioutil.WriteFile(serverPidFile, []byte{}, 0644); err != nil { return err } fmt.Println("停止进程:", pid) } return nil }, } ``` ### 重启命令 最后我们要完成重启命令,还是在framework/command/app.go中。大致逻辑也很清晰,读取PID文件之后判断,如果PID文件中没有PID,说明没有进程在运行,直接启动新进程;如果PID文件中有PID,检查旧进程是否存在,如果不存在,直接启动新进程,如果存在,这里就有一些需要注意的了。 ```go //获取pid ... if content != nil && len(content) != 0 { // 解析pid是否存在 if util.CheckProcessExist(pid) { // 关闭旧的pid进程 ... } } appDaemon = true // 启动新的进程 return appStartCommand.RunE(c, args) ``` 因为重启的逻辑是先结束旧进程,再启动新进程。结束进程和停止命令一样,使用SIGTERM信号就能保证进程的优雅关闭了。但是**由于新、旧进程都是使用同一个端口,所以必须保证旧进程结束,才能启动新的进程**。 而怎么保证旧进程确实结束了呢? 这里可以使用前面定义的 CheckProcessExist 方法,每秒做一次轮询,检测PID对应的进程是否已经关闭。那么轮询多少次呢? 我们知道在启动进程的时候,设置了一个优雅关闭的最大超时时间closeWait,这个closeWait的时间设置为秒。那么**为了轮询检查旧进程是否关闭,我们只需要设置次数超过closeWait的轮询时间即可**。考虑到net/http 在closeWait之后还有一些程序运行的逻辑,这里我们可以设置为2 \* closeWait,时间是非常充裕的。关键代码如下: ```go // 确认进程已经关闭,每秒检测一次, 最多检测closeWait * 2秒 for i := 0; i < closeWait*2; i++ { if util.CheckProcessExist(pid) == false { break } time.Sleep(1 * time.Second) } ``` 再严谨一些,可以这么设置,如果在2\*closeWait时间内,旧进程还未关闭,那么就不能启动新进程了,需要直接返回错误。所以,在 2 \* closeWait 轮询之后,我们还需要再做一次检查,检查进程是否关闭,如果没有关闭的话,直接返回error: ```go // 确认进程已经关闭,每秒检测一次, 最多检测closeWait * 2秒 for i := 0; i < closeWait*2; i++ { if util.CheckProcessExist(pid) == false { break } time.Sleep(1 * time.Second) } // 如果进程等待了2*closeWait之后还没结束,返回错误,不进行后续的操作 if util.CheckProcessExist(pid) == true { fmt.Println("结束进程失败:"+strconv.Itoa(pid), "请查看原因") return errors.New("结束进程失败") } ``` 在确认旧进程结束后,记得把PID文件清空,再启动一个新进程。启动进程的逻辑还是比较复杂的,就不重复写了,我们直接调用appStartCommand的RunE方法来实现,会更优雅一些。 同其他命令一样,这里再梳理一下判断旧进程存在之后详细的实现步骤,如果存在: * 发送SIGTERM信号 * 循环2\*closeWait次数,每秒执行一次查询进程是否已经结束 * 如果某次查询进程已经结束,或者等待2\*closeWait循环结束之后,再次查询一次进程 * 如果还未结束,返回进程结束失败 * 如果已经结束,将PID文件清空,启动新进程 在framework/command/app.go中,整体代码如下: ```go // 重新启动一个app服务 var appRestartCommand = &cobra.Command{ Use: "restart", Short: "重新启动一个app服务", RunE: func(c *cobra.Command, args []string) error { container := c.GetContainer() appService := container.MustMake(contract.AppKey).(contract.App) // GetPid serverPidFile := filepath.Join(appService.RuntimeFolder(), "app.pid") content, err := ioutil.ReadFile(serverPidFile) if err != nil { return err } if content != nil && len(content) != 0 { pid, err := strconv.Atoi(string(content)) if err != nil { return err } if util.CheckProcessExist(pid) { // 杀死进程 if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { return err } if err := ioutil.WriteFile(serverPidFile, []byte{}, 0644); err != nil { return err } // 获取closeWait closeWait := 5 configService := container.MustMake(contract.ConfigKey).(contract.Config) if configService.IsExist("app.close_wait") { closeWait = configService.GetInt("app.close_wait") } // 确认进程已经关闭,每秒检测一次, 最多检测closeWait * 2秒 for i := 0; i < closeWait*2; i++ { if util.CheckProcessExist(pid) == false { break } time.Sleep(1 * time.Second) } // 如果进程等待了2*closeWait之后还没结束,返回错误,不进行后续的操作 if util.CheckProcessExist(pid) == true { fmt.Println("结束进程失败:"+strconv.Itoa(pid), "请查看原因") return errors.New("结束进程失败") } fmt.Println("结束进程成功:" + strconv.Itoa(pid)) } } appDaemon = true // 直接daemon方式启动apps return appStartCommand.RunE(c, args) }, } ``` ### 测试 下面来测试一下。首先记得使用 `./hade build sef` 命令编译,我们设置的默认服务启动地址为 “:8888”,这里就不用这个默认启动地址,使用环境变量ADDRESS=:8080 来启动服务。这样能测试到环境变量是否能生效。 调用命令 `ADDRESS=:8080 ./hade app start --daemon=true` 以daemon方式启动一个8080端口的服务: ![](https://static001.geekbang.org/resource/image/5d/a4/5dc59ecb06330bd5097c79fc2040e6a4.png?wh=1316x150) 使用浏览器打开 localhost:8080/demo/demo: ![](https://static001.geekbang.org/resource/image/aa/97/aa16b16c741b837d94682664d1c0yy97.png?wh=1458x372) 服务启动成功,且正常提供服务。 使用 `./hade app state` 查看进程状态: ![](https://static001.geekbang.org/resource/image/eb/ee/eb02d0c5f341cbc3632037a6f119dcee.png?wh=922x98) 使用命令 `ADDRESS=:8080 ./hade app restart` 重新启动进程: ![](https://static001.geekbang.org/resource/image/2a/c4/2a1b9e077e357364529e43b786e943c4.png?wh=1152x194) 再次访问浏览器 localhost:8080/demo/demo,正常提供服务: ![](https://static001.geekbang.org/resource/image/95/f4/95a1ce5d43a1bf28133a75f773341cf4.png?wh=804x280) 最后调用停止进程命令 `./hade app stop` : ![](https://static001.geekbang.org/resource/image/0f/40/0fb19b39412d970ccb72e43635830040.png?wh=964x100) 到这里,对进程的启动、关闭、查询和重启的命令就验证完成了。 今天我们的所有代码都保存在GitHub上的[geekbang/24](https://github.com/gohade/coredemo/tree/geekbang/24)分支了。只修改了framework/command/app.go 和 framework/util/exec.go文件,其他保持不变。 ![](https://static001.geekbang.org/resource/image/c1/6b/c137079e767c7fb3a4e1bd2292b6ca6b.png?wh=584x1626) ### 小结 今天我们完成了运行app相关的命令,包括app一级命令和四个二级命令,启动app服务、停止app服务、重启app服务、查询app服务。基本上已经把一个app服务启动的状态变更都包含了。有了这些命令,我们对app的控制就方便很多了。特别是daemon运行模式,为线上运行提供了不少方便。 在实现这四个命令的过程中,我们使用了不少第三方库,gspt、go-daemon,这些库的使用你要能熟练掌握,特别是go-daemon库,我们已经不止一次使用到它了。确认一个进程是否已经结束,我们使用每秒做一次轮询的 CheckProcessExist 方法实现了检查机制,并仔细考虑了轮训的次数和效果,你可以多多体会这么设计的好处。 ### 思考题 我们在启动应用的时候,使用的地址格式为“:8080”,其实这里也可以为“localhost:8080”、“127.0.0.1:8080”或者“10.11.22.33:8080”(10.11.22.33为本机绑定的IP)。你了解localhost、127.0.0.1、10.11.22.33 以及不填写IP的区别么? 欢迎在留言区分享你的思考。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。我们下节课见~