a story

Vault를 활용한 쿠버네티스 Secret 관리 본문

Kubernetes

Vault를 활용한 쿠버네티스 Secret 관리

한명 2025. 4. 13. 02:24

이번 포스트에서는 Kubernetes 환경에서 Secret 관리를 위해서 Vault를 활용하는 방식을 살펴 보겠습니다.

실습에서는 Vault에 Secret을 저장하고, 애플리케이션에서 Vault의 Secret을 참조하는 방식을 살펴봅니다. Vault의 Secret을 활용하는 방식에는 세가지가 있습니다.

  1. The Vault Sidecar Agent Injector
  2. The Vault Container Storage Interface provider
  3. The Vault Secrets Operator

이를 실습을 통해서 살펴보겠습니다.

 

목차

  1. 실습 환경 구성
  2. Valut Sidecar Agent Injector 실습
  3. Vault CSI Driver 실습
  4. Vault Secrets Operator 실습

 

1. 실습 환경 구성

실습 환경은 kind로 생성한 쿠버네티스 환경을 통해 진행합니다.

# 클러스터 배포 전 확인
docker ps
mkdir cicd-labs
cd cicd-labs

# WSL2 Ubuntu eth0 IP를 지정
ip -br -c a

MyIP=<각자 자신의 WSL2 Ubuntu eth0 IP>
MyIP=172.28.157.42

# cicd-labs 디렉터리에서 아래 파일 작성
cat > kind-3node.yaml <<EOF
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
networking:
  apiServerAddress: "$MyIP"
nodes:
- role: control-plane
  extraPortMappings:
  - containerPort: 30000
    hostPort: 30000
  - containerPort: 30001
    hostPort: 30001
  - containerPort: 30002
    hostPort: 30002
  - containerPort: 30003
    hostPort: 30003
  - containerPort: 30004
    hostPort: 30004
  - containerPort: 30005
    hostPort: 30006
- role: worker
- role: worker
EOF
kind create cluster --config kind-3node.yaml --name myk8s --image kindest/node:v1.32.2

# 확인
kind get nodes --name myk8s
myk8s-worker2
myk8s-control-plane
myk8s-worker

# kind 는 별도 도커 네트워크 생성 후 사용 : 기본값 172.18.0.0/16
docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
9b31821d4e20   bridge    bridge    local
31ba753a11a7   host      host      local
d91da96d2114   kind      bridge    local
f2e13485b121   none      null      local
docker inspect kind |jq
[
  {
    "Name": "kind",
    "Id": "d91da96d2114ac123cfadc41d90d4ef2be33b049c1e369b4acaa38249d367a4d",
    "Created": "2025-03-27T22:07:47.108546778+09:00",
    "Scope": "local",
    "Driver": "bridge",
    "EnableIPv6": true,
    "IPAM": {
      "Driver": "default",
      "Options": {},
      "Config": [
        {
          "Subnet": "172.18.0.0/16",
          "Gateway": "172.18.0.1"
        },
        {
          "Subnet": "fc00:f853:ccd:e793::/64",
          "Gateway": "fc00:f853:ccd:e793::1"
        }
      ]
    },
    "Internal": false,
    "Attachable": false,
    "Ingress": false,
    "ConfigFrom": {
      "Network": ""
    },
    "ConfigOnly": false,
    "Containers": {
      "10a9d53e4bd22ce9fc210c6693f8087185686eb51aa9d58696e0d95dafa24b6c": {
        "Name": "myk8s-worker2",
        "EndpointID": "b3cb0b47353edc3e6aaa258bd6729d94ea648bc7fcba661fa6c1287e7f349923",
        "MacAddress": "02:42:ac:12:00:03",
        "IPv4Address": "172.18.0.3/16",
        "IPv6Address": "fc00:f853:ccd:e793::3/64"
      },
      "595658837c5ff4934afbd6a2bcf1c23047490e22825e78697ff311a92cef88d9": {
        "Name": "myk8s-worker",
        "EndpointID": "6d011d2675ca64750be2c1c35a9cdff22f714de2ca72bebee05bc62e11686205",
        "MacAddress": "02:42:ac:12:00:04",
        "IPv4Address": "172.18.0.4/16",
        "IPv6Address": "fc00:f853:ccd:e793::4/64"
      },
      "ae13bcf2982f31e171f89953a89471524480e994c924349162d31e7795e0c369": {
        "Name": "myk8s-control-plane",
        "EndpointID": "e35cfd5f5280307273fc4a6c26fd8311b3dcb32d1fb3d9dd0f55f2ad32f0b515",
        "MacAddress": "02:42:ac:12:00:02",
        "IPv4Address": "172.18.0.2/16",
        "IPv6Address": "fc00:f853:ccd:e793::2/64"
      }
    },
    "Options": {
      "com.docker.network.bridge.enable_ip_masquerade": "true",
      "com.docker.network.driver.mtu": "1500"
    },
    "Labels": {}
  }
]

# k8s api 주소 확인
kubectl cluster-info
Kubernetes control plane is running at https://172.28.157.42:41243
CoreDNS is running at https://172.28.157.42:41243/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

# 노드 정보 확인 : CRI 는 containerd 사용
kubectl get node -o wide
NAME                  STATUS   ROLES           AGE   VERSION   INTERNAL-IP   EXTERNAL-IP   OS-IMAGE                         KERNEL-VERSION                       CONTAINER-RUNTIME
myk8s-control-plane   Ready    control-plane   12m   v1.32.2   172.18.0.2    <none>        Debian GNU/Linux 12 (bookworm)   5.15.167.4-microsoft-standard-WSL2   containerd://2.0.3
myk8s-worker          Ready    <none>          11m   v1.32.2   172.18.0.4    <none>        Debian GNU/Linux 12 (bookworm)   5.15.167.4-microsoft-standard-WSL2   containerd://2.0.3
myk8s-worker2         Ready    <none>          11m   v1.32.2   172.18.0.3    <none>        Debian GNU/Linux 12 (bookworm)   5.15.167.4-microsoft-standard-WSL2   containerd://2.0.3

# 파드 정보 확인 : CNI 는 kindnet 사용
kubectl get pod -A -o wide
NAMESPACE            NAME                                          READY   STATUS    RESTARTS   AGE   IP           NODE                  NOMINATED NODE   READINESS GATES
kube-system          coredns-668d6bf9bc-cxqxb                      1/1     Running   0          12m   10.244.0.4   myk8s-control-plane   <none>           <none>
kube-system          coredns-668d6bf9bc-g45j9                      1/1     Running   0          12m   10.244.0.3   myk8s-control-plane   <none>           <none>
kube-system          etcd-myk8s-control-plane                      1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
kube-system          kindnet-fclmw                                 1/1     Running   0          12m   172.18.0.3   myk8s-worker2         <none>           <none>
kube-system          kindnet-gfxg4                                 1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
kube-system          kindnet-vvqvp                                 1/1     Running   0          12m   172.18.0.4   myk8s-worker          <none>           <none>
kube-system          kube-apiserver-myk8s-control-plane            1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
kube-system          kube-controller-manager-myk8s-control-plane   1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
kube-system          kube-proxy-7sgcd                              1/1     Running   0          12m   172.18.0.4   myk8s-worker          <none>           <none>
kube-system          kube-proxy-8xck8                              1/1     Running   0          12m   172.18.0.3   myk8s-worker2         <none>           <none>
kube-system          kube-proxy-tpvwq                              1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
kube-system          kube-scheduler-myk8s-control-plane            1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
local-path-storage   local-path-provisioner-7dc846544d-p9l6q       1/1     Running   0          12m   10.244.0.2   myk8s-control-plane   <none>           <none>


