Vault와 External Secrets Operator로 Kubernetes Secret 관리 자동화하기 (1편) — MariaDB 연동 실습

Kubernetes Secret은 기본적으로 base64 인코딩만 되어 있을 뿐 평문에 가깝고, kubectl get secret -o yaml 한 번이면 누구나 값을 복원할 수 있다. Git에 커밋하면 이력에 평문이 영구히 남고, GPG + SOPS + Kustomize로 암호화해도 값 자체는 여전히 Git 안에 있어서 복호화 키만 있으면 누구나 원문을 복원할 수 있다.

이 글에서 다루는 Vault 방식은 접근이 다르다. Secret 값은 암호화된 형태로도 Git에 존재하지 않고, HashiCorp Vault라는 살아있는 중앙 저장소에만 있다. 클러스터는 배포 시점에 External Secrets Operator(ESO)를 통해 그 값을 Kubernetes Secret으로 동기화해올 뿐이고, Git에는 “이 경로의 값을 가져오라"는 참조 정보만 남는다. 그 결과 접근 정책·감사 로그·회전을 Vault 한 곳에서 관리할 수 있고, 여러 클러스터가 같은 Secret을 공유해도 사본이 늘어나지 않는다. 이 글은 이 구조를 실제로 구성해서, MariaDB가 그 Secret을 사용해 기동되고 접속까지 되는 과정을 처음부터 끝까지 따라간 기록이다.

Vault
  │
  │ DB_USERNAME / DB_PASSWORD
  ▼
External Secrets Operator
  │
  │ 동기화 (polling)
  ▼
Kubernetes Secret
  │
  ▼
MariaDB
  │
  ▼
mysql 접속 → DB 생성 → 데이터 조회

1. Kubernetes Secret을 왜 외부 Secret Manager로 관리하는가

Kubernetes Secret의 한계

  • etcd에 저장되는 값은 암호화(encryption at rest)를 별도로 켜지 않으면 평문이나 다름없다.
  • base64는 인코딩이지 암호화가 아니다. 클러스터에 접근 권한만 있으면 누구나 디코딩할 수 있다.
  • Secret 값 변경 이력, 접근 이력을 Kubernetes 자체는 추적하지 않는다.
  • 여러 클러스터/네임스페이스에 동일한 Secret을 뿌리려면 값을 복제해야 하고, 회전(rotation) 시 모든 위치를 일일이 갱신해야 한다.

Git에 Secret을 저장하지 않는 이유

GitOps는 “선언한 상태를 Git이 진실의 원천(source of truth)이 되게 한다"가 핵심인데, Secret 값 자체를 Git에 평문으로 커밋하면 다음 문제가 그대로 발생한다.

  • Git 이력은 삭제해도 완전히 지워지지 않는다. 강제 push나 이력 재작성 없이는 과거 커밋에 값이 남는다.
  • 저장소에 접근 가능한 모든 사람(리뷰어, CI, 포크한 사람)이 값을 볼 수 있다.
  • 값 회전 시 커밋을 새로 만들어야 하고, 그 커밋 자체가 또 이력에 남는다.

GPG + SOPS + Kustomize 조합처럼 암호화된 파일을 Git에 커밋하는 방식도 가능하지만, 이번 글에서는 아예 Secret 값을 Git 저장소 바깥의 Vault에만 두고 Kubernetes에는 배포 시점에만 동기화되도록 하는 방식을 다룬다.

Vault + External Secrets Operator(ESO) 구조

  • Vault: Secret 값을 실제로 저장하고 관리하는 중앙 저장소. KV(Key-Value) 엔진, 접근 정책, 감사 로그, rotation 기능을 제공한다.
  • External Secrets Operator: Kubernetes 클러스터 안에서 동작하는 오퍼레이터로, ExternalSecret 커스텀 리소스를 감시하다가 Vault 등 외부 저장소의 값을 읽어와 일반 Kubernetes Secret으로 동기화한다.

