Deploy a Serverless .NET API using NativeAOT.
In this article, we describe a production-ready template for building and deploying a .NET serverless application using AWS CDK.
All code for this article is open source and available on GitHub.
In this article we will cover
- Serverless REST API using API Gateway, Lambda and DynamoDB.
- What is NativeAOT?
- Compilation using dotnet NativeAOT and Docker.
- AWS CDK (Infrastructure as Code).
- Local deployment.
- Unit tests, Integration tests and end-to-end tests.
- Github actions for CI/CD.
The application is a simple blog API that leverages API Gateway, Lambda and DynamoDB.
- Each endpoint is deployed as a standalone lambda function.
- We opted to use the request, endpoint, response (REPR) design pattern.
- Input is validated using fluent validation.
- Endpoints should be easy to test.
- Structured logging using the Serilog logging library.
We have used the latest .NET language/compiler features such as:
- Nullable value types and use of the 'required' modifier for class properties.
- Compile time checking of potential null reference exceptions.
- Source generators for JSON and object mapping.
- Publishing using NativeAOT.
There are three main components to each Lambda function:
The 'Program.cs' is the main entry point to the function, and is what the lambda runtime will call to execute your function. Here is where you register your endpoint and instantiate any dependencies you require such as the AWS DynamoDB client and logging.
The 'Endpoint.cs' is the core of each lambda function. Each endpoint class is responsible for:
- Listening to incoming HTTP requests from API Gateway.
- JSON Deserialization/serialization of requests/responses.
- Validation of input using the FluentValidation validation library.
- Execution of business logic and persistence of state in DynamoDB.
- Mapping DynamoDB entities to DTOs using the Mapperly source code generator.
- Formatting HTTP responses using the OneOff library that provides F# like unions for C#.
The 'Validator.cs' is responsible for ensuring the request that is being processed by the endpoint conforms to expectations. If all validation conditions are not met, the request is considered invalid and a 400 Bad Request is returned.
What is dotnet NativeAOT?
.NET Native AOT (Ahead-of-Time) is a technology that compiles .NET code into native machine code that can run directly on a device without requiring a just-in-time (JIT) compiler at runtime. Native AOT deployment produces an app that is self-contained and that has been compiled into native code. Native AOT apps start up very quickly, use less memory, and don't need the .NET runtime to be installed. This makes this technology particularly beneficial for lambda functions written using dotnet as it drastically improves cold starts.
NativeAOT requirements with Lambda
To use NativeAOT, we require .NET 7. AWS has a policy of releasing managed Lambda runtimes only for long-term support (LTS) versions of .NET. Since .NET 7 is not an LTS version, there is no AWS-managed runtime for .NET 7 with Lambda.
Fortunately, we have a couple of other options available:
- Container image
- Custom runtime based on Amazon Linux 2
It is possible to run a Lambda function as a container, where the operating system, runtime, and application code are bundled in the image. The alternative approach is to leverage Lambda's custom runtime support. The custom runtime utilizes Amazon Linux 2 as the base operating system and requires an executable file named bootstrap. The bootstrap executable is run by the Lambda runtime and is passed event data in response to an invocation. The bootstrap executable processes the event data and passes the result back to the Lambda runtime. Since .NET 7 NativeAOT produces a single executable, it is a good fit for the custom runtime approach.
Building native binaries for Amazon Linux 2 using Docker
To run a .NET NativeAOT lambda, we must use a custom runtime based on Amazon Linux 2. NativeAOT has a limitation in that an executable built for an Operating System (e.g. Linux) must be compiled on the same target operating system. It is not possible to cross-platform compile a NativeAOT application. Given that most people aren't running Linux for their development machine, we decided to use Docker to build and package the NativeAOT binaries.
We utilize AWS CDK to provision the required API Gateway, DynamoDB table, and Lambda functions. AWS CDK was chosen for the following reasons:
- Type safety and code completion in your IDE of choice.
- Sensible defaults when provisioning resources.
- Easy to integrate with CI/CD such as GitHub Actions.
- Simplifies the granting of IAM permissions and enforces best practices.
- Ability to create more advanced infrastructure without having to write raw CloudFormation.
In this example, we used TypeScript as our language of choice. For further information on CDK, please visit https://aws.amazon.com/cdk/
Deploying from your dev machine
- Install Node.js https://nodejs.org/en/
- Install AWS CDK https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html
- Install AWS cli https://aws.amazon.com/cli/
Step 1: Configure an AWS SSO profile setup with the name "sg-dev".
Step 2: Refresh your AWS credentials
Step 3: Publish the lambda functions using NativeAOT
Note: the build script copies the compiled binaries out of the docker container and onto your local file system. These will be packaged by CDK as .zip files and uploaded to S3 for deployment.
Step 4: Deploy
We use 'feat-1008' as the stage name when deploying, this could be your JIRA ticket number for example. When deploying to development we use 'dev' and 'prod' for production.
We follow a pretty standard approach to testing serverless applications:
- Unit tests — Anything that can be run in memory for example validators
- Integration tests — Test that the code functions correctly against real AWS infrastructure like DynamoDB.
- End to end tests — Test the full application usually against the actual HTTP API. This is important to make sure that IAM permissions have been setup correctly.
For more information about serverless testing we recommend this blog post.
We have included the following Github Actions that run when creating PR’s into various branches:
- check.yml — Runs on pull requests to the dev branch. This is used to test feature branches.
- cleanup.yml — Runs when pull request to the dev branch are closed. Runs 'cdk destroy' to cleanup feature branch stacks.
- dev.yml — Runs when a pull request is merged to the dev branch.
- prod.yml — Runs when a pull request is merged to the main branch.
In conclusion, this article provides a comprehensive guide to building and deploying a serverless .NET API using NativeAOT with AWS CDK. The article covers the design considerations for building a serverless REST API with API Gateway, Lambda, and DynamoDB. It also explains what NativeAOT is and how to build native binaries for Amazon Linux 2 using Docker.