この記事では、パフォーマンスを最適化し、AspNetCore.Mvcの機能を調査した経験を共有したいと思います。
背景
数年前、ロードされたサービスの1つで、CPUリソースの大幅な消費に気付きました。 サービスのタスクは実際にメッセージを取得してキューに入れることであり、検証、データ追加などの操作を以前に実行していたため、奇妙に見えました
プロファイリングの結果、デシリアライズはほとんどのプロセッサー時間を「消費」することがわかりました。 私たちは標準のシリアライザーを捨てて、Jilで独自のシリアライザーを作成しました。その結果、リソース消費が数倍減少しました。 すべてが正常に機能し、それを忘れることができました。
問題
セキュリティ、パフォーマンス、フォールトトレランスを含むすべての領域でサービスを継続的に改善しているため、「セキュリティチーム」はサービスのさまざまなテストを実施しています。 そして、しばらく前に、ログのエラーに関するアラートが「飛びます」-どういうわけか、無効なメッセージをさらに逃しました。
詳細な分析では、すべてがかなり奇妙に見えました。 リクエストモデルがあります(ここでは、簡単なコード例を示します)。
public class RequestModel { public string Key { get; set; } FromBody] Required] public PostRequestModelBody Body { get; set; } } public class PostRequestModelBody { [Required] [MinLength(1)] public IEnumerable<long> ItemIds { get; set; } }
アクションPostを持つコントローラーがあります。例えば:
[Route("api/[controller]")] public class HomeController : Controller { [HttpPost] public async Task<ActionResult> Post(RequestModel request) { if (this.ModelState.IsValid) { return this.Ok(); } return this.BadRequest(); } }
すべてが論理的なようです。 リクエストがボディビューから送信された場合
{"itemIds":["","","" … ] }
APIはBadRequestを返します。これにはテストがあります。
それにもかかわらず、ログでは逆のことがわかります。 ログからメッセージを取得してAPIに送信し、ステータスOK ...および...ログの新しいエラーを取得しました。 私たちの目を信じないで、私たちは間違いを犯し、はい、確かにModelState.IsValid == trueであることを確認しました。 同時に、約500ミリ秒の異常に長いクエリ実行時間に気付きましたが、通常の応答時間は50ミリ秒を超えることはほとんどなく、これは1秒あたり数千のリクエストに対応します。
このリクエストとテストの違いは、リクエストに600以上の空行が含まれていることだけでした...
その後、多くのbukafコードがあります。
理由
彼らは何が悪いのか理解し始めました。 エラーを排除するために、彼らはクリーンなアプリケーションを作成し(そこから例を挙げました)、それを使用して説明した状況を再現しました。 合計で、AspNetCore.Mvcコードの調査、テスト、メンタルデバッグに数日間を費やし、問題がJsonInputFormatterにあることが判明しました 。
JsonInputFormatterは、JsonSerializerを使用します。JsonSerializerは、逆シリアル化および型のストリームを取得し、配列(この配列のすべての要素)である場合、各プロパティのシリアル化を試みます。 同時に、シリアル化中にエラーが発生した場合、JsonInputFormatterは各エラーとそのパスを保存し、処理済みとしてマークします。これにより、さらに逆シリアル化の試行を続けることができます。
JsonInputFormatterエラーハンドラーのコードを以下に示します(上記のリンクのGithubで入手できます)。
void ErrorHandler(object sender, Newtonsoft.Json.Serialization.ErrorEventArgs eventArgs) { successful = false; // When ErrorContext.Path does not include ErrorContext.Member, add Member to form full path. var path = eventArgs.ErrorContext.Path; var member = eventArgs.ErrorContext.Member?.ToString(); var addMember = !string.IsNullOrEmpty(member); if (addMember) { // Path.Member case (path.Length < member.Length) needs no further checks. if (path.Length == member.Length) { // Add Member in Path.Memb case but not for Path.Path. addMember = !string.Equals(path, member, StringComparison.Ordinal); } else if (path.Length > member.Length) { // Finally, check whether Path already ends with Member. if (member[0] == '[') { addMember = !path.EndsWith(member, StringComparison.Ordinal); } else { addMember = !path.EndsWith("." + member, StringComparison.Ordinal); } } } if (addMember) { path = ModelNames.CreatePropertyModelName(path, member); } // Handle path combinations such as ""+"Property", "Parent"+"Property", or "Parent"+"[12]". var key = ModelNames.CreatePropertyModelName(context.ModelName, path); exception = eventArgs.ErrorContext.Error; var metadata = GetPathMetadata(context.Metadata, path); var modelStateException = WrapExceptionForModelState(exception); context.ModelState.TryAddModelError(key, modelStateException, metadata); _logger.JsonInputException(exception); // Error must always be marked as handled // Failure to do so can cause the exception to be rethrown at every recursive level and // overflow the stack for x64 CLR processes eventArgs.ErrorContext.Handled = true; }
プロセッサの最後でエラーを処理済みとしてマークします
eventArgs.ErrorContext.Handled = true;
したがって、すべての逆シリアル化エラーとそれらへのパスを出力するための機能が実装されており、それらがどのフィールド/要素にあったか、...ほとんどすべて...
実際、JsonSerializerには200個のエラーの制限があり、その後クラッシュしますが、すべてのコードがクラッシュし、ModelStateが...有効になります!...エラーも失われます。
解決策
ためらうことなく、Jil Deserializerを使用してAsp.Net CoreのJsonフォーマッターを実装しました。 ボディのエラーの数は私たちにとって絶対に重要ではないので、それらの存在の事実のみが重要であり(そして、それが本当に役立つ状況を想像することは一般に困難です)、シリアライザーコードは非常に簡単であることが判明しました。
カスタムJilJsonInputFormatterのメインコードを提供します。
using (var reader = context.ReaderFactory(request.Body, encoding)) { try { var result = JSON.Deserialize( reader: reader, type: context.ModelType, options: this.jilOptions); if (result == null && !context.TreatEmptyInputAsDefaultValue) { return await InputFormatterResult.NoValueAsync(); } else { return await InputFormatterResult.SuccessAsync(result); } } catch { // - } return await InputFormatterResult.FailureAsync(); }
注意! Jilは大文字と小文字を区別します。つまり、ボディのコンテンツは
{"ItemIds":["","","" … ] }
そして
{"itemIds":["","","" … ] }
同じではありません。 最初のケースでは、camelCaseが使用されている場合、逆シリアル化後にItemIdsプロパティはnullになります。
しかし、これは私たちのAPIであるため、それを使用および制御します。私たちにとっては重要ではありません。 パブリックAPIであり、誰かがそれを呼び出して、キャメルケースではなくパラメーター名を渡す場合、問題が発生する可能性があります。
結果
結果は予想をはるかに上回りました。予想通り、APIはリクエストされたリクエストにBadRequestを返し始め、それを非常に迅速に行いました。 以下に、展開の前後の応答時間とCPUの変化を明確に示すグラフのスクリーンショットを示します。
リードタイムのリクエスト:
16:00頃に展開がありました。 展開前のp99の実行時間は30〜57ミリ秒でしたが、展開後は9〜15ミリ秒になりました。 (18:00のピークの繰り返しは無視できます-これは別の展開でした)
CPUグラフは次のように変更されました。
このため、執筆時点でGithubに問題を持ち込み、マイルストーン3.0.0-preview3でバグとしてフラグを立てました。
結論として
問題が解決するまで、特にパブリックAPIがある場合は、標準のデシリアライゼーションの使用を放棄する方が良いと考えています。 この問題を知っている攻撃者は、誤った配列を大きくするほど、Bodyが多くなるほど、JsonInputFormatterでの処理が長くなるため、同様の無効なリクエストを大量にスローすることで簡単にサービスを投入できます。
Artyom Astashkin、開発チームリーダー