<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Lambda on AWS McMillearn Blog</title><link>https://aws.mcmillearn.net/aws_services/lambda/</link><description>Technical blog focused on AWS best practices and architecture patterns on AWS McMillearn Blog</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>John McMillan</managingEditor><webMaster>John McMillan</webMaster><lastBuildDate>Fri, 02 Jan 2026 09:00:00 +0000</lastBuildDate><atom:link href="https://aws.mcmillearn.net/aws_services/lambda/index.xml" rel="self" type="application/rss+xml"/><category>AWS</category><category>Cloud Computing</category><category>DevOps</category><category>Architecture</category><item><title>Half Baked Lifecycles</title><link>https://aws.mcmillearn.net/posts/half-baked-lifecycles/</link><pubDate>Fri, 02 Jan 2026 09:00:00 +0000</pubDate><author>John McMillan</author><guid>https://aws.mcmillearn.net/posts/half-baked-lifecycles/</guid><description>How to lifecycle-manage AMIs produced by Amazon EC2 Image Builder</description><content:encoded><![CDATA[<h1 id="intro">Intro</h1>
<p>In my previous post, <a href="https://aws.mcmillearn.net/posts/half-baked-images/">Half Baked Images</a> I walked through building hardened AMIs using Amazon EC2 Image Builder, automating the pipeline, and wiring it into Launch Templates for Auto Scaling Groups. I touched on some aspects that felt <em>almost, but not quite</em> useful. In this post I&rsquo;ll talk about my attempts to keep on top of the images produced and why (spoiler!) I ultimately chose Lambda over built-in Image Builder functionality.</p>
<h2 id="task-stop-amis-and-snapshots-from-piling-up">Task: Stop AMIs and snapshots from piling up</h2>
<p>AWS has an answer for that, of course: Image Builder lifecycle policies.<br>
This should be easy (AKA Foreshadowing).</p>
<p>When asked &ldquo;<em>What does Lifecycle management in Image Builder do?</em>&rdquo;, Amazon Q gave this answer:</p>
<blockquote>
<p>&ldquo;The lifecycle manager implements automated rules to manage your image resources throughout their lifecycle, helping ensure image freshness while minimizing costs for underlying infrastructure like snapshot storage for AMIs or ECR repository storage for container images.&rdquo;</p>
</blockquote>
<p>I understand all those words so this is going to be easy peasy.</p>
<h3 id="lifecycle-management-made-confusing">Lifecycle management made&hellip; confusing.</h3>
<p>What I didn’t anticipate was how surprisingly awkward it would be to implement what felt like a very simple solution.</p>
<p>Unfortunately, the behaviour of those policies was less intuitive than the name suggested&hellip; to me at least.</p>
<p>This post is about the assumptions I made, why they were wrong, what Image Builder lifecycle actually does, and why I ultimately replaced it with a small Lambda function that behaved better for my use case.</p>
<h3 id="initial-approach">Initial approach</h3>
<p>I started with a simple goal:</p>
<blockquote>
<p>Retain the last 5 AMIs produced by Image Builder.<br>
Delete anything older, including associated snapshots.</p>
</blockquote>
<p>Image Builder has a native lifecycle feature. Terraform exposes it cleanly.
This felt so obvious that I didn’t question it. The console UI even said things like:</p>
<p><img alt="Keep the last 5" loading="lazy" src="/posts/half-baked-lifecycles/keep5.png"></p>
<p>So I configured a lifecycle policy in terraform like this:</p>
<pre tabindex="0"><code>resource &#34;aws_imagebuilder_lifecycle_policy&#34; &#34;retain_5_images&#34; {
  name           = &#34;${var.project}-lifecycle-policy&#34;
  description    = &#34;Retain only the 5 most recent AMIs created by Image Builder&#34;
  execution_role = aws_iam_role.imagebuilder_role.arn

  resource_type = &#34;AMI_IMAGE&#34; 

  resource_selection {
    tag_map = {
      Project   = var.project
      ManagedBy = &#34;AWSImageBuilder&#34;
    }
  }

  policy_detail {
    action {
      type = &#34;DELETE&#34;
      include_resources {
        amis       = true
        containers = false
        snapshots  = true
      }
    }

    filter {
      type  = &#34;COUNT&#34; # or &#34;COUNT&#34;
      value = &#34;5&#34;
    }
  }
}
</code></pre><p><strong>Note:</strong> This wasn&rsquo;t my first attempt, this was the first that ran correctly.<br>
It took me a week to get it to run. See Assumption #1.</p>
<p>Even now, recovering from the savage AMI cull of &lsquo;25, I still read that code and think the policy should:</p>
<ul>
<li>Take the <code>DELETE</code> action for resources type &ldquo;AMI_IMAGE&rdquo;&hellip;</li>
<li>also including resource types AMIs and snapshots, for those AMI_IMAGEs that matched&hellip;</li>
<li>tags <code>Project</code> &amp; <code>ManagedBy</code>, whilst retaining&hellip;</li>
<li>Filter: COUNT = 5</li>
</ul>
<p>Therefore I expected the outcome to be:</p>
<blockquote>
<p>Find Images were <code>Project = var.project</code> and <code>ManagedBy = &quot;AWSImageBuilder&quot;</code><br>
If there are ≤5 images that match that criteria → delete nothing<br>
If there are 6 → delete the oldest 1<br>
If there are 18 → delete the oldest 13</p>
</blockquote>
<p>etc. Simple. Predictable. Safe.</p>
<p>That is not what happened.<br>
But what went wrong?</p>
<h2 id="assumption-is-the-mother-of-all-fowl-ups">Assumption is the mother of all&hellip; fowl ups.</h2>
<h3 id="assumption-1">Assumption #1</h3>
<p>My first assumption was that I would have some control over when the lifecycle management would run. Similar to controlling the pipeline schedule, I&rsquo;d be able to control a lifecycle management schedule.</p>
<p>e.g. I&rsquo;d be able to define a cron schedule or trigger it to run immediately after a new image became <code>AVAILABLE</code>. Nope.</p>
<p>Image Builder Lifecycle policies run on an internal AWS schedule, completely decoupled from the triggers or control I expected. There is also no &ldquo;Run now&rdquo; button to force an evaluation.</p>
<p>This had two consequences that significantly impacted my use case:</p>
<ol>
<li>
<p><strong>Testing is non-linear.</strong><br>
Unsurprisingly my lifecycle didn&rsquo;t work on the first try. So, when I attempted to fix it, I had to wait 24 hours to see if that had worked.<br>
Testing, that could have taken a couple of hours, took <em><strong>days</strong></em> to work through as a result of this approach.</p>
</li>
<li>
<p><strong>Troubleshooting was complex and misleading</strong><br>
I was working on a few aspects of the code simultaneously, so by the time my lifecycle ran each day I had usually made several changes.</p>
<ul>
<li>Tags had been added or changed</li>
<li>IAM permissions were tightened</li>
<li>Images components were changing, meaning recipes had been updated.</li>
</ul>
</li>
</ol>
<p>The combination of those two realities made it very difficult to figure out cause and effect: Was lifecycle management failing because I&rsquo;d borked the config? Was it related to my tagging changes? or the new recipe?<br>
<em>sigh.</em></p>
<h3 id="assumption-2">Assumption #2</h3>
<p>My second assumption was that the lifecycle policy in Image Builder relates to the images built by Image Builder&hellip; that is to say - the AMIs.</p>
<p>Sensible assumption? I thought so&hellip; and if you did too&hellip; join me in my pit of despair because, as we&rsquo;ll get to in a moment, this was <strong>not</strong> the case.</p>
<h3 id="assumption-3">Assumption #3</h3>
<p>My third, and what proved to be my most costly assumption, was that I&rsquo;d provided enough details to safely identify the images to be deleted and that only those Images would be deleted. That this turned out to be incorrect was the real sore point - this one wasn&rsquo;t easy to predict or see coming until it went wrong.</p>
<h2 id="so-what-actually-happened">So what actually happened?</h2>
<p>When my lifecycle policy ran, it removed <strong>all</strong> AMIs. Everything.<br>
Including AMIs <em><strong>still</strong></em> in use by my Auto Scaling Group&rsquo;s Launch Template.<br>
Nothing useful remained. Zero AMIs, zero snapshots.</p>
<p>Thankfully this occurred in a lab environment so wasn&rsquo;t too impactful&hellip; but it did give me chills. I looked over the code, at the resources, and the lifecycle policy in the console, etc. It all looked correct. It <em>seemed</em> to make sense - but here I was with no useable images left.<br>
And at the moment of discovering this issue, even from that point, it took a good few hours to understand why it had gone so badly wrong.</p>
<p>To say I was a bit surprised at the outcome is putting it mildly.<br>
This could not be allowed to happen in an environment that matters to me.</p>
<h3 id="where-did-the-assumptions-fall-down">Where did the assumptions fall down?</h3>
<ol>
<li>
<p>Not having control over the scheduling of the lifecycle policy was simultaneously a relatively minor issue <strong>and</strong> almost enough to be a deal breaker on its own. Who&rsquo;s happy waiting 24 hours to test configuration changes in a lab? Fail fast isn&rsquo;t an option, if you have to wait 24 hours to test each change.</p>
</li>
<li>
<p>My assumption that my lifecycle policy was targeting my AMI&rsquo;s directly was incorrect. This one <em>really</em> mattered.<br>
EC2 Image Builder Lifecycle Policies target Image builder resources, e.g. things with arns like <code>arn:aws:imagebuilder:eu-west-2:123456789012:image/custom-linux-build/1.0.6/3</code>.<br>
This is a subtle but important distinction to understand when you&rsquo;re thinking about how Image Builder Lifecycle policies identify targets. You can, as I did in the code above, state that it should include <code>amis = true</code> &amp; <code>snapshots = true</code>. But, again, the AMIs and snapshots are secondary in this.</p>
</li>
<li>
<p>Assuming I&rsquo;d provided the correct selection criteria was compounded by the previous assumption about what the lifecycle policies target.<br>
I mentioned that after my lifecycle policy ran I had zero AMI&rsquo;s and zero snapshots.<br>
But I <em><strong>did</strong></em> still have 5 &ldquo;images&rdquo;. I still had 5 <em><strong>Image Builder</strong></em> Image&rsquo;s retained. Although the AMI&rsquo;s that they pointed to were gone.</p>
</li>
</ol>
<p>Yup, I swore inwardly at this point.</p>
<h3 id="bringing-it-home">Bringing it home</h3>
<p>I asked Amazon Q why it would do this (to me), and in its reply it had this little unhelpful truth bomb:</p>
<blockquote>
<p>&ldquo;Even if the Image Builder Image resources were retained, if your policy had:<code>&quot;includeResources&quot;: { &quot;amis&quot;: true, &quot;snapshots&quot;: true }</code>
<strong>This would delete the underlying AMIs and snapshots while potentially keeping the Image Builder metadata.</strong>&rdquo;</p>
</blockquote>
<p>I swore outwardly at this point. Loudly.</p>
<p>This ia an awful failure condition by any stretch. I accept that this was my configuration, and <strong>I</strong> am responsible for this, it still feels like this was a booby trap waiting to go off rather than something you could reasonably foresee &amp; avoid.</p>
<h2 id="so-what-did-i-do-about-it">So what did I do about it</h2>
<p>After a fair amount of frustration, confusion, and lost AMIs I decided to abandon the Image Builder lifecycle policy approach and turned to AWS Lambda - what can&rsquo;t Lambda fix?</p>
<p>I created a small function that I deployed using Terraform, as follows:</p>
<pre tabindex="0"><code>import os
import json
import boto3
import logging
from datetime import datetime, timezone