# 컨트롤플레인/워커 노드(컨테이너) 확인 : 도커 컨테이너 이름은 myk8s-control-plane , myk8s-worker/worker-2 임을 확인
docker ps
CONTAINER ID   IMAGE                  COMMAND                  CREATED          STATUS          PORTS                                                                                           NAMES
10a9d53e4bd2   kindest/node:v1.32.2   "/usr/local/bin/entr…"   13 minutes ago   Up 13 minutes                                                                                                   myk8s-worker2
ae13bcf2982f   kindest/node:v1.32.2   "/usr/local/bin/entr…"   13 minutes ago   Up 13 minutes   0.0.0.0:30000-30004->30000-30004/tcp, 172.28.157.42:41243->6443/tcp, 0.0.0.0:30006->30005/tcp   myk8s-control-plane
595658837c5f   kindest/node:v1.32.2   "/usr/local/bin/entr…"   13 minutes ago   Up 13 minutes                                                                                                   myk8s-worker

 

이제 생성된 쿠버네티스 환경에 Vault를 설치하도록 하겠습니다.

# Create a Kubernetes namespace.
kubectl create namespace vault

# Setup Helm repo
helm repo add hashicorp https://helm.releases.hashicorp.com

# Check that you have access to the chart.
helm search repo hashicorp/vault
NAME                                    CHART VERSION   APP VERSION     DESCRIPTION
hashicorp/vault                         0.30.0          1.19.0          Official HashiCorp Vault Chart
hashicorp/vault-secrets-gateway         0.0.2           0.1.0           A Helm chart for Kubernetes
hashicorp/vault-secrets-operator        0.10.0          0.10.0          Official Vault Secrets Operator Chart

 

Helm 차트에 사용할 values 파일을 생성합니다.

cat <<EOF > vault-values.yaml
global:
  enabled: true
  tlsDisable: true  # Disable TLS for demo purposes

server:
  image:
    repository: "hashicorp/vault"
    tag: "1.19.0"
  standalone:
    enabled: true
    replicas: 1
    config: |
      ui = true

      listener "tcp" {
        address = "[::]:8200"
        cluster_address = "[::]:8201"
        tls_disable = 1
      }

      storage "file" {
        path = "/vault/data"
      }

  service:
    enabled: true
    type: NodePort
    port: 8200
    targetPort: 8200
    nodePort: 30000   # 🔥 Kind에서 열어둔 포트 중 하나 사용

injector:
  enabled: true

csi:
  enabled: true
EOF

 

실습에서는 테스트 환경이므로 standalone 방식으로 구성하였습니다.

또한 injector를 enable하여, sidecar injector를 사용할 수 있도록 정의하였고, csi 드라이버 테스트를 위해 csi 또한 enable하였습니다.

 

이제 Helm으로 Vault를 배포합니다.

# Helm Install 실행
helm upgrade vault hashicorp/vault -n vault -f vault-values.yaml --install

# 배포확인
kubectl get pods,svc,pvc -n vault
NAME                                        READY   STATUS    RESTARTS   AGE
pod/vault-0                                 0/1     Running   0          4m45s
pod/vault-agent-injector-56459c7545-fnv9t   1/1     Running   0          4m45s
pod/vault-csi-provider-79rg5                2/2     Running   0          4m45s
pod/vault-csi-provider-x58j8                2/2     Running   0          4m45s

NAME                               TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                         AGE
service/vault                      NodePort    10.96.116.44   <none>        8200:30000/TCP,8201:31459/TCP   4m45s
service/vault-agent-injector-svc   ClusterIP   10.96.166.73   <none>        443/TCP                         4m45s
service/vault-internal             ClusterIP   None           <none>        8200/TCP,8201/TCP               4m45s

NAME                                 STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
persistentvolumeclaim/data-vault-0   Bound    pvc-e5065e5d-dc6e-43ef-a647-da4b0685a79e   10Gi       RWO            standard       <unset>                 4m45s

 

배포된 내용을 확인해보면 vault-0가 statefulset으로 실행 중이며, 옵션으로 추가한 vault-agent-injector-56459c7545-9n94t가 이후에 sidecar injection을 수행합니다. 또한 vault-csi-provider가 실행 중인 것을 알 수 있습니다.

 

확인해보면 vault-0가 아직 READY가 되지 않은 상태(0/1)인 것을 알 수 있습니다. Vault를 배포한다고 바로 실행되는 것은 아니며, 초기화 되지 않은 상태에서는 기본적으로 Sealed 되어 있습니다.

 

아래와 같이 상태를 확인하고 초기화를 진행합니다.

# Vault Status 명령으로 Sealed 상태확인
kubectl exec -it vault-0 -n vault -- vault status
Key                Value
---                -----
Seal Type          shamir
Initialized        false
Sealed             true
Total Shares       0
Threshold          0
Unseal Progress    0/0
Unseal Nonce       n/a
Version            1.19.0
Build Date         2025-03-04T12:36:40Z
Storage Type       file
HA Enabled         false
command terminated with exit code 2

 

실습에서는 아래 스크립트를 통해서 unseal key를 획득하고 이 key를 등록하여 초기화를 진행합니다.

cat <<EOF > init-unseal.sh
#!/bin/bash

# Vault Pod 이름
VAULT_POD="vault-0"

# Vault 명령 실행
VAULT_CMD="kubectl exec -it \$VAULT_POD -n vault -- vault"

# 출력 저장 파일
VAULT_KEYS_FILE="./vault-keys.txt"
UNSEAL_KEY_FILE="./vault-unseal-key.txt"
ROOT_TOKEN_FILE="./vault-root-token.txt"

# Vault 초기화 (Unseal Key 1개만 생성되도록 설정)
\$VAULT_CMD operator init -key-shares=1 -key-threshold=1 | sed \$'s/\\x1b\\[[0-9;]*m//g' | tr -d '\r' > "\$VAULT_KEYS_FILE"

# Unseal Key / Root Token 추출
grep 'Unseal Key 1:' "\$VAULT_KEYS_FILE" | awk -F': ' '{print \$2}' > "\$UNSEAL_KEY_FILE"
grep 'Initial Root Token:' "\$VAULT_KEYS_FILE" | awk -F': ' '{print \$2}' > "\$ROOT_TOKEN_FILE"

# Unseal 수행
UNSEAL_KEY=\$(cat "\$UNSEAL_KEY_FILE")
\$VAULT_CMD operator unseal "\$UNSEAL_KEY"

# 결과 출력
echo "[🔓] Vault Unsealed!"
echo "[🔐] Root Token: \$(cat \$ROOT_TOKEN_FILE)"
EOF

# 실행 권한 부여
chmod +x init-unseal.sh

# 실행
./init-unseal.sh
Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    1
Threshold       1
Version         1.19.0
Build Date      2025-03-04T12:36:40Z
Storage Type    file
Cluster Name    vault-cluster-74a36fbf
Cluster ID      46a5b7ff-288c-eb37-408b-bf8eebd7960c
HA Enabled      false
[🔓] Vault Unsealed!
[🔐] Root Token: hvs.aRGCNIlhHVcy2VuDf2afTKCm

 

이제 vault status 명령으로 Unseal 상태를 확인 합니다.

kubectl exec -it vault-0 -n vault -- vault status
Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    1
Threshold       1
Version         1.19.0
Build Date      2025-03-04T12:36:40Z
Storage Type    file
Cluster Name    vault-cluster-74a36fbf
Cluster ID      46a5b7ff-288c-eb37-408b-bf8eebd7960c
HA Enabled      false

 

Unseal이 완료되고 파드 상태를 조회하면, 이제 vault-0가 READY상태가 된 것으로 확인됩니다.

kubectl get po -n vault
NAME                                    READY   STATUS    RESTARTS   AGE
vault-0                                 1/1     Running   0          10m
vault-agent-injector-56459c7545-fnv9t   1/1     Running   0          10m
vault-csi-provider-79rg5                2/2     Running   0          10m
vault-csi-provider-x58j8                2/2     Running   0          10m

 

초기화가 마무리되면 이제 UI에도 접속이 가능합니다. 앞서 확인한 Root token을 사용합니다.

 

로그인 후 Vault의 UI화면은 아래와 같습니다.

 

아래와 같이 WSL 환경에 CLI도 설치를 진행합니다.

참고: https://developer.hashicorp.com/vault/install#linux

wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install vault

