paint-brush
Rust アプリケーションでヒープの断片化を特定して回避する方法@tomhacohen
571 測定値
571 測定値

Rust アプリケーションでヒープの断片化を特定して回避する方法

Tom Hacohen8m2023/06/27
Read on Terminal Reader

長すぎる; 読むには

Rust プロジェクトでは予想外のメモリ増加が見られました。メモリ使用量が不釣り合いに増加しました。メモリが無制限に増加すると、サービスが強制終了される可能性があります。私にとっての魔法の組み合わせは、*より大きなリクエストボディ*と*より高い同時実行性でした。この特定の「階段ステップ」の詳細は、アプリケーション自体に固有です。
featured image - Rust アプリケーションでヒープの断片化を特定して回避する方法
Tom Hacohen HackerNoon profile picture
0-item
1-item
2-item

階段状プロファイルの謎

最近、 Rust プロジェクトの 1 つであるaxumサービスが、メモリ使用量に関して奇妙な動作を示すのを確認しました。奇妙に見えるメモリプロファイルは、Rust プログラムに期待できるものではありませんが、ここにはそれがあります。



サービスは一定期間「フラットな」メモリで実行され、その後突然新しいプラトーにジャンプします。このパターンは、負荷がかかると数時間にわたって繰り返されますが、常にそうとは限りません。心配なのは、一度急激な増加が見られると、記憶力が元に戻ることはほとんどないことです。まるで記憶が失われるか、そうでなければ時々「漏れる」かのようでした。


通常の状況では、この「階段状」プロファイルは奇妙に見えるだけですが、ある時点でメモリ使用量が不釣り合いに上昇しました。メモリが無制限に増加すると、サービスが強制終了される可能性があります。サービスが突然終了すると、可用性が低下する可能性があり、ビジネスに悪影響を及ぼします。何が起こっているのかを掘り下げて理解したいと思いました。


通常、プログラムでの予期せぬメモリの増加について考えるとき、私はリークを思い浮かべます。それでも、これは違うようでした。漏れがある場合、より安定した規則的な増加パターンが見られる傾向があります。


多くの場合、これは右上がりに傾いた線のように見えます。では、私たちのサービスが漏洩していないとしたら、何が行われていたのでしょうか?


メモリ使用量の急増を引き起こした条件を特定できれば、何が起こっていても軽減できるかもしれません。

掘り下げる

私には 2 つの熱い質問がありました。


  • この動作を促進するためにコードに何か変更が加えられましたか?
  • そうでない場合、新しいトラフィック パターンが出現しましたか?


過去の指標を見ると、長い横ばい期間の間に同様の急激な増加のパターンが見られましたが、これほどの成長はかつてありませんでした。 (「階段状」パターンが私たちにとって正常であるにもかかわらず) 成長自体が新しいものであるかどうかを知るには、この動作を再現する信頼できる方法が必要です。


「ステップ」を強制的に表示できれば、メモリの増加を抑制するための措置を講じたときの動作の変化を検証する方法が得られます。また、Git 履歴を遡って、サービスが際限のない成長を示していなかった時点を探すこともできます。

負荷テストを実行するときに使用したディメンションは次のとおりです。


  • サービスに送信される POST 本文のサイズ。

  • リクエストレート (つまり、1 秒あたりのリクエスト数)。

  • 同時クライアント接続の数。


私にとっての魔法の組み合わせは、より大きなリクエストボディより高い同時実行性でした。


ローカル システムで負荷テストを実行する場合、クライアントとサーバー自体の両方を実行するために使用できるプロセッサの数が有限であるなど、あらゆる種類の制限要因があります。それでも、全体的なリクエスト レートが低い場合でも、適切な状況があれば、ローカル マシンのメモリに「階段状」を確認することができました。


固定サイズのペイロードを使用し、短い休憩を挟んでリクエストをバッチで送信することで、サービスのメモリを一度に 1 ステップずつ繰り返し増やすことができました。


