Deploy a Serverless .NET API Using NativeAOT.

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.

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:

  1. Container image
  2. 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.

  <code>
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

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.

Serverless Handbook
Access free book

The dream team

At Serverless Guru, we're a collective of proactive solution finders. We prioritize genuineness, forward-thinking vision, and above all, we commit to diligently serving our members each and every day.

See open positions

Looking for skilled architects & developers?

Join businesses around the globe that trust our services. Let's start your serverless journey. Get in touch today!
Ryan Jones
Founder
Book a meeting
arrow
Founder
Eduardo Marcos
Chief Technology Officer
Chief Technology Officer
Book a meeting
arrow

Join the Community

Gather, share, and learn about AWS and serverless with enthusiasts worldwide in our open and free community.