# 확인
export VAULT_ADDR='http://localhost:30000'
vault status
Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    1
Threshold       1
Version         1.19.0
Build Date      2025-03-04T12:36:40Z
Storage Type    file
Cluster Name    vault-cluster-74a36fbf
Cluster ID      46a5b7ff-288c-eb37-408b-bf8eebd7960c
HA Enabled      false

vault login
Token (will be hidden):
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.

Key                  Value
---                  -----
token                hvs.aRGCNIlhHVcy2VuDf2afTKCm
token_accessor       LckN5gmpyUcQPxKYFGWIS3KU
token_duration       ∞
token_renewable      false
token_policies       ["root"]
identity_policies    []
policies             ["root"]

 

Vault를 Static Secret, Dynamic Secret, Data Encryption, Leasing and Renewal, Revocation과 같은 다양한 목적에 맞게 사용할 수 있습니다.

 

이후 Static Secret으로 활용하는 실습을 위해서 Vault의 Key Value 엔진을 활성화하겠습니다.

실습에서는 Vault KV version 2 엔진을 활성화하고 샘플 데이터를 저장합니다. 여기서 version 1 은 Key-Value에 대한 버전관리가 불가하며, version 2에서는 Key-Value에서 버전관리가 가능합니다.

# KV v2 형태로 엔진 활성화
vault secrets enable -path=secret kv-v2
Success! Enabled the kv-v2 secrets engine at: secret/

# 샘플 시크릿 저장
vault kv put secret/sampleapp/config \
  username="demo" \
  password="p@ssw0rd"

======== Secret Path ========
secret/data/sampleapp/config

======= Metadata =======
Key                Value
---                -----
created_time       2025-04-12T13:18:05.548287122Z
custom_metadata    <nil>
deletion_time      n/a
destroyed          false
version            1


# 입력된 데이터 확인
vault kv get secret/sampleapp/config

======== Secret Path ========
secret/data/sampleapp/config

======= Metadata =======
Key                Value
---                -----
created_time       2025-04-12T13:18:05.548287122Z
custom_metadata    <nil>
deletion_time      n/a
destroyed          false
version            1

====== Data ======
Key         Value
---         -----
password    p@ssw0rd
username    demo

 

Vault UI에 접속하여 [Secrets Engine] 탭에 접속 후 secret으로 이동하여, sampleapp - config] 접속하여 정보를 확인합니다.

 

Secret 탭에 접근하면 실제 저장된 Key / Value 를 확인할 수 있습니다.

 

Vault에 저장된 Secret을 활용하는 방식을 실습을 통해 살펴보겠습니다.

 

 

1. Valut Sidecar Agent Injector 실습

생성된 Vault의 Secret을 참조하기 위해서 Sidecar 패턴을 활용하는 방식을 살펴보겠습니다.

 

앞서 Helm을 통해 Vault를 배포할 때 injector를 활성화 한 것을 기억하실 겁니다.

Vault Agent Injector는 Kubernetes Pod 내부에 Vault Agent를 자동으로 주입해주는 기능입니다. 이를 통해 어플리케이션이 Vault로부터 자동으로 비밀 정보를 받아올 수 있게 됩니다.

출처: https://www.hashicorp.com/en/blog/kubernetes-vault-integration-via-sidecar-agent-injector-vs-csi-provider

 

먼저 그림의 좌측을 살펴보면 애플리케이션 파드 스펙에 Sidecar Annotation과 함께 API 서버로의 요청하면, Mutating Webhook을 통해 SideCar Injector Service를 통해서 Pods Spec을 업데이트 하는 것을 알 수 있습니다.

 

우측의 애플리케이션 파드에서는 Vault Agent Init Container를 통해서 Kubernetes Auth를 통해서 Vault Token을 획득하고, 이후 Vault Agent Sicdcar Container가 Vault Token으로 Vault에 요청해 Secret을 획득해 애플리케이션에 주입해주는 방식으로 동작합니다.

 

실제 파드에 아래와 같은 annotation을 추가해야 합니다.

vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "example-role"

 

Vault의 Kubernetes 인증 활성화합니다. 이를 통해 클라이언트가 쿠버네티스의 Service Account 토큰을 통해 인증을 받을 수 있도록 제공합니다.

# Kubernetes Auth Method 활성화
vault auth enable kubernetes
Success! Enabled kubernetes auth method at: kubernetes/

# 확인
vault auth list
Path           Type          Accessor                    Description                Version
----           ----          --------                    -----------                -------
approle/       approle       auth_approle_54604e8f       n/a                        n/a
kubernetes/    kubernetes    auth_kubernetes_986afb3c    n/a                        n/a
token/         token         auth_token_ecdae7a6         token based credentials    n/a

# Kubernetes Auth Config 설정
vault write auth/kubernetes/config \
  token_reviewer_jwt="$(kubectl get secret $(kubectl get serviceaccount vault -n vault -o jsonpath='{.secrets[0].name}') -n vault -o jsonpath="{.data.token}" | base64 --decode)" \
  kubernetes_host="$(kubectl config view --raw --minify --flatten -o jsonpath='{.clusters[0].cluster.server}')" \
  kubernetes_ca_cert="$(kubectl get secret $(kubectl get serviceaccount vault -n vault -o jsonpath='{.secrets[0].name}') -n vault -o jsonpath='{.data.ca\.crt}' | base64 --decode)"

 

그리고 생성된 Secret을 가져올 수 있는 권한(Policy)와 쿠버네티스 Service Account와 Policy를 묶어주는 Role을 생성합니다.

# 필요한 Policy 작성 (앞선 과정에서 만들었으므로 생략가능)
vault policy write sampleapp-policy - <<EOF
path "secret/data/sampleapp/*" {
  capabilities = ["read"]
}
EOF

# Role 생성 (Injector가 로그인할 수 있도록)
vault write auth/kubernetes/role/sampleapp-role \
    bound_service_account_names="vault-ui-sa" \
    bound_service_account_namespaces="vault" \
    policies="sampleapp-policy" \
    ttl="24h"

 

Vault의 권한 부여 방식은 AWS와 유사합니다. 위의 예제를 살펴보면, "secret/data/sampleapp/*" 에 ["read"]를 할 수 있는 sampleapp-policy라는 Policy를 만들고, 그 이후 sampleapp-role라는 role을 만들어 vault-ui-sa와 sampleapp-policy를 연결해 주는 것을 알 수 있습니다.

 

테스트 애플리케이션 배포합니다. 또한 위에서 정의한 vault-ui-sa 라는 Service Account도 같이 생성합니다.

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ServiceAccount
metadata:
  name: vault-ui-sa
  namespace: vault
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vault-injected-ui
  namespace: vault
spec:
  replicas: 1
  selector:
    matchLabels:
      app: vault-injected-ui
  template:
    metadata:
      labels:
        app: vault-injected-ui
      annotations:
        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "sampleapp-role"
        vault.hashicorp.com/agent-inject-secret-config.json: "secret/data/sampleapp/config"
        vault.hashicorp.com/agent-inject-template-config.json: |
          {{- with secret "secret/data/sampleapp/config" -}}
          {
            "username": "{{ .Data.data.username }}",
            "password": "{{ .Data.data.password }}"
          }
          {{- end }}
        vault.hashicorp.com/agent-inject-output-path: "/vault/secrets"
    spec:
      serviceAccountName: vault-ui-sa
      containers:
      - name: app
        image: python:3.10
        ports:
        - containerPort: 5000
        command: ["sh", "-c"]
        args:
          - |
            pip install flask && cat <<PYEOF > /app.py
            import json, time
            from flask import Flask, render_template_string
            app = Flask(__name__)
            while True:
                try:
                    with open("/vault/secrets/config.json") as f:
                        secret = json.load(f)
                    break
                except:
                    time.sleep(1)
            @app.route("/")
            def index():
                return render_template_string("<h2>🔐 Vault Injected UI</h2><p>👤 사용자: {{username}}</p><p>🔑 비밀번호: {{password}}</p>", secret)
            app.run(host="0.0.0.0", port=5000)
            PYEOF
            python /app.py
