Building a Multi-Region app with AWS CDK — Part 1

There are lots of tutorials of deploying basic CRUD applications with AWS CDK using API Gateway, and Lambda connecting to a DynamoDB table. The defacto starter set for a serverless application. You build and deploy, modify the hello-world application to match your needs, you’re happy! The API is running and acting perfectly correctly in your default region.. us-east-1…

The Issues Begin

December rolls around and you’ve successfully migrated your application to us-west-2. It wasn’t too difficult, your CDK app was able to tear down everything in us-east-1, and then deploy everything again in the new region. You let out the breath you’d been holding.

January 7th, 2021 started off really well, and as you started looking forward to the end of the day… the alerts start ringing. Your new region, us-west-2, has begun to act up! After two hours of sweating, you are back up.

Multi-Region Solution

For our CRUD application, there are two components we need to deal with:

  • Ensuring our data is consistent between the regions.
  • That DNS always resolves to a region that is up.

Thankfully there are solutions to both of these issues in our existing tech stack.

Data

Multi-Region Solution: DynamoDB Global Table

DNS

Multi-Region Solution: Route53 Latency Routing Policy

In part 1, I’ll dive into the complexities of implementing a multi-region data configuration.

DynamoDB Global Tables

For simplicity of use, there can be a number of “gotchas” from an Infrastructure as Code perspective.

CDK Primary Region

So I would instantiate my stack in us-west-2, and configure it to replicate to us-east-1, us-east-2, and us-west-1.

import * as dynamodb from '@aws-cdk/aws-dynamodb'; 
// Stack was called in us-west-2
const gloablTable = new dynamodb.Table(this, 'globalTable', {
partitionKey: {
name: 'id',
type: dynamodb.AttributeType.STRING
},
billingMode: dynamodb.BillingMode.PROVISIONED,
replicationRegions: ['us-east-1', 'us-east-2', 'us-west-1'],
removalPolicy: cdk.RemovalPolicy.DESTROY, }
);
...

Since we want to reference our global table’s name later on as environment variables for our Lambdas, we will want to build this out into two stacks.

This will make our app resemble the following:

const appRegions = ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2']; const app = new cdk.App(); 
const globalstack = new GlobalStack(app,'DynamoDBGlobalStack', {
env: {region: 'us-west-2'}});
appRegions.forEach(function (item, index) {
new AppStack(app, 'AppStack-'.concat(item), {
env: {account: process.env.CDK_DEFAULT_ACCOUNT, region: item},
globalTableName: globalstack.globalTable.tableName });
});

Region References — Mocks

For most use cases, simply mocking out the table should be all that you need.

import * as cdk from '@aws-cdk/core'; 
import * as dynamodb from '@aws-cdk/aws-dynamodb';
interface CustomStackProps extends cdk.StackProps {
readonly globalTableName: string;
readonly env: any;
}
export class AppStack extends cdk.Stack {
constructor(
scope: cdk.Construct,
id: string,
props: CustomStackProps) {
super(scope, id, props);
const globalTable = new dynamodb.Table.fromTableName(
this, 'globalTable', props.globalTableName);

We can reference the globalTable to grant IAM permissions etc.

Triggers

arn:aws:dynamodb:REGION:AWS-ACCOUNT:table/TABLE-NAME/stream/2021-01-16T19:47:47.531

This timestamp is specific to each region, and so the only way to retrieve this information is to describe the specific region table.

... 
constructor(
scope: cdk.Construct,
id: string,
props: CustomStackProps) {
super(scope, id, props);
const client = new DynamoDB({ region: props.env.region });
// Query the regions table
let globalTableInfoRequest = async() => await client.describeTable({ TableName: props.globalTableName});
globalTableInfoRequest().then( globalTableInfoResult => { // Mock the table with the specific ARN and Stream ARN
const globalTable = dynamodb.Table.fromTableAttributes(
this, "globalTable", {
tableArn: globalTableInfoResult?.Table?.TableArn,
tableStreamArn: globalTableInfoResult?.Table?.LatestStreamArn
});
// Lambda
const triggerLambda = new lambda.Function(
this, 'triggerLambda', {
...
environment: {
TABLE_NAME: props.globalTableName,
...
}
});
// Grant read access
globalTable.grantStreamRead(triggerLambda);
// Deadletter queue
const triggerDLQueue = new sqs.Queue(this, 'triggerDLQueue');
// Trigger Event
triggerLambda.addEventSource(
new DynamoEventSource(
globalTable, {
startingPosition: lambda.StartingPosition.TRIM_HORIZON,
batchSize: 5,
bisectBatchOnError: true,
onFailure: new SqsDlq(triggerDLQueue),
retryAttempts: 10 }));
});
}
}

Regions Available

  • Africa (Cape Town) — af-south-1
  • Asia Pacific (Hong Kong) — ap-east-1
  • Europe (Milan) — eu-south-1
  • Middle East (Bahrain) — me-south-1

These four regions are disabled by default, and you will need to enable them if you wanted to use them.

Conclusion

My next post will examine the DNS routing policy that we will want to implement to ensure users are not impacted by any region downtime in the future.

All code for this post can be accessed on GitHub

Originally published at https://gizmo.codes.

Drink to Code and Code to Drink

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store