Our team has been working with serverless applications for two years. It changed the way our team deploys applications. We no longer worry about provisioning servers and ensuring they are up. With AWS Lambda, we just make sure our code works and trust the infrastructure will run it.
Serverless Framework (SF) made it possible to build large projects in AWS Lambda
Before Serverless Framework, we considered Lambda only for scripting. Back then, mature tooling around Lambda didn't exist to build production-grade systems.
Serverless Framework made deployment easier. It is a necessary abstraction on top of AWS Lambda and CloudFormation; allowing teams to provision AWS Services to deploy serverless applications. It also comes with a plugin community that extends the functionality of Serverless Framework. Some plugins package code dependencies. Other plugins extend SF's syntax to make running AWS Services easier.
SF is a microframework for deployment: customizability over convention
But on organizing code, SF is at par with micro-frameworks like Flask (for Python) and Sinatra (for Ruby). It only requires you to point your Lambda function to an entry point in your code called a "handler function" and that's it. You are free to structure your code however you like.
However, SF's priority on customizability does not mean there should be no convention. It just means we are free to define it to fit our business requirements. This flexibility gives us a lot of power. We can shape our code in any form we like. But it also provides us with a lot of room for mistakes.
And boy, we did we make a lot of mistakes. You see, a microframework already assumes you already know what's best for your application. And for many people, that's clearly not the case. Here is a preview of mistakes we discovered as we encountered them in production:
- Lambda has a time limit of 900s. If your workload exceeds that, it gets cut off.
- CloudFormation has a limit of 500 resources (used to be 250). Beyond that, you can no longer deploy your changes
- Lambda can be very expensive if you let thousands of long-running functions repeatedly run (SQS allows retries of 3x by default). Imagine 5000 1GB files to be processed, maxing out the 900s, with up to 5x retries for each files. That's easily 6,250 hours of Lambda runtime. Very expensive.
- Lambda's performance is directly tied to how big its deployment package is and how much memory it comes when it is executing
Without the proper "guardrails" baked into the framework, we are bound to make these mistakes over and over
That's why we need another web framework on top of Serverless Framework. We need a convention-based framework like Django and Ruby on Rails to run on top of SF. With the MVC (Model-View-Controller) pattern, it has baked in good software architecture by default.
We cannot use MVC, however. MVC largely subscribes to having a relational database backed by an ORM accessible via the Model layer. With serverless, however, we lean towards using external services that can handle the scale that AWS Lambda is capable of. This mindset favors using AWS Services instead, which are built for scale to begin with. Key favorites are:
- DynamoDB: A NoSQL alternative for storing data
- SQS: A very performant queuing service
- SNS / SES: An easy way to send notifications, etc
Serverless web applications shouldn't also have views. They should be web APIs that is being used by frontend applications written in ReactJS or VueJS.
Introducing the HMG pattern for a web framework
Over the course of 2 years, we built applications on SF over and over. After experimenting across dozens of projects, we came up with the HMG pattern - Handler, Model, Gateway.
Serverless applications are Cloud Native applications. They are built to maximize the usage of AWS services and to minimize the coding that has to be done. With gateways, we store the library of classes that allow us to access external services.
Our gateway classes wrap common boto3 classes and methods so we can abstract away how to use "boto3" from our model-layer. We also add our own custom logic on top of boto3. Here are some examples:
SqsGateway.group_batch_send_message(queue_name, messages)- automatically groups messages in batches of 10 and sends them over to SQS
SqsGateway.delete_message(queue_name, messages)- delete the messages from the queue
We store our business logic in models. This enforces that our developers code in Object-Oriented Programming instead of using a bunch of "helper" classes:
# bad def handler(event, context): body = event['body'] data = ValidateOrderPayment.perform(body) if data['success']: ValidateOrderContents.perform(body) package_body = CreatePackageForOrder.perform(body) SendEmailHelper.perform(package_body) # good def handler(event, context): body = event['body']['order'] payment_info = event['body']['payment_info'] order = Order(body) order.validate_payment(payment_info) order.validate_contents() package = Package(order, payment_info) package.send_customer_email()
And finally, the handlers. A handler is equivalent to the controllers in an MVC framework. It creates the necessary objects and calls the necessary functions in the model layer to orchestrate the kind of action we expect from it. For example, in the code snippet above we saw the code for the "Complete Order" handler.
The API /order/complete was called and SF pointed it to this handler function. This code then created an order and package objects, and did the actions needed to complete an order.
On top of the HMG pattern, we have also adopted the service-based architecture where we split our applications into many services that have their own serverless.yml. This creates small deployable units. Instead of carrying all the dependencies of the whole applications, the Lambda functions deployed within that unit will just carry the dependencies shared with that service.
This pattern solves the 500 resource limit of CloudFormation since each of our serverless.yml files is built for services, which are usually small in scope (and won't reach 500 resources). It also helps our Lambda function be performant as the deployment packages are small.
shared/ gateways/ dynamodb_gateway.py app/ checkouts/ handler/ create_checkout.py patch_checkout.py complete_checkout.py models/ checkout.py product.py cart.py cart_item.py package.json requirements.txt serverless.yml products/ controllers/ get_product_by_id.py handler/ product.py collection.py category.py package.json requirements.txt serverless.yml
Over the next few blog posts, I will elaborate more on the HMG pattern. And how we believe your deployments should be structured.
We have built no web framework yet. The HMG pattern is just a design pattern we impose on all our projects. We have scoured the internet for something similar; the best we found is a loose-based of standards recommended by the serverless stack.
We might be wrong, though. There may be a framework out there that already implements the HMG design pattern for Serverless Framework. But we are yet to see one (please comment if you have seen one!). This is something we may build in the future. But we are otherwise glad to have it built by someone else. If you are working on something similar, perhaps we can work together.
How about you?
What patterns do you follow? And what other pain points of Severless Framework are you experiencing?
Let me know in the comments!
Photo by Denys Nevozhai on Unsplash
Top comments (0)