マルチインスタンス展開
OP はリクエストをまたいでステートレスで、すべてのレプリカが設定済みの store.Store 経由で読み書きします。レプリカを 1 から N に増やすと、論点は「プロセスメモリに何があるか」から「全レプリカが見えなければならないものは何か、失ってよいものは何か、ログアウトをどう全 RP へ届けるか」に移ります。
自動で共有されるもの
全レプリカから同じ外部バックエンドへ振り分けたデータは、レプリカ間で共有されます。composite ストアでは、composite.TxClusterKinds の 7 要素(認可コード、リフレッシュトークン、grant、PAR、JWT アクセストークン登録、opaque アクセストークン、grant 失効 tombstone)を、1 つの比較可能な store.Transactional バックエンドに配置する必要があります。次のサブストアはこの cluster の外にあり、別々に振り分けられます。
- clients
- sessions
- users
- IATs / RATs
- device codes
- CIBA requests
- interactions
- consumed JTIs
- metadata
- Shared Signals の stream と event(同一バックエンドに置く)
ただし本番の複数レプリカでは、レプリカ間の可視性が必要なデータを共有ストレージへ配置してください。インメモリストア(inmem)はプロセス単位なので、レプリカが複数なら Sessions と Interactions は SQL、DynamoDB、Redis のいずれかの共有バックエンドに置きます。
Redis アダプタが実装するサブストアは Sessions、Interactions、ConsumedJTIs(JAR / DPoP / private_key_jwt の replay set)に加えて、Metadata と Shared Signals の 2 つのサブストア(Shared Signals を参照)です。Hot/Cold 分離 を参照してください。Sessions と Interactions は追い出されても構いません。ConsumedJTIs、Shared Signals のキュー、Metadata は別です。ConsumedJTIs は有効なマーカーが追い出されると塞いでいた replay の窓が再び開き、キューでは追い出された event が届かない SET になります。noeviction の Redis か永続バックエンドで動かしてください。DPoP server-nonce ストアはサブストアではなく、op.WithDPoPNonceSource 経由で差し込む別の seam です。同じ Redis store が DPoPNonceSource としてその実装を提供します(DPoP server-nonce ストア を参照)。
authn-factor 用のロックアウトカウンタも store.Store のサブストアには含まれませんが、WithStore の値が AuthnLockouts() メソッドを持っていれば、op.New がそこから取得します。in-memory、SQL、DynamoDB の adapter は持っており、composite.Store は AuthnLockouts の Kind に振り分けたバックエンドのものを転送します(振り分けがなければ WithDefault、次に Clients のバックエンドに従います)。この Kind を耐久性のあるバックエンドに振り分ければ、hot/cold 構成でも追加の設定は要りません。自動で選ばれたものは op.WithAuthnLockoutStore で上書きできます。WithStore の値が持たない場合(Redis adapter は実装していません)も、このオプションで渡します。
op.WithAuthnLockoutStore(storage.AuthnLockouts())カウンタを使うステップがログインフローにあるのに store が解決できないときは、op.New は op.ErrAuthnLockoutStoreRequired で起動を拒否します。brute-force 対策が黙って無効になることはありません。同梱の SQL / DynamoDB adapter は、永続かつレプリカ間で共有される実装を提供します。inmem.Store.AuthnLockouts() は process-local です。再起動でリセットされ、レプリカごとに別の試行回数枠を持ちます。別のバックエンドを使う場合に、cross-factor のブルートフォース対策を再起動後も維持し、全レプリカで一貫させるには、耐久性があり共有される store.AuthnLockoutStore を実装してください。
device-code の確認でも、レコード状態と試行状態を分けて考えます。レコードに紐付くフローでは devicecodekit.VerifyUserCode または VerifyUserCodeByUserCode が共有 device-code store を使います。まだ device_code が分からない最初の手入力画面では、opaque で安定した ceremony key(ブラウザセッションやアカウント単位の key など)を使って devicecodekit.VerifyUserCodeByAttemptKey を呼びます。devicecodekit.Deps に共有 devicecodekit.AttemptLimiter を渡してください。既定の InMemoryAttemptLimiter はプロセス単位なので、レプリカごとに別の予算になります。分散リミッタの Allow は compare-and-increment を atomic に実行し、key の reset は認証済み ceremony の所有者だけが行います。
明示的に対処が必要なもの
| 論点 | レプリカ 1 つ | レプリカ N 個 |
|---|---|---|
| DPoP server nonce | ライブラリ同梱のインメモリ参照実装で済む | 分散ソースが必要 |
| Session cookie | WithCookieKeys で暗号化。鍵が一致する限りレプリカをまたいで共有可能 | 同上 — 全レプリカが同じ cookie 鍵を持つ必要がある |
| Session レコード | どのストアでも可 | 共有ストア(SQL、DynamoDB、Redis)が必要。inmem はプロセス単位 |
Interaction 状態(/interaction/{uid}) | inmem を含むどのストアでも可 | 共有ストア(SQL、Redis)または sticky ルーティングが必要 |
| レートリミット | 上流 / プロセス外 | 上流 / プロセス外 |
| OFCS conformance harness | 1 OP インスタンスに対して実行する | 1 OP インスタンスに対して実行する — 単一レプリカか LB エンドポイントを指定 |
DPoP server-nonce ストア
op.NewInMemoryDPoPNonceSource は単一プロセス用です。round-robin な LB の裏では、/token で nonce を発行したレプリカと、その次の /userinfo を捌くレプリカが別になり得ます。
選択肢は 2 つです:
- server-nonce フローを無効化する。
WithDPoPNonceSourceを渡さない形です。クライアントは server 供給の nonce 無しで進みます。RFC 9449 §8 のハードニングが要らないなら、これで足ります。ただし FAPI 2.0 Message Signing と FAPI-CIBA のプロファイルは source を必須とし、DPoP を有効にしたまま渡さないとop.Newが起動を拒否します。 - Redis で 1 つの source を共有する。
op/storeadapter/redisのStore.DPoPNonceSource(rotate)が、現在の nonce と 1 つ前の nonce を store のキー prefix 配下の 1 つの Redis キーに置くop.DPoPNonceSourceを返します。同じ prefix を指すレプリカは、すべて同じ値を発行し、同じ値を受理します。Redis 以外の共有ストアを使う場合は、2 メソッド(IssueNonce、Validate)のop.DPoPNonceSourceを自前で実装します。
nonces, err := redisStore.DPoPNonceSource(time.Minute) // op/storeadapter/redis
if err != nil {
log.Fatal(err)
}
op.WithDPoPNonceSource(nonces)source を渡すと、/token、/par、/userinfo は nonce claim が無い、または受理できない DPoP proof を拒否し、新しい値を DPoP-Nonce ヘッダに載せた use_dpop_nonce を返します。インメモリ版でこのやり取りを確認できる examples/51-dpop-nonce を参照してください。Redis に接続できないときや、キーが追い出されたときの Redis source の挙動は DPoP nonce フロー にあります。
Session 配置
session は永続(SQL)にも揮発(永続化無しの Redis)にも置けます。トレードオフ:
| 配置 | 利点 | 欠点 |
|---|---|---|
| 永続(SQL) | 再起動 / failover 後も、ブラウザからのログアウト要求が subject と セッションのスナップショットを解決できる。RP の対象は session 一覧ではなく grant から導出する | login のたびに DB 書き込みが発生する |
| 揮発(Redis) | 書き込みレイテンシが低く、DB のホットな行が無い | 再起動や maxmemory の追い出しでスナップショットを取る前にブラウザからのログアウト要求を失うことがある。スナップショット後の RP 対象も SessionStore の行ではなく grant から導出する |
WithSessionDurabilityPosture(...) で配置を宣言します(既定は揮発)。この値は bcl.no_sessions_for_subject に記録され、このイベントは session を伴うログアウトで grant 由来の RP 対象が 0 件だったことを表します。ライブラリは配置自体を制約しません。永続配置は、再起動や failover をまたいで、ブラウザからのログアウト要求と fan-out 前のスナップショットを保護します。揮発な配置では、fan-out が始まる前に追い出しでログアウト要求を失うことがあります。スナップショット後の対象解決は SessionStore の行を辿らず grant から行うため、方針注釈は要求の喪失と対象 0 件を SOC ダッシュボードで解釈するための文脈になります。
Interaction 状態
進行中のログイン(/interaction/{uid})の状態は store.InteractionStore に置かれ、ブラウザは封緘された __Host-oidc_interaction cookie で uid を運びます。インメモリストアでは、この状態はプロセス単位になります。レプリカが N 個なら 2 通りです。
- LB で sticky ルーティング。 同じ
uid(パスに含まれます)のリクエストを常に同じレプリカへ転送します。簡単ですが、ログイン途中でレプリカが落ちるとユーザに汎用エラーが見えます。 - 共有 interaction store。 SQL アダプタを使うか、Redis アダプタを composite ストア の揮発スライスに置きます。任意のレプリカが任意のログインを再開できます。本番の既定推奨です。
正常停止と切り離されたログアウト配送
RP-Initiated Logout は、ブラウザへの応答後も Back-Channel Logout 配送を実行していることがあります。HTTP サーバを先に止め、その後 provider を drain します。
srv.Shutdown(ctx) // 受付を停止
provider.Shutdown(ctx) // detached fan-out を drainBackchannelLogout.FanOutBudget は detached fan-out 全体(既定 30 秒)を制限し、BackchannelLogout.Timeout は各 RP リクエストを別々に制限します(既定 5 秒)。どちらも WithBackchannelLogout に渡す値のフィールドです。Provider.Shutdown(ctx) は実行中の fan-out が完了するまで待ちますが、provider がリクエストを受け付けるのを止めるわけではありません。HTTP サーバを先に止めるのはそのためです。drain 中も全レプリカで共有セッション/grant ストアを使って対象解決を一貫させてください。
Cookie 鍵の一貫性
すべてのレプリカが同じ WithCookieKeys スライスを持つ必要があります。別の鍵を持つレプリカは、自分が封緘していない cookie を開けず、そのブラウザを未ログインとして扱います。スケールすると「ランダムにユーザがログアウトする」現象として現れます。
secret manager から鍵を取り、すべてのレプリカに同じ値を注入してください:
key, err := loadFromSecretManager("/op/cookie/current")
if err != nil { log.Fatal(err) }
op.WithCookieKeys(key)ローリングデプロイでは、N レプリカでの鍵ローテーションは 3 段階になります。ある鍵で封緘を始めるレプリカより先に、すべてのレプリカがその鍵で cookie を開けるようになっている必要があるためです。
- 全レプリカに
WithCookieKeys(old, new)をデプロイします。まだnewで封緘するレプリカはありませんが、全レプリカがnewの cookie を開けます。 WithCookieKeys(new, old)をデプロイします。以後はnewで封緘し、oldの cookie もまだ開けます。- 最長のセッションが終わった後に、
WithCookieKeys(new)をデプロイします。
サイクルと重なり期間の長さは 鍵ローテーション を参照してください。
静的クライアントの変更
op.New は構築時に、WithStaticClients のシードと保存済みのレコードを突き合わせ、食い違えば構成エラー(ErrConflict)で失敗します。そのため、op.SyncStaticClients がメタデータの変更や新しいシークレットを書き込んだ後は、古いシードで再起動した旧バージョンのレプリカが起動に失敗し、同期の前に起動した新バージョンのレプリカも同様に失敗します。すでに動いているレプリカは、そのまま処理を続けます。
ロールアウトの直前に新しいシードで op.SyncStaticClients を実行し、旧バージョンのレプリカが置き換わるまで再起動やスケールアウトをさせないでください。ロールバックする場合は、先に古いシードで同期を実行します。シードを変えないデプロイでは同期は不要です。静的クライアントを変更するを参照してください。
LB アフィニティ
| エンドポイント | アフィニティ必要? |
|---|---|
/.well-known/openid-configuration、/jwks | 不要 — 純粋な read |
/auth、/par、/end_session | interaction と session の状態が共有ストアにあれば不要、プロセスローカルなら必要 |
/token、/userinfo、/introspect、/revoke | 不要 |
/device_authorization、/bc-authorize | 不要 |
/register、/register/{client_id} | 不要 |
/interaction/{uid} | /auth のリダイレクトを受けたレプリカに sticky。ただし interaction store が共有なら不要 |
パスは既定値で、mount prefix(既定は /oidc)からの相対です。最も簡素な本番形は、全エンドポイントを round-robin にしつつ、interaction store を共有にする形です。次に簡素なのは、uid で sticky にしつつ、interaction の状態をプロセスローカルに置く形です。
ヘルスチェック
OP 自体はヘルスエンドポイントをマウントしません。よくある pattern は次のとおりです:
- Liveness:
/.well-known/openid-configurationの 2xx。discovery 文書はストアアクセスなしで描画できます。 - Readiness: ストアの ping を含めます。ストア全体に対する health メソッドは公開していないので、自前の型に実装してください(以下の
MyCompositeはライブラリの型ではなく、その自前の型です):
func ready(store *MyComposite) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
if err := store.Ping(ctx); err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
}別のパス(/healthz/ready)でマウントし、公開ルータからは外してください。
キャパシティプランニング
何を測るべきか決める手がかりとして、エンドポイントごとの 1 リクエストあたりの主なコストを示します。
| エンドポイント | 主なコスト |
|---|---|
/jwks、discovery | 静的 JSON でストアにアクセスしない。CDN とも相性が良い |
/auth(interaction なし) | code と session のストア書き込み |
/token(authorization_code) | ID トークンの署名に加え、ストア書き込みとユーザの lookup |
/token(refresh_token) | 署名に加え、1 トランザクションでのローテーション書き込み |
/userinfo | アクセストークンの検証とユーザの lookup |
ボトルネックは多くの場合 OP ではなく永続ストア側にあります。KMS をバックエンドにした署名器は、署名のたびに独自の待ち時間を足します(JWKS エンドポイント を参照)。サイジングの前に、自前のストアに対して負荷試験をしてください。本ライブラリはスループットの数値を公開していません。
マルチインスタンスでもできないこと
- 同じトランザクショナルストアに対して、設定の異なる OP を 2 つ稼働させる。 discovery 文書、scope カタログ、alg リスト、grant set はレプリカ間で一致が必須です。差があると、RP から見て drift になります(あるレプリカが発行したトークンを別のレプリカが拒否する、など)。
composite.TxClusterKindsを 2 つのバックエンドに分割する。 composite アダプタは、cluster の分割、比較不能な cluster バックエンド、store.Transactionalを実装しないアンカーを拒否します。cluster 外の永続Kindは、それぞれの整合性・可用性要件を満たす限り別のバックエンドへ配置できます。Hot/Cold 分離 を参照してください。