Terraform 実践 #6 シークレット管理 — SSM vs Secrets Manager、write-only 引数

読了 5分

前回の最後の確認は、落ち着かないものでした。sensitive を付けたのに、DB パスワードは state に平文で残り、tfvars にも平文で残っています。今回はこの問題を 3 段階で解決します。シークレットを置く場所を決め(保管先)、Terraform がシークレットを扱いながらも記録に残さないようにし(write-only)、アプリケーションがシークレットを受け取る道筋を用意します(ランタイム参照)。3 つのうち 1 つでも欠けると、どこかに平文が残ります。

保管先の選択 — SSM Parameter Store vs Secrets Manager #

AWS でシークレットの保管先候補は 2 つです。

項目SSM Parameter StoreSecrets Manager
料金標準パラメータは無料シークレットごとの月額 + API 呼び出し
暗号化SecureString(KMS)デフォルトで KMS 暗号化
自動ローテーションなし(自前で実装)RDS などと統合されたローテーションを内蔵
向いている用途設定値、小規模なシークレットローテーションが必要な DB 認証情報

選択基準はローテーションです。パスワードを定期的に自動交換する要件があるなら Secrets Manager がその機能を内蔵していますし、その要件がなければ無料の SSM SecureString で十分です。この実習はコストの原則に従って SSM で進めますが、コードの構造はどちらでも同じなので、あとから移すのは難しくありません。

ephemeral と write-only — 記録に残らない値 #

Terraform 1.10 と 1.11 で、この問題のための言語機能が正式に入りました。部品は 2 つです。

  • ephemeral リソース: plan にも state にも記録されない一回限りの値です。ephemeral "random_password" は、実行中にだけ存在するパスワードを作ります。
  • write-only 引数: 値を provider に渡すだけで記録しない引数です。aws_db_instance の password_wo がそれで、通常の password 引数と違って state に保存されません。

2 つを組み合わせると、パスワードの一生が記録の外で完結します。

secrets.tf
# secrets.tf(新しいファイル)
ephemeral "random_password" "db" {
  length  = 24
  special = false
}

resource "aws_ssm_parameter" "db_password" {
  name             = "/myapp/${var.env}/db-password"
  type             = "SecureString"
  value_wo         = ephemeral.random_password.db.result
  value_wo_version = 1
}
db.tf
# db.tf を修正
resource "aws_db_instance" "main" {
  # ... 残りは前回のまま ...

  password_wo         = ephemeral.random_password.db.result
  password_wo_version = 1
}

前回の password = var.db_password と variable "db_password" は削除します。もうパスワードは、人が決めることも、ファイルに書かれることもありません。実行中に生成されて RDS と SSM に渡され、消えます。terraform state show aws_db_instance.main をもう一度実行してみると、password は見えません。

_wo_version という見慣れない相方が付く理由も覚えておきます。write-only の値は state にないため、Terraform は値の変更を検知できません。そのため、パスワードを交換したいときは値を変えるのではなく、password_wo_version を 1 から 2 に上げます。バージョン番号の変更が plan に載り、provider が新しい値を渡す仕組みです。

アプリケーションはランタイムに読みます #

シークレットが SSM に安全に入っても、それを user_data で取り出してファイルに埋め込めば、インスタンスの中に平文のコピーが生まれて振り出しに戻ります。原則は、アプリが起動時に保管先から直接読むことです。

シークレット取得
# アプリケーションの起動スクリプトで
DB_PASSWORD=$(aws ssm get-parameter \
  --name "/myapp/dev/db-password" \
  --with-decryption \
  --query Parameter.Value --output text)

この構造の利点は、シークレットの交換がデプロイと切り離されることです。SSM の値を変えてアプリを再起動すれば終わりで、イメージや launch template を作り直す必要はありません。残るパズルのピースは権限です。インスタンスが ssm:GetParameter を呼ぶには IAM ロールが必要ですが、今のインスタンスにはロールがありません。これがまさに次回のテーマで、SSH なしの接続(Session Manager)まで一緒に解決します。

残る記録とリスクの整理 #

この構成で何が消えて何が残るのか、正直に整理しておきます。

  • 消えたもの: コード、tfvars、plan、state のパスワード平文です。人がパスワードを知る段階そのものがありません。
  • 残るもの: SSM パラメータ(KMS 暗号化で保存)と RDS の内部です。この 2 つはシークレットが本来あるべき場所で、アクセスは IAM で統制します。
  • 変わらない原則: 基礎 #9 の state バケットのアクセス制御は、これまでどおり重要です。write-only が DB パスワードを消しても、state にはインフラの構造全体が入ったままだからです。

まとめ #

今回扱った内容です。

  • 保管先の選択基準はローテーションです。必要なら Secrets Manager、不要なら無料の SSM SecureString で十分です
  • ephemeral リソースは実行中にだけ存在する値を作り、write-only 引数(password_wo、value_wo)は値を渡すだけで state に残しません。どちらも Terraform 1.11 の正式機能です
  • write-only の値の交換は、値の変更ではなく _wo_version の増加でトリガーします。state に値がなく、変更を検知できないためです
  • アプリはシークレットをランタイムに保管先から直接読みます。user_data に埋め込むと平文のコピーが復活します
  • シークレットは今や SSM と RDS の中にだけ存在し、残る課題は読み取り権限を与える IAM です

次回(#7 IAM のコード化)では、インスタンスロールで SSM アクセスと Session Manager 接続を完成させ、GitHub Actions がアクセスキーなしで AWS に入る OIDC 連携まで作ります。

X