logo
Menu
Adding flexibility to your deployments with Lambda Web Adapter

Adding flexibility to your deployments with Lambda Web Adapter

Explore how to use Lambda Web Adapter and CDK to simplify the deployment of your Web apps in Lambda and how to easily transition to ECS Fargate.

Published Apr 19, 2024
Last Modified May 10, 2024
Lambda Web Adapter (LWA) is an open-source project that enables running Web apps on Lambda functions without the need to change or adapt the code.
In my opinion, LWA opens up interesting pathways for architecture evolution: While it’s an interesting tool that helps lift & shift Web apps and APIs to Lambda functions without a lot of effort, it can also enable another migration path : Start deploying your application in Lambda as a Lambdalith and then transition to a classical container deployment when needed (e.g. ECS Fargate). In some scenarios, it may happen that you don’t have enough data to decide whether it’s better to host on Lambda or on Fargate. LWA contributes by adding portability to your deployments.
In this article, we’ll explore how to use LWA with CDK to simplify the deployment of your Web apps in Lambda and how to easily transition to ECS Fargate.

How Lambda Web adapter works ?

LWA is a Lambda extension. It creates an independent process within the Lambda execution environment that listens for incoming events and forwards them to your HTTP server.
LWA can integrate with invocations from Lambda function URLs (FURL), ALB, and API Gateway, converting invocation JSON payloads into HTTP requests that web frameworks like fastify or ASP.NET can handle. LWA also supports non-HTTP triggers (e.g. SQS, S3 notifications), but in these cases, it acts as a pass-through without converting the invocation payload.
LWA supports functions packaged as zip as well as Docker or OCI images.

Solution overview

Let’s have a look on how we’ll create our flexible deployment using CDK. In this example we’ll be focusing on deploying a public Web application using fastify as a Web framework:
solution overview
My objective is to create a CDK construct supporting two deployment strategies: Lambda or ECSFargate. Depending on the selected strategy, only the required components will be deployed:
  • When in Lambda mode, we’ll configure the function to use LWA extension. We’ll also configure a REST API Gateway with lambda proxy integration.
  • When the deployment mode is ECSFargate, We will deploy our application in an ECS Fargate service that is exposed via an ALB.
In both of these deployment strategies, users access the Web app through CloudFront. We will associate a WAF Web ACL to restrict access to both the API Gateway and the Application Load Balancer. These origins will only respond to requests that include a custom verification header added by the CloudFront distribution. This approach prevents bypassing the CloudFront distribution to access the origin directly.
☝️Some notes:
When deploying in Lambda, I ruled-out the use of FURL or HTTP API Gateway:
To improve security, the custom verification header can be stored in secrets manager with rotation enabled so that the header can be updated as well as the origin WAF and the CloudFront distribution configurations.

Let’s see the code

Here are the relevant parts of the solution:

1- Deploying fastify Web app on Lambda using LWA

Creating a new fastify project is a breeze, I generally go with typescript; for the purpose of this article, I will create one super basic api:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import fastify from "fastify";

const server = fastify();

server.get("/health", async () => {
return "yup ! I am healthy";
});

server.get("/where-are-you-deployed", async () => {
return {
"i-am-deployed-on": process.env.DEPLOYED_ON,
};
});

server.listen({ host: "0.0.0.0", port: 8080 }, (err, address) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`Server listening at ${address}`);
});
I will deploy the Lambda function using zip archive and in order to use LWA as a Lambda extension, we’ll need to:
  • Attach the LWA layer to the function
  • Set the handler to the startup command run.sh script. This script starts the fastify Web app. It will be added to the zip package after the code bundling.
  • And lastly, define the AWS_LAMBDA_EXEC_WRAPPER environment variable to /opt/bootstrap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const fastifyFunction = new NodejsFunction(this, `function`, {
entry: resolve("../src/index.ts"),
functionName: `${props.app}-lambda`,
architecture: Architecture.ARM_64,
handler: "run.sh",
environment: {
AWS_LAMBDA_EXEC_WRAPPER: "/opt/bootstrap",
DEPLOYED_ON: "Lambda",
},

bundling: {
minify: true,
commandHooks: {
beforeInstall: () => [],
beforeBundling: () => [],
afterBundling: (inputDir: string, outputDir: string) => {
return [`cp ${inputDir}/../src/run.sh ${outputDir}`];
},
},
},
layers: [
LayerVersion.fromLayerVersionArn(
this,
"layer",
`arn:aws:lambda:${props.region}:753240598075:layer:LambdaAdapterLayerArm64:20`
),
],
});
The RestApi CDK construct simplifies exposition of the Lambda function:
1
2
3
4
5
const api = new LambdaRestApi(this, "restapi", {
handler: fastifyFunction,
deploy: true,
endpointTypes: [EndpointType.REGIONAL]
});
After deployment, you will be able to view in the console the layer associated with the function:
Lambda with LWA layer configuration

2- Defining the alternative deployment on ECS Fargate

CDK offers an L3 construct to deploy a load balanced ECS service. What I find interesting about this construct is that it hides all the complexity and verbosity of defining such a deployment, while allowing a level of flexibility. Another neat feature is that it can build and push our container image.
We’ll make sure to enable HTTPS, for that we’ll create a certificate and associated to the load balancer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const hostedZone = aws_route53.HostedZone.fromLookup(this, "hosted-zone", {
domainName: props.hostedZoneDomainName,
});

const { certificate } = new Certificate(this, Certificate.name, {
hostedZoneDomainName: props.hostedZoneDomainName,
domainName: originDomainName,
});

const loadBalancedService = new ApplicationLoadBalancedFargateService(
this,
"FargateService",
{
cluster,
certificate,
redirectHTTP: true,
protocol: ApplicationProtocol.HTTPS,
domainName: this.originDomainName,
domainZone: hostedZone,
taskImageOptions: {
image: ContainerImage.fromAsset("../src"),
containerPort: 8080,
environment: {
HOST: "0.0.0.0",
PORT: "8080",
DEPLOYED_ON: "ECS",
},
},
});
Here, I am using the default configuration, but you will want to adapt it to your own requirements.
Once deployed, the ECS service looks like this on the AWS console
You can find the full definition of the construct here.

3- Handling the two different deployment strategies

The important bit, the CDK construct that enables flexible deployments:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
export class WebAppDeployment extends Construct {
constructor(
scope: Construct,
id: string,
props: {
app: string;
region: string;
hostedZoneDomainName: string;
domainName: string;
deployOn: "Lambda" | "ECSFargate";
webAppOnLambdaDefinition: () => RestApi;
webAppOnECSFargateDefinition: () => {
loadbalancer: ApplicationLoadBalancer;
fargateService: FargateService;
originDomainName: string;
};
}
) {
super(scope, id);

const {
deployOn,
app,
webAppOnLambdaDefinition,
webAppOnECSFargateDefinition,
} = props;

let httpOrigin: IOrigin;
let originResourceArn: string;
let cfDependencies: IDependable[] = [];

const verifiyOriginHeaderSecret = this.createOriginHeaderSecret();
const webAcl = this.getWebACLDefinition(verifiyOriginHeaderSecret);

switch (deployOn) {
case "Lambda": {

const api = webAppOnLambdaDefinition();
originResourceArn = api.deploymentStage.stageArn;

httpOrigin = new RestApiOrigin(api, {
customHeaders: {
"x-origin-header": verifiyOriginHeaderSecret
.secretValueFromJson("VerifyOriginHeader")
.unsafeUnwrap(),
},
});

cfDependencies.push(api);
break;
}
case "ECSFargate": {
const { originDomainName, fargateService, loadbalancer } =
webAppOnECSFargateDefinition();

httpOrigin = new HttpOrigin(originDomainName, {
customHeaders: {
"x-origin-header": verifiyOriginHeaderSecret
.secretValueFromJson("VerifyOriginHeader")
.unsafeUnwrap(),
},
});

originResourceArn = loadbalancer.loadBalancerArn;
cfDependencies.push(fargateService);
break;
}
default: {
throw new Error(`Deployment mode ${deployOn} not supported`);
}
}

const webAclAssocitation = new CfnWebACLAssociation(
this, "webacl-association", { resourceArn: originResourceArn, webAclArn: webAcl.attrArn});

const distribution = new Distribution(this, "dist", {
defaultBehavior: {
origin: httpOrigin,
cachePolicy: CachePolicy.CACHING_DISABLED,
},
certificate,
domainNames: [props.domainName],
priceClass: PriceClass.PRICE_CLASS_100,
});

distribution.node.addDependency(...cfDependencies, webAclAssocitation);
}
}
This construct handles two deployment strategies Lambda or ECSFargate. For each strategy, we’ll need to provide a factory function that creates the required resources. These two functions need to be injected from a parent construct and they are lazily evaluated given the selected strategy.
We’ll also make sure that the distribution cache policy is disabled for both of these two origins.
As an example, the origin of the distribution, should end up looking like this when you select Lambda deployment strategy
Configured origin on “Lambda + RestAPI” mode
And finally, let’s see how to define the WAF WebACL with a rule that checks the x-origin-header verification header:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
return new wafv2.CfnWebACL(this, "webacl", {
defaultAction: {
block: {},
},
scope: "REGIONAL",
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: "metric-for-webapp",
sampledRequestsEnabled: true,
},
name: "webapp-webacl",
rules: [
{
name: "OriginHeaderRule",
priority: 1,
action: {
allow: {},
},
statement: {
byteMatchStatement: {
fieldToMatch: {
singleHeader: { Name: "x-origin-header" },
},
positionalConstraint: "EXACTLY",
searchString: verifiyOriginHeaderSecret
.secretValueFromJson("VerifyOriginHeader")
.unsafeUnwrap(),
textTransformations: [
{
priority: 0,
type: "NONE",
},
],
},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: "metric-for-webapp-origin-header",
sampledRequestsEnabled: true,
},
},
],
});
You can follow this link for the complete WebAppDeployment construct definition

Flexible deployment in action

Before wapping up, let’s call where-are-you-deployed endpoint defined in the sample web app for each strategy:
calling the “where-are-you-deployed” endpoint from Postman

Wrapping up

Lambda web adapter is certainly not the only tool that helps running full-fledged web apps on Lambda functions, but it simplifies their deployment while supporting architectural evolution.
In this article we have seen how to build a CDK construct that offers a way to deploy the same web app on two distinct platforms, we can choose Lambda function or ECS Fargate by specifying a configuration during design time. We can extend this further by enabling the system to automatically redeploy itself, during runtime, on a specific target based on some events or CloudWatch alarms !
As always, you can find the full code source, ready to be adapted and deployed here:
Thanks for reading and hope you enjoyed it !

Resources

Comments