Deploy a Multi Region Serverless Golang App with CDK

Nov 11, 2020

In this example we will show how you can deploy a serverless application written in Golang and deploy it in multiple regions using the AWS CDK with Python as the configuration language.

The stack running our application will be composed out of the following resources:

  • API Gateway with custom domain names
  • ACM Certificates
  • Lambda function (written in Golang)

NOTE: The code used in this post can be found on GitHub

How to install AWS CDK

This is rather straighforward

npm install -g aws-cdk

Bootstrap a new project with AWS CDK

For this example we will bootstrap the project in a folder named serverless-multi-region.

cd serverless-multi-region
cdk init --language python

NOTE: if you have Git installed cdk init will also initialize a Git repository.

Since we are using Python it’s a best practice to use a virtualenv so we can install additional Python libraries without impacting our system installed libraries. cdk init already provides everything to make this easy for us and all we need to do is run:

source .env/bin/activate
python -m pip install -r requirements.txt

NOTE: You can also use pipenv if you want but then you’ll need to perform some small hacks that might bite you later on so we’ll keep it simple for now.

Project structure

Before we get started lets have a look at how the project is structured after bootstrapping.

.
├── app.py
├── serverless_multi_region
│   ├── serverless_multi_region_stack.py
│   └── __init__.py
├── cdk.json
├── README.md
├── requirements.txt
├── setup.py
└── source.bat

app.py: This is the entrypoint for the stacks we want to deploy. Here we will import all the stacks from our module folder . We’ll make some changes later on by adding more stacks for all the different regions.

serverless_multi_region: this folder is used as a Python module and contains all the stacks used to create the AWS resources. The CDK creates a default stack with the following content.

cdk.json: CDK configuration file that defines what needs to be executed to generate the CDK construct tree.

README.md: Bootstrapped readme file describing the project.

requirements and setup.py are used to install the necessary Python libraries in our Virtualenv.

.gitignore: Initial .gitignore file to prevent committing CDK staging and local Python data

Serverless app code

We’ll keep the code of the serverless application simple by just printing Serverless runs on servers when we issue a GET request to the /test endpoint of our API.

To keep everything clean we’ll store the application code in a src folder which we create in the root of our project.

CDK

Lambda function

Before adding the Lambda function to our stack we need to build the golang binary to which we’ll point our handler to. The binary will be put in a dist folder so we can exclude it from being committed to Git.

go build -o dist/myfunction ./src/main.go

For each AWS Resource Type we want to create using the CDK we need to install the necessary Python libraries to enable support for these resources. For our Lambda function we need both the aws-lambda and aws-logs libraries.

python -m pip install aws-cdk.aws-logs aws-cdk.aws-lambda

NOTE: Make sure you are running in a virtualenv

Then we can add our Lambda function to the stack by editing the ServerlessMultiRegionStack (which can be found in the serverless_multi_region folder.

from aws_cdk import (
    core,
    aws_lambda as _lambda,
    aws_logs
)
import os


class ServerlessMultiRegionStack(core.Stack):

    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        # The code that defines your stack goes here
        MyFunction = _lambda.Function(
            self,
            "MyFunction",
            code=_lambda.Code.from_asset("dist"),
            handler="myfunction",
            runtime=_lambda.Runtime.GO_1_X,
            log_retention=aws_logs.RetentionDays.ONE_WEEK,
            memory_size=128,
            events=[],
            environment={
                "DNS_SUFFIX_PUB": os.getenv("DNS_SUFFIX_PUB")
            }
        )

Now we are ready to test and deploy. You can test your code by running cdk synth. This will generate a Cloudformation template and show it in stdout. If this runs without errors you are ready to deploy with cdk deploy

cdk synth
...
<output ommited>
...
cdk deploy
IAM Statement Changes
┌───┬────────────────────────────┬────────┬────────────────────────────┬──────────────────────────────┬───────────┐
│   │ Resource                   │ Effect │ Action                     │ Principal                    │ Condition │
├───┼────────────────────────────┼────────┼────────────────────────────┼──────────────────────────────┼───────────┤
│ + │ ${AggregatorFunction/Servi │ Allow  │ sts:AssumeRole             │ Service:lambda.amazonaws.com │           │
│   │ ceRole.Arn}                │        │                            │                              │           │
├───┼────────────────────────────┼────────┼────────────────────────────┼──────────────────────────────┼───────────┤
│ + │ ${LogRetentionaae0aa3c5b4d │ Allow  │ sts:AssumeRole             │ Service:lambda.amazonaws.com │           │
│   │ 4f87b02d85b201efdd8a/Servi │        │                            │                              │           │
│   │ ceRole.Arn}                │        │                            │                              │           │
├───┼────────────────────────────┼────────┼────────────────────────────┼──────────────────────────────┼───────────┤
│ + │ *                          │ Allow  │ logs:DeleteRetentionPolicy │ AWS:${LogRetentionaae0aa3c5b │           │
│   │                            │        │ logs:PutRetentionPolicy    │ 4d4f87b02d85b201efdd8a/Servi │           │
│   │                            │        │                            │ ceRole}                      │           │
└───┴────────────────────────────┴────────┴────────────────────────────┴──────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬──────────────────────────────────────────────────────┬──────────────────────────────────────────────────────┐
│   │ Resource                                             │ Managed Policy ARN                                   │
├───┼──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────┤
│ + │ ${AggregatorFunction/ServiceRole}                    │ arn:${AWS::Partition}:iam::aws:policy/service-role/A │
│   │                                                      │ WSLambdaBasicExecutionRole                           │
├───┼──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────┤
│ + │ ${LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/Servi │ arn:${AWS::Partition}:iam::aws:policy/service-role/A │
│   │ ceRole}                                              │ WSLambdaBasicExecutionRole                           │
└───┴──────────────────────────────────────────────────────┴──────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)?
serverless-multi-region-eu: deploying...
[0%] start: Publishing 88d538cad4f5f278bb5e7a4e487063ef747abc747bef069f9cf467937c8487e0:current
[50%] success: Published 88d538cad4f5f278bb5e7a4e487063ef747abc747bef069f9cf467937c8487e0:current
[50%] start: Publishing 67b7823b74bc135986aa72f889d6a8da058d0c4a20cbc2dfc6f78995fdd2fc24:current
[100%] success: Published 67b7823b74bc135986aa72f889d6a8da058d0c4a20cbc2dfc6f78995fdd2fc24:current
serverless-multi-region-eu: creating CloudFormation changeset...
[██████████████████████████████████████████████████████████] (8/8)

API Gateway

Install the necessary Python CDK libraries for api gateway resources

python -m pip install aws-cdk.aws-apigateway

And import the library

from aws_cdk import (
    core,
    aws_lambda as _lambda,
    aws_logs,
    aws_apigateway as apigateway
)

Add the resources to our stack.

        my_api = apigateway.RestApi(
            self,
            "MyApi"
        )

        myfunction_integration = apigateway.LambdaIntegration(my_function)

        test_resource = my_api.root.add_resource("test")
        test_resource.add_method("GET", myfunction_integration)

That’s it. These few lines are enough to create:

  • Rest API
  • resource (GET on/test)
  • Lambda integration with all the necessary permissions.

Update the stack by running cdk deploy

IAM Statement Changes
┌───┬────────────────────────┬────────┬────────────────────────┬────────────────────────┬─────────────────────────┐
│   │ Resource               │ Effect │ Action                 │ Principal              │ Condition               │
├───┼────────────────────────┼────────┼────────────────────────┼────────────────────────┼─────────────────────────┤
│ + │ ${LogRetentionaae0aa3c │ Allow  │ sts:AssumeRole         │ Service:lambda.amazona │                         │
│   │ 5b4d4f87b02d85b201efdd │        │                        │ ws.com                 │                         │
│   │ 8a/ServiceRole.Arn}    │        │                        │                        │                         │
├───┼────────────────────────┼────────┼────────────────────────┼────────────────────────┼─────────────────────────┤
│ + │ ${MyApi/CloudWatchRole │ Allow  │ sts:AssumeRole         │ Service:apigateway.ama │                         │
│   │ .Arn}                  │        │                        │ zonaws.com             │                         │
├───┼────────────────────────┼────────┼────────────────────────┼────────────────────────┼─────────────────────────┤
│ + │ ${MyFunction.Arn}      │ Allow  │ lambda:InvokeFunction  │ Service:apigateway.ama │ "ArnLike": {            │
│   │                        │        │                        │ zonaws.com             │   "AWS:SourceArn": "arn │
│   │                        │        │                        │                        │ :${AWS::Partition}:exec │
│   │                        │        │                        │                        │ ute-api:${AWS::Region}: │
│   │                        │        │                        │                        │ ${AWS::AccountId}:${MyA │
│   │                        │        │                        │                        │ pi49610EDF}/${MyApi/Dep │
│   │                        │        │                        │                        │ loymentStage.prod}/GET/ │
│   │                        │        │                        │                        │ test"                   │
│   │                        │        │                        │                        │ }                       │
│ + │ ${MyFunction.Arn}      │ Allow  │ lambda:InvokeFunction  │ Service:apigateway.ama │ "ArnLike": {            │
│   │                        │        │                        │ zonaws.com             │   "AWS:SourceArn": "arn │
│   │                        │        │                        │                        │ :${AWS::Partition}:exec │
│   │                        │        │                        │                        │ ute-api:${AWS::Region}: │
│   │                        │        │                        │                        │ ${AWS::AccountId}:${MyA │
│   │                        │        │                        │                        │ pi49610EDF}/test-invoke │
│   │                        │        │                        │                        │ -stage/GET/test"        │
│   │                        │        │                        │                        │ }                       │
├───┼────────────────────────┼────────┼────────────────────────┼────────────────────────┼─────────────────────────┤
│ + │ ${MyFunction/ServiceRo │ Allow  │ sts:AssumeRole         │ Service:lambda.amazona │                         │
│   │ le.Arn}                │        │                        │ ws.com                 │                         │
├───┼────────────────────────┼────────┼────────────────────────┼────────────────────────┼─────────────────────────┤
│ + │ *                      │ Allow  │ logs:DeleteRetentionPo │ AWS:${LogRetentionaae0 │                         │
│   │                        │        │ licy                   │ aa3c5b4d4f87b02d85b201 │                         │
│   │                        │        │ logs:PutRetentionPolic │ efdd8a/ServiceRole}    │                         │
│   │                        │        │ y                      │                        │                         │
└───┴────────────────────────┴────────┴────────────────────────┴────────────────────────┴─────────────────────────┘
IAM Policy Changes
┌───┬──────────────────────────────────────────────────────┬──────────────────────────────────────────────────────┐
│   │ Resource                                             │ Managed Policy ARN                                   │
├───┼──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────┤
│ + │ ${LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/Servi │ arn:${AWS::Partition}:iam::aws:policy/service-role/A │
│   │ ceRole}                                              │ WSLambdaBasicExecutionRole                           │
├───┼──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────┤
│ + │ ${MyApi/CloudWatchRole}                              │ arn:${AWS::Partition}:iam::aws:policy/service-role/A │
│   │                                                      │ mazonAPIGatewayPushToCloudWatchLogs                  │
├───┼──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────┤
│ + │ ${MyFunction/ServiceRole}                            │ arn:${AWS::Partition}:iam::aws:policy/service-role/A │
│   │                                                      │ WSLambdaBasicExecutionRole                           │
└───┴──────────────────────────────────────────────────────┴──────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
serverless-multi-region-eu: deploying...
[0%] start: Publishing 88d538cad4f5f278bb5e7a4e487063ef747abc747bef069f9cf467937c8487e0:current
[50%] success: Published 88d538cad4f5f278bb5e7a4e487063ef747abc747bef069f9cf467937c8487e0:current
[50%] start: Publishing 67b7823b74bc135986aa72f889d6a8da058d0c4a20cbc2dfc6f78995fdd2fc24:current
[100%] success: Published 67b7823b74bc135986aa72f889d6a8da058d0c4a20cbc2dfc6f78995fdd2fc24:current
serverless-multi-region-eu: creating CloudFormation changeset...
[██████████████████████████████████████████████████████████] (17/17)





 ✅  serverless-multi-region-eu

