
Terraformの実行環境を、CFnでゼロから構築してみた!
はじめに
おはようございます〜
クラウド事業統括本部/プロフェッショナルサービス事業本部/運用イノベーション部の三ツ口寛太です(╹◡╹)
今回も前職での経験ベースでつらつらと書いていきます〜
AWS環境にて、Terraformの実行環境をゼロから作成してみます🧑💻
Terraformを使い始めるとき、最初に「Terraformをどこで動かすか」という問題に当たりますよね。手元のPCに入れる方法もありますが、検証環境をチームで使い回したい、手元に認証情報を置きたくない、といった場面ではサーバー側に実行環境を用意したくなります。
そこで今回は、EC2にTerraformを入れ、tfファイルはサーバー内に置き、tfstateはS3に保存する構成です。最後に実際にTerraformでS3バケットを作成し、tfstateがS3に保存されること、ロックが効くことまで確認していきます!
この記事で分かること
- CloudFormationで、VPC・EC2・IAM・tfstate用S3を一括で作る方法
- EC2(Amazon Linux 2023)にTerraformをUserDataで自動インストールする方法
- Session Managerで接続し、SSHなしでTerraformを実行する方法
- tfstateをS3に置き、S3のネイティブロックでDynamoDBなしに排他制御する方法
構成
今回の構成はこちら💁♂️

