🏦

kubernetes 上に HashiCorp vault を構築して機能を試す

2024/04/02に公開

はじめに

Vault は Terraform で有名な HashiCorp が提供する Secret Management 用のサービスであり、各サービスに使う認証情報や任意の key-value など種々の機密情報を一元管理することができます。

https://developer.hashicorp.com/vault

AWS の Secret Manager や GCP の Secret Manager など各クラウドプロバイダーで機密情報を管理するためのマネージドサービスがありますが、それと同じカテゴリのサービスという位置付けになっています。各種パッケージマネージャーや docker イメージ、helm など様々な方法で構築できるため(インストールページを参照)、今回は helm で k8s クラスタ上にインストールします。helm でインストールできる vault 関連のコンポーネントでは通常の vault server の他に k8s secret と連動可能な機能があるため、そちらについても実際に使ってみます。

セットアップ

helm で vault をインストールする際はさまざまな項目をカスタマイズできますが、はじめはデフォルト設定でインストールします。設定可能な項目は Configuration を参照。

また、helm では vault の構成について以下の 4 つのモードを選択できます。

  • Dev: 開発用のモード。データはメモリ上に保存され、永続化されない。
  • StandAlone: 単一の pod で稼働。データは persistentVolume に保存、永続化される。
  • HA: 複数の pod で稼働する High Availability 構成。
  • External: 外部にある vault server を利用する構成。

今回はデフォルト設定の StandAlone モードで作成します。また pod を起動する際に persistedVolume が必要になるので事前に作成しておきます (今回は openEBS による dynamic provisioner で対応)。
helm でレポジトリを追加し、hashicorp/vault をインストール。

$ helm repo add hashicorp https://helm.releases.hashicorp.com
$ helm install vault hashicorp/vault

インストールにより以下のような pod, service が作成されます。

$ kubectl get pod,svc
NAME                                        READY   STATUS    RESTARTS       AGE
pod/vault-0                                 0/1     Running   0              58s
pod/vault-agent-injector-55748c487f-hjxrj   1/1     Running   0              59s

NAME                               TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)             AGE
service/vault                      ClusterIP   10.97.192.100    <none>        8200/TCP,8201/TCP   59s
service/vault-agent-injector-svc   ClusterIP   10.110.234.104   <none>        443/TCP             59s
service/vault-internal             ClusterIP   None             <none>        8200/TCP,8201/TCP   59s
service/vault-ui                   ClusterIP   10.111.79.217    <none>        8200/TCP            10d

Initialize と unseal

インストールが成功すると vault-0 pod が起動しますが、この時点ではまだ Ready にはなりません。上記の 4 つのモードのうち Dev 以外の構成では vault server は seal 状態で起動します。seal は「封をする」という意味ですが、文字通りこのままではほとんどの操作を行えないため unseal という操作を実行する必要があります。

CLI initialize and unseal に従って 初期化処理と unseal を実行します。
まずは vault-0 pod 内で vault operator init を実行します。

$ kubectl exec -ti vault-0 -- vault operator init
Unseal Key 1: C0eR4peV1QvcCVl5OCG9i6lkEr4/wlYIfIZs/YaGYe2F
Unseal Key 2: QZUUlu9Gdir9bC+qT2cBMZJqIvfhqmglv24/iKlsUKdv
Unseal Key 3: X1SN6V/DoPWmQjG9T4irbbmfWBX1JqOHXW8wAS92pm5w
Unseal Key 4: vXoXviXzA0MsBZKuFk0Cjg3uqzqUC4x8kharKMFcjE49
Unseal Key 5: 2gFG5OCZ4pdn2YDwKgIG5OvZ4I8omsa9TGMO6aHg3fQP

Initial Root Token: hvs.pmlUwjaoZKR3tQTUU0GnBnOV

Vault initialized with 5 key shares and a key threshold of 3. Please securely
distribute the key shares printed above. When the Vault is re-sealed,
restarted, or stopped, you must supply at least 3 of these keys to unseal it
before it can start servicing requests.

Vault does not store the generated root key. Without at least 3 keys to
reconstruct the root key, Vault will remain permanently sealed!

It is possible to generate new unseal keys, provided you have a quorum of
existing unseal keys shares. See "vault operator rekey" for more information.

ここで表示されるメッセージにも記載されているように、上記 5 つの Unseal KeyInitial Root Token は vault における重要な key と token となっています。詳しくは以下を参照。

これらの値は今後も使うのでどこかにメモしておきます。
次に上記の Unseal key を使って unseal を実行します。pod 内で vault operator unseal を実行すると unseal key の入力を求められるので、出力された unseal key のいずれかの値を入力します。

$  kubectl exec -ti vault-0 -- vault operator unseal
Unseal Key (will be hidden): # Unseal key 1 を入力

key の値を重複しないように 3 回繰り返すと unseal が成功し、pod が Ready 状態となります。これで vault server を使うための準備が完了し、secret の読み書きが行えるようになります。

vault CLI のインストール

vault pod 内では vault CLI がインストール済みのため vault コマンドが実行できますが、pod 外でもコマンドが実行できたほうが便利なので vault CLI をノード等にインストールしておきます。Install Vault から使用している OS, arch に合ったものをインストールします。例えば amd64 用のバイナリをインストールする場合は以下。

