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 章の構成と読み進め方