Знаете эту боль? Сначала вы ставите Keycloak «на пробу» на пару месяцев. Всё летает. Вы даёте ссылку на auth.myapp.ru разрабам. А потом бац - прод, 10 тысяч пользователей, и Keycloak внезапно говорит: «Я устал, я ухожу, и все сессии с собой забираю». Мммм. Классика!

И тут из-за угла, на белом коне (слегка прихрамывающем, потому что JVM жрёт память) приезжает кластерный Keycloak.

Мы его сейчас настроим так, чтобы он выдерживал падение двух нод и смотрел на отказавший сервер БД с высокомерной ухмылкой «а нас-то двое осталось». Без магии, но с правильным количеством реплик и кучей аннотаций.

Почему «один под Keycloak» - это как один носок: вроде есть, а счастья нет?

Один под - это не отказоустойчивость. Это «single point of failure» с человеческим лицом. Когда он падает по OOMKiller (а он упадет, потому что мы все экономили на memory limits), ваш SSO встаёт. Все пользователи идут в бан - включая CI/CD, админку и ботов.

Поэтому минимальное количество - 2 пода. Лучше 3. Потому что если два пода, то при rolling update один остаётся, а второй обновляется - и в этот момент третий не при делах. А если оба лягут? Но, не будем об этом в пятницу вечером.

Что у вас должно быть до старта

Проверьте по списку, как диспетчер на АЭС перед запуском реактора:

1. PostgreSQL есть? Настоящий кластерный БД, а не «а давайте поднимем postgres-operator». У вас или zalando/postgres-operator, или crunchydata, или managed database типа RDS или Patroni. Keycloak с SQLite или standalone в проде - это путь самурая, которому надоела жизнь.

2. Ingress Controller жив? ingress-nginx, contour или traefik - без разницы. Но чтобы виртуальные хосты работали.

3. Cert-manager выдаёт сертификаты? Иначе Keycloak начнет ругаться про https, а мы не в 2015, самоподписанные - только для олдскула (Или, возвращаемся к статьям про Vault или FreeIPA в блоге).

4. Канал для кэша есть? Keycloak кластеризуется через JGroups. Ему нужен KUBE_PING или DNS_PING. Мы возьмём KUBE_PING - он нативно смотрит на поды через API.

5. Памяти не жалко? Каждому поду: минимум 1.5 GiB RAM. Если дадите 1 GiB - будет грустно, но не сразу. JVM пожуёт и скажет "спасибо, но я пошел...".

Если всё зелёное - поехали.

Шаг 1. Готовим базу данных (ту самую, одну на всех)

Keycloak в кластерном режиме требует общую базу данных. Не «каждый под свою», иначе сессии разбегутся, как котята.

Пример для zalando/postgres-operator (самый дружелюбный к смертным):

apiVersion: acid.zalan.do/v1
kind: postgresql
metadata:
  name: keycloak-db
  namespace: keycloak
spec:
  teamId: "sre"
  numberOfInstances: 2
  volume:
    size: 50Gi
  users:
    keycloak: []  # пароль сгенерируется сам
  databases:
    keycloak: keycloak
  postgresql:
    version: "15"

Важнейший нюанс: включите параметры в PostgreSQL для Keycloak:

ALTER SYSTEM SET max_connections = 300;
ALTER SYSTEM SET shared_buffers = '512MB';

Потому что дефолтных 100 соединений Keycloak съест за завтрак, даже не поморщившись.

Шаг 2. Готовим конфигурацию кластеризации (самый важный кусок)

Кладём ConfigMap с cache-ispn.xml. Без него поды не увидят друг друга, и будет два одиночных короля - грустная картина.

apiVersion: v1
kind: ConfigMap
metadata:
  name: keycloak-cluster-config
  namespace: keycloak
data:
  cache-ispn.xml: |
    <?xml version="1.0" encoding="UTF-8"?>
    <infinispan
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="urn:infinispan:config:13.0 https://infinispan.org/schemas/infinispan-config-13.0.xsd"
        xmlns="urn:infinispan:config:13.0">
       <cache-container name="keycloak">
         <transport lock-timeout="60000" stack="kubernetes"/>
         <replicated-cache name="work">
           <encoding media-type="application/x-java-object"/>
         </replicated-cache>
         <replicated-cache name="sessions">
           <encoding media-type="application/x-java-object"/>
         </replicated-cache>
         <replicated-cache name="authenticationSessions">
           <encoding media-type="application/x-java-object"/>
         </replicated-cache>
         <distributed-cache name="offlineSessions" owners="2"/>
       </cache-container>
    </infinispan>

А вот тут самое сердце - передаём JGroups через переменные окружения в StatefulSet:

env:
- name: KC_CACHE_CONFIG_FILE
  value: /cache-config/cache-ispn.xml
- name: KC_CACHE_STACK
  value: kubernetes
- name: JGROUPS_DISCOVERY_PROTOCOL
  value: KUBE_PING
- name: JGROUPS_DISCOVERY_PROPERTIES
  value: "namespace=keycloak,labelSelector=app=keycloak"

Если вы забудете JGROUPS_DISCOVERY_PROPERTIES - поды никогда не найдут друг друга. И вы будете два дня смотреть логи, где написано: Unable to merge cache, no cluster formed. А всё потому, что вы не указали лейбл. Бывает.

Шаг 3. StatefulSet, а не Deployment (спойлер: сеть важна)

Почему StatefulSet? Потому что Keycloak при старте пишет persistent-sessions и кэш на диск. При перезапуске на том же PVC данные никуда не деваются. Deployment пересоздаст под с новым PVC и сессии - au revoir.