$ wget https://releases.hashicorp.com/vault/1.15.6/vault_1.15.6_linux_amd64.zip
$ unzip vault_1.15.6_linux_amd64.zip
$ sudo mv vault /usr/local/bin
$ rm vault_1.15.6_linux_amd64.zip
$ vault version
Vault v1.15.6 (615cf6f1dce9aa91bc2035ce33b9f689952218f0), built 2024-02-28T17:07:34Z

Completion は以下を実行した後シェルを再起動することで有効化できます。参考

vault -autocomplete-install

vault server に接続するには接続先をコマンド実行時のオプションか環境変数 VAULT_ADDR に指定します。
vault pod に接続するための k8s service は ClusterIP として作成されるため、k8s クラスタ内から接続する場合はこの CLUSTER-IP を指定できます (クラスタ外部から接続する場合は ingress の設定が必要)。

$ kubectl get svc
NAME                       TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)             AGE
vault                      ClusterIP   10.111.155.187   <none>        8200/TCP,8201/TCP   4d23h

また、各種コマンドの実行には vault の認証が必要になります。これに関しては様々な方法がありますが、手っ取り早く行うには initialize 時の root token を使うことができます。

環境変数を使う場合は VAULT_ADDR にアドレス、VAULT_TOKEN にトークンの値を指定します。

export VAULT_ADDR="http://10.111.155.187:8200"
export VAULT_TOKEN="hvs.pmlUwjaoZKR3tQTUU0GnBnOV"

これにより vault server に対して接続・認証が成功し、secret の読み書き等の各種コマンドが実行できるようになります。

vault k8s の機能を試す

上記で k8s クラスタ上に vault server を構築できました。
ingress を作成してクラスタ外部からもアクセスできるようにすれば、特に k8s 上で動作していることを意識せずに secret の書き込みや参照が実行できます。
vault 自体の使い方は検索するといろいろ記事が出てくるので、ここでは k8s vault 独自の機能をいくつか試してみます。

Vault CSI provider

Vault CSI provider は secrets-store-csi-driver を利用した vault の機能の 1 つであり、事前に設定は必要ですが pod 作成時に vault server から secret を動的に取得して pod 内で参照することができます。

hashicorp の以下のページに使い方が記載されているため、こちらを参考に実際に使ってみます。

hashicorp-japan による日本語の説明も以下にあります。

事前準備

vault CSI provider を使うには secrets-store-csi-driver が必要になるので、helm を使ってインストールします。

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

vault CSI provider は vault を helm でインストールする際に --set "csi.enabled=true" をつけることで追加されます。

helm install vault hashicorp/vault --set "csi.enabled=true"

インストールが完了すると vault-csi-provider の pod (daemonset) が起動します。

vault-0                                 1/1     Running   0             20h
vault-csi-provider-k92cr                2/2     Running   0             20h

vault 側の準備

pod 内で vault 側の secret を参照する動作を確認するため、適当な secret を vault server に書き込んでおきます。参照する secret は何でも良いので、ここでは kv v2 タイプでdatabase/secret というパスに username, password の key-value を書き込みます。

# kv v2 secret engine を有効化
$ vault secrets enable -path=secret kv-v2

# 書き込み
$ vault kv put  -mount=secret database/secret username=myusername password=mypassword

# 参照
$ vault kv get -mount=secret database/secret

======= Secret Path =======
secret/data/database/secret

...

====== Data ======
Key         Value
---         -----
password    mypassword
username    myusername

vault CSI provider では vault の kubernetes 認証 を使って認証を行うため、以下のコマンドで有効化します。

$ vault auth enable kubernetes

vault 側で k8s ホストを設定するため、vault pod 内で以下のコマンドを実行します
(pod 内でないと KUBERNETES_PORT_443_TCP_ADDR の環境変数が設定されていないため)。

# pod でシェル起動
$ kubectl exec -it vault-0 -- sh

# vault server に接続するために環境変数を設定
/ $ export VAULT_ADDR="http://vault.vault:8200"
/ $ export VAULT_TOKEN="[root_token の値]"

# kubernetes 認証の k8s ホストを設定
/ $ vault write auth/kubernetes/config kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443"

read して kubernetes_host に ip address が設定されていれば ok

/$ vault read auth/kubernetes/config
Key                       Value
---                       -----
disable_iss_validation    true
disable_local_ca_jwt      false
issuer                    n/a
kubernetes_ca_cert        n/a
kubernetes_host           https://10.96.0.1:443
pem_keys                  []

また、kubernetes 認証時に使用される vault 側の role と policy を作成します。
policy では上記で作成した kv v2 のパス secret/data/database/secret を read できる権限を設定します。

$ vault policy write db-policy - <<EOF
path "secret/data/database/*" {
  capabilities = ["read"]
}
EOF

role は k8s 側の serviceAccount (SA) にマッピングする形で作成します。SA 名はこの時点では何でもいいですが、ここで指定した値を後ほど k8s 側で作成して使用します (namespace も同様)。policies には上記で作成した db-policy を指定し、database という名前の role に結びつけます。

  • vault 側 role 名: database
  • k8s 側 ServiceAccount: db
  • k8s 側 namespace : default
$ vault write auth/kubernetes/role/database \
    bound_service_account_names=db \
    bound_service_account_namespaces=default \
    policies=db-policy \
    ttl=20m

k8s 側の準備

