はじめに
以前は Terraform を頻繁に利用していましたが、最近は触れる機会がなく、どのような機能があるのか気になっていました。また、2025年11月17日に一般公開された Gemini 3 へ「過去に自分が執筆した Terraform 関連の技術ブログ」を参考情報としてインポートすれば、効率的に前線復帰できるのでは?という思いも重なり、アドカレのタイミングを利用して改めて Terraform を触ってみました。
過去には以下のような記事を発信していました。
| 公開場所 | 公開日 | 内容とリンク |
|---|---|---|
| Future Tech Blog | 2020.11.13 | LocalStackに向けてTerraformを実行する |
| connpass | 2022.2.17 | Future Tech Night #20 Terraform State縛りの勉強会 |
| Future Tech Cast | 2022.6.13 | 【TechNight】 Terraformエンジニア棚井さんから学ぶ「Terraform初心者を脱する瞬間」 - 前編 |
| Future Tech Cast | 2022.6.30 | 【TechNight】 Terraformエンジニア棚井さんから学ぶ「Terraform初心者を脱する瞬間」 - 後編 |
| Future Tech Blog | 2024.03.26 | Terraformの実装コードを、動かしながら読む |
これらの情報を明示的に Gemini へインポートして、その上で、Terraform にはどのような機能が追加されたのかを質問してキャッチアップしました。本記事では、Gemini が生成した各種説明文のうち、ファクトチェックとしてローカル環境で検証しやすかった以下2ポイントについてみていきます。
- (1)リファクタリングの敷居が下がった
- (2)ローカルテストの選択肢が増えた
(1)リファクタリングの敷居が下がった
- State 操作のコード化
- moved
- Config-Driven Import とコード生成
- terraform plan -generate-config-out=generated.tf
Terraform のリファクタリングは、私の記憶では「state ファイルに対する CLI 操作と .tf 定義自体の整合性を厳密に目視チェックしながら進めるタスク」でしたが、現在時点では「Terraform が提供する機能内で安全に進められるタスク」になっていました。
State 操作のコード化
以前は、Terraform 定義コードをリファクタリングしたい場合、State ファイルを state mv コマンド経由の CLI 操作によって直編集していました。このため、せっかくインフラ定義を IaC で管理していたとしても、重要なリファクタリングの操作履歴が「Gitに残らない」という問題点がありました。この問題には moved ブロックの登場により解消されています。
例えば、単にリソース名を aws_s3_bucket.test から aws_s3_bucket.prod に変えたいだけのケースを想定してみます。コード上の名前を変えると、Terraformは「testの削除」と「prodの作成」と判断してしまいます。これを防ぐには、以下の手順が必要でした。
-
コード修正: .tf ファイルのリソース名を書き換える
-
State操作: ターミナルで以下を実行する
terraform state mv aws_s3_bucket.test aws_s3_bucket.prod -
Apply: terraform apply を実行して、差分が出ないことを確認する
コードの変更は Pull Request に残りますが、その操作に紐づく state mv 作業はコード履歴からは確認できず、変更適用の安全性は作業実施者に依存してしまいます。
ここで moved ブロックを利用すると、CLI 操作が不要となります。
-
コード修正: リソース名を書き換える
-
移動宣言:
movedブロックを追記する# 新しい定義 resource "aws_s3_bucket" "prod" { ... } # 移動の宣言 moved { from = aws_s3_bucket.test to = aws_s3_bucket.prod } -
Plan: terraform plan を実行すると、以下の情報が表示される
aws_s3_bucket.test has moved to aws_s3_bucket.prod -
Apply: terraform apply を実行する
-
宣言削除: moved ブロックを削除する
moved ブロックで from / to を宣言的に記述することで、リファクタリング操作対象がコード上で明確になり、作業者とレビュアーの双方の認識ズレや作業ミスのリスクを削減できます。また、Plan / Apply 時のメッセージ文(X has moved to Y)による操作内容のチェックも可能となりました。
State ファイルのリファクタリングは「高度なテク」の1つだと認識していましたが、moved の登場により「誰でも安全に行える通常のタスク」となったことは、Terraform リファクタリングの敷居が下がったのだと思います。
Config-Driven Import とコード生成
システムのスタート地点から Terraform を利用していたチームであれば「リソースの取り込み」という問題はほとんど発生しませんが、運用上の理由や各種規格対応の中で「IaC 化を進めよう」と後から決めた場合には「これまでコンソール画面操作により作成したリソースをコード定義化する」というタスクが発生します。いわゆる「Terraform への移植」です。このタスクが技術的に「面倒」というのはもちろんのこと、あえて言ってしまうと「IaC 化したところで、サービスの生み出す付加価値が劇的に変わるわけではない」という点と、むしろ「一定程度の作業工数が取られてしまい、かつ、実作業時の障害発生リスクを抱える」ことが重なり、この作業自体を進める「意義」を説得するのが難しかったりします。
IaC 化を推進する説得コストはさておき、Terraform の操作方法を見ていきます。例えば、手動で作成した S3 バケット(manual-bucket-2025)があり、これを後から Terraform で管理するには、次のような操作が必要でした。
-
空のリソース定義: まず、HCLファイルに「中身のないリソース」を書く。これがないとImportコマンドが失敗するため
resource "aws_s3_bucket" "target" { # 中身はまだ分からないので空にしておく } -
CLI操作: ターミナルでコマンドを実行し、State ファイルに紐付ける
terraform import aws_s3_bucket.target manual-bucket-2025 -
Plan確認: この状態で terraform plan をして、作成済みリソースと .tf 定義の差分を確認する
-
手動での差分反映: Plan の結果とコンソール画面を付き合わせながら、コード定義を埋める
設定項目の多いリソース(EC2 や RDS)の場合、コード化だけでも膨大な時間を消化します。かつ、写経ミスによる「意図しない設定値の変更」によりシステム事故につながってしまうリスクがありました。
import ブロックとコード生成機能は、このプロセスを劇的に効率化しました。
-
Import宣言: リソース定義(resource "...")は記載せず、代わりに「取り込みたいリソース」だけを書く
import { id = "manual-bucket-2025" to = aws_s3_bucket.target } -
コード自動生成: 以下のコマンドを実行する
terraform plan -generate-config-out=generated.tf -
Terraform が既存リソースを参照し、その設定値をもとに作成した Terraform 定義を
generate.tfに出力する# generated.tf resource "aws_s3_bucket" "target" { bucket = "manual-bucket-2025" tags = { Env = "Dev" } object_lock_enabled = false # その他の細かい設定 } -
生成されたファイルから不要な行(デフォルト値)を削除して取り込む
これらの便利機能により、従来であればエンジニアの目視チェックと手動作業により時間をかけて進めていた作業が、より少ない工数で対応可能となりました。また、既存リソースからシステム的な自動取り込みを行うため、意図しない設定変更リスクを大幅に下げることが可能です。
既存リソースの取り込みを低作業コストで実施可能ならば「とりあえずコンソール画面からインフラを構築して、うまくいったらコード化して保存する」というような開発フローを試してみたいなと動作検証しながら思いました。
(2)ローカルテストの選択肢が増えた
- LocalStack への Terraform 適用
tflocal
- Terraform のロジックテスト
terraform test
LocalStack への Terraform 実行
今となってはもはや常識なのでしょうが、ローカル環境内にて手軽に Terraform を動かせるようになっていました。LocalStack(Community版 / 無料版)と tflocal の組み合わせで、実際に動作検証(デプロイ〜確認)が可能な主要AWSリソースは以下です。
- Compute & Serverless
- Lambda, EC2(一部機能のみ)
- Database & Storage
- S3, DynamoDB
- Networking
- VPC, Subnet, Security Group, Route 53
- Integration
- SQS, SNS, Kinesis, EventBridge, API Gateway
- Security & Management
- IAM, SSM, Secrets Manager, CloudWatch Logs
執筆時点にて、Pro版(有料)でのみ動くものはこちらです。
- RDS
- Elastisearch
- ElastiCache
- EKS / ECS
- Cognito
- CloudFront
tflocal でサクッと plan, apply, destroy できるので、筆者の環境で試してみました。以下が今回利用した各種コマンドのバージョン情報です。
$ terraform --version
Terraform v1.14.1 on darwin_arm64
$ tflocal --version
terraform-local v0.25.0
Terraform v1.14.1
on darwin_arm64
+ provider registry.terraform.io/hashicorp/aws v6.25.0
$ awslocal --version
aws-cli/2.32.11 Python/3.13.11 Darwin/24.5.0 source/arm64
LocalStack の定義には「こちら」を利用しました。
今回は動作チェックとして、LocalStack の無料枠で作成可能な S3, DynamoDB, SQS, SSM Parameter Store を構築していきます。tflocal により Terraform の endpoint や aws credential の情報は自動設定されるため、.tf ファイル自体に LocalStack 専用の項目定義は不要です。
terraform {
required_version = "~> 1.14.1"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
# ---------------------------------------------------------
# 1. S3 Bucket
# ---------------------------------------------------------
resource "aws_s3_bucket" "demo_bucket" {
bucket = "demo-bucket-tflocal"
}
# ---------------------------------------------------------
# 2. DynamoDB Table
# ---------------------------------------------------------
resource "aws_dynamodb_table" "demo_table" {
name = "DemoTable"
billing_mode = "PAY_PER_REQUEST"
hash_key = "UserId"
range_key = "CreatedAt"
attribute {
name = "UserId"
type = "S"
}
attribute {
name = "CreatedAt"
type = "S"
}
}
# ---------------------------------------------------------
# 3. SQS Queue
# ---------------------------------------------------------
resource "aws_sqs_queue" "demo_queue" {
name = "demo-queue"
delay_seconds = 0
max_message_size = 2048
message_retention_seconds = 86400
receive_wait_time_seconds = 10
}
# ---------------------------------------------------------
# 4. SSM Parameter Store
# ---------------------------------------------------------
resource "aws_ssm_parameter" "demo_param" {
name = "/config/app_mode"
type = "String"
value = "local-dev-mode"
}
tflocal の操作は terraform と同じです。init による初期化と apply でリソースを作成していきます。
$ tflocal init
...
Terraform has been successfully initialized
$ tflocal apply --auto-approve
...
Plan: 4 to add, 0 to change, 0 to destroy.
...
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
各リソースが正しく作成されたかを、こちらもローカル操作用コマンド awslocal でチェックしていきます。
# S3
$ awslocal s3 ls
2025-12-07 21:55:38 demo-bucket-tflocal
# DynamoDB
$ awslocal dynamodb list-tables
{
"TableNames": [
"DemoTable"
]
}
$ awslocal dynamodb describe-table --table-name DemoTable --query "Table.TableStatus"
"ACTIVE"
# SQS
$ awslocal sqs list-queues
{
"QueueUrls": [
"http://sqs.ap-northeast-1.localhost.localstack.cloud:4566/000000000000/demo-queue"
]
}
$ awslocal sqs send-message --queue-url http://localhost:4566/000000000000/demo-queue --message-body "Hello LocalStack"
{
"MD5OfMessageBody": "175c9741aa05c083347a312f7b98117c",
"MessageId": "86850e6a-a542-427c-b40d-d864536d1072"
}
$ awslocal sqs receive-message --queue-url http://localhost:4566/000000000000/demo-queue
{
"Messages": [
{
"MessageId": "86850e6a-a542-427c-b40d-d864536d1072",
"ReceiptHandle": "YzRhNzA5ODMtZDJkOC00MWQ0LWFkM2QtMmNiZjEwMTlhMThlIGFybjphd3M6c3FzOmFwLW5vcnRoZWFzdC0xOjAwMDAwMDAwMDAwMDpkZW1vLXF1ZXVlIDg2ODUwZTZhLWE1NDItNDI3Yy1iNDBkLWQ4NjQ1MzZkMTA3MiAxNzY1MTEyNTc2LjE4ODQ1ODc=",
"MD5OfBody": "175c9741aa05c083347a312f7b98117c",
"Body": "Hello LocalStack"
}
]
}
# SSM Parameter Store
$ awslocal ssm get-parameter --name "/config/app_mode" --query "Parameter.Value"
"local-dev-mode"
LocalStack 内でのリソースは main.tf への記載通りに成功していました。せっかく作成した環境なので、この環境定義を terraform test でチェックするコード自体を書いてみます。tests ディレクトリ配下に unit.tftest.hcl を作成して、各リソースに対する assert 文を定義しました。宣言的に作成したリソースへのテストなので、あまりテスト自体の意味がありませんが、こんな感じになるのねという動作チェックがメインです。
.
├── main.tf
└── tests
└── unit.tftest.hcl
mock_provider "aws" {}
# ---------------------------------------------------------
# Test Case 1: S3 Bucketの設定値を検証
# ---------------------------------------------------------
run "verify_s3_config" {
command = plan
assert {
condition = aws_s3_bucket.demo_bucket.bucket == "demo-bucket-tflocal"
error_message = "S3バケット名が期待値と異なります"
}
}
# ---------------------------------------------------------
# Test Case 2: DynamoDBの設定値を検証
# ---------------------------------------------------------
run "verify_dynamodb_config" {
command = plan
assert {
condition = aws_dynamodb_table.demo_table.name == "DemoTable"
error_message = "テーブル名が DemoTable ではありません"
}
assert {
condition = aws_dynamodb_table.demo_table.billing_mode == "PAY_PER_REQUEST"
error_message = "Billing Mode がオンデマンド(PAY_PER_REQUEST)になっていません"
}
assert {
condition = aws_dynamodb_table.demo_table.hash_key == "UserId"
error_message = "Hash Key が UserId ではありません"
}
}
# ---------------------------------------------------------
# Test Case 3: SQS Queueの設定値を検証
# ---------------------------------------------------------
run "verify_sqs_config" {
command = plan
assert {
condition = aws_sqs_queue.demo_queue.max_message_size == 2048
error_message = "最大メッセージサイズが 2048 ではありません"
}
assert {
condition = aws_sqs_queue.demo_queue.message_retention_seconds == 86400
error_message = "メッセージ保持期間が 86400秒(1日) ではありません"
}
}
# ---------------------------------------------------------
# Test Case 4: SSM Parameter Storeの設定値を検証
# ---------------------------------------------------------
run "verify_ssm_config" {
command = plan
assert {
condition = aws_ssm_parameter.demo_param.value == "local-dev-mode"
error_message = "パラメータの値が local-dev-mode ではありません"
}
}
tflocal test で各テストの通過を確認しました。
$ tflocal test
tests/unit.tftest.hcl... in progress
run "verify_s3_config"... pass
run "verify_dynamodb_config"... pass
run "verify_sqs_config"... pass
run "verify_ssm_config"... pass
tests/unit.tftest.hcl... tearing down
tests/unit.tftest.hcl... pass
Success! 4 passed, 0 failed.
正直なところ「宣言文が、想定通りの定義であること」はテストコードがなくても自明なので、別途、ロジックをチェックするテストコードを2パターン作成してみました。
- パターンA:環境によって設定を変える
- パターンB:タグの自動付与
パターンA:環境によって設定を変える
「本番環境(prod)なら削除保護を有効にするが、開発環境(dev)なら無効にする」 というロジックのテストです。
variable "env" {
type = string
description = "Environment (dev or prd)"
default = "dev"
}
resource "aws_s3_bucket" "data_bucket" {
bucket = "my-app-data-${var.env}"
# 【ロジック】prdなら強制削除不可(false)、devなら強制削除OK(true)
force_destroy = var.env == "prd" ? false : true
}
↓
mock_provider "aws" {}
# ケース1: dev環境のテスト
run "test_dev_environment" {
command = plan
variables {
env = "dev"
}
# devなら force_destroy が true であるべき
assert {
condition = aws_s3_bucket.data_bucket.force_destroy == true
error_message = "Dev環境なのに force_destroy が false になっています"
}
# 名前も dev になっているか確認
assert {
condition = aws_s3_bucket.data_bucket.bucket == "my-app-data-dev"
error_message = "バケット名に dev が含まれていません"
}
}
# ケース2: prod環境のテスト
run "test_prod_environment" {
command = plan
variables {
env = "prd"
}
# prdなら force_destroy が false であるべき
assert {
condition = aws_s3_bucket.data_bucket.force_destroy == false
error_message = "Prd 環境なのに force_destroy が true になっています"
}
}
パターンB:タグの自動付与
「必須タグ(ManagedBy = Terraform)が自動的に付与されているか」 をチェックするテストです。
variable "project_name" {
type = string
default = "my-project"
}
locals {
# 常に付与したい共通タグ
common_tags = {
Project = var.project_name
ManagedBy = "Terraform"
}
}
resource "aws_sqs_queue" "app_queue" {
name = "${var.project_name}-queue"
# 【ロジック】タグを自動設定
tags = local.common_tags
}
↓
mock_provider "aws" {}
run "verify_auto_tagging" {
command = plan
variables {
project_name = "demo-app"
}
# ManagedBy タグが含まれているか?
assert {
condition = aws_sqs_queue.app_queue.tags["ManagedBy"] == "Terraform"
error_message = "ManagedBy タグが欠落しています"
}
# Project タグが変数通りに入っているか?
assert {
condition = aws_sqs_queue.app_queue.tags["Project"] == "demo-app"
error_message = "Project タグが正しく設定されていません"
}
}
おわりに
本記事では Terraform の「リファクタリング」と「テスト」を取り上げました。
最近の AI ツールを活用すれば、最新事情まで一気にキャッチアップできることを改めて実感しました。また同時に、細かい実装仕様のところでハルシネーションが多々見られたため、一定程度は自力でファクトチェック(今回であれば、ローカルでの環境構築と動作検証を進める)スキルはまだ必要だなと本執筆を通して再認識しました。また、過去に執筆したブログを AI にインプットさせることで「あなたが Terraform を使っていた頃には 〇〇 するしかありませんでしたが、現在はその問題が克服されて...」のようなパーソナライズされた解説を生成してくれるのが非常に面白く感じました。