---
apiVersion: v1
kind: Service
metadata:
  name: vault-injected-ui
  namespace: vault
spec:
  type: NodePort
  ports:
    - port: 5000
      targetPort: 5000
      nodePort: 30002
  selector:
    app: vault-injected-ui
EOF

 

Deployment의 spec을 잘 살펴보면, 아래와 같은 annotation을 명시하고 있습니다.

        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "sampleapp-role"

 

생성된 컨테이너 확인를 확인해보겠습니다.

# 파드 내에 사이드카 컨테이너 추가되어 2/2 확인
kubectl get pod -l app=vault-injected-ui -n vault
NAME                                 READY   STATUS    RESTARTS   AGE
vault-injected-ui-77fb865789-f6sn8   2/2     Running   0          2m

kubectl describe pod -l app=vault-injected-ui -n vault
...
Init Containers:
  vault-agent-init:
    Container ID:  containerd://aea5f02cc79d2b89d6f80a5111ae8b64640d0e13d0900fbe06f3eb3750389de1
    Image:         hashicorp/vault:1.19.0
    Image ID:      docker.io/hashicorp/vault@sha256:bbb7f98dc67d9ebdda1256de288df1cb9a5450990e48338043690bee3b332c90
    Port:          <none>
    Host Port:     <none>
    Command:
      /bin/sh
      -ec
    Args:
      echo ${VAULT_CONFIG?} | base64 -d > /home/vault/config.json && vault agent -config=/home/vault/config.json
...

Containers:
  app:
    Container ID:  containerd://e2caeef00e2e408a4db48e57cba1152b4496bd49d6b66d9571371c2efea64178
    Image:         python:3.10
    Image ID:      docker.io/library/python@sha256:e2c7fb05741c735679b26eda7dd34575151079f8c615875fbefe401972b14d85
    Port:          5000/TCP
    Host Port:     0/TCP
...

  vault-agent:
    Container ID:  containerd://a2acf0a3fda4ba1f5637d8e843718ec19382c3011d8db97e701bae0d457fb92c
    Image:         hashicorp/vault:1.19.0
    Image ID:      docker.io/hashicorp/vault@sha256:bbb7f98dc67d9ebdda1256de288df1cb9a5450990e48338043690bee3b332c90
    Port:          <none>
    Host Port:     <none>
    Command:
      /bin/sh
      -ec
...

 

앞서 살펴본 것과 같이 vault-agent-init 이라는 InitContainer가 있으며, 또한 app 컨테이너 하단을 보면 vault-agent가 사이드카로 실행 중입니다.

# 볼륨 마운트 확인
kubectl exec -it  deploy/vault-injected-ui -c app -n vault -- ls /vault/secrets
config.json

kubectl exec -it  deploy/vault-injected-ui -c app -n vault -- cat /vault/secrets/config.json
{
  "username": "demo",
  "password": "p@ssw0rd"
}

kubectl exec -it deploy/nginx-vault-demo -c vault-agent-sidecar -- ls -l /etc/secrets       
total 4
-rw-r--r--    1 vault    vault           94 Apr 10 02:09 index.html

kubectl exec -it deploy/nginx-vault-demo -c vault-agent-sidecar -- cat /etc/secrets/index.html
  <html>
  <body>
    <p>username: demo</p>
    <p>password: p@ssw0rd</p>
  </body>
  </html>

kubectl exec -it deploy/nginx-vault-demo -c nginx -- cat /usr/share/nginx/html/index.html
  <html>
  <body>
    <p>username: demo</p>
    <p>password: p@ssw0rd</p>
  </body>
  </html>


# 로그 확인
kubectl logs -l app=vault-injected-ui -c vault-agent -n vault
2025-04-12T13:37:10.607Z [INFO]  agent: (runner) creating watcher
2025-04-12T13:37:10.612Z [INFO]  agent.auth.handler: authentication successful, sending token to sinks
2025-04-12T13:37:10.612Z [INFO]  agent.auth.handler: starting renewal process
2025-04-12T13:37:10.613Z [INFO]  agent.sink.file: token written: path=/home/vault/.vault-token
2025-04-12T13:37:10.613Z [INFO]  agent.template.server: template server received new token
2025-04-12T13:37:10.614Z [INFO]  agent: (runner) stopping
2025-04-12T13:37:10.614Z [INFO]  agent: (runner) creating new runner (dry: false, once: false)
2025-04-12T13:37:10.614Z [INFO]  agent: (runner) creating watcher
2025-04-12T13:37:10.617Z [INFO]  agent: (runner) starting
2025-04-12T13:37:10.618Z [INFO]  agent.auth.handler: renewed auth token

# mutating admission
kubectl get mutatingwebhookconfigurations.admissionregistration.k8s.io
NAME                       WEBHOOKS   AGE
vault-agent-injector-cfg   1          82m


kubectl describe mutatingwebhookconfigurations.admissionregistration.k8s.io vault-agent-injector-cfg
Name:         vault-agent-injector-cfg
Namespace:
Labels:       app.kubernetes.io/instance=vault
              app.kubernetes.io/managed-by=Helm
              app.kubernetes.io/name=vault-agent-injector
Annotations:  meta.helm.sh/release-name: vault
              meta.helm.sh/release-namespace: vault
API Version:  admissionregistration.k8s.io/v1
Kind:         MutatingWebhookConfiguration
Metadata:
  Creation Timestamp:  2025-04-12T12:29:06Z
  Generation:          2
  Resource Version:    2170
  UID:                 1260c09d-a982-47f6-b15a-b3de37628915
Webhooks:
  Admission Review Versions:
    v1
    v1beta1
  Client Config:
    Ca Bundle:  LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNTVENDQWU2Z0F3SUJBZ0lVZjZGQ0xaam5iTjg1TURSWm5Ka0VWZ1pMcnhrd0NnWUlLb1pJemowRUF3SXcKR2pFWU1CWUdBMVVFQXhNUFFXZGxiblFnU1c1cVpXTjBJRU5CTUI0WERUSTFNRFF4TWpFeU1qZ3pNRm9YRFRNMQpNRFF4TURFeU1qa3pNRm93R2pFWU1CWUdBMVVFQXhNUFFXZGxiblFnU1c1cVpXTjBJRU5CTUZrd0V3WUhLb1pJCnpqMENBUVlJS29aSXpqMERBUWNEUWdBRVhSbjR3Z0lYSUhKTFN6cnV5MGhDZkV0RWtnN0NHYUp6aFo1TkdKMTMKTXlnVkgzL2NISS9pQ0ZrbG5HWFRsQ3ZjZzUySjlXL0tnUVljSjg2NEVVaUxLS09DQVJBd2dnRU1NQTRHQTFVZApEd0VCL3dRRUF3SUNoREFUQmdOVkhTVUVEREFLQmdnckJnRUZCUWNEQVRBUEJnTlZIUk1CQWY4RUJUQURBUUgvCk1HZ0dBMVVkRGdSaEJGOHdOVG8zWlRwak56b3haam93TXpwbE1UcGtaanBpTnpwaU1EcGxOam80WlRwaVpUcGgKWWpvMk9UbzRaam81TnpvMllUbzFOam96T0RveE16bzFOem94WlRvMllUb3lZenBsTkRvNU1qb3pOem93WlRvdwpORG94TXpvNU9EbzJNekJxQmdOVkhTTUVZekJoZ0Y4d05UbzNaVHBqTnpveFpqb3dNenBsTVRwa1pqcGlOenBpCk1EcGxOam80WlRwaVpUcGhZam8yT1RvNFpqbzVOem8yWVRvMU5qb3pPRG94TXpvMU56b3haVG8yWVRveVl6cGwKTkRvNU1qb3pOem93WlRvd05Eb3hNem81T0RvMk16QUtCZ2dxaGtqT1BRUURBZ05KQURCR0FpRUExKzdRaE5HKwpkM1NRajdZRVdHRHptcXpOUVozU2dtR1ExemQwWHI5L2ZRSUNJUURBQTcwODRDOTY4WVdnUXhPSmQvKysxZm9XCndoUWJ0Rm9taUlPNnMvSkZVZz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
    Service:
      Name:        vault-agent-injector-svc
      Namespace:   vault
      Path:        /mutate
      Port:        443
  Failure Policy:  Ignore
  Match Policy:    Exact
  Name:            vault.hashicorp.com
  Namespace Selector:
  Object Selector:
    Match Expressions:
      Key:       app.kubernetes.io/name
      Operator:  NotIn
      Values:
        vault-agent-injector
  Reinvocation Policy:  Never
  Rules:
    API Groups:

    API Versions:
      v1
    Operations:
      CREATE
    Resources:
      pods
    Scope:          Namespaced
  Side Effects:     None
  Timeout Seconds:  30