k8s 側では vault から secret を取得するためのカスタムリソース SecretProviderClass を作成します。
マニフェストでは spec.parameters に以下の項目を指定します。

  • vaultAddress: vault server のアドレス。vault server は namespace vault 上に vault という service 名で公開されているので、http://vault.vault:8200 を指定すればアクセスできる。
  • roleName: vault server 側で作成した kubernetes の role 名。上記の例では database
  • objects: vault 側から取得する secret を list 形式で指定
    • secretPath: vault server 上の対象 secret までのパス
    • secretKey: secret 内の取得する key 名
    • objectName: 取得した secret を格納する key 名。pod 内ではここで指定したファイルの中に value が書き込まれる。
secret-class.yml
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: db-secret-class
spec:
  provider: vault
  parameters:
    vaultAddress: "http://vault.vault:8200"
    roleName: database
    objects: |
      - objectName: "obj-username"
        secretPath: "secret/data/database/secret"
        secretKey: "username"
      - objectName: "obj-password"
        secretPath: "secret/data/database/secret"
        secretKey: "password"

また、上記を実行するための serviceAccount を作成

kubectl create sa db

次に実際に secret を参照する pod を作成します。
上記で作成した secretProviderClass オブジェクトを pod 内で参照するには spec.volumes で定義します。secretProviderClass には上記で作成したリソース名 db-secret-class を指定します。

volumes:
  - name: vault-db-creds
    csi:
      driver: 'secrets-store.csi.k8s.io'
      readOnly: true
      volumeAttributes:
        secretProviderClass: db-secret-class

これを POD 内の特定のコンテナにマウントするために spec.containers[].volumeMounts を指定します。
name には上記で指定した volumes.name と同じものを指定し、mountPath にはコンテナ内で mount するパスを指定します。

spec:
  containers:
    volumeMounts:
      - name: vault-db-creds
        mountPath: '/mnt/secrets-store'
        readOnly: true

今回は検証のためイメージは適当に busybox を使用します。最終的なマニフェストは以下。

deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
  labels:
    app: demo
spec:
  selector:
    matchLabels:
      app: demo
  replicas: 1
  template:
    metadata:
      annotations:
      labels:
        app: demo
    spec:
      serviceAccountName: db
      containers:
        - name: app
          image: busybox:1.29
          command:
            - "/bin/sleep"
            - "10000"
          volumeMounts:
            - name: vault-db-creds
              mountPath: '/mnt/secrets-store'
              readOnly: true
      volumes:
        - name: vault-db-creds
          csi:
            driver: 'secrets-store.csi.k8s.io'
            readOnly: true
            volumeAttributes:
              secretProviderClass: db-secret-class

動作確認

上記で準備した pod を作成すると pod 内コンテナが起動するタイミングで vault server への認証が行われ、secret の取得 ~ pod 内のマウントが行われます。
pod 内でシェルを起動して volumeMounts.mountPath に指定したパスを確認すると、SecretProviderClass において指定した secret 内の key がシンボリックリンクとしてマウントされていることが確認できます。ファイル名は objectName に指定した名前にマッピングされており、各々のファイルの中身に key に対応する value が書き込まれています。

# Pod 内でシェル起動
$ kubectl exec -it app-75fbd57ff8-pz482 -- sh

/ # ls -l /mnt/secrets-store/
total 0
lrwxrwxrwx    1 root     root            15 Mar 20 08:26 obj-password -> ..data/obj-password
lrwxrwxrwx    1 root     root            15 Mar 20 08:26 obj-username -> ..data/obj-username

/ # cat /mnt/secrets-store/username
myusername

/ # cat /mnt/secrets-store/password
mypassword

このようにして vault CSI provider を使うことで vault 側の secret を pod 内で参照することができます。

ちなみに、 vault 側で secret の値を変更した際に pod 内の値も変更されるか見ておきます。
vault 側の value をそれぞれ myusername2, mypassword2 に更新します。

$ vault kv put  -mount=secret database/secret username=myusername2 password=mypassword2

pod 内を見ても値は変更されていません。

/ # cat /mnt/secrets-store/username
myusername

/ # cat /mnt/secrets-store/password
mypassword

前述の通り secret は pod 作成時に vault server から取得する仕様であるため、vault 側の secret を変更してもリアルタイムで更新が反映されるわけではなく pod の再作成が必要となります。
既存の pod を削除した後、再度値を確認すると更新した値になっていることが確認できます。

# Pod を削除.
$ kubectl delete pod app-75fbd57ff8-pz482
pod "app-75fbd57ff8-pz482" deleted

# deployment により別 pod が起動するのでシェル起動
$ kubectl exec -it app-75fbd57ff8-z9927 -- sh
/ #

# 確認
/ # cat /mnt/secrets-store/username
myusername2

Secret の使用状況

SecretProviderClass で作成された secret がいずれかの pod で使用されているときは secretproviderclasspodstatuses リソースが作成されます。これは SecretProviderClass リソースがいずれかの pod で参照されているときのみ動的に作成されるリソースとなっており、どの pod からも参照されていない場合には自動的に削除されます。
例えば、上記の例では app-75fbd57ff8-vxclr という pod で db-secret-class という SecretProviderClass を参照しているため、以下のリソースが作成されています。
(リソース名は [pod-name]-[namespace]-[SecretProviderClass-name] となるっぽい)。

$ kubectl get secretproviderclasspodstatuses.secrets-store.csi.x-k8s.io
NAME                                           AGE
app-75fbd57ff8-vxclr-default-db-secret-class   89s

kubectl describe で内容を見ると、実際に secret がどのような key 名で pod にマウントされているかが確認できます。

