Python編寫守護程式程式

昀溪發表於2018-07-28

Python編寫守護程式程式思路

1. fork子程式,父程式退出
通常,我們執行服務端程式的時候都會透過終端連線到伺服器,成功連線後會載入shell環境,終端和shell都是程式,shell程式是終端程式的子程式,透過ps命令可以很容易的檢視到。在這個shell環境下一開始執行的程式都是shell程式的子程式,自然會受到shell程式的影響。在程式裡fork子程式後,父程式退出,對了shell程式來說,這個父程式就算執行完了,而產生的子程式會被init程式接管,從而也就脫離了終端的控制。

2-4步驟的意義
守護程式必須與其執行前的環境隔離開來。這些環境包括未關閉的檔案描述符、控制終端、會話和程式組、工作目錄以及檔案建立掩碼等。
這些環境通常是守護程式從執行它的父程式(特別是shell)中繼承下來的。
2、修改子程式的工作目錄
子程式在建立的時候會繼承父程式的工作目錄,如果執行的程式是在u盤裡的,就會導致u盤不能解除安裝。比如Nginx就有它的預設工作目錄 /etc/nginx/conf.d/default.conf

3、建立程式組
使用setsid後,子程式就會成為新會話的首程式(session leader);子程式會成為新程式組的組長程式;子程式沒有控制終端。

4、修改umask
由於umask會遮蔽許可權,所以設定為0,這樣可以避免讀寫檔案時碰到許可權問題。

5、fork孫子程式,子程式退出
經過上面幾個步驟後,子程式會成為新的程式組老大,可以重新申請開啟終端,為了避免這個問題,fork孫子程式出來。

6、重定向孫子程式的標準輸入流、標準輸出流、標準錯誤流到/dev/null
因為是守護程式,本身已經脫離了終端,那麼標準輸入流、標準輸出流、標準錯誤流就沒有什麼意義了。所以都轉向到/dev/null,就是都丟棄的意思。

守護程式的啟動方式有其特殊之處。它可以在系統啟動時從啟動指令碼/etc/rc.d中啟動,可以由inetd守護程式啟動,可以有作業規劃程式crond啟動,
還可以由使用者終端(通常是shell)執行。

總之,除開這些特殊性以外,守護程式與普通程式基本上沒有什麼區別。
因此,編寫守護程式實際上是把一個普通程式按照上述的守護程式的特性改造成為守護程式。如果大家對程式的認識比較深入,就對守護程式容易理解和程式設計了。

Linux系統程式的一些概念

這裡主要是回答針對下面程式碼的疑問,為什麼要FORK?為什麼要設定SID等。

這個“1”號程式就是所有程式的父程式,因為這是CentOS7它得啟動機制變化了,如果在CentOS6中那麼1號程式則是INIT程式。但不管怎麼作用是一樣的。

我們平時所理解的守護程式就是你在命令列執行一個程式它自己就在後臺執行了,你退出了終端再進去它依然在執行就像Nginx那樣。首先我們要知道幾個概念

程式ID(PID):就是這個程式的程式號

父程式ID(PPID):該程式的父程式ID號

程式組ID(PGID):程式所在程式組ID,每一個程式都屬於一個程式組,一個程式組可以包含多個程式同時包含一個組長程式(如果程式ID和其對應的程式組ID相同則表示該程式是該組的組長)。比如一個程式是多程式的,執行該程式就會啟動多個程式,那麼這些程式都屬於一個程式組,因為你可以針對組來傳送訊號,其實也就是管理。

會話ID(SID):當有新的使用者登入Linux時,登入程式會為這個使用者建立一個會話。使用者的登入shell就是會話的首程式。會話的首程式ID會作為整個會話的ID。會話是一個或多個程式組的集合,囊括了登入使用者的所有活動。

ps -axo pid,ppid,pgid,sid,tty,comm

pts/0是繫結到會話的一個終端裝置,這裡之所有有pts/1是因為我開了兩個連線到Linux的終端,都是透過SSH進行登入的。

