В этой статье мы поделимся опытом развертывания в кластере 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 как бэкенд авторизации своего приложения, но переживал за устойчивость решения и сохранность кэшей при авариях / развертываниях. Поэтому перед нами стояли две задачи:
Обеспечить надежность/устойчивость к авариям, высокую доступность.
Сохранить пользовательские данные (сессии, токены) при потенциальном переполнении памяти.
Описание инфраструктуры и архитектуры решения
Изначально 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)
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.xmlWimbo
15.07.2021 16:07Хорошо, а почему именно эти IP-адреса? Они во всех кластерах везде будут одинаковые? Если я захочу 2 Keycloak в кластер, они тоже будут одинаковые?
AlexGluck
16.07.2021 20:13В дополнение спрошу, а почему не использовали JDBC_PING (про KUBE_PING уже спросили) и deployments.
Зачем modcluster в данной настройке, можно просто балансить по clusterip?
Multicast так же не нужен (он в большинстве инсталляций облаков\куберов не работает), все задачи jgroups выполняются на unicast.
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 только его сейчас и поддерживает насколько я понял.
Подскажите пожалуста:- Могут ли токены передаваться между разными клиентами? Ну то есть сайт получивший токен на чистом клиенте, отдает его на сервер, где уже другой keycloak клиент должен проверить его валидность и получить данные по нему из keycloak? Возможен ли такой сценарий?
- Возможно нужно использовать и там и там реквизиты одного keycloak-клиента, но ведь по логике статическое веб-приложение должно быть клиентом c Access type = public, в то время, как серверное приложение должно быть bearer-only. Или я ошибаюсь?
- Выяснил, что если даже одним клиентом обращаться к keycloak по разным адресам, то корректный результат он отдает только при обращении на тот же адрес, по которому изначально авторизовывался. Поясню: при перенаправлении на форму авторизации с сайта идет обращение на публичный адрес, а когда обращаюсь с серверного приложения он доступен уже по внутреннему адресу, который от внешнего отличается. Опять же возможный ли это сценарий? И если да, то какие особенности настройки keycloak под это могут быть?
Wimbo
Спасибо большое за статью и за примеры использования.
Я бы хотел задать пару вопросов:
А почему выбрали метод 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)'
Год назад мне очень помог этот репозиторий и соответствующая статья.
d9tetra Автор
Тестировали ли вы реальную отказоустойчивость? Т.е. убивали ли поды Keycloak/Infinispan/Вырубали одну из нод в кластере. И как это все переживало эти тесты?
Поды - да, включали / выключали. С нодами - специально нет.
darthslider
Про метрики. мы используем вот этот плагин: https://github.com/aerogear/keycloak-metrics-spi
И вот мой дашборд под них, который показывает всё, что нам нужно: https://grafana.com/grafana/dashboards/14607