Terraformを使って学ぶーAWSにインフラを構築するIaCの基本と、SREが実務で役立つ機能とエコシステムを徹底解説

Terraformは、パブリッククラウドのインフラ構築と自動化のツールとして、IaCのデファクトスタンダードとなっています。この記事では、AWS(Amazon Web Services)を活用するハンズオンを通してTerraformの動作を理解し、実務にもとづいて役立つ機能や便利なエコシステム、さらにSRE視点の事例を紹介します。アソビュー株式会社でSREユニットリーダーを務める鈴木剛志さんを中心に6名のメンバーによる共同執筆です。 アイキャッチ画像

Terraformを使って学ぶーAWSにインフラを構築するIaCの基本と、SREが実務で役立つ機能とエコシステムを徹底解説

アソビューでは、インフラストラクチャーの変更管理にTerraformを利用しています。Terraformはクラウドインフラの自動構築ツールで、HashiCorp, Inc.によって開発されました。

Terraform by HashiCorp

今回の記事では、アソビューが普段の業務でTerraformをどのように利用しているか、どういう手順で構築すればよりよく活用できるのかといった話を交えながら、実務でインフラを管理されている方に向けて実践的なTerraformの知識の一部をご紹介できればと思います。

内容は環境を作るところから始めて、運用やエコシステムなどいくつかのテーマに分けて書いています。とくに運用のセクションはそれぞれ依存関係がなく、触ってみたい部分だけを実践する読み方も可能になっています。より理解が深まるように、実際に手を動かしながら読んでいただくことを推奨しています。

Terraform=宣言型IaCツールのメリット

インフラストラクチャーをコードを用いて管理することを、一般的にはIaC(Infrastructure as Code)と表現します。IaCは、大きく宣言型と命令型の2つのタイプに分けられます。宣言型は最後の形を表現するコードを記述するのに対して、命令型はこの処理をこの順番で行うという書き方をします。

単純な例で説明します。Amazon S3にexampleというバケットを作って、そこに環境名を表すタグを作りたい場合、Terraformのような宣言型のIaCツールでは次のように記述できます。

resource "aws_s3_bucket" "example" {
  bucket = "my-bucket-for-dev"

  tags = {
    Environment = "Dev"
  }
}

このように、ツールが提供するフォーマットに従って、完成形の状態を記述するのが宣言型です。

一方、命令型では次のように、aws cliの仕様に従って命令を1つずつ投げていく形式で記述します。どちらを実行しても同じ結果が得られます。

aws s3api create-bucket --bucket my-bucket-for-dev
aws s3api put-bucket-tagging --bucket my-bucket-for-dev --tagging 'TagSet=[{Key=Environment,Value=Dev}]'

それではここで、このバケットに対して新しいタグを付与したいというケースを考えてみましょう。宣言型の場合には、次のように「Type = "Image"」という1行を追加することになります。これをIaCツールが現在の状況と比較して判断し、適切な差分を補完してくれるのが宣言型の挙動です。

resource "aws_s3_bucket" "example" {
  bucket = "my-bucket-for-dev"

  tags = {
    Environment = "Dev"
    Type = "Image"
  }
}

一方、命令型でゼロから作る場合には、次のコマンドを投入することになります。このように自分でスクリプトを書く場合、現在の状態を見て必要なコマンドを実行するといった対応が必要です。

aws s3api create-bucket --bucket my-bucket-for-dev
aws s3api put-bucket-tagging --bucket my-bucket-for-dev --tagging 'TagSet=[{Key=Environment,Value=Dev}]'
aws s3api put-bucket-tagging --bucket my-bucket-for-dev --tagging 'TagSet=[{Key=Type,Value=Image}]'

また、命令型の処理をべき等になるよう記述するには、if分岐などを駆使しなくてはなりません。そのためシェルなどの簡易言語で命令型を記述することも多かったのですが、宣言型のツールが広く使われるようになって、環境に対する変更も簡単に記述できるようになりました。

さらに、これをGitなどのソースコード管理システムで管理すると、いつどのような差分が入ったのかも分かりやすくなる利点もあります。

ハンズオンに必要な環境とTerraformの準備

ここでは、ハンズオンで扱う環境の設定から実際に動かすまでの準備について説明します。なお、掲載したサンプルは以下の環境で動作を確認しています。

  • macOS Monterey 12.3
  • AWS CLI 2.9.9
  • Terraform 1.5.0
  • tfenv 3.0.0

AWSを操作するAWS CLIのインストールと設定

このハンズオンでは、クラウド環境としてAWS(Amazon Web Services)上にインフラを構築します。そのため、コマンドラインからAWSを管理できるAWS CLIを用意してください。

AWS CLIをインストールする必要がある方は、次の公式ドキュメントを参考にしてください。

AWS CLIの最新バージョンを使用してインストールまたは更新を行う - AWS Command Line Interface

また、ハンズオンでAWSクレデンシャル(認証情報)が必要になります。例えばAWSアカウントでIAMユーザーを次のように作成します。

  1. AWSマネジメントコンソールの「IAM」に移動
  2. 「ユーザー」→「ユーザーを作成」の順にクリック
  3. ユーザー名を入力
  4. 「許可を設定」画面の「ポリシーを直接アタッチする」で「AdministratorAccess」を選択
  5. ユーザーを作成
  6. セキュリティ認証情報からアクセスキーを作成
  7. アクセスキーを取得

この認証情報などの設定は、次のドキュメントの「設定ファイルと認証ファイルの形式」で「IAM role」タブの設定例などを参考にしてください。

設定ファイルと認証情報ファイルの設定 - AWS Command Line Interface

このとき、説明例で[profile user1]とあるプロファイル名は、Terraformの設定時にprovider.tfファイルで使用します。このハンズオンの説明に合わせるなら次のようにしてください。

[profile terraform-hands-on]

tfenvを用いたTerraformのインストール

Terraformをインストールするには、tfenvを用います。tfenvは、Terraformのバージョンを管理してくれるツールです。複数のバージョンで開発する場合があるので、このツールを入れておくと便利です。

tfenvそのもののインストール方法は、次の公式ドキュメントを参照してください。

tfutils/tfenv: Terraform version manager

tfenvがインストールされた前提で、Terraformのインストールを含めた基本的な使い方を説明します。

まず次のコマンドで、インストール可能なTerraformのバージョン一覧を取得できます。

❯ tfenv list-remote
1.6.0-alpha20230719
1.5.4
1.5.3
1.5.2
1.5.1
1.5.0
1.5.0-rc2
1.5.0-rc1
1.5.0-beta2
1.5.0-beta1
1.5.0-alpha20230504
1.5.0-alpha20230405
1.4.6

・・・

今回はバージョン1.5.0をインストールします。

❯ tfenv install 1.5.0

インストールが完了したらバージョンを確認します。

❯ tfenv list
* 1.5.0 (set by /opt/homebrew/Cellar/tfenv/3.0.0/version)
  0.13.7
・・・

複数バージョンをインストールしている場合は、次のコマンドで環境を切り替えることができます。

