init: 폴더구조 설계 및 인프라 설계
This commit is contained in:
@@ -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` 사용.
|
||||
@@ -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과 병행
|
||||
@@ -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 경로로만 비밀을 배포해야 한다.
|
||||
@@ -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로 대체
|
||||
@@ -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 발생
|
||||
- 하나의 배포 도구에 맞춰 한쪽만 사용할 것
|
||||
@@ -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`.
|
||||
@@ -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 경로 없음
|
||||
@@ -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.
|
||||
@@ -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 배포 없음
|
||||
@@ -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 정책 통일.
|
||||
@@ -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 명시 필수.
|
||||
@@ -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)"
|
||||
}
|
||||
```
|
||||
@@ -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를 예방.
|
||||
@@ -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로 바인딩
|
||||
- 환경 간 재현 불가
|
||||
@@ -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 전체 충족
|
||||
@@ -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 가능. 해결: 항상 명시.
|
||||
Reference in New Issue
Block a user