Date: 2018-12-12
Tags: beproud, unixドメインソケット

UNIXドメインソケットの抽象名前空間を使った多重実行防止 #beproud

これは BeProud Advent Calendar 2018 12日目の記事です。 昨日は @hajime_nakagami動く疑似コードとしての Python でした。

動機

これは、売上集計プロジェクト(仮称)で実際にあった話なんですが、 1回の実行に数分かかるプログラムを10分毎に実行するようなシーンがあります。

こういうバッチ処理は多重実行されると困ることがあります。例えば、外部のWeb APIからデータを取り込む処理は、時々データ量が多かったりAPIの応答が遅かったりで時間がかかることがあります。だからといって、前の取り込みバッチが終了してないのに次のバッチ時刻に新しく取り込みが始まってしまうと、同じデータを2重に取り込んでしまったり、APIに余計な負荷がかかったりするので、避けなければいけません。

バッチの多重実行を避けるためのジョブスケジューラーも色々ありますが、今回はPythonプログラム側で多重実行されないようにガードする方法を紹介します。

多重実行防止の方法

プログラムの多重実行を避ける代表的な方法のうち、Pythonから操作しやすい方法をいくつか紹介します。他にもいくつもあると思います。

  • 自分と同じコマンド名が起動中か確認

    • Pythonからの確認が簡単じゃないので説明略

  • mutex (ミューテックス), semaphore (セマフォ) を使う

    • Pythonの文脈だと同一プロセス上での排他制御として登場するので、今回は使えない

  • multiprocessing.Lock を使う

    • lockオブジェクトをプロセス間で共有していれば使える

    • 今回のバッチのように、実行プロセス同士でlockオブジェクトを共有していない場合は使えない

  • ロックファイル

    • /var/run/sales.lock のようなファイルをプログラム起動時に作成

    • もし次のプログラム起動時に /var/run/sales.lock がもうあれば、自プログラムを終了(多重起動防止)

    • /var/run/sales.lock を作成したプログラムは、終了時にこのファイルを削除

    • コードの見た目も仕組みも分かりやすい方法

    • プログラムが異常終了したとき、 /var/run/sales.lock が取り残されて次の起動ができなくなる場合がある

  • ソケット利用 (address, port)

    • 起動時にソケットを開く(例: localhost:9999

    • 多重起動したプログラムが同じソケットを開こうとして、 Address already in use エラーになることで、多重起動を防止する

    • プログラムがクラッシュしたら、ソケットは一定時間後に解放される、ファイルは無いのでゴミは残らない

    • たまたま9999ポートを利用するプログラムがあると競合してしまうため、環境にあわせてどのポートを使うか変更が必要

  • UNIXドメインソケット

    • 起動時にソケットを開く(例: /var/run/sales.sock )

    • 期待する動作は、ソケット利用と同じ。ただし、こちらの方法だとファイルの存在が見える

    • ファイルパスは自由に決めて良いので、「たまたま別のプログラムと競合」はほぼない

    • プログラムがクラッシュしたら、ソケットは一定時間後に解放される、UNIXドメインソケットファイルはソケット解放で消滅する

    • ファイルパスへの書き込み権限が必要

  • UNIXドメインソケットの抽象名前空間

    • 起動時にソケットを開く(例: \0sales.sock )

    • 期待する動作は、ソケット利用と同じ。ファイルは存在しない

    • 名前は自由に決めて良いので競合しない

    • ファイルが存在しないので書き込み権限も不要

    • プログラムがクラッシュしたら、ソケットは一定時間後に解放される

    • この「抽象名前空間」はLinuxでしか使えない

どの方法も、メリット、デメリットがあります。某売上集計プロジェクトはLinux(Ubuntu)環境なので、「UNIXドメインソケットの抽象名前空間」を利用しました。

UNIXドメインソケットによるロック処理

シンプルに実装すると以下のようになります。

import sys
import socket


def main():
    # UNIXドメインソケットを作成
    s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    name = 'tekitou'
    try:
        # 抽象名前空間をbind
        s.bind('\0' + name)
    except socket.error:
        print("既に実行中です")
        sys.exit(-1)

    # 処理

    s.close()


if __name__ == '__main__':
    main()

通常、UNIXドメインソケットをbindするには bind() メソッドにsocketファイルパスを指定しますが、抽象名前空間をbindするにはパスの先頭にnull文字 (\0) を付けます。

もう少し使いやすくするために、 with (context managerプロトコル)に対応してみます。

import sys
import socket
import time


class critical_section:

    def __init__(self, name:str):
        self.name = name

    def __enter__(self):
        self.s = s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        try:
            # 抽象名前空間をbind
            s.bind('\0' + self.name)
        except socket.error:
            print("既に実行中です")
            sys.exit(-1)

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.s.close()


def main():
    with critical_section('tekitou'):
        time.sleep(5)
        print('hello')


if __name__ == '__main__':
    main()
../../_images/console.png

実行結果

デコレータにも使える版も書いてみました。

import sys
import socket
import functools
import time


class critical_section:

    def __init__(self, name:str):
        self.name = name

    def __enter__(self):
        self.s = s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        try:
            # 抽象名前空間をbind
            s.bind('\0' + self.name)
        except socket.error:
            print("既に実行中です")
            sys.exit(-1)

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.s.close()

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kw):
            with self:
                return func(*args, **kw)
        return wrapper


@critical_section('tekitou')
def main():
    time.sleep(5)
    print('hello')


if __name__ == '__main__':
    main()

某売上集計プロジェクトでは、このコードで数年運用して特に問題は出ていません。 (print関数ではなくloggerを使ったり、細かい違いはあります)

まとめ

UNIXドメインソケットの抽象名前空間は、Linuxでしか使えないため、環境非依存なプログラムを書く場合には使えません。また、ソケットを使うロックの仕組みはサーバー毎のリソースのため、1サーバーならうまく動作しますが、複数サーバーでは期待通りには動作しません。複数サーバーの場合は多分バッチジョブスケジューラー側で同時実行しないように対処するのが良さそうです。

参考文献: