Skip to content
Published on

Keycloak 쿠버네티스 HA 운영 — Infinispan 클러스터링과 무중단 배포

Authors

들어가며

SSO 서버는 조직의 모든 서비스가 의존하는 단일 진입점입니다. Keycloak이 죽으면 로그인이 멈추고, 로그인이 멈추면 사실상 전사 장애입니다. 그래서 Keycloak 운영의 핵심 과제는 언제나 고가용성(HA)이었습니다.

다행히 2026년의 Keycloak 26.x는 HA 운영의 난이도를 크게 낮췄습니다. persistent user sessions가 기본값이 되면서 "재기동하면 전 사용자 로그아웃" 문제가 사라졌고, 26.6의 zero-downtime rolling patch로 패치 업그레이드 시 무중단 배포가 공식 지원됩니다. 이 글에서는 쿠버네티스 위에서 Keycloak HA 클러스터를 구축하고 운영하는 전 과정을 다룹니다.

  • Operator vs Helm 배포 방식 선택
  • Infinispan 캐시 구조와 JGroups DNS_PING 디스커버리
  • persistent user sessions의 의미와 동작
  • DB 선택, 커넥션 풀, sticky session 논쟁 정리
  • multi-site(cross-DC) Active-Active 구성
  • 리소스 사이징, JVM 튜닝, 헬스체크
  • 장애 시나리오별 대응 매뉴얼

배포 방식 선택 — Operator vs Helm

쿠버네티스에 Keycloak을 올리는 대표적인 방법은 공식 Operator와 커뮤니티 Helm 차트(주로 Bitnami 또는 codecentric)입니다.

항목Keycloak Operator (공식)Helm 차트 (커뮤니티)
유지보수 주체Keycloak 프로젝트 공식커뮤니티/벤더
추상화 수준Keycloak CR로 선언values.yaml로 세부 제어
26.6 rolling patch 자동화지원 (update strategy)수동 구성 필요
realm importKeycloakRealmImport CR초기화 스크립트
커스텀 이미지지원 (권장 패턴)지원
세밀한 파드 제어제한적 (podTemplate로 보완)자유로움
권장 대상표준 구성, 운영 자동화 중시비표준 토폴로지, 기존 Helm 파이프라인

신규 구축이라면 공식 Operator를 권장합니다. 버전 업그레이드 자동화와 26.6의 무중단 패치 전략이 Operator에 내장되어 있기 때문입니다. Operator 설치는 다음과 같습니다.

kubectl create namespace keycloak
kubectl apply -n keycloak \
  -f https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.6.2/kubernetes/keycloaks.k8s.keycloak.org-v1.yml
kubectl apply -n keycloak \
  -f https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.6.2/kubernetes/keycloakrealmimports.k8s.keycloak.org-v1.yml
kubectl apply -n keycloak \
  -f https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.6.2/kubernetes/kubernetes.yml

Keycloak CR의 실전 예제입니다.

apiVersion: k8s.keycloak.org/v2alpha1
kind: Keycloak
metadata:
  name: keycloak
  namespace: keycloak
spec:
  instances: 3
  image: registry.example.com/idp/keycloak-custom:26.6.2
  startOptimized: true
  db:
    vendor: postgres
    host: keycloak-db.database.svc.cluster.local
    port: 5432
    database: keycloak
    usernameSecret:
      name: keycloak-db-secret
      key: username
    passwordSecret:
      name: keycloak-db-secret
      key: password
    poolMinSize: 10
    poolInitialSize: 10
    poolMaxSize: 30
  hostname:
    hostname: sso.example.com
    strict: true
  http:
    httpEnabled: true
  proxy:
    headers: xforwarded
  additionalOptions:
    - name: log-console-output
      value: json
    - name: event-metrics-user-enabled
      value: "true"
  resources:
    requests:
      cpu: "1"
      memory: 1500Mi
    limits:
      memory: 3Gi
  update:
    strategy: Auto

Infinispan 캐시 구조 — 세션과 인증 상태의 저장소

Keycloak 클러스터의 심장은 임베디드 Infinispan 캐시입니다. 노드 간 상태 공유가 모두 여기서 일어납니다. 주요 캐시를 분류하면 다음과 같습니다.

