.NET APIがステージングでは完璧に動作していても、本番環境の負荷でダウンする場合、その根本原因はほぼ単一の致命的なバグではありません。通常は小さな非効率性の集合 — ここでのブロッキング呼び出し、そこで追跡されていないエンティティグラフ、ミス設定されたミドルウェアパイプライン — であり、これらはリクエスト同時実行量が1分間に数千件に達した時点で初めて顕在化します。ASP.NET Coreパフォーマンスチューニングとは、こうした非効率性を体系的に発見・除去する実践であり、アウテージ発生後に反応するのではなく、事前に取り組むものです。本記事では、高トラフィックAPIで特に重要な領域 — 非同期処理の正確性、キャッシュ、データアクセス、シリアライズ、ミドルウェア順序、スケーリング、そして実際に時間をかけるべき場所を教えてくれる診断ツール — を順に解説します。

正しいAsync/Awaitの実践

本番環境のASP.NET Coreアプリケーションで最もよく見られるパフォーマンス低下の原因は、sync-over-asyncコード — Taskに対して.Result.Wait()を呼び出してawaitせずに待機するパターン — です。このパターンはI/Oバウンド操作の完了を待つ間、スレッドプールスレッドをブロックし、負荷がかかるとスレッドプール枯渇を引き起こします。プールがゆっくり拡大し、新規リクエストが空きスレッドを待ってキューイングされ、CPU使用率は低く見えてもアプリケーション全体でレイテンシが急増します。原則としては単純な解決策 — 呼び出しスタック全体でawaitすること — ですが、静的コンストラクタ、カスタムミドルウェア、バックグラウンドサービスループなど、開発者が予期しない箇所も含めてコードベース全体で規律が必要です。

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IOrderRepository _orders;

    public OrdersController(IOrderRepository orders)
    {
        _orders = orders;
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<OrderDto>> GetOrder(int id, CancellationToken cancellationToken)
    {
        var order = await _orders.GetByIdAsync(id, cancellationToken);
        if (order is null)
        {
            return NotFound();
        }

        return Ok(order.ToDto());
    }
}

Enter fullscreen mode Exit fullscreen mode

CancellationTokenパラメータに注目してください。ASP.NET Coreはこのパラメータをリクエストの中断シグナルに自動的に接続します。データアクセスや下流のHTTP呼び出しまでトークンを伝播することで、クライアント切断やリクエストタイムアウト時に、受信されないレスポンスのためにスレッドプールやデータベースリソースを消費し続けるのを防げます。また、イベントハンドラ以外のasync voidメソッドにも注意してください — その中で発生した例外は呼び出し元でキャッチできず、プロセスをクラッシュさせ、負荷下で特に厄介なデバッグ対象になります。

レスポンスキャッシュと出力キャッシュ戦略

すべてのリクエストがアプリケーションロジックとデータベースに到達する必要はありません。短い時間窓内で多くの呼び出し元に同一データを返すエンドポイント — 商品カタログ、設定データ、パブリックコンテンツ — では、出力キャッシュによりバックエンド処理の大部分を完全に排除できます。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("Catalog", policy =>
        policy.Expire(TimeSpan.FromSeconds(60))
              .Tag("catalog"));
});

var app = builder.Build();
app.UseOutputCache();

app.MapGet("/api/products", async (ICatalogService catalog) =>
{
    var products = await catalog.GetAllAsync();
    return Results.Ok(products);
})
.CacheOutput("Catalog");

app.Run();

Enter fullscreen mode Exit fullscreen mode

上記のタグベース無効化は重要です。商品が更新されたときに、固定有効期限の経過を待ったりキャッシュ全体をフラッシュしたりする代わりに、catalogタグのみを削除できるため、ヒット率を犠牲にせずにデータを適度に最新に保てます。サーバー側出力キャッシュを使用していない場合でも、レスポンスキャッシュヘッダー(Cache-ControlETag)を正しく設定する価値があります。これによりブラウザ、モバイルクライアント、CDNがラウンドトリップ自体を回避できます。

Minimal APIとControllerのオーバーヘッド比較

Minimal APIは、従来のMVCコントローラーパイプラインに伴う定型コードとリクエストあたりのオーバーヘッドを削減するために導入されました。コントローラーはモデルバインディング、フィルター実行、アクション呼び出しの仕組みを経由するため柔軟ですが、ルートを直接デリゲートにマッピングするMinimal APIエンドポイントに比べると測定可能なオーバーヘッドがあります。

ただし、データベース呼び出しや下流HTTPリクエストが全体のリクエスト時間を支配する典型的なビジネスエンドポイントでは、人々が想定するほど差は大きくありません。より実用的なアプローチは、モデルバインディング検証、一貫したフィルターパイプライン、Swagger統合、共有ベースクラスなどで価値を提供するコントローラーは残し、本当にホットなパスや軽量な新サービスでのみMinimal APIを採用することです。アーキテクチャの決定は、一般的な「どちらのスタイルが常に速い」という信念ではなく、実際に測定されたボトルネックに基づいて行うべきです。

コネクションプーリングとデータベースクエリ最適化

データベースアクセスは、高トラフィックASP.NET Core APIが実際に時間を費やす場所です。繰り返し現れる2つの問題があります。読み取り専用クエリでの不要な変更追跡と、1つの論理リクエストが数十〜数百回のデータベースラウンドトリップを暗黙的に発行するN+1クエリパターンです。

Entity Framework Coreは、データベースへの変更を検知・永続化できるようにデフォルトでエンティティを追跡します。ほとんどのAPIでトラフィックの大部分を占める読み取り専用クエリでは、この追跡オーバーヘッドは純粋な無駄です。AsNoTracking()を呼び出すことで、EF Coreに変更追跡スナップショットの構築をスキップさせられます。

public async Task<List<OrderSummaryDto>> GetRecentOrdersAsync(
    int customerId, CancellationToken cancellationToken)
{
    return await _dbContext.Orders
        .AsNoTracking()
        .Where(o => o.CustomerId == customerId).OrderByDescending(o => o.CreatedAt)
        .Take(20)
        .Select(o => new OrderSummaryDto
        {
            Id = o.Id,
            Total = o.Total,
            Status = o.Status
        })
        .ToListAsync(cancellationToken);
}

Enter fullscreen mode Exit fullscreen mode

上記のようにSelect()で直接DTOに射影することには、追跡オーバーヘッドを回避する以外にも利点があります。EF Coreは射影を実際に必要な列のみを選択するSQLクエリに変換でき、エンティティ行全体をフェッチする必要がなくなります。N+1問題は小規模データセットでのローカルテストでは見えにくい微妙な問題で、親エンティティのコレクションを読み込んだ後、それぞれの子データを読み込むために個別のクエリを遅延実行する際に発生します。解決策はInclude()で関連データを単一クエリで事前読み込みするか、必要なフィールドのみを射影することです。

コネクションプーリング面では、接続文字列とDbContextのライフタイムをワークロードに適した形で構成してください。AddDbContextPoolはリクエストごとに新しいインスタンスを割り当てるのではなく、DbContextインスタンスをリクエスト間で再利用するため、高リクエスト量での割り当て負荷を軽減します。

レスポンス圧縮

意味のあるサイズのJSONペイロードを返すAPIでは、レスポンス圧縮を有効にすることでネットワーク送信バイト数を削減できます。ASP.NET Coreのレスポンス圧縮ミドルウェアはGzipとBrotliの両方をサポートし、Brotliは一般的に典型的なJSONコンテンツで同等またはより良い圧縮速度でより小さなペイロードを生成します。

builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.Providers.Add<BrotliCompressionProvider>();
    options.Providers.Add<GzipCompressionProvider>();
});

builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
    options.Level = System.IO.Compression.CompressionLevel.Fastest;
});

Enter fullscreen mode Exit fullscreen mode

圧縮レベルは重要です。高い圧縮レベルはより多くのバイトを削減しますが、リクエストあたりのCPUコストが増加し、高い同時実行下ではそれ自体がボトルネックになる可能性があります。すでにTLS終端やリバースプロキシ/CDN層で圧縮している場合は、二重圧縮していないか確認してください。

効率的なJSONシリアライズ

シリアライズはAPIが処理するほぼすべてのリクエストで発生するため、チューニングの優先度が高い領域です。System.Text.JsonはASP.NET Coreのデフォルトシリアライザーで、箱から出してすぐに高速ですが、デフォルト設定がアプリケーション固有のペイロード形状に常に最適化されているわけではありません。

builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
{
    options.SerializerOptions.DefaultIgnoreCondition =
        System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
    options.SerializerOptions.PropertyNamingPolicy =
        System.Text.Json.JsonNamingPolicy.CamelCase;
    options.SerializerOptions.NumberHandling =
        System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString;
});

Enter fullscreen mode Exit fullscreen mode

上記のようにnullプロパティを省略することで、ペイロードサイズを縮小し、多くのオプションフィールドを持つオブジェクトのシリアライズ処理を削減できます。非常に高いスループットが求められるシナリオでは、JsonSerializerContextによるソース生成シリアライズを検討してください。これはランタイムリフレクションに依存せず、コンパイル時にシリアライズコードを生成します。また、完全なEF Coreエンティティをシリアライズするのではなく、DTOを直接監査する価値もあります。エンティティにはナビゲーションプロパティや余分なフィールドが含まれることが多く、ペイロードを肥大化させ、シリアライズ中に意図せず遅延読み込みクエリをトリガーする可能性があるためです。

