AWS Compute Blog

Building Amazon Machine Images (AMIs) for EC2 Mac instances with Packer

This post is written by Joerg Woehrle, AWS Solutions Architect

On November 30, 2020 AWS announced the availability of Amazon EC2 Mac instances. EC2 Mac instances are powered by the AWS Nitro System and built on Apple Mac mini computers.

This blog post focuses on the specific best practices of building custom AMIs for EC2 Mac instances using HashiCorp Packer. If you are interested in automating your Packer build, look at these existing blog posts Creating Packer images using AWS System Manager Automation and How to Create an AMI Builder with AWS CodeBuild and HashiCorp Packer.

If you’ve used Amazon EC2, you might be familiar with the concept of pre-baking your requirements on pre-installed software, security, and other individual settings for your environment into an Amazon Machine Image (AMI). With that, you can make sure that the software running on your EC2 instances on launch is configured in line with your policies and contains all the software necessary to operate in your environment. Besides AWS’ EC2 Image Builder, a popular tool to build “Golden Images” is HashiCorp Packer.

Creating a custom AMI helps you make the most of your Amazon EC2 Mac instances. Your familiar tools and environment setup become part of every instance you launch from the AMI. That means there is more time for productive work on the task at hand and less time spent on installation and setup.

Product Overview

EC2 Mac instances run on dedicated Mac mini computers, which means you get a physical machine assigned to you. So, to get started with launching an instance you must allocate an Amazon EC2 Dedicated Host. For EC2 Mac instances, there is a one-to-one mapping between the Dedicated Host and the instance running on this host. This means you are not able to slice a Dedicated Host into multiple instances like you would for Linux and Windows machines.

As part of the macOS Software Licensing Agreement (for example find the one for macOS Big Sur here), there is a 24-hour minimum allocation period for Mac1 hosts. This means that once you successfully get your host-id back, a 24-hour clock ‘starts running’.  So the earliest time you can release an EC2 Mac Dedicated Host is after the 24-hour period has passed (see the EC2 Mac Instances User Guide for details).

Solution Overview

In this blog post, I complete the following steps

In this blog post, I provision the AMI’s source instance from my local machine. Before you begin the tutorial, you must have a working installation of Packer. You can get Packer at HashiCorp’s Downloads Page. This blog has been tested with version 1.7.0. The following image is an overview diagram of the solution implemented in this post.

Overview Diagram

Overview diagram

Walkthrough

Dedicated Host creation

A Dedicated Host is a piece of physical hardware reserved and allocated for you to run your compute workloads. When creating a Dedicated Host, you decide in which of our Availability Zones (AZ) you want to create the host. In this blog post, I select us-east-2b, but you can choose whatever AZ is closest to your location and has support for Amazon EC2 Mac instances.

To determine if a given Region and Availability Zone combination supports the mac1.metal instance type, use the describe-instance-type-offerings command of the AWS CLI. In this case, I use the us-east-2 Region and the us-east-2b AZ.

The output of aws ec2 describe-instance-type-offerings --filters Name=instance-type,Values=mac1.metal Name=location,Values=us-east-2b --location-type availability-zone --region us-east-2 is:

{
    "InstanceTypeOfferings": [
        {
            "InstanceType": "mac1.metal",
            "LocationType": "availability-zone",
            "Location": "us-east-2b"
        }
    ]
}

This response indicates that the mac1.metal instance type is supported by the given Region and AZ combination.

Allocating a Dedicated Host is easy. Run the following CLI command (if you like, you can accomplish the same from the “Dedicated Hosts” section of the web console):

aws ec2 allocate-hosts --auto-placement on --region us-east-2 --availability-zone us-east-2b --instance-type mac1.metal --quantity 1

Now, you should have an idea of where you are going to create your EC2 Mac instance and have a Dedicated Host allocated. The next step is to use the Dedicated Host to create the AMI via Packer. For this, I first create a Packer template.

The Packer template

In this section, I create the Packer template. Then, I explain the details of its relevant sections, and use the template to create the AMI via Packer.

Put the following content into a file called mac.pkr.hcl:

variable "ami_name" {
  type    = string
  default = "mac-os-big-sur-ami"
}

variable "subnet_id" {
  type = string
  default = null
}

variable "region" {
  type    = string
  default = null
}

variable "root_volume_size_gb" {
  type = number
  default = 150
}

locals { timestamp = regex_replace(timestamp(), "[- TZ:]", "") }

