↓メインコンテンツへスキップ
  1. Blogs/

TerraformでGKEのPodにSecret Managerの値をファイルとしてマウントしてみた

3 分
Terraform GoogleCloud GKE SecretManager
0222-nnn
著者
0222-nnn
猫が好き
目次
Terraform-GoogleCloud - この記事は連載の一部です
パート 32: この記事

概要
#

Terraformのwrite-only argumentでSecret Managerへ値を登録するでは、シークレットを保管するところまでを扱いました。保管した値をPodからどう取り出すかは残ったままです。

環境変数で渡す方法は手軽ですが、kubectl describe podやプロセス一覧から値が見えてしまいます。GKEのSecret Manager add-onを使うと、Podのファイルシステムにマウントする形で渡せます。

ただ「マウントできた」だけでは運用に足りません。シークレットを更新したら自動で反映されるのか。権限を剥がしたら動いているPodはどうなるのか。ログに何が残るのか。運用に入ってから調べ直すのでは遅いので、この3点を先に確かめておく必要があります。

対象読者は、GKEでPodを動かしたことがあり、Secret Managerに値を入れたことがある人です。それぞれの入門は扱いません。

環境変数への同期(secretObjects)は扱いません。シークレットの自動ローテーションも扱いません。

検証すること
#

  • Podの中でマウントしたファイルが読めること。Pod specに値が現れないこと
  • シークレットを更新したとき、稼働中のPodに自動で反映されるか
  • IAM権限を剥がしたとき、稼働中のPodと新しいPodがそれぞれどうなるか
  • Cloud Loggingに何が残るか

前提環境
#

  • Google Cloud CLI、Terraform、Docker
  • Billingが有効な検証用Google Cloud Project
  • GKE、Secret Manager、Compute Engineを操作できるGoogleアカウント
  • 検証時のバージョン:Terraform 1.14.3、google 7.46.0

使用するTerraformコード
#

今回の構成
#

