1.前言

因为最近用go语言开发了一个websocket服务,启动后需要后台运行,还希望异常退出时可以自动重启。整体思路是启动程序后,转为后台运行,这个后台程序暂且称为守护进程(daemon)。它不处理具体业务逻辑,只是再次按一样的参数调用自身,启动一个子进程,有子进程负责业务逻辑处理。守护进程监视子进程状态,若退出则再次启动一次。如此可以保证服务异常中止时可以及时重启。

github.com/sevlyar/go-daemon

另外还找了一些库,思路有所不同,基本是通过增加特殊参数来标记进程身份的,这让我感并没有完美的启动了自身进程,有些遗憾。

最终决定自己实现一个库解决我项目中的需求,同时也期望它是一个很通用的库,可以快速方便把go语言编写的服务程序转为后台运行,或者转为守护进程的模式运行。本文算是对这次探索的一次总结和梳理。

2.区分两个概念

后台运行daemon
后台进程
守护进程后台运行守护进程

3.首先排除的方案

nohub&setsid

4.相关的标准库的探索

因为种种原因,在go语言中我们无法很好的直接操作 fork 调用。我们转换一下思路,启动自身为一个子进程,也可以看做是调用外部程序。标准库中找到下面三种方法: - syscall.ForkExec - os.StartProcess - exec.Cmd

syscall.ForkExecos.StartProcessexec.Cmdos.StartProcessexec.Cmd
func StartProcess(name string, argv []string, attr *ProcAttr) (*Process, error) StartProcess使用提供的属性、程序名、命令行参数开始一个新进程。StartProcess函数是一个低水平的接口。os/exec包提供了高水平的接口,应该尽量使用该包。如果出错,错误的底层类型会是*PathError。
exec.Cmd
type Cmd struct {
    // Path是将要执行的命令的路径。
    //
    // 该字段不能为空,如为相对路径会相对于Dir字段。
    Path string
    // Args保管命令的参数,包括命令名作为第一个参数;如果为空切片或者nil,相当于无参数命令。
    //
    // 典型用法下,Path和Args都应被Command函数设定。
    Args []string
    // Env指定进程的环境,如为nil,则是在当前进程的环境下执行。
    Env []string
    // Dir指定命令的工作目录。如为空字符串,会在调用者的进程当前目录下执行。
    Dir string
    // Stdin指定进程的标准输入,如为nil,进程会从空设备读取(os.DevNull)
    Stdin io.Reader
    // Stdout和Stderr指定进程的标准输出和标准错误输出。
    //
    // 如果任一个为nil,Run方法会将对应的文件描述符关联到空设备(os.DevNull)
    //
    // 如果两个字段相同,同一时间最多有一个线程可以写入。
    Stdout io.Writer
    Stderr io.Writer
    // ExtraFiles指定额外被新进程继承的已打开文件流,不包括标准输入、标准输出、标准错误输出。
    // 如果本字段非nil,entry i会变成文件描述符3+i。
    //
    // BUG: 在OS X 10.6系统中,子进程可能会继承不期望的文件描述符。
    // http://golang.org/issue/2603
    ExtraFiles []*os.File
    // SysProcAttr保管可选的、各操作系统特定的sys执行属性。
    // Run方法会将它作为os.ProcAttr的Sys字段传递给os.StartProcess函数。
    SysProcAttr *syscall.SysProcAttr
    // Process是底层的,只执行一次的进程。
    Process *os.Process
    // ProcessState包含一个已经存在的进程的信息,只有在调用Wait或Run后才可用。
    ProcessState *os.ProcessState
    // 内含隐藏或非导出字段
}
exec.Cmd
type Cmd
func Command(name string, arg ...string) *Cmd
func (c *Cmd) StdinPipe() (io.WriteCloser, error)
func (c *Cmd) StdoutPipe() (io.ReadCloser, error)
func (c *Cmd) StderrPipe() (io.ReadCloser, error)
func (c *Cmd) Run() error
func (c *Cmd) Start() error
func (c *Cmd) Wait() error
func (c *Cmd) Output() ([]byte, error)
func (c *Cmd) CombinedOutput() ([]byte, error)

