본문 바로가기
삵
Cloud Computing & MSA

External Secrets Operator로 AWS 비밀 동기화

아아무로레이·2026년 9월 25일·조회 1

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의 비밀 회전이 클러스터까지 자동으로 이어진다.

자주 묻는 질문

SecretStore와 ClusterSecretStore는 어떻게 다른가?

SecretStore는 특정 네임스페이스 안에서만 참조할 수 있고, ClusterSecretStore는 클러스터 전역에서 여러 네임스페이스의 ExternalSecret이 공유해서 쓸 수 있다. 여러 팀이 같은 AWS 연결을 재사용한다면 ClusterSecretStore가 편하고, 네임스페이스별로 권한과 접근 범위를 분리하려면 SecretStore를 쓴다. ClusterSecretStore의 인증 참조에는 서비스어카운트의 namespace를 명시해야 한다.

creationPolicy None으로 두면 Secret이 채워지지 않는 이유는?

공식 정의상 None은 Secret을 생성하지도 갱신하지도 않는 옵션이며 injector와 함께 쓰기 위해 예약된 값이다. 값을 채우는 용도가 아니다. 기존 Secret에 키만 병합하려면 Merge를 쓰고, 이때 대상 Secret이 미리 존재해야 한다. 새로 만들어 채우려면 기본값 Owner를 쓴다.

refreshInterval을 0으로 두면 어떻게 되나?

0으로 두면 최초 1회만 원격 값을 가져오고 이후에는 갱신하지 않는다. AWS에서 비밀이 회전되어도 쿠버네티스 Secret은 옛 값으로 남는다. 회전을 반영하려면 1m, 1h처럼 양수 주기를 지정해야 한다. 즉시 당겨오고 싶으면 ExternalSecret에 임의 애너테이션을 찍어 강제 조정을 유발한다.

Secret이 갱신됐는데 파드에 새 값이 안 보인다.

환경변수로 주입한 값은 파드가 시작할 때 한 번 읽히므로, Secret이 바뀌어도 실행 중인 프로세스에는 자동 반영되지 않는다. 롤아웃을 다시 태워야 한다. 볼륨 마운트는 kubelet이 파일을 갱신하지만 애플리케이션이 변경을 감지해 다시 로드해야 실제로 적용된다. Reloader 같은 도구로 Secret 변경 시 배포 재기동을 자동화할 수 있다.

IRSA 대신 정적 액세스 키를 써도 되나?

auth.secretRef로 accessKeyID와 secretAccessKey를 참조하는 정적 키 방식도 지원한다. 다만 EKS에서는 키를 클러스터에 저장하지 않고 임시 자격증명을 발급받는 IRSA가 권장된다. EKS Pod Identity를 쓰는 방법도 있다. 자격증명 유출 위험과 회전 부담을 줄이려면 IRSA나 Pod Identity를 선택한다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.