linux守护进程、SIGHUP与nohup详解
前段时间帮忙定位个问题。docker容器故障恢复后,其中的keepalived进程始终⽆法启动,也看不到Keepalived的⽇志。
strace 查看系统调⽤之后,发现了原因所在
1 socket(PF_LOCAL, SOCK_DGRAM|SOCK_CLOEXEC, 0) = 3
2 connect(3, {sa_family=AF_LOCAL, sun_path="/dev/log"}, 110) = -1 ENOENT (No such file or directory)
3 close(3)                                = 0
4 open("/var/run/keepalived.pid", O_RDONLY) = 3
5 fstat(3, {st_mode=S_IFREG|0644, st_size=1, ...}) = 0
6 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fe85ab1b000
7 read(3, "\n", 4096)                    = 1
8 read(3, "", 4096)                      = 0
9 close(3)                                = 0
10 munmap(0x7fe85ab1b000, 4096)            = 0
11 kill(0, SIG_0)                          = 0
12 socket(PF_LOCAL, SOCK_DGRAM|SOCK_CLOEXEC, 0) = 3
13 connect(3, {sa_family=AF_LOCAL, sun_path="/dev/log"}, 110) = -1 ENOENT (No such file or directory)
14 close(3)                                = 0
15 exit_group(0)                          = ?
16 +++ exited with 0 +++
这就是⼀个典型的linux单例守护进程启动做的事情:检测进程是否已经存在(判断记录⽂件是否存在以及对应pid进程是否还在执⾏),并通过syslog套接字⽂件向syslog服务端发送⽇志。
很显然,Keepalived⽆法正常启动是故障宕机时,相应的pid⽂件没有清理⼲净,如果仅仅如此,Keepalived应该可以启动,⼀般守护进程启动都会覆盖残留的锁⽂件,问题关键在read(3, "\n", 4096) :锁⽂件Keepalived.pid是空的!!⽽kil 向进程0 发送信号0,执⾏成功,则Keepalived认为已经有Keepalived进程正在运⾏。所以问题出在锁⽂件存在且内容为"\n",故依次清理 keepalived.pid vrrp.pid checkers.pid⽂件后,Keepalived正常启动。⾄于定位为何锁⽂件内容为"\n",那是后话了。
经此⼀事,笔者想写⼀写Linux 守护进程
守护进程特点与相关概念
并⾮运⾏时间长的程序即是守护进程,笔者并未到守护进程最标准的定义,但守护进程都有下⾯⼏个特点:
1、没有控制终端,终端名设置为?号:也就意味着没有 stdin 0 、stdout 1、stderr 2
2、⽗进程不是⽤户创建的进程,init进程或者systemd(pid=1)以及⽤户⼈为启动的⽤户层进程⼀般以pid=1的进程为⽗进程,⽽以kthreadd内核进程创建的守护进程以kthreadd为⽗进程
3、守护进程⼀般是会话⾸进程、组长进程。
4、⼯作⽬录为 \ (根),主要是为了防⽌占⽤磁盘导致⽆法卸载磁盘
这⾥涉及到⼀些概念,是unix为了更好管理进程间的关系提出的概念和⽅法,稍做说明下
控制终端
通过⽹络登录或者终端登录建⽴的会话,会分配唯⼀⼀个tty终端或者pts伪终端(⽹络登录),实际上它们都是虚拟的,以⽂件的形式建⽴在/dev⽬录,⽽并⾮实际的物理终端。
在终端中按下的特殊按键:中断键(ctrl+c)、退出键(ctrl+\)、终端挂起键(ctrl + z)会发送给当前终端连接的会话中的前台进程组中的所有进程
在⽹络登录程序中,登录认证守护程序 fork ⼀个进程处理连接,并以ptys_open 函数打开⼀个伪终端设备(⽂件)获得⽂件句柄,并将此句柄复制到⼦进程中作为标准输⼊、标准输出、标准错误,所以位于此控制终端进程下的所有⼦进程将可以持有终端
与控制终端相连的会话⾸进程也叫控制进程
进程组
进程组是⼀个或者多个进程的集合。⼀般由某个程序fork出⼀个家族来构成进程组,或者由管道命令建⽴作业构成进程组。
同⼀个进程组中的所有进程接收来⾃同⼀终端的信号。
进程组中的第⼀个进程作为进程组的⾸长,进程组id取⾸长进程的id。在各个进程中,通过函数getpgrp获取其所属进程组id
孤⼉进程组
⼀个进程的⽗进程终⽌后,进程变成了孤⼉进程,将被pid为1的进程(init进程或者systemd)收养。
⽽对孤⼉进程组的定义是:进程组中每个进程的⽗进程要么在组中,也么不在该组所在会话中。
换⾔之,如果⼀个进程组中进程的⽗进程如果是组中成员,或者是init、systemd进程的话,这个进程组就⼀定是孤⼉进程组。这样的进程组是很常见的,下图就是⼀个简单且典型的孤⼉进程组
很显然,只有⼀个进程的进程组,并且是孤⼉进程的话,进程组将变成孤⼉进程组(哪怕它只有⼀个进程)。
典型的例⼦是⼀个⽗进程fork⼦进程之后,⽗进程⽴即退出,这样⼦进程所在的进程组将变为孤⼉进程组。这样的孤⼉进程组中的每个停⽌(Stopped)状态的每个进程都将收到挂断信号(SIGHUP),然后⼜⽴即收到继续信号(SIGCONT)。所以fork⼦进程之后,退出⽗进程,如果⼦进程还需要继续运⾏,则需要处理挂断信号,否则进程对挂断信号的默认处理将是退出。
此时的孤⼉进程组并没有变为后台进程,⼀些博客将后台进程说成是孤⼉进程组的⼀个特点,笔者认为是不正确的,在他们的⽰例中,孤⼉进程组变为后台进程的原因是:⽗进程退出后,⼦进程在运⾏时向⾃⾝发送了SIGTSTP信号,这就像在终端按下终端挂起键(ctrl+z)⼀样,暂时断开了进程与控制终端的连接,⾃然变成了后台进程。
所以这是将进程转到后台运⾏的⼀个⼿段,但并不能创建守护进程,后⾯会将怎么创建守护进程。
会话
表⽰⼀个或多个进程组的集合,在有控制终端的会话中,可以被分为⼀个前台进程组和多个后台进程组。
取⾸进程id为会话id。
函数getsid⽤来获取会话id,⽽函数setsid⽤来新建⼀个会话,只有⾮⾸长进程(⾮进程组的组长)才能
调⽤setsid新建会话。实际上setsid做了三件事
设置当前进程的会话id为该进程id,此进程成为会话⾸进程。
将调⽤setsid的进程设置为⼀个新进程组的⾸长进程。
断开已连接的控制终端
这三步是创建守护进程的重要步骤。
下图结合了笔者对这些概念的理解,做出的判断
守护进程的创建
创建守护进程有标准的步骤:
1. 如果是单例守护进程,结合锁⽂件和kill函数检测是否有进程已经运⾏
2. umask取消进程本⾝的⽂件掩码设置,也就是设置Linux⽂件权限,⼀般设置为000,这是为了防⽌⼦进程创建创建⼀个不能访问的⽂件(没有正确分配权
限)。此过程并⾮必须,如果守护进程不会创建⽂件,也可以不修改
3. fork出⼦进程,⽗进程退出。这样⼦进程⼀定不是组长进程(进程id不等于进程组id)
4. ⼦进程调⽤setsid新建会话(使⼦进程变为会话⾸进程、组长进程,并断开终端)
5. 如果是单例守护进程,将pid写⼊到记录锁⽂件,⼀般为/var/run/xxx.pid
6. 切换⼯作⽬录到根⽬录,这是为了防⽌占⽤磁盘造成磁盘不能卸载。所以也可以改到别的⽬录,只要保证⽬录所在磁盘不会中途卸载
7. 重定向输⼊输⼊错误⽂件句柄,将其指向/dev/null。
前⾯提到,守护进程⼀般借助记录锁⽂件来(⽂件存在并且⽂件内记录的pid对应的进程依然活跃)判断是否已经有进程存在。
多数守护进程并不⾃⼰维护⽇志⽂件,⽽是统⼀将⽇志输出给遵循syslog协议的⽇志进程(如:rsyslogd)处理,统⼀将⽇志输出⾄ /var/log/messages,当然这些⽇志进程也是可以配置的。
⽽且守护进程因为是没有终端的后台进程,所以系统不会发送⼀些跟终端相关的信号给守护进程,程序
可以通过捕捉这些只有可能⼈为发送的信号,来处理⼀些事情,⽐如处理SIGHUP来动态更新程序配置就是典型例⼦。下⾯的代码演⽰了如何创建⼀个守护进程。
1 #include <stdio.h>
2 #include <syslog.h>
3 #include <errno.h>
4 #include <unistd.h>
5 #include <stdlib.h>
6 #include <fcntl.h>
7 #include <signal.h>
8 #include <sys/types.h>
9 #include <sys/stat.h>
10 #include <sys/resource.h>
11
12#define PID_FILE "/var/run/sampled.pid"
13
14int sampled_running(){
15    FILE * pidfile = fopen(PID_FILE,"r");
16    pid_t pid;
17int ret ;
18
19if (! pidfile) {
20return0;
21    }
22
23    ret = fscanf(pidfile,"%d",&pid);
24if (ret == EOF && ferror(pidfile) != 0){
25        syslog(LOG_INFO,"Error open pid file %s",PID_FILE);
26    }
27
28    fclose(pidfile);
29
30// 检测进程是否存在
31if ( kill(pid , 0 ) ){
32        syslog(LOG_INFO,"Remove a zombie pid file %s", PID_FILE);
33        unlink(PID_FILE);
34return0;
35    }
36
37return pid;
38 }
39
40 pid_t sampled(){
41    pid_t pid;
42struct rlimit rl;
43int fd,i;
44
45// 创建⼦进程,并退出当前⽗进程
46if((pid = fork()) < 0){
47        syslog(LOG_INFO,"sampled : fork error");
48return -1;
49    }
50if ( pid != 0) {
51//  ⽗进程直接退出
52        exit(0);
53    }
54
55// 新建会话,成功返回值是会话⾸进程id,进程组id ,⾸进程id
56    pid = setsid();
57
58if ( pid < -1 ){
59        syslog(LOG_INFO,"sampled : setsid error");
60return -1;
61    }
62
63// 将⼯作⽬录切换到根⽬录
64if ( chdir("/") < 0 ) {
65        syslog(LOG_INFO,"sampled : chidr error");
66return -1;
67    }
68
69// 关闭所有打开的句柄,如果确定⽗进程未打开过句柄,此步可以不做
70if ( rl.rlim_max == RLIM_INFINITY ){
71        rl.rlim_max = 1024;
72    }
73for(i = 0 ; i < rl.rlim_max; i ++) {
74        close(i);
75    }
76
77// 重定向输⼊输出错误
78    fd = open("/dev/null",O_RDWR,0);
79if(fd != -1){
80        dup2(fd,STDIN_FILENO);
81        dup2(fd,STDOUT_FILENO);
82        dup2(fd,STDERR_FILENO);
83if (fd > 2){
84            close(fd);
85        }
86    }
87
88// 消除⽂件掩码
89    umask(0);
90return0;
91 }
92
93int pidfile_write(){
94// 这⾥不⽤fopen直接打开⽂件是不想创建666权限的⽂件
95    FILE * pidfile = NULL;
96int pidfilefd = creat(PID_FILE,S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH); 97if(pidfilefd != -1){
98        pidfile = fdopen(pidfilefd,"w");
99    }
100
101if (! pidfile){
102        syslog(LOG_INFO,"pidfile write : can't open pidfile:%s",PID_FILE);
103return0;
104    }
105    fprintf(pidfile,"%d",getpid());
106    fclose(pidfile);
107return1;
108 }
109
110int main(){
111int err,signo;
112    sigset_t mask;
113
114if (sampled_running() > 0 ){
115          exit(0);
116    }
117
118if ( sampled() != 0 ){
119fopen和open区别
120    }
121// 写记录锁⽂件
122if (pidfile_write() <= 0) {
123        exit(0);
124    }
125
126while(1) {
127// 捕捉信号
128        err = sigwait(&mask,&signo);
129if( err != 0  ){
130            syslog(LOG_INFO,"sigwait error : %d",err);
131            exit(1);
132        }
133switch (signo){
134default :
135                syslog(LOG_INFO,"unexpected signal %d \n",signo);
136break;
137case SIGTERM:
138                syslog(LOG_INFO,"got SIGTERM. exiting");
139                exit(0);
140        }
141
142    }
143
144 }
程序编译运⾏结果,可以看到pid  、进程组id、会话id是⼀样的,没有终端,并且直接由pid为1的进程接管。此时的进程已经成为⼀个守护进程。
sighup与nohup
sighup(挂断)信号在控制终端或者控制进程死亡时向关联会话中的进程发出,默认进程对SIGHUP信号的处理时终⽌程序,所以我们在shell下建⽴的程序,在登录退出连接断开之后,会⼀并退出。
nohup,故名思议就是忽略SIGHUP信号,⼀般搭配& ⼀起使⽤,&表⽰将此程序提交为后台作业或者说后台进程组。执⾏下⾯的命令
nohup bash -c "tail -f /var/log/messages | grep sys" &
nohup与&启动的程序,在终端还未关闭时,完全不像传统的守护进程,因为其不是会话⾸进程且持有终端,只是其忽略了SIGHUP信号
从nohup源码就可以看到,其实nohup只做了3件事情
1. dofile函数将输出重定向到nohup.out⽂件
2. signal函数设置SIGHUP信号处理函数为SIG_IGN宏(指向sigignore函数),以此忽略SIG_HUP信号
3. execvp函数⽤新的程序替换当前进程的代码段、数据段、堆段和栈段。
execvp 函数执⾏后,新程序(并没有fork进程)会继承⼀些调⽤进程属性,⽐如:进程id、会话id,控制终端等
登录连接断开之后
在终端关闭后,nohup起到类似守护进程的效果,但是跟传统的守护进程还是有区别的
1、nohup创建的进程⼯作⽬录是你执⾏命令时所在的⽬录
2、0 1 2 标准输⼊标准输出标准错误指向nohup.out⽂件
3、nohup创建的进程组中,除⾸长进程的⽗进程id变为1之外,其余进程依然保留原来的会话id、进程组id、⽗进程id,都保持不变