AWS, Service by ServiceAWS
0%

Deep dive  ·  one service at a time, in Go and Python

AWS,
service by service.

The console teaches you where the buttons are. This page teaches you what the service actually does when your code calls it — how Aurora survives a lost writer, why a Lambda in a private subnet cannot reach S3, what a DynamoDB partition key costs you three years later, and which knob on an ECS deployment decides whether a bad build takes production with it. Every service gets its concepts, its defaults, its failure modes and working code. Use the switch in the bar above to read every snippet in Go or in Python.

6 copiesEvery Aurora writeThree availability zones, two copies each, quorum of four to commit. The database is the storage layer; the instance is a cache with a query planner attached.
3,000 RCUOne DynamoDB partitionTable-level capacity is a fiction the moment one key is hot. Design for the partition, not the table.
$0.045/GBThrough a NAT gatewayThe most expensive line on a small company’s bill is usually not compute. It is bytes taking the wrong path out of a private subnet.
Part one

The contract every service shares

Before any single service, three things are true of all of them: your code gets credentials from a chain it did not write, every call is wrapped in retries and signing you cannot see, and the packet has to physically leave your subnet. Most “the SDK is broken” incidents are one of these three.

AWS · 01

The client, the credential chain and the retry

An AWS client is not a connection. It is a credential provider, a retryer, a signer and an HTTP pool bolted together — and each of those four has a default that is wrong for a busy service.

HOW THE SDK FINDS CREDENTIALS — FIRST HIT WINS 1  passed in code · StaticCredentialsProvider, client(…) kwargs 2  environment · AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ token) 3  shared config · ~/.aws/config profile, SSO session, role_arn chain 4  web identity · AWS_WEB_IDENTITY_TOKEN_FILE → sts:AssumeRoleWithWebIdentity 5  container · ECS / Fargate task role over 169.254.170.2 6  instance · IMDSv2 on 169.254.169.254, token-first, hop limit applies WHAT THIS MEANS FOR YOUR CODE The chain runs once, when the client is built. Whichever provider answers then caches and refreshes itself shortly before expiry. So: one client per service per process, kept for the life of the process. A client built inside a request handler pays DNS, TLS and often an STS round trip, every single time. What wraps every single call your call with a ctx middleware serialise, SigV4 sign retryer 3 tries, full jitter HTTP client pool + attempt t/o regional endpoint dynamodb.eu-west-1 The context deadline covers all attempts; the HTTP timeout covers one. Set both, or a single hung socket eats the whole budget.
The dashed arrow is the one people forget. A call that takes 3 seconds may have been three attempts of one second, and the service may have executed all three. Everything downstream of the retryer must therefore be safe to repeat — which is why idempotency keys, conditional writes and message deduplication appear in nearly every section below.

Snippets below in Go and Python — switch with the control in the bar, or press g / p.

// config.go — built once at start-up, shared by every client in the process.
cfg, err := config.LoadDefaultConfig(ctx,
    config.WithRegion("eu-west-1"),
    config.WithRetryer(func() aws.Retryer {
        // Standard: 3 attempts, exponential backoff with full jitter, and a
        // token bucket so a storm of retries cannot amplify an outage.
        // retry.NewAdaptiveMode wraps it with a client-side rate limiter that
        // slows you down when the service starts throttling.
        return retry.NewStandard(func(o *retry.StandardOptions) {
            o.MaxAttempts = 5
            o.MaxBackoff = 3 * time.Second
        })
    }),
    config.WithHTTPClient(awshttp.NewBuildableClient().
        WithTimeout(10*time.Second).                // PER ATTEMPT, not per call
        WithTransportOptions(func(t *http.Transport) {
            t.MaxIdleConnsPerHost = 100             // the SDK client defaults to 10
        })),
)
if err != nil {
    return fmt.Errorf("aws config: %w", err)        // fail the process, not the request
}

// One client per service. They are goroutine-safe and hold the pool.
app := &App{
    ddb: dynamodb.NewFromConfig(cfg),
    s3:  s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = false }),
    sqs: sqs.NewFromConfig(cfg),
}
# aws.py — imported once; module-level clients live for the whole process.
import boto3
from botocore.config import Config

CFG = Config(
    region_name="eu-west-1",
    retries={"max_attempts": 5, "mode": "adaptive"},  # legacy | standard | adaptive
    connect_timeout=2,             # PER ATTEMPT
    read_timeout=10,               # PER ATTEMPT — total wall time can be 5x this
    max_pool_connections=50,       # default 10, shared by every thread
    tcp_keepalive=True,
    user_agent_appid="orders",     # lands in CloudTrail; worth its weight in triage
)

_SESSION = boto3.session.Session()
DDB = _SESSION.client("dynamodb", config=CFG)
S3 = _SESSION.client("s3", config=CFG)
SQS = _SESSION.client("sqs", config=CFG)

# Clients are thread-safe. Resources (boto3.resource) are NOT — if you use
# them, build one per thread, or stay on clients as everything here does.
KnobDefaultSet it toBecause
Max attempts3 in Go; boto3 ships legacy mode with 53–5 for reads, 1 for anything non-idempotent you retry yourselfDouble retry layers multiply: your 3 × the SDK’s 3 is nine calls and nine chances to duplicate a side effect.
Retry modestandard in Go, legacy in boto3adaptive when you regularly hit throttlingAdaptive adds a client-side rate limiter, so a throttled fleet backs off together instead of hammering in lockstep.
Read timeout60s (boto3), none (Go transport)Just above your p99 for that call, typically 2–10sThe default is longer than most callers’ own deadline, so the socket outlives the request that wanted it.
Pool size10 (boto3), 10 per host (the Go SDK’s client)≥ your concurrencyBeyond the pool, calls queue invisibly. The latency shows up in your metrics as “AWS is slow”.
Client lifetime—ProcessConstruction resolves the credential chain, may call STS, and starts a fresh connection pool.

Errors: the code matters more than the message

Every AWS error carries a code, an HTTP status and a request ID. Branch on the code — never on the message text, which AWS reserves the right to reword. The request ID is what support asks for, so log it.

// Typed errors: the modelled ones have Go types, the rest satisfy smithy.APIError.
_, err := app.ddb.PutItem(ctx, &dynamodb.PutItemInput{
    TableName:           aws.String("orders"),
    Item:                item,
    ConditionExpression: aws.String("attribute_not_exists(pk)"),
})

var dup *types.ConditionalCheckFailedException
var thr *types.ProvisionedThroughputExceededException
var api smithy.APIError

switch {
case errors.As(err, &dup):
    return ErrAlreadyExists                       // not an error: someone won the race
case errors.As(err, &thr):
    return backoffAndRetryLater(ctx)              // the SDK already tried; the table is hot
case errors.As(err, &api):
    // RequestID is on the response metadata, not on the error itself
    var re *awshttp.ResponseError
    id := ""
    if errors.As(err, &re) {
        id = re.ServiceRequestID()
    }
    log.Error("aws call failed", "code", api.ErrorCode(),
        "fault", api.ErrorFault().String(),       // client | server | unknown
        "request_id", id)
    return err
case err != nil:
    return err                                    // context cancelled, DNS, TLS…
}
# botocore raises ClientError for anything the service returned; the shape is
# always the same, and every client exposes its modelled errors as attributes.
from botocore.exceptions import ClientError, EndpointConnectionError

try:
    DDB.put_item(
        TableName="orders",
        Item=item,
        ConditionExpression="attribute_not_exists(pk)",
    )
except DDB.exceptions.ConditionalCheckFailedException:
    raise AlreadyExists                    # not an error: someone won the race
except DDB.exceptions.ProvisionedThroughputExceededException:
    raise Retryable("table is hot")        # the SDK already retried; still throttled
except ClientError as e:
    err = e.response["Error"]
    meta = e.response["ResponseMetadata"]
    log.error(
        "aws call failed",
        extra={
            "code": err["Code"],                          # branch on THIS
            "status": meta["HTTPStatusCode"],
            "request_id": meta["RequestId"],              # what support will ask for
            "attempts": meta.get("RetryAttempts"),        # how many the SDK burned
        },
    )
    raise
except EndpointConnectionError:            # never reached the service at all:
    raise Unreachable                      # DNS, route, security group, endpoint

Pagination and waiters, without the loop you always get wrong

Every list-shaped AWS API is paginated, and the page you get back is capped by size as well as count — a Query can return zero items and still hand you a continuation token because it scanned 1 MB of non-matching rows. Never treat an empty page as the end. Both SDKs ship paginators that get this right; use them.

p := s3.NewListObjectsV2Paginator(app.s3, &s3.ListObjectsV2Input{
    Bucket: aws.String("orders-archive"),
    Prefix: aws.String("2026/08/"),
})
for p.HasMorePages() {
    page, err := p.NextPage(ctx)          // ctx cancels mid-listing
    if err != nil {
        return err
    }
    for _, o := range page.Contents {
        handle(aws.ToString(o.Key), aws.ToInt64(o.Size))
    }
}

// Waiters poll for you, with the service’s own recommended interval —
// and, crucially, with a maximum wait, so a stuck resource fails the deploy.
w := rds.NewDBInstanceAvailableWaiter(app.rds)
err = w.Wait(ctx, &rds.DescribeDBInstancesInput{
    DBInstanceIdentifier: aws.String("orders-prod"),
}, 20*time.Minute)
for page in S3.get_paginator("list_objects_v2").paginate(
    Bucket="orders-archive",
    Prefix="2026/08/",
    PaginationConfig={"PageSize": 1000},          # per request, not a total cap
):
    for obj in page.get("Contents", []):          # .get: an empty page has no key
        handle(obj["Key"], obj["Size"])

# Server-side filtering beats client-side: JMESPath runs in botocore, but
# search() still downloads every page — use Prefix/StartAfter where you can.
keys = S3.get_paginator("list_objects_v2").paginate(
    Bucket="orders-archive", Prefix="2026/08/"
).search("Contents[?Size > `0`].Key")

# Waiters: the service’s own polling loop, with a bounded maximum.
RDS.get_waiter("db_instance_available").wait(
    DBInstanceIdentifier="orders-prod",
    WaiterConfig={"Delay": 30, "MaxAttempts": 40},   # 20 minutes, then raise
)

Region is not a formality

The region decides the endpoint, the signing scope and the price. A client with no region falls back to the environment, then the profile, then fails — and in Lambda it silently inherits the function’s region, which is why code that works deployed fails on a laptop.

The retry you did not order

SDK retries are invisible in your traces unless you look for them. In Go, retry.AddWithMaxAttempts and the attempt metrics; in Python, ResponseMetadata.RetryAttempts. A p99 that is exactly 3× your p50 is a retry story, not a slow-service story.

AWS · 02

IAM: the evaluation order, and how a process gets a role

IAM is not a list of permissions. It is a decision function run per request over up to six policy types, and it answers “deny” unless something explicitly allows — and nothing explicitly denies.

ONE API CALL, EVALUATED — TOP TO BOTTOM, EVERY TIME 1  explicit Deny in ANY policy → DENY, stop 2  SCP · the organisation’s ceiling on the account must allow 3  RCP · the organisation’s ceiling on the resource must allow 4  session policy · passed at AssumeRole, subtracts only if present 5  permissions boundary · the maximum this identity may have if present 6  identity policy Allow — or the resource policy’s → ALLOW Nothing matched → implicit deny. Cross-account needs an Allow on both sides. How a running process gets that identity workload identity EC2 / task / pod / OIDC trust policy WHO may assume sts:AssumeRole* 15 min – 12 h creds role policy WHAT it may do
Two policies, two questions. The trust policy answers “who may become this role” and the attached policies answer “what may it then do”. Nearly every AccessDenied during a migration is the first one: the permissions were copied across, the trust relationship was not.
PrincipalA user has long-lived keys. A role has none — it is assumed, and hands out credentials that expire. Production code should only ever use roles.
Trust policyThe resource policy of the role itself. Names the principal allowed to assume it: a service, an account, or an OIDC provider with a sub condition.
BoundaryA ceiling attached to an identity. Lets you delegate “you may create roles” without letting the delegate create an admin role.
Condition keysWhere real least privilege lives: aws:PrincipalOrgID, aws:SourceVpce, s3:prefix, aws:SecureTransport, sts:ExternalId.

Assuming a role from code — and refreshing it

The one detail people miss: AssumeRole returns credentials with a fixed expiry. If your process outlives it and you did not wrap the call in something that refreshes, you get a clean, confusing ExpiredToken an hour after deploy.

// Cross-account: assume the data account’s reader role. NewCredentialsCache
// is what makes this safe for a long-lived process — it refreshes shortly
// before expiry and serialises concurrent refreshes into one STS call.
base, err := config.LoadDefaultConfig(ctx, config.WithRegion("eu-west-1"))
if err != nil {
    return err
}

prov := stscreds.NewAssumeRoleProvider(sts.NewFromConfig(base),
    "arn:aws:iam::222233334444:role/orders-reader",
    func(o *stscreds.AssumeRoleOptions) {
        o.RoleSessionName = "orders-" + hostname       // shows up in CloudTrail
        o.Duration = 30 * time.Minute                  // short: blast radius of a leak
        o.ExternalID = aws.String(externalID)          // third-party: confused deputy
        o.Tags = []types.Tag{                          // session tags can drive ABAC
            {Key: aws.String("service"), Value: aws.String("orders")},
        }
    })

cross := base.Copy()
cross.Credentials = aws.NewCredentialsCache(prov)
reader := dynamodb.NewFromConfig(cross)

// Who am I, really? The first line of any IAM debugging session.
who, err := sts.NewFromConfig(cross).GetCallerIdentity(ctx, nil)
log.Info("identity", "arn", aws.ToString(who.Arn))     // .../orders-reader/orders-h7
# boto3’s assume_role returns FIXED credentials — fine for a script, a trap
# for a server. For anything long-lived, hand botocore a refreshable provider
# so it re-assumes before expiry instead of dying with ExpiredToken.
from botocore.credentials import DeferredRefreshableCredentials
from botocore.session import get_session

STS = boto3.client("sts")
ROLE = "arn:aws:iam::222233334444:role/orders-reader"


def _fetch():
    r = STS.assume_role(
        RoleArn=ROLE,
        RoleSessionName=f"orders-{HOSTNAME}",     # shows up in CloudTrail
        DurationSeconds=1800,                     # short: blast radius of a leak
        ExternalId=EXTERNAL_ID,                   # third-party: confused deputy
        Tags=[{"Key": "service", "Value": "orders"}],   # session tags drive ABAC
    )["Credentials"]
    return {
        "access_key": r["AccessKeyId"],
        "secret_key": r["SecretAccessKey"],
        "token": r["SessionToken"],
        "expiry_time": r["Expiration"].isoformat(),
    }


_bs = get_session()
_bs._credentials = DeferredRefreshableCredentials(_fetch, "assume-role")
CROSS = boto3.Session(botocore_session=_bs)
reader = CROSS.client("dynamodb", config=CFG)

# Who am I, really? First line of any IAM debugging session.
print(CROSS.client("sts").get_caller_identity()["Arn"])   # .../orders-reader/orders-h7

The policy you should actually write

A policy with "Resource": "*" is a policy you will never be able to audit. Scope by resource ARN, then narrow further with conditions — the two below are the highest-value conditions on the platform, and neither costs you anything.

// The same JSON, whichever language calls it. Two things make it real:
// a resource ARN with a prefix, and conditions that survive a leaked key.
policy := `{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadOwnTenantObjects",
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::orders-archive/tenant/${aws:PrincipalTag/tenant}/*",
      "Condition": {
        "Bool":        {"aws:SecureTransport": "true"},
        "StringEquals":{"aws:SourceVpce": "vpce-0abc123"}
      }
    },
    {
      "Sid": "DenyEverythingOutsideTheOrg",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {"StringNotEquals": {"aws:PrincipalOrgID": "o-exampleorgid"}}
    }
  ]
}`

// Pass it as a session policy to shrink a broad role for one job.
o.Policy = aws.String(policy)   // inside AssumeRoleOptions: intersects, never grants
# The same JSON, whichever language calls it. Two things make it real:
# a resource ARN with a prefix, and conditions that survive a leaked key.
POLICY = {
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ReadOwnTenantObjects",
            "Effect": "Allow",
            "Action": ["s3:GetObject"],
            "Resource": "arn:aws:s3:::orders-archive/tenant/${aws:PrincipalTag/tenant}/*",
            "Condition": {
                "Bool": {"aws:SecureTransport": "true"},
                "StringEquals": {"aws:SourceVpce": "vpce-0abc123"},
            },
        },
        {
            "Sid": "DenyEverythingOutsideTheOrg",
            "Effect": "Deny",
            "Action": "*",
            "Resource": "*",
            "Condition": {"StringNotEquals": {"aws:PrincipalOrgID": "o-exampleorgid"}},
        },
    ],
}

# Pass it as a session policy to shrink a broad role for one job.
STS.assume_role(RoleArn=ROLE, RoleSessionName="job",
                Policy=json.dumps(POLICY))    # intersects, never grants

Read the denial, it tells you which rule fired

“User: arn:…/task-role is not authorized to perform: s3:GetObject … because no identity-based policy allows” means step 6 found nothing. “…with an explicit deny in a service control policy” means step 1 or 2, and no amount of editing the role will help. IAM Access Analyzer’s policy generator will write a first draft from 90 days of CloudTrail — a far better starting point than *.

AWS · 03

Where your code sits in the network

A private subnet has no route to the internet. Everything that follows — NAT bills, timing-out Lambdas, endpoints, security-group loops — is a consequence of that one sentence.

VPC 10.0.0.0/16 — THREE WAYS OUT, THREE PRICES PUBLIC SUBNET · 10.0.0.0/20 ALB · public IP, 0.0.0.0/0 → igw NAT gateway · hourly + per GB nothing of yours runs here PRIVATE SUBNET · 10.0.16.0/20 ECS tasks, Lambda ENIs, pods RDS / Aurora · no public IP ever default route → nat, or nowhere one ENI per task · subnet IPs are finite VIA NAT → INTERNET GATEWAY works for everything, bills every byte GATEWAY ENDPOINT · FREE S3 and DynamoDB only · a route table entry INTERFACE ENDPOINT · PRIVATELINK an ENI per AZ · hourly + per GB, but cheaper than NAT and never leaves the network SECURITY GROUP vs NACL SG · stateful, allow-only, attached to the ENI. Return traffic is implied. Can reference another SG by id — the correct way to say “the app, whatever its IP is today”. NACL · stateless, subnet-wide, has deny rules. Forget the ephemeral port range on the return path and you get a one-way network. “It just hangs” — in the order to check HANGS, THEN TIMES OUT A packet left and nothing answered: missing route, security group with no egress rule, NACL blocking the return, or no NAT in that subnet’s table. FAILS IMMEDIATELY DNS or TLS, not the network: private DNS off on the endpoint, wrong region in the hostname, or enableDnsHostnames disabled on the VPC.
Fast failure and slow failure mean different things. Connection refused or an NXDOMAIN in under a second is a name or a listener problem. A hang that ends at your timeout is a filtering or routing problem — something dropped the packet silently, which is exactly what security groups and NACLs are built to do.

