LoginSignup
4
5

Nginx をリバプロに採用した Keycloak 本番クラスター構成を構築する

Last updated at Posted at 2024-01-30

この記事の目的

Keycloak の本番構成に関して Nginx (OSS) を Keycloak 前のリバースプロキシに採用している記事はそう多くはありません.
特に Keycloak をクラスター構成にした場合で接続エラー解消に少し手間取ったので,こちらの記事に残します.

こちらの記事を参照すると Keycloak 本番構成のクラスターを構築することができます.
また,Nginx (OSS) を利用した Keycloak アクセスのロードバランシングができるようになります.

Keycloak 本番構成に Nginx (OSS) を推奨する記事ではありません.(むしろ逆)

結論

インスタンス共有の永続化 DB をつけることをまず最初に考えよう.

Nginx (OSS) を使い続けたい場合はロードバランシング方法を ip-hash にすることを検討しよう.

ただし,特別に Nginx (OSS) に強いこだわりや使わなくてはならない理由がないのであれば,HAProxy などに変更することを検討しよう.

構成

nginx.png

各サービスの簡単な紹介

Keycloak

keycloak.PNG

Add authentication to applications and secure services with minimum effort.
No need to deal with storing users or authenticating users.
Keycloak provides user federation, strong authentication, user management, fine-grained authorization, and more.

Keycloak は,モダンなアプリケーションと API に対して一元的な認証・認可を提供するオープンソースの Identity and Access Management (IAM) ソリューションです.
2023 年の 4 月には Cloud Native Computing Foundation (CNCF) incubation project となりました.1

機能については以下の記事で詳しく紹介されています.

Keycloak の本番構築を理解するには少しだけ知識の補強が必要です.
詳しくは以下の書籍の『第 9 章 本番環境用の Keycloak の設定』をご覧ください.

筆者が以前執筆した Red Hat OpenShift Service on AWS (ROSA) を利用して Keycloak クラスターを構築してみた の記事や,AWS EKS での構築方法が紹介されている EKSを使ってクラスタ構成のKeycloakを構築してみた の記事ではクラウドサービスあるいは Ingress がネットワーク設定をうまくやってくれていました.
本記事はローカルで改めて動きを確認します.

Nginx

image.png

nginx [engine x] is an HTTP and reverse proxy server, a mail proxy server, and a generic TCP/UDP proxy server, originally written by Igor Sysoev. For a long time, it has been running on many heavily loaded Russian sites including Yandex, Mail.Ru, VK, and Rambler. According to Netcraft, nginx served or proxied 20.71% busiest sites in January 2024. Here are some of the success stories: Dropbox, Netflix, Wordpress.com, FastMail.FM.2

Nginx は HTTP サーバー,リバースプロキシサーバー,汎用 TCP/UDP プロキシサーバー等の機能を提供する OSS です.
イベント駆動型アーキテクチャによって C10k 問題 に耐性を持つことでも知られて3おり,今日ではデファクトスタンダードとしてその地位を確立しています.

本記事では,様々なアクセスを一括管理したりアクセスログを収集するなどの目的でリバースプロキシとしての Nginx を利用します.

動作確認環境

名称 バージョン
Windows 11 22H2
Ubuntu (WSL2) 20.04.6 LTS
Keycloak 23.0.2
Nginx 1.24.0
PostgreSQL 16.1-bullseye
OpenJDK 21
Docker 20.10.17
Docker Compose v2.10.2

本番環境 Keycloak と Nginx の TLS 有効化のために予め自己署名証明書を準備しておきます.
Keycloak では OpenJDK 21 の keytool を利用して Java KeyStore を使うこととします.

# Nginx 用
mkdir -p $HOME/.ssh/nginx && cd $_ && \
openssl genrsa -out private-server.pem 2048 && \
openssl rsa -in private-server.pem -pubout -out public-server.pem && \
openssl req -batch -new -key private-server.pem -out server.csr && \
openssl x509 -days 398 -req -signkey private-server.pem -in server.csr -out server.crt

# Keycloak 用
mkdir -p $HOME/.ssh/keycloak && cd $_ && \
keytool -genkey -v -keystore mykeycloak.jks -keyalg RSA -keysize 2048 -validity 10000 -alias mykeycloakkey

openssl genrsa -out private-server.pem 2048 && \
openssl rsa -in private-server.pem -pubout -out public-server.pem && \
openssl req -batch -new -key private-server.pem -out server.csr && \
openssl x509 -days 398 -req -signkey private-server.pem -in server.csr -out server.crt

