文章预览
前几日,遇到一个问题,需要收集在容器里一个 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 进程:我只想做一个安静的好进程。主要有以下特点:后台运行(无控制终端,且不能打开新的控制终端,不接收控制台信号),无标准输出、标准错误输出(不会打印到控制台,或者重定向到其它文件),无标准输入(不接收控制台输入)。
-
一些系统调用
-
umask() 设置文件模式创建屏蔽字。子进程一般会继承父进程的文件模式创建屏蔽字。
#include <sys/types.h>
#include <sys/stat.h>
mode_t umask(mode_t mask);
-
setsid() 创建一个新会话。
#include <unistd.h>
pid_t setsid(void);
如果调用该函数的进程是一个进程组的组长,则函数出错,若不是一个进程组的组长, 则新建一个会话,并执行下面步骤:
-
该进程成为会话首进程。进程组 ID 为调用进程的进程 ID.
-
该进程成为组长进程。
-
该进程没有控制终端,如果之前有控制终端,则联系被切断.
-
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 2),其它父进程打开的但是不需要的也要关掉。
-
没有切换工作目录。daemon 进程一般是常驻进程,正在运行的进程的工作目录所在的文件系统无法被卸载。一般需要将 dameon 进程的工作目录切换到 / 下。
-
没有处理信号, 一般进程在收到 SIGHUP 信号时默认行为为中断进程,daemon 进程一般在收到 SIGHUP 信号时不会中断进程,而是会重新加载配置文件,例如 nginx。我们这里没有配置文件,简单忽略该信号。
#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
查看日志输出。
问题解答
-
容器中,docker logs 的输出其实是容器内 init 进程即 1 号进程的 stdout 和 stderr. 因此将 /proc/1/fd/1 软链接到对应的日志文件 即可将日志输出到容器的标准输出上。
-
daemon 进程试图读取标准输入时会出错。同理只需要将 daemon 进程的输出文件重定向到前台控制台前台进程的 stdout 即可输出到控制台了。
-
supervisor 和 docker 都不能直接运行 daemon 进程,是因为 daemon 进程必定要执行 fork 操作。而与 supervisor 与 docker 直接监视到的进程由于 fork 操作而退出。也无法凭借之前的进程找到 daemon 进程,所以无法监视 daemon 进程。视为程序退出。
………………………………