S3: バケット設計とtfstate管理の定石
前章で VPC とサブネットができ、ALB・ECS・RDS を置く場所の下地が整いました。この章で扱うのはワークロードそのものではなく、S3です。Tasuku における S3 の役目は 3 つあります。ユーザーがアップロードする添付ファイルの保管、ALB のアクセスログの保管、そして Terraform の state ファイルの保管です。1 このうち ALB のアクセスログ用バケットは、ALB 自体を作る第 6 章で用意します。扱うのは残る 2 つ、添付ファイル用バケットと、tfstate の保管先としての S3 です。
第 2 章では、Terraform の state をローカルのファイルとして扱うところから始めました。2 複数人での共同作業や、state 自体の安全な保管が必要になれば、いずれ S3 へ移すことになるとそのときに予告しています。3 この章はその約束を果たす章でもあります。
5.1. S3の基礎とバケット設計原則
S3 (Simple Storage Service) は、ファイルをオブジェクトという単位で保存するストレージサービスです。オブジェクトはバケットというコンテナに入り、バケットの中ではキーという文字列でオブジェクトを一意に識別します。VPC のようにサブネットで区切る構造は持たず、バケットとオブジェクトの 2 階層だけで完結します。
バケット名には、他の AWS リソースにはない制約があります。同じ AWS アカウント内で一意であればよいサブネット名などとは違い、バケット名は同じパーティション内の全AWSアカウントを通じて一意でなければなりません。4 パーティションとは商用リージョン (aws) や中国リージョン (aws-cn) のような AWS の区分であり、Tasuku が使うap-northeast-1は商用パーティションに属します。他のアカウントが既に使っている名前は選べないため、この章で作る 2 つのバケットには、名前の末尾に AWS アカウント ID を付けて一意性を確保します。
S3 のバケットには、4 つの設計原則があります。
- Block Public Access: バケットやオブジェクトが意図せず公開されることを防ぐ、4 つの独立した設定です。
- 暗号化: 保存するオブジェクトを暗号化します。
- バージョニング: 上書きや削除で失われる旧バージョンのオブジェクトを保持します。
- ライフサイクル: 一定期間が経過したオブジェクトを自動的に削除したり、別のストレージクラスへ移したりします。
この章で作る 2 つのバケットは、どちらもこの 4 原則に沿って設定しますが、原則の適用範囲は用途によって変わります。次の節で、2 つのバケットそれぞれの役割を整理します。
5.2. この章で作る2つのバケット
| バケット | 用途 | 作る場所 |
|---|---|---|
| tfstate用 | Terraformのstateファイルの保管先 | bootstrap/ (この章で新設) |
| 添付ファイル用 | ユーザーがアップロードするファイルの保管先 | main/ (既存のvpc.tf等と同じ場所) |
tfstate 用のバケットは、main/とは別のbootstrap/という場所に作ります。理由は 5.4 節で説明します。添付ファイル用のバケットは、これまでの章と同じくmain/にs3.tfとして追加します。
ALB のアクセスログ用バケットは、この表に含まれていません。ログの送信元となる ALB がまだ存在しない段階でバケットだけを作っても、バケットポリシーの設計まで含めて検証できないためです。第3章で IAM ロールの実体を予告表にとどめ、使う章に実装を委ねたのと同じ考え方です。ALB のアクセスログ用バケットとそのバケットポリシーは、ALB を作る第 6 章で扱います。
5.3. tfstateの鶏と卵の問題
第 2 章で、Terraform の state はローカルのファイルとして扱ってきました。2 個人の検証環境ではこれで足りますが、複数人で apply を行う実務では 2 つの問題が生じます。1 人が apply している間に別の人が同時に apply すると、state の内容が競合して壊れる可能性があります。加えて、ローカルのファイルのままでは、誤って削除したときに復旧する手段がありません。
state 共有の定石には、大きく 3 つの選択肢があります。
1 つ目は、ローカルファイルのまま運用し、Git などでの共有は避ける方法です。個人開発の検証環境では問題になりませんが、複数人が同時に apply する状況には向きません。
2 つ目は、HCP Terraform のようなマネージドサービスに state の保管とロックを任せる方法です。ロックの機構は作り込む必要がなく、UI での閲覧や実行履歴の管理も付いてきますが、追加のサービスに依存することになります。
3 つ目が、AWS の中で完結するbackend "s3"です。state ファイルを S3 バケットに置き、ロックの機構も S3 自身が提供します。この連載は AWS の中で Tasuku の構成を組み立てる方針のため、追加サービスを増やさずに済むこの方法を選びます。
以前は S3 backend のロックに別途 DynamoDB のテーブルが必要でしたが、この方式は現在では非推奨です。5 Terraform v1.10.0 で、S3 バケット自身にロック用のオブジェクトを置くuse_lockfileが導入され、6 DynamoDB を別途用意しなくてもロックが機能するようになっています。この章では DynamoDB を使わず、use_lockfileだけでロックを実現します。
5.4. bootstrap構成: stateバケットを別モジュールで作る
backend "s3"を使うには、state の保管先となるバケットが先に存在している必要があります。ここで 1 つの問題が生じます。そのバケット自体を、backend "s3"を使うmain/の中で作ろうとすると、backend が参照するバケットを、その backend が管理する state の中で作ることになってしまいます。バケットができる前に backend の設定がそのバケットを要求するため、順序が噛み合いません。
この章では、state バケットをmain/とは別のbootstrap/というルートモジュールに切り出すことで、この問題を避けます。bootstrap/自身はローカルの state のまま運用し、ここで作ったバケットをmain/のbackend "s3"から参照します。bootstrap/が持つリソースはバケット 1 個とその属性設定だけなので、ローカル state のままでも運用上の負荷は小さいという判断です。
bootstrap/の構成は、main/と同じversions.tf・providers.tf・variables.tf・locals.tfに、バケット本体を書くstate.tfと、main/から参照する値を出すoutputs.tfを加えたものです。
data "aws_caller_identity" "current" {}
resource "aws_s3_bucket" "state" {
bucket = "${local.name_prefix}-tfstate-${data.aws_caller_identity.current.account_id}"
tags = {
Name = "${local.name_prefix}-tfstate"
}
}
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
bucket = aws_s3_bucket.state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
バケット名の末尾にdata.aws_caller_identity.current.account_idを足しているのは、5.1 節で述べた名前の一意性のためです。バージョニングを有効にしているのは、誤操作で state を壊したときに復旧できるようにするためです。7
5.5. backend "s3"への移行
bootstrap/でバケットができたら、main/のversions.tfにbackend "s3"ブロックを追加します。
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.53"
}
}
backend "s3" {
bucket = "tasuku-dev-tfstate-123456789012" # bootstrap/ の terraform output で得た値
key = "main/terraform.tfstate"
region = "ap-northeast-1"
use_lockfile = true
encrypt = true
}
}
backend "s3"ブロックには、bucket・key・regionの 3 つが必須です。8 bucketは 5.4 節で作ったバケットの名前、keyはそのバケット内で state ファイルを置くパスです。backend ブロックの引数はmain/の他の変数を参照できないため、bootstrap/のterraform outputで得た値をそのままリテラルとして書き込みます。
設定を追加した状態でterraform initを実行すると、-migrate-stateというオプションが使えるようになります。既存のローカル state を新しい backend へコピーする動作で、変更内容によってはワークスペースの移行を確認するプロンプトが表示されることがあります。9 プロンプトを省略したい場合は-force-copyを付けます。
この移行の手順は、記事のサンプルとして以上の内容を示すにとどめます。この連載を検証している terraform-verify プロジェクトのmain/は、既存の章との整合を保つため、ローカル state のまま運用を続けます。10 そのため、このbackend "s3"ブロックは検証対象のコードには含まれていません。
5.6. 添付バケットの設計
添付ファイル用バケットは、5.1 節で挙げた 4 原則をすべて適用します。
Block Public Accessの 4 設定は、それぞれ役割が違います。BlockPublicAclsは ACL による公開を許可する操作自体を拒否し、IgnorePublicAclsは既存の ACL があってもそれを無視します。BlockPublicPolicyは公開を許すバケットポリシーの設定を拒否し、RestrictPublicBucketsはバケットポリシーが公開設定でも、アカウント内の許可された利用者以外からのアクセスを遮断します。11 Tasuku の添付ファイルはアプリケーションを経由してのみ配信するため、4 つとも有効にします。
暗号化は、S3 が 2023 年 1 月 5 日以降、すべての新規オブジェクトに自動で適用するようになっています。12 それでもaws_s3_bucket_server_side_encryption_configurationを明示的に宣言するのは、暗黙のデフォルトに依存するのではなく、意図した設定であることをコードとして残すためです。
バージョニングを有効にすると、上書きや削除で失われるはずだった旧バージョンが残ります。ただし残し続けると保存コストが積み上がるため、ライフサイクル設定で不要になった旧バージョンを一定期間後に削除します。あわせて、アップロードが失敗して完了しなかったマルチパートアップロードの断片も、放置すると課金対象のまま残るため、一定日数後に自動で中断します。13 14
添付ファイルは、ブラウザから直接 S3 へアップロードする構成を想定しています。ユーザーのブラウザは、アプリケーションのオリジンとは異なる S3 のドメインへPUTリクエストを送ることになるため、バケット側で CORS を許可しておく必要があります。実際にブラウザへ発行するアップロード用の URL は、有効期限付きのpresigned URLという仕組みで、アプリケーションのバックエンドが SDK を使って発行します。15 presigned URL の発行自体は Terraform の管轄外のため、この章では CORS の設定だけをコード化し、presigned URL の発行はアプリケーション側の実装として扱います。
5.7. 添付バケットのTerraform実装
main/s3.tfに、添付ファイル用バケットを書きます。
resource "aws_s3_bucket" "attachments" {
bucket = "${local.name_prefix}-attachments-${data.aws_caller_identity.current.account_id}"
tags = {
Name = "${local.name_prefix}-attachments"
}
}
resource "aws_s3_bucket_public_access_block" "attachments" {
bucket = aws_s3_bucket.attachments.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "attachments" {
bucket = aws_s3_bucket.attachments.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "attachments" {
bucket = aws_s3_bucket.attachments.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_lifecycle_configuration" "attachments" {
bucket = aws_s3_bucket.attachments.id
rule {
id = "abort-incomplete-multipart-upload"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
rule {
id = "expire-noncurrent-versions"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = 90
}
}
}
resource "aws_s3_bucket_cors_configuration" "attachments" {
bucket = aws_s3_bucket.attachments.id
cors_rule {
allowed_methods = ["GET", "PUT"]
allowed_origins = ["https://app.tasuku.example"]
allowed_headers = ["*"]
expose_headers = ["ETag"]
max_age_seconds = 3000
}
}
data.aws_caller_identity.currentは、第3章のiam.tfで既に宣言済みのものをそのまま参照しています。同じデータソースを 1 つのモジュール内で二重に宣言する必要はありません。
バケット本体・アクセスブロック・バージョニング・暗号化・ライフサイクル・CORS が、それぞれ独立したリソースとして分かれている点に注意してください。以前のバージョンではこれらをaws_s3_bucket1 つのブロック内に書けましたが、現在のプロバイダではこの書き方は非推奨で、別リソースへの分離が推奨されています。16 cors_ruleのallowed_originsはアプリケーションのオリジンの例値です。実際に使うときは、自分のアプリケーションのオリジンに置き換えてください。
5.8. S3へのVPCエンドポイント
第4章で作った private-app サブネットの ECS Fargate タスクは、外向きの通信を NAT Gateway 経由で行います。S3 へのアクセスも、何もしなければこの NAT Gateway を通ります。S3 にはGateway型のVPCエンドポイントという別の経路があり、これを使うと NAT Gateway を経由せず、VPC のルートテーブルを通じて直接 S3 へアクセスできます。この経路の利用に追加の課金はありません。17
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [for k, v in aws_route_table.private_app : v.id]
tags = {
Name = "${local.name_prefix}-s3-gateway-endpoint"
}
}
関連付けるルートテーブルは、private-app の 2 つだけです。第4章で、private-data にはインターネットへの経路を意図的に持たせていません。18 S3 へのアクセスも例外にする理由はないため、private-data のルートテーブルには関連付けません。
この章の時点では、S3 へアクセスするワークロードである ECS Fargate のタスクがまだ存在しません。第 8 章で ECS タスクができてはじめて、NAT Gateway 経由のデータ処理課金を避けるというこのエンドポイントの効果が実際に働き始めます。この章で先にエンドポイントを用意しておくのは、S3 のバケットを作るこの章でネットワーク経路も合わせて完結させておくためです。
5.9. 動作確認
bootstrap/とmain/の両方で、terraform fmt -check -recursive -diffとterraform validateを実行し、構文とプロバイダスキーマの整合を確認します。10 bootstrap/はmain/とは独立したルートモジュールのため、terraform initもそれぞれの場所で個別に行います。
terraform planやapplyは、AWS の認証情報を要する操作のため、この連載の検証範囲には含めません。5.5 節で示したbackend "s3"への移行や CORS の実際の挙動、VPC エンドポイントが実際にトラフィックを経路変更する様子は、構文の検証だけでは確かめられない部分です。試す場合は、自身の AWS 環境でplanから確認してください。
本章のまとめ
- S3 のバケット名はパーティション内でグローバルに一意である必要があり、この章では末尾に AWS アカウント ID を付けて一意性を確保しました
- tfstate を S3 へ移すには、backend が参照するバケット自体を先に作る必要があり、この鶏と卵の問題を
bootstrap/という別モジュールへの切り出しで解決しました - S3 backend のロックは
use_lockfileで実現し、以前の定石だった DynamoDB は使いません - 添付ファイル用バケットには、Block Public Access・暗号化・バージョニング・ライフサイクル・CORS の 5 つを設定しました
- S3 への Gateway 型 VPC エンドポイントを用意しましたが、効果が働き始めるのは ECS Fargate のタスクができる第 8 章からです
次に読む
- 第 6 章: ALB: ロードバランサーとアクセスログ用バケットの実装
- 第 4 章: VPC: CIDR 設計と 2 AZ のサブネット構成
- 連載の目次: 全 12 章の構成と読み進め方