Terraform入門 ── AWSにEC2+RDSを作ってaws loginの認証エラーとドリフトを確かめる
はじめに
Terraformを初めて触りました。題材として、AWS上に次の構成を作っています。
- VPCの中に、プライベートサブネットのEC2とRDS(PostgreSQL)を置く
- EC2にはSSM Session Managerで入る(SSHは使わない)
- EC2から
psqlでRDSに接続する
EC2もRDSもパブリックIPを持たず、SSHのポートも開けていません。それでもEC2のシェルには入れて、DBにも繋がります。
Terraformが何と何(.tf ファイル、tfstate、実際のリソース)を比べているのかを、あえてドリフト(Terraformの外での変更)を起こして確かめ、理解を深めました。
この記事は自分と同じく、Terraformをこれから触る人に向けた記事です。コマンドの出力は、実際に自分の環境で出たものをそのまま載せています。エラーもそのまま載せました。ただし、リソースIDなどは伏せ字(xxxx など)にしています。
ソースコード
今回使ったTerraformのコードはGitHubに置いています。記事中では要所だけ抜粋しているので、全体はこちらを見てください。
yuudee/terraform_handson(GitHub)
先に注意(お金の話)
この構成はNAT Gatewayを使います。NAT Gatewayは無料利用枠の対象外で、置いておくだけで時間課金されます(通信量に応じたデータ処理料金や、パブリックIPv4アドレスの料金もかかります)。RDSやEC2も、アカウントの状態によっては課金されます。試したら、最後に必ず terraform destroy まで実行してください。
また、コードのリージョンは us-east-1(バージニア北部)にしています。東京リージョンで試す場合は variables.tf の aws_region と az_primary / az_secondary を任意のリージョンに書き換えてください。
作るもの
全体の構成は次のとおりです。

