以下を実施する手順をまとめます。(なお本記事のローカルの作業環境は Mac です)
- Node.js アプリを作成する。Node.js アプリの Docker イメージを作成して ECR に登録する。
- Packer を使って AMI を作成する。インストール処理は Ansible で実施する。AMI には Docker をインストールする。
- Terraform を使って AMI から EC2 インスタンスを起動する。インスタンス起動時の Cloud-init で ECR から Docker イメージを取得してコンテナを起動、アプリを実行する。
本記事の手順を実施すると、最終的に以下のようなファイル構成になります。
my-iac/
├── 1_docker-app/
│ ├── Dockerfile
│ ├── main.js
│ └── package.json
├── 2_terraform-vpc/
│ └── main.tf
├── 3_terraform-iam/
│ └── main.tf
├── 4_packer-ami/
│ ├── main.pkr.hcl
│ └── site.yml
├── 5_terraform-ecr/
│ └── main.tf
└── 6_terraform-ec2/
├── cloud-init.yml
└── main.tf
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
# Docker
brew install docker
brew install colima # または Docker Desktop など
インストールできたことのチェックとして、各コマンドを実行して成功することを確認します。
aws --version
session-manager-plugin --version
packer --version
terraform --version
ansible --version
docker --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-good-terraform-remote-state-bucket
という名前でバケットを作成しました。
補足:AWS CLI でやるならこちらのコマンドです
aws s3 mb s3://my-good-terraform-remote-state-bucket --region ap-northeast-1
1. アプリケーションの準備 #
今回デプロイするアプリケーションとして、Node.js の簡易なスクリプトを用意します。実行するとずっと動き続け、30 秒ごとにログファイルにタイムスタンプを書き込むだけのシンプルなスクリプトです。
また、アプリの Docker イメージを作成するための Dockerfile も用意します。
以下のディレクトリとファイルを用意します。
my-iac/
└── 1_docker-app/
├── Dockerfile
├── main.js
└── package.json
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);
Dockerfile
FROM node:22-slim
# Create a group and non-root user to run the application
RUN addgroup my-app-group
RUN useradd --create-home -g my-app-group --shell /bin/bash my-app-user
USER my-app-user
ENV HOME=/home/my-app-user
WORKDIR /home/my-app-user/my-app
COPY --chown=my-app-user:my-app-group . .
CMD ["node", "main.js"]
Docker イメージのビルドやプッシュは後述の ECR リポジトリを作成した後に行います。
2. Terraform で VPC、サブネット、セキュリティグループ を作成する #
VPC、サブネット、セキュリティグループを作成します。
以下のディレクトリとファイルを用意します。
my-iac/
└── 2_terraform-vpc/
└── main.tf
main.tf
################################################################################
# Root configurations
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
region = "ap-northeast-1"
bucket = "my-good-terraform-remote-state-bucket" # ✅ TODO: Set the actual value before running
key = "vpc/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"
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"]
}
}
################################################################################
# Outputs of the results
################################################################################
output "subnet_id" {
value = aws_subnet.this.id
}
output "security_group_id" {
value = aws_security_group.this.id
}
ファイルを用意したら、以下のコマンドを順に実行してリソースを作成します。
cd 2_terraform-vpc/
- 初期化:
terraform init
- 検証:
terraform plan
- 実行:
terraform apply -auto-approve
- 実行結果確認:
terraform output -json > output.json
今回 output.json には以下のような内容が出力されました(必要部分のみ抜粋)。値は人によって異なります。以降に作成する定義で利用します。
{
"security_group_id": {
"value": "sg-08e2e2d599206b8b9"
},
"subnet_id": {
"value": "subnet-0068d492aac6eb405"
}
}
3. Terraform で IAM を作成する #
次に EC2 インスタンスで使用するための IAM ロールを作成します。
以下のディレクトリとファイルを用意します。
my-iac/
└── 3_terraform-iam/
└── main.tf
main.tf
################################################################################
# Root configurations
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
region = "ap-northeast-1"
bucket = "my-good-terraform-remote-state-bucket" # ✅ TODO: Set the actual value before running
key = "iam/terraform.tfstate"
}
}
provider "aws" {
region = "ap-northeast-1"
}
################################################################################
# Definitions - IAM
################################################################################
###################
# IAM Role for EC2
###################
data "aws_iam_policy_document" "ec2" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ec2" {
assume_role_policy = data.aws_iam_policy_document.ec2.json
}
############################
# Instance Profile for EC2
############################
resource "aws_iam_instance_profile" "ec2" {
role = aws_iam_role.ec2.name
}
################################################
# Attach SSM Session Manager Policy to IAM Role
################################################
resource "aws_iam_role_policy_attachment" "ssm_manager" {
role = aws_iam_role.ec2.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
##########################################
# Attach SSM Parameter Policy to IAM Role
##########################################
data "aws_kms_key" "ssm_parameter" {
key_id = "alias/aws/ssm"
}
data "aws_iam_policy_document" "ssm_parameter" {
statement {
sid = "ReadAllParameters"
actions = [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath",
]
resources = ["*"]
}
statement {
sid = "DecryptSecureStringWithDefaultAlias"
actions = ["kms:Decrypt"]
resources = [data.aws_kms_key.ssm_parameter.arn]
}
}
resource "aws_iam_policy" "ssm_parameter" {
policy = data.aws_iam_policy_document.ssm_parameter.json
}
resource "aws_iam_role_policy_attachment" "ssm_parameter" {
role = aws_iam_role.ec2.name
policy_arn = aws_iam_policy.ssm_parameter.arn
}
################################
# Attach ECR Policy to IAM Role
################################
data "aws_iam_policy_document" "ecr" {
statement {
actions = ["ecr:GetAuthorizationToken"]
resources = ["*"]
}
statement {
actions = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage"
]
resources = ["*"]
}
}
resource "aws_iam_policy" "ecr" {
policy = data.aws_iam_policy_document.ecr.json
}
resource "aws_iam_role_policy_attachment" "ecr" {
role = aws_iam_role.ec2.name
policy_arn = aws_iam_policy.ecr.arn
}
################################################################################
# Outputs of the results
################################################################################
output "iam_instance_profile" {
value = aws_iam_instance_profile.ec2.name
}
ファイルを用意したら、以下のコマンドを順に実行してリソースを作成します。
cd 3_terraform-iam/
- 初期化:
terraform init
- 検証:
terraform plan
- 実行:
terraform apply -auto-approve
- 実行結果確認:
terraform output -json > output.json
今回 output.json には以下のような内容が出力されました(必要部分のみ抜粋)。値は人によって異なります。以降に作成する定義で利用します。
{
"iam_instance_profile": {
"value": "terraform-20250925162609809900000004"
}
}
4. Packer で AMI を作成する #
Packer を使って AMI を作成します。インストール処理は Ansible で実施します。AMI には Ansible と Node.js をインストールします。
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 |||
||+-------------------------------------+-------------------------------------------+||
以下のディレクトリとファイルを用意します。
my-iac/
└── 4_packer-ami/
├── main.pkr.hcl
└── site.yml
main.pkr.hcl
locals
の部分は各自の値で置き換えてください。
################################################################################
# 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-0068d492aac6eb405" # ✅ TODO: Set the actual value before running
security_group_id = "sg-08e2e2d599206b8b9" # ✅ TODO: Set the actual value before running
iam_instance_profile = "terraform-20250925162609809900000004" # ✅ 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 Docker
block:
- ansible.builtin.dnf:
name: docker
state: present
- ansible.builtin.command: docker --version
register: docker_result
- ansible.builtin.debug:
msg: "Docker version: {{ docker_result.stdout }}"
- name: Enable Docker daemon
ansible.builtin.systemd:
name: docker
enabled: true
ファイルを用意したら、以下のコマンドを順に実行してリソースを作成します。
cd 4_packer-ami/
- 初期化:
packer init .
- 検証:
packer validate .
- 実行:
packer build .
今回 manifest.json には以下のような内容が出力されました(必要部分のみ抜粋)。作成された AMI の ID が分かります。値は人によって異なります。以降に作成する定義で利用します。
{
"builds": [
{
"artifact_id": "ap-northeast-1:ami-0da0fc2c9b34b599c"
}
]
}
5. Terraform で ECR リポジトリを作成する #
Terraform を使って、ECR リポジトリを作成します。作成したリポジトリにアプリの Docker イメージを登録します。
以下のディレクトリとファイルを用意します。
my-iac/
└── 5_terraform-ecr/
└── site.yml
main.tf
################################################################################
# Root configurations
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
region = "ap-northeast-1"
bucket = "my-good-terraform-remote-state-bucket" # ✅ TODO: Set the actual value before running
key = "ecr/terraform.tfstate"
}
}
provider "aws" {
region = "ap-northeast-1"
}
################################################################################
# Definitions - ECR
################################################################################
resource "aws_ecr_repository" "this" {
name = "my-app-repository"
force_delete = true
}
################################################################################
# Outputs of the results
################################################################################
output "ecr_repository_name" {
value = aws_ecr_repository.this.name
}
output "ecr_repository_url" {
value = aws_ecr_repository.this.repository_url
}
ファイルを用意したら、以下のコマンドを順に実行してリソースを作成します。
cd 5_terraform-ecr/
- 初期化:
terraform init
- 検証:
terraform plan
- 実行:
terraform apply -auto-approve
- 実行結果確認:
terraform output -json > output.json
今回 output.json には以下のような内容が出力されました(必要部分のみ抜粋)。値は人によって異なります。以降に作成する定義で利用します。
{
"ecr_repository_name": {
"value": "my-app-repository"
},
"ecr_repository_url": {
"value": "012345678901.dkr.ecr.ap-northeast-1.amazonaws.com/my-app-repository"
}
}
ECR リポジトリが作成できたので、アプリの Docker イメージをビルドしてプッシュします。
cd 1_docker-app/
- ECR にログイン:
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin 012345678901.dkr.ecr.ap-northeast-1.amazonaws.com
- Docker イメージのビルド:
docker build --platform=linux/amd64 -t 012345678901.dkr.ecr.ap-northeast-1.amazonaws.com/my-app-repository:amd64 .
- Docker イメージのプッシュ:
docker push 012345678901.dkr.ecr.ap-northeast-1.amazonaws.com/my-app-repository:amd64
イメージが登録できたことを確認します。
aws ecr list-images --region ap-northeast-1 --repository-name my-app-repository
表示された結果に "imageTag": "amd64"
と書かれているものがあればプッシュできています。
6. Terraform で EC2 インスタンスを作成する #
Terraform を使って、作成した AMI から EC2 インスタンスを起動します。インスタンス起動時の Cloud-init で GitHub から自分のコードを clone します。その後 Ansible を実行してインスタンスにアプリケーションを配置します。Systemd のサービスとしてアプリを登録し、起動します。
以下のディレクトリとファイルを用意します。
my-iac/
└── 6_terraform-ec2/
├── cloud-init.yml
└── main.tf
main.tf
locals
の部分は各自の値で置き換えてください。
################################################################################
# Root configurations
################################################################################
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
region = "ap-northeast-1"
bucket = "my-good-terraform-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-0da0fc2c9b34b599c" # ✅ TODO: Set the actual value before running
subnet_id = "subnet-0068d492aac6eb405" # ✅ TODO: Set the actual value before running
security_group_id = "sg-08e2e2d599206b8b9" # ✅ TODO: Set the actual value before running
iam_instance_profile = "terraform-20250925162609809900000004" # ✅ 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
##########################################################################
ECR_REGION="ap-northeast-1"
ECR_REGISTRY="012345678901.dkr.ecr.ap-northeast-1.amazonaws.com" # ✅ TODO: Set the actual value before running
ECR_REPOSITORY_NAME="my-app-repository" # ✅ TODO: Set the actual value before running
DOCKER_IMAGE_TAG="amd64" # ✅ TODO: Set the actual value before running
DOCKER_IMAGE="${ECR_REGISTRY}/${ECR_REPOSITORY_NAME}:${DOCKER_IMAGE_TAG}"
##########################################################################
# Enable and start Docker daemon if not active
if ! systemctl is-active --quiet docker; then
systemctl enable docker || true
systemctl start docker
fi
# Wait until Docker daemon is ready (timeout: 30 seconds)
for i in $(seq 1 30); do
if docker info >/dev/null 2>&1; then
break
fi
sleep 1
done
# Pull Docker image from ECR
aws ecr get-login-password --region "${ECR_REGION}" \
| docker login --username AWS --password-stdin "${ECR_REGISTRY}"
docker pull "${DOCKER_IMAGE}"
# Remove all containers if exist
docker ps -aq | xargs -r docker rm -f || true
# Start new container with auto-restart
CONTAINER_NAME="my-app-container"
docker run -d --name "${CONTAINER_NAME}" \
--restart unless-stopped \
-v my-app-data:/home/my-app-user/my-app \
"${DOCKER_IMAGE}"
runcmd:
- [bash, -lc, "/root/my-bootstrap.sh >> /var/log/my-bootstrap.log 2>&1"]
ファイルを用意したら、以下のコマンドを順に実行してリソースを作成します。
cd 6_terraform-ec2/
- 初期化:
terraform init
- 検証:
terraform plan
- 実行:
terraform apply -auto-approve
- 実行結果確認:
terraform output
最終的に以下のような内容が出力されます。作成された EC2 インスタンスの ID です。
aws_instance = "i-0d49f543e1ea9a258"
これで作業は完了です。では結果を確認しましょう。
AWS コンソールの EC2 ページから、作成されたインスタンスを確認します。
- 先ほど表示された ID のインスタンスが起動していることを確認します。
- インスタンスのページ(EC2 > Instances > i-07ab88e964b66493c)を開き、「Connect」ボタンを押します。
- 「Session Manager」タブを選択し、「Connect」ボタンを押して接続します。
- インスタンスにログインできたら、root ユーザに切り替えます:
sudo su - root
- コンテナの起動状態を確認します:
docker ps
- 少し時間を置いてからログファイルを確認します:
cat /var/lib/docker/volumes/my-app-data/_data/my-app.log
定期的にログが書き込まれていれば成功です。
99. 後片付け #
今回作成したリソースをすべて削除する手順を示します。
EC2 インスタンスの削除 #
まずは EC2 インスタンスを削除します。
cd 6_terraform-ec2/
terraform destroy
削除してよいか確認されますので yes
と答えます。
ECR リポジトリの削除 #
ECR リポジトリを削除します。
cd 5_terraform-ecr/
terraform destroy
削除してよいか確認されますので yes
と答えます。
AMI の削除 #
AMI を削除します。Packer には AMI を削除するコマンドがないため手動で削除します。AWS コンソールの EC2 > AMIs から Deregister しましょう。このとき、AMI に紐づくスナップショットも削除するかどうか聞かれますので、あわせて削除しておきましょう。
IAM の削除 #
IAM ロールを削除します。
cd 3_terraform-iam/
terraform destroy
削除してよいか確認されますので yes
と答えます。
VPC、サブネット、セキュリティグループの削除 #
VPC、サブネット、セキュリティグループを削除します。
cd 2_terraform-vpc/
terraform destroy
削除してよいか確認されますので yes
と答えます。
S3 バケット、AWS CLI 用の IAM の削除 #
最後に、S3 バケット、AWS CLI 用の IAM ユーザを削除します。
AWS コンソール画面から手動で削除しましょう。