はじめに
こんにちは。プラットフォームエンジニアリングチームに所属している徳富(@yannKazu1)です。
新規プロダクトを立ち上げるとき、インフラ構築って意外とやることが多いですよね。その中でも地味にめんどくさいのがDBユーザーの作成と権限付与。手動でやると「あ、権限つけ忘れた」「このユーザー名スペルミスってない?」みたいなヒヤリハットが発生しがちです。
今回は、この作業をTerraformでIaC化した話を書いていきます。
背景:ボイラープレートでインフラ構築を爆速にしている
弊社ではTerraformのボイラープレートと、それをもとにインフラを構築するためのDevinへの指示プロンプトをセットで管理しているリポジトリがあります。
新規プロダクトのインフラが必要になったら、このリポを使ってDevinにお願いするだけ。数時間もあれば、AWSアカウントの作成、VPC・ECS・Auroraなどの基盤構築、Terraform実行に必要なIAMロール・バックエンド・CI/CDワークフローの設定に加え、Datadog設定、監査設定、ログ基盤の作成まで、必要なインフラがひととおり立ち上がります。
このボイラープレートを整備するにあたって目指したのは、「Devinにお願いするだけで、新規プロダクトのインフラを簡単に作れる状態」でした。ところが、DBユーザーの作成だけはどうしても手動作業が残ってしまっていました。
せっかくDevinに投げれば数時間でインフラができるのに、DBユーザーだけは人間が踏み台経由でDBに繋いで CREATE USER して GRANT して……とやらないといけない。これだとボイラープレートの意味が薄れてしまいますし、手動オペレーションにはミスのリスクもあります。
ここをTerraformでIaC化できれば、ボイラープレートにサクッと組み込めて、Devinに任せるだけでインフラ構築が完結するようになります。
なぜTerraform?
DBユーザーの管理といえばAnsibleを使うパターンも考えました。ただ、弊社のインフラは基本的にTerraformで一元管理しているので、できればTerraformの中で完結させたい。ツールが増えると学習コストも運用コストも増えますし、ボイラープレートにAnsibleのステップを追加するよりも、Terraformモジュールとして組み込む方がシンプルです。
というわけで、Terraformでなんとかする方向で調査を進めました。
Aurora Data APIという選択肢
弊社ではAurora MySQLを使用しており、バージョン3.07以降でRDS Data APIに対応しています。
Data APIは、HTTPSエンドポイント経由でSQLを実行できるAPIです。従来のようにVPC内から直接DBに接続する必要がなく、AWS CLIやSDKからサクッとSQLを叩けます。
これを terraform_data リソースの local-exec プロビジョナーと組み合わせれば、Terraformの中からDBユーザーを作成できるというわけです。
モジュールの実装
実際に作ったTerraformモジュールを紹介します。
DBユーザーの作成・削除
resource "terraform_data" "db_user" { input = { rds_cluster_arn = var.rds_cluster_arn rds_secret_arn = var.rds_secret_arn database_name = var.database_name username = var.username ssm_parameter_name = var.ssm_parameter_name } provisioner "local-exec" { command = <<-EOT PASSWORD=$(aws ssm get-parameter --name "${self.input.ssm_parameter_name}" --with-decryption --query 'Parameter.Value' --output text) aws rds-data execute-statement \ --resource-arn "${self.input.rds_cluster_arn}" \ --secret-arn "${self.input.rds_secret_arn}" \ --database "${self.input.database_name}" \ --sql "CREATE USER IF NOT EXISTS '${self.input.username}'@'%' IDENTIFIED BY '$PASSWORD'" EOT } provisioner "local-exec" { when = destroy command = <<-EOT aws rds-data execute-statement \ --resource-arn "${self.input.rds_cluster_arn}" \ --secret-arn "${self.input.rds_secret_arn}" \ --database "${self.input.database_name}" \ --sql "DROP USER IF EXISTS '${self.input.username}'@'%'" EOT } }
ポイントは以下のとおりです。
terraform_dataリソースを使って、local-execプロビジョナーでData API経由のSQLを実行CREATE USER IF NOT EXISTSで冪等性を担保when = destroyのプロビジョナーで、terraform destroy時にユーザーを自動削除- パスワードはSSM Parameter Storeから取得(後述の工夫で安全に管理)
権限の付与・取り消し
resource "terraform_data" "db_grant" { for_each = { for idx, grant in var.grants : idx => grant } depends_on = [terraform_data.db_user] input = { rds_cluster_arn = var.rds_cluster_arn rds_secret_arn = var.rds_secret_arn database_name = var.database_name username = var.username privileges = each.value.privileges grant_database = coalesce(each.value.database, var.database_name) grant_table = coalesce(each.value.table, "*") } provisioner "local-exec" { command = <<-EOT aws rds-data execute-statement \ --resource-arn "${self.input.rds_cluster_arn}" \ --secret-arn "${self.input.rds_secret_arn}" \ --database "${self.input.database_name}" \ --sql "GRANT ${self.input.privileges} ON ${self.input.grant_database}.${self.input.grant_table} TO '${self.input.username}'@'%'" EOT } provisioner "local-exec" { when = destroy command = <<-EOT aws rds-data execute-statement \ --resource-arn "${self.input.rds_cluster_arn}" \ --secret-arn "${self.input.rds_secret_arn}" \ --database "${self.input.database_name}" \ --sql "REVOKE ${self.input.privileges} ON ${self.input.grant_database}.${self.input.grant_table} FROM '${self.input.username}'@'%'" || true EOT } }
for_each で権限セットを柔軟に定義できるようにしています。DMLとDDLを分けて付与したい、みたいなケースにも対応可能です。
使い方
モジュールの呼び出しはこんな感じです。
module "db_user_app" { source = "../../modules/db_user" rds_cluster_arn = module.aurora_main.cluster_arn rds_secret_arn = module.aurora_main.cluster_master_user_secret[0].secret_arn database_name = "hoge_db" username = "hoge_app_user" ssm_parameter_name = aws_ssm_parameter.app_db_password.name depends_on = [ aws_ssm_parameter.app_db_password, module.aurora_main, ] grants = [ { # DML権限: データの読み書き privileges = "SELECT, INSERT, UPDATE, DELETE" }, { # DDL権限: マイグレーション実行用(テーブル作成・変更・削除、インデックス操作) privileges = "CREATE, ALTER, DROP, INDEX, REFERENCES" } ] }
DMLとDDLの権限を分けて書けるのが個人的に気に入っています。どの権限を付与しているかが一目でわかりますし、将来的に読み取り専用ユーザーを追加したいときもモジュールを再利用するだけでOKです。
パスワードをstateに残さない工夫
ここが今回一番こだわったポイントです。
Terraformでパスワードを扱うとき、普通にやるとstateファイルにパスワードが平文で残ってしまいます。sensitive = true をつけても、plan出力でマスクされるだけでstateには書き込まれてしまう。これはセキュリティ的によろしくないですよね。
そこで活用したのが、Terraform 1.10で導入された ephemeral リソースと、Terraform 1.11で導入された value_wo(write-only引数) です。
ephemeral "random_password" "app_db" { length = 32 special = false } resource "aws_ssm_parameter" "app_db_password" { name = "/${var.app_name}/${var.env}/db/app_user_password" type = "SecureString" value_wo = ephemeral.random_password.app_db.result value_wo_version = 1 }
ephemeral リソースとは
Terraform 1.10で登場した新しいリソースタイプです。通常の resource や data と違い、planやstateに一切値が保存されません。毎回のplan/apply時に一時的に生成され、使い終わったら破棄されます。
ephemeral "random_password" を使うことで、パスワードの生成結果がstateに残ることを防げます。従来の resource "random_password" だと、生成したパスワードがstateファイルにそのまま記録されてしまっていたので、これは大きな改善です。
value_wo(write-only引数)とは
Terraform 1.11で導入されたwrite-only引数の仕組みです。aws_ssm_parameter リソースの value_wo を使うと、SSM Parameter Storeへの書き込みは行われますが、その値がTerraformのstateやplanファイルに保存されることはありません。
value_wo_version は変更検知のためのバージョン番号です。パスワードをローテーションしたい場合はこの値をインクリメントすれば、次のapply時に新しいパスワードが生成・設定されます。逆にバージョンを変えなければ、ephemeral が毎回新しいパスワードを生成しても、SSM Parameter Storeの値は更新されません。
この2つを組み合わせることで、パスワードの生成から保存まで、一度もstateファイルにパスワードが記録されないフローが実現できました。
現時点での制約:パスワードの更新には対応していない
ここまで読んで「パスワードのローテーションはどうするの?」と思った方もいるかもしれません。正直に言うと、このモジュールはパスワードの更新(ALTER USER)には対応していません。
理由はシンプルで、Terraformのプロビジョナーには when = create と when = destroy しかなく、when = update が存在しないからです。つまり、リソースの入力値が変わったときに「更新用のSQLを実行する」ということができません。
terraform_data の input が変わると、Terraformは古いリソースをdestroyしてから新しいリソースをcreateする(replace)動きになります。DBユーザーの場合、これは DROP USER → CREATE USER という流れになるので、パスワード変更だけしたいケースには少々大げさです。
この when = update の追加については、HashiCorpのGitHubリポジトリにIssueが上がっています(hashicorp/terraform#35825)。いつか実装されれば、パスワードローテーションもこのモジュールの中でスマートに対応できるようになるはずです。それまでは、パスワードの更新が必要になった場合は手動対応か、別の仕組みで対応する必要があります。
とはいえ、新規プロダクト立ち上げ時の初期ユーザー作成という用途においては、create/destroyだけで十分に機能しています。
まとめ
やったことをまとめると以下のとおりです。
- Aurora MySQLのData API(3.07以降で利用可能)を使って、Terraformの
terraform_data+local-execでDBユーザーの作成・権限付与をIaC化 - Terraform 1.10の
ephemeralリソースと1.11のvalue_woを活用して、パスワードをstateに残さないセキュアな構成を実現 - これらをモジュール化してボイラープレートに組み込み、新規プロダクトのインフラ構築をさらにスムーズに
- プロビジョナーに
when = updateがないため、パスワードの更新には現時点では未対応
Terraformの ephemeral や value_wo はまだ比較的新しい機能なので、知らない方も多いかもしれません。パスワード管理で困っている方はぜひ試してみてください。
参考リンク
- Amazon Aurora MySQL で RDS Data API のサポートを開始
- RDS Data API でサポートされているリージョンと Aurora DB エンジン - Amazon Aurora
- Terraform 1.11 brings ephemeral values to managed resources with write-only arguments
- Ephemeral values in Terraform
- Ephemeral values in resources | Terraform | HashiCorp Developer
- Use temporary write-only arguments | Terraform | HashiCorp Developer
- I would like provisioner/s to support when=update - hashicorp/terraform#35825