04 ยท Multi-Region Active-Active on AWS with Sub-Minute Failover

Code Repository / projects / 04-multi-region-active-active
Project 04 ยท AWS CDK (TypeScript)

Two regions, both serving reads and writes, with DNS failover in under a minute when one goes dark.

README

Format: AWS CDK (TypeScript), 3 stacks across 2 regions ยท Est. cost: pennies at idle (serverless) + ~$1/month per Route 53 health check

                        Route 53 (latency routing + health checks every 10s)
                         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                 us-east-1                                   us-west-2
        HTTP API โ”€โ–ถ Lambda โ”€โ–ถ DynamoDB replica โ—€โ•โ• global table โ•โ•โ–ถ DynamoDB replica โ—€โ”€ Lambda โ—€โ”€ HTTP API

Every region serves reads and writes. If a region's /health fails twice (โ‰ˆ20 s), Route 53 stops sending it traffic.

Deploy

npm install
npx cdk bootstrap aws://ACCOUNT/us-east-1 aws://ACCOUNT/us-west-2

# Without a domain (regional URLs only)
npx cdk deploy --all

# With a domain you host in Route 53 (enables global URL + failover)
npx cdk deploy --all -c domainName=api.example.com -c zoneName=example.com -c hostedZoneId=Z123456ABCDEFG

Change regions with -c primaryRegion=... -c secondaryRegion=....

Try it

curl -s -X POST https://api.example.com/orders -d '{"item":"rocket","qty":2}'
curl -s https://<us-west-2 RegionalUrl>/orders      # replicated in ~1 s
curl -si https://api.example.com/health | grep x-served-by-region

Failover drill

# 1. Watch which region answers
while true; do curl -s https://api.example.com/health; echo; sleep 2; done
# 2. "Fail" the closest region
aws ssm put-parameter --region us-east-1 --name /active-active/us-east-1/healthy --value false --overwrite
# 3. Within ~30-60 s (health checks + DNS TTL) answers come from us-west-2. Restore:
aws ssm put-parameter --region us-east-1 --name /active-active/us-east-1/healthy --value true --overwrite

Record RTO (time until traffic moves) and RPO (0 for committed writes, thanks to global tables).

Design notes

  • Global tables use last-writer-wins; design idempotent writes (client-generated IDs) for safety.
  • For strict one-writer semantics use DynamoDB global tables' multi-region strong consistency or a "home region" per tenant.
  • For an ARC-grade setup, swap the SSM kill switch for Route 53 Application Recovery Controller routing controls.

Clean up

npx cdk destroy --all

Files (8)

README.md ยท 53 lines
# 04 ยท Multi-Region Active-Active App with Sub-Minute Failover

**Format:** AWS CDK (TypeScript), 3 stacks across 2 regions ยท **Est. cost:** pennies at idle (serverless) + ~$1/month per Route 53 health check

```
                        Route 53 (latency routing + health checks every 10s)
                         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                 us-east-1                                   us-west-2
        HTTP API โ”€โ–ถ Lambda โ”€โ–ถ DynamoDB replica โ—€โ•โ• global table โ•โ•โ–ถ DynamoDB replica โ—€โ”€ Lambda โ—€โ”€ HTTP API
```
Every region serves reads **and** writes. If a region's `/health` fails twice (โ‰ˆ20 s), Route 53 stops sending it traffic.

## Deploy
```bash
npm install
npx cdk bootstrap aws://ACCOUNT/us-east-1 aws://ACCOUNT/us-west-2

# Without a domain (regional URLs only)
npx cdk deploy --all

# With a domain you host in Route 53 (enables global URL + failover)
npx cdk deploy --all -c domainName=api.example.com -c zoneName=example.com -c hostedZoneId=Z123456ABCDEFG
```
Change regions with `-c primaryRegion=... -c secondaryRegion=...`.

## Try it
```bash
curl -s -X POST https://api.example.com/orders -d '{"item":"rocket","qty":2}'
curl -s https://<us-west-2 RegionalUrl>/orders      # replicated in ~1 s
curl -si https://api.example.com/health | grep x-served-by-region
```

## Failover drill
```bash
# 1. Watch which region answers
while true; do curl -s https://api.example.com/health; echo; sleep 2; done
# 2. "Fail" the closest region
aws ssm put-parameter --region us-east-1 --name /active-active/us-east-1/healthy --value false --overwrite
# 3. Within ~30-60 s (health checks + DNS TTL) answers come from us-west-2. Restore:
aws ssm put-parameter --region us-east-1 --name /active-active/us-east-1/healthy --value true --overwrite
```
Record **RTO** (time until traffic moves) and **RPO** (0 for committed writes, thanks to global tables).