keytool -import -trustcacerts -file server.crt -keystore mykeycloak.jks
> Enter keystore password:
> Owner: O=Internet Widgits Pty Ltd, ST=Some-State, C=AU
> Issuer: O=Internet Widgits Pty Ltd, ST=Some-State, C=AU
> Serial number: xxxxxxxxxx
> Valid from: Mon Jan 22 12:58:05 JST 2024 until: Sun Feb 23 12:58:05 JST 2025
> Certificate fingerprints:
>          SHA1: XX:...
>          SHA256: XX:...
> Signature algorithm name: SHA256withRSA
> Subject Public Key Algorithm: 2048-bit RSA key
> Version: 1
> Trust this certificate? [no]:  yes
> Certificate was added to keystore

続いて Keycloak の設定ファイルを準備しておきます.

keycloak/conf/keycloak.conf
# If the server should expose healthcheck endpoints.
health-enabled=true

# Keystore
https-key-store-file=/opt/keycloak/mykeycloak.jks
https-key-store-password=password

# The proxy address forwarding mode if the server is behind a reverse proxy.
proxy=reencrypt

# Hostname for Keycloak instances.
hostname=localhost
hostname-strict-backchannel=true

また Keycloak へのルーティング設定以外の Nginx 設定ファイルも準備しておきます.

nginx/conf/default.conf
server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}
nginx/conf/ssl.conf
server {
    listen 443 default ssl;

    root   /usr/share/nginx/html;
    index  index.html index.htm;

    location / {
       root   /usr/share/nginx/html;
       index  index.html index.htm;
    }
    
    ssl on;
    ssl_certificate     /etc/nginx/certs/server.crt;
    ssl_certificate_key /etc/nginx/certs/private-server.pem;
}

最後に docker-compose.yml 用の .env ファイルも準備しておきます.

.env
NGINX_VERSION=1.24.0
NGINX_KEY_LOCATION=$HOME/.ssh/nginx
NGINX_CONF_LOCATION=./nginx/conf

KC_VERSION=23.0.2
KC_KEY_LOCATION=$HOME/.ssh/keycloak
KC_CONF_LOCATION=./keycloak/conf
KC_ADMIN_USER=admin
KC_ADMIN_PASSWORD=password
KC_PORT_1=8443
KC_PORT_2=8543
KC_PORT_3=8643

POSTGRES_VERSION=16.1-bullseye
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=keycloak
POSTGRES_PASSWORD=testpassword
POSTGRES_DATA_PATH=$HOME/volumes/postgres

NG パターン

  • DB 共有なし
  • ラウンドロビンでロードバランシング
docker-compose.yml
version: '3'

services:
  nginx:
    image: nginx:${NGINX_VERSION}
    container_name: nginx
    ports:
      - 80:80
      - 443:443
    volumes:
      - ${NGINX_KEY_LOCATION}/private-server.pem:/etc/nginx/certs/private-server.pem:ro
      - ${NGINX_KEY_LOCATION}/server.crt:/etc/nginx/certs/server.crt:ro
      - ${NGINX_CONF_LOCATION}:/etc/nginx/conf.d
    restart: always
    logging:
      driver: json-file
      options:
        max-file: '3'
        max-size: 128m

  keycloak-1:
    image: quay.io/keycloak/keycloak:${KC_VERSION}
    container_name: keycloak-1
    ports:
      - ${KC_PORT_1}:${KC_PORT_1}
    volumes:
      - ${KC_KEY_LOCATION}/mykeycloak.jks:/opt/keycloak/mykeycloak.jks:ro
      - ${KC_CONF_LOCATION}:/opt/keycloak/conf
    environment:
      KEYCLOAK_ADMIN: ${KC_ADMIN_USER}
      KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
    command: 
      - "start"
      - "--https-port=${KC_PORT_1}"
    restart: always
    logging:
      driver: json-file
      options:
        max-file: '3'
        max-size: 128m
  
  keycloak-2:
    image: quay.io/keycloak/keycloak:${KC_VERSION}
    container_name: keycloak-2
    ports:
      - ${KC_PORT_2}:${KC_PORT_2}
    volumes:
      - ${KC_KEY_LOCATION}/mykeycloak.jks:/opt/keycloak/mykeycloak.jks:ro
      - ${KC_CONF_LOCATION}:/opt/keycloak/conf
    environment:
      KEYCLOAK_ADMIN: ${KC_ADMIN_USER}
      KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
    command: 
      - "start"
      - "--https-port=${KC_PORT_2}"
    restart: always
    logging:
      driver: json-file
      options:
        max-file: '3'
        max-size: 128m

  keycloak-3:
    image: quay.io/keycloak/keycloak:${KC_VERSION}
    container_name: keycloak-3
    ports:
      - ${KC_PORT_3}:${KC_PORT_3}
    volumes:
      - ${KC_KEY_LOCATION}/mykeycloak.jks:/opt/keycloak/mykeycloak.jks:ro
      - ${KC_CONF_LOCATION}:/opt/keycloak/conf
    environment:
      KEYCLOAK_ADMIN: ${KC_ADMIN_USER}
      KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
    command: 
      - "start"
      - "--https-port=${KC_PORT_3}"
    restart: always
    logging:
      driver: json-file
      options:
        max-file: '3'
        max-size: 128m