$ kubectl describe secretproviderclasspodstatuses.secrets-store.csi.x-k8s.io
...
Status:
  Mounted:  true
  Objects:
    Id:                        obj-password
    Version:                   CyNGjiAyP1qYpoZcgGU-suc3FMYduH64LU-Pwy2X9MQ=
    Id:                        obj-username
    Version:                   uoQc8U20pD_ebH3RTbcZgmyWuDbSTQ_9MRXU3daavOM=
  Pod Name:                    app-75fbd57ff8-vxclr
  Secret Provider Class Name:  db-secret-class
  Target Path:                 /var/lib/kubelet/pods/b3193227-239d-437f-b73d-a40fc13d6a35/volumes/kubernetes.io~csi/vault-db-creds/mount
Events:                        <none>

SecretProviderClass を参照する pod がなくなると secretproviderclasspodstatuses も自動で削除されます。

$ kubectl delete deployments.apps app
deployment.apps "app" deleted

$ kubectl get secretproviderclasspodstatuses.secrets-store.csi.x-k8s.io   1 ↵
No resources found in default namespace.

ちなみに上記の Target Path に表示されているパスは実際に secret がマウントされているノード上のパスを表しています。
POD が起動しているノード上の上記のパスを確認すると、pod 内で確認したのと同じ secret がマウントされていることが確認できます。

$ pwd
/var/lib/kubelet/pods/b3193227-239d-437f-b73d-a40fc13d6a35/volumes/kubernetes.io~csi/vault-db-creds/mount

$ ls -l
total 0
lrwxrwxrwx 1 root root 19 Mar 20 08:26 obj-password -> ..data/obj-password
lrwxrwxrwx 1 root root 19 Mar 20 08:26 obj-username -> ..data/obj-username

$ cat obj-username
myusername2

エラーの確認

マニフェストの設定などを間違えていて vault server から正常に secret が取得できない場合、 pod 作成時に status が ContainerCreating のままになります。

$ kubectl get pod
NAME                                    READY   STATUS              RESTARTS   AGE
app-75fbd57ff8-hm787                    0/1     ContainerCreating   0          13s

describe pod でエラー内容を確認できます。

$ kubectl describe pod  app-75fbd57ff8-hm787

...
Events:
  Type     Reason       Age                 From               Message
  ----     ------       ----                ----               -------
  Normal   Scheduled    99s                 default-scheduler  Successfully assigned vault/app-75fbd57ff8-hm787 to k8s-w1
  Warning  FailedMount  36s (x8 over 100s)  kubelet            MountVolume.SetUp failed for volume "vault-db-creds" : rpc error: code = Unknown desc = failed to mount secrets store objects for pod vault/app-75fbd57ff8-hm787, err: rpc error: code = Unknown desc = error making mount request: couldn't read secret "username": failed to login: Error making API request.

URL: POST http://vault.vault:8200/v1/auth/kubernetes/login
Code: 400. Errors:

* invalid role name "db"

上記の例では * invalid role name "db" となっています。これは SecretProviderClass オブジェクト内では rolename が db と指定されていますが、vault server 内で指定した rolename は database であることから、vault server へのログインに失敗したことがわかります。
なお、vault-csi-provider pod の vault-csi-provider コンテナのログからも同様のエラーメッセージが確認できます。

$  kubectl logs  vault-csi-provider-k92cr vault-csi-provider
2024-03-20T08:22:18.838Z [INFO]  server: Finished unary gRPC call: grpc.method=/v1alpha1.CSIDriverProvider/Mount grpc.time=9.530387ms grpc.code=Unknown
  err=
  | error making mount request: couldn't read secret "username": failed to login: Error making API request.
  |
  | URL: POST http://vault.vault:8200/v1/auth/kubernetes/login
  | Code: 400. Errors:
  |
  | * invalid role name "db"

そのため、うまく行かない場合はこれらのメッセージを確認すると良いでしょう。

Vault Agent Injector

Vault Agent Injector は secret を参照する pod に annotation をつけておくと、pod 作成時に Agent injector が検出して secret を含む sidecar コンテナを pod 内に inject するという仕組みです(よくある sidecar injection 型の仕組み)。

機能的には CSI Provider と同じなのでここでは割愛します。両者の比較については以下を参照。
https://developer.hashicorp.com/vault/docs/platform/k8s/injector-csi

Vault Secrets Operator

Vault Secrets Operator は k8s でよくある Operator を利用し、カスタムリソースを使って vault server に保存された各 secret を k8s の secret に同期する機能になっています。詳しい説明は以下を参照。

https://developer.hashicorp.com/vault/docs/platform/k8s/vso

こちらも上記ドキュメントに使用手順が記載されているので試してみます。

セットアップ

Vault Secrets Operator も helm でインストール可能ですが、vault server とは別の chart hashicorp/vault-secrets-operator を使います。

helm install --version 0.5.2 --create-namespace --namespace vault-secrets-operator vault-secrets-operator hashicorp/vault-secrets-operator

インストールが完了すると operator pod が起動します。

$ kubectl get pod -n vault-secrets-operator
NAME                                                         READY   STATUS    RESTARTS   AGE
vault-secrets-operator-controller-manager-7d48875c77-fmftl   2/2     Running   0          30h

Vault Secrets Operator では vault server への認証や取得する secret はカスタムリソースで定義していきます。
まずはじめに接続先の vault server を記述する VaultConnection カスタムリソースを作成します。ここでの接続先は同じクラスタ内の vault service になるので、vault svc を spec.address に指定すれば ok です。

apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultConnection
metadata:
  name: vault-connection
spec:
  address: http://vault.vault.svc.cluster.local:8200

次に vault server に認証を行うためのカスタムリソース VaultAuth を作成します。Supported Vault authentication methods にあるように kubernetes 認証の他に以下のような認証が使用できます。

  • kubernetes
  • JWT
  • AppRole

ドキュメントでは kubernetes 認証の設定が記載されていますが、せっかくなのでここでは appRole を使って認証するように設定します。

認証に使う appRole を k8s-vault-op として事前に vautl server 上に作成しておきます。secret は secret/data/k8s/testdata/secret に kv v2 として作成し、これを読み取れるように policy を設定します。

$ cat policy.yml
path "secret/data/k8s/*" {
  capabilities = ["create", "read", "update", "delete", "list"]
}

# appRole 用の policy 作成
$ vault policy write k8s-vault-op policy.yml

# appRole 作成
$ vault write auth/approle/role/k8s-vault-op policies="k8s-vault-op"

# Role id の取得
$ vault read auth/approle/role/k8s-vault-op/role-id

# secret id を取得
$ vault write -f auth/approle/role/k8s-vault-op/secret-id

次に上記の appRole を使って認証を行う VaultAuth カスタムリソースを作成します。kubernetes 認証を使う際の例が VaultAuth custom resource に記載されているので、これを元に appRole 用の VaultAuth に設定するプロパティを見ていきます。
API Reference の VaultAuthSpec を参照すると appRole の場合は appRole フィールドに VaultAuthConfigAppRole を指定し、この中に roleId の値と secretId を格納した secretRef を指定すれば良いことがわかります。そのため、上記で取得した secret id を記載した k8s secret k8s-vault-op を作成します。

secret-id.yml
apiVersion: v1
kind: Secret
metadata:
  name: k8s-vault-op
type: Opaque
stringData:
  id: 3a145c1e-54ed-b841-2d59-f2eb1839e539 # secret ID
$ kubectl apply -f secret-id.yml

VaultAuth のマニフェストには appRole 以下に roleId を指定し、secretRef には上記の k8s-vault-op を指定。

vault-auth.yml
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
  name: vault-auth
spec:
  vaultConnectionRef: vault-connection
  method: appRole
  mount: approle
  appRole:
    roleId: e7a69056-729a-5448-ca6b-5c2b39035ee9
    secretRef: k8s-vault-op

リソース作成後、vaultauths を describe することで認証に成功したか確認できます。

$ kubectl describe vaultauths.secrets.hashicorp.com vault-auth
...
Events:
  Type    Reason    Age   From       Message
  ----    ------    ----  ----       -------
  Normal  Accepted  3s    VaultAuth  Successfully handled VaultAuth resource request

これで一連の準備ができたので、最後に vault server 側で以下の検証に使用する kv v2 の値を書き込んでおきます。

$ vault kv put  -mount=secret k8s/testdata/secret key1=value1
========= Secret Path =========
secret/data/k8s/testdata/secret

StaticSecret の作成

vault 側の secret を k8s 側に同期するには VaultStaticSecret カスタムリソース を作成します。spec に指定する内容は以下。

  • vaultAuthRef: vault server への認証に使用する vaultAuth カスタムリソース名を指定。
  • mount: vault 側の mount を指定。secret 作成時に -mount=secret を指定したためここでも secret を指定。
  • type: vault 側の type を指定。secret 作成時に kv v2 で書き込んだため kv-v2 を指定。
  • path: vault 側の secret までの path を指定。
  • refreshAfter: secret が同期される周期?(ドキュメントの定義を見てもよくわからなかった)
  • destination: vault 側から取得した値を保存する k8s 側の secret に関する情報
    • create: true にした場合、secret がない場合に新規作成する。
    • name: k8s secret 名
static-secret.yml
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
  name: vault-static-secret-v2
spec:
  vaultAuthRef: vault-auth
  mount: secret
  type: kv-v2
  path: k8s/testdata/secret
  refreshAfter: 60s
  destination:
    create: true
    name: kv-secret

リソース作成後 vault 側から正常に secret が取得できると、vaultstaticsecrets リソースの Event に Secret synced が記録されます。

$  kubectl describe  vaultstaticsecrets.secrets.hashicorp.com vault-static-secret-v2

  Type    Reason         Age   From               Message
  ----    ------         ----  ----               -------
  Normal  SecretSynced   10s   VaultStaticSecret  Secret synced
  Normal  SecretRotated  10s   VaultStaticSecret  Secret synced

これによりマニフェスト内で指定した名前の secret が作成されます。

$ kubectl get secret kv-secret
NAME        TYPE     DATA   AGE
kv-secret   Opaque   2      35s

vault 側では key1: value1 という key-value を書き込みましたが、secret 内では data 以下に key が格納され、value は base64 encode されて書き込まれます。

$ kubectl get secret kv-secret -o yaml
apiVersion: v1
data:
  _raw: eyJkYXRhIjp7ImtleTEiOiJ2YWx1ZTEifSwibWV0YWRhdGEiOnsiY3JlYXRlZF90aW1lIjoiMjAyNC0wMy0yMVQwOTo1NTo0NC4zMjgxOTIyMTFaIiwiY3VzdG9tX21ldGFkYXRhIjpudWxsLCJkZWxldGlvbl90aW1lIjoiIiwiZGVzdHJveWVkIjpmYWxzZSwidmVyc2lvbiI6MX19
  key1: dmFsdWUx