이 구조의 핵심은 Kubernetes Secret 매니페스트 자체는 Git에 커밋해도 되지만(값이 없으므로), 실제 값은 Vault에만 존재하고 클러스터에는 ESO가 주기적으로 동기화한 결과물만 남는다는 점이다.

전체 실습 아키텍처

이 글에서 구성할 환경은 다음과 같다.

  • Kubernetes 클러스터 (k3s 또는 kind 등, 단일 노드로도 충분)
  • Vault (dev 모드가 아닌 실제 초기화/unseal 과정을 거친 구성)
  • External Secrets Operator
  • MariaDB (Bitnami Helm 차트)

모든 컴포넌트는 vault, external-secrets, mariadb 네임스페이스로 분리해서 설치한다.

kubectl create namespace vault
kubectl create namespace external-secrets
kubectl create namespace mariadb

2. Vault 설치

Helm으로 Vault 설치

helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update

helm install vault hashicorp/vault \
  --namespace vault \
  --set "server.dev.enabled=false" \
  --set "server.standalone.enabled=true" \
  --set "ui.enabled=true"

dev.enabled=false로 두는 이유는 dev 모드는 재시작하면 데이터가 날아가고 자동으로 unseal되어 있어 실습 이상의 의미가 없기 때문이다. standalone 모드로 설치하면 파일 기반 storage backend를 사용하는 단일 Pod가 뜬다.

kubectl get pods -n vault
NAME       READY   STATUS    RESTARTS   AGE
vault-0    0/1     Running   0          30s

0/1인 이유는 Vault가 아직 초기화(init)되지 않았고 sealed 상태이기 때문이다.

초기화 및 unseal

kubectl exec -n vault -it vault-0 -- vault operator init \
  -key-shares=5 \
  -key-threshold=3 \
  -format=json > vault-init.json

vault-init.json에는 unseal key 5개와 root token이 담긴다. 이 파일은 실습이 끝나면 반드시 안전하게 보관하거나 폐기해야 하며, 절대 Git에 커밋하지 않는다.

UNSEAL_KEY_1=$(jq -r '.unseal_keys_b64[0]' vault-init.json)
UNSEAL_KEY_2=$(jq -r '.unseal_keys_b64[1]' vault-init.json)
UNSEAL_KEY_3=$(jq -r '.unseal_keys_b64[2]' vault-init.json)
ROOT_TOKEN=$(jq -r '.root_token' vault-init.json)

kubectl exec -n vault -it vault-0 -- vault operator unseal $UNSEAL_KEY_1
kubectl exec -n vault -it vault-0 -- vault operator unseal $UNSEAL_KEY_2
kubectl exec -n vault -it vault-0 -- vault operator unseal $UNSEAL_KEY_3

key-threshold=3으로 초기화했기 때문에 5개 중 3개의 키가 모여야 unseal된다. Pod가 재시작될 때마다 이 unseal 과정을 다시 거쳐야 하는데, 운영 환경에서는 이 과정을 auto-unseal(KMS, Transit 등)로 자동화한다. 이 부분은 7장에서 다시 다룬다.

kubectl get pods -n vault
NAME       READY   STATUS    RESTARTS   AGE
vault-0    1/1     Running   0          2m

Kubernetes 인증 방식 설정

ESO가 Vault에 접근할 때 Kubernetes ServiceAccount 토큰으로 인증하도록 Kubernetes Auth Method를 활성화한다.

kubectl exec -n vault -it vault-0 -- sh -c "
export VAULT_TOKEN=$ROOT_TOKEN
vault auth enable kubernetes

vault write auth/kubernetes/config \
  kubernetes_host=\"https://\$KUBERNETES_SERVICE_HOST:443\"
"

이어서 ESO가 사용할 정책(policy)과 role을 만든다.

kubectl exec -n vault -it vault-0 -- sh -c "
export VAULT_TOKEN=$ROOT_TOKEN

vault policy write eso-policy - <<EOF
path \"secret/data/*\" {
  capabilities = [\"read\"]
}
EOF

vault write auth/kubernetes/role/eso-role \
  bound_service_account_names=eso-vault-sa \
  bound_service_account_namespaces=mariadb \
  policies=eso-policy \
  ttl=1h
