午後になると落ちる — 尽きる資源をデータで捕まえる
目標
ファイルディスクリプター(fd)のリークを傾きで証明し、上限を下げて枯渇を前倒しで再現し、枯渇したあとに見当違いの場所で出るエラーを記録します。メモリもアドレス空間の上限で安全に再現して、例外として受け取られる場合と殺される場合の違いを書き、修正バージョンの傾きが0であることを同じツールで証明します。
なぜ重要なのか
リソース枯渇は、症状が原因を指さない代表的な出来事です。ディスクリプターをリークさせたのはセッションハンドラーなのに、エラーはデータベース接続で出ます。リークさせたコードはすでに自分の分を取り終えていて、枯渇が表に出た瞬間に、その次にリソースを要求したコードが失敗するからです。 そのため、この調査はメッセージを読む作業ではなく、測る作業です。リクエスト数を変えながら開いているディスクリプターの数を測ると、点がいくつか出て、その傾きがリクエスト1つあたりのリーク数です。傾きが0ならリークしていません。これが、「感覚」をデータに変える方法です。 上限は、調査の道具でもあります。ソフト上限はプロセスが自分で下げられるので、10時間後に出会う枯渇を、今数秒で作れます。逆に、上限を上げて覆い隠すと、落ちる時刻が後ろにずれるだけです。 採点ツールは、あなたの結論を信じません。採点ツールが、リクエスト1つあたり正確に何個リークさせるかを知っているハンドラーを別に作って、あなたの計測ツールを実際に接続し、傾きと上限とエラー名を直接照合します。その個数は、実行のたびに変わります。
ステップ
- /root/exhaust/gen_exhaust.pyを作成して実行し、/root/exhaust/leaky.pyを作ってください。
- /root/exhaust/fdcount.pyで、生きているプロセスの開いているディスクリプターの数を数えてください。
- /root/exhaust/measure_leak.pyでリクエスト数を変えながら測り、/root/exhaust/trend.jsonに傾きを残してください。
- /root/exhaust/run_under_limit.pyで上限を下げて枯渇を前倒しで起こし、/root/exhaust/nofile.jsonに書いてください。
- /root/exhaust/symptoms.pyで、枯渇したあとの失敗を集めて、/root/exhaust/symptoms.jsonに書いてください。
- /root/exhaust/mem.pyでアドレス空間の上限を再現し、/root/exhaust/mem.jsonに書いてください。
- 修正バージョンを同じツールで測り、/root/exhaust/fixed.jsonに、傾き0と合格を残してください。
- /root/exhaust/summary.jsonを作り、/root/exhaust/exhaust_report.mdに4つの節で報告してください。
参考
- ハンドラーの契約:
python3 /root/exhaust/leaky.py --requests N [--fixed] [--dir D] [--ready-file F] [--pause-file P]は、リクエストを処理したあとreadyファイルを作り、pauseファイルができるまで生きていて、JSONを1行出力します。生きている間に、ディスクリプターの数を測れます。 - 数えるツール:
python3 /root/exhaust/fdcount.py --pid <번호>(プレースホルダーは番号です)は、pid・open_fdsを含むJSONを1つ出力します。Linuxは/proc/<pid>/fdに、開いているディスクリプターごとにエントリを1つ置きます。 - 計測ツール:
python3 /root/exhaust/measure_leak.py --target <처리기> --points 10,40,80 [--target-arg=--fixed] --out <json>(プレースホルダーはハンドラーです)は、target・target_args・points(リクエスト数とディスクリプター数のペア)・per_request(傾き)・baselineを出力します。--target-argで渡した値は、ハンドラーにそのまま渡されます(等号でつないで書きます)。傾きは、(最後のディスクリプター数 - 最初のディスクリプター数) / (最後のリクエスト数 - 最初のリクエスト数)です。 - 上限ツール:
python3 /root/exhaust/run_under_limit.py --nofile <상한> --cmd "<명령>" --out <json>(プレースホルダーは上限とコマンドです)は、そのコマンドにだけ低い上限をかけて実行し、nofile・cmd・exit_code・errno_name・stderr_tail・stdout_tailを出力します。errno_nameは、ディスクリプターが枯渇していたらEMFILE、そうでなければnullです。 - 症状ツール:
python3 /root/exhaust/symptoms.py --nofile <상한> --out <json>(プレースホルダーは上限です)は、ディスクリプターを枯渇させたあと、open・socket・subprocess・sqlite3の4つを試して、nofile・held・observations(op・error・errno)を出力します。 - メモリツール:
python3 /root/exhaust/mem.py --as-mb <상한> --alloc-mb <할당> --out <json>(プレースホルダーは上限と割り当てです)は、as_mb・alloc_mb・outcome(MemoryErrorまたはok)・exit_codeを出力します。 resource.setrlimitは、自分のプロセスと、その後に生まれる子プロセスに適用されます。子プロセスを起動する直前(preexec_fn)に下げれば、親はそのままにして、そのコマンドだけを狭い部屋に入れられます。- よくあるミスは、1回だけ測ってリークだと言うこと、上限を上げて覆い隠すこと、ディスクリプターをファイルだけで数えること、すでに終了したプロセスの/procを読もうとすることの4つです。
- このラボの前提: 傾きを直線と見なすのは、このハンドラーがリクエストごとに同じ数を開く単純な構造だからです。実際のサービスでは、点をさらに打って、直線かどうかから確認する必要があります。
- 負荷テストを作らないでください。Podは2コア・メモリ2Giで、採点1回の予算は60秒です。
セッションハンドラーを手に入れる
/root/exhaust/gen_exhaust.pyを作成して実行し、/root/exhaust/leaky.pyを作ってください。--requestsでリクエスト数を渡し、--fixedで修正バージョンを実行できます。
このスクリプトをそのまま保存して実行すれば構いません。leaky.pyを--requests 100で一度実行してみてください。何も起きていないように見えます。ディスクリプターはプロセスの中でだけ溜まり、プロセスが終わればカーネルがすべて回収するからです。
今いくつ持っているかを数える
/root/exhaust/fdcount.pyを作成して、--pid <번호>(プレースホルダーは番号です)でそのプロセスの開いているディスクリプターの数を数え、pid・open_fdsを含むJSONを出力させてください。
Linuxはproc(5)に、プロセスごとに/proc//fdディレクトリを置き、開いているディスクリプターごとにエントリを1つ置きます。数える作業は、そのディレクトリのエントリ数を数えることです。存在しないプロセスを指定されたときにどう答えるかも、決めておいてください。
リクエスト1つあたり何個リークするかを傾きで求める
/root/exhaust/measure_leak.pyでリクエスト数10・40・80のそれぞれでディスクリプターの数を測り、/root/exhaust/trend.jsonにtarget・points・per_request・baselineを残してください。per_requestは1以上である必要があります。
ハンドラーは、readyファイルを作ったあと、pauseファイルができるまで生きています。その間に/procで数えればよいです。点が2つあれば傾きが出ますが、3つ打てば直線かどうかも見られます。測り終えたら、pauseファイルを作ってハンドラーを終わらせるのを忘れないでください。
上限を下げて枯渇を前倒しで起こす
/root/exhaust/run_under_limit.pyでleaky.pyを低い--nofileの下で実行し、/root/exhaust/nofile.jsonにnofile・cmd・exit_code・errno_name・stderr_tail・stdout_tailを残してください。errno_nameはEMFILEである必要があります。
resource.setrlimitは、自分のプロセスと、その後に生まれる子プロセスに適用されます。subprocessのpreexec_fnで下げれば、そのコマンドだけを狭い部屋に入れられます。リクエスト数は、上限より十分大きく与えてください。上限に届いて初めて、枯渇が見えます。
症状が原因を指さない
/root/exhaust/symptoms.pyでディスクリプターを枯渇させたあと、open・socket・subprocess・sqlite3の4つを試して、/root/exhaust/symptoms.jsonにnofile・held・observationsとmisleading_opsを残してください。misleading_opsは、エラーメッセージにディスクリプターの話がない項目です。
4つとも、ディスクリプターを必要としますが、メッセージの顔は違います。特にsqlite3は、「データベースファイルを開けない」としか言いません。そのメッセージを見てディスクと権限を調べると、何時間も消えてしまいます。どの項目が原因を隠しているのかを、一覧にして残してください。
メモリはどのように枯渇するのか
/root/exhaust/mem.pyで、アドレス空間の上限を超える割り当てと超えない割り当てをそれぞれ再現し、/root/exhaust/mem.jsonにover・underの2つの結果とnoteを残してください。overのoutcomeはMemoryError、underはokである必要があります。
RLIMIT_ASはアドレス空間の上限なので、上限を超える割り当てはMemoryErrorとして上がってきます。トレースバックが残る点が重要です。カーネルのOOMキラーはSIGKILLなので、何の跡も残しません。noteには、この違いと、アドレス空間が実際の使用量とは違うことを書いてください。
修正バージョンの傾きが0であることを証明する
修正バージョン(--fixed)を同じツールで測り、/root/exhaust/fixed.jsonにper_request_before・per_request_after・nofile・requests・exit_code_afterを残してください。per_request_afterは0で、exit_code_afterは0である必要があります。
直したという主張も、同じ計測で行う必要があります。傾きが0なら、リクエストが増えてもディスクリプターが増えないという意味で、低い上限で多くのリクエストを処理しても合格するなら、枯渇に届かないという意味です。2つの証拠を一緒に残してください。
症状と原因を分けて書いて報告する
/root/exhaust/summary.jsonにper_request_before・per_request_after・nofile・errno_name・misleading_ops・mem_outcomeを書き、/root/exhaust/exhaust_report.mdに## 무엇이 바닥났나、## 증상은 어디서 났나、## 어떻게 증명했나、## 남은 위험(韓国語の見出しは、順に「何が尽きたか」「症状はどこで出たか」「どう証明したか」「残るリスク」という意味です)の4つの節で報告してください。
報告書の価値は、「ディスクリプターがリークしました」ではなく、「リクエスト1つにつき1個ずつリークしていて、直したあとは0です」にあります。症状の一覧と原因を別々に書いて、次の人がsqliteのメッセージを見てディスクを調べ回らないようにしてください。