init: 폴더구조 설계 및 인프라 설계
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# ADR-0001: Keycloak admin host 는 공개 Ingress 로 노출하지 않는다
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-04-24
|
||||
- Scope: dev / staging / prod 전 환경
|
||||
|
||||
## 컨텍스트
|
||||
|
||||
Keycloak CR 의 `spec.hostname.admin` 는 `https://keycloak-admin.dev.example.com` 로 선언되어 있다. Keycloak 26 Hostname v2 는 이 값을 admin console 의 베이스 URL 및 redirect 기준으로 사용한다 (공식 문서: https://www.keycloak.org/server/hostname). 그러나 해당 FQDN 에 대응하는 Kubernetes `Ingress` 리소스는 **의도적으로 생성하지 않는다**.
|
||||
|
||||
## 결정
|
||||
|
||||
1. `keycloak-admin.<env>.example.com` 에 대한 공개 Ingress 는 repo 내에 두지 않는다.
|
||||
2. admin console 접근은 다음 중 하나를 요구한다:
|
||||
- 사내 VPN + `kubectl port-forward -n mnt svc/keycloak 9000:9000` (관리 포트 직접 접근)
|
||||
- bastion 에서 `kubectl exec` 로 `kcadm.sh` 호출
|
||||
- 향후 별도로 도입할 zero-trust proxy (Pomerium / cloudflared tunnel / Teleport) 경로
|
||||
3. `spec.hostname.admin` 선언 자체는 유지한다 — Keycloak 이 admin UI 링크를 올바른 FQDN 으로 발행해야 외부 OIDC/SAML 메타데이터와 충돌이 없기 때문이다. "FQDN 은 있으나 외부 공개 라우터는 없다" 가 정식 상태다.
|
||||
|
||||
## 근거
|
||||
|
||||
- `docs/standards/infra/keycloak.md` §11 "Admin Console 은 별도 host 로 분리" + "사내 IP 화이트리스트 / VPN / OIDC forward-auth" 요구와 정합.
|
||||
- `docs/standards/infra/network-ingress-tls.md` §7 "내부 도구(Argo CD, Grafana, Kibana)는 VPN/zero-trust proxy 로만 노출" 및 §16 "`/admin/*` 는 Ingress path 에 포함하지 않는다" 기준.
|
||||
- Keycloak 공식 권장: admin console 의 인터넷 공개는 공격면 확대. `sslRequired=external` 만으로는 brute-force / credential stuffing / SSRF 경로를 차단하지 못한다.
|
||||
- CWE-284 (Improper Access Control) 예방을 위해 관리 평면을 데이터 평면과 물리적으로 분리한다.
|
||||
|
||||
## 결과
|
||||
|
||||
- admin console 접근은 SRE / platform 팀에만 부여되며, 접근 경로는 Runbook (`docs/standards/infra/operations-runbook-upgrade-rollback.md`) 의 "Keycloak admin 접근" 섹션(TODO)을 따른다.
|
||||
- 인증 실패에 대한 alerting 은 `/admin/*` 공개 환경보다 훨씬 낮은 임계치로 설정 가능 (외부 스캐너 노이즈가 없기 때문).
|
||||
- 향후 공개가 필요해지면 이 ADR 의 status 를 `Superseded by ADR-XXXX` 로 바꾸고 신규 ADR 에서:
|
||||
1. `keycloak-admin.<env>.example.com` 용 Certificate (ECDSA, dev 는 letsencrypt-staging)
|
||||
2. 전용 Ingress + Traefik `CIDRAllowList` middleware (사내 CIDR 만 허용)
|
||||
3. oauth2-proxy forward-auth 또는 상호 TLS 인증 중 하나
|
||||
4. admin-specific NetworkPolicy (egress-from-keycloak 에 대한 회귀 방지)
|
||||
를 함께 도입한다.
|
||||
|
||||
## 대안과 기각 사유
|
||||
|
||||
- **대안 A — public Ingress + IP allowlist**: Traefik `CIDRAllowList` middleware 로 사내 CIDR 만 허용. 기각 사유: 사내 CIDR 이 변하거나 원격 근무자 VPN 미사용 시 실수로 통과시키는 위험. 관리 평면은 데이터 평면과 동일 ingress controller 를 공유하지 않는 것이 수비적으로 낫다.
|
||||
- **대안 B — Keycloak 내장 IP allowlist**: Keycloak 자체 인증 흐름에는 path-level IP 필터 기능이 없다. 기각.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Architecture 상세
|
||||
|
||||
README 의 Architecture / Key Components 를 보충하는 문서. 폴더 구조 전체와 워크로드, 이미지 정책을 모은다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```
|
||||
Project-Infra/
|
||||
├── .gitignore # vault-init-keys.json 제외
|
||||
├── .kube-linter.yaml # kube-linter 규칙 (컨텍스트 오탐 4종 제외)
|
||||
├── README.md # 본 프로젝트 진입 문서
|
||||
├── guide.md # 운영 절차서
|
||||
├── docs/ # README 보조 분할 문서
|
||||
│
|
||||
├── k8s/
|
||||
│ ├── base/
|
||||
│ │ ├── managing/
|
||||
│ │ │ ├── namespace/ # mnt namespace + PSS restricted 라벨
|
||||
│ │ │ └── migration-flyway/ # Flyway Job (공식 이미지)
|
||||
│ │ │
|
||||
│ │ ├── app/ # 애플리케이션 워크로드 (소유권 = 개발팀)
|
||||
│ │ │ ├── identity/auth/
|
||||
│ │ │ │ ├── stateful/identity-postgres/
|
||||
│ │ │ │ └── stateless/auth-server/
|
||||
│ │ │ ├── storage/minio/stateful/minio/
|
||||
│ │ │ └── test/stateless/test-server-{1,2,3}/
|
||||
│ │ │
|
||||
│ │ └── plugins/ # 플랫폼 플러그인 (다른 워크로드가 의존)
|
||||
│ │ ├── vault/ # Vault StatefulSet (공식 이미지)
|
||||
│ │ ├── docker-registry/ # Registry Deployment (공식 이미지)
|
||||
│ │ ├── oauth2-proxy/ # ForwardAuth 용 auth proxy
|
||||
│ │ └── vso/ # VSO CRD 리소스 (VaultConnection / VaultAuth / VaultStaticSecret)
|
||||
│ │
|
||||
│ ├── components/
|
||||
│ │ └── forward-auth/ # oauth2-proxy + Traefik ForwardAuth 재사용 component
|
||||
│ │
|
||||
│ ├── overlays/
|
||||
│ │ ├── dev/
|
||||
│ │ │ ├── kustomization.yaml # dev 전체 집계 (namespace: mnt)
|
||||
│ │ │ ├── networkpolicy-baseline.yaml # default-deny + DNS egress
|
||||
│ │ │ ├── platform/
|
||||
│ │ │ │ ├── traefik/ # HelmChartConfig + Middleware + TLSOption
|
||||
│ │ │ │ ├── cert-manager/ # cert-manager v1.20.2
|
||||
│ │ │ │ ├── cert-manager-issuers/ # letsencrypt-staging/prod ClusterIssuer
|
||||
│ │ │ │ └── keycloak-operator/ # Keycloak Operator 26.6.1
|
||||
│ │ │ ├── tls/ # cert-manager 적용 후 Certificate
|
||||
│ │ │ ├── vault/ # Vault overlay + NetworkPolicy + storage patch
|
||||
│ │ │ ├── registry/ # Registry overlay + NetworkPolicy + storage patch
|
||||
│ │ │ ├── vso/ # VSO CRDs (Helm 설치 후 별도 apply)
|
||||
│ │ │ ├── database/ # identity-postgres + VaultStaticSecret
|
||||
│ │ │ ├── auth/ # auth-server + Ingress(project.com) + flyway
|
||||
│ │ │ ├── keycloak/ # Keycloak CR + public Ingress
|
||||
│ │ │ ├── keycloak-realm/ # KeycloakRealmImport (Git-managed realm/client)
|
||||
│ │ │ ├── storage/ # minio + VaultStaticSecret + certConfig FQDN patch
|
||||
│ │ │ └── test/ # test-server 1/2/3
|
||||
│ │ ├── components/forward-auth/ # dev overlay 에 포함되는 ForwardAuth component
|
||||
│ │ ├── staging/ # 의도적으로 비어둠
|
||||
│ │ └── prod/ # 의도적으로 비어둠
|
||||
│ │
|
||||
│ └── scripts/
|
||||
│ ├── bin/ # 사용자 진입점 (bootstrap.sh / teardown.sh)
|
||||
│ ├── ci/validate.sh # kustomize + kubeconform + kube-linter
|
||||
│ ├── lib/ # 공통 라이브러리 (common.sh / vault.sh)
|
||||
│ └── tasks/ # 재사용 작업 (vault-init / vault-seed-apps / vso-install)
|
||||
│
|
||||
└── terraform/ # contracts 만 존재, 추후 구현
|
||||
```
|
||||
|
||||
## 네임스페이스 전략
|
||||
|
||||
`mnt` 단일 namespace. 학습 단계의 단순성 우선. 실무에서는 역할별 namespace(`auth`, `storage`, `security`, `registry`) 분리가 원칙이며, base 는 환경 중립이라 overlay 재구성으로 분리 가능하다.
|
||||
|
||||
- Pod Security Standards: `pod-security.kubernetes.io/enforce=restricted` (audit + warn 동시).
|
||||
- 모든 리소스는 overlay 의 `namespace: mnt` 로 일괄 주입.
|
||||
|
||||
## 워크로드 목록
|
||||
|
||||
| 워크로드 | 종류 | 위치 | 참조 Secret |
|
||||
|---|---|---|---|
|
||||
| `identity-postgres` | StatefulSet | `base/app/identity/auth/stateful/` | `identity-postgres-superuser`, `keycloak-db`, `auth-server-db` |
|
||||
| [`auth-server`](https://github.com/donghyeon-ka/project-auth-server/tree/develop) | Deployment | `base/app/identity/auth/stateless/` | `auth-server-db` |
|
||||
| `keycloak` | Keycloak CR (Operator 생성 StatefulSet) | `overlays/dev/keycloak/` | `keycloak-db-operator`, `keycloak-bootstrap-admin-operator` |
|
||||
| `minio` | Tenant CRD | `base/app/storage/minio/stateful/` | `minio-tenant-env` |
|
||||
| `test-server-1/2/3` | Deployment | `base/app/test/stateless/` | — |
|
||||
| `migration-flyway` | Job (PreSync / sync-wave=-1) | `base/managing/migration-flyway/` | `auth-server-db` |
|
||||
| `vault` | StatefulSet | `base/plugins/vault/` | — |
|
||||
| `docker-registry` | Deployment | `base/plugins/docker-registry/` | `docker-registry-basic-auth`, `docker-registry-pull-credentials` |
|
||||
|
||||
auth-server / keycloak 모두 `jdbc:postgresql://identity-postgres:5432/<db>` 로 short name 접속 (같은 namespace).
|
||||
|
||||
### Flyway 실행 순서
|
||||
|
||||
dev overlay 가 migration-flyway Job 에 ArgoCD annotation 을 patch:
|
||||
|
||||
```
|
||||
argocd.argoproj.io/sync-wave: "-1"
|
||||
argocd.argoproj.io/hook: PreSync
|
||||
```
|
||||
|
||||
ArgoCD 배포 시 Job 이 앱보다 먼저 돌고 스키마 마이그레이션을 마친 뒤 `auth-server` 가 뜬다.
|
||||
|
||||
### Keycloak hostname patch
|
||||
|
||||
dev overlay JSON patch 가 Keycloak ConfigMap 에 다음을 주입:
|
||||
|
||||
- `KC_HOSTNAME=https://keycloak.dev.example.com`
|
||||
- `KC_HOSTNAME_ADMIN=https://keycloak-admin.dev.example.com`
|
||||
|
||||
staging / prod 는 자체 hostname 을 overlay 에서 주입.
|
||||
|
||||
### MinIO certConfig.dnsNames
|
||||
|
||||
base 는 short name(`minio`, `minio-hl`) 만 둔다. dev overlay 에서 `minio.mnt.svc.cluster.local`, `*.minio-hl.mnt.svc.cluster.local` 을 patch — base 환경 중립성 원칙.
|
||||
|
||||
## 이미지 정책
|
||||
|
||||
| 구분 | 이미지 | 근거 |
|
||||
|---|---|---|
|
||||
| 공식 upstream | `hashicorp/vault:1.17.2` | HashiCorp 공식 |
|
||||
| | `registry:2.8.3` | Docker library 공식 |
|
||||
| | `postgres:16.4` | PostgreSQL 공식 |
|
||||
| | `quay.io/keycloak/keycloak:26.6.1` | Keycloak Operator 26.6.1 관리 |
|
||||
| | `minio/minio:RELEASE.2025-01-20T14-49-07Z` | MinIO 공식 |
|
||||
| | `flyway/flyway:10.20.1` | Flyway 공식 |
|
||||
| 사용자 개발 | `registry.example.com/auth-platform/auth-server:0.1.0` | 조직 개발 서비스 |
|
||||
| | `registry.example.com/test-platform/test-server-{1,2,3}:0.1.0` | 조직 개발 서비스 |
|
||||
|
||||
prod 승격 시 공식 이미지도 digest pin(`@sha256:…`)으로 전환.
|
||||
|
||||
## Docker Registry
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 이미지 | `registry:2.8.3` |
|
||||
| 내부 서비스 | `docker-registry.mnt.svc.cluster.local:5000` |
|
||||
| 외부 Ingress | `registry.project.com` (`/v2` only) |
|
||||
| 인증 | 내부 Service 무인증, 외부 Ingress + kubelet pull 만 credential 사용 |
|
||||
| 저장 | MinIO S3 bucket `docker-registry` |
|
||||
|
||||
### 인증 경계
|
||||
|
||||
Registry 자체 auth 는 켜지 않는다. 인증 경계는 두 곳:
|
||||
|
||||
- 외부 Ingress: Traefik `Middleware/docker-registry-basic-auth` 가 VSO 로 생성된 `docker-registry-basic-auth` Secret 의 htpasswd 를 검증
|
||||
- 내부 pull: 앱 ServiceAccount 에 `docker-registry-pull-credentials` imagePullSecret
|
||||
|
||||
따라서 `docker-registry-ingress-traefik` NetworkPolicy + BasicAuth Secret + imagePullSecret 이 함께 있어야 push/pull 양쪽이 안전하다.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 715 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 643 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 632 KiB |
@@ -0,0 +1,53 @@
|
||||
# ForwardAuth · Cold Path (최초 로그인)
|
||||
|
||||
세션 쿠키가 없는 첫 요청. OIDC Authorization Code Flow + PKCE 전 구간이 1 회 일어난다. **사용자당 세션 만료 주기마다 한 번** 만 발생 — 일상 운영 트래픽의 99% 는 [warm path](forward-auth-warm.md) 다.
|
||||
|
||||
> Traefik 의 path-based Ingress 라우팅이 전제: `project.com/oauth2/*` 는 oauth2-proxy 로, 그 외 path 는 ForwardAuth Middleware 를 거쳐 백엔드로 간다. 이 라우팅 결정이 그림의 모든 분기의 기반이다.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User
|
||||
participant Traefik
|
||||
participant OAuth as oauth2-proxy
|
||||
participant KC as Keycloak
|
||||
|
||||
User->>Traefik: GET project.com/api/me
|
||||
Traefik->>OAuth: ForwardAuth GET /oauth2/auth (no cookie)
|
||||
OAuth-->>Traefik: 401 Unauthorized
|
||||
Traefik-->>User: 302 to /oauth2/start
|
||||
|
||||
User->>Traefik: GET /oauth2/start
|
||||
Note over Traefik: Ingress 가 /oauth2/* 를 oauth2-proxy 로 라우팅
|
||||
Traefik->>OAuth: forward
|
||||
Note over OAuth: PKCE code_verifier 생성, code_challenge 산출
|
||||
OAuth-->>User: 302 to Keycloak authorize with code_challenge
|
||||
|
||||
User->>KC: GET /realms/platform/protocol/openid-connect/auth
|
||||
KC-->>User: 로그인 폼
|
||||
User->>KC: POST 자격증명
|
||||
KC-->>User: 302 to /oauth2/callback with auth code
|
||||
|
||||
User->>Traefik: GET /oauth2/callback with code
|
||||
Traefik->>OAuth: forward
|
||||
OAuth->>KC: POST /token (code, code_verifier)
|
||||
KC-->>OAuth: id_token, access_token, refresh_token
|
||||
|
||||
OAuth->>KC: GET /realms/platform/protocol/openid-connect/certs
|
||||
KC-->>OAuth: JWKS 공개키
|
||||
Note over OAuth: id_token 서명 검증 with JWKS, nonce 일치 확인
|
||||
|
||||
OAuth-->>User: Set-Cookie _oauth2_proxy + 302 to /api/me
|
||||
Note over User: 이후 요청은 warm path
|
||||
```
|
||||
|
||||
## 핵심 인사이트
|
||||
|
||||
- **Ingress 라우팅이 분기의 뿌리**: 그림의 Note 가 가리키듯 `/oauth2/*` 와 그 외 path 가 *Ingress 단에서* 갈린다. 이 라우팅이 없으면 cold path 가 시작 자체를 못 한다.
|
||||
- **PKCE 가 핵심 보안 장치**: `code_verifier` 는 메시지 7~8 에서 oauth2-proxy 가 생성해 자기 세션에 저장하고, 메시지 16 에서 token exchange 시 함께 보낸다. Keycloak 은 `code_challenge` 와 매칭 검증. **authorization code 가 중간에 가로채지더라도 verifier 없이는 token 으로 교환 불가**. oauth2-proxy v7.5+ 는 PKCE 가 기본 활성.
|
||||
- **JWKS 검증의 위치**: 메시지 18~19 (`GET .../certs`) 가 별개의 호출이다. oauth2-proxy 는 JWKS 를 *처음 1 회 fetch 후 캐시* 하고, Keycloak 의 JWKS endpoint 가 회전 가능 (`kid` 헤더로 식별). **id_token 서명 검증 (메시지 20 의 Note) 이 끝나야 쿠키가 발급되므로**, 이후 warm path 에서 백엔드가 받는 `X-Forwarded-User` 는 *이미 검증된 사용자* 다.
|
||||
- **TLS 검증 전제**: 현재 oauth2-proxy 설정은 `ssl_insecure_skip_verify=false` 이다. 따라서 Keycloak 공개 호스트(`keycloak.dev.example.com`) 인증서 체인이 정상이어야 token 교환과 JWKS 검증 흐름이 끝까지 진행된다.
|
||||
|
||||
## 평소 요청 흐름은?
|
||||
|
||||
→ [forward-auth-warm.md](forward-auth-warm.md)
|
||||
@@ -0,0 +1,47 @@
|
||||
# ForwardAuth · Warm Path (세션 쿠키 보유)
|
||||
|
||||
쿠키 검증으로 끝나는 평소 요청 경로. **운영 트래픽의 99% 가 이 흐름** 이다. cold path 의 OIDC handshake 는 세션 만료 시에만 다시 일어난다.
|
||||
|
||||
3 가지 결과가 있다: (1) 쿠키 정상 → 즉시 통과, (2) access_token 만료 → silent refresh 후 통과, (3) 쿠키 위조 또는 refresh 실패 → cold path 재진입.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User
|
||||
participant Traefik
|
||||
participant OAuth as oauth2-proxy
|
||||
participant KC as Keycloak
|
||||
participant App as auth-server
|
||||
|
||||
User->>Traefik: GET project.com/api/me with cookie
|
||||
Traefik->>OAuth: ForwardAuth GET /oauth2/auth
|
||||
|
||||
alt 쿠키 HMAC 유효 + access_token 미만료
|
||||
OAuth-->>Traefik: 202 Accepted with X-Auth-Request-User
|
||||
else access_token 만료, refresh_token 유효
|
||||
Note over OAuth: silent refresh
|
||||
OAuth->>KC: POST /token with refresh_token
|
||||
KC-->>OAuth: 새 access_token
|
||||
OAuth-->>Traefik: 202 Accepted with X-Auth-Request-User
|
||||
else 쿠키 위조 또는 refresh 실패
|
||||
OAuth-->>Traefik: 401 Unauthorized
|
||||
Traefik-->>User: 302 to /oauth2/start
|
||||
Note over User: cold path 재진입
|
||||
end
|
||||
|
||||
Note over Traefik: 클라이언트 X-Forwarded 헤더 strip 후 oauth2-proxy 응답 헤더만 주입
|
||||
|
||||
Traefik->>App: GET /api/me with X-Forwarded-User alice
|
||||
App-->>User: 200 OK
|
||||
```
|
||||
|
||||
## 핵심 인사이트
|
||||
|
||||
- **백엔드가 헤더만 신뢰해도 안전한 이유**: Traefik 의 ForwardAuth Middleware 가 *클라이언트로부터 들어온* `X-Forwarded-*` 헤더를 strip 하고, *oauth2-proxy 응답에 담긴* 헤더만 백엔드로 전달한다. 클라이언트가 위조한 `X-Forwarded-User: admin` 은 도달하지 못한다. **이 strip 동작이 무너지면 권한 우회 취약점**이 되므로 Traefik Middleware 의 `authResponseHeaders` 와 (Traefik global) `forwardedHeaders` 설정이 핵심.
|
||||
- **백엔드 코드 단순화의 실체**: `auth-server` 의 컨트롤러는 `request.getHeader("X-Forwarded-User")` 한 줄만 본다. JWT 라이브러리, JWKS 캐시, 쿠키 파서, 세션 스토어가 모두 사라진다. 단위 테스트도 헤더 1 개 주입으로 인증된 사용자 시나리오가 만들어진다.
|
||||
- **silent refresh 는 사용자에게 보이지 않음**: alt 의 두 번째 분기가 그 경우. 사용자 브라우저는 redirect 를 안 본다 — Traefik ForwardAuth 호출 안에서 refresh 가 끝나고 같은 응답이 202 로 돌아온다.
|
||||
- **위조 시 회귀 경로**: 세 번째 분기. 쿠키 HMAC 가 안 맞거나 refresh 가 실패하면 oauth2-proxy 가 401 을 반환하고, Traefik 이 cold path 의 시작점인 `/oauth2/start` 로 돌려보낸다. 즉 **공격자가 쿠키를 위조해 봤자 결과는 로그인 페이지로의 redirect 일 뿐**이다.
|
||||
|
||||
## 처음 로그인 시 흐름은?
|
||||
|
||||
→ [forward-auth-cold.md](forward-auth-cold.md)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Secret Pipeline · Bootstrap (1 회)
|
||||
|
||||
`tasks/vault-init.sh` 가 클러스터 최초 셋업 시 한 번만 수행하는 흐름. Vault 의 Kubernetes auth method 와 두 개의 role/policy, 그리고 VSO 가 사용할 VaultAuth CR 까지 준비한다. 모든 단계는 멱등 체크 후 차이만 적용된다 — Note 에 명시된 read 호출이 그 체크 지점.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Op as Operator
|
||||
participant Vault
|
||||
participant K8s as K8s API
|
||||
|
||||
Op->>Vault: operator init (5 unseal keys)
|
||||
Op->>Vault: operator unseal (3 keys)
|
||||
Vault-->>Op: Unsealed
|
||||
|
||||
Op->>K8s: apply ClusterRoleBinding (system auth-delegator)
|
||||
Note over K8s: Vault Pod SA 에 TokenReview 권한 위임
|
||||
|
||||
Op->>Vault: auth enable kubernetes + write config
|
||||
Note over Vault: vault auth list 후 미존재 시에만 enable
|
||||
|
||||
Op->>Vault: secrets enable kv-v2 at path secret
|
||||
Note over Vault: vault secrets list 후 미존재 시에만 enable
|
||||
|
||||
Op->>Vault: policy write x2 + role write x2 (auth-platform, storage)
|
||||
Note over Vault: 각 policy/role read 후 차이만 적용
|
||||
|
||||
Op->>Vault: kv put auth-server-db, keycloak-db, minio-tenant-env
|
||||
|
||||
Op->>K8s: apply VaultAuth x2
|
||||
Op->>K8s: apply VaultStaticSecret x7
|
||||
Note over K8s: VaultAuth 가 먼저, VaultStaticSecret 나중 - 그래야 reconcile 성공
|
||||
```
|
||||
|
||||
## 핵심 인사이트
|
||||
|
||||
- **두 role 의 의도**: VSO 의 ServiceAccount 는 `vault-secrets-operator/mnt` 한 개뿐이다. 그러나 Vault 에 role 두 개를 두고 각각 다른 policy 를 묶었다. **VaultStaticSecret 마다 자기 도메인의 VaultAuth CR 을 참조**하므로, auth-platform role 의 토큰이 유출돼도 minio secret 은 못 읽는다.
|
||||
- **`system:auth-delegator` 의 위치**: 이 ClusterRoleBinding 은 *Vault Pod 의 SA* 에 부여된다. Vault 가 VSO 의 SA JWT 를 검증하기 위해 K8s 의 `TokenReview` API 를 호출할 권한이 필요하기 때문. VSO 측이 아니라 Vault 측에 붙는다는 점이 자주 헷갈리는 지점.
|
||||
- **멱등성의 위치**: 각 enable / write 호출 직전에 `vault auth list`, `vault secrets list`, `vault policy read`, `vault read auth/kubernetes/role/<name>` 으로 현재 상태를 체크하고 차이만 적용한다. 따라서 이 다이어그램의 모든 단계는 *재실행 안전*.
|
||||
- **마지막 두 단계의 순서**: VaultAuth 가 먼저, VaultStaticSecret 이 나중. 그래야 VSO 가 첫 reconcile 에서 `vaultAuthRef` 를 정상 해석한다.
|
||||
|
||||
## 정상 운영 시 reconcile 흐름은?
|
||||
|
||||
→ [secret-pipeline-runtime.md](secret-pipeline-runtime.md)
|
||||
@@ -0,0 +1,54 @@
|
||||
# Secret Pipeline · Steady-State Reconcile
|
||||
|
||||
VSO 가 VaultStaticSecret CR 을 reconcile 할 때마다 일어나는 흐름. **이 다이어그램은 한 reconcile 사이클** 만 다룬다 — 부트스트랩(정책/role/CR 적용) 은 [secret-pipeline-bootstrap.md](secret-pipeline-bootstrap.md) 에서 이미 끝난 상태를 전제한다.
|
||||
|
||||
VSO 는 controller-runtime 기반이라 informer 가 *시작 시 1 회 watch 등록* 하고, 이후 K8s API 가 push 하는 이벤트로 reconcile 이 트리거된다. 즉 매 cycle 마다 watch 호출이 새로 일어나는 게 아니다.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant VSO as VSO Operator
|
||||
participant K8s as K8s API
|
||||
participant Vault
|
||||
participant Sec as K8s Secret
|
||||
|
||||
Note over VSO,K8s: informer 가 시작 시 1 회 watch 등록 후 이벤트 수신 대기
|
||||
|
||||
K8s-->>VSO: event for VaultStaticSecret auth-server-db-creds
|
||||
VSO->>K8s: read VaultStaticSecret spec
|
||||
K8s-->>VSO: vaultAuthRef, path
|
||||
|
||||
VSO->>K8s: read VaultAuth vault-auth-auth-platform
|
||||
K8s-->>VSO: role vso-auth-platform, mount kubernetes
|
||||
|
||||
VSO->>Vault: POST auth/kubernetes/login (role, jwt)
|
||||
Vault->>K8s: TokenReview (VSO SA JWT)
|
||||
Note over Vault,K8s: system auth-delegator 권한 사용
|
||||
K8s-->>Vault: ok, sa vault-secrets-operator
|
||||
Vault-->>VSO: Vault token (policy vso-auth-platform, ttl 1h)
|
||||
|
||||
VSO->>Vault: GET secret/data/auth-server/db
|
||||
Vault-->>VSO: username, password, jdbc-url
|
||||
|
||||
Note over VSO: destination overwrite false 면 기존 Secret 유지
|
||||
VSO->>K8s: create or update Secret auth-server-db
|
||||
K8s-->>Sec: stored
|
||||
|
||||
Note over Sec: kubelet 이 Pod 시작 시 envFrom 으로 마운트 (시퀀스 외)
|
||||
|
||||
loop every refreshAfter (1h)
|
||||
VSO->>Vault: GET secret/data/auth-server/db
|
||||
Vault-->>VSO: 최신 값
|
||||
opt 값이 변경된 경우
|
||||
VSO->>K8s: update Secret auth-server-db
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## 핵심 인사이트
|
||||
|
||||
- **이 다이어그램의 시작점은 K8s 가 던지는 event**: VSO 가 매 cycle 마다 watch API 를 새로 호출하는 게 아니다. controller-runtime 의 informer 가 startup 에 watch 를 establish 하고, K8s API 가 변경 사항을 push 하면 reconcile loop 이 깨어난다. 그래서 메시지 1 의 화살표 방향이 K8s → VSO.
|
||||
- **TokenReview 는 VSO 가 부르는 게 아니라 Vault 가 부른다**: 메시지 7 (`Vault to K8s API: TokenReview`) 가 그 호출. Vault 가 *받은* SA JWT 가 진짜 VSO 의 것인지 확인하기 위해 K8s 에 위임 검증한다.
|
||||
- **role 결정은 VaultAuth CR 이 한다**: 메시지 4~5 에서 VSO 는 *VaultStaticSecret 이 가리키는 VaultAuth* 를 읽고, 거기에 박힌 `role: vso-auth-platform` 으로 Vault login 한다. 같은 SA 라도 어느 VaultAuth 를 거쳤느냐에 따라 받는 policy 가 달라진다.
|
||||
- **`overwrite=false` 의 책임 위치**: 이건 K8s API 의 동작이 아니라 *VSO reconciler 가 update 호출 전에 자기 로직으로 결정* 한다. 그래서 Note 가 VSO 위에 붙는다.
|
||||
- **즉시 반영**: Vault 값 변경 직후 반영하려면 `kubectl -n mnt delete secret auth-server-db`. 다음 reconcile 에서 VSO 가 위 흐름을 다시 돌아 새 값으로 재생성한다 — Pod 는 envFrom 으로 받은 값이 바뀌었음을 자동으로 알 수 없으므로 rollout 도 함께.
|
||||
@@ -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 가능. 해결: 항상 명시.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Ingress / Traefik 운영
|
||||
|
||||
dev 환경은 K3s packaged Traefik 을 그대로 유지한다. 단 **`/var/lib/rancher/k3s/server/manifests/traefik.yaml` 는 수정하지 않는다.** 운영 설정은 `k8s/overlays/dev/platform/traefik/` 의 `HelmChartConfig` 로만 오버라이드한다.
|
||||
|
||||
## 현재 구성
|
||||
|
||||
| 위치 | 역할 |
|
||||
|---|---|
|
||||
| `k8s/overlays/dev/platform/traefik/helmchartconfig.yaml` | Traefik replica, 기본 ingressClass, HTTP→HTTPS redirect, metrics, 기본 TLS option 연결 |
|
||||
| `k8s/overlays/dev/platform/traefik/middleware.yaml` | 공용 `security-headers` Middleware + `modern-tls` TLSOption |
|
||||
| `k8s/overlays/dev/auth/ingress.yaml` | `project.com` → `auth-server` |
|
||||
| `k8s/overlays/dev/keycloak/ingress-public.yaml` | `keycloak.dev.example.com` → Keycloak 공개 path (`/realms/`, `/resources/`, `/.well-known/`, `/js/`) |
|
||||
| `k8s/overlays/dev/platform/cert-manager/` | cert-manager `v1.20.2` CRD/controller 설치 overlay |
|
||||
| `k8s/overlays/dev/platform/cert-manager-issuers/` | `letsencrypt-staging` / `letsencrypt-prod` ClusterIssuer |
|
||||
| `k8s/overlays/dev/platform/keycloak-operator/` | Keycloak Operator `26.6.1`. dev 제약상 `mnt` 에 설치해 `mnt` 의 Keycloak CR 을 watch. K8s API egress NetworkPolicy 포함 |
|
||||
| `k8s/overlays/dev/tls/*.yaml` | cert-manager 설치 후 발급할 `Certificate` 리소스 |
|
||||
| `k8s/components/forward-auth/` | oauth2-proxy + Traefik ForwardAuth 재사용 component |
|
||||
| `k8s/overlays/dev/` | 기본 dev overlay. 현재 forward-auth component 를 직접 포함 |
|
||||
| `k8s/overlays/dev/keycloak-realm/` | `KeycloakRealmImport` 로 realm/client 를 Git 관리 |
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- 앱은 `Ingress` 만 선언하고, 공통 보안 정책은 Traefik Middleware / TLSOption 으로 재사용
|
||||
- Keycloak 은 외부 전체 공개가 아니라 **최소 공개 path** 만 연다. `/admin`, `/metrics`, `/health` 는 비공개
|
||||
- TLS 리소스는 cert-manager + ClusterIssuer 적용 후 `tls/` overlay 에서 발급
|
||||
- north-south ingress 는 `kube-system` 의 Traefik Pod 에서만 시작 → app NetworkPolicy 도 그에 맞춰 작성
|
||||
|
||||
## ForwardAuth variant
|
||||
|
||||
`k8s/components/forward-auth/` 는 oauth2-proxy + ForwardAuth Middleware 를 담은 Kustomize component 다. 현재 `k8s/overlays/dev/` 가 이 component 를 직접 포함한다.
|
||||
|
||||
구성:
|
||||
|
||||
- `oauth2-proxy` Deployment / Service / ConfigMap / VaultStaticSecret
|
||||
- `project.com/oauth2/*` 경로용 Ingress
|
||||
- `oauth2-proxy-auth` Traefik Middleware
|
||||
- `auth-server` Ingress patch — `project.com/` 요청은 oauth2-proxy 를 거친 인증된 사용자만 통과
|
||||
|
||||
흐름: `Traefik ForwardAuth → oauth2-proxy → Keycloak`.
|
||||
|
||||
### 적용 전제
|
||||
|
||||
- `k8s/overlays/dev/keycloak-realm/` 또는 동등한 방법으로 `platform` realm + `auth-server-ingress` client 가 준비됨
|
||||
- redirect URI: `https://project.com/oauth2/callback`
|
||||
- Vault path `secret/oauth2-proxy/forward-auth` 에 `client_secret`, `cookie_secret` 저장
|
||||
- `project.com`, `keycloak.dev.example.com` 이 실제 Traefik 진입점으로 해석됨
|
||||
|
||||
### 브라우저 접속 전제
|
||||
|
||||
curl 검증은 `--resolve project.com:443:<ingress-ip>` 와 `-k` 로 DNS/TLS 문제를 우회할 수 있다. 브라우저는 이 옵션이 없으므로 dev 환경에서 직접 접속하려면 운영자가 아래를 별도로 맞춰야 한다.
|
||||
|
||||
```text
|
||||
<ingress-ip> project.com
|
||||
<ingress-ip> keycloak.dev.example.com
|
||||
```
|
||||
|
||||
예: Traefik `LoadBalancer` IP 중 하나가 `10.208.141.123` 이면 로컬 `/etc/hosts` 에 두 host 를 추가한다. dev overlay 는 현재 외부 ACME 발급 대신 `dev-selfsigned` ClusterIssuer 를 사용하므로 브라우저에서는 인증서 경고를 허용하거나 해당 인증서를 로컬 trust store 에 등록해야 한다. 공인 DNS 가 Traefik 진입점으로 향하고 ACME 인증서가 Ready 가 되면 이 임시 조치는 제거한다.
|
||||
|
||||
Chrome 에서 계속 실패하면 먼저 boundary 를 나눈다.
|
||||
|
||||
| Boundary | 확인 |
|
||||
|---|---|
|
||||
| 로컬 DNS | `getent hosts project.com keycloak.dev.example.com` 이 Traefik IP 를 반환해야 한다. |
|
||||
| 브라우저 DNS cache | `/etc/hosts` 수정 후 Chrome 재시작 또는 `chrome://net-internals/#dns` 에서 cache clear. |
|
||||
| TLS trust | `ERR_CERT_*` 가 나오면 dev self-signed 인증서를 허용하거나 trust store 에 등록한다. |
|
||||
| 인증 redirect | `curl -k -D - --resolve project.com:443:<ingress-ip> https://project.com/swagger-ui.html` 가 `302 Location: https://keycloak...` 를 반환해야 한다. |
|
||||
| 로그인 후 app route | 인증 후 `404 PRES-005` 는 ForwardAuth 실패가 아니라 auth-server 에 해당 route 가 없다는 뜻이다. |
|
||||
|
||||
### 브라우저 검증 순서
|
||||
|
||||
dev ForwardAuth 를 브라우저에서 직접 확인할 때는 아래 순서로 진행한다. 중간 단계를 건너뛰면 "Chrome 이 안 된다" 만 보이고 어느 boundary 가 깨졌는지 알기 어렵다.
|
||||
|
||||
#### 1. Traefik 진입 IP 확인
|
||||
|
||||
```bash
|
||||
kubectl -n kube-system get svc traefik \
|
||||
-o jsonpath='{.status.loadBalancer.ingress[*].ip}{"\n"}'
|
||||
```
|
||||
|
||||
예상 예시:
|
||||
|
||||
```text
|
||||
10.208.141.123 10.208.141.14
|
||||
```
|
||||
|
||||
이 문서의 예시는 `10.208.141.123` 을 사용한다. 실제 클러스터에서 나온 IP 중 하나를 선택한다.
|
||||
|
||||
#### 2. curl 로 클러스터 경로 먼저 확인
|
||||
|
||||
브라우저를 열기 전에 curl 로 Traefik / oauth2-proxy / Keycloak boundary 가 살아있는지 확인한다.
|
||||
|
||||
```bash
|
||||
curl -k -sS -L \
|
||||
-D /tmp/project-infra-login.headers \
|
||||
-o /tmp/project-infra-login.body \
|
||||
--resolve project.com:443:10.208.141.123 \
|
||||
--resolve keycloak.dev.example.com:443:10.208.141.123 \
|
||||
https://project.com/swagger-ui.html
|
||||
```
|
||||
|
||||
정상 신호:
|
||||
|
||||
```bash
|
||||
sed -n '1,80p' /tmp/project-infra-login.headers
|
||||
grep -o '<title>[^<]*' /tmp/project-infra-login.body
|
||||
```
|
||||
|
||||
정상이라면 헤더에는 첫 응답 `HTTP/2 302` 와 `location: https://keycloak.dev.example.com/.../auth` 가 보이고, body title 은 아래처럼 나온다.
|
||||
|
||||
```text
|
||||
<title>Sign in to platform
|
||||
```
|
||||
|
||||
이 단계가 실패하면 브라우저를 볼 필요가 없다. 먼저 `docs/troubleshooting.md` 의 `ForwardAuth 로그인 E2E 검증 실패` 사건에서 해당 boundary 를 찾는다.
|
||||
|
||||
#### 3. 빠른 Chrome 임시 프로필로 확인
|
||||
|
||||
로컬 `/etc/hosts` 와 인증서 trust 를 건드리기 전에, Chrome 실행 옵션으로 DNS/TLS 를 임시 우회해 본다.
|
||||
|
||||
```bash
|
||||
google-chrome \
|
||||
--user-data-dir=/tmp/project-infra-chrome \
|
||||
--ignore-certificate-errors \
|
||||
--host-resolver-rules="MAP project.com 10.208.141.123, MAP keycloak.dev.example.com 10.208.141.123" \
|
||||
https://project.com/swagger-ui.html
|
||||
```
|
||||
|
||||
정상 흐름:
|
||||
|
||||
1. `https://project.com/swagger-ui.html` 접속
|
||||
2. Traefik ForwardAuth 가 미인증 요청을 감지
|
||||
3. `302` 로 Keycloak 로그인 화면 이동
|
||||
4. `Sign in to platform` 화면 표시
|
||||
5. 로그인 성공 후 `project.com` 으로 callback
|
||||
|
||||
이 방식으로 성공하면 Kubernetes / Traefik / oauth2-proxy / Keycloak 경로는 정상이다. 평소 Chrome 에서 안 되는 원인은 로컬 DNS cache, `/etc/hosts`, 인증서 trust, 기존 쿠키 중 하나다.
|
||||
|
||||
#### 4. 일반 Chrome 으로 볼 수 있게 hosts 등록
|
||||
|
||||
임시 Chrome 이 성공하면 로컬 OS resolver 를 맞춘다.
|
||||
|
||||
```bash
|
||||
sudo tee -a /etc/hosts >/dev/null <<'EOF'
|
||||
|
||||
# Project-Infra dev ingress
|
||||
10.208.141.123 project.com
|
||||
10.208.141.123 keycloak.dev.example.com
|
||||
EOF
|
||||
```
|
||||
|
||||
확인:
|
||||
|
||||
```bash
|
||||
getent hosts project.com keycloak.dev.example.com
|
||||
```
|
||||
|
||||
두 host 가 선택한 Traefik IP 를 반환해야 한다.
|
||||
|
||||
#### 5. Chrome DNS cache / 기존 세션 정리
|
||||
|
||||
hosts 를 바꾼 뒤에도 Chrome 이 이전 DNS / 쿠키를 들고 있을 수 있다.
|
||||
|
||||
권장 순서:
|
||||
|
||||
1. `chrome://net-internals/#dns` 에서 DNS cache clear
|
||||
2. `chrome://net-internals/#sockets` 에서 socket pools flush
|
||||
3. `project.com`, `keycloak.dev.example.com` 사이트 데이터 삭제
|
||||
4. Chrome 완전 종료 후 재시작
|
||||
|
||||
그래도 헷갈리면 아래처럼 새 임시 프로필을 쓰는 게 가장 빠르다.
|
||||
|
||||
```bash
|
||||
google-chrome --user-data-dir=/tmp/project-infra-normal https://project.com/swagger-ui.html
|
||||
```
|
||||
|
||||
#### 6. 인증서 경고 처리
|
||||
|
||||
dev overlay 는 현재 `dev-selfsigned` ClusterIssuer 로 TLS Secret 을 만든다. 따라서 일반 Chrome 에서는 인증서 경고가 뜰 수 있다.
|
||||
|
||||
검증 목적이면 고급 옵션에서 예외를 허용한다. 장기적으로 반복 검증할 예정이면 `project-com-tls`, `keycloak-dev-example-com-tls` 인증서를 로컬 trust store 에 등록한다.
|
||||
|
||||
이 경고는 dev self-signed 인증서 때문에 생기는 것으로, ForwardAuth 실패와는 다른 boundary 다.
|
||||
|
||||
#### 7. 로그인 후 결과 해석
|
||||
|
||||
로그인 후 `swagger-ui.html` 이 열리면 브라우저 검증은 성공이다.
|
||||
|
||||
로그인 후 `/api/me` 를 열어 `404 PRES-005` 가 나오면 이것도 ForwardAuth 실패가 아니다. 인증은 통과했고 auth-server 애플리케이션에 `/api/me` route 가 없다는 뜻이다.
|
||||
|
||||
판단 기준:
|
||||
|
||||
| 결과 | 의미 |
|
||||
|---|---|
|
||||
| Keycloak 로그인 화면이 뜸 | 미인증 redirect 정상 |
|
||||
| 로그인 후 `project.com` 으로 돌아옴 | callback / token exchange / session cookie 정상 |
|
||||
| `/oauth2/auth` 가 `202` | oauth2-proxy 세션 인증 정상 |
|
||||
| auth-server 가 `404 PRES-005` 반환 | 인증 통과 후 application route 없음 |
|
||||
| auth-server 가 `401` 반환 | Authorization header 또는 JWT validation boundary 문제 |
|
||||
|
||||
### 인증 실패 처리
|
||||
|
||||
Traefik ForwardAuth 는 `/oauth2/auth` 를 호출한다. oauth2-proxy 가 `401` 또는 `403` 을 반환하면 `oauth2-proxy-errors` Middleware 가 `/oauth2/start?rd={url}` 로 넘겨 로그인 흐름을 시작한다.
|
||||
|
||||
중요: Traefik errors middleware 는 기본적으로 원래 status code 를 유지할 수 있다. 그러면 oauth2-proxy 가 `Location` 을 내려도 브라우저는 `401` 응답을 자동 redirect 로 처리하지 않는다. dev 구성은 `statusRewrites` 로 `401`/`403` 을 `302` 로 바꿔 브라우저가 바로 Keycloak 로그인 화면으로 이동하게 한다.
|
||||
|
||||
## cert-manager / ClusterIssuer
|
||||
|
||||
repo 에 `k8s/overlays/dev/platform/cert-manager/` 와 `k8s/overlays/dev/platform/cert-manager-issuers/` 가 추가되어 있다.
|
||||
|
||||
- 설치 overlay: 공식 static install `v1.20.2`
|
||||
- issuer overlay: ACME HTTP-01 용 `letsencrypt-staging` / `letsencrypt-prod`
|
||||
|
||||
source-of-truth 관점에서 cert-manager 도 이 repo 의 선언형 관리 대상. 단, **실제 인증서 발급은 DNS 가 Traefik 외부 진입점을 가리키고 80/443 도달이 가능해야** 완료된다.
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/overlays/dev/platform/cert-manager
|
||||
kubectl apply -k k8s/overlays/dev/platform/cert-manager-issuers
|
||||
kubectl apply -k k8s/overlays/dev/tls
|
||||
```
|
||||
|
||||
운영 보정 필요: `admin@project.com` 은 실제 운영 수신 가능한 메일로 교체.
|
||||
|
||||
## Keycloak realm / client Git 관리
|
||||
|
||||
`k8s/overlays/dev/keycloak-realm/` 는 `KeycloakRealmImport` 로 `platform` realm 과 `auth-server-ingress` client 를 선언한다. `k8s/overlays/dev/keycloak/` 도 수제 `Deployment` 가 아니라 `Keycloak` CR 기반으로 전환되어 있다.
|
||||
|
||||
`KeycloakRealmImport` 는 같은 `mnt` namespace 의 `Keycloak/keycloak` 을 대상으로 동작한다.
|
||||
|
||||
### 적용 순서
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/overlays/dev/platform/keycloak-operator
|
||||
# 기존 수제 Deployment/Service/ConfigMap/ServiceAccount keycloak* 정리
|
||||
kubectl apply -k k8s/overlays/dev
|
||||
kubectl apply -k k8s/overlays/dev/keycloak-realm
|
||||
```
|
||||
|
||||
## 적용 범위
|
||||
|
||||
repo 가 커버하는 것:
|
||||
|
||||
- Traefik 운영 정책의 Git 관리
|
||||
- app ingress host / path / policy 정의
|
||||
- Traefik → app 방향 ingress allow NetworkPolicy
|
||||
- TLS `Certificate` 선언 준비
|
||||
- ForwardAuth variant 와 KeycloakRealmImport 선언
|
||||
|
||||
> 미완 항목(DNS / ACME 발급 / end-to-end 테스트)은 README 의 [Limitations](../README.md#limitations-honest-scope) 섹션을 참고.
|
||||
@@ -0,0 +1,31 @@
|
||||
# NetworkPolicy 매트릭스
|
||||
|
||||
단일 namespace(`mnt`) 내부에서도 서비스 간 트래픽을 **최소권한** 으로 제한한다. baseline 은 모든 Pod 의 ingress/egress 를 차단하고, 컴포넌트별로 필요한 경로만 명시적으로 연다.
|
||||
|
||||
## 정책 목록
|
||||
|
||||
| 정책 파일 | 역할 |
|
||||
|---|---|
|
||||
| `overlays/dev/networkpolicy-baseline.yaml` | `default-deny-all` (전 Pod ingress/egress 기본 차단) + `allow-dns-egress` (kube-system/kube-dns 53) |
|
||||
| `overlays/dev/database/networkpolicy.yaml` | identity-postgres ingress ← keycloak / auth-server / migration-flyway (5432) |
|
||||
| `overlays/dev/auth/networkpolicy.yaml` | auth-server ingress ← `kube-system/traefik`(8080); egress → postgres(5432) + keycloak(8080); flyway egress → postgres(5432) |
|
||||
| `overlays/dev/keycloak/networkpolicy.yaml` | keycloak ingress ← `kube-system/traefik`(8080) + auth-server(8080); egress → postgres(5432); Keycloak Pod 간 peer 통신 (Infinispan/JGroups) |
|
||||
| `overlays/dev/storage/networkpolicy.yaml` | minio ingress ← `part-of=auth-platform`(9000); 자체 peer(9000/9001) |
|
||||
| `overlays/dev/test/networkpolicy.yaml` | test-server 3 대 내부 상호 통신만 허용 |
|
||||
| `overlays/dev/vault/networkpolicy.yaml` | vault ingress ← VSO Operator Pod(8200) |
|
||||
| `overlays/dev/registry/networkpolicy.yaml` | docker-registry ingress ← namespace 내 전 Pod(5000) + `kube-system/traefik`(5000); egress → minio(9000) |
|
||||
|
||||
## 작성 규칙
|
||||
|
||||
- cross-namespace 참조가 필요한 항목(예: `kube-system/traefik`)은 `namespaceSelector` + `podSelector` 를 한 블록에 조합해 **AND 시맨틱** 으로 작성한다. 두 selector 를 별도 블록에 두면 OR 가 되어 정책이 헐거워진다.
|
||||
- north-south ingress 는 `kube-system` 의 Traefik Pod 에서만 시작되므로, app 측 NetworkPolicy 도 실제 클러스터 기준으로 `kube-system` 을 허용해야 한다 (`ingressClassName=traefik` 만으로는 부족).
|
||||
- baseline default-deny 가 켜져 있는 한, 새 워크로드를 올릴 때마다 ingress / egress 를 **명시적으로** 추가해야 한다. 이게 의도된 마찰이다 (실수로 wide-open 으로 시작하지 않도록).
|
||||
|
||||
## Keycloak Operator 추가 고려사항
|
||||
|
||||
Keycloak Operator 가 Keycloak Pod 를 만들고 watch 하기 때문에 default-deny 환경에서는 다음 두 가지를 NetworkPolicy 로 명시한다:
|
||||
|
||||
- Keycloak Operator 의 Kubernetes API egress (CR reconcile)
|
||||
- Keycloak Pod 간 Infinispan/JGroups peer 통신 (cluster mode)
|
||||
|
||||
해당 정책은 `overlays/dev/keycloak/networkpolicy.yaml` 에 함께 들어 있다.
|
||||
@@ -0,0 +1,83 @@
|
||||
# 운영 / 검증
|
||||
|
||||
bootstrap, teardown, validate.sh, 환경별 차등 계획의 **설계 의도** 를 정리한 문서. 단계별 실제 실행 절차는 [guide.md](../guide.md) 에 있다.
|
||||
|
||||
## bootstrap 단계
|
||||
|
||||
`VaultConnection` / `VaultAuth` / `VaultStaticSecret` 은 VSO Helm 설치로 CRD 가 등록된 뒤에만 apply 할 수 있다. 그래서 `overlays/dev/vso/` 는 dev kustomization 집계에 포함되지 않으며, `bin/bootstrap.sh` 마지막 단계에서 별도로 `kubectl apply -k overlays/dev/vso/` 한다.
|
||||
|
||||
| Phase | 작업 | 의존하는 직전 상태 | 멱등 안전? |
|
||||
|:---:|---|---|---|
|
||||
| 0 | MinIO Operator Helm install (`tasks/minio-operator-install.sh`) | helm 가능한 클러스터 | ✅ `helm upgrade --install` |
|
||||
| 1 | `kubectl apply -k base/managing/namespace/` (PSS restricted 라벨 선행) | — | ✅ `kubectl apply` |
|
||||
| 2 | VSO-managed Secret 점검 | namespace 존재 | ⚠️ `RESET_STALE_SECRETS=yes` 옵션 시 파괴적 |
|
||||
| 3 | `kubectl apply -k overlays/dev/` (vault + registry + 앱) | namespace + PSS 라벨 | ✅ `kubectl apply` |
|
||||
| 4 | `vault-0` Pod Running 대기 | Phase 3 의 Vault StatefulSet | ✅ wait 만 |
|
||||
| 5 | `tasks/vault-init.sh` (init / unseal / auth / policy×2 / role×2) | `vault-0` Running | ✅ 상태 체크 후 차이만 적용 |
|
||||
| 6 | `tasks/vso-install.sh` (helm upgrade --install) | Vault auth/role 준비 | ✅ `helm upgrade --install` |
|
||||
| 7 | `kubectl apply -k overlays/dev/vso/` (VaultConnection / VaultAuth / VaultStaticSecret) | Phase 6 의 VSO CRD 등록 | ✅ `kubectl apply` |
|
||||
|
||||
Phase 5 의 1 회성 셋업 흐름은 [secret-pipeline-bootstrap 시퀀스](diagrams/sequence/secret-pipeline-bootstrap.md), Phase 7 이후의 정상 reconcile 은 [secret-pipeline-runtime 시퀀스](diagrams/sequence/secret-pipeline-runtime.md) 참고.
|
||||
|
||||
```bash
|
||||
# dev — 비밀번호를 프롬프트에서 무음 입력 (bash history 에 안 남음)
|
||||
bash k8s/scripts/bin/bootstrap.sh dev
|
||||
|
||||
# teardown — 대화형 y/N
|
||||
bash k8s/scripts/bin/teardown.sh dev
|
||||
```
|
||||
|
||||
## 스크립트 구조
|
||||
|
||||
`k8s/scripts/` 는 `bin / ci / lib / tasks` 4 축:
|
||||
|
||||
| 디렉토리 | 역할 |
|
||||
|---|---|
|
||||
| `bin/` | 사용자 진입점. `bootstrap.sh` / `teardown.sh` |
|
||||
| `ci/` | CI / 로컬 검증. `validate.sh` (kustomize + kubeconform + kube-linter) |
|
||||
| `lib/` | 공통 Bash 라이브러리. `common.sh` (strict mode / trap / log / confirm / retry / mask_secret) + `vault.sh` |
|
||||
| `tasks/` | 재사용 작업. `vault-init.sh` / `vault-seed-apps.sh` / `vso-install.sh` |
|
||||
|
||||
모든 쉘 스크립트는 `set -Eeuo pipefail` + `IFS=$'\n\t'` + `trap_cleanup` 으로 공통 에러 처리. root token / registry BasicAuth 같은 민감 값은 **stdin 파이프** 로만 전달하고 stdout 에 찍지 않는다.
|
||||
|
||||
## 검증 (validate.sh)
|
||||
|
||||
```bash
|
||||
bash k8s/scripts/ci/validate.sh
|
||||
```
|
||||
|
||||
3 단계:
|
||||
|
||||
1. 각 overlay 에 대해 `kustomize build` (환경 중립성 / patch 유효성)
|
||||
2. 렌더 결과에 `kubeconform -strict -ignore-missing-schemas` (Kubernetes OpenAPI + Datree CRD catalog)
|
||||
3. 렌더 결과에 `kube-linter lint --config .kube-linter.yaml` (securityContext / resources / PSS / image tag 등)
|
||||
|
||||
`.kube-linter.yaml` 은 **블록 단위 분석으로 생기는 컨텍스트 오탐 4 종**(`dangling-service`, `non-existent-service-account`, `mismatching-selector`, `no-anti-affinity`) 만 제외한다. 나머지는 모두 활성.
|
||||
|
||||
목표 상태:
|
||||
|
||||
```
|
||||
k8s/overlays/dev build=ok schema=ok lint=ok
|
||||
k8s/overlays/dev/vso build=ok schema=ok lint=ok
|
||||
```
|
||||
|
||||
## 환경별 배포
|
||||
|
||||
현재 `dev` overlay 만 완성. `staging` / `prod` 는 의도적으로 비어 있고 추후 확장 예정. validate.sh 는 `kustomization.yaml` 이 없는 환경을 자동 스킵한다 — 빈 overlay 가 CI 를 빨갛게 만들지 않기 위함.
|
||||
|
||||
### 계획된 환경별 차등
|
||||
|
||||
| 리소스 | dev | staging | prod |
|
||||
|---|---|---|---|
|
||||
| Vault replicas / storage | 1 / 1Gi | 1 / 5Gi | 3 (HA Raft) / 20Gi |
|
||||
| Registry replicas / storage | 1 / 5Gi | 1 / 10Gi | 2 / 50Gi |
|
||||
| PostgreSQL retention policy | Delete | Retain | Retain |
|
||||
| 이미지 tag 정책 | semver tag | semver tag | `@sha256:` digest pin |
|
||||
| TLS | 비활성화 | cert-manager | cert-manager + HSTS |
|
||||
|
||||
prod 승격 시 필수 작업:
|
||||
|
||||
- Vault storage `file` → `raft` + KMS auto-unseal
|
||||
- Postgres backup CronJob (Velero / pgBackRest)
|
||||
- cert-manager ClusterIssuer 로 TLS 전환
|
||||
- 이미지 tag → digest pin
|
||||
@@ -0,0 +1,256 @@
|
||||
# Security Hardening
|
||||
|
||||
운영 절차가 아닌 **정책 / 거버넌스** 영역. guide.md 가 *"오늘 oncall 이 따라할 절차"* 라면 이 문서는 *"이 클러스터를 책임진다면 알아야 할 보안 결정"* 이다.
|
||||
|
||||
## 목차
|
||||
|
||||
1. [etcd encryption at rest](#1-etcd-encryption-at-rest)
|
||||
2. [Vault 운영자 토큰 관리](#2-vault-운영자-토큰-관리)
|
||||
3. [bash history 에 비밀번호 남기지 않기](#3-bash-history-에-비밀번호-남기지-않기)
|
||||
|
||||
---
|
||||
|
||||
## 1. etcd encryption at rest
|
||||
|
||||
VSO 가 만드는 K8s Secret 은 기본적으로 **etcd 에 base64 로만 저장된다 (평문과 동일)**. 운영 전 반드시 암호화를 켠다.
|
||||
|
||||
### K3s 방식 (권장 — 학습/소규모)
|
||||
|
||||
최초 설치 시:
|
||||
|
||||
```bash
|
||||
# /etc/rancher/k3s/config.yaml
|
||||
write-kubeconfig-mode: "0644"
|
||||
secrets-encryption: true
|
||||
|
||||
# 또는 설치 커맨드
|
||||
curl -sfL https://get.k3s.io | sh -s - server --secrets-encryption
|
||||
```
|
||||
|
||||
K3s 가 AES-CBC 키를 자동 생성해서 `/var/lib/rancher/k3s/server/cred/encryption-config.json` 에 저장한다.
|
||||
|
||||
이미 돌고 있는 클러스터에서 켜는 경우:
|
||||
|
||||
```bash
|
||||
sudo vim /etc/rancher/k3s/config.yaml # secrets-encryption: true 추가
|
||||
sudo systemctl restart k3s
|
||||
|
||||
# 기존 Secret 을 즉시 재암호화 (없으면 새 Secret 부터 적용)
|
||||
sudo k3s secrets-encrypt prepare
|
||||
sudo systemctl restart k3s
|
||||
sudo k3s secrets-encrypt rotate
|
||||
sudo systemctl restart k3s
|
||||
sudo k3s secrets-encrypt reencrypt
|
||||
```
|
||||
|
||||
확인:
|
||||
|
||||
```bash
|
||||
sudo k3s secrets-encrypt status
|
||||
# Encryption Status: Enabled
|
||||
# Current Rotation Stage: start
|
||||
# Server Encryption Hashes: All hashes match
|
||||
```
|
||||
|
||||
### 표준 Kubernetes 방식 (kubeadm 등)
|
||||
|
||||
1. `/etc/kubernetes/encryption.yaml` 생성:
|
||||
|
||||
```yaml
|
||||
apiVersion: apiserver.config.k8s.io/v1
|
||||
kind: EncryptionConfiguration
|
||||
resources:
|
||||
- resources: ["secrets"]
|
||||
providers:
|
||||
- aescbc:
|
||||
keys:
|
||||
- name: key-2026-04-21
|
||||
secret: <head -c 32 /dev/urandom | base64>
|
||||
- identity: {}
|
||||
```
|
||||
|
||||
2. kube-apiserver manifest 에 플래그 추가:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
containers:
|
||||
- command:
|
||||
- kube-apiserver
|
||||
- --encryption-provider-config=/etc/kubernetes/encryption.yaml
|
||||
volumeMounts:
|
||||
- name: encryption-config
|
||||
mountPath: /etc/kubernetes/encryption.yaml
|
||||
readOnly: true
|
||||
```
|
||||
|
||||
3. 기존 Secret 재암호화:
|
||||
|
||||
```bash
|
||||
kubectl get secrets --all-namespaces -o json \
|
||||
| kubectl replace -f -
|
||||
```
|
||||
|
||||
### KMS provider (프로덕션 권장)
|
||||
|
||||
`aescbc` 대신 KMS plugin (Vault transit / AWS KMS / GCP KMS) 사용. circular dependency (Vault 도 K8s Secret 에 의존) 회피를 위해 Vault transit 은 **별도 provider Vault** 를 띄워야 한다. 현 프로젝트는 단일 Vault 이므로 KMS 는 추후 작업.
|
||||
|
||||
### 검증
|
||||
|
||||
Secret 이 암호화됐는지 확인:
|
||||
|
||||
```bash
|
||||
# K3s
|
||||
sudo k3s kubectl -n mnt get secret auth-server-db -o yaml \
|
||||
| grep -A1 "data:"
|
||||
|
||||
# etcd 에 직접 접근해서 암호화 확인 (K3s)
|
||||
sudo ETCDCTL_API=3 etcdctl \
|
||||
--endpoints=https://127.0.0.1:2379 \
|
||||
--cacert=/var/lib/rancher/k3s/server/tls/etcd/server-ca.crt \
|
||||
--cert=/var/lib/rancher/k3s/server/tls/etcd/client.crt \
|
||||
--key=/var/lib/rancher/k3s/server/tls/etcd/client.key \
|
||||
get /registry/secrets/mnt/auth-server-db
|
||||
# 출력이 'k8s:enc:aescbc:v1:...' 로 시작하면 암호화됨
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Vault 운영자 토큰 관리
|
||||
|
||||
root token 은 **비상시 (rekey / generate-root / 전체 복구) 전용**으로만 사용한다. 평시 작업은 개인별 계정 + `vault-admin` policy 로 수행한다.
|
||||
|
||||
### 초기 설정
|
||||
|
||||
부트스트랩 완료 후 한 번만:
|
||||
|
||||
```bash
|
||||
REPO_ROOT="$(pwd)" \
|
||||
VAULT_ADMIN_USERNAME='alice' \
|
||||
VAULT_ADMIN_PASSWORD='<초기 비밀번호>' \
|
||||
bash k8s/scripts/tasks/vault-setup-admin.sh
|
||||
```
|
||||
|
||||
이 스크립트가 수행:
|
||||
- `userpass` auth method 활성화 (idempotent)
|
||||
- `vault-admin` policy 작성 (`secret/*`, `auth/*`, `sys/mounts/*`, `sys/audit/*` 등 관리 권한)
|
||||
- `${VAULT_ADMIN_USERNAME}` 계정 생성 (token TTL 기본 8h / max 24h)
|
||||
|
||||
### 운영자 로그인 — root token 사용 중단
|
||||
|
||||
```bash
|
||||
# port-forward 로 vault CLI 접근
|
||||
kubectl -n mnt port-forward svc/vault 8200:8200 &
|
||||
export VAULT_ADDR=http://127.0.0.1:8200
|
||||
|
||||
# 로그인 (로그인 시 발급되는 토큰은 8h 후 자동 만료)
|
||||
vault login -method=userpass username=alice
|
||||
# Password (will be hidden): <입력>
|
||||
# Token is displayed and automatically cached in ~/.vault-token
|
||||
```
|
||||
|
||||
첫 로그인 후 비밀번호 변경:
|
||||
|
||||
```bash
|
||||
vault write auth/userpass/users/alice/password password='<새 비밀번호>'
|
||||
```
|
||||
|
||||
### root token 처리
|
||||
|
||||
`vault-init-keys.json` 에 있는 root token 은:
|
||||
|
||||
1. **즉시 오프라인 금고 (1Password Team / 하드웨어 보안 금고 / 봉인 봉투) 로 이동**
|
||||
2. 원본 파일에서 `root_token` 필드 삭제 (unseal keys 는 재부팅 시 필요하므로 유지)
|
||||
3. root token 이 필요하면:
|
||||
|
||||
```bash
|
||||
# 기존 root token 유효하면 재사용
|
||||
vault login <root-token>
|
||||
|
||||
# 분실/만료됐으면 재발급 (unseal key 쿼럼 필요)
|
||||
vault operator generate-root -init
|
||||
# 응답의 nonce 저장, unseal key 보유자들이 otp 로 제출
|
||||
vault operator generate-root -nonce=<nonce> -otp=<your-otp> <unseal-key-1>
|
||||
vault operator generate-root -nonce=<nonce> -otp=<your-otp> <unseal-key-2>
|
||||
vault operator generate-root -nonce=<nonce> -otp=<your-otp> <unseal-key-3>
|
||||
# 마지막 응답에 Encoded Token 이 나옴 → otp 로 decode
|
||||
vault operator generate-root -decode=<encoded> -otp=<your-otp>
|
||||
# 새 root token 확보 후 기존 것 revoke:
|
||||
vault token revoke <old-root-token>
|
||||
```
|
||||
|
||||
### 운영자 계정 추가 / 제거
|
||||
|
||||
```bash
|
||||
# 추가
|
||||
REPO_ROOT="$(pwd)" \
|
||||
VAULT_ADMIN_USERNAME='bob' \
|
||||
VAULT_ADMIN_PASSWORD='<임시 pw>' \
|
||||
bash k8s/scripts/tasks/vault-setup-admin.sh
|
||||
|
||||
# 제거
|
||||
vault delete auth/userpass/users/bob
|
||||
```
|
||||
|
||||
### 권한 분리 (추후 확장)
|
||||
|
||||
`vault-admin` 은 전권 정책이다. 실무에서는 역할별 분리 권장:
|
||||
|
||||
| 역할 | policy 이름 | 권한 범위 |
|
||||
|---|---|---|
|
||||
| 인프라 admin | `vault-admin` | 현재 정의된 전권 (rekey 제외) |
|
||||
| 앱 팀 (read) | `secret-readonly` | `secret/data/*` read 전용 |
|
||||
| 앱 팀 (write) | `secret-writer` | 팀별 경로 제한 (`secret/data/auth-server/*` 등) |
|
||||
| 감사자 | `audit-reader` | `sys/audit/*` read + Vault audit log 접근 |
|
||||
|
||||
각 policy 를 만들고 userpass user 생성 시 `token_policies=<policy-name>` 로 바인딩한다.
|
||||
|
||||
### 감사 로그 활성화 (추후)
|
||||
|
||||
Vault 자체 감사 로그는 기본 비활성. 운영에서는 반드시 활성화:
|
||||
|
||||
```bash
|
||||
# file 방식
|
||||
vault audit enable file file_path=/vault/logs/audit.log
|
||||
|
||||
# socket 방식 (중앙집중 수집)
|
||||
vault audit enable socket address=loki-syslog.monitoring.svc:514 socket_type=tcp
|
||||
```
|
||||
|
||||
`statefulset.yaml` 의 volumeMounts 에 `/vault/logs` 를 추가해야 파일 방식 사용 가능. 별도 작업.
|
||||
|
||||
---
|
||||
|
||||
## 3. bash history 에 비밀번호 남기지 않기
|
||||
|
||||
`VAR=value command` 형태로 env var 를 명령줄에 직접 적으면 **그대로 `~/.bash_history` 에 저장** 된다. 대응:
|
||||
|
||||
### 권장 — 대화형 입력
|
||||
|
||||
```bash
|
||||
bash k8s/scripts/bin/bootstrap.sh dev
|
||||
# 프롬프트에서 무음 입력 (echo 안 됨)
|
||||
```
|
||||
|
||||
본 프로젝트의 모든 시크릿 seed 스크립트 (`bootstrap.sh`, `tasks/vault-seed-apps.sh`, `tasks/vault-setup-admin.sh`) 는 env var 가 비어 있으면 TTY 에서 자동으로 `read -r -s` 프롬프트로 전환한다.
|
||||
|
||||
### 비대화 (CI) 실행 시
|
||||
|
||||
어쩔 수 없이 env var 를 넣어야 할 때:
|
||||
|
||||
```bash
|
||||
# 이번 명령만 history 에 안 남기기
|
||||
HISTFILE=/dev/null \
|
||||
POSTGRES_SUPERUSER_PASSWORD='...' \
|
||||
KEYCLOAK_DB_PASSWORD='...' \
|
||||
AUTH_SERVER_DB_PASSWORD='...' \
|
||||
KEYCLOAK_ADMIN_PASSWORD='...' \
|
||||
MINIO_ROOT_PASSWORD='...' \
|
||||
bash k8s/scripts/bin/bootstrap.sh dev
|
||||
|
||||
# 또는 세션 전체 history 비활성화
|
||||
set +o history
|
||||
POSTGRES_SUPERUSER_PASSWORD='...' bash ...
|
||||
set -o history
|
||||
```
|
||||
|
||||
> **Tip**: `HISTCONTROL=ignorespace` 가 설정된 쉘이면 **명령 앞에 공백 1 칸** 넣어도 저장되지 않는다. 다만 쉘마다 설정이 다르니 `HISTFILE=/dev/null` 이 가장 확실.
|
||||
@@ -0,0 +1,275 @@
|
||||
# STYLE.md — 인프라 문서 공용 규약 (Single Source of Truth)
|
||||
|
||||
이 문서는 `docs/standards/infra/**` 와 `docs/examples/infra/**` 에 등장하는 모든 라벨, 네이밍, 포트, 이미지, 리소스 관례의 **정규(normative)** 정의다. 다른 모든 문서의 YAML 조각은 예시이며, 여기 규약과 충돌할 경우 **이 문서가 우선한다.** AI 에이전트가 매니페스트를 생성할 때 관례가 문서 간 표류하는 것을 방지하려는 목적이다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 라벨 (Labels)
|
||||
|
||||
### 1.1 Kubernetes well-known labels (`app.kubernetes.io/*`)
|
||||
|
||||
공식 well-known set. 이 namespace 아래에는 아래 6개 외에 임의 키를 **추가하지 않는다.**
|
||||
|
||||
| 키 | 의미 | 예시 |
|
||||
| --- | --- | --- |
|
||||
| `app.kubernetes.io/name` | 애플리케이션 이름 | `auth-server` |
|
||||
| `app.kubernetes.io/instance` | 인스턴스 (환경/리전 포함 가능) | `auth-server-prod`, `auth-server` |
|
||||
| `app.kubernetes.io/version` | semver 또는 release tag | `1.24.0` |
|
||||
| `app.kubernetes.io/component` | 역할 | `api`, `worker`, `migration`, `database` |
|
||||
| `app.kubernetes.io/part-of` | 상위 시스템 | `auth-platform` |
|
||||
| `app.kubernetes.io/managed-by` | 배포 도구 | `kustomize`, `argocd`, `helm` |
|
||||
|
||||
### 1.2 조직 커스텀 라벨 (`example.com/*`)
|
||||
|
||||
`example.com/` namespace 는 문서 전용 플레이스홀더다. 실제 조직은 자사 도메인(e.g., `acme.corp/`)으로 치환한다.
|
||||
|
||||
| 키 | 허용 값 |
|
||||
| --- | --- |
|
||||
| `example.com/environment` | `dev` \| `staging` \| `prod` |
|
||||
| `example.com/owner-team` | 팀 slug (e.g., `auth-platform`, `sre`) |
|
||||
| `example.com/cost-center` | 회계 코스트 센터 ID |
|
||||
| `example.com/data-classification` | `public` \| `internal` \| `confidential` \| `restricted` |
|
||||
| `example.com/tier` | `0` (critical) \| `1` \| `2` \| `3` (best-effort) |
|
||||
|
||||
### 1.3 규칙
|
||||
|
||||
1. 모든 워크로드(Deployment / StatefulSet / DaemonSet / Job / CronJob)에는 위 6개 `app.kubernetes.io/*` 라벨 + `example.com/environment` + `example.com/owner-team` 이 **필수**다.
|
||||
2. `app.kubernetes.io/environment` 라벨은 **사용 금지**. 공식 well-known set 에 없으며, 환경 라벨은 조직 namespace 아래에 둔다.
|
||||
3. Selector (`spec.selector.matchLabels`)에는 **`app.kubernetes.io/name` 과 `app.kubernetes.io/instance` 만** 사용한다. 이유: selector 는 immutable 이고, `version` / `component` 이외 라벨은 릴리즈마다 바뀌기 때문에 selector 에 포함하면 rollout 이 막힌다.
|
||||
4. 라벨 값은 DNS-1123 subdomain 또는 label 규칙을 따른다: 소문자 알파벳, 숫자, `-`, `.`, 최대 63자. 공백/대문자/언더스코어 금지.
|
||||
5. 라벨은 metadata 의 최상위 `labels:` 와 Pod template 의 `spec.template.metadata.labels:` 에 **동일하게** 복제한다(선택자 일치 보장).
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
name: auth-server
|
||||
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: auth-platform
|
||||
app.kubernetes.io/managed-by: kustomize
|
||||
example.com/environment: prod
|
||||
example.com/owner-team: auth-platform
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: auth-server
|
||||
app.kubernetes.io/instance: auth-server-prod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 네이밍 (Naming)
|
||||
|
||||
### 2.1 Namespace
|
||||
|
||||
1. 기본 스키마: `<env>-<domain>-<service>` — 예: `prod-auth-keycloak`, `staging-billing-api`.
|
||||
2. 단일 서비스 네임스페이스에 여러 컴포넌트가 있으면 서비스 이름까지만 사용한다: `prod-auth` 네임스페이스 안에 Keycloak, PostgreSQL, Flyway Job 이 공존.
|
||||
3. `default` 네임스페이스는 **금지**. `kube-*` 는 Kubernetes 예약.
|
||||
4. 클러스터 공통 플랫폼 컴포넌트는 별도 접두어: `platform-vault`, `platform-cert-manager`, `platform-monitoring`.
|
||||
|
||||
### 2.2 리소스 이름
|
||||
|
||||
케밥-케이스, 소문자. Service / ServiceAccount / Secret / ConfigMap 이름은 관련 워크로드 이름을 접두어로 공유한다.
|
||||
|
||||
| 리소스 | 규약 | 예시 |
|
||||
| --- | --- | --- |
|
||||
| Deployment / StatefulSet | `<app>` | `auth-server` |
|
||||
| Service (ClusterIP) | `<app>` (Deployment와 동일) | `auth-server` |
|
||||
| Headless Service (StatefulSet peer 통신용) | `<app>-headless` (옆에 일반 ClusterIP `<app>` 병행) | `keycloak-headless`, `keycloak` |
|
||||
| ServiceAccount | `<app>-sa` | `auth-server-sa` |
|
||||
| Secret (앱 소유) | `<app>-<purpose>` | `auth-server-db`, `auth-server-oidc` |
|
||||
| ConfigMap | `<app>-<purpose>` | `auth-server-config`, `auth-server-runtime` |
|
||||
| PDB | `<app>-pdb` | `auth-server-pdb` |
|
||||
| HPA | `<app>-hpa` | `auth-server-hpa` |
|
||||
| NetworkPolicy | `<app>-<direction>-<peer>` | `auth-server-egress-db`, `auth-server-ingress-traefik` |
|
||||
| Job (일회성) | `<app>-<action>-<timestamp-or-version>` | `auth-server-migrate-1-24-0` |
|
||||
| CronJob | `<app>-<action>` | `auth-server-session-cleanup` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 포트 (Ports)
|
||||
|
||||
### 3.1 이름 규약
|
||||
|
||||
모든 containerPort / servicePort 에는 `name` 필드가 **필수**다. 아래 이름은 예약어로 취급한다.
|
||||
|
||||
| name | 용도 | 관행 포트 |
|
||||
| --- | --- | --- |
|
||||
| `http` | HTTP 앱 트래픽 | 8080 |
|
||||
| `https` | HTTPS 직접 종료 | 8443 |
|
||||
| `grpc` | gRPC | 9090 또는 앱별 지정 |
|
||||
| `metrics` | Prometheus scrape | 9090 (kube-prometheus 관행). 컴포넌트가 이미 9090 을 쓰면 9100 |
|
||||
| `health` | 별도 헬스/관리 포트 | Keycloak Quarkus 관리 포트 9000 등 |
|
||||
| `admin` | 관리 UI | 컴포넌트별 |
|
||||
| `cluster` | 내부 peer / 레플리케이션 | Vault 8201, Postgres 5432, etcd 2380 |
|
||||
|
||||
### 3.2 규칙
|
||||
|
||||
1. `targetPort` 는 number 대신 **이름 참조**를 권장: `targetPort: http`. 이유: 컨테이너가 바인드 포트를 바꿔도 Service 쪽 조정이 필요 없다.
|
||||
2. `metrics` 포트는 **외부 노출 금지**. ClusterIP 만 쓰며 NetworkPolicy 로 Prometheus 네임스페이스에서만 ingress 허용.
|
||||
3. `health`, `admin` 포트는 Ingress 에 붙이지 않는다. NetworkPolicy 로 접근 대역을 제한한다.
|
||||
|
||||
Container ports 스탠자 (Deployment/Pod spec 내부):
|
||||
|
||||
```
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
- name: metrics
|
||||
containerPort: 9090
|
||||
protocol: TCP
|
||||
- name: health
|
||||
containerPort: 9000
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
Service 정의 (targetPort 는 이름 참조):
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: auth-server
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: auth-server
|
||||
app.kubernetes.io/instance: auth-server-prod
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 이미지 (Images)
|
||||
|
||||
1. **prod 환경**: `<registry>/<path>@sha256:<digest>` 형태 digest pin **필수**. 뮤터블 태그(`:1`, `:latest`, `:main`) 금지.
|
||||
2. **staging**: digest 권장, 최소 semver tag(`:1.24.0`) 허용. 절대 `:latest` 금지.
|
||||
3. **dev**: semver tag 허용, `:latest` 지양 (로컬 / 노드 cache invalidation 이슈).
|
||||
4. `imagePullPolicy`:
|
||||
- digest 사용 시 `IfNotPresent` (이미지 콘텐츠는 immutable)
|
||||
- 뮤터블 태그 사용 시 `Always`
|
||||
5. 레지스트리: 조직 내부 미러가 우선한다. 예: `registry.example.com/<ns>/<app>`. Docker Hub 직접 pull 금지 (rate limit + 공급망 리스크).
|
||||
6. SHA256 digest 로 pin 한 이미지는 CI 파이프라인에서 cosign 서명 검증(선택)과 `imagePullSecrets` digest 검증에 연결한다.
|
||||
|
||||
```yaml
|
||||
containers:
|
||||
- name: app
|
||||
image: registry.example.com/auth-platform/auth-server@sha256:9f0b2c4d8e7a1b3c5d7e9f1a3b5c7d9e1f3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c
|
||||
imagePullPolicy: IfNotPresent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 리소스 (Resources)
|
||||
|
||||
### 5.1 필수 필드
|
||||
|
||||
1. 모든 컨테이너는 `resources.requests.cpu`, `resources.requests.memory`, `resources.limits.memory` 를 **반드시** 설정한다.
|
||||
2. `resources.limits.cpu` 는 **선택**이다. 레이턴시 민감 워크로드에만 설정한다. 이유: CFS throttling 으로 인한 p99 tail-latency 악화를 회피하려는 Tim Hockin / Google SRE 가이던스.
|
||||
|
||||
### 5.2 QoS 클래스
|
||||
|
||||
1. `Guaranteed` — latency-critical (Keycloak, Vault, Postgres 등): `requests == limits`, CPU limit 도 설정.
|
||||
2. `Burstable` — 일반 stateless 앱: `requests < limits` 또는 CPU limit 생략.
|
||||
3. `BestEffort` — **금지**. requests/limits 를 생략한 워크로드는 PR 에서 블록.
|
||||
|
||||
### 5.3 기본 가이드라인 (1000-서비스 스케일 기준 출발점)
|
||||
|
||||
| 워크로드 | CPU req | Memory req / limit |
|
||||
| --- | --- | --- |
|
||||
| 일반 stateless API | 100m | 128–256Mi |
|
||||
| 무거운 JVM (Keycloak, Elasticsearch) | 500m–1 | 1–2Gi (req == limit) |
|
||||
| 배경 worker | 250m | 512Mi–1Gi |
|
||||
| 전환성(transient) Job (Flyway) | 100m | 128Mi |
|
||||
|
||||
실제 값은 부하 테스트 / VPA 권고 결과로 조정한다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 보안 기준선 (Security baselines — 모든 Pod)
|
||||
|
||||
아래 블록은 **모든** Pod 의 최소 baseline 이다. 이걸 내린 설정은 security-hardening.md 의 예외 절차를 거쳐야 한다.
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001 # 앱별 고정 UID, 루트(0) 금지
|
||||
runAsGroup: 10001
|
||||
fsGroup: 10001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: app
|
||||
image: registry.example.com/auth-platform/auth-server@sha256:...
|
||||
imagePullPolicy: IfNotPresent
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. PDB, Job, Deployment 기타
|
||||
|
||||
1. **PDB**: 1.27+ 에서 `spec.unhealthyPodEvictionPolicy: AlwaysAllow` **필수**. 기본값 `IfHealthyBudget` 은 노드 drain 중 복구 불가능한 Pod 가 evict 되지 못해 업그레이드가 멈추는 원인이 된다.
|
||||
2. **Job / CronJob**:
|
||||
- `spec.ttlSecondsAfterFinished: 86400` (24h) 기본. 민감 로그가 남는 경우 `3600` (1h).
|
||||
- `spec.backoffLimit` 명시 (기본 6). 크리티컬 마이그레이션(Flyway)은 `0` 또는 `1` 로 줄여 재시도 폭주 방지.
|
||||
- CronJob 은 `spec.concurrencyPolicy: Forbid` 를 기본값으로 둔다(중복 실행 금지).
|
||||
3. **Deployment**:
|
||||
- `spec.revisionHistoryLimit: 5` (기본 10 은 너무 많음 — etcd 부하).
|
||||
- `spec.progressDeadlineSeconds: 600` 명시.
|
||||
- `spec.strategy.rollingUpdate.maxUnavailable: 0` + `maxSurge: 25%` 가 안전한 기본.
|
||||
|
||||
```yaml
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: auth-server-pdb
|
||||
spec:
|
||||
minAvailable: 2
|
||||
unhealthyPodEvictionPolicy: AlwaysAllow
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: auth-server
|
||||
app.kubernetes.io/instance: auth-server-prod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 문서 내 예시 규약
|
||||
|
||||
1. 모든 YAML 예시는 ```` ```yaml ```` 펜스로 감싼다. 다른 언어 펜스 금지.
|
||||
2. 한 파일에 여러 리소스가 등장하면 `---` separator 를 **명시적으로** 추가한다.
|
||||
3. 예시는 원칙적으로 `kubectl apply -f` 로 바로 적용 가능한 완전체여야 한다. 지면상 생략할 때는 주석으로 표기: `# ... (full spec omitted for brevity)`.
|
||||
4. 나쁜 예시(안티패턴)는 반드시 `## 나쁜 예시`, `## ❌`, 또는 `## bad example` 헤더 아래에 둔다. CI 검증 스크립트가 이 헤더 규약으로 나쁜 예시 블록을 제외한다. 헤더 없이 안티패턴을 노출하면 검증기가 정당한 예시로 오인해 lint 규칙 위반을 일으킨다.
|
||||
5. 네임스페이스, 이미지 레지스트리, 도메인 이름은 `example.com`, `registry.example.com` 플레이스홀더를 사용한다. 실제 조직 도메인은 overlays 에서만 등장한다.
|
||||
|
||||
---
|
||||
|
||||
## 검증 (Validation)
|
||||
|
||||
이 문서의 규약은 CI 에서 기계 검증된다.
|
||||
|
||||
- 실행: `k8s/scripts/ci/validate-docs.sh`
|
||||
- Lint 설정 위치: `.kube-linter.yaml` (repo root)
|
||||
- 목표 스코어:
|
||||
- syntax 에러: **0**
|
||||
- schema 에러: **0**
|
||||
- lint warning: **≤ 5**
|
||||
|
||||
syntax 또는 schema 에러가 있으면 PR 은 머지 불가. lint warning 이 임계치를 넘으면 리뷰어가 수정 또는 예외 주석을 요구한다.
|
||||
@@ -0,0 +1,297 @@
|
||||
# infra architecture / environments 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 1000+ 서비스 규모의 K3s 기반 production 클러스터에서
|
||||
- 환경을 어떻게 나눌지
|
||||
- namespace / label / selector를 어떻게 고정할지
|
||||
- K3s 기본 컴포넌트와 GitOps source of truth를 어떻게 구분할지
|
||||
- cross-cluster / multi-region / DR(RPO·RTO)을 어떻게 문서화할지
|
||||
|
||||
를 먼저 고정한다.
|
||||
|
||||
이 문서의 목표:
|
||||
|
||||
- dev / staging / prod 환경 분리를 **label·namespace·selector 레벨에서** 일관되게 만든다
|
||||
- 서비스별 리소스 소유권(팀·도메인·컴포넌트)을 label로 쿼리 가능하게 한다
|
||||
- K3s packaged component와 사용자 AddOn을 혼동하지 않는다
|
||||
- 멀티 서버에서 `manifests/` 디렉터리를 source-of-truth로 쓰는 사고를 원천 차단한다
|
||||
- 이후 storage / secrets / ingress / workload / observability 표준의 전제 조건을 고정한다
|
||||
|
||||
## 공식 의미 (근거)
|
||||
|
||||
- Kubernetes well-known label set (공식, SIG-Apps 공인): `app.kubernetes.io/{name,instance,version,component,part-of,managed-by}` — 총 6개. `environment`는 포함되지 **않는다** (`https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/`).
|
||||
- `app.kubernetes.io/*` 외의 운영 차원(environment, team, tier, region 등)은 **자체 도메인 네임스페이스**(`example.com/*`)를 붙여 선언해야 한다.
|
||||
- K3s는 `coredns`, `traefik`, `local-storage`, `metrics-server`를 packaged component로 제공한다.
|
||||
- `/var/lib/rancher/k3s/server/manifests` 아래 파일은 서버 시작 시와 파일 변경 시 자동 적용된다(AddOn auto-deploy).
|
||||
- packaged component manifest는 K3s가 재기록하므로 직접 수정 금지.
|
||||
- 멀티 서버 K3s는 AddOn 파일을 자동 동기화하지 않는다.
|
||||
- Kustomize v5+부터는 `labels:` 필드(기본 `includeSelectors: false`)가 `commonLabels`보다 안전한 기본이다. `commonLabels`는 항상 `selector.matchLabels`에 주입되며, Deployment/StatefulSet의 selector는 **immutable**이므로 운영 중 label 추가만으로 apply가 실패한다.
|
||||
- GitOps 기본 apply 방식은 **Server-Side Apply** (`kubectl apply --server-side --field-manager=...`)다. CI/ArgoCD/Flux 모두 SSA 기본.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 환경은 명시적으로 분리하고 **label + namespace 양쪽에** 박는다
|
||||
|
||||
기본 환경:
|
||||
|
||||
- `dev`
|
||||
- `staging`
|
||||
- `prod`
|
||||
|
||||
필요 시 `sandbox` / `canary` / `dr`을 추가할 수 있으나 dev/staging/prod 의미를 흐리지 않는다.
|
||||
|
||||
각 리소스는 두 곳에 동시에 환경이 드러나야 한다.
|
||||
|
||||
- `metadata.namespace` — 물리적 격리
|
||||
- `metadata.labels["example.com/environment"]` — 쿼리·정책용 (well-known label에는 환경이 없으므로 **자체 도메인 label** 사용)
|
||||
|
||||
### 2. 환경 간 혼합 배포 전면 금지
|
||||
|
||||
하나의 namespace / hostname / PVC / Secret / TLS cert scope 안에서 서로 다른 환경 리소스가 섞이지 않는다.
|
||||
|
||||
금지:
|
||||
|
||||
- `auth-dev`, `auth-prod`가 같은 namespace 공유
|
||||
- dev와 prod가 같은 ingress host (`auth.example.com`) 공유
|
||||
- staging과 prod가 같은 PostgreSQL schema / S3 bucket / Vault mount 공유
|
||||
- NetworkPolicy / ResourceQuota / LimitRange가 환경 경계를 걸치지 않음
|
||||
|
||||
### 3. namespace 전략은 “환경 prefix + 서비스 이름” 고정
|
||||
|
||||
1000+ 서비스 스케일에서 초기에 하나의 포맷을 박는다. 본 표준 권장은:
|
||||
|
||||
```
|
||||
<env>-<domain>-<service>
|
||||
```
|
||||
|
||||
예:
|
||||
|
||||
- `prod-identity-auth`
|
||||
- `prod-identity-keycloak`
|
||||
- `staging-identity-auth`
|
||||
- `dev-identity-auth`
|
||||
- `prod-platform-ingress-nginx`
|
||||
- `prod-data-postgres-identity`
|
||||
|
||||
이유:
|
||||
|
||||
- `kubectl -n prod-*` 와일드카드 RBAC / 모니터링 쿼리가 쉬움
|
||||
- `prod-` prefix로 PodSecurity admission (`pod-security.kubernetes.io/enforce=restricted`)을 one-shot으로 강제 가능
|
||||
- `default` namespace는 production workload 배포 전면 금지
|
||||
|
||||
### 4. `app.kubernetes.io/*` 6종은 전 리소스 필수
|
||||
|
||||
모든 워크로드·서비스·ingress·PVC·ConfigMap·Secret에 아래 6개가 반드시 붙는다.
|
||||
|
||||
- `app.kubernetes.io/name` — 애플리케이션 이름 (예: `auth`)
|
||||
- `app.kubernetes.io/instance` — 인스턴스 (예: `auth-prod`)
|
||||
- `app.kubernetes.io/version` — semver 또는 image tag
|
||||
- `app.kubernetes.io/component` — 역할 (예: `api`, `worker`, `database`)
|
||||
- `app.kubernetes.io/part-of` — 상위 도메인 (예: `identity-platform`)
|
||||
- `app.kubernetes.io/managed-by` — 관리 도구 (예: `kustomize`, `argocd`, `flux`)
|
||||
|
||||
### 5. 운영 차원 label은 **자체 도메인**으로 선언
|
||||
|
||||
well-known label 6종으로 표현되지 않는 축은 다음 키로 고정한다.
|
||||
|
||||
- `example.com/environment` — `dev|staging|prod|canary|dr`
|
||||
- `example.com/team` — 소유 팀 (예: `identity-sre`)
|
||||
- `example.com/tier` — `frontend|backend|data|platform`
|
||||
- `example.com/data-classification` — `public|internal|confidential|restricted`
|
||||
- `example.com/cost-center` — FinOps tag
|
||||
- `example.com/slo-tier` — `tier-1|tier-2|tier-3`
|
||||
|
||||
금지:
|
||||
|
||||
- `app.kubernetes.io/environment` 사용 (well-known set에 없음)
|
||||
- 도메인 없는 커스텀 키 (`environment: prod` 같은 top-level key)
|
||||
|
||||
### 6. selector에 들어가는 label은 **불변 3종만**
|
||||
|
||||
Deployment / StatefulSet의 `selector.matchLabels`는 일단 apply 후 수정 불가다. 여기에는 운영 중 **절대 바뀌지 않는** 값만 넣는다.
|
||||
|
||||
허용:
|
||||
|
||||
- `app.kubernetes.io/name`
|
||||
- `app.kubernetes.io/instance`
|
||||
- `app.kubernetes.io/component`
|
||||
|
||||
금지 (selector에 넣지 말 것):
|
||||
|
||||
- `app.kubernetes.io/version` (배포 때마다 바뀜)
|
||||
- `app.kubernetes.io/managed-by` (툴 교체 시 drift)
|
||||
- `example.com/environment` (overlay에서 주입되면 selector immutable 위반)
|
||||
|
||||
### 7. K3s packaged component는 “기본 제공”일 뿐 “무조건 사용”이 아니다
|
||||
|
||||
다음 컴포넌트는 클러스터 bootstrap 초기에 유지/비활성 결정을 박는다.
|
||||
|
||||
- `traefik`
|
||||
- `servicelb`
|
||||
- `local-storage`
|
||||
- `metrics-server`
|
||||
- `coredns` (교체는 특수 케이스)
|
||||
|
||||
기본:
|
||||
|
||||
- 무엇을 끄는지 Git에 기록
|
||||
- packaged manifest 직접 수정 금지 — `--disable` 플래그 또는 `HelmChartConfig`
|
||||
- prod 1000-서비스 스케일에서는 traefik / servicelb 모두 disable 후 **ingress-nginx DaemonSet + MetalLB/외부 LB** 조합이 일반적
|
||||
|
||||
### 8. `/var/lib/rancher/k3s/server/manifests`는 source of truth 아님
|
||||
|
||||
이 디렉터리는 AddOn auto-deploy 경로다.
|
||||
멀티 서버 환경에서 자동 동기화가 **안 되므로**, Git이 source of truth고 이 디렉터리는 apply sink에 지나지 않는다.
|
||||
|
||||
기본:
|
||||
|
||||
- Git repo의 `k8s/` 디렉터리가 SoT
|
||||
- CI/ArgoCD/Flux가 `kubectl apply --server-side`로 push
|
||||
- 서버별 scp / vim 절대 금지
|
||||
- 멀티 서버 bootstrap AddOn도 Git 관리(예: `k8s/bootstrap/*`를 첫 서버에만 배치)
|
||||
|
||||
### 9. GitOps apply는 Server-Side Apply가 기본
|
||||
|
||||
```
|
||||
kubectl apply --server-side --field-manager=<ci-id> -k <overlay>
|
||||
kubectl diff --server-side -k <overlay>
|
||||
```
|
||||
|
||||
이유:
|
||||
|
||||
- multi-controller 환경(ArgoCD + HPA + VPA + operator)에서 ownership 충돌을 `managedFields`로 명시적 해결
|
||||
- `last-applied-configuration` annotation 2MB 한계 회피
|
||||
- 3-way merge 실패로 인한 silent drift 제거
|
||||
|
||||
### 10. base는 환경 중립, overlay는 환경 차이만
|
||||
|
||||
이후 `kustomize.md`에서 상세히 다룬다. 이 문서에서는 원칙만 박는다.
|
||||
|
||||
- `k8s/base/` — 공통 shape, 환경-agnostic
|
||||
- `k8s/overlays/{dev,staging,prod}/` — patches / images / replicas / resources / labels
|
||||
|
||||
overlay는 base를 재작성하지 않는다. overlay diff가 100줄을 넘으면 base 설계 실패 신호다.
|
||||
|
||||
### 11. `app/managing/plugins` 책임 분리
|
||||
|
||||
`k8s/base/` 하위는 다음 3축으로 고정한다.
|
||||
|
||||
- `app/units/<domain>/<service>/` — 애플리케이션 유닛 (auth, keycloak, test-server)
|
||||
- `managing/` — Job/CronJob 운영 작업 (flyway-migrate, backup, restore, bootstrap admin)
|
||||
- `plugins/` — 플랫폼 (ingress-controller, cert-manager, external-secrets, observability, policy)
|
||||
|
||||
이 축은 **소유 팀이 다르다**는 가정 위에 있다. 각 축은 독립된 Git owner (CODEOWNERS)를 가진다.
|
||||
|
||||
### 12. 상태 저장 / 외부 공개 범위를 architecture 단계에서 분류
|
||||
|
||||
모든 서비스는 아래 2축으로 초기 분류한다.
|
||||
|
||||
| 축 | 값 |
|
||||
|-----------------|-----------------------------------------------------------------|
|
||||
| workload 성격 | `stateless` / `stateful` / `job` / `cronjob` / `daemonset` |
|
||||
| 공개 범위 | `public` / `internal-only` / `operator-only` / `cluster-only` |
|
||||
|
||||
예:
|
||||
|
||||
- `auth-server` — stateless / public
|
||||
- `keycloak` — stateless(앱) + stateful(외부 DB) / public (관리 포트는 internal-only)
|
||||
- `vault` — stateful / operator-only (+ cluster-only service endpoint)
|
||||
- `minio-tenant` — stateful / internal-only
|
||||
- `postgres-identity` — stateful / cluster-only
|
||||
- `fluent-bit` — daemonset / cluster-only
|
||||
- `flyway-migrate` — job / cluster-only
|
||||
|
||||
### 13. SLO·RPO·RTO를 환경 문서에서 먼저 박는다
|
||||
|
||||
환경 분리가 의미 있으려면 각 환경의 목표를 숫자로 고정해야 한다. 서비스 tier별로 아래 항목을 환경 문서에서 표로 둔다.
|
||||
|
||||
| tier | availability SLO | RPO | RTO | backup 주기 | multi-AZ | PDB minAvailable |
|
||||
|--------|------------------|------|------|-------------|----------|------------------|
|
||||
| tier-1 | 99.95% | 5m | 15m | 15m | required | 50% |
|
||||
| tier-2 | 99.9% | 1h | 1h | 1h | required | 1 |
|
||||
| tier-3 | 99.5% | 24h | 4h | 24h | optional | 0 |
|
||||
|
||||
tier는 `example.com/slo-tier` label로 리소스마다 붙는다.
|
||||
|
||||
### 14. 멀티 서버 K3s는 critical config를 Git에서 통일
|
||||
|
||||
K3s multi-server에서는 아래가 모든 서버에서 동일해야 한다(불일치 시 `critical configuration value mismatch`로 join 실패).
|
||||
|
||||
- `cluster-cidr`, `service-cidr`, `cluster-dns`, `cluster-domain`
|
||||
- `disable` 플래그 세트
|
||||
- `flannel-backend` / CNI 관련
|
||||
- `embedded-registry` 활성화 여부
|
||||
|
||||
기본:
|
||||
|
||||
- `/etc/rancher/k3s/config.yaml` Git 관리
|
||||
- 서버별 ad-hoc 수정 금지
|
||||
- 신규 서버 조인 전 `config.yaml` diff 확인
|
||||
|
||||
### 15. 공개 범위별 ingress host 패턴 고정
|
||||
|
||||
- public: `<service>.example.com`
|
||||
- internal: `<service>.internal.example.com`
|
||||
- operator: `<service>.ops.example.com` (mTLS + SSO 필수)
|
||||
- cluster-only: ingress 없음, ClusterIP + NetworkPolicy로만 접근
|
||||
|
||||
### 16. 네이밍 규칙 정리 (요약)
|
||||
|
||||
- namespace: `<env>-<domain>-<service>`
|
||||
- Deployment/StatefulSet 이름: `<service>` (namespace로 환경 구분, 이름에 env 중복 금지)
|
||||
- Service 이름: Deployment 이름과 동일 (headless면 `-headless` suffix)
|
||||
- PVC 이름: `<service>-<purpose>-<ordinal>` (StatefulSet volumeClaimTemplate은 자동)
|
||||
- Kustomize overlay 디렉터리: `overlays/<env>/<region>/` (멀티 region 시)
|
||||
|
||||
## 추천 디렉터리 구조
|
||||
|
||||
```text
|
||||
k8s/
|
||||
base/
|
||||
app/
|
||||
shared/
|
||||
units/
|
||||
identity/
|
||||
auth/
|
||||
kustomization.yaml
|
||||
keycloak/
|
||||
kustomization.yaml
|
||||
data/
|
||||
postgres-identity/
|
||||
kustomization.yaml
|
||||
managing/
|
||||
flyway-migrate-identity/
|
||||
backup-postgres/
|
||||
plugins/
|
||||
ingress-nginx/
|
||||
cert-manager/
|
||||
external-secrets/
|
||||
kube-prometheus-stack/
|
||||
overlays/
|
||||
dev/
|
||||
kustomization.yaml
|
||||
staging/
|
||||
kustomization.yaml
|
||||
prod/
|
||||
kustomization.yaml
|
||||
region-kr-main/
|
||||
region-kr-dr/
|
||||
bootstrap/
|
||||
k3s-addons-disabled/
|
||||
scripts/
|
||||
render.sh
|
||||
diff.sh
|
||||
apply.sh
|
||||
```
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 환경 3종(`dev`/`staging`/`prod`) + namespace prefix 고정
|
||||
- well-known `app.kubernetes.io/*` 6개 + 자체 도메인 운영 label 필수
|
||||
- `app.kubernetes.io/environment` 사용 금지, `example.com/environment`로 대체
|
||||
- selector에는 불변 3종만
|
||||
- K3s packaged component는 초기에 disable 여부 결정, 직접 수정 금지
|
||||
- `manifests/`는 apply sink, Git이 SoT
|
||||
- `kubectl apply --server-side` GitOps 기본
|
||||
- SLO / RPO / RTO 표가 환경 문서의 일부
|
||||
@@ -0,0 +1,241 @@
|
||||
# backup / restore 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 K3s 및 일반 Kubernetes 인프라에서
|
||||
- 무엇을 백업해야 하는지
|
||||
- 어떤 도구/방식으로 백업할지 (Velero / CSI snapshot / pgBackRest / WAL-G / CNPG Barman)
|
||||
- RPO / RTO를 어떻게 선언할지
|
||||
- 복구 단위와 절차를 어떻게 표준화할지
|
||||
- restore drill을 어떻게 운영할지
|
||||
를 먼저 고정한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- "PVC가 있으니 백업도 된 것"이라는 착각을 제거한다
|
||||
- "K3s etcd snapshot이 있으니 DB/PVC도 복구된다"는 오해를 제거한다
|
||||
- 선언형 원본, 제어 평면, 상태 저장소를 서로 다른 백업 대상으로 구분한다
|
||||
- 컴포넌트별 복구 전략과 도구를 먼저 정하고 YAML을 쓰게 한다
|
||||
- 실제 장애 시 복구 절차를 재현 가능하게 만든다
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- K3s etcd snapshot은 **클러스터 API 상태**(namespaces, secrets encryption key, RBAC, CRD instance 등)만 백업한다. **PVC의 데이터 내용은 백업하지 않는다.**
|
||||
- K3s snapshot에는 cluster CA 인증서/개인키와 secrets encryption 관련 데이터가 포함될 수 있다.
|
||||
- 새 호스트로 K3s snapshot을 복구할 때는 snapshot 당시 사용한 server token이 필요하다.
|
||||
- Velero는 CNCF 표준 K8s 백업 도구로 `Backup` / `Schedule` / `Restore` CRD와 object storage 백엔드(S3 / MinIO / GCS / Azure Blob)를 사용한다.
|
||||
- Velero file-level backup: File System Backup (FSB, kopia/restic) — 모든 CSI/비-CSI 볼륨의 파일 내용을 복제.
|
||||
- Velero volume-level backup: CSI snapshot — CSI driver가 지원하는 경우 블록 수준 snapshot.
|
||||
- VolumeSnapshotClass의 `deletionPolicy: Retain`이면 VolumeSnapshot 삭제 후에도 VolumeSnapshotContent(클라우드 snapshot)는 남는다.
|
||||
- PostgreSQL의 기본 physical backup 도구로는 `pg_basebackup`(standalone) 외에 **pgBackRest** 또는 **WAL-G**가 사실상 표준이다. CloudNativePG operator는 내장으로 **Barman Cloud**를 사용한다.
|
||||
- `pg_dump`는 logical export이며 정기 production 전체 백업의 기본 도구로는 보통 적합하지 않다.
|
||||
- MinIO `mc mirror`는 현재 객체만 동기화하며 버전 이력/전체 메타데이터 보존에는 적합하지 않다.
|
||||
- MinIO bucket replication은 versioning을 전제로 하고, DR 상황에서 `resync`를 지원한다.
|
||||
|
||||
## RPO / RTO 선언
|
||||
|
||||
모든 백업 대상은 아래 세 줄을 runbook에 먼저 적는다.
|
||||
|
||||
- **RPO (Recovery Point Objective)**: 허용 가능한 데이터 손실 시간
|
||||
- **RTO (Recovery Time Objective)**: 허용 가능한 복구 시간
|
||||
- **Retention**: 백업 보존 기간
|
||||
|
||||
이 세 값이 비어 있으면 도구/스케줄을 선택할 수 없다.
|
||||
|
||||
기본 tier 예시:
|
||||
|
||||
| Tier | RPO | RTO | Retention | 대표 도구 |
|
||||
|---|---|---|---|---|
|
||||
| gold | 5분 | 30분 | 30일 | CNPG continuous WAL + CSI snap 매일 |
|
||||
| silver | 1시간 | 2시간 | 14일 | Velero hourly + CSI snap |
|
||||
| bronze | 24시간 | 24시간 | 90일 | Velero daily FSB |
|
||||
| archive | 24시간 | 72시간 | 7년 | Velero weekly → Glacier / cold bucket |
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 백업 대상은 세 층으로 분리
|
||||
1. **선언형 원본 (Git)**: Kustomize base/overlay, Helm values, Argo CD Application, 운영 문서/runbook, 스크립트
|
||||
2. **제어 평면 (K8s API state)**: K3s etcd snapshot / Velero API object backup
|
||||
3. **상태 저장 데이터 (data plane)**: PVC / VolumeSnapshot, PostgreSQL 물리 백업, Vault raft snapshot, MinIO object data
|
||||
|
||||
이 셋을 하나의 방식으로 뭉뚱그리지 않는다. 특히 **K3s etcd snapshot은 (3)을 커버하지 않는다.**
|
||||
|
||||
### 2. Git은 배포 원본 백업이지 런타임 상태 백업이 아니다
|
||||
Git/Kustomize는 source of truth지만 runtime DB state, Vault secret state, MinIO object data, K3s cluster membership state를 복원해 주지 않는다. Git 백업만으로 운영 복구가 된다고 판단하지 않는다.
|
||||
|
||||
### 3. K3s etcd snapshot은 제어 평면 전용 백업
|
||||
K3s etcd snapshot은 API server의 선언 상태만 백업한다. PVC 안의 파일 내용은 포함하지 않는다.
|
||||
|
||||
기본:
|
||||
- scheduled etcd snapshot 사용 (예: 6시간마다)
|
||||
- local retention + S3/off-node retention 병행
|
||||
- snapshot에는 secrets encryption key와 CA private key가 포함될 수 있으므로 민감 정보로 취급
|
||||
- 저장 위치 암호화, 접근 통제, 보존 기간 통제, chain of custody 확인
|
||||
- 새 호스트 복구용 server token을 별도 위치에 보관 (같은 곳에 두면 동시 유출 위험)
|
||||
|
||||
### 4. 상태 저장 데이터 백업은 Velero가 표준
|
||||
Kubernetes 수준에서 PVC / 네임스페이스 / CRD를 함께 백업/복구하려면 **Velero**를 기본 도구로 둔다.
|
||||
|
||||
기본 구성:
|
||||
- `BackupStorageLocation`: off-cluster S3 또는 cluster 외부 MinIO (같은 cluster 안 MinIO에 백업하지 않는다)
|
||||
- `VolumeSnapshotLocation`: CSI driver 대응
|
||||
- `Schedule` CRD로 cron 기반 정기 백업
|
||||
- selector(`labelSelector`, `includedNamespaces`)로 tier별 스케줄 분리
|
||||
- TTL로 retention 관리
|
||||
|
||||
### 5. Velero FSB(kopia/restic) vs CSI snapshot 선택
|
||||
- **CSI snapshot**: 볼륨 수준 crash-consistent, 빠름, CSI driver 지원 필요. DB처럼 큰 볼륨에 적합. 클라우드 snapshot cost 고려.
|
||||
- **File System Backup (FSB, kopia/restic)**: 파일 수준, 모든 볼륨에서 동작, 암호화/중복제거, 느림. local-path / hostPath / 비-CSI 볼륨에 적합.
|
||||
|
||||
운영 기본:
|
||||
- CSI snapshot이 가능한 볼륨은 CSI snapshot 우선
|
||||
- 크지 않은 설정/아카이브 볼륨은 FSB 허용
|
||||
- DB 볼륨은 CSI snapshot이어도 app-consistent hook(pre/post backup) 필요
|
||||
|
||||
### 6. 오프-클러스터 백업 저장소 필수
|
||||
백업을 **같은 K8s 클러스터 안**의 MinIO/S3에 두지 않는다. cluster 장애 = 백업 동시 소실이다.
|
||||
|
||||
기본:
|
||||
- 별도 리전/별도 account의 S3-호환 object storage
|
||||
- bucket versioning 활성화
|
||||
- object-lock / WORM (규제 필요 시)
|
||||
- 접근은 IRSA / Workload Identity / 최소권한 IAM
|
||||
|
||||
### 7. VolumeSnapshotClass `deletionPolicy`는 정책에 맞춘다
|
||||
운영 gold tier 데이터의 VolumeSnapshotClass는 `deletionPolicy: Retain`을 기본으로 둔다.
|
||||
이렇게 하면 K8s에서 VolumeSnapshot object가 지워져도 CSI driver의 실제 snapshot(VolumeSnapshotContent)은 남아서 사고 복구 여지를 준다.
|
||||
|
||||
### 8. PostgreSQL은 logical / physical / continuous를 구분
|
||||
- `pg_dump` — logical export. 선택적 export, schema 비교, 마이그레이션 준비용. 프로덕션 전체 복구 기본값으로 두지 않는다.
|
||||
- `pg_basebackup` — standalone base backup. 소규모/단순 케이스에 적합하지만 WAL archiving을 직접 구성해야 한다.
|
||||
- **pgBackRest / WAL-G** — 프로덕션 표준. incremental / differential backup, parallel restore, retention, PITR, S3 업로드를 내장.
|
||||
- **CloudNativePG (CNPG)** — K8s-native Postgres operator. 내장 **Barman Cloud**로 object storage에 WAL + base backup을 지속 업로드. `Backup` / `ScheduledBackup` CRD 제공.
|
||||
|
||||
### 9. K8s 위 Postgres 운영 기본은 CloudNativePG
|
||||
2026 기준 K8s 상에서 Postgres를 운영한다면 **CloudNativePG (CNPG)**를 기본 후보로 둔다 (CNCF sandbox).
|
||||
|
||||
이유:
|
||||
- `Cluster` CRD로 primary + standby 자동 관리, failover, rolling upgrade
|
||||
- `backup` 섹션에서 Barman Cloud 기반 continuous archiving을 선언만 하면 동작
|
||||
- `Backup` (on-demand), `ScheduledBackup` (cron), PITR restore가 `Cluster.spec.bootstrap.recovery`로 표준화
|
||||
- Prometheus `PodMonitor` 내장
|
||||
|
||||
대안:
|
||||
- **Zalando postgres-operator** — 오래된 생태계, Spilo 기반
|
||||
- **Crunchy PGO** — 상용 지원 강점, pgBackRest 내장
|
||||
|
||||
manual StatefulSet + sidecar는 1000개 서비스 규모에서는 권장하지 않는다.
|
||||
|
||||
### 10. Keycloak DB는 애플리케이션과 분리된 DB 전략을 따른다
|
||||
Keycloak이 외부 PostgreSQL을 사용하면 Keycloak 복구는 애플리케이션 Pod 복구보다 DB 백업 전략에 크게 의존한다. Keycloak server manifest만 백업해서는 충분하지 않다.
|
||||
|
||||
### 11. Vault는 storage mode에 따라 백업 방식을 다르게 본다
|
||||
- integrated storage (raft) → `vault operator raft snapshot save` 기본
|
||||
- external storage (Consul 등) → 해당 백엔드 백업 전략
|
||||
- dev mode → 운영 대상 아님
|
||||
|
||||
Vault snapshot 복구 테스트는 격리된 네트워크/환경에서 수행한다 (live credential revoke, 원치 않는 cluster 간 통신, 데이터 일관성 훼손 방지).
|
||||
|
||||
### 12. MinIO는 PVC snapshot만으로 충분하다고 보지 않는다
|
||||
object store는 단순 PV 파일 복사 관점보다 object versioning / replication / resync 포함 전략으로 본다.
|
||||
|
||||
기본:
|
||||
- bucket versioning enabled
|
||||
- 소스/대상 cluster replication configured
|
||||
- DR 시 `mc replicate resync` 절차 문서화
|
||||
- `mc mirror`는 현재 객체 동기화 용도로만 제한 (버전 이력 보존 안 됨)
|
||||
- 스토리지 layer snapshot은 보조 수단
|
||||
|
||||
### 13. stateless workload는 데이터보다 재현성을 백업
|
||||
다음은 기본적으로 런타임 파일 백업 대상이 아니다.
|
||||
|
||||
- auth-server, ingress-controller, stateless test-server
|
||||
- 외부 DB 사용 Keycloak 서버 자체
|
||||
|
||||
복구 핵심:
|
||||
- Git / Kustomize / Helm values
|
||||
- Config / Secret source (Vault Secrets Operator 기준)
|
||||
- 이미지 digest
|
||||
- 운영 문서
|
||||
|
||||
### 14. migration-flyway는 산출물이 아닌 migration source를 백업
|
||||
Flyway Job 자체나 container 파일시스템/PVC는 backup 대상이 아니다. 중요한 것은:
|
||||
|
||||
- migration script (Git)
|
||||
- migration ordering + schema history table 상태 (DB 백업으로 포함)
|
||||
- Flyway 실행 이력 (CI/CD 로그, Argo Rollout 기록)
|
||||
|
||||
### 15. 모든 백업은 "주기 + 보존기간 + 저장 위치 + 암호화 + 무결성 검증 + 복구 테스트"를 갖춘다
|
||||
파일만 남기고 정책이 없는 것을 백업 전략으로 보지 않는다. 최소 메타데이터:
|
||||
|
||||
- Schedule cron 또는 RPO
|
||||
- Retention TTL
|
||||
- 저장 위치 (버킷, prefix, 리전)
|
||||
- 암호화 방식 (SSE-S3 / SSE-KMS / client-side)
|
||||
- 무결성 검증 (checksum, Velero `backup describe`의 errors)
|
||||
- 복구 테스트 cadence + 마지막 성공 일자
|
||||
|
||||
### 16. restore drill은 표준 운영 절차
|
||||
복구 가능한지 확인하지 않은 백업은 신뢰하지 않는다.
|
||||
|
||||
기본 cadence:
|
||||
- 제어 평면 (K3s etcd / Velero): 분기별 1회
|
||||
- DB physical restore + PITR: 월 1회
|
||||
- Vault raft restore: 분기별 1회
|
||||
- MinIO replication resync: 반기별 1회
|
||||
|
||||
기록 항목:
|
||||
- 실행 일자
|
||||
- 실행자
|
||||
- 대상 snapshot/backup ID
|
||||
- 실제 RTO / 확인된 RPO
|
||||
- 발견된 issue
|
||||
- 다음 drill 예정일
|
||||
|
||||
최근 90일 내 성공 기록이 없는 백업은 "신뢰할 수 있는 백업"으로 보지 않는다.
|
||||
|
||||
### 17. 복구 단위는 컴포넌트별로 다르게 정의
|
||||
| 컴포넌트 | 복구 단위 | 기본 도구 |
|
||||
|---|---|---|
|
||||
| K3s control plane | cluster snapshot | k3s etcd-snapshot |
|
||||
| K8s API object (namespace 단위) | Velero Backup | Velero |
|
||||
| PostgreSQL cluster | DB cluster 전체 + PITR | CNPG Backup / pgBackRest |
|
||||
| PostgreSQL single database | logical dump | `pg_dump` (보조) |
|
||||
| Vault | raft snapshot | `vault operator raft snapshot` |
|
||||
| MinIO | bucket / object / site | mc replication + resync |
|
||||
| stateless apps | namespace/service redeploy | Argo CD + Git |
|
||||
| PVC 일반 | VolumeSnapshot / Velero FSB | Velero + CSI |
|
||||
|
||||
모든 것을 "서비스 단위" 또는 "PVC 단위" 하나로만 보지 않는다.
|
||||
|
||||
### 18. 백업 구성은 Git으로 관리되고 GitOps sync된다
|
||||
Velero `Schedule`, `BackupStorageLocation`, `VolumeSnapshotClass`, CNPG `ScheduledBackup`은 Argo CD / Flux로 동기화한다. kubectl 수동 편집 금지.
|
||||
|
||||
### 19. 백업과 복구는 다른 문서와 연결
|
||||
다음 문서와 항상 연결한다.
|
||||
|
||||
- `storage-pvc.md` (PVC tier ↔ snapshot class)
|
||||
- `db-and-migration.md` (DB 복구 전략)
|
||||
- `operations-runbook-upgrade-rollback.md` (장애 시 절차)
|
||||
- `config-and-secrets.md` (Vault 백업)
|
||||
|
||||
## 현재 스택 기본 권장안
|
||||
|
||||
- **K3s control plane**: etcd scheduled snapshot (6h) + S3 off-node 보관, server token 별도 안전 보관
|
||||
- **PostgreSQL**: CloudNativePG + Barman Cloud (continuous WAL + daily base backup), RPO 5분
|
||||
- **Vault**: integrated storage → raft snapshot 매일, 격리 환경에서 분기 1회 drill
|
||||
- **MinIO**: bucket versioning + 별도 region으로 replication, mc resync runbook
|
||||
- **PVC 일반**: Velero Schedule (tier별 분리) + CSI VolumeSnapshot
|
||||
- **auth-server / ingress-controller / stateless**: Git + Argo CD 재현
|
||||
- **migration-flyway**: migration source는 Git, DB 상태는 CNPG 백업에 포함
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 백업 대상은 선언형 원본 / 제어 평면 / 상태 저장 데이터로 분리
|
||||
- K3s etcd snapshot은 PVC 데이터를 포함하지 않음 — 별도 Velero 필수
|
||||
- Velero를 Kubernetes 백업 표준으로, off-cluster 저장소에 보관
|
||||
- PostgreSQL은 CNPG + Barman Cloud (또는 pgBackRest / WAL-G) — `pg_dump`는 보조
|
||||
- VolumeSnapshotClass `deletionPolicy`를 tier에 맞게 (운영은 Retain)
|
||||
- RPO/RTO/Retention을 tier로 선언
|
||||
- restore drill은 분기/월 단위 표준 cadence + 최근 성공 일자 기록
|
||||
- 백업 구성은 GitOps로 관리
|
||||
@@ -0,0 +1,223 @@
|
||||
# config / secrets 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 1000+ 서비스 운영 클러스터에서
|
||||
- 무엇을 ConfigMap에 두고 무엇을 Secret/Vault에 두는지
|
||||
- 민감정보를 어떤 경로로 Pod에 주입하는지 (VSO / ESO / CSI / SealedSecrets / SOPS)
|
||||
- Kubernetes Secret at-rest encryption을 어떻게 구성하는지
|
||||
- Image registry credential은 어떻게 다루는지
|
||||
를 단일 ground truth로 고정한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 민감정보가 manifest/Git/image/log 어디에도 새지 않는다
|
||||
- Vault를 single source of truth로 두고 K8s Secret은 **파생 산출물**로만 존재
|
||||
- 주입 방식(envFrom/volume/CSI)과 source(VSO/ESO/Vault Injector)를 표준화
|
||||
- GitOps와 비밀 관리를 구조적으로 분리
|
||||
|
||||
## 공식 의미 (근거)
|
||||
|
||||
- ConfigMap은 **비기밀** 데이터 저장용 API object. 최대 1MiB.
|
||||
- Secret은 민감정보용 object. **data는 base64 encoded(암호화 아님)**. stringData는 생성 시 자동 base64.
|
||||
- Secret은 기본적으로 etcd에 평문 저장(base64 decode가 암호화가 아님). Kubernetes는 **at-rest encryption을 운영에서 필수**로 권장.
|
||||
- Secret 타입: `Opaque`, `kubernetes.io/tls`, `kubernetes.io/dockerconfigjson`, `kubernetes.io/service-account-token`, `bootstrap.kubernetes.io/token`, `kubernetes.io/basic-auth`, `kubernetes.io/ssh-auth`.
|
||||
- **Vault Secrets Operator(VSO)**: Vault의 secret(KV v2, dynamic DB, PKI, AWS 등)을 Kubernetes Secret으로 sync하는 controller. CRD: `VaultConnection`, `VaultAuth`, `VaultStaticSecret`, `VaultDynamicSecret`, `VaultPKISecret`, `HCPVaultSecretsApp`. 앱은 그냥 K8s Secret을 `envFrom`/`volumeMounts`로 소비.
|
||||
- **Vault Agent Injector**: Mutating webhook이 Pod에 sidecar/init container를 주입해 tmpfs에 비밀을 렌더링. K8s Secret을 **만들지 않는다**(Vault → file).
|
||||
- **External Secrets Operator(ESO)**: AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, Vault 등 **외부 provider → K8s Secret sync**. VSO와 유사하지만 멀티 provider.
|
||||
- **CSI Secret Store Driver**: volume으로만 마운트(K8s Secret 미생성, optionally mirror). Azure Key Vault, AWS Secrets Manager, GCP Secret Manager, Vault provider 존재.
|
||||
- **Sealed Secrets (Bitnami)**: public key로 암호화된 `SealedSecret` CRD를 Git에 커밋 → controller가 cluster private key로 복호화해 K8s Secret 생성. GitOps 친화적.
|
||||
- **SOPS**: 파일 수준 암호화(age/GPG/KMS) + kustomize/Helm/Flux plugin. Git 커밋 가능.
|
||||
- `EncryptionConfiguration`은 API Server `--encryption-provider-config` 플래그로 지정. providers: `identity`(평문), `aescbc`, `aesgcm`, `secretbox`, `kms` v1/v2.
|
||||
- K3s는 `--secrets-encryption` 플래그로 aescbc provider 활성화.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 분류: ConfigMap vs Secret vs Vault
|
||||
#### ConfigMap
|
||||
- host/port/base path
|
||||
- feature flag
|
||||
- timeout/retry/batch size
|
||||
- 공개 가능한 application config (`application.yaml` 비기밀 부분)
|
||||
- log level
|
||||
- probe 관련 non-secret 설정
|
||||
|
||||
#### Kubernetes Secret (하지만 **Vault 파생**이 기본)
|
||||
- DB password, OAuth client secret, signing key, API token
|
||||
- TLS 인증서 (cert-manager가 자동 생성)
|
||||
- imagePullSecret(`kubernetes.io/dockerconfigjson`)
|
||||
- VSO/ESO가 sync한 Secret
|
||||
|
||||
#### Vault (source of truth)
|
||||
- 모든 운영 credential의 1차 저장소
|
||||
- DB dynamic credentials, PKI, transit encryption keys
|
||||
- OIDC client secret, SMTP credential
|
||||
- KV v2 path로 서비스별 격리
|
||||
|
||||
**원칙:** "조금이라도 민감하면 Vault/Secret 쪽". ConfigMap에는 절대 비밀 넣지 않는다. base64는 암호화가 아니다.
|
||||
|
||||
### 2. Kubernetes Secret at-rest encryption 필수
|
||||
운영 클러스터는 API Server `--encryption-provider-config`로 Secret 자원을 암호화한다. 권장 순서: **KMS v2 > KMS v1 > aescbc > identity(금지)**.
|
||||
|
||||
```yaml
|
||||
apiVersion: apiserver.config.k8s.io/v1
|
||||
kind: EncryptionConfiguration
|
||||
resources:
|
||||
- resources: ["secrets"]
|
||||
providers:
|
||||
- kms:
|
||||
apiVersion: v2
|
||||
name: platform-kms
|
||||
endpoint: unix:///var/run/kmsplugin/socket.sock
|
||||
cachesize: 1000
|
||||
timeout: 3s
|
||||
- aescbc:
|
||||
keys:
|
||||
- name: fallback-2026-q1
|
||||
secret: <32-byte base64 key>
|
||||
- identity: {}
|
||||
```
|
||||
|
||||
- KMS 소켓/플러그인은 노드 hardening 대상.
|
||||
- K3s는 `curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server --secrets-encryption" sh -` 또는 config.yaml `secrets-encryption: true`.
|
||||
- 기존 Secret은 `kubectl get secrets --all-namespaces -o json | kubectl replace -f -`로 강제 재암호화.
|
||||
- 키 회전은 `kube-apiserver` restart + `replace` 절차를 ADR로 고정.
|
||||
|
||||
### 3. Secret delivery 경로 우선순위
|
||||
1. **VSO** — 운영 기본. Vault KV v2/dynamic credential → K8s Secret → envFrom/volume. 앱 코드 변경 0.
|
||||
2. **ESO** — 멀티 클라우드 provider 필요 시. API는 VSO와 유사하지만 `SecretStore`/`ClusterSecretStore` + `ExternalSecret`.
|
||||
3. **CSI Secret Store Driver** — K8s Secret object를 아예 만들고 싶지 않을 때(volume only). SA별 scope가 필요한 sensitive mount.
|
||||
4. **Vault Agent Injector** — 앱이 template engine을 필요로 할 때(JSON/XML 포맷 렌더링). K8s Secret 없음.
|
||||
5. **SealedSecrets / SOPS** — GitOps 전용 + 소규모 클러스터 + VSO 미도입 환경. Git에 encrypted blob 커밋.
|
||||
6. **Plain Secret manifest** — 운영 금지. 로컬/부트스트랩 한정.
|
||||
|
||||
선택 기준:
|
||||
- "Vault가 SoT이고 K8s Secret을 앱이 envFrom으로 소비" → VSO
|
||||
- "멀티 클라우드/비-Vault provider" → ESO
|
||||
- "K8s Secret 자체를 만들고 싶지 않음(audit/scope)" → CSI
|
||||
- "앱이 Vault template로 renderng 필요" → Vault Agent Injector
|
||||
- "Vault 없음 + Git에 커밋해야 함" → SealedSecrets/SOPS
|
||||
|
||||
### 4. VSO CRD 사용 표준
|
||||
VSO는 Helm으로 `vault-secrets-operator` namespace에 설치되어 있다고 가정한다.
|
||||
|
||||
- `VaultConnection` (namespace or cluster) — Vault address, CA bundle, TLS skipVerify=false
|
||||
- `VaultAuth` — auth method(kubernetes, jwt, approle). kubernetes auth 기본.
|
||||
- `VaultStaticSecret` — KV v2 secret → K8s Secret
|
||||
- `VaultDynamicSecret` — Postgres/MySQL/AWS dynamic credentials
|
||||
- `VaultPKISecret` — PKI engine → `kubernetes.io/tls` Secret
|
||||
- `HCPVaultSecretsApp` — HCP Vault Secrets 소비
|
||||
|
||||
모든 CRD는 같은 namespace 안에서 선언하고, 결과 Secret의 이름은 서비스명 규칙을 따른다.
|
||||
|
||||
### 5. Vault path 규칙 + auth policy
|
||||
- KV v2 path: `kv/data/<team>/<service>/<env>/<component>` (예: `kv/data/identity/auth-server/prod/db`)
|
||||
- Vault role은 namespace + service account로 제한:
|
||||
```
|
||||
bound_service_account_names=auth-server
|
||||
bound_service_account_namespaces=auth-prod
|
||||
```
|
||||
- Vault policy는 `path "kv/data/identity/auth-server/prod/*" { capabilities = ["read"] }` 수준으로 scope.
|
||||
- dynamic credential TTL은 pod 수명과 맞춘다(예: Postgres role 24h, auto-renew).
|
||||
|
||||
### 6. 주입 방식: envFrom vs volume
|
||||
- **envFrom** — 전체 Secret의 key를 env로 투사. 간단, 12-factor 친화. 하지만 프로세스 env는 sub-process 상속, `/proc/<pid>/environ` 노출 위험.
|
||||
- **volume** — 파일로 마운트(`/var/run/secrets/<name>`). 권장 in-memory(`readOnly: true`). 민감 key는 volume 우선.
|
||||
- **envFrom + volume 혼합** 허용(db env는 env, signing key는 volume).
|
||||
- **subPath**는 사용 금지(Secret 업데이트가 자동 반영 안 됨).
|
||||
|
||||
### 7. 한 Pod 내에서도 필요한 컨테이너에만 주입
|
||||
- sidecar(metrics, proxy)에는 secret 전달 금지.
|
||||
- Pod `volumes`로 선언하더라도 각 컨테이너 `volumeMounts`는 필요한 컨테이너에만.
|
||||
|
||||
### 8. Secret/ConfigMap naming
|
||||
- 패턴: `<service>-<purpose>` (`auth-server-db`, `auth-server-oidc-client`, `keycloak-db`).
|
||||
- 금지: `common-*`, `shared-*`, `global-*` (스코프가 불분명하고 권한 팽창 원인).
|
||||
|
||||
### 9. immutable Secret/ConfigMap
|
||||
- 변경 빈도 낮은 `kubernetes.io/tls`, 앱 release-tied config는 `immutable: true` 검토.
|
||||
- immutable이면 수정 불가 → 삭제 후 재생성 + rollout 필요. VSO가 갱신하는 Secret은 immutable 금지.
|
||||
|
||||
### 10. Kustomize generator 사용 기준
|
||||
- `configMapGenerator` — 비기밀 설정에 허용. hash suffix로 rollout 트리거.
|
||||
- `secretGenerator` — **운영 금지**. 로컬/테스트/부트스트랩 한정.
|
||||
- 운영은 VSO/ESO/SealedSecrets 경로.
|
||||
|
||||
### 11. Image pull secret
|
||||
- 타입: `kubernetes.io/dockerconfigjson`.
|
||||
- 구조:
|
||||
```json
|
||||
{
|
||||
"auths": {
|
||||
"registry.example.com": {
|
||||
"username": "ci-bot",
|
||||
"password": "<token>",
|
||||
"auth": "<base64(username:password)>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- SA의 `imagePullSecrets`에 연결 → Deployment마다 반복 선언 불필요.
|
||||
- Registry credential 자체도 VSO로 Vault → `kubernetes.io/dockerconfigjson` Secret sync(VSO `VaultStaticSecret.destination.type: kubernetes.io/dockerconfigjson`).
|
||||
|
||||
### 12. image 지정: digest pin 기본
|
||||
- mutable tag(`latest`, `main`, `dev`)는 `imagePullPolicy: Always` + staging 환경에만.
|
||||
- 운영은 `image: registry.example.com/auth-server@sha256:<digest>` 고정. `imagePullPolicy: IfNotPresent` 충분.
|
||||
- digest는 CI가 release 시 생성하고 GitOps manifest(ArgoCD)에 커밋.
|
||||
- Kyverno/Gatekeeper로 namespace `auth-prod`의 Pod image가 `@sha256:`를 포함하도록 enforce.
|
||||
|
||||
### 13. Secret 접근 RBAC
|
||||
- `secrets` 리소스의 `list`/`watch`는 controller(VSO, cert-manager, argo-cd)에만 허용.
|
||||
- 일반 workload는 `get` + `resourceNames` 배열로 제한.
|
||||
- 같은 namespace에서 Pod 생성 권한은 Secret 간접 접근이 될 수 있음을 전제로 RBAC 설계(namespace 분리).
|
||||
|
||||
### 14. 민감정보 로깅/에러 보호
|
||||
- 앱은 비밀을 평문 로그, 예외 메시지, telemetry attribute, debug endpoint에 포함 금지.
|
||||
- Exception handler는 `password`, `token`, `secret`, `authorization` 포함 필드 자동 redact.
|
||||
- APM/Logging pipeline에도 scrub rule 추가.
|
||||
|
||||
### 15. Secret 회전
|
||||
- dynamic credential: VSO `VaultDynamicSecret`이 TTL 전에 자동 renew/rotate + Pod rollout trigger(`rolloutRestartTargets`).
|
||||
- static credential: VSO `refreshAfter` + Vault rotate cron + `rolloutRestartTargets`로 Deployment 자동 rolling.
|
||||
- TLS cert: cert-manager가 `renewBefore`에 맞춰 회전. Pod는 `reloader` annotation 또는 webhook으로 rollout.
|
||||
|
||||
### 16. 설정 타입과 도메인 타입 분리
|
||||
- `@ConfigurationProperties` / `application.yaml`은 설정 계약.
|
||||
- 도메인 Value Object는 config에서 복사하되 config 타입을 도메인에 노출하지 않는다.
|
||||
- 테스트에서는 config를 직접 주입할 수 있어야 한다(포트 바인딩, spring profile).
|
||||
|
||||
### 17. 환경별 overlay
|
||||
- `base/` — 공통 ConfigMap/Service/Deployment/RBAC
|
||||
- `overlays/{dev,staging,prod}/` — 환경별 patch(`replicas`, `resources`, `image digest`, `ingress host`)
|
||||
- Secret은 **overlay에 plain 저장 금지**. VSO CRD도 prod overlay에서 Vault mount path만 override.
|
||||
|
||||
### 18. 현재 스택 기본 권장안
|
||||
|
||||
#### auth-server / test-server / keycloak
|
||||
- ConfigMap: `application.yaml` 비기밀
|
||||
- Secret 경로: VSO `VaultStaticSecret`(OIDC client) + `VaultDynamicSecret`(Postgres role)
|
||||
- 주입: envFrom(DB creds) + volume(signing key 파일)
|
||||
|
||||
#### migration-flyway
|
||||
- short-lived Job
|
||||
- SA token automount false
|
||||
- VSO `VaultDynamicSecret`이 migration 전용 Postgres role을 짧은 TTL로 발급
|
||||
|
||||
#### vault
|
||||
- Vault server 자체의 unseal key는 cluster 밖(HSM/KMS/cloud KMS auto-unseal)
|
||||
- bootstrap token은 `vault-bootstrap` namespace에 at-rest encrypted Secret으로 저장, 사용 후 삭제
|
||||
|
||||
#### registry
|
||||
- `kubernetes.io/dockerconfigjson` Secret은 VSO로 Vault KV에서 sync
|
||||
- namespace SA `imagePullSecrets`에 연결
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- ConfigMap = 비기밀, Secret = 민감정보, Vault = source of truth
|
||||
- Kubernetes Secret at-rest encryption(KMS 우선, aescbc 최소) 필수
|
||||
- Secret delivery 우선순위: VSO > ESO > CSI > Vault Agent Injector > SealedSecrets/SOPS
|
||||
- 운영 Secret generator/plain Secret manifest 금지
|
||||
- image는 digest pin + private registry, `kubernetes.io/dockerconfigjson` Secret은 SA imagePullSecrets 연결
|
||||
- Secret 주입은 필요한 컨테이너/필요한 key만, env보다 volume 우선
|
||||
- RBAC은 namespace Role + `resourceNames` + list/watch controller 전용
|
||||
- 회전은 VSO/cert-manager + rolloutRestart 자동화
|
||||
@@ -0,0 +1,305 @@
|
||||
# db / migration 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 Kubernetes 상의 PostgreSQL과 Flyway를 기준으로
|
||||
- 데이터베이스를 어떻게 나눌지
|
||||
- 어떤 operator / 도구로 운영할지 (CNPG, Zalando, Crunchy, self-managed StatefulSet)
|
||||
- migration을 어디서 어떻게 실행할지
|
||||
- migration과 배포(Helm / Argo CD)의 순서를 어떻게 보장할지
|
||||
- validate / migrate / rollback / backup / PITR을 어떤 순서로 볼지
|
||||
- zero-downtime을 위한 expand-migrate-contract를 어떻게 적용할지
|
||||
를 먼저 고정한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- app rollout과 schema 변경을 분리한다
|
||||
- Keycloak DB와 auth-server DB 경계를 먼저 고정한다
|
||||
- Flyway를 앱 시작 로직에 숨기지 않는다
|
||||
- PostgreSQL backup/restore 전략과 migration 전략을 함께 본다
|
||||
- 1000+ 서비스 규모에서 일관된 migration Job 표준을 만든다
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- `pg_dump`는 logical export다. 정기 production 전체 백업 기본값으로는 보통 적합하지 않다.
|
||||
- `pg_basebackup`은 실행 중인 PostgreSQL cluster의 base backup을 만들며 PITR/standby 시작점으로 쓴다.
|
||||
- PostgreSQL PITR은 base backup + WAL archiving 결합이다.
|
||||
- 운영 표준 물리 백업 도구: **pgBackRest**, **WAL-G**. 또는 operator-native (CNPG Barman Cloud, Crunchy PGO).
|
||||
- PostgreSQL의 **대부분 DDL은 트랜잭션 내에서 실행 가능**하지만, `CREATE INDEX CONCURRENTLY`, `REINDEX CONCURRENTLY`, `ALTER TYPE ... ADD VALUE`, `VACUUM`은 트랜잭션 밖에서만 실행된다.
|
||||
- CloudNativePG operator는 CNCF Sandbox 프로젝트로 K8s-native Postgres 운영 표준 후보다.
|
||||
- Flyway `validate`는 적용된 migration과 로컬 migration 사이의 이름/타입/checksum 차이, 로컬에 없는 적용 버전, 아직 적용되지 않은 로컬 버전을 검증한다.
|
||||
- Flyway `validateOnMigrate` 기본값은 `true`, `cleanDisabled` 기본값은 `true` (Flyway 9+).
|
||||
- Flyway `migrate`는 schema history table을 자동 생성하고 최신 migration까지 적용한다.
|
||||
- Flyway Community(OSS)는 **undo(U__) migration을 지원하지 않는다.** Undo는 Teams/Enterprise 전용이다.
|
||||
- Flyway는 migration 실행 시 schema history table에 advisory lock을 걸어 동시 실행을 방지한다.
|
||||
|
||||
## RPO / RTO
|
||||
|
||||
모든 DB는 다음을 runbook에 먼저 적는다.
|
||||
|
||||
- RPO / RTO / Retention
|
||||
- 복구 목표 (cluster restore / PITR / standby seed)
|
||||
- 운영 tier (gold / silver / bronze)
|
||||
|
||||
이 값들이 없으면 backup 도구 선택이 되지 않는다. `backup-restore.md` 참조.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. DB 경계는 애플리케이션 경계보다 먼저 고정
|
||||
다음을 명시적으로 정한다.
|
||||
|
||||
- Keycloak DB와 auth-server DB를 **물리적 cluster**로 분리할지, 같은 cluster 내 **logical DB / schema**로 분리할지
|
||||
- test-server가 DB를 가지는지
|
||||
- migration 소유권이 누구에게 있는지 (보통 서비스 팀)
|
||||
|
||||
기본:
|
||||
- 인증 critical data (Keycloak)와 앱 data (auth-server)는 **cluster 분리 권장**
|
||||
- 같은 cluster를 쓰더라도 database / role / schema ownership을 섞지 않음
|
||||
- 한 migration tool/job이 여러 서비스 schema를 동시에 소유하지 않음
|
||||
|
||||
### 2. K8s 위 Postgres 운영 기본은 operator
|
||||
1000+ 서비스 규모에서 self-managed StatefulSet은 운영 부담이 너무 크다. Operator를 기본 후보로 둔다.
|
||||
|
||||
우선순위 (2026 기준):
|
||||
1. **CloudNativePG (CNPG)** — CNCF Sandbox, K8s-native, Barman Cloud 내장, `Cluster` / `Backup` / `ScheduledBackup` CRD
|
||||
2. **Crunchy PGO** — 상용 지원, pgBackRest 내장
|
||||
3. **Zalando postgres-operator** — Spilo 기반, 레거시 환경
|
||||
|
||||
operator를 쓰면 자동으로 얻는 것:
|
||||
- primary/standby 구성 + failover
|
||||
- rolling minor upgrade
|
||||
- WAL archiving + continuous backup
|
||||
- pg_basebackup, PITR, replica re-clone
|
||||
- PodMonitor 연동
|
||||
|
||||
### 3. migration은 앱 startup에 숨기지 않는다
|
||||
Flyway migration은 **독립 실행 단계**다.
|
||||
|
||||
기본:
|
||||
- `validate` → (필요시 `info`) → `migrate` → app rollout
|
||||
|
||||
기본 금지:
|
||||
- app container 시작 시 자동 migration (Spring Boot `spring.flyway.enabled=true` + `@SpringBootApplication` 부팅 시 migrate)
|
||||
- readiness/liveness와 migration 실패를 섞는 구조
|
||||
- "서버가 뜨면 알아서 schema를 맞춘다" 방식
|
||||
|
||||
### 4. Flyway 실행 기본값은 Kubernetes Job
|
||||
운영 환경에서 Flyway는 다음 중 하나로만 실행한다.
|
||||
|
||||
- Kubernetes Job (권장)
|
||||
- CI/CD 명시 단계
|
||||
- 운영자 명시 실행 절차
|
||||
|
||||
장기 실행 Deployment에 넣지 않는다. Flyway 예시는 `examples/infra/flyway.md` 참조.
|
||||
|
||||
### 5. migration Job은 배포 흐름 안에서 app보다 먼저 실행
|
||||
migration을 app보다 **선행**시키는 것은 manifest 메타데이터로 선언한다.
|
||||
|
||||
패턴 A — Helm hook:
|
||||
```yaml
|
||||
metadata:
|
||||
annotations:
|
||||
"helm.sh/hook": "pre-upgrade,pre-install"
|
||||
"helm.sh/hook-weight": "-10"
|
||||
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded"
|
||||
```
|
||||
|
||||
패턴 B — Argo CD sync wave + hook:
|
||||
```yaml
|
||||
metadata:
|
||||
annotations:
|
||||
argocd.argoproj.io/sync-wave: "-1"
|
||||
argocd.argoproj.io/hook: Sync
|
||||
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
|
||||
```
|
||||
|
||||
기본:
|
||||
- migration은 sync wave가 app보다 **작은** 값 (먼저 실행)
|
||||
- app Deployment는 wave `0` 또는 그 이상
|
||||
- Helm과 Argo CD를 혼용하는 경우 **한 쪽으로 통일** (둘 다 hook을 걸면 순서가 꼬인다)
|
||||
|
||||
### 6. migration Job 안전 설정
|
||||
모든 migration Job은 다음을 명시한다.
|
||||
|
||||
- `parallelism: 1` — 병렬 실행 금지 (Flyway advisory lock이 막아주지만, Job 수준에서도 명시)
|
||||
- `completions: 1`
|
||||
- `backoffLimit: 0` 또는 작은 값 (1~2) — 실패 시 무한 재시도 금지
|
||||
- `activeDeadlineSeconds` — 타임아웃 (예: 1800)
|
||||
- `ttlSecondsAfterFinished` — 완료 후 자동 정리 (예: 86400)
|
||||
- `restartPolicy: Never`
|
||||
- 이미지는 **digest pinning** (`flyway/flyway@sha256:...`)
|
||||
- `resources.requests/limits` 명시
|
||||
- `securityContext` restricted PSA 준수
|
||||
|
||||
### 7. validate를 먼저, migrate를 나중에
|
||||
운영 절차 기본 순서:
|
||||
1. `flyway info` (pending migration 확인)
|
||||
2. `flyway validate`
|
||||
3. `flyway migrate`
|
||||
4. `flyway info` (결과 확인)
|
||||
5. app rollout
|
||||
|
||||
`validateOnMigrate=true` 기본값이 있더라도, 운영 runbook에서는 validate 단계를 **분리 Job** 또는 **initContainer**로 분리한다. `examples/infra/flyway.md` 참조.
|
||||
|
||||
### 8. migration source는 Git이 source of truth
|
||||
중요한 것은 아래다.
|
||||
|
||||
- versioned migration script (`V__`)
|
||||
- repeatable migration script (`R__`)
|
||||
- migration ordering
|
||||
- schema history table 상태
|
||||
|
||||
기본 금지:
|
||||
- 운영 서버에서 migration 파일 수동 수정
|
||||
- 적용된 migration 파일을 사후 편집 (checksum mismatch)
|
||||
- Flyway schema history table을 사람이 직접 UPDATE/DELETE
|
||||
|
||||
### 9. Flyway undo(U__)는 쓰지 않는다
|
||||
Flyway Community(OSS)는 **U__ 파일을 지원하지 않는다**. Teams/Enterprise에서만 `undo` 명령이 동작한다.
|
||||
|
||||
기본:
|
||||
- undo migration 파일을 만들지 않음
|
||||
- rollback은 forward-only migration + PITR로 수행
|
||||
- 운영 기본은 "다음 migration으로 앞으로 수정"
|
||||
|
||||
### 10. DB backup 전략과 migration 전략을 같이 본다
|
||||
schema 변경이 production에 들어간다면, 같은 변경 계획 안에 아래가 같이 있어야 한다.
|
||||
|
||||
- rollback 가능 여부
|
||||
- 변경 직전 backup 시점 (예: on-demand CNPG `Backup` 실행)
|
||||
- restore 단위 (전체 cluster / logical DB)
|
||||
- PITR 필요 여부 + targetTime 후보
|
||||
- migration 실패 시 중단 지점 (어느 V__에서 멈췄는지)
|
||||
|
||||
### 11. PostgreSQL 운영 기본 백업은 continuous physical backup
|
||||
운영 기본 복구 목표가 cluster-level restore / PITR / standby seed 중 하나면 continuous WAL archiving + base backup이 기본이다.
|
||||
|
||||
도구 선택:
|
||||
- K8s + CNPG → Barman Cloud (내장)
|
||||
- K8s + Crunchy → pgBackRest (내장)
|
||||
- 자체 운영 → pgBackRest 또는 WAL-G
|
||||
|
||||
`pg_dump`는 다음 용도로 제한:
|
||||
- 선택적 logical export
|
||||
- 로컬/테스트 seed
|
||||
- 일부 schema/table 보존
|
||||
- migration 검증용 비교 데이터
|
||||
|
||||
### 12. PITR 필요 여부를 초기에 결정
|
||||
다음 질문에 "예"면 PITR을 우선 검토한다.
|
||||
|
||||
- 잘못된 migration/DDL을 특정 시점 직전으로 되돌려야 하는가
|
||||
- 운영 데이터 손실 허용 시간이 짧은가 (RPO < 1h)
|
||||
- 인증 관련 데이터 정합성이 중요한가
|
||||
|
||||
### 13. schema ownership은 서비스별로 분리
|
||||
기본:
|
||||
- auth-server schema는 auth-server 팀이 소유
|
||||
- keycloak schema는 keycloak이 소유
|
||||
- 공용 schema 남발 금지
|
||||
- "편해서" 하나의 migration 프로젝트로 통합 관리 금지
|
||||
|
||||
### 14. Flyway history table 전략을 먼저 고정
|
||||
초기에 결정:
|
||||
|
||||
- `flyway.table` (기본 `flyway_schema_history`)
|
||||
- `flyway.defaultSchema`
|
||||
- `flyway.schemas`
|
||||
- `flyway.createSchemas`
|
||||
- 필요 시 `flyway.initSql`
|
||||
|
||||
기본:
|
||||
- history table을 service별 schema에 배치 (예: `auth_server.flyway_schema_history`)
|
||||
- 여러 서비스의 history table을 하나의 schema에 몰지 않음
|
||||
|
||||
### 15. baseline / repair는 예외 절차
|
||||
baseline과 repair는 정상 운영 흐름이 아니다.
|
||||
|
||||
허용 예:
|
||||
- legacy DB를 처음 Flyway 관리로 편입 (baseline)
|
||||
- 의도적 migration 수정 후 공식 절차로 checksum 회복 (repair)
|
||||
- history corruption 복구 (repair)
|
||||
|
||||
기본 금지:
|
||||
- CI/CD에서 습관적 baseline/repair
|
||||
- validate 오류를 없애기 위해 무분별하게 repair
|
||||
|
||||
### 16. migration은 forward-only를 기본값으로
|
||||
운영 기본값:
|
||||
- 새 migration으로 앞으로 수정
|
||||
- rollback용 SQL을 미리 기대하지 않음
|
||||
- 실패 시 restore/PITR 또는 다음 migration으로 교정
|
||||
|
||||
### 17. Keycloak DB와 auth-server DB는 따로 본다
|
||||
둘 다 PostgreSQL을 써도 운영 기준은 별도로 둔다.
|
||||
|
||||
- migration 파이프라인 분리
|
||||
- backup/restore 영향도 분리
|
||||
- schema/table ownership 분리
|
||||
- 버전 업그레이드 절차 분리
|
||||
- Keycloak은 자체 migration을 내장하므로 **Flyway로 관리하지 않는다**
|
||||
|
||||
### 18. test-server는 DB를 기본 전제로 두지 않는다
|
||||
test-server가 DB 연결이 없으면 migration 대상 아님, DB secret 불필요, rollout 절차도 DB 의존 없이 단순화된다.
|
||||
|
||||
### 19. destructive migration은 expand → migrate → contract
|
||||
Zero-downtime을 위한 3단계 릴리즈:
|
||||
|
||||
1. **Expand** — 새 컬럼/테이블 추가 (NULL 허용 또는 default 값 있음). 기존 앱 호환.
|
||||
2. **Migrate** — 앱을 새 스키마 기준으로 배포 + 데이터 backfill.
|
||||
3. **Contract** — 기존 컬럼/테이블/제약 제거. 한 릴리즈 이상 뒤.
|
||||
|
||||
각 단계는 **별도 릴리즈**로 나간다. 같은 릴리즈에서 expand와 contract를 같이 하지 않는다.
|
||||
|
||||
인증/권한/토큰 관련 테이블은 특히 보수적으로.
|
||||
|
||||
### 20. DB 변경은 애플리케이션 호환성 윈도우를 고려
|
||||
migration 문서는 다음을 포함한다.
|
||||
|
||||
- 이전 앱 버전과 호환 여부
|
||||
- 새 앱 버전과 호환 여부
|
||||
- 중간 배포 구간에서 허용되는 상태 (N-1 ↔ N 동시 운영 가능 여부)
|
||||
- 롤백 시 DB가 이미 바뀐 상태일 때의 대응
|
||||
|
||||
### 21. 대용량 / long-running DDL은 트랜잭션 밖에서
|
||||
Postgres에서 다음은 트랜잭션 밖에서 실행해야 한다.
|
||||
|
||||
- `CREATE INDEX CONCURRENTLY`
|
||||
- `REINDEX CONCURRENTLY`
|
||||
- `ALTER TYPE ... ADD VALUE` (Postgres 12+에서는 트랜잭션 내에서도 제한적으로 가능)
|
||||
- `VACUUM`
|
||||
|
||||
Flyway에서는 해당 migration 파일 상단에 다음을 적는다:
|
||||
```sql
|
||||
-- flyway:executeInTransaction=false
|
||||
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
|
||||
```
|
||||
|
||||
### 22. restore 테스트 없는 backup/migration 전략 금지
|
||||
다음은 반드시 drill이 있어야 한다.
|
||||
|
||||
- PostgreSQL base backup 복구
|
||||
- WAL/PITR 절차
|
||||
- Flyway 적용 후 실패 시 중단 및 복구 절차
|
||||
- Keycloak/auth-server 개별 DB restore 절차
|
||||
|
||||
## 현재 스택 기본 권장안
|
||||
|
||||
- **auth-server DB**: CNPG `Cluster` 3 instances, Barman Cloud, RPO 5분, Flyway Job으로 migration
|
||||
- **keycloak DB**: CNPG `Cluster` 별도, Keycloak 자체 migration (Flyway 밖)
|
||||
- **test-server**: DB 없음
|
||||
- **migration-flyway**: Kubernetes Job (Helm/Argo hook), 앱 Deployment보다 먼저 실행
|
||||
- **backup**: CNPG Barman continuous WAL + daily base backup, `pg_dump`는 보조
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- app rollout과 migration을 분리 (app startup migration 금지)
|
||||
- Postgres on K8s는 CNPG operator를 기본 후보로
|
||||
- migration Job은 Helm hook 또는 Argo CD sync wave로 app보다 먼저 실행
|
||||
- migration Job은 `parallelism: 1`, `backoffLimit: 0`, digest pinning, restricted PSA
|
||||
- Flyway undo(U__) 파일 만들지 않음 (OSS 미지원)
|
||||
- validate → migrate → app rollout 순서
|
||||
- CNPG Barman Cloud (또는 pgBackRest / WAL-G) 물리 백업 + PITR, `pg_dump`는 보조
|
||||
- schema ownership은 서비스별 분리
|
||||
- destructive migration은 expand → migrate → contract, 여러 릴리즈에 걸쳐
|
||||
- non-transactional DDL은 `-- flyway:executeInTransaction=false`
|
||||
@@ -0,0 +1,304 @@
|
||||
# Flyway 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 PostgreSQL 기반 서비스에서 Flyway를
|
||||
- 어디서 실행할지 (Kubernetes Job)
|
||||
- 어떤 순서로 실행할지 (validate / info / migrate / app rollout)
|
||||
- 어떤 배포 흐름과 맞물릴지 (Helm hook / Argo CD sync-wave)
|
||||
- config를 어떻게 공급할지 (env var + Secret via Vault Secrets Operator)
|
||||
- schema history table을 어떻게 둘지
|
||||
- baseline / repair / out-of-order / undo를 어떻게 다룰지
|
||||
- non-transactional DDL을 어떻게 처리할지
|
||||
를 먼저 고정한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- Flyway를 앱 startup 내부 로직처럼 숨기지 않는다
|
||||
- validate / migrate / repair / baseline의 역할을 분리한다
|
||||
- schema history table을 운영 감사 추적의 일부로 본다
|
||||
- migration Job을 1000+ 서비스 규모에서 재현 가능하게 표준화한다
|
||||
- DB 변경을 애플리케이션 rollout과 분리해 운영한다
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- Flyway `validate`는 적용된 migration과 로컬 migration 사이의 이름/타입/checksum 차이, 로컬에 없는 적용 버전, 아직 적용되지 않은 로컬 버전을 검증한다.
|
||||
- `migrate`는 schema history table이 없으면 자동 생성하고 최신 migration까지 적용한다.
|
||||
- schema history table은 migration 실행 내역, checksum, 성공/실패 상태를 기록하는 audit trail이다.
|
||||
- `repair`는 schema history table을 수정하는 명령이며, 실패한 migration 엔트리 제거, checksum/description/type 재정렬, missing migration 삭제 표시를 수행한다. user object는 정리하지 않는다.
|
||||
- schema history table 기본 이름은 `flyway_schema_history`다.
|
||||
- schema history table 위치는 `table`, `defaultSchema`, `schemas`로 제어할 수 있다.
|
||||
- `createSchemas=false`일 때 history table이 들어갈 schema가 미리 준비되지 않으면 migrate가 실패할 수 있다.
|
||||
- 기존 non-empty schema에 Flyway를 도입할 때 history table이 없으면 `baseline` 또는 `baselineOnMigrate`가 필요할 수 있다.
|
||||
- schema history에는 `Pending`, `Success`, `Missing`, `Out of Order`, `Outdated`, `Superseded`, `Deleted` 등 상태가 기록될 수 있다.
|
||||
- Flyway는 migration 실행 중 schema history table에 **advisory lock**을 걸어 동시 실행을 직렬화한다. 다중 replica Job 수준의 race를 방지한다.
|
||||
- `cleanDisabled`는 Flyway 9 이후 기본 `true`. production에서는 반드시 `true`를 명시한다.
|
||||
- Flyway 8.2+ 에서 `-- flyway:executeInTransaction=false` directive로 migration 파일 단위 트랜잭션 비활성화가 가능하다.
|
||||
- Flyway Community(OSS)는 **undo(U__) migration을 지원하지 않는다.** Undo는 Teams/Enterprise 상용 기능이다.
|
||||
- 환경변수 config 지원: `FLYWAY_URL`, `FLYWAY_USER`, `FLYWAY_PASSWORD`, `FLYWAY_LOCATIONS`, `FLYWAY_SCHEMAS`, `FLYWAY_DEFAULT_SCHEMA`, `FLYWAY_TABLE`, `FLYWAY_BASELINE_ON_MIGRATE`, `FLYWAY_VALIDATE_ON_MIGRATE`, `FLYWAY_CLEAN_DISABLED`, `FLYWAY_OUT_OF_ORDER`, 그 외 `FLYWAY_*`.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. Flyway는 앱 startup이 아니라 독립 실행 단계
|
||||
운영 환경에서 Flyway는 다음 중 하나로만 실행한다.
|
||||
|
||||
- Kubernetes Job (권장)
|
||||
- CI/CD 명시 단계
|
||||
- 운영자 명시 실행 절차
|
||||
|
||||
기본 금지:
|
||||
- 애플리케이션 startup 시 자동 migration
|
||||
- Spring Boot `spring.flyway.enabled=true`로 앱 부팅 경로에 포함
|
||||
- readiness/liveness와 migration 실패를 섞는 구조
|
||||
|
||||
### 2. 기본 순서는 info → validate → migrate → info → app rollout
|
||||
운영 기본 순서:
|
||||
|
||||
1. `flyway info` (pending 확인)
|
||||
2. `flyway validate`
|
||||
3. `flyway migrate`
|
||||
4. `flyway info` (결과 확인)
|
||||
5. 애플리케이션 rollout
|
||||
|
||||
`validateOnMigrate=true`가 기본값이지만, 운영 절차상 validate를 **분리 initContainer** 또는 **사전 단계**로 둔다.
|
||||
|
||||
### 3. 배포 흐름 안에서 app보다 먼저 실행 — 두 가지 패턴
|
||||
|
||||
**패턴 A: Helm hook**
|
||||
```yaml
|
||||
annotations:
|
||||
"helm.sh/hook": "pre-upgrade,pre-install"
|
||||
"helm.sh/hook-weight": "-10"
|
||||
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded"
|
||||
```
|
||||
|
||||
**패턴 B: Argo CD sync-wave**
|
||||
```yaml
|
||||
annotations:
|
||||
argocd.argoproj.io/sync-wave: "-1"
|
||||
argocd.argoproj.io/hook: Sync
|
||||
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
|
||||
```
|
||||
|
||||
기본:
|
||||
- 두 패턴을 혼용하지 않는다 (Argo CD가 Helm chart를 렌더링할 때 Helm hook을 일반 리소스로 취급해 순서가 꼬임)
|
||||
- 배포 도구에 맞춰 한쪽만 사용
|
||||
|
||||
### 4. migration Job 안전 설정 체크리스트
|
||||
Job manifest에 반드시 다음이 있어야 한다.
|
||||
|
||||
- `parallelism: 1`, `completions: 1`
|
||||
- `backoffLimit: 0` 또는 작은 값 (1~2)
|
||||
- `activeDeadlineSeconds` (권장 1800 = 30분, 대형 migration은 더 길게)
|
||||
- `ttlSecondsAfterFinished` (권장 86400 = 1일)
|
||||
- `restartPolicy: Never`
|
||||
- 이미지 digest pinning (`flyway/flyway@sha256:...`)
|
||||
- `imagePullPolicy: IfNotPresent`
|
||||
- `resources.requests/limits`
|
||||
- `securityContext`: `runAsNonRoot: true`, `readOnlyRootFilesystem: true`, `capabilities: drop: [ALL]`
|
||||
- Pod-level `seccompProfile: RuntimeDefault`
|
||||
- `fsGroup` 명시 (필요 시)
|
||||
|
||||
### 5. config는 환경변수 + Secret
|
||||
Flyway CLI는 `FLYWAY_*` 환경변수를 읽는다. Secret은 Vault Secrets Operator(VSO) 또는 External Secrets Operator를 통해 클러스터에 동기화된 `Secret`에서 주입한다.
|
||||
|
||||
필수 env var:
|
||||
- `FLYWAY_URL` — `jdbc:postgresql://host:5432/db`
|
||||
- `FLYWAY_USER`
|
||||
- `FLYWAY_PASSWORD` — Secret에서 주입
|
||||
- `FLYWAY_LOCATIONS` — `filesystem:/flyway/sql`
|
||||
|
||||
운영 권장 env var:
|
||||
- `FLYWAY_SCHEMAS` — 대상 schema
|
||||
- `FLYWAY_DEFAULT_SCHEMA` — history table 위치
|
||||
- `FLYWAY_TABLE` — 기본 `flyway_schema_history`
|
||||
- `FLYWAY_VALIDATE_ON_MIGRATE=true`
|
||||
- `FLYWAY_BASELINE_ON_MIGRATE=false` (운영 기본값)
|
||||
- `FLYWAY_CLEAN_DISABLED=true` (production 필수)
|
||||
- `FLYWAY_OUT_OF_ORDER=false`
|
||||
- `FLYWAY_MIXED=false`
|
||||
|
||||
### 6. `cleanDisabled=true`는 production 필수
|
||||
`flyway clean`은 모든 object를 drop 하는 파괴적 명령이다.
|
||||
|
||||
- production: `FLYWAY_CLEAN_DISABLED=true` 반드시 명시 (Flyway 9+ 기본값이지만 명시적으로 선언)
|
||||
- dev/test: 필요 시 `false` 허용, 단 접근 권한 분리
|
||||
|
||||
### 7. migration SQL은 ConfigMap 또는 이미지 레이어로
|
||||
옵션:
|
||||
- **ConfigMap**: 서비스 manifest와 함께 Argo CD로 관리. small/medium migration set에 적합. ConfigMap 1MiB 제한 주의.
|
||||
- **이미지 레이어**: 서비스 repo에서 migration SQL을 Docker image로 빌드하고 Flyway image와 합쳐 사용. 대규모 migration set에 적합.
|
||||
|
||||
기본:
|
||||
- 두 방식 모두 Git이 source of truth
|
||||
- 운영 서버에서 `kubectl edit configmap`으로 migration 편집 금지
|
||||
|
||||
### 8. schema history table은 운영 감사 추적의 일부
|
||||
수동 UPDATE / DELETE 금지. 위치는 명시적으로 결정.
|
||||
|
||||
기본:
|
||||
- service별 schema를 `FLYWAY_DEFAULT_SCHEMA`로 지정 (예: `auth_server`)
|
||||
- history table 이름은 기본값 `flyway_schema_history` 유지
|
||||
- 여러 서비스의 history table을 하나의 schema에 몰지 않음
|
||||
|
||||
### 9. `createSchemas=false`면 history schema를 사전 준비
|
||||
`createSchemas=false`를 쓰면 history table이 들어갈 schema를 별도 준비해야 한다.
|
||||
|
||||
기본:
|
||||
- `FLYWAY_INIT_SQL`로 `CREATE SCHEMA IF NOT EXISTS` 지시 가능
|
||||
- 또는 CNPG `Cluster.bootstrap.initdb.postInitSQL`에서 schema 사전 생성
|
||||
- 생성 책임이 누구인지 문서화
|
||||
|
||||
### 10. baseline은 예외 절차
|
||||
허용 예:
|
||||
- legacy DB를 처음 Flyway 관리로 편입
|
||||
- 기존 non-empty schema를 Flyway에 편입할 때
|
||||
|
||||
기본 금지:
|
||||
- 새 프로젝트인데 baseline부터 쓰기
|
||||
- 운영 배포 파이프라인에서 습관적으로 baseline 사용
|
||||
|
||||
### 11. `baselineOnMigrate`는 기본값 아님
|
||||
`baselineOnMigrate=true`는 도입/전환 시 편의를 줄 수 있지만, 운영 기본값으로 두지 않는다.
|
||||
|
||||
이유:
|
||||
- 예상치 못한 기존 schema를 "정상 상태"처럼 받아들일 수 있다
|
||||
- 실수 탐지력이 떨어진다
|
||||
|
||||
`FLYWAY_BASELINE_ON_MIGRATE=false`로 명시.
|
||||
|
||||
### 12. `repair`는 예외 절차
|
||||
허용 예:
|
||||
- 의도적으로 migration 파일을 수정했고 checksum 정렬이 필요
|
||||
- missing migration을 문서화된 절차로 정리
|
||||
- failed repeatable migration 이후 history 정리
|
||||
|
||||
기본 금지:
|
||||
- validate 오류가 나면 원인 분석 없이 바로 repair
|
||||
- CI/CD에서 습관적으로 repair 실행
|
||||
|
||||
### 13. `repair`는 user object를 고쳐주지 않는다
|
||||
repair는 schema history table만 정리한다. 실패한 migration이 남긴 DB object 정리, 불완전한 DDL/DML 정리는 별도 절차로 수행해야 한다.
|
||||
|
||||
### 14. 적용된 migration 파일은 수정 금지
|
||||
이유:
|
||||
- checksum mismatch
|
||||
- 재현 불가
|
||||
- 환경 간 drift
|
||||
|
||||
대응:
|
||||
- 새 migration으로 교정
|
||||
- 정말 예외적인 수정만 공식 repair 절차와 함께 수행
|
||||
|
||||
### 15. out-of-order는 기본 금지
|
||||
Out-of-order migration은 전체 migration history를 다시 실행할 때 다른 결과를 만들 수 있다.
|
||||
|
||||
기본:
|
||||
- `FLYWAY_OUT_OF_ORDER=false`
|
||||
- 뒤늦게 빠진 migration을 넣는 방식을 기본값으로 두지 않음
|
||||
- 예외 허용 시 영향 범위 검토 문서 필수
|
||||
|
||||
### 16. repeatable migration(R__)은 목적 제한
|
||||
Repeatable migration은 다음 용도에 제한한다.
|
||||
|
||||
- view 정의
|
||||
- function / procedure
|
||||
- trigger 재생성
|
||||
- reference / static data refresh
|
||||
|
||||
기본 금지:
|
||||
- 순서가 중요한 핵심 schema change를 repeatable로 남발
|
||||
- versioned migration 대신 repeatable로 대체
|
||||
|
||||
### 17. Undo(U__) migration은 만들지 않는다
|
||||
Flyway Community(OSS)는 undo를 지원하지 않는다.
|
||||
|
||||
- U__ 파일을 repo에 두지 않음 (오해 유발)
|
||||
- rollback은 forward-only 새 migration + PITR로 대응
|
||||
|
||||
### 18. locations는 environment별로 흔들지 않는다
|
||||
`migrate`와 `repair`는 같은 `locations` 전제를 가져야 한다.
|
||||
|
||||
기본:
|
||||
- env마다 location 구조가 달라지지 않게 유지
|
||||
- 운영과 개발에서 전혀 다른 migration set을 쓰지 않음
|
||||
- env별 변수는 `placeholders`(`FLYWAY_PLACEHOLDERS_*`)로 분리
|
||||
|
||||
### 19. migration은 서비스 소유권 단위로 분리
|
||||
기본:
|
||||
- auth-server는 auth-server migration set
|
||||
- keycloak은 keycloak 고유 migration (사실 Keycloak은 내부 migration을 사용하므로 Flyway 대상이 아님)
|
||||
- 공용 migration 프로젝트 금지
|
||||
|
||||
### 20. migration naming / versioning
|
||||
기본:
|
||||
- versioned: `V<N>__<snake_case>.sql`, N은 증가하는 정수 또는 점표기(예: `V12__`, `V1.2.3__`)
|
||||
- repeatable: `R__<snake_case>.sql`
|
||||
- 이름은 변경 의도를 드러나게 작성
|
||||
|
||||
예:
|
||||
- `V42__add_refresh_token_audit_columns.sql`
|
||||
- `R__refresh_user_views.sql`
|
||||
|
||||
### 21. destructive change는 expand → migrate → contract
|
||||
`db-and-migration.md` #19 참조. Flyway 입장에서 각 단계는 **별도 릴리즈**의 versioned migration으로 나간다.
|
||||
|
||||
### 22. non-transactional DDL은 `executeInTransaction=false`
|
||||
Postgres에서 트랜잭션 밖 실행이 필요한 DDL:
|
||||
|
||||
- `CREATE INDEX CONCURRENTLY`
|
||||
- `REINDEX CONCURRENTLY`
|
||||
- `ALTER TYPE ... ADD VALUE`
|
||||
- `VACUUM`
|
||||
|
||||
migration 파일 상단:
|
||||
```sql
|
||||
-- flyway:executeInTransaction=false
|
||||
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
|
||||
```
|
||||
|
||||
기본:
|
||||
- 이런 DDL은 **전용 migration 파일**로 분리 (다른 statement와 섞지 않음)
|
||||
- runtime 추정치 주석
|
||||
- low-traffic window로 배포 일정 조정
|
||||
|
||||
### 23. rollback은 Flyway 명령에 기대지 않는다
|
||||
운영 기본 rollback:
|
||||
|
||||
- 새 migration으로 수정
|
||||
- PostgreSQL PITR (CNPG bootstrap.recovery)
|
||||
- 애플리케이션 버전 rollback + DB 호환 윈도우 유지 (expand-contract의 효과)
|
||||
|
||||
rollout undo가 DB schema rollback을 대신하지 않는다.
|
||||
|
||||
### 24. 현재 스택 기준 기본 권장안
|
||||
|
||||
- **auth-server**
|
||||
- Flyway Job (Helm hook 또는 Argo sync-wave)
|
||||
- `FLYWAY_DEFAULT_SCHEMA=auth_server`
|
||||
- validate → migrate → app rollout
|
||||
- digest pinning
|
||||
- **keycloak**
|
||||
- Keycloak 자체 migration 사용, Flyway 대상 아님
|
||||
- **test-server**
|
||||
- DB가 없으면 Flyway 대상 아님
|
||||
- **운영 절차**
|
||||
- repair/baseline은 예외 승인 절차
|
||||
- applied migration 수정 금지
|
||||
- CLEAN_DISABLED=true 필수
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- Flyway는 독립 실행 단계 (Kubernetes Job)
|
||||
- info → validate → migrate → info → app rollout
|
||||
- 배포 흐름 내 순서는 Helm hook 또는 Argo CD sync-wave 중 하나로 통일
|
||||
- Job: `parallelism: 1`, `backoffLimit: 0`, `ttlSecondsAfterFinished`, digest pinning, restricted PSA
|
||||
- config는 `FLYWAY_*` env var + Secret (VSO / ESO)
|
||||
- `FLYWAY_CLEAN_DISABLED=true` 필수, `FLYWAY_BASELINE_ON_MIGRATE=false`, `FLYWAY_OUT_OF_ORDER=false`
|
||||
- schema history table 위치를 `FLYWAY_DEFAULT_SCHEMA`로 명시
|
||||
- baseline / repair / out-of-order는 예외 절차
|
||||
- applied migration 수정 금지
|
||||
- Undo(U__) 파일 만들지 않음 (OSS 미지원)
|
||||
- non-transactional DDL은 `-- flyway:executeInTransaction=false`로 파일 단위 분리
|
||||
- service별 migration ownership 분리
|
||||
- destructive migration은 expand → migrate → contract
|
||||
@@ -0,0 +1,224 @@
|
||||
# K3s-specific 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 일반 Kubernetes 표준과 **분리**해서, K3s 운영에서만 발생하는 특수성을 고정한다.
|
||||
|
||||
목표:
|
||||
|
||||
- K3s packaged component (`coredns`, `traefik`, `local-storage`, `metrics-server`, `servicelb`)를 일반 manifest처럼 관리하는 실수를 막는다
|
||||
- `/var/lib/rancher/k3s/server/manifests`를 source-of-truth로 쓰는 실수를 막는다
|
||||
- 멀티 server HA 환경에서 `critical configuration value mismatch` join 실패를 예방한다
|
||||
- embedded registry mirror(Spegel)의 네트워크·버전 게이트를 정확히 이해한다
|
||||
- 1000+ 서비스 prod 스케일에서 K3s의 어떤 기능을 켜고 어떤 기능을 외부로 뺄지 기준을 박는다
|
||||
|
||||
## 공식 의미 (근거 URL 포함)
|
||||
|
||||
- K3s packaged component: `coredns`, `traefik`, `local-storage`, `metrics-server` (매니페스트 파일 기반) + `servicelb`(매니페스트 없이 `--disable`만 가능).
|
||||
- AddOn auto-deploy: `/var/lib/rancher/k3s/server/manifests` 하위 파일은 server 시작 시 + 파일 변경 시 자동 apply. packaged component는 K3s가 재기록하므로 직접 수정 금지.
|
||||
- multi-server 유저 AddOn은 서버 간 자동 동기화되지 **않는다**.
|
||||
- K3s 설정: `/etc/rancher/k3s/config.yaml` + `/etc/rancher/k3s/config.yaml.d/*.yaml` drop-in.
|
||||
- critical 값 (cluster-cidr / service-cidr / cluster-dns / cluster-domain / disable 세트 / CNI / embedded-registry 활성화)이 서버 간 불일치면 `critical configuration value mismatch` join 실패.
|
||||
- packaged Helm component(`traefik` 등) 커스터마이징은 `HelmChartConfig` (apiVersion `helm.cattle.io/v1`).
|
||||
- K3s 기본 local storage는 Rancher Local Path Provisioner (`local-path` StorageClass, node-local, not replicated).
|
||||
- **embedded registry mirror (Spegel)**: 기본 비활성. 활성화 시 노드 간 TCP 5001 (p2p gossip) + TCP 6443 (registry + supervisor)이 reachable해야 한다. 출처: `https://docs.k3s.io/installation/registry-mirror` — "all nodes must be able to reach each other via their internal IP addresses, on TCP ports 5001 and 6443".
|
||||
- K3s 이미지 import: `/var/lib/rancher/k3s/agent/images/*.tar{,.zst,.gz}`.
|
||||
- K3s는 기본적으로 network policy enforcer (kube-router 기반)를 포함한다. 외부 CNI(Cilium 등) 사용 시 `--disable-network-policy` + `--flannel-backend=none` 조합 필요.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. K3s 전용 규칙은 별도 문서로 유지
|
||||
|
||||
일반 Kubernetes 표준 문서에 K3s 특수성을 흩뿌리지 않는다. 분리 범주:
|
||||
|
||||
- packaged component
|
||||
- AddOn auto-deploy
|
||||
- config.yaml / config.yaml.d
|
||||
- local-path provisioner
|
||||
- embedded registry mirror
|
||||
- critical server flags
|
||||
- Helm component customization
|
||||
|
||||
### 2. packaged component는 “편의 기능”, 직접 수정 절대 금지
|
||||
|
||||
관리 대상:
|
||||
|
||||
- `coredns`
|
||||
- `traefik`
|
||||
- `local-storage`
|
||||
- `metrics-server`
|
||||
- `servicelb` (manifest 없음, flag로만 제어)
|
||||
|
||||
금지:
|
||||
|
||||
- `/var/lib/rancher/k3s/server/manifests/traefik.yaml` 직접 edit
|
||||
- packaged manifest를 Git SoT로 관리
|
||||
- 재시작 후 overwrite되는 파일에 운영 커스터마이징 저장
|
||||
|
||||
### 3. packaged component 유지/비활성은 cluster bootstrap 때 박는다
|
||||
|
||||
1000-서비스 prod 스케일에서 현재 기준:
|
||||
|
||||
| component | prod 기본 | 이유 |
|
||||
|----------------|-----------|-------------------------------------------------------------|
|
||||
| `traefik` | disable | ingress-nginx / Envoy Gateway로 교체. Traefik은 dev만. |
|
||||
| `servicelb` | disable | MetalLB L2/BGP 또는 외부 LB. klipper는 노드 80/443 점유. |
|
||||
| `local-storage`| disable | Longhorn / Ceph RBD / CSI. node-local은 DR 불가. |
|
||||
| `metrics-server`| keep | HPA + `kubectl top` 전제. 대체 pipeline 준비되면 교체 가능. |
|
||||
| `coredns` | keep | 교체는 특수 케이스. node-local dns cache는 별도로 추가. |
|
||||
| network policy | 상황별 | Cilium 도입 시 disable. 기본 kube-router 유지도 가능. |
|
||||
|
||||
### 4. server critical config는 Git에서 단일 파일로 관리
|
||||
|
||||
`/etc/rancher/k3s/config.yaml`이 Git의 inventory repo (Ansible / Fleet / CI)에서 push된다.
|
||||
서버별 ad-hoc 수정 금지. critical 값 mismatch는 **join 실패**로 직결된다.
|
||||
|
||||
일치해야 하는 값:
|
||||
|
||||
- `cluster-cidr`, `service-cidr`, `cluster-dns`, `cluster-domain`
|
||||
- `disable` 세트
|
||||
- `flannel-backend` / `disable-network-policy`
|
||||
- `embedded-registry` 활성화 여부
|
||||
- `datastore-endpoint` (etcd / external DB)
|
||||
|
||||
### 5. CLI argument보다 config file 우선
|
||||
|
||||
재현성 / diff / multi-node 동기화를 위해 server/agent 플래그는 모두 `config.yaml`로.
|
||||
`/etc/rancher/k3s/config.yaml.d/*.yaml` drop-in은 역할별 파일 분리(예: `10-networking.yaml`, `20-audit.yaml`)에 사용.
|
||||
|
||||
### 6. `/var/lib/rancher/k3s/server/manifests`는 apply sink, SoT 아님
|
||||
|
||||
- 운영 SoT = Git (+ Kustomize / ArgoCD / Flux)
|
||||
- 이 디렉터리는 bootstrap addon에만 한정 (예: `k3s-addons-disabled.yaml` placeholder)
|
||||
- 서버별로 다른 파일을 두고 "알아서 맞겠지"는 금지
|
||||
- `.skip` 파일은 **임시** 비활성화 용. 장기 disable은 `--disable` 플래그로.
|
||||
|
||||
### 7. multi-server user AddOn은 Git push, 로컬 scp 금지
|
||||
|
||||
K3s는 user AddOn을 서버 간 동기화하지 않는다. 멀티 server 환경에서 AddOn을 쓰려면:
|
||||
|
||||
- GitOps 컨트롤러(ArgoCD/Flux)가 apply
|
||||
- 또는 Ansible/Fleet이 단일 server 노드에만 drop
|
||||
- 또는 완전히 포기하고 `kubectl apply`로만 관리 (권장)
|
||||
|
||||
### 8. packaged Helm component 커스터마이징은 `HelmChartConfig`
|
||||
|
||||
traefik 유지가 불가피할 때:
|
||||
|
||||
```yaml
|
||||
apiVersion: helm.cattle.io/v1
|
||||
kind: HelmChartConfig
|
||||
metadata:
|
||||
name: traefik
|
||||
namespace: kube-system
|
||||
spec:
|
||||
valuesContent: |-
|
||||
<override values>
|
||||
```
|
||||
|
||||
- `metadata.name` / `namespace`는 대응 `HelmChart`와 반드시 일치
|
||||
- 민감 값은 `valuesSecrets`로 Secret 참조 (valuesContent에 하드코딩 금지)
|
||||
- HelmChartConfig 자체는 Git 관리
|
||||
|
||||
### 9. local-path provisioner는 dev/test 한정
|
||||
|
||||
Rancher Local Path Provisioner = node-local hostPath. 특성:
|
||||
|
||||
- ReadWriteOnce only
|
||||
- 노드 장애 시 데이터 접근 불가
|
||||
- 백업/DR 불가 (StorageClass 레벨 스냅샷 없음)
|
||||
- binding mode = WaitForFirstConsumer (Pod가 뜰 때 PV 생성)
|
||||
|
||||
기준:
|
||||
|
||||
- dev/test StatefulSet의 PVC 기본값으로만 허용
|
||||
- prod의 DB / Vault / MinIO / Kafka / etcd backup target에 절대 사용 금지
|
||||
- prod storage는 **Longhorn (K3s 권장) / Ceph RBD / 외부 CSI** 중 택1
|
||||
|
||||
### 10. metrics-server는 유지 기본값
|
||||
|
||||
HPA v2 metrics, `kubectl top`, VPA, kube-state-metrics 연동 모두가 전제. disable 시 Prometheus Adapter 등 대체 pipeline을 먼저 준비한 뒤에만 꺼야 한다.
|
||||
|
||||
### 11. traefik / servicelb는 포트 점유 + 노드 노출 전략을 같이 본다
|
||||
|
||||
- `servicelb` (klipper) = 모든 노드가 80/443 HostPort로 열림. prod에서는 거의 항상 disable + MetalLB 또는 외부 LB.
|
||||
- `traefik` 유지 시 IngressClass / Middleware / EntryPoint 세 레이어가 전부 K3s 관리. prod에서는 disable + `ingress-nginx` DaemonSet 또는 Envoy Gateway Deployment.
|
||||
|
||||
### 12. network policy controller 충돌
|
||||
|
||||
- 기본: K3s 내장 kube-router 기반 enforcer
|
||||
- Cilium / Calico 도입 시: `--flannel-backend=none` + `--disable-network-policy` + `--disable=servicelb`
|
||||
- 도입 계획은 클러스터 bootstrap 결정 사항 (리빌드 없이 swap 불가에 가까움)
|
||||
|
||||
### 13. embedded registry mirror (Spegel): 명시적 opt-in + 네트워크 요구사항
|
||||
|
||||
- 기본 **비활성**
|
||||
- 활성화 방법: `/etc/rancher/k3s/config.yaml`에 `embedded-registry: true` + `registries.yaml`에 mirror 설정
|
||||
- **네트워크 요구사항** (공식): 모든 노드가 서로 **TCP 5001 (p2p gossip) + TCP 6443 (local registry + supervisor)**에 도달 가능해야 한다. firewall / security group에서 해당 포트 오픈 필수.
|
||||
- 활성화 대상:
|
||||
- airgap / 반-airgap 환경
|
||||
- 이미지 pull bottleneck이 심한 대규모 배포
|
||||
- external registry 의존을 낮춰야 하는 환경
|
||||
- 클러스터 범위 기능이므로 **모든 server/agent에 동일 적용**
|
||||
|
||||
### 14. 이미지 import / airgap 전략
|
||||
|
||||
- 평상시: registry pull (internal mirror 선호)
|
||||
- airgap: `/var/lib/rancher/k3s/agent/images/*.tar{,.zst,.gz}` 사용, import 절차를 runbook에 명시
|
||||
- 이미지 import는 agent startup 때만 로드됨 → 런타임 교체는 re-push 필요
|
||||
|
||||
### 15. K3s version gating을 항상 확인
|
||||
|
||||
다음 기능은 버전에 따라 동작/옵션이 바뀌므로, 업그레이드 전 CHANGELOG 확인 필수:
|
||||
|
||||
- embedded registry mirror (Spegel)
|
||||
- image pre-import
|
||||
- `HelmChartConfig` schema
|
||||
- `disable-helm-controller` 동작
|
||||
- etcd snapshot / S3 backup 옵션
|
||||
|
||||
### 16. K3s-specific 예외는 component 문서보다 먼저 확정
|
||||
|
||||
이 문서에서 박고 내려가야 하는 결정:
|
||||
|
||||
- traefik 유지/비활성
|
||||
- servicelb 유지/비활성
|
||||
- local-storage 유지 범위 (env별)
|
||||
- metrics-server 유지
|
||||
- network policy controller 선택
|
||||
- embedded registry mirror 사용 여부
|
||||
|
||||
그 다음에 keycloak / vault / minio / ingress / storage 문서로 내려간다.
|
||||
|
||||
### 17. `kubectl apply --server-side` 기본 사용
|
||||
|
||||
K3s도 SSA 지원. ArgoCD / Flux / CI 모두 `--server-side --field-manager=<id>` 기본. last-applied-configuration annotation 2MB 한계 회피 + multi-controller ownership 명시.
|
||||
|
||||
### 18. etcd snapshot은 K3s 고유 메커니즘 사용
|
||||
|
||||
- embedded etcd면 `k3s etcd-snapshot` CLI 또는 `--etcd-snapshot-*` config
|
||||
- S3 업로드 설정은 `/etc/rancher/k3s/config.yaml`에 선언
|
||||
- 외부 datastore(PostgreSQL/MySQL) 사용 시 backup은 해당 DB 레이어에서 따로
|
||||
|
||||
## 현재 스택 기본 권장안 (prod)
|
||||
|
||||
- `traefik`: disable, ingress-nginx + cert-manager로 교체
|
||||
- `servicelb`: disable, MetalLB (L2 또는 BGP)로 교체
|
||||
- `local-storage`: disable (prod), dev/staging 만 유지. Longhorn으로 교체
|
||||
- `metrics-server`: keep (HPA 전제)
|
||||
- `network policy`: 현 단계 kube-router 유지, Cilium 도입은 별 RFC
|
||||
- `embedded registry mirror`: off (현재 airgap 아님), 옵션으로 남김
|
||||
- `etcd snapshot`: S3 업로드 활성, 6시간 주기, 72시간 retention
|
||||
- `HelmChartConfig`: traefik 유지 경로를 쓰지 않으므로 현재 미사용
|
||||
- apply 방식: `kubectl apply --server-side --field-manager=argocd`
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- K3s 전용 규칙은 별도 문서
|
||||
- packaged component 직접 수정 금지 (HelmChartConfig / disable만)
|
||||
- `manifests/`는 SoT 아님
|
||||
- critical config는 Git 단일 파일, 서버 간 동일
|
||||
- local-path는 dev/test만
|
||||
- embedded registry mirror는 TCP 5001 + 6443 reachability가 전제
|
||||
- prod에서 traefik/servicelb/local-storage 전부 disable이 기본
|
||||
- Server-Side Apply가 GitOps 기본
|
||||
@@ -0,0 +1,227 @@
|
||||
# Keycloak 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 Kubernetes 환경에서 Keycloak 26+ (Quarkus distribution)을 1000+ 서비스의 ID 브로커로 운영하기 위한 기준을 고정한다.
|
||||
|
||||
- 빌드/실행 두 단계(`kc.sh build` → `kc.sh start --optimized`)를 전제한다
|
||||
- Hostname v2, proxy-headers, management port 9000, Infinispan 캐시를 명시한다
|
||||
- 단일 Deployment 수제 배포 대신 **Keycloak Operator**를 1차 권장 경로로 둔다
|
||||
- DB / 캐시 / probe / Ingress / RealmImport 를 YAML이 아닌 "설계 결정"으로 먼저 고정한다
|
||||
- auth-server(도메인 위임)와 Keycloak(IdP)의 ownership 경계를 분리한다
|
||||
|
||||
## 공식 의미 (Keycloak 26+ 기준)
|
||||
|
||||
- 운영 실행 방식은 **두 단계**다. `kc.sh build`가 Quarkus augmentation을 수행해 optimized 이미지를 만들고, `kc.sh start --optimized`가 그 이미지를 기동한다. 빌드 시 configuration은 런타임에 변경 불가능하다.
|
||||
- **`--proxy` 옵션은 v24에서 deprecated, v26에서 제거되었다.** 대체는 `--proxy-headers=xforwarded` 또는 `--proxy-headers=forwarded`다.
|
||||
- **Hostname v2**가 기본값이며 `--hostname`은 full URL을 받는다. v24+ 이후 `hostname-url`, `hostname-path`, `hostname-port`는 제거되었다. admin 전용 주소는 `--hostname-admin`으로 지정한다.
|
||||
- `--hostname-strict`의 production 기본값은 `true`다. `--hostname-backchannel-dynamic`은 기본 `false`다.
|
||||
- HTTPS 종료를 Ingress/LB가 하면 Keycloak은 `KC_HTTP_ENABLED=true`로 HTTP를 수신한다.
|
||||
- DB는 `KC_DB=postgres`, `KC_DB_URL`은 **JDBC URL**(`jdbc:postgresql://host:5432/db`) 형식이다.
|
||||
- **Management interface는 기본 포트 `9000`**에서 제공되고, `/health`, `/health/started`, `/health/ready`, `/health/live`, `/metrics`를 호스팅한다. Pod probe와 Prometheus scrape는 모두 9000 대상이다.
|
||||
- Production cache type 기본은 `ispn`(Infinispan distributed). **cache-stack 기본값이 `kubernetes`(DNS_PING)에서 v25부터 `jdbc-ping`으로 바뀌었다.** Operator가 관리하는 StatefulSet은 Raft-less 클러스터링을 jdbc-ping으로 수행한다.
|
||||
- Operator가 생성하는 워크로드는 **StatefulSet**이다 (pod ordering이 Infinispan discovery와 맞물린다). 사용자 수제 YAML에서도 Operator 경로가 1차 권장이다.
|
||||
- `KeycloakRealmImport` CR은 Keycloak server가 준비된 후 realm JSON을 server side로 import하는 1회성 Job을 생성한다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 운영 실행은 `start --optimized` 두 단계
|
||||
|
||||
빌드 단계에서 feature/db/health/metrics를 굽고, 실행 단계에서 runtime config만 주입한다.
|
||||
|
||||
기본:
|
||||
- Dockerfile에서 `RUN /opt/keycloak/bin/kc.sh build`로 optimized 이미지 생성
|
||||
- 컨테이너 CMD는 `kc.sh start --optimized`
|
||||
- runtime-only config: hostname, DB URL/credential, log level
|
||||
|
||||
기본 금지:
|
||||
- `start-dev` 운영 사용
|
||||
- `start` 단독 실행(build 없이 매 기동마다 augmentation)
|
||||
|
||||
### 2. `--proxy-headers` 사용, `--proxy` 금지
|
||||
|
||||
Keycloak 26에서 `--proxy`는 제거되었다.
|
||||
|
||||
기본:
|
||||
- HTTPS 종료 proxy 뒤: `KC_PROXY_HEADERS=xforwarded` (nginx, Traefik, ingress-nginx 등)
|
||||
- RFC 7239 지원 proxy: `KC_PROXY_HEADERS=forwarded`
|
||||
- proxy가 Host / X-Forwarded-* 를 **덮어쓰도록** 고정
|
||||
|
||||
기본 금지:
|
||||
- `KC_PROXY=edge|reencrypt|passthrough` 등 legacy 옵션
|
||||
|
||||
### 3. Hostname v2: full URL로 고정
|
||||
|
||||
기본:
|
||||
- `KC_HOSTNAME=https://auth.example.com` (full URL)
|
||||
- Admin Console 분리: `KC_HOSTNAME_ADMIN=https://admin-auth.example.com`
|
||||
- `KC_HOSTNAME_STRICT=true` (production 기본 유지)
|
||||
- `KC_HOSTNAME_BACKCHANNEL_DYNAMIC=false` (기본값; 다중 cluster federation일 때만 true 검토)
|
||||
|
||||
기본 금지:
|
||||
- 제거된 옵션 사용: `KC_HOSTNAME_URL`, `KC_HOSTNAME_PATH`, `KC_HOSTNAME_PORT`
|
||||
- hostname 없이 요청 헤더에서 해석되도록 방치
|
||||
|
||||
### 4. HTTPS는 Ingress/LB에서 종료, Pod는 HTTP
|
||||
|
||||
Pod 내부에서 TLS 재암호화가 필요 없으면 Pod는 HTTP로 수신한다.
|
||||
|
||||
기본:
|
||||
- `KC_HTTP_ENABLED=true`, `KC_HTTP_PORT=8080`
|
||||
- Ingress가 TLS 종료 + proxy-header 주입
|
||||
- passthrough TLS가 필요한 보안 요구가 있을 때만 `KC_HTTPS_*` 경로 채택
|
||||
|
||||
### 5. DB는 외부 PostgreSQL + JDBC URL
|
||||
|
||||
기본:
|
||||
- `KC_DB=postgres`
|
||||
- `KC_DB_URL=jdbc:postgresql://keycloak-db-rw:5432/keycloak` (CloudNativePG `-rw` RW endpoint 권장)
|
||||
- `KC_DB_USERNAME`, `KC_DB_PASSWORD` → Secret `secretKeyRef`
|
||||
- Keycloak schema와 auth-server schema는 **다른 DB 또는 다른 database**로 분리
|
||||
|
||||
기본 금지:
|
||||
- 내장 H2 (`dev-file`, `dev-mem`) 운영
|
||||
- root/superuser credential 사용
|
||||
- Keycloak DB에 auth-server migration 수행
|
||||
|
||||
### 6. Management port 9000은 외부 비공개
|
||||
|
||||
기본:
|
||||
- Pod containerPort 9000 (`KC_HTTP_MANAGEMENT_PORT=9000`)
|
||||
- Service에 9000 expose하되 Ingress 대상 제외
|
||||
- probe는 9000 대상: `/health/started`, `/health/ready`, `/health/live`
|
||||
- Prometheus scrape는 내부 scraper가 9000/`/metrics`에 직접 접근
|
||||
|
||||
### 7. Probe timing은 Keycloak 기동 특성에 맞춘다
|
||||
|
||||
Keycloak은 JVM + Quarkus + Infinispan + DB migration으로 cold start가 30~120초다.
|
||||
|
||||
기본:
|
||||
- `startupProbe`: `/health/started`, `periodSeconds: 5`, `failureThreshold: 60` → 최대 5분 유예
|
||||
- `readinessProbe`: `/health/ready`, `periodSeconds: 10`, `failureThreshold: 3`
|
||||
- `livenessProbe`: `/health/live`, `periodSeconds: 30`, `failureThreshold: 3`, `initialDelaySeconds: 60`
|
||||
|
||||
### 8. Cache: Infinispan + 버전별 stack 기본값 인지
|
||||
|
||||
v25+ 기본 stack은 **`jdbc-ping`**이다. DB를 discovery 매체로 쓰므로 headless service / ServiceAccount RBAC가 필요 없다.
|
||||
|
||||
기본:
|
||||
- Operator 관리 클러스터: `KC_CACHE=ispn`, `KC_CACHE_STACK=jdbc-ping` (명시)
|
||||
- 수제 StatefulSet에서 headless service 경유 discovery를 쓰려면 `KC_CACHE_STACK=kubernetes` (DNS_PING) 선택
|
||||
- local mode 운영 금지 (`KC_CACHE=local`은 single replica 테스트 전용)
|
||||
|
||||
### 9. Operator 경로를 1차 권장으로
|
||||
|
||||
1000+ 서비스 규모에서 realm import, CR 기반 롤아웃, cache stack 자동 설정, StatefulSet 관리를 Operator가 담당한다.
|
||||
|
||||
기본:
|
||||
- `Keycloak` CR + `KeycloakRealmImport` CR 조합
|
||||
- OLM(OperatorHub) 또는 공식 manifest 설치
|
||||
- 수제 StatefulSet 유지보수는 Operator 기능이 부족할 때만 허용
|
||||
|
||||
### 10. 공개 경로 최소화
|
||||
|
||||
Ingress에 허용하는 기본 경로:
|
||||
- `/realms/` — OIDC / SAML endpoint
|
||||
- `/resources/` — Keycloak theme / JS
|
||||
- `/.well-known/` — OIDC discovery, JWKS
|
||||
- `/js/` — Keycloak adapter JS (필요 시)
|
||||
|
||||
기본 금지:
|
||||
- `/admin/` 외부 공개 (별도 admin host 경유)
|
||||
- `/metrics`, `/health*` 외부 공개
|
||||
- `/` 전체 wildcard 공개
|
||||
|
||||
### 11. Admin Console은 별도 host로 분리
|
||||
|
||||
Admin 접근은 일반 SSO host와 다른 경로로 둔다.
|
||||
|
||||
기본:
|
||||
- `KC_HOSTNAME_ADMIN=https://admin-auth.example.com`
|
||||
- Admin host는 사내 IP 화이트리스트 / VPN / OIDC forward-auth로 추가 보호
|
||||
- production에서 `/admin/` 을 SSO 공용 host에 노출 금지
|
||||
|
||||
### 12. Realm은 `KeycloakRealmImport` CR로 선언적 관리
|
||||
|
||||
기본:
|
||||
- realm JSON은 Git에 보관
|
||||
- `KeycloakRealmImport` CR이 Job을 생성해 server-side import
|
||||
- secret이 들어가는 identity provider client secret은 Vault에서 주입
|
||||
|
||||
기본 금지:
|
||||
- Admin REST / kcadm.sh를 CI/CD pipeline이 직접 호출해 상태 변경
|
||||
- realm export 파일을 Pod 내부 파일로 배포
|
||||
|
||||
### 13. High Availability: replicas ≥ 2 + PDB + topologySpread
|
||||
|
||||
Operator는 `instances` 필드로 replica를 제어한다.
|
||||
|
||||
기본:
|
||||
- `instances: 3` (odd quorum 아님 — cache replication 안정성)
|
||||
- `PodDisruptionBudget minAvailable: 2`
|
||||
- `topologySpreadConstraints`로 node/zone 분산
|
||||
|
||||
### 14. Sticky session은 성능 최적화 옵션
|
||||
|
||||
Infinispan이 session을 복제하므로 필수는 아니지만, login flow 중간 redirect 지연을 줄인다.
|
||||
|
||||
기본:
|
||||
- Ingress controller에서 `AUTH_SESSION_ID` cookie affinity
|
||||
- Service `sessionAffinity: ClientIP`는 2차 선택지
|
||||
|
||||
### 15. Security context: Restricted PSS 준수
|
||||
|
||||
기본:
|
||||
- `runAsNonRoot: true`, `runAsUser: 1000`
|
||||
- `readOnlyRootFilesystem: true` (Keycloak은 `/opt/keycloak/data` 만 writable 요구; emptyDir 마운트)
|
||||
- `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`
|
||||
- `seccompProfile: RuntimeDefault`
|
||||
|
||||
### 16. Resource 요청은 JVM 특성 반영
|
||||
|
||||
기본 단일 replica:
|
||||
- requests: `cpu: 500m`, `memory: 1Gi`
|
||||
- limits: `cpu: 2`, `memory: 2Gi`
|
||||
- JVM: `JAVA_OPTS_APPEND=-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50`
|
||||
|
||||
login throughput 요구가 높으면 replica 수평 확장 우선 (JVM heap 수직 확장 2차).
|
||||
|
||||
### 17. Observability
|
||||
|
||||
기본:
|
||||
- `KC_METRICS_ENABLED=true`, `KC_HEALTH_ENABLED=true`
|
||||
- `ServiceMonitor` 또는 `PodMonitor`로 9000/`/metrics` scrape
|
||||
- event metric (login failure, token issuance)은 필요한 것만 활성화 (high cardinality 방지)
|
||||
|
||||
### 18. DB credential / admin credential은 Vault 경유
|
||||
|
||||
기본:
|
||||
- `KC_DB_PASSWORD`: VSO `VaultDynamicSecret`(postgres dynamic role) 또는 `VaultStaticSecret` → K8s Secret 동기화
|
||||
- Bootstrap admin (`KEYCLOAK_ADMIN`, `KEYCLOAK_ADMIN_PASSWORD`): 최초 기동 후 제거, 실 운영 admin은 realm-managed
|
||||
|
||||
기본 금지:
|
||||
- Secret을 Git에 평문 저장
|
||||
- 환경변수 default value로 credential 하드코딩
|
||||
|
||||
### 19. 현재 스택 기본 권장안
|
||||
|
||||
- 배포: Keycloak Operator + `Keycloak` CR + `KeycloakRealmImport` CR
|
||||
- 워크로드: StatefulSet (Operator 생성)
|
||||
- Service: ClusterIP (9000, 8080)
|
||||
- Ingress: SSO host + Admin host 분리
|
||||
- DB: CloudNativePG PostgreSQL cluster + Vault dynamic secret
|
||||
- Cache: `ispn` + `jdbc-ping`
|
||||
- Probe: 9000 management port
|
||||
- Replicas: 3 + PDB + topologySpread
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- Keycloak 26+ Quarkus distribution, `start --optimized` 두 단계
|
||||
- `--proxy-headers` 사용, `--proxy` 금지
|
||||
- Hostname v2 full URL, admin host 분리, strict=true 유지
|
||||
- DB: 외부 PostgreSQL, JDBC URL, Vault credential
|
||||
- Management port 9000 내부 전용, probe / metrics 대상
|
||||
- Infinispan `ispn` + `jdbc-ping` (v25+)
|
||||
- Operator 경로 1차 권장 (CR로 realm import 포함)
|
||||
- Admin Console 별도 host, 공개 경로는 `/realms/`, `/resources/`, `/.well-known/`
|
||||
- Replicas ≥ 2 + PDB + topologySpread + Restricted PSS
|
||||
@@ -0,0 +1,325 @@
|
||||
# Kustomize 기준
|
||||
|
||||
## 목적
|
||||
|
||||
Kustomize는 Kubernetes 리소스를 **template-free**로 조합하고 환경별 차이를 overlay로 표현하는 도구다.
|
||||
1000+ 서비스 prod 스케일에서 기본 배포 도구로 사용하며, Helm 차트는 특정 플랫폼 컴포넌트(Prometheus Operator, cert-manager 등)에만 제한적으로 쓴다.
|
||||
|
||||
목표:
|
||||
|
||||
- base / overlay / component 세 축을 명확히 구분한다
|
||||
- `commonLabels`의 selector immutability 함정을 피한다
|
||||
- `kubectl apply --server-side`를 전제로 field manager ownership을 관리한다
|
||||
- GitOps (ArgoCD/Flux) 또는 CI `kubectl apply -k` 어느 쪽이든 같은 원본을 쓴다
|
||||
|
||||
## 공식 의미 (근거)
|
||||
|
||||
- 공식 문서: `https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/`, `https://kubectl.docs.kubernetes.io/references/kustomize/`
|
||||
- `kubectl kustomize <dir>` 렌더, `kubectl apply -k <dir>` apply, `kubectl diff -k <dir>` diff.
|
||||
- **Kustomize v5+ `labels:` 필드**: label을 리소스에 추가하되 **기본적으로 selector에 주입하지 않는다** (`includeSelectors: false`). 공식 문서 인용: *"A field that allows adding labels without also automatically injecting corresponding selectors. This can be used instead of the `commonLabels` field, which always adds selectors."*
|
||||
- **`commonLabels`**: 모든 리소스의 `metadata.labels` + `spec.selector.matchLabels` + Pod template labels에 주입된다. Deployment/StatefulSet의 `selector.matchLabels`는 **immutable** 이므로, 이미 apply된 리소스에 `commonLabels`로 label을 추가하면 `field is immutable` 에러로 apply 실패.
|
||||
- **`components:`** (v4+): 재사용 가능한 cross-cutting overlay 단위. `kind: Component`. resource 집합 + patch 집합을 하나의 단위로 묶어 여러 overlay에서 `components:` 키로 참조.
|
||||
- `configMapGenerator` / `secretGenerator`: 이름 끝에 hash suffix가 자동으로 붙어 rollout trigger. `generatorOptions.disableNameSuffixHash: true`로 비활성 가능.
|
||||
- `patches:` (v5 권장): `target:` 선택 + `patch:` inline 또는 `path:` 파일. strategic merge / JSON patch 양쪽 지원.
|
||||
- `images:`: image name/tag/digest 교체.
|
||||
- `replicas:`: resource별 replica 수 override.
|
||||
- `namespace:` / `namePrefix:` / `nameSuffix:`: overlay에서 공통 변환.
|
||||
- Server-Side Apply (`kubectl apply --server-side --field-manager=<id> -k`)가 GitOps 기본.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. Kustomize 디렉터리가 선언형 source of truth
|
||||
|
||||
- 렌더: `kubectl kustomize <dir>`
|
||||
- diff: `kubectl diff --server-side -k <dir>`
|
||||
- apply: `kubectl apply --server-side --field-manager=<ci-id> -k <dir>`
|
||||
|
||||
`kubectl apply -f` 단일 파일 apply는 금지 (bootstrap 예외 제외).
|
||||
|
||||
### 2. base는 환경 중립
|
||||
|
||||
허용:
|
||||
|
||||
- Deployment/StatefulSet/DaemonSet/Job/CronJob 기본 shape
|
||||
- `app.kubernetes.io/{name,instance,component,part-of,managed-by}` (`version`은 overlay에서 image tag와 함께 주입)
|
||||
- 공통 container spec (resources, probes, securityContext)
|
||||
- 공통 volume mount / ConfigMap reference
|
||||
|
||||
금지:
|
||||
|
||||
- replicas 고정값 (overlay `replicas:`에서 결정)
|
||||
- 환경별 host / domain / issuer 이름
|
||||
- 환경별 secret / ConfigMap 이름
|
||||
- 환경별 resources requests/limits
|
||||
- `example.com/environment` label (overlay에서 `labels:`로 주입)
|
||||
|
||||
### 3. overlay는 환경 차이만, patches는 파일로 분리
|
||||
|
||||
overlay 한 디렉터리의 `kustomization.yaml`은 짧아야 한다. diff가 몇 백 줄을 넘으면 base 설계 실패 신호.
|
||||
|
||||
권장 구조:
|
||||
|
||||
```
|
||||
overlays/prod/
|
||||
kustomization.yaml
|
||||
patches/
|
||||
auth-replicas.yaml
|
||||
auth-resources.yaml
|
||||
auth-topology-spread.yaml
|
||||
ingress-host.yaml
|
||||
postgres-storage.yaml
|
||||
```
|
||||
|
||||
### 4. 디렉터리 구조는 base / components / overlays 3축
|
||||
|
||||
```
|
||||
k8s/
|
||||
base/
|
||||
app/units/<domain>/<service>/
|
||||
managing/<job>/
|
||||
plugins/<platform>/
|
||||
components/
|
||||
<reusable-cross-cutting>/
|
||||
overlays/
|
||||
<env>/[region/]
|
||||
```
|
||||
|
||||
`components/`는 "Kustomize Components"로, 여러 overlay에서 재사용.
|
||||
|
||||
### 5. `commonLabels` 금지, `labels:` 사용
|
||||
|
||||
신규 코드에서는 `commonLabels` 사용을 금지한다.
|
||||
|
||||
```yaml
|
||||
# DO
|
||||
labels:
|
||||
- pairs:
|
||||
example.com/environment: prod
|
||||
example.com/region: kr-main
|
||||
includeSelectors: false
|
||||
includeTemplates: true
|
||||
```
|
||||
|
||||
이유:
|
||||
|
||||
- `commonLabels`는 `selector.matchLabels`에 자동 주입 → live Deployment/StatefulSet apply 시 `field is immutable` 실패
|
||||
- `labels:`는 `includeSelectors: false`가 기본 → safe
|
||||
- `includeTemplates: true`로 Pod template labels에는 전파되므로 관찰성은 유지
|
||||
|
||||
기존 `commonLabels` 사용 코드는 migration plan을 세워 교체. selector에 이미 들어간 label이 있다면 해당 리소스를 **재배포** (delete + recreate) 없이는 변경 불가.
|
||||
|
||||
### 6. selector에는 불변 3종만
|
||||
|
||||
overlay에서 selector를 건드리지 않는다. selector에 허용되는 label은:
|
||||
|
||||
- `app.kubernetes.io/name`
|
||||
- `app.kubernetes.io/instance`
|
||||
- `app.kubernetes.io/component`
|
||||
|
||||
이 3종은 base에서 고정. overlay가 `labels:`로 추가하는 label은 반드시 `includeSelectors: false`.
|
||||
|
||||
### 7. `patches:` (v5 스타일) 사용, `patchesStrategicMerge` / `patchesJson6902` 금지
|
||||
|
||||
```yaml
|
||||
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
|
||||
```
|
||||
|
||||
이유:
|
||||
|
||||
- 단일 키로 strategic merge + JSON patch 양쪽 지원
|
||||
- `target:` selector로 여러 리소스에 적용 가능
|
||||
- 레거시 `patchesStrategicMerge` / `patchesJson6902`는 v5에서 deprecated (여전히 작동하지만 신규 사용 금지)
|
||||
|
||||
### 8. `components:`로 cross-cutting 재사용
|
||||
|
||||
multiple overlay에서 공통으로 끼워야 하는 변경(예: mTLS 활성화, sidecar 주입, monitoring label 추가)은 component로.
|
||||
|
||||
```
|
||||
components/
|
||||
with-istio-sidecar/
|
||||
kustomization.yaml # kind: Component
|
||||
patches/
|
||||
inject-sidecar.yaml
|
||||
with-service-monitor/
|
||||
kustomization.yaml
|
||||
service-monitor.yaml
|
||||
with-pdb-tier1/
|
||||
kustomization.yaml
|
||||
pdb-patch.yaml
|
||||
```
|
||||
|
||||
overlay에서:
|
||||
|
||||
```yaml
|
||||
components:
|
||||
- ../../components/with-service-monitor
|
||||
- ../../components/with-pdb-tier1
|
||||
```
|
||||
|
||||
### 9. `namePrefix` / `nameSuffix`는 꼭 필요할 때만
|
||||
|
||||
리소스 이름이 바뀌면 ConfigMap/Secret 참조 (`envFrom`, `volumes.configMap.name`)도 모두 바뀐다. namespace 격리가 기본이고, 같은 cluster 안에서 같은 이름 리소스를 여러 번 생성할 때만 prefix/suffix를 쓴다.
|
||||
|
||||
### 10. generator 기준
|
||||
|
||||
- `configMapGenerator`: 비민감 설정만. 기본 hash suffix로 rollout 자동 트리거.
|
||||
- `secretGenerator`: 로컬/테스트/bootstrap 에만. prod secret은 External Secrets Operator / Vault Secrets Operator / SealedSecrets로 관리.
|
||||
- `generatorOptions.disableNameSuffixHash: true`는 GitOps 외부 컨슈머가 이름을 하드코딩해야 할 때만 (예외).
|
||||
|
||||
### 11. `images:`로 image tag/digest 고정
|
||||
|
||||
```yaml
|
||||
images:
|
||||
- name: registry.example.com/auth
|
||||
newTag: "1.24.3"
|
||||
- name: registry.example.com/keycloak
|
||||
digest: "sha256:abcd1234..."
|
||||
```
|
||||
|
||||
- prod에서는 digest 권장 (tag는 mutable)
|
||||
- CI가 overlay의 `images:` 섹션을 빌드 후 새 digest로 patch (kustomize edit set image)
|
||||
|
||||
### 12. `replicas:`는 overlay에서 resource별 값 주입
|
||||
|
||||
```yaml
|
||||
replicas:
|
||||
- name: auth
|
||||
count: 6
|
||||
- name: keycloak
|
||||
count: 3
|
||||
```
|
||||
|
||||
HPA 주도 rollout 환경에서는 `replicas:` override가 HPA와 충돌할 수 있다. HPA 활성 리소스는 base `replicas`를 HPA `minReplicas`와 일치시키고 overlay에서는 건드리지 않는다.
|
||||
|
||||
### 13. `kubectl apply --server-side --field-manager=<id>` 기본
|
||||
|
||||
- ArgoCD: field manager `argocd-controller`
|
||||
- Flux: field manager `kustomize-controller`
|
||||
- CI manual: field manager `ci-<pipeline-id>`
|
||||
|
||||
field manager 이름을 환경별로 통일해야 `managedFields` 충돌이 예측 가능해진다.
|
||||
|
||||
### 14. render 전 검증
|
||||
|
||||
CI가 아래를 순서대로 실행:
|
||||
|
||||
```bash
|
||||
kubectl kustomize overlays/prod > /tmp/rendered.yaml
|
||||
kubeconform -strict -summary -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' /tmp/rendered.yaml
|
||||
kubectl diff --server-side --field-manager=ci -k overlays/prod
|
||||
```
|
||||
|
||||
- kubeconform / kubeval: schema validation
|
||||
- kyverno / OPA Gatekeeper: policy validation (post-render)
|
||||
- conftest: opa policy bundle 실행
|
||||
|
||||
### 15. base는 overlay를 모른다
|
||||
|
||||
공식 원칙. base `kustomization.yaml`은 overlay에서만 의미 있는 설정(환경 host / issuer / region label)을 전제하지 않는다. 위반 시 base가 더 이상 재사용 가능한 unit이 아니다.
|
||||
|
||||
### 16. Kustomize를 템플릿 엔진으로 남용하지 않는다
|
||||
|
||||
분기 / 조건 / 반복이 필요하면:
|
||||
|
||||
1. 리소스 분리
|
||||
2. component 도입
|
||||
3. overlay 추가
|
||||
4. (마지막 수단) Helm / jsonnet / cdk8s
|
||||
|
||||
Kustomize는 patch/overlay 도구다. Go template이 아니다.
|
||||
|
||||
### 17. scripts는 Kustomize 보조, 대체 아님
|
||||
|
||||
`scripts/render.sh`, `scripts/diff.sh`, `scripts/apply.sh`는 Kustomize 명령의 wrapper에 그치고 overlay 구조를 우회하지 않는다.
|
||||
|
||||
### 18. `resources:` vs `bases:` — v5에서는 `resources:` 통일
|
||||
|
||||
v2.1에서 `bases:`가 `resources:`로 통합됨. 신규 파일에서 `bases:` 금지.
|
||||
|
||||
### 19. overlay에서 StatefulSet PVC retention 변경 주의
|
||||
|
||||
`persistentVolumeClaimRetentionPolicy`는 StatefulSet spec 필드 (GA 1.27). 환경별로 값이 다르면 overlay patch로 조정하되 prod는 기본 `{whenDeleted: Retain, whenScaled: Retain}` 유지.
|
||||
|
||||
## 추천 폴더 구조
|
||||
|
||||
```text
|
||||
k8s/
|
||||
base/
|
||||
app/
|
||||
kustomization.yaml
|
||||
units/
|
||||
identity/
|
||||
auth/
|
||||
kustomization.yaml
|
||||
deployment.yaml
|
||||
service.yaml
|
||||
servicemonitor.yaml
|
||||
pdb.yaml
|
||||
hpa.yaml
|
||||
keycloak/
|
||||
kustomization.yaml
|
||||
data/
|
||||
postgres-identity/
|
||||
kustomization.yaml
|
||||
statefulset.yaml
|
||||
service-headless.yaml
|
||||
service.yaml
|
||||
managing/
|
||||
flyway-migrate-identity/
|
||||
kustomization.yaml
|
||||
job.yaml
|
||||
backup-postgres/
|
||||
kustomization.yaml
|
||||
cronjob.yaml
|
||||
plugins/
|
||||
ingress-nginx/
|
||||
cert-manager/
|
||||
external-secrets/
|
||||
kube-prometheus-stack/
|
||||
fluent-bit/
|
||||
components/
|
||||
with-service-monitor/
|
||||
with-pdb-tier1/
|
||||
with-topology-spread-zone/
|
||||
with-network-policy-deny-default/
|
||||
overlays/
|
||||
dev/
|
||||
kustomization.yaml
|
||||
staging/
|
||||
kustomization.yaml
|
||||
prod/
|
||||
kr-main/
|
||||
kustomization.yaml
|
||||
patches/
|
||||
kr-dr/
|
||||
kustomization.yaml
|
||||
patches/
|
||||
scripts/
|
||||
render.sh
|
||||
diff.sh
|
||||
apply.sh
|
||||
validate.sh
|
||||
```
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- Kustomize v5 문법 기준, `commonLabels` 금지, `labels:` 사용
|
||||
- `patches:` 단일 키, `target:` + `path:` 또는 `patch:` inline
|
||||
- `components:`로 cross-cutting 재사용
|
||||
- selector에는 불변 3종만 (name / instance / component)
|
||||
- generator는 configMap만 기본, secret은 External Secrets
|
||||
- `kubectl apply --server-side --field-manager=<id>` 전제
|
||||
- render + schema + policy 검증을 CI에서 강제
|
||||
- base / components / overlays 3축 디렉터리
|
||||
- overlay diff는 짧아야 한다 (base 재작성 금지)
|
||||
@@ -0,0 +1,238 @@
|
||||
# MinIO 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 Kubernetes 환경에서 MinIO를 1000+ 서비스의 S3 호환 object storage로 운영하기 위한 기준을 고정한다.
|
||||
|
||||
- MinIO Operator + **Tenant CRD** (`minio.min.io/v2`)를 기본 배포 모델로 둔다
|
||||
- Erasure coding 최소 요건 (`servers × volumesPerServer ≥ 4`)을 명시한다
|
||||
- KES sidecar + Vault transit backend로 SSE-KMS를 구성한다
|
||||
- STS + OIDC (Keycloak)로 서비스 인증을 수행한다
|
||||
- 버전 관리 / object lock / replication / lifecycle rule을 운영 필수 요소로 둔다
|
||||
|
||||
## 공식 의미 (MinIO Operator + Tenant CRD 기준)
|
||||
|
||||
- MinIO Operator는 `minio.min.io/v2` API group의 **Tenant** CR을 watch하여 StatefulSet, Service, PVC, 인증서를 자동 생성한다.
|
||||
- Tenant는 **namespace당 1개**를 권장한다 (namespace = 소유/정책/쿼터 경계).
|
||||
- MinIO는 erasure coding을 사용한다. **`servers × volumesPerServer`는 최소 4이어야** 기동한다. EC:N parity (기본 EC:4 ~ EC:8)는 parity drive 수를 결정하며, 장애 허용 drive 수 = parity 수.
|
||||
- MinIO pool은 불변이다(immutable). pool 내 servers / volumesPerServer는 Tenant 생성 후 변경 불가. 용량 확장은 **새 pool 추가**로 수행.
|
||||
- **Health endpoints**:
|
||||
- `/minio/health/live` — 프로세스 liveness (인증 없음)
|
||||
- `/minio/health/cluster` — **write quorum** 기준 (rolling update 중 false 가능, readiness 비권장)
|
||||
- `/minio/health/cluster/read` — **read quorum** 기준 (rolling update 허용, readiness 권장)
|
||||
- **Metrics endpoints**:
|
||||
- `/minio/v2/metrics/cluster` — cluster-wide (기본 Bearer token 필요)
|
||||
- `/minio/v2/metrics/node` — per-node
|
||||
- `/minio/v2/metrics/bucket/api/<bucket>` — bucket API metrics
|
||||
- `mc admin prometheus generate` 로 scrape config + token 생성. 또는 `prometheusAuthType: public` 설정으로 unauth scrape 허용.
|
||||
- **KES** (Key Encryption Service)는 별도 sidecar/Deployment로 Vault transit backend와 통신해 SSE-KMS / SSE-S3 per-object key를 발급한다.
|
||||
- MinIO **service account**는 root access key의 하위 derived credential이다(IAM role 개념 아님). 앱은 service account만 사용하고 root는 bootstrap 전용.
|
||||
- **Object lock**은 bucket 생성 시점에 활성화해야 하며, `GOVERNANCE` (bypass 권한자 우회 가능) vs `COMPLIANCE` (root도 우회 불가) 두 모드.
|
||||
- **Site replication**은 최대 16개 MinIO 클러스터를 동기화한다 (IAM, bucket config, object 전체). **Bucket replication**은 특정 bucket만 대상.
|
||||
- Console은 9090 port, API는 9000 port.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 배포는 MinIO Operator + Tenant CR
|
||||
|
||||
기본:
|
||||
- `kubectl apply -k "https://github.com/minio/operator?ref=v6.0.4"` 또는 Helm `minio-operator` + `tenant` chart
|
||||
- Tenant CR로 pool, credential, TLS, KES, logging, monitoring 선언
|
||||
- 수제 StatefulSet 운영 금지
|
||||
|
||||
### 2. Tenant는 namespace당 1개
|
||||
|
||||
기본:
|
||||
- `minio-prod` namespace에 Tenant 1개
|
||||
- 테넌트 간 격리가 필요하면 namespace를 복수 생성
|
||||
- 동일 namespace에 다른 워크로드와 공존 금지
|
||||
|
||||
### 3. Erasure coding 요건: `servers × volumesPerServer ≥ 4`
|
||||
|
||||
기본 topology 후보:
|
||||
| servers | volumesPerServer | 총 drive | 기본 EC parity | 장애 허용 drive |
|
||||
|---------|------------------|----------|----------------|-----------------|
|
||||
| 4 | 4 | 16 | EC:4 | 4 |
|
||||
| 4 | 8 | 32 | EC:4 | 4 |
|
||||
| 8 | 4 | 32 | EC:4 | 4 |
|
||||
| 8 | 8 | 64 | EC:4 ~ EC:8 | 4 ~ 8 |
|
||||
|
||||
기본:
|
||||
- 최소 `4 × 4 = 16` drive 시작 (prod)
|
||||
- `MINIO_STORAGE_CLASS_STANDARD=EC:4` 이상, critical data는 `EC:8`
|
||||
- parity 증가 = 용량 감소 + 신뢰성 증가
|
||||
|
||||
기본 금지:
|
||||
- `servers × volumesPerServer < 4` → Tenant가 기동 실패
|
||||
|
||||
### 4. Pool은 immutable — 확장은 새 pool 추가
|
||||
|
||||
기본:
|
||||
- 초기 pool의 `servers`, `volumesPerServer`, `volumeClaimTemplate.size`는 평생 고정
|
||||
- 용량 부족 시 `spec.pools[]`에 `pool-1`, `pool-2` 추가
|
||||
- pool 간 데이터 rebalance는 `mc admin rebalance start`
|
||||
|
||||
### 5. StorageClass 명시 (local-path 금지)
|
||||
|
||||
기본:
|
||||
- prod: `volumeClaimTemplate.spec.storageClassName: ceph-rbd-retain` / `ebs-gp3` / `local-volume-xfs` (명시적)
|
||||
- 파일시스템은 `xfs` 권장 (MinIO는 ext4보다 xfs에 최적화)
|
||||
- `reclaimPolicy: Retain` + PVC 삭제 가드 (Tenant 삭제 시 데이터 소실 방어)
|
||||
|
||||
기본 금지:
|
||||
- k3s local-path prod 사용
|
||||
- default StorageClass fallback
|
||||
|
||||
### 6. Credential: root는 bootstrap 전용, 앱은 service account
|
||||
|
||||
기본:
|
||||
- `spec.configuration.name`에 root credential Secret (MINIO_ROOT_USER, MINIO_ROOT_PASSWORD)
|
||||
- Vault KV에 root credential 저장, VSO로 Secret 동기화
|
||||
- 앱용 access는 `mc admin user svcacct add` 로 service account 발급
|
||||
- service account는 최소 권한 policy 바인딩
|
||||
|
||||
### 7. TLS는 기본 활성화
|
||||
|
||||
기본:
|
||||
- `spec.requestAutoCert: true` → Operator가 Kubernetes CSR로 인증서 자동 발급 (MinIO 자체 CA)
|
||||
- 사내 PKI 사용 시 `spec.externalCertSecret` + cert-manager Certificate
|
||||
- API (9000), Console (9090), KES 전부 TLS
|
||||
|
||||
### 8. KES + Vault transit으로 SSE-KMS
|
||||
|
||||
기본:
|
||||
- `spec.kes` 필드에 KES 사이드카 spec
|
||||
- KES는 Vault transit engine을 key store로 사용
|
||||
- bucket 생성 시 `mc encrypt set sse-kms minio-backup/critical key-id=my-app-key`
|
||||
- per-object DEK를 KES에서 받아 암호화
|
||||
|
||||
기본 금지:
|
||||
- KES 없이 SSE-S3만 사용 (master key가 MinIO 내부에만 존재 → 분실 위험)
|
||||
- KES가 local filesystem key store 사용 (prod)
|
||||
|
||||
### 9. Versioning + Object Lock은 critical bucket 기본값
|
||||
|
||||
기본:
|
||||
- 금융/감사 데이터: `mc version enable` + Object Lock `COMPLIANCE` 모드
|
||||
- 백업 bucket: Object Lock `GOVERNANCE` + retention 30일
|
||||
- 일반 app bucket: versioning만 (실수 복구)
|
||||
- lifecycle rule로 오래된 버전 자동 정리 (`mc ilm add --expire-noncurrent-days 90`)
|
||||
|
||||
### 10. Replication: site vs bucket
|
||||
|
||||
기본:
|
||||
- 전체 IAM/config 동기화 필요: **site replication** (`mc admin replicate add`)
|
||||
- 특정 bucket만 cross-region 복제: **bucket replication** (`mc replicate add`)
|
||||
- async replication 특성 인지 (RPO > 0)
|
||||
- `mc mirror`는 DR 전략 아님 — 일회성 migration/sync 용도
|
||||
|
||||
### 11. STS + OIDC (Keycloak) 통합
|
||||
|
||||
기본:
|
||||
- Keycloak에 `minio` client 생성 (confidential)
|
||||
- MinIO 설정:
|
||||
```
|
||||
mc admin config set ALIAS identity_openid \
|
||||
config_url="https://auth.example.com/realms/platform/.well-known/openid-configuration" \
|
||||
client_id="minio" \
|
||||
client_secret="..." \
|
||||
claim_name="policy" \
|
||||
scopes="openid,profile,email"
|
||||
```
|
||||
- 앱은 `AssumeRoleWithWebIdentity`로 JWT → 임시 STS credential 교환
|
||||
- MinIO policy에 JWT `policy` claim으로 매핑
|
||||
|
||||
### 12. Health probe: read quorum을 readiness로
|
||||
|
||||
기본:
|
||||
- `livenessProbe`: `/minio/health/live` (프로세스 생존)
|
||||
- `readinessProbe`: `/minio/health/cluster/read` (read quorum) — rolling update 허용
|
||||
- `startupProbe`: `/minio/health/live` + `failureThreshold` 넉넉하게
|
||||
|
||||
기본 금지:
|
||||
- `readinessProbe`로 `/minio/health/cluster` (write quorum) 사용 → rolling update 시 전체 pod unready
|
||||
|
||||
### 13. Metrics: 내부 scrape 전용
|
||||
|
||||
기본:
|
||||
- `spec.prometheus` 또는 `prometheusAuthType: public` (내부 network만)
|
||||
- 또는 `mc admin prometheus generate` 로 scrape token 발급 후 `bearerTokenSecret`
|
||||
- ServiceMonitor는 `/minio/v2/metrics/cluster` 대상
|
||||
- 외부 Ingress 공개 금지
|
||||
|
||||
### 14. API Ingress — Console은 내부 전용
|
||||
|
||||
기본:
|
||||
- API (9000): 필요한 경우 Ingress로 공개 (S3 API host: `s3.example.com`)
|
||||
- Console (9090): 내부/운영자 전용, 외부 공개 금지 (별도 host + IP whitelist + OIDC forward-auth)
|
||||
- Console을 공개하면 root credential UI 로그인 표면 확장
|
||||
|
||||
### 15. Lifecycle rule로 용량 관리
|
||||
|
||||
기본:
|
||||
- 로그 bucket: 30~90일 expire
|
||||
- tmp / cache bucket: 7일 expire
|
||||
- versioning enabled bucket: noncurrent version 90일 expire
|
||||
- incomplete multipart upload: 7일 abort (`mc ilm add --expire-incomplete-upload-days 7`)
|
||||
|
||||
### 16. 로깅
|
||||
|
||||
기본:
|
||||
- `spec.log.audit` → bucket에 audit log 저장 또는 webhook으로 외부 전송
|
||||
- stdout으로 console log → fluent-bit / Loki 수집
|
||||
- audit log는 Object Lock bucket에 저장해 변조 방지
|
||||
|
||||
### 17. SecurityContext + Resource
|
||||
|
||||
기본:
|
||||
- `spec.securityContext`: `runAsNonRoot: true`, `runAsUser: 1000`, `fsGroup: 1000`, `runAsGroup: 1000`
|
||||
- Restricted PSS 준수
|
||||
- 단일 pod resource (4 server cluster 기준):
|
||||
- requests: `cpu: 500m`, `memory: 2Gi`
|
||||
- limits: `cpu: 4`, `memory: 8Gi`
|
||||
- 데이터 규모/동시 요청 수에 따라 조정
|
||||
|
||||
### 18. Anti-affinity + topologySpread
|
||||
|
||||
기본:
|
||||
- `podAntiAffinity`: hostname 기준 required (같은 node에 MinIO pod 복수 금지)
|
||||
- `topologySpreadConstraints`: zone 분산
|
||||
- EC:4 + 4-zone = 1 zone 장애 허용
|
||||
|
||||
### 19. Console은 분리, auth는 OIDC
|
||||
|
||||
기본:
|
||||
- Console endpoint에 `MINIO_IDENTITY_OPENID_*` OIDC 설정
|
||||
- root credential UI 로그인은 break-glass 전용
|
||||
- 일반 운영자는 OIDC 로그인 + group → policy 매핑
|
||||
|
||||
### 20. 현재 스택 기본 권장안
|
||||
|
||||
- 배포: MinIO Operator + Tenant CR (`minio.min.io/v2`)
|
||||
- Topology: 최소 `4 × 4 = 16` drive, prod는 `8 × 4 = 32` 이상
|
||||
- EC: `EC:4` 기본, critical data `EC:8`
|
||||
- StorageClass: 명시적 (xfs, Retain)
|
||||
- TLS: `requestAutoCert: true`
|
||||
- KMS: KES sidecar + Vault transit
|
||||
- 인증: root는 VSO 주입, 앱은 service account, 사용자는 Keycloak OIDC
|
||||
- Health: live / cluster-read (readiness)
|
||||
- Metrics: Prometheus bearer-token scrape
|
||||
- Versioning + Object Lock: critical bucket 기본값
|
||||
- Replication: site (전체) / bucket (부분) 구분
|
||||
- Console: 내부 전용
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- MinIO Operator + Tenant CR 기본 배포
|
||||
- namespace당 Tenant 1개
|
||||
- `servers × volumesPerServer ≥ 4` erasure coding 요건
|
||||
- Pool immutable — 확장은 새 pool
|
||||
- StorageClass 명시 + xfs 권장 + Retain
|
||||
- KES + Vault transit으로 SSE-KMS
|
||||
- Root credential은 Vault → VSO → Secret 경로
|
||||
- 앱 접근은 service account, 사용자는 Keycloak OIDC STS
|
||||
- Health: `/minio/health/live` + `/minio/health/cluster/read`
|
||||
- Metrics: Bearer token scrape
|
||||
- Console 외부 비공개, API만 필요 시 Ingress
|
||||
- Versioning + Object Lock + Lifecycle rule로 데이터 보호
|
||||
- site/bucket replication으로 DR (`mc mirror`는 DR 아님)
|
||||
@@ -0,0 +1,170 @@
|
||||
# network / ingress / TLS 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 K3s/Kubernetes(1000+ 서비스) 환경에서
|
||||
- 어떤 Service 타입을 언제 쓸지
|
||||
- 외부 공개는 Ingress/Gateway 어디로 할지
|
||||
- TLS를 어디서 종료할지
|
||||
- 인증서는 누가 발급·회전할지
|
||||
- NetworkPolicy로 L3/L4 경계를 어떻게 그을지
|
||||
를 단일 ground truth로 고정한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 외부 attack surface를 최소화한다
|
||||
- `ClusterIP`/`NodePort`/`LoadBalancer`/`Ingress`의 역할을 섞지 않는다
|
||||
- 모든 Ingress는 cert-manager 발급 TLS + HTTPS redirect + HSTS + TLS 1.2+ 기본
|
||||
- K3s 기본 Traefik을 유지하되 packaged manifest는 수정하지 않는다
|
||||
- Keycloak/Vault/DB 같은 민감 컴포넌트의 노출 범위를 manifest로 증명한다
|
||||
|
||||
## 공식 의미 (근거)
|
||||
|
||||
- Service 기본 타입은 `ClusterIP`. 외부 L4 노출은 `NodePort` 또는 `LoadBalancer`, 외부 L7은 Ingress 또는 Gateway API.
|
||||
- Ingress v1 API는 GA이지만 spec은 frozen 상태이고, 신규 기능(L4, traffic split, header match)은 Gateway API로 이동 중이다.
|
||||
- Ingress v1은 `spec.ingressClassName` 필드로 컨트롤러를 선택한다. 이전의 `kubernetes.io/ingress.class` annotation은 deprecated이며 1.22에서 공식 deprecation 고지.
|
||||
- Ingress TLS Secret은 타입이 `kubernetes.io/tls`이고 data key는 `tls.crt`, `tls.key`여야 한다. `spec.tls[].hosts`와 `rules[].host`는 일치해야 한다.
|
||||
- cert-manager는 `Issuer`/`ClusterIssuer`, `Certificate`, `CertificateRequest`, `Order`, `Challenge` CRD로 구성된다. `Certificate`가 참조하는 `secretName`에 자동으로 `kubernetes.io/tls` Secret이 생성·갱신된다.
|
||||
- ACME HTTP-01은 public DNS + 80 reachable 필요. DNS-01은 wildcard(`*.example.com`) 발급에 필수이며 DNS provider API credential이 요구된다.
|
||||
- Traefik v2/v3는 `IngressRoute`(CRD) + `Middleware`(CRD)로 L7 정책(redirect, HSTS, rate-limit, auth)을 체계적으로 구성한다. 기본 Ingress API도 annotation으로 일부 기능을 쓸 수 있다.
|
||||
- K3s는 Traefik을 packaged component로 설치한다(`/var/lib/rancher/k3s/server/manifests/traefik.yaml`). packaged manifest 직접 수정은 재설치 시 덮어쓰인다. `HelmChartConfig`로 override한다.
|
||||
- NetworkPolicy는 CNI가 지원해야 enforce된다. K3s 기본 flannel + kube-router policy controller는 v1 NetworkPolicy를 지원한다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 기본 Service 타입은 `ClusterIP`
|
||||
- 내부 통신: `ClusterIP`
|
||||
- 외부 HTTP/HTTPS: Ingress
|
||||
- 외부 TCP/UDP L4: `LoadBalancer`(ServiceLB/MetalLB/클라우드 LB 전제)
|
||||
- `NodePort`는 개발/bootstrap 용도 외 운영 금지. namespace `ResourceQuota.services.nodeports: 0`으로 선제 차단.
|
||||
|
||||
### 2. 모든 Service는 named port + `appProtocol`
|
||||
```yaml
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
appProtocol: http
|
||||
```
|
||||
- `name: http|https|grpc|metrics`로 명명.
|
||||
- `appProtocol` 명시는 Ingress controller/서비스 메시가 L7 처리를 최적화할 수 있게 한다.
|
||||
- container `ports[].name`과 Service `targetPort`를 이름으로 연결해 포트 번호 drift를 방지.
|
||||
|
||||
### 3. Ingress는 `spec.ingressClassName: traefik` 필수
|
||||
- `kubernetes.io/ingress.class` annotation은 **deprecated**. 어떤 Ingress에도 남기지 않는다.
|
||||
- 컨트롤러가 여러 개인 클러스터(예: Traefik + internal-only NGINX)는 `IngressClass` 리소스를 만들어 class를 명시한다.
|
||||
- 기본 클래스는 `ingressclass.kubernetes.io/is-default-class: "true"` annotation으로 한 개만 지정.
|
||||
|
||||
### 4. 외부 HTTPS는 cert-manager ClusterIssuer로 발급
|
||||
- 운영 공인 도메인: `letsencrypt-prod` ClusterIssuer(ACME HTTP-01) 기본.
|
||||
- Wildcard/internal CA: DNS-01(`letsencrypt-prod-dns`) 또는 Vault PKI issuer.
|
||||
- Staging 검증: `letsencrypt-staging` ClusterIssuer로 선행 테스트 후 prod 전환.
|
||||
- Ingress에는 annotation으로 issuer 지정: `cert-manager.io/cluster-issuer: letsencrypt-prod`. cert-manager가 Certificate + Secret을 자동 생성·갱신한다.
|
||||
- Certificate CRD를 명시적으로 선언하는 방식도 허용(공유 Secret 재사용, 세밀한 `duration`/`renewBefore` 제어 필요 시).
|
||||
|
||||
### 5. HTTPS redirect + HSTS + TLS 1.2+ 기본
|
||||
- 모든 외부 Ingress는 HTTP → HTTPS 영구 리다이렉트.
|
||||
- HSTS: `max-age=31536000; includeSubDomains; preload` 기본.
|
||||
- TLS minVersion: `VersionTLS12`(가능하면 1.3). 취약 cipher(RC4, 3DES) disable.
|
||||
- Traefik에서는 `Middleware`(redirectScheme, headers) + `TLSOption` CRD로 정책을 선언. Ingress annotation 방식 예:
|
||||
- `traefik.ingress.kubernetes.io/router.entrypoints: websecure`
|
||||
- `traefik.ingress.kubernetes.io/router.middlewares: default-hsts@kubernetescrd,default-https-redirect@kubernetescrd`
|
||||
- `traefik.ingress.kubernetes.io/router.tls: "true"`
|
||||
|
||||
### 6. Ingress host는 환경별로 분리, wildcard 남용 금지
|
||||
- dev/staging/prod별 호스트 분리(`auth.dev.example.com`, `auth.staging.example.com`, `auth.example.com`).
|
||||
- Wildcard 인증서는 플랫폼 수준 Certificate로 관리하고 서비스 Ingress가 `secretName` 재사용.
|
||||
- `defaultBackend`(host 없음) 금지. host가 명시된 rule만 허용.
|
||||
|
||||
### 7. 외부 공개 범위 = "반드시 공개해야 하는 path"만
|
||||
- Keycloak: `/realms/`, `/resources/`, `/.well-known/`만 노출. `/admin/`, `/metrics`, `/health`는 공개 금지.
|
||||
- 관리 포트(Keycloak 9000, Vault 8201, Postgres 5432, Redis 6379)는 Ingress 경유 금지.
|
||||
- 내부 도구(Argo CD, Grafana, Kibana)는 VPN/zero-trust proxy(예: Pomerium, cloudflared tunnel)로만 노출.
|
||||
|
||||
### 8. TLS 종료 위치와 내부 재암호화 정책
|
||||
- 기본: Ingress(Traefik)에서 TLS 종료, 내부 Pod까지는 ClusterIP 경유 평문.
|
||||
- 민감 backend(Vault, Keycloak token endpoint)는 **Ingress→Pod 재암호화** 검토. Traefik `serversTransport` + `insecureSkipVerify: false`로 backend TLS 사용.
|
||||
- E2E mTLS가 필요하면 서비스 메시(Linkerd/Istio) 도입을 별도 ADR로 결정.
|
||||
|
||||
### 9. K3s 기본 Traefik은 유지·격리
|
||||
- packaged manifest(`traefik.yaml`) 직접 수정 금지.
|
||||
- 커스터마이징은 `HelmChartConfig`(`kind: HelmChartConfig` in `helm.cattle.io/v1`)로 override.
|
||||
- Traefik은 `ingress-traefik` namespace에 격리, PSA `baseline`, NetworkPolicy는 80/443/8443 inbound + 모든 app namespace outbound 허용.
|
||||
|
||||
### 10. ServiceLB(klipper-lb) / MetalLB 결정
|
||||
- 단일 노드 또는 on-prem 초기: K3s ServiceLB.
|
||||
- 다중 노드 + BGP/ARP 정책이 필요: MetalLB(`kubectl get deploy -n kube-system | grep servicelb`가 없어야 함, `--disable=servicelb`로 off).
|
||||
- 클라우드(EKS/GKE/AKS): cloud-provider LoadBalancer가 우선.
|
||||
- 이 결정이 ADR로 고정되기 전에는 `LoadBalancer` Service를 새로 만들지 않는다.
|
||||
|
||||
### 11. NetworkPolicy는 namespace default-deny 기본
|
||||
모든 운영 namespace는 다음 3종 + 서비스별 allow가 기본 세트다.
|
||||
1. `default-deny-all` (ingress+egress)
|
||||
2. `allow-dns-egress` (to `kube-system` `k8s-app=kube-dns`, 53/UDP+TCP)
|
||||
3. `allow-from-ingress-traefik` (특정 app Pod만 허용)
|
||||
|
||||
### 12. NetworkPolicy `from`/`to` 엔트리 AND/OR 규칙
|
||||
- **동일 엔트리 내 `namespaceSelector`+`podSelector`** → AND(교집합). 권장 패턴.
|
||||
- **별도 엔트리로 분리** → OR(합집합). 거의 항상 버그.
|
||||
- `ipBlock`은 같은 엔트리 내 `namespaceSelector`/`podSelector`와 함께 쓸 수 없다. 외부 CIDR allow는 별도 엔트리.
|
||||
|
||||
### 13. egress NetworkPolicy는 DNS 먼저, 서비스별 allow 나중
|
||||
- `default-deny-all`만 적용하면 DNS 해석 실패로 앱이 기동 불가.
|
||||
- kube-dns 53/UDP+TCP가 첫 번째 allow.
|
||||
- 외부 API(OIDC issuer, SMTP, S3)는 FQDN이 아니라 IP CIDR로 나와야 v1 NetworkPolicy로 표현 가능. FQDN 기반 egress가 필요하면 Cilium `CiliumNetworkPolicy` 또는 egress gateway 검토.
|
||||
|
||||
### 14. Prometheus scrape는 ingress rule로 열기
|
||||
- `monitoring` namespace의 Prometheus Pod만 허용.
|
||||
- `namespaceSelector: kubernetes.io/metadata.name=monitoring` + `podSelector: app.kubernetes.io/name=prometheus` AND.
|
||||
- 포트는 `metrics`(9090/9100 등) 전용, 앱 `http` 포트 재사용 금지.
|
||||
|
||||
### 15. Gateway API는 단계적 도입
|
||||
- 신규 요구사항(traffic split, header routing, gRPC filter)이 Ingress v1으로 표현 불가하면 Gateway API 검토.
|
||||
- 전환은 서비스 단위로 Ingress → `HTTPRoute`로 마이그레이션. `GatewayClass`/`Gateway`는 platform-team 소유.
|
||||
|
||||
### 16. health/metrics/admin endpoint 외부 공개 금지
|
||||
- `/actuator/*`, `/debug/pprof/*`, `/admin/*`, `/metrics`는 Ingress path에 포함하지 않는다.
|
||||
- 별도 Service 포트(`name: metrics`)를 만들고 NetworkPolicy로 Prometheus만 허용.
|
||||
|
||||
### 17. Ingress 경로 설계는 prefix + 명시 + 최소
|
||||
- `pathType: Prefix` 명시(`ImplementationSpecific` 금지).
|
||||
- `/`를 바로 노출하기 전 사용자 경로만 선언 가능한지 검토(Keycloak 패턴 참조).
|
||||
- Path rewrite가 필요하면 Traefik `Middleware.stripPrefix`를 사용하고 annotation으로 명시.
|
||||
|
||||
### 18. ExternalName/headless Service는 용도에 맞춰
|
||||
- `ExternalName`은 클러스터 외부 CNAME alias 용도. 인증/TLS 경계와 별개 고려.
|
||||
- Headless(`clusterIP: None`)는 StatefulSet DNS, client-side LB 용도. Ingress 대상 아님.
|
||||
|
||||
### 19. 현재 스택 기본 권장안
|
||||
|
||||
#### auth-server / test-server
|
||||
- Service: `ClusterIP` with named `http`, `metrics`
|
||||
- Ingress: `ingressClassName: traefik`, cert-manager `letsencrypt-prod`, HSTS + HTTPS redirect
|
||||
- NetworkPolicy: default-deny + dns + ingress-traefik + db + vault + prometheus
|
||||
|
||||
#### keycloak
|
||||
- Service: `ClusterIP`, named `http`(8080), `management`(9000)
|
||||
- Ingress: `/realms/`, `/resources/`, `/.well-known/`만 노출. 9000 포트는 Service로도 cluster 외부 비공개.
|
||||
- Certificate: 전용(`sso.example.com`), 전용 TLS Secret
|
||||
|
||||
#### vault / db / migration-flyway
|
||||
- Ingress 없음. ClusterIP only. 접근은 bastion + `kubectl port-forward` 또는 zero-trust proxy.
|
||||
|
||||
#### minio
|
||||
- API/Console Ingress 분리. Console은 내부 전용, API는 필요 시 signed URL 중심.
|
||||
|
||||
#### ingress-traefik
|
||||
- `ingress-traefik` namespace 격리, PSA `baseline`
|
||||
- `Service type=LoadBalancer`(ServiceLB/MetalLB) 또는 `hostPort` 80/443만
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 기본 Service 타입은 `ClusterIP`, named port 필수
|
||||
- 모든 Ingress는 `spec.ingressClassName: traefik`, annotation `kubernetes.io/ingress.class` 금지
|
||||
- 모든 외부 HTTPS는 cert-manager ClusterIssuer 발급 + HSTS + HTTP→HTTPS redirect + TLS 1.2+
|
||||
- Keycloak/Vault/DB 노출 범위는 path/host로 증명, 관리 포트 비공개
|
||||
- K3s Traefik은 packaged manifest 직접 수정 금지, `HelmChartConfig` override
|
||||
- NetworkPolicy default-deny + DNS allow + ingress-traefik allow 기본 세트
|
||||
- `namespaceSelector`+`podSelector` AND/OR 차이를 정확히 사용
|
||||
- Gateway API는 단계적 도입, 기존 Ingress 유지
|
||||
@@ -0,0 +1,219 @@
|
||||
# observability / health 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 1000+ 서비스가 공통으로 따르는 observability 기준선이다. metrics 수집 경로, golden signal 정의, 로그 포맷 / 수집 stack, trace 수집(OTel), health endpoint 외부 비공개 원칙, cardinality 가드를 한 파일에 고정한다.
|
||||
|
||||
## 공식 / 업계 근거
|
||||
|
||||
- **Google SRE Book (Ch.6)**: Four Golden Signals = **Latency, Traffic, Errors, Saturation**. 운영 대시보드의 기본 구성 원칙.
|
||||
- **RED method (Tom Wilkie, Weaveworks)**: request-driven service에 대해 **Rate, Errors, Duration**.
|
||||
- **USE method (Brendan Gregg)**: resource에 대해 **Utilization, Saturation, Errors**.
|
||||
- **kube-prometheus-stack**: Prometheus Operator를 통한 `ServiceMonitor` / `PodMonitor` CRD가 primary scrape path.
|
||||
- **Prometheus annotation fallback**: `prometheus.io/scrape: "true"` 등은 Operator가 없을 때만 사용.
|
||||
- **OpenTelemetry**: OTLP protocol + OTel Collector (Deployment gateway + DaemonSet agent) 가 표준.
|
||||
- **Log shipping canonical stacks**: Loki + Grafana Alloy (또는 Promtail) / Fluent Bit → OpenSearch. 한 플랫폼에서 둘 이상 섞지 않는다.
|
||||
- `kubectl events` (1.27+ stable) — 기존 `kubectl get events`보다 sort/watch 기본 제공.
|
||||
- metrics-server: HPA/VPA와 `kubectl top` 을 위한 최소 resource metric. full metrics와 분리.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. Four Golden Signals를 모든 서비스 대시보드의 골격으로
|
||||
|
||||
각 traffic-facing service는 최소 4개 signal을 노출한다.
|
||||
|
||||
- **Latency**: `request_duration_seconds` histogram (p50/p95/p99).
|
||||
- **Traffic**: `requests_per_second` by method/status.
|
||||
- **Errors**: `error_rate` (5xx / 전체).
|
||||
- **Saturation**: resource utilization (CPU / memory / connection pool / queue depth).
|
||||
|
||||
SLO / alert / dashboard가 이 4개에서 시작한다.
|
||||
|
||||
### 2. RED는 request-driven, USE는 resource에 쓴다
|
||||
|
||||
- HTTP / gRPC 서비스 → **RED**.
|
||||
- Node / disk / CPU / DB pool → **USE**.
|
||||
- 두 방법론을 동시에 활용 가능 (golden signal은 양쪽 합집합).
|
||||
|
||||
### 3. ServiceMonitor / PodMonitor 를 primary scrape path로
|
||||
|
||||
kube-prometheus-stack을 운영하는 플랫폼에서는 `ServiceMonitor` CRD가 표준이다.
|
||||
|
||||
- `selector.matchLabels` 로 대상 Service 매칭.
|
||||
- `namespaceSelector` 명시 (암묵적 전체 허용 금지).
|
||||
- `endpoints[].port` 는 **named port**, 숫자 port 금지.
|
||||
- `interval` (기본 30s), `scrapeTimeout` (interval < interval) 명시.
|
||||
- `scheme` (http/https) 명시.
|
||||
- `bearerTokenSecret` / `tlsConfig` 로 인증 scrape.
|
||||
- `relabelings` 로 label 위생 (pod_template_hash drop 등).
|
||||
|
||||
Pod에 직접 연결되는 경우 (Service가 없는 워크로드) `PodMonitor` 사용.
|
||||
|
||||
### 4. Annotation-based scrape 는 fallback
|
||||
|
||||
`prometheus.io/scrape: "true"` 계열 annotation은 Prometheus가 Operator 없이 kubernetes_sd_configs로 직접 discover하는 방식이다. ServiceMonitor 대비 label relabel / auth / tls 제어가 약하다.
|
||||
|
||||
- kube-prometheus-stack이 있는 환경: **사용 금지**, ServiceMonitor 통일.
|
||||
- legacy / 교체 진행 중인 플랫폼: 전환 기간 동안만 사용.
|
||||
|
||||
지원 annotation:
|
||||
- `prometheus.io/scrape: "true"`
|
||||
- `prometheus.io/port: "8081"`
|
||||
- `prometheus.io/path: "/metrics"`
|
||||
- `prometheus.io/scheme: "http"`
|
||||
|
||||
### 5. metrics port는 외부 비공개, NetworkPolicy로 scraper만 허용
|
||||
|
||||
- `/metrics` 는 절대 Ingress 경로에 노출하지 않는다.
|
||||
- metrics port는 별도 containerPort (ex: 8081, 9000).
|
||||
- NetworkPolicy로 **monitoring namespace의 prometheus pod만** 해당 port에 ingress 허용.
|
||||
|
||||
### 6. Cardinality는 label 설계 단계에서 가드
|
||||
|
||||
Prometheus TSDB에서 **각 label value 조합 = 새 time series**. cardinality 폭발은 쿼리 OOM / storage 폭증의 가장 흔한 원인.
|
||||
|
||||
금지 label:
|
||||
|
||||
- `user_id`, `tenant_id` (높은 기수) — 대신 top-N aggregation 또는 별도 logging.
|
||||
- `path` (path에 UUID / numeric ID 포함) — template된 route로 바꾼다 (`/users/:id`).
|
||||
- `url` 전체, `request_id`, `trace_id`, `session_id`.
|
||||
- timestamp, epoch value.
|
||||
|
||||
허용 label 예:
|
||||
- `method` (GET/POST/…), `status_code` (bucketed 2xx/4xx/5xx가 더 안전), `route` (template).
|
||||
|
||||
규칙: **한 metric당 series 수 ≤ 10,000** 목표. 10만 넘어가면 review.
|
||||
|
||||
### 7. Histogram 을 p99 표현 기본값으로
|
||||
|
||||
- summary는 aggregatable 하지 않다 (서비스 간 p99 합산 불가).
|
||||
- `histogram_quantile()` 를 위한 `_bucket` + `_count` + `_sum` 를 쓴다.
|
||||
- bucket boundary는 SLO에 맞춰 튜닝 (`le: 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10`).
|
||||
|
||||
### 8. 로그는 JSON structured, stdout/stderr 로만
|
||||
|
||||
application log는 **JSON one-line per record**, stdout/stderr로 출력. PVC / hostPath / 컨테이너 내부 file 금지.
|
||||
|
||||
필수 field:
|
||||
|
||||
- `timestamp` (ISO 8601 RFC3339, UTC).
|
||||
- `level` (`DEBUG`/`INFO`/`WARN`/`ERROR`).
|
||||
- `service` (= `app.kubernetes.io/name`).
|
||||
- `trace_id`, `span_id` (OTel에서 주입).
|
||||
- `message`.
|
||||
- `error` (object with `type`, `message`, `stacktrace` when level=ERROR).
|
||||
- optional: `user_id` (hashed), `request_id`, `http_status`.
|
||||
|
||||
### 9. 로그 수집 stack은 한 플랫폼당 하나
|
||||
|
||||
canonical choice:
|
||||
|
||||
- **Loki + Grafana Alloy (권장)**: 낮은 storage cost, Grafana 통합.
|
||||
- **Fluent Bit → OpenSearch/Elasticsearch**: full-text search 중심, 높은 storage cost.
|
||||
|
||||
플랫폼 하나에서 둘 다 운영하지 않는다. AI agent가 매니페스트 생성할 때 플랫폼 선택을 context에서 받아 일관되게 적용한다.
|
||||
|
||||
node-level: DaemonSet으로 agent 배포 → tail `/var/log/containers/*.log`.
|
||||
|
||||
### 10. 민감정보는 로그 금지 + 자동 masking
|
||||
|
||||
금지:
|
||||
|
||||
- access/refresh token, bearer, API key.
|
||||
- DB password, connection string의 password 부분.
|
||||
- Vault secret value.
|
||||
- full Authorization header.
|
||||
- PII (email, phone, SSN 등) 원문.
|
||||
|
||||
구현:
|
||||
|
||||
- logging framework의 structured field 에서만 쓰고 `toString()` 흐름 차단.
|
||||
- 중앙 수집 파이프라인에 redaction filter 추가.
|
||||
- 의심스러운 pattern은 debug 로그에서도 masking.
|
||||
|
||||
### 11. OpenTelemetry / OTLP를 trace / metrics 통로로
|
||||
|
||||
- 애플리케이션: OTel SDK로 계측, OTLP (gRPC 4317 또는 HTTP 4318) 로 export.
|
||||
- 수집: **OTel Collector DaemonSet (agent)** → **OTel Collector Deployment (gateway)** → backend (Tempo / Jaeger / New Relic / Datadog).
|
||||
- gateway에서 sampling / tail-based sampling / PII scrubbing 적용.
|
||||
- app은 cluster 내부 agent endpoint만 알면 됨 (localhost:4317 → DaemonSet).
|
||||
|
||||
### 12. health / metrics / admin endpoint 는 외부 비공개 기본값
|
||||
|
||||
외부 비공개 대상:
|
||||
|
||||
- `/health`, `/health/*`, `/actuator/*`.
|
||||
- `/metrics`.
|
||||
- `/admin`, `/internal`, `/debug`.
|
||||
- Keycloak management port 9000.
|
||||
- Vault `/sys/*` endpoint.
|
||||
|
||||
외부 공개는 명시적 review 필요.
|
||||
|
||||
### 13. probe는 health endpoint 와 목적을 구분
|
||||
|
||||
- probe용 endpoint는 shallow, 빠른 응답.
|
||||
- 운영자 점검용 deep health는 별도 endpoint (ex: `/ops/deep-health`), 인증 필요.
|
||||
- Prometheus 가 `/metrics` 를 스크레이프하더라도 probe가 `/metrics` 를 쓰지 않는다 (cost 문제).
|
||||
|
||||
### 14. `kubectl events` 를 기본 event 조회 수단으로 (1.27+)
|
||||
|
||||
Kubernetes 1.27+ 부터 `kubectl events` 가 stable.
|
||||
|
||||
- `kubectl events -A --watch` — cluster-wide live view.
|
||||
- `kubectl events -n <ns> --for pod/<name>` — 특정 오브젝트.
|
||||
- `kubectl events --types=Warning` — 경고만.
|
||||
|
||||
`kubectl get events` 대비 sort-by-timestamp 기본, watch 안정적.
|
||||
|
||||
### 15. 알림 기준: Golden Signal 에 SLO 를 먼저 정의
|
||||
|
||||
- availability SLO: 99.9% / 99.95% 등.
|
||||
- latency SLO: p99 < 500ms.
|
||||
- error budget: (1 - SLO) × 기간.
|
||||
- alert는 **burn rate** 기준 (1h/6h fast burn + 6h/3d slow burn 이중 창).
|
||||
|
||||
단순 "CPU > 80%" alert 는 actionable 하지 않다 (saturation은 dashboard용, 알림은 SLO 기반).
|
||||
|
||||
### 16. 워크로드별 기본 권장안
|
||||
|
||||
#### auth-server (Spring Boot)
|
||||
- metrics: micrometer + prometheus registry, `/actuator/prometheus`.
|
||||
- ServiceMonitor with named port `metrics` (8081).
|
||||
- tracing: OTel Java agent, OTLP to DaemonSet.
|
||||
- logging: logback JSON encoder → stdout.
|
||||
|
||||
#### keycloak
|
||||
- metrics: management port 9000 `/metrics`.
|
||||
- ServiceMonitor 대상, `/admin` 과 `9000` 외부 비공개.
|
||||
- event metric cardinality는 `event_type` level 까지만, user / session ID 금지.
|
||||
|
||||
#### vault
|
||||
- `/sys/metrics?format=prometheus` (token 필요) → ServiceMonitor with `bearerTokenSecret`.
|
||||
- `/sys/health` 는 sealed/standby 구분해서 alert 룰 따로.
|
||||
|
||||
#### minio
|
||||
- `/minio/v2/metrics/cluster` + `/node` + `/bucket`.
|
||||
- bucket metric은 bucket 수 폭증 시 cardinality 주의.
|
||||
|
||||
#### db (PostgreSQL / MySQL)
|
||||
- postgres_exporter / mysqld_exporter sidecar 또는 별도 Deployment.
|
||||
- USE method (connection pool saturation, lock wait).
|
||||
|
||||
#### ingress-controller
|
||||
- RED + upstream response time.
|
||||
- path label은 반드시 template 화.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- Four Golden Signals를 dashboard 골격으로, RED/USE를 세부 방법론으로.
|
||||
- ServiceMonitor / PodMonitor 를 primary scrape, annotation은 fallback.
|
||||
- metrics port는 NetworkPolicy로 monitoring namespace만 허용.
|
||||
- Cardinality는 label 설계에서 가드 (user_id / raw path / timestamp 금지).
|
||||
- 로그는 JSON structured stdout, trace_id/span_id 포함.
|
||||
- log shipping stack은 플랫폼당 하나 (Loki+Alloy 또는 Fluent Bit→OpenSearch).
|
||||
- 로그에 민감정보 금지, 중앙 파이프라인 redaction.
|
||||
- OpenTelemetry DaemonSet agent + Deployment gateway.
|
||||
- health / metrics / admin endpoint 외부 비공개.
|
||||
- `kubectl events` 를 기본 event 조회 수단으로 (1.27+).
|
||||
- alert는 SLO burn rate 기반, CPU% 같은 단순 threshold 금지.
|
||||
@@ -0,0 +1,301 @@
|
||||
# operations / runbook / upgrade / rollback 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 1000+ 서비스를 운영하는 플랫폼에서 모든 변경이 거쳐야 하는 **runbook 규칙**을 고정한다. GitOps 원본, rolling update 파라미터 튜닝, 진보된 배포 전략 (Argo Rollouts, canary, blue/green), K3s 자동 업그레이드, node 작업(drain/cordon), rollback 의미와 경계가 대상이다.
|
||||
|
||||
## 공식 / 업계 근거
|
||||
|
||||
- Kubernetes `Deployment.spec.strategy`: `RollingUpdate` (default, maxSurge/maxUnavailable 25%/25%) 또는 `Recreate` (singleton).
|
||||
- `kubectl rollout`: `status --timeout`, `history`, `undo --to-revision`, `pause`, `resume`, `restart`.
|
||||
- **Argo Rollouts** (https://argoproj.github.io/argo-rollouts/): `Rollout` CRD가 Deployment의 대체제로 canary / blueGreen 지원. `AnalysisTemplate` + Prometheus metric으로 자동 승격/롤백.
|
||||
- **Flagger**: Argo Rollouts의 대안, service-mesh 친화적 (Istio/Linkerd/App Mesh).
|
||||
- **ArgoCD**: sync wave (`argocd.argoproj.io/sync-wave: "<int>"`), sync phase hook (`PreSync`, `Sync`, `PostSync`, `SyncFail`, `PostDelete`).
|
||||
- **Flux**: `Kustomization.spec.dependsOn` 으로 순서 명시.
|
||||
- `kubectl drain --ignore-daemonsets --delete-emptydir-data --grace-period=30` 가 node maintenance 표준. PDB를 존중하므로 PDB 설계가 전제.
|
||||
- **K3s System Upgrade Controller** (https://docs.k3s.io/upgrades/automated): `Plan` CRD로 server-plan / agent-plan 분리, concurrency 제어, nodeSelector로 대상 제한.
|
||||
- Flyway `validate`, `info`, `migrate` — application rollout 과 분리.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. Source of truth = Git 의 Kustomize / Helm overlay
|
||||
|
||||
운영 변경은 Git에 있는 선언형 원본에서만 시작한다.
|
||||
|
||||
기본 금지:
|
||||
|
||||
- 운영 노드에서 manifest 파일 직접 편집.
|
||||
- `kubectl edit` 로 live object 수정 후 문서 없음.
|
||||
- `/var/lib/rancher/k3s/server/manifests` 를 1차 원본처럼 사용.
|
||||
|
||||
### 2. 변경 절차는 render → diff → apply → status → post-check 로 고정
|
||||
|
||||
```
|
||||
1. kubectl kustomize <overlay> # render
|
||||
2. kubectl diff -k <overlay> # preview
|
||||
3. kubectl apply -k <overlay> # apply
|
||||
4. kubectl rollout status ... --timeout=10m
|
||||
5. post-check (smoke test, SLO check)
|
||||
```
|
||||
|
||||
`diff` 없는 `apply` 는 프로덕션 금지.
|
||||
|
||||
### 3. `rollingUpdate.maxSurge` / `maxUnavailable` 는 워크로드별 튜닝
|
||||
|
||||
기본값 `25% / 25%` 는 **replica 수에 따라 틀릴 수 있다**.
|
||||
|
||||
- **replica 2**: default는 maxUnavailable 0, maxSurge 1 추천 → 항상 최소 2 유지 + 1 추가.
|
||||
- **replica 3**: `maxSurge: 1, maxUnavailable: 0` → 가용성 우선.
|
||||
- **replica 10+**: `maxSurge: 25%, maxUnavailable: 10%` → 속도와 가용성 균형.
|
||||
- **latency-sensitive**: `maxUnavailable: 0` 고정.
|
||||
- **cost-sensitive large fleet**: `maxSurge: 10%, maxUnavailable: 10%`.
|
||||
|
||||
### 4. `Recreate` 전략은 singleton / 동시성 금지 워크로드에만
|
||||
|
||||
- PVC ReadWriteOnce + 단일 pod 가 전제인 app (legacy MySQL single instance 등).
|
||||
- Old/New 동시 실행 시 데이터 부정합이 나는 앱.
|
||||
- 짧은 downtime이 허용되는 경우.
|
||||
|
||||
일반 stateless app은 절대 Recreate 쓰지 않는다.
|
||||
|
||||
### 5. `kubectl rollout` 명령 계열
|
||||
|
||||
- `kubectl rollout status deployment/<name> --timeout=10m`: 타임아웃 필수.
|
||||
- `kubectl rollout history deployment/<name>`: revision 확인.
|
||||
- `kubectl rollout undo deployment/<name> --to-revision=<N>`: 이전 revision으로 되돌림.
|
||||
- `kubectl rollout pause deployment/<name>`: 롤아웃 중단 (부분 적용 뒤 관찰용).
|
||||
- `kubectl rollout resume deployment/<name>`: 재개.
|
||||
- `kubectl rollout restart deployment/<name>`: 이미지 변경 없이 Pod 재생성 (secret 갱신 후 등).
|
||||
|
||||
### 6. 진보된 배포 전략: Argo Rollouts (canary / blueGreen)
|
||||
|
||||
표준 `Deployment` 로는 부족한 경우 (자동화된 canary, metric-based 승격) 에는 Argo Rollouts 의 `Rollout` CRD 를 쓴다.
|
||||
|
||||
- **canary**: `steps:` 로 traffic %, pause, analysis 순서 기술.
|
||||
- **blueGreen**: `activeService` / `previewService` 로 서비스 두 개 전환.
|
||||
- **AnalysisTemplate**: Prometheus query로 success rate / p99 latency 측정 → 자동 promote or abort.
|
||||
- **대안 Flagger**: Istio / Linkerd / App Mesh + Flagger `Canary` CRD. service mesh 있는 플랫폼에서 선택.
|
||||
|
||||
### 7. Argo Rollouts 기본 canary 스텝
|
||||
|
||||
```
|
||||
steps:
|
||||
- setWeight: 10
|
||||
- pause: { duration: 2m }
|
||||
- analysis: { templates: [{ templateName: success-rate }] }
|
||||
- setWeight: 25
|
||||
- pause: { duration: 5m }
|
||||
- analysis: { templates: [...] }
|
||||
- setWeight: 50
|
||||
- pause: { duration: 10m }
|
||||
- setWeight: 100
|
||||
```
|
||||
|
||||
각 setWeight 사이에 pause + analysis 로 자동 abort gate.
|
||||
|
||||
### 8. blueGreen 은 traffic cutover 가 필요한 경우만
|
||||
|
||||
blueGreen은:
|
||||
- schema 변경이 양립 불가해서 instant cutover가 필요.
|
||||
- 외부 system 과 coordination 필요 (rollback도 instant).
|
||||
|
||||
일반 변경은 canary 가 우선. blueGreen 은 trade-off (리소스 2배, warm-up 부담) 때문에 default 가 아니다.
|
||||
|
||||
### 9. ArgoCD sync wave / hook
|
||||
|
||||
배포 순서는 sync wave annotation 으로 명시한다.
|
||||
|
||||
- `argocd.argoproj.io/sync-wave: "-2"` → CRD.
|
||||
- `argocd.argoproj.io/sync-wave: "-1"` → namespace, secret store, operator.
|
||||
- `argocd.argoproj.io/sync-wave: "0"` → 본 리소스 (기본).
|
||||
- `argocd.argoproj.io/sync-wave: "1"` → Ingress, post-deploy job.
|
||||
|
||||
hook:
|
||||
|
||||
- `PreSync`: schema migration job.
|
||||
- `Sync`: 본 리소스 (default).
|
||||
- `PostSync`: smoke test Job, cache warm.
|
||||
- `SyncFail`: 실패 시 알림 Job.
|
||||
- `PostDelete`: 삭제 후 cleanup.
|
||||
|
||||
### 10. Flux Kustomization dependsOn
|
||||
|
||||
Flux 플랫폼에서는 `Kustomization.spec.dependsOn` 으로 순서를 명시한다.
|
||||
|
||||
```yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: auth-server
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 5m
|
||||
path: ./k8s/overlays/prod
|
||||
prune: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: platform
|
||||
dependsOn:
|
||||
- name: cert-manager
|
||||
- name: postgres-operator
|
||||
```
|
||||
|
||||
### 11. node 작업 (drain / cordon) 은 PDB 존중 흐름
|
||||
|
||||
```
|
||||
1. kubectl cordon <node>
|
||||
2. kubectl drain <node> \
|
||||
--ignore-daemonsets \
|
||||
--delete-emptydir-data \
|
||||
--grace-period=30 \
|
||||
--timeout=10m
|
||||
3. 작업 수행
|
||||
4. kubectl uncordon <node>
|
||||
```
|
||||
|
||||
옵션 의미:
|
||||
|
||||
- `--ignore-daemonsets`: DaemonSet pod는 evict 대상이 아님.
|
||||
- `--delete-emptydir-data`: ephemeral 데이터 수용.
|
||||
- `--grace-period=30`: preStop + terminationGracePeriod 존중.
|
||||
- `--timeout=10m`: PDB 로 인한 무한 대기 차단.
|
||||
|
||||
**PDB 없는 critical workload** 는 drain 실패 또는 downtime 유발. PDB 설계가 선결 조건.
|
||||
|
||||
### 12. K3s System Upgrade Controller
|
||||
|
||||
K3s 자동 업그레이드는 `system-upgrade-controller` 의 `Plan` CRD 를 쓴다.
|
||||
|
||||
구성:
|
||||
|
||||
- **server-plan**: control-plane 먼저 업그레이드. `concurrency: 1`, nodeSelector: `node-role.kubernetes.io/control-plane=true`.
|
||||
- **agent-plan**: agent 노드. `concurrency: 1~N` (small cluster는 1), server-plan 완료 후.
|
||||
- `cordon: true`, `drain.force: true, deleteEmptydirData: true, ignoreDaemonsets: true` 표준.
|
||||
- `version:` 또는 `channel:` 로 target K3s version.
|
||||
- `upgrade.image: rancher/k3s-upgrade` + 버전 tag.
|
||||
|
||||
### 13. blue/green via two Services (수동 패턴)
|
||||
|
||||
Argo Rollouts 없이 간단 blue/green 이 필요하면:
|
||||
|
||||
- Deployment A (blue), Deployment B (green) 각각.
|
||||
- Service selector 의 `version` label 만 전환 (blue → green).
|
||||
- rollback = selector 를 다시 blue 로.
|
||||
- canary 는 이 방식으로 구현하지 않는다 (Argo Rollouts 사용).
|
||||
|
||||
### 14. Rollback 은 DB rollback 이 아니다
|
||||
|
||||
**가장 자주 오해되는 규칙**. 반드시 내재화한다.
|
||||
|
||||
- `kubectl rollout undo` 는 Deployment workload 만 되돌린다.
|
||||
- **DB schema 변경 / migration 은 되돌아가지 않는다**.
|
||||
- rollback 설계는 **schema-forward-compatible** 로 한다:
|
||||
- Expand (schema 추가 → 이전 코드도 호환) → Migrate (데이터 이전) → Contract (이전 코드용 schema 제거). Expand/Contract를 별도 릴리스로 분리.
|
||||
- 긴급 상황에서도 rollout undo 로 DB 를 되돌릴 수 없다. DB 는 별도 restore 절차 (PITR, snapshot).
|
||||
|
||||
### 15. Flyway validate → migrate 를 application rollout 과 분리
|
||||
|
||||
```
|
||||
1. flyway validate # checksum / 순서 확인
|
||||
2. flyway info # 대기 migration 확인
|
||||
3. flyway migrate # 실제 적용
|
||||
4. kubectl apply -k ... # app rollout (별도 단계)
|
||||
5. kubectl rollout status # 앱 기동 확인
|
||||
```
|
||||
|
||||
application startup 안에 migration 을 숨기지 않는다 (rollout 실패와 migration 실패 섞임).
|
||||
|
||||
### 16. restore 와 rollout 구분
|
||||
|
||||
**rollout** (workload 변경 되돌리기):
|
||||
|
||||
- `kubectl rollout undo` 또는 이전 Git revision apply.
|
||||
- Deployment / StatefulSet / DaemonSet 대상.
|
||||
|
||||
**restore** (상태 복구):
|
||||
|
||||
- K3s control plane → etcd snapshot restore.
|
||||
- PostgreSQL → PITR / base backup + WAL.
|
||||
- Vault → raft snapshot restore.
|
||||
- MinIO → replication resync 또는 DR site cutover.
|
||||
|
||||
서로 다른 runbook 이다. "rollback" 이라는 한 단어로 뭉치지 않는다.
|
||||
|
||||
### 17. 긴급 변경도 runbook 을 벗어나지 않음
|
||||
|
||||
장애 대응 hot-fix 라도:
|
||||
|
||||
- 어떤 overlay 를 바꿨는지 commit / PR.
|
||||
- 어떤 명령을 실행했는지 기록 (shell history / runbook log).
|
||||
- 사후 Git 반영 (live-cluster drift 제거).
|
||||
- 임시 조치의 만료 / 정리 시점 기록.
|
||||
|
||||
### 18. destructive 작업은 명시 승인 + 증거 보존
|
||||
|
||||
요구 작업:
|
||||
|
||||
- namespace 삭제.
|
||||
- PVC 삭제.
|
||||
- StatefulSet 삭제 + PVC 정리.
|
||||
- K3s snapshot restore.
|
||||
- Vault raft snapshot restore.
|
||||
- DB restore overwrite.
|
||||
- MinIO bucket purge / replication cutover.
|
||||
|
||||
규칙:
|
||||
|
||||
- 2-person approval.
|
||||
- 작업 전 full snapshot 확보.
|
||||
- dry-run / diff 선행.
|
||||
- post-mortem 작성.
|
||||
|
||||
## 권장 절차 템플릿
|
||||
|
||||
### 일반 app 변경
|
||||
1. PR 생성 + review
|
||||
2. `kubectl kustomize <overlay>` → 렌더 검증
|
||||
3. `kubectl diff -k <overlay>` → 변경 확인
|
||||
4. `kubectl apply -k <overlay>`
|
||||
5. `kubectl rollout status deployment/<name> --timeout=10m`
|
||||
6. smoke test + SLO dashboard 확인
|
||||
7. 결과 PR comment
|
||||
|
||||
### DB migration 포함 변경
|
||||
1. migration SQL review
|
||||
2. `flyway validate` → `flyway info` → `flyway migrate`
|
||||
3. app overlay apply
|
||||
4. `kubectl rollout status`
|
||||
5. post-check
|
||||
6. 실패 시 DB runbook 과 app rollback runbook 분리 적용
|
||||
|
||||
### K3s control plane upgrade
|
||||
1. 해당 버전 release notes / caveat 확인
|
||||
2. etcd snapshot 확보
|
||||
3. `Plan` CRD apply (server-plan)
|
||||
4. control-plane 업그레이드 완료 확인
|
||||
5. `Plan` CRD apply (agent-plan)
|
||||
6. agent 업그레이드 완료 확인
|
||||
7. packaged component 영향 확인
|
||||
8. 실패 시 etcd restore runbook
|
||||
|
||||
### node maintenance
|
||||
1. `kubectl cordon <node>`
|
||||
2. `kubectl drain <node> --ignore-daemonsets --delete-emptydir-data --grace-period=30 --timeout=10m`
|
||||
3. 작업 수행
|
||||
4. `kubectl uncordon <node>`
|
||||
5. `kubectl get pods -o wide` 로 재배치 확인
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- source of truth = Git Kustomize/Helm overlay, live-cluster 수정 금지.
|
||||
- render → diff → apply → rollout status → post-check 순서 고정.
|
||||
- rollingUpdate 파라미터는 워크로드별 튜닝, default 25%/25% 맹신 금지.
|
||||
- Argo Rollouts 로 canary + AnalysisTemplate 자동 gate, Flagger 는 mesh 환경 대안.
|
||||
- ArgoCD sync wave / hook, Flux dependsOn 으로 순서 명시.
|
||||
- node 작업은 PDB 존중 drain 흐름, PDB 설계가 선결.
|
||||
- K3s 업그레이드는 System Upgrade Controller `Plan` CRD (server → agent).
|
||||
- blue/green 은 cutover 필요 시, canary 가 default.
|
||||
- **rollback 은 DB rollback 이 아니다** — schema-forward-compatible 로 설계.
|
||||
- Flyway validate/migrate 는 application rollout 과 분리.
|
||||
- restore 와 rollout 은 다른 runbook.
|
||||
- destructive 작업은 2-person approval + snapshot.
|
||||
@@ -0,0 +1,199 @@
|
||||
# resources / probes / availability 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 1000+ 서비스를 운영하는 Kubernetes 플랫폼에서 AI coding agent가 생성하는 모든 워크로드 매니페스트의 ground truth다. 모든 rule은 Google SRE / Netflix / Shopify의 실제 프로덕션 합의를 기반으로 한다.
|
||||
|
||||
정하는 것:
|
||||
|
||||
- resource requests/limits를 어떤 값으로, 어떤 QoS class로 줄지
|
||||
- probe (startup / readiness / liveness) 세 축을 어떻게 분리할지
|
||||
- 가용성(PDB / topologySpread / HPA)을 어떤 조합으로 구성할지
|
||||
- K3s 환경에서 metrics-server 전제를 어떻게 다룰지
|
||||
|
||||
## 공식 / 업계 근거
|
||||
|
||||
- Kubernetes QoS class는 resources 값에 의해 자동 결정된다 (`Guaranteed`, `Burstable`, `BestEffort`).
|
||||
- CPU는 compressible resource로 limit 초과 시 throttle된다. memory는 incompressible로 OOM kill된다.
|
||||
- Tim Hockin (Google, Kubernetes co-founder) 및 다수 SRE 컨퍼런스 토크: **CPU limit는 CFS throttling을 quota 미만에서도 유발하므로 대부분의 프로덕션 워크로드에서 제거한다**. CPU request만 설정하여 노드 capacity를 공정 공유한다.
|
||||
- memory limit는 OOM kill의 유일한 제어 수단이므로 반드시 설정한다.
|
||||
- `topologySpreadConstraints`는 1.19+ stable. zone과 host 두 축으로 skew를 제한하는 것이 표준이다.
|
||||
- `podAntiAffinity`는 legacy 대안, 현대 가이드는 topologySpreadConstraints 우선.
|
||||
- HPA v2 (`autoscaling/v2`) 는 `behavior` block으로 scale up/down stabilizationWindow와 policy를 분리 제어한다.
|
||||
- PodDisruptionBudget은 `maxUnavailable` 또는 `minAvailable`. 대규모 fleet에서는 `maxUnavailable` 권장 (replica scale 변화 추종).
|
||||
- startup probe는 성공 전까지 liveness/readiness를 차단한다. slow boot 서비스에 필수.
|
||||
- Kubernetes 1.29+ native sidecar: init container에 `restartPolicy: Always` 명시.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. QoS class는 의도적으로 선택한다
|
||||
|
||||
QoS class는 `resources` 값의 결과물이 아니라 **선택**이다.
|
||||
|
||||
- **Guaranteed**: 모든 컨테이너의 request == limit. 가장 높은 eviction 우선순위 보호.
|
||||
- 적용: latency-sensitive JVM (Keycloak, auth-server critical tier), stateful 단일 인스턴스 (vault active), 단일 ReplicaSet critical path.
|
||||
- **Burstable**: request < limit 또는 일부만 설정. 탄력적 CPU burst 허용.
|
||||
- 적용: stateless HTTP API, worker, generic service — 기본값.
|
||||
- **BestEffort**: request/limit 모두 없음. 가장 먼저 evict됨.
|
||||
- 적용: 일시적 debugging pod, 무영향 experiment. 프로덕션 금지.
|
||||
|
||||
### 2. CPU limit anti-pattern — 기본은 CPU request only
|
||||
|
||||
Google SRE 및 Tim Hockin의 공식 stance는 "대부분의 워크로드에서 CPU limit를 설정하지 말 것"이다. Linux CFS의 quota 회계가 sub-period burst에서도 throttle을 유발하기 때문이다.
|
||||
|
||||
기본:
|
||||
|
||||
- **CPU**: request만 설정, limit 생략.
|
||||
- **Memory**: limit 반드시 설정.
|
||||
- Guaranteed를 원하면: `limits.memory == requests.memory`.
|
||||
- Burstable 기본값: `limits.memory = 1.1 ~ 1.5 × requests.memory`.
|
||||
|
||||
예외 (CPU limit를 설정해야 하는 경우):
|
||||
|
||||
- multi-tenant 노드에서 noisy neighbor가 측정 가능한 손해를 유발.
|
||||
- batch/cron Job에서 예산 통제가 필요.
|
||||
- billing-backed 측정으로 인한 compliance 요구.
|
||||
|
||||
### 3. requests 값은 측정 기반으로 잡는다
|
||||
|
||||
- p95 cpu usage × 1.2 가 request 시작점.
|
||||
- p99 memory (steady state) × 1.3 이 memory request 시작점.
|
||||
- 최초 배포는 **overprovision** 으로 시작 → 1~2주 관측 후 right-sizing.
|
||||
- VPA recommendation을 참고하되 자동 적용은 하지 않는다 (review 필요).
|
||||
|
||||
### 4. `limit`만 있고 `request`가 없는 구성 금지
|
||||
|
||||
Kubernetes는 request 미설정 시 limit를 request로 복사한다. 이는 암묵적 Guaranteed QoS로 귀결되며 의도와 다를 수 있다. 반드시 둘 다 명시한다.
|
||||
|
||||
### 5. Probe는 세 축으로 분리한다
|
||||
|
||||
- **startup probe**: "부팅이 끝났는가". 성공 전까지 readiness/liveness는 실행되지 않는다.
|
||||
- 필수: Keycloak, Vault, JVM warm-up이 긴 서비스.
|
||||
- 타이밍 규칙: `failureThreshold × periodSeconds ≥ 최악의 cold start (p99)`. 예: Keycloak `periodSeconds: 10, failureThreshold: 30` = 300s.
|
||||
- **readiness probe**: "지금 트래픽을 받아도 되는가". 실패 시 Service endpoint에서 제외.
|
||||
- 모든 traffic-facing 서비스 필수.
|
||||
- 외부 의존성 전체 가용성을 묶지 않는다 (동시 탈락 방지).
|
||||
- **liveness probe**: "재시작이 치료인가" (deadlock only).
|
||||
- Default = 설정하지 않거나 readiness와 다른 가벼운 self-check.
|
||||
- **잘못 설정하면 cascading restart 유발**. Kubernetes 공식 문서 명시.
|
||||
|
||||
### 6. readiness는 shallow, liveness는 더 shallow
|
||||
|
||||
readiness는 "app loop이 요청을 처리 가능한가"까지만 검사한다. DB connection pool 초기화처럼 intra-pod 조건은 OK. 외부 DB `SELECT 1` 전체 가용성 체크는 금지.
|
||||
|
||||
liveness는 process deadlock 감지 전용. HTTP endpoint면 `/livez` 같은 매우 가벼운 200 응답.
|
||||
|
||||
### 7. topologySpreadConstraints를 기본 가용성 primitive로
|
||||
|
||||
production multi-zone cluster에서는 **zone + host 두 축** 모두 제약한다.
|
||||
|
||||
```yaml
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: topology.kubernetes.io/zone
|
||||
whenUnsatisfiable: DoNotSchedule
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: auth-server
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: auth-server
|
||||
```
|
||||
|
||||
- zone `DoNotSchedule`: 프로덕션에서 zone 장애 격리에 필수.
|
||||
- host `ScheduleAnyway`: 노드 부족 시 배포 불가 방지.
|
||||
|
||||
### 8. podAntiAffinity는 legacy로 본다
|
||||
|
||||
topologySpreadConstraints가 등장한 뒤 podAntiAffinity는 대부분의 use case에서 대체되었다. 신규 매니페스트는 topologySpreadConstraints를 우선 적용한다.
|
||||
|
||||
예외: 단순 "한 노드에 두 개 이상 금지" 규칙만 필요하고 spread 회계가 불필요한 경우.
|
||||
|
||||
### 9. HPA는 autoscaling/v2, behavior block 필수
|
||||
|
||||
`autoscaling/v1`은 더 이상 사용하지 않는다. `autoscaling/v2`를 기본으로 한다.
|
||||
|
||||
- `metrics:` 유형: `Resource` (cpu/memory), `Pods`, `Object`, `External`, `ContainerResource`.
|
||||
- `behavior.scaleUp.stabilizationWindowSeconds: 0` (트래픽 급증에 빠르게 반응).
|
||||
- `behavior.scaleDown.stabilizationWindowSeconds: 300` (flapping 방지).
|
||||
- `policies` 조합: `type: Percent` (현 replica의 X%)와 `type: Pods` (절대 수) 동시 지정, `selectPolicy: Max` 또는 `Min`.
|
||||
|
||||
### 10. HPA 전제 조건
|
||||
|
||||
- resource requests가 먼저 잡혀 있어야 한다 (utilization target이 request 기준).
|
||||
- startup probe가 안정화되어 있어야 한다 (scale-up 중 flapping 방지).
|
||||
- 해당 워크로드가 **horizontal scale로 효과가 있는** 성격이어야 한다. stateful / DB / quorum 기반은 HPA 비대상.
|
||||
- K3s metrics-server가 packaged로 배포되어 있음을 전제로 하되, availability를 runbook에서 점검한다.
|
||||
|
||||
### 11. PDB는 fleet 규모에 맞춰 `maxUnavailable` 우선
|
||||
|
||||
- replica ≥ 3: `maxUnavailable: 1` 또는 `maxUnavailable: 25%`.
|
||||
- replica 대규모 (10+): `maxUnavailable: 10%` 권장 (유연성).
|
||||
- replica 2: `maxUnavailable: 1`.
|
||||
- replica 1: PDB 금지 (node drain을 막는다).
|
||||
- quorum 기반 (etcd, vault raft, DB cluster): `minAvailable` 로 quorum 수 명시.
|
||||
|
||||
### 12. PDB zero disruption 금지
|
||||
|
||||
`maxUnavailable: 0` 또는 `minAvailable: 100%` 는 node drain / maintenance를 완전 차단한다. Kubernetes 업그레이드 자체가 불가능해진다. 명시적 예외 승인 없이 사용 금지.
|
||||
|
||||
### 13. init container 와 sidecar 순서 (1.29+)
|
||||
|
||||
- **init container**: main 전에 실행, 완료 후 종료. schema migration, secret preparation 용.
|
||||
- **native sidecar (1.29+)**: init container에 `restartPolicy: Always` 명시. main과 병렬 실행, main 종료 후 종료.
|
||||
- 사용: log forwarder, metrics exporter, service mesh proxy.
|
||||
- `initContainers` 배열 순서가 실행 순서다.
|
||||
|
||||
### 14. 워크로드별 기본 권장안
|
||||
|
||||
#### auth-server (stateless Spring Boot)
|
||||
- QoS: **Burstable**.
|
||||
- CPU: request only (`500m`). Memory: request `1Gi`, limit `1.5Gi`.
|
||||
- Probes: startup `/actuator/health/started` (60s), readiness `/actuator/health/readiness`, liveness `/actuator/health/liveness`.
|
||||
- HPA: CPU 70%, min 3, max 20, scale-down 300s.
|
||||
- PDB: `maxUnavailable: 1`.
|
||||
- topologySpread: zone `DoNotSchedule`, host `ScheduleAnyway`.
|
||||
|
||||
#### keycloak (JVM, slow boot, latency-sensitive)
|
||||
- QoS: **Guaranteed** (request == limit, memory 2Gi 고정).
|
||||
- CPU: request `1`, limit `1` (Guaranteed 요구).
|
||||
- Probes: startup 5분 budget (`periodSeconds: 10, failureThreshold: 30`), readiness `/health/ready` on 9000, liveness `/health/live` on 9000.
|
||||
- HPA: 보통 **비대상**. 고정 replica (3)로 시작, 측정 후 검토.
|
||||
- PDB: `maxUnavailable: 1`.
|
||||
|
||||
#### vault (raft quorum)
|
||||
- QoS: **Guaranteed**.
|
||||
- Probes: readiness/liveness는 raft sealed/active 상태 구분.
|
||||
- HPA: 비대상.
|
||||
- PDB: `minAvailable: 2` (3-node raft 기준 quorum 보존).
|
||||
|
||||
#### minio (erasure coded storage)
|
||||
- QoS: **Guaranteed**.
|
||||
- PDB: `minAvailable: N-1` (erasure set 기준).
|
||||
- HPA: 비대상.
|
||||
|
||||
#### migration-flyway (Job)
|
||||
- probe 없음 (Job은 probe 무의미).
|
||||
- requests 명시, limit는 memory만.
|
||||
- activeDeadlineSeconds 설정.
|
||||
- HPA/PDB 비대상.
|
||||
|
||||
#### ingress-controller
|
||||
- QoS: **Burstable** 또는 Guaranteed (tier에 따라).
|
||||
- HPA 후보 (traffic 기반).
|
||||
- PDB: `maxUnavailable: 1`.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- QoS는 의도적으로 선택. Guaranteed는 latency-sensitive JVM, Burstable은 stateless 기본.
|
||||
- CPU limit 기본 제거 (throttling 회피). Memory limit 필수.
|
||||
- requests/limits 함께 명시. limit만 단독 금지.
|
||||
- probe 세 축 분리. startup 타이밍은 worst-case cold start 기준.
|
||||
- topologySpreadConstraints zone + host 두 축으로 기본 구성.
|
||||
- HPA v2 + behavior block. resource requests / startup 안정화 후 적용.
|
||||
- PDB는 `maxUnavailable` 우선, replica 전략과 함께 결정.
|
||||
- 1.29+ native sidecar는 init container `restartPolicy: Always`.
|
||||
- K3s metrics-server는 HPA 전제로만 신뢰, full metrics는 별도 stack.
|
||||
@@ -0,0 +1,293 @@
|
||||
# infra scripts 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 1000+ 서비스 플랫폼에서 인프라 스크립트가 지켜야 할 품질 기준선이다. 스크립트는 선언형 원본 (Kustomize / Helm / ArgoCD / Flux) 을 **대체하지 않는다**. 렌더 / diff / 적용 / 백업 / 복구 / 부트스트랩을 **orchestration** 하는 얇은 레이어로 제한한다.
|
||||
|
||||
## 공식 / 업계 근거
|
||||
|
||||
- **Google Shell Style Guide**: `#!/usr/bin/env bash`, `set -e`, `main "$@"`, function-first, `local`.
|
||||
- **Unofficial Bash Strict Mode (Aaron Maxwell)**: `set -euo pipefail` + `IFS=$'\n\t'` 가 사실상 표준.
|
||||
- **ShellCheck** (https://www.shellcheck.net/): 정적 분석. CI에서 mandatory.
|
||||
- **shfmt** (mvdan/sh): 자동 포맷터. line-length / indent 규격 강제.
|
||||
- **GitOps 원칙** (Weaveworks 정의): 선언형 원본 + auto-reconcile. 스크립트는 원본을 소유하지 않는다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 모든 스크립트 맨 위에 strict mode
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
```
|
||||
|
||||
의미:
|
||||
|
||||
- `set -e` : 명령 실패 시 즉시 종료.
|
||||
- `set -u` : unset variable 참조 시 에러.
|
||||
- `set -o pipefail` : pipeline 중 하나라도 실패하면 전체 실패.
|
||||
- `IFS=$'\n\t'` : 기본 IFS에서 space 제거 → 파일명 공백 sane split.
|
||||
|
||||
예외 금지. CI lint 에서 검사.
|
||||
|
||||
### 2. 정리 작업은 `trap` 으로 보장
|
||||
|
||||
임시 파일 / 임시 kubeconfig / port-forward / background job 은 반드시 trap EXIT 에서 정리.
|
||||
|
||||
```bash
|
||||
TMPDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${TMPDIR}"' EXIT INT TERM
|
||||
```
|
||||
|
||||
- `EXIT`: 정상/비정상 종료 모두 잡음.
|
||||
- `INT TERM`: signal 기반 종료 시에도 실행.
|
||||
- trap은 setup 직후 즉시 설치.
|
||||
|
||||
### 3. ShellCheck + shfmt 는 CI 에서 필수
|
||||
|
||||
- `shellcheck -S style scripts/**/*.sh` → CI fail 시 merge 금지.
|
||||
- `shfmt -i 2 -bn -ci -d scripts/` → 자동 포맷 검증.
|
||||
- suppress (`# shellcheck disable=...`) 는 **줄 단위**로만, 이유 주석 필수.
|
||||
- "경고 너무 많아서 꺼둔다" 금지.
|
||||
|
||||
### 4. 표준 `log()` 함수 (ISO 8601 timestamp + level, stderr)
|
||||
|
||||
```bash
|
||||
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; }
|
||||
```
|
||||
|
||||
- stdout 은 머신 판독용 결과 전용.
|
||||
- stderr 로 로그 → pipeline 안전.
|
||||
- UTC ISO 8601 로 tz 모호성 제거.
|
||||
|
||||
### 5. 엔트리포인트 `main "$@"` 패턴
|
||||
|
||||
```bash
|
||||
usage() {
|
||||
cat <<'EOF' >&2
|
||||
Usage: render-diff-apply.sh [--overlay PATH] [--context NAME] [--yes]
|
||||
--overlay PATH path to kustomize overlay (required)
|
||||
--context NAME kube context (required)
|
||||
--yes skip confirmation for apply
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
# 인자 파싱
|
||||
# 환경 검증
|
||||
# 함수 호출
|
||||
:
|
||||
}
|
||||
|
||||
main "$@"
|
||||
```
|
||||
|
||||
- 엔트리포인트 스크립트는 **얇게**. 비즈니스 로직은 `lib/` 또는 `tasks/`.
|
||||
- `usage()` 함수 필수.
|
||||
|
||||
### 6. 변수는 `local`, command substitution 은 분리
|
||||
|
||||
```bash
|
||||
bad_pattern() {
|
||||
local ctx="$(kubectl config current-context)" # local 이 exit status 가려버림
|
||||
}
|
||||
|
||||
good_pattern() {
|
||||
local ctx
|
||||
ctx="$(kubectl config current-context)" # 분리 → $? 보존
|
||||
}
|
||||
```
|
||||
|
||||
ShellCheck SC2155 가 이것을 잡음.
|
||||
|
||||
### 7. Idempotent 를 기본값으로
|
||||
|
||||
- `create` 보다 `apply` / `ensure` 성격.
|
||||
- `kubectl apply -k` 는 idempotent.
|
||||
- `mkdir -p`, `kubectl create namespace X --dry-run=client -o yaml | kubectl apply -f -` 패턴.
|
||||
- destroy 성격은 반드시 opt-in.
|
||||
|
||||
### 8. `kubectl diff` → `kubectl apply` 필수 흐름
|
||||
|
||||
프로덕션 적용 스크립트 기본 흐름:
|
||||
|
||||
```
|
||||
1. kubectl kustomize <overlay> > render.yaml # render
|
||||
2. kubeconform / kubectl apply --dry-run=server # validate
|
||||
3. kubectl diff -k <overlay> # preview
|
||||
4. confirm gate (CONFIRM=yes 또는 --yes)
|
||||
5. kubectl apply -k <overlay> # apply
|
||||
6. kubectl rollout status ... --timeout=10m # watch
|
||||
```
|
||||
|
||||
### 9. `--dry-run=server` 를 validation 기본값으로
|
||||
|
||||
client-side dry run 은 CRD schema / admission webhook 을 평가하지 않는다. **server-side dry run** 을 쓴다:
|
||||
|
||||
```bash
|
||||
kubectl apply -k "${OVERLAY}" --dry-run=server
|
||||
```
|
||||
|
||||
### 10. destructive 작업은 `--yes` 또는 `CONFIRM=yes` gate
|
||||
|
||||
delete / prune / restore overwrite 류는 명시적 opt-in 없이 실행 금지.
|
||||
|
||||
```bash
|
||||
if [[ "${CONFIRM:-no}" != "yes" ]]; then
|
||||
fatal "destructive operation requires CONFIRM=yes"
|
||||
fi
|
||||
```
|
||||
|
||||
또는:
|
||||
|
||||
```bash
|
||||
if [[ "${YES:-0}" -ne 1 ]]; then
|
||||
warn "re-run with --yes to confirm"
|
||||
exit 2
|
||||
fi
|
||||
```
|
||||
|
||||
### 11. 환경을 암묵적으로 추론하지 않는다
|
||||
|
||||
- 대상 overlay / namespace / context 는 **명시적 인자**로.
|
||||
- `kubectl config current-context` 에 몰래 의존 금지.
|
||||
- 필요한 env var 는 시작 시 `[[ -z "${FOO:-}" ]] && fatal "FOO required"` 로 검증.
|
||||
|
||||
### 12. JSON 파싱은 `jq` / `kubectl -o jsonpath`, 절대 regex 로 하지 않는다
|
||||
|
||||
```bash
|
||||
# BAD
|
||||
kubectl get pod foo -o yaml | grep "image:" | awk '{print $2}'
|
||||
|
||||
# GOOD
|
||||
kubectl get pod foo -o jsonpath='{.spec.containers[0].image}'
|
||||
|
||||
# GOOD
|
||||
kubectl get pod foo -o json | jq -r '.spec.containers[0].image'
|
||||
```
|
||||
|
||||
kubectl/kubernetes 출력에 regex 쓰면 field 순서 / 라벨 / 버전 변화에 깨진다.
|
||||
|
||||
### 13. 비밀값은 로그 / stdout / 파일에 남기지 않는다
|
||||
|
||||
- env var / secret value 를 `set -x` 아래에서 직접 사용 금지.
|
||||
- debug 모드에서는 masking:
|
||||
|
||||
```bash
|
||||
mask_secrets() {
|
||||
sed -E \
|
||||
-e 's/(password=)[^ ]+/\1***/g' \
|
||||
-e 's/(token=)[^ ]+/\1***/g' \
|
||||
-e 's/(Authorization: Bearer )[A-Za-z0-9._-]+/\1***/g'
|
||||
}
|
||||
|
||||
some_command --debug | mask_secrets
|
||||
```
|
||||
|
||||
- secret 을 참조해야 하면 `--from-file` 이나 stdin pipe 로 주입, argv 금지.
|
||||
|
||||
### 14. 스크립트는 선언형 원본을 소유하지 않는다
|
||||
|
||||
**금지**:
|
||||
|
||||
- 대규모 heredoc YAML 생성기 (스크립트 내부에 매니페스트 숨김).
|
||||
- 환경별 로직이 if/else 로만 존재.
|
||||
- 스크립트만 실행해야 실제 상태를 알 수 있는 구조.
|
||||
|
||||
**허용**:
|
||||
|
||||
- `kubectl apply -k overlays/<env>` wrapping.
|
||||
- Helm chart render + apply orchestration.
|
||||
- backup / restore (stateful data 만 대상).
|
||||
- bootstrap (namespace, secret store 설치 같은 일회성).
|
||||
- smoke test.
|
||||
|
||||
### 15. 폴더 구조
|
||||
|
||||
```text
|
||||
scripts/
|
||||
bin/ # 엔트리포인트 (얇게)
|
||||
render
|
||||
diff
|
||||
apply
|
||||
backup-k3s
|
||||
restore-k3s
|
||||
lib/ # 공통 함수
|
||||
common.sh # log, fatal, require_cmd, confirm
|
||||
kubectl.sh # kubectl wrappers
|
||||
kustomize.sh # kustomize render helpers
|
||||
tasks/ # 도메인 작업
|
||||
keycloak.sh
|
||||
vault.sh
|
||||
flyway.sh
|
||||
ci/ # CI 검증 전용
|
||||
lint.sh
|
||||
validate.sh
|
||||
```
|
||||
|
||||
- `bin/` 파일 이름은 동사.
|
||||
- `lib/` 는 20개 내외, 잡동사니 함수 금지.
|
||||
- 하나의 거대 `deploy.sh` 금지.
|
||||
|
||||
### 16. retry 는 함수화, 무한 루프 금지
|
||||
|
||||
```bash
|
||||
retry() {
|
||||
local max="$1"; shift
|
||||
local delay="$1"; shift
|
||||
local n=0
|
||||
until "$@"; do
|
||||
n=$((n + 1))
|
||||
if (( n >= max )); then
|
||||
return 1
|
||||
fi
|
||||
sleep "${delay}"
|
||||
done
|
||||
}
|
||||
|
||||
retry 5 3 kubectl rollout status deployment/foo --timeout=30s
|
||||
```
|
||||
|
||||
backoff 는 선형/지수 명시, 무한 retry 금지.
|
||||
|
||||
### 17. quoting / array 기본값
|
||||
|
||||
- 모든 변수 전개는 `"${VAR}"`.
|
||||
- 인자 list 는 array: `args=(--namespace foo --context bar)`.
|
||||
- `"$@"` 유지.
|
||||
- unquoted glob / word splitting 금지.
|
||||
|
||||
### 18. 출력 채널 규칙
|
||||
|
||||
- stdout → 머신 판독 결과 (jsonpath 결과, 렌더된 YAML 등).
|
||||
- stderr → 로그, 경고, 에러, 진행 표시.
|
||||
- exit code → 0 success, 1 error, 2 usage error.
|
||||
|
||||
pipeline 하류 도구가 stdout 을 parse 한다는 전제로 작성.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- strict mode `set -euo pipefail` + `IFS=$'\n\t'` 필수.
|
||||
- trap EXIT INT TERM 으로 정리 보장.
|
||||
- ShellCheck + shfmt CI 필수.
|
||||
- ISO 8601 UTC + LEVEL 로그 함수 (stderr).
|
||||
- `main "$@"` 패턴 + usage() 함수.
|
||||
- local 선언과 command substitution 분리.
|
||||
- idempotent 기본, destructive 는 `--yes` / `CONFIRM=yes` gate.
|
||||
- `kubectl diff` → `apply`, `--dry-run=server` validation.
|
||||
- 환경 추론 금지, overlay/namespace/context 명시.
|
||||
- JSON 은 jq / jsonpath, 절대 regex 금지.
|
||||
- secret 은 log / argv 에 남기지 않고 masking.
|
||||
- 스크립트는 선언형 원본을 소유하지 않는 orchestration 레이어.
|
||||
- `bin/ lib/ tasks/ ci/` 폴더 분리, giant deploy.sh 금지.
|
||||
@@ -0,0 +1,183 @@
|
||||
# security hardening 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 K3s/Kubernetes 기반 인프라(1000+ 서비스 규모)에서
|
||||
- 어떤 Pod Security Standard(PSS) 수준을 강제할지
|
||||
- Pod/ServiceAccount/RBAC/NetworkPolicy/Secret/Image supply chain을 어디까지 하드닝할지
|
||||
- Platform 예외를 어떻게 선언할지
|
||||
를 단일 ground truth로 고정한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- root/privileged/host namespace 사용을 기본 금지하고 예외는 manifest로 증명한다
|
||||
- allow-all network/RBAC을 운영 기본값으로 두지 않는다
|
||||
- Secret의 저장·접근·전송 모든 단계에서 신뢰 경계를 명시한다
|
||||
- K3s production hardening(PSS, NetworkPolicy, audit, at-rest encryption)을 묶음으로 본다
|
||||
|
||||
## 공식 의미 (근거)
|
||||
|
||||
- Kubernetes 1.25부터 `PodSecurityPolicy`(PSP)는 제거되었다. 대체는 **Pod Security Admission (PSA)** + `pod-security.kubernetes.io/*` namespace label이다.
|
||||
- Pod Security Standards는 `privileged`, `baseline`, `restricted` 세 프로파일이다. `restricted`는 업계 최신 hardening best practice를 반영한다.
|
||||
- PSA는 `enforce`, `audit`, `warn` 세 모드를 지원하고, 각 모드마다 버전을 `latest`/`vX.Y`로 고정할 수 있다.
|
||||
- `restricted` 프로파일이 강제하는 주요 필드: `runAsNonRoot=true`, `allowPrivilegeEscalation=false`, `capabilities.drop=["ALL"]`(네트워크 capability는 `NET_BIND_SERVICE`만 추가 허용), `seccompProfile.type in {RuntimeDefault, Localhost}`, host namespace/Port/Path 금지, `privileged=false`, `procMount=Default`, ephemeral volume 화이트리스트.
|
||||
- NetworkPolicy는 namespace 내 매칭되는 Pod가 하나라도 있으면 그 Pod의 해당 방향 트래픽은 **정책 합집합**만 허용된다(그 외 default deny). 매칭되는 Pod가 없으면 기본은 allow-all이다.
|
||||
- NetworkPolicy `from`/`to` 원소 내에서 `namespaceSelector`와 `podSelector`를 **동일 엔트리** 안에 두면 AND(교집합), **별도 엔트리**로 두면 OR(합집합)로 계산된다. 이 차이가 cross-namespace 정책 버그의 1순위 원인이다.
|
||||
- Secret은 기본적으로 etcd에 base64로만 저장되므로 운영 클러스터는 `EncryptionConfiguration`(aescbc/aesgcm/KMS)을 필수로 구성한다. K3s는 `--secrets-encryption` 플래그로 aescbc provider를 활성화한다.
|
||||
- ServiceAccount token은 Pod에 기본 자동 마운트된다. 1.24부터는 time-bound projected token이 기본이다.
|
||||
- RBAC는 additive-only이며 `Role`/`RoleBinding`(namespace) 우선, `ClusterRole`/`ClusterRoleBinding`은 예외적이다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 모든 application namespace는 PSA `restricted` enforce 라벨이 기본
|
||||
namespace 생성 시 다음 라벨을 **enforce** 수준으로 붙인다(예외는 rule 3).
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
labels:
|
||||
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
|
||||
```
|
||||
|
||||
- `enforce`: deny on violation (hard gate)
|
||||
- `audit`: audit log 기록
|
||||
- `warn`: kubectl 사용자에 경고
|
||||
- version은 `latest` 대신 **명시 버전 pin**을 기본으로 한다. 업그레이드는 ADR로 관리한다.
|
||||
- PSP는 1.25에서 제거되었으므로 어떤 manifest/차트에도 `policy/v1beta1 PodSecurityPolicy`를 남기지 않는다.
|
||||
|
||||
### 2. Restricted 프로파일 전 필드 계약
|
||||
application Pod/Deployment는 아래 전부를 만족해야 한다. 하나라도 빠지면 PSA가 reject한다.
|
||||
|
||||
- `spec.automountServiceAccountToken: false` (API 호출 불필요 시)
|
||||
- `spec.securityContext.runAsNonRoot: true`
|
||||
- `spec.securityContext.runAsUser: <non-zero numeric>` (예: `10001`)
|
||||
- `spec.securityContext.runAsGroup: <non-zero numeric>` (예: `10001`)
|
||||
- `spec.securityContext.fsGroup: <numeric>` (volume 쓰기 필요 시)
|
||||
- `spec.securityContext.seccompProfile.type: RuntimeDefault` (Pod 또는 컨테이너 레벨)
|
||||
- 컨테이너 `securityContext`:
|
||||
- `allowPrivilegeEscalation: false`
|
||||
- `privileged: false`
|
||||
- `readOnlyRootFilesystem: true`
|
||||
- `runAsNonRoot: true`
|
||||
- `capabilities.drop: ["ALL"]`
|
||||
- `capabilities.add`: 비어있거나 `["NET_BIND_SERVICE"]`만 허용
|
||||
- Pod spec 금지 필드: `hostNetwork`, `hostPID`, `hostIPC`, `hostUsers=false`, `hostPath` volume, `hostPort`, `ephemeralContainers`에 한해 `privileged`
|
||||
|
||||
### 3. Platform 예외 namespace는 ADR 문서 + 좁은 enforce
|
||||
`kube-system`, `ingress-traefik`, `vault`, `cert-manager`, `vault-secrets-operator`, `monitoring` 등은 `baseline` 또는 `privileged` 프로파일이 필요할 수 있다. 예외는 다음을 manifest에 고정한다.
|
||||
|
||||
- namespace label은 **필요한 최저 수준**(`baseline` 우선, `privileged`는 CNI/CSI/Node-exporter에 한정)
|
||||
- 예외 근거 ADR 링크 annotation: `platform.example.com/psa-exception: "ADR-0042"`
|
||||
- 예외 받는 구체 필드(예: `hostNetwork`, `CAP_NET_ADMIN`)만 열고 나머지는 restricted
|
||||
|
||||
### 4. privileged / host namespace 기본 금지
|
||||
application Pod는 `privileged: true`, `hostNetwork`, `hostPID`, `hostIPC`, `hostPath`, `hostPort`를 쓰지 않는다. 필요성이 있으면 rule 3의 platform 예외로 이관한다.
|
||||
|
||||
### 5. runAsNonRoot + numeric UID 강제
|
||||
image가 `USER` 지시어로 non-numeric user만 지정해도 PSA는 runtime에 UID 확인이 불가능하면 reject할 수 있다. 항상 numeric UID를 명시한다(권장 범위: 10000–65535). UID 0은 전 영역 금지.
|
||||
|
||||
### 6. seccompProfile은 RuntimeDefault 우선
|
||||
Pod 레벨 `seccompProfile.type: RuntimeDefault`를 기본으로 두어 모든 컨테이너에 상속. 특정 컨테이너가 custom profile이 필요하면 `Localhost`로 개별 선언하고 profile 파일 경로를 문서화한다. kubelet `--seccomp-default=true`를 클러스터 플래그로 검토한다.
|
||||
|
||||
### 7. capabilities drop-first
|
||||
`drop: ["ALL"]`이 기본이다. 추가 허용 화이트리스트는 `NET_BIND_SERVICE`뿐. `CAP_SYS_ADMIN`, `CAP_NET_ADMIN`, `CAP_SYS_PTRACE`, `CAP_NET_RAW`는 platform 예외에서만 허용한다.
|
||||
|
||||
### 8. readOnlyRootFilesystem + writable emptyDir
|
||||
모든 application container는 `readOnlyRootFilesystem: true`. 쓰기 경로는 `emptyDir`(가능하면 `medium: Memory`, `sizeLimit` 명시)로 분리한다. `/tmp`, `/var/run`, 앱 cache 경로는 별도 volume mount.
|
||||
|
||||
### 9. ServiceAccount는 workload 1:1, 토큰 기본 비마운트
|
||||
- `default` SA 사용 금지. namespace당 Deployment별 전용 SA 생성.
|
||||
- `automountServiceAccountToken: false`를 Pod spec에 기본 명시.
|
||||
- Kubernetes API를 호출해야 하는 Pod만 `true` + projected token volume을 명시적으로 선언.
|
||||
|
||||
### 10. RBAC는 namespace Role + RoleBinding 우선
|
||||
- `*` resource/verb 금지.
|
||||
- `secrets` 리소스는 `get` + `resourceNames` 명시. `list`/`watch`는 controller/operator에만 허용.
|
||||
- `ClusterRole`/`ClusterRoleBinding`은 CRD controller, metrics scraper, admission webhook 같은 cluster-wide 컴포넌트에 한정하고 subject는 platform SA로 제한.
|
||||
- `system:masters` group binding 금지.
|
||||
|
||||
### 11. NetworkPolicy default-deny + 명시적 allow
|
||||
운영 namespace는 생성 직후 다음 3종을 배포한다.
|
||||
|
||||
1. default-deny-all (ingress + egress)
|
||||
2. allow-dns-egress (to `kube-system`의 `k8s-app=kube-dns`, UDP/TCP 53)
|
||||
3. allow-from-ingress-controller (namespaceSelector=`ingress-traefik` + podSelector=`app.kubernetes.io/name=traefik`)
|
||||
|
||||
추가 allow는 서비스별 요구(예: DB, Redis, Vault, S3, OIDC endpoint)에 맞춰 **한 엔트리 = AND, 여러 엔트리 = OR** 규칙을 지켜 작성한다.
|
||||
|
||||
### 12. NetworkPolicy enforcement 전제 검증
|
||||
- K3s 기본 CNI(flannel) + kube-router policy controller가 실제로 enforce하는지 배포 후 negative test 필수.
|
||||
- Calico/Cilium 전환 시 `--disable-network-policy` + `--flannel-backend=none` 조합을 ADR로 관리.
|
||||
- 운영 정책 변경 후에는 synthetic probe(`netshoot` Pod)로 deny/allow 경로를 모두 검증한다.
|
||||
|
||||
### 13. Secret at-rest encryption은 운영 필수
|
||||
- API Server `--encryption-provider-config` 지정: `aescbc` 또는 KMS provider(권장: AWS KMS/GCP KMS/HashiCorp Vault Transit).
|
||||
- K3s는 `--secrets-encryption` 플래그 활성화(aescbc). 기존 Secret은 `kubectl get secrets -A -o json | kubectl replace -f -`로 재암호화.
|
||||
- etcd 백업 자체도 별도 암호화 저장.
|
||||
|
||||
### 14. 민감정보 delivery 경로 표준화
|
||||
1순위: Vault Secrets Operator(VSO)가 Vault → K8s Secret으로 sync → envFrom/volume
|
||||
2순위: External Secrets Operator(ESO) + AWS/GCP Secret Manager
|
||||
3순위: CSI Secret Store Driver (volume mount only, K8s Secret 미생성)
|
||||
4순위: SealedSecrets / SOPS (GitOps + encrypted-at-rest in Git)
|
||||
모든 경로는 config-and-secrets 문서의 선택 기준 표를 따른다.
|
||||
|
||||
### 15. Image supply chain 통제
|
||||
- `imagePullPolicy: Always`는 mutable tag(`latest`, `main`)에만. 운영은 **digest pin** `image: registry.example.com/auth-server@sha256:<64hex>` 을 기본으로.
|
||||
- `kubernetes.io/dockerconfigjson` 타입 `imagePullSecret`은 workload SA에 `spec.imagePullSecrets`로 연결.
|
||||
- Private registry만 허용: `ImagePolicyWebhook` 또는 Kyverno/Gatekeeper로 public Docker Hub deny.
|
||||
- Image signing(cosign)과 SBOM 요구를 CI에서 강제, cluster-level로는 `ClusterImagePolicy` (sigstore policy-controller) 검토.
|
||||
|
||||
### 16. health/metrics/admin endpoint 내부 전용 기본
|
||||
- `/metrics`는 Service 별도 포트(`name: metrics`) + NetworkPolicy로 `monitoring` namespace Prometheus만 허용.
|
||||
- `/actuator/*`, `/admin`, `/debug/pprof`는 Ingress 경로에 노출 금지.
|
||||
- Keycloak `/admin/`, `/metrics`, `/health`는 외부 공개 금지 기본값(network-ingress-tls 문서와 함께 enforce).
|
||||
|
||||
### 17. audit + policy together
|
||||
- API Server `--audit-policy-file`로 최소한 Secret/RBAC/PodSecurity violation을 `RequestResponse` 수준으로 기록.
|
||||
- PSA `audit` 라벨을 모든 namespace에 붙여 violation을 audit log로 수집.
|
||||
- Kyverno 또는 Gatekeeper로 PSA 밖의 policy(resource limits, image registry, required labels)를 보완.
|
||||
|
||||
### 18. Pod spec 기타 하드닝 기본
|
||||
- `resources.limits.cpu`, `resources.limits.memory` 필수. memory limit 없는 Pod는 OOM-Kill 전파 위험.
|
||||
- `terminationGracePeriodSeconds` 명시(기본 30은 서비스별로 재조정).
|
||||
- `readinessProbe` + `livenessProbe` 분리. `startupProbe`는 JVM/느린 기동 앱에 필수.
|
||||
- `topologySpreadConstraints` 또는 `podAntiAffinity`로 node 단일 장애 블라스트 반경 축소.
|
||||
|
||||
### 19. 현재 스택 기본 권장안
|
||||
|
||||
#### auth-server / test-server / keycloak / migration-flyway
|
||||
- namespace PSA: `restricted` enforce pinned to `v1.29`
|
||||
- SA: 서비스별 1:1, `automountServiceAccountToken: false`
|
||||
- Secret: VSO로 Vault → K8s Secret sync
|
||||
- NetworkPolicy: default-deny + dns + ingress + db + vault + metrics-scrape
|
||||
|
||||
#### ingress-traefik
|
||||
- namespace PSA: `baseline` (예외 ADR 기록)
|
||||
- `hostNetwork` 금지(ServiceLB 또는 MetalLB 사용), hostPort는 80/443/8443만
|
||||
- CAP_NET_BIND_SERVICE만 add
|
||||
|
||||
#### vault / vault-secrets-operator
|
||||
- namespace PSA: `baseline` (Vault server IPC_LOCK 필요)
|
||||
- storage PVC는 encrypted StorageClass
|
||||
- unseal key는 cluster 밖(HSM/KMS auto-unseal)
|
||||
|
||||
#### db (Postgres/MySQL)
|
||||
- namespace PSA: `restricted` (StatefulSet, fsGroup 999)
|
||||
- NetworkPolicy: application SA의 Pod만 5432 허용
|
||||
- backup은 별도 namespace의 Job에서 수행, 해당 Job에만 read-only secret 부여
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 모든 app namespace에 `pod-security.kubernetes.io/enforce: restricted` + pinned version 라벨 부착
|
||||
- PSP는 제거되었으므로 어디에도 남기지 않는다
|
||||
- Restricted 프로파일 전 필드 계약을 Pod/Deployment가 만족
|
||||
- default-deny NetworkPolicy + DNS allow + ingress allow + metrics-scrape allow를 namespace 기본 세트로 배포
|
||||
- `namespaceSelector`+`podSelector` AND/OR 차이를 정확히 사용
|
||||
- Secret at-rest encryption + VSO(1순위) delivery 표준화
|
||||
- 운영 이미지는 digest pin, private registry only
|
||||
- audit policy + Kyverno/Gatekeeper 보완 정책과 함께 운영
|
||||
@@ -0,0 +1,227 @@
|
||||
# storage / PVC 기준
|
||||
|
||||
## 목적
|
||||
|
||||
PVC는 단순히 "데이터를 남기기 위한 옵션"이 아니라,
|
||||
- 어떤 워크로드가 상태를 가지는지
|
||||
- 그 상태의 수명과 복구 단위가 무엇인지
|
||||
- 어떤 storage class / access mode / reclaim policy / binding mode가 필요한지
|
||||
- snapshot / expansion 지원이 필요한지
|
||||
를 먼저 고정한 뒤에 사용한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 상태 저장 워크로드와 무상태 워크로드를 저장소 기준으로 명확히 구분한다
|
||||
- separate PVC 남발을 막는다
|
||||
- K3s 기본 local-path provisioner의 운영 사용 범위를 통제한다
|
||||
- PVC lifecycle과 backup/restore 단위를 먼저 고정한다
|
||||
- StorageClass / VolumeSnapshotClass / reclaim policy / binding mode를 선언적으로 명시한다
|
||||
|
||||
## 공식 의미 (Kubernetes 기준)
|
||||
|
||||
- PV는 클러스터의 저장소 리소스이며 Pod lifecycle과 독립적이다.
|
||||
- PVC는 저장소에 대한 요청(size, access mode, StorageClass, volumeMode 등)이다.
|
||||
- StorageClass는 동적 프로비저닝 파라미터, `reclaimPolicy`, `allowVolumeExpansion`, `volumeBindingMode`, `mountOptions`를 정의한다.
|
||||
- `reclaimPolicy`는 PV 해제 시 동작을 결정한다. 동적 프로비저닝 PV의 기본값은 `Delete`다. 운영 데이터가 있으면 StorageClass에서 `Retain`을 명시한다.
|
||||
- `volumeBindingMode`의 기본값은 `Immediate`이며, topology-aware / late-binding이 필요하면 `WaitForFirstConsumer`를 사용한다.
|
||||
- `hostPath`는 single-node testing 전용이다. 운영 클러스터에서 사용하지 않는다.
|
||||
- K3s는 Rancher Local Path Provisioner를 기본 제공해 노드 로컬 저장소를 사용할 수 있지만, RWO만 지원하고 snapshot/expansion은 지원하지 않는다.
|
||||
- VolumeSnapshot / VolumeSnapshotContent / VolumeSnapshotClass는 CSI snapshot을 위한 K8s API다. `deletionPolicy: Retain` / `Delete`를 정책에 맞게 선택한다.
|
||||
- StatefulSet은 `persistentVolumeClaimRetentionPolicy`로 삭제/스케일다운 시 PVC 보존 여부를 제어할 수 있다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. PVC는 상태가 있을 때만 사용
|
||||
다음 중 하나가 아니면 PVC를 붙이지 않는다.
|
||||
|
||||
- 재시작 후에도 유지되어야 하는 데이터가 있음
|
||||
- Pod 교체와 무관하게 보존되어야 하는 파일/데이터가 있음
|
||||
- 복구 대상이 되는 저장 상태가 있음
|
||||
- 애플리케이션이 명시적으로 영속 저장소를 요구함
|
||||
|
||||
금지:
|
||||
- "혹시 몰라서" PVC 추가
|
||||
- 로그/캐시/임시 파일을 습관적으로 PVC에 저장
|
||||
- stateless 앱에 관성적으로 PVC 부착
|
||||
|
||||
### 2. PVC 존재만으로 StatefulSet을 결정하지 않는다
|
||||
PVC가 있다고 무조건 StatefulSet은 아니다.
|
||||
|
||||
먼저 묻는다.
|
||||
- Pod마다 고유한 저장소가 필요한가?
|
||||
- stable network identity가 필요한가?
|
||||
- 순서 있는 확장/축소가 필요한가?
|
||||
|
||||
아니면:
|
||||
- Deployment + 단일 PVC(RWO, replicas 1) 또는 Deployment + RWX PVC
|
||||
도 가능하다.
|
||||
|
||||
### 3. separate PVC는 "데이터 수명과 복구 단위가 다를 때만"
|
||||
하나의 워크로드가 여러 PVC를 가져도 되는 경우는 아래와 같다.
|
||||
|
||||
- 데이터 종류별 수명주기가 다름
|
||||
- backup/restore 단위가 다름
|
||||
- 성능 요구(StorageClass) 또는 IOPS 특성이 다름
|
||||
- 보안/접근 제어 단위가 다름
|
||||
- 장애 시 독립적으로 보존/삭제되어야 함
|
||||
|
||||
금지:
|
||||
- 디렉터리 몇 개를 기계적으로 PVC로 분리
|
||||
- mount path별로 습관적으로 PVC 추가
|
||||
- 이유 없이 "앱 데이터/설정/로그"를 모두 개별 PVC로 분리
|
||||
|
||||
### 4. 기본 원칙은 "적게, 명확하게"
|
||||
기본적으로는 하나의 워크로드 / 하나의 상태 저장 목적 / 하나의 PVC를 먼저 검토한다.
|
||||
분리는 정당한 이유(#3)가 있을 때만 한다.
|
||||
|
||||
### 5. StorageClass는 항상 명시적으로 지정
|
||||
PVC는 `storageClassName`을 항상 명시한다. 클러스터 default annotation에 의존하지 않는다.
|
||||
|
||||
기본:
|
||||
- 운영 표준 StorageClass 3~5개를 미리 정의 (예: `fast-ssd-retain`, `standard-delete`, `archive-retain`, `rwx-shared`)
|
||||
- 성능/복제/노드 종속성 차이가 있으면 workload별로 구분
|
||||
- 각 StorageClass는 `provisioner`, `reclaimPolicy`, `volumeBindingMode`, `allowVolumeExpansion`을 모두 선언
|
||||
|
||||
### 6. StorageClass `volumeBindingMode` 기본값은 `WaitForFirstConsumer`
|
||||
운영 표준은 `WaitForFirstConsumer`다.
|
||||
|
||||
이유:
|
||||
- Pod가 스케줄되는 노드의 topology(zone, node-local disk, GPU affinity 등)에 맞춰 PV를 바인딩한다
|
||||
- `Immediate`는 PVC 생성 즉시 PV를 바인딩하므로, 이후 Pod가 해당 노드/zone에 스케줄되지 못하는 상황이 생긴다
|
||||
- K3s local-path provisioner는 노드 로컬이므로 반드시 `WaitForFirstConsumer`여야 한다
|
||||
|
||||
`Immediate` 허용 예외:
|
||||
- 네트워크 스토리지(Ceph, NFS, S3 CSI 등)이고 topology 제약이 없는 경우
|
||||
- 사전에 PV를 warm-up 해야 하는 특수 케이스
|
||||
|
||||
### 7. StorageClass `reclaimPolicy`는 데이터 등급에 맞춘다
|
||||
동적 프로비저닝의 기본 `reclaimPolicy`는 `Delete`다. 이는 PVC 삭제 시 PV와 데이터가 사라진다는 뜻이다.
|
||||
|
||||
기본:
|
||||
- production stateful data (DB, object store backend, identity store 등) → `Retain`
|
||||
- dev/test, ephemeral cache, rebuild-safe data → `Delete`
|
||||
- `Retain`을 쓰면 PVC 삭제 후 남은 PV를 정리하는 책임이 운영자에게 생긴다. runbook에 정리 절차를 명시한다.
|
||||
|
||||
### 8. `allowVolumeExpansion`은 기본 `true`로 두되 축소는 불가
|
||||
PVC 확장 요구는 자주 생긴다. StorageClass에서 `allowVolumeExpansion: true`를 기본으로 둔다.
|
||||
|
||||
주의:
|
||||
- PVC 용량 축소는 K8s가 지원하지 않는다
|
||||
- 파일시스템 online expansion 지원 여부는 CSI 드라이버마다 다르다
|
||||
- 확장 후 Pod 재시작이 필요한 드라이버가 있다
|
||||
|
||||
### 9. AccessMode는 실제 요구에 맞게 고른다
|
||||
기본:
|
||||
- 단일 writer면 `ReadWriteOnce` (RWO)
|
||||
- 동일 노드의 여러 Pod가 공유 필요시 `ReadWriteOncePod` (K8s 1.27+) 또는 RWO
|
||||
- 여러 Pod/노드 동시 read/write가 진짜 필요할 때만 `ReadWriteMany` (RWX)
|
||||
- 읽기 전용 공유는 `ReadOnlyMany` (ROX)
|
||||
|
||||
편의상 RWX를 기본값으로 두지 않는다. RWX는 NFS/CephFS 같은 별도 스토리지 백엔드를 요구한다.
|
||||
|
||||
### 10. K3s local-path provisioner는 운영에서 기본값 아님
|
||||
K3s 기본 local-path provisioner의 하드 제약:
|
||||
|
||||
- RWO 전용 (RWX 불가)
|
||||
- VolumeSnapshot 미지원
|
||||
- VolumeExpansion 미지원
|
||||
- 노드 로컬이므로 Pod가 특정 노드에 pin 됨 → 노드 장애 시 데이터 접근 불가
|
||||
- backup은 노드 파일시스템에 직접 접근해야 함
|
||||
|
||||
기본:
|
||||
- dev/test: 허용
|
||||
- production: Longhorn, OpenEBS, Rook-Ceph, 또는 클라우드 CSI driver(EBS, PD, Azure Disk 등)로 교체
|
||||
- 불가피하게 prod에서 local-path를 쓸 경우 `backup-restore.md`와 반드시 연동하고 노드 affinity/zone 분리를 명시
|
||||
|
||||
### 11. `hostPath` 직접 사용 금지
|
||||
운영 PV/PVC에 `hostPath`를 사용하지 않는다.
|
||||
|
||||
예외:
|
||||
- 학습/단일 노드 로컬 테스트
|
||||
- 매우 제한된 디버깅 용도 (CSI driver 진단 등)
|
||||
|
||||
운영 표준으로 채택하지 않는다.
|
||||
|
||||
### 12. VolumeSnapshotClass를 StorageClass와 1:1로 매칭
|
||||
snapshot 대상 PVC가 있는 StorageClass는 대응되는 VolumeSnapshotClass를 반드시 정의한다.
|
||||
|
||||
기본:
|
||||
- `driver`는 StorageClass의 provisioner와 맞춤
|
||||
- `deletionPolicy`는 운영 데이터면 `Retain`, ephemeral이면 `Delete`
|
||||
- snapshot class는 `labels`로 RPO/retention 정책과 연결
|
||||
|
||||
### 13. PVC lifecycle은 workload 생성 전에 문서화
|
||||
PVC를 만들기 전에 아래를 정한다.
|
||||
|
||||
- 누가 생성하는가 (Helm, Kustomize, Operator, manual)
|
||||
- 누가 삭제하는가 (GitOps sync, 운영자 수동)
|
||||
- scale down 시 어떻게 되는가
|
||||
- workload 삭제 시 어떻게 되는가
|
||||
- backup 대상인가 (어떤 RPO/RTO)
|
||||
- restore 단위인가 (PVC / VolumeSnapshot / backup tool 별)
|
||||
|
||||
"삭제하면 같이 정리되겠지"를 금지한다.
|
||||
|
||||
### 14. StatefulSet의 PVC retention policy를 명시적으로 검토
|
||||
StatefulSet을 쓰는 경우 `persistentVolumeClaimRetentionPolicy.whenDeleted` / `whenScaled`를 기본값에 두지 않는다.
|
||||
|
||||
기본:
|
||||
- 운영 데이터: 둘 다 `Retain`
|
||||
- ephemeral 데이터: 둘 다 `Delete`
|
||||
- 혼용시 명시적 이유를 주석에 남김
|
||||
|
||||
### 15. Pod와 PVC는 같은 namespace 소유권
|
||||
PVC는 Pod와 같은 namespace에서 사용된다. 스토리지도 workload의 namespace 소유권을 따라간다.
|
||||
|
||||
금지:
|
||||
- "공용 저장소 namespace"에 무분별하게 PVC 몰아넣기
|
||||
- 여러 서비스가 의미 없이 같은 PVC를 기대하는 구조
|
||||
|
||||
### 16. 워크로드별 기본 선택
|
||||
|
||||
| Workload | 기본 PVC | StorageClass | AccessMode | Snapshot |
|
||||
|---|---|---|---|---|
|
||||
| auth-server | 없음 | - | - | - |
|
||||
| test-server | 없음 | - | - | - |
|
||||
| ingress-controller | 없음 | - | - | - |
|
||||
| migration-flyway (Job) | 없음 | - | - | - |
|
||||
| Keycloak (external DB) | 없음 | - | - | - |
|
||||
| PostgreSQL / CNPG | 필수 | fast-ssd-retain | RWO | 필수 |
|
||||
| Vault (raft) | 필수 | fast-ssd-retain | RWO | 필수 |
|
||||
| MinIO | 필수 | standard-retain | RWO | 보조 (replication 우선) |
|
||||
|
||||
### 17. 로그와 임시 파일은 PVC 기본 금지
|
||||
다음은 기본적으로 PVC에 저장하지 않는다.
|
||||
|
||||
- application log (→ stdout + 로그 수집기)
|
||||
- temp file (→ `emptyDir`)
|
||||
- cache (→ `emptyDir` 또는 memory-backed)
|
||||
- rendered config copy
|
||||
- transient upload staging
|
||||
|
||||
정말 영속화가 필요하면 이유를 주석에 명시한다.
|
||||
|
||||
### 18. backup/restore와 반드시 연결
|
||||
PVC를 허용한 워크로드는 반드시 아래와 연결한다.
|
||||
|
||||
- `backup-restore.md` (Velero schedule, snapshot class, RPO/RTO)
|
||||
- `operations-runbook-upgrade-rollback.md` (복구 절차)
|
||||
|
||||
PVC가 생기면 복구 전략도 같이 생겨야 한다. 백업 없는 PVC는 merge 금지.
|
||||
|
||||
### 19. 파일시스템 / 블록 모드 명시
|
||||
`volumeMode`는 기본 `Filesystem`이지만, DB raw block 같은 경우 `Block`을 쓸 수 있다. DB 운영이 요구하지 않으면 `Filesystem` 고정.
|
||||
|
||||
### 20. securityContext와 fsGroup
|
||||
PVC를 쓰는 Pod는 `securityContext.fsGroup` 또는 `fsGroupChangePolicy: OnRootMismatch`를 명시해서 permission 문제를 예방한다. restricted PSA 하에서는 `runAsNonRoot: true`, `runAsUser`, `fsGroup`을 모두 설정한다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- PVC는 상태가 있을 때만, separate PVC는 수명/복구 단위가 다를 때만
|
||||
- StorageClass는 항상 명시, `volumeBindingMode: WaitForFirstConsumer` 기본, `reclaimPolicy`는 데이터 등급에 맞춤
|
||||
- 동적 프로비저닝 기본 `reclaimPolicy=Delete`를 인지하고 운영 데이터는 `Retain` 명시
|
||||
- K3s local-path는 RWO / no snapshot / no expansion — prod 기본값 아님
|
||||
- VolumeSnapshotClass를 StorageClass와 매칭해서 정의
|
||||
- StatefulSet PVC retention policy 명시
|
||||
- 로그/임시 파일은 PVC 기본 금지
|
||||
- PVC가 생기면 backup/restore 기준도 같이 만든다
|
||||
@@ -0,0 +1,277 @@
|
||||
# Vault 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 Kubernetes 환경에서 HashiCorp Vault 1.17+ 를 1000+ 서비스의 secret / PKI / dynamic credential 소스로 운영하기 위한 기준을 고정한다.
|
||||
|
||||
- Integrated Storage (Raft) HA + auto-unseal을 1차 권장 경로로 둔다
|
||||
- 공식 Helm chart (`hashicorp/vault`) values.yaml의 핵심 필드를 명시한다
|
||||
- Vault Secrets Operator (VSO) 0.8+ CRD 경로를 secret delivery 기본값으로 둔다
|
||||
- Vault Agent Injector는 Kubernetes Secret을 우회하고 싶은 워크로드의 2차 경로로 둔다
|
||||
- Raft snapshot / audit device / telemetry / TLS / Kubernetes auth role을 운영 필수 요소로 둔다
|
||||
|
||||
## 공식 의미 (Vault 1.17+ 기준)
|
||||
|
||||
- Vault는 **sealed** 상태로 기동한다. Shamir 수동 unseal 또는 auto-unseal (`awskms`, `gcpckms`, `azurekeyvault`, `transit`)로 unseal한다.
|
||||
- **Integrated Storage (Raft)**는 공식 지원 HA backend다. 기동 시 `storage "raft"` stanza, `cluster_addr`, listener의 `cluster_address`가 모두 필요하다. `ha_storage`와 동시 선언 금지.
|
||||
- Vault는 두 포트를 쓴다: **`8200` (API/client), `8201` (cluster-to-cluster Raft replication)**. Service는 8201을 반드시 expose해야 peer-to-peer Raft가 성립한다.
|
||||
- `/v1/sys/health` 는 단일 endpoint로 상태 코드로 응답한다: `200` active, `429` standby (`standbyok=true`면 200), `472` DR secondary, `473` performance standby, `501` uninitialized, `503` sealed.
|
||||
- **Audit device는 최소 하나 활성화해야 한다.** audit device가 전부 실패하면 Vault는 요청 처리를 멈춘다(블로킹). 여러 개 운영 권장.
|
||||
- Kubernetes auth method는 ServiceAccount JWT를 TokenReview API로 검증한다. Vault 1.17+는 short-lived projected SA token(`audiences`)을 권장한다.
|
||||
- VSO 0.8+는 `secrets.hashicorp.com/v1beta1` API group을 사용하고 `VaultConnection`, `VaultAuth`, `VaultStaticSecret`, `VaultDynamicSecret`, `VaultPKISecret`, `HCPAuth`, `HCPVaultSecretsApp` CRD를 제공한다.
|
||||
- Vault Agent Injector는 `vault.hashicorp.com/agent-inject: "true"` 같은 Pod annotation으로 sidecar/init container를 주입해 secret을 `/vault/secrets/<name>` 파일로 렌더링한다.
|
||||
- DR replication / Performance replication은 **Enterprise 기능**이다. OSS에서는 Raft snapshot restore가 복구 경로다.
|
||||
- Telemetry는 `telemetry { prometheus_retention_time = "24h" disable_hostname = true }` stanza로 활성화하고 `/v1/sys/metrics?format=prometheus`에서 scrape한다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 배포는 공식 Helm chart (`hashicorp/vault`)
|
||||
|
||||
기본:
|
||||
- `helm repo add hashicorp https://helm.releases.hashicorp.com`
|
||||
- `server.ha.enabled=true` + `server.ha.raft.enabled=true`
|
||||
- `injector.enabled` 는 secret delivery 전략에 따라 결정 (VSO만 쓰면 `false`)
|
||||
- values.yaml은 Git에 보관 + Helmfile / Argo CD Application로 배포
|
||||
|
||||
기본 금지:
|
||||
- 수제 StatefulSet으로 처음부터 조립
|
||||
- `dev` 모드 운영
|
||||
- chart 기본 `standalone` 모드 production 사용 (single node + file storage)
|
||||
|
||||
### 2. HA topology: Raft 3-node 또는 5-node
|
||||
|
||||
기본:
|
||||
- `server.ha.replicas: 3` (과반수 장애 허용: 1 node)
|
||||
- critical path면 `5`로 확장 (2 node 장애 허용)
|
||||
- `server.ha.raft.setNodeId: true` (각 pod의 hostname을 node_id로 자동 주입)
|
||||
- anti-affinity: hostname 기준 required, zone 기준 preferred
|
||||
|
||||
### 3. Raft config: listener 8200 + cluster 8201 + service_registration
|
||||
|
||||
`server.ha.raft.config` HCL에 최소한 아래 stanza가 필요하다.
|
||||
|
||||
```hcl
|
||||
ui = true
|
||||
listener "tcp" {
|
||||
address = "[::]:8200"
|
||||
cluster_address = "[::]:8201"
|
||||
tls_disable = 0
|
||||
tls_cert_file = "/vault/tls/tls.crt"
|
||||
tls_key_file = "/vault/tls/tls.key"
|
||||
}
|
||||
storage "raft" {
|
||||
path = "/vault/data"
|
||||
node_id = "$(HOSTNAME)"
|
||||
}
|
||||
cluster_addr = "https://$(HOSTNAME).vault-internal:8201"
|
||||
api_addr = "https://$(HOSTNAME).vault-internal:8200"
|
||||
service_registration "kubernetes" {}
|
||||
telemetry {
|
||||
prometheus_retention_time = "24h"
|
||||
disable_hostname = true
|
||||
}
|
||||
```
|
||||
|
||||
`cluster_addr`는 headless service(`vault-internal`)의 pod FQDN을 쓴다. 8201 Service expose 필수.
|
||||
|
||||
### 4. Auto-unseal 채택 (1차 권장)
|
||||
|
||||
기본:
|
||||
- AWS: `seal "awskms" { region = "..." kms_key_id = "..." }`
|
||||
- GCP: `seal "gcpckms" { project = "..." region = "..." key_ring = "..." crypto_key = "..." }`
|
||||
- Azure: `seal "azurekeyvault" { tenant_id = "..." vault_name = "..." key_name = "..." }`
|
||||
- Vault-to-Vault: `seal "transit" { address = "..." token = "..." key_name = "autounseal" mount_path = "transit/" }`
|
||||
|
||||
기본 금지:
|
||||
- Shamir key를 CI/CD 환경변수나 Kubernetes Secret에 저장
|
||||
- seal backend에 lifecycle 보호 없음 (KMS key deletion protection 필수)
|
||||
|
||||
### 5. Audit device는 최소 2개
|
||||
|
||||
Audit device 전부 실패 시 Vault가 요청을 block한다. redundancy 확보.
|
||||
|
||||
기본:
|
||||
- `auth/kubernetes/login` 경로 포함 모든 API 감사
|
||||
- `file`: `server.auditStorage.enabled: true` → `/vault/audit/audit.log`
|
||||
- `syslog` 또는 `socket`: 중앙 로그 파이프라인 (Loki, Splunk, CloudWatch)
|
||||
- `vault audit enable file file_path=/vault/audit/audit.log`
|
||||
|
||||
기본 금지:
|
||||
- audit device 0개 운영
|
||||
- audit log PVC 용량 무제한 (log rotation + sink 필수)
|
||||
|
||||
### 6. TLS는 end-to-end
|
||||
|
||||
기본:
|
||||
- cert-manager Certificate로 `vault-tls` Secret 발급 (cluster issuer)
|
||||
- listener에 `tls_cert_file`, `tls_key_file`, `tls_min_version = "tls13"`
|
||||
- client (app, VSO, Injector)는 CA bundle trust
|
||||
- Vault ↔ Storage ↔ seal backend 전 구간 TLS
|
||||
|
||||
### 7. Vault는 기본 내부 전용 (ClusterIP)
|
||||
|
||||
기본:
|
||||
- Service type: ClusterIP (8200, 8201)
|
||||
- Ingress 기본 금지
|
||||
- 외부 관리자 접근은 VPN / bastion / port-forward / OIDC-protected admin Ingress
|
||||
|
||||
### 8. Probe: `/v1/sys/health` 상태코드 의미 반영
|
||||
|
||||
기본:
|
||||
- readiness: `GET /v1/sys/health?standbyok=true&perfstandbyok=true&uninitcode=204` (uninitialized를 200으로 수용 초기 bootstrap 허용)
|
||||
- liveness: `GET /v1/sys/health?standbyok=true&sealedcode=204&uninitcode=204` (sealed + uninit이어도 pod 생존)
|
||||
- startup: initialDelay 10s, failureThreshold 12 (2분 유예)
|
||||
|
||||
기본 금지:
|
||||
- `GET /` 단순 probe
|
||||
- sealed 상태에서 liveness 실패 → 무한 재시작 루프
|
||||
|
||||
### 9. Kubernetes auth method 구성
|
||||
|
||||
Vault 쪽 (1회 bootstrap):
|
||||
|
||||
```bash
|
||||
vault auth enable kubernetes
|
||||
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
|
||||
```
|
||||
|
||||
Role은 ServiceAccount + namespace에 바인딩:
|
||||
|
||||
```bash
|
||||
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 \
|
||||
audience=vault
|
||||
```
|
||||
|
||||
기본 금지:
|
||||
- `bound_service_account_names=*` 또는 `bound_service_account_namespaces=*`
|
||||
- TTL 무한 또는 24h 이상
|
||||
|
||||
### 10. Secret delivery: VSO가 1차 권장
|
||||
|
||||
기본:
|
||||
- 클러스터 전체 1개 `VaultConnection` (namespace: `vault`)
|
||||
- 앱 namespace마다 `VaultAuth` (ServiceAccount 바인딩)
|
||||
- 정적 KV 동기화: `VaultStaticSecret`
|
||||
- 동적 DB credential: `VaultDynamicSecret`
|
||||
- TLS 인증서: `VaultPKISecret`
|
||||
- `destination.create: true`로 K8s Secret 자동 생성, `rolloutRestartTargets`로 consumer 재시작
|
||||
|
||||
### 11. Vault Agent Injector: Kubernetes Secret 우회가 필요할 때
|
||||
|
||||
기본 annotation set:
|
||||
|
||||
```yaml
|
||||
vault.hashicorp.com/agent-inject: "true"
|
||||
vault.hashicorp.com/role: "auth-server"
|
||||
vault.hashicorp.com/agent-inject-secret-db-creds: "database/creds/auth-server"
|
||||
vault.hashicorp.com/agent-inject-template-db-creds: |
|
||||
{{ with secret "database/creds/auth-server" -}}
|
||||
DATABASE_USERNAME={{ .Data.username }}
|
||||
DATABASE_PASSWORD={{ .Data.password }}
|
||||
{{- end }}
|
||||
vault.hashicorp.com/agent-pre-populate-only: "true" # init-only (앱이 파일 1회 읽음)
|
||||
vault.hashicorp.com/agent-inject-file-db-creds: "db.env"
|
||||
```
|
||||
|
||||
기본:
|
||||
- etcd에 민감정보를 남기고 싶지 않을 때 선택
|
||||
- 앱이 파일 기반 secret 소비 가능해야 함
|
||||
- 장기 실행 sidecar 대신 `agent-pre-populate-only: "true"`로 init container만 사용해 resource overhead 감소
|
||||
|
||||
### 12. Raft snapshot 백업은 운영 필수
|
||||
|
||||
기본:
|
||||
- 하루 1회 `vault operator raft snapshot save` CronJob
|
||||
- snapshot을 off-cluster object storage (S3, GCS, MinIO replicated bucket)에 저장
|
||||
- retention 30일 이상 + 주간 / 월간 snapshot 분리
|
||||
- restore 절차를 runbook으로 문서화
|
||||
|
||||
### 13. Telemetry + Prometheus scrape
|
||||
|
||||
기본:
|
||||
- config: `telemetry { prometheus_retention_time = "24h" disable_hostname = true }`
|
||||
- 내부 Prometheus token policy:
|
||||
```
|
||||
path "sys/metrics" { capabilities = ["read"] }
|
||||
```
|
||||
- Prometheus scrape: `/v1/sys/metrics?format=prometheus` + Bearer token (unauth-endpoint 가능하지만 권장하지 않음)
|
||||
|
||||
### 14. 포트 expose: 8200 + 8201 둘 다
|
||||
|
||||
기본:
|
||||
- Pod containerPort: 8200 (api), 8201 (cluster)
|
||||
- Service `vault`: ClusterIP, 8200
|
||||
- Service `vault-internal`: Headless, 8200 + **8201** (Raft peer discovery 필수)
|
||||
- 8201 누락 시 Raft peer-to-peer 실패, leader election 불가
|
||||
|
||||
### 15. Replication 경계: OSS vs Enterprise
|
||||
|
||||
DR replication, performance replication, namespace multi-tenancy는 **Vault Enterprise** 전용이다.
|
||||
|
||||
OSS 기준 복구:
|
||||
- Raft snapshot restore로 state 복원
|
||||
- 동일 seal backend 요구 (auto-unseal이면 KMS key 필요)
|
||||
|
||||
기본 금지:
|
||||
- OSS에서 DR topology를 가정한 설계
|
||||
- Enterprise 기능을 OSS manifest에 넣기
|
||||
|
||||
### 16. Security context: Restricted PSS
|
||||
|
||||
기본:
|
||||
- `runAsNonRoot: true`, `runAsUser: 100` (vault user)
|
||||
- `readOnlyRootFilesystem: true`
|
||||
- `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`, `capabilities.add: [IPC_LOCK]` (mlockall을 위함, swap 방지)
|
||||
- `seccompProfile: RuntimeDefault`
|
||||
|
||||
### 17. Resource 요청
|
||||
|
||||
기본 단일 replica (Raft 3 node 클러스터 중 하나):
|
||||
- requests: `cpu: 250m`, `memory: 256Mi`
|
||||
- limits: `cpu: 1`, `memory: 512Mi`
|
||||
|
||||
대규모 PKI / dynamic secret 발급량이 많으면 `memory: 1Gi` 이상.
|
||||
|
||||
### 18. Token / root token 취급
|
||||
|
||||
기본:
|
||||
- `vault operator init` 출력 root token은 1회성 bootstrap
|
||||
- 초기 설정 완료 후 `vault token revoke <root-token>`
|
||||
- 장기 root 필요 시 `vault operator generate-root` 절차로 ephemeral 생성
|
||||
- app token은 Kubernetes auth login 경로로만 발급
|
||||
- CLI history에 unseal key, root token 남기지 않음 (`HISTCONTROL=ignorespace`)
|
||||
|
||||
### 19. 현재 스택 기본 권장안
|
||||
|
||||
- 배포: Helm chart `hashicorp/vault`, `server.ha.enabled=true` + `server.ha.raft.enabled=true`
|
||||
- Replicas: 3
|
||||
- Storage: Integrated Storage (Raft) + dataStorage PVC + auditStorage PVC
|
||||
- Unseal: auto-unseal (awskms / gcpckms / azurekeyvault / transit)
|
||||
- TLS: end-to-end, cert-manager Certificate
|
||||
- Service: ClusterIP 8200 + Headless 8200/8201
|
||||
- Ingress: 기본 금지 (관리자 경로만 OIDC-protected 예외)
|
||||
- Probe: `/v1/sys/health` status-code aware
|
||||
- Audit: file + syslog 중복
|
||||
- Secret delivery: VSO 1차, Injector 2차
|
||||
- Backup: daily Raft snapshot → off-cluster object storage
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- Helm chart 공식 배포, HA Raft 3-node, auto-unseal
|
||||
- 8200 (client) + 8201 (cluster) Service expose 필수
|
||||
- `/v1/sys/health` status-code 기반 probe
|
||||
- audit device 최소 2개, 전체 실패 시 block 특성 인지
|
||||
- Kubernetes auth role은 SA + namespace 단위, wildcard 금지
|
||||
- VSO 1차 / Injector 2차 (`agent-pre-populate-only` init-only 선호)
|
||||
- Raft snapshot daily CronJob → off-cluster 보관
|
||||
- Telemetry `/v1/sys/metrics?format=prometheus` + Prometheus token policy
|
||||
- DR/perf replication은 Enterprise 기능, OSS 경계 분명
|
||||
- Restricted PSS + IPC_LOCK capability (mlockall)
|
||||
@@ -0,0 +1,324 @@
|
||||
# workload selection 기준
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 각 컴포넌트를
|
||||
- Deployment
|
||||
- StatefulSet
|
||||
- DaemonSet
|
||||
- Job
|
||||
- CronJob
|
||||
|
||||
중 무엇으로 배포할지 먼저 고정한다.
|
||||
|
||||
목표:
|
||||
|
||||
- 상태 저장 / 무상태 / 노드로컬 / 일회성 / 주기성 워크로드를 섞지 않는다
|
||||
- PVC가 필요하다는 이유만으로 StatefulSet을 선택하는 실수를 막는다
|
||||
- Ingress controller / CNI / CSI / 로그 shipper / node-exporter 같은 노드로컬 에이전트를 Deployment로 배포하는 실수를 막는다
|
||||
- migration / bootstrap / 백업을 장기 실행 앱과 분리한다
|
||||
- 1000+ 서비스 스케일에서 operator-managed 패턴이 기본인 영역(DB, Kafka, monitoring)은 operator를 기본 선택으로 문서화한다
|
||||
|
||||
## 공식 의미 (근거)
|
||||
|
||||
- **Deployment**: stateless 장기 실행. `spec.replicas` 기반 수평 확장. ReplicaSet으로 rolling update. `https://kubernetes.io/docs/concepts/workloads/controllers/deployment/`.
|
||||
- **StatefulSet**: stable network identity, stable persistent storage, ordered deployment/scaling. 각 Pod는 `<name>-<ordinal>` 이름을 가지고 PVC가 `volumeClaimTemplates`로 자동 생성. `persistentVolumeClaimRetentionPolicy` (GA since 1.27) 필드: 기본값 `{whenDeleted: Retain, whenScaled: Retain}`. `podManagementPolicy` (OrderedReady / Parallel). `updateStrategy` (RollingUpdate / OnDelete).
|
||||
- **DaemonSet**: 선택된 모든 노드에 정확히 한 Pod를 실행. 노드 추가/제거에 따라 자동 생성/삭제. `https://kubernetes.io/docs/concepts/workloads/controllers/daemonset/`.
|
||||
- **Job**: 한 번 실행되어 완료. `restartPolicy: Never | OnFailure`. `backoffLimit` / `activeDeadlineSeconds` / `ttlSecondsAfterFinished` / `parallelism` / `completions`.
|
||||
- **CronJob**: 시간 기반 스케줄 Job. `concurrencyPolicy: Allow | Forbid | Replace`, `startingDeadlineSeconds`, `successfulJobsHistoryLimit` / `failedJobsHistoryLimit`.
|
||||
- **Operator pattern**: CRD + controller. 1000-서비스 스케일에서 DB/Kafka/Redis/monitoring은 사실상 Deployment/StatefulSet을 직접 쓰지 않고 operator가 소유.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 기본 선택 기준은 "상태 + 수명 + 배치 위치"
|
||||
|
||||
3축으로 먼저 분류:
|
||||
|
||||
1. **수명**: 장기 실행 / 일회성 / 주기성
|
||||
2. **상태**: stable identity + persistent storage 필요 / 불필요
|
||||
3. **배치**: 노드별 한 Pod 필요 / cluster-wide 자유 배치
|
||||
|
||||
매핑:
|
||||
|
||||
- 장기 + 무상태 + 자유 배치 → **Deployment**
|
||||
- 장기 + stateful + 자유 배치 → **StatefulSet** (또는 operator)
|
||||
- 장기 + 무상태 + 노드별 한 Pod → **DaemonSet**
|
||||
- 일회성 → **Job**
|
||||
- 주기성 → **CronJob**
|
||||
|
||||
### 2. Deployment는 stateless 장기 실행 기본값
|
||||
|
||||
조건:
|
||||
|
||||
- Pod identity가 교체 가능
|
||||
- durable state가 외부 DB / 외부 storage / 외부 cache에 있음
|
||||
- 수평 확장이 자연스러움
|
||||
- Pod 이름 / 순서가 의미 없음
|
||||
|
||||
적용 후보:
|
||||
|
||||
- `auth-server`
|
||||
- `test-server` (장기 실행 모드)
|
||||
- 외부 DB 사용하는 `keycloak`
|
||||
- 대부분의 stateless API / worker
|
||||
|
||||
**필수 동반 리소스 (replicas≥2인 prod 워크로드):**
|
||||
|
||||
- `PodDisruptionBudget` (minAvailable ≥ 50% 또는 SLO tier에 맞춘 값)
|
||||
- `HorizontalPodAutoscaler` v2 (behavior 포함)
|
||||
- `topologySpreadConstraints` (zone + hostname)
|
||||
- `ServiceMonitor` 또는 Prometheus scrape annotation
|
||||
|
||||
### 3. StatefulSet은 "stable identity + storage" 모두 맞을 때만
|
||||
|
||||
조건 중 하나라도 강하면:
|
||||
|
||||
- stable network identity (DNS name per replica) 필요
|
||||
- stable persistent storage per Pod 필요
|
||||
- ordered rollout / termination 필요
|
||||
- replica 간 peer discovery가 ordinal에 의존
|
||||
|
||||
적용 후보:
|
||||
|
||||
- `postgres` (operator 없을 때 — 있으면 CloudNativePG 같은 operator 우선)
|
||||
- `minio` (MinIO Operator 가능)
|
||||
- `vault` (Raft storage mode)
|
||||
- `etcd` (외부)
|
||||
- `kafka`, `zookeeper` (Strimzi operator 우선)
|
||||
- `elasticsearch` (ECK operator 우선)
|
||||
|
||||
**필수 선언**:
|
||||
|
||||
- `serviceName` (headless Service 참조)
|
||||
- `volumeClaimTemplates`
|
||||
- `podManagementPolicy: OrderedReady` 기본. Parallel은 peer discovery가 순서를 요구하지 않을 때만.
|
||||
- `updateStrategy: RollingUpdate` + `partition`으로 canary rollout
|
||||
- `persistentVolumeClaimRetentionPolicy` 명시 (prod 기본 `{whenDeleted: Retain, whenScaled: Retain}`)
|
||||
|
||||
### 4. DaemonSet은 노드 전역 에이전트 전용
|
||||
|
||||
조건:
|
||||
|
||||
- 노드별 한 개만 떠야 함 (또는 특정 노드 group에 한 개)
|
||||
- 노드 추가/제거에 자동 반응
|
||||
- hostPath / hostNetwork / hostPID 필요한 경우 다수
|
||||
|
||||
적용 후보:
|
||||
|
||||
- 로그 shipper: `fluent-bit`, `fluentd`, `vector`
|
||||
- metric exporter: `node-exporter`, `cadvisor`
|
||||
- CNI agent: `calico-node`, `cilium-agent`
|
||||
- CSI node plugin: `longhorn-manager`, `ceph-csi-node`
|
||||
- security agent: `falco`, `tetragon`
|
||||
- service mesh node proxy: `istio-cni-node`
|
||||
- ingress-nginx **DaemonSet 모드** (edge 노드가 고정되고 모든 edge 노드가 80/443 HostPort / hostNetwork로 외부 노출해야 할 때)
|
||||
|
||||
**Ingress controller: DaemonSet vs Deployment 결정**:
|
||||
|
||||
- **Deployment** + `Service type=LoadBalancer` (MetalLB / 외부 LB): 기본 권장. 노드와 ingress replica 수가 분리됨. HPA 적용 가능.
|
||||
- **DaemonSet** + `hostNetwork: true` / HostPort 80,443: bare-metal + 외부 LB 없이 모든 노드가 ingress가 되어야 할 때. 80/443 노드 포트 점유, HPA 불가, 노드 수 = replica 수.
|
||||
|
||||
**필수**:
|
||||
|
||||
- `tolerations`로 노드 taint (예: `node-role.kubernetes.io/control-plane`) 대응 결정
|
||||
- `nodeSelector` 또는 `affinity`로 대상 노드 그룹 명시 (label 기반)
|
||||
- `updateStrategy: RollingUpdate` + `maxUnavailable` 지정
|
||||
- `priorityClassName: system-node-critical` (필수 인프라 에이전트)
|
||||
|
||||
### 5. PVC가 있다고 무조건 StatefulSet 아니다
|
||||
|
||||
체크리스트:
|
||||
|
||||
- Pod마다 고유한 storage identity 필요? 아니면 단일 PVC 공유?
|
||||
- Pod 이름 / 순서가 의미 있나?
|
||||
- peer discovery가 stable DNS name에 의존?
|
||||
|
||||
아니면:
|
||||
|
||||
- **단일 replica Deployment + PVC (ReadWriteOnce)** 도 유효
|
||||
- **Deployment + ReadWriteMany PVC** (여러 replica가 동일 storage 공유) 도 유효 (shared cache 등)
|
||||
|
||||
### 6. Job은 migration / bootstrap / one-off 기본값
|
||||
|
||||
적용 후보:
|
||||
|
||||
- `flyway-migrate` / `liquibase-migrate`
|
||||
- schema validation
|
||||
- 초기 admin 사용자 bootstrap
|
||||
- 데이터 리페어 / 정리
|
||||
- 이미지 빌드 trigger
|
||||
|
||||
**필수**:
|
||||
|
||||
- `restartPolicy: Never` (실패 원인 디버깅 가능) 또는 `OnFailure` (transient 실패 재시도)
|
||||
- `backoffLimit` 명시 (기본값 6은 prod에서 너무 관대할 수 있음)
|
||||
- `activeDeadlineSeconds` (무한 실행 방지)
|
||||
- `ttlSecondsAfterFinished` (완료 Job 자동 정리, 1000-서비스 스케일 필수)
|
||||
- ServiceAccount 최소 권한
|
||||
|
||||
### 7. CronJob은 주기 실행 전용
|
||||
|
||||
적용 후보:
|
||||
|
||||
- etcd / DB 백업
|
||||
- 정기 정리 (old PVC, old Snapshot, old Job)
|
||||
- 정기 검증 / 리포트
|
||||
- 비즈니스 배치 (야간 집계)
|
||||
|
||||
**필수**:
|
||||
|
||||
- `concurrencyPolicy: Forbid` 기본 (동시 실행 방지). 멱등하면 `Allow`.
|
||||
- `startingDeadlineSeconds` (노드 장애로 miss 된 job 무한 누적 방지)
|
||||
- `successfulJobsHistoryLimit: 3` / `failedJobsHistoryLimit: 5`
|
||||
- schedule timezone 명시 (`spec.timeZone` v1.25+)
|
||||
|
||||
금지:
|
||||
|
||||
- 항상 떠 있어야 하는 서버를 CronJob으로 배포
|
||||
- 본 서비스 온라인 처리를 CronJob에 의존
|
||||
|
||||
### 8. Stateful workload는 retention / scale-down 정책을 먼저 박는다
|
||||
|
||||
StatefulSet의 `persistentVolumeClaimRetentionPolicy`:
|
||||
|
||||
- `whenDeleted` (StatefulSet이 삭제될 때 PVC 처리): `Retain` (기본) / `Delete`
|
||||
- `whenScaled` (replica 축소될 때 PVC 처리): `Retain` (기본) / `Delete`
|
||||
|
||||
**prod 기본**: `{whenDeleted: Retain, whenScaled: Retain}` (기본값). DB/Vault/MinIO 모두 여기서 이탈하지 않는다.
|
||||
**dev/staging**: `{whenDeleted: Delete, whenScaled: Delete}` 허용 (클러스터 재생성 시 자동 정리).
|
||||
|
||||
### 9. 외부 DB를 쓰는 앱 서버는 stateless 우선
|
||||
|
||||
Pod에 durable state가 없으면 Deployment. Pod identity가 고정되어야 한다는 이유만으로 StatefulSet 선택 금지.
|
||||
|
||||
기준:
|
||||
|
||||
- `auth-server` → Deployment
|
||||
- 외부 DB 사용 `keycloak` → Deployment (caching은 external Redis / Infinispan cluster)
|
||||
- 외부 object store 사용 앱 → Deployment
|
||||
|
||||
### 10. Keycloak은 앱 레이어와 저장소를 분리
|
||||
|
||||
Keycloak 서버 자체는 stateless로 다룬다.
|
||||
|
||||
- **외부 DB (PostgreSQL)** 사용이 prod 기본
|
||||
- session / cache는 Infinispan embedded 또는 remote 모드 결정 (remote 선호, replica 간 peer discovery는 Kubernetes DNS)
|
||||
- **Deployment** + externalTrafficPolicy 고려
|
||||
- HA replica ≥ 2 + PDB
|
||||
|
||||
### 11. Vault는 모드별로 다르다
|
||||
|
||||
- **dev mode**: 학습 전용. prod 절대 금지.
|
||||
- **standalone (file storage)**: StatefulSet + PVC. replica=1. 단일 장애점.
|
||||
- **HA Raft**: StatefulSet (integrated storage). replica 3 또는 5. `podManagementPolicy: Parallel` 허용.
|
||||
- **HA Consul backend**: StatefulSet (Vault) + StatefulSet (Consul). operator 권장.
|
||||
- **external Vault**: 클러스터 내부 서버 없음, ExternalSecrets로 참조만.
|
||||
|
||||
prod 권장: **HA Raft mode StatefulSet (replica 3)** 또는 **external Vault**.
|
||||
|
||||
### 12. MinIO는 Operator 기본 (StatefulSet은 fallback)
|
||||
|
||||
1000-서비스 스케일에서 MinIO는 MinIO Operator + `Tenant` CRD가 기본. tenant가 StatefulSet을 내부적으로 생성.
|
||||
raw StatefulSet은 단일 node/dev 환경에서만 예외 허용.
|
||||
|
||||
### 13. Flyway는 Job 기본값
|
||||
|
||||
- 앱 startup 내부 migration 금지 (앱 부팅 실패와 migration 실패가 섞임)
|
||||
- Flyway Job이 선행되고 Success 후에 Deployment rollout
|
||||
- ArgoCD PostSync hook 또는 Argo Workflows로 순서 제어
|
||||
- `migrate`, `validate`, `info`, `repair` 각각 독립 Job
|
||||
|
||||
### 14. Ingress controller 배치 결정
|
||||
|
||||
prod 권장:
|
||||
|
||||
- **ingress-nginx Deployment** + `Service type=LoadBalancer` (MetalLB L2 또는 BGP, 또는 외부 LB)
|
||||
- 또는 **Envoy Gateway / Gateway API 기반 Deployment**
|
||||
- HPA 가능, PDB 필수 (tier-1 로 취급)
|
||||
- 복수 IngressClass (`nginx-public`, `nginx-internal`) 분리
|
||||
|
||||
DaemonSet 선택 조건:
|
||||
|
||||
- edge 노드가 고정되어 있고 hostNetwork 80/443이 필요
|
||||
- 외부 LB가 없고 DNS round-robin으로 다수 노드 IP 노출
|
||||
|
||||
### 15. DB는 operator 기본, StatefulSet은 fallback
|
||||
|
||||
1000-서비스 스케일의 PostgreSQL:
|
||||
|
||||
- **CloudNativePG Operator** 기본 → `Cluster` CRD. operator가 StatefulSet/Service/Secret/ConfigMap/Backup 전부 관리.
|
||||
- **Zalando Postgres Operator**도 대안
|
||||
- raw StatefulSet은 dev / 특수 케이스에만
|
||||
|
||||
MySQL/MariaDB:
|
||||
|
||||
- **MariaDB Operator** / **mysql-operator**
|
||||
|
||||
Redis:
|
||||
|
||||
- **Spotahome redis-operator** / **Redis Enterprise Operator**
|
||||
- cluster 모드면 StatefulSet, sentinel 모드면 Deployment(sentinel) + StatefulSet(redis)
|
||||
|
||||
### 16. Batch / 대량 병렬은 Job + `parallelism` + IndexedJob
|
||||
|
||||
단일 Job으로 수천 개 task 병렬 실행:
|
||||
|
||||
- `completionMode: Indexed` + `parallelism: N`
|
||||
- 각 Pod가 `JOB_COMPLETION_INDEX` env로 자기 작업 식별
|
||||
- 더 복잡한 DAG는 Argo Workflows / Tekton
|
||||
|
||||
### 17. workload 종류만 맞는다고 품질이 보장되지 않는다
|
||||
|
||||
최종 결정 전 동반 표준 확인:
|
||||
|
||||
- `storage-pvc.md` (StorageClass / volumeClaimTemplate / snapshot)
|
||||
- `network-ingress-tls.md` (ingressClassName / TLS)
|
||||
- `resources-probes-availability.md` (PDB / HPA / probe / resources)
|
||||
- `backup-restore.md` (RPO / RTO / 절차)
|
||||
- `security-podsecurity.md` (PSA / seccomp / capabilities)
|
||||
- `observability.md` (ServiceMonitor / log shipping)
|
||||
|
||||
### 18. priority class / preemption 전략
|
||||
|
||||
- 플랫폼 에이전트 (CNI, CSI, log shipper, node-exporter): `system-node-critical`
|
||||
- 클러스터 컨트롤러 (cert-manager, external-secrets, operator): `system-cluster-critical`
|
||||
- 비즈니스 tier-1: 커스텀 `tier-1-critical` (value 1000000)
|
||||
- 비즈니스 tier-2: `tier-2` (value 100000)
|
||||
- 비즈니스 tier-3 / batch: `tier-3` (value 10000)
|
||||
|
||||
## 현재 스택 기본 권장안 (prod)
|
||||
|
||||
| 컴포넌트 | workload 종류 | operator | 비고 |
|
||||
|----------------------|--------------------------------|-------------------|--------------------------------|
|
||||
| `auth-server` | Deployment | — | tier-1 HPA+PDB |
|
||||
| `test-server` (장기) | Deployment | — | |
|
||||
| `test-server` (검증) | Job | — | ttlSecondsAfterFinished |
|
||||
| `keycloak` | Deployment (외부 DB) | — | HA replicas≥2 |
|
||||
| `postgres-identity` | StatefulSet (via CNPG) | CloudNativePG | replica 3 |
|
||||
| `vault` | StatefulSet (Raft) | Vault Operator | replica 3 |
|
||||
| `minio` | StatefulSet (via Tenant) | MinIO Operator | |
|
||||
| `kafka` | StatefulSet (via Strimzi) | Strimzi | |
|
||||
| `redis` | StatefulSet (via operator) | redis-operator | sentinel 또는 cluster 모드 |
|
||||
| `flyway-migrate` | Job | — | ArgoCD PostSync hook |
|
||||
| `postgres-backup` | CronJob | CNPG ScheduledBkp | operator가 소유 |
|
||||
| `ingress-nginx` | Deployment + MetalLB | — | public / internal 분리 |
|
||||
| `cert-manager` | Deployment | — | system-cluster-critical |
|
||||
| `external-secrets` | Deployment | — | system-cluster-critical |
|
||||
| `prometheus` | StatefulSet (via Prometheus) | Prometheus Op | |
|
||||
| `fluent-bit` | DaemonSet | — | system-node-critical |
|
||||
| `node-exporter` | DaemonSet | — | system-node-critical |
|
||||
| `cilium-agent` | DaemonSet | Cilium Op (opt) | system-node-critical |
|
||||
| `longhorn-manager` | DaemonSet | Longhorn | |
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- stateless 장기 → Deployment (+PDB+HPA+topologySpread 필수)
|
||||
- stateful + stable identity/storage → StatefulSet (또는 operator)
|
||||
- 노드 전역 에이전트 → DaemonSet
|
||||
- 일회성 → Job (ttlSecondsAfterFinished 필수)
|
||||
- 주기성 → CronJob (concurrencyPolicy + startingDeadlineSeconds)
|
||||
- PVC ≠ StatefulSet 신호 전부 아님
|
||||
- DB/Kafka/Redis/Prometheus는 operator 기본
|
||||
- `persistentVolumeClaimRetentionPolicy` 명시, prod는 Retain/Retain
|
||||
- Flyway는 Job, 앱 startup 내부 migration 금지
|
||||
- ingress controller는 Deployment+LB 기본, DaemonSet은 edge 조건에만
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
# Docs validation report
|
||||
|
||||
Generated: 2026-04-20T08:51:44Z
|
||||
Tools: kubeconform v0.6.7, kube-linter v0.7.4, yq v4.44.3
|
||||
CRD schemas: datreeio/CRDs-catalog (remote fetch)
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|---|---|
|
||||
| Files scanned | 37 |
|
||||
| YAML blocks extracted | 146 |
|
||||
| K8s-resource blocks | 109 |
|
||||
| YAML syntax errors | 0 |
|
||||
| Schema invalid/errors | 0 |
|
||||
| kube-linter findings | 0 |
|
||||
|
||||
## Per-file
|
||||
|
||||
| File | Blocks | K8s | Syntax | Schema | Lint |
|
||||
|---|---|---|---|---|---|
|
||||
| docs/examples/infra/architecture-environments.md | 4 | 3 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/backup-restore.md | 9 | 7 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/config-and-secrets.md | 9 | 8 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/db-and-migration.md | 6 | 4 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/flyway.md | 5 | 4 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/k3s-specific.md | 9 | 2 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/keycloak.md | 7 | 5 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/kustomize.md | 8 | 8 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/minio.md | 7 | 6 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/network-ingress-tls.md | 8 | 8 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/observability-health.md | 7 | 7 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/operations-runbook-upgrade-rollback.md | 5 | 5 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/resources-probes-availability.md | 7 | 7 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/scripts.md | 0 | 0 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/security-hardening.md | 6 | 6 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/storage-pvc.md | 13 | 11 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/vault.md | 9 | 7 | 0 | 0 | 0 |
|
||||
| docs/examples/infra/workload-selection.md | 6 | 6 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/architecture-environments.md | 0 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/backup-restore.md | 0 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/config-and-secrets.md | 1 | 1 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/db-and-migration.md | 2 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/flyway.md | 2 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/k3s-specific.md | 1 | 1 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/keycloak.md | 0 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/kustomize.md | 5 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/minio.md | 0 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/network-ingress-tls.md | 1 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/observability-health.md | 0 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/operations-runbook-upgrade-rollback.md | 1 | 1 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/resources-probes-availability.md | 1 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/scripts.md | 0 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/security-hardening.md | 1 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/storage-pvc.md | 0 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/STYLE.md | 5 | 2 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/vault.md | 1 | 0 | 0 | 0 | 0 |
|
||||
| docs/standards/infra/workload-selection.md | 0 | 0 | 0 | 0 | 0 |
|
||||
|
||||
## Error details
|
||||
|
||||
_No errors found._
|
||||
@@ -0,0 +1,98 @@
|
||||
# Vault / VSO 상세
|
||||
|
||||
README 의 Vault·VSO 핵심 섹션을 보충한다. Kubernetes auth 초기화 명령, policy/role 매핑, VaultStaticSecret 카탈로그, dockerconfigjson `.auth` 이슈를 모은다.
|
||||
|
||||
## Vault 기본 정보
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 이미지 | `hashicorp/vault:1.17.2` |
|
||||
| 배포 | StatefulSet (replicas 1, file backend) |
|
||||
| 실행 | `vault server -config=/vault/config/vault.hcl` |
|
||||
| 포트 | 8200 (http) / 8201 (cluster) — 내부 ClusterIP, NodePort 없음 |
|
||||
| 저장 | PVC 5Gi (dev overlay 1Gi patch) |
|
||||
| UI 접근 | `kubectl -n mnt port-forward svc/vault 8200:8200` (외부 노출 금지) |
|
||||
|
||||
## 설계 결정
|
||||
|
||||
- **file backend (학습 환경 전용)**: 단일 노드 + 학습 목적으로 `storage "file"`. HA 불가. prod 승격 시 `storage "raft"` + KMS 기반 auto-unseal 로 전환.
|
||||
- **`disable_mlock = true`**: 컨테이너에 `IPC_LOCK` capability 를 부여하지 않고 PSS Restricted 프로필을 유지하기 위함. 대신 swap 이 꺼진 노드에서 실행해야 한다.
|
||||
- **`tls_disable = 1`**: 단일 namespace 내부 통신만 발생하고 cert-manager 전에 부트스트랩이 끝나야 해서 현재는 비활성화. 클러스터 밖 노출 시 cert-manager 발급 인증서로 TLS 활성화 필수.
|
||||
- **`api_addr: http://vault:8200` + `cluster_addr: http://vault:8201`**: 짧은 Service 이름. 모든 소비자가 같은 `mnt` namespace 에 있어 FQDN 불필요.
|
||||
|
||||
## RBAC
|
||||
|
||||
ClusterRoleBinding `vault-tokenreview-binding` ← `system:auth-delegator`. Vault 의 Kubernetes auth method 는 클라이언트(VSO 등)가 제출한 ServiceAccount JWT 를 `TokenReview` + `SubjectAccessReview` API 로 검증한다. 이 ClusterRoleBinding 이 없으면 VSO 로그인이 `permission denied` 로 실패한다.
|
||||
|
||||
Vault Pod 의 ServiceAccount 는 `automountServiceAccountToken: true` (기본). Vault 는 `/var/run/secrets/kubernetes.io/serviceaccount/{token,ca.crt}` 를 읽어 `auth/kubernetes/config` 의 `token_reviewer_jwt` / `kubernetes_ca_cert` 를 채운다.
|
||||
|
||||
## Kubernetes auth 초기 설정
|
||||
|
||||
`tasks/vault-init.sh` 가 수행:
|
||||
|
||||
```bash
|
||||
vault auth enable kubernetes # idempotent 체크
|
||||
vault write auth/kubernetes/config \
|
||||
kubernetes_host="https://kubernetes.default.svc.cluster.local:443" \
|
||||
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
|
||||
token_reviewer_jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token
|
||||
|
||||
vault secrets enable -path=secret kv-v2 # idempotent 체크
|
||||
|
||||
# policy 2개 (역할별 least-privilege)
|
||||
vault policy write vso-auth-platform - \
|
||||
# identity-postgres/* + auth-server/* + keycloak/* read
|
||||
vault policy write vso-storage - \
|
||||
# minio/* read
|
||||
|
||||
# role 2개 (같은 SA, 다른 policy)
|
||||
vault write auth/kubernetes/role/vso-auth-platform \
|
||||
policies=vso-auth-platform bound_sa=vault-secrets-operator/mnt ttl=1h
|
||||
vault write auth/kubernetes/role/vso-storage \
|
||||
policies=vso-storage bound_sa=vault-secrets-operator/mnt ttl=1h
|
||||
```
|
||||
|
||||
## VaultAuth / VaultStaticSecret 매핑
|
||||
|
||||
policy 분리에 따라 VaultAuth CR 도 2 개. 각 VaultStaticSecret 은 자기 도메인의 VaultAuth 를 참조한다:
|
||||
|
||||
| VaultAuth CR | Vault role | 참조 VaultStaticSecret |
|
||||
|---|---|---|
|
||||
| `vault-auth-auth-platform` | `vso-auth-platform` | `identity-postgres-superuser`, `keycloak-db-creds`, `auth-server-db-creds`, `keycloak-bootstrap-admin` |
|
||||
| `vault-auth-storage` | `vso-storage` | `minio-tenant-env` |
|
||||
|
||||
VSO Operator SA(`vault-secrets-operator`) 는 한 개이지만 Vault 쪽에서 role 별 policy 가 분리되어 있다. auth-platform 토큰이 유출돼도 MinIO secret 은 보호된다.
|
||||
|
||||
## VSO 가 관리하는 Secret 카탈로그
|
||||
|
||||
Registry 는 auth 없이 운영(NetworkPolicy 로 `mnt` 내부 전용 보호)이라 base 에는 VaultStaticSecret 이 없다. dev overlay 에서 BasicAuth / pull credential 두 개를 추가한다.
|
||||
|
||||
| VaultStaticSecret (dev overlay) | Vault 경로 | K8s Secret | 소비 방식 |
|
||||
|---|---|---|---|
|
||||
| `identity-postgres-superuser` | `secret/identity-postgres/superuser` | `identity-postgres-superuser` | file mount (`/run/secrets/superuser/`) → `POSTGRES_USER_FILE`, `POSTGRES_PASSWORD_FILE` |
|
||||
| `keycloak-db-creds` | `secret/keycloak/db` | `keycloak-db` | file mount — postgres initdb + keycloak `KC_DB_PASSWORD_FILE` |
|
||||
| `auth-server-db-creds` | `secret/auth-server/db` | `auth-server-db` | file mount — `SPRING_CONFIG_IMPORT=configtree:/etc/secrets/` + Flyway sh wrapper |
|
||||
| `keycloak-bootstrap-admin` | `secret/keycloak/bootstrap-admin` | `keycloak-bootstrap-admin` | file mount — `KC_BOOTSTRAP_ADMIN_{USERNAME,PASSWORD}_FILE` |
|
||||
| `minio-tenant-env` | `secret/minio/tenant-env` | `minio-tenant-env` | MinIO Operator `spec.configuration.name` (env file) |
|
||||
| `docker-registry-basic-auth` (dev) | `secret/docker-registry/basic-auth` | `docker-registry-basic-auth` | Traefik Middleware basicAuth |
|
||||
| `docker-registry-pull-credentials` (dev) | `secret/docker-registry/pull-cred` | `docker-registry-pull-credentials` | imagePullSecret (`.dockerconfigjson`) |
|
||||
|
||||
모든 VaultStaticSecret 은 `destination.overwrite` 기본값(`false`) 사용. 기존 Secret 이 수동으로 존재하면 VSO 가 덮어쓰지 않는다 (소유권 경합 방지).
|
||||
|
||||
> **Vault 값 교체 후 즉시 반영**: `kubectl -n mnt delete secret <name>` 으로 기존 Secret 을 지우면 VSO 가 다음 reconcile 에 새 값으로 재생성한다.
|
||||
|
||||
`refreshAfter: 1h` — Vault 값 변경 시 1 시간 내 K8s Secret 에 자동 반영.
|
||||
|
||||
## dockerconfigjson `.auth` 필드
|
||||
|
||||
Docker 공식 config 스키마는 `.auth = base64("<username>:<password>")` 형태다. 기존에는 password 만 base64 하던 버그가 있었고 현재는 다음으로 수정되어 있다:
|
||||
|
||||
```
|
||||
{{ printf "%s:%s" username password | b64enc }}
|
||||
```
|
||||
|
||||
Docker daemon 이 Registry 에 로그인할 때 이 필드를 디코드하므로 정확한 포맷이 필수.
|
||||
|
||||
## VaultConnection address
|
||||
|
||||
base 는 `http://vault:8200` (짧은 이름) 만 둔다. VSO Operator Pod 가 같은 `mnt` namespace 에 있으면 Kubernetes DNS 가 짧은 이름을 해결한다. 다른 namespace 에서 운영할 때는 overlay 에서 FQDN(`http://vault.mnt.svc.cluster.local:8200`) 으로 patch.
|
||||
Reference in New Issue
Block a user