External Secrets Operator 실전 가이드: PushSecret과 ClusterSecretStore 제대로 쓰기

External Secrets Operator(ESO)를 처음 도입하면 보통 ExternalSecret 리소스부터 만든다. AWS Secrets Manager나 Vault에 있는 시크릿을 Kubernetes Secret으로 끌어오는 흐름. 이 정도는 어렵지 않다. 그런데 운영을 6개월쯤 하다 보면 다음 두 가지가 슬슬 필요해진다.
첫째, 클러스터에서 생성한 시크릿(예: 앱이 동적으로 만든 API 키)을 외부 시크릿 저장소로 밀어 올리고 싶어진다. 둘째, 클러스터 단위로 한 번만 SecretStore를 정의하고 여러 네임스페이스에서 공유하고 싶어진다. 전자가 PushSecret, 후자가 ClusterSecretStore다.
이 글은 두 리소스를 실무에 적용할 때 빠지기 쉬운 함정과, 우리 팀에서 정착시킨 패턴을 정리한 가이드다. ESO v0.18 기준으로 작성했다(2026년 5월 릴리스에서 PushSecret이 GCP의 multiple replicationLocations를 지원하면서 PushSecret을 본격적으로 검토하는 팀이 많아졌다).
사전 준비: SecretStore vs ClusterSecretStore
먼저 두 리소스를 헷갈리지 말자. SecretStore는 네임스페이스 스코프, ClusterSecretStore는 클러스터 스코프다. 멀티테넌트 환경에서 ClusterSecretStore를 무지성으로 쓰면 보안 사고 직행이다. 다른 팀 네임스페이스의 ExternalSecret이 우리 팀 시크릿을 끌어다 쓸 수 있게 된다.
ESO 0.9부터 ClusterSecretStore에 conditions 필드가 추가됐는데, 이걸 반드시 활용해야 한다.
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: aws-sm-platform
spec:
conditions:
- namespaceSelector:
matchLabels:
eso-tenant: platform
- namespaces:
- platform-system
- platform-monitoring
provider:
aws:
service: SecretsManager
region: ap-northeast-2
auth:
jwt:
serviceAccountRef:
name: eso-platform
namespace: external-secrets
namespaceSelector 또는 namespaces 리스트로 어떤 네임스페이스가 이 ClusterSecretStore를 참조할 수 있는지 명시한다. 이걸 안 걸면 사실상 클러스터 전체에 노출된다.
내가 팀에 ESO 가이드라인 박을 때 첫 번째로 못 박은 게 이거다. "공유 ClusterSecretStore는 반드시 conditions로 namespace 제한 걸 것. 안 걸린 ClusterSecretStore는 PR에서 반려." 이 규칙 하나로 사고 한 건 막은 적 있다(다른 팀이 우리 RDS 패스워드를 자기 네임스페이스로 가져가려다 PR 리뷰에서 걸렸다).
PushSecret: 클러스터 → 외부로 밀어 올리기
PushSecret은 ExternalSecret의 반대다. Kubernetes Secret을 외부 저장소로 동기화한다. 어디 쓰냐 하면:
- cert-manager가 발급한 TLS 인증서를 AWS Secrets Manager로 백업
- 앱이 부팅 시 동적으로 생성한 시크릿(예: 첫 admin 패스워드)을 Vault로 영속화
- 멀티 클러스터에서 한 클러스터의 시크릿을 다른 클러스터로 공유(중간 저장소 경유)
기본 구조:
apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
name: push-tls-to-asm
namespace: ingress
spec:
refreshInterval: 1h
secretStoreRefs:
- name: aws-sm-platform
kind: ClusterSecretStore
selector:
secret:
name: wildcard-tls
updatePolicy: Replace
deletionPolicy: Delete
data:
- match:
secretKey: tls.crt
remoteRef:
remoteKey: prod/ingress/wildcard
property: cert
- match:
secretKey: tls.key
remoteRef:
remoteKey: prod/ingress/wildcard
property: key
세 가지 옵션을 꼭 짚고 가자.
updatePolicy — Replace(기본)와 IfNotExists 둘 중 하나. 외부 저장소가 source of truth라면 IfNotExists로 둬야 한다. 안 그러면 외부에서 수정한 값을 PushSecret이 덮어쓴다. cert-manager 인증서처럼 클러스터가 source of truth인 경우만 Replace.
deletionPolicy — None(기본)과 Delete. Delete로 두면 PushSecret 리소스를 삭제할 때 외부 시크릿도 같이 지워진다. 운영에선 위험하니까 처음엔 None으로 시작해서 정착되면 바꾸는 걸 권장한다. 우리 팀은 결국 None으로 통일했다. 외부 저장소 삭제는 의식적인 행동이어야 한다는 결론.
data[].match.remoteRef.property — 외부 시크릿이 JSON 객체일 때 어떤 필드로 들어갈지 지정한다. 안 쓰면 외부 시크릿 전체가 통째로 덮인다. 여러 PushSecret이 같은 remoteKey를 공유하는 경우(예: TLS 인증서 cert + key가 한 시크릿에 들어가는 경우) 반드시 명시.
함정 1: PushSecret 무한 루프
처음 PushSecret 도입했을 때 겪은 일이다. 같은 시크릿을 PushSecret으로 외부에 올리고, 다른 클러스터의 ExternalSecret이 그걸 다시 끌어오는 구조를 만들었다. 그런데 ExternalSecret이 가져온 값이 PushSecret의 source secret과 미묘하게 달라서(예: 직렬화 순서 차이) PushSecret이 계속 "변경됐다"고 판단하고 업데이트를 쏴댔다.
AWS Secrets Manager는 시크릿 업데이트 호출에 versionId가 붙고, 1초에 10번 호출하면 throttle 걸린다. 우리는 30분 만에 8개 시크릿이 throttle로 동기화 실패 알람을 띄웠다.
해법은 두 가지였다.
- PushSecret의
refreshInterval을 늘려서 (15분 → 1시간) 호출 빈도 자체를 줄임 - ExternalSecret이 다시 끌어가는 클러스터에서는 PushSecret을 만들지 않기 (단방향 흐름 유지)
PushSecret과 ExternalSecret이 같은 시크릿을 양방향으로 처리하면 무조건 사고난다. 한쪽 흐름만 유지해야 한다.
함정 2: RBAC, ServiceAccount, IRSA의 삼각관계
ESO를 AWS에서 운영할 때 ServiceAccount → IAM Role 매핑(IRSA 또는 Pod Identity)에서 권한 범위 제어를 빼먹기 쉽다. SecretStore가 사용하는 ServiceAccount의 IAM Role이 secretsmanager:*로 와이드 오픈돼 있으면 ESO 자체가 거대한 권한 위임 통로가 된다.
권장 IAM 정책 예시(PushSecret까지 쓰는 경우):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Resource": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:prod/platform/*"
},
{
"Sid": "PushSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:CreateSecret",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecret",
"secretsmanager:TagResource"
],
"Resource": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:prod/ingress/*"
}
]
}
읽기 권한과 쓰기 권한의 리소스 prefix를 다르게 둬서 PushSecret이 의도치 않은 경로로 쓰는 걸 막는다. 그리고 팀별로 ServiceAccount를 분리해서 각 팀이 자기 prefix만 만지게 한다.
함정 3: refreshInterval 설정의 균형
문서에는 "static secret은 1시간이 기본, 자주 회전하는 시크릿은 15~30분 권장"이라고 적혀있다. 그대로 따르면 된다고 생각하지만, 클러스터 규모가 커지면 호출량이 무시 못 할 수준이 된다.
우리 환경(노드 80대, ExternalSecret 350개) 기준:
- 15분 간격: 시간당 1,400회 호출 → AWS Secrets Manager 비용 약 $1.4/시간
- 1시간 간격: 시간당 350회 호출 → $0.35/시간
비용도 비용이지만 throttle이 더 큰 문제다. AWS Secrets Manager의 GetSecretValue는 계정당 기본 5,000 TPS인데, 다른 워크로드가 같이 쓰면 금방 한계 친다.
우리 팀 기준:
- DB 패스워드(거의 안 바뀜): 6h
- API 키, 외부 서비스 토큰: 1h
- 인증서: 15m (만료 임박 시 빠르게 받아야 해서)
- 동적으로 발급되는 OAuth 토큰: 5m
전부 다 짧게 잡지 말고 카테고리별로 차등 적용하는 게 좋다.
모니터링: ESO 자체가 죽으면 모른다
ESO controller가 죽거나 권한 문제로 동기화에 실패해도 기존 Kubernetes Secret은 그대로 남아있다. 즉 앱은 멀쩡히 돌아간다. 그래서 "ESO가 망가졌는지" 자체를 인지하지 못한다.
Prometheus로 다음 두 메트릭은 무조건 알람 걸어야 한다.
# ExternalSecret 동기화 실패
- alert: ExternalSecretSyncFailed
expr: |
sum by (namespace, name) (
externalsecret_status_condition{condition="Ready", status="False"}
) > 0
for: 10m
annotations:
summary: "ExternalSecret {{ $labels.namespace }}/{{ $labels.name }} sync failed"
# PushSecret 동기화 실패
- alert: PushSecretSyncFailed
expr: |
sum by (namespace, name) (
pushsecret_status_condition{condition="Ready", status="False"}
) > 0
for: 5m
PushSecret 알람은 더 짧게 잡았다. 외부 저장소가 source of truth가 아닌 시나리오(예: cert-manager 백업)에서는 PushSecret이 실패하면 데이터 손실 위험이 있다.
마무리
ESO는 시작은 쉽지만 운영 깊이 들어가면 잡일이 늘어나는 도구다. ClusterSecretStore의 conditions, PushSecret의 update/deletion policy, refreshInterval 차등 설정 — 이 세 가지만 처음부터 제대로 정해놓으면 나중에 마이그레이션할 일이 줄어든다.
다음 글에서는 ESO와 Vault Agent Injector 중 어떤 걸 골라야 하는지, 우리 팀의 6개월 비교 결과를 정리해보려고 한다. 결론 스포 살짝 — 둘 다 쓰고 있다.
혹시 PushSecret을 프로덕션에 도입한 사례가 있다면 어떤 패턴으로 쓰는지 궁금하다. 댓글로 공유 부탁드린다.