Terraform 実践 #5 RDS と S3 — データ層のコード化と安全装置

読了 4分

ウェブ層が立ち上がったので、次はデータ層です。今回のリソース自体は難しくありませんが、データを抱えるリソースはミスの代償が違うという緊張感が初めて登場する回です。ウェブサーバーは消しても作り直せばすみますが、データベースはそうはいきません。だからこそ、基礎 #7 で学んだ安全装置が今回、実戦配備されます。

DB サブネットグループとセキュリティグループ #

RDS はサブネットを直接指定するのではなく、DB サブネットグループというまとまりを受け取ります。プライベートサブネットで作成します。

db.tf
# db.tf(新しいファイル)
resource "aws_db_subnet_group" "main" {
  name_prefix = "myapp-${var.env}-"
  subnet_ids  = [for s in aws_subnet.private : s.id]
}

resource "aws_security_group" "db" {
  name_prefix = "myapp-${var.env}-db-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.web.id]   # ウェブ層からのみ
  }

  lifecycle {
    create_before_destroy = true
  }
}

#3 で決めたチェイニングの原則が、もう 1 段下りてきました。インターネット → ALB SG → web SG → db SG。データベースの 5432 番ポートはウェブ層のセキュリティグループから来るトラフィックだけを受け付け、どの CIDR にも開いていません。トラフィックの通り道全体が、セキュリティグループ参照の連鎖としてコードに残っています。

RDS インスタンス #

db.tf
resource "aws_db_instance" "main" {
  identifier_prefix = "myapp-${var.env}-"

  engine         = "postgres"
  engine_version = "17"
  instance_class = "db.t4g.micro"

  allocated_storage = 20
  storage_type      = "gp3"

  db_name  = "myapp"
  username = "myapp"
  password = var.db_password        # 一時的な措置。次回で置き換えます

  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.db.id]

  backup_retention_period = 7
  skip_final_snapshot     = true    # 実習用。運用では false

  lifecycle {
    prevent_destroy = false         # 実習用。運用では true
  }
}

1 行 1 行が設計上の判断です。

  • db.t4g.micro、gp3 20GB: 実習用の最小サイズです。インスタンスクラスの選び方は RDS インスタンスクラス比較にまとめてあります。
  • backup_retention_period = 7: 自動バックアップ 7 日です。バックアップのないデータベースは運用とは呼べないので、実習でも有効にする習慣をつけます。
  • skip_final_snapshot = true: destroy 時に最終スナップショットを残しません。実習では作って消してを繰り返すので true ですが、運用では false にして、削除の瞬間にもスナップショットが残るようにします。
  • prevent_destroy: 同じ理由で実習ではオフ、運用ではオンにします。この 2 つの値が環境によって変わるという事実そのものが、#9 の環境分離の予告です。

apply には数分かかります。RDS の作成はもともと遅く、Terraform はリソースが利用可能な状態になるまで待ってから次に進みます。

静的アセット用の S3 バケット #

画像や CSS などの静的アセットを置くバケットです。基礎 #8 で作ったものと同じ構成(バージョニング + パブリックアクセスブロック)なので、コードは要点だけ示します。

storage.tf
# storage.tf(新しいファイル)
resource "aws_s3_bucket" "assets" {
  bucket = "myapp-${var.env}-assets-${data.aws_caller_identity.current.account_id}"
}

resource "aws_s3_bucket_public_access_block" "assets" {
  bucket                  = aws_s3_bucket.assets.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

バケット名の末尾にアカウント ID を付けるのは実務の定番です。バケット名は世界中で一意である必要がありますが、data.aws_caller_identity(基礎 #4)で取得したアカウント ID を付ければ、名前の衝突を心配せずに、どのアカウントでも同じコードが動きます。パブリックアクセスをブロックしたのに静的アセットをどう配信するのか疑問が残るはずですが、答えは #10 の CloudFront です。バケットは閉じたままにして CDN だけに読ませるのが現在の標準です。

残した問題 — パスワードが state にあります #

var.db_password は sensitive 変数として宣言してあります。

variables.tf
variable "db_password" {
  type      = string
  sensitive = true
}

plan の画面では (sensitive value) と表示されて隠されます。しかし 基礎 #5 で確認したとおり、state ファイルの中にはこのパスワードが平文で保存されています。直接確かめることもできます。terraform state show aws_db_instance.main を実行すると、password がそのまま見えます。state バケットにアクセスできるすべての人とシステムが、運用 DB のパスワードを読めるということです。tfvars ファイルで値を渡しているなら、そのファイルも平文です。この問題を場当たり的にではなく構造的に解決するのが、次回まるごとのテーマです。

まとめ #

今回扱った内容です。

  • RDS は DB サブネットグループでプライベートサブネットに置き、セキュリティグループのチェイニングを db 層まで延長しました。5432 番は web SG に対してだけ開きます
  • バックアップ 7 日は実習でも有効にします。skip_final_snapshot と prevent_destroy は実習と運用で逆の値を取り、この違いが環境分離の必要性を示します
  • アセットバケットの名前にはアカウント ID を付けて衝突を避けます。パブリックアクセスブロックは維持し、配信は #10 の CloudFront が担います
  • sensitive は画面を隠すだけです。DB パスワードが state と tfvars に平文で残っている問題を確認し、次回で構造的に解決します

次回(#6 シークレット管理)では、SSM Parameter Store と Secrets Manager を比較し、Terraform 1.11 の write-only 引数でパスワードが state にいっさい残らない構成を作ります。

X