Edited at

aws-sam-cliでLambda,DynamoDBのサーバーレスアプリケーション開発に入門してみる

この記事はただの集団 Advent Calendar 2018 - Adventarの19日目です。昨日は todokr さんのScalaをGraalVMでネイティブイメージにコンパイルしてAWS Lambdaでサクサク動かすでした。


目的

本記事ではsam-cliを使用したnodejsのサーバーレスアプリケーションの開発の流れを環境構築からデプロイまで行います。

入門向けのため、例として、Lambda, API Gateway, DynamoDBを一通り使ったtodoアプリを作ってみます。

sam-cliはSAMの定義に従ったCloudFormationのコード生成や、ローカルでの動作確認用コマンドを有する便利なツールです。

サーバーレスアプリケーションの開発は愚か、DynamoDBを触ったことさえない素人の私でも簡単にアプリを構築することができましたので、ぜひ触ってみてはいかがでしょうか。

本記事の手順完了後の実装はこちらにあります。

https://github.com/umeneri/sam-todo


環境


  • MacOS High Sierra 10.13.6

  • Docker 18.06.1-ce

  • Node v8.10.0 (ローカルはndenvでlambdaのバージョンと合わせています)

  • NPM 5.6.0

  • Python 2.7.15

  • AWS CLI aws-cli 1.16.28

  • Lambda Runtime: nodejs8.10


作るもの

構成:

ApiGateway -> lambda -> dynamodb

シンプルなサーバーレス構成です。

動作:

- ApiGatewayへのgetでタスク一覧を取得

- ApiGatewayへのputでタスクを新規作成

文面の都合上、今回は2つのメソッドだけ作成します。


sam-cliの環境構築

※事前にaws-cliは使えるものとします。

sam-cliをpipでインストールします。

$ pip install aws-sam-cli


雛形アプリケーション作成

まず雛形を作ります。

sam initを行うと、簡単にサーバーレスアプリケーションの雛形を作成できます。また、デフォルトではnodejsの雛形が作成されます。

$ sam init --name sam-todo

$ cd sam-todo

雛形ができていることを確認します。 hello-worldディレクトリ内にlambdaで使用するアプリケーションコードがあります。

$ ls

README.md hello-world/ template.yaml

hello-worldの名前を変え、タスク管理用のソースを作ります。

$ mv hello-world task


ローカルでのlambdaアプリケーション作成

task/app.jsを下記コードに変更します。lambdaHandlerがlambdaで実行する関数です。

exports.lambdaHandler = async (event, context) => {

try {
switch (event.httpMethod) {
case "GET": {
const tasks = [
{
id: 1,
name: 'task1'
},
{
id: 2,
name: 'task2'
}
];
return {
"statusCode": 200,
"body": JSON.stringify(tasks)
};
}
case "PUT": {
return {
"statusCode": 200,
"body": JSON.stringify({})
};
}
default:
return {
"statusCode": 501
};
}
} catch (err) {
console.log(err);
return err;
}
};

eventにはAPI Gatewayからのリクエストが入る想定なので、リクエストのhttpMethodがGET, PUT, それ以外で場合分けしてレスポンスを変えています。

template.ymlは下記の様に修正します。元のファイルと違い、DynamoDBへのアクセス権限をつけたり、ApiGatewayのメソッドを2種類用意しています。

AWSTemplateFormatVersion: '2010-09-09'

Transform: AWS::Serverless-2016-10-31
Description: sam-task
Globals:
Function:
Timeout: 3

Resources:
# lambdaの関数
TaskFunction:
Type: AWS::Serverless::Function
Properties:
# 関数内で実行するコードのパス
CodeUri: task/
Handler: app.lambdaHandler
Runtime: nodejs8.10
Policies: AmazonDynamoDBFullAccess
# Lambda内環境変数
Environment:
Variables:
PARAM1: VALUE
Events:
# API Gatewayでアクセスする際のパスとメソッド
TaskList:
Type: Api
Properties:
Path: /task
Method: get


lambdaの動作確認

では、以下で動作確認用のリクエストデータを作成します。samはこのようなデータも自動生成できて大変便利ですね。

$ sam local generate-event apigateway aws-proxy --method GET > task/data/get_event.json

動作確認します。sam local invokeでtemplate.ymlで定義したTaskFunctionを実行します。

-eで先程生成したgetのリクエストを入れたときの動作を確認します。

最後の行にタスク一覧のjsonが確認できればOKです。

[git][* master]:~/dev/infra/sam-todo/ $ sam local invoke TaskFunction -e task/data/get_event.json

2018-12-16 11:19:37 Found credentials in shared credentials file: ~/.aws/credentials
2018-12-16 11:19:37 Invoking app.lambdaHandler (nodejs8.10)

Fetching lambci/lambda:nodejs8.10 Docker container image......
2018-12-16 11:19:41 Mounting /Users/koishi/dev/infra/sam-todo/task as /var/task:ro inside runtime container
START RequestId: 03df4092-e556-143e-8038-187fd187896b Version: $LATEST
END RequestId: 03df4092-e556-143e-8038-187fd187896b
REPORT RequestId: 03df4092-e556-143e-8038-187fd187896b Duration: 7.60 ms Billed Duration: 100 ms Memory Size: 128 MB Max Memory Used: 30 MB

{"statusCode":200,"body":"[{\"id\":1,\"name\":\"task1\"},{\"id\":2,\"name\":\"task2\"}]"}

PUTイベントも同じように動作確認します。{"statusCode":200,"body":"{}"}が出力されればOKです。

$ sam local generate-event apigateway aws-proxy --method PUT > task/data/put_event.json

$ sam local invoke TaskFunction -e task/data/put_event.json


DynamoDBを使用する

それではDynamoDBも加え、タスクを永続的に保存できるようにします。


DynamoDBの準備

まず、ローカルでDynamoDBを使うためにDynamoDB Localをインストールします。

今後の運用を考えdocker-compose.ymlに記述します。

version: '3'

services:
dynamodb:
image: amazon/dynamodb-local
container_name: dynamodb
ports:
- 8000:8000

DynamoDBを起動しておきます。

$  docker-compose up -d

DynamoDBへのアクセスを確認します。

$ curl http://localhost:8000

以下が出力されます。少なくとも動いてはいることが確認できました。

{"__type":"com.amazonaws.dynamodb.v20120810#MissingAuthenticationToken","message":"Request must contain either a valid (registered) AWS access key ID or X.509 certificate."}%

localのLambdaがDynamoDBにアクセスするためには、DynamoDBのエンドポイントを知る必要があります。

今回はエンドポイントを環境ごとに分けたいので、環境変数にエンドポイントを設定することにします。

まずtemplate.ymlのEnvironmentでDynamoDBのエンドポイントを指定します。空でもまずは大丈夫です。

Environment:

Variables:
DYNAMODB_ENDPOINT: ""

続いて、task/env.jsonを作成します。こちらは環境ごとに環境変数を切り替えるためのファイルです。今回はローカルのDynamoDBのエンドポイントのみ環境変数として指定します。

{

"TaskFunction": {
"DYNAMODB_ENDPOINT": "http://192.168.100.185:8000"
}
}

注意です。エンドポイントをlocalhostにすると、後述しますsam local start-apiでDynamoDBへアクセス出来ませんでした。そこでローカルのIP192.168.100.185をifconfig等で取得し入力しています。


テーブル定義作成

以下のスキーマを用意し、DynamoDBにタスク管理用テーブルを作成します。

{

"AttributeDefinitions": [
{
"AttributeName": "Id",
"AttributeType": "N"
}
],
"TableName": "Task",
"KeySchema": [
{
"AttributeName": "Id",
"KeyType": "HASH"
}
]
}

各タスクはIdを持ち、Idに紐づくHashにタスク名等を保存する形式です。

では、下記でテーブルを作成します。

$ aws dynamodb create-table --cli-input-json file://task/schema/task.json --endpoint-url http://127.0.0.1:8000

長かったですが、ようやくDynamoDBの準備ができました。


アプリ修正

テーブルができたところで、アプリからテーブルにアクセスできるよう修正します。

以下の様にDynamoDBを扱うためのクラスとして、task/dynamodb-client.jsを作成します。

今後行数が増え複雑化することを考えると、lambdaのコードとファイルを分離させておいたほうが全体の見通しがよく再利用性も上がります。

const AWS = require('aws-sdk')

class DynamoDBClient {
constructor(tableName) {
const endpoint = process.env.DYNAMODB_ENDPOINT;
const config = endpoint !== "" ? { endpoint } : { region: 'ap-northeast-1' };

this.documentClient = new AWS.DynamoDB.DocumentClient(config);
this.tableName = tableName;
}

scan() {
return this.documentClient.scan({ TableName: this.tableName }).promise();
}

put(itemParams) {
const dbParams = {
TableName: this.tableName,
Item: itemParams,
}

return this.documentClient.put(dbParams).promise();
}
}

exports.DynamoDBClient = DynamoDBClient;

DyamoDBClientのメソッドはscanとputの2つです。それぞれタスク一覧、新規作成に対応します。

次にapp.jsをDynamoDBClientを使用するように修正します。

const DynamoDB = require('./dynamodb-client')

const dbClient = new DynamoDB.DynamoDBClient('Task');

exports.lambdaHandler = async (event, context) => {
try {
switch (event.httpMethod) {
case "GET": {
const dbOutput = await dbClient.scan();

return {
"statusCode": 200,
"body": JSON.stringify(dbOutput)
};
}
case "PUT": {
const body = JSON.parse(event.body);
const dbOutput = await dbClient.put(body);

return {
"statusCode": 200,
"body": JSON.stringify(dbOutput)
};
}
default:
return {
"statusCode": 501
};
}
} catch (err) {
console.log(err);
return err;
}
};

put_event.jsonのbodyを下記のように修正します。json形式で新しいタスクのIdとNameが入ってくる想定ですね。

  "body": "{ \"Id\": 1, \"Name\": \"task1\"}",

それでは、DynamoDBも含めてローカルで動作確認してみましょう。lambdaの実行は、先程と同じくsam local invokeです。

はじめにputで作成したタスクをgetで出力する手順で、正しくタスクができていることを確認します。

タスクの作成:

$ sam local invoke TaskFunction -e task/data/put_event.json --env-vars task/env.json

タスクの一覧表示:

$ sam local invoke TaskFunction -e task/data/get_event.json --env-vars task/env.json


API Gatewayからの動作確認

では、API Gatewayも含めて動作確認をします。sam local start-apiを行うと、http://127.0.0.1:3000にApi Gatewayのサーバーが立ち上がるので、今度はcurlでリクエストできるようになります。

$ sam local start-api --env-vars task/env.json

別ターミナルを開き、http://127.0.0.1:3000をcurlで叩いてみましょう。

taskの作成:

$ curl -XPUT http://127.0.0.1:3000/task -d '{"Id": 2, "Name": "task2" }'

taskの一覧表示:

curl http://127.0.0.1:3000/task

PUTでタスクを作成し、一覧表示でタスクができていることが確認できればOKです!

これでローカルの開発は完了です。


本番デプロイ

本番デプロイし、最後の動作確認をしましょう。


DynamoDBをtemplateに追加

まず本番でDynamoDBを生成するためにtemplate内にDynamoDBの定義を追加します。

Resouces:の配下に以下を追加します。

    TaskDynamoDBTable:

Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: Id
AttributeType: N
TableName: Task
KeySchema:
- AttributeName: Id
KeyType: HASH

DynamoDBのBillingModeは従量課金制にしています。開発中で使用頻度が小さいのであれば従来のProvisiondの場合より料金が安くすみます。

TypeはAWS::Serverless::SimpleTableとするのがSAMの流儀なのでしょうが、こちらだと従量課金設定がtemplate上でできませんでした。


デプロイする

さて、準備が整ったので、いよいよデプロイです。

まずlambdaのソースをzip化したものを置くために、S3バケットを作成します。

$ aws s3 mb s3://sam-todo-backet

sam packageでzipをS3のバケットにアップロードします。また、--output-template-fileで指定したpackaged.ymlに整形されたCloudFormationのテンプレートファイルが作られます。

$ sam package --template-file template.yaml --s3-bucket sam-todo-backet --output-template-file packaged.yaml

次に、sam deployでデプロイを行い、AWS上にリソースを作成できればOKです。sam-todo-stackがCloud Formationのstack名です。

$ sam deploy --template-file packaged.yaml --stack-name sam-todo-stack --capabilities CAPABILITY_IAM


本番の動作確認する

API Gatewayができているので、そのURIにアクセスして動作確認します。

URIとリソースができているかの確認もしたいので、AWSのコンソールのLambdaの関数TaskFunctionに行きます。



画像のように、LambdaとAPI Gatewayが紐付いていますね。

APIGateWayのパネルをクリックするとURIが表示されます。

URIに対して下記のようにリクエストしてみます。

タスクの作成(PUT):

curl -XPUT <API Gateway URL> -d '{"Id": 1, "Name": "task1" }'

タスク一覧(GET):

 curl <API Gateway URL>

GETして以下のようにタスクが作成できていればOKです! お疲れ様でした。

{"Items":[{"Id":1,"Name":"task1"}],"Count":1,"ScannedCount":1}%