S3/CloudFront/Route53/ACM構成の静的サイトをTerraformで構築 & CircleCIで自動デプロイ

みなさん、はじめまして。LUCHE HOLDINGS(ルーチェ ホールディングス)@nori0620です。 LUCHE HOLDINGSはギフト領域にフォーカスしたベンチャー企業で、日本とシンガポールに拠点をおいています。

今まであまり情報を発信できていなかったのですが、今後積極的に情報を発信していく一環として、このブロクを開始しました。 会社の詳細については、↓のサイトをご覧ください!

https://careers.lucheholdings.com

ちなみに上記のサイトはS3,CloudFrontな構成でTerraformで構築 、 CircleCIで自動デプロイといった仕組みで管理しています。 本ブログの最初の記事として、このような構成の組み方を解説する記事にしたいと思います。

最近だと単純な静的サイトであればNetlity のようなホスティングサービスを使う選択肢もあると思いますが、弊社ではTerraform + CircleCI運用の足回りが整っているのと、チーム利用での価格帯を見て今回はこの構成にしてます。

また今回解説する例は静的なサイトだけでなく、pathによってLambdaを処理を挟むような静的と動的が混在している場合や、動的サーバのcacheとしてCloudFront利用したいような場面でも参考にできる部分があると思います。

構築するシステムの全体構成

f:id:nori0620:20180925215026p:plain

今回解説する構成の全体像は上のようになっています。 静的ファイルはS3に格納しておいて、CloudFrontをCDNとして配信、GithubにコミットするとCircleCI経由でBuild&Deploy、という構成になっています。

Terraformを使ってAWSを構築する

AWSの構築をTerraformを用いて行なっていきます。

実際の運用では再利用性や、構造的な設計のためにTerraformのモジュール機能を使うケースが多いと思いますが、まずはわかりやすい例としてモジュールを使わずに構築する例を説明してみたいと思います。 その後、同じ構成をモジュールを利用して、より実践的に構築する例について説明します。

1. AWS環境とドメインの準備

AWS環境の準備

構築用のAWSアカウントも用意しておく必要があります。 用意したアカウントでTerraform実行用のIAMユーザを以下のように作成してAccess key IDSecret access keyを保存しておいてください

  • ユーザ名: terraform
  • アクセスの種類: プログラムによるアクセス
  • アクセス許可: 既存のポリシーを直接アタッチから以下のポリシーをアタッチ
    • AmazonS3FullAccess
    • AmazonRoute53FullAccess
    • AWSCertificateManagerFullAccess
    • CloudFrontFullAccess

ドメインの準備

公開するサイトのドメインは手作業で取得しておいてください。Route53で習得しても良いですし、NSレコードが設定可能であればAWSの外側で取得したドメインでも問題ありません。

2. Terraform実行環境の準備

実行マシンにTerraformが入っていない場合、 公式のDownloadページからDownloadしておいてください。 実行マシンで作業用のディレクトリを作って作業を進めていきます。

まずはファイルprovider.tfを定義します

# ---------------
# provider.tf
# ---------------

provider "aws" {
  region     = "us-east-1"
}

※リージョンをus-east-1に固定しているのはCloudFrontのACM証明証を設置するリージョンがus-east-1におくことが必須のためです。 この記事では詳しくは扱いませんが、ACM以外のリソースを他リージョンで扱いたい場合は場合はTerraformのMultiple Provider Instancesを利用すれば、ACMのみをus-east-1リージョンで管理することができます。

次に、全体で共有する変数をvariables.tfに定義しておきます

# ---------------
# variables.tf
# ---------------

variable "site_domain" {
  # 公開するサイトのドメイン
  default = "foo.example.com"
}

variable "root_domain" {
  # 公開するサイトのルートドメイン
  # ( ルートドメインでサイトを公開してる場合は, site_domainとroot_domainは同じ値でOKです)
  default = "example.com"
}

variable "bucket_name" {
  # 静的ファイルを保管しておくs3 bucket名
  default = "site-example.com"
}

この状態でterraformの初期化を行います。 まず、環境変数にAWSのcredential情報を読み込んでおきます。

$ export AWS_ACCESS_KEY_ID=********
$ export AWS_SECRET_ACCESS_KEY=********

その後、以下のコマンドを実行してNo changesになってることを確認します。

$ terraform init
$ terraform plan

4. S3にbucketを作成する

