本番の IIS 上で Blazor Server(.NET 8)の WebSocket が確立できず、403/404 や Long Polling へのフォールバックが起きる――。開発環境では問題ないのに本番だけ不安定、という相談は珍しくありません。本記事では原因の全体像を「ネットワーク」「IIS」「アプリ」の3軸で体系化し、実運用に直結する具体的な確認手順・設定例・一時回避策、そして実際に詰まったコード上の落とし穴までまとめて解説します。
Blazor Server(.NET 8)で WebSocket が確立できないときの全体像
典型的な症状は次のとおりです。
wss://<site>/_blazor?id=...への接続が失敗し、ブラウザのコンソールに WebSocket failed to connect、または Network タブに 404/403 が記録される。- SignalR が WebSocket に失敗した後、Long Polling に自動フォールバックした旨の警告が出る。
- IIS のアクセスログに
403 POST /_blazorや403 DELETE /_blazorが残る。
前提は次のようなケースが多いでしょう。
- Windows IIS ホスティング(
inprocessモデル)。 web.configの<webSocket enabled="true" />は設定済み。- ファイアウォール/ロードバランサー/CDN は「ない」か、許可したつもり。
- 同一サーバーで動作する別の Blazor Server プロジェクトでは WebSocket が使えている。
なぜ起きるのか(仕組みの要点)
Blazor Server は内部で SignalR のハブ(/_blazor) に接続します。接続シーケンスは概ね次の通りです。
/_blazor/negotiateへの HTTP 要求でトランスポートを交渉。- WebSocket が許可されていれば
/_blazor?id=...に 101 Switching Protocols で昇格。 - 何らかの理由で WebSocket が失敗すると、SSE もしくは Long Polling を試みます。Long Polling の場合、POST/GET/DELETE が
/_blazorに対して行われます。
そのため、WebSocket を阻害する要因だけでなく、Request Filtering(動詞の制限)や URL Rewrite による /_blazor への介入でも障害が発生します。
まずはここを潰す:最短チェックリスト
| チェック項目 | 期待値・推奨設定 | 確認方法 | よくある落とし穴 |
|---|---|---|---|
| WebSocket プロトコル(IIS 機能) | サーバー機能「WebSocket Protocol」を有効 | サーバーマネージャー / Windows の機能 | アプリ側の <webSocket /> だけ有効で、サーバー機能自体が未インストール |
| 経路上のプロキシ/WAF | Upgrade ヘッダーを通過、101 を返せる | DevTools の WS タブで 101 を確認 | HTTP/2/3 の終端やヘッダー改変、圧縮強制で握手失敗 |
| スティッキーセッション | LB/ARR でセッションアフィニティ有効 | ARR の ARRAffinity Cookie を確認 | 複数インスタンスでアフィニティなし(回線が別ノードへ揺れる) |
| Request Filtering(HTTP 動詞) | /_blazor に対する POST/DELETE を許可 | IIS Manager > Request Filtering | セキュリティポリシーで DELETE が全体禁止 |
| URL Rewrite | /_blazor* を除外 | Rewrite ルール確認 | SPA 向けのキャッチオールが /_blazor を誤捕捉 |
| ハンドラ順序 | aspNetCore ハンドラが先頭で verb="*" | web.config の <handlers> | 静的ファイルハンドラが先にマッチして 404/403 |
| アプリのエンドポイント | Blazor Hub が公開されている | Program.cs のマッピング | 新旧テンプレート差で /_blazor が未登録 |
ネットワーク経路の点検(ブラウザ〜IIS 間)
Upgrade: websocket が本当に通っているか
開発者ツール(Network > WS)で /_blazor?id=... の握手が 101 Switching Protocols になっているか確認します。200/403/404 で止まっている場合は、途中のどこかで Upgrade や Connection: Upgrade が落ちています。
ロードバランサー/リバースプロキシの設定
- スティッキーセッション(セッションアフィニティ)を必ず有効化。IIS/ARR の場合は
ARRAffinityCookie が配布されます。WebSocket は長寿命接続のため、ノード間でセッションが揺れると即切断されます。 - HTTP/2/3 終端の装置では、WebSocket 拡張接続(または HTTP/1.1 での Upgrade)を受けられる設定か確認します。
- 中間装置で ヘッダーの書き換え・圧縮強制・アイドルタイムアウトが有効だと握手や維持に失敗します。WebSocket 経路だけは除外する運用が安全です。
テスト用ワンライナー
# Node.js wscat の利用例(事前に npm i -g wscat)
wscat -c wss://<site>/_blazor?id=test
# 握手に成功すれば "connected (press CTRL+C to quit)" が表示されます
IIS 側の設定と落とし穴
WebSocket Protocol の有効化
サーバー機能としての「WebSocket Protocol」を必ず有効にします。アプリの web.config に <webSocket enabled="true"/> を書いただけでは不足です。
Request Filtering(HTTP 動詞の制御)
403 Method Not Allowed は、POST/DELETE が拒否されているときに発生します。Blazor Server は WebSocket 利用前後やフォールバック時にこれらの動詞を使います。IIS Manager の Request Filtering > HTTP Verbs で該当動詞を許可するか、web.config で次のように /_blazor だけを許可します。
<!-- web.config の一例:/_blazor 直下だけ HTTP 動詞の制限を緩める -->
<location path="_blazor">
<system.webServer>
<security>
<requestFiltering>
<verbs allowUnlisted="true">
<add verb="POST" allowed="true" />
<add verb="DELETE" allowed="true" />
<add verb="GET" allowed="true" />
<add verb="OPTIONS" allowed="true" />
</verbs>
</requestFiltering>
</security>
</system.webServer>
</location>
環境によってはグローバルに DELETE を禁止していることが多いので、/_blazor だけ例外許可するのが現実的です。
Handlers / Modules / URL Rewrite の干渉
<handlers>で aspNetCore ハンドラが先にマッチし、verb="*"になっているか確認します。静的ファイルハンドラが先に来ていると 404/403 を引き起こします。- URL Rewrite で SPA 用のキャッチオールを入れている場合、
/_blazorを除外してください。 - ARR を使う場合は「WebSocket Support」をオンにします(オフだと握手が 200/301 などに化けます)。
web.config の最小構成例(inprocess)
<configuration>
<system.webServer>
<webSocket enabled="true" />
<handlers>
<add name="aspNetCore" path="*" verb="*"
modules="AspNetCoreModuleV2"
resourceType="Unspecified" />
</handlers>
<aspNetCore processPath=""dotnet""
arguments=""MyApp.dll""
stdoutLogEnabled="false"
hostingModel="inprocess" />
アプリ(.NET 8)側の確認ポイント
従来テンプレート(Server-Side Blazor)の最小構成
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor(); // ← これが SignalR ハブを登録する
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.MapBlazorHub(); // ← /_blazor を公開
app.MapFallbackToPage("/_Host");
app.Run();
.NET 8 の Razor Components(Server インタラクティブ)の最小構成
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddRazorComponents()
.AddInteractiveServerComponents(); // ← サーバー対話の登録
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.MapRazorComponents()
.AddInteractiveServerRenderMode(); // ← /_blazor の SignalR ハブが公開される
app.Run();
新旧テンプレートでメソッド名は異なりますが、どちらも最終的には SignalR ハブ(/_blazor) が公開されます。テンプレートの混在や不要な Map の省略があると 404 になります。
Hub オプション(KeepAlive/Timeout)の調整
頻繁な切断や遅延がある場合は KeepAlive/Timeout を調整します(従来テンプレートの例)。
builder.Services.AddServerSideBlazor()
.AddHubOptions(o =>
{
o.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
o.KeepAliveInterval = TimeSpan.FromSeconds(15);
// 必要に応じて最大メッセージサイズなども
// o.MaximumReceiveMessageSize = 64 * 1024;
});
クライアント側で Long Polling を強制する暫定回避
WebSocket がどうしても通らない環境では、暫定的に Long Polling を強制して可用性を確保できます(ただしスケールしづらく通信量が増えるため、本番常用は非推奨)。
<script src="_framework/blazor.web.js" autostart="false"></script>
<script>
Blazor.start({
circuit: {
configureSignalR: (builder) => {
// 4 = LongPolling(signalR.HttpTransportType.LongPolling)
builder.withUrl('_blazor', { transport: 4 });
}
}
});
</script>
ログの読み方と診断の進め方
ブラウザ側
- 開発者ツール > Network > WS で
/_blazor?id=...を選択。101 で確立、メッセージフレームが流れていれば正常。 - コンソールに failed to connect や CORS/証明書エラーが出ていないか。
IIS ログ(%SystemDrive%\inetpub\logs\LogFiles)
| ログ例 | 示唆する原因 | 対処の方向性 |
|---|---|---|
404 GET /_blazor | ハブが公開されていない / Rewrite で誤転送 | MapBlazorHub() または AddInteractiveServerRenderMode() の見直し、Rewrite 除外 |
403 POST /_blazor | 動詞(Verb)制限、WAF で POST 制限 | Request Filtering で許可、WAF 例外ルール |
403 DELETE /_blazor | DELETE が禁止(Long Polling の終了時に利用) | /_blazor に限って DELETE を許可 |
200 GET /_blazor?id=...(Upgrade なし) | 途中で Upgrade ヘッダーが落ちた / Proxy が握手を変換 | Proxy/ARR の WebSocket 許可、HTTP バージョンの見直し |
アプリのログ
Application Insights や Serilog で Microsoft.AspNetCore.SignalR と Microsoft.AspNetCore.Components.Server のログレベルを Information/Debug に上げると、接続・切断・例外が把握できます。例外が起きているのにブラウザではただ「失敗した」ように見えるケースを拾えます。
運用上の暫定回避と注意点
- Long Polling 強制:ピークトラフィック時のリクエスト数増加に備え、IIS の Max Requests、アプリケーションプールの Queue Length を余裕ある値に。
- スケーリング:WebSocket が使えない前提でスケールする場合、コネクション数ベースの容量計画(ユーザー数 × 同時接続 × ポーリング間隔)を行う。
- フェイルオーバーテスト:LB 切替・再起動時のセッション維持を確認。アフィニティや再接続戦略(
reconnectionOptions)の動作を実地検証します。
実例:KeyValuePair の扱いミスが原因でハブ起動がハングしたケース
ネットワークや IIS 設定は問題なし。にもかかわらず WebSocket 接続が確立せず、Long Polling にフォールバックする――。最終的な原因がアプリ内の例外だった事例です。
症状
- ブラウザは WebSocket failed to connect を表示。IIS ログには 403/404 はなし。
- アプリのログには 起動直後に 500 エラー が sporadic に出るが、明確なスタックトレースが残っていない。
- 原因は、構成読み込み時に
KeyValuePair<string,string>を辞書へ詰め替える処理で 重複キー が紛れ込み、ArgumentExceptionを投げていたこと。
問題のあるコード(例)
// 設定に重複キーが含まれていると例外(同一キー追加)
// 例外は初期化中に発生し、ハブの初期化が完了しないまま接続要求がハングする
var pairs = LoadPairsFromConfig(); // IEnumerable<KeyValuePair<string,string>>
var dict = new Dictionary();
foreach (var kv in pairs)
{
dict.Add(kv.Key, kv.Value); // ← Duplicate key で ArgumentException
}
// その後、dict を使って DI 設定や Hub 構成を行う...
修正例
// 1) 重複キーを許容して最後勝ち
var dict = pairs
.GroupBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Last().Value, StringComparer.OrdinalIgnoreCase);
// 2) 重複検出して起動中断(ログを出して fail-fast)
var duplicateKeys = pairs.GroupBy(k => k.Key)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToArray();
if (duplicateKeys.Length > 0)
{
throw new InvalidOperationException(
$"Duplicate keys in configuration: {string.Join(", ", duplicateKeys)}");
}
この修正によりハブの初期化が正常に完了し、WebSocket 接続が即座に確立するようになりました。「つながらない=ネットワーク」と決めつけず、アプリ内の例外でハブ公開が完了していない可能性も常に疑うのがコツです。
トラブル対応の標準フロー
- ブラウザのエラー確認:JS コンソールと Network(WS)。101 になっているか。
- IIS ログを突き合わせ:時刻・IP・URL で該当行を特定。
/_blazorの POST/DELETE/GET がどう扱われたかを確認。 - アプリのサーバーログ:Hub 初期化時の例外(DI 失敗、設定値の不整合、辞書の重複など)を洗う。
- ネットワークキャプチャ:Upgrade ヘッダーや 101 が通過しているか。プロキシ越しでヘッダーが落ちていないか。
- IIS 構成の是正:Request Filtering / Handlers / URL Rewrite / ARR の WebSocket を順に確認。
- 暫定運用:Long Polling を強制してユーザー影響を抑えつつ、根本原因の修正を継続。
補足:よくある勘違いと予防策
- <webSocket enabled=”true”> だけで十分ではない:サーバー機能の WebSocket Protocol がオフだと握手は成立しません。
- HTTP/2/3 と WebSocket:HTTP/2/3 を終端する装置があるときは、WebSocket の拡張接続(または HTTP/1.1 Upgrade)がサポートされていることを確認。
- Rewrite の除外忘れ:SPA の catch-all が
/_blazorを飲み込むと 404/200 化して握手が壊れます。 - Verb の社内ポリシー:DELETE 全面禁止のガイドラインがある場合、
/_blazorだけは例外扱いにする承認フローを用意しておく。 - LB のアイドルタイムアウト:短いと定期的に切断され、利用者から「固まる」と報告されます。KeepAlive とセットで見直しましょう。
検証手順テンプレート(現場でそのまま使える)
- 新規の 素の Blazor Server(.NET 8) を同一サーバーにデプロイし、
/_blazorが WebSocket 確立できるかを確認。できるなら アプリ固有 の問題の可能性が高い。 - 本番サイトで URL Rewrite を一時的に無効化(影響範囲に注意)し、握手が通るか試す。
- IIS の Request Filtering(HTTP Verbs) で
/_blazorの POST/DELETE を一時許可。 - ARR/LB の WebSocket サポートとアフィニティ を明示的に有効化。
- ブラウザの Network タブで 101 を確認、ダメなら
wscatで直接接続をテスト。 - アプリログを Info/Debug に上げ、起動時例外や DI 失敗の有無を確認。疑わしければ設定周りを最小化して切り分け。
安定運用のベストプラクティス
- ログの粒度管理:通常は Information、本番障害中のみ Debug を短時間有効化する仕組み(リモートフラグ)を用意。
- メトリクス監視:接続数、切断率、再接続回数、/_blazor のステータスコード分布をダッシュボード化。
- タイムアウト設計:LB/IIS/アプリのアイドルタイムアウト値を揃える。ズレると想定外の再接続ラッシュが起きます。
- デプロイ健全性チェック:リリース前に
wscatとブラウザで 101 を確認する簡易スモークテストを標準化。 - 例外耐性:構成読み込みや辞書化など、起動時に失敗しやすい箇所は fail-fast + 明確なログ に。
まとめ(決め手のポイント)
- WebSocket は Upgrade(101) が通ること、LB の アフィニティ があること、IIS の 動詞制限で
/_blazorを妨げないことが最低条件。 - WebSocket が使えない間は Long Polling を強制してユーザー影響を抑えつつ、恒久対処を進める。
- 「設定は合っているのにダメ」なときは、アプリ内部の例外(初期化・辞書化・DI など)でハブ公開が完了していない可能性を疑う。
IIS 設定チェックリスト(再掲)
- WebSocket Protocol(サーバー機能)を有効化。
- ARR を使うなら「WebSocket Support」をオン、ARRAffinity を有効化。
- URL Rewrite から
/_blazor*を除外。 <handlers>で aspNetCore ハンドラが先頭、verb="*"。- Request Filtering(HTTP Verbs)で
/_blazorの POST/DELETE を許可。
テスト方法(現場でのチェック観点)
- ブラウザ開発者ツール > Network > WS:101 を確認。
wscat -c wss://<site>/_blazor?id=test:握手の可否を CLI で確認。- IIS ログ:
/_blazorに対する POST/DELETE/GET の扱いを時刻で追跡。 - アプリログ:Hub 初期化時の例外・切断理由を収集。
- ネットワークキャプチャ:
Upgrade: websocketヘッダー、101 の有無を確認。
以上の手順を上から順に実施すれば、「WebSocket がつながらず Long Polling にフォールバックする」「403/404 が出る」といった Blazor Server 特有の接続トラブルを確実に切り分けられます。ネットワーク・IIS・アプリの三点をそれぞれ「最小構成で通ること」を確認し、最後に本番の制約へ合わせて積み戻していくのが最短経路です。
付記:コード断片(まとめ)
Request Filtering(/_blazor の動詞許可)
<location path="_blazor">
<system.webServer>
<security>
<requestFiltering>
<verbs allowUnlisted="true">
<add verb="POST" allowed="true" />
<add verb="DELETE" allowed="true" />
</verbs>
</requestFiltering>
</security>
</system.webServer>
</location>
従来テンプレートの最小 Program.cs
builder.Services.AddServerSideBlazor();
app.MapBlazorHub();
.NET 8 Razor Components の最小 Program.cs
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
Long Polling 強制(暫定運用)
<script>
Blazor.start({
circuit: {
configureSignalR: b => b.withUrl('_blazor', { transport: 4 })
}
});
</script>
最後に:本記事の観点をチェックリスト化して運用手順書に組み込んでおけば、現場での初動対応が一気に速くなります。特に Request Filtering の動詞制限と URL Rewrite の除外は見落としがちです。まずは本番のルールセットに /_blazor の例外を明示し、監視・ログ・テストをひとまとめにしておきましょう。

コメント