"

bound_service_account_namesbound_service_account_namespaces가 뒤에서 만들 SecretStore의 ServiceAccount와 정확히 일치해야 한다. SecretStoremariadb 네임스페이스에 만들 것이므로, bound_service_account_namespacesmariadb로 지정한다.

Secret 저장소(KV) 구성

kubectl exec -n vault -it vault-0 -- sh -c "
export VAULT_TOKEN=$ROOT_TOKEN
vault secrets enable -path=secret kv-v2
"

이제 secret/ 경로 아래에 KV v2 엔진이 준비되었다.

3. External Secrets Operator 설치

Helm으로 ESO 설치

helm repo add external-secrets https://charts.external-secrets.io
helm repo update

helm install external-secrets external-secrets/external-secrets \
  --namespace external-secrets \
  --set installCRDs=true
kubectl get pods -n external-secrets
NAME                                                READY   STATUS    RESTARTS   AGE
external-secrets-7d9f8c9b5-abcde                    1/1     Running   0          40s
external-secrets-cert-controller-6f7d8c9b5-fghij     1/1     Running   0          40s
external-secrets-webhook-5c6d7e8f9-klmno             1/1     Running   0          40s

Vault와 ESO 연동

앞서 Vault에 등록한 eso-role을 사용할 ServiceAccount를 만든다. SecretStore는 클러스터 전역이 아니라 네임스페이스 스코프 리소스이고, serviceAccountRef가 가리키는 ServiceAccount도 반드시 그 SecretStore와 같은 네임스페이스에 있어야 한다. 뒤에서 SecretStoremariadb 네임스페이스에 만들 것이므로, ServiceAccount도 mariadb에 만든다.

kubectl create serviceaccount eso-vault-sa -n mariadb

ServiceAccount를 SecretStore와 다른 네임스페이스(예: external-secrets)에 만들면 kubectl apply 시 admission webhook이 namespace should either be empty or match the namespace of the SecretStore for a namespaced SecretStore 에러로 거부한다. ClusterSecretStore를 쓰면 이 제약이 없지만(7장 참고), 이 글에서는 네임스페이스 스코프인 SecretStore로 진행한다.

SecretStore 구성

SecretStore는 ESO에게 “어디에서, 어떤 방식으로 값을 가져올지"를 알려주는 리소스다. 이 글에서 사용한 ESO(Helm external-secrets/external-secrets 최신 차트)는 external-secrets.io/v1beta1이 아니라 external-secrets.io/v1 CRD로 배포된다. kubectl api-resources | grep external-secrets로 클러스터에 등록된 CRD 버전을 먼저 확인하고, 그에 맞는 apiVersion을 사용해야 한다.

apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: vault-backend
  namespace: mariadb
spec:
  provider:
    vault:
      server: "http://vault.vault.svc.cluster.local:8200"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "eso-role"
          serviceAccountRef:
            name: eso-vault-sa
kubectl apply -f secretstore-vault.yaml
kubectl get secretstore -n mariadb
NAME            AGE   STATUS   CAPABILITIES   READY
vault-backend   10s   Valid    ReadWrite      True

READY=True면 ESO가 Vault Kubernetes Auth를 통해 정상적으로 토큰을 발급받았다는 뜻이다. False라면 대부분 bound_service_account_namespaces 불일치나 Vault 주소 문제이니 kubectl describe secretstore로 이벤트를 확인한다.

Vault의 Secret을 Kubernetes Secret으로 동기화하는 구조

SecretStore가 연결 정보라면, 실제로 “어떤 Vault 경로의 값을 어떤 Kubernetes Secret으로 만들어라"라고 지시하는 것은 ExternalSecret 리소스다. 이 리소스는 다음 장에서 MariaDB Secret을 만들 때 바로 작성한다.

4. MariaDB 설치와 Secret 연동

Vault에 MariaDB 계정/비밀번호 저장

kubectl exec -n vault -it vault-0 -- sh -c "
export VAULT_TOKEN=$ROOT_TOKEN
vault kv put secret/mariadb/app \
  username=appuser \
  password=SuperSecretP@ss1 \
  root-password=RootP@ss1
