Terraform managed AMIs with Packer

Here we go!

Been working with a friend on learning Terraform to manage his new, and growing, AWS environment. One of the challenges I gave him was to use Terraform to manage the AMI updates that Packer creates or to initiate an update if the source AMI is newer than the current state.

Terraform doesn’t have a packer provider so this requires using other resources built into Terraform to accomplish a working and trackable state.

Problem statement

Maintain current AMIs based on source AMI and userdata updates and rebuild the AMI as needed when the source, or gold image, AMI is updated, or you update your userdata, using Packer to accomplish customization.

  1. figure out our source AMI via data lookup(s)
  2. if source ami-id has changed then initiate new AMI build
  3. if userdata has changed then initiate new AMI build
  4. if source ami-id and userdata have not changed do nothing (idempotent!)

Terraform built in resources

I accomplished this by abusing the null_resource provider and local-exec provisioner.

First, let’s go find the AMI we need as the source:

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }

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

  # Canonical
  owners = [
    "099720109477"
  ]
}

This returns an ami-id of ami-0c007ac192ba0744b (as of 20210114 in AWS region us-east-2). These AMIs are updated by Canonical periodically and there will be a new ami-id.

Now that we have an ami-id we can add that as a trigger to execute changes to null_resource. This has a second trigger to check on the userdata file that will be used to do customization:

resource "null_resource" "build_custom_ami" {
  triggers = {
    aws_ami_id      = data.aws_ami.ubuntu.id
    sha256_userdata = filesha256("deploy/packer-customize.sh")
  }

  provisioner "local-exec" {
    environment = {
      VAR_AWS_REGION = var.aws_region
      VAR_AWS_AMI_ID = data.aws_ami.ubuntu.id
    }

    command = <<EOF
    set -ex;
    packer validate \
      -var "aws_region=$VAR_AWS_REGION" \
      packer-configs/custom_ami.json
    packer build \
      -var "aws_region=$VAR_AWS_REGION" \
      packer-configs/custom_ami.json
EOF
  }
}

So basically I have the following directory structure that is relevant. You will probably also have backend resource, perhaps some requirements, etc.

data.tf
ami.tf
-> packer-configs/
---> custom_ami.json
-> deploy/
---> packer-customize.sh

Implementation via Jenkins or other CI/CD systems is left to you to figure out.

What are the variables used for in local-exec?

I have items running in multiple regions and each region has their own AMIs (and resulting ami-ids). The above has been pared down a bit for brevity.

You can use the aws provider to connect to multiple regions concurrently:

### per region provider info using provider listings
provider "aws" {
  alias  = "region-us-east-1"
  region = "us-east-1"
}

provider "aws" {
  alias  = "region-us-east-2"
  region = "us-east-2"
}

provider "aws" {
  alias  = "region-us-west-1"
  region = "us-west-1"
}

provider "aws" {
  alias  = "region-us-west-2"
  region = "us-west-2"
}

Then you can build AMIs in each region. This example code is not complete but the concept is very straight forward:

data "aws_ami" "ubuntu-use2" {
  provider    = aws.region-us-east-2
  most_recent = true
  ...
}

data "aws_ami" "ubuntu-usw2" {
  provider    = aws.region-us-west-2
  most_recent = true
  ...
}
resource "null_resource" "build_use2_ami" {
  provider = aws.region-us-east-2
  triggers = {
    aws_ami_id      = data.aws_ami.ubuntu-use2.id
    sha256_userdata = filesha256("deploy/userdata.sh")
  }

  provisioner "local-exec" {
    environment = {
      VAR_AWS_REGION = "us-east-2"
      VAR_AWS_AMI_ID = data.aws_ami.ubuntu-use2.id
    }
  ...
}

resource "null_resource" "build_usw2_ami" {
  provider = aws.region-us-west-2
  triggers = {
    aws_ami_id      = data.aws_ami.ubuntu-usw2.id
    sha256_userdata = filesha256("deploy/userdata.sh")
  }

  provisioner "local-exec" {
    environment = {
      VAR_AWS_REGION = "us-west-2"
      VAR_AWS_AMI_ID = data.aws_ami.ubuntu-usw2.id
    }
  ...
}

Of course you can do other things to make it even more dynamic using data calls for aws_caller_identity within the region you are working against and applying it programmatically but I’ll leave that to you for now.

Comments are closed.