❯ tfenv use 1.0.11

使ってみよう ― ローカルファイルを作成する構成例

インストールできたところで、試しにTerraformを使ってローカルファイルを作成してみましょう。

まずterraform-testという作業ディレクトリを作成し、以下のディレクトリやファイルを用意します。配下のファイルを順に説明していきます。

terraform-test
 L modules           ## 本番、検証環境共通のモジュールを用意します。
    L local_file        ## リソースごとにディレクトリを用意します。
       L main.tf           ## 本番、検証環境に共通したリソースの設定を記入します。
       L variable.tf       ## main.tfで変数を記入します。
 L playground        ## 検証環境用のモジュールです。
    L bacnkend.tf       ## tfファイルを管理します。場所はAWSアカウントのS3バケットです。
    L main.tf           ## 検証環境用の変数を記入します。
    L provider.tf       ## プロバイダーを管理します。
    L version.tf        ## Terraformのバージョンを指定します。

直下のmodulesディレクトリには、検証環境で利用するモジュールを置きます。ここでは「local_file」モジュールがあります。

modules/local_file/main.tfでは、ローカルファイルを生成するlocal_fileリソースを定義します。この作業ディレクトリ構成では、リソースごとにモジュールを用意します。

resource "local_file" "helloworld" {
    content  = var.content
    filename = var.filename
}

上記で使用する変数を、module/local_file/variable.tfで定義します。

variable "content" {
  type = string
}

variable "filename" {
  type = string
}

今回はplaygroundディレクトリに、疎通確認を実行する検証環境を定義します。

playground/main.tfでは、local_fileモジュールでローカルにhello.txtというファイルを作成します。

module "local_file"{
  source    = "../modules/local_file"
  content   = "hello world!"
  filename  = "hello.txt"
}

playground/provider.tfではproviderブロックでAWSを指定し、さらにどのAWSアカウントでどのリージョンに作るのかを定義します。プロファイルなどは先ほどの認証情報に合わせてください。

provider "aws" {
  region  = "ap-northeast-1"
  profile = "terraform-hands-on"
}

playground/versions.tfでは、Terraformのバージョンを指定します。

terraform {
  required_version = "~> 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
}

playground/backend.tfでは、Terraformが管理するリソースの状態を記録するStateファイル(tfstate)を指定します。今回はAWSアカウントにS3バケットを用意して、管理します。

terraform {
  backend "s3" {
    bucket  = "terraform.hands.on"                      # <-tfファイルを配置するS3バケットの名前を記載します。
    region  = "ap-northeast-1"                          # <-tfファイルを配置するS3バケットがいるリージョンを指定します。
    key     = "terraform-hands-on/terraform.tfstate"    # <- S3バケット内のterraform-hands-onディレクトリ配下でtfstateファイルを管理するよう指定します。
    profile = "terraform-hands-on"                      # <- playground/provider.tfで記載しているプロファイルを指定します。
  }
}

使ってみよう ― Terraformを実行してローカルファイルを作成

作業ディレクトリのterraform-test/playgroundでTerraformを実行してみましょう。

まず、terraform initします。コマンドの詳細は後で説明します。

❯ terraform init

Initializing the backend...
Initializing modules...
- s3 in ../modules/s3

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 4.0"...
- Installing hashicorp/aws v4.67.0...
- Installed hashicorp/aws v4.67.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

次にterraform planを実行し、作成予定のリソースを確認します。

