午前3時、引き継ぎ文書のコマンドが間違っていた
目標
引き継ぎを受ける運用チームのために、メトリクス → アラート条件 → ランブックの項目 → 実行できる確認コマンドのチェーンを作り、採点ツールがダミーのサービスをさまざまな障害モードで立ち上げて、そのチェーンが正確な項目を指しているかを、訓練で確認します。
なぜ重要なのか
引き継ぎ文書は、書いた日にしか合っていません。アラートがどんな条件で鳴るのか、鳴ったときに何で確認するのかが、コードとは別に書かれていると、サービスが変わった瞬間に文書が古くなり、その事実は、明け方の当番がコマンドを打つときに表に出ます。そのため、アラートのルールは、スクリプトが読むデータとして置き、ランブックの確認の行は、終了コードで答えるコマンドで書き、そのコマンドが実際に動くかを、チェッカーと訓練で繰り返し確認します。静かなダッシュボード(メトリクスをスクレイプできていない状態)を、正常と読まないことも、同じチェーンの一部です。
想定所要時間は75分です。デフォルトの60分のセッションが終わる前に、+時間で延長してください(最大180分)。セッションが終わると、/root/drillのファイルは消えるので、残したいコードは、終了の前に別に保管してください。
用意するもの
- 引き継ぎメモ(アラートの表・ランブックの規則):
/opt/lab/drill/brief.md - ダミーのサービス:
python3 /opt/lab/drill/fakesvc.py --port 9311 --mode normal(障害モードは--helpで確認) - ベンダーが残した古いランブック:
/opt/lab/drill/vendor-runbook.md - 採点ツールは、あなたが立ち上げたサーバーを使いません。空いているポートに、ダミーのサービスをさまざまなモード・さまざまなシードで自分で立ち上げ、あなたのスクリプトを実行してみます。提供された数字を暗記して入れても、通過しません。
ステップ
- ダミーのサービスを正常モードで起動し、
/metricsの応答を、加工せずに保存します(ファイル:/root/drill/normal.prom)。原文の# TYPEの行から、이름 형식を1行に1つずつ記録します(プレースホルダーは名前と型です。ファイル:/root/drill/families.txt)。 - パーサーを書きます(ファイル:
/root/drill/promtext.py)。python3 promtext.py FILEが、サンプルごとに{"name","labels","value","timestamp"}オブジェクトのJSON配列を出力します。値は数値、無限大・NaNは文字列"+Inf"・"-Inf"・"NaN"、タイムスタンプがなければnullです。コメント・空行は飛ばし、ラベルの値の中のカンマ・波括弧と、\\・\"・\nのエスケープを、正しく読みます。不正なサンプル行が1つでもあれば、終了コード2です。 - 引き継ぎメモの6つのアラートを移します(ファイル:
/root/drill/alerts.json)。形式は{"alerts": [{"alert","severity","runbook","rule"}]}で、rule.kindは、threshold(metric・op・value)、ratio(metric・denominator・op・value)、expires_within(metric・seconds)、scrape、absent(metrics)のどれかです。採点ツールは、境界値を含むさまざまな状況で、あなたのルールを判定してみます。 - 診断スクリプトを書きます(ファイル:
/root/drill/diagnose.py)。python3 diagnose.py --url URL [--rules 경로](デフォルトは/root/drill/alerts.json。プレースホルダーはパスです)が、URL/metricsを1回スクレイプしてルールで判定し、{"target": URL, "firing": [{"alert","severity","runbook","labels"}]}を出力します。labelsは、そのサンプルのラベルです。終了コードは、アラートがなければ0、あれば1です。しきい値は、ルールファイルから読みます。 - diagnose.pyを、スクレイプできなかった状況をアラートに上げるように直します(ファイル:
/root/drill/diagnose.py)。接続の失敗・HTTP 200ではない・形式のエラー・orders_upがない場合は、OrdersTargetDown(labelsは{})だけを出して、終了コード2です。必須のメトリクスが抜けていれば、抜けたメトリクスごとにOrdersMetricAbsent(labelsは{"metric": 이름})を出します(プレースホルダーは名前です)。 - 6つのアラートのランブックの項目を書きます(ファイル:
/root/drill/runbook.md)。項目は、## 런북이름の見出し(プレースホルダーはランブック名です)と、- 경보:・- 확인:・- 판단:・- 조치:・- 에스컬레이션:の5行です(5つの行の見出しは、韓国語で「アラート」「確認」「判断」「対処」「エスカレーション」を、順に意味する語です)。確認の行は、バッククォートで囲んだ1行のコマンドで、$ORDERS_URLでサービスを指し、bash -o pipefail -cで動かしたとき、正常なら0、その障害なら0以外の値で、すぐに終わる必要があります。 - ランブックチェッカーを書きます(ファイル:
/root/drill/check_runbook.py)。python3 check_runbook.py 런북.md --url URLが、項目ごとに、確認コマンドをORDERS_URL環境変数とあわせて、bash -o pipefail -cで、5秒の制限内で動かし、ランブックの順に[{"id","check","exit","status","reason"}]を出力します(プレースホルダーはランブックのファイル名です)。statusは、終了コード0ならok、そうでなければbrokenです。reasonは、ok・timeout・not-found(127)・exit N・no-check(確認の行がない場合)です。1つでもbrokenがあれば、終了コード1です。ベンダーのランブックで動かして、壊れた行を見つけます。 - 当番の訓練スクリプトを書きます(ファイル:
/root/drill/oncall.sh)。bash oncall.sh URLが、diagnose.pyでアラートを得て、アラートごとにrunbook.mdの項目を探して確認コマンドを動かし、{"target": URL, "incidents": [{"alert","severity","runbook","labels","check_exit","confirmed","escalation"}]}を出力します。confirmedは、確認コマンドが0以外の値で終わったとき(障害の確証)にtrue、escalationは、ランブックのエスカレーションの行のままです。インシデントがあれば終了コード1、なければ0です。
参考
- 原文の保存:
curl -fsS http://127.0.0.1:9311/metrics -o 파일(プレースホルダーはファイル名です)。パイプで加工すると、最後の改行やコメントが消えることがあります。 - サービスを複数立ち上げて比べるときは、ポートを変えます(9312、9313…)。終わったサービスは、
pkill -f 'fakesvc.py --port 9312'のように、ポートまで指定して停止します。 - よくある間違い1: ラベルをカンマで切る。
note="handoff \"v2\", see RB"で壊れます。 - よくある間違い2: スクレイプの失敗を、空のリストとして飲み込む。そうすると、サービスが落ちた夜に、アラートが0件で出ます。
- よくある間違い3: 確認コマンドで
curl ... | grep -qを使う。最初の一致でgrepが終わり、curlがSIGPIPE(終了コード23)を受けて、pipefailのもとでは、正常なのに失敗に見えることがあります。grep -c ... >/dev/nullやjq -eのように、入力を最後まで読む形を使います。 - よくある間違い4: 時間制限をbashだけにかける。パイプの後ろのcurlが生き残って、チェッカーが一緒に止まります。新しいセッションで起動して、プロセスグループごと切ります。
メトリクスの原文をそのままスクレイプしておく
正常モードのダミーのサービスの/metricsの応答を保存し(ファイル: /root/drill/normal.prom)、TYPEの行の이름 형식の一覧を保存してください(プレースホルダーは名前と型です。ファイル: /root/drill/families.txt)。
# TYPE <이름> <형식>の行(プレースホルダーは名前と型です)は、メトリクスのファミリーごとに1つだけです。histogramのファミリーは、TYPEの行の名前が基準で、サンプルには、_bucket・_sum・_countが付いて出てきます。awkで、最初の2つのフィールドが#とTYPEの行だけを選べばよいです。
カンマと引用符にだまされないパーサー
Prometheusのテキスト公開形式を、サンプルオブジェクトのJSON配列に変えるパーサーを書いてください(ファイル: /root/drill/promtext.py)。不正なサンプル行があれば、終了コード2です。
ラベルの部分は、split(',')で切れません。引用符の中では、カンマと}が値の一部で、バックスラッシュの次の文字はエスケープです。文字を1つずつ読む小さな状態機械(引用符の中か・バックスラッシュの直後か)を作ってください。値はfloat()で読み、JSONにない無限大・NaNは、文字列に変えます。
引き継ぎメモの6つのアラートをデータにする
/opt/lab/drill/brief.mdのアラートの表を、ルールに移してください(ファイル: /root/drill/alerts.json)。
「未満」と「以下」、「超過」と「以上」は、別のopです。証明書のメトリクスは有効期限の時刻なので、expires_withinを、キューは、深さではなく、最も古いメッセージの経過時間を使います。スクレイプ失敗はscrape、必須のメトリクスの一覧は、absentのmetricsに書きます。
1回スクレイプして、ルールで判定する
メトリクスをスクレイプしてalerts.jsonで判定し、鳴ったアラートをJSONで出す診断スクリプトを書いてください(アラートなしは0、ありは1。ファイル: /root/drill/diagnose.py)。
サンプルを名前ごとにまとめておくと、ルールの種類ごとに必要なものだけを取り出せます。ratioは、分子と分母を、同じラベルどうしで対応させる必要があります。expires_withinは、「メトリクスの値 − 現在の時刻」を比べます。前のステップのpromtext.pyをimportすれば、パーサーを書き直す必要はありません。
静かなダッシュボードを正常と読まない
スクレイプできなかった場合はOrdersTargetDownと終了コード2で、抜けた必須のメトリクスはOrdersMetricAbsentで、アラートに上げるように直してください(ファイル: /root/drill/diagnose.py)。
urlopenは、接続の失敗にURLError、200ではない応答にHTTPErrorを投げます。パーサーのParseErrorも、スクレイプの失敗です。この3つを空のサンプルのリストに変えると、すべての比較が偽になって、アラートが0件で出ます。それ自体が、アラートでなければなりません。absentは、「この名前のサンプルが1つもないか」で判定します。
終了コードで答えるランブック
6つのアラートの項目を書いてください(ファイル: /root/drill/runbook.md)。確認コマンドは、正常なら0、該当する障害なら0以外で、実際に動作する必要があります。
診断エンドポイント(/debug/deps・/debug/disk・/debug/tls・/debug/queue)はJSONなので、jq -eがよく合います。-eは、結果がfalse・nullなら、終了コード1を出します。curlには、-f(4xx・5xxを失敗として扱う)と-m(時間制限)を付けてください。target-downは、200のメンテナンスページも捕まえる必要があるので、応答の中にorders_upのサンプルがあるかまで見ます。
ベンダーのランブックを動かして、壊れた行を見つける
ランブックの確認コマンドを実際に動かして判定するチェッカーを書き(ファイル: /root/drill/check_runbook.py)、/opt/lab/drill/vendor-runbook.mdで動かしてみてください。
subprocess.run(timeout=…)は、時間が過ぎるとbashだけを殺し、パイプの後ろに残ったcurlが出力のパイプを握って、待ちが終わらないことがあります。Popen(start_new_session=True)で起動し、時間が過ぎたら、os.killpgでグループごと切ってください。終了コード127は、「コマンドが見つからない」です。
アラートからエスカレーションまでの当番の訓練
diagnose.py → runbook.md → 確認コマンド → エスカレーションを、一度に動かすスクリプトを書いてください(ファイル: /root/drill/oncall.sh)。
diagnose.pyの終了コード1・2は、エラーではなく判定の結果なので、set -eでスクリプトを止めないでください。ランブックの解析と時間制限つきの実行は、ステップ7のcheck_runbook.pyの関数をimportして、再利用すればよいです。confirmedは、「確認コマンドが障害を確証したか」なので、終了コードが0ではないときにtrueです。