How to deploy ASP.NET Core API into AWS Lambda

In this how-to guide, learn about how we can deploy our ASP.NET Core Web API as an AWS Lambda function and the things we need to look into for it.

Introduction – What is a Microservice?

For applications targeting the AWS Cloud stack, these “microservices” are deployed in the form of Lambda functions, which offer a highly available and scalable model for developing load-balanced web services, which also provides a cost effective pricing model.

In order to build highly scalable and robust web services hosted in the cloud, developers generally consider breaking up into Microservices – these facilitate quick replication, scaling and availability.

In this article, Let’s look at how we can convert our ASP.NET Core WebAPI service into such a Lambda Function and deploy it in AWS.

Setting up the Example API Project

In our previous example, we have looked at building a simple Readers Management Web Application with ASP.NET Core MVC which is backed by a DynamoDB table. Let’s say we have also added another component to this band, which offers a RESTful API solution for the Readers entity.

Since this service component only comprises of API which represents only a single Responsibility (Readers API resource), we can assume this as a “microservice”.

> dotnet new webapi --name DynamoDb.ReadersApp.WebApi

Since we’ve separated our Models and Domain functionalities into distinct Layers as a part of embracing Clean/Layered/Onion Architecture model, adding a new component which uses these tiers is simple and makes no redundant code.

The new WebAPI project contains a single Controller endpoint representing the Readers resource:

namespace DynamoDb.ReadersApp.WebApi.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ReadersController : ControllerBase
    {
        private IReadersRepository _repository;

        public ReadersController(IReadersRepository repository)
        {
            _repository = repository;
        }

        [HttpGet]
        public async Task<IEnumerable<Reader>> Get(string userName = "")
        {
            if (!string.IsNullOrEmpty(userName))
            {
                var readers = await _repository.Find(
                    new SearchRequest { UserName = userName });
                return readers;
            }
            else
            {
                var readers = await _repository.All();
                return readers.Readers;
            }
        }

        [HttpGet]
        [Route("{readerId}")]
        public async Task<IActionResult> Single(Guid readerId)
        {
            try
            {
                var reader = await _repository.Single(readerId);

                if (reader != null)
                    return Ok(reader);
                else
                    return NotFound();
            }
            catch (Exception ex)
            {
                return StatusCode(
                    StatusCodes.Status500InternalServerError, ex);
            }
        }

        // POST: ReadersController/Create
        [HttpPost]
        public async Task<ActionResult> Create(ReaderInputModel model)
        {
            try
            {
                await _repository.Add(model);
                return StatusCode(
                    StatusCodes.Status201Created);
            }
            catch (Exception ex)
            {
                return StatusCode(
                    StatusCodes.Status500InternalServerError, ex);
            }
        }

        // POST: ReadersController/Edit/5
        [HttpPatch]
        [Route("{readerId}")]
        public async Task<ActionResult> Edit(
            Guid readerId, ReaderInputModel model)
        {
            try
            {
                await _repository.Update(readerId, model);
                return StatusCode(StatusCodes.Status200OK);
            }
            catch (Exception ex)
            {
                return StatusCode(
                    StatusCodes.Status500InternalServerError, ex);
            }
        }

        [HttpDelete]
        [Route("{readerId}")]
        public async Task<ActionResult> Delete(Guid readerId)
        {
            try
            {
                await _repository.Remove(readerId);
                return StatusCode(StatusCodes.Status200OK);
            }
            catch (Exception ex)
            {
                return StatusCode(
                    StatusCodes.Status500InternalServerError, ex);
            }
        }
    }
}

Since we’re injecting our abstraction IReadersRepository into this project, we’d register this class as a service inside our Startup class in this WebAPI project.

services.AddScoped<IReadersRepository, ReadersRepository>();

To test how things work, we can make a sample API call to fetch all the Reader resources from the DynamoDB table.

curl -X GET "http://localhost:5000/api/Readers" -H "accept: application/json"

And we can see that the response indicates our API is working fine.

Converting the Solution to Lambda

A Lambda function in AWS cloud stack works for a particular responsibility, and can be invoked through an API Gateway which provides a powerful solution for routing, authorization and other performance aspects such as caching.

