目录

很多同学第一次做守护进程时,都会遇到同一组问题:

  • 服务一退出,谁来拉起?
  • 怎么和当前终端彻底“断开关系”?
  • 父进程和子进程之间,最小可用的协作模型是什么?

这篇文章将分享一套“父子双进程”守护方案:父进程负责拉起和监控,子进程负责干活。 我会先讲清底层机制,再给一份可以直接改造的 Go 示例。


1. 先看实现守护需要的基础能力

不管你用不用第三方库,父子双进程守护都离不开几块基础能力:启动、等待、信号、环境变量、探活。

核心点有四个。

1.1 启动子进程并“忽略 stdio”

核心就是 exec.CommandContext + stdio 重定向到 /dev/null:

func Start(ctx context.Context, cmd string, args ...string) (*exec.Cmd, error) {
    c := exec.CommandContext(ctx, cmd, args...)

    in, err := os.Open(os.DevNull)
    if err != nil {
        return nil, err
    }
    out, err := os.Open(os.DevNull)
    if err != nil {
        _ = in.Close()
        return nil, err
    }
    errOut, err := os.Open(os.DevNull)
    if err != nil {
        _ = in.Close()
        _ = out.Close()
        return nil, err
    }

    c.Stdin = in
    c.Stdout = out
    c.Stderr = errOut

    if err := c.Start(); err != nil {
        _ = in.Close()
        _ = out.Close()
        _ = errOut.Close()
        return nil, err
    }
    _ = in.Close()
    _ = out.Close()
    _ = errOut.Close()
    return c, nil
}

把 stdin/stdout/stderr 都接到 os.DevNull 这一步非常关键,它的意义是:

  • 子进程不会因为父进程终端关闭而被“拖死”;
  • 不会意外把日志刷到控制台;
  • 进程模型更接近真正 daemon 的运行语义。

1.2 用“进程句柄”管理生命周期

实践里你至少需要三个动作:

  • 拿到子进程 PID(cmd.Process.Pid);
  • 给子进程发信号(cmd.Process.Signal(…));
  • Wait() 等待退出并读取退出码。

其中 Wait() 最关键:把 *exec.ExitError 转成 Unix WaitStatus 后拿到 exit code,上层才能做“按退出码分类策略”。

1.3 环境变量合并

子进程环境变量建议在 os.Environ() 基础上叠加:

  • 继承宿主环境,避免 PATH 等基础变量丢失;
  • 覆盖关键变量,实现“按实例注入配置”;
  • 更适合做 supervisor -> worker 的参数下发。

1.4 监控探活

探活常见手段是 signal 0:

func IsRunning(pid int) bool {
    if pid <= 0 {
        return false
    }
    p, err := os.FindProcess(pid)
    if err != nil {
        return false
    }
    return p.Signal(syscall.Signal(0)) == nil
}

它不发送真实信号,只做存在性和权限检测,很适合做轻量轮询监控。


2. 父子双进程模型到底怎么跑

我们先统一术语:

  • 父进程(supervisor):只负责“拉起 + 监控 + 重启 + 转发退出信号”
  • 子进程(worker):只负责业务逻辑

典型状态机如下:

  1. 父进程启动;
  2. 父进程拉起子进程;
  3. 子进程执行工作;
  4. 子进程异常退出 -> 父进程等待退避后重启;
  5. 父进程收到 SIGTERM/SIGINT -> 通知子进程优雅退出并回收;
  6. 父进程自己退出。

这个模型的价值在于:把“业务故障恢复”从业务代码里剥离出来。子进程不需要关心“挂了之后谁来拉起”,只要保证退出语义清晰即可。


3. 一份可运行的 Go 模板(父子双进程)

下面给一个单文件示例,展示如何用“角色切换 + 自拉起”实现双进程。你可以把它改成自己的服务入口。

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "math"
    "os"
    "os/exec"
    "os/signal"
    "strconv"
    "syscall"
    "time"
)

const (
    envRole     = "DAEMON_ROLE"
    roleParent  = "parent"
    roleChild   = "child"
    maxBackoff  = 30 * time.Second
    baseBackoff = 1 * time.Second
)

func main() {
    role := os.Getenv(envRole)
    switch role {
    case roleChild:
        runChild()
    default:
        if err := runParent(); err != nil {
            log.Fatalf("parent exit: %v", err)
        }
    }
}