캐시 이름종류용도26+ 기본 동작
realms, userslocalDB 엔티티의 읽기 캐시노드별 로컬, invalidation 메시지로 동기화
authorizationlocal인가 정책 캐시노드별 로컬
sessions, clientSessionsdistributed로그인 세션DB 영속화 + 캐시
offlineSessionsdistributed오프라인 세션DB 영속화 + 캐시
authenticationSessionsdistributed진행 중 인증(로그인 폼 단계)클러스터 분산
loginFailuresdistributed브루트포스 카운터클러스터 분산
workreplicated노드 간 invalidation 전파전 노드 복제
actionTokensdistributed이메일 링크 등 일회성 토큰클러스터 분산

구조를 그림으로 보면 다음과 같습니다.

        +-----------------+   +-----------------+   +-----------------+
        |  Keycloak Pod 1 |   |  Keycloak Pod 2 |   |  Keycloak Pod 3 |
        |                 |   |                 |   |                 |
        | local: realms,  |   | local: realms,  |   | local: realms,  |
        |        users    |   |        users    |   |        users    |
        |                 |   |                 |   |                 |
        | distributed:    |   | distributed:    |   | distributed:    |
        |  sessions(o2)  <----> sessions(o2)   <----> sessions(o2)    |
        |  authSessions   |   |  authSessions   |   |  authSessions   |
        |                 |   |                 |   |                 |
        | replicated:     |   | replicated:     |   | replicated:     |
        |  work          <----> work           <----> work            |
        +--------+--------+   +--------+--------+   +--------+--------+
                 |                     |                     |
                 +----------+----------+----------+----------+
                            |   JGroups (gossip)  |
                            v                     v
                     +-------------+      +--------------+
                     | PostgreSQL  |      | DNS headless |
                     | (sessions   |      | service      |
                     |  영속화)     |      | (DNS_PING)   |
                     +-------------+      +--------------+

distributed 캐시는 기본적으로 owners 수가 2여서, 하나의 엔트리가 두 노드에 복제됩니다. 즉 노드 하나가 죽어도 세션 데이터는 살아 있습니다. 동시에 두 노드를 잃으면 캐시 상의 데이터는 유실될 수 있지만, 26부터는 세션이 DB에도 영속화되므로 복구가 가능합니다.

JGroups DNS_PING — 쿠버네티스에서의 노드 디스커버리

Infinispan의 클러스터 멤버십은 JGroups가 담당합니다. 쿠버네티스에서는 멀티캐스트가 막혀 있으므로 DNS 기반 디스커버리(DNS_PING)를 사용합니다. 동작 원리는 단순합니다.

  1. headless Service가 모든 Keycloak 파드의 IP를 DNS A 레코드로 노출
  2. 각 노드가 기동 시 해당 DNS 이름을 조회해 피어 목록 획득
  3. JGroups가 7800 포트로 클러스터 형성

Operator를 쓰면 자동 구성되지만, 직접 구성한다면 다음과 같습니다.

apiVersion: v1
kind: Service
metadata:
  name: keycloak-discovery
  namespace: keycloak
spec:
  clusterIP: None
  publishNotReadyAddresses: true
  selector:
    app: keycloak
  ports:
    - name: jgroups
      port: 7800
      targetPort: 7800
# Keycloak 시작 옵션 (StatefulSet/Deployment 환경변수 기준)
KC_CACHE=ispn
KC_CACHE_STACK=kubernetes
JAVA_OPTS_APPEND=-Djgroups.dns.query=keycloak-discovery.keycloak.svc.cluster.local

publishNotReadyAddresses를 켜는 이유는, 파드가 readiness 전 단계에서도 클러스터에 합류해야 기동 중 세션 리밸런싱이 정상 동작하기 때문입니다. 클러스터 형성 확인은 로그로 합니다.

kubectl logs -n keycloak keycloak-0 | grep "ISPN000094"
# ISPN000094: Received new cluster view ... (3) [keycloak-0-..., keycloak-1-..., keycloak-2-...]

