시크릿 관리는 처음엔 SealedSecret로 시작했다가, SOPS로 갔다가, 결국 External Secrets Operator(ESO)에 정착하는 팀이 꽤 많다. 우리도 비슷한 경로를 걸었다. 이 글은 ESO를 도입한 뒤 한참을 SecretStore 기반으로 운영하다가 ClusterSecretStore로 갈아탄 과정에서 정리한 내용이다.
ESO 자체 입문 글은 검색하면 많이 나오는데, 막상 운영에 들어가면 "네임스페이스마다 SecretStore를 만들어야 하나, 아니면 ClusterSecretStore 하나로 묶어야 하나" 같은 부분에서 한참을 헤맨다. 어제도 신규 팀이 와서 같은 질문을 했길래, 그동안 정리한 내용을 한번 풀어본다.
언제 ClusterSecretStore를 쓰는가
SecretStore는 네임스페이스 스코프, ClusterSecretStore는 클러스터 스코프다. 이름만 보면 명확한데, 실무에서는 둘 중 뭘 써야 할지 헷갈리는 케이스가 많다. 우리 팀 기준은 이렇다.
ClusterSecretStore가 더 나은 케이스:
- TLS 인증서를 cert-manager가 아닌 외부 KMS에서 가져와서 여러 네임스페이스에 뿌려야 할 때
- 공용 DB 접속 정보, 공용 API 키처럼 플랫폼 레벨에서 관리하는 시크릿
- 신규 팀이 네임스페이스를 만들 때마다 SecretStore까지 새로 만들어주기 귀찮을 때
SecretStore가 더 나은 케이스:
- 팀별로 IAM Role이 다르거나, Vault policy가 다른 경우
- 시크릿 노출 범위를 네임스페이스 단위로 강하게 격리해야 하는 규제 환경
- 팀마다 다른 백엔드(예: 팀 A는 AWS Secrets Manager, 팀 B는 Vault)를 쓰는 경우
처음엔 보안팀에서 "무조건 네임스페이스 단위 분리"를 요구해서 SecretStore로만 운영했다. 근데 네임스페이스가 80개를 넘어가면서 SecretStore manifest 관리가 헬게이트가 됐다. ArgoCD App-of-Apps에 SecretStore가 80개 들어가 있는 걸 보고 그날 ClusterSecretStore 이관을 결심했다.
ClusterSecretStore 정의 — namespaceSelector가 핵심
ClusterSecretStore의 가장 큰 함정은 "기본적으로 모든 네임스페이스가 참조 가능"이라는 점이다. 아무 설정 없이 만들면, 누가 어디서 ExternalSecret을 만들어서 시크릿을 빼가도 막을 방법이 없다. 그래서 condition을 꼭 걸어야 한다.
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: platform-vault
spec:
conditions:
- namespaceSelector:
matchLabels:
eso.platform/allowed: "true"
- namespaces:
- kube-system
- cert-manager
provider:
vault:
server: "https://vault.internal:8200"
path: "kv"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "eso-platform"
serviceAccountRef:
name: eso-platform
namespace: external-secrets
conditions는 OR 조건이다. namespaceSelector로 라벨 매칭을 걸거나, namespaces로 명시적 화이트리스트를 둔다. 우리는 둘 다 쓴다. 일반 워크로드 네임스페이스는 라벨로, 시스템 네임스페이스는 명시적으로.
조건을 안 걸면 어떻게 되는지 실험해봤는데, 클러스터의 아무 네임스페이스에서나 ExternalSecret을 만들어 같은 ClusterSecretStore를 참조하면 시크릿이 그대로 나온다. 이게 디폴트라는 게 좀 무섭다.
RBAC도 같이 챙겨야 한다
conditions는 ESO 컨트롤러가 enforce하는 거고, RBAC은 별개다. 둘 다 신경 써야 한다.
ClusterSecretStore가 사용하는 ServiceAccount의 IAM Role이나 Vault policy를 보면, 의외로 "시크릿 전체 읽기 권한"이 박혀 있는 경우가 많다. 처음 셋업할 때 빨리 끝내려고 와일드카드를 주고 그대로 잊어버린 케이스다.
Vault라면 policy를 prefix 단위로 자르고:
path "kv/data/platform/*" {
capabilities = ["read"]
}
path "kv/data/team-a/*" {
capabilities = ["deny"]
}
AWS Secrets Manager라면 IAM Resource ARN을 명시적으로 제한한다:
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:platform/*"
}
ClusterSecretStore service account에 모든 secret 접근을 주지 않는 게 핵심이다. 플랫폼 ClusterSecretStore와 팀 SecretStore를 분리해서, 플랫폼 시크릿은 ClusterSecretStore가, 팀 전용 시크릿은 팀 SecretStore가 처리하게 한다.
refreshInterval, 어떻게 잡을 것인가
ExternalSecret의 refreshInterval 기본값은 1시간이다. 대부분의 정적 시크릿엔 충분하다. 하지만 다음 경우엔 줄여야 한다.
- DB 자동 로테이션이 활성화된 경우 (예: AWS RDS rotation, Vault dynamic secrets) — 15분 이하
- 외부 SaaS API 토큰이 짧게 만료되는 경우 — 토큰 만료 주기의 1/3 이하
- cert-manager 없이 ESO만으로 TLS 갱신하는 경우 — 별도 알람과 함께 30분 이하
반대로 길게 잡아야 하는 케이스도 있다. 백엔드 API rate limit이 빡빡한 경우, refreshInterval을 너무 짧게 잡으면 ESO가 throttling을 먹는다. 우리는 AWS Secrets Manager rate limit에 한번 걸려서 일부 ExternalSecret이 stale 상태로 몇 시간 멈춘 적이 있다. 그때 모니터링이 없었으면 모르고 지나갔을 거다.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-db-credentials
namespace: team-a
spec:
refreshInterval: 30m
secretStoreRef:
name: platform-vault
kind: ClusterSecretStore
target:
name: app-db-credentials
creationPolicy: Owner
deletionPolicy: Delete
data:
- secretKey: username
remoteRef:
key: platform/db/team-a
property: username
- secretKey: password
remoteRef:
key: platform/db/team-a
property: password
creationPolicy: Owner와 deletionPolicy: Delete는 거의 항상 같이 쓴다. ExternalSecret을 지우면 생성된 Secret도 같이 정리되도록. 안 그러면 orphan Secret이 클러스터에 쌓인다.
모니터링 — 이건 진짜로 해야 한다
ClusterSecretStore가 인증 실패하면 그걸 참조하는 모든 ExternalSecret이 한꺼번에 stale 상태가 된다. 이게 조용히 발생하는 게 가장 무섭다. 시크릿은 이미 클러스터에 있으니까 앱은 멀쩡히 돌아간다. 근데 시크릿이 로테이션되는 순간 전부 망한다.
Prometheus 알람은 최소 이 두 개는 걸어둔다:
- alert: ExternalSecretSyncFailed
expr: |
sum by (namespace, name) (
externalsecret_sync_calls_error
) > 0
for: 10m
annotations:
summary: "ExternalSecret {{ $labels.namespace }}/{{ $labels.name }} sync 실패"
- alert: ClusterSecretStoreUnhealthy
expr: |
clustersecretstore_status_condition{type="Ready", status="False"} == 1
for: 5m
labels:
severity: critical
annotations:
summary: "ClusterSecretStore {{ $labels.name }} 인증 실패 가능성"
ESO가 노출하는 메트릭 이름은 버전마다 살짝 바뀐다. v0.10 이후로 이름이 정리됐는데, 알람 만들기 전에 본인 환경 /metrics에서 직접 확인하는 게 안전하다.
Git 관리 — 권한 분리가 중요
ClusterSecretStore는 클러스터 스코프 리소스라서, 누구나 PR로 추가/수정할 수 있게 두면 위험하다. 플랫폼 팀 owned 디렉토리에 분리해 두고 CODEOWNERS로 잠근다.
infra/
platform-secrets/ # 플랫폼 팀만 수정 가능 (CODEOWNERS)
cluster-secret-stores/
platform-vault.yaml
platform-aws.yaml
team-a/ # team-a가 수정 가능
external-secrets/
app-db.yaml
ClusterSecretStore 정의는 플랫폼 팀이, ExternalSecret 정의는 각 팀이 관리한다. ArgoCD에서도 ApplicationSet을 두 개로 나눠서, 플랫폼 시크릿스토어 동기화 실패가 팀 워크로드 배포를 막지 않게 한다.
마지막으로
ClusterSecretStore가 SecretStore보다 항상 더 좋은 건 아니다. 강한 격리가 필요한 환경에선 여전히 SecretStore가 맞다. 우리도 결국 둘 다 쓴다. 공용 시크릿은 ClusterSecretStore, 팀 전용은 SecretStore. 이걸 처음부터 명확히 잡고 시작했으면 80개 SecretStore를 만들었다가 다시 정리하는 삽질은 안 했을 것 같다.
ESO 자체는 굉장히 잘 만들어진 컨트롤러다. 다만 디폴트가 "permissive"한 부분이 꽤 있어서, 운영 들어가기 전에 conditions, RBAC, refreshInterval, 모니터링 이 네 가지는 반드시 점검해야 한다.
'IT > DevSecOps' 카테고리의 다른 글
| Kyverno ClusterPolicy를 ValidatingPolicy(CEL)로 옮기는 법 (0) | 2026.06.05 |
|---|---|
| Vault Agent Injector annotation 충돌로 새벽에 일어난 이야기 (0) | 2026.05.31 |
| GitHub Actions OIDC, 아직도 sub에 wildcard 쓰고 계세요? (0) | 2026.05.30 |
| Trivy Operator vs Kubescape, 6개월 굴려보고 내린 결정 (0) | 2026.05.30 |
| Falco vs Tetragon, 둘 다 6개월 써본 결정 (0) | 2026.05.28 |