時間が経つにつれて記憶力は向上するものの、最終的には成果が逓減する点に達することが興味深いと思いました。最終的には、成長にはある程度の(それでも予想よりもはるかに高い)天井が存在することになるでしょう。もう少し試してみると、さまざまなペイロード サイズでリクエストを送信することで、さらに高い上限に到達できることがわかりました。


自分の入力を特定したら、Git 履歴を遡って作業することができ、最終的に、本番環境での不安は、私たち側での最近の変更の結果ではない可能性が高いことがわかりました。

この「階段ステップ」を引き起こすワークロードの詳細はアプリケーション自体に固有ですが、おもちゃのプロジェクトで同様のグラフを強制的に発生させることができました。


 #[derive(serde::Deserialize, Clone)] struct Widget { payload: serde_json::Value, } #[derive(serde::Serialize)] struct WidgetCreateResponse { id: String, size: usize, } async fn create_widget(Json(widget): Json<Widget>) -> Response { ( StatusCode::CREATED, Json(process_widget(widget.clone()).await), ) .into_response() } async fn process_widget(widget: Widget) -> WidgetCreateResponse { let widget_id = uuid::Uuid::new_v4(); let bytes = serde_json::to_vec(&widget.payload).unwrap_or_default(); // An arbitrary sleep to pad the handler latency as a stand-in for a more // complex code path. // Tweak the duration by setting the `SLEEP_MS` env var. tokio::time::sleep(std::time::Duration::from_millis( std::env::var("SLEEP_MS") .as_deref() .unwrap_or("150") .parse() .expect("invalid SLEEP_MS"), )) .await; WidgetCreateResponse { id: widget_id.to_string(), size: bytes.len(), } }

そこに到達するためにはそれほど多くは必要ないことがわかりました。 JSON 本文を受け取る単一のハンドラーを備えたaxumアプリからも、同様の急激な (ただし、この場合ははるかに小さい) 増加を確認することができました。


私のおもちゃプロジェクトでのメモリの増加は、運用サービスで見られたほど劇的なものではありませんでしたが、調査の次の段階で比較対照するには十分でした。また、さまざまなワークロードを実験する際に、より小さなコードベースのより緊密な反復ループを実現するのにも役立ちました。負荷テストの実行方法の詳細については、 README を参照してください。

私は、同様の動作を説明している可能性のあるバグ レポートやディスカッションを Web で検索するのに時間を費やしました。繰り返し出てきた用語は「ヒープ断片化」で、このトピックについてもう少し読んだ後、それが私が見ていたものに当てはまりそうな気がしました。

ヒープの断片化とは何ですか?

ある程度の年齢の人は、DOS またはWindows のデフラグ ユーティリティがハードディスク上でブロックを移動して、「使用済み」領域と「空き」領域を統合するのを見たことがあるかもしれません。



この古い PC ハード ドライブの場合、さまざまなサイズのファイルがディスクに書き込まれ、後で移動または削除され、他の使用済み領域の間に利用可能なスペースの「穴」が残されました。ディスクがいっぱいになり始めると、これらの小さな領域の 1 つに収まらない新しいファイルを作成しようとする場合があります。ヒープの断片化のシナリオでは、割り当ての失敗につながりますが、ディスクの断片化の障害モードはそれほど深刻ではありません。ディスク上では、ファイルをより小さなチャンクに分割する必要があるため、アクセスの効率が大幅に低下します ( wongarsuの修正に感謝します)。ディスク ドライブの解決策は、ドライブを「デフラグ」(デフラグ) して、開いているブロックを連続したスペースに再配置することです。


アロケータ (プログラム内のメモリ割り当ての管理を担当するもの) が一定期間にわたってさまざまなサイズの値を追加したり削除したりするときにも、同様のことが発生する可能性があります。ギャップが小さすぎてヒープ全体に散在している場合、そうでなければ適合しない新しい値を収容するために、新しい「新しい」メモリ ブロックが割り当てられる可能性があります。ただし、残念ながら、メモリ管理の仕組みにより、「デフラグ」は不可能です。