❯ terraform plan
module.s3.aws_s3_bucket.this: Refreshing state... [id=terraform-hands-on]
module.s3.aws_s3_bucket_ownership_controls.this: Refreshing state... [id=terraform-hands-on]
module.s3.aws_s3_bucket_public_access_block.this: Refreshing state... [id=terraform-hands-on]
module.s3.aws_s3_bucket_acl.this: Refreshing state... [id=terraform-hands-on,public-read]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.local_file.local_file.helloworld will be created
  + resource "local_file" "helloworld" {
      + content              = "hello world!"
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "hello.txt"
      + id                   = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

そしてterraform applyを実行すると、hello.txtファイルが作成されます。

❯ terraform apply
module.s3.aws_s3_bucket.this: Refreshing state... [id=terraform-hands-on]
module.s3.aws_s3_bucket_public_access_block.this: Refreshing state... [id=terraform-hands-on]
module.s3.aws_s3_bucket_ownership_controls.this: Refreshing state... [id=terraform-hands-on]
module.s3.aws_s3_bucket_acl.this: Refreshing state... [id=terraform-hands-on,public-read]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.local_file.local_file.helloworld will be created
  + resource "local_file" "helloworld" {
      + content              = "hello world!"
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "hello.txt"
      + id                   = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

module.local_file.local_file.helloworld: Creating...
module.local_file.local_file.helloworld: Creation complete after 0s [id=430ce34d020724ed75a196dfc2ad67c77772d169]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed. <-作成が成功しました。

以下のように、playground配下にhello.txtが作成されていれば疎通確認の完了です。

terra1

使ってみよう ― 作成したローカルファイルを削除する

続いて、作成したhello.txtファイルを削除してみましょう。

  1. terraform-test/playground/main.tfに記載したコードを削除
  2. terraform initしてterraform planで差分を確認
  3. terraform applyを実行

これで削除できます。terraform applyの出力結果は以下のようになります。

❯ terraform apply
module.local_file.local_file.helloworld: Refreshing state... [id=430ce34d020724ed75a196dfc2ad67c77772d169]
module.s3.aws_s3_bucket.this: Refreshing state... [id=terraform-hands-on]
module.s3.aws_s3_bucket_ownership_controls.this: Refreshing state... [id=terraform-hands-on]
module.s3.aws_s3_bucket_public_access_block.this: Refreshing state... [id=terraform-hands-on]
module.s3.aws_s3_bucket_acl.this: Refreshing state... [id=terraform-hands-on,public-read]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  - destroy

Terraform will perform the following actions:

  # module.local_file.local_file.helloworld will be destroyed
  # (because local_file.helloworld is not in configuration)
  - resource "local_file" "helloworld" {
      - content              = "hello world!" -> null
      - content_base64sha256 = "dQnlvaDHYtK6x/kNdYtbImP6Acy8VCq1498WO+CObKk=" -> null
      - content_base64sha512 = "25sc0yYt7jd1agm5BklzWJhHyqjlPTGp0ULqJwGxsoq9l4OLuaJwaLowXcjQSkWh/PB53lTWB2ZplrPMVPa2fA==" -> null
      - content_md5          = "fc3ff98e8c6a0d3087d515c0473f8677" -> null
      - content_sha1         = "430ce34d020724ed75a196dfc2ad67c77772d169" -> null
      - content_sha256       = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" -> null
      - content_sha512       = "db9b1cd3262dee37756a09b9064973589847caa8e53d31a9d142ea2701b1b28abd97838bb9a27068ba305dc8d04a45a1fcf079de54d607666996b3cc54f6b67c" -> null
      - directory_permission = "0777" -> null
      - file_permission      = "0777" -> null
      - filename             = "hello.txt" -> null
      - id                   = "430ce34d020724ed75a196dfc2ad67c77772d169" -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

module.local_file.local_file.helloworld: Destroying... [id=430ce34d020724ed75a196dfc2ad67c77772d169]
module.local_file.local_file.helloworld: Destruction complete after 0s

Apply complete! Resources: 0 added, 0 changed, 1 destroyed. <- destroy(リソース削除)に成功しました。

ハンズオンで実行するTerraformコマンドについて

すでに環境のセットアップでいくつかコマンドを打ってきましたが、改めて概略を説明します。各作業ディレクトリで作業するときには、次のコマンドを利用します。

terraform init

このinitコマンドは、スタックを作った後に必ず一度最初に実行します。

terraform plan

このplanコマンドで、適用する前にどのような処理が実行されるのかを確認します。コードの変更によって意図した変更がされることを適用前に確認しておきます。

現在の適用状況については、Stateファイル(tfstate)により管理されます。Stateファイルの状況については、次のコマンドで一覧を取得できます。

terraform state list

このリストの値を参照したい場合には、次のコマンドを実行します。

terraform state show [項目名]

マルチクラウドサポートとモジュールの利用

Terraformでは、次のTerraform Registryというサイトでモジュールが公開されており、AWSやGCP(Google Cloud Platform)、Azureなどの各種クラウドベンダーのリソースを管理するためのモジュールがあります。

HashiCorp | Terraform Registry

自分がやりたいことなどがある場合には、まずは、公開されたモジュールがあるかどうかを検索するのがおすすめです。基本的には、自分たちの用途に必要なものを上記のサイトで探して利用するようにしましょう。

CIに変更確認を組み込む

ファイルを変更したときに、CIツールで前述のterraform planによる結果を表示するようにしておくと便利です。適用するとどのような変更がされるのかが分かりやすく、しっかりレビューしてもらった上で本番環境に適用できます。

実践的な環境構築 ― S3とCloudFrontでSPAアプリケーション

このセクションでは、Amazon S3とCloudFrontを利用したSPAアプリケーションの環境を構築します。

SPAアプリケーションを単に公開するだけなら、S3の静的ホスティング機能も利用できます。しかし、大量アクセスに対して可用性を維持し、コンテンツへダイレクトアクセスされるセキュリティリスクを避け、より高速なコンテンツデリバリの実現を検討するなら、CloudFrontと組み合わせることが最適です。

このハンズオンでは上記の点を踏まえて、S3へのアクセスをCloudFront経由のみに許可するアプリケーション環境を構築します。

なお、以下はこのセクションで実施・説明しません。

  • WAFとCloudFrontとの連携
  • 独自ドメイン設定によるアプリケーション公開
  • SPAアプリケーションの作成方法
  • SPAアプリケーションの自動反映

S3バケットを作成する

作業ディレクトリのファイル構成は以下の通りです。それぞれの概要は前セクションの構成図のコメントを参考にしてください。先ほどの「Local_files」に代わって「s3」モジュールがあります。

terraform-test
 L modules
    L s3
       L main.tf
       L variable.tf
 L playground
    L bacnkend.tf
    L main.tf
    L provider.tf

まずmodules/s3/main.tfでは、S3バケットを提供するaws_s3_bucketリソースを定義します。バケット名は変数で指定します。

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
}

modules/s3/variable.tfで、バケット名の変数を定義します。

variable "bucket_name" {
  type = string
}

playground/main.tfで、作成するバケット名を指定します。

module "s3" {
  source      = "../modules/s3"
  bucket_name = "terraform-spa-app-hands-on"
}

なお、ここでは「terraform-spa-app-hands-on」という名称を指定していますが、S3のバケット名はグローバルで一意でなければならないため、このバケット名はもう使えません。読者の皆さんがハンズオンを実施する際には名称を適宜変更してください。

上記の定義でterraform applyまでコマンドを実行すると、S3バケットが作成されます。前述の通りアクセスをCloudFront経由のみで許可するため、ここではパブリック公開を設定していません。AWSのコンソールで確認すると、次のように非公開で「パブリックアクセスをすべてブロック」設定となっています。

terra2

ただしこの状態では、CloudFront経由のみというアクセス制御もまだ設定できていません。これは以降の手順で説明します。

CloudFrontを作成する

続いてCloudFrontの作成です。ポイントとして、OAC(Origin Access Control)機能を利用したS3へのアクセス制御を設定します。

CloudFrontからS3へのアクセス制御は、以前まではOAI(Origin Access Identity)によるものが一般的でしたが、2022年に新たなアクセス制御方法としてOACが利用できるようになりました詳細。またAWSからもOACを利用することが推奨されています。

作業ディレクトリのファイル構成は、次のように変更(追加および修正)となります。

terraform-test
 L modules
    L cloudfront        ◎ 追加
       L main.tf        ◎ 追加
       L variable.tf    ◎ 追加
    L s3
       L main.tf
       L outputs.tf     ◎ 追加
       L variable.tf
 L playground
    L bacnkend.tf
    L main.tf           ○ 修正
    L provider.tf

まず、CloudFrontのためのモジュールを追加します。modules/cloudfront/main.tfは次のようになります。定義項目が多いため、コード内にコメントに示した説明を参照してください。

resource "aws_cloudfront_distribution" "this" {
  origin {
    domain_name = var.bucket_domain_name
    origin_id   = var.bucket_id
    # OACの設定
    # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution#origin_access_control_id
    origin_access_control_id = aws_cloudfront_origin_access_control.this.id
  }

  enabled             = true
  default_root_object = "index.html"

  # 今回はGetリクエストのみ対象とします。
  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = var.bucket_id

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

    viewer_protocol_policy = "redirect-to-https"
    # コンテンツキャッシュ時間
    min_ttl                = 60
    default_ttl            = 60
    max_ttl                = 60
    compress               = true
  }

  # 特定のファイルのみキャッシュ時間を変更したいなどのニーズがある場合は、
  # デフォルトのビヘイビアを上書きする設定を追加する
  # ordered_cache_behavior {
  #   path_pattern = "/static/css/*"
  #   # :(割愛)
  # }

  # 地理的制限
  # 必須で設定が必要. 今回はサンプルのため特に制限をかけない形としています。
  # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution#restrictions-arguments
  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  # ディストリビューションのSSL設定
  # 今回はCloudFrontのドメイン名を利用するためtrueとします。
  # 独自のドメインでの配信を行う場合は追加の設定が必要です。
  # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution#viewer_certificate
  # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution#viewer-certificate-arguments
  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

 # OACの作成
 # 各項目の詳細は以下を参照してください。
 # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_origin_access_control
resource "aws_cloudfront_origin_access_control" "this" {
  name = "terraform-spa-app-hands-on.s3.ap-northeast-1.amazonaws.com"
  # 現在設定できるタイプは's3'か'mediastore'のどちら
  origin_access_control_origin_type = "s3"
  # 現在設定できるタイプは'always'・'never'・'no-override.'のいずれか
  signing_behavior = "always"
  # 現在設定できるprotocolは'sigv4'のみ
  signing_protocol = "sigv4"
}

modules/cloudfront/variable.tfで変数を定義します。

variable "bucket_id" {
  type = string
}

variable "bucket_domain_name" {
  type = string
}

続いて、s3モジュールにはmodules/s3/outputs.tfを追加します。ここではCloudFront側にS3バケットの情報を連携するため、outputブロックで出力を定義します。

output "bucket_id" {
  value = aws_s3_bucket.this.id
}

output "bucket_regional_domain_name" {
  value = aws_s3_bucket.this.bucket_regional_domain_name
}

最後にplayground/main.tfで、先ほど追加したcloudfrontモジュールの設定を追加します。s3モジュールの出力を参照しています。

module "s3" {
  source      = "../modules/s3"
  bucket_name = "terraform-spa-app-hands-on"
}

 # 追加
module "cloudfront" {
  source             = "../modules/cloudfront"
  # S3のBucketの情報を設定する
  bucket_id          = module.s3.bucket_id
  bucket_domain_name = module.s3.bucket_regional_domain_name
}

上記の設定を反映後、作成されたCloudFrontのディストリビューションをAWSコンソールから確認します。

terra3

「オリジン」タブから編集画面に進むと、OACが設定されていることが分かります。

terra4

パケットポリシーをコピーする

このディストリビューション経由でS3にアクセスできるようにするには、バケットポリシーをS3側に設定する必要があります。ポリシーの定義内容は次の画面からコピーできます。

terra5

コピーしたポリシーが以下になります(一部マスクしています)

{
  "Version": "2008-10-17",
  "Id": "PolicyForCloudFrontPrivateContent",
  "Statement": [
      {
          "Sid": "AllowCloudFrontServicePrincipal",
          "Effect": "Allow",
          "Principal": {
              "Service": "cloudfront.amazonaws.com"
          },
          "Action": "s3:GetObject",
          "Resource": "arn:aws:s3:::terraform-spa-app-hands-on/*",
          "Condition": {
              "StringEquals": {
                "AWS:SourceArn": "arn:aws:cloudfront::xxxxxxxx(アカウントID):distribution/xxxxxxxx(ディストリビューションID)"
              }
          }
      }
  ]
}

このポリシーと同等の設定を、TerraformのS3側に定義します。

S3にバケットポリシーを追加する

先ほどコピーしたポリシーには、特定のアカウントIDやディストリビューションIDの情報が含まれています。個人の環境であればそのまま使っても問題ないと思いますが、業務などで複数アカウント(例えば開発環境用と本番環境用など)で利用したいケースもあるでしょう。その点を踏まえて実装していきます。

作業ディレクトリのファイル構成は、次のように変更(追加および修正)となります。

terraform-test
 L modules
    L cloudfront
       L main.tf
       L outputs.tf     ◎ 追加
       L variable.tf
    L s3
       L main.tf        ○ 修正
       L outputs.tf
       L variable.tf    ○ 修正
 L playground
    L bacnkend.tf
    L main.tf           ○ 修正
    L provider.tf

まずs3モジュールから見ていきます。modules/s3/main.tfは次のように修正します。dataブロックの記述は上記のコピーしたポリシーと同じ内容ですが、CloudFrontのARNは変数を参照しています。

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
}

 # バケットポリシーの作成の定義の追加
resource "aws_s3_bucket_policy" "this" {
  bucket = aws_s3_bucket.this.id
  policy = data.aws_iam_policy_document.this.json
}

 # 複数環境で共通利用できるようcloudfrontのarnやs3のarnの値は変数で設定する
data "aws_iam_policy_document" "this" {
  statement {
    sid = "AllowCloudFrontServicePrincipal"
    principals {
      identifiers = ["cloudfront.amazonaws.com"]
      type        = "Service"
    }
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.this.arn}/*"]
    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = [var.cloudfront_arn]
    }
  }
}

modules/s3/variable.tfでは、上記で参照している変数の定義を追加します。

variable "bucket_name" {
  type = string
}

 # 追加
variable "cloudfront_arn" {
  type = string
}

cloudfrontモジュール側のmodules/cloudfront/outputs.tfでは、ARN情報をS3側に連携するためoutputブロックで出力します。

output "cloudfront_arn" {
  value = aws_cloudfront_distribution.this.arn
}

playground/main.tfでは、cloudfrontから出力されたARNをs3モジュールの変数として参照します。

module "s3" {
  source      = "../modules/s3"
  bucket_name = "terraform-spa-app-hands-on"
  # 追加
  cloudfront_arn = module.cloudfront.cloudfront_arn
}

上記を反映後、S3のAWSコンソールの「アクセス許可」タブで、定義したバケットポリシーが反映されていることが確認できます。

terra6

SPAアプリケーションの動作確認

SPAアプリケーションを別途作成してS3バケットにアップロードし、CloudFrontで配信されるドメインのURLにWebブラウザから正常にアクセスできることを確認します(ドメインは該当ディストリビューションの「一般」タブで確認してください)

https://{cloudfront-distribution-domain}

以下はサンプルとしてReactで作成したSPAアプリケーションです。

terra7

今回S3のアクセス制御の説明のため、実装を細かく分けて解説しましたが、一括で定義ファイルを作成し、反映する形でも問題はありません。また今回はSPAアプリケーションを手動で反映しましたが、デプロイの自動化をしたい場合はさらにS3に設定の変更が必要です。

補足. CloudFrontの設定の調整

SPAアプリケーションで存在しないページにアクセスした場合、以下のような403エラーとなってしまいます。

terra8

システムのエラーがそのまま表示されるのはよろしくないため、CloudFrontで特定のエラー発生時の代替レスポンスを設定します。403エラー発生時には、トップページにリダイレクトされるよう設定しました。

  # エラー発生時のレスポンスのカスタマイズ
  custom_error_response {
    error_code            = 403
    response_code         = 200
    response_page_path    = "/index.html"
  }

上記を反映後、不正なパスにアクセスするとトップページにリダイレクトされることが確認できました。

terra9

Terraformを利用したSPA環境の環境構築手順は以上となります。今回の内容だけでは説明が十分ではない部分もありますので、より詳しく知りたい場合は、Terraformの公式ドキュメントなどをご確認ください。

TIPS. Terraformの運用で役立つ機能とエコシステムを紹介

このセクションでは、アソビューがTerraformを運用する中で役立ってきたいくつかの機能を紹介します。セクションの後半では、Terraformのエコシステムの利用についても説明します。

機能1. 既存のAWSリソースをTerraform管理下にする

すべてのAWSリソースをTerraformで管理できている状態は理想的ですが、実際の運用ではそのようにいかないことが多々あります。例えば以下のユースケースが該当します。

  • 途中からTerraformの運用を開始する
  • Terraformを運用しているが、緊急的にやむを得ずAWSマネジメントコンソールからリソースを作成した

このような場合、既存のAWSリソースをTerraformの管理下に置く必要があります。Terraformバージョン1.5でimportブロックが追加されたので、今回はこれを利用します。

インポートの対象になるリソースを作成する

AWSのマネジメントコンソールから、バケット名「manual-created-bucket」でS3バケットを新規作成しておきます。これがTerraformの管理下にないことを確認しましょう。

次のように同じ名前のリソースを定義します。

resource "aws_s3_bucket" "manual-created-bucket" {
  bucket = "manual-created-bucket"
}

これでterraform planを実行すると新規作成という扱いになり、先ほどマネジメントコンソールで作成したS3バケットをTerraformが認識していないことが分かります。

> terraform plan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_s3_bucket.manual-created-bucket will be created
  + resource "aws_s3_bucket" "manual-created-bucket" {
      + acceleration_status         = (known after apply)
      + acl                         = "private"
      + arn                         = (known after apply)
      + bucket                      = "manual-created-bucket"
      + bucket_domain_name          = (known after apply)
      + bucket_regional_domain_name = (known after apply)
      + force_destroy               = false
      + hosted_zone_id              = (known after apply)
      + id                          = (known after apply)
      + object_lock_enabled         = (known after apply)
      + region                      = (known after apply)
      + request_payer               = (known after apply)
      + tags_all                    = (known after apply)
      + website_domain              = (known after apply)
      + website_endpoint            = (known after apply)
    }
Plan: 1 to add, 0 to change, 0 to destroy.

なお、実際にterraform applyすると同じバケットがすでに存在するためエラーになります。

インポートする定義ファイルを適用する

それではこの「manual-created-bucket」をTerraformの管理下にインポートします。次のようにimportブロックを定義します。

import {
  id = "manual-created-bucket"             #リソース識別子
  to = aws_s3_bucket.manual-created-bucket #import先のリソースを指定する
}

resource "aws_s3_bucket" "manual-created-bucket" {
  bucket = "manual-created-bucket"
}

terraform planすると、importされることが確認できます。

> terraform plan
aws_s3_bucket.manual-created-bucket: Preparing import... [id=manual-created-bucket]
aws_s3_bucket.manual-created-bucket: Refreshing state... [id=manual-created-bucket]

Terraform will perform the following actions:

  # aws_s3_bucket.manual-created-bucket will be imported
    resource "aws_s3_bucket" "manual-created-bucket" {
        arn                         = "arn:aws:s3:::manual-created-bucket"
        bucket                      = "manual-created-bucket"
        bucket_domain_name          = "manual-created-bucket.s3.amazonaws.com"
        bucket_regional_domain_name = "manual-created-bucket.s3.ap-northeast-1.amazonaws.com"
        hosted_zone_id              = "XXXXXXXXXXXXXX"
        id                          = "manual-created-bucket"
        object_lock_enabled         = false
        region                      = "ap-northeast-1"
        request_payer               = "BucketOwner"
        tags                        = {}
        tags_all                    = {}

        grant {
            id          = "05fe9ca108f7173912ab5dab5ee2c0a5a445bc50470193d54e7fde8fcbd0ed97"
            permissions = [
                "FULL_CONTROL",
            ]
            type        = "CanonicalUser"
        }

        server_side_encryption_configuration {
            rule {
                bucket_key_enabled = true

                apply_server_side_encryption_by_default {
                    sse_algorithm = "AES256"
                }
            }
        }

        versioning {
            enabled    = false
            mfa_delete = false
        }
    }
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.

terraform applyしてインポートを実施します。

> terraform apply
Apply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.

これで既存のAWSリソースをTerraform管理下に置くことができました。

機能2. movedブロックを利用したリファクタリング

movedブロックとは、Terraformにおけるリソース名を明示的に変更できる機能です。リファクタリング時に既存のリソース名を変更したいユースケースで利用できます。

今回は、TerraformでS3バケットを新規作成後、Terraform上のリソース名とタグを変更します。

まず次の定義を適用して、S3バケットを新規作成しておきます。

resource "aws_s3_bucket" "before" {
  bucket = "playground-sample-bucket"
  tags = {
    Name = "before"
  }
}
既存の方法でリソース名を変更すると

続いて、前述の定義から次の2点を変更してみます。

  • リソース名を「before」から「after」に変更
  • S3バケットのタグを「before」から「after」に変更
resource "aws_s3_bucket" "after" {
  bucket = "playground-sample-bucket"
  tags = {
    Name = "after"
  }
}

ここでterraform planを確認すると、既存リソース「before」が削除されて新規リソース「after」が作成される予定になっています。

> terraform plan
aws_s3_bucket.before: Refreshing state... [id=playground-sample-bucket]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
  - destroy

Terraform will perform the following actions:

  # aws_s3_bucket.after will be created
  + resource "aws_s3_bucket" "after" {
      + acceleration_status         = (known after apply)
      + acl                         = (known after apply)
      + arn                         = (known after apply)
      + bucket                      = "playground-sample-bucket"
      + bucket_domain_name          = (known after apply)
      + bucket_prefix               = (known after apply)
      + bucket_regional_domain_name = (known after apply)
      + force_destroy               = false
      + hosted_zone_id              = (known after apply)
      + id                          = (known after apply)
      + object_lock_enabled         = (known after apply)
      + policy                      = (known after apply)
      + region                      = (known after apply)
      + request_payer               = (known after apply)
      + tags                        = {
          + "Name" = "after"
        }
      + tags_all                    = {
          + "Name" = "after"
        }
      + website_domain              = (known after apply)
      + website_endpoint            = (known after apply)
    }

  # aws_s3_bucket.before will be destroyed
  # (because aws_s3_bucket.before is not in configuration)
  - resource "aws_s3_bucket" "before" {
      - arn                         = "arn:aws:s3:::playground-sample-bucket" -> null
      - bucket                      = "playground-sample-bucket" -> null
      - bucket_domain_name          = "playground-sample-bucket.s3.amazonaws.com" -> null
      - bucket_regional_domain_name = "playground-sample-bucket.s3.ap-northeast-1.amazonaws.com" -> null
      - force_destroy               = false -> null
      - hosted_zone_id              = "Z2M4EHUR26P7ZW" -> null
      - id                          = "playground-sample-bucket" -> null
      - object_lock_enabled         = false -> null
      - region                      = "ap-northeast-1" -> null
      - request_payer               = "BucketOwner" -> null
      - tags                        = {
          - "Name" = "before"
        } -> null
      - tags_all                    = {
          - "Name" = "before"
        } -> null

      - grant {
          - id          = "05fe9ca108f7173912ab5dab5ee2c0a5a445bc50470193d54e7fde8fcbd0ed97" -> null
          - permissions = [
              - "FULL_CONTROL",
            ] -> null
          - type        = "CanonicalUser" -> null
        }

      - server_side_encryption_configuration {
          - rule {
              - bucket_key_enabled = false -> null

              - apply_server_side_encryption_by_default {
                  - sse_algorithm = "AES256" -> null
                }
            }
        }

      - versioning {
          - enabled    = false -> null
          - mfa_delete = false -> null
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

この方法ではリソースが作り直されてしまいますが、今回の期待値はリソース名の変更のみです。ここでmovedブロックを利用すると解決できます。

movedブロックでリソース名の変更を定義する

定義ファイルのリソース名を書き換えるだけでなく、次のようにmovedブロックを追加します。

resource "aws_s3_bucket" "after" {
  bucket = "playground-sample-bucket"
  tags = {
    Name = "after"
  }
}

moved {
  from = aws_s3_bucket.before # 変更前のリソース名
  to = aws_s3_bucket.after # 変更後のリソース名
}

これでterraform planすると、リソース名とTagが変更されることが確認できます。

> terraform plan
aws_s3_bucket.after: Refreshing state... [id=playground-sample-bucket]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # aws_s3_bucket.after will be updated in-place
  # (moved from aws_s3_bucket.before)
  ~ resource "aws_s3_bucket" "after" {
        id                          = "playground-sample-bucket"
      ~ tags                        = {
          ~ "Name" = "before" -> "after"
        }
      ~ tags_all                    = {
          ~ "Name" = "before" -> "after"
        }
        # (9 unchanged attributes hidden)

        # (3 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

機能3. terraform planの結果をGitHubのプルリクエストと連携する

Terraformのコードレビューでは、前述のようにソースコードに加えてplanの結果を含めて確認する必要があります。アソビューではGitHubで開発を進めているため、Terraformの修正を行ったときにplanの結果をプルリクストにコメントする仕組みをCIで採用しています。

これはtfcmtというOSSを利用して実現します。

suzuki-shunsuke/tfcmt: Fork of mercari/tfnotify. tfcmt enhances tfnotify in many ways, including Terraform >= v0.15 support and advanced formatting options

例えば、先ほどのimportブロックを利用した場合、プルリクエストには下記のようなコメントが追加されるため、効率的なレビューを実施できます。

terra10

アソビューではコメントのテンプレートをymlファイルで管理しており、必要な情報を可能な限り追加できるようにしています。

機能4. dynamicブロックの利用

dynamicブロックとは、リソースの中のブロック単位で繰り返しを行う記法です。

繰り返し記法には他に「count」と「for_each」が存在しますが、それらはリソース単位で繰り返しを行います。つまりdynamicブロックでは作成されるリソースは1つですが、「count」と「for_each」では繰り返しの数だけリソースが作成されます。

dynamicブロックを利用して、複数のS3バケット(bucket1とbucket2)において、特定のアクションを許可するIAMポリシーを定義します。

次のように「aws_iam_policy_document」のデータソース定義でdynamicブロックを利用すると、1つのデータソースに複数のステートメントを定義することができます。仮にcountとfor_eachを利用して定義する場合はaws_iam_policy_documentデータソースが2つ作成されます。

locals {
  s3_bucket_names = ["bucket1_arn", "bucket2_arn"]
}

data "aws_iam_policy_document" "iam_policy_document" {
  dynamic "statement" { # dynamicブロック
    for_each = local.s3_bucket_names # ループしたい変数を展開
    content {
      actions = [
        "s3:Get*",
        "s3:List*",
        "s3:Put*",
        "s3:DeleteObject*",
      ]
      resources = [
        statement.value, # dynamicブロック名「statement」で要素にアクセスできる
        "${statement.value}/*",
      ]
    }
  }
}

resource "aws_iam_policy" "this" {
  name   = "aws_iam_policy"
  policy = data.aws_iam_policy_document.iam_policy_document.json
}

下記がterraform planの結果です。期待値通りにバケットポリシーが設定されています。

data.aws_iam_policy_document.iam_policy_document: Reading...
aws_s3_bucket.manual-created-bucket: Refreshing state... [id=manual-created-bucket]
data.aws_iam_policy_document.iam_policy_document: Read complete after 0s [id=3960698127]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_iam_policy.this will be created
  + resource "aws_iam_policy" "this" {
      + arn         = (known after apply)
      + id          = (known after apply)
      + name        = "aws_iam_policy"
      + name_prefix = (known after apply)
      + path        = "/"
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = [
                          + "s3:Put*",
                          + "s3:List*",
                          + "s3:Get*",
                          + "s3:DeleteObject*",
                        ]
                      + Effect   = "Allow"
                      + Resource = [
                          + "bucket1_arn/*",
                          + "bucket1_arn",
                        ]
                      + Sid      = ""
                    },
                  + {
                      + Action   = [
                          + "s3:Put*",
                          + "s3:List*",
                          + "s3:Get*",
                          + "s3:DeleteObject*",
                        ]
                      + Effect   = "Allow"
                      + Resource = [
                          + "bucket2_arn/*",
                          + "bucket2_arn",
                        ]
                      + Sid      = ""
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + policy_id   = (known after apply)
      + tags_all    = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

エコシステム1. tfenvでTerraformバージョンを固定して運用

アソビューではTerraformのバージョンを管理するツールであるtfenvと、.terraform-versionファイル(Terraformのバージョンを指定するメタファイル)を組み合わせてバージョンを管理運用しています。そのメリットと、実際にバージョンを固定にすることでどのような恩恵があるかについて記載します。

tfenvのメリット

複数のバージョンのTerraformを扱う必要がある場合は、簡単にバージョンの切り替えができるようになります。さらに、.terraform-versionファイルと組み合わせて利用することで、異なるバージョンを使ってしまう事故を防ぐことができます。

また引数なしのtfenv installを実行すると、.terraform-versionファイルに記載されたバージョンをインストールできます。メリットをまとめると以下のようになります。

  • バージョンの切り替えが容易になる
  • バージョンが異なるスタックの管理が容易になる
  • バージョンを固定にするとバージョンの差異による問題が発生しない
tfenvでのバージョン不整合の検知

tfenvを利用することで、実際にバージョンの不整合を検知する例を紹介します。

前提として.terraform-versionファイルに「1.2.6」と記載してinstallコマンドを実行したとします。実際にインストールされているバージョンをlistコマンドで確認できます。

$ tfenv list
  1.3.0
* 1.2.6 (set by /stacks/.terraform-version)
  1.2.4
  0.14.11
  0.13.0
  ...

このとき、次のようなvariableブロックがあったとします。

terraform {
  experiments = [module_variable_optional_attrs]
}

variable "test_list" {
  type = list(
    object({
      id           = string
      title        = string
      resource_map = list(object({
        id              = string
        endpoint        = string
      }))
    })
  )
}

ここで.terraform-versionファイルの内容を「1.3.0」に変更(バージョンアップ)したとします。すると、「module_variable_optional_attrs」がこのバージョンから利用できなくなっているため、次のようなエラーになりました。

╷
│ Error: Experiment has concluded
│
│   on ../modules/variables.tf line 2, in terraform:
│    2:   experiments = [module_variable_optional_attrs]
│
│ Experiment "module_variable_optional_attrs" is no longer available. The final feature corresponding to this experiment differs from the experimental form and is available in the Terraform language from Terraform v1.3.0 onwards.
╵

バージョンを固定化しておくことによって異なるバージョンで本番環境へ反映できなくなるため、予期せぬ障害を回避することができます。

エコシステム2. fmtでフォーマットの統一

Terraformには、公式が用意しているフォーマットコマンドのfmtがあります。次のようにコマンドを1つ実行するだけでフォーマットしてくれます。

terraform fmt

しかし、複数の開発者がいる場合は、フォーマットの違いで可読性の低下や予期せぬバグが発生してしまう可能性があります。

そのため、fmtを自動化しておくことが大切になります。もちろん複数人に限らず、1人で開発している場合でもfmtを忘れてしまうことがあるので、fmtを自動化しておくことがおすすめです。アソビューではコミット時とCI時にfmtを利用し、そして自動化しています。

コミット時に自動フォーマットを行う

アソビューではGitHubを利用しているため、pre-commitにfmtコマンドを埋め込んでいます。 pre-commitのコマンドシェル自体もGitHubで管理し、開発者が環境構築をするときにpre-commitコマンドがローカル環境に反映されるようにしておくとよいでしょう。

terraform fmt -recursive --diff
for file in `git diff --diff-filter=d --staged --name-only`; do
    git add "../../${file}"
done
CI時に自動フォーマットを行う

pre-commitでは開発者に委ねるところになりますので漏れる可能性もあります。よって、最終的なチェックとしてCIに組み込んでおくことが必要です。アソビューではCircleCIを利用しており、applyの実行前にフォーマットのチェックを導入しています。

iac-terraform__all:
    executor: { name: ci-terraform }
    steps:
      - job_support:
          steps:
            - checkout_package
            - run:
                name: terraform format check
                command: |
                  cd packages/iac-terraform/stacks
                  terraform fmt -diff -check -recursive
            - run:
                name: terraform apply
                command: |
                  cd packages/iac-terraform/stacks
                  /* apply処理 */

エコシステム3. TFLintの使用

TFLintは、TerraformのためのLinterです。TFLintは非推奨の構文や未使用の宣言、命名規則の違反などを検出してくれます。

ここではTFLintの簡単な使い方を解説します。

TFLintのインストール

ここでは、Homebrewを利用してインストールします。

$ brew install tflint

今回、インストールしたバージョンは次の通りです。

$ tflint --version
TFLint version 0.47.0

TFLintには推奨されるプラグインがデフォルトでバンドルされています。また、AWS・GCP・Azureといったプロバイダごとのプラグインがあります。例えば、AWSプロバイダのプラグインをインストールする場合には、.tflint.hclファイルに次のように記述します。

plugin "aws" {
    enabled = true
    version = "0.24.3"
    source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

そして、次のコマンドを実行してプラグインをインストールします。

$ tflint --init
Installing `aws` plugin...
Installed `aws` (source: github.com/terraform-linters/tflint-ruleset-aws, version: 0.24.3)
TFLintの使い方

次のようなリソースを定義します。

resource "aws_instance" "main" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "t3.micro"
}

terraform {
  required_version = "~> 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

TFLintを実行します。ここでは特に不正な記述はないため、何も出力されません。

$ tflint

今度は次のようなリソースを定義します。instance_typeに不正な値を記述します。

resource "aws_instance" "main" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "x3.micro"
}

terraform {
  required_version = "~> 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

TFLintを実行します。今度はエラーメッセージが表示されます。

$ tflint
1 issue(s) found:

Error: "x3.micro" is an invalid value as instance_type (aws_instance_invalid_type)

  on main.tf line 3:
   3:   instance_type = "x3.micro"

このように、TFLintでは不正な定義を検出できます。

deep checkingを有効にする

ところで、先ほどのリソース定義の「ami」に指定したAMI IDは存在しません。これは実際にAWSのAPIで問い合わせないとチェックができません。これをチェックするには、次のように設定を.tflint.hclに追加してdeep checkingを有効にします。

plugin "aws" {
  enabled    = true
  deep_check = true
  version    = "0.24.3"
  source     = "github.com/terraform-linters/tflint-ruleset-aws"
}

先ほどTFLintが正しく実行できたリソース定義でも、この設定でTFLintを実行するとエラーメッセージが表示されます。

$ tflint
1 issue(s) found:

Error: "ami-0abcdef1234567890" is invalid AMI ID. (aws_instance_invalid_ami)

  on main.tf line 2:
   2:   ami           = "ami-0abcdef1234567890"
一歩進んだ使い方

TFLintのルールでは、個別に有効・無効を切り替えたり、自分たちの運用に合わせたルールを定義したりできます。tflint-ruleset-awsにおいて、S3バケットの命名規則を定義する例を説明します。

.tflint.hclに次のように定義します。

rule "aws_s3_bucket_name" {
  enabled = true
  prefix  = "terraform-hands-on-"
}

ここで、次のようなリソースを定義します。

resource "aws_s3_bucket" "this" {
  bucket = "hogehoge"
}

TFLintを実行します。バケット名が定義した命名規則に沿っていないため、エラーメッセージが表示されました。

$ tflint
1 issue(s) found:

Warning: Bucket name "hogehoge" does not have prefix "terraform-hands-on-" (aws_s3_bucket_name)

  on main.tf line 2:
   2:   bucket = "hogehoge"

Reference: https://github.com/terraform-linters/tflint-ruleset-aws/blob/v0.24.3/docs/rules/aws_s3_bucket_name.md
ルールを無効にする

次に、ルールを無効にする方法を説明します。次のようなリソースを定義します。

resource "aws_instance" "main" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "t1.micro"
}

terraform {
  required_version = "~> 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

TFLintを実行します。前世代のインスタンスタイプを指定しているため、エラーメッセージが表示されます。

$ tflint
1 issue(s) found:

Warning: "t1.micro" is previous generation instance type. (aws_instance_previous_type)

  on main.tf line 3:
   3:   instance_type = "t1.micro"

Reference: https://github.com/terraform-linters/tflint-ruleset-aws/blob/v0.24.3/docs/rules/aws_instance_previous_type.md

ルールを無効にするには2つの方法があります。.tflint.hclに定義する方法と「tflint-ignore」を利用する方法です。.tflint.hclに定義する場合は次のように設定します。

rule "aws_instance_previous_type" {
  enabled = false
}

tflint-ignoreを利用する場合は、コメントとして設定します。

resource "aws_instance" "main" {
  ami           = "ami-0abcdef1234567890"
  # tflint-ignore: aws_instance_previous_type
  instance_type = "t1.micro"
}

どちらの設定でTFLintを実行しても、先ほどのエラーメッセージは表示されなくなります。

なお、デフォルトで有効になっているルールは、TFLintや各プロパイダのプラグインとして推奨されたルールであるため、特別な理由がない限りは無効にしない方がよいでしょう。

SREの観点で見るTerraform

ここまでは、具体的なTerraformの使い方について説明をしてきました。ここからはアソビューのSREチームとSRE観点から見たときのTerraformについてお話ししようと思います。

SREではサービスが稼働している・稼働していないにかかわらず、ITインフラをTerraformでコード管理しています。Terraformで管理しているのには、いくつか理由があります。

変更管理を容易にする
変更差分をきちんとGitで管理できるので、変更や切り戻し時にかかる手間や時間が、コード管理をしていない場合と比べると比較的少ないように思います。アソビューでは、マネジメントコンソールでリソースを変更する場合には、ダブルチェックをするという運用を行っておりますが、AWSのリソースの設定を変更するときに複数人でダブルチェックをするやり方と、コードを修正してレビューをもらう時間や手間とを比較すると、後者の方が圧倒的に効率的な運用が可能になります。
複数環境への展開
本番環境、検証環境の環境構築やプロジェクトごとに必要になった環境構築(検証環境のコピーなど)が、モジュールを展開するだけで簡単に複製できるため、コード管理していない状態と比べると開発開始までのリードタイムが短いのも利点だと考えています。また、複数のAWSアカウントへ同じリソースを展開したい場合も、楽に複製できます。
新規アプリケーションに必要なリソースが分かりやすくなる、または類似モジュールを簡単に展開できる
マイクロサービスアプリケーションごとに、Dockerレジストリやコンテナイメージスキャンの設定や、ライフサイクルの設定、アプリケーション用の権限を作成するなど、基本的な設定群をTerraformで管理すると、新しいマイクロサービスを作る場合も必要なセットが分かりやすくなります。また、そのリソースの作成に必要なリソースセットを作成する際にも複製が楽になるというメリットがあります。

実際のプロジェクト事例

SREチームでは、横断的にサービスの可用性や信頼性の向上のための改善活動をしていますが、その一環としてSLO(Service Level Object)も監視しています。SLOとはサービスを運営する上での目標値であり、この目標を下回らないように運営していきます。

アソビューでは主な指標として、Uptime、Availability、Latencyの目標値を各種重要なエンドポイントごとに定めて、Datadogのダッシュボードを用いて日々チェックする運用を行っています。このとき次のような役割分担をしています。

  • SREチームでは、Datadogダッシュボードに表示する部分をTerraformで記述する
  • 開発チームでは、エンドポイントに対する目標値を定めた上で、SLOを運用する

運用していく上で計算式の微調整を行いたいなどの要望が生まれてきましたが、Terraformで管理することによって各チームが管理しているSLOを一元的に修正することが可能になります。

Terraformの管理が適しているものと適さないもの

アソビューでの事例を紹介してきましたが、これまでのトライアンドエラーで管理して良かったもの、管理して困ったものについて簡単に紹介しようと思います。

基本的には、すべてのクラウドリソースをAWSで管理するべきだと考えています。ほとんどのリソースについては、管理したからといって困る部分はありませんでした。一部、困った部分として下記のようなケースがありました。

AWSにはControlTowerなど、マネジメントコンソールから一元管理しやすいように特化された機能がいくつかあります。この機能を使った方がいいのか、Terraformで管理した方がいいのかという議論について、現時点ではControlTowerなどで統合がサポートされている機能に関しては、Terraformで管理するよりもAWSの機能に頼った方が運用が楽になるケースもあるように感じています。

また、最新のバージョンアップに追随しやすいという点でも、Terraformで管理しているとやや窮屈に感じる部分がありました。具体的には、Terraformで管理していたAmazon ElastiCacheでRedisのバージョンの7系がリリースされた後、Terraformの機能が追いつかず、結局は手動でバージョンを上げる運用が発生しました。もともとコード管理していたので、バージョン部分をignore_changeで表現して暫定的に対応しました。

AWSの最新アップデートにすぐ追随する必要があるケースでは、Terraformにすることで逆に不便になることもあるかもしれません。

トラブルシューティング実例 ― AWSのAPI呼び出しで原因不明のエラー

Terraformを利用してインフラ構築する中で、エラーが発生して思い通りに進まないことがあります。ここでは、実際に直面したエラーをどのように解決したかを解説します。

CloudFrontのディストリビューション作成がエラーになる事例

TerraformでCloudFrontのディストリビューションを作成していました。これは、オリジンをサードパーティのCDNサービスに向けたものでしたが、実際にterraform applyしてみると次のようなエラーメッセージが表示されました。

Error: creating CloudFront Distribution: InvalidArgument: The parameter Origin DomainName does not refer to a valid S3 bucket.
    status code: 400, request id: xxx-xxx-xxx
  ...

CloudFrontのディストリビューションを作成する際に、何かしらのパラメータが正しくないようです。ここで、CloudTrailのログを見てみます。ログには次のように出力されていました。

...(省略)
"origins": {
    "quantity": 2,
    "items": [
        {
            "originPath": "",
            "connectionTimeout": 10,
            "s3OriginConfig": {
                "originAccessIdentity": ""
            },
            "id": "foo-example.cdn.com",
            "domainName": "example.cdn.com",
            "connectionAttempts": 3,
            "customHeaders": {
                "quantity": 0,
                "items": []
            },
            "originAccessControlId": ""
        }
    ]
},
...(省略)

どうやら、オリジンをサードパーティのCDNサービスにしたいのですが、オリジンがS3であると解釈して実行しているようです。ディストリビューションのオリジンをS3以外にする場合、custom_origin_configの定義が必要になります。今回はoriginの定義にcustom_origin_configを追加することでディストリビューションが作成できるようになりました。

resource "aws_cloudfront_distribution" "distribution" {
  origin {
    domain_name = xxx
    origin_id   = xxx
    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "match-viewer"
      origin_ssl_protocols = [
        "TLSv1.1",
        "TLSv1.2",
      ]
      origin_keepalive_timeout = 5
      origin_read_timeout      = 60
    }
  }
  ...(省略)
}

最後に

ここまで読み進めていただきありがとうございます。今回の記事では、実践的なTerraformの使い方を具体的なコードでご紹介するとともに、SREではTerraformを使うにあたってどのような視点が大事なのかということを補足する形でお届けしました。

今後も多くのアップデートがあると思います。さらにTerraformを学んでいきたい方は、公式のチュートリアルを次のページから探してみるのもおすすめです。

Tutorials | Terraform | HashiCorp Developer

また、Terraformのベストプラクティスは次のサイトが参考になるかもしれません。

Terraform を使用するためのベスト プラクティス - Google Cloud

この記事でTerraformへの理解が少しでも深まり、少しでも皆様の業務に応用してもらえれば幸いです。

参考情報

環境と準備のセクションを執筆する際に参考にした情報のリストです。

アソビュー株式会社でSREユニットに所属するメンバーを中心に執筆しました(50音順敬称略)

  • 頭島卓也(かしらじま・たくや / GitHub: kassshi
  • 鈴木剛志(すずき・たけし / GitHub: Takesuz)ユニットリーダー
  • 鈴木洋平(すずき・ようへい / GitHub: suzukiyo
  • 三森弘満(みつもり・ひろみつ / GitHub: hmitsumori
  • 村松健太郎(むらまつ・けんたろう / GitHub: kntmr
  • 山野泰裕(やまの・やすひろ / GitHub: yasuhiro

編集:中薗 昴
制作:はてな編集部

若手ハイキャリアのスカウト転職