はじめに
ASP.NET Coreでは、設定値をIOptions<T>で型付きオブジェクトとして受け取るOptionsパターンが標準的な方法です。ただし、設定の変更が反映されるタイミングや、キー名の誤りが無言で無視される挙動を知らないと、「設定を変えたのに反映されない」「本番だけ値が空になる」といった不具合につながります。本記事では、Optionsパターンと設定読み込みで実務上よく遭遇する3つの落とし穴を扱います。
前提知識 / 用語整理
-
IOptions<T>: シングルトンとして動作し、アプリ起動後は設定変更を反映しない -
IOptionsSnapshot<T>: スコープ(リクエスト)ごとに設定を再読み込みする。スコープ付きサービスとして登録される -
IOptionsMonitor<T>: シングルトンで、設定の変更を検知して最新値を返す。OnChangeで変更通知も受け取れる -
構成プロバイダーの優先順位: 後から追加されたプロバイダーが先のプロバイダーの値を上書きする(既定では、
appsettings.json→appsettings.{Environment}.json→ ユーザーシークレット(Development) → 環境変数 → コマンドライン引数の順)
落とし穴1: IOptions<T>では設定ファイルの変更が反映されない
再現コード
public class FeatureSettings
{
public bool EnableNewCheckout { get; set; }
}
public class CheckoutService
{
private readonly FeatureSettings _settings;
// ❌ IOptions<T>は起動後の変更を反映しない
public CheckoutService(IOptions<FeatureSettings> options)
{
_settings = options.Value;
}
}
なぜ起きるか
IOptions<T>はシングルトンとして登録され、最初に値を取得した時点の内容を保持し続けます。appsettings.jsonを書き換えて再読み込み(reloadOnChange)が行われても、IOptions<T>.Valueには反映されません。フィーチャーフラグのように「再起動せずに切り替えたい」設定をこの方法で受け取ると、設定ファイルを更新しても挙動が変わらず、原因の切り分けに時間がかかります。
正しい書き方
// ◎ シングルトンなサービスでは IOptionsMonitor<T> を使う
public class CheckoutService
{
private readonly IOptionsMonitor<FeatureSettings> _monitor;
public CheckoutService(IOptionsMonitor<FeatureSettings> monitor)
{
_monitor = monitor;
}
public bool IsNewCheckoutEnabled => _monitor.CurrentValue.EnableNewCheckout;
}
// ◎ スコープ付きサービスでは IOptionsSnapshot<T> も使える
public class OrderController : ControllerBase
{
public OrderController(IOptionsSnapshot<FeatureSettings> snapshot) { /* ... */ }
}
使い分けの目安は、「再起動なしで反映したいか」と「受け取る側のサービスの寿命」です。IOptionsSnapshot<T>はスコープ付きのため、シングルトンサービスには注入できません(注入するとDIの検証でエラーになるか、スコープ付きサービスをシングルトンが保持する不整合を招きます)。シングルトンにはIOptionsMonitor<T>を使ってください。設定値が実行中に変わらないなら、IOptions<T>のままで問題ありません。
落とし穴2: キー名の誤りが無言で無視され、既定値のまま動く
再現コード
{
"Smtp": {
"Hots": "smtp.example.com",
"Port": 587
}
}
public class SmtpSettings
{
public string Host { get; set; } = "";
public int Port { get; set; }
}
// ❌ バインドだけ行い、検証していない
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
なぜ起きるか
設定のバインドは、一致するキーがあれば値を設定し、なければ何もしない動作です。上記のHotsのようなタイプミス、セクション名の誤り、環境ごとの設定の入れ忘れがあっても、エラーにならず、Hostは初期値の空文字のまま使われます。開発環境では設定が揃っていて問題にならず、本番にデプロイして初めてメール送信などの実処理で失敗するため、設定の誤りと分かるまでに時間がかかります。
正しい書き方
// ◎ 検証属性を付け、起動時に検証する(.NET 6以降)
public class SmtpSettings
{
[Required]
public string Host { get; set; } = "";
[Range(1, 65535)]
public int Port { get; set; }
}
builder.Services
.AddOptions<SmtpSettings>()
.Bind(builder.Configuration.GetSection("Smtp"))
.ValidateDataAnnotations()
.ValidateOnStart();
ValidateOnStart()を付けると、アプリ起動時に検証が実行され、不正な設定の場合はOptionsValidationExceptionでアプリが起動しません。ValidateOnStart()がない場合、検証はそのオプションが最初に使用された時点まで遅延されるため、本番で該当機能が呼ばれるまでエラーに気づけません。「設定が不正なら起動しない」ようにしておくと、デプロイ直後に問題を検知できます。
落とし穴3: 環境変数の階層指定と優先順位を誤解する
再現コード
# ❌ appsettings.json の Smtp:Host を環境変数で上書きしたい
export Smtp:Host=smtp.prod.example.com
なぜ起きるか
多くのLinuxシェルやコンテナ環境では、環境変数名に:を使えません。階層構造のキーSmtp:Hostを環境変数で指定するには、区切り文字に__(アンダースコア2つ)を使い、Smtp__Hostと書きます。:を含む名前で設定しようとすると、環境変数として登録できなかったり、意図したキーとして読み込まれなかったりして、appsettings.jsonの値がそのまま使われます。さらに、環境変数はappsettings.jsonより後から追加されるプロバイダーのため優先されます。そのため、CI/CDやコンテナの環境変数に古い値が残っていると、appsettings.jsonを修正しても反映されないように見えます。
正しい書き方
# ◎ 階層の区切りには __(アンダースコア2つ)を使う
export Smtp__Host=smtp.prod.example.com
// ◎ 実際にどの値が読み込まれているかをデバッグ用に確認する(本番の機密値はログに出さないこと)
var host = builder.Configuration["Smtp:Host"];
Console.WriteLine($"Smtp:Host = {host}");
// どのプロバイダーが値を持つかの一覧は、(builder.Configuration as IConfigurationRoot)?.GetDebugView() で確認できる
コード上ではSmtp:Hostと:区切りで参照しますが、環境変数側では__区切りで設定する点が対応関係です。値の取得元が分からないときはGetDebugView()で、各キーがどのプロバイダー(どのファイル・環境変数)から来たかを確認できます。パスワードなどの機密情報は、環境変数や設定ファイルに直接書かず、シークレット管理の仕組み(Azure Key Vaultなど)の利用を検討してください。
ベストプラクティスまとめ
-
実行中に設定変更を反映したい箇所では
IOptionsMonitor<T>(スコープ内ならIOptionsSnapshot<T>)を使う -
IOptionsSnapshot<T>はシングルトンサービスに注入しない -
設定クラスに検証属性を付け、
ValidateDataAnnotations().ValidateOnStart()で起動時に検証する -
環境変数で階層キーを指定するときは
:ではなく__を使う -
設定値が意図通りに読み込まれない場合は、構成プロバイダーの優先順位と
GetDebugView()で取得元を確認する - 機密情報は設定ファイルにコミットせず、シークレット管理の仕組みを使う
おわりに
設定読み込みの不具合は、コードが正しくても環境ごとの設定値によって発生するため、デプロイ後に気づくことが多い領域です。Optionsパターンの寿命の違いを理解し、起動時検証を入れておくだけで、本番障害の多くは未然に防げます。