Files
project-infra/docs/examples/infra/keycloak.md
T

15 KiB

Keycloak 예시

Keycloak 26+ (Quarkus distribution) + Keycloak Operator 기준. 모든 YAML은 그대로 kubectl apply로 적용 가능한 완전한 manifest다.


좋은 예시 1: optimized 이미지 빌드 (두 단계)

kc.sh build로 Quarkus augmentation을 굽고, 실행 이미지를 분리한다.

# Dockerfile.keycloak
FROM quay.io/keycloak/keycloak:26.0.7 AS builder

ENV KC_DB=postgres
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
ENV KC_CACHE=ispn
ENV KC_CACHE_STACK=jdbc-ping
ENV KC_FEATURES=token-exchange,admin-fine-grained-authz
ENV KC_HTTP_ENABLED=true

RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:26.0.7

COPY --from=builder /opt/keycloak/ /opt/keycloak/

USER 1000

ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start", "--optimized"]

왜 좋은가:

  • 빌드 단계에서 augmentation 완료, 런타임은 runtime-only config만 수신
  • --optimized 플래그로 매 기동 시 build 재실행 방지 (cold start 50% 단축)
  • v26+ --proxy 제거 대응: legacy 옵션이 build 시 포함되지 않음

나쁜 예시 1: dev mode / 매 기동 build

args:
  - start-dev

또는

args:
  - start

문제:

  • start-dev는 hostname-strict=false, H2 in-memory DB, TLS 해제 — production 부적합
  • start는 optimized 이미지가 아니면 매 기동마다 Quarkus augmentation 수행 → cold start 2배+
  • v26에서 --proxy edge 같은 legacy 옵션은 아예 기동 실패

좋은 예시 2: Keycloak Operator Keycloak CR (1차 권장)

---
apiVersion: v1
kind: Namespace
metadata:
  name: keycloak
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted
---
apiVersion: v1
kind: Secret
metadata:
  name: keycloak-db-secret
  namespace: keycloak
type: Opaque
stringData:
  username: keycloak
  password: REPLACE_VIA_VSO
---
apiVersion: v1
kind: Secret
metadata:
  name: keycloak-tls
  namespace: keycloak
type: kubernetes.io/tls
data:
  tls.crt: LS0tLS1CRUdJTi...  # cert-manager 발급 권장
  tls.key: LS0tLS1CRUdJTi...
---
apiVersion: k8s.keycloak.org/v2alpha1
kind: Keycloak
metadata:
  name: keycloak
  namespace: keycloak
  labels:
    app.kubernetes.io/name: keycloak
    app.kubernetes.io/instance: keycloak-prod
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: keycloak-operator
spec:
  instances: 3
  image: registry.example.com/platform/keycloak:26.0.7-optimized
  startOptimized: true
  db:
    vendor: postgres
    host: keycloak-db-rw.keycloak.svc.cluster.local
    port: 5432
    database: keycloak
    usernameSecret:
      name: keycloak-db-secret
      key: username
    passwordSecret:
      name: keycloak-db-secret
      key: password
    poolMinSize: 5
    poolInitialSize: 5
    poolMaxSize: 20
  hostname:
    hostname: https://auth.example.com
    admin: https://admin-auth.example.com
    strict: true
    backchannelDynamic: false
  http:
    httpEnabled: true
    tlsSecret: keycloak-tls
  proxy:
    headers: xforwarded
  features:
    enabled:
      - token-exchange
      - admin-fine-grained-authz
  additionalOptions:
    - name: cache
      value: ispn
    - name: cache-stack
      value: jdbc-ping
    - name: log-console-output
      value: json
    - name: metrics-enabled
      value: "true"
    - name: health-enabled
      value: "true"
  resources:
    requests:
      cpu: 500m
      memory: 1Gi
    limits:
      cpu: "2"
      memory: 2Gi
  scheduling:
    topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: ScheduleAnyway
        labelSelector:
          matchLabels:
            app: keycloak
    affinity:
      podAntiAffinity:
        preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              topologyKey: kubernetes.io/hostname
              labelSelector:
                matchLabels:
                  app: keycloak
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: keycloak
  namespace: keycloak
spec:
  minAvailable: 2
  unhealthyPodEvictionPolicy: AlwaysAllow
  selector:
    matchLabels:
      app: keycloak

왜 좋은가:

  • Operator가 StatefulSet, Service, cache stack 설정을 자동 관리
  • hostname v2 (full URL, admin host 분리, strict=true, backchannelDynamic=false) 명시
  • startOptimized: true로 Operator가 kc.sh start --optimized 실행
  • PDB minAvailable: 2 + topologySpread로 zone-level disruption 방어

나쁜 예시 2: 수제 Deployment + --proxy edge

apiVersion: apps/v1
kind: Deployment
metadata:
  name: keycloak
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: keycloak
          image: quay.io/keycloak/keycloak:26.0.7
          args: ["start", "--proxy", "edge"]
          env:
            - name: KC_HOSTNAME
              value: auth.example.com
            - name: KC_HOSTNAME_STRICT
              value: "false"

