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.
Application overview
The application is a simple blog API that leverages API Gateway, Lambda and DynamoDB.
Design considerations:
- 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.
using System.Text.Json;
using Amazon.DynamoDBv2;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using SgBlogApi.Core;
using SgBlogApi.CreatePost;
var logger = SerilogConfiguration.ConfigureLogging();
var ddb = new AmazonDynamoDBClient();
var store = new DynamoDbStore(ddb);
var endpoint = new Endpoint(logger, store);
var handler = endpoint.ExecuteAsync;
var serializer = new SourceGeneratorLambdaJsonSerializer<SerializerContext>(
x => x.PropertyNamingPolicy = JsonNamingPolicy.CamelCase
);
await LambdaBootstrapBuilder.Create(handler, serializer).Build().RunAsync();
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#.
using System.Text.Json;
using Amazon.Lambda.APIGatewayEvents;
using OneOf;
using Serilog;
using SgBlogApi.Core;
namespace SgBlogApi.CreatePost;
public class Endpoint
{
private readonly Validator _validator;
private readonly ILogger _logger;
private readonly DynamoDbStore _store;
private readonly SerializerContext _serializerContext;
private readonly PostMapper _mapper;
public Endpoint(ILogger logger, DynamoDbStore store)
{
_validator = new Validator();
_serializerContext = new SerializerContext(new () { PropertyNameCaseInsensitive = true });
_logger = logger;
_store = store;
_mapper = new PostMapper();
}
public async Task<APIGatewayProxyResponse> ExecuteAsync(APIGatewayProxyRequest apiRequest)
{
var result = await CreatePostAsync(apiRequest);
return result.Match(
success => Response.From(success),
invalidRequest => Response.From(invalidRequest),
validationError => Response.From(validationError),
serverError => Response.From(serverError)
);
}
private async Task<OneOf<CreatePostResponse, InvalidRequest, ValidationError, ServerError>> CreatePostAsync(APIGatewayProxyRequest apiRequest)
{
try
{
if (apiRequest.Body is null)
return new InvalidRequest();
CreatePostRequest? request = JsonSerializer.Deserialize(apiRequest.Body, _serializerContext.CreatePostRequest);
if (request is null)
return new InvalidRequest();
ValidationResult? validation = await _validator.ValidateAsync(request);
if (!validation.IsValid)
return new ValidationError(validation);
var blogId = apiRequest.PathParameters["blogId"]!;
PostEntity entity = await _store.CreatePostAsync(new ()
{
BlogId = blogId,
Title = request.Title!,
Body = request.Body!,
});
PostDto dto = _mapper.PostToDto(entity);
return new CreatePostResponse
{
Post = dto
};
}
catch (Exception ex)
{
_logger.Error(ex, "Something went wrong");
return new ServerError();
}
}
}
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.
using FluentValidation;
using SgBlogApi.Core;
namespace SgBlogApi.CreatePost;
public class Validator : AbstractValidator<CreatePostRequest>
{
public Validator()
{
RuleFor(x => x.Title).NotEmpty();
RuleFor(x => x.Body).NotEmpty();
}
}
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.
FROM public.ecr.aws/amazonlinux/amazonlinux:2 AS base
WORKDIR /source
# Install dotnet 7 and other dependencies for compiling naively
RUN rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm
RUN yum update -y && yum install -y dotnet-sdk-7.0 clang krb5-devel openssl-devel zip
# Only copy the dotnet source code
COPY ./src/ ./src
COPY ./sg-blog-api.sln sg-blog-api.sln
ENV DOTNET_NOLOGO=true
ENV DOTNET_CLI_TELEMETRY_OPTOUT=true
RUN dotnet publish -r linux-x64 -c Release --self-contained ./src/SgBlogApi.CreatePost
RUN dotnet publish -r linux-x64 -c Release --self-contained ./src/SgBlogApi.GetPost
RUN dotnet publish -r linux-x64 -c Release --self-contained ./src/SgBlogApi.UpdatePost
RUN dotnet publish -r linux-x64 -c Release --self-contained ./src/SgBlogApi.DeletePost
RUN dotnet publish -r linux-x64 -c Release --self-contained ./src/SgBlogApi.ListPost
AWS CDK
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
Prerequisites
- Install Node.js nodejs.org/en
- Install AWS CDK docs.aws.amazon.com/cdk/v2/guide/getting_st..
- Install AWS cli aws.amazon.com/cli
Step 1: Configure an AWS SSO profile setup with the name "sg-dev".
aws configure sso --profile sg-dev
Step 2: Refresh your AWS credentials
npm run sso
Step 3: Publish the lambda functions using NativeAOT
npm run build
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.
#!/usr/bin/env zx
import { existsSync, mkdirSync, rmSync } from "fs";
if (existsSync(".build")) {
rmSync(".build", { recursive: true });
}
mkdirSync(".build");
mkdirSync(".build/SgBlogApi.CreatePost");
mkdirSync(".build/SgBlogApi.GetPost");
mkdirSync(".build/SgBlogApi.UpdatePost");
mkdirSync(".build/SgBlogApi.DeletePost");
mkdirSync(".build/SgBlogApi.ListPost");
const name = "sg-blog-api";
const existing = await $`docker ps -aqf "name=${name}"`;
if (existing.stdout) {
await $`docker rm ${name}`;
}
await $`docker build -t ${name} -f Dockerfile .`;
const run = await $`docker run -d --name ${name} ${name}:latest`;
const containerId = run.stdout.trim();
await $`docker cp ${containerId}:/source/src/SgBlogApi.CreatePost/bin/Release/net7.0/linux-x64/native/. .build/SgBlogApi.CreatePost`;
await $`docker cp ${containerId}:/source/src/SgBlogApi.GetPost/bin/Release/net7.0/linux-x64/native/. .build/SgBlogApi.GetPost`;
await $`docker cp ${containerId}:/source/src/SgBlogApi.UpdatePost/bin/Release/net7.0/linux-x64/native/. .build/SgBlogApi.UpdatePost`;
await $`docker cp ${containerId}:/source/src/SgBlogApi.DeletePost/bin/Release/net7.0/linux-x64/native/. .build/SgBlogApi.DeletePost`;
await $`docker cp ${containerId}:/source/src/SgBlogApi.ListPost/bin/Release/net7.0/linux-x64/native/. .build/SgBlogApi.ListPost`;
await $`mv ./.build/SgBlogApi.CreatePost/SgBlogApi.CreatePost ./.build/SgBlogApi.CreatePost/bootstrap`;
await $`mv ./.build/SgBlogApi.GetPost/SgBlogApi.GetPost ./.build/SgBlogApi.GetPost/bootstrap`;
await $`mv ./.build/SgBlogApi.UpdatePost/SgBlogApi.UpdatePost ./.build/SgBlogApi.UpdatePost/bootstrap`;
await $`mv ./.build/SgBlogApi.DeletePost/SgBlogApi.DeletePost ./.build/SgBlogApi.DeletePost/bootstrap`;
await $`mv ./.build/SgBlogApi.ListPost/SgBlogApi.ListPost ./.build/SgBlogApi.ListPost/bootstrap`;
Step 4: Deploy
cdk deploy sg-blog-api-feat-1008-app --profile sg-dev
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.
Testing
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.
Github Actions
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.
Conclusion
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.