nginx/conf/keycloak-host.conf
upstream localhost-keycloak {
    server keycloak-1:8443;
    server keycloak-2:8543;
    server keycloak-3:8643;
}

server {
    listen 443 ssl http2;
    server_name localhost;

    location / {
        proxy_pass https://localhost-keycloak/;
        proxy_set_header Host               $host;
        proxy_set_header X-Real-IP          $remote_addr;
        proxy_set_header X-Forwarded-For    $remote_addr;
        proxy_set_header X-Forwarded-Host   $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-Port   $server_port;
        proxy_set_header X-Forwarded-Proto  $scheme;
    }

}

Infinispan によるキャッシュの共有設定ができており,正常にクラスター化されていることがわかります.

 docker ps -a
> CONTAINER ID   IMAGE                              COMMAND                  CREATED         STATUS         PORTS                                        NAMES
> b7febb72678c   nginx:1.24.0                       "/docker-entrypoint.…"   2 minutes ago   Up 2 minutes   0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp     nginx
> cfbc146f0ca5   quay.io/keycloak/keycloak:23.0.2   "/opt/keycloak/bin/k…"   2 minutes ago   Up 2 minutes   8080/tcp, 0.0.0.0:8443->8443/tcp             keycloak-1
> 2e4d391e644e   quay.io/keycloak/keycloak:23.0.2   "/opt/keycloak/bin/k…"   2 minutes ago   Up 2 minutes   8080/tcp, 8443/tcp, 0.0.0.0:8643->8643/tcp   keycloak-3
> 43845dc5c350   quay.io/keycloak/keycloak:23.0.2   "/opt/keycloak/bin/k…"   2 minutes ago   Up 2 minutes   8080/tcp, 8443/tcp, 0.0.0.0:8543->8543/tcp   keycloak-2
> e3634f2443aa   postgres:16.1-bullseye             "docker-entrypoint.s…"   2 minutes ago   Up 2 minutes   0.0.0.0:5432->5432/tcp                       postgres

docker logs keycloak-1
> ...
> 2024-01-30 00:21:04,304 INFO  [org.infinispan.CLUSTER] (jgroups-13,cfbc146f0ca5-7191) [Context=work] ISPN100010: Finished rebalance with members [cfbc146f0ca5-7191, 2e4d391e644e-22991, 43845dc5c350-1273], topology id 9
> ...

接続してみます.

https://localhost
image.png

css の読み込みができていません.
エラー文はlocalhost/:1 Refused to apply style from 'https://localhost/resources/s1asf/common/keycloak/node_modules/patternfly/dist/css/patternfly.css' because its MIME type ('') is not a supported stylesheet MIME type, and strict MIME checking is enabled.404 が返ってきています.

Administration Console に接続すると,js と css が返ってこず画面が止まってしまいます.

image.png

OK パターン

  • DB 共有あり

OR

  • ip-hash でロードバランシング
docker-compose.yml
version: '3'