문제:

  • --proxy 옵션은 v26에서 제거되어 기동 실패
  • KC_HOSTNAME에 scheme 없는 호스트명 단독 전달 → v2 검증에서 경고
  • KC_HOSTNAME_STRICT=false는 proxy hop이 Host 헤더를 조작할 수 있는 공격 벡터를 열어둠
  • replicas: 1 + Deployment → rolling update 시 Infinispan cluster membership 이슈 + 단일 장애

좋은 예시 3: Probe (management port 9000)

ports:
  - name: http
    containerPort: 8080
    protocol: TCP
  - name: management
    containerPort: 9000
    protocol: TCP

startupProbe:
  httpGet:
    path: /health/started
    port: 9000
    scheme: HTTP
  periodSeconds: 5
  failureThreshold: 60
  timeoutSeconds: 3

readinessProbe:
  httpGet:
    path: /health/ready
    port: 9000
    scheme: HTTP
  periodSeconds: 10
  failureThreshold: 3
  timeoutSeconds: 3

livenessProbe:
  httpGet:
    path: /health/live
    port: 9000
    scheme: HTTP
  initialDelaySeconds: 60
  periodSeconds: 30
  failureThreshold: 3
  timeoutSeconds: 3

왜 좋은가:

  • 9000은 management port (KC_HTTP_MANAGEMENT_PORT 기본값)
  • startupProbe 5분 유예: JVM + Quarkus + DB migration cold start 수용
  • readiness는 /health/ready (DB connectivity 포함), liveness는 /health/live (프로세스 생존)

나쁜 예시 3: Probe를 8080 / 로 설정

readinessProbe:
  httpGet:
    path: /
    port: 8080
  periodSeconds: 3
  failureThreshold: 2

문제:

  • 8080 /는 redirect 응답이고 DB / cache readiness를 검증하지 않음
  • failureThreshold: 2 + periodSeconds: 3은 cold start 중 pod 재시작 유발
  • health endpoint가 켜져 있어도 사용하지 않아 관찰 포인트 상실

좋은 예시 4: Service + ServiceMonitor

---
apiVersion: v1
kind: Service
metadata:
  name: keycloak
  namespace: keycloak
  labels:
    app.kubernetes.io/name: keycloak
    app.kubernetes.io/instance: keycloak-prod
spec:
  type: ClusterIP
  selector:
    app: keycloak
  ports:
    - name: http
      port: 8080
      targetPort: 8080
    - name: management
      port: 9000
      targetPort: 9000
---
apiVersion: v1
kind: Service
metadata:
  name: keycloak-headless
  namespace: keycloak
spec:
  type: ClusterIP
  clusterIP: None
  selector:
    app: keycloak
  ports:
    - name: http
      port: 8080
      targetPort: 8080
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: keycloak
  namespace: keycloak
  labels:
    app.kubernetes.io/name: keycloak
    release: kube-prometheus-stack
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: keycloak
  endpoints:
    - port: management
      path: /metrics
      interval: 30s
      scrapeTimeout: 10s

왜 좋은가:

  • ClusterIP Service가 사용자 트래픽용(8080), management(9000)을 분리 expose
  • Headless service는 cache peer discovery 보조 (jdbc-ping에서는 불필요하지만 DNS_PING fallback 대비)
  • ServiceMonitor는 management port의 /metrics만 scrape

좋은 예시 5: Ingress — SSO host + Admin host 분리

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: keycloak-sso
  namespace: keycloak
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/proxy-buffer-size: "128k"
    nginx.ingress.kubernetes.io/proxy-body-size: "4m"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - auth.example.com
      secretName: keycloak-sso-tls
  rules:
    - host: auth.example.com
      http:
        paths:
          - path: /realms/
            pathType: Prefix
            backend:
              service:
                name: keycloak
                port:
                  number: 8080
          - path: /resources/
            pathType: Prefix
            backend:
              service:
                name: keycloak
                port:
                  number: 8080
          - path: /.well-known/
            pathType: Prefix
            backend:
              service:
                name: keycloak
                port:
                  number: 8080
          - path: /js/
            pathType: Prefix
            backend:
              service:
                name: keycloak
                port:
                  number: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: keycloak-admin
  namespace: keycloak
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,192.168.0.0/16"
    nginx.ingress.kubernetes.io/auth-url: "https://oauth2-proxy.example.com/oauth2/auth"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - admin-auth.example.com
      secretName: keycloak-admin-tls
  rules:
    - host: admin-auth.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: keycloak
                port:
                  number: 8080

왜 좋은가:

  • SSO host는 /realms/, /resources/, /.well-known/, /js/ 만 공개 (필요 최소)
  • Admin host는 별도 hostname + IP whitelist + forward-auth 2중 보호
  • /metrics, /health*, /admin/이 SSO host에 노출되지 않음

