Skip to content

Commit d95aa1e

Browse files
authored
Custom CDK - Support for GT MRSC from gerrardcowburn/master
Add Global Tables MRSC CDK example including KMS and Witness Region
2 parents f4e0cdf + 69af17c commit d95aa1e

File tree

14 files changed

+414
-0
lines changed

14 files changed

+414
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ workshops/relational-migration/source-tables/app_db.*
2929

3030
**/.aws-sam/
3131
**.db
32+
33+
cdk.out
34+
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
!bin
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# DynamoDB Global Table with Multi-Region Strong Consistency
2+
3+
This example demonstrates how to create a DynamoDB Global Table with `MultiRegionConsistency: STRONG` using AWS CDK and a CloudFormation L1 override. The implementation supports JSON configuration for multi-region deployment with witness region capabilities.
4+
5+
## Configuration
6+
7+
### Setting up config.json
8+
9+
1. Copy one of the example configurations from the `config-examples/` directory:
10+
```bash
11+
cp config-examples/us-regions.json config.json
12+
# OR
13+
cp config-examples/eu-regions.json config.json
14+
# OR
15+
cp config-examples/ap-regions.json config.json
16+
```
17+
18+
2. Modify the `config.json` file as needed for your deployment.
19+
20+
### Configuration Format
21+
22+
The `config.json` file defines deployment regions and witness settings:
23+
24+
```json
25+
{
26+
"regions": [
27+
{
28+
"region": "us-east-1",
29+
"witness": false
30+
},
31+
{
32+
"region": "us-east-2",
33+
"witness": false
34+
},
35+
{
36+
"region": "us-west-2",
37+
"witness": true
38+
}
39+
]
40+
}
41+
```
42+
43+
**Requirements:**
44+
- Minimum 3 regions required
45+
- All regions must be from the same geographical set
46+
- At least 2 regions must be full replicas (`"witness": false`)
47+
- Maximum 1 region can be a witness (`"witness": true`)
48+
49+
### Valid Region Sets
50+
51+
Regions must be from the same geographical set:
52+
53+
- **US Regions**: us-east-1, us-east-2, us-west-2
54+
- **EU Regions**: eu-west-1, eu-west-2, eu-west-3, eu-central-1
55+
- **AP Regions**: ap-northeast-1, ap-northeast-2, ap-northeast-3
56+
57+
### Witness Regions
58+
59+
Set `"witness": true` for a single region that should act as a witness-only replica. Only one witness region is allowed per global table. Witness regions provide additional voting capacity for strong consistency without serving read/write traffic.
60+
61+
## Setup
62+
63+
1. Install dependencies:
64+
```bash
65+
npm install
66+
```
67+
68+
2. Configure your deployment by copying an example:
69+
```bash
70+
cp config-examples/us-regions.json config.json
71+
```
72+
73+
3. Modify `config.json` for your specific requirements.
74+
75+
## Deploy
76+
77+
```bash
78+
npx cdk deploy --all
79+
```
80+
81+
## Key Implementation
82+
83+
The implementation includes:
84+
85+
1. **JSON Configuration Loading**: Reads region configuration from `config.json`
86+
2. **Region Validation**: Ensures regions are valid and from the same geographical set
87+
3. **Dynamic KMS Stack Creation**: Creates regional KMS stacks based on configuration
88+
4. **Witness Region Support**: Adds `GlobalTableWitness` property for witness regions
89+
5. **Strong Consistency**: Applies `MultiRegionConsistency: STRONG` override
90+
91+
```typescript
92+
// Load and validate configuration
93+
const config = this.loadConfig();
94+
this.validateConfig(config);
95+
96+
// Create global table with dynamic replicas
97+
const globalTable = new dynamodb.TableV2(this, 'GlobalTable', {
98+
// ... configuration
99+
replicas: config.regions
100+
.filter(r => r.region !== this.region)
101+
.map(r => ({ region: r.region }))
102+
});
103+
104+
// Add strong consistency and witness regions
105+
const cfnTable = globalTable.node.defaultChild as dynamodb.CfnTable;
106+
cfnTable.addPropertyOverride('MultiRegionConsistency', 'STRONG');
107+
this.addWitnessRegions(cfnTable, config);
108+
```
109+
110+
## AWS Documentation References
111+
112+
- [DynamoDB Global Tables](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/V2globaltables_HowItWorks.html) - Overview of Global Tables functionality
113+
- [CloudFormation GlobalTable Resource](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-dynamodb-globaltable.html#cfn-dynamodb-globaltable-multiregionconsistency) - MultiRegionConsistency property reference
114+
- [GlobalTableWitness Property](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-properties-dynamodb-globaltable-globaltablewitness.html) - Witness region configuration reference
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env node
2+
import * as cdk from 'aws-cdk-lib';
3+
import { DdbGlobalMrscCdkStack } from '../lib/ddb-global-mrsc-cdk-stack';
4+
import { RegionalKmsStack } from '../lib/regional-kms-stack';
5+
import * as fs from 'fs';
6+
import * as path from 'path';
7+
import { RegionConfig, GlobalTableConfig } from '../lib/types';
8+
9+
const app = new cdk.App();
10+
11+
// Load config
12+
const configPath = path.join(__dirname, '..', 'config.json');
13+
const config: GlobalTableConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
14+
15+
// Create regional KMS stacks for all regions except primary
16+
const primaryRegion = config.regions[0].region;
17+
const regionalStacks: cdk.Stack[] = [];
18+
19+
for (const regionConfig of config.regions) {
20+
if (regionConfig.region !== primaryRegion && !regionConfig.witness) {
21+
const stack = new RegionalKmsStack(app, `DdbGlobalMrsc${regionConfig.region.replace(/-/g, '')}`, {
22+
env: {
23+
account: process.env.CDK_DEFAULT_ACCOUNT,
24+
region: regionConfig.region
25+
},
26+
tableName: config.tableName
27+
});
28+
regionalStacks.push(stack);
29+
}
30+
}
31+
32+
// Main global table stack
33+
const globalStack = new DdbGlobalMrscCdkStack(app, 'DdbGlobalMrscCdkStack', {
34+
env: {
35+
account: process.env.CDK_DEFAULT_ACCOUNT,
36+
region: primaryRegion
37+
}
38+
});
39+
40+
// Add dependencies
41+
regionalStacks.forEach(stack => globalStack.addDependency(stack));
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"app": "npx ts-node --prefer-ts-exts bin/app.ts"
3+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"tableName": "ap-global-table",
3+
"regions": [
4+
{
5+
"region": "ap-northeast-1",
6+
"witness": false
7+
},
8+
{
9+
"region": "ap-northeast-2",
10+
"witness": false
11+
},
12+
{
13+
"region": "ap-northeast-3",
14+
"witness": true
15+
}
16+
]
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"tableName": "eu-global-table",
3+
"regions": [
4+
{
5+
"region": "eu-west-1",
6+
"witness": false
7+
},
8+
{
9+
"region": "eu-west-2",
10+
"witness": false
11+
},
12+
{
13+
"region": "eu-central-1",
14+
"witness": true
15+
}
16+
]
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"tableName": "us-global-table",
3+
"regions": [
4+
{
5+
"region": "us-east-1",
6+
"witness": false
7+
},
8+
{
9+
"region": "us-east-2",
10+
"witness": false
11+
},
12+
{
13+
"region": "us-west-2",
14+
"witness": true
15+
}
16+
]
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"tableName": "global-table",
3+
"regions": [
4+
{
5+
"region": "eu-west-1",
6+
"witness": false
7+
},
8+
{
9+
"region": "eu-west-2",
10+
"witness": false
11+
},
12+
{
13+
"region": "eu-central-1",
14+
"witness": true
15+
}
16+
]
17+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import * as cdk from 'aws-cdk-lib';
2+
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
3+
import * as kms from 'aws-cdk-lib/aws-kms';
4+
import { Construct } from 'constructs';
5+
import * as fs from 'fs';
6+
import * as path from 'path';
7+
import { RegionConfig, GlobalTableConfig } from './types';
8+
9+
const VALID_REGIONS = {
10+
US: ['us-east-1', 'us-east-2', 'us-west-2'],
11+
EU: ['eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-central-1'],
12+
AP: ['ap-northeast-1', 'ap-northeast-2', 'ap-northeast-3']
13+
};
14+
15+
const ALL_VALID_REGIONS = [...VALID_REGIONS.US, ...VALID_REGIONS.EU, ...VALID_REGIONS.AP];
16+
17+
export class DdbGlobalMrscCdkStack extends cdk.Stack {
18+
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
19+
super(scope, id, props);
20+
21+
const config = this.loadConfig();
22+
this.validateConfig(config);
23+
24+
const primaryKey = new kms.Key(this, 'GlobalTableKey', {
25+
description: 'KMS key for DynamoDB Global Table encryption',
26+
enableKeyRotation: true,
27+
alias: `${config.tableName}-${this.region}`
28+
});
29+
30+
const replicaTableKeys = this.buildReplicaKeys(config);
31+
const replicas = config.regions
32+
.filter(r => r.region !== this.region && !r.witness)
33+
.map(r => ({ region: r.region }));
34+
35+
const globalTable = new dynamodb.TableV2(this, 'GlobalTable', {
36+
tableName: config.tableName,
37+
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
38+
billing: dynamodb.Billing.onDemand(),
39+
removalPolicy: cdk.RemovalPolicy.DESTROY,
40+
encryption: dynamodb.TableEncryptionV2.customerManagedKey(primaryKey, replicaTableKeys),
41+
replicas
42+
});
43+
44+
const cfnTable = globalTable.node.defaultChild as dynamodb.CfnTable;
45+
cfnTable.addPropertyOverride('MultiRegionConsistency', 'STRONG');
46+
47+
this.addWitnessRegions(cfnTable, config);
48+
}
49+
50+
private loadConfig(): GlobalTableConfig {
51+
const configPath = path.join(__dirname, '..', 'config.json');
52+
const configData = fs.readFileSync(configPath, 'utf8');
53+
return JSON.parse(configData);
54+
}
55+
56+
private validateConfig(config: GlobalTableConfig): void {
57+
if (!config.regions || config.regions.length < 3) {
58+
throw new Error('Config must specify at least 3 regions');
59+
}
60+
61+
for (const regionConfig of config.regions) {
62+
if (!ALL_VALID_REGIONS.includes(regionConfig.region)) {
63+
throw new Error(`Invalid region: ${regionConfig.region}. Valid regions: ${ALL_VALID_REGIONS.join(', ')}`);
64+
}
65+
}
66+
67+
const regionSet = Object.values(VALID_REGIONS);
68+
const configRegions = config.regions.map(r => r.region);
69+
const isValidSet = regionSet.some(validSet =>
70+
configRegions.every(region => validSet.includes(region))
71+
);
72+
73+
if (!isValidSet) {
74+
throw new Error('All regions must be from the same region set (US, EU, or AP)');
75+
}
76+
77+
const witnessCount = config.regions.filter(r => r.witness).length;
78+
const fullReplicaCount = config.regions.filter(r => !r.witness).length;
79+
80+
if (witnessCount > 1) {
81+
throw new Error('Maximum 1 witness region allowed');
82+
}
83+
84+
if (fullReplicaCount < 2) {
85+
throw new Error('At least 2 full replica regions required');
86+
}
87+
}
88+
89+
private buildReplicaKeys(config: GlobalTableConfig): Record<string, string> {
90+
const keys: Record<string, string> = {};
91+
for (const regionConfig of config.regions) {
92+
if (regionConfig.region !== this.region && !regionConfig.witness) {
93+
keys[regionConfig.region] = `arn:aws:kms:${regionConfig.region}:${this.account}:alias/${config.tableName}-${regionConfig.region}`;
94+
}
95+
}
96+
return keys;
97+
}
98+
99+
private addWitnessRegions(cfnTable: dynamodb.CfnTable, config: GlobalTableConfig): void {
100+
const witnessRegions = config.regions.filter(r => r.witness);
101+
if (witnessRegions.length > 0) {
102+
cfnTable.addPropertyOverride('GlobalTableWitnesses',
103+
witnessRegions.map(r => ({ Region: r.region }))
104+
);
105+
}
106+
}
107+
}

0 commit comments

Comments
 (0)