Make the network failure legible at boot

The worst version of this bug is a service that starts healthy, passes its health check, and only fails when the first real request needs S3. A ten-line probe at start-up turns a 3 a.m. mystery into a log line.

// reachable dials the service endpoint and separates the failure modes:
// name resolution (DNS / private DNS) from the TCP handshake (route / SG).
func reachable(ctx context.Context, host string) error {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    ips, err := net.DefaultResolver.LookupHost(ctx, host)
    if err != nil {
        // private DNS off on the interface endpoint, or DNS support off on the VPC
        return fmt.Errorf("dns %s: %w", host, err)
    }

    var d net.Dialer
    conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, "443"))
    if err != nil {
        // resolved but unreachable: no route, no egress rule, or no endpoint
        return fmt.Errorf("dial %s (%v): %w", host, ips[0], err)
    }
    _ = conn.Close()
    return nil
}

// At start-up, before the health check goes green:
for _, h := range []string{
    "sts.eu-west-1.amazonaws.com",              // credentials
    "dynamodb.eu-west-1.amazonaws.com",         // gateway endpoint or NAT
    "secretsmanager.eu-west-1.amazonaws.com",   // interface endpoint only
} {
    if err := reachable(ctx, h); err != nil {
        return fmt.Errorf("startup connectivity: %w", err)   // crash loudly, now
    }
}
# reachable() dials the service endpoint and separates the failure modes:
# name resolution (DNS / private DNS) from the TCP handshake (route / SG).
import socket


def reachable(host: str, port: int = 443, timeout: float = 3.0) -> None:
    try:
        infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
    except socket.gaierror as e:
        # private DNS off on the interface endpoint, or DNS support off on the VPC
        raise Unreachable(f"dns {host}: {e}") from e

    family, kind, proto, _, addr = infos[0]
    with socket.socket(family, kind, proto) as s:
        s.settimeout(timeout)
        try:
            s.connect(addr)
        except OSError as e:
            # resolved but unreachable: no route, no egress rule, or no endpoint
            raise Unreachable(f"dial {host} ({addr[0]}): {e}") from e


# At start-up, before the health check goes green:
for host in (
    "sts.eu-west-1.amazonaws.com",              # credentials
    "dynamodb.eu-west-1.amazonaws.com",         # gateway endpoint or NAT
    "secretsmanager.eu-west-1.amazonaws.com",   # interface endpoint only
):
    reachable(host)                             # crash loudly, now, not at 03:00
ConceptWhat it really isThe detail that bites
SubnetA CIDR pinned to one availability zone, with one route tableAWS reserves 5 addresses per subnet. A /28 gives you 11 usable IPs — and one ECS task in awsvpc mode takes one each.
Public vs privatePurely whether the route table has 0.0.0.0/0 → igwThere is no “public subnet” setting. A subnet is public because of a route, and instances also need a public IP to use it.
NAT gatewayA managed SNAT device in a public subnet, per AZCharged hourly and per gigabyte processed. One per AZ for resilience triples the hourly cost; one shared across AZs adds cross-AZ transfer to every byte.
Gateway endpointA route table entry for S3 or DynamoDBFree, and the single highest-return change on most bills. If your NAT charge is dominated by S3 traffic, this deletes it.
Interface endpointAn ENI in your subnet with private DNS for one servicePriced per AZ per hour plus data. Worth it above roughly a terabyte a month, or whenever compliance forbids the public path.
Security groupA stateful allow-list on an ENIReference other groups, not CIDRs. sg-app → sg-db:5432 keeps working when the app fleet is replaced.
Lambda in a VPCHyperplane ENIs shared across invocationsSince 2019 this costs no per-invocation ENI setup — but a VPC Lambda still has no internet unless the subnet routes to NAT, which surprises people calling third-party APIs.

Plan addresses before you plan anything else

A VPC CIDR cannot be shrunk, and every workload that uses awsvpc networking — Fargate tasks, EKS pods with the VPC CNI, VPC Lambdas — consumes real subnet addresses. A /16 split into three /20 private subnets gives you about 4,000 usable addresses per zone, which sounds infinite until a pod-per-request architecture arrives. Size private subnets at least two rungs larger than you think, and keep a spare /20 unallocated in each zone.

Part two

Relational: RDS and Aurora

RDS is your database with the operations attached. Aurora is a different storage engine wearing the same wire protocol. The distinction decides your failover time, your bill, and how much of your recovery plan is a button versus a weekend.

AWS · 04

RDS: every knob, and which ones cost you an outage

An RDS instance is a normal Postgres or MySQL server plus six configuration surfaces — compute, storage, parameters, backups, availability and maintenance. Changing three of them is instant; changing the other three restarts your database.

ONE DB INSTANCE — WHAT A CHANGE COSTS YOU COMPUTE · db.r7g.2xlarge Class change = reboot. Multi-AZ makes it a failover instead: seconds, not minutes. Graviton is ~20% cheaper. STORAGE · gp3, 500 GiB Grow online. Never shrink — that is a dump and restore. IOPS and throughput are separate dials on gp3. PARAMETER GROUP Dynamic params apply live. Static ones sit in pending-reboot until you reboot — silently, for months. BACKUP · 7–35 DAYS Daily snapshot + WAL every 5 min ⇒ restore to any second in the window. Always into a NEW instance. AVAILABILITY Multi-AZ instance: 1 hidden standby, 60–120s failover. Multi-AZ cluster: 2 readable standbys, under 35s. MAINTENANCE WINDOW Minor versions auto-apply here. Major versions never do, and are one-way. CA rotation lands here too. Point-in-time recovery is a restore, not a rewind daily snapshot incremental, in S3 WAL, every 5 min the RPO you get new instance, new endpoint SO YOUR RTO INCLUDES restore + DNS change + cache warm-up A restored volume loads lazily from S3, so the first pass over cold data is slow — the restore finishing is not the recovery finishing. THE THREE THAT ARE NOT REVERSIBLE IN PLACE Turning on encryption · shrinking storage · a major version upgrade. Each one is snapshot, restore, cut over.
The parameter group is the surface people misjudge. Postgres max_connections, shared_buffers and rds.force_ssl are static: you can save the change, see it in the console, and still be running the old value until something reboots the instance — often the next failover, at the worst possible moment.
Storage · gp33,000 IOPS and 125 MB/s baseline included, both independently provisionable. The default for almost everything, and it removed gp2’s burst-credit cliff.
Storage · io2Up to 256,000 IOPS with a five-nines durability claim. You want it when a sustained write workload is IOPS-bound, and you will feel the price.
AutoscalingGrows storage when free space drops below 10%. Set a maximum — without one a runaway write can grow you into a very large bill.
T-classBurstable CPU credits. Fine for dev, dangerous in production: when credits run out the instance is throttled to baseline and everything queues at once.

The parameters worth setting on day one

Parameter (Postgres)DefaultSet toWhy
statement_timeout0 — unlimitedSlightly above your slowest legitimate queryWithout it, one bad plan holds a connection until someone notices. This is the single highest-value parameter on the list.
idle_in_transaction_session_timeout030–60sAn application that opens a transaction and then makes an HTTP call will hold locks and block VACUUM for as long as that call takes.
log_min_duration_statement−1 — off500–1000 msTurns “the database is slow” into a list of statements. Cheap; log volume is bounded by how bad things are.
rds.force_ssl0 (1 on newer engines)1Otherwise a misconfigured client silently talks plaintext inside your VPC. Static — needs a reboot.
max_connectionsLEAST({DBInstanceClassMemory/9531392}, 5000)Leave it; fix the pool insteadRaising it moves the failure from “cannot connect” to “everything is slow”. Connections cost memory per backend.
log_autovacuum_min_duration−10Autovacuum problems are invisible until table bloat becomes an incident. Log every run and you will see it coming.

A pre-deploy guard for the database

Three RDS facts should block a release: pending maintenance that will reboot you mid-deploy, a certificate authority about to expire, and storage close to the autoscaling ceiling. All three are one API call each.

// preflight fails the pipeline if the database is about to move under us.
func preflight(ctx context.Context, c *rds.Client, id string) error {
    d, err := c.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{
        DBInstanceIdentifier: aws.String(id),
    })
    if err != nil {
        return err
    }
    in := d.DBInstances[0]

    // 1. a modification already queued for the next window (class, storage, params)
    if p := in.PendingModifiedValues; p != nil && p.DBInstanceClass != nil {
        return fmt.Errorf("pending class change to %s", aws.ToString(p.DBInstanceClass))
    }

    // 2. static parameters saved but not yet live — the reboot is still owed
    for _, g := range in.DBParameterGroups {
        if aws.ToString(g.ParameterApplyStatus) == "pending-reboot" {
            return fmt.Errorf("parameter group %s awaits a reboot",
                aws.ToString(g.DBParameterGroupName))
        }
    }

    // 3. maintenance AWS will apply for us, whether or not we are deploying
    m, err := c.DescribePendingMaintenanceActions(ctx,
        &rds.DescribePendingMaintenanceActionsInput{
            ResourceIdentifier: in.DBInstanceArn,
        })
    if err != nil {
        return err
    }
    for _, r := range m.PendingMaintenanceActions {
        for _, a := range r.PendingMaintenanceActionDetails {
            log.Warn("pending maintenance",
                "action", aws.ToString(a.Action),                 // system-update, ca-certificate-rotation…
                "auto_applied_after", a.AutoAppliedAfterDate)
        }
    }
    log.Info("db ok", "ca", aws.ToString(in.CACertificateIdentifier),
        "engine", aws.ToString(in.EngineVersion),
        "multi_az", aws.ToBool(in.MultiAZ))
    return nil
}
# preflight() fails the pipeline if the database is about to move under us.
RDS = boto3.client("rds", config=CFG)


def preflight(db_id: str) -> None:
    inst = RDS.describe_db_instances(DBInstanceIdentifier=db_id)["DBInstances"][0]

    # 1. a modification already queued for the next window (class, storage, params)
    pending = inst.get("PendingModifiedValues") or {}
    if "DBInstanceClass" in pending:
        raise Blocked(f"pending class change to {pending['DBInstanceClass']}")

    # 2. static parameters saved but not yet live — the reboot is still owed
    for group in inst["DBParameterGroups"]:
        if group["ParameterApplyStatus"] == "pending-reboot":
            raise Blocked(f"{group['DBParameterGroupName']} awaits a reboot")

    # 3. maintenance AWS will apply for us, whether or not we are deploying
    actions = RDS.describe_pending_maintenance_actions(
        ResourceIdentifier=inst["DBInstanceArn"]
    )["PendingMaintenanceActions"]
    for res in actions:
        for a in res["PendingMaintenanceActionDetails"]:
            log.warning(
                "pending maintenance",
                extra={"action": a["Action"],            # ca-certificate-rotation…
                       "after": a.get("AutoAppliedAfterDate")},
            )

    log.info("db ok", extra={"ca": inst["CACertificateIdentifier"],
                             "engine": inst["EngineVersion"],
                             "multi_az": inst["MultiAZ"]})

The recovery you should have rehearsed

“Someone dropped a table at 14:07” is not a backup restore — it is a point-in-time restore into a new instance, from which you copy the one table back. Practise it once and the real thing is forty minutes instead of a day.

// Restore to one second before the mistake. This never touches production:
// it creates a second instance you then read from.
target := "orders-prod-recovery-1407"
_, err := c.RestoreDBInstanceToPointInTime(ctx, &rds.RestoreDBInstanceToPointInTimeInput{
    SourceDBInstanceIdentifier: aws.String("orders-prod"),
    TargetDBInstanceIdentifier: aws.String(target),
    RestoreTime:                aws.Time(dropTime.Add(-1 * time.Second)),
    DBSubnetGroupName:          aws.String("private"),        // same VPC, no public IP
    VpcSecurityGroupIds:        []string{"sg-db"},
    DBInstanceClass:            aws.String("db.r7g.xlarge"),  // size for the copy job
    MultiAZ:                    aws.Bool(false),              // it is temporary
    DeletionProtection:         aws.Bool(false),
})
if err != nil {
    return err
}

// Restores take tens of minutes on a large volume; the waiter has a cap so a
// stuck restore fails the runbook instead of hanging it.
err = rds.NewDBInstanceAvailableWaiter(c).Wait(ctx,
    &rds.DescribeDBInstancesInput{DBInstanceIdentifier: aws.String(target)},
    45*time.Minute)
# Restore to one second before the mistake. This never touches production:
# it creates a second instance you then read from.
target = "orders-prod-recovery-1407"
RDS.restore_db_instance_to_point_in_time(
    SourceDBInstanceIdentifier="orders-prod",
    TargetDBInstanceIdentifier=target,
    RestoreTime=drop_time - timedelta(seconds=1),
    DBSubnetGroupName="private",          # same VPC, no public IP
    VpcSecurityGroupIds=["sg-db"],
    DBInstanceClass="db.r7g.xlarge",      # size for the copy job, not for prod
    MultiAZ=False,                        # it is temporary
    DeletionProtection=False,
)

# Restores take tens of minutes on a large volume; the waiter has a cap so a
# stuck restore fails the runbook instead of hanging it.
RDS.get_waiter("db_instance_available").wait(
    DBInstanceIdentifier=target,
    WaiterConfig={"Delay": 30, "MaxAttempts": 90},    # 45 minutes
)

Blue/Green is the upgrade path

RDS Blue/Green clones the instance, keeps it in sync by logical replication, lets you upgrade and test the green copy, then switches over in about a minute with the endpoint names swapped. It is the only sane way to do a major version upgrade on a database you care about.

Performance Insights or nothing

CloudWatch tells you CPU is 90%. Performance Insights tells you which SQL, waiting on what, measured as average active sessions against your vCPU count. Seven days of retention is free. Turn it on before you need it — it cannot show you yesterday.

AWS · 05

Aurora: the log is the database

Aurora keeps the Postgres or MySQL front end and replaces everything below the buffer pool. Instances ship redo log records to a distributed storage fleet; storage materialises pages. Every property people like about Aurora falls out of that one decision.

WRITES GO DOWN AS LOG RECORDS — NOT PAGES WRITER one, always no checkpointing reader × up to 15 lag: 10–20 ms typical SHARED STORAGE VOLUME · 10 GiB SEGMENTS AZ-a copy 1, 2 AZ-b copy 3, 4 AZ-c copy 5, 6 Commit needs 4 of 6. Reads need 3 of 6. Lose a whole AZ plus one more copy and the volume still takes writes. redo page reads THE ENDPOINTS cluster — always the writer reader — DNS round-robin, readers only custom — your own subset, e.g. analytics DNS TTL is 5 seconds. A driver that caches DNS forever will not fail over. Failover: nothing is copied, so it is fast t = 0s writer dies ~5–10s reader promoted, tier 0 first ~15s cluster DNS now points at it ~30s your pool has reconnected WHAT YOUR APPLICATION SEES IN THAT WINDOW In-flight transactions are lost — the writer is gone, so they were never committed. Existing pooled connections do not heal: they are TCP sessions to a machine that is now a reader, and writes through them fail with “cannot execute in a read-only transaction”. The pool has to be told to discard them.
The last line is the one that turns a 30-second blip into a 30-minute outage. Aurora finishes failing over in well under a minute; applications routinely take far longer because their connection pool holds sockets to the demoted instance and nothing invalidates them. The fix is in the next topic, and it is four lines long.
Serverless v2Capacity in ACUs, roughly 2 GiB of memory each, scaling in place without dropping connections. It can scale to zero, at the price of a resume delay on the next connection.
CloningCopy-on-write on the storage volume: a full-size test cluster in minutes, paying only for pages that then diverge. The best staging trick AWS sells.
Global DatabaseStorage-level replication to another region, typically under a second behind, with managed planned switchover and unplanned promotion in minutes.
I/O-OptimizedA flat compute-and-storage price with no per-request I/O charge. Worth switching to once I/O passes roughly a quarter of the cluster bill.
BehaviourRDS PostgresAurora Postgres
Write pathPages + WAL to an EBS volume, checkpoints, full-page writesRedo records only, to six storage nodes. No checkpoint, no full-page writes.
Failover60–120s instance, under 35s for a Multi-AZ clusterTypically 10–30s — nothing is copied, only promoted
ReplicasUp to 15, asynchronous, each with its own storage copy15, reading the same volume, lag in milliseconds
Storage growthProvisioned; you pay for allocated spaceAutomatic in 10 GiB steps to 128 TiB; you pay for used
Restore a copyFull restore from snapshot, minutes to hoursClone in minutes, copy-on-write
Cost shapeInstance + provisioned storage + provisioned IOPSInstance + used storage + per million I/Os, unless I/O-Optimized
ExtensionsWide support, close to community PostgresSlightly behind, and some extensions never arrive

The Data API: SQL over HTTPS, no pool at all

Aurora — provisioned and Serverless v2 alike — exposes an HTTP endpoint that runs statements with IAM auth and credentials from Secrets Manager. There is no connection to manage, which makes it the right answer for Lambda — and the wrong answer for a chatty request path, because every statement is a signed HTTPS round trip.

// Data API: no driver, no pool, no VPC attachment. Each call is signed HTTPS.
rd := rdsdata.NewFromConfig(cfg)

const clusterARN = "arn:aws:rds:eu-west-1:1234:cluster:orders"
const secretARN = "arn:aws:secretsmanager:eu-west-1:1234:secret:orders-db"

out, err := rd.ExecuteStatement(ctx, &rdsdata.ExecuteStatementInput{
    ResourceArn: aws.String(clusterARN),
    SecretArn:   aws.String(secretARN),
    Database:    aws.String("orders"),
    Sql:         aws.String(`INSERT INTO orders (id, email, total_cents)
                             VALUES (:id, :email, :total)
                             ON CONFLICT (id) DO NOTHING
                             RETURNING created_at`),
    Parameters: []types.SqlParameter{                     // never string-concatenate SQL
        {Name: aws.String("id"), Value: &types.FieldMemberStringValue{Value: id}},
        {Name: aws.String("email"), Value: &types.FieldMemberStringValue{Value: email}},
        {Name: aws.String("total"), Value: &types.FieldMemberLongValue{Value: cents}},
    },
    FormatRecordsAs: types.RecordsFormatTypeJson,          // JSON rows, not column soup
})
if err != nil {
    return err
}
if out.NumberOfRecordsUpdated == 0 {
    return ErrAlreadyExists                                // the ON CONFLICT branch
}

