はじめに
AWSでリソースをコードとして管理する方法には、CloudFormation、SAM、CDK、Terraformなどいくつかの選択肢があります。それぞれのサービスが持つ特徴と、どのような場面で使うのがベストなのかを実践を通じて学んでいきたいと思います!今回が最終編ということで、Terraformを使ってみました。
TerraformはHashiCorp社が開発したオープンソースのIaCツールで、AWS以外にもAzure、GCPなどのパブリッククラウドにも対応しています。これまでのAWSネイティブツールとの違いを見ていきたいと思います!
-
前回の記事:実践して学ぶAWSのIaC【CDK編】
-
前々回の記事:実践して学ぶAWSのIaC【SAM編】
構築するシステム
ユーザーがアップロードしたファイルを、拡張子に基づいて振り分け、適切なS3バケットに保存する処理を行うものです。
処理フロー:
- API Gatewayを通じてファイルをアップロード
- Lambda関数がファイルを受け取り、拡張子を基にファイル種別を判定
- 適切なS3バケットにファイルを保存
- 画像ファイル(jpg, png, gif)→ images-bucket
- ドキュメント(pdf, docx, txt)→ documents-bucket
- ログファイル(log, csv)→ logs-bucket
Terraformとは
Terraformは、インフラストラクチャをコードとして定義・管理できるオープンソースのツールです。CloudFormationやSAM、CDKがAWS専用なのに対し、TerraformはAWS、Azure、GCPなど複数のクラウドプロバイダーに対応し、HashiCorp Configuration Language(HCL)という独自のDSLで宣言的にインフラを定義します。
プロジェクトの構造
file-sorter-terraform/
├── main.tf
├── variables.tf
├── outputs.tf
├── .gitignore
├── terraform.tfvars
├── .terraform.lock.hcl
└── terraform.tfstate
簡単に各ファイルについて触れておきます。AWSリソースを定義するのは太字のmain.tfです。いくつかのファイルについて以降で紹介します。
- main.tf:AWSリソース定義。S3、Lambda、API Gateway等を定義
- variables.tf:入力変数とlocals定義。環境設定とパラメータ化
- outputs.tf:出力値定義。デプロイ後の参照可能な値
- .gitignore:Git管理除外ファイル。状態ファイルや機密情報を除外
- terraform.tfvars:変数の値設定
- .terraform.lock.hcl:プロバイダーバージョン固定
- terraform.tfstate:インフラ状態管理
Terraformテンプレート
今回作成したテンプレートはこちらです。以下の3つのファイルに絞って紹介します。
- main.tf
- variables.tf
- outputs.tf
1.main.tf
terraform {
required_version = ">= 1.12"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# Get current AWS account ID
data "aws_caller_identity" "current" {}
# ===== S3 Buckets =====
resource "aws_s3_bucket" "images" {
bucket = "file-sorter-images-${var.environment}-${local.account_id}"
}
resource "aws_s3_bucket" "documents" {
bucket = "file-sorter-documents-${var.environment}-${local.account_id}"
}
resource "aws_s3_bucket" "logs" {
bucket = "file-sorter-logs-${var.environment}-${local.account_id}"
}
# ===== IAM Role for Lambda =====
resource "aws_iam_role" "lambda_role" {
name = "file-sorter-lambda-role-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
# Basic Lambda execution policy
resource "aws_iam_role_policy_attachment" "lambda_basic" {
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
role = aws_iam_role.lambda_role.name
}
# S3 write policy
resource "aws_iam_role_policy" "lambda_s3_policy" {
name = "file-sorter-lambda-s3-policy-${var.environment}"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:PutObject"
]
Resource = [
"${aws_s3_bucket.images.arn}/*",
"${aws_s3_bucket.documents.arn}/*",
"${aws_s3_bucket.logs.arn}/*"
]
}
]
})
}
# ===== Lambda Function =====
data "archive_file" "lambda_zip" {
type = "zip"
output_path = "lambda_function.zip"
source {
content = <<LAMBDA_CODE
import json
import boto3
import base64
import os
def lambda_handler(event, context):
try:
# ファイルデータの取得
if event.get('isBase64Encoded', False):
file_content = base64.b64decode(event['body'])
else:
file_content = event['body'].encode()
# ファイル名の取得
file_name = event['headers'].get('x-file-name', 'unknown.txt')
# 拡張子で振り分け
extension = file_name.lower().split('.')[-1]
if extension in ['jpg', 'png', 'gif']:
bucket = os.environ['IMAGES_BUCKET']
elif extension in ['pdf', 'docx', 'txt']:
bucket = os.environ['DOCUMENTS_BUCKET']
else:
bucket = os.environ['LOGS_BUCKET']
# S3に保存
s3 = boto3.client('s3')
s3.put_object(Bucket=bucket, Key=file_name, Body=file_content)
return {
'statusCode': 200,
'body': json.dumps(f'File saved to {bucket}')
}
except Exception as e:
return {
'statusCode': 500,
'body': json.dumps(f'Error: {str(e)}')
}
LAMBDA_CODE
filename = "lambda_function.py"
}
}
resource "aws_lambda_function" "file_sorter" {
filename = data.archive_file.lambda_zip.output_path
function_name = "file-sorter-${var.environment}"
role = aws_iam_role.lambda_role.arn
handler = "lambda_function.lambda_handler"
runtime = "python3.9"
timeout = local.lambda_timeout
memory_size = local.lambda_memory
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
environment {
variables = {
IMAGES_BUCKET = aws_s3_bucket.images.bucket
DOCUMENTS_BUCKET = aws_s3_bucket.documents.bucket
LOGS_BUCKET = aws_s3_bucket.logs.bucket
}
}
}
# ===== API Gateway =====
resource "aws_api_gateway_rest_api" "file_sorter_api" {
name = "file-sorter-api-${var.environment}"
binary_media_types = [
"application/octet-stream",
"image/*",
"application/pdf"
]
}
# /upload リソース
resource "aws_api_gateway_resource" "upload" {
rest_api_id = aws_api_gateway_rest_api.file_sorter_api.id
parent_id = aws_api_gateway_rest_api.file_sorter_api.root_resource_id
path_part = "upload"
}
# POST method
resource "aws_api_gateway_method" "upload_post" {
rest_api_id = aws_api_gateway_rest_api.file_sorter_api.id
resource_id = aws_api_gateway_resource.upload.id
http_method = "POST"
authorization = "NONE"
}
# OPTIONS method (CORS用)
resource "aws_api_gateway_method" "upload_options" {
rest_api_id = aws_api_gateway_rest_api.file_sorter_api.id
resource_id = aws_api_gateway_resource.upload.id
http_method = "OPTIONS"
authorization = "NONE"
}
# Lambda integration
resource "aws_api_gateway_integration" "upload_lambda" {
rest_api_id = aws_api_gateway_rest_api.file_sorter_api.id
resource_id = aws_api_gateway_resource.upload.id
http_method = aws_api_gateway_method.upload_post.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.file_sorter.invoke_arn
}
# OPTIONS integration (CORS)
resource "aws_api_gateway_integration" "upload_options" {
rest_api_id = aws_api_gateway_rest_api.file_sorter_api.id
resource_id = aws_api_gateway_resource.upload.id
http_method = aws_api_gateway_method.upload_options.http_method
type = "MOCK"
request_templates = {
"application/json" = "{\"statusCode\": 200}"
}
}
# Method responses
resource "aws_api_gateway_method_response" "upload_post_200" {
rest_api_id = aws_api_gateway_rest_api.file_sorter_api.id
resource_id = aws_api_gateway_resource.upload.id
http_method = aws_api_gateway_method.upload_post.http_method
status_code = "200"
response_parameters = {
"method.response.header.Access-Control-Allow-Origin" = true
}
}
resource "aws_api_gateway_method_response" "upload_options_200" {
rest_api_id = aws_api_gateway_rest_api.file_sorter_api.id
resource_id = aws_api_gateway_resource.upload.id
http_method = aws_api_gateway_method.upload_options.http_method
status_code = "200"
response_parameters = {
"method.response.header.Access-Control-Allow-Headers" = true
"method.response.header.Access-Control-Allow-Methods" = true
"method.response.header.Access-Control-Allow-Origin" = true
}
}
# Integration responses
resource "aws_api_gateway_integration_response" "upload_post_200" {
rest_api_id = aws_api_gateway_rest_api.file_sorter_api.id
resource_id = aws_api_gateway_resource.upload.id
http_method = aws_api_gateway_method.upload_post.http_method
status_code = "200"
response_parameters = {
"method.response.header.Access-Control-Allow-Origin" = "'*'"
}
depends_on = [aws_api_gateway_integration.upload_lambda]
}
resource "aws_api_gateway_integration_response" "upload_options_200" {
rest_api_id = aws_api_gateway_rest_api.file_sorter_api.id
resource_id = aws_api_gateway_resource.upload.id
http_method = aws_api_gateway_method.upload_options.http_method
status_code = "200"
response_parameters = {
"method.response.header.Access-Control-Allow-Headers" = "'Content-Type,x-file-name'"
"method.response.header.Access-Control-Allow-Methods" = "'POST,OPTIONS'"
"method.response.header.Access-Control-Allow-Origin" = "'*'"
}
depends_on = [aws_api_gateway_integration.upload_options]
}
# Lambda permission
resource "aws_lambda_permission" "api_gateway" {
statement_id = "AllowExecutionFromAPIGateway"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.file_sorter.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.file_sorter_api.execution_arn}/*/*"
}
# Deployment
resource "aws_api_gateway_deployment" "deployment" {
depends_on = [
aws_api_gateway_integration.upload_lambda,
aws_api_gateway_integration.upload_options
]
rest_api_id = aws_api_gateway_rest_api.file_sorter_api.id
stage_name = var.environment
}
S3バケット、Lambda関数、API Gateway、IAMロール・ポリシーを定義し、ファイル拡張子による振り分けシステム全体を構築するように記述しています。
2.variables.tf
variable "environment" {
description = "Deployment environment"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "aws_region" {
description = "AWS region"
type = string
default = "ap-northeast-1"
}
# Local values for computed names
locals {
account_id = data.aws_caller_identity.current.account_id
# Environment-based conditions
is_prod_or_staging = contains(["prod", "staging"], var.environment)
# Lambda configuration based on environment
lambda_timeout = local.is_prod_or_staging ? 15 : 10
lambda_memory = local.is_prod_or_staging ? 256 : 128
}
デプロイする環境(dev/staging/prod)とAWSリージョンの変数宣言、環境に応じたLambdaのタイムアウト・メモリサイズの条件分岐設定を記述しています。
3.outputs.tf
output "api_gateway_url" {
description = "URL of the API Gateway"
value = "https://${aws_api_gateway_rest_api.file_sorter_api.id}.execute-api.${var.aws_region}.amazonaws.com/${var.environment}"
}
デプロイ後に参照可能なAPI GatewayのエンドポイントURLを出力値として設定しています。
Terraformの特徴とメリット
実際にシステムを構築してみて感じた特徴やメリットについて紹介します!
1. HCLの記述性
Conditions:
IsProdOrStaging: !Or [!Equals [!Ref Environment, prod], !Equals [!Ref Environment, staging]]
locals {
is_prod_or_staging = contains(["prod", "staging"], var.environment)
}
TerraformのHCLは、CloudFormationやSAMのYAMLの!Orや!Equalsのような組み込み関数よりも、プログラミング言語に近い自然な記述ができます。条件分岐も直感的で読みやすくなっています!
2. IAM設定の抽象化と明示性
imagesBucket.grantWrite(fileSorterFunction);
resource "aws_iam_role_policy" "lambda_s3_policy" {
policy = jsonencode({
Statement = [{
Effect = "Allow"
Action = ["s3:PutObject"]
Resource = [
"${aws_s3_bucket.images.arn}/*",
"${aws_s3_bucket.documents.arn}/*",
"${aws_s3_bucket.logs.arn}/*"
]
}]
})
}
CDKのgrantWrite()は1行で済む処理をTerraformでは10行以上のIAMポリシー記述が必要になるため、開発効率の面では劣ります。抽象化による簡潔性を重視するか、明示性による透明性を重視するかで、ツールの選択が分かれるポイントになるかもしれません。
3. マルチクラウド対応
今回はAWSのみでしたが、同じHCL記述で他のクラウドプロバイダーにも対応できる柔軟性があります。Terraformを選択する理由として、この点に価値を見出す利用者は多いのではないでしょうか。AWS以外にAzureやGCPを利用するようなケースでは、それぞれのネイティブなIaCツールの学習コストも馬鹿にならないと思うので、Terraformが有力な選択肢になるかと思います。
デプロイ手順と実行時間
time (terraform init && terraform apply -auto-approve)
上記コマンドでデプロイ完了までの実行時間を計測した結果、43.1秒で完了しました。
参考:他のツールを使用した場合の実行時間
- CloudFormation:1分7.4秒
- SAM:1分13.8秒
- CDK:1分16.8秒
Terraformが高速な理由は、CloudFormationサービスを経由せず直接AWS APIを呼び出すためです。CDKやSAMがコードをCloudFormationテンプレートに変換してからデプロイするのに対し、Terraformは中間処理を省略し、並列処理も活用してリソースを効率的に作成することができます。
エンドエンドの試験
簡単に試験手順にも触れておきます。
API_URL=$(terraform output -raw api_gateway_url)
curl -X POST $API_URL/upload \
-H "x-file-name: test.jpg" \
-H "Content-Type: application/octet-stream" \
--data-binary @test.jpg
aws s3 ls s3://file-sorter-images-dev-<account-id>/
このようにして、test.jpgが画像ファイル用のバケットに保存されることを確認しています。
まとめ
今回はTerraformでファイル振り分けシステムを構築してみました!HCLの記述性やマルチクラウド対応など、Terraform特有の恩恵を知ることができました。
4つのIaCツールを実際に使ってみることで、それぞれの特徴と適用場面が分かるようになってきました。プロジェクトの要件に応じて最適なツールを選択することが重要ですね!
次回は、これまでの学習を踏まえて、各IaCツールを総合的に比較してみたいと思います!
