Keycloak ― построение отказоустойчивого кластера

от автора

Разворачивая у нас в tutu Keycloak, мы столкнулись с необходимостью создания отказоустойчивого кластера. И если с БД всё более-менее понятно, то вот реализовать корректный обмен кешами между Keycloak оказалось довольно непростой для настройки задачей.

Мы упёрлись в то, что в документации Keycloak описано, как создать кластер, используя UDP-мультикаст. И это работает, если у вас все ноды будут находиться в пределах одного сегмента сети (например, ЦОДа). Если с этим сегментом что-то случится, то мы лишимся Keycloak. Нас это не устраивало.

Необходимо сделать так, чтобы ноды приложения были географически распределены между ЦОДами, находясь в разных сегментах сети.

В этом случае в документации Keycloak довольно неочевидно предлагается создать свой собственный кастомный JGroups транспортный стек, чтобы указать все необходимые вам параметры.

Бонусом приложу shell-скрипт, написанный для Consul, который предназначен для снятия анонсов путём выключения bird и попытки восстановления приложения.

Особенности

Нами была выбрана инсталляция без контейнеризации, приложение завёрнуто в systemd-сервис.

Keycloak может принять конфигурацию из четырёх разных источников:

  • CLI: kc.sh —key=value.

  • Переменная окружения: KC_KEY=value.

  • Файл конфигурации: key=value.

  • Файл Java KeyStore: kc.key=value.

Когда в туториале будет заходить речь про добавление переменной в конфигурацию, то подразумевается, что вы сами выбираете удобный для вас вариант. 
В туториале я буду описывать передачу параметров через файл конфигурации.

Дано

  • Нода keycloak1.

  • Keycloak версии 20, завёрнутая в systemd-сервис.

  • Интерфейс eth0 с локальным IP-адресом виртуалки. Каждой ноде этот адрес должен быть доступен.

  • Интерфейс eth1, в котором через bgp анонсируется anycast IP-адрес.

  • Отказоустойчивая база данных за пределами Keycloak, к которой мы подключаем приложение.

Задача

Сделать Keycloak отказоустойчивым и геораспределённым.

Нам нужно создать кластер, в котором можно жёстко прибить адреса нод в конфигурации.
Для этого надо создать custom transport stack.

TCPPING

Остановим Keycloak.

Скопируем файл conf/cache-ispn.xml в новый файл conf/custom-cache-ispn.xml.

Добавим в секцию infinispan следующее:

  <jgroups>         <stack name="add_tcpping" extends="tcp">             <TCPPING initial_hosts="<eth0_ip_keycloak1>[7800],<eth0_ip_keyclaok2>[7800],<eth0_ip_keycloak3>[7800]"                      port_range="0"                      stack.combine="REPLACE"                      stack.position="MPING"             />         </stack>     </jgroups>     <cache-container name="keycloak">         <transport lock-timeout="60000" stack="add_tcpping"/>

stack name ― имя стека, который мы потом используем в секции transport. Можно указать что угодно. Имя стека будет писаться в логах.

initial_hosts ― перечисляем IP-адреса с портами всех наших Keycloak-нод.

port_range ― TCPPING будет пытаться связать с каждой из нод кластера, начиная с указанного порта + port_range. В нашем случае будет использоваться только порт 7800.

stack.combine ― способ изменения параметров протокола. REPLACE заменяет протокол.

stack.position ― протокол, который мы меняем.

Теперь надо в конфигурации задать с помощью переменной cache-config-file наш .xml-файл, а также переменной http-host указать anycast-адрес (cache=ispn ― это дефолтное значение):

cache=ispn cache-config-file=cache-ispn-tcpping.xml   http-host=<anycast_eth1_ip>

Из-за того, что мы используем anycast-адрес, надо указать IP-адрес хоста, по которому infinispan будет слушать порт 7800. Для этого при запуске сервера нам надо явно задать основной IP-адрес ноды:

bin/kc.sh start -Djgroups.bind.address=<eth0_ip>

После этого мы должны увидеть в логах, что JGroups запускается со стеком add_tcpping:

2023-04-21 10:40:54,586 INFO  [org.infinispan.CLUSTER] (keycloak-cache-init) ISPN000078: Starting JGroups channel `ISPN` with stack `add_tcpping`

При запуске остальных нод с такой конфигурацией мы увидим, что кластер обнаружил новый хост и добавил его:

2023-04-21 10:41:02,197 INFO  [org.infinispan.CLUSTER] (jgroups-12,keycloak1-57393) ISPN100000: Node keycloak2-60977 joined the cluster 2023-04-21 10:41:02,643 INFO  [org.infinispan.CLUSTER] (jgroups-5,keycloak1-57393) [Context=authenticationSessions] ISPN100002: Starting rebalance with members [keycloak1-57393, keycloak2-60977], phase READ_OLD_WRITE_ALL, topology id 7 2023-04-21 10:41:06,963 INFO  [org.infinispan.CLUSTER] (jgroups-12,keycloak1-57393) ISPN100000: Node keycloak3-7710 joined the cluster 2023-04-21 10:41:07,242 INFO  [org.infinispan.CLUSTER] (jgroups-12,keycloak1-57393) [Context=authenticationSessions] ISPN100002: Starting rebalance with members [keycloak1-57393, keycloak2-60977, keycloak3-7710], phase READ_OLD_WRITE_ALL, topology id 11

Готово!

Объяснение

Понять, что мы сейчас настроили в .xml-файле, нам помог дефолтный конфиг стека TCP, находящегося по пути:

lib/lib/main/org.infinispan.infinispan-core-<version>.jar/default-configs/default-jgroups-tcp.xml

Там мы можем увидеть, что в качестве протокола обнаружения используется MPING. В conf/custom-cache-ispn.xml c помощью stack.position мы выбираем MPING, а с помощью stack.combine заменяем его на TCPPING.

HashiCorp Consul

Вы настроили anycast (у нас анонсируется адрес с помощью bird), кластеризацию, но вам надо как-то снимать анонсы, если с приложением что-то случится. Вариантов много, я рассмотрю используемый нами.

В этом туториале я не буду разбирать, как настраивать консул, рассмотрим лишь shell-скрипт, который запускается с его помощью раз в 15 секунд.

Keycloak имеет встроенный healthcheck, на его основе и построим проверку.
Чтобы включить его, надо в конфигурации задать переменную:

health-enabled=true

После этого у приложения становятся доступны следующие эндпоинты:

/health /health/live /health/ready

Будем отслеживать последний эндпоинт, так как там есть проверка подключения к базе данных. Её тоже будем отслеживать:

function keycloak_healthcheck {   app_status=$(curl -sk https://127.0.0.1/health/ready | python -c "import sys, json; print(json.load(sys.stdin)['status'])" 2>/dev/null)   db_status=$(curl -sk https://127.0.0.1/health/ready | python -c "import sys, json; print(json.load(sys.stdin)['checks'][0]['status'])" 2>/dev/null)     if [ "$app_status" != "UP" ] || [ "$db_status" != "UP" ]     then       healthcheck=1     else       healthcheck=0   fi }

Также попытаемся один раз восстановить работу Keycloak ребилдом приложения:

function keycloak_recover {   echo $(date +%s) > $tmp_recover   cmd="systemctl stop keycloak && <keycloak_dir>/bin/kc.sh build >/dev/null && systemctl start keycloak"   timeout 50s bash -c "$cmd" & disown }

Запуск ребилда в фоне позволяет нам запускать скрипт сколько угодно часто, чтобы как можно быстрее реагировать на упавшее приложение и выключать bird.service.

Ну и для управления всем этим безобразием создаём tmp-файл для отслеживания времени запуска восстановления:

tmp_recover="/tmp/keycloak_recover_try" touch $tmp_recover recover_try=$(cat $tmp_recover)

Подробная настройка консула выходит за рамки данного туторила.
Собираем это всё вместе в скрипт:

Целиком скрипт
#!/bin/bash   keyclaok_dir="<keycloak_dir>" tmp_recover="/tmp/keycloak_recover_try" touch $tmp_recover   function disable_bird {   pgrep bird > /dev/null 2>&1   bird_status=$?   if [[ "$bird_status"  == "1" ]]     then       echo "Bird disabled"     else       /bin/systemctl stop bird       echo "Bird disabled"   fi }   function enable_bird {   pgrep bird > /dev/null 2>&1   bird_status=$?   if [[ "$bird_status"  == "1" ]]     then       echo "Bird enabled"     then       /bin/systemctl start bird       echo "Bird enabled"   fi }   function keycloak_healthcheck {   app_status=$(curl -sk https://127.0.0.1/health/ready | python -c "import sys, json; print(json.load(sys.stdin)['status'])" 2>/dev/null)   db_status=$(curl -sk https://127.0.0.1/health/ready | python -c "import sys, json; print(json.load(sys.stdin)['checks'][0]['status'])" 2>/dev/null)     if [ "$app_status" != "UP" ] || [ "$db_status" != "UP" ]     then       healthcheck=1     else       healthcheck=0   fi }   # Попытка восстановления, запущенная в background с таймаутом function keycloak_recover {   echo $(date +%s) > $tmp_recover   cmd="systemctl stop keycloak && <keycloak_dir>/bin/kc.sh build >/dev/null && systemctl start keycloak"   timeout 50s bash -c "$cmd" & disown }   keycloak_healthcheck # Когда запускалось восстановление recover_try=$(cat $tmp_recover) # Если восстановление запускалось, то подсчитываем сколько секунд с тех пор прошло if [[ ! -z "$recover_try" ]]   then     let "try_s = $(date +%s) - $recover_try" fi   if [[ "$healthcheck" == 0 ]]   then     enable_bird     echo "Keycloak is ok"     echo "" > $tmp_recover     exit 0     elif [[ "$healthcheck" != 0 ]]   then     disable_bird       if [[ -z "$recover_try" ]]       then         keycloak_recover         exit 2       elif [[ ! -z "$recover_try" && "$try_s" -ge 60 ]]       then           if [[ "$app_status" != "UP" ]]           then             echo "Keycloak service is down, bird disabled"         elif [[ "$db_status" != "UP" ]]           then             echo "Keycloak database problem, bird disabled"         fi         exit 2     fi fi

И настраиваем конфиг консула:

{   "check": {   "id": "Keycloak",   "name": "Keycloak healthcheck",   "args": ["/opt/consul/check/script-check-keycloak.sh"],   "interval": "15s",   "timeout": "15s"   } }

Заключение

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

Мы живём в такой конфигурации уже более полугода, и за это время она ни разу не давала сбой. За исключением не зависящих от кластера ситуаций.

Источники информации

Keycloak on Distributed SQL (CockroachDB) — Part 2/2

Guide to Infinispan Server

Configuring distributed caches


ссылка на оригинал статьи https://habr.com/ru/articles/766284/