External Secrets Operator, IRSA로 시작하는 실전 가이드
Kubernetes Secret을 base64로 관리하는 시대는 이제 좀 지나갔다. etcd에 평문으로 들어가는 걸 감안하면 그냥 인코딩된 문자열일 뿐이다. 그래서 대부분 팀은 외부 시크릿 매니저(AWS Secrets Manager, Vault, GCP Secret Manager 등)로 옮겨간다.
문제는 이걸 어떻게 Pod까지 안전하게, 그리고 회전(rotation)까지 자동으로 반영되도록 연결하냐는 것이다. External Secrets Operator(이하 ESO)가 이 자리를 채운다. 올해 초 나온 ESO 2026 가이드들을 훑어보면 대부분 "설치하고 SecretStore 만들고 ExternalSecret 정의하면 끝"이라고 쓰여있는데, 실제 프로덕션에 넣어보면 IRSA 권한, refreshInterval, rate limit에서 다 걸린다.
우리 팀도 얼마 전에 Sealed Secrets에서 ESO(v0.10.x)로 넘어갔다. 그 과정에 정리한 실전 가이드다.
SecretStore vs ClusterSecretStore, 뭘 쓸까
먼저 결정해야 하는 게 있다. SecretStore는 네임스페이스 단위, ClusterSecretStore는 클러스터 전역이다.
경험상 팀 단위로 네임스페이스가 나뉘어 있으면 SecretStore가 낫다. IRSA(IAM Roles for Service Accounts) 권한 경계가 네임스페이스에 딱 붙기 때문에, "결제팀 네임스페이스의 SecretStore는 결제팀 시크릿만 읽을 수 있다"는 걸 IAM 정책으로 강제할 수 있다.
ClusterSecretStore는 플랫폼팀이 관리하는 공용 시크릿(예: monitoring 스택의 Grafana admin 비번)에만 제한적으로 쓴다. 한 번 열어두면 모든 네임스페이스가 참조 가능하니까 관리가 애매해진다.
IRSA 설정, 이 부분에서 다 막힌다
AWS EKS에서 ESO를 쓸 때 가장 흔한 함정이다. ServiceAccount에 IAM role annotation을 붙이는 건 다들 하는데, ESO 컨트롤러 자체의 SA랑, SecretStore가 참조하는 SA를 헷갈린다.
정답은 SecretStore가 참조하는 SA에 IRSA를 설정하는 것이다. ESO 컨트롤러 SA는 별도로 두고, 실제 AWS API 호출은 SecretStore 정의에 명시한 SA의 IAM role로 sts:AssumeRoleWithWebIdentity를 태운다.
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: payment-secrets
namespace: payment
spec:
provider:
aws:
service: SecretsManager
region: ap-northeast-2
auth:
jwt:
serviceAccountRef:
name: payment-eso-sa
payment-eso-sa에는 다음 annotation이 붙어있어야 한다.
apiVersion: v1
kind: ServiceAccount
metadata:
name: payment-eso-sa
namespace: payment
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/payment-eso-role
그리고 IAM role의 trust policy에서 system:serviceaccount:payment:payment-eso-sa를 명시적으로 허용해야 한다. 여기서 네임스페이스나 SA 이름 오타 하나만 나도 조용히 실패한다. 로그에 AccessDenied만 뜨고 원인이 안 나온다.
IAM 정책은 최소 권한으로. secretsmanager:GetSecretValue만 필요하고, 특정 시크릿 ARN prefix로 Resource를 좁혀둔다.
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"],
"Resource": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:payment/*"
}
refreshInterval, 너무 짧게 두면 rate limit 걸린다
이게 은근히 사고 포인트다. refreshInterval: 1m 같이 짧게 두면 시크릿 하나당 분당 한 번씩 AWS API를 때린다. Secret 100개면 분당 100 request. 여러 클러스터, 여러 네임스페이스 합치면 순식간에 Secrets Manager API rate limit(계정당 초당 100~500 요청 정도)에 걸린다.
우리 팀 기준선은 이렇다.
- 정적 시크릿(DB 비번 같은 것):
refreshInterval: 1h - 회전 주기가 있는 시크릿(RDS 자동 회전, 24시간 주기):
refreshInterval: 15m - 진짜 급하게 반영해야 하는 것(극히 드묾):
refreshInterval: 5m+ PushSecret 조합 고민
회전이 필요하면 오히려 AWS 쪽에서 회전 이벤트를 EventBridge로 받아서 pod restart 트리거를 거는 방식이 낫다. 폴링으로 전부 커버하려는 게 실수다.
ExternalSecret 템플릿, 이것도 알아두면 편하다
Secret을 그대로 K8s Secret으로 옮기는 것 말고, 형태를 바꿔야 할 때가 있다. 예를 들어 .env 파일 형태로 만들거나, JSON 구조에서 특정 키만 뽑거나.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: payment-db
namespace: payment
spec:
refreshInterval: 1h
secretStoreRef:
name: payment-secrets
kind: SecretStore
target:
name: payment-db-secret
template:
engineVersion: v2
data:
DATABASE_URL: "postgresql://{{ .username }}:{{ .password }}@{{ .host }}:5432/payment"
data:
- secretKey: username
remoteRef:
key: payment/rds
property: username
- secretKey: password
remoteRef:
key: payment/rds
property: password
- secretKey: host
remoteRef:
key: payment/rds
property: host
여기서 engineVersion: v2를 꼭 넣자. v1은 deprecated고 v2는 Go template 문법을 제대로 지원한다.
Sealed Secrets에서 넘어올 때 팁
기존에 Sealed Secrets 쓰던 팀이면, 마이그레이션 순서를 잘 짜야 한다. 우리가 한 방식.
한 번에 다 옮기려 하지 말고, 새 시크릿부터 ESO로 만들면서 기존 Sealed Secret은 그대로 둔다. 그러다가 각 시크릿이 다음에 회전될 시점에 하나씩 ESO로 옮긴다. 2~3개월 걸리는데, 이게 안전하다. 한 번에 옮기다가 하나 실수하면 pod가 CrashLoopBackOff 나면서 새벽에 호출 온다.
그리고 컨트롤러 배포 순서상 ESO는 각 클러스터에 하나씩 있는 게 맞는데, HA 구성은 초기부터 하는 게 좋다. replicaCount: 2 이상, leaderElection: true. 시크릿 반영이 안 되면 배포 자체가 막힌다.