Running ASP.NET 8 Minimal API on AWS Lambda with Container Image
Code samples were updated to run with x86_64
architecture
.NET 8 is finally here, since its release over a month ago.
Probably one of the much anticipated feature was Native AOT and its support for ASP.NET Core. Benefits coming along with it (such as minimized disk footprint, reduced startup time, and reduced memory demand) will lead to exploration how to run ASP.NET Core 8 apps in AWS Lambda.
Last year AWS gave us only two options to run .NET 7 Lambda functions: either container image or custom runtime. Now, in a twist for 2024, AWS is throwing its full support behind .NET 8 runtime, set to drop officially sometime in January. I think it's due to the fact that .NET 8 is an LTS release with a solid 3-year support plan.
And today I will show you the simplest way to run ASP.NET Core 8 in AWS Lambda without tearing everything apart!
This article targets experienced ASP.NET Core developers who considering to run their apps in serverless environment without major changes in existing programming model
TL;DR expected changes:
- use
Amazon.Lambda.AspNetCoreServer.Hosting
package with.AddAWSLambdaHosting
extension method - use AWS managed base image for .NET 8. Currently only
public.ecr.aws/lambda/dotnet:8-preview
is available - use
CMD
with assembly name instead of function handler string in Dockerfile
All codebase is available at: https://github.com/ahanoff/how-to/tree/main/aspnet8-minimal-api-lambda-container-image
First let's see what kind of objectives ASP.NET Core developers might have prior to investing their time and efforts into AWS serverless technology.
Objectives
1. Preserving existing application architecture
Rationale: protects the investment in the current ASP.NET application architecture, ensuring a seamless transition to a serverless environment without the need for a complete rewrite.
2. Local run and debug capabilities
Rationale: facilitates efficient development by allowing developers to run and debug the ASP.NET application locally, reducing the time and effort needed to identify and resolve issues.
3. Preserving current release workflow
Rationale: ensures consistency and compatibility by retaining the established release workflow for containerized applications, streamlining the integration of serverless capabilities without disrupting deployment practices.
To meet these objectives let's review available options AWS offers us to run .NET apps on Lambda.
Getting started with AWS Lambda
Since beginning AWS have provided custom .NET templates for developers to get started with AWS Lambda using different programming models and use-cases.
You can get current list of available templates by running the dotnet new list
command and filter them by author:
dotnet new list --author AWS
Irrelevant templates are ommited, and only kept those which names contain either container image
, .net 8
, or asp.net core
words:
dotnet new list --author AWS
These templates matched your input: --author='AWS'
Template Name Short Name Language Tags
------------------------------------------------------------------------------------ -------------------------------------------- -------- --------------------------------
Lambda Empty Function (.NET 7 Container Image) lambda.image.EmptyFunction [C#],F# AWS/Lambda/Function
Lambda Custom Runtime Function (.NET 8) lambda.CustomRuntimeFunction [C#],F# AWS/Lambda/Function
Lambda Empty Serverless (.NET 7 Container Image) serverless.image.EmptyServerless [C#],F# AWS/Lambda/Serverless
Lambda ASP.NET Core Web API serverless.AspNetCoreWebAPI [C#],F# AWS/Lambda/Serverless
Lambda ASP.NET Core Minimal API serverless.AspNetCoreMinimalAPI [C#] AWS/Lambda/Serverless
...
In the table below I summarize approximate efforts for each template to achieve above mentioned objectives, and in the following sections I'll explain pros and cons in more details.
Template name | Preserving existing application architecture | Local run and debug capabilities | Preserving current release workflow |
---|---|---|---|
Lambda Empty Serverless (.NET 7 Container Image) or Lambda Custom Runtime Function (.NET 8) | major changes | major changes | major changes |
Lambda Empty Serverless (.NET 7 Container Image) | moderate changes | major changes | major changes |
Lambda ASP.NET Core Web API template (.NET 6 Container Image) | minor changes | minor changes | moderate changes |
Lambda ASP.NET Core Minimal API template | minor changes | no changes | moderate changes |
Default deployment options for all these templates is dotnet lambda tool and it is often combined with Cloudformation template and aws-lambda-tools-defaults.json
files
Lambda Empty Function (.NET 7 Container Image) or Lambda Custom Runtime Function (.NET 8)
First two templates contain only single function handler which will be .NET equivalent of lambda functions written in NodeJS or Python.
public class Function
{
/// <summary>
/// A simple function that takes a string and returns both the upper and lower case version of the string.
/// </summary>
public Casing FunctionHandler(string input, ILambdaContext context)
{
return new Casing(input.ToLower(), input.ToUpper());
}
}
While being idiomatic lambda code, it's not very helpful for us, as it's significant shift in programming paradigm that typical ASP.NET developer get used to.
It also means we can't utilize ASP.NET 8 framework out of the box, and all heavy lifting (configuration, DI, mapping) have to be done by us, thus require major changes in all aspects.
Lambda Empty Serverless (.NET 7 Container Image)
This template contains more familiar concepts for traditional ASP.NET development, but still far from perfect.
It does contain Startup.cs
with ConfigureServices
methods to allow us to use Dependency Injection, configuration, etc.
But to make it work with Lambda function it uses Amazon.Lambda.Annotations NuGet package. This packages provides .NET attributes like [LambdaFunction]
and [RestApi]
to map handler and request/response structures
/// <summary>
/// A Lambda function to respond to HTTP Get methods from API Gateway
/// </summary>
/// <remarks>
/// This uses the <see href="https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.Annotations/README.md">Lambda Annotations</see>
/// programming model to bridge the gap between the Lambda programming model and a more idiomatic .NET model.
///
/// This automatically handles reading parameters from an <see cref="APIGatewayProxyRequest"/>
/// as well as syncing the function definitions to serverless.template each time you build.
/// </remarks>
/// <param name="context">Information about the invocation, function, and execution environment</param>
/// <returns>The response as an implicit <see cref="APIGatewayProxyResponse"/></returns>
[LambdaFunction(PackageType = LambdaPackageType.Image, Policies = "AWSLambdaBasicExecutionRole", MemorySize = 256, Timeout = 30)]
[RestApi(LambdaHttpMethod.Get, "/")]
public IHttpResult Get(ILambdaContext context)
{
context.Logger.LogInformation("Handling the 'Get' Request");
return HttpResults.Ok("Hello AWS Serverless");
}
It is still idiomatic .NET code, but requires significant amount of time to complete route, and event source request and reponse mapping.
Changing event source (from REST API Gateway to HTTP API Gateway) will require replacing of existing attributes with new one and rebuild.
By default relies on Cloudformation template and dotnet lambda
tool, but README covers multi-stage Dockerfile as deployment option.
Lambda ASP.NET Core Web API template (.NET 6 Container Image)
The app structure is very close (I would say 95%) to what any ASP.NET dev would expect. This made possible with Amazon.Lambda.AspNetCoreServer NuGet package
Amazon.Lambda.AspNetCoreServer package
From description:
This package makes it easy to run ASP.NET Core Web API applications as a Lambda function with API Gateway or an ELB Application Load Balancer. This allows .NET Core developers to create "serverless" applications using the ASP.NET Core Web API framework.
The function takes a request from an API Gateway Proxy or from an Application Load Balancer and converts that request into the classes the ASP.NET Core framework expects and then converts the response from the ASP.NET Core framework into the response body that API Gateway Proxy or Application Load Balancer understands.
Unfortunately it comes with so called two entrypoints pattern
:
Two entrypoints pattern
Based on environment different entrypoint for the app will be used
LambdaEntryPoint.cs
is used in Lambda environmentLocalEntryPoint.cs
is used in local environment with Kestrel
Having two entrypoints is already unconventional, but what is even worse, for LambdaEntryPoint
in order to handle different event source (REST API Gateway, HTTP API, LoadBalancer) request / response correctly it must derive from respective base class
/// <summary>
/// This class extends from APIGatewayProxyFunction which contains the method FunctionHandlerAsync which is the
/// actual Lambda function entry point. The Lambda handler field should be set to
/// LambdaASP.NETCoreWebAPI4::LambdaASP.NETCoreWebAPI4.LambdaEntryPoint::FunctionHandlerAsync
/// </summary>
public class LambdaEntryPoint :
// The base class must be set to match the AWS service invoking the Lambda function. If not Amazon.Lambda.AspNetCoreServer
// will fail to convert the incoming request correctly into a valid ASP.NET Core request.
//
// API Gateway REST API -> Amazon.Lambda.AspNetCoreServer.APIGatewayProxyFunction
// API Gateway HTTP API payload version 1.0 -> Amazon.Lambda.AspNetCoreServer.APIGatewayProxyFunction
// API Gateway HTTP API payload version 2.0 -> Amazon.Lambda.AspNetCoreServer.APIGatewayHttpApiV2ProxyFunction
// Application Load Balancer -> Amazon.Lambda.AspNetCoreServer.ApplicationLoadBalancerFunction
//
// Note: When using the AWS::Serverless::Function resource with an event type of "HttpApi" then payload version 2.0
// will be the default and you must make Amazon.Lambda.AspNetCoreServer.APIGatewayHttpApiV2ProxyFunction the base class.
Amazon.Lambda.AspNetCoreServer.APIGatewayProxyFunction
{
/// <summary>
/// The builder has configuration, logging and Amazon API Gateway already configured. The startup class
/// needs to be configured in this method using the UseStartup<>() method.
/// </summary>
/// <param name="builder"></param>
protected override void Init(IWebHostBuilder builder)
{
builder
.UseStartup<Startup>();
}
/// <summary>
/// Use this override to customize the services registered with the IHostBuilder.
///
/// It is recommended not to call ConfigureWebHostDefaults to configure the IWebHostBuilder inside this method.
/// Instead customize the IWebHostBuilder in the Init(IWebHostBuilder) overload.
/// </summary>
/// <param name="builder"></param>
protected override void Init(IHostBuilder builder)
{
}
}
That means that event source changing requires us to rewrite, rebuild and redeploy entire app.
Lambda ASP.NET Core Minimal API template
This template based on new package Amazon.Lambda.AspNetCoreServer.Hosting NuGet package,
and uses extension method .AddAWSLambdaHosting
to handle lambda and non-lambda environments.
This minimalistic setup is probably the best way among the other.
Unfortunately this template is .NET 6 based, doesn't use container image, relying instead for deployment on built-in .NET 6 runtime in AWS, cloudformation template and dotnet lambda
tool.
Solution
At this point I think the simplest way to run existing app would be:
- use
Amazon.Lambda.AspNetCoreServer.Hosting
with.AddAWSLambdaHosting
extension method - use Dockerfile instead of
dotnet lambda
tool for deployment
Unfortunately neither official AWS docs for .NET Container image nor other blog posts cover this setup at all.
I have to find bits of relevant information in few Github issues and their comments #1032 Using .NET 6 minimal API with Dockerfile?, #1324 AWS lambda in docker container run local), and #1152 Is AddAWSLambdaHosting only for minimal API or it will also work with API With controllers, template READMEs, and then compile them into something working.
I would like to thank Norm Johanson for one of the old replies where he clarified the reason for incosistency between .NET WebAPI template and Minimal API template:
When we updated the templates for .NET 6 we did not change the existing ASP.NET Core template which has always had the 2 different entry point classes as you described.
We thought there were lots of users already used to that style and there are a lot of docs and blog posts out in the wild describe how to build ASP.NET Core Lambda function that way.
You can deploy an ASP.NET Core application that is not using Minimal API using the AddAWSLambdaHosting as long as you deploy the application as an executable assembly. That means you need to change the function handler string in your CloudFormation template to just the assembly name instead of ::::.
It finally clicked for me, thus all requried changes would be:
- use
Amazon.Lambda.AspNetCoreServer.Hosting
package with.AddAWSLambdaHosting
extension method - use AWS managed base image for .NET 8. Currently only
public.ecr.aws/lambda/dotnet:8-preview
is availbale - use
CMD
with assembly name instead of function handler string in Dockerfile :::
Development
In this section, I’ll guide you through the development process, demonstrating the creation of an ASP.NET 8 Minimal API application, cover how to containerize it with an AWS base image for .NET 8 and deploy it as a Lambda function using a container image.
For your convenience, I’ll provide AWS CDK configurations—code snippets written in Typescript — for creating an ECR registry, IAM role, Lambda function and an HTTP API Gateway.
You can skip development instructions and go to full source code available at https://github.com/ahanoff/how-to/tree/main/aspnet8-minimal-api-lambda-container-image
ASP.NET 8 Minimal API app
First we create empty web app with CLI and change directory to newly create project directory:
dotnet new web -n AspNet8ContainerImage
cd AspNet8ContainerImage
Add Amazon.Lambda.AspNetCoreServer.Hosting
package
dotnet add package Amazon.Lambda.AspNetCoreServer.Hosting
Register appropriate Lambda hosting. I will use HTTP API, but it can also be REST API or Loadbalncer
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
Add multi-stage Dockerfile based on AWS base images for .NET.
I used multi-stage Dockerfile from ASP.NET WebAPI template README, replacing respective images with .NET 8 equivalent and adding CMD
with assembly name
FROM public.ecr.aws/lambda/dotnet:8-preview AS base
FROM mcr.microsoft.com/dotnet/sdk:8.0 as build
WORKDIR /src
COPY ["AspNet8ContainerImage.csproj", "AspNet8ContainerImage/"]
RUN dotnet restore "AspNet8ContainerImage/AspNet8ContainerImage.csproj"
WORKDIR "/src/AspNet8ContainerImage"
COPY . .
RUN dotnet build "AspNet8ContainerImage.csproj" --configuration Release --output /app/build
FROM build AS publish
RUN dotnet publish "AspNet8ContainerImage.csproj" \
--configuration Release \
--runtime linux-x64 \
--self-contained false \
--output /app/publish \
-p:PublishReadyToRun=true
FROM base AS final
WORKDIR /var/task
COPY /app/publish .
CMD ["AspNet8ContainerImage"] # run the application as an executable assembly
Build dockerfile image
docker build -t aspnet8-minimal-api-container-image .
Deployment and testing
To deploy container image Lambda function we will need:
- ECR registry
- IAM Role for lambda function execution
- HTTP API Gateway
Technically for testing you can use Lambda URL.
Requests have to be formatted accordingly to event source used with .AddAWSLambdaHosting
, eg. HTTP API, REST API or ALB event
Let's proceed to create them with AWS CDK
ECR registry
import { Repository, TagMutability } from 'aws-cdk-lib/aws-ecr';
/**
* ECR Registry for asp.net 8 minimal api container image
*/
const ecrRepository = new Repository(this, 'aspnet8-minimal-api-container-image', {
imageTagMutability: TagMutability.IMMUTABLE,
repositoryName: 'aspnet8-minimal-api-container-image',
removalPolicy: cdk.RemovalPolicy.DESTROY
})
IAM role
We will need IAM role for Lambda function execution.
In example below I attach AWSLambdaBasicExecutionRole AWS managed policy which allows to create CloudWatch Log streams and push log messages there.
For production I would advice to write own policy with more strict rules
import { ManagedPolicy, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
/**
* IAM Role for lambda
*/
const lambdaContainerImageRole = new Role(this, 'aspnet8-minimal-api-container-image-lambda-role', {
assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
managedPolicies: [ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
roleName: 'aspnet8-minimal-api-container-image-function-role',
})
Run cdk deploy
to create ECR registry and IAM role first.
Publish Docker image to ECR
Authorize with AWS ECR and push docker image to registry
aws ecr get-login-password --region ap-southeast-1 | docker login --username AWS --password-stdin <AWS_ACCOUNT_NUMBER>.dkr.ecr.ap-southeast-1.amazonaws.com
docker tag aspnet8-minimal-api-container-image:latest <AWS_ACCOUNT_NUMBER>.dkr.ecr.ap-southeast-1.amazonaws.com/aspnet8-minimal-api-container-image:latest
docker push <AWS_ACCOUNT_NUMBER>.dkr.ecr.ap-southeast-1.amazonaws.com/aspnet8-minimal-api-container-image:latest
Container image lambda function
import { Architecture, Code, Function, Handler, Runtime } from 'aws-cdk-lib/aws-lambda';
/**
* Lambda function created from ECR image: see `runtime: Runtime.FROM_IMAGE`, `code: Code.fromEcrImage`, and `handler: Handler.FROM_IMAGE`
*/
const lambda = new Function(this, 'aspnet8-minimal-api-container-image-lambda', {
code: Code.fromEcrImage(ecrRepository, {
tagOrDigest: 'latest',
}),
runtime: Runtime.FROM_IMAGE,
role: lambdaContainerImageRole,
architecture: Architecture.X86_64,
functionName: 'aspnet8-minimal-api-container-image-lambda',
memorySize: 1024,
handler: Handler.FROM_IMAGE
})
HTTP API Gateway and Lambda integration
import { HttpApi, PayloadFormatVersion } from 'aws-cdk-lib/aws-apigatewayv2'
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'
const api = new HttpApi(this, 'aspnet8-minimal-api-container-image-lambda-http-api', {
apiName: 'aspnet8-minimal-api-container-image-lambda-http-api',
createDefaultStage: true,
defaultIntegration: new HttpLambdaIntegration('aspnet8-minimal-api-container-image-lambda-integration', lambda, {
payloadFormatVersion: PayloadFormatVersion.VERSION_2_0
})
})
Testing
Our ASP.NET 8 app contains only single handler that returns 'Hello, World!'
app.MapGet("/", () => "Hello World!");
After HTTP API gateway creation we can get its endpoint and test our function with curl
:
curl -XGET https://5w7pet7qth.execute-api.ap-southeast-1.amazonaws.com/
Hello World!
Known issues
Some people were running with x86_64
architecture (1611#issuecomment-1913687420) and get the following error:
Error: fork/exec /lambda-entrypoint.sh: exec format error Runtime.InvalidEntrypoint
This was caused by building ASP.NET 8 app for ARM64 architecture, but running with x86_64 Lambda runtime.
Summary
Looking back I think all objectives have been met:
- able to run ASP.NET 8 in serverless environment
- able to preserve existing application architecture, local run and debug capabilities with just one line change
- able to keep existing release workflow without relying on additional third party tools like
dotnet lambda
- able to keep cognitive load for developers at same level (no Cloudformation template or
aws-lambda-tools-defaults.json
files)
Stay tuned for future updates! I’m planning to delve deeper into specific aspects of ASP.NET 8 and serverless integration once official support is becomes available.
All codebase is available at: https://github.com/ahanoff/how-to/tree/main/aspnet8-minimal-api-lambda-container-image