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

669 lines
15 KiB
Markdown

# Keycloak 예시
Keycloak 26+ (Quarkus distribution) + Keycloak Operator 기준. 모든 YAML은 그대로 `kubectl apply`로 적용 가능한 완전한 manifest다.
---
## 좋은 예시 1: optimized 이미지 빌드 (두 단계)
`kc.sh build`로 Quarkus augmentation을 굽고, 실행 이미지를 분리한다.
```dockerfile
# 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
```yaml
args:
- start-dev
```
또는
```yaml
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차 권장)
```yaml
---
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 # gitleaks:allow
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`
```yaml
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)
```yaml
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 `/` 로 설정
```yaml
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
```yaml
---
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 분리
```yaml
---
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 노출
```yaml
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
```yaml
---
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 직접 호출
```bash
# 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
```yaml
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)
```yaml
---
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
```yaml
env:
- name: KC_DB_PASSWORD
value: "SuperSecret123!"
- name: KEYCLOAK_ADMIN_PASSWORD
value: "admin"
```
**문제:**
- Git에 평문 저장 → 권한 있는 모든 인원이 조회 가능
- 기본 `admin/admin` credential → bootstrap 직후 자동화된 스캐너에 탈취 위험
- rotation 경로 없음