Events:             <none>

kubectl get svc -n vault
NAME                       TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                         AGE
vault                      NodePort    10.96.116.44   <none>        8200:30000/TCP,8201:31459/TCP   83m
vault-agent-injector-svc   ClusterIP   10.96.166.73   <none>        443/TCP                         83m
vault-injected-ui          NodePort    10.96.2.33     <none>        5000:30002/TCP                  16m
vault-internal             ClusterIP   None           <none>        8200/TCP,8201/TCP               83m

 

mutation webhook configuration 정의를 보면 vault-agent-injector-svc로 연결되는 것을 확인할 수 있습니다.

 

생성된 서비스로 접속해보면 Vault에 저장된 Secret 값이 노출되는 것을 알 수있습니다.

 

 

2. Vault CSI Driver 실습

Vault에서 setcret을 사용하는 두번째 방식은 CSI Driver를 사용하는 방식입니다.

 

이를 위해서 Vault 생성 시에 옵션을 통해서 Vault CSI provider가 생성되어 있습니다. 이후 Secrets Store CSI Driver를 설치하면 각 노드에서 Daemonset이 생성됩니다. 사용자는 SecretProviderClass를 생성하고, 여기에는 Secret을 가져오기 위해 어떤 Secret Provider를 사용할지를 선언합니다.

출처: https://www.hashicorp.com/en/blog/kubernetes-vault-integration-via-sidecar-agent-injector-vs-csi-provider

 

그림을 보면 CSI Volume을 요청하는 파드가 생성되면, CSI Secret Store Driver는 Vault CSI provider에게 요청을 보내고 Vault CSI provider가 SecretProviderClass와 파드의 service Account를 사용해 Vault로 부터 secret을 가져와 파드의 CSI volume으로 마운트 합니다.

 

아래 문서를 바탕으로 실습을 진행했습니다.

https://developer.hashicorp.com/vault/tutorials/kubernetes/kubernetes-secret-store-driver?in=vault%2Fkubernetes

 

먼저 Vault는 이미 설치된 상태이기 때문에 아래 내용은 skip하겠습니다.

# 리포 추가
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update

# Vault 설치
helm install vault hashicorp/vault \
    --set "server.dev.enabled=true" \
    --set "injector.enabled=false" \
    --set "csi.enabled=true"

 

또한 Secret을 생성과 Kubernetes 인증 설정을 진행합니다.

# Secret 생성
# kubectl exec -it vault-0 -- /bin/sh
vault kv put secret/db-pass password="db-secret-password"
=== Secret Path ===
secret/data/db-pass

======= Metadata =======
Key                Value
---                -----
created_time       2025-04-12T14:45:47.117669981Z
custom_metadata    <nil>
deletion_time      n/a
destroyed          false
version            1

vault kv get secret/db-pass
=== Secret Path ===
secret/data/db-pass

======= Metadata =======
Key                Value
---                -----
created_time       2025-04-12T14:45:47.117669981Z
custom_metadata    <nil>
deletion_time      n/a
destroyed          false
version            1

====== Data ======
Key         Value
---         -----
password    db-secret-password

# Kubernetes 인증 설정 (앞서 설정하였으므로 skip)
vault auth enable kubernetes 
vault write auth/kubernetes/config \
    kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443"

# policy 생성
vault policy write internal-app - <<EOF
path "secret/data/db-pass" {
  capabilities = ["read"]
}
EOF

# role 생성
vault write auth/kubernetes/role/database \
    bound_service_account_names=webapp-sa \
    bound_service_account_namespaces=default \
    policies=internal-app \
    ttl=20m

 

이제 클러스터에 secrets store CSI driver를 설치합니다.

helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts

helm install csi secrets-store-csi-driver/secrets-store-csi-driver \
    --set syncSecret.enabled=true -n vault

# 확인
kubectl get ds -n vault
NAME                           DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR            AGE
csi-secrets-store-csi-driver   3         3         3       3            3           kubernetes.io/os=linux   77s
vault-csi-provider             2         2         2       2            2           <none>                   133m

kubectl get po -n vault
NAME                                    READY   STATUS    RESTARTS   AGE
csi-secrets-store-csi-driver-kmkqf      3/3     Running   0          71s
csi-secrets-store-csi-driver-tct7q      3/3     Running   0          71s
csi-secrets-store-csi-driver-xvfjb      3/3     Running   0          71s
vault-0                                 1/1     Running   0          133m
vault-agent-injector-56459c7545-fnv9t   1/1     Running   0          133m
vault-csi-provider-79rg5                2/2     Running   0          133m
vault-csi-provider-x58j8                2/2     Running   0          133m
vault-injected-ui-77fb865789-f6sn8      2/2     Running   0          66m

 

Vault를 provider로 지정한 SecretProviderClass를 생성하고, 앞서 생성한 role을 사용합니다.

cat > spc-vault-database.yaml <<EOF
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: vault-database
spec:
  provider: vault
  parameters:
    vaultAddress: "http://vault:8200"
    roleName: "database"
    objects: |
      - objectName: "db-password"
        secretPath: "secret/data/db-pass"
        secretKey: "password"
EOF

kubectl apply --filename spc-vault-database.yaml

 

Role에 지정한 Service Account를 생성합니다.

kubectl create serviceaccount webapp-sa

 

아래와 같이 파드를 생성하면서, Volume절에 앞서 생성한 secretProviderClass를 참조하는 볼륨을 생성합니다.

cat > webapp-pod.yaml <<EOF
kind: Pod
apiVersion: v1
metadata:
  name: webapp
spec:
  serviceAccountName: webapp-sa
  containers:
  - image: jweissig/app:0.0.1
    name: webapp
    volumeMounts:
    - name: secrets-store-inline
      mountPath: "/mnt/secrets-store"
      readOnly: true
  volumes:
    - name: secrets-store-inline
      csi:
        driver: secrets-store.csi.k8s.io
        readOnly: true
        volumeAttributes:
          secretProviderClass: "vault-database"
EOF
kubectl apply --filename webapp-pod.yaml

 

확인해보면 secretProviderClass를 통해 Vault의 Secret을 마운트 한 것을 확인할 수 있습니다.

kubectl get pods
NAME     READY   STATUS    RESTARTS   AGE
webapp   1/1     Running   0          19s

kubectl exec webapp -- cat /mnt/secrets-store/db-password
db-secret-password

 

 

4. Vault Secrets Operator(VSO) 실습

앞서 살펴본 방식들은 워크로드에서 Sidecar를 설정하거나 혹은 CSI Volume를 추가하는 방식으로 변경이 필요로 하게 됩니다. 이러한 방식은 기존 리소스를 수정해야 하는 단점이 있습니다.

 

반면 VSO는 기존 쿠버네티스의 Secret을 사용하되, VSO를 통해서 관리하고자 하는 Secret을 지정하면 VSO가 Vault를 통해 Secret을 가져와 쿠버네티스의 Secret을 변경하는 방식을 사용합니다.

 

이를 위해서 Vault Secrets Operator는 Vault Role에 대응하는 Vault Auth CRD와 Vault Secret에 대응하는 VaultSecret과 같은 CRD를 생성하고, 이들 CRD의 변경 사항을 감시하여 동작합니다. 각 CRD는 Secret 소스에서 가져와 Kubernetes Secret으로 동기화할 수 있도록 필요한 사양을 제공합니다.

 