次にs3のBucketを作成するようなtfファイルを作ります。 s3.tfというファイルを作成して、以下のように記載してください。

# ---------------
# s3.tf
# ---------------

resource "aws_s3_bucket" "site" {
  bucket = "${var.bucket_name}"
  acl = "private"
}

ファイル作成後、terraform applyを実行するとs3 bucketの作成が実行されます。 完了したら実際に、S3 Management Consoleを開いてみてbucketが作成されたことを確認してみてください。 bucketが正常に作成されていれば、Management Consoleから以下のような動作確認用のindex.htmlを設定しておいてください。

<!-- index.html / 動作確認用ファイル -->
Hello!!

5. CloudFrontを構築させてサイトを表示させる

CloudFrontの構築用に、ファイルcloudfront.tfを追加して以下のように記載します。

# ---------------
# cloudfront.tf
# ---------------

# CloudFrontの配信元の識別子
locals {
  s3_origin_id = "s3-origin-${var.site_domain}"
}

# PrivateなS3 Bucketにアクセスするためにオリジンアクセスアイデンティティを利用する
resource "aws_cloudfront_origin_access_identity" "site" {
  comment = "${var.site_domain}"
}

# CloudFrontのディストリビューション設定
resource "aws_cloudfront_distribution" "site" {
  origin {

    domain_name = "${aws_s3_bucket.site.bucket_regional_domain_name}"
    origin_id   = "${local.s3_origin_id}"

    s3_origin_config {
      origin_access_identity = "${aws_cloudfront_origin_access_identity.site.cloudfront_access_identity_path}"
    }
  }

  enabled             = true
  is_ipv6_enabled     = true
  comment             = "${var.site_domain}"
  default_root_object = "index.html"

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "${local.s3_origin_id}"

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

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  price_class = "PriceClass_200"

  # とりあえずCloudFrontドメインの証明書を利用
  # Route53&ACM設定が終わった後で、自ドメインの証明書に変更します
  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

今回の例ではs3のbucketをprivateにしたままCloudFrontからのアクセスのみを許容するためにオリジンアクセスアイデンティティを利用することでアクセスを許可するようにします。

cloudfront.tfで設定したaws_cloudfront_origin_access_identityからのアクセスを許可するように、s3.tfに以下のような追記を行ってください。

# ---------------
# s3.tf
# ---------------

resource "aws_s3_bucket" "site" {
  ...
}

# ★以下の設定を追加★
# CloudFrontからのオリジンアクセスアイデンティティ付きアクセスに対してReadのみを許可する
resource "aws_s3_bucket_policy" "site" {
  bucket = "${aws_s3_bucket.site.id}"
  policy = "${data.aws_iam_policy_document.s3_site_policy.json}"
}

data "aws_iam_policy_document" "s3_site_policy" {
  statement {
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.site.arn}/*"]

    principals {
      type        = "AWS"
      identifiers = ["${aws_cloudfront_origin_access_identity.site.iam_arn}"]
    }
  }
}

さらにCloudFront Distributionのドメイン名を確認できるようにoutput.tfファイルを作成して以下のように記載しておきます。

# ---------------
# output.tf
# ---------------

# 作成されたCloudFront Destributionのドメインを出力
output "cloud_front_destribution_domain_name" {
  value = "${aws_cloudfront_distribution.site.domain_name}"
}

terraform applyを実行すると、CloudFrontのディストリビューションが作成されます。 AWSのManagement ConsoleでCloudFrontを確認すると、実際に作成されているのが確認できると思います。 また、以下のコマンドで作成されたCloudFrontのドメインが表示されます。

$ terraform output cloud_front_destribution_domain_name
********.cloudfront.net

起動完了後にブラウザから上記のドメインにhttps経由でアクセスしてみて、S3 bucketに設置したindex.htmlの内容が表示されれば正常に動作しています。 ( 起動完了するまで時間がかかるのでCloudFrontが起動中になっていれば、下のRoute53の作業を進めていって問題ありません。)

5. Route53の設定

次に自ドメインで表示されるように、Route53の設定を行なっていきます。 以下のように新規ファイルroute53.tfを作成します。

# ---------------
# route53.tf
# ---------------

resource "aws_route53_zone" "saws_route53_zonete_zone" {
  name = "${var.root_domain}"

  tags {
    Name = "${var.root_domain}"
  }
}

resource "aws_route53_record" "site" {
  zone_id = "${aws_route53_zone.site_zone.zone_id}"
  name = "${var.site_domain}"
  type = "A"

  alias {
    name = "${aws_cloudfront_distribution.site.domain_name}"
    zone_id = "${aws_cloudfront_distribution.site.hosted_zone_id}"
    evaluate_target_health = false
  }
}

さらに生成されたZoneのネームサーバ一覧を確認するためにoutput.tfにname_serversを追加します。

# ---------------
# output.tf
# ---------------

...

# 以下を追加 / 作成されたZoneのName Serversを出力
output "zone_name_servers" {
  value = "${aws_route53_zone.site_zone.name_servers}"
}

この後、Terraformコマンドを実行になりますが、ドメインをRoute53で取得していてZoneがすでに作成済みになっているかどうかで少し対応がかわります。

もしRoute53でドメインを取得していて、既にHosted Zoneが自動生成されている場合は、terraform importコマンドを使うことで作成済みのリソースをtfstateに取り込むことができます。以下ようにterraform import`コマンドを実行して、tfstateに取り込んでおいてください。 ( 他アカウントや別サービスでドメインを管理している場合は、以下のコマンドは実行する必要はありません。)

# 自動生成済みのZoneを、aws_route53_zone.site_zoneに紐付け
$ terraform import aws_route53_zone.site_zone  [生成済みのHosted Zone ID(例: Z1D633PJN98FT9 )]

次にterraform applyを実行します。 実行が成功したら以下のように叩くと作成(またはimport)されたZoneのName Serversが表示されます。

% terraform output zone_name_servers

ドメインの管理をAWS以外の他サービスで行なっている場合は、その管理画面などからドメインのNSレコードに上記のname serverを設定しておいてください。

digコマンドなどで、ドメインを引いてAレコードが返ってきていれば、正常に設定できています。 この時点では証明書の設定をまだ行なっていないので、ドメインをブラウザから開いてもエラーになります。 下記で、ACM証明書の設定を行なっていきます。

6. ACMを設定してhttpsでサイトを表示させる

ACMを用いて、証明書の取得を行います。 以下のようにacm.tfファイルを作成してください。

# ---------------
# acm.tf
# ---------------

resource "aws_acm_certificate" "acm_cert" {
  provider = "aws"
  domain_name = "${var.root_domain}"
  subject_alternative_names = ["*.${var.root_domain}"]
  validation_method = "DNS"
}

resource "aws_route53_record" "acm_cert" {
  # "example.com", "*.example.com"の2つを登録するのでcount=2
  count = 2
  name = "${lookup(aws_acm_certificate.acm_cert.domain_validation_options[count.index], "resource_record_name")}"
  type = "${lookup(aws_acm_certificate.acm_cert.domain_validation_options[count.index], "resource_record_type")}"
  zone_id = "${aws_route53_zone.site_zone.id}"
  records = ["${lookup(aws_acm_certificate.acm_cert.domain_validation_options[count.index], "resource_record_value")}"]
  ttl = 60
}

resource "aws_acm_certificate_validation" "acm_cert" {
  certificate_arn = "${aws_acm_certificate.acm_cert.arn}"
  validation_record_fqdns = ["${aws_route53_record.acm_cert.*.fqdn}"]
}

さらに、CloudFrontが作成される証明書を参照できるようにcloudfront.tfにも以下のように変更を加えます。

# ---------------
# cloudfront.tf
# ---------------

...

# CloudFrontのディストリビューション設定
resource "aws_cloudfront_distribution" "site" {
+  aliases = ["${var.site_domain}"]

   ...

-  viewer_certificate {
-    cloudfront_default_certificate = true
-  }
+  viewer_certificate {
+    acm_certificate_arn = "${aws_acm_certificate_validation.acm_cert.certificate_arn}"
+    ssl_support_method = "sni-only"
+    minimum_protocol_version = "TLSv1"
+  }
}

ここまでできたら、terraform applyを実行して、設定したドメインをブラウザで開いて見てください。 無事、bucketにuploadしたindex.htmlが表示されていれば、これでサイトの構築完了です。

TerraformのModule機能を使った再利用

さて、これでとりあえずの構築は完了したわけですが、なかなか記述量が多かったのではと思います。 「サイト作るたびに、毎回これだけのtfファイルを用意するのだろうか?」と思う方もいるかもしれません。

実際にはTerraformにはモジュールという、tfファイルを再利用する仕組みが用意されています。 このモジュールの仕組みを利用すると、同じようなシステムを構築する際に、再利用することができます。 今回構築したサイトでも、モジュール化しておくと、同じような構成のサイトを作る際に、数行の記述で静的サイトを立ち上げることができるようになります。

また、モジュールを使うと各リソースをモジュール内に隠蔽化することができるというメリットもあるので、再利用の予定がない場合でも、最初からモジュール化した設計にすることで、グローバル空間を汚染しないシンプルな設計にすることができます。

上で構築したサイトをmodule化してみた例を説明してみます。

モジュール化されたコード

今回は「ACMを構築するためのもモジュールacm」と「静的サイトを構築するためのモジュールstatic_site」の2つに分割しました。 作成したモジュールをgithub上に置いてみました。

それぞれのモジュールは以下の3つのファイルで構成されています。

file 説明
mian.tf モジュール内でのリソース定義を行うファイル. (ほとんどモジュールなしで書いたtfファイルを移動させているだけなことがわかると思います)
variables.tf モジュールに対する入力. モジュールをロードする際に、ここで定義されたvariableを引数のように渡す.
outputs.tf モジュールからの出力. モジュールで指定した値のみをモジュールの外側から参照できる. outputしていない値はグローバルからは参照できない( = 隠蔽化できる )

モジュールのより詳細な構造や方針については 公式ドキュメントのCreating Modulesも見てみてください。

次に、このgithub上のモジュールを利用する側のtfファイルの例を説明します。

モジュールを利用して同じ構成を構築してみる

モジュールを使わずに構築したAWSインフラと同様のものを、上のgithub上に置いたモジュールを利用して構築しなおしてみます。 未構築のAWSアカウントを用意して、新規に作業用ディレクトリを用意して、以下のようなファイルymain.tfを作成します。

# ---------------
# main.tf
# ---------------

provider "aws" {
  region     = "us-east-1"
}

# Route53経由でドメインを取得した場合は, importをしておいてください
# 外部でドメインを管理している場合は、一旦以下以下だけを実行して
# 作成されたHosted ZoneのName Serversを管理先でドメインに設定しておいてください
resource "aws_route53_zone" "site_zone" {
  name = "example.com"
}

module "acm" {
  #githubからacmモジュールを読み込み
  source  = "github.com/lucheholdings/terraform_static_site//modules/acm"

  # acmモジュールに入力(variable)を渡す
  root_domain = "example.com"
  zone_id = "${aws_route53_zone.site_zone.zone_id}"
}

module "static_site" {
  #githubからstatic_siteモジュールを読み込み
  source  = "github.com/lucheholdings/terrafrom_static_site//modules/static_site"

  # static_siteモジュールに入力(variable)を渡す
  bucket_name = "site-example.com"
  domain = "example.com"
  zone_id = "${aws_route53_zone.site_zone.zone_id}"

   # acmモジュールの出力(output)を、static_siteモジュールの入力(variable)として利用している
  acm_certificate_arn = "${module.acm.certificate_arn}"
}

これだけの記述でモジュールなしで構築していたシステムと同じ構成を立ち上げることができます。 記述量や内容が、かなりシンプルになったことがわかると思います。 さらに、1つのモジュールを複数回利用することもできるので、サブドメインで複数のサイトを運用するような例でも、以下のように簡単に複数サイトの構築を行うことができます。

# ---------------
# main.tf
# ---------------

provider "aws" {
  region     = "us-east-1"
}

resource "aws_route53_zone" "site_zone" {
  name = "example.com"
}

module "acm" {
  source  = "github.com/lucheholdings/terraform_static_site//modules/acm"
  root_domain = "example.com"
  zone_id = "${aws_route53_zone.site_zone.zone_id}"
}

# ルートドメイン用サイト : example.com用を構築
module "static_site_root" {
  source  = "github.com/lucheholdings/terrafrom_static_site//modules/static_site"
  bucket_name = "site-example.com"
  domain = "example.com"
  zone_id = "${aws_route53_zone.site_zone.zone_id}"
  acm_certificate_arn = "${module.acm.certificate_arn}"
}

# サブドメイン用サイト : foo.example.com用を構築
module "static_site_root" {
  source  = "github.com/lucheholdings/terrafrom_static_site//modules/static_site"
  bucket_name = "site-foo.example.com"
  domain = "foo.example.com"
  zone_id = "${aws_route53_zone.site_zone.zone_id}"
  acm_certificate_arn = "${module.acm.certificate_arn}"
}

# サブドメイン用サイト : bar.example.com用を構築
module "static_site_root" {
  source  = "github.com/lucheholdings/terrafrom_static_site//modules/static_site"
  bucket_name = "site-bar.example.com"
  domain = "bar.example.com"
  zone_id = "${aws_route53_zone.site_zone.zone_id}"
  acm_certificate_arn = "${module.acm.certificate_arn}"
}

このように、モジュールを利用することによって、コードの再利用と、構造化された設計を行なっていくことができます。 またTerraformではModule Registryも用意されているので、公開されているモジュールを利用したり、質の高い公認モジュールを設計の参考にしたりすることができます。

CirlceCIでデプロイする

サイトの構築は終わりましたが、このままだとサイトを更新する際に、S3 Bucketを手動で更新する必要があります。 そこでサイトのコンテンツを管理しているGithubレポジトリと連携して、Githubにコミット時に自動的にデプロイされるCircleCIを設定しておく方法を説明します。

デプロイ用IAMユーザの作成

以下の権限を持つIAMユーザを取得します - 該当S3バケットのRead/Write権限 - 該当CloudFront DestributionのInvaliate権限

そのユーザのCredential情報を取得おいてください。

CircleCIの環境変数を設定しておく

https://circleci.com/docs/2.0/env-vars/#setting-an-environment-variable-in-a-project

上のドキュメントと同様の方法でCircleCIのProjectのBuild Settings > Environment Variablesから以下の変数を設定しておきます。

変数名
S3_BUCKET_NAME 該当サイトをホスティングしてるBucket名
CLOUDFRONT_DISTRIBUTION_ID 該当サイトを配信しているCloudFront DestributionのID
AWS_ACCESS_KEY_ID デプロイ用ユーザのアクセスキー
AWS_SECRET_ACCESS_KEY デプロイ用ユーザのシークレットキー

.circleci/config.ymlの設定

defaults: &defaults
  docker:
    - image: circleci/python:3.6-node-browsers
  working_directory: ~/workdir

version: 2
jobs:

  # Buildを行うJob定義です
  build:
    <<: *defaults
    steps:
      - checkout
      # Buildの実行. 通常は "npm run build"などを入れます.
      # 今回は 動作確認のためにデプロイ時間をpublic/index.htmlに吐くだけの処理になっています
      - run: echo "Hello. Deployed at `date`" > public/index.html
 
      # Buildされたpublicディレクトリを他のjobからも参照できるように保存します. 
      - persist_to_workspace:
          root: .
          paths: public

  # Deployを行うJobの定義です
  deploy:
    <<: *defaults
    steps:
      - checkout
      # Buildのjobで persist_to_workspaceされた内容を復元します
      - attach_workspace:
          at: .
      # awscliのインストールします
      - run:
          name: Install awscli
          command: sudo pip install awscli
      # Buildされたpublicディレクトリをbucketにアップロードします
      - run:
          name: Deploy to S3
          command: aws s3 sync public/ s3://${S3_BUCKET_NAME}/ --delete
      # CloudFrontのcacheをクリアします
      - run:
          name: Invalidate CDN Cache
          command: aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_DISTRIBUTION_ID} --paths '/*'

workflows:
  version: 2
 # buildからdeployまでを行うワークフローを定義
  build-deploy:
    jobs:
      - build
      - deploy:
          # 通常 jobは並列で走りますが, このケースではdeploy前にbuildが終わっておく
          # 必要があるため, その依存関係を以下で定義しています
          requires:
            - build
          # deployはmasterブランチにコミットされた場合のみ実行するようにします     
          filters:
            branches:
              only: master

masterにコミットをしてみると、サイトが更新されることを確認できると思います。 これで、インフラ構築とコミットで自動デプロイの環境が整いました!

おわりに

以上、静的サイトを構築する際に、Terraform & CircleCIをどのように組んだかを解説させていただきました。 弊社では静的サイト以外にも、インフラ構築にはTerraformを、CIにはCircleCIを利用しています。 弊社では、インフラ構築方法やビルドプロセスを改善したいというソフトウェアエンジニアを絶賛募集中です! 少しでもLUCHE HOLDINGS(ルーチェ ホールディングス)に興味を持っていただけた方はぜひ一度弊社にお越しください!

↓また今後も幅広く技術情報を投稿していく予定なので、ご購読いただければ!