DEV Community

Cover image for AWS EC2 Image Builder by example with Terraform
Piotr Pabis for AWS Community Builders

Posted on • Originally published at pabis.eu

AWS EC2 Image Builder by example with Terraform

Image Builder is a service that allows you to build AMIs and Docker images step-by-step using a recipe you define. This is an equivalent service to HashiCorp's Packer but completely AWS managed that you can use from your browser (AWS Console). However, as Infrastructure as Code is a best practice, we should rather consider writing every piece of Image Builder recipe using CloudFormation or as I chose for this post - Terraform.

Today, I am going to guide you through building an Amazon Machine Image with Docker and Nginx installed in a scheduled and automated fashion. But first I want you to understand how this whole service functions.

Visit the repository to see the code

Image Builder's process

You choose a base image to build on, for example Amazon Linux 2 or Ubuntu 24.04. You define it in the recipe. Image Builder creates an EC2 instance with this image. What is important is that this image contains AWS Systems Manager Agent. The type of instance and in which subnet it should be created is defined in the Infrastructure Configuration.

EC2 Image Builder uses AWS Systems Manager to connect to the instance. Systems Manager Agent functions similar to SSH but is more featureful. Image Builder simply sends commands (SSM Documents) to the agent to execute. So the first command is to install AWS Task Orchestrator and Executor (AWSTOE). This is another agent but it runs locally, running scripts, modifying and validating the operating system that is running in the EC2 instance.

What you give to Image Builder (that it passes forward to AWSTOE during build) are Components. These are YAML documents that contain steps to perform, for example to install Nginx, configure an example website by copying the resources from an S3 bucket and validate that it runs at startup on port 8080. Recipe is just a set of components to install (and later automatically test) that should be installed on top of the base image.

After all components are executed by AWSTOE, Image Builder requests AWS to create an AMI from the EC2 instance. The instance is shut down and the EBS disk is saved into a snapshot. Afterwards, Image Builder created a new EC2 instance from this AMI and tests it based on the Test sections of the components to see if everything is installed and configured correctly.

In another piece called Distribution Configuration, you can specify to which regions and accounts to copy the AMI and which tags to attach. All these pieces together are used to form a pipeline.

Pipeline = Recipe + Distribution + Infrastructure.
Enter fullscreen mode Exit fullscreen mode

Pipelines can be triggered manually in the console, via API/SDK/CLI or by schedule. There's also an option to integrate it with Systems Manager patch cycles or simply with EventBridge. You can customize the flow of the pipeline using workflows but it's out of scope for this post.

Diagram of Image Builder process

Creating components

The most elemental part of the process are components. These are the scripts that install and test the software on the image we want to create. There are three phases of an Image Builder component: build, validate and test. The build phase is for installing and configuring. validate is run before the base instance is shut down to check if everything is working as expected. test phase is run on the new instance created from the new AMI we just built to check if our expectations are correct, such as does the service start on boot.

Components are written using in YAML. They can accept parameters to customize how the component should be configured. Each phase of the component has multiple steps where you execute either raw Bash/PowerShell scripts or use some predefined actions such as CreateFile or S3Download. I won't cover all of them in this single post but you can look around here to find something interesting. What is more, they can also be executed in a loop or conditionally. (In general you can just write everything in Bash/PowerShell and call it a day 😁 but AWS gives us some actions to make YAML more readable.)

Our first component will be just installation of Docker. As I target Amazon Linux, we will use yum package manager and systemd for service startup. This is also pretty straightforward to test - just check if you can run a hello-world container. If Docker wasn't installed or started, this will just fail.

---
description: "This component installs latest Docker on Amazon Linux 2023"
schemaVersion: "1.0"
phases:
  - name: build
    steps:
      - name: InstallDocker
        action: ExecuteBash
        inputs:
          commands:
            - yum install docker -y
            - systemctl enable docker
            - systemctl start docker
  - name: validate
    steps:
      - name: ValidateDocker
        action: ExecuteBash
        inputs:
          commands:
            - sudo docker run hello-world
  - name: test
    steps:
      - name: TestDocker
        action: ExecuteBash
        inputs:
          commands:
            - sudo docker run hello-world
Enter fullscreen mode Exit fullscreen mode

Next we can create a bit more complex component which is Nginx. It will be parametrized (port on which to run the service) and will be tested more thoroughly by checking if a static website is responding with the new location on a given port. In validation step we will only check if the port is serving anything and if the default files were modified.

---
description: "This component installs and configures latest Nginx on Amazon Linux 2023."
schemaVersion: "1.0"
parameters:
  - port:
      type: string
      description: "Port to forward to"
      default: "8080"
phases:
  - name: build
    steps:
      - name: InstallNginx
        action: ExecuteBash
        inputs:
          commands:
            - yum update -y
            - yum install nginx -y
            - systemctl enable nginx
            - systemctl start nginx
      - name: ConfigureNginxDefaults
        action: CreateFile
        inputs:
          - path: /etc/nginx/conf.d/default.conf
            overwrite: true
            content: |-
              server {
                listen {{ port }};
                server_name _;
                location / {
                  root   /usr/share/nginx/html;
                  index  index.html index.htm;
                }
              }
          - path: /usr/share/nginx/html/index.html
            overwrite: true
            content: |-
              <html> <body>
                <p>This Nginx instance was configured by Image Builder.</p>
              </body> </html>

  - name: validate
    steps:
      - name: ValidateNginx
        action: ExecuteBash
        inputs:
          commands:
            - systemctl restart nginx
            - CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:{{ port }})
            - DATA=$(curl -s http://localhost:{{ port }})
            - if [[ "$CODE" != "200" ]]; then exit 1; fi # Should be 200 because default page is served
            - if [[ "$DATA" != *"configured by Image Builder"* ]]; then exit 1; fi # Should contain the new string

  - name: test
    steps:
      - name: NewTestConfig
        action: CreateFile
        inputs:
          - path: /etc/nginx/conf.d/default.conf
            overwrite: true
            content: |-
              server {
                listen {{ port }};
                server_name _;
                location /api { return 200 "Hello api!"; }
                location / {
                  root   /usr/share/nginx/html;
                  index  index.html index.htm;
                }
              }
      - name: TestNginx
        action: ExecuteBash
        inputs:
          commands:
            - systemctl restart nginx
            - DATA=$(curl -s http://localhost:{{ port }})
            - if [[ "$DATA" != *"configured by Image Builder"* ]]; then exit 1; fi # Should contain the previous string
            - DATA=$(curl -s http://localhost:{{ port }}/api)
            - if [[ "$DATA" != *"Hello api"* ]]; then exit 1; fi # Should contain new location
Enter fullscreen mode Exit fullscreen mode

We went though three steps above. First we installed and started Nginx. We also created some custom configuration. Then we validated if this configuration was picked up by Nginx. In the test phase, we check if modifications to the configuration are working and we can expose a new location. To create all the test files we use a built-in action CreateFile which makes it cleaner to modify files than bash.

Now we should load both components into Terraform and create them. I saved both of these YAML files in the components directory of our project. Assuming you have already imported the aws provider for Terraform/OpenTofu, we can create a new file components.tf in the project root.

resource "aws_imagebuilder_component" "docker_component" {
  name                  = "docker-component"
  platform              = "Linux"
  version               = "1.0.0"
  supported_os_versions = ["Amazon Linux 2023"]
  data                  = file("./components/docker.yaml")
}

resource "aws_imagebuilder_component" "nginx_component" {
  name                  = "nginx-component"
  platform              = "Linux"
  version               = "1.0.0"
  supported_os_versions = ["Amazon Linux 2023"]
  data                  = file("./components/nginx.yaml")
}
Enter fullscreen mode Exit fullscreen mode

Creating the recipe, infrastructure and distribution

Recipe connects the above components and executes them on a base image. I'm going to use Amazon Linux 2023 but with the latest version of this image. For that I can use a data source that is specific to EC2 Image Builder. Connecting components is very easy by just referencing them by ARN and giving required parameters. You can also optionally change the EBS volume size and type here.

data "aws_region" "current" {}

data "aws_imagebuilder_image" "latest" {
  arn = "arn:aws:imagebuilder:${data.aws_region.current.name}:aws:image/amazon-linux-2023-arm64/x.x.x"
}

resource "aws_imagebuilder_image_recipe" "image_recipe" {
  component { 
    component_arn = aws_imagebuilder_component.docker_component.arn
  }
  component { 
    component_arn = aws_imagebuilder_component.nginx_component.arn
    parameter {
      name  = "port"
      value = "9001"
    }
  }
  name         = "my-image-recipe"
  parent_image = data.aws_imagebuilder_image.latest.build_version_arn
  version      = "1.0.0"
  description  = "This is a recipe that takes latest Amazon Linux 2023 and installs latest Docker, Nginx and configures it."
}
Enter fullscreen mode Exit fullscreen mode

Next we can specify infrastructure so where will our image be built. I will use a vpc module to create a new VPC in which the image will be built. We also need a security group that allows access to the internet. To simplify things, I will just give the instance a public IP address. And let's not forget an IAM role for the instance with proper permissions for the Image Builder to function.

module "vpc" {
  source  = "aws-ia/vpc/aws"
  version = ">= 4.2.0"

  name       = "image-builder-vpc"
  cidr_block = "10.123.0.0/16"
  az_count   = 2
  subnets = {
    public  = { netmask = 24 }
    private = { netmask = 24 }
  }
}

module "security_group" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "5.3.0"

  name         = "instance-sg"
  vpc_id       = module.vpc.vpc_attributes.id
  description  = "Security group for Image Builder"
  egress_rules = ["all-all"]
}

module "instance_profile" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-assumable-role"
  version = "5.55.0"

  trusted_role_services   = ["ec2.amazonaws.com"]
  role_name               = "image-builder-role"
  create_role             = true
  create_instance_profile = true
  role_requires_mfa       = false
  custom_role_policy_arns = [
    "arn:aws:iam::aws:policy/EC2InstanceProfileForImageBuilder",
    "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
  ]
}

resource "aws_imagebuilder_infrastructure_configuration" "infrastructure" {
  instance_types                = ["t4g.small", "t4g.medium"]
  security_group_ids            = [module.security_group.security_group_id]
  instance_profile_name         = module.instance_profile.iam_instance_profile_name
  subnet_id                     = values(module.vpc.public_subnet_attributes_by_az)[0].id
  name                          = "my-pipeline-infra"
  terminate_instance_on_failure = true
}
Enter fullscreen mode Exit fullscreen mode

As the last step, we will create a distribution configuration. This is where we pick regions to copy the new AMI to. I will use the current region and Ireland (eu-west-1) as an example. I will also add tags to easily find the AMI later. Here you can also specify accounts to share with, create launch templates or even export the disk snapshot to S3. Look at this documentation.

resource "aws_imagebuilder_distribution_configuration" "distribution" {
  name = "distribution-configuration"

  distribution {
    region = data.aws_region.current.name
    ami_distribution_configuration {
      ami_tags = { "Name" = "my-pipeline-ami" }
    }
  }

  distribution {
    region = "eu-west-1"
    ami_distribution_configuration {
      ami_tags = { "Name" = "my-pipeline-ami" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The central point: pipeline

Eventually we have to create a pipeline that will run the recipe on the infrastructure and distribute the new image. We can also schedule the pipeline to run automatically using a Cron expression.

resource "aws_imagebuilder_image_pipeline" "my_pipeline" {
  schedule {
    schedule_expression = "cron(0 0 ? * 7 *)" # Every Saturday at midnight
  }
  name                             = "my-pipeline"
  image_recipe_arn                 = aws_imagebuilder_image_recipe.image_recipe.arn
  infrastructure_configuration_arn = aws_imagebuilder_infrastructure_configuration.infrastructure.arn
  distribution_configuration_arn   = aws_imagebuilder_distribution_configuration.distribution.arn
}
Enter fullscreen mode Exit fullscreen mode

Run the pipeline

However! You can also trigger a build without a pipeline directly from Terraform. Just use aws_imagebuilder_image resource and set appropriate parameters. The build process will start but Terraform will be locked until it's finished (or it times out), so be careful with that.

# This can take a loong time. Better use a pipeline 😉
resource "aws_imagebuilder_image" "image" {
  distribution_configuration_arn   = aws_imagebuilder_distribution_configuration.distribution.arn
  image_recipe_arn                 = aws_imagebuilder_image_recipe.image_recipe.arn
  infrastructure_configuration_arn = aws_imagebuilder_infrastructure_configuration.infrastructure.arn
}
Enter fullscreen mode Exit fullscreen mode

During the build process you can view the logs in the AWS Console. For example below are the steps of the default workflow and I even caught some logs from installation and validation of Docker. If the build succeeds, you can view the logs from testing in the same way.

Default workflow steps

Docker validated successfully

Using the image

Let's create a new EC2 instance using this image. The tags we have associated with it earlier will help us find it using aws_ami data source. I will also create a new security group with port 8080 open and reconfigure Nginx in the user data. It will pass everything to a Caddy container with some default site. Everything is conditionally created only if you set the create_test_instance variable to true. Do it only after you have built the image.

variable "create_test_instance" {
  type    = bool
  default = false
}

data "aws_ami" "my_ami" {
  count       = var.create_test_instance ? 1 : 0
  most_recent = true
  owners      = ["self"]
  tags        = { "Name" = "my-pipeline-ami" }
}

locals {
  user_data = <<-EOF
    #!/bin/bash
    cat > /etc/nginx/conf.d/default.conf << EOT
        server {
            listen 8080;
            server_name _;
            location / {
                proxy_pass http://localhost:8888;
                proxy_set_header Host \$host;
                proxy_set_header X-Real-IP \$remote_addr;
            }
        }
    EOT
    if ! docker ps -a | grep -q caddy; then
      docker create --name caddy -p 8888:80 --restart=always caddy:latest
    fi
    docker start caddy
    systemctl restart nginx
    EOF
}

module "security_group_test" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "5.3.0"

  name                = "test-instance-sg"
  vpc_id              = module.vpc.vpc_attributes.id
  description         = "Security group for Image Builder"
  egress_rules        = ["all-all"]
  ingress_rules       = ["http-8080-tcp"]
  ingress_cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_instance" "test_instance" {
  count                  = var.create_test_instance ? 1 : 0
  ami                    = data.aws_ami.my_ami[0].id
  instance_type          = "t4g.micro"
  vpc_security_group_ids = [module.security_group_test.security_group_id]
  subnet_id              = values(module.vpc.public_subnet_attributes_by_az)[1].id # Use a different subnet because why not
  tags                   = { "Name" = "test-instance" }
  user_data              = local.user_data
}

output "test_instance_public_ip" {
  value = aws_instance.test_instance[0].public_ip
}
Enter fullscreen mode Exit fullscreen mode

After applying the above example you will get a new EC2 instance and the IP in the output. Try connecting to it via the browser on port 8080.

Default Caddy page

There's more to EC2 Image Builder. You should ideally integrate it with your CI/CD pipeline to create images on Git merge. This service is simpler to use than Packer as it doesn't require you to install anything on your machine or on any build server. You can simply use any AWS CLI or SDK to integrate it with your Git provider or run it on a schedule to have the latest packages. However, Packer comes with other advantages such as being multi-cloud, integrating well with Ansible, etc.

I have already wrote this post once but decided to improve it

Top comments (0)