"
kubectl exec -n vault -it vault-0 -- sh -c "
export VAULT_TOKEN=$ROOT_TOKEN
vault kv get secret/mariadb/app
"
====== Data ======
Key             Value
---             -----
password        SuperSecretP@ss1
root-password   RootP@ss1
username        appuser

ESO ExternalSecret 작성

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: mariadb-credentials
  namespace: mariadb
spec:
  refreshInterval: 1m
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: mariadb-credentials
    creationPolicy: Owner
  data:
    - secretKey: mariadb-username
      remoteRef:
        key: mariadb/app
        property: username
    - secretKey: mariadb-password
      remoteRef:
        key: mariadb/app
        property: password
    - secretKey: mariadb-root-password
      remoteRef:
        key: mariadb/app
        property: root-password

refreshInterval: 1m은 ESO가 1분마다 Vault 값을 다시 조회해 변경 여부를 확인한다는 뜻이다. 이 값이 뒤의 6장에서 다룰 Secret rotation 동작을 좌우한다.

kubectl apply -f externalsecret-mariadb.yaml
kubectl get externalsecret -n mariadb
NAME                   STORETYPE     STORE           REFRESH INTERVAL   STATUS         READY   LAST SYNC
mariadb-credentials    SecretStore   vault-backend   1m                  SecretSynced   True    8s

ESO가 생성한 Kubernetes Secret 확인

kubectl get secret mariadb-credentials -n mariadb -o yaml
apiVersion: v1
kind: Secret
metadata:
  name: mariadb-credentials
  namespace: mariadb
  ownerReferences:
    - apiVersion: external-secrets.io/v1
      kind: ExternalSecret
      name: mariadb-credentials
data:
  mariadb-username: YXBwdXNlcg==
  mariadb-password: U3VwZXJTZWNyZXRQQHNzMQ==
  mariadb-root-password: Um9vdFBAc3Mx
type: Opaque

ownerReferencesExternalSecret로 설정되어 있는 걸 볼 수 있는데, ExternalSecret을 삭제하면 이 Kubernetes Secret도 함께 삭제된다(creationPolicy: Owner 기준).

MariaDB가 Secret을 사용하도록 구성

Bitnami MariaDB 차트는 existingSecret 옵션으로 외부에서 만든 Secret을 그대로 사용할 수 있다.

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

helm install mariadb bitnami/mariadb \
  --namespace mariadb \
  --set auth.existingSecret=mariadb-credentials \
  --set auth.username=appuser \
  --set auth.database=appdb

Bitnami 차트는 existingSecret 안의 키 이름이 mariadb-root-password, mariadb-password, mariadb-replication-password로 고정되어 있으므로, ExternalSecretsecretKey를 이 이름에 맞춰 작성해야 한다(위 예시에서 이미 맞춰 두었다).

5. 실제 접속 테스트

MariaDB Pod 확인

kubectl get pods -n mariadb
NAME        READY   STATUS    RESTARTS   AGE
mariadb-0   1/1     Running   0          1m

Kubernetes Secret으로 생성된 계정 확인

kubectl get secret mariadb-credentials -n mariadb -o jsonpath="{.data.mariadb-password}" | base64 -d
SuperSecretP@ss1

Vault에 저장한 값과 동일하게 나오면 Vault → ESO → Kubernetes Secret 동기화가 정상 동작한 것이다.

mysql CLI로 접속

kubectl run mariadb-client --rm --tty -i --restart='Never' \
  --namespace mariadb \
  --image docker.io/bitnami/mariadb:latest \
  --env MARIADB_ROOT_PASSWORD=$(kubectl get secret mariadb-credentials -n mariadb -o jsonpath="{.data.mariadb-root-password}" | base64 -d) \
  --command -- bash

Pod 안에서:

mysql -h mariadb.mariadb.svc.cluster.local -uroot -p"$MARIADB_ROOT_PASSWORD"

데이터베이스 생성 및 테이블/데이터 입력

CREATE DATABASE IF NOT EXISTS appdb;
USE appdb;

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(100),
  email VARCHAR(100)
);

INSERT INTO users (name, email) VALUES ('lemon', 'lemon@example.com');

SELECT * FROM users;
+----+-------+--------------------+
| id | name  | email              |
+----+-------+--------------------+
|  1 | lemon | lemon@example.com  |
+----+-------+--------------------+

애플리케이션에서 Secret을 사용하는 예

일반 애플리케이션 Deployment에서는 다음처럼 동일한 Secret을 환경변수로 주입해서 사용하면 된다.

env:
  - name: DB_USERNAME
    valueFrom:
      secretKeyRef:
        name: mariadb-credentials
        key: mariadb-username
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: mariadb-credentials
        key: mariadb-password

6. Secret 변경과 동기화 확인

Vault의 비밀번호 변경

kubectl exec -n vault -it vault-0 -- sh -c "
export VAULT_TOKEN=$ROOT_TOKEN
vault kv put secret/mariadb/app \
  username=appuser \
  password=NewP@ssw0rd2 \
  root-password=RootP@ss1
"

ESO의 동기화 과정

ExternalSecret에 설정한 refreshInterval: 1m에 따라 ESO는 최대 1분 안에 Vault 값을 다시 조회한다. 강제로 즉시 확인하고 싶다면 ExternalSecret에 어노테이션을 추가해 재동기화를 트리거할 수 있다.

kubectl annotate externalsecret mariadb-credentials -n mariadb \
  force-sync=$(date +%s) --overwrite

Kubernetes Secret 변경 확인

kubectl get secret mariadb-credentials -n mariadb -o jsonpath="{.data.mariadb-password}" | base64 -d
NewP@ssw0rd2

Vault 값이 그대로 Kubernetes Secret에 반영된 것을 확인할 수 있다.

변경된 Secret을 실제로 반영하는 방법

Kubernetes Secret 자체는 이미 자동으로 갱신됐다. 남은 질문은 그 값을 실제로 사용 중인 MariaDB가 반영하는지 여부다.

Bitnami MariaDB 차트는 비밀번호를 일반 환경변수가 아니라 MARIADB_PASSWORD_FILE처럼 볼륨 마운트된 파일 경로로 주입한다. Secret이 갱신되면 마운트된 파일 내용도 Pod를 재시작하지 않아도 kubelet의 동기화 주기(최대 1분 내외)에 따라 함께 자동으로 갱신된다.

kubectl exec -n mariadb mariadb-0 -- cat /opt/bitnami/mariadb/secrets/mariadb-password
NewP@ssw0rd2

문제는 파일 내용이 바뀐 것과 실제 MariaDB 계정 비밀번호가 바뀐 것은 별개라는 점이다. 새 비밀번호로 접속을 시도하면 실패하고, 옛 비밀번호는 여전히 통한다.

mysql -h mariadb.mariadb.svc.cluster.local -uappuser -pNewP@ssw0rd2 -e "SELECT 1;"
ERROR 1045 (28000): Access denied for user 'appuser'@'...' (using password: YES)
mysql -h mariadb.mariadb.svc.cluster.local -uappuser -pSuperSecretP@ss1 -e "SELECT 1;"
+---+
| 1 |
+---+
| 1 |
+---+

Pod를 재시작해도(kubectl rollout restart statefulset mariadb -n mariadb) 결과는 같다.

kubectl rollout restart statefulset mariadb -n mariadb
kubectl rollout status statefulset mariadb -n mariadb

mysql -h mariadb.mariadb.svc.cluster.local -uappuser -pNewP@ssw0rd2 -e "SELECT 1;"
ERROR 1045 (28000): Access denied for user 'appuser'@'...' (using password: YES)

이유는 Bitnami MariaDB 이미지의 엔트리포인트 스크립트가 계정 생성/비밀번호 설정을 데이터 디렉터리가 비어있는 최초 초기화 시점에만 수행하기 때문이다. PVC에 이미 데이터가 있는 상태로 재시작하면 이 초기화 로직 자체를 건너뛰므로, 파일과 Kubernetes Secret은 새 값을 가리켜도 MySQL 내부 계정 테이블(mysql.user)의 실제 비밀번호는 그대로 남는다.

