TL; DR
- 宅鯖環境のシークレット管理を暗号化・Git管理によりセキュアかつIaC化しました
- シークレットのIaC管理にはTerraformを、シークレットの参照にはHashicorp Vaultを利用しています
- AnsibleやTerraformで参照できるほか、Vault Secrets Operatorを使うことでk8sのSecretとしても参照できます
モチベーション
我が家では実際のマシン上のVMでk8sクラスタを運用している他、物理マシン上でもいくつかのワークロードを稼働させています。 これらのインフラの構成管理は目的に応じて以下のようなツールでIaC化しています。
- Ansible:物理マシンの設定投入・k8sクラスタのバージョンアップ・VMイメージの構築(Packer経由)
- Terraform:VMのプロビジョニング・keycloakなどワークロードの設定
- ArgoCD:k8sクラスタ上のワークロードのデプロイ
IaCによる構成管理は今の所順調に運用できているのですが、現在のやり方にはシークレット管理上の課題があります。
まず、コードベースにおいてシークレットが統一的に管理されていないことが挙げられます。 例えばAnsibleで利用するシークレットはAnsible Vaultで暗号化し、Terraformで利用するシークレットはSOPSで暗号化しています。 管理しているディレクトリも散らばっておりシークレットの一覧を把握するのが難しいです。
また、シークレットの機密性にも課題があります。 プライベートリポジトリでGit管理していることに甘えて、特にk8sのマニフェストにシークレットがハードコードされている部分があります。
これらの課題を踏まえ、IaCでのシークレット管理を見直すことにしました。
シークレット管理に求める要件
私がシークレット管理に求める要件は以下の3(4)点です。
- シークレットはコードベース上で分散させず統一的に管理する
- 暗号化シークレットをGitで管理できる
- 極力暗号化方式は統一する
- そもそもGitで管理するべきなのか?というものについてはおまけを参照
- Gitで管理しない手動登録シークレットも扱える
- (無料)
この要件を満たすためには大きく分けて2つのアプローチがありえます。
一つはKMSを利用することで、コード上で暗号化したシークレットをKMSにIaCで登録し、シークレットを参照する側はKMS経由でシークレットを参照する方法です。 どの要件も満たすことができますが、製品によっては料金がかかります。
もう一つは暗号化シークレットを各種IaCツールで直接参照する方法でOSSツールを適切に組み合わせれば実現できます。 例えばSOPSにはTerraformのプロバイダ・Ansibleのモジュールがある他、k8sのマニフェストもKSOPSを使えば良さそうです。 一方で、コードベース上で直接参照するため、シークレットを管理するディレクトリの変更に弱いという欠点はあります。
まとめると以下の表のようになります。
観点 | 案1 KMS経由の参照 | 案2 暗号化シークレットの直接参照 |
---|---|---|
コードベース上の統一管理 | ◯ | △ シークレットを利用する場所から離れるため構造の変更に弱い |
暗号化シークレットのGit管理 | ◯ | ◯ |
手動管理 | ◯ | ◯ |
料金 | ◯ ~ × 利用するKMSによる | ◯ |
どちらも甲乙つけがたいですが、今回は案1のKMS経由の参照を採用しました。 理由としては、すでにHashicorp Vaultを利用しており料金については考えなくてよいことと、一度KMSを経由することでシークレットの暗号化・管理は統一できることを考慮しました。
あとは単純にKMSを利用したシークレット管理はベストプラクティスでもあり試してみたかったというのもあります。
今回実装したアーキテクチャ
以上の要件を受けて、実際に今回実装したアーキテクチャが以下です。
シークレットはSOPSで暗号化しGit管理します。 この際、全てのシークレットを同じディレクトリにまとめて管理することによってシークレットの統一管理を可能にします。
このシークレットはTerraformによってHashicorp Vaultに登録することでIaCな構成管理が可能となります。 この際、外部サイトのパスワードなどこのリポジトリでライフサイクルの管理をしないシークレットは手動でVaultに登録する余地を残しています。
Vaultに登録されたシークレットは、TerraformやAnsibleの利用時に参照できる他、Vault Secrets Operatorというk8s Operatorを使用することでk8sのSecretとしても利用可能になります。
以降では、このアーキテクチャを使ったシークレット管理の方法について詳しく解説していきます。 なお、この記事ではHashicorp VaultやVault Secrets Operatorはすでに導入済みであることを前提にしています。 具体的なインストール方法については公式ドキュメントを参考にしてください。
シークレットの登録
まずはシークレットの登録方法についてです。
詳しくはSOPSのREADMEを参照して欲しいのですが、SOPSはあくまでも暗号化ツールであり、鍵自体は提供していません。 対応している鍵はパブリッククラウドのKMSの他Hashicorp Vaultなど多岐に渡りますが、今回は簡単のためageという公開鍵ベースの鍵を利用します。 brewやaptなどでインストールが可能です。
インストールしたら以下のようにして公開鍵と秘密鍵を生成します。
$ age-keygen
# created: 2025-07-03T23:50:38+09:00
# public key: {age... という形式の公開鍵}
{AGE-SECRET-KEY-... という形式の秘密鍵}
ageによる鍵の生成ができたら、SOPSの設定ファイルを作成します。
# .sops.yaml
creation_rules:
- age: 'ageの公開鍵を記載'
それでは実際に暗号化シークレットを作成してみましょう。 ここでは以下のようなシークレットを暗号化してみます。
# secret.yaml
app1:
SECRET_VALUE: "from terraform"
sopsを使って暗号化すると以下のように元のシークレットが暗号化されたファイルが生成されます(dockerでsopsを動かしていますが特に理由はありません)。
$ docker run --rm -v ./.sops.yaml:/.sops.yaml -v ./secret.yaml:/secret.yaml ghcr.io/getsops/sops:v3.10.2 encrypt --age "{ageの公開鍵を記載}" /secret.yaml > secret.enc.yaml
$ cat secret.enc.yaml
app1:
SECRET_VALUE: ENC[AES256_GCM,data:nTn91Empl/fhmio/Im4=,iv:z6vPocQjh+WWDRVhCxUF7zLOQbmRsJPyTMGCk8ULWiM=,tag:chta5ASaX5pvrlbpvpxF/g==,type:str]
sops:
age:
- recipient: {ageの公開鍵}
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYZms2NG9LWDM0V1Zxemds
d2l5MStocHpzTkxFTkR3NWVHeFh3Y2MycFJ3ClJobm9aNHNqVmRiU2UwSXdDRERu
Zmd6K2tsUnlTWi9UZmM4TU1Oc2Z4dWMKLS0tIGYwdlliRGxlMGpvcFdxUytUVlFu
MnV0U1M1N2IzbHRHeTRJR29ZNFRlZ2cKrbW7h08XCD/l+O44fpUUkCkdTpdrWPSc
vx+TOlEm3/bd8g20xToLTxW4gsz9PNOBixtB+8VL8L8MKMBk55dB9w==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2025-07-03T14:59:43Z"
mac: ENC[AES256_GCM,data:eA42Xdc54B1N2A87pTr2Tz4DbFyhwowz2NE1n3M2ThNr6HGTxqfaIiMOl3+MNtBidE4ynnDOGKPEPrbFmVLeiQsWra55C2LBHY3Xv/udrycxb5tGuDzqY+cZQD1VPOWh6Nllk1AI8YQNKFoFgFNaGpfW0m0FxKQadSAmAqi1BRM=,iv:4cw663IEKeBOLwlNDWB32S3a93VlJK+XO6KjjB41RwQ=,tag:DitYa+mZGrw0VkT7peIhdA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.10.2
なお、この暗号化ファイルはageの秘密鍵によって復号することができます。
$ docker run --rm -v ./.sops.yaml:/.sops.yaml -v ./secret.enc.yaml:/secret.enc.yaml -e SOPS_AGE_KEY="{SOPSの秘密鍵を記載}" ghcr.io/getsops/sops:v3.10.2 decrypt /secret.enc.yaml
app1:
SECRET_VALUE: from terraform
それでは実際に暗号化シークレットの内容をTerraformを使ってVaultに登録してみましょう。 Vaultにはsecret engineという種類毎のシークレット保存機能があるのですが、今回は単純なkey-valueストアであるkvv2に登録します。
以下のようなtfファイルを用いることで、SOPSで暗号化されたシークレットを参照してkvv2に登録することができます。
terraform {
# ...
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.0.0"
}
sops = {
source = "carlpett/sops"
version = "~> 1.0"
}
}
}
provider "vault" {
# ...
}
# kvv2の有効化
resource "vault_mount" "lab-secret" {
path = "lab-secret"
type = "kv"
options = { version = "2" }
}
# 現時点ではephemeral resourceではないためtfstateには保存されてしまうが...
data "sops_file" "secret" {
source_file = "./secret.enc.yaml"
}
resource "vault_kv_secret_v2" "app1" {
mount = vault_mount.lab-secret.path
name = "app1"
# yaml形式をJSON形式に変換した上で登録
# ephemeral attributeなためtfstateにシークレットの値は保存されない
data_json_wo = jsonencode(yamldecode(data.sops_file.secret.raw).app1)
}
$ SOPS_AGE_KEY="{SOPSの秘密鍵を記載}" terraform apply
Vaultを確認してみると、以下のように登録されていることが確認できます。
上記までで、暗号化されてセキュアにGit管理されたシークレットをIaCなやり方でVaultに登録することができました。
ちなみにTerraformに詳しい方ならお気づきかと思いますが、実はこのままでは完璧にセキュアとは言えません。 詳しい理由は参考サイトなどを見ていただければですが、簡単に説明するとTerraformが状態管理に使う内部ファイルにシークレットの内容が平文で保存されてしまうためです。 通常この内部ファイルはパブリックな形で保存はされませんが、漏洩してしまうと危険です。 この問題に対しての私の答えは、「高確率で近いうちに問題ではなくなると思っているので今は許容」というものですが、本筋からずれるので詳しくはおまけを参照してください。
シークレットの利用
次に実際にシークレットを利用する方法についてですが、TerraformやAnsibleからVaultに登録されたシークレットを参照する方法は複雑ではないためそれぞれの公式ドキュメントを参照してください(ここやここ)。 この記事では、Vault Secrets Operator(VSO)を使ってk8sのSecretとしてVault内部のシークレットを参照する方法について解説します。
いきなりk8sのマニフェストを見てもわかりにくいと思うので、まずはVSOがどのようにVaultのシークレットをk8sのSecretとして参照するかについて説明します。
基本的な流れとしては、シークレット作成要求をVSOが検知し、その要求に基づいて適切なシークレットをVaultから取得し、k8sのSecretとして登録するというものです。 これだけ聞くと非常にシンプルですが、これだと誰でもVaultのシークレットを参照できてしまいセキュリティ的によろしくないので、実際のところは図のような流れになります。
シークレット作成要求をVSOが検知するところまでは同じですが、Vaultのシークレットを参照するためにはVSOがVaultに対して認証を行う必要があります。 この認証には複数の方法がありますが、最も一般的な方法はk8sのServiceAccountを利用する方法です。
VSOはServiceAccountのJWTトークンをVaultに提示し、Vaultはそのトークンが有効なのか・記載されているnamespaceやServiceAccount名が正しいのかをk8sのTokenReview APIを使ってAPIサーバーに問い合わせます。 JWTトークンが有効であれば、VaultはそのServiceAccountがVaultに対してどのような権限を持っているかを確認し、シークレットの参照を許可します。
両方のチェックを通過すると、VSOはVaultからシークレットを取得し、k8sのSecretとして登録します。
以上の流れを踏まえた上で、実際にVSOを使ったSecretの作成方法を見ていきましょう。
まずはVault自体の設定からです。
# k8s認証を有効化
resource "vault_auth_backend" "kubernetes" {
type = "kubernetes"
}
# ごにょごにょしているがk8s APIサーバーのCA証明書を取得
data "http" "cluster_info" {
url = "{k8s APIサーバーのURL}/api/v1/namespaces/kube-public/configmaps/cluster-info"
request_headers = {
Accept = "application/json"
}
insecure = true
}
locals {
configmap_json = jsondecode(data.http.cluster_info.response_body)
kubeconfig_yaml = yamldecode(local.configmap_json.data.kubeconfig)
ca_data_b64 = local.kubeconfig_yaml["clusters"][0]["cluster"]["certificate-authority-data"]
ca_cert = base64decode(local.ca_data_b64)
}
# k8s認証において必要なk8s APIサーバーの接続情報を設定
resource "vault_kubernetes_auth_backend_config" "kubernetes" {
backend = vault_auth_backend.kubernetes.path
kubernetes_host = "{k8s APIサーバーのURL}"
kubernetes_ca_cert = local.ca_cert
# VSOが提示してきたJWTトークンをそのままTokenReview APIへの認証に利用する
# cf. https://developer.hashicorp.com/vault/docs/auth/kubernetes#how-to-work-with-short-lived-kubernetes-tokens
token_reviewer_jwt = ""
# VSOが提示したJWTトークンのissuerはvault側では検証しない(k8sが検証してくれる)
# cf. https://developer.hashicorp.com/vault/docs/auth/kubernetes#kubernetes-1-21
disable_iss_validation = "true"
}
# 認証可能なServiceAccountやnamespaceを指定
resource "vault_kubernetes_auth_backend_role" "vault-secrets-operator" {
backend = vault_auth_backend.kubernetes.path
role_name = "vault-secrets-operator"
bound_service_account_names = ["vault-k8s-auth"]
bound_service_account_namespaces = ["*"]
token_ttl = 3600
token_policies = [vault_policy.vault-secrets-operator.name]
audience = "vault"
}
# VSOがどのシークレットを参照できるかのポリシー
resource "vault_policy" "vault-secrets-operator" {
name = "vault-secrets-operator"
policy = <<EOT
path "lab-secret/data/*" {
capabilities = ["read", "list"]
}
EOT
}
何やら長いですが、先ほどの図でのk8s認証・TokenReview APIの部分や、その後のシークレット参照権限を設定しているだけです。
一つ注意することとして、この設定では「VSOが提示してきたServiceAccountのJWTトークン」をそのまま「TokenReview APIへの認証」に利用するという点があります。 何を言っているのかというと、Vaultのk8s認証とは独立して、TokenReview APIを利用するためにも認証・認可が必要なのですがそのためのクレデンシャルとしてVSOから渡されたものをそのまま利用しているということです。
本来この2つのトークン(クレデンシャル)は独立でも問題はなく、実際にTokenReview APIへの認証用のJWTトークンを手動で設定する項目もあるのですが、もしこの項目を設定してしまうとそのJWTトークンが失効した時にk8s認証が失敗してしまいます。 最近のk8sではServiceAccountのJWTトークンは定期的に更新されこの項目の設定は望ましくないため、VSOが提示してきたJWTトークンをそのままTokenReview APIへの認証に利用する設定をしています。 このあたりの詳細は公式ドキュメントを参照してください。1
次に、VSOがVaultに対して認証するための設定を行います。
# VSOがVaultに対して認証するための情報を記載
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: default
namespace: vault-secrets-operator
spec:
kubernetes:
# JWTトークン中に埋め込むaudience情報(≒このトークンは誰に対して提示されるものか)
audiences:
# VaultがJWTトークンの検証を行うために必要
- vault
# このトークンを使ってTokenReview APIへの認証を行うために必要
- https://kubernetes.default.svc.cluster.local
role: vault-secrets-operator
# JWTトークンを発行するServiceAccountの情報
serviceAccount: vault-k8s-auth
method: kubernetes
mount: kubernetes
vaultConnectionRef: vault-connection
---
# 接続するVaultの情報を設定するCR
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultConnection
metadata:
name: vault-connection
namespace: vault-secrets-operator
spec:
address: "{VaultのベースURLを記載}"
skipTLSVerify: true
Vault側の設定を比較すると簡単で、Vaultへの認証方法や接続情報を設定しているくらいです。 途中にあるaudience(トークンは誰に対して提示されるのか)の設定は先ほどVaultの設定で説明したような、JWTトークンをVaultのk8s認証とTokenReview APIへの認証両方に利用するために必要です。
ここまで設定し終えたらいよいよVaultのシークレットからk8sのSecretを作成することができます。
# 作成したSecretを利用するためのテストアプリ
apiVersion: apps/v1
kind: Deployment
metadata:
name: app1
spec:
replicas: 1
selector:
matchLabels:
app: app1
template:
metadata:
labels:
app: app1
spec:
containers:
- name: app
image: busybox
command: ["/bin/sh", "-c"]
args:
- |
while true; do
echo "SECRET=$SECRET_VALUE"
sleep 1
done
env:
- name: SECRET_VALUE
valueFrom:
secretKeyRef:
name: app1-secret
key: SECRET_VALUE
---
# VSOへのSecret作成要求のCR
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: app1
namespace: default
spec:
# 参照するVaultのシークレットの情報
type: kv-v2
mount: lab-secret
path: app1
vaultAuthRef: vault-secrets-operator/default
refreshAfter: 30s
# 作成するk8s Secretの設定
destination:
name: app1-secret
create: true
# この設定を入れておくことでVaultのシークレットを変更すると自動でDeploymentをrolloutしてくれる
rolloutRestartTargets:
- kind: Deployment
name: app1
---
# Vaultへの認証に使うServiceAccount
apiVersion: v1
kind: ServiceAccount
metadata:
name: vault-k8s-auth
namespace: default
---
# TokenReview APIを利用するためにServiceAccountに権限を付与
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: vault-k8s-auth-rolebinding
roleRef:
kind: ClusterRole
name: system:auth-delegator
apiGroup: rbac.authorization.k8s.io
subjects:
- kind: ServiceAccount
name: vault-k8s-auth
namespace: default
DeploymentについてはSecretを参照するためのものであり特に説明はしません。 他の3つのリソースについては、それぞれVSOへのSecret作成要求・Vaultへの認証に使うServiceAccount23・TokenReview APIを利用するための権限付与となります。
正しく設定されると、secretが無事作成され投入したSecretの値を正しく参照できていることがわかります。
$ kubectl logs deployment/app1 -f
SECRET=from terraform
SECRET=from terraform
...
なお、便利な機能としてVSOにはVault側のシークレット変更を検知して自動でアプリケーションの再起動を行ってくれる機能があります。 上記でも設定しているrolloutRestartTargetsという設定に対象のアプリを指定しておくだけでPodの再起動を行ってくれます。
実際にVaultのGUIでシークレットの値を更新してみると、しばらく経つとPodが再起動しSecretの値も更新されていることがわかります。
$ kubectl logs deployment/app1 -f
SECRET=changed!!!
SECRET=changed!!!
...
まとめ
この記事では、Vault Secrets OperatorとTerraformを使ってシークレット管理をセキュアかつIaCな方法で行う方法について解説しました。
この方法で宅鯖リポジトリからハードコードシークレットを一掃していきたいです。
おまけ:シークレットのGit管理の是非
そもそも論として、暗号化しようがしまいがシークレットをGitで管理するのってどうなの?という疑問もあるかと思います。 実際、IaCによる効果とセキュリティ考慮の手間を考慮して管理しないとしている事例も多いです(例)。
今回のユースケースは私の自宅の環境であり大した個人情報は扱っていないこと、自前運用Vaultの耐障害性はパブリッククラウドのKMSと比較して圧倒的に劣りデータがロストした際にもそのままの形で復旧できる必要があることから、シークレットをGitで管理することにしました。
おまけ:Terraformのtfstateファイルにシークレットが平文で保存されるのでは?
節タイトルの通り、いくらコードベースでの暗号化を頑張ろうがtfstateに平文で乗るのでは?という疑問もあるかと思います。 この疑問は正しく、検証されているように、sops_file dataブロックの内容やその他リソースの属性はtfstateに平文で保存されてしまいます。
しかし、Terraform 1.10でGAとなったEphemeral resourcesやその周辺機能によって、この状況は解決されつつあります。 詳しくは解説記事を参照して欲しいのですが、簡単にいうとtfstateに残らないような値を扱えるようになるという機能です。
この機能を取り入れるissue(sops provider・keycloak provider)も続々と上がっており、vaultではすでに対応済みです(Vault provider)。
このように、主要なproviderはEphemeral機能に対応する流れができているため、今後はtfstateにシークレットが平文で保存されることはなくなると考えています。
Footnotes
-
公式ドキュメントのチュートリアルではVaultをk8sクラスタ内部にインストールしており、Vault用に作成されたServiceAccountの権限でTokenReview APIへの認証が行えるようになっています。そのため、今回外部のVaultを利用するにあたっての設定はあまり書いておらず設定にはかなり苦労しました。 ↩
-
このServiceAccountの名前は、Vaultの設定のroleで許可したServiceAccount・VaultAuthで指定したServiceAccountの名前と一致させる必要があります。 ↩
-
VSOのnamespaceにServiceAccountを作成してそれを使いまわせないか?とも思いましたが、認証に使用するServiceAccountは作成するsecretと同じnamespaceである必要があるようです。 cf. 公式ドキュメント ↩