Skip to main content

Running ASP.NET 8 Minimal API on AWS Lambda with Container Image

· 14 min read
Akhan Zhakiyanov
Lead engineer
warning

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!

info

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

info

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

info

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

info

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 namePreserving existing application architectureLocal run and debug capabilitiesPreserving current release workflow
Lambda Empty Serverless (.NET 7 Container Image) or Lambda Custom Runtime Function (.NET 8)major changesmajor changesmajor changes
Lambda Empty Serverless (.NET 7 Container Image)moderate changesmajor changesmajor changes
Lambda ASP.NET Core Web API template (.NET 6 Container Image)minor changesminor changesmoderate changes
Lambda ASP.NET Core Minimal API templateminor changesno changesmoderate changes
warning

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 environment
  • LocalEntryPoint.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
note

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.

tip

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.

tip

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 --from=publish /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
tip

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.

tip

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:

danger

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)
info

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