Lambda(Node.js)の開発、テスト、デプロイ

概要

Lambda(Node.js)関数を作成するには、手元でコードを書き、zipにまとめてコンソールでアップするとドキュメントにも書かれていますが、実際の開発のときにはもうちょっと手間のかからないデプロイのやり方が必要です。また、デプロイの前には手元で動作確認やテストも実行したくなります。

今回は、Dockerを使用して手元でLambda関数の開発、テスト、デプロイを行う方法を紹介します。

開発

開発環境構築

開発環境構築はDockerで行います。 Lambdaの実行環境はAWSドキュメントにありますが、この環境になるべく近い開発環境を立てたいので、ドキュメントを参考にDockerfileを作成します。 また、このDockerにデプロイツールのapex、テスト用ライブラリもインストールします。

docker-lambdaというLambda用のDockerイメージもありますが、今回は使用しません。docker-lambdaにはaws-cliなど今回使用しない機能がはいっているのと、作成するDockerfileがそう複雑ではないためです。
Serverless Frameworkというものもありますが、今回は同じく利用しません。リソース管理は別にCloudFormationで行いたいからです。

作成したDockerfileはこちらです。 これをdocker-compose.ymlを使って立ち上げます。

git clone https://github.com/ropupu/lambda-dev-test-deploy-sample.git
cd lambda-dev-test-deploy-sample/
docker-compose build
docker-compose up -d
docker ps

これで、開発用のlambdaコンテナが立ち上がります。

AWSリソース構築

AWSリソースの構築を行います。リソースはCloudFormationを使用して管理します。 今回は例として、図のようにLambdaとSNSを使い、Lambda->SNSにpublishするためのリソースを作成します。 f:id:ropupu-ropupu:20190120121728p:plain

これを構築するCloudFormationファイルはこちらです。

また、このCloudFormationを使ってリソースを構築すると、パラメーター(SNSトピックをサブスクライブするメールアドレス) の入力を求められます。その後リソース構築が完了すると、入力したメールアドレスに『AWS Notification - Subscription Confirmation』というタイトルで確認メールが届くので、本文中の確認リンクを押しておいてください。

コードを書く

.
├── cloudformation.yml
├── docker
│   └── Dockerfile
├── docker-compose.yml
└── lambda
    └── functions
       └── publishSNSMessage
           └── index.js

このようなディレクトリ構成を作り、lambda/functions/publishSNSMessage/index.jsにファイルを作成します。このindex.jsがLambda関数に対応します。コードの中身は以下のようになります。

const AWS = require('aws-sdk');
const sns = new AWS.SNS({apiVersion: '2010-03-31'});

exports.handler = async function(event, context) {
  const snsTopicArn = process.env['SNS_TOPIC_ARN'];
  const subject = "hello";
  const message = "hello from Lambda!";

  const params = {
    TopicArn: snsTopicArn,
    Subject: subject,
    Message: message
  };

  try {
    await sns.publish(params).promise();
  } catch (err) {
    console.log(err);
    throw err;
  }

  return "success!";
};

環境変数からSNSトピックのARNを取得し、そのトピックに『hello』というメッセージを送信する関数です。

初回デプロイ

apex設定

apexを利用してLambdaをデプロイする前に、いくつか設定を行う必要があります。 まず、docker-compose.ymlと同階層に.envファイルを作成します(.env.exampleファイルをコピーして使ってください)。.envファイルには、以下のパラメーターを入力します。

AWS_ACCESS_KEY_ID=AKIAXXXXXXXXX
AWS_SECRET_ACCESS_KEY=xxxxxx
AWS_REGION=ap-northeast-1

ここで、AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEYは、CloudFormationで作成したIAMユーザー『lambda-deploy-user』のアクセスキーIDとシークレットアクセスキーをAWSコンソールから取得して入力します。入力が終わったら、この設定を反映させるため、docker-composeコマンドを利用してdockerコンテナを再起動させてください。

次に、lambdaディレクトリ内にproject.jsonを作成します。これはapexによるデプロイを行う際の全体設定のためのファイルです。内容はこちらです。

最後に、index.jsと同階層にfunction.jsonを作成します(function.example.jsonファイルをコピーして使ってください)。これはapexによるデプロイを行う際の関数ごとの設定ファイルです。

{
    "description": "publish SNS topic",
    "handler": "index.handler",
    "role": "arn:aws:iam::xxxxxx",
    "environment": {
        "SNS_TOPIC_ARN": "arn:aws:sns:xxxxxx"
    }
}

この『role』と『SNS_TOPIC_ARN』にあたるものは、それぞれCloudFormationで作成したIAMロールとSNSトピックのARNです。コンソールなどで確認し、入力してください。

これらの設定が終わると、プロジェクトのディレクトリ全体はこのようになっています。

├── .env
├── cloudformation.yml
├── docker
│   └── Dockerfile
├── docker-compose.yml
└── lambda
    ├── functions
    │   └── publishSNSMessage
    │       ├── function.json
    │       └── index.js
    └── project.json

デプロイ

docker execを使ってdockerコンテナの中でapexコマンドを実行します。

$ docker exec -it lambda bash
bash-4.2# apex deploy
   • updating config           env= function=publishSNSMessage
   • updating function         env= function=publishSNSMessage
   • created alias current     env= function=publishSNSMessage version=1
   • function updated          env= function=publishSNSMessage name=publishSNSMessage version=1

これでLambda関数がデプロイできました。デプロイした後、apexで関数の実行もできます。

bash-4.2# apex invoke publishSNSMessage
"success!"

"success!"と表示された後、CloudFormationでのリソース構築時に入力したメールアドレス宛メールをチェックしてください。Lambdaから送信されたメールが届いているはずです。

f:id:ropupu-ropupu:20190120155712p:plain

テスト

一旦Lambdaのコード作成、デプロイまでが完了しましたが、コードのテストを行っていません。 ここではテストフレームワークmochaアサーションツールとしてchai、ライブラリのスタブ化を行ってくれるproxyquireを利用してテストを行います。

テスト作成

まず、index.jsと同階層にpackage.jsonを作成します。

{
  "name": "publishsnsmessage",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "devDependencies": {
    "aws-sdk": "^2.290.0"
  }
}

そして、Dockerコンテナに入り、package.jsonをもとにnpm installを行います。

$ docker exec -it lambda bash
bash-4.2# cd functions/publishSNSMessage
bash-4.2# npm install

これは、proxyquireによるライブラリのスタブ化を行う場合、そのライブラリがインストールされている必要があるためです。

次に、index.jsと同階層にtestディレクトリを作成します。その中にtest.jsファイルを作成し、ここにテストコードを書いていきます。 また、test.jsと同じ階層にtest.envファイルも作成します。今回テスト対象であるindex.jsはprocess.envを使用しているため、テスト時に環境変数のセットを行うためです。 test.jsの内容はこちら、test.envの内容はこちらです。

すべての設定が終わると、プロジェクトのディレクトリ全体はこのようになっています。

├── .env
├── cloudformation.yml
├── docker
│   └── Dockerfile
├── docker-compose.yml
└── lambda
    ├── functions
    │   └── publishSNSMessage
    │       ├── function.json
    │       └── index.js
    │       ├── node_modules
    │       ├── package-lock.json
    │       ├── package.json
    │       └── test
    │           ├── test.env
    │           └── test.js
    └── project.json

テスト実行

Dockerコンテナに入り、テスト対象関数(今回はpublishSNSMessage)ディレクトリ内に移動します。そして、mochaコマンドでテストを実行します。

$ docker exec -it lambda bash
bash-4.2# cd functions/publishSNSMessage
bash-4.2# mocha


  publishSNSMessage
    ✓ should return success! when sns publishing succeeds (3526ms)
