K8s 배포 자동화와 시크릿 관리

쿠버네티스 시크릿 관리 — Secrets Manager + External Secrets

2026. 06. 22.
KubernetesSecretsAWSExternal SecretsSecurity

이 글의 목표: 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 친화적 (ExternalSecret CRD를 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 secretSecrets Manager수동 회전 (서비스 재배포 필요)
OAuth secretSecrets ManagerOAuth 공급자에서 갱신
API 키, 외부 서비스Secrets Manager수동
공개 설정 (port, log level 등)Helm values.yamlgit에서 수정
환경별 비-시크릿ConfigMap 또는 values.yamlgit

민감하지 않은 건 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에 들어가면 어쩌지?

이미 들어갔다면:

  1. 즉시 시크릿 회전 (DB 비밀번호 변경 등)
  2. git history에서 제거 (git-filter-repo)
  3. 강제 push (위험, 동료에 알림 필수)

예방:

  • pre-commit hook: gitleaks, detect-secrets 등 자동 감지
  • GitHub의 secret scanning 활성화
  • PR 리뷰

옵션 비교 정리

옵션어디 저장?git 공개?회전복잡도
평문 envvalues.yaml❌ 위험X낮음
K8s Secret (kubectl)etcdOK (기본 X)X낮음
K8s Secret YAMLgit❌ base64만X낮음
AWS Secrets Manager + ESOAWS✅ 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.io

ESO에 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: MeenyExternalSecretsRole
eksctl 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-system

Deployment에 어노테이션:

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

흔한 이유:

  1. IAM 권한 부족 → IRSA Role이 시크릿 ARN에 접근 권한 있는지
  2. 시크릿 이름 오타meeny/prod/backend/db 정확히 맞는지
  3. JSON property 이름 다름 → AWS 시크릿이 단일 string인데 property 지정함
  4. ClusterSecretStore가 NotReady

ESO Pod 로그

kubectl logs -n external-secrets deploy/external-secrets -f

IAM 권한 테스트

# 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으로 읽기 (코드 수정 없음)