services:
  nginx:
    image: nginx:${NGINX_VERSION}
    container_name: nginx
    ports:
      - 80:80
      - 443:443
    volumes:
      - ${NGINX_KEY_LOCATION}/private-server.pem:/etc/nginx/certs/private-server.pem:ro
      - ${NGINX_KEY_LOCATION}/server.crt:/etc/nginx/certs/server.crt:ro
      - ${NGINX_CONF_LOCATION}:/etc/nginx/conf.d
    restart: always
    logging:
      driver: json-file
      options:
        max-file: '3'
        max-size: 128m

  keycloak-1:
    image: quay.io/keycloak/keycloak:${KC_VERSION}
    container_name: keycloak-1
    ports:
      - ${KC_PORT_1}:${KC_PORT_1}
    volumes:
      - ${KC_KEY_LOCATION}/mykeycloak.jks:/opt/keycloak/mykeycloak.jks:ro
      - ${KC_CONF_LOCATION}:/opt/keycloak/conf
    environment:
      KEYCLOAK_ADMIN: ${KC_ADMIN_USER}
      KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
      KC_DB: postgres
      KC_DB_URL: "jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
      KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
      KC_DB_USERNAME: postgres
    command: 
      - "start"
      - "--https-port=${KC_PORT_1}"
    restart: always
    logging:
      driver: json-file
      options:
        max-file: '3'
        max-size: 128m
  
  keycloak-2:
    image: quay.io/keycloak/keycloak:${KC_VERSION}
    container_name: keycloak-2
    ports:
      - ${KC_PORT_2}:${KC_PORT_2}
    volumes:
      - ${KC_KEY_LOCATION}/mykeycloak.jks:/opt/keycloak/mykeycloak.jks:ro
      - ${KC_CONF_LOCATION}:/opt/keycloak/conf
    environment:
      KEYCLOAK_ADMIN: ${KC_ADMIN_USER}
      KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
      KC_DB: postgres
      KC_DB_URL: "jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
      KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
      KC_DB_USERNAME: postgres
    command: 
      - "start"
      - "--https-port=${KC_PORT_2}"
    restart: always
    logging:
      driver: json-file
      options:
        max-file: '3'
        max-size: 128m

  keycloak-3:
    image: quay.io/keycloak/keycloak:${KC_VERSION}
    container_name: keycloak-3
    ports:
      - ${KC_PORT_3}:${KC_PORT_3}
    volumes:
      - ${KC_KEY_LOCATION}/mykeycloak.jks:/opt/keycloak/mykeycloak.jks:ro
      - ${KC_CONF_LOCATION}:/opt/keycloak/conf
    environment:
      KEYCLOAK_ADMIN: ${KC_ADMIN_USER}
      KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
      KC_DB: postgres
      KC_DB_URL: "jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
      KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
      KC_DB_USERNAME: postgres
    command: 
      - "start"
      - "--https-port=${KC_PORT_3}"
    restart: always
    logging:
      driver: json-file
      options:
        max-file: '3'
        max-size: 128m

  postgres:
    image: postgres:${POSTGRES_VERSION}
    container_name: ${POSTGRES_HOST}
    ports:
      - ${POSTGRES_PORT}:${POSTGRES_PORT}
    volumes:
      - ${POSTGRES_DATA_PATH}:/var/lib/postgresql
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
nginx/conf/keycloak-host.conf
upstream localhost-keycloak {
    ip_hash;
    server keycloak-1:8443;
    server keycloak-2:8543;
    server keycloak-3:8643;
}

server {
    listen 443 ssl http2;
    server_name localhost;

    location / {
        proxy_pass https://localhost-keycloak/;
        proxy_set_header Host               $host;
        proxy_set_header X-Real-IP          $remote_addr;
        proxy_set_header X-Forwarded-For    $remote_addr;
        proxy_set_header X-Forwarded-Host   $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-Port   $server_port;
        proxy_set_header X-Forwarded-Proto  $scheme;
    }

}

永続化用 DB を加えるか,ロードバランシングを ip_hash に変更すると解決できます.

https://localhost/
image.png

image.png

image.png

何が原因だったのか

似た現象を探ると以下の issue が見つかりました.

v-kamerdinerov on Feb 10. 2023

@kkcmadhu So, i added this variables to but them does't help :3
Only, when i enables sticky sessions on my load balancer, all seems work fine:

こちらでは HAProxy を利用してはいますが,現象は sticky session を設定したら解決したとのこと.
更に以下の issue を確認すると,

dominikschlosser commented on Sep. 30, 2022

We have a similar but slightly different issue since Keycloak 19... I noticed that the random-looking part behind /resources/ in the URL (/resources/z6yq4/common/keycloak/web_modules/@patternfly/...) seems to differ in each instance we start. So when there are multiple instances running behind a load-balancer, we get a 404 each time we dont hit the correct instance (the one which generated the Web-UI)...

なるほど確かに NG パターンを確認した際に /resources/ の直後に並んでいる文字列が気になっていましたが,これが seed でありインスタンスを区別しているようです.

DB を接続するとこの seed 値が共有されているということですね.
実際に確認してみると migration_model に値が見つかりました.

docker exec -it postgres /bin/bash
> psql -U postgres -d postgres
>> psql (16.1 (Debian 16.1-1.pgdg110+1))
>> Type "help" for help.

> postgres=# \l
>>                                                       List of databases
>>    Name    |  Owner   | Encoding | Locale Provider |  Collate   |   Ctype    | ICU Locale | ICU Rules |   Access privileges
>> -----------+----------+----------+-----------------+------------+------------+------------+-----------+-----------------------
>>  keycloak  | postgres | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           |
>>  postgres  | postgres | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           |
>>  template0 | postgres | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           | =c/postgres
>>      +
>>            |          |          |                 |            |            |            |           | postgres=CTc/postgrestemplate1 | postgres | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           | =c/postgres
>>      +
>>            |          |          |                 |            |            |            |           | postgres=CTc/postgres
>> (4 rows)

