はじめに
ある時、Terraformの存在を知る機会がありました。
その時はGCPへのデプロイに用いましたが、仕事の中ではほぼAWSを利用しており、どれくらい便利なのだろうか試してみようと思いました。
Terrafromとは?
公式ページの説明文には次のように書かれています(DeepL翻訳より)
TerraformはInfrastructure as Code(IaC)ツールで、インフラの構築、変更、バージョン管理を安全かつ効率的に行うことができます。これには、コンピュートインスタンス、ストレージ、ネットワークなどの低レベルのコンポーネントや、DNSエントリ、SaaS機能などの高レベルのコンポーネントが含まれます。Terraformは、既存のサービスプロバイダーとカスタムインハウスソリューションの両方を管理できます。
インストール
公式のインストール手順に全部まとまっていました。
自分はWindows11のWSL2上で動いているUbuntu20.04にインストールしています。
バージョン情報
$ terraform -v
Terraform v1.0.11
on linux_amd64
1. 設定ファイルを用意する
Terraform用の設定ファイルを作成します。
名前は何でも良いですが、.tf
という拡張子のファイルを用意します。
この中に設定を記述していきます。
また、ここではAWSのS3に静的ウェブサイトをホスティングするというシナリオで進めます。
1.1. プロバイダを定義
Terraformのプロバイダとは、各クラウドサービスやSaaSプロバイダ、その他APIとやり取するためのプラグインです。
この節で作成する内容は次のようになります。
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
}
}
}
# Configure the AWS Provider
provider "aws" {
region = "ap-northeast-1"
profile = "myprofile"
}
required_providers
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
}
}
}
Terraformがどのプロバイダを利用するか宣言する必要があります。
基本的には各プロバイダのドキュメントに書かれており、上記はAWSプロバイダを使用する際の例です。
では実際にプロバイダの設定を定義しましょう。
provider
provider "aws" {
region = "ap-northeast-1"
profile = "myprofile"
}
ここではリージョンと、使用するAWSのプロファイル名を指定しています。
クレデンシャルの指定方法はいくつかあるのですが、これが一番良さそうに感じます。
【余談】クレデンシャルの指定方法
1.ハードコード
provider "aws" {
region = "ap-northeast-1"
access_key = "my-access-key"
secret_key = "my-secret-key"
}
2.環境変数を使用する
-
AWS_ACCESS_KEY_ID
&AWS_SECRET_ACCESS_KEY
AWS_PROFILE
provider "aws" {
region = "ap-northeast-1"
# ここには何も指定しない
}
例:
$ AWS_PROFILE=myprofile terraform apply
AWS CLIを使用する際に設定する環境変数を使用可能。リージョン指定も同様。
3.profile
引数を指定
provider "aws" {
region = "ap-northeast-1"
profile = "myprofile"
# shared_credentials_file = "/path/to/credentials"
}
プロファイルを参照する場所は、Linuxではデフォルトが ~/.aws/credentials
となっている。
shared_credentials_file
引数を指定すると、参照先を変更可能。
閑話休題
1.2. リソースを定義
resource
キーワードを使用して、リソースブロックを作成して定義します。
resource "resource_type" "resource_name" {
# ...
}
リソースブロックでは、リソースタイプの指定と名前付け、ブロックの中で様々な設定を行います。
リソースタイプ
- リソースタイプは、常に
${provider_name}_
という接頭辞を持ちます。- 例:
uniquevision_beluga
というリソースタイプは、uniquevision
というプロバイダに属する。
- 例:
- ブロック内は、「引数」「属性」「メタ引数」を使用して設定を記述します。
引数(arguments)
- リソース固有の設定はこちらで行います。
- 設定が必須のものとそうでないものがあり、詳細は各リソースのドキュメントに記載されています。
属性(attributes)
- 既存のリソースに設定されている値のこと。
-
resource_type.resource_name.attribute_name
の形式で参照することができます。
メタ引数(meta-arguments)
- リソースの動作を変更するような引数
- 例:
count
メタ引数を使って複数のリソースを作成する
- 例:
- これはリソースやプロバイダー固有の設定ではなくTerrafrom自体の機能
S3バケット用のリソースを定義
resource "aws_s3_bucket" "s3bucket" {
bucket = "terraform-test-20211208"
}
記述する引数はリソースによるので、詳細は各リソースのドキュメントを参照されたし。
上記は最小の例。
2. 一旦デプロイしてみる
2.1. terraform init
$ terraform init
terraform init
コマンドは、設定ファイルに記述されたプロバイダやリソースの定義に基づいて、リモートからモジュールをダウンロードするコマンドです。リソースを追加したらこれを行う必要があります。
2.2. terraform plan
$ terraform plan
デプロイを行った場合、定義したリソースについてどのような変化(作成・更新・削除)が起こるかを表示してくれます。
2.3. terraform apply
$ terraform apply
実際にデプロイを実行します。
実行の前に、terraform plan
を実行したときと同じものが表示される。その後 yes
の入力を求められ、入力することでデプロイが開始されます。
3. ファイルも一緒に置いてみる
上記ではバケットを作っただけだったのですが、今度は作成したバケットにファイルをアップロードするようにしてみます。
3.1. リソースを追加
先ほど作成した設定ファイルに以下の記述を追加
# Put single file
resource "aws_s3_bucket_object" "single_object" {
bucket = aws_s3_bucket.s3bucket.bucket
key = "index.html"
source = "../dist/index.html"
}
bucket
引数では、同じ設定ファイル内のリソースの属性を参照しています。
${リソース名}.${一意な名前}.${属性名}
で参照可能です。
アップロードするファイルを source
引数に指定し、再度 terraform apply
コマンドを実行すると、無事S3にファイルがアップロードされています。
ただしこれでは問題があります。
3.2. ディレクトリを指定してアップロードする
上記の記述の仕方では、ディレクトリを一つ指定してその中のファイルを全てアップロードするといった処理が行えません。
ここで登場するのが、メタ引数の一つである for_each
です。
先ほどのリソース定義を次のように修正します。
# Put multiple files
resource "aws_s3_bucket_object" "multiple_objects" {
for_each = fileset("../dist/")
bucket = aws_s3_bucket.s3bucket.bucket
key = each.value
source = "../dist/${each.value}"
}
for_each
を使用すると、リソース内で each
というメタ属性が使用可能になります。
上記の場合、組み込み関数である fileset
に渡したディレクトリの中のファイルパスが順に each
に渡されます。
これによって、1つのファイルではなく、1つのディレクトリを指定してアップロードすることが可能になりました。
実は問題はまだ解決していないのです。それを確認するためにまたデプロイしてみましょう。
今度は ../dist
の中に index.js
と style.css
も置いてみます(複数のファイルが増えていれば何でもよいです)。
terraform apply
コマンドを実行すると、無事、全てのファイルがアップロードできたでしょうか?
試しにindex.htmlにアクセスしてみましょう。
3.3. MIMEタイプ
上記の方法でアップロードすると、HTMLファイルのMIMEタイプが binary/octet-stream
となってしまいます。
aws_s3_bucket_object
リソースでは content_type
の指定もできますが、それではHTMLとそれ以外でリソースを分ける手間が発生してしまいます。
Template Directory Module
これは既知の問題として知られており、Terraformの提供しているモジュールを利用することで対処可能です。
設定ファイルに以下の内容を追記し、モジュールを定義します。
module "distribution_files" {
source = "hashicorp/dir/template" # 固定
base_dir = "../dist" # ファイルを読み取るディレクトリ
}
また、先ほど作成した aws_s3_bucket_object
リソースを以下のように修正します。
resource "aws_s3_bucket_object" "multiple_objects" {
for_each = module.distribution_files.files
bucket = aws_s3_bucket.s3bucket.bucket
key = "dir2/${each.key}"
source = each.value.source_path
content_type = each.value.content_type
}
Template Directory Module は、filesetと同じように for_each
に渡して使用しますが、MIMEタイプの情報も持っているため、一緒に引数に渡すことで期待する結果になります。
4. ファイルの更新
Terraformは、設定ファイルに記述したリソースやその引数の値に変化が無いとデプロイが行われません。
例えば、ここまで用意した設定ファイルを用いて既にTerraformによってアップロードがされたファイルについて、その中身を修正しただけで対象ディレクトリ内のファイルの追加・削除やファイル名の更新が無い場合、リソースの持つ値が変らないため「No changes.」と出てしまいデプロイができません。
そのため、ファイルに何らかの更新がされた際に値が変化するような仕掛けをする必要があります。
よくある方法としては、ファイル内容からハッシュ値を生成して引数に渡すというものがあります。
以下のように記述してみます。
resource "aws_s3_bucket_object" "multiple_objects" {
for_each = module.distribution_files.files
bucket = aws_s3_bucket.s3bucket.bucket
key = "dir2/${each.key}"
source = each.value.source_path
content_type = each.value.content_type
etag = filemd5(each.value.source_path)
}
組み込み関数である filemd5
とは、与えられたパスのファイルの内容からハッシュ値を生成します。これを aws_s3_bucket_object
のリソースに渡すことで、内容に変更があった場合はハッシュ値が変化するためデプロイ対象になるというやり方です。
ここまでで、任意のファイルの追加・更新・削除をS3に反映させる仕組みが整いました。
5. CloudFront Distributionの作成
せっかくなので、作成されたバケットをオリジンとするCloudFront Distributionも管理してみましょう。
5.1. リソースの作成
設定ファイルに以下の記述を追加します。
aws_cloudfront_distribution
リソースは、必須項目が多いので少し長くなっています。
# Create a CloudFront Origin Access Identity
resource "aws_cloudfront_origin_access_identity" "oai" {
comment = "Terraform Test"
}
# Create a CloudFront Distribution
resource "aws_cloudfront_distribution" "s3_distribution" {
origin {
domain_name = aws_s3_bucket.s3bucket.bucket_regional_domain_name
origin_id = aws_s3_bucket.s3bucket.bucket_regional_domain_name
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.oai.cloudfront_access_identity_path
}
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # CachingOptimized
target_origin_id = aws_s3_bucket.s3bucket.bucket_regional_domain_name
viewer_protocol_policy = "redirect-to-https"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
comment = "Terraform Test"
default_root_object = "index.html"
enabled = true
price_class = "PriceClass_200"
wait_for_deployment = false # これを設定しないと、完全に使用できるようになる(=ステータスがDeploymentになる)まで処理が終わらなくなる
}
オリジンアクセスアイデンティティを作成するために、リソースを定義しています。
また、aws_cloudfront_distribution
リソースからそれを参照しています。
5.2. Invalidation
せっかくなので、静的ファイルの更新時にキャッシュ削除もされるように設定しましょう。
Invalidationはリソースとしては用意されていないため、素直にAWS CLIコマンドを実行します。
一旦、まずは terraform apply
の度にキャッシュ削除が行われるようにしてみましょう。
以下の記述を追加します。
resource "null_resource" "invalidation" {
triggers = {
check_value = timestamp()
}
provisioner "local-exec" {
command = "aws cloudfront create-invalidation --profile myprofile --distribution-id $DISTRIBUTION_ID --path '/*'"
environment = {
DISTRIBUTION_ID = aws_cloudfront_distribution.s3_distribution.id
}
}
}
色々新しい記述が含まれているので、1つずつ見ていきます。
null_resource
通常のリソースと同じく、作成・更新・削除のライフサイクルを実装してはいますが、実際に何かを作成したり削除したりというアクションは行いません。
他のリソースと同様、初回追加時は terraform init
コマンドが必要になります。
triggers
triggers
引数で設定された値が変化したとき、このリソースを置き換えます。
provisioner
これが(現状)null_resource
を使う最大のメリットです。
provisionerブロックは、Terraformの設定ファイルの中で宣言するモデルでは直接表現できない、特定のアクションをモデル化するのに使用されます。
ただし、provisionerはあらゆるアクションができるという点で、Terraformの使用に不確実性をもたらしてしまいます。
command
command
引数には、行いたいコマンドを記述します。
上記の例では、AWS CLIを用いて特性のディストリビューションでキャッシュの削除を行うようなコマンドを記述しています。
envirionment
コマンド実行時に、環境変数としてTerraform内の値を渡すことが可能です。
上記の例では、Terraform設定ファイル内で定義されたリソースの値を参照しています。
コマンドが内部で環境変数を参照するなど、コマンド引数で渡せない場合は環境変数で指定するのがよさそうです。
5.3. Invalidationをファイル差分のある時に限定する
単にディストリビューションの設定を変えただけでキャッシュを無効化していては無駄が多いですしお金がもったいないです($0.005/回)。
ファイルに差分があることを検証するにはどうしたらよいでしょうか?
ファイルの差分を検知する仕組みは、既にS3バケットへのファイルのアップロードの項で説明しました(etag
にファイルのハッシュを渡す方法)。
ではどうやって複数のファイルを監視すればよいでしょうか?
archive_file
Terrafromには、ZIPファイル等アーカイブを作成する便利な方法があります。
以下の記述を追加します。
data "archive_file" "dist" {
type = "zip"
source_dir = "../dist"
output_path = "/tmp/terraform/src.zip"
}
data
キーワードを使用すると、Data Sourceの作成ができます。Data Sourceについては、ここでは深く説明はしません。
また、先ほど作成した null_resource
の trigger
を修正します。
resource "null_resource" "invalidation" {
triggers = {
src_hash = "${data.archive_file.dist.output_sha}"
}
provisioner "local-exec" {
command = "aws cloudfront create-invalidation --profile home --distribution-id $DISTRIBUTION_ID --path '/*'"
environment = {
DISTRIBUTION_ID = aws_cloudfront_distribution.s3_distribution.id
}
}
}
アップロード対象のディレクトリを指定してZIP化し、そのハッシュの変化をトリガーに設定することで、ファイルの追加・削除・内容の更新によって差分が発生するとキャッシュ削除が実行されるようになりました。
6. ログ出力
では、実際に自身のAWSコンソールでデプロイされたか確認しましょう。あなたはAWSコンソールからディストリビューションの一覧を確認します。
数が多い(100ページ)。そしてステータスがIn Progressのものが無い...。果たしてうまくいったのかわからない。探すのもこの数では時間が...。
ここではあるリソースの属性を参照しているのだが、どうにも上手く動かないようだ。
このパスにファイルがあるはず...、いや無い。しかし作成はされているようだ。いったいどこに作られてしまっている?
Terraformのエラーログはとても見やすいですが、だからと言ってデバッグが必要無くなることはないでしょう。
6.1. output
CloudFrontディストリビューションは、作成後ユニークなIDが割り当てられます。
terraform apply
実行後、すぐにAWSコンソールに確認に行きたいのですが、そのユニークな値がわかればすぐに探せそうですね。
設定ファイルに次の記述を追加してみましょう。
output "cloudfront_distribution_id" {
value = aws_cloudfront_distribution.s3_distribution.id
description = "Cloudfront Distribution ID"
}
output
キーワードの直後にダブルクォートで囲った変数名が、valueに指定した値ですよという出力がされます。
例:
Apply complete! Resources: X added, Y changed, Z destroyed.
Outputs:
cloudfront_distribution_id = "EWL0CDW7YJZ9H"
まとめと今後
- これまでは、必要な環境変数を全部最初にセットして、READMEに羅列されたコマンドをコピー&ペーストで実行していただけだったが、Terraformでコマンド一発で実行できるようになったのが大きい
- また実行前に何が変化するのかわかるのは、ミス防止にも役立つと思われる。
- 今回は触れなかったが、ステージごとに異なる変数定義ファイルを用意できるので、より使い勝手は良さそう(曖昧)。
- アドベントカレンダーに間に合わなければ次回発表に回す予定だが、以下の内容を考えている。
- GitLab CIのTerraform統合機能を試す
- BelugaキャンペーンのECS周りを模倣して全部Terafformで管理できるか試してみる
- LocalStackとの組み合わせ