Keycloak.Xをクラスタ構成で動かしてみる

初版: 2021/7/29
著者: 田畑義之, 株式会社日立製作所 (GitHubアカウント: [@y-tabata](https://github.com/y-tabata))

# はじめに
IAM(Identity and Access Management)製品で、OAuth 2.0/OpenID Connect 1.0の認可サーバとしても利用可能なKeycloakというOSSがあります。現行のKeycloakディストリビューションは、WildFlyベースのディストリビューションですが、それに加えバージョン12.0.0で、Quarkus[^1]ベースのKeycloak.Xディストリビューションがプレビュー公開されました[^2]。**次世代Keycloak**としても注目されているKeycloak.Xですが、現在ドキュメントは準備中[^3]で、公開されている情報はほとんどありません。

今回は、そんなKeycloak.Xがどの程度使えるものなのかを調査するために、まずは本番適用時に必須となるクラスタ構成を組んで動かしてみたいと思います。なお、Keycloakに関する詳細は、Think ITの連載「[Keycloakで実現するAPIセキュリティ](https://thinkit.co.jp/series/9721)」をご参照ください。また、OpenShift.Run Winter 2020の発表資料「[Keycloak on Quarkus](https://speakerdeck.com/ido_kara_deru/keycloak-on-quarkus)」では、Keycloak.Xの背景やビルド方法、コンテナ化、性能評価についてご紹介しておりますので、こちらもご参照ください。

本記事は、あくまで執筆者の見解であり、日立製作所の公式なドキュメントではありません。

# Keycloak.Xとは
Keycloak.Xとは、次世代Keycloakとして注目されている、Quarkusベースのディストリビューションです。現行のWildFlyベースのディストリビューションと比較し、Keycloak.Xには以下のような特徴があると謳われています。

- 短い起動時間
- 小さいメモリフットプリント
- コンテナファースト
- より良い開発環境の提供
- より良いユーザビリティ

Quarkusをベースとしていることからも、特に3つ目のポイントである「コンテナファースト」に重きを置いているように思います。各特徴の詳細は、Keycloak公式の「[Introducing Keycloak.X Distribution](https://www.keycloak.org/2020/12/first-keycloak-x-release.adoc)」をご参照ください。

# Keycloak.Xのクラスタ構成の概要
今回構築するKeycloak.Xのクラスタ構成について説明します。

ここでは、OAuth 2.0 (RFC 6749)のプロトコルフローを実現する一般的なAPI管理システムを考えます。

| OAuth 2.0の役割 | システム構成図(下図)のコンポーネント                         |
| --------------- | ------------------------------------------------------------ |
| リソースサーバ  | APIゲートウェイ + APIサーバ<br />※APIゲートウェイでアクセス制御を行う。 |
| クライアント    | Webアプリ                                                    |
| 認可サーバ      | Keycloak.X #1、Keycloak.X #2                                 |

![システム構成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267112/4108c81a-5602-99e8-1460-97be175c04c5.png)

Webアプリは外部LBを経由してKeycloak.Xにトークン要求を行い、APIゲートウェイは内部LBを経由してKeycloak.Xにトークン検証(トークンイントロスペクション)を行うものとします。また、SSL終端はAPIゲートウェイと外部LBで行うものとします。Keycloak.Xが使うDBはPostgreSQLとします。

今回は上図のKeycloak.X #1とKeycloak.X #2を構築します。

# Keycloak.Xのクラスタ構成を組んでみる
それでは実際にKeycloak.Xのクラスタ構成を組んでみましょう。

## 事前準備
今回はAWS上にKeycloak.Xのクラスタ構成を組んでいきます。事前に以下のAWSリソースを準備します。

| 必要なAWSリソース            | 用途                                                         |
| ---------------------------- | ------------------------------------------------------------ |
| Amazon EC2 ×2                | Keycloak.Xを動作させるEC2です。今回はコンテナではなくEC2上で動かしてみます。 |
| Amazon RDS for PostgreSQL    | Keycloak.Xが使うDBです。                                     |
| Application Load Balancer ×2 | 動作確認用の外部LBと内部LBです。外部LBにはSSL終端を設定します。 |

## Keycloak.Xをインストールする
まずはKeycloak.Xをインストールします。インストールは配布ファイルを解凍するだけですので簡単です。今回はバージョン14.0.0を使用します。

```
$ wget https://github.com/keycloak/keycloak/releases/download/14.0.0/keycloak.x-preview-14.0.0.zip
$ unzip keycloak.x-preview-14.0.0.zip
$ sudo mv keycloak.x-14.0.0 /opt/
```

## Keycloak.Xが使うDBを外部DBに設定する
次に、Keycloak.Xが使うDBを外部のPostgreSQLに設定します。現行のKeycloakでは、standalone-ha.xmlを設定ファイルとして使用しましたが、Keycloak.Xでは、/opt/keycloak.x-14.0.0/confにあるkeycloak.propertiesファイルを設定ファイルとして使用します。

```properties
db=postgres-10 # DBのベンダを指定します。ここでは、PostgreSQL 10を指定しています。
db-url=jdbc\:postgresql\://***.***.ap-northeast-1.rds.amazonaws.com/keycloak # DBのJDBC URLを指定します。
db-username=postgres # DBのユーザ名を指定します。
db-password=postgres # DBのパスワードを指定します。
```

Keycloak.Xでは、JDBCドライバは自動で選択されるので、手動で配置する必要はありません。

## Keycloak.Xの手前にLBを配置する
次に、Keycloak.Xの手前に外部LBと内部LBを配置します。Keycloak.Xの手前にLB等を配置する場合は、プロキシモードを有効にします。また、外部LBを経由したアクセスも内部LBを経由したアクセスもHTTPでのアクセスとなるため、HTTPリスナを有効にします。

```properties
proxy=edge # プロキシモードを有効にします。Keycloak.Xの手前でSSL終端されている場合は、edgeを指定します。
http-enabled=true # HTTPリスナを有効にします。8080ポートでアクセスを受け付けることができるようになります。
http-host=***.ap-northeast-1.compute.internal # Keycloak.Xが稼働するEC2のホスト名を指定します。
```

外部LBと内部LBによるヘルスチェック用に、Keycloak.Xのヘルスチェック用エンドポイントを公開することを考えます。Keycloak.Xでは、ヘルスチェック用エンドポイントやメトリクス取得用エンドポイントを公開する場合、メトリクスを有効にします。LBによるヘルスチェック用に設定するパスとしては、Readinessチェック用のパスである`/health/ready`が適当です。

```properties
metrics.enabled=true # メトリクスを有効にします。
```

Keycloak.Xは現行のKeycloakと同様、デフォルトでは自身が受け取ったリクエストを利用して、自身の識別子を構成します。そのため、Keycloak.XのURLが複数(外部LB経由と内部LB経由)ある場合、Keycloak.Xはそれぞれに対して別々の認可サーバとして振舞います。この挙動では、今回のように複数の経路で同一の認可サーバとして振舞ってほしい場合に問題が生じます。この問題を回避するための設定を加えます。具体的には、Keycloak.Xが常に外部LB経由のURLを利用して自身の識別子を構成するように設定します。

```properties
hostname-frontend-url=https://***.***.elb.amazonaws.com # 外部LBのURLを指定します。
```

## Keycloak.Xのインスタンス間通信をTCPに設定する
次に、Keycloak.Xのインスタンス間の通信をTCPに設定します。Keycloak.Xは現行のKeycloakと同様、JGroupsを用いてインスタンス間の通信を行います。今回は、現行のKeycloakでもよく使われているJDBC_PINGを用いて他のインスタンスを検出することを考えます。

デフォルトでは、JDBC_PINGを実現できるスタック構成ファイルがないので、自作します。ファイル名はdefault-jgroups-jdbc.xmlとします。

```xml
<config xmlns="urn:org:jgroups"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="urn:org:jgroups http://www.jgroups.org/schema/jgroups-4.2.xsd">
   <TCP bind_addr="${jgroups.bind.address,jgroups.tcp.address:SITE_LOCAL}"
        bind_port="${jgroups.bind.port,jgroups.tcp.port:7800}"
        enable_diagnostics="false"
        thread_naming_pattern="pl"
        send_buf_size="640k"
        sock_conn_timeout="300"
        bundler_type="no-bundler"

        thread_pool.min_threads="${jgroups.thread_pool.min_threads:0}"
        thread_pool.max_threads="${jgroups.thread_pool.max_threads:200}"
        thread_pool.keep_alive_time="60000"

        thread_dumps_threshold="${jgroups.thread_dumps_threshold:10000}"
   />
   <JDBC_PING connection_url="${jgroups.jdbc.connection_url}"
              connection_username="${jgroups.jdbc.connection_username}"
              connection_password="${jgroups.jdbc.connection_password}"
              connection_driver="${jgroups.jdbc.connection_driver}"
              initialize_sql="CREATE TABLE IF NOT EXISTS JGROUPSPING (
                                  own_addr VARCHAR(200) NOT NULL,
                                  cluster_name VARCHAR(200) NOT NULL,
                                  ping_data bytea DEFAULT NULL,
                                  added timestamp DEFAULT NOW(),
                                  PRIMARY KEY (own_addr, cluster_name)
                                  )"
              remove_all_data_on_view_change="true"
   />
   <MERGE3 min_interval="10000"
           max_interval="30000"
   />
   <FD_SOCK />
   <!-- Suspect node `timeout` to `timeout + timeout_check_interval` millis after the last heartbeat -->
   <FD_ALL timeout="10000"
           interval="2000"
           timeout_check_interval="1000"
   />
   <VERIFY_SUSPECT timeout="1000"/>
   <pbcast.NAKACK2 use_mcast_xmit="false"
                   xmit_interval="100"
                   xmit_table_num_rows="50"
                   xmit_table_msgs_per_row="1024"
                   xmit_table_max_compaction_time="30000"
                   resend_last_seqno="true"
   />
   <UNICAST3 xmit_interval="100"
             xmit_table_num_rows="50"
             xmit_table_msgs_per_row="1024"
             xmit_table_max_compaction_time="30000"
   />
   <pbcast.STABLE stability_delay="500"
                  desired_avg_gossip="5000"
                  max_bytes="1M"
   />
   <pbcast.GMS print_local_addr="false"
               join_timeout="${jgroups.join_timeout:2000}"
   />
   <UFC max_credits="4m"
        min_threshold="0.40"
   />
   <MFC max_credits="4m"
        min_threshold="0.40"
   />
   <FRAG3/>
</config>
```

default-jgroups-jdbc.xmlをスタック構成ファイルとして読み込む、クラスタ構成ファイルを自作します。ファイル名はcluster-custom.xmlとします。

```xml
<?xml version="1.0" encoding="UTF-8"?>

<infinispan
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="urn:infinispan:config:11.0 http://www.infinispan.org/schemas/infinispan-config-11.0.xsd"
        xmlns="urn:infinispan:config:11.0">

    <jgroups>
	    <stack-file name="default-jdbc" path="/opt/keycloak.x-14.0.0/conf/default-jgroups-jdbc.xml"/>
    </jgroups>

    <cache-container name="keycloak">
        <transport stack="default-jdbc" lock-timeout="60000"/>
        <local-cache name="realms">
            <encoding>
                <key media-type="application/x-java-object"/>
                <value media-type="application/x-java-object"/>
            </encoding>
            <memory storage="HEAP" max-count="10000"/>
        </local-cache>
        <local-cache name="users">
            <encoding>
                <key media-type="application/x-java-object"/>
                <value media-type="application/x-java-object"/>
            </encoding>
            <memory storage="HEAP" max-count="10000"/>
        </local-cache>
        <distributed-cache name="sessions" owners="1"/>
        <distributed-cache name="authenticationSessions" owners="1"/>
        <distributed-cache name="offlineSessions" owners="1"/>
        <distributed-cache name="clientSessions" owners="1"/>
        <distributed-cache name="offlineClientSessions" owners="1"/>
        <distributed-cache name="loginFailures" owners="1"/>
        <local-cache name="authorization">
            <encoding>
                <key media-type="application/x-java-object"/>
                <value media-type="application/x-java-object"/>
            </encoding>
            <memory storage="HEAP" max-count="10000"/>
        </local-cache>
        <replicated-cache name="work"/>
        <local-cache name="keys">
            <encoding>
                <key media-type="application/x-java-object"/>
                <value media-type="application/x-java-object"/>
            </encoding>
            <expiration max-idle="3600000"/>
            <memory storage="HEAP" max-count="1000"/>
        </local-cache>
        <distributed-cache name="actionTokens" owners="2">
            <encoding>
                <key media-type="application/x-java-object"/>
                <value media-type="application/x-java-object"/>
            </encoding>
            <expiration max-idle="-1" interval="300000"/>
            <memory storage="HEAP" max-count="-1"/>
        </distributed-cache>
    </cache-container>
</infinispan>
```

cluster-custom.xmlをクラスタ構成ファイルとして読み込む設定をkeycloak.propertiesファイルに加えます。

```properties
cluster=custom # クラスタ構成ファイルとして、cluster-custom.xmlを指定します。
```

## Keycloak.Xのインスタンス間でキャッシュを同期する
最後に、Keycloak.Xのインスタンス間でキャッシュを同期するように設定します。先ほど自作したcluster-custom.xmlの中の、各キャッシュのownersの値を2にします。

```xml
<?xml version="1.0" encoding="UTF-8"?>

<infinispan
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="urn:infinispan:config:11.0 http://www.infinispan.org/schemas/infinispan-config-11.0.xsd"
        xmlns="urn:infinispan:config:11.0">
    ...
    <cache-container name="keycloak">
        ...
        <distributed-cache name="sessions" owners="2"/>
        <distributed-cache name="authenticationSessions" owners="2"/>
        <distributed-cache name="offlineSessions" owners="2"/>
        <distributed-cache name="clientSessions" owners="2"/>
        <distributed-cache name="offlineClientSessions" owners="2"/>
        <distributed-cache name="loginFailures" owners="2"/>
        ...
    </cache-container>
</infinispan>
```

# Keycloak.Xのクラスタ構成を動かしてみる
それでは、構築したKeycloak.Xのクラスタ構成を実際に動かしてみましょう。

## Keycloak.Xを起動してみる
Keycloak.Xを起動してみましょう。

まずは起動する前に、keycloak.propertiesファイルの設定をKeycloak.Xに反映します。

```
$ cd /opt/keycloak.x-14.0.0/bin
$ ./kc.sh config
```

また、初回起動の場合はKeycloak.Xの管理者ユーザを作成するために、事前に以下の環境変数を設定する必要があります。

```
$ export KEYCLOAK_ADMIN=admin
$ export KEYCLOAK_ADMIN_PASSWORD=admin
```

Keycloak.Xを起動します。ここでは、JDBC_PINGで用いる以下のオプションを指定します。

- jgroups.jdbc.connection_url: DBのJDBC URLを指定します。
- jgroups.jdbc.connection_username: DBのユーザ名を指定します。
- jgroups.jdbc.connection_password: DBのパスワードを指定します。
- jgroups.jdbc.connection_driver: DBのJDBCドライバの識別子を指定します。

```
$ ./kc.sh -Djgroups.jdbc.connection_url=jdbc:postgresql://***.***.ap-northeast-1.rds.amazonaws.com/keycloak -Djgroups.jdbc.connection_username=postgres -Djgroups.jdbc.connection_password=postgres -Djgroups.jdbc.connection_driver=org.postgresql.Driver
```

起動に成功すると、以下のようなログが出力されます。

```
2021-07-16 06:51:51,431 INFO  [io.quarkus] (main) Keycloak 14.0.0 on JVM (powered by Quarkus 1.13.3.Final) started in 15.226s. Listening on: http://***.ap-northeast-1.compute.internal:8080
2021-07-16 06:51:51,432 INFO  [io.quarkus] (main) Profile prod activated.
2021-07-16 06:51:51,433 INFO  [io.quarkus] (main) Installed features: [agroal, cdi, hibernate-orm, jdbc-h2, jdbc-mariadb, jdbc-mysql, jdbc-postgresql, keycloak, mutiny, narayana-jta, resteasy, resteasy-jackson, smallrye-context-propagation, smallrye-health, smallrye-metrics, vertx, vertx-web]
```

また、クラスタ構成の構築に成功すると、以下のようなログが出力されます。

```
2021-07-16 07:09:27,608 INFO  [org.inf.CLUSTER] (jgroups-4,{Keycloak.X #1}) ISPN000094: Received new cluster view for channel ISPN: [{Keycloak.X #1}|1] (2) [{Keycloak.X #1}, {Keycloak.X #2}]
2021-07-16 07:09:27,618 INFO  [org.inf.CLUSTER] (jgroups-4,{Keycloak.X #1}) ISPN100000: Node {Keycloak.X #2} joined the cluster
2021-07-16 07:09:28,051 INFO  [org.inf.CLUSTER] (jgroups-4,{Keycloak.X #1}) [Context=authenticationSessions] ISPN100002: Starting rebalance with members [{Keycloak.X #1}, {Keycloak.X #2}], phase READ_OLD_WRITE_ALL, topology id 2
...
2021-07-16 07:09:28,531 INFO  [org.inf.CLUSTER] (jgroups-4,{Keycloak.X #1}) [Context=actionTokens] ISPN100010: Finished rebalance with members [{Keycloak.X #1}, {Keycloak.X #2}], topology id 5
```

## インスタンス間キャッシュ同期の挙動を確認してみる
次に、Keycloak.Xのインスタンス間キャッシュ同期の挙動を確認します。具体的には、Keycloak.X #1で発行したアクセストークンが、Keycloak.X #2で有効と判断されるかどうかを確認します。

まずは、Keycloak.X #2を停止します。Keycloak.X #2を停止すると、Keycloak.X #2がクラスタ構成から離脱した旨、Keycloak.X #1のログに出力されます。

```
2021-07-16 07:22:30,063 INFO  [org.inf.CLUSTER] (jgroups-12,{Keycloak.X #1}) [Context=authenticationSessions] ISPN100008: Updating cache members list [{Keycloak.X #1}], topology id 6
...
2021-07-16 07:22:30,125 INFO  [org.inf.CLUSTER] (jgroups-12,{Keycloak.X #1}) [Context=actionTokens] ISPN100008: Updating cache members list [{Keycloak.X #1}], topology id 6
2021-07-16 07:22:30,214 INFO  [org.inf.CLUSTER] (jgroups-12,{Keycloak.X #1}) ISPN000094: Received new cluster view for channel ISPN: [{Keycloak.X #1}|2] (1) [{Keycloak.X #1}]
2021-07-16 07:22:30,219 INFO  [org.inf.CLUSTER] (jgroups-12,{Keycloak.X #1}) ISPN100001: Node {Keycloak.X #2} left the cluster
```

Keycloak.X #1でアクセストークンを発行します。

```
$ curl https://***.ap-northeast-1.elb.amazonaws.com/realms/sample_service/protocol/openid-connect/token -d "grant_type=password&client_id=sample_client_application&client_secret=***&username=sample_user&password=***" | jq .

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5...",
  "expires_in": 300,
  "refresh_expires_in": 1800,
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5...",
  "token_type": "Bearer",
  "not-before-policy": 0,
  "session_state": "cf8d40bb-629a-4267-9908-f899a6f032a5",
  "scope": "profile email"
}
```

次に、Keycloak.X #2を起動します。Keycloak.X #2を起動すると、Keycloak.X #2がクラスタ構成に参加した旨、Keycloak.X #1のログに出力されます。

```
2021-07-16 07:40:46,627 INFO  [org.inf.CLUSTER] (jgroups-14,{Keycloak.X #1}) ISPN000094: Received new cluster view for channel ISPN: [{Keycloak.X #1}|3] (2) [{Keycloak.X #1}, {Keycloak.X #2}]
2021-07-16 07:40:46,628 INFO  [org.inf.CLUSTER] (jgroups-14,{Keycloak.X #1}) ISPN100000: Node {Keycloak.X #2} joined the cluster
```

その後、Keycloak.X #1を停止します。Keycloak.X #1を停止すると、Keycloak.X #1がクラスタ構成から離脱した旨、Keycloak.X #2のログに出力されます。

```
2021-07-16 07:41:29,101 INFO  [org.inf.CLUSTER] (jgroups-10,{Keycloak.X #2}) ISPN000094: Received new cluster view for channel ISPN: [{Keycloak.X #2}|4] (1) [{Keycloak.X #2}]
2021-07-16 07:41:29,110 INFO  [org.inf.CLUSTER] (jgroups-10,{Keycloak.X #2}) ISPN100001: Node {Keycloak.X #1} left the cluster
```

Keycloak.X #2でアクセストークンを検証します。

```
$ curl http://***.ap-northeast-1.elb.amazonaws.com/realms/sample_service/protocol/openid-connect/token/introspect -d "client_id=sample_resource_server&client_secret=***&token=eyJhbGciOiJSUzI1NiIsInR5..." | jq .

{
  "exp": 1626421388,
  "iat": 1626421088,
  "jti": "c01e28fc-ce22-4f91-970c-6c77e884ee9b",
  "iss": "https://***.ap-northeast-1.elb.amazonaws.com/realms/sample_service",
  "aud": "account",
  "sub": "a25d8fbe-046b-4737-8572-6780e342e2a2",
  "typ": "Bearer",
  "azp": "sample_client_application",
  "session_state": "cf8d40bb-629a-4267-9908-f899a6f032a5",
  "preferred_username": "sample_user",
  "email_verified": false,
  "acr": "1",
  "realm_access": {
    "roles": [
      "offline_access",
      "uma_authorization"
    ]
  },
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "profile email",
  "client_id": "sample_client_application",
  "username": "sample_user",
  "active": true
}
```

Keycloak.X #1で発行したアクセストークンがKeycloak.X #2で有効(トークンイントロスペクションのレスポンスのactiveがtrue)と判断されたことにより、適切にKeycloak.Xのクラスタ構成が構築され、キャッシュが同期されたことがわかります。

# おわりに
Keycloak.Xのクラスタ構成を組んで動かしてみました。現行のKeycloakで実現できる機能に関しては、Keycloak.Xでもそん色なく実現できているように見受けられます。今後は、現在実装中の「無停止VUP」や「Infinispanレス」といったKeycloak.X独自の新機能にも注目していきたいところです。

[^1]: KubernetesネイティブのJavaスタックで、高速起動や省メモリといった特徴を持ちます。
[^2]: [Keycloak 12.0.0のリリースノート](https://www.keycloak.org/docs/latest/release_notes/index.html#keycloak-x-distribution-preview)
[^3]: [Keycloak.Xのドキュメント準備用のJIRAチケット](https://issues.redhat.com/browse/KEYCLOAK-18104)