こんにちは、サイオステクノロジー武井です。今回はShibboleth IdPに適した負荷分散方式について考えてみました。
Shibboleth IdPとは?
Shibbolethとは、大学などでよく使われる学術系オープンソースシングルサインオンシステムです。要はSMALをベースとしたシングルサインオンシステムです。KeyCloakなどとは違うのは、学術系SPに特化したということですね。認証のデータソースにはOpenLDAPやRDBなどを使うことができたり、その他様々な条件でSAML Responseを生成できます。詳細は以下にて!!
Shibboleth IdPを正常に動作させるための冗長化方式
Shibboleth IdP自身と、Shibboleth IdPが参照するLDAPの両方について考えてみます。いきなり結論ですが、以下の2案がベストです。
案1: LDAPをHAクラスタにする
LDAPに対する処理性能がそれほど必要ではない場合はこちらの方法になります。
ユーザー→Shibboleth IdPの負荷分散には、L7のロードバランサーを用いて、Cookieベースのアフィニティでバックエンドにルーティングします。つまり、ロードバランサーから専用のCookieを発行し、そのCookieが有効である限りは、常に同じShibboleth IdPに接続されます。
Shibboleth IdP→LDAPは、keepalivedなどのHAクラスタソフトウェアを用いて、正常時はMasterに接続し、異常時はSlaveに接続するようにします。
案2: HealthCheck用エンドポイントでLDAPを監視する
LDAPに処理性能が求められる場合は負荷分散をしなければいけないのでこちらの方法になります。
ユーザー→Shibboleth IdPについては、案1と同様にCookieベースのアフィニティとします。
Shibboleth IdP→LDAPについては、下図のようにShibboleth IdPは常に同じLDAPに接続するようにします。そして、Shibboleth IdP上にHealthCheck用エンドポイントのAPIを設けて、そのAPIではShibboleth IdPの正常性はもちろんのこと、Shibboleth IdPが接続しているLDAPの正常性も合わせて確認し、その両方が正常であるときに、ロードバランサーに正常性OK(一般的にはHTTPステータスコード200)を返します。
つまりShibboleth IdPもしくはLDAPどちらかに異常が発生したら、Shibboleth IdPとそれに接続しているLDAPをセットでロードバランサーから切り離します。
検証結果
先に説明した2案に総じて言えるのは、「Shibboleth IdPはL7でCookieベースのアフィニティで負荷分散を行い、LDAPは負荷分散しない(特定のLDAPに接続する)」としています。
なぜこのようにしなければいけないかを説明するために、L7のロードバランサーとの対比でよく用いられるL4のロードバランサーを用いた場合を例にあげて説明します。
Shibboleth IdPの負荷分散に対する要件
まず初めにShibboleth IdPの負荷分散に対する要件を整理してみます。以下のドキュメントを参考にしました。
https://shibboleth.atlassian.net/wiki/spaces/IDP5/pages/3199501063/Clustering
Shibboleth IdPでは以下の状態を管理します。
画面遷移で使われるSpring Web Flowのフレームワークの状態
Shibbolethは画面遷移にSpring Web Flowというフレームワークを使っていて、画面遷移の状態をサーバーサイドに保存します。これはデフォルトの設定ではJavaが管理するセッションに保存します。つまりメモリに保存するということになります。そして、この保存先は変更が出来ず、例えばDBに出すということが出来ないようです。つまりこの情報だけは完全にステートフルになります。
認証情報
Shibboleth IdP自身が認証済みの状態かどうかを管理している情報ですが、デフォルトではShibboleth IdP側で暗号化された認証情報をCookie格納しています。認証に必要な情報はサーバーサイドになく、Cookieに格納された情報だけで完結するようなので、ステートレスということになります。よって、例えば、Shibboleth IdPで認証後、Shibboleth IdPがスケールアウトで台数が増えて、その増えたShibboleth IdPに認証に行っても再度認証画面は表示されません。
メッセージリプライキャッシュ
メッセージリプライ攻撃を防ぐためのキャッシュです。デフォルトではメモリに保存されますが、DBなどの外部ストレージに逃がす事ができます。
ユーザーの同意情報
属性送出の際のユーザー同意の結果ですが、デフォルトではCookieとHTMLローカルストレージに保存されるようです。これもDBなどの外部ストレージに逃がす事ができます。
SAMLアーティファクト
IdPとSP間の通信に使われるSAMLアーティファクトの情報で、デフォルトではメモリ保存です。DBなどの外部ストレージに保存することができます。
CASの情報
CASで交換される認証チケットの情報で、デフォルトではCookieに保存されるようです。ただ、これも認証情報と同様にユーザー情報も含まれる自己復元可能な情報を暗号化してCookieに格納しているので、クラスター環境でも特に意識する必要はないです。
つまり、上記の情報を総合すると、「画面遷移で使われるSpring Web Flowのフレームワークの状態」のみがステートレスにすることができないということになります。つまり、Shibboleth IdPはステートフルなWebアプリケーションということになります。
L4のロードバランサーでShibboleth IdPを負荷分散する
案1や案2では、L7のロードバランサーを用いてましたが、L4のロードバランサーではだめでしょうか?AzureでいえばAzure Load Balancerですし、NGINXにも同じような機能があります。
L4のロードバランサーでは負荷分散のアルゴリズムとして、IPアドレスや送信元ポート番号もしくは両方のハッシュ値などをもとにルーティングするバックエンドを決めます。Azure Load Balancerでは以下のアルゴリズムを採用することができます(NGINXではもっと細やかにできます)。
- 発信元IP ・ソースポート・宛先IP・宛先ポート・プロトコルの種類
- 発信元IP
- 発信元IP・プロトコルの種類
「Shibboleth IdPの負荷分散に対する要件」でも説明したように、Shibboleth IdPはステートフルなWebアプリケーションなので、ブラウザからは常に同じShibboleth IdPに接続する必要があります。一連のフローの中で接続するShibboleth IdPが変わるとエラーになります。
よって、L4のロードバランサーでは、「発信元のIP」でハッシュするとよさそうにみえますね。IPアドレスが変わらなければ常に同じShibboleth IdPに接続されます。ただ、これはNGです。4Gや5Gなどのモバイルネットワークでは接続する基地局が変わるたびに、発信元IPが変わります。つまり、高速で移動する乗り物(電車やバス等)で移動中にスマホで認証するとエラーになる可能性があります。
例えば、パスワード認証のあとにTOTP認証を要求するようなフローを考えてみます。パスワード認証のときは発信元IPが203.0.113.1であり、TOTP認証するときには203.0.113.2に変わったとすると、パスワード認証で接続したShibboleth IdPと異なるShibboleth IdPに接続される可能性があり、Spring Web Flowの画面遷移の状態を維持できなくなりエラーとなります。
上記のような複雑なフローではなくても、単純なパスワード認証のみでも同じようなことが起こります。例えば、パスワード認証で1回目の認証でパスワード間違いで認証エラーになり、もう一度正しいパスワードを入力しようとしたケースを考えてみます。この際、1回目と2回目の認証の間に発信元IPが変わると、同じくSpring Web Flowの画面遷移の状態を維持できないと判断され、同様のエラーが起こります。
同様の理由で、「発信元IP・プロトコルの種類」の場合もNGです。
「発信元IP ・ソースポート・宛先IP・宛先ポート・プロトコルの種類」、つまりソースポートをハッシュに含める場合はどうでしょうか?これも別の理由でNGになります。同じブラウザ・同じタブでも、HTTPリクエストのたびにソースポートが変わるブラウザがあります。私が検証した限りではFireFoxがそうでした。Edgeは常に同じソースポートでした。
以下はFireFoxにて、同じタブでShibboleth IdPのパスワード認証シーケンスをキャプチャしたパケットダンプになります。SSLなので非常にわかりにくいのですが、ソースポートがころころ変わっているのがわかると思います。ソースポートをハッシュに入れるとラウンドロビンのような挙動になってしまい、Spring Web Flowの画面遷移の状態を維持できず、これもエラーとなります。
ということで、やはりL7のロードバランサーでCookieベースのアフィニティを用いるのが一番よさそうです。
L4のロードバランサーでLDAPを負荷分散する
LDAPをL4のロードバランサーで分散するケースも考えてみます。以下のような構成になります。
Shibboleth IdPのときと同様に、Azure Load Balancerでの負荷分散アルゴリズムを例に考えてみます(NGINXなどのロードバランサーでも基本は同じと思います)。
発信元IPを負荷分散アルゴリズムとした場合は、どうでしょうか?この場合は特定のLDAPに負荷が偏る場合があります。Azure Load Balancerの場合は、発信元IPのハッシュによって、ルーティングされるバックエンドを決定するのですが、そのアルゴリズムは公開されていません。よってIPアドレスによっては、複数のShibboleth IdPが特定のLDAPに接続して、そのLDAPが超過負荷状態になることが懸念されます。LDAPに接続するクライアントが多数ある場合は、適度に分散される確率も高くなります。ただし、クライアントが少ないとその限りではありません。例えばLDAPが2台ある場合は、アルゴリズムの計算結果によっては1台に集中する可能性が高くなります。まぁ、この場合は、2台分の負荷を1台で受け止めるだけなのでなんとなりそうな気もしますが、仮に4台のLDAPがあった場合、アルゴリズムの計算結果によっては、1台にルーティングされることも理論上はあります。1台のLDAPで4台分の負荷を受け止めなければならないことになります。このあたりはマイクロソフトのドキュメントにも記載されています。
https://learn.microsoft.com/ja-jp/azure/load-balancer/distribution-mode-concepts#use-cases
「発信元IP ・ソースポート・宛先IP・宛先ポート・プロトコルの種類」、つまりソースポートをハッシュに含める場合はどうでしょうか?これもNGとなります。ソースポートをハッシュにするので均等に分散されるような気もします。ただし、Shibboleth IdPは一連の認証処理において複数回LDAPに接続する場合がありますが、同じソースポートで接続するとは限りません。
例えば、認証処理の中で多くの場合では、認証のためにLDAPに接続し、次にSPに送信する属性を取得するためにもう一度LDAPに接続します。この場合、1回目と2回目のLDAP接続のソースポートは異なるものになります。
以下は、パスワード認証→TOTP認証のフローで認証処理を行ったときのLDAP接続のパケットキャプチャの結果になります。
1は、Shibboleth IdPのログイン画面に入力されたユーザーIDに基づいて、ユーザーのDNを取得する処理です。
2は、1で取得したDNでLDAPにバインドしています。
3は、TOTPのトークンを取得する処理です。
4は、SPに送信する属性を取得する処理です。
1、2、3はすべてソースポート番号が異なるのがわかると思います。つまり一連のフローの中で、異なるLDAPに接続する場合があることを示しています。
では、これがなぜダメなのかというと、LDAPのレプリケーション方式にあります。Shibboleth IdPでよく利用されるOpenLDAPや389DS、その他多くのLDAPは非同期レプリケーションを取ります。ある瞬間ではすべてのLDAPで同じデータを持つことは保証しないけど、時間が経てばいつかは収束して、すべてのLDAPが同じデータになります。いわゆる結果整合性というものです。
なので、MasterのLDAPに更新した情報がSlaveに反映する前に、一連の認証フローの中でMaster→Slaveとアクセスしてしまうとデータの不整合が発生する可能性があります。
例えば、フローA→フローBと遷移する認証フローがあったとします。MasterのLDAPに対してmail属性をfuga@example.comからhoge@example.comに更新しましたが、Slaveにはまだ未反映とします。この状態でフローAが実行された場合にはMasterのLDAPに接続したとします。次にフローBが実行されたときに、LDAPに接続する際のShibboleth IdPのソースポートがフローAのときと変わり、SlaveのLDAPに接続したとします。このとき、フローAではmail属性がhoge@example.comであったが、フローBではfuga@example.comであり、不整合が発生しております。一連の認証フローはいわゆる一つのトランザクションです。トランザクション実行中にデータが変更になることは、データベーストランザクションのACID原則であるI(Isolation/分離性)に反することであり、あってはならないことです。
つまり、一連の認証フローでは必ず同じLDAPにアクセスする必要があるため、ソースポートをハッシュに含めた負荷分散方式はダメということになります。
まとめ
今までのお話をまとめますと、Shibboleth IdPの冗長化の際に必須の条件は
一つの認証フローが開始してから終了するまでは、必ず同じShibboleth IdP、同じLDAPに接続する必要がある
ということになります。
その視点で、最初に提起した案1と案2を再び見てみます。
案1: LDAPをHAクラスタにする
L7のロードバランサーでCookieベースのアフィニティで負荷分散しています。これは最初に説明したように、ロードバランサーから専用のCookieを発行し、そのCookieが有効である限りは、常に同じShibboleth IdPに接続されます。つまりクライアントのIPアドレスがどんなに変わろうが、常に同じShibboleth IdPに接続されます。IPアドレスがコロコロかわるスマホでもOKです。
またLDAPについては、HAクラスタを組んでいるので、通常運用時は常に同じLDAP(Master)に接続されます。一連の認証フローの途中でLDAPの接続先が変更になることはありません。
障害発生時はSlaveに接続することとなり、最新の情報が反映されないこととなりますが、ACID原則は守られるので、業務上大きな問題はありません。
ちなみにHAクラスタは、OSSのkeepalivedや弊社製のLifeKeeperを使うと実現できます。
案2: HealthCheck用エンドポイントでLDAPを監視する
案2は基本的に案1と思想は変わらないのですが、ちょっと変わっているのがLDAPへのアクセス部分です。
大抵のL7ロードバランサーは、HTTPのプロトコルで正常性を監視することができます。そこで、Shibboleth IdP内部にHealthCheck用のAPIを設けて、ロードバランサーはそのAPIのエンドポイントを監視します。そのAPIでは、Shibboleth IdP自体の正常性はもちろんのこと、LDAPも監視し、その2つの正常性がOKの場合に、最終的にロードバランサーに正常性OK(一般的にはHTTPステータスコード200)を返します。
そして、Shibboleth IdPの接続するLDAPは必ず固定とします。上図でいうと、左のShibboleth IdPはLDAP(Master)、右のShibboleth IdPはLDAP(Slave)に接続を固定化します。
こうすることにより、Shibboleth IdPは一連の認証フローの中で必ず同じLDAPに接続することとなり、Shibboleth IdPの冗長化の際に必須の条件である「一つの認証フローが開始してから終了するまでは、必ず同じShibboleth IdP、同じLDAPに接続する必要がある」を満たすことができます。
障害発生時は、以下のようになります。Shibbleth IdPもしくはLDAPのどちらかに異常が発生すれば、異常が発生したShibboleth IdPとLDAPのセットがロードバランサーから切り離されて、正常なShibboleth IdPとLDAPのセットの方に接続されます。
案1と比較すると、構築が少々面倒であるHAクラスタを組む必要がないということです。Shibboleth IdPとLDAPの正常性をチェックするAPIを作成するほうが遥かに楽かと思います。
また、案1の場合は2台のShibboleth IdPが1台のLDAPに接続するので、LDAPに求められる性能要件が厳しくなります。案2では適度に分散されているので、そのようなことはありません。
ということで、まとめますと、案2の方がよさそうな感じがしますね。もうちょっと色々考えるともっといい案が浮かぶかと思いますが、今回のところはここまでにしておきたいと思いますm(_ _)m