5.尝试让go程序后台运行

5.1 go调用普通外部程序

exec.Command()
//示例:shell.go

package main

import (
    "fmt"
    "os/exec"
    "bytes"
)

func main() {
    str, err := execShell("ls -l /|head -n 3")
    fmt.Println(err)
    fmt.Println(str)
}

//@link https://www.zhihu.com/people/zh-five
func execShell(s string) (string, error) {
    //这里是一个小技巧, 以'/bin/bash -c xxx'的方式调用shell命令, 则可以在命令中使用管道符,组合多个命令
    cmd := exec.Command("/bin/bash", "-c", s)
    var out bytes.Buffer
    cmd.Stdout = &out //把执行命令的标准输出定向到out
    cmd.Stderr = &out //把命令的错误输出定向到out

    //启动一个子进程执行命令,阻塞到子进程结束退出
    err := cmd.Run()
    if err != nil {
        return "", err
    }

    return out.String(), err
}
go run shell.go
$ go run shell.go
<nil>
total 21
drwxrwxr-x+ 109 root  admin  3488  6  4 16:17 Applications
drwxr-xr-x+  69 root  wheel  2208 12 22 23:19 Library
ls -l /|head -n 3fork

5.2 go程序调用自身转为后台运行

我们调用自身程序成功后,是希望子进程可以独自运行,然后父进程退出。这与上面调用外部程序的例子有几点不一样了:

  • 调用自身程序时,父进程不能以阻塞的方式进行了。因为若阻塞了,那就无法提前退出了
  • 父进程不能等待获取子进程的结果输出了,同样是为了提前退出
exec.Cmdfunc (c *Cmd) Start() errornil
exec.CmdStdoutStderrio.WriterStdoutStderr

按以上的解决方案,我代码修改一下,用于启动go程序自身

// !!! 切勿运行此程序 !!!
//示例:self.go

package main

import (
    "log"
    "os"
    "os/exec"
)

func main() {
    background("/tmp/daemon.log")
}

