DEV Community

Frédéric Barthelet for Kumo

Posted on

Serverless can benefit from not being so Lambda-centric

Stop writing unnecessary code

As a developer, I need to write code to develop specific features for a project. However, each line that I write is a potential futur bug and increases my project technical debt.

Less code is better. Serverless services have ways to enable you to remove more code than traditional architectures. The use of managed services reduces the amount of boiler plate code that does not directly adds value to your application while retaining the possibility to write full custom code where it's needed most.

One can argue that full-fledged frameworks achieve the same result of reducing boilerplate code. However, it requires the development team to continuously update this code dependency to its latest revision and ship frequent dependency updates in production. With managed services, this burden is passed down to the cloud provider.

Most software engineers relates serverless to its per-use ephemeral compute environment - also known as FaaS - (Lambda/ECS for AWS, Cloud Functions for GCP or Azure Functions for Azure), often missing on a large opportunity offered by all the other serverless services a cloud provider has to offer. Serverless real value is not within compute services, but rather in side-car managed services, handling task usually handled by non-ephemeral compute environment.

This article focus on missed opportunities to write less code and leverage managed serverless services, where those can handle efficiently and flawlessly a large number of request without the need to write or depend on a single runtime-executed line of code. I'll dive on a specific exemple, using AWS, to explain the use case and benefits of using non-compute services and actually have them interact in order to develop a specific feature - hereby named functionless.

What you can do with AWS Lambda

One Lambda to rule them all

Lambda can be invoked from a variety of services, allowing the development of event-driven architectures. With the variety of triggers offered to request the execution of your code, the possibilities are endless. You can easily react to a change in your database, to a new file being upload on your bucket, to a new user being registered on your application.

The versatility of writing your own code and execute it in reaction to those changes is however often in contrast with what I actually see people implementing in their code base.

Countless times I've seen the magic triptych: API Gateway - Lambda - DynamoDB

API Gateway - Lambda - DynamoDB triptych in action

This triptych is often used to develop HTTP endpoints, be it a simple CRUD REST API used as a backend or an incoming webhook endpoint receiving notifications from 3rd parties.

Why do we see this architecture so often ?

  • Serverless Framework is lambda-centric and encourages new adopters to trigger lambdas everywhere
  • Imperative code is familiar to developer, while AWS services configuration is much less common knowledge
  • The developer experience setting up an alternative architecture not relying on Lambda is considerably worse (you can jump to the end of this article for more information)

Let's take the exemple of writing-reading data to DynamoDB.

What you should do with AWS Lambda

In order to identify precisely what you should do within your Lambda handlers' code, it is important to understand what NOT to do. As with our previous exemple, almost everything can be done directly within API Gateway.

  • Authorization: using a Cognito user pool or an API key
    Authorization

  • Input validation: using built-in JSON schema validation configuration
    Input validation

  • Routing: describing specific routes in API Gateway rather than use a catch all {proxy+} integration
    Routing

  • Content transformation: transform HTTP request content and map to the corresponding attributes as expected in the database layer
    Content transformation

At the end of the day, for such "simple" operations, you can almost always by-pass lambda altogether and rely on the following functionless architecture.
Target functionless architecture for CRUD operations

Why should I opt-out of Lambda for such use cases ?

As I explained at the beginning of this article, writing less code has benefits of its own for a project code base in the long run. However, that's not the only advantages brought by Functionless.

💰 Cost

API Gateway incurred cost in the Functionless architecture are always the same, whether you use it for validation, routing, authorization and content transformation or not.

The cost repartition in the original triptych architecture are as follow (per million executions, in us-east-1 region):

  • 3,50$ for API Gateway
  • 0,70$ for Lambda (including invocation + 30ms warm execution)
  • 1,25$ for DynamoDB

Total: 6,45$ per million executions

Removing Lambda from this architecture simply removes Lambda incurred costs from the bills, without increasing other services respective costs, actually cutting off our costs by 13%.

🚀 Performance

The serverless triptych stack - in green on graph below - and functionless stack - in blue on graph below - performs almost identically. The results shown below represents API Gateway latency (not taking networking latency into account) for both stacks with the same feature: a POST request with a payload containing a single attribute resulting with an item being persisted in DynamoDB.