ポイントは次の3点です。
- tfファイルはEC2内(
~/tf-lab)に置き、tfstateはS3に保存します。EC2を作り直してもstateは残るような構成にしています・ - tfstate用のS3バケットは、Terraform自身ではなくCloudFormationで作成するようにしています。CFnを実行すれば、Terraformのインストールからtfstateファイル等の作成まで完了します。
- EC2は外部からのインバウンドを一切許可せず、Session Managerで接続します。
前提
- EC2からインターネットへ出られること(Terraformのパッケージとプロバイダの取得に必要)
- CloudFormation、EC2、IAM、S3、Systems Managerを操作できる権限
作成するリソースと主な設定
| リソース | 重要な設定 | 理由 |
|---|---|---|
| VPC | 10.0.0.0/16、DNSホスト名有効 | 専用のネットワークをゼロから作る |
| サブネット | パブリック1つ(AZ先頭)、ルートはIGWへ | NAT Gatewayを使わず費用を抑えるため |
| セキュリティグループ | インバウンドなし | 接続はSession Managerのみ。SSHポートは開けない |
| EC2 | AL2023、既定 t3.small、gp3 20GB、IMDSv2必須 |
AWSプロバイダは意外とメモリを使うため、t3.micro より余裕を持たせました |
| IAMロール | AmazonSSMManagedInstanceCore + tfstate用S3アクセス + サンプルバケット用 s3:* |
Terraformが操作できる範囲をに限定 |
| tfstate用S3 | バージョニング有効、SSE-S3暗号化、パブリックアクセスブロック | stateの誤上書き・消失に備える |
| CreationPolicy | Count: 1、Timeout: PT15M |
UserDataの失敗をスタックの失敗として検知する |
テンプレート
再現できるようにCFnのyamlファイルを以下に置いておきます!
テンプレート全文(terraform-ec2.yaml)
AWSTemplateFormatVersion: "2010-09-09"
Description: Terraform execution environment on EC2 (Session Manager access, tfstate on S3). For verification use only.
Parameters:
ProjectName:
Type: String
Default: tf-lab
AllowedPattern: "^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$"
Description: Lowercase letters, digits and hyphens (3-20 chars). Used for resource names and the S3 bucket names.
LatestAmiId:
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
InstanceType:
Type: String
Default: t3.small
AllowedValues: [t3.micro, t3.small, t3.medium]
Description: The AWS provider needs memory, so t3.small (2GiB) or larger is recommended.
TerraformVersion:
Type: String
Default: latest
Description: "latest, or an exact version available in the HashiCorp repo (e.g. 1.16.5)."
Conditions:
UseLatestTerraform: !Equals [!Ref TerraformVersion, latest]
Resources:
# ---------------- Network (one public subnet, no NAT, no inbound) ----------------
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
EnableDnsSupport: true
EnableDnsHostnames: true
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-vpc" }
InternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-igw" }
IgwAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref Vpc
InternetGatewayId: !Ref InternetGateway
PublicSubnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.0.0.0/24
AvailabilityZone: !Select [0, !GetAZs ""]
MapPublicIpOnLaunch: true
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-public-subnet" }
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref Vpc
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-public-rt" }
PublicDefaultRoute:
Type: AWS::EC2::Route
DependsOn: IgwAttachment
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref InternetGateway
PublicSubnetAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnet
RouteTableId: !Ref PublicRouteTable
InstanceSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Terraform server (no inbound; access via Session Manager only)
VpcId: !Ref Vpc
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-sg" }
# ---------------- tfstate bucket (managed by CloudFormation, not by Terraform) ----------------
StateBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Delete
UpdateReplacePolicy: Delete
Properties:
BucketName: !Sub "${ProjectName}-tfstate-${AWS::AccountId}-${AWS::Region}"
VersioningConfiguration:
Status: Enabled
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
# ---------------- IAM ----------------
TerraformRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal: { Service: ec2.amazonaws.com }
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
Policies:
- PolicyName: terraform-state-access
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: s3:ListBucket
Resource: !GetAtt StateBucket.Arn
# State file and the lock file (use_lockfile = true creates <key>.tflock)
- Effect: Allow
Action: [s3:GetObject, s3:PutObject, s3:DeleteObject]
Resource: !Sub "${StateBucket.Arn}/*"
- PolicyName: terraform-sample-s3-only
PolicyDocument:
Version: "2012-10-17"
Statement:
# Verification only: Terraform may manage buckets named <ProjectName>-sample-* and nothing else
- Effect: Allow
Action: s3:*
Resource:
- !Sub "arn:${AWS::Partition}:s3:::${ProjectName}-sample-*"
- !Sub "arn:${AWS::Partition}:s3:::${ProjectName}-sample-*/*"
- Effect: Allow
Action: [s3:ListAllMyBuckets, s3:GetBucketLocation]
Resource: "*"
InstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Roles: [!Ref TerraformRole]
# ---------------- EC2 ----------------
TerraformInstance:
Type: AWS::EC2::Instance
DependsOn: PublicDefaultRoute
CreationPolicy:
ResourceSignal:
Count: 1
Timeout: PT15M
Properties:
ImageId: !Ref LatestAmiId
InstanceType: !Ref InstanceType
IamInstanceProfile: !Ref InstanceProfile
MetadataOptions:
HttpTokens: required
HttpEndpoint: enabled
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeType: gp3
VolumeSize: 20
Encrypted: true
NetworkInterfaces:
- DeviceIndex: "0"
AssociatePublicIpAddress: true
SubnetId: !Ref PublicSubnet
GroupSet: [!Ref InstanceSecurityGroup]
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-terraform-server" }
UserData:
Fn::Base64:
!Sub
- |
#!/bin/bash
set -Eeuxo pipefail
signal() { /opt/aws/bin/cfn-signal -e $1 --stack ${AWS::StackName} --resource TerraformInstance --region ${AWS::Region}; }
trap 'signal 1' ERR
# ---- 1. Install Terraform from the HashiCorp repository ----
dnf install -y yum-utils git
yum-config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo
dnf install -y ${TfPackage}
# ---- 2. Working directory for the ssm-user (Session Manager login user) ----
id ssm-user || useradd -m ssm-user
WORKDIR=/home/ssm-user/tf-lab
mkdir -p $WORKDIR
cat > $WORKDIR/backend.tf <<'TFEOF'
terraform {
backend "s3" {
bucket = "${StateBucket}"
key = "sample/terraform.tfstate"
region = "${AWS::Region}"
encrypt = true
use_lockfile = true
}
}
TFEOF
cat > $WORKDIR/main.tf <<'TFEOF'
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "${AWS::Region}"
}
data "aws_caller_identity" "current" {}
# A sample resource: one private S3 bucket (name must start with <ProjectName>-sample- to match the IAM policy)
resource "aws_s3_bucket" "sample" {
bucket = format("${ProjectName}-sample-%s", data.aws_caller_identity.current.account_id)
tags = {
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_public_access_block" "sample" {
bucket = aws_s3_bucket.sample.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
output "sample_bucket_name" {
value = aws_s3_bucket.sample.bucket
}
TFEOF
chown -R ssm-user:ssm-user /home/ssm-user/tf-lab
terraform version
signal 0
- TfPackage: !If [UseLatestTerraform, terraform, !Sub "terraform-${TerraformVersion}"]
Outputs:
InstanceId:
Description: Terraform server (connect with Session Manager)
Value: !Ref TerraformInstance
InstanceConsoleUrl:
Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/ec2/home?region=${AWS::Region}#InstanceDetails:instanceId=${TerraformInstance}"
StateBucketName:
Description: S3 bucket that stores the tfstate (managed by this stack)
Value: !Ref StateBucket
SampleBucketName:
Description: Name of the sample bucket Terraform will create
Value: !Sub "${ProjectName}-sample-${AWS::AccountId}"
UserDataでやっていること (こだわりポイントです)
EC2の初回起動時に、次の処理が自動で走ります。
- HashiCorpの公式リポジトリを追加し、
dnfでTerraformをインストール - Session Managerのログインユーザーのホームに
~/tf-labを作成 backend.tf(tfstateをS3に保存する設定)とmain.tf(サンプルのS3バケットを作る定義)を配置cfn-signalでCloudFormationに成功を通知(途中で失敗した場合は失敗を通知するようにします)
backend.tf では、S3バックエンドに use_lockfile = true を指定しています。これでtfstateと同じバケットにロックファイル(.tflock)が作られ、DynamoDBなしで排他制御ができます。HashiCorpのドキュメントでは、DynamoDBによるロックは非推奨とされています。
構築手順
1. スタックを作成する
CloudFormationコンソールでスタックの作成をしていきます。
デプロイ完了までの数分間は夕飯なににするか考えましょう🍛

無事デプロイ完了しました!
2. Session Managerで接続する
Terraform実行サーバが作成されているのを確認し、SSM接続します。
無事接続できたら次のコマンドを実行します!
terraform version
cd ~/tf-lab
ls

このように、Terraformのバージョンと、backend.tf と main.tf が見えれば準備完了です!
念のため、backend.tf と main.tf の中身も確認しました、ちゃんと書き込みできていますね!

3. Terraformを実行する
それでは早速実行コマンドより、S3リソースのアプライをしていきましょう。
terraform fmt
terraform init
terraform plan
terraform apply
init でS3バックエンドとAWSプロバイダが初期化されます。apply の確認プロンプトに yes と入力すると、サンプルのS3バケット(<ProjectName>-sample-<アカウントID>)が作成されます。

無事applyも完了しました🧑💻
4. tfstateがS3に保存されたか確認します
- S3コンソールで、スタックの出力
StateBucketNameのバケットを開きます。 sample/terraform.tfstateがあることを確認します。- 「バージョン」の表示をオンにすると、
applyごとに履歴が増えているのが確認できます。

5. 排他制御の動きを確認する
apply の途中でロックファイルが作られる様子を観察します。
- Session Managerで、同じインスタンスにもう1つセッションを開きます(ターミナルを2つ用意します)。
- 1つ目で
terraform applyを実行し、確認プロンプトでyesを入力せずに待ちます。

- S3コンソールで
sample/terraform.tfstate.tflockが作られていることを確認します。

- 2つ目のセッションで
terraform planを実行します。Error acquiring the state lockで止まれば、排他制御が効いています。

- 1つ目に戻って
noを入力してキャンセルすると、.tflockが消えます。
しっかりと排他制御が効いていることを確認できました〜!
後片付け
CFnのスタック削除でリソース削除をしますが、S3のtfstate用バケットにtfstateファイルが残っていると削除できません。
先に対象バケットの中身を空にしてからCFnで削除実行するようにしましょう!🪣
まとめ
CloudFormationで、VPC・EC2・IAM・tfstate用S3を一括で作り、その上でTerraformを実行するところまでを試しました。tfstateをS3に置いておけば、EC2を作り直してもstateが残り、S3のネイティブロックでDynamoDBなしに排他制御もできます。
今回は検証用に、1台のEC2から手動で実行する最小構成でしたが、チームで使う場合はIAM権限の絞り込み、GitHubなどのリポジトリでのtfファイル管理、CodeBuildなどによる実行の自動化まで進めると、より実用的になります!
最後に
最後まで見てくださりありがとうございました〜
本日の写真はこちらです(ででん)

長野県の美ヶ原高原というところです!
景色があまりにも美しくて、ジブリの世界観で大感動でした。








