CloudFront S3 環境をterraformで構築する（basic認証も）

# はじめに

awsサービスを使用した定番構成であるCF-S3環境をterraformで構築してみたまとめです。
ついでにlambda@Egdeでbasic認証の設定もしました。

今回terraformで作成するもの一覧

1. S3バケット
2. S3バケットポリシー
3. Lambda
4. CloudFront Distribution

今回はインフラ構築のみで行くので、S3に配置するindex.htmlは手動でアップロードします（github + circleciで自動化出来たりもしますが今回はパス）。
アクセスはCFのドメインでアクセスするところまでです（Route53やらACMでドメインの作成管理もterraformで出来ますが、そこまでterraformでやらなくても.....と思うので割愛）。
アクセスログの設定は今回すっ飛ばします。まずは作ってみましょうというお話になります。

terraformのバージョンはこちら

```
$ terraform --version
Terraform v0.15.0
on darwin_amd64
Your version of Terraform is out of date! The latest version
is 0.15.4. You can update by downloading from
```

terraformの変数ファイルはこちら

```:variavles.tf
variable "site_domain" {
  default = "foo-terraform.com"
}
variable "bucket_name" {
  default = "foo-terraform.com"
}
```

## S3

ではまずS3バケットの作成から。
記述内容はS3バケットの作成、パブリックアクセスブロックの設定、バケットポリシーの設定の3点です。
バケットの作成が出来たらaws-cliやらなんやらで適当に書いたindex.htmlをアップロードしておきます。

```:s3.tf
# バケット作成。CF以外でアクセスしないのでaclはprivateで
resource "aws_s3_bucket" "site" {
  bucket = var.bucket_name
  acl    = "private"
}

# パブリックアクセスブロックの設定。terraformだとこれを明記しておかないと全部falseで作成されてしまうので注意
resource "aws_s3_bucket_public_access_block" "example" {
  bucket                  = aws_s3_bucket.site.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# CFからアクセスするためのバケットポリシーを作成
resource "aws_s3_bucket_policy" "b" {
  bucket = aws_s3_bucket.site.id
  policy = jsonencode(
    {
      Id = "PolicyForCloudFrontPrivateContent"
      Statement = [
        {
          Action = "s3:GetObject"
          Effect = "Allow"
          Principal = {
            AWS = aws_cloudfront_origin_access_identity.site.iam_arn
          }
          Resource = "${aws_s3_bucket.site.arn}/*"
          Sid      = "1"
        },
      ]
      Version = "2008-10-17"
    }
  )
}
```

今回は検証がてらの作成なのであまり気にしなくていいですが、S3のお漏らしは非常にマズ味なので気を付けましょう。。。


バケットポリシー自体は、コンソールから手動でdistributionを作成する時に設定できるS3のバケットポリシーと同じ内容をそのまま入れ込んでいるだけです。リソース値はterraformで作成するのでそれ用に書き換えているイメージです。
ポリシーは(当然)jsonで書く必要がありますが、terraformの変数の呼び出し等はいい感じに対応してくれている。素敵。

捕捉としては、ココのprincipalは後々作成するOAIのarnを引っ張ってきています。

```
          Principal = {
            AWS = aws_cloudfront_origin_access_identity.site.iam_arn
```

余談ですが、terraformのバージョン0.12以降では変数や他のリソース値を引っ張ってくるときに **"${}"** で括る必要がなくなりました。
しかし、引っ張ってきた値と文字列を結合したい時は相変わらず必要なので注意しましょう。

上記のtfファイルだとココ

```
          Resource = "${aws_s3_bucket.site.arn}/*"
          Sid      = "1"
```

