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

# 背景

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

# 構築

## ディレクトリ構成

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

```bash:ターミナル
$ 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`はコミットゼッタイダメ

（予定はないですが）海外移住に備えてリージョン情報もついでに記載します。

```go:main.tf
aws_access_key = "AKIAZUH76..."
aws_secret_key = "OhNKSFXXl9bn..."
aws_region     = "ap-northeast-1"
```

機密情報の読み込みは`variable`セクションにで行います。

`aws.tfvars`の左辺の文字列と`variable`の名前を一致させると読み込ませることができます。

```go: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`は下記ページのパンくずリストから利用したいバージョンを確認し設定してください。

[AWS - Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest)

今回作成したリソースをコンソール上でも区別できるように全てのリソースにタグを付けます。

全てのリソースに共有したタグを付けるには`default_tags`を利用します。今回は`Personal cloud storage`という`Name`タグを付けています。

```go: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は配信するファイル配置するバケットとログを保存するバケットの２つ用意します。
配信用バケットには動作確認用の最低限のことがかかれたHTMLファイルをデプロイ時に配置します。
コードが長くなったので折りたたんでいます。

<details><summary>modules/s3-logs.tf</summary>

```go
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/PersonalCloudStorage"
      }
      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
}
```
</details>

<details><summary>modules/s3-logs.tf</summary>

```go
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
}
```

ntents/index.html

```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>
```
</details>

今回はバケットポリシーの設定方法がわからずだいぶ時間をかけてしまいました。
`data`で設定後、jsonに変換して設定する方法を初めて知りました。公式ドキュメントの確認は大事。
[Data Source: aws_iam_policy_document | AWS - Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document)

## CloudFront

S3からの直接配信ではHTTPのみなのでHTTPS化するためにCloudFrontをS3の前に配置します。
コードが長くなったので折りたたんでいます。

<details><summary>modules/s3-logs.tf</summary>

```go
resource "aws_cloudfront_origin_access_control" "pcs" {
  name                              = "cf-oac-with-tf-"
  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/"
  }
}
```
</details>
    

## Output

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

```go: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`を指定して実行。
指定しない場合、インタラクティブに入力します。

```bash:ターミナル
$ terraform plan -var-file aws.tfvars
```

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

```bash:ターミナル
$ 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をすると一緒に消えてしまいます。通常はオブジェクトが入った状態では削除時にエラーがでますが、下記のように設定しているためオブジェクトごと削除されます。

```go
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フォローをお願いします。