Card image cap

Deploying an ASP.NET Core API into an AWS Lambda Function

AWS ASP.NET Core  • Posted 24 days ago

In order to build highly scalable and robust web services hosted in the cloud, developers generally opt for popular architectural designs such as Microservices which facilitate quick replication, scaling and availability.

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 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 - A "Microservice":

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

Things that distinguish an ASP.NET Core Web API project from a Lambda function are:

  • 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?

Creating a Lambda and Deploying the Code:

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.

Creating 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
}

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.

What is the difference between Response.Redirect() and Server.Transfer() ?
How do you handle errors Globally in ASP.NET Core?
How do you design a strongly-typed class for a configuration?
How can you bind a configuration section to an object?
When to use IOptionsMonitor?
We use cookies to provide you with a great user experience, analyze traffic and serve targeted promotions.   Learn More   Accept