
「メモリリーク」と「.NETアプリケーション」という用語は、よく一緒に使われるわけではありません。しかし、最近.NET Core Webアプリケーションで一連のメモリ不足例外が発生しました。この問題はEntity Framework Coreの動作変更に起因していました。最終的な解決策は非常にシンプルでしたが、そこに至るまでのプロセスは困難でありながらも興味深いものでした。
このシステムはAzureでホストされ、Angular SPAフロントエンドと.NET Core APIバックエンドで構成され、Entity Framework Coreを使用してAzure SQL Databaseと通信していました。.NET開発を専門とするソフトウェアコンサルティング会社として、私たちはこれまでにも同様のアプリケーションを多数開発してきました。そのため、メモリ不足によるクラッシュは想定外であり、これは真剣に対処すべき問題だとすぐに認識しました。Azureポータルのメトリクスを使用すると、メモリ使用率が着実に上昇し、その後突然低下していることがわかりました。この低下がアプリケーションのクラッシュです。

そこで、調査に時間を費やし、古典的なメモリリークのように見える問題を解決するために段階的な変更を行いました。.NETのリークでよくある原因は、何らかのリソースが適切に破棄されていないことです。今回のケースでは、おそらくEF Coreデータベースコンテキストが原因と考えられました。そのため、ソースコードをくまなく調べ、コンテキストが適切に破棄されていない可能性のある箇所を探しました。しかし、何も見つかりませんでした。
Entity Framework Coreを最新バージョンにアップグレードしました。最近の更新にはメモリリークの修正や全体的な効率向上が含まれているためです。
また、使用していたApplication Insightsのバージョンにもメモリリークの可能性があることがわかり(https://github.com/microsoft/ApplicationInsights-dotnet/issues/594 参照)、このパッケージもアップグレードしました。
これらのいずれも問題を解決できなかったため、Azure App Serviceから取得したメモリダンプを解析しました(https://blogs.msdn.microsoft.com/jpsanders/2017/02/02/how-to-get-a-full-memory-dump-in-azure-app-services/ 参照)。
その結果、管理対象メモリの大部分が最終的にMemoryCacheクラスによって使用されていることに気づきました。さらに詳しく調べると、キャッシュされたデータのほとんどが生のSQLクエリであることがわかりました。本質的に同じクエリが何度もキャッシュされ、パラメータ自体がパラメータ化されずにクエリにハードコードされているという事態が大量に発生していました。
例えば、次のようなクエリがキャッシュされる代わりに:
SELECT TOP (1) UserId, FirstName, LastName, EmailAddress
FROM Users
WHERE UserId = @param_1
次のような複数のクエリが見つかりました:
SELECT TOP (1) UserId, FirstName, LastName, EmailAddress
FROM Users
WHERE UserId = 5
そこで、これに関連するEF Coreの問題を検索し、次の問題に遭遇しました:https://github.com/aspnet/EntityFrameworkCore/issues/10535。
この問題の主題は、私たちの問題を指摘していました。動的な式ツリーを構築し、Expressions.Expression.Constantを使用してwhere句にパラメータを渡していたのです。定数式を使用すると、Entity Framework CoreはSQLクエリをパラメータ化せず、これはEntity Framework 6からの動作変更でした。
この式ツリーは、何かをIDで取得するためにあらゆる場所で使用していたため、大きな問題となりました。
そこで、次のような変更を行いました:
// 修正前
var param = Expressions.Expression.Parameter(typeof(T));
Expression = Expressions.Expression.Lambda<Func<T, bool>>(
Expressions.Expression.Call(
Expressions.Expression.Constant(valuesToFilter),
"Contains",
Type.EmptyTypes,
Expressions.Expression.Property(param, propertyName)),
param);
// 修正後
var param = Expressions.Expression.Parameter(typeof(T));
// これが追加した部分
Expression<Func<List<int>>> valuesToFilterLambda = () => valuesToFilter;
Expression = Expressions.Expression.Lambda<Func<T, bool>>(
Expressions.Expression.Call(
valuesToFilterLambda.Body,
"Contains",
Type.EmptyTypes,
Expressions.Expression.Property(param, propertyName)),
param);
ラムダ式を使って式本体を取得することで、Entity Framework CoreがSQLクエリをパラメータ化するようになり、その結果、クエリのインスタンスが1つだけキャッシュされるようになりました。
これは、修正を含む期間のメモリ使用量です。修正箇所は赤でマークされており、大きな違いが見られます。安定したメモリ使用量は200MBを超えることはなく、以前は1GBを超えて上昇し、クラッシュが発生していました。

最初に調査したとき、本当の解決策は私たちが注目していたものではありませんでしたが、メモリダンプを調べて証拠を追跡することで、最終的にそこにたどり着きました。
この調査から得られた教訓は次のとおりです:
- メモリダンプは嘘をつかない - メモリリークが発生した場合は、まず証拠を確認すること。
- MicrosoftはEF Coreのソースコードを公開しており、すべての問題が誰でも見られるようになっており、必要な開発者にとって非常に便利です。
- 単純なコード変更(このケースでは1行)が大きな影響を与える可能性があります。