Serverless Framework+Node.jsをつかったLambda関数の開発フロー

今回は、Serverless Framework+Node.jsをつかったLambda関数の具体的な開発フローを書いてみる。動作確認やテスト、CI連携まで一貫して行なったので、参考になればと思う。

つくったもの

Webアプリケーション側である変更がはいった際に、複数のRDBとElasticsearchにまたがるデータを合わせて更新する、という、中間的な役割を実装した。S3に更新のデータが5分毎にPUTされるので、そこからイベントを取る。

目次

フロー全体

一連の開発フローとしては以下のような流れになる。

  1. ロジック実装/ユニットテスト
  2. ローカルでの結合テスト
  3. development環境での結合テスト
  4. PR作成(CIでテストの実行)
  5. レビュー
  6. マージ(CIでテストの実行)
  7. staging(production)環境へデプロイ
  8. staging(production)環境での結合テスト

UIがないというのもあり、型とテストを主軸に開発をすすめるスタイルとなった。型・テストで動作担保しつつ、ローカルで動作確認、デプロイして結合動作確認、本番へ、というイメージ。以下、一つ一つ説明していく。

アーキテクチャ

Serverless Framework+Node.jsで普通に設計するという記事にも書いたが、構成としては以下のような感じである。

  • デプロイ/パッケージング
    • Serverless Framework
  • ビルド
    • webpack+Babel
  • 開発効率系
    • AVA
    • Flow
    • eslint
    • yarn
  • その他
    • axios/bluebird/lodash/momentなど

モジュール等はLambdaのサイズを削減するため、例えば以下のような読み込み方をしている。

1
2
3
4
5
6
7
// 👌使いたいモジュールのみを読み込む
import {isArray} from 'lodash';
isArray([1, 2, 3]);

// 🙅モジュール全部を読み込む
import lodash from 'lodash';
lodash.isArray([1, 2, 3]);

Lambda関係なしにこうするべきだが、今回は特に心がけた。それでもAWSコンソール上で書くLambda関数よりはだいぶ大きくなってしまうが…そこは多少複雑になるとやむを得ないのかなと思う。もしくは、webpack以外のbundlerを使って容量を削減するのは正しい気がする。

UIを考慮する必要がない分かなりシンプルな設計に落とし込めたと。特にフレームワーク等も使う必要はなかったが、モジュール単位で開発ができたのは品質に貢献したかなと思う。普段使っているスタックと変わらない構成で開発ができたが、特に、開発効率系がそのまま使えたのは嬉しかった。

ロジック実装/テスト

AWS SDKのテスト

Writing Testable AWS Lambda FunctionsTestable Lambda: Working Effectively with Legacy Lambdaでも述べられているが、Seam(接合部)に着目し、ロジックを切り分ければテストを書くのは難しくない。最初の記事ではLocalStackを使用しているが、今回はそこまでのことはせず、関数の責務を切り分け、テスタブルになるよう実装した。例えば以下のような感じである。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export const putToS3 = (params: TParams, s3: TS3Instance = S3Instance) => {
const putObject = promisify(s3.putObject, {context: s3});
return putObject(params)
.then((data) => data)
.catch((error) => error);
};

// 実際に使う時
import S3 from 'aws-sdk/clients/s3';
putToS3(new S3());

// テスト
import S3 from 'aws-sdk/clients/s3';
const s3Mock = {
putObject: (param, callback) => {
callback('', DATA_SUCCESS); // レスポンスもモックを返す
},
};
putToS3(s3Mock);

これだけだと意味がないテストのようにも見えるが、実際には複数の関数が結合されるので、この手法を応用してテストを書いておくと、リファクタや機能追加時にかなり安心できる。RDBのテストも、コレと同じ要領でDBインスタンスをモックしてやったり、mockeryを使うなどしてまるごと書き換えたりをした。これにより、あとあとになって開発効率が上がってゆく感じになりとてもよかった。

関数の分離

↑の話ともかぶってしまうのだが、関数の責務をわけて、なるべく副作用のない純粋関数になるように気をつけた。これによってLambda関数がテスタブルになるというのはもちろんだが、責務が小さいために、実装のスピードにも影響すると感じた。

Node FunctionをPromise化

他に気をつけた点として、例外が起きた時のビジネス的な損失が大きな機能だったため、Node FunctionなどはすべてPromise化して、どこでエラーが起きてもcatchできるようにしたというのがある。例えば以下のような感じである。

1
2
3
4
5
6
import xml2js from 'xml2js';
const parser = new xml2js.Parser();
const parseString = promisify(parser.parseString, {context: parser});
return parseString(xmlData)
.then((results) => results)
.catch((error) => error);

このようにシンプルな処理ならもちろんNode Functionでもよいのだが、やはりPromiseにした方が見通しが良くなり値が扱いやすいのと、万が一、成功時の処理のなかでエラーが起きてもcatchされるので安心できた。

エラーログ

前述したが、例外が起きた時の損失に耐えうるように、基本的に確認しうるすべての箇所でエラーログをとった(CloudWatchにログされる)。また、Slackやメール通知、Lambda自体の監視(こちらはコードの管轄外だが)も行なった。

ローカルでの結合テスト

Serverless Frameworkを使うとLambda関数をローカルでも実行できる。これはWeb開発でいうと、ブラウザでの動作確認のようなもので、基本的に開発フローとしては、テストが通った後に結合テスト的な意味合いで確認した。例えば、handler.jsにLambdaへ露出させる関数を以下のように書いていたとする。

1
2
3
4
5
export const runDeleteFromDB = async () => {
const ids = ['50681112', '50733612'];
const result = await deleteFromDB(ids);
console.log(result);
};

これは下記のようなコマンドで実行できる(実際はyarnコマンドにまとめてある)。

1
$(npm bin)/serverless webpack invoke --function runDeleteFromDB

DBの環境はDockerで用意してもらっていたが、関数を実行することできちんとローカルのDBに対して更新の処理をかけることができる。また、イベントなども任意で指定できる。

development環境での結合テスト

一通りテストと動作確認ができたら、デプロイして結合テストを行う。これは、あらかじめdevelopment環境を用意しておき、本番にいく前に確認する。

AWSの設定

Serverless Frameworkではデプロイする関数に対して指定するIAMロールを自動生成してくれる。例えば以下のように指定すると、BUCKET_NAMEに対してS3のGETアクションがかけられるようになる。

1
provider:
  iamRoleStatements:
    - Effect: Allow
      Action:
        - s3:GetObject
      Resource: "arn:aws:s3:::BUCKET_NAME/*"

BUCKET_NAMEなどの値を環境毎に切り替えるには以下のように記述できる。これで、Serverless CLIのstageオプションによってバケット名が切り替えられたりする。環境毎に値を切り替えたければ基本的にcustomに環境ごとの値を作っておけば切り分けができる。

1
provider:
  iamRoleStatements:
    - Effect: Allow
      Action:
        - s3:GetObject
      Resource: "arn:aws:s3:::${self:custom.bucketNameForUpload.${opt:stage}}/*"

custom:
  bucketNameForUpload:
    development: ${env:S3_BUCKET_NAME_FOR_UPLOAD_LOCAL} # ${env:}で環境変数を取得できる
    staging: ${env:S3_BUCKET_NAME_FOR_UPLOAD_STAGING}
    production: ${env:S3_BUCKET_NAME_FOR_UPLOAD_PRODUCTION}

VPCも以下のように設定できる。

1
provider:
  vpc: ${self:custom.vpc.${opt:stage}}

custom:
  vpc:
    development:
      securityGroupIds:
      subnetIds:
    staging:
      securityGroupIds:
        - sg-xxx
      subnetIds:
        - subnet-xxx
        - subnet-xxx
    production:
      securityGroupIds:
        - sg-yyy
      subnetIds:
        - subnet-yyy
        - subnet-yyy

このように、実装によってAWSの設定を変更する場合があればその対応をする。

デプロイ・実行

準備ができたらdevelopment環境へデプロイし、動作確認する。

1
2
3
4
5
# デプロイ
$(npm bin)/serverless deploy --stage development

# アップロードされているLambda関数をターミナルから実行
$(npm bin)/serverless invoke --log --stage development --function runDeleteFromDB

PR作成(CIでテストの実行)

ここまでできたらプッシュして、PRを作成する。CIでテストの実行は、yarn testでlintとユニットテストが流れる。

レビュー、マージ(CIでテストの実行)

レビューで指摘事項があれば修正し、マージする。マージ後、再度CIでテストが実行される。

staging(production)環境へデプロイ

マージ先がmasterブランチならステージングへ、releaseなら本番環境へデプロイされる。

staging(production)環境での結合テスト

Webでもなんでもそうかもしれないが、本番へデプロイされたら結合テストを行い、動作確認終了、これにて一連のフローが完了である。

その他

  • MySQLのコネクションは実行終了後に毎回切っておかないと、例えばVPCの設定を接続できないように変えても、同じインスタンスで接続し続けてしまうので、Lambdaであるにもかかわらずステートレスでなくなってしまう。また、JS側の側での接続インスタンス作成処理は、変数などにいれずに毎回リフレッシュしないと、これも同じインスタンスを使い続けてしまう。
  • 稀に、Lambdaの関数実行中のタイミングと合わせてデプロイすると(?)、Unable to import module 'handler': Errorみたいなエラーが起こることがあって、詳細まで原因を切り分けられていない…。そのため、デプロイのタイミングは実際にはCI自動デプロイではなく、手動でやることも多かった。もしわかる方いれば教えてください。
  • ハマった時にAWS側の基礎知識がそこそこ必要。僕のように普段フロントエンドをやってる人は最初は辛い時があるかも
  • 既存のS3バケットにトリガーをアタッチするときはこちら

まとめ

Webの開発フローをかなり踏襲できるとはいえ、Lambda特有のくせもあったので、今回やってみて、Lambdaの開発フローを通しでできたのは良かった。しっかり設計を行って開発ができたことは、品質(に対する要求を楽に実装するために…)に貢献したと思う。まだLambdaの開発フローは確立されていないところがあるので、よりよいスタイルを目指してやっていきたい。

このエントリーをはてなブックマークに追加