## Design notes
- Global tables use **last-writer-wins**; design idempotent writes (client-generated IDs) for safety.
- For strict one-writer semantics use DynamoDB global tables' multi-region strong consistency or a "home region" per tenant.
- For an ARC-grade setup, swap the SSM kill switch for **Route 53 Application Recovery Controller** routing controls.

## Clean up
```bash
npx cdk destroy --all
```
bin/app.ts ยท 29 lines
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { GlobalTableStack, TABLE_NAME } from '../lib/global-table-stack';
import { RegionalApiStack } from '../lib/regional-api-stack';

const app = new cdk.App();
const account = process.env.CDK_DEFAULT_ACCOUNT;
const primary: string = app.node.tryGetContext('primaryRegion') ?? 'us-east-1';
const secondary: string = app.node.tryGetContext('secondaryRegion') ?? 'us-west-2';

// Optional custom domain (both required to enable latency routing + health-check failover).
const domainName: string | undefined = app.node.tryGetContext('domainName'); // e.g. api.example.com
const hostedZoneId: string | undefined = app.node.tryGetContext('hostedZoneId');
const zoneName: string | undefined = app.node.tryGetContext('zoneName'); // e.g. example.com

const table = new GlobalTableStack(app, 'ActiveActive-GlobalTable', {
  env: { account, region: primary },
  replicaRegions: [secondary],
});

for (const region of [primary, secondary]) {
  const api = new RegionalApiStack(app, `ActiveActive-Api-${region}`, {
    env: { account, region },
    tableName: TABLE_NAME,
    dns: domainName && hostedZoneId && zoneName ? { domainName, hostedZoneId, zoneName } : undefined,
  });
  api.addStackDependency(table);
}
lib/global-table-stack.ts ยท 29 lines
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';

export const TABLE_NAME = 'active-active-orders';

export interface GlobalTableStackProps extends cdk.StackProps {
  replicaRegions: string[];
}

/** One DynamoDB global table, writable in every region (multi-active, last-writer-wins). */
export class GlobalTableStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: GlobalTableStackProps) {
    super(scope, id, props);

    const table = new dynamodb.TableV2(this, 'Orders', {
      tableName: TABLE_NAME,
      partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
      billing: dynamodb.Billing.onDemand(),
      pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
      deletionProtection: false,
      replicas: props.replicaRegions.map((region) => ({ region })),
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    new cdk.CfnOutput(this, 'TableName', { value: table.tableName });
  }
}
lib/regional-api-stack.ts ยท 100 lines
import * as path from 'path';
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as apigw from 'aws-cdk-lib/aws-apigatewayv2';
import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as ssm from 'aws-cdk-lib/aws-ssm';

export interface RegionalApiStackProps extends cdk.StackProps {
  tableName: string;
  dns?: { domainName: string; hostedZoneId: string; zoneName: string };
}

/** Identical, independent API stack per region. Route 53 latency routing + health checks glue them together. */
export class RegionalApiStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: RegionalApiStackProps) {
    super(scope, id, props);

    // Flip this to "false" to simulate a regional failure during a drill.
    const killSwitch = new ssm.StringParameter(this, 'RegionHealthy', {
      parameterName: `/active-active/${this.region}/healthy`,
      stringValue: 'true',
      description: 'Set to false to make /health return 503 and trigger DNS failover',
    });