나쁜 예시 4: 전체 공개 + 9000 노출

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: keycloak
spec:
  rules:
    - host: auth.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: keycloak
                port:
                  number: 8080
          - path: /metrics
            pathType: Prefix
            backend:
              service:
                name: keycloak
                port:
                  number: 9000

문제:

  • / 공개 → /admin/ 포함 전부 외부 노출 → credential stuffing / brute force 표면 확장
  • /metrics는 인증이 없는 운영 데이터 endpoint → 정보 유출
  • 9000 management port가 인터넷에 노출 → health/metrics 둘 다 오픈

좋은 예시 6: KeycloakRealmImport CR

---
apiVersion: v1
kind: Secret
metadata:
  name: platform-realm
  namespace: keycloak
type: Opaque
stringData:
  realm.json: |
    {
      "realm": "platform",
      "enabled": true,
      "sslRequired": "external",
      "registrationAllowed": false,
      "loginWithEmailAllowed": true,
      "accessTokenLifespan": 300,
      "clients": [
        {
          "clientId": "auth-server",
          "protocol": "openid-connect",
          "publicClient": false,
          "standardFlowEnabled": true,
          "redirectUris": ["https://auth-server.example.com/*"],
          "webOrigins": ["https://auth-server.example.com"]
        }
      ],
      "roles": {
        "realm": [
          {"name": "platform-admin"},
          {"name": "platform-user"}
        ]
      }
    }
---
apiVersion: k8s.keycloak.org/v2alpha1
kind: KeycloakRealmImport
metadata:
  name: platform-realm
  namespace: keycloak
spec:
  keycloakCRName: keycloak
  realm:
    realm: platform
    enabled: true
    sslRequired: external
    registrationAllowed: false
    loginWithEmailAllowed: true
    accessTokenLifespan: 300

왜 좋은가:

  • Realm을 선언적으로 관리 (GitOps 연계)
  • Operator가 keycloak CR ready 이후 server-side import Job을 자동 생성
  • client secret처럼 민감한 값은 별도 Vault 경로로 분리, realm JSON은 Git 안전

나쁜 예시 5: kcadm.sh pipeline 직접 호출

# CI pipeline
kcadm.sh config credentials \
  --server https://auth.example.com \
  --realm master \
  --user admin \
  --password $KEYCLOAK_ADMIN_PASSWORD

kcadm.sh create realms -s realm=platform -s enabled=true
kcadm.sh create clients -r platform -s clientId=auth-server

문제:

  • 상태가 선언적이지 않아 drift 탐지 불가
  • admin credential이 CI runner 환경에 상주
  • 실패 시 재실행 안전성(idempotency) 없음
  • GitOps 원칙과 충돌

좋은 예시 7: SecurityContext + Resource

securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  runAsGroup: 1000
  fsGroup: 1000
  seccompProfile:
    type: RuntimeDefault

containers:
  - name: keycloak
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop:
          - ALL
    env:
      - name: JAVA_OPTS_APPEND
        value: "-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50 -Djgroups.dns.query=keycloak-headless.keycloak.svc.cluster.local"
    resources:
      requests:
        cpu: 500m
        memory: 1Gi
      limits:
        cpu: "2"
        memory: 2Gi
    volumeMounts:
      - name: tmp
        mountPath: /tmp
      - name: data
        mountPath: /opt/keycloak/data

volumes:
  - name: tmp
    emptyDir: {}
  - name: data
    emptyDir: {}

왜 좋은가:

  • Restricted PSS 전부 충족: non-root, no privilege escalation, RO root fs, cap drop ALL
  • MaxRAMPercentage=70은 JVM이 container limit의 70%까지만 heap 사용 (나머지는 direct memory / metaspace)
  • readOnlyRootFilesystem: true + emptyDir 마운트로 runtime write path 격리

좋은 예시 8: Vault에서 DB credential 주입 (VSO)

---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
  name: keycloak-db
  namespace: keycloak
spec:
  vaultAuthRef: default
  mount: kv
  path: keycloak/db
  type: kv-v2
  refreshAfter: 1h
  destination:
    name: keycloak-db-secret
    create: true
    overwrite: true
    transformation:
      excludeRaw: true
      templates:
        username:
          text: '{{ .Secrets.username }}'
        password:
          text: '{{ .Secrets.password }}'

왜 좋은가:

  • Vault KV v2의 keycloak/db에서 credential을 K8s Secret으로 동기화
  • 1시간 주기 refresh, VSO가 Pod를 재시작시켜 rotation 적용 가능 (별도 rolloutRestartTargets 설정 시)
  • Git에 평문 credential이 없다

나쁜 예시 6: env에 평문 credential

env:
  - name: KC_DB_PASSWORD
    value: "SuperSecret123!"
  - name: KEYCLOAK_ADMIN_PASSWORD
    value: "admin"

문제:

  • Git에 평문 저장 → 권한 있는 모든 인원이 조회 가능
  • 기본 admin/admin credential → bootstrap 직후 자동화된 스캐너에 탈취 위험
  • rotation 경로 없음