쿠버네티스 시크릿 관리 — Secrets Manager + External Secrets
이 글의 목표: DB 비밀번호, JWT secret, OAuth client_id 같은 민감 값을 어디에 저장하고 어떻게 Pod에 전달할지 결정한다. 이론(전략) + 실전(External Secrets Operator 적용)까지 한 번에.
1부. 시크릿 관리 전략
시크릿이 새는 흔한 실수
| 실수 | 위험도 |
|---|---|
application.yaml에 비밀번호 하드코딩 | ⚠️⚠️⚠️ Git에 평문 커밋됨 |
terraform.tfvars에 secret 입력 + git 커밋 | ⚠️⚠️⚠️ |
K8s Manifest의 value: 에 평문 | ⚠️⚠️ git에 들어가면 끝 |
| Slack/이메일/스크린샷에 secret 포함 | ⚠️⚠️ |
.env 파일을 .gitignore 안 함 | ⚠️⚠️ |
| 평범한 ConfigMap에 secret 넣기 | ⚠️ K8s에서 base64만 됨 |
→ secret은 git/이미지/평문로그에 절대 들어가면 안 된다.
시크릿 저장소 옵션
Level 0: 그냥 환경변수 (X — 절대 X)
env:
DB_PASSWORD: "P@ssw0rd123" # 이것도 안 됨Level 1: Kubernetes Secret
K8s 자체 리소스. base64 인코딩만 됨.
kubectl create secret generic backend-secret \
--from-literal=DB_PASSWORD=... \
-n backend# Pod에서 사용
envFrom:
- secretRef:
name: backend-secret장점:
- 추가 도구 없이 바로 가능
- 네임스페이스 RBAC으로 접근 제어
단점:
- ⚠️ base64는 인코딩이지 암호화가 아님
- etcd 암호화 별도 설정 안 하면 평문 저장
- 어떻게 만들었는지 추적 어려움 (kubectl 명령어로 만들면 git 기록 X)
→ etcd 암호화는 EKS에서 KMS encryption_config 옵션으로 켤 수 있다.
Level 2: AWS Secrets Manager (외부 비밀 저장소)
AWS의 관리형 시크릿 서비스.
장점:
- ✅ 자동 회전 (RDS는 자동 회전 정책 내장)
- ✅ KMS 암호화
- ✅ IAM 정책으로 세밀한 접근 제어
- ✅ CloudTrail로 누가 읽었는지 감사
- ✅ 다중 클러스터/계정에서 공유 가능
단점:
- 비용 (시크릿당 월 $0.40 + API 호출 비용)
- Pod에 가져오려면 연결 도구 필요
Level 3: AWS Systems Manager Parameter Store (대안)
비슷한 역할. 시크릿 자체 비용은 무료(SecureString 무료, Advanced는 유료). 자동 회전 같은 고급 기능은 부족.
→ 간단한 환경변수는 SSM, DB 자격증명은 Secrets Manager 권장.
K8s에 외부 시크릿을 가져오는 두 도구
AWS Secrets Manager에 시크릿이 있다고 해서, Pod이 자동으로 읽을 수는 없다. 연결 다리가 필요하다.
옵션 A: External Secrets Operator (ESO) ⭐
별도 Operator(CRD)를 클러스터에 설치. AWS Secrets Manager → K8s Secret 자동 동기화.
[AWS Secrets Manager]
│
▼ (External Secrets Operator가 주기적으로 가져옴)
│
[K8s Secret]
│
▼
[Pod이 envFrom으로 읽음]
장점:
- ✅ Pod 입장에서는 그냥 K8s Secret처럼 보임 (코드 수정 X)
- ✅ Secrets Manager 갱신되면 K8s Secret도 자동 갱신
- ✅ GitOps 친화적 (
ExternalSecretCRD를 git에 보관, 실제 값은 git에 없음)
단점:
- 추가 컴포넌트 운영 필요
옵션 B: AWS Secrets Manager CSI Driver
Pod이 시작할 때 시크릿을 볼륨으로 마운트. K8s Secret 안 거치고 직접.
[AWS Secrets Manager]
│
▼ (CSI Driver가 Pod 시작 시 가져옴)
│
[Pod의 /mnt/secrets-store/ 볼륨]
장점:
- ✅ K8s Secret 단계 없음 (etcd에 안 남음)
- ✅ 회전 시 자동 재마운트(설정에 따라)
단점:
- 코드 수정 필요할 수 있음 (env 대신 파일 읽기)
- envFrom으로 쓰려면 K8s Secret과 동기화 옵션 켜야 됨
→ 여기서는 ESO를 선택한다. (개발자 입장에서 변경 적음, GitOps 친화적)
적용할 패턴
┌──────── AWS Secrets Manager ──────────┐
│ meeny/prod/db │
│ { │
│ "username": "meeny", │
│ "password": "...auto-generated..." │
│ "host": "rds-...", │
│ "port": 5432, │
│ "dbname": "meeny" │
│ } │
│ │
│ meeny/prod/jwt │
│ { "secret": "..." } │
│ │
│ meeny/prod/oauth │
│ { "google_client_ids": "..." } │
└────────────────────────────────────────┘
│
│ (ESO가 IAM Role로 읽기)
▼
┌────────── EKS Cluster ─────────────────┐
│ │
│ ExternalSecret CRD (git에 보관) │
│ ↓ │
│ K8s Secret (자동 생성) │
│ ↓ │
│ Pod (envFrom) │
│ │
└────────────────────────────────────────┘
시크릿 구분 원칙
| 종류 | 어디에 | 회전 |
|---|---|---|
| DB 자격증명 | Secrets Manager (RDS 통합) | 자동 가능 |
| JWT secret | Secrets Manager | 수동 회전 (서비스 재배포 필요) |
| OAuth secret | Secrets Manager | OAuth 공급자에서 갱신 |
| API 키, 외부 서비스 | Secrets Manager | 수동 |
| 공개 설정 (port, log level 등) | Helm values.yaml | git에서 수정 |
| 환경별 비-시크릿 | ConfigMap 또는 values.yaml | git |
→ 민감하지 않은 건 git에, 민감한 건 Secrets Manager에.
시크릿 ARN 구조와 권한 분리
Secrets Manager의 시크릿 이름 컨벤션 추천:
meeny/<env>/<service>/<purpose>
예시:
meeny/prod/backend/db
meeny/prod/backend/jwt
meeny/prod/backend/oauth
meeny/dev/backend/db
→ IAM 정책에서 prefix 단위로 권한 부여:
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:ap-northeast-2:505947591451:secret:meeny/prod/backend/*"
}→ backend Pod은 meeny/prod/backend/*만 읽음. frontend는 meeny/prod/frontend/*만. 권한 분리.
시크릿이 git에 들어가면 어쩌지?
이미 들어갔다면:
- 즉시 시크릿 회전 (DB 비밀번호 변경 등)
- git history에서 제거 (
git-filter-repo) - 강제 push (위험, 동료에 알림 필수)
예방:
- pre-commit hook:
gitleaks,detect-secrets등 자동 감지 - GitHub의 secret scanning 활성화
- PR 리뷰
옵션 비교 정리
| 옵션 | 어디 저장? | git 공개? | 회전 | 복잡도 |
|---|---|---|---|---|
| 평문 env | values.yaml | ❌ 위험 | X | 낮음 |
| K8s Secret (kubectl) | etcd | OK (기본 X) | X | 낮음 |
| K8s Secret YAML | git | ❌ base64만 | X | 낮음 |
| AWS Secrets Manager + ESO | AWS | ✅ ARN만 | 가능 | 중간 |
| Vault | 별도 서비스 | ✅ | 강력 | 높음 |
→ AWS Secrets Manager + ESO 가 적정한 균형점이다.
2부. External Secrets Operator 적용
전체 그림
┌─ AWS Secrets Manager ─┐
│ meeny/prod/backend/db │ ← 운영자가 RDS 비밀번호 등 저장
└─────┬─────────────────┘
│
│ (1) GetSecretValue
│
┌─────▼──────────────────┐
│ ESO ServiceAccount │ ← IRSA로 IAM Role 매핑
│ (external-secrets ns) │
└─────┬──────────────────┘
│
│ (2) ESO가 주기적으로 가져오기
▼
┌─── ClusterSecretStore ─┐ ← 어디서 시크릿을 가져올지 정의 (CRD)
│ provider: aws │
└────┬───────────────────┘
│ 참조
▼
┌─── ExternalSecret ─────┐ ← "이 시크릿을 가져와서 K8s Secret 만들어라" (CRD, git 보관)
│ refreshInterval: 1h │
│ data: │
│ - secretKey: DB_* │
│ remoteRef: ... │
└────┬───────────────────┘
│ 자동 생성
▼
┌─── K8s Secret ─────────┐ ← ESO가 자동 생성/업데이트
│ backend-secret │
└────┬───────────────────┘
│ envFrom
▼
┌─── Pod (Spring Boot) ──┐
│ ${DB_PASSWORD} │ ← 환경변수로 읽음
└────────────────────────┘
ESO 설치 (Helm)
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
-n external-secrets \
--create-namespace \
--set installCRDs=true→ external-secrets 네임스페이스에 컨트롤러 + Webhook + CRD 설치.
설치 검증
kubectl get pods -n external-secrets
# external-secrets-...
# external-secrets-cert-controller-...
# external-secrets-webhook-...
kubectl get crd | grep external-secrets
# clustersecretstores.external-secrets.io
# externalsecrets.external-secrets.io
# secretstores.external-secrets.ioESO에 IAM 권한 (IRSA)
ESO가 AWS Secrets Manager에 접근하려면 IAM 권한 필요. AWS Load Balancer Controller에 했던 IRSA 패턴 그대로.
IAM Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret",
"secretsmanager:ListSecrets"
],
"Resource": [
"arn:aws:secretsmanager:ap-northeast-2:505947591451:secret:meeny/*"
]
},
{
"Effect": "Allow",
"Action": ["kms:Decrypt"],
"Resource": "*",
"Condition": {
"StringEquals": {
"kms:ViaService": "secretsmanager.ap-northeast-2.amazonaws.com"
}
}
}
]
}eksctl로 ServiceAccount + Role 생성
# external-secrets-sa.yaml
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: meeny-prod-eks
region: ap-northeast-2
iam:
withOIDC: true
serviceAccounts:
- metadata:
name: external-secrets
namespace: external-secrets
attachPolicyARNs:
- arn:aws:iam::505947591451:policy/MeenyExternalSecretsPolicy
roleName: MeenyExternalSecretsRoleeksctl create iamserviceaccount -f external-secrets-sa.yaml \
--approve --override-existing-serviceaccounts💡
installCRDs=true로 ESO를 설치한 후 IRSA를 붙이면 ESO Pod이 재시작되어야 권한 인식.
ClusterSecretStore (또는 SecretStore)
- SecretStore: 한 네임스페이스 안에서만 사용
- ClusterSecretStore: 클러스터 전체에서 공유
여러 네임스페이스에서 같은 AWS 계정 시크릿을 쓸 거면 ClusterSecretStore가 편하다.
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: aws-secretsmanager
spec:
provider:
aws:
service: SecretsManager
region: ap-northeast-2
auth:
jwt:
serviceAccountRef:
name: external-secrets
namespace: external-secrets→ "ESO가 external-secrets ns의 SA로 인증해서, 서울 리전 Secrets Manager에 접근".
AWS Secrets Manager에 시크릿 만들기
옵션 ① RDS 통합 (자동)
RDS 인스턴스 생성 시 manage_master_user_password = true 설정:
# Terraform
resource "aws_db_instance" "main" {
# ...
manage_master_user_password = true
master_user_secret_kms_key_id = "alias/aws/secretsmanager"
}→ AWS가 자동으로 Secrets Manager에 시크릿 생성:
- 이름:
rds!cluster-XXXX...(자동) - 내용:
{ "username": "...", "password": "..." } - KMS 자동 암호화
- 자동 회전 가능
옵션 ② 수동 생성
aws secretsmanager create-secret \
--name meeny/prod/backend/db \
--description "Backend DB credentials" \
--secret-string '{
"host": "meeny-prod.xxxx.ap-northeast-2.rds.amazonaws.com",
"port": 5432,
"username": "meeny",
"password": "...",
"dbname": "meeny"
}'aws secretsmanager create-secret \
--name meeny/prod/backend/jwt \
--secret-string '{"secret":"..."}'→ RDS는 ① 자동 + 추가 시크릿(JWT 등)은 ② 수동 조합이 일반적이다.
ExternalSecret 작성 (가장 핵심)
backend가 필요한 환경변수를 받아오는 ExternalSecret:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: backend-secret
namespace: backend
spec:
refreshInterval: 1h # 1시간마다 동기화
secretStoreRef:
name: aws-secretsmanager
kind: ClusterSecretStore
target:
name: backend-secret # 만들어질 K8s Secret 이름
creationPolicy: Owner # ESO가 K8s Secret을 소유 (삭제도 동기화)
data:
# AWS Secrets Manager의 JSON에서 특정 key 추출 → K8s Secret의 특정 key로
- secretKey: DB_URL
remoteRef:
key: meeny/prod/backend/db
property: url # JSON 안에 "url" 필드가 있다고 가정
- secretKey: DB_USERNAME
remoteRef:
key: meeny/prod/backend/db
property: username
- secretKey: DB_PASSWORD
remoteRef:
key: meeny/prod/backend/db
property: password
- secretKey: JWT_SECRET
remoteRef:
key: meeny/prod/backend/jwt
property: secret
- secretKey: GOOGLE_CLIENT_IDS
remoteRef:
key: meeny/prod/backend/oauth
property: google_client_ids체크포인트
secretKey: K8s Secret 안의 키 이름 (= Pod 환경변수 이름)remoteRef.key: AWS Secrets Manager의 시크릿 이름remoteRef.property: JSON 시크릿의 필드명 (단일 문자열이면 생략)
→ 이 ExternalSecret 자체는 git에 보관 가능 (실제 시크릿 값은 안 들어있음).
Pod에서 사용 (envFrom)
Pod 정의에서 K8s Secret을 통째로 환경변수로:
spec:
containers:
- name: backend
envFrom:
- secretRef:
name: backend-secret→ K8s Secret의 모든 키가 환경변수로 주입.
→ Spring Boot의 application-prod.yaml이 ${DB_PASSWORD} 같은 placeholder로 받는다.
ExternalSecret을 Helm 차트에 통합
helm/backend/templates/externalsecret.yaml:
{{- if .Values.externalSecret.enabled }}
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: {{ .Release.Name }}-secret
labels:
app: {{ .Release.Name }}
spec:
refreshInterval: {{ .Values.externalSecret.refreshInterval | default "1h" }}
secretStoreRef:
name: {{ .Values.externalSecret.secretStore.name }}
kind: {{ .Values.externalSecret.secretStore.kind }}
target:
name: {{ .Release.Name }}-secret
creationPolicy: Owner
data:
{{- range .Values.externalSecret.data }}
- secretKey: {{ .secretKey }}
remoteRef:
key: {{ .remoteRef.key }}
{{- if .remoteRef.property }}
property: {{ .remoteRef.property }}
{{- end }}
{{- end }}
{{- end }}values.yaml:
externalSecret:
enabled: true
refreshInterval: 1h
secretStore:
name: aws-secretsmanager
kind: ClusterSecretStore
data:
- secretKey: DB_URL
remoteRef:
key: meeny/prod/backend/db
property: url
- secretKey: DB_USERNAME
remoteRef:
key: meeny/prod/backend/db
property: username
# ...templates/deployment.yaml 에 envFrom 추가:
containers:
- name: backend
envFrom:
{{- if .Values.externalSecret.enabled }}
- secretRef:
name: {{ .Release.Name }}-secret
{{- end }}검증 시나리오
# 1. ExternalSecret 생성됐는지
kubectl get externalsecret -n backend
# NAME STORE STATUS READY
# backend-secret aws-secretsmanager SecretSynced True
# 2. K8s Secret이 자동 생성됐는지
kubectl get secret -n backend backend-secret
# NAME TYPE DATA AGE
# backend-secret Opaque 5 1m
# 3. 어떤 키가 들어있는지
kubectl get secret backend-secret -n backend -o jsonpath='{.data}' | jq 'keys'
# ["DB_PASSWORD","DB_URL","DB_USERNAME","GOOGLE_CLIENT_IDS","JWT_SECRET"]
# 4. 실제 값 (디버깅용, 운영 환경에선 자제)
kubectl get secret backend-secret -n backend -o jsonpath='{.data.DB_USERNAME}' | base64 -d
# 5. Pod이 환경변수 받았는지
kubectl exec -it deploy/backend -n backend -- env | grep DB_시크릿 회전 시 Pod 재시작 자동화
ESO가 K8s Secret을 갱신해도, Pod의 환경변수는 자동으로 안 바뀐다.
옵션 A: Stakater Reloader
helm install reloader stakater/reloader -n kube-systemDeployment에 어노테이션:
metadata:
annotations:
reloader.stakater.com/auto: "true"→ Secret이 바뀌면 Reloader가 Deployment를 자동 재시작.
옵션 B: 수동 rollout
kubectl rollout restart deploy/backend -n backend→ 작은 환경엔 충분.
트러블슈팅
Status: SecretSyncedError
kubectl describe externalsecret backend-secret -n backend흔한 이유:
- IAM 권한 부족 → IRSA Role이 시크릿 ARN에 접근 권한 있는지
- 시크릿 이름 오타 →
meeny/prod/backend/db정확히 맞는지 - JSON property 이름 다름 → AWS 시크릿이 단일 string인데 property 지정함
- ClusterSecretStore가 NotReady
ESO Pod 로그
kubectl logs -n external-secrets deploy/external-secrets -fIAM 권한 테스트
# Pod 안에서 시도
kubectl exec -it deploy/external-secrets -n external-secrets -- sh
aws secretsmanager get-secret-value --secret-id meeny/prod/backend/db --region ap-northeast-2베스트 프랙티스 정리
- ✅ ClusterSecretStore 1개 두고 모든 ns에서 공유
- ✅ IRSA로 ESO에만 권한 (root key 등 X)
- ✅ 시크릿 이름에 prefix (
meeny/<env>/<service>/<purpose>) - ✅ ExternalSecret만 git에 보관 (실제 값 X)
- ✅ Reloader로 회전 시 자동 재시작
- ✅ refreshInterval 1h 정도로 적정 (너무 짧으면 API 호출 비용 ↑)
- ❌ K8s Secret을 직접 git에 넣지 말기
- ❌ AWS Access Key를 ESO에 넘기지 말기 (IRSA 사용)
핵심 요약
- 민감 값은 git에 절대 들어가면 안 됨
- K8s Secret은 base64 = 암호화 아님
- AWS Secrets Manager 가 표준 저장소
- External Secrets Operator 가 다리 역할
- 시크릿 이름 prefix로 IAM 권한 분리
- Pod은 평소처럼 envFrom으로 읽기 (코드 수정 없음)