Introduction

 

I recently set up a brand new API for a client. AWS and .NET were givens, the remaining choices were up to me. This article is my way of writing down all the things I wish I knew when I started that work. I assume you already know your way around .NET 6C# 10GraphQL and have your ~/.aws/credentials configured.

Why GraphQL

 

GraphQL has quickly become my primary choice when it comes to building most kinds of APIs for a number of reasons:

  • Great frameworks available for a variety of programming languages
  • Type safety and validation for both input and output is built-in (including client-side if using codegen)
  • There are different interactive ”swaggers” available, only much better

 

Something often mentioned about GraphQL is that the client can request only whatever fields it needs. In practice I find that a less convincing argument because most of us are usually developing our API for a single client anyway.

For the .NET platform my framework of choice is Hot Chocolate. It has great docs and can generate a GraphQL schema in runtime based on existing .NET types.

Why Lambda

 

Serverless is all the hype now. What attracts me most is the ease of deployment and the ability to dynamically scale based on load.

AWS Lambda is usually marketed (and used) as a way to run small isolated functions. Usually with 10 line Node.js examples. But it is so much more! I would argue it is the quickest and most flexible way to run any kind of API.

 

The only real serverless alternative on AWS is ECS on Fargate, but that comes with a ton of configuration and also requires you to run your code in Docker.

 

Initial application setup

 

We start by creating a new dotnet project:

 

dotnet new web -o MyApi && cd MyApi

 

Add AspNetCore and HotChocolate:

 

dotnet add package DotNetCore.AspNetCore --version "16.*"
dotnet add package HotChocolate.AspNetCore --version "12.*"

 

Add a single GraphQL field:

 

// Query.cs
using static System.Runtime.InteropServices.RuntimeInformation;

public class Query {
  public string SysInfo =>
    $"{FrameworkDescription} running on {RuntimeIdentifier}";
}

 

Set up our AspNetCore application (using the new minimal API):

 

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services
  .AddGraphQLServer()
  .AddQueryType<Query>();

var app = builder.Build();

app.UseRouting();

app.UseEndpoints(endpoints =>
  endpoints.MapGraphQL());

await app.RunAsync();

Running locally

 

Let’s verify that our GraphQL API works locally.

 

Start the API:

dotnet run

 

Verify using curl:

curl "http://localhost:<YourPort>/graphql?query=%7B+sysInfo+%7D"

 

You should see a response similar to:

 

{ "data": { "sysInfo":".NET 6.0.1 running on osx.12-x64" } }

 

Lambda runtimes

 

AWS offers a number of different managed runtimes for Lambda, including .NET Core, Node, Python, Ruby, Java and Go. For .NET the latest supported version is .NET Core 3.1, which I think is too old to base new applications on.

.NET 6 was released a few months ago, so that’s what we’ll be using. There are two main alternatives for running on a newer runtime than what AWS provides out of the box:

 

  • Running your Lambda in Docker
  • Using a custom runtime

 

Running your Lambda in Docker was up until recently the easiest way for custom runtimes. The Dockerfile was only two or three lines and easy to understand. But I still feel it adds a complexity that isn’t always justified.

Therefore we will be using a custom runtime.

 

Using a custom runtime

 

There is a hidden gem available from AWS, and that is the Amazon.Lambda.AspNetCoreServer.Hosting nuget package. It’s hardly mentioned anywhere except in a few GitHub issues, and has a whopping 425 (!) downloads as I write this. But it’s in version 1.0.0 and should be stable.

 

Add it to the project:
dotnet add package Amazon.Lambda.AspNetCoreServer.Hosting --version "1.*"

 

Then add this:

// Program.cs
...
builder.Services
  .AddAWSLambdaHosting(LambdaEventSource.HttpApi);
...

 

The great thing about this (except it being a one-liner!) is that if the application is not running in Lambda, that method will do nothing! So we can continue and run our API locally as before.

 

Bootstrapping

 

There are two main ways of bootstrapping our Lambda function:

  • Changing the assembly name to bootstrap
  • Adding a shell script named bootstrap

 

Changing the assembly name to bootstrap could be done in our .csproj. Although it’s a seemingly harmless change, it tends to confuse developers and others when the ”main” dll goes missing from the build output and an extensionless bootstrap file is present instead.

Therefore my preferred way is adding a shell script named bootstrap:

 

// bootstrap
#!/bin/bash

${LAMBDA_TASK_ROOT}/MyApi

 

 

LAMBDA_TASK_ROOT is an environment variable available when the Lambda is run on AWS.

We also need to reference this file in our .csproj to make sure it’s always published along with the rest of our code:

 

 

// MyApi.csproj
...
<ItemGroup>
  <Content Include="bootstrap">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  </Content>
</ItemGroup>
...

Creating the Lambda package

 

We will be using the dotnet lambda cli tool to package our application. (I find it has some advantages over a plain dotnet publish followed by zip.)

 

dotnet new tool-manifest
dotnet tool install amazon.lambda.tools --version "5.*"

 

I prefer to install tools like this locally. I believe global tools will eventually cause you to run into version conflicts.

We also add a default parameter to msbuild, so we don’t have to specify it on the command line.

 

// aws-lambda-tools-defaults.json
{
  "msbuild-parameters": "--self-contained true"
}

 

Building and packaging the application is done by
dotnet lambda package -o dist/MyApi.zip

 

Deploying to AWS

 

The way I prefer to deploy simple Lambdas is by using the Serverless framework.

(For an excellent comparison between different tools of this kind for serverless deployments on AWS, check out this post by Sebastian Bille.)

You might argue that Terraform has emerged as the de facto standard for IaC. I would tend to agree, but it comes with a cost in terms of complexity and state management. For simple setups like this, I still prefer the Serverless framework.

We add some basic configuration to our serverless.yml file:

 

// serverless.yml
service: myservice

provider:
  name: aws
  region: eu-west-2
  httpApi:
    payload: "2.0"
  lambdaHashingVersion: 20201221

functions:
  api:
    runtime: provided.al2
    package:
      artifact: dist/MyApi.zip
      individually: true
    handler: required-but-ignored
    events:
      - httpApi: "*"

 

 

Even though we are using AspNetCore, a Lambda is really just a function. AWS therefore requires an API Gateway in front of it. Serverless takes care of this for us. The combination of httpApi and 2.0 here means that we will use the new HTTP trigger of the API Gateway. This would be my preferred choice, as long as we don’t need some of the functionality still only present in the older REST trigger.

 

runtime: provided.al2 means we will use the custom runtime based on Amazon Linux 2.

 

Now we are finally ready to deploy our Lambda!

npx serverless@^2.70 deploy

 

The output should look something like this:

 

...
endpoints:
  ANY - https://ynop5r4gx2.execute-api.eu-west-2.amazonaws.com
...

 

Here you’ll find the URL where our Lambda can be reached. Let’s call this <YourUrl>.

Calling the Lambda

 

Using curl:
curl "https://<YourUrl>/graphql?query=%7B+sysInfo+%7D"

 

You should see a response similar to:

{ "data": { "sysInfo":".NET 6.0.1 running on amzn.2-x64" } }

Cleaning up

 

Unless you want to keep our Lambda running, you can remove all deployed AWS resources with:
npx serverless@^2.70 remove

 

Take me to the summary!

 

Bonus: Running on ARM

 

AWS recently announced the possibility to run Lambda on the new ARM-based Graviton2 CPU. It’s marketed as faster and cheaper. Note that ARM-based Lambdas are not yet available in all AWS regions and that they might not work with pre-compiled x86/x64 dependencies.

If we want to run on Graviton2 a few small changes are necessary:

  • Compiling for ARM
  • Configuring Lambda for ARM
  • Add additional packages for ARM

 

Compiling for ARM

 

Here we need to add our runtime target for the dotnet lambda tool to pick up:

 

// aws-lambda-tools-defaults.json
{
  "msbuild-parameters":
    "--self-contained true --runtime linux-arm64"
}

Configure Lambda for ARM

 

We need to specify the architecture of the Lambda function:

 

// serverless.yml
functions:
  api:
    ...
    architecture: arm64
    ...

Adding additional packages for ARM

 

According to this GitHub issue we need to add and configure an additional package when running a custom runtime on ARM:

 

// MyApi.csproj
...
<ItemGroup>
  <RuntimeHostConfigurationOption
    Include="System.Globalization.AppLocalIcu"
    Value="68.2.0.9"/>
  <PackageReference
    Include="Microsoft.ICU.ICU4C.Runtime"
    Version="68.2.0.9"/>
</ItemGroup>
...

 

When adding this the API stops working on non-ARM platforms though. A more portable solution is to use a condition on the ItemGroup, like this:

 

// MyApi.csproj
...
<ItemGroup Condition="'$(RuntimeIdentifier)' == 'linux-arm64'">
  <RuntimeHostConfigurationOption
    Include="System.Globalization.AppLocalIcu"
    Value="68.2.0.9"/>
  <PackageReference
    Include="Microsoft.ICU.ICU4C.Runtime"
    Version="68.2.0.9"/>
</ItemGroup>
...

Building, deploying, and calling it once more

 

Build and deploy as before.

Call the Lambda as before.

 

You should see a response similar to:

 

{ "data": { "sysInfo":".NET 6.0.1 running on amzn.2-arm64" } }

 

confirming that we are now running on ARM!

 

Clean up as before.

Summary

 

That’s it! We have now deployed a minimal serverless GraphQL API in .NET 6 on AWS Lambda. Full working code is available at GitHub.

Opinionated take aways:

  • Use GraphQL for most APIs
  • Use Hot Chocolate for GraphQL on .NET
  • Use Lambda for entire APIs, not just simple functions
  • Use dotnet lambda cli tool for packaging
  • Use Amazon.Lambda.AspNetCoreServer.Hosting for custom runtimes
  • Use a simple bootstrap script to start the API
  • Use Serverless framework for deployment
  • Use ARM if you can

 

Any comments or questions are welcome!

Author – Magnus Markling, Freelance Cloud Architect