Pull to refresh
Флант
DevOps-as-a-Service, Kubernetes, обслуживание 24×7

Настраиваем отказоустойчивый Keycloak с Infinispan в Kubernetes

Reading time11 min
Views20K

В этой статье мы поделимся опытом развертывания в кластере 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 будем использовать конфиг по умолчанию /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.

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

Tags:
Hubs:
Total votes 27: ↑27 and ↓0+27
Comments8

Articles

Information

Website
flant.ru
Registered
Founded
Employees
201–500 employees
Location
Россия
Representative
Тимур Тукаев