多进程 daemon进程和优雅重启

本文会详细介绍主流的daemon进程的实现方案,以及网络编程中如何实现优雅重启,这些都是多进程的一些编程技巧!

如何创建daemon进程

  • 为什么我们需要daemon进程?
  • 我们平时做服务器开发都是启动一个程序,这个程序是一个前台程序,但是前台程序它一直在那开着,我想让他后台运行,例如mysql的server,那么怎么解决呢,我自己怎么实现一个daemon进程呢?

  • 常见的手段
    • systemctl 是linux最常见的手段,它需要软件定义一个 .service 文件,来定义和管理 软件的等 www.freedesktop.org/software/sy…
    • 可以用 systemctl status 查看所有 systemctl 的进程

    image-20230807132711259

    ~ cat /lib/systemd/system/docker.service
    [Unit]
    Description=Docker Application Container Engine
    Documentation=https://docs.docker.com
    After=network-online.target docker.socket firewalld.service
    Wants=network-online.target
    Requires=docker.socket
    
    [Service]
    Type=notify
    1. the default is not to use systemd for cgroups because the delegate issues still
    1. exists and systemd currently does not support the cgroup feature set required
    1. for containers run by docker
    ExecStart=/usr/bin/dockerd -H fd://
    ExecReload=/bin/kill -s HUP $MAINPID
    LimitNOFILE=1048576
    1. Having non-zero Limit*s causes performance problems due to accounting overhead
    1. in the kernel. We recommend using cgroups to do container-local accounting.
    LimitNPROC=infinity
    LimitCORE=infinity
    1. Uncomment TasksMax if your systemd version supports it.
    1. Only systemd 226 and above support this version.
    TasksMax=infinity
    TimeoutStartSec=0
    1. set delegate yes so that systemd does not reset the cgroups of docker containers
    Delegate=yes
    1. kill only the docker process, not all processes in the cgroup
    KillMode=process
    1. restart the docker process if it exits prematurely
    Restart=on-failure
    StartLimitBurst=3
    StartLimitInterval=60s
    
    [Install]
    WantedBy=multi-user.target
    

    其实大概描述了,当前软件详情信息,如何启动当前软件等,具体可以参考这个文章 segmentfault.com/a/119000002…

    • nohup 就更简单了,只需要 nohup 命令一下即可,其实它所做的就更简单了,就是一个后台运行,并不会涉及到重启等操作

    • supervisor 我个人感觉就和 systemctl差不多

    如何实现一个daemon进程

    首先需要了解一个进程的机制,例如我们在shell里执行了一个命令,那么整体流程是,shell进程启动了我们的进程,那么我们当前进程的父亲进程就是 shell 进程,当我们把shell进程关了,那么我们的进程也没了!

    下图是我写了一个 Go代码,其实就是解释了上面说的!整个进程的关系!

    image-20230807134220378

    根据上面描述基本无解了,那么怎么办呢,实际上这里就需要用到孤儿进程,孤儿进程的父进程ID是1,他的回收权就转移给了init进程(进程ID为1),那么如何创建一个孤儿进程了!

    其实很简单,就是当父进程退出,子进程还在运行,此时子进程就是孤儿进程了,孤儿进程的生命周期会被系统决定!

    具体文章可以看: blog.csdn.net/a745233700/…

    简单实现一个daemon进程

    这个例子是实现一个 http 服务的daemon进程,直接运行后会后台启动一个http服务!

    package main
    
    import (
        "fmt"
        "net/http"
        "os"
        "path/filepath"
    )
    
    func main() {
        fmt.Printf("当前ppid: %v, pid: %v, args: %#vn", os.Getppid(), os.Getpid(), os.Args)
        if len(os.Args) > 1 && os.Args[1] == "child_process" { // 子进程
            fmt.Println("child process")
            if err := http.ListenAndServe(":10099", http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
                fmt.Printf("method: %s, url: %sn", request.Method, request.URL)
                _, _ = writer.Write([]byte(`hello world`))
            })); err != nil {
                panic(err)
            }
            return
        }
    
        executable, err := os.Executable()
        if err != nil {
            panic(err)
        }
        dir, err := os.Getwd()
        if err != nil {
            panic(err)
        }
        fmt.Printf("dir: %sn", dir)
        stdout, err := os.OpenFile(filepath.Join(dir, "child_process.log"), os.O_CREATE|os.O_WRONLY, 0644)
        if err != nil {
            panic(err)
        }
        process, err := os.StartProcess(executable, []string{os.Args[0], "child_process"}, &os.ProcAttr{
            Dir: dir,
            Files: []*os.File{ // 共享fd
                os.Stdin,  // stdin
                stdout, // stdout
                stdout, // std error
            },
        })
        if err != nil {
            panic(err)
        }
        pidFile, err := os.OpenFile(filepath.Join(dir, "child_process.pid"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
        if err != nil {
            panic(err)
        }
        defer pidFile.Close()
        if _, err := pidFile.WriteString(fmt.Sprintf("%d", process.Pid)); err != nil {
            panic(err)
        }
        fmt.Printf("create child process %dn", process.Pid)
    
        return
        // 不执行这个,直接return就是孤儿进程了
        if _, err := process.Wait(); err != nil {
            panic(err)
        }
    }
    
    

    image-20230807142245307

    我们成功实现了一个 孤儿进程, 如何结束孤儿进程了, 直接 kill 孤儿进程的进程ID即可

    ➜  test git:(master) ✗ ps -ef | grep './main'  
      502 48190     1   0  2:42PM ttys052    0:00.01 ./main child_process
      502 48312 43352   0  2:42PM ttys052    0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --exclude-dir=.idea --exclude-dir=.tox ./main
    ➜  test git:(master) ✗ kill `cat child_process.pid`
    

    封装 daemon 进程

    这里我就不造轮子了,大概可以看一下 github.com/sevlyar/go-… 这个项目,我大概介绍一些几个方法的核心原理

  • ctx
  • func (s *DaemonService) newCtx() *daemon.Context {
        return &daemon.Context{
            PidFileName: filepath.Join(s.homeDir, PidFile), // pid所在文件,主要是解决如何获取子进程pid的问题
            PidFilePerm: 0644,
            LogFileName: filepath.Join(s.homeDir, LogFile), // 替换子进程的stdout/stderr
            LogFilePerm: 0644,
            WorkDir:     s.homeDir, // 子进程工作目录
            Umask:       027, // 文件权限,有兴趣可以查一下
            Args:        os.Args, // 子进程参数
        }
    }
    
  • DaemonStart
  • func (s *DaemonService) DaemonStart() error {
        ctx := s.newCtx()
        // Search
        // 其实就是读取pid文件,判断进程是否存在
        search, err := ctx.Search()
        if err == nil && search != nil {
            return fmt.Errorf(`the background program has started. PID: %d`, search.Pid)
        }
        // Reborn
        // 如果是父进程,则返回 child process
        // 如果是子进程,则返回 空 (判断父子进程逻辑很简单就是根据环境变量 _GO_DAEMON=1,子进程会被注入这个环境变量)
        childProcess, err := ctx.Reborn()
        if err != nil {
            return fmt.Errorf("unable to run background program, reason: %v", err)
        }
      // 子进程不为空,说明是父进程,直接退出即可(这里子进程就是孤儿进程了)
        if childProcess != nil {
            logs.Infof("start parent process success. pid: %d, cpid: %d", os.Getpid(), childProcess.Pid)
            return nil
        }
        defer func() {
            _ = ctx.Release() // 释放一些当时创建时分配的资源
        }()
        logs.Infof("start child process success. ppid: %d, pid: %d", os.Getppid(), os.Getpid())
        return s.run()
    }
    

    实现优雅重启tcp服务

  • 现在已经有了 k8s / 自研的发布平台都支持滚动重启了,滚动重启阶段会新建一个新的服务,然后等待旧服务结束。但是吧他比较消耗资源,因为假如你服务1w台,滚动粒度时10%,那么需要冗余1000台服务器的资源!
  • 原地重启吧,需要实现优雅重启或者暴力重启了,暴力重启可能会短暂影响sla,所以优雅重启也非常重要!
  • 优雅重启的大概原理就是:多进程的文件共享,这里共享的是tcp socket的文件,当需要重启时候会创建一个新进程,然后通知旧进程关闭监听socket文件,两个进程共享socket文件,新进程启动后会重新监听共享的socket,那么新的连接会打向新进程,旧进程依然处理旧的连接,最后处理完后旧进程会退出,最终实现优雅重启,它很好的解决了新连接/旧连接的处理!
  • 造个轮子

    这个例子,我是主进程正常创建和监听TCPListener, 当需要重启的时候此时需要关闭 TCPListener 然后创建子进程继续监听,当再监听到重启时同样的需要主进程关闭子进程!

    package main
    
    import (
        "fmt"
        "net"
        "net/http"
        "os"
        "os/exec"
        "os/signal"
        "strings"
        "sync"
        "syscall"
        "time"
    )
    
    func main() {
        var name string
        var err error
        var listen net.Listener
    
        // 如果是子进程的话,listen 获取不太一样
        if os.Getenv("is_slave") == "true" {
            file := os.NewFile(uintptr(3), "")
            listen, err = net.FileListener(file)
            name = fmt.Sprintf("slave-%d", os.Getpid())
        } else {
            listen, err = net.Listen("tcp", ":10086")
            name = "master"
        }
        if err != nil {
            panic(fmt.Errorf("init (%s) listen err: %v", name, err))
        }
        debug("[%s] start", name)
    
        go func() {
            if isSlave(name) {
                return
            }
    
            var listenFd *os.File
            var loadFD = sync.Once{}
            loadListenFd := func() *os.File {
                loadFD.Do(func() {
                    tl := listen.(*net.TCPListener)
                    fds, err := tl.File()
                    if err != nil {
                        panic(fmt.Errorf("tl.File() find err: %vn", err))
                    }
                    if err := listen.Close(); err != nil { // 只需要关闭一次,所以用sync.once
                        panic(err)
                    }
                    listenFd = fds
                })
                return listenFd
            }
    
            // 父亲进程watch 变更
            debug("[%s] watch file changed", name)
    
            var command *exec.Cmd
            var done chan bool
            var errch chan error
            for {