Serverless technology eases much of the pain of managing your infrastructure. Unfortunately, it’s only a partial solution to the headaches of multi-region deployments. While some core services like Amazon S3, Amazon Route53, and Amazon DynamoDB let you deploy globally, others, like AWS Lambda and AWS AppSync, don’t come with multi-region features out of the box.
I’ve found that deploying AppSync, AWS’ managed GraphQL API service, into two regions isn’t as straightforward as it is with an API Gateway. In this article, I’ll explain how to build a simple AppSync GraphQL API and deploy it on two continents.
The Need for a Multi-Region Deployment
Before we start, let’s talk about why you’d need a multi-region deployment in the first place. After all, it costs more and takes more work to maintain.
First, if your users want to load data from the other side of the world, latency quickly becomes a significant issue. The closer your infrastructure is to your users, the faster everything will be delivered.
Security and compliance are also crucial. If you collect data from people worldwide, you’ll likely need to implement different functionality to conform with local laws.
Finally, the more deployments you have, the smaller the chance that you’ll lose them all if something goes wrong. This could mean a whole region going bust in a catastrophe, or that a region would not be reachable for a few hours due to network problems. In either case, you can redirect users to fail-over regions.
Building a Multi-Region AppSync App
My journey to a multi-region serverless application started with some research that led to the architecture in Figure 1.
From my research, I learned that Route53 could route one domain to different services based on geolocation rules. Location-based routing allows me to route clients to a preferred region right at the DNS resolution. Furthermore, DynamoDB, the serverless AWS database, has a feature called global tables that will automatically sync its changes to tables of the same name in other regions.
This also works the other way around; if a replicated table receives a change, it will propagate that change back to the other tables. Of course, this replication is eventually consistent, but it still removes implementation I’d have to do myself.
Sadly, AppSync doesn’t support custom domains out of the box. As a result, I had to put an API Gateway in front of every AppSync deployment in order to get the DNS-based routing to work.
The overall idea is that Route53 sees where the client is located and resolves to the region that is configured for the client’s location. The API gateways will act as an HTTP proxy between the client and the API and simply forward everything they get. If the API has to write something to a DynamoDB table, this table will propagate the replica’s changes in the other regions.
Using an Infrastructure as Code Tool
For this project, I’m using the AWS Cloud Development Kit (CDK), since it allows me to define my infrastructure in JavaScript, and because it comes with some abstractions that AWS CloudFormation (CFN) doesn’t bring. These abstractions remove much of the boilerplate YAML code I would otherwise have to write myself. Also, DynamoDB global tables aren’t directly supported by CloudFormation, but the CDK does support them.
In this process, my 200 lines of commented JavaScript code were translated into 400 lines of YAML code. But using the CDK came with a downside: Route53 record sets for geolocation weren’t directly supported with CDK constructs. The CDK has constructs that abstract the CFN resources, and CFN has constructs that directly translate to CFN resources. While the CDK constructs are a bit more straightforward than the CFN ones, everything that works in CFN also works in the CDK.
Diving Into the Code
In developing the code, I worked from the bottom up, starting with the DynamoDB table, then creating an AppSync API. I put an API Gateway in front of the API and finally linked my domain to the API gateway.
You can find the finished code on GitHub, but I’ll cover the exciting parts in this article.
Creating a Table
Creating a global table is easy with the CDK. I just had to supply the constructor of the Table class with a replicationRegions array.
The following code defines the table:
createGlobalTable(props) {
const demoTable = "DemoTable";
if (!props.main)
return dynamodb.Table.fromTableName(
this,
demoTable,
demoTable
);
return new dynamodb.Table(this, demoTable, {
tableName: demoTable,
partitionKey: {
name: "id",
type: dynamodb.AttributeType.STRING
},
replicationRegions: props.replicationRegions,
});
}
The crucial part of this code is the “if” statement, because it checks if the props.main value is true. Props is an object that is supplied to the constructor of the stack class. Usually, I’ll create one stack per region or account, which allows me to supply different props for every instance of my stack, depending on the region.
I created a main prop here because I only need one global table. The other regions should use the replicas with the same name as the main table. This way, only the main deployment will create a table, and the other deployments will use an existing replica.
We know tech blogging.
Creating the AppSync API
The AppSync API here is just a standard API that can be used via HTTP and an automatically generated API key. It uses the DynamoDB table as a data source, along with a very simple GraphQL schema.
Creating the API Gateway Proxy
The most interesting part of this process is the proxy. AppSync doesn’t allow direct configuration with custom domains, and the geolocation via DNS requires you to set a custom domain.
There are two solutions to this problem. One is to create a CloudFront distribution in front of the AppSync API. I tried this approach, but it led nowhere. Only one CloudFront distribution can hold a domain, and I wanted to route from one domain to multiple distributions in different regions.
The other solution is to use an API Gateway in front of the AppSync API. This method worked. Because I could configure as many API gateways as I liked with one domain, routing from one domain to multiple API gateways was possible.
Registering a Domain with Route53
I first registered a domain with Route53 so I could use it to manage my hosted zones for that domain. I did this with the AWS console because, while my app will create some records in a hosted zone, it won’t create or delete the hosted zone.
Creating an SSL Certificate
The next steps were more complex. I had to configure the custom domain on the API Gateway side as well as the Route53 side.
API Gateway requires an SSL certificate to assign a custom domain to its APIs. To create an SSL certificate in the CDK, I needed the Certificate Manager, and the Certificate Manager required the hosted zone to create a new certificate. I then had to look up the hosted zone of my domain, create a certificate for the sub-domain in that hosted zone, and tell API Gateway that it could use that subdomain because I had a certificate for it.
The code for these steps looks like this:
const hostedZone =
route53.HostedZone.fromHostedZoneAttributes(
this,
"DemoZone",
{
zoneName: props.hostedZoneName,
hostedZoneId: props.hostedZoneId,
}
);
const certificate = new certificatemanager.Certificate(
this,
"DemoCert",
{
domainName: props.hostedDomain,
validation: certificatemanager.CertificateValidation
.fromDns(hostedZone),
}
);
const domainName = new apigateway.DomainName(
this,
"DemoDomain",
{
domainName: props.hostedDomain,
certificate,
}
);
Since I configured the domain using the AWS console, I passed the zone ID and name with props to my stack. The hostedDomain is the actual subdomain I want to use for my API. If my hostedZoneName were “example.com”, my hostedDomain would be “api.example.com.”
I tried to create a wildcard certificate with the AWS console first because I thought one certificate would be enough. However, the API Gateway requires a certificate in every region I want to deploy to, so I created the certificate in my CDK stack instead.
Putting the API Gateway in Front of the GraphQL API
Setting up an API gateway as proxy for the GraphQL API was pretty straightforward, as the following code illustrates:
const httpProxy = new apigateway.HttpApi(
this,
"DemoProxy",
{
defaultDomainMapping: {
domainName,
mappingKey: "graphql",
},
}
);
const graphqlApiIntegration =
new apigateway.HttpProxyIntegration({
url: api.graphqlUrl,
});
httpProxy.addRoutes({
path: "/",
methods: [apigateway.HttpMethod.POST],
integration: graphqlApiIntegration,
});
I used a plain HTTP API here and gave it the domainName object from a previous step. I also set a default mapping key for graphql, so every route I defined would go there. Then I created an HTTP integration and linked it to a route. Because GraphQL only uses POST requests, I just defined the route for this one method.
Creating a DNS Record
The last step was to create the geolocated DNS record with Route53. The CDK does not have its own construct to do this—only a plain CFN resource. This required some acrobatics with CFN intrinsic functions, but I got it working, as you can see in this code:
const dnsName = cdk.Fn.select(
2,
cdk.Fn.split("/", httpProxy.url)
);
new route53.CfnRecordSet(this, "DemoRecordSet", {
type: "A",
name: props.hostedDomain,
setIdentifier: "DemoApiRecordSet-" + props.geoLocation,
hostedZoneId: props.hostedZoneId,
geoLocation: { continentCode: props.geoLocation },
aliasTarget: {
dnsName,
hostedZoneId: props.apiGatewayHostedZoneId,
},
});
So, what’s happening here?
Route53 wants a domain, but the previously created HTTP API is only giving me a URL with protocol, etc. This means I have to remove everything around the domain. The problem is that the URL was only available when CFN deployed the HTTP API, so I couldn’t use JavaScript to modify it. In JavaScript, that URL is a token I need to pass to special functions for string manipulation supplied by the CDK.
After I extracted the domain, I created the record. It’s a DNS type A record for our hosted domain (i.e., api.example.com). Because I use one domain for multiple services and route based on location, it needs a unique setIdentifier to be distinguishable from the other deployments (Remember, Route53 is a global service.)
The hosted zone ID is retrieved from the Route53 AWS console, and as continentCode, I pass NA for North-America or EU for Europe.
The dnsName is the domain AWS generated for our HTTP API. When you create an API gateway, AWS automatically assigns it a subdomain from its own contingent. These domains are defined in an AWS-owned hosted zone, so the hostedZoneId of the aliasTarget needs to be the ID of this AWS-owned zone. They differ from region to region, but you can find a list of zones here.
Exporting the URLs and API Key
The last step is just defining some outputs: the regional URL of the GraphQL API, the global geolocated URL, and the API key of the deployed API—essentially, all of the information required to use the API.
Summary
As you can see, building a simple AppSync GraphQL API, and deploying it on two continents isn’t always easy. Through this process, I sometimes found solutions that worked great, like DynamoDB global tables. Other times, the solutions failed right before the finish line. For example, I invested multiple days into the CloudFront approach, only to discover that I couldn’t reuse a domain for multiple distributions.
It also took me a few days to find out that it wasn’t enough to create a domain in Route53 that points to an API Gateway, and I had to tell the API Gateway that it was okay to serve data over that domain.
I tried many options with the AWS console before coding them in JavaScript, and I sometimes found problems within just a few clicks. For instance, the console does many things implicitly that need to be created explicitly when coding it later. Inconsistent naming was also a problem. Another issue was that the console showed one string, while the CDK used another.
I started this process by splitting the whole project into small chunks and working to solve the multi-region problem for every one of those chunks. Creating a serverless application that gets replicated in multiple regions isn’t as straightforward as one may hope, but with time and dedication, it can be done.