断片化の具体的な原因としては、 serdeによる JSON 解析、 axumのフレームワーク レベルでの何か、 tokioのより深い部分、または特定のシステムの特定のアロケーター実装の単なる癖など、さまざまな原因が考えられます。根本原因が分からなくても (そのようなものがあれば)、その動作は環境内で観察可能であり、必要最低限のアプリでもある程度再現可能です。 (更新: さらなる調査が必要ですが、JSON 解析によるものであることは確信しています。HackerN ewsに関するコメントを参照してください)


これがプロセスメモリに起こっていることである場合、それに対して何ができるでしょうか?断片化を避けるためにワークロードを変更するのは難しいようです。また、プロジェクト内のすべての依存関係を解きほぐして、コード内で断片化イベントがどのように発生しているのかの根本原因を見つけることも難しいようです。それで、何ができるでしょうか?

Jemalloc救助に向かう

jemalloc 「断片化の回避とスケーラブルな同時実行のサポートを強調する」ことを目的としていると自己説明しています。実際、同時実行性は私のプログラムの問題の一部であり、断片化を回避することが重要です。 jemallocまさに私が必要としているもののように思えます。

jemallocそもそも断片化を回避するためにわざわざ努力するアロケーターであるため、メモリを徐々に増加させずにサービスをより長く実行できるかもしれないという期待がありました。


プログラムへの入力やアプリケーションの依存関係の山を変更するのは、それほど簡単なことではありません。ただし、アロケータを交換するのは簡単です。


https://github.com/tikv/jemallocator Readme の例に従って、テストドライブを実行するために必要な作業はほとんどありませんでした。


私のおもちゃプロジェクトでは、オプションでjemallocのデフォルト アロケーターを交換するカーゴ機能を追加し、負荷テストを再実行しました。


シミュレートされたロード中に常駐メモリを記録すると、2 つの異なるメモリ プロファイルが表示されます。

jemallocを使用しないと、見慣れた階段状のプロファイルが表示されます。 jemallocを使用すると、テストの実行中にメモリが繰り返し増減することがわかります。さらに重要なことは、ロード時とアイドル時のjemallocによるメモリ使用量にはかなりの違いがありますが、メモリは常にベースラインに戻るため、以前のように「勢いを失う」ことはありません。

まとめ

Rust サービスで「階段状」プロファイルを見つけた場合は、 jemallocテストドライブとして利用することを検討してください。ヒープの断片化を促進するワークロードがある場合は、 jemalloc全体的に良い結果が得られる可能性があります。


これとは別に、おもちゃプロジェクトリポジトリには、 https://github.com/fcsonline/drill負荷テスト ツールで使用するbenchmark.ymlが含まれています。同時実行性、本体サイズ (およびサービス自体の任意のハンドラーのスリープ期間) などを変更して、アロケーターの変更がメモリ プロファイルにどのような影響を与えるかを確認してください。


現実世界への影響に関しては、 jemallocへの切り替えをロールアウトしたときにプロファイルが変化したことがはっきりとわかります。


以前のサービスでは、多くの場合負荷に関係なく、平らな線と大きなステップが表示されていましたが、現在は、アクティブなワークロードをより厳密に追従する、より不規則な線が表示されます。この変更により、サービスが不必要なメモリの増加を回避できるという利点のほかに、サービスが負荷にどのように応答するかについてより良い洞察が得られるようになり、全体としては前向きな結果となりました。


Rust を使用して堅牢でスケーラブルなサービスを構築することに興味がある方を募集しています。詳細については、採用ページをご覧ください。


このようなコンテンツをさらに知りたい場合は、 TwitterGithub 、またはRSSでフォローしてSvix Webhook サービスの最新アップデートを入手するか、コミュニティ Slackでのディスカッションに参加してください。


こちらでも公開しております。