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"
經此一事,筆者想寫一寫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的程序設定為一個新程序組的首長程序。
- 斷開已連線的控制終端
這三步是建立守護程序的重要步驟。
下圖結合了筆者對這些概念的理解,做出的判斷
守護程序的建立
建立守護程序有標準的步驟:
- 如果是單例守護程序,結合鎖檔案和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起到類似守護程序的效果,但是跟傳統的守護程序還是有區別的
1、nohup建立的程序工作目錄是你執行命令時所在的目錄
2、0 1 2 標準輸入 標準輸出 標準錯誤 指向nohup.out檔案
3、nohup建立的程序組中,除首長程序的父程序id變為1之外,其餘程序依然保留原來的會話id、程序組id、父程序id,都保持不變