How to Build an AWS EC2 Machine Images (AMI) With Packer

An Amazon Machine Image (AMI) is a supported and maintained image provided by AWS that provides the information required to launch an instance. You must specify an AMI when you launch an instance. You can launch multiple instances from a single AMI when you require multiple instances with the same configuration. You can use different AMIs to launch instances when you require instances with different configurations.

An AMI provides the information required to launch an instance, which may include Base Operating system, application dependencies, and other runtime libraries required.

An AMI includes the following:

  • One or more Amazon Elastic Block Store (Amazon EBS) snapshots, or, for instance-store-backed AMIs, a template for the root volume of the instance (for example, an operating system, an application server, and applications).
  • Launch permissions that control which AWS accounts can use the AMI to launch instances.
  • A block device mapping that specifies the volumes to attach to the instance when it’s launched.

Related content:

# Packer

Packer is a free and open source tool for creating golden images for multiple platforms from a single source configuration. It is lightweight, runs on every major operating system, and is highly performant, creating machine images for multiple platforms in parallel. It is made by Hashicorp to create identical machine images for multiple platforms from a single JSON config file. It gives you the flexibility of building your custom AMI for use in AWS EC2 platform.

# Packer provisioners

Provisioners are components of Packer that install and configure software within a running machine prior to that machine being turned into a static image. They perform the major work of making the image contain useful software. Example provisioners include shell scripts, ansible, Chef, Puppet, etc.

# Step 1 – Ensure packer is installed

Since we are using packer, we have to make sure that it is installed before proceeding.

If you are an ubuntu user, use these commands to install packer:

curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add -
sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main"
sudo apt-get update && sudo apt-get install packer

If you are using rhel based OS like Rocky Linux or Alma linux:

sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo yum -y install packer

If you are using mac or homebrew package manager, use this command to install:

brew tap hashicorp/tap
brew install hashicorp/tap/packer

For other operating systems, checkout packer downloads page here.

# Step 2 – Create packer project

Create a project directory and switch to it:

mkdir packer
cd packer

Under the project, create a folder called scripts that we will use for our provisioner.

mkdir scripts

This is my current directory structure

➜ tree packer
packer
└── scripts

1 directory, 0 files

# Step 3 – Creating Packer templates

Packer reads its configuration either from a json or hcl file. In this guide, we are going to create our configuration template in json. We are going to define variables, builders and provisioners.

Let us create an example nginx web server. Open a webserver.json template file using your favourite text editor, I am using vim in my case:

vim webserver.json

Add this content to the file:

{
    "variables": {
        "name_prefix": "webserver",
        "aws_region": "{{env `AWS_REGION`}}",
        "subnet_id": "subnet-xxxxx",
        "vpc_id": "vpc-xxxxx"
    },
    "builders": [{
        "type": "amazon-ebs",
        "region": "{{user `aws_region`}}",
        "instance_type": "t2.micro",
        "ssh_username": "rocky",
        "subnet_id": "{{user `subnet_id`}}",
        "vpc_id": "{{user `vpc_id`}}",
        "ami_name": "{{user `name_prefix`}}-v{{isotime \"200601021504\"}}",
        "ami_description": "Citizix Web Server Image",
        "associate_public_ip_address": "true",
        "ami_block_device_mappings" : [
            {
                "device_name" : "/dev/sda1",
                "volume_size" : "8",
                "delete_on_termination" : true
            }
        ],
        "source_ami_filter": {
            "filters": {
                "name": "Rocky-8-*.x86_64-*"
            },
            "owners": ["aws-marketplace"],
            "most_recent": true
        }
    }],
    "provisioners": [
        {
            "type": "shell",
            "scripts": [
                "scripts/webserver.sh"
            ]
        }
    ]
}

Under variables key section, set required variables. In my case I am setting the image name, aws region which is obtained from the env variable AWS_REGION, subnet id and vpc id.

Under builders key section, set the aws properties for the source image and the name of the image to build. The source_ami_filter will filter the latest rocky instance to use for the build. Consult the AMI Builder documentation for more details.