오퍼레이터는 원본 Secret 데이터를 대상 쿠버네티스 Secret에 직접 작성하며, 원본에 변경 사항이 발생할 경우 해당 내용을 대상 Secret에도 수명 주기 동안 지속적으로 반영합니다. 이렇게 함으로써 애플리케이션은 쿠버네티스 Secret에만 접근하면 Secret의 데이터를 사용할 수 있습니다.

출처: https://www.hashicorp.com/en/blog/kubernetes-vault-integration-via-sidecar-agent-injector-vs-csi-provider

 

즉, 위의 그림을 보면 Vault 연결과 관련된 VaultConnection과 VaultAuth와 같은 CRD를 가지며, 또한 Static, Dynamic Secret에 따라 아래와 같은 CRD를 가집니다.

 

Static Secrets 는 운영자가 Vault의 단일 Static Secret을 단일 쿠버네티스 Secret으로 동기화하는 데 필요한 구성입니다.

  • CRD: VaultStaticSecret

Static Secret에 대한 상세 실습은 아래 문서를 참고하실 수 있습니다.

https://developer.hashicorp.com/vault/tutorials/kubernetes/vault-secrets-operator

 

Dynamic Secrets는 Vault의 Dynamic Secret Engine을 활용하여 동적으로 변경되는 Secrets을 K8s Secrets에 동기화할 수 있습니다. 여기에 지원되는 시크릿 엔진에는 DB Credentials, Cloud Credentials(AWS, Azure, GCP 등)이 있습니다.

  • CRD: VaultDynamicSecret

 

본 실습에서 Dynamic Secret에 대해서 실습을 이어 나가겠습니다.

먼저 VSO 배포를 위한 Chart Values 파일 작성합니다.

cat > vault-operator-values.yaml  <<EOF
defaultVaultConnection:
  enabled: true
  address: "http://vault.vault.svc.cluster.local:8200"
  skipTLSVerify: false
controller:
  manager:
    clientCache:
      persistenceModel: direct-encrypted
      storageEncryption:
        enabled: true
        mount: k8s-auth-mount
        keyName: vso-client-cache
        transitMount: demo-transit
        kubernetes:
          role: auth-role-operator
          serviceAccount: vault-secrets-operator-controller-manager
          tokenAudiences: ["vault"]
EOF

 

value.yaml을 바탕으로 Vault Secret Operator를 생성합니다.

helm install vault-secrets-operator hashicorp/vault-secrets-operator \
  -n vault-secrets-operator-system \
  --create-namespace \
  --values vault-operator-values.yaml

# 확인
kubectl get po,svc -n vault-secrets-operator-system
NAME                                                             READY   STATUS    RESTARTS   AGE
pod/vault-secrets-operator-controller-manager-7f67cd89fd-qmsb2   2/2     Running   0          39s

NAME                                             TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
service/vault-secrets-operator-metrics-service   ClusterIP   10.96.156.114   <none>        8443/TCP   39s

kubectl describe pod -n vault-secrets-operator-system                                       
...
Service Account:  vault-secrets-operator-controller-manager
...
Containers:
  kube-rbac-proxy:
    Container ID:  containerd://6500b98aa55510c0b1a5950c8c91e3b6c2b4ea312f5c7b0b5ce843d615a543ac
    Image:         quay.io/brancz/kube-rbac-proxy:v0.18.1
    Image ID:      quay.io/brancz/kube-rbac-proxy@sha256:e6a323504999b2a4d2a6bf94f8580a050378eba0900fd31335cf9df5787d9a9b
    Port:          8443/TCP
    Host Port:     0/TCP
    Args:
      --secure-listen-address=0.0.0.0:8443
      --upstream=http://127.0.0.1:8080/
      --logtostderr=true
      --v=0
  ...
  manager:
    Container ID:  containerd://c65fc319d184fe9145281db7bdfa88e9169ee4ddc86db42ded2a61b5194ca7c0
    Image:         hashicorp/vault-secrets-operator:0.10.0
    Image ID:      docker.io/hashicorp/vault-secrets-operator@sha256:3ee9b27677077cb3324ad02feb68fb7c25cfe381cb8ab5f940eee23c16f8c9a8
    Port:          <none>
    Host Port:     <none>
    Command:
      /vault-secrets-operator
    Args:
      --health-probe-bind-address=:8081
      --metrics-bind-address=127.0.0.1:8080
      --leader-elect
      --client-cache-persistence-model=direct-encrypted
      --global-vault-auth-options=allow-default-globals
      --backoff-initial-interval=5s
      --backoff-max-interval=60s
      --backoff-max-elapsed-time=0s
      --backoff-multiplier=1.50
      --backoff-randomization-factor=0.50
      --zap-log-level=info
      --zap-time-encoding=rfc3339
      --zap-stacktrace-level=panic
...

# CRD 확인
kubectl get crd |grep vault
hcpvaultsecretsapps.secrets.hashicorp.com                   2025-04-12T15:48:09Z
vaultauthglobals.secrets.hashicorp.com                      2025-04-12T15:48:09Z
vaultauths.secrets.hashicorp.com                            2025-04-12T15:48:10Z
vaultconnections.secrets.hashicorp.com                      2025-04-12T15:48:10Z
vaultdynamicsecrets.secrets.hashicorp.com                   2025-04-12T15:48:10Z
vaultpkisecrets.secrets.hashicorp.com                       2025-04-12T15:48:10Z
vaultstaticsecrets.secrets.hashicorp.com                    2025-04-12T15:48:10Z

# vault Auth 확인
kubectl get vaultauth -n vault-secrets-operator-system vault-secrets-operator-default-transit-auth -o jsonpath='{.spec}' | jq
{
  "kubernetes": {
    "audiences": [
      "vault"
    ],
    "role": "auth-role-operator",
    "serviceAccount": "vault-secrets-operator-controller-manager",
    "tokenExpirationSeconds": 600
  },
  "method": "kubernetes",
  "mount": "k8s-auth-mount",
  "storageEncryption": {
    "keyName": "vso-client-cache",
    "mount": "demo-transit"
  },
  "vaultConnectionRef": "default"
}

# vault Connnection 확인
kubectl get vaultconnection -n vault-secrets-operator-system default -o jsonpath='{.spec}' | jq
{
  "address": "http://vault.vault.svc.cluster.local:8200",
  "skipTLSVerify": false
}

 

실습에서는 Dynamic Secret을 테스트 하기 위해서 데이터베이스의 credentials을 dynamic Secret으로 사용합니다. 예제 데이터베이스인 PostgreSQL 설치합니다.

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

helm upgrade --install postgres bitnami/postgresql \
  --namespace postgres \
  --create-namespace \
  --set auth.audit.logConnections=true \
  --set auth.postgresPassword=secret-pass

# 확인
kubectl get po -n postgres
NAME                    READY   STATUS    RESTARTS   AGE
postgres-postgresql-0   1/1     Running   0          108s

# psql 로그인 확인
kubectl exec -it -n postgres postgres-postgresql-0 -- sh -c 'PGPASSWORD=secret-pass psql -U postgres -h localhost'
psql (17.4)
Type "help" for help.

postgres=#

kubectl exec -it -n postgres postgres-postgresql-0 -- sh -c "PGPASSWORD=secret-pass psql -U postgres -h localhost -c '\l'"
                                                     List of databases
   Name    |  Owner   | Encoding | Locale Provider |   Collate   |    Ctype    | Locale | ICU Rules |   Access privileges
-----------+----------+----------+-----------------+-------------+-------------+--------+-----------+-----------------------
 postgres  | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           |
 template0 | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           | =c/postgres          +
           |          |          |                 |             |             |        |           | postgres=CTc/postgres
 template1 | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           | =c/postgres          +
           |          |          |                 |             |             |        |           | postgres=CTc/postgres
(3 rows)

 

