Safety Nets: CloudFormation Custom Resources for Regional API Gateways

Up until this week we’ve been utilizing edge optimized custom domains within our API Gateways. This has been really easy to set up using CloudFormation and has been a great way for us to tightly control the URLs used to access our REST platform.

In order to support a more global expansion of the platform on AWS, we’re ditching the Edge optimized custom domains and getting into the Regional custom domains which were launched by AWS at reInvent 2017.

This caused me one major headache – the AWS::ApiGateway:DomainName resource in CloudFormation currently has no way to look up what the underlying URL is for me to add the appropriate Route53 record.

Before I get into the sample code and solution, let’s do a quick introduction into the difference between edge, and regional API Gateway endpoints.

Essentially an API Gateway Custom Domain is just an abstraction layer over top of CloudFront. When you create a Custom Domain, AWS will create a simplified CloudFront distribution under the hood, and hide it from you in the AWS Console. This saves a hundred or so lines of CloudFormation template code, but does limit you to using the very simplified Custom Domain console.

An Edge Optimized API Gateway takes advantage of CloudFront to reduce latency between your API and a requester by utilizing an edge location that’s as close to the requester as possible. You can cache API responses in these edge locations for even faster response times for commonly used routes that have little change to the data returned.

AWS launched Regional API Endpoints at reInvent in 2017 which allows you to handle the traffic routing to the closest API Gateway endpoint yourself (which was our driving factor to moving away from Edge endpoints), or to spin up your own CloudFront distribution – which lets you leverage some very powerful features like Lambda@Edge, caching based on headers, and a whole lot more.

Okay, introduction to Regional and Edge endpoints done, let’s talk about solving the problem at hand.

With the AWS::ApiGateway::DomainName resource, you can do an Fn::GetAtt function to retrieve the CloudFront domain name (abcd12fgab.cloudfront.net) which lets you easily create the Route53 alias records to direct traffic to the endpoint. Here’s what my output looked like:

"RestApiLiveCloudFrontDistribution": {
   "Description": "The api-gateway live cloudfront distribution for this api",
   "Export": {
      "Name": "RestApiLiveCloudFrontDistribution"
   },
   "Value": {
      "Fn::GetAtt": [
         "ApiLiveDomain",
         "DistributionDomainName"
      ]
  }
}

Frustratingly this seems to have been overlooked by the CloudFormation team when they implemented Regional endpoints, as the AWS API actually uses a different property for the underlying domain name. When utilizing a Regional endpoint, the DistributionDomainName property doesn’t exist. The AWS REST API actually wants to return a RegionalDomainName.

I can’t really fault the CloudFormation team, they’ve built an incredible product, and it’s hard to keep up with the speed of innovation of the entire AWS product development team. Luckily for all of us, the CloudFormation team thought of this and long ago introduced Lambda Backed Custom Resources.

A Lambda backed resource simply lets you create a custom resource type in CloudFormation that takes input, utilizes a Lambda to do some logic, and return a set of values to CloudFormation.

So here’s how we get the RegionalDomainName from API Gateway using a custom resource.

Assuming we have an API Gateway DomainName similar to this to start:

"ApiStagingDomain": {
   "Type": "AWS::ApiGateway::DomainName",
   "DependsOn": "WebCert",
   "Properties": {
      "DomainName": "example.domain.io",
      "RegionalCertificateArn": {
         "Ref": "WebCert"
      },
      "EndpointConfiguration": {
          "Types": [
             "REGIONAL"
          ]
      }
   }
}

We need two things to support our custom resource, a Lambda function, and an IAM Role that has permissions to access API Gateway. Here’s my sample IAM Role:

    "ApiGwResourceRole": {
        "Type": "AWS::IAM::Role",
        "Properties": {
            "RoleName": "platform-api-apigw-getdomain",
            "AssumeRolePolicyDocument": {
                "Version": "2012-10-17",
                "Statement": [
                    {
                        "Effect": "Allow",
                        "Principal": {
                            "Service": [
                                "lambda.amazonaws.com"
                            ]
                        },
                        "Action": [
                            "sts:AssumeRole"
                        ]
                    }
                ]
            },
            "Path": "/",
            "Policies": [
                {
                    "PolicyName": "root",
                    "PolicyDocument": {
                        "Version": "2012-10-17",
                        "Statement": [
                            {
                                "Effect": "Allow",
                                "Action": [
                                    "apigateway:GET*"
                                ],
                                "Resource": "*"
                            },
                            {
                                "Effect": "Allow",
                                "Action": [
                                    "logs:CreateLogGroup",
                                    "logs:CreateLogStream",
                                    "logs:PutLogEvents",
                                    "logs:PutMetricFilter",
                                    "logs:PutRetentionPolicy"
                                ],
                                "Resource": [
                                    {
                                        "Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*:log-stream:*"
                                    }
                                ]
                            }
                        ]
                    }
                }
            ]
        }
    }

This just gives access to Cloudwatch logging, and the API Gateway GET action.

Here’s my lambda function, which references the zipped NodeJS function that’s in a bucket in the same region as the CloudFormation template I’m going to deploy.

"ApiGwResourceLambda": {
        "Type": "AWS::Lambda::Function",
        "DependsOn": "ApiGwResourceRole",
        "Properties": {
            "FunctionName": "get-apigateway-domainname",
            "Code": {
                "S3Bucket": {
                    "Fn::Sub": "some-s3-bucket-${AWS::Region}"
                },
                "S3Key": "get-regional-apigw-domain.zip"
            },
            "Handler": "index.handler",
            "Role": {
                "Fn::GetAtt": [
                    "ApiGwResourceRole",
                    "Arn"
                ]
            },
            "Timeout": 30,
            "Runtime": "nodejs6.10"
        }
    }

And finally, my custom resource. The ServiceToken property indicates which Lambda to call, and then Region and CustomDomainName are passed as parameters to that Lambda.

"ApiGatewayDomainInfo": {
        "Type": "Custom::ApiGatewayDomainInfo",
        "DependsOn": [
            "ApiGwResourceLambda",
            "ApiLiveDomain"
        ],
        "Properties": {
            "ServiceToken": {
                "Fn::GetAtt": [
                    "ApiGwResourceLambda",
                    "Arn"
                ]
            },
            "Region": {
                "Ref": "AWS::Region"
            },
            "CustomDomainName": "example.domain.io"
        }
    }

So this is all pretty straight forward. I won’t paste the entirety of my Lambda code inline here, but you can download the ZIP and pick it apart to your hearts content. Get it here: get-regional-apigw-domain

Feel free to use, abuse, and reuse that code to your hearts content. It’s a good basic sample of a custom resource that simply returns a value that’s not available in CloudFormation. I’ll do a post at a later date on how to use it to actually handle creates, updates, and deletes if you want to create resources elsewhere (like calling another API).

So, now that we have everything in place, let’s see how our Fn::GetAtt function has changed:

    "RestApiStageCloudFrontDistribution": {
        "Description": "The api-gateway staging cloudfront distribution for this api",
        "Export": {
            "Name": {
                "Fn::Sub": "${AWS::StackName}:RestApiStageCloudFrontDistribution"
            }
        },
        "Value": {
            "Fn::GetAtt": [
                "ApiGatewayStageDomainInfo",
                "RegionalDomainName"
            ]
        }
    }

You can see we’re now referencing the resource name of our Custom Resouce “ApiGatewayStageDomainInfo” and the attribute we want (which is the name that is returned by the Lambda function) is RegionalDomainName.

That’s it! Custom Resources have saved me in about 3 or 4 different major areas (like before CloudFront Origin IDs were in CloudFormation), so it’s really worth digging in and mastering this powerful tool. Eventually once the CloudFormation team is able to implement something directly in CloudFormation, I’ll simply replace my Fn::GetAtt and delete the Lambda, IAM Role, and Custom Resource. Once you do it once, you can use your own implementation as a template and it makes it really quick and easy to implement.

That’s it for this week. Hope you all have a fantastic weekend.

Cheers,
James.


Posted

in

, , , ,

by

Tags: