Keycloak + Infinispanで分散キャッシュをしてセッション共有・冗長化する方法

# background
前任者が残した keycloak が kubernetes の上で動いていました。セッションを共有できないため 1 pod で運用しています。pod を止めずに kubernetes version up ができるように冗長化しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24737/1f22a4ac-9c1f-7c45-bbee-2ba7dd2a3f74.png)


# issue
デフォルト設定のkeycloakはセッションをメモリに保存します。そのため keycloak を 2 pods 以上起動すると、pod 間でのセッション不整合が起こります。あなたが keycloakにログインしたとき、 pod-A に接続されていたとします。ログイン後の画面遷移で表示されるのが pod-B だった場合、pod-Bはあなたのセッション情報をメモリに持っていないため、まだログイン前のユーザだとしてログイン画面を表示します。こんな感じで、二回に一回は思った画面が表示するけど・・みたいな謎の挙動をするようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24737/b2cc0e0c-6978-5cb9-2309-7da51cbd785f.png)



# Workaround
podsへのアクセスを振り分けるLoad balancerでsticky sessionを利用すれば、あなたのリクエストを随時pod-Aに振り分けるため一見問題なくなったように見えます。しかし、pod-Aがなんらかの原因でstopした場合、あなたはpod-Bを利用することになります。pod-Bのメモリにはあなたのセッションがありませんから、ログインからやり直すことになります。これは冗長化というよりは、cold standby があるようなものです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24737/4b9c0629-544f-7a00-6cf8-67098ee9e97a.png)


# 分散キャッシュ
pod A, B が同じメモリを持てば問題は解決します。それが keycloak では分散キャッシュ(公式には Distributed Caches)と呼ばれる機能です。

https://www.keycloak.org/server/caching

keycloak はデフォルトで infinispan を使った分散キャッシュに対応しています。infinispan は Redis, etcd, memcached のような高速なインメモリストレージです。kubernetesに対応している点、Javaの組み込みライブラリとして利用できる点が特徴（のよう）です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24737/23fd59ec-8858-a8cb-c1e5-22d305303e6c.png)

*著者は keycloak もろくに理解していませんし infinispan を目にするのはこれが初めてです

注意点として、infinispanは基本的に UDP で動作しますが kubernetes network では UDP が使えないので TCP で動作するように設定を変える必要があります。この後のセクションで説明します。


# How to set up Keycloak x Infinispan

## Docker Image

Versionは最新の 23.0.7 を利用しました。これ自体に inifispan に対応した keycloak　が入ってます。


Docker image
```
FROM quay.io/keycloak/keycloak:23.0.7-0
```

https://quay.io/repository/keycloak/keycloak?tab=tags&tag=latest

*infinispanは独立したprocessとして稼働するわけではないようです。keycloakのプロセス内でinfinispanの組み込み関数を利用してinfinispanを動作させているみたい。

keycloakが古すぎる(特にv19以前のwildflyの方)はupgradeしましょう。こちらに解説があります。

https://qiita.com/uturned0/items/e9256c48ccba6f588d79


## Deployement

環境変数でkeycloakのinfinispanを有効にします。

- KC_CACHE
    - `ispn` にすることで infinispan を使います。
- KC_CACHE_STACK
    - `kubernetes` にすることで inifispan を k8s 用の動作モードにします。
        - おそらく UDP ではなく TCP で動くようにする、また cluster nodesのdiscoveryを k8s の DNS を使ってするようにするんだと思います
- JAVA_OPTS_APPEND
    - これが重要です。infinispan の node discoveryに使う FQDN を `jgroups.dns.query` に指定します。 k8s の特殊なリソースである headless-service のFQDNを指定する必要があります。後述します。

https://www.keycloak.org/server/all-config

```yml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: keycloak
  name: keycloak
  namespace: keycloak
spec:
  replicas: 2
  selector:
    matchLabels:
      app: keycloak
  strategy:
    rollingUpdate:
      maxSurge: 2
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: keycloak
    spec:
      containers:
      - name: keycloak
        image: quay.io/keycloak/keycloak:23.0.7-0
        command:
        - /opt/keycloak/bin/kc.sh
        - --verbose
        - start
        env:
        - name: TZ
          value: Asia/Tokyo
        # Infinispan 分散キャッシュ設定 -->
        - name: KC_CACHE
          value: ispn
        - name: KC_CACHE_STACK
          value: kubernetes
        ## **重要** ↓↓↓ これをserviceと一致する名前にすること。後述。
        - name: JAVA_OPTS_APPEND
          value: -Djgroups.dns.query=keycloak-infinispan-headless-service.keycloak.svc.cluster.local   
        # <--
        
        # 初期設定
        - name: KEYCLOAK_ADMIN
          value: admin
        
        # DB 設定
  		- name: KC_DB
  		  value: mysql
  		- name: KC_DB_URL
  		  value: jdbc:mysql://my-mysql.example.com:3306/keycloak
  		- name: KC_DB_USERNAME
  		  value: keycloak
        
        # Load balancer で TLS termination している時に必要な設定
        - name: KC_PROXY
          value: edge
        - name: KC_PROXY_ADDRESS_FORWARDING
          value: "true"
        - name: KC_HOSTNAME_STRICT
          value: "false"
        - name: KC_HOSTNAME_STRICT_HTTPS
          value: "false"
        - name: KC_HTTP_ENABLED
          value: "true"
        - name: KC_HTTP_PORT
          value: "8080"
        envFrom:
        - secretRef:
            name: keycloak-secret    # KC_DB_PASSWORD を読み込む
        ports:
        - containerPort: 8080
          name: http
        readinessProbe:
          httpGet:
            path: /realms/master
            port: 8080
```


## Services
### Infinispan用の headless service
これが大事です。普通の k8s serviceは通信をpodに繋げるためにありますが、これは違います。infinispanに、ns内に存在するpodを教えてあげるためだけに作ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24737/35a83b05-445d-1fdc-7990-26285adbd295.png)

```yml
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: keycloak
  name: keycloak-infinispan-headless-service   # <---- JAVA_OPTS_APPENDに入れるprefixと一致すること。
  namespace: keycloak
spec:
  clusterIP: None      # <--------- 絶対 `None`。省略禁止。
  ports:
  - name: http
    port: 8080
    targetPort: 8080
  selector:
    app: keycloak
```

大事なポイント
- `name: keycloak-infinispan-headless-service`
    - k8sではserviceの名前を使ってFQDNを作り名前解決に利用します。
    - `<service-name>`.`<namespace-name>`.*svc.cluster.local* というルールです
    - そのFQDNを deployment の `JAVA_OPTS_APPEND` に入れます
- `clusterIP: None` 
    - ここでやりたいのは、`keycloak-infinispan-headless-service.keycloak.svc.cluster.local` に名前解決をすると *全ての pod の IP address(A record)* を返却する DNS サーバを作ることです。
    - 設定で言うと `dnsAllPods: true` みたいなサービスを作りたいわけです（そんなオプションはありませんが）
    - `clusterIP: None` にすると、そういうサービスを作ることができます。これを `headless service`と呼びます。
    - 詳しくはこちらの記事に書きました
        - [Kubernetes の Headless Service / ヘッドレスサービスとは
](https://qiita.com/uturned0/items/2825fd274e1bcb6b522f)


### 通信用の service & ingress
これはごく普通のものです。podの8080に通信を繋げます。 `clusterIP: None` がないので、この service は名前解決を依頼された時にpodのIP addressesではなく service のIPを返します。そのため、このserviceは infinispan の cluster node discovery には使えません。

```yml
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: keycloak
  name: keycloak
  namespace: keycloak
spec:
  type: ClusterIP
  ports:
  - name: http
    port: 8080
  selector:
    app: keycloak


---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/backend-protocol: HTTP
    nginx.ingress.kubernetes.io/configuration-snippet: |
      if ($http_x_forwarded_proto = 'http') {
        return 302 https://$host$request_uri;
      }
    nginx.ingress.kubernetes.io/proxy-buffer-size: 128k
    nginx.ingress.kubernetes.io/use-forwarded-headers: "true"
  name: keycloak
  namespace: keycloak
spec:
  rules:
  - host: my-keycloak.example.com
    http:
      paths:
      - backend:
          service:
            name: keycloak
            port:
              number: 8080
        path: /
        pathType: Prefix
```


# 起動
## 1 つ目の pod log
keycloakを起動して、infinispan の接続に成功すると `keycloak-cache-init` がついた log が出ます。意味は調べていませんが、成功例として記録します

> Starting user marshaller 

この log を起点として infinispan の処理が始まるようです。

> (keycloak-cache-init) server listening on *.57800

infinispanが起動したようです。

> no members discovered after 2003 ms: creating cluster as coordinator

既存の infinispan clusterが見つからないため、自身が coordinator (おそらくmaster nodeのようなもの) として起動したようです。

> Received new cluster view for channel ISPN: 

clusterの情報が表示されます。よく見るとこの文言が複数回logに出ています。これはclusterのnodesが変わった時に出るようです。読み方は後述。

> Unable to persist Infinispan internal caches as no global state enabled

これは気にしなくていいエラーです。podが全部死んでもインメモリのsessionを保存しておきたいときにpersist cacheを使いますが、今回は不要なので使っていません。(その場合はログインし直してもらえばok)


```rb
2024-03-04 12:57:45,352 WARN  [org.infinispan.PERSISTENCE] (keycloak-cache-init) ISPN000554: jboss-marshalling is deprecated and planned for removal
2024-03-04 12:57:45,508 INFO  [org.infinispan.CONTAINER] (keycloak-cache-init) ISPN000556: Starting user marshaller 'org.infinispan.jboss.marshalling.core.JBossUserMarshaller'
2024-03-04 12:57:46,364 INFO  [org.infinispan.CLUSTER] (keycloak-cache-init) ISPN000078: Starting JGroups channel `ISPN` with stack `kubernetes`
2024-03-04 12:57:46,367 INFO  [org.jgroups.JChannel] (keycloak-cache-init) local_addr: 7da7c155-16be-45b9-b3e0-d64de166f6c0, name: keycloak-7c7d6ddd84-2lk9h-42677
2024-03-04 12:57:46,388 INFO  [org.jgroups.protocols.FD_SOCK2] (keycloak-cache-init) server listening on *.57800
2024-03-04 12:57:48,404 INFO  [org.jgroups.protocols.pbcast.GMS] (keycloak-cache-init) keycloak-7c7d6ddd84-2lk9h-42677: no members discovered after 2003 ms: creating cluster as coordinator
2024-03-04 12:57:48,420 INFO  [org.infinispan.CLUSTER] (keycloak-cache-init) ISPN000094: Received new cluster view for channel ISPN: [keycloak-7c7d6ddd84-2lk9h-42677|0] (1) [keycloak-7c7d6ddd84-2lk9h-42677]
2024-03-04 12:57:48,582 INFO  [org.infinispan.CLUSTER] (keycloak-cache-init) ISPN000079: Channel `ISPN` local address is `keycloak-7c7d6ddd84-2lk9h-42677`, physical addresses are `[172.18.3.53:7800]`
2024-03-04 12:57:48,597 WARN  [org.infinispan.CONFIG] (keycloak-cache-init) ISPN000569: Unable to persist Infinispan internal caches as no global state enabled
...
2024-03-04 12:59:54,798 INFO  [org.infinispan.CLUSTER] (jgroups-16,keycloak-7c7d6ddd84-2lk9h-42677) ISPN000094: Received new cluster view for channel ISPN: [keycloak-7c7d6ddd84-2lk9h-42677|4] (1) [keycloak-7c7d6ddd84-2lk9h-42677]
2024-03-04 13:00:18,029 INFO  [org.infinispan.CLUSTER] (jgroups-16,keycloak-7c7d6ddd84-2lk9h-42677) ISPN000094: Received new cluster view for channel ISPN: [keycloak-7c7d6ddd84-2lk9h-42677|5] (2) [keycloak-7c7d6ddd84-2lk9h-42677, keycloak-7c7d6ddd84-b2l59-34175]
```

## 2 つめの pod log

後から clusterにjoinする pod は少しlogが変わります。

> server listening on *.57800

ここまでは同じです。

> Received new cluster view for channel ISPN: 

その後、 `no members...` が出ずにすぐに cluster 情報を recieveしています。これで joinが完了しています。

```rb
2024-03-04 13:00:16,977 WARN  [org.infinispan.PERSISTENCE] (keycloak-cache-init) ISPN000554: jboss-marshalling is deprecated and planned for removal       
2024-03-04 13:00:17,092 INFO  [org.infinispan.CONTAINER] (keycloak-cache-init) ISPN000556: Starting user marshaller 'org.infinispan.jboss.marshalling.core.JBossUserMarshaller'      
2024-03-04 13:00:17,793 INFO  [org.infinispan.CLUSTER] (keycloak-cache-init) ISPN000078: Starting JGroups channel `ISPN` with stack `kubernetes`           
2024-03-04 13:00:17,798 INFO  [org.jgroups.JChannel] (keycloak-cache-init) local_addr: c8eef3e0-3c27-496f-b6d2-b225981142c9, name: keycloak-7c7d6ddd84-b2l59-34175
2024-03-04 13:00:17,815 INFO  [org.jgroups.protocols.FD_SOCK2] (keycloak-cache-init) server listening on *.57800
2024-03-04 13:00:18,109 INFO  [org.infinispan.CLUSTER] (keycloak-cache-init) ISPN000094: Received new cluster view for channel ISPN: [keycloak-7c7d6ddd84-2lk9h-42677|5] (2) [keycloak-7c7d6ddd84-2lk9h-42677, keycloak-7c7d6ddd84-b2l59-34175]
2024-03-04 13:00:18,313 INFO  [org.infinispan.CLUSTER] (keycloak-cache-init) ISPN000079: Channel `ISPN` local address is `keycloak-7c7d6ddd84-b2l59-34175`, physical addresses are `[172.18.5.221:7800]`
2024-03-04 13:00:18,390 WARN  [org.infinispan.CONFIG] (keycloak-cache-init) ISPN000569: Unable to persist Infinispan internal caches as no global state enabled
```

### `Received new cluster view for channel ISPN:` の読み方

眺めただけでなんとなくlogの意味がわかった気がします。

> [keycloak-7c7d6ddd84-2lk9h-42677|0] (1) [keycloak-7c7d6ddd84-2lk9h-42677]

この部分はおそらくこう言う意味だと想像します。

> [`cluster-name`|`state-version`] (`number of nodes`) [`node-name1`, `node-name2`...]

- cluster-name
    - 最初にcoordinatorになったpodのhostnameが入ると想像します。
- state-version
    - nodeの増減など何か変化があるたびにincrementされるような気がします
- number of nodes
    - そのclusterで稼働しているnodeの数
- node-name1, 2...
    - clusterに参加しているnodeのhostname list


1. pod-A を起動
    - pod-A: `[keycloak-7c7d6ddd84-2lk9h-42677|0] (1) [keycloak-7c7d6ddd84-2lk9h-42677]`
        - nodeがpod-A自身の一台しかいません。
2. pod-Bを起動
    - pod-B: `[keycloak-7c7d6ddd84-2lk9h-42677|1] (2) [keycloak-7c7d6ddd84-2lk9h-42677, keycloak-7c7d6ddd84-b2l59-34175]`
        - pod-Bのnodeが増えて、合計 `(2)` 台になりました。
3. pod-A がpod-Bの増加を認識
    - pod-A: `[keycloak-7c7d6ddd84-2lk9h-42677|1] (2) [keycloak-7c7d6ddd84-2lk9h-42677, keycloak-7c7d6ddd84-b2l59-34175]`
        - このログは "2." と全く同じ内容です。
4. pod-B を削除すると、pod-Aがnode減少を認識する
    - pod-A: `[keycloak-7c7d6ddd84-2lk9h-42677|2] (1) [keycloak-7c7d6ddd84-2lk9h-42677]`
        - 合計 `(1)` 台になりました。


# 上手く動かない場合

headless serviceの設定不足でハマりましたが、特にエラーログは出ませんでした。それぞれのpodが独自にclusterを構築して、一見すると動作しているように見えるためです。`Received new cluster view for channel ISPN` で `(2)` 以上の値が出ない、または異なる cluster-name が表示されていれば、分散キャッシュは動いていないと思っていいと思います。

headless-serviceの名前解決ができていない場合は keycloak podにdebug containerをつけて、 `host keycloak-infinispan-headless-service.keycloak.svc.cluster.local`  を叩いてみましょう。podのAレコードが返ってきなければそこが原因です。

infinispanが動く場合は、dig/nslookup/hostなどの名前解決でこのように全てのpodが返却されます。

e.g. 5 podsある場合

```
[app-5dfd9989bc-6fl2q] $ host your-headless-service.your-namespace.svc.cluster.local
    your-headless-service.your-namespace.svc.cluster.local has address 172.18.5.211
    your-headless-service.your-namespace.svc.cluster.local has address 172.18.4.253
    your-headless-service.your-namespace.svc.cluster.local has address 172.18.3.35
    your-headless-service.your-namespace.svc.cluster.local has address 172.18.5.212
    your-headless-service.your-namespace.svc.cluster.local has address 172.18.3.36

$ dig a +short keycloak-infinispan-headless-service.keycloak.svc.cluster.local
172.18.5.211
172.18.4.253
172.18.3.35
172.18.5.212
172.18.3.36    
```


# よもやま話
headless-serviceの意味がわからない頃に一度挫折したんですがやっとできた感じです。
https://qiita.com/uturned0/items/e9256c48ccba6f588d79

その頃、tcpdumpでadverting してるpacketを探してたんですが全然パケットが見つからなくて困ってました。7800/tcpだったと思うんですがそこで起動してるはずなのに全然packet飛んでる気配がなかった。あれは今思えば、infinispanがdnsを見て特定のip addressだけに飛ばす仕組みだったからなんでしょうね。てっきり [VRRP の multicast みたい](https://qiita.com/uturned0/items/860c99783550d9be323e) に network broadcast address とかで discovery すると思ってたので k8s がそれをサポートしていないことも勉強になったし、無駄な通信を省いてる昨今は進化してるなぁと感想を抱きました
そして主にsecurity対策だったと言う学びもありました

https://developers.redhat.com/articles/2021/10/22/broadcasting-microservices-kubernetes#



時間があれば inifinispanのadvertisingなどを観測したかったんですが今回はここまで。とりあえず動いてよかった。

k8s勉強しないとなぁ。こうやって一つずつ覚えていきましょう。