ミドルウェアパイプラインの順序とパフォーマンスへの影響

ASP.NET Coreのすべてのリクエストは登録された順序でミドルウェアパイプラインを通過し、その順序は正しさだけでなく実際のパフォーマンスに影響します。パイプラインの早い段階で登録されたミドルウェアは、後に短絡されるリクエストも含めてすべてのリクエストで実行されるため、高コストなミドルウェアを早い段階に配置すると、必要のないリクエストに対して無駄な処理が発生します。認証・認可ミドルウェアは、一般的に高コストなビジネスロジックミドルウェアより前に実行し、未認証リクエストを安価かつ早期に拒否すべきです。出力キャッシュは、キャッシュヒット時にパイプライン全体を短絡できる程度に早い段階に配置するのが適切です。

ヘルスチェックとロードバランサーの考慮事項

水平スケーリングされたデプロイメントでは、ロードバランサやオーケストレータがヘルスチェックエンドポイントに依存して、インスタンスがトラフィックを受信し続けるべきかを判断します。

builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>("database")
    .AddCheck<DependencyHealthCheck>("downstream-api");

app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("live")
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = _ => true
});

Enter fullscreen mode Exit fullscreen mode

上記のようにlivenessとreadinessを分離することは、スケール時に重要です。livenessチェックは極めて安価で、プロセスが動作中かつ応答可能であることを確認するだけで十分です。オーケストレータがインスタンスを再起動するかを数秒ごとに判断するためです。readinessチェックは下流依存関係の検証を許容できますが、チェック自体が遅いと、多くのインスタンスで頻繁にポーリングされた場合にそれ自体が負荷源になる可能性があります。

水平スケーリングとステートレス設計

プロセス内チューニングがどれだけ進んでも、トラフィック増加時にインスタンスを追加できる能力に取って代わることはできません。ただし、水平スケーリングが正常に機能するのは、アプリケーションが実際にステートレスである場合のみです。セッション状態、インスタンス間で同期されないインメモリキャッシュ、リクエスト固有の可変状態を保持するシングルトンサービスは、いずれも「どのインスタンスでも任意のリクエストを処理できる」という前提を崩します。スケールアウト前にこれらのパターンを監査してください。セッションデータはインメモリではなくRedisなどの分散ストアに保存し、正確に1回だけ実行する必要があるバックグラウンド処理は、分散ロックや専用ワーカーを使用し、すべてのインスタンスで冗長に起動するインメモリタイマーに依存しないようにしてください。

プロファイリングと診断:推測ではなくボトルネックの発見

上述のすべての手法は、実際に測定されたボトルネックに適用された場合にのみ有用です。dotnet-traceは、実行中のASP.NET Coreプロセスが負荷下で実際に何をしているかを理解する際に最初に検討すべきツールです。低オーバーヘッドのイベントトレースをキャプチャし、スレッドプール枯渇、ガベージコレクションプレッシャー、特定のメソッド呼び出しに費やされた時間を特に効果的に可視化できます。Application Insightsや同等のAPMツールは、データベースや下流サービスへの依存関係呼び出しを含む完全なリクエストライフサイクルにわたる継続的かつ低オーバーヘッドの監視という、異なる補完的な役割を果たします。マイクロレベルの質問 — この特定のメソッドはAsNoTracking()で高速化するか、このシリアライズ設定は実際に割り当てを削減するか — には、BenchmarkDotNetが適切です。JITウォームアップ、イテレーション間のガベージコレクション分離、ノイズと実差を区別する統計レポートなど、.NETベンチマークの扱いにくい仕組みを正しく処理します。

ASP.NET Coreパフォーマンスチューニングの統合

これらの手法はどれも単独では存在せず、プロファイリングされていないコードベースに一度にすべてを適用するのは良い戦略ではありません。正しいアプローチは反復的です。代表的な負荷下で実際の診断ツールで測定し、実際のボトルネックを特定 — JSONシリアライズやミドルウェア順序ではなく、データベースアクセスやsync-over-asyncパターンである可能性が非常に高い — し、特定の問題を修正して再度測定します。キャッシュ、圧縮、Minimal APIの採用は実効性のあるレバーですが、非同期処理の正確性と効率的なデータアクセスという基本がすでに確立されてから最も価値を発揮します。N+1クエリ問題の上にキャッシュを重ねても、問題を修正するのではなく遅いレスポンスをキャッシュすることになります。

既存のAPIをスケールしなければならず、チーム内にこれらのパターンに関する深い経験がない場合は、本番障害で学ぶよりも、経験豊富なASP.NET Core開発者をパフォーマンスチューニングの集中プロジェクトに招く方が、通常はより迅速かつリスクが低いでしょう。