    const fn = new lambda.Function(this, 'Handler', {
      runtime: lambda.Runtime.PYTHON_3_12,
      architecture: lambda.Architecture.ARM_64,
      handler: 'api.handler',
      code: lambda.Code.fromAsset(path.join(__dirname, '..', 'lambda')),
      memorySize: 256,
      timeout: cdk.Duration.seconds(10),
      environment: { TABLE_NAME: props.tableName, HEALTH_PARAM: killSwitch.parameterName },
      logGroup: new logs.LogGroup(this, 'HandlerLogs', {
        retention: logs.RetentionDays.TWO_WEEKS,
        removalPolicy: cdk.RemovalPolicy.DESTROY,
      }),
    });
    fn.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:Scan', 'dynamodb:DescribeTable'],
        resources: [`arn:${this.partition}:dynamodb:${this.region}:${this.account}:table/${props.tableName}`],
      }),
    );
    killSwitch.grantRead(fn);

    const api = new apigw.HttpApi(this, 'HttpApi', {
      apiName: `active-active-${this.region}`,
      defaultIntegration: new integrations.HttpLambdaIntegration('Lambda', fn),
    });

    new cdk.CfnOutput(this, 'RegionalUrl', { value: api.apiEndpoint });

    if (!props.dns) return;

    // ---------------------------------------------------------------- global DNS
    const zone = route53.HostedZone.fromHostedZoneAttributes(this, 'Zone', {
      hostedZoneId: props.dns.hostedZoneId,
      zoneName: props.dns.zoneName,
    });
    const cert = new acm.Certificate(this, 'Cert', {
      domainName: props.dns.domainName,
      validation: acm.CertificateValidation.fromDns(zone),
    });
    const domain = new apigw.DomainName(this, 'Domain', { domainName: props.dns.domainName, certificate: cert });
    new apigw.ApiMapping(this, 'Mapping', { api, domainName: domain });

    const healthCheck = new route53.CfnHealthCheck(this, 'HealthCheck', {
      healthCheckConfig: {
        type: 'HTTPS',
        fullyQualifiedDomainName: `${api.apiId}.execute-api.${this.region}.amazonaws.com`,
        resourcePath: '/health',
        requestInterval: 10,
        failureThreshold: 2,
      },
      healthCheckTags: [{ key: 'Name', value: `active-active-${this.region}` }],
    });

    new route53.CfnRecordSet(this, 'LatencyRecord', {
      hostedZoneId: props.dns.hostedZoneId,
      name: props.dns.domainName,
      type: 'A',
      setIdentifier: this.region,
      region: this.region,
      healthCheckId: healthCheck.attrHealthCheckId,
      aliasTarget: {
        dnsName: domain.regionalDomainName,
        hostedZoneId: domain.regionalHostedZoneId,
        evaluateTargetHealth: false,
      },
    });

    new cdk.CfnOutput(this, 'GlobalUrl', { value: `https://${props.dns.domainName}` });
  }
}
lambda/api.py ยท 67 lines
"""Tiny orders API served identically from every region.

Routes:  GET /health   GET /orders   GET /orders/{id}   POST /orders
"""
import json
import os
import time
import uuid

import boto3

REGION = os.environ["AWS_REGION"]
TABLE = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
SSM = boto3.client("ssm")
HEALTH_PARAM = os.environ["HEALTH_PARAM"]
_cache = {"value": True, "at": 0.0}


def region_enabled():
    # Cache the kill-switch for 5 seconds so health checks stay cheap.
    if time.time() - _cache["at"] > 5:
        value = SSM.get_parameter(Name=HEALTH_PARAM)["Parameter"]["Value"]
        _cache.update(value=value.lower() == "true", at=time.time())
    return _cache["value"]


def respond(status, body):
    return {
        "statusCode": status,
        "headers": {"content-type": "application/json", "x-served-by-region": REGION},
        "body": json.dumps(body, default=str),
    }


def handler(event, _context):
    method = event["requestContext"]["http"]["method"]
    path = event.get("rawPath", "/")

    if path == "/health":
        if not region_enabled():
            return respond(503, {"status": "drained", "region": REGION})
        TABLE.load()  # proves the in-region replica is reachable
        return respond(200, {"status": "ok", "region": REGION})

    if path == "/orders" and method == "POST":
        body = json.loads(event.get("body") or "{}")
        item = {
            "pk": str(uuid.uuid4()),
            "item": body.get("item", "widget"),
            "qty": int(body.get("qty", 1)),
            "written_in": REGION,
            "written_at": int(time.time() * 1000),
        }
        TABLE.put_item(Item=item)
        return respond(201, item)

    if path == "/orders" and method == "GET":
        items = TABLE.scan(Limit=25).get("Items", [])
        return respond(200, {"region": REGION, "orders": items})

    if path.startswith("/orders/") and method == "GET":
        order_id = path.split("/", 2)[2]
        item = TABLE.get_item(Key={"pk": order_id}).get("Item")
        return respond(200 if item else 404, item or {"error": "not found", "region": REGION})

    return respond(404, {"error": "route not found", "region": REGION})
cdk.json ยท 6 lines
{
  "app": "npx ts-node --prefer-ts-exts bin/app.ts",
  "watch": { "include": ["**"], "exclude": ["README.md", "cdk*.json", "**/*.d.ts", "**/*.js", "node_modules", "cdk.out"] },
  "context": {}
}
package.json ยท 23 lines
{
  "name": "04-multi-region-active-active",
  "version": "1.0.0",
  "private": true,
  "bin": { "app": "bin/app.js" },
  "scripts": {
    "build": "tsc",
    "synth": "cdk synth",
    "deploy": "cdk deploy --all",
    "destroy": "cdk destroy --all"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "aws-cdk": "^2.1000.0",
    "ts-node": "^10.9.2",
    "typescript": "~5.6.3"
  },
  "dependencies": {
    "aws-cdk-lib": "^2.200.0",
    "constructs": "^10.4.2"
  }
}
tsconfig.json ยท 14 lines
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["es2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"]
  },
  "exclude": ["node_modules", "cdk.out"]
}

Build it yourself

Download the folder, run npm install (CDK) or aws cloudformation deploy (CloudFormation), and follow the README.