Разворачивая у нас в 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

Комментарии (10)


  1. MalevMih
    10.10.2023 13:28

    Можно уточнить, какую скорость failover удалось достигнуть таким образом?


    1. Okunev_Sergey Автор
      10.10.2023 13:28

      Все узлы в данной конфигурации работают в режиме active / active, точную скорость не мерял, но в случае отказа infinispan почти сразу удаляет ноду из кластера, остаётся разве что снять анонсы bgp. Этим у меня занимается healthcheck скрипт.


  1. KorDen32
    10.10.2023 13:28

    и если с БД всё более-менее понятно

    Прошлись бы мимоходом, какую СУБД используете и как кластеризуете.

    Скажем, красивые инструкции по запуску Keycloak на CockroachDB на практике не такие красивые - выполнить последующий апгрейд даже на минорную версию будет невозможно из-за несовместимости при обработке транзакций.


    1. Okunev_Sergey Автор
      10.10.2023 13:28

      Посчитал, что это выходит за рамки статьи. Всё достаточно обыденно, но это я ещё не обновлял ни разу keycloak, только предстоит.

      Используем в качестве БД PostgreSQL 15, кластеризуем через Patroni 3.0.2, балансируем нагрузку с помощью HAProxy.


      1. KorDen32
        10.10.2023 13:28

        Как раз при использовании обычного PostgreSQL проблем на уровне Keycloak нет.

        А вот при использовании Postrgres-совместимой CockroachDB, например как описано в статье, на которую вы сослались в источниках, вылезают проблемы именно в момент апгрейда. Заподозрить неладное можно уже из предлагаемого пути установки: сначала предлагается ставить на PostgreSQL, а затем импортировать дамп в CockroachDB.


  1. rakhinskiy
    10.10.2023 13:28

    А как же настройка самого Infinispan?

    Если я правильно помню, то по умолчанию он хранит только один экземпляр в кэше и если перезапустить keycloak на одной из нод, то все сессии на ней пропадут. Нужно явно указать количество owners для записей.

    Keycloak replication


    1. Okunev_Sergey Автор
      10.10.2023 13:28

      Посмотрел, в нашей конфигурации keycloak 20.0.3 по умолчанию проставлено owners="2" для всех кэшей.

      Подобной настройки не производил, не думал в эту сторону, так как для нас подобные последствия из-за отказа одной из нод не являются значимыми. Но узнать было полезно, спасибо.


      1. rakhinskiy
        10.10.2023 13:28

        Я уже давно с keycloak не сталкивался, раньше видимо по умолчанию было owners="1", а так как там хранятся сессии, то upgrade бы приводил к logout части пользователей. На саму доступность сервиса это не влияет. В целом keycloak довольно удобен, но я сейчас присматриваюсь к zitadel (она по умолчанию использует CockroachDB)


  1. ufoton
    10.10.2023 13:28
    +1

    Мы сделали через

    --cache-stack=kubernetes
    -Djgroups.dns.query=dns-name

    dns-name возвращает несколько A записей.


    1. Okunev_Sergey Автор
      10.10.2023 13:28

      Честно говоря я исключил этот вариант сразу же, так как название стэка ввело в заблуждение, у нас же не в кубере разворачивается кейклок. Сейчас открыл его дефолтный конфиг и удивился. Оно, по факту, не относится напрямую к k8s, странный нейминг.

      Полезно было узнать, спасибо.