# Keycloak HA cluster

<https://habr.com/ru/companies/tuturu/articles/766284/>

![](/files/HPzOhf4DuNin7iOOqXRo)

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

### Заключение

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

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

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

<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>


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://book.konstantinsecurity.com/readme/architect/identity-and-access-management-idm/keycloak/keycloak-ha-cluster.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