> postgres=# \c keycloak
>> You are now connected to database "keycloak" as user "postgres".

> keycloak=# \dt
>>                      List of relations
>>  Schema |             Name              | Type  |  Owner
>> --------+-------------------------------+-------+----------
>>  public | admin_event_entity            | table | postgres
>>  public | associated_policy             | table | postgres
>>  public | authentication_execution      | table | postgres
>>  public | authentication_flow           | table | postgres
>>  public | authenticator_config          | table | postgres
>>  public | authenticator_config_entry    | table | postgres
>>  public | broker_link                   | table | postgres
>>  ...

> keycloak=# select * from migration_model;
>>   id   | version | update_time
>> -------+---------+-------------
>>  54gh1 | 23.0.2  |  1706575527
>> (1 row)

ではもし Nginx (OSS) だけで Sticky session を張って接続したい場合はどうすればよいのか.

実は Nginx で "Sticky Session" を公式にサポートしているのは有償の Nginx Plus のみです.
Nginx Plus の公式ドキュメントを読むと,Nginx (OSS) では haship_hash を使うこととあるのでこちらを使うことになります.

NGINX Plus supports three session persistence methods. The methods are set with the sticky directive. (For session persistence with NGINX Open Source, use the hash or ip_hash directive as described above.)

では ip-hash とはどんなロードバランシング方法かというと,

IP Hash – The server to which a request is sent is determined from the client IP address. In this case, either the first three octets of the IPv4 address or the whole IPv6 address are used to calculate the hash value. The method guarantees that requests from the same address get to the same server unless it is not available.4

との記載がありました.
すなわち同じアドレスからのリクエストは常に同じサーバーに接続することを保証してくれるということですね.
ただし,IPv4 では最初の 3 オクテットまでしか見てくれないようなので注意が必要に思います.
実際に調べてみると,大規模な構成でアクセス実験をした結果ロードバランシングとしての期待からずれてしまう可能性があるという報告があります.

IP Hash load‑balancing can work as "sticky sessions", but you have to keep in mind that this load balancing method is working relative bad itself, because a lot of user/devices shared same external IP-Address in modern world.

We experimented with a rather heavily loaded (thousands of parallel users) application and observed tens of percent imbalance between servers when using IP Hash.

Theoretically, the situation should improve with increasing load and number of servers, but for example we did not see any significant difference when using 3 and 5 servers.

So, I would strongly advise against using IP Hash in productive environment.

As open-source based sticky sessions solution, not bad idea to use HAProxy, because HAProxy support it out-of-the-box. Or HAProxy + Nginx bundle, where HAProxy is responsible for "sticky sessions". (I know about one extremely loaded system that successfully uses such a bundle for this very purpose, so, this is working idea.)

以上の理由で Keycloak の本番構成では Nginx (OSS) が推奨されず,HAProxy や Apache HTTP Server が利用されているんだなぁと学びになりました.

※ なお Nginx (OSS) で Sticky を有効にするモジュール5 6がいくつか見つかりましたが,更新が 10 年近く途絶えているので利用するには相応の覚悟が必要ですね...

まとめ

Nginx をリバプロに据えた Keycloak 本番クラスター構成の構築確認を行いました.

クラスター構成を考える場合は,第一に様々な設定値を共有する DB の配置が大事であることが身に染みてわかりました.
また結果的に,なぜ Nginx (OSS) が Keycloak 本番クラスター構成に使われていないのかもわかってきた気がします.

ご参考になればと思います.

Keycloak 23.0.1 ではうまく動かない (オマケ)

Keyloak 23.0.1 では PostgreSQL 接続設定時に利用した KC_DB_USERNAME を設定するとビルドに失敗することが報告されています.

本記事のコードで動作確認する場合は,23.0.2 あるいは 22.0.5 を利用してください.

  1. https://www.cncf.io/projects/keycloak/

  2. https://nginx.org/en/

  3. https://webhostinggeeks.com/blog/c10k-problem-understanding-and-overcoming-the-10000-concurrent-connections-challenge/

  4. https://docs.nginx.com/nginx/admin-guide/load-balancer/http-load-balancer/#choosing-a-load-balancing-method

  5. https://github.com/lusis/nginx-sticky-module

  6. https://bitbucket.org/nginx-goodies/nginx-sticky-module-ng/src/master/

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5