func background(logFile string) error {
    //os.Args 是一个切片,保管了命令行参数,第一个是程序名
    //go程序启动时不包含管道符了,就直接运行了
    cmd := exec.Command(os.Args[0], os.Args[1:]...)

    //若有日志文件, 则把子进程的输出导入到日志文件
    if logFile != "" {
        stdout, err := os.OpenFile(logFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
        if err != nil {
            log.Println(os.Getpid(), ": 打开日志文件错误:", err)
            return err
        }
        cmd.Stderr = stdout
        cmd.Stdout = stdout
    }

    //异步启动子进程
    err := cmd.Start()
    if err != nil {
        return err
    }

    return nil
}
background()

解决怎么区分父进程子进程的问题

为了区分子进程父进程,大多数开源库的解决方案是设置特殊的参数。这种方案是入侵式的,新设置的参数,有可能和go程序原有参数冲突。虽然设置一些奇怪的参数名来降低冲突概率,但至少在使用过程中,并非完全保持参数原样启动子进程,可能会造成使用者的迷惑。这种方案不太完美,先舍弃。

github.com/sevlyar/go-daemonexec.CmdEnvfork
//示例:self1.go

package main

import (
    "fmt"
    "log"
    "os"
    "os/exec"
    "time"
)

func main() {
    cmd, err := background("/tmp/daemon.log")
    if err != nil {
        log.Fatal("启动子进程失败:", err)
    }

    //根据返回值区分父进程子进程
    if cmd != nil { //父进程
        log.Println("我是父进程:", os.Getpid(), "; 启动了子进程:", cmd.Process.Pid, "; 运行参数", os.Args)
        return //父进程退出
    } else { //子进程
        log.Println("我是子进程:", os.Getpid(), "; 运行参数:",os.Args)
    }

    //以下代码只有子进程会执行
    log.Println("只有子进程会运行:", os.Getpid(), "; 开始...")
    time.Sleep(time.Second * 20) //休眠20秒
    log.Println("只有子进程会运行:", os.Getpid(), "; 结束")
}

//@link https://www.zhihu.com/people/zh-five
func background(logFile string) (*exec.Cmd, error) {
    envName := "XW_DAEMON" //环境变量名称
    envValue := "SUB_PROC" //环境变量值

    val := os.Getenv(envName) //读取环境变量的值,若未设置则为空字符串
    if val == envValue {      //监测到特殊标识, 判断为子进程,不再执行后续代码
        return nil, nil
    }

    /*以下是父进程执行的代码*/

    //因为要设置更多的属性, 这里不使用`exec.Command`方法, 直接初始化`exec.Cmd`结构体
    cmd := &exec.Cmd{
        Path: os.Args[0],
        Args: os.Args, //注意,此处是包含程序名的
        Env:  os.Environ(), //父进程中的所有环境变量
    }

    //为子进程设置特殊的环境变量标识
    cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", envName, envValue))

    //若有日志文件, 则把子进程的输出导入到日志文件
    if logFile != "" {
        stdout, err := os.OpenFile(logFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
        if err != nil {
            log.Println(os.Getpid(), ": 打开日志文件错误:", err)
            return nil, err
        }
        cmd.Stderr = stdout
        cmd.Stdout = stdout
    }

    //异步启动子进程
    err := cmd.Start()
    if err != nil {
        return nil, err
    }

    return cmd, nil
}
go rungo run
#编译
$ go build self1.go

#随便设置一些参数,查看执行效果
$ ./self1 -a -b
2020/06/05 19:05:44 我是父进程: 37886 ; 启动了子进程: 37887 ; 运行参数 [./self1 -a -b]

#查看子进程 37887
$ ps -ef |grep self1
  501 37887     1   0  7:05下午 ttys003    0:00.01 ./self1 -a -b

#查看子进程输出日志
$ tail /tmp/daemon.log
2020/06/05 19:05:44 我是子进程: 37887 ; 运行参数: [./self1 -a -b]
2020/06/05 19:05:44 只有子进程会运行: 37887 ; 开始...
2020/06/05 19:06:04 只有子进程会运行: 37887 ; 结束

从日志输出看,我们成功的把go程序已自身转为了一个后台运行的子进程,并且子进程的运行参数和父进程完全一样。

background

5.3 如何在子进程中再次启动子进程

backgroundbackground
background
backgroundruuIdxbackgroundenvIdxrunIdxenvIdxbackground
runIdxenvIdxrunIdxenvIdxrunIdxenvIdx
background
//示例:self2.go

package main

import (
    "fmt"
    "log"
    "os"
    "os/exec"
    "strconv"
    "time"
)

func main() {
    logFile := "/tmp/daemon.log"
    background(logFile, true) //启动子进程后退出
    background(logFile, true) //启动子进程后退出
    background(logFile, true) //启动子进程后退出

    //以下代码只有最后一代子进程会执行
    log.Println(os.Getpid(), "业务代码开始...")
    time.Sleep(time.Second * 20) //休眠20秒
    log.Println(os.Getpid(), "业务代码结束")
}

var runIdx int = 0               //background调用计数
const ENV_NAME = "XW_DAEMON_IDX" //环境变量名

//@link https://www.zhihu.com/people/zh-five
func background(logFile string, isExit bool) (*exec.Cmd, error) {
    //判断子进程还是父进程
    runIdx++
    envIdx, err := strconv.Atoi(os.Getenv(ENV_NAME))
    if err != nil {
        envIdx = 0
    }
    if runIdx <= envIdx { //子进程, 退出
        return nil, nil
    }

    /*以下是父进程执行的代码*/

    //因为要设置更多的属性, 这里不使用`exec.Command`方法, 直接初始化`exec.Cmd`结构体
    cmd := &exec.Cmd{
        Path: os.Args[0],
        Args: os.Args,      //注意,此处是包含程序名的
        Env:  os.Environ(), //父进程中的所有环境变量
    }

    //为子进程设置特殊的环境变量标识
    cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%d", ENV_NAME, runIdx))

    //若有日志文件, 则把子进程的输出导入到日志文件
    if logFile != "" {
        stdout, err := os.OpenFile(logFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
        if err != nil {
            log.Println(os.Getpid(), ": 打开日志文件错误:", err)
            return nil, err
        }
        cmd.Stderr = stdout
        cmd.Stdout = stdout
    }

    //异步启动子进程
    err = cmd.Start()
    if err != nil {
        log.Println(os.Getpid(), "启动子进程失败:", err)
        return nil, err
    } else {
        //执行成功
        log.Println(os.Getpid(), ":", "启动子进程成功:", "->", cmd.Process.Pid, "\n ")
    }

    //若启动子进程成功, 父进程是否直接退出
    if isExit {
        os.Exit(0)
    }

    return cmd, nil
}

编译后执行

#编译
$ go build self2.go

#随便设置一些参数执行
$ ./self2 -a -b -c 123
2020/06/05 19:58:27 38984 : 启动子进程成功: -> 38985

#查看进程,看到的是最终子进程
$ ps -ef |grep self2
  501 38990     1   0  7:58下午 ttys003    0:00.01 ./self2 -a -b -c 123

#查看日志
$ tail /tmp/daemon.log
2020/06/05 19:58:27 38985 : 启动子进程成功: -> 38988

2020/06/05 19:58:27 38988 : 启动子进程成功: -> 38990

2020/06/05 19:58:28 38990 业务代码开始...
2020/06/05 19:58:48 38990 业务代码结束

由日志可以看出,成功的启动了3代子进程:38984(父进程)-> 38985 -> 38988 -> 38990。最终的38990子进程执行了业务代码。

注意:此种策略判断的前提条件是,逐代启动子进程。若某进程里重复启动了多个子进程,那么其子进程若想再启动子进程,可能会失败。如以下例子
//非逐代启动子进程的异常情况
func main() {
    logFile := "/tmp/daemon.log"
    cmd,err := background(logFile, false)//启动子进程后不自动退出
    if err != nil {
        log.Fatal("启动子进程失败:", err)
    }

    //根据返回值区分父进程子进程
    if cmd != nil { //父进程
        //父进程再次启动一个子进程, 非逐代启动了
        background(logFile, true) //启动子进程后退出
        return //父进程退出
    }

    //父进程里第2次启动的子进程, 此处调用出现异常情况: 将不会启动子进程,而会直接略过执行后面的代码
    background(logFile, true) //启动子进程后退出

    //以下代码只有最后一代子进程会执行
    log.Println(os.Getpid(), "业务代码开始...")
    time.Sleep(time.Second * 20) //休眠20秒
    log.Println(os.Getpid(), "业务代码结束")
}

执行结果为

#编译
$ go build self2.go

#执行。启动了两个子进程,注意第2此启动39291进程将有异常
$ ./self2 -a -b -c 123
2020/06/05 20:16:58 39289 : 启动子进程成功: -> 39290

2020/06/05 20:16:58 39289 : 启动子进程成功: -> 39291

#查看进程
$ ps -ef |grep self2
  501 39291     1   0  8:16下午 ttys003    0:00.01 ./self2 -a -b -c 123
  501 39292     1   0  8:16下午 ttys003    0:00.01 ./self2 -a -b -c 123

#查看日志。主要只有39290再次启动了子进程,而39291则直接执行了业务代码
$ tail /tmp/daemon.log
2020/06/05 20:16:58 39290 : 启动子进程成功: -> 39292

2020/06/05 20:16:58 39291 业务代码开始...
2020/06/05 20:16:58 39292 业务代码开始...
2020/06/05 20:17:18 39291 业务代码结束
2020/06/05 20:17:18 39292 业务代码结束

若是重复启动的子进程不再启动子进程,则无影响。后续守护进程的实现,会有这种情况。

6.守护进程的实现

func (c *Cmd) Wait() errorWait()Wait()

以下是示例代码

//守护进程的实现, 基于之前的 background() 。可以替换示例self2.go中的main()函数进行测试
func main(){
    logFile := "/tmp/daemon.log"

    //启动一个子进程作为守护进程
    background(logFile, true) //启动子进程后退出

    //在守护进程中循环启动子进程
    for{
        cmd,err := background(logFile, false)//启动子进程后不自动退出
        if err != nil {
            log.Fatal("启动子进程失败:", err)
        }

        //根据返回值区分父进程子进程
        if cmd != nil { //父进程
            cmd.Wait() //等等子进程执行结束(监视子进程)
        } else { //子进程, 跳出让其执行后续业务代码
            break
        }
    }

    //以下是业务代码
    log.Println(os.Getpid(), "业务代码开始...")
    time.Sleep(time.Second * 20) //休眠20秒
    log.Println(os.Getpid(), "业务代码结束")

}

执行结果为:

#编译
$ go build self2.go

#执行,启动的守护进程为 39541
$ ./self2 -a -b -c 123
2020/06/05 20:36:05 39540 : 启动子进程成功: -> 39541

#查看进程。可以看出,业务进程是39543,其父进程是39541
$ ps -ef |grep self2
  501 39541     1   0  8:36下午 ttys003    0:00.01 ./self2 -a -b -c 123
  501 39543 39541   0  8:36下午 ttys003    0:00.01 ./self2 -a -b -c 123

#查看日志。可以看到业务进程39543退出后,守护进程及时的启动了另一个业务进程39574
$ tail /tmp/daemon.log
2020/06/05 20:36:05 39541 : 启动子进程成功: -> 39543

2020/06/05 20:36:05 39543 业务代码开始...
2020/06/05 20:36:25 39543 业务代码结束
2020/06/05 20:36:25 39541 : 启动子进程成功: -> 39574

2020/06/05 20:36:25 39574 业务代码开始...

到此,守护进程的功能已经实现了。但作为一个库,对使用者还不太友好,我们需要封装一下。并且结合业务场景似乎还有一些细节问题需要考虑一下:

  • 一个正常服务进程一般不会异常退出,可能并不需要无限的循环重启,这可以让使用者自定义最大重启次数
  • 若业务进程连续不断的异常退出,是不应该继续不断重启了。可设置一个允许的最大连续异常退出次数
  • 实际编写的服务程序,异常退出时不一定退出码就是非0。可以设置一个最短运行时间,协助判断是否是异常退出
xdaemon

其核心代码如下

package xdaemon

import (
    "fmt"
    "log"
    "os"
    "os/exec"
    "strconv"
    "time"
)

const ENV_NAME = "XW_DAEMON_IDX"

//运行时调用background的次数
var runIdx int = 0

//守护进程
type Daemon struct {
    LogFile     string //日志文件, 记录守护进程和子进程的标准输出和错误输出. 若为空则不记录
    MaxCount    int    //循环重启最大次数, 若为0则无限重启
    MaxError    int    //连续启动失败或异常退出的最大次数, 超过此数, 守护进程退出, 不再重启子进程
    MinExitTime int64  //子进程正常退出的最小时间(秒). 小于此时间则认为是异常退出
}

// 把本身程序转化为后台运行(启动一个子进程, 然后自己退出)
// logFile 若不为空,子程序的标准输出和错误输出将记入此文件
// isExit  启动子加进程后是否直接退出主程序, 若为false, 主程序返回*os.Process, 子程序返回 nil. 需自行判断处理
func Background(logFile string, isExit bool) (*exec.Cmd, error) {
    //判断子进程还是父进程
    runIdx++
    envIdx, err := strconv.Atoi(os.Getenv(ENV_NAME))
    if err != nil {
        envIdx = 0
    }
    if runIdx <= envIdx { //子进程, 退出
        return nil, nil
    }

    //设置子进程环境变量
    env := os.Environ()
    env = append(env, fmt.Sprintf("%s=%d", ENV_NAME, runIdx))

    //启动子进程
    cmd, err := startProc(os.Args, env, logFile)
    if err != nil {
        log.Println(os.Getpid(), "启动子进程失败:", err)
        return nil, err
    } else {
        //执行成功
        log.Println(os.Getpid(), ":", "启动子进程成功:", "->", cmd.Process.Pid, "\n ")
    }

    if isExit {
        os.Exit(0)
    }

    return cmd, nil
}

func NewDaemon(logFile string) *Daemon {
    return &Daemon{
        LogFile:     logFile,
        MaxCount:    0,
        MaxError:    3,
        MinExitTime: 10,
    }
}

// 启动后台守护进程
func (d *Daemon) Run() {
    //启动一个守护进程后退出
    Background(d.LogFile, true)

    //守护进程启动一个子进程, 并循环监视
    var t int64
    count := 1
    errNum := 0
    for {
        //daemon 信息描述
        dInfo := fmt.Sprintf("守护进程(pid:%d; count:%d/%d; errNum:%d/%d):",
            os.Getpid(), count, d.MaxCount, errNum, d.MaxError)
        if errNum > d.MaxError {
            log.Println(dInfo, "启动子进程失败次数太多,退出")
            os.Exit(1)
        }
        if d.MaxCount > 0 && count > d.MaxCount {
            log.Println(dInfo, "重启次数太多退出")
            os.Exit(0)
        }
        count++

        t = time.Now().Unix() //启动时间戳
        cmd, err := Background(d.LogFile, false)
        if err != nil { //启动失败
            log.Println(dInfo, "子进程启动失败;", "err:", err)
            errNum++
            continue
        }

        //子进程,
        if cmd == nil {
            log.Printf("子进程pid=%d: 开始运行...", os.Getpid())
            break
        }

        //父进程: 等待子进程退出
        err = cmd.Wait()
        dat := time.Now().Unix() - t //子进程运行秒数
        if dat < d.MinExitTime {     //异常退出
            errNum++
        } else { //正常退出
            errNum = 0
        }
        log.Printf("%s 监视到子进程(%d)退出, 共运行了%d秒: %v\n", dInfo, cmd.ProcessState.Pid(), dat, err)
    }
}

func startProc(args, env []string, logFile string) (*exec.Cmd, error) {
    cmd := &exec.Cmd{
        Path:        args[0],
        Args:        args,
        Env:         env,
        SysProcAttr: NewSysProcAttr(),
    }

    if logFile != "" {
        stdout, err := os.OpenFile(logFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
        if err != nil {
            log.Println(os.Getpid(), ": 打开日志文件错误:", err)
            return nil, err
        }
        cmd.Stderr = stdout
        cmd.Stdout = stdout
    }

    err := cmd.Start()
    if err != nil {
        return nil, err
    }

    return cmd, nil
}

xdaemon库的使用示例

background
//本示例, 将把进程转为后台运行, 并保留所有参数不变

package main

import (
    "github.com/zh-five/xdaemon"
    "log"
    "os"
    "time"
)

func main() {
    logFile := "daemon.log"

    //启动一个子进程后主程序退出
    xdaemon.Background(logFile, true)

    //以下代码只有子程序会执行
    log.Println(os.Getpid(), "start...")
    time.Sleep(time.Second * 10)
    log.Println(os.Getpid(), "end")
}
daemon
//本示例, 将启动一个后台运行的守护进程. 然后由守护进程启动和维护最终子进程

package main

import (
    "github.com/zh-five/xdaemon"
    "flag"
    "log"
    "os"
    "time"
)

func main() {
    d := flag.Bool("d", false, "是否后台守护进程方式运行")
    flag.Parse()

    //启动守护进程
    if *d {
        //创建一个Daemon对象
        logFile := "daemon.log"
        d := xdaemon.NewDaemon(logFile)
        //调整一些运行参数(可选)
        d.MaxCount = 2 //最大重启次数

        //执行守护进程模式
        d.Run()
    }

    //当 *d = true 时以下代码只有最终子进程会执行, 主进程和守护进程都不会执行
    log.Println(os.Getpid(), "start...")
    time.Sleep(time.Second * 10)
    log.Println(os.Getpid(), "end")

}

有bug或建议,欢迎联系我。本博客留言或https://github.com/zh-five/xdaemon都行。写得有点长了,感谢您的阅读!