前段時間幫忙定位個問題。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 守護進程
守護進程特點與相關概念
控制終端
通過網絡登錄或者終端登錄建立的會話,會分配唯一一個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的進程設置為一個新進程組的首長進程。
- 斷開已連接的控制終端
這三步是創建守護進程的重要步驟。
下圖結合了筆者對這些概念的理解,做出的判斷

守護進程的創建
- 如果是單例守護進程,結合鎖文件和kill函數檢測是否有進程已經運行
- umask取消進程本身的文件掩碼設置,也就是設置Linux文件權限,一般設置為000,這是為了防止子進程創建創建一個不能訪問的文件(沒有正確分配權限)。此過程並非必須,如果守護進程不會創建文件,也可以不修改
- fork出子進程,父進程退出。這樣子進程一定不是組長進程(進程id不等於進程組id)
- 子進程調用setsid新建會話(使子進程變為會話首進程、組長進程,並斷開終端)
- 如果是單例守護進程,將pid寫入到記錄鎖文件,一般為/var/run/xxx.pid
- 切換工作目錄到根目錄,這是為了防止占用磁盤造成磁盤不能卸載。所以也可以改到別的目錄,只要保證目錄所在磁盤不會中途卸載
- 重定向輸入輸入錯誤文件句柄,將其指向/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 14 int sampled_running(){ 15 FILE * pidfile = fopen(PID_FILE,"r"); 16 pid_t pid; 17 int ret ; 18 19 if (! pidfile) { 20 return 0; 21 } 22 23 ret = fscanf(pidfile,"%d",&pid); 24 if (ret == EOF && ferror(pidfile) != 0){ 25 syslog(LOG_INFO,"Error open pid file %s",PID_FILE); 26 } 27 28 fclose(pidfile); 29 30 // 檢測進程是否存在 31 if ( kill(pid , 0 ) ){ 32 syslog(LOG_INFO,"Remove a zombie pid file %s", PID_FILE); 33 unlink(PID_FILE); 34 return 0; 35 } 36 37 return pid; 38 } 39 40 pid_t sampled(){ 41 pid_t pid; 42 struct rlimit rl; 43 int fd,i; 44 45 // 創建子進程,並退出當前父進程 46 if((pid = fork()) < 0){ 47 syslog(LOG_INFO,"sampled : fork error"); 48 return -1; 49 } 50 if ( pid != 0) { 51 // 父進程直接退出 52 exit(0); 53 } 54 55 // 新建會話,成功返回值是會話首進程id,進程組id ,首進程id 56 pid = setsid(); 57 58 if ( pid < -1 ){ 59 syslog(LOG_INFO,"sampled : setsid error"); 60 return -1; 61 } 62 63 // 將工作目錄切換到根目錄 64 if ( chdir("/") < 0 ) { 65 syslog(LOG_INFO,"sampled : chidr error"); 66 return -1; 67 } 68 69 // 關閉所有打開的句柄,如果確定父進程未打開過句柄,此步可以不做 70 if ( rl.rlim_max == RLIM_INFINITY ){ 71 rl.rlim_max = 1024; 72 } 73 for(i = 0 ; i < rl.rlim_max; i ++) { 74 close(i); 75 } 76 77 // 重定向輸入輸出錯誤 78 fd = open("/dev/null",O_RDWR,0); 79 if(fd != -1){ 80 dup2(fd,STDIN_FILENO); 81 dup2(fd,STDOUT_FILENO); 82 dup2(fd,STDERR_FILENO); 83 if (fd > 2){ 84 close(fd); 85 } 86 } 87 88 // 消除文件掩碼 89 umask(0); 90 return 0; 91 } 92 93 int pidfile_write(){ 94 // 這里不用fopen直接打開文件是不想創建666權限的文件 95 FILE * pidfile = NULL; 96 int pidfilefd = creat(PID_FILE,S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH); 97 if(pidfilefd != -1){ 98 pidfile = fdopen(pidfilefd,"w"); 99 } 100 101 if (! pidfile){ 102 syslog(LOG_INFO,"pidfile write : can't open pidfile:%s",PID_FILE); 103 return 0; 104 } 105 fprintf(pidfile,"%d",getpid()); 106 fclose(pidfile); 107 return 1; 108 } 109 110 int main(){ 111 int err,signo; 112 sigset_t mask; 113 114 if (sampled_running() > 0 ){ 115 exit(0); 116 } 117 118 if ( sampled() != 0 ){ 119 120 } 121 // 寫記錄鎖文件 122 if (pidfile_write() <= 0) { 123 exit(0); 124 } 125 126 while(1) { 127 // 捕捉信號 128 err = sigwait(&mask,&signo); 129 if( err != 0 ){ 130 syslog(LOG_INFO,"sigwait error : %d",err); 131 exit(1); 132 } 133 switch (signo){ 134 default : 135 syslog(LOG_INFO,"unexpected signal %d \n",signo); 136 break; 137 case 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件事情
- dofile函數將輸出重定向到nohup.out文件
- signal函數設置SIGHUP信號處理函數為SIG_IGN宏(指向sigignore函數),以此忽略SIG_HUP信號
- execvp函數用新的程序替換當前進程的代碼段、數據段、堆段和棧段。
execvp 函數執行后,新程序(並沒有fork進程)會繼承一些調用進程屬性,比如:進程id、會話id,控制終端等
登錄連接斷開之后

在終端關閉后,nohup起到類似守護進程的效果,但是跟傳統的守護進程還是有區別的