はじめに
クラウドネイティブな開発が当たり前になり、FirebaseやGCS(Google Cloud Storage)、SendGridなどの外部サービスSDKをアプリケーションに組み込む機会が増えました。
C#(ASP.NET Core)で開発していると、DI(依存性の注入)コンテナに何も考えずに AddScoped や AddTransient でサービスを登録してしまいがちですが、外部APIのSDKをリクエスト毎にDI(都度生成)すると、システムを殺す原因になります。
「DBの DbContext はリクエスト毎(Scoped)に生成しているんだから、外部APIへの接続も同じでいいんじゃないの?」
かつてWinFormsなどのステートフルなクライアント開発を主戦場としていた筆者も、Webシステムの世界に足を踏み入れた際、同じ疑問を抱きました。しかし、Web特有の「多数の同時アクセス」と「外部APIの仕様」が絡むと、この素朴な実装は地獄への入り口となります。
この記事では、現場で実際に起こり得る「外部APIのインスタンス管理」に関する罠と、キーローテーション(秘密情報の定期変更)要件に立ち向かう際のアーキテクチャ設計における最適解(ADR: Architecture Decision Record)を、失敗談を交えて詳細に解説します。
前提環境
- 言語: C# 12 / .NET 8
- Webフレームワーク: ASP.NET Core MVC / Web API / Worker Service
- DIコンテナ:
Microsoft.Extensions.DependencyInjection - 対象となる技術: Firebase Admin SDK, Google Cloud Storage, 外部HTTPクライアントなど
- クラウド環境: パブリッククラウド(GCP, AWS, Azureなど)
第1章: なぜ外部APIのSDKをリクエスト毎にDIしてはいけないのか?
⏱ 10秒まとめ
外部APIのSDKを Scoped や Transient で都度生成すると、毎回重い初期化処理が走りパフォーマンスが劣化します。さらにTCPポートを食いつぶし(ソケット枯渇)、システム全体が通信不能に陥ります。公式SDKは原則「シングルトン」前提で作られています。
🌩 課題:こんなこと困るよね。難しいよね。
Webアプリケーションを開発する際、データベースの DbContext はリクエスト単位(Scoped)でDIするのが定石です。そのノウハウのまま、「外部APIを叩くサービスクラス」もなんとなく Scoped や Transient で登録してしまう現場は少なくありません。
// 【アンチパターン】毎回インスタンスを作り直してしまう
builder.Services.AddScoped<IFirebaseService, FirebaseService>();
builder.Services.AddTransient<IGcsStorageService, GcsStorageService>();
テスト環境や数人が触る程度の段階では問題なく動きます。「動いているからヨシ!」として本番リリースを迎えると、恐ろしい悲劇が待っています。
本番環境で「1秒間に何十、何百」というリクエストが同時に来ると、以下のような現象が連鎖的に発生します。
悲劇1: 毎回の「重い準備処理」によるパフォーマンスの劇的な悪化(初期化オーバーヘッド)
外部APIのSDKは、インスタンスを作る(new する)際に、裏で非常に多くの準備をしています。
- 秘密鍵ファイル(またはJSON)の読み込みとパース
- クラウドの認証サーバーへ通信し、短時間だけ有効な「アクセストークン(OAuth2)」を取得・検証する処理
- 暗号化処理の初期化とセキュアなコネクションプールの構築
DB(SQL Serverなど)の場合は、フレームワーク側(ADO.NET等)に「コネクションプール」という優秀な仕組みがあり、プログラム上で毎回 new SqlConnection() しても、裏では「すでに繋がっている通信経路」を高速に使い回してくれます。
しかし、外部APIのSDKにはデフォルトでそのような「全体をまたぐプール」の仕組みが備わっていないことが多いのです(あるいはインスタンス単位でプールを持つ仕様)。
リクエスト(ユーザーからのアクセス)が来るたびに、毎回この重い認証処理と初期化が走ることになり、レスポンスが数百ミリ秒〜数秒単位で遅くなります。最悪の場合、外部API側のレートリミット(接続数制限)に引っかかり、エラーを連発します。
悲劇2: 通信用の「穴(ポート)」が使い果たされる(ソケット枯渇問題)
Webシステムでは、WinFormsアプリと違い大量の同時アクセスを処理します。
リクエスト毎に外部APIのインスタンスを作り、HTTPリクエストを飛ばし、終わったら破棄する…ということを繰り返すと、サーバー内部で外部APIと通信するための「TCPポート」を大量に消費します。
ネットワークの仕様上、使い終わったポートはすぐには再利用できず、しばらく「待機状態(TIME_WAIT状態)」になります。アクセスが集中すると、あっという間にサーバーのOS側が管理するポート(エフェメラルポート)が枯渇し、「外部APIはおろか、DBにもどこにも繋がらない」というシステムダウン(TCP Port Exhaustion)を引き起こします。
💡 解決策:採用理由と他の選択肢
公式SDKはそもそも、「シングルトン(アプリ起動時に1つだけ作成し、全体で使い回す)」ことを前提に設計・最適化されています。
-
選択肢A:
Transient/Scopedを使う(見送り)- リクエスト毎に認証情報のパースやトークン取得が行われ、CPU/メモリを浪費する。
- コネクションプールが効かず、前述のソケット枯渇を引き起こす。
- メリットは実装が脳死でできることだけ。絶対に採用してはいけない。
-
選択肢B:
Singletonを使う(採用)- SDK内部のアクセストークンのキャッシュ管理や、HTTPコネクションの再利用(Keep-Alive)が有効に働く。
- パフォーマンスが劇的に向上し、リソース消費も安定する。TCPポートも適切に再利用される。
そのため、外部APIのSDK、およびそれを直接ラップするサービスは、必ず Singleton で登録するか、起動時に一度だけ初期化(静的保持)する設計を採用します。
💻 具体例・サンプルコード
Firebase Admin SDKをシングルトンとして安全に初期化し、サービスに注入する例です。
using FirebaseAdmin;
using Google.Apis.Auth.OAuth2;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// 【正解1】SDK本体の初期化は起動時に1度だけ行う
// DefaultInstanceがnullの場合のみ作成することで、二重初期化エラーを防ぐ
if (FirebaseApp.DefaultInstance == null)
{
// ※本来はハードコードせず、Secret Manager等から取得する(第2章参照)
var credentialJson = builder.Configuration["Firebase:CredentialJson"];
var projectId = builder.Configuration["Firebase:ProjectId"];
FirebaseApp.Create(new AppOptions
{
Credential = GoogleCredential.FromJson(credentialJson),
ProjectId = projectId
});
}
// 【正解2】Firebaseの機能を使うサービス自体も Singleton で登録する
// 注意: Singletonサービス内に状態(特定のユーザーのデータなど)を持たせないこと!
builder.Services.AddSingleton<IAuthSyncService, AuthSyncService>();
// ----------------------------------------------------
// 【補足】もしScopedなサービスからSingletonを呼びたい場合
// DIコンテナは「寿命の長いもの(Singleton)から短いもの(Scoped)は呼べない」が
// 「寿命の短いもの(Scoped)から長いもの(Singleton)は呼べる」というルールがあります。
builder.Services.AddScoped<IUserContextService, UserContextService>();
builder.Services.AddScoped<SomeBusinessLogicService>();
// ↑ SomeBusinessLogicService (Scoped) のコンストラクタで
// IAuthSyncService (Singleton) を受け取ることは可能です。
🔰 初心者向け解説:TCPポートの枯渇(ソケット枯渇)とは?
あなたが会社の代表電話(サーバー)の受付担当だとします。電話をかけるために、毎回「新しい使い捨ての携帯電話(Transient/Scoped)」を買ってきて、1回通話したらすぐゴミ箱に捨てていたらどうなるでしょう?
ゴミ箱(OSのネットワークリソース)がすぐにパンパンになり、新しい電話が買えなくなります。これがソケット枯渇です。
正解は、「1つの立派な電話機(Singleton)」を机に置き、みんなでそれを使い回す(コネクションの再利用)ことです。
第2章: 理想と現実の狭間。機密情報のローテーション(鍵変更)はどうするべきか?
⏱ 10秒まとめ
「無停止でのAPIキー切り替え」は理想ですが、バッチ処理の途中エラーなどを引き起こす「バグの温床」です。無理にシステムを動かしたままにするのではなく、「物理ファイルの完全廃止」+「計画停止(メンテナンス枠)での再起動」の組み合わせが最も安全で確実な最適解です。
🌩 課題:こんなこと困るよね。難しいよね。
第1章で「SDKはシングルトンにするべき」と学びました。しかし、セキュリティ意識の高い顧客や監査部門から、次のような要件を突きつけられることがあります。
「万が一APIキーが漏洩した時のために、キーを定期的に変更(ローテーション)する仕組みを入れてください。あ、もちろんシステムは止めないでね(無停止要件)。」
DB接続情報の場合、リクエスト毎(Scoped)にDIコンテナがインスタンスを作るため、その都度Secret Manager(クラウドの金庫)から最新のパスワードを取得する「動的取得」が比較的容易に実装できます。
しかし、外部APIは「起動時に1回だけ読み込む」シングルトンです。稼働中に Secret Manager上のAPIキーが更新されても、システムは古いキーを持ち続けてしまいます。
「じゃあやっぱり、キーが常に最新になるようにリクエスト毎にDIして作り直そう!」と考えてしまうと、第1章のソケット枯渇問題に逆戻りします。
このジレンマにどう立ち向かえば良いのでしょうか?
💡 解決策:採用理由と他の選択肢
「システムを一切止めずに自動更新する」というのは一見理想的ですが、実際には過剰な開発コストと運用上の爆弾を抱え込むことになります。アーキテクチャ・デシジョン(ADR)は以下のようになります。
-
選択肢A: システム無停止でのインスタンス再生成(見送り)
- 仕組み: キーの変更をポーリング等で検知し、稼働中に古いシングルトンインスタンスを破棄して作り直す。
- デメリット1(開発が超複雑): ユーザーからのアクセスが来ている最中にインスタンスをすり替えるため、「一時的にAPIが使えなくなる瞬間」をどう待たせるか等、高度な排他制御(マルチスレッド処理)が必要です。
- デメリット2(最大の罠:長時間バッチにおけるトランザクション切断): 夜間に3時間かかる集計バッチ処理(Worker Service)が走っているとします。その処理の途中で鍵が切り替わった場合、バッチの後半が権限エラーで突然死します。「どこまで処理したか」を厳密に管理し、エラーが起きたら安全に巻き戻す(ロールバック)「冪等性(べきとうせい)」を全ジョブに完璧に実装する必要があり、バグの温床になります。
-
選択肢B: 物理ファイルを廃止し、計画停止(メンテナンス枠)で再起動(採用)
- 仕組み: 物理ファイルを手動で配置する運用を廃止し、Secret Managerから直接メモリに読み込むように改修します。その上で、深夜帯などに数分間の「計画停止」を設け、安全にアプリを再起動して新しいキーを読み込ませます。
-
採用理由:
- ヒューマンエラーの撲滅: ファイルのパーミッション設定ミスや、別環境のファイルを誤配置する事故を根本から防げます。
- 確実な安全性: バッチ処理が完全に止まっている状態(または安全なチェックポイント)で再起動するため、途中終了によるデータ不整合リスクがゼロになります。
過剰な「無停止要件」は開発コストと運用リスクを跳ね上げます。「データ不整合を防ぎ、お客様の大切なデータを守るため」という大義名分のもと、計画停止を選択するのは非常に優れたインフラ設計です。顧客に対しても「よりセキュアなインフラ構成(完全Secret Manager化)へのアップデート」として前向きに説明できます。
💻 具体例・サンプルコード
アプリケーション起動時(Program.cs)に、クラウドの Secret Manager から機密情報を取得し、外部APIを初期化するフローです。物理ファイルをサーバーに配置する運用(FromFile)を根絶します。
using Google.Cloud.SecretManager.V1;
using FirebaseAdmin;
using Google.Apis.Auth.OAuth2;
// ... (その他のusing)
var builder = WebApplication.CreateBuilder(args);
// =========================================================================
// 【環境切り替えフラグ (Feature Toggles)】
// プログラム内に「if (Localhost) ...」といった環境名へのハードコードを
// 残さないため、appsettings.json のフラグで制御します。
// =========================================================================
bool useSecretManager = builder.Configuration.GetValue<bool>("GoogleCloud:UseSecretManager", true);
if (useSecretManager)
{
// GCP等のSecret ManagerをConfigurationに統合する拡張メソッドを呼び出す想定
// これにより builder.Configuration["SecretName"] で取得可能になる
builder.Configuration.AddGcpSecretManager();
}
else
{
// ローカル開発環境などでスキップする場合
Console.WriteLine("設定により Secret Manager の読み込みをスキップします。");
}
// ---------------------------------------------------------
// Firebase Admin SDK の初期化 (完全Secret Manager化)
// ---------------------------------------------------------
if (FirebaseApp.DefaultInstance == null)
{
// Secret Managerから取得したJSON文字列(環境変数やappsettings経由)
var credentialJson = builder.Configuration["FirebaseCredentialJson"];
var projectId = builder.Configuration["GoogleCloud:ProjectId"];
if (!string.IsNullOrEmpty(credentialJson))
{
// 【正解】FromFile ではなく FromJson を使うことで、ファイルレス化を実現
FirebaseApp.Create(new AppOptions
{
Credential = GoogleCredential.FromJson(credentialJson),
ProjectId = projectId
});
}
else
{
// 開発環境等でキーがない場合のフォールバック(警告のみで起動は継続)
Console.WriteLine("Warning: Firebase credential not found.");
}
}
🔰 初心者向け解説:無停止ローテーションの罠とは?
走っている自動車(実行中のバッチ処理)のタイヤ(APIキー)を、車を止めることなく走りながら交換しようとするようなものです。サーカスとしては凄いですが、乗っている人(データ)は命がけです。
安全にタイヤを交換したいなら、ピットインして一度車を止める(計画停止による再起動)のが当たり前ですよね。
第3章: バッチ処理とWeb処理のライフサイクルの違いを意識する
⏱ 10秒まとめ
Webリクエストは一瞬で終わりますが、Worker Service等で動くバッチ処理は長時間実行されます。バッチ処理の中でDIコンテナのスコープを正しく管理しないと、DBコンテキストなどが肥大化し、メモリリークを引き起こします。
🌩 課題:こんなこと困るよね。難しいよね。
第1章・第2章では外部API(シングルトン)の話をしましたが、対照的に「リクエスト毎(Scoped)」であるべきDBコンテキスト(DbContext)の扱いも、Webとバッチで大きく異なります。
Webアプリケーションでは、ASP.NET Coreのフレームワークが「HTTPリクエストが来てから、レスポンスを返すまで」を1つのスコープ(IServiceScope)として自動的に管理してくれます。そのため、コントローラーで DbContext を受け取っても、リクエストが終われば自動で破棄されます。
しかし、Worker Service(常駐バッチ)の場合はどうでしょう?
// 【アンチパターン】Worker全体のスコープでDBコンテキストを持ち続けてしまう
public class MyWorker : BackgroundService
{
private readonly ApplicationDbContext _dbContext; // <- これが危険
public MyWorker(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// ジョブを取得して処理する
var jobs = await _dbContext.JobQueue.ToListAsync();
// ... 処理 ...
await Task.Delay(1000, stoppingToken);
}
}
}
この実装は致命的です。MyWorker 自体はアプリ起動中ずっと生き続ける(Singleton相当の)寿命を持つため、そこに注入された _dbContext もずっと生き続けます。
EF Core(Entity Framework Core)は、取得したエンティティをメモリ上で追跡(Tracking)します。無限ループの中で次々とデータを取得し続けると、追跡対象のオブジェクトが雪だるま式に増え続け、最終的に「メモリリーク(OutOfMemoryException)」でバッチが死にます。
また、長時間同じDBコネクションを握り続けることになり、ネットワークの瞬断等でコネクションが切れた際に回復できなくなります。
💡 解決策:採用理由と他の選択肢
Worker Serviceのような長時間稼働するプロセスの中で、Scopedなサービス(DbContextなど)を使いたい場合は、ループ(処理の単位)ごとに明示的にスコープを作成し、使い終わったら破棄する必要があります。
-
選択肢A: WorkerのコンストラクタでScopedサービスを受け取る(見送り)
- メモリリークを起こす。コネクションが腐る。
-
選択肢B:
IServiceProviderを受け取り、都度CreateScope()する(採用)- 処理単位(ジョブ1件ごと、あるいは1回のポーリングごと)にクリーンな状態のDBコンテキストを生成できる。
- エラーが起きても、次のループでは新しいコンテキストでやり直せる(回復性が高い)。
- 動的なDB接続(ローテーション時の最新キー取得など)にも対応しやすい。
💻 具体例・サンプルコード
Worker Service内で安全にスコープを生成し、ジョブを処理するパターンです。
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
public class JobWorker : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<JobWorker> _logger;
// 同時実行数を制御するためのセマフォ(インフラ負荷考慮)
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(3);
// DbContext ではなく IServiceProvider を受け取る
public JobWorker(IServiceProvider serviceProvider, ILogger<JobWorker> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await _semaphore.WaitAsync(stoppingToken);
// 【重要】タスクごとに別スレッドで処理し、独立したスコープを作る
_ = Task.Run(async () =>
{
// 1回のジョブ処理ごとにスコープを作成!
using var scope = _serviceProvider.CreateScope();
// スコープの中から必要なサービスを取り出す(これでDbContextも新品になる)
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var logicService = scope.ServiceProvider.GetRequiredService<IBusinessLogicService>();
try
{
// ジョブの取得と処理
var job = await dbContext.JobQueues.FirstOrDefaultAsync();
if (job != null)
{
await logicService.ExecuteJobAsync(job);
// ... ステータス更新 ...
await dbContext.SaveChangesAsync();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "ジョブ実行中にエラーが発生しました。");
}
finally
{
_semaphore.Release(); // 枠を空ける
}
}, stoppingToken);
await Task.Delay(1000, stoppingToken); // ポーリング間隔
}
}
}
このように、Webとは異なりバッチ処理では「どこからどこまでが1つの処理(スコープ)なのか」をプログラマが明示的に設計する必要があります。
🔰 初心者向け解説:ステートフルとステートレスとは?
WinFormsのようなクライアントアプリ(ステートフル)は、アプリを起動してから閉じるまで、ずっと記憶(メモリ)を保持します。ずっと同じ部屋にいるイメージです。
一方、Webシステム(ステートレス)は、ユーザーからのリクエスト(注文書)が届くたびに、新しい担当者(Scoped)が記憶喪失の状態で対応にあたります。対応が終わったら担当者は解散します。
Worker(バッチ処理)は「ずっと部屋にいる(ステートフル)」存在ですが、処理する内容(ジョブ)は次々と変わるため、「ジョブごとに新しい担当者を呼んで対応させる(CreateScope)」という、Webに近い動きをさせるのが正解なのです。
単語一覧 (Glossary)
-
DI (Dependency Injection: 依存性の注入)
クラスが依存する別のクラス(サービス)を、外部から渡してあげる設計パターン。ASP.NET Coreには強力なDIコンテナが標準搭載されており、クラスの「寿命(ライフサイクル)」を管理してくれます。
-
Transient / Scoped / Singleton
DIコンテナがインスタンスの寿命を管理する3つのモード。
-
Transient: 要求されるたびに毎回新品を作る。(使い捨てのティッシュ) -
Scoped: 1回のリクエスト(画面遷移など)の中では同じものを使い回し、リクエスト終了時に破棄する。(1回の食事で使うお箸) -
Singleton: アプリが立ち上がってから落ちるまで、たった1つのインスタンスを全員で使い回す。(共有の電子レンジ)
-
-
ソケット枯渇 (TCP Port Exhaustion)
サーバーが外部と通信を行うための「接続口(エフェメラルポート)」を使い果たしてしまい、新たな通信ができなくなる現象。使い捨てのHTTP通信を大量に行うと発生しやすいです。
-
ADR (Architecture Decision Record)
「なぜその技術や設計を採用したのか(あるいは採用しなかったのか)」という意思決定の背景を記録に残すドキュメントのこと。後から参加したメンバーが「なんでこんな実装になってるの?」と迷うのを防ぎます。
-
冪等性(べきとうせい: Idempotency)
「同じ操作を1回やっても、100回やっても、結果が同じになる」という性質のこと。バッチ処理やAPI通信において、エラー時の再試行(リトライ)を安全に行うために必須の概念です。