func runParent() error {
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    log.Println("[parent] supervisor started")
    restarts := 0

    for {
        select {
        case <-ctx.Done():
            log.Println("[parent] receive stop signal")
            return nil
        default:
        }

        env := map[string]string{
            envRole: roleChild,
        }
        child, err := startWithEnv(ctx, env, os.Args[0], os.Args[1:]...)
        if err != nil {
            return fmt.Errorf("start child failed: %w", err)
        }
        log.Printf("[parent] child started, pid=%d", child.Process.Pid)

        code, waitErr := waitExitCode(child)
        if waitErr != nil {
            // 如果父 context 结束,通常会带着子进程一起退出
            if errors.Is(waitErr, context.Canceled) || errors.Is(waitErr, context.DeadlineExceeded) {
                log.Printf("[parent] wait interrupted by context: %v", waitErr)
                return nil
            }
            log.Printf("[parent] child wait error: %v", waitErr)
        }

        // 父进程已进入退出流程时,不再进入重启分支。
        if ctx.Err() != nil {
            return nil
        }

        // 约定:0 表示正常退出,不再拉起
        if code == 0 {
            log.Printf("[parent] child exit code=0, stop supervise")
            return nil
        }

        restarts++
        backoff := calcBackoff(restarts)
        log.Printf("[parent] child exit code=%d, restart after %s", code, backoff)

        timer := time.NewTimer(backoff)
        select {
        case <-ctx.Done():
            timer.Stop()
            return nil
        case <-timer.C:
        }
    }
}

func runChild() {
    log.Printf("[child] worker started, pid=%d", os.Getpid())

    // 模拟业务主循环:可替换为 HTTP server / queue consumer / scheduler
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    defer signal.Stop(quit)

    for {
        select {
        case <-ticker.C:
            log.Printf("[child] doing work")

            // 演示异常退出:通过环境变量触发非 0 退出
            if n := os.Getenv("FAIL_AFTER"); n != "" {
                v, _ := strconv.Atoi(n)
                if v > 0 {
                    v--
                    _ = os.Setenv("FAIL_AFTER", strconv.Itoa(v))
                    if v == 0 {
                        log.Printf("[child] simulate crash")
                        os.Exit(2)
                    }
                }
            }

        case sig := <-quit:
            log.Printf("[child] receive signal=%s, graceful shutdown", sig)
            // 在这里做资源释放:flush、close、drain ...
            os.Exit(0)
        }
    }
}

func calcBackoff(restarts int) time.Duration {
    // 指数退避并封顶,避免崩溃风暴
    exp := math.Pow(2, float64(restarts-1))
    d := time.Duration(exp) * baseBackoff
    if d > maxBackoff {
        return maxBackoff
    }
    if d < baseBackoff {
        return baseBackoff
    }
    return d
}

func startWithEnv(ctx context.Context, overlay map[string]string, cmd string, args ...string) (*exec.Cmd, error) {
    c := exec.CommandContext(ctx, cmd, args...)
    c.Env = mergeEnv(overlay)

    in, err := os.Open(os.DevNull)
    if err != nil {
        return nil, err
    }
    out, err := os.Open(os.DevNull)
    if err != nil {
        _ = in.Close()
        return nil, err
    }
    errOut, err := os.Open(os.DevNull)
    if err != nil {
        _ = in.Close()
        _ = out.Close()
        return nil, err
    }

    c.Stdin = in
    c.Stdout = out
    c.Stderr = errOut
    if err := c.Start(); err != nil {
        _ = in.Close()
        _ = out.Close()
        _ = errOut.Close()
        return nil, err
    }
    _ = in.Close()
    _ = out.Close()
    _ = errOut.Close()
    return c, nil
}

func mergeEnv(overlay map[string]string) []string {
    m := make(map[string]string)
    for _, kv := range os.Environ() {
        for i := 0; i < len(kv); i++ {
            if kv[i] == '=' {
                m[kv[:i]] = kv[i+1:]
                break
            }
        }
    }
    for k, v := range overlay {
        m[k] = v
    }
    out := make([]string, 0, len(m))
    for k, v := range m {
        out = append(out, k+"="+v)
    }
    return out
}

func waitExitCode(c *exec.Cmd) (int, error) {
    err := c.Wait()
    if err == nil {
        return 0, nil
    }
    var ee *exec.ExitError
    if errors.As(err, &ee) {
        ws := ee.ProcessState.Sys().(syscall.WaitStatus)
        return ws.ExitStatus(), nil
    }
    return -1, err
}