At its simplest terms, a Lambda function is a single function – called as a FunctionHandler, which wraps a certain functionality which is executed based on an input Request context and a Response is sent out on completion.

The following distinguish an ASP.NET Core Web API project from a Lambda function –

  • A FunctionHandler definition which takes a Request and produces a Response
  • LocalEntryPoint and LambdaEntryPoint classes for entry points
  • AWSProjectType as Lambda

When we add these things into our WebAPI project, our project turns itself into a Lambda function!

To get started, let’s install the necessary libraries into our API project to add the Lambda related features.

<PackageReference Include="Amazon.Lambda.AspNetCoreServer" Version="5.1.1" />
<PackageReference Include="AWSSDK.Extensions.NETCore.Setup" Version="3.3.101" />

The LocalEntryPoint class looks like below, which is same as our Program class. Either you can Rename the Program class to LocalEntryPoint or replace the file with this. When we run the Lambda project in our local system, it looks up for LocalEntryPoint class and runs the Main() from there.

using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;

namespace DynamoDb.ReadersApp.WebApi
{
    /// <summary>
    /// The Main function can be used to run the 
    /// ASP.NET Core application locally using the Kestrel webserver.
    /// </summary>
    public class LocalEntryPoint
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
    }
}

The LambdaEntryPoint class is where the magic happens in the Cloud. It looks like below:

namespace DynamoDb.ReadersApp.WebApi
{
    /// <summary>
    /// This class extends from APIGatewayProxyFunction 
    /// which contains the method FunctionHandlerAsync which is the 
    /// actual Lambda function entry point. 
    /// The Lambda handler field should be set to
    /// 
    /// DynamoDb.ReadersApp.WebApi::
    /// DynamoDb.ReadersApp.WebApi.LambdaEntryPoint::
    /// FunctionHandlerAsync
    /// </summary>
    public class LambdaEntryPoint : APIGatewayProxyFunction
    {
        /// <summary>
        /// The builder has configuration, 
        /// logging and Amazon API Gateway already configured. 
        /// The startup class needs to be 
        /// configured in this method using the UseStartup<>() method.
        /// </summary>
        /// <param name="builder"></param>
        protected override void Init(IWebHostBuilder builder)
        {
            builder.UseStartup<Startup>();
        }

        /// <summary>
        /// Use this override to customize 
        /// the services registered with the IHostBuilder. 
        /// 
        /// It is recommended not to call ConfigureWebHostDefaults 
        /// to configure the IWebHostBuilder inside this method.
        /// Instead customize the IWebHostBuilder 
        /// in the Init(IWebHostBuilder) overload.
        /// </summary>
        /// <param name="builder"></param>
        protected override void Init(IHostBuilder builder)
        {
        }
    }
}

The APIGatewayProxyFunction contains the FunctionHandlerAsync() method, which is invoked first when the lambda starts its execution. The function invokes the Init() method, where we’re attaching our Startup class (from the WebAPI which contains the Service registrations) here.

Well, that’s all. We’ve converted our API into a Lambda function. How do we deploy it?

Deploying to Lambda via dotnet CLI –

While there are several ways for doing this, let’s go the manual way to better understand how it is done. First, we need to install tools which help us in this deployment.

> dotnet tool install -g Amazon.Lambda.Tools

This command installs Lambda tools, which help us in packing our code for deployment in Lambda. To pack our application, we run:

> dotnet lambda package

Which basically publishes our code against Linux environment (Lambda runs on Linux, FYI) and compresses the binaries into a zip file, which we can use to deploy.

The default output location is “bin\Release\netcoreapp3.1\” and in our case the output is “DynamoDb.ReadersApp.WebApi.zip” generated.

How to create a Lambda in Console –