source "amazon-ebs" "mac-packer-example" {
  ami_name      = "${var.ami_name}-${local.timestamp}"
  ami_virtualization_type = "hvm"
  ssh_username = "ec2-user"
  ssh_timeout = "2h"
  tenancy = "host"
  ebs_optimized = true
  instance_type = "mac1.metal"
  region        = "${var.region}"
  subnet_id = "${var.subnet_id}"
  ssh_interface = "session_manager"
  aws_polling {
    delay_seconds = 60
    max_attempts = 60
  }
  launch_block_device_mappings {
    device_name = "/dev/sda1"
    volume_size = "${var.root_volume_size_gb}"
    volume_type = "gp3"
    iops = 3000
    throughput = 125
    delete_on_termination = true
  }
  source_ami_filter {
    filters = {
      name                = "amzn-ec2-macos-11.2.*"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    most_recent = true
    owners      = ["amazon"]
  }
  temporary_iam_instance_profile_policy_document {
    Version = "2012-10-17"
    Statement {
      Effect = "Allow"
      Action = [
        "ssm:DescribeAssociation",
        "ssm:GetDeployablePatchSnapshotForInstance",
        "ssm:GetDocument",
        "ssm:DescribeDocument",
        "ssm:GetManifest",
        "ssm:GetParameter",
        "ssm:GetParameters",
        "ssm:ListAssociations",
        "ssm:ListInstanceAssociations",
        "ssm:PutInventory",
        "ssm:PutComplianceItems",
        "ssm:PutConfigurePackageResult",
        "ssm:UpdateAssociationStatus",
        "ssm:UpdateInstanceAssociationStatus",
        "ssm:UpdateInstanceInformation"
      ]
      Resource = ["*"]
    }
    Statement {
      Effect = "Allow"
      Action = [
        "ssmmessages:CreateControlChannel",
        "ssmmessages:CreateDataChannel",
        "ssmmessages:OpenControlChannel",
        "ssmmessages:OpenDataChannel"
      ]
      Resource = ["*"]
    }
    Statement {
      Effect = "Allow"
      Action = [
        "ec2messages:AcknowledgeMessage",
        "ec2messages:DeleteMessage",
        "ec2messages:FailMessage",
        "ec2messages:GetEndpoint",
        "ec2messages:GetMessages",
        "ec2messages:SendReply"
      ]
      Resource = ["*"]
    }
  }
}

build {
  sources = ["source.amazon-ebs.mac-packer-example"]
  # resize the partition to use all the space available on the EBS volume
  provisioner "shell" {
    inline = [
      "PDISK=$(diskutil list physical external | head -n1 | cut -d' ' -f1)",
      "APFSCONT=$(diskutil list physical external | grep Apple_APFS | tr -s ' ' | cut -d' ' -f8)",
      "yes | sudo diskutil repairDisk $PDISK",
      "sudo diskutil apfs resizeContainer $APFSCONT 0"
    ]
  }
  # clean the ec2-macos-init history in order to make instance from AMI as it were the first boot
  # see https://github.com/aws/ec2-macos-init#clean for details.
  provisioner "shell" {
    inline = [
      "sudo /usr/local/bin/ec2-macos-init clean --all"
    ]
  }
  provisioner "shell" {
    inline = [
      "/usr/local/bin/brew update",
      "/usr/local/bin/brew upgrade",
      "/usr/local/bin/brew install go"
    ]
  }
}

In the following sections, I deep dive into some sections of the template.

Variables

The template can be customized via variables. The following table provides an overview of the used variables and their purpose.

variable default required example value purpose
ami_name mac-os-big-sur-ami yes my-custom-ami change the name of the resulting AMI. Be aware that a trailing timestamp will be added.
region null yes us-east-2 set the AWS region in which you allocated the Dedicated Host
subnet_id null no subnet-0d3d002af8EXAMPLE assign a custom subnet (for example, not to use a public IP for the instance)

NOTE: If you want to customize the subnet selection, you must provide a subnet in the same Availability Zone as you allocated the Dedicated Host in.

Instance profile for SSM Session Manager

By default, Packer launches the instance in a public subnet. I prefer not to expose a public IP for my instance and run it in a private subnet instead. I use SSM to establish the connection. With SSM, the instance doesn’t need a public IP and I don’t have to open port 22 via its security group. The only requirements for SSM are that the instance has outbound internet access (or access via a VPC Endpoint for Systems Manager), has the SSM agent installed, and the appropriate permissions to open the connection. In this case, I use Packer’s temporary_iam_instance_profile_policy_document to pass in a policy document, which is a blunt copy and paste of the managed policy “AmazonSSMManagedInstanceCore”. This setting along with ssh_interface = “session_manager” makes Packer work with SSM.

Timeout Settings

Starting and stopping EC2 Mac instances can take longer than starting Linux instances. So, you must increase Packer’s timeout settings. Specifically, set ‘ssh_timeout’ to two hours and aws_polling to poll once a minute and for a maximum of 60 attempts. This gives you ample time and ensures that Packer doesn’t cancel any operation due to timeouts.

EBS Volume size

Amazon Elastic Block Storage (EBS) is a block-storage service designed for use with Amazon EC2. The default settings for the root volume of a mac1.metal are a size of 60 GiB and a volume type of gp2. Since AWS now offers the gp3 volume type which has several advantages over gp2 (including reduced pricing), I use gp3 for this example. Because 60 GiB doesn’t leave lots of space for installation of Xcode and other tooling, I use 150 GiB as the default volume size. To tweak that you can override the root_volume_size_gb variable. The settings for changing the default device type and size go into Packer’s launch_block_device_mappings.

Increasing the partition size from macOS

In order to make macOS use the additional space I added to the root volume in the preceding step, I must resize its partition. This is what’s happening in the first section of provisioner “shell”. It uses macOS commands to expand the partition size to the whole volume.

Resetting ec2-macos-init

A recommended part of creating custom AMIs is to clear any history of previous launches. This makes instances launched from the AMI to run as though it was their first boot. In order to archive this, I use the clean –all command of EC2 macOS init. EC2 macOS Init is the launch daemon used for Mac instances. It is also responsible for running any provided user data, and you can also use it to run arbitrary commands at the startup time of your instances. The necessary command is in the second provisioner “shell” section.

Installing additional packages via brew

I use golang as an example of software you might install onto your AMI. Brew (“The Missing Package Manager for macOS”) comes pre-installed on all EC2 Mac instances. You can see how we use it in the third provisioner “shell” section: Before doing the actual go installation, I update brew’s local index and upgrade any installed packages to their latest version.

Running packer

To run your template and make Packer build the AMI, issue the following command:

packer build -var 'subnet_id=subnet-0d3d002af8EXAMPLE' -var 'region=us-east-2' mac.pkr.hcl

After around 35 minutes, Packer reports back the successful creation of the AMI as shown in the following screenshot:

Packer Output

Packer Output

What next?

Now that the AMI is created, let’s launch an instance from it. Afterwards, I connect via SSH for command line access and via Virtual Network Computing (VNC) for a remote desktop.

Launch an instance from the AMI

First, I launch a new instance from my AMI. I use the same subnet as for Packer, take the ID of the Dedicated Host I created earlier on, and I specify an IAM Role, which has the required permissions to use SSM.

aws ec2 run-instances --instance-type mac1.metal \
                      --subnet-id subnet-0d3d002af8EXAMPLE \
                      --image-id ami-0d6fb7542bb0a8da3 \
                      --region us-east-2 \
                      --placement HostId=h-03464da766df06f5c \
                      --iam-instance-profile Name=SSMInstanceRole

NOTE: After the image was created and the instance is shut down it can take up to 200 minutes (45 minutes on average) for the Dedicated Host to become available again. That means you won’t be able to immediately launch a new instance on the same host. See the EC2 Mac Instances User Guide for details.

Accessing the instance

Access via VNC is disabled by default. If you want to use the GUI of your Mac instance, you must enable it. Let’s take the necessary steps and connect to the instance afterwards:

Connecting to the instance

I can start an SSH session to the instance via SSM with the following command:

aws ssm start-session --target <YOUR_INSTANCE_ID> --region us-east-2

Enable VNC

Via the SSH connection, I set a password for the user ec2-user and issue the required commands to activate remote GUI access.

sudo passwd ec2-user
sudo /System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart \
    -activate -configure -access -on \
    -configure -allowAccessFor -specifiedUsers \
    -configure -users ec2-user \
    -configure -restart -agent -privs -all
sudo /System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart \
    -configure -access -on -privs -all -users ec2-user

Access the GUI from your localhost:

I now create a secure tunnel between my local machine and Amazon EC2.  I use it for the VNC connection:

1.     Create tunnel via ssm:

aws ssm start-session \
    --target i-0bd054c24ed30074a --region us-east-2 \
    --document-name AWS-StartPortForwardingSession \
    --parameters '{"portNumber":["5900"], "localPortNumber":["5900"]}'

2.     Connect to the local tunnel endpoint:
I use the built-in VNC client of my local Mac to connect to the remote machine. If you’re on a different operating system, you might have to install a VNC viewer first.

open vnc://ec2-user@localhost:5900

After entering the credentials, I’m prompted with the GUI of the EC2 Mac instance:

Login GUI

Login GUI

With that I can start using the instance via VNC or SSH and run my macOS development workloads on EC2 Mac.

Clean Up

In case you followed along with this blog post and want to prevent incurring costs you need to deregister the created AMI, shutdown any launched instances and release the Dedicated Host.

Summary

In the preceding sections I’ve shown how to create an AMI of an EC2 Mac instance via HashiCorp Packer. I’ve carried out some administrative tasks such as resizing the hard drive, resetting ec2-macos-init and installed some base software (golang) onto the image. In the process I also demonstrated how to facilitate SSM to connect to an EC2 instance without the need to open any ports or a public IP.

Now I’d be eager to know what AWSome solution you’re going to build with the content described by this blog post! In order to get started with EC2 Mac instances, please visit this page.