🦔

自分専用クラウドストレージをTerraformで自動構築

2023/04/13に公開

背景

世の中にGoogle DriveやDropboxといった便利なサービスがありますが、プライバシーの観点から自分が管理できるクラウドストレージが欲しくなってきました
S3とCloudFrontを利用すればセキュアな自分用クラウドストレージが作成できるのではと思いつきやってみました。

構築

ディレクトリ構成

思いつきで変更して後で再現できないことになったら泣きそうになるので、構築にはAWSとTerraformを利用します。
色々な方がディレクトリ構成案を考えてくれていますが、今回はシンプルにいきます。

$ tree ./
.
├── aws.tfvars
├── Makefile
├── contents
│   └── index.html # 動作確認用のHTMLファイル
├── main.tf
└── modules
    ├── cloudfront.tf
    ├── main.tf
    └── s3-storage.tf
    └── s3-logs.tf

秘密情報

IAMユーザのアクセスキー、シークレットキーなどの機密情報はaws.tfvarsに保存します。
aws.tfvarsはコミットゼッタイダメ
(予定はないですが)海外移住に備えてリージョン情報もついでに記載します。

aws.tfvars
aws_access_key = "AKIAZUH76..."
aws_secret_key = "OhNKSFXXl9bn..."
aws_region     = "ap-northeast-1"

機密情報の読み込みはvariableセクションにで行います。
aws.tfvarsの左辺の文字列とvariableの名前を一致させると読み込ませることができます。

main.tf
variable "aws_access_key" {
	type        = string
	sensitive   = true // コンソールに値を直接出さない
	description = "The access key for your IAM user in AWS."
}

variable "aws_secret_key " {
	type        = string
	sensitive   = true
	description = "The secret access key for your IAM user in AWS."
}

variable "aws_region" {
	type        = string
	description = "The region name to deployment."
}

Provider

aws.tfvarsで設定した情報を読み込み。terraform.required_providers.aws.versionは下記ページのパンくずリストから利用したいバージョンを確認し設定してください。
Terraform Registry

今回作成したリソースをコンソール上でも区別できるように全てのリソースにタグを付けます。
全てのリソースに共有したタグを付けるにはdefault_tagsを利用します。今回はPersonal cloud storageというNameタグを付けています。

main.tf
terraform {
	required_providers {
		aws = {
			source  = "hashicorp/aws"
			version = "= 4.61.0"
		}
	}
}

provider "aws" {
	region     = var.aws_region
	access_key = var.aws_access_key
	secret_key = var.aws_secret_key 
	default_tags {
		tags = {
			"Name" = "Personal cloud storage"
		}
	}
}

module "aws" {
	source = "./modules"
}

// CloudFrontのドメイン名を表示
output "cloudfront_distribution_domain_name" {
	value = module.aws.cloudfront_distribution_domain_name
}

S3

S3は配信するファイル配置するバケットとログを保存するバケットの2つ用意します。
配信用バケットには動作確認用の最低限のことがかかれたHTMLファイルをデプロイ時に配置します。

modules/s3-logs.tf
resource "aws_s3_bucket" "access_logs" {
	bucket        = "personal-cloud-storage-57356-logs"
	force_destroy = true
}

// パブリックアクセス無効
resource "aws_s3_bucket_public_access_block" "access_logs" {
	bucket                  = aws_s3_bucket.access_logs.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" "server_access_logs" {
	bucket = aws_s3_bucket.access_logs.id
	rule {
		apply_server_side_encryption_by_default {
			sse_algorithm = "AES256" // SSE-S3
		}
	}
}

// ACL設定
data "aws_canonical_user_id" "current" {}
resource "aws_s3_bucket_acl" "access_logs" {
	bucket = aws_s3_bucket.access_logs.id
	access_control_policy {
		grant {
			grantee {
				id   = data.aws_canonical_user_id.current.id
				type = "CanonicalUser"
			}
			permission = "FULL_CONTROL"
		}

		grant {
			grantee {
				type = "Group"
				uri  = "http://acs.amazonaws.com/groups/s3/LogDelivery"
			}
			permission = "FULL_CONTROL"
		}

		owner {
			id = data.aws_canonical_user_id.current.id
		}
	}
}

// ライフサイクル設定 : 1年保存設定
resource "aws_s3_bucket_lifecycle_configuration" "access_logs" {
	bucket = aws_s3_bucket.access_logs.id
	rule {
		id     = "expiration-rule"
		status = "Enabled"
		expiration {
			days = 365
		}
	}
}

// バケットポリシー
data "aws_caller_identity" "current" {}
data "aws_iam_policy_document" "access_logs" {
	// アクセスログのPutを許可
	statement {
		sid    = "S3ServerAccessLogsPolicy"
		effect = "Allow"
		principals {
			identifiers = ["*"]
			type        = "*"
		}
		actions = [
			"s3:ListBucket",
			"s3:PutObject",
			"s3:GetObject"
		]
		resources = [
			"${aws_s3_bucket.access_logs.arn}",
			"${aws_s3_bucket.access_logs.arn}/*"
		]
		condition {
		  test     = "ArnLike"
		  variable = "aws:SourceArn"
		  values = [
			"arn:aws:s3:::*"
		  ]
		}
		condition {
		  test     = "StringEquals"
		  variable = "aws:SourceAccount"
		  values = [
			"${data.aws_caller_identity.current.account_id}" // 自アカウントからのみ操作を許可
		  ]
		}
	}
}

resource "aws_s3_bucket_policy" "access_logs" {
	bucket = aws_s3_bucket.access_logs.bucket
	policy = data.aws_iam_policy_document.access_logs.json
}
modules/s3-storage.tf
resource "aws_s3_bucket" "pcs" {
	bucket        = "personal-cloud-storage-57356" // 世界でユニークな名前 末尾に好きな数字を5桁くらい入れれば大丈夫
	force_destroy = true // オブジェクトがあっても強制削除
}

// 動作確認用HTMLファイルをバケットに配置
resource "aws_s3_object" "pcs" {
	bucket       = aws_s3_bucket.pcs.id
	key          = "index.html"
	source       = "contents/index.html"
	content_type = "text/html"
}

// パブリックアクセス無効
resource "aws_s3_bucket_public_access_block" "pcs" {
	bucket                  = aws_s3_bucket.pcs.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" "pcs" {
	bucket = aws_s3_bucket.pcs.id
	rule {
		apply_server_side_encryption_by_default {
			sse_algorithm = "AES256" // SSE-S3
		}
	}
}

// ACL無効 (バケット所有者の強制)
resource "aws_s3_bucket_ownership_controls" "pcs" {
	bucket = aws_s3_bucket.pcs.id
	rule {
		object_ownership = "BucketOwnerEnforced"
	}
}

// アクセスログ送信設定
resource "aws_s3_bucket_logging" "pcs" {
	bucket        = aws_s3_bucket.pcs.id
	target_bucket = aws_s3_bucket.access_logs.id // ログ送信先バケット
	target_prefix = "s3"                         // ログ送信先Prefix
}

// 誤削除対策にバージョニングを有効化
resource "aws_s3_bucket_versioning" "pcs" {
	bucket = aws_s3_bucket.pcs.id
	versioning_configuration {
		status = "Enabled"
	}
}

// CloudFrontからのみアクセスできるバケットポリシー
data "aws_iam_policy_document" "pcs" {
	version = "2012-10-17"
	statement {
		sid    = "AllowCloudFrontServicePrincipal"
		effect = "Allow"
		principals {
			type        = "Service"
			identifiers = ["cloudfront.amazonaws.com"]
		}
		actions = [
			"s3:GetObject"
		]
		resources = [
			"${data.aws_s3_bucket.pcs.arn}/*"
		]
		condition {
			test     = "StringEquals"
			variable = "AWS:SourceArn"
			values   = [aws_cloudfront_distribution.pcs.arn]
		}
	}
}

// バケットポリシーを設定
resource "aws_s3_bucket_policy" "pcs" {
	bucket = data.aws_s3_bucket.pcs.id
	policy = data.aws_iam_policy_document.pcs.json
}
contents/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
	<meta charset="UTF-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>Personal Cloud Storage</title>
</head>
<body>
	<h1>Personal Cloud Storage</h1>
	<div>Test page.</div>
</body>
</html>

今回はバケットポリシーの設定方法がわからずだいぶ時間をかけてしまいました。

dataで設定後、jsonに変換して設定する方法を初めて知りました。公式ドキュメントの確認は大事。

Terraform Registry

CloudFront

S3からの直接配信ではHTTPのみなのでHTTPS化するためにCloudFrontをS3の前に配置します。

modules/cloudfront.tf
resource "aws_cloudfront_origin_access_control" "pcs" {
	name                              = "cf-oac-with-tf-pcs"
	origin_access_control_origin_type = "s3"
	signing_behavior                  = "always"
	signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "pcs" {
	enabled             = true
	comment             = "自分専用クラウドストレージ"
	default_root_object = "index.html"
	price_class         = "PriceClass_200" // コスト面を考えて配信地域を北米,ヨーロッパ,アジアに限定

	origin {
		domain_name              = "personal-cloud-storage-57356.s3.ap-northeast-1.amazonaws.com"
		origin_id                = "personal-cloud-storage-57356"
		origin_access_control_id = aws_cloudfront_origin_access_control.pcs.id
	}

	default_cache_behavior {
		allowed_methods        = ["GET", "HEAD"] // 今回はダウンロードのみなのでGET, HEADのみ
		cached_methods         = ["GET", "HEAD"]
		target_origin_id       = "personal-cloud-storage-57356"
		compress               = true
		viewer_protocol_policy = "redirect-to-https"
		min_ttl                = 0
		default_ttl            = 3600
		max_ttl                = 86400

		forwarded_values {
			query_string = false
			cookies {
				  forward = "none"
			}
		}
	}

	restrictions {
		geo_restriction {
			restriction_type = "whitelist"
			locations        = ["JP"]
		}
	}

	viewer_certificate {
		cloudfront_default_certificate = true
	}

	// アクセスログの保管場所設定
	logging_config {
		bucket          = "personal-cloud-storage-57356-logs.s3.amazonaws.com"
		include_cookies = true
		prefix          = "cloudfront/"
	}
}

Output

CloudFrontへのドメイン名を構築後にコンソールへ表示させます。

modules/main.tf
terraform {
	required_providers {
		aws = {
		  source  = "hashicorp/aws"
		  version = "4.61.0"
		}
	}
}

output "cloudfront_distribution_domain_name" {
	value = aws_cloudfront_distribution.pcs.domain_name
}

実行

実行時にオプションでaws.tfvarsを指定して実行。
指定しない場合、インタラクティブに入力します。

$ terraform plan -var-file aws.tfvars

私は時間が経つと忘れるので、Makefileにコマンドをまとめています

$ cat Makefile
---
plan:
	terraform plan -var-file aws.tfvars
apply:
	terraform apply -var-file aws.tfvars
format:
	terraform fmt -recursive
destroy:
	terraform destroy -var-file aws.tfvars
---

$ make plan  # 実行計画を表示
$ make apply # デプロイ

※注意点※

S3バケットの削除

この環境はCloudFrontとS3バケットを一緒に作ってしまっているのでdestroyをすると一緒に消えてしまいます。通常はオブジェクトが入った状態では削除時にエラーがでますが、下記のように設定しているためオブジェクトごと削除されます。

resource "aws_s3_bucket" "pcs" {
  bucket        = "personal-cloud-storage-57356" // 世界でユニークな名前 末尾に好きな数字を5桁くらい入れれば大丈夫
  force_destroy = true // オブジェクトがあっても強制削除
}

例えば、CloudFrontに独自ドメインとACMの証明書を設定するために一旦削除とするといままで登録していたファイルごといかれます。私の調査方法が悪かったのか、片方のリソースだけを消すうまい方法を見つけることができませんでした。なので、私はCloudFrontとS3を別々のフォルダに分け、それぞれapplyしています。
この方法だとCloudFront側でS3の補完が効かないのでdataでバケットを定義しています。
うまい方法をご存じであればコメント欄にてご教示ください。

認証

今回の構成ではCloudFront distributionのURLを知られると誰でもアクセス可能になってしまいます。リクエストヘッダに秘密のトークンを付け、CloudFront側はLambda@Edgeで検証するといった認証機能が必要です。

まとめ

AWSのCloudFrontとS3を利用したファイル配信環境をTerraformを利用して構築してみました。サービスは2つしか使っていないのに思った以上のコード量になってしまいました。やはり自動化は難しいですね。
今はアップロードはAWSコンソールからなのでブラウザからアップロードする方法とファイル一覧表示する機能を考えたいです。

料金がいくらかかるのか気になって試算しました。DropBoxで2TBを使おうとすると1,500円/月。
S3標準で2TBを使おうとすると東京リージョンでは0.025USD/GB*2000GB=50USD …
特別な理由がない限りはSaaSを使いましょう

拙文最後までお読みいただきありがとうございます。
気軽にいいね!、Twitterフォローをお願いします。

Discussion