Error: aws sdk error
    at Object.promise (/var/lambda/functions/publishSNSMessage/test/test.js:25:17)
    at Object.exports.handler (/var/lambda/functions/publishSNSMessage/index.js:16:31)
    at Context.it (/var/lambda/functions/publishSNSMessage/test/test.js:49:25)
    at callFn (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runnable.js:372:21)
    at Test.Runnable.run (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runnable.js:364:7)
    at Runner.runTest (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:455:10)
    at /opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:573:12
    at next (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:369:14)
    at /opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:379:7
    at next (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:303:14)
    at Immediate._onImmediate (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:347:5)
    at runCallback (timers.js:794:20)
    at tryOnImmediate (timers.js:752:5)
    at processImmediate [as _immediateCallback] (timers.js:729:5)
    ✓ should throw error when sns publishing fails


  2 passing (4s)

これで、手元でLambda関数の開発、テスト、デプロイを行えるようになりました。

2018年にやったこと[ソフトウェアエンジニアリング関連]

2019年の年始の雰囲気も薄れてきた今になってようやく書く、2018年にやったこととそこから学んだことです。

リストアップしてみるとこうなります。

  • Lambda(Node.js)

    • Lambdaの概要
    • test(mocha, chai, proxyquire)
  • Deploy

    • to S3(CodeBuild, CodePipeline, API Gateway, Microsoft Flow)
    • to Lambda(apex)
    • to DB(db-migrate)
  • E2E test

    • puppeteer
  • Docker

    • ECS
  • Coursera


Lambda

既存のEC系サービスのバックエンドにLambda(Node.js v6)が使われていて、途中からプロジェクトに入り、開発や運用を引き取りました。 Lambdaをちゃんと使ったことがなかったので、Lambdaへのデプロイ方法、ライフサイクル、バージョニングとエイリアス、等Lambdaに関する知識を学びました。

コードについては、最初の段階では処理が共通化されていなかったり、直にSQL文が書かれていたりしたので、モデルを作成してそちらに処理を移すようにしました。 また、テストをmocha, chai, proxyquireを使って書きました。

Deploy

to S3

上記のプロジェクトでは、フロントエンドのコードを手元でwebpackを使ってビルドし、awsコマンドを使って必要なファイルだけをアップ、その後CloudFrontのキャッシュを削除、という手順でデプロイが行われていたので、この手順をCodeBuildを使って自動化しました。 また、このCodeBuildとCodePipeline、それにAPIGatewayとLambdaを組み合わせて、HTTPリクエストを投げるとデプロイが始まるという状態を作りました。これにより、今までエンジニアサイドでのみ可能だったデプロイが他の人でもできるようになりました。デザインのみの修正を行う場合、デザイナーがデザイン・コーディングを行い、その後ボタン(今回はMicrosoft Flowを使っています)を押せば変更が開発環境に反映されるようになりました。また開発環境上で動作の確認をした後、ステージング、本番への反映も可能です。これにより、素早い開発が可能となりました。

to Lambda

Lambdaもコードを手元でzip化してコンソールでアップロードという状態だったので、Apexを使ってデプロイするようにしました。

to DB

データベースの変更はmysqlに接続して直にSQL文を流す状態だったので、db-migrateを導入しました。ライブラリとしては薄めですが、今回のケースには合っていて必要十分という感じです。

E2E test

puppeteerを使ったE2Eテストを作成しました。サンプルコードはこちら

Docker

Dockerを本格的に使い始めました。こちらの本で一通りを学習できてよかったです

Docker/Kubernetes 実践コンテナ開発入門

Docker/Kubernetes 実践コンテナ開発入門

また、ECSを現在進行系で使用中です。Fargateの料金が下がって嬉しい。今はここらへんのデプロイ周りのところに悩んでいます。

Coursera

ちょっと毛色が違いますが、去年からCourseraをはじめました。"Software Design and Architecture"というコースをとりました。これは4つの講座から成り立っていて、"Object-Oriented Design", "Design Patterns", "Software Architecture", "Service-Oriented Architecture"をそれぞれ1ヶ月ずつ使って受けた感じです。 ビデオ講義と、実際に手を動かして提出する課題、それにテストがあり、週末はけっこうこれに費やしてました。 内容はオブジェクト指向デザインパターンアーキテクチャ設計+UMLなどです。 ちゃんとこれらを体系立てて学んでいなかったので、歯抜けになった知識が埋まって、ずーと放っておいた宿題を片付けたような気分です。

去年にやった主なことはこんなかんじですね。もうちょっとちょくちょくまとめていったほうがよさそうです。今年はもうちょいまめにブログを書くことを目標にします。

LeetCode: Binary Tree-3

Solve Tree Problems Recursively

ツリーの問題は、トップダウンもしくはボトムアップ再帰的に解くことができる。 トップダウンのアプローチでは、最初に探索したノードの子ノードを再帰的に探索する。つまり、このようなやり方になる。

  1. 対象ノードが空のノードである場合は終了
  2. 返り値を対象ノードの値で更新する
  3. 左の子ノードに対して同じ関数を適用し、返り値を取得する
  4. 右の子ノードに対して同じ関数を適用し、返り値を取得する
  5. 返り値に3と4の結果を追加し、返り値を返す

例えば、与えられた二分木の深さを知るためには、次のようなやり方で解く。 まず、ルートノードの深さは1である。ノードの深さがわかっていれば、その子ノードの深さもわかる。なので、再帰的に解くとき、親ノードの深さをパラメーターとして渡せば、子ノードの深さもわかり、またその子ノードの深さを知ることができる。深さを知るための関数maximum_depth(root, depth)は以下のような動きをする。

  1. rootが空ならここで終わる
  2. もしrootが空でなければ
  3. answer = このノードの深さとanswerの深い方
  4. maximum_depth(root.left, depth + 1)
  5. maximum_depth(root.right, depth + 1)

ボトムアップのアプローチでは、まず最初にすべての子ノードに対して再帰的に関数を呼び出す。そして返り値と対象ノードの値を比較して答えを導く。つまり、このようなやり方になる。 1. 対象ノードが空のノードである場合は終了 2. 左の子ノードに対して同じ関数を適用し、返り値を取得する 3. 右の子ノードに対して同じ関数を適用し、返り値を取得する 4. 2,3の結果と対象ノードの値から答えを導き、返す

ツリーの深さを知るときには、先程のトップダウンのアプローチとまた違った考え方もできる。左の子ノードからはじまるサブツリーと右の子ノードからはじまるサブツリーの深さがわかっていれば、対象ノードの深さはわかる。これを順々に適用することによって、ツリー全体の深さが得られる。深さを知るための関数maximum_depth(root)は以下のような動きをする。

  1. 対象ノードが空なら0を返す
  2. left_depth = maximum_depth(root.left)
  3. right_depth = maximum_depth(root.right)
  4. 2と3の深さの大きいほうに1を足した値を返す

結論

再起を理解し、またそれを問題に適用するやり方を見つけるのは難しい。 ツリーの問題にあたったとき、次の二つの問を自分に投げかけるといい。 - ノードに対していくつかのパラメーターを与えることで、そのノードから答えを得られるか? - 子ノードに何のパラメーターを渡すのかについて、定義されたパラメーターもしくは対象ノード自身の値から算出することはできるか? 両方を満たすなら、トップダウン・アプローチを使うことができる。

もし、対象ノードの子ノードから導かれる答えがわかっていれば、対象ノードの答えがわかるか?これが満たされるなら、ボトムアップ・アプローチを使うことができる。


コードはこちら

LeetCode: Binary Tree-2

Level-order Traversal 概要

Breadth-First Search(幅優先探索)は、ツリーやグラフのようなデータ構造を探索するアルゴリズム。 探索はルートからはじまり、そのルート自身をまず訪問済みにする。次に、隣接している階層のノードを探索し、訪問済みとする。その階層のノードすべてを訪問済みにしたら、その下の階層のノードを探索し、訪問済みとする。これを続ける。 ノードの探索順は、階層順になる。(ルート→第二階層のノード→第三階層のノード→...となる)

幅優先探索のためにキューを使うのが特徴的である。(訪問済みのノードをキューにためておく)

LeetCode: Binary Tree-1

概要

LeetCodeを始めたのでその学習記録です。

Binary Tree

まえがき

『ツリー』とは、階層木構造を表すデータ構造である。 ツリーの各ノードは、ルート(根)と、子ノードと呼ばれる他のノードへの参照リストを持つ。 グラフ理論でいえば、ツリーは連結されていて閉路を持たないグラフであり、N個のノードとN-1個のエッジ(枝)を持つ。

バイナリツリーは、ツリーのもっとも典型的なものである。各ノードは多くて二個の子ノード(左と右)を持つ。

Traverse a Tree - Introduction

  • Pre-order Traversal ルートを訪問済みとして、左の子ノードを訪問していく。末端のノードにたどり着いたらそのルートノードに戻り、右から始まるサブツリーを、同じルールで探索する。根、左、右、の順。

  • In-order Traversal ルートを出発点にして、左のサブツリーから末端の左ノードを探索する。末端のノードにたどり着いたらそれを訪問済みとして、そのルートノードも訪問済みにし、次に右から始まるサブツリーを同じルールで探索する。左、根、右の順。 In-order Traversalを使うと、バイナリツリー内のソートされたデータを順序通り取得できる。

  • Post-order Traversal ルートを出発点にして、左のサブツリーから末端の左ノードを探索する。末端のノードにたどり着いたらそれを訪問済みとして、次にその同階層の右ノードから始まるサブツリーを同じルールで探索する。すべての子ノードが訪問済になったらルートも訪問済みにする。左、右、根の順。

例えば、ノードとその子ノードをツリーから削除しようとしたとき、対象ノードの子孫ノードは対象ノードの削除より前に削除されていなければならない。Post-order Traversalは、子孫ノードを先に訪問済みにできるので、このような処理に適している。 また、Post-order Traversalは、計算式を表現するのにも適している。

Coursera Service-Oriented Architecture Week3 REST Service

REST API設計の注意点

  • URIには名詞のみを使う 例えば、大学用のAPIを作るとしたら、/students/や/students/SID/coursesのように、名詞をURIに使う。 (ただしこれは厳密にRESTfulであるとはいえない。よいURIは、リソースを示し、クライアントがそれに容易にアクセスできるようにする)

  • GETメソッドはリソースの状態を変えない GETメソッドは、リソースを参照するためだけに使う。リソースの状態に影響を与える操作は、POST、PUT、DELETEメソッドで行う

  • URIには複数形を使う x student -> o students

  • リソースとリソースの関係を表すためには、サブリソースを使う 例えば、生徒がコースをとっているということを表すために、/students/SID/coursesというようにサブリソースを使用する。

  • 入力/出力フォーマットを指定するためにHTTPヘッダーを使う Content-Typeでリクエストメッセージのフォーマット、Acceptで期待するレスポンスメッセージのフォーマットを指定する。

  • クエリパラメーターでフィルターやオフセットの指定ができるようにする /students?name=hogeのように

  • APIをバージョンづけする 既存のAPIを変えずに、新しいバージョンをリリースできるよう、URIにバージョン番号を含めるとよい

  • 適切なHTTPステータスコードを使用する

マイクロサービス

巨大なチームで作られる一枚岩(同じコードベース)のシステムには、 - メンテナンスしづらい - スケールしづらい - 開発期間が長くなる - パフォーマンスが悪い 等の問題が出てくる。 これに対応するために、Service Oriented Architectureは、巨大なシステムを小さい機能ごとにサービスとして分割し、それぞれのサービスは疎結合カプセル化されているようにすることを提案する。

マイクロサービスは、Service Oriented Architectureのバリエーションの一つである。 マイクロサービスのアーキテクチャスタイルは、マイクロサービスを組み合わせて一つのアプリケーションを作り上げるためのものである。 マイクロサービスは、一つの独立したタスクに対して責務を持つ。例えば、あるアプリケーションの中で、一つのマイクロサービスは検索機能の責務をもち、また一つのマイクロサービスはレコメンド機能の責務を持つなどである。 マイクロサービスはそれぞれ独立して開発される。マイクロサービスはしばしばレイヤードアーキテクチャーのすべての層を実装しない。なぜなら、マイクロサービスは他のマイクロサービスと連携することを意図しており、例えばエンドユーザー向けのプレゼンテーション層を持たない場合があるからである。

マイクロサービス同士のやりとりでは、HTTPがよく使われる。RESTインターフェースは、マイクロサービス同士のステートレスなコミュニケーションによく用いられる。

マイクロサービスには、次のような利点がある。 - マイクロサービスは、そのサービスに適した言語、フレームワークアーキテクチャを使うことができる。そのため、実装したい機能に一番適した組み合わせで開発を行うことができ、開発者も新しいテクノロジーを使うことができる。 - 疎結合なので、スケールやメンテナンスがしやすい。 - 一つのサービスは一つの小さなチームによって独立に開発されるので、素早く開発できる - コードのメンテナンス性が高くなる

しかし、次のような欠点もある。 - それぞれのサービスは分散し、非同期に動作するため、すべてのサービスを管理する中央システムが必要になる - それぞれのサービスが独自にデータベースを持っているため、トランザクション範囲が複数のサービスに及ぶ - 複数のサービスに渡ったテストが難しい - すべてのサービスは、他のサービスが処理に失敗したときのことを考えて実装されなければいけない - サービス同士のコミュニケーションにコストがかかる

Coursera Service-Oriented Architecture Week1 Service-Oriented Architecture

Service-Oriented Architecture

ソフトウェアは、外部のサービスを利用することができる。天気の情報がほしいときには、観測基地を建てるのではなく天気情報APIを使うなどである。

サービスとは、コンポーネントとは違い、外部(たいていは外部の会社のサーバーやインターネット上)に存在する。 サービスについて話すとき、サービスのリクエスタとプロバイダ、両方の役割について言及することになる。

Service-Oriented Architectureは、サービスを構築する・利用する・組み合わせるやり方についてのアーキテクチャである。

Service-Oriented Architectureでは、非機能要件がたいへん重要になってくる。なぜなら、外部のサービスについては開発者が制御できないからである。 レスポンスタイム、サポート、可用性などである。サービスの利用には利益もあるが、トレードオフとのバランスを考える必要がある。

Service Principle

使いやすいサービスの提供のためには、サービスとService-Oriented Architectureのためのベストプラクティスがある。

  • モジュールになっていること:サービスはモジュール性があり、疎結合である必要がある。これによって、サービスは可搬性があり再利用可能になる。
  • 組立可能であること:例えば、Javaで書かれたサービスをRubyで書かれたシステムから利用できるといったように、サービスは外部のシステムに組み込まれることを前提とし、外部とは決まったプロトコルでコミュニケーションできるようになっている必要がある。
  • 自己記述方式:サービスは自分自身をどう使ったらよいか説明することができる必要がある。(WSDL等を使う)

Web-System Architecture

Webシステムは、 - プレゼンテーション層(ブラウザ、Webサーバ) - アプリケーション層(アプリケーションサーバ) - データ層(データベース) の層に分かれることが多い。 静的Webページを表示する場合は、 - プレゼンテーション層(ブラウザ、Webサーバ) - データ層(HTML) の二つの層だけでも事足りることがある。

このように、レイヤードアーキテクチャーを使うことは、関心の分離やコードの再利用に役立つ。

Remote Procedure Call(RPC)

現代のシステムは、ネットワーク越しにコミュニケーションできるようになっていることが多い。 個々のシステム、例えばクライアントとサーバーは、それぞれの用途に特化した構成となっていて、お互いの詳細な実装については知らない。その二つがコミュニケーションするためには、ミドルウェアを間に挟む。ミドルウェアがコミュニケーション用のインターフェースを提供することにより、やりとりが可能になる。これはデザインパターンのMediatorパターンに似ている。

RPCは、あるマシンが別のマシン上のプログラムを実行することができるようにする仕組みである。 - 呼び出し元 - 呼び出される側 - インターフェイス定義言語 の3つの主要なコンポーネントから構成されている。

Object Brokers

Object Brokerは、分散システム上でのそれぞれのコンポーネントを接続し、オブジェクト指向のアプローチによって使用することができるようにするものである。