В этой статье мы поделимся опытом развертывания в кластере Kubernetes устойчивой и масштабируемой инсталляции популярного решения для обеспечения «единого входа» (SSO) — Keycloak в связке с Infinispan (для кэширования пользовательских метаданных).

Keycloak и область применения

Keycloak – проект с открытым исходным кодом компании Red Hat, предназначенный для управления аутентификацией и авторизацией в приложениях, функционирующих на серверах приложений WildFly, JBoss EAP, JBoss AS и прочих web-серверах. Keycloak упрощает реализацию защиты приложений, предоставляя им бэкенд авторизации практически без дополнительного кода. За подробной информацией о том, как это осуществляется, можно обратиться к этому руководству.

Как правило, Keycloak устанавливается на отдельный виртуальный или выделенный сервер приложений WildFly. Пользователи однократно аутентифицируются с помощью Keycloak для всех приложений, интегрированных с данным решением. Таким образом, после входа в Keycloak пользователям не нужно снова входить в систему для доступа к другому приложению. Аналогично происходит и с выходом.

Для хранения своих данных Keycloak поддерживает работу с рядом наиболее популярных реляционных систем управления базами данных (РСУБД): Oracle, MS SQL, MySQL, PostgreSQL. В нашем случае использовалась CockroachDB — современная распределенная СУБД (изначально Open Source, а впоследствии — под BSL), которая обеспечивает согласованность данных, масштабируемость и устойчивость к авариям. Одной из её приятных особенностей является совместимость с PostgreSQL на уровне протокола.

Кроме того, в своей работе Keycloak активно использует кэширование: кэшируются пользовательские сессии, авторизационные и аутентификационные токены, успешные и неуспешные попытки авторизации. По умолчанию для хранения всего этого используется Infinispan. На ней мы остановимся подробнее.

Infinispan

Infinispan — это масштабируемая, высокодоступная платформа для хранения данных типа ключ-значение, написанная на Java и распространяемая под свободной лицензией (Apache License 2.0). Основная область применения Infinispan — распределенный кэш, но также её применяют как KV-хранилище в базах данных типа NoSQL.

Платформа поддерживает два способа запуска: развертывание в качестве отдельно-стоящего сервера / кластера серверов и использование в виде встроенной библиотеки для расширения функций основного приложения.

KC в конфигурации по умолчанию использует встроенный кэш Infinispan. Он позволяет настраивать распределенные кэши, чтобы репликация и перекаты данных осуществлялись без простоя. Таким образом, даже если мы полностью отключим сам KC, а потом поднимем его обратно, авторизованных пользователей это не затронет.

Сам IS хранит всё в памяти, а на случай переполнения (или полного отключения IS) можно настроить сбрасывание его данных в БД. В нашем случае эту функцию выполняет CockroachDB.

Постановка задачи

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

  1. Обеспечить надежность/устойчивость к авариям, высокую доступность.

  2. Сохранить пользовательские данные (сессии, токены) при потенциальном переполнении памяти.

Описание инфраструктуры и архитектуры решения

Изначально KC был запущен в 1 реплике и настройками кэширования по умолчанию, т.е. использовался встроенный Infinispan, который все держал в памяти. Источником данных был кластер CockroachDB.

Для обеспечения надежности потребовалось развернуть несколько реплик KC. Keycloak позволяет это сделать, используя несколько механизмов автообнаружения. В первой итерации мы сделали 3 реплики KC, использующих IS в качестве модуля/плагина:

К сожалению, IS, используемый как модуль, предоставлял недостаточно возможностей для настройки поведения кэшей (кол-во записей, объем занимаемой памяти, алгоритмы вытеснения в постоянное хранилище) и предлагал только файловую систему как постоянное хранилище для данных.

Поэтому на следующей итерации мы развернули отдельный кластер Infinispan и отключили встроенный модуль IS в настройках Keycloak:

Решение было развернуто в кластере Kubernetes. Keycloak и Infinispan запущены в одном namespace по 3 реплики. За основу для такой инсталляции был взят этот Helm-чарт. CockroachDB разворачивалась в отдельном пространстве имен и использовалась совместно с другими компонентами клиентского приложения.

Практическая реализация

Полные примеры Helm-шаблонов доступны в нашем репозитории flant/examples.

1. Keycloak

КС поддерживает несколько режимов запуска: standalone, standalone-ha, domain cluster, DC replication. Режим standalone-ha является идеальным вариантом для запуска в Kubernetes, потому что легко добавлять/удалять реплики, общий конфиг-файл хранится в ConfigMap, правильно выбранная стратегия развертывания обеспечивает доступность узлов при обновлении ПО.

Хотя для KC не требуется постоянного файлового хранилища (PV/PVC) и можно было выбрать тип Deployment, мы используем StatefulSet. Это делается для того, чтобы задавать имя узлов в Java-переменной jboss.node.name при настройке обнаружения узлов на основе DNS_PING. Длина этой переменной должна быть меньше 23 символов.

Для настройки KC используются:

  • переменные окружения, которые задают режимы работы KC (standalone, standalone-ha и т.д.);

  • конфигурационный файл /opt/jboss/keycloak/standalone/configuration/standalone-ha.xml, который позволяет выполнить максимально полную и точную настройку Keycloak;

  • переменные JAVA_OPTS, определяющие поведение Java-приложения. 

По умолчанию KC запускается со standalone.xml — этот конфиг сильно отличается от HA-версии. Для получения нужной нам конфигурации добавим в values.yaml:

# Additional environment variables for Keycloak
extraEnv: |
…
   - name: JGROUPS_DISCOVERY_PROTOCOL
     value: "dns.DNS_PING"
   - name: JGROUPS_DISCOVERY_PROPERTIES
     value: "dns_query={{ template "keycloak.fullname". }}-headless.{{ .Release.Namespace }}.svc.{{ .Values.clusterDomain }}"
   - name: JGROUPS_DISCOVERY_QUERY
     value: "{{ template "keycloak.fullname". }}-headless.{{ .Release.Namespace }}.svc.{{ .Values.clusterDomain }}"

После первого запуска можно достать из pod’а c KC нужный конфиг и на его основе подготовить .helm/templates/keycloak-cm.yaml:

$ kubectl -n keycloak cp keycloak-0:/opt/jboss/keycloak/standalone/configuration/standalone-ha.xml /tmp/standalone-ha.xml

После получения файла переменные JGROUPS_DISCOVERY_PROTOCOL и JGROUPS_DISCOVERY_PROPERTIES можно переименовать или удалить, чтобы KC не пытался создавать этот файл при каждом повторном деплое.

Устанавливаем JAVA_OPTS в .helm/values.yaml:

java:
  _default: "-server -Xms64m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true -Djboss.modules.system.pkgs=org.jboss.byteman --add-exports=java.base/sun.nio.ch=ALL-UNNAMED --add-exports=jdk.unsupported/sun.misc=ALL-UNNAMED --add-exports=jdk.unsupported/sun.reflect=ALL-UNNAMED -Djava.awt.headless=true -Djboss.default.jgroups.stack=kubernetes -Djboss.node.name=${POD_NAME} -Djboss.tx.node.id=${POD_NAME} -Djboss.site.name=${POD_NAMESPACE} -Dkeycloak.profile.feature.admin_fine_grained_authz=enabled -Dkeycloak.profile.feature.token_exchange=enabled -Djboss.default.multicast.address=230.0.0.5 -Djboss.modcluster.multicast.address=224.0.1.106 -Djboss.as.management.blocking.timeout=3600"

 Для корректной работы DNS_PING указываем:

-Djboss.node.name=${POD_NAME}, -Djboss.tx.node.id=${POD_NAME} -Djboss.site.name=${POD_NAMESPACE} и -Djboss.default.multicast.address=230.0.0.5 -Djboss.modcluster.multicast.address=224.0.1.106 

Все остальные манипуляции проводим с .helm/templates/keycloak-cm.yaml.

Подключение базы:

            <subsystem xmlns="urn:jboss:domain:datasources:6.0">
                <datasources>
                    <datasource jndi-name="java:jboss/datasources/KeycloakDS" pool-name="KeycloakDS" enabled="true" use-java-context="true" use-ccm="true">
                        <connection-url>jdbc:postgresql://${env.DB_ADDR:postgres}/${env.DB_DATABASE:keycloak}${env.JDBC_PARAMS:}</connection-url>
                        <driver>postgresql</driver>
                        <pool>
                            <flush-strategy>IdleConnections</flush-strategy>
                        </pool>
                        <security>
                            <user-name>${env.DB_USER:keycloak}</user-name>
                            <password>${env.DB_PASSWORD:password}</password>
                        </security>
                        <validation>
                            <check-valid-connection-sql>SELECT 1</check-valid-connection-sql>
                            <background-validation>true</background-validation>
                            <background-validation-millis>60000</background-validation-millis>
                        </validation>
                    </datasource>
                    <drivers>
                        <driver name="postgresql" module="org.postgresql.jdbc">
                            <xa-datasource-class>org.postgresql.xa.PGXADataSource</xa-datasource-class>
                        </driver>
                    </drivers>
                </datasources>
            </subsystem>
            <subsystem xmlns="urn:jboss:domain:ee:5.0">
            …
                 <default-bindings context-service="java:jboss/ee/concurrency/context/default" datasource="java:jboss/datasources/KeycloakDS" managed-executor-service="java:jboss/ee/concurrency/executor/default" managed-scheduled-executor-service="java:jboss/ee/concurrency/scheduler/default" managed-thread-factory="java:jboss/ee/concurrency/factory/default"/>
            </subsystem>

Настройки кэшей:

           <subsystem xmlns="urn:jboss:domain:infinispan:11.0">
                <cache-container name="keycloak" module="org.keycloak.keycloak-model-infinispan">
                    <transport lock-timeout="60000"/>
                     <local-cache name="realms">
                              <heap-memory size="10000"/>
                    </local-cache>
                        <!-- В локальном кэше храним users, authorization и keys - аналогично realms -->
                    <replicated-cache name="work"/>
                    
                    <distributed-cache name="authenticationSessions" owners="${env.CACHE_OWNERS_AUTH_SESSIONS_COUNT:1}">
                      <remote-store cache="authenticationSessions" remote-servers="remote-cache" passivation="false" preload="false" purge="false" shared="true">
                        <property name="rawValues">true</property>
                        <property name="marshaller">org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory</property>
                      </remote-store>
                    </distributed-cache>
                      <!-- В отдельно стоящем IS - sessions,  offlineSessions, clientSessions, offlineClientSessions, loginFailures и actionTokens -->

                      <!-- Для actionTokens устанавливаем owners = env.CACHE_OWNERS_AUTH_SESSIONS_COUNT (>=2) - для их сохранности в момент редеплоя -->
                </cache-container>
            </subsystem>

Настройки JGROUPS и DNS_PING:

            <subsystem xmlns="urn:jboss:domain:jgroups:8.0">
                <channels default="ee">
                              <channel name="ee" stack="tcp" cluster="ejb"/>
                </channels>
                <stacks>
                     <stack name="udp">
                        <transport type="UDP" socket-binding="jgroups-udp"/>
                        <protocol type="dns.DNS_PING">
                            <property name="dns_query">${env.JGROUPS_DISCOVERY_QUERY}</property>
                        </protocol>
                        ...
                    </stack>
                    <stack name="tcp">
                        <transport type="TCP" socket-binding="jgroups-tcp"/>
                        <protocol type="dns.DNS_PING">
                            <property name="dns_query">${env.JGROUPS_DISCOVERY_QUERY}</property>
                        </protocol>
                        ...
                    </stack>
                </stacks>
            </subsystem>
        <socket-binding-group name="standard-sockets" default-interface="public" port-offset="${jboss.socket.binding.port-offset:0}">
            <socket-binding name="ajp" port="${jboss.ajp.port:8009}"/>
            <socket-binding name="http" port="${jboss.http.port:8080}"/>
            <socket-binding name="https" port="${jboss.https.port:8443}"/>
            <socket-binding name="jgroups-mping" interface="private" multicast-address="${jboss.default.multicast.address:230.0.0.4}" multicast-port="45700"/>
            <socket-binding name="jgroups-tcp" interface="private" port="7600"/>
            <socket-binding name="jgroups-tcp-fd" interface="private" port="57600"/>
            <socket-binding name="jgroups-udp" interface="private" port="55200" multicast-address="${jboss.default.multicast.address:230.0.0.4}" multicast-port="45688"/>
            <socket-binding name="jgroups-udp-fd" interface="private" port="54200"/>
            <socket-binding name="management-http" interface="management" port="${jboss.management.http.port:9990}"/>
            <socket-binding name="management-https" interface="management" port="${jboss.management.https.port:9993}"/>
            <socket-binding name="modcluster" multicast-address="${jboss.modcluster.multicast.address:224.0.1.105}" multicast-port="23364"/>
            <socket-binding name="txn-recovery-environment" port="4712"/>
            <socket-binding name="txn-status-manager" port="4713"/>
        </socket-binding-group>