这份模板的关键点是:

  • 用 startWithEnv 给子进程打角色标记;
  • 用 waitExitCode 获取可靠退出码;
  • 用 context + signal 统一收敛退出链路;
  • 父进程负责退避重启,子进程只做业务。

4. 守护进程实现里最容易踩的坑

4.1 不调用 Wait,僵尸进程就会出现 只 Start 不 Wait,子进程退出后资源不会被回收,最终留下 zombie。
所以不管你封不封装,拿到进程句柄后都要在合适时机 Wait()。

4.2 快速重启导致“崩溃风暴”

子进程启动即崩,父进程立刻重启,会把 CPU 打满并刷爆日志。
正确做法就是上面的指数退避 + 上限。

4.3 信号只给父进程,子进程没收到

父进程收到了 SIGTERM,但没有显式转发/收敛,子进程可能继续跑。
建议做法:

  • 父进程收到退出信号后,取消 context 或显式 child.Signal(syscall.SIGTERM);
  • 等待一段宽限时间后再决定是否 SIGKILL;
  • 最后 Wait 回收。

4.4 日志策略混乱

守护模式通常会把 stdio 丢到 /dev/null,这对 daemon 很友好,但前提是你有独立日志链路(文件、journald、ELK、OpenTelemetry 等)。
否则你会遇到“服务跑着,但什么都看不到”。


5. 生产环境怎么选型

如果你跑在 systemd / k8s 这类成熟运行时上,通常不需要自己写父子守护,交给平台重启策略更简单。
但下面场景自己实现仍然有价值:

  • 嵌入式或受限环境,没有现成 supervisor;
  • 单二进制交付,希望进程自治;
  • 需要非常细颗粒度的重启策略和前置检查。

可以把这套模式理解为“进程控制积木”:框架不重要,关键是把启动、等待、信号、重启策略这几块拼好。


6. 小结

父子双进程守护这件事,说白了就三句话:

  • 父进程只管生命周期,子进程只管业务。
  • 退出码是协作协议,Wait 是协议兑现。
  • 脱离终端、信号收敛、退避重启,是可用性的三个底座。

基于这套轻量模式,你可以很快搭出可靠的 daemon 原型,然后再按业务把配置、日志、健康检查、热升级逐步补齐。


7. 最小可运行 Demo:main.go + Makefile + systemd

如果你想马上动手,这里给一套最小目录,复制就能跑。

7.1 目录结构

daemon-demo/
├── go.mod
├── main.go
├── Makefile
└── daemon-demo.service

7.2 main.go

为避免重复,main.go 直接使用第 3 节的完整代码,保存为 daemon-demo/main.go 即可。

7.3 Makefile

APP=daemon-demo

.PHONY: build run clean

build:
    go build -o $(APP) .

run: build
    ./$(APP)

clean:
    rm -f $(APP)

7.4 daemon-demo.service

这里用 systemd 启动“父进程”。由于父进程内部已经有子进程守护逻辑,建议 Restart=on-failure 即可,避免和内部重启策略互相“打架”。

[Unit]
Description=Go Parent-Child Daemon Demo
After=network.target

[Service]
Type=simple
WorkingDirectory=/opt/daemon-demo
ExecStart=/opt/daemon-demo/daemon-demo
Restart=on-failure
RestartSec=5
User=nobody
Group=nogroup

# 把日志交给 journald;也可改为文件落盘方案
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

7.5 本地验证步骤

# 1) 本地直接跑
make run

# 2) 观察输出:默认持续运行;可用 FAIL_AFTER 模拟异常退出并触发重启
#    FAIL_AFTER=5 ./daemon-demo

# 3) Ctrl+C,观察父子一起退出(优雅停止)

如果你要验证 systemd:

sudo mkdir -p /opt/daemon-demo
sudo cp daemon-demo /opt/daemon-demo/
sudo cp daemon-demo.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable daemon-demo
sudo systemctl start daemon-demo
sudo systemctl status daemon-demo
sudo journalctl -u daemon-demo -f

跑通这套 demo 后,你再把 runChild 里的“心跳循环”替换成你自己的 HTTP 服务、队列消费或任务调度逻辑,就完成了从原理到业务落地的闭环。