// Multi-statement work needs an explicit transaction: BeginTransaction returns
// an id you pass to each call, then Commit or Rollback. An abandoned
// transaction is rolled back after about three minutes idle — do not rely on it.
tx, err := rd.BeginTransaction(ctx, &rdsdata.BeginTransactionInput{
    ResourceArn: aws.String(clusterARN), SecretArn: aws.String(secretARN),
    Database: aws.String("orders"),
})
# Data API: no driver, no pool, no VPC attachment. Each call is signed HTTPS.
DATA = boto3.client("rds-data", config=CFG)

CLUSTER = "arn:aws:rds:eu-west-1:1234:cluster:orders"
SECRET = "arn:aws:secretsmanager:eu-west-1:1234:secret:orders-db"

res = DATA.execute_statement(
    resourceArn=CLUSTER,
    secretArn=SECRET,
    database="orders",
    sql="""INSERT INTO orders (id, email, total_cents)
           VALUES (:id, :email, :total)
           ON CONFLICT (id) DO NOTHING
           RETURNING created_at""",
    parameters=[                                   # never string-format SQL
        {"name": "id", "value": {"stringValue": order_id}},
        {"name": "email", "value": {"stringValue": email}},
        {"name": "total", "value": {"longValue": cents}},
    ],
    formatRecordsAs="JSON",                        # rows as JSON, not column soup
)
if res["numberOfRecordsUpdated"] == 0:
    raise AlreadyExists                            # the ON CONFLICT branch

# Multi-statement work needs an explicit transaction: begin_transaction returns
# an id you pass to each call, then commit or rollback. An abandoned
# transaction is rolled back after about three minutes idle — do not rely on it.
tx = DATA.begin_transaction(resourceArn=CLUSTER, secretArn=SECRET, database="orders")
try:
    DATA.execute_statement(resourceArn=CLUSTER, secretArn=SECRET,
                           transactionId=tx["transactionId"], sql="…")
    DATA.commit_transaction(resourceArn=CLUSTER, secretArn=SECRET,
                            transactionId=tx["transactionId"])
except Exception:
    DATA.rollback_transaction(resourceArn=CLUSTER, secretArn=SECRET,
                              transactionId=tx["transactionId"])
    raise

Rehearse the failover, in a script

A failover you have never triggered deliberately is a failover you do not know the cost of. Run this against staging monthly with traffic flowing, and watch your own error rate rather than the console.

// Game day: promote a specific reader and time what the application sees.
start := time.Now()
_, err := c.FailoverDBCluster(ctx, &rds.FailoverDBClusterInput{
    DBClusterIdentifier:        aws.String("orders"),
    TargetDBInstanceIdentifier: aws.String("orders-reader-1"),   // pick, do not guess
})
if err != nil {
    return err
}

// The cluster reports "available" almost at once; what matters is when a
// write through the cluster endpoint succeeds again. Measure THAT.
for {
    if _, err := pool.Exec(ctx, "SELECT pg_is_in_recovery()"); err == nil {
        var readOnly bool
        _ = pool.QueryRow(ctx, "SELECT pg_is_in_recovery()").Scan(&readOnly)
        if !readOnly {
            log.Info("writable again", "after", time.Since(start))
            break
        }
    }
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-time.After(500 * time.Millisecond):
    }
}
# Game day: promote a specific reader and time what the application sees.
start = time.monotonic()
RDS.failover_db_cluster(
    DBClusterIdentifier="orders",
    TargetDBInstanceIdentifier="orders-reader-1",     # pick, do not guess
)

# The cluster reports "available" almost at once; what matters is when a
# write through the cluster endpoint succeeds again. Measure THAT.
while True:
    try:
        with engine.connect() as conn:
            read_only = conn.exec_driver_sql("SELECT pg_is_in_recovery()").scalar()
        if not read_only:
            log.info("writable again", extra={"after_s": time.monotonic() - start})
            break
    except OperationalError:
        pass                       # expected: the old writer is gone
    time.sleep(0.5)

Aurora DSQL is a different product with a familiar name

Aurora DSQL is a serverless, distributed, active-active SQL database with a Postgres-compatible wire protocol and optimistic concurrency control. It is not “Aurora, but more so”: there are no instances to size, no failover, and no long-lived transactions holding locks — conflicting writes are rejected at commit and your code must retry them. Reach for it for multi-region write workloads, not as a drop-in for an existing Aurora cluster.

AWS · 06

Connecting to it without falling over

Three application-side decisions determine whether a database incident is thirty seconds or thirty minutes: how many connections you open, how you authenticate, and what your pool does when the server on the other end changes identity.

THE ARITHMETIC NOBODY DOES UNTIL IT BREAKS 40 TASKS each with its own process and its own pool × POOL MAX 25 a number somebody copied from a blog post = 1,000 CONNECTIONS against a db.r7g.large that allows about 1,700 AND THEN a deploy doubles it for ninety seconds Each Postgres connection is a process with its own memory. The limit is not a quota you can raise your way out of — it is RAM. What the pool must do during a failover t = 0s writer demoted sockets stay open t + 1s writes fail: read-only transaction, SQLSTATE 25006 t + 15s DNS now resolves to the new writer — for NEW sockets t + 16s you discard the pool and reconnect — or you do not RDS PROXY EARNS ITS FEE HERE It holds the database connections, multiplexes many clients onto few, follows the failover for you, and speaks IAM auth natively. …UNTIL YOU PIN IT Session state — SET, temp tables, advisory locks, server-side prepared statements — pins a client to one backend, and the multiplexing stops.
Pinning is why teams conclude “RDS Proxy did nothing”. The DatabaseConnectionsCurrentlySessionPinned metric tells you the truth in one number. In Go with pgx, the usual culprit is the extended query protocol: set default_query_exec_mode=exec behind a transaction-mode proxy. In Python with SQLAlchemy it is usually a SET in a connect event.
// pgxpool, sized deliberately, with IAM auth and a failover-aware health check.
cfgp, err := pgxpool.ParseConfig(
    "host=orders.cluster-abc.eu-west-1.rds.amazonaws.com port=5432 " +
        "user=orders_app dbname=orders sslmode=verify-full " +
        "sslrootcert=/etc/ssl/certs/rds-global-bundle.pem")
if err != nil {
    return err
}

// Sizing: (max_connections * 0.8) / number of tasks, then leave headroom for a
// deploy running twice the tasks. Bigger is not better — it just queues later.
cfgp.MaxConns = 10
cfgp.MinConns = 2
cfgp.MaxConnLifetime = 30 * time.Minute      // forces rebalancing after a failover
cfgp.MaxConnIdleTime = 5 * time.Minute
cfgp.HealthCheckPeriod = 30 * time.Second

// IAM auth: the password is a signed, 15-minute token, generated per connection.
// No secret to rotate, and every connection is attributable in CloudTrail.
cfgp.BeforeConnect = func(ctx context.Context, c *pgx.ConnConfig) error {
    tok, err := auth.BuildAuthToken(ctx, c.Host+":5432", "eu-west-1",
        c.User, cfg.Credentials)
    if err != nil {
        return err
    }
    c.Password = tok
    return nil
}

// The four lines that turn a 30-minute outage into a 30-second one: refuse to
// hand out a connection to an instance that is no longer the writer.
cfgp.BeforeAcquire = func(ctx context.Context, c *pgx.Conn) bool {
    var inRecovery bool
    if err := c.QueryRow(ctx, "SELECT pg_is_in_recovery()").Scan(&inRecovery); err != nil {
        return false                          // broken: destroy it
    }
    return !inRecovery                        // demoted: destroy it, reconnect via DNS
}

pool, err := pgxpool.NewWithConfig(ctx, cfgp)
# SQLAlchemy, sized deliberately, with IAM auth and a failover-aware check.
from sqlalchemy import create_engine, event, text
from sqlalchemy.exc import DisconnectionError

HOST = "orders.cluster-abc.eu-west-1.rds.amazonaws.com"

engine = create_engine(
    f"postgresql+psycopg://orders_app@{HOST}:5432/orders",
    # Sizing: (max_connections * 0.8) / workers, with headroom for a deploy
    # running twice the tasks. pool_size + max_overflow is the real ceiling.
    pool_size=8,
    max_overflow=2,
    pool_timeout=3,            # fail fast; do not queue behind a saturated pool
    pool_recycle=1800,         # forces rebalancing after a failover
    pool_pre_ping=True,        # cheap SELECT 1 before handing a connection out
    connect_args={"sslmode": "verify-full",
                  "sslrootcert": "/etc/ssl/certs/rds-global-bundle.pem"},
)


@event.listens_for(engine, "do_connect")
def _iam_token(dialect, conn_rec, cargs, cparams):
    # IAM auth: the password is a signed, 15-minute token, per connection.
    # No secret to rotate, and every connection is attributable in CloudTrail.
    cparams["password"] = RDS.generate_db_auth_token(
        DBHostname=HOST, Port=5432, DBUsername="orders_app", Region="eu-west-1"
    )


@event.listens_for(engine, "checkout")
def _reject_demoted(dbapi_conn, conn_rec, conn_proxy):
    # The check that turns a 30-minute outage into a 30-second one: never hand
    # out a connection to an instance that is no longer the writer.
    with dbapi_conn.cursor() as cur:
        cur.execute("SELECT pg_is_in_recovery()")
        if cur.fetchone()[0]:
            raise DisconnectionError("demoted to reader")   # pool discards + retries
DecisionChooseReasoning
Where the pool livesIn the process, small — plus RDS Proxy if you have Lambda or many tasksLambda cannot pool across invocations; hundreds of short-lived environments each opening one connection is exactly what Proxy exists for.
Password or IAMIAM for services, Secrets Manager for anything that cannot use itIAM tokens last 15 minutes and leave a CloudTrail entry per connection. There is nothing to leak and nothing to rotate.
Reads on the reader endpointA second, explicitly-named pool — never automatic routingReplica lag is real. “Read your own writes” breaks silently and only under load, which is the worst way to find out.
Retry on failoverRetry the transaction, not the statement, and only if it is idempotentA retried INSERT after a lost commit is a duplicate order. Use a unique key or a conditional write to make it safe.
TLSverify-full with the regional CA bundle, pinned in the imagerequire encrypts but does not authenticate the server. The bundle expires — RDS certificate rotation is an announced, dated event.

The rule that survives every architecture

Connections are the scarcest resource in the system and the least elastic. Everything else in this part — Proxy, IAM auth, pool sizing, reader endpoints, Serverless v2 — is a way of spending fewer of them or recovering faster when they all break at once. If you only take one number away: work out tasks × pool_max at peak during a deploy, and compare it with your instance’s real limit before you ship.

Part three

Key-value, objects and cache

DynamoDB and S3 are the two services that will still be under your architecture in ten years, and both punish the same mistake: designing for the data you have rather than the access pattern you will run against it a million times a day.

AWS · 07

DynamoDB: the partition key is the design

Every DynamoDB property — the latency, the throughput ceiling, the queries you can and cannot run, the bill — follows from one decision: what you hash to choose a partition. Change your mind later and it is a migration, not an ALTER TABLE.

ONE WRITE, FROM KEY TO PARTITION PutItem pk = TENANT#42 hash(pk) no choice, no skew fix partition A · 8% partition B · 7% partition D · 6% partition C · 79% CEILING PER PARTITION: 3,000 RCU / 1,000 WCU Table capacity is irrelevant once one key is hot. Adaptive capacity moves throughput towards a busy partition and isolates hot items — but it cannot split a single key. Shard it yourself: TENANT#42#<0-9>. Inside a partition, and beside the table SORT KEY — THE ONLY ORDERING YOU GET pk=ORDER#9001   sk=META         status, total pk=ORDER#9001   sk=ITEM#001    sku, qty pk=ORDER#9001   sk=EVENT#2026-08-25   shipped One Query, begins_with(sk, “ITEM#”), returns the whole order in one round trip. That is single-table design. GLOBAL SECONDARY INDEX — A SECOND TABLE Its own partition key, its own capacity, its own partitions. Updated asynchronously, so reads from it are eventually consistent — always. Every base write that touches a projected attribute writes here too. Project KEYS_ONLY unless you have measured the extra fetch, and remember: a throttled GSI throttles writes to the base table.
“It scales infinitely” is true of the table and false of any single key. Tenant identifiers, dates, status flags and country codes all look like reasonable partition keys and all produce the picture above. If a key must be hot, add a suffix you control — then fan the read across the suffixes, paying a few extra reads to buy unlimited writes.
Capacity units1 WCU = one 1 KB write. 1 RCU = one 4 KB strongly consistent read, or two eventually consistent. Transactions cost double, both ways.
Hard limitsItem 400 KB. Query or Scan page 1 MB before your filter runs. BatchWriteItem 25 items, TransactWriteItems 100.
On-demandPay per request, no planning, instant doubling of the previous peak. Roughly 3–4× the unit price of provisioned since the 2024 price cut — and worth it below about 30% steady utilisation.
StreamsA 24-hour ordered log per partition key, with old and new images. The hook for search indexing, audit trails and outbox delivery.

Write like the retry is guaranteed

Two idioms cover most correctness problems: a condition expression that makes a create idempotent, and a version attribute that makes an update safe against concurrent writers. Both are free — a failed condition costs capacity but never corrupts.

// Optimistic locking: succeed only if the item is still at the version we read.
// A lost race returns ConditionalCheckFailed, which is a normal outcome here.
upd := expression.Set(expression.Name("status"), expression.Value("shipped")).
    Set(expression.Name("version"), expression.Value(cur.Version+1)).
    Set(expression.Name("updated_at"), expression.Value(time.Now().UTC().Format(time.RFC3339)))

cond := expression.Name("version").Equal(expression.Value(cur.Version))

expr, err := expression.NewBuilder().WithUpdate(upd).WithCondition(cond).Build()
if err != nil {
    return err
}

_, err = app.ddb.UpdateItem(ctx, &dynamodb.UpdateItemInput{
    TableName: aws.String("orders"),
    Key: map[string]types.AttributeValue{
        "pk": &types.AttributeValueMemberS{Value: "ORDER#" + id},
        "sk": &types.AttributeValueMemberS{Value: "META"},
    },
    UpdateExpression:          expr.Update(),
    ConditionExpression:       expr.Condition(),
    ExpressionAttributeNames:  expr.Names(),      // handles reserved words for you
    ExpressionAttributeValues: expr.Values(),
    ReturnValues:              types.ReturnValueAllNew,
})

var stale *types.ConditionalCheckFailedException
if errors.As(err, &stale) {
    return ErrConcurrentUpdate                     // re-read and decide, do not blind-retry
}

// Idempotent create: the same request twice leaves one item and no error.
_, err = app.ddb.PutItem(ctx, &dynamodb.PutItemInput{
    TableName:           aws.String("orders"),
    Item:                item,
    ConditionExpression: aws.String("attribute_not_exists(pk)"),
})
# Optimistic locking: succeed only if the item is still at the version we read.
# A lost race raises ConditionalCheckFailed, which is a normal outcome here.
try:
    DDB.update_item(
        TableName="orders",
        Key={"pk": {"S": f"ORDER#{order_id}"}, "sk": {"S": "META"}},
        UpdateExpression=(
            "SET #s = :new, version = :next, updated_at = :now"
        ),
        ConditionExpression="version = :cur",
        # "status" is a reserved word — every table hits this eventually
        ExpressionAttributeNames={"#s": "status"},
        ExpressionAttributeValues={
            ":new": {"S": "shipped"},
            ":cur": {"N": str(current_version)},
            ":next": {"N": str(current_version + 1)},
            ":now": {"S": datetime.now(timezone.utc).isoformat()},
        },
        ReturnValues="ALL_NEW",
    )
except DDB.exceptions.ConditionalCheckFailedException:
    raise ConcurrentUpdate      # re-read and decide; do not blind-retry

# Idempotent create: the same request twice leaves one item and no error.
try:
    DDB.put_item(
        TableName="orders", Item=item,
        ConditionExpression="attribute_not_exists(pk)",
    )
except DDB.exceptions.ConditionalCheckFailedException:
    pass                        # already created — that is success

Read the page, not the table

A Query stops at 1 MB of scanned data and hands back a cursor. A FilterExpression runs after that limit, so filtering never saves you capacity — it only saves you bandwidth. And a batch write is not a transaction: it can partially succeed, and the items it refused come back for you to send again.

// Paginated query over one partition, newest first, only the fields we need.
kc := expression.Key("pk").Equal(expression.Value("ORDER#"+id)).
    And(expression.Key("sk").BeginsWith("EVENT#"))
expr, _ := expression.NewBuilder().
    WithKeyCondition(kc).
    WithProjection(expression.NamesList(
        expression.Name("sk"), expression.Name("kind"))).
    Build()

p := dynamodb.NewQueryPaginator(app.ddb, &dynamodb.QueryInput{
    TableName:                 aws.String("orders"),
    KeyConditionExpression:    expr.KeyCondition(),
    ProjectionExpression:      expr.Projection(),
    ExpressionAttributeNames:  expr.Names(),
    ExpressionAttributeValues: expr.Values(),
    ScanIndexForward:          aws.Bool(false),      // descending by sort key
    Limit:                     aws.Int32(100),       // items per page, not a total
})
for p.HasMorePages() {
    page, err := p.NextPage(ctx)
    if err != nil {
        return err
    }
    // An empty page with a cursor is normal: 1 MB scanned, nothing matched.
    var events []Event
    if err := attributevalue.UnmarshalListOfMaps(page.Items, &events); err != nil {
        return err
    }
    out = append(out, events...)
}

// BatchWriteItem is NOT atomic. Unprocessed items are the normal case under
// load, and dropping them silently loses data.
req := map[string][]types.WriteRequest{"orders": writes}
for attempt := 0; len(req) > 0 && attempt < 5; attempt++ {
    out, err := app.ddb.BatchWriteItem(ctx, &dynamodb.BatchWriteItemInput{
        RequestItems: req,
    })
    if err != nil {
        return err
    }
    req = out.UnprocessedItems
    if len(req) > 0 {
        time.Sleep(time.Duration(1<<attempt) * 50 * time.Millisecond)   // back off
    }
}
if len(req) > 0 {
    return fmt.Errorf("gave up with %d unwritten items", len(req))
}
# Paginated query over one partition, newest first, only the fields we need.
pages = DDB.get_paginator("query").paginate(
    TableName="orders",
    KeyConditionExpression="pk = :pk AND begins_with(sk, :prefix)",
    ExpressionAttributeValues={
        ":pk": {"S": f"ORDER#{order_id}"},
        ":prefix": {"S": "EVENT#"},
    },
    ProjectionExpression="sk, kind",
    ScanIndexForward=False,                # descending by sort key
    PaginationConfig={"PageSize": 100},    # items per request, not a total
)
events = []
for page in pages:
    # An empty page with a cursor is normal: 1 MB scanned, nothing matched.
    events.extend(page.get("Items", []))

# BatchWriteItem is NOT atomic. Unprocessed items are the normal case under
# load, and dropping them silently loses data.
pending = {"orders": writes}
for attempt in range(5):
    res = DDB.batch_write_item(RequestItems=pending)
    pending = res.get("UnprocessedItems") or {}
    if not pending:
        break
    time.sleep(0.05 * 2 ** attempt)        # back off before resending