Наконец, подключаем внешний Infinispan:

        <socket-binding-group name="standard-sockets" default-interface="public" port-offset="${jboss.socket.binding.port-offset:0}">
           …
            <outbound-socket-binding name="remote-cache">
                <remote-destination host="${env.INFINISPAN_SERVER}" port="11222"/>
            </outbound-socket-binding>
           …
        </socket-binding-group>

Подготовленный XML-файл монтируем в контейнер из ConfigMap’а .helm/templates/keycloak-cm.yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: keycloak-stand
spec:
  serviceName: keycloak-stand-headless
  template:
    spec:
      containers:
        image: registry.host/keycloak
        name: keycloak
        volumeMounts:
        - mountPath: /opt/jboss/keycloak/standalone/configuration/standalone-ha.xml
          name: standalone
          subPath: standalone.xml
      volumes:
      - configMap:
          defaultMode: 438
          name: keycloak-stand-standalone
        name: standalone

2. Infinispan

Настройка Infinispan гораздо легче, чем KC, поскольку шаги с генерацией нужного конфиг-файла отсутствуют.

Достаем конфиг по умолчанию /opt/infinispan/server/conf/infinispan.xml из Docker-образа infinispan/server:12.0 и на его основе готовим .helm/templates/infinispan-cm.yaml.

Первым делом настраиваем auto-discovery. Для этого устанавливаем уже знакомые нам переменные окружения в .helm/templates/infinispan-sts.yaml:

        env:
{{- include "envs" . | indent 8 }}
        - name: POD_IP
          valueFrom:
            fieldRef:
              fieldPath: status.podIP
        - name: JGROUPS_DISCOVERY_PROTOCOL
          value: "dns.DNS_PING"
        - name: JGROUPS_DISCOVERY_PROPERTIES
          value: dns_query={{ ( printf "infinispan-headless.keycloak-%s.svc.cluster.local" .Values.global.env ) }}

… и добавляем секцию jgroups в XML-конфиг:

        <jgroups>
            <stack name="image-tcp" extends="tcp">
                <TCP bind_addr="${env.POD_IP}" bind_port="${jgroups.bind.port,jgroups.tcp.port:7800}" enable_diagnostics="false"/>
                <dns.DNS_PING dns_address="" dns_query="${env.INFINISPAN_SERVER}" dns_record_type="A" stack.combine="REPLACE" stack.position="MPING"/>
            </stack>
            <stack name="image-udp" extends="udp">
                <UDP enable_diagnostics="false" port_range="0" />
                <dns.DNS_PING dns_address="" dns_query="${env.INFINISPAN_SERVER}" dns_record_type="A" stack.combine="REPLACE" stack.position="PING"/>
                <FD_SOCK client_bind_port="57600" start_port="57600"/>
            </stack>
        </jgroups>

Для корректной работы Infinispan c CockroachDB нам пришлось пересобрать образ Infinispan, добавив в него новую версию SQL-драйвера PostgreSQL. Для сборки использовалась утилита werf с таким простым werf.yaml:

---
image: infinispan
from: infinispan/server:12.0
git:
- add: /jar/postgresql-42.2.19.jar
  to: /opt/infinispan/server/lib/postgresql-42.2.19.jar
shell:
  setup: |
    chown -R 185:root /opt/infinispan/server/lib/

Добавим в XML-конфиг секцию <data-source>:

            <data-sources>
              <data-source name="ds" jndi-name="jdbc/datasource" statistics="true">
                  <connection-factory driver="org.postgresql.Driver" username="${env.DB_USER:keycloak}" password="${env.DB_PASSWORD:password}" url="jdbc:postgresql://${env.DB_ADDR:postgres}:${env.DB_PORT:26257}/${env.DB_DATABASE:keycloak}${env.JDBC_PARAMS_IS:}" new-connection-sql="SELECT 1" transaction-isolation="READ_COMMITTED">
                    <connection-property name="name">value</connection-property>
                  </connection-factory>
                  <connection-pool initial-size="1" max-size="10"  min-size="3" background-validation="1000" idle-removal="1" blocking-timeout="1000" leak-detection="10000"/>
              </data-source>
            </data-sources>

В Infinispan мы должны описать те кэши, которые в KC были созданы с типом distributed-cache. Например, offlineSessions:

            <distributed-cache name="offlineSessions" owners="${env.CACHE_OWNERS_COUNT:1}" xmlns:jdbc="urn:infinispan:config:store:jdbc:12.0">
               <persistence passivation="false">
                   <jdbc:string-keyed-jdbc-store fetch-state="false" shared="true" preload="false">
                       <jdbc:data-source jndi-url="jdbc/datasource"/>
                       <jdbc:string-keyed-table drop-on-exit="false" create-on-start="true" prefix="ispn">
                           <jdbc:id-column name="id" type="VARCHAR(255)"/>
                           <jdbc:data-column name="datum" type="BYTEA"/>
                           <jdbc:timestamp-column name="version" type="BIGINT"/>
                           <jdbc:segment-column name="S" type="INT"/>
                       </jdbc:string-keyed-table>
                   </jdbc:string-keyed-jdbc-store>
               </persistence>
            </distributed-cache>

Таким же образом настраиваем и остальные кэши.

Подключение XML-конфига происходит аналогично тому, что мы рассматривали Keycloak.

На этом настройка Keycloak и Infinispan закончена. Повторюсь, что полные листинги доступны на GitHub: flant/examples.

Заключение

Использование Kubernetes в качестве фундамента позволило легко масштабировать решение, добавляя по мере необходимости или узлы Keycloak для обработки входящих запросов, или узлы Infinispan для увеличения емкости кэшей.

С момента сдачи данной работы клиенту прошло 2 месяца. Каких-либо жалоб и недостатков за этот период не выявлено. Поэтому можно считать, что поставленные цели достигнуты: мы получили устойчивое, масштабируемое решение для обеспечения SSO.

P.S.

Читайте также в нашем блоге:

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


  1. Wimbo
    15.07.2021 10:37
    +4

    Спасибо большое за статью и за примеры использования.

    Я бы хотел задать пару вопросов:

    • А почему выбрали метод DNS_PING, а не KUBE_PING

    • Снимаете ли вы метрики с WildFly и метрики самого Keycloak?

    • Есть опыт снятия метрик с Infinispan? Честно у меня так и не завелось.

    • Бывали ли у вас проблемы с таймаутами у infinispan? У меня были странные развалы кластера infinispan и пока проблема решилась только переключением на TCP с UDP.

    • livenessProbe/readinessProbe специально отсутствуют у Infinispan?

    • Бывали ли у вас проблемы с replicated cache у Infinispan? С режимом работы sync/async? Я пока перешел на sync из-за развала replicated cache.

    • А что это за IP-адреса в JAVA_OPTS ? -Djboss.default.multicast.address=230.0.0.5 -Djboss.modcluster.multicast.address=224.0.1.106

    • Тестировали ли вы реальную отказоустойчивость? Т.е. убивали ли поды Keycloak/Infinispan/Вырубали одну из нод в кластере. И как это все переживало эти тесты?

    • Проводили ли вы нагрузочное тестирование? Возможно есть какие-то утилиты для проведения таких тестов?

    • А точно ли это лучший способ? Может стоит заменить на корректный shutdown: jboss-cli.sh --connect command=':shutdown(timeout=30)'

    Год назад мне очень помог этот репозиторий и соответствующая статья.


    1. d9tetra Автор
      15.07.2021 14:10

      • Тестировали ли вы реальную отказоустойчивость? Т.е. убивали ли поды Keycloak/Infinispan/Вырубали одну из нод в кластере. И как это все переживало эти тесты?

      Поды - да, включали / выключали. С нодами - специально нет.


    1. darthslider
      05.08.2021 12:19

      Про метрики. мы используем вот этот плагин: https://github.com/aerogear/keycloak-metrics-spi
      И вот мой дашборд под них, который показывает всё, что нам нужно: https://grafana.com/grafana/dashboards/14607


  1. d9tetra Автор
    15.07.2021 14:07

    А что это за IP-адреса в JAVA_OPTS ? -Djboss.default.multicast.address=230.0.0.5 -Djboss.modcluster.multicast.address=224.0.1.106


    https://en.wikipedia.org/wiki/IP_multicast
    https://www.iana.org/assignments/multicast-addresses/multicast-addresses.xml


    1. Wimbo
      15.07.2021 16:07

      Хорошо, а почему именно эти IP-адреса? Они во всех кластерах везде будут одинаковые? Если я захочу 2 Keycloak в кластер, они тоже будут одинаковые?


  1. AlexGluck
    16.07.2021 20:13

    В дополнение спрошу, а почему не использовали JDBC_PING (про KUBE_PING уже спросили) и deployments.

    Зачем modcluster в данной настройке, можно просто балансить по clusterip?

    Multicast так же не нужен (он в большинстве инсталляций облаков\куберов не работает), все задачи jgroups выполняются на unicast.


  1. obraga
    19.07.2021 10:45

    Большое спасибо за статью! Очень интересно, ведь в русскоязычном интернете информации по keycloak в принципе не так много.
    Если позволите, несколько «оффтоп»-вопрос по keycloak, как к более опытным специалистам в данном вопросе:

    Пробую использовать keycloak в своем pet-проекте (пока больше в целях самообучения):
    Развернул в докере: keycloak, postgries БД для него, nginx со статическим vue приложением и arangodb+foxx в качестве основной базы приложении и REST API к ней.

    Стыковка Vue и Keycloak прошли без проблем — проверяется наличие актуального токена, если что кидается на форму авторизации, получаем оттуда токен обратно — дальше данные по пользователю получаю без проблем.

    А вот дальше начались проблемы.
    Я надеялся, что я полученный токен передам в свой REST API и там смогу его проверить — ну то есть уже со стороны серверного приложения внутри REST API дернуть какой-нибудь endpoint (например: auth/realms/{realm}/protocol/openid-connect/token/introspect или auth/realms/{realm}/protocol/openid-connect/userinfo) и подтвердив что он валидный и проверив доступы отдать пользователю то, что он просит.
    Но ни один из перечисленных endpoint не хочет признавать мой токен валидным при обращении с серверной стороны REST API.

    Так же пробовал делать валидацию без обращения к endpoint, но не смог найти рабочего решения под NodeJS для декодирования RS256, а keycloak только его сейчас и поддерживает насколько я понял.

    Подскажите пожалуста:

    1. Могут ли токены передаваться между разными клиентами? Ну то есть сайт получивший токен на чистом клиенте, отдает его на сервер, где уже другой keycloak клиент должен проверить его валидность и получить данные по нему из keycloak? Возможен ли такой сценарий?
    2. Возможно нужно использовать и там и там реквизиты одного keycloak-клиента, но ведь по логике статическое веб-приложение должно быть клиентом c Access type = public, в то время, как серверное приложение должно быть bearer-only. Или я ошибаюсь?
    3. Выяснил, что если даже одним клиентом обращаться к keycloak по разным адресам, то корректный результат он отдает только при обращении на тот же адрес, по которому изначально авторизовывался. Поясню: при перенаправлении на форму авторизации с сайта идет обращение на публичный адрес, а когда обращаюсь с серверного приложения он доступен уже по внутреннему адресу, который от внешнего отличается. Опять же возможный ли это сценарий? И если да, то какие особенности настройки keycloak под это могут быть?