<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Darryl Ruggles on Medium]]></title>
        <description><![CDATA[Stories by Darryl Ruggles on Medium]]></description>
        <link>https://medium.com/@RDarrylR?source=rss-7ecf33fd997------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*5phrBDCKISiZG6GZrMOo8Q.jpeg</url>
            <title>Stories by Darryl Ruggles on Medium</title>
            <link>https://medium.com/@RDarrylR?source=rss-7ecf33fd997------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Tue, 10 Nov 2026 05:32:51 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@RDarrylR/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Lambda Managed Instances with Terraform: Multi-Concurrency, High Memory, and Compute Options]]></title>
            <link>https://medium.com/@RDarrylR/lambda-managed-instances-with-terraform-multi-concurrency-high-memory-and-compute-options-481a7b6e4bd5?source=rss-7ecf33fd997------2</link>
            <guid isPermaLink="false">https://medium.com/p/481a7b6e4bd5</guid>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[serverless]]></category>
            <category><![CDATA[aws-lambda]]></category>
            <dc:creator><![CDATA[Darryl Ruggles]]></dc:creator>
            <pubDate>Fri, 29 May 2026 23:57:43 GMT</pubDate>
            <atom:updated>2026-05-30T14:23:43.564Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*latGNyBfBmSoCShj4ipebA.png" /></figure><p>Lambda has always been one request at a time per execution environment. Your function starts, processes a single invocation, and sits idle until the next one arrives. If you need to handle a thousand concurrent requests, Lambda spins up a thousand execution environments — each with its own memory, its own cold start, and its own per-GB-second bill.</p><p>Lambda Managed Instances changes that model. Announced at re:Invent 2025 and expanded with <a href="https://aws.amazon.com/about-aws/whats-new/2026/03/lambda-32-gb-memory-16-vcpus/">32 GB memory / 16 vCPU support</a> in March 2026, LMI runs your functions on EC2 instances in your VPC with AWS handling provisioning, patching, scaling, and load balancing. Each execution environment handles multiple concurrent requests. You keep the Lambda programming model and gain EC2 hardware selection and pricing.</p><p>I built a product similarity engine to explore how this works in practice. The handler loads a product catalog with Nova embeddings via Bedrock into memory, uses Amazon Nova Multimodal Embeddings to embed incoming search queries, and computes cosine similarity across categories in parallel using ThreadPoolExecutor. It’s the kind of workload that doesn’t fit well on standard Lambda — sustained throughput, memory-intensive, with a mix of I/O (Bedrock API calls) and CPU (vector math) that benefits from multi-concurrency and configurable memory-to-vCPU ratios. The project uses Terraform for infrastructure, Python 3.14 with Powertools for observability, and the embedding model is configurable (Nova by default, Titan Text Embeddings V2 as an alternative).</p><p>The source code is on GitHub: <a href="https://github.com/RDarrylR/lambda-managed-instances-similarity-engine">lambda-managed-instances-similarity-engine</a></p><h3>The AWS Compute Continuum</h3><p>Before diving into the implementation, it helps to understand where Lambda Managed Instances fits in the AWS compute landscape. The options form a continuum from fully managed to fully self-managed:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/799/0*C9H-z6RqqTsfyibH.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*-qc3JgR_39qisFiA2QO67w.png" /></figure><p><strong>When to choose Lambda Managed Instances:</strong></p><ul><li>Sustained, predictable throughput (hundreds or thousands of requests per second)</li><li>Workloads that benefit from specific EC2 instance types (Graviton4, high-bandwidth networking)</li><li>Memory-intensive functions that exceed standard Lambda’s 10 GB limit or need configurable memory-to-vCPU ratios</li><li>Cost optimization at scale (10M+ invocations/month where EC2 pricing with Savings Plans beats per-GB-second)</li><li>Functions that load large datasets into memory and reuse them across requests (embeddings, models, reference data)</li></ul><p><strong>When standard Lambda is still better:</strong></p><ul><li>Bursty, unpredictable traffic patterns</li><li>Low to moderate throughput (standard Lambda’s per-invocation pricing wins)</li><li>Functions that need instant scaling (LMI scales asynchronously based on CPU utilization and execution-environment saturation; if traffic more than doubles within 5 minutes you may see throttles while capacity catches up)</li></ul><p>I’ve written about several of these compute options in previous projects. My <a href="https://darryl-ruggles.cloud/elastic-container-service-ecs-my-default-choice-for-containers-on-aws/">ECS deep dive</a> covers Fargate and ECS Express Mode. The <a href="https://darryl-ruggles.cloud/serverless-data-processor-using-aws-lambda-step-functions-and-fargate-on-ecs-with-rust/">Serverless Data Processor</a> demonstrates Step Functions with both Lambda and Fargate. My <a href="https://darryl-ruggles.cloud/powertools-for-aws-lambda-best-practices-by-default/">Powertools best practices</a> article covers the observability patterns used in this project.</p><h3>Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*nsUGrv9ag7bOcCC6.png" /></figure><p>The architecture has three layers:</p><p><strong>Capacity Provider</strong> — The foundation. Defines the VPC configuration, instance requirements (architecture, instance types), and scaling policies. <strong>Capacity providers define both the security boundary and the failure blast radius of your workload.</strong> All functions assigned to the same capacity provider share EC2 instances and must be mutually trusted. This uses container-based isolation, not Firecracker. A compromised function on a shared capacity provider can affect every other function on the same instances. Separate untrusted workloads, regulated workloads, and production from non-production into distinct capacity providers.</p><p><strong>Managed Instances</strong> — EC2 instances launched and managed by Lambda in your VPC. They’re visible in the EC2 console (tagged as managed by Lambda) but you don’t SSH into them, patch them, or configure autoscaling groups — Lambda handles all of that. The lifecycle includes a 14-day rotation for security compliance.</p><p><strong>Execution Environments</strong> — Containers running your function code on the managed instances. Each environment handles multiple concurrent requests. For Python, each concurrency slot is a separate process with its own memory space.</p><p><strong>Networking</strong> — VPC connectivity is mandatory. Without proper outbound connectivity, functions execute but logs and traces are silently lost. This project uses private subnets with a NAT Gateway for telemetry transmission and Bedrock API access. For production, consider VPC endpoints to keep traffic on the AWS network.</p><h3>Two-Level Concurrency</h3><p>This is what makes Lambda Managed Instances architecturally different from standard Lambda. There are two levels of parallelism:</p><p><strong>Level 1 — LMI manages for you:</strong> Multiple processes handle concurrent requests. Python’s LMI runtime spawns a separate process for each concurrency slot (default: 16 per vCPU). Each process has its own memory space, its own global variables, and its own boto3 clients. No shared mutable state between processes. Scaling decisions are based on both execution environment saturation and CPU utilization, not request count alone.</p><p><strong>Level 2 — You manage yourself:</strong> Within each request, you can use ThreadPoolExecutor to parallelize I/O operations. If your handler needs to search 5 product categories, you can search them in parallel rather than sequentially.</p><p>Combined, this means a single execution environment with 1 vCPU and 10 concurrent processes, each running 4 search threads, can have 40 category searches in flight concurrently. On standard Lambda, you’d need 10 separate execution environments to handle those 10 concurrent requests, each paying per-GB-second for its own copy of the catalog in memory.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*onESDPFLxSBd4WBL.png" /></figure><p>Each process receives a request, calls Bedrock to embed the query text, then fans out across categories using ThreadPoolExecutor. The catalog data (loaded from DynamoDB at process init) stays in memory across all requests handled by that process.</p><h3>Why LMI Instead of Standard Lambda</h3><p>This workload is a poor fit for standard Lambda and a strong fit for LMI. Here’s why:</p><p><strong>In-memory catalog at scale.</strong> Each process loads the product catalog with embedding vectors into memory at initialization. A 100K product catalog with 384-dimensional vectors is roughly 150 MB per process. With 10 concurrent processes, that’s 1.5 GB for catalog data alone. Standard Lambda’s maximum is 10 GB total, and you pay per-GB-second for every millisecond of that memory. LMI gives you up to 32 GB with configurable memory-to-vCPU ratios, and you pay EC2 instance pricing regardless of how much memory your function uses.</p><p><strong>Multi-concurrency amortizes catalog loading.</strong> On standard Lambda, 10 concurrent requests means 10 independent execution environments, each cold-starting and loading the catalog into its own memory, each paying per-GB-second. On LMI, those 10 requests run as 10 processes on one EC2 instance. The catalog loads once per process at init time and stays warm for all subsequent requests routed to that process. At sustained throughput, this eliminates the repeated cold-start penalty.</p><p><strong>Sustained throughput economics.</strong> A product recommendation API serving a storefront has predictable, sustained traffic — hundreds of requests per second during business hours. Each request involves a Bedrock API call for query embedding (I/O), cosine similarity across categories (CPU), and structured logging (I/O). At 10M+ invocations per month, EC2 pricing with Savings Plans is 60–72% cheaper than standard Lambda’s per-GB-second model.</p><p><strong>Configurable memory-to-vCPU ratio.</strong> This workload is memory-heavy (large catalog) with moderate CPU needs (vector math on 384 dimensions). The 4:1 memory-to-vCPU ratio gives 4 GB of memory per vCPU — enough for the catalog plus Bedrock client overhead. Standard Lambda locks you into a fixed ratio where more memory always means proportionally more CPU and higher cost.</p><h3>Why Not Fargate?</h3><p>This project could run on ECS Fargate. The handler logic would move into a FastAPI app, the catalog would load at container startup, and an ALB would handle routing. It would work fine. But the infrastructure footprint would be significantly larger:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1006/1*TfhrMNdRANUlxZ_cOYybFw.png" /></figure><p>For teams already comfortable with Lambda, LMI is the path of least resistance to get EC2 pricing and multi-concurrency without learning container orchestration. You keep the programming model you know and gain the hardware flexibility you need. The reverse is also true: <strong>for teams already invested in ECS, Fargate may remain the more operationally familiar choice</strong> — the muscle memory, dashboards, deployment pipelines, and on-call runbooks are already in place.</p><p>Where Fargate or EKS would be the better choice: custom native dependencies that exceed Lambda layer limits (PyTorch, large ML models), persistent connections (WebSocket, gRPC), specialized instance types not supported by LMI, or workloads that need the Kubernetes ecosystem. I covered Fargate patterns in my <a href="https://darryl-ruggles.cloud/elastic-container-service-ecs-my-default-choice-for-containers-on-aws/">ECS deep dive</a> and <a href="https://darryl-ruggles.cloud/dsql-kabob-store/">Kabob Store</a> projects. My <a href="https://darryl-ruggles.cloud/a-complete-terraform-setup-for-eks-auto-mode-is-it-right-for-you/">EKS Auto Mode</a> article covers Karpenter-based scaling.</p><p>One specific area where EKS with Karpenter is significantly more sophisticated: <strong>scaling down and cost optimization at idle.</strong> LMI’s scale-down is conservative — in my testing, 2 EC2 instances remained running overnight with zero traffic (1 per AZ). There’s no minimum instance setting, no consolidation, and no way to force scale-to-zero short of deleting the function version or capacity provider. Karpenter, by contrast, actively consolidates workloads onto fewer nodes, replaces larger instances with smaller ones when demand drops, and can use Spot instances for fault-tolerant workloads. If your traffic has significant idle periods (nights, weekends), this difference matters for cost. LMI’s simplicity comes at the price of less intelligent scaling.</p><h3>Setting It Up with Terraform</h3><p>The complete infrastructure is organized into four Terraform modules: networking, IAM, capacity provider, and Lambda. Every IAM policy follows least privilege, and the configuration follows the <a href="https://docs.aws.amazon.com/wellarchitected/latest/framework/welcome.html">AWS Well-Architected Framework</a> Security and Cost Optimization pillars. All resources use official HashiCorp providers (hashicorp/aws and hashicorp/archive where applicable) - no community modules or third-party providers.</p><p>For a fully production-hardened deployment, you’d also want to address the Reliability, Performance Efficiency, and Operational Excellence pillars more explicitly. The <a href="https://docs.aws.amazon.com/wellarchitected/latest/serverless-applications-lens/welcome.html">AWS Serverless Applications Lens</a> emphasizes thinking in concurrent requests, sharing nothing, designing for failures and duplicates, and using versions and aliases for safe reversible deployments. Concretely:</p><ul><li><strong>Multi-AZ deployment</strong> — subnets in at least two AZs (this demo does)</li><li><strong>Encryption at rest with customer-managed KMS keys</strong> — on the capacity provider (kms_key_arn on aws_lambda_capacity_provider), DynamoDB (server_side_encryption with kms_key_arn), and CloudWatch Logs (kms_key_id on the log group)</li><li><strong>VPC endpoints instead of NAT Gateway</strong> (covered later in this article)</li><li><strong>Invoke through an alias, not the published version directly</strong> — The demo invokes the qualified ARN of the published function version (function:name:1). For production, create an alias (prod, live, stable) pointing to a specific version and have callers invoke the alias ARN. Aliases enable instant rollback by updating one pointer, support traffic-shifting deployments (10% to a new version, then 50%, then 100%), and decouple caller code from version numbers.</li><li><strong>Idempotency for downstream side effects</strong> — <strong>Because Lambda may retry or duplicate events, handlers must remain idempotent — even when using long-lived in-memory state.</strong> The Powertools idempotency utility uses DynamoDB to deduplicate requests by a configurable key. For this similarity engine the Bedrock embedding call is a read operation and the only state change is logging, so idempotency is less critical. For handlers that write to DynamoDB, send notifications, or charge a payment, idempotency is essential because LMI’s at-least-once delivery semantics mean retries can produce duplicate side effects. The in-memory catalog is read-only and shared across requests, but any per-request state that produces side effects needs deduplication.</li><li><strong>CloudWatch alarms on LMI-specific metrics</strong> (covered in the Observability section)</li></ul><p>The demo includes the basics. The production hardening above is straightforward incremental work.</p><h3>Provider Configuration</h3><pre>terraform {<br>  required_version = &quot;&gt;= 1.11.0&quot;<br><br>  required_providers {<br>    aws = {<br>      # Validated with AWS provider v6.x (tested with 6.31+)<br>      source  = &quot;hashicorp/aws&quot;<br>      version = &quot;~&gt; 6.31&quot;<br>    }<br>    archive = {<br>      source  = &quot;hashicorp/archive&quot;<br>      version = &quot;~&gt; 2.7&quot;<br>    }<br>  }<br>}<br>provider &quot;aws&quot; {<br>  region  = var.aws_region<br>  profile = var.aws_profile  # Set via AWS_PROFILE env var or -var flag<br>  default_tags {<br>    tags = {<br>      Project     = &quot;lambda-managed-instances&quot;<br>      Environment = var.environment<br>      ManagedBy   = &quot;terraform&quot;<br>    }<br>  }<br>}</pre><p>The ~&gt; 6.31 constraint pins to the current stable major (6.31.0 at the time of writing) without locking too tightly. memory_size values above 10240 MB require hashicorp/aws 6.29.0 or later - earlier releases had a schema validator that capped memory_size at 10 GB even for LMI functions (fixed in #46065). Without a recent provider, attempting to set 16 GB or 32 GB on an LMI function fails at terraform plan with a confusing validation error.</p><h3>IAM: The Two-Role Model</h3><p>Lambda Managed Instances requires two separate IAM roles — a deliberate separation of concerns:</p><p><strong>Operator Role</strong> — Allows Lambda to manage EC2 instances on your behalf. Your function code never gets these permissions.</p><pre>resource &quot;aws_iam_role&quot; &quot;operator&quot; {<br>  name = &quot;${var.project_name}-${var.environment}-operator&quot;<br>  assume_role_policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [{<br>      Effect = &quot;Allow&quot;<br>      Principal = {<br>        Service = &quot;lambda.amazonaws.com&quot;<br>      }<br>      Action = &quot;sts:AssumeRole&quot;<br>      Condition = {<br>        StringEquals = {<br>          &quot;aws:SourceAccount&quot; = var.account_id<br>        }<br>      }<br>    }]<br>  })<br>}<br>resource &quot;aws_iam_role_policy_attachment&quot; &quot;operator&quot; {<br>  role       = aws_iam_role.operator.name<br>  policy_arn = &quot;arn:aws:iam::aws:policy/service-role/AWSLambdaManagedEC2ResourceOperator&quot;<br>}</pre><p><strong>Execution Role</strong> — Scoped to only what the function needs. No EC2 permissions, no wildcard resources. Bedrock access is limited to specific embedding model families.</p><pre># DynamoDB - least privilege: handler only does Query on the category-index GSI.<br># The seed script runs locally with the operator&#39;s credentials and uses its own<br># IAM identity for PutItem - not this role.<br>resource &quot;aws_iam_role_policy&quot; &quot;execution_dynamodb&quot; {<br>  name = &quot;dynamodb-access&quot;<br>  role = aws_iam_role.execution.id<br>  policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [{<br>      Effect = &quot;Allow&quot;<br>      Action = [&quot;dynamodb:Query&quot;]<br>      Resource = [<br>        var.dynamodb_table,<br>        &quot;${var.dynamodb_table}/index/*&quot;<br>      ]<br>    }]<br>  })<br>}<br># Bedrock - scoped to the specific configured embedding model<br>resource &quot;aws_iam_role_policy&quot; &quot;execution_bedrock&quot; {<br>  name = &quot;bedrock-embeddings&quot;<br>  role = aws_iam_role.execution.id<br>  policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [{<br>      Effect   = &quot;Allow&quot;<br>      Action   = [&quot;bedrock:InvokeModel&quot;]<br>      Resource = [<br>        &quot;arn:aws:bedrock:${var.aws_region}::foundation-model/${var.embedding_model_id}&quot;<br>      ]<br>    }]<br>  })<br>}</pre><p>The runtime function only needs dynamodb:Query because _load_catalog() queries the category-index GSI rather than scanning the table. No PutItem, no GetItem, no Scan. The seed script (scripts/seed_catalog.py) runs locally on the developer&#39;s machine with their own IAM identity - it never assumes the function execution role, so the runtime role doesn&#39;t need write permissions. The Bedrock policy is scoped to the exact model ARN configured via var.embedding_model_id, not a wildcard. This is what &quot;least privilege&quot; looks like when you actually walk through the code.</p><h3>Capacity Provider</h3><p>The capacity provider defines the EC2 infrastructure where your functions run. The scaling_mode = &quot;Manual&quot; with a target CPU utilization policy gives you control over scaling behavior while still letting Lambda handle the mechanics.</p><pre>resource &quot;aws_lambda_capacity_provider&quot; &quot;main&quot; {<br>  name = &quot;${var.project_name}-${var.environment}&quot;<br>  vpc_config {<br>    subnet_ids         = var.subnet_ids<br>    security_group_ids = [var.security_group_id]<br>  }<br>  permissions_config {<br>    capacity_provider_operator_role_arn = var.operator_role_arn<br>  }<br>  instance_requirements {<br>    architectures = [var.instance_architecture]  # &quot;arm64&quot; for Graviton<br>  }<br>  capacity_provider_scaling_config {<br>    max_vcpu_count = var.max_vcpu_count<br>    scaling_mode   = &quot;Manual&quot;<br>    scaling_policies = [{<br>      predefined_metric_type = &quot;LambdaCapacityProviderAverageCPUUtilization&quot;<br>      target_value           = var.target_cpu_utilization<br>    }]<br>  }<br>}</pre><p>The capacity provider supports two scaling modes: Auto and Manual. Auto mode is hands-off - Lambda picks an internal target CPU utilization and scales based on AWS-chosen defaults, with no explicit scaling_policies block needed. I chose Manual mode for this project because it lets me set an explicit target (50% in the demo config) so the scaling behavior is predictable and tunable. With a lower target, the capacity provider scales out faster and maintains more headroom for traffic bursts. For a production workload where you trust AWS to pick reasonable defaults, Auto mode is simpler and a valid choice.</p><h3>Lambda Function with Capacity Provider</h3><p>Four key differences from a standard Lambda function:</p><ol><li>capacity_provider_config attaches the function to LMI</li><li>publish = true is required - LMI runs on published versions</li><li>memory_size minimum is 2048 MB (2 GB / 1 vCPU)</li><li>execution_environment_memory_gib_per_vcpu controls the memory-to-vCPU ratio (new in March 2026)</li></ol><pre>locals {<br>  powertools_layer_arn = (<br>    var.instance_architecture == &quot;arm64&quot;<br>    ? &quot;arn:aws:lambda:${var.aws_region}:017000801446:layer:AWSLambdaPowertoolsPythonV3-python314-arm64:${var.powertools_layer_version}&quot;<br>    : &quot;arn:aws:lambda:${var.aws_region}:017000801446:layer:AWSLambdaPowertoolsPythonV3-python314-x86_64:${var.powertools_layer_version}&quot;<br>  )<br>  powertools_env_vars = {<br>    POWERTOOLS_SERVICE_NAME      = &quot;${var.project_name}-handler&quot;<br>    POWERTOOLS_METRICS_NAMESPACE = var.metrics_namespace<br>    POWERTOOLS_LOG_LEVEL         = var.log_level<br>  }<br>}<br>resource &quot;aws_lambda_function&quot; &quot;handler&quot; {<br>  function_name = &quot;${var.project_name}-${var.environment}-handler&quot;<br>  role          = var.execution_role_arn<br>  handler       = &quot;handler.lambda_handler&quot;<br>  runtime       = &quot;python3.14&quot;<br>  architectures = [var.instance_architecture]<br>  memory_size   = var.lambda_memory_size<br>  timeout       = 30<br>  publish       = true<br>  filename         = data.archive_file.function.output_path<br>  source_code_hash = data.archive_file.function.output_base64sha256<br>  layers = [local.powertools_layer_arn]<br>  capacity_provider_config {<br>    lambda_managed_instances_capacity_provider_config {<br>      capacity_provider_arn                    = var.capacity_provider_arn<br>      execution_environment_memory_gib_per_vcpu = var.memory_gib_per_vcpu<br>      per_execution_environment_max_concurrency = var.max_concurrency_per_environment<br>    }<br>  }<br>  logging_config {<br>    log_format            = &quot;JSON&quot;<br>    application_log_level = var.log_level<br>    system_log_level      = &quot;WARN&quot;<br>    log_group             = aws_cloudwatch_log_group.function.name<br>  }<br>  tracing_config {<br>    mode = &quot;Active&quot;<br>  }<br>  environment {<br>    variables = merge(local.powertools_env_vars, {<br>      DYNAMODB_TABLE      = aws_dynamodb_table.products.name<br>      ENVIRONMENT         = var.environment<br>      EMBEDDING_MODEL_ID  = var.embedding_model_id<br>      EMBEDDING_DIMENSION = tostring(var.embedding_dimension)<br>    })<br>  }<br>}</pre><p>The memory_gib_per_vcpu setting is powerful. LMI enforces a minimum of 1 vCPU per execution environment, so the ratio determines how much memory you get for that minimum. Examples at the 8 GB level:</p><ul><li><strong>2:1 ratio</strong> = 8 GB / 4 vCPUs (compute-heavy: batch processing, data crunching)</li><li><strong>4:1 ratio</strong> = 8 GB / 2 vCPUs (balanced: API handlers, typical workloads)</li><li><strong>8:1 ratio</strong> = 8 GB / 1 vCPU (memory-heavy: large in-memory datasets, caching)</li></ul><p>The product similarity engine uses 4 GB at 4:1–1 vCPU per environment, which is the smallest balanced configuration that fits the catalog plus 10 worker processes.</p><h3>A Note on Packaging Dependencies</h3><p>The Powertools layer is pinned to a specific version (minimum 3.23.0 — the first release that officially supports LMI). For everything else, follow AWS’s guidance for Python Lambda functions: package all dependencies, including boto3 and botocore, with the function rather than relying on the runtime&#39;s bundled copies. <strong>Even though boto3 is available in the runtime, package it with your function to avoid version drift.</strong> The runtime&#39;s boto3 is updated on AWS&#39;s schedule, not yours, and version drift between local development and the runtime can produce subtle bugs that are hard to reproduce. For production zip deployments, pip install --target build/ boto3 botocore and ship them in the zip. The demo uses the runtime&#39;s boto3 for simplicity, but production code should not.</p><h3>Multi-Concurrency by Language</h3><p>LMI supports five runtimes today: Python 3.13+, Node.js 22+, Java 21+, .NET 8+, and Rust on the OS-only runtime. All modern runtimes (Python 3.12+) are based on Amazon Linux 2023, replacing AL2 ahead of its June 2026 end-of-life. <strong>Every language handles multi-concurrency differently</strong>, and the differences matter — they change how you write the handler, what concurrency bugs you have to worry about, and how memory scales.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1008/1*ARR1SwTzlewGzw0C8AvlhQ.png" /></figure><p>Python is the <strong>simplest</strong> model because there’s no shared memory between concurrent requests. The trade-off is per-process memory multiplication. Java is the <strong>hardest</strong> because thread safety becomes a concern on every line that touches shared state. Rust is the <strong>safest</strong> because the compiler refuses to let you write non-thread-safe code in the first place.</p><p><strong>This blog focuses on the Python implementation.</strong> The patterns shown here (process isolation, ThreadPoolExecutor for parallel I/O within a request, memory tuning around per_execution_environment_max_concurrency) are specific to how Python&#39;s LMI runtime works. The architecture concepts (capacity providers, scaling, networking, IAM) apply identically across all five languages, but the handler code patterns would differ if you were writing in Java or Rust.</p><h3>The Python Handler</h3><p>Python’s LMI runtime uses multiple <strong>processes</strong> (not threads) for multi-concurrency. Each concurrent request runs in a separate process with its own memory space. Global variables, module-level caches, and boto3 clients are completely isolated between processes. This is simpler than the thread-based and async models above because there are no shared-memory concurrency concerns.</p><p>This blog uses Python 3.14, the newest supported version. Note that Lambda’s Python 3.14 ships with the JIT and free-threaded mode disabled, so the GIL is still in effect.</p><p>The one shared resource: /tmp. All processes in an execution environment share the same /tmp directory. Use request-scoped filenames to prevent collisions.</p><h3>Handler Structure with Powertools</h3><p>Following the <a href="https://darryl-ruggles.cloud/powertools-for-aws-lambda-best-practices-by-default/">Powertools best practices</a> pattern — Logger, Tracer, and Metrics decorators in the correct order:</p><pre>import json<br>import math<br>import os<br>from concurrent.futures import ThreadPoolExecutor, as_completed<br>import boto3<br>from aws_lambda_powertools import Logger, Metrics, Tracer<br>from aws_lambda_powertools.metrics import MetricUnit<br>from aws_lambda_powertools.utilities.typing import LambdaContext<br><br>logger = Logger()<br>tracer = Tracer()<br>metrics = Metrics()<br># Module-level init runs ONCE PER PROCESS.<br># With 10 concurrent processes, this runs 10 times.<br># Each process loads its own catalog copy and boto3 clients.<br>PROCESS_ID = os.getpid()<br>AWS_REGION = os.environ.get(&quot;AWS_REGION&quot;, &quot;us-east-1&quot;)<br>EMBEDDING_MODEL_ID = os.environ.get(<br>    &quot;EMBEDDING_MODEL_ID&quot;, &quot;amazon.nova-2-multimodal-embeddings-v1:0&quot;<br>)<br>EMBEDDING_DIMENSION = int(os.environ.get(&quot;EMBEDDING_DIMENSION&quot;, &quot;384&quot;))<br>dynamodb = boto3.resource(&quot;dynamodb&quot;, region_name=AWS_REGION)<br>table = dynamodb.Table(os.environ[&quot;DYNAMODB_TABLE&quot;])<br>bedrock_runtime = boto3.client(&quot;bedrock-runtime&quot;, region_name=AWS_REGION)<br>_catalog: dict[str, list[dict]] = {}<br><br>@tracer.capture_method<br>def _load_catalog() -&gt; None:<br>    &quot;&quot;&quot;Load product catalog once per process. Uses Query (least privilege).&quot;&quot;&quot;<br>    if _catalog:  # already loaded in this process<br>        return<br>    # ... query DynamoDB by category and populate _catalog ...<br><br>@logger.inject_lambda_context(log_event=True)<br>@tracer.capture_lambda_handler<br>@metrics.log_metrics(capture_cold_start_metric=True)<br>def lambda_handler(event: dict, context: LambdaContext) -&gt; dict:<br>    logger.append_keys(process_id=PROCESS_ID)<br>    _load_catalog()  # no-op after first call in this process<br>    # Extract params from event body or direct invocation<br>    body = event.get(&quot;body&quot;)<br>    params = json.loads(body) if isinstance(body, str) else (body or event)<br>    top_k = int(params.get(&quot;top_k&quot;, 5))<br>    # Step 1: Embed the query text via Bedrock (I/O-bound)<br>    query_embedding = _embed_query(params[&quot;query&quot;])<br>    # Step 2: Search categories in parallel (CPU-bound)<br>    results: dict = {}<br>    categories = params.get(&quot;categories&quot;, list(_catalog.keys()))<br>    with ThreadPoolExecutor(max_workers=4) as executor:<br>        futures = {<br>            executor.submit(_search_category, cat, query_embedding, top_k): cat<br>            for cat in categories<br>        }<br>        for future in as_completed(futures):<br>            results[futures[future]] = future.result()<br>    metrics.add_metric(name=&quot;SearchRequests&quot;, unit=MetricUnit.Count, value=1)<br>    return {&quot;statusCode&quot;: 200, &quot;body&quot;: json.dumps({&quot;results&quot;: results})}</pre><h3>Bedrock Embedding — Configurable Model</h3><p>The query text is embedded via Amazon Bedrock before similarity search. The model is configurable via the EMBEDDING_MODEL_ID environment variable - Nova Multimodal Embeddings by default, with Titan Text Embeddings V2 as an alternative:</p><pre>@tracer.capture_method<br>def _embed_query_nova(text: str) -&gt; list[float]:<br>    &quot;&quot;&quot;Nova Multimodal Embeddings request format.&quot;&quot;&quot;<br>    request_body = {<br>        &quot;taskType&quot;: &quot;SINGLE_EMBEDDING&quot;,<br>        &quot;singleEmbeddingParams&quot;: {<br>            &quot;embeddingPurpose&quot;: &quot;TEXT_RETRIEVAL&quot;,<br>            &quot;embeddingDimension&quot;: EMBEDDING_DIMENSION,<br>            &quot;text&quot;: {&quot;truncationMode&quot;: &quot;END&quot;, &quot;value&quot;: text},<br>        },<br>    }<br>    response = bedrock_runtime.invoke_model(<br>        body=json.dumps(request_body),<br>        modelId=EMBEDDING_MODEL_ID,<br>        accept=&quot;application/json&quot;,<br>        contentType=&quot;application/json&quot;,<br>    )<br>    response_body = json.loads(response[&quot;body&quot;].read())<br>    return response_body[&quot;embeddings&quot;][0][&quot;embedding&quot;]</pre><p>Product embeddings are generated at seed time using GENERIC_INDEX purpose and stored in DynamoDB alongside the product data. Query embeddings use TEXT_RETRIEVAL purpose at runtime. Nova supports 4 dimension sizes (256, 384, 1024, 3072) - trading off accuracy against memory and compute cost. The demo uses 384 dimensions as a practical balance.</p><h3>Cosine Similarity — The CPU Bottleneck</h3><p>The vector similarity computation is the compute-intensive core after the Bedrock call returns. For production, use NumPy — it’s 10–50x faster than a pure Python loop and releases the GIL during C-level operations, which makes the ThreadPoolExecutor pattern actually parallel:</p><pre>import numpy as np<br><br>def _cosine_similarity(query: np.ndarray, catalog: np.ndarray) -&gt; np.ndarray:<br>    &quot;&quot;&quot;Production version: batch operation across all products in a category.&quot;&quot;&quot;<br>    # query shape: (D,), catalog shape: (N, D)<br>    norms = np.linalg.norm(catalog, axis=1) * np.linalg.norm(query)<br>    return np.dot(catalog, query) / np.where(norms == 0, 1, norms)</pre><p>The pure Python version is included in the demo as an educational fallback (no NumPy dependency, easier to read):</p><pre>def _cosine_similarity_pure(vec_a: list[float], vec_b: list[float]) -&gt; float:<br>    &quot;&quot;&quot;Educational version: shows the math, no dependencies.&quot;&quot;&quot;<br>    dot = sum(a * b for a, b in zip(vec_a, vec_b))<br>    norm_a = math.sqrt(sum(a * a for a in vec_a))<br>    norm_b = math.sqrt(sum(b * b for b in vec_b))<br>    if norm_a == 0 or norm_b == 0:<br>        return 0.0<br>    return dot / (norm_a * norm_b)</pre><p>The handler code, the capacity provider, the Terraform — none of it would need to change to run on an instance type with hardware-accelerated vector operations. The capacity provider’s instance type selection is the only variable.</p><h3>Process Memory Multiplication</h3><p>This is the most important thing to understand about Python LMI. Each process loads its own copy of the catalog:</p><pre>10 concurrent processes x 200 MB catalog = 2 GB just for catalog data</pre><p>The MemoryUtilization CloudWatch metric tracks total memory consumption across all processes. If you&#39;re loading large datasets and running high concurrency, you&#39;ll hit memory limits. Tune with:</p><ul><li>Reduce PerExecutionEnvironmentMaxConcurrency (fewer processes, less memory)</li><li>Increase memory_size (more memory per environment)</li><li>Use 8:1 memory_gib_per_vcpu ratio (more memory, fewer vCPUs)</li><li>Use shared /tmp as a cross-process cache (load once, read from all processes)</li></ul><h3>Observability</h3><p>LMI publishes its own CloudWatch metrics in the AWS/Lambda namespace at 5-minute granularity. The capacity-provider-level metrics describe overall instance utilization; the execution-environment-level metrics describe per-function resource consumption.</p><p><strong>Capacity provider metrics</strong> (dimensions: CapacityProviderName, InstanceType):</p><ul><li>CPUUtilization - CPU usage across all instances in the capacity provider</li><li>MemoryUtilization - Memory usage across all instances</li><li>vCPUAllocated / vCPUAvailable - Used vs available vCPU count</li><li>MemoryAllocated / MemoryAvailable - Used vs available memory</li></ul><p><strong>Execution environment metrics</strong> (dimensions: FunctionName, CapacityProviderName, Resource):</p><ul><li>ExecutionEnvironmentConcurrency - Active concurrent requests per environment</li><li>ExecutionEnvironmentConcurrencyLimit - Configured maximum concurrency per environment</li><li>ExecutionEnvironmentCPUUtilization - CPU usage of this function&#39;s environments</li><li>ExecutionEnvironmentMemoryUtilization - Memory usage of this function&#39;s environments</li></ul><h3>Alarms to Set First</h3><p>If you only set three alarms when adopting LMI, set these:</p><ol><li><strong>Capacity provider CPU utilization</strong> — Alarm when sustained CPU exceeds your scaling target (e.g., &gt; 80% for 10 minutes if your target is 50%). This indicates the capacity provider is failing to scale out fast enough or has hit max_vcpu_count.</li><li><strong>Execution environment concurrency vs limit</strong> — Alarm when ExecutionEnvironmentConcurrency reaches ExecutionEnvironmentConcurrencyLimit for sustained periods. This means processes are saturated and incoming requests are being throttled or queued.</li><li><strong>Execution environment memory utilization</strong> — Alarm when memory exceeds 80%. With Python’s per-process memory multiplication, hitting memory limits causes new process spawns to fail (InitResourceExhausted) rather than gradual degradation. Catch this before it happens.</li></ol><p>These three cover the LMI-specific failure modes that standard Lambda alarms (Errors, Throttles, Duration) won’t catch.</p><h3>Deployment</h3><h3>Prerequisites</h3><ul><li>AWS CLI configured with a profile (export AWS_PROFILE=your-profile)</li><li>Terraform &gt;= 1.11</li><li>Python 3.14+ with boto3 (for the seed script)</li><li>Amazon Nova Multimodal Embeddings model enabled in your AWS account (Bedrock console, Model Access)</li></ul><h3>Deploy</h3><pre># Clone the repo<br>git clone https://github.com/RDarrylR/lambda-managed-instances-similarity-engine.git<br>cd lambda-managed-instances-similarity-engine<br><br># Configure<br>cp infrastructure/terraform.tfvars.example infrastructure/terraform.tfvars<br># Edit terraform.tfvars with your values<br># Deploy infrastructure<br>make init<br>make apply<br># Seed the product catalog<br>make seed<br># Invoke<br>make invoke</pre><h3>Cost Analysis</h3><p>Lambda Managed Instances pricing is fundamentally different from standard Lambda. Understanding when each model wins is the key decision.</p><p><strong>Standard Lambda pricing (arm64/Graviton):</strong></p><ul><li>$0.20 per million requests</li><li>$0.0000133334 per GB-second (arm64)</li><li>No minimum charge, no idle cost</li></ul><p><strong>Lambda Managed Instances pricing:</strong></p><ul><li>$0.20 per million requests (same)</li><li>EC2 on-demand instance pricing (varies by type)</li><li>15% management fee on the EC2 on-demand price</li><li><strong>No per-invocation duration charge</strong></li></ul><p>The critical difference: standard Lambda charges per GB-second of execution. LMI charges for EC2 time regardless of how many requests you serve. At low volume, you’re paying for idle EC2 capacity. At high volume, that fixed EC2 cost is amortized across millions of requests.</p><h4>Break-Even: Standard Lambda vs LMI</h4><p>Consider this workload: 4 GB memory, 200ms average duration, sustained traffic.</p><p><strong>Standard Lambda cost per request (arm64):</strong></p><ul><li>Compute: 4 GB x 0.2s = 0.8 GB-seconds x $0.0000133334 = $0.00001067</li><li>Request: $0.0000002</li><li><strong>Total: ~$0.0000109 per request</strong></li></ul><p><strong>LMI on a c7g.medium (1 vCPU, 2 GB, ~$0.034/hr on-demand):</strong></p><ul><li>EC2 + 15% fee: $0.034 x 1.15 = $0.0391/hr</li><li>With 10 concurrent processes and 200ms per request, each process handles ~5 req/sec</li><li>Instance throughput: ~50 req/sec = ~180,000 req/hr</li><li><strong>Cost per request: $0.0391 / 180,000 = ~$0.000000217</strong></li></ul><p>At this throughput, <strong>LMI is roughly 50x cheaper per request</strong> than standard Lambda. But the EC2 cost runs 24/7 whether you have traffic or not.</p><h4>Monthly Cost Comparison</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1012/1*gVCJ5XE8BR_ZvEPEgX5_sQ.png" /></figure><p>A single c7g.medium tops out around ~130M requests/month at 50 req/sec sustained. Beyond that, instance count scales roughly linearly with load — 500M req/month requires approximately 4 instances. The LMI columns reflect the actual instance count needed at each volume.</p><p>The break-even is around <strong>2.5M requests/month</strong> at this memory and duration profile. Below that, standard Lambda wins because you pay nothing when idle. Above that, LMI wins and the advantage grows with volume.</p><h4>Commitment Discounts Change the Math</h4><p>LMI supports EC2 Savings Plans and Reserved Instances. Standard Lambda supports Compute Savings Plans (up to 17% discount on duration). The discount gap is significant:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1004/1*k2EWUCJdmjWa9n7uMZcrzg.png" /></figure><p>For predictable production workloads with steady traffic, a 3-year commitment on LMI can reduce costs by 60% on the EC2 portion. Standard Lambda’s maximum discount is 17%. This difference widens the gap at scale.</p><h4>Hidden Costs</h4><p>Don’t forget the supporting infrastructure that LMI requires and standard Lambda doesn’t:</p><ul><li><strong>NAT Gateway</strong>: ~$32/month + $0.045/GB data transfer (required for VPC telemetry)</li><li><strong>VPC endpoints</strong> (if used instead of NAT): ~$7.20/month per endpoint per AZ</li><li><strong>DynamoDB</strong>: On-demand reads for catalog loading (minimal for small catalogs, significant at scale)</li><li><strong>Bedrock</strong>: Nova Multimodal Embeddings per-token pricing for each query embedding</li><li><strong>CloudWatch</strong>: Log storage and metric costs increase with concurrency</li></ul><p>For low-volume workloads, these fixed costs can exceed the compute savings. Factor them into your total cost of ownership.</p><h4>When Each Pricing Model Wins</h4><p><strong>Standard Lambda wins when:</strong></p><ul><li>Traffic is bursty or unpredictable (you pay nothing at zero traffic)</li><li>Monthly volume is below the break-even threshold (~2–3M requests for this workload profile)</li><li>You can’t commit to 1-year or 3-year terms</li><li>You don’t need VPC connectivity (avoids NAT Gateway cost)</li></ul><p><strong>LMI wins when:</strong></p><ul><li>Traffic is sustained and predictable (the EC2 cost is fully amortized)</li><li>Monthly volume exceeds 5–10M requests</li><li>You can commit to Savings Plans or Reserved Instances</li><li>You need more than 10 GB memory or specific instance types</li><li>You’re already paying for VPC infrastructure</li></ul><p>For this demo, expect to pay for:</p><ul><li>NAT Gateway (~$0.045/hour + data transfer)</li><li>EC2 instances (varies by type, auto-selected by Lambda)</li><li>DynamoDB on-demand reads (minimal for this catalog size)</li><li>Bedrock embedding calls (per-token pricing for each query)</li></ul><h3>CLEANUP (IMPORTANT!!)</h3><p><strong>This infrastructure costs real money while running — approximately $2–4/day even with zero traffic (NAT Gateway + EC2 managed instances). Don’t forget about it.</strong></p><p><strong>Make sure to destroy all resources when you’re done:</strong></p><pre>make destroy</pre><p>If the capacity provider fails to delete (it can take a few minutes to drain instances), wait and retry. Verify in the AWS console that no EC2 instances tagged with your project name are still running.</p><h3>Networking: Three Supported Patterns</h3><p>LMI requires VPC connectivity — the function execution environments need outbound network access for telemetry transmission and any AWS service calls. AWS documents three supported connectivity patterns:</p><ol><li><strong>Public subnets with an internet gateway</strong> — simplest, suitable for dev/test only</li><li><strong>Private subnets with NAT Gateway</strong> — the pattern this demo uses</li><li><strong>Private subnets with VPC endpoints</strong> — the most AWS-aligned production pattern</li></ol><h3>NAT Gateway (used in this demo)</h3><ul><li>Simple to set up — one resource, all outbound traffic routes through it</li><li>~$32/month base + $0.045/GB data transfer</li><li>Traffic leaves your VPC, crosses the public internet (encrypted), then re-enters AWS</li><li>Single point of failure unless you deploy one per AZ (~$64/month for 2-AZ HA)</li></ul><h3>VPC Endpoints (recommended for production)</h3><p>For production, the most AWS-aligned pattern is one VPC endpoint per service per AZ. Traffic stays entirely on the AWS network and never touches the public internet. The endpoint set must cover <strong>every</strong> service the function calls — if you forget one, the function fails silently or hangs. For this workload, that means:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1014/1*FQTV3jDoffbfWw_zrb2FBg.png" /></figure><p><strong>Critical security group detail:</strong> Interface endpoints have their own security groups. They must allow inbound HTTPS (port 443) from the function’s security group. The function security group must allow outbound HTTPS to the endpoint security groups. If you skip this, DNS resolves but the connection is silently blocked.</p><p><strong>Endpoints should be deployed in each AZ used by the capacity provider to avoid cross-AZ latency and data transfer costs.</strong> If your capacity provider has subnets in us-east-1a and us-east-1b, every interface endpoint also needs ENIs in both AZs. This is the same Cross-AZ Tax pattern from my <a href="https://darryl-ruggles.cloud/eks-and-the-cross-az-tax-how-to-stop-paying-aws-002gb-for-traffic-that-should-never-leave-your-availability-zone/">previous blog</a> - cross-AZ data transfer charges apply when traffic from a function in us-east-1a hits an endpoint ENI in us-east-1b. Provision endpoints per AZ to keep traffic local.</p><p><strong>Cost math:</strong> ~$7.20/month per interface endpoint per AZ. With 4 interface endpoints across 2 AZs, that’s ~$58/month — roughly double the single NAT Gateway, but cheaper than 2-AZ NAT Gateway HA. The DynamoDB gateway endpoint is free. At high data transfer volumes (more than ~900 GB/month through the NAT Gateway), endpoints become cheaper because there’s no per-GB data transfer surcharge for in-region traffic.</p><p><strong>When endpoints win on security:</strong> Always. Traffic never leaves the AWS network. You can attach endpoint policies to restrict which resources each endpoint can access (e.g., limit the Bedrock endpoint to specific model ARNs). This aligns with the <a href="https://docs.aws.amazon.com/wellarchitected/latest/framework/security.html">AWS Well-Architected Security Pillar</a> — minimize the attack surface.</p><p>The Terraform for VPC endpoints is straightforward but verbose. I left it out of this demo to keep the focus on LMI itself. A follow-up project could add a networking_mode variable that switches between NAT Gateway and VPC endpoints.</p><h3>A few things to watch for:</h3><ul><li><strong>VPC connectivity isn’t optional.</strong> Lambda Managed Instances requires a VPC. Without outbound connectivity (NAT Gateway or VPC endpoints), your function executes but logs and traces are silently lost. You’ll debug a working function with no visible output. This is documented but easy to miss.</li><li><strong>Scaling is asynchronous.</strong> LMI scales based on CPU utilization and execution-environment saturation, not per-invocation demand. <strong>Unlike standard Lambda, scaling isn’t triggered by incoming requests — it’s driven by resource consumption inside existing execution environments.</strong> <strong>Because scaling reacts to resource pressure instead of incoming traffic, inefficient code or high memory usage can delay scaling and increase throttling risk.</strong> The Scaler component decides when to add or remove instances, and instance launches aren’t instant. <strong>Lambda maintains headroom so traffic can roughly double within minutes without immediate throttling</strong>, but if your traffic more than doubles within 5 minutes, you may see 429 throttles while capacity catches up. This is fundamentally different from standard Lambda’s near-instant scaling. Plan for it with the target CPU utilization setting — lower values maintain more headroom.</li><li><strong>Process memory multiplies.</strong> With Python, each concurrency slot is a separate process. <strong>Because Python uses process-based concurrency, memory usage scales linearly with concurrency — each worker process consumes its own memory. With Python, concurrency isn’t “free” — each additional request increases memory consumption linearly.</strong> If your function uses 500 MB of memory and you set concurrency to 16, that’s 8 GB of memory consumed per execution environment. Monitor the MemoryUtilization metric and tune accordingly.</li><li><strong>publish = true is required.</strong> LMI runs on published function versions, not $LATEST. If you forget this, Terraform applies successfully but the function doesn&#39;t run on managed instances. Every code change needs a new published version.</li><li><strong>Capacity providers are security boundaries, not isolation boundaries.</strong> Functions sharing a capacity provider run in containers on the same EC2 instances. This isn’t Firecracker isolation. Separate untrusted workloads into separate capacity providers.</li><li><strong>Powertools minimum version matters.</strong> Lambda Managed Instances requires Powertools for AWS Lambda (Python) version 3.23.0 or later. Pin the layer version in Terraform rather than using latest.</li><li><strong>LMI doesn’t scale to zero.</strong> Unlike standard Lambda where you pay nothing at zero traffic, LMI keeps a baseline of warm EC2 instances running for high availability. <strong>AWS launches a baseline of three managed instances for availability across AZs</strong> when you publish a function version with a capacity provider. In my testing with 2 AZs configured, 2 instances remained active overnight with zero traffic, but the documented baseline is three. There’s no minimum instance setting, no Karpenter-style consolidation, and no way to force scale-to-zero short of deleting the function version or capacity provider. This is a meaningful cost difference for dev/test environments where you might leave infrastructure running between sessions. Run make destroy when you&#39;re not actively using the infrastructure, or design your dev environments to use standard Lambda where idle cost is zero.</li><li><strong>Quotas to plan around.</strong> LMI has its own <a href="https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html">service quotas</a>: 1 request per second on capacity provider write APIs (Create/Update/Delete — rate-limited to prevent infrastructure churn), 100 function versions per capacity provider, and 1,000 capacity providers per account per region. These are soft limits but worth knowing when you start automating capacity provider management or running multiple environments.</li></ul><h3>SAM Support</h3><p>If you came in from the AWS Serverless plugin angle and are wondering whether SAM supports LMI — yes, it does. AWS::Serverless::CapacityProvider is the SAM resource equivalent to aws_lambda_capacity_provider. The SAM template syntax is more concise but follows the same model: capacity provider definition, function with CapacityProviderConfig property, and IAM roles. I chose Terraform for this project because the LMI Terraform path is less documented in the wild and I wanted to fill that gap, but SAM is a perfectly valid choice if your team already uses it.</p><h3>Instance Type Selection</h3><p>The capacity provider’s instance_requirements block controls which EC2 instance types Lambda selects. By default, Lambda chooses the best fit automatically. You can constrain this with allowed_instance_types or excluded_instance_types.</p><p>Today, the interesting choice is between arm64 (Graviton4 - better price/performance for most workloads) and x86_64. But the architecture of Lambda Managed Instances - your function code running in containers on EC2 instances you specify - means the compute capabilities available to your functions expand with every new EC2 instance type AWS makes available for LMI.</p><p>The product similarity engine in this project calls Bedrock for query embeddings (I/O-bound) and then computes cosine similarity on CPU (compute-bound). The handler code isn’t coupled to a specific compute architecture. The embedding call is behind a clean interface (_embed_query). The similarity computation is pure math. The instance type is a configuration parameter, not an application concern.</p><p>This is the practical difference between Lambda Managed Instances and standard Lambda. Standard Lambda abstracts the hardware entirely — you get what AWS gives you. Lambda Managed Instances lets you choose, and that choice extends to whatever EC2 instance types AWS makes available.</p><h3>Wrapping Up</h3><p>Lambda Managed Instances fills the gap between standard Lambda and ECS Fargate. The handler function and event-driven invocation pattern stay the same, but you gain EC2 hardware selection, multi-concurrency, configurable memory-to-vCPU ratios, and commitment-based pricing.</p><p>The key decisions:</p><ul><li><strong>Use it for sustained, predictable throughput</strong> where EC2 pricing beats per-GB-second</li><li><strong>Choose your memory-to-vCPU ratio</strong> based on whether your workload is compute-bound or memory-bound</li><li><strong>Understand the process model</strong> for your language — Python uses processes (simple, no shared-memory concerns), Java uses OS threads (requires thread-safe code), Node.js uses worker threads with async dispatch, .NET uses Tasks, and Rust uses Tokio async tasks (handlers must be Clone + Send)</li><li><strong>Monitor </strong><strong>MemoryUtilization</strong> because process memory multiplies with concurrency</li></ul><p>The full Terraform configuration, Python handler, seed script, and Makefile are in the <a href="https://github.com/RDarrylR/lambda-managed-instances-similarity-engine">GitHub repository</a>.</p><h3>Resources</h3><ul><li><a href="https://docs.aws.amazon.com/lambda/latest/dg/lambda-managed-instances.html">Lambda Managed Instances Documentation</a></li><li><a href="https://docs.aws.amazon.com/lambda/latest/dg/lambda-managed-instances-python-runtime.html">Lambda Managed Instances — Python Runtime Guide</a></li><li><a href="https://aws.amazon.com/about-aws/whats-new/2026/03/lambda-32-gb-memory-16-vcpus/">32 GB Memory / 16 vCPU Announcement (March 2026)</a></li><li><a href="https://docs.aws.amazon.com/lambda/latest/dg/lambda-managed-instances-capacity-providers.html">Capacity Provider Documentation</a></li><li><a href="https://docs.aws.amazon.com/nova/latest/userguide/nova-embeddings.html">Amazon Nova Multimodal Embeddings</a> — Embedding model used in this project</li><li><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_capacity_provider">Terraform aws_lambda_capacity_provider</a></li><li><a href="https://darryl-ruggles.cloud/powertools-for-aws-lambda-best-practices-by-default/">Powertools for AWS Lambda Best Practices</a> — Observability patterns used in this project</li><li><a href="https://darryl-ruggles.cloud/elastic-container-service-ecs-my-default-choice-for-containers-on-aws/">Elastic Container Service — My Default Choice for Containers on AWS</a> — ECS Fargate and Express Mode comparison</li><li><a href="https://darryl-ruggles.cloud/serverless-data-processor-using-aws-lambda-step-functions-and-fargate-on-ecs-with-rust/">Serverless Data Processor</a> — Step Functions with Lambda and Fargate</li></ul><p><em>Connect with me on</em> <a href="https://x.com/RDarrylR">X</a><em>,</em> <a href="https://bsky.app/profile/darrylruggles.bsky.social">Bluesky</a><em>,</em> <a href="https://www.linkedin.com/in/darryl-ruggles/">LinkedIn</a><em>,</em> <a href="https://github.com/RDarrylR">GitHub</a><em>,</em> <a href="https://medium.com/@RDarrylR">Medium</a><em>,</em> <a href="https://dev.to/rdarrylr">Dev.to</a><em>, or the</em> <a href="https://community.aws/@darrylr">AWS Community</a><em>. Check out more of my projects at</em> <a href="https://darryl-ruggles.cloud/">darryl-ruggles.cloud</a> <em>and join the</em> <a href="https://www.believeinserverless.com/">Believe In Serverless</a> <em>community.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=481a7b6e4bd5" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Live Canary Deployments with AWS SAM, the New WebSocket API Resource, and Lambda Durable Functions]]></title>
            <link>https://medium.com/@RDarrylR/live-canary-deployments-with-aws-sam-the-new-websocket-api-resource-and-lambda-durable-functions-4b029533b34f?source=rss-7ecf33fd997------2</link>
            <guid isPermaLink="false">https://medium.com/p/4b029533b34f</guid>
            <category><![CDATA[serverless]]></category>
            <category><![CDATA[websocket]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[durable-functions]]></category>
            <dc:creator><![CDATA[Darryl Ruggles]]></dc:creator>
            <pubDate>Sat, 16 May 2026 16:36:47 GMT</pubDate>
            <atom:updated>2026-05-16T16:36:47.456Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*W-jaN6-Bd29GdksyyjulAg.png" /></figure><p>It’s great to see new serverless offerings all the time. Two recent serverless features pair unusually well: Lambda Durable Functions and SAM’s new first-class WebSocket API resource. On May 5, 2026, SAM gained AWS::Serverless::WebSocketApi, a resource type that turns the verbose dance of ApiGatewayV2::Api + Stage + Route + Integration + Lambda::Permission into a few lines of YAML.</p><p>Lambda Durable Functions also reached an important maturity point. They went GA in December 2025, have been supported in SAM CLI since v1.150.0, and now have an AWS <a href="https://aws.amazon.com/blogs/compute/best-practices-for-lambda-durable-functions-using-a-fraud-detection-example/">best-practices post</a>. They let a single Lambda handler run a multi-step workflow that can pause for hours (or days) waiting on a human callback without burning compute and costing you money. I covered the durable mechanics in detail in <a href="https://darryl-ruggles.cloud/aws-lambda-durable-functions/">AWS Lambda Durable Functions: Build a Loan Approval Workflow</a>; this post focuses on what changes when you pair them with a real-time UI.</p><blockquote>A quick disambiguation up front: “canary deployments with SAM” has traditionally meant SAM’s <a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/automating-updates-to-serverless-apps.html">DeploymentPreference</a> feature - CodeDeploy traffic-shifting on a Lambda alias, with built-in Canary10Percent5Minutes and Linear10PercentEvery1Minute types. This post isn&#39;t about that. It&#39;s about a custom canary pipeline built on Durable Functions, where the operator watches live baseline-vs-canary metrics and intervenes in real time. The two patterns are complementary - you could (and arguably should) use DeploymentPreference on the orchestrator itself once you ship this to production.</blockquote><p>These features are made for each other. WebSocket APIs without a long-running backend tend to collapse into chat-app demos. Durable Functions without a real-time UI mean operators stare at CloudWatch waiting for a workflow to finish. Put them together and you get a workflow the user can both observe and steer in real time, with checkpoint-and-replay handling failures behind the scenes.</p><p>I built the most universally relatable version of that pairing I could think of: a <strong>canary deployment pipeline</strong>. Submit a build artifact, watch it move through smoke tests, staging deploy, integration tests, and a canary rollout, then watch live baseline-vs-canary metrics during a configurable observation window. Promote to 100%, roll back, or extend the window — and the bidirectional WebSocket means the “roll back NOW” button is always one click away while the canary is still serving traffic. Every developer reading this has lived this exact moment. The orchestrator runs Python 3.14 on arm64 with <a href="https://docs.powertools.aws.dev/lambda/python/">Powertools for AWS Lambda</a>, IAM is scoped with SAM policy templates where they fit and targeted inline policies where the templates are too broad, and the whole stack deploys with a single sam deploy.</p><p>The full source — SAM template, Python handlers, React+Vite frontend, Makefile, and architecture diagram — is on GitHub: <a href="https://github.com/RDarrylR/live-canary-deploys-with-sam-the-new-websocket-api-and-durable-functions">live-canary-deploys-with-sam-the-new-websocket-api-and-durable-functions</a>.</p><h3>Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*rn6bwoOtS9OVGTIZ.png" /></figure><p>The flow is:</p><ol><li><strong>Browser submits the build artifact</strong> to POST /deploy on the HTTP API. The Start Deploy Lambda persists deployment metadata to DynamoDB and asynchronously kicks off the durable orchestrator, returning the deploymentId synchronously.</li><li><strong>Browser opens a WebSocket</strong> to wss://...execute-api..., fires the $connect route, and sends a subscribe frame naming the deploymentId.</li><li><strong>The orchestrator runs durable steps</strong>: smoke -&gt; staging deploy -&gt; integration tests -&gt; canary deploy. Each step is checkpointed; failures replay from the last checkpoint, not from scratch. Each stage streams its log lines over WebSocket so the pipeline tracker in the UI animates as the workflow progresses.</li><li><strong>Observation window</strong>: the orchestrator pauses on a durable wait_for_callback and asynchronously invokes a separate MetricsEmitter Lambda. The emitter streams synthetic baseline-vs-canary metrics (error rate, p50, p99, RPS) every 2 seconds via the Progress Publisher. The browser renders a side-by-side metrics dashboard with sparklines, threshold deltas, and a countdown timer.</li><li><strong>The operator decides</strong> — Promote / Roll back / Extend — by clicking a button that sends an intervene WebSocket frame. The Intervene Lambda completes the durable callback via lambda:SendDurableExecutionCallbackSuccess and the orchestrator resumes from exactly where it stopped. If the operator does nothing, the metrics emitter completes the callback at end-of-window with an auto decision based on the configured thresholds.</li><li><strong>Promote or rollback</strong>: the orchestrator runs the corresponding step (each emitting its own log lines to the UI), writes the final state to DynamoDB, and emits a completed event so the UI shows the final result.</li></ol><p>A single DynamoDB table (PK = DEPLOY#&lt;id&gt;, SK = META | CONN#&lt;connectionId&gt; plus a connectionId GSI) holds both the deployment state and the WebSocket subscriptions. To keep it simple, CloudFront and S3 hosting are deliberately absent: Vite serves the frontend on localhost:5173, the browser calls the AWS-hosted endpoints directly, and there&#39;s one fewer layer of infrastructure to learn.</p><p>The pipeline stages are intentionally simulated. Each step function (step_smoke_tests, step_deploy_staging, etc.) sleeps briefly and emits realistic-looking log lines. In a real deployment you&#39;d replace each body with calls to CodeDeploy / ECS service updates / Lambda alias shifting / your CI provider&#39;s API. The shape stays identical; only the leaves change.</p><p>Here is a shot of the sample app i built.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Ye7bERSYRMrPMbpW.png" /></figure><h3>SAM in 30 seconds</h3><p>If you’ve never used SAM before, the elevator pitch is: it’s a CloudFormation <strong>macro</strong>, not a separate tool. Add Transform: AWS::Serverless-2016-10-31 to a CloudFormation template and you can write resources like AWS::Serverless::Function that expand at deploy time into full CloudFormation - a Lambda function plus its IAM execution role, log group, event source mappings, version, alias, and so on. Anything you can write in vanilla CloudFormation still works. I am a big fan of using Terraform for IaC but SAM is my second favorite - especially when mostly dealing with AWS serverless resources.</p><p>The SAM CLI layers on developer tooling that vanilla CloudFormation lacks: local invoke, the sam sync --watch inner loop, log tailing, sample event generation, and pipeline scaffolding.</p><pre>Transform: AWS::Serverless-2016-10-31<br>Globals:<br>  Function:<br>    Runtime: python3.14<br>    Architectures: [arm64]<br>    Tracing: Active<br>    LoggingConfig:<br>      LogFormat: JSON<br>      ApplicationLogLevel: INFO<br>      SystemLogLevel: WARN<br>    Environment:<br>      Variables:<br>        POWERTOOLS_SERVICE_NAME: deploy-pipeline<br>        TABLE_NAME: !Ref DeploymentTable</pre><p>The Globals: block is one of SAM&#39;s nicest small features: every AWS::Serverless::Function in the template inherits these defaults. No copy-paste of Runtime: python3.14 across nine handlers. New runtimes (Python 3.14 was added in November 2025), structured JSON logging, table names - changing the global block changes them all.</p><p>For deeper SAM background and the broader IaC landscape, the <a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html">AWS SAM developer guide</a> is a good starting point. The Powertools setup used throughout this project — logger, tracer, metrics decorators in the right order, structured JSON logging, EMF metrics — is the same pattern I covered in <a href="https://darryl-ruggles.cloud/powertools-for-aws-lambda-best-practices-by-default/">Powertools for AWS Lambda Best Practices</a>. The rest of this post focuses on what’s new.</p><h3>The new WebSocket API resource</h3><p>Before May 2026, defining a WebSocket API in CloudFormation looked like this for the simplest possible case:</p><pre># ~70 lines, redacted for brevity<br>MyApi: { Type: AWS::ApiGatewayV2::Api, Properties: {...} }<br>ConnectRoute: { Type: AWS::ApiGatewayV2::Route, Properties: {...} }<br>ConnectIntegration: { Type: AWS::ApiGatewayV2::Integration, Properties: {...} }<br>ConnectPermission: { Type: AWS::Lambda::Permission, Properties: {...} }<br>DisconnectRoute: { ... }<br>DisconnectIntegration: { ... }<br>DisconnectPermission: { ... }<br>DefaultRoute: { ... }<br>DefaultIntegration: { ... }<br>DefaultPermission: { ... }<br>Stage: { Type: AWS::ApiGatewayV2::Stage, Properties: {...} }<br>Deployment: { Type: AWS::ApiGatewayV2::Deployment, Properties: {...} }</pre><p>Every basic Lambda-backed route needed three resources: Route, Integration, and Permission. Integration URIs needed manually constructed arn:aws:apigateway:.../invocations strings. Forgetting the Lambda::Permission resource on any route was an easy mistake to make: the route would exist, the integration would exist, the connection would even succeed, and route invocations would fail at runtime in a way that looks like an integration problem rather than a missing permission.</p><p>The new AWS::Serverless::WebSocketApi collapses all of that into:</p><pre>DeployWebSocketApi:<br>  Type: AWS::Serverless::WebSocketApi<br>  Properties:<br>    StageName: dev<br>    RouteSelectionExpression: &quot;$request.body.action&quot;<br>    Routes:<br>      &quot;$connect&quot;:<br>        FunctionArn: !GetAtt ConnectFunction.Arn<br>      &quot;$disconnect&quot;:<br>        FunctionArn: !GetAtt DisconnectFunction.Arn<br>      subscribe:<br>        FunctionArn: !GetAtt SubscribeFunction.Arn<br>      intervene:<br>        FunctionArn: !GetAtt InterveneFunction.Arn</pre><p>That’s the entire WebSocket API. Behind the scenes SAM still generates <a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification-generated-resources-websocketapi.html">the same set of CloudFormation resources</a> — one Api, one Stage, and per-route Route + Integration + Lambda::Permission - so anything you could do in raw CloudFormation you can still do here. You&#39;re trading verbosity for a smaller blast radius for typos.</p><p>A few things worth noting from the <a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-websocketapi.html">resource reference</a>:</p><ul><li><strong>RouteSelectionExpression is required.</strong> The conventional value is $request.body.action, which means: parse incoming messages as JSON and dispatch on the action field. The frontend sends { &quot;action&quot;: &quot;subscribe&quot;, &quot;deploymentId&quot;: &quot;...&quot; } and API Gateway routes it to the subscribe integration.</li><li><strong>The route property is </strong><strong>FunctionArn, not </strong><strong>Function.</strong> Easy to get wrong if you&#39;re paraphrasing the resource by analogy with other SAM event sources. Mistakes here fail at deploy time, not runtime.</li><li><strong>There is no documented </strong><strong>!GetAtt MyApi.ApiEndpoint.</strong> !Ref MyWebSocketApi returns the API ID, and !GetAtt MyWebSocketApi.Stage returns the generated stage&#39;s logical reference. To get the wss endpoint you construct it: wss://${MyApi}.execute-api.${AWS::Region}.amazonaws.com/${StageName}.</li></ul><h3>Authorization is $connect-only, and $connect auth alone isn&#39;t enough</h3><p>The SAM Auth block applies only to $connect. Valid AuthType values are NONE, AWS_IAM, and CUSTOM for a Lambda authorizer.</p><p>I left this as NONE in the demo for simplicity, but for this specific design that&#39;s a real leak vector worth naming. Any connected client can send {&quot;action&quot;:&quot;subscribe&quot;,&quot;deploymentId&quot;:&quot;...&quot;} and start receiving the live metrics and log stream for an arbitrary deployment ID. Worse, intervene is a control-plane action that promotes or rolls back a deploy.</p><p>$connect authentication proves <em>who</em> opened the socket. It doesn&#39;t prove that the caller is allowed to subscribe to a given deployment or send intervene for it.</p><p>Production deployments need:</p><ul><li>A Lambda authorizer on $connect that validates the caller. Browsers can&#39;t set arbitrary headers on the WebSocket upgrade, so the conventional carriers are a query-string token or the Sec-WebSocket-Protocol subprotocol header. Both can leak into request logs, devtools, reverse-proxy logs, and support captures.</li><li>A short-lived, audience-scoped WebSocket ticket minted by your backend after the user authenticates through your normal flow, instead of a long-lived bearer token in any of those carriers. The $connect authorizer should bind the validated claims to the connectionId so route handlers can look them up later.</li><li>Origin validation in the $connect authorizer. WebSocket APIs don&#39;t enforce CORS the same way fetch does, so the Origin header is your own perimeter check. Reject anything not from your expected frontend origin list.</li><li>Per-message authorization in every route handler. Store the authenticated principal alongside each connection record, and in subscribe and intervene, check the principal against the deployment owner, team, or environment before honoring the action. Reject unauthorized subscribe, promote, rollback, or extend messages with a 403-equivalent and log it.</li><li>AccessLogSettings on the stage, with a format that doesn&#39;t log raw tokens.</li><li>DefaultRouteSettings.ThrottlingBurstLimit and DefaultRouteSettings.ThrottlingRateLimit so a misbehaving client can&#39;t burn your connection-message budget.</li></ul><h3>Why not just SSE?</h3><p>The first question I asked myself was “do I actually need WebSockets?” Server-Sent Events would handle one-way progress streaming with much less infrastructure — a Lambda Function URL with RESPONSE_STREAM invoke mode, no connection store, no fan-out publisher. (Function URLs are public endpoints unless protected with IAM auth or your own application-level authorization layer; that&#39;s a separate piece of work either way.)</p><p>But this workflow needs a <em>return channel</em>, and a low-latency one. The operator’s “roll back NOW” decision has to land while the canary is still serving traffic — if a customer-impacting regression appeared at second 35 of a 60-second observation window, you don’t want to wait until the window ends and hope the auto-decision picks rollback. You want a button that triggers an immediate callback completion. SSE is one-way. You’d end up with SSE for metrics streaming <em>and</em> HTTP for the intervention, two different protocols, two different idle-connection lifecycles, two sets of error-handling. WebSockets give you one bidirectional channel for both.</p><p>The same logic applies to any workflow with <strong>bidirectional, low-latency interaction over long time horizons</strong>: collaborative editors, multi-agent systems where the human can re-prompt, interactive ML training where the operator can adjust hyperparameters mid-run, IoT control planes. Each of those becomes a tractable SAM application with WebSocketApi + Durable Functions; without one or the other, you’re either gluing together more services or polling.</p><h3>Sending messages back from Lambda</h3><p>The other half of the WebSocket story is how a Lambda function pushes a frame to a connection. API Gateway exposes a small management API — POST /@connections/{id} - and boto3 has a dedicated client for it:</p><pre>import boto3<br># domain and stage come from event[&#39;requestContext&#39;] inside any route handler,<br># or are constructed from the WEBSOCKET_API_ID env var elsewhere.<br>client = boto3.client(<br>    &quot;apigatewaymanagementapi&quot;,<br>    endpoint_url=f&quot;https://{api_id}.execute-api.{region}.amazonaws.com/{stage}&quot;,<br>)<br>client.post_to_connection(ConnectionId=connection_id, Data=b&#39;{&quot;hello&quot;:&quot;world&quot;}&#39;)</pre><p>The IAM action is execute-api:ManageConnections and the resource ARN format is arn:aws:execute-api:{region}:{account}:{api-id}/{stage}/POST/@connections/*. In the SAM template I scope this tightly:</p><pre>- Version: &#39;2012-10-17&#39;<br>  Statement:<br>    - Effect: Allow<br>      Action: execute-api:ManageConnections<br>      Resource: !Sub &#39;arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${DeployWebSocketApi}/dev/POST/@connections/*&#39;</pre><p>The Lambda needs <em>both</em> the apigatewaymanagementapi boto3 client (which is just an HTTP client pointed at a constructed endpoint URL) <em>and</em> an IAM policy granting execute-api:ManageConnections against the specific WebSocket API + stage. Wildcard either piece and you&#39;ll regret it during pen-test time.</p><p>The Progress Publisher Lambda also has to handle the case where a browser closed the tab between the moment the orchestrator decided to publish and the moment the publisher actually POSTed. API Gateway returns HTTP 410 Gone; boto3 surfaces this as GoneException. The publisher catches it and prunes the dead connection from DynamoDB:</p><pre>for connection_id in subscribers:<br>    try:<br>        client.post_to_connection(ConnectionId=connection_id, Data=frame)<br>        sent += 1<br>    except client.exceptions.GoneException:<br>        dead.append(connection_id)<br>if dead:<br>    remove_subscriptions(dead)</pre><h3>Lambda Durable Functions</h3><p>Standard Lambda has one execution environment per request and a 15-minute hard timeout. Step Functions handles long-running orchestration but at the cost of writing your business logic in Amazon States Language and paying per state transition. Durable Functions split the difference: you write the workflow as plain Python code in a Lambda handler, and the Lambda runtime handles the checkpoint-and-replay underneath. If you want the full mental model of how checkpoints, replays, and callbacks work, my <a href="https://darryl-ruggles.cloud/aws-lambda-durable-functions/">loan approval workflow post</a> walks through it from scratch with a different example.</p><p>Each call to context.step() is a checkpoint. If the underlying Lambda crashes, the runtime kills the environment for capacity, or your replay hits the 15-minute wall-clock, the runtime simply re-invokes the handler with the same execution ID. Recorded step results are returned without re-executing the body. From the developer&#39;s perspective, the function &quot;just resumes&quot; - even if &quot;resume&quot; means the wait was 23 hours and 59 minutes.</p><p>The replay model also means the orchestration body should stay <strong>deterministic</strong>. Generate UUIDs, read the clock, call external APIs, query DynamoDB, write to a file, or emit side effects <em>inside</em> @durable_step functions rather than inline in the orchestration path. Otherwise a replay can compute a different value (a new UUID, a different current timestamp, a now-different DynamoDB row) and take a different branch than the original execution, which defeats the point of checkpoint-and-replay. The orchestration body should read like a recipe of step calls plus branching on their recorded results, nothing else.</p><p>In SAM, you opt in by adding a DurableConfig block to an AWS::Serverless::Function:</p><pre>DeployOrchestratorFunction:<br>  Type: AWS::Serverless::Function<br>  Properties:<br>    Handler: handlers.deploy_orchestrator.handler<br>    Timeout: 900             # per-replay wall clock; the durable timeout is what matters<br>    AutoPublishAlias: live<br>    DurableConfig:<br>      ExecutionTimeout: 86400        # 24 hours, integer seconds (max 31622400 = 366 days)<br>      RetentionPeriodInDays: 30</pre><p>ExecutionTimeout is the outer hard limit on how long a single durable execution can live, in plain integer seconds (not ISO 8601 - early blog posts disagreed on this). RetentionPeriodInDays controls how long the execution history is kept after completion (1-90, default 14).</p><p>Supported runtimes today are python3.13, python3.14, nodejs22.x, nodejs24.x, java17, java21, and java25, plus container images.</p><h3>IAM for durable execution</h3><p>Two execution-role actions are required (these go on the orchestrator’s role, not the caller’s):</p><pre>Statement:<br>  - Effect: Allow<br>    Action:<br>      - lambda:CheckpointDurableExecution<br>      - lambda:GetDurableExecutionState<br>    Resource: !Sub &#39;${DeployOrchestratorFunction.Arn}:*&#39;</pre><p>AWS publishes a managed policy (AWSLambdaBasicDurableExecutionRolePolicy) that bundles these with CloudWatch Logs basics, but you can construct it inline with two lines. The :* suffix matches all published versions, since durable execution requires a published version (not $LATEST).</p><p>For services that need to <em>complete</em> a callback — the Intervene Lambda and the Metrics Emitter in this project — the actions are different:</p><pre>- Effect: Allow<br>  Action:<br>    - lambda:SendDurableExecutionCallbackSuccess<br>    - lambda:SendDurableExecutionCallbackFailure<br>    - lambda:SendDurableExecutionCallbackHeartbeat<br>  Resource: !Sub &#39;${DeployOrchestratorFunction.Arn}:*<br></pre><p>The heartbeat action is the pattern that makes the metrics emitter behave nicely when the operator interrupts; more on that below.</p><h3>The Python SDK</h3><p>The durable SDK is a separate PyPI package, not part of the runtime or Powertools. It’s installed via requirements.txt:</p><pre>aws-durable-execution-sdk-python&gt;=0.1.0</pre><p>The handler signature changes slightly: context is no longer a LambdaContext but a DurableContext, and the function is decorated with @durable_execution. Steps are decorated with @durable_step. Heads-up: a few of the config classes (Duration, WaitForCallbackConfig, etc.) aren&#39;t re-exported from the top-level package - import them from aws_durable_execution_sdk_python.config:</p><pre>from aws_durable_execution_sdk_python import (<br>    DurableContext, durable_execution, durable_step,<br>)<br>from aws_durable_execution_sdk_python.config import Duration, WaitForCallbackConfig<br># WaitForCallbackContext is a runtime-protocol type used to annotate the<br># submitter callable; it lives in .types, not .config.<br>from aws_durable_execution_sdk_python.types import WaitForCallbackContext<br><br>@durable_step<br>def step_smoke_tests(_ctx, deployment_id, artifact):<br>    # Each &quot;stage&quot; is a durable step. The body emits log lines through the<br>    # Progress Publisher and sleeps to simulate real work; in a real deploy<br>    # this would call CodeDeploy / ECS / your CI provider.<br>    publish_log(deployment_id, &quot;smoke&quot;, f&quot;pulling artifact {artifact[:12]}&quot;)<br>    publish_log(deployment_id, &quot;smoke&quot;, &quot;/health 200 ok&quot;)<br>    return {&quot;passed&quot;: True}<br>@durable_execution<br>def handler(event, context: DurableContext):<br>    deployment_id = event[&quot;deploymentId&quot;]<br>    context.step(step_smoke_tests(deployment_id, event[&quot;artifact&quot;]))<br>    context.step(step_deploy_staging(...))<br>    context.step(step_integration_tests(...))<br>    context.step(step_deploy_canary(...))<br>    decision = context.wait_for_callback(register_observation, ...)<br>    if decision == &quot;promote&quot;:<br>        context.step(step_promote(...))<br>    else:<br>        context.step(step_rollback(...))<br>    context.step(step_finalize(...))</pre><p>Each @durable_step-decorated function takes a StepContext as its first argument; context.step(step_smoke_tests(deployment_id, artifact)) calls the wrapper to produce a curried Callable[[StepContext], T], and the durable runtime then calls <em>that</em> with a real StepContext. Once you see the pattern, all the parallel/map/wait helpers follow it consistently.</p><h3>The killer feature: callbacks</h3><p>This is the single most useful primitive Durable Functions adds to the Lambda model. We have callback with Step Functions but it’s great to have these now with Lambda!context.wait_for_callback() registers an external completion point and <strong>pauses the workflow without paying for compute</strong> while waiting:</p><pre>def register_observation(callback_id: str, _ctx) -&gt; None:<br>    # Persist the callback ID so the Intervene Lambda can find it.<br>    store_callback_id(deployment_id, callback_id)<br>    # Kick off the metrics emitter as an async fire-and-forget invoke.<br>    # It will stream metrics_tick events to subscribers and complete this<br>    # callback at end-of-window with an auto decision (or stop early if<br>    # the operator beats it to the punch).<br>    invoke_metrics_emitter(deployment_id, callback_id, observation_seconds)<br>    publish(deployment_id, {&quot;type&quot;: &quot;stage&quot;, &quot;stage&quot;: &quot;observation&quot;, &quot;status&quot;: &quot;running&quot;})<br><br>decision_payload = context.wait_for_callback(<br>    register_observation,<br>    name=&quot;canary-observation&quot;,<br>    config=WaitForCallbackConfig(<br>        timeout=Duration.from_seconds(observation_seconds + 120)<br>    ),<br>)</pre><p>Duration is a frozen dataclass with seconds: int and a family of factory classmethods (from_seconds, from_minutes, from_hours, from_days). The keyword form Duration(seconds=N) works too because it&#39;s the underlying field, but the factory methods are the canonical pattern shown in the SDK examples and read better for non-trivial durations: Duration.from_hours(24) is unambiguous; Duration(seconds=86400) makes a reader do mental math.</p><p>The submitter’s signature is (callback_id: str, ctx: WaitForCallbackContext) -&gt; None. The runtime calls it once with a fresh callback_id, the function persists that ID and notifies the outside world (here: kicks off the metrics emitter and tells the UI), and wait_for_callback blocks until something completes the callback via the Lambda API.</p><p>In this project there are <em>two</em> parties racing to complete the same callback:</p><ul><li>The <strong>operator</strong> clicks Promote / Roll back / Extend in the UI. The Intervene Lambda receives the WebSocket frame, looks up the callback ID, and calls lambda:SendDurableExecutionCallbackSuccess with the chosen decision.</li><li>The <strong>metrics emitter</strong> runs the observation window to completion (e.g. 60 seconds), evaluates the canary metrics against the configured thresholds, and calls the same API with an auto decision (&quot;promote&quot; if metrics stayed within thresholds, &quot;rollback&quot; if they didn&#39;t).</li></ul><p>First call wins. The orchestrator’s wait_for_callback returns whatever payload was passed, and execution continues from there.</p><h3>Heartbeating to detect superseded callbacks</h3><p>When the operator clicks <strong>Extend observation</strong>, the orchestrator gets decision = &quot;extend&quot;, registers a <em>new</em> callback, and invokes a <em>new</em> metrics emitter for the extension window. But the <em>original</em> metrics emitter is still running - Lambda invocations can&#39;t be cancelled mid-flight. Without intervention, both emitters publish ticks for the same deployment ID for the next ~60 seconds, and the dashboard shows two timers and conflicting metrics.</p><p>The fix is a heartbeat at the top of every tick:</p><pre>while time.time() &lt; deadline:<br>    try:<br>        _lambda_client.send_durable_execution_callback_heartbeat(<br>            CallbackId=callback_id<br>        )<br>    except _lambda_client.exceptions.ClientError as exc:<br>        # CallbackTimeoutException covers both &quot;no longer pending&quot; cases:<br>        # the callback was completed by another party (operator clicked<br>        # extend/rollback/promote, or a sibling emitter), or its heartbeat<br>        # timeout expired. Either way, we should stop emitting.<br>        logger.info(&quot;callback no longer pending; stopping emitter early&quot;)<br>        return<br>    publish_tick(...)<br>    time.sleep(2)</pre><p>The exception name is a bit misleading — CallbackTimeoutException is documented as the timeout-expiry signal, but in practice it&#39;s also what surfaces when the callback was already completed by another caller (I caught this on a SendDurableExecutionCallbackSuccess race in testing - same exception, message &quot;The callback is either timed out or already completed&quot;). Catching it covers both supersede paths. The result: the moment the operator clicks Extend, the next heartbeat from the <em>old</em> emitter raises and the old emitter exits cleanly. Only the new extension emitter continues. This is the durable equivalent of &quot;send a cancellation token down&quot; and it costs essentially nothing.</p><p>An image of the pipeline in action is below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*DVckBGub0Quri-hJ.png" /></figure><h3>Step Functions vs Durable Functions</h3><p>Worth a quick comparison since teams will reach for both. <strong>Step Functions</strong> is mature, has a visual editor, supports JSONata for transforms (added at re:Invent 2024; AWS now recommends it for transformation-heavy workflows, though JSONPath remains supported and widely used), and is the right answer for state machines that span Lambda + ECS + Bedrock + SQS + SNS with no application code. I built a version of that pattern in <a href="https://darryl-ruggles.cloud/serverless-data-processor-using-aws-lambda-step-functions-and-fargate-on-ecs-with-rust/">Serverless Data Processor with Step Functions, Lambda, and Fargate (Rust)</a> — that workflow has multiple compute backends and a clean state-machine shape, which Step Functions handles well.</p><p><strong>Durable Functions</strong> is the right answer when:</p><ul><li>The workflow logic <em>is</em> code you’d want to write anyway (loops, conditionals, branching on metrics, optional extension windows that recurse into more callbacks)</li><li>You want callbacks measured in minutes-to-days without paying State Transitions costs — the canary observation window in this project is exactly that pattern</li><li>The team already lives in Python/Node.js/Java and doesn’t want to maintain ASL</li></ul><p>A canary deploy with operator intervention is a borderline case. Step Functions can absolutely express it — the wait-for-callback / Task-with-task-token pattern has been around for years. But in practice the deploy logic ends up being half ASL and half code, with the branching rules (“if errors &gt; threshold then rollback else if extend then loop”) split awkwardly between the two. Writing it as one Python function with if statements and wait_for_callback reads like the workflow you&#39;d describe to a colleague.</p><h3>What SAM CLI brings to the table</h3><p>The CLI is the half of SAM that turns a CloudFormation template into a tight inner loop. Worth calling out the commands I actually use day-to-day on this project:</p><ul><li>sam validate --lint: Schema plus cfn-lint pass over the template; runs in seconds.</li><li>sam build: Bundles each function&#39;s CodeUri with its requirements.txt into .aws-sam/build/.</li><li>sam deploy --guided: First-time interactive deploy that writes samconfig.toml.</li><li>sam deploy: Subsequent deploys; uses the parameters in samconfig.toml.</li><li>sam sync --watch: Dev inner loop; code-only changes deploy in seconds via service APIs, bypassing CloudFormation.</li><li>sam logs --tail --name MyFunc: Live tail of one function&#39;s logs.</li><li>sam local invoke MyFunc --event events/foo.json: Run a function locally in Docker or Finch.</li><li>sam local invoke MyFunc --durable-execution-name local-1: Run a function locally as a durable execution.</li><li>sam local execution history &lt;name&gt;: Inspect the durable runtime&#39;s view of a workflow&#39;s steps.</li><li>sam local callback succeed &lt;id&gt;: Manually complete a paused callback during local testing.</li><li>sam local generate-event s3 put: Emit sample event payloads; also supports services such as SQS, SNS, EventBridge, Kinesis, and Cognito.</li><li>sam pipeline init: Generate CI/CD pipeline configs for CodePipeline, GitHub Actions, GitLab, Jenkins, and Bitbucket.</li></ul><p>For reference, samconfig.toml for this project is six lines of parameter overrides plus the standard scaffolding. The first sam deploy --guided writes this for you; subsequent sam deploy runs use it without prompting:</p><pre>version = 0.1<br>[default.global.parameters]<br>stack_name = &quot;canary-deploy&quot;<br>region = &quot;us-east-1&quot;<br><br>[default.deploy.parameters]<br>profile = &quot;blog_admin&quot;<br>capabilities = &quot;CAPABILITY_IAM&quot;<br>confirm_changeset = false<br>fail_on_empty_changeset = false<br>resolve_s3 = true<br>parameter_overrides = &quot;Environment=\&quot;dev\&quot; AllowedOrigin=\&quot;http://localhost:5173\&quot; ObservationSeconds=\&quot;60\&quot; DurableExecutionTimeoutSeconds=\&quot;86400\&quot;&quot;</pre><p>The big one in real-world use is sam sync --watch. A standard sam deploy runs a CloudFormation changeset, which takes 30-90 seconds even when the only change is one line of Python. sam sync differentiates code vs. infrastructure changes: code changes are deployed directly via lambda:UpdateFunctionCode, taking 2-3 seconds. Infrastructure changes (a new resource, a changed property) still go through CloudFormation. The trade-off is that sam sync introduces drift between CloudFormation&#39;s view of the stack and reality - never use it on a production stack, but for local development it&#39;s transformative.</p><p>A few smaller things landed in <strong>SAM CLI v1.156.0</strong> (March 2026) that this project uses:</p><ul><li><strong>.env file format support</strong> for --env-vars. You can now write KEY=value lines instead of the old JSON envelope.</li><li><strong>Route-specific CORS</strong> on AWS::Serverless::HttpApi - useful for the demo&#39;s localhost dev origin without opening up production routes.</li><li><strong>BuildKit support for container image builds</strong> — not used here (zip is simpler) but a meaningful win for teams packaging Lambdas as containers.</li><li><strong>Rust cargo-lambda graduated from experimental to stable</strong> — separate story, covered in <a href="https://aws.amazon.com/blogs/compute/optimizing-compute-intensive-serverless-workloads-with-multi-threaded-rust-on-aws-lambda/">Daniel Abib’s multi-threaded Rust on Lambda post</a>.</li></ul><h3>Policy templates over Connectors</h3><p>A note on IAM. SAM offers two abstractions for granting permissions: <a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-policy-template-list.html">policy templates</a> (named, scoped policies like DynamoDBCrudPolicy) and <a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-connector.html">Connectors</a> (declarative Read/Write semantics between resources). Jeremy Daly&#39;s <a href="https://www.jeremydaly.com/getting-abstractions-wrong-with-aws-sam-serverless-connectors/">critique of Connectors</a> is still the most coherent position on this: the Read/Write semantics are confusing (e.g., Write on DynamoDB enables deletions, Read on SQS only receives but you need Write to delete after processing), and they don&#39;t compose well across nested or multi-stack architectures. For this project, policy templates are easier to reason about because each function&#39;s permissions are visible right where the function is defined. Note that template names like DynamoDBCrudPolicy include deletes too - &quot;least privilege&quot; with the CRUD template still grants more than a strict read-only function needs, so I fall back to inline policies for the few cases where the templates are too broad (the execute-api:ManageConnections grant scoped to a specific API and stage, the durable-callback grants, etc.).</p><p>This project uses policy templates where they fit, plus targeted inline policies for the permissions the templates don’t cover:</p><pre>Policies:<br>  - DynamoDBWritePolicy:<br>      TableName: !Ref DeploymentTable<br>  - LambdaInvokePolicy:<br>      FunctionName: !Sub &#39;${AWS::StackName}-ProgressPublisher&#39;<br>  - Version: &#39;2012-10-17&#39;      # inline for the cases templates don&#39;t cover<br>    Statement:<br>      - Effect: Allow<br>        Action: execute-api:ManageConnections<br>        Resource: !Sub &#39;arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${DeployWebSocketApi}/dev/POST/@connections/*&#39;</pre><p>Every Lambda has its own role. The Start Deploy function can write to DynamoDB and invoke the orchestrator — that’s it. The Intervene function can read DynamoDB and complete durable callbacks — that’s it. The Progress Publisher can read DynamoDB, prune subscriptions, and POST to WebSocket connections — that’s it. The blast radius of any one function being compromised stays small.</p><h3>Things to watch for</h3><p>A real list of things I tripped over while building this. Most aren’t obvious until you hit them.</p><ul><li><strong>Use a recent SAM CLI.</strong> AWS::Serverless::WebSocketApi support landed in 1.159.1 (released April 28, 2026, ahead of the public AWS announcement on May 5). I verified this template against 1.159.1. As of mid-May 2026, 1.160.0 is the current release - use that or newer unless you have a reason to pin. Older versions (the Durable Functions launch 1.150.x, the post-BuildKit 1.156.0) don&#39;t know the new resource type and sam deploy fails at changeset creation with the unhelpful Transform AWS::Serverless-2016-10-31 failed with: Internal transform failure. Upgrade with pip3 install --user --upgrade aws-sam-cli (or grab the packaged installer from the latest <a href="https://github.com/aws/aws-sam-cli/releases/latest">AWS SAM CLI GitHub release</a>) and confirm with sam --version. Expected: SAM CLI, version 1.160.0 or newer.</li><li><strong>StageName on </strong><strong>WebSocketApi must be a literal string in SAM 1.159.1.</strong> Any intrinsic - !Ref Environment, !Sub &quot;${Environment}&quot; - trips a TypeError deep inside the SAM transform&#39;s per-route Lambda permission generator: Error transforming template: can only concatenate str (not &quot;dict_node&quot;) to str. The traceback points at samtranslator/model/api/websocket_api_generator.py line 299 (_construct_permission); the constructor builds the permission&#39;s SourceArn by string concatenation with StageName and chokes on the dict node. The workaround in this template is hard-coding StageName: dev and matching it in the Lambda env var (WEBSOCKET_STAGE: dev) and the Outputs. Per-environment parameterization for the rest of the stack still works through the Environment parameter; only the WebSocket stage name has to be a constant. Reported in SAM 1.159.1; if you&#39;re on a later release, try the intrinsic form first and drop back to the literal only if you hit the same traceback.</li><li><strong>AutoPublishAlias only republishes on code changes, not env-var-only changes.</strong> This bites hard with durable functions because each version freezes its environment variables. If you change a config value but don&#39;t touch any Python file, sam deploy updates $LATEST but doesn&#39;t publish a new version - the alias keeps pointing at the old version with the stale env var. Verify with aws lambda get-alias --function-name X --name live --query FunctionVersion and force-publish with aws lambda publish-version + aws lambda update-alias if the version is older than your env-var change. A practical workaround if you control the function code: change a Description field or bump a comment in the Python file at the same time as the env var change; SAM treats that as a code change and republishes.</li><li><strong>wait_for_callback returns the </strong><strong>Result field as a raw string, not a parsed object.</strong> Both the Intervene Lambda and the Metrics Emitter pass Result=json.dumps({...}) to SendDurableExecutionCallbackSuccess. The orchestrator&#39;s wait_for_callback returns that string verbatim. Forgetting this gives you TypeError: string indices must be integers, not &#39;str&#39; the moment you try result[&quot;decision&quot;]. The quick fix is a one-liner: if isinstance(decision_payload, str): decision_payload = json.loads(decision_payload). The more idiomatic fix is to configure a JSON SerDes on WaitForCallbackConfig (and on the matching CallbackConfig of the sender) so the SDK handles round-tripping for you; CallbackConfig and WaitForCallbackConfig both expose a serdes field. The demo uses the manual parse to keep the dependency surface minimal and the failure mode visible, but for production code the SerDes path is cleaner.</li><li><strong>Watch for circular dependencies.</strong> Anything in Globals.Function.Environment that uses !Ref DeployWebSocketApi creates a dependency from <em>every</em> function (including the four route-handler functions the API references back) to the API itself. SAM rejects this. The same trap exists for any IAM policy whose Resource is !Sub &#39;...${DeployWebSocketApi}/...&#39; if that policy is on a function in the API&#39;s Routes map. The fix in this template is to keep WEBSOCKET_API_ID only on ProgressPublisherFunction, which isn&#39;t in the routes map. The publisher and metrics emitter also get explicit FunctionName values based on ${AWS::StackName}, so the orchestrator can reference them as strings (PUBLISHER_NAME: !Sub &#39;${AWS::StackName}-ProgressPublisher&#39;) instead of with !Ref ProgressPublisherFunction. The ${AWS::StackName} pseudo-parameter has no resource dependency, so the loop never forms.</li><li><strong>Depending on your cfn-lint version, cfn-lint may still complain about the new resource types.</strong> Until its schema catches up in your environment, expect E3006 on AWS::Serverless::WebSocketApi and E3002 on DurableConfig. Suppress them at the resource level with the Metadata.cfn-lint.config.ignore_checks pattern shown in template.yaml. SAM CLI 1.159.1 accepts both correctly; newer releases should as well.</li><li><strong>WaitConfig doesn&#39;t exist in the durable SDK.</strong> The naming I&#39;d seen referenced in early write-ups didn&#39;t match the published package. The real types are WaitForCallbackConfig (extends CallbackConfig) and Duration, both in aws_durable_execution_sdk_python.config (not re-exported from the top-level). Submitter callable is (callback_id: str, ctx) -&gt; None, not (callback_id) -&gt; None. Importing the wrong name throws Runtime.ImportModuleError at Lambda cold start and the orchestrator never runs. That can be silent from the user&#39;s perspective because lambda_client.invoke(InvocationType=&quot;Event&quot;) returns 202 once Lambda accepts the event for asynchronous invocation, even though the handler may fail later during cold start. Always tail the orchestrator&#39;s CloudWatch log group when wiring up a durable function for the first time.</li><li><strong>The Powertools v3 Lambda layer for </strong><strong>python314-arm64 isn&#39;t published yet.</strong> As of 2026-05, AWS publishes AWSLambdaPowertoolsPythonV3-python313-arm64 (latest version 30, Powertools 3.26.0) but not the python3.14 variant. This is the kind of thing that changes quickly; check again before publishing or before copying this template into a long-lived production repo. Confirm yourself with aws lambda list-layer-versions --layer-name AWSLambdaPowertoolsPythonV3-python314-arm64 --region us-east-1. Expected result today: &quot;LayerVersions&quot;: []. If you point your template at a layer ARN that doesn&#39;t exist, sam deploy rolls back at function-creation time with lambda:GetLayerVersion AccessDenied (the layer&#39;s resource-based policy doesn&#39;t grant access because the layer doesn&#39;t exist - a misleading error). The fix here is to install Powertools via src/requirements.txt so each function bundles its own copy. Adds ~5 MB to each function package; cheap insurance until the layer ships.</li><li><strong>Async fan-out tasks need to know when their callback has been superseded.</strong> The metrics emitter spawned at the start of the observation window keeps running until its window expires — even if the operator clicks Extend, completes the original callback, and a fresh emitter has already been launched for the extension window. Without intervention you get two emitters streaming overlapping metrics_tick events to the same UI. The cleanest fix is the heartbeat pattern shown earlier: lambda:SendDurableExecutionCallbackHeartbeat at the top of every tick raises CallbackTimeoutException the moment someone else completes the callback, and the emitter exits early.</li><li><strong>Async invokes can fail silently before durable replay can save you.</strong> lambda_client.invoke(InvocationType=&quot;Event&quot;, ...) returns 202 once Lambda accepts the event for asynchronous invocation. That doesn&#39;t mean the target handler actually ran successfully. If the async invoke later fails because of throttling, handler errors, bad payload shape, or runtime/import problems, the orchestrator may never make visible progress and the user can see a frozen UI. The fix is an async-invoke destination (OnFailure -&gt; SQS or EventBridge) or a Lambda DLQ on the orchestrator. In SAM, configure that with EventInvokeConfig on the function: set MaximumRetryAttempts, then add a DestinationConfig.OnFailure target such as an SQS queue ARN. Skipped in the demo for brevity; not skippable in production.</li><li><strong>API Gateway WebSocket doesn’t enforce a fixed concurrent-connection quota.</strong> The practical ceiling is shaped by the new-connections-per-second quota (500 per account per Region by default), the 2-hour maximum connection duration, and the 10-minute idle timeout. AWS’s own example: 500 new connections per second sustained over the 2-hour window can support up to 3.6M concurrent connections. For most canary dashboards this isn’t the first limit you hit, but reconnect storms (browser refreshes during a multi-environment deploy) and large internal audiences can still justify a quota-increase request on new-connection rate. Long-lived dashboards also need application-level ping/pong or periodic traffic to avoid the 10-minute idle disconnect.</li><li><strong>CloudWatch alarms aren’t optional for a workflow whose auto-decision is a safety net.</strong> The orchestrator’s “auto-promote at end-of-window if metrics look fine” branch is only as good as your confidence that the orchestrator and metrics emitter are themselves healthy. At minimum, alarm on: Lambda Errors and Throttles on each function, lambda:CheckpointDurableExecution failures (visible as Lambda errors but worth a dedicated metric filter), API Gateway WebSocket 5xx rate, and DynamoDB throttling on the table. None of these alarms are in the template - omitted for demo simplicity, mandatory before you trust the auto-decision in anger.</li><li><strong>DynamoDB encryption is AWS-owned by default; deploy metadata may want a CMK.</strong> The table uses default AWS-managed encryption. For a deploy-pipeline table holding build artifact IDs and operator decisions, customer-managed KMS keys give you per-environment isolation, key rotation control, and an audit trail of who/what decrypted. Trade-off: every read and write needs kms:Decrypt / kms:GenerateDataKey on the CMK, which adds a small per-request cost and a configuration surface (key policies, grants).</li><li><strong>DynamoDB on-demand is the right default for hobby use; provisioned will be cheaper at scale.</strong> The template uses PAY_PER_REQUEST because the demo&#39;s traffic is bursty and small. A real CI pipeline running this dozens of times an hour will hit on-demand&#39;s per-write cost, roughly $1.25 per million write request units in us-east-1 as of this writing, which can be several times higher than provisioned capacity once traffic is steady. Switch to provisioned with auto-scaling once your request rate stabilizes.</li></ul><h3>Production hardening checklist</h3><p>Because intervene can promote, roll back, or extend a deployment, this WebSocket is a control-plane interface, not just a UI convenience. Treat it like you&#39;d treat a deployment API.</p><p>Demo vs production is more than a one-line caveat. Before you wire this up to anything that matters, walk this list:</p><ul><li>Require $connect authorization (Lambda authorizer with a short-lived WebSocket ticket, not a long-lived bearer token).</li><li>Validate Origin in the $connect authorizer.</li><li>Persist authenticated principal/claims alongside each connection record.</li><li>Authorize every subscribe and intervene message against the deployment owner/team/environment, not just the connection identity.</li><li>Configure AccessLogSettings on the WebSocket stage with a format that omits raw tokens.</li><li>Configure DefaultRouteSettings.ThrottlingBurstLimit / ThrottlingRateLimit per route, and know your account-level throttling limits.</li><li>Add DynamoDB TTL on CONN#... rows to clean up stale subscriptions if $disconnect ever misfires.</li><li>Encrypt the deploy state table with a customer-managed KMS key if it holds anything you wouldn’t paste in chat.</li><li>Add an async-invoke destination (SQS / EventBridge) or DLQ on the orchestrator so lambda_client.invoke(InvocationType=&quot;Event&quot;) failures aren&#39;t silent.</li><li>Add CloudWatch alarms on Lambda errors/throttles, durable-execution failures, API Gateway 5xx rate, and DynamoDB throttling.</li><li>Plan for new-connection-rate quota increase requests if you scale internal audiences.</li><li>Keep the orchestration body deterministic; push side effects into @durable_step functions.</li><li>Use AutoPublishAlias plus a DeploymentPreference on the orchestrator itself if you want canary semantics on your canary orchestrator (yes, really).</li></ul><p>Most of these are one-liners or one-resource additions in the template. None are skippable for production.</p><h3>Cost and cleanup</h3><p>The stack is cheap but not free at idle. With zero traffic:</p><ul><li>DynamoDB on-demand with PITR enabled: pennies per day for an empty-ish table</li><li>Published Lambda versions: free at rest, billed per invoke</li><li>API Gateway HTTP API and WebSocket API: free at rest, billed per request and per connection-minute respectively</li><li>CloudWatch Logs: pennies per day for the application/system log groups</li></ul><p>Per-deployment costs:</p><ul><li>Lambda durable executions: standard Lambda compute pricing for the active steps. The orchestrator only runs during checkpoints; the wait_for_callback pause is free.</li><li>Metrics emitter Lambda: one invocation per observation window, running for about 60 seconds and publishing roughly 30 tick messages, plus the callback lifecycle calls. Most of that time is time.sleep between ticks, so it&#39;s billed Lambda duration, but still small at 512 MB for a demo-scale observation window.</li><li>DynamoDB: a handful of writes and reads per workflow, fractions of a cent.</li><li>WebSocket API: <strong>$0.25 per million connection-minutes</strong> plus <strong>$1 per million messages</strong>. The demo uses ~30 messages per workflow and a connection of a few minutes; the connection-minute share is essentially noise.</li></ul><p>For this demo, expect well under a cent to around a cent per completed workflow on the AWS side, dominated by Lambda execution time during the observation window. The exact number depends on Lambda memory size, duration, and region. Negligible at hobby scale, worth understanding if you wire this up to your real CI pipeline and start firing it dozens of times a day.</p><p>When you’re done:</p><pre>make destroy</pre><p>Verify in the console that the stack is gone and no orphaned API Gateway APIs remain. The DynamoDB table is part of the stack and will be deleted with it — if you’ve put data you care about there, export it first.</p><h3>Wrapping up</h3><p>The new AWS::Serverless::WebSocketApi resource type is exactly the kind of incremental-but-meaningful improvement SAM has been shipping since 2018. It doesn&#39;t enable anything CloudFormation couldn&#39;t do before; it just removes 60+ lines of boilerplate per WebSocket API and eliminates a category of &quot;forgot the Lambda::Permission&quot; silent failures.</p><p>Where it gets genuinely interesting is when you pair it with Lambda Durable Functions. Real-time UIs and long-running workflows have always wanted to talk to each other; the standard pattern was either polling (clunky), Step Functions + WebSocket fan-out (works, but lots of glue), or maintaining a custom orchestrator on Fargate (overkill for most workloads). Now you can write the workflow as plain Python in a single Lambda handler, deploy it with one SAM template, and the human-in-the-loop story is two API calls and a callback.</p><p>The key decisions in this project:</p><ul><li><strong>WebSocket over SSE</strong> when you need a low-latency return channel, not just one-way streaming. Watching a canary’s metrics live is a one-way story, but a “roll back NOW” button is bidirectional and time-sensitive.</li><li><strong>Durable Functions over Step Functions</strong> when the workflow is naturally code, not a state machine. A canary deploy with operator intervention has branching that reads more naturally as Python if statements than as ASL.</li><li><strong>Policy templates plus targeted inline policies</strong> instead of Connectors, so the effective permissions stay visible beside each function.</li><li><strong>Vite locally over CloudFront/S3</strong> when the frontend doesn’t need to be reachable from outside your laptop.</li><li><strong>Heartbeat callbacks from any background Lambda</strong> that might be racing another finisher, so the loser exits cleanly instead of double-publishing.</li><li><strong>Decoupled metrics emitter</strong> so the orchestrator’s durable replay surface stays minimal: one wait_for_callback, the emitter does the rest.</li></ul><p>The full source — SAM template, Python handlers, React+Vite frontend, Makefile, samconfig, architecture diagram — is on GitHub: <a href="https://github.com/RDarrylR/live-canary-deploys-with-sam-the-new-websocket-api-and-durable-functions">live-canary-deploys-with-sam-the-new-websocket-api-and-durable-functions</a>. Clone it, swap in your AWS profile, run make deploy-guided &amp;&amp; make frontend-env &amp;&amp; make frontend-dev, and you should have the demo running locally in a few minutes once your AWS credentials and local toolchain are set up. Toggle the <strong>Inject canary error spike</strong> checkbox in the form to see the rollback path with the metrics dashboard turning red.</p><h3>Resources</h3><ul><li><a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-websocketapi.html">AWS::Serverless::WebSocketApi reference</a></li><li><a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification-generated-resources-websocketapi.html">Generated CloudFormation resources for </a><a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification-generated-resources-websocketapi.html">WebSocketApi</a></li><li><a href="https://aws.amazon.com/blogs/aws/build-multi-step-applications-and-ai-workflows-with-aws-lambda-durable-functions/">Lambda Durable Functions launch post</a></li><li><a href="https://aws.amazon.com/blogs/compute/building-fault-tolerant-long-running-application-with-aws-lambda-durable-functions/">Building fault-tolerant applications with Lambda Durable Functions</a></li><li><a href="https://aws.amazon.com/blogs/compute/best-practices-for-lambda-durable-functions-using-a-fraud-detection-example/">Best practices for Durable Functions (fraud detection example)</a></li><li><a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/test-and-debug-durable-functions.html">Test and debug durable functions with SAM</a></li><li><a href="https://github.com/aws/aws-sam-cli/releases/tag/v1.156.0">SAM CLI v1.156.0 release notes</a> — .env support, route-specific CORS, BuildKit, Rust GA</li><li><a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-how-to-call-websocket-api-connections.html">API Gateway WebSocket connection management API</a></li><li><a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-policy-template-list.html">SAM policy template list</a></li><li><a href="https://www.jeremydaly.com/getting-abstractions-wrong-with-aws-sam-serverless-connectors/">Jeremy Daly: Getting abstractions wrong with SAM Serverless Connectors</a></li><li><a href="https://docs.powertools.aws.dev/lambda/python/">Powertools for AWS Lambda (Python)</a> — logger, tracer, metrics, idempotency</li><li><a href="https://aws.amazon.com/blogs/compute/serverless-icymi-q1-2026/">Serverless ICYMI Q1 2026</a> — quarterly recap, AI-assisted serverless tooling</li></ul><h3>My related posts</h3><ul><li><a href="https://darryl-ruggles.cloud/aws-lambda-durable-functions/">AWS Lambda Durable Functions: Build a Loan Approval Workflow</a> — my deep dive on the durable mechanics (checkpoint, replay, callbacks) with a different worked example</li><li><a href="https://darryl-ruggles.cloud/powertools-for-aws-lambda-best-practices-by-default/">Powertools for AWS Lambda Best Practices</a> — the logger/tracer/metrics pattern used throughout this project</li><li><a href="https://darryl-ruggles.cloud/serverless-data-processor-using-aws-lambda-step-functions-and-fargate-on-ecs-with-rust/">Serverless Data Processor with Lambda, Step Functions, and Fargate (Rust)</a> — companion piece on Step Functions orchestration, when ASL is the right tool over Durable</li><li><a href="https://darryl-ruggles.cloud/lambda-managed-instances-with-terraform-multi-concurrency-high-memory-and-compute-options/">Lambda Managed Instances with Terraform</a> — the rest of the Lambda compute continuum (sustained throughput, 32 GB memory, EC2 pricing)</li><li><a href="https://darryl-ruggles.cloud/elastic-container-service-ecs-my-default-choice-for-containers-on-aws/">Elastic Container Service — my default for containers on AWS</a> — when you graduate beyond Lambda</li></ul><p><em>Connect with me on</em> <a href="https://x.com/RDarrylR">X</a><em>,</em> <a href="https://bsky.app/profile/darrylruggles.bsky.social">Bluesky</a><em>,</em> <a href="https://www.linkedin.com/in/darryl-ruggles/">LinkedIn</a><em>,</em> <a href="https://github.com/RDarrylR">GitHub</a><em>,</em> <a href="https://medium.com/@RDarrylR">Medium</a><em>,</em> <a href="https://dev.to/rdarrylr">Dev.to</a><em>, or the</em> <a href="https://community.aws/@darrylr">AWS Community</a><em>. Check out more of my projects at</em> <a href="https://darryl-ruggles.cloud/">darryl-ruggles.cloud</a> <em>and join the</em> <a href="https://www.believeinserverless.com/">Believe In Serverless</a> <em>community.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4b029533b34f" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[It’s All About That Memory — Using Long and Short Term Memory with Agents]]></title>
            <link>https://medium.com/@RDarrylR/its-all-about-that-memory-using-long-and-short-term-memory-with-agents-5f7c6577f519?source=rss-7ecf33fd997------2</link>
            <guid isPermaLink="false">https://medium.com/p/5f7c6577f519</guid>
            <category><![CDATA[strands-agents]]></category>
            <category><![CDATA[amazon-bedrock-agentcore]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[agents]]></category>
            <category><![CDATA[serverless]]></category>
            <dc:creator><![CDATA[Darryl Ruggles]]></dc:creator>
            <pubDate>Sun, 03 May 2026 18:41:41 GMT</pubDate>
            <atom:updated>2026-05-03T18:41:41.370Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tehK3r8f1DwyrYeosiIcTg.png" /></figure><p><em>Building a multi-session detective game with AgentCore Memory’s 4 built-in strategies</em></p><p>Every AI memory demo starts the same way. “Hi, my name is Bob.” Close the session, open a new one. “What’s my name?” “Your name is Bob!” Confetti. Blog post done.</p><p>That’s not interesting.</p><p>What if memory wasn’t a feature bolted onto an agent — what if it was the entire product? What if the agent couldn’t function without it? I wanted to build something where forgetting wasn’t a minor inconvenience but a catastrophic failure. A detective who forgets the alibi they just disproved. A narrator who can’t recall which suspects have been interviewed. A case file that resets to blank every time you close your browser.</p><p>That’s the project: a noir detective mystery game called “The Blackwell Murder,” built on Amazon Bedrock AgentCore, where all 4 long-term memory strategies plus short-term memory work together to make the investigation feel continuous across sessions. The detective arrives at a crime scene, interviews suspects, examines evidence, and builds a case — and when they come back the next day, the narrator picks up exactly where they left off.</p><p>The source code is on GitHub: <a href="https://github.com/RDarrylR/agentcore-memory-murder-mystery">agentcore-memory-murder-mystery</a></p><h3>Architecture Overview</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*hgH2ZPyLXUPmcuPo.png" /></figure><p>The key mental model: STM is working memory within a conversation, LTM is the case file that persists between sessions. The agent needs both.</p><p>The architecture is deliberately simple. It uses a local FastAPI proxy that sits between the React frontend and AgentCore. The example doesn’t include CloudFront, Lambda, or API Gateway. The point of this project is memory rather than AWS networking. If you have AWS credentials and Terraform installed, you can clone the repo and be playing in 15 minutes.</p><p><strong>This demo uses PUBLIC network mode with no authentication on the proxy for simplicity. Production deployments should use VPC mode with private subnets, authentication on the proxy layer, and VPC endpoints for Bedrock and AgentCore services.</strong></p><p><strong>Why a local proxy?</strong> The browser can’t call AgentCore directly — it requires IAM SigV4 signing. The FastAPI server handles that, plus it gives us a clean place to filter out model artifacts like \ tags before they reach the UI. In production, this proxy would need authentication (Cognito, API keys, or similar) - the demo version accepts any request from localhost.</p><p><strong>Why not the </strong><strong>agentcore invoke CLI?</strong> The Python SDK (bedrock-agentcore) supports streaming and integrates cleanly with FastAPI&#39;s StreamingResponse. No subprocess overhead, no output parsing.</p><h3>AgentCore Memory — The 4 Strategies</h3><p>AgentCore Memory has two layers: short-term memory (STM) that handles turn-by-turn conversation within a session, and long-term memory (LTM) with four built-in strategies that extract, organize, and recall information across sessions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*hwPE9ra4UTCZp56S.png" /></figure><p>What makes this interesting for a detective game is that every strategy maps naturally to how real investigators work. Detectives maintain fact files, write case summaries, track interrogation patterns, and adapt their approach based on what is working. The four LTM strategies do exactly this.</p><h3>Short-Term Memory (STM)</h3><p>STM captures the raw conversation — detective actions, narrator descriptions, tool calls and results — within a single session. The agent reads back the last few turns automatically so it knows what just happened.</p><p>When the detective says “examine the broken window” and then follows up with “check for fingerprints on the frame,” STM is why the agent knows which window you’re talking about without you having to repeat the context. STM events in this project expire after 30 days (configurable from 7 to 365 via the event_expiry_duration parameter).</p><h3>Semantic Strategy — “CaseFiles”</h3><p>Extracts and indexes factual information from conversations for retrieval by meaning, not keywords.</p><p><strong>Namespace</strong>: /cases/{actorId}/facts/</p><p>This is the detective’s fact file. Every time the agent learns something concrete — a suspect’s alibi, a piece of evidence, a relationship between characters — the semantic strategy extracts it and stores it as a retrievable fact.</p><p>When the detective returns 3 sessions later and asks “what do we know about Helena’s alibi?”, the agent retrieves everything related to Helena: she claims she was at the Grand Hotel bar until midnight, the bartender says she left at 11:30 PM, there’s a 17-minute gap, and the hotel security cameras had a convenient “glitch” during that window. No contradictions slip through. No established facts get lost.</p><h3>Summary Strategy — “CaseNotes”</h3><p>Creates condensed summaries of each session — the detective’s case notes.</p><p><strong>Namespace</strong>: /cases/{actorId}/{sessionId}/</p><p>At the end of each session, the summary strategy distills the conversation into a concise case update: what evidence was discovered, which suspects were interviewed, what leads are open, and where the investigation stands.</p><p>When the player starts a new session, the agent retrieves the last summary and opens with a case file briefing: “Case #1247 — The Blackwell Murder. Day 3. Last session you discovered the staged break-in and the 17-minute gap in Helena’s alibi. Two leads remain open…” This is how real detectives work. They write case notes so they can pick up where they left off.</p><h3>User Preferences Strategy — “DetectiveStyle”</h3><p>Automatically identifies and tracks the player’s investigation approach.</p><p><strong>Namespace</strong>: /detectives/{actorId}/preferences/</p><p>This strategy watches how the player investigates and adapts the experience. If the player consistently chooses indirect questioning over confrontation, the narrator starts offering more subtle conversation options. If they prefer forensic evidence over witness interviews, crime scenes get richer physical detail.</p><p>It picks up on investigation style (methodical vs. intuitive), interrogation preference (confrontational, sympathetic, indirect), detail level (forensic deep-dives vs. big-picture summaries), and pacing preference (slow reveals vs. rapid progress).</p><p>The preference strategy is subtle. You don’t notice it working until the third or fourth session, when the narrator’s suggestions start feeling tailored to exactly how you like to play.</p><h3>Episodic Strategy — “Interrogations”</h3><p>Captures key interactions as structured episodes, then generates cross-session reflections.</p><p><strong>Namespace</strong>: /episodes/{actorId}/{sessionId}/<br><strong>Reflection namespace</strong>: /episodes/{actorId}/</p><p>This is the most compelling strategy, and the one that makes the detective game feel genuinely intelligent. Episodic memory doesn’t just store what happened — it reflects on patterns across interactions.</p><p>An episode captures structured fields — the AWS docs define these as situation, intent, assessment, justification, and episode-level reflection. In practice, the output for this project looks like:</p><ul><li><strong>Situation</strong>: Interrogation of Helena Voss regarding her whereabouts</li><li><strong>Intent</strong>: Catch Helena in a lie about the hotel bar timeline</li><li><strong>Assessment</strong>: Presented the bartender’s statement showing she left at 11:30, not midnight</li><li><strong>Justification</strong>: Helena became defensive, changed story to “went for a walk,” refused further questions</li></ul><p>Reflections synthesize across episodes:</p><ul><li>“This detective excels at catching timeline inconsistencies — present evidence contradictions early”</li><li>“Direct confrontation causes suspects to shut down — this player gets better results with patience”</li><li>“Helena’s changing story pattern matches classic alibi fabrication — flag for cross-reference”</li></ul><p>The practical result is detective intuition. The narrator can say things like “You have noticed Helena’s story shifts every time you press on the timeline. Your instinct says the 17 minutes matter.” The player didn’t ask for that observation — the episodic reflection surfaced it automatically.</p><h3>How the Strategies Map to the Investigation</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ipQWnn9COelv6qLm6g7rqg.png" /></figure><h3>A Note on Namespace Design</h3><p>The AWS docs recommend a default namespace pattern like /strategy/{memoryStrategyId}/actor/{actorId}/session/{sessionId}/. This project uses custom descriptive namespaces instead - /cases/{actorId}/facts/, /detectives/{actorId}/preferences/, etc. - because they map directly to how a detective organizes information. When you&#39;re debugging and see a record in /cases/sloane/facts/, you immediately know what it is.</p><p>The tradeoff is that without {memoryStrategyId} in the path, multiple strategies could theoretically write to overlapping namespaces if you configure them carelessly. In practice, each strategy in this project has a distinct namespace root (/cases/, /detectives/, /episodes/), so there&#39;s no overlap. If you&#39;re building a system with many strategies, the AWS-recommended pattern with strategy IDs in the path is safer.</p><h3>Where Does the Extraction Logic Live?</h3><p>This is the thing that took me the longest to internalize: you don’t write extraction logic. There’s no code in this project that says “pull out facts for semantic memory” or “summarize this session.” The platform does all of it.</p><p>When you define a strategy, you provide a type, a name, a description, and namespaces. That’s it. The extraction pipeline reads your STM events — the raw conversation messages — and applies each strategy’s built-in logic to decide what to extract. You never see the extraction prompt. For the built-in strategies used in this project, customization is limited to the strategy description field. AWS also offers built-in overrides (custom prompts, custom model selection) and self-managed strategies (full pipeline control) for deeper customization — see the <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory-strategies.html">AgentCore Memory documentation</a> for details.</p><p>For built-in strategies, your actual levers for influencing LTM quality are indirect:</p><ol><li><strong>Strategy descriptions</strong> — the only direct hint you give the extraction model. “Extracts and indexes case facts for semantic retrieval” tells it to focus on facts. “Tracks detective communication style and investigation preferences” tells it to watch for behavioral patterns.</li><li><strong>Your system prompt</strong> — shapes how the agent talks, which shapes what the extraction pipeline has to work with. A system prompt that produces atmospheric noir prose gives the summarization strategy rich material. A prompt that produces terse responses gives it less.</li><li><strong>Your tools</strong> — return structured data that becomes part of the conversation. When examine_evidence returns forensic details about tool marks on a window frame, that structured output gives the semantic strategy concrete facts to extract. When interrogate_witness returns a suspect&#39;s shifting alibi, the episodic strategy captures it as a meaningful interaction.</li><li><strong>The conversation itself</strong> — longer, richer conversations produce more extraction material. A single-turn “look at the window” produces less than a multi-turn investigation where the detective examines evidence, cross-references alibis, and confronts a suspect with contradictions.</li></ol><p>The practical implication is that designing good prompts and tools is indirectly designing your memory. I didn’t set out to optimize for LTM quality, but the debug watch tool showed me that conversations where the detective digs deeper — following up on inconsistencies, asking witnesses about specific details, comparing evidence across locations — produce significantly richer LTM records than surface-level interactions. The extraction pipeline rewards conversational depth.</p><h3>Building the Agent</h3><p>The agent runs on AgentCore via the Strands SDK. Three things matter: the system prompt, the tools, and the memory integration.</p><h3>System Prompt — Noir Narrator Persona</h3><p>The agent isn’t the detective. It’s the narrator — the voice in the dark that describes what the detective sees, hears, and feels. The system prompt establishes this firmly:</p><pre>You are the narrator for &quot;The Blackwell Murder,&quot; a noir detective mystery.<br>You speak in the style of classic noir fiction - rain-slicked streets, long<br>shadows, moral ambiguity, and the kind of truth that cuts deeper than any blade.<br>You are not the detective. You are the voice in the dark that describes what<br>the detective sees, hears, and feels.</pre><p>The prompt also includes the full case briefing (locations, suspects, the solution), narrator rules, and memory integration instructions. Two rules that matter the most:</p><ol><li><strong>Never break character.</strong> The model must never mention tools, functions, errors, or its own reasoning. If a tool fails, the narrator says “the trail goes cold” — not “there was an error in the category specified.”</li><li><strong>Memory integration on session start.</strong> On the first message in a new session with no prior history, set the scene. On returning sessions where memory context is available, open with a case file briefing.</li></ol><h3>Custom Tools</h3><p>Four tools drive the investigation. Each is a @tool-decorated function that returns narrative text and silently tracks state in a case file:</p><p><strong>examine_evidence(item, method)</strong> - Three examination methods (visual, forensic, compare) reveal different details about the same evidence. The broken window looks suspicious on visual inspection, reveals tool marks under forensic analysis, and confirms the staged break-in on comparison.</p><p><strong>interrogate_witness(witness, approach, topic)</strong> - Four interview approaches (neutral, sympathetic, confrontational, indirect) produce different responses from the same witness. Confrontation shuts Marcus down. Sympathy gets Clara to reveal the shadow she saw. Indirect questioning catches Marcus mentioning the service passage he claims was sealed.</p><p><strong>search_location(location, area)</strong> - Five locations with multiple searchable areas. The study alone has the desk, window, bookcase, safe, and floor - each hiding different clues.</p><p><strong>check_case_file(query, category)</strong> - The detective&#39;s notebook. Reviews all discovered evidence, suspect information, alibis, timeline events, and connections between suspects. Supports free-text search across all categories.</p><p>Every tool call that discovers something new pushes a notification to the frontend, which updates the Case Board and Persons of Interest panels in real time.</p><h3>Memory Integration with Strands</h3><p>The Strands SDK’s AgentCoreMemorySessionManager handles the memory lifecycle:</p><pre>config = AgentCoreMemoryConfig(<br>    memory_id=MEMORY_ID,<br>    session_id=session_id,<br>    actor_id=detective_id,<br>    retrieval_config={<br>        f&quot;/cases/{detective_id}/facts/&quot;: RetrievalConfig(top_k=5),<br>        f&quot;/detectives/{detective_id}/preferences/&quot;: RetrievalConfig(top_k=3),<br>        f&quot;/episodes/{detective_id}/&quot;: RetrievalConfig(top_k=3),<br>    },<br>)<br><br>with AgentCoreMemorySessionManager(config, region_name=REGION) as session_manager:<br>    agent = Agent(<br>        model=model,<br>        system_prompt=SYSTEM_PROMPT,<br>        tools=[examine_evidence, interrogate_witness, check_case_file, search_location],<br>        session_manager=session_manager,<br>    )</pre><p>The retrieval_config tells the session manager which LTM namespaces to query when loading context for a new request. Without it, the agent only gets STM conversation history - it wouldn&#39;t recall facts, preferences, or episode patterns from prior sessions.</p><p>The session manager does two things: on entry, it loads relevant memories (STM conversation history, LTM strategy results) into the agent’s context. On exit, it persists the current conversation as new memory events. The actor_id is the detective&#39;s name, which namespaces all memory operations so multiple detectives could theoretically investigate the same case without cross-contamination.</p><h3>Model Configuration</h3><p>Nova Pro is the default because it has good narrative quality and is the most cost-effective option for iterative development. But the model is switchable at deploy time via the ACTIVE_LLM environment variable:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0AmnwPWxf6ayJc2n53yN6Q.png" /></figure><p>The difference in narrative quality between Nova Pro and Claude Sonnet is noticeable. Claude produces more atmospheric prose and stays in character more consistently. Nova Pro occasionally breaks the fourth wall by mentioning tool names or its own reasoning process — something I had to filter out in the proxy server. For a polished demo, Claude Sonnet is the better choice. For development and iteration, Nova Pro keeps costs low. A typical 15–20 minute play session (10–15 turns, 4 tool calls per session) costs roughly $0.02–0.05 in model inference alone with Nova Pro. Claude Sonnet runs about 5–10x that. Memory operations and KMS add negligible cost on top.</p><h3>Infrastructure as Code</h3><p>All durable infrastructure is managed by Terraform using the AWS provider (~&gt; 6.35). The agent itself is deployed via the agentcore CLI, which handles the zip packaging and runtime provisioning. This is a clean separation: Terraform manages what persists (Memory, IAM, KMS, S3), the CLI manages what deploys (agent code, runtime configuration).</p><h3>Memory and KMS</h3><p>AgentCore Memory requires a KMS key for encryption. The memory resource itself is straightforward:</p><pre>resource &quot;aws_bedrockagentcore_memory&quot; &quot;detective&quot; {<br>  name                      = &quot;${var.memory_name}_${var.name_suffix}&quot;<br>  description               = &quot;Persistent memory for the AI detective agent&quot;<br>  event_expiry_duration     = var.event_expiry_duration_days<br>  encryption_key_arn        = aws_kms_key.memory.arn<br>  memory_execution_role_arn = var.memory_execution_role_arn<br>}</pre><p>Note the variable name event_expiry_duration_days - the Terraform attribute is event_expiry_duration (which takes a value in days), and the variable adds the _days suffix for clarity so readers don&#39;t have to guess the unit.</p><p>The KMS key policy grants three principals access: the root account for administration (full kms:*), the AgentCore service for memory encryption operations (kms:Encrypt, kms:Decrypt, kms:GenerateDataKey, kms:DescribeKey), and the memory execution role for runtime access (same encryption actions). All policies use aws_iam_policy_document data sources - never inline JSON strings. This gives you compile-time validation and readable diffs. Note: resources = [&quot;*&quot;] in a KMS key policy means &quot;this key&quot; - it&#39;s not a wildcard across all keys.</p><p>A random_id suffix is appended to all AWS resources (S3 buckets, KMS aliases, memory names) to ensure global uniqueness. The suffix is generated once and shared across all modules.</p><h3>Three Strategies via Terraform, One via CLI</h3><p>Here’s the real-world gotcha. The aws_bedrockagentcore_memory_strategy resource supports three of the four strategy types:</p><pre>resource &quot;aws_bedrockagentcore_memory_strategy&quot; &quot;case_files&quot; {<br>  name        = &quot;CaseFiles&quot;<br>  memory_id   = aws_bedrockagentcore_memory.detective.id<br>  type        = &quot;SEMANTIC&quot;<br>  description = &quot;Extracts and indexes case facts for semantic retrieval&quot;<br>  namespaces  = [&quot;/cases/{actorId}/facts/&quot;]<br>}<br><br>resource &quot;aws_bedrockagentcore_memory_strategy&quot; &quot;case_notes&quot; {<br>  name        = &quot;CaseNotes&quot;<br>  memory_id   = aws_bedrockagentcore_memory.detective.id<br>  type        = &quot;SUMMARIZATION&quot;<br>  description = &quot;Summarizes investigation sessions into concise case notes&quot;<br>  namespaces  = [&quot;/cases/{actorId}/{sessionId}/&quot;]<br>}<br>resource &quot;aws_bedrockagentcore_memory_strategy&quot; &quot;detective_style&quot; {<br>  name        = &quot;DetectiveStyle&quot;<br>  memory_id   = aws_bedrockagentcore_memory.detective.id<br>  type        = &quot;USER_PREFERENCE&quot;<br>  description = &quot;Tracks detective communication style and investigation preferences&quot;<br>  namespaces  = [&quot;/detectives/{actorId}/preferences/&quot;]<br>}</pre><p>The EPISODIC type isn’t yet supported in the Terraform provider as of March 2026. This is tracked in <a href="https://github.com/hashicorp/terraform-provider-aws/issues/45599">terraform-provider-aws #45599</a>. The workaround is a make target that calls the AWS CLI:</p><pre>aws bedrock-agentcore-control update-memory \<br>  --memory-id $(MEMORY_ID) \<br>  --memory-strategies &#39;{<br>    &quot;addMemoryStrategies&quot;: [{<br>      &quot;episodicMemoryStrategy&quot;: {<br>        &quot;name&quot;: &quot;Interrogations&quot;,<br>        &quot;description&quot;: &quot;Key interrogation episodes with cross-case reflections&quot;,<br>        &quot;namespaces&quot;: [&quot;/episodes/{actorId}/{sessionId}/&quot;],<br>        &quot;reflectionConfiguration&quot;: {<br>          &quot;namespaces&quot;: [&quot;/episodes/{actorId}/&quot;]<br>        }<br>      }<br>    }]<br>  }&#39;</pre><p>Three things to note about the episodic strategy. First, it requires a reflectionConfiguration with its own namespace - this is where cross-session reflections are stored. Second, the reflection namespace must be at or above the episode namespace&#39;s depth - meaning reflections are less nested than episodes. In practice, this means the reflection namespace must be a prefix of the episode namespace (e.g., /episodes/{actorId}/ works as a reflection namespace for episodes stored in /episodes/{actorId}/{sessionId}/). Get this wrong and the API returns a validation error that doesn&#39;t clearly explain the constraint.</p><p>Third, because the episodic strategy lives outside Terraform, terraform destroy won&#39;t clean it up. If you destroy and recreate the infrastructure, you&#39;ll get a naming collision or an orphaned strategy. The project includes a corresponding make remove-episodic-strategy target for teardown. On the Terraform side, the memory resource&#39;s attributes don&#39;t reflect CLI-managed strategy state, so terraform plan won&#39;t show unexpected diffs after you add the episodic strategy via the CLI - no ignore_changes block is needed.</p><h3>A Note on IAM Permissions</h3><p>When you deploy an agent with the agentcore CLI, it auto-creates an IAM role (AmazonBedrockAgentCoreSDKRuntime-*) with a baseline policy. This policy covers what the agent needs to run - model invocation, memory read/write, and the basics. The agent works fine out of the box.</p><p>Where you will need extra IAM permissions is if you build debug tools that call the boto3 memory APIs directly — like the watch script in this project. Those tools run under your own IAM identity, not the agent’s runtime role, and need explicit permissions for ListMemoryRecords, RetrieveMemoryRecords, ListEvents, and KMS decrypt on the memory encryption key. In production, create a separate narrowly-scoped IAM role for debug tools rather than granting these permissions to developer identities. Budget 15 minutes to set this up if you plan to inspect memory outside the agent.</p><p><strong>A note on the auto-created runtime role.</strong> The agentcore CLI generates a role with broad permissions - for example, bedrock:InvokeModel with Resource: * rather than scoped to specific model ARNs. This is fine for a demo, but for production deployments, create a custom IAM role with explicitly scoped permissions. At minimum, scope bedrock:InvokeModel to the specific model ARNs your agent uses and ensure memory access policies reference only the memory resources that agent needs.</p><h3>Naming Constraints</h3><p>AgentCore resource names must match [a-zA-Z][a-zA-Z0-9_]{0,47} - letters, numbers, and underscores only, starting with a letter. No hyphens. This tripped me up repeatedly: case-files fails, CaseFiles works. detective-memory-abc123 fails, detective_memory_abc123 works. KMS aliases are fine with hyphens, but everything else in AgentCore isn&#39;t.</p><h3>The Frontend</h3><p>A noir-themed React 19 SPA with four components: the narrative log (the main detective story), the detective input, the Case Board (discovered evidence), and the Persons of Interest panel (suspect information with alibi status).</p><p>The narrative log displays the agent’s noir prose as it streams in via SSE. Tool use events show as gold italic indicators — “Examining evidence…”, “Interrogating witness…” — so the player knows the agent is working.</p><p>The Case Board and Persons of Interest panels update in real time as the investigation progresses. When the agent examines evidence or interviews a suspect, the tools push structured notifications through the SSE stream. New evidence items appear with an amber highlight that fades after a few seconds. Suspects show their interview count and alibi verification status (verified, contradicted, or unverified).</p><p>SSE streaming deserves a note. AgentCore returns the response as a StreamingBody - but when accessed through the invoke_agent_runtime API, the entire response arrives as a single read. The SSE events are concatenated inside it, sometimes without newline separators:</p><pre>data: {&quot;chunk&quot;: &quot;The rain&quot;}data: {&quot;chunk&quot;: &quot; hasn&#39;t stopped&quot;}data: {&quot;chunk&quot;: &quot; for three days.&quot;}</pre><p>The proxy server splits on data: boundaries using a regex, reassembles all chunk text, strips tags and `&lt;/tool&gt;` XML artifacts that occasionally leak through, and re-emits clean SSE events to the browser. This filtering is model-agnostic - Nova Pro produces these artifacts most often, but any model with reasoning capabilities (including Nova 2 Lite with extended thinking enabled) can emit blocks. This two-pass approach - collect everything, clean it, then emit - is more reliable than trying to filter partial tags from individual chunks.</p><h3>Demo Walkthrough</h3><h3>Session 1: The Crime Scene</h3><p>The player opens the app. The narrator sets the scene:</p><blockquote><em>The rain has not stopped for three days. You step under the police tape at 42 Ashford Lane, the collar of your coat turned up against the damp. The study door is open now — the locksmith saw to that this morning — but inside, everything is exactly as Edward Blackwell left it. Or as someone left it for him.</em></blockquote><p>The player types “search the study.” The agent calls search_location and describes the room - the scattered desk, the broken window, the bookcase. The Case Board lights up: &quot;Broken Window - glass fragments outside, break pattern radiates outward.&quot;</p><p>They examine the broken window forensically. The narrator reveals the wiped frame, the tool marks. The Case Board updates: “Broken Window (forensic) — frame wiped clean, tool marks from inside.”</p><p>They interview Clara Webb sympathetically. She breaks down and admits she heard arguing at 11:15 PM. The Persons of Interest panel shows Clara with her first interview note.</p><p>All of this — the evidence, the suspect information, the timeline — flows into AgentCore Memory. STM keeps the turn-by-turn conversation. The semantic strategy extracts the facts. The episodic strategy logs Clara’s interrogation.</p><h3>Session 2: The Case Continues</h3><p>The player closes the browser, has lunch, and comes back. They start a new session with the same detective ID. The narrator opens differently now:</p><blockquote><em>Case #1247 — The Blackwell Murder. Your notebook is open on the desk, the pages curling at the edges from the rain. Last time, you found the staged break-in — glass broken outward, frame wiped clean, tool marks from inside. Clara Webb heard arguing at 11:15 PM. Two voices. One was Blackwell. The other was a man she could not identify, but she said the shadow was tall, broad-shouldered. Like Marcus.</em></blockquote><p>The summary strategy provided the session recap. The semantic strategy filled in the specific details. The player picks up where they left off and starts pressing on Marcus’s alibi. Three sessions in, when the player consistently uses indirect questioning instead of confrontation, the narrator starts offering subtler options — the preference strategy at work.</p><p>And when the player catches Helena in another timeline inconsistency, the narrator adds: “Her story shifts every time you push on the timeline. Your instinct says the 17 minutes matter more than she is letting on.” That is the episodic reflection — pattern recognition across sessions that makes the detective feel like they are building real intuition.</p><h3>Observing Memory in Real Time</h3><p>Understanding LTM is abstract until you watch it happen. The project includes a debug watch command that polls AgentCore Memory every 5 seconds and prints new STM events and LTM records as they appear:</p><pre>make debug-memory-watch</pre><p>This runs python server/debug_memory.py --watch 5, which seeds with the current state (so you only see new additions) and then streams changes. A typical session looks like this:</p><pre>Seeding current state... done (652 STM events, 156 LTM records)<br>  Watching for new additions...<br>[20:11:01] [STM] [fe400085] [user] Use a firm and aggressive approach with Clara<br>[20:11:11] [STM] [fe400085] [assistant] A confrontational approach with Clara Webb proves<br>  ineffective. She flinches at the sharp tone and retreats into monosyllables...<br>[20:11:59] [LTM] [USER_PREFERENCE (DetectiveStyle)]<br>{&quot;context&quot;:&quot;The user initially requested a softer approach when interrogating Clara Webb<br>but later explicitly requested to use a firm and aggressive approach, indicating a shift<br>toward more confrontational interrogation tactics with witnesses.&quot;,<br>&quot;preference&quot;:&quot;Prefers firm and aggressive interrogation approach with witnesses&quot;}<br>[20:12:38] [LTM] [SUMMARIZATION (CaseNotes)]<br>&lt;topic name=&quot;Witness Interview - Clara Webb (Confrontational Approach - Failed)&quot;&gt;<br>Detective Sloane attempted a firm and aggressive approach with Clara Webb. The<br>confrontational strategy proved completely ineffective. Clara flinched at the sharp<br>tone and retreated into monosyllables. This failed interrogation confirms Clara&#39;s<br>fear is a significant barrier and indicates a gentler approach is necessary.<br>&lt;/topic&gt;</pre><p>STM events appear immediately as the conversation flows. LTM records follow 30–60 seconds later as the platform’s extraction pipeline processes the events. You can see exactly what each strategy produces:</p><ul><li><strong>SEMANTIC</strong> records are plain factual statements — “Helena Blackwell was found dead in the study at 10:42 PM”</li><li><strong>SUMMARIZATION</strong> records are topic-tagged XML with detailed session notes</li><li><strong>USER_PREFERENCE</strong> records are structured JSON with context, preference, and categories</li><li><strong>EPISODIC</strong> records come in two flavors: situation recaps (&quot;situation&quot;: &quot;A detective begins investigating...&quot;) and cross-session strategy patterns (&quot;title&quot;: &quot;Escalating Interrogation Pressure with Evidence Leverage&quot;)</li></ul><p>Seeing these raw values is what made the strategies click for me. Reading the documentation, I understood that “semantic extracts facts” and “episodic captures patterns.” But watching the actual output — seeing the platform independently decide that a failed interrogation was worth logging as an episode, or that a shift from soft to aggressive questioning counted as a preference change — made the system feel real. The extraction isn’t just summarizing what happened. It’s interpreting the conversation through each strategy’s lens and producing genuinely different representations of the same events.</p><p>The watch also exposed a debugging gotcha. As of bedrock-agentcore SDK version 1.4.4, the AgentCore list_memory_records and retrieve_memory_records APIs return results under the key memoryRecordSummaries, not memoryRecords. The SDK&#39;s retrieve_memories() method handles this correctly, so the agent works fine - but if you write your own debug scripts using boto3 directly, you&#39;ll get empty results and spend hours investigating an extraction pipeline that was working all along. The watch script in this repo has the correct key. Check the latest SDK docs if you&#39;re reading this in the future - response key names can change between versions.</p><p>Other debug modes are available:</p><pre># Dump everything - strategies, STM events, and LTM records<br>uv run python server/debug_memory.py<br># Only LTM records (skip raw conversation events)<br>uv run python server/debug_memory.py --ltm-only<br># Only STM events<br>uv run python server/debug_memory.py --stm-only<br># Show all sessions (default: most recent only)<br>uv run python server/debug_memory.py --all-sessions</pre><h3>What I Learned</h3><p><strong>STM vs LTM isn’t either/or — they serve completely different functions.</strong> STM is working memory within a conversation. LTM is the case file that persists between sessions. You need both, and trying to use one for the other’s job leads to problems. STM without LTM means the detective forgets everything between sessions. LTM without STM means the agent can’t follow a multi-turn investigation within a single session.</p><p><strong>Episodic reflections are the most compelling strategy.</strong> The semantic strategy is the workhorse — it stores facts and retrieves them reliably. But the episodic strategy’s cross-session reflections are what make the agent feel genuinely intelligent. When the narrator surfaces a pattern the player didn’t explicitly ask about, it creates a moment that feels like the detective is actually thinking. This is the strategy I would lead with in any demo.</p><p><strong>Model choice matters more than I expected for character consistency.</strong> Nova Pro occasionally breaks character — mentioning tool names, exposing its reasoning process, or dropping the noir tone mid-paragraph. Claude Sonnet stays in character almost perfectly. For a narrative application where immersion matters, the model’s ability to maintain a persona is as important as its raw capability. I ended up adding server-side filtering to strip \ tags and &lt;/tool&gt; XML artifacts that leaked through from Nova Pro.</p><p><strong>Prompt engineering is still the job — the prompt is the product.</strong> The system prompt went through more revisions than any other file in this project. The first version let the model call six tools in a single turn, drowning the player in information before they had asked a single question. Another version produced beautiful prose but kept breaking character to mention tool names. Getting the narrator to call exactly one tool per turn, stay in character when tools error, and set the scene without immediately investigating required specific, firm language — “do not chain multiple tool calls” works where “one action per turn” didn’t. If you’re building an agent-based application, expect to spend as much time tuning the system prompt as you do writing the code around it.</p><p><strong>The Terraform provider gap is a real-world pattern.</strong> Three of four strategies are supported in Terraform. The fourth requires a CLI workaround. This is a common pattern with new AWS services — Terraform support lags behind the API by weeks or months. The pragmatic approach is to manage what you can in Terraform and script the rest in your Makefile, documenting the gap clearly so your future self (or your team) knows what to update when provider support arrives.</p><p><strong>Build a memory watch tool early.</strong> The single most useful debugging aid was a script that polls memory and prints new STM events and LTM records in real time. Without it, memory’s a black box — events go in, and you hope the right things come out. With it, you can see exactly what the platform extracts, how long extraction takes (30–60 seconds typically), and whether your namespace configuration is producing records where you expect them. I would build this before writing any agent code on my next project.</p><p><strong>Going to production would add several layers.</strong> This demo runs in PUBLIC network mode with an unauthenticated local proxy. A production deployment would need: VPC mode with private subnets, VPC endpoints for Bedrock and AgentCore services (avoiding public internet for API calls), CloudFront distribution with WAF, Cognito or API key authentication on the proxy, a custom IAM role with least-privilege permissions (scoped bedrock:InvokeModel to specific model ARNs, scoped memory access to specific resources), an S3 backend for Terraform state, and Bedrock Guardrails for input validation. The architecture section of this post shows the demo setup. The production architecture is a different article.</p><p>The full source code, Terraform configurations, and Makefile workflow are available on GitHub <a href="https://github.com/RDarrylR/agentcore-memory-murder-mystery">agentcore-memory-murder-mystery</a>. Clone the repo, run make init &amp;&amp; make apply &amp;&amp; make deploy-agent &amp;&amp; make serve, and start investigating. The rain is still falling on Ashford Lane.</p><p><em>Connect with me on</em> <a href="https://x.com/RDarrylR">X</a><em>,</em> <a href="https://bsky.app/profile/darrylruggles.bsky.social">Bluesky</a><em>,</em> <a href="https://www.linkedin.com/in/darryl-ruggles/">LinkedIn</a><em>,</em> <a href="https://github.com/RDarrylR">GitHub</a><em>,</em> <a href="https://medium.com/@RDarrylR">Medium</a><em>,</em> <a href="https://dev.to/rdarrylr">Dev.to</a><em>, or the</em> <a href="https://community.aws/@darrylr">AWS Community</a><em>. Check out more of my projects at</em> <a href="https://darryl-ruggles.cloud/">darryl-ruggles.cloud</a> <em>and join the</em> <a href="https://www.believeinserverless.com/">Believe In Serverless</a> <em>community.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5f7c6577f519" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[S3 Files: The End of Download-Process-Upload (with Terraform)]]></title>
            <link>https://medium.com/@RDarrylR/s3-files-the-end-of-download-process-upload-with-terraform-a37f5a278c05?source=rss-7ecf33fd997------2</link>
            <guid isPermaLink="false">https://medium.com/p/a37f5a278c05</guid>
            <category><![CDATA[s3]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[serverless]]></category>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[s3-files]]></category>
            <dc:creator><![CDATA[Darryl Ruggles]]></dc:creator>
            <pubDate>Fri, 01 May 2026 15:22:54 GMT</pubDate>
            <atom:updated>2026-05-01T15:22:54.582Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xNGNQODCqXKZb-rYqu7yDg.png" /></figure><p>On April 7, 2026, AWS launched S3 Files — a managed NFS v4.1/4.2 layer built on Amazon EFS that provides file-system semantics on top of S3, including read-after-write consistency, advisory file locking, and POSIX permissions. (AWS Storage Gateway’s File Gateway has offered NFS-over-S3 for years, but as a caching gateway appliance, not a native file system with these guarantees.) You can mount S3 Files from EC2, Lambda, EKS, and ECS (Fargate and ECS Managed Instances launch types; EC2 launch type is not yet supported). Your code reads and writes files with open(), os.rename(), and os.listdir(). No boto3 for the data path. No /tmp juggling. No copy-then-delete to simulate a rename.</p><p>In this post, I’ll build two identical document-processing Lambda functions — one using the traditional S3 API approach and one using S3 Files — deploy them with Terraform, and benchmark the difference.</p><h3>The Long Road to a Real S3 File System</h3><p>For nearly two decades, developers have been trying to use S3 as a file system. Here’s how the tools evolved:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UimDKgBUbXn0U7kIwOruDg.png" /></figure><p><em>Performance figures from published launch-day benchmarks; see</em> <a href="https://computingforgeeks.com/s3-files-vs-mountpoint-vs-s3fs/">S3 Files vs Mountpoint vs s3fs-fuse comparison</a> <em>and</em> <a href="https://dev.classmethod.jp/en/articles/amazon-s3-files-ga-mount-and-compare-efs/">DevelopersIO GA walkthrough</a><em>.</em></p><p>Each generation solved the previous one’s biggest limitation. s3fs-fuse gave you a file system but was slow and unreliable. Mountpoint gave you speed but restricted writes to append-only — ruling out most real applications. S3 Files closes the remaining gaps: file-system semantics including advisory file locking and POSIX permissions, managed infrastructure, and strong performance for both small and large file workloads.</p><h3>The “Before” Pattern: Download-Process-Upload</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/843/0*-jTdGsmbn-OPcBpJ.png" /></figure><p>If you’ve written a Lambda function that processes files in S3, you’ve written this pattern:</p><pre>import boto3<br>import os<br>import json<br><br>s3 = boto3.client(&quot;s3&quot;)<br><br>def lambda_handler(event, context):<br>    # 1. List files in the inbox<br>    response = s3.list_objects_v2(Bucket=BUCKET, Prefix=&quot;inbox/&quot;)<br>    <br>    for obj in response.get(&quot;Contents&quot;, []):<br>        key = obj[&quot;Key&quot;]<br>        filename = key.removeprefix(&quot;inbox/&quot;)<br>        <br>        # 2. Download to /tmp (the only writable space Lambda gives you)<br>        s3.download_file(BUCKET, key, f&quot;/tmp/{filename}&quot;)<br>        <br>        # 3. Process the file<br>        with open(f&quot;/tmp/{filename}&quot;, &quot;r&quot;) as f:<br>            content = f.read()<br>        result = {&quot;word_count&quot;: len(content.split()), &quot;lines&quot;: content.count(&quot;\n&quot;)}<br>        <br>        # 4. Upload processed file (S3 has no rename - copy then delete)<br>        s3.copy_object(<br>            Bucket=BUCKET,<br>            CopySource={&quot;Bucket&quot;: BUCKET, &quot;Key&quot;: key},<br>            Key=f&quot;processed/{filename}&quot;,<br>        )<br>        <br>        # 5. Upload metadata<br>        s3.put_object(<br>            Bucket=BUCKET,<br>            Key=f&quot;processed/{filename}.meta.json&quot;,<br>            Body=json.dumps(result),<br>        )<br>        <br>        # 6. Delete the original<br>        s3.delete_object(Bucket=BUCKET, Key=key)<br>        <br>        # 7. Clean up /tmp (Lambda reuses containers)<br>        os.remove(f&quot;/tmp/{filename}&quot;)</pre><p>Every step is an S3 API call. Every file passes through /tmp. “Renaming” a file requires a full copy followed by a delete — two API calls for something that should be instant. If your function processes 100 files, that’s hundreds of API calls, each adding latency.</p><p>And /tmp itself is limited. Lambda gives you 512MB by default (up to 10GB at extra cost). If you’re processing large files or many files concurrently, you’ll hit that ceiling.</p><h3>The “After” Pattern: Just Use the File System</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/953/0*O9NRqoK4OhJJsroJ.png" /></figure><p>With S3 Files mounted at /mnt/docs, the same logic becomes:</p><pre>import os<br>import json<br>from aws_lambda_powertools import Logger, Tracer, Metrics<br>from aws_lambda_powertools.metrics import MetricUnit<br><br>logger = Logger()<br>tracer = Tracer()<br>metrics = Metrics()<br>MOUNT_PATH = os.environ[&quot;MOUNT_PATH&quot;]<br>@logger.inject_lambda_context<br>@tracer.capture_lambda_handler<br>@metrics.log_metrics(capture_cold_start_metric=True)<br>def lambda_handler(event, context):<br>    inbox = os.path.join(MOUNT_PATH, &quot;inbox&quot;)<br>    processed = os.path.join(MOUNT_PATH, &quot;processed&quot;)<br>    os.makedirs(processed, exist_ok=True)<br>    count = 0<br>    for filename in os.listdir(inbox):<br>        src = os.path.join(inbox, filename)<br>        with open(src, &quot;r&quot;) as f:<br>            content = f.read()<br>        result = {&quot;word_count&quot;: len(content.split()), &quot;lines&quot;: content.count(&quot;\n&quot;)}<br>        with open(os.path.join(processed, f&quot;{filename}.meta.json&quot;), &quot;w&quot;) as f:<br>            json.dump(result, f)<br>        os.rename(src, os.path.join(processed, filename))<br>        count += 1<br>    metrics.add_metric(name=&quot;FilesProcessed&quot;, unit=MetricUnit.Count, value=count)<br>    logger.info(&quot;batch complete&quot;)</pre><p>There is no boto3 import, no /tmp management, and no copy-then-delete dance. Powertools makes it easy to add <a href="https://darryl-ruggles.cloud/powertools-for-aws-lambda-best-practices-by-default">structured logging, tracing, and EMF metrics</a> — the decorators above wire all three into this handler. The rename returns instantly from the NFS client’s perspective. The code is materially shorter and maps more directly to the workload.</p><p>One caveat: “instant” means instant from <em>your code’s perspective</em>. Under the hood, S3 Files still has to copy + delete the S3 object to implement the rename — general-purpose S3 buckets have no native rename operation. (S3 Express One Zone directory buckets do have a RenameObject API, but S3 Files works with general-purpose buckets.) For single files, this happens fast enough to be invisible. For directory renames across thousands of objects, the S3-side sync can take a long time - AWS documentation warns about performance impact for large recursive rename operations on prefixes with many objects. Your NFS client sees the rename as complete immediately, but S3 API consumers see the old key until the background sync finishes.</p><p>This is not just cleaner code — it’s a fundamentally different model. S3 remains the authoritative data store; the file system is a synchronized view. Your Lambda function sees files and directories. S3 sees objects and prefixes. Both are looking at the same data.</p><h3>Building the Infrastructure with Terraform</h3><p>Good news: the Terraform AWS provider shipped native S3 Files resources in <a href="https://github.com/hashicorp/terraform-provider-aws/releases">v6.40.0</a> on April 8, 2026 — just one day after S3 Files went GA. The new resources are aws_s3files_file_system, aws_s3files_mount_target, and aws_s3files_access_point, plus corresponding data sources and aws_s3files_file_system_policy for resource-based policies.</p><h3>The S3 Bucket (Versioning is Mandatory)</h3><p>S3 Files requires bucket versioning to be enabled. This is how it tracks the relationship between file-system state and S3 object versions. The full bucket setup also includes SSE-S3 encryption (explicit, even though it’s the default for new buckets), a public access block, and a bucket policy enforcing TLS-only access:</p><pre>resource &quot;aws_s3_bucket&quot; &quot;docs&quot; {<br>  bucket = &quot;${var.project_name}-${var.environment}-docs&quot;<br>}<br><br>resource &quot;aws_s3_bucket_versioning&quot; &quot;docs&quot; {<br>  bucket = aws_s3_bucket.docs.id<br>  versioning_configuration {<br>    status = &quot;Enabled&quot;<br>  }<br>}<br>resource &quot;aws_s3_bucket_server_side_encryption_configuration&quot; &quot;docs&quot; {<br>  bucket = aws_s3_bucket.docs.id<br>  rule {<br>    apply_server_side_encryption_by_default {<br>      sse_algorithm = &quot;AES256&quot;<br>    }<br>  }<br>}<br>resource &quot;aws_s3_bucket_public_access_block&quot; &quot;docs&quot; {<br>  bucket                  = aws_s3_bucket.docs.id<br>  block_public_acls       = true<br>  block_public_policy     = true<br>  ignore_public_acls      = true<br>  restrict_public_buckets = true<br>}<br># Disable ACLs - bucket-owner-enforced is the default for new buckets, but<br># being explicit prevents readers from relying on defaults they may not understand.<br>resource &quot;aws_s3_bucket_ownership_controls&quot; &quot;docs&quot; {<br>  bucket = aws_s3_bucket.docs.id<br>  rule {<br>    object_ownership = &quot;BucketOwnerEnforced&quot;<br>  }<br>}<br># Versioning is mandatory for S3 Files, so without lifecycle cleanup old<br># versions accumulate silently during repeated benchmark runs.<br>resource &quot;aws_s3_bucket_lifecycle_configuration&quot; &quot;docs&quot; {<br>  bucket = aws_s3_bucket.docs.id<br>  rule {<br>    id     = &quot;expire-noncurrent-versions&quot;<br>    status = &quot;Enabled&quot;<br>    noncurrent_version_expiration {<br>      noncurrent_days = 7<br>    }<br>  }<br>}<br>resource &quot;aws_s3_bucket_policy&quot; &quot;docs&quot; {<br>  bucket = aws_s3_bucket.docs.id<br>  policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [{<br>      Sid       = &quot;DenyNonTLS&quot;<br>      Effect    = &quot;Deny&quot;<br>      Principal = &quot;*&quot;<br>      Action    = &quot;s3:*&quot;<br>      Resource  = [aws_s3_bucket.docs.arn, &quot;${aws_s3_bucket.docs.arn}/*&quot;]<br>      Condition = { Bool = { &quot;aws:SecureTransport&quot; = &quot;false&quot; } }<br>    }]<br>  })<br>}</pre><p><strong>Production hardening notes:</strong> For workloads that should stay private inside the VPC, you can go beyond TLS-only and restrict bucket access to your S3 VPC endpoint using aws:sourceVpce or aws:sourceVpc conditions in the bucket policy. This can prevent bucket access except through your approved VPC or VPC endpoint, even when credentials are otherwise valid. For SSE-KMS encrypted buckets, the S3 Files service role would also need kms:GenerateDataKey, kms:Encrypt, kms:Decrypt, kms:ReEncryptFrom, and kms:ReEncryptTo scoped with kms:ViaService = s3.&lt;region&gt;.amazonaws.com. This demo uses SSE-S3 (AES256), so KMS permissions are not needed here.</p><h3>The S3 Files Service Role</h3><p>S3 Files needs an IAM role it can assume to read and write your bucket. This is separate from your Lambda’s execution role. First surprise: <strong>the service principal is </strong><strong>elasticfilesystem.amazonaws.com, not </strong><strong>s3files.amazonaws.com</strong>. S3 Files is built on EFS, and the trust policy has to name the underlying service. If you guess the obvious name, CreateRole fails with MalformedPolicyDocument: Invalid principal.</p><pre>resource &quot;aws_iam_role&quot; &quot;s3files_service&quot; {<br>  name = &quot;${var.project_name}-${var.environment}-s3files-service&quot;<br><br>assume_role_policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [{<br>      Sid       = &quot;AllowS3FilesAssumeRole&quot;<br>      Effect    = &quot;Allow&quot;<br>      Principal = { Service = &quot;elasticfilesystem.amazonaws.com&quot; }<br>      Action    = &quot;sts:AssumeRole&quot;<br>      Condition = {<br>        StringEquals = {<br>          &quot;aws:SourceAccount&quot; = data.aws_caller_identity.current.account_id<br>        }<br>        ArnLike = {<br>          &quot;aws:SourceArn&quot; = &quot;arn:aws:s3files:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:file-system/*&quot;<br>        }<br>      }<br>    }]<br>  })<br>}<br>resource &quot;aws_iam_role_policy&quot; &quot;s3files_bucket_access&quot; {<br>  name = &quot;s3-bucket-access&quot;<br>  role = aws_iam_role.s3files_service.id<br>  policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [{<br>      Effect = &quot;Allow&quot;<br>      Action = [<br>        &quot;s3:ListBucket&quot;, &quot;s3:ListBucketVersions&quot;,<br>        &quot;s3:GetBucketLocation&quot;, &quot;s3:GetBucketVersioning&quot;,<br>        &quot;s3:AbortMultipartUpload&quot;, &quot;s3:ListMultipartUploadParts&quot;,<br>        &quot;s3:GetObject&quot;, &quot;s3:GetObjectVersion&quot;, &quot;s3:GetObjectTagging&quot;, &quot;s3:GetObjectVersionTagging&quot;,<br>        &quot;s3:PutObject&quot;, &quot;s3:PutObjectTagging&quot;,<br>        &quot;s3:DeleteObject&quot;, &quot;s3:DeleteObjectVersion&quot;<br>      ]<br>      Resource = [aws_s3_bucket.docs.arn, &quot;${aws_s3_bucket.docs.arn}/*&quot;]<br>      Condition = {<br>        StringEquals = {<br>          &quot;aws:ResourceAccount&quot; = data.aws_caller_identity.current.account_id<br>        }<br>      }<br>    }]<br>  })<br>}</pre><p>The role also needs <strong>EventBridge permissions</strong> — this is the mechanism behind S3-to-NFS synchronization. S3 Files creates EventBridge rules (prefixed DO-NOT-DELETE-S3-Files*) to detect out-of-band bucket changes. Without these, S3-side writes never propagate to the NFS mount:</p><pre>resource &quot;aws_iam_role_policy&quot; &quot;s3files_eventbridge&quot; {<br>  name = &quot;eventbridge-sync&quot;<br>  role = aws_iam_role.s3files_service.id<br><br>policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [<br>      {<br>        Sid    = &quot;EventBridgeManage&quot;<br>        Effect = &quot;Allow&quot;<br>        Action = [<br>          &quot;events:PutRule&quot;, &quot;events:PutTargets&quot;,<br>          &quot;events:DeleteRule&quot;, &quot;events:DisableRule&quot;,<br>          &quot;events:EnableRule&quot;, &quot;events:RemoveTargets&quot;<br>        ]<br>        Resource = &quot;arn:aws:events:*:*:rule/DO-NOT-DELETE-S3-Files*&quot;<br>        Condition = {<br>          StringEquals = {<br>            &quot;events:ManagedBy&quot; = &quot;elasticfilesystem.amazonaws.com&quot;<br>          }<br>        }<br>      },<br>      {<br>        Sid    = &quot;EventBridgeRead&quot;<br>        Effect = &quot;Allow&quot;<br>        Action = [<br>          &quot;events:DescribeRule&quot;, &quot;events:ListRules&quot;,<br>          &quot;events:ListRuleNamesByTarget&quot;, &quot;events:ListTargetsByRule&quot;<br>        ]<br>        Resource = &quot;arn:aws:events:*:*:rule/*&quot;<br>      }<br>    ]<br>  })<br>}</pre><p>If you use SSE-KMS encryption on the bucket, you’d also need kms:GenerateDataKey, kms:Encrypt, kms:Decrypt, kms:ReEncryptFrom, and kms:ReEncryptTo scoped with kms:ViaService = s3.&lt;region&gt;.amazonaws.com. This demo uses SSE-S3 (AES256), so KMS permissions aren&#39;t needed.</p><p>The aws:SourceArn condition and the full set of object/multipart/EventBridge actions are documented in the <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-files-prereq-policies.html">S3 Files prerequisites</a>. The biggest risk from an incomplete policy isn&#39;t a permission error - it&#39;s silent failure. Missing EventBridge permissions mean the sync rules never get created, and S3-side changes simply don&#39;t appear on the mount. Missing multipart permissions cause large-file uploads to leak incomplete parts.</p><h3>Creating the File System, Mount Targets, and Access Point</h3><p>The aws_s3files_file_system resource takes just a bucket ARN and the service role ARN:</p><pre>resource &quot;aws_s3files_file_system&quot; &quot;docs&quot; {<br>  bucket   = aws_s3_bucket.docs.arn<br>  role_arn = aws_iam_role.s3files_service.arn<br>}</pre><p>Mount targets go in each subnet where your Lambda runs. One per AZ:</p><pre>resource &quot;aws_s3files_mount_target&quot; &quot;az&quot; {<br>  count = length(var.private_subnet_ids)<br>  file_system_id  = aws_s3files_file_system.docs.id<br>  subnet_id       = var.private_subnet_ids[count.index]<br>  security_groups = [aws_security_group.mount_target.id]<br>}</pre><p>Mount targets take about 5 minutes to create. Terraform’s create timeout handles that wait — but there’s a trap: the provider returns once the API call completes, which happens <em>before</em> the target reaches the available lifecycle state. If you create a Lambda that references the access point immediately after, CreateFunction fails with not all are in the available life cycle state yet. The fix is an explicit wait between mount targets and downstream consumers:</p><pre>resource &quot;time_sleep&quot; &quot;wait_for_mount_targets&quot; {<br>  depends_on      = [aws_s3files_mount_target.az]<br>  create_duration = &quot;90s&quot;<br>}<br><br>resource &quot;aws_s3files_access_point&quot; &quot;lambda&quot; {<br>  file_system_id = aws_s3files_file_system.docs.id<br>  depends_on     = [time_sleep.wait_for_mount_targets]<br>  # DEMO SHORTCUT: uid 0:0 avoids ownership collisions during the side-by-side<br>  # comparison. In production, prefer a scoped access point path with a non-root<br>  # UID/GID (e.g., uid=1000), or grant s3files:ClientRootAccess on the Lambda<br>  # role instead. AWS&#39;s Lambda console defaults to UID/GID 1000:1000 with<br>  # root_directory.path = &quot;/lambda&quot; for good reason.<br>  posix_user {<br>    uid = 0<br>    gid = 0<br>  }<br>  root_directory {<br>    path = &quot;/&quot;<br>  }<br>}</pre><p>The access point controls the POSIX UID/GID that all NFS operations execute as. The choice of 0:0 here is a demo compromise, not a recommendation - I&#39;ll explain the tradeoffs and better alternatives in the &quot;Things to Look Out For&quot; section.</p><p>Finally, add an aws_s3files_file_system_policy - the resource-based policy on the file system itself (equivalent to a bucket policy). Without this, any principal with s3files:ClientMount in their IAM policy can mount your file system:</p><pre>resource &quot;aws_s3files_file_system_policy&quot; &quot;docs&quot; {<br>  file_system_id = aws_s3files_file_system.docs.id<br><br>  policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [<br>      {<br>        Sid       = &quot;AllowMountFromKnownRoles&quot;<br>        Effect    = &quot;Allow&quot;<br>        Principal = { AWS = [var.lambda_role_arn, var.ec2_role_arn] }<br>        Action    = [&quot;s3files:ClientMount&quot;, &quot;s3files:ClientWrite&quot;]<br>        Resource  = aws_s3files_file_system.docs.arn<br>      },<br>      {<br>        Sid       = &quot;EnforceTLS&quot;<br>        Effect    = &quot;Deny&quot;<br>        Principal = &quot;*&quot;<br>        Action    = &quot;s3files:*&quot;<br>        Resource  = aws_s3files_file_system.docs.arn<br>        Condition = { Bool = { &quot;aws:SecureTransport&quot; = &quot;false&quot; } }<br>      }<br>    ]<br>  })<br>}</pre><h3>The VPC (No NAT Gateway Needed)</h3><p>S3 Files requires your Lambda to be in a VPC — the NFS mount targets live inside your VPC subnets. But you don’t need a NAT Gateway (which costs about $35/month). Instead, use a free S3 Gateway VPC endpoint for S3 API traffic:</p><pre>resource &quot;aws_vpc&quot; &quot;main&quot; {<br>  cidr_block           = &quot;10.0.0.0/16&quot;<br>  enable_dns_support   = true   # Required for VPC endpoints<br>  enable_dns_hostnames = true<br>}<br><br>resource &quot;aws_subnet&quot; &quot;private&quot; {<br>  count             = 2<br>  vpc_id            = aws_vpc.main.id<br>  cidr_block        = var.private_subnet_cidrs[count.index]<br>  availability_zone = var.availability_zones[count.index]<br>}<br># Free S3 Gateway endpoint - no NAT gateway needed<br>resource &quot;aws_vpc_endpoint&quot; &quot;s3&quot; {<br>  vpc_id            = aws_vpc.main.id<br>  service_name      = &quot;com.amazonaws.${data.aws_region.current.region}.s3&quot;<br>  vpc_endpoint_type = &quot;Gateway&quot;<br>  route_table_ids   = [aws_route_table.private.id]<br>}</pre><p>Security groups allow NFS traffic (TCP 2049) between the Lambda and mount targets:</p><pre># Lambda can reach mount targets on NFS port<br>resource &quot;aws_vpc_security_group_egress_rule&quot; &quot;lambda_to_nfs&quot; {<br>  security_group_id            = aws_security_group.lambda_after.id<br>  referenced_security_group_id = aws_security_group.mount_target.id<br>  from_port                    = 2049<br>  to_port                      = 2049<br>  ip_protocol                  = &quot;tcp&quot;<br>}<br><br># Mount targets accept NFS from Lambda<br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;nfs_from_lambda&quot; {<br>  security_group_id            = aws_security_group.mount_target.id<br>  referenced_security_group_id = aws_security_group.lambda_after.id<br>  from_port                    = 2049<br>  to_port                      = 2049<br>  ip_protocol                  = &quot;tcp&quot;<br>}</pre><h3>The Lambda Function with S3 Files Mount</h3><p>The Lambda configuration uses the same file_system_config block as EFS. The key additions are the VPC config and the S3 Files-specific IAM permissions:</p><pre>resource &quot;aws_lambda_function&quot; &quot;processor_after&quot; {<br>  function_name = &quot;${var.project_name}-${var.environment}-after&quot;<br>  runtime       = &quot;python3.14&quot;<br>  architectures = [&quot;arm64&quot;]<br>  memory_size   = 512    # &gt;= 512MB enables direct S3 read optimization<br>  timeout       = 300<br>  handler       = &quot;handler.lambda_handler&quot;<br><br>  vpc_config {<br>    subnet_ids         = var.private_subnet_ids<br>    security_group_ids = [var.lambda_sg_id]<br>  }<br>  file_system_config {<br>    arn              = var.access_point_arn    # S3 Files access point<br>    local_mount_path = &quot;/mnt/docs&quot;            # Must start with /mnt/<br>  }<br>  environment {<br>    variables = {<br>      MOUNT_PATH = &quot;/mnt/docs&quot;<br>    }<br>  }<br>}</pre><p>The Lambda execution role needs S3 Files mount permissions <strong>and</strong> S3 read permissions for the direct-read optimization:</p><pre>resource &quot;aws_iam_role_policy&quot; &quot;s3files_mount&quot; {<br>  name = &quot;s3files-mount&quot;<br>  role = aws_iam_role.execution.id<br><br>  policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [{<br>      Effect   = &quot;Allow&quot;<br>      Action   = [&quot;s3files:ClientMount&quot;, &quot;s3files:ClientWrite&quot;]<br>      Resource = var.access_point_arn<br>    }]<br>  })<br>}<br># Required for the &gt;=1 MiB direct-read bypass (streams from S3 at up to 3 GB/s).<br># Without this, reads silently fall back to the cached path - the mount works<br># but you lose the throughput optimization and pay S3 Files access charges.<br>resource &quot;aws_iam_role_policy&quot; &quot;s3_direct_read&quot; {<br>  name = &quot;s3-direct-read&quot;<br>  role = aws_iam_role.execution.id<br>  policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [{<br>      Effect   = &quot;Allow&quot;<br>      Action   = [&quot;s3:GetObject&quot;, &quot;s3:GetObjectVersion&quot;]<br>      Resource = &quot;${var.bucket_arn}/*&quot;<br>    }]<br>  })<br>}</pre><p>Note: s3files:ClientMount is required for all access. s3files:ClientWrite is only needed for read-write mounts. s3files:ClientRootAccess lets a non-root access point UID operate on root-owned entries (see the Access Point Ownership section below - it&#39;s the cleanest fix for mixed S3-API/NFS workflows). The s3:GetObject/s3:GetObjectVersion permissions are technically optional, but without them the direct-read bypass doesn&#39;t activate and your &gt;=512MB memory setting buys you nothing.</p><h3>Performance Comparison</h3><p>I deployed both Lambda functions and ran them against 20 medium-sized text files (500–2000 words each) across 3 runs. The benchmark script seeds each approach into its own S3 prefix, invokes the relevant Lambda, and collects timing breakdowns from both handlers.</p><pre>make benchmark</pre><p>Actual results from a 20-file, 3-run benchmark (arm64 Lambda, 512MB, us-east-1):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-37znoxBe6Ckue4BsBuU4w.png" /></figure><p>The Lambda-internal win is about 5x. Wall clock narrows the gap because the after-Lambda pays a VPC cold start penalty on its first invocation in each run (2.2s wall on run 1, ~600ms on runs 2–3 once the ENI is warm). For batch workloads you’d amortize that across many files; for sporadic triggers you’d feel it every time.</p><p>The single non-win — list time — is counterintuitive but worth calling out. os.listdir over NFS had a cold-run outlier of 440ms (vs ~80ms for a warm ListObjectsV2 call). I didn&#39;t chase this down, but it looks like metadata that hasn&#39;t been touched recently isn&#39;t in the S3 Files cache yet and needs to be hydrated from S3 on first access. After warmup, listdir settles at 8ms - 10x faster than the S3 API.</p><p>The biggest wins are in small file reads (no per-object HTTP round trip), writes (no multipart setup for small files), and rename (a single inode operation vs CopyObject + DeleteObject).</p><h3>The Lambda Managed Instances Connection</h3><p>In my <a href="https://darryl-ruggles.cloud/lambda-managed-instances-with-terraform-multi-concurrency-high-memory-and-compute-options">previous post on Lambda Managed Instances</a>, I explored how high-memory Lambda functions unlock new workload patterns. S3 Files adds another dimension to this.</p><p>When your Lambda function has <strong>512MB or more memory</strong>, S3 Files enables direct S3 read routing: reads of <strong>1 MiB or larger</strong> bypass the file system’s high-performance storage entirely and stream directly from S3 at up to 3 GB/s per client (that’s a throughput ceiling, not a typical number — actual throughput depends on file size, network, and concurrency). These direct reads don’t incur S3 Files access charges — you only pay standard S3 GET pricing. (Your Lambda execution role needs s3:GetObject and s3:GetObjectVersion on the bucket for this to work - without them, reads silently fall back to the cached path.)</p><p>There’s a separate threshold at play too: files <strong>smaller than 128 KiB</strong> are asynchronously imported into the high-performance storage on first access (a prefetch optimization, not a bypass). Files between 128 KiB and 1 MiB get metadata imported but data is fetched on demand. This creates a three-tier read architecture:</p><ul><li><strong>Tiny files (under 128 KiB)</strong> (configs, metadata, indexes): prefetched into S3 Files cache, sub-millisecond on subsequent reads</li><li><strong>Mid-size files (128 KiB to 1 MiB)</strong>: fetched on demand from the cache or S3, depending on access pattern</li><li><strong>Large files (1 MiB and above)</strong> (datasets, models, media): streamed directly from S3 at 3 GB/s, skipping the cache entirely</li></ul><p>The 128 KiB import threshold is tunable per file system via aws_s3files_synchronization_configuration in Terraform (not shown in the demo). The 1 MiB direct-read bypass is not tunable.</p><p>For data-intensive Lambda workloads, combining Managed Instances (multi-concurrency, high memory) with S3 Files (mounted file system, direct S3 read bypass) is a compelling alternative to containerized processing.</p><h3>The Three-Way EC2 Comparison: S3 API vs S3 Files vs Mountpoint</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1012/0*1B8bF2yF3DlMVOSz.png" /></figure><p>The Lambda benchmark above covers the serverless use case, but it doesn’t include <a href="https://github.com/awslabs/mountpoint-s3">Mountpoint for Amazon S3</a> — AWS’s FUSE-based file-system client. Mountpoint is widely used for analytics and ML workloads, so it’s a natural comparison. There’s just one problem: <strong>Mountpoint can’t run on Lambda</strong>. It’s FUSE-based, and Lambda’s Firecracker microVM doesn’t expose /dev/fuse or grant CAP_SYS_ADMIN - both required for userspace file-system mounts. S3 Files sidesteps this entirely by using NFS, which Lambda natively supports through its existing EFS mount infrastructure.</p><p>So for the three-way comparison, I added a Graviton EC2 instance (c7g.large, arm64, in the same VPC) with both S3 Files and Mountpoint mounted, plus direct S3 API access via boto3. Same bucket, same data, three different interfaces.</p><h3>Large-Directory Walk (10,000 Small Files)</h3><p>Seed 10,000 small text files under a single prefix, then enumerate every entry and stat each one:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*95GFhwF0l1EWEVPhelhW8Q.png" /></figure><p>S3 Files is 1.8x faster than the S3 API. Mountpoint is <strong>194x slower</strong> — nearly three minutes for 10,000 entries. This is Mountpoint’s known weakness: it makes a ListObjectsV2 call per directory and then individual HeadObject calls for stat(), with no prefetching or metadata caching. If your workload involves browsing or enumerating directories, Mountpoint is the wrong tool.</p><h3>Large-File Throughput (5 x 1 GiB Random Binary)</h3><p>Seed five 1 GiB random binary files, stream-read each one into a SHA-256 hash, write the digest back:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oEPzR8R9PG6HGdErf3fPYA.png" /></figure><p>For large sequential reads, Mountpoint dominates at 459 MiB/s — nearly 3x S3 Files and 10x the S3 API. This isn’t an accident: Mountpoint splits each large read into <strong>parallel HTTP Range GET requests</strong> across multiple TCP connections, with aggressive read-ahead prefetching. A 1 GiB file read becomes many concurrent range fetches that saturate the network link. It’s a purpose-built parallel download accelerator for large, sequential, read-heavy workloads (ML training data, analytics datasets, media processing).</p><p>S3 Files (161 MiB/s) goes through NFS 4.1/4.2 to a managed server that reads from its cache or S3 — the protocol framing and cache coherency tracking add overhead. The S3 API (43 MiB/s) is a single GetObject stream over one HTTP connection with no parallelism.</p><p>The same design that makes Mountpoint fast for large reads makes it very slow for directories: it has <strong>no metadata cache</strong>, so every stat() call becomes an individual HeadObject API call to S3. That&#39;s why 10,000 files takes 176 seconds.</p><p>Write time tells a similar story: Mountpoint takes 1,469ms to write five small digest files. S3 Files does it in 71ms. S3 API in 151ms. Mountpoint’s FUSE-to-S3 translation adds high per-file overhead for small writes.</p><h3>When to Use Which</h3><p>The benchmark reveals that no single approach wins everywhere:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6ASbM4-8fENHG3s71diyCA.png" /></figure><h3>Things to Look Out For</h3><p>S3 Files is impressive, but it’s not magic. Here are the real-world constraints you need to know:</p><h3>60-Second Commit Delay</h3><p>S3 Files uses a “stage and commit” model. File-system writes are batched for approximately 60 seconds before committing to S3. Files you write are immediately visible through the NFS mount, but they won’t appear in aws s3 ls or s3.list_objects_v2() for about a minute.</p><p>For the document processing use case, this is fine — the Lambda reads and writes through the mount, so consistency is maintained within the NFS view. But if you have a downstream process polling S3 directly for new objects, it will see a delay.</p><h3>VPC Cold Starts</h3><p>Putting Lambda in a VPC adds cold start latency. AWS has improved this significantly with Hyperplane ENI caching, but in this benchmark I observed roughly 1–2 seconds of additional cold start time compared to the non-VPC Lambda. For infrequently-invoked functions, this matters. For functions that process batches (like our document processor), the cold start is amortized across many files.</p><h3>50 Million Object Limit</h3><p>Each mounted file system supports up to 50 million objects. For most workloads this is generous, but if you’re mounting a bucket with hundreds of millions of small objects, you’ll need to scope the mount to a prefix. In Terraform, this is a creation-time argument on aws_s3files_file_system (not shown in the demo, which mounts the entire bucket). Via the CLI, use the --prefix flag on create-file-system.</p><h3>Key Name Restrictions</h3><p>S3 allows object keys that don’t map cleanly to POSIX filenames. According to AWS documentation, keys with trailing slashes, path traversal patterns (../), or components longer than 255 characters will not appear in the file-system view. The objects remain accessible via the S3 API, but the file system won&#39;t show them. AWS recommends monitoring the CloudWatch ImportFailures metric to detect these cases, as there are no client-side errors.</p><h3>Delete and Update Propagation</h3><p>S3-side changes only propagate to the NFS mount for files whose data is currently in the high-performance storage (the “hot” cache). In testing, hot-file deletes via the S3 API remained readable on the mount for roughly 6–18 seconds before disappearing. Modifications followed the same pattern: the mount saw the stale version until the EventBridge notification arrived.</p><p>For files whose data has been expired out of the cache (cold files), S3-side changes don’t propagate at all until the next NFS read, at which point S3 Files fetches the latest version from S3. So the 6–18 second range observed above is a hot-path number; cold-path updates are lazy and unbounded. If you’re designing a pipeline that writes via the S3 API and reads via the mount, test both cases.</p><h3>Access Point Ownership</h3><p>This is the biggest surprise I hit, and it drove a design change in the demo.</p><p>Objects written through the NFS mount <em>do</em> carry POSIX ownership metadata — S3 Files stores it as user-defined S3 object metadata (file-permissions, file-owner, file-group, file-mtime) on every object it writes. But objects written via the S3 API - s3.put_object(), aws s3 cp, the before Lambda&#39;s boto3 calls - don&#39;t have that metadata. When S3 Files imports those API-written objects into the NFS view, they get default permissions: root:root (UID 0, GID 0) with mode 0644 for files and 0755 for directories. That asymmetry is the mechanism behind this issue: directories are traversable and readable by everyone (which is why the inbox reads worked), but only writable by root (which is why creating entries in processed/ failed). Those directories, incidentally, are just S3 prefixes materialized as zero-byte objects - which is why S3-API writes can create them as a side effect of PutObject and why they end up root-owned when imported.</p><p>The first time my after Lambda ran with posix_user { uid = 1000, gid = 1000 }, it failed with PermissionError: [Errno 13] Permission denied: &#39;/mnt/docs/processed/.... The Lambda could read the inbox just fine, but it couldn&#39;t <em>create</em> anything under /mnt/docs/processed/ because S3 Files had reflected a previous before-Lambda PutObject into NFS as a root-owned directory.</p><p>Four ways out, ordered from best (least privilege) to most expedient:</p><ol><li><strong>Use a scoped access point path</strong> (recommended for production). Set root_directory.path = &quot;/lambda-workspace&quot; with creation_permissions { owner_uid = 1000, owner_gid = 1000, permissions = &quot;755&quot; } and posix_user { uid = 1000, gid = 1000 }. S3 Files creates that path owned by your UID, and the Lambda only sees its owned subtree. The tradeoff: every S3 object the Lambda needs to see must be keyed under lambda-workspace/..., and a raw aws s3 cp into any other prefix is invisible to the mount. This enforces least privilege at the access-point level.</li><li><strong>Grant </strong><strong>s3files:ClientRootAccess</strong> on the Lambda&#39;s IAM role. This lets a non-root UID (still posix_user { uid = 1000 }) perform operations against root-owned entries - including creating files inside root-owned directories imported from S3 - without running the entire Lambda as UID 0. It&#39;s the middle ground: keep least-privilege POSIX identity, elevate only for cross-boundary operations with S3-origin content. This permission is included in the AmazonS3FilesClientFullAccess managed policy, which is probably why I missed it - the demo&#39;s inline policy has only ClientMount + ClientWrite.</li><li><strong>Avoid path collisions</strong>: have each S3-API-side producer write to a prefix the NFS client never writes into. The demo does this — the before Lambda writes to processed-before/ and the after Lambda writes to processed-after/ - so their outputs never fight over directory ownership.</li><li><strong>Run as root</strong> (access point posix_user { uid = 0, gid = 0 }). The Lambda runs as &quot;root&quot; for NFS purposes and can write alongside S3-born files. This is what the demo uses because the side-by-side comparison needs both approaches to see the same bucket root. <strong>This is the opposite of least privilege</strong> - any NFS client can read, write, and delete anything on the mount. Last resort only.</li></ol><p>If you’re using S3 Files to replace an existing boto3 pipeline, plan this up front. Any prefix your NFS clients will <em>write into</em> should be created from the mount side first, or left entirely unpopulated from the S3 side. Anything written via PutObject will arrive in NFS as root-owned and block writes from non-root access points (unless you&#39;ve granted ClientRootAccess).</p><p>Note: the demo pairs option 4 with an aws_s3files_file_system_policy that restricts which IAM principals can mount at all (deny-by-default, allow only the Lambda and EC2 benchmark roles, enforce TLS). If you use uid=0, this resource-based policy is your primary access control.</p><p>Related: don’t pre-create “directory” marker objects (zero-byte inbox/, processed/, etc.) from Terraform. I had three aws_s3_object resources doing this and they turned out to be the exact cause of the ownership collision. The Lambda&#39;s os.makedirs(exist_ok=True) creates the directories over NFS with the correct access-point ownership - let it do its job.</p><h3>S3-to-NFS Propagation Delay</h3><p>Also worth knowing: writes go in both directions, but they don’t propagate symmetrically. NFS writes commit to S3 on the 60-second schedule described above. S3 writes appear in the NFS view asynchronously via EventBridge notifications, which typically takes a few seconds but can take longer under load. If your benchmark seeds files via s3.put_object() and immediately invokes an NFS-mounted Lambda, the mount will see an empty inbox. The benchmark script in this project waits 60 seconds after S3-seeding to sidestep this.</p><p><strong>Conflict resolution:</strong> if the same file is modified through both the NFS mount and the S3 API before synchronization completes, the S3 bucket version wins. The file-system copy is not silently overwritten — it gets moved to a .s3files-lost+found-&lt;file-system-id&gt; directory on the mount. Files in lost+found are not copied back to the S3 bucket and persist indefinitely on the file system, counting toward storage costs until explicitly deleted. This is important to understand for mixed API + file-system workflows: the S3 side is always authoritative, and your NFS edits may end up in lost+found if there&#39;s a race.</p><h3>When NOT to Use S3 Files</h3><p>S3 Files isn’t always the right choice:</p><ul><li><strong>Read-only analytics at lowest cost</strong>: Mountpoint for S3 adds zero software cost and is optimized for sequential reads of large files. If you’re running Spark, Presto, or ML training jobs that only read data, Mountpoint is cheaper and simpler (no VPC required).</li><li><strong>Non-AWS or S3-compatible storage</strong>: s3fs-fuse works with MinIO, Ceph, and other S3-compatible object stores. S3 Files is AWS-only.</li><li><strong>Existing EFS mounts</strong>: Lambda supports one file-system mount — EFS or S3 Files, not both. For any new build where the backing data lives in S3, prefer S3 Files over EFS (you skip the EFS-to-S3 sync problem entirely). Only stick with EFS if the function needs a shared writable file system that multiple Lambda invocations coordinate through simultaneously.</li><li><strong>Latency-critical writes that must appear in S3 immediately</strong>: The 60-second commit delay means writes aren’t visible to S3 API consumers right away. If you need sub-second S3 visibility, stick with direct S3 API calls.</li></ul><h3>Wrapping Up</h3><p>S3 Files eliminates an entire category of boilerplate from AWS applications. The download-process-upload pattern that we’ve all written hundreds of times is no longer necessary. Your code just reads and writes files. The underlying storage happens to be S3.</p><p>The Terraform story is solid from day one — native provider resources shipped in v6.40.0, just one day after S3 Files went GA. Three resources (aws_s3files_file_system, aws_s3files_mount_target, aws_s3files_access_point) cover the full setup, and the file_system_config block on aws_lambda_function works identically to the existing EFS mount pattern.</p><p>All the code for this post — Terraform modules, Lambda handlers (with Powertools), the EC2 runner, benchmark scripts, and the Makefile — is available in the <a href="https://github.com/RDarrylR/s3-files-initial">companion repository</a>.</p><h3>Cost Summary</h3><p>For a demo deployment: approximately $75/month if you leave everything running. The EC2 instance (about $53/month) and SSM VPC interface endpoints (about $21/month, needed because the EC2 is in a private subnet with no NAT) are the bulk. Lambda and S3 costs are negligible. Stop the EC2 and run make destroy when done.</p><p><strong>Cost note for production:</strong> S3 Files meters data reads and writes with a <strong>minimum of 32 KiB per operation</strong>, regardless of actual size. This benchmark’s medium text files (500–2000 words) are above that threshold, so it didn’t show up. But at scale with many tiny files — say 10,000 sub-1 KiB JSON configs read once each — you’d pay for 10,000 x 32 KiB = 320 MiB of reads, not 10 MiB. For small-file-heavy workloads, factor this into your cost model.</p><h3>Key Takeaways</h3><ol><li>S3 Files provides file-system semantics on S3 via NFS v4.1/4.2 — read, write, rename, advisory file locking</li><li>Lambda functions with &gt;=512MB memory get direct S3 read bypass for reads &gt;=1 MiB (up to 3 GB/s ceiling) — but only if the execution role has s3:GetObject</li><li>No NAT Gateway needed — use S3 Gateway VPC endpoints (but add SSM interface endpoints for EC2)</li><li>Mountpoint can’t run on Lambda (no FUSE) — S3 Files is the only file-system option for serverless</li><li>For large sequential reads, Mountpoint still wins (459 MiB/s vs 161 MiB/s) — it was purpose-built for throughput</li><li>For directory operations, Mountpoint is prohibitively slow (176s vs 0.9s for 10K entries) — use S3 Files</li><li>The 60-second commit delay (NFS to S3) and the async EventBridge propagation (S3 to NFS) are the two consistency boundaries you have to design around</li><li>Access point ownership interacts with S3-origin objects in ways that will surprise you — plan prefix ownership up front</li><li>The trust policy service principal is elasticfilesystem.amazonaws.com, not s3files.amazonaws.com - S3 Files is built on EFS</li><li>Native Terraform support shipped day one in AWS provider v6.40.0, but use a time_sleep between mount targets and Lambda to avoid lifecycle state races</li></ol><h3>Resources</h3><ul><li><a href="https://aws.amazon.com/blogs/aws/launching-s3-files-making-s3-buckets-accessible-as-file-systems/">Launching S3 Files, Making S3 Buckets Accessible as File Systems</a> — AWS News Blog announcement</li><li><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-files.html">Amazon S3 Files Documentation</a> — Official user guide</li><li><a href="https://docs.aws.amazon.com/lambda/latest/dg/configuration-filesystem-s3files.html">Configuring S3 Files Access for Lambda</a> — Lambda-specific setup guide</li><li><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-files-getting-started.html">S3 Files Getting Started Tutorial</a> — Step-by-step walkthrough</li><li><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3files_file_system">Terraform aws_s3files_file_system Resource</a> — Terraform provider docs</li><li><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3files_mount_target">Terraform aws_s3files_mount_target Resource</a> — Mount target configuration</li><li><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3files_access_point">Terraform aws_s3files_access_point Resource</a> — Access point with POSIX user mapping</li><li><a href="https://www.theregister.com/2026/04/09/aws_s3_files_stress_test_corey_quinn/">AWS S3 Files Stress Test</a> — The Register’s independent stress test with edge case findings</li><li><a href="https://computingforgeeks.com/s3-files-vs-mountpoint-vs-s3fs/">S3 Files vs Mountpoint vs s3fs-fuse Comparison</a> — Detailed feature and performance comparison</li><li><a href="https://dev.to/aws-builders/architecture-layers-that-s3-files-eliminates-and-creates-16ke">Architecture Layers That S3 Files Eliminates</a> — Architectural patterns analysis</li><li><a href="https://github.com/awslabs/mountpoint-s3">Mountpoint for Amazon S3</a> — The read-heavy alternative for analytics workloads</li><li><a href="https://darryl-ruggles.cloud/lambda-managed-instances-with-terraform-multi-concurrency-high-memory-and-compute-options">Lambda Managed Instances with Terraform</a> — High-memory Lambda patterns that complement S3 Files</li><li><a href="https://darryl-ruggles.cloud/powertools-for-aws-lambda-best-practices-by-default">Powertools for AWS Lambda — Best Practices By Default</a> — Observability patterns for Lambda functions</li></ul><p><em>Connect with me on</em> <a href="https://x.com/RDarrylR">X</a><em>,</em> <a href="https://bsky.app/profile/darrylruggles.bsky.social">Bluesky</a><em>,</em> <a href="https://www.linkedin.com/in/darryl-ruggles/">LinkedIn</a><em>,</em> <a href="https://github.com/RDarrylR">GitHub</a><em>,</em> <a href="https://medium.com/@RDarrylR">Medium</a><em>,</em> <a href="https://dev.to/rdarrylr">Dev.to</a><em>, or the</em> <a href="https://community.aws/@darrylr">AWS Community</a><em>. Check out more of my projects at</em> <a href="https://darryl-ruggles.cloud/">darryl-ruggles.cloud</a> <em>and join the</em> <a href="https://www.believeinserverless.com/">Believe In Serverless</a> <em>community.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a37f5a278c05" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Amazon Aurora DSQL: A Practical Guide to AWS’s Distributed SQL Database]]></title>
            <link>https://medium.com/@RDarrylR/amazon-aurora-dsql-a-practical-guide-to-awss-distributed-sql-database-e842b46234c7?source=rss-7ecf33fd997------2</link>
            <guid isPermaLink="false">https://medium.com/p/e842b46234c7</guid>
            <category><![CDATA[serverless]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[aurora-dsql]]></category>
            <category><![CDATA[postgres]]></category>
            <category><![CDATA[database]]></category>
            <dc:creator><![CDATA[Darryl Ruggles]]></dc:creator>
            <pubDate>Sun, 19 Apr 2026 16:38:28 GMT</pubDate>
            <atom:updated>2026-04-19T16:38:28.264Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SyerDtLvruMB8AT9YIh3Cg.png" /></figure><p><em>Architecture, features, Terraform setup, and real application code — March 2026</em></p><p>When AWS announced Aurora DSQL at re:Invent 2024, I was very interested. We had heard promises about distributed SQL databases before and I really wanted to try it out. I experimented with it locally for a while and then built the <a href="https://darryl-ruggles.cloud/dsql-kabob-store/">Kabob Store</a> example on it. Fifteen months later, DSQL has gone from preview to general availability, expanded to 14 regions, and shipped a steady stream of features. It fills the gap between DynamoDB’s serverless economics and Aurora PostgreSQL’s SQL power — and it does it well.</p><p>This is my comprehensive look at where DSQL stands in March 2026: what it does, what it doesn’t do yet, how to set it up with Terraform, and practical application code you can use today.</p><h3>Why Aurora DSQL?</h3><p>For years, the database decision on AWS looked like this:</p><ul><li><strong>Need serverless economics?</strong> DynamoDB. But learn single-table design and give up SQL.</li><li><strong>Need SQL?</strong> RDS or Aurora PostgreSQL. But accept always-on costs, instance sizing, and 10–15 minute provisioning.</li><li><strong>Need multi-Region?</strong> DynamoDB Global Tables. SQL wasn’t an option without manual replication.</li></ul><p>Aurora DSQL eliminates the tradeoff. Four things make it different:</p><ul><li><strong>Serverless to zero</strong> — No instances, no capacity planning. Zero DPU charges when idle. Provisions in under 60 seconds.</li><li><strong>PostgreSQL compatible</strong> — Based on PostgreSQL 16. Use psql, psycopg2, pgx, JDBC — the drivers you already know.</li><li><strong>Strongly consistent</strong> — Not eventually consistent. Snapshot isolation with linearizability. Readers always see committed data.</li><li><strong>Active-active multi-Region</strong> — Two full regions with concurrent reads and writes. No leader, no failover, no replication lag on commit.</li></ul><h3>What is Aurora DSQL?</h3><p>Aurora DSQL is a serverless, distributed SQL database that disaggregates every component of a traditional database engine. Unlike Aurora PostgreSQL (which separates storage from compute but keeps them coupled), DSQL breaks the database into six independently scaling components:</p><ol><li><strong>Query Processors (QPs)</strong> — Run customized PostgreSQL engines inside Firecracker MicroVMs. Handle SQL parsing, planning, and execution. Scale independently based on query load.</li><li><strong>Adjudicators</strong> — Validate transactions at COMMIT time using Optimistic Concurrency Control (OCC). Stateless and reconstructible.</li><li><strong>Journal</strong> — A Paxos-based distributed transaction log (same technology as MemoryDB). Provides cross-AZ and cross-Region durability.</li><li><strong>Crossbar</strong> — Merges journal streams and publishes committed writes to storage replicas. Sits between the Journal and Storage layers, ensuring all storage replicas receive the same ordered stream of committed transactions.</li><li><strong>Storage</strong> — MVCC storage replicas distributed across 3 AZs. Consume committed entries from the Crossbar. Scale independently.</li><li><strong>Control Plane</strong> — Coordinates all components, handles cluster lifecycle and scaling.</li></ol><p><em>Note: The</em> <a href="https://docs.aws.amazon.com/aurora-dsql/latest/userguide/what-is-aurora-dsql.html">official AWS User Guide</a> <em>describes these layers as “Relay and connectivity, Compute and databases, Transaction log/concurrency control/isolation, Storage, and Control plane.” The component names used here (Query Processors, Adjudicators, Journal, Crossbar) come from</em> <a href="https://brooker.co.za/blog/2024/12/03/aurora-dsql.html">Marc Brooker’s architecture deep-dive series</a> <em>and the</em> <a href="https://aws.amazon.com/blogs/database/introducing-amazon-aurora-dsql/">AWS Database Blog</a><em>, which provide more implementation detail.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*3VClWwDdsBkCI_r5.png" /></figure><p>The key design achievement, as Marc Brooker (VP/Distinguished Engineer at AWS) explained in his <a href="https://brooker.co.za/blog/2024/12/03/aurora-dsql.html">DSQL blog series</a>, is that cross-region latency is incurred <strong>only at COMMIT time</strong>, not per-statement. During a transaction, reads and writes execute locally on the Query Processor. Only when you commit does the system coordinate with the Adjudicator and Journal for conflict detection and durability. Read-only transactions need no validation, no persistence, and no cross-region coordination at all.</p><h3>Core Concepts</h3><p><strong>Optimistic Concurrency Control (OCC)</strong> — DSQL doesn’t use locks. Transactions proceed without blocking each other. At COMMIT, the Adjudicator checks for write-write conflicts. If two transactions modified the same rows, one succeeds and the other gets a serialization failure (SQLSTATE 40001). Your application retries the failed transaction. No deadlocks, ever.</p><p><strong>Snapshot Isolation</strong> — Each transaction sees a consistent snapshot of the database as of its start time (tau_start). All reads within a transaction see the same data, regardless of concurrent commits by other transactions. Equivalent to PostgreSQL’s REPEATABLE READ.</p><p><strong>IAM Authentication</strong> — No database passwords. Period. Applications generate tokens using generate_db_connect_auth_token (for runtime DML) or generate_db_connect_admin_auth_token (for schema migrations only). Integrates with IAM roles, so your ECS tasks and Lambda functions authenticate using their execution role. Tokens default to 15 minutes but can be configured up to one week using the token-duration-secs parameter in the connectors and CLI.</p><p><strong>Asynchronous Indexes</strong> — DSQL requires CREATE INDEX ASYNC (synchronous CREATE INDEX is not supported). The index builds asynchronously while transactions continue. You can monitor build progress through system catalog queries.</p><p><strong>Single DDL Per Transaction</strong> — Each CREATE TABLE, ALTER TABLE, or CREATE INDEX statement needs its own transaction with an explicit commit before the next DDL statement.</p><h3>Feature Timeline: From Preview to Production</h3><p>DSQL has shipped features at a steady pace since launch. Here’s what has been added:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WeXJb8YcPuHy1TrJYL0erw.png" /></figure><h3>Region Availability (March 2026)</h3><p>DSQL is now available in <strong>14 regions</strong> across 4 continents:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8f8n23-PEG4ChUuGkd0-eA.png" /></figure><h3>Multi-Region Cluster Sets</h3><p>Multi-Region clusters must stay within one geographic set:</p><ul><li><strong>US</strong>: us-east-1, us-east-2, us-west-2</li><li><strong>Europe</strong>: eu-central-1, eu-west-1, eu-west-2, eu-west-3</li><li><strong>Asia Pacific</strong>: ap-northeast-1, ap-northeast-2, ap-northeast-3</li></ul><p>Canada (ca-central-1, ca-west-1), Sydney (ap-southeast-2), and Melbourne (ap-southeast-4) are available as single-region clusters only and are not part of any multi-Region set. This is a common gotcha for customers in those regions.</p><p>Cross-continent multi-Region clusters are not supported. For global data sync across continents, DynamoDB Global Tables remain the option.</p><h3>DSQL vs the Alternatives</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3thwrad9n_BO33oJ-RPvdw.png" /></figure><h3>When to Use What</h3><p><strong>Choose DSQL when</strong> you need SQL with serverless economics, multi-Region strong consistency, or you’re building new applications that benefit from zero infrastructure management.</p><p><strong>Choose Aurora PostgreSQL when</strong> you need foreign keys, stored procedures, triggers, pgvector for AI embeddings, or you’re running an existing PostgreSQL application that uses unsupported features. Aurora Serverless v2 now scales to 0 ACUs with auto-pause (since November 2024), so it also offers scale-to-zero economics — with the tradeoff of a ~15-second cold start on resume.</p><p><strong>Choose DynamoDB when</strong> your data model fits key-value or document patterns naturally, you need sub-millisecond latency, cross-continent global replication, or unlimited throughput scaling.</p><h3>Setting Up DSQL with Terraform</h3><p>All the Terraform code below uses Terraform &gt;= 1.11 and the AWS provider ~&gt; 6.0 . The terraform-aws-modules/rds-aurora DSQL submodule requires Terraform &gt;= 1.11 and provider &gt;= 6.18. The complete examples are in the <a href="https://github.com/RDarrylR/aurora-dsql-2026">GitHub repo</a>.</p><h3>Single-Region Cluster</h3><p>This is the simplest setup. One resource, 60 seconds to provision, automatically distributed across 3 AZs:</p><pre>terraform {<br>  required_version = &quot;&gt;= 1.11&quot;<br>  required_providers {<br>    aws = {<br>      source  = &quot;hashicorp/aws&quot;<br>      version = &quot;~&gt; 6.0&quot;<br>    }<br>  }<br>}<br>data &quot;aws_region&quot; &quot;current&quot; {}<br>resource &quot;aws_dsql_cluster&quot; &quot;main&quot; {<br>  deletion_protection_enabled = false<br>  # For production, enable deletion protection and add a KMS CMK:<br>  # deletion_protection_enabled = true<br>  # kms_encryption_key          = aws_kms_key.dsql.arn<br>  tags = {<br>    Name        = &quot;my-app-dsql&quot;<br>    Environment = &quot;dev&quot;<br>  }<br>}<br># DSQL has no &quot;endpoint&quot; attribute - construct it from the identifier<br>output &quot;dsql_endpoint&quot; {<br>  value = &quot;${aws_dsql_cluster.main.identifier}.dsql.${data.aws_region.current.id}.on.aws&quot;<br>}<br>output &quot;dsql_arn&quot; {<br>  value = aws_dsql_cluster.main.arn<br>}</pre><p>That’s it. No instance class, no storage allocation, no replica configuration. One resource gives you a PostgreSQL-compatible database with 99.99% availability.</p><h3>Multi-Region Cluster with Terraform Module</h3><p>For production workloads requiring 99.999% availability, use multi-Region clusters. The official terraform-aws-modules/rds-aurora module includes a DSQL submodule:</p><pre>provider &quot;aws&quot; {<br>  region = &quot;us-east-1&quot;<br>}<br><br>provider &quot;aws&quot; {<br>  alias  = &quot;secondary&quot;<br>  region = &quot;us-east-2&quot;<br>}<br>module &quot;dsql_primary&quot; {<br>  source  = &quot;terraform-aws-modules/rds-aurora/aws//modules/dsql&quot;<br>  version = &quot;~&gt; 10.0&quot;<br>  deletion_protection_enabled = false<br>  witness_region              = &quot;us-west-2&quot;<br>  create_cluster_peering      = true<br>  clusters                    = [module.dsql_secondary.arn]<br>  tags = {<br>    Name = &quot;my-app-dsql-primary&quot;<br>  }<br>}<br>module &quot;dsql_secondary&quot; {<br>  source  = &quot;terraform-aws-modules/rds-aurora/aws//modules/dsql&quot;<br>  version = &quot;~&gt; 10.0&quot;<br>  providers = {<br>    aws = aws.secondary<br>  }<br>  deletion_protection_enabled = false<br>  witness_region              = &quot;us-west-2&quot;<br>  create_cluster_peering      = true<br>  clusters                    = [module.dsql_primary.arn]<br>  tags = {<br>    Name = &quot;my-app-dsql-secondary&quot;<br>  }<br>}</pre><p>The module handles cluster peering automatically. One terraform apply creates:</p><ul><li>Primary cluster in us-east-1 with full read/write endpoint</li><li>Secondary cluster in us-east-2 with full read/write endpoint</li><li>Witness region in us-west-2 for Journal-only quorum (no endpoint, no user access)</li><li>Bidirectional peering with synchronous replication</li></ul><p>Both endpoints present a single logical database. Your application can read and write to either endpoint. Strong consistency across both regions with zero replication lag on commit.</p><p>If you prefer using the native aws_dsql_cluster resource directly instead of the module, the multi-Region interface uses multi_region_properties with witness_region - see the commented-out Option B in the <a href="https://github.com/RDarrylR/aurora-dsql-2026/blob/main/terraform/dsql-multi-region.tf">dsql-multi-region.tf</a> example. Also note that AWS provider 6.x introduced per-resource region attributes, which can eliminate the need for provider aliases in some configurations.</p><h3>IAM Authentication Policy</h3><p>DSQL uses two IAM permission levels. Use the right one for each role:</p><ul><li>dsql:DbConnect - Generates tokens for connecting with custom database roles. <strong>Use this for application runtime.</strong></li><li>dsql:DbConnectAdmin - Generates tokens for connecting as the admin database user (full DDL + DML). <strong>Use this only for schema migrations and admin tasks.</strong></li></ul><p>Note that the DDL/DML restriction is enforced at the database role level, not the IAM layer. DbConnect generates a token that can only authenticate as a custom role (not admin), and custom roles only have the permissions you grant them. DbConnectAdmin generates a token that authenticates as admin, which has full privileges. AWS&#39;s security best practices are clear: don&#39;t use the admin role for everyday operations. Create separate IAM roles and custom database roles for application access.</p><pre># Application runtime policy - DML only (least privilege)<br>data &quot;aws_iam_policy_document&quot; &quot;dsql_app_runtime&quot; {<br>  statement {<br>    effect    = &quot;Allow&quot;<br>    actions   = [&quot;dsql:DbConnect&quot;]<br>    resources = [aws_dsql_cluster.main.arn]<br>  }<br>}<br><br># Admin/migration policy - DDL + DML (for CI/CD pipelines, not app runtime)<br>data &quot;aws_iam_policy_document&quot; &quot;dsql_admin&quot; {<br>  statement {<br>    effect    = &quot;Allow&quot;<br>    actions   = [&quot;dsql:DbConnectAdmin&quot;]<br>    resources = [aws_dsql_cluster.main.arn]<br>  }<br>}<br># ECS task role for application runtime - uses DbConnect, NOT DbConnectAdmin<br>resource &quot;aws_iam_role&quot; &quot;app_task&quot; {<br>  name = &quot;my-app-task-role&quot;<br>  assume_role_policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [{<br>      Action    = &quot;sts:AssumeRole&quot;<br>      Effect    = &quot;Allow&quot;<br>      Principal = { Service = &quot;ecs-tasks.amazonaws.com&quot; }<br>    }]<br>  })<br>}<br>resource &quot;aws_iam_role_policy&quot; &quot;app_dsql&quot; {<br>  name   = &quot;dsql-runtime-access&quot;<br>  role   = aws_iam_role.app_task.id<br>  policy = data.aws_iam_policy_document.dsql_app_runtime.json<br>}<br># Separate role for schema migrations (CI/CD pipeline, not the running app)<br>resource &quot;aws_iam_role&quot; &quot;migration_role&quot; {<br>  name = &quot;my-app-migration-role&quot;<br>  assume_role_policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [{<br>      Action    = &quot;sts:AssumeRole&quot;<br>      Effect    = &quot;Allow&quot;<br>      Principal = { Service = &quot;ecs-tasks.amazonaws.com&quot; }<br>    }]<br>  })<br>}<br>resource &quot;aws_iam_role_policy&quot; &quot;migration_dsql&quot; {<br>  name   = &quot;dsql-admin-access&quot;<br>  role   = aws_iam_role.migration_role.id<br>  policy = data.aws_iam_policy_document.dsql_admin.json<br>}</pre><p>Always scope DSQL permissions to the specific cluster ARN. No wildcard resources. Your running application should never have DbConnectAdmin - reserve that for migration tasks.</p><h3>Custom Database Role (Least Privilege at the Database Layer)</h3><p>IAM controls which token type you can generate, but you should also avoid connecting as admin for everyday operations. Create a custom database role and map it to an IAM identity:</p><pre>-- Connect as admin (one-time setup via DbConnectAdmin)<br>CREATE ROLE app_role WITH LOGIN;<br>GRANT USAGE ON SCHEMA public TO app_role;<br>GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_role;<br><br>-- Map the IAM role ARN to the custom database role (DSQL-specific syntax)<br>AWS IAM GRANT app_role TO &#39;arn:aws:iam::123456789012:role/my-app-task-role&#39;;<br>-- To revoke later:<br>-- AWS IAM REVOKE app_role FROM &#39;arn:aws:iam::123456789012:role/my-app-task-role&#39;;</pre><p>Then connect as the custom role in your application code:</p><pre>conn = psycopg2.connect(<br>    host=cluster_endpoint,<br>    port=5432,<br>    database=&quot;postgres&quot;,<br>    user=&quot;app_role&quot;,  # Custom role, not admin<br>    password=token,   # Token from generate_db_connect_auth_token<br>    sslmode=&quot;require&quot;,<br>)</pre><p>This completes the least-privilege story at both layers: IAM controls token generation (DbConnect vs DbConnectAdmin), and the database role controls what SQL the connection can execute.</p><h3>PrivateLink (Production)</h3><p>For production workloads, keep database traffic off the public internet using VPC endpoints:</p><pre>resource &quot;aws_vpc_endpoint&quot; &quot;dsql&quot; {<br>  vpc_id              = aws_vpc.main.id<br>  service_name        = aws_dsql_cluster.main.vpc_endpoint_service_name<br>  vpc_endpoint_type   = &quot;Interface&quot;<br>  subnet_ids          = aws_subnet.private[*].id<br>  security_group_ids  = [aws_security_group.dsql_endpoint.id]<br>  private_dns_enabled = true<br>}<br><br>resource &quot;aws_security_group&quot; &quot;dsql_endpoint&quot; {<br>  name_prefix = &quot;${var.project_name}-dsql-endpoint-&quot;<br>  vpc_id      = aws_vpc.main.id<br>  ingress {<br>    from_port       = 5432<br>    to_port         = 5432<br>    protocol        = &quot;tcp&quot;<br>    security_groups = [aws_security_group.app.id]<br>  }<br>  egress {<br>    from_port   = 0<br>    to_port     = 0<br>    protocol    = &quot;-1&quot;<br>    cidr_blocks = [&quot;0.0.0.0/0&quot;]<br>    description = &quot;Allow all outbound (required for VPC endpoint communication)&quot;<br>  }<br>}</pre><p>With private_dns_enabled = true, your application connects using the same cluster endpoint - no code changes needed. For connections from on-premises via Direct Connect without private DNS, use the amzn-cluster-id connection parameter.</p><h3>Application Code: Python</h3><p>The examples below use Python 3.13+ with psycopg2 2.9.11 and boto3. The full example is in <a href="https://github.com/RDarrylR/aurora-dsql-2026/blob/main/python/dsql_connection.py">dsql_connection.py</a>.</p><h3>Connection with IAM Auth</h3><pre>import boto3<br>import psycopg2<br>from psycopg2.extras import RealDictCursor<br><br>client = boto3.client(&quot;dsql&quot;, region_name=&quot;us-east-1&quot;)<br>cluster_endpoint = f&quot;{cluster_id}.dsql.us-east-1.on.aws&quot;<br># Token method must match the user:<br># - user=&quot;admin&quot; -&gt; generate_db_connect_admin_auth_token (DDL + DML)<br># - custom role  -&gt; generate_db_connect_auth_token (DML only)<br>token = client.generate_db_connect_admin_auth_token(cluster_endpoint, &quot;us-east-1&quot;)<br>conn = psycopg2.connect(<br>    host=cluster_endpoint,<br>    port=5432,<br>    database=&quot;postgres&quot;,<br>    user=&quot;admin&quot;,  # For production, use a custom database role - see &quot;Custom Database Role&quot; section<br>    password=token,<br>    sslmode=&quot;require&quot;,<br>    cursor_factory=RealDictCursor,<br>)</pre><p>Or use the official connector (pip install aurora-dsql-python-connector, v0.2.6+) which handles token refresh automatically:</p><pre>from aurora_dsql_python_connector import connect<br><br>conn = connect(<br>    cluster_endpoint=&quot;abc123.dsql.us-east-1.on.aws&quot;,<br>    region=&quot;us-east-1&quot;,<br>)</pre><h3>The OCC Retry Pattern</h3><p>This is the most important pattern for DSQL applications. Since DSQL uses Optimistic Concurrency Control instead of locks, write transactions can fail at COMMIT when concurrent modifications conflict:</p><pre>import time<br>import psycopg2.errors<br><br>def with_occ_retry(func, max_retries=3, base_delay=0.1):<br>    &quot;&quot;&quot;Retry wrapper for OCC conflicts (SQLSTATE 40001).&quot;&quot;&quot;<br>    for attempt in range(max_retries):<br>        try:<br>            return func()<br>        except psycopg2.errors.SerializationFailure:<br>            if attempt == max_retries - 1:<br>                raise<br>            delay = base_delay * (2 ** attempt)<br>            time.sleep(delay)<br>def create_order(customer_email, items, total):<br>    def _do_insert():<br>        conn = db.get_connection()<br>        cur = conn.cursor()<br>        cur.execute(&quot;&quot;&quot;<br>            INSERT INTO orders (customer_email, items, total_amount)<br>            VALUES (%s, %s, %s)<br>            RETURNING *<br>        &quot;&quot;&quot;, (customer_email, json.dumps(items), total))<br>        result = cur.fetchone()<br>        conn.commit()<br>        return dict(result)<br>    return with_occ_retry(_do_insert)</pre><p>Key points about OCC:</p><ul><li>Read-only transactions never conflict — they don’t need retry logic</li><li>OCC conflicts are SQLSTATE 40001 (serialization_failure)</li><li>Use exponential backoff to avoid retry storms</li><li>Design transactions to be small and fast to minimize conflict windows</li><li>Avoid hot-spot writes (e.g., incrementing a single counter row from many threads)</li></ul><h3>Schema Setup with DDL Limits</h3><pre>def create_tables():<br>    conn = db.get_connection()<br>    cur = conn.cursor()<br><br>    # One DDL per transaction - commit before next DDL<br>    cur.execute(&quot;&quot;&quot;<br>        CREATE TABLE IF NOT EXISTS products (<br>            id UUID DEFAULT gen_random_uuid() PRIMARY KEY,<br>            name VARCHAR(200) NOT NULL,<br>            price NUMERIC(10, 2) NOT NULL,<br>            category VARCHAR(50),<br>            created_at TIMESTAMPTZ DEFAULT now()<br>        )<br>    &quot;&quot;&quot;)<br>    conn.commit()  # Must commit before next DDL<br>    cur.execute(&quot;&quot;&quot;<br>        CREATE TABLE IF NOT EXISTS orders (<br>            id UUID DEFAULT gen_random_uuid() PRIMARY KEY,<br>            customer_email VARCHAR(255) NOT NULL,<br>            items TEXT NOT NULL,<br>            total_amount NUMERIC(10, 2) NOT NULL,<br>            status VARCHAR(20) DEFAULT &#39;pending&#39;,<br>            created_at TIMESTAMPTZ DEFAULT now()<br>        )<br>    &quot;&quot;&quot;)<br>    conn.commit()  # Separate transaction for each DDL</pre><h3>Sequences and Identity Columns (New — February 2026)</h3><pre># Using identity columns for auto-incrementing IDs<br>cur.execute(&quot;&quot;&quot;<br>    CREATE TABLE IF NOT EXISTS audit_log (<br>        id BIGINT GENERATED ALWAYS AS IDENTITY (CACHE 65536) PRIMARY KEY,<br>        event_type VARCHAR(50) NOT NULL,<br>        payload TEXT,<br>        created_at TIMESTAMPTZ DEFAULT now()<br>    )<br>&quot;&quot;&quot;)<br>conn.commit()<br><br># Or use sequences directly<br>cur.execute(&quot;CREATE SEQUENCE IF NOT EXISTS invoice_seq START 1000 CACHE 65536&quot;)<br>conn.commit()<br>cur.execute(&quot;SELECT nextval(&#39;invoice_seq&#39;)&quot;)<br>next_invoice = cur.fetchone()[&quot;nextval&quot;]</pre><h3>Application Code: Node.js</h3><p>The examples below use Node.js 24.x LTS with @aws-sdk/dsql-signer and pg 8.20+. The full example is in <a href="https://github.com/RDarrylR/aurora-dsql-2026/blob/main/nodejs/dsql-connection.mjs">dsql-connection.mjs</a>. You can also use the official connector @aws/aurora-dsql-node-postgres-connector (v0.1.8+) which wraps pg with automatic IAM auth.</p><h3>Connection with AWS SDK Signer</h3><pre>import { DsqlSigner } from &quot;@aws-sdk/dsql-signer&quot;;<br>import pg from &quot;pg&quot;;<br><br>const signer = new DsqlSigner({<br>  hostname: &quot;abc123.dsql.us-east-1.on.aws&quot;,<br>  region: &quot;us-east-1&quot;,<br>});<br>// Token method matches the user:<br>// - &quot;admin&quot; -&gt; getDbConnectAdminAuthToken (DDL + DML)<br>// - custom role -&gt; getDbConnectAuthToken (DML only)<br>const token = await signer.getDbConnectAdminAuthToken();<br>const pool = new pg.Pool({<br>  host: &quot;abc123.dsql.us-east-1.on.aws&quot;,<br>  port: 5432,<br>  database: &quot;postgres&quot;,<br>  user: &quot;admin&quot;,<br>  password: token,<br>  ssl: true,<br>});</pre><h3>OCC Retry in Node.js</h3><pre>async function withOccRetry(pool, txnFn, maxRetries = 3) {<br>  for (let attempt = 0; attempt &lt; maxRetries; attempt++) {<br>    const client = await pool.connect();<br>    try {<br>      await client.query(&quot;BEGIN&quot;);<br>      const result = await txnFn(client);<br>      await client.query(&quot;COMMIT&quot;);<br>      return result;<br>    } catch (err) {<br>      await client.query(&quot;ROLLBACK&quot;).catch(() =&gt; {});<br>      if (err.code === &quot;40001&quot; &amp;&amp; attempt &lt; maxRetries - 1) {<br>        await new Promise((r) =&gt; setTimeout(r, 100 * 2 ** attempt));<br>        continue;<br>      }<br>      throw err;<br>    } finally {<br>      client.release();<br>    }<br>  }<br>}<br><br>// Usage<br>const order = await withOccRetry(pool, async (client) =&gt; {<br>  const result = await client.query(<br>    `INSERT INTO orders (customer_email, items, total_amount)<br>     VALUES ($1, $2, $3) RETURNING *`,<br>    [email, JSON.stringify(items), total]<br>  );<br>  return result.rows[0];<br>});</pre><h3>Multi-Region Application Architecture</h3><p>For applications that need 99.999% availability and low-latency reads from multiple regions, deploy your application stack in each DSQL region with Route53 latency-based routing:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*JT8adq23HFzWxSmi.png" /></figure><p>The architecture uses:</p><ul><li><strong>Route53 latency-based routing</strong> to direct users to the nearest region</li><li><strong>CloudFront</strong> for static asset caching and edge termination</li><li><strong>ECS Fargate</strong> running application containers in each region</li><li><strong>Aurora DSQL</strong> with active-active clusters in both regions and a witness for quorum</li></ul><p>Both DSQL endpoints present a single logical database. East Coast users connect to us-east-1, West Coast users connect to us-east-2 (or us-west-2 if available as a full endpoint) — both reading and writing the same strongly consistent data. The witness region in us-west-2 stores only encrypted Journal entries for quorum, with no user endpoint.</p><p>This is conceptually similar to DynamoDB Global Tables, but with full PostgreSQL SQL support and strong consistency instead of eventual consistency.</p><h3>Database Limits to Know</h3><p>DSQL has intentional limits that prevent tail latency and keep the system predictable. These aren’t bugs — they’re design choices:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/778/1*M7Y0nbk4w3IcL-m3NHZo5Q.png" /></figure><h3>Pricing</h3><p>DSQL uses a DPU (Distributed Processing Unit) billing model that covers all database activity — compute, I/O, and transaction processing — in a single metric.</p><ul><li><strong>DPU rate</strong>: $8 per million DPUs (us-east-2)</li><li><strong>Storage</strong>: $0.33 per GB-month (pay for one logical copy per region)</li><li><strong>Multi-Region writes</strong>: Additional DPU charges equal to originating write DPUs</li><li><strong>Free tier</strong>: 100,000 DPUs + 1 GB storage per month (roughly 700K TPC-C equivalent transactions)</li><li><strong>Scales to zero</strong>: No DPU charges when idle</li></ul><h3>Cost Comparison for a Modest Workload</h3><p>For an application processing 1,000 transactions per hour, 10 GB storage:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/770/1*_7tWTJ93mqLRy61lIaiiYA.png" /></figure><p>Both DSQL and Aurora Serverless v2 can now scale to zero. The difference: DSQL resumes instantly with no cold start, while Aurora Serverless v2 takes approximately 15 seconds to resume from a paused state. For development environments with intermittent traffic, both cost pennies when idle. For production workloads that need instant response times, DSQL’s zero cold start matters. DSQL is also eligible for Database Savings Plans for predictable workloads.</p><p>You can monitor DPU breakdown in CloudWatch under the AWS/AuroraDSQL namespace: ComputeDPU, ReadDPU, WriteDPU, and MultiRegionWriteDPU.</p><h3>Developer Experience and Tooling</h3><p>DSQL’s tooling ecosystem has grown quickly:</p><p><strong>Connectors</strong> (official, handle IAM auth automatically):</p><ul><li>Python: aurora-dsql-python-connector v0.2.6 - wraps psycopg, psycopg2, asyncpg</li><li>Node.js: @aws/aurora-dsql-node-postgres-connector v0.1.8 (pg) and @aws/aurora-dsql-postgresjs-connector v0.2.1 (Postgres.js)</li><li>Java: JDBC connector (PgJDBC wrapper)</li><li>Go: pgx v5.8.0 wrapper (February 2026)</li><li>Ruby: aurora-dsql-ruby-pg-connector (February 2026)</li></ul><p><strong>ORM and Migration Tooling</strong>:</p><ul><li>Tortoise ORM adapter (Python async ORM)</li><li>Prisma CLI tools (Node.js ORM integration)</li><li>Flyway dialect (database migration tooling)</li></ul><p><strong>IDE Integrations</strong>:</p><ul><li>DBeaver plugin (Community and Pro editions)</li><li>VS Code SQLTools driver</li><li>JupyterLab and SageMaker AI integration</li><li>AWS Console Query Editor</li></ul><p><strong>AI Steering</strong>:</p><ul><li>Aurora DSQL MCP Server for AI-assisted development</li><li>Kiro Powers for Kiro IDE</li><li>Skills for Claude Code, Cursor, Gemini, Codex</li><li>Steering ensures AI assistants generate DSQL-compatible code (handling OCC retries, DDL limits, IAM auth)</li></ul><p><strong>Infrastructure</strong>:</p><ul><li>Terraform 1.11+ with AWS provider 6.18+ — native aws_dsql_cluster resource</li><li>terraform-aws-modules/rds-aurora DSQL submodule for multi-Region</li><li>CloudFormation support</li><li>AWS Backup integration for automated backups</li></ul><h3>Best Practices</h3><ol><li><strong>Implement OCC retry logic on every write path.</strong> Use exponential backoff with 3–5 retries. Read-only transactions don’t need retries.</li><li><strong>Keep transactions small and fast.</strong> The 3,000 row and 5-minute limits exist for good reason. Batch large operations into chunks of 500 rows.</li><li><strong>Use UUID primary keys.</strong> Random UUIDs distribute writes evenly across storage shards. Sequential IDs create hot spots that increase OCC conflicts.</li><li><strong>Refresh IAM tokens proactively.</strong> Tokens default to 15 minutes (configurable up to one week via token-duration-secs). With the default, refresh at 10 minutes to avoid connection failures. The official connectors handle this automatically.</li><li><strong>Use the official connectors for production SSL.</strong> Raw psycopg2 with sslmode=&quot;require&quot; encrypts the connection but doesn&#39;t verify the server&#39;s identity. The official aurora-dsql-python-connector and @aws/aurora-dsql-node-postgres-connector handle full certificate verification automatically. For production, use the connectors rather than managing SSL configuration yourself.</li><li><strong>One DDL per transaction.</strong> Always commit after each CREATE TABLE, ALTER TABLE, or CREATE INDEX. This catches many migration scripts that batch DDL.</li><li><strong>Scope IAM policies to cluster ARNs.</strong> Never use wildcard resources for DSQL permissions. Scope dsql:DbConnect and dsql:DbConnectAdmin to specific cluster ARNs.</li><li><strong>Use </strong><strong>EXPLAIN ANALYZE VERBOSE for query optimization.</strong> Covering indexes can significantly reduce DPU costs by enabling index-only scans instead of full table scans.</li><li><strong>Implement referential integrity in application code.</strong> Without foreign keys, enforce relationships through application-level validation and carefully designed transaction boundaries.</li><li><strong>Test with AWS FIS.</strong> Use Fault Injection Service to simulate region failures and validate your application’s multi-Region behavior before you need it.</li><li><strong>Monitor DPU breakdown in CloudWatch.</strong> Watch ComputeDPU, ReadDPU, WriteDPU separately. High WriteDPU relative to reads may indicate OCC conflict storms.</li></ol><h3>What’s Not There Yet — And Why</h3><p>This is the most contentious part of DSQL. If you’re coming from standard RDS PostgreSQL or Aurora PostgreSQL, the list of missing features is significant. But these aren’t oversights — the DSQL team made deliberate engineering tradeoffs to deliver strong consistency and predictable performance across a distributed, multi-Region architecture. Some of these features are fundamentally difficult in a disaggregated, OCC-based system. Others have been deliberately deprioritized based on customer usage patterns.</p><h3>The Full Gap List vs Standard PostgreSQL</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/392/1*Ps-XSxbGE0PYnd0PVJZG4A.png" /></figure><h3>The Engineering Reasoning</h3><p>Marc Brooker addressed the feature gaps directly in his <a href="https://brooker.co.za/blog/2025/11/02/thinking-dsql.html">Simplifying Architectures</a> post. The key insight: DSQL’s limits aren’t arbitrary restrictions — they’re what make the system’s guarantees possible.</p><p><strong>Transaction limits (3,000 rows, 10 MiB, 5 minutes)</strong> prevent head-of-line blocking. In a traditional database, one long-running transaction holding locks can stall every other transaction behind it. DSQL’s OCC model doesn’t have locks, but oversized commits would still create contention at the Adjudicator and Journal layers. The limits keep individual transactions fast and predictable, which keeps the entire system fast and predictable.</p><p><strong>No stored procedures or triggers</strong> is the most opinionated choice. The DSQL team observed that customers are increasingly moving business logic out of the database and into application code deployed through CI/CD pipelines. Code in the database is hard to version, hard to test, and hard to debug. DSQL leans into this direction rather than supporting both models.</p><p><strong>No foreign keys yet</strong> is the gap most customers notice first. The team has acknowledged the gap and may add support where it makes sense for the distributed architecture, but has deprioritized it based on customer feedback. The challenge is that cascading operations (CASCADE DELETE, CASCADE UPDATE) can create implicit transactions that exceed the row limits and generate unpredictable OCC conflict windows. Many high-scale PostgreSQL users already avoid foreign keys for exactly these reasons — but having the option matters.</p><h3>What to Use Instead</h3><p>For applications that depend heavily on the missing features today, here’s the practical guidance:</p><ul><li><strong>Need foreign keys, stored procedures, triggers?</strong> Use Aurora PostgreSQL Serverless v2. Full PostgreSQL feature set with serverless scaling (though not to zero).</li><li><strong>Need vector search?</strong> Aurora PostgreSQL with pgvector, S3 Vectors, or OpenSearch Serverless.</li><li><strong>Need full-text search?</strong> OpenSearch Serverless or Amazon Kendra.</li><li><strong>Need pub/sub notifications?</strong> EventBridge + Lambda instead of LISTEN/NOTIFY.</li><li><strong>Need geospatial queries?</strong> Amazon Location Service instead of PostGIS.</li></ul><p>DSQL is best for new applications that can work within these constraints, or existing applications that were already avoiding the missing features. The team is actively expanding compatibility — views, sequences, identity columns, and the Go connector all shipped based on direct customer feedback. Foreign key constraints remain a known gap, and customer demand will likely influence when they’re addressed.</p><h3>Things to Know</h3><p><strong>Connection caching</strong> — DSQL manages prepared statements cluster-wide. You may see more prepared statements per connection than expected. This is by design.</p><p><strong>IPv4 connections</strong> — Some PostgreSQL clients attempt IPv6 first in dualstack mode. If you’re on IPv4-only hosts, configure your client for IPv4 explicitly to avoid NetworkUnreachable errors.</p><p><strong>Schema propagation</strong> — GRANT and REVOKE changes propagate to existing connections within the connection lifetime (up to one hour). For immediate effect, reconnect after permission changes.</p><p><strong>Catalog cache</strong> — After creating schemas or tables, refresh your connection (disconnect/reconnect or SET search_path again) to update the catalog cache. This catches &quot;Schema Already Exists&quot; errors.</p><p><strong>Deletion protection</strong> — Enable deletion_protection_enabled = true in production Terraform configs. If you need to destroy a DSQL cluster, disable protection first then run terraform apply before terraform destroy.</p><p><strong>Row counts</strong> — For large tables, use the system catalog instead of COUNT(*) for row counts. DSQL stores approximate counts in pg_class.reltuples.</p><p><strong>TRUNCATE</strong> — Not supported. Use DELETE FROM table_name to clear all rows, or DROP TABLE followed by CREATE TABLE for a full reset. This is a common migration stumbling block for scripts that use TRUNCATE for test data cleanup.</p><p><strong>Connection pooling</strong> — With 60-minute connection limits and IAM token refresh, pool refresh behavior matters. Configure your connection pool to close and recreate connections before the 60-minute limit. The official connectors handle token refresh, but pool-level eviction still needs configuration. Set idleTimeoutMillis (Node.js) or equivalent to well under 60 minutes.</p><p><strong>PostgreSQL client version</strong> — AWS recommends PostgreSQL client version 17 or later for best compatibility with DSQL.</p><h3>Recent Features Worth Highlighting</h3><p><strong>DSQL Playground (February 2026)</strong> — A browser-based sandbox where you can create schemas, load sample data, and run SQL queries against a real DSQL database — no AWS account required. This is the fastest way to try DSQL. Visit the <a href="https://playground.dsql.demo.aws/">Aurora DSQL Playground</a> and start writing queries in seconds.</p><p><strong>Sequences and Identity Columns (February 2026)</strong> — The most requested feature after foreign keys. You can now use GENERATED ALWAYS AS IDENTITY columns and explicit CREATE SEQUENCE / nextval() calls. Up to 5,000 sequences per database.</p><p><strong>AI Steering (February 2026)</strong> — The DSQL MCP server and IDE skills ensure AI coding assistants generate code that handles DSQL’s specific patterns — OCC retries, DDL limits, IAM auth. If you use Claude Code, Cursor, or similar tools, install the DSQL steering skill. It saves real debugging time.</p><p><strong>PrivateLink with Direct Connect (December 2025)</strong> — Connect to DSQL from on-premises networks without traversing the public internet. Uses the amzn-cluster-id connection option for clusters behind PrivateLink without private DNS.</p><p><strong>Resource-Based Policies (October 2025)</strong> — Attach policies directly to DSQL clusters for cross-account access patterns. Useful for shared database architectures.</p><p><strong>AWS FIS Integration (August 2025)</strong> — Inject connection errors into specific regions to test your application’s failover behavior. For multi-Region deployments, run experiments in one region while the other continues normal operations.</p><h3>My Project: The Kabob Store on DSQL</h3><p>I built the <a href="https://darryl-ruggles.cloud/dsql-kabob-store/">Kabob Store</a> as a real-world test of DSQL. It’s a full e-commerce platform with menu browsing, cart management, and order processing, running on ECS Fargate with a FastAPI backend.</p><p>Key architectural decisions from that project:</p><ul><li><strong>Direct psycopg2 instead of an ORM</strong> — Better control over transaction boundaries and DSQL-specific patterns</li><li><strong>Container-based architecture</strong> — The same Docker image deploys to Fargate, Lambda, EC2, or EKS without code changes</li><li><strong>Multi-Region DSQL with single-Region compute</strong> — Data replication for disaster recovery, with plans to add multi-Region compute with Route53 routing</li><li><strong>Defense-in-depth security</strong> — Six layers from client validation through parameterized queries</li><li><strong>IAM token refresh manager</strong> — Thread-safe connection management with 55-minute token refresh</li></ul><p>The architecture principles from that project apply to any DSQL application. I covered <a href="https://darryl-ruggles.cloud/elastic-container-service-ecs-my-default-choice-for-containers-on-aws/">ECS as my default container runtime</a> and <a href="https://darryl-ruggles.cloud/amazon-eventbridge-the-event-driven-backbone-of-aws-and-my-favourite-service/">EventBridge for event-driven patterns</a> in previous posts — DSQL fits naturally into both patterns.</p><h3>Cleanup</h3><p>If you deployed a DSQL cluster to follow along, destroy your resources to avoid ongoing charges:</p><pre>cd terraform<br><br># If you enabled deletion protection, disable it first:<br># Edit dsql-single-region.tf: set deletion_protection_enabled = false<br># terraform apply<br>terraform destroy</pre><p>For CI/CD pipelines and automated testing, set deletion_protection_enabled = false from the start, or use the force_destroy option in the Terraform module to skip the protection check during teardown.</p><p>DSQL charges only for DPUs consumed and storage used — there are no idle compute charges. But storage charges ($0.33/GB-month) continue as long as data exists in the cluster. For multi-Region clusters, destroy both the primary and secondary clusters. The witness region has no standalone resources to clean up.</p><h3>Wrapping Up</h3><p>Aurora DSQL is 15 months old and has matured quickly. It went from a 3-region preview to a 14-region GA service with CloudWatch monitoring, AWS Backup, PrivateLink, FIS chaos testing, resource-based policies, sequences, and a growing ecosystem of connectors and IDE integrations.</p><p>The gaps are real — no foreign keys, no stored procedures, no vector support. These matter for some workloads. But for new applications that need SQL with serverless economics, multi-Region strong consistency without managing replicas, or a database that actually scales to zero, DSQL delivers.</p><p>My decision tree for new projects now has a clear path:</p><ul><li><strong>Need key-value at scale?</strong> DynamoDB.</li><li><strong>Need full PostgreSQL?</strong> Aurora PostgreSQL Serverless v2.</li><li><strong>Need SQL + serverless + multi-Region?</strong> Aurora DSQL.</li></ul><p>The code examples in this post are in the <a href="https://github.com/RDarrylR/aurora-dsql-2026">GitHub repo</a> — Terraform for infrastructure, Python and Node.js for application patterns. If you want to try DSQL without even creating an AWS account, the <a href="https://playground.dsql.demo.aws/">DSQL Playground</a> lets you run queries in your browser in seconds. When you’re ready for your own cluster, it’s sixty seconds from terraform apply to a running PostgreSQL-compatible database with no instances to manage.</p><p>If you’ve been waiting for a serverless SQL database on AWS that isn’t a compromise, this is it.</p><h3>Resources</h3><ul><li><a href="https://playground.dsql.demo.aws/">Aurora DSQL Playground</a> — Try DSQL in your browser, no AWS account needed</li><li><a href="https://docs.aws.amazon.com/aurora-dsql/latest/userguide/what-is-aurora-dsql.html">Aurora DSQL User Guide</a></li><li><a href="https://aws.amazon.com/rds/aurora/dsql/pricing/">Aurora DSQL Pricing</a></li><li><a href="https://docs.aws.amazon.com/aurora-dsql/latest/userguide/doc-history.html">Aurora DSQL Document History</a> — Track every feature addition</li><li><a href="https://brooker.co.za/blog/2024/12/03/aurora-dsql.html">Marc Brooker’s DSQL Blog Series</a> — Essential reading. Marc is the VP/Distinguished Engineer behind DSQL. His five-part series covers the architecture internals (reads, writes, transactions, multi-Region, simplifying architectures) in detail you won’t find anywhere else.</li><li><a href="https://discord.com/invite/nEF6ksFWru">Aurora DSQL Discord</a> — Community Discord for questions, feedback, and discussion with the DSQL team</li><li><a href="https://registry.terraform.io/modules/terraform-aws-modules/rds-aurora/aws/latest/submodules/dsql">terraform-aws-modules/rds-aurora DSQL Module</a></li><li><a href="https://docs.aws.amazon.com/aurora-dsql/latest/userguide/SECTION_aurora-dsql-mcp-server.html">Aurora DSQL MCP Server</a> — AI steering for DSQL-aware code generation</li><li><a href="https://docs.aws.amazon.com/aurora-dsql/latest/userguide/SECTION_connectors.html">Aurora DSQL Connectors</a> — Official Python, Node.js, Java, Go, Ruby connectors</li><li><a href="https://darryl-ruggles.cloud/dsql-kabob-store/">My Kabob Store Project</a> — My previous DSQL blog — building a multi-Region e-commerce platform</li><li><a href="https://darryl-ruggles.cloud/elastic-container-service-ecs-my-default-choice-for-containers-on-aws/">ECS: My Default Choice for Containers</a></li><li><a href="https://darryl-ruggles.cloud/amazon-eventbridge-the-event-driven-backbone-of-aws-and-my-favourite-service/">EventBridge: The Event-Driven Backbone of AWS</a></li></ul><p><em>Connect with me on</em> <a href="https://x.com/RDarrylR">X</a><em>,</em> <a href="https://bsky.app/profile/darrylruggles.bsky.social">Bluesky</a><em>,</em> <a href="https://www.linkedin.com/in/darryl-ruggles/">LinkedIn</a><em>,</em> <a href="https://github.com/RDarrylR">GitHub</a><em>,</em> <a href="https://medium.com/@RDarrylR">Medium</a><em>,</em> <a href="https://dev.to/rdarrylr">Dev.to</a><em>, or the</em> <a href="https://community.aws/@darrylr">AWS Community</a><em>. Check out more of my projects at</em> <a href="https://darryl-ruggles.cloud/">darryl-ruggles.cloud</a> <em>and join the</em> <a href="https://www.believeinserverless.com/">Believe In Serverless</a> <em>community.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e842b46234c7" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Powertools for AWS Lambda — Best Practices By Default]]></title>
            <link>https://medium.com/@RDarrylR/powertools-for-aws-lambda-best-practices-by-default-c9ef6c574a2d?source=rss-7ecf33fd997------2</link>
            <guid isPermaLink="false">https://medium.com/p/c9ef6c574a2d</guid>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[serverless]]></category>
            <category><![CDATA[power-tools]]></category>
            <dc:creator><![CDATA[Darryl Ruggles]]></dc:creator>
            <pubDate>Fri, 10 Apr 2026 01:59:06 GMT</pubDate>
            <atom:updated>2026-04-10T02:16:03.655Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0DcW9r-HFX_R_1gTKGSMuA.png" /></figure><p>AWS Lambda Powertools for Python is the library I add to every Lambda function before writing a single line of business logic. While Powertools is also available for TypeScript, Java, and .NET, this post focuses on the Python edition. Three decorators give you structured logging, distributed tracing, and CloudWatch metrics. The rest of the toolkit — event routing, idempotency, batch processing, parameter management — eliminates the boilerplate that otherwise dominates serverless code. As of early 2026, Powertools v3.25.0 supports Python 3.10 through 3.14, and with the X-Ray SDK entering maintenance mode on February 25, 2026, Powertools Tracer is one of the recommended migration paths for Lambda tracing — alongside AWS Distro for OpenTelemetry (ADOT) and native OpenTelemetry instrumentation.</p><p>I wanted to consolidate everything I’ve learned from using Powertools across real projects into one place. This blog post is a deep dive into what Powertools offers, how I use it in production, and how you can adopt it today.</p><h3>Why Observability Matters for Serverless</h3><p>Serverless architectures introduce unique observability challenges. There aren’t any servers to SSH into, no log files to tail, and no process monitors to check. When something goes wrong at 2 AM, your only window into the problem is what your functions chose to emit.</p><p>Four problems hit every Lambda team eventually:</p><ul><li><strong>Unstructured logs</strong> — print() statements scattered through code produce text that&#39;s nearly impossible to search, filter, or alert on at scale.</li><li><strong>Missing correlation</strong> — When a single user request touches five Lambda functions, connecting the dots across CloudWatch log groups requires manual effort or custom correlation IDs.</li><li><strong>Manual metrics</strong> — Calling put_metric_data directly means writing boto3 code, handling batching, and dealing with CloudWatch API limits.</li><li><strong>Tracing gaps</strong> — Setting up X-Ray manually with the SDK requires segment management, subsegment creation, and careful exception handling in every function.</li></ul><p>Multiply these across dozens of functions and you get a codebase where observability code outweighs business logic.</p><h3>The Problem Without Powertools</h3><p>This is what observability code typically looks like without Powertools — a pattern I see in many Lambda projects:</p><pre>import logging<br>import json<br>import time<br>import boto3<br>from aws_xray_sdk.core import patch_all, xray_recorder<br><br>patch_all()<br>logger = logging.getLogger()<br>logger.setLevel(logging.INFO)<br>cloudwatch = boto3.client(&#39;cloudwatch&#39;)<br>def lambda_handler(event, context):<br>    # Manual structured logging<br>    logger.info(json.dumps({<br>        &quot;message&quot;: &quot;Processing request&quot;,<br>        &quot;request_id&quot;: context.aws_request_id,<br>        &quot;function_name&quot;: context.function_name,<br>        &quot;event&quot;: event<br>    }))<br>    # Manual X-Ray subsegment<br>    subsegment = xray_recorder.begin_subsegment(&#39;process_data&#39;)<br>    try:<br>        result = process_data(event)<br>        subsegment.put_annotation(&#39;status&#39;, &#39;success&#39;)<br>    except Exception as e:<br>        subsegment.put_annotation(&#39;status&#39;, &#39;error&#39;)<br>        raise<br>    finally:<br>        xray_recorder.end_subsegment()<br>    # Manual CloudWatch metric<br>    cloudwatch.put_metric_data(<br>        Namespace=&#39;MyApp&#39;,<br>        MetricData=[{<br>            &#39;MetricName&#39;: &#39;ProcessedItems&#39;,<br>            &#39;Value&#39;: 1,<br>            &#39;Unit&#39;: &#39;Count&#39;<br>        }]<br>    )<br>    return result</pre><p>That’s 40+ lines of infrastructure code before you write any business logic. It’s error-prone — forget to end a subsegment and your traces break. It’s inconsistent — each developer formats logs differently. And it makes an API call for every metric, adding latency and cost.</p><h3>What is AWS Lambda Powertools?</h3><p>AWS Lambda Powertools is an open-source toolkit maintained by AWS that provides a suite of utilities for Lambda functions. It’s available for <strong>Python</strong>, <strong>TypeScript</strong>, <strong>Java</strong>, and <strong>.NET</strong> — the core patterns (Logger, Tracer, Metrics) are consistent across all languages. It’s not a framework — it’s a collection of independent utilities you adopt incrementally.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*sKefqIJiFiEipO7l.png" /></figure><p>The toolkit includes a growing suite of utilities organized into categories:</p><p><strong>Core Observability (The Big Three)</strong></p><ul><li><strong>Logger</strong> — Structured JSON logging with automatic Lambda context injection</li><li><strong>Tracer</strong> — AWS X-Ray tracing via decorators with automatic annotation</li><li><strong>Metrics</strong> — CloudWatch Embedded Metric Format (EMF) for zero-latency custom metrics</li></ul><p><strong>Event Handling</strong></p><ul><li><strong>Event Handler</strong> — REST, HTTP, and GraphQL routing for API Gateway, ALB, and Lambda Function URLs</li><li><strong>Batch Processing</strong> — SQS, Kinesis, and DynamoDB Streams with partial failure reporting</li><li><strong>Kafka Consumer</strong> — MSK and self-managed Kafka with automatic JSON, Avro, and Protocol Buffer deserialization</li><li><strong>Event Source Data Classes</strong> — Typed access to event payloads from 20+ AWS services</li></ul><p><strong>Data &amp; Validation</strong></p><ul><li><strong>Parser</strong> — Pydantic v2 models for event validation and transformation</li><li><strong>Idempotency</strong> — At-most-once execution with DynamoDB-backed deduplication</li><li><strong>Data Masking</strong> — PII redaction in logs and traces</li></ul><p><strong>Configuration</strong></p><ul><li><strong>Parameters</strong> — SSM Parameter Store, Secrets Manager, AppConfig, and DynamoDB with caching and transformation</li><li><strong>Feature Flags</strong> — AppConfig-backed feature toggles with rule evaluation</li></ul><p><strong>Utilities</strong></p><ul><li><strong>Middleware Factory</strong> — Custom middleware with before/after hooks</li><li><strong>JMESPath Functions</strong> — Built-in JMESPath custom functions for extracting data from complex event structures</li><li><strong>Typing</strong> — LambdaContext type for IDE autocompletion</li><li><strong>Streaming</strong> — S3 object streaming for large file processing without loading entire objects into memory</li></ul><h3>Installation and Setup</h3><p>Powertools can be added three ways:</p><h3>Lambda Layer (Recommended for production)</h3><p>The fastest approach — no bundling required:</p><pre>arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV3-{python_version}-{arch}:{version}</pre><p>For example, Python 3.13 on ARM64:</p><pre>arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV3-python313-arm64:29</pre><h3>pip / uv</h3><pre>pip install aws-lambda-powertools<br># Or with uv:<br>uv add aws-lambda-powertools<br><br># With Pydantic for Parser utility:<br>pip install &quot;aws-lambda-powertools[parser]&quot;<br># With all optional dependencies:<br>pip install &quot;aws-lambda-powertools[all]&quot;</pre><h3>Environment Variables</h3><p>Three environment variables configure the core utilities across all functions:</p><pre>POWERTOOLS_SERVICE_NAME=my-service        # Appears in every log entry and metric<br>POWERTOOLS_METRICS_NAMESPACE=MyApp        # CloudWatch metrics namespace<br>POWERTOOLS_LOG_LEVEL=INFO                 # Log level (DEBUG, INFO, WARNING, ERROR)</pre><h3>Logger — Structured Logging</h3><p>The Logger utility replaces Python’s standard logging with structured JSON output. Every log entry automatically includes the Lambda request ID, function name, memory allocation, cold start status, and sampling rate.</p><p>I use it like this in the Smurf ETL project’s process-upload function:</p><pre>from aws_lambda_powertools import Logger<br>from aws_lambda_powertools.utilities.typing import LambdaContext<br><br>logger = Logger()<br>@logger.inject_lambda_context(log_event=True)<br>def lambda_handler(event, context: LambdaContext):<br>    logger.info(&quot;Processing file&quot;, extra={<br>        &quot;file_name&quot;: filename,<br>        &quot;bucket&quot;: bucket_name<br>    })</pre><p>The @logger.inject_lambda_context decorator automatically adds Lambda context to every log entry. Setting log_event=True logs the entire incoming event on the first line - invaluable for debugging. The extra parameter adds custom fields that become searchable in CloudWatch Logs Insights.</p><p>A single log entry produces JSON like:</p><pre>{<br>    &quot;level&quot;: &quot;INFO&quot;,<br>    &quot;location&quot;: &quot;lambda_handler:119&quot;,<br>    &quot;message&quot;: &quot;Processing file&quot;,<br>    &quot;timestamp&quot;: &quot;2026-01-15T10:30:00.000Z&quot;,<br>    &quot;service&quot;: &quot;sales-data-platform&quot;,<br>    &quot;cold_start&quot;: true,<br>    &quot;function_name&quot;: &quot;process_upload&quot;,<br>    &quot;function_request_id&quot;: &quot;c6af9ac6-7b61-11e6-9a41-93e8deadbeef&quot;,<br>    &quot;file_name&quot;: &quot;store_0001_2026-01-15.json&quot;,<br>    &quot;bucket&quot;: &quot;sales-data-uploads&quot;<br>}</pre><p>Every field is searchable. In CloudWatch Logs Insights:</p><pre>fields @timestamp, message, file_name<br>| filter service = &quot;sales-data-platform&quot;<br>| filter level = &quot;ERROR&quot;<br>| sort @timestamp desc</pre><h3>Key Logger Features</h3><ul><li><strong>Correlation IDs</strong> — Automatically propagates correlation IDs across Lambda invocations via API Gateway headers, SQS messages, or custom sources</li><li><strong>Child loggers</strong> — logger.child(extra={&quot;module&quot;: &quot;parser&quot;}) creates loggers that inherit the parent context</li><li><strong>Sampling</strong> — Logger(sampling_rate=0.1) logs DEBUG for 10% of invocations without changing code</li><li><strong>Exception logging</strong> — logger.exception(&quot;Error&quot;) automatically includes the full stack trace as structured data</li></ul><h3>Tracer — Distributed Tracing</h3><p>The Tracer utility wraps AWS X-Ray with a decorator-based approach that eliminates manual segment management.</p><p>From the Account Watcher project:</p><pre>from aws_lambda_powertools import Tracer<br><br>tracer = Tracer()<br>@tracer.capture_method<br>def parse_event(event):<br>    &quot;&quot;&quot;Parse details of event&quot;&quot;&quot;<br>    # ... business logic ...<br>    return eventName, result<br>@tracer.capture_method<br>def send_slack_message(payload, webhook):<br>    &quot;&quot;&quot;Send Slack message&quot;&quot;&quot;<br>    return requests.post(webhook, data=json.dumps(payload))<br>@tracer.capture_lambda_handler<br>def lambda_handler(event, context):<br>    event_name, event_detail = parse_event(event)<br>    send_slack_message({&quot;text&quot;: f&quot;{event_name}: {event_detail}&quot;}, webhook)</pre><p>@tracer.capture_lambda_handler creates the root X-Ray segment. @tracer.capture_method creates subsegments for each decorated function. Exceptions are automatically captured as fault annotations.</p><h3>Key Tracer Features</h3><ul><li><strong>Automatic patching</strong> — Patches boto3, requests, and other libraries for downstream tracing</li><li><strong>Annotations and metadata</strong> — tracer.put_annotation(&quot;order_id&quot;, &quot;123&quot;) for searchable fields, tracer.put_metadata(&quot;response&quot;, data) for detailed inspection</li><li><strong>Async support</strong> — Works with asyncio functions via @tracer.capture_method</li><li><strong>Provider pattern</strong> — Swap X-Ray for OpenTelemetry when the native integration ships</li></ul><p><strong>A note on the future of tracing:</strong> With the X-Ray SDK now in maintenance mode (February 2026), the Powertools team has an active RFC for native OpenTelemetry support in the Tracer utility. When this ships, Tracer will support any OTLP-compatible backend (Datadog, Honeycomb, Grafana, Jaeger) without changing your application code. If you adopt @tracer.capture_lambda_handler today, your tracing code will transition automatically when the OTel backend lands.</p><h3>Metrics — CloudWatch Embedded Metric Format</h3><p>The Metrics utility uses CloudWatch Embedded Metric Format (EMF) to publish custom metrics with zero API calls. Metrics are embedded in log output and extracted asynchronously by CloudWatch — no put_metric_data calls, no latency impact, no API throttling.</p><p>From the Smurf ETL process-upload function:</p><pre>from aws_lambda_powertools import Metrics<br>from aws_lambda_powertools.metrics import MetricUnit<br><br>metrics = Metrics()<br>@metrics.log_metrics(capture_cold_start_metric=True)<br>def lambda_handler(event, context):<br>    # Business logic...<br>    metrics.add_metric(name=&quot;FilesProcessed&quot;, unit=MetricUnit.Count, value=1)<br>    metrics.add_metric(name=&quot;RecordsProcessed&quot;, unit=MetricUnit.Count, value=len(df))</pre><p>capture_cold_start_metric=True automatically tracks cold starts per function - one of the most useful metrics for Lambda performance tuning.</p><p>From the detect-anomalies function, adding dimensions for more granular metrics:</p><pre>metrics.add_metric(name=&quot;AnomaliesDetected&quot;, unit=MetricUnit.Count, value=len(anomalies))<br>metrics.add_metric(name=&quot;BedrockInvocations&quot;, unit=MetricUnit.Count, value=1)<br>metrics.add_dimension(name=&quot;ModelId&quot;, value=BEDROCK_MODEL_ID)</pre><p>Dimensions let you filter and group metrics in CloudWatch. Here the ModelId dimension means you can see invocation counts per Bedrock model.</p><h3>Key Metrics Features</h3><ul><li><strong>Zero latency</strong> — EMF metrics are extracted from logs, not from API calls</li><li><strong>Auto-flush</strong> — @metrics.log_metrics ensures metrics are flushed even if the function throws</li><li><strong>Default dimensions</strong> — Set once, applied to all metrics: metrics.set_default_dimensions(environment=&quot;prod&quot;)</li><li><strong>High resolution</strong> — Supports 1-second resolution metrics via resolution=MetricResolution.High</li><li><strong>Validation</strong> — Raises SchemaValidationError if you exceed CloudWatch limits (30 dimensions, 1000 metrics per EMF blob)</li><li><strong>Datadog provider</strong> — Powertools v3 also supports Datadog as an alternative metrics backend via DatadogProvider, using the same add_metric API</li></ul><h3>The Big Three Together</h3><p>The real power of Powertools emerges when Logger, Tracer, and Metrics work together. The pattern I use across all 17 functions in the Smurf ETL project looks like this:</p><pre>from aws_lambda_powertools import Logger, Tracer, Metrics<br>from aws_lambda_powertools.metrics import MetricUnit<br>from aws_lambda_powertools.utilities.typing import LambdaContext<br><br>logger = Logger()<br>tracer = Tracer()<br>metrics = Metrics()<br>@logger.inject_lambda_context(log_event=True)<br>@tracer.capture_lambda_handler<br>@metrics.log_metrics(capture_cold_start_metric=True)<br>def lambda_handler(event, context: LambdaContext):<br>    # All three utilities active with 3 lines of decorators<br>    logger.info(&quot;Processing started&quot;)<br>    # ... business logic with @tracer.capture_method on helper functions ...<br>    metrics.add_metric(name=&quot;ItemsProcessed&quot;, unit=MetricUnit.Count, value=count)</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*GgAX8jBG1hxXr1Pv.png" /></figure><p>Three decorators. Structured JSON logs with Lambda context. X-Ray traces with automatic subsegments. CloudWatch metrics with cold start tracking. All correlated by the same request ID.</p><p>This pattern is used across all 17 Lambda functions in the Smurf ETL project. The consistency means every function produces the same log format, the same trace structure, and the same baseline metrics.</p><h3>Event Handler — REST Routing</h3><p>The Event Handler utility provides Flask-like routing for API Gateway, ALB, and Lambda Function URLs. Instead of a monolithic handler with if/elif chains, you define routes with decorators.</p><pre>from aws_lambda_powertools.event_handler import APIGatewayRestResolver<br><br>app = APIGatewayRestResolver()<br>@app.get(&quot;/items&quot;)<br>def list_items():<br>    return {&quot;items&quot;: get_all_items()}<br>@app.post(&quot;/items&quot;)<br>def create_item():<br>    body = app.current_event.json_body<br>    item = save_item(body)<br>    return {&quot;item&quot;: item}, 201<br>@app.get(&quot;/items/&lt;item_id&gt;&quot;)<br>def get_item(item_id: str):<br>    item = find_item(item_id)<br>    if not item:<br>        raise NotFoundError(f&quot;Item {item_id} not found&quot;)<br>    return {&quot;item&quot;: item}<br>def lambda_handler(event, context):<br>    return app.resolve(event, context)</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*H8qm84JdrytuYA27.png" /></figure><p>Resolvers are available for REST API (APIGatewayRestResolver), HTTP API (APIGatewayHttpResolver), ALB (ALBResolver), Lambda Function URL (LambdaFunctionUrlResolver), and VPC Lattice (VPCLatticeV2Resolver).</p><h3>Key Event Handler Features</h3><ul><li><strong>Path parameters</strong> — &lt;item_id&gt; with type hints for automatic validation</li><li><strong>CORS</strong> — Built-in CORS configuration with CORSConfig</li><li><strong>Middleware</strong> — Per-route middleware for auth, validation, and logging</li><li><strong>Exception handling</strong> — Custom exception handlers for consistent error responses</li><li><strong>OpenAPI</strong> — Automatic OpenAPI schema generation from route definitions</li><li><strong>Compression</strong> — Automatic gzip compression for responses</li></ul><h3>Parameters — SSM, Secrets Manager, and More</h3><p>The Parameters utility provides a unified interface for retrieving configuration from SSM Parameter Store, Secrets Manager, AppConfig, and DynamoDB — with built-in caching and transformation.</p><pre>from aws_lambda_powertools.utilities import parameters<br><br># SSM Parameter Store (cached for 5 minutes by default in v3)<br>api_url = parameters.get_parameter(&quot;/myapp/api-url&quot;)<br># Secrets Manager (auto-deserializes JSON)<br>db_creds = parameters.get_secret(&quot;myapp/db-credentials&quot;, transform=&quot;json&quot;)<br># AppConfig with custom cache TTL<br>feature_config = parameters.get_app_config(<br>    name=&quot;features&quot;,<br>    environment=&quot;prod&quot;,<br>    application=&quot;myapp&quot;,<br>    max_age=300  # cache for 5 minutes<br>)</pre><p>Caching is critical for Lambda performance. You could roll your own caching with a module-level variable, but you’d need to manage TTL expiry, handle invalidation, and replicate that logic across every function. Powertools handles all of this automatically — values are cached in memory across warm invocations with a configurable TTL (5 minutes by default in v3), reducing latency and cost with no boilerplate.</p><h3>Idempotency</h3><p>The Idempotency utility ensures Lambda functions process each unique request exactly once. It uses DynamoDB to track processed requests by hashing the payload and storing results for a configurable TTL.</p><pre>from aws_lambda_powertools.utilities.idempotency import (<br>    DynamoDBPersistenceLayer,<br>    IdempotencyConfig,<br>    idempotent,<br>)<br><br>persistence_layer = DynamoDBPersistenceLayer(table_name=&quot;IdempotencyTable&quot;)<br>config = IdempotencyConfig(expires_after_seconds=3600)<br>@idempotent(config=config, persistence_store=persistence_layer)<br>def lambda_handler(event, context):<br>    payment = process_payment(event[&quot;payment_id&quot;], event[&quot;amount&quot;])<br>    return {&quot;payment_id&quot;: payment.id, &quot;status&quot;: &quot;completed&quot;}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*w4ZZ6-mEfCpuOZ8s.png" /></figure><p>The flow:</p><ol><li>Request arrives, Powertools hashes the relevant payload fields</li><li>Checks DynamoDB for an existing record with that hash</li><li>If found and not expired, returns the cached result (no re-execution)</li><li>If not found, executes the function and stores the result</li><li>Concurrent requests with the same hash are handled with DynamoDB conditional writes</li></ol><h3>Key Idempotency Features</h3><ul><li><strong>Selective hashing</strong> — event_key_jmespath=&quot;body.payment_id&quot; to hash only specific fields</li><li><strong>Method-level idempotency</strong> — @idempotent_function for individual methods, not just handlers</li><li><strong>Custom serialization</strong> — Pluggable serializers for complex result types</li><li><strong>Expiry</strong> — Configurable TTL for cached results</li><li><strong>Jitter</strong> — Built-in jitter to prevent cache stampedes</li></ul><p>This is essential for payment processing, order creation, or any operation where double-execution causes real harm.</p><p>The idempotency table is a required infrastructure component. In Terraform:</p><pre>resource &quot;aws_dynamodb_table&quot; &quot;idempotency&quot; {<br>  name         = &quot;IdempotencyTable&quot;<br>  billing_mode = &quot;PAY_PER_REQUEST&quot;<br>  hash_key     = &quot;id&quot;<br><br>attribute {<br>    name = &quot;id&quot;<br>    type = &quot;S&quot;<br>  }<br>  ttl {<br>    attribute_name = &quot;expiration&quot;<br>    enabled        = true<br>  }<br>}</pre><p>PAY_PER_REQUEST avoids capacity planning headaches for bursty workloads, and the TTL attribute lets DynamoDB automatically clean up expired idempotency records.</p><h3>Batch Processing</h3><p>The Batch Processing utility handles partial failures for SQS, Kinesis, and DynamoDB Streams. When processing a batch of 10 SQS messages and 2 fail, Powertools reports only those 2 as failed — the other 8 are deleted from the queue.</p><pre>from aws_lambda_powertools.utilities.batch import (<br>    BatchProcessor,<br>    EventType,<br>    process_partial_response,<br>)<br><br>processor = BatchProcessor(event_type=EventType.SQS)<br>def record_handler(record):<br>    payload = record.body<br>    # Process individual message<br>    result = process_message(json.loads(payload))<br>    return result<br>def lambda_handler(event, context):<br>    return process_partial_response(<br>        event=event,<br>        record_handler=record_handler,<br>        processor=processor,<br>        context=context,<br>    )</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*ERPAoIawxkSyouMI.png" /></figure><p>Without this utility, a single failed message causes the entire batch to retry — including messages that already processed successfully. This leads to duplicate processing, wasted compute, and potential data corruption.</p><h3>Key Batch Processing Features</h3><ul><li><strong>Partial failures</strong> — Only failed records are retried via batchItemFailures response</li><li><strong>Error handling</strong> — Configurable error handling strategies (raise, suppress, or custom)</li><li><strong>Async support</strong> — AsyncBatchProcessor for asyncio-based handlers</li><li><strong>Pydantic integration</strong> — Combine with Parser for typed record processing</li></ul><h3>Parser and Validation</h3><p>The Parser utility uses Pydantic v2 for event validation and transformation. Define models for your event payloads and Powertools validates them automatically:</p><pre>from pydantic import BaseModel, field_validator<br>from aws_lambda_powertools.utilities.parser import event_parser<br><br>class OrderEvent(BaseModel):<br>    order_id: str<br>    customer_id: str<br>    amount: float<br>    items: list[str]<br>    @field_validator(&quot;amount&quot;)<br>    @classmethod<br>    def amount_must_be_positive(cls, v):<br>        if v &lt;= 0:<br>            raise ValueError(&quot;Amount must be positive&quot;)<br>        return v<br>@event_parser(model=OrderEvent)<br>def lambda_handler(event: OrderEvent, context):<br>    # event is already validated and typed<br>    logger.info(f&quot;Processing order {event.order_id}&quot;)</pre><p>Built-in envelope support handles the common pattern of extracting business data from AWS event wrappers (API Gateway, SQS, EventBridge, etc.).</p><h3>Feature Flags and Data Masking</h3><h3>Feature Flags</h3><p>Feature Flags use AWS AppConfig to evaluate rules at runtime without redeploying:</p><pre>from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore<br><br>app_config = AppConfigStore(<br>    environment=&quot;prod&quot;,<br>    application=&quot;myapp&quot;,<br>    name=&quot;features&quot;<br>)<br>feature_flags = FeatureFlags(store=app_config)<br># Simple boolean flag<br>premium_enabled = feature_flags.evaluate(name=&quot;premium_features&quot;, default=False)<br># Context-based rules (e.g., percentage rollout, user segments)<br>new_ui = feature_flags.evaluate(<br>    name=&quot;new_checkout_ui&quot;,<br>    context={&quot;user_id&quot;: user_id, &quot;tier&quot;: &quot;premium&quot;},<br>    default=False<br>)</pre><h3>Data Masking</h3><p>Data Masking redacts sensitive fields in logs and traces:</p><pre>from aws_lambda_powertools.utilities.data_masking import DataMasking<br><br>masker = DataMasking()<br>masked = masker.mask(data, fields=[&quot;$.credit_card&quot;, &quot;$.ssn&quot;])<br># credit_card: &quot;*****&quot;, ssn: &quot;*****&quot;</pre><p>Beyond simple field redaction, Data Masking also supports encryption via the AWS Encryption SDK. This lets you encrypt sensitive fields in transit and decrypt them only in authorized downstream services — a step beyond masking for compliance-heavy workloads.</p><h3>Middleware Factory</h3><p>The Middleware Factory creates reusable middleware with before/after hooks:</p><pre>from aws_lambda_powertools.middleware_factory import lambda_handler_decorator<br><br>@lambda_handler_decorator<br>def middleware_with_validation(handler, event, context):<br>    # Before handler<br>    if &quot;Authorization&quot; not in event.get(&quot;headers&quot;, {}):<br>        return {&quot;statusCode&quot;: 401, &quot;body&quot;: &quot;Unauthorized&quot;}<br>    # Execute handler<br>    response = handler(event, context)<br>    # After handler<br>    response[&quot;headers&quot;] = {<br>        **response.get(&quot;headers&quot;, {}),<br>        &quot;X-Request-Id&quot;: context.aws_request_id<br>    }<br>    return response<br>@middleware_with_validation<br>def lambda_handler(event, context):<br>    return {&quot;statusCode&quot;: 200, &quot;body&quot;: &quot;OK&quot;}</pre><p>This is useful for cross-cutting concerns like authentication, input validation, response enrichment, and error standardization.</p><h3>Infrastructure as Code: Terraform</h3><p>This is how I configure Powertools across 17 Lambda functions in the Smurf ETL project using Terraform:</p><pre>locals {<br>  powertools_layer_arn = &quot;arn:aws:lambda:${var.aws_region}:017000801446:layer:AWSLambdaPowertoolsPythonV3-python313-arm64:29&quot;<br><br># Common Powertools environment variables<br>  powertools_env_vars = {<br>    POWERTOOLS_SERVICE_NAME      = &quot;sales-data-platform&quot;<br>    POWERTOOLS_METRICS_NAMESPACE = &quot;SalesDataPlatform&quot;<br>    POWERTOOLS_LOG_LEVEL         = var.log_level<br>  }<br>}<br>resource &quot;aws_lambda_function&quot; &quot;process_upload&quot; {<br>  function_name = &quot;process_upload&quot;<br>  role          = aws_iam_role.lambda_role.arn<br>  runtime       = &quot;python3.13&quot;<br>  architectures = [&quot;arm64&quot;]<br>  handler       = &quot;process_upload.lambda_handler&quot;<br>  timeout       = 30<br>  memory_size   = 256<br>  reserved_concurrent_executions = 10  # prevent runaway scaling<br>  layers = [local.powertools_layer_arn]<br>  tracing_config {<br>    mode = &quot;Active&quot;<br>  }<br>  environment {<br>    variables = merge(local.powertools_env_vars, {<br>      S3_BUCKET        = aws_s3_bucket.upload_bucket.id<br>      PROCESSED_PREFIX = var.processed_prefix<br>    })<br>  }<br>}<br># Explicit log group with retention - don&#39;t pay for infinite retention<br>resource &quot;aws_cloudwatch_log_group&quot; &quot;process_upload&quot; {<br>  name              = &quot;/aws/lambda/process_upload&quot;<br>  retention_in_days = 30<br>}</pre><h3>IAM for Powertools Utilities</h3><p>Each Powertools utility has specific IAM requirements. A least-privilege role scoped to what the function actually needs looks like this:</p><pre>resource &quot;aws_iam_role&quot; &quot;lambda_role&quot; {<br>  name               = &quot;process-upload-role&quot;<br>  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json<br>}<br><br>data &quot;aws_iam_policy_document&quot; &quot;lambda_assume&quot; {<br>  statement {<br>    actions = [&quot;sts:AssumeRole&quot;]<br>    principals {<br>      type        = &quot;Service&quot;<br>      identifiers = [&quot;lambda.amazonaws.com&quot;]<br>    }<br>  }<br>}<br>resource &quot;aws_iam_role_policy&quot; &quot;powertools_permissions&quot; {<br>  name   = &quot;powertools-permissions&quot;<br>  role   = aws_iam_role.lambda_role.id<br>  policy = data.aws_iam_policy_document.powertools.json<br>}<br>data &quot;aws_caller_identity&quot; &quot;current&quot; {}<br>data &quot;aws_iam_policy_document&quot; &quot;powertools&quot; {<br>  # Note: This policy covers Powertools-specific permissions only.<br>  # Your function will also need permissions for its business logic<br>  # (S3, DynamoDB, Bedrock, etc.).<br>  # CloudWatch Logs - required for Logger<br>  statement {<br>    actions   = [&quot;logs:CreateLogStream&quot;, &quot;logs:PutLogEvents&quot;]<br>    resources = [&quot;${aws_cloudwatch_log_group.process_upload.arn}:*&quot;]<br>  }<br>  # X-Ray - required for Tracer<br>  statement {<br>    actions   = [&quot;xray:PutTraceSegments&quot;, &quot;xray:PutTelemetryRecords&quot;]<br>    resources = [&quot;*&quot;]<br>  }<br>  # SSM Parameter Store - required for Parameters utility<br>  statement {<br>    actions   = [&quot;ssm:GetParameter&quot;, &quot;ssm:GetParametersByPath&quot;]<br>    resources = [&quot;arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/myapp/*&quot;]<br>  }<br>  # DynamoDB - required for Idempotency utility<br>  statement {<br>    actions   = [&quot;dynamodb:GetItem&quot;, &quot;dynamodb:PutItem&quot;, &quot;dynamodb:UpdateItem&quot;, &quot;dynamodb:DeleteItem&quot;]<br>    resources = [aws_dynamodb_table.idempotency.arn]<br>  }<br>}</pre><p>Key patterns:</p><ul><li><strong>Layer ARN in a local</strong> — Change the version once, all functions update</li><li><strong>merge() for env vars</strong> - Common Powertools vars merged with function-specific vars</li><li><strong>tracing_config { mode = &quot;Active&quot; }</strong> - Enables X-Ray for the Tracer utility</li><li><strong>ARM64 architecture</strong> — Graviton gives better price-performance, and Powertools provides ARM64-specific layers</li><li><strong>Explicit timeout and memory</strong> — Don’t rely on defaults (3s/128MB); Powertools Tracer adds some overhead, so size accordingly</li><li><strong>Reserved concurrency</strong> — Prevents runaway scaling from overwhelming downstream services like DynamoDB or external APIs</li><li><strong>Explicit log group with retention</strong> — Don’t pay for infinite retention; 30 days covers most debugging needs</li><li><strong>Least-privilege IAM</strong> — Each Powertools utility gets only the permissions it needs, scoped to specific resource ARNs where possible</li></ul><h3>Infrastructure as Code: SAM</h3><p>From the Account Watcher project, using SAM with Powertools:</p><pre>Globals:<br>  Function:<br>    Timeout: 3<br>    MemorySize: 128<br>    Tracing: Active<br>    LoggingConfig:<br>      LogFormat: JSON<br>    Environment:<br>      Variables:<br>        POWERTOOLS_SERVICE_NAME: AccountEventHandler<br><br>Resources:<br>  AccountEventHandler:<br>    Type: AWS::Serverless::Function<br>    Properties:<br>      CodeUri: account_event_handler/<br>      Handler: app.lambda_handler<br>      Runtime: python3.12<br>      Architectures:<br>        - arm64<br>      Layers:<br>        - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:69</pre><p>SAM’s Globals section is powerful here - Tracing: Active and LoggingConfig apply to all functions in the template. The layer ARN uses !Sub for region substitution.</p><p><strong>Warning: This project still uses the v2 layer, which reached end of support on March 25, 2025.</strong> Don’t copy this ARN into new projects. For v3, replace the layer with:</p><pre>arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV3-python313-arm64:29</pre><p>The migration is straightforward — update the layer ARN, bump the runtime, and fix any deprecated imports. The core decorator pattern (@logger.inject_lambda_context, @tracer.capture_lambda_handler, @metrics.log_metrics) is identical between v2 and v3.</p><p><strong>CDK users:</strong> Powertools is equally well-supported with AWS CDK. The aws-lambda-python-alpha construct handles bundling, and you can pass the Powertools layer ARN via LayerVersion.fromLayerVersionArn(). The same environment variables and layer patterns apply - just expressed as CDK constructs instead of HCL or YAML.</p><h3>Real Architecture: Smurf ETL Pipeline</h3><p>My <a href="https://darryl-ruggles.cloud/building-a-serverless-sales-analytics-platform-with-ai-insights-for-under-10month/">Serverless Smurf ETL and Analytics</a> project is a sales data platform with 17 Lambda functions — every one using Powertools.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*AB6lYXK_drNqynvy.png" /></figure><p>The pipeline:</p><ol><li>Store managers upload daily sales JSON via a presigned URL</li><li>EventBridge triggers the process-upload function (JSON validation, Parquet conversion)</li><li>Step Functions orchestrates the daily analysis pipeline</li><li>Lambda functions calculate store metrics, company metrics, and product metrics</li><li>Three Bedrock-powered functions detect anomalies, analyze trends, and generate recommendations</li><li>Results are stored in DynamoDB and exported to QuickSight</li><li>A React frontend displays analytics via CloudFront</li></ol><p>Every Lambda function uses the same three-decorator pattern:</p><pre>@logger.inject_lambda_context(log_event=True)<br>@tracer.capture_lambda_handler<br>@metrics.log_metrics(capture_cold_start_metric=True)<br>def lambda_handler(event, context: LambdaContext):</pre><p>The Terraform configuration uses merge(local.powertools_env_vars, {...}) to ensure consistent service names and namespaces across all 17 functions. CloudWatch Logs Insights queries work seamlessly across all functions because the log format is identical.</p><h3>Real Architecture: Account Watcher</h3><p>My <a href="https://darryl-ruggles.cloud/serverless-account-watcher/">Serverless Account Watcher</a> monitors AWS account changes and sends notifications via Slack and email.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*_zR_qql3HxxQ9jAo.png" /></figure><p>The architecture:</p><ol><li>CloudTrail captures all API activity in the AWS account</li><li>EventBridge rules match specific events (S3 bucket deletion, IAM key creation, root login)</li><li>A Lambda function processes matched events, formats messages, and sends notifications</li><li>Slack webhook for instant alerts, SNS for email distribution</li></ol><p>The Lambda function uses all three Powertools decorators plus @tracer.capture_method on helper functions:</p><pre>@tracer.capture_lambda_handler<br>@logger.inject_lambda_context(log_event=True)<br>@metrics.log_metrics(capture_cold_start_metric=True)<br>def lambda_handler(event, context):<br>    event_name, event_detail = parse_event(event)<br>    send_slack_message({&quot;text&quot;: f&quot;{event_name}: {event_detail}&quot;}, SLACK_WEBHOOK_URL)<br>    publish_to_sns(event_name, event_detail, SNS_TOPIC_ARN)</pre><p>This is a great starter project for anyone learning Powertools — it’s a single Lambda function with real-world observability, and you can deploy it with sam deploy in minutes.</p><h3>Best Practices and Patterns</h3><p>After using Powertools across multiple production projects, here are the practices I’ve found most valuable:</p><ol><li><strong>Always use all three core decorators.</strong> Logger, Tracer, and Metrics together provide comprehensive observability. The overhead is negligible compared to the debugging value.</li><li><strong>Set </strong><strong>POWERTOOLS_SERVICE_NAME consistently.</strong> Use the same service name across related functions. This is the primary filter key in CloudWatch Logs Insights and X-Ray.</li><li><strong>Use </strong><strong>log_event=True in non-production only.</strong> Logging the full event is invaluable for debugging but can expose PII, payment data, or health information in production - potentially violating GDPR, PCI-DSS, or HIPAA. Omit log_event in production (it defaults to False), and use an environment variable to enable it per stage. For events that do contain sensitive fields, pair this with the Data Masking utility to redact or encrypt before logging.</li><li><strong>Prefer layers over pip install.</strong> Layers reduce deployment package size and ensure all functions use the same Powertools version. Update the layer version in one place (Terraform local or SAM Globals).</li><li><strong>Use </strong><strong>merge() in Terraform for environment variables.</strong> Define common Powertools vars once in a local block, then merge with function-specific vars. This prevents drift.</li><li><strong>Add custom dimensions to metrics.</strong> Default metrics are useful but dimensions like ModelId, StoreId, or Environment make metrics actionable.</li><li><strong>Use </strong><strong>@tracer.capture_method on important helper functions.</strong> Not every function needs tracing, but external calls (APIs, databases, S3) should always be traced.</li><li><strong>Adopt idempotency for payment and write operations.</strong> The cost of a DynamoDB lookup is trivial compared to the cost of double-processing a payment.</li><li><strong>Use batch processing for SQS consumers.</strong> Partial failure reporting prevents the “poison message” problem where one bad record blocks the entire queue.</li><li><strong>Keep the layer version pinned.</strong> Use a specific version number, not latest. Update deliberately after testing.</li></ol><h3>Powertools Timeline</h3><p>Powertools for Python has had an active release cadence since the v3.0 GA launch. Here are the key milestones in reverse chronological order:</p><ul><li><strong>March 2026</strong> — <strong>v3.25.0</strong> released. An RFC is also in progress for native <strong>OpenTelemetry support</strong> in the Tracer utility. When shipped, Tracer will support any OTLP-compatible backend (Datadog, Honeycomb, Grafana) without changing application code.</li><li><strong>February 2026</strong> — The <strong>AWS X-Ray SDK entered maintenance mode</strong> on February 25, 2026. AWS recommends migrating to ADOT (AWS Distro for OpenTelemetry) or native OpenTelemetry instrumentation. For Lambda functions, Powertools Tracer remains a strong option that abstracts the underlying SDK — and with the OTel RFC in progress, Powertools itself will eventually move to an OTel backend.</li><li><strong>January 2026</strong> — <strong>v3.24.0</strong> released. Python 3.9 was dropped. Supported versions: 3.10, 3.11, 3.12, 3.13, 3.14.</li><li><strong>November 2025</strong> — <strong>v3.23.0</strong> added Python 3.14 Lambda runtime support.</li><li><strong>October 2025</strong> — <strong>v3.21–3.22</strong> brought Pydantic model support for query string and header parameter validation in Event Handler, plus enhanced OpenAPI response capabilities (headers, links, examples, encoding).</li><li><strong>June 2025</strong> — <strong>v3.14–3.15</strong> introduced the BedrockAgentFunctionResolver for building Bedrock Agent integrations with Lambda, and the Kafka Consumer utility with automatic JSON, Avro, and Protocol Buffer deserialization.</li><li><strong>April 2025</strong> — <strong>v3.11</strong> launched the AppSync Events Resolver for handling real-time APIs, plus Data Masking support for Pydantic models, dataclasses, and standard Python classes.</li><li><strong>March 2025</strong> — <strong>v2 reached end of support</strong> on March 25, 2025. Migration to v3 is straightforward — update the layer ARN and fix deprecated imports. The core decorator patterns are identical.</li></ul><h3>Common Pitfalls and Tips</h3><ul><li><strong>Forgetting </strong><strong>@metrics.log_metrics</strong> - Without this decorator, metrics are never flushed. Your add_metric calls silently do nothing. Always decorate the handler.</li><li><strong>Oversized log events</strong> — log_event=True with large payloads (S3 batch notifications, SQS batch of 10,000 records) can exceed CloudWatch log size limits. Use log_event=True selectively.</li><li><strong>Tracer in non-Lambda contexts</strong> — The Tracer utility assumes X-Ray is available. In unit tests, set POWERTOOLS_TRACE_DISABLED=true to avoid errors.</li><li><strong>Idempotency table capacity</strong> — For high-throughput functions, use on-demand capacity for the idempotency DynamoDB table. Provisioned capacity can throttle under burst.</li><li><strong>Layer version mismatch</strong> — Mixing Powertools layer versions across functions in the same service causes inconsistent log formats and metric namespaces. Pin to one version.</li><li><strong>Cold start metrics confusion</strong> — The ColdStart metric counts cold starts, not warm starts. A spike means your functions are scaling up or being recycled.</li><li><strong>Missing </strong><strong>tracing_config { mode = &quot;Active&quot; }</strong> - The Tracer decorator works without active tracing, but traces will not appear in X-Ray. Always enable active tracing in your IaC.</li></ul><h3>Resources</h3><ul><li><a href="https://docs.powertools.aws.dev/lambda/python/latest/">Powertools for AWS Lambda (Python) Documentation</a></li><li><a href="https://github.com/aws-powertools/powertools-lambda-python">Powertools GitHub Repository</a></li><li><a href="https://darryl-ruggles.cloud/serverless-smurf-etl-and-analytics/">Smurf ETL and Analytics</a> — 17 Lambda functions with Powertools</li><li><a href="https://darryl-ruggles.cloud/serverless-account-watcher/">Serverless Account Watcher</a> — EventBridge + Powertools monitoring</li><li><a href="https://github.com/RDarrylR/serverless-smurf-etl-and-analytics">Smurf ETL GitHub Repo</a> — Full Terraform and Python code</li><li><a href="https://github.com/RDarrylR/serverless-account-watcher">Account Watcher GitHub Repo</a> — SAM template and Lambda code</li><li><a href="https://aws.amazon.com/blogs/developer/x-ray-sdk-maintenance-mode/">AWS X-Ray SDK Maintenance Mode Announcement</a></li><li><a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html">CloudWatch Embedded Metric Format Specification</a></li><li><a href="https://www.meetup.com/aws-london-on-user-group/">AWS London Ontario User Group</a> — Community meetups and talks</li><li><a href="https://www.youtube.com/@AWSLondonONMeetup">AWS London Ontario User Group YouTube</a> — Recorded presentations</li></ul><p><em>Connect with me on</em> <a href="https://x.com/RDarrylR">X</a><em>,</em> <a href="https://bsky.app/profile/darrylruggles.bsky.social">Bluesky</a><em>,</em> <a href="https://www.linkedin.com/in/darryl-ruggles/">LinkedIn</a><em>,</em> <a href="https://github.com/RDarrylR">GitHub</a><em>,</em> <a href="https://medium.com/@RDarrylR">Medium</a><em>,</em> <a href="https://dev.to/rdarrylr">Dev.to</a><em>, or the</em> <a href="https://community.aws/@darrylr">AWS Community</a><em>. Check out more of my projects at</em> <a href="https://darryl-ruggles.cloud/">darryl-ruggles.cloud</a> <em>and join the</em> <a href="https://www.believeinserverless.com/">Believe In Serverless</a> <em>community.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c9ef6c574a2d" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[EKS and The Cross-AZ Tax: How to Stop Paying AWS $0.02/GB]]></title>
            <link>https://medium.com/@RDarrylR/eks-and-the-cross-az-tax-how-to-stop-paying-aws-0-02-gb-ca5229760951?source=rss-7ecf33fd997------2</link>
            <guid isPermaLink="false">https://medium.com/p/ca5229760951</guid>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[aws-eks]]></category>
            <category><![CDATA[finops]]></category>
            <dc:creator><![CDATA[Darryl Ruggles]]></dc:creator>
            <pubDate>Fri, 03 Apr 2026 13:57:09 GMT</pubDate>
            <atom:updated>2026-04-03T13:57:09.535Z</atom:updated>
            <content:encoded><![CDATA[<h3>EKS and The Cross-AZ Tax: How to Stop Paying AWS $0.02/GB for Traffic That Should Never Leave Your Availability Zone</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sBUCnG7-gQH-AwCn7gunIA.png" /></figure><p>I run EKS clusters across three Availability Zones because that’s what production resilience typically demands. You probably do too. But here’s something that many teams overlook: in a standard 3-AZ cluster, roughly two-thirds of east-west traffic crosses AZ boundaries for no reason. AWS charges $0.01/GB in each direction for that, $0.02/GB round trip, and it adds up fast.</p><p>This isn’t a theoretical problem. A cluster pushing 10 TB/month of inter-service traffic with 50–67% crossing AZ boundaries racks up $50–67/month in cross-AZ charges ($0.01/GB per direction). Scale that to 50 TB/month and you’re looking at $250–335/month. For a cost that’s entirely avoidable with a one-line YAML change on your Services.</p><p>I built a test environment to measure the actual impact of the major cross-AZ optimization strategies that AWS and Kubernetes provide. This article walks through what I found, what actually works, and what the documentation doesn’t tell you. All the code is Terraform and Kubernetes 1.35, using EKS Auto Mode.</p><p>If you have not read my earlier article, <a href="https://darryl-ruggles.cloud/a-complete-terraform-setup-for-eks-auto-mode-is-it-right-for-you/">A Complete Terraform Setup for EKS Auto Mode</a>, that one covers the compute side of EKS cost optimization (Auto Mode, Graviton NodePools, Spot fallback chains, and the Terraform module structure I use). This article picks up where that one left off: you have a well-provisioned cluster, but your networking costs are quietly eating into the savings you’ve worked hard to achieve on the compute side.</p><h3>Where the Money Goes</h3><p>Before optimizing anything, I wanted to understand exactly where cross-AZ traffic happens. There are three places, and the first one is by far the biggest.</p><h3>East-West Traffic: The Silent Majority</h3><p>When Pod A calls Pod B through a Service, kube-proxy picks a backend pod using iptables rules. By default, it distributes randomly across every endpoint regardless of zone. The math is straightforward: in a 3-AZ cluster with evenly distributed pods, any request has a (N-1)/N = 67% chance of landing in a different AZ.</p><p>That 67% is the number that matters. It means two-thirds of your entire service mesh is paying cross-AZ charges by default, and nothing in the Kubernetes or EKS setup warns you about it.</p><h3>North-South Traffic: The Extra Hop</h3><p>The AWS Load Balancer Controller supports “instance” mode and “ip” mode for targeting. In instance mode (the default), the ALB sends traffic to a NodePort on any node, then kube-proxy routes it to the actual pod, potentially crossing AZs twice in the process. In ip mode, the ALB targets pod IPs directly. Switching to IP target mode eliminates the problem entirely.</p><h3>NAT Gateway Traffic: Death by a Thousand Pulls</h3><p>Pods in private subnets reach the internet through NAT Gateways. If you have a single NAT in one AZ (which is what most “getting started” guides recommend), pods in other AZs cross boundaries just to reach the NAT at $0.045/GB processing plus the cross-AZ charge. Container image pulls from ECR are the worst offender here — a cluster pulling even modest images several times a day adds up.</p><p>Here is what a typical unoptimized cluster looks like. The red lines are cross-AZ traffic you are paying for:</p><figure><img alt="Cross-AZ traffic before optimization" src="https://cdn-images-1.medium.com/max/1024/0*y_YUuZmqCaSi24bS.png" /><figcaption>Cross-AZ traffic before optimization</figcaption></figure><h3>The Test Setup</h3><p>I built this with Terraform using the same modular approach from my <a href="https://darryl-ruggles.cloud/a-complete-terraform-setup-for-eks-auto-mode-is-it-right-for-you/">EKS Auto Mode article</a>: community modules for VPC and EKS, the alekc/kubectl provider for Kubernetes resources. I use kubectl rather than the kubernetes or helm providers because it handles server-side apply of CRDs (like Karpenter NodePools) cleanly, which the standard kubernetes provider struggles with. If you followed that article, the structure here will look familiar. The difference is that I have added a VPC endpoints module and the VPC configuration is more opinionated about NAT Gateway placement. Three modules: VPC, EKS, and VPC endpoints.</p><h3>The VPC: One NAT Per AZ</h3><p>The first infrastructure decision that matters is NAT Gateway placement. Here is what I used:</p><pre>module &quot;vpc&quot; {<br>  source  = &quot;terraform-aws-modules/vpc/aws&quot;<br>  version = &quot;~&gt; 5.0&quot;<br><br>  name = &quot;${var.cluster_name}-vpc&quot;<br>    cidr = var.vpc_cidr<br>    azs  = var.azs<br>    private_subnets = [<br>      cidrsubnet(var.vpc_cidr, 4, 0),<br>      cidrsubnet(var.vpc_cidr, 4, 1),<br>      cidrsubnet(var.vpc_cidr, 4, 2),<br>    ]<br>    public_subnets = [<br>      cidrsubnet(var.vpc_cidr, 4, 3),<br>      cidrsubnet(var.vpc_cidr, 4, 4),<br>      cidrsubnet(var.vpc_cidr, 4, 5),<br>    ]<br>    # This is the setting that matters<br>    enable_nat_gateway     = true<br>    single_nat_gateway     = false<br>    one_nat_gateway_per_az = true<br>    enable_dns_hostnames = true<br>    enable_dns_support   = true<br>    public_subnet_tags = {<br>      &quot;kubernetes.io/role/elb&quot; = 1<br>    }<br>    private_subnet_tags = {<br>      &quot;kubernetes.io/role/internal-elb&quot; = 1<br>    }<br>    tags = var.tags<br>}<br></pre><p>Each additional NAT Gateway costs about $32/month. You save $0.01/GB in each direction ($0.02/GB round trip) on traffic that no longer crosses AZ boundaries to reach the NAT. That means the extra NAT pays for itself at just 1.6 TB/month of internet-bound traffic per AZ. The NAT processing charge ($0.045/GB) applies regardless of which AZ the NAT is in, so that cost doesn’t change with this optimization. For most production clusters, 1.6 TB/month of internet traffic per AZ is a low bar to clear.</p><p>I see many Terraform examples online that set single_nat_gateway = true to save money in dev environments. That&#39;s fine for dev, but if you copy that pattern into production you are silently adding cross-AZ charges to every internet-bound packet from two of your three AZs.</p><h3>VPC Endpoints: The Optimization Most Teams Skip</h3><p>This is the optimization that surprises most teams. Without VPC endpoints, every ECR image pull and CloudWatch log shipment routes through your NAT Gateway at $0.045/GB. I always add endpoints for the services that generate the most traffic:</p><pre># S3 Gateway - FREE. No hourly cost, no data cost. No reason not to have this.<br>resource &quot;aws_vpc_endpoint&quot; &quot;s3&quot; {<br>  vpc_id            = var.vpc_id<br>  service_name      = &quot;com.amazonaws.${data.aws_region.current.name}.s3&quot;<br>  vpc_endpoint_type = &quot;Gateway&quot;<br>  route_table_ids   = var.route_table_ids<br>}<br><br># ECR API - for docker login and manifest operations<br>resource &quot;aws_vpc_endpoint&quot; &quot;ecr_api&quot; {<br>  vpc_id              = var.vpc_id<br>  service_name        = &quot;com.amazonaws.${data.aws_region.current.name}.ecr.api&quot;<br>  vpc_endpoint_type   = &quot;Interface&quot;<br>  subnet_ids          = var.private_subnet_ids<br>  security_group_ids  = [aws_security_group.vpc_endpoints.id]<br>  private_dns_enabled = true<br>}<br># ECR Docker - for image layer pulls (this is the high-volume one)<br>resource &quot;aws_vpc_endpoint&quot; &quot;ecr_dkr&quot; {<br>  vpc_id              = var.vpc_id<br>  service_name        = &quot;com.amazonaws.${data.aws_region.current.name}.ecr.dkr&quot;<br>  vpc_endpoint_type   = &quot;Interface&quot;<br>  subnet_ids          = var.private_subnet_ids<br>  security_group_ids  = [aws_security_group.vpc_endpoints.id]<br>  private_dns_enabled = true<br>}<br># STS - for IRSA and Pod Identity. Low volume but breaks auth if missing.<br>resource &quot;aws_vpc_endpoint&quot; &quot;sts&quot; {<br>  vpc_id              = var.vpc_id<br>  service_name        = &quot;com.amazonaws.${data.aws_region.current.name}.sts&quot;<br>  vpc_endpoint_type   = &quot;Interface&quot;<br>  subnet_ids          = var.private_subnet_ids<br>  security_group_ids  = [aws_security_group.vpc_endpoints.id]<br>  private_dns_enabled = true<br>}</pre><p>The interface endpoints cost about $7.20/month per AZ ($21.60 total across 3 AZs, per endpoint). The S3 and DynamoDB gateway endpoints are free. At $0.045/GB NAT processing, the interface endpoints pay for themselves once you push more than about 480 GB/month through each service. ECR image pulls alone blow past that in most clusters.</p><p><strong>A nuance worth understanding:</strong> ECR uses two separate service endpoints. The ecr.api endpoint handles authentication and manifest resolution (low-volume API calls). The ecr.dkr endpoint handles the actual image layer downloads, which is where the bulk of the data transfer happens. If you still have a NAT Gateway (and you likely do for other internet-bound traffic), you don&#39;t strictly need both. The ecr.dkr endpoint alone captures the majority of the cost savings because that is where the gigabytes flow. The ecr.api calls are small enough that routing them through NAT is negligible. I include both in the repo for completeness, but if you&#39;re watching your interface endpoint spend, ecr.dkr plus the free S3 gateway endpoint are the ones that move the needle. ECR stores image layers in S3, which is why the S3 gateway endpoint is critical for ECR cost savings. The STS endpoint is shown in the code above. I also add a CloudWatch Logs endpoint in the full module for container log shipping.</p><p><strong>Another gotcha:</strong> the security group on your interface endpoints must allow inbound HTTPS (443) from your VPC CIDR. Skip that and you get timeouts that look like DNS failures. Here is what the security group looks like:</p><pre>resource &quot;aws_security_group&quot; &quot;vpc_endpoints&quot; {<br>  name_prefix = &quot;${var.cluster_name}-vpce-&quot;<br>  description = &quot;Security group for VPC interface endpoints&quot;<br>  vpc_id      = var.vpc_id<br><br>  ingress {<br>      description = &quot;HTTPS from VPC&quot;<br>      from_port   = 443<br>      to_port     = 443<br>      protocol    = &quot;tcp&quot;<br>      cidr_blocks = [var.vpc_cidr]<br>    }<br>}</pre><p>This allows the entire VPC CIDR, which is fine for most setups. In a tighter security posture, scope the source to your private subnet CIDRs or reference the cluster’s security group ID directly.</p><h3>EKS Auto Mode on Kubernetes 1.35</h3><p>I used Kubernetes 1.35, the latest version available on EKS as of March 2026. Traffic Distribution was introduced as alpha in 1.30 with PreferClose, which graduated to GA in 1.33. In 1.34, PreferSameZone was introduced as a clearer replacement and PreferClose became a deprecated alias. Both PreferSameZone and PreferSameNode graduated to stable in 1.35. More on both below.</p><pre>module &quot;eks&quot; {<br>  source  = &quot;terraform-aws-modules/eks/aws&quot;<br>  version = &quot;~&gt; 21.0&quot;<br>  name               = var.cluster_name<br><br>  kubernetes_version = &quot;1.35&quot;<br>  vpc_id     = var.vpc_id<br>  subnet_ids = var.private_subnet_ids<br>  # When node_pools are specified, the module automatically creates<br>  # the required Auto Mode IAM resources.<br>  compute_config = {<br>    enabled    = true<br>    node_pools = [&quot;general-purpose&quot;, &quot;system&quot;]<br>  }<br>  # Private access for in-VPC communication, public for kubectl access.<br>  # In production, restrict public access to specific CIDRs or disable it.<br>  endpoint_private_access = true<br>  endpoint_public_access  = true<br>  # Control plane logging for observability<br>  enabled_log_types = [&quot;api&quot;, &quot;audit&quot;, &quot;authenticator&quot;]<br>  # Cross-AZ traffic visibility in the EKS console<br>  addons = {<br>    amazon-cloudwatch-observability = {<br>      most_recent = true<br>    }<br>  }<br>  # Fine for demos. For production, use scoped access entries with narrower policies.<br>  enable_cluster_creator_admin_permissions = true<br>  tags = var.tags<br>}</pre><p>I also added a Graviton NodePool at weight 10 for better price-performance, using the same weighted NodePool pattern I described in the <a href="https://darryl-ruggles.cloud/a-complete-terraform-setup-for-eks-auto-mode-is-it-right-for-you/">Auto Mode article</a>. That isn’t strictly related to cross-AZ optimization, but when you’re already optimizing costs, the extra 20% from Graviton is worth grabbing. The full NodePool manifest is in terraform/modules/eks/main.tf.</p><p><strong>Important Auto Mode detail:</strong> both the NodePool and NodeClass API groups are eks.amazonaws.com in Auto Mode, not karpenter.k8s.aws as in self-managed Karpenter installations. I got bitten by this when I first tried to reuse manifests from a self-managed cluster. The resource silently fails to apply with the wrong API group.</p><h3>Measuring Cross-AZ Traffic: Build a Tool, Don’t Guess</h3><p>The AWS docs describe these routing strategies well enough, but they don’t show you how to verify that they are actually working. I built two things for this:</p><h3>A Zone-Aware Echo Server</h3><p>Instead of using a generic HTTP echo server, I wrote a small FastAPI app that returns its own AZ, node, and pod name in every response:</p><pre># app/zone-echo/server.py<br>@app.get(&quot;/&quot;)<br>async def echo():<br>    return {<br>        &quot;zone&quot;: _get_zone(),<br>        &quot;node&quot;: os.environ.get(&quot;NODE_NAME&quot;, &quot;unknown&quot;),<br>        &quot;pod&quot;: os.environ.get(&quot;POD_NAME&quot;, &quot;unknown&quot;),<br>        &quot;pod_ip&quot;: os.environ.get(&quot;POD_IP&quot;, &quot;unknown&quot;),<br>    }</pre><p>The zone is resolved at startup by querying the Kubernetes API for the node’s topology.kubernetes.io/zone label (IMDS is blocked on Auto Mode&#39;s Bottlerocket nodes). If you adapt this for non-Auto-Mode clusters, ensure IMDSv2 is enforced (http_tokens = &quot;required&quot;) and set the hop limit appropriately. Each response tells you exactly where it came from with zero ambiguity.</p><h3>A Traffic Measurement Tool</h3><p>The second tool sends hundreds of requests to each service variant and counts how many stayed in-zone versus crossed AZ boundaries:</p><pre># app/traffic-test/measure.py (abbreviated)<br>def run_test(service: str, num_requests: int, my_zone: str, my_node: str) -&gt; RoutingStats:<br>    stats = RoutingStats(service=service)<br><br>    for i in range(num_requests):<br>        body, latency = send_request(service)<br>        stats.total += 1<br>        if body is None:<br>            stats.errors += 1<br>            continue<br>        stats.latencies_ms.append(latency)<br>        resp_zone = body.get(&quot;zone&quot;, &quot;unknown&quot;)<br>        if resp_zone == my_zone:<br>            stats.same_zone += 1<br>        else:<br>            stats.cross_zone += 1<br>    return stats</pre><p>Run it as a Kubernetes Job and check the logs:</p><pre>kubectl apply -f k8s/monitoring/traffic-test-job.yaml<br>kubectl logs -n cross-az-demo job/traffic-test</pre><p>The output looks like this:</p><pre>==============================================================================<br>CROSS-AZ TRAFFIC MEASUREMENT REPORT<br>==============================================================================<br>Source zone: us-east-1a<br>Source node: i-07812b1c2b6ed6405<br><br>Service                        Reqs  Same-Zone  Cross-AZ  Errors  Avg(ms)  P99(ms)<br>--------------------------------------------------------------------------------<br>backend-baseline               5000     49.9%     50.1%       0     2.4      4.2<br>backend-prefer-same-zone       5000    100.0%      0.0%       0     1.8      3.1<br>backend-prefer-same-node       5000    100.0%      0.0%       0     1.6      2.5<br>backend-topology-aware         5000    100.0%      0.0%       0     1.5      2.5<br>--- Zone Distribution per Service ---<br>  backend-baseline: us-east-1a: 2497, us-east-1b: 827, us-east-1c: 1676<br>  backend-prefer-same-zone: us-east-1a: 5000<br>  backend-prefer-same-node: us-east-1a: 5000<br>  backend-topology-aware: us-east-1a: 5000<br>--- Cost Estimate (at 10 TB/month east-west traffic) ---<br>  backend-baseline: $100.12/month (5,006 GB cross-AZ)<br>  backend-prefer-same-zone: $0.00/month (0 GB cross-AZ)<br>  backend-prefer-same-node: $0.00/month (0 GB cross-AZ)<br>  backend-topology-aware: $0.00/month (0 GB cross-AZ)<br>  Best improvement: backend-prefer-same-zone<br>  Cross-AZ reduction: 100.0%<br>  Estimated monthly savings: $100.12<br>==============================================================================</pre><p>The baseline came in at 50% cross-AZ, not the theoretical 67%. That is because I had 3 of my 6 backend pods in us-east-1a (where the test ran), so random distribution gives 3/6 = 50% same-zone. The exact number depends on how many pods are in the caller’s zone relative to the total. In a real cluster with dozens of services and uneven pod counts, the cross-AZ percentage will vary per service.</p><p>The important result: all three optimized strategies hit 100% same-zone across 5,000 requests. Zero cross-AZ leakage. With stable pods and no churn during the test, there was no endpoint propagation delay to cause even a single cross-AZ request.</p><p>PreferSameNode edged out PreferSameZone on latency (1.6ms average versus 1.8ms) because it routes to pods on the same node, not just the same zone. The baseline’s 2.4ms average includes the cross-AZ network hop penalty.</p><p>This is why I say “build a tool, don’t guess.” The AWS documentation suggests 60–80% reduction from topology-aware routing. My actual measurement shows 100% reduction with properly distributed pods. That gap is significant enough to change how you think about the ROI.</p><h3>What Happens During Pod Churn?</h3><p>Stable pods are one thing. I wanted to know what happens when pods are being created and destroyed, which is the reality of any production cluster running rolling deployments. I ran the same 5,000-request test while simultaneously scaling the backend from 6 to 12 to 3 to 9 and back, repeatedly.</p><pre>=== RESULTS DURING POD CHURN ===<br><br>Service                        Reqs  Same-Zone  Cross-AZ  Errors  Avg(ms)  P99(ms)<br>--------------------------------------------------------------------------------<br>backend-baseline               5000     54.7%     42.3%     151     2.6      6.3<br>backend-prefer-same-zone       5000    100.0%      0.0%       0     1.7      3.5<br>backend-prefer-same-node       5000    100.0%      0.0%       0     1.7      5.4<br>backend-topology-aware         5000    100.0%      0.0%       0     1.8      5.3<br></pre><p>All three optimized strategies held at 100% same-zone during churn. Not a single cross-AZ request leaked through while pods were being created and destroyed.</p><p>The baseline told a different story: 151 errors (3%) from requests hitting endpoints that were mid-termination. The optimized strategies had zero errors because zone-local endpoints remained available throughout the churn even as remote ones cycled. This is an availability benefit I didn’t expect on top of the cost savings.</p><p>P99 latency did increase under churn. PreferSameNode went from 2.5ms to 5.4ms as the endpoint list updated. PreferSameZone stayed more stable at 3.5ms. That’s expected and acceptable.</p><p>One thing I got wrong in my initial prediction: I expected Topology Aware Routing (TAR) to drop hints during the churn and leak cross-AZ traffic. It did not. With on-demand instances and even pod distribution, TAR’s proportional allocation held steady. The hint-dropping issue likely requires more extreme capacity changes, like Spot interruptions removing entire nodes from a zone, rather than the gradual scaling I tested here.</p><h3>The Routing Strategies: What Actually Works</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*x5O2qraRJvHeLWOx.png" /></figure><p>I tested five approaches. Here is my honest ranking.</p><h3>1. Traffic Distribution: PreferSameZone (Use This One)</h3><p>This is the winner. One field on your Service spec:</p><pre>apiVersion: v1<br>kind: Service<br>metadata:<br>  name: backend-prefer-same-zone<br>  namespace: cross-az-demo<br>spec:<br>  type: ClusterIP<br>  trafficDistribution: PreferSameZone  # stable in K8s 1.35, replaces deprecated PreferClose<br>  selector:<br>    app: backend<br>  ports:<br>    - port: 8080<br>      targetPort: 8080</pre><p>That’s it. One line. kube-proxy routes to same-zone endpoints first. If no local endpoints exist, it falls back to any available endpoint. PreferSameZone was introduced in 1.34 and graduated to stable in 1.35. No feature gates, no annotation. It is a first-class spec field.</p><p>I recommend this as the default for every ClusterIP Service in your cluster unless you have a specific reason not to use it. The prerequisite is having your pods distributed across zones with topology spread constraints, which you should be doing anyway for availability.</p><p><strong>What about AZ failures?</strong> During an AZ outage, pods in the failed zone lose their local endpoints. PreferSameZone handles this gracefully by falling back to cross-AZ routing automatically. There is no availability impact, only a temporary return to cross-AZ costs until the zone recovers. This is the correct behavior: you get cost savings during normal operation and resilience during failures without any manual intervention.</p><h3>2. Topology Aware Routing (It Works, But PreferSameZone is Better)</h3><p>The older approach, using an annotation:</p><pre>metadata:<br>  annotations:<br>    service.kubernetes.io/topology-mode: Auto</pre><p>TAR uses a more complex algorithm: the EndpointSlice controller allocates endpoints proportionally across zones and sets “hints” for each one. The idea is to balance both locality and load distribution.</p><p>In my testing, it works. The numbers are close to PreferSameZone. But I found two problems that make me recommend PreferSameZone instead:</p><p><strong>Problem 1: Spot instances break it.</strong> TAR recalculates proportional allocation based on zone capacity. When Spot instances get reclaimed, zone capacity fluctuates, and during the transition the controller may drop hints entirely. When hints are dropped, you revert to random distribution with no warning. PreferSameZone doesn’t have this problem because it uses simpler same-zone-first logic without capacity-proportional math.</p><p><strong>Problem 2: The annotation is deprecated.</strong> The service.kubernetes.io/topology-mode: Auto annotation won&#39;t graduate to GA. The underlying mechanism (EndpointSlice hints) is GA, but the recommended path forward is the trafficDistribution field (PreferSameZone), which is stable in 1.35. There&#39;s no reason to use the deprecated annotation when the replacement is available and simpler.</p><h3>3. PreferSameNode: The Best of Both Worlds (New in 1.35)</h3><p>Kubernetes 1.35 added a new Traffic Distribution option that solves a real gap:</p><pre>spec:<br>  trafficDistribution: PreferSameNode</pre><p>This routes to endpoints on the same <em>node</em> first, then falls back to same-zone, then cluster-wide. It gives you the latency benefits of internalTrafficPolicy: Local but with a graceful fallback instead of dropped traffic.</p><p>This is the option I wish had existed from the start. It is strictly better than internalTrafficPolicy: Local for any use case where you want node-local preference but cannot tolerate dropped requests. Stable since 1.35.</p><p>I recommend this for latency-sensitive service pairs that are often (but not always) co-located. For pure cost optimization, PreferSameZone is sufficient. PreferSameNode adds value when sub-millisecond latency between specific services matters.</p><h3>4. Internal Traffic Policy: Local (The Nuclear Option)</h3><pre>spec:<br>  internalTrafficPolicy: Local</pre><p>This restricts traffic to endpoints on the same <em>node</em>, not just the same zone. Zero cross-AZ traffic is possible. Zero cross-node traffic either.</p><p>I call this the nuclear option because if no endpoint exists on the calling pod’s node, <strong>the traffic is dropped.</strong> Not rerouted or retried — just dropped. You must guarantee co-location with pod affinity rules or use DaemonSet-backed services.</p><p>I proved this during testing. I created per-AZ services with internalTrafficPolicy: Local and ran a test from zone us-east-1a against the zone-b service. The test hung indefinitely. Every request timed out because there were no zone-b pods on the zone-a node. There were no errors and no fallbacks - just a silent failure.</p><p>When it does work (when a backend pod exists on the same node), the latency is the best of any strategy: 1.6ms average in my testing, versus 1.8ms for PreferSameZone. But the risk of silent traffic drops makes it unsuitable for most services.</p><p>I use this for exactly two situations:</p><ul><li>DaemonSet services (every node has a local endpoint by definition)</li><li>Tightly coupled pod pairs with strict requiredDuringSchedulingIgnoredDuringExecution affinity</li></ul><p>For everything else, PreferSameZone is safer and almost as effective. And now with PreferSameNode in 1.35, there is even less reason to use Local. You get the same node-local preference with a graceful fallback.</p><h3>5. Per-AZ Deployments (When PreferSameZone Is Not Enough)</h3><p>There is a subtle scaling problem that PreferSameZone doesn’t solve. Say traffic to your backend spikes in us-east-1a. The HPA scales the Deployment from 6 to 12 replicas. Kubernetes spreads the new pods across all three zones, but only us-east-1a needed the capacity. You have wasted compute in 1b and 1c, and if Karpenter provisions new nodes to host those pods, you are paying for nodes that serve no traffic.</p><p>The fix is per-AZ Deployments with independent HPAs:</p><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: backend-az-a<br>spec:<br>  replicas: 2<br>  selector:<br>    matchLabels:<br>      app: backend-zonal<br>      zone: az-a<br>  template:<br>    spec:<br>      affinity:<br>        nodeAffinity:<br>          requiredDuringSchedulingIgnoredDuringExecution:<br>            nodeSelectorTerms:<br>              - matchExpressions:<br>                  - key: topology.kubernetes.io/zone<br>                    operator: In<br>                    values: [&quot;us-east-1a&quot;]<br>---<br>apiVersion: autoscaling/v2<br>kind: HorizontalPodAutoscaler<br>metadata:<br>  name: backend-az-a<br>spec:<br>  scaleTargetRef:<br>    apiVersion: apps/v1<br>    kind: Deployment<br>    name: backend-az-a<br>  minReplicas: 2<br>  maxReplicas: 10<br>  metrics:<br>    - type: Resource<br>      resource:<br>        name: cpu<br>        target:<br>          type: Utilization<br>          averageUtilization: 70</pre><p>Multiply by three AZs. You get triple the manifests and triple the HPA objects to monitor. I would only use this pattern for my top 2–3 highest-traffic services where the zone-specific scaling behavior justifies the operational complexity.</p><p>The full 3-AZ manifest is in k8s/cross-az-optimization/05-per-az-deployment.yaml.</p><h3>Load Balancer: Switch to IP Mode</h3><p>For services fronted by an ALB, switch the target type from “instance” to “ip”:</p><pre>apiVersion: networking.k8s.io/v1<br>kind: Ingress<br>metadata:<br>  annotations:<br>    alb.ingress.kubernetes.io/target-type: ip<br>    alb.ingress.kubernetes.io/scheme: internet-facing</pre><p>In instance mode, traffic flows: ALB — NodePort (any node) — kube-proxy — Pod (maybe different AZ). That is up to two cross-AZ hops. In IP mode: ALB — Pod IP directly. The ALB registers pod IPs as targets in the target group, bypassing kube-proxy entirely.</p><p>Combine this with trafficDistribution: PreferSameZone on the backing Service for the best result.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*UheQjU4jZ0gExJ5K.png" /></figure><h3>What the AWS Documentation Doesn’t Tell You</h3><p>I ran into several things during testing that aren’t obvious from the official docs:</p><p><strong>Topology spread constraints need </strong><strong>minDomains with Auto Mode.</strong> This one bit me during testing. I set topologySpreadConstraints with maxSkew: 1 and whenUnsatisfiable: DoNotSchedule on my Deployment - the standard recipe from every example out there. But with EKS Auto Mode, you start from zero nodes. Karpenter provisions one node in one AZ, and the scheduler sees only one topology domain. A maxSkew of 1 across a single domain is always satisfied, so all 6 pods land on that one node in one AZ.</p><p>The fix is minDomains: 3. This tells the scheduler that at least 3 zones must exist before scheduling is satisfied, which forces Karpenter to provision nodes in other AZs:</p><pre>topologySpreadConstraints:<br>  - maxSkew: 1<br>    minDomains: 3<br>    topologyKey: topology.kubernetes.io/zone<br>    whenUnsatisfiable: DoNotSchedule<br>    labelSelector:<br>      matchLabels:<br>        app: backend</pre><p>Most topology spread examples assume you already have nodes in multiple AZs. With Auto Mode and Karpenter, you do not. Without minDomains, your pods will quietly consolidate into a single zone while the topology constraint reports no violations.</p><p><strong>The EKS console has built-in cross-AZ visibility now.</strong> Enable the amazon-cloudwatch-observability addon (included in the Terraform code above) and you get a service map in the EKS console showing cross-AZ flows with data volume. This is much easier than building custom Prometheus queries against VPC flow logs.</p><p><strong>VPC endpoint interface charges don’t include cross-AZ data transfer.</strong> This is buried in a “What’s New” announcement from 2022. Traffic between pods in us-east-1a and a VPC endpoint ENI in us-east-1b doesn’t incur the $0.01/GB cross-AZ charge. The PrivateLink data processing charge still applies, but it is less than NAT processing.</p><p><strong>PreferSameZone occasionally sends cross-AZ during endpoint updates.</strong> When pods are added or removed, there’s a brief window where EndpointSlice hints haven’t propagated. During this window, kube-proxy may route to any endpoint. In my testing this was under 1% of requests, but for latency-critical services it’s worth knowing.</p><p><strong>CoreDNS is a cross-AZ blind spot.</strong> DNS lookups via the kube-dns Service are themselves subject to cross-AZ routing, and as of this writing you can&#39;t set trafficDistribution on the CoreDNS addon service directly. There&#39;s an <a href="https://github.com/aws/containers-roadmap/issues/2628">open EKS feature request</a> for this. If DNS latency matters to you, this is a gap to watch.</p><p><strong>Finding cross-AZ charges in Cost Explorer.</strong> Beyond the EKS console’s network observability, you can identify cross-AZ charges in AWS Cost Explorer by filtering for the DataTransfer-Regional-Bytes and NatGateway-Bytes usage types. These are the line items that this article&#39;s optimizations target.</p><h3>Production Considerations</h3><p>This article focuses on FinOps, but if you are taking these patterns into production, a few security points are worth noting:</p><ul><li><strong>Cluster access:</strong> The demo uses enable_cluster_creator_admin_permissions = true for simplicity. For production, define scoped access entries with narrower policies (e.g., AmazonEKSViewPolicy for read-only users, AmazonEKSClusterAdminPolicy only for platform teams).</li><li><strong>Workload identity:</strong> EKS Auto Mode with module v21+ uses Pod Identity by default. If your workloads need AWS API access (unlike the demo pods here), configure Pod Identity associations rather than IRSA.</li><li><strong>VPC endpoint security groups:</strong> The demo allows all VPC CIDR traffic on port 443. In production, scope the source to your private subnet CIDRs or reference the cluster’s security group directly.</li><li><strong>Network ACLs:</strong> The default NACL allows all traffic. Production environments may want to restrict outbound rules on private subnets to known destinations and ports.</li><li><strong>IMDSv2:</strong> Auto Mode uses Bottlerocket, which enforces IMDSv2. If you adapt this code for self-managed node groups with Amazon Linux, ensure IMDSv2 is enforced (http_tokens = &quot;required&quot; in your launch template) and set http_put_response_hop_limit = 2 for containerized workloads.</li><li><strong>Encryption at rest:</strong> The demo doesn’t configure a custom KMS key for EKS secrets encryption. For production, enable envelope encryption with encryption_config in the EKS module to encrypt Kubernetes secrets at rest with a customer-managed key.</li><li><strong>Encryption in transit:</strong> Traffic between pods is unencrypted by default. If your services handle sensitive data, consider a service mesh with mTLS (Istio ambient mode or Linkerd) or use application-level TLS. Note that a sidecar-based mesh adds cross-AZ overhead of its own, so evaluate the cost trade-off against the security benefit.</li></ul><h3>What This Costs and What You Save</h3><p>Here is my cost analysis for a cluster with 10 TB/month east-west traffic and 2 TB/month AWS service traffic (ECR, S3, CloudWatch):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zrnZXAk3Un3q2RBbF_FS8Q.png" /></figure><p>At 10 TB/month, the savings are modest at about $84/month. But cross-AZ costs scale linearly with traffic. At 50 TB/month east-west, the east-west savings alone hit $650/month. At 100 TB/month, over $1,300/month. The infrastructure costs (VPC endpoints, extra NATs) stay flat.</p><p>The real point is that the traffic routing changes (PreferSameZone on Services and IP mode on Ingress) are free. There is zero infrastructure cost and only one line of YAML per Service. Do those first and measure the impact before deciding whether VPC endpoints and per-AZ NATs are worth it for your traffic volume.</p><h3>My Recommended Order of Operations</h3><ol><li><strong>Add VPC endpoints for S3 (free), ECR, STS, and CloudWatch Logs.</strong> This is the biggest win for most teams. The S3 and DynamoDB gateway endpoints are free, and the ECR interface endpoint eliminates NAT processing charges ($0.045/GB) on every container image pull. If your cluster pulls images regularly, this alone can save more than all the other optimizations combined.</li><li><strong>Add </strong><strong>trafficDistribution: PreferSameZone to all ClusterIP Services.</strong> The highest-impact change for east-west traffic. It&#39;s a single field (stable in K8s 1.35), and in my testing eliminates 99% of cross-AZ east-west traffic with properly distributed pods. For latency-sensitive pairs, consider PreferSameNode (stable in 1.35).</li><li><strong>Add topology spread constraints to all Deployments.</strong> PreferSameZone only works if pods are actually distributed across zones. If they aren’t, this is your real first step.</li><li><strong>Switch ALB Ingress resources to IP target mode.</strong> Another free change. One annotation eliminates the NodePort-to-pod cross-AZ hop.</li><li><strong>Deploy one NAT Gateway per AZ.</strong> Worth it when your cross-AZ NAT traffic exceeds ~1.6 TB/month per AZ.</li><li><strong>Consider per-AZ Deployments for your top 2–3 highest-traffic services.</strong> Only if you have zone-specific scaling problems that PreferSameZone doesn’t solve.</li></ol><h3>Getting Started</h3><p>The complete code is in the <a href="https://github.com/RDarrylR/eks-cross-az-exploration">companion repository</a>. Everything is configurable (region, cluster name, CIDR range) through Terraform variables with no hardcoded account IDs or resource names.</p><p><strong>Prerequisites:</strong> AWS CLI configured, Terraform 1.10+, Docker, kubectl, Python 3.13+.</p><p>The fastest path is the one-command setup:</p><pre>git clone https://github.com/darryl-ruggles/eks-cross-az-exploration<br>cd eks-cross-az-exploration<br><br># Deploy everything and run the measurement (takes ~15 minutes)<br>make setup</pre><p>That runs Terraform, builds and pushes container images to ECR, deploys the demo app with all service variants, and executes the traffic measurement job. At the end you get the comparison report.</p><p>If you prefer to step through it:</p><pre># 1. Deploy infrastructure (VPC, EKS, VPC endpoints, ECR repos)<br>make infra<br># 2. Configure kubectl<br>make kubeconfig<br># 3. Build and push container images<br>make images<br># 4. Deploy the demo app and all routing strategies<br>make deploy<br># 5. Run the cross-AZ measurement<br>make test<br># 6. See pod distribution across AZs<br>make show-distribution<br># 7. Run again with PreferSameNode included<br>make test-all<br># 8. Clean up when done<br>make teardown<br>Copy</pre><p>All configuration is overridable: make test NUM_REQUESTS=1000, make infra (uses variables.tf defaults; edit region, cluster_name, vpc_cidr as needed).</p><p><strong>IMPORTANT:</strong> This deploys real AWS resources that cost money. The EKS cluster alone is $72/month, plus 3 NAT Gateways (~$97/month), EC2 instances for worker nodes, and VPC endpoint charges. Run make teardown when you&#39;re done testing. If terraform destroy fails with dangling ENIs (which can happen if VPC endpoints are destroyed before EKS releases its network interfaces), wait a few minutes and retry.</p><p>The measurement report gives you actual numbers for your cluster, which matters more than any estimate in a blog post.</p><p>Cross-AZ data transfer is one of those costs that seems small on any individual request but compounds into a meaningful line item at scale. The good news is that the highest-impact fix is also the simplest: one field on your Service spec, no infrastructure changes required.</p><h3>Resources</h3><ul><li><a href="https://docs.aws.amazon.com/eks/latest/best-practices/cost-opt-networking.html">EKS Best Practices — Cost Optimization Networking</a></li><li><a href="https://kubernetes.io/docs/reference/networking/virtual-ips/#traffic-distribution">Kubernetes Traffic Distribution</a></li><li><a href="https://kubernetes.io/docs/concepts/services-networking/topology-aware-routing/">Kubernetes Topology Aware Routing</a></li><li><a href="https://kubernetes.io/docs/concepts/services-networking/service-traffic-policy/">Kubernetes Service Internal Traffic Policy</a></li><li><a href="https://aws.amazon.com/blogs/containers/getting-visibility-into-your-amazon-eks-cross-az-pod-to-pod-network-bytes/">Getting Visibility into Amazon EKS Cross-AZ Pod to Pod Network Bytes</a></li><li><a href="https://docs.aws.amazon.com/eks/latest/userguide/network-observability.html">Container Network Observability on EKS</a></li><li><a href="https://docs.aws.amazon.com/AmazonECR/latest/userguide/vpc-endpoints.html">Amazon ECR Interface VPC Endpoints</a></li><li><a href="https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints-s3.html">Gateway Endpoints for Amazon S3</a></li><li><a href="https://aws.amazon.com/privatelink/pricing/">AWS PrivateLink Pricing</a></li><li><a href="https://aws.amazon.com/ec2/pricing/on-demand/#Data_Transfer">AWS Data Transfer Pricing</a></li><li><a href="https://docs.aws.amazon.com/eks/latest/userguide/kubernetes-versions-standard.html">Kubernetes 1.35 Release Notes — PreferSameNode Stable</a></li><li><a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-network/4444-service-traffic-distribution">KEP-3015 — Traffic Distribution for Services</a></li><li><a href="https://docs.aws.amazon.com/eks/latest/userguide/automode.html">EKS Auto Mode Overview</a></li><li><a href="https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/latest/examples/eks-auto-mode">terraform-aws-modules/eks v21 Auto Mode Example</a></li><li><a href="https://aws.amazon.com/eks/pricing/">Amazon EKS Pricing</a></li><li><a href="https://github.com/RDarrylR/eks-cross-az-exploration">Full Companion Code — GitHub</a></li><li><a href="https://darryl-ruggles.cloud/a-complete-terraform-setup-for-eks-auto-mode-is-it-right-for-you/">A Complete Terraform Setup for EKS Auto Mode</a></li></ul><p>Connect with me on <a href="https://x.com/darrylruggles">X</a>, <a href="https://bsky.app/profile/darrylruggles.bsky.social">Bluesky</a>, <a href="https://linkedin.com/in/darrylruggles">LinkedIn</a>, <a href="https://github.com/darryl-ruggles">GitHub</a>, <a href="https://medium.com/@darrylruggles">Medium</a>, <a href="https://dev.to/darrylruggles">Dev.to</a>, or the <a href="https://community.aws/@darrylruggles">AWS Community</a>. Check out more of my projects at <a href="https://darryl-ruggles.cloud/">darryl-ruggles.cloud</a> and join the <a href="https://believeinserverless.com/">Believe In Serverless</a> community.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ca5229760951" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AWS Lambda Now Knows Where It Lives: Exploring the New AZ Metadata]]></title>
            <link>https://medium.com/@RDarrylR/aws-lambda-now-knows-where-it-lives-exploring-the-new-az-metadata-9ab675c9d05a?source=rss-7ecf33fd997------2</link>
            <guid isPermaLink="false">https://medium.com/p/9ab675c9d05a</guid>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[serverless]]></category>
            <category><![CDATA[aws-lambda]]></category>
            <dc:creator><![CDATA[Darryl Ruggles]]></dc:creator>
            <pubDate>Fri, 27 Mar 2026 01:40:19 GMT</pubDate>
            <atom:updated>2026-03-27T01:42:59.684Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-PpDXikkHAuWinzIxnbxZw.png" /></figure><p>Lambda was the last holdout. EC2 has had its Instance Metadata Service since the beginning. ECS and Fargate have their task metadata endpoints. But Lambda? If your function needed to know which Availability Zone it was running in, you were out of luck — unless you enjoyed building elaborate workarounds with private API Gateway endpoints and DescribeNetworkInterfaces calls.</p><p>On March 19, 2026, AWS quietly fixed this with a <a href="https://aws.amazon.com/about-aws/whats-new/2026/03/lambda-availability-zone-metadata/">new metadata endpoint</a> that exposes the AZ ID of the execution environment. No VPC is required, no IAM permissions are needed, and there are no API calls to external services. It’s just a localhost HTTP endpoint that returns your AZ in under a millisecond.</p><p>I built a small demo app to explore three practical uses for this new metadata, using Powertools for AWS Lambda for observability throughout (I wrote about <a href="https://darryl-ruggles.cloud/powertools-for-aws-lambda-best-practices-by-default/">Powertools best practices</a> previously). The code is <a href="https://github.com/RDarrylR/lambda-az-metadata">on GitHub</a> if you want to deploy it yourself.</p><h3>What’s in the Box</h3><p>Lambda now sets two new reserved environment variables in every execution environment:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9x3YLyteRqlf2-hIonMoUw.png" /></figure><p>You make a GET request to the endpoint with the token as a Bearer header, and you get back your AZ ID:</p><pre>GET http://${AWS_LAMBDA_METADATA_API}/2026-01-15/metadata/execution-environment<br><br>Authorization: Bearer ${AWS_LAMBDA_METADATA_TOKEN}{<br>  &quot;AvailabilityZoneID&quot;: &quot;use1-az1&quot;<br>}</pre><p>That’s it. The response is cached for 12 hours and marked immutable - it never changes within an execution environment.</p><p>Powertools for AWS Lambda makes it even simpler:</p><pre>from aws_lambda_powertools.utilities.metadata import get_lambda_metadata<br><br>metadata = get_lambda_metadata()<br>az_id = metadata.availability_zone_id  # &quot;use1-az1&quot;</pre><p>Powertools handles the caching and SnapStart invalidation automatically.</p><h3>AZ IDs vs AZ Names</h3><p>One important detail: the metadata returns <strong>AZ IDs</strong> (use1-az1), not AZ names (us-east-1a). This matters because AZ IDs are consistent across all AWS accounts - use1-az1 is always the same physical data center regardless of which account you&#39;re in. AZ names are mapped differently per account to distribute load, so my us-east-1a might be a completely different physical zone than yours.</p><p>If you need the AZ name for your account, you can resolve it with a single EC2 API call:</p><pre>ec2 = boto3.client(&quot;ec2&quot;)<br>resp = ec2.describe_availability_zones(<br>    Filters=[{&quot;Name&quot;: &quot;zone-id&quot;, &quot;Values&quot;: [&quot;use1-az1&quot;]}]<br>)<br>az_name = resp[&quot;AvailabilityZones&quot;][0][&quot;ZoneName&quot;]  # &quot;us-east-1a&quot;</pre><p>Cache this — it does not change.</p><h3>The Demo App</h3><p>I built a single Lambda function with three API routes using <a href="https://docs.powertools.aws.dev/lambda/python/latest/core/event_handler/api_gateway/">Powertools Event Handler</a>, deployed with SAM. Each route demonstrates a different use case for the new AZ metadata.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*L4k5F3JZTvps48Eb.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WdQiDld4KeXk4pN6kwJshA.png" /></figure><h3>The Foundation: AZ-Enriched Observability</h3><p>Before getting to the three routes, the most important pattern is in the module initialization and handler. I resolve the AZ ID once during cold start and set it as a default metric dimension:</p><pre>from aws_lambda_powertools import Logger, Metrics, Tracer<br>from aws_lambda_powertools.event_handler import APIGatewayRestResolver<br><br>logger = Logger()<br>tracer = Tracer()<br>metrics = Metrics()<br>app = APIGatewayRestResolver()<br># Resolve AZ ID at module level - metadata endpoint is localhost, sub-millisecond<br>AZ_ID = _init_az_id()<br># Every custom metric gets the AZ dimension automatically<br>if AZ_ID != &quot;unknown&quot;:<br>    metrics.set_default_dimensions(AvailabilityZoneID=AZ_ID)</pre><p>Then in the handler, following the <a href="https://darryl-ruggles.cloud/powertools-for-aws-lambda-best-practices-by-default/">Powertools three-decorator pattern</a>, I inject the AZ ID into every log line and X-Ray trace:</p><pre>@logger.inject_lambda_context(log_event=True)<br>@tracer.capture_lambda_handler<br>@metrics.log_metrics(capture_cold_start_metric=True)<br>def lambda_handler(event: dict, context: LambdaContext) -&gt; dict:<br>    logger.append_keys(availability_zone_id=AZ_ID)<br>    tracer.put_annotation(&quot;availability_zone_id&quot;, AZ_ID)<br>    return app.resolve(event, context)</pre><p>Now every log line from this function includes the AZ:</p><pre>{<br>    &quot;level&quot;: &quot;INFO&quot;,<br>    &quot;message&quot;: &quot;Routing decision&quot;,<br>    &quot;availability_zone_id&quot;: &quot;use1-az4&quot;,<br>    &quot;endpoint&quot;: &quot;cache-az4.internal:6379&quot;,<br>    &quot;same_az&quot;: true,<br>    &quot;service&quot;: &quot;lambda-metadata-explorer&quot;<br>}</pre><p>During an AZ incident, you can filter to the affected zone instantly in CloudWatch Logs Insights:</p><pre>fields @timestamp, availability_zone_id, message, level<br>| filter availability_zone_id = &quot;use1-az4&quot;<br>| filter level = &quot;ERROR&quot;<br>| sort @timestamp desc</pre><p>The tracer.put_annotation call adds the AZ ID as an X-Ray annotation on every trace. You can filter traces by AZ in the X-Ray console using:</p><pre>annotation.availability_zone_id = &quot;use1-az4&quot;</pre><p>This lets you isolate all traces from a specific AZ during an incident. Click any trace segment and check the Annotations tab to see the AZ ID.</p><p>This alone is worth the feature. Before March 19, you simply could not do this with Lambda.</p><h3>Use Case 1: Exploring the Metadata (GET /metadata)</h3><p>The first route is straightforward — it returns the AZ metadata from both the Powertools utility and the raw HTTP endpoint, plus the resolved AZ name. This is mostly educational, showing developers both access methods side by side:</p><pre>@app.get(&quot;/metadata&quot;)<br>@tracer.capture_method<br>def explore_metadata():<br>    az_name = _resolve_az_name()  # EC2 API call, cached after first use<br>    metrics.add_metric(name=&quot;MetadataRequests&quot;, unit=MetricUnit.Count, value=1)<br><br>return {<br>        &quot;availability_zone&quot;: {<br>            &quot;az_id&quot;: AZ_ID,<br>            &quot;az_name&quot;: az_name,<br>        },<br>        &quot;execution_context&quot;: {<br>            &quot;function_name&quot;: os.environ.get(&quot;AWS_LAMBDA_FUNCTION_NAME&quot;, &quot;local&quot;),<br>            &quot;region&quot;: os.environ.get(&quot;AWS_REGION&quot;, &quot;unknown&quot;),<br>            &quot;memory_mb&quot;: os.environ.get(&quot;AWS_LAMBDA_FUNCTION_MEMORY_SIZE&quot;, &quot;unknown&quot;),<br>        },<br>    }</pre><h3>Seeing the Multi-AZ Distribution</h3><p>There’s a subtlety here. If you call the endpoint 20 times sequentially, you’ll likely see the same AZ every time. That’s because Lambda reuses warm execution environments, and a warm instance stays in the same AZ for its lifetime.</p><p>To actually see Lambda’s multi-AZ distribution, you need to force it to spin up multiple execution environments simultaneously. The included test script fires 60 concurrent requests using xargs -P, which overwhelms a single warm instance and forces Lambda to create new environments across different AZs:</p><pre># 60 total requests, 20 concurrent at a time<br>./scripts/test_az_distribution.sh $API_URL 20 60<br><br>--- AZ Distribution ---<br>Count  AZ ID          AZ Name<br>-----  -------------- -------<br>22     use1-az4       us-east-1d<br>20     use1-az1       us-east-1a<br>18     use1-az6       us-east-1f<br>Unique AZs observed: 3<br>Total responses:     60</pre><p>Lambda spreads execution environments across multiple AZs for resilience. Now you can actually see it happening, and with the AZ-enriched CloudWatch dashboard, you can monitor this distribution in real time.</p><p>This is also why the chaos engineering use case (Use Case 3) needs concurrent traffic. If you only have one warm instance, targeting a specific AZ is hit-or-miss. With concurrent load, you’ll have instances spread across AZs, and the ones in the target AZ will return 503 while the others continue serving normally — exactly how a real AZ outage would behave.</p><h3>Use Case 2: Same-AZ Routing (POST /route)</h3><p>This is the use case with the most immediate dollar impact. Cross-AZ data transfer costs $0.01/GB in each direction. If you have a Lambda function making thousands of calls per second to an ElastiCache cluster or RDS read replica, and it’s routing to whatever endpoint it gets, you’re paying for cross-AZ traffic on roughly two-thirds of those calls (assuming 3 AZs).</p><p>With the AZ metadata, you can route to the same-AZ endpoint:</p><pre>@app.post(&quot;/route&quot;)<br>@tracer.capture_method<br>def route_request():<br>    body = app.current_event.json_body or {}<br><br>    # In production, load from SSM Parameter Store or service discovery<br>    endpoint_map = json.loads(os.environ.get(&quot;AZ_ENDPOINT_MAP&quot;, &quot;{}&quot;)) or {<br>        &quot;use1-az1&quot;: &quot;cache-az1.internal:6379&quot;,<br>        &quot;use1-az2&quot;: &quot;cache-az2.internal:6379&quot;,<br>        &quot;use1-az4&quot;: &quot;cache-az4.internal:6379&quot;,<br>        &quot;use1-az6&quot;: &quot;cache-az6.internal:6379&quot;,<br>    }<br>    fallback = os.environ.get(&quot;FALLBACK_ENDPOINT&quot;, &quot;cache-primary.internal:6379&quot;)<br>    selected = endpoint_map.get(AZ_ID, fallback)<br>    same_az = AZ_ID in endpoint_map<br>    metrics.add_metric(name=&quot;RoutingDecisions&quot;, unit=MetricUnit.Count, value=1)<br>    tracer.put_annotation(&quot;routing_type&quot;, &quot;same-az&quot; if same_az else &quot;cross-az&quot;)<br>    return {<br>        &quot;az_id&quot;: AZ_ID,<br>        &quot;routing&quot;: {&quot;selected_endpoint&quot;: selected, &quot;same_az&quot;: same_az},<br>        &quot;cost_insight&quot;: {<br>            &quot;cross_az_cost_per_gb&quot;: &quot;$0.01 per direction ($0.02 round-trip)&quot;,<br>            &quot;same_az_cost_per_gb&quot;: &quot;$0.00&quot;,<br>        },<br>    }</pre><p>The endpoint map is a simple AZ-ID-to-endpoint mapping. In production, you’d load this from SSM Parameter Store using the <a href="https://docs.powertools.aws.dev/lambda/python/latest/utilities/parameters/">Powertools Parameters</a> utility with caching:</p><pre><br>from aws_lambda_powertools.utilities import parameters<br>endpoint_map = parameters.get_parameter(&quot;/myapp/az-endpoint-map&quot;, transform=&quot;json&quot;)</pre><p>For an ElastiCache cluster, you’d use the node endpoints for each AZ. For RDS with read replicas, you’d map to the replica in each AZ. The key insight is that you now know which AZ you’re in and can make an informed routing decision.</p><p><strong>Cost math:</strong> At 1 TB/month of data transfer with 3 AZs, roughly 67% crosses AZ boundaries. AWS charges $0.01/GB in each direction, so cross-AZ round-trips cost $0.02/GB effectively. That’s ~$13.40/month in cross-AZ charges for this example. With same-AZ routing, it drops to near zero. The savings scale linearly with traffic volume.</p><h3>Use Case 3: AZ-Aware Chaos Engineering (POST /chaos)</h3><p>The third route is about resilience testing. You specify a target AZ, and if the function happens to be running there, it injects a failure. This lets you test how your upstream systems — load balancers, retry logic, circuit breakers — handle an AZ going unhealthy.</p><pre>@app.post(&quot;/chaos&quot;)<br>@tracer.capture_method<br>def chaos_test():<br>    body = app.current_event.json_body or {}<br>    target_az = body.get(&quot;target_az&quot;, &quot;&quot;)<br>    failure_mode = body.get(&quot;failure_mode&quot;, &quot;error&quot;)<br><br>    if target_az and AZ_ID == target_az:<br>        metrics.add_metric(name=&quot;ChaosFailuresInjected&quot;, unit=MetricUnit.Count, value=1)<br>        logger.warning(&quot;Chaos: injecting failure&quot;, extra={<br>            &quot;target_az&quot;: target_az,<br>            &quot;failure_mode&quot;: failure_mode,<br>        })<br>        if failure_mode == &quot;latency&quot;:<br>            time.sleep(min(body.get(&quot;delay_seconds&quot;, 3), 10))<br>            return {&quot;az_id&quot;: AZ_ID, &quot;chaos&quot;: {&quot;triggered&quot;: True, &quot;mode&quot;: &quot;latency&quot;}}<br>        raise ServiceError(503, f&quot;Simulated AZ failure in {AZ_ID}&quot;)<br>    return {&quot;az_id&quot;: AZ_ID, &quot;chaos&quot;: {&quot;triggered&quot;: False, &quot;target_az&quot;: target_az}}</pre><p>Two failure modes:</p><ul><li><strong>error</strong> - Returns HTTP 503. Tests whether callers retry and eventually hit a healthy AZ.</li><li><strong>latency</strong> - Injects a delay (capped at 10 seconds). Tests whether callers have appropriate timeouts and circuit breakers.</li></ul><p>In a real project, you’d combine this with an ALB or API Gateway with health checks. Target a specific AZ and observe whether traffic shifts to healthy AZs. The CloudWatch dashboard shows failures concentrated in the target AZ while other AZs continue serving normally — exactly what you’d see during a real AZ impairment.</p><p>One important detail for testing: make test-chaos auto-detects your current warm instance&#39;s AZ and targets it, guaranteeing a triggered failure. But for a more realistic simulation, fire concurrent traffic while targeting a specific AZ - some requests will fail (those in the target AZ) while others succeed (those in healthy AZs).</p><p>Before this feature, you’d need to deploy different code per AZ or use external feature flags combined with some mechanism to map invocations to AZs. Now it’s a simple string comparison.</p><h3>The SAM Template</h3><p>The deployment is a straightforward SAM template — one function, one API, one CloudWatch dashboard:</p><pre>Globals:<br>  Function:<br>    Runtime: python3.13<br>    Architectures:<br>      - arm64<br>    Timeout: 30<br>    MemorySize: 256<br>    Tracing: Active<br>    LoggingConfig:<br>      LogFormat: JSON<br><br>  Resources:<br>    AZMetadataFunction:<br>      Type: AWS::Serverless::Function<br>      Properties:<br>        FunctionName: !Sub lambda-metadata-explorer-${Environment}<br>        CodeUri: src/<br>        Handler: app.lambda_handler<br>        Layers:<br>          - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPythonV3-python313-arm64:${PowertoolsLayerVersion}<br>        Environment:<br>          Variables:<br>            POWERTOOLS_SERVICE_NAME: lambda-metadata-explorer<br>            POWERTOOLS_METRICS_NAMESPACE: AZMetadataExplorer<br>            POWERTOOLS_LOG_LEVEL: !Ref LogLevel<br>            POWERTOOLS_TRACER_CAPTURE_RESPONSE: &#39;true&#39;<br>            POWERTOOLS_TRACER_CAPTURE_ERROR: &#39;true&#39;<br>        Policies:<br>          - Statement:<br>              - Effect: Allow<br>                Action:<br>                  - ec2:DescribeAvailabilityZones<br>                Resource: &#39;*&#39;<br>        Events:<br>          GetMetadata:<br>            Type: Api<br>            Properties:<br>              Path: /metadata<br>              Method: get<br>          PostRoute:<br>            Type: Api<br>            Properties:<br>              Path: /route<br>              Method: post<br>          PostChaos:<br>            Type: Api<br>            Properties:<br>              Path: /chaos<br>              Method: post</pre><p>ARM64 (Graviton) for better price-performance. Powertools via the AWS-managed Lambda Layer (plus pip bundling from requirements.txt to ensure v3.26.0+ with the metadata utility is available even if the Layer hasn&#39;t been updated yet). X-Ray tracing active. JSON log format. The only extra IAM permission is ec2:DescribeAvailabilityZones for the AZ name resolution - the metadata endpoint itself requires no permissions.</p><p>The template also creates a CloudWatch dashboard with AZ distribution widgets. The AZ dimension comes from metrics.set_default_dimensions(AvailabilityZoneID=AZ_ID) which tags every custom metric with the availability zone. The dashboard references explicit metric definitions for each AZ:</p><pre>[&quot;AZMetadataExplorer&quot;, &quot;MetadataRequests&quot;, &quot;AvailabilityZoneID&quot;, &quot;use1-az1&quot;, &quot;service&quot;, &quot;lambda-metadata-explorer&quot;]</pre><p>One gotcha: the ColdStart metric uses Powertools’ built-in dimensions (function_name + service) rather than our custom AZ dimension, because Powertools captures it before the handler runs. So the Cold Starts widget shows a total count rather than a per-AZ breakdown.</p><h3>Deploy and Try It</h3><p><strong>Prerequisites:</strong> AWS CLI, SAM CLI, Python 3.13+, an AWS account.</p><pre>git clone https://github.com/RDarrylR/lambda-az-metadata.git<br>cd lambda-az-metadata<br><br># Build and deploy<br>sam build --profile your-profile --region us-east-1<br>sam deploy --guided --profile your-profile --region us-east-1<br># Or use the Makefile<br>make deploy PROFILE=your-profile</pre><p>After deploying, grab the API URL from the stack outputs and explore:</p><pre># See your AZ metadata<br>curl -s $API_URL/metadata | python3 -m json.tool<br><br># Test same-AZ routing<br>curl -s -X POST $API_URL/route \<br>  -H &quot;Content-Type: application/json&quot; \<br>  -d &#39;{&quot;message&quot;: &quot;hello&quot;}&#39; | python3 -m json.tool<br># Test chaos engineering (replace use1-az1 with an AZ from your /metadata response)<br>curl -s -X POST $API_URL/chaos \<br>  -H &quot;Content-Type: application/json&quot; \<br>  -d &#39;{&quot;target_az&quot;: &quot;use1-az1&quot;, &quot;failure_mode&quot;: &quot;error&quot;}&#39; | python3 -m json.tool<br># See the AZ distribution across 60 concurrent invocations<br>make test-distribution</pre><p>Cleanup:</p><pre>sam delete --stack-name lambda-az-metadata --profile your-profile --region us-east-1</pre><h3>What It Costs</h3><p>This demo runs well within the AWS free tier. Lambda gives you 1M requests/month and 400K GB-seconds. API Gateway gives you 1M calls for the first 12 months. The metadata endpoint itself is free — no additional charges.</p><h3>Gotchas</h3><p>A few things to watch for:</p><ul><li><strong>Region-specific AZ IDs.</strong> The demo code and dashboard use us-east-1 AZ IDs (use1-az1 through use1-az6). If you deploy to a different region, update the default endpoint map in app.py and the dashboard metrics in template.yaml with your region&#39;s AZ IDs (e.g., usw2-az1 for us-west-2).</li><li><strong>Caching is your friend.</strong> The metadata response is immutable within an execution environment. Resolve it once at module level and forget about it. There is no reason to call the endpoint on every invocation.</li><li><strong>VPC doesn’t matter.</strong> The metadata endpoint is localhost inside the execution environment — it works identically with or without a VPC. No VPC endpoints, NAT gateways, or security group rules needed.</li><li><strong>SnapStart changes things.</strong> If you use SnapStart, the execution environment might move to a different AZ after restore. The metadata endpoint cooperates with SnapStart by reducing the cache TTL during initialization, so Powertools can detect the need to re-fetch after restore and handles this automatically. But this demo resolves AZ_ID at module level during init - that code runs during the snapshot phase. After restore, the cached module-level variable could be wrong. For SnapStart, move the AZ resolution into the handler or use a @register_checkpoint_restore hook to re-resolve after restore.</li><li><strong>The response may grow.</strong> The documentation says additional fields may be added in future updates. Don’t fail on unknown fields.</li><li><strong>log_event=True and </strong><strong>CAPTURE_RESPONSE in production.</strong> I use both here for debugging visibility, but in production, disable them if your payloads contain sensitive data. log_event=True logs full request bodies to CloudWatch, and POWERTOOLS_TRACER_CAPTURE_RESPONSE stores full response bodies as X-Ray metadata. Control both via environment variables without code changes.</li><li><strong>Layer version.</strong> The get_lambda_metadata() utility requires Powertools v3.26.0+ (Lambda Layer version 30+, released March 20, 2026). The SAM template defaults to version 30. Check the <a href="https://docs.aws.amazon.com/powertools/python/latest/getting-started/install/">layer version history</a> for the latest.</li><li><strong>Import path.</strong> As of v3.26.0, the working import path is from aws_lambda_powertools.utilities.metadata import get_lambda_metadata. The <a href="https://docs.aws.amazon.com/lambda/latest/dg/configuration-metadata-endpoint.html">Lambda documentation</a> currently shows utilities.lambda_metadata, which may be corrected in a future docs update. Verify against your installed version if in doubt.</li></ul><h3>What’s Next</h3><p>The metadata endpoint currently returns only AvailabilityZoneID, but the versioned API path (/2026-01-15/) and the documentation&#39;s note about future fields suggest more is coming. Execution environment ID? Host-level metadata? We&#39;ll see.</p><p>For now, the AZ ID alone unlocks meaningful improvements in observability, cost optimization, and resilience testing. It’s one of those features that’s so obviously useful, you wonder why it took this long.</p><p>The code is on <a href="https://github.com/RDarrylR/lambda-az-metadata">GitHub</a>. Clone it, deploy it, and check your CloudWatch dashboard to see which AZs your Lambda functions are actually running in.</p><h3>Resources</h3><ul><li><a href="https://aws.amazon.com/about-aws/whats-new/2026/03/lambda-availability-zone-metadata/">AWS Announcement: Lambda Availability Zone Metadata</a></li><li><a href="https://docs.aws.amazon.com/lambda/latest/dg/configuration-metadata-endpoint.html">Lambda Metadata Endpoint Documentation</a></li><li><a href="https://docs.powertools.aws.dev/lambda/python/latest/">Powertools for AWS Lambda (Python)</a></li><li><a href="https://docs.aws.amazon.com/powertools/python/latest/utilities/metadata/">Powertools Lambda Metadata Utility</a></li><li><a href="https://docs.aws.amazon.com/powertools/python/latest/getting-started/install/">Powertools Layer Versions</a></li><li><a href="https://docs.aws.amazon.com/whitepapers/latest/advanced-multi-az-resilience-patterns/">Advanced Multi-AZ Resilience Patterns (Whitepaper)</a></li><li><a href="https://aws.amazon.com/blogs/compute/aws-lambda-resilience-under-the-hood/">AWS Lambda Resilience Under the Hood</a></li></ul><p><em>Connect with me on</em> <a href="https://x.com/RDarrylR">X</a><em>,</em> <a href="https://bsky.app/profile/darrylruggles.bsky.social">Bluesky</a><em>,</em> <a href="https://www.linkedin.com/in/darryl-ruggles/">LinkedIn</a><em>,</em> <a href="https://github.com/RDarrylR">GitHub</a><em>,</em> <a href="https://medium.com/@RDarrylR">Medium</a><em>,</em> <a href="https://dev.to/rdarrylr">Dev.to</a><em>, or the</em> <a href="https://community.aws/@darrylr">AWS Community</a><em>. Check out more of my projects at</em> <a href="https://darryl-ruggles.cloud/">darryl-ruggles.cloud</a> <em>and join the</em> <a href="https://www.believeinserverless.com/">Believe In Serverless</a> <em>community.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9ab675c9d05a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[A Complete Terraform Setup for EKS Auto Mode: Is it right for you?]]></title>
            <link>https://medium.com/@RDarrylR/a-complete-terraform-setup-for-eks-auto-mode-is-it-right-for-you-7bd380128040?source=rss-7ecf33fd997------2</link>
            <guid isPermaLink="false">https://medium.com/p/7bd380128040</guid>
            <category><![CDATA[terraform]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[aws-eks]]></category>
            <dc:creator><![CDATA[Darryl Ruggles]]></dc:creator>
            <pubDate>Tue, 24 Mar 2026 16:26:03 GMT</pubDate>
            <atom:updated>2026-03-24T16:28:34.541Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lVa7seSJAV2FFI5Rch5x9Q.png" /></figure><p>One of the things I appreciate about AWS is the range of container options available. I recently wrote about why <a href="https://darryl-ruggles.cloud/elastic-container-service-ecs-my-default-choice-for-containers-on-aws/">ECS is my default choice for containers on AWS</a> — it’s simple, powerful, and handles most workloads well. But when you need Kubernetes specifically — for portability, ecosystem tooling, or team expertise — EKS Auto Mode is an interesting approach worth exploring.</p><p>Instead of managing node groups, installing Karpenter, configuring the VPC CNI plugin, deploying the AWS Load Balancer Controller, setting up the EBS CSI driver, and keeping all of those components updated and compatible with each other — you enable a single flag and AWS handles all of it. The data plane becomes a managed service. The limitations that Auto Mode has are not insignificant but depending on the use case it may make sense for many teams to consider using Auto Mode.</p><p>I built a complete production-ready EKS Auto Mode cluster with Terraform to see how it works in practice. All the code is on GitHub: <a href="https://github.com/RDarrylR/eks-auto-mode-terraform">github.com/RDarrylR/eks-auto-mode-terraform</a>.</p><p>This post walks through everything: what Auto Mode actually manages, how the Terraform setup works, deploying a real application, autoscaling from pods to nodes, observability with Container Insights, the security posture you get out of the box, custom NodePools for cost optimization, and the honest trade-offs you should know about before adopting it.</p><h3>Why EKS Auto Mode?</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*8uk3mNfDRGiGUQN1.png" /></figure><p>If you’ve ever managed an EKS cluster, you know the operational burden. The control plane is managed by AWS, but everything else is on you:</p><ul><li><strong>Node management</strong> — Choosing instance types, managing AMIs, patching OS, handling node drains and replacements</li><li><strong>Networking</strong> — Installing and configuring the VPC CNI plugin, managing pod IP allocation, setting up network policies</li><li><strong>Storage</strong> — Deploying the EBS CSI driver, managing StorageClasses, handling volume lifecycle</li><li><strong>Load balancing</strong> — Installing the AWS Load Balancer Controller, configuring IngressClasses, managing target group bindings</li><li><strong>Scaling</strong> — Deploying Karpenter or Cluster Autoscaler, configuring NodePools, tuning consolidation policies</li><li><strong>Add-on compatibility</strong> — Making sure all these components work together after every Kubernetes version upgrade</li></ul><p>Multiply this across multiple clusters and environments, and you are spending more time on infrastructure plumbing than on the applications running on top of it.</p><p>EKS Auto Mode eliminates all of this. AWS takes over the entire data plane — compute, networking, storage, load balancing, and health monitoring. The only pods running in your cluster are the Kubernetes Metrics Server. Everything else runs off-cluster as managed services.</p><h3>What AWS Manages For You</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*hLAkorVLhWO9dm6b.png" /></figure><p>When you enable Auto Mode, AWS takes responsibility for six major areas:</p><h3>Compute</h3><p>Auto Mode uses Karpenter under the hood, running it as a managed service outside your cluster. You never see Karpenter pods, never upgrade them, never troubleshoot their OOM kills. Karpenter is a significant upgrade over the standard Cluster Autoscaler in my experience. With Auto Mode, AWS handles:</p><ul><li>Node provisioning, scaling, and termination</li><li>AMI selection — custom Bottlerocket variants with SELinux enforcing and read-only root filesystem</li><li>OS patching via a continuous deployment pipeline with CVE scanning and staged rollout</li><li>14-day default node expiration (configurable up to 21 days) with automatic replacement</li><li>Spot Instance interruption handling</li><li>GPU support with drivers for NVIDIA, Neuron (Trainium/Inferentia), and EFA</li></ul><p>The nodes are <strong>EC2 managed instances</strong> — a new EC2 primitive where operational control is delegated to AWS. They appear in your account, use your Reserved Instances and Savings Plans, but you cannot SSH into them or modify their configuration directly.</p><h3>Networking</h3><p>The VPC CNI runs as a managed component:</p><ul><li>Pod networking with IPv4 and IPv6 support</li><li>Network Policy enforcement</li><li>Node-local DNS — CoreDNS runs as a system service directly on each node rather than as a traditional cluster deployment. This eliminates cross-node DNS latency and removes CoreDNS pods from your workload scheduling</li><li>Pod Identity Agent (built-in, no add-on installation)</li></ul><h3>Storage</h3><p>The EBS CSI driver is managed with provisioner ebs.csi.eks.amazonaws.com:</p><ul><li>Persistent volume provisioning with configurable IOPS, throughput, and encryption</li><li>Ephemeral storage configuration</li><li>Automatic NVMe setup for supported instance types</li></ul><h3>Load Balancing</h3><p>The AWS Load Balancer Controller runs as a managed service:</p><ul><li>ALB and NLB provisioning from Kubernetes Ingress/Service resources</li><li>Uses IngressClass alb (controller: eks.amazonaws.com/alb) and loadBalancerClass eks.amazonaws.com/nlb</li><li>Supports Ingress API and Service annotations only</li><li><strong>Gateway API is not supported.</strong> The managed ALB controller only supports the Ingress API and Service annotations. This could be a deal breaker for teams adopting the Gateway API.</li></ul><h3>Health Monitoring</h3><ul><li>Automatic detection and repair of unhealthy nodes</li><li>Replaces unresponsive kubelets and PID-exhausted nodes</li><li>Reports through Kubernetes events and conditions</li></ul><h3>The Terraform Setup</h3><p>I used the terraform-aws-modules/eks/aws community module (v21.x) with the AWS provider ~&gt; 6.0. There is no separate official AWS Terraform module for EKS Auto Mode - the community module maintained by Anton Babenko is the de facto standard for EKS on Terraform, and it has first-class Auto Mode support. When you set compute_config.enabled = true, the module automatically handles the storage and load balancing configuration, hardcodes the right bootstrap settings, creates least-privilege IAM roles, and attaches the five managed policies the cluster role needs. You could build this with raw aws_eks_cluster resources, but the module eliminates several hundred lines of boilerplate and is referenced in AWS&#39;s own documentation.</p><p>The complete setup creates a production-ready cluster with VPC, Auto Mode enabled, custom NodePools, Container Insights observability, and least-privilege IAM configuration. Here is what the full infrastructure looks like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Y7KiSiJHwM5pWvYl.png" /></figure><h3>Provider Configuration</h3><pre>terraform {<br>  required_version = &quot;&gt;= 1.10&quot;<br><br>  required_providers {<br>      aws = {<br>        source  = &quot;hashicorp/aws&quot;<br>        version = &quot;~&gt; 6.0&quot;<br>      }<br>      kubernetes = {<br>        source  = &quot;hashicorp/kubernetes&quot;<br>        version = &quot;~&gt; 2.38&quot;<br>      }<br>      kubectl = {<br>        source  = &quot;alekc/kubectl&quot;<br>        version = &quot;~&gt; 2.1&quot;<br>      }<br>    }<br>  }<br>  provider &quot;aws&quot; {<br>    region = var.aws_region<br>    default_tags {<br>      tags = {<br>        Project     = var.project_name<br>        Environment = var.environment<br>        ManagedBy   = &quot;terraform&quot;<br>      }<br>    }<br>  }<br>}</pre><p>A note on the kubectl provider: I use the alekc/kubectl fork rather than gavinbunney/kubectl. The alekc fork is actively maintained and handles CRD resources (like Karpenter NodePools and NodeClasses) reliably.</p><h3>VPC</h3><p>The VPC uses the community VPC module (v6.x) with both private and public subnets. The key detail is the subnet tags — EKS Auto Mode uses these to discover where to place nodes and load balancers:</p><pre>module &quot;vpc&quot; {<br>  source  = &quot;terraform-aws-modules/vpc/aws&quot;<br>  version = &quot;~&gt; 6.0&quot;<br>  name = &quot;${var.project_name}-vpc&quot;<br>    cidr = &quot;10.0.0.0/16&quot;<br>    azs             = slice(data.aws_availability_zones.available.names, 0, 3)<br>    private_subnets = [&quot;10.0.1.0/24&quot;, &quot;10.0.2.0/24&quot;, &quot;10.0.3.0/24&quot;]<br>    public_subnets  = [&quot;10.0.101.0/24&quot;, &quot;10.0.102.0/24&quot;, &quot;10.0.103.0/24&quot;]<br>    enable_nat_gateway   = true<br>    single_nat_gateway   = true<br>    enable_dns_hostnames = true<br>    public_subnet_tags = {<br>      &quot;kubernetes.io/role/elb&quot; = 1<br>    }<br>    private_subnet_tags = {<br>      &quot;kubernetes.io/role/internal-elb&quot; = 1<br>    }<br>}</pre><p>A few things worth noting:</p><ul><li><strong>Single NAT gateway</strong> for cost optimization in non-production. Use one per AZ for production HA.</li><li><strong>No VPC endpoints</strong> in this demo for simplicity. Production setups should add VPC endpoints for ECR (both ecr.api and ecr.dkr), S3, STS, and CloudWatch to reduce NAT gateway costs and keep traffic private. This is especially impactful at scale - NAT gateway data processing charges add up quickly.</li><li><strong>Public subnet tags</strong> with kubernetes.io/role/elb tell the managed load balancer controller where to create internet-facing ALBs/NLBs.</li><li><strong>Private subnet tags</strong> with kubernetes.io/role/internal-elb are for internal load balancers.</li><li>Nodes always run in private subnets. The load balancers sit in public subnets.</li></ul><h3>EKS Cluster with Auto Mode</h3><p>This is where Auto Mode gets enabled:</p><pre>module &quot;eks&quot; {<br>  source  = &quot;terraform-aws-modules/eks/aws&quot;<br>  version = &quot;~&gt; 21.0&quot;<br>  name               = var.project_name<br>  kubernetes_version = &quot;1.35&quot;<br>  endpoint_public_access = true<br>  # UNCOMMENT for production: restrict to your IP ranges or use private-only access<br>  # cluster_endpoint_public_access_cidrs = [&quot;YOUR_CIDR/32&quot;]<br>  # Enable Auto Mode - this single block enables:<br>  #   - Managed Karpenter (compute)<br>  #   - Managed EBS CSI driver (storage_config derived from compute_config.enabled)<br>  #   - Managed ALB/NLB Controller (elastic_load_balancing derived from compute_config.enabled)<br>  #   - Managed VPC CNI, kube-proxy, CoreDNS (bootstrap_self_managed_addons hardcoded false)<br>  compute_config = {<br>    enabled    = true<br>    node_pools = [&quot;general-purpose&quot;, &quot;system&quot;]<br>  }<br>  vpc_id     = module.vpc.vpc_id<br>  subnet_ids = module.vpc.private_subnets<br>  # Auto Mode IAM resources - creates the node IAM role with required policies<br>  create_node_iam_role           = true<br>  create_auto_mode_iam_resources = true<br>  # Cluster access - grants the deploying identity cluster admin<br>  enable_cluster_creator_admin_permissions = true<br>  # Addons - metrics-server for HPA + Container Insights for observability<br>  addons = {<br>    metrics-server = {<br>      most_recent = true<br>    }<br>    amazon-cloudwatch-observability = {<br>      most_recent = true<br>      pod_identity_association = [{<br>        role_arn        = aws_iam_role.cloudwatch.arn<br>        service_account = &quot;cloudwatch-agent&quot;<br>      }]<br>    }<br>  }<br>}</pre><p><strong>Production notes:</strong> This demo uses a public API endpoint without CIDR restrictions and local Terraform state. For production, restrict cluster_endpoint_public_access_cidrs to your corporate IP ranges (or use private-only access with a VPN/bastion), and configure a remote backend (S3 + DynamoDB) for state locking and team collaboration.</p><p>The critical parameters:</p><ul><li><strong>compute_config.enabled = true</strong> - This is the flag that enables Auto Mode. The module automatically derives storage_config (block storage) and kubernetes_network_config (elastic load balancing) from this single flag - you do not need to set them separately.</li><li><strong>compute_config.node_pools</strong> - The built-in pools to activate. general-purpose handles your application workloads (On-Demand, C/M/R families, gen 4+, AMD and ARM). system handles EKS internal add-ons with a CriticalAddonsOnly taint.</li><li><strong>bootstrap_self_managed_addons</strong> - The module hardcodes this to false when Auto Mode is enabled, so you do not need to set it. Auto Mode manages VPC CNI, kube-proxy, and CoreDNS off-cluster.</li><li><strong>create_auto_mode_iam_resources = true</strong> - Creates the node IAM role with AmazonEKSWorkerNodeMinimalPolicy and AmazonEC2ContainerRegistryPullOnly - the least-privilege policies AWS recommends for Auto Mode (not the broader AmazonEKSWorkerNodePolicy or AmazonEC2ContainerRegistryReadOnly).</li><li><strong>create_node_iam_role = true</strong> - Must be true (default) so the module creates the Auto Mode node role with nodeRoleArn. Setting this to false without providing a custom node_role_arn causes the EKS API to reject the cluster.</li><li><strong>addons</strong> - Deploys the metrics-server (required for HPA to read CPU/memory metrics) and CloudWatch Container Insights with Pod Identity for observability (more on this below).</li></ul><p>The module also automatically attaches five managed policies to the cluster IAM role: AmazonEKSClusterPolicy, AmazonEKSComputePolicy, AmazonEKSBlockStoragePolicy, AmazonEKSLoadBalancingPolicy, and AmazonEKSNetworkingPolicy. Plus a custom tags policy for Auto Mode resource tagging.</p><p><strong>Important Terraform caveat:</strong> When disabling Auto Mode later, you must first apply with compute_config.enabled = false before removing the block entirely. Removing it in one step causes issues.</p><h3>IAM Architecture</h3><p>The setup follows AWS recommended least-privilege principles with three distinct roles:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zI-L9ENdWRP7L_A4jOJuyA.png" /></figure><p>The node role uses AmazonEKSWorkerNodeMinimalPolicy instead of the broader AmazonEKSWorkerNodePolicy, and AmazonEC2ContainerRegistryPullOnly instead of AmazonEC2ContainerRegistryReadOnly. These are the least-privilege policies AWS specifically recommends for Auto Mode.</p><h3>Custom NodePool for Spot Instances</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*l5jzxYtYnux6wbnk.png" /></figure><p>The built-in general-purpose pool uses On-Demand instances only, with C, M, and R instance families (generation 4+, both AMD and ARM/Graviton). For cost optimization, you can create a custom NodePool that uses Spot:</p><pre>resource &quot;kubectl_manifest&quot; &quot;spot_nodeclass&quot; {<br>  yaml_body = yamlencode({<br>    apiVersion = &quot;eks.amazonaws.com/v1&quot;<br>    kind       = &quot;NodeClass&quot;<br>    metadata = {<br>      name = &quot;spot-class&quot;<br>    }<br>    spec = {<br>      role = module.eks.node_iam_role_name<br>      subnetSelectorTerms = [<br>        {<br>          tags = {<br>            &quot;kubernetes.io/role/internal-elb&quot; = &quot;1&quot;<br>          }<br>        }<br>      ]<br>      securityGroupSelectorTerms = [<br>        {<br>          tags = {<br>            &quot;aws:eks:cluster-name&quot; = var.project_name<br>          }<br>        }<br>      ]<br>    }<br>  })<br>depends_on = [module.eks]<br>}<br><br>resource &quot;kubectl_manifest&quot; &quot;spot_nodepool&quot; {<br>  yaml_body = yamlencode({<br>    apiVersion = &quot;karpenter.sh/v1&quot;<br>    kind       = &quot;NodePool&quot;<br>    metadata = {<br>      name = &quot;spot-compute&quot;<br>    }<br>    spec = {<br>      template = {<br>        metadata = {<br>          labels = {<br>            &quot;workload-type&quot; = &quot;spot-eligible&quot;<br>          }<br>        }<br>        spec = {<br>          nodeClassRef = {<br>            group = &quot;eks.amazonaws.com&quot;<br>            kind  = &quot;NodeClass&quot;<br>            name  = &quot;spot-class&quot;<br>          }<br>          requirements = [<br>            {<br>              key      = &quot;karpenter.sh/capacity-type&quot;<br>              operator = &quot;In&quot;<br>              values   = [&quot;spot&quot;]<br>            },<br>            {<br>              key      = &quot;eks.amazonaws.com/instance-category&quot;<br>              operator = &quot;In&quot;<br>              values   = [&quot;c&quot;, &quot;m&quot;, &quot;r&quot;]<br>            },<br>            {<br>              key      = &quot;eks.amazonaws.com/instance-generation&quot;<br>              operator = &quot;Gte&quot;<br>              values   = [&quot;5&quot;]<br>            }<br>          ]<br>          expireAfter = &quot;336h&quot;<br>        }<br>      }<br>      disruption = {<br>        consolidationPolicy = &quot;WhenEmptyOrUnderutilized&quot;<br>        consolidateAfter    = &quot;1m&quot;<br>        budgets = [<br>          { nodes = &quot;20%&quot; }<br>        ]<br>      }<br>      weight = 80<br>    }<br>  })<br>  depends_on = [<br>    module.eks,<br>    kubectl_manifest.spot_nodeclass,<br>  ]<br>}</pre><p>Key differences from self-managed Karpenter:</p><ul><li><strong>NodeClass uses </strong><strong>eks.amazonaws.com/v1</strong> instead of karpenter.k8s.aws/v1</li><li><strong>NodeClass requires </strong><strong>role</strong> - You must specify role (IAM role name) or instanceProfile. The EKS API rejects NodeClasses without one of these. Use module.eks.node_iam_role_name from the Terraform module</li><li><strong>Labels use </strong><strong>eks.amazonaws.com/*</strong> instead of karpenter.k8s.aws/*</li><li><strong>NodePool API</strong> is the same karpenter.sh/v1 in both cases</li><li><strong>consolidateAfter is required</strong> in the disruption block - omitting it causes a validation error</li><li>The <strong>weight</strong> field (80 for Spot vs the default pool) determines scheduling preference</li><li>Instance families (C/M/R) and generation (5+) are specified via requirements - Karpenter selects the specific instance size based on pod resource requests at scheduling time</li></ul><p>To direct workloads to Spot nodes, add a nodeSelector:</p><pre>spec:<br>  nodeSelector:<br>    workload-type: spot-eligible</pre><h3>Deploying a Real Application</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Prv6NKybTVbNyaV9.png" /></figure><p>To demonstrate the full workflow, I deployed a Python FastAPI application with an ALB, autoscaling, and a load generator for scaling demos.</p><h3>Application Code</h3><p>A demo API with four endpoints designed to showcase Auto Mode’s capabilities:</p><ul><li>/health - Health check for probes and ALB health checks</li><li>/info - Returns pod and node details including the instance type Karpenter selected, whether the node is Spot or On-Demand, which NodePool it belongs to, and the AZ. Uses the Kubernetes API with a read-only RBAC binding to query node labels</li><li>/compute/{iterations} - CPU-intensive endpoint for triggering HPA scaling</li><li>/stress/{mb}?seconds=30 - Allocates memory to demonstrate memory pressure. Holds the allocation then auto-releases</li></ul><p>The most interesting part is the /info endpoint, which queries the Kubernetes API to read node labels set by Karpenter:</p><pre>def _get_node_info():<br>    &quot;&quot;&quot;Fetch node labels via Kubernetes API to show Auto Mode scheduling decisions.&quot;&quot;&quot;<br>    from kubernetes import client, config<br>    config.load_incluster_config()<br>    v1 = client.CoreV1Api()<br>    node = v1.read_node(os.getenv(&quot;NODE_NAME&quot;))<br>    labels = node.metadata.labels or {}<br>    return {<br>        &quot;node_name&quot;: os.getenv(&quot;NODE_NAME&quot;),<br>        &quot;instance_type&quot;: labels.get(&quot;node.kubernetes.io/instance-type&quot;, &quot;unknown&quot;),<br>        &quot;capacity_type&quot;: labels.get(&quot;karpenter.sh/capacity-type&quot;, &quot;unknown&quot;),<br>        &quot;nodepool&quot;: labels.get(&quot;karpenter.sh/nodepool&quot;, &quot;unknown&quot;),<br>        &quot;zone&quot;: labels.get(&quot;topology.kubernetes.io/zone&quot;, &quot;unknown&quot;),<br>        &quot;arch&quot;: labels.get(&quot;kubernetes.io/arch&quot;, &quot;unknown&quot;),<br>        &quot;compute_type&quot;: labels.get(&quot;eks.amazonaws.com/compute-type&quot;, &quot;unknown&quot;),<br>    }</pre><p>The other endpoints: /compute/{iterations} does CPU-intensive work for HPA scaling demos, and /stress/{mb} allocates memory (auto-releases after a configurable hold period) for memory pressure demos. <a href="https://github.com/RDarrylR/eks-auto-mode-terraform/blob/main/app/main.py">Full app code on GitHub</a>.</p><p>Here is a real response from the running demo:</p><pre>{<br>  &quot;pod&quot;: {<br>    &quot;name&quot;: &quot;demo-api-847f8d6ff5-9gvsq&quot;,<br>    &quot;namespace&quot;: &quot;demo&quot;,<br>    &quot;ip&quot;: &quot;10.0.3.184&quot;,<br>    &quot;service_account&quot;: &quot;demo-api&quot;<br>  },<br>  &quot;node&quot;: {<br>    &quot;node_name&quot;: &quot;i-01e881b330651bbc6&quot;,<br>    &quot;instance_type&quot;: &quot;c7g.large&quot;,<br>    &quot;capacity_type&quot;: &quot;spot&quot;,<br>    &quot;nodepool&quot;: &quot;spot-compute&quot;,<br>    &quot;zone&quot;: &quot;us-east-1c&quot;,<br>    &quot;arch&quot;: &quot;arm64&quot;,<br>    &quot;compute_type&quot;: &quot;auto&quot;<br>  }<br>}</pre><p>Notice Karpenter selected a <strong>c7g.large</strong> — a Graviton (ARM) Spot instance. This is a key insight: Karpenter optimizes for cost, and Graviton Spot instances are often the cheapest available option. The built-in general-purpose pool includes both AMD and ARM architectures, and the custom Spot pool does too. Hit the endpoint multiple times and you may see responses from different pods on different nodes, potentially in different AZs.</p><h3>Building the Container Image</h3><p>The <a href="https://github.com/RDarrylR/eks-auto-mode-terraform/blob/main/app/Dockerfile">Dockerfile</a> is a standard Python 3.13-slim image running uvicorn.</p><p><strong>Important: Build for the right architecture.</strong> Auto Mode’s NodePools include ARM (Graviton) instances by default — Karpenter often selects them because they are the cheapest option. You need to target the right platform:</p><pre># Build for ARM (Graviton nodes - most common with Auto Mode Spot)<br>docker buildx build --platform linux/arm64 -t my-app:latest --load .<br><br># Or if your nodes are AMD64<br>docker buildx build --platform linux/amd64 -t my-app:latest --load .</pre><p>Using :latest tags with Auto Mode requires imagePullPolicy: Always in the deployment to ensure nodes pull the newest image on every pod creation.</p><h3>Kubernetes Manifests</h3><p>The key manifest design decisions (<a href="https://github.com/RDarrylR/eks-auto-mode-terraform/tree/main/k8s">full manifests on GitHub</a>):</p><p><strong>RBAC for /info</strong> — The pod needs a ServiceAccount with a ClusterRole to read node labels. This is a minimal read-only get on nodes:</p><pre>apiVersion: rbac.authorization.k8s.io/v1<br>kind: ClusterRole<br>metadata:<br>  name: node-reader<br>rules:<br>  - apiGroups: [&quot;&quot;]<br>    resources: [&quot;nodes&quot;]<br>    verbs: [&quot;get&quot;]</pre><p><strong>Downward API env vars</strong> — The deployment injects NODE_NAME, POD_NAME, POD_NAMESPACE, POD_IP, and POD_SERVICE_ACCOUNT via the Kubernetes downward API. The /info endpoint uses NODE_NAME to query the Kubernetes API for that node&#39;s labels:</p><pre>env:<br>  - name: NODE_NAME<br>    valueFrom:<br>      fieldRef:<br>        fieldPath: spec.nodeName</pre><p>Other important details:</p><ul><li><strong>imagePullPolicy: Always</strong> is required when using :latest tags, since nodes cache images</li><li><strong>IngressClass</strong> is alb (not eks.amazonaws.com/alb, since / is invalid in RFC 1123 subdomain names). You must explicitly create the IngressClassParams and IngressClass resources - Auto Mode does not auto-register them</li><li><strong>Resource requests</strong> are set on every container. This is critical with Auto Mode — Karpenter provisions nodes based on requested resources. If you don’t set requests, Auto Mode has no signal for right-sizing</li><li><strong>Health probes</strong> are defined for proper rolling updates and load balancer health checks</li></ul><h3>Horizontal Pod Autoscaler</h3><pre>apiVersion: autoscaling/v2<br>kind: HorizontalPodAutoscaler<br>metadata:<br>  name: demo-api<br>  namespace: demo<br>spec:<br>  scaleTargetRef:<br>    apiVersion: apps/v1<br>    kind: Deployment<br>    name: demo-api<br>  minReplicas: 2<br>  maxReplicas: 10<br>  behavior:<br>    scaleDown:<br>      stabilizationWindowSeconds: 120<br>  metrics:<br>    - type: Resource<br>      resource:<br>        name: cpu<br>        target:<br>          type: Utilization<br>          averageUtilization: 60<br>    - type: Resource<br>      resource:<br>        name: memory<br>        target:<br>          type: Utilization<br>          averageUtilization: 70</pre><p>The HPA scales on both CPU and memory — whichever metric exceeds its threshold first triggers a scale-up. The /compute endpoint drives CPU scaling, while the /stress endpoint drives memory scaling. Both trigger the same pod-to-node scaling chain through Auto Mode.</p><p>The stabilizationWindowSeconds: 120 shortens the default 5-minute stabilization window for scale-down, allowing the HPA to reduce replicas faster after load drops - useful for demos and workloads where you want quicker consolidation.</p><p><strong>Important:</strong> The HPA requires the Kubernetes Metrics Server to read CPU/memory utilization. The metrics-server is deployed as an EKS addon in the Terraform configuration (see the EKS cluster configuration above). Without it, HPA reports cpu: &lt;unknown&gt; and never scales.</p><h3>PodDisruptionBudget</h3><p>Since Auto Mode consolidation is enabled by default, a PDB protects your application during node replacement:</p><pre>apiVersion: policy/v1<br>kind: PodDisruptionBudget<br>metadata:<br>  name: demo-api<br>  namespace: demo<br>spec:<br>  minAvailable: 1<br>  selector:<br>    matchLabels:<br>      app: demo-api</pre><p>This ensures at least one pod is always available when Auto Mode consolidates or rotates nodes. Without a PDB, Karpenter could drain all pods from a node simultaneously during consolidation.</p><p>When the HPA scales pods up and existing nodes can’t fit them, Auto Mode’s managed Karpenter automatically provisions new nodes. When pods scale down and nodes become underutilized, consolidation kicks in and nodes are terminated. You never interact with the scaling infrastructure directly.</p><h3>Autoscaling in Action</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*I4QlU9SD4IMRCw_3.png" /></figure><p>One of the most compelling features of Auto Mode is how seamlessly pod-level and node-level scaling work together. The repo includes everything needed to demonstrate this end-to-end.</p><h3>Manual Scaling Demo</h3><p>The quickest way to see Auto Mode’s node provisioning is to scale the deployment beyond what existing nodes can handle:</p><pre># Scale to 15 replicas - watch Auto Mode provision new nodes<br>kubectl scale deployment demo-api -n demo --replicas=15<br><br># Watch the scaling in real time<br>kubectl get pods -n demo -o wide -w    # Pods scheduling across nodes<br>kubectl get nodes -w                    # New nodes appearing<br>kubectl get events -A --sort-by=&#39;.lastTimestamp&#39; | grep -E &#39;Nominated|Launched&#39;<br><br># Scale back down - watch consolidation<br>kubectl scale deployment demo-api -n demo --replicas=2</pre><p>When you scale to 15 replicas, each requesting 256m CPU and 512Mi memory, the existing nodes cannot fit all the pods. Auto Mode’s managed Karpenter evaluates the pending pods, selects optimal instance types from the C/M/R families (generation 4+, including Graviton), and provisions new nodes. The instance type is determined by the aggregate resource requests and cost optimization — Karpenter might select a c7g.large (Graviton) over an m7i.large if it is cheaper and fits the workload. You can verify this by hitting the /info endpoint to see exactly what Karpenter chose.</p><p>When you scale back down, pods are removed and nodes become underutilized. Auto Mode’s consolidation policy (WhenEmptyOrUnderutilized) kicks in, draining and terminating nodes that are no longer needed.</p><h3>Load-Driven Scaling Demo</h3><p>For a more realistic demonstration, the repo includes a <a href="https://github.com/RDarrylR/eks-auto-mode-terraform/blob/main/k8s/load-generator.yaml">load generator</a> — a busybox pod that continuously hits /compute/500000 to drive CPU utilization above the HPA threshold.</p><p>The full scaling chain works like this:</p><ol><li>Load generator drives traffic to the API</li><li>CPU utilization exceeds the HPA’s 60% target</li><li>HPA increases the replica count</li><li>New pods enter Pending state (no room on existing nodes)</li><li>Auto Mode’s Karpenter detects pending pods and provisions new nodes</li><li>Pods schedule onto the new nodes and begin serving traffic</li></ol><pre># Start the load generator<br>kubectl apply -f k8s/load-generator.yaml<br><br># Watch the full chain: HPA -&gt; pods -&gt; nodes<br>kubectl get hpa -n demo -w<br>kubectl get pods -n demo -o wide -w<br>kubectl get nodes -w<br><br># Stop the load<br>kubectl delete -f k8s/load-generator.yaml<br># HPA scales pods down, Auto Mode consolidates nodes<br></pre><p>The Makefile includes convenience commands for all of this: make demo-scale-up, make demo-scale-down, make demo-load-start, make demo-load-stop, and make demo-watch.</p><h3>Observability with Container Insights</h3><p>Since February 2026, Auto Mode supports Managed Capability Logging — you can deliver logs from compute autoscaling (Karpenter), block storage, load balancing, and pod networking to CloudWatch Logs, S3, or Kinesis Data Firehose using CloudWatch Vended Logs. This closed what was previously the biggest observability gap.</p><p>CloudWatch Container Insights complements this by providing node, pod, and container-level metrics with pre-built dashboards.</p><h3>Terraform Setup</h3><p>The CloudWatch observability addon is deployed as an EKS addon with Pod Identity — the recommended approach for Auto Mode:</p><pre># In the EKS module<br>addons = {<br>  metrics-server = {<br>    most_recent = true<br>  }<br>  amazon-cloudwatch-observability = {<br>    most_recent = true<br>    pod_identity_association = [{<br>      role_arn        = aws_iam_role.cloudwatch.arn<br>      service_account = &quot;cloudwatch-agent&quot;<br>    }]<br>  }<br>}</pre><p>The metrics-server addon is required for HPA to read CPU and memory utilization. Without it, HPA cannot function.</p><p>The IAM role uses Pod Identity (not IRSA) with least-privilege policies:</p><pre>data &quot;aws_iam_policy_document&quot; &quot;cloudwatch_assume&quot; {<br>  statement {<br>    actions = [&quot;sts:AssumeRole&quot;, &quot;sts:TagSession&quot;]<br>    principals {<br>      type        = &quot;Service&quot;<br>      identifiers = [&quot;pods.eks.amazonaws.com&quot;]<br>    }<br>  }<br>}<br><br>resource &quot;aws_iam_role&quot; &quot;cloudwatch&quot; {<br>  name               = &quot;${var.project_name}-cloudwatch-observability&quot;<br>  assume_role_policy = data.aws_iam_policy_document.cloudwatch_assume.json<br>}<br>resource &quot;aws_iam_role_policy_attachment&quot; &quot;cloudwatch_agent&quot; {<br>  role       = aws_iam_role.cloudwatch.name<br>  policy_arn = &quot;arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy&quot;<br>}<br>resource &quot;aws_iam_role_policy_attachment&quot; &quot;xray_write&quot; {<br>  role       = aws_iam_role.cloudwatch.name<br>  policy_arn = &quot;arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess&quot;<br>}</pre><p>Key decisions:</p><ul><li><strong>Pod Identity over IRSA</strong> — Pod Identity is the recommended approach for EKS addons. It is built into Auto Mode (the Pod Identity Agent runs on every node automatically).</li><li><strong>Trust principal is </strong><strong>pods.eks.amazonaws.com</strong> - This is the Pod Identity service, not ec2.amazonaws.com.</li><li><strong>AWSXrayWriteOnlyAccess</strong> - Required alongside CloudWatchAgentServerPolicy for full observability including trace collection.</li></ul><h3>What You Get</h3><p>Once deployed, Container Insights provides:</p><ul><li><strong>Node metrics</strong> — CPU, memory, network, and disk utilization per node</li><li><strong>Pod metrics</strong> — CPU and memory usage per pod, with requests vs actual</li><li><strong>Container-level performance</strong> — Individual container metrics within pods</li><li><strong>Cluster dashboards</strong> — Pre-built CloudWatch dashboards for cluster health</li><li><strong>Log collection</strong> — Application and system logs forwarded to CloudWatch Logs</li></ul><p>This complements Managed Capability Logging by providing resource-level metrics that controller logs don’t surface. Container Insights gives you the visibility to understand scaling behavior, identify resource bottlenecks, and verify that consolidation is working as expected.</p><h3>Security Out of the Box</h3><p>Auto Mode provides a strong security posture with minimal configuration. Here is what you get by default:</p><h3>Node Security</h3><ul><li><strong>Bottlerocket OS</strong> — Minimal, purpose-built container OS with SELinux enforcing and a read-only root filesystem. No package manager, no shell access.</li><li><strong>IMDSv2 enforced</strong> — Instance metadata hop limit hardcoded to 1, preventing containers from accessing node credentials via IMDS.</li><li><strong>No SSH/SSM access</strong> — Nodes are locked down. You cannot open a shell on an Auto Mode node.</li><li><strong>14-day node rotation</strong> — Nodes expire after 14 days by default (configurable up to 21 days) and are automatically replaced with the latest patched Bottlerocket AMI, ensuring CVE fixes are applied continuously.</li><li><strong>Drift detection</strong> — When AWS releases a new AMI (approximately weekly), Auto Mode detects the drift and replaces nodes with the updated image.</li></ul><h3>Data Security</h3><ul><li><strong>Encrypted EBS volumes</strong> — Auto Mode’s managed EBS CSI driver supports encrypted volumes via StorageClass parameters (encrypted: &quot;true&quot;), ensuring persistent volumes are encrypted at rest.</li><li><strong>allowedTopologies</strong> - StorageClasses can restrict volume provisioning to Auto Mode compute nodes via eks.amazonaws.com/compute-type: auto.</li><li><strong>Secrets encryption</strong> — EKS supports envelope encryption for Kubernetes secrets using KMS. Add encryption_config to the EKS module to encrypt etcd secrets at rest with your own KMS key. This is not enabled by default but is recommended for production clusters handling sensitive data.</li></ul><h3>IAM Security</h3><ul><li><strong>Least-privilege node role</strong> — Uses AmazonEKSWorkerNodeMinimalPolicy (not the broader AmazonEKSWorkerNodePolicy) and AmazonEC2ContainerRegistryPullOnly (not AmazonEC2ContainerRegistryReadOnly).</li><li><strong>Pod Identity</strong> — Built-in, used for the CloudWatch addon. No need for IRSA or node-level IAM policies for application workloads.</li><li><strong>Separate IAM roles</strong> — Cluster role, node role, and addon roles are distinct with non-overlapping permissions.</li></ul><h3>Network Security</h3><ul><li><strong>Private subnets</strong> — Nodes run exclusively in private subnets with no direct internet access.</li><li><strong>Managed VPC CNI</strong> — Network policies are enforced at the CNI level without additional tooling.</li><li><strong>NAT gateway</strong> — Outbound traffic routes through NAT, providing a single egress point.</li></ul><h3>Resource Requests Matter More Than Ever</h3><p>This deserves its own section because it is the single most important operational consideration with Auto Mode.</p><p>In a traditional EKS setup with Managed Node Groups, you might have fixed-size nodes (e.g., m5.xlarge) and the Kubernetes scheduler bins pods onto available capacity. Wasted capacity is absorbed by the fixed node pool.</p><p>With Auto Mode (and Karpenter in general), nodes are provisioned to fit the requested resources. If your pod requests 256m CPU and 512Mi memory, Karpenter might provision a small instance just for that pod. If your pod actually needs 2 vCPU but only requests 256m, it will get throttled.</p><p><strong>Best practices for resource requests:</strong></p><ol><li><strong>Always set both CPU and memory requests</strong> on every container</li><li><strong>Use tools like Goldilocks or VPA in recommendation mode</strong> to determine actual usage before setting requests</li><li><strong>Set requests close to actual usage</strong> — not the theoretical maximum</li><li><strong>Set limits higher than requests</strong> for burst capacity, but not orders of magnitude higher</li><li><strong>Monitor actual vs requested</strong> using Container Insights or Prometheus</li></ol><h3>The Karpenter Connection</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*eqjZ0Hfklc0DdXch.png" /></figure><p>Understanding that Auto Mode is Karpenter helps explain its behavior:</p><ul><li><strong>Consolidation</strong> is enabled by default. If a node is empty or underutilized, Auto Mode replaces it with a better-fitting instance</li><li><strong>Node expiration</strong> is 14 days by default (max 21 days). Nodes are automatically replaced, picking up the latest Bottlerocket AMI</li><li><strong>Drift detection</strong> triggers replacement when AWS releases a new AMI (approximately weekly)</li><li><strong>Disruption budget</strong> of 10% prevents too many nodes from being replaced simultaneously</li><li><strong>Instance selection</strong> is automatic — Karpenter evaluates the C/M/R families (generation 4+ for built-in, 5+ for our Spot pool) and picks the optimal size based on pending pod requests</li></ul><p>Since February 2026, you can access Karpenter’s logs through Managed Capability Logging. Configure CloudWatch Vended Logs to deliver compute autoscaling logs to CloudWatch Logs, S3, or Kinesis Data Firehose. For quick troubleshooting, Kubernetes events (from eks-auto-mode/compute) remain the fastest signal.</p><pre># View Auto Mode compute events<br>kubectl get events -A --sort-by=&#39;.lastTimestamp&#39; | tail -20<br><br># Check node provisioning<br>kubectl get nodes -o wide --show-labels<br># See NodePool status<br>kubectl get nodepools<br>kubectl describe nodepool general-purpose</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*S0HW2Py-MIBjt1Aw4XHbCw.png" /></figure><h3>Example Monthly Costs</h3><p>For a cluster running 3x m6i.xlarge (4 vCPU, 16 GiB each) on On-Demand:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jsEa5yi4OuBelA_BWlA1ig.png" /></figure><p>Compare to self-managed EKS with Karpenter:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*amjrgSECbqdSxGPSATmtiQ.png" /></figure><p>The ~$52/month premium (9–12% of compute) buys you elimination of operational overhead for Karpenter, VPC CNI, EBS CSI driver, ALB controller, Pod Identity Agent, AMI lifecycle, OS patching, and node health monitoring. For most teams, that operational burden costs far more than $52/month in engineer time.</p><h3>Cost Optimization Strategies</h3><ul><li><strong>Custom Spot NodePools</strong> — Create a Spot pool (shown above) for fault-tolerant workloads. Spot savings of 60–70% significantly offset the management fee</li><li><strong>Right-size resource requests</strong> — Auto Mode provisions exactly what you request. Accurate requests mean less wasted capacity</li><li><strong>Consolidation</strong> — Enabled by default. Auto Mode automatically bin-packs and replaces underutilized nodes</li><li><strong>Compute Savings Plans</strong> — Apply to Auto Mode EC2 instances just like regular instances</li></ul><h3>Recent Feature Additions</h3><p>Auto Mode has been gaining features steadily since GA. Notable additions through early 2026:</p><ul><li><strong>Managed Capability Logging</strong> (February 2026) — Deliver logs from compute autoscaling, block storage, load balancing, and pod networking to CloudWatch Logs, S3, or Kinesis Data Firehose via CloudWatch Vended Logs. This closed the biggest observability gap.</li><li><strong>Secondary IP mode</strong> (December 2025) — IP conservation for large clusters</li><li><strong>SOCI parallel pull</strong> (November 2025) — Up to 60% faster container image pulls for GPU instances</li><li><strong>Static-capacity NodePools</strong> (November 2025) — Fixed replica count for predictable workloads that need a guaranteed number of nodes</li><li><strong>GovCloud availability for Auto Mode</strong> (October 2025) — US-East and US-West GovCloud regions</li><li><strong>FIPS-compatible AMIs</strong> (October 2025) — For US compliance requirements</li><li><strong>Local Zones support</strong> (October 2025) — Deploy Auto Mode nodes to edge locations</li><li><strong>associatePublicIPAddress</strong> (August 2025) — Control public IP assignment in NodeClass</li><li><strong>ODCR/Capacity Reservation support</strong> (June 2025) — Use On-Demand Capacity Reservations with Auto Mode</li><li><strong>Pod subnet and security group selectors</strong> (June 2025) — podSubnetSelectorTerms and podSecurityGroupSelectorTerms in NodeClass for network isolation</li></ul><p>Static-capacity NodePools and ODCR support are particularly relevant for enterprise readers who need guaranteed capacity for critical workloads.</p><h3>Current Limitations</h3><p>Auto Mode is not the right choice for every workload. Here are the constraints:</p><ul><li><strong>No custom AMIs</strong> — Only AWS-provided Bottlerocket variants. If you need Amazon Linux, Ubuntu, or custom-built images with compliance tooling baked in, Auto Mode won’t work. Workaround: deploy host-level tooling as DaemonSets</li><li><strong>No SSH/SSM access to nodes</strong> — Nodes are locked down. You can get node logs through Kubernetes APIs and EKS troubleshooting tools, but cannot open a shell on a node</li><li><strong>Controller logs require extra setup</strong> — Karpenter, ALB Controller, and other managed components run off-cluster. Since February 2026, you can access their logs via Managed Capability Logging (CloudWatch Vended Logs), but this requires explicit configuration — it is not enabled by default</li><li><strong>No self-managed Karpenter</strong> — Cannot coexist. Must uninstall before enabling Auto Mode</li><li><strong>No third-party CNI</strong> — Only the managed VPC CNI. Calico and Cilium (as CNI) are not supported. Cilium for network policy only may work</li><li><strong>Linux only</strong> — No Windows node support. This matters for .NET shops running Windows containers</li><li><strong>IMDSv2 hop limit of 1</strong> — Hardcoded, cannot be changed. This breaks some tools that need IMDS access from within pods (Datadog agent, some AWS SDK configurations). Workaround: pods that require IMDS access must run with hostNetwork: true</li><li><strong>No DRA</strong> — Dynamic Resource Allocation is not yet supported</li><li><strong>No Gateway API</strong> — The managed ALB controller only supports the Ingress API and Service annotations (for NLB). If you need Gateway API (HTTPRoute, GatewayClass), you would need to disable Auto Mode’s load balancing capability and self-manage the AWS Load Balancer Controller, which defeats much of the purpose of Auto Mode</li></ul><p>The most common blocker I see discussed is the lack of custom AMIs and SSH access. If your security team requires specific hardening beyond what Bottlerocket provides, or your compliance framework mandates SSH-based auditing, Auto Mode is not ready for you yet.</p><h3>Migration Considerations</h3><p>If you have an existing EKS cluster, migration to Auto Mode is possible but has significant caveats.</p><h3>Storage Migration</h3><p>PVCs created with the self-managed EBS CSI driver (ebs.csi.aws.com) cannot be directly mounted by Auto Mode&#39;s driver (ebs.csi.eks.amazonaws.com). You need to recreate PV/PVC resources. AWS provides the eks-auto-mode-ebs-migration-tool to help.</p><h3>Load Balancer Migration</h3><p>Existing load balancers from the self-managed AWS Load Balancer Controller cannot be transferred to the managed controller. You need a blue-green approach — create new LBs under the managed controller and shift traffic via DNS.</p><h3>From Self-Managed Karpenter</h3><ol><li>Uninstall self-managed Karpenter</li><li>Uninstall AWS Load Balancer Controller</li><li>Enable Auto Mode</li><li>Recreate NodePool/NodeClass resources with Auto Mode API versions</li></ol><h3>Real-World Advice</h3><p>The Playtomic engineering team documented their migration experience and concluded: “If you’re already running an EKS cluster with Karpenter and AWS Load Balancer Controller, we don’t recommend migrating to EKS Auto Mode.” Their recommendation is to adopt Auto Mode for new clusters.</p><p>I agree with this assessment. The migration path has enough friction that the ROI for existing, well-running clusters is questionable. For greenfield deployments, Auto Mode is the obvious choice (if the limitations don’t block you and you want an easier approach to running full Kubernetes on AWS)</p><h3>When to Use Auto Mode vs Standard EKS vs Fargate</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*fodMHdP1s78Orqns.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aiYWECErLq_zgsiQUT7dhg.png" /></figure><p><strong>Choose Auto Mode</strong> when you want Kubernetes with minimal operational overhead, your workloads run on Linux, you don’t need custom AMIs or SSH access, and you value AWS managing the full data plane.</p><p><strong>Choose Standard EKS</strong> when you need custom AMIs, third-party CNIs, want to use Gateway API, need SSH access, or your team already has mature Karpenter automation.</p><p><strong>Choose Fargate</strong> when you have lightweight stateless workloads, need true serverless (no node concept), or want the absolute simplest deployment model.</p><h3>Best Practices</h3><p>After building and testing this setup, here are the practices I recommend:</p><ol><li><strong>Always set resource requests on every container.</strong> Auto Mode provisions based on requests. Missing requests means unpredictable bin-packing and potentially oversized nodes.</li><li><strong>Use custom Spot NodePools for non-critical workloads.</strong> The built-in general-purpose pool is On-Demand only. Creating a Spot pool can cut compute costs by 60-70% for batch jobs, dev environments, and other interruptible work.</li><li><strong>Tag your subnets correctly.</strong> Auto Mode discovers subnets via kubernetes.io/role/elb and kubernetes.io/role/internal-elb tags. Missing tags means load balancers and nodes won&#39;t land where you expect.</li><li><strong>Use the right resource class names.</strong> StorageClass provisioner is ebs.csi.eks.amazonaws.com, IngressClass is alb (not eks.amazonaws.com/alb), NLB loadBalancerClass is eks.amazonaws.com/nlb. You must create IngressClassParams and IngressClass resources explicitly - Auto Mode does not auto-register them.</li><li><strong>Configure PodDisruptionBudgets.</strong> Auto Mode consolidates underutilized nodes by default. PDBs ensure your application maintains availability during node replacement and consolidation. The demo includes a PDB with minAvailable: 1.</li><li><strong>Enable observability.</strong> Configure Managed Capability Logging for controller logs (compute, storage, LB, networking) and deploy Container Insights with Pod Identity for node and pod metrics.</li><li><strong>Start with Auto Mode for new clusters.</strong> Don’t migrate existing clusters unless you have a compelling reason. The storage and load balancer migration paths add significant risk.</li><li><strong>Test the IMDSv2 hop limit.</strong> If your pods use the AWS SDK, IMDS, or tools like the Datadog agent that rely on instance metadata, verify they work with the hardcoded hop limit of 1. Pods that need IMDS access must use hostNetwork: true.</li><li><strong>Use least-privilege IAM.</strong> Stick with AmazonEKSWorkerNodeMinimalPolicy and AmazonEC2ContainerRegistryPullOnly for the node role. Use Pod Identity for application IAM needs.</li></ol><h3>The Complete Terraform</h3><p>The full Terraform code for this article is available on GitHub: <a href="https://github.com/RDarrylR/eks-auto-mode-terraform">github.com/RDarrylR/eks-auto-mode-terraform</a></p><p>The repository includes:</p><ul><li>VPC with public and private subnets across 3 AZs</li><li>EKS cluster with Auto Mode enabled (Kubernetes 1.35)</li><li>Custom Spot NodePool and NodeClass (Karpenter CRDs)</li><li>Least-privilege IAM roles (cluster, node, CloudWatch)</li><li>CloudWatch Container Insights with Pod Identity</li><li>ECR repository for the demo application</li><li>Demo FastAPI app with /info (node scheduling details), /compute (CPU stress), and /stress (memory pressure) endpoints</li><li>Kubernetes manifests with ALB, HPA, and IngressClass configuration</li><li>Load generator for autoscaling demos</li><li>Makefile with deployment, scaling demo, and inspection commands</li></ul><pre># Deploy the infrastructure<br>make init<br>make plan<br>make apply<br><br># Build and push the demo app<br>make docker-build<br>make docker-push<br><br># Deploy the application<br>make deploy-app<br><br># Run the scaling demo<br>make demo-scale-up    # Scale to 15 replicas, watch node provisioning<br>make demo-watch       # Watch HPA, pods, nodes, and events<br>make demo-scale-down  # Scale back, watch consolidation<br><br># Or trigger HPA-driven scaling<br>make demo-load-start  # Deploy load generator<br>make demo-watch       # Watch the full scaling chain<br>make demo-load-stop   # Clean up<br><br># Clean up<br>make delete-app<br>make destroy</pre><h3>CLEANUP (IMPORTANT!!)</h3><p><strong>If you deploy this infrastructure, it will cost you real money (~$610/month for the default configuration). Please do not forget about it.</strong></p><p><strong>Make sure to delete all resources when you are done:</strong></p><pre>make delete-app   # Remove K8s resources first (avoids orphaned LBs/ENIs)<br>make destroy      # Destroy all Terraform resources<br>Copy</pre><p><strong>The EKS cluster, EC2 instances, NAT gateway, and ALB all incur hourly charges. Even if you are not running any application workloads, the cluster and VPC infrastructure will continue to cost you money until it is destroyed.</strong></p><h3>Wrapping Up</h3><p>EKS Auto Mode represents a significant shift in how AWS approaches Kubernetes. Instead of handing you a control plane and saying “good luck with the rest,” AWS now manages the entire stack — compute, networking, storage, and add-ons — while you focus on your applications. It’s similar in philosophy to Fargate, but with full EC2 visibility and Kubernetes compatibility.</p><p>The ~12% cost premium is real, but the operational simplification is substantial. No more Karpenter upgrades, no more VPC CNI compatibility issues, no more EBS CSI driver version mismatches, no more ALB Controller deployments. For teams that want Kubernetes without the infrastructure burden, Auto Mode delivers.</p><p>The Terraform setup with the community module is straightforward — enable a flag, configure your NodePools, and deploy. The biggest adjustment is remembering the Auto Mode resource class names (eks.amazonaws.com/*) and accepting that you are trading visibility and control for operational simplicity.</p><p>The security posture is strong out of the box — Bottlerocket with SELinux, IMDSv2 enforced, encrypted storage, least-privilege IAM, and automatic node rotation. Observability is now comprehensive — Managed Capability Logging delivers controller logs to CloudWatch, and Container Insights provides pod and node metrics. And the autoscaling story is seamless — HPA drives pod scaling, Karpenter drives node scaling, consolidation cleans up the rest.</p><p>If you are starting a new Kubernetes project on AWS in 2026, Auto Mode should be considered for your default choice unless you have a problem with one of the things in the limitations list or have a specific reason to manage the data plane yourself.</p><h3>Resources</h3><ul><li><a href="https://docs.aws.amazon.com/eks/latest/userguide/automode.html">EKS Auto Mode Overview — AWS Docs</a></li><li><a href="https://docs.aws.amazon.com/eks/latest/best-practices/automode.html">EKS Auto Mode Best Practices</a></li><li><a href="https://aws.amazon.com/blogs/containers/under-the-hood-amazon-eks-auto-mode/">Under the Hood: Amazon EKS Auto Mode</a></li><li><a href="https://aws.amazon.com/blogs/containers/getting-started-with-amazon-eks-auto-mode/">Getting Started with EKS Auto Mode</a></li><li><a href="https://docs.aws.amazon.com/eks/latest/userguide/auto-change.html">EKS Auto Mode Release Notes</a></li><li><a href="https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/latest/examples/eks-auto-mode">terraform-aws-modules/eks Auto Mode Example</a></li><li><a href="https://docs.aws.amazon.com/eks/latest/userguide/auto-migrate-karpenter.html">Migrate from Karpenter to Auto Mode</a></li><li><a href="https://docs.aws.amazon.com/whitepapers/latest/security-overview-amazon-eks-auto-mode/benefits.html">EKS Auto Mode Security Whitepaper</a></li><li><a href="https://dev.to/playtomic/migrating-your-cluster-to-eks-auto-mode-what-you-need-to-know-before-taking-the-leap-253m">Playtomic Migration Experience</a></li><li><a href="https://aws.amazon.com/eks/pricing/">Amazon EKS Pricing</a></li><li><a href="https://github.com/RDarrylR/eks-auto-mode-terraform">Full Terraform Code — GitHub</a></li><li><a href="https://www.meetup.com/aws-london-on-user-group/">AWS London Ontario User Group</a> — Meetups, talks, and community for AWS builders in the London, Ontario area</li><li><a href="https://www.youtube.com/@AWSLondonONMeetup">AWS London Ontario User Group YouTube</a> — Recorded talks and presentations</li></ul><p><em>Connect with me on</em> <a href="https://x.com/RDarrylR">X</a><em>,</em> <a href="https://bsky.app/profile/darrylruggles.bsky.social">Bluesky</a><em>,</em> <a href="https://www.linkedin.com/in/darryl-ruggles/">LinkedIn</a><em>,</em> <a href="https://github.com/RDarrylR">GitHub</a><em>,</em> <a href="https://medium.com/@RDarrylR">Medium</a><em>,</em> <a href="https://dev.to/rdarrylr">Dev.to</a><em>, or the</em> <a href="https://community.aws/@darrylr">AWS Community</a><em>. Check out more of my projects at</em> <a href="https://darryl-ruggles.cloud/">darryl-ruggles.cloud</a> <em>and join the</em> <a href="https://www.believeinserverless.com/">Believe In Serverless</a> <em>community.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7bd380128040" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elastic Container Service (ECS): My default choice for containers on AWS]]></title>
            <link>https://medium.com/@RDarrylR/elastic-container-service-ecs-my-default-choice-for-containers-on-aws-a7314ba3f418?source=rss-7ecf33fd997------2</link>
            <guid isPermaLink="false">https://medium.com/p/a7314ba3f418</guid>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[aws-fargate]]></category>
            <category><![CDATA[containers]]></category>
            <category><![CDATA[aws-ecs]]></category>
            <dc:creator><![CDATA[Darryl Ruggles]]></dc:creator>
            <pubDate>Wed, 18 Mar 2026 14:29:07 GMT</pubDate>
            <atom:updated>2026-03-18T14:29:07.671Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AHo_Z0QlGhjz-uSxw8ZP3w.png" /></figure><p>Amazon Elastic Container Service is the default AWS service I reach for whenever I need to run containers. Whether it’s a batch processing pipeline that fans out across hundreds of Fargate tasks or a FastAPI backend sitting behind an Application Load Balancer, ECS handles the orchestration without the operational complexity of Kubernetes. The control plane is free, the AWS integration is deep, and as of early 2026, the deployment capabilities rival anything in the container ecosystem.</p><p>I recently presented on ECS and decided to write down the things I have learned from building real projects into one place. This blog post is the companion to that presentation — a deep dive into what ECS offers, how I use it, and how you can start building with it today.</p><h3>Why Containers?</h3><p>Before we talk about ECS specifically, let’s talk about why containers matter. Four core principles make containers compelling:</p><ul><li><strong>Consistency</strong> — The same image runs identically on your laptop, in CI, and in production. No more “works on my machine.”</li><li><strong>Isolation</strong> — Each container gets its own filesystem, networking, and process space. Multiple services on the same host without conflicts.</li><li><strong>Efficiency</strong> — Containers share the host OS kernel. Startup in seconds, not minutes. Far less overhead than virtual machines.</li><li><strong>Portability</strong> — A Docker image runs on ECS, EKS, Lambda, or your own servers. Your business logic stays runtime-agnostic.</li></ul><p>In my <a href="https://darryl-ruggles.cloud/dsql-kabob-store/">Aurora DSQL Kabob Store</a> project, I made this a deliberate design decision — keeping business logic runtime-agnostic so the same FastAPI application could deploy on Fargate, EC2, EKS, or Lambda with minimal adapter code.</p><h3>What is Amazon ECS?</h3><p>ECS is a fully managed container orchestration service. You define what to run and how, and ECS handles placement, scaling, availability, and integration with the rest of AWS.</p><figure><img alt="ECS Overview" src="https://cdn-images-1.medium.com/max/1024/1*CjQUoykmbvpPBqMogd__lA.png" /><figcaption>ECS Overview</figcaption></figure><p>Four things make ECS stand out:</p><ol><li><strong>No control plane cost</strong> — Unlike EKS (~$75/month per cluster), the ECS orchestration layer is completely free. You only pay for the compute your containers use.</li><li><strong>Deep AWS integration</strong> — IAM roles per task, CloudWatch Container Insights, native ALB target groups, Secrets Manager injection, and tight integration with every major AWS service.</li><li><strong>Flexible compute</strong> — Choose between Fargate (serverless), EC2 (self-managed), or the new Managed Instances (AWS-managed EC2).</li><li><strong>Deployment sophistication</strong> — Rolling updates, native blue/green, canary, and linear deployments all built in.</li></ol><h3>Core Concepts</h3><p>Five building blocks make up ECS:</p><p><strong>Cluster</strong> — A logical grouping of tasks and services. Think of it as your namespace. A cluster can span Fargate, EC2, and Managed Instances simultaneously.</p><p><strong>Task Definition</strong> — The blueprint. A JSON document that specifies container images, CPU, memory, networking mode, volumes, IAM roles, and logging configuration. Versioned — each registration creates a new revision (e.g., my-app:3).</p><p><strong>Task</strong> — A running instance of a task definition. One or more containers working together. On Fargate, each task gets its own elastic network interface and private IP.</p><p><strong>Service</strong> — Maintains a desired count of tasks. Handles replacement of failed tasks, load balancer registration, auto scaling, and deployments.</p><p><strong>Container Instance</strong> — An EC2 instance running the ECS agent, registered to a cluster. Only relevant if you’re using the EC2 launch type.</p><h3>Compute Options</h3><p>ECS gives you four ways to provide compute for your containers. Choosing the right one depends on your workload characteristics, cost sensitivity, and operational preferences.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OHSMeIjYl-BtP7n2wlMKzQ.png" /><figcaption>ECS Compute Options</figcaption></figure><h3>Fargate (Serverless) — The Default Choice</h3><p>Fargate is what I recommend for most workloads. With it, there are no EC2 instances to manage. You specify CPU and memory at the task level, and AWS handles everything underneath.</p><p>CPU (vCPU)Memory OptionsNotes0.25512 MiB, 1–2 GBLinux only0.51–4 GBLinux only12–8 GBLinux and Windows24–16 GBLinux and Windows48–30 GBLinux and Windows816–60 GBLinux only1632–120 GBLinux only</p><p>Fargate supports both x86_64 and ARM64 (Graviton) architectures. Graviton gives you roughly 20% better price-performance for most workloads. Pricing is per-second based on vCPU and memory consumed.</p><p><strong>Fargate Spot</strong> offers up to 70% savings for fault-tolerant workloads. When AWS reclaims capacity, tasks receive a SIGTERM with a 2-minute warning. I use this for batch processing jobs where interruption just means retrying one file.</p><h3>EC2 Launch Type</h3><p>This offers full control over the underlying instances. You choose the AMI, instance type, and manage patching and scaling yourself. Choose EC2 when you need GPUs, custom AMIs, specific instance families, or when sustained high utilization makes reserved instances cheaper than Fargate.</p><p>The trade-off is clear: more control, more operational burden.</p><h3>ECS Managed Instances</h3><p>Launched in September 2025, Managed Instances bridge the gap between Fargate simplicity and EC2 flexibility. AWS handles provisioning, auto-scaling, Bottlerocket OS patching (14-day cycles), and host replacement. You control instance type selection via attribute-based selection — say “I need 4 GPUs” and ECS picks the right instance.</p><p>The “start before stop” principle for host replacement is particularly nice — new capacity comes up before old goes down, maintaining availability throughout.</p><p>This is the answer for GPU workloads and ML inference where Fargate isn’t an option but you don’t want to manage EC2 fleets.</p><h3>Capacity Providers</h3><p>Capacity providers are the recommended way to configure compute. The strategy uses two parameters:</p><ul><li><strong>Base</strong> — Minimum tasks guaranteed on a specific provider (only one provider can have a base)</li><li><strong>Weight</strong> — Relative proportion of tasks after the base is filled</li></ul><p>Example: base 2 on FARGATE, weight 4 on FARGATE_SPOT, weight 1 on FARGATE. Your first 2 tasks are guaranteed to use on-demand Fargate. After that, 4 out of every 5 new tasks go to Spot. Cost optimization with a reliability floor.</p><h3>Task Definitions — The Blueprint</h3><p>The task definition is where you define everything about your containers. Here are the critical parameters:</p><figure><img alt="ECS Task Definition Anatomy" src="https://cdn-images-1.medium.com/max/1024/1*quflCzODs7VnFkAnW-HQ9g.png" /><figcaption>ECS Task Definition Anatomy</figcaption></figure><h3>Container Definitions</h3><p>Each task definition contains one or more container definitions. Key parameters include:</p><ul><li><strong>image</strong> — Docker image from ECR (Elastic Container Registry), Docker Hub, or any private registry</li><li><strong>essential</strong> — If an essential container stops, the entire task stops. Your main app is essential; your log router sidecar might not be</li><li><strong>portMappings</strong> — Container ports, with named ports for Service Connect</li><li><strong>healthCheck</strong> — CMD-SHELL command with configurable interval, timeout, retries, and start period</li><li><strong>dependsOn</strong> — Container startup ordering with conditions: START, COMPLETE, SUCCESS, HEALTHY</li><li><strong>restartPolicy</strong> — Container-level restarts without killing the entire task. Configurable attempt period (60–1800 seconds) and ignored exit codes</li></ul><h3>Task Role vs Execution Role</h3><p>This distinction trips people up:</p><p>Task RoleExecution Role<strong>Purpose</strong>Permissions for your application codePermissions for the ECS agent<strong>Used by</strong>Your containers calling AWS APIsECS pulling images, pushing logs, fetching secrets<strong>Example</strong>S3 read/write, DynamoDB accessecr:GetAuthorizationToken, logs:CreateLogStream, secretsmanager:GetSecretValue</p><p>Two different roles, two different purposes. The task role follows least privilege for your application. The execution role is about infrastructure plumbing.</p><h3>Secrets Injection</h3><p>ECS natively injects secrets as environment variables from Secrets Manager or SSM Parameter Store:</p><pre>{<br>  &quot;secrets&quot;: [<br>    {<br>      &quot;name&quot;: &quot;DB_PASSWORD&quot;,<br>      &quot;valueFrom&quot;: &quot;arn:aws:secretsmanager:us-east-1:123456789:secret:my-secret&quot;<br>    },<br>    {<br>      &quot;name&quot;: &quot;API_KEY&quot;,<br>      &quot;valueFrom&quot;: &quot;arn:aws:ssm:us-east-1:123456789:parameter/my-param&quot;<br>    }<br>  ]<br>}</pre><p>Secrets Manager supports specific JSON keys (arn:...secret:my-secret:username::) and version staging. Never bake secrets into container images or task definitions.</p><h3>Networking</h3><figure><img alt="ECS Networking with awsvpc Mode" src="https://cdn-images-1.medium.com/max/1024/1*MlgojBR4Bfev46h_O5E0MA.png" /><figcaption>ECS Networking with awsvpc Mode</figcaption></figure><h3>Networking Modes</h3><p>ModeDescriptionUse With<strong>awsvpc</strong>Each task gets its own ENI and private IP. Per-task security groups. Required for Fargate.Fargate, EC2, Managed Instances<strong>bridge</strong>Docker’s virtual network. Dynamic port mapping with ALB.EC2 only<strong>host</strong>Containers use host’s network directly. No port isolation.EC2 only<strong>none</strong>No external networking.EC2 only</p><p><strong>Recommendation:</strong> Use awsvpc unless you have a specific reason not to. It’s the only mode that works everywhere and gives you per-task security groups.</p><h3>VPC Architecture</h3><p>For production workloads, run ECS tasks in private subnets. Use VPC endpoints for ECR, S3, and CloudWatch to avoid NAT gateway data transfer costs. This is the biggest hidden cost in ECS architectures — NAT gateways charging for every image pull and log push.</p><h3>Service Connect</h3><p>Service Connect is the recommended way to handle service-to-service communication. It automatically injects an Envoy proxy as a sidecar, providing:</p><ul><li>Service discovery via Cloud Map namespaces</li><li>Client-side load balancing with retries and outlier detection</li><li>Free application-level traffic metrics in CloudWatch (request count, latency, error rates)</li><li>Support for HTTP, HTTP2, gRPC, and TCP</li><li>Per-request Envoy access logs (October 2025)</li></ul><figure><img alt="ECS Service Connect" src="https://cdn-images-1.medium.com/max/1024/1*_0kYCc4-biN1MjYryXUGzg.png" /><figcaption>ECS Service Connect</figcaption></figure><p>Service Connect replaces AWS App Mesh, which will be discontinued in September 2026.</p><h3>Deployment Strategies</h3><p>ECS has the most sophisticated deployment options of any container orchestrator on AWS. As of March 2026, four strategies are available natively.</p><figure><img alt="ECS Deployment Strategies" src="https://cdn-images-1.medium.com/max/1024/1*D71hkGQ4aoD74aN9T6ko2Q.png" /><figcaption>ECS Deployment Strategies</figcaption></figure><h3>Rolling Update (Default)</h3><p>Gradually replaces old tasks with new ones. Controlled by:</p><ul><li><strong>minimumHealthyPercent</strong> (default 100%) — Minimum tasks that must remain running</li><li><strong>maximumPercent</strong> (default 200%) — Maximum tasks allowed during deployment</li></ul><p>For zero-downtime with a desired count of 4: min 100%, max 200% means ECS starts 4 new tasks, waits for them to be healthy, then stops the 4 old tasks.</p><h3>Blue/Green</h3><p>Built into ECS without CodeDeploy dependency. Provisions 100% new capacity (“green”), validates, then shifts all production traffic at once. Key features:</p><ul><li>Six Lambda lifecycle hooks: pre-scale-up, post-scale-up, test traffic shift, production traffic shift, post-test, post-production</li><li>Configurable bake time for instant rollback window</li><li>Works with ALB, NLB, and Service Connect</li></ul><h3>Canary (October 2025)</h3><p>Two-stage deployment: shift a small percentage of traffic first (configurable from 0.1% to 99.9%), validate with real production traffic, then shift the rest. Ideal for critical user-facing services where you want to limit blast radius.</p><h3>Linear (October 2025)</h3><p>Gradual traffic shift in equal increments as small as 3%, with configurable bake time between each step. The most conservative approach — allows monitoring at each increment.</p><h3>Deployment Circuit Breaker</h3><p>The safety net across all strategies. If tasks keep failing to start or pass health checks, ECS automatically stops the deployment and optionally rolls back to the last successful version. You can wire CloudWatch Alarms into the circuit breaker to detect application-level failures, not just infrastructure failures.</p><h3>Service Auto Scaling</h3><p>ECS uses Application Auto Scaling with four policy types:</p><ol><li><strong>Target Tracking</strong> — Set a target metric value (e.g., CPU at 50%). Simplest to configure — works like a thermostat.</li><li><strong>Step Scaling</strong> — Define explicit threshold/action pairs. React differently at different severity levels.</li><li><strong>Scheduled Scaling</strong> — Time-based. Scale up for business hours, down at night. Supports scaling to zero (set minimum capacity to 0).</li><li><strong>Predictive Scaling</strong> — ML-based. Analyzes historical patterns and proactively scales before demand hits. Doesn’t trigger scale-ins on its own — pair with target tracking.</li></ol><p>Important behavior: scale-in is automatically paused during deployments to protect availability.</p><h3>Storage Options</h3><p>StoragePersistenceSharedUse Case<strong>Ephemeral</strong>Task lifetimeWithin taskTemp files, caches. Default 20 GiB, up to 200 GiB on Fargate<strong>EFS</strong>PersistentAcross tasksShared config, models, content. Multi-AZ, IAM auth<strong>EBS</strong>ConfigurableSingle taskHigh-IOPS data processing. One volume per task<strong>Bind Mounts</strong>Task lifetimeWithin taskContainer-to-container data sharing</p><p>EFS is the most versatile — persistent, shared across tasks, supports IAM authorization and transit encryption. EBS is for high-performance block storage when EFS throughput is insufficient. One gotcha with EFS though is that it can take minutes to create a new EFS but this is typically a one time thing for a given application.</p><h3>Security</h3><p>Security in ECS follows the principle of least privilege at the task level:</p><ul><li><strong>Task roles</strong> — Each task definition gets its own IAM role. Your batch processor gets S3 and SQS access. Your API gets DynamoDB access. Not a shared instance profile.</li><li><strong>Secrets injection</strong> — Secrets Manager and SSM Parameter Store values injected as environment variables at startup.</li><li><strong>Network isolation</strong> — awsvpc mode gives each task its own security group. Run tasks in private subnets.</li><li><strong>Read-only root filesystem</strong> — Run containers with readonlyRootFilesystem: true for hardening.</li><li><strong>Image scanning</strong> — ECR enhanced scanning with Amazon Inspector continuously scans for OS and language package vulnerabilities. As of 2026, it supports minimal base images like scratch and distroless, and shows which images are running in your clusters.</li></ul><h3>Observability</h3><h3>CloudWatch Container Insights (Enhanced)</h3><p>Container Insights provides granular metrics at the cluster, service, task, and container level. The honeycomb visualization gives you cluster health at a glance — alarm state and utilization side by side. Deployment tracking alongside infrastructure anomalies. Cross-account monitoring for unified views. Can be enabled per-cluster or account-wide.</p><h3>Logging</h3><p>The <strong>awslogs</strong> driver sends container logs directly to CloudWatch Logs. As of June 2025, the default mode switched from blocking to non-blocking — if the log buffer fills up, excess logs are dropped rather than blocking your application. This prioritizes availability over logging completeness.</p><p>For advanced log routing, <strong>FireLens</strong> with Fluent Bit as a sidecar routes logs to any destination — CloudWatch, S3, Elasticsearch, Datadog, Splunk. Different containers can route to different destinations.</p><h3>Tracing</h3><p>Deploy the <strong>AWS Distro for OpenTelemetry (ADOT)</strong> collector as a sidecar. It receives OTLP traces on port 4317 (gRPC) or 4318 (HTTP) and exports to X-Ray automatically. This replaces the legacy X-Ray daemon approach.</p><h3>ECS Exec</h3><p>ECS Exec lets you shell into a running container directly — the equivalent of docker exec but for tasks running on Fargate or EC2. It uses AWS Systems Manager (SSM) under the hood, so there&#39;s no need to open inbound ports or SSH. I use this all the time - it&#39;s one of the most useful ECS features IMO.</p><p>To enable it, set enableExecuteCommand: true on your service or run task call. Then:</p><pre>aws ecs execute-command \<br>  --cluster my-cluster \<br>  --task abc123 \<br>  --container my-app \<br>  --interactive \<br>  --command &quot;/bin/sh&quot;</pre><p>This is invaluable for debugging — inspecting environment variables, checking network connectivity, verifying mounted volumes, or tailing logs inside the container. A few things to keep in mind:</p><ul><li>It must be enabled before the task launches — you can’t retroactively enable it on already-running tasks. For services, enabling it requires a new deployment</li><li>The task role needs SSM permissions (ssmmessages:CreateControlChannel, ssmmessages:CreateDataChannel, ssmmessages:OpenControlChannel, ssmmessages:OpenDataChannel)</li><li>The container image needs a shell (/bin/sh or /bin/bash) - scratch and distroless images won&#39;t work</li><li>All sessions are logged to CloudWatch or S3 for audit</li><li>Works with both Fargate and EC2 launch types</li></ul><p>For quick diagnostics, the <a href="https://github.com/aws-containers/amazon-ecs-exec-checker">amazon-ecs-exec-checker</a> script validates that your task, role, and agent are configured correctly.</p><h3>Real-World Architecture: Batch Processing with Fargate</h3><p>My <a href="https://darryl-ruggles.cloud/serverless-data-processor-using-aws-lambda-step-functions-and-fargate-on-ecs-with-rust/">Serverless Data Processor</a> project demonstrates the batch processing pattern.</p><figure><img alt="Batch Processing with Step Functions and Fargate" src="https://cdn-images-1.medium.com/max/1024/1*6JDTSQVWtaK-mmB4Myluyg.png" /><figcaption>Batch Processing with Step Functions and Fargate</figcaption></figure><p>The architecture: S3 upload triggers a Lambda that extracts files. Step Functions distributed map fans out processing across Fargate tasks — each file gets its own container. The containers use the waitForTaskToken pattern - Step Functions passes a callback token as an environment variable, the Rust container processes the data, then calls send_task_success to signal completion.</p><p>Key details:</p><ul><li>Fargate at minimum specs: 0.25 vCPU, 512 MB RAM</li><li>OpenTelemetry sidecar for CloudWatch metrics</li><li>Container images in ECR with multi-stage Docker builds</li><li>Written in Rust for the worker containers</li><li>Infrastructure managed with Terraform</li></ul><p>Here is the Step Functions integration that launches Fargate tasks:</p><pre>{<br>  &quot;Type&quot;: &quot;Task&quot;,<br>  &quot;Resource&quot;: &quot;arn:aws:states:::ecs:runTask.waitForTaskToken&quot;,<br>  &quot;Parameters&quot;: {<br>    &quot;LaunchType&quot;: &quot;FARGATE&quot;,<br>    &quot;Cluster&quot;: &quot;${ecs_cluster}&quot;,<br>    &quot;TaskDefinition&quot;: &quot;${task_def_name}&quot;,<br>    &quot;NetworkConfiguration&quot;: {<br>      &quot;AwsvpcConfiguration&quot;: {<br>        &quot;Subnets&quot;: [&quot;${fargate_subnet}&quot;],<br>        &quot;SecurityGroups&quot;: [&quot;${vpc_default_sg}&quot;]<br>      }<br>    },<br>    &quot;Overrides&quot;: {<br>      &quot;ContainerOverrides&quot;: [{<br>        &quot;Name&quot;: &quot;store_data_processor_daily&quot;,<br>        &quot;Environment&quot;: [<br>          {&quot;Name&quot;: &quot;TASK_TOKEN&quot;, &quot;Value.$&quot;: &quot;$$.Task.Token&quot;},<br>          {&quot;Name&quot;: &quot;S3_BUCKET&quot;, &quot;Value.$&quot;: &quot;$.BatchInput.source_bucket_name&quot;},<br>          {&quot;Name&quot;: &quot;S3_KEY&quot;, &quot;Value.$&quot;: &quot;$.Items[0].Key&quot;}<br>        ]<br>      }]<br>    }<br>  }<br>}</pre><p>This pattern works for any fan-out workload — ETL, media processing, report generation, ML batch inference. Each task is independent, starts in seconds, processes its data, and terminates. You pay only for the compute time used.</p><p>I also used this same Fargate + callback token pattern in my <a href="https://darryl-ruggles.cloud/serverless-pizza-ordering/">Serverless Pizza Ordering</a> project, where the Fargate container simulated pizza preparation and delivery — chosen over Lambda because the “AI” insisted some pizzas could take more than 15 minutes.</p><h3>Real-World Architecture: API Backend on Fargate</h3><p>My <a href="https://darryl-ruggles.cloud/dsql-kabob-store/">Aurora DSQL Kabob Store</a> project uses ECS Fargate as an always-on API backend.</p><figure><img alt="FastAPI Backend on ECS Fargate" src="https://cdn-images-1.medium.com/max/1024/1*6DRw32kbvyv5SfhVUCupRA.png" /><figcaption>FastAPI Backend on ECS Fargate</figcaption></figure><p>React frontend behind CloudFront, Application Load Balancer routing to FastAPI containers on Fargate, connecting to Aurora DSQL for multi-region active-active writes.</p><p>The key design decision: keep business logic runtime-agnostic. The same FastAPI application uses direct psycopg2 queries (not ORM) so it can deploy across Fargate, ECS on EC2, Lambda, or EKS with minimal adapter code.</p><p>Fargate costs about 20–30% more than equivalent EC2 on-demand for sustained workloads, but the operational simplicity during development is worth it. In practice, real migrations from Fargate to EC2 often yield smaller savings than expected — <a href="https://www.tines.com/blog/migrating-ecs-fargate-ec2/">Tines reported only ~5% compute cost savings</a> after migrating, though they saw 30% faster job processing and 10% lower P95 latency from having dedicated hardware. The biggest cost was actually the VPC infrastructure — NAT gateways at ~$2–3/day — not ECS itself. I terraform destroy when not actively developing.</p><h3>Terraform Examples</h3><p>Here is the core Terraform for an ECS Fargate setup, taken from my <a href="https://github.com/RDarrylR/step-functions-fargate-processor">Serverless Data Processor</a> project:</p><h3>Cluster</h3><pre>resource &quot;aws_ecs_cluster&quot; &quot;ecs_cluster&quot; {<br>  name = &quot;${var.project_name}-cluster&quot;<br>  setting {<br>      name  = &quot;containerInsights&quot;<br>      value = &quot;enabled&quot;<br>    }<br>}</pre><h3>Task Definition</h3><pre>resource &quot;aws_ecs_task_definition&quot; &quot;fargate_processor_task&quot; {<br>  family                   = var.task_definition_name<br>  execution_role_arn       = aws_iam_role.ecs_task_execution_role.arn<br>  task_role_arn            = aws_iam_role.ecs_task_role.arn<br>  network_mode             = &quot;awsvpc&quot;<br>  requires_compatibilities = [&quot;FARGATE&quot;]<br>  cpu                      = var.fargate_cpu<br>  memory                   = var.fargate_memory<br>  container_definitions = templatefile(<br>      &quot;${path.module}/container-definitions.json.tpl&quot;,<br>      {<br>        app_image           = var.app_image<br>        fargate_cpu         = var.fargate_cpu<br>        fargate_memory      = var.fargate_memory<br>        aws_region          = var.aws_region<br>        project_name        = var.project_name<br>        task_container_name = var.task_container_name<br>      }<br>    )<br>}</pre><h3>Container Definition Template</h3><pre>[<br>  {<br>    &quot;cpu&quot;: ${fargate_cpu},<br>    &quot;essential&quot;: true,<br>    &quot;image&quot;: &quot;${app_image}&quot;,<br>    &quot;memory&quot;: ${fargate_memory},<br>    &quot;name&quot;: &quot;${task_container_name}&quot;,<br>    &quot;networkMode&quot;: &quot;awsvpc&quot;,<br>    &quot;environment&quot;: [<br>      {&quot;name&quot;: &quot;S3_BUCKET&quot;, &quot;value&quot;: &quot;my-bucket&quot;},<br>      {&quot;name&quot;: &quot;S3_KEY&quot;, &quot;value&quot;: &quot;data/input.json&quot;}<br>    ],<br>    &quot;logConfiguration&quot;: {<br>      &quot;logDriver&quot;: &quot;awslogs&quot;,<br>      &quot;options&quot;: {<br>        &quot;awslogs-group&quot;: &quot;/ecs/${project_name}&quot;,<br>        &quot;awslogs-region&quot;: &quot;${aws_region}&quot;,<br>        &quot;awslogs-stream-prefix&quot;: &quot;${project_name}-log-stream&quot;<br>      }<br>    }<br>  },<br>  {<br>    &quot;image&quot;: &quot;public.ecr.aws/aws-observability/aws-otel-collector:latest&quot;,<br>    &quot;name&quot;: &quot;aws-otel-collector&quot;,<br>    &quot;essential&quot;: false,<br>    &quot;command&quot;: [&quot;--config=/etc/ecs/ecs-cloudwatch.yaml&quot;],<br>    &quot;logConfiguration&quot;: {<br>      &quot;logDriver&quot;: &quot;awslogs&quot;,<br>      &quot;options&quot;: {<br>        &quot;awslogs-group&quot;: &quot;/ecs/${project_name}-otel&quot;,<br>        &quot;awslogs-region&quot;: &quot;${aws_region}&quot;,<br>        &quot;awslogs-stream-prefix&quot;: &quot;otel&quot;<br>      }<br>    }<br>  }<br>]</pre><h3>ECS Service with ALB (for always-on workloads)</h3><pre>resource &quot;aws_ecs_service&quot; &quot;api_service&quot; {<br>  name            = &quot;${var.project_name}-service&quot;<br>  cluster         = aws_ecs_cluster.ecs_cluster.id<br>  task_definition = aws_ecs_task_definition.api_task.arn<br>  desired_count   = var.desired_count<br>  launch_type     = &quot;FARGATE&quot;<br>  network_configuration {<br>      subnets          = var.private_subnet_ids<br>      security_groups  = [aws_security_group.ecs_tasks.id]<br>      assign_public_ip = false<br>    }<br>  load_balancer {<br>    target_group_arn = aws_lb_target_group.api.arn<br>    container_name   = var.container_name<br>    container_port   = var.container_port<br>  }<br>  deployment_circuit_breaker {<br>    enable   = true<br>    rollback = true<br>  }<br>  depends_on = [aws_lb_listener.api]<br>}</pre><p>Three resources for the core ECS setup: cluster, task definition, and service. The container definition template handles the application specifics. The full Terraform for both projects is in the GitHub repos linked at the end.</p><h3>Recent Features (2025–2026)</h3><p>ECS has had a remarkable year of feature launches:</p><ul><li><strong>AI Developer Tools</strong> (December 2025) — ECS MCP Server for AI-assisted development and operations. Natural language commands for cluster management.</li><li><strong>ECS Express Mode</strong> (November 2025) — Deploy a production-ready containerized web app with just three inputs: a container image, a task execution role, and an infrastructure role. Provisions Fargate, ALB with SSL, auto scaling, monitoring, and a unique URL. Up to 25 services can share one ALB. No additional charge beyond the underlying resources.</li><li><strong>Canary and Linear Deployments</strong> (October 2025) — Fine-grained traffic shifting. Canary from 0.1% to 99.9%, linear in increments as small as 3%.</li><li><strong>Service Connect Envoy Access Logs</strong> (October 2025) — Per-request telemetry for HTTP, HTTP2, gRPC, and TCP.</li><li><strong>ECS Managed Instances</strong> (September 2025) — AWS-managed EC2 with Bottlerocket OS. Attribute-based instance selection for GPUs and specialized hardware.</li><li><strong>Native Blue/Green Deployments</strong> (July 2025) — Built into ECS without CodeDeploy. Six Lambda lifecycle hooks for testing and approval at each phase. Configurable bake time for instant rollback.</li><li><strong>Non-Blocking Log Driver Default</strong> (June 2025) — Prioritizes task availability over logging completeness.</li></ul><h3>ECS vs EKS — When to Use What</h3><p>This is the most common question I get. Both solve the same fundamental problem — running containers reliably at scale.</p><p>CriteriaECSEKS<strong>Control plane cost</strong>Free~$75/month per cluster<strong>Learning curve</strong>AWS-native conceptsKubernetes concepts<strong>AWS integration</strong>Deep, nativeGood, via add-ons<strong>Multi-cloud</strong>AWS onlyPortable K8s manifests<strong>Ecosystem</strong>AWS toolingHelm, ArgoCD, Istio, operators<strong>Managed compute</strong>Fargate, Managed InstancesFargate, managed node groups</p><p><strong>Choose ECS</strong> when your team is AWS-focused, you want operational simplicity, you value the free control plane, and your workloads are straightforward services and batch jobs.</p><p><strong>Choose EKS</strong> when your team knows Kubernetes, you need multi-cloud portability, you want the Kubernetes ecosystem (Helm, ArgoCD, custom operators), or you’re running complex stateful workloads.</p><p>Most organizations pick based on team expertise and existing tooling, not technical limitations.</p><h3>ECS vs Lambda — Containers vs Functions</h3><p>Another comparison that comes up frequently:</p><p>CriteriaECS/FargateLambda<strong>Max duration</strong>Unlimited15 minutes<strong>Max memory</strong>120 GB10 GB<strong>Startup</strong>Seconds (image pull)Milliseconds (warm) to seconds (cold)<strong>Pricing</strong>Per-second (vCPU + memory)Per-invocation + duration<strong>Scaling</strong>Service auto scalingAutomatic per-request<strong>Best for</strong>Long-running, resource-heavy, always-onEvent-driven, short-lived, bursty</p><p>In my projects I use both. Lambda for event handling — S3 triggers, API endpoints, file extraction. Fargate for heavy processing — data transformation, ML inference, container workloads that need full runtime control.</p><h3>Best Practices</h3><p>After building several production systems with ECS, here are the practices I’ve found most valuable:</p><ol><li><strong>Use Fargate unless you need GPUs or specific instance types.</strong> The operational simplicity is worth the cost premium for most workloads.</li><li><strong>Use awsvpc networking mode everywhere.</strong> It’s the only mode that works on all compute types and gives you per-task security groups.</li><li><strong>Enable the deployment circuit breaker with rollback.</strong> This catches failed deployments before they impact all traffic.</li><li><strong>Use capacity provider strategies to mix Spot and on-demand.</strong> A base of on-demand with weighted Spot gives you cost savings with a reliability floor.</li><li><strong>Inject secrets via Secrets Manager.</strong> Never bake them into images or pass them as plain environment variables.</li><li><strong>Enable Container Insights.</strong> The per-task metrics and honeycomb visualization are invaluable for debugging.</li><li><strong>Use Service Connect for service-to-service communication.</strong> Free traffic metrics and managed Envoy proxies with no code changes.</li><li><strong>Use VPC endpoints for ECR, S3, and CloudWatch.</strong> NAT gateway data transfer costs are the biggest hidden expense in ECS architectures.</li><li><strong>Use multi-stage Docker builds.</strong> Keep images small. A Rust binary in a scratch image is a few megabytes. A Python app in a slim image with only production dependencies.</li><li><strong>Define health checks in the task definition.</strong> Don’t rely solely on ALB health checks — container-level health checks catch issues faster.</li></ol><h3>Pricing — You Pay for Compute, Not Orchestration</h3><p>The most important thing to know: <strong>ECS orchestration is free.</strong> You only pay for the compute resources your containers consume.</p><ul><li><strong>Fargate</strong> — Per-second billing for vCPU ($0.04048/hour) and memory ($0.004445/GB/hour). Spot is up to 70% less. Compute Savings Plans can reduce costs by up to 49% (3-year all-upfront) or ~20% (1-year no-upfront).</li><li><strong>EC2</strong> — Standard instance pricing. Use Savings Plans or Reserved Instances for sustained workloads.</li><li><strong>Managed Instances</strong> — EC2 instance pricing plus a management fee for automated provisioning, patching, and host replacement.</li></ul><p><strong>Hidden costs to watch:</strong></p><ul><li>NAT gateways — $0.045/GB for data processed. Use VPC endpoints.</li><li>ALB — Fixed hourly cost plus per-LCU. Up to 25 ECS Express Mode services can share one ALB.</li><li>ECR storage — $0.10/GB/month. Use lifecycle policies to clean up old images.</li><li>Ephemeral storage — Fargate charges $0.000111/GB/hour above the default 20 GiB.</li></ul><p><strong>Cost optimization strategies:</strong></p><ul><li><strong>Right-sizing</strong> — The single biggest lever. Reducing from 1 vCPU/2GB to 0.5 vCPU/1GB can yield ~45–50% lower Fargate task cost.</li><li><strong>Scheduled shutdowns</strong> — Running dev/staging environments only during business hours (8 hours/day, 5 days/week) can reduce costs by over 75%.</li><li><strong>Savings Plans</strong> — Commit to consistent usage for 1–3 years. Even no-upfront 1-year plans save ~20% on Fargate.</li></ul><p>For my projects, the ECS cost has been minimal. The batch processor runs tasks for seconds at minimum specs. The Kabob Store’s main cost was VPC infrastructure ($2–3/day), not ECS.</p><h3>Things to Know</h3><p>A few operational details worth keeping in mind:</p><ul><li><strong>Task placement</strong> — Fargate handles placement automatically. For EC2, use the binpack placement strategy to consolidate workloads on fewer instances and reduce waste.</li><li><strong>Task recycling</strong> — Fargate tasks on platform version 1.4.0+ are recycled after 14 days of continuous running. Your service will gradually replace old tasks.</li><li><strong>ENI limits</strong> — In awsvpc mode on EC2, each task needs an ENI. Enable ENI trunking to increase density (requires CloudFormation custom resources).</li><li><strong>Image pull time</strong> — Large images slow task startup. Keep images lean. Set ECS_IMAGE_PULL_BEHAVIOR=prefer-cached on EC2 instances to use cached images when available.</li><li><strong>Spot instance draining</strong> — For EC2 Spot instances, set ECS_ENABLE_SPOT_INSTANCE_DRAINING=true on the ECS agent for graceful task termination.</li><li><strong>Service quotas</strong> — Default Fargate vCPU quota is 6 on new accounts (up to 4,000 in production). Request increases proactively.</li><li><strong>Force new deployment</strong> — If you update a secret or parameter store value, the running tasks won’t pick it up automatically. Force a new deployment to refresh.</li></ul><h3>Wrapping Up</h3><p>ECS is the container orchestration service I use most on AWS. The free control plane, deep AWS integration, and flexible compute options make it the right choice for most container workloads that don’t require Kubernetes-specific tooling.</p><p>The recent feature launches have been particularly impressive — native blue/green without CodeDeploy, canary and linear deployments, Managed Instances for GPU workloads, and Express Mode for rapid prototyping. Combined with Fargate’s serverless simplicity and Service Connect’s built-in service mesh, ECS has matured into a comprehensive platform for running containers at any scale.</p><p>I’ve used it for <a href="https://darryl-ruggles.cloud/serverless-data-processor-using-aws-lambda-step-functions-and-fargate-on-ecs-with-rust/">batch data processing</a> with Step Functions fan-out, <a href="https://darryl-ruggles.cloud/serverless-pizza-ordering/">pizza ordering</a> with long-running container workflows, and <a href="https://darryl-ruggles.cloud/dsql-kabob-store/">multi-region API backends</a> with Aurora DSQL. In every case, ECS handled the orchestration cleanly while I focused on the application logic.</p><p>If you’re running containers on AWS and haven’t looked at ECS recently, the current feature set is worth a fresh evaluation. Start with a Fargate service behind an ALB, enable Container Insights, and go from there.</p><h3>Resources</h3><ul><li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/Welcome.html">Amazon ECS Documentation</a></li><li><a href="https://docs.aws.amazon.com/AmazonECS/latest/bestpracticesguide/intro.html">Amazon ECS Best Practices Guide</a></li><li><a href="https://darryl-ruggles.cloud/serverless-data-processor-using-aws-lambda-step-functions-and-fargate-on-ecs-with-rust/">Serverless Data Processor — Step Functions + Fargate</a> — Batch processing with ECS</li><li><a href="https://darryl-ruggles.cloud/serverless-pizza-ordering/">Serverless Pizza Ordering</a> — Long-running Fargate container workflows</li><li><a href="https://darryl-ruggles.cloud/dsql-kabob-store/">Aurora DSQL Kabob Store</a> — FastAPI on Fargate with multi-region database</li><li><a href="https://github.com/RDarrylR/step-functions-fargate-processor">Step Functions + Fargate GitHub Repo</a> — Full Terraform and Rust container code</li><li><a href="https://github.com/RDarrylR/serverless-pizza-ordering">Serverless Pizza GitHub Repo</a> — Full Terraform and container code</li><li><a href="https://www.meetup.com/aws-london-on-user-group/">AWS London Ontario User Group</a> — Meetups, talks, and community for AWS builders in the London, Ontario area</li><li><a href="https://www.youtube.com/@AWSLondonONMeetup">AWS London Ontario User Group YouTube</a> — Recorded talks and presentations</li></ul><p><em>Connect with me on</em> <a href="https://x.com/RDarrylR">X</a><em>,</em> <a href="https://bsky.app/profile/darrylruggles.bsky.social">Bluesky</a><em>,</em> <a href="https://www.linkedin.com/in/darryl-ruggles/">LinkedIn</a><em>,</em> <a href="https://github.com/RDarrylR">GitHub</a><em>,</em> <a href="https://medium.com/@RDarrylR">Medium</a><em>,</em> <a href="https://dev.to/rdarrylr">Dev.to</a><em>, or the</em> <a href="https://community.aws/@darrylr">AWS Community</a><em>. Check out more of my projects at</em> <a href="https://darryl-ruggles.cloud/">darryl-ruggles.cloud</a> <em>and join the</em> <a href="https://www.believeinserverless.com/">Believe In Serverless</a> <em>community.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a7314ba3f418" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>