669 lines
15 KiB
Markdown
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 경로 없음
|