else:
    raise IncompleteWrite(f"{len(pending.get('orders', []))} items unwritten")
You wantUseNotBecause
All rows for one entityQuery on the partition keyScan with a filterScan reads every partition and bills you for all of it, then throws most of it away.
A second access patternA GSI, or a second item type in the same partitionA second table kept in sync by your codeThe index is maintained transactionally with the write. Your synchroniser is not, and will drift.
Two items to change togetherTransactWriteItemsTwo PutItem callsAll-or-nothing across up to 100 items, at 2× the capacity. Cheaper than a reconciliation job.
Strong consistencyA base-table read with ConsistentReadA GSI readGSIs are never strongly consistent, whatever the parameter you pass.
To delete old dataTTL attribute, epoch secondsA scheduled delete jobTTL is free and does not consume write capacity. It deletes within about 48 hours — it is a cleanup, not a deadline.
Sub-millisecond readsDAX, or a read-through cacheProvisioning more RCUDynamoDB is single-digit milliseconds by design. Capacity buys throughput, never latency.

The three metrics to alarm on

ThrottledRequests and the per-operation *Throttle* counters catch capacity problems. SuccessfulRequestLatency catches item-size and index problems. And ConsumedWriteCapacityUnits on each GSI compared with the base table catches the projection you over-specified — usually the difference between a reasonable bill and a strange one.

AWS · 08

S3: a flat namespace with strong opinions

There are no directories, no rename, and no partial update: an object is written whole or not at all. Everything S3 gives you — versioning, lifecycle, presigned access, event notification — is built on that immutability.

ONE KEY, OVER TIME — s3://orders-archive/2026/08/9001.json MULTIPART parts ≥ 5 MiB, in parallel, retried alone VERSION v1 STANDARD · readable immediately, always VERSION v2 a PUT never edits — it adds DELETE MARKER GET now 404s, but you still pay for v1, v2 EVENT to SQS, at least once, unordered Lifecycle is the only thing that ever removes data. Nothing above expires on its own. The four lifecycle rules every bucket should have ABORT INCOMPLETE Failed multipart uploads are invisible in a listing and billed forever. Seven days. Free money. EXPIRE OLD VERSIONS Versioning without this rule is a bucket that only grows. Keep 30–90 days of noncurrent versions. TRANSITION Standard → IA at 30 days, Glacier later — but only for objects big enough to beat the per-object fee. OR: INTELLIGENT-TIERING A small monitoring fee per object, and no retrieval charges or minimum durations.
The abort rule is the most commonly missing configuration in AWS. A client that dies halfway through a large upload leaves parts that no ListObjects will ever show you, and they are charged at full Standard rates indefinitely. One lifecycle rule, seven days, every bucket, no exceptions.
ConceptWhat is actually true
ConsistencyStrong read-after-write for PUT, DELETE and LIST, in all regions, with no performance penalty. The old “eventually consistent, add a retry” advice is obsolete.
PrefixesNot folders — just key strings. Rate limits scale per prefix: 3,500 writes and 5,500 reads per second, automatically partitioned. Randomised key prefixes are no longer necessary, but a single hot prefix still throttles.
Conditional writesIf-None-Match: * makes a PUT fail if the key exists — a real compare-and-swap, and the basis for a leader lock or a write-once ledger. If-Match on an ETag gives you optimistic concurrency.
ETagsAn MD5 for a single-part upload; for multipart it is a hash of hashes with a -N suffix. Never use it as a content checksum — ask for a CRC32C instead.
EncryptionSSE-S3 is on by default for every new object. SSE-KMS adds an audited, revocable key — enable bucket keys with it, or a busy prefix will generate a KMS call per object and a surprising KMS bill.
Public accessBlock Public Access is on by default for every new bucket, and ACLs are disabled — but the account-level setting is still yours to switch on. If you need public reads, put CloudFront in front with an origin access control instead of opening the bucket.
Express One ZoneSingle-AZ, directory buckets, several times the storage price but cheaper requests, and roughly a tenth of the latency. For shuffle and scratch data, not for anything you need to survive an AZ.

Presign the upload, and never proxy the bytes

The default architecture for user uploads is wrong: bytes flow through your service, consuming memory, request time and NAT charges. A presigned URL moves them directly to S3 with a signature that constrains the method, the key and the expiry — and the conditional PUT below makes the write idempotent as well.

// A presigned PUT: the browser uploads straight to S3, we never see the bytes.
ps := s3.NewPresignClient(app.s3)

req, err := ps.PresignPutObject(ctx, &s3.PutObjectInput{
    Bucket:      aws.String("orders-uploads"),
    Key:         aws.String("tenant/" + tenantID + "/" + uuid.NewString()),
    ContentType: aws.String("application/pdf"),
    // Signed headers are constraints: a client that sends a different
    // content type or length gets a 403, not a surprise object. Note that
    // this pins an EXACT length; a MAXIMUM needs a presigned POST, below.
    ContentLength:        aws.Int64(size),
    ServerSideEncryption: types.ServerSideEncryptionAwsKms,
    SSEKMSKeyId:          aws.String(kmsKeyARN),
}, s3.WithPresignExpires(10*time.Minute))       // bounded by the caller’s own session
if err != nil {
    return err
}
return json.NewEncoder(w).Encode(map[string]string{"url": req.URL})

// Write-once semantics without a lock: If-None-Match fails if the key exists,
// so two workers racing to produce the same artefact cannot overwrite it.
_, err = app.s3.PutObject(ctx, &s3.PutObjectInput{
    Bucket:      aws.String("orders-archive"),
    Key:         aws.String(key),
    Body:        bytes.NewReader(payload),
    IfNoneMatch: aws.String("*"),
    ChecksumAlgorithm: types.ChecksumAlgorithmCrc32c,   // verified end to end
})
// S3 does not model 412 as a typed error in the Go SDK, so match the code.
var api smithy.APIError
if errors.As(err, &api) && api.ErrorCode() == "PreconditionFailed" {
    return ErrAlreadyWritten        // somebody else got there; that is fine
}
# A presigned PUT: the browser uploads straight to S3, we never see the bytes.
url = S3.generate_presigned_url(
    "put_object",
    Params={
        "Bucket": "orders-uploads",
        "Key": f"tenant/{tenant_id}/{uuid4()}",
        "ContentType": "application/pdf",
        # Signed parameters are constraints: a client that sends a different
        # content type gets a 403, not a surprise object.
        "ServerSideEncryption": "aws:kms",
        "SSEKMSKeyId": KMS_KEY_ARN,
    },
    ExpiresIn=600,           # bounded by the caller’s own session length
)

# For browser form uploads, presigned POST is better: it can enforce a
# maximum size, which a presigned PUT cannot.
post = S3.generate_presigned_post(
    Bucket="orders-uploads", Key=f"tenant/{tenant_id}/${{filename}}",
    Conditions=[["content-length-range", 1, 20 * 1024 * 1024],
                {"Content-Type": "application/pdf"}],
    ExpiresIn=600,
)

# Write-once semantics without a lock: If-None-Match fails if the key exists,
# so two workers racing to produce the same artefact cannot overwrite it.
try:
    S3.put_object(
        Bucket="orders-archive", Key=key, Body=payload,
        IfNoneMatch="*",
        ChecksumAlgorithm="CRC32C",      # verified end to end
    )
except ClientError as e:
    if e.response["Error"]["Code"] != "PreconditionFailed":
        raise
    # somebody else got there; that is fine

Big objects: let the transfer manager do it

// The uploader splits, parallelises and retries individual parts. It also
// aborts the multipart upload on failure — the one thing hand-rolled code
// forgets, and the reason the abort lifecycle rule exists.
up := manager.NewUploader(app.s3, func(u *manager.Uploader) {
    u.PartSize = 16 * 1024 * 1024      // 16 MiB parts; minimum is 5 MiB
    u.Concurrency = 8
})
_, err := up.Upload(ctx, &s3.PutObjectInput{
    Bucket:       aws.String("orders-archive"),
    Key:          aws.String(key),
    Body:         file,                // any io.Reader — no need to know the size
    StorageClass: types.StorageClassIntelligentTiering,
})

// Downloading a slice of a large object beats downloading all of it:
// a range GET is billed on bytes transferred, not on object size.
obj, err := app.s3.GetObject(ctx, &s3.GetObjectInput{
    Bucket: aws.String("orders-archive"),
    Key:    aws.String(key),
    Range:  aws.String("bytes=0-8191"),      // just the header of the file
})
# upload_fileobj splits, parallelises and retries individual parts. It also
# aborts the multipart upload on failure — the one thing hand-rolled code
# forgets, and the reason the abort lifecycle rule exists.
from boto3.s3.transfer import TransferConfig

TRANSFER = TransferConfig(
    multipart_threshold=16 * 1024 * 1024,   # switch to multipart above this
    multipart_chunksize=16 * 1024 * 1024,   # minimum part size is 5 MiB
    max_concurrency=8,
    use_threads=True,
)

with open(path, "rb") as fh:
    S3.upload_fileobj(
        fh, "orders-archive", key,
        ExtraArgs={"StorageClass": "INTELLIGENT_TIERING"},
        Config=TRANSFER,
    )

# Downloading a slice of a large object beats downloading all of it:
# a range GET is billed on bytes transferred, not on object size.
head = S3.get_object(
    Bucket="orders-archive", Key=key, Range="bytes=0-8191"
)["Body"].read()                            # just the header of the file

Events are at-least-once and unordered

An S3 notification can arrive twice and out of order, and a rapid overwrite can deliver the second event first. Carry the versionId in the event and fetch that version, or make the handler idempotent on the key. Routing events through EventBridge instead gives you filtering, replay and multiple consumers for the same cost.

Requests are not free

Roughly $0.0004 per thousand GETs sounds like nothing until a job lists a million-key prefix in a loop. On a bucket of small objects, request charges and per-object overheads regularly exceed storage. Aggregate small files before you archive them.

AWS · 09

ElastiCache: a cache is a load-bearing dependency

Adding a cache changes your failure modes as much as your latency. The two questions that matter are what happens on a miss storm, and what happens when the cache itself is gone.

ONE POPULAR KEY EXPIRES, 500 REQUESTS IN FLIGHT WITHOUT PROTECTION 500 misses 500 queries pool empty The database was fine a second ago. Now every request in the fleet is queued behind the same missing key, and the cache filling up does not drain the queue. WITH SINGLE FLIGHT 500 misses 1 query 499 wait One caller per key does the work; the rest wait on its result. Add jitter to the TTL so a batch written together does not expire together. What you are actually buying CLUSTER MODE DISABLED One shard: a primary plus up to five replicas. Simple clients work. Ceiling is one node’s memory and one core for the main thread. CLUSTER MODE ENABLED Keys hashed into 16,384 slots across shards. Scales sideways. Your client must follow MOVED, and multi-key ops need hash tags. THE METRICS THAT MATTER Evictions above zero means the cache is too small for its TTLs. EngineCPUUtilization, not CPU: one thread does the real work.
A cache that fails closed converts a cache outage into a total outage. Every call into the cache needs a timeout in the tens of milliseconds and an error path that goes to the origin, and the origin needs enough capacity to survive that. If it does not, you no longer have a cache — you have a database with an availability multiplier attached.
// Cache-aside with single flight and fail-open. golang.org/x/sync/singleflight
// collapses concurrent misses of the same key into one origin call.
type Cache struct {
    rdb *redis.Client
    sf  singleflight.Group
}

func (c *Cache) Order(ctx context.Context, id string) (*Order, error) {
    key := "order:" + id

    // A cache read gets a much tighter deadline than the request itself.
    rctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
    defer cancel()

    if b, err := c.rdb.Get(rctx, key).Bytes(); err == nil {
        var o Order
        if json.Unmarshal(b, &o) == nil {
            return &o, nil
        }
    } else if !errors.Is(err, redis.Nil) {
        cacheErrors.Inc()          // FAIL OPEN: log it, fall through to origin
    }

    v, err, _ := c.sf.Do(key, func() (any, error) {
        o, err := loadFromDB(ctx, id)
        if err != nil {
            return nil, err
        }
        b, _ := json.Marshal(o)
        // Jitter: 10 minutes +/- 10%, so a batch cached together does not
        // expire together and rebuild the stampede you just prevented.
        ttl := 10*time.Minute + time.Duration(rand.Int63n(int64(2*time.Minute))) - time.Minute
        _ = c.rdb.Set(ctx, key, b, ttl).Err()
        return o, nil
    })
    if err != nil {
        return nil, err
    }
    return v.(*Order), nil
}
# Cache-aside with single flight and fail-open. One asyncio task per key does
# the work; everyone else awaits the same future.
import asyncio, json, random

_inflight: dict[str, asyncio.Future] = {}


async def get_order(order_id: str) -> Order:
    key = f"order:{order_id}"

    # A cache read gets a much tighter deadline than the request itself.
    try:
        raw = await asyncio.wait_for(redis.get(key), timeout=0.05)
        if raw is not None:
            return Order(**json.loads(raw))
    except (asyncio.TimeoutError, RedisError):
        CACHE_ERRORS.inc()      # FAIL OPEN: count it, fall through to origin

    # Single flight: first caller creates the future, the rest await it.
    fut = _inflight.get(key)
    if fut is not None:
        return await asyncio.shield(fut)

    fut = asyncio.get_running_loop().create_future()
    _inflight[key] = fut
    try:
        order = await load_from_db(order_id)
        # Jitter: 10 minutes +/- 10%, so a batch cached together does not
        # expire together and rebuild the stampede you just prevented.
        ttl = int(600 * random.uniform(0.9, 1.1))
        await redis.set(key, json.dumps(order.dict()), ex=ttl)
        fut.set_result(order)
        return order
    except Exception as exc:
        fut.set_exception(exc)
        raise
    finally:
        _inflight.pop(key, None)
DecisionGuidance
EngineValkey is the default now: same protocol, materially cheaper than the Redis OSS option, and Serverless removes node sizing entirely. Memcached only if you genuinely want nothing but a hash table with multiple threads.
Eviction policyThe ElastiCache default is volatile-lru, which only evicts keys that have a TTL — so a cache holding any TTL-less keys can fill and start rejecting writes. For a pure cache, set allkeys-lru.
What to cacheExpensive reads with a tolerable staleness, not everything. A cached row that must be correct will produce a bug you debug at the wrong layer.
InvalidationWrite-through on the same code path as the write, or delete-on-write and let the next reader repopulate. Never a background job that hopes to keep up.
FailoverMulti-AZ with automatic failover takes tens of seconds and returns errors during the switch. Client-side retries plus fail-open turn that into a latency bump.
DAXOnly for DynamoDB, and it caches by request shape: an item cache plus a query cache, write-through on the item side. Microseconds instead of milliseconds, at the price of another cluster to run.
Part four

Compute: Lambda and ECS

Two ways to run your code, with different units of scaling: Lambda scales the number of concurrent invocations, ECS scales the number of long-lived processes. Almost every difference in cost, latency and operational feel follows from that.

AWS · 10

Lambda: the execution environment is the whole model

A Lambda function is not invoked — an execution environment is created, initialised, then used for many invocations, frozen between them, and eventually destroyed. Cold starts, connection reuse, background threads and half the surprises all live in that lifecycle.

ONE EXECUTION ENVIRONMENT, START TO FINISH INIT — ONCE Download, start runtime, run module-level code. INVOKE 1 handler runs billed per ms FROZEN no CPU, memory kept, not billed INVOKE 2…n same process, same globals SHUTDOWN no warning for your code Anything built during INIT is reused for free by every later invocation — and anything you start in the handler is suspended the moment you return. Three invocation models, three retry stories SYNCHRONOUS API Gateway, ALB, direct Invoke No Lambda-side retry at all. The caller sees the error, and the caller’s SDK may retry it. payload limit 6 MB ASYNCHRONOUS S3, SNS, EventBridge Lambda queues it, then retries twice with backoff. After that: on-failure destination, or gone. payload limit 256 KB EVENT SOURCE MAPPING SQS, Kinesis, DynamoDB, Kafka Lambda polls for you. A failure redelivers the whole batch unless you report partial failures. batching is the throughput dial MEMORY IS THE CPU DIAL vCPU is allocated in proportion to memory; 1,769 MB buys one full vCPU, a second near 3,538. Doubling memory on a CPU-bound function often halves the duration, which means the same bill and half the latency. Measure, do not assume.
“Frozen” is the detail that catches people. A goroutine, thread or asyncio task still running when the handler returns is suspended mid-instruction — possibly for hours, possibly for ever. Fire-and-forget logging, metric flushes and span exports must complete before you return, which is exactly what the Lambda extensions and Powertools flush hooks are for.
Reserved concurrencyBoth a floor and a ceiling for one function: it is guaranteed that much, and can never exceed it. The blast-radius control — set it on anything that talks to a database.
Provisioned concurrencyEnvironments kept initialised and warm, billed by the hour. The only real fix for cold-start-sensitive user-facing paths, and it can be scheduled or autoscaled.
SnapStartSnapshots the environment after init and restores from it — large cold-start wins for Java, Python and .NET. Your init must not bake in anything unique, such as a random seed or an open socket.
Scaling rateA function can add on the order of 1,000 concurrent executions every 10 seconds, up to the account limit. A genuine spike is throttled at the edge of that ramp, not at the account cap.

The handler shape that gets everything right

Clients built at module scope, per-invocation deadlines derived from the remaining time, and partial batch failure reporting so one poison message does not replay the other nine.

// main.go — Go runs on provided.al2023 as a custom runtime; the binary is the
// runtime. Everything above main() happens once, during INIT, with full CPU.
var (
    cfg = mustConfig()                          // credential chain resolved once
    ddb = dynamodb.NewFromConfig(cfg)           // pool survives across invocations
    log = slog.New(slog.NewJSONHandler(os.Stdout, nil))
)

func handle(ctx context.Context, ev events.SQSEvent) (events.SQSEventResponse, error) {
    // Leave headroom: finish, flush and return before Lambda kills us.
    if dl, ok := ctx.Deadline(); ok {
        var cancel context.CancelFunc
        ctx, cancel = context.WithDeadline(ctx, dl.Add(-500*time.Millisecond))
        defer cancel()
    }

    // Partial batch response: without this, ONE bad message redelivers all ten,
    // which is how a single poison record becomes a permanent replay loop.
    var failures []events.SQSBatchItemFailure

    for _, m := range ev.Records {
        if err := process(ctx, m); err != nil {
            log.Error("record failed", "message_id", m.MessageId, "err", err)
            failures = append(failures, events.SQSBatchItemFailure{
                ItemIdentifier: m.MessageId,
            })
        }
    }
    // Return nil error: the batch itself succeeded. Only the listed ids return
    // to the queue. Requires ReportBatchItemFailures on the event source mapping.
    return events.SQSEventResponse{BatchItemFailures: failures}, nil
}

