IT/DevSecOps

External Secrets Operator + AWS Secrets Manager, 실무 세팅 가이드

gfrog 2026. 7. 3. 15:43
SMALL

Kubernetes에 시크릿을 어떻게 넣을지 팀 내부에서 얘기가 몇 번 오갔다. Sealed Secrets는 GitOps랑 궁합이 좋긴 한데 로테이션이 귀찮고, kubectl create secret은 GitOps 원칙에 어긋난다. 결국 우리 팀은 External Secrets Operator(ESO) + AWS Secrets Manager 조합으로 갔다. 6개월 정도 운영해보니 손이 덜 가서 만족스럽다.

이 글은 ESO를 처음 도입할 때 필요한 IRSA 설정, ClusterSecretStore/SecretStore 결정, 그리고 실무에서 자주 걸리는 몇 가지 함정을 정리했다. 최근 v0.10 대에서 문법이 조금씩 바뀐 부분도 반영했다.

왜 ESO인가

간단히 말하면 AWS Secrets Manager를 원본(source of truth)으로 두고, ESO가 주기적으로 폴링해서 Kubernetes Secret 오브젝트로 동기화해준다. 애플리케이션 입장에서는 그냥 평범한 Secret을 마운트하는 거라 코드 변경이 필요 없다.

경쟁 도구로는 Secrets Store CSI Driver도 있는데, CSI는 파드 재시작 없이 값이 반영되지 않는 이슈가 있고 별도 mount라 코드가 파일 기반 시크릿을 읽어야 한다. 우리는 기존 앱들이 env 변수로 시크릿을 읽는 구조라서 ESO가 더 잘 맞았다.

IRSA 설정부터

ESO Pod가 AWS API를 호출해야 하니 IRSA(IAM Roles for Service Accounts)로 권한을 준다. Access Key를 Secret으로 넣는 것도 가능하지만 그건 안티패턴이니 넘어간다.

