多进程 daemon进程和优雅重启

2023年 8月 14日 60.8k 0

本文会详细介绍主流的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
    # the default is not to use systemd for cgroups because the delegate issues still
    # exists and systemd currently does not support the cgroup feature set required
    # for containers run by docker
    ExecStart=/usr/bin/dockerd -H fd://
    ExecReload=/bin/kill -s HUP $MAINPID
    LimitNOFILE=1048576
    # Having non-zero Limit*s causes performance problems due to accounting overhead
    # in the kernel. We recommend using cgroups to do container-local accounting.
    LimitNPROC=infinity
    LimitCORE=infinity
    # Uncomment TasksMax if your systemd version supports it.
    # Only systemd 226 and above support this version.
    TasksMax=infinity
    TimeoutStartSec=0
    # set delegate yes so that systemd does not reset the cgroups of docker containers
    Delegate=yes
    # kill only the docker process, not all processes in the cgroup
    KillMode=process
    # 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 {

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论