kind: Secret
...

$ kubectl get secret kv-secret -o yaml | yq -r ".data.key1" | base64 -d
value1

上記のように作成された secret は通常の secret と同じように扱えるため、pod 側でマウント等を行うことで参照できます。

また、 k8s 側で secret の値を value2 に書き換えてみます。

$ echo -n "value2" | base64
dmFsdWUy

# base64 encode した値を secret に書き込み
$ kubectl edit secret kv-secret
secret/kv-secret edited

# 値の取得
$ kubectl get secret kv-secret -o yaml | yq -r ".data.key1" | base64 -d
value2%

変更した直後は value2 となっていますが、少し待ってから再度 key1 の値を取得すると value1 となっています。

$ kubectl get secret kv-secret -o yaml | yq -r ".data.key1" | base64 -d
value1%

k8s 側の secret は vault server 側と定期的に同期されているため、k8s secret 側の値が変更されていても同期のタイミングで vault server 側の値に戻されます。同期が完了するタイミングで VaultStaticSecret のイベントで SecretRotated が出力されます。

Events
  Normal  SecretRotated  15s (x4 over 7m51s)  VaultStaticSecret  Secret synced

ただあくまで vault → k8s で定期的に同期されているだけなので、k8s 側の値を変えたからといって vault 側の値が変更されることはありません。そのため同期というよりは上記の通り定期的に secret が rotate されるといった表現の方が適切かもしれません。
もちろん k8s secret 側の値を変更した直後でも vault server 側の値が変わってないことが確認できます。

# base64 encode した値を secret に書き込み
$ kubectl edit secret kv-secret
secret/kv-secret edited

# 直後に vault server 側の secret を確認しても value 1 のまま。
$ vault kv get  -mount=secret k8s/testdata/secret
========= Secret Path =========
secret/data/k8s/testdata/secret

==== Data ====
Key     Value
---     -----
key1    value1

StaticSecret のカスタムリソースで作成された secret は定期的に rotate されるため、k8s 側で不意の操作により secret の値が書き換わった結果、想定しない動作が発生するといった事故を防ぐことができます。

DynamicSecret の作成

StaticSecret の他に DynamicSecret に対応するカスタムリソースを作成することもできます。 StaticSecret は上記で見たように事前に Vault server に書き込んである値を参照する際に使用しますが、DynamicSecret はリクエストに基づいて動的に作成され、有効期限を持つような資格情報などを参照する際に使用します。ドキュメントの例では AWS Secret を使う例が記載されているのでこちらを試してみます。

この例ではカスタムリソース作成時に AWS の IAM ユーザー を動的に作成する動作となっています。また、作成される IAM ユーザーは vault 側で指定した有効期限でローテートされるため、IAM ユーザーの認証情報が漏洩しても一定時間後に有効期限が切れるため悪用されにくいというメリットがあるようです。

AWS Secret を使う場合は AWS secrets engine の Setup を事前に済ませておく必要があります。
また、この時に DynamicSecret で作成される IAM ユーザーの詳細やポリシーなどを設定します。

# AWS secret engine の有効化
$ vault secrets enable aws

# AWS にアクセスする iam credential の設定
# access_key, secret_key は事前に作成しておく
$ vault write aws/config/root \
    access_key=... \
    secret_key=... \
    region=ap-northeast-1

# 作成する IAM user の設定
$ vault write aws/roles/my-role \
    credential_type=iam_user \
    policy_document=-<<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "ec2:*",
      "Resource": "*"
    }
  ]
}
EOF

上記を設定した後、通常の Vault コマンドを使用する方法では vault read aws/creds/my-role を実行することで AWS 側に動的に IAM ユーザーを作成できます。
Secret Operator ではコマンドを実行する代わりに VaultDynamicSecret カスタムリソースを作成することにより、リソース作成のタイミングで IAM ユーザーを作成します。

まず先ほど利用した appRole で aws/creds/my-role にアクセスできるようポリシーを追加しておきます。

$ cat role-policy.hcl
path "aws/creds/*" {
  capabilities = ["read"]
}

$ vault policy write iam-role role-policy.hcl
$ vault write auth/approle/role/k8s-vault-op policies=k8s-vault-op,iam-role

次にドキュメントの例に沿って VaultDynamicSecret のマニフェストを作成します。

---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultDynamicSecret
metadata:
  name: vault-dynamic-secret-aws-iam
spec:
  vaultAuthRef: vault-auth
  mount: aws
  path: creds/my-role
  destination:
    create: true
    name: dynamic-aws-iam

このをリソースを作成すると AWS 側に IAM ユーザーが作成されます。
同時に k8s 側に secret dynamic-aws-iam も作成され、この中に作成した IAM ユーザーに関する 認証情報 (access_key と secret_key) が記載されます。

$ kubectl get secret dynamic-aws-iam -o yaml | yq -r ".data._raw"  | base64 -d | jq
{
  "access_key": "xxx",
  "secret_key": "yyy",
  "security_token": null
}

AWS 側も確認すると、vault-approle-my-role-1711468574-iTMb25HW7bU0mfk1v4yV という IAM ユーザーが作成されているのが確認できます。