On provisioners section, provide the paths to your scripts to be executed during build. In my case, I am defining a script in scripts/webserver.sh.

# Step 4 – Create provisioners scripts

Finally, let us define the script that will be executed when the ami is being build. In our case, since we want to set up nginx to serve basic content, we will install nginx and create a hello world file to be served.

Open the script file with your text editor:

sudo vim <span style="font-size: calc(11px + 0.2em);">scripts/webserver.sh</span>

Add this content to the file:

#!/bin/bash -xe

sudo dnf -y update

sudo setenforce 0
sudo sed -i s/^SELINUX=.*$/SELINUX=permissive/ /etc/selinux/config

sudo dnf install -y epel-release
sudo dnf install -y vim wget curl telnet htop

sudo dnf install -y nginx

sudo bash -c "cat > /usr/share/nginx/html/hello.html <<EOC
Hello world from Citizix.
EOC"

sudo systemctl start --now nginx

# Step 5 – Run the packer build

First ensure that you are logged in to aws. You I have a profile called citizix where I have added my credentials. The commands below will set the AWS region and citizix profile to be active.

export AWS_REGION=eu-west-1
export AWS_PROFILE=citizix

Next let is build our ami. We can save the build log to build-artifact.log so we can refer to it in future.

packer build -machine-readable webserver.json | tee build-artifact.log

Once done with provisioning, packer will Stop and destroy temporary instance used, then create an AMI. AMI ID is printed at the end.

# Step 5: Testing AMI Created

In this section, I’ll use Terraform to provision a new instance with created AMI. The same can be done from AWS console. We are going to create an AWS instance using the image we build. We will use terraform to achieve this.

Before proceeding, ensure that you have terraform installed. confirm with this command:

➜ terraform --version
Terraform v1.2.0
on darwin_arm64

Create terraform projects directory.

mkdir terraform

We are querying the latest ami matching the webserver ami we created then using it. Add these content to main.tf.

provider "aws" {
  region = "eu-west-1"
}

data "aws_ami" "web" {
  most_recent = true
  owners      = ["self"]

  filter {
    name   = "name"
    values = ["webserver-*"]
  }

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

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

module "ec2-instance" {
  source                      = "terraform-aws-modules/ec2-instance/aws"
  version                     = "~> 4.0"
  name                        = "test-webserver-instance"
  ami                         = data.aws_ami.web.id
  associate_public_ip_address = true
  disable_api_termination     = false
  instance_type               = "t3.small"
  key_name                    = "id_citizix"
  monitoring                  = true
  subnet_id                   = "subnet-xxxxxx"

  vpc_security_group_ids = [
    aws_security_group.ec2-instance-sg.id
  ]

  root_block_device = [
    {
      volume_size           = 30
      volume_type           = "gp2"
      delete_on_termination = true
    },
  ]
}

resource "aws_security_group" "ec2-instance-sg" {
  name        = "test-webserver-instance-sg"
  description = "Test webserver instance SG "
  vpc_id      = "vpc-xxxxxxx"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = -1
    to_port     = -1
    protocol    = "icmp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

output "public-ip" {
  value = module.ec2-instance.public_ip
}

The next section is to create the resources usig terraform. Initialize terraform using this command:

terraform init

Show an execution plan.

terraform plan

Finally apply the changes. You will be shown the execution plan then prompted to confirm the changes by typing yes.

terraform apply

The new instance will be created and its public IP will be shown as part of the outputs. You can also see it in AWS console.

To confirm that out provisioner is working, visit http://server_ip/hello.html.

Once you are done with the test, you should delete the resources to avoid incurring costs. To destroy your test infrastructure, run this command:

terraform destroy

# Conclusion

In this guide we learnt how to use packer to package an AWS ami with the help of a script provisioner.[][1]

[1]: data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%221024%22 height=%22194%22%3E%3C/svg%3E

Last updated on Mar 20, 2024 17:19 +0300
comments powered by Disqus
Citizix Ltd
Built with Hugo
Theme Stack designed by Jimmy