Разворачивая у нас в 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"
}
}
Заключение
Данная конфигурация позволила очень легко масштабировать приложение и сделать его геораспределённым. Конечно это далеко не всё, что можно сделать для отказоустойчивости, но, пожалуй, необходимый минимум.
Мы живём в такой конфигурации уже более полугода, и за это время она ни разу не давала сбой. За исключением не зависящих от кластера ситуаций.