func main() { lambda.Start(handle) }
# handler.py — everything at module scope runs once, during INIT, with full
# CPU. Imports and client construction belong here, not in the handler.
import json, os, time
import boto3
from aws_lambda_powertools import Logger, Metrics, Tracer
from aws_lambda_powertools.utilities.batch import (
    BatchProcessor, EventType, process_partial_response,
)

logger, tracer, metrics = Logger(), Tracer(), Metrics()
DDB = boto3.client("dynamodb")          # connection pool survives invocations
TABLE = os.environ["TABLE_NAME"]

processor = BatchProcessor(event_type=EventType.SQS)


@tracer.capture_method
def record_handler(record):
    body = json.loads(record.body)
    DDB.put_item(
        TableName=TABLE,
        Item={"pk": {"S": body["id"]}, "payload": {"S": record.body}},
        ConditionExpression="attribute_not_exists(pk)",   # at-least-once delivery
    )


@logger.inject_lambda_context
@tracer.capture_lambda_handler
@metrics.log_metrics                    # flushes EMF metrics BEFORE the freeze
def handler(event, context):
    # Partial batch response: without this, ONE bad message redelivers all ten,
    # which is how a single poison record becomes a permanent replay loop.
    # Requires ReportBatchItemFailures on the event source mapping.
    return process_partial_response(
        event=event, record_handler=record_handler,
        processor=processor, context=context,
    )
SymptomUsuallyFix
p99 far above p50, in burstsCold starts, or a scaling rampProvisioned concurrency for the user-facing path; SnapStart where the runtime supports it; shrink the deployment package.
Duration close to the timeout, then a hard killA downstream call with no deadline of its ownDerive every client timeout from the remaining execution time. A Lambda timeout gives you no stack trace and no flushed logs.
The same message processed over and overBatch failure without partial reporting, or a visibility timeout below the function timeoutTurn on ReportBatchItemFailures; set the queue’s visibility timeout to at least six times the function timeout; add a DLQ with maxReceiveCount.
Rate exceeded on the databaseLambda scaled to hundreds of concurrent environments, each opening connectionsReserved concurrency on the function, and RDS Proxy in front of the database.
Works locally, times out deployedThe function is in a VPC with no route to the internetInterface endpoints for the AWS services, and a NAT route only if you genuinely call the public internet.
Logs missing for failed invocationsBuffered output lost when the process was killedLog unbuffered and flush before returning; the Powertools logger and Go’s slog to stdout both do the right thing.

Tune memory with data, not instinct

Duration typically falls faster than price rises, up to the point where the function stops being CPU-bound. Sweep 512 MB to 3 GB against a real payload and plot cost per invocation — the minimum is often at three or four times the memory somebody guessed, and it is faster as well as cheaper.

Arm64 unless something stops you

Graviton pricing is roughly 20% lower per GB-second and the same code is usually a little faster. The only reasons to stay on x86 are a native dependency with no aarch64 wheel or binary, and a container image you have not rebuilt.

AWS · 11

ECS and Fargate: the deployment is the interesting part

A task definition is easy. What decides whether ECS is calm is the deployment configuration — the percentages, the circuit breaker, the health check grace period and the shutdown sequence, all of which have defaults that will eventually hurt.

ROLLING DEPLOYMENT — DESIRED 4, MIN 100%, MAX 200% STEP 1 · 4 old ECS starts new tasks first because min healthy is 100% capacity now 4 → up to 8 STEP 2 · HEALTH GATE Target group must see N consecutive successes before a task counts as healthy. STEP 3 · DRAIN OLD Deregister, wait out the deregistration delay, then SIGTERM the container. CIRCUIT BREAKER Repeated launch failures stop the deployment and, if enabled, roll it back. Min 100% and max 200% means a deploy briefly doubles your task count — and therefore your database connections, your ENIs, and your subnet addresses. Shutdown: four clocks that must be ordered correctly t = 0 deregistered from the target group +300s default deregistration delay ends; in-flight requests finished SIGTERM your handler runs: stop accepting, drain, close +stopTimeout SIGKILL, 30s by default — raise it if you drain GET THIS ORDER WRONG AND YOU DROP REQUESTS ON EVERY DEPLOY If the container exits on SIGTERM immediately, requests already in flight through the load balancer fail. If stopTimeout is shorter than your drain, SIGKILL truncates them instead. Both look like random 502s that only happen during deploys.
ECS gives you the timers; ordering them is your job. The safe recipe: deregistration delay slightly longer than your longest request, a SIGTERM handler that stops accepting new connections and finishes existing ones, and a stopTimeout comfortably longer than the drain. Then a deploy is invisible in your error rate.
Piece of the task definitionWhat it controlsThe detail
taskRoleArnThe IAM role your code assumesDelivered over the container credentials endpoint. This is the role your SDK picks up.
executionRoleArnThe role ECS itself usesPulls the image from ECR, writes the log stream, and reads the secrets you inject. Confusing these two is the classic first-deploy failure.
networkMode: awsvpcOne ENI, one private IP, per taskThe task gets its own security group. It also consumes a subnet address, and ENI attachment adds several seconds to task start.
secretsEnvironment variables from Secrets Manager or SSMResolved at task start by the execution role, so a rotated secret needs a new task — not a restart of your process.
Container healthCheckWhether ECS considers the container healthySeparate from the load balancer’s check. Use both: the container check catches a wedged process with no traffic on it.
stopTimeoutSIGTERM to SIGKILLDefault 30s, maximum 120s on Fargate. If your drain is longer than this, the drain does not happen.
cpu / memoryFargate’s billable sizeOnly certain combinations are valid, and the whole task is billed on the total — a fat sidecar costs the same as fat application.

Shut down properly, or drop requests forever

// The four-stage shutdown ECS expects. Nothing here is Go-specific except the
// syntax — the sequence is what matters.
srv := &http.Server{Addr: ":8080", Handler: mux}

go func() {
    if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
        log.Error("listen", "err", err)
        os.Exit(1)
    }
}()

sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
<-sig

// 1. Fail readiness first, so the load balancer stops sending new requests
//    even before ECS deregisters us. A few seconds of overlap is deliberate.
ready.Store(false)
time.Sleep(5 * time.Second)

// 2. Stop accepting, finish what is in flight. Shutdown does exactly this.
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    log.Warn("graceful shutdown timed out", "err", err)   // stopTimeout must exceed 25s
}

// 3. Then, and only then, release everything downstream.
pool.Close()
_ = tracerProvider.Shutdown(ctx)
# The four-stage shutdown ECS expects. Uvicorn already handles SIGTERM by
# draining, but the readiness flip in front of it is yours to write.
import signal, asyncio, contextlib, uvicorn

READY = True


@app.get("/readyz")
async def readyz():
    # 1. Fail readiness first, so the load balancer stops sending new requests
    #    even before ECS deregisters us. A few seconds of overlap is deliberate.
    return Response(status_code=200 if READY else 503)


# Run uvicorn programmatically, so the signal handler can hand the signal
# back to it. The graceful timeout must be SHORTER than stopTimeout.
server = uvicorn.Server(uvicorn.Config(
    app, host="0.0.0.0", port=8080,
    timeout_keep_alive=75,             # above the ALB idle timeout
    timeout_graceful_shutdown=20,      # below stopTimeout, which we set to 60
))


@contextlib.asynccontextmanager
async def lifespan(app):
    loop = asyncio.get_running_loop()

    def _term():
        global READY
        READY = False
        # Installing this handler REPLACED uvicorn’s own, so we have to pass
        # the signal on — otherwise nothing drains and ECS just SIGKILLs us.
        loop.call_later(5.0, server.handle_exit, signal.SIGTERM, None)

    loop.add_signal_handler(signal.SIGTERM, _term)
    yield
    # 2. By the time teardown runs, uvicorn has stopped accepting and has
    #    already waited out the in-flight requests.
    # 3. Then, and only then, release everything downstream.
    await engine.dispose()
    await tracer_provider.shutdown()

Deploy from code, and fail the pipeline honestly

// UpdateService returns as soon as the deployment is accepted — not when it
// is safe. The waiter is what makes a pipeline meaningful.
_, err := ecsc.UpdateService(ctx, &ecs.UpdateServiceInput{
    Cluster:            aws.String("prod"),
    Service:            aws.String("orders-api"),
    TaskDefinition:     aws.String(newTaskDefARN),
    ForceNewDeployment: aws.Bool(true),
    DeploymentConfiguration: &types.DeploymentConfiguration{
        MinimumHealthyPercent: aws.Int32(100),
        MaximumPercent:        aws.Int32(200),
        DeploymentCircuitBreaker: &types.DeploymentCircuitBreaker{
            Enable:   true,
            Rollback: true,       // revert to the last good task definition
        },
    },
})
if err != nil {
    return err
}

// "Stable" means: desired count running, one deployment, all targets healthy.
if err := ecs.NewServicesStableWaiter(ecsc).Wait(ctx, &ecs.DescribeServicesInput{
    Cluster: aws.String("prod"), Services: []string{"orders-api"},
}, 15*time.Minute); err != nil {
    // The circuit breaker may have rolled us back to the previous revision —
    // which is a successful rollback and a FAILED deploy. Say so.
    return fmt.Errorf("deployment did not stabilise: %w", err)
}
# update_service returns as soon as the deployment is accepted — not when it
# is safe. The waiter is what makes a pipeline meaningful.
ECS = boto3.client("ecs", config=CFG)

ECS.update_service(
    cluster="prod",
    service="orders-api",
    taskDefinition=new_task_def_arn,
    forceNewDeployment=True,
    deploymentConfiguration={
        "minimumHealthyPercent": 100,
        "maximumPercent": 200,
        "deploymentCircuitBreaker": {
            "enable": True,
            "rollback": True,       # revert to the last good task definition
        },
    },
)

# "Stable" means: desired count running, one deployment, all targets healthy.
try:
    ECS.get_waiter("services_stable").wait(
        cluster="prod", services=["orders-api"],
        WaiterConfig={"Delay": 15, "MaxAttempts": 60},   # 15 minutes
    )
except WaiterError as e:
    # The circuit breaker may have rolled us back to the previous revision —
    # which is a successful rollback and a FAILED deploy. Say so.
    events = ECS.describe_services(cluster="prod", services=["orders-api"])
    for ev in events["services"][0]["events"][:5]:
        log.error("ecs event", extra={"message": ev["message"]})
    raise DeployFailed("deployment did not stabilise") from e

Autoscale on the right signal

Target tracking on ALBRequestCountPerTarget beats CPU for a web service: it reacts to arriving work rather than to the symptom of work. Keep a scale-in cooldown longer than the scale-out one, and remember that scaling is bounded by how fast a task can start — image pull plus ENI attach plus your own init.

Fargate Spot is real capacity with a real catch

Around 70% cheaper, and reclaimed with a two-minute SIGTERM warning. Perfect for queue consumers that checkpoint, wrong for anything holding a long request. Mix capacity providers: a base of on-demand tasks with the elastic portion on Spot.

AWS · 12

Choosing between them, with arithmetic

The Lambda-versus-containers argument is usually settled by taste. It should be settled by two numbers: your requests per second and your p50 duration, which together tell you exactly where the crossover sits.

COST PER MONTH AS TRAFFIC GROWS cost requests / month Lambda pay per ms Fargate · 2 tasks always on crossover work it out, do not guess WHY THE LINES BEND Lambda is a straight line from zero: no idle cost, no floor. Fargate starts above zero and stays nearly flat until you add tasks. Spiky and occasional favours the first; steady and busy the second. LAMBDA 15 min ceiling · no idle cost cold starts · no daemons Events, spikes, glue, cron FARGATE no servers · per-task price warm always · any runtime Steady web services, workers EC2 / ECS ON EC2 cheapest per vCPU at scale you patch it · GPUs, local NVMe Big steady fleets, special kit EKS portable · huge ecosystem a control plane fee + a team Many teams, many services
The honest rule: pick Lambda until the arithmetic says otherwise, then move the hot path. Nothing stops one system from doing both — a Fargate service for the steady request path and Lambda for the spiky, event-driven edges is the most common shape in practice, and each part is billed on its own terms.
// Work out the crossover instead of arguing about it. Prices are illustrative
// US-East-1 list rates — substitute your own before quoting anyone.
const (
    lambdaPerGBSecond = 0.0000166667
    lambdaPerRequest  = 0.0000002
    fargateVCPUHour   = 0.04048       // x86; Graviton and Spot are cheaper
    fargateGBHour     = 0.004445
)

// One Lambda invocation: memory in GB, duration rounded up to the millisecond.
func lambdaMonthly(reqsPerMonth float64, memGB, durSec float64) float64 {
    return reqsPerMonth*(lambdaPerRequest+memGB*durSec*lambdaPerGBSecond)
}

// A Fargate task charges whether or not a request arrives.
func fargateMonthly(tasks, vcpu, memGB float64) float64 {
    return tasks * 730 * (vcpu*fargateVCPUHour + memGB*fargateGBHour)
}

// 40 ms at 512 MB, against two 0.5 vCPU / 1 GB tasks:
//   5M requests/month   Lambda $2.67   Fargate $36.04   → Lambda, easily
//   500M requests/month Lambda $267    Fargate $36.04   → containers, easily
// The crossover is near 68M requests — but only if the tasks can actually
// serve them. Capacity check first: reqs/sec x duration must fit in
// tasks x concurrency_per_task, or you are comparing against a fantasy.
fmt.Printf("%.2f vs %.2f\n",
    lambdaMonthly(5e6, 0.5, 0.04), fargateMonthly(2, 0.5, 1))
# Work out the crossover instead of arguing about it. Prices are illustrative
# US-East-1 list rates — substitute your own before quoting anyone.
LAMBDA_GB_SECOND = 0.0000166667
LAMBDA_REQUEST = 0.0000002
FARGATE_VCPU_HOUR = 0.04048        # x86; Graviton and Spot are cheaper
FARGATE_GB_HOUR = 0.004445


def lambda_monthly(reqs: float, mem_gb: float, dur_s: float) -> float:
    # One invocation: memory in GB, duration rounded up to the millisecond.
    return reqs * (LAMBDA_REQUEST + mem_gb * dur_s * LAMBDA_GB_SECOND)


def fargate_monthly(tasks: float, vcpu: float, mem_gb: float) -> float:
    # A Fargate task charges whether or not a request arrives.
    return tasks * 730 * (vcpu * FARGATE_VCPU_HOUR + mem_gb * FARGATE_GB_HOUR)


# 40 ms at 512 MB, against two 0.5 vCPU / 1 GB tasks:
#   5M requests/month   Lambda $2.67   Fargate $36.04   → Lambda, easily
#   500M requests/month Lambda $267    Fargate $36.04   → containers, easily
# The crossover is near 68M requests — but only if the tasks can actually
# serve them. Capacity check first: reqs/sec * duration must fit in
# tasks * concurrency_per_task, or you are comparing against a fantasy.
print(f"{lambda_monthly(5e6, 0.5, 0.04):.2f} vs {fargate_monthly(2, 0.5, 1):.2f}")
If this is trueChoose
Traffic is spiky, occasional, or zero most of the timeLambda — nothing else bills at zero
A request can take longer than fifteen minutesFargate or EC2; Lambda cannot, at any price
You need a persistent connection pool, or a warm in-process cacheFargate or EC2. Lambda can hold one per environment, and you do not control how many there are
Cold-start latency is visible to a humanFargate, or Lambda with provisioned concurrency — and price that concurrency honestly
The work is a reaction to an event that already exists in AWSLambda — the event source mapping is the integration you would otherwise write
You are running more than a handful of services with a platform teamEKS starts to pay for its own complexity; below that it rarely does
You need a GPU, local NVMe, or a specific kernelEC2. This is the remaining reason to manage instances
Part five

Queues, buses and streams

Three services that all “move messages” and are not interchangeable. A queue buffers work for one consumer. A bus routes one event to many. A stream keeps an ordered log you can rewind. Picking the wrong one is recoverable; pretending one is another is not.

AWS · 13

SQS: visibility timeout is the whole contract

SQS does not deliver messages — it lends them. A receive hides the message for a while; a delete ends the loan. Every SQS bug is a disagreement between how long you borrowed it for and how long you actually needed it.

ONE MESSAGE, ONE LOAN AT A TIME SendMessage up to 1 MiB since 2025 IN QUEUE 4 days by default IN FLIGHT — INVISIBLE for VisibilityTimeout seconds DeleteMessage the only way it leaves timeout expires → visible again, ReceiveCount + 1 DEAD LETTER QUEUE after maxReceiveCount tries A queue with no DLQ retries a poison message until retention expires. The arithmetic that prevents duplicate work WRONG: HANDLER 45s, VISIBILITY 30s handler visibility ends here A second consumer picks the message up while the first is still working. Both finish. Both bill. RIGHT: HEARTBEAT WHILE WORKING handler ChangeMessageVisibility every few seconds keeps the loan alive, and a crashed worker still releases it.
With Lambda, the rule of thumb is a visibility timeout at least six times the function timeout — the event source mapping needs room to retry the batch. With your own consumer, either extend the visibility while you work or keep handlers comfortably shorter than the timeout. Either way, every handler must still be idempotent: SQS is at-least-once by design, not by accident.
SettingDefaultSet it toEffect
VisibilityTimeout30sLongest realistic handler, or heartbeatToo short duplicates work; too long delays retry after a crash.
ReceiveMessageWaitTimeSeconds0 — short polling20Long polling. Cuts empty receives, cuts the bill, and lowers latency because the call returns the instant a message lands.
maxReceiveCount + DLQnone3–5, with a DLQWithout it, a message that always fails is retried until retention expires — burning your consumers for days.
MessageRetentionPeriod4 days14 days on the DLQThe DLQ is your evidence. Give yourself a fortnight to look at it.
Batch size1 in the API, 10 in Lambda10, with partial batch failuresTen messages per request is a tenfold cut in request cost. Without partial failure reporting it is also a tenfold blast radius.
FIFO MessageGroupId—Your entity idOrdering is per group, and so is parallelism: one group means one consumer at a time, however many you run.
// A consumer loop that does the three things people leave out: long polling,
// a visibility heartbeat, and a batch delete of exactly what succeeded.
for {
    out, err := app.sqs.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
        QueueUrl:              aws.String(queueURL),
        MaxNumberOfMessages:   10,
        WaitTimeSeconds:       20,          // long poll: cheaper AND lower latency
        VisibilityTimeout:     60,
        MessageAttributeNames: []string{"All"},
        // ApproximateReceiveCount tells you this is a retry — log it, and treat
        // a high count as a signal rather than a surprise.
        MessageSystemAttributeNames: []types.MessageSystemAttributeName{"ApproximateReceiveCount"},
    })
    if err != nil {
        if errors.Is(err, context.Canceled) {
            return nil                       // shutting down
        }
        log.Error("receive", "err", err)
        time.Sleep(time.Second)
        continue
    }

    var done []types.DeleteMessageBatchRequestEntry
    for _, m := range out.Messages {
        hb, stop := context.WithCancel(ctx)
        go heartbeat(hb, app.sqs, queueURL, *m.ReceiptHandle, 60)   // extend while busy

        err := handle(ctx, m)
        stop()
        if err != nil {
            log.Error("handler", "id", aws.ToString(m.MessageId), "err", err)
            continue                          // leave it: it becomes visible again
        }
        done = append(done, types.DeleteMessageBatchRequestEntry{
            Id: m.MessageId, ReceiptHandle: m.ReceiptHandle,
        })
    }

    if len(done) > 0 {
        // A batch delete can partially fail. Anything in .Failed stays in the
        // queue and WILL be delivered again — which is why handlers are idempotent.
        res, err := app.sqs.DeleteMessageBatch(ctx, &sqs.DeleteMessageBatchInput{
            QueueUrl: aws.String(queueURL), Entries: done,
        })
        if err == nil && len(res.Failed) > 0 {
            log.Warn("delete partially failed", "count", len(res.Failed))
        }
    }
}
# A consumer loop that does the three things people leave out: long polling,
# a visibility heartbeat, and a batch delete of exactly what succeeded.
import threading

QUEUE = "https://sqs.eu-west-1.amazonaws.com/1234/orders"


def heartbeat(receipt: str, every: float, timeout: int, stop: threading.Event):
    # Extend the loan while we are still working on it.
    while not stop.wait(every):
        SQS.change_message_visibility(
            QueueUrl=QUEUE, ReceiptHandle=receipt, VisibilityTimeout=timeout
        )


while running:
    res = SQS.receive_message(
        QueueUrl=QUEUE,
        MaxNumberOfMessages=10,
        WaitTimeSeconds=20,              # long poll: cheaper AND lower latency
        VisibilityTimeout=60,
        MessageAttributeNames=["All"],
        # ApproximateReceiveCount tells you this is a retry — log it, and treat
        # a high count as a signal rather than a surprise.
        MessageSystemAttributeNames=["ApproximateReceiveCount"],
    )

    done = []
    for msg in res.get("Messages", []):
        stop = threading.Event()
        t = threading.Thread(
            target=heartbeat, args=(msg["ReceiptHandle"], 20.0, 60, stop), daemon=True
        )
        t.start()
        try:
            handle(msg)
            done.append({"Id": msg["MessageId"], "ReceiptHandle": msg["ReceiptHandle"]})
        except Exception:
            log.exception("handler failed", extra={"id": msg["MessageId"]})
            # leave it: visibility expires and it comes back
        finally:
            stop.set()

    if done:
        # A batch delete can partially fail. Anything in Failed stays in the
        # queue and WILL be delivered again — hence idempotent handlers.
        out = SQS.delete_message_batch(QueueUrl=QUEUE, Entries=done)
        if out.get("Failed"):
            log.warning("delete partially failed", extra={"n": len(out["Failed"])})

The DLQ is not a graveyard

Alarm on ApproximateNumberOfMessagesVisible on the dead letter queue — any non-zero value is a bug report with the payload attached. When you have fixed the cause, the redrive API moves messages back to the source queue without a custom script.

FIFO ordering costs you parallelism

Messages in one group are delivered strictly in order, which means strictly one at a time. If your throughput target divided by your per-message time exceeds one, you need many groups — usually one per customer or entity, never one per queue.

AWS · 14

SNS and EventBridge: publish once, route many

Both take one message and give it to several places. SNS is a fast, cheap fan-out pipe with attribute filtering. EventBridge is a router that reads the message body, keeps a schema registry, and can replay last Tuesday.

ONE PUBLISH, MANY DESTINATIONS — WHO DECIDES? producer OrderPlaced SNS TOPIC filters on message attributes or body EVENTBRIDGE BUS rules match the whole event, structurally SQS · billing worker Lambda · email Step Functions another account’s bus ARCHIVE → REPLAY Keep every event, then re-emit a time range to one rule after you fix the consumer. SNS cannot. Fan-out means each subscriber gets its own copy and its own retries. A slow subscriber cannot slow the others — which is the point. Reach for which one SNS Tens of milliseconds, enormous throughput, cheapest per message. Fan-out inside one system, mobile push, and SMS or email delivery. EVENTBRIDGE Sub-second, content routing, 20+ target types, archive and replay. Cross-team and cross-account events, SaaS integrations, audit. NEITHER, ALONE Put SQS between the bus and every consumer that can fall behind. The queue is the buffer, the retry budget and the DLQ, all at once.
The default production shape is bus → queue → consumer, not bus → consumer. A Lambda subscribed straight to a topic has two retries and then the event is gone; the same Lambda behind a queue has a visibility timeout, a receive count, a dead letter queue and a backlog metric you can alarm on. The queue costs $0.40 per million requests — roughly three per delivered message — and buys you all of that.
// EventBridge: the envelope matters more than the payload. Source, DetailType
// and the fields inside Detail are what every consumer’s rule will match on,
// so they are a public interface — version them.
type OrderPlaced struct {
    Version    string `json:"version"`      // "1" — additive changes only
    OrderID    string `json:"orderId"`
    TenantID   string `json:"tenantId"`
    TotalCents int64  `json:"totalCents"`
    Currency   string `json:"currency"`
    PlacedAt   string `json:"placedAt"`
}

detail, _ := json.Marshal(OrderPlaced{Version: "1", OrderID: id, TenantID: tenant,
    TotalCents: cents, Currency: "GBP", PlacedAt: now.Format(time.RFC3339)})

out, err := app.eb.PutEvents(ctx, &eventbridge.PutEventsInput{
    Entries: []types.PutEventsRequestEntry{{
        EventBusName: aws.String("orders"),
        Source:       aws.String("com.example.orders"),   // reverse-DNS, stable
        DetailType:   aws.String("OrderPlaced"),          // the event name
        Detail:       aws.String(string(detail)),
        Resources:    []string{"arn:aws:acme:orders::" + id},
        TraceHeader:  aws.String(traceHeader),            // keeps X-Ray joined up
    }},
})
// PutEvents returns 200 even when individual entries fail. This is the single
// most-missed error path in EventBridge code.
if err != nil {
    return err
}
if out.FailedEntryCount > 0 {                   // a plain int32 here, not a pointer
    for _, e := range out.Entries {
        if e.ErrorCode != nil {
            return fmt.Errorf("putevents: %s %s",
                aws.ToString(e.ErrorCode), aws.ToString(e.ErrorMessage))
        }
    }
}
# EventBridge: the envelope matters more than the payload. Source, DetailType
# and the fields inside Detail are what every consumer’s rule will match on,
# so they are a public interface — version them.
EB = boto3.client("events", config=CFG)

detail = {
    "version": "1",                 # additive changes only
    "orderId": order_id,
    "tenantId": tenant_id,
    "totalCents": cents,
    "currency": "GBP",
    "placedAt": now.isoformat(),
}

res = EB.put_events(
    Entries=[{
        "EventBusName": "orders",
        "Source": "com.example.orders",      # reverse-DNS, stable
        "DetailType": "OrderPlaced",         # the event name
        "Detail": json.dumps(detail),
        "Resources": [f"arn:aws:acme:orders::{order_id}"],
        "TraceHeader": trace_header,         # keeps X-Ray joined up
    }]
)

# put_events returns HTTP 200 even when individual entries fail. This is the
# single most-missed error path in EventBridge code.
if res["FailedEntryCount"]:
    bad = [e for e in res["Entries"] if "ErrorCode" in e]
    raise PublishFailed(bad[0]["ErrorCode"], bad[0]["ErrorMessage"])

The rule that receives it — and the filter that saves you money

An EventBridge pattern matches the structure of the event, not a string. Anything you omit matches everything; anything you include must match exactly, unless you use one of the comparison operators. Filtering at the bus is free; filtering inside a Lambda costs an invocation.

// A rule pattern is JSON that mirrors the event. Values are always ARRAYS —
// "any of these" — which is the syntax people get wrong first.
pattern := `{
  "source": ["com.example.orders"],
  "detail-type": ["OrderPlaced"],
  "detail": {
    "currency": ["GBP", "EUR"],
    "totalCents": [{"numeric": [">=", 50000]}],
    "tenantId": [{"anything-but": ["internal-test"]}]
  }
}`

_, err := app.eb.PutRule(ctx, &eventbridge.PutRuleInput{
    Name:         aws.String("large-orders-to-review"),
    EventBusName: aws.String("orders"),
    EventPattern: aws.String(pattern),
    State:        types.RuleStateEnabled,
})

// Every target needs its own retry policy and dead letter queue: a target
// that fails without one loses the event after 24 hours of attempts.
_, err = app.eb.PutTargets(ctx, &eventbridge.PutTargetsInput{
    Rule:         aws.String("large-orders-to-review"),
    EventBusName: aws.String("orders"),
    Targets: []types.Target{{
        Id:  aws.String("review-queue"),
        Arn: aws.String(reviewQueueARN),
        RetryPolicy: &types.RetryPolicy{
            MaximumRetryAttempts:     aws.Int32(4),
            MaximumEventAgeInSeconds: aws.Int32(3600),
        },
        DeadLetterConfig: &types.DeadLetterConfig{Arn: aws.String(dlqARN)},
    }},
})
# A rule pattern is JSON that mirrors the event. Values are always LISTS —
# "any of these" — which is the syntax people get wrong first.
PATTERN = {
    "source": ["com.example.orders"],
    "detail-type": ["OrderPlaced"],
    "detail": {
        "currency": ["GBP", "EUR"],
        "totalCents": [{"numeric": [">=", 50000]}],
        "tenantId": [{"anything-but": ["internal-test"]}],
    },
}

EB.put_rule(
    Name="large-orders-to-review",
    EventBusName="orders",
    EventPattern=json.dumps(PATTERN),
    State="ENABLED",
)

# Every target needs its own retry policy and dead letter queue: a target
# that fails without one loses the event after 24 hours of attempts.
EB.put_targets(
    Rule="large-orders-to-review",
    EventBusName="orders",
    Targets=[{
        "Id": "review-queue",
        "Arn": REVIEW_QUEUE_ARN,
        "RetryPolicy": {"MaximumRetryAttempts": 4,
                        "MaximumEventAgeInSeconds": 3600},
        "DeadLetterConfig": {"Arn": DLQ_ARN},
    }],
)

# The SNS equivalent of the same idea — a subscription filter policy, which
# can read message attributes or, with FilterPolicyScope, the body itself.
SNS.set_subscription_attributes(
    SubscriptionArn=sub_arn,
    AttributeName="FilterPolicy",
    AttributeValue=json.dumps({"currency": ["GBP", "EUR"]}),
)
QuestionSNSEventBridge
LatencyTens of millisecondsSub-second, but measurably slower
FilteringMessage attributes, or the body with a policy scopeFull structural patterns: prefix, suffix, numeric ranges, anything-but, exists
ReplayNoArchive plus replay over a time range
TargetsSQS, Lambda, HTTPS, email, SMS, FirehoseTwenty-plus AWS services, API destinations, other accounts’ buses
OrderingFIFO topics to FIFO queues onlyNone — assume reordering
SchemaYours to enforceSchema registry with generated bindings, if you want them
Cost shapeCheapest per message at volumePer million events published; ingestion is what you pay for, not fan-out
AWS · 15

Kinesis Data Streams: an ordered log with a cursor

A stream is not a queue. Records are not deleted when read — each consumer keeps its own position, records stay for as long as retention says, and ordering holds within a shard. That buys replay and multiple readers, and costs you the ability to skip a bad record for free.

PARTITION KEY → SHARD → ORDER PutRecords key = orderId shard-0   | r1 | r2 | r3 | r4 | …   1 MB/s in, 2 MB/s out shard-1   | r1 | r2 | r3 | …      1,000 records/s in shard-2   | r1 | POISON | r3 | r4 | …   blocked ONE BAD RECORD STOPS THE SHARD Ordering means no skipping. Nothing after it is processed until you give up on it. Two consumers share the 2 MB/s read budget — unless each registers for enhanced fan-out and gets its own. Iterator age is the only metric that matters age time consumer slower than producer the gap never closes on its own WHAT TO DO ABOUT IT More shards, if the key spread allows. ParallelizationFactor on the Lambda mapping: up to 10 batches per shard, still ordered per partition key. Bigger batches, or a faster handler. Never: silently dropping records.
Retention turns a lag incident into a deadline. At the default 24 hours, a consumer that is eight hours behind has sixteen hours before data is lost for good. Alarm on GetRecords.IteratorAgeMilliseconds long before that — a steadily rising age is the earliest possible warning, and the only one you get.
// PutRecords is a batch of up to 500 records — and it returns 200 with
// individual failures inside. Resending the WHOLE batch duplicates the
// records that succeeded, so resend only the failed indexes.
func putAll(ctx context.Context, k *kinesis.Client, stream string,
    recs []types.PutRecordsRequestEntry) error {

    for attempt := 0; attempt < 5 && len(recs) > 0; attempt++ {
        out, err := k.PutRecords(ctx, &kinesis.PutRecordsInput{
            StreamName: aws.String(stream),
            Records:    recs,
        })
        if err != nil {
            return err
        }
        if aws.ToInt32(out.FailedRecordCount) == 0 {
            return nil
        }

        var retry []types.PutRecordsRequestEntry
        for i, r := range out.Records {
            if r.ErrorCode != nil {
                // ProvisionedThroughputExceededException — this shard is hot
                retry = append(retry, recs[i])
            }
        }
        recs = retry
        time.Sleep(time.Duration(1<<attempt) * 100 * time.Millisecond)
    }
    return fmt.Errorf("%d records never accepted", len(recs))
}

// The partition key is the ordering AND the sharding decision. orderID keeps
// each order’s events in sequence and spreads load; "orders" would put the
// entire stream on one shard.
entry := types.PutRecordsRequestEntry{
    PartitionKey: aws.String(orderID),
    Data:         payload,
}
# put_records is a batch of up to 500 records — and it returns HTTP 200 with
# individual failures inside. Resending the WHOLE batch duplicates the records
# that succeeded, so resend only the failed indexes.
KIN = boto3.client("kinesis", config=CFG)


def put_all(stream: str, records: list[dict]) -> None:
    pending = records
    for attempt in range(5):
        res = KIN.put_records(StreamName=stream, Records=pending)
        if not res["FailedRecordCount"]:
            return
        # ProvisionedThroughputExceededException — that shard is hot
        pending = [
            rec for rec, out in zip(pending, res["Records"]) if "ErrorCode" in out
        ]
        time.sleep(0.1 * 2 ** attempt)
    raise PublishFailed(f"{len(pending)} records never accepted")


# The partition key is the ordering AND the sharding decision. order_id keeps
# each order’s events in sequence and spreads load; "orders" would put the
# entire stream on one shard.
put_all("order-events", [
    {"PartitionKey": order_id, "Data": json.dumps(event).encode()},
])
You needSQSKinesisMSK / Kafka
Replay old messagesNo — delete is finalYes, up to 365 daysYes, as long as retention allows
Several independent consumersOne queue each, fed by SNS or EventBridgeBuilt in, each with its own positionBuilt in, consumer groups
OrderingFIFO queues, per message groupPer shard, alwaysPer partition, always
Skip one bad messageTrivially — DLQ it and move onOnly by configuring bisect and retry limitsOnly by advancing the offset yourself
Scaling unitNone visible — it just absorbsShards, which you split and mergePartitions, which you can only add
Operational weightAlmost noneLow, but shard maths is yoursReal — unless you take the serverless option

Configure the poison-pill escape before you go live

On a Kinesis or DynamoDB Streams event source mapping, set MaximumRetryAttempts, turn on BisectBatchOnFunctionError so a failing batch is halved until the bad record is isolated, and give it an OnFailure destination so the record lands somewhere you can read it. Without those three, one malformed record halts a shard until retention drops it — and takes every well-formed record behind it along the way.

Part six

The front door

Something has to accept the connection, decide it is allowed, and pick a backend. Whether that is a load balancer or API Gateway changes your latency floor, your per-request cost and the vocabulary of your 5xx errors.

AWS · 16

Load balancers, and what each 5xx actually means

An ALB is a listener, a set of rules, and target groups with health checks. Four numbers — idle timeout, keep-alive, deregistration delay and health check thresholds — produce nearly every mysterious error it will ever show you.

