One of the major challenges when working with AWS Lambda is bundling all your node_modules into one zip file. Most simple examples rely on zipping up the entirety of ./node_modules, but that doesn't scale well if you're looking to built a suite of Lambda functions as opposed to a single "Hello, World" example. In this article, I'll demonstrate the problem with zipping up Lambda functions yourself and show you how to use Webpack to bundle a Lambda function that connects to MongoDB.

Motivation

For example, say you're building a couple simple API endpoints in Lambda using Mongoose. You have a common file that handles connecting to the database called db.js:

const config = require('./.config');
const mongoose = require('mongoose');

let conn = null;

module.exports = async function connect() {
  if (conn == null) {
    conn = await mongoose.connect(config.mongodb, {
      bufferCommands: false, // Disable mongoose buffering
      bufferMaxEntries: 0 // and MongoDB driver buffering
    });

    conn.model('State', new mongoose.Schema({ uptime: Number }));
  }

  return conn;
};

Now, to create a Lambda bundle lambda.zip with a function that uses db.js, you need to zip -r ./lambda.zip ./db.js ./fn.js ./node_modules. Not bad. But presumably you also want to create some extra Mongoose models, which involves adding another file to your zip command. In other words, you need to manually figure out the entire tree of require()-ed files and make sure they're all in your zip file, otherwise your Lambda function will crash.

Furthermore, zipping up all of node_modules is a bad idea. Once your project grows beyond the proof-of-concept phase and you want to add a testing framework like Mocha or a linter like eslint, your node_modules will be filled with modules that your Lambda function doesn't need in production. devDependencies can help, but then you need to remember to npm install --production if you're building the bundle locally.

In other words, creating a zip bundle manually means you need to follow require() statements to make sure you get all the modules you need and none of the modules you don't. Sounds like a textbook use case for Webpack.

Bundling a Lambda Function with Webpack

Suppose you have a function that gets a document from the 'State' collection in MongoDB. If you're looking to try this code out, make sure you npm install mongoose webpack webpack-cli first.

const db = require('./db');
const handler = require('./handler');

exports.handler = handler(getState);

async function getState() {
  const conn = await db();

  return conn.model('State').findOne();
}

The handler.js file contains a small wrapper function that wraps the getState() function in AWS' preferred function signature.

module.exports = fn => function(event, context, callback) {
  // See https://www.mongodb.com/blog/post/serverless-development-with-nodejs-aws-lambda-mongodb-atlas
  context.callbackWaitsForEmptyEventLoop = false;

  fn().
    then(res => {
      callback(null, {
        statusCode: 200,
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Credentials': true
        },
        body: JSON.stringify(res)
      });
      console.log('done');
    }).
    catch(error => callback(error));
};

There are 4 files to bundle, in addition to Mongoose and whatever node_modules Mongoose pulls in:

  • getState.js: this is the entry point
  • handler.js
  • db.js
  • .config.js: exports an object that contains sensitive credentials. You can also use Lambda environment variables.

The Webpack config file is shown below. This config is simpler than most Webpack configs because you don't need Babel or JSX or any other plugins for Lambda. Lambda now supports Node.js 8, so you don't even need Lambda for async/await.

module.exports = {
  entry: ['./getState.js'],
  target: 'node',
  output: {
    path: `${process.cwd()}/bin`,
    filename: 'getState.js',
    libraryTarget: 'umd'
  }
};

Lambda only accepts zip files as function uploads, so you still need to zip up ./bin/getState.js after you run ./node_modules/.bin/webpack.

zip -r ./bin/getState.zip ./bin/getState.js

Now, you're ready to upload the getState() function to Lambda. Go to the AWS Lambda console and click "Create Function". Name the function getState() and make sure you select "Node.js 8.10" as the runtime for async/await support.

Make sure you tweak the 'handler' input to point to the correct file path. If you zipped up ./bin/getState.js, Lambda will create a ./bin directory when it unzips.

After setting the 'handler', click 'Test' and you should see your function successfully execute.

Moving On

Serverless frameworks like AWS Lambda are becoming increasingly popular for API development. Lambda has numerous advantages over hosting an Express API on Amazon EC2. Lambda's free tier gives you 1 million free requests per month, and doesn't expire after 12 months like EC2's. Lambda functions also are behind HTTPS by default, so you don't have to worry about renewing your LetsEncrypt certificates. So next time you find yourself deploying a REST API, try tinkering with putting it on Lambda instead.

Lambda recently added support for Node.js 8 and async/await. But, Lambda still doesn't support async functions as handlers. Want to know where Lambda's support for async functions breaks? Chapter 4 of Mastering Async/Await explains the core principles for determining whether a given library or framework supports async/await, so get the ebook today!

Found a typo or error? Open up a pull request! This post is available as markdown on Github
comments powered by Disqus