이전 글에서는 Vault를 직접 사용해 WebApp이 Vault API를 호출하여 Secret을 조회하는 흐름을 실습했다.
[Study][GitOps] HashiCorp Vault — 나무늘보의 IT생활
[Study][GitOps] HashiCorp Vault
Vault 란Vault는 애플리케이션에서 사용하는 민감한 정보(Secret)를 안전하게 저장하고 필요할 때만 조회할 수 있도록 제공하는 서버이다.Vault에 대한 모든 접근은 Token 기반으로 제어되며, Token에는 P
ersia.tistory.com
이번 글에서는 Vault Secrets Operator(VSO) 를 사용해, Vault에 저장된 Secret을 Kubernetes Secret으로 자동 동기화하는 구조를 간단하게 실습해본다. 이 방식을 사용하면 애플리케이션은 Vault를 직접 알 필요 없이, 기존과 동일하게 Kubernetes Secret만 사용할 수 있다.
Vault Secrets Operator란
Vault Secrets Operator(VSO)는 Vault에 저장된 Secret을 Kubernetes Secret으로 동기화해주는 컨트롤러이다.
구조는 아래와 같다.
Vault (Source of Truth)
↓
Vault Secrets Operator
↓
Kubernetes Secret
↓
Pod / Application
즉, Vault를 Secret의 Source of Truth로 사용하면서도 애플리케이션은 기존 K8S Secret 사용 방식을 유지할 수 있다.
실습환경
- 이전 글에서 구성한 실습 환경(kind + Vault + Kubernetes Auth) 을 그대로 사용한다.
- Vault, Kubernetes Auth, Secret(secret/webapp/config) 이미 구성되어 있다고 가정
Vault Secrets Operator 설치
Helm Repo 추가
이전 실습에서 이미 되어있으므로 건너뛰어도 무방함
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update
VSO 설치
kubectl create ns vault-secrets-operator
helm install vso hashicorp/vault-secrets-operator -n vault-secrets-operator
kubectl create sa vault-secrets-operator -n default
배포확인
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl get pod -n vault-secrets-operator
NAME READY STATUS RESTARTS AGE
vso-vault-secrets-operator-controller-manager-d58f9c859-j24qx 2/2 Running 0 40s
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl api-resources | grep -i vault
hcpvaultsecretsapps secrets.hashicorp.com/v1beta1 true HCPVaultSecretsApp
vaultauthglobals secrets.hashicorp.com/v1beta1 true VaultAuthGlobal
vaultauths secrets.hashicorp.com/v1beta1 true VaultAuth
vaultconnections secrets.hashicorp.com/v1beta1 true VaultConnection
vaultdynamicsecrets secrets.hashicorp.com/v1beta1 true VaultDynamicSecret
vaultpkisecrets secrets.hashicorp.com/v1beta1 true VaultPKISecret
vaultstaticsecrets secrets.hashicorp.com/v1beta1 true VaultStaticSecret
Vault Kubernetes Auth Role 재정의
default 네임스페이스까지 허용하도록 설정한다.
vault write auth/kubernetes/role/vso \
bound_service_account_names="vault-secrets-operator" \
bound_service_account_namespaces="vault-secrets-operator,default" \
policies="webapp" \
ttl=24h \
audience="https://kubernetes.default.svc.cluster.local"
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# vault read auth/kubernetes/role/vso
Key Value
--- -----
alias_name_source serviceaccount_uid
audience https://kubernetes.default.svc.cluster.local
bound_service_account_names [vault-secrets-operator]
bound_service_account_namespace_selector n/a
bound_service_account_namespaces [vault-secrets-operator default]
policies [webapp]
token_bound_cidrs []
token_explicit_max_ttl 0s
token_max_ttl 0s
token_no_default_policy false
token_num_uses 0
token_period 0s
token_policies [webapp]
token_ttl 24h
token_type default
ttl 24h
VaultConnection 생성
VSO가 어디에 있는 Vault에 접근할지 정의해야한다. (Vault는 클러스터 내부 서비스 주소로 접근)
cat <<'EOF' | kubectl apply -f -
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultConnection
metadata:
name: vault-conn
namespace: vault-secrets-operator
spec:
address: "http://vault.vault.svc:8200"
EOF
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl get vaultconnections -n vault-secrets-operator
NAME AGE
vault-conn 33s
VaultAuth 생성 (Kubernetes Auth)
VSO가 Vault에 인증할 때 사용할 Auth와 Role을 정의한다.
- Auth: Kubernetes Auth
- Role: vso
- ServiceAccount: vault-secrets-operator
cat <<'EOF' | kubectl apply -f -
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: vault-auth
namespace: vault-secrets-operator
spec:
allowedNamespaces:
- default
vaultConnectionRef: vault-conn
method: kubernetes
kubernetes:
role: vso
serviceAccount: vault-secrets-operator
audiences:
- https://kubernetes.default.svc.cluster.local
EOF
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl get vaultauths -n vault-secrets-operator
NAME AGE
vault-auth 7s
VaultStaticSecret이 다른 네임스페이스의 VaultAuth를 참조하는 경우, VaultAuth에 `allowedNamespaces` 설정이 필요하다.
이를 설정하지 않으면 VSO는 보안상 이유로 Secret 동기화를 차단한다.
VaultStaticSecret 생성 (Vault → K8S Secret 동기화)
이제 Vault의 KV Secret을 Kubernetes Secret으로 동기화한다.
- Vault KV(v2)
- Path: secret/webapp/config
- Kubernetes Secret 이름: webapp-secret
- 30초마다 갱신
cat <<'EOF' | kubectl apply -f -
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: webapp-static
namespace: default
spec:
vaultAuthRef: vault-secrets-operator/vault-auth
mount: "secret"
type: "kv-v2"
path: "webapp/config"
destination:
name: webapp-secret
create: true
refreshAfter: "30s"
EOF
여기까지 작업 후 webapp-secret을 확인해보면 아무것도 나오지 않는다.
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl get secret webapp-secret -n default
Error from server (NotFound): secrets "webapp-secret" not found
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl describe vaultstaticsecret webapp-static -n default
...
Reason: Unhealthy
Status: False
Type: Healthy
Last Generation: 2
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Unrecoverable 2m22s (x25 over 3m59s) vaultClientFactory Failed to get cacheKey from obj, err=target namespace "default" is not allowed by kind=VaultAuth obj=vault-secrets-operator/vault-auth, allowedNamespaces=[]
Warning VaultClientConfigError 2m22s (x25 over 3m59s) VaultStaticSecret Failed to get Vault auth login: target namespace "default" is not allowed by kind=VaultAuth obj=vault-secrets-operator/vault-auth, allowedNamespaces=[]
Kubernetes Secret 자동 생성 확인
잠시 후 Kubernetes Secret이 자동으로 생성된다.
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl get secret webapp-secret -n default
NAME TYPE DATA AGE
webapp-secret Opaque 3 55s
Secret값 확인
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl get secret webapp-secret -n default -o json \
| jq -r '.data | map_values(@base64d)'
{
"_raw": "{\"data\":{\"password\":\"changed-password\",\"username\":\"changed-user\"},\"metadata\":{\"created_time\":\"2025-12-13T16:54:55.916252198Z\",\"custom_metadata\":null,\"deletion_time\":\"\",\"destroyed\":false,\"version\":2}}",
"password": "changed-password",
"username": "changed-user"
}
Pod에서 일반 Kubernetes Secret처럼 사용
이제 애플리케이션은 Vault를 전혀 몰라도 동작할 수 있다.
Vault Secrets Operator가 Vault의 값을 Kubernetes Secret으로 동기화해주기 때문에, Pod에서는 기존과 동일하게 Secret을 참조하면 된다.
테스트용 Pod 생성
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: test-pod
namespace: default
spec:
restartPolicy: Never
containers:
- name: busybox
image: busybox
command: ["/bin/sh", "-c", "sleep 3600"]
envFrom:
- secretRef:
name: webapp-secret
EOF
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl get pod test-pod -n default
NAME READY STATUS RESTARTS AGE
test-pod 1/1 Running 0 8s
Pod 내부에서 Secret 값 확인
Pod에 접속해 환경 변수를 확인한다.
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl exec -it test-pod -n default -- /bin/sh
/ # env | grep -E 'username|password'
username=changed-user
password=changed-password
_raw={"data":{"password":"changed-password","username":"changed-user"},"metadata":{"created_time":"2025-12-13T16:54:55.916252198Z","custom_metadata":null,"deletion_time":"","destroyed":false,"version":2}}
/ #
/ # echo "$username"
changed-user
/ #
/ # echo "$password"
changed-password
Vault에 저장된 값이 그대로 환경 변수로 주입된 것을 확인할 수 있다.
Vault 값 변경 → 자동 반영 확인
Vault의 Secret 값을 변경해본다.
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# vault kv put secret/webapp/config \
username="new-user" \
password="new-password"
====== Secret Path ======
secret/data/webapp/config
======= Metadata =======
Key Value
--- -----
created_time 2025-12-13T18:14:31.870255338Z
custom_metadata <nil>
deletion_time n/a
destroyed false
version 3
잠시 후 Kubernetes Secret을 다시 확인한다.
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl get secret webapp-secret -n default -o json | jq -r '.data | map_values(@base64d)'
{
"_raw": "{\"data\":{\"password\":\"new-password\",\"username\":\"new-user\"},\"metadata\":{\"created_time\":\"2025-12-13T18:14:31.870255338Z\",\"custom_metadata\":null,\"deletion_time\":\"\",\"destroyed\":false,\"version\":3}}",
"password": "new-password",
"username": "new-user"
}
단, test pod의 경우 envFrom으로 주입한 환경변수기 때문에 바로 적용되진 않는다.
만약 Pod 재시작 없이도 바뀌는 걸 보고 싶다면: Secret을 볼륨으로 마운트하는 등 다른 방법을 사용해야 한다.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: test-pod-vol
namespace: default
spec:
restartPolicy: Never
containers:
- name: busybox
image: busybox
command: ["/bin/sh", "-c", "sleep 3600"]
volumeMounts:
- name: s
mountPath: /mnt/secret
readOnly: true
volumes:
- name: s
secret:
secretName: webapp-secret
EOF
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl exec -it test-pod-vol -n default -- /bin/sh
/ # ls -al /mnt/secret
total 4
drwxrwxrwt 3 root root 140 Dec 13 18:18 .
drwxr-xr-x 3 root root 4096 Dec 13 18:18 ..
drwxr-xr-x 2 root root 100 Dec 13 18:18 ..2025_12_13_18_18_16.1850601374
lrwxrwxrwx 1 root root 32 Dec 13 18:18 ..data -> ..2025_12_13_18_18_16.1850601374
lrwxrwxrwx 1 root root 11 Dec 13 18:18 _raw -> ..data/_raw
lrwxrwxrwx 1 root root 15 Dec 13 18:18 password -> ..data/password
lrwxrwxrwx 1 root root 15 Dec 13 18:18 username -> ..data/username
/ # cat /mnt/secret/username
new-user/ #
/ # cat /mnt/secret/password
new-password/ #
Secret 변경 후 pod에서 실시간 변경 확인
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# vault kv put secret/webapp/config \
username="last-user" \
password="last-password"
====== Secret Path ======
secret/data/webapp/config
======= Metadata =======
Key Value
--- -----
created_time 2025-12-13T18:20:04.157816805Z
custom_metadata <nil>
deletion_time n/a
destroyed false
version 4
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl get secret webapp-secret -n default -o json \
| jq -r '.data | map_values(@base64d)'
{
"_raw": "{\"data\":{\"password\":\"last-password\",\"username\":\"last-user\"},\"metadata\":{\"created_time\":\"2025-12-13T18:20:04.157816805Z\",\"custom_metadata\":null,\"deletion_time\":\"\",\"destroyed\":false,\"version\":4}}",
"password": "last-password",
"username": "last-user"
}
(⎈|kind-myk8s:N/A) root@DESKTOP-O4EPQ9T:~# kubectl exec -it test-pod-vol -n default -- /bin/sh
/ # cat /mnt/secret/password
last-password/ #
/ #
/ # cat /mnt/secret/username
last-user/ #
/ #
/ # ls -al /mnt/secret
total 4
drwxrwxrwt 3 root root 140 Dec 13 18:20 .
drwxr-xr-x 3 root root 4096 Dec 13 18:18 ..
drwxr-xr-x 2 root root 100 Dec 13 18:20 ..2025_12_13_18_20_44.2227610919
lrwxrwxrwx 1 root root 32 Dec 13 18:20 ..data -> ..2025_12_13_18_20_44.2227610919
lrwxrwxrwx 1 root root 11 Dec 13 18:18 _raw -> ..data/_raw
lrwxrwxrwx 1 root root 15 Dec 13 18:18 password -> ..data/password
lrwxrwxrwx 1 root root 15 Dec 13 18:18 username -> ..data/username