26.x부터는 JGroups 트래픽에 대한 TLS 암호화가 기본 활성화되어(Operator 배포 시), 노드 간 세션 데이터가 평문으로 흐르지 않습니다.

Persistent User Sessions — 26의 게임 체인저

Keycloak 24까지 온라인 세션은 순수 인메모리(Infinispan)였습니다. 전체 재기동이나 동시 다중 노드 장애 시 모든 사용자가 로그아웃되는 구조였죠. Keycloak 26부터 persistent-user-sessions 기능이 기본 활성화되어 다음이 바뀌었습니다.

  • 모든 user session / client session이 생성 시점에 DB에 기록됩니다.
  • Infinispan은 핫 데이터 캐시 역할로 내려오고, 진실의 원천은 DB가 됩니다.
  • 전체 클러스터 재기동 후에도 사용자는 로그인 상태를 유지합니다.
  • 메모리 사용량이 크게 줄어듭니다 (세션 전부를 메모리에 들 필요가 없음).

대가는 DB 쓰기 부하 증가입니다. 로그인/로그아웃/refresh마다 DB 쓰기가 발생하므로, 로그인 폭주 시나리오(아침 9시 출근 시간)의 DB IOPS를 산정에 반영해야 합니다. 비활성화는 가능하지만(features에서 제외) 26의 운영 모델은 영속 세션을 전제로 설계되어 있으므로 특별한 이유가 없다면 기본값을 유지하는 것을 권장합니다.

DB 선택과 커넥션 풀

항목권장이유
DB 엔진PostgreSQL 15+공식 성능 테스트 기준, Aurora PostgreSQL 검증됨
격리 수준READ COMMITTED기본값, 변경 불필요
풀 크기노드당 max 30 내외과대 풀은 DB 부하만 가중
HAPatroni / RDS Multi-AZ / AuroraDB가 SPOF가 되지 않도록
풀 산정피크 동시 요청 기반로그인 TPS x 평균 쿼리 수 고려

커넥션 풀 공식은 단순하지 않지만, 경험칙으로 "로그인 100 TPS당 노드당 풀 10-15"에서 시작해 모니터링(agroal 메트릭)으로 조정합니다. 풀이 고갈되면 로그인 지연이 아니라 로그인 실패로 직결되므로 db-pool 관련 메트릭에 알람을 걸어야 합니다.

# Keycloak CR 내 커넥션 풀 설정 부분
  db:
    poolMinSize: 10
    poolInitialSize: 10
    poolMaxSize: 30
  additionalOptions:
    - name: transaction-xa-enabled
      value: "false"

Sticky Session은 필요한가

결론부터: 26 기준 필수는 아니지만 여전히 유익합니다.

  • authenticationSessions(로그인 진행 상태)는 distributed 캐시이므로 어느 노드로 요청이 가도 처리 가능합니다.
  • 다만 같은 노드로 계속 라우팅되면 owner 노드 직행 확률이 높아져 노드 간 RPC가 줄고 지연이 개선됩니다.
  • Keycloak은 AUTH_SESSION_ID 쿠키에 노드 정보를 인코딩하며, 이를 활용하는 LB(예: ingress-nginx의 session affinity)는 자연스럽게 sticky 동작을 합니다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: keycloak
  namespace: keycloak
  annotations:
    nginx.ingress.kubernetes.io/affinity: "cookie"
    nginx.ingress.kubernetes.io/session-cookie-name: "KC_ROUTE"
    nginx.ingress.kubernetes.io/proxy-buffer-size: "128k"
spec:
  ingressClassName: nginx
  rules:
    - host: sso.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: keycloak-service
                port:
                  number: 8080
  tls:
    - hosts: [sso.example.com]
      secretName: sso-tls

proxy-buffer-size 확대는 실무 필수 팁입니다. Keycloak의 응답 헤더(특히 토큰 포함 리다이렉트)가 기본 버퍼를 초과해 502가 발생하는 사례가 흔합니다.

Multi-Site (Cross-DC) Active-Active