Vault Database Secret Engine 을 활성화 합니다. 아래 과정에서 보면 알 수 있듯이, Database Secret Engine을 통해서 PostgreSQL 연결 정보를 등록하고, 이 연결 정보를 사용할 수 있는 Role을 지정합니다.

# demo-db라는 경로로 Database Secret Engine을 활성화
vault secrets enable -path=demo-db database

# PostgreSQL 연결 정보 등록
# 해당 과정은 postgres가 정상적으로 동작 시 적용 가능
# allowed_roles: 이후 설정할 Role 이름 지정
vault write demo-db/config/demo-db \
   plugin_name=postgresql-database-plugin \
   allowed_roles="dev-postgres" \
   connection_url="postgresql://{{username}}:{{password}}@postgres-postgresql.postgres.svc.cluster.local:5432/postgres?sslmode=disable" \
   username="postgres" \
   password="secret-pass"

 

위 작업을 통해 postgresql-database-plug을 통해 연결정보를 등록했고, 이를 사용하는데 허용된 Role을 아래와 같이 생성합니다.

해당 Role 사용 시 Vault가 동적으로 사용자 계정과 비밀번호를 생성 가능하게 됩니다. 이때 실제 postgreSQL에 대한 연결정보는 Vault의 Database Secret Engine에 저장되고, Vault가 중간에 동적으로 DB 연결에 필요한 계정/비밀번호를 생성하게 됩니다. 이를 통해 postgreSQL에 대한 관리 권한을 Vault에 위임하다고 볼 수 있습니다.

# DB 사용자 동적 생성 Role 등록 
vault write demo-db/roles/dev-postgres \
   db_name=demo-db \
   creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
      GRANT ALL PRIVILEGES ON DATABASE postgres TO \"{{name}}\";" \
   revocation_statements="REVOKE ALL ON DATABASE postgres FROM  \"{{name}}\";" \
   backend=demo-db \
   name=dev-postgres \
   default_ttl="1m" \
   max_ttl="1m"

 

애플리케이션에서 사용할 Policy와 Role을 생성합니다.

# Policy 설정: DB 자격증명 읽기 권한
# demo-db/creds/dev-postgres 경로에 대한 read 권한 부여
# 추후 Kubernetes 서비스 어카운트(demo-dynamic-app)에 이 정책을 연결해서 자격증명 요청 가능
vault policy write demo-auth-policy-db - <<EOF
path "demo-db/creds/dev-postgres" {
   capabilities = ["read"]
}
EOF

# Role 생성: 실행 중인 애플리케이션이 Vault로부터 DB 자격증명(동적 사용자)을 받아올 수 있도록 권한을 연결을 위한 설정
vault write auth/kubernetes/role/auth-role \
    bound_service_account_names="demo-dynamic-app" \
    bound_service_account_namespaces="demo-ns" \
    policies="demo-auth-policy-db" \
    token_ttl=0 \
    token_period=120 \
    audience=vault  

 

아래의 과정은 쿠버네티스의 토큰을 받아오는 과정을 캐싱하기 위해서 Transit Secret Engine 설정하는 과정입니다.

# kubectl exec --stdin=true --tty=true vault-0 -n vault -- /bin/sh
----------------------------------------------------------------
# Transit Secret Engine 활성화
# transit 엔진을 demo-transit 경로로 활성화.
# 데이터를 저장하지 않고 암복호화 기능만 제공하는 Vault의 기능
# 클라이언트 캐시는 리더십 변경 시에도 Vault 토큰 및 동적 비밀 임대를 계속 추적하고 갱신할 수 있으므로 원활한 업그레이드를 지원합니다
## Vault 서버에 클라이언트 캐시를 저장하고 암호화할 수 있습니다.
## Vault 동적 비밀을 사용하는 경우 클라이언트 캐시를 영구적으로 저장하고 암호화하는 것이 좋습니다.
## 이렇게 하면 재시작 및 업그레이드를 통해 동적 비밀 임대가 유지됩니다.
vault secrets enable -path=demo-transit transit
vault secrets list
Path             Type         Accessor              Description
----             ----         --------              -----------
cubbyhole/       cubbyhole    cubbyhole_baa07f5b    per-token private secret storage
demo-db/         database     database_3608860f     n/a
demo-transit/    transit      transit_e5ab4c20      n/a
identity/        identity     identity_d7905b56     identity store
secret/          kv           kv_d4b43c42           n/a
sys/             system       system_4a32a805       system endpoints used for control, policy and debugging

# vso-client-cache라는 키를 생성
# 이 키는 VSO가 암복호화 시 사용할 암호화 키 역할
vault write -force demo-transit/keys/vso-client-cache

# vso-client-cache 키에 대해 암호화(encrypt), 복호화(decrypt)를 허용하는 정책 생성
vault policy write demo-auth-policy-operator - <<EOF
path "demo-transit/encrypt/vso-client-cache" {
   capabilities = ["create", "update"]
}
path "demo-transit/decrypt/vso-client-cache" {
   capabilities = ["create", "update"]
}
EOF

# Vault Secrets Operator가 사용하는 ServiceAccount에 위 정책을 바인딩
# vso가 Vault에 로그인할 때 사용할 수 있는 JWT 기반 Role 설정
# 해당 Role을 통해 Operator는 Transit 엔진을 이용한 암복호화 API 호출 가능
vault write auth/kubernetes/role/auth-role-operator \
   bound_service_account_names=vault-secrets-operator-controller-manager \
   bound_service_account_namespaces=vault-secrets-operator-system \
   token_ttl=0 \
   token_period=120 \
   token_policies=demo-auth-policy-operator \
   audience=vault

vault read auth/kubernetes/role/auth-role-operator
Key                                         Value
---                                         -----
alias_name_source                           serviceaccount_uid
audience                                    vault
bound_service_account_names                 [vault-secrets-operator-controller-manager]
bound_service_account_namespace_selector    n/a
bound_service_account_namespaces            [vault-secrets-operator-system]
token_bound_cidrs                           []
token_explicit_max_ttl                      0s
token_max_ttl                               0s
token_no_default_policy                     false
token_num_uses                              0
token_period                                2m
token_policies                              [demo-auth-policy-operator]
token_ttl                                   0s
token_type                                  default

 

이제 샘플 애플리케이션 YAML 작성 및 배포하겠습니다.

  • demo-ns 네임스페이스 생성하고, 폴더를 생성합니다.
  • vault-auth-dynamic.yaml :
    • 애플리케이션이 Vault에 인증하기 위한 ServiceAccount 및 VaultAuth 리소스
    kubectl create ns demo-ns
    mkdir vso-dynamic
    cd vso-dynamic
    
    cat > vault-auth-dynamic.yaml <<EOF
    apiVersion: v1
    kind: ServiceAccount
    metadata:
      namespace: demo-ns
      name: demo-dynamic-app
    ---
    apiVersion: secrets.hashicorp.com/v1beta1
    kind: VaultAuth
    metadata:
      name: dynamic-auth
      namespace: demo-ns
    spec:
      method: kubernetes
      mount: kubernetes # kubernetes auth 이름
      kubernetes:
        role: auth-role
        serviceAccount: demo-dynamic-app
        audiences:
          - vault
    EOF
  • app-secret.yaml :
    • Spring App에서 PostgreSQL에 접속할 때 사용할 해당 시크릿에 username/password을 동적으로 생성
    cat > app-secret.yaml <<EOF
    apiVersion: v1
    kind: Secret
    metadata:
      name: vso-db-demo
      namespace: demo-ns
    EOF
  • vault-dynamic-secret.yaml
    • Vault에서 동적으로 PostgreSQL 접속정보 생성하고 K8s Secret에 저장
    cat > vault-dynamic-secret.yaml <<EOF
    apiVersion: secrets.hashicorp.com/v1beta1
    kind: VaultDynamicSecret
    metadata:
      name: vso-db-demo
      namespace: demo-ns
    spec:
      refreshAfter: 25s
      mount: demo-db
      path: creds/dev-postgres
      destination:
        name: vso-db-demo # 대상 secret을 지정
        create: true
        overwrite: true
      vaultAuthRef: dynamic-auth # VaultAuth 을 참조함
      rolloutRestartTargets: # secret이 변경될때 rollout되는 디플로이먼트를 지정
      - kind: Deployment
        name: vaultdemo
    EOF
  • app-spring-deploy.yaml :
    • DB 접속 테스트를 위한 Spring App
    cat > app-spring-deploy.yaml <<EOF
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: vaultdemo
      namespace: demo-ns
      labels:
        app: vaultdemo
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: vaultdemo
      template:
        metadata:
          labels:
            app: vaultdemo
        spec:
          volumes:
            - name: secrets
              secret:
                secretName: "vso-db-demo"
          containers:
            - name: vaultdemo
              image: hyungwookhub/vso-spring-demo:v5
              imagePullPolicy: IfNotPresent
              env:
                - name: DB_PASSWORD
                  valueFrom:
                    secretKeyRef:
                      name: "vso-db-demo"
                      key: password
                - name: DB_USERNAME
                  valueFrom:
                    secretKeyRef:
                      name: "vso-db-demo"
                      key: username
                - name: DB_HOST
                  value: "postgres-postgresql.postgres.svc.cluster.local"
                - name: DB_PORT
                  value: "5432"
                - name: DB_NAME
                  value: "postgres"
              ports:
                - containerPort: 8088
              volumeMounts:
                - name: secrets
                  mountPath: /etc/secrets
                  readOnly: true
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: vaultdemo
      namespace: demo-ns
    spec:
      ports:
        - name: vaultdemo
          port: 8088         
          targetPort: 8088 
          nodePort: 30003
      selector:
        app: vaultdemo
      type: NodePort
    EOF
    애플리케이션 배포를 위해서 해당 폴더에서 kubectl apply -f .를 수행합니다.

