はじめに
AWS Lambdaによってバックエンドアプリをサーバーレスにすると、可用性やコスト、スケーリングの面で利点があります。
DBとやり取りするバックエンドアプリの場合、かつてはDBコネクションの所要時間が懸念されたり、またそもそもLambda全般に言えるコールドスタートの遅さが課題でした。しかし最近(2020年頃)ではこれらの課題はかなり解消しており、バックエンドアプリをサーバーレスにすることが随分と現実的になっています。
この記事では、DBがMongoDBであり、Expressで書かれたバックエンドアプリについて、Lambdaによるサーバーレス化の流れを紹介します。作成したLambda関数は、API Gatewayを通して公開する想定とします。
MongoDBへのコネクションを再利用する
DBコネクションの課題への対処法は、MongoDBの公式ドキュメントにポイントがまとめられています。
Best Practices Connecting from AWS Lambda
ポイントは2つです。
DBコネクションの変数をLambdaのハンドラの外側に置く
1つ目のポイントは、DBコネクション(を含んだ)部分の変数を、Lambdaのハンドラ関数の外側に定義することです。ハンドラの外側で定義した変数は、同一コンテナ上で実行される複数回のLambda呼び出しに渡って再利用されます。キャッシュみたいなものです。そのため、コネクションを使い回すことができます。
MongoDBのドキュメントに詳しいサンプルコードが載っていますが、概念的には以下のような要領です。
let cachedDb = null; // handlerの外側に定義しておく。
module.exports.handler = (event, context, callback) => {
if (!cachedDb) {
cachedDb = // ここでDBコネクションを作る。
}
// 以下略
};
上記の例ではDBコネクションそのもの( cachedDb
)を変数としましたが、実際にはDBコネクションを内包したExpressの app
を変数にすることになるかもしれません。その方が、appを作る処理を毎回しなくて済むはずです(コネクション作成の所要時間に比べれば微々たる節約になりそうですが)。
ただし、この再利用が効くのは同一コンテナ上でLambda関数が実行された場合だけです。同時にたくさんのLambda呼び出しが来ると、それに対応するために新たなコンテナの作成などが行われます(コールドスタート)。新しく作られたコンテナの初回呼び出しにおいては再利用が効かず、新たなDBコネクション作成が必要となります。
callbackWaitsForEmptyEventLoopをfalseにする
2つ目のポイントは、ハンドラ関数の中で context.callbackWaitsForEmptyEventLoop
という設定をfalseにすることです。これの詳しい説明はMongoDBのドキュメントに載っているのですが、ともかく素直に従ってfalseにしておきます。
// 前略
module.exports.handler = (event, context, callback) => {
context.callbackWaitsForEmptyEventLoop = false; // これ
// 以下略
};
AWS Serverless Expressを使って、ExpressアプリをAPI Gatewayに「Lambdaプロキシ統合」する
Lambdaの前段にAPI Gatewayを置いて公開する形は定番パターンの一つです。ここで、LambdaとAPI Gatewayを繋げる際のシンプルな手段が「Lambdaプロキシ統合」です。これは平たく言うと、API GatewayがLambda関数に要求するI/F仕様みたいなものです。
Lambdaプロキシ統合は手段の一つですので必ずしもこれを使う必要はないのですが、便利なのでおすすめです。詳しくは、以下の記事が参考になります。
API Gateway + Lambda プロキシ結合の使用有無による違い
Expressアプリを扱う際には、ここでAWS Serverless Expressというライブラリが使えます。このライブラリを使うと、Lambda上で動かすExpressアプリをよしなにLambdaプロキシ統合のルールに合わせてくれます。イメージとしては、下図のような変換役です。
AWS Serverless Expressのおかげで、Expressアプリ開発者はLambda統合プロキシの仕様を意識する必要がなくなります。普段扱っている app
を、Lambdaにぽんと載せられるようになります。
AWS Serverless Expressの使い方は、GitHubにあるREADMEに従えばよいだけです。ただし注意点として、Lambdaのハンドラ関数をasyncにしたい場合は、こちらの使い方を参照してください。
ここまでを踏まえたハンドラ関数の実装例
MongoDBのコネクション再利用のテクニックと、AWS Serverless Expressを交えて、ハンドラ関数実装の具体例を載せておきます。TypeScriptで書いた例です。
import { Handler } from "aws-lambda";
import * as awsServerlessExpress from "aws-serverless-express";
// これは何らかの自前の実装。
// この中で、MongoDBへのコネクション作成なども行っているという想定。よってasync関数。
import { createAppAsync } from "./app";
// createServerを呼んだ最終結果を代入しておくための変数を、handlerの外側で定義しておく。
let server: ReturnType<typeof awsServerlessExpress.createServer>;
export const handler: Handler = async (event, context) => { // handlerをasyncにした例
context.callbackWaitsForEmptyEventLoop = false; // セオリー通りにfalse
if (!server) { // Lambdaがコールドスタートしたときは、このif文の中に入る。
const app = await createAppAsync(); // MongoDBに接続しつつ、Expressのappを作って・・・
server = awsServerlessExpress.createServer(app); // AWS Serverless Expressに渡す。
}
// appをAWS Serverless Expressに渡したので、あとはよしなにやってもらうだけ。
return awsServerlessExpress.proxy(server, event, context, "PROMISE").promise;
// 上記は、handlerがasyncの場合の呼び方。
// 4番目の引数 "PROMISE" をつけたり、末尾に .promise をつけたりする。
};
実装ができたら、コードをLambdaにデプロイしたり、API GatewayのLambdaプロキシ統合の設定をしたりして、APIとして公開しましょう。このあたりの詳細は、この記事では割愛します。
コールドスタートの問題も、最近は許容範囲内になってきた
ここまで、MongoDBへのコネクションを再利用したりして、Lambda関数を実行するコンテナが再利用されるとき(ウォームスタート)のパフォーマンス効率化が図れました。
とはいえ、少なくとも初回の呼び出し時はコールドスタートになります。コールドスタートの方が遅くなりますが、Lambdaそのものの進歩によって、最近(2020年)ではかなり改善されているようです。
試しに自分で作ったLambda関数を実行したところ、コールドスタートとウォームスタートの差は1〜2秒くらいで、現実的に十分許容できる印象です。先ほどのMongoDBの公式ドキュメントにおいても、通常1秒以内と言われていました(参照)。
コールドスタートに関する近況については、以下の記事が参考になります。VPC Lambda(通常と違って、自分のVPC内で実行するLambda)の場合のオーバーヘッドも改善されていることや、Provisioned Concurrencyによってお金の力でコールドスタート頻度を下げられること(自前の暖機運転が不要になること)も触れられています。
まとめ
MongoDBにアクセスするExpressアプリを、LambdaとAPI Gatewayによってサーバーレスにする流れを紹介しました。ポイントは以下の通りです。
- ウォームスタート時に再利用したい変数をハンドラ関数の外側に定義したり、callbackWaitsForEmptyEventLoopをfalseにすることで、DBコネクションを再利用する。
- AWS Serverless Expressを使うと、ExpressアプリをLambdaプロキシ統合の仕組みに簡単に従わせられる。LambdaとAPI Gatewayを簡単に繋げられる。
- 2019年頃からLambdaが根本的に進歩しており、コールドスタートの問題も許容範囲内になっている。