26.x의 공식 multi-site 아키텍처는 두 사이트 Active-Active를 지원합니다. 핵심 구성 요소는 다음과 같습니다.

        Site A (eu-west-1)                  Site B (eu-central-1)
   +------------------------+         +------------------------+
   |  Keycloak (3 pods)     |         |  Keycloak (3 pods)     |
   |        |               |         |        |               |
   |  Infinispan (external) | <-----> |  Infinispan (external) |
   |  cross-site replication|  RELAY2 |  cross-site replication|
   +-----------+------------+         +-----------+------------+
               |                                  |
               +----------------+-----------------+
                                |
                     +----------v-----------+
                     |  Aurora Global DB    |
                     |  (writer: Site A)    |
                     +----------------------+
                                ^
               +----------------+----------------+
               |     Global LB (Route53 /        |
               |     health-check 기반 failover) |
               +---------------------------------+
  • 세션 동기화: 외부 Infinispan 클러스터의 cross-site replication(RELAY2)
  • DB: Aurora Global Database 같은 단일 writer 글로벌 DB
  • 라우팅: 글로벌 LB가 헬스체크 기반으로 두 사이트에 트래픽 분배
  • persistent user sessions 덕분에 사이트 간 캐시 동기화 실패 시에도 DB 경유 복구 가능

multi-site는 운영 복잡도가 매우 높으므로, RTO/RPO 요구가 정말 필요한 경우에만 도입하고, 그 전에는 단일 리전 다중 AZ + 견고한 백업/복원 절차로 충분한지 먼저 검토하는 것이 좋습니다. 자세한 내용은 공식 HA 가이드를 참고하시기 바랍니다.

26.6 Zero-Downtime Rolling Patch

26.6 이전에는 어떤 버전 업그레이드든 캐시 프로토콜 비호환 가능성 때문에 전체 클러스터를 내렸다 올리는 recreate 전략이 기본이었습니다. 26.6부터는 패치 릴리스 간(예: 26.6.0에서 26.6.2) 호환성이 보장되어 rolling update가 공식 지원됩니다.

# Keycloak CR
spec:
  update:
    strategy: Auto   # 호환성 자동 판단: 가능하면 rolling, 아니면 recreate

Auto 전략의 동작은 다음과 같습니다.

  1. Operator가 새 이미지로 update-compatibility 검사 잡을 실행
  2. 캐시/설정 호환이면 파드를 하나씩 교체 (무중단)
  3. 비호환이면 recreate (전체 재기동, persistent sessions로 로그인 유지)

수동으로 호환성을 검사할 수도 있습니다.

# 기존 버전에서 메타데이터 생성
bin/kc.sh update-compatibility metadata --file=/tmp/metadata.json

# 새 버전에서 검사
bin/kc.sh update-compatibility check --file=/tmp/metadata.json
echo $?   # 0이면 rolling 가능

리소스 사이징과 JVM 튜닝

공식 사이징 가이드 기반의 출발점은 다음과 같습니다.

부하 지표1 vCPU당 처리량(대략)비고
비밀번호 로그인초당 15회 내외해시 코스트(argon2)에 크게 좌우
client credentials 그랜트초당 120회 내외가장 가벼운 작업
refresh token초당 120회 내외DB 쓰기 포함
메모리(비힙 포함)파드당 1.25-3Gi세션 수보다 realm/클라이언트 수 영향 큼

JVM 메모리는 26부터 컨테이너 메모리 기반 비율 산정이 기본입니다(기본 힙 70%). 명시적으로 제어하려면:

  additionalOptions: []
  # 또는 환경변수로
  # JAVA_OPTS_KC_HEAP: "-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50"
  resources:
    requests:
      cpu: "1"
      memory: 1500Mi
    limits:
      memory: 3Gi

CPU limit은 걸지 않는 것이 일반적 권장입니다(스로틀링으로 인한 지연 스파이크 방지). 메모리 limit은 OOMKill 방지를 위해 힙+메타스페이스+네이티브 합산보다 여유 있게 잡습니다.

헬스체크와 Startup Probe

Keycloak은 관리 포트(기본 9000)에서 health 엔드포인트를 제공합니다.

