1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub Copilot × Terraform で AWS の最小検証環境を作ってみる

Last updated at Posted at 2025-12-25

はじめに

アプリケーション開発をすぐに始めたい一方で、
クラウドインフラに不慣れな方や、GUIコンソールから操作されている方は、
環境準備の手間やGUI操作の正確性などに負荷を感じることが多いのではないでしょうか。

このような課題に対して、
環境準備をコード化することで用意する負荷を下げ
GUI操作ミスも抑えられるのではないかと考えました。

ネットワーク環境とEC2インスタンスの構築を例に
GitHub CopilotでTerraformコードを生成し、
どのようなコンテキストを与えれば、実用的になるか を検証しました。

全体の流れ

以下の流れで進めました

  1. 作成するアーキテクチャの検討 → コンテキストへのまとめ
  2. GitHub CopilotでTerraformコードを生成
  3. 生成物の確認
  4. Terraformの実行 → AWSリソース作成
  5. AWSリソース作成後の確認
  6. クリーンアップ

1. 作成するアーキテクチャの検討 → コンテキストへのまとめ

  • どんな環境を作るか仕様を整理(今回は最小限の検証環境想定)
  • 生成物は「Terraformコード」「説明書的なもの」「アーキテクチャ図」とする
  • コンテキストへの記述が曖昧であったり、矛盾があると生成物のブレにつながるので、
    やってほしいこと、やってほしくないことを明確に記述します
  • GitHub Copilotのチャットで壁打ちし、整理した仕様をMarkdownファイルに書き出します

簡単にやることだけまとめてしまいましたが、
実際には最初から明確なアーキテクチャを描けていたわけではありません。

検証環境とはいえ、AWS には選択肢となるサービスが多く、
「何を使い、何を使わないか」の決めの検討が必要でした。

そのため、まずは以下のようなラフなイメージを箇条書きで書き出し、
GitHub Copilot のチャットに渡すところから始めました。

  • ネットワークはパブリックとプライベートに分ける
  • パブリック側を入口、プライベート側は非公開にしたい
  • 特定のマネージドサービスに依存せず、汎用性の高い EC2 とする
  • リージョンで単一AZ構成とする
  • 検証用途のため、極力コストはかけない
  • 外部からのアクセスは筆者の IP アドレスからの SSH のみに限定する

構成する規模自体は小さいですが、
この工程は「何を作るか」を詰める作業そのものであり、
いわゆる要件定義から基本設計に相当するとイメージいただくとよいと思います。

このイメージを起点に生成AIと壁打ちをしながら
VPC / Subnet / IGW / NAT Gateway / Route Table / Security Group / EIP / EC2
アーキテクチャ図 / 手順 / 監視やログ取得は不要といった内容を具体化し、
最終的にコンテキストファイルとして整理しました。

書ききれないため、詳細は割愛させていただきますが、
「コンテキスト作成 → Terraform生成 → デプロイ → 確認 → コンテキスト修正」を何度も繰り返し
最終的に以下のように固めることができました。

【コンテキストファイル最終形】
# 目的・ゴール
- 最小限の検証環境を構築するterraformコードおよびドキュメントの作成

## 前提条件
- AWSにIAMユーザーおよび認証情報が作成されていること
- 指定したリージョンに KeyPair(`key_name`)が作成済みであること
- ローカルPCにTerraformをインストールしていること

## アーキテクチャ構成
- AWS リージョン: ap-northeast-1(東京)
- Availability Zone: 単一AZ(例: ap-northeast-1a)
- VPC: 10.0.0.0/16
- Public Subnet: 10.0.1.0/24(Public IPv4 の自動割当は無効化を想定: map_public_ip_on_launch = false)
- Private Subnet: 10.0.2.0/24
- Internet Gateway: VPC にアタッチし Public Route Table で 0.0.0.0/0 を向ける
- NAT Gateway: Public Subnet に配置し、Private Route Table で 0.0.0.0/0 -> NAT Gateway を設定して Private Subnet のアウトバウンドを許可
- Route Tables: Public RT(0.0.0.0/0 -> IGW)と Private RT(0.0.0.0/0 -> NAT)を作成し、それぞれ対応Subnetに関連付ける
- Security Group:
  - Bastion SG:
    - Inbound: 管理者IP(my_ip_cidr)からのSSH(22)のみ
    - Outbound: 全許可
  - Private SG:
    - Inbound: Bastion SGからのSSH(22)のみ
    - Outbound: 全許可(PrivateからのアウトバウンドはNAT経由)