LISTENER → RULE → TARGET GROUP → TARGET :443 listener TLS ends here rule · /api/* → orders host, path, header, weight TARGET GROUP health check owns membership task · healthy task · healthy task · draining 300s by default Reading the failure from the status code 502 BAD GATEWAY The target answered, badly — or closed the connection mid-request. Almost always a keep-alive race, or a process that exited mid-flight. 503 SERVICE UNAVAILABLE No registered targets in the group at all. The load balancer never reached your code. Look at the health check path, not at the application logs. 504 GATEWAY TIMEOUT The target was still thinking when the idle timeout expired. Raise the idle timeout, or make the handler faster. Usually the latter. The keep-alive ordering rule CORRECT: BACKEND IDLE > ALB IDLE ALB 60s, application 75s. The balancer closes idle connections; the application never does. BROKEN: BACKEND IDLE < ALB IDLE The application closes a pooled connection just as a request arrives on it. Intermittent 502s.
Intermittent 502s under low traffic, with nothing in the application log, are the keep-alive race almost every time. The load balancer reuses an idle connection at the moment the backend decides to close it; neither side is wrong, and the request dies in between. Make the backend’s idle timeout comfortably longer than the load balancer’s and the errors disappear.
SettingDefaultConsiderWhy
Idle timeout60sJust above your slowest endpointIt bounds every request. A long-poll or a streaming response needs it raised deliberately, not accidentally.
Deregistration delay300sSlightly longer than your longest request — often 30sFive minutes per task makes every rolling deploy glacial. Too short truncates requests in flight.
Health check interval / threshold30s, 2 unhealthy (healthy threshold 5)10s, 2The defaults still take about a minute to remove a dead target. That is a long time to serve errors.
Health check path/A dedicated /healthz that checks nothing downstreamA health check that queries the database turns one slow database into zero healthy targets, fleet-wide.
AlgorithmRound robinLeast outstanding requestsRound robin sends work to a task that is already stuck. LOR routes around it automatically.
Cross-zoneOn for ALB, off for NLBOn, and know the costOff means an imbalanced zone gets an imbalanced share. On means cross-AZ data transfer charges on NLB.
Access logsOffOn, to S3, with a lifecycle ruleThey contain the target processing time, the target status and the TLS details. Without them a 502 investigation is guesswork.
// An HTTP server configured for life behind an ALB. Every timeout here exists
// to fail in a way you can reason about.
srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,     // slow-header attacks, and stuck clients
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    // THE important one: strictly greater than the ALB idle timeout (60s),
    // so the balancer always closes idle connections, never the application.
    IdleTimeout: 75 * time.Second,
}

// Two endpoints, two meanings. Conflating them is how a slow database becomes
// a fleet-wide outage.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
    // Liveness: am I this process, and is my event loop turning? Nothing else.
    w.WriteHeader(http.StatusOK)
})

mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
    // Readiness: should the load balancer send me traffic right now?
    // Flipped to false by the SIGTERM handler before the drain begins.
    if !ready.Load() {
        w.WriteHeader(http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
})

// The client’s real address is in X-Forwarded-For, appended by the ALB. Trust
// only the LAST entry — anything earlier is client-supplied and forgeable.
func clientIP(r *http.Request) string {
    xff := r.Header.Values("X-Forwarded-For")
    if len(xff) == 0 {
        return r.RemoteAddr
    }
    parts := strings.Split(xff[len(xff)-1], ",")
    return strings.TrimSpace(parts[len(parts)-1])
}
# A uvicorn server configured for life behind an ALB. Every timeout here
# exists to fail in a way you can reason about.
#
#   uvicorn app:app --host 0.0.0.0 --port 8080 \
#     --timeout-keep-alive 75 \      THE important one: strictly greater than
#                                    the ALB idle timeout (60s), so the
#                                    balancer always closes idle connections
#     --timeout-graceful-shutdown 20 \
#     --workers 4 --proxy-headers --forwarded-allow-ips='*'

# Two endpoints, two meanings. Conflating them is how a slow database becomes
# a fleet-wide outage.


@app.get("/healthz")
async def healthz():
    # Liveness: am I this process, and is my event loop turning? Nothing else.
    return Response(status_code=200)


@app.get("/readyz")
async def readyz():
    # Readiness: should the load balancer send me traffic right now?
    # Flipped to False by the SIGTERM handler before the drain begins.
    return Response(status_code=200 if READY else 503)


def client_ip(request: Request) -> str:
    # The client’s real address is in X-Forwarded-For, appended by the ALB.
    # Trust only the LAST entry — anything earlier is forgeable by the client.
    xff = request.headers.get("x-forwarded-for")
    if not xff:
        return request.client.host
    return xff.split(",")[-1].strip()

NLB when you need layer four

Static IP addresses per zone, source IP preserved to the target, millions of connections, and no HTTP awareness at all. Choose it for non-HTTP protocols, for clients that must allow-list an IP, and for extreme connection counts. Everything else wants an ALB, where routing rules and WAF live.

Health checks must not cascade

If /healthz checks the database, then one database blip marks every target unhealthy at the same moment. The load balancer falls back to routing at unhealthy targets anyway, ECS starts replacing tasks that were never broken, and a slow dependency becomes a fleet-wide restart. Check dependencies in readiness at most, and even then with a stale-tolerant cached result.

AWS · 17

API Gateway, and the three cheaper things it competes with

API Gateway is four products in one name. Most teams need the HTTP API, some need REST’s older feature set, and a fair number would be better served by a Function URL or an ALB — at a tenth of the price.

FOUR FRONT DOORS, CHEAPEST FIRST FUNCTION URL Free. One Lambda, one HTTPS endpoint, IAM or nothing for auth. Response streaming, and no 29-second ceiling. ALB Hourly + capacity units, not per request — so it gets cheaper with volume. WAF, OIDC login, mixed Lambda and container targets. HTTP API About $1 per million. JWT authorizers, CORS, routes, stages, VPC links. The default choice for a serverless HTTP API. REST API About $3.50 per million, and worth it only for what only it has: API keys and usage plans, caching, request validation. What happens to one request, in order route match GET /orders/{id} authorizer JWT, IAM or Lambda throttle burst, then rate integration Lambda, HTTP, service access log turn it on THE THROTTLE IS A TOKEN BUCKET, AND IT IS SHARED Burst is the bucket size, rate is the refill. The account-level default applies across every API in the region, so one noisy endpoint throttles the others. Set per-route limits before you need them; a 429 here is invisible to your app.
Choose by the feature you cannot live without, not by the name. Need API keys and per-customer quotas? REST. Need OIDC login at the edge and container targets? ALB. Need one endpoint for one function with no per-request fee? A Function URL. Everything else is an HTTP API, and it is both cheaper and faster than the REST API it replaced.
// An HTTP API (payload format 2.0) handler. The event shape is different from
// the REST API’s v1 — routeKey, http.method, and a flattened single-value
// header map are the differences that bite when you migrate.
func handle(ctx context.Context, req events.APIGatewayV2HTTPRequest) (
    events.APIGatewayV2HTTPResponse, error) {

    id := req.PathParameters["id"]                       // from route GET /orders/{id}

    // The authorizer’s output arrives here — already verified, so do not
    // re-parse the token, and never trust a claim you did not configure.
    tenant, _ := req.RequestContext.Authorizer.JWT.Claims["tenant_id"]

    order, err := app.load(ctx, tenant, id)
    switch {
    case errors.Is(err, ErrNotFound):
        return jsonErr(404, "not_found", "no such order"), nil
    case err != nil:
        // Returning an error gives the caller an opaque 500 and no body.
        // Prefer an explicit response you control, and log the detail.
        log.Error("load order", "id", id, "err", err)
        return jsonErr(500, "internal", "try again"), nil
    }

    body, _ := json.Marshal(order)
    return events.APIGatewayV2HTTPResponse{
        StatusCode: 200,
        Headers: map[string]string{
            "content-type":  "application/json",
            "cache-control": "no-store",
        },
        Body: string(body),
    }, nil
}
# An HTTP API (payload format 2.0) handler. The event shape differs from the
# REST API’s v1 — routeKey, requestContext.http.method, and single-value
# headers are the differences that bite when you migrate.
from aws_lambda_powertools.event_handler import APIGatewayHttpResolver
from aws_lambda_powertools.event_handler.exceptions import NotFoundError

app_router = APIGatewayHttpResolver()


@app_router.get("/orders/<order_id>")
def get_order(order_id: str):
    # The authorizer’s output arrives already verified — do not re-parse the
    # token, and never trust a claim you did not configure the authorizer to check.
    claims = app_router.current_event.request_context.authorizer.jwt_claim
    tenant = claims["tenant_id"]

    order = load(tenant, order_id)
    if order is None:
        raise NotFoundError                     # becomes a clean 404 JSON body

    return Response(
        status_code=200,
        content_type="application/json",
        headers={"cache-control": "no-store"},
        body=json.dumps(order),
    )


@logger.inject_lambda_context
@tracer.capture_lambda_handler
def handler(event, context):
    # The resolver turns unhandled exceptions into 500s with a request id,
    # instead of the opaque gateway error you get from raising here.
    return app_router.resolve(event, context)
ConceptDetail worth knowing
Integration timeout29 seconds is the long-standing maximum, and on REST APIs it can now be raised by quota. Anything longer needs an asynchronous pattern: accept, return 202 with a status URL, and do the work behind a queue.
AuthorizersJWT authorizers on HTTP APIs validate an OIDC token natively — no code, no cold start. A Lambda authorizer is for anything else, and its result is cacheable by identity source for up to an hour.
StagesA deployment is immutable; a stage points at one. Stage variables plus a canary deployment give you a percentage rollout on REST APIs without touching the client.
Private integrationA VPC link lets the gateway reach an internal ALB or Cloud Map service, so “public API, private compute” needs no public subnet at all.
CORSConfigured on the API for HTTP APIs, which then answers OPTIONS itself. On REST APIs it is a mock integration you must create per resource — a classic half-day.
Access logsOff by default. The $context variables carry the request id, the integration latency and the authorizer result — the three fields you need to tell “slow backend” from “slow gateway”.
Payload limits10 MB request, and Lambda’s own 6 MB synchronous response cap sits behind it. Large uploads belong on a presigned S3 URL, not through the gateway.
Part seven

Operating it

The three things you will be asked about at three in the morning and at the end of the quarter: what is happening, who is allowed to see it, and why the bill went up.

AWS · 18

CloudWatch: metrics from logs, and alarms worth waking for

CloudWatch charges for what you send it, in three different currencies — per custom metric, per gigabyte ingested, per query scanned. The embedded metric format lets you pay one of those and get two.

TWO WAYS TO RECORD “ORDER PLACED, 43 ms, TENANT 42” PutMetricData — A NETWORK CALL YOU WAIT FOR handler CloudWatch +10–30 ms On the request path, throttled at high volume, and the log line that explains it lives somewhere else. EMBEDDED METRIC FORMAT — JUST A LOG LINE stdout parsed for you, later +0 ms The metric and the context that explains it are the same record, so you can query from alarm to cause. Three meters running at once CUSTOM METRICS Priced per metric per month. A metric is one unique set of dimension values — so a user id dimension is a fortune. LOG INGESTION Priced per gigabyte in, then per gigabyte stored. Retention defaults to never expire, which is a bill that only grows. INSIGHTS QUERIES Priced per gigabyte scanned. Narrow the time range before you widen the filter, or an incident gets its own invoice. ALARM ON THE THING A CUSTOMER WOULD NOTICE Error rate, p99 latency, queue age, iterator age, DLQ depth. Not CPU — CPU is how you explain the page, not why you send it.
Cardinality is the whole cost model. A metric with a tenant_id dimension across 5,000 tenants is 5,000 metrics, billed monthly, for ever — even after the tenants leave. Keep dimensions to the handful you would actually alarm on, and put the high-cardinality identifiers in the log fields beside them, where they cost per gigabyte instead of per series.
// EMF: a metric and its context in one stdout line. CloudWatch parses the
// _aws block and creates the metrics; every other field stays queryable in
// Logs Insights. No API call, no added latency, no throttling.
type metricDef struct {
    Name string `json:"Name"`
    Unit string `json:"Unit"`
}

type directive struct {
    Namespace  string      `json:"Namespace"`
    Dimensions [][]string  `json:"Dimensions"`
    Metrics    []metricDef `json:"Metrics"`
}

func emit(op string, d time.Duration, tenant, order, trace string) {
    rec := map[string]any{
        "_aws": map[string]any{
            "Timestamp": time.Now().UnixMilli(),
            "CloudWatchMetrics": []directive{{
                Namespace: "Orders",
                // TWO dimensions, not five: every unique combination is a
                // separate billed metric series, for ever.
                Dimensions: [][]string{{"service", "operation"}},
                Metrics:    []metricDef{{Name: "latency", Unit: "Milliseconds"}},
            }},
        },
        // Dimensions must also appear as top-level fields.
        "service":   "orders-api",
        "operation": op,
        "latency":   d.Milliseconds(),
        // High-cardinality context: searchable in Logs Insights, but NOT a
        // dimension, so not billed as thousands of metric series.
        "tenantId": tenant,
        "orderId":  order,
        "traceId":  trace,
    }
    _ = json.NewEncoder(os.Stdout).Encode(rec)
}
# EMF: a metric and its context in one stdout line. CloudWatch parses it and
# creates the metrics; every other field stays queryable in Logs Insights.
# No API call, no added latency, no throttling. Powertools writes the format.
from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit

metrics = Metrics(namespace="Orders", service="orders-api")


def place_order(tenant_id: str, order_id: str, elapsed_ms: float):
    # Dimensions: the two or three you would actually alarm on. Every unique
    # combination is a separate billed metric series, for ever.
    metrics.add_dimension(name="operation", value="place_order")
    metrics.add_metric(name="latency", unit=MetricUnit.Milliseconds, value=elapsed_ms)

    # High-cardinality context goes in metadata: searchable in Logs Insights,
    # attached to the same record, and NOT a billed metric series.
    metrics.add_metadata(key="tenantId", value=tenant_id)
    metrics.add_metadata(key="orderId", value=order_id)


# Correlate logs with traces so one click goes from alarm to request:
logger = Logger(service="orders-api")
logger.info("order placed", extra={"orderId": order_id})
# Powertools injects xray_trace_id automatically when tracing is enabled.

# The Logs Insights query that turns those lines into an answer:
#   fields @timestamp, tenantId, latency
#   | filter operation = "place_order" and latency > 1000
#   | stats count(*) as slow, pct(latency, 99) as p99 by bin(5m), tenantId
#   | sort slow desc | limit 20
Alarm onThreshold shapeBecause
Error rate, as a ratioMetric maths: 5xx / requests, over 3 of 5 datapointsAn absolute count alarms at 3 a.m. on a traffic spike and stays silent during a quiet-hours outage.
p99 latencyAbove your SLO, sustainedAverages hide everything. If you alarm on one latency statistic, make it the tail.
Queue ageApproximateAgeOfOldestMessageDepth tells you how much work there is; age tells you whether anyone is doing it. Age is the one that means “stuck”.
DLQ depthAny value above zeroEvery message there is a bug with a reproduction attached.
Iterator ageA fraction of your retentionRising age has a deadline: when it reaches retention, data is gone.
Missing dataTreat as notBreaching for spiky metrics, breaching for heartbeatsThe default of missing means a job that stopped emitting entirely never alarms — the exact failure you cared about.

Set log retention on every group, today

New log groups keep data for ever unless told otherwise, and the ones created implicitly by Lambda and ECS are the easiest to forget. Thirty days for application logs and a year for audit trails is a defensible default; anything you truly need for longer belongs in S3 through a subscription filter, at a twentieth of the price.

AWS · 19

Secrets, parameters and KMS

KMS never gives you a key — it gives you a key encrypted under a key, and a decrypted copy to use immediately. Everything above it, Secrets Manager included, is that trick plus a place to keep the ciphertext.

ENVELOPE ENCRYPTION — WHY KMS SCALES AT ALL GenerateDataKey one call to KMS the master key never leaves plaintext key · use, then wipe encrypted key · store it YOUR OBJECT ciphertext + the wrapped key, side by side Bulk data is encrypted locally at memory speed. KMS is called once per key, not once per byte — and that is what S3 bucket keys reuse. Rotation, and the window where both are valid createSecret new value stored as AWSPENDING setSecret the database now accepts BOTH old and new testSecret connect with the pending value, or roll back finishSecret AWSPENDING becomes AWSCURRENT THE APPLICATION’S JOB IN ALL OF THIS Cache the secret — a fetch per request costs money and latency and will eventually throttle. But re-fetch on an authentication failure rather than only on a timer, or the pods that cached the old password keep failing until the TTL happens to expire. Both halves are required; one without the other is the outage.
Rotation breaks applications that only refresh on a timer. The safe pattern is a cache with a moderate TTL plus an explicit invalidation triggered by an authentication error, so a rotated credential costs you one failed connection rather than an hour of them. The AWS Parameters and Secrets Lambda extension implements exactly this as a local HTTP cache, if you would rather not write it.
// Cache with a TTL, and refresh on demand when authentication fails.
// Both halves matter: the cache saves money, the invalidation survives rotation.
type SecretCache struct {
    mu    sync.RWMutex
    sm    *secretsmanager.Client
    ttl   time.Duration
    value string
    at    time.Time
}

func (c *SecretCache) Get(ctx context.Context, force bool) (string, error) {
    c.mu.RLock()
    if !force && c.value != "" && time.Since(c.at) < c.ttl {
        v := c.value
        c.mu.RUnlock()
        return v, nil
    }
    c.mu.RUnlock()

    c.mu.Lock()
    defer c.mu.Unlock()
    if !force && c.value != "" && time.Since(c.at) < c.ttl {
        return c.value, nil                    // another goroutine refreshed it
    }

    out, err := c.sm.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
        SecretId:     aws.String("orders/db"),
        VersionStage: aws.String("AWSCURRENT"),   // never pin a version id
    })
    if err != nil {
        return "", err
    }
    c.value, c.at = aws.ToString(out.SecretString), time.Now()
    return c.value, nil
}

// At the call site: one retry with force=true turns a rotation from an outage
// into a single failed connection.
pw, _ := cache.Get(ctx, false)
if err := connect(ctx, pw); errors.Is(err, ErrAuth) {
    pw, _ = cache.Get(ctx, true)
    err = connect(ctx, pw)
}
# Cache with a TTL, and refresh on demand when authentication fails.
# Both halves matter: the cache saves money, the invalidation survives rotation.
import threading, time

SM = boto3.client("secretsmanager", config=CFG)


class SecretCache:
    def __init__(self, secret_id: str, ttl: float = 900.0):
        self._id, self._ttl = secret_id, ttl
        self._lock = threading.Lock()
        self._value: str | None = None
        self._at = 0.0

    def get(self, force: bool = False) -> str:
        fresh = self._value is not None and time.monotonic() - self._at < self._ttl
        if fresh and not force:
            return self._value
        with self._lock:
            # re-check: another thread may have refreshed while we waited
            fresh = self._value is not None and time.monotonic() - self._at < self._ttl
            if fresh and not force:
                return self._value
            res = SM.get_secret_value(
                SecretId=self._id,
                VersionStage="AWSCURRENT",     # never pin a version id
            )
            self._value, self._at = res["SecretString"], time.monotonic()
            return self._value


# At the call site: one retry with force=True turns a rotation from an outage
# into a single failed connection.
cache = SecretCache("orders/db")
try:
    connect(cache.get())
except AuthError:
    connect(cache.get(force=True))
Secrets ManagerSSM Parameter Store
PricePer secret per month, plus per API callStandard tier is free; the advanced tier is charged per parameter
RotationBuilt in, with a Lambda and staging labelsNone — you write it
Native integrationsRDS, Redshift, DocumentDB rotation; ECS and Lambda injectionECS and Lambda injection, and almost every IaC tool
StructureFlat names, JSON values, cross-region replicasHierarchical paths, and GetParametersByPath to fetch a whole tree
Use it forAnything that rotates: database credentials, third-party API keysConfiguration, feature flags, ARNs, and secrets that never rotate

Key policies come first

A KMS key’s own policy is authoritative: an IAM policy granting kms:Decrypt does nothing unless the key policy delegates to IAM in that account. This is the reason cross-account S3 reads fail with a KMS error that names a role which visibly has the permission.

Never put a secret in a task definition

Plain environment values are visible to anyone with ecs:DescribeTaskDefinition, and they live in every revision for ever. Use the secrets block, which resolves an ARN at task start using the execution role — the value never appears in the definition, the API, or the console.

AWS · 20

The bill, and where it actually comes from

On a small-to-medium account the top of the bill is rarely compute. It is data movement, log ingestion, and things nobody deleted — all of which are invisible in the architecture diagram.

THE LINES THAT SURPRISE PEOPLE, ROUGHLY IN ORDER NAT gateway · hourly per AZ + every gigabyte processed Gateway endpoints for S3 and DynamoDB are free. Add them. cross-AZ traffic · charged in BOTH directions Keep chatty pairs zone-local; know what cross-zone costs. CloudWatch Logs · per GB ingested, then stored for ever Set retention. Drop debug logs at the source, not in a filter. nobody deleted it · EBS volumes, snapshots, idle endpoints A monthly sweep, scripted, beats an annual panic. over-provisioned tasks · 4 vCPU using 0.3 Right-size from p95 usage, then move to Graviton. actual compute · the thing you thought you were buying Savings Plans once the shape is stable. Not before. Data transfer, in one line: in from the internet is free; out to the internet is charged; between zones is charged both ways; within a zone using private addresses is free; and out through CloudFront is cheaper than out through anything else.
Tag before you optimise. Without cost allocation tags every conversation about the bill is anecdotal. One required tag — the owning team or service, enforced by a tag policy and checked in CI — converts a single opaque invoice into a set of per-service numbers that the people who can act on them actually see.
// Month over month, by service, biggest increase first. Ten seconds of API
// calls that answers "why is the bill up" better than any dashboard.
ce := costexplorer.NewFromConfig(cfg)

get := func(start, end string) (map[string]float64, error) {
    out, err := ce.GetCostAndUsage(ctx, &costexplorer.GetCostAndUsageInput{
        TimePeriod:  &types.DateInterval{Start: aws.String(start), End: aws.String(end)},
        Granularity: types.GranularityMonthly,
        Metrics:     []string{"UnblendedCost"},
        GroupBy: []types.GroupDefinition{
            {Type: types.GroupDefinitionTypeDimension, Key: aws.String("SERVICE")},
        },
    })
    if err != nil {
        return nil, err
    }
    m := map[string]float64{}
    for _, r := range out.ResultsByTime {
        for _, g := range r.Groups {
            v, _ := strconv.ParseFloat(aws.ToString(g.Metrics["UnblendedCost"].Amount), 64)
            m[g.Keys[0]] += v
        }
    }
    return m, nil
}

prev, _ := get("2026-07-01", "2026-08-01")
curr, _ := get("2026-08-01", "2026-09-01")
type row struct {
    svc           string
    amount, delta float64
}
rows := make([]row, 0, len(curr))
for svc, amount := range curr {
    rows = append(rows, row{svc, amount, amount - prev[svc]})
}
// a map has no order, so sort — otherwise "biggest increase first" is a lie
sort.Slice(rows, func(i, j int) bool { return rows[i].delta > rows[j].delta })

for _, r := range rows {
    if r.delta > 50 {                        // only what is worth a conversation
        fmt.Printf("%-42s %8.2f  (%+.2f)\n", r.svc, r.amount, r.delta)
    }
}
# Month over month, by service, biggest increase first. Ten seconds of API
# calls that answers "why is the bill up" better than any dashboard.
CE = boto3.client("ce", region_name="us-east-1")   # Cost Explorer is us-east-1


def by_service(start: str, end: str) -> dict[str, float]:
    res = CE.get_cost_and_usage(
        TimePeriod={"Start": start, "End": end},
        Granularity="MONTHLY",
        Metrics=["UnblendedCost"],
        GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
    )
    out: dict[str, float] = {}
    for period in res["ResultsByTime"]:
        for g in period["Groups"]:
            out[g["Keys"][0]] = out.get(g["Keys"][0], 0.0) + float(
                g["Metrics"]["UnblendedCost"]["Amount"]
            )
    return out


prev = by_service("2026-07-01", "2026-08-01")
curr = by_service("2026-08-01", "2026-09-01")

rows = [(svc, amt, amt - prev.get(svc, 0.0)) for svc, amt in curr.items()]
for svc, amt, delta in sorted(rows, key=lambda r: -r[2]):
    if delta > 50:                     # only what is worth a conversation
        print(f"{svc:<42} {amt:8.2f}  ({delta:+.2f})")

Three habits worth more than any optimisation

Turn on Cost Anomaly Detection the day you open the account — it is free, and it emails you about the mistake rather than letting the invoice do it. Put a budget with an alert at 80% of what you expect to spend, so a runaway loop is a notification instead of a quarter. And run a monthly sweep for unattached volumes, forgotten snapshots, idle load balancers, empty NAT gateways and log groups with no retention. Between them these cost nothing and catch nearly everything.

Part eight

Putting it together

One service, built from the pieces above, with the awkward parts written out rather than waved at: idempotency across a retry, a database write and an event that must not disagree, and a checklist you can hold a design review against.

AWS · 21

One order service, end to end

A POST /orders that survives a client retry, a failover mid-transaction and a consumer that was down for an hour. Every hop below is a service from an earlier section, wired in the order that makes those three things true.

POST /orders — THE WHOLE PATH client Idempotency-Key CloudFront TLS, WAF, edge ALB idle 60s, LOR ECS FARGATE task role, awsvpc private subnet 1 · DYNAMODB IDEMPOTENCY conditional put on the key, TTL 24h already there → return the stored reply 2 · AURORA — ONE TRANSACTION, TWO ROWS INSERT INTO orders … and INSERT INTO outbox … commit together. The event cannot exist without the order, or the order without the event. 3 · OUTBOX RELAY polls, publishes, marks sent at-least-once, on purpose 4 · EVENTBRIDGE rules route on content archive keeps a replay SQS → billing worker SQS → email Lambda WHY THE OUTBOX, AND NOT JUST A PUBLISH Committing to Aurora and then calling PutEvents is two writes with no shared fate. Crash in between and the order exists but nobody hears about it. Publish first and you can announce an order that was never committed. The outbox row rides the same transaction, so the two facts cannot disagree. The relay may publish twice — which is why every consumer in this diagram is idempotent too. Exactly-once delivery does not exist. Exactly- once effect is what you build instead. Every arrow can be retried. Every box can be restarted. Nothing in the picture assumes a message arrives once, or in order. That is the whole design; the services are just the parts it is assembled from.
Read the picture as a set of claims about failure. The idempotency record makes a client retry free. The outbox makes the database and the bus agree. The queues make a slow consumer someone else’s problem rather than the API’s. Remove any one of them and there is a specific, nameable way the system lies to a customer.
// POST /orders — the handler, with every failure mode from this page in it.
func (a *App) PlaceOrder(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
    defer cancel()

    key := r.Header.Get("Idempotency-Key")
    if key == "" {
        writeErr(w, 400, "idempotency_key_required")
        return
    }

    var req PlaceOrderRequest
    if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
        writeErr(w, 400, "bad_json")
        return
    }

    // 1. Claim the key. Conditional put: whoever wins does the work, and the
    //    TTL means abandoned claims clean themselves up.
    claimed, prior, err := a.claim(ctx, key, 24*time.Hour)
    switch {
    case err != nil:
        writeErr(w, 503, "try_again")
        return
    case !claimed && prior != nil:
        w.Header().Set("Idempotent-Replay", "true")
        _, _ = w.Write(prior)                    // byte-identical to the first reply
        return
    case !claimed:
        writeErr(w, 409, "in_progress")          // claimed, not finished: retry later
        return
    }

    // 2. Order and outbox row commit together, or neither exists.
    tx, err := a.pool.Begin(ctx)
    if err != nil {
        writeErr(w, 503, "try_again")
        return
    }
    defer tx.Rollback(ctx)                        // no-op after a successful commit

    order, err := insertOrder(ctx, tx, req)
    if err != nil {
        writeErr(w, 500, "internal")
        return
    }
    event, _ := json.Marshal(OrderPlaced{Version: "1", OrderID: order.ID})
    if err := insertOutbox(ctx, tx, order.ID, "OrderPlaced", event); err != nil {
        writeErr(w, 500, "internal")
        return
    }
    if err := tx.Commit(ctx); err != nil {
        // A commit that fails after a failover may still have applied. The
        // idempotency key is what makes the client’s retry safe here.
        writeErr(w, 503, "try_again")
        return
    }

    // 3. Store the reply against the key, so the retry gets THIS body.
    body, _ := json.Marshal(order)
    _ = a.completeClaim(ctx, key, body)
    w.WriteHeader(201)
    _, _ = w.Write(body)
}
# POST /orders — the handler, with every failure mode from this page in it.


@app.post("/orders", status_code=201)
async def place_order(req: PlaceOrderRequest, response: Response,
                      idempotency_key: str = Header(alias="Idempotency-Key")):

    # 1. Claim the key. Conditional put: whoever wins does the work, and the
    #    TTL means abandoned claims clean themselves up.
    claimed, prior = await claim(idempotency_key, ttl=timedelta(hours=24))
    if not claimed and prior is not None:
        response.headers["Idempotent-Replay"] = "true"
        return Response(content=prior, media_type="application/json")
    if not claimed:
        raise HTTPException(409, "in_progress")   # claimed, not finished

    # 2. Order and outbox row commit together, or neither exists.
    try:
        async with engine.begin() as conn:        # commits on exit, rolls back on raise
            order = await insert_order(conn, req)
            await conn.execute(
                text("""INSERT INTO outbox (aggregate_id, type, payload)
                        VALUES (:id, :type, CAST(:payload AS jsonb))"""),
                {"id": order.id, "type": "OrderPlaced",
                 "payload": json.dumps({"version": "1", "orderId": order.id})},
            )
    except OperationalError:
        # A commit that fails after a failover may still have applied. The
        # idempotency key is what makes the client’s retry safe here.
        raise HTTPException(503, "try_again")

    # 3. Store the reply against the key, so the retry gets THIS body.
    body = order.model_dump_json()
    await complete_claim(idempotency_key, body)
    return Response(content=body, status_code=201, media_type="application/json")

The claim, in DynamoDB

One conditional write does all of it: reserve the key if nobody has it, return the stored response if the work is finished, and allow a re-attempt if a previous holder died before completing.

// claim reserves the idempotency key, or hands back the finished response.
func (a *App) claim(ctx context.Context, key string, ttl time.Duration) (bool, []byte, error) {
    now := time.Now()
    _, err := a.ddb.PutItem(ctx, &dynamodb.PutItemInput{
        TableName: aws.String("idempotency"),
        Item: map[string]types.AttributeValue{
            "pk":       &types.AttributeValueMemberS{Value: key},
            "state":    &types.AttributeValueMemberS{Value: "in_progress"},
            "expires":  &types.AttributeValueMemberN{Value: fmt.Sprint(now.Add(ttl).Unix())},
            "lockedAt": &types.AttributeValueMemberN{Value: fmt.Sprint(now.Unix())},
        },
        // Win if the key is unknown, OR if a previous holder crashed mid-flight
        // and its lock is stale. A FINISHED claim is never retaken — without the
        // state check, a retry 30s later re-runs the order instead of replaying it.
        // ("state" is a DynamoDB reserved word, hence the #st alias.)
        ConditionExpression: aws.String(
            "attribute_not_exists(pk) OR (#st = :inprog AND lockedAt < :stale)"),
        ExpressionAttributeNames: map[string]string{"#st": "state"},
        ExpressionAttributeValues: map[string]types.AttributeValue{
            ":inprog": &types.AttributeValueMemberS{Value: "in_progress"},
            ":stale": &types.AttributeValueMemberN{
                Value: fmt.Sprint(now.Add(-30 * time.Second).Unix())},
        },
        ReturnValuesOnConditionCheckFailure: types.ReturnValuesOnConditionCheckFailureAllOld,
    })
    if err == nil {
        return true, nil, nil
    }

    var failed *types.ConditionalCheckFailedException
    if !errors.As(err, &failed) {
        return false, nil, err
    }
    // We lost. The existing item comes back with the failure, so a completed
    // request needs no second read.
    if resp, ok := failed.Item["response"].(*types.AttributeValueMemberB); ok {
        return false, resp.Value, nil
    }
    return false, nil, nil                 // still in progress elsewhere
}
# claim() reserves the idempotency key, or hands back the finished response.
async def claim(key: str, ttl: timedelta) -> tuple[bool, bytes | None]:
    now = int(time.time())
    try:
        DDB.put_item(
            TableName="idempotency",
            Item={
                "pk": {"S": key},
                "state": {"S": "in_progress"},
                "expires": {"N": str(now + int(ttl.total_seconds()))},
                "lockedAt": {"N": str(now)},
            },
            # Win if the key is unknown, OR if a previous holder crashed mid-flight
            # and its lock is stale. A FINISHED claim is never retaken — without the
            # state check, a retry 30s later re-runs the order instead of replaying it.
            # ("state" is a DynamoDB reserved word, hence the #st alias.)
            ConditionExpression=(
                "attribute_not_exists(pk) OR (#st = :inprog AND lockedAt < :stale)"
            ),
            ExpressionAttributeNames={"#st": "state"},
            ExpressionAttributeValues={":inprog": {"S": "in_progress"},
                                       ":stale": {"N": str(now - 30)}},
            ReturnValuesOnConditionCheckFailure="ALL_OLD",
        )
        return True, None
    except DDB.exceptions.ConditionalCheckFailedException as e:
        # We lost. The existing item comes back with the failure, so a completed
        # request needs no second read.
        item = e.response.get("Item") or {}
        stored = item.get("response", {}).get("B")
        return False, stored          # stored is None while still in progress

The infrastructure, in about forty lines

Written once, reviewed like code, and identical in both languages because it is neither. The parts worth pointing at are the circuit breaker, the DLQ, and the two timers that must be ordered correctly.

# The pieces that this page argued for, expressed as configuration.

resource "aws_ecs_service" "orders" {
  name            = "orders-api"
  desired_count   = 4
  launch_type     = "FARGATE"

  deployment_circuit_breaker {
    enable   = true          # stop a bad rollout
    rollback = true          # and put the last good revision back
  }
  deployment_minimum_healthy_percent = 100
  deployment_maximum_percent         = 200      # doubles connections for 90s
  health_check_grace_period_seconds  = 60       # let the app warm up first
}

resource "aws_lb_target_group" "orders" {
  deregistration_delay = 30                     # > longest request, < stopTimeout
  health_check {
    path                = "/readyz"             # NOT a check that touches the DB
    interval            = 10
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }
}

resource "aws_sqs_queue" "billing" {
  visibility_timeout_seconds = 180              # 6x the consumer’s 30s timeout
  receive_wait_time_seconds  = 20               # long polling, always
  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.billing_dlq.arn
    maxReceiveCount     = 5
  })
}

resource "aws_s3_bucket_lifecycle_configuration" "archive" {
  rule {
    id     = "abort-incomplete-multipart"       # the free money rule
    status = "Enabled"
    abort_incomplete_multipart_upload { days_after_initiation = 7 }
  }
}

resource "aws_cloudwatch_log_group" "orders" {
  retention_in_days = 30                        # the default is FOREVER
}
AWS · 22

The checklist, and one script that proves it

Everything above, compressed into questions you can ask at a design review — and a start-up self-test that turns half of them from a promise into an assertion.

BEFORE IT CARRIES REAL TRAFFIC IDENTITY AND NETWORK No long-lived access keys anywhere Roles scoped by ARN, not by star Nothing of yours in a public subnet Gateway endpoints for S3, DynamoDB Security groups reference groups Private subnets sized with headroom DATA statement_timeout set, and tested tasks × pool_max below the limit A failover has been rehearsed A restore has been rehearsed Partition keys spread, not clump Buckets abort incomplete uploads COMPUTE SIGTERM drains before it exits stopTimeout exceeds the drain Circuit breaker on, rollback on Backend keep-alive above ALB idle Health check touches no dependency Reserved concurrency on DB writers MESSAGING Every queue has a dead letter queue Visibility timeout exceeds the handler Long polling is on Batch failures reported per message Every consumer is idempotent Batch APIs check their failure lists OPERATIONS Log retention set on every group Alarms on symptoms, not on causes Missing-data behaviour chosen Secrets cached, and refreshed on 401 Cost anomaly detection is on Everything carries an owner tag
Half of these can be asserted rather than asked. The script below runs at start-up and refuses to become healthy if the environment is not what the deployment claimed — wrong region, missing table, unreachable endpoint, queue without a dead letter queue. A failed deploy is cheaper than a silent misconfiguration.
// preflight runs before the readiness probe goes green. Everything it checks
// is something that has silently broken a production deploy at least once.
func preflight(ctx context.Context, a *App) error {
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    // 1. Am I who the deployment thinks I am?
    who, err := a.sts.GetCallerIdentity(ctx, nil)
    if err != nil {
        return fmt.Errorf("no usable credentials: %w", err)
    }
    if !strings.Contains(aws.ToString(who.Arn), expectedRole) {
        return fmt.Errorf("wrong role: %s", aws.ToString(who.Arn))
    }

    // 2. Does the table exist, in this region, with a TTL as designed?
    tt, err := a.ddb.DescribeTimeToLive(ctx, &dynamodb.DescribeTimeToLiveInput{
        TableName: aws.String("idempotency"),
    })
    if err != nil {
        return fmt.Errorf("idempotency table: %w", err)
    }
    if tt.TimeToLiveDescription.TimeToLiveStatus != types.TimeToLiveStatusEnabled {
        return errors.New("idempotency table has no TTL: it will grow for ever")
    }

    // 3. Does every queue we consume have somewhere for poison to go?
    for _, q := range a.queues {
        at, err := a.sqs.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
            QueueUrl:       aws.String(q),
            AttributeNames: []sqstypes.QueueAttributeName{"RedrivePolicy"},
        })
        if err != nil {
            return err
        }
        if at.Attributes["RedrivePolicy"] == "" {
            return fmt.Errorf("queue %s has no dead letter queue", q)
        }
    }

    // 4. Can we actually write, with the timeout we promised?
    return a.pool.QueryRow(ctx, "SELECT 1").Scan(new(int))
}
# preflight() runs before the readiness probe goes green. Everything it checks
# is something that has silently broken a production deploy at least once.
def preflight() -> None:
    # 1. Am I who the deployment thinks I am?
    arn = boto3.client("sts", config=CFG).get_caller_identity()["Arn"]
    if EXPECTED_ROLE not in arn:
        raise Misconfigured(f"wrong role: {arn}")

    # 2. Does the table exist, in this region, with a TTL as designed?
    ttl = DDB.describe_time_to_live(TableName="idempotency")
    if ttl["TimeToLiveDescription"]["TimeToLiveStatus"] != "ENABLED":
        raise Misconfigured("idempotency table has no TTL: it will grow for ever")

    # 3. Does every queue we consume have somewhere for poison to go?
    for url in QUEUES:
        attrs = SQS.get_queue_attributes(
            QueueUrl=url, AttributeNames=["RedrivePolicy"]
        )["Attributes"]
        if "RedrivePolicy" not in attrs:
            raise Misconfigured(f"queue {url} has no dead letter queue")

    # 4. Can we actually write, with the timeout we promised?
    with engine.connect() as conn:
        conn.exec_driver_sql("SELECT 1")


# Called from the lifespan handler, before READY flips to True. A crash loop
# here is a failed deploy — which is exactly what it should be.

The four sentences worth remembering

Every call can be retried, so every effect must be idempotent. Every managed service has a limit that is per-partition, per-shard or per-connection, not per-account — find yours before it finds you. The default configuration is optimised for getting started, not for running. And the bill is mostly data movement: bytes crossing a boundary you did not think about cost more than the compute that produced them.