再試行が二度目の障害を生まないように
目標
外部コマンドを呼び出すPythonツールが、「終わらないコマンド」と「2回実行されるコマンド」を防ぐようにします。タイムアウト、リトライ、冪等性、ロック、シグナルの処理、試行のログです。
なぜ重要なのか
リトライは、コマンドがいつか終わること、そしてもう一度実行しても安全であることという2つの前提の上でだけ、安全です。その前提なしに付けたリトライは、障害を3倍にします。subprocess.runのtimeoutは、子を強制終了して待ってくれ、マーカーファイルは成功のあとにだけ残し、flockは同時実行を防ぎ、SIGTERMのハンドラーは孤児プロセスを残しません。このラボは、その4つを、小さな実行スクリプト1つにすべて入れます。
用意されたコマンドは/opt/fixtures/pyops/bin/にあります。flaky.sh(最初の2回は失敗、3回目は成功。試行の回数は、環境変数FLAKY_STATEのファイルに)、hang.sh(60秒間終わらない)、apply.sh <이름>(呼ぶたびにAPPLY_LOG、既定は/root/pyops/sub/applied.logに1行を追加。APPLY_FAIL=1なら失敗。プレースホルダーは名前です)です。
ステップ
/root/pyops/sub/runner.pyを作ってください。runner.py run -- <명령...>(プレースホルダーはコマンドです)は、subprocess.runでコマンドを実行して、コマンドの標準出力をそのまま標準出力に出力し、コマンドの終了コードで終了します。--timeout <초>(プレースホルダーは秒です)を付けてください。時間が過ぎるとコマンドは強制終了され、標準エラー出力にtimeoutを含む1行が出力されて、終了コード124で終了します。コマンドのプロセスが残っていてはいけません。--retries <n> --backoff <초>(プレースホルダーは、回数と秒です)を付けてください。終了コードが0以外なら、backoff × 2^(試行-1)秒待ってから、最大n回再試行します。タイムアウト(124)も失敗とみなして、リトライします。runner.py apply <이름>(プレースホルダーは名前です)を作ってください。/root/pyops/sub/state/<이름>.done(プレースホルダーは名前です)のマーカーがあれば、already appliedを出力して0で終了し、apply.shを呼び出しません。なければ/opt/fixtures/pyops/bin/apply.sh <이름>を呼び出し、成功(0)したときだけマーカーを作ります。失敗したら、マーカーなしで1で終了します。applyは、/root/pyops/sub/state/lockにfcntl.flock(LOCK_EX | LOCK_NB)のロックを取ります。ロックを取れなければ、標準エラー出力にanother run in progressを出力して、終了コード3で終了します。- SIGTERMを受け取ったら、実行中のコマンドにSIGTERMを伝えて、終わるのを待ったあと、終了コード143で終了します。コマンドのプロセスが残っていてはいけません。
- 試行ごとに、
/root/pyops/sub/runs.jsonlにJSONを1行追記します。キーは、ts(ISO 8601の文字列)・cmd(文字列のリスト)・attempt(1から)・rc(整数)・duration_ms(整数)です。 apply --dry-run <이름>(プレースホルダーは名前です)は、apply.shを呼び出さず、マーカーも作らずに、何をするかを名前を含めて標準出力に書いて、0で終了します。すでに適用された名前なら、already appliedを書きます。
参考
- subprocess.run(cmd, timeout=...)は、TimeoutExpiredを投げる前に、子を強制終了して待ちます。Popenを直接使うなら、
start_new_session=Trueで起動して、os.killpg(os.getpgid(proc.pid), sig)でグループ全体にシグナルを送ってください。hang.shはシェルがsleep 60を起動するので、シェルだけを強制終了すると、孫のsleepがパイプを握ったまま残ります。 - flockは、開いているファイルディスクリプターにかかります。ロックファイルは、実行が終わるまで開いたままにしてください。プロセスが死ぬと、カーネルが解放してくれます。
- テスト:
FLAKY_STATE=/tmp/f1 python3 runner.py run --retries 3 --backoff 0.2 -- /opt/fixtures/pyops/bin/flaky.sh - よくあるミスは、shell=Trueで文字列を渡すこと、マーカーをコマンドの実行前に作ること、リトライの間に同じ間隔で待つことです。
コマンドを実行して、終了コードをそのまま返す
/root/pyops/sub/runner.pyを作ってください。runner.py run -- <명령...>(プレースホルダーはコマンドです)は、subprocess.runでコマンドを実行して、標準出力をそのまま出力し、コマンドの終了コードで終了します。
argparseのsubparsersでrunを作り、commandはnargs=argparse.REMAINDERで受け取ります。リストで渡して、shell=Trueは使わないでください。r.returncodeを返せば済みます。
終わらないコマンドを終わらせる
--timeout <초>(プレースホルダーは秒です)を付けてください。時間が過ぎるとコマンドが強制終了され、標準エラー出力にtimeoutを含む1行が出力されて、終了コード124で終了します。コマンドのプロセスが残らないようにしてください。
subprocess.run(timeout=...)は、TimeoutExpiredを投げる前に、子を強制終了して待ちます。Popenを使うなら、start_new_session=Trueで起動して、os.killpgでプロセスグループを強制終了してください。hang.shの孫のsleepがパイプを握っていると、子だけを強制終了しても、communicate()が戻ってきません。
指数バックオフのリトライ
--retries <n> --backoff <초>(プレースホルダーは、回数と秒です)を付けてください。失敗したら、backoff × 2^(試行-1)秒待ってから、最大n回再試行します。タイムアウトも失敗とみなします。
range(1, retries + 2)でループして、rc == 0ならすぐに返してください。待つ時間は、試行ごとに2倍になります。FLAKY_STATEのファイルを削除してから、flaky.shでテストしてください。
マーカーによる冪等なapply
runner.py apply <이름>(プレースホルダーは名前です)は、/root/pyops/sub/state/<名前>.doneがあれば、already appliedを出力して0で終了してください。なければ、apply.sh <名前>を呼び出して、成功したときだけマーカーを作ります。失敗したら、マーカーなしで1で終了します。
マーカーは、コマンドが0で終わったあとにだけ、write_textしてください。実行前に作ると、失敗した変更が「適用済み」として残ります。APPLY_FAIL=1で、失敗の経路をテストしてください。
同時実行をロックで防ぐ
applyは、/root/pyops/sub/state/lockにfcntl.flock(LOCK_EX | LOCK_NB)を取ります。取れなければ、標準エラー出力にanother run in progressを出力して、終了コード3で終了します。
ロックファイルをopen('w')で開いたままにして、fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)を呼びます。OSErrorのerrnoがEACCESまたはEAGAINなら、別の実行が取っています。
SIGTERMを子に伝える
runnerがSIGTERMを受け取ったら、実行中のコマンドにSIGTERMを送って、終わるのを待ったあと、終了コード143で終了してください。コマンドのプロセスが残らないようにしてください。
signal.signal(signal.SIGTERM, handler)でハンドラーを付け、ハンドラーからPopenオブジェクトのsend_signal(signal.SIGTERM)を呼んでください。ハンドラーが実行されたあとで、communicate()が戻ってきたら、143を返します。
試行ごとにJSONを1行
試行ごとに、/root/pyops/sub/runs.jsonlにJSONを1行追記してください。キーは、ts・cmd(文字列のリスト)・attempt(1から)・rc(整数)・duration_ms(整数)です。
json.dumps(dict) + '\n'を、appendモードで書きます。タイムアウト(124)も1回の試行なので、記録します。time.monotonic()の差を1000倍して、整数にしてください。
実行せずに、何をするかだけを伝える
apply --dry-run <이름>(プレースホルダーは名前です)は、apply.shを呼び出さず、マーカーも作らずに、名前を含む計画を標準出力に書いて、0で終了してください。すでに適用された名前なら、already appliedを書いてください。
dry-runは、ロックのあとではなく、前で処理してもかまいません。何も変更しないからです。マーカーの有無だけを見て、2つの文のどちらかを選んでください。