Outputs:
serverless-multi-region-eu.MyApiEndpoint869ABE96 = https://z0nqyt0r1i.execute-api.eu-west-1.amazonaws.com/prod/

Stack ARN:
arn:aws:cloudformation:eu-west-1:440357826049:stack/serverless-multi-region-eu/5827c510-169d-11eb-b147-0ae0d664c880

Notice the link to the base URI of our deployment which can be used to test the API. This URI is mainly for testing purposes and later on we’ll change that to a proper domain name.

❯ curl -s https://z0nqyt0r1i.execute-api.eu-west-1.amazonaws.com/prod/test | jq
{
  "message": "Serverless runs on servers."
}

NOTE: By default a prod stage is created in API Gateway during deployment as you can see in the URI which ends with /prod. To test your API, you need to add the resource. In this case /test.

Multi region deploy

To have our API deployed in multiple regions we’ll create a second stack in another region (us-east-1 in this example). This can simply be accomplished by adding a stack to app.py using different Environments. Notice that we are using the same stack as for eu-west-1 but just passing different Environment options.

#!/usr/bin/env python3

from aws_cdk import core

from serverless_multi_region.serverless_multi_region_stack import ServerlessMultiRegionStack


app = core.App()

env_EU = core.Environment(region="eu-west-1")
env_US = core.Environment(region="us-east-1")

ServerlessMultiRegionStack(app, "serverless-multi-region-eu", env=env_EU)
ServerlessMultiRegionStack(app, "serverless-multi-region-us", env=env_US)

app.synth()

You could now run a cdk deploy for each stack separately but the CDK also allows the use of wildcards so we can run:

cdk deploy serverless-multi-region-*` 

If that finished successful you should have 1 API deployed in eu-west-1 and onther one in us-east-1 each with their own base URI.

Custom Domain Names

The base URIs generated during the deployment are useful for testing but not really convenient for end users of our service. Besides that, we also want users to connect to the REST API geographically closesd to them. This is where Custom Domains can be used for.

In this example we assume that:

  • there is already a Route53 zone created
  • an SSL/TLS certificate in AWS Certificate Manager (ACM) in all the regions where we want to deploy the API. This is needed because we use regional custom domain named instead of edge-opimized custom domain names for which the certificate needs to be available in us-east-1

Install the necessary Python libraries:

python pip install aws-cdk.aws-route53-targets aws-cdk.aws-route53 aws-cdk.aws-certificatemanager

And import them.

from aws_cdk import (
    core,
    aws_lambda as _lambda,
    aws_logs,
    aws_apigateway as apigateway,
    aws_route53 as route53,
    aws_route53_targets as route53_targets,
    aws_certificatemanager as certificatemanager
)
import os

Then we can add the resources to our stack

        # -- DNS Configuration
        # Get the Route53 Zone
        dns_zone = route53.HostedZone.from_lookup(
            self,
            "ApiZone",
            private_zone=False,
            domain_name="zoolite.eu"
        )

        # Get the certificate
        api_domain_cert = certificatemanager.Certificate.from_certificate_arn(
            self,
            "DomainCertificate",
            cert_arn
        )

        # Create the Custom Domain
        api_dns_name = apigateway.DomainName(
            self,
            "ApiDomainName",
            domain_name="my-api.zoolite.eu",
            endpoint_type=apigateway.EndpointType.REGIONAL,
            certificate=api_domain_cert,
            security_policy=apigateway.SecurityPolicy.TLS_1_2
        )

        #api_dns_name.add_base_path_mapping(my_api, base_path="test")
        api_dns_name.add_base_path_mapping(my_api)

        # Create the Route53 record
        api_dns_record = route53.ARecord(
            self,
            "MyAPiDNSRecord",
            target=route53.RecordTarget.from_alias(
                route53_targets.ApiGatewayDomain(api_dns_name)
            ),
            record_name="my-api",
            zone=dns_zone
        )

        # Configure latency based routing on record
        recordset = api_dns_record.node.default_child
        recordset.region = self.region
        recordset.set_identifier = api_dns_record.node.unique_id

ACM Certificate

Because the SSl/TLS certificates are not created in this stack we need to provide the CDK with the necessary information. This means we need a certificate resource as a Python ICertificate class type.

To do this, we can use the from_certificate_arn method of the Certificate resource and pass it the ARN of the certificate as shown here:

        # Get the certificate
        api_domain_cert = certificatemanager.Certificate.from_certificate_arn(
            self,
            "DomainCertificate",
            cert_arn
        )

The only thing left to do is define the cert_arn parameter. One way this can be done is by passing it as a parameter to our Stack like this in app.py:

#!/usr/bin/env python3

from aws_cdk import core

from serverless_multi_region.serverless_multi_region_stack import ServerlessMultiRegionStack


app = core.App()

env_EU = core.Environment(account="123456789012", region="eu-west-1")
env_US = core.Environment(account="123456789012", region="us-east-1")

ServerlessMultiRegionStack(
    app,
    "serverless-multi-region-eu",
    cert_arn="arn:aws:acm:eu-west-1:123456789012:certificate/6c626c39-7573-54bc-b458-efd959e0170a",
    env=env_EU,
)

ServerlessMultiRegionStack(
    app,
    "serverless-multi-region-us",
    cert_arn="arn:aws:acm:us-east-1:123456789012:certificate/551e8c78-e6e1-44e7-bf01-bb55cc19fd23",
    env=env_US,
)

app.synth()

And then add the parameter to the module itself in serverless_multi_region_stack.py

    def __init__(self, scope: core.Construct, id: str, cert_arn, **kwargs) -> None:

Troubleshooting

While building this I encounterd some issues like this one and thought I’d leave it here in case someone encounters the same:

This CDK CLI is not compatible with the CDK library used by your application. Please upgrade the CLI to the latest version.
(Cloud assembly schema version mismatch: Maximum schema version supported is 5.0.0, but found 6.0.0)

This can be fixed by updating the CDK

npm update -g aws-cdk

Conclusion

With the AWS CDK you can define your infrastructure/resources in a “real” programing language which allows you to use all the primitives that langauge provides. You can also leverage all the features available in your your IDE/editor that language (like autocompletion, syntax checks, ..)

The CDK also abstracts away a lot more compared to Cloudformation where every property and resource needs to be specified. This keeps things more condense

As the saying “With great power comes great responsibility” goes, you’ll need to be careful not to make things to complicated. And the fact that there is more abstraction doesn’t mean you don’t need to know these details anymore. On the contrary, I would advise newcomers to start with Cloudformation as it will learn you a lot about how the AWS services work and what their “quirks” are (and there are quite a lot of them 😃.