You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

23 KiB

24管理进程如何设计完善的运行命令

你好,我是轩脉刃。

第13章我们引入命令行的时候将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。关键的代码逻辑如下

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对应的代码

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服务。

重点是启动的时候注意设置优雅关闭机制。先使用第六章实现的优雅关闭机制开启一个Goroutine启动服务主Goroutine监听信号当获取到信号之后等待所有请求都结束或者超过最长等待时长就结束信号。当然这里的最长等待时长可以设置为配置项从app.close_wait配置项中获取如果没有配置项我们默认使用5s的最长等待时长。

启动相关代码:

// 启动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方法的会把主进程给阻塞挂起来了怎么办呢

这个其实在第十四章定时任务中有说到,我们可以使用和定时任务一样的实现机制,使用开源库 go-daemon。比较重要,所以这里再啰嗦一下,理解go-daemon库的使用要理解最核心的daemon.Context结构

在我们框架这个需求中daemon方式启动命令为 ./hade app start --daemon=true 。所以在daemon.Context结构中的Args参数填写如下

// 创建一个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一个子进程的效果了

理解了这一点,对应的代码就很简单了:

// 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。它使用MIT协议虽然star数不多但是我个人亲测是功能齐全且有效的。在Golang中没有现成的设置进程名称的方法只能调用C的设置进程名称的方法 setproctitle。所以这个库使用的方式是使用cgo从Go中调用C的方法来实现进程名称的修改。

它的使用非常简单就是一个函数SetProcTitle方法

gspt.SetProcTitle("hade app")

现在,进程的启动就基本完成了。当然最后还有非常重要的关闭逻辑也记得加上。

好了以上我们讨论了start的关键设计再回头梳理一遍这个命令的实现步骤

  • 从四个方式获取参数appAddress
  • 获取参数daemon
  • 确认runtime目录和PID文件存在
  • 确认log目录的log文件存在
  • 判断是否是daemon方式。如果是就使用go-daemon来启动一个子进程如果不是直接进行后续调用
  • 使用gspt来设置当前进程名称
  • 启动app服务

具体的实现步骤相信你已经很清楚了,完整代码我们写在 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代表进程存在否则进程不存在。

// 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文件中

// 获取启动的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
   },
}

停止命令

命令的启动和获取完成了,就到了第三个停止命令了。既然有了进程号,需要停止一个进程,我们还是可以使用第六章说的信号量方法,回顾下当时说的四个关闭信号:

由于启动进程监听了SIGINT、SIGQUIT、SIGTERM 这三个信号所以我们在这三个信号中选取一个发送给PID所在的进程即可这里就选择更符合“关闭”语义的SIGTERM信号。

同样实现步骤也很清晰获取PID文件内容之后判断如果有PID文件且有内容再继续否则什么都不做之后就是

  • 将内容转换为PID的int类型转换失败则什么都不做
  • 直接给这个PID进程发送SIGTERM信号
  • 将PID文件内容清空

对应代码同样在framework/command/app.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检查旧进程是否存在如果不存在直接启动新进程如果存在这里就有一些需要注意的了。

//获取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时间是非常充裕的。关键代码如下

// 确认进程已经关闭,每秒检测一次, 最多检测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

// 确认进程已经关闭,每秒检测一次, 最多检测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中整体代码如下

// 重新启动一个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端口的服务

使用浏览器打开 localhost:8080/demo/demo

服务启动成功,且正常提供服务。

使用 ./hade app state 查看进程状态:

使用命令 ADDRESS=:8080 ./hade app restart 重新启动进程:

再次访问浏览器 localhost:8080/demo/demo正常提供服务

最后调用停止进程命令 ./hade app stop

到这里,对进程的启动、关闭、查询和重启的命令就验证完成了。

今天我们的所有代码都保存在GitHub上的geekbang/24分支了。只修改了framework/command/app.go 和 framework/util/exec.go文件其他保持不变。

小结

今天我们完成了运行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的区别么

欢迎在留言区分享你的思考。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。我们下节课见~