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

8713a6f2bc7464b7d411493b6e01519f.png

Разворачивая у нас в 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 следующее:

  
        
            
        
    
    
        

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-адрес, надо указать IP-адрес хоста, по которому infinispan будет слушать порт 7800. Для этого при запуске сервера нам надо явно задать основной IP-адрес ноды:

bin/kc.sh start -Djgroups.bind.address=

После этого мы должны увидеть в логах, что 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-.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 && /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=""
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 && /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"
  }
}

Заключение

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

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

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

https://dantheengineer.com/keycloak-on-distributed-sql-cockroach-part-2–2/
https://infinispan.org/docs/stable/titles/server/server.html
https://www.keycloak.org/server/caching

© Habrahabr.ru