# Deployment/StatefulSet 직접 구성 시
livenessProbe:
  httpGet:
    path: /health/live
    port: 9000
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet:
    path: /health/ready
    port: 9000
  periodSeconds: 10
  failureThreshold: 3
startupProbe:
  httpGet:
    path: /health/started
    port: 9000
  periodSeconds: 5
  failureThreshold: 60   # 최대 5분의 기동 유예
  • started: 기동 완료 판정. startup probe 전용으로, 마이그레이션이 긴 업그레이드 직후를 고려해 failureThreshold를 넉넉히 줍니다.
  • ready: DB 연결 가능 여부를 포함합니다. DB 순단 시 파드가 일제히 not-ready가 되어 전면 장애처럼 보일 수 있다는 점을 알아두어야 합니다.
  • live: 프로세스 자체의 생존. 실패 시 재시작이 발생하므로 보수적으로 설정합니다.

추가로 PodDisruptionBudget과 topologySpreadConstraints는 HA의 기본기입니다.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: keycloak-pdb
  namespace: keycloak
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: keycloak

장애 시나리오별 대응

시나리오 1: 노드 1대 다운

  • 증상: 거의 없음. distributed 캐시 owners 2로 세션 보존, LB가 트래픽 재분배.
  • 대응: 파드 자동 재생성 확인. JGroups 클러스터 뷰에 재합류했는지 로그 확인.

시나리오 2: DB 순단 (failover 30초)

  • 증상: 전 노드 readiness 실패, 로그인/토큰 발급 전면 실패. 기존 발급 토큰 검증은 영향 적음(서명 검증은 로컬).
  • 대응: DB failover 자동화 확인이 최우선. Keycloak은 DB 복귀 시 자동 회복하므로 파드 재시작은 불필요. 오히려 liveness를 너무 민감하게 잡아 재시작 폭풍이 나지 않도록 주의.

시나리오 3: 스플릿 브레인 (네트워크 파티션)

  • 증상: 클러스터가 두 그룹으로 갈라져 각자 뷰 형성. 브루트포스 카운터/세션 불일치 가능.
  • 대응: 26의 기본 설정은 파티션 병합 시 MERGE 이벤트로 회복. persistent sessions 덕에 세션 데이터는 DB 기준으로 수렴. 파티션이 잦다면 CNI/노드 네트워크 점검이 근본 대응.

시나리오 4: 전체 재기동 (재해 복구)

  • 증상: 26 이전엔 전 사용자 로그아웃이었으나, 26+에서는 DB에서 세션 복원되어 로그인 유지.
  • 대응: DB 백업에서 복원하는 최악 시나리오에 대비해 realm export를 별도로 보관(설정과 데이터의 이중 백업).
# 정기 realm export (CronJob으로 자동화 권장)
bin/kc.sh export --dir /tmp/export --realm production --users different_files

시나리오 5: 로그인 폭주 (출근 시간 스파이크)

  • 증상: CPU 포화, 비밀번호 해시 연산이 병목.
  • 대응: HPA로 수평 확장하되, 해시 코스트가 CPU를 지배하므로 스케일 아웃이 직접적으로 효과 있음. 단 DB 커넥션 총합이 DB 한계를 넘지 않도록 풀 최대치 재계산.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: keycloak-hpa
  namespace: keycloak
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: StatefulSet
    name: keycloak
  minReplicas: 3
  maxReplicas: 8
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60

마치며

Keycloak 26 시대의 HA 운영은 "Infinispan을 어떻게든 달래는 작업"에서 "DB를 중심에 둔 평범한 스테이트풀 서비스 운영"으로 단순해졌습니다. persistent user sessions와 zero-downtime rolling patch는 그 전환점입니다. 그럼에도 JGroups 디스커버리, 커넥션 풀 산정, 프로브 튜닝 같은 기본기는 여전히 운영자의 몫입니다. 이 글의 YAML 예제들을 출발점 삼아, 반드시 자기 환경의 부하 테스트로 수치를 검증하시기 바랍니다.

다음 글에서는 Keycloak의 기능 자체를 확장하는 SPI 개발(커스텀 Authenticator, EventListener)을 다룹니다.

참고 자료