$ aws iam list-users
Users:
- Arn: ....
  CreateDate: ...
  Path: /
  UserId: AIDARTHR5BBQBSFCH6X6R
  UserName: vault-approle-my-role-1711468574-iTMb25HW7bU0mfk1v4yV

DynamicSecret は一時的な認証情報であるため TTL による有効期限を設定するのが一般的です。TTL が切れた際の動作についても確認するため、ここでは以下のコマンドにより上記の IAM ユーザーの有効期限 (TTL) を 1 分に設定します。

$ vault write sys/mounts/aws/tune default_lease_ttl=1m max_lease_ttl=1m

これで上記の VaultDynamicSecret リソースを再度作成して動作を見てみると、どうやら 1 分間に IAM ユーザーの削除 → 新しいIAM ユーザーの作成という動作が実行されているようです。こちらは aws iam list-users を 1 分間毎に実行してユーザーの CreateDate を見ることで確認できます。
また、この様子は VaultDynamicSecret を describe した際のイベントからも確認できます。

Events:
  Type    Reason              Age    From                Message
  ----    ------              ----   ----                -------
  Normal  SecretSynced        2m30s  VaultDynamicSecret  Secret synced, lease_id="aws/creds/my-role/QQq6eSDpqOmUl2mfbV3FniBS", horizon=44.279659737s
  Normal  SecretRotated       2m28s  VaultDynamicSecret  Secret synced, lease_id="aws/creds/my-role/csc72JohsmDQVtbzIKYJsN95", horizon=43.631735595s
  Normal  SecretLeaseRenewal  106s   VaultDynamicSecret  Lease renewal duration was truncated from 60s to 18s, requesting new credentials
  Normal  SecretRotated       104s   VaultDynamicSecret  Secret synced, lease_id="aws/creds/my-role/yVwNVa8PQ105wpQrurYyFWbu", horizon=41.194170039s
  Normal  SecretLeaseRenewal  63s    VaultDynamicSecret  Lease renewal duration was truncated from 60s to 19s, requesting new credentials
  Normal  SecretRotated       60s    VaultDynamicSecret  Secret synced, lease_id="aws/creds/my-role/AvZyDu9DecRCG5RodD8P22Pt", horizon=43.546827077s
  Normal  SecretLeaseRenewal  17s    VaultDynamicSecret  Lease renewal duration was truncated from 60s to 17s, requesting new credentials
  Normal  SecretRotated       14s    VaultDynamicSecret  Secret synced, lease_id="aws/creds/my-role/bDKo5OTSlzlwr7j0SVVfx6s1", horizon=41.868678212s

新しい IAM ユーザーが作成されてから 1 分後には TTL が切れて削除されますが、VaultDynamicSecret リソースが存在している間は SecretRotated により都度新しいユーザーが作成されます。これにより有効期限が切れても VaultDynamicSecret 側でよしなに対応してくれることがわかりました。TTL 毎に認証情報が rotate されるため、この認証情報を使う pod 等では特に TTL の有効期限切れ等を意識せずに使うことができます(rotate される度に IAM ユーザー名や access_key の値自体は変更されるので場合によっては意識する必要があるかも知れませんが)。

VaultDynamicSecret リソースを削除すると rotate も行われなくなり、この中で作成された IAM ユーザーは TTL が切れると vault により削除されるので、最終的には VaultDynamicSecret リソース作成前の状態に戻ります。

Auto unseal

Dev 以外のモードで起動した vault server pod は Initialize と unseal で見たように seal 状態で起動するため、pod が再作成される度に unseal の操作が必要になります。ただ pod が再作成される度に手動で unseal を実行するのは運用上かなり不便なので Auto unseal という機能が備わっています。これは vault 外部のクラウド等に保存した key を使って unseal を自動で実行する機能になっています。

key の保管場所としては Auto unseal に以下の選択肢が記載されています。

  • AWS KMS
  • GCP Cloud KMS
  • Azure Key Vault
  • HSM (Hardware Security Module)
  • Transit Secret Engine

このうち上 3 つはクラウドプロバイダーの Key Management サービスを利用する方法であり、HSM は Vault enterprise 専用の機能となっています。最後の transit secret engine は vault の engine を利用した機能となっており、対象の vault server とは別に構築した vault server 上に key を保存する仕組みになっています。こちらは手元でも環境を用意できるので試してみます。

セットアップ

基本的にはドキュメントのAuto-unseal using Transit secrets engine の手順に沿って実現します。

Transit Secret Engine の方法では k8s クラスタ上に構築した vault server とは別に key を保存するための vault server が必要になります。もうひとつ k8s クラスタを用意して helm で vault を構築してもいいのですが、ここでは手軽さを重視して別のサーバ上に docker で構築します。
構築のための docker-compose.yml は以下。

docker-compose.yml
services:
  vault:
    container_name: vault
    image: hashicorp/vault
    ports:
      - 8200:8200
    cap_add:
      - IPC_LOCK
    command: server -dev -dev-root-token-id="00000000-0000-0000-0000-000000000000"
    environment:
      VAULT_DEV_ROOT_TOKEN_ID: '00000000-0000-0000-0000-000000000000'
      VAULT_TOKEN: '00000000-0000-0000-0000-000000000000'

また、ドキュメントでは Auto unseal を有効化する vault server を vault1, そのための key を保存する vault server を vault 2 としていますが、ここではそれぞれ以下のように読み変えます。

  • vault1: vault 2 の key を保存する vault server. docker で構築する。
  • vault2: k8s クラスタ上の vault server. 今まで使用してきたものを使う。


