今天看啥  ›  专栏  ›  西门早柿

Daemon进程与日志输出

西门早柿  · 简书  ·  · 2021-02-10 21:37

文章预览

前几日,遇到一个问题,需要收集在容器里一个 daemon 进程的输出日志。一般来说,容器里的进程只需要打日志到标准输出就可以了,但 daemon 进程比较特殊,daemon 进程没有控制终端,也没有继承相应的文件描述符。
上述问题可以转化为一个通用的问题:如何让一个 daemon 进程输出日志到当前的控制台上?
另外,supervisor 及 docker 在启动的时候,是不允许启动后台进程的,这是为什么呢?
在解答之前,先来看一些相关的基础知识。

基础知识

  • 控制终端
    控制终端的本质是一个设备文件,由会话首进程打开,由终端驱动程序控制。控制终端接收用户从终端的输入,将输入内容传送给与该终端相关联的前台进程;或者发送相应的信号到相应的前台进程。
    1.一个控制终端对应着一个标准输入、标准输出及标准错误输出(fd 0, 1, 2)。
    2.一个控制终端对应一个会话。
    3.一个控制终端对应着一组前台进程组,多组后台进程组,这些进程组属于同一个会话。用户在终端输入 CTRL-C 时,控制终端会发送信号到前台进程组的所有进程。
    4.一个控制终端对应着一个会话首进程,该进程是建立会话的第一个进程,通常为 shell 进程,终端断开后收到相应的信号(挂起信号),进行相应的清理工作。

  • 会话
    一个会话包含多个进程组。
    一个会话包含一个会话首进程,只有会话首进程可以打开控制终端。

  • 进程组
    多个进程组成一个进程组。有一个进程组长,进程组长的 pid 即为该进程组的 group id。通常通过 fork 调用生成的进程都属于同一个进程组。一个进程只能为它自己或者子进程设置 group id。

    [图片上传失败...(image-fffa87-1612964265920)]
    会话、进程组、控制终端的关系

查看一个进程的 pid,gpid,spid,command

ps -p 32036 -o pid,ppid,pgid,sid,tpgid,comm
  PID  PPID  PGID   SID TPGID COMMAND
32036  1098 32036 32036    -1 sshd

上述命令打印 32036 号进程,父进程为 1098,进程组号为 32036,会话号为 32036。

ps -o pid,ppid,pgid,sid,tpgid,comm

打印前台进程组的进程信息。

ps axjf

查看进程树。

  • daemon 进程
    daemon 进程:我只想做一个安静的好进程。主要有以下特点:后台运行(无控制终端,且不能打开新的控制终端,不接收控制台信号),无标准输出、标准错误输出(不会打印到控制台,或者重定向到其它文件),无标准输入(不接收控制台输入)。
  • 一些系统调用
  1. umask() 设置文件模式创建屏蔽字。子进程一般会继承父进程的文件模式创建屏蔽字。

    #include <sys/types.h>
    #include <sys/stat.h>
    
    mode_t umask(mode_t mask);
    
  2. setsid() 创建一个新会话。

    #include <unistd.h>
    pid_t setsid(void);
    

    如果调用该函数的进程是一个进程组的组长,则函数出错,若不是一个进程组的组长, 则新建一个会话,并执行下面步骤:

    1. 该进程成为会话首进程。进程组 ID 为调用进程的进程 ID.
    2. 该进程成为组长进程。
    3. 该进程没有控制终端,如果之前有控制终端,则联系被切断.
  3. fork()
    创建进程,调用一次,返回两次。

    pid = fork();
    if(pid==0)
    printf("In child process");
    else
    printf("In parent process");
    

创建一个 daemon 进程

  • 脱离控制终端
    可以通过 fork() 和 setsid() 系统调用来实现。

    pid = fork();
    if(pid == 0){
      setsid(); // 保证该子进程一定不是组长进程,保证 setsid() 调用成功.
      pid = fork();
      if(pid == 0){
        //执行应用程序,此时既不是会话首进程也不是组长进程,再也不能打开控制终端.  
      }
    }
    
  • 设置文件创建屏蔽位为 0,umask(0). 创建子进程一般都需要设置这一步。

  • 清空信号屏蔽位。

  • 关闭不需要的文件描述符。

  • 关闭标准输入、输出、标准错误输出。可以直接 close() 相应的 fd,或者重定向到 /dev/null 或其它日志文件。重定向可用 dup() 系统调用实现。

  • 一个 C 语言实现的简单的 damonize 例程:

#include <sys/types.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>