この書き方でarnの後に/*を繋げられています。

# Lambda作成

basic認証を設定するためのLambdaを作成していきます。
やってることは以下の記事に書いてあるものをterraformで作成した感じになります。
https://dev.classmethod.jp/articles/cloudfront-lambdaedge-basic-spa/

Lambda関連で作成するものは以下
1. Lambdaで動かしたいコード
2. IAMポリシーとIAMロール
3. Lambda本体

appディレクトリ配下にコードを配置して、後々zipします。
Lambdaはus-east-1に作成しないとCFが気付いてくれないので注意しましょう。

ディレクトリ構成はこんな感じ

```
./
├── app
│   └── index.js
├── iam.tf
└── lambda.tf
```

まずはLambdaで動かしたいコードを書いていきましょう。
と言っても先に貼ったリンクと中身は一緒です。user/passはお好みでどうぞ

```:index.js
'use strict';
exports.handler = (event, context, callback) => {
    // Get request and request headers
    const request = event.Records[0].cf.request;
    const headers = request.headers;
    // Configure authentication
    const authUser = 'hogehoge';
    const authPass = 'huga1919';
    // Construct the Basic Auth string
    const authString = 'Basic ' + new Buffer(authUser + ':' + authPass).toString('base64');
    // Require Basic authentication
    if (typeof headers.authorization == 'undefined' || headers.authorization[0].value != authString) {
        const body = 'Unauthorized';
        const response = {
            status: '401',
            statusDescription: 'Unauthorized',
            body: body,
            headers: {
                'www-authenticate': [{key: 'WWW-Authenticate', value:'Basic'}]
            },
        };
        callback(null, response);
    }
    // Continue request processing if authentication passed
    callback(null, request);
};
```

では次にLambda用のIAMポリシーとIAMロールを作成します。
普段コンソールからLambdaを作成しているものと同内容で作成するものをterraformで書くイメージです。

共にstatement内でLambda実行時の権限を記述しています。
ポリシーとロールを個別に作成して **aws_iam_role_policy_attachment** で繋いでいます。

```:iam.tf
resource "aws_iam_policy" "policy" {
  name        = "lambda_basic_policy"
  description = "lambda_basic_policy"
  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Effect" : "Allow",
        "Action" : [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ],
        "Resource" : [
          "arn:aws:logs:*:*:*"
        ]
      }
    ]
    }
  )
}
resource "aws_iam_role" "role" {
  name = "lambdabasic_role"
  path = "/service-role/"
  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Effect" : "Allow",
        "Principal" : {
          "Service" : [
            "edgelambda.amazonaws.com",
            "lambda.amazonaws.com"
          ]
        },
        "Action" : "sts:AssumeRole"
      }
    ]
  })
}
resource "aws_iam_role_policy_attachment" "attach" {
  role       = aws_iam_role.role.name
  policy_arn = aws_iam_policy.policy.arn
}
```

ここまで出来たら本体の設定を書いてLambdaは終了です。

dataの所でappディレクトリをzipに圧縮しています。ファイルの出力先はoutput_pathで設定。

```:lambda.tf
data "archive_file" "function_source" {
  type        = "zip"
  source_dir  = "app"
  output_path = "archive/basic.zip"
}
resource "aws_lambda_function" "function" {
  function_name = "basic"
  handler       = "index.handler"
  role          = aws_iam_role.role.arn
  runtime       = "nodejs10.x"
  filename         = data.archive_file.function_source.output_path
  source_code_hash = data.archive_file.function_source.output_base64sha256
  publish          = true
}
```

捕捉としては、nodejsのバージョン指定とpubrishをtrueにすることに注意しましょう。
nodejsのバージョンに関しては先の記事を参考に。publishに関してはtrueにしてバージョンを発行しないとCFが読み込んでくれなくなります。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-versions.html

# CF作成

いざdistributionの作成です。

```cf.tf
#### s3のorogin IDをローカル変数として定義。
# localについてはこのあたりを参考に
# https://febc-yamamoto.hatenablog.jp/entry/2018/01/30/185416
locals {
  s3_origin_id = "s3-origin-${var.site_domain}"
}

# origin access identifyを作成。
# s3.tfのバケットポリシー部分で指定してたのはこれ。
resource "aws_cloudfront_origin_access_identity" "site" {
  comment = var.site_domain
}

#### distributionの作成
resource "aws_cloudfront_distribution" "site" {

#オリジンを作成。対象は先に作成したS3バケット
  origin {
    domain_name = aws_s3_bucket.site.bucket_regional_domain_name
    origin_id   = local.s3_origin_id

# S3オリジンのパスを指定
    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.site.cloudfront_access_identity_path
    }
  }

# distributionを作成後に有効にするかどうか。とりあえず作ってみるだけならfalseでも可
  enabled             = true

# ipv6を有効にするかどうか
  is_ipv6_enabled     = false

# ドメインにアクセスした時に返すデフォルトのオブジェクト。s3にindex.htmlを上げるのでそれを指定
  default_root_object = "index.html"

#### ここからbehaviorsの設定
# キャッシュ設定。今回はコンソールから作られるデフォルトの設定をほぼそのまま設定
  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
    cached_methods   = ["GET", "HEAD"]

# S3バケットのオリジンIDを指定
    target_origin_id = local.s3_origin_id

# 転送値の設定。今回は特に設定せず
    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }

#### viewer protocol policyの設定。
# 今回はallow-allを設定、コンソール上では"HTTP and HTTPS"に該当
# ここは3択から選べて、残りは"https-only"と"redirect-to-https"
# とりあえず作ってみるだけなのでキャッシュ時間は0で
    viewer_protocol_policy = "allow-all"
    min_ttl                = 0
    default_ttl            = 0
    max_ttl                = 0

#### behaviorに設定するlambdaを指定。basic認証用
    lambda_function_association {
      event_type   = "viewer-request"
      lambda_arn   = aws_lambda_function.function.qualified_arn
      include_body = false
    }

  }

#### エッジロケーション毎のコンテンツの配布制限。今回は制限なし
  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

#### priceclassを設定
# ここも書き方は3択で、残りは "PriceClass_All"と"PriceClass_100"
  price_class = "PriceClass_200"

# 証明書設定。今回はRoute53を使用しないのでCFのデフォルト証明書を設定
  viewer_certificate {
    cloudfront_default_certificate = true
  }
}
```

以上で設定完了。apply後にアクセス確認してみる。

```
$ curl -I d6s69hiieigig.cloudfront.net
HTTP/1.1 401 Unauthorized
Content-Length: 12
Connection: keep-alive
Server: CloudFront
Date: Tue, 30 Mar 2021 09:12:27 GMT
WWW-Authenticate: Basic
X-Cache: LambdaGeneratedResponse from cloudfront
Via: 1.1 3713468e68e20152a89ab133cc836321.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: NRT12-C3
X-Amz-Cf-Id: BhzTTUjbm5L8q4FzY915d0qsR90I2f-SVpiBAr6zmkmCriZlTiiDrw==
```

お、basic聞かれましたね。
ではuser/passを指定して再度アクセス。

```
$ curl -I d6s69hiieigig.cloudfront.net -u hogehoge -p
Enter host password for user 'hogehoge':
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 47
Connection: keep-alive
x-amz-id-2: CDn1iRkTioMIDxt15AhwtQ7VsWTOdtniv/njrKS6TdRd0Flp966k8qmLOqyamWNdf25k9Qs6idA=
x-amz-request-id: 40H5W1R0WBBC8W0C
Last-Modified: Tue, 30 Mar 2021 09:12:17 GMT
Accept-Ranges: bytes
Server: AmazonS3
Date: Tue, 30 Mar 2021 09:12:33 GMT
ETag: "d800b27cb1bf0435431cde83b79c8ea2"
X-Cache: RefreshHit from cloudfront
Via: 1.1 9e763d54b60e5dbe2c1faa8e75e52b67.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: NRT12-C3
X-Amz-Cf-Id: 4EHuWDFl24xeDt9J0g7sgdIeM8WXl1ID0lOY9LEq7wXzv6_goWDhrQ==
```

無事にbasicが通って200が返ってきました。

以上でterraformを使用してCF-S3環境を構築することが出来ましたが、削除に問題点が一つ。
lambda edgeは各エッジロケーションにレプリカを作成しているため、destroy一発で消すことが出来ない模様。
消そうとすると下記のようなエラーが出ます。

```
Error: error deleting Lambda Function (basic): InvalidParameterValueException: Lambda was unable to delete arn:aws:lambda:us-east-1:646390588566:function:basic:6 because it is a replicated function. Please see our documentation for Deleting Lambda@Edge Functions and Replicas.
{
  RespMetadata: {
    StatusCode: 400,
    RequestID: "39cac4e5-f28d-4160-ad76-6badac247fcf"
  },
  Message_: "Lambda was unable to delete arn:aws:lambda:us-east-1:646390588566:function:basic:6 because it is a replicated function. Please see our documentation for Deleting Lambda@Edge Functions and Replicas."
}
```

これが出た場合はしばらく待って再度destroyするか手動で削除対応。
また、S3に上げたindex.htmlも手動で消す必要があります。
今回はLambdaの都合上すべてus-east-1で作成しましたが、S3を東京リージョン等に置きたい場合はworkspace化してリージョン切り替えで対応するなどですね。

ではまた。