즉 “Secret이 갱신되면 Pod만 재시작하면 새 값이 반영된다"는 일반화는 무상태(stateless) 애플리케이션에는 맞지만, MariaDB처럼 계정 정보가 영속 데이터(PVC)에 각인되는 서비스에는 적용되지 않는다. 이런 경우 비밀번호를 실제로 회전시키려면 다음 중 하나가 필요하다.

  • ALTER USER 등으로 DB 내부에서 직접 비밀번호를 변경하고, 그 값을 Vault에도 반영해 Secret과 실제 DB 상태를 일치시킨다.
  • Vault의 Database Secrets Engine으로 전환해 Vault가 직접 DB 계정 비밀번호를 발급/회전하도록 위임한다(7장 참고).
  • 애플리케이션(비상태 워크로드)의 경우에는 Secret 변경 시 자동 재시작을 트리거하는 Reloader 같은 컨트롤러로 충분하다.

ESO는 Vault 값을 Kubernetes Secret으로 동기화하는 것까지만 책임지며, 그 값을 실제로 어떻게 소비하고 반영할지는 워크로드의 특성(무상태 vs. 영속 데이터를 가진 스테이트풀 서비스)에 따라 별도로 설계해야 한다는 점이 이번 실습에서 확인한 핵심이다.

7. 운영 환경에서 고려할 것

Vault 장애와 복구

  • Vault Pod가 재시작되면 다시 sealed 상태가 되어 수동 unseal이 필요하다. 운영 환경에서는 이 문제를 피하기 위해 AWS KMS, GCP KMS, Transit auto-unseal을 구성해 재시작 시 자동으로 unseal되도록 해야 한다.
  • Vault가 완전히 다운되면 ESO는 마지막으로 동기화한 Kubernetes Secret 값을 그대로 유지한다(캐시처럼 동작). 즉 Vault 장애가 곧바로 서비스 장애로 이어지지는 않지만, 그 사이 Secret 회전은 멈춘다.
  • Vault storage는 반드시 별도 백업(스냅샷)을 주기적으로 받아야 하며, unseal key는 여러 담당자에게 분산 보관(Shamir’s Secret Sharing)하는 것이 원칙이다.

