This article provides five key pieces of advice to keep in mind when building a serverless app using AWS Lambda and the Serverless framework. Consider function scope, latency and testing, with code samples in Node.js
Serverless architectures are powered by a cloud service such as AWS Lambda. This cloud service manages the servers on which the application runs, thereby taking the burden of managing infrastructure and scaling off the developer’s shoulders.
Serverless technologies have been increasing in popularity. The absence of server provisioning makes them particularly enticing. In this article, we’ll consider a few dos and don’ts to look out for when building a serverless application, specifically when using the serverless framework.
Serverless architectures work best with a microservices or service-oriented model. While you could technically build a complete content management system (CMS) as a set of Lambda functions, the serverless approach is best suited for building small, self-contained services that serve a larger application. The serverless framework encourages this approach by using the term “service” rather than “app”.
Lambda functions should be designed as small, focused units of functionality. For instance, suppose we have a web app that works similarly to Google Translate Word Lens. It extracts text in a foreign language from images and translates them to English. A poorly designed service would handle everything, from rendering the page where you upload the image to displaying the translated text. A better approach would be to reduce the scope of the service, so it takes in only the image and returns the translated text. With this structure, our service is free to serve mobile apps as well.
The ability of a Lambda function to invoke another is a useful tool that helps with keeping functions focused. In our image-translation service, we could have a function that extracts text from images and one that translates text to English. We could then expose a third function that takes in an image and calls these two:
1const AWS = require('aws-sdk'); 2 const lambda = new AWS.Lambda(); 3 4 module.exports.extractTextFromImage = (event, callback, context) => { 5 ... 6 } 7 8 module.exports.translateText = (event, callback, context) => { 9 ... 10 } 11 12 module.exports.translateTextFromImage = (event, callback, context) => { 13 let params = { 14 FunctionName: 'extractTextFromImage', 15 InvocationType: 'RequestResponse', 16 Payload: JSON.parse(event.body).image 17 }; 18 19 lambda.invoke(params).promise() 20 .then(response => { 21 params = { 22 FunctionName: 'translateText', 23 InvocationType: 'RequestResponse', 24 Payload: JSON.parse(response).text 25 }; 26 return lambda.invoke(params).promise(); 27 }) 28 .then(response => callback(null, response)) 29 .catch(callback); 30 };
For operations with independent results, we can invoke the functions asynchronously by setting the InvocationType
to Async
.
Your Lambda functions should be stateless. This means they shouldn’t persist any data or session information in the environment beyond the lifetime of a single request.
Why? AWS Lambda typically launches a new instance of your function to serve a new request, with each function running in its own isolated environment. However, as part of its auto-scaling, a function instance might be reused (especially if you have low traffic). In such a situation, only the code in the handler function gets executed in the new run. This means any variables not local to the function retain any changes from previous runs.
This can give rise to some weird bugs. Here’s an example:
1let tweets = []; 2 module.exports.handler = (event, callback, context) => { 3 for (tweet of getTenRecentTweetsFromTwitterApi()) { 4 if (isAboutTech(tweet)) { 5 tweets.push(tweet.id); 6 } 7 } 8 callback({ body: tweets}); 9 }
The first time this function is invoked, ten recent tweets will be fetched and filtered. On subsequent invocations, if the same instance is used, the variable tweets
will still hold tweets from the previous run.
We can refactor the function above to avoid this by replacing the loop-and-push operation with an assignment:
1let tweets = []; 2 module.exports.handler = (event, callback, context) => { 3 tweets = getTenRecentTweetsFromTwitterApi() 4 .filter(isAboutTech) 5 .map(tweet => tweet.id); 6 callback({ body: tweets}); 7 }
This is on the flip side of the above tip. This involves taking advantage of the fact that Lambda might reuse your function instances to make some performance optimizations.
Let’s continue with the example of our image-translation service. This time, our function translateTextFromImage
uses a few packages to do its job, rather than delegating to other functions. It also keeps a log of every translation in a database. Here’s one way we could write it:
1module.exports.translateTextFromImage = (event, callback, context) => { 2 let db= require('db'); 3 let encode = require('some-encoding-package'); 4 let extractTextFromImage = require('another-package'); 5 let translate = require('yet-another-package'); 6 7 let body = JSON.parse(event.body); 8 let image = encode(body.image); 9 let text = extractTextFromImage(image); 10 let translated = translate(text, body.from, body.to); 11 let conn = db.createConnection(process.env.MONGODB_URL); 12 conn.model('TranslationLog') 13 .create({text, translated}, (err, res) => { 14 callback({ body: translated}); 15 }); 16 }
However, if we wanted to take advantage of execution context reuse, it would be better to write it like this:
1let db= require('db'); 2 let encode = require('some-encoding-package'); 3 let extractTextFromImage = require('another-package'); 4 let translate = require('yet-another-package'); 5 let conn = db.createConnection(process.env.MONGODB_URL); 6 7 module.exports.translateTextFromImage = (event, callback, context) => { 8 let body = JSON.parse(event.body); 9 let image = encode(body.image); 10 let text = extractTextFromImage(image); 11 let translated = translate(text, body.from, body.to); 12 conn.model('TranslationLog') 13 .create({text, translated}, (err, res) => { 14 callback({ body: translated}); 15 }); 16 }
As we mentioned earlier, AWS Lambda runs each function in its own isolated container. Sometimes a container is kept alive and reused on multiple requests. After a period of time with no activity, the container will be destroyed.
This means AWS has to create and launch a new container for the next request. This is called a cold start, and it causes a performance hit by introducing some latency (the time it takes to provision and starts up the container). This can cause your function to take several seconds to respond and tie up a large application with other actions waiting for the function’s output.
You can reduce the latency of your Lambda functions by scheduling a CloudWatch event that pings the function every few minutes to keep it alive, as outlined here.
You can also make use of serverless-plugin-warmup, a plugin for the serverless framework that keeps Lambda functions warm. Here’s a guide on how to do that.
Testing is an integral part of developing software, and with serverless, it’s no different. With AWS Lambda, however, things are a bit more involved. Functions are stateless, so testing locally is more difficult.
There are varying schools of thought on how best to test Lambda functions. One useful thing to keep in mind is to use dependency injection to make your functions more easily testable. For instance, the following function is used to add a new user:
1const db = require('db').connect(); 2 const mailer = require('mailer'); 3 4 module.exports.createUser = (event, context, callback) => { 5 db.insert('users', JSON.parse(event.body)) 6 .then((user) => { 7 mailer.sendWelcomeEmail(user.email); 8 callback(null, user); 9 }); 10 };
We could extract the logic into a new module that has its dependencies injected:
1// users/create.js 2 module.exports.createUser = (db, mailer) => { 3 return (data) => { 4 db.insert('users', data) 5 .then((user) => { 6 mailer.sendWelcomeEmail(user.email); 7 return user; 8 }); 9 }; 10 } 11 12 // handler.js 13 const db = require('db').connect(); 14 const mailer = require('mailer'); 15 const createUser = require('users/create')(db, mailer); 16 17 module.exports.createUser = (event, context, callback) => { 18 createUser(JSON.parse(event.body)) 19 .then(user => callback(null, user)); 20 };
Now we can proceed to write traditional unit tests for the createUser
function and inject mock database
and mailer
objects.
It’s also important to test our functions as a whole (integration tests), both locally and on deployments. The serverless framework allows you to invoke your functions locally in a Lambda-like environment with custom event data by running the serverless invoke local
command.
For remote tests, we can make use of an AWS API Gateway feature called stages. Stages are specific releases of a service. By default, the serverless framework deploys to the dev
stage, which is where we typically run tests. Once a release has passed testing, we can then deploy to a master
or production
stage. For more resources on testing, see Designing testable Lambda functions and How to Test Serverless Applications.
Serverless computing is a powerful tool that allows you to unleash your inner code ninja by freeing you from the hassle of DevOps. Knowing when to use it and the right things to do can go a long way in improving your experience with it. Here are some more useful resources for building apps for AWS Lambda: