Terraformの実行環境を、CFnでゼロから構築してみた!

Terraformの実行環境を、CFnでゼロから構築してみた!

Terraformの実行環境をEC2とS3で一からセットアップする方法を、CloudFormationテンプレート付きで紹介します。Session Managerでの接続、S3ネイティブロックによる排他制御の動きまで実際に確認していきます!
2026.10.13

はじめに

おはようございます〜
クラウド事業統括本部/プロフェッショナルサービス事業本部/運用イノベーション部の三ツ口寛太です(╹◡╹)

今回も前職での経験ベースでつらつらと書いていきます〜
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の初回起動時に、次の処理が自動で走ります。

  1. HashiCorpの公式リポジトリを追加し、dnf でTerraformをインストール
  2. Session Managerのログインユーザーのホームに ~/tf-lab を作成
  3. backend.tf(tfstateをS3に保存する設定)と main.tf(サンプルのS3バケットを作る定義)を配置
  4. 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に保存されたか確認します

  1. S3コンソールで、スタックの出力 StateBucketName のバケットを開きます。
  2. sample/terraform.tfstate があることを確認します。
  3. 「バージョン」の表示をオンにすると、apply ごとに履歴が増えているのが確認できます。

5. 排他制御の動きを確認する

apply の途中でロックファイルが作られる様子を観察します。

  1. Session Managerで、同じインスタンスにもう1つセッションを開きます(ターミナルを2つ用意します)。
  2. 1つ目で terraform apply を実行し、確認プロンプトで yes を入力せずに待ちます。

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

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

  1. 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などによる実行の自動化まで進めると、より実用的になります!

最後に

最後まで見てくださりありがとうございました〜
本日の写真はこちらです(ででん)

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

参考

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事