실행된 파드와 서비스를 확인합니다.

kubectl get po,svc -n demo-ns
NAME                             READY   STATUS    RESTARTS   AGE
pod/vaultdemo-65766f6679-sccdh   1/1     Running   0          4s

NAME                TYPE       CLUSTER-IP    EXTERNAL-IP   PORT(S)          AGE
service/vaultdemo   NodePort   10.96.46.22   <none>        8088:30003/TCP   7m37s

 

이제 실제 동작을 UI(http://localhost:30003)에서 확인합니다.

 

샘플 애플리케이션에서 노출된 연결 정보를 통해 데이터베이스 접속이 성공하는 것을 알 수 있습니다.

 

세부적인 내용은 CLI를 통해서 살펴보겠습니다.

# Secret이 변경되고, 적용을 위해서 파드가 42~45초 사이로 재생성됨
while true; do kubectl get pod -n demo-ns ; echo; kubectl get secret -n demo-ns vso-db-demo -oyaml |egrep "username|password"; date; sleep 30 ; echo ; done
NAME                         READY   STATUS    RESTARTS   AGE
vaultdemo-7ccfbc4bc5-sv24l   1/1     Running   0          8s

  password: M1h5dkctVXFEZm05TnVvYm1Jamc=
  username: di1rdWJlcm5ldC1kZXYtcG9zdC1kOXZWSnBwWlF5S0RIa2V3VjVEUi0xNzQ0NDc3MDc4
Sun Apr 13 01:58:07 KST 2025

NAME                         READY   STATUS    RESTARTS   AGE
vaultdemo-7ccfbc4bc5-sv24l   1/1     Running   0          39s

  password: M1h5dkctVXFEZm05TnVvYm1Jamc=
  username: di1rdWJlcm5ldC1kZXYtcG9zdC1kOXZWSnBwWlF5S0RIa2V3VjVEUi0xNzQ0NDc3MDc4
Sun Apr 13 01:58:38 KST 2025

NAME                         READY   STATUS    RESTARTS   AGE
vaultdemo-685f97956f-5xn9d   1/1     Running   0          28s

  password: d0YtdzJMSXZocngxbnppLUU1SFo=
  username: di1rdWJlcm5ldC1kZXYtcG9zdC1JNmVsSmxXRHdvY3ZUa0NKUXdtVy0xNzQ0NDc3MTIw
Sun Apr 13 01:59:08 KST 2025

# 실제 postgresql 에 사용자 정보 확인 (계속 추가되고 있음)
# kubectl exec -it -n postgres postgres-postgresql-0 -- sh -c "PGPASSWORD=secret-pass psql -U postgres -h localhost -c '\du'"
                                                  List of roles
                      Role name                      |                         Attributes
-----------------------------------------------------+------------------------------------------------------------
 postgres                                            | Superuser, Create role, Create DB, Replication, Bypass RLS
 v-kubernet-dev-post-1UGLywrLiJTZaaEf8miA-1744476863 | Password valid until 2025-04-12 16:55:28+00
 v-kubernet-dev-post-6UvLI5FPiUF9fMY7QNEp-1744476652 | Password valid until 2025-04-12 16:51:57+00
 v-kubernet-dev-post-7mJgWsGrFamOFToYkZLX-1744476819 | Password valid until 2025-04-12 16:54:44+00
 v-kubernet-dev-post-9heCLQZOM1vEr5F61aC2-1744476733 | Password valid until 2025-04-12 16:53:18+00
 v-kubernet-dev-post-BTeXCNTCA2sRfAXUs84e-1744476390 | Password valid until 2025-04-12 16:47:35+00
 v-kubernet-dev-post-BiTwN5EhluVL7vqbnMng-1744476390 | Password valid until 2025-04-12 16:47:35+00
 v-kubernet-dev-post-F23WnObMJvf1b3EXjMX8-1744476909 | Password valid until 2025-04-12 16:56:14+00
 v-kubernet-dev-post-F66ZR0rV2R9yaoelOtBr-1744476566 | Password valid until 2025-04-12 16:50:31+00
 v-kubernet-dev-post-I6elJlWDwocvTkCJQwmW-1744477120 | Password valid until 2025-04-12 16:59:45+00
 v-kubernet-dev-post-Jh15Ui20lrcxuFo8yH1b-1744476432 | Password valid until 2025-04-12 16:48:17+00
 v-kubernet-dev-post-T8gKAH6Z2h7hPJeDpVs6-1744476521 | Password valid until 2025-04-12 16:49:46+00
...


# 로그 확인
kubectl stern -n demo-ns -l app=vaultdemo
...

kubectl stern -n vault vault-0
...
vault-0 vault 2025-04-10T08:32:09.484Z [INFO]  expiration: revoked lease: lease_id=demo-db/creds/dev-postgres/Ph1sg8efnqssOU5FVIuAqhMW
vault-0 vault 2025-04-10T08:32:54.229Z [INFO]  expiration: revoked lease: lease_id=demo-db/creds/dev-postgres/XKkHZ65ZYLeILvo8GqWfE7F3
vault-0 vault 2025-04-10T08:33:36.804Z [INFO]  expiration: revoked lease: lease_id=demo-db/creds/dev-postgres/rk1KUFsV7SjZPNFsCqCHGZVx
...

 

실습을 마무리하고 kind 클러스터를 삭제합니다.

# 클러스터 삭제
kind delete cluster --name myk8s

# 확인
docker ps
cat ~/.kube/config

 

 

마무리

쿠버네티스 환경에서 Setcret을 사용하기 위해서 Vault를 활용하는 방안을 살펴보았습니다. 실습은 간략한 Static Secret과 Dynamic Secret을 예제로 살펴보았지만, Vault를 사용해 Secret 관리를 위임하는 다양한 사례가 있을 수 있습니다.

 

Vault를 활용하는 방식에서 Sidecar injector나 CSI Driver를 사용하는 방식은 간단하지만 기존 워크로드의 변경이 있어야 하기 때문에 다소 불편할 수 있습니다. 한편 VSO를 사용하는 방법은 사용 방식은 복잡하지만, 개발자 입장에서는 기존 쿠버네티스의 Secret을 사용하는 인터페이스를 그대로 사용할 수 있다는 장점이 있었습니다.

 

그럼 이번 포스트를 마무리 하겠습니다.