flowchart TB
    subgraph GCP["Google Cloud"]
        SM["Secret Manager
(値はTerraform管理外)"] subgraph Project["Project"] subgraph VPC["VPC"] subgraph GkeSubnet["GKE Subnet(外部IPなし)"] CSI["CSIドライバ
DaemonSet"] Pod["Pod
/var/secrets/db-password.txt"] end Bastion["踏み台"] end end end CSI -->|"Private Google Access"| SM CSI -->|"ファイルとして配置"| Pod

ノードには外部IPがありません。Secret Manager APIへはPrivate Google Access経由で到達します。

設計上の2つの選択
#

値をTerraformで管理しない
#

google_secret_manager_secret_versionのsecret_dataを使うと、平文がterraform.tfstateに残ります。器だけをTerraformで作り、値は別で投入します。

resource "google_secret_manager_secret" "demo" {
  secret_id = var.secret_id

  replication {
    auto {}
  }
}
printf '%s' 'super-secret-value' | gcloud secrets versions add \
  "$(terraform output -raw secret_id)" --data-file=- \
  --project="$(terraform output -raw project_id)"

--data-file=-で標準入力から読ませると、シェル履歴にもファイルにも値が残りません。

Google Service Accountを作らない
#

Workload Identity Federation for GKEを使います。KubernetesのServiceAccountを、そのままIAMのprincipalとして書けます。

locals {
  ksa_principal = "principal://iam.googleapis.com/projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${var.project_id}.svc.id.goog/subject/ns/${var.k8s_namespace}/sa/${var.k8s_service_account_name}"
}

resource "google_secret_manager_secret_iam_member" "ksa_accessor" {
  secret_id = google_secret_manager_secret.demo.secret_id
  role      = "roles/secretmanager.secretAccessor"
  member    = local.ksa_principal
}

GSAの作成も、iam.gke.io/gcp-service-accountアノテーションも、鍵JSONも要りません。KSA側のマニフェストはこれだけです。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-ksa
  namespace: default

principalの文字列には名前空間とKSA名が埋め込まれます。マニフェストとTerraform変数がずれると権限が付きません。

ノードは e2-medium 以上が必要
#

最初e2-smallで作ったところ、Podが起動しませんでした。

0/1 nodes are available: 1 Insufficient cpu.

ノードの割り当て状況を見ると理由が分かります。

kubectl describe node | grep -A 4 'Allocated resources'
  Resource           Requests          Limits
  --------           --------          ------
  cpu                935m (99%)        8 (851%)

CSIドライバのDaemonSetとGKE標準コンポーネントだけで、割り当て可能CPUの99%が埋まっていました。e2-mediumに上げて解決しています。

マウントできることを確認する
#

SecretProviderClassでどのシークレットをどのファイル名で出すかを指定します。

apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: gcp-sm-demo
spec:
  provider: gke
  parameters:
    secrets: |
      - resourceName: "projects/PROJECT_ID/secrets/SECRET_ID/versions/latest"
        path: "db-password.txt"

Deployment側はcsiボリュームとしてマウントします。

      volumes:
        - name: secret-vol
          csi:
            driver: secrets-store-gke.csi.k8s.io
            readOnly: true
            volumeAttributes:
              secretProviderClass: gcp-sm-demo
kubectl exec deploy/app-secret-file -- ls -l /var/secrets/
kubectl exec deploy/app-secret-file -- cat /var/secrets/db-password.txt
lrwxrwxrwx    1 root     root            22 Sep  3 23:18 db-password.txt -> ..data/db-password.txt
tf-adv-demo-secret-value-2026

投入した値が読めました。Pod specに値が現れないことも確認します。

kubectl get pod -l app=app-secret-file -o yaml | grep -c 'tf-adv-demo-secret-value'
0

specに出るのはSecretProviderClassの名前だけです。

シークレットを更新しても自動では反映されない
#

versions/latestを指定しているので、新しいバージョンを追加すれば反映されそうに見えます。実際はどうか試します。

printf '%s' 'UPDATED-value-v2' | gcloud secrets versions add \
  "$(terraform output -raw secret_id)" --data-file=- \
  --project="$(terraform output -raw project_id)"
Created version [2] of the secret [tf-adv-smcsi-demo].

30秒、90秒、180秒と間隔を空けて確認しました。

経過30s後: tf-adv-demo-secret-value-2026
経過90s後: tf-adv-demo-secret-value-2026
経過180s後: tf-adv-demo-secret-value-2026

5分近く待っても更新されません。 反映にはPodの作り直しが必要です。

kubectl rollout restart deploy/app-secret-file
kubectl rollout status deploy/app-secret-file --timeout=240s
kubectl exec deploy/app-secret-file -- cat /var/secrets/db-password.txt
UPDATED-value-v2

ローテーションを組む場合、シークレットを更新するだけでは足りず、Podの再起動までを手順に入れる必要があります。

権限を剥がしたときの挙動
#

IAMバインディングが本当に効いているかは、付いている状態で成功することだけでは確かめられません。外して試します。

terraform apply -var="grant_ksa_secret_access=false"

稼働中のPodは影響を受けない
#

kubectl exec deploy/app-secret-file -- cat /var/secrets/db-password.txt
UPDATED-value-v2

権限を失った後も読めます。マウント済みのボリュームはノード上に保持されているためです。

影響が出るのはPodを作り直したとき
#

kubectl rollout restart deploy/app-secret-file
kubectl get pods -l app=app-secret-file
NAME                               READY   STATUS              RESTARTS   AGE
app-secret-file-7599cff88f-9qcnk   1/1     Running             0          3m36s
app-secret-file-79b4454f4c-rnftx   0/1     ContainerCreating   0          90s

古いPodは動いたまま、新しいPodがContainerCreatingで止まっています。

kubectl describe pod -l app=app-secret-file | grep -A 5 Events
Warning  FailedMount  kubelet  MountVolume.SetUp failed for volume "secret-vol" :
  rpc error: code = PermissionDenied desc = Permission 'secretmanager.versions.access'
  denied on resource (or it may not exist).
  ... reason = IAM_PERMISSION_DENIED domain = iam.googleapis.com

この挙動は運用上おさえておく価値があります。権限を誤って剥がしても即座には気づけず、次のデプロイやノード入れ替えで初めて表面化するということです。

権限を戻すと、詰まっていたPodは自動的に起動します。

terraform apply

Cloud Loggingに何が残るか
#

CSIドライバのログ
#

成功・失敗の両方が残ります。

gcloud logging read \
  'resource.type="k8s_container" AND resource.labels.pod_name=~"csi-secrets-store"' \
  --limit=5 --freshness=30m --format="value(timestamp,jsonPayload.message)"
"node publish volume complete" targetPath="/var/lib/kubelet/pods/.../mount" pod="default/app-secret-file-79b4454f4c-rnftx" time="277.0441ms"

Pod名と所要時間まで出るので、マウント遅延の切り分けにも使えます。

Secret Managerの監査ログには読み取りが残らない
#

gcloud logging read \
  'protoPayload.serviceName="secretmanager.googleapis.com"' \
  --limit=20 --freshness=30m --format="value(protoPayload.methodName)" | sort -u
google.cloud.secretmanager.v1.SecretManagerService.AddSecretVersion
google.cloud.secretmanager.v1.SecretManagerService.CreateSecret
google.cloud.secretmanager.v1.SecretManagerService.DeleteSecret
google.cloud.secretmanager.v1.SecretManagerService.SetIamPolicy

シークレットの作成、値の追加、権限の変更、削除は残っています。一方でAccessSecretVersion、つまり値の読み取りは記録されていません。

管理操作は追える一方、「誰がいつシークレットの値を読んだか」は既定では分かりません。追跡が必要ならデータアクセス監査ログを明示的に有効化します(既定で無効、かつ課金対象)。

マウントのバリエーション
#

複数のシークレットを1つのボリュームに
#

SecretProviderClassにresourceNameを並べると、同じボリューム内に別ファイルとして配置されます。

    secrets: |
      - resourceName: "projects/PROJECT_ID/secrets/SECRET_ID/versions/latest"
        path: "db-password.txt"
      - resourceName: "projects/PROJECT_ID/secrets/SECRET_ID_2/versions/latest"
        path: "api-key.txt"
kubectl exec deploy/app-multi-secret -- ls /var/secrets/
api-key.txt
db-password.txt

版を固定する
#

versions/latestではなくversions/1のように明示すると、その版を保持し続けます。

kubectl exec deploy/app-pinned-secret -- cat /var/secrets/db-password-v1.txt
kubectl exec deploy/app-secret-file   -- cat /var/secrets/db-password.txt
tf-adv-demo-secret-value-2026
UPDATED-value-v2

版を固定したPodは、latest側が更新されても影響を受けません。ローテーション時に意図せず新しい値を拾わせたくない場合に使えます。

プライベートクラスタからの到達
#

ノードに外部IPがない構成ですが、Secret Manager APIへは問題なく到達できています。

gcloud compute instances list --filter="name~gke-tf-adv-gke-smcsi" \
  --format="table(name,networkInterfaces[0].accessConfigs[0].natIP)"
NAME                                                 NAT_IP
gke-tf-adv-gke-smcsi-tf-adv-gke-smcsi-a20e7a19-anrj

NAT_IPは空です。サブネットのprivateIpGoogleAccessが有効なため、Google APIへはこの経路で到達します。追加のVPCエンドポイントは要りません。

後片付け
#

terraform destroy
Destroy complete! Resources: 27 destroyed.

GKE・踏み台VM・Cloud NATは利用中に料金が発生します。Secret Managerのシークレットとその全バージョンも削除されます。

まとめ
#

  • ファイルマウントならPod specにシークレットの値が現れない。specに出るのはSecretProviderClassの名前だけ
  • versions/latestを指定しても自動では更新されない。 ローテーションにはPodの再起動が必要
  • 権限を剥がしても稼働中のPodは読み続ける。 影響が出るのは次にPodを作り直したときで、そこで初めてFailedMountになる
  • 監査ログに値の読み取りは残らない。 管理操作は追えるが、AccessSecretVersionはデータアクセス監査ログを有効化しないと記録されない
  • CSIドライバのDaemonSetはCPUを消費する。e2-smallではアプリのPodが載らない
  • Google Service Accountを作らず、KSAを直接IAM principalにできる。鍵JSONもアノテーションも不要

参考資料
#

Terraform-GoogleCloud - この記事は連載の一部です
パート 32: この記事

関連記事

GKEノードのブートディスクをCMEKで暗号化して鍵をローテーションしてみた
6 分
Terraform GoogleCloud GKE CloudKMS CMEK
TerraformでGKEのスタンドアロンNEGを消したらLBのバックエンドが戻らなかった話
3 分
Terraform GoogleCloud GKE LoadBalancing
Peering越しのPod→VM通信が落ちる原因を、ルートだと思い込んでいた話
4 分
Terraform GoogleCloud GKE VPC
TerraformでGKE Dataplane V2を有効化し、NetworkPolicyでPod間通信を遮断してみた
2 分
Terraform GoogleCloud GKE NetworkPolicy
TerraformでGKEのノードあたり最大Pod数を小さくしすぎるとどうなるか試してみた
2 分
Terraform GoogleCloud GKE
TerraformでGKEのPodをInternal HTTP LB(NEG)でポート別に振り分けてみた
3 分
Terraform GoogleCloud GKE LoadBalancing