void daemonize(char* path, char* argv[]) {
    pid_t pid;
    if(pid=fork()==0) {
        umask(0);
        setsid();
        if(pid =fork()==0){
            //关闭所有已打开的文件描述符
            int fd;
            fd = open("/home/wxl/test.log", O_RDWR | O_APPEND | O_CREAT);
            dup2(fd, 1);
            dup2(fd, 2);
            execv(path, argv);
        }
        else if(pid > 0) {
            exit(0);
        }
    } else if(pid > 0) {
        exit(0);
    }
}

int main(int argc, char* argv[]) {
    if(argc < 2){
        return -1;
    }

    char* path = argv[1];
    char* exec_argv[10];
    int i = 0;
    for(i = 2; i < argc; i++) {
        exec_argv[i-2] = argv[i];
    }

    daemonize(path, exec_argv);
}

上面的程序实现了一个简单的 daemonize 例程。有以下一些地方还没有考虑到:

  1. 只处理了标准输出和标准错误输出两个文件描述符(1 2),其它父进程打开的但是不需要的也要关掉。
  2. 没有切换工作目录。daemon 进程一般是常驻进程,正在运行的进程的工作目录所在的文件系统无法被卸载。一般需要将 dameon 进程的工作目录切换到 / 下。
  3. 没有处理信号, 一般进程在收到 SIGHUP 信号时默认行为为中断进程,daemon 进程一般在收到 SIGHUP 信号时不会中断进程,而是会重新加载配置文件,例如 nginx。我们这里没有配置文件,简单忽略该信号。
  • 一个完整的 daemonize 例程
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/resource.h>

void err_quit(char* msg) {
    printf("%s\n", msg);
    exit(-1);
}

void daemonize(char* path, char* argv[]) {
    pid_t pid;
    struct rlimit r1; //用来获取当前进程打开的最大文件描述符数
    struct sigaction sa; //用来进行信号处理
    int i;
    if(pid=fork()==0) {
        umask(0);
        setsid();

        sa.sa_handler = SIG_IGN;
        sigemptyset(&sa.sa_mask);
        sa.sa_flags = 0;// 控制信号的一些行为
        if (sigaction(SIGHUP, &sa, NULL)<0)
            err_quit("cannot igore SIGHUP");
        if (getrlimit(RLIMIT_NOFILE, &r1)<0)
            err_quit("get rlimit error");

        if(pid =fork()==0){
            if(chdir("/") < 0)
                err_quit("cannot chdir to /");
            //关闭所有已打开的文件描述符
            if(r1.rlim_max == RLIM_INFINITY)
                r1.rlim_max = 1024;
            for(i=3;i<r1.rlim_max;i++)
                close(i);
            int fd;
            fd = open("/dev/null", O_RDWR);
            dup2(fd, 0);
            fd = open("/home/wanglu/test.log", O_RDWR | O_APPEND | O_CREAT);
            dup2(fd, 1);
            dup2(fd, 2);
            execv(path, argv);
        }
        else if(pid > 0) {
            exit(0);
        }
    } else if(pid > 0) {
        exit(0);
    }
}

int main(int argc, char* argv[]) {
    if(argc < 2){
        return -1;
    }

    char* path = argv[1];
    char* exec_argv[10];
    int i = 0;
    for(i = 2; i < argc; i++) {
        exec_argv[i-2] = argv[i];
    }

    daemonize(path, exec_argv);
}

写一个简单的 shell 程序进行测试:

#!/bin/bash
while true;
    do echo "hello"
    sleep 1
done

编译运行,注意现在的工作路径为 /,可执行程序要写绝对路径。

gcc daemonize.c -o daemonize
./daemonize /home/wxl/hello.sh

查看进程:

ps -p 30467 -o pid,ppid,pgid,sid,comm
  PID  PPID  PGID   SID COMMAND
30467     1 30466 30466 hello.sh

可以看到 daemon 进程 pid 为 30467, 其父进程为 init 进程,组长为 30466,为会话首进程,session id 也是 30466。
可以 tailf test.log 查看日志输出。

问题解答

  1. 容器中,docker logs 的输出其实是容器内 init 进程即 1 号进程的 stdout 和 stderr. 因此将 /proc/1/fd/1 软链接到对应的日志文件 即可将日志输出到容器的标准输出上。
  2. daemon 进程试图读取标准输入时会出错。同理只需要将 daemon 进程的输出文件重定向到前台控制台前台进程的 stdout 即可输出到控制台了。
  3. supervisor 和 docker 都不能直接运行 daemon 进程,是因为 daemon 进程必定要执行 fork 操作。而与 supervisor 与 docker 直接监视到的进程由于 fork 操作而退出。也无法凭借之前的进程找到 daemon 进程,所以无法监视 daemon 进程。视为程序退出。
………………………………

原文地址:访问原文地址
快照地址: 访问文章快照
总结与预览地址:访问总结与预览