Packer、Terraform、Ansible で AWS EC2 をプロビジョニングする

Packer、Terraform、Ansible で AWS EC2 をプロビジョニングする

以下を実施する手順をまとめます。

  1. Packer をつかって Ansible と Nodejs がインストールされた AMI を作成する
  2. Terraform をつかって 上記 AMI から EC2 インスタンスを起動する定義を作成する
  3. 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 Console - IAM 1

作成したユーザのアクセスキーを発行します。(以下のアクセスキーはすでに削除済みです)

AWS Console - IAM 2

発行した内容を使って 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 Console - S3

補足: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 Console - S3

補足: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
}

ファイルを用意したら、以下のコマンドを順に実行してリソースを作成します。

  1. 初期化:terraform init
  2. 検証:terraform plan
  3. 実行:terraform apply -auto-approve
  4. 実行結果確認: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 }}"

ファイルを用意したら、以下のコマンドを順に実行してリソースを作成します。

  1. 初期化:packer init .
  2. 検証:packer validate .
  3. 実行: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

ファイルを用意したら、以下のコマンドを順に実行してリソースを作成します。

  1. 初期化:terraform init
  2. 検証:terraform plan
  3. 実行:terraform apply -auto-approve
  4. 実行結果確認:terraform output

最終的に以下のような内容が出力されます。作成された EC2 インスタンスの ID です。

aws_instance = "i-08712b45d86e03d6b"

99. 結果確認・後片付け #

99-1. 結果確認 #

AWS コンソールの EC2 ページから、作成されたインスタンスを確認します。

  1. 先ほど表示された ID のインスタンスが起動していることを確認します。
  2. インスタンスのページ(EC2 > Instances > i-08712b45d86e03d6b)を開き、「Connect」ボタンを押します。
  3. 「Session Manager」タブを選択し、「Connect」ボタンを押して接続します。
  4. インスタンスにログインできたら、root ユーザに切り替えます:sudo su - root
  5. サービスの起動状態を確認します:systemctl status my-app
  6. 少し時間を置いてからログファイルを確認します: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 の画面から手動で削除しましょう。