먼저 IAM 정책. 필요한 최소 권한만 준다:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetResourcePolicy",
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret",
        "secretsmanager:ListSecretVersionIds"
      ],
      "Resource": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:prod/*"
    }
  ]
}

Resource ARN에 prefix를 걸어두는 게 중요하다. *로 열어두면 나중에 감사에서 지적당한다. 우리는 환경별로 prod/*, stg/*, dev/*로 나눠서 관리한다.

Trust relationship은 OIDC provider가 이미 EKS에 붙어 있다고 가정하면:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.ap-northeast-2.amazonaws.com/id/EXAMPLE"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "oidc.eks.ap-northeast-2.amazonaws.com/id/EXAMPLE:sub": "system:serviceaccount:external-secrets:external-secrets",
        "oidc.eks.ap-northeast-2.amazonaws.com/id/EXAMPLE:aud": "sts.amazonaws.com"
      }
    }
  }]
}

aud 컨디션은 안 넣어도 동작하는데, 넣는 게 confused deputy 공격 방어 관점에서 낫다.

Helm으로 ESO 설치

helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  -n external-secrets --create-namespace \
  --set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=arn:aws:iam::123456789012:role/eso-role \
  --set installCRDs=true

CRD는 별도로 관리하는 걸 추천한다. Helm으로 CRD를 관리하면 업그레이드할 때 종종 꼬인다. 우리는 CRD만 별도 매니페스트로 빼서 ArgoCD sync-wave로 먼저 배포한다.

ClusterSecretStore vs SecretStore

여기서 한 번 정리하고 넘어가자. 이걸 처음에 헷갈려서 삽질했다.

  • SecretStore: 네임스페이스 스코프. 해당 네임스페이스의 ExternalSecret만 참조 가능
  • ClusterSecretStore: 클러스터 스코프. 모든 네임스페이스에서 참조 가능

멀티 테넌트 구조라 팀별 격리가 중요하면 SecretStore를, 플랫폼 팀이 통제하는 구조라면 ClusterSecretStore를 쓰는 게 편하다. 근데 ClusterSecretStore를 쓰면 IRSA 하나로 모든 시크릿에 접근하게 되니 IAM 정책 스코프에 특별히 신경 써야 한다.

우리 팀은 환경별로 ClusterSecretStore를 하나씩 두고, 각 스토어에 다른 IAM 역할을 붙였다:

apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: aws-secrets-prod
spec:
  provider:
    aws:
      service: SecretsManager
      region: ap-northeast-2
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets

ExternalSecret 리소스 작성

가장 자주 쓰는 패턴 두 가지만 소개한다.

패턴 1: 단일 시크릿을 그대로 가져오기

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-credentials
  namespace: my-app
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-prod
    kind: ClusterSecretStore
  target:
    name: db-credentials
    creationPolicy: Owner
  data:
    - secretKey: DB_USERNAME
      remoteRef:
        key: prod/myapp/db
        property: username
    - secretKey: DB_PASSWORD
      remoteRef:
        key: prod/myapp/db
        property: password

AWS Secrets Manager에는 보통 JSON 형태로 저장한다: {"username": "admin", "password": "xxx"}. property 필드로 JSON path를 지정하면 원하는 값만 뽑아올 수 있다.

패턴 2: dataFrom으로 전체 JSON을 통째로 가져오기

spec:
  dataFrom:
    - extract:
        key: prod/myapp/config

이 방식은 JSON의 모든 키를 자동으로 Secret 키로 변환한다. 시크릿 스키마가 자주 바뀌는 경우 편하다. 대신 어떤 키가 들어있는지 매니페스트만 봐서는 알기 어렵다는 단점이 있다.

refreshInterval, 얼마로 둘 것인가

기본이 1시간인데, 이걸 얼마로 두느냐가 좀 애매하다. 짧게 두면 로테이션 반영이 빠르지만 AWS API 호출량과 비용이 늘어난다. Secrets Manager는 GetSecretValue 호출당 $0.05 per 10,000 calls 정도인데, ExternalSecret 개수 × 클러스터 개수 × 호출 빈도로 곱해지니 무시 못 한다.

우리 팀 기준: - 로테이션이 잦은 DB 비밀번호: 15분 - 거의 안 바뀌는 API 키: 6시간 - 배포 시점에만 필요한 값: 24시간

0으로 두면 폴링을 안 하고 최초 한 번만 가져온다. 이것도 나쁘지 않은 선택이다. 시크릿 변경 시에는 어차피 파드 롤링이 필요한 앱이 많으니까.

실무에서 부딪히는 함정들

시크릿 값이 안 바뀌면 파드가 재시작 안 된다

ESO가 Secret을 업데이트해도 Deployment는 그걸 감지 못 한다. Reloader나 Stakater의 툴을 같이 쓰거나, 시크릿 해시를 Deployment annotation에 넣어서 롤링을 유도해야 한다. 이걸 놓쳐서 "왜 새 비밀번호가 반영이 안 되지" 하고 30분 헤맨 적 있다.

IAM 정책 스코프를 너무 좁히면 DescribeSecret에서 막힌다

GetSecretValue만 열어두면 secret not found 에러가 나는데, 실제로는 DescribeSecret 권한이 없어서 발생하는 경우가 많다. 위 정책 예시에 넣은 4개 액션이 최소 세트다.

PushSecret 쓸 때 tags 관리 주의

v0.10부터 PushSecret이 태그, resource policy, replication location에 대해 source of truth로 동작한다. 즉 PushSecret metadata에 tag를 명시 안 하면 AWS에서 수동으로 붙인 태그도 지워진다. 우리 회사는 비용 태그가 필수라 이거 때문에 한 번 사고 날 뻔했다.

마무리

ESO 자체는 그렇게 복잡하지 않은데, IRSA 세팅과 IAM 스코프 설계가 초반에 시간을 잡아먹는다. 한 번 세팅하고 나면 시크릿 관리는 거의 손을 안 대게 된다. Secrets Manager 콘솔에서 값만 바꾸면 자동으로 클러스터에 반영되는 경험은 확실히 편하다.

최근에 AWS Secrets Manager가 Datadog, Snowflake 같은 서드파티 시크릿을 자동 로테이션해주는 managed external secrets 기능을 추가했는데, 이거랑 ESO를 조합하면 진짜 손 놓고 살 수 있을 것 같다. 다음 분기에 검토해볼 예정.

혹시 ESO 대신 다른 방식 쓰시는 분 있으면 어떤 이유로 그 선택을 했는지 궁금하다.


태그: ExternalSecrets, AWS, SecretsManager, Kubernetes, DevSecOps, IRSA

BIG