Минимальный StatefulSet на 3 реплики:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: keycloak
spec:
  serviceName: keycloak-hs  # headless service
  replicas: 3
  selector:
    matchLabels:
      app: keycloak
  template:
    metadata:
      labels:
        app: keycloak
    spec:
      containers:
      - name: keycloak
        image: quay.io/keycloak/keycloak:24.0.5
        args: ["start", "--optimized"]
        env:
        - name: KEYCLOAK_ADMIN
          valueFrom:
            secretKeyRef:
              name: keycloak-admin-secret
              key: admin-user
        - name: KEYCLOAK_ADMIN_PASSWORD
          valueFrom:
            secretKeyRef:
              name: keycloak-admin-secret
              key: admin-password
        - name: KC_DB
          value: "postgres"
        - name: KC_DB_URL
          value: "jdbc:postgresql://keycloak-db.default.svc:5432/keycloak"
        - name: KC_DB_USERNAME
          valueFrom: ...
        - name: KC_HOSTNAME
          value: "auth.mycompany.com"
        - name: KC_PROXY
          value: "edge"   # если nginx-termination ssl
        - name: KC_HTTP_ENABLED
          value: "true"
        ports:
        - containerPort: 8080
        volumeMounts:
        - name: kc-data
          mountPath: /opt/keycloak/data
        - name: cache-config
          mountPath: /cache-config
      volumes:
      - name: cache-config
        configMap:
          name: keycloak-cluster-config
  volumeClaimTemplates:
  - metadata:
      name: kc-data
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 10Gi

Но! Обратите внимание на KC_HOSTNAME. Если забудете - Keycloak будет на каждый редирект вставлять имя пода (keycloak-0.keycloak-hs), и пользователь получит 502. А вы будете думать, что Ingress сломался. Не сломался - это Keycloak злой.

Шаг 4. Делаем Service и Ingress с умом

Headless Service (для discovery):

apiVersion: v1
kind: Service
metadata:
  name: keycloak-hs
spec:
  clusterIP: None
  selector:
    app: keycloak
  ports:
  - port: 8080

Обычный ClusterIP (для ingress):

apiVersion: v1
kind: Service
metadata:
  name: keycloak-svc
spec:
  selector:
    app: keycloak
  ports:
  - port: 8080

Ingress с передачей реального IP (иначе убьётесь с редиректами):

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: keycloak
  annotations:
    nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
    nginx.ingress.kubernetes.io/proxy-buffers-number: "8"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  rules:
  - host: auth.mycompany.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: keycloak-svc
            port:
              number: 8080
  tls:
  - hosts:
    - auth.mycompany.com
    secretName: keycloak-tls

Без proxy-buffer-size большие куки (а у Keycloak они будут под 4KB) прилягут с ошибкой по upstream sent too big header. И вы в пятницу вечером полезете в дебри nginx-ingress.

Шаг 5. "А теперь, дети, про боль" - что пойдёт не так

Факап 1: Поды встали, но сессии не шарится

JGroups не завелся. Зайдите в под и выполните:

cat /proc/1/environ | tr '\0' '\n' | grep JGROUPS

и проверьте лейблы.

Смотрите логи: 

kubectl logs keycloak-0 | grep -i "merge\|cluster"

Если видите JGRP000012: discarded message from different cluster - значит у вас разные jgroups.cluster.name или транспорт.

Факап 2: При отказе пода пользователь вылетает из всех приложений

Не настроено кэширование сессий в Infinispan - идём в конфиг cache-ispn.xml, проверяем replicated-cache. Если там стоит local-cache - это не кластер.

Факап 3: Keycloak стартует 10 минут и падает по таймауту

База не отвечает. Или миграция схемы не проходит. Делаем так: сначала разворачиваем БД, потом даём Keycloak старт с --import-realm и без пользователей. База мигрирует один раз, при первом запуске.

Эпилог: а как же бедные пользователи Helm?

Знаете, есть категория людей, которые, увидев 700 строк манифестов, берут bitnami/keycloak. И живут себе спокойно. Но приходит момент, когда надо:

· поставить свой плагин (например, телеграм-авторизацию)

· изменить настройки пула соединений к БД

· или настроить keystore для взаимного TLS

И тут оказывается, что Helm-чарт написан так, что всё, что вы хотели, - закомментировано. И переопределять надо через 3 уровня values.yaml, а документация врёт.

Что делать?

Правильно - обмануть Helm и сделать свой поверх его.

Добавьте в values.yaml:

extraEnvVars:
- name: KC_SPI_EVENTS_LISTENER_CUSTOM_JGROUPS
  value: "kubernetes"
extraVolumeMounts:
- name: cache-config
  mountPath: /cache-config
extraVolumes:
- name: cache-config
  configMap:
    name: keycloak-cluster-config

Но это всё равно не спасает, когда в чарте жёстко зашита версия JDK 11, а вам нужно 17 для новых фич. Тогда один путь - форкать чарт или пилить StatefulSet самим.

Итог - живите теперь с кластерным SSO

Кластерный Keycloak - это не магия, а три кита:

1. Общая база данных (PostgreSQL с репликами).

2. Кэширование с JGroups/KUBE_PING (иначе поды будут скучать в одиночестве).

3. StatefulSet + правильный Ingress (куки не должны теряться).

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

И да, теперь вы можете гордо говорить:

«Я перенёс SSO в кластер, и он не падает при каждом рестарте».

А теперь - технический бонус для настоящих девопсов

Если вы хотите совсем по-взрослому, добавьте PDP (Pattern-Driven Pod Autoscaling) - KEDA, чтобы скейлить реплики по количеству активных сессий.

Но это уже тема для отдельной статьи, с рок-балладами, падшими подами и танцами с бубном вокруг метрик Prometheus.

Главное правило DevOps-инженера: если автоматизация ломает сессии при каждом rolling update - значит это не автоматизация, а говно.

Настройте Keycloak один раз правильно.