Знаете эту боль? Сначала вы ставите 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 один раз правильно.