- EC2: Amazon Linux 2023(x86_64)
  - インスタンスタイプ: t3.micro(x86_64)
  - Bastionインスタンス:
    - Public Subnetに配置
    - Elastic IP を付与
     EIPを作成しインスタンスに関連付ける。自動割当のPublic IPv4は使用しない
    - SSH接続の踏み台として利用
  - Privateインスタンス:
    - Private Subnetに配置
    - Elastic IP は付与しない
    - Bastion 経由でSSH接続(SG: Bastion SGからsshのみ許可)
  - タグ: Project、 Env、 Owner、 ManagedBy=Terraform

## 作成するファイル
- main.tf — VPC、subnet、igw、route table、eip、nat gateway、sg、ec2
- variables.tf — 設定可能な変数(`aws_region`, `availability_zone`, `vpc_cidr`, `public_subnet_cidr`, `private_subnet_cidr`, `key_name`, `my_ip_cidr`, `instance_type`)
- terraform.tfvars — ユーザー固有値(`key_name`, `my_ip_cidr` など)
- outputs.tf — リソース作成時に決まる値を出力
- README.md
  - AWSへプロビジョニング前の準備作業をステップバイステップで記載する
  - variables.tfで設定可能な変数について記載する
  - リソース作成後の稼働確認チェック
    - 最低限チェックするレベルの稼働チェック方法
    - 作成したリソースをステップバイステップで詳細にチェックする方法
  - 注意事項を記述する
  - 動作確認後のリソース削除方法を記述する
- Architecture.md - Mermaidでアーキテクチャ図を作成する

2. GitHub CopilotでTerraformコードを生成

GitHub CopilotでAgentモード及びモデルを選択し、
コンテキストファイル含め、以下のプロンプトで指示すると生成が始まります。
(下記スクリーンショット参照)

■ 実際に投入した一行プロンプト

「任意」フォルダの配下にコンテキストの指示通りに作成してください

プロンプトはあえて簡潔にしました。
というのも、コンテキストとプロンプトを分けて書くと
どちらに何を書いたか私自身が混乱したためです。

仕様=コンテキスト
何を、どのように、どうやって、どのくらい=プロンプト
と頭の中で区別はつけてはいました。

ところが、実際にやってみると整理しきれないところもあり、
コンテキスト側にまとめるのが今のところの正解だと考えています。

image.png

作成されたコード1:main.tf
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

locals {
  common_tags = {
    Project   = "iac_poc_20251218_01"
    Env       = "poc"
    Owner     = "tbd"
    ManagedBy = "Terraform"
  }
}

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  filter {
    name   = "root-device-type"
    values = ["ebs"]
  }
}

resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = merge(local.common_tags, { Name = "poc-vpc" })
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = merge(local.common_tags, { Name = "poc-igw" })
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.public_subnet_cidr
  availability_zone       = var.availability_zone
  map_public_ip_on_launch = false

  tags = merge(local.common_tags, { Name = "poc-public-subnet" })
}

resource "aws_subnet" "private" {
  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.private_subnet_cidr
  availability_zone       = var.availability_zone
  map_public_ip_on_launch = false

  tags = merge(local.common_tags, { Name = "poc-private-subnet" })
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }

  tags = merge(local.common_tags, { Name = "poc-public-rt" })
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

resource "aws_eip" "nat" {
  domain = "vpc"

  tags = merge(local.common_tags, { Name = "poc-nat-eip" })
}

resource "aws_nat_gateway" "this" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public.id

  tags = merge(local.common_tags, { Name = "poc-nat" })

  depends_on = [aws_internet_gateway.this]
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.this.id
  }

  tags = merge(local.common_tags, { Name = "poc-private-rt" })
}

resource "aws_route_table_association" "private" {
  subnet_id      = aws_subnet.private.id
  route_table_id = aws_route_table.private.id
}

resource "aws_security_group" "bastion" {
  name        = "poc-bastion-sg"
  description = "Bastion SG"
  vpc_id      = aws_vpc.this.id

  ingress {
    description = "SSH from admin IP"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.my_ip_cidr]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(local.common_tags, { Name = "poc-bastion-sg" })
}

resource "aws_security_group" "private" {
  name        = "poc-private-sg"
  description = "Private instance SG"
  vpc_id      = aws_vpc.this.id

  ingress {
    description     = "SSH from bastion SG"
    from_port       = 22
    to_port         = 22
    protocol        = "tcp"
    security_groups = [aws_security_group.bastion.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(local.common_tags, { Name = "poc-private-sg" })
}

resource "aws_instance" "bastion" {
  ami                         = data.aws_ami.al2023.id
  instance_type               = var.instance_type
  subnet_id                   = aws_subnet.public.id
  vpc_security_group_ids      = [aws_security_group.bastion.id]
  key_name                    = var.key_name
  associate_public_ip_address = false

  tags = merge(local.common_tags, { Name = "poc-bastion" })
}

resource "aws_eip" "bastion" {
  domain = "vpc"

  tags = merge(local.common_tags, { Name = "poc-bastion-eip" })
}

resource "aws_eip_association" "bastion" {
  allocation_id = aws_eip.bastion.id
  instance_id   = aws_instance.bastion.id
}

resource "aws_instance" "private" {
  ami                         = data.aws_ami.al2023.id
  instance_type               = var.instance_type
  subnet_id                   = aws_subnet.private.id
  vpc_security_group_ids      = [aws_security_group.private.id]
  key_name                    = var.key_name
  associate_public_ip_address = false

  tags = merge(local.common_tags, { Name = "poc-private" })
}
作成されたコード2:variables.tf
terraform {
  required_version = ">= 1.6.0"
}

variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "ap-northeast-1"
}

variable "availability_zone" {
  description = "Single AZ to deploy into (e.g., ap-northeast-1a)"
  type        = string
  default     = "ap-northeast-1a"
}

variable "vpc_cidr" {
  description = "CIDR for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "public_subnet_cidr" {
  description = "CIDR for the public subnet"
  type        = string
  default     = "10.0.1.0/24"
}

variable "private_subnet_cidr" {
  description = "CIDR for the private subnet"
  type        = string
  default     = "10.0.2.0/24"
}

variable "key_name" {
  description = "Existing EC2 Key Pair name in the target region"
  type        = string
}

variable "my_ip_cidr" {
  description = "Your admin IP in CIDR notation for SSH to bastion"
  type        = string

  validation {
    condition     = can(cidrnetmask(var.my_ip_cidr))
    error_message = "my_ip_cidr must be a valid CIDR."
  }
}

variable "instance_type" {
  description = "EC2 instance type (x86_64)"
  type        = string
  default     = "t3.micro"
}
作成されたコード3:terraform.tfvars
# 必須: 事前に対象リージョンに EC2 Key Pair を作成してください
key_name = "キーペア名"

# 必須: あなたのグローバルIP を /32 で指定してください
my_ip_cidr = "XXX.XXX.XXX.XXX/32"

# 任意
aws_region        = "ap-northeast-1"
availability_zone = "ap-northeast-1a"
instance_type     = "t3.micro"

# 任意(デフォルトのままでOK)
vpc_cidr            = "10.0.0.0/16"
public_subnet_cidr  = "10.0.1.0/24"
private_subnet_cidr = "10.0.2.0/24"
作成されたコード4:outputs.tf
output "aws_region" {
  description = "AWS region"
  value       = var.aws_region
}

output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.this.id
}

output "public_subnet_id" {
  description = "Public subnet ID"
  value       = aws_subnet.public.id
}

output "private_subnet_id" {
  description = "Private subnet ID"
  value       = aws_subnet.private.id
}

output "bastion_eip" {
  description = "Bastion Elastic IP"
  value       = aws_eip.bastion.public_ip
}

output "bastion_instance_id" {
  description = "Bastion instance ID"
  value       = aws_instance.bastion.id
}

output "private_instance_id" {
  description = "Private instance ID"
  value       = aws_instance.private.id
}

output "private_instance_private_ip" {
  description = "Private instance private IP"
  value       = aws_instance.private.private_ip
}
作成された REAMDME.md ファイル
最小限の検証環境を Terraform で構築します(単一AZ / VPC + Public/Private Subnet + IGW + NAT GW + Bastion EC2(EIP) + Private EC2)。

## 前提条件
- AWS に IAM ユーザー(またはロール)と認証情報があること
- 対象リージョンに EC2 Key Pair(`key_name`)が作成済みであること
- ローカルPCに Terraform がインストールされていること

## 構成(要点)
- Region: `ap-northeast-1`(デフォルト)
- AZ: 単一(デフォルト `ap-northeast-1a`- Public Subnet: `map_public_ip_on_launch = false`(自動割当の Public IPv4 は使わない)
- Bastion: Public Subnet 配置 + Elastic IP を関連付け
- Private EC2: Private Subnet 配置(Public IPv4 なし)
- Private のアウトバウンド: NAT Gateway 経由
- セキュリティ:
  - Bastion SG: `my_ip_cidr` からの SSH(22) のみ許可
  - Private SG: Bastion SG からの SSH(22) のみ許可
- タグ(全リソース): `Project`, `Env`, `Owner`, `ManagedBy=Terraform`
  - 既定値は [main.tf](main.tf)`local.common_tags` にあります(必要に応じて編集してください)

## 変数
- `aws_region`(default: ap-northeast-1)
- `availability_zone`(default: ap-northeast-1a)
- `vpc_cidr`(default: 10.0.0.0/16)
- `public_subnet_cidr`(default: 10.0.1.0/24)
- `private_subnet_cidr`(default: 10.0.2.0/24)
- `key_name`(必須)
- `my_ip_cidr`(必須)
- `instance_type`(default: t3.micro)

## AWS へプロビジョニング前の準備(ステップ)
1. AWS 認証情報を用意
   - 例(PowerShell): `setx AWS_PROFILE your-profile-name`
   - 既に `~/.aws/credentials` を使っている場合は設定不要です
2. EC2 Key Pair を作成(未作成の場合)
   - AWS Console > EC2 > Key Pairs で作成し、`key_name` を控える
3. 自分のグローバルIPを確認し、`my_ip_cidr``/32` で用意
4. 設定ファイルを編集
   - [terraform.tfvars](terraform.tfvars)`key_name``my_ip_cidr` を置換

## デプロイ手順
このフォルダで実行します。

1. 初期化
   - `terraform init`
2. 事前確認(推奨)
   - `terraform fmt -recursive`
   - `terraform validate`
3. 計画
   - `terraform plan -out plan.tfplan`
4. 適用
   - `terraform apply plan.tfplan`

## 稼働確認チェック
### 最低限(疎通)
1. Bastion の EIP を確認
   - `terraform output bastion_eip`
2. Bastion へ SSH
   - `ssh -i /path/to/key.pem ec2-user@<bastion_eip>`
3. Private インスタンスのプライベートIPを確認
   - `terraform output private_instance_private_ip`
4. Bastion から Private へ SSH
   - `ssh -i /path/to/key.pem ec2-user@<private_instance_private_ip>`

### もう少し丁寧(リソース単位で確認)
- VPC / Subnet / Route Table
  - Public RT に `0.0.0.0/0 -> IGW` がある
  - Private RT に `0.0.0.0/0 -> NAT GW` がある
- NAT Gateway
  - Public Subnet に配置され、EIP が割り当てられている
- セキュリティグループ
  - Bastion: `my_ip_cidr` のみ 22/tcp
  - Private: Bastion SG のみ 22/tcp

## 注意事項(重要)
- NAT Gateway は時間課金・転送課金が発生します。検証後は必ず削除してください。
- `my_ip_cidr` は必ず自分のIPのみに絞ってください(`0.0.0.0/0` は避ける)。
- Public Subnet は `map_public_ip_on_launch=false` のため、Bastion の到達性は EIP に依存します。

## リソース削除方法
- `terraform destroy`

必要なら、削除前に `terraform state list` で作成リソースを確認してください。

3. 生成物の確認

コードやドキュメントなど生成されたものをチェックし、
事前に定めた観点で生成結果を評価していきます。

  • 指示したファイルが生成されたこと
  • コンテキストで指示した内容に従ったコードやファイルが生成されること
  • 意図した通りになるまで「1」~「3」を繰り返し、
    再現性、生成されるコード等に問題がないか見極めます

【生成されたアーキテクチャ図】
以下のような残念ポイントもありましたが、

  • IGWやNAT Gatewayの先に外部接続するイメージがほしかった
  • 文字と図形がかぶさっている
    (ただ、作図のコンテキストがかなり大雑把なので、これ指示する側の問題ですね。)
    おおむね設計した感じになったのではないでしょうか

とはいえ、
パーフェクトに作図してもらうのは工夫と根気が必要そうでした
気になる個所があれば直接手を入れた方が早いですし、ストレスは少ないと思います

4. Terraformの実行 → AWSリソース作成

  • ここからは実際にコードを実行し、AWSのリソースをプロビジョニングします
  • コードの準備や利用方法を README.md に記述するように指示しているので内容を確認
    → 他の環境を破壊するコマンドはありませんが、意図を読み取りながら進めます

4-1. 固有変数(terraform.tfvars)を修正

  • terraform.tfvars の以下を編集
    • key_name にEC2 Key Pair 名を記述
    • my_ip_cidr に発信元のIPを確認して記述

4-2. デプロイ

4-2-1. 初期化

PS> terraform init

4-2-2. 事前確認

PS> terraform fmt -recursive
PS> terraform validate

4-2-3. Createされるリソースを確認

PS> terraform plan -out plan.tfplan

4-2-4. applyしてAWSへプロビジョニング

PS> terraform apply plan.tfplan

4-2-5. Outputsを確認

EIPとprivateのEC2インスタンスのIPアドレスを確認します

PS> terraform output

4-2-6. terraform state list で作成リソース一覧を確認

  • 実際には「4-2-1」「4-2-2」までエージェント側で実施してくれたので、筆者が実行したのは「4-2-3」以降
  • 記載通りに進めたい場合、プロンプト等でその旨を指示してみてください

5. Terraform実行後の確認

README.mdを参考に以下の流れで確認します(WindowsターミナルとWSLを併用)

5-1. Bastion へ SSH

# ssh -i /path/to/key.pem ec2-user@<bastion_eip>

5-2. Bastionインスタンスからインターネットへの疎通確認

# curl -I http://google.com

5-3. Bastionインスタンス経由でPrivateインスタンス へSSH

【ローカルPCで実行】

# eval "$(ssh-agent -s)"
# ssh-add /path/to/key.pem
# ssh -A ec2-user@<bastion_instance_ip>

【Bastion 上で実行】

# ssh ec2-user@<private_instance_ip>

5-4. Privateインスタンスからインターネットへのアウトバウンド疎通確認

# curl -I http://google.com

5-5. terraform state list の確認

5-6. AWSコンソール上で各リソースの確認

ここまでで問題ないことを確認した上で
当初の想定通りに環境を構成できたと判断しました。

6. クリーンアップ

最後に作成したリソースを削除します。
課金対象のリソースもあるので下記コマンドで忘れずに削除します
本記事の課金対象リソースは「NAT Gateway」「EC2×2台」になります

PS> terraform destroy

まとめ&感想

3つにまとめます。

1点目
生成AIを使い IaCを生成するよりも
「何を作るか」を仕様にまとめ、コンテキストに落とし込むのに時間と判断を要しました。

仕様が曖昧な場合、エージェント側が補完してくれることが多いですが、
たいがいはイメージとずれた結果で出てくることになります。

2点目
エージェントが実装作業を一気に進めるので生産性は大きく向上します。
使うか使わないかで差が出るのは間違いないと感じました。

と同時に、技術知識や設計力の重要性を意識させられました。
知らないことは聞くことも、指示することも、確認することもできません。

3点目
生成AIで出力することを前提にする場合、
どこまでをAIに任せ、どこからを人がレビューし、
承認するかの線引きとバランスが悩ましくなると感じました。

今回は一通り、コマンドでもGUIでも目視で確認しましたが、
ネットワーク、IAMなど外せない重要なポイントは人が確認する必要があると考えています。

今後の発展として、開発・テスト・ステージング・本番といった
各フェーズにおけるWell-Architected な構成を整理したり、
モデルごとの生成物の違いについても検証し把握したいと考えています。

最後までお読みいただきありがとうございました。
本記事が生成AIを用いたIaC活用の一つの参考になれば幸いです。

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?