はじめに
新しいPCでTerraformを動かしたいとき、あるいは新メンバーに環境構築をお願いするとき、IAMの権限やAWS CLI、tfenv、profile、backend用のS3バケットといった細かい手順をいちいち思い出すのは面倒です。自分も新しい環境を作るたびに「あれ、最初に何やるんだっけ」と過去の履歴を漁っていました。
そこで、実際に運用しているAWSインフラ用Terraformリポジトリの手順を、git clone してから terraform plan が通るまでコピペ中心で再現できるようにまとめ直しました。丸暗記しなくても上から順にコピペすれば終わる、という状態を目指しています。
対象は macOS(Homebrew環境)です。tfenv はソースビルドではなく公式バイナリをダウンロードして展開する仕組みなので、Apple Silicon(M1/M2 など)でもそのまま動きます。
この記事で扱うこと
- IAMユーザー(デプロイ用)の作成
-
aws cli/tfenvのHomebrewインストール - AWS profileの設定
-
main.tf/providers.tfの最小構成 -
0_setup_backend.sh(tfstate用のS3バケットを先に作るbootstrapシェル)の話 -
terraform init→terraform planまでのコマンド
なお bootstrap とは、Terraform本体を動かす前に必要な土台(ここではstate保管用のS3バケット)を、Terraform以外の手段で先に用意しておく作業のことです。
環境
| 項目 | バージョン |
|---|---|
| OS | macOS |
| Terraform | 1.15.6(tfenvで固定) |
| AWS Provider | 6.50.0 |
| リージョン | ap-northeast-1 |
全体像(最速ルート)
細かい説明に入る前に、全体の流れを掴んでおきましょう。大きく分けると次の6ステップで terraform plan まで到達できます。
[1] IAMユーザーを作る(デプロイ用、アクセスキー発行)
↓
[2] brew で aws cli / tfenv を入れる
↓
[3] tfenv で Terraform のバージョンを固定する
↓
[4] aws configure --profile でクレデンシャルを設定
↓
[5] 0_setup_backend.sh で tfstate 用の S3 を作る(DynamoDBは作らない)
↓
[6] terraform init → terraform plan
リポジトリの構成はこうなっています。コマンドを打つのは基本的に environments/develop/ の中です。
providers/aws/
├── module/ # s3, waf, iam ... 再利用モジュール
└── environments/
└── develop/
├── 0_setup_backend.sh # state用S3バケットを作るbootstrap
├── providers.tf # backend設定 + provider定義
├── main.tf # module 呼び出し
├── variables.tf # 変数宣言
└── terraform.tfvars # 変数の値(環境固有)
ここで develop という環境名が、このあと出てくる IAMユーザー名・profile名・S3バケット名・ディレクトリ名すべての軸になります。staging や production を増やすときもこの1語を差し替えるだけで一式が揃うように作ってあります。
ひとつだけ順番に気をつけてください。0_setup_backend.sh は terraform init より先に実行する必要があります。これを飛ばしていきなり terraform init すると、state置き場のバケットがまだ無いためエラーになります。なぜこの順番なのかはSTEP 5で詳しく書きます。
STEP 1: IAMユーザーを作る
Terraformからリソースを作成・変更するためのIAMユーザーを用意します。環境ごとに project-x-local-deployer-develop のようなデプロイ専用ユーザーを切っていて、末尾の develop が前述の環境名です。
マネジメントコンソールで作る場合
- IAM → ユーザー → 「ユーザーの作成」
- ユーザー名:
project-x-local-deployer-develop(環境名を末尾に付ける) - 権限: 検証段階なら
AdministratorAccessを直接アタッチが最速。本番運用では必要な権限に絞る - ユーザー作成後、「セキュリティ認証情報」→「アクセスキーを作成」→「コマンドラインインターフェイス (CLI)」を選択
- アクセスキーIDとシークレットアクセスキーを控える(この画面を閉じると二度と見られません)
AWS CLIで作る場合(管理者権限が手元にあるなら最速)
# ユーザー作成
aws iam create-user --user-name project-x-local-deployer-develop
# 検証用に AdministratorAccess を付与(本番では絞ること)
aws iam attach-user-policy \
--user-name project-x-local-deployer-develop \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess
# アクセスキー発行(AccessKeyId / SecretAccessKey が返る)
aws iam create-access-key --user-name project-x-local-deployer-develop
⚠️ 検証環境では
AdministratorAccessが手っ取り早いですが、本番では最小権限ポリシーを別途設計してください。このユーザーは0_setup_backend.shのS3操作から、その後のterraform applyが触る全リソースまでを実行します。
STEP 2: Homebrewで aws cli / tfenv を入れる
macOSなら brew 一発です。
# AWS CLI v2
brew install awscli
# tfenv(Terraform のバージョン管理ツール)
brew install tfenv
インストール確認:
aws --version
# aws-cli/2.x.x ...
tfenv --version
# tfenv 3.x.x
terraform を直接 brew install しないのは、バージョンを固定したいからです。多くのプロジェクトは providers.tf で required_version = "= 1.15.6" のようにバージョンを縛っており、tfenv を使えばプロジェクトごとに合わせて切り替えられます。最初から tfenv で入れておくのが結果的に最速です。
STEP 3: tfenv でTerraformのバージョンを固定する
このプロジェクトは providers.tf で次のように固定しています。
terraform {
required_version = "= 1.15.6"
# ...
}
いちばん手数が少ないのは、リポジトリに .terraform-version(中身は 1.15.6 の1行だけ)を置いておく方法です。これがあると、
-
tfenv installを引数なしで実行するだけでそのバージョンが入る - インストール済みであれば、そのディレクトリに
cdした時点でtfenvが自動的にそのバージョンを選択する(tfenv useも不要)
ので、新メンバーは事実上 tfenv install の1コマンドで終わります。自動で選んでくれるのはすでにインストール済みのバージョンなので、まだそのバージョンを入れていない場合は、最初に一度だけ tfenv install で用意してください。一度入れてしまえば、あとは cd するだけで自動的に選ばれるようになります。
# .terraform-version があれば、これだけ
tfenv install
# 確認(自動選択されている)
terraform version
# Terraform v1.15.6
.terraform-version を置いていない場合は、バージョンを明示してインストール・選択します。
tfenv install 1.15.6
tfenv use 1.15.6
STEP 4: AWS profileを設定する
STEP 1で発行したアクセスキーを、profile名を指定して設定します。profile名は project-x-local-deployer-develop、つまり環境名を末尾に付けた名前に揃えます。
aws configure --profile project-x-local-deployer-develop
対話で以下を入力します。
AWS Access Key ID [None]: <STEP 1 で控えたアクセスキーID>
AWS Secret Access Key [None]: <STEP 1 で控えたシークレットアクセスキー>
Default region name [None]: ap-northeast-1
Default output format [None]: json
設定できたか確認します。
# profile が一覧に出るか
aws configure list-profiles | grep project-x-local-deployer-develop
# 実際に認証が通るか(これが一番確実)
aws sts get-caller-identity --profile project-x-local-deployer-develop
Account / Arn が返ってくればOKです。
このprofile名は、次の3か所と同じ命名規則(project-x-local-deployer-<環境名>)で揃えておく必要があります。ズレると、STEP 5のシェルが「AWS Profile not found」で即終了します。なぜ揃うと動くのか(シェルがディレクトリ名から環境名を導出している仕組み)は、次のSTEP 5を読むと腑に落ちるはずです。
-
providers.tfの backend ブロックのprofile -
terraform.tfvarsのaws_profile -
0_setup_backend.shのAWS_PROFILE
STEP 5: backend用のS3を 0_setup_backend.sh で作る
このSTEPがセットアップで一番つまずきやすく、そして一番大事なところです。逆にここさえ理解できれば残りは流れ作業になります。
その前にstateロックの方針を共有しておきます。以前はS3 backendのstateロックにDynamoDBテーブルが必須でしたが、Terraform 1.10以降は use_lockfile = true でS3ネイティブのロックファイルが使えるようになりました。さらに 1.11 で dynamodb_table 引数が非推奨(deprecated)となり将来削除される予定です。本記事ではDynamoDBを作らず use_lockfile = true を使う前提で進めます。作成するのはS3バケット1つだけです。
なぜ最初にシェルを実行するのか(鶏と卵問題)
Terraformのstate(tfstate)はS3に置きたい。providers.tf には次のように書いてあります。
terraform {
backend "s3" {
bucket = "project-x-terraform-state-develop"
region = "ap-northeast-1"
key = "develop/terraform.tfstate"
profile = "project-x-local-deployer-develop"
use_lockfile = true # S3ネイティブロック(DynamoDB不要)
}
}
ところが、このS3バケットが存在しない状態では terraform init が通りません。stateを置くバケット自体をTerraformで管理しようとすると、「そのバケットを作るstateはどこに置くのか?」という無限ループ(鶏と卵問題)に陥ります。
そこで、AWS CLIでバケットを先に作るシェルを 0_setup_backend.sh として用意しています。ファイル名の先頭に 0_ を付けているのは、terraform init の前に必ず実行する、という順序を ls した時点で読み手に伝えるためです。
0_setup_backend.sh の中身
このシェルはリポジトリに含まれているので、既存メンバーは実行するだけです。ゼロから新規構築する人は、以下を environments/develop/0_setup_backend.sh として保存してください。何度実行しても壊れない(=冪等な)作りにしてあります。冪等とは、同じシェルを2回3回流しても結果が変わらない、という性質のことです。
#!/usr/bin/env bash
set -euo pipefail # エラー・未定義変数・パイプ途中の失敗で停止
# ディレクトリ名から環境名を取得 (develop|staging|production ...)
CURRENT=$(cd "$(dirname "$0")"; pwd)
ENV=$(echo "$CURRENT" | sed -e 's/.*\/\([^\/]*\)$/\1/')
AWS_PROFILE="project-x-local-deployer-$ENV"
S3_BUCKET_NAME="project-x-terraform-state-$ENV"
REGION="ap-northeast-1"
# 1. AWS profile の存在確認(無ければ即終了)
if ! aws configure list-profiles | grep -qx "${AWS_PROFILE}"; then
echo "Error: AWS Profile '${AWS_PROFILE}' not found"
exit 1
fi
# 2. 認証情報のテスト(SSO 期限切れ等をここで弾く)
aws sts get-caller-identity --profile "${AWS_PROFILE}" > /dev/null
# 3. S3 バケット作成(head-bucket で冪等化。自分の所有バケット前提)
# ※ 他人が同名バケットを所有していると head-bucket は 403 を返し、作成に進んで失敗します
if ! aws s3api head-bucket --bucket "${S3_BUCKET_NAME}" --profile "${AWS_PROFILE}" 2>/dev/null; then
# us-east-1 は LocationConstraint を受け付けないため、リージョンで作り分ける
if [ "${REGION}" = "us-east-1" ]; then
aws s3api create-bucket \
--bucket "${S3_BUCKET_NAME}" \
--region "${REGION}" \
--profile "${AWS_PROFILE}"
else
aws s3api create-bucket \
--bucket "${S3_BUCKET_NAME}" \
--region "${REGION}" \
--create-bucket-configuration LocationConstraint="${REGION}" \
--profile "${AWS_PROFILE}"
fi
# バージョニング有効化
aws s3api put-bucket-versioning \
--bucket "${S3_BUCKET_NAME}" \
--versioning-configuration Status=Enabled \
--profile "${AWS_PROFILE}"
# パブリックアクセスブロック
aws s3api put-public-access-block \
--bucket "${S3_BUCKET_NAME}" \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" \
--profile "${AWS_PROFILE}"
# サーバーサイド暗号化 (AES256)
aws s3api put-bucket-encryption \
--bucket "${S3_BUCKET_NAME}" \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}' \
--profile "${AWS_PROFILE}"
fi
ちなみに us-east-1(バージニア北部)は唯一 LocationConstraint を受け付けないリージョンで、付けたまま投げると InvalidLocationConstraint で落ちます。上のスクリプトは REGION を見て us-east-1 のときだけ --create-bucket-configuration を外すようにしてあるため、CloudFront用バケットなどで REGION="us-east-1" に変えてもそのまま動きます。
実行するコマンドは以下のとおりです。
cd providers/aws/environments/develop
bash 0_setup_backend.sh
スクリプトに入れている安全策
最初はもっと素朴なスクリプトでしたが、運用しながら踏んだ事故を1つずつ潰していった結果、今の形になりました。地味なところを補足しておきます。
- 冪等性は
head-bucketで取る。create-bucketは2回目にBucketAlreadyOwnedByYouでコケるので、先に存在確認してスキップ。複数人が同じシェルを走らせても壊れません。 - profileチェックと認証テストを最初にやる。これを入れる前は「シェルは成功した風だが、実はクレデンシャル切れで何も作られていなかった」という事故がありました。
- 暗号化とパブリックアクセスブロックは作成時に設定する。後付けすると監査時に「いつから暗号化されていたか」を問われます。
- バージョニングを有効化しておく。stateファイルを誤って空で上書きしても、1つ前のバージョンに戻せます。
STEP 6: terraform init → plan
backendのバケットができたら、いよいよTerraform本体です。あとは次のコマンドを順に打っていきます。
cd providers/aws/environments/develop
# backend を初期化(プロバイダDLとstate接続)
terraform init
# 差分確認
terraform plan
plan が通れば、実際の適用は apply です。
terraform apply
⚠️
init/plan/applyに-var=...を渡す必要はありません。profileは backend ブロックに直接書いてあり、provider用のvar.aws_profileなどはterraform.tfvarsで値が入るためです。特にterraform initは-varを受け付けず、付けるとError: Too many command line argumentsで落ちます。
なお、コードを自分で書き足す場合は terraform fmt(整形)してから terraform validate(検証)を挟みます。整形済みの状態を検証したいので fmt が先です。git clone 直後のコードはすでに整形済みのはずなので、セットアップを通すだけならこの2つは省略して構いません。
plan と apply の間はあまり時間を空けないほうが安全です。stateロックは plan / apply が実行されている最中だけ取られ、plan が終わった時点で解放されます。間隔を空けたくない理由はロックの期限ではなく、その間に他人が apply すると state が実態とズレる(ドリフトする)ためです。plan結果をSlackに貼って30分相談してから apply、という運用は、割り込み apply が入ると plan と実態が食い違います。
main.tf / providers.tf の最小構成
実プロジェクトのファイルは巨大ですが、新規に同じ構成を立ち上げるときの最小テンプレートを抜き出しておきます。
providers.tf
terraform {
required_version = "= 1.15.6"
backend "s3" {
bucket = "project-x-terraform-state-develop"
region = "ap-northeast-1"
key = "develop/terraform.tfstate" # 環境で一意にすること
profile = "project-x-local-deployer-develop"
use_lockfile = true # S3ネイティブロック(DynamoDB不要)
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.50.0"
}
}
}
# メインリージョン(ap-northeast-1)
provider "aws" {
region = var.aws_region
profile = var.aws_profile
default_tags {
tags = {
Build = "Terraform"
}
}
}
# CloudFront / WAF / Lambda@Edge 用(us-east-1 にしか作れないリソース向け)
provider "aws" {
alias = "virginia"
region = "us-east-1"
profile = var.aws_profile
default_tags {
tags = {
Build = "Terraform"
}
}
}
required_version とプロバイダ version を = で固定しておくと、メンバー間でバージョンがずれません。default_tags で全リソースに共通タグを付けておけば、誤削除のレビュー時にIaC由来かどうかがすぐ分かります。あとはCloudFront用WAFやLambda@Edgeが us-east-1 にしか作れない都合で、alias = "virginia" の provider を別に用意し、モジュール側へ providers = { aws = aws.virginia } で渡しています。
なお backend "s3" ブロックのバケット名やprofile名は変数(var.*)を使えず、リテラルで直書きする必要があります(backendの初期化は変数評価より前に走るためです)。0_setup_backend.sh が「ディレクトリ名から環境名を導出」していたのに対し、ここだけは develop をハードコードしているのはこの制約によるものです。環境を増やすときはこのブロックの3箇所(bucket / key / profile)の環境名を忘れず差し替えてください。
variables.tf
providers.tf で使っている var.aws_profile や var.aws_region は、値を terraform.tfvars に書くだけでは使えません。どこかで variable 宣言が必要で、それがこのファイルです。tfvars は「宣言済みの変数に値を代入するファイル」なので、宣言(variables.tf)と代入(terraform.tfvars)はセットになります。
variable "aws_region" {
type = string
}
variable "aws_profile" {
type = string
}
variable "account_id" {
type = string
}
variable "vpc_id" {
type = string
}
# ... 以下、使う変数の数だけ宣言が続く
main.tf
main.tf は各モジュールを呼び出すだけのファイルです。backend のリソースは含めず、通常リソースのみを書きます。
module "project_x_s3_bucket" {
source = "../../module/s3"
account_id = var.account_id
}
# us-east-1 に作るリソースは provider を明示的に渡す
module "project_x_waf" {
source = "../../module/waf"
providers = {
aws = aws.virginia
}
}
# 以降、security-group / iam / rds / ecs / lambda ... と続く
terraform.tfvars
profile・account_id・リージョンなどの実際の値はここにまとめます。機密値を含むので .gitignore 対象にするのが基本です(後述)。
account_id = "123456789012"
aws_region = "ap-northeast-1"
aws_profile = "project-x-local-deployer-develop"
vpc_id = "vpc-xxxxxxxx"
# ... 以下、環境固有の値
.gitignore の注意点
tfstate や認証情報をうっかりコミットしないよう、これだけは入れておきます。
# 機密値を含む変数ファイル
terraform.tfvars
*.auto.tfvars
# .terraform ディレクトリ
**/.terraform/*
# state ファイル
*.tfstate
*.tfstate.*
# ロックファイル(lock.hcl)を共有しない運用の場合
.terraform.lock.hcl
.terraform.lock.hcl は、チーム全員でプロバイダのバージョン・ハッシュを固定したい場合はコミットするのがTerraform公式の推奨です。環境差異が大きく各自で init させたい運用では .gitignore に入れる選択もあるので、ここはプロジェクトの方針に合わせてください。
まとめ:コピペ用・最速セットアップ手順
新しいPCで git clone してから terraform plan が通るまでを一気通貫で並べておきます。
# --- 0. 事前準備(初回のみ)---
# IAM ユーザーを作成し、アクセスキーID / シークレットを控えておく
# --- 1. ツールのインストール ---
brew install awscli tfenv
tfenv install 1.15.6
tfenv use 1.15.6
# --- 2. AWS profile 設定 ---
aws configure --profile project-x-local-deployer-develop
# Access Key ID / Secret / ap-northeast-1 / json を入力
aws sts get-caller-identity --profile project-x-local-deployer-develop # 認証確認
# --- 3. リポジトリへ移動 ---
cd providers/aws/environments/develop
# --- 4. backend(S3バケット)を bootstrap シェルで作る ---
bash 0_setup_backend.sh
# --- 5. init → plan → apply ---
terraform init
terraform plan
terraform apply
時間を溶かしがちなのは profile名の不一致・0_setup_backend.sh の実行忘れ(鶏と卵問題)・Terraformのバージョンずれの3つです。逆に言えばこの3つさえ押さえておけばセットアップはあっさり終わります。同じところでつまずく人が一人でも減れば嬉しいです。
おまけ: 99_cleanup_backend.sh で backend を消す
検証用に作った環境を畳むとき向けに、0_setup_backend.sh で作ったS3バケットを削除するスクリプトも置いています。先頭を 99_ にしてあるのは、ls したとき一番下に並んで普段の作業では目に入らないようにするためです。0_setup_backend.sh と同じくディレクトリ名から環境名を導出するので、environments/develop/ で実行すれば develop 環境のbackendが対象になります。
⚠️ これは tfstate を保管しているS3バケットそのものを消します。実運用中の環境に実行するとTerraformが管理対象を見失うので検証環境を畳むとき専用です。事故防止に、実行時は環境名の入力を求めます。
#!/usr/bin/env bash
set -euo pipefail
# ディレクトリ名から環境名を取得(0_setup_backend.sh と同じ流儀)
CURRENT=$(cd "$(dirname "$0")"; pwd)
ENV=$(echo "$CURRENT" | sed -e 's/.*\/\([^\/]*\)$/\1/')
AWS_PROFILE="project-x-local-deployer-$ENV"
S3_BUCKET_NAME="project-x-terraform-state-$ENV"
# 確認(環境名を打たせて取り違えを防ぐ)
echo "About to DELETE bucket '${S3_BUCKET_NAME}' and ALL its contents."
read -p "Type the environment name '${ENV}' to confirm: " CONFIRM
[ "${CONFIRM}" = "${ENV}" ] || { echo "Cancelled."; exit 0; }
# profile存在チェック & 認証テスト
if ! aws configure list-profiles | grep -qx "${AWS_PROFILE}"; then
echo "Error: AWS Profile '${AWS_PROFILE}' not found"
exit 1
fi
aws sts get-caller-identity --profile "${AWS_PROFILE}" > /dev/null
# バケットが無ければ何もしない(冪等)
if ! aws s3api head-bucket --bucket "${S3_BUCKET_NAME}" --profile "${AWS_PROFILE}" 2>/dev/null; then
echo "Bucket '${S3_BUCKET_NAME}' not found. Nothing to do."
exit 0
fi
# バージョニング有効なので、全バージョン + 削除マーカーを消してから本体を消す
# (use_lockfile の .tflock オブジェクトもここで一緒に消える)
DELETE_JSON=$(aws s3api list-object-versions \
--bucket "${S3_BUCKET_NAME}" --profile "${AWS_PROFILE}" --output json \
--query '{Objects: [Versions[].{Key:Key,VersionId:VersionId}, DeleteMarkers[].{Key:Key,VersionId:VersionId}][]}')
if [ "$(echo "${DELETE_JSON}" | jq '.Objects | length')" -gt 0 ]; then
echo "${DELETE_JSON}" | aws s3api delete-objects \
--bucket "${S3_BUCKET_NAME}" --profile "${AWS_PROFILE}" \
--delete file:///dev/stdin > /dev/null
fi
# 空になったバケットを削除
aws s3api delete-bucket --bucket "${S3_BUCKET_NAME}" --profile "${AWS_PROFILE}"
echo "Deleted bucket: ${S3_BUCKET_NAME}"
オブジェクト削除のJSON組み立てに jq を使うので、無ければ brew install jq で入れておきます。use_lockfile 方式ではロックがS3上の .tflock として存在し、上の全バージョン削除で一緒に消えるため、DynamoDBを別途消す処理は要りません。なお delete-objects は1回で最大1000オブジェクトなので、バージョンが大量に溜まったバケットでは複数回に分けるループが必要ですが、tfstateバケットなら通常はこれで足ります。