init: 폴더구조 설계 및 인프라 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:31:53 +09:00
parent 34ad612281
commit f9c463f87a
1839 changed files with 323096 additions and 1 deletions
@@ -0,0 +1,417 @@
# architecture / environments 예시
이 파일의 모든 YAML은 `kubectl apply --server-side --dry-run=server` 에 통과해야 한다.
모든 예시는 1000+ 서비스 운영 기준으로 작성되었고, 단독으로 복붙해서 바로 apply 할 수 있도록 self-contained 하다.
---
## 좋은 예시 1: namespace에 환경 · 도메인 · PodSecurity · 운영 label 전부 박기
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/version: "1.24.3"
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
example.com/team: identity-sre
example.com/tier: backend
example.com/slo-tier: tier-1
example.com/data-classification: confidential
example.com/cost-center: cc-1042
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
annotations:
example.com/owner-email: identity-sre@example.com
example.com/runbook: https://runbooks.example.com/identity/auth
example.com/slo-doc: https://slo.example.com/identity/auth
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: default-quota
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: quota
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
spec:
hard:
requests.cpu: "20"
requests.memory: 40Gi
limits.cpu: "40"
limits.memory: 80Gi
pods: "200"
persistentvolumeclaims: "20"
---
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: limits
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
spec:
limits:
- type: Container
default:
cpu: "500m"
memory: 512Mi
defaultRequest:
cpu: "100m"
memory: 128Mi
max:
cpu: "4"
memory: 8Gi
min:
cpu: "10m"
memory: 32Mi
```
**왜 좋은가:**
- `app.kubernetes.io/*` well-known 6종이 모두 있고, 운영 축은 `example.com/*`로 분리되어 selector immutability를 깨지 않는다
- PodSecurity admission이 namespace 레벨에서 `restricted`로 강제 → 이후 Pod spec이 noncompliant면 창조 시점에 거부
- ResourceQuota + LimitRange가 namespace 단위로 고정되어 하나의 서비스가 클러스터를 삼킬 수 없다
- 환경(prod)·도메인(identity)·서비스(auth)가 namespace 이름과 label 양쪽에 드러남
---
## 좋은 예시 2: multi-region prod overlay 디렉터리 (kr-main + kr-dr)
```text
k8s/
base/
app/
units/
identity/
auth/
kustomization.yaml
deployment.yaml
service.yaml
servicemonitor.yaml
pdb.yaml
hpa.yaml
plugins/
ingress-nginx/
cert-manager/
external-secrets/
managing/
flyway-migrate-identity/
overlays/
dev/
kustomization.yaml
staging/
kustomization.yaml
prod/
kr-main/
kustomization.yaml
patches/
auth-replicas.yaml
auth-resources.yaml
auth-topology-spread.yaml
kr-dr/
kustomization.yaml
patches/
auth-replicas.yaml
auth-image-pull-mirror.yaml
```
**왜 좋은가:**
- 1000+ 서비스 스케일에서 단일 overlay/prod로는 region 차이를 표현할 수 없다. region이 overlay 하위 계층이 되어야 한다
- base는 region·환경을 모른다 (원칙 충족)
- DR region은 base의 image pull spec만 mirror로 패치하고 나머지는 공유
---
## 좋은 예시 3: SLO tier 별 기본 default per namespace
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: slo-defaults
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: slo-config
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
example.com/slo-tier: tier-1
data:
availability-slo: "99.95"
rpo-minutes: "5"
rto-minutes: "15"
backup-interval-minutes: "15"
multi-az-required: "true"
pdb-min-available-percent: "50"
```
**왜 좋은가:**
- SLO/RPO/RTO 숫자가 YAML로 문서화되어 audit 가능
- 같은 tier 정의가 팀마다 제각각 drift 되는 일을 막는다
- `example.com/slo-tier` label이 cluster-wide 쿼리 축 제공 (`kubectl get ns -l example.com/slo-tier=tier-1`)
---
## 좋은 예시 4: K3s packaged component disable을 bootstrap 레벨에서 선언
```yaml
# /etc/rancher/k3s/config.yaml (Git-managed, applied identically to every server node)
write-kubeconfig-mode: "0640"
cluster-cidr: "10.42.0.0/16"
service-cidr: "10.43.0.0/16"
cluster-dns: "10.43.0.10"
cluster-domain: "cluster.local"
disable:
- traefik
- servicelb
- local-storage
disable-network-policy: false
tls-san:
- "k3s.prod.example.internal"
- "10.0.0.10"
kube-apiserver-arg:
- "audit-log-path=/var/log/k3s/audit.log"
- "audit-log-maxage=30"
- "audit-log-maxbackup=10"
- "audit-log-maxsize=100"
- "audit-policy-file=/etc/rancher/k3s/audit-policy.yaml"
kubelet-arg:
- "config=/etc/rancher/k3s/kubelet.yaml"
```
**왜 좋은가:**
- prod 스케일에서 traefik / servicelb / local-storage는 전부 외부 컴포넌트로 대체되므로 disable이 기본
- critical config (`cluster-cidr`, `service-cidr`, `cluster-dns`, `cluster-domain`)가 Git 하나의 파일에 고정 → 서버 간 mismatch 불가능
- audit log와 kubelet config가 선언형으로 박힘 → 신규 서버 조인 시 drift 없음
---
## 좋은 예시 5: 도메인 분리 + public/internal/operator ingress host 패턴
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: auth-public
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/version: "1.24.3"
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
example.com/exposure: public
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "8m"
spec:
ingressClassName: nginx-public
tls:
- hosts:
- auth.example.com
secretName: auth-public-tls
rules:
- host: auth.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: auth
port:
number: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: auth-admin
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: admin
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
example.com/exposure: operator-only
annotations:
cert-manager.io/cluster-issuer: internal-ca
nginx.ingress.kubernetes.io/auth-url: "https://sso.ops.example.com/oauth2/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://sso.ops.example.com/oauth2/sign_in?rd=$escaped_request_uri"
nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8"
spec:
ingressClassName: nginx-internal
tls:
- hosts:
- auth.ops.example.com
secretName: auth-admin-tls
rules:
- host: auth.ops.example.com
http:
paths:
- path: /actuator
pathType: Prefix
backend:
service:
name: auth
port:
number: 8081
```
**왜 좋은가:**
- 한 서비스(auth)가 public API와 operator-only admin 포트를 별도 ingress + 별도 ingressClass + 별도 TLS issuer로 분리
- CIDR whitelist + OAuth2 sso forward-auth가 admin endpoint에 강제
- `example.com/exposure` label로 cluster-wide audit 쿼리 가능
---
## 나쁜 예시 1: `default` namespace에 prod workload
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
namespace: default
spec:
replicas: 3
selector:
matchLabels:
app: auth-server
template:
metadata:
labels:
app: auth-server
spec:
containers:
- name: auth
image: registry.example.com/auth:1.24.3
```
**문제:** `default` namespace는 PodSecurity / Quota / NetworkPolicy를 걸기 위한 격리 단위가 될 수 없고, 다른 팀 리소스와 섞인다. 1000-서비스 환경에서 `default`는 영구적으로 비워두는 것이 운영 원칙.
---
## 나쁜 예시 2: `app.kubernetes.io/environment` 사용 (well-known label에 없음)
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/environment: prod # invalid well-known key
```
**문제:** Kubernetes 공식 well-known label set은 `{name,instance,version,component,part-of,managed-by}` 6종뿐. `environment`는 여기 없으므로 **자체 도메인**(`example.com/environment`)을 써야 한다. 다른 팀이 `app.kubernetes.io/env` 같은 변종을 만들어 drift가 퍼진다.
---
## 나쁜 예시 3: `manifests/` 디렉터리에 운영 리소스 직접 배치
```text
/var/lib/rancher/k3s/server/manifests/auth-prod.yaml
/var/lib/rancher/k3s/server/manifests/keycloak-prod.yaml
/var/lib/rancher/k3s/server/manifests/ingress-nginx.yaml
```
**문제:** 멀티 서버 K3s는 이 디렉터리를 서버 간 동기화하지 **않는다**. 서버 A에만 있는 파일은 서버 B 리더가 되면 사라진 것처럼 보인다. source of truth는 Git + Kustomize여야 한다.
---
## 나쁜 예시 4: selector에 버전 / 환경 label 포함
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth
namespace: prod-identity-auth
spec:
selector:
matchLabels:
app.kubernetes.io/name: auth
app.kubernetes.io/version: "1.24.3" # changes on every release
example.com/environment: prod # injected by overlay
template:
metadata:
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/version: "1.24.3"
example.com/environment: prod
spec:
containers:
- name: auth
image: registry.example.com/auth:1.24.3
```
**문제:** `selector.matchLabels`는 Deployment/StatefulSet에서 **immutable**이다. `version`은 배포마다 바뀌고 `environment`는 overlay가 주입한다 → 첫 배포 이후 재apply 시 `field is immutable` 에러로 영구 차단. selector에는 불변 3종(`name`/`instance`/`component`)만.
---
## 나쁜 예시 5: 같은 hostname을 dev와 prod가 공유
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: auth
namespace: dev-identity-auth
spec:
ingressClassName: nginx-public
rules:
- host: auth.example.com # same as prod
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: auth
port:
number: 8080
```
**문제:** 환경 간 host 공유는 TLS cert race, 동일 hostname의 두 ingress 간 routing 불확실성, 외부 모니터링이 어느 환경을 보는지 혼동을 유발한다. dev는 반드시 `auth.dev.example.com` 같이 별도 hostname을 쓴다.
---
## 나쁜 예시 6: K3s traefik manifest 직접 수정으로 prod ingress 커스터마이즈
```bash
vim /var/lib/rancher/k3s/server/manifests/traefik.yaml
# added custom middleware config inline
systemctl restart k3s
```
**문제:** K3s는 재시작 시 이 파일을 packaged 원본으로 overwrite한다. 운영 커스터마이징이 조용히 사라진다. prod 1000-서비스 스케일에서는 `--disable=traefik` 후 ingress-nginx를 별도 컴포넌트로 관리하는 것이 유일한 정답. 유지한다면 **반드시** `HelmChartConfig` 사용.
+564
View File
@@ -0,0 +1,564 @@
# backup / restore 예시
모든 예시는 실제 매니페스트로 `kubectl apply -f` 가능하다.
---
## 좋은 예시 1: Velero 설치 후 BackupStorageLocation / VolumeSnapshotLocation
```yaml
---
apiVersion: velero.io/v1
kind: BackupStorageLocation
metadata:
name: default
namespace: velero
labels:
app.kubernetes.io/part-of: platform-backup
spec:
provider: aws
objectStorage:
bucket: acme-prod-velero-backups
prefix: k3s-prod
config:
region: us-east-1
s3ForcePathStyle: "false"
s3Url: https://s3.us-east-1.amazonaws.com
default: true
accessMode: ReadWrite
credential:
name: velero-s3-credentials
key: cloud
---
apiVersion: velero.io/v1
kind: VolumeSnapshotLocation
metadata:
name: csi-default
namespace: velero
spec:
provider: csi
```
왜 좋은가:
- 백업 저장소가 **클러스터 외부** S3 (같은 cluster MinIO에 넣지 않음)
- credential은 별도 Secret
- CSI snapshot location이 명시됨
❌ 나쁜 예시 1: 같은 cluster 안 MinIO를 백업 저장소로 사용
```yaml
spec:
provider: aws
objectStorage:
bucket: backups
config:
s3Url: http://minio.object-prod.svc.cluster.local:9000 # 같은 cluster!
```
문제:
- cluster 장애 = 백업 동시 소실
- MinIO 자체를 복구하려면 외부 백업이 또 필요 — 순환 의존
---
## 좋은 예시 2: Velero Schedule (tier별 분리, 30일 retention)
```yaml
---
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: gold-daily
namespace: velero
labels:
backup.platform.io/tier: gold
spec:
schedule: "0 2 * * *" # 매일 02:00 UTC
useOwnerReferencesInBackup: true
template:
ttl: 720h0m0s # 30일 retention
includedNamespaces:
- auth-prod
- data-prod
- object-prod
includedResources:
- persistentvolumeclaims
- persistentvolumes
- secrets
- configmaps
- deployments
- statefulsets
- services
- ingresses
- networkpolicies
labelSelector:
matchLabels:
backup.platform.io/tier: gold
snapshotVolumes: true
defaultVolumesToFsBackup: false
csiSnapshotTimeout: 30m
storageLocation: default
volumeSnapshotLocations:
- csi-default
hooks:
resources:
- name: postgres-consistent
includedNamespaces: [data-prod]
labelSelector:
matchLabels:
app.kubernetes.io/name: postgres
pre:
- exec:
container: postgres
command: ["/bin/sh", "-c", "psql -U postgres -c CHECKPOINT"]
onError: Fail
timeout: 2m
---
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: bronze-weekly-fsb
namespace: velero
labels:
backup.platform.io/tier: bronze
spec:
schedule: "0 3 * * 0" # 매주 일요일 03:00 UTC
template:
ttl: 2160h0m0s # 90일
includedNamespaces: ["archive-prod"]
labelSelector:
matchLabels:
backup.platform.io/tier: bronze
snapshotVolumes: false
defaultVolumesToFsBackup: true # kopia/restic FSB
storageLocation: default
```
왜 좋은가:
- Schedule이 tier별로 분리되어 RPO/retention/도구를 구분
- CSI snapshot(gold)과 FSB(bronze)를 목적에 맞게 선택
- Postgres는 pre-hook으로 `CHECKPOINT`를 수행해 crash-consistent에 가까운 스냅샷 확보
- `labelSelector`가 PVC의 `backup.platform.io/tier`와 매칭
---
## 좋은 예시 3: Velero Restore
```yaml
---
apiVersion: velero.io/v1
kind: Restore
metadata:
name: auth-prod-restore-2026-04-16
namespace: velero
spec:
backupName: gold-daily-20260415020000
includedNamespaces: ["auth-prod"]
restorePVs: true
existingResourcePolicy: none # 기존 리소스 보존, 누락된 것만 복원
namespaceMapping:
auth-prod: auth-prod-restore # 검증용 별도 네임스페이스로 복원
labelSelector:
matchLabels:
backup.platform.io/tier: gold
```
왜 좋은가:
- 복원 대상이 `auth-prod-restore`로 분리되어 운영 영향 없이 검증 가능
- `existingResourcePolicy: none`으로 실수 덮어쓰기 방지
- `restorePVs: true`로 PVC/PV까지 함께 복원
---
## 좋은 예시 4: CloudNativePG Cluster + ScheduledBackup + Backup
```yaml
---
apiVersion: v1
kind: Namespace
metadata:
name: data-prod
labels:
pod-security.kubernetes.io/enforce: restricted
---
apiVersion: v1
kind: Secret
metadata:
name: cnpg-s3-credentials
namespace: data-prod
type: Opaque
stringData:
ACCESS_KEY_ID: REPLACE_VIA_VSO
ACCESS_SECRET_KEY: REPLACE_VIA_VSO
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: auth-pg
namespace: data-prod
labels:
app.kubernetes.io/name: auth-pg
app.kubernetes.io/component: database
app.kubernetes.io/part-of: auth-platform
backup.platform.io/tier: gold
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8
primaryUpdateStrategy: unsupervised
postgresql:
parameters:
shared_buffers: "512MB"
max_connections: "200"
wal_compression: "on"
archive_timeout: "60s"
bootstrap:
initdb:
database: auth
owner: auth_app
secret:
name: auth-pg-app
storage:
size: 50Gi
storageClass: fast-ssd-retain
walStorage:
size: 20Gi
storageClass: fast-ssd-retain
monitoring:
enablePodMonitor: true
resources:
requests: {cpu: "500m", memory: "2Gi"}
limits: {cpu: "2", memory: "4Gi"}
backup:
retentionPolicy: "30d"
barmanObjectStore:
destinationPath: s3://acme-prod-pg-backups/auth-pg
endpointURL: https://s3.us-east-1.amazonaws.com
s3Credentials:
accessKeyId:
name: cnpg-s3-credentials
key: ACCESS_KEY_ID
secretAccessKey:
name: cnpg-s3-credentials
key: ACCESS_SECRET_KEY
wal:
compression: gzip
maxParallel: 8
data:
compression: gzip
immediateCheckpoint: true
jobs: 4
affinity:
podAntiAffinityType: required
topologyKey: kubernetes.io/hostname
---
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: auth-pg-daily
namespace: data-prod
spec:
schedule: "0 0 2 * * *" # 매일 02:00 (CNPG는 6-field cron)
backupOwnerReference: self
cluster:
name: auth-pg
method: barmanObjectStore
---
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata:
name: auth-pg-premigration-2026-04-16
namespace: data-prod
spec:
cluster:
name: auth-pg
method: barmanObjectStore
```
왜 좋은가:
- Postgres 16, 3 instances, 자동 failover
- WAL continuous archiving + daily base backup으로 RPO 5분 / PITR 가능
- `ScheduledBackup`이 cron 기반 정기 백업, `Backup`이 on-demand (마이그레이션 직전 등)
- `enablePodMonitor`로 Prometheus 연동
- `podAntiAffinity`로 노드 분산
- `backup.retentionPolicy: 30d`
❌ 나쁜 예시 2: StatefulSet + cron으로 `pg_dump` 하나만
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: pg-dump-nightly
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: dump
image: postgres:16
command: ["sh", "-c", "pg_dumpall -U postgres > /backup/dump.sql"]
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 512Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- { name: tmp, mountPath: /tmp }
- { name: backup, mountPath: /backup }
volumes:
- name: tmp
emptyDir: {}
- name: backup
emptyDir: {}
```
문제:
- PITR 불가 (base backup + WAL 아님)
- single file → 대규모에서 restore 시간 폭증
- logical dump는 replication slot / extension / large object 처리에 구멍
- 같은 cluster의 PVC에 저장 시 장애 시 동시 소실
---
## 좋은 예시 5: CNPG PITR restore (bootstrap.recovery)
```yaml
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: auth-pg-restore
namespace: data-prod
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8
storage:
size: 50Gi
storageClass: fast-ssd-retain
walStorage:
size: 20Gi
storageClass: fast-ssd-retain
bootstrap:
recovery:
source: auth-pg-source
recoveryTarget:
targetTime: "2026-04-16 09:45:00.00+00"
externalClusters:
- name: auth-pg-source
barmanObjectStore:
destinationPath: s3://acme-prod-pg-backups/auth-pg
endpointURL: https://s3.us-east-1.amazonaws.com
s3Credentials:
accessKeyId: {name: cnpg-s3-credentials, key: ACCESS_KEY_ID}
secretAccessKey: {name: cnpg-s3-credentials, key: ACCESS_SECRET_KEY}
wal:
maxParallel: 8
```
왜 좋은가:
- PITR을 declarative CRD로 표현
- 원본 cluster를 건드리지 않고 별도 `auth-pg-restore` 클러스터로 복원
- 특정 시점(`targetTime`)까지 WAL replay
---
## 좋은 예시 6: K3s etcd snapshot + S3 업로드
```ini
# /etc/rancher/k3s/config.yaml (control-plane nodes)
etcd-snapshot-schedule-cron: "0 */6 * * *"
etcd-snapshot-retention: 28
etcd-s3: true
etcd-s3-endpoint: "s3.us-east-1.amazonaws.com"
etcd-s3-bucket: "acme-prod-k3s-etcd"
etcd-s3-folder: "prod-cluster-1"
etcd-s3-region: "us-east-1"
etcd-s3-access-key-file: /var/lib/rancher/k3s/server/etcd-s3-access
etcd-s3-secret-key-file: /var/lib/rancher/k3s/server/etcd-s3-secret
secrets-encryption: true
```
token 별도 보관 (예: 운영자 금고 / 외부 Vault):
```
/var/lib/rancher/k3s/server/token → offline backup, 접근 로그 남김
```
왜 좋은가:
- 6시간마다 etcd snapshot + S3 자동 업로드 + 28개 보관
- secrets encryption 활성화로 snapshot 유출 시 노출 감소
- server token을 snapshot과 같은 위치에 두지 않음
**주의: 이 snapshot은 PVC 데이터를 포함하지 않는다. 반드시 Velero + CNPG backup과 병행.**
---
## 좋은 예시 7: Vault raft snapshot (CronJob)
```yaml
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: vault-raft-snapshot
namespace: vault
spec:
schedule: "0 */6 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
serviceAccountName: vault-snapshot
securityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: snapshot
image: hashicorp/vault@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
env:
- name: VAULT_ADDR
value: https://vault.vault.svc:8200
- name: VAULT_TOKEN
valueFrom:
secretKeyRef:
name: vault-snapshot-token
key: token
command:
- sh
- -c
- |
set -eu
TS=$(date -u +%Y%m%dT%H%M%SZ)
vault operator raft snapshot save /snap/vault-${TS}.snap
aws s3 cp /snap/vault-${TS}.snap s3://acme-prod-vault-snap/ --sse aws:kms
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 512Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
volumeMounts:
- {name: snap, mountPath: /snap}
volumes:
- name: snap
emptyDir: {}
```
왜 좋은가:
- 6시간마다 raft snapshot + S3 (SSE-KMS) 업로드
- snapshot용 scoped token 사용 (최소권한)
- `concurrencyPolicy: Forbid`로 snapshot 중복 실행 방지
---
## 좋은 예시 8: MinIO bucket replication (DR)
```bash
# 소스 클러스터 MinIO에서
mc alias set src https://minio.prod-a.acme.io $SRC_KEY $SRC_SECRET
mc alias set dst https://minio.prod-b.acme.io $DST_KEY $DST_SECRET
mc admin replicate add src dst
mc version enable src/assets
mc version enable dst/assets
mc replicate add src/assets --remote-bucket dst/assets --replicate "delete,delete-marker,existing-objects,metadata-sync"
# DR 발생 시 (소스 완전 장애 후 복구)
mc replicate resync start src/assets --remote-bucket dst/assets
```
왜 좋은가:
- bucket versioning이 replication 전제
- `resync`로 DR 복구 경로 확보
- `mc mirror`를 단독 DR 수단으로 사용하지 않음
❌ 나쁜 예시 3: `mc mirror`만 단독 사용
```bash
mc mirror --overwrite src/assets dst/assets # 현재 객체만 동기화, 버전 이력 없음
```
문제:
- 버전 이력 / 삭제 marker / metadata 누락
- 랜섬웨어 / 실수 삭제 시 복구 불가
---
## 좋은 예시 9: Restore drill 기록 양식
```yaml
# /runbooks/restore-drills/2026-Q1-auth-pg.yaml
drill:
id: drill-2026-q1-auth-pg
component: cloudnativepg:auth-pg
tier: gold
target_rpo: 5m
target_rto: 30m
executed_at: 2026-03-18T14:00:00Z
executor: sre@acme.io
source_backup: barman:auth-pg/base/20260318T020000
restore_target_time: "2026-03-17 23:59:00+00"
restore_cluster: auth-pg-drill
result:
status: success
observed_rpo: 3m
observed_rto: 22m
verification_query: "select count(*) from users where created_at < '2026-03-17 23:59:00'"
verification_result: 1842317
issues:
- description: "WAL fetch parallelism bumped from 4 to 8 for better RTO"
action: "updated Cluster.spec.externalClusters[0].barmanObjectStore.wal.maxParallel to 8"
next_drill_due: 2026-06-18
```
왜 좋은가:
- RPO/RTO 목표 vs 실측을 같이 기록
- 검증 query 결과까지 남김
- 다음 drill 예정일이 명시 → 90일 초과 시 경보
---
## 나쁜 예시 4: "Git에 manifest 있으니 복구 완료"
```
✗ manifests are in Git
✗ so restore is solved
```
문제:
- DB state, Vault state, MinIO objects, K3s cluster state 모두 복구 안 됨
- Argo CD sync만으로는 runtime data가 돌아오지 않음
---
## 나쁜 예시 5: K3s etcd snapshot만 있으면 PVC도 복구된다고 오해
```
✗ k3s etcd-snapshot restore → all data back
```
문제:
- etcd snapshot은 API object 선언만 복구. PVC 안의 파일은 복구 안 됨
- 반드시 Velero + DB-level backup과 병행
+642
View File
@@ -0,0 +1,642 @@
# config / secrets 예시
모든 YAML은 `kubectl apply --dry-run=server -f -` 기준 clean. VSO는 Helm chart `hashicorp/vault-secrets-operator``vault-secrets-operator` namespace에 설치되어 있고, Vault는 `vault` namespace(`https://vault.vault.svc:8200`)에서 기동 중이며, Kubernetes auth method(`auth/kubernetes`)가 활성화되어 있다고 가정한다.
---
## 좋은 예시 1: 비기밀 ConfigMap (hash-suffixed by Kustomize)
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: auth-server-config
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/part-of: identity-platform
data:
application.yaml: |
server:
port: 8080
shutdown: graceful
management:
endpoints:
web:
base-path: /actuator
exposure:
include: health,info,prometheus
server:
port: 9090
spring:
main:
banner-mode: off
datasource:
hikari:
maximum-pool-size: 20
connection-timeout: 5000
logging:
level:
root: INFO
com.example.auth: INFO
```
**왜 좋은가:**
- 비밀값은 하나도 없다(username/password/url 제외). Hikari pool size, log level, actuator 경로 같은 operational config만.
- Kustomize `configMapGenerator`로 hash suffix를 붙이면 Deployment가 자동 rollout.
---
## 좋은 예시 2: VSO 전체 스택 (VaultConnection + VaultAuth + VaultStaticSecret + VaultDynamicSecret + VaultPKISecret)
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: auth-prod
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.29
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: auth-server
namespace: auth-prod
automountServiceAccountToken: true
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultConnection
metadata:
name: vault
namespace: auth-prod
spec:
address: https://vault.vault.svc:8200
skipTLSVerify: false
caCertSecretRef: vault-ca-bundle
headers: {}
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: auth-server
namespace: auth-prod
spec:
vaultConnectionRef: vault
method: kubernetes
mount: kubernetes
kubernetes:
role: auth-server
serviceAccount: auth-server
audiences:
- vault
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: auth-server-oidc-client
namespace: auth-prod
spec:
vaultAuthRef: auth-server
mount: kv
type: kv-v2
path: identity/auth-server/prod/oidc
refreshAfter: 1h
destination:
name: auth-server-oidc-client
create: true
type: Opaque
rolloutRestartTargets:
- kind: Deployment
name: auth-server
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultDynamicSecret
metadata:
name: auth-server-db
namespace: auth-prod
spec:
vaultAuthRef: auth-server
mount: database
path: creds/auth-server-role
destination:
name: auth-server-db
create: true
type: Opaque
transformation:
templates:
DB_URL:
text: 'jdbc:postgresql://identity-postgres.data-prod.svc:5432/auth?user={{ .Secrets.username }}&password={{ .Secrets.password }}&sslmode=require'
DB_USERNAME:
text: '{{ .Secrets.username }}'
DB_PASSWORD:
text: '{{ .Secrets.password }}'
rolloutRestartTargets:
- kind: Deployment
name: auth-server
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultPKISecret
metadata:
name: auth-server-internal-tls
namespace: auth-prod
spec:
vaultAuthRef: auth-server
mount: pki_int
role: auth-server
commonName: auth-server.auth-prod.svc
altNames:
- auth-server.auth-prod.svc.cluster.local
- auth-server
ttl: 24h
destination:
name: auth-server-internal-tls
create: true
type: kubernetes.io/tls
rolloutRestartTargets:
- kind: Deployment
name: auth-server
```
**왜 좋은가:**
- Vault가 source of truth. 모든 비밀이 `kv/identity/auth-server/prod/*` 또는 database/PKI engine에서 발급.
- VSO가 결과물을 표준 Kubernetes Secret(`Opaque`, `kubernetes.io/tls`)으로 materialize.
- Dynamic DB credential은 Postgres role에서 TTL 기반 자동 발급/폐기. Rotation 시 `rolloutRestartTargets`로 Deployment rolling restart.
- PKI Secret은 `kubernetes.io/tls` 타입 → Traefik/앱 TLS에 그대로 소비 가능.
---
## 좋은 예시 3: VSO Secret을 소비하는 Deployment (envFrom + volume 혼합)
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/version: 1.42.0
app.kubernetes.io/managed-by: argocd
spec:
replicas: 6
revisionHistoryLimit: 5
selector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server
template:
metadata:
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/version: 1.42.0
spec:
serviceAccountName: auth-server
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: auth-server
image: registry.example.com/identity/auth-server@sha256:8f3c0a8c6b3a2a7a0f1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
- name: metrics
containerPort: 9090
envFrom:
- configMapRef:
name: auth-server-config
- secretRef:
name: auth-server-db
- secretRef:
name: auth-server-oidc-client
volumeMounts:
- name: internal-tls
mountPath: /var/run/secrets/tls
readOnly: true
- name: appconfig
mountPath: /workspace/config
readOnly: true
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: "2"
memory: 1Gi
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
- name: metrics-exporter
image: registry.example.com/platform/jmx-exporter@sha256:1111111111111111111111111111111111111111111111111111111111111111
ports:
- name: jmx-metrics
containerPort: 9091
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
volumes:
- name: internal-tls
secret:
secretName: auth-server-internal-tls
defaultMode: 0400
- name: appconfig
configMap:
name: auth-server-config
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 64Mi
imagePullSecrets:
- name: registry-example-com
```
**왜 좋은가:**
- VSO가 생성한 `auth-server-db`, `auth-server-oidc-client`를 envFrom으로 소비. 앱 코드는 `DB_USERNAME`, `DB_PASSWORD`, `OIDC_CLIENT_SECRET` 환경변수를 읽기만 함.
- TLS private key는 volume(`/var/run/secrets/tls`, mode 0400)으로만 마운트. env 노출 없음.
- `metrics-exporter` sidecar에는 **어떤 secret도 envFrom/volumeMount로 전달하지 않는다**. Scope 최소화.
- image는 digest pin, `imagePullPolicy: IfNotPresent`.
---
## 나쁜 예시 1: plain Secret manifest + ConfigMap에 비밀 혼재
```yaml
apiVersion: v1
kind: Secret
metadata:
name: auth-server-db
namespace: auth-prod
type: Opaque
stringData:
username: prod-admin
password: S3cur3P@ssw0rd!
---
apiVersion: v1
kind: ConfigMap
metadata:
name: auth-server-config
namespace: auth-prod
data:
application.yaml: |
spring:
datasource:
url: jdbc:postgresql://prod-db:5432/auth
username: prod-admin
password: S3cur3P@ssw0rd!
```
**문제:**
- 운영 비밀이 Git에 평문으로 커밋된다. base64/stringData 여부와 무관.
- ConfigMap에 password가 들어가 있음 → RBAC `configmaps:get` 권한을 가진 모든 SA가 읽을 수 있음.
- secret source가 두 곳에 있어 회전 불가능.
- VSO/ESO/SealedSecrets 어느 경로에도 부합하지 않음.
---
## 좋은 예시 4: ImagePullSecret을 VSO로 Vault에서 sync
```yaml
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: registry-example-com
namespace: auth-prod
spec:
vaultAuthRef: auth-server
mount: kv
type: kv-v2
path: platform/registry/example-com
refreshAfter: 24h
destination:
name: registry-example-com
create: true
type: kubernetes.io/dockerconfigjson
transformation:
templates:
.dockerconfigjson:
text: |
{
"auths": {
"registry.example.com": {
"username": "{{ .Secrets.username }}",
"password": "{{ .Secrets.password }}",
"auth": "{{ printf "%s:%s" .Secrets.username .Secrets.password | b64enc }}"
}
}
}
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: auth-server
namespace: auth-prod
automountServiceAccountToken: true
imagePullSecrets:
- name: registry-example-com
```
**왜 좋은가:**
- registry credential도 Vault가 SoT. 하드코딩 없음.
- VSO가 `kubernetes.io/dockerconfigjson` 타입 Secret을 생성. kubelet이 바로 인식.
- SA에 묶여 있어 Deployment마다 imagePullSecrets 반복 선언 불필요.
---
## 좋은 예시 5: cert-manager + VSO 비교 — Ingress TLS는 cert-manager, internal mTLS는 VSO PKI
cert-manager가 외부 공인 도메인용 `kubernetes.io/tls` Secret을 발급하고, VSO `VaultPKISecret`은 internal service mesh mTLS용 단기 인증서를 발급한다. 두 경로 모두 최종 형태는 `kubernetes.io/tls` Secret으로 동일하므로 앱은 secret name만 구분한다.
```yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: auth-example-com
namespace: auth-prod
spec:
secretName: auth-example-com-tls
issuerRef:
kind: ClusterIssuer
name: letsencrypt-prod
dnsNames:
- auth.example.com
duration: 2160h
renewBefore: 360h
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultPKISecret
metadata:
name: auth-server-internal-tls
namespace: auth-prod
spec:
vaultAuthRef: auth-server
mount: pki_int
role: auth-server
commonName: auth-server.auth-prod.svc
ttl: 24h
destination:
name: auth-server-internal-tls
create: true
type: kubernetes.io/tls
rolloutRestartTargets:
- kind: Deployment
name: auth-server
```
**왜 좋은가:**
- 외부 ACME 인증서는 공인 CA(Let's Encrypt), 내부는 조직 CA(Vault PKI)로 분리.
- 둘 다 같은 Secret 타입이라 Traefik/앱이 동일하게 소비 가능.
- VSO PKI는 24h TTL로 짧게 회전 → lateral movement window 최소화.
---
## 좋은 예시 6: Vault Agent Injector가 K8s Secret 없이 파일로 템플릿 렌더링
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: legacy-report-generator
namespace: reports-prod
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: report-generator
template:
metadata:
labels:
app.kubernetes.io/name: report-generator
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "report-generator"
vault.hashicorp.com/agent-inject-secret-report.conf: "kv/data/reports/smtp"
vault.hashicorp.com/agent-inject-template-report.conf: |
{{- with secret "kv/data/reports/smtp" -}}
[smtp]
host = {{ .Data.data.host }}
port = {{ .Data.data.port }}
username = {{ .Data.data.username }}
password = {{ .Data.data.password }}
{{- end }}
vault.hashicorp.com/secret-volume-path-report.conf: "/vault/secrets"
vault.hashicorp.com/agent-inject-containers: "report-generator"
vault.hashicorp.com/agent-run-as-user: "10001"
vault.hashicorp.com/agent-run-as-group: "10001"
spec:
serviceAccountName: report-generator
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: report-generator
image: registry.example.com/reports/generator@sha256:2222222222222222222222222222222222222222222222222222222222222222
ports:
- name: http
containerPort: 8080
volumeMounts:
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
volumes:
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 32Mi
```
**왜 좋은가:**
- K8s Secret object가 **생성되지 않는다**. RBAC audit이 Secret API 호출 없이 Vault audit log로 대체된다.
- Vault Agent sidecar가 tmpfs에 템플릿 렌더링 → 앱은 파일만 읽음.
- legacy 앱이 INI/TOML 포맷 설정 파일을 요구할 때 적합.
**VSO vs Vault Agent Injector:**
| 항목 | VSO | Vault Agent Injector |
|---|---|---|
| 결과 | K8s Secret | Pod tmpfs 파일 |
| K8s API 노출 | Secret object 존재 | 없음 |
| 소비 방식 | envFrom/volume | file read |
| 회전 시 | `rolloutRestartTargets` | Agent re-render(인메모리) |
| 복잡도 | 낮음(CRD만) | 높음(sidecar/init) |
| 권장 | **운영 기본** | 템플릿/legacy 앱 |
---
## 나쁜 예시 2: Vault Injector annotation을 모든 컨테이너에 적용 + env 렌더링
```yaml
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "auth-server"
vault.hashicorp.com/agent-inject-secret-db: "kv/data/auth-server/db"
vault.hashicorp.com/agent-inject-template-db: |
{{- with secret "kv/data/auth-server/db" -}}
export DB_USERNAME={{ .Data.data.username }}
export DB_PASSWORD={{ .Data.data.password }}
{{- end }}
```
**문제:**
- `agent-inject-containers` 미지정 → sidecar(metrics, proxy) 포함 모든 컨테이너의 `/vault/secrets`가 보임.
- `export DB_PASSWORD=...``source`로 읽는 launcher 스크립트 → process env로 비밀이 흘러 `/proc/<pid>/environ` 노출.
- dynamic lease renew를 활용하지 못하고, 회전 시 rollout trigger 없음.
---
## 좋은 예시 7: SealedSecret (VSO 미도입 환경/bootstrap)
```yaml
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: vault-bootstrap-token
namespace: vault
spec:
encryptedData:
token: AgCd9sK... (public key로 암호화된 blob)
template:
metadata:
name: vault-bootstrap-token
namespace: vault
type: Opaque
```
**왜 좋은가:**
- Git 커밋 가능(public key로 암호화, cluster controller만 복호화).
- VSO 자체를 기동하기 위한 bootstrap credential(Vault root token, unseal key 대신 KMS auto-unseal 권장)에 적합.
- SealedSecrets controller가 `Secret`을 namespace에 materialize.
**주의:**
- 운영에서 **VSO가 기동되면 SealedSecrets 경로는 최소화**. 이중 source of truth 방지.
- Key 회전은 controller의 sealing key rotation 절차 준수.
---
## 좋은 예시 8: EncryptionConfiguration for Secret at-rest (API Server 레벨)
```yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- kms:
apiVersion: v2
name: platform-kms-v2
endpoint: unix:///var/run/kmsplugin/socket.sock
timeout: 3s
- aescbc:
keys:
- name: fallback-2026-q1
secret: c2VjcmV0LTMyLWJ5dGUtZmFsbGJhY2sta2V5LTIwMjZxMS1leGFtcGxl
- identity: {}
```
**K3s 활성화:**
```yaml
# /etc/rancher/k3s/config.yaml
secrets-encryption: true
kube-apiserver-arg:
- "encryption-provider-config=/etc/rancher/k3s/encryption-config.yaml"
- "audit-policy-file=/etc/rancher/k3s/audit-policy.yaml"
- "audit-log-path=/var/log/k3s-audit.log"
```
**왜 좋은가:**
- KMS v2 provider가 primary → envelope encryption, 키는 KMS 외부에 존재.
- `aescbc`는 fallback. `identity`는 마지막(평문), 기존 Secret을 재암호화하기 전 decryption용.
- K3s config.yaml에 `secrets-encryption: true`로 선언. 서버 재시작 후 `kubectl get secrets -A -o json | kubectl replace -f -`로 기존 Secret 재암호화.
---
## 나쁜 예시 3: Kustomize secretGenerator로 운영 비밀 literal
```yaml
# overlays/prod/kustomization.yaml
secretGenerator:
- name: auth-server-db
literals:
- username=prod-admin
- password=S3cur3P@ssw0rd!
```
**문제:**
- 운영 비밀이 Git에 literal 평문 저장.
- Kustomize hash suffix는 비밀 보호가 아님.
- 회전 시 매번 Git 커밋 필요(감사/리뷰 시 비밀 노출).
- 운영은 VSO/ESO/SealedSecrets 경로로만 비밀을 배포해야 한다.
+499
View File
@@ -0,0 +1,499 @@
# db / migration 예시
모든 YAML은 `kubectl apply` 가능하다. 상세 Flyway Job 예시는 `examples/infra/flyway.md` 참조.
---
## 좋은 예시 1: auth-server와 keycloak DB 경계 분리 (CNPG 2 cluster)
```yaml
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: auth-pg
namespace: data-prod
labels:
app.kubernetes.io/name: auth-pg
app.kubernetes.io/part-of: auth-platform
backup.platform.io/tier: gold
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8
bootstrap:
initdb:
database: auth
owner: auth_app
secret: {name: auth-pg-app}
storage: {size: 50Gi, storageClass: fast-ssd-retain}
walStorage: {size: 20Gi, storageClass: fast-ssd-retain}
monitoring: {enablePodMonitor: true}
backup:
retentionPolicy: "30d"
barmanObjectStore:
destinationPath: s3://acme-prod-pg-backups/auth-pg
s3Credentials:
accessKeyId: {name: cnpg-s3-credentials, key: ACCESS_KEY_ID}
secretAccessKey: {name: cnpg-s3-credentials, key: ACCESS_SECRET_KEY}
wal: {compression: gzip, maxParallel: 8}
data: {compression: gzip, immediateCheckpoint: true, jobs: 4}
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: keycloak-pg
namespace: data-prod
labels:
app.kubernetes.io/name: keycloak-pg
app.kubernetes.io/part-of: identity-platform
backup.platform.io/tier: gold
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8
bootstrap:
initdb:
database: keycloak
owner: keycloak
secret: {name: keycloak-pg-app}
storage: {size: 30Gi, storageClass: fast-ssd-retain}
walStorage: {size: 10Gi, storageClass: fast-ssd-retain}
monitoring: {enablePodMonitor: true}
backup:
retentionPolicy: "30d"
barmanObjectStore:
destinationPath: s3://acme-prod-pg-backups/keycloak-pg
s3Credentials:
accessKeyId: {name: cnpg-s3-credentials, key: ACCESS_KEY_ID}
secretAccessKey: {name: cnpg-s3-credentials, key: ACCESS_SECRET_KEY}
wal: {compression: gzip, maxParallel: 8}
data: {compression: gzip, jobs: 4}
```
왜 좋은가:
- auth와 keycloak이 별도 CNPG cluster → 장애 / 업그레이드 영향 분리
- 각각 schema ownership이 분리되어 migration 파이프라인도 분리 가능
- 백업 destination path도 분리 → retention / 암호화 정책 독립
❌ 나쁜 예시 1: 하나의 cluster의 하나의 database에 두 서비스 schema
```yaml
# single CNPG cluster, database=shared
# auth-server uses schema "auth"
# keycloak uses schema "keycloak"
# one Flyway project manages both
```
문제:
- 서비스별 업그레이드 / restore 영향 격리 불가
- Flyway history가 서로 섞임
- 한 서비스가 lock을 오래 잡으면 다른 서비스가 멈춤
---
## 좋은 예시 2: migration을 Helm hook으로 app보다 먼저 실행
```yaml
---
apiVersion: batch/v1
kind: Job
metadata:
name: auth-flyway-migrate
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/component: db-migration
app.kubernetes.io/managed-by: Helm
annotations:
"helm.sh/hook": "pre-upgrade,pre-install"
"helm.sh/hook-weight": "-10"
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded"
spec:
parallelism: 1
completions: 1
backoffLimit: 0
activeDeadlineSeconds: 1800
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile: {type: RuntimeDefault}
containers:
- name: flyway
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
args: ["-X", "migrate"]
env:
- {name: FLYWAY_URL, value: "jdbc:postgresql://auth-pg-rw.data-prod.svc:5432/auth"}
- {name: FLYWAY_USER, value: "auth_app"}
- {name: FLYWAY_LOCATIONS, value: "filesystem:/flyway/sql"}
- {name: FLYWAY_SCHEMAS, value: "auth_server"}
- {name: FLYWAY_DEFAULT_SCHEMA, value: "auth_server"}
- {name: FLYWAY_TABLE, value: "flyway_schema_history"}
- {name: FLYWAY_VALIDATE_ON_MIGRATE, value: "true"}
- {name: FLYWAY_BASELINE_ON_MIGRATE, value: "false"}
- {name: FLYWAY_CLEAN_DISABLED, value: "true"}
- name: FLYWAY_PASSWORD
valueFrom: {secretKeyRef: {name: auth-pg-app, key: password}}
resources:
requests: {cpu: "100m", memory: "256Mi"}
limits: {cpu: "1", memory: "1Gi"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
volumeMounts:
- {name: sql, mountPath: /flyway/sql, readOnly: true}
- {name: tmp, mountPath: /tmp}
volumes:
- name: sql
configMap: {name: auth-flyway-sql}
- name: tmp
emptyDir: {}
```
왜 좋은가:
- Helm hook으로 app install/upgrade보다 **먼저** 실행 (`-10` weight)
- `before-hook-creation,hook-succeeded` 삭제 정책으로 이전 Job 깨끗이 정리
- `cleanDisabled=true` 명시 (실수로 `flyway clean` 방지)
- `parallelism: 1`, `backoffLimit: 0`, `activeDeadlineSeconds: 1800`
- digest pinning, restricted PSA
---
## 좋은 예시 3: Argo CD sync wave로 순서 지정
```yaml
---
apiVersion: batch/v1
kind: Job
metadata:
name: auth-flyway-migrate
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/component: db-migration
annotations:
argocd.argoproj.io/sync-wave: "-1"
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
parallelism: 1
completions: 1
backoffLimit: 0
activeDeadlineSeconds: 1800
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: flyway
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
args: ["-X", "migrate"]
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 1Gi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
template:
metadata:
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: auth-server
image: registry.example.com/identity/auth-server:1.24.0
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { memory: 1536Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
```
왜 좋은가:
- Argo CD는 sync-wave가 낮은 것부터 실행
- Helm hook과 혼용하지 않음 (한쪽만 사용)
❌ 나쁜 예시 2: Helm hook + Argo CD hook 혼용
```yaml
annotations:
"helm.sh/hook": "pre-upgrade"
"argocd.argoproj.io/sync-wave": "-1"
"argocd.argoproj.io/hook": Sync
```
문제:
- Argo CD가 Helm chart를 렌더링할 때 Helm hook을 일반 리소스로 취급해 sync 순서가 꼬임
- 실행이 중복되거나 누락됨
- 한 방식으로 통일할 것
---
## 좋은 예시 4: Expand → Migrate → Contract 3단계 릴리즈
### 배경
`users` 테이블의 `email` 컬럼 (NULL 허용)을 NOT NULL + 정규화된 `email_canonical` 컬럼으로 바꾸고 싶다.
### Release 1 — Expand
`V120__add_email_canonical_nullable.sql`:
```sql
-- flyway:executeInTransaction=false
ALTER TABLE users ADD COLUMN email_canonical text;
CREATE INDEX CONCURRENTLY idx_users_email_canonical ON users(email_canonical);
```
`V121__backfill_email_canonical.sql` (같은 릴리즈 또는 별도 배치 Job):
```sql
UPDATE users
SET email_canonical = lower(trim(email))
WHERE email_canonical IS NULL
AND email IS NOT NULL;
```
앱은 쓰기: `email` + `email_canonical` 둘 다 채움. 읽기: 여전히 `email`.
### Release 2 — Migrate
앱 읽기 경로를 `email_canonical`로 전환. 새 가입/수정은 `email_canonical`만 보장.
`V122__add_email_canonical_not_null.sql`:
```sql
-- 이 시점에는 모든 row에 email_canonical이 채워져 있어야 함
ALTER TABLE users ALTER COLUMN email_canonical SET NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_email_canonical_unique UNIQUE (email_canonical);
```
### Release 3 — Contract
앱이 `email` 컬럼을 더 이상 읽지/쓰지 않는 버전으로 완전히 롤아웃된 뒤.
`V130__drop_legacy_email_column.sql`:
```sql
ALTER TABLE users DROP COLUMN email;
```
왜 좋은가:
- 각 릴리즈가 N-1 ↔ N 동시 운영 가능
- `CREATE INDEX CONCURRENTLY``-- flyway:executeInTransaction=false`로 분리
- Contract는 backfill + 앱 전환이 모두 끝난 뒤 별도 릴리즈
❌ 나쁜 예시 3: 한 릴리즈에 expand + contract
```sql
-- V100__rename_email.sql
ALTER TABLE users RENAME COLUMN email TO email_old;
ALTER TABLE users ADD COLUMN email text NOT NULL DEFAULT '';
-- 앱이 어느 버전이든 장애 발생 가능
```
문제:
- rolling deploy 중간에 앱이 N-1 / N 모두 실행 → 컬럼 없음 / 이름 다름으로 에러
- rollback 시 DB 상태가 앞서가 있어 N-1 앱이 기동 안 됨
---
## 좋은 예시 5: PITR 복구 계획 (CNPG)
```yaml
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: auth-pg-restore
namespace: data-prod
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8
storage: {size: 50Gi, storageClass: fast-ssd-retain}
walStorage: {size: 20Gi, storageClass: fast-ssd-retain}
bootstrap:
recovery:
source: auth-pg-source
recoveryTarget:
targetTime: "2026-04-16 09:45:00+00" # 잘못된 migration 직전
externalClusters:
- name: auth-pg-source
barmanObjectStore:
destinationPath: s3://acme-prod-pg-backups/auth-pg
s3Credentials:
accessKeyId: {name: cnpg-s3-credentials, key: ACCESS_KEY_ID}
secretAccessKey: {name: cnpg-s3-credentials, key: ACCESS_SECRET_KEY}
wal: {maxParallel: 8}
```
왜 좋은가:
- 운영 cluster는 건드리지 않고 `auth-pg-restore`로 복원
- `recoveryTarget.targetTime`을 분단위로 지정
- 복원 후 검증 → 운영 전환은 별도 runbook
---
## 좋은 예시 6: non-transactional DDL을 별도 migration 파일로
`V200__create_idx_users_last_login.sql`:
```sql
-- flyway:executeInTransaction=false
-- Long-running DDL. Run in low-traffic window.
-- Runtime estimate: ~15min on 50M rows.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_last_login
ON users(last_login_at);
```
왜 좋은가:
- `CREATE INDEX CONCURRENTLY`는 Postgres에서 트랜잭션 내 실행 불가
- Flyway 8.2+ `executeInTransaction=false` directive로 파일 단위 제어
- 주석에 runtime 추정치 / 영향 명시
❌ 나쁜 예시 4: 트랜잭션 내 CREATE INDEX CONCURRENTLY
```sql
-- V200__.sql (기본 트랜잭션 모드)
CREATE INDEX CONCURRENTLY idx_users_last_login ON users(last_login_at);
-- → ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
```
문제:
- Flyway가 자동으로 트랜잭션을 열기 때문에 실패
- `-- flyway:executeInTransaction=false`가 필수
---
## 좋은 예시 7: 운영 절차 runbook snippet
```text
# auth-server DB schema change — 2026-04-16 02:00 UTC maintenance window
## Pre-check (T-1d)
1. Pending migration 검토: 로컬 `flyway info`
2. PR review + migration 영향 분석 문서 작성 (expand/migrate/contract 단계)
3. Backup 상태 확인:
kubectl -n data-prod get scheduledbackup auth-pg-daily
kubectl -n data-prod get backup -l cnpg.io/cluster=auth-pg --sort-by=.metadata.creationTimestamp
## T-5min
1. On-demand backup:
cat <<EOF | kubectl apply -f -
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata:
name: auth-pg-pre-$(date -u +%Y%m%dT%H%M%SZ)
namespace: data-prod
spec:
cluster: {name: auth-pg}
method: barmanObjectStore
EOF
2. Argo CD sync (dry-run):
argocd app diff auth-server-prod
## Apply
1. argocd app sync auth-server-prod
→ Flyway Job이 sync-wave -1로 먼저 실행
→ Deployment는 wave 0에서 롤아웃
2. Flyway Job 로그 확인:
kubectl -n auth-prod logs job/auth-flyway-migrate
3. Deployment rollout 확인:
kubectl -n auth-prod rollout status deploy/auth-server
## Post-check
1. flyway info (적용 결과)
2. 앱 스모크 테스트
3. DB 메트릭 (slow query, error rate)
4. Next PITR recovery point 확인
```
왜 좋은가:
- migration 직전 on-demand backup
- migration → app rollout 순서가 선언 (sync-wave)으로 보장됨
- 실패 시 PITR 복구 지점이 명확
---
## 나쁜 예시 5: 앱 시작 시 자동 migration
```yaml
# Spring Boot application.properties
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
# 앱이 기동될 때마다 Flyway migrate 수행
```
문제:
- replicas=3이면 3개 Pod가 동시에 migrate 시도 (Flyway advisory lock이 막아주지만 기동 latency 증가)
- app rollout 실패와 migration 실패가 섞임 — 원인 추적 어려움
- 신규 Pod가 기동되는 rolling restart 시에도 매번 validate 수행
---
## 나쁜 예시 6: pg_dump 하나만으로 운영 복구
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: pg-dump-nightly
spec:
schedule: "0 3 * * *"
# ... pg_dumpall > /backup/dump.sql
```
문제:
- PITR 불가, RPO = 24h
- replication slot / extension / large object 누락
- 대규모 DB에서 restore 시간 폭증
- 같은 cluster 안 PVC에 저장하면 동시 소실
---
## 나쁜 예시 7: U__ undo migration 작성
```
flyway/
V120__add_column.sql
U120__drop_column.sql ← OSS Flyway는 실행 불가
```
문제:
- Flyway Community(OSS)는 undo 미지원 → `flyway undo`가 에러
- rollback 전략은 forward-only migration + PITR로 대체
+560
View File
@@ -0,0 +1,560 @@
# Flyway 예시
전 예시는 `kubectl apply -f` 가능한 완성 매니페스트다. 1000+ 서비스 규모에서 복사/수정해 쓸 수 있도록 full manifest로 구성했다.
---
## 좋은 예시 1: 완전한 Flyway Job (Helm hook 패턴)
### (1) ConfigMap — migration SQL
```yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
name: auth-flyway-sql
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/component: db-migration
app.kubernetes.io/part-of: auth-platform
app.kubernetes.io/managed-by: Helm
data:
V1__init_auth_schema.sql: |
CREATE TABLE IF NOT EXISTS users (
id bigserial PRIMARY KEY,
email text NOT NULL,
display_name text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(lower(email));
V2__add_refresh_tokens.sql: |
CREATE TABLE IF NOT EXISTS refresh_tokens (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash bytea NOT NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
V3__add_last_login_column.sql: |
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at timestamptz;
V4__create_idx_last_login_concurrently.sql: |
-- flyway:executeInTransaction=false
-- Long-running DDL. Schedule in low-traffic window.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_last_login
ON users(last_login_at);
R__refresh_active_users_view.sql: |
CREATE OR REPLACE VIEW active_users AS
SELECT id, email, display_name, last_login_at
FROM users
WHERE last_login_at > now() - interval '30 days';
```
### (2) Vault Secrets Operator — DB password
```yaml
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: auth-pg-app
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/component: db-migration
spec:
type: kv-v2
mount: kv
path: auth-prod/postgres/app
destination:
name: auth-pg-app
create: true
type: Opaque
refreshAfter: 1h
vaultAuthRef: vault-auth-auth-prod
```
### (3) Flyway Job — pre-upgrade / pre-install
```yaml
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: auth-flyway
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/component: db-migration
---
apiVersion: batch/v1
kind: Job
metadata:
name: auth-flyway-migrate
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
app.kubernetes.io/component: db-migration
app.kubernetes.io/part-of: auth-platform
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/version: "2026.04.16"
annotations:
"helm.sh/hook": "pre-upgrade,pre-install"
"helm.sh/hook-weight": "-10"
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded"
spec:
parallelism: 1
completions: 1
backoffLimit: 0
activeDeadlineSeconds: 1800
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/component: db-migration
spec:
serviceAccountName: auth-flyway
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile: {type: RuntimeDefault}
initContainers:
- name: flyway-info
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
imagePullPolicy: IfNotPresent
args: ["info"]
env: &flywayEnv
- {name: FLYWAY_URL, value: "jdbc:postgresql://auth-pg-rw.data-prod.svc:5432/auth?sslmode=require"}
- {name: FLYWAY_USER, value: "auth_app"}
- {name: FLYWAY_LOCATIONS, value: "filesystem:/flyway/sql"}
- {name: FLYWAY_SCHEMAS, value: "auth_server"}
- {name: FLYWAY_DEFAULT_SCHEMA, value: "auth_server"}
- {name: FLYWAY_TABLE, value: "flyway_schema_history"}
- {name: FLYWAY_VALIDATE_ON_MIGRATE, value: "true"}
- {name: FLYWAY_BASELINE_ON_MIGRATE, value: "false"}
- {name: FLYWAY_OUT_OF_ORDER, value: "false"}
- {name: FLYWAY_MIXED, value: "false"}
- {name: FLYWAY_CLEAN_DISABLED, value: "true"}
- name: FLYWAY_PASSWORD
valueFrom: {secretKeyRef: {name: auth-pg-app, key: password}}
resources:
requests: {cpu: "50m", memory: "128Mi"}
limits: {cpu: "500m", memory: "512Mi"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
volumeMounts:
- {name: sql, mountPath: /flyway/sql, readOnly: true}
- {name: tmp, mountPath: /tmp}
- name: flyway-validate
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
imagePullPolicy: IfNotPresent
args: ["validate"]
env: *flywayEnv
resources:
requests: {cpu: "50m", memory: "128Mi"}
limits: {cpu: "500m", memory: "512Mi"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
volumeMounts:
- {name: sql, mountPath: /flyway/sql, readOnly: true}
- {name: tmp, mountPath: /tmp}
containers:
- name: flyway-migrate
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
imagePullPolicy: IfNotPresent
args: ["-X", "migrate"]
env: *flywayEnv
resources:
requests: {cpu: "100m", memory: "256Mi"}
limits: {cpu: "1", memory: "1Gi"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
volumeMounts:
- {name: sql, mountPath: /flyway/sql, readOnly: true}
- {name: tmp, mountPath: /tmp}
volumes:
- name: sql
configMap: {name: auth-flyway-sql}
- name: tmp
emptyDir: {}
```
왜 좋은가:
- Helm hook으로 app Deployment보다 **먼저** 실행 (`pre-upgrade,pre-install`, weight `-10`)
- `before-hook-creation,hook-succeeded` 삭제 정책으로 과거 Job 정리
- initContainer로 `info` + `validate`를 먼저 실행해 실패를 앞당김
- 메인 container에서 `migrate` (advisory lock 덕분에 같은 Job이 중복 실행돼도 직렬화됨)
- `FLYWAY_CLEAN_DISABLED=true` (production 필수)
- `FLYWAY_BASELINE_ON_MIGRATE=false`, `FLYWAY_OUT_OF_ORDER=false`
- digest pinning, restricted PSA, anchor/alias로 env 중복 제거
- `parallelism: 1`, `backoffLimit: 0`, `activeDeadlineSeconds: 1800`
---
## 좋은 예시 2: Argo CD sync-wave 패턴
```yaml
---
apiVersion: batch/v1
kind: Job
metadata:
name: auth-flyway-migrate
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/component: db-migration
app.kubernetes.io/managed-by: argocd
annotations:
argocd.argoproj.io/sync-wave: "-1"
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
parallelism: 1
completions: 1
backoffLimit: 0
activeDeadlineSeconds: 1800
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
# (containers 세부는 예시 1과 동일; 요지만 재현)
- name: flyway
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
args: ["-X", "migrate"]
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 1Gi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
namespace: auth-prod
annotations:
argocd.argoproj.io/sync-wave: "0"
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
template:
metadata:
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: auth-server
image: registry.example.com/identity/auth-server:1.24.0
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { memory: 1536Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
```
왜 좋은가:
- Argo CD가 wave `-1``0` 순서로 sync
- Helm hook과 혼용하지 않음
- `BeforeHookCreation` 정책으로 이전 Job 정리 후 새 Job 실행
---
## 좋은 예시 3: non-transactional DDL 전용 migration
`V4__create_idx_last_login_concurrently.sql`:
```sql
-- flyway:executeInTransaction=false
-- CREATE INDEX CONCURRENTLY는 Postgres에서 트랜잭션 내 실행 불가.
-- Flyway 8.2+ directive로 파일 단위 트랜잭션 비활성화.
-- Runtime estimate: 약 15분 (50M rows 기준).
-- Deploy window: 주간 트래픽 저점 (예: 화요일 03:00 UTC)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_last_login
ON users(last_login_at);
```
왜 좋은가:
- 파일 단독으로 분리 (다른 statement 없음)
- 주석에 runtime / window 명시
- `IF NOT EXISTS`로 재실행 안전성 (CREATE INDEX CONCURRENTLY 실패 시 INVALID 인덱스가 남을 수 있음 — 별도 cleanup 필요)
❌ 나쁜 예시 1: 트랜잭션 내 CREATE INDEX CONCURRENTLY
```sql
-- V4__.sql (executeInTransaction directive 없음)
CREATE INDEX CONCURRENTLY idx_users_last_login ON users(last_login_at);
```
문제:
- Flyway가 자동으로 트랜잭션을 열어 실행 → `ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block`
- 해결: `-- flyway:executeInTransaction=false` directive
---
## 좋은 예시 4: history table schema를 명시적으로 분리
env:
```yaml
- {name: FLYWAY_CREATE_SCHEMAS, value: "false"}
- {name: FLYWAY_INIT_SQL, value: "CREATE SCHEMA IF NOT EXISTS auth_server; CREATE SCHEMA IF NOT EXISTS flyway_history"}
- {name: FLYWAY_DEFAULT_SCHEMA, value: "flyway_history"}
- {name: FLYWAY_SCHEMAS, value: "flyway_history,auth_server"}
- {name: FLYWAY_TABLE, value: "flyway_schema_history"}
```
왜 좋은가:
- history table은 `flyway_history.flyway_schema_history`
- migration 대상 schema는 `auth_server`
- `createSchemas=false` 조건 하에서 `initSql`로 schema 사전 생성
---
## 좋은 예시 5: 운영 절차 (Helm + on-demand CNPG backup 연계)
```bash
# 1. pending migration 확인 (로컬)
docker run --rm -v $PWD/sql:/flyway/sql:ro \
-e FLYWAY_URL=jdbc:postgresql://stage.../auth \
-e FLYWAY_USER=auth_app -e FLYWAY_PASSWORD=... \
flyway/flyway@sha256:... info
# 2. PR review + migration 영향 분석
# 3. 운영 배포 직전 on-demand backup
cat <<EOF | kubectl apply -f -
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata:
name: auth-pg-pre-2026-04-16
namespace: data-prod
spec:
cluster: {name: auth-pg}
method: barmanObjectStore
EOF
# 4. Helm upgrade (pre-upgrade hook이 Flyway Job 실행)
helm upgrade auth-server ./charts/auth-server \
--namespace auth-prod \
--values values/prod.yaml \
--atomic --timeout 20m
# 5. Flyway Job 로그 확인
kubectl -n auth-prod logs job/auth-flyway-migrate --all-containers
# 6. Deployment rollout 확인
kubectl -n auth-prod rollout status deploy/auth-server --timeout=10m
# 7. flyway info 재실행 (post-check)
```
왜 좋은가:
- migration 직전 on-demand backup으로 PITR 지점 확보
- `helm upgrade --atomic`으로 실패 시 자동 롤백
- hook이 `hook-succeeded` 정책으로 정리됨
---
## 좋은 예시 6: expand → migrate → contract (여러 릴리즈)
### Release 1 (V120~V121) — Expand
```sql
-- V120__add_email_canonical_nullable.sql
-- flyway:executeInTransaction=false
ALTER TABLE users ADD COLUMN email_canonical text;
CREATE INDEX CONCURRENTLY idx_users_email_canonical ON users(email_canonical);
```
```sql
-- V121__backfill_email_canonical.sql
-- Flyway 기본 트랜잭션 모드 — 소규모 테이블용. 대용량은 별도 배치 Job.
UPDATE users
SET email_canonical = lower(trim(email))
WHERE email_canonical IS NULL
AND email IS NOT NULL;
```
앱: 쓰기 시 두 컬럼 채움. 읽기는 아직 `email`.
### Release 2 (V122) — Migrate
```sql
-- V122__add_email_canonical_constraints.sql
ALTER TABLE users ALTER COLUMN email_canonical SET NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_email_canonical_unique UNIQUE (email_canonical);
```
앱: 읽기/쓰기 모두 `email_canonical`. 기존 `email`도 fallback 유지.
### Release 3 (V130) — Contract
```sql
-- V130__drop_legacy_email_column.sql
ALTER TABLE users DROP COLUMN email;
```
왜 좋은가:
- N-1 ↔ N 동시 배포 허용
- 각 릴리즈가 독립 롤백 가능 (V130 제외 모두 non-destructive)
- expand와 contract가 같은 릴리즈에 섞이지 않음
---
## 좋은 예시 7: repeatable migration은 정의성 오브젝트에만
```
sql/
V120__add_email_canonical_nullable.sql
V121__backfill_email_canonical.sql
V122__add_email_canonical_constraints.sql
V130__drop_legacy_email_column.sql
R__refresh_active_users_view.sql
R__user_signup_function.sql
```
왜 좋은가:
- 핵심 schema change는 versioned
- view / function만 repeatable — 체크섬 변경 시 재적용
❌ 나쁜 예시 2: 순서 중요한 schema change를 R__로
```
R__create_users_table.sql ← 잘못. 순서 보장 없음
R__add_refresh_tokens.sql
```
문제:
- repeatable은 ordering 보장 없음 — 의존성 있는 change에 부적합
---
## 나쁜 예시 3: app startup에 migration 숨김
```properties
# application.properties
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
spring.flyway.out-of-order=true
```
문제:
- replicas=3이면 Pod 3개가 동시 migrate 시도 (advisory lock이 직렬화는 하지만 기동 latency 증가)
- app rollout 실패와 migration 실패가 섞임
- 신규 Pod 기동마다 validate 수행 → 오차 탐지 시점이 흐려짐
- `baseline-on-migrate=true` + `out-of-order=true` 조합은 migration history 신뢰도 저하
---
## 나쁜 예시 4: validate 실패 후 바로 repair
```bash
flyway validate || flyway repair
flyway migrate
```
문제:
- history 문제를 원인 분석 없이 덮음
- repair를 정상 운영 흐름처럼 사용 — 탐지력 저하
---
## 나쁜 예시 5: 적용된 migration 파일 수정
```
V42__add_refresh_token_column.sql
# 처음엔 빈 migration
# prod apply 후 컬럼 타입을 나중에 editor로 수정
```
문제:
- checksum mismatch → validate 실패
- 환경 간 재현성 깨짐
- 대응은 "새 V__ migration으로 교정"
---
## 나쁜 예시 6: U__ undo migration 작성
```
V120__add_column.sql
U120__drop_column.sql ← OSS Flyway는 실행 불가
```
문제:
- `flyway undo`는 Teams/Enterprise 전용
- OSS 환경에서는 U__ 파일이 실행되지 않아 오해 유발
- rollback은 forward-only + PITR로
---
## 나쁜 예시 7: parallelism 누락 + 재시도 무한
```yaml
spec:
# parallelism, backoffLimit, activeDeadlineSeconds 모두 누락
template:
spec:
restartPolicy: OnFailure # 무한 재시도 유발
```
문제:
- `backoffLimit` 기본 6 + `restartPolicy: OnFailure` → 실패 시 지수 backoff로 계속 재시도
- `activeDeadlineSeconds` 없음 → hang된 migration이 영원히 살아있음
- advisory lock이 걸린 실패 Job이 새 Job을 블록
---
## 나쁜 예시 8: Helm hook + Argo CD hook 혼용
```yaml
annotations:
"helm.sh/hook": "pre-upgrade"
"helm.sh/hook-weight": "-10"
argocd.argoproj.io/sync-wave: "-1"
argocd.argoproj.io/hook: Sync
```
문제:
- Argo CD가 Helm chart를 렌더링할 때 Helm hook annotation을 일반 리소스의 annotation으로 해석
- 결과적으로 Flyway Job이 일반 리소스로 취급되거나, 두 시스템이 서로 다른 시점에 Job을 만들어 race 발생
- 하나의 배포 도구에 맞춰 한쪽만 사용할 것
+400
View File
@@ -0,0 +1,400 @@
# K3s-specific 예시
모든 config 파일과 manifest는 1000+ 서비스 production 기준. YAML은 `kubectl apply --server-side --dry-run=server` 통과.
config.yaml은 `k3s server --help`와 공식 docs에 대응하는 키만 사용.
---
## 좋은 예시 1: prod server config.yaml (disable 세트 + audit + etcd snapshot S3)
```yaml
# /etc/rancher/k3s/config.yaml
# Single source of truth, identically applied to every server node via Ansible/CI.
write-kubeconfig-mode: "0640"
# --- Cluster network (must match on ALL server nodes) ---
cluster-cidr: "10.42.0.0/16"
service-cidr: "10.43.0.0/16"
cluster-dns: "10.43.0.10"
cluster-domain: "cluster.local"
flannel-backend: "vxlan"
# --- Disable packaged components (prod defaults) ---
disable:
- traefik
- servicelb
- local-storage
disable-cloud-controller: false
disable-network-policy: false
disable-helm-controller: false
# --- TLS SAN for kube-apiserver cert ---
tls-san:
- "k3s.prod.example.internal"
- "10.0.1.10"
- "10.0.1.11"
- "10.0.1.12"
# --- Audit logging ---
kube-apiserver-arg:
- "audit-log-path=/var/log/k3s/audit.log"
- "audit-log-maxage=30"
- "audit-log-maxbackup=10"
- "audit-log-maxsize=100"
- "audit-policy-file=/etc/rancher/k3s/audit-policy.yaml"
- "feature-gates=ServerSideApply=true"
# --- Kubelet hardening ---
kubelet-arg:
- "config=/etc/rancher/k3s/kubelet.yaml"
# --- etcd snapshot to S3 (every 6h, keep 72h) ---
etcd-snapshot-schedule-cron: "0 */6 * * *"
etcd-snapshot-retention: 12
etcd-s3: true
etcd-s3-endpoint: "s3.ap-northeast-2.amazonaws.com"
etcd-s3-bucket: "k3s-etcd-backups-prod"
etcd-s3-region: "ap-northeast-2"
etcd-s3-folder: "prod-cluster"
# etcd-s3-access-key / etcd-s3-secret-key loaded from /etc/rancher/k3s/.env via systemd EnvironmentFile
# --- Registries mirror (cluster-internal pull accelerator) ---
# Not using embedded-registry (Spegel); using external Harbor mirror instead.
# See /etc/rancher/k3s/registries.yaml.
# --- Node labels / taints applied to this server's kubelet ---
node-label:
- "example.com/role=control-plane"
- "example.com/environment=prod"
node-taint:
- "node-role.kubernetes.io/control-plane=:NoSchedule"
```
**왜 좋은가:**
- `cluster-cidr` / `service-cidr` / `cluster-dns` / `cluster-domain` / `flannel-backend` / `disable` 세트가 Git 하나의 파일에 고정 → 다음 server 노드 조인 시 mismatch 불가
- audit log 설정이 kube-apiserver에 강제 주입됨 (SOC2/ISO27001 요구)
- etcd snapshot이 6시간 주기 + S3 업로드로 DR 대비
- ssm key는 파일에 없고 systemd EnvironmentFile로 주입 (Secret을 Git에 박지 않음)
---
## 좋은 예시 2: config.yaml.d drop-in 분할 (역할별 파일)
```yaml
# /etc/rancher/k3s/config.yaml.d/10-networking.yaml
cluster-cidr: "10.42.0.0/16"
service-cidr: "10.43.0.0/16"
flannel-backend: "vxlan"
```
```yaml
# /etc/rancher/k3s/config.yaml.d/20-audit.yaml
kube-apiserver-arg:
- "audit-log-path=/var/log/k3s/audit.log"
- "audit-policy-file=/etc/rancher/k3s/audit-policy.yaml"
```
```yaml
# /etc/rancher/k3s/config.yaml.d/30-etcd-backup.yaml
etcd-snapshot-schedule-cron: "0 */6 * * *"
etcd-snapshot-retention: 12
etcd-s3: true
etcd-s3-endpoint: "s3.ap-northeast-2.amazonaws.com"
etcd-s3-bucket: "k3s-etcd-backups-prod"
```
**왜 좋은가:**
- 역할별 파일 = 팀별 CODEOWNERS 분리 (네트워크 / 감사 / DR)
- 변경 diff가 좁아짐
- K3s는 drop-in 파일들을 병합해서 로드
---
## 좋은 예시 3: Traefik을 유지해야 할 때 (dev 클러스터) `HelmChartConfig`
```yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
labels:
app.kubernetes.io/name: traefik
app.kubernetes.io/instance: traefik-dev
app.kubernetes.io/component: ingress-controller
app.kubernetes.io/part-of: platform
app.kubernetes.io/managed-by: argocd
example.com/environment: dev
spec:
valuesContent: |-
deployment:
replicas: 2
ports:
web:
forwardedHeaders:
trustedIPs:
- 10.0.0.0/8
- 172.16.0.0/12
proxyProtocol:
trustedIPs:
- 10.0.0.0/8
websecure:
tls:
enabled: true
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
podSecurityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
metrics:
prometheus:
enabled: true
serviceMonitor:
enabled: true
```
**왜 좋은가:**
- packaged manifest를 직접 수정하지 않음
- `metadata.name` + `namespace`가 K3s 생성 `HelmChart`와 일치 → override가 merge됨
- secret/TLS 민감값은 `valuesSecrets`로 분리 가능 (이 예시는 non-sensitive만 보여줌)
- ServiceMonitor 활성화로 observability 자동 연결
---
## 좋은 예시 4: embedded registry mirror (Spegel) opt-in + registries.yaml
```yaml
# /etc/rancher/k3s/config.yaml (partial — applied to every node, server AND agent)
embedded-registry: true
```
```yaml
# /etc/rancher/k3s/registries.yaml
mirrors:
docker.io:
endpoint:
- "https://harbor.prod.example.internal"
registry.k8s.io:
endpoint:
- "https://harbor.prod.example.internal"
"*":
# Spegel will also share images between nodes via p2p
configs:
"harbor.prod.example.internal":
auth:
username: "robot$k3s-pull"
password: "__HARBOR_PULL_TOKEN__"
tls:
insecure_skip_verify: false
ca_file: "/etc/rancher/k3s/harbor-ca.crt"
```
**네트워크 전제 (반드시 검증):**
```bash
# From each node, to every other node:
nc -zv <other-node-ip> 5001 # Spegel p2p gossip
nc -zv <other-node-ip> 6443 # Local registry + K3s supervisor
```
**왜 좋은가:**
- 공식 문서 기준 포트 (`TCP 5001 + TCP 6443`) 정확히 반영
- external mirror (Harbor) + intra-cluster p2p 공유 조합 → airgap 경계 대비
- `embedded-registry: true`가 **모든 노드 (server+agent)의 config.yaml에 동일**하게 박혀야 함
---
## 좋은 예시 5: local-path를 dev/test에만 제한 (StorageClass 레벨)
```yaml
# local-path: default false, only used when explicitly requested
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-path
annotations:
storageclass.kubernetes.io/is-default-class: "false"
labels:
app.kubernetes.io/name: local-path
app.kubernetes.io/instance: local-path-dev
app.kubernetes.io/component: storage
app.kubernetes.io/part-of: platform
app.kubernetes.io/managed-by: argocd
example.com/environment: dev
example.com/storage-tier: local-ephemeral
provisioner: rancher.io/local-path
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: longhorn-replicated
annotations:
storageclass.kubernetes.io/is-default-class: "true"
labels:
app.kubernetes.io/name: longhorn
app.kubernetes.io/instance: longhorn-prod
app.kubernetes.io/component: storage
app.kubernetes.io/part-of: platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
example.com/storage-tier: replicated-persistent
provisioner: driver.longhorn.io
allowVolumeExpansion: true
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
parameters:
numberOfReplicas: "3"
staleReplicaTimeout: "30"
fromBackup: ""
fsType: "ext4"
dataLocality: "best-effort"
```
**왜 좋은가:**
- `local-path`는 default가 아니고 `example.com/storage-tier: local-ephemeral`로 dev에서만 수용
- prod default는 Longhorn replicated (3 replica) + `reclaimPolicy: Retain`
- DB/Vault/MinIO PVC는 `storageClassName: longhorn-replicated` 명시
---
## 좋은 예시 6: registries.yaml에서 production pull-through mirror
```yaml
# /etc/rancher/k3s/registries.yaml (every node)
mirrors:
docker.io:
endpoint:
- "https://harbor.prod.example.internal/v2/dockerhub-proxy"
quay.io:
endpoint:
- "https://harbor.prod.example.internal/v2/quay-proxy"
registry.k8s.io:
endpoint:
- "https://harbor.prod.example.internal/v2/k8s-proxy"
ghcr.io:
endpoint:
- "https://harbor.prod.example.internal/v2/ghcr-proxy"
configs:
"harbor.prod.example.internal":
tls:
ca_file: "/etc/rancher/k3s/harbor-ca.crt"
auth:
username: "robot$k3s-pull"
password: "__HARBOR_PULL_TOKEN__"
```
**왜 좋은가:**
- public registry rate limit / downtime이 클러스터 pull을 못 죽임
- Harbor에서 CVE scan + image signing 검증
- 모든 노드에 동일 파일 (Ansible/Fleet push)
---
## 나쁜 예시 1: packaged traefik.yaml 직접 edit
```bash
ssh k3s-server-1
sudo vim /var/lib/rancher/k3s/server/manifests/traefik.yaml
# added forwardedHeaders.trustedIPs inline
sudo systemctl restart k3s
```
**문제:** K3s는 재시작 시 이 파일을 packaged 기본값으로 overwrite한다. 커스터마이징이 조용히 사라지고 서버별로 drift까지 생긴다. `HelmChartConfig`만 허용되는 경로.
---
## 나쁜 예시 2: server 간 서로 다른 critical 플래그
```yaml
# k3s-server-1: /etc/rancher/k3s/config.yaml
cluster-cidr: "10.42.0.0/16"
disable: [ traefik, servicelb ]
```
```yaml
# k3s-server-2: /etc/rancher/k3s/config.yaml
cluster-cidr: "10.44.0.0/16" # mismatched
disable: [ traefik ] # mismatched
```
**문제:** `critical configuration value mismatch` 로 server-2의 join이 실패하거나, 최악의 경우 이전 값이 캐시되어 silent drift가 생긴다. critical 값은 **Git 하나의 파일**로 통일해야 한다.
---
## 나쁜 예시 3: embedded registry mirror를 켜고 firewall 포트 미개방
```yaml
# /etc/rancher/k3s/config.yaml (all nodes)
embedded-registry: true
```
```bash
# On each node, firewalld / iptables only allows 6443, 10250, 8472
# Port 5001 is CLOSED between nodes
```
**문제:** Spegel은 **TCP 5001 (p2p) + TCP 6443 (registry + supervisor)** 양쪽이 모든 노드 간 reachable해야 한다. 5001이 막혀있으면 p2p gossip 실패로 image sharing이 작동하지 않고, pull 지연이 오히려 커진다. 공식 기준: `https://docs.k3s.io/installation/registry-mirror`.
---
## 나쁜 예시 4: 운영 AddOn을 서버마다 scp로 push
```bash
scp ingress-custom.yaml k3s-server-1:/var/lib/rancher/k3s/server/manifests/
# forgot server-2 and server-3
```
**문제:** K3s는 이 디렉터리를 server 간 동기화하지 않는다. 리더가 server-2로 바뀌면 AddOn이 사라진 것처럼 보인다. Git + ArgoCD/Flux가 단일 진입점이어야 한다.
---
## 나쁜 예시 5: prod postgres StatefulSet을 `local-path`에 배치
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-postgres-0
namespace: prod-data-postgres
spec:
storageClassName: local-path
accessModes: [ ReadWriteOnce ]
resources:
requests:
storage: 200Gi
```
**문제:** local-path = 노드 hostPath. 노드가 죽으면 PVC 데이터도 죽는다. 스냅샷 불가, 복제 불가, 마이그레이션 불가. prod DB는 Longhorn replicated / Ceph RBD / 외부 CSI 필수.
---
## 나쁜 예시 6: Traefik 유지하면서 `HelmChartConfig` 이름을 잘못 박음
```yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik-custom # WRONG: must match the packaged HelmChart name
namespace: kube-system
spec:
valuesContent: |-
deployment:
replicas: 3
```
**문제:** `HelmChartConfig``metadata.name`은 K3s가 생성한 `HelmChart`와 **이름·namespace 모두 일치**해야 override가 merge된다. `traefik-custom`은 무시되고, override가 반영되지 않는다. 올바른 이름은 `traefik`.
+668
View File
@@ -0,0 +1,668 @@
# 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
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 경로 없음
+548
View File
@@ -0,0 +1,548 @@
# Kustomize 예시
모든 예시는 Kustomize v5 문법 기준. 렌더 검증:
```bash
kubectl kustomize <dir> | kubectl apply --server-side --field-manager=ci --dry-run=server -f -
```
---
## 좋은 예시 1: base / components / overlays 전체 구조 + 실제 base `kustomization.yaml`
```text
k8s/
base/
app/units/identity/auth/
kustomization.yaml
deployment.yaml
service.yaml
servicemonitor.yaml
pdb.yaml
hpa.yaml
components/
with-topology-spread-zone/
kustomization.yaml
patch.yaml
with-pdb-tier1/
kustomization.yaml
patch.yaml
overlays/
prod/kr-main/
kustomization.yaml
patches/
auth-resources.yaml
auth-ingress-host.yaml
```
```yaml
# k8s/base/app/units/identity/auth/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- servicemonitor.yaml
- pdb.yaml
- hpa.yaml
labels:
- pairs:
app.kubernetes.io/name: auth
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
includeSelectors: false
includeTemplates: true
```
**왜 좋은가:**
- base가 환경·region을 모른다 (namespace / replicas / host / image tag 전부 없음)
- `labels:` (v5) 사용, `commonLabels` 없음 → selector immutability 안전
- `includeTemplates: true`로 Pod label에는 전파되어 observability 쿼리 가능
- selector에 들어가는 label은 base의 Deployment 내부에서 명시적으로 고정
---
## 좋은 예시 2: base Deployment (완전 apply-ready)
```yaml
# k8s/base/app/units/identity/auth/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
annotations:
example.com/owner-email: identity-sre@example.com
spec:
replicas: 2
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth
app.kubernetes.io/component: api
template:
metadata:
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8081"
prometheus.io/path: "/actuator/prometheus"
spec:
serviceAccountName: auth
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
terminationGracePeriodSeconds: 45
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth
app.kubernetes.io/component: api
containers:
- name: auth
image: registry.example.com/auth:placeholder
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: management
containerPort: 8081
protocol: TCP
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75 -XX:+UseG1GC"
envFrom:
- configMapRef:
name: auth-config
- secretRef:
name: auth-secrets
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 1Gi
startupProbe:
httpGet:
path: /actuator/health/liveness
port: management
periodSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: management
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: management
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /app/cache
volumes:
- name: tmp
emptyDir:
sizeLimit: 64Mi
- name: cache
emptyDir:
sizeLimit: 256Mi
```
**왜 좋은가:**
- image는 `:placeholder`, overlay의 `images:`가 digest로 patch → base는 버전 모름
- `revisionHistoryLimit: 5` → 대규모 cluster에서 ReplicaSet 누적 방지
- PodSecurity restricted 호환 (non-root, seccomp RuntimeDefault, capabilities drop ALL, readOnlyRootFilesystem)
- startup/liveness/readiness 3종이 타이밍 분리 (startup 150s, liveness 30s, readiness 15s 윈도우)
- topologySpreadConstraints로 zone별 분산
- `automountServiceAccountToken: false` (ServiceAccount token을 쓰지 않는 워크로드)
---
## 좋은 예시 3: overlay prod/kr-main — 환경 차이만
```yaml
# k8s/overlays/prod/kr-main/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: prod-identity-auth
resources:
- ../../../base/app/units/identity/auth
components:
- ../../../components/with-topology-spread-zone
- ../../../components/with-pdb-tier1
labels:
- pairs:
example.com/environment: prod
example.com/region: kr-main
example.com/slo-tier: tier-1
includeSelectors: false
includeTemplates: true
images:
- name: registry.example.com/auth
digest: "sha256:f1a2b3c4d5e6f7081920aabbccddeeff00112233445566778899aabbccddeeff"
replicas:
- name: auth
count: 6
patches:
- target:
kind: Deployment
name: auth
path: patches/auth-resources.yaml
- target:
kind: Ingress
name: auth-public
patch: |-
- op: replace
path: /spec/rules/0/host
value: auth.example.com
- op: replace
path: /spec/tls/0/hosts/0
value: auth.example.com
```
```yaml
# k8s/overlays/prod/kr-main/patches/auth-resources.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth
spec:
selector:
matchLabels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth
template:
metadata:
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: auth
image: registry.example.com/auth-server:1.24.0
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "2"
memory: 2Gi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
```
**왜 좋은가:**
- overlay 자체가 짧음 (base를 재작성하지 않음)
- digest 기반 image pinning
- `components:`로 zone spread + PDB tier-1을 재사용
- `labels:` 사용, `includeSelectors: false` → selector immutability 안전
- replicas override는 HPA minReplicas와 일치 (HPA base에서 `minReplicas: 6`으로 설정되어 있다고 가정)
---
## 좋은 예시 4: Kustomize Component — `with-pdb-tier1`
```yaml
# k8s/components/with-pdb-tier1/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
resources:
- pdb.yaml
```
```yaml
# k8s/components/with-pdb-tier1/pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/slo-tier: tier-1
spec:
minAvailable: 50%
unhealthyPodEvictionPolicy: AlwaysAllow
selector:
matchLabels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth
app.kubernetes.io/component: api
```
**왜 좋은가:**
- `kind: Component`로 선언 → 여러 overlay에서 `components:` 키로 재사용
- tier-1의 PDB 정책(50% minAvailable)이 단일 파일에 고정
- 다른 tier는 별도 component (`with-pdb-tier2`, `with-pdb-tier3`)
---
## 좋은 예시 5: ConfigMap generator + hash suffix를 활용한 자동 rollout
```yaml
# k8s/base/app/units/identity/auth/kustomization.yaml (with generator)
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
configMapGenerator:
- name: auth-config
files:
- application.yaml=config/application.yaml
- logback.xml=config/logback.xml
options:
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/component: config
generatorOptions:
disableNameSuffixHash: false
```
**왜 좋은가:**
- ConfigMap 내용 변경 시 hash suffix가 바뀜 → Deployment가 새 이름을 참조 → rolling update 자동 트리거
- annotation 기반 "checksum" hack 불필요
- Secret은 generator로 만들지 않고 External Secrets로 관리
---
## 좋은 예시 6: HPA v2 + behavior (base 리소스)
```yaml
# k8s/base/app/units/identity/auth/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: auth
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 25
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 4
periodSeconds: 30
selectPolicy: Max
```
**왜 좋은가:**
- HPA v2 behavior로 scaleDown stabilization (5분) vs scaleUp aggressive (즉시) 분리
- overlay는 `minReplicas` / `maxReplicas`만 override하고 behavior는 상속
---
## 나쁜 예시 1: `commonLabels`로 environment 주입 → selector immutable 에러
```yaml
# k8s/overlays/prod/kustomization.yaml (BAD)
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: prod-identity-auth
resources:
- ../../base/app/units/identity/auth
commonLabels:
example.com/environment: prod
```
**문제:** `commonLabels``spec.selector.matchLabels`에 자동 주입된다. 이미 live 상태인 Deployment/StatefulSet에 apply하면 `The Deployment "auth" is invalid: spec.selector: Invalid value: ...: field is immutable` 로 차단. 해결: `labels:` + `includeSelectors: false`로 교체.
---
## 나쁜 예시 2: overlay가 base를 거의 재작성
```text
k8s/base/app/units/identity/auth/deployment.yaml (150 lines)
k8s/overlays/prod/deployment.yaml (140 lines, 95% identical)
k8s/overlays/staging/deployment.yaml (140 lines)
k8s/overlays/dev/deployment.yaml (135 lines)
```
**문제:** overlay가 base의 95%를 복붙 + 몇 줄 수정. drift 발생 시점부터 base가 의미 없어진다. 해결: overlay는 `patches:` + `images:` + `replicas:` + `labels:`만 쓰고 전체 리소스는 base에서 가져온다.
---
## 나쁜 예시 3: 운영 secret을 `secretGenerator`로 plaintext Git 커밋
```yaml
# k8s/overlays/prod/kustomization.yaml (BAD)
secretGenerator:
- name: auth-secrets
literals:
- OAUTH_CLIENT_SECRET=s3cr3t-prod-value
- DB_PASSWORD=prod-db-password
```
**문제:** plaintext secret이 Git에 박힌다. 해결: External Secrets Operator + Vault / AWS Secrets Manager / Bitwarden Secrets. 또는 SealedSecrets (public key encrypted).
---
## 나쁜 예시 4: `patchesStrategicMerge` / `patchesJson6902` (deprecated)
```yaml
# k8s/overlays/prod/kustomization.yaml (BAD, v5 deprecated)
patchesStrategicMerge:
- patches/auth-resources.yaml
patchesJson6902:
- target:
group: apps
version: v1
kind: Deployment
name: auth
path: patches/auth-env.yaml
```
**문제:** 두 필드는 Kustomize v5에서 deprecated (여전히 동작하지만 신규 사용 금지). 하나의 `patches:` 필드로 통합되어 strategic merge + JSON patch 양쪽을 지원하므로 혼재할 이유 없음. 해결: `patches:` 단일 키 사용.
---
## 나쁜 예시 5: base에 환경 host / domain 고정
```yaml
# k8s/base/app/units/identity/auth/ingress.yaml (BAD)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: auth-public
spec:
rules:
- host: auth.example.com # prod host hardcoded in base
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: auth
port:
number: 8080
```
**문제:** base가 prod를 전제한다. dev/staging overlay가 host를 교체하려고 `patches:`를 추가해야 하고, base는 더 이상 환경 중립이 아니다. 해결: base에서는 host를 placeholder (`auth.placeholder.invalid`)로 두고 overlay `patches:`에서 주입.
---
## 나쁜 예시 6: `bases:` 사용 (v2.1에서 `resources:`로 통합됨)
```yaml
# k8s/overlays/prod/kustomization.yaml (BAD)
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
bases:
- ../../base/app/units/identity/auth
```
**문제:** `bases:`는 v2.1에서 `resources:`에 흡수됨. 신규 코드에서 사용 금지. 해결: `resources:` 사용.
---
## 나쁜 예시 7: HPA가 있는 Deployment에 overlay `replicas:`로 고정값 주입
```yaml
# k8s/overlays/prod/kustomization.yaml (BAD — conflicts with HPA)
replicas:
- name: auth
count: 3
```
(한편 HPA는 `minReplicas: 6 / maxReplicas: 20`)
**문제:** Kustomize가 `replicas: 3`으로 apply → HPA가 즉시 6으로 끌어올림 → 매 ArgoCD sync마다 `out-of-sync` flap. 해결: HPA 활성 리소스에서는 overlay `replicas:`를 쓰지 않고, HPA `minReplicas`를 환경별로 patch.
+855
View File
@@ -0,0 +1,855 @@
# MinIO 예시
MinIO Operator + Tenant CRD (`minio.min.io/v2`) + KES + Vault transit 기준. 모든 manifest는 `kubectl apply` 적용 가능한 완전한 형태다.
---
## 좋은 예시 1: Namespace + Tenant configuration Secret
```yaml
---
apiVersion: v1
kind: Namespace
metadata:
name: minio-prod
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
app.kubernetes.io/part-of: storage-platform
---
apiVersion: v1
kind: Secret
metadata:
name: minio-tenant-env
namespace: minio-prod
type: Opaque
stringData:
config.env: |
export MINIO_ROOT_USER="REPLACE_VIA_VSO"
export MINIO_ROOT_PASSWORD="REPLACE_VIA_VSO"
export MINIO_STORAGE_CLASS_STANDARD="EC:4"
export MINIO_STORAGE_CLASS_RRS="EC:2"
export MINIO_BROWSER_REDIRECT_URL="https://minio-console.internal.example.com"
export MINIO_SERVER_URL="https://s3.example.com"
export MINIO_IDENTITY_OPENID_CONFIG_URL="https://auth.example.com/realms/platform/.well-known/openid-configuration"
export MINIO_IDENTITY_OPENID_CLIENT_ID="minio"
export MINIO_IDENTITY_OPENID_CLAIM_NAME="policy"
export MINIO_IDENTITY_OPENID_SCOPES="openid,profile,email"
export MINIO_PROMETHEUS_AUTH_TYPE="jwt"
---
# 실 운영에서는 VSO가 이 Secret을 채움
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: minio-root-creds
namespace: minio-prod
spec:
vaultAuthRef: default
mount: kv
path: minio/prod/root
type: kv-v2
refreshAfter: 24h
destination:
name: minio-tenant-env
create: false
overwrite: true
transformation:
excludeRaw: true
templates:
config.env:
text: |
export MINIO_ROOT_USER="{{ .Secrets.username }}"
export MINIO_ROOT_PASSWORD="{{ .Secrets.password }}"
export MINIO_STORAGE_CLASS_STANDARD="EC:4"
export MINIO_BROWSER_REDIRECT_URL="https://minio-console.internal.example.com"
export MINIO_SERVER_URL="https://s3.example.com"
export MINIO_IDENTITY_OPENID_CONFIG_URL="https://auth.example.com/realms/platform/.well-known/openid-configuration"
export MINIO_IDENTITY_OPENID_CLIENT_ID="minio"
export MINIO_IDENTITY_OPENID_CLIENT_SECRET="{{ .Secrets.oidc_client_secret }}"
export MINIO_IDENTITY_OPENID_CLAIM_NAME="policy"
export MINIO_IDENTITY_OPENID_SCOPES="openid,profile,email"
export MINIO_PROMETHEUS_AUTH_TYPE="jwt"
```
**왜 좋은가:**
- Tenant configuration은 **shell-source 형식**(`export KEY=VALUE`) Secret으로 전달 (Operator 규약)
- Root credential을 Vault KV에서 VSO가 주입 — Git에 평문 없음
- OIDC 통합 (Keycloak), storage class EC:4, Prometheus JWT auth 한 파일에 고정
- `MINIO_SERVER_URL`로 외부 S3 endpoint 명시 (presigned URL 생성 시 사용)
---
## 좋은 예시 2: Tenant CR — 4 server × 4 volume + KES + TLS
```yaml
apiVersion: minio.min.io/v2
kind: Tenant
metadata:
name: minio
namespace: minio-prod
labels:
app.kubernetes.io/name: minio
app.kubernetes.io/instance: minio-prod
app.kubernetes.io/part-of: storage-platform
app.kubernetes.io/managed-by: minio-operator
annotations:
prometheus.io/path: /minio/v2/metrics/cluster
prometheus.io/port: "9000"
prometheus.io/scrape: "true"
spec:
image: quay.io/minio/minio:RELEASE.2025-01-20T14-49-07Z
imagePullPolicy: IfNotPresent
mountPath: /export
configuration:
name: minio-tenant-env
requestAutoCert: true
certConfig:
commonName: minio.minio-prod.svc.cluster.local
organizationName:
- example.com
dnsNames:
- minio.minio-prod.svc.cluster.local
- "*.minio-hl.minio-prod.svc.cluster.local"
- s3.example.com
pools:
- name: pool-0
servers: 4
volumesPerServer: 4
volumeClaimTemplate:
metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 500Gi
storageClassName: local-xfs-retain
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
cpu: "4"
memory: 8Gi
securityContext:
runAsUser: 1000
runAsGroup: 1000
runAsNonRoot: true
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containerSecurityContext:
runAsUser: 1000
runAsGroup: 1000
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
v1.min.io/tenant: minio
v1.min.io/pool: pool-0
topologyKey: kubernetes.io/hostname
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
v1.min.io/tenant: minio
tolerations:
- key: storage
operator: Equal
value: dedicated
effect: NoSchedule
features:
bucketDNS: false
domains:
console: https://minio-console.internal.example.com
minio:
- https://s3.example.com
kes:
image: quay.io/minio/kes:2025-01-16T16-24-39Z
replicas: 2
kesSecret:
name: kes-configuration
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
runAsUser: 1000
runAsGroup: 1000
runAsNonRoot: true
fsGroup: 1000
containerSecurityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
prometheusOperator: true
podManagementPolicy: Parallel
exposeServices:
minio: true
console: false
logging:
anonymous: false
json: true
quiet: false
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: minio
namespace: minio-prod
spec:
minAvailable: 3
unhealthyPodEvictionPolicy: AlwaysAllow
selector:
matchLabels:
v1.min.io/tenant: minio
```
**왜 좋은가:**
- `servers × volumesPerServer = 4 × 4 = 16` drive → erasure coding 최소 요건 충족, `EC:4` 기본 parity (4 drive 장애 허용)
- `requestAutoCert: true` + `certConfig.dnsNames`로 Operator가 API/Console TLS 자동 발급
- `podAntiAffinity` hostname required → 한 node에 MinIO pod 복수 배치 금지 (EC 의미 보존)
- KES가 별도 2 replica로 사이드카 없이 Deployment로 분리 (Tenant CR에서 관리됨)
- `exposeServices.console: false` → Console은 Tenant Service에서 Ingress로 별도 처리만 허용
- `minAvailable: 3` → 4 server 중 1 동시 drain까지 허용 (write quorum 보존)
---
## 나쁜 예시 1: 단일 Deployment로 MinIO
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: minio
spec:
replicas: 1
template:
spec:
containers:
- name: minio
image: minio/minio
args: ["server", "/data"]
env:
- name: MINIO_ROOT_USER
value: minioadmin
- name: MINIO_ROOT_PASSWORD
value: minioadmin
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
emptyDir: {}
```
**문제:**
- Single-drive MinIO → erasure coding 없음, 1 drive 장애 = 전체 data loss
- Deployment = 재시작 시 PVC binding 보장 없음, 복수 replica 시 동일 volume 충돌
- emptyDir → pod 재시작 시 모든 object 사라짐
- 기본 `minioadmin/minioadmin` credential → 공개 인터넷 스캐너가 수 분 내 탈취
- Operator + Tenant가 자동화하는 인증서, 서비스, headless, auto-restart를 전부 수제로 다시 만들어야 함
---
## 좋은 예시 3: KES configuration + Vault transit
```yaml
---
apiVersion: v1
kind: Secret
metadata:
name: kes-configuration
namespace: minio-prod
type: Opaque
stringData:
server-config.yaml: |
version: v1
address: 0.0.0.0:7373
admin:
identity: disabled
tls:
key: /tmp/kes/server.key
cert: /tmp/kes/server.cert
policy:
minio-app:
allow:
- /v1/key/create/minio-*
- /v1/key/generate/minio-*
- /v1/key/decrypt/minio-*
- /v1/key/bulk/decrypt/minio-*
- /v1/key/list/minio-*
- /v1/status
- /v1/metrics
- /v1/api
identities:
- ${MINIO_KES_IDENTITY}
keystore:
vault:
endpoint: https://vault.vault.svc.cluster.local:8200
engine: transit
version: v1
namespace: ""
prefix: minio
approle:
id: ${VAULT_APPROLE_ID}
secret: ${VAULT_APPROLE_SECRET}
retry: 15s
tls:
ca: /tmp/kes/vault-ca.crt
status:
ping: 10s
---
# Vault AppRole credential은 VSO 또는 별도 Secret으로 주입
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: kes-vault-approle
namespace: minio-prod
spec:
vaultAuthRef: default
mount: kv
path: minio/kes/approle
type: kv-v2
refreshAfter: 24h
destination:
name: kes-vault-approle
create: true
overwrite: true
```
그리고 bucket에 SSE-KMS 적용:
```bash
mc alias set minio https://s3.example.com $ROOT_USER $ROOT_PASS
# Vault transit에 key 생성
mc admin kms key create minio minio-critical
# bucket에 SSE-KMS 기본 적용
mc encrypt set sse-kms minio-critical minio/critical-bucket
```
**왜 좋은가:**
- KES가 Vault transit을 key store로 사용 → master key는 Vault가 관리, MinIO는 DEK만 캐시
- KES policy로 `minio-*` prefix key만 access 허용 (최소 권한)
- AppRole credential은 Vault → VSO → Secret 경로
- bucket level SSE-KMS → 업로드되는 모든 object가 per-object DEK로 자동 암호화
---
## 나쁜 예시 2: KES 없이 평문 저장
```yaml
# Tenant CR
spec:
kes: {} # 미설정
# ... SSE 설정 없음
```
```bash
mc cp secret.pdf minio/bucket/secret.pdf
# object가 disk에 평문 저장
```
**문제:**
- PVC가 탈취되거나 물리 drive가 반출되면 평문 유출
- 감사/규제 요구(GDPR, PCI-DSS, ISO 27001) 위반
- SSE-S3를 대신 쓰더라도 master key가 MinIO 자체에 있어 키 라이프사이클 관리 불가
---
## 좋은 예시 4: Probe — live + cluster-read
Tenant CR이 자동으로 probe를 구성하지만, 커스텀 오버라이드가 필요할 때:
```yaml
spec:
pools:
- name: pool-0
# ...
containers:
- name: minio
livenessProbe:
httpGet:
path: /minio/health/live
port: 9000
scheme: HTTPS
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /minio/health/cluster/read
port: 9000
scheme: HTTPS
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
startupProbe:
httpGet:
path: /minio/health/live
port: 9000
scheme: HTTPS
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 5
```
**왜 좋은가:**
- `readinessProbe``/minio/health/cluster/read`**read quorum** 검사. rolling update 중에도 read가 가능하면 Service에 남아있음
- `/minio/health/cluster` (write quorum)을 readiness로 쓰면 rolling 재시작 시 pod가 전부 빠져 완전 unavailable
- `livenessProbe`는 단순 프로세스 생존만 확인 → 일시적 quorum 상실로 pod 강제 재시작 방지
- HTTPS scheme (requestAutoCert과 일치)
---
## 나쁜 예시 3: readiness를 write quorum으로
```yaml
readinessProbe:
httpGet:
path: /minio/health/cluster
port: 9000
periodSeconds: 5
failureThreshold: 1
```
**문제:**
- rolling update로 pod 1개를 재시작하면 write quorum이 일시적으로 무너져 살아있는 pod들도 NotReady
- Service가 endpoint를 전부 제거 → **읽기도 불가능**
- `failureThreshold: 1` + 5초 주기 → 한 번 느린 응답으로 pod 빠짐
---
## 좋은 예시 5: Ingress — API는 공개, Console은 내부
```yaml
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: minio-api
namespace: minio-prod
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/backend-protocol: HTTPS
spec:
ingressClassName: nginx
tls:
- hosts:
- s3.example.com
secretName: minio-api-ingress-tls
rules:
- host: s3.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minio
port:
number: 443
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: minio-console
namespace: minio-prod
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"
nginx.ingress.kubernetes.io/backend-protocol: HTTPS
spec:
ingressClassName: nginx-internal
tls:
- hosts:
- minio-console.internal.example.com
secretName: minio-console-ingress-tls
rules:
- host: minio-console.internal.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minio-console
port:
number: 9443
```
**왜 좋은가:**
- API Ingress는 `proxy-body-size: 0` + request/response buffering off → 대용량 multipart upload 지원
- Console은 내부 ingress class + IP whitelist + OIDC forward-auth 2중 보호
- `backend-protocol: HTTPS` → MinIO의 auto-cert TLS를 TLS passthrough 형태로 전달 (인증서 SAN 보존)
---
## 나쁜 예시 4: Console 외부 공개
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: minio-all
spec:
rules:
- host: minio.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minio
port:
number: 9090
```
**문제:**
- Console이 인터넷에 그대로 노출 → root/admin credential brute force 표면 확장
- OIDC forward-auth 없음 → 기본 login 페이지가 공격자에게 노출
- IP 제한 없음
- Bucket 목록, access key 관리, 사용자 관리가 모두 공개 domain에 위치
---
## 좋은 예시 6: ServiceMonitor (Prometheus bearer-token)
먼저 MinIO 내부에서 scrape token 발급:
```bash
mc admin prometheus generate minio cluster
# 출력에 bearer token과 scrape config가 나옴
```
그 결과 token을 Secret로 저장:
```yaml
---
apiVersion: v1
kind: Secret
metadata:
name: minio-prometheus-token
namespace: minio-prod
type: Opaque
stringData:
token: "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9..."
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: minio
namespace: minio-prod
labels:
app.kubernetes.io/name: minio
release: kube-prometheus-stack
spec:
selector:
matchLabels:
v1.min.io/tenant: minio
endpoints:
- port: https-minio
scheme: https
path: /minio/v2/metrics/cluster
interval: 30s
scrapeTimeout: 10s
bearerTokenSecret:
name: minio-prometheus-token
key: token
tlsConfig:
insecureSkipVerify: false
ca:
secret:
name: minio-tls
key: ca.crt
serverName: minio.minio-prod.svc.cluster.local
```
**왜 좋은가:**
- `MINIO_PROMETHEUS_AUTH_TYPE=jwt` 와 매칭 (기본값)
- `/minio/v2/metrics/cluster`는 cluster-wide view (replication lag, bucket 사용량, API latency)
- TLS 검증 유지 (`insecureSkipVerify: false`, CA bundle 제공)
- 외부 노출 없이 내부 scrape만
---
## 좋은 예시 7: Bucket 초기화 (Job) — versioning + Object Lock + lifecycle
```yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
name: minio-bootstrap
namespace: minio-prod
data:
init.sh: |
#!/bin/sh
set -eu
mc alias set minio https://minio.minio-prod.svc.cluster.local "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" --api S3v4
# Object Lock은 bucket 생성 시점에만 활성화 가능
mc mb --with-lock minio/critical-audit || true
mc retention set --default COMPLIANCE 2555d minio/critical-audit # 7년 보관
# Versioning + lifecycle
mc mb minio/app-data || true
mc version enable minio/app-data
mc ilm add --expire-noncurrent-days 90 minio/app-data
mc ilm add --expire-incomplete-upload-days 7 minio/app-data
# SSE-KMS 기본 적용
mc encrypt set sse-kms minio-app-key minio/app-data
mc encrypt set sse-kms minio-critical-key minio/critical-audit
# Service account 발급 (앱 전용, 최소 권한 policy)
mc admin policy create minio auth-server-rw /policies/auth-server-rw.json
mc admin user svcacct add minio "$MINIO_ROOT_USER" \
--access-key "$AUTH_SERVER_ACCESS_KEY" \
--secret-key "$AUTH_SERVER_SECRET_KEY" \
--policy /policies/auth-server-rw.json || true
echo "bootstrap complete"
auth-server-rw.json: |
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::app-data/*", "arn:aws:s3:::app-data"]
}
]
}
---
apiVersion: batch/v1
kind: Job
metadata:
name: minio-bootstrap
namespace: minio-prod
spec:
backoffLimit: 3
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: OnFailure
serviceAccountName: minio-bootstrap
containers:
- name: mc
image: quay.io/minio/mc:RELEASE.2025-01-17T23-25-50Z
command: ["/bin/sh", "/scripts/init.sh"]
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 256Mi }
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: minio-root-creds
key: username
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: minio-root-creds
key: password
- name: AUTH_SERVER_ACCESS_KEY
valueFrom:
secretKeyRef:
name: auth-server-minio-svcacct
key: access_key
- name: AUTH_SERVER_SECRET_KEY
valueFrom:
secretKeyRef:
name: auth-server-minio-svcacct
key: secret_key
volumeMounts:
- name: scripts
mountPath: /scripts
- name: policies
mountPath: /policies
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumes:
- name: scripts
configMap:
name: minio-bootstrap
defaultMode: 0755
items:
- key: init.sh
path: init.sh
- name: policies
configMap:
name: minio-bootstrap
items:
- key: auth-server-rw.json
path: auth-server-rw.json
```
**왜 좋은가:**
- `mc mb --with-lock`은 bucket 생성 시점에만 Object Lock 활성화 가능 — Job이 그 타이밍을 보장
- COMPLIANCE 모드 7년 retention = 감사/규제 요구 충족 (root도 bypass 불가)
- `app-data` bucket은 versioning + lifecycle (90일 noncurrent expire + 7일 incomplete abort)
- service account는 특정 bucket prefix만 접근 가능한 policy로 제한
- `backoffLimit: 3` + idempotent 명령 (`|| true`) → 재실행 안전
---
## 나쁜 예시 5: mc mirror만으로 DR
```bash
# 매일 자정 crontab
mc mirror minio/critical remote-minio/critical-backup
```
**문제:**
- `mc mirror`**현재 object만** 동기화 — 버전 히스토리 유실
- Object Lock 상태, bucket policy, IAM 설정 미복제
- 메타데이터 중 일부(tag, legal hold) 누락
- RPO = 1일 (하루 단위 손실), replication은 async ms 단위 RPO
- DR 연습(resync 절차) 불가
대안: `mc admin replicate add` (site replication, IAM + bucket + object 전부 async 동기화).
---
## 좋은 예시 8: Bucket replication
```bash
# source alias 설정
mc alias set source https://minio.minio-prod.svc.cluster.local $SRC_USER $SRC_PASS
mc alias set target https://minio.minio-dr.svc.cluster.local $TGT_USER $TGT_PASS
# target에 replication 전용 user + policy
mc admin policy create target replication-target /policies/replication.json
mc admin user add target replication-bot $(openssl rand -hex 16)
mc admin policy attach target replication-target --user replication-bot
# source에서 remote target 등록
mc replicate add source/app-data \
--remote-bucket https://replication-bot:PASS@minio.minio-dr.svc.cluster.local/app-data \
--replicate "delete,delete-marker,existing-objects,metadata-sync" \
--priority 1
```
`replication.json`:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetBucketVersioning",
"s3:PutBucketVersioning",
"s3:GetReplicationConfiguration",
"s3:ReplicateObject",
"s3:ReplicateDelete",
"s3:ReplicateTags",
"s3:GetObjectVersion",
"s3:GetObjectVersionTagging",
"s3:GetObjectVersionForReplication"
],
"Resource": ["arn:aws:s3:::app-data/*", "arn:aws:s3:::app-data"]
}
]
}
```
**왜 좋은가:**
- `existing-objects` 옵션으로 기존 데이터 backfill
- `delete` + `delete-marker`로 삭제도 복제 (true mirror)
- 전용 replication user + 최소 권한 policy
- async 복제, bucket versioning 전제
---
## 좋은 예시 9: Keycloak OIDC STS 로그인
Keycloak에 `minio` client 생성 후:
```bash
# 앱에서 JWT를 받은 다음
curl -X POST https://s3.example.com/ \
-d "Action=AssumeRoleWithWebIdentity" \
-d "Version=2011-06-15" \
-d "WebIdentityToken=${KEYCLOAK_ID_TOKEN}" \
-d "DurationSeconds=3600"
```
응답의 `AccessKeyId`, `SecretAccessKey`, `SessionToken`을 S3 SDK에 주입.
```bash
# AWS CLI 예시
aws configure set aws_access_key_id "$STS_ACCESS_KEY"
aws configure set aws_secret_access_key "$STS_SECRET_KEY"
aws configure set aws_session_token "$STS_SESSION_TOKEN"
aws s3 ls s3://app-data/ --endpoint-url https://s3.example.com
```
**왜 좋은가:**
- 앱/사용자는 Keycloak에 로그인만 하면 됨 — MinIO에 user 등록 불필요
- 임시 credential (1시간 TTL) → 유출 시 피해 제한
- JWT의 `policy` claim이 MinIO 정책과 자동 매핑
- 장기 access key 배포 없음
+578
View File
@@ -0,0 +1,578 @@
# network / ingress / TLS 예시
모든 YAML은 `kubectl apply --dry-run=server -f -` 기준 clean. Traefik은 `ingress-traefik` namespace에 IngressClass `traefik`으로 설치되어 있다고 가정한다. cert-manager는 `cert-manager` namespace에 설치되어 있다.
---
## 좋은 예시 1: cert-manager ClusterIssuer (staging + prod) + DNS-01 wildcard
```yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: platform@example.com
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
ingress:
ingressClassName: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: platform@example.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
ingressClassName: traefik
selector:
dnsZones:
- example.com
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod-dns
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: platform@example.com
privateKeySecretRef:
name: letsencrypt-prod-dns-account-key
solvers:
- dns01:
route53:
region: us-east-1
hostedZoneID: Z2FDTNDATAQYW2
selector:
dnsZones:
- example.com
```
**왜 좋은가:**
- Staging issuer로 먼저 발급 테스트(LE rate limit 절약). 검증 후 prod로 교체.
- HTTP-01 solver는 `ingressClassName: traefik`으로 challenge Ingress가 정확히 Traefik만 수락.
- DNS-01 solver는 wildcard(`*.example.com`) 발급에 필수. Route53 hosted zone ID 고정.
---
## 좋은 예시 2: Certificate CRD로 TLS Secret 자동 생성 + Ingress 재사용
```yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: auth-example-com
namespace: auth-prod
spec:
secretName: auth-example-com-tls
secretTemplate:
annotations:
reflector.v1.k8s.emberstack.com/reflection-allowed: "false"
labels:
app.kubernetes.io/part-of: identity-platform
duration: 2160h # 90d
renewBefore: 360h # 15d
privateKey:
algorithm: ECDSA
size: 256
rotationPolicy: Always
usages:
- server auth
- digital signature
- key encipherment
dnsNames:
- auth.example.com
issuerRef:
kind: ClusterIssuer
name: letsencrypt-prod
```
**왜 좋은가:**
- cert-manager가 `auth-example-com-tls`라는 `kubernetes.io/tls` Secret을 자동 생성·회전(15일 전).
- ECDSA P-256 + 회전 정책으로 key lifecycle 관리.
- `usages` 명시로 SAN certificate의 Extended Key Usage 제어.
---
## 좋은 예시 3: Traefik Middleware(HSTS + HTTPS redirect) + TLSOption
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: https-redirect
namespace: ingress-traefik
spec:
redirectScheme:
scheme: https
permanent: true
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: security-headers
namespace: ingress-traefik
spec:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
forceSTSHeader: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: strict-origin-when-cross-origin
frameDeny: true
---
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: modern-tls
namespace: ingress-traefik
spec:
minVersion: VersionTLS12
sniStrict: true
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
curvePreferences:
- CurveP521
- CurveP384
```
**왜 좋은가:**
- HSTS preload 조건(`max-age>=31536000` + `includeSubDomains` + `preload`)을 모두 만족.
- TLS 1.2+ 강제, 취약 cipher 제거. `sniStrict: true`로 SNI 없는 클라이언트 차단.
- 재사용 가능한 platform middleware — 각 namespace Ingress가 참조.
---
## 좋은 예시 4: auth-server Service + Ingress (TLS, HSTS, HTTPS redirect)
```yaml
apiVersion: v1
kind: Service
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
appProtocol: http
- name: metrics
port: 9090
targetPort: metrics
protocol: TCP
appProtocol: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/part-of: identity-platform
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.options: ingress-traefik-modern-tls@kubernetescrd
traefik.ingress.kubernetes.io/router.middlewares: ingress-traefik-https-redirect@kubernetescrd,ingress-traefik-security-headers@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- auth.example.com
secretName: auth-example-com-tls
rules:
- host: auth.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: auth-server
port:
name: http
```
**왜 좋은가:**
- `ingressClassName: traefik` 필드 사용, deprecated annotation 미사용.
- `cert-manager.io/cluster-issuer` annotation으로 TLS Secret(`auth-example-com-tls`)이 자동 발급.
- Traefik middleware 체인으로 HSTS + HTTPS redirect + TLSOption 적용.
- Service는 named port `http`, `metrics` 분리. Ingress는 `http`만 라우팅, metrics는 NetworkPolicy로 Prometheus만 허용.
---
## 나쁜 예시 1: deprecated ingress.class annotation + TLS 누락
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: auth-server
namespace: auth-prod
annotations:
kubernetes.io/ingress.class: traefik
spec:
rules:
- host: auth.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: auth-server
port:
number: 80
```
**문제:**
- `kubernetes.io/ingress.class` annotation은 1.22부터 deprecated. 일부 컨트롤러는 무시한다.
- `spec.tls` 없음 → 평문 HTTP로 노출. 인증 시스템에는 특히 부적절.
- HSTS/HTTPS redirect 미적용.
- Service 포트를 `number: 80`으로 hard-code. named port drift에 취약.
---
## 좋은 예시 5: Keycloak은 `/realms/`, `/resources/`, `/.well-known/`만 공개
```yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: sso-example-com
namespace: keycloak
spec:
secretName: sso-example-com-tls
duration: 2160h
renewBefore: 360h
privateKey:
algorithm: ECDSA
size: 256
dnsNames:
- sso.example.com
issuerRef:
kind: ClusterIssuer
name: letsencrypt-prod
---
apiVersion: v1
kind: Service
metadata:
name: keycloak
namespace: keycloak
labels:
app.kubernetes.io/name: keycloak
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: keycloak
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
appProtocol: http
- name: management
port: 9000
targetPort: management
protocol: TCP
appProtocol: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: keycloak
namespace: keycloak
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.options: ingress-traefik-modern-tls@kubernetescrd
traefik.ingress.kubernetes.io/router.middlewares: ingress-traefik-https-redirect@kubernetescrd,ingress-traefik-security-headers@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- sso.example.com
secretName: sso-example-com-tls
rules:
- host: sso.example.com
http:
paths:
- path: /realms/
pathType: Prefix
backend:
service:
name: keycloak
port:
name: http
- path: /resources/
pathType: Prefix
backend:
service:
name: keycloak
port:
name: http
- path: /.well-known/
pathType: Prefix
backend:
service:
name: keycloak
port:
name: http
```
**왜 좋은가:**
- Keycloak 공식 권장 공개 경로만 노출.
- `/admin/`, `/metrics`, `/health`는 Ingress에 없음 → 외부에서 접근 불가.
- `management`(9000) 포트는 Service에만 존재하고 Ingress에는 없음.
---
## 나쁜 예시 2: host 없는 defaultBackend + admin 노출
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: catch-all
namespace: keycloak
spec:
ingressClassName: traefik
defaultBackend:
service:
name: keycloak
port:
number: 8080
rules:
- http:
paths:
- path: /admin
pathType: Prefix
backend:
service:
name: keycloak
port:
number: 9000
```
**문제:**
- `defaultBackend`가 모든 host의 unmatched 요청을 Keycloak으로 포워딩 → 다른 앱 공격면 확대.
- `/admin`을 관리 포트 9000으로 프록시 → Keycloak 공식 권고 위반, 관리 콘솔 외부 노출.
- TLS/HSTS/redirect 미적용.
---
## 좋은 예시 6: Traefik IngressRoute + Middleware(TLS 1.2, rate-limit, BasicAuth)
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: rate-limit
namespace: auth-prod
spec:
rateLimit:
average: 100
burst: 200
period: 1s
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: auth-server
namespace: auth-prod
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`auth.example.com`) && PathPrefix(`/api/v1`)
services:
- kind: Service
name: auth-server
port: http
scheme: http
passHostHeader: true
middlewares:
- name: https-redirect
namespace: ingress-traefik
- name: security-headers
namespace: ingress-traefik
- name: rate-limit
namespace: auth-prod
tls:
secretName: auth-example-com-tls
options:
name: modern-tls
namespace: ingress-traefik
```
**왜 좋은가:**
- Traefik CRD 네이티브. match 표현이 강력(Host + PathPrefix 조합, Header match 가능).
- Middleware 체인(HSTS + redirect + rate-limit)을 순서대로 지정.
- `TLSOption`을 Route마다 override 가능(특정 host만 mTLS 요구 등).
---
## 좋은 예시 7: Vault/DB는 Ingress 없이 ClusterIP
```yaml
apiVersion: v1
kind: Service
metadata:
name: vault
namespace: vault
labels:
app.kubernetes.io/name: vault
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: vault
ports:
- name: https
port: 8200
targetPort: https
protocol: TCP
appProtocol: https
- name: cluster
port: 8201
targetPort: cluster
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: identity-postgres
namespace: data-prod
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: identity-postgres
spec:
type: ClusterIP
clusterIP: None
selector:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: identity-postgres
ports:
- name: postgres
port: 5432
targetPort: postgres
protocol: TCP
appProtocol: postgresql
```
**왜 좋은가:**
- Vault/Postgres 둘 다 Ingress 없음 → 외부 L7 공격면 0.
- Postgres는 headless(`clusterIP: None`) → StatefulSet Pod에 직접 DNS.
- named port `https`/`postgres` 사용.
---
## 나쁜 예시 3: 운영 DB를 NodePort로 공개
```yaml
apiVersion: v1
kind: Service
metadata:
name: identity-postgres
namespace: data-prod
spec:
type: NodePort
ports:
- port: 5432
targetPort: 5432
nodePort: 30032
```
**문제:**
- 모든 노드의 30032 포트가 외부에서 접근 가능 → DB가 인터넷에 노출될 수 있음.
- TLS/mTLS/NetworkPolicy 어디서도 통제 불가.
- `services.nodeports: 0` quota를 걸어 namespace 단에서 차단해야 한다.
---
## 좋은 예시 8: K3s Traefik HelmChartConfig override
```yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
deployment:
replicas: 3
service:
spec:
externalTrafficPolicy: Local
ports:
web:
redirectTo:
port: websecure
priority: 10
websecure:
tls:
enabled: true
ingressClass:
enabled: true
isDefaultClass: true
additionalArguments:
- "--providers.kubernetesingress.ingressclass=traefik"
- "--metrics.prometheus=true"
- "--entrypoints.websecure.http.tls.options=modern-tls@kubernetescrd"
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
```
**왜 좋은가:**
- K3s packaged Traefik manifest는 건드리지 않고, override만 선언적으로 관리.
- replica 3, `externalTrafficPolicy: Local`로 source IP 보존.
- 기본 websecure에 `modern-tls` TLSOption을 묶어 platform 전역 TLS 정책 통일.
+601
View File
@@ -0,0 +1,601 @@
# observability / health 예시
---
## 좋은 예시 1: ServiceMonitor (kube-prometheus-stack 표준)
```yaml
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
app.kubernetes.io/part-of: identity-platform
release: kube-prometheus-stack
spec:
namespaceSelector:
matchNames:
- auth-prod
selector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
endpoints:
- port: metrics # named port (required)
path: /actuator/prometheus
scheme: http
interval: 30s
scrapeTimeout: 10s
honorLabels: false
relabelings:
- sourceLabels: [__meta_kubernetes_pod_name]
targetLabel: pod
- sourceLabels: [__meta_kubernetes_namespace]
targetLabel: namespace
- sourceLabels: [__meta_kubernetes_pod_label_app_kubernetes_io_version]
targetLabel: version
- action: labeldrop
regex: "pod_template_hash|controller_revision_hash"
metricRelabelings:
- sourceLabels: [__name__]
regex: "jvm_gc_pause_seconds_.*"
action: keep
- sourceLabels: [__name__]
regex: "debug_.*"
action: drop
```
**왜 좋은가:**
- `namespaceSelector` 명시로 암묵적 전체 허용 방지.
- `port: metrics` 는 Service/Deployment의 named port를 참조 → 포트 번호 변경에 내성.
- `interval / scrapeTimeout` 관계 유지 (timeout < interval).
- `relabelings` 로 pod / namespace / version label 정리, noise label drop.
- `metricRelabelings` 로 불필요 metric drop (cardinality / storage 절감).
- `release: kube-prometheus-stack` label 로 Operator가 선택.
---
## 좋은 예시 2: ServiceMonitor with bearer token (Vault telemetry)
```yaml
---
apiVersion: v1
kind: Secret
metadata:
name: vault-metrics-token
namespace: vault
type: Opaque
stringData:
token: "hvs.xxxx.prometheus-readonly"
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: vault
namespace: vault
labels:
app.kubernetes.io/name: vault
app.kubernetes.io/instance: vault-prod
release: kube-prometheus-stack
spec:
namespaceSelector:
matchNames:
- vault
selector:
matchLabels:
app.kubernetes.io/name: vault
app.kubernetes.io/instance: vault-prod
endpoints:
- port: https
path: /v1/sys/metrics
params:
format: ["prometheus"]
scheme: https
interval: 30s
scrapeTimeout: 10s
bearerTokenSecret:
name: vault-metrics-token
key: token
tlsConfig:
insecureSkipVerify: false
ca:
secret:
name: vault-ca
key: ca.crt
serverName: vault.vault.svc
relabelings:
- sourceLabels: [__meta_kubernetes_pod_name]
targetLabel: pod
```
**왜 좋은가:**
- Vault `/sys/metrics` 는 read token 필수. `bearerTokenSecret` 참조로 Operator가 주입.
- TLS CA pinning + serverName 으로 MitM 방지.
- `params` 로 Prometheus format 요청.
---
## 좋은 예시 3: PodMonitor (Service 없는 워크로드)
```yaml
---
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: batch-worker
namespace: batch
labels:
release: kube-prometheus-stack
spec:
namespaceSelector:
matchNames:
- batch
selector:
matchLabels:
app.kubernetes.io/name: batch-worker
podMetricsEndpoints:
- port: metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
relabelings:
- sourceLabels: [__meta_kubernetes_pod_name]
targetLabel: pod
```
**왜 좋은가:**
- Job / headless workload처럼 Service 뒤에 없는 경우 PodMonitor로 직접 pod 매칭.
---
## 좋은 예시 4: Annotation-based fallback (Operator 없는 환경 only)
```yaml
---
apiVersion: v1
kind: Service
metadata:
name: legacy-app
namespace: legacy
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8081"
prometheus.io/path: "/metrics"
prometheus.io/scheme: "http"
spec:
selector:
app.kubernetes.io/name: legacy-app
ports:
- name: http
port: 80
targetPort: 8080
- name: metrics
port: 8081
targetPort: 8081
```
**왜 좋은가 (조건부):**
- kube-prometheus-stack이 없는 legacy 환경에서만 유효.
- Operator가 있으면 ServiceMonitor로 전환.
---
## 좋은 예시 5: NetworkPolicy — prometheus namespace만 metrics scrape 허용
```yaml
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: auth-server-default-deny
namespace: auth-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: auth-server
policyTypes: ["Ingress", "Egress"]
ingress: []
egress: []
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: auth-server-allow-metrics
namespace: auth-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: auth-server
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app.kubernetes.io/name: prometheus
ports:
- port: metrics
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: auth-server-allow-http-from-ingress
namespace: auth-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: auth-server
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- port: http
protocol: TCP
```
**왜 좋은가:**
- default-deny → allow-list 패턴.
- metrics port는 monitoring namespace의 prometheus pod만.
- http port는 ingress controller namespace만.
---
## 좋은 예시 6: JSON structured log (Spring Boot logback)
```xml
<!-- logback-spring.xml -->
<configuration>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>trace_id</includeMdcKeyName>
<includeMdcKeyName>span_id</includeMdcKeyName>
<includeMdcKeyName>request_id</includeMdcKeyName>
<customFields>{"service":"auth-server"}</customFields>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
</configuration>
```
Actual output:
```json
{"timestamp":"2026-04-16T09:31:42.017Z","level":"INFO","service":"auth-server","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7","logger":"c.e.auth.LoginController","thread":"http-nio-8080-exec-3","message":"login success","user_id_hash":"ab12..."}
```
**왜 좋은가:**
- ISO 8601 UTC timestamp.
- trace_id / span_id 가 MDC에서 자동 주입 → Tempo / Jaeger와 correlate.
- service label이 customFields로 고정.
- user_id는 hashed → cardinality/PII 안전.
---
## 좋은 예시 7: OpenTelemetry Collector (DaemonSet agent + Deployment gateway)
```yaml
---
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
name: otel-agent
namespace: observability
spec:
mode: daemonset
image: otel/opentelemetry-collector-contrib:0.101.0
config:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
send_batch_size: 1024
timeout: 5s
k8sattributes:
passthrough: false
extract:
metadata:
- k8s.pod.name
- k8s.namespace.name
- k8s.node.name
exporters:
otlp/gateway:
endpoint: otel-gateway.observability.svc:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [k8sattributes, batch]
exporters: [otlp/gateway]
metrics:
receivers: [otlp]
processors: [k8sattributes, batch]
exporters: [otlp/gateway]
---
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
name: otel-gateway
namespace: observability
spec:
mode: deployment
replicas: 3
image: otel/opentelemetry-collector-contrib:0.101.0
config:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
batch:
send_batch_size: 2048
timeout: 5s
tail_sampling:
decision_wait: 10s
policies:
- name: errors-keep
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-keep
type: latency
latency: { threshold_ms: 500 }
- name: default-10pct
type: probabilistic
probabilistic: { sampling_percentage: 10 }
attributes/redact:
actions:
- key: http.request.header.authorization
action: delete
- key: user.email
action: hash
exporters:
otlp/tempo:
endpoint: tempo.observability.svc:4317
tls:
insecure: true
prometheusremotewrite:
endpoint: http://prometheus.monitoring.svc:9090/api/v1/write
service:
pipelines:
traces:
receivers: [otlp]
processors: [attributes/redact, tail_sampling, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheusremotewrite]
```
**왜 좋은가:**
- agent (DaemonSet) → gateway (Deployment) 2단 구조.
- gateway에서 tail-based sampling (error + slow + 10% 나머지).
- PII redaction을 gateway에서 중앙 처리.
- agent가 node-local이라 app은 localhost endpoint만 알면 됨.
---
## 좋은 예시 8: Loki + Grafana Alloy DaemonSet (log shipping)
```yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
name: alloy-config
namespace: observability
data:
config.alloy: |
discovery.kubernetes "pods" {
role = "pod"
}
discovery.relabel "pods" {
targets = discovery.kubernetes.pods.targets
rule {
source_labels = ["__meta_kubernetes_namespace"]
target_label = "namespace"
}
rule {
source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"]
target_label = "service"
}
}
loki.source.kubernetes "pods" {
targets = discovery.relabel.pods.output
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint {
url = "http://loki.observability.svc:3100/loki/api/v1/push"
}
}
```
**왜 좋은가:**
- Alloy DaemonSet이 node-level log tail.
- label은 namespace / service 두 개로 제한 (cardinality 안전).
---
## 좋은 예시 9: `kubectl events` (1.27+ stable)
```bash
# cluster-wide live watch, warnings only
kubectl events -A --types=Warning --watch
# specific pod
kubectl events -n auth-prod --for pod/auth-server-abc123
# last hour
kubectl events -n auth-prod --since=1h
```
**왜 좋은가:**
- `--for` 로 특정 오브젝트 event 만 필터링.
- `--watch``get events -w` 보다 안정적.
- timestamp sort 기본 제공.
---
## 나쁜 예시 1: `/metrics` 를 Ingress로 외부 공개
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
spec:
rules:
- host: auth.example.com
http:
paths:
- path: /metrics # BAD
pathType: Prefix
backend:
service:
name: auth-server
port:
number: 8081
```
**문제:**
- Prometheus metric으로 내부 구조 / error rate / version 노출.
- DoS vector (scrape 비용).
- audit / compliance 위반.
**Fix:** metrics port는 외부 비공개, NetworkPolicy로 monitoring namespace만 허용.
---
## 나쁜 예시 2: high-cardinality label
```yaml
# app code
http_requests_total{user_id="12345", path="/users/12345/orders/98765", request_id="a1b2c3..."}
```
**문제:**
- user_id × path × request_id = 수백만 time series → Prometheus OOM.
- query 성능 붕괴.
**Fix:**
```
http_requests_total{route="/users/:id/orders/:id", method="GET", status_class="2xx"}
```
- route template 화, status는 bucket (2xx/4xx/5xx).
- user_id 는 logging에만, metric label 금지.
---
## 나쁜 예시 3: ServiceMonitor에 namespaceSelector 없음
```yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
spec:
selector:
matchLabels:
app: my-app
# namespaceSelector 없음 → Operator 설정에 따라 전체 cluster scan
endpoints:
- port: metrics
```
**문제:**
- 암묵적으로 너무 넓은 범위 (Operator 설정에 따라 다름).
- 동일 label 를 다른 namespace에서 쓰면 의도치 않은 scrape.
**Fix:** `namespaceSelector.matchNames` 명시.
---
## 나쁜 예시 4: probe가 `/metrics` 사용
```yaml
readinessProbe:
httpGet:
path: /metrics # BAD
port: 8081
periodSeconds: 5
```
**문제:**
- `/metrics` 는 비용이 큰 endpoint (모든 registry dump).
- periodSeconds 5초 × N pod = unnecessary load.
- readiness 의미와 무관.
**Fix:** `/actuator/health/readiness` 같은 전용 shallow endpoint.
---
## 나쁜 예시 5: 로그에 access token 그대로
```
2026-04-16T09:32:11.002 INFO Exchanging code for token: access_token=eyJhbGciOi...
```
**문제:**
- token이 log index에 그대로 저장 → 유출 리스크.
- 중앙 로그 시스템 (OpenSearch / Loki) 에 영구 보관.
**Fix:**
- 애플리케이션에서 token 값 로깅 금지.
- 중앙 파이프라인에 regex redaction (`access_token=[^ ]+``access_token=***`).
- debug 로그에서도 masking.
---
## 나쁜 예시 6: 로그를 PVC / file로 적재
```yaml
volumeMounts:
- name: app-logs
mountPath: /var/log/app # BAD
volumes:
- name: app-logs
persistentVolumeClaim:
claimName: app-logs-pvc
```
**문제:**
- 컨테이너 표준 (stdout/stderr) 위반.
- Pod 삭제 시 로그 손실 또는 orphan PVC.
- `kubectl logs` 로 안 보임.
- node log agent가 수집 못 함.
**Fix:** stdout/stderr로 출력, DaemonSet agent가 수집.
@@ -0,0 +1,744 @@
# operations / runbook / upgrade / rollback 예시
---
## 좋은 예시 1: 표준 application 변경 절차
```bash
# 1) render
kubectl kustomize k8s/overlays/prod > /tmp/render.yaml
# 2) diff
kubectl diff -k k8s/overlays/prod
# 3) apply
kubectl apply -k k8s/overlays/prod
# 4) rollout status with timeout
kubectl rollout status deployment/auth-server -n auth-prod --timeout=10m
# 5) smoke test
curl -fsS https://auth.internal.example.com/actuator/health/readiness
# 6) SLO dashboard check (p99 latency, error rate)
```
**왜 좋은가:**
- render / diff / apply / status / post-check 가 명시적으로 분리.
- `--timeout` 으로 무한 대기 방지.
- post-check가 단순 curl 이 아니라 readiness endpoint 대상.
---
## 좋은 예시 2: Deployment rollingUpdate 파라미터 워크로드별 튜닝
```yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
replicas: 10
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 10% # latency-sensitive면 0
selector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
template:
metadata:
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: auth-server
image: registry.example.com/identity/auth-server:1.25.0
ports:
- { name: http, containerPort: 8080 }
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { memory: 1536Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: legacy-singleton
namespace: legacy
spec:
replicas: 1
strategy:
type: Recreate # singleton이며 동시성 금지
selector:
matchLabels:
app.kubernetes.io/name: legacy-singleton
template:
metadata:
labels:
app.kubernetes.io/name: legacy-singleton
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: registry.example.com/legacy/singleton:1.0.0
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 512Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
```
**왜 좋은가:**
- fleet 규모에 맞춘 maxSurge/maxUnavailable.
- singleton 에 Recreate (PVC ReadWriteOnce 전제 충족).
---
## 좋은 예시 3: Argo Rollouts canary with AnalysisTemplate
```yaml
---
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: success-rate
namespace: auth-prod
spec:
args:
- name: service-name
metrics:
- name: success-rate
interval: 1m
count: 5
successCondition: result[0] >= 0.99
failureLimit: 2
provider:
prometheus:
address: http://prometheus.monitoring.svc:9090
query: |
sum(rate(http_requests_total{service="{{args.service-name}}",status_class=~"2.."}[2m]))
/
sum(rate(http_requests_total{service="{{args.service-name}}"}[2m]))
- name: p99-latency
interval: 1m
count: 5
successCondition: result[0] <= 0.5
failureLimit: 2
provider:
prometheus:
address: http://prometheus.monitoring.svc:9090
query: |
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket{service="{{args.service-name}}"}[2m]))
)
---
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
replicas: 10
revisionHistoryLimit: 5
selector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
template:
metadata:
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
containers:
- name: auth-server
image: registry.example.com/identity/auth-server:1.25.0
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
memory: "1536Mi"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
strategy:
canary:
canaryService: auth-server-canary
stableService: auth-server-stable
trafficRouting:
nginx:
stableIngress: auth-server
steps:
- setWeight: 10
- pause: { duration: 2m }
- analysis:
templates:
- templateName: success-rate
args:
- name: service-name
value: auth-server
- setWeight: 25
- pause: { duration: 5m }
- analysis:
templates:
- templateName: success-rate
args:
- name: service-name
value: auth-server
- setWeight: 50
- pause: { duration: 10m }
- analysis:
templates:
- templateName: success-rate
args:
- name: service-name
value: auth-server
- setWeight: 100
---
apiVersion: v1
kind: Service
metadata:
name: auth-server-stable
namespace: auth-prod
spec:
selector:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: v1
kind: Service
metadata:
name: auth-server-canary
namespace: auth-prod
spec:
selector:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
ports:
- name: http
port: 80
targetPort: http
```
**왜 좋은가:**
- `AnalysisTemplate` 이 Prometheus success-rate + p99 latency 를 동시에 측정.
- `failureLimit: 2` → 두 번 실패 시 자동 abort.
- canary step: 10% → 25% → 50% → 100% 각 단계에 pause + analysis.
- stable/canary Service 두 개 + NGINX ingress traffic routing.
---
## 좋은 예시 4: K3s System Upgrade Controller Plan (server + agent)
```yaml
---
apiVersion: v1
kind: Namespace
metadata:
name: system-upgrade
---
apiVersion: v1
kind: Secret
metadata:
name: k3s-upgrade-token
namespace: system-upgrade
type: Opaque
stringData:
# 실제 환경은 K3S_TOKEN 값
token: "REPLACE_WITH_NODE_TOKEN"
---
apiVersion: upgrade.cattle.io/v1
kind: Plan
metadata:
name: k3s-server
namespace: system-upgrade
labels:
k3s-upgrade: server
spec:
concurrency: 1
nodeSelector:
matchExpressions:
- { key: node-role.kubernetes.io/control-plane, operator: In, values: ["true"] }
serviceAccountName: system-upgrade
cordon: true
drain:
force: true
deleteEmptydirData: true
ignoreDaemonSets: true
skipWaitForDeleteTimeout: 60
upgrade:
image: rancher/k3s-upgrade
version: v1.30.3+k3s1
---
apiVersion: upgrade.cattle.io/v1
kind: Plan
metadata:
name: k3s-agent
namespace: system-upgrade
labels:
k3s-upgrade: agent
spec:
concurrency: 1
nodeSelector:
matchExpressions:
- { key: node-role.kubernetes.io/control-plane, operator: NotIn, values: ["true"] }
serviceAccountName: system-upgrade
prepare:
image: rancher/k3s-upgrade
args: ["prepare", "k3s-server"] # server plan 완료 대기
cordon: true
drain:
force: true
deleteEmptydirData: true
ignoreDaemonSets: true
skipWaitForDeleteTimeout: 60
upgrade:
image: rancher/k3s-upgrade
version: v1.30.3+k3s1
```
**왜 좋은가:**
- server-plan → agent-plan 분리 + agent 가 `prepare` 로 server 완료 대기.
- `concurrency: 1` → 한 번에 한 노드만 업그레이드 (가용성 보호).
- `cordon + drain` → PDB 존중.
- `deleteEmptydirData: true, ignoreDaemonsets: true` 표준.
- `version` 명시 (channel 사용 시 의도치 않은 upgrade 가능).
---
## 좋은 예시 5: ArgoCD sync wave + PreSync migration hook
```yaml
---
apiVersion: batch/v1
kind: Job
metadata:
name: flyway-migrate
namespace: auth-prod
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
argocd.argoproj.io/sync-wave: "-1"
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
app.kubernetes.io/component: db-migration
spec:
backoffLimit: 0
activeDeadlineSeconds: 600
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: flyway
image: flyway/flyway:10.15.0
args: ["-url=jdbc:postgresql://postgres:5432/auth", "validate", "info", "migrate"]
envFrom:
- secretRef:
name: auth-db
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 512Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- name: tmp
emptyDir: {}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
namespace: auth-prod
annotations:
argocd.argoproj.io/sync-wave: "0"
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
# ... (생략)
---
apiVersion: batch/v1
kind: Job
metadata:
name: smoke-test
namespace: auth-prod
annotations:
argocd.argoproj.io/hook: PostSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded
argocd.argoproj.io/sync-wave: "1"
spec:
backoffLimit: 2
activeDeadlineSeconds: 300
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: smoke
image: registry.example.com/tools/smoke:1.4.0
args: ["--target", "https://auth.internal.example.com"]
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 128Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
```
**왜 좋은가:**
- PreSync Job 으로 Flyway migrate 가 app rollout 앞 단계에 실행.
- PostSync Job 으로 smoke test 자동 실행.
- sync-wave 로 순서 명시 (-1 → 0 → 1).
- `BeforeHookCreation` 으로 이전 Job 충돌 방지.
---
## 좋은 예시 6: blue/green via two Services (수동 패턴)
```yaml
---
apiVersion: v1
kind: Service
metadata:
name: auth-server # live traffic
namespace: auth-prod
spec:
selector:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
version: blue # <- 이 label만 바꾸면 cutover
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server-blue
namespace: auth-prod
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: auth-server
version: blue
template:
metadata:
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
version: blue
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: auth-server
image: registry.example.com/identity/auth-server:1.24.0
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { memory: 1536Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server-green
namespace: auth-prod
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: auth-server
version: green
template:
metadata:
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
version: green
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: auth-server
image: registry.example.com/identity/auth-server:1.25.0
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { memory: 1536Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
```
Cutover:
```bash
kubectl patch svc auth-server -n auth-prod \
-p '{"spec":{"selector":{"app.kubernetes.io/name":"auth-server","app.kubernetes.io/instance":"auth-server-prod","version":"green"}}}'
```
**왜 좋은가:**
- Service selector version label 하나로 전환 / rollback.
- canary 가 아니라 instant cutover.
- 데이터 호환성이 깨진 경우만 사용.
---
## 좋은 예시 7: node maintenance flow
```bash
NODE=worker-3
# 1) cordon
kubectl cordon "${NODE}"
# 2) drain (PDB 존중)
kubectl drain "${NODE}" \
--ignore-daemonsets \
--delete-emptydir-data \
--grace-period=30 \
--timeout=10m
# 3) 작업 수행 (OS patch, reboot, ...)
# 4) 복귀
kubectl uncordon "${NODE}"
# 5) 재배치 확인
kubectl get pods -A -o wide --field-selector spec.nodeName="${NODE}"
```
**왜 좋은가:**
- cordon → drain → uncordon 표준 시퀀스.
- PDB 위반 시 drain 이 대기, `--timeout=10m` 로 무한 대기 방지.
- 플래그 조합이 표준.
---
## 좋은 예시 8: Git revision rollback
```bash
# 1) 이전 release tag 체크아웃
git checkout v1.24.0
# 2) diff
kubectl diff -k k8s/overlays/prod
# 3) apply
kubectl apply -k k8s/overlays/prod
# 4) rollout status
kubectl rollout status deployment/auth-server -n auth-prod --timeout=10m
```
**왜 좋은가:**
- live-cluster 수정이 아니라 declarative source of truth 기준.
- 재현 가능.
- `kubectl rollout undo` 대비 audit trail 이 명확 (Git commit 기반).
---
## 나쁜 예시 1: diff 없이 apply
```bash
kubectl apply -k k8s/overlays/prod
```
**문제:**
- 실제 변경 범위를 모른 채 적용.
- review / 승인 / 검증 프로세스 약화.
- 의도치 않은 리소스 삭제/수정 가능 (특히 pruned resource).
**Fix:** `kubectl diff -k` 선행.
---
## 나쁜 예시 2: migration을 app startup에 숨김
```yaml
# Deployment container
command: ["/bin/sh", "-c", "flyway migrate && java -jar app.jar"]
```
**문제:**
- app rollout 실패와 schema 변경 실패가 섞임.
- rollout 중 여러 replica 가 동시에 migrate → race condition / lock contention.
- 롤백 시 schema 변경이 남음.
**Fix:** PreSync Job 또는 별도 CI 단계로 Flyway migrate 를 분리.
---
## 나쁜 예시 3: rollout undo 로 DB rollback 기대
```bash
kubectl rollout undo deployment/auth-server
# ... 이제 DB schema 도 되돌아갔을 것이다?
```
**문제:**
- rollout undo 는 workload pod template 만 되돌린다.
- schema 변경은 남아 있음 → 이전 버전 app이 새 schema 와 mismatch → 500 error.
- **rollback ≠ DB rollback**.
**Fix:** schema 는 expand/contract 패턴으로 forward-compatible. 이전 버전 코드가 새 schema 에서도 동작하도록 릴리스를 분리.
---
## 나쁜 예시 4: 운영 노드 manifests 디렉터리 직접 편집
```bash
ssh k3s-server-1
vim /var/lib/rancher/k3s/server/manifests/auth-server.yaml
```
**문제:**
- Git source of truth 우회.
- 멀티 서버 간 동기화 없음.
- packaged AddOn 동작과 충돌 가능.
- ArgoCD 가 drift 로 인식하고 되돌릴 수 있음.
**Fix:** Git PR → render → diff → apply 흐름.
---
## 나쁜 예시 5: Recreate strategy 를 stateless app에 사용
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
spec:
replicas: 5
strategy:
type: Recreate # BAD - stateless 인데 downtime 발생
```
**문제:**
- 모든 replica 동시 종료 → full downtime.
- rolling update 의 장점 (점진 전환, rollback 용이) 상실.
**Fix:** stateless app은 `RollingUpdate` + 워크로드별 maxSurge/maxUnavailable 튜닝.
---
## 나쁜 예시 6: PDB 없이 drain
```bash
kubectl drain worker-3 --ignore-daemonsets --delete-emptydir-data
```
**문제:**
- PDB 가 없으면 critical workload 가 동시에 evict → downtime.
- 특히 replica < 3 이면 완전 손실.
**Fix:** PDB 설계 선결 조건. 좋은 예시 7 참조.
---
## 나쁜 예시 7: `kubectl rollout status` 에 timeout 없음
```bash
kubectl rollout status deployment/auth-server -n auth-prod
# 무한 대기 가능
```
**문제:**
- rollout 이 hang 상태일 때 CI/CD pipeline 이 무한 대기.
- 자동화 실패 원인이 숨는다.
**Fix:** 항상 `--timeout=10m` (워크로드별 조정).
@@ -0,0 +1,659 @@
# resources / probes / availability 예시
아래 예시는 1000+ 서비스를 운영하는 기준선이다. 모든 YAML은 그대로 `kubectl apply -f` 가능한 형태이며, 라벨 / probe / PDB / HPA / topologySpread / ServiceMonitor / NetworkPolicy가 한 세트로 맞물린다.
---
## 좋은 예시 1: auth-server 완전 매니페스트 세트 (Burstable + HPA)
### Deployment
```yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
app.kubernetes.io/version: "1.24.0"
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
spec:
replicas: 3
revisionHistoryLimit: 10
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
template:
metadata:
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
app.kubernetes.io/version: "1.24.0"
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
spec:
serviceAccountName: auth-server
terminationGracePeriodSeconds: 45
securityContext:
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
containers:
- name: auth-server
image: registry.example.com/identity/auth-server:1.24.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
- name: metrics
containerPort: 8081
env:
- name: JAVA_OPTS
value: "-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
memory: "1536Mi"
startupProbe:
httpGet:
path: /actuator/health/started
port: http
periodSeconds: 5
failureThreshold: 24
timeoutSeconds: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
periodSeconds: 5
failureThreshold: 3
timeoutSeconds: 2
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 15
failureThreshold: 3
timeoutSeconds: 3
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
```
**왜 좋은가:**
- QoS는 의도적으로 Burstable (CPU limit 생략으로 throttling 회피, memory는 1.5× headroom).
- startup probe가 최대 120초 (24×5) cold start를 덮으며 그 전까지 readiness/liveness는 실행되지 않는다.
- topologySpreadConstraints로 zone 장애 격리 + host 분산.
- app.kubernetes.io/* 표준 라벨 full set.
- preStop sleep 15초로 endpoint 제거 전파 시간을 확보한다.
- rolling update `maxUnavailable: 0`으로 항상 N replica 이상 유지.
### Service + PDB
```yaml
---
apiVersion: v1
kind: Service
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
ports:
- name: http
port: 80
targetPort: http
- name: metrics
port: 8081
targetPort: metrics
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
maxUnavailable: 1
unhealthyPodEvictionPolicy: AlwaysAllow
selector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
```
### HorizontalPodAutoscaler v2 with behavior block
```yaml
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: auth-server
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Pods
pods:
metric:
name: http_requests_in_flight
target:
type: AverageValue
averageValue: "50"
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 4
periodSeconds: 30
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 25
periodSeconds: 60
selectPolicy: Max
```
**왜 좋은가:**
- Resource metric과 custom Pods metric을 동시에 평가.
- scaleUp stabilization 0s → 스파이크에 즉시 반응.
- scaleDown 300s stabilization + 25%/min rate → flapping 방지.
- Pods metric은 pod당 in-flight request 수 (label cardinality 안전).
### ServiceMonitor (kube-prometheus-stack)
```yaml
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
release: kube-prometheus-stack
spec:
namespaceSelector:
matchNames:
- auth-prod
selector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
endpoints:
- port: metrics
path: /actuator/prometheus
scheme: http
interval: 30s
scrapeTimeout: 10s
honorLabels: false
relabelings:
- sourceLabels: [__meta_kubernetes_pod_name]
targetLabel: pod
- sourceLabels: [__meta_kubernetes_namespace]
targetLabel: namespace
- action: labeldrop
regex: "pod_template_hash"
```
### NetworkPolicy (scrape만 prometheus namespace에서 허용)
```yaml
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: auth-server-metrics-from-prom
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server-prod
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app.kubernetes.io/name: prometheus
ports:
- port: metrics
protocol: TCP
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- port: http
protocol: TCP
```
---
## 좋은 예시 2: Keycloak — Guaranteed QoS + slow startup
```yaml
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: keycloak
namespace: identity
labels:
app.kubernetes.io/name: keycloak
app.kubernetes.io/instance: keycloak-prod
app.kubernetes.io/version: "24.0.4"
app.kubernetes.io/component: identity-provider
app.kubernetes.io/part-of: identity-platform
spec:
serviceName: keycloak-headless
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: keycloak
app.kubernetes.io/instance: keycloak-prod
template:
metadata:
labels:
app.kubernetes.io/name: keycloak
app.kubernetes.io/instance: keycloak-prod
app.kubernetes.io/version: "24.0.4"
spec:
terminationGracePeriodSeconds: 60
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: keycloak
app.kubernetes.io/instance: keycloak-prod
containers:
- name: keycloak
image: quay.io/keycloak/keycloak:24.0.4
args: ["start"]
ports:
- name: http
containerPort: 8080
- name: mgmt
containerPort: 9000
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "1"
memory: "2Gi"
startupProbe:
httpGet:
path: /health/started
port: mgmt
periodSeconds: 10
failureThreshold: 30
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /health/ready
port: mgmt
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /health/live
port: mgmt
periodSeconds: 30
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- { name: tmp, mountPath: /tmp }
- { name: data, mountPath: /opt/keycloak/data }
volumes:
- name: tmp
emptyDir: {}
- name: data
emptyDir: {}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: keycloak
namespace: identity
spec:
maxUnavailable: 1
unhealthyPodEvictionPolicy: AlwaysAllow
selector:
matchLabels:
app.kubernetes.io/name: keycloak
app.kubernetes.io/instance: keycloak-prod
```
**왜 좋은가:**
- Guaranteed QoS (request == limit 모든 리소스) → eviction 우선순위 최고.
- startup budget = 10s × 30 = 300s, Keycloak cold boot p99 덮음.
- management port 9000에만 health, HTTP 8080은 traffic 전용.
- PDB maxUnavailable: 1로 3-node infinispan cluster 중 최소 2개 생존 보장.
---
## 좋은 예시 3: 1.29+ native sidecar (log forwarder)
```yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: report-worker
namespace: reporting
labels:
app.kubernetes.io/name: report-worker
app.kubernetes.io/instance: report-worker-prod
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: report-worker
app.kubernetes.io/instance: report-worker-prod
template:
metadata:
labels:
app.kubernetes.io/name: report-worker
app.kubernetes.io/instance: report-worker-prod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
initContainers:
- name: schema-check
image: registry.example.com/tools/schema-check:1.2.0
command: ["/bin/schema-check", "--fail-fast"]
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 128Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop: ["ALL"]
- name: log-forwarder
image: grafana/alloy:v1.2.0
restartPolicy: Always # <- native sidecar (1.29+)
args: ["run", "/etc/alloy/config.alloy"]
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { memory: 256Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: alloy-config
mountPath: /etc/alloy
- name: shared-logs
mountPath: /var/log/app
containers:
- name: worker
image: registry.example.com/reporting/worker:2.3.1
resources:
requests:
cpu: "200m"
memory: "512Mi"
limits:
memory: "768Mi"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: shared-logs
mountPath: /var/log/app
- name: tmp
mountPath: /tmp
volumes:
- name: alloy-config
configMap:
name: alloy-config
- name: shared-logs
emptyDir: {}
- name: tmp
emptyDir: {}
```
**왜 좋은가:**
- `restartPolicy: Always` on init container = native sidecar 패턴 (1.29+).
- init container 순서: schema-check 완료 → log-forwarder sidecar 시작 → main container.
- sidecar는 main 종료 후 SIGTERM 받음 (log flush 가능).
---
## 나쁜 예시 1: CPU limit 기계적 설정 (throttling 유발)
```yaml
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "500m" # BAD
memory: "1Gi"
```
**문제:**
- Linux CFS quota가 100ms period 내 burst만으로도 throttle을 발생시킨다.
- p99 latency가 간헐적으로 튀어도 원인이 숨는다 (metric은 평균 usage 기준).
- Google SRE / Tim Hockin 공식 가이드: "대부분의 워크로드에서 CPU limit를 제거하라".
**Fix:** CPU는 request만, memory만 limit로.
---
## 나쁜 예시 2: liveness로 readiness 대신함
```yaml
livenessProbe:
httpGet:
path: /actuator/health # BAD - deep check
port: 8080
periodSeconds: 5
failureThreshold: 2
# readiness 없음
```
**문제:**
- deep `/actuator/health`는 DB/외부 의존성 포함. DB blip → 모든 Pod 재시작 → cascading failure.
- 트래픽 수용 준비 상태를 표현할 수단이 없다.
**Fix:** startup / readiness / liveness 세 축 분리. liveness는 `/health/live` 같은 shallow check.
---
## 나쁜 예시 3: podAntiAffinity로 spread 시도 (legacy)
```yaml
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values: ["auth-server"]
topologyKey: kubernetes.io/hostname
```
**문제:**
- replica 수가 노드 수보다 많으면 스케줄 불가.
- zone 분산이 회계되지 않는다 (skew 개념 없음).
- maxSkew 튜닝 불가.
**Fix:** topologySpreadConstraints 사용 (좋은 예시 1 참조).
---
## 나쁜 예시 4: replica 1 서비스에 PDB
```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
spec:
minAvailable: 1 # BAD - with replica=1
selector:
matchLabels:
app: singleton-app
```
**문제:**
- node drain이 영구 블록된다 (`PDB violation`).
- Kubernetes 업그레이드가 불가능해진다.
**Fix:** replica 1은 PDB 제거. 필요 시 replica 2+로 늘리고 PDB 적용.
---
## 나쁜 예시 5: HPA v1 스타일 (behavior 없음)
```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: auth-server
minReplicas: 1
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 80
# behavior block 없음
```
**문제:**
- 기본 scale-down stabilization 300s지만 scale-up도 쓸데없이 보수적.
- 트래픽 burst에 대응 지연.
- 트래픽 drop 뒤 flapping 발생 가능 (policy 정의 없음).
**Fix:** `behavior` block 필수 (좋은 예시 1 참조).
---
## 나쁜 예시 6: limit만 있고 request 없음
```yaml
resources:
limits:
cpu: "1"
memory: "1Gi"
```
**문제:**
- Kubernetes가 request = limit로 복사 → 암묵적 Guaranteed.
- 스케줄러 회계가 과대 평가되어 cluster density 저하.
- 의도한 QoS class와 다름.
**Fix:** requests 명시 필수.
+602
View File
@@ -0,0 +1,602 @@
# infra scripts 예시
---
## 좋은 예시 1: `scripts/lib/common.sh` (공통 라이브러리)
```bash
#!/usr/bin/env bash
# common.sh - shared helpers. source this from bin/ scripts.
# do NOT execute directly.
# shellcheck disable=SC2034 # variables may be used by callers
readonly COMMON_SH_LOADED=1
log() {
local level="$1"; shift
local ts
ts="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
printf '%s [%s] %s\n' "${ts}" "${level}" "$*" >&2
}
info() { log INFO "$@"; }
warn() { log WARN "$@"; }
error() { log ERROR "$@"; }
fatal() { log FATAL "$@"; exit 1; }
require_cmd() {
local cmd="$1"
command -v "${cmd}" >/dev/null 2>&1 \
|| fatal "required command not found: ${cmd}"
}
require_env() {
local name="$1"
local val="${!name:-}"
[[ -n "${val}" ]] || fatal "required env var not set: ${name}"
}
confirm() {
# usage: confirm "delete namespace foo?" || return 1
local prompt="${1:-continue?}"
if [[ "${CONFIRM:-no}" == "yes" || "${YES:-0}" -eq 1 ]]; then
return 0
fi
local reply
printf '%s [y/N] ' "${prompt}" >&2
read -r reply
[[ "${reply}" == "y" || "${reply}" == "Y" ]]
}
mask_secrets() {
sed -E \
-e 's/(password=)[^ ]+/\1***/g' \
-e 's/(token=)[^ ]+/\1***/g' \
-e 's/(Authorization: Bearer )[A-Za-z0-9._-]+/\1***/g'
}
retry() {
local max="$1"; shift
local delay="$1"; shift
local n=0
until "$@"; do
n=$((n + 1))
if (( n >= max )); then
error "retry exhausted after ${max} attempts: $*"
return 1
fi
warn "retry $n/$max failed, sleeping ${delay}s"
sleep "${delay}"
done
}
```
**왜 좋은가:**
- log 함수가 ISO 8601 UTC + LEVEL + stderr.
- require_cmd / require_env / confirm / mask_secrets / retry 가 재사용 가능한 작은 단위.
- shellcheck suppression 은 이유 주석과 함께.
---
## 좋은 예시 2: `scripts/bin/render-diff-apply` (render → diff → apply wrapper)
```bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
source "${SCRIPT_DIR}/../lib/common.sh"
usage() {
cat <<'EOF' >&2
Usage: render-diff-apply [OPTIONS]
--overlay PATH kustomize overlay directory (required)
--context NAME kube context name (required)
--namespace NS target namespace (optional, derived from overlay)
--timeout DUR rollout status timeout (default: 10m)
--yes skip interactive confirmation for apply
--dry-run render + diff only, no apply
-h, --help show this help
Environment:
CONFIRM=yes non-interactive confirmation (alternative to --yes)
Examples:
render-diff-apply --overlay k8s/overlays/prod --context prod-eu
CONFIRM=yes render-diff-apply --overlay k8s/overlays/prod --context prod-eu --timeout 15m
EOF
}
parse_args() {
OVERLAY=""
CONTEXT=""
NAMESPACE=""
TIMEOUT="10m"
YES=0
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--overlay) OVERLAY="$2"; shift 2 ;;
--context) CONTEXT="$2"; shift 2 ;;
--namespace) NAMESPACE="$2"; shift 2 ;;
--timeout) TIMEOUT="$2"; shift 2 ;;
--yes) YES=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage; fatal "unknown arg: $1" ;;
esac
done
[[ -n "${OVERLAY}" ]] || { usage; fatal "--overlay is required"; }
[[ -n "${CONTEXT}" ]] || { usage; fatal "--context is required"; }
[[ -d "${OVERLAY}" ]] || fatal "overlay not found: ${OVERLAY}"
}
kctx() {
kubectl --context="${CONTEXT}" "$@"
}
render() {
local out="$1"
info "rendering ${OVERLAY}"
kubectl kustomize "${OVERLAY}" > "${out}"
info "rendered $(wc -l < "${out}") lines to ${out}"
}
validate() {
local rendered="$1"
info "server-side dry-run validation"
kctx apply -f "${rendered}" --dry-run=server >/dev/null
}
show_diff() {
info "computing diff"
# kubectl diff exit code: 0 no diff, 1 diff, >1 error
set +e
kctx diff -k "${OVERLAY}"
local rc=$?
set -e
case "${rc}" in
0) info "no diff" ;;
1) info "diff present" ;;
*) fatal "diff failed with code ${rc}" ;;
esac
return "${rc}"
}
apply_overlay() {
info "applying ${OVERLAY} to context=${CONTEXT}"
kctx apply -k "${OVERLAY}"
}
watch_rollout() {
[[ -n "${NAMESPACE}" ]] || return 0
local deployments
deployments="$(kctx -n "${NAMESPACE}" get deploy -o jsonpath='{.items[*].metadata.name}' || true)"
for d in ${deployments}; do
info "rollout status: deployment/${d}"
retry 3 5 kctx -n "${NAMESPACE}" rollout status "deployment/${d}" --timeout="${TIMEOUT}"
done
}
main() {
parse_args "$@"
require_cmd kubectl
require_cmd kustomize
TMPDIR="$(mktemp -d)"
trap 'rm -rf "${TMPDIR}"' EXIT INT TERM
local rendered="${TMPDIR}/rendered.yaml"
render "${rendered}"
validate "${rendered}"
local diff_rc=0
show_diff || diff_rc=$?
if (( DRY_RUN == 1 )); then
info "dry-run mode: skipping apply"
exit 0
fi
if (( diff_rc == 0 )); then
info "no changes, nothing to apply"
exit 0
fi
if (( YES != 1 )) && [[ "${CONFIRM:-no}" != "yes" ]]; then
confirm "apply changes to context=${CONTEXT} overlay=${OVERLAY}?" \
|| fatal "aborted by user"
fi
apply_overlay
watch_rollout
info "done"
}
main "$@"
```
**왜 좋은가:**
- strict mode + trap + usage + main "$@" + log 전부 포함.
- `--yes` / `CONFIRM=yes` 이중 gate.
- `--dry-run=server` validation 이 apply 전 필수.
- `kubectl diff` 의 exit code (0/1/>1) 정확히 분기.
- retry 함수로 rollout status 불안정성 흡수.
- secret 을 argv / 로그에 쓰지 않음.
- jsonpath 로 deployment 목록 파싱, regex 없음.
---
## 좋은 예시 3: `scripts/bin/backup-k3s` (etcd snapshot backup, destructive-aware)
```bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
source "${SCRIPT_DIR}/../lib/common.sh"
usage() {
cat <<'EOF' >&2
Usage: backup-k3s [OPTIONS]
--node HOST server node to snapshot on (required)
--s3-endpoint URL S3 endpoint for offsite copy (optional)
--retention N days to keep local snapshots (default: 7)
-h, --help show this help
Environment:
SSH_USER ssh user (default: current user)
S3_ACCESS_KEY required if --s3-endpoint is set
S3_SECRET_KEY required if --s3-endpoint is set
EOF
}
main() {
local NODE="" S3_ENDPOINT="" RETENTION=7
while [[ $# -gt 0 ]]; do
case "$1" in
--node) NODE="$2"; shift 2 ;;
--s3-endpoint) S3_ENDPOINT="$2"; shift 2 ;;
--retention) RETENTION="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage; fatal "unknown arg: $1" ;;
esac
done
[[ -n "${NODE}" ]] || { usage; fatal "--node required"; }
require_cmd ssh
if [[ -n "${S3_ENDPOINT}" ]]; then
require_env S3_ACCESS_KEY
require_env S3_SECRET_KEY
fi
local ts
ts="$(date -u +'%Y%m%dT%H%M%SZ')"
local snap="k3s-snapshot-${ts}.db"
info "creating snapshot on node=${NODE}"
ssh "${SSH_USER:-$USER}@${NODE}" \
"sudo k3s etcd-snapshot save --name ${snap}"
info "pruning snapshots older than ${RETENTION} days on ${NODE}"
ssh "${SSH_USER:-$USER}@${NODE}" \
"sudo find /var/lib/rancher/k3s/server/db/snapshots -name 'k3s-snapshot-*.db' -mtime +${RETENTION} -print -delete"
if [[ -n "${S3_ENDPOINT}" ]]; then
info "uploading ${snap} to ${S3_ENDPOINT} (credentials masked)"
# secret 은 env 로 mc 에 전달, argv 노출 금지
ssh "${SSH_USER:-$USER}@${NODE}" \
"S3_ACCESS_KEY='${S3_ACCESS_KEY}' S3_SECRET_KEY='${S3_SECRET_KEY}' \
mc alias set backup ${S3_ENDPOINT} \"\${S3_ACCESS_KEY}\" \"\${S3_SECRET_KEY}\" 2>&1 | mask-secrets || true && \
mc cp /var/lib/rancher/k3s/server/db/snapshots/${snap} backup/k3s-snapshots/${snap}"
fi
info "backup complete: ${snap}"
}
main "$@"
```
**왜 좋은가:**
- backup 은 destructive 가 아니므로 `--yes` 는 없지만, prune 은 retention 일수로 guard.
- secret 은 argv 로 전달 X, env 로 ssh 내부에서만.
- ISO 8601 UTC timestamp 로 이름 충돌 방지.
- require_env 로 credential 선검증.
---
## 좋은 예시 4: destructive 스크립트 예시 (`scripts/bin/delete-namespace`)
```bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
source "${SCRIPT_DIR}/../lib/common.sh"
usage() {
cat <<'EOF' >&2
Usage: delete-namespace --context CTX --namespace NS [--yes]
DANGER: this deletes the namespace and all its resources (including PVCs
if reclaimPolicy=Delete). Requires --yes or CONFIRM=yes.
EOF
}
main() {
local CONTEXT="" NS="" YES=0
while [[ $# -gt 0 ]]; do
case "$1" in
--context) CONTEXT="$2"; shift 2 ;;
--namespace) NS="$2"; shift 2 ;;
--yes) YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage; fatal "unknown arg: $1" ;;
esac
done
[[ -n "${CONTEXT}" ]] || { usage; fatal "--context required"; }
[[ -n "${NS}" ]] || { usage; fatal "--namespace required"; }
require_cmd kubectl
if (( YES != 1 )) && [[ "${CONFIRM:-no}" != "yes" ]]; then
usage
fatal "destructive op requires --yes or CONFIRM=yes"
fi
warn "will DELETE namespace=${NS} in context=${CONTEXT}"
local pvc_count
pvc_count="$(kubectl --context="${CONTEXT}" -n "${NS}" get pvc -o json | jq '.items | length')"
warn "PVC count in namespace: ${pvc_count}"
kubectl --context="${CONTEXT}" delete namespace "${NS}" --wait=true
info "deleted namespace=${NS}"
}
main "$@"
```
**왜 좋은가:**
- destructive op 는 `--yes` / `CONFIRM=yes` 이중 gate.
- 삭제 전 PVC 수를 jq 로 보여줌 (사용자 자각).
- `--wait=true` 로 실제 삭제 완료 확인.
- JSON 파싱은 jq, regex 없음.
---
## 좋은 예시 5: local 선언과 command substitution 분리
```bash
get_current_context() {
local ctx
ctx="$(kubectl config current-context)" # 분리
printf '%s\n' "${ctx}"
}
```
**왜 좋은가:**
- ShellCheck SC2155: `local ctx="$(...)"``local` 의 exit status 가 cmd substitution 을 가리므로 에러가 숨는다.
- 분리해야 `$?` 가 실제 kubectl 결과 반영.
---
## 좋은 예시 6: JSON 파싱
```bash
# jsonpath
get_image() {
local ns="$1" deploy="$2"
kubectl -n "${ns}" get deploy "${deploy}" \
-o jsonpath='{.spec.template.spec.containers[0].image}'
}
# jq
get_all_images() {
local ns="$1"
kubectl -n "${ns}" get pods -o json \
| jq -r '.items[].spec.containers[].image' \
| sort -u
}
```
**왜 좋은가:**
- jsonpath / jq 는 구조적 파싱 → field 순서나 formatting 변화에 내성.
---
## 좋은 예시 7: secret masking 적용 예
```bash
deploy_with_debug() {
local overlay="$1"
if [[ "${DEBUG:-0}" -eq 1 ]]; then
set -x
fi
kubectl apply -k "${overlay}" 2>&1 | mask_secrets
if [[ "${DEBUG:-0}" -eq 1 ]]; then
set +x
fi
}
```
**왜 좋은가:**
- debug 시에도 stdout/stderr 에 secret 이 새지 않음.
- mask_secrets 가 common lib 에서 재사용.
---
## 나쁜 예시 1: strict mode 없음
```bash
#!/bin/bash
# strict mode 없음
TMP=/tmp/foo
rm -rf $TMP
mkdir $TMP
some_command
# 실패해도 계속 진행
```
**문제:**
- 실패가 조용히 통과 (`set -e` 없음).
- unset variable 에서 빈 경로로 rm → 재앙 가능.
- unquoted `$TMP` 공백 split.
**Fix:** `set -euo pipefail` + `IFS=$'\n\t'` + trap + quote.
---
## 나쁜 예시 2: heredoc YAML 생성기
```bash
deploy_auth() {
cat <<EOF > /tmp/auth.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
spec:
replicas: ${REPLICAS}
template:
spec:
containers:
- name: auth
image: auth:${VERSION}
EOF
kubectl apply -f /tmp/auth.yaml
}
```
**문제:**
- 선언형 원본이 스크립트 안에 숨음.
- Git diff 로 환경별 차이 추적 불가.
- 리뷰 / audit / kustomize 기능 모두 상실.
**Fix:** Kustomize overlay → `kubectl apply -k`.
---
## 나쁜 예시 3: regex 로 kubectl 출력 파싱
```bash
kubectl get pods | grep Running | awk '{print $1}'
```
**문제:**
- column 순서나 추가 field 변화에 깨짐.
- `Running` 이 pod 이름에 포함되면 오인식.
**Fix:**
```bash
kubectl get pods --field-selector=status.phase=Running -o jsonpath='{.items[*].metadata.name}'
```
---
## 나쁜 예시 4: secret 을 argv 로 전달
```bash
mc alias set backup https://s3.example.com "${ACCESS}" "${SECRET}"
# ps aux 에 노출, history 에 기록
```
**문제:**
- `ps` 나 audit log 에서 credential 유출.
- bash history (`HISTFILE`) 에 기록 가능.
**Fix:**
```bash
mc alias set backup https://s3.example.com \
"$(echo "${ACCESS}")" "$(cat /run/secrets/s3-secret)"
# 또는 환경변수로 mc 가 직접 읽도록
MC_HOST_backup="https://${ACCESS}:${SECRET}@s3.example.com" mc cp ...
```
---
## 나쁜 예시 5: confirmation 없는 destructive
```bash
#!/usr/bin/env bash
kubectl delete ns prod
```
**문제:**
- 의도 / 권한 / audit 전혀 없음.
- 사고 직결.
**Fix:** 좋은 예시 4 참조 (`--yes` / `CONFIRM=yes` gate + 사전 정보 표시).
---
## 나쁜 예시 6: trap 없이 임시파일
```bash
TMP="$(mktemp)"
do_something > "${TMP}"
# 실패 시 /tmp 에 쓰레기 남음
rm "${TMP}"
```
**문제:**
- 스크립트 실패 / Ctrl-C 시 임시 파일 누적.
- secret 이 들어있으면 유출.
**Fix:**
```bash
TMP="$(mktemp)"
trap 'rm -f "${TMP}"' EXIT INT TERM
do_something > "${TMP}"
```
---
## 나쁜 예시 7: `local` 과 command substitution 한 줄
```bash
bad() {
local ctx="$(kubectl config current-context)" # $? 가려짐
}
```
**문제:**
- ShellCheck SC2155. `local` 의 exit status 가 cmd substitution 을 덮어 에러 감지 실패.
**Fix:**
```bash
good() {
local ctx
ctx="$(kubectl config current-context)"
}
```
+528
View File
@@ -0,0 +1,528 @@
# security hardening 예시
이 문서의 모든 YAML은 `kubectl apply --dry-run=server -f -` 기준 clean을 목표로 한다. 1000+ 서비스 운영 클러스터의 auth-server namespace를 기준 예시로 사용한다.
---
## 좋은 예시 1: Namespace에 Pod Security Admission 라벨 enforce
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: auth-prod
labels:
app.kubernetes.io/part-of: identity-platform
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.29
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: v1.29
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: v1.29
annotations:
platform.example.com/owner: identity-team
platform.example.com/adr: ADR-0017-psa-restricted-baseline
```
**왜 좋은가:**
- 운영 namespace의 PSA 기본값을 `restricted`로 enforce. violation Pod는 API server 단에서 reject된다.
- version을 pin해 Kubernetes 업그레이드 시 silent behavior drift를 방지한다.
- audit/warn을 함께 붙여 위반을 audit log와 kubectl warning으로 수집한다.
---
## 좋은 예시 2: Restricted 프로파일을 완전히 만족하는 Deployment
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
namespace: auth-prod
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/version: 1.42.0
app.kubernetes.io/managed-by: argocd
spec:
replicas: 6
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server
template:
metadata:
labels:
app.kubernetes.io/name: auth-server
app.kubernetes.io/instance: auth-server
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/version: 1.42.0
app.kubernetes.io/managed-by: argocd
spec:
serviceAccountName: auth-server
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: auth-server
containers:
- name: auth-server
image: registry.example.com/identity/auth-server@sha256:8f3c0a8c6b3a2a7a0f1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: metrics
containerPort: 9090
protocol: TCP
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
envFrom:
- secretRef:
name: auth-server-db
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: "2"
memory: 1Gi
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 5
startupProbe:
httpGet:
path: /actuator/health/liveness
port: http
failureThreshold: 30
periodSeconds: 5
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
allowPrivilegeEscalation: false
privileged: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
volumeMounts:
- name: tmp
mountPath: /tmp
- name: workdir
mountPath: /workspace
volumes:
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 64Mi
- name: workdir
emptyDir:
sizeLimit: 256Mi
imagePullSecrets:
- name: registry-example-com
```
**왜 좋은가:**
- `restricted` 프로파일의 전 필드(runAsNonRoot, numeric UID/GID, fsGroup, seccompProfile, allowPrivilegeEscalation, readOnlyRootFilesystem, drop ALL capabilities)를 Pod+컨테이너 양쪽에 일관 명시한다.
- image는 digest pin. mutable tag에 의존하지 않는다.
- ServiceAccount는 전용 SA + `automountServiceAccountToken: false`.
- writable 경로는 `emptyDir`로 분리해 root FS는 read-only 유지.
---
## 나쁜 예시 1: Restricted 프로파일 위반 Pod
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-server
namespace: auth-prod
spec:
replicas: 1
selector:
matchLabels:
app: auth-server
template:
metadata:
labels:
app: auth-server
spec:
containers:
- name: auth-server
image: auth-server:latest
securityContext:
privileged: true
```
**문제:**
- `privileged: true`는 baseline조차 위반. PSA enforce=restricted namespace에서는 API server가 reject한다.
- `runAsNonRoot`, `allowPrivilegeEscalation`, `capabilities.drop`, `seccompProfile`, `readOnlyRootFilesystem` 전부 누락.
- image tag `latest`는 digest 고정 없이 rolling silently breaks.
- SA 미지정 → `default` SA가 토큰 자동 마운트.
---
## 좋은 예시 3: Default-deny + DNS + Ingress + DB + Prometheus allow NetworkPolicy 세트
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: auth-prod
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: auth-prod
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-ingress-traefik
namespace: auth-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: auth-server
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-traefik
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports:
- protocol: TCP
port: 8080
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-to-postgres
namespace: auth-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: auth-server
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: data-prod
podSelector:
matchLabels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: identity-postgres
ports:
- protocol: TCP
port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-metrics-scrape-from-prometheus
namespace: auth-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: auth-server
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app.kubernetes.io/name: prometheus
ports:
- protocol: TCP
port: 9090
```
**왜 좋은가:**
- `namespaceSelector``podSelector`**동일 `from` 엔트리** 안에 있으므로 AND(교집합): monitoring namespace 안의 Prometheus Pod만 9090 scrape 허용된다.
- default-deny + minimum allow 세트로 ingress/egress 모두 통제.
- DNS는 `kube-system``k8s-app=kube-dns` Pod로 한정, egress 전체를 열지 않음.
---
## 나쁜 예시 2: NetworkPolicy AND/OR 혼동
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: broken-scrape
namespace: auth-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: auth-server
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
- podSelector:
matchLabels:
app.kubernetes.io/name: prometheus
ports:
- protocol: TCP
port: 9090
```
**문제:**
- `namespaceSelector``podSelector`**별도 엔트리**(두 개의 `-`) → OR로 해석된다.
- 결과: ① monitoring namespace의 **모든 Pod**가 허용되고, ② `auth-prod` namespace의 label `app.kubernetes.io/name=prometheus`를 가진 **아무 Pod**도 허용된다.
- 의도했던 "monitoring의 Prometheus만 허용"이 아니라 훨씬 넓은 경로가 열린다. 실제 클러스터에서 NetworkPolicy 버그의 1순위.
---
## 좋은 예시 4: Namespace-scoped RBAC (Role + RoleBinding)
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: auth-server-secret-rotator
namespace: auth-prod
automountServiceAccountToken: true
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: auth-server-secret-reader
namespace: auth-prod
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames:
- auth-server-db
- auth-server-oidc-client
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: auth-server-secret-reader
namespace: auth-prod
subjects:
- kind: ServiceAccount
name: auth-server-secret-rotator
namespace: auth-prod
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: auth-server-secret-reader
```
**왜 좋은가:**
- namespace 경계 안에서 특정 Secret 이름 2개만 `get`. `list`/`watch` 미부여.
- SA/Role/RoleBinding 모두 같은 namespace에 명시. `---`로 분리된 다중 리소스 문서.
- `system:masters``cluster-admin` 같은 전능 role과 무관.
---
## 나쁜 예시 3: cluster-admin ClusterRoleBinding 남용
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: auth-server-admin
subjects:
- kind: ServiceAccount
name: auth-server
namespace: auth-prod
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
```
**문제:**
- 단일 SA가 모든 namespace의 모든 리소스(Secret, Node, CRD)를 수정할 수 있다.
- 앱 노드 1개가 compromise되면 전체 클러스터가 compromise된다.
- least privilege 원칙의 정반대.
---
## 좋은 예시 5: Private registry ImagePullSecret
```yaml
apiVersion: v1
kind: Secret
metadata:
name: registry-example-com
namespace: auth-prod
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: eyJhdXRocyI6eyJyZWdpc3RyeS5leGFtcGxlLmNvbSI6eyJ1c2VybmFtZSI6ImNpLWJvdCIsInBhc3N3b3JkIjoiPFJFREFDVEVEPiIsImF1dGgiOiI8UkVEQUNURUQ+In19fQ==
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: auth-server
namespace: auth-prod
automountServiceAccountToken: false
imagePullSecrets:
- name: registry-example-com
```
**왜 좋은가:**
- type이 `kubernetes.io/dockerconfigjson`으로 정확. kubelet이 이 포맷만 pull credential로 인식한다.
- SA에 `imagePullSecrets`를 묶어 Deployment 마다 반복 선언 불필요.
- 실제 운영에서는 이 Secret 자체도 VSO로 Vault → K8s로 sync(config-and-secrets 문서 참고).
---
## 나쁜 예시 4: 정책 없는 운영 namespace
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: auth-prod
```
**문제:**
- PSA 라벨 없음 → `privileged` Pod도 통과.
- NetworkPolicy 없음 → ingress/egress 모두 allow-all. 침해 시 lateral movement 자유.
- ResourceQuota/LimitRange 없음 → 한 Deployment가 namespace CPU/memory 전부 점유 가능.
- 1000-서비스 운영에서 이런 namespace는 허용되지 않는다.
---
## 좋은 예시 6: ResourceQuota + LimitRange 묶음
```yaml
apiVersion: v1
kind: ResourceQuota
metadata:
name: auth-prod-quota
namespace: auth-prod
spec:
hard:
requests.cpu: "50"
requests.memory: 100Gi
limits.cpu: "100"
limits.memory: 200Gi
pods: "200"
services.loadbalancers: "0"
services.nodeports: "0"
---
apiVersion: v1
kind: LimitRange
metadata:
name: auth-prod-defaults
namespace: auth-prod
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 128Mi
max:
cpu: "4"
memory: 4Gi
```
**왜 좋은가:**
- `services.loadbalancers=0`, `services.nodeports=0`으로 namespace 내 외부 노출 Service 생성을 금지(ingress 경유 강제).
- LimitRange로 컨테이너별 default request/limit을 보장해 limit 누락 Pod를 예방.
+641
View File
@@ -0,0 +1,641 @@
# storage / PVC 예시
모든 예시는 `kubectl apply -f` 로 바로 적용 가능한 완성 매니페스트다.
생략(`...`)이 있는 곳은 의도적으로 다른 문서로 위임한 부분이다.
---
## 좋은 예시 1: 운영 StorageClass 표준 세트 (WaitForFirstConsumer + Retain)
```yaml
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd-retain
labels:
app.kubernetes.io/part-of: platform-storage
storage.platform.io/tier: gold
annotations:
storage.platform.io/description: "prod stateful (DB, vault, object store). retain on PVC delete."
provisioner: driver.longhorn.io
parameters:
numberOfReplicas: "3"
staleReplicaTimeout: "30"
fsType: ext4
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: standard-delete
labels:
app.kubernetes.io/part-of: platform-storage
storage.platform.io/tier: silver
annotations:
storage.platform.io/description: "dev/test, ephemeral, rebuild-safe data. deletes on PVC removal."
provisioner: driver.longhorn.io
parameters:
numberOfReplicas: "2"
fsType: ext4
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: rwx-shared
labels:
app.kubernetes.io/part-of: platform-storage
storage.platform.io/tier: shared
provisioner: nfs.csi.k8s.io
parameters:
server: nfs.storage.svc.cluster.local
share: /exports/shared
reclaimPolicy: Retain
volumeBindingMode: Immediate # 네트워크 스토리지이고 topology 제약 없음 → 예외적으로 Immediate 허용
allowVolumeExpansion: true
mountOptions:
- nfsvers=4.1
- hard
- noatime
```
왜 좋은가:
- `volumeBindingMode: WaitForFirstConsumer` 기본, `Immediate`는 이유를 주석으로 명시
- `reclaimPolicy`가 데이터 등급에 따라 다르게 선언됨 (Retain / Delete)
- `allowVolumeExpansion: true` 기본
- 라벨/annotation으로 용도 구분
❌ 나쁜 예시 1: 기본값 의존 + Immediate 바인딩
```yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: default
provisioner: driver.longhorn.io
# reclaimPolicy 미지정 → 기본 Delete (운영 데이터도 삭제됨)
# volumeBindingMode 미지정 → 기본 Immediate (topology 충돌 유발)
# allowVolumeExpansion 미지정 → 확장 불가
```
문제:
- `reclaimPolicy` 기본 `Delete`: 실수로 PVC를 지우면 PV와 데이터까지 사라진다
- `volumeBindingMode` 기본 `Immediate`: Pod가 스케줄되지 못하는 zone/node에 PV가 붙을 수 있다
- 확장 불가
---
## 좋은 예시 2: VolumeSnapshotClass를 StorageClass와 매칭
```yaml
---
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: fast-ssd-snap-retain
labels:
app.kubernetes.io/part-of: platform-storage
velero.io/csi-volumesnapshot-class: "true"
driver: driver.longhorn.io
deletionPolicy: Retain
parameters:
type: bak
csi.storage.k8s.io/snapshotter-secret-name: longhorn-backup-secret
csi.storage.k8s.io/snapshotter-secret-namespace: longhorn-system
---
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: standard-snap-delete
labels:
app.kubernetes.io/part-of: platform-storage
driver: driver.longhorn.io
deletionPolicy: Delete
```
왜 좋은가:
- snapshot class가 StorageClass 등급과 1:1 매칭
- 운영 데이터용은 `deletionPolicy: Retain`
- Velero가 인식하도록 `velero.io/csi-volumesnapshot-class: "true"` 라벨 부여
---
## 좋은 예시 3: PostgreSQL StatefulSet + PVC retention Retain
```yaml
---
apiVersion: v1
kind: Namespace
metadata:
name: data-prod
labels:
pod-security.kubernetes.io/enforce: restricted
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: data-prod
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-prod
app.kubernetes.io/component: database
app.kubernetes.io/part-of: auth-platform
app.kubernetes.io/managed-by: argocd
app.kubernetes.io/version: "16.4"
spec:
serviceName: postgres
replicas: 1
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
selector:
matchLabels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-prod
template:
metadata:
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-prod
app.kubernetes.io/component: database
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: postgres
image: postgres@sha256:8a6b7c6f0e0b5e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b
imagePullPolicy: IfNotPresent
ports:
- name: pg
containerPort: 5432
env:
- name: POSTGRES_DB
value: auth
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
envFrom:
- secretRef:
name: postgres-credentials
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "2"
memory: "4Gi"
readinessProbe:
exec:
command: ["pg_isready", "-U", "postgres"]
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
exec:
command: ["pg_isready", "-U", "postgres"]
initialDelaySeconds: 30
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
- name: tmp
mountPath: /tmp
- name: run
mountPath: /var/run/postgresql
volumes:
- name: tmp
emptyDir: {}
- name: run
emptyDir: {}
volumeClaimTemplates:
- metadata:
name: data
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-prod
backup.platform.io/tier: gold
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd-retain
resources:
requests:
storage: 50Gi
```
왜 좋은가:
- `persistentVolumeClaimRetentionPolicy`가 명시적으로 `Retain`
- StorageClass `fast-ssd-retain`에 맞물리는 `RWO`
- `fsGroup` + `fsGroupChangePolicy` 설정
- restricted PSA 준수 (runAsNonRoot, readOnlyRootFilesystem, capabilities drop all)
- `emptyDir`로 tmp/run 분리 (PVC 남발 방지)
- 라벨 full set + backup tier 라벨
---
## 좋은 예시 4: 단일 PVC Deployment (RWO, replicas 1)
```yaml
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: minio-data
namespace: object-prod
labels:
app.kubernetes.io/name: minio
app.kubernetes.io/component: object-store
backup.platform.io/tier: gold
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd-retain
resources:
requests:
storage: 500Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: minio
namespace: object-prod
labels:
app.kubernetes.io/name: minio
app.kubernetes.io/instance: minio-prod
app.kubernetes.io/component: object-store
app.kubernetes.io/part-of: platform-storage
spec:
replicas: 1
strategy:
type: Recreate # RWO 단일 PVC이므로 RollingUpdate 금지
selector:
matchLabels:
app.kubernetes.io/name: minio
app.kubernetes.io/instance: minio-prod
template:
metadata:
labels:
app.kubernetes.io/name: minio
app.kubernetes.io/instance: minio-prod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: minio
image: quay.io/minio/minio@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
args: ["server", "/data", "--console-address", ":9001"]
ports:
- {name: s3, containerPort: 9000}
- {name: console, containerPort: 9001}
envFrom:
- secretRef:
name: minio-root-credentials
resources:
requests: {cpu: "250m", memory: "512Mi"}
limits: {cpu: "2", memory: "4Gi"}
readinessProbe:
httpGet: {path: /minio/health/ready, port: s3}
periodSeconds: 5
livenessProbe:
httpGet: {path: /minio/health/live, port: s3}
periodSeconds: 20
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- {name: data, mountPath: /data}
volumes:
- name: data
persistentVolumeClaim:
claimName: minio-data
```
왜 좋은가:
- StatefulSet 없이도 stable한 단일 writer 구성
- `strategy: Recreate`로 RWO 충돌 방지
- PVC와 StorageClass가 명시적으로 매칭
- backup tier 라벨 → Velero selector와 연동
❌ 나쁜 예시 2: RWO에 RollingUpdate + replicas 2
```yaml
spec:
replicas: 2
strategy:
type: RollingUpdate
template:
spec:
containers:
- volumeMounts:
- {name: data, mountPath: /data}
volumes:
- name: data
persistentVolumeClaim:
claimName: minio-data # RWO인데 두 Pod가 동시에 마운트 시도
```
문제:
- RWO PVC를 두 Pod가 동시에 잡을 수 없어 신규 Pod가 영원히 Pending
- RollingUpdate가 old→new 전환 시 마운트 충돌
- 해결: replicas=1 + Recreate, 또는 RWX, 또는 StatefulSet
---
## 좋은 예시 5: separate PVC (정당한 수명/복구 단위 차이)
```yaml
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: archive-ledger
namespace: finance-prod
labels:
app.kubernetes.io/name: archive-ledger
app.kubernetes.io/instance: archive-ledger-prod
spec:
serviceName: archive-ledger
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: archive-ledger
app.kubernetes.io/instance: archive-ledger-prod
template:
metadata:
labels:
app.kubernetes.io/name: archive-ledger
app.kubernetes.io/instance: archive-ledger-prod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: archive-ledger
image: registry.example.com/finance/archive-ledger@sha256:3fbc632167424a6d997e74f52b878d7cc478225cffac6bc977eedfe51c7f4e79
ports:
- { name: http, containerPort: 8080 }
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { memory: 2Gi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- { name: data, mountPath: /var/lib/ledger }
- { name: audit-archive, mountPath: /var/lib/ledger/audit }
volumeClaimTemplates:
- metadata:
name: data
labels:
backup.platform.io/tier: gold # 5분 RPO, 매일 snapshot
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd-retain
resources:
requests: {storage: 500Gi}
- metadata:
name: audit-archive
labels:
backup.platform.io/tier: bronze # 24h RPO, 주 1회 snapshot, 7년 보존
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: archive-retain
resources:
requests: {storage: 2Ti}
```
왜 좋은가:
- data와 audit-archive의 RPO/retention이 다름
- StorageClass도 다름 (SSD vs 아카이브)
- backup tier 라벨이 Velero schedule selector에 의해 다르게 잡힘
❌ 나쁜 예시 3: separate PVC 남발
```yaml
volumeClaimTemplates:
- {metadata: {name: logs}}
- {metadata: {name: tmp}}
- {metadata: {name: config-copy}}
- {metadata: {name: cache}}
```
문제:
- 로그/tmp/cache는 `emptyDir` 또는 stdout 대상
- PVC 4개는 수명 구분 없이 쪼갠 것 — 운영 복잡도만 증가
- snapshot/backup 단위가 파편화됨
---
## 좋은 예시 6: 파이프라인 전체 — PVC → Snapshot → Restore → Verify
아래 6개 블록은 순서대로 `kubectl apply` 한다.
### (1) PVC
```yaml
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data
namespace: app-prod
labels:
app.kubernetes.io/name: app
backup.platform.io/tier: gold
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd-retain
resources:
requests: {storage: 20Gi}
```
### (2) VolumeSnapshotClass (전역 1회)
```yaml
---
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: fast-ssd-snap-retain
labels:
velero.io/csi-volumesnapshot-class: "true"
driver: driver.longhorn.io
deletionPolicy: Retain
```
### (3) On-demand VolumeSnapshot
```yaml
---
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: app-data-2026-04-16-pre-migration
namespace: app-prod
labels:
app.kubernetes.io/name: app
snapshot.platform.io/reason: pre-migration
spec:
volumeSnapshotClassName: fast-ssd-snap-retain
source:
persistentVolumeClaimName: app-data
```
### (4) Restore: snapshot을 소스로 하는 새 PVC
```yaml
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data-restored
namespace: app-prod
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd-retain
resources:
requests: {storage: 20Gi}
dataSource:
name: app-data-2026-04-16-pre-migration
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
```
### (5) Verify Job
```yaml
---
apiVersion: batch/v1
kind: Job
metadata:
name: app-data-restore-verify
namespace: app-prod
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
containers:
- name: verify
image: busybox@sha256:3fbc632167424a6d997e74f52b878d7cc478225cffac6bc977eedfe51c7f4e79
command:
- sh
- -c
- |
set -eu
test -d /data
COUNT=$(find /data -type f | wc -l)
echo "file_count=${COUNT}"
test "${COUNT}" -gt 0
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 128Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
volumeMounts:
- {name: data, mountPath: /data, readOnly: true}
volumes:
- name: data
persistentVolumeClaim:
claimName: app-data-restored
```
### (6) 최종 확인
```bash
kubectl -n app-prod get volumesnapshot,pvc,job
kubectl -n app-prod logs job/app-data-restore-verify
```
왜 좋은가:
- PVC → SnapshotClass → Snapshot → dataSource 기반 PVC restore → Job 검증의 end-to-end 흐름
- `deletionPolicy: Retain`으로 snapshot을 실수로 삭제해도 PV는 남음
- Job이 restricted PSA 준수, 이미지 digest 고정, `backoffLimit: 0`
---
## 나쁜 예시 4: hostPath를 운영 PV로 사용
```yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: pg-host
spec:
capacity: {storage: 50Gi}
accessModes: ["ReadWriteOnce"]
hostPath:
path: /data/postgres
```
문제:
- 노드 장애 = 데이터 손실
- snapshot / expansion / 다중 노드 스케줄링 전부 불가
- 운영 표준 아님
---
## 나쁜 예시 5: K3s local-path로 production Postgres
```yaml
volumeClaimTemplates:
- metadata: {name: data}
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: local-path # K3s default
resources: {requests: {storage: 100Gi}}
```
문제:
- local-path는 snapshot 미지원 → Velero CSI snapshot 불가
- expansion 미지원 → 용량 부족 시 마이그레이션 필요
- 노드 pin → 노드 장애 시 Postgres 복구 불가
- 해결: Longhorn / OpenEBS / cloud CSI driver로 교체
---
## 나쁜 예시 6: StorageClass 생략
```yaml
spec:
accessModes: ["ReadWriteOnce"]
resources: {requests: {storage: 20Gi}}
# storageClassName 미지정 → 클러스터 default annotation 사용
```
문제:
- 어떤 tier를 기대했는지 선언에서 드러나지 않음
- 클러스터 default가 바뀌면 침묵적으로 다른 StorageClass로 바인딩
- 환경 간 재현 불가
+777
View File
@@ -0,0 +1,777 @@
# Vault 예시
Vault 1.17+ + Helm chart `hashicorp/vault` + VSO 0.8+ 기준. 모든 manifest는 `kubectl apply` 적용 가능한 완전한 형태다.
---
## 좋은 예시 1: Helm values.yaml — HA Raft + auto-unseal + audit
```yaml
# values/vault-prod.yaml
global:
enabled: true
tlsDisable: false
injector:
enabled: true
replicas: 2
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
server:
image:
repository: hashicorp/vault
tag: "1.17.6"
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
extraEnvironmentVars:
VAULT_CACERT: /vault/tls/ca.crt
VAULT_TLSCERT: /vault/tls/tls.crt
VAULT_TLSKEY: /vault/tls/tls.key
AWS_REGION: ap-northeast-2
volumes:
- name: vault-tls
secret:
secretName: vault-tls
volumeMounts:
- name: vault-tls
mountPath: /vault/tls
readOnly: true
serviceAccount:
create: true
name: vault
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/vault-autounseal
readinessProbe:
enabled: true
path: "/v1/sys/health?standbyok=true&perfstandbyok=true&uninitcode=204"
port: 8200
scheme: HTTPS
failureThreshold: 2
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
livenessProbe:
enabled: true
path: "/v1/sys/health?standbyok=true&sealedcode=204&uninitcode=204"
port: 8200
scheme: HTTPS
failureThreshold: 3
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 3
dataStorage:
enabled: true
size: 20Gi
storageClass: ebs-gp3
accessMode: ReadWriteOnce
mountPath: /vault/data
auditStorage:
enabled: true
size: 10Gi
storageClass: ebs-gp3
accessMode: ReadWriteOnce
mountPath: /vault/audit
service:
enabled: true
type: ClusterIP
port: 8200
targetPort: 8200
ha:
enabled: true
replicas: 3
apiAddr: "https://$(POD_IP):8200"
clusterAddr: "https://$(HOSTNAME).vault-internal:8201"
raft:
enabled: true
setNodeId: true
config: |
ui = true
listener "tcp" {
address = "[::]:8200"
cluster_address = "[::]:8201"
tls_cert_file = "/vault/tls/tls.crt"
tls_key_file = "/vault/tls/tls.key"
tls_min_version = "tls13"
}
storage "raft" {
path = "/vault/data"
retry_join {
leader_api_addr = "https://vault-0.vault-internal:8200"
leader_ca_cert_file = "/vault/tls/ca.crt"
leader_client_cert_file = "/vault/tls/tls.crt"
leader_client_key_file = "/vault/tls/tls.key"
}
retry_join {
leader_api_addr = "https://vault-1.vault-internal:8200"
leader_ca_cert_file = "/vault/tls/ca.crt"
leader_client_cert_file = "/vault/tls/tls.crt"
leader_client_key_file = "/vault/tls/tls.key"
}
retry_join {
leader_api_addr = "https://vault-2.vault-internal:8200"
leader_ca_cert_file = "/vault/tls/ca.crt"
leader_client_cert_file = "/vault/tls/tls.crt"
leader_client_key_file = "/vault/tls/tls.key"
}
}
seal "awskms" {
region = "ap-northeast-2"
kms_key_id = "alias/vault-autounseal"
}
service_registration "kubernetes" {}
telemetry {
prometheus_retention_time = "24h"
disable_hostname = true
}
affinity: |
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: vault
component: server
topologyKey: kubernetes.io/hostname
topologySpreadConstraints: |
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: vault
component: server
```
**왜 좋은가:**
- `ha.enabled=true` + `raft.enabled=true` + `raft.setNodeId=true` 3종 필수 플래그
- listener와 storage raft stanza가 8200/8201 모두 바인드, `cluster_address` 명시 → peer replication 성립
- `seal "awskms"`로 auto-unseal, Pod 재시작 시 수동 개입 불필요
- `auditStorage.enabled=true` → audit 전용 PVC 분리 (dataStorage 오염 방지)
- IRSA(`eks.amazonaws.com/role-arn`)로 KMS 접근 권한 위임 (static IAM key 없음)
---
## 나쁜 예시 1: chart 기본값 standalone
```bash
helm install vault hashicorp/vault --namespace vault --create-namespace
```
**문제:**
- 기본은 `standalone` + `file` storage → single pod, PVC 1개, HA 없음, snapshot restore로만 복구
- Shamir 수동 unseal → pod 재시작마다 운영자 개입
- audit device 미활성 → 감사 로그 없음
- chart 문서 자체가 "not suitable for production"이라 명시
---
## 좋은 예시 2: Service — 8200 + 8201 둘 다 expose
Helm chart가 자동 생성하지만, 수제 Service 예시:
```yaml
---
apiVersion: v1
kind: Service
metadata:
name: vault
namespace: vault
labels:
app.kubernetes.io/name: vault
app.kubernetes.io/instance: vault-prod
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: vault
component: server
ports:
- name: https
port: 8200
targetPort: 8200
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: vault-internal
namespace: vault
labels:
app.kubernetes.io/name: vault
spec:
type: ClusterIP
clusterIP: None
publishNotReadyAddresses: true
selector:
app.kubernetes.io/name: vault
component: server
ports:
- name: https
port: 8200
targetPort: 8200
- name: https-internal
port: 8201
targetPort: 8201
```
**왜 좋은가:**
- `vault-internal` headless + `publishNotReadyAddresses: true` → Raft peer가 unseal 전에도 서로 발견 가능
- **8201 포트 expose** → peer-to-peer Raft replication 성립 (누락 시 leader election 영구 실패)
- 사용자용 `vault` Service는 8200만 노출
---
## 나쁜 예시 2: 8201 누락
```yaml
spec:
ports:
- port: 8200
targetPort: 8200
```
**문제:**
- Raft peer가 8201로 서로 통신해야 하는데 Service가 expose하지 않음
- `vault operator raft list-peers`에서 follower가 리더로 못 붙음
- 증상: 단일 노드만 unsealed, 나머지는 "storage: IO error" 로그 루프
---
## 좋은 예시 3: Kubernetes auth bootstrap + role
```bash
# 1. Kubernetes auth method 활성화
vault auth enable kubernetes
# 2. Vault가 Kubernetes TokenReview API를 호출하기 위한 설정
# (Vault Pod 내부에서 실행하거나 reviewer SA의 JWT를 주입)
vault write auth/kubernetes/config \
token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
kubernetes_host="https://kubernetes.default.svc.cluster.local" \
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
disable_iss_validation=false
# 3. Policy 생성 (auth-server가 읽을 수 있는 경로만)
vault policy write auth-server-read - <<'EOF'
path "kv/data/auth-server/*" {
capabilities = ["read"]
}
path "database/creds/auth-server" {
capabilities = ["read"]
}
EOF
# 4. Role 생성 — 특정 SA + namespace에만 바인딩
vault write auth/kubernetes/role/auth-server \
bound_service_account_names=auth-server \
bound_service_account_namespaces=auth-prod \
policies=auth-server-read \
ttl=1h \
max_ttl=24h \
audience=vault
```
**왜 좋은가:**
- TokenReview JWT를 명시적으로 구성 → Vault가 SA 토큰 유효성 검증 가능
- Policy는 `kv/data/auth-server/*`, `database/creds/auth-server`만 허용 (최소 권한)
- Role은 `auth-prod` namespace의 `auth-server` SA에만 바인딩
- `audience=vault`로 projected token의 audience 검증 (token confusion 방어)
---
## 나쁜 예시 3: wildcard role
```bash
vault write auth/kubernetes/role/all-apps \
bound_service_account_names="*" \
bound_service_account_namespaces="*" \
policies=default \
ttl=720h
```
**문제:**
- 모든 namespace의 모든 SA가 로그인 가능 → 한 워크로드 침해가 전체 Vault 접근으로 확대
- TTL 30일은 token revocation window가 너무 김
- `default` policy가 넓으면 실질적인 접근 제어 상실
---
## 좋은 예시 4: VSO — 클러스터 수준 연결 + 앱 namespace auth
```yaml
---
apiVersion: v1
kind: Namespace
metadata:
name: vault-secrets-operator-system
---
# 1) 클러스터 전체 1개 VaultConnection
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultConnection
metadata:
name: default
namespace: vault-secrets-operator-system
spec:
address: https://vault.vault.svc.cluster.local:8200
tlsServerName: vault.vault.svc.cluster.local
caCertSecretRef: vault-ca
skipTLSVerify: false
timeout: 60s
---
# 2) 앱 namespace의 ServiceAccount
apiVersion: v1
kind: ServiceAccount
metadata:
name: auth-server
namespace: auth-prod
---
# 3) 앱 namespace의 VaultAuth (Vault Kubernetes auth role로 로그인)
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: default
namespace: auth-prod
spec:
vaultConnectionRef: vault-secrets-operator-system/default
method: kubernetes
mount: kubernetes
kubernetes:
role: auth-server
serviceAccount: auth-server
audiences:
- vault
tokenExpirationSeconds: 600
```
**왜 좋은가:**
- `VaultConnection` 1개를 operator namespace에 두고, 앱 namespace에서 cross-reference
- `VaultAuth.method: kubernetes`가 Vault의 `auth/kubernetes/role/auth-server`를 호출
- `audiences: [vault]`로 projected SA token의 audience 바인딩
- `tokenExpirationSeconds: 600` → projected token 10분마다 rotate
---
## 좋은 예시 5: VSO — Static / Dynamic / PKI secret
```yaml
---
# KV v2에서 정적 secret 동기화
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: auth-server-config
namespace: auth-prod
spec:
vaultAuthRef: default
mount: kv
path: auth-server/config
type: kv-v2
refreshAfter: 30m
hmacSecretData: true
rolloutRestartTargets:
- kind: Deployment
name: auth-server
destination:
name: auth-server-config
create: true
overwrite: true
---
# Postgres dynamic credential (TTL 1h)
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultDynamicSecret
metadata:
name: auth-server-db
namespace: auth-prod
spec:
vaultAuthRef: default
mount: database
path: creds/auth-server
renewalPercent: 67
rolloutRestartTargets:
- kind: Deployment
name: auth-server
destination:
name: auth-server-db
create: true
overwrite: true
transformation:
excludeRaw: true
templates:
DATABASE_URL:
text: 'postgresql://{{ .Secrets.username }}:{{ .Secrets.password }}@auth-db-rw.auth-prod.svc.cluster.local:5432/authdb?sslmode=require'
---
# PKI 인증서 발급
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultPKISecret
metadata:
name: auth-server-cert
namespace: auth-prod
spec:
vaultAuthRef: default
mount: pki_int
role: auth-server
commonName: auth-server.auth-prod.svc.cluster.local
altNames:
- auth-server
- auth-server.auth-prod
ipSans: []
ttl: 720h
revoke: true
clear: true
expiryOffset: 120h
destination:
name: auth-server-cert
create: true
type: kubernetes.io/tls
```
**왜 좋은가:**
- 세 패턴(정적 KV, 동적 DB credential, PKI cert)을 한 namespace에서 일관되게 선언
- `rolloutRestartTargets`로 secret 갱신 시 consumer Deployment 자동 롤링 재시작
- `renewalPercent: 67` → TTL 67% 경과 시 갱신 (default는 보통 70%)
- `transformation.templates`로 연결 문자열 포맷 변환 (앱이 username/password 파싱 안 해도 됨)
---
## 좋은 예시 6: Vault Agent Injector (init-only 모드)
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: legacy-app
namespace: legacy
spec:
replicas: 2
selector:
matchLabels:
app: legacy-app
template:
metadata:
labels:
app: legacy-app
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "legacy-app"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/agent-inject-secret-db.env: "database/creds/legacy-app"
vault.hashicorp.com/agent-inject-template-db.env: |
{{ with secret "database/creds/legacy-app" -}}
DATABASE_USERNAME={{ .Data.username }}
DATABASE_PASSWORD={{ .Data.password }}
{{- end }}
vault.hashicorp.com/agent-inject-file-db.env: "db.env"
vault.hashicorp.com/agent-limits-cpu: "200m"
vault.hashicorp.com/agent-limits-mem: "128Mi"
vault.hashicorp.com/agent-requests-cpu: "50m"
vault.hashicorp.com/agent-requests-mem: "64Mi"
spec:
serviceAccountName: legacy-app
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: registry.example.com/legacy-app:1.4.2
command: ["sh", "-c", "source /vault/secrets/db.env && exec /app/run"]
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 256Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- name: tmp
emptyDir: {}
```
**왜 좋은가:**
- `agent-pre-populate-only: "true"` → init container만 돌고 sidecar 없음 → 2 pod 당 컨테이너 1개 절감
- etcd에 Kubernetes Secret 생성 없음 (annotation에 명시적 destination 없음; in-memory volume)
- template로 `.env` 포맷 렌더링 → legacy 앱이 그대로 소비
---
## 나쁜 예시 4: Injector + long-lived sidecar + 무한 renew
```yaml
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "legacy-app"
vault.hashicorp.com/agent-inject-secret-creds: "database/creds/legacy-app"
# agent-pre-populate-only 없음 → sidecar 상시 실행
# agent-limits-* 없음 → sidecar가 limit 없이 메모리 증가
```
**문제:**
- sidecar가 Pod 수명 내내 상주 → 1000 서비스 x 3 replica = 3000 추가 컨테이너
- resource limit 미지정 → OOM cascading
- VSO로 대체 가능한데 Injector를 default로 쓰면 운영 복잡도 증가
---
## 좋은 예시 7: Raft snapshot CronJob
```yaml
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: vault-snapshot
namespace: vault
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: vault-raft-snapshot
namespace: vault
spec:
schedule: "0 2 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
serviceAccountName: vault-snapshot
securityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: snapshot
image: hashicorp/vault:1.17.6
env:
- name: VAULT_ADDR
value: https://vault.vault.svc.cluster.local:8200
- name: VAULT_CACERT
value: /vault/tls/ca.crt
- name: VAULT_TOKEN
valueFrom:
secretKeyRef:
name: vault-snapshot-token
key: token
- name: AWS_REGION
value: ap-northeast-2
command:
- sh
- -c
- |
set -eu
TS=$(date -u +%Y%m%dT%H%M%SZ)
SNAP=/tmp/vault-${TS}.snap
vault operator raft snapshot save "${SNAP}"
aws s3 cp "${SNAP}" "s3://vault-backup.example.com/daily/vault-${TS}.snap" \
--sse aws:kms --sse-kms-key-id alias/vault-backup
rm -f "${SNAP}"
volumeMounts:
- name: vault-tls
mountPath: /vault/tls
readOnly: true
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: vault-tls
secret:
secretName: vault-tls
- name: tmp
emptyDir: {}
```
**왜 좋은가:**
- 매일 02:00 UTC `raft snapshot save` 실행
- 결과를 S3 SSE-KMS로 off-cluster 보관 (PVC와 독립적 failure domain)
- 짧은 TTL snapshot token을 별도 Secret로 주입 (root token 미사용)
- `concurrencyPolicy: Forbid`로 snapshot 중복 방지
---
## 좋은 예시 8: ServiceMonitor + Prometheus policy
```yaml
---
# Vault policy: Prometheus가 /v1/sys/metrics 읽기 전용
# (이 정책은 Vault 내부에 생성)
# vault policy write prometheus-metrics - <<EOF
# path "sys/metrics" { capabilities = ["read"] }
# EOF
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: vault
namespace: vault
labels:
app.kubernetes.io/name: vault
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app.kubernetes.io/name: vault
endpoints:
- port: https
scheme: https
path: /v1/sys/metrics
params:
format:
- prometheus
interval: 30s
scrapeTimeout: 10s
bearerTokenSecret:
name: prometheus-vault-token
key: token
tlsConfig:
ca:
secret:
name: vault-ca
key: ca.crt
serverName: vault.vault.svc.cluster.local
```
**왜 좋은가:**
- `telemetry { prometheus_retention_time = "24h" }` stanza와 매칭
- Prometheus가 전용 Vault token으로 `sys/metrics`만 read (최소 권한)
- TLS serverName 명시로 hostname 검증
---
## 나쁜 예시 5: Vault Ingress 외부 공개
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vault
spec:
rules:
- host: vault.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vault
port:
number: 8200
```
**문제:**
- Vault는 일반 외부 서비스가 아님 — `/v1/auth/*`, `/v1/sys/*` 가 인터넷에 노출되면 brute-force / DoS 표면 확대
- root token / unseal key가 UI에서 한 번이라도 취급되면 공격 가치가 매우 큼
- 관리자 접근은 VPN / port-forward / OIDC 보호된 별도 bastion 경로로
---
## 좋은 예시 9: PodDisruptionBudget + Restricted securityContext
```yaml
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: vault
namespace: vault
spec:
minAvailable: 2
unhealthyPodEvictionPolicy: AlwaysAllow
selector:
matchLabels:
app.kubernetes.io/name: vault
component: server
```
그리고 values.yaml에서:
```yaml
server:
statefulSet:
securityContext:
pod:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
container:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
add:
- IPC_LOCK
```
**왜 좋은가:**
- Raft 3-node quorum 유지: `minAvailable: 2` → 한 번에 1 pod만 drain 가능
- `IPC_LOCK` capability는 Vault의 mlockall을 허용 (swap으로 secret 유출 방지) — 그 외 capability 전부 drop
- Restricted PSS 전체 충족
+991
View File
@@ -0,0 +1,991 @@
# workload selection 예시
모든 YAML은 `kubectl apply --server-side --dry-run=server` 통과. PodSecurity `restricted` 호환.
각 예시는 namespace 하나에 그대로 붙여 넣을 수 있는 self-contained 단위.
---
## 좋은 예시 1: auth-server Deployment (tier-1 prod, 완전 동반 리소스)
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: auth
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/version: "1.24.3"
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
example.com/slo-tier: tier-1
spec:
replicas: 6
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: api
template:
metadata:
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
example.com/slo-tier: tier-1
spec:
serviceAccountName: auth
automountServiceAccountToken: false
priorityClassName: tier-1-critical
terminationGracePeriodSeconds: 45
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: api
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: api
containers:
- name: auth
image: registry.example.com/auth@sha256:f1a2b3c4d5e6f7081920aabbccddeeff00112233445566778899aabbccddeeff
imagePullPolicy: IfNotPresent
ports:
- { name: http, containerPort: 8080, protocol: TCP }
- { name: management, containerPort: 8081, protocol: TCP }
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { cpu: "2", memory: 2Gi }
startupProbe:
httpGet: { path: /actuator/health/liveness, port: management }
periodSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: management }
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: management }
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ "ALL" ]
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- name: tmp
emptyDir: { sizeLimit: 64Mi }
---
apiVersion: v1
kind: Service
metadata:
name: auth
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: api
ports:
- { name: http, port: 8080, targetPort: http }
- { name: management, port: 8081, targetPort: management }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: auth
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/slo-tier: tier-1
spec:
minAvailable: 50%
unhealthyPodEvictionPolicy: AlwaysAllow
selector:
matchLabels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: api
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: auth
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: auth
app.kubernetes.io/instance: auth-prod
app.kubernetes.io/component: api
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: auth
minReplicas: 6
maxReplicas: 30
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
- type: Resource
resource:
name: memory
target: { type: Utilization, averageUtilization: 80 }
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- { type: Percent, value: 25, periodSeconds: 60 }
scaleUp:
stabilizationWindowSeconds: 0
policies:
- { type: Percent, value: 100, periodSeconds: 30 }
- { type: Pods, value: 4, periodSeconds: 30 }
selectPolicy: Max
```
**왜 좋은가:**
- stateless 장기 실행 → Deployment 정답
- PDB + HPA + topologySpread (zone+hostname) 모두 tier-1에 맞게 동반
- digest pinning, restricted PodSecurity 호환, preStop sleep으로 graceful drain
- selector에는 불변 3종만 (version/environment 없음)
---
## 좋은 예시 2: PostgreSQL StatefulSet (operator 없는 fallback 케이스, 완전 schema)
```yaml
apiVersion: v1
kind: Service
metadata:
name: postgres-identity-headless
namespace: prod-data-postgres
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-identity
app.kubernetes.io/component: database
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
spec:
clusterIP: None
selector:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-identity
app.kubernetes.io/component: database
ports:
- { name: postgres, port: 5432, targetPort: postgres }
---
apiVersion: v1
kind: Service
metadata:
name: postgres-identity
namespace: prod-data-postgres
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-identity
app.kubernetes.io/component: database
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-identity
app.kubernetes.io/component: database
ports:
- { name: postgres, port: 5432, targetPort: postgres }
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres-identity
namespace: prod-data-postgres
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-identity
app.kubernetes.io/version: "16.3"
app.kubernetes.io/component: database
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
example.com/slo-tier: tier-1
example.com/data-classification: confidential
spec:
serviceName: postgres-identity-headless
replicas: 3
podManagementPolicy: OrderedReady
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 0
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
revisionHistoryLimit: 5
selector:
matchLabels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-identity
app.kubernetes.io/component: database
template:
metadata:
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-identity
app.kubernetes.io/component: database
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
spec:
serviceAccountName: postgres-identity
automountServiceAccountToken: false
priorityClassName: tier-1-critical
terminationGracePeriodSeconds: 120
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-identity
app.kubernetes.io/component: database
containers:
- name: postgres
image: registry.example.com/postgres@sha256:aabbccddeeff00112233445566778899aabbccddeeff0011223344556677abcd
imagePullPolicy: IfNotPresent
ports:
- { name: postgres, containerPort: 5432, protocol: TCP }
env:
- name: POSTGRES_DB
value: identity
- name: POSTGRES_USER
valueFrom:
secretKeyRef: { name: postgres-identity-creds, key: username }
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef: { name: postgres-identity-creds, key: password }
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
resources:
requests: { cpu: "2", memory: 4Gi }
limits: { cpu: "4", memory: 8Gi }
startupProbe:
exec:
command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"]
periodSeconds: 5
failureThreshold: 60
livenessProbe:
exec:
command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"]
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
exec:
command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"]
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
securityContext:
runAsNonRoot: true
runAsUser: 999
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ "ALL" ]
volumeMounts:
- { name: data, mountPath: /var/lib/postgresql/data }
- { name: tmp, mountPath: /tmp }
- { name: run, mountPath: /var/run/postgresql }
volumes:
- name: tmp
emptyDir: {}
- name: run
emptyDir: {}
volumeClaimTemplates:
- metadata:
name: data
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/instance: postgres-identity
app.kubernetes.io/component: database
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: longhorn-replicated
resources:
requests:
storage: 200Gi
```
**왜 좋은가:**
- headless Service + clusterIP Service 양쪽 선언 (peer discovery + 일반 client)
- `persistentVolumeClaimRetentionPolicy: {whenDeleted: Retain, whenScaled: Retain}` 명시 (GA 1.27)
- `podManagementPolicy: OrderedReady` + `updateStrategy.partition: 0` (canary 시 1씩)
- zone topologySpread `DoNotSchedule`로 강제 (DB는 AZ 분산이 강건성 핵심)
- Secret 외부 참조 (External Secrets로 관리 가정)
- StorageClass `longhorn-replicated` (local-path 금지)
**실전 주의**: 1000-서비스 스케일에서는 raw StatefulSet 대신 **CloudNativePG Operator** 사용을 강력 권장. 이 예시는 operator 불가 케이스의 reference.
---
## 좋은 예시 3: fluent-bit DaemonSet (로그 shipper)
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: fluent-bit
namespace: prod-platform-observability
labels:
app.kubernetes.io/name: fluent-bit
app.kubernetes.io/instance: fluent-bit-prod
app.kubernetes.io/component: log-shipper
app.kubernetes.io/part-of: observability-platform
app.kubernetes.io/managed-by: argocd
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: fluent-bit
namespace: prod-platform-observability
labels:
app.kubernetes.io/name: fluent-bit
app.kubernetes.io/instance: fluent-bit-prod
app.kubernetes.io/version: "3.1.7"
app.kubernetes.io/component: log-shipper
app.kubernetes.io/part-of: observability-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
spec:
selector:
matchLabels:
app.kubernetes.io/name: fluent-bit
app.kubernetes.io/instance: fluent-bit-prod
app.kubernetes.io/component: log-shipper
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 10%
revisionHistoryLimit: 5
template:
metadata:
labels:
app.kubernetes.io/name: fluent-bit
app.kubernetes.io/instance: fluent-bit-prod
app.kubernetes.io/component: log-shipper
app.kubernetes.io/part-of: observability-platform
app.kubernetes.io/managed-by: argocd
spec:
serviceAccountName: fluent-bit
automountServiceAccountToken: true
priorityClassName: system-node-critical
hostNetwork: false
terminationGracePeriodSeconds: 30
tolerations:
- operator: Exists
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: fluent-bit
image: registry.example.com/fluent-bit@sha256:112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00
imagePullPolicy: IfNotPresent
ports:
- { name: metrics, containerPort: 2020, protocol: TCP }
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 256Mi }
livenessProbe:
httpGet: { path: /, port: metrics }
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /api/v1/health, port: metrics }
periodSeconds: 5
failureThreshold: 3
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ "ALL" ]
add: [ "DAC_READ_SEARCH" ]
volumeMounts:
- { name: varlog, mountPath: /var/log, readOnly: true }
- { name: varlibdockercontainers, mountPath: /var/lib/docker/containers, readOnly: true }
- { name: config, mountPath: /fluent-bit/etc }
volumes:
- name: varlog
hostPath: { path: /var/log, type: Directory }
- name: varlibdockercontainers
hostPath: { path: /var/lib/docker/containers, type: DirectoryOrCreate }
- name: config
configMap: { name: fluent-bit-config }
```
**왜 좋은가:**
- DaemonSet으로 모든 노드에 정확히 1 Pod
- `tolerations: Exists`로 control-plane taint 포함 모든 노드 커버
- `priorityClassName: system-node-critical`로 eviction 방지
- root 필요(hostPath 로그 읽기) 하지만 capabilities는 `DAC_READ_SEARCH`만 추가하고 나머지 drop
- `updateStrategy.rollingUpdate.maxUnavailable: 10%`로 대규모 클러스터 rolling 안정화
### ❌ Bad counterpart (같은 역할을 Deployment로)
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: fluent-bit
spec:
replicas: 10
```
**문제:** Deployment는 특정 노드에 Pod가 없을 수 있고, 같은 노드에 여러 Pod가 떠서 로그 중복 수집. DaemonSet만 "노드당 정확히 1"을 보장.
---
## 좋은 예시 4: flyway-migrate Job (ArgoCD PostSync hook)
```yaml
apiVersion: batch/v1
kind: Job
metadata:
name: flyway-migrate-identity-1-24-3
namespace: prod-identity-auth
labels:
app.kubernetes.io/name: flyway
app.kubernetes.io/instance: flyway-identity-1-24-3
app.kubernetes.io/version: "1.24.3"
app.kubernetes.io/component: schema-migration
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
annotations:
argocd.argoproj.io/hook: PostSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
backoffLimit: 2
activeDeadlineSeconds: 600
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app.kubernetes.io/name: flyway
app.kubernetes.io/instance: flyway-identity-1-24-3
app.kubernetes.io/component: schema-migration
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
spec:
restartPolicy: Never
serviceAccountName: flyway-identity
automountServiceAccountToken: false
priorityClassName: tier-1-critical
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: flyway
image: registry.example.com/flyway@sha256:ccddeeff0011223344556677889900aabbccddeeff0011223344556677889900
imagePullPolicy: IfNotPresent
args: [ "migrate" ]
env:
- { name: FLYWAY_URL, valueFrom: { secretKeyRef: { name: flyway-identity-creds, key: url } } }
- { name: FLYWAY_USER, valueFrom: { secretKeyRef: { name: flyway-identity-creds, key: username } } }
- { name: FLYWAY_PASSWORD, valueFrom: { secretKeyRef: { name: flyway-identity-creds, key: password } } }
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { cpu: "1", memory: 512Mi }
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ "ALL" ]
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- name: tmp
emptyDir: { sizeLimit: 32Mi }
```
**왜 좋은가:**
- `restartPolicy: Never` + `backoffLimit: 2` → migration 실패는 debug 가능하게 노출
- `activeDeadlineSeconds: 600` → 무한 lock 방지
- `ttlSecondsAfterFinished: 86400` → 24시간 후 자동 정리 (1000-서비스 스케일 필수)
- ArgoCD `PostSync` hook으로 Deployment rollout 이후 실행
- 이름에 버전 suffix (`-1-24-3`) → 같은 이름 Job 재생성 충돌 방지
---
## 좋은 예시 5: postgres-backup CronJob (timezone + concurrencyPolicy)
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: postgres-identity-backup
namespace: prod-data-postgres
labels:
app.kubernetes.io/name: postgres-backup
app.kubernetes.io/instance: postgres-identity-backup
app.kubernetes.io/component: backup
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
spec:
schedule: "0 */6 * * *"
timeZone: "Asia/Seoul"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 3600
template:
metadata:
labels:
app.kubernetes.io/name: postgres-backup
app.kubernetes.io/instance: postgres-identity-backup
app.kubernetes.io/component: backup
app.kubernetes.io/part-of: identity-platform
app.kubernetes.io/managed-by: argocd
spec:
restartPolicy: Never
serviceAccountName: postgres-identity-backup
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: pgbackup
image: registry.example.com/pgbackup@sha256:ddeeff00112233445566778899aabbccddeeff00112233445566778899aabbcc
imagePullPolicy: IfNotPresent
env:
- { name: PGHOST, value: postgres-identity.prod-data-postgres.svc.cluster.local }
- { name: PGUSER, valueFrom: { secretKeyRef: { name: pgbackup-creds, key: username } } }
- { name: PGPASSWORD, valueFrom: { secretKeyRef: { name: pgbackup-creds, key: password } } }
- { name: S3_BUCKET, value: pg-backups-prod }
- { name: S3_PREFIX, value: identity }
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { cpu: "1", memory: 512Mi }
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ "ALL" ]
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- name: tmp
emptyDir: { sizeLimit: 2Gi }
```
**왜 좋은가:**
- `timeZone: Asia/Seoul` (v1.25+) → DST 안정
- `concurrencyPolicy: Forbid` → 이전 백업이 돌고 있으면 skip
- `startingDeadlineSeconds: 600` → 노드 장애 후 미싱 누적 제한
- history limit으로 완료 Job 정리 (`successfulJobsHistoryLimit: 3`, `failedJobsHistoryLimit: 5`)
- CronJob의 jobTemplate에는 `ttlSecondsAfterFinished`를 설정하지 않음 — history limit과 중복/충돌 방지 (standalone Job에서만 사용)
**실전 주의**: CloudNativePG `ScheduledBackup` CRD를 쓰면 이 CronJob을 operator가 대체한다.
---
## 좋은 예시 6: Ingress controller Deployment + MetalLB (prod 기본)
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ingress-nginx-public
namespace: prod-platform-ingress-nginx
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx-public-prod
app.kubernetes.io/version: "1.11.2"
app.kubernetes.io/component: controller
app.kubernetes.io/part-of: platform
app.kubernetes.io/managed-by: argocd
example.com/environment: prod
example.com/exposure: public
example.com/slo-tier: tier-1
spec:
replicas: 3
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx-public-prod
app.kubernetes.io/component: controller
template:
metadata:
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx-public-prod
app.kubernetes.io/component: controller
app.kubernetes.io/part-of: platform
app.kubernetes.io/managed-by: argocd
spec:
serviceAccountName: ingress-nginx
automountServiceAccountToken: true
priorityClassName: system-cluster-critical
terminationGracePeriodSeconds: 300
securityContext:
runAsNonRoot: true
runAsUser: 101
fsGroup: 101
seccompProfile: { type: RuntimeDefault }
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx-public-prod
app.kubernetes.io/component: controller
containers:
- name: controller
image: registry.example.com/ingress-nginx@sha256:eeff00112233445566778899aabbccddeeff00112233445566778899aabbccdd
args:
- /nginx-ingress-controller
- --publish-service=$(POD_NAMESPACE)/ingress-nginx-public
- --election-id=ingress-nginx-public-leader
- --controller-class=k8s.io/ingress-nginx-public
- --ingress-class=nginx-public
- --configmap=$(POD_NAMESPACE)/ingress-nginx-public
env:
- { name: POD_NAMESPACE, valueFrom: { fieldRef: { fieldPath: metadata.namespace } } }
- { name: POD_NAME, valueFrom: { fieldRef: { fieldPath: metadata.name } } }
ports:
- { name: http, containerPort: 80, protocol: TCP }
- { name: https, containerPort: 443, protocol: TCP }
- { name: metrics, containerPort: 10254, protocol: TCP }
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { cpu: "2", memory: 1Gi }
livenessProbe:
httpGet: { path: /healthz, port: 10254, scheme: HTTP }
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet: { path: /healthz, port: 10254, scheme: HTTP }
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["/wait-shutdown"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 101
capabilities:
drop: [ "ALL" ]
add: [ "NET_BIND_SERVICE" ]
volumeMounts:
- { name: tmp, mountPath: /tmp }
- { name: nginx-etc, mountPath: /etc/nginx }
- { name: nginx-cache, mountPath: /var/cache/nginx }
volumes:
- name: tmp
emptyDir: {}
- name: nginx-etc
emptyDir: {}
- name: nginx-cache
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: ingress-nginx-public
namespace: prod-platform-ingress-nginx
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx-public-prod
app.kubernetes.io/component: controller
app.kubernetes.io/part-of: platform
app.kubernetes.io/managed-by: argocd
annotations:
metallb.universe.tf/address-pool: prod-public-pool
metallb.universe.tf/allow-shared-ip: "ingress-nginx-public"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx-public-prod
app.kubernetes.io/component: controller
ports:
- { name: http, port: 80, targetPort: http, protocol: TCP }
- { name: https, port: 443, targetPort: https, protocol: TCP }
```
**왜 좋은가:**
- Deployment + LoadBalancer (MetalLB L2/BGP) → HPA 가능, 노드 수 ≠ replica 수
- `externalTrafficPolicy: Local` → source IP 보존 + 노드 horn-in 회피
- `priorityClassName: system-cluster-critical`
- `NET_BIND_SERVICE` capability만 추가 (80/443 바인딩), 나머지 drop
- public / internal IngressClass 분리 가능 (별도 Deployment)
**DaemonSet 선택이 맞는 케이스**: bare-metal + 외부 LB 없음 + 모든 edge 노드가 고정 IP로 80/443 직접 노출. 이 경우 `hostNetwork: true` + DaemonSet + `tolerations`로 edge 노드만 label selector.
---
## 나쁜 예시 1: auth-server를 StatefulSet으로
```yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: auth-server
spec:
serviceName: auth-server
replicas: 3
selector:
matchLabels: { app: auth-server }
template:
metadata: { labels: { app: auth-server } }
spec:
containers:
- name: auth
image: registry.example.com/auth:1.24.3
```
**문제:** stable identity/storage 요구가 없는 stateless 앱에 StatefulSet. rolling update가 OrderedReady로 느려지고, replica 증설 시 `auth-server-2`, `auth-server-3` 이름이 의미 없이 고정된다. 해결: Deployment + HPA.
---
## 나쁜 예시 2: postgres를 Deployment로
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
template:
spec:
containers:
- name: postgres
image: postgres:16
volumeMounts:
- { name: data, mountPath: /var/lib/postgresql/data }
volumes:
- name: data
persistentVolumeClaim: { claimName: postgres-pvc }
```
**문제:** replica=1 Deployment + 단일 PVC는 rolling update 시 잠깐 새 Pod가 뜨면서 같은 PVC에 두 Pod가 붙으려다 RWO 충돌. StatefulSet이면 `OrderedReady`로 이전 Pod 완전히 내려간 다음 새 Pod가 뜬다. 해결: StatefulSet + volumeClaimTemplates + podManagementPolicy: OrderedReady.
---
## 나쁜 예시 3: fluent-bit을 Deployment로 배포
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: fluent-bit
spec:
replicas: 10
template:
spec:
containers:
- name: fluent-bit
image: fluent/fluent-bit:latest
volumeMounts:
- { name: varlog, mountPath: /var/log }
volumes:
- name: varlog
hostPath: { path: /var/log }
```
**문제:** Deployment는 Pod 배치를 스케줄러에 맡김 → 어떤 노드에는 0 Pod(로그 유실), 어떤 노드에는 2 Pod(중복 수집). 노드 수가 바뀌면 수동으로 replicas 조정. 해결: DaemonSet.
---
## 나쁜 예시 4: flyway를 앱 startup에 포함
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth
spec:
template:
spec:
initContainers:
- name: flyway-migrate
image: registry.example.com/flyway:1.24.3
args: ["migrate"]
containers:
- name: auth
image: registry.example.com/auth:1.24.3
```
**문제:** Deployment scale-up 때마다 모든 새 Pod가 migration 시도 → DB lock 경합. migration 실패가 앱 부팅 실패로 섞여 debug 불가. rollback 시 downgrade migration 제어 불가. 해결: 독립 Job + ArgoCD PostSync hook.
---
## 나쁜 예시 5: CronJob에 `concurrencyPolicy`와 `startingDeadlineSeconds` 누락
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: backup
spec:
schedule: "*/5 * * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: registry.example.com/backup:1.0.0
```
**문제:**
1. `concurrencyPolicy` 미지정 → 기본 `Allow` → 이전 backup이 오래 걸리면 중복 실행, PVC lock 충돌
2. `startingDeadlineSeconds` 미지정 → 노드 장애 후 수십 개 missed Job이 한꺼번에 생성
3. history limit 미지정 → 완료 Job이 무한 누적
해결: 모든 prod CronJob에 `concurrencyPolicy: Forbid` + `startingDeadlineSeconds: <short>` + history limit.
---
## 나쁜 예시 6: StatefulSet에 `persistentVolumeClaimRetentionPolicy` 미지정 (prod)
```yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: vault
spec:
serviceName: vault
replicas: 3
# persistentVolumeClaimRetentionPolicy missing
volumeClaimTemplates:
- metadata: { name: data }
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: longhorn-replicated
resources:
requests: { storage: 20Gi }
```
**문제:** 명시가 없으면 기본값 (`{whenDeleted: Retain, whenScaled: Retain}`)이 적용되어 "동작은 맞지만" 의도가 코드에 드러나지 않는다. 팀원이 `{Delete, Delete}`인지 추측. 1000-서비스 스케일에서는 모든 StatefulSet이 이 필드를 **명시**해야 정책이 audit 가능. 해결: 항상 명시.