DEV Community

Magnus Markling
Magnus Markling

Posted on

Running a GraphQL API in .NET 6 on AWS Lambda

Table of contents

  1. Introduction
  2. Why GraphQL
  3. Why Lambda
  4. Initial application setup
  5. Running locally
  6. Lambda runtimes
  7. Bootstrapping
  8. Creating the Lambda package
  9. Deploying to AWS
  10. Calling the Lambda
  11. Cleaning up
  12. Bonus: Running on ARM
  13. Summary

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 6, C# 10, GraphQL 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}";
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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" } }
Enter fullscreen mode Exit fullscreen mode

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);
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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: "*"
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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" } }
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Configure Lambda for ARM

We need to specify the architecture of the Lambda function:

// serverless.yml
functions:
  api:
    ...
    architecture: arm64
    ...
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

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" } }
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (8)

Collapse
 
daniel_frank profile image
Daniel Frank

Great article.
How about cold starts? Do you have tested R2R?

Collapse
 
memark profile image
Magnus Markling

Thank you!
No, I really haven't. But I could look into it! Are you interested in coldstarts for R2R in particular, or .NET on Lambda in general?

Collapse
 
daniel_frank profile image
Daniel Frank

I'm interested in coldstart for .NET (.NET 6 and custom runtime). For now, my team are using netcore 3.1 with R2R to reduce Lambda coldstart.

Collapse
 
andreav profile image
andrea

Hello, great article!
Can you use subscriptions with this approach? It looks like it's hard to use them with API gateway and lambda, at least I didn't find a simple solution until now.

Collapse
 
memark profile image
Magnus Markling

Thanks!
I honestly don't know. What specific issues did you run into? 15 min timeout?

Here are some ideas though:
tsh.io/blog/implementing-websocket...

Collapse
 
andreav profile image
andrea

Thank you for the response.
I know when it comes to web sockets that you have to manage it manually through $connect, $disconnect and so on (as described into the article you linked).
I was asking if with this approach you have the same problem.
By the way, I tried AppSync, and it works very well. Subscriptions are supported out if the box.

Collapse
 
win32nipuh profile image
win32nipuh

Hi Magnus, thank you for the article. Yet another question: I deploy the app to Lambda. How to declare GraphQL endpoints in AWS Gateway? HTTP?

Collapse
 
memark profile image
Magnus Markling

Did you try deploying it the way I describe above? It should work right away with any manual handling.