| # | 流れ | 内容 |
|---|---|---|
| ① | 自分の端末 → Session Manager | aws ssm start-session でセッションを開始 |
| ② | Session Manager → EC2 | EC2上のSSM Agent経由でシェルに入る |
| ③ | EC2 → RDS | psql で5432番ポートに接続 |
| ④〜⑥ | EC2 → NAT Gateway → Internet Gateway → インターネット | dnf install やSSMへの通信に使う |
環境
| ツール | バージョン |
|---|---|
| Terraform | 1.15.8 |
| AWS CLI | 2.36.14 |
| session-manager-plugin | 1.2.835.0 |
| AWSプロバイダ(hashicorp/aws) | 6.65.0 |
| randomプロバイダ(hashicorp/random) | 3.9.1 |
macOS(Apple Silicon)で実行しました。session-manager-plugin は aws ssm start-session をローカルから使うために必要です。
AWSへのログインは、AWS CLIの aws login(ブラウザで認証する方式)を使いました。
コードをざっくり読む
apply する前に、何を書いたのかを確認しておきます。
| ファイル | 内容 |
|---|---|
versions.tf |
Terraform本体とプロバイダのバージョン指定 |
variables.tf |
変数(リージョン、CIDR、インスタンスタイプなど) |
vpc.tf |
VPC、サブネット、Internet Gateway、NAT Gateway、ルートテーブル |
security_groups.tf |
EC2用・RDS用のセキュリティグループ |
iam.tf |
EC2にSSM用の権限を渡すIAMロール |
ec2.tf |
EC2インスタンス |
rds.tf |
RDS、DBサブネットグループ、マスターパスワードの生成 |
outputs.tf |
作成後に表示したい値(インスタンスID、RDSのホスト名など) |
Terraformはディレクトリ内の .tf をまとめて1つとして読むので、ファイルは自由に分けてかまいません(分け方のベストプラクティスは今回は調べていません)。
最低限の読み方
出てくるブロックは主に4種類です。
resource:作りたいもの。resource "aws_vpc" "main"なら「aws_vpcという種類のものを1つ作り、Terraformの中ではmainという名前で参照する」(AWS上の名前ではありません)data:既にあるものを読み取るだけ。今回はAmazon Linux 2023の最新AMI IDを取ってくるのに使っていますvariable:変数output:apply後に表示する値
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
# ...
}
resource "aws_subnet" "private_ec2" {
vpc_id = aws_vpc.main.id # ← VPCのIDを参照
cidr_block = var.private_subnet_ec2_cidr
availability_zone = var.az_primary
}
aws_vpc.main.id のように別のリソースを参照すると、Terraformが「サブネットはVPCができてから作る」という順番を自分で判断してくれます。コードのどこにも作る順番は書いていません。依存関係のないものは並列で作られます。処理の順番を気にしなくてよい点は便利です。
SSHなしでEC2に入れる理由
EC2用のセキュリティグループには、インバウンドルールを1つも書いていません。
# SSM Session ManagerはEC2上のSSMエージェントがアウトバウンドでSSMに接続する方式のため、インバウンドルールは一切不要。
resource "aws_security_group" "ec2" {
name = "${var.project_name}-ec2-sg"
description = "EC2 instance security group (SSM only, no inbound)"
vpc_id = aws_vpc.main.id
egress {
description = "Allow all outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
ingress = []
tags = {
Name = "${var.project_name}-ec2-sg"
}
}
ingress はインバウンド(外から入ってくる通信)、egress はアウトバウンド(外へ出ていく通信)のルールです。Session Managerは、EC2上のSSM Agentが自分から外(SSMのサービス)へ接続しに行く仕組みなので、入ってくる通信を許可する必要がありません。代わりに、EC2には AmazonSSMManagedInstanceCore を付けたIAMロールを渡しています(iam.tf)。
ingress = [] の行は最初はなく、後半の実験で追加しました。
RDS用のセキュリティグループは「EC2用のセキュリティグループが付いたものからの5432番だけ」を許可しています。
ingress {
description = "PostgreSQL from EC2"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.ec2.id]
}
プライベートサブネットが3つある理由
構成図を見ると、RDS用とは別に何も置いていない「予備」のサブネットがあります。
RDSを置くには「DBサブネットグループ」が必要で、ここには異なるAZのサブネットを最低2つ登録しないといけません。Multi-AZにしない単一構成のRDSでも同じです。そのため、別AZ(1c)に予備のサブネットを用意して登録だけしています。
RDS本体は availability_zone を明示して、必ずRDS用サブネット(1a)に置かれるようにしています。
resource "aws_db_subnet_group" "rds" {
name = "${var.project_name}-rds-subnet-group"
subnet_ids = [aws_subnet.private_rds.id, aws_subnet.private_rds_secondary.id]
}
resource "aws_db_instance" "main" {
# ...
db_subnet_group_name = aws_db_subnet_group.rds.name
availability_zone = var.az_primary
# ...
}
EC2用のサブネットはDBサブネットグループに入れていません。入れてしまうと、RDSがEC2と同じサブネットに置かれる可能性が出てくるためです。
RDSまわりで気をつけたところ
- マスターパスワードは
random_passwordで生成しています。RDSのパスワードに使えない文字(/、'、"、@、スペース)があるので、override_specialで使う記号を指定しています
resource "random_password" "rds_master" {
length = 20
special = true
# RDSのマスターパスワードで使用できない文字(/, ', ", @, スペース)を除外
override_special = "!#$%&*()-_=+[]{}<>:?"
}
Terraformが見ている3つの場所
コマンドを実行する際に、これを知っておくと後がだいぶ分かりやすくなりました。Terraformは次の3つを見ています。
| 場所 | 実体 | 意味 |
|---|---|---|
.tf ファイル |
自分で書いたコード | こうなっていてほしい目標とする姿 |
terraform.tfstate |
apply したときに作られるJSONファイル |
前回 apply した結果の記録(台帳) |
| 実際のインフラ | AWS上のリソース | 今の実際の状態 |
コマンドごとに整理すると、こうなります。
| コマンド | やること | AWS上のリソースを変更するか |
|---|---|---|
terraform init |
必要なプロバイダをダウンロードする | しない |
terraform fmt |
コードの見た目を整える | しない |
terraform validate |
文法や属性名が正しいか調べる | しない |
terraform plan |
何が変わるかを表示する | しない |
terraform apply |
実際に作る・変える | する |
terraform destroy |
作ったものを全部消す | する |
AWSのリソースを実際に変えるのは apply と destroy だけで、それ以外は何回打っても大丈夫です。
terraform init / fmt / validate
リポジトリをクローンして、そのディレクトリの中で実行します。
$ git clone https://github.com/yuudee/terraform_handson.git
$ cd terraform_handson
以下は自分が最初に init したときのログです。このときは versions.tf でAWSプロバイダを5系にしていたので、5.100.0が入っています。GitHubのコードは6系に直してあり、ロックファイルもあるので、クローンして init すると6.65.0が入ります。
$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/random versions matching "~> 3.0"...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/random v3.9.1...
- Installed hashicorp/random v3.9.1 (signed by HashiCorp)
- Installing hashicorp/aws v5.100.0...
- Installed hashicorp/aws v5.100.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
init は、.tf を読んで必要なプロバイダ(TerraformからAWSのAPIを操作するためのプラグイン)をダウンロードします。今回は aws と random の2つです。AWS上には何も作られません。
このとき、aws は ~> 5.0(5系の範囲で最新)の指定で、5.100.0が入っています。これが次の plan でエラーの原因になりました。
init すると .terraform.lock.hcl というファイルもできます。実際に入れたプロバイダのバージョンが記録されていて、次に init したときも同じバージョンが使われます。出力にも「バージョン管理に含めて(Include this file in your version control repository)」と書かれているので、GitHubにはこのファイルもコミットしています。
$ terraform fmt -check -recursive
$ terraform validate
Success! The configuration is valid.
fmt -check は、コードの書き方(インデントなど)が崩れていないかを調べるだけで、書き換えはしません。問題がなければ何も表示されずに終わります。
validate は、文法や属性名が正しいかを調べます。ただし、AWSには問い合わせません。validate が通っても、AWS上で本当に作れるかどうかは分かりません。
terraform plan
1回目:認証エラー
$ terraform plan
Terraform planned the following actions, but then encountered a problem:
# random_password.rds_master will be created
+ resource "random_password" "rds_master" {
...
}
Plan: 1 to add, 0 to change, 0 to destroy.
╷
│ Error: No valid credential sources found
│
│ with provider["registry.terraform.io/hashicorp/aws"],
│ on versions.tf line 16, in provider "aws":
│ 16: provider "aws" {
│
│ Please see https://registry.terraform.io/providers/hashicorp/aws
│ for more information about providing credentials.
│
│ Error: failed to refresh cached credentials, no EC2 IMDS role found, operation error ec2imds: GetMetadata, exceeded maximum number of attempts, 3, request send failed, Get
│ "http://169.254.169.254/latest/meta-data/iam/security-credentials/": dial tcp 169.254.169.254:80: i/o timeout
AWS CLIでは aws sts get-caller-identity が通るのに、Terraformからは認証情報が見つからないと言われました。
原因は、AWSプロバイダのバージョンでした。自分は aws login でログインしていましたが、このログイン情報を5系のAWSプロバイダ(5.100.0)では読めませんでした。
なぜ5系では読めなかったのか
aws login は、AWS CLI 2.32.0(2025年11月)で追加された新しいログイン方法です。ログインすると、~/.aws/config のプロファイルに login_session という項目が書き込まれ、認証情報そのものは ~/.aws/login/cache/ に保存されます。これまでの「アクセスキー」「SSO」「AssumeRole」などとは別の、新しい種類の認証情報です。
一方、TerraformのAWSプロバイダは、AWS CLIを呼び出しているわけではありません。内部に組み込まれた AWS SDK for Go v2 を使って、自分で認証情報を探しに行きます。探す順番(デフォルトの認証情報チェーン)は、ざっくり次のとおりです。
- 環境変数(
AWS_ACCESS_KEY_IDなど) ~/.aws/credentialsや~/.aws/configのプロファイル- コンテナ用の認証情報エンドポイント
- EC2のインスタンスメタデータ(IMDS)
aws login の認証情報を読む仕組みがSDK for Go v2に入ったのは、config パッケージの v1.32.0(2025年11月19日)です。AWSプロバイダがこのバージョンのSDKを取り込んだのは 6.23.0(2025年11月26日) からでした。
| AWSプロバイダ | 中のSDK(aws-sdk-go-v2/config) | aws login の認証情報 |
|---|---|---|
| 5.100.0 | v1.29.15 | 読めない |
| 6.22.0 | v1.31.21 | 読めない |
| 6.23.0 以降 | v1.32.2 〜 | 読める |
5系の最後のバージョンは5.100.0(2025年6月)で、aws login が登場するより前にリリースされています。その後は6系しか更新されていないので、5系をどれだけ新しくしても aws login には対応しません。
5.100.0のプロバイダから見ると、プロファイルに書かれた login_session は「知らない項目」でしかありません。そのため使える認証情報が見つからず、最後のIMDSまで探しに行きます。自分のMacはEC2ではないので 169.254.169.254 に繋がらずにタイムアウトし、最終的に No valid credential sources found になりました。AWS CLIは自分自身が aws login に対応しているので、get-caller-identity は普通に通っていました。
参考:
- aws-sdk-go-v2 config の CHANGELOG(v1.32.0 で aws login の認証情報に対応)
- terraform-provider-aws Issue #45316(aws login の認証情報への対応要望)
そこで、versions.tf を6系に変えました。
aws = {
source = "hashicorp/aws"
version = "~> 6.0" # 5.0 から変更
}
ロックファイルで5.100.0に固定されているので、バージョンを上げるときは -upgrade を付けて init し直します。
$ terraform init -upgrade
Initializing provider plugins...
- Finding hashicorp/random versions matching "~> 3.0"...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Using previously-installed hashicorp/random v3.9.1
- Installing hashicorp/aws v6.65.0...
- Installed hashicorp/aws v6.65.0 (signed by HashiCorp)
Terraform has made some changes to the provider dependency selections recorded
in the .terraform.lock.hcl file. Review those changes and commit them to your
version control system if they represent changes you intended to make.
.terraform.lock.hcl の中身が6.65.0に書き換わります。aws login を使っている人は、最初からAWSプロバイダを6系(正確には6.23.0以上)にしておくと、このエラーは避けられます。6系の中でも6.22.0以前だと同じエラーになるので、ロックファイルで古い6系に固定されている場合は注意してください。
2回目:成功
$ terraform plan
(リソースごとの詳細は省略)
Plan: 23 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ ec2_instance_id = (known after apply)
+ ec2_private_ip = (known after apply)
+ nat_gateway_public_ip = (known after apply)
+ rds_address = (known after apply)
+ rds_db_name = "appdb"
+ rds_master_password = (sensitive value)
+ rds_master_username = "dbadmin"
+ rds_port = (known after apply)
+ vpc_id = (known after apply)
23個のリソースを新しく作る(to add)という計画が出ました。plan はあくまで予告で、この時点ではまだ何も作られていません。
(known after apply) は、実際に作るまで決まらない値です。インスタンスIDやプライベートIPはAWSが作成時に決めるので、こう表示されます。逆に rds_db_name = "appdb" のように、コードに書いてある値は最初から分かっています。
plan の出力では、先頭の記号で何が起きるかが分かります。主なものは次のとおりです。
| 記号 | 意味 |
|---|---|
+ |
新しく作る |
~ |
作り直さずに設定だけ変える(update in-place) |
-/+ |
一度消して作り直す(replace) |
+/- |
先に新しいものを作ってから古いものを消す(create_before_destroy) |
- |
消す |
<= |
data ソースを読み取る |
-/+ は、たとえばEC2なら中身ごと作り直しになります。本番環境でやると事故になるので、apply の前に plan を読む癖をつけるのが大事だと思いました。
terraform apply
1回目:リージョンが違ってエラー
apply でもまた認証エラーになりました。
$ terraform apply
Terraform planned the following actions, but then encountered a problem:
(省略)
╷
│ Error: No valid credential sources found
│
│ with provider["registry.terraform.io/hashicorp/aws"],
│ on versions.tf line 16, in provider "aws":
│ 16: provider "aws" {
│
│ Error: failed to refresh cached credentials, create oauth2 token: operation error Signin: CreateOAuth2Token, https response error StatusCode: 400, RequestID:
│ ...
│ ValidationException: The provided authorization grant is invalid, expired, revoked, or malformed
このときコードのリージョンは東京(ap-northeast-1)でしたが、自分が aws login したのは us-east-1 でした。
原因は次のとおりです。
aws loginのログイン情報には、次の2つが入っている(~/.aws/login/cache/に保存される)- 一時的な認証情報:実際にAWSのAPIを呼ぶのに使う。有効期限は約15分
- リフレッシュトークン:一時的な認証情報の期限が切れたときに、新しいものをもらうために使う。ログインのセッション自体は最長12時間まで続く
planのときはまだ一時的な認証情報が有効だったので、そのまま使われた。一時的な認証情報そのものはどのリージョンのAPIにも使えるので、リージョンが違っても通ったapplyのときには一時的な認証情報の期限が切れていて、リフレッシュトークンで更新しようとした- ところがリフレッシュトークンは、発行されたリージョン(=
aws loginしたリージョン)でしか使えない。AWSプロバイダはコードに書いたリージョン(ap-northeast-1)で更新しに行くので、us-east-1で発行されたリフレッシュトークンが受け付けられず失敗した
この挙動は、AWS CLIのGitHub IssueでAWSのメンテナーが説明しています。
根本的な原因は、SignInがリフレッシュトークンを生成時と同じリージョンで使用しなければならないという制約を設けていることです。リクエストを行った際にトークンの有効期限が切れている場合、AWS CLI(およびその他のSDK)はリクエストが行われたリージョンでトークンを更新しようとします。(筆者訳)
出典:aws login refresh fails after ~10 minutes with CreateOAuth2Token INVALID_REQUEST(aws/aws-cli#10613)
variables.tf の aws_region を us-east-1 に揃えたら、そのまま通りました。コード側を東京リージョンのままにしたい場合は、逆に aws login --region ap-northeast-1 でログインし直しても解決します。どちらにしても、ログインしたリージョンとコードのリージョンを揃えておけば起きない問題でした。
2回目:成功
apply を実行すると、plan と同じ内容が表示されて yes の入力を求められます。yes と打つと作成が始まります。
$ terraform apply
(省略)
Apply complete! Resources: 23 added, 0 changed, 0 destroyed.
Outputs:
ec2_instance_id = "i-0xxxxxxxxxxxxxxxx"
ec2_private_ip = "10.0.1.162"
nat_gateway_public_ip = "xx.xx.xx.xx"
rds_address = "simple-vpc-handson-db.xxxxxxxxxxxx.us-east-1.rds.amazonaws.com"
rds_db_name = "appdb"
rds_master_password = <sensitive>
rds_master_username = "dbadmin"
rds_port = 5432
vpc_id = "vpc-0xxxxxxxxxxxxxxxx"
23個全部作られました。outputs.tf に書いた値が最後に表示されます。パスワードは sensitive = true にしているので <sensitive> と伏せられています。
tfstateが作られる
apply が終わると、作業ディレクトリに terraform.tfstate というファイルができています。これが先ほどの「台帳」です。作ったリソースのIDや設定が全部書かれています。
注意点として、このファイルにはRDSのマスターパスワードも平文で入っています。output では <sensitive> と伏せられていても、tfstateの中では丸見えです。GitHubに上げるときは、.gitignore で必ず除外してください。リポジトリにも .gitignore を入れてあります。
なお、aws_db_instance に manage_master_user_password = true を指定すると、マスターパスワードを AWS Secrets Manager が生成・管理するため、tfstate にパスワードを持たずに済みます。今回は仕組みを確かめるために random_password を使いましたが、実運用ではこちらを検討するとよいです。
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
tfplan
.terraform.lock.hcl は、さっき書いたとおりコミットする側です。
EC2に入ってRDSに繋ぐ
まずローカルで接続先の情報を取り出します。-raw を付けると引用符なしの値が出ます。
$ terraform output -raw rds_address
simple-vpc-handson-db.xxxxxxxxxxxx.us-east-1.rds.amazonaws.com
$ terraform output -raw rds_master_password
(パスワードが表示される)
sensitive にしたパスワードも、名前を指定して terraform output すれば表示されます。<sensitive> の伏せ字は一覧表示のときに画面に出さないだけで、値を隠す仕組みではありません。
次に、Session ManagerでEC2に入ります。
$ aws ssm start-session --target "$(terraform output -raw ec2_instance_id)" --region us-east-1
Starting session with SessionId: xxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxx
sh-5.2$
EC2の起動時に user_data で psql をインストールしているので、入っているか確認します。
sh-5.2$ which psql
/usr/bin/psql
起動直後はインストールが終わっておらず、何も表示されないことがあります。その場合は少し待ってから確認してください。
RDSに接続します。パスワードは先ほど terraform output -raw で出したものです。
sh-5.2$ psql -h simple-vpc-handson-db.xxxxxxxxxxxx.us-east-1.rds.amazonaws.com -U dbadmin -d appdb
Password for user dbadmin:
psql (15.19, server 15.17)
SSL connection (protocol: TLSv1.2, cipher: ECDHE-RSA-AES256-GCM-SHA384, compression: off)
Type "help" for help.
appdb=> SELECT version();
version
------------------------------------------------------------------------------------------------------------------
PostgreSQL 15.17 on x86_64-pc-linux-gnu, compiled by x86_64-pc-linux-gnu-gcc (GCC) 12.4.0, 64-bit
(1 row)
appdb=> \q
sh-5.2$
EC2からRDSに繋がりました。構成図の①〜③が全部通ったことになります。
コンソールから手を加えたらどうなるか試す
Terraformで作ったリソースを、Terraformを通さずに変更してから、もう一度 plan / apply するとどうなるかを試しました。
変更はAWS CLIで行っていますが、コンソールで手で変えるのと同じことです。
.tf・tfstate・実際のインフラの関係と、ドリフトが起きる場所を図にすると次のようになります。

実験1:EC2の名前(Nameタグ)を変える
EC2の Name タグを、Terraformの外から manually-renamed に書き換えます。Name タグは、コンソールのインスタンス一覧に出てくる名前のことです。
$ INSTANCE_ID=$(terraform output -raw ec2_instance_id)
$ aws ec2 create-tags --resources "$INSTANCE_ID" --tags Key=Name,Value=manually-renamed --region us-east-1
コンソールで見ると、Nameタグが manually-renamed に変わっています。

この状態で plan します。
$ terraform plan
Terraform will perform the following actions:
# aws_instance.app will be updated in-place
~ resource "aws_instance" "app" {
id = "i-0xxxxxxxxxxxxxxxx"
~ tags = {
~ "Name" = "manually-renamed" -> "simple-vpc-handson-ec2"
}
~ tags_all = {
~ "Name" = "manually-renamed" -> "simple-vpc-handson-ec2"
}
# (40 unchanged attributes hidden)
# (9 unchanged blocks hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
manually-renamed を simple-vpc-handson-ec2 に戻す、という計画が出ました。
ここで大事なのは、plan が manually-renamed という今の値を知っていることです。tfstateには前回 apply したときの simple-vpc-handson-ec2 しか書かれていません。plan は毎回、tfstateに載っているリソースの今の状態をAWSに問い合わせてから(これをrefreshと言います)、.tf と比べています。
apply すると元に戻ります。
$ terraform apply
(plan と同じ内容が表示される)
Enter a value: yes
aws_instance.app: Modifying... [id=i-0xxxxxxxxxxxxxxxx]
aws_instance.app: Modifications complete after 4s [id=i-0xxxxxxxxxxxxxxxx]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
~(update in-place)なので、EC2は作り直されず、タグだけが変わりました。コンソールで入れた変更は、Terraformに上書きされて消えます。
実験2:RDS用のセキュリティグループにルールを足す
次はセキュリティグループです。まずIDを取り出します。
$ RDS_SG_ID=$(echo 'aws_security_group.rds.id' | terraform console | tr -d '"')
$ EC2_SG_ID=$(echo 'aws_security_group.ec2.id' | terraform console | tr -d '"')
terraform console はTerraformの式を評価できるコマンドで、ここではtfstateからセキュリティグループのIDを読み出しています。
RDS用のセキュリティグループに、VPC内(10.0.0.0/16)からの5432番を許可するルールを追加します。
$ aws ec2 authorize-security-group-ingress --group-id "$RDS_SG_ID" --protocol tcp --port 5432 --cidr 10.0.0.0/16 --region us-east-1
{
"Return": true,
"SecurityGroupRules": [
{
"SecurityGroupRuleId": "sgr-0xxxxxxxxxxxxxxxx",
"GroupId": "sg-0xxxxxxxxxxxxxxxx",
"IsEgress": false,
"IpProtocol": "tcp",
"FromPort": 5432,
"ToPort": 5432,
"CidrIpv4": "10.0.0.0/16",
...
}
]
}
$ terraform plan
Terraform will perform the following actions:
# aws_security_group.rds will be updated in-place
~ resource "aws_security_group" "rds" {
id = "sg-0xxxxxxxxxxxxxxxx"
~ ingress = [
- {
- cidr_blocks = [
- "10.0.0.0/16",
]
- from_port = 5432
- ipv6_cidr_blocks = []
- prefix_list_ids = []
- protocol = "tcp"
- security_groups = []
- self = false
- to_port = 5432
# (1 unchanged attribute hidden)
},
# (1 unchanged element hidden)
]
name = "simple-vpc-handson-rds-sg"
# (9 unchanged attributes hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
追加したルールが -(削除)の対象として出ました。# (1 unchanged element hidden) は、コードに書いてある「EC2からの5432番」のルールで、こちらはそのまま残ります。
apply すると、追加したルールが消えました。
$ terraform apply
(省略)
aws_security_group.rds: Modifying... [id=sg-0xxxxxxxxxxxxxxxx]
aws_security_group.rds: Modifications complete after 2s [id=sg-0xxxxxxxxxxxxxxxx]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
ここまでは実験1と同じで、予想どおりです。
実験3:EC2用のセキュリティグループに22番を開ける
同じことを、EC2用のセキュリティグループでやります。SSHの22番を開けます(許可元はVPC内の 10.0.0.0/16 に限定し、実験4で閉じます。0.0.0.0/0 には開けないでください)。
この時点では、まだコードに ingress = [] の行はありません。EC2用のセキュリティグループには egress しか書いていない状態です。
$ aws ec2 authorize-security-group-ingress --group-id "$EC2_SG_ID" --protocol tcp --port 22 --cidr 10.0.0.0/16 --region us-east-1
{
"Return": true,
"SecurityGroupRules": [
{
"SecurityGroupRuleId": "sgr-0yyyyyyyyyyyyyyyy",
"GroupId": "sg-0yyyyyyyyyyyyyyyy",
"IsEgress": false,
"IpProtocol": "tcp",
"FromPort": 22,
"ToPort": 22,
"CidrIpv4": "10.0.0.0/16",
...
}
]
}
$ terraform plan
No changes. Your infrastructure matches the configuration.
「変更なし」と表示されました。しかし、ルールは残ったままです。
$ aws ec2 describe-security-groups --group-ids "$EC2_SG_ID" --region us-east-1 --query 'SecurityGroups[0].IpPermissions'
[
{
"IpProtocol": "tcp",
"FromPort": 22,
"ToPort": 22,
"UserIdGroupPairs": [],
"IpRanges": [
{
"CidrIp": "10.0.0.0/16"
}
],
"Ipv6Ranges": [],
"PrefixListIds": []
}
]
実験2とやっていることは同じなのに、結果が違います。
RDS用のセキュリティグループには ingress ブロックを書いていて、EC2用には何も書いていません。ingress を1つも書いていないと、Terraformはインバウンドのルールを管理しません。これはAWSプロバイダの仕様です。なお、現在のプロバイダでは、インラインの ingress/egress より個別のルールリソース(aws_vpc_security_group_ingress_rule など)の利用が推奨されています。
実験4:ingress = [] を書いて管理下に入れる
EC2用のセキュリティグループに、ingress = [] を追加します。
resource "aws_security_group" "ec2" {
# ...
ingress = [] # インバウンドは空、ということまで管理する
# ...
}
$ terraform plan
Terraform will perform the following actions:
# aws_security_group.ec2 will be updated in-place
~ resource "aws_security_group" "ec2" {
id = "sg-0yyyyyyyyyyyyyyyy"
~ ingress = [
- {
- cidr_blocks = [
- "10.0.0.0/16",
]
- from_port = 22
- ipv6_cidr_blocks = []
- prefix_list_ids = []
- protocol = "tcp"
- security_groups = []
- self = false
- to_port = 22
# (1 unchanged attribute hidden)
},
]
name = "simple-vpc-handson-ec2-sg"
# (9 unchanged attributes hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
今度は、実験3で開けた22番が削除対象として出ました。apply すると消えます。
$ terraform apply
(省略)
aws_security_group.ec2: Modifying... [id=sg-0yyyyyyyyyyyyyyyy]
aws_security_group.ec2: Modifications complete after 1s [id=sg-0yyyyyyyyyyyyyyyy]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
$ aws ec2 describe-security-groups --group-ids "$EC2_SG_ID" --region us-east-1 --query 'SecurityGroups[0].IpPermissions'
[]
GitHubのコードは、この ingress = [] を入れた状態にしています。
Terraformが戻してくれるのは、コードに書いてあるものだけです。書いていない部分は見てもらえません。「Terraformで管理しているから大丈夫」と思っていても、書き方によっては管理されていない部分がある、というのはこの実験をやるまで気付きませんでした。
実験5:apply -refresh-only で変更を受け入れたらどうなるか
最後に、「コンソールの変更を戻されたくない場合」を試しました。
-refresh-only を付けると、tfstate(台帳)を今の実際の状態に合わせることだけをします。AWSのリソースは変更しません。
もう一度Nameタグを manually-renamed に変えてから実行しました。
$ terraform apply -refresh-only
Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:
# aws_instance.app has changed
~ resource "aws_instance" "app" {
id = "i-0xxxxxxxxxxxxxxxx"
~ tags = {
~ "Name" = "simple-vpc-handson-ec2" -> "manually-renamed"
}
~ tags_all = {
~ "Name" = "simple-vpc-handson-ec2" -> "manually-renamed"
}
# (40 unchanged attributes hidden)
# (9 unchanged blocks hidden)
}
This is a refresh-only plan, so Terraform will not take any actions to undo
these. If you were expecting these changes then you can apply this plan to
record the updated values in the Terraform state without changing any remote
objects.
Would you like to update the Terraform state to reflect these detected changes?
Terraform will write these changes to the state without modifying any real infrastructure.
There is no undo. Only 'yes' will be accepted to confirm.
Enter a value: yes
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
矢印の向きが、実験1の plan とは逆になっています。実験1は「manually-renamed → simple-vpc-handson-ec2 に戻す」、こちらは「台帳の simple-vpc-handson-ec2 → manually-renamed に更新する」です。0 changed なので、AWS側は何も変わっていません。
これでtfstateは manually-renamed になりました。この状態で、もう一度 plan してみます。
$ terraform plan
Terraform will perform the following actions:
# aws_instance.app will be updated in-place
~ resource "aws_instance" "app" {
id = "i-0xxxxxxxxxxxxxxxx"
~ tags = {
~ "Name" = "manually-renamed" -> "simple-vpc-handson-ec2"
}
~ tags_all = {
~ "Name" = "manually-renamed" -> "simple-vpc-handson-ec2"
}
# (40 unchanged attributes hidden)
# (9 unchanged blocks hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
やはり元に戻そうとします。
plan が比べているのは .tf と実際のインフラで、.tf には simple-vpc-handson-ec2 と書いたままだからです。台帳を書き換えても、比べる相手は変わりません。「こうなっていてほしい姿」を決めているのは、常に .tf のほうでした。
コンソールでの変更を本当に残したいなら、.tf のほうを書き換える必要があります。今回は apply して元に戻しました。
実験のまとめ
| 実験 | やったこと | plan の結果 | apply 後 |
|---|---|---|---|
| 1 | EC2のNameタグを変える | 戻す差分が出る | 元の名前に戻る |
| 2 | RDS用SGにルールを足す | 削除の差分が出る | ルールが消える |
| 3 | EC2用SGに22番を開ける(ingress を書いていない) |
No changes | ルールが残ったまま |
| 4 | ingress = [] を書く |
削除の差分が出る | ルールが消える |
| 5 | apply -refresh-only してから plan |
戻す差分が出る | (applyして元に戻した) |
terraform destroy
$ terraform destroy
(省略)
Destroy complete! Resources: 23 destroyed.
作った23個が全部消えました。消すときは依存関係の逆順になるので、VPCのように他から参照されているものは後回しになります。
destroy が消すのは、tfstateに載っているものだけです。Terraformの外で手で作ったものは消えないので、心配な人はコンソールでも確認しておくと安心です。今回のコードでは、RDSの削除時にスナップショットを作らない設定(skip_final_snapshot = true)にしています。
ハマったところのまとめ
実際に踏んだものだけ並べます。
- Terraformから認証できない(
No valid credential sources found)
aws loginのログイン情報を、AWSプロバイダの5系では読めませんでした。6系に上げてterraform init -upgradeしたら通りました。 - applyのときだけ認証エラー(
The provided authorization grant is invalid...)
aws loginしたリージョンと、コードのリージョンが違っていました。揃えたら解決しました。 - AWS CLIで「インスタンスが存在しない」と言われる(
InvalidInstanceID.NotFound)
実験中に--region ap-northeast-1のままcreate-tagsを実行したところ、このエラーが出ました。IDは合っていても、リソースがあるのと違うリージョンを探すと「存在しない」と言われます。
リージョンがらみが2つあるので、最初にリージョンを1つに決めて、AWS CLIとコードの両方で揃えておくのが一番の対策です。
おわりに
コードを書いてコマンドを打つだけでインフラができることを、実際に手を動かして確かめられました。コンソールでの構築は再現性が低い一方、インフラをコードで管理すれば同じ構成を何度でも作れます。このメリットを体感できたのが一番の収穫です。
一方で、実験で見たとおり、Terraformの外で変更すると .tf と実際のインフラがずれます。実際のプロダクトで運用する場合は、「変更は必ずTerraformから行う」といった手順をチームで決めておく必要があると感じました。
次は、今回やらなかった次のあたりを試してみたいです。
- tfstateをS3に置く
- moduleを使ってコードを分割する
- GitHub Actionsで
planを自動実行する(CI/CD関連)