Vault 인증 및 권한 분리

  • 이 글에서는 root token으로 정책을 설정했지만, 운영에서는 root token을 초기 설정 이후 즉시 폐기(revoke)하고 최소 권한 정책만 가진 관리자 토큰을 사용해야 한다.
  • bound_service_account_names/bound_service_account_namespaces를 최대한 좁게 지정해서, 특정 네임스페이스의 ESO만 특정 경로(secret/data/mariadb/*)에 접근하도록 정책을 세분화해야 한다.
  • 애플리케이션/환경별로 Vault policy와 경로를 분리하면(secret/prod/*, secret/dev/*) 권한 사고 시 영향 범위를 줄일 수 있다.

Secret rotation

  • refreshInterval은 짧을수록 반영이 빠르지만 Vault에 대한 조회 부하가 늘어난다. 값의 민감도와 변경 빈도에 맞춰 조정한다(예: DB 비밀번호는 5~15분, 거의 바뀌지 않는 API 키는 1시간 이상).
  • 값 변경 후 Pod가 자동으로 재시작되지 않는 문제는 앞서 언급한 Reloader, 또는 애플리케이션이 Secret을 파일 마운트로 읽어 변경을 감지하는 방식으로 보완할 수 있다.
  • Vault의 Database Secrets Engine을 사용하면 아예 짧은 TTL을 가진 동적 DB 계정을 발급받는 방식으로 전환할 수도 있다. 이 경우 ESO가 매번 새로운 계정/비밀번호를 동기화하게 된다.

SecretStore vs ClusterSecretStore

  • SecretStore는 네임스페이스 스코프라 해당 네임스페이스의 ExternalSecret만 참조할 수 있다.
  • ClusterSecretStore는 클러스터 전역에서 참조 가능하다. 여러 네임스페이스가 같은 Vault 백엔드를 공유한다면 ClusterSecretStore 하나로 관리하는 편이 중복 설정을 줄인다.
  • 다만 ClusterSecretStore는 모든 네임스페이스에서 접근 가능하므로, 네임스페이스별 권한 분리가 필요한 멀티테넌트 클러스터에서는 오히려 SecretStore를 네임스페이스마다 분리하는 것이 안전하다.

Secret을 Pod 환경변수로 주입할 때의 주의점

  • 환경변수로 주입된 Secret은 kubectl describe pod, 프로세스 덤프, /proc/<pid>/environ 등을 통해 노출될 위험이 있다. 민감도가 높은 값은 환경변수 대신 volume mount로 파일 형태로 제공하는 것이 더 안전하다.
  • 로그에 환경변수를 그대로 덤프하는 애플리케이션이나 미들웨어가 있다면 그 자체가 Secret 유출 경로가 될 수 있으니 점검이 필요하다.

Vault/ESO를 운영 환경에 적용할 때의 구성

  • Vault는 고가용성을 위해 Raft integrated storage 기반의 멀티 노드 클러스터로 구성하는 것이 일반적이다(이 글의 standalone 구성은 실습용).
  • ESO는 여러 Provider(Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault 등)를 동시에 지원하므로, 멀티 클라우드 환경에서는 Provider별 SecretStore를 나눠 구성할 수 있다.
  • GitOps 파이프라인에는 SecretStore, ExternalSecret 매니페스트만 커밋하고, Vault 자체의 초기화/unseal/정책 설정은 별도의 부트스트랩 절차(Terraform, Ansible 등)로 관리하는 것이 실무에서 흔한 패턴이다.

8. 마무리

Vault → ESO → Kubernetes Secret → MariaDB 전체 흐름 정리

이번 실습에서 확인한 흐름을 다시 정리하면 다음과 같다.

  1. Vault에 KV v2 엔진을 활성화하고 MariaDB 계정 정보를 저장한다.
  2. Kubernetes Auth Method로 ESO의 ServiceAccount가 Vault에 인증할 수 있도록 role과 policy를 구성한다.
  3. SecretStore로 ESO와 Vault 사이의 연결을 정의한다.
  4. ExternalSecret으로 Vault의 특정 경로/키를 Kubernetes Secret으로 동기화한다.
  5. MariaDB Helm 차트가 existingSecret으로 이 Secret을 참조해 계정을 생성한다.
  6. Vault 값이 바뀌면 ESO가 Kubernetes Secret을 자동으로 갱신하지만, MariaDB처럼 계정 정보가 PVC에 각인된 스테이트풀 서비스는 Pod를 재시작해도 실제 DB 비밀번호는 바뀌지 않는다 — 별도로 ALTER USER를 실행하거나 Vault Database Secrets Engine으로 전환해야 한다.

이 구조가 기존 Kubernetes Secret 관리와 어떻게 다른가

기존에는 kubectl create secret으로 값을 직접 클러스터에 넣거나, SOPS로 암호화한 파일을 Git에 커밋하는 방식을 사용했다. 이번 구조의 차이는 Secret 값의 진실의 원천이 Git도 Kubernetes도 아닌 Vault라는 점이다. Git에는 SecretStoreExternalSecret이라는, 값이 없는 “참조 정보"만 커밋되고, 실제 값은 Vault의 접근 정책과 감사 로그로 통제된다. 값 회전, 접근 이력 추적, 여러 클러스터 간 Secret 공유 같은 문제들이 Vault라는 단일 지점으로 모이면서 GitOps 워크플로우와 Secret 관리가 자연스럽게 분리된다.


DB 계정 정보 외에 TLS 인증서처럼 만료·회전이 있는 다른 종류의 Secret도 같은 방식으로 다룰 수 있을까? 이 질문은 2편: cert-manager 인증서를 Vault와 ESO로 Ingress까지 연동하기에서 이어서 다룬다.