Кластеризация Keycloak — тема, которая в рунете описана крайне слабо. Для того, чтобы провернуть это, мне пришлось проштудировать кучу статей и документации на зарубежных сайтах.
Здесь я описываю весь опыт, которые постиг в процесс кластеризации.
В данной статье описывается геораспределенное кластерное решение

Общая схема распределения серверов следующая.
KeycloakAPP — это узлы кластера Keycloak
KeycloakDB — узлы кластера Patroni
DC1 Reverse и DC2 Reverse — реверс-прокси, расположенные в разных ДЦ
Мы ставим Keycloak на основе Quarkus, поэтому он у нас будет работать как служба.
Базовая подготовка
Необходимо развернуть кластер PostgreSQL. Развертывание кластера PostgreSQL в данной статье не рассматривается, так как статей на эту тему в интернете много.
Также необходимо создать БД для keycloak и пользователя, под которым Keycloak будет подключаться к БД.
Установка Keycloak
На все узлы серверов приложений Keycloak необходимо установить Java, Haproxy и загрузить архив с исполняемыми файлами Keycloak c сайта https://www.keycloak.org/downloads
Haproxy нам потребуется для подключения серверов приложений к БД
Распаковываем архив в директорию /opt/keycloak-$version и делаем симлинк /opt/keycloak на эту директорию, чтобы в дальнейшем легче было обновлять.
Создаем unit-file для сервиса /etc/systemd/system/keycloak.service
[Unit]
Description=Keycloak Identity Provider
Requires=network.target
After=syslog.target network.target
[Service]
Type=idle
User=root
Group=root
LimitNOFILE=102642
ExecStart="/opt/keycloak"/bin/kc.sh start --https-port=8080 --log=console,file
[Install]
WantedBy=multi-user.target
Выполняем команду systemctl daemon-reload
Настройка Haproxy
Создаем конфигурационный файл /etc/haproxy/haproxy.cfg и вносим в него следующий текст.
global
maxconn 100000
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
stats timeout 30s
user haproxy
group haproxy
daemon
defaults
mode tcp
log global
retries 2
timeout queue 5s
timeout connect 5s
timeout client 60m
timeout server 60m
timeout check 15s
listen stats
mode http
bind *:7000
stats enable
stats uri /stats
stats auth login:pass
listen master
bind 0.0.0.0:5432
maxconn 10000
option tcplog
option httpchk OPTIONS /primary
http-check expect status 200
default-server inter 3s fastinter 1s fall 3 rise 4 on-marked-down shutdown-sessions
server db1 192.168.0.1:5432 check port 8008
server db2 192.168.10.1:5432 check port 8008
server db3 10.172.54.32:5432 check port 8008
listen replicas
bind 0.0.0.0:5001
maxconn 10000
option tcplog
option httpchk OPTIONS /replica?lag=100MB
balance roundrobin
http-check expect status 200
default-server inter 3s fastinter 2s fall 3 rise 2 on-marked-down shutdown-sessions
server db1 192.168.0.1:5432 check port 8008
server db2 192.168.10.1:5432 check port 8008
server db3 10.172.54.32:5432 check port 8008
Haproxy необходимо сконфигурировать таким образом на каждом узле приложений.
Суть в том, что Haproxy сам определяет при таком конфиге, кто мастер и запросы отправляет на него, вне зависимости от того, кто стал мастером.
Haproxy ставится на каждый хост для того, чтобы не делать отдельный кластер балансировщиков, поскольку в противном случае он становится у нас точкой отказа. Плюс кластер балансировщиков делать достаточно затрудительно в случае геораспределения, что приводит к усложнению схему.
При установке Haproxy на сам сервер приложений мы ничего не теряем, если у нас упадет один или два сервера приложений.
Настройка Keycloak
Редактируем конфигурационный файл /opt/keycloak/conf/keycloak.conf и вносим в него следующие строки
# Database
db=postgres
db-username=Пользователь БД
db-password=Пароль пользователя БД
db-url=jdbc:postgresql://127.0.0.1/Название БД
# Metrics
health-enabled=true
metrics-enabled=true
http-metrics-histograms-enabled=true
cache-metrics-histograms-enabled=true
http-metrics-slos=5,10,25,50,250,500,1000,2500,5000,10000
# Log
log=console,file
log-format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n
log-level=info,org.keycloak.truststore:debug,org.keycloak.events:debug,org.infinispan:info
log-file=/var/log/keycloak.log
# HTTP
http-enabled=true
proxy-headers=xforwarded
hostname=FQDN keycloak
Как видим, здесь мы говорим кейлоку подключаться на localhost, на котором у нас слушает Haproxy и он будет перенаправлять трафик к серверам БД
Настройка кластера Keycloak
Для настройки кластера Keycloak нам необходимо на каждом сервере создать в директории /opt/keycloak/conf/cache-ispn.xml и внести в него следующий код.
В комментариях даны пояснения по тем параметрам, которые необходимо менять.
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2019 Red Hat, Inc. and/or its affiliates
~ and other contributors as indicated by the @author tags.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<infinispan
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:infinispan:config:15.0 http://www.infinispan.org/schemas/infinispan-config-15.0.xsd"
xmlns="urn:infinispan:config:15.0">
<jgroups>
<stacks>
<stack name="keycloak-stack" extends="tcp">
<TCP
bind_addr="${jgroups.bind.address,jgroups.tcp.address:192.168.0.2}"
bind_port="${jgroups.bind.port,jgroups.tcp.port:7600}"
thread_naming_pattern="pl"
send_buf_size="640k"
sock_conn_timeout="300"
bundler_type="transfer-queue"
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"
/>
<TCPPING initial_hosts="${jgroups.tcpping.initial_hosts:192.168.0.2[7600],192.168.0.3[7600],192.168.0.4[7600],192.168.0.5[7600]}"
stack.combine="REPLACE" stack.position="MPING"/>
<FD_ALL3 stack.combine="COMBINE" interval="1000" timeout="4000"/>
</stack>
</stacks>
</jgroups>
<cache-container name="keycloak">
<transport lock-timeout="60000"/>
<transport cluster="${infinispan.cluster.name:keycloak-cluster}"
stack="keycloak-stack"
node-name="${infinispan.node.name:KeycloakNode01}"/>
<local-cache name="realms" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<memory max-count="10000"/>
</local-cache>
<local-cache name="users" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<memory max-count="10000"/>
</local-cache>
<distributed-cache name="sessions" owners="2">
<expiration lifespan="-1"/>
<memory max-count="10000"/>
</distributed-cache>
<distributed-cache name="authenticationSessions" owners="2">
<expiration lifespan="-1"/>
</distributed-cache>
<distributed-cache name="offlineSessions" owners="2">
<expiration lifespan="-1"/>
<memory max-count="10000"/>
</distributed-cache>
<distributed-cache name="clientSessions" owners="2">
<expiration lifespan="-1"/>
<memory max-count="10000"/>
</distributed-cache>
<distributed-cache name="offlineClientSessions" owners="2">
<expiration lifespan="-1"/>
<memory max-count="10000"/>
</distributed-cache>
<distributed-cache name="loginFailures" owners="2">
<expiration lifespan="-1"/>
</distributed-cache>
<local-cache name="authorization" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<memory max-count="10000"/>
</local-cache>
<replicated-cache name="work">
<expiration lifespan="-1"/>
</replicated-cache>
<local-cache name="keys" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<expiration max-idle="3600000"/>
<memory 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" lifespan="-1" interval="300000"/>
<memory max-count="-1"/>
</distributed-cache>
</cache-container>
</infinispan>
<stack name=»keycloak-stack» extends=»tcp»> — Название вашего стека, задается по желанию
bind_addr=»${jgroups.bind.address,jgroups.tcp.address:192.168.0.2}» — Указывается адрес текущего сервера Keycloak.
bind_port=»${jgroups.bind.port,jgroups.tcp.port:7600}» — порт, который слушает Keycloak, менять необязательно, но если хочется, то можно.
initial_hosts=»${jgroups.tcpping.initial_hosts:192.168.0.2[7600],192.168.0.3[7600],192.168.0.4[7600],192.168.0.5[7600]}» — Список серверов,которые участвуют в кластере
<FD_ALL3 stack.combine=»COMBINE» interval=»1000″ timeout=»4000″/> — interval определяет, как часто узлы опрашивают друг друга, timeout — через сколько миллисекунд сервер считается отказавшим и выкидывается из кластера.
<transport cluster=»${infinispan.cluster.name:keycloak-cluster}» — Название вашего кластера Keycloak
stack=»keycloak-stack» — Стэк, к которому привязываются параметры репликации распределенных кэшей. Должен совпадать с названием стэка, которое задается параметром stack name
node-name=»${infinispan.node.name:KeycloakNode01}» — Название ноды Keycloak, должно быть уникальным в пределах кластера, разрешается любое понятное название
У каждого распределенного кэша есть параметр owners=»X», где X > 1 и X =< N, где N количество узлов кластера
Остальные параметры на начальном этапе можно не трогать и уже править по мере необходимости.
В текущей конфигурации Keycloak выдерживает 6000 авторизаций за 2 минуты и не падает.
По завершении редактирования файла на каждом сервере, необходимо запустить один за другим все узлы командой systemctl start keycloak.
Можно запустить сразу все узлы, но тогда первые 5 минут в кластере будет идти революция, связанная с выборами координатора.
Если запускать с интервалом в 2 минуты, то первый запущенный сервер станет координатором, и все остальные сервера привяжутся к нему.
Настройка балансировщика для Keycloak
В качестве балансировщика я использую Nginx, поэтому приведу пример для него.
upstream keycloak {
ip_hash;
server 192.168.0.2:8080 max_fails=3 fail_timeout=10s;
server 192.168.0.3:8080 max_fails=3 fail_timeout=10s;
server 192.168.0.4:8080 max_fails=3 fail_timeout=10s;
server 192.168.0.5:8080 max_fails=3 fail_timeout=10s;
keepalive 4096;
}
upstream keycloak_health {
ip_hash;
server 192.168.0.2:8080 max_fails=3 fail_timeout=10s;
server 192.168.0.3:8080 max_fails=3 fail_timeout=10s;
server 192.168.0.4:8080 max_fails=3 fail_timeout=10s;
server 192.168.0.5:8080 max_fails=3 fail_timeout=10s;
keepalive 4096;
}
server {
server_name keycloak_fqdn;
include snippets/redirect2https;
}
server {
server_name keycloak_fqdn;
access_log /var/log/nginx/keycloak_fqdn.access.log main;
error_log /var/log/nginx/keycloak_fqdn.error.log;
include snippets/https;
include ssl/ssl.cfg;
include snippets/big-header;
location / {
proxy_pass http://keycloak;
include snippets/proxy;
include snippets/HAConfig;
}
location ~* /health {
allow 192.168.0.0/24;
deny all;
include snippets/streamproxy;
include snippets/HAConfig;
proxy_pass http://keycloak_health;
}
location /admin/ {
allow 192.168.0.0/24;
deny all;
include snippets/proxy;
proxy_pass http://keycloak;
include snippets/HAConfig;
}
location /realms/master/ {
allow 192.168.0.0/24;
deny all;
include snippets/proxy;
include snippets/HAConfig;
proxy_pass http://keycloak;
}
}
Параметр ip_hash нужен, чтобы запросы от одного пользователя шли на конкретный сервер Keycloak, поскольку кэши синхронизируются не так быстро, как идут запросы и есть риск получить 403 из-за несовпадения токенов, поскольку запрос на аутентификацию пришел на первый сервер, а запрос на получение токена на второй, а второй сервер еще не знает, что клиент запросил аутентификацию
В HAConfig указаны параметры, чтобы Nginx перенаправил запрос на другой сервер, если первый не ответил, при этом не отображая пользователю ошибку
proxy_connect_timeout 3s;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504 non_idempotent;
proxy_next_upstream_timeout 15s;
proxy_next_upstream_tries 2;
В snippets/proxy указаны дополнительные параметры
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header REMOTE_ADDR $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Remote-Port $remote_port;
proxy_set_header X-Url-Scheme $scheme;
Необходимо, чтобы параметры
proxy_http_version 1.1;
proxy_set_header Connection "";
обязательно присутствовали, если на реверс-прокси у вас есть сайты, использующие веб-сокеты.
В snippets/big-header указываются параметры для хэдеров, поскольку некоторые сайты при авторизации могут быть выставлять огромные куки для авторизации.
proxy_buffer_size 256k;
proxy_buffers 8 256k;
proxy_busy_buffers_size 256k;
large_client_header_buffers 4 64k;
Нюансы работы кластера Keycloak
В кластере Keycloak все узлы равнозначны, но всегда есть координатор, который управляет работой кластера.
Поэтому при необходимости перезапустить весь кластер необходимо проверить, кто координатор и его перезапускать в последнюю очередь, поскольку тогда выборы нового координатора пройдут быстрее, в противном случае в кластере опять начнется революция.
Самый легкий способ определить координатора это выполнить следующую команду. Для выполнения этой команды обязательно должно быть включено логирование.
grep "Received new cluster view for channel keycloak-cluster" /var/log/keycloak.log | tail -n1 | sed -E 's/.*\[(.*)\].*/\1/' | cut -d',' -f1 | xargs
Команда выдаст имя текущего координатора кластера, его и необходимо перезапускать в последнюю очередь.