pts/0的程式ID是29641,它得PPID和PGID都是一樣的,說明它就是程式組29641的組長,為什麼呢?因為我透過SSH登入,登入後執行的第一個就是bash也就是和我進行命令互動的程式,所以你可以看到29641的父程式ID是29639它是一個sshd服務。

這裡為什麼有這麼多1172,上面的1172是守護程式,下面的29639是sshd服務派生出來的一個子程式用於負責一個使用者的連線,程式ID為1172的sshd它的父程式就是1.

會話組

通常我們執行的命令屬於前端任務,也就是和會話繫結,如果會話消失了任務也就是消失了。我這裡執行一個ping操作,它會一直執行

我們在另外一個終端檢視

它的父程式是29641,不就是我們上面的bash麼,而且它的SID也就是會話ID也是29641,因為它屬於哪個會話,如果哪個會話消失了,這個ping操作也可以叫做作業,也就是消失了。我們把那個執行ping命令的終端直接關閉,然後在另外的終端上檢視,不一會你就看不到那個ping任務了。所以這就是會話。

其實無論是程式組還是會話都屬於作業控制。會話ID相同的程式只要會話消失,這些程式也就消失了,也就是結束了。

下面我們來說一下程式組

上面這一條命令其實執行了是兩個程式。我們在另外一個終端檢視

 

bash的程式ID是30150,所以由它派生的子程式的父程式ID都是30150,就像下面的tailf和grep.這個不用多數,因為都是在那個會話也就是終端上執行的,所以他們三個的會話ID相同。大家可以看到tailf和grep的程式組ID相同,都是30374說明他們是在一個程式組中,而組長就是tailf的程式其ID為30374。

程式組ID相同我們就可以給程式組發訊號比如去結束這個組裡所有的程式。這還是作業管理的內容。如下面操作:

kill -SIGTERM 30374

另外一個終端的任務自動就結束了

如何判斷你自己當前是哪個終端呢? 

 

關閉終端為什麼有些程式不退出呢?

透過SID的演示我們知道,命令列裡執行的程式會依賴當前會話,所以程式的執行不受會話影響那麼肯定就要脫離之前的會話。另外還需要讓程式脫離當前程式可以理解為當前的bash也就是完全隔斷父子關係,因為畢竟我們是透過bash來執行的程式,bash又依賴終端pts/N這種,如果bash沒了,程式也沒了。看下圖

還是這個命令這回我們放到後臺執行,

可以看到它倆的SID和bash的並不相同

但是這時候如果你關閉這個終端,這個任務也就沒了。你可以試一下。

完整程式碼

# !/usr/bin/env python
# coding: utf-8

# python模擬linux的守護程式

import sys, os, time, atexit, string
from signal import SIGTERM

__metaclass__ = type


class Daemon:
    def __init__(self, pidfile="/tmp/Daemon.pid", stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'):
        # 需要獲取除錯資訊,改為stdin='/dev/stdin', stdout='/dev/stdout', stderr='/dev/stderr',以root身份執行。
        self.stdin = stdin
        self.stdout = stdout
        self.stderr = stderr
        self.pidfile = pidfile
        self.applicationName = "Application"
        self._homeDir = "/"
        # 除錯模式是否開啟
        self._verbose = False
        # 使用者掩碼,預設為0
        self._umask = 0

    # 獲取守護程式掩碼
    @property
    def umask(self):
        return self._umask

    # 設定守護程式掩碼
    @umask.setter
    def umask(self, umask):
        self._umask = umask

    # 獲取當前是否是除錯模式
    @property
    def VerboseMode(self):
        return self._verbose

    # 除錯模式開關,預設不是除錯模式
    @VerboseMode.setter
    def VerboseMode(self, verboseMode):
        self._verbose = verboseMode

    # 除錯模式和非除錯模式設定
    def _verbosSwitch(self):
        # 除錯模式是輸出日誌到指定檔案,這些檔案在物件初始化時指定
        if self._verbose:
            pass
            # self.stdin = '/dev/stdin'
            # self.stdout = '/dev/stdout'
            # self.stderr = '/dev/stderr'
        else:
            self.stdin = '/dev/null'
            self.stdout = '/dev/null'
            self.stderr = '/dev/null'

    def setApplicationName(self, appName):
        self.applicationName = appName

    # 獲取和設定程式住目錄
    @property
    def HomeDir(self):
        return self._homeDir

    @HomeDir.setter
    def HomeDir(self, homeDir):
        self._homeDir = homeDir

    # 這個方法的主要目的就是脫離主體,為程式創造環境
    def _daemonize(self):
        # 第一步
        try:
            # 第一次fork,生成子程式,脫離父程式,它會返回兩次,PID如果等於0說明是在子程式裡面,如果大於0說明當前是在父程式裡
            pid = os.fork()
            # 如果PID大於0,說明當前在父程式裡,然後sys.exit(0),則是退出父程式,此時子程式還在執行。
            if pid > 0:
                # 退出父程式,此時linux系統的init將會接管子程式
                sys.exit(0)
        except OSError, e:
            sys.stderr.write('fork #1 failed: %d (%s)\n' % (e.errno, e.strerror))
            sys.exit(1)

        # 第二、三、四步
        os.chdir("/")  # 修改程式工作目錄
        os.setsid()  # 設定新的會話,子程式會成為新會話的首程式,同時也產生一個新的程式組,該程式組ID與會話ID相同
        os.umask(self._umask)  # 重新設定檔案建立許可權,也就是工作目錄的umask

        # 第五步
        try:
            # 第二次fork,禁止程式開啟終端,相當於是子程式有派生一個子程式
            pid = os.fork()
            if pid > 0:
                # 子程式退出,孫子程式執行,此時孫子程式由init程式接管,在CentOS 7中是Systemed。
                sys.exit(0)
        except OSError, e:
            sys.stderr.write('fork #2 failed: %d (%s)\n' % (e.errno, e.strerror))
            sys.exit(1)

        # 第六步
        # 把之前的刷到硬碟上
        sys.stdout.flush()
        sys.stderr.flush()
        # 重定向標準檔案描述符
        si = file(self.stdin, 'r')
        so = file(self.stdout, 'a+')
        se = file(self.stderr, 'a+', 0)
        # os.dup2可以原子化的開啟和複製描述符,功能是複製檔案描述符fd到fd2, 如果有需要首先關閉fd2. 在unix,Windows中有效。
        # File的 fileno() 方法返回一個整型的檔案描述符(file descriptor FD 整型)
        os.dup2(si.fileno(), sys.stdin.fileno())
        os.dup2(so.fileno(), sys.stdout.fileno())
        os.dup2(se.fileno(), sys.stderr.fileno())

        # 註冊退出函式,根據檔案pid判斷是否存在程式
        atexit.register(self.delpid)
        pid = str(os.getpid())
        file(self.pidfile, 'w+').write('%s\n' % pid)

    # 程式退出後移除PID檔案
    def delpid(self):
        os.remove(self.pidfile)

    def start(self, *args, **kwargs):
        # 檢查pid檔案是否存在以探測是否存在程式
        try:
            pid = self._getPid()
        except IOError:
            pid = None

        # 如果PID存在,則說明程式沒有關閉。
        if pid:
            message = 'pidfile %s already exist. Process already running!\n'
            sys.stderr.write(message % self.pidfile)
            # 程式退出
            sys.exit(1)

        # 構造程式環境
        self._daemonize()
        # 執行具體任務
        self._run(*args, **kwargs)

    def stop(self):
        # 從pid檔案中獲取pid
        try:
            pid = self._getPid()
        except IOError:
            pid = None

        # 如果程式沒有啟動就直接返回不在執行
        if not pid:
            message = 'pidfile %s does not exist. Process not running!\n'
            sys.stderr.write(message % self.pidfile)
            return

        # 殺程式
        try:
            while 1:
                # 傳送訊號,殺死程式
                os.kill(pid, SIGTERM)
                time.sleep(0.1)
                message = 'Process is stopped.\n'
                sys.stderr.write(message)
        except OSError, err:
            err = str(err)
            if err.find('No such process') > 0:
                if os.path.exists(self.pidfile):
                    os.remove(self.pidfile)
            else:
                print str(err)
                sys.exit(1)

    # 獲取PID
    def _getPid(self):
        try:
            # 讀取儲存PID的檔案
            pf = file(self.pidfile, 'r')
            # 轉換成整數
            pid = int(pf.read().strip())
            # 關閉檔案
            pf.close()
        except IOError:
            pid = None
        except SystemExit:
            pid = None
        return pid

    # 重啟的功能就是殺死之前的程式,然後再執行一個
    def restart(self, *args, **kwargs):
        self.stop()
        self.start(*args, **kwargs)

    # 獲取守護程式執行狀態
    def status(self):
        try:
            pid = self._getPid()
        except IOError:
            pid = None

        if not pid:
            message = "No such a process running.\n"
            sys.stderr.write(message)
        else:
            message = "The process is running, PID is %s .\n"
            sys.stderr.write(message % str(pid))

    def _run(self, *args, **kwargs):
        """
        這裡是孫子程式需要做的事情,你可以繼承這個類,然後重寫這裡的程式碼,上面其他的都可以不做修改
        """
        while True:
            """
            print 等於呼叫 sys.stdout.write(), sys.stdout.flush()是立即重新整理輸出。正常情況下如果是輸出到控制檯那麼會立即輸出
            但是重定向到一個檔案就不會了,因為等於寫檔案,所以需要進行重新整理進行立即輸出。 下面使用print 還是 write都是一樣的。
            """
            # print '%s:hello world\n' % (time.ctime(),)
            sys.stdout.write('%s:hello world\n' % (time.ctime(),))
            sys.stdout.flush()
            time.sleep(2)


if __name__ == '__main__':
    daemon = Daemon('/tmp/watch_process.pid', stdout='/tmp/watch_stdout.log')
    if len(sys.argv) == 2:
        if 'start' == sys.argv[1]:
            daemon.setApplicationName(sys.argv[0])
            daemon.start()
        elif 'stop' == sys.argv[1]:
            daemon.stop()
        elif 'restart' == sys.argv[1]:
            daemon.restart()
        elif 'status' == sys.argv[1]:
            daemon.status()
        else:
            print 'unknown command'
            sys.exit(2)
        sys.exit(0)
    else:
        print 'usage: %s start|stop|restart|status' % sys.argv[0]
        sys.exit(2)

關於fork函式

fork呼叫一次返回兩次其實比較難理解,返回0表示當前執行在子程式中,返回大於0的正整數表示當前執行在父程式中,透過返回值我們可以判斷當前執行在哪裡。子程式返回0而不是父程式的PID是因為每一個子程式只能有一個父程式,它任何時候都可以獲取父程式的PID,但是父程式可能有多個子程式它無法獲取各個子程式的ID,所以它要想跟蹤所有它的子程式就必須在fork之後返回產生的這個子程式的ID。另外程式呼叫fork之前所開啟的檔案描述符在呼叫fork之後都會複製給子程式,其實他倆的資料是一模一樣的(注意是複製給子程式而不是共享也就是相同的東西有兩份),就是程式號和記憶體地址不同。

fork函式有2中典型用法:

  • 產生一個自己的副本(子程式),這樣多個子程式就可以做相同的事情而當有新的事情要做的時候父程式就派生一個子程式去幹。最典型的就是WEB伺服器。
  • 執行另外的程式碼,你可以理解為一個新程式和之前的父程式所執行的程式碼完全不同。因為在類Unix系統上執行一個可執行檔案的方式就是fork一個子程式然後呼叫exec函式填充新的可執行程式碼進來。我們上面的例子就是這種。

相關文章