Pythonで関数をデーモン化する

Pythonスクリプトをデーモン化したいときに、便利なものを見つけたのでメモ。
起動スクリプトを作っておけばサービスとして使うことも可能です。
(2018/6/2 ちょこちょこバグが潜んでたので大幅に書き直しました)

環境
Amazon Linux AMI 2017.09.1 (python入ってれば正直あんまり関係ない、、、)
Python 3.6.4

まずインストール。python-daemonに関しては[コチラ](https://www.python.org/dev/peps/pep-3143/)。

```
$ pip install python-daemon

$ pip freeze
python-daemon==2.1.2
```

以下のコードを丸コピしてimportすれば利用可能です。

pidpathにPIDのパスを指定すればデーモンとして実行されますし、指定なしの場合はそのままフォアグラウンド実行されます。

ログの出力はsys.stdoutの仕組みを使っているので、対象の関数にprint()を仕込んでおけばログも吐けます。(log_to_stdoutというやつを使うと時間も出せます)
フォアグラウンド実行の場合は普通に標準出力されます。

```daemonize.py
import sys
import time
from datetime import datetime, timedelta
# pip
import daemon
import daemon.pidfile

__all__ = ['start_daemon', 'log_to_stdout', 'interval_run']


def log_to_stdout(*msg, end=None):
    '''ログを標準(&エラー)出力

    Parameter
    ------------------------------
        *msg: object(ログ内容)
        end: str(print末尾の文字)

    Return
    ------------------------------
    '''
    print(datetime.now(), *msg, end=end)
    sys.stdout.flush()
    sys.stderr.flush()

def interval_run(interval):
    '''interval分ごとに関数実行
    intervalに1または60を割り切れない数を入れるとバグる(すいません)

    Parameter
    ------------------------------
        interval: int(実行間隔 -分)

    Return
    ------------------------------
        function(デコレーション後の関数)
    '''
    def _deco(f):
        def _wrapper(*args, **kws):
            while 1:
                # 現在時刻と次回起動時刻
                t = datetime.now().replace(second=0, microsecond=0)
                n = t + timedelta(minutes=interval)

                # intervalの倍数ジャストに起動
                if t.minute % interval == 0:
                    f(*args, **kws)

                # 1周目 次回実行時間まで調整
                else:
                    stop = stop = interval - t.minute % interval
                    n = t + timedelta(minutes=stop)

                w = (n - datetime.now()).total_seconds()
                log_to_stdout('[INFO]', 'Sleep', w, 'seconds.')
                time.sleep(w)

        return _wrapper
    return _deco


def start_daemon(func, *args, pidpath=None, logpath=None, **kws):
    '''対象の関数をデーモン化
    SIGTERMで終了 関数内でのエラーでは停止しない(1分sleep)

    Parameter
    ------------------------------
        func: function(デーモンにしたい関数)
        *args: list(funcで使う引数)
        pidpath: str(PIDファイルパス -default:fg)
        logpath: str(ログファイルパス -default:stdout)
        **kws: dict(funcで使うキーワード引数)

    Return
    ------------------------------
    '''

    # 多重起動防止
    if pidpath:
        pid = daemon.pidfile.PIDLockFile(pidpath)
        if pid.is_locked():
            raise Exception('Process is already started.')

    # PID指定がなければフォアグラウンド
    else:
        pid = None

    # ログ
    if logpath:
        std_out = open(logpath, mode='a+', encoding='utf-8')
        std_err = std_out

    # 指定がなければ標準出力
    else:
        std_out = sys.stdout
        std_err = sys.stderr

    dc = daemon.DaemonContext(
        umask=0o002,
        pidfile=pid,
        stdout=std_out,
        stderr=std_err,
    )

    # 内部で実行される
    def forever():
        while 1:
            try:
                func(*args, **kws)

            # kill -SIGTERM {pid} で停止する
            except SystemExit as e:
                log_to_stdout('Killed by SIGTERM.')
                raise

            # それ以外のエラーは無視して動き続ける
            except Exception as e:
                log_to_stdout('Uncaught exception was raised, but process continue.', e)

    with dc: forever()

```

使い方はこんな感じです。

```test.py
from daemonize import *

# 5分おきに実行
@interval_run(5)
def test(arg, kws=None):
    print(arg, kws)

start_daemon(test, 'Test', pidpath='PID置くパス', logpath='ログ吐くパス', kws='keyword')
```

これを実行すればデーモンとして動き出します。

```
$ python test.py
```

ちょこっと解説すると、
##### log_to_stdout
まあやってることはprintですが、ログっぽく時間をつけて、その場でflushするのですぐログを確認できる感じです。

##### interval_run
引数にしたinterval分ごとに関数を実行するデコレータです。
メインで実行したい関数にをデコっておくと指定した間隔で実行できます。
コメントにありますが潜在バグがあるので用途に合っていればおまけくらいで使って下さい。

##### start_daemon
これが本体です。
pidpathを指定すると対象の関数をデーモンとして実行できます。
killしないと止まらないので、困ったらcat {pidpath}かps aux | grep pythonでPIDを確認してkill {PID}で止めます。

killしたくないとかエラーがあったら止めたい場合はforeverのtry-exceptを消してあげればOKです。

logpathを指定するとそこにログを吐きます。
指定なしだと標準出力しますが、デーモンとして動いていると確認できないので一緒に指定します。
実行したい関数の中でprintしてもそのままログに出せます。
printはfile.writeと違って文字列以外でも気にせずぶっこめるので結構便利ですね。

まあどうでもいいんですが、python-daemon内部でforkしてるので対話モードから実行するとPID変わります。

スクリプトをデーモン化するってあんまり需要ないせいか情報少なめですね。

以上、お疲れ様でした。