EKS 클러스터를 운영하다 보면 DB 비밀번호나 API 키를 어디에 둘지가 늘 문제로 남는다. AWS Secrets Manager에는 이미 값이 있는데, 파드는 쿠버네티스 Secret을 읽는다. 두 곳을 사람이 손으로 맞추면 회전(rotation)이 일어난 순간 어긋난다. 그래서 이 다리를 자동으로 놔주는 도구가 필요하다.
결론부터 말하자면, External Secrets Operator(ESO)를 설치하고 SecretStore로 AWS 연결을 한 번 정의한 뒤, 워크로드마다 ExternalSecret을 만들면 된다. 인증은 IRSA(IAM Roles for Service Accounts)로 붙이고, refreshInterval을 짧게 주면 AWS에서 값이 바뀔 때 쿠버네티스 Secret이 그 주기 안에 따라 갱신된다.
1. ESO가 하는 일과 핵심 리소스
External Secrets Operator는 외부 비밀 저장소(AWS Secrets Manager, SSM Parameter Store, Vault 등)의 값을 읽어 클러스터 안 네이티브 Secret으로 만들어 주는 컨트롤러다. 파드는 평소처럼 Secret만 마운트하거나 환경변수로 받으면 되고, 원본이 AWS에 있다는 사실은 몰라도 된다.
리소스는 두 종류를 이해하면 된다.
- SecretStore: 어떤 외부 저장소에, 어떤 권한으로 접속하는지를 정의한다. 네임스페이스 범위다. 클러스터 전역으로 쓰려면
ClusterSecretStore를 쓴다. - ExternalSecret: 그 저장소의 어떤 키를 가져와 어떤 이름의
Secret으로 만들지를 정의한다. 실제 동기화 단위가 이것이다.
아래에서는 설치, IRSA 권한, SecretStore와 ExternalSecret 작성, 그리고 회전 반영 검증을 차례로 살펴본다.
2. Helm으로 ESO 설치
차트 저장소를 추가하고 전용 네임스페이스에 설치한다. CRD는 기본으로 함께 설치된다.
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
파드가 뜨는지 확인한다. 컨트롤러, webhook, cert-controller 세 개가 올라온다.
kubectl get pods -n external-secrets NAME READY STATUS RESTARTS AGE external-secrets-6d9b8c7f4b-abcde 1/1 Running 0 40s external-secrets-cert-controller-7c9d5f6b8c-fghij 1/1 Running 0 40s external-secrets-webhook-5f7c8d9b6a-klmno 1/1 Running 0 40s
CRD를 Argo CD나 별도 파이프라인으로 관리한다면 --set installCRDs=false를 붙여 차트가 CRD를 건드리지 않게 한다. 버전 번호는 저장소 상태에 따라 다를 수 있다.
3. IRSA로 권한 붙이기
ESO 파드가 AWS API를 호출하려면 자격증명이 필요하다. 정적 액세스 키를 클러스터에 넣는 방식은 피하고, EKS라면 IRSA를 쓴다. IRSA는 서비스어카운트에 IAM 역할을 연결해, 파드가 OIDC 토큰으로 임시 자격증명을 받게 하는 구조다.
먼저 대상 비밀을 읽을 IAM 정책을 만든다. Secrets Manager와 SSM Parameter Store 둘 다 쓸 계획이면 아래처럼 두 서비스 액션을 함께 허용한다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Resource": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:prod/*"
},
{
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
],
"Resource": "arn:aws:ssm:ap-northeast-2:123456789012:parameter/prod/*"
}
]
}
eksctl이 있으면 서비스어카운트와 역할, 신뢰 정책까지 한 번에 만든다. 직접 만든 정책 ARN을 붙인다.
eksctl create iamserviceaccount \ --name external-secrets-sa \ --namespace default \ --cluster my-eks \ --role-name eso-aws-reader \ --attach-policy-arn arn:aws:iam::123456789012:policy/eso-aws-read \ --approve
여기서 한 번 걸리기 쉬운 지점이 있다. IRSA 역할을 ESO 컨트롤러 파드의 서비스어카운트에 붙일 수도 있고, SecretStore가 참조하는 별도 서비스어카운트에 붙일 수도 있다. 나는 워크로드별로 권한을 쪼개고 싶어서 후자, 즉 SecretStore의 serviceAccountRef가 가리키는 서비스어카운트에 역할을 붙이는 방식을 쓴다. 이러면 네임스페이스마다 읽을 수 있는 비밀 범위를 IAM에서 분리할 수 있다.
4. SecretStore 작성: Secrets Manager와 Parameter Store
AWS provider는 service 필드로 대상을 고른다. SecretsManager는 Secrets Manager, ParameterStore는 SSM Parameter Store다. 인증은 auth.jwt.serviceAccountRef로 위에서 만든 서비스어카운트를 가리키면 IRSA가 동작한다.
Secrets Manager용 SecretStore다.
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: aws-secretsmanager
namespace: default
spec:
provider:
aws:
service: SecretsManager
region: ap-northeast-2
auth:
jwt:
serviceAccountRef:
name: external-secrets-sa
Parameter Store는 service만 ParameterStore로 바꾸면 된다.
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: aws-parameterstore
namespace: default
spec:
provider:
aws:
service: ParameterStore
region: ap-northeast-2
auth:
jwt:
serviceAccountRef:
name: external-secrets-sa
적용한 뒤 상태를 확인한다. Valid가 뜨면 연결과 권한이 정상이다.
kubectl apply -f secretstore-sm.yaml kubectl get secretstore aws-secretsmanager NAME AGE STATUS CAPABILITIES READY aws-secretsmanager 6s Valid ReadWrite True
여기서 READY가 False로 남고 kubectl describe에 AccessDenied가 보이면 IAM 정책의 리소스 ARN 범위나 신뢰 정책의 OIDC 조건을 다시 봐야 한다. 대개 정책 Resource가 실제 비밀 이름 접두사와 안 맞는 경우다.
5. ExternalSecret 작성: data와 dataFrom
이제 실제로 어떤 값을 가져올지 정한다. 두 가지 방식이 있다.
data - 키를 하나씩 매핑
원격 비밀의 특정 속성을 골라 Secret의 특정 키로 넣는다. JSON 형태로 저장한 비밀이면 property로 안쪽 필드를 지정한다. 예를 들어 prod/app/db라는 비밀에 {"username":"app","password":"s3cr3t"}가 들어 있다고 하자.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: app-db
namespace: default
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secretsmanager
kind: SecretStore
target:
name: app-db-secret
creationPolicy: Owner
data:
- secretKey: DB_USERNAME
remoteRef:
key: prod/app/db
property: username
- secretKey: DB_PASSWORD
remoteRef:
key: prod/app/db
property: password
dataFrom - 통째로 펼치기
비밀 JSON의 모든 키를 Secret 키로 그대로 펼치고 싶으면 dataFrom.extract를 쓴다. 필드가 늘어도 매니페스트를 안 고쳐도 된다는 게 장점이다.
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secretsmanager
kind: SecretStore
target:
name: app-db-secret
dataFrom:
- extract:
key: prod/app/db
SSM Parameter Store의 단순 문자열 파라미터는 property 없이 key만 파라미터 경로로 주면 된다.
data:
- secretKey: API_TOKEN
remoteRef:
key: /prod/app/api-token
적용하면 같은 이름의 네이티브 Secret이 생긴다.
kubectl apply -f externalsecret-db.yaml
kubectl get externalsecret app-db
NAME STORE REFRESH INTERVAL STATUS READY
app-db aws-secretsmanager 1m SecretSynced True
kubectl get secret app-db-secret -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
6. creationPolicy와 deletionPolicy
target.creationPolicy는 대상 Secret을 어떻게 다룰지를 정한다. 공식 정의 그대로 정리하면 이렇다.
- Owner(기본):
Secret을 만들고ownerReferences를 설정한다. ExternalSecret을 지우면Secret도 함께 지워진다. - Merge:
Secret을 새로 만들지 않고, 이미 존재하는Secret에 데이터 필드를 병합한다. 대상Secret이 미리 있어야 한다. - Orphan:
Secret을 만들되ownerReferences를 설정하지 않는다. 이미 있으면 갱신한다. - None:
Secret을 생성하지도 갱신하지도 않는다. injector와 함께 쓰기 위해 예약된 옵션이다.
여기서 주의할 점 하나. None은 값을 채우는 옵션이 아니다. 기존 Secret에 키만 얹고 싶으면 Merge를 써야 한다. None으로 두면 Secret 데이터가 채워지지 않는다. 나도 처음엔 이름만 보고 None을 골랐다가 Secret이 비어 있어 헤맸는데, 문서를 보면 예약된 값이라고 못 박혀 있다.
deletionPolicy는 원격에서 값이 사라졌을 때의 처리다. 기본 Retain은 Secret을 그대로 둔다. Delete는 원격의 모든 필드가 사라지면 Secret도 지운다. Merge는 해당 키만 지우고 Secret은 남긴다. 운영에서는 원격 사고가 곧 Secret 삭제로 번지지 않도록 기본값 Retain을 그대로 두는 편을 권한다.
7. refreshInterval과 회전 반영 검증
refreshInterval은 ESO가 원격 값을 다시 읽어 Secret을 맞추는 주기다. 1m이면 1분마다 조회한다. 0으로 두면 최초 1회만 가져오고 이후 갱신하지 않으니, 회전을 반영하려면 반드시 양수로 준다.
주기를 아주 짧게 잡으면 그만큼 GetSecretValue 호출이 늘어 AWS API 요청 비용과 스로틀 위험이 커진다. 회전 주기가 하루 단위라면 1h 정도로도 충분하다. 나는 검증할 때만 1m으로 낮춰 확인하고, 운영에서는 회전 주기에 맞춰 늘린다.
실제로 회전이 반영되는지 확인하는 순서다. 먼저 현재 Secret 값을 본다.
kubectl get secret app-db-secret -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
s3cr3t
AWS에서 값을 바꾼다. 이 창은 그대로 두고 다른 터미널에서 실행한다.
aws secretsmanager put-secret-value \
--secret-id prod/app/db \
--secret-string '{"username":"app","password":"rotated-new"}' \
--region ap-northeast-2
refreshInterval만큼 기다린 뒤 다시 읽으면 새 값으로 바뀌어 있다.
kubectl get secret app-db-secret -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
rotated-new
주기를 기다리지 않고 즉시 당겨오려면 애너테이션을 찍어 강제 조정을 유발한다.
kubectl annotate externalsecret app-db \ force-sync=$(date +%s) --overwrite
동기화 이력은 이벤트로 확인한다. Updated가 찍히면 새 값이 반영된 것이다.
kubectl describe externalsecret app-db | tail -n 8 Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Updated 70s (x2 over 5m) external-secrets Updated Secret
한 가지 더. Secret이 새 값으로 바뀌어도 이미 그 값을 환경변수로 주입받아 실행 중인 파드는 자동으로 다시 읽지 않는다. 환경변수 주입 방식이면 롤아웃을 다시 태워야 새 값이 프로세스에 들어간다. 볼륨으로 마운트한 경우는 kubelet이 파일을 갱신하지만, 애플리케이션이 파일 변경을 감지해 다시 로드하도록 만들어야 실제로 반영된다. 회전 자동화를 한다면 Reloader 같은 도구로 Secret 변경 시 배포를 재기동하게 엮는 방법을 함께 검토한다.
8. 정리
ESO는 SecretStore로 AWS 연결과 권한을 한 번 정의하고, ExternalSecret으로 워크로드별 매핑을 선언하는 구조다. 인증은 IRSA로 정적 키 없이 붙이고, 읽기 권한은 IAM 정책의 리소스 ARN으로 네임스페이스별로 좁힌다. 회전 반영은 refreshInterval이 담당하되, 실행 중 파드까지 새 값을 태우려면 롤아웃 재기동을 별도로 엮어야 한다. 이 세 가지를 맞춰 두면 AWS의 비밀 회전이 클러스터까지 자동으로 이어진다.