To deploy this code, we need to first create a Lambda function.

  1. Open the AWS Console, and look for “Lambda” under services.
  2. Navigate to the Lambda services page, where you can find a “create function” button.
  3. Click it to navigate to the create function form.
  4. Choose the box “Author form Scratch” and under the function name provide “testDynamoDbNetCoreFunction”.
  5. Runtime shall be .NET Core 3.1 (C#/Powershell) and Click on “Create Function”.

Configuring Lambda Access Policies –

Once the function is created, you’ll be taken to the LambdaFunction details page.

  1. Look for the Basic Settings card, and click on Edit.
  2. For the Handler input, provide input in the format => “namespace.class.FunctionHandlerAsync” where our namespace is “DynamoDb.ReadersApp.WebApi” and our fully qualified class name is “DynamoDb.ReadersApp.WebApi.LambdaEntryPoint”.
  3. In my case, it is DynamoDb.ReadersApp.WebApi::DynamoDb.ReadersApp.WebApi.LambdaEntryPoint::FunctionHandlerAsync
  4. Under the Existing Role section, you’ll have an option to view the role on IAM console, click it. It’d open up the Role inside IAM console.
  5. Since our API accesses a DynamoDB table, our Lambda Function must be provided access for DynamoDB resource (typical AWS Cloud thing). For this, we shall add a relavant access “Policy” to the role which our Lambda function assumes while execution.
  6. I’m interested in providing access to only the particular table “test_readers” to our Lambda, because it is a security best practice to let the Lambda access only what is required of it.
  7. In the IAM portal, where i’m now able to see the Role details my Lambda has assumed, click on “Attach Policies” and then in the next page click on “Create Policy”. Here we need to add a JSON policy describing how our Lambda needs to be allowed for access only on “test_readers” table. I’d make use of the Policy provided in the AWS documentation for this purpose.
  8. Once the policy is created, back in our Attach Policies section, look for the created Policy and click on “Attach Policy”. Now we have our DynamoDB access ready for our Lambda.
  9. Finally, in the Basic Settings Card, click on Save once all these are done. The Lambda now has the Handler pointing to the one inside our Code, and has a Policy to access the “test_readers” table.

Uploading the Zip –

Time to upload our zip file into Lambda.

  1. In the Lambda Properties page, Look for Function Code section and on the Actions button, click on “Upload a zip file” option.
  2. Once the file dialog opens, select the zip file we just created using the Lambda Package command.
  3. Once uploaded, the Page shows up a confirmation that upload is done.

Testing the Functionality –

Since we have not yet added an API Gateway for this Lambda, we can’t access this function directly. Instead, we can simply use the “Test” feature inside the Console page.

  1. Expand the dropdown next to “Actions” inside the Lambda page which says “Configure Test Events”
  2. A Dialog opens, where select “create new test event” and then provide an “Event name”.
  3. In the Event template, select “Amazon API Gateway AWS Proxy” which is what our API has extended in the code (APIGatewayProxyFunction)
  4. You’ll find a sample template populated inside the dialog, where paste the below content:
{
  "resource": "/api/Readers",
  "path": "/api/Readers",
  "httpMethod": "GET",
  "isBase64Encoded": true
}
  1. Click on Save and once the Dialog closes, click on “Test”.

The Lambda is now executed and results in a response shown with an alert highlighted in green “Execution result: succeeded”. When you expand the alert, you can find our expected response.


  "statusCode": 200,
  "headers": {},
  "multiValueHeaders": {
    "Content-Type": [
      "application/json; charset=utf-8"
    ]
  },
  "body": "{"id":"ef5256e3-8f21-4c3e-8579-c7580e7c4407","name":"Jack","emailAddress":"jack@op.com","username":"jack","addedOn":"2020-09-04T14:53:49.262+00:00", ... }",
  "isBase64Encoded": false
}

Conclusion

In this way, we can convert our API into a “microservice” deployed in Lambda function and then verify its functionality. While we have done all these manual things for setting up the Lambda in this attempt, there’s another way where we can create our Lambda function and an API Gateway associated with it along with required Policies and Roles auto mapped – which we shall look in the next article.

Buy Me A Coffee

Found this article helpful? Please consider supporting!


Buy Me A Coffee

Found this article helpful? Please consider supporting!

Ram
Ram

I'm a full-stack developer and a software enthusiast who likes to play around with cloud and tech stack out of curiosity. You can connect with me on Medium, Twitter or LinkedIn.

Leave a Reply

Your email address will not be published. Required fields are marked *