https://developer.hashicorp.com/vault/tutorials/auto-unseal/autounseal-transit#scenario-introduction より引用

docker で vault server を起動したらコンテナの vault server に接続するための環境変数を設定、transit engine の有効化、autounseal という名前の key を作成します。このあたりはドキュメントの手順と同じです。

# vault1 が動作するサーバ上で実行

$ export VAULT_ADDR="http://0.0.0.0:8200"
$ export VAULT_TOKEN="00000000-0000-0000-0000-000000000000"

# Audit log の有効化
# ドキュメントでは audit.log に出力するように設定しているが
# docker の場合は stdout に出すように設定
$ vault audit enable file file_path=stdout

$ vault secrets enable transit
$ vault write -f transit/keys/autounseal

$  vault policy write autounseal -<<EOF
path "transit/encrypt/autounseal" {
   capabilities = [ "update" ]
}

path "transit/decrypt/autounseal" {
   capabilities = [ "update" ]
}
EOF

# Token を作成
$ vault token create -orphan -policy="autounseal" \
   -wrap-ttl=120 -period=24h \
   -field=wrapping_token > wrapping-token.txt

# Unwrap
$ vault unwrap -field=token $(cat wrapping-token.txt)

動作確認

auto Unseal を有効化するためには、vault server の configuration ファイル内に seal block を追加します。k8s クラスタ上の vault server では configmap の vault-config に hcl 構文で設定が記載されているため、こちらを編集します。

  • address: docker で稼働している vault server のアドレスを指定
  • token: 上記の工程で unwrap した token の値を設定
  • disable_renewal: false に設定
  • key_name: docker vault で設定した keyname を指定
  • mount_path: docker vault で設定した mount path を指定
  • tls_skip_verify: http 通信なので true に設定
seal "transit" {
   address     = "http://192.168.3.181:8200"
   token = "hvs.CAESIFCKWyUjvdWquPuRdiLSkbxgiog0Ke6zODf5ApbdP8awGh4KHGh2cy5WWUlZQ0c5YWdsNGhxOGc1N1dHSERCeG0"
   disable_renewal = "false"
   key_name   = "autounseal"
   mount_path = "transit/"
   tls_skip_verify = "true"
}

configmap を編集して上記の値を設定。

$ kubectl edit configmaps vault-config

編集したら vault pod を削除して configmap の変更を反映させます。
pod が再作成されると seal 状態で起動しますが、この段階ではまだ auto unseal が適用されていません。
というのも、Initialize と unseal において一度 unseal token を使って unseal を実行しているので、現在の seal の設定は Shamir seal になっています。なので Seal migration の手順に沿って Shamir seal から auto unseal に移行する必要があります。手順では vault のバージョンや HA 構成によって実行方法が異なりますが、今回のように StandAlone 構成の vault では以下の手順に従えば良いです。

Now, bring the standby node back up and run the unseal command on each key, by supplying the -migrate flag.

よって、k8s クラスタ上の vault server に対して kubectl exec -ti vault-0 -- vault operator unseal --migrate を実行し、Initialize と unseal の際と同様に異なる unseal key の入力を 3 回繰り返します。

入力に成功すると特にメッセージ等は表示されませんが auto unseal が有効になります。これを確認するためにもう一度 vault pod を削除して少し待つと、unseal 操作を実行しなくても pod が ready となります。

docker 側の vault server のログを docker log vault で確認すると、audit log によって以下のようなリクエストが記録されています。ドキュメントの記載の通り path: transit/decrypt/autounseal に対する operation: update が実行されているので、このリクエストにより auto unseal が実行されていることが確認できます。

"request": {
    "id": "e353d0e1-57e3-b714-db1a-0bd4b12fad18",
    "client_id": "CazzHB1T6UPO5F7ytGq17GnkZg/t3aiSR1ZYkz28Cxk=",
    "operation": "update",
    "mount_point": "transit/",
    "mount_type": "transit",
    "mount_accessor": "transit_292d86f0",
    "mount_running_version": "v1.15.6+builtin.vault",
    "mount_class": "secret",
    ...
    "path": "transit/encrypt/autounseal",
    ...
    "remote_address": "192.168.3.125",

以上より、auto-unseal のための key を別の vault server に保存し、Transit Secret Engine を使って実行する動作が確認できました。

その他

OpenBao について

2023 年に hashicorp terraform のライセンス関係でいろいろあり、terraform から fork されたオープンソースプロジェクトの OpenTofu が開始されました。

https://opentofu.org/

Vault に関しても同様に vault から fork された OpenBao というプロジェクトが開始されています。

https://github.com/openbao/openbao

現時点ではプロジェクトのホームページ等はない状態ですが、OpenTofu が(少なくとも普通に使う分には) terraform と同様の感覚で使えることから、openbao プロジェクトに関してもが上記のような機能が充実していくことが期待されます。

おわりに

HashiCorp vault を k8s クラスタ上に構築していろいろな機能を試しました。vault 自体が secret management として機密情報を管理するサービスであり、kubernetes の secret とシームレスに統合する機能がけっこう充実していることが確認できました。実際の運用において機密情報 (secret) をどう取り扱っていくかというのはよくある問題だと思いますが、vault 側で一元管理し、kubernetes の secret に対しては今回紹介した Vault Secrets Operator を使って同期させるなどいろいろな工夫が考えられそうです。

Discussion