Comparaison of response time distribution over 300 requests for serverless triptych (in green) and functionless (in blue)

The serverless triptych stack is much more consistent over time with almost all warm responses latency ranging between 30ms and 40ms.

The functionless stack is less performant most of the time - around 50ms - but much faster on some occasions:

  • 6% of the requests have a latency in the 10ms range
  • 16% in the 20ms range

However, in the event of a cold serverless triptych stack, results are considerably worse.

Lambda's cold start issue

Lambda is fast once it's warm. The first request requiring compute from Lambda service is much slower than the consecutive requests due to cold start issues. Lambda actually requires to provision a dedicated environment to run your code. You can overcome this issue using provisioned Lambda instances, but what's the point of going serverless then? Provisioned instances significantly impacts the overall cost of the stack, making it much less competitive.

In this instance, the serverless triptych stack averages at 600ms latency when Lambda is cold, which is 10x more than the warm latency.

Regarding cost, while init duration is not included in billed duration, the overall cost of the execution is much larger due to a larger amount of time required to process the event. In practice, the average billed duration for cold lambda is 188ms, which represents 3,40$ per million execution. Removing this dependency to Lambda cuts off our costs by 42% in worst case scenario (or best case scenario, depending on the way you look at it) - considering all invocations are cold invocations.

🗜 Throttling

Using a functionless stack removes limitations from Lambda service:
Lambda concurrent execution quota (1000 by default) limits the total quantity of lambda being invoked at some point in time in a single AWS account. While this quota can be increased, not using Lambda removes any worry related to concurrency limits. I'm not saying other services used in the functionless stack aren't limited as well: API Gateway has a 10.000 RPS throttling limit as well as a 5000 requests burst limit, while DynamoDB, in its on-demand provisioned capacity, has a 1000 WCU limit per second. You should pay special attention to those services configuration to handle larger RPS on your application. You just have one less service - Lambda - to worry about in a functionless stack.

How to deploy your first functionless feature ?

Now it's time to address the elephant in the room: developer experience. My first interaction with AWS service direct integrations like API Gateway to DynamoDB was a disaster. I was guided in this experimentation following Andrew Baird's guide on using API Gateway as a proxy to DynamoDB from 2016. If you haven't already face the challenge of correctly configuring such direct integration in API Gateway, I strongly suggest you try following this guide. You lose all benefits from using a more performant and less expensive stack if nobody from your team wants to touch API Gateway functionless configuration with a ten-foot pole.

The process of implementing such functionless stack, as described in Andrew's article, will be used as our experience baseline. Moving forward, I'll detail an alternative that can be used to make the overall experience much more enjoyable and practicable.

Integration Uri

In order to forward HTTP requests made to API Gateway to DynamoDB endpoint, you must setup an AWS service integration. The instructions to setup one using the web console requires knowledge on what to fill in the following fields.

AWS service integration setup in web console

Some of those fields can easily be guessed, like region and action, as they are commonly used in other IaC scenarios (like IAM role statements definition).

Others require factual knowledge: all AWS services, including DynamoDB, expose an HTTP API that is used by AWS CLI, SDK, web console. All routes on those API use POST http verb. While this could make sense for our exemple at hand here, calling PutItem operation, it is much less obvious if what you're trying to do is a GetItem or Query operation and you're familiar with SaaS exposing RESTful APIs.

The service you want to use is another one of those parameter that requires factual knowledge: while using dynamodb for the DynamoDB integration is quite straight forward, some other services are less obvious, such as events instead of eventbridge for an EventBridge integration.

The HTTP method - POST - as well as the service to be used - dynamodb - are used in combinaison with the region to form the resource Uri. While using the web console is a great first step in using direct AWS services integration feature, teams often rely on versioned IaC to provision an application, where such helper is not always available. In order to setup such integration using CloudFormation, you'd need to provision an AWS::ApiGateway::Method resource, which requires you to provide the full integration Uri all by yourself. This Uri is a source of frustration for most developers I worked with as it greatly differs from one service to another. In addition, it requires deep understanding of a 15-lines long documentation in order to build it correctly. The resulting CloudFormation templates ressembles something like this:

MyAPIGatewayMethod:
  Type: AWS::ApiGateway::Method
  Properties:
    Integration:
      IntegrationHttpMethod: POST
      Type: AWS
      Uri:
        Fn::Join:
          - ":"
          - - "arn"
            - Ref: AWS::Partition
            - apigateway:eu-west-1:dynamodb:action/PutItem
Enter fullscreen mode Exit fullscreen mode

Hopefully, the CDK provides a way to benefit back from the web console helper to build such Uri, using the AwsIntegration class you only need to provide region, action, service to achieve the same provisioning.

const putItemIntegration = new apigateway.AwsIntegration({
  service: 'dynamodb',
  action: 'PutItem',
  region: 'eu-west-1'
});
Enter fullscreen mode Exit fullscreen mode

Integration mapping template

Choosing a service and an action for API Gateway to forward request to is the first step at building an AWS service integration. The next step is to transform the payload of the HTTP request made to API Gateway into what the integrated AWS service expects.

This is done using Velocity Template Language. Here after is an exemple of such a template:

{
    "TableName": "MyTable",
    "Item": {
        "PK": {
            "S": "Dog"
        },
        "SK": {
            "S": "$context.requestId"
        },
        "name": {
            "S": "$input.path('$.name')"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This template shapes the payload of the DynamoDB action. It includes API Gateway custom method to hydrate SK and name attribute's value:

  • $context.requestId uses a context variable to generate a unique id for the PutItem action
  • $input.path('$.name') uses an input variable to retrieve a specific value from the HTTP body of the request made to API Gateway

Relying on VTL is cumbersome: it requires additional knowledge of the syntax and it is not easily testable (some librairies gave it a go like API Gateway Template Tester but are currently unmaintained).

Easier integration with aws-apigateway-integrations CDK construct

One way to improve this is to allow the use of imperative language and tools people are used to in the triptych serverless stack to generate such template. With this goal in mind, I started a CDK Construct library, called aws-apigateway-integrations, aiming at simplifying AWS service integration definition, following @aws-cdk/aws-apigatewayv2-integrations module footsteps.

Using the new constructs shipped with this library, you can rely on familiar AWS SDK v3 DynamoDB Commands interfaces in order to build your VTL, simplifying previous template down to:

import { PutCommandInput } from "@aws-sdk/lib-dynamodb";
import { DynamoDBActions, DynamoDBIntegration } from "aws-apigateway-integrations";

const putItemCommand: Omit<PutCommandInput, "TableName"> = {
  Item: {
    PK: "Dog",
    SK: "$context.requestId",
    name: "$input.path('$.name')"
  }
};
const putItemIntegration = new DynamoDBIntegration({
  action: DynamoDBActions.PutItem,
  command: putItemCommand,
  //...
});
Enter fullscreen mode Exit fullscreen mode

Integration response and method response

Request authorization, validation and transformation is only half of the functionless architecture. Both integration response, handling content transformation from DynamoDB HTTP response and method response, detailing API Gateway response typologies, must be provisioned for the overall architecture to work properly.

Integration response similarly relies on VTL to unmarshall content returned by DynamoDB.

{
  "id": "$input.path('$.Item.SK.S')",
  "name": "$input.path('$.Item.name.S')"
}
Enter fullscreen mode Exit fullscreen mode

Such VTL can easily be generated using DynamoDBIntegration, removing the burden from actually defining it yourself.

What's next in the functionless ecosystem ?

While aws-apigateway-integrations is currently a work in progress, I have great hope such integration would greatly facilitate adoption of functionless patterns by greatly improving the overall developer experience. With this in mind, the current development roadmap for this CDK Constructs library includes:

  • adding JSON schema generation and configuration for API Gateway validation within DynamoDBIntegration
  • adding IAM role generation within the scope of the construct to allow API Gateway to execute DynamoDB actions
  • providing better API to wrap $input variable
  • implementing other AWS services integration

All feedbacks or even contributions are welcome !

Such implementation opens up a lot of new possibilities: a functionless RESTful API where the only code is configuration-only IaC is not out of reach anymore and can help team achieve cheaper, more robust code base with minimal efforts. Such implementation is currently under discussion within Lift project where your feedbacks on the implementation are highly valued.

Discussion (0)