logger = logging.getLogger()
logger.setLevel(logging.INFO)

ec2 = boto3.client(&#34;ec2&#34;)

RETAIN_COUNT = int(os.getenv(&#34;RETAIN_COUNT&#34;, &#34;5&#34;))
PROJECT_TAG_VALUE = os.getenv(&#34;PROJECT_TAG_VALUE&#34;, &#34;labpoc&#34;)
MANAGED_BY_VALUE = os.getenv(&#34;MANAGED_BY_VALUE&#34;, &#34;AWSImageBuilder&#34;)
LAUNCH_TEMPLATE_ID = os.getenv(&#34;LAUNCH_TEMPLATE_ID&#34;)  # required for protection
DRY_RUN = os.getenv(&#34;DRY_RUN&#34;, &#34;true&#34;).lower() == &#34;true&#34;


def _parse_dt(s: str) -&gt; datetime:
    return datetime.fromisoformat(s.replace(&#34;Z&#34;, &#34;+00:00&#34;)).astimezone(timezone.utc)


def _list_matching_amis():
    filters = [
        {&#34;Name&#34;: &#34;tag:Project&#34;, &#34;Values&#34;: [PROJECT_TAG_VALUE]},
        {&#34;Name&#34;: &#34;tag:ManagedBy&#34;, &#34;Values&#34;: [MANAGED_BY_VALUE]},
        {&#34;Name&#34;: &#34;state&#34;, &#34;Values&#34;: [&#34;available&#34;]},
    ]
    resp = ec2.describe_images(Owners=[&#34;self&#34;], Filters=filters)
    images = resp.get(&#34;Images&#34;, [])
    images.sort(key=lambda i: _parse_dt(i[&#34;CreationDate&#34;]), reverse=True)  # newest first
    return images


def _snapshots_for_ami(image: dict) -&gt; list[str]:
    snaps = []
    for bdm in image.get(&#34;BlockDeviceMappings&#34;, []):
        ebs = bdm.get(&#34;Ebs&#34;)
        if ebs and ebs.get(&#34;SnapshotId&#34;):
            snaps.append(ebs[&#34;SnapshotId&#34;])
    return snaps


def _summarise_images(images: list[dict], max_items: int = 50) -&gt; list[dict]:
    out = []
    for img in images[:max_items]:
        out.append({
            &#34;ImageId&#34;: img.get(&#34;ImageId&#34;),
            &#34;CreationDate&#34;: img.get(&#34;CreationDate&#34;),
            &#34;Name&#34;: img.get(&#34;Name&#34;),
        })
    if len(images) &gt; max_items:
        out.append({&#34;note&#34;: f&#34;truncated: showing {max_items} of {len(images)}&#34;})
    return out


def _amis_referenced_by_latest_lt_versions(launch_template_id: str, protect_versions: int) -&gt; set[str]:
    &#34;&#34;&#34;
    Protect AMIs referenced by the *latest N* launch template versions.
    &#34;&#34;&#34;
    protected = set()

    # Fetch versions (we&#39;ll sort and take the latest N)
    versions = []
    paginator = ec2.get_paginator(&#34;describe_launch_template_versions&#34;)
    for page in paginator.paginate(LaunchTemplateId=launch_template_id):
        versions.extend(page.get(&#34;LaunchTemplateVersions&#34;, []))

    if not versions:
        return protected

    # Sort by VersionNumber descending and take latest N
    versions.sort(key=lambda v: v.get(&#34;VersionNumber&#34;, 0), reverse=True)
    latest = versions[:protect_versions]

    for v in latest:
        data = v.get(&#34;LaunchTemplateData&#34;, {})
        image_id = data.get(&#34;ImageId&#34;)
        if image_id:
            protected.add(image_id)

    return protected


def lambda_handler(event, context):
    logger.info(
        &#34;Starting AMI retention. retain=%s project=%s managedby=%s dry_run=%s lt_id=%s&#34;,
        RETAIN_COUNT, PROJECT_TAG_VALUE, MANAGED_BY_VALUE, DRY_RUN, LAUNCH_TEMPLATE_ID
    )

    if RETAIN_COUNT &lt; 1:
        raise ValueError(&#34;RETAIN_COUNT must be &gt;= 1&#34;)

    images = _list_matching_amis()
    total = len(images)
    logger.info(&#34;Found %s matching AMIs.&#34;, total)
    logger.info(&#34;Matched AMIs (newest-&gt;oldest): %s&#34;, json.dumps(_summarise_images(images)))


    if total &lt;= RETAIN_COUNT:
        logger.info(&#34;&lt;= %s matching AMIs; deleting none.&#34;, RETAIN_COUNT)
        return {&#34;statusCode&#34;: 200, &#34;body&#34;: json.dumps({&#34;matching&#34;: total, &#34;deleted&#34;: 0})}

    protected = set()
    if LAUNCH_TEMPLATE_ID:
        protected = _amis_referenced_by_latest_lt_versions(LAUNCH_TEMPLATE_ID, RETAIN_COUNT)
        logger.info(&#34;Protected AMIs referenced by latest %s Launch Template versions: %s&#34;,
                RETAIN_COUNT, list(sorted(protected)))
    else:
        logger.warning(&#34;LAUNCH_TEMPLATE_ID not set; NOT protecting in-use AMIs (not recommended).&#34;)

    # Keep newest RETAIN_COUNT (always)
    keep_newest = {img[&#34;ImageId&#34;] for img in images[:RETAIN_COUNT]}

    # Eligible = older than newest RETAIN_COUNT AND not protected by LT
    older = images[RETAIN_COUNT:]
    candidates = []
    for img in older:
        ami_id = img[&#34;ImageId&#34;]
        if ami_id in protected:
            continue
        candidates.append(img)

    logger.info(&#34;Candidates for deletion (older than newest %s, not referenced by LT): %s&#34;,
                RETAIN_COUNT, len(candidates))

    deleted = []
    for img in candidates:
        ami_id = img[&#34;ImageId&#34;]
        snaps = _snapshots_for_ami(img)

        logger.info(&#34;Deleting AMI %s (snapshots=%s)&#34;, ami_id, snaps)

        if not DRY_RUN:
            ec2.deregister_image(ImageId=ami_id)
            for snap_id in snaps:
                ec2.delete_snapshot(SnapshotId=snap_id)

        deleted.append({&#34;ami&#34;: ami_id, &#34;snapshots&#34;: snaps})

    logger.info(&#34;Done. Deleted %s AMIs.&#34;, len(deleted))
    return {&#34;statusCode&#34;: 200, &#34;body&#34;: json.dumps({&#34;matching&#34;: total, &#34;deleted&#34;: len(deleted), &#34;items&#34;: deleted})}
</code></pre><p>Note: the environment vars, you can set these in terraform when you specify your <code>aws_lambda_function</code></p>
<pre tabindex="0"><code>  environment {
    variables = {
      RETAIN_COUNT       = tostring(var.ami_retain_count)
      PROJECT_TAG_VALUE  = var.project
      MANAGED_BY_VALUE   = &#34;AWSImageBuilder&#34;
      DRY_RUN            = &#34;true&#34; # flip to false when confident it&#39;s working
      LAUNCH_TEMPLATE_ID = aws_launch_template.wafr_lt.id
    }
  }
</code></pre><p>This Lambda function is, in many ways, much simpler than what I thought I’d been doing with the Image Builder Lifecycle Manager… This function:</p>
<ul>
<li>Lists AMIs by tag (Project, ManagedBy)</li>
<li>Sorts them by creation date</li>
<li>Keeps the newest N (defined by Terraform variable <code>ami_retain_count</code>)</li>
<li>Deregisters anything older</li>
<li>Deletes the snapshots referenced by those AMIs</li>
<li>Never deletes AMIs currently referenced by the latest N Launch Template (also defined by <code>ami_retain_count</code>.)</li>
</ul>
<p>This runs on a schedule I control. And understand!<br>
It&rsquo;s observable, repeatable, and it can be run in dry-run mode while testing.</p>
<p>Most importantly, the behaviour can be explained in one sentence:</p>
<blockquote>
<p>“We keep the 5 newest AMIs for this project, and we never delete one that’s in use.”</p>
</blockquote>
<p>That is the rule I <em>thought</em> I was configuring when I attempted to enable lifecycle retention — but now it’s actually true!</p>
<h2 id="why-a-custom-solution-won--why-im-okay-with-that">Why a custom solution won (&amp; why I&rsquo;m okay with that)</h2>
<p>After working through this in detail, I came to a conclusion that surprised me:<br>
<strong>Image Builder lifecycle policies are not a good fit for intuitive AMI retention.</strong></p>
<p>They are:</p>
<ul>
<li>metadata-centric</li>
<li>recipe-scoped</li>
<li>on their own schedule</li>
<li>capable of deleting artifacts without leaving usable AMIs behind (talk about sharp edges!)</li>
</ul>
<p>This doesn’t mean lifecycle policies are broken — but it does make them dangerous if your mental model is <strong>“Keep the last <em>N</em> usable AMIs.”</strong></p>
<p>In my experience this model doesn’t map cleanly to how Image Builder lifecycle works.</p>
<p>By contrast my Lambda function is:</p>
<ul>
<li>explicit - I could set my own boundaries that did what I expected them to do.</li>
<li>controlled - it runs when <em>i</em> want.</li>
<li>able to add clear and simple guardrails - e.g. <em>don&rsquo;t delete any AMIs still in use, regardless of other filters</em>.</li>
<li>Auditable - see what&rsquo;s going on easily in the execution logs.</li>
<li>Test friendly. Flip &ldquo;dry-run&rdquo; to &rsquo;true&rsquo; to test what would be deleted before you take the training wheels off.</li>
</ul>
<p>The result was a solution that worked as I expected, trimmed my AMIs and Snapshots to the number I wanted, and stopped my bill from growing forever with the ghost of a million unused snapshots, allowed me to safely test changes <em>when I wanted</em>. And it only cost only a tiny amount in Lambda usage. Worth it.</p>
<h2 id="final-thoughts">Final thoughts</h2>
<p>EC2 Image Builder is excellent at building &amp; customising images.<br>
EventBridge and Lambda are excellent at reacting to them.</p>
<p>But AMI retention is an operational concern, not a build concern — and once I treated it that way, the solution became simpler, safer, and easier to understand.</p>
<p>If you’re currently using Image Builder lifecycle today and it works for you, that’s great.</p>
<p>If, however, your mental model is <em>“keep the last N usable AMIs”</em>, it’s worth pausing and double-checking that the system is actually doing what you think it is.</p>
<p>Sometimes the most robust solution is the one you can explain easily — and without waiting 24 hours to see what it decided to do.</p>
<h2 id="additional-resources">Additional Resources</h2>
<ul>
<li><a href="https://github.com/jmcmillan1873/autoscaling-with-imagebuilder">Complete EC2 Image Builder with Lifecycle Management Example</a></li>
<li><a href="https://aws.amazon.com/image-builder/">Amazon EC2 Image Builder</a></li>
<li><a href="https://aws.amazon.com/pm/lambda/">AWS Lambda</a></li>
<li><a href="https://aws.mcmillearn.net/posts/half-baked-images/">Half Baked Images - previous blog post</a></li>
</ul>
<hr>
<p><em>This post is part of my AWS technical blog series. Found this helpful? Connect with me on <a href="https://www.linkedin.com/in/john-mcmillan-aaa4274/">LinkedIn</a> or check back here for more AWS content and discussions.</em></p>
]]></content:encoded><category>AWS</category><category>EC2 Image Builder</category><category>Lambda</category><category>AWS</category><category>EC2</category><category>Auto Scaling Group</category><category>EC2 Image Builder</category><category>AWS EC2 Image Builder</category><category>AWS Auto Scaling Group</category><category>AWS EventBridge</category><category>AWS Lambda</category><enclosure url="https://aws.mcmillearn.net/images/Half-baked-lifecycles.png" type="image/png"/></item><item><title>Half baked Images?</title><link>https://aws.mcmillearn.net/posts/half-baked-images/</link><pubDate>Thu, 02 Oct 2025 09:00:00 +0000</pubDate><author>John McMillan</author><guid>https://aws.mcmillearn.net/posts/half-baked-images/</guid><description>Making ImageBuilder work with Terraform managed Auto scaling</description><content:encoded><![CDATA[<h1 id="tldr">TL;DR</h1>
<p>Using Terraform to deploy and manage an Auto Scaling Group that utilises custom AMIs built by EC2 Image Builder and dealing with the limitations of EC2 Image Builder&rsquo;s native capabilities for managing Launch Templates.</p>
<h1 id="what-were-we-asked">What were we asked?</h1>
<p>I&rsquo;ve recently worked on a project that required an Auto Scaling Group of EC2 instances that utilised custom-built AMIs produced by EC2 Image Builder. The requirement was to have an EC2 Image Builder pipeline produce weekly images based on Amazon Linux 2023, with various custom components applied.<br>
It&rsquo;s quite straightforward to do this in Terraform, so happy days!</p>
<p>If you&rsquo;ve used Terraform to manage Auto Scaling Groups in AWS, you&rsquo;ll know that this is relatively  straightforward too. There are lots of examples and use cases out there to draw from.<br>
So it should be super simple to marry those two concepts together into one simple, happy, solution you&rsquo;d think.<br>
Yeah right!</p>
<p>If you want to skip the dramatic retelling of my pain and just get the answer, skip to <a href="#so-what-was-the-solution">the solution</a></p>
<h2 id="whats-the-problem-go">What&rsquo;s the problem? Go!</h2>
<p>The first two parts <em>are</em> straightforward - the ASG definition and EC2 Image Builder pipeline setup in Terraform. As is the rest of the Terraform really. Working with my buddy we quickly had IaC that would deploy the VPC that would host our Auto Scaling Group, the Launch Template that would complement that ASG, and the EC2 Image Builder components, recipe, pipeline, and distribution, etc. As well as all the trimmings, IAM policies, security groups, KMS keys, etc, etc.<br>
During the initial deploy attempt we encountered our first red flag. We had valid Terraform code, but it threw up this error:</p>
<pre tabindex="0"><code>Planning failed. Terraform encountered an error while generating this plan.

╷
│ Error: Your query returned no results. Please change your search criteria and try again.
│
│   with data.aws_ami.latest_custom,
│   on data.tf line 486, in data &#34;aws_ami&#34; &#34;latest_custom&#34;:
│  486: data &#34;aws_ami&#34; &#34;latest_custom&#34; {
│
</code></pre><p>I&rsquo;d used a data source to capture the ID of the latest image that EC2 Image Builder would produce, but of course that hadn&rsquo;t run yet.<br>
I thought I&rsquo;d been smart, I had foreseen the bootstrapping issue, and thought I&rsquo;d get around it by temporarily pointing the image_id at an AWS managed SSM parameter that stores the Amazon Linux 2023 AMI ID.</p>
<pre tabindex="0"><code>resource &#34;aws_launch_template&#34; &#34;my-cool-project-resource&#34; {
  name_prefix            = &#34;my-cool-project-name-lt-&#34;
  #image_id               = data.aws_ami.latest_custom.id
  image_id               = data.aws_ssm_parameter.al2023.id
</code></pre><p>This didn&rsquo;t prevent the error above. To my shame (not really, I&rsquo;m actually quite pragmatic) I commented out the data source for my latest custom image and was able to successfully deploy my infrastructure. I immediately reversed the commented out sections. <code>data.aws_ami.latest_custom</code> was reinstated, and the Launch Template was reverted back to <code>image_id = data.aws_ami.latest_custom.id</code> and all worked well. Sorted.</p>
<h3 id="5-influence-points-if-you-see-where-this-is-going">+5 influence points if you see where this is going</h3>
<p>It wasn&rsquo;t sorted. Not completely.<br>
I could now successfully deploy the infrastructure reliably, I&rsquo;d hacked my way past the bootstrapping issue, but comforted by my solemn vow to myself that I&rsquo;d go back and fix that. Scout&rsquo;s honour.<br>
<img alt="IdontBelieveYou" loading="lazy" src="/posts/half-baked-images/dontbelieveyou.gif"></p>
<p>The situation was better but not quite what we needed. We had an EC2 Image Builder pipeline that ran to a schedule and worked as expected. Our Launch Template looked good but it wasn&rsquo;t using the most recent image. It was using the most recent -1.  Consider this sequence of events:</p>
<ol>
<li>Terraform deploys IaC, creating ASG, Launch Template, and EC2 Image builder pipeline.</li>
<li>EC2 Image Builder was triggered manually to confirm it was working.</li>
<li>EC2 Image Builder produces a new version of the AMI.</li>
<li>Launch Template is still running the TF deployed AMI version and doesn&rsquo;t know about the one EC2 Image Builder produced.</li>
</ol>
<p>This surfaced what might have been obvious to you dear reader, that the Launch Template <strong>must</strong> be built before we&rsquo;ve built any images.<br>
Also that our Pipeline would run independently of our Terraform code - and so our Launch Template would never know about the latest image until we next ran Terraform to update the Launch Template. That&rsquo;s not ideal as the intention was to set up a CI/CD pipeline to run the Terraform code upon merges to the main branch.</p>
<p>I was opposed to the idea of scheduling the Terraform code to run <em>after</em> the pipeline schedule. That wouldn&rsquo;t be pragmatic, that would be messy.</p>
<h3 id="why-dont-you-just-update-the-lt-natively-from-ec2-image-builder">Why don&rsquo;t you just update the LT natively from EC2 Image Builder?</h3>
<p>Reasons!<br>
Mostly that I didn&rsquo;t know that was a thing at this point. I started looking at how other people have solved this, because surely using custom-built AMIs in Auto Scaling Groups is super common? It kinda is, but so is this challenge.</p>
<p>In actual fact, the distribution settings within EC2 Image Builder allow for updating a Launch Template with the latest AMI&hellip; directly. It&rsquo;s explained nicely <a href="https://docs.aws.amazon.com/imagebuilder/latest/userguide/dist-using-launch-template.html">here</a>.</p>
<p>So that resulted in me adding this <code>launch_template_configuration</code> block to my distribution config:</p>
<pre tabindex="0"><code>resource &#34;aws_imagebuilder_distribution_configuration&#34; &#34;dist&#34; {
  name = &#34;${var.project}-dist&#34;

  distribution {
    region = var.region

    ami_distribution_configuration {
      name        = &#34;${var.project}-{{imagebuilder:buildDate}}&#34;
      description = &#34;A descriptive description describing things&#34;

      ami_tags = {
        OS       = &#34;AmazonLinux2023&#34;
        Hardened = &#34;true&#34;
        Name     = &#34;${var.project}-{{imagebuilder:buildDate}}&#34;
      }
    }
    launch_template_configuration {
      launch_template_id = aws_launch_template.my-cool-project-resource.id
      account_id         = local.account_id
    }
  }
}
</code></pre><p>My distribution would now update the Launch Templates with details of the new AMI ID.<br>
And it did.</p>
<h3 id="half-baked-images">Half Baked Images!</h3>
<p>This was a step forward, if you&rsquo;d first pivoted 180 degrees.<br>
While it did solve the problem of the Launch Template not having the latest AMI - it absolutely <em>did</em> have the latest AMI now - it came at a price.</p>
<p>So, for some reason the documentation gods appear not to have fully explained (please tell me on Linkedin if I&rsquo;m wrong) EC2 Image Builder Distribution Configuration can only configure <em>some</em> elements of the Launch Template, not all!</p>
<p>And it doesn&rsquo;t update the Launch Template in the sense that it retains previous config and simply updates the AMI ID. It updates in the sense that it creates a <strong>new</strong> version with the details you&rsquo;ve told it to. In my case the Account ID and the Launch Template to update with the AMI ID. That makes sense when you step back and think about it, but it also felt unintuitive.</p>
<h3 id="why-is-this-bad">Why is this bad?</h3>
<p>Because the updated version of the Launch Template no longer contains several key details about the rest of the required configuration, Instance profile, Security groups, key pair, etc.</p>
<p>I&rsquo;ll save you the suspense - I checked with Amazon Q to point me to the explanation, and it had this to say:</p>
<blockquote>
<p><em>&ldquo;Unfortunately, EC2 Image Builder cannot preserve existing Launch Template settings when updating the AMI. When EC2 Image Builder creates a new Launch Template version through its distribution settings, it only copies the AMI ID to the new version - it doesn&rsquo;t inherit or preserve other configuration settings like instance profiles, security groups, or other parameters from your existing Launch Template&hellip;<br>
&hellip;the limitations are not explicitly stated in the official AWS docs. The documentation focuses on how to configure EC2 Image Builder to update Launch Templates, but it doesn&rsquo;t clearly specify what happens to existing Launch Template settings when it creates a new version.&rdquo;</em></p>
</blockquote>
<p>The result of this was that my ASG was now spinning up instances with no instance profile, which wasn&rsquo;t the only misconfiguration introduced, but this alone was a show stopper.</p>
<h2 id="so-what-was-the-solution">So what was the solution?</h2>
<p>The solution I settled on was a two-parter, but let&rsquo;s start by recapping what we want to happen:</p>
<ol>
<li>Using Terraform: deploy &amp; manage infrastructure that will create an ASG, Launch Templates, and an EC2 Image Builder pipeline that will update images frequently.</li>
<li>Shared custody of the latest AMI ID &amp; Launch Template configuration between EC2 Image Builder and Terraform. By that I mean:<br>
a. Terraform creates the infrastructure that holds the AMI details and doesn&rsquo;t complain when&hellip;<br>
b. The Launch Template is always referring to the latest AMI ID.</li>
<li>The Launch Template is updated as soon as an updated image is produced, without waiting for Terraform.</li>
<li>Terraform won&rsquo;t freak out when it realises the Launch Template is using a newer AMI ID than it was expecting.</li>
</ol>
<p>So practically how do we do this?</p>
<h3 id="step-1---get-the-iac-correct">Step 1 - Get the IaC correct</h3>
<p>First of all, Terraform needs to create an SSM Parameter as shown in this example:</p>
<pre tabindex="0"><code>
data &#34;aws_ssm_parameter&#34; &#34;al2023&#34; {
  name = &#34;/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64&#34;
}

resource &#34;aws_ssm_parameter&#34; &#34;custom_built_image_id&#34; {
  name        = &#34;/imagebuilder/${var.project}/custom_id&#34;
  description = &#34;SSM Parameter for storing the AMI ID of the image built from EC2 Image Builder&#34;
  type        = &#34;String&#34;
  data_type   = &#34;aws:ec2:image&#34;
  value       = data.aws_ssm_parameter.al2023.value

  lifecycle {
    ignore_changes = [
      value
    ]
  }
}
</code></pre><p>So what did we accomplish with the SSM parameter definition in Terraform?<br>
This is our source of truth for our AMI image.</p>
<ul>
<li>The Terraform definition for the Launch Template should retrieve the value stored here. Hence why it needs to be seeded with a real AMI ID to start with.</li>
<li>Something (no spoilers yet) needs to update the Launch Template with the latest custom built AMI ID. Hence why we have the lifecycle policy ignoring changes to the value.</li>
<li><strong>Important 1</strong> - The managed service role for EC2 Image Builder only has permissions to publish to items in Parameter Store that begin with the prefix <code>/imagebuilder/</code> - see <a href="https://docs.aws.amazon.com/imagebuilder/latest/userguide/tutorial-ssm-parameters-recipe.html">AWS docs on that point</a>. i.e. You <em>must</em> use a <code>name</code> similar to the one in the example above.</li>
<li><strong>Important 2</strong> - <code> data_type   = &quot;aws:ec2:image&quot;</code> <strong>must</strong> be set otherwise the Distribution Configuration in EC2 Image Builder will fail to update the parameter. (Ask me how I know this!)</li>
<li><strong>Important 3</strong> - Don&rsquo;t forget to ignore changes to the <code>value</code> as shown above!</li>
</ul>
<p>Now that we have somewhere to store the AMI ID, we need to make sure its referred to in our code, specifically in the Launch Template and the EC2 Image Builder distribution configs:</p>
<pre tabindex="0"><code>resource &#34;aws_launch_template&#34; &#34;my_lt&#34; {
  name_prefix            = &#34;my-lt-&#34;
  image_id               = aws_ssm_parameter.custom_built_image_id.value
  instance_type          = var.instance_type
  vpc_security_group_ids = [aws_security_group.private_sg.id]

  iam_instance_profile {
    name = aws_iam_instance_profile.custom_permissions.name
  }

  block_device_mappings {
    device_name = &#34;/dev/xvda&#34;
    ebs {
      volume_size = 20
      volume_type = &#34;gp3&#34;
      encrypted   = true
    }
  }

  tag_specifications {
    resource_type = &#34;instance&#34;
    tags = {
      Name    = &#34;NamingIsHard&#34;
      Project = var.project
    }
  }
}

resource &#34;aws_imagebuilder_distribution_configuration&#34; &#34;dist&#34; {
  name = &#34;${var.project}-dist&#34;

  distribution {
    region = var.region

    ami_distribution_configuration {
      name        = &#34;${var.project}-{{imagebuilder:buildDate}}&#34;
      description = &#34;Custom built AL2023 image&#34;

      ami_tags = {
        OS       = &#34;AmazonLinux2023&#34;
        Hardened = &#34;true&#34;
        Name     = &#34;${var.project}-{{imagebuilder:buildDate}}&#34;
      }
    }

    ssm_parameter_configuration {
      parameter_name = aws_ssm_parameter.custom_built_image_id.name
      ami_account_id = local.account_id
    }
  }
}
</code></pre><p>So now the Launch Template is happy, because even at boot time we&rsquo;ve given it a valid AMI ID.<br>
EC2 Image Builder is no longer sabotaging our Launch Template and is instead updating the SSM Parameter Store.</p>
<h3 id="step-2---join-the-dots">Step 2 - Join the dots</h3>
<p>So, thus far we&rsquo;ve completed our deployment, but we still need something to handle the Launch Template updates. Cue <strong>EventBridge</strong> &amp; <strong>Lambda</strong>.<br>
Whereas the native option in EC2 Image Builder would allow us to create a new version of the Launch Template with the new AMI ID, it didn&rsquo;t allow us to specify the other important requirements like security group, profile etc.<br>
A custom Lambda function allows us to do exactly that.</p>
<p>e.g. We can take this function, deploy it using Terraform&hellip;</p>
<pre tabindex="0"><code>import boto3
import os

ssm = boto3.client(&#34;ssm&#34;)
ec2 = boto3.client(&#34;ec2&#34;)

PARAM_NAME = os.environ[&#34;SSM_PARAM_NAME&#34;]
LT_ID = os.environ[&#34;LAUNCH_TEMPLATE_ID&#34;]
INSTANCE_TYPE = os.environ[&#34;INSTANCE_TYPE&#34;]
SECURITY_GROUP_ID = os.environ[&#34;SECURITY_GROUP_ID&#34;]
IAM_INSTANCE_PROFILE = os.environ[&#34;IAM_INSTANCE_PROFILE&#34;]

def handler(event, context):
    # Get latest AMI from SSM
    response = ssm.get_parameter(Name=PARAM_NAME)
    ami_id = response[&#34;Parameter&#34;][&#34;Value&#34;]

    # Create new Launch Template version
    ec2.create_launch_template_version(
        LaunchTemplateId=LT_ID,
        SourceVersion=&#34;$Default&#34;,
        LaunchTemplateData={
            &#34;ImageId&#34;: ami_id,
            &#34;InstanceType&#34;: INSTANCE_TYPE,
            &#34;SecurityGroupIds&#34;: [SECURITY_GROUP_ID],
            &#34;IamInstanceProfile&#34;: {&#34;Name&#34;: IAM_INSTANCE_PROFILE}
        }
    )

    # Set the new version as default
    latest = ec2.describe_launch_template_versions(
        LaunchTemplateId=LT_ID,
        Versions=[&#34;$Latest&#34;]
    )[&#34;LaunchTemplateVersions&#34;][0][&#34;VersionNumber&#34;]

    ec2.modify_launch_template(
        LaunchTemplateId=LT_ID,
        DefaultVersion=str(latest)
    )

    print(f&#34;Updated LT {LT_ID} to use {ami_id}&#34;)
    return {&#34;statusCode&#34;: 200, &#34;ami_id&#34;: ami_id}
</code></pre><h4 id="what-will-this-function-do">What will this function do?</h4>
<p>It takes five environment variables that are used to ensure all the required data for creating a <em>useful</em> new version of the Launch Template is included, but simply put it&rsquo;s doing three things:</p>
<ul>
<li>It obtains the latest AMI ID - that EC2 Image Builder&rsquo;s Distribution config applied to the SSM Parameter.</li>
<li>Create a new version of the Launch Template, passing in the required details, image ID, instance type, security group(s), &amp; Instance profile for permissions.</li>
<li>It updates the Launch Template to set the latest version it just created as the default version.</li>
</ul>
<h4 id="how-do-we-trigger-that-function">How do we trigger that function?</h4>
<p>Now that we have the function to handle the updates, we just need to trigger it appropriately. We do that using EventBridge, and we can also deploy this from Terraform using something like this:</p>
<pre tabindex="0"><code># This is used to trigger Lambda to update the ASG Launch Template when there&#39;s a new AMI available.

resource &#34;aws_cloudwatch_event_rule&#34; &#34;imagebuilder_completed&#34; {
  name          = &#34;${var.project}-imagebuilder-completed&#34;
  description   = &#34;Trigger on successful image builds&#34;
  event_pattern = &lt;&lt;EOF
{
  &#34;source&#34;: [&#34;aws.imagebuilder&#34;],
  &#34;detail-type&#34;: [&#34;EC2 Image Builder Image State Change&#34;],
  &#34;detail&#34;: {
    &#34;state&#34;: {
      &#34;status&#34;: [&#34;AVAILABLE&#34;]
    }
  }
}
EOF
}

resource &#34;aws_cloudwatch_event_target&#34; &#34;trigger_lambda&#34; {
  rule      = aws_cloudwatch_event_rule.imagebuilder_completed.name
  target_id = &#34;LaunchTemplateUpdater&#34;
  arn       = aws_lambda_function.update_launch_template.arn
}
</code></pre><p>This creates an EventBridge rule that is triggered every time your EC2 Image Builder pipeline successfully produces an AMI with a status of &lsquo;AVAILABLE&rsquo;.</p>
<p>That rule in turn triggers the Lambda function shown above.
So now we&rsquo;ll have this sequence:</p>
<ol>
<li>EC2 Image Builder runs and produces a new Image</li>
<li>It also updates the SSM Parameter store with the latest AMI ID</li>
<li>When the new AMI is updated to be &lsquo;AVAILABLE&rsquo;, our EventBridge Rule is triggered.</li>
<li>The EventBridge rule in turn runs the Lambda function to retrieve this new ID and create a new Launch Template version.</li>
</ol>
<p>The result is that when the ASG next scales up, it&rsquo;ll use the latest AMI ID.<br>
It also means that on the next Terraform run it&rsquo;ll pull the latest AMI ID from SSM and recognise that no change is required.</p>
<h2 id="conclusion">Conclusion</h2>
<p>If you want to use EC2 Image Builder to manage AMIs for an ASG, all but the most simple use cases will require some extra plumbing when it comes to handling the Launch Template configuration.<br>
In those more advanced scenarios a method as highlighted here will be effective in handling the synchronisation between Terraform&rsquo;s view of the world and ensuring the Launch Template is always up to date (with a useful Instance Profile.)</p>
<h2 id="additional-resources">Additional Resources</h2>
<ul>
<li><a href="https://github.com/jmcmillan1873/autoscaling-with-imagebuilder">Complete Auto Scaling Group with EC2 Image Builder Example</a></li>
<li><a href="https://docs.aws.amazon.com/imagebuilder/latest/userguide/tutorial-ssm-parameters-recipe.html">EC2 Image Builder &amp; SSM</a></li>
</ul>
<hr>
<p><em>This post is part of my AWS technical blog series. Found this helpful? Connect with me on <a href="https://www.linkedin.com/in/john-mcmillan-aaa4274/">LinkedIn</a> or check back here for more AWS content and discussions.</em></p>
]]></content:encoded><category>AWS</category><category>EC2 Image Builder</category><category>Auto Scaling Group</category><category>AWS</category><category>EC2</category><category>Auto Scaling Group</category><category>EC2 Image Builder</category><category>AWS EC2 Image Builder</category><category>AWS Auto Scaling Group</category><category>AWS EventBridge</category><category>AWS Lambda</category><enclosure url="https://aws.mcmillearn.net/images/half-baked-title.png" type="image/png"/></item></channel></rss>