以下を実施する手順をまとめます。
- Packer をつかって Ansible と Nodejs がインストールされた AMI を作成する
- Terraform をつかって 上記 AMI から EC2 インスタンスを起動する定義を作成する
- Cloud-init(User Data) から Ansible を呼び出して EC2 インスタンスにアプリケーションをデプロイする
ローカルの作業環境は Mac を想定しています。
0-1. 事前準備 - ツールのインストール #
必要なツールをインストールしておきましょう。ここではすべて Homebrew でインストールします。
# AWS CLI
brew install awscli
# AWS SSM Session Manager Plugin
brew install --cask session-manager-plugin
# Packer
brew tap hashicorp/tap
brew install hashicorp/tap/packer
# Terraform
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Ansible
brew install ansible
インストールできたことのチェックとして、各コマンドを実行して成功することを確認します。
aws --version
session-manager-plugin --version
packer --version
terraform --version
ansible --version
0-2. 事前準備 - AWS の環境準備 #
0-2-1. AWS CLI 用 IAM ユーザの作成とアクセスキーの取得 #
IAM > Users からユーザを作成します。ここでは my-aws-cli-user
という名前でユーザを作成しました。AdministratorAccess
のポリシーをアタッチしています。
作成したユーザのアクセスキーを発行します。(以下のアクセスキーはすでに削除済みです)
発行した内容を使って aws configure
を実行し、AWS CLI の設定を行います。
AWS Access Key ID [****************FFC4]: AKIAUB35HC3KPZGRHBXA
AWS Secret Access Key [****************iEe8]: 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
Default region name [None]:
Default output format [None]:
0-2-2. Terraform 用 S3 バケットの作成 #
Amazon S3 ページにて、Terraform のリモートステート用のバケットを作成します。
ここでは my-super-great-awesome-remote-state-bucket
という名前でバケットを作成しました。
補足:AWS CLI でやるならこちらのコマンドです
aws s3 mb s3://my-sample-bucket --region ap-northeast-1
0-2-3. SSM Parameter Store に GitHub の情報を登録 #
GitHub で Personal Access Token を発行します。repo
権限を付与しておきます。
AWS Systems Manager > Parameter Store から、発行したアクセストークンと、さらに GitHub のユーザ名を登録します。
補足:AWS CLI でやるならこちらのコマンドです
aws ssm put-parameter \
--region "ap-northeast-1" \
--name "/my-sample-parameter/github/user-name" \
--value "MY_GITHUB_USER_NAME" \
--type "String"
aws ssm put-parameter \
--region "ap-northeast-1" \
--name "/my-sample-parameter/github/access-token" \
--value "MY_GITHUB_PERSONAL_ACCESS_TOKEN" \
--type "SecureString"
0-3. 事前準備 - アプリケーションの準備 #
今回デプロイするアプリケーション例として、Node.js の簡易なスクリプトを用意します。
実行するとずっと動き続け、30 秒ごとにログファイルにタイムスタンプを書き込むだけのシンプルなスクリプトです。
package.json
{
"type": "module"
}
main.js
import fs from "fs";
const logFilePath = "./my-app.log";
function addLog() {
const now = new Date().toISOString();
fs.appendFileSync(logFilePath, `${now}\n`, "utf8");
console.log(`Log added: ${now}`);
}
addLog();
setInterval(addLog, 30_000);
1. Terraform で VPC、サブネット、セキュリティグループ、IAM を作成する #
最初に、以降で Packer や Terraform が利用する VPC、サブネット、セキュリティグループ、IAM ロールを作成します。
main.tf
################################################################################
# Root configurations
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
region = "ap-northeast-1"
bucket = "my-super-great-awesome-remote-state-bucket" # ✅ TODO: Set the actual value before running
key = "vpc-iam/terraform.tfstate"
}
}
provider "aws" {
region = "ap-northeast-1"
}
################################################################################
# Definitions - VPC, Subnet, Security Group
################################################################################
resource "aws_vpc" "this" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
}
resource "aws_subnet" "this" {
vpc_id = aws_vpc.this.id
cidr_block = "10.0.1.0/24"
availability_zone = "ap-northeast-1a"
map_public_ip_on_launch = true
}
resource "aws_route_table" "this" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
}
resource "aws_route_table_association" "this" {
subnet_id = aws_subnet.this.id
route_table_id = aws_route_table.this.id
}
resource "aws_security_group" "this" {
vpc_id = aws_vpc.this.id
################################
# Prohibit all inbound traffic
################################
# ingress {
# }
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
################################################################################
# Definitions - IAM
################################################################################
data "aws_iam_policy_document" "this" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "this" {
assume_role_policy = data.aws_iam_policy_document.this.json
}
resource "aws_iam_role_policy_attachment" "this" {
role = aws_iam_role.this.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "this" {
role = aws_iam_role.this.name
}
################################################################################
# Outputs of the results
################################################################################
output "subnet_id" {
value = aws_subnet.this.id
}
output "security_group_id" {
value = aws_security_group.this.id
}
output "iam_instance_profile" {
value = aws_iam_instance_profile.this.name
}
ファイルを用意したら、以下のコマンドを順に実行してリソースを作成します。
- 初期化:
terraform init
- 検証:
terraform plan
- 実行:
terraform apply -auto-approve
- 実行結果確認:
terraform output -json > output.json
今回 output.json には以下のような内容が出力されました(必要部分のみ抜粋)。以降の Packer の定義で利用します。
{
"iam_instance_profile": {
"value": "terraform-20250924021127296400000003"
},
"security_group_id": {
"value": "sg-0771f216351be7bc2"
},
"subnet_id": {
"value": "subnet-0285eefb82ce847ed"
}
}
2. Packer で AMI を作成する #
Packer 定義を記述する前に、ベースとなる AMI の ID を調べておきます。ここでは Amazon Linux の最新の AMI を使うことにします。
ローカルで AWS CLI コマンドを以下のように実行して調べることができます。(または AWS コンソールから AMI の一覧を確認できます。EC2 > Images > AMI Catalog)
aws ec2 describe-images \
--region ap-northeast-1 \
--owners amazon \
--filters "Name=name,Values=al*-ami-*" "Name=architecture,Values=x86_64" \
--query 'sort_by(Images, &CreationDate)[-1]' \
--output table
この記事を書いている時点では以下のような結果が得られました。AMI ID は ami-0925d63721690c5b8
だとわかります。
---------------------------------------------------------------------------------------
| DescribeImages |
+---------------------+---------------------------------------------------------------+
| Architecture | x86_64 |
| BootMode | uefi-preferred |
| CreationDate | 2025-09-16T21:01:07.000Z |
| DeprecationTime | 2027-09-16T21:01:07.000Z |
| Description | Amazon Linux AMI 2023.0.20250916 x86_64 ECS HVM EBS |
| EnaSupport | True |
| FreeTierEligible | True |
| Hypervisor | xen |
| ImageId | ami-0925d63721690c5b8 |
| ImageLocation | amazon/al2023-ami-ecs-hvm-2023.0.20250916-kernel-6.1-x86_64 |
| ImageOwnerAlias | amazon |
| ImageType | machine |
| ImdsSupport | v2.0 |
| Name | al2023-ami-ecs-hvm-2023.0.20250916-kernel-6.1-x86_64 |
| OwnerId | 591542846629 |
| PlatformDetails | Linux/UNIX |
| Public | True |
| RootDeviceName | /dev/xvda |
| RootDeviceType | ebs |
| SourceImageId | ami-03d7a4be0d871c897 |
| SourceImageRegion | us-west-1 |
| SriovNetSupport | simple |
| State | available |
| UsageOperation | RunInstances |
| VirtualizationType | hvm |
+---------------------+---------------------------------------------------------------+
|| BlockDeviceMappings ||
|+------------------------------------------+----------------------------------------+|
|| DeviceName | /dev/xvda ||
|+------------------------------------------+----------------------------------------+|
||| Ebs |||
||+-------------------------------------+-------------------------------------------+||
||| DeleteOnTermination | True |||
||| Encrypted | False |||
||| Iops | 3000 |||
||| SnapshotId | snap-0857677d6f9f45184 |||
||| Throughput | 125 |||
||| VolumeSize | 30 |||
||| VolumeType | gp3 |||
||+-------------------------------------+-------------------------------------------+||
main.pkr.hcl
################################################################################
# Root Configurations
################################################################################
packer {
required_version = ">= 1.9.0"
required_plugins {
amazon = {
source = "github.com/hashicorp/amazon"
version = ">= 1.3.0"
}
ansible = {
source = "github.com/hashicorp/ansible"
version = ">= 1.1.0"
}
}
}
################################################################################
# Required variables
################################################################################
locals {
subnet_id = "subnet-0285eefb82ce847ed" # ✅ TODO: Set the actual value before running
security_group_id = "sg-0771f216351be7bc2" # ✅ TODO: Set the actual value before running
iam_instance_profile = "terraform-20250924021127296400000003" # ✅ TODO: Set the actual value before running
}
################################################################################
# Definitions - AMI
################################################################################
source "amazon-ebs" "this" {
source_ami = "ami-0925d63721690c5b8" # ✅ TODO: Update if necessary
region = "ap-northeast-1"
instance_type = "t3.micro"
ssh_username = "ec2-user"
ami_name = "my-ami-{{timestamp}}"
force_deregister = true
force_delete_snapshot = true
subnet_id = local.subnet_id
security_group_id = local.security_group_id
associate_public_ip_address = true
ssh_interface = "session_manager"
iam_instance_profile = local.iam_instance_profile
}
build {
sources = ["source.amazon-ebs.this"]
# 1. Install Ansible
provisioner "shell" {
execute_command = "sudo -E sh -c '{{ .Vars }} {{ .Path }}'" # Run as root
inline = [
"dnf -y update",
"dnf -y install ansible-core",
"ansible --version"
]
}
# 2. Run Ansible Playbook
provisioner "ansible-local" {
playbook_file = "./site.yml"
}
# 3. Output results to a file
post-processor "manifest" {
output = "manifest.json"
}
}
site.yml
- hosts: localhost
connection: local
become: true
gather_facts: false
tasks:
- name: Install AWS CLI
block:
- ansible.builtin.dnf:
name: awscli
state: present
- ansible.builtin.command: aws --version
register: aws_result
- ansible.builtin.debug:
msg: "AWS CLI version: {{ aws_result.stdout }}"
- name: Install Git
block:
- ansible.builtin.dnf:
name: git
state: present
- ansible.builtin.command: git --version
register: git_result
- ansible.builtin.debug:
msg: "Git version: {{ git_result.stdout }}"
- name: Install Node.js
block:
- ansible.builtin.dnf:
name: nodejs
state: present
- ansible.builtin.command: node --version
register: node_result
- ansible.builtin.debug:
msg: "Node.js version: {{ node_result.stdout }}"
ファイルを用意したら、以下のコマンドを順に実行してリソースを作成します。
- 初期化:
packer init .
- 検証:
packer validate .
- 実行:
packer build .
今回 manifest.json には以下のような内容が出力されました(必要部分のみ抜粋)。作成された AMI の ID が分かります。以降の Terraform の定義で利用します。
{
"builds": [
{
"artifact_id": "ap-northeast-1:ami-098e641373fce0096"
}
]
}
3. Terraform で EC2 インスタンスを作成する #
main.tf
################################################################################
# Root configurations
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
region = "ap-northeast-1"
bucket = "my-super-great-awesome-remote-state-bucket" # ✅ TODO: Set the actual value before running
key = "ec2/terraform.tfstate"
}
}
provider "aws" {
region = "ap-northeast-1"
}
################################################################################
# Required variables
################################################################################
locals {
ami_id = "ami-098e641373fce0096" # ✅ TODO: Set the actual value before running
subnet_id = "subnet-0285eefb82ce847ed" # ✅ TODO: Set the actual value before running
security_group_id = "sg-0771f216351be7bc2" # ✅ TODO: Set the actual value before running
iam_instance_profile = "terraform-20250924021127296400000003" # ✅ TODO: Set the actual value before running
}
################################################################################
# Definitions - EC2
################################################################################
resource "aws_instance" "this" {
ami = local.ami_id
instance_type = "t3.micro"
subnet_id = local.subnet_id
vpc_security_group_ids = [local.security_group_id]
associate_public_ip_address = true
iam_instance_profile = local.iam_instance_profile
user_data = file("${path.module}/cloud-init.yml")
user_data_replace_on_change = true
}
################################################################################
# Outputs of the results
################################################################################
output "aws_instance" {
value = aws_instance.this.id
}
cloud-init.yml
#cloud-config
package_update: false
package_upgrade: false
packages: []
write_files:
- path: /root/my-bootstrap.sh
permissions: "0755"
owner: root:root
content: |
#!/usr/bin/env bash
set -euo pipefail
##########################################################################
SSM_REGION="ap-northeast-1"
SSM_PARAMETER_STORE_KEY_GITHUB_USER_NAME="/my-parameter/github/user-name" # ✅ TODO: Set the actual value before running
SSM_PARAMETER_STORE_KEY_GITHUB_ACCESS_TOKEN="/my-parameter/github/access-token" # ✅ TODO: Set the actual value before running
GITHUB_ORGANIZATION_NAME="my-org" # ✅ TODO: Set the actual value before running
GITHUB_REPOSITORY_NAME="my-repo" # ✅ TODO: Set the actual value before running
APP_EXEC_FILE_PATH="app/main.js" # ✅ TODO: Set the actual value before running
PLAYBOOK_FILE_PATH="terraform/site.yml" # ✅ TODO: Set the actual value before running
##########################################################################
# Create directory
CHECKOUT_DIRECTORY="/opt/my-app"
rm -rf "${CHECKOUT_DIRECTORY}"
mkdir -p "${CHECKOUT_DIRECTORY}"
# Fetch GitHub credentials from SSM Parameter Store
# Use --with-decryption option for SecureString
GITHUB_USER_NAME="$(aws ssm get-parameter --region "${SSM_REGION}" --name "${SSM_PARAMETER_STORE_KEY_GITHUB_USER_NAME}" --query 'Parameter.Value' --output text)"
GITHUB_ACCESS_TOKEN="$(aws ssm get-parameter --region "${SSM_REGION}" --with-decryption --name "${SSM_PARAMETER_STORE_KEY_GITHUB_ACCESS_TOKEN}" --query 'Parameter.Value' --output text)"
# Clone repository
cd "${CHECKOUT_DIRECTORY}"
GITHUB_REPOSITORY_URL_WITH_CREDENTIAL="https://${GITHUB_USER_NAME}:${GITHUB_ACCESS_TOKEN}@github.com/${GITHUB_ORGANIZATION_NAME}/${GITHUB_REPOSITORY_NAME}.git"
git clone --depth=1 "${GITHUB_REPOSITORY_URL_WITH_CREDENTIAL}" .
# Run Ansible
cd "${CHECKOUT_DIRECTORY}"
EXEC_FILE_PATH="${CHECKOUT_DIRECTORY}/${APP_EXEC_FILE_PATH}"
ansible-playbook \
-e "my_exec_file_path=${EXEC_FILE_PATH}" \
"${PLAYBOOK_FILE_PATH}"
runcmd:
- [bash, -lc, "/root/my-bootstrap.sh >> /var/log/my-bootstrap.log 2>&1"]
site.yml
---
- hosts: localhost
connection: local
become: true
gather_facts: false
vars:
app_group: my-app-group
app_user: my-app-user
app_service_name: my-app
exec_file_path: "{{ my_exec_file_path }}"
tasks:
- name: Create app system group
group:
name: "{{ app_group }}"
system: true
- name: Create app system user
user:
name: "{{ app_user }}"
group: "{{ app_group }}"
system: true
shell: /usr/sbin/nologin
create_home: true
- name: Create systemd unit for the app
copy:
dest: "/etc/systemd/system/{{ app_service_name }}.service"
mode: "0644"
content: |
[Unit]
Description=My App
After=network.target
[Service]
ExecStart=/usr/bin/env node {{ exec_file_path }}
WorkingDirectory=/home/{{ app_user }}
User={{ app_user }}
Group={{ app_group }}
Restart=always
RestartSec=2
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
notify: Reload systemd and restart my-app
- name: Enable and start service
systemd:
name: "{{ app_service_name }}"
enabled: true
state: started
handlers:
- name: Reload systemd and restart my-app
systemd:
name: "{{ app_service_name }}"
state: restarted
daemon_reload: true
ファイルを用意したら、以下のコマンドを順に実行してリソースを作成します。
- 初期化:
terraform init
- 検証:
terraform plan
- 実行:
terraform apply -auto-approve
- 実行結果確認:
terraform output
最終的に以下のような内容が出力されます。作成された EC2 インスタンスの ID です。
aws_instance = "i-08712b45d86e03d6b"
99. 結果確認・後片付け #
99-1. 結果確認 #
AWS コンソールの EC2 ページから、作成されたインスタンスを確認します。
- 先ほど表示された ID のインスタンスが起動していることを確認します。
- インスタンスのページ(EC2 > Instances > i-08712b45d86e03d6b)を開き、「Connect」ボタンを押します。
- 「Session Manager」タブを選択し、「Connect」ボタンを押して接続します。
- インスタンスにログインできたら、root ユーザに切り替えます:
sudo su - root
- サービスの起動状態を確認します:
systemctl status my-app
- 少し時間を置いてからログファイルを確認します:
cat /home/my-app-user/my-app.log
定期的にログが書き込まれていれば成功です。
99-2. 後片付け #
今回作成したリソースをすべて削除する手順を示します。
EC2 インスタンスの削除 #
まずはステップ3で作成した EC2 インスタンスを削除です。
terraform destroy
削除してよいか確認されますので yes
と答えます。
AMI の削除 #
次にステップ2で作成した AMI を削除します。Packer には AMI を削除するコマンドがないため手動で削除します。AWS コンソールの EC2 > AMIs から Deregister しましょう。このとき、AMI に紐づくスナップショットも削除するかどうか聞かれますので、あわせて削除しておきましょう。
VPC、サブネット、セキュリティグループ、IAM の削除 #
ステップ1で作成した VPC、サブネット、セキュリティグループ、IAM ロールを削除します。
terraform destroy
削除してよいか確認されますので yes
と答えます。
SSM Parameter Store、GitHub のアクセストークン、S3 バケット、AWS CLI 用の IAM の削除 #
最後に、SSM Parameter Store に登録したパラメータ、GitHub のアクセストークン、 S3 バケット、AWS CLI 用の IAM ユーザを削除します。
AWS コンソールや GitHub の画面から手動で削除しましょう。