Hadrian Hughes
Making use of Lambda@Edge outside of us-east-1
Recently I’ve been working on a pet project involving the orbits of the planets. It’s a serverless REST API built with AWS CDK. I won’t talk much about my goals for the project here as I’ll likely make another post going further into that, but if you’re interested you can take a look at the GitHub repo here. Moreover, I was motivated to write this post after running into what must be a fairly common problem when building serverless applications, but for which helpful information was sorely lacking. Hopefully, this article will save someone a bit of time.
The Problem
The problem emerged when I decided to have CloudFront handle requests before they’re sent to the origin, by deciding which underlying API Gateway handler to send the traffic to based on query string values. The logic made sense, and the CDK made it easy to hook up my Python file as a Lambda@Edge handler. That was until I tried to deploy my stack and this error appeared:

Lambda@Edge functions must be created in the us-east-1 region. This shouldn’t have come as a surprise since, as I subsequently found out, this restriction is quite well documented in the CDK docs, and there are some other AWS services with the same restriction, such as ACM. The limitation doesn’t lead to an increase in latency because Lambda@Edge is a feature of CloudFront, so while the resources themselves are stored in us-east-1, replicas of them are automatically distributed across the CloudFront CDN.
A Solution
This is all well and good, but how does one deal with this when their CDK stack is housed in another region? My first thought was to move my entire CDK stack into the us-east-1 region. This would undoubtedly fix the issue, but it wasn’t the most attractive solution since I live in the UK and realistically, so do most of my would-be users.
After some digging through StackOverflow and GitHub issues, I found that spinning up a separate stack to house the Lambda@Edge functions was going to be unavoidable. On the upside, this is quite simple; it’s just a case of making a class that extends cdk.Stack
and instantiating it alongside the main stack in bin/cdk.ts.
require('dotenv').config()
import 'source-map-support/register'
import * as cdk from '@aws-cdk/core'
import { PlanetsStack } from '../lib/planets-stack'
import { EdgeStack } from '../lib/edge-stack'
= new cdk.App();
const app
= new EdgeStack(app, 'EdgeStack')
const edgeStack
new PlanetsStack(app, 'PlanetsStack').addDependency(edgeStack);
Here my edgeStack
provisions the Lambda resources to be used for Lambda@Edge. The tricky part is grabbing the relevant Amazon Resource Name (ARN) from your main stack (PlanetsStack
) in the other region (in my case eu-west-2).
Storing ARNs in SSM Parameter Store
It’s worth mentioning up front that a dirty but perfectly functioning solution is to just hard code the ARN of your Lambda resource from us-east-1 into your main stack. If that approach solves the issue for your use case, you can safely stop reading here. For no particular reason, I wanted a solution that didn’t involve any hard coding of values; there’s something stinky about specifying an ARN for a resource that may or may not be there down the line.
The solution I found which involved the least overhead was to store the ARNs from us-east-1 in the SSM Parameter Store, where they can then be fetched by a CustomResource
in any other region. The code snippets in this GitHub reply worked perfectly for me - many thanks to KurtMar!
.Stack {
export class EdgeStack extends cdk: iam.Role
private customIamRole
constructor(scope: cdk.Construct, id: string) {
super(scope, id, {
: {
env: 'us-east-1'
region
}
})
// Create role for Lambda@Edge functions
.customIamRole = new iam.Role(this, 'AllowLambdaServiceToAssumeRole', {
this: new iam.CompositePrincipal(
assumedBynew iam.ServicePrincipal('lambda.amazonaws.com'),
new iam.ServicePrincipal('edgelambda.amazonaws.com')
,
): [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')]
managedPolicies
})
// Make Lambda@Edge functions
: Dict<string> = {
const edgeFunctions'QueryToID': this.makeEdgeFunction('QueryToID', 'query_to_id'),
'StripAPIPath': this.makeEdgeFunction('StripAPIPath', 'strip_api_path')
}
// Export an SSM Parameter for each function
.keys(edgeFunctions).forEach(key => new ssm.StringParameter(this, `${key}ARN`, {
Object: `/PlanetsAPI/${key}ARN`,
parameterName: `CDK parameter from ${key} Lambda@Edge function`,
description: edgeFunctions[key]
stringValue
}))
}
makeEdgeFunction(name: string, dir: string): string {
private new lambda.PythonFunction(this, name, {
return : PYTHON_RUNTIME,
runtime: path.join(__dirname, '..', 'lambdas', 'edge', dir),
entry: 'handler',
handler: this.customIamRole
role.currentVersion.functionArn
})
} }
Here I create a basic execution role and add it as a property so it can be shared by all the Lambdas. I had two Lambdas to bring in, so I made the makeEdgeFunction
method as an abstraction to remove bloat from the constructor. I then iterate over each entry in the edgeFunctions
object and create an ssm.StringParameter
for each one.
Reading SSM Parameters in PlanetsStack
The next step is to make a call from our main stack to fetch the values saved in SSM.
.Construct {
export class EdgeHandler extends cdkreadonly edgeFunctions: Dict<lambda.IVersion>
public : cdk.Stack
private stack
constructor(scope: cdk.Construct, id: string) {
super(scope, id)
.stack = scope as cdk.Stack
this
.edgeFunctions =
this'QueryToID', 'StripAPIPath']
[.reduce((acc: Dict<lambda.IVersion>, name: string) => {
= this.loadParameter(`${name}ARN`)
const resource = resource.getResponseField('Parameter.Value')
const arn
return {...acc,
: lambda.Version.fromVersionArn(this, `${name}EdgeVersion`, arn)
[name]
}, {})
}
}
loadParameter(name: string): cr.AwsCustomResource {
private new cr.AwsCustomResource(this, `Get${name}Parameter`, {
return : cr.AwsCustomResourcePolicy.fromStatements([
policynew iam.PolicyStatement({
: iam.Effect.ALLOW,
effect: ['ssm:GetParameter*'],
actions: [
resources.stack.formatArn({
this: 'ssm',
service: 'us-east-1',
region: `parameter/PlanetsAPI/${name}`
resource
})
]
}),
]): {
onUpdate: 'SSM',
service: 'getParameter',
action: {
parameters: `/PlanetsAPI/${name}`
Name,
}: 'us-east-1',
region: cr.PhysicalResourceId.of(Date.now().toString())
physicalResourceId
}
})
} }
Since there’s quite a sizable chunk of code responsible for accessing the parameter store, I built an EdgeHandler
class to deal with any and all cross-region stuff.
In the constructor I build up a dictionary of Lambda@Edge functions by calling the loadParameter
method for each one. This method returns a unique AwsCustomResource
containing the value from SSM. Back in the constructor, I call getResponseField('Parameter.Value')
on the returned resource in order to get the ARN as a string. Since this dictionary is a public property on the class, its entries will be accessible from our main stack.
Putting it all together
Finally, we want to plug the Lambda@Edge functions into a CloudFront distribution.
.Stack {
export class PlanetsStack extends cdkconstructor(scope: cdk.Construct, id: string) {
super(scope, id);
= new EdgeHandler(this, 'EdgeHandler')
const edgeHandler
// Set up API Gateway
= new PlanetsAPI(this, 'PlanetsAPI')
const api
= new cf.CloudFrontWebDistribution(this, 'CFDistribution', {
const distribution : [
originConfigs
{: {
customOriginSource: `${api.api.httpApiId}.execute-api.${this.region}.${this.urlSuffix}`
domainName,
}: [
behaviors
{: '/api/visible',
pathPattern: CloudFrontAllowedMethods.GET_HEAD_OPTIONS,
allowedMethods: cdk.Duration.minutes(CACHE_TTL_MINUTES),
defaultTtl: {
forwardedValues: true
queryString,
}: [
lambdaFunctionAssociations
{: cf.LambdaEdgeEventType.VIEWER_REQUEST,
eventType: edgeHandler.edgeFunctions.QueryToID
lambdaFunction,
}
{: cf.LambdaEdgeEventType.ORIGIN_REQUEST,
eventType: edgeHandler.edgeFunctions.StripAPIPath
lambdaFunction
}
],
}
{: '/api/*',
pathPattern: CloudFrontAllowedMethods.GET_HEAD_OPTIONS,
allowedMethods: cdk.Duration.minutes(CACHE_TTL_MINUTES),
defaultTtl: {
forwardedValues: true
queryString,
}: [
lambdaFunctionAssociations
{: cf.LambdaEdgeEventType.ORIGIN_REQUEST,
eventType: edgeHandler.edgeFunctions.StripAPIPath
lambdaFunction
}
]
}
]
}
]
})
} }
In my PlanetsStack
constructor, I instantiate the EdgeHandler class which will load the SSM parameter values into memory. I then set up my CloudFront distribution, where I define two behaviours - one for /api/visible
and one for all other /api
routes. It’s then as simple as referencing the appropriate entry from edgeHandler.edgeFunctions
for each item in the lambdaFunctionAssociations
array.
Summary
To summarise, Lambda@Edge functions can only be created in the us-east-1 region. In order to get around this restriction, we’ve created two CDK stacks - EdgeStack
in us-east-1 and PlanetsStack
in some other region, and we’ve used the SSM Parameter Store to save the ARNs of our Lambda@Edge functions from EdgeStack
. Then in PlanetsStack
we’ve used AwsCustomResource
to read the saved values from the parameter store, and reference them in a CloudFront distribution.
It’s not the most elegant solution, but it’s also not too bad considering we’d now be able to reference from any region just about anything that can be serialised. Maybe something like this will eventually be included in the CDK Construct library, but for now, hopefully this is helpful.