再試行が二度目の障害を生んだ
一言でいうと
外部コマンドを呼び出すツールは、終わらないコマンドと2回実行されるコマンドという2つの事故を、あらかじめ防ぐ必要があります。タイムアウトは1つ目を、冪等性とロックは2つ目を防ぎます。リトライは、その2つがそろったあとでだけ、安全です。
なぜ必要なのか
デプロイスクリプトがrestart-serviceを呼び出すのに、応答がありませんでした。担当者が「リトライ」を入れました。次の障害のとき、スクリプトは、応答のないコマンドを3回呼び出して30分待ち、実際には3回とも裏で実行されて、サービスが3回再起動されました。リトライが2つ目の障害を作ったのです。問題はリトライではなく、リトライが前提とする2つのものがなかったことです。コマンドがいつか終わるという保証(タイムアウト)と、もう一度実行しても結果が同じという保証(冪等性)です。
どう動くのか
subprocessのrun()は、コマンドを実行して、終わるまで待ったあと、CompletedProcessを返します。3つの引数が、運用ツールの骨格です。
timeout=초(プレースホルダーは秒です): 時間が過ぎると、子プロセスを強制終了して待ってから、TimeoutExpiredを投げます。ドキュメントが「killed and waited for」と明記している部分が核心です。ゾンビを残しません。check=True: 終了コードが0以外なら、CalledProcessErrorを投げます。例外には、引数・終了コード・(捕捉していれば)stdout/stderrが入っています。capture_output=True: stdoutとstderrを捕捉します。text=Trueなら、文字列で受け取ります。
import subprocess
try:
r = subprocess.run(cmd, timeout=30, capture_output=True, text=True)
except subprocess.TimeoutExpired:
return 124 # coreutils timeout 과 같은 코드를 쓰면 셸 사용자가 바로 안다
return r.returncode
リトライは、失敗の種類を見分ける必要があります。ネットワークが切れて1で終わったコマンドは、もう一度呼び出す価値がありますが、不正な引数で2で終わったコマンドは、100回呼び出しても同じです。そして、待つ間隔は延ばす必要があります。相手が過負荷で失敗したのなら、同じ間隔のリトライは、過負荷を維持します。backoff * 2 ** (attempt - 1)のように、指数的に延ばすのが慣例です。
冪等性(idempotency)は、「1回実行した結果と、複数回実行した結果が同じ」という性質です。外部コマンドが冪等でないなら、ツールがその前に確認のステップを置きます。すでに適用されたかどうかをマーカー(marker)ファイルで見て、適用されていればスキップします。マーカーは、コマンドが成功したあとにだけ作ります。失敗したのにマーカーができると、次の実行が「すでに済んでいる」と信じてしまいます。
同じツールが同時に2つ動くと、マーカーの確認が競合します。2つとも「まだ済んでいない」を見て、2つとも実行します。fcntlのflock()にLOCK_EX | LOCK_NBを渡すと、ロックを取れないときに、待たずにOSError(errnoはEACCESまたはEAGAIN。ドキュメントは、移植性のために両方を確認するよう書いています)を投げます。ツールは、その例外を「別の実行が進行中」という意味として受け取って、引き下がります。ロックファイルは、プロセスが死ぬとカーネルが解放してくれるので、古いロックが残る問題がありません。
落とし穴がもう1つあります。コマンドがシェルスクリプトで、その中でsleepや別のコマンドを起動していたら、子(シェル)だけを強制終了しても、孫は残ります。孫が標準出力のパイプを握っていると、パイプが閉じられず、communicate()が戻ってきません。Popen(..., start_new_session=True)で子を新しいプロセスグループのリーダーとして起動し、os.killpg()でグループ全体にシグナルを送れば、孫まで一緒に後始末されます。run()は、タイムアウトのあとでwait()だけを行うので、この問題を避けられますが、孫はそのまま残ります。
最後は、終了シグナルです。cronが時間を超えたジョブを強制終了したり、KubernetesがPodを停止したりするとき、ツールはSIGTERMを受け取ります。signalのsignal.signal(signal.SIGTERM, handler)でハンドラーを付けると、ツールは、子に同じシグナルを伝えて待ったあと、終了コード143(128 + 15)で終われます。ハンドラーがないと、ツールだけが死んで、子は孤児になって動き続けます。再起動コマンドが3回実行された事故の、別の形です。
現場での姿
subprocess.run(cmd, shell=True)で文字列を渡す習慣が、2つの事故を呼びます。引数に空白や引用符が混ざると、別のコマンドになり、タイムアウトがかかったときに、死ぬのはシェルであって、シェルが起動した本物のコマンドではないことがあります。リストで渡して、shell=False(既定値)を使います。2つ目は、「成功の記録をコマンドの実行前に残す」順序のミスです。マーカー・ログ・DBの更新は、必ず成功のあとです。3つ目は、リトライの回数を記録しないことです。3回目の試行で成功したという事実がログになければ、そのコマンドが毎回2回ずつ失敗していることを、誰も知りません。試行ごとに1行ずつJSONで残せば、そのログがそのままメトリクスになります。
次のラボですること
外部コマンドの実行スクリプトrunner.pyを作ります。コマンドを実行して終了コードをそのまま返すところから始めて、タイムアウト(124)、指数バックオフのリトライ、マーカーファイルで作る冪等なapply、flockによる単一実行のロック、SIGTERMの伝達、試行ごとに残すJSONログ、そして--dry-runまで付けます。用意されたコマンド3つ(最初の2回は失敗するflaky.sh、終わらないhang.sh、呼ぶたびに適用されるapply.sh)は、/opt/fixtures/pyops/bin/にあります。