API Gateway の WebSocket でチャットを実装 w/Serverless Framework

この記事は Serverless Advent Calendar 2018 の 22日目になります。

AWS re:Invent で発表された API Gateway の WebSocket 対応、ついに利用できるようになりました!
WebSocket がサーバーレスで簡単に利用できるようになるとアプリの幅も広がり、いろいろなことができるようになります。
さっそく API Gateway の WebSocket を試してみます。

※ 今回 Serverless Framework の serverless-websockets-plugin を使いますが 2018年12月現在、暫定の実装になるとのことです。API Gateway の WebSocket 対応が AWS CloudFormation で未サポートのため Serverless Framework 本体には含まれず Plugin になっているとのこと。正式版では構文など変わる可能性があることに注意が必要です。

しかしながら “With all that out of the way, play with our new presents!” ということで、楽しんでみましょう!

環境
本記事の開発環境は以下となります。

  • Windows 10 64bit + WSL Ubuntu 18.04.1 LTS
  • Visual Studio Code
  • Node.js 8.10.0
  • Yarn 1.12.3
  • TypeScript 3.2.2
  • Serverless Framework 1.35.1
  • Serverless Websockets Plugin 1.0.0

参考情報

WebSocket について

HTTP ベースの Web API ではサーバーに対して接続、リクエストを送り、処理結果のレスポンスを受け取り、切断終了の流れになります。
必要に応じて、このリクエスト/レスポンスのやり取りを繰り返す形になります。

それに対して WebSocket はサーバーに接続後、コネクションを維持したまま双方向でメッセージを送ることができます。
これは通常のリクエスト/レスポンスに当たるやり取りのほかに、サーバー側から任意のタイミングでメッセージを送れます。

たとえば私たちがハッカソンで作った「ラップ、タップ、アップ 🎶」では、演奏者へのフィードバックとして「👍」を送ることができ、その数をリアルタイムでカウントする機能があります。

この「👍」をリアルタイムでカウントする部分を HTTP ベースの Web API で作るとすると、アプリから「👍」の数を取得する API を繰り返し「リクエスト/レスポンス」して受け取る必要が出てきます。
仮に数に変化がなかったとしてもアプリ側は知るすべがないので、繰り返し「👍」の数を取得しに行かなければなりません。

WebSocket が使える場合、コネクションは維持したまま必要な時に「👍」の数をサーバー側がアプリへメッセージを送ることができるようになります。
「👍」の数に変化がなかった場合は維持されているコネクションの中にメッセージが流れないだけでアプリはとくに何もする必要はありません。変化があった時だけサーバーが教えてくれるので、それに応じてアプリの表示を変えるだけになります。

これにより不要なリクエスト/レスポンスを減らせるほか、よりリアルタイムに近い状態で変化を受け取ることができるようになります。
HTTP ベースでは「リクエスト/レスポンス」で受け取り、次のリクエストを出すまでの間にあった変化は結果しか受け取れません。5だった「👍」を次のリクエスト/レスポンスでは 10になっているかもしれません。

WebSocket では状態の変化を断続的に返すことも可能になるので、5、6、7… とメッセージを返せます。

これにより、よりスムーズに描画を変えていくことができるようになります。
これはチャットのようなコミュニケーションや金融などの取引の価格情報などに使うことができます。

今回 API Gateway がこの WebSocket に対応したので、その使い方をチャットを実装することで確認します。
(WebSocket でチャットは定番すぎますが、「👍」のカウントだとさみしいですからね)

※「ラップ、タップ、アップ 🎶」では、API Gateway がまだ WebSocket が扱えなかったので、 Firebase Realtime Database を使って実現しています。

Node.js プロジェクトの準備

Serverless Framework のボイラープレートを使って、TypeScript の Node.js プロジェクトを作成します。

プロジェクトのディレクトリを作成しプロジェクトのひな型を作ります。

1
2
3
4
5
6
7
8
9
username@pc:~$ mkdir samples-apigateway-websocket-chat
username@pc:~$ cd samples-apigateway-websocket-chat
username@pc:~/samples-apigateway-websocket-chat$

username@pc:~/samples-apigateway-websocket-chat$ npx serverless create --template aws-nodejs-typescript
Serverless: Generating boilerplate...
(省略)
Serverless: Successfully generated boilerplate for template: "aws-nodejs-typescript"
Serverless: NOTE: Please update the "service" property in serverless.yml with your service name

続いて Serverless Framework をローカルで追加し、AWS SDK と WebSocket クライアント wscat、必要なパッケージを最新化してインストールします。
Serverless Framework はグローバルに追加して使うようですが、複数人開発でグローバルのを使っているとバージョンの違いなどでトラブルがあったので package.json で明示して、それを使うようにしているためです。サクッと試すにはグローバルでもよいでしょう。

1
2
3
username@pc:~/samples-apigateway-websocket-chat$ yarn add -D serverless serverless-websockets-plugin wscat
username@pc:~/samples-apigateway-websocket-chat$ yarn upgrade --latest
username@pc:~/samples-apigateway-websocket-chat$ yarn add aws-sdk

package.json をプロジェクトに合わせて修正します。
主に name description author あたりを合わせるとよいでしょう。
また、作成しているのはアプリケーションなのでモジュール公開の防止のために "private": true を設定しておくとよいでしょう。private の詳細は、こちらの package.json | npm Documentation をご確認ください。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"name": "samples-apigateway-websocket-chat",
"version": "1.0.0",
"private": true,
"dependencies": {
"aws-sdk": "^2.382.0"
},
"devDependencies": {
"@types/aws-lambda": "8.10.17",
"@types/node": "^10.12.18",
"serverless": "^1.35.1",
"serverless-webpack": "^5.1.1",
"serverless-websockets-plugin": "^1.0.0",
"source-map-support": "^0.5.6",
"ts-loader": "^5.3.2",
"typescript": "^3.2.2",
"webpack": "^4.5.0",
"wscat": "^2.2.1"
},
"author": "Riotz.works (https://riotz.works)",
"license": "MIT"
}

Serverless Framework の設定

serverless.yml を編集し Serverless Framework の設定を行います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
service:
name: samples-apigateway-websocket-chat

plugins:
- serverless-webpack
- serverless-websockets-plugin

provider:
name: aws
stage: ${ opt:stage, 'dev' }
region: ${ opt:region, 'us-west-2' }
runtime: nodejs8.10
iamRoleStatements:
- Effect: Allow
Action:
- execute-api:ManageConnections
Resource:
- arn:aws:execute-api:*:*:**/@connections/*
- Effect: Allow
Action:
- dynamodb:Scan
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
Resource:
- Fn::GetAtt: [ ChatConnectionsTable, Arn ]
environment:
DYNAMODB_CONNECTIONS:
Ref: ChatConnectionsTable
websocketApiName: ${self:service.name}-${self:provider.stage}
websocketApiRouteSelectionExpression: $request.body.action

functions:
connect:
handler: handler.connect
events:
- websocket:
routeKey: $connect
disconnect:
handler: handler.disconnect
events:
- websocket:
routeKey: $disconnect
defaultMessage:
handler: handler.defaultMessage
events:
- websocket:
routeKey: $default
sendMessage:
handler: handler.sendMessage
events:
- websocket:
routeKey: sendMessage

resources:
Resources:
ChatConnectionsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:service.name}-connections-${self:provider.stage}
AttributeDefinitions:
- AttributeName: ConnectionId
AttributeType: S
KeySchema:
- AttributeName: ConnectionId
KeyType: HASH
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
SSESpecification:
SSEEnabled: True
StreamSpecification:
StreamViewType: NEW_AND_OLD_IMAGES

plugins に API Gateway WebSocket を使うための serverless-websockets-plugin を追加します。

iamRoleStatements では、API Gateway の WebSocket API を呼び出すための execute-api:ManageConnections に許可を与えます。
また WebSocket に接続しているクライアントのコネクションを管理するための DynamoDB が必要なので、DynamoDB に関する許可も与えます。

30行目、31行目の websocketApiNamewebsocketApiRouteSelectionExpression は、serverless-websockets-plugin の設定になります。websocketApiName は管理用にわかりやすい名前を設定しておくとよいでしょう。

functions は WebSocket のイベントとアクションに合わせて定義を行います。
eventswebsocket が追加でき、 websocketApiRouteSelectionExpression に設定された Key の値と routeKey の文字列のマッチングによって起動する Lambda を振り分けています。
$connect $disconnect は WebSocket の接続と切断に対応します。また $default はマッチするアクションがない場合に呼び出されます。
最後の sendMessage が WebSocket で受け取るメッセージのキーになります。具体的には { "action":"sendMessage", "data":"hello world" } のような JSON の action に入る文字列のマッチングになります。

resources は WebSocket のコネクション管理用 DynamoDB を定義しています。
今回は最低限の ConnectionId だけを管理します。名前など無しの完全に匿名のメッセージだけのチャットになります。(WebSocket の動きと実装を試すってことで💦)

AWS Lambda の実装

handler.ts に処理を実装します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import { APIGatewayEventRequestContext, APIGatewayProxyEvent, APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda';
import { ApiGatewayManagementApi, DynamoDB } from 'aws-sdk';

interface APIGatewayProxyEventWithWebSocket extends APIGatewayProxyEvent {
requestContext: APIGatewayEventRequestContextWithWebSocket
}
interface APIGatewayEventRequestContextWithWebSocket extends APIGatewayEventRequestContext {
domainName: string,
connectionId: string
}


const apigateway = ({ domainName, stage }): ApiGatewayManagementApi => new ApiGatewayManagementApi({ endpoint: `${domainName}/${stage}` });
const dynamodb = new DynamoDB.DocumentClient();

export const connect: APIGatewayProxyHandler = async (event: APIGatewayProxyEventWithWebSocket): Promise<Result> => {
console.debug('Starting Lambda handler: event=%s', JSON.stringify(event));
await dynamodb.put({ TableName: process.env.DYNAMODB_CONNECTIONS, Item: { ConnectionId: event.requestContext.connectionId }}).promise();
return new Result('Connected');
};

export const disconnect: APIGatewayProxyHandler = async (event: APIGatewayProxyEventWithWebSocket): Promise<Result> => {
console.debug('Starting Lambda handler: event=%s', JSON.stringify(event));
await dynamodb.delete({ TableName: process.env.DYNAMODB_CONNECTIONS, Key: { ConnectionId: event.requestContext.connectionId }}).promise();
return new Result('Disconnected');
};

export const defaultMessage: APIGatewayProxyHandler = async (event: APIGatewayProxyEventWithWebSocket): Promise<Result> => {
console.debug('Starting Lambda handler: event=%s', JSON.stringify(event));
const params: ApiGatewayManagementApi.Types.PostToConnectionRequest = {
ConnectionId: event.requestContext.connectionId,
Data: 'Error: Invalid action type'
};
await apigateway(event.requestContext).postToConnection(params).promise();
return new Result('Error: Invalid action type', 500);
}

export const sendMessage: APIGatewayProxyHandler = async (event: APIGatewayProxyEventWithWebSocket, context, callback): Promise<Result> => {
console.debug('Starting Lambda handler: event=%s', JSON.stringify(event));
const connections = await dynamodb.scan({ TableName: process.env.DYNAMODB_CONNECTIONS, ProjectionExpression: 'ConnectionId' }).promise();
await Promise.all(connections.Items.map(async ({ ConnectionId }) => {
try {
const params: ApiGatewayManagementApi.Types.PostToConnectionRequest = {
ConnectionId: ConnectionId,
Data: JSON.parse(event.body).data
};
await apigateway(event.requestContext).postToConnection(params).promise();
} catch (e) {
if (e.statusCode === 410) {
await dynamodb.delete({ TableName: process.env.DYNAMODB_CONNECTIONS, Key: { ConnectionId: ConnectionId }}).promise();
} else {
throw e;
}
}
}));
return new Result('OK');
}


class Result implements APIGatewayProxyResult {
public constructor(public body: string, public statusCode: number = 200) {}
}

4行目~10行目の interface 宣言ですが、こちらは API Gateway の WebSocket 対応の TypeScript 型定義がまだ入っていないので補完するために宣言しています。 requestContext に入ってくる domainNameconnectionId が必要なため追加しています。型定義が更新されたら不要となります。

13行目 ApiGatewayManagementApi のインスタンスを取得する関数ですが、API Gateway から WebSocket のメッセージを返す際に endpoint が必要となり、これが domainNamestage から決まってきます。固定値の場合はよいのですが、今回のように動的に決まってくる場合は requestContext から受け取った domainNamestage が必要なため毎回インスタンスを生成しています。

16行目~58行目で serverless.yml に定義した Lambda のエントリーポイントと実装があります。

connectdisconnect は WebSocket の「接続/切断」の際に呼び出されます。
接続時に connectionId を DynamoDB へ保存し、切断時に削除しています。切断は呼び出されないこともあるので、注意が必要です。

defaultMessage は WebSocket で受け取ったメッセージに対応するアクションがなかった場合に呼び出されます。ここでは “Error: Invalid action type” をクライアントに返しています。
WebSocket でメッセージを返すには ApiGatewayManagementApipostToConnection() を呼び出します。その際にクライアントの connectionId が必要となります。
この関数のようにメッセージを送ってきたクライアントだけにメッセージを送る場合は requestContextconnectionId が使えるので、それを使ってエラーメッセージを返します。

最後の sendMessage は、チャットのコメントを送る処理になります。リアルタイムに複数クライアントへメッセージを送る WebSocket を使う処理の肝となる部分です。
複数クライアントにメッセージを返すには、メッセージを送るべきクライアントすべての connectionId が必要となり、それぞれに ApiGatewayManagementApi#postToConnection() を行う必要があります。
つまり API Gateway が接続中のクライアントを知ってくれてるわけではないので、自前で管理する必要があり、そのために DynamoDB を用意しているものになります。

今回は全員参加の簡単な実装なので、DynamoDB のコネクション管理テーブルをスキャンし、全件に対して ApiGatewayManagementApi#postToConnection() を呼び出しています。(なんか、もっとスマートな実装できないかなぁ)

ApiGatewayManagementApi#postToConnection() を呼び出す際に、コネクションが切断されているクライアントがありえます。先ほど「切断は呼び出されないこともあるので、注意が必要です。」と書きました通り、クライアントが切断されてもイベントが呼び出されないケースもあります。そのため DynamoDB への削除されず connectionId が残るケースもありえます。その場合 statusCode410 のエラーが投げられるので、そのエラーが投げられた場合は DynamoDB から該当する connectionId を削除しておき再発防止しておきます。

デプロイ & 実行!

デプロイは Serverless Framework がしっかりやってくれるので、以下のコマンドで行えます。
あらかじめ AWS の Access Key の用意とプロファイルを設定しておきます。(デフォルト・プロファイルでない場合は --aws-profile [your profile name] と利用するプロファイル名を --aws-profile で指定します)

1
2
3
4
5
6
username@pc:~/samples-apigateway-websocket-chat$ yarn serverless deploy
(省略)
Serverless: Deploying Websockets API named "samples-apigateway-websocket-chat-dev"...
Serverless: Websockets API named "samples-apigateway-websocket-chat-dev" with ID "6ovkt8XX" has been deployed.
Serverless: Websocket URL: wss://6ovkt8XX.execute-api.us-west-2.amazonaws.com/dev/
Done in 137.90s.

デプロイが完了すると、コマンドの実行結果に WebSocket の URL が出力されます。
その URL に対して wscat で接続し API Gateway WebSocket の動きを確認します。

1
2
3
username@pc:~/samples-apigateway-websocket-chat$ yarn wscat -c wss://6ovkt8XX.execute-api.us-west-2.amazonaws.com/dev/
connected (press CTRL+C to quit)
>

接続できると > で入力待ちになります。 { "action":"sendMessage", "data":"hello world" } とメッセージを送ると、チャットのコメントが帰ってきます。

1
2
3
4
5
username@pc:~/samples-apigateway-websocket-chat$ yarn wscat -c wss://6ovkt8XX.execute-api.us-west-2.amazonaws.com/dev/
connected (press CTRL+C to quit)
> { "action":"sendMessage", "data":"hello world" }
< hello world
>

複数のコンソールから接続すると、送ったコメントがすべてのコンソールに流れてきます。
WebSocket でリアルタイムに複数クライアントへメッセージが遅れていることが確認できます。

チャットとは名ばかりのコメントだけを送りあうだけの実装(しかも wscat によるコマンドライン)でしたが、API Gateway WebSocket の実装がつかめました。

Serverless Framework は今後の AWS CloudFormation 対応によって変わってくる部分がありますが、こんなに簡単に設定できるのでとても助かります。

WebSocket が簡単に利用できるようになったので、いろいろなアプリづくりに生かせそうで楽しみです。


では、ハッピー・サーバーレス・ライフ! ハッピー・ホリデー!!

Serverless Framework と TypeScript で Hello & Version の Web API を実装

前回サーバーレスなアプリケーションのひな形を作りました。
今回は、ひな形で作られた AWS Lambda のソースを Hello World にし、また新しくアプリケーションのバージョンを返す Version Web API を追加します。

シリーズの記事

環境
本記事の開発環境は以下となります。

  • Windows 10 64bit
  • Visual Studio Code
  • Node.js 9
  • TypeScript 2.7
  • Serverless Framework 1.26.0

主な実行環境について、また全部稼働まではいきませんが以下を想定しています。
※ Amazon API Gateway などは、Serverless Framework が自動設定するので省略、明示的に使うものとしてリストになります。

  • Amazon S3
  • AWS Lambda

ベースとなるソースコード

前回の続きとなりますので、こちら https://github.com/riotz-works/samples-hello-serverless/tree/0.0.1 をもとに追加開発します。

本記事と一緒に作成する場合は前回のソースもしくはタグを指定してチェックアウトしたソースをお使いください。

1
2
3
c:\Temp\nlog>git clone --depth 1 -b 0.0.1 https://github.com/riotz-works/samples-hello-serverless.git
Cloning into 'samples-hello-serverless'...
(省略)

Hello Web API の改修

Serverless Framework のボイラープレートで生成した Lambda のプログラムは handler.js で以下のようになっています。
これを Hello World の Web API へ改修するにあたり、このままで responsebody を変えるだけでよさそうです。今回は message'Hello Serverless !!' にし、input は削除することにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda';

export const hello: Handler = (event: APIGatewayEvent, context: Context, cb: Callback) => {
const response = {
statusCode: 200,
body: JSON.stringify({
message: 'Go Serverless Webpack (Typescript) v1.0! Your function executed successfully!',
input: event,
}),
};

cb(null, response);
}

そしてプロジェクトのディレクトリ直下で汎用的な名前の handler.js だと、このあと追加する Version Web API の配置などで困ります。
まずはディレクトリ配置を見直し、Lambda のハンドラー・ファンクションのソースが入っていることがわかるように src/handler 配下に置おきます。
続いて Hello Web API のハンドラーに対応したファンクションのファイル名として greetings.ts とすることにします。

ファイル名と配置を変えたので Lambda の定義をしている serverless.yml を修正します。
hello ファンクションのハンドラーは handler.hello と定義されていましたが、greetings ファクション src/handler/greetings.hello ハンドラーに変更します。

Hello Web API ができました。
この時点で serverless deploy コマンドを使いデプロイできます。(–aws-profile [profilename] を忘れずに)

Version Web API の作成

Hello Web API が作れましたがボイラープレートのままだったので、新しく Web API を追加します。
とりあえず、アプリケーションのバージョンを返す Version Web API を作ることにします。
仕様は「HTTP GET /versionpackage.jsonversion を返す」にします。

まずは、Lambda のハンドラーのソースを配置する src/handlersystems.ts を作ります。
他にシステム関連の Web API の想定はありませんが、システム関連 Web API のハンドラーを配置する systems.tsversion のハンドラーを追加するイメージにしました。

コードは以下のようにします。(このコードを使う場合は、1行目の Copyright は書き換えてください)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* Copyright 2018 Riotz.works. */
import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda';
import { version } from '../../package.json';

/**
* System Web API's AWS Lambda handler function.
*
* @param event – event data.
* @param context – runtime information of the Lambda function that is executing.
* @param callback – optional callback to return information to the caller, otherwise return value is null.
* @see http://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-handler.html
*/
export const handle: Handler = (event: APIGatewayEvent, context: Context, cb?: Callback): void => {

const response = {
'body': JSON.stringify({
'version': version
}),
'statusCode': 200
};

if (cb !== undefined) {
cb(null, response); // tslint:disable-line
} else {
context.succeed(response);
}
};

主なキー・ポイントは以下になります。 先の Hello Web API と若干異なる部分 (context.succeed() とか) がありますが詳細は後日の記事として、基本的には変わりありません。

  • 3行目で、バージョンのもとになる package.jsonsystems.ts の相対パスで指定し version 要素をインポートします
  • 17行目で、インポートした version 属性の値をレスポンス・ボディとしてセットします

Visual Studio Code の環境によってはエラーが出ないかもしれませんが、3行目のインポート文でエラーにあります。

何も表示されていなくても serverless package を実行するとエラーが出力されます。

1
2
3
4
5
C:\Temp\samples-hello-serverless> serverless package
Serverless: Bundling with Webpack...
(省略)
ERROR in C:\Develop\repos\riotz\samples-hello-serverless\src\handler\systems.ts
(3,25): error TS2307: Cannot find module '../../package.json'.

これは package.json のモジュール定義が存在しないためになります。
このように型定義などを確認してくれるのが TypeScript のありがたいところです。
一方で今回のは若干無茶な実装でした。標準モジュールには存在しませんし、適切なモジュール定義をとってくることも難しいでしょう。さりとて package.json と何か別ファイルで version のダブルメンテはしたくないです。。。 ということで、モジュール定義を追加することにします。
ソースディレクトリの srctypes.d.ts を追加します。.d.ts ファイルでしたらとくに命名規則はなく typing.d.ts もよく見かけます。

コードは以下のようにします。(1行目の Copyright は書き換えてください)

1
2
3
4
5
6
7
8
9
10
/* Copyright 2018 Riotz.works. */

/**
* Module declaration for JSON file.
*/
declare module '*.json' {

/** version element of package.json using Version API. */
const version: string | undefined;
}

*.json なので、モジュール定義が見つからなかった JSON ファイルの型定義になります。
その中に version が文字列として存在することを定義しますが、これはすべてての JSON ファイルに当てはまることではないので、型定義は string | undefined としドキュメントで注釈しました。
頑張ると package.json 固有のモジュール定義もできたのですが version を取得するだけにしては複雑になりすぎたので、このあたりを落としどころとしました。

最後に serverless.yml へ Version Web API の Lambda 定義を追加します。
functions: greetings: の下に追加します。

1
2
3
4
5
6
systems:
handler: src/handler/systems.handle
events:
- http:
method: get
path: version

Version Web API ができました。

デプロイ

AWS 環境へデプロイします。前回の デプロイ と 稼働テスト 同様に、AWS のプロファイを参照して serverless deploy --aws-profile [profilename] を実行します。

endpointshelloversion の URL が出力されるので、それぞれブラウザでアクセスすると、プログラムした JSON が返ってきます。

ソースコード

今回作成した部分までのソースを GitHub へアップしました。
https://github.com/riotz-works/samples-hello-serverless/tree/0.0.2


Hello Web API はボイラープレートのレスポンスを改修しただけだったのでとくに大きな変化はありませんでした。一方で Version Web API は新しく追加となりましたし、両方のプログラムを配置するためにプロジェクトの構造も変化させました。

Hello と Version で実装レベルが異なっている部分はありますが、これについては次の記事で確認しながら合わせこみをしていきます。できれば TypeScript のコンパイラ設定や Lint にまでふれたいです。

Serverless Framework と TypeScript でサーバレス開発事始め

前回の記事から時間が空いてしまいましたが、ちょうど新しいプロダクトの開発を始めるところなので Serverless FrameworkTypeScript で開発する際の事始めについてまとめます。
例によって Hello World になりますが、こんな感じで作り始めをお伝えします。

シリーズの記事

環境
本記事の開発環境は以下となります。

  • Windows 10 64bit
  • Visual Studio Code
  • Node.js 9
  • TypeScript 2.7
  • Serverless Framework 1.26.0

主な実行環境について、今回は準備なので全部稼働まではいきませんが以下を想定しています。
※ Amazon API Gateway などは、Serverless Framework が自動設定するので省略、明示的に使うものとしてリストになります。

  • Amazon S3
  • AWS Lambda

想定アプリケーション

ウェブサイトで、アクセス時に JavaScript で Web API を呼び出し “Hello World” の挨拶を取得します。 まずは動作させるところを目標にシンプルな形で進めます。

ウェブサイトは Amazon S3 に HTML を配置し静的ウェブサイトのホスティングを使います。
本格稼働するには Amazon CloudFrontAmazon Route 53 などが必要となりますが、まずは稼働を目指し S3 だけで進めます。

Web API はサーバーレスで作りたいので AWS Lambda を使います。
インスタンスやコンテナーの管理が不要で、純粋にアプリケーションの開発やメンテに集中できるのサーバーレスにこだわりたいです。
こちらも本格稼働にあたっては Amazon API Gateway にカスタム・ドメインを割り当てたりしますが、まずは稼働を目指し自動割り当てで進めます。

開発環境の構築

コーディングにためのエディターを用意します。こちらは手慣れたもので大丈夫です。今回は Visual Studio Code を使いました。
こちら https://code.visualstudio.com から取得できます。

続いて Node.js をインストールします。こちら https://nodejs.org からダウンロードしてインストールします。今回は 9.5.0 Current をインストールしました。

プロジェクトの作成

Node.js がインストールできたら、プロジェクトのディレクトリを作成します。
今回は C:\Temp\hello-serverless をプロジェクトのディレクトリとしました。

続いて Serverless Framework を 導入します。今回は Visual Studio Code の ターミナルを使っていますが、コマンド プロンプト からも同様のコマンドになります。
今回はプロジェクトごとに Serverless Framework を 導入したいので、-g なしで npm install serverless としました。

1
2
3
4
C:\Temp\hello-serverless> npm install serverless
(省略)
+ serverless@1.26.0
added 302 packages in 44.148s

Serverless のモジュールが導入され、node_modulespackage-lock.json が作られています。

Serverless の AWS/TypeScript ボイラープレートからプロジェクトのひな形を作ります。
※ 今回はプロジェクト・ローカルにServerless Framework を導入しているため node_modules\.bin\serverless コマンドになります。node_modules\.bin にパスを通しておくか、めんどくさい場合は npm install -g serverless-g をつけてグローバルに入れてしまうのも手です。

1
2
3
4
C:\Temp\hello-serverless> node_modules\.bin\serverless create --template aws-nodejs-typescript
(省略)
Serverless: Successfully generated boilerplate for template: "aws-nodejs-typescript"
Serverless: NOTE: Please update the "service" property in serverless.yml with your service name

Node.js/npm プロジェクト関連のファイルが追加されます。

package.jsondevDependencies に Serverless Framework が入っていないので、もう一度 --save-dev を付けて npm install --save-dev serverless を実行します。
※ 最初から --save-dev しないのは package.json が作られているとボイラープレートが展開できないためです。

他のモジュールもインストールするため、npm install を実行します。

プロジェクトの設定

自動生成された設定が入っているので、プロジェクトに合わせた設定を行います。

package.json は、最小限 name description author あたりを変更しておきます。
また、作成しているのはアプリケーションなのでモジュール公開の防止のために "private": true を設定しておくとよいでしょう。private の詳細はこちら package.json | npm Documentation をご確認ください。

serverless.yml は、service: name: の値を変更しておきます。

tsconfig.jsonlibesnext を追加します。
2018年2月現在、これを追加しておかないとビルド時に参照しているモジュールで以下のようなエラーが発生するためです。

1
2
3
4
5
6
7
C:\Temp\hello-serverless> node_modules\.bin\serverless package

ERROR in C:\Temp\hello-serverless\node_modules\@types\graphql\subscription\subscribe.d.ts
(17,4): error TS2304: Cannot find name 'AsyncIterator'.

ERROR in C:\Temp\hello-serverless\node_modules\@types\graphql\subscription\subscribe.d.ts
(29,4): error TS2304: Cannot find name 'AsyncIterable'.

プロジェクトのビルド

serverless package コマンドでビルドします。

1
2
3
4
5
6
7
8
9
C:\Temp\hello-serverless> node_modules\.bin\serverless package
Serverless: Bundling with Webpack...
ts-loader: Using typescript@2.6.2 and C:\Temp\hello-serverless\tsconfig.json
Time: 2085ms
Asset Size Chunks Chunk Names
handler.js 2.94 kB 0 [emitted] handler
handler.js.map 3.18 kB 0 [emitted] handler
[0] ./handler.ts 350 bytes {0} [built]
Serverless: Packaging service...

TypeScript がコンパイルされ、JavaScript とソースマップが作られ、.serverless ディレクトリに AWS CloudFormation のテンプレートと Lambda のデプロイ Zip が作られます。

デプロイと稼働テスト

ビルドに成功したら serverless deploy コマンドで AWS 環境へデプロイします。
デプロイにあたっては AWS IAM ユーザー のアクセス・キーが必要となります。あらかじめ作成アクセス・キーを取得し、%USERPROFILE%\.aws\credentials (e.g. C:\Users\username\.aws\credentials) に記述しておきます。
IAM のアクセス権は PowerUserAccess では足りず IAM 周りの権限追加が必要です。まだ絞り込みきれておらず結局 AdministratorAccess を使ってしまっています。頑張らないと。。。

1
2
3
4
[hello]
region = us-west-2
aws_access_key_id = ZDEJREC4D1GM3DXXXX
aws_secret_access_key = ce4FH4Dcyh5DGh2dDGG4hDk6DYfhDAjrFhXXXX

デプロイを実行します。
serverless deploy --aws-profile hello--aws-profilecredentials[] で指定したプロファイル名を指定します。

1
2
3
4
5
6
C:\Temp\hello-serverless> node_modules\.bin\serverless deploy --aws-profile hello
(省略)
endpoints:
GET - https://ca3kg0hrXX.execute-api.us-east-1.amazonaws.com/dev/hello
functions:
hello: hello-serverless-dev-hello

無事にデプロイできると自動生成された API Gateway のエンドポイントが出力されます。

ブラウザでアクセスすると、handler.ts の実装の通り「Go Serverless webpack (TypeScript) v1.0! Your function executed successfully!」のメッセージと HTTP リクエストの内容が表示されます。

お掃除

serverless remove で AWS 環境をクリーンアップできます。

1
2
3
4
5
6
7
C:\Temp\hello-serverless> node_modules\.bin\serverless remove --aws-profile hello
Serverless: Getting all objects in S3 bucket...
Serverless: Removing objects in S3 bucket...
Serverless: Removing Stack...
Serverless: Checking Stack removal progress...
..........
Serverless: Stack removal finished...

ソースコード

今回作成した部分までのソースをGitHub へアップしました。
https://github.com/riotz-works/samples-hello-serverless/tree/0.0.1


初期設定に多少の作業がありましたが、Serverless Framework を使うことで簡単にデプロイとクリーンアップができました。

アプリケーションとしては Lambda でメッセージを返すレベルではありますが、インフラ面を考えると、これをしべて AWS マネジメントコンソールではかなりの手間がかかります。それを Serverless Framework が補ってくれるので、サーバレス開発のインフラ運用が削減できるメリットに加えて、インフラ構築も格段と楽になるのではないでしょうか。

また Try & Error を繰り返すうちに AWS 環境がついつい散かってしまい、よくわからないリソースを放置されるケースが出てきたりします。開発環境とはいえ Serverless Framework で環境を作るようにすると片付けも簡単ですね。

引き続き、このプロジェクトに設定やコードを加えてもう少し発展させていきます。

CSV ファイルのバッチ連携も AWS Lambda でサーバーレス

この記事は Serverless Advent Calendar 2017 の 7日目になります。

Serverlessconf Tokyo 2017 で発表させていただく機会をいただき、 Java チームが選択した TypeScript による AWS Lambda 開発 のタイトルでお話をさせていただきました。

今回はそのシステムの裏手側、CSV ファイルを連携するバッチをサーバーレスで実現したアーキテクチャについてご紹介します。

システム全体の概略図

ざっくりと下図のようなシステムで AWS 上に作られています。
スマートフォンなどから IoT 機器を操作するようなクラウド・サービスがあり、そのバックグラウンド・プラットフォームになります。認証や機器のデータ管理を行うシステムです。
このシステムは、図の Amazon API GatewayAWS Lambda が、ペアになっているアイコン毎が1つ1つのマイクロサービスとして作られています。つまり 10を超えるマイクロ・サービスの集合体になります!

今回の話は、この図の右上部分 “Traditional DC”、企業の伝統的なデータセンターからデータを受ける部分です。
この部分は伝統とシキタリにのっとり CSV ファイルを受け取り、処理する仕組みが必要となります。

※ CSV の C は Comma と信じてましたが、Character や Column 説 (e.g. Wikipedia) があり広義には Comma に限らず特定の文字でデータを区切るファイルらしい。どうでもいいけど。。。

なぜ AWS Lambda ?

このようなシステムを AWS で実装するには、AWS BatchAWS Glue などを使います。しかしながら、今回は Lambda で実装することにしました。

背景としては、以下のような点があります。

  • 少人数の DevOps チームであり、AWS のマネージド度合いが高いほど嬉しかった
  • データ量や処理量から想定してコスパが優れていた
  • 1回に連携するデータ量が Lambda の処理時間におさまる可能性が持てた

とくに2番目のコストについては、Glue に対して圧倒的によかったのが大きかったです。今回の処理は ETL に近いとはいえ、DWH 連携ではなく、DB のマスター・データ登録だったので Glue を活かしきれない点がありました。

なお Lambda の実行ランタイムは Java になります。
(Serverlessconf Tokyo 2017 の 発表資料 の通り、Java チームなので)

CSV ファイル連携のアーキテクチャ概要図


“Traditional DC” からは、Amazon S3 に CSV を置いてもらうことにします。FTP/SFTP とのリクエストがありましたが、サーバーレス厨とは言わずとも、ここは S3 へのアップロードへお願いしたいところ、何としてでも承諾いただきます。(いただきました)

S3 からは Amazon SNS へイベント通知をし、Lambda を起動します。その Lambda で CSV をパースして DynamoDB へデータを登録します。

複雑なデータの加工や外部の API/データを参照するような場合は、DynamoDB Streams から Lambda を呼び出して後続の処理として実装します。このようにすることで、データの登録に特化させて処理数を稼ぐことができ、また DynamoDB Streams の後続処理は、登録されたデータ1件ごとの処理になるので時間的な余裕が生まれます。

ここでのポイントは S3 ⇒ SNS からの Lambda は DynamoDB への登録だけに特化し、他の処理をしないことです。このようにすることで、なるべく CSV ファイルの対応に処理を振り分けるようにし、時間内で処理できる量を増やすようにしています。

処理能力とアーキテクチャの改良

このような形にすることで、CSV を Lambda で処理できるようになります。
このアーキテクチャの処理能力は、CSV ファイル 1行のデータ量や、カラム数によって変わってくる部分がありますが、今回のケースでは 1ファイルで約 800行のレコードが処理できました!
て、まったく処理できてません。。。

これでは役に立たないので対応が必要となります。
Lambda は設定するメモリの使用量によって処理速度が変わるという話もあり、最大に設定してみましたが設定による差はとくにありませんでした。ファイルを読み込みながらの順次処理なので、ここでの処理性能はあまり変わらないのでしょう。また DynamoDB へアクセスするオーバーヘッドも処理性能では大きく変わらないはずです。

では、どうするのか。


S3 に置かれたファイルを DynamoDB へ登録する前に、ファイル分割する処理をはさみます。
1回の Lambda で DynamoDB へ登録できるのは余裕を見て 500件までとして、CSV ファイルを 500行ごとのファイルに分割して S3 へ再アップロードします。その 500行ごとのファイルを Lambda で DynamoDB へ登録する形にしました。

これにより、30,000行まで処理できるようになり現実的なラインまで持っていけるようになりました。S3 からの Lambda に変わりはないので、サーバーレスの形態も維持できます。

まとめ

サーバーレスでシステムを作ろうとしても、往々にして発生してしまう既存システムとのバッチ連携。そのために FTP/SFTP やら、CSV パースするための処理やらとどうしてもサーバ・インスタンスが欲しくなってしまい、せっかくのサーバーレスがサーバー有り(サーバーレスの反対語って何だろう)になってしまいます。

Glue に見合う処理であればよいのですが、今回のように小規模なデータ登録となるとオーバースペック気味にもなります。そんな時には S3 からの Lambda で処理することで、サーバーレスでバッチ連携をするのも手ではないでしょうか。

ポイントとしては以下になります。

  • ファイルは S3 に置いてもらう
  • S3 ⇒ SNS から Lambda を起動し DynamoDB へデータ登録に特化する
  • 1回の Lambda で処理できるデータ量は少ない、必要に応じてファイル分割の Lambda をはさむ

Serverless Advent Calendar 2017 、楽しく、そして勉強になる記事がたくさんです。

昨日6日目は @RyoheiMorimotoさんの AWS LambdaをTest Runnerとして使ってみる でした。 ユーザ管理のシステムをサーバーレスで 34歳タローさんがハナコさんになれるかをテストしています。サーバーレス・テスト、おもしろいですね!

明日8日目は @narikeiさんです。何の記事か楽しみです!


では、ハッピー・サーバーレス・ライフ! ハッピー・ホリデー!!

Hello World !!

こんにちは、世界!
こんにちは、みなさん!!

わたしたちは Riotz.works です。

“a cheerful engineering team” を標榜する、わたしたちは
IT を中心に技術的なことやさまざまな挑戦が大好きで陽気で愉快なエンジニアの集団です。

これまで学んできたこと今挑戦していること、先々やってみたいことなど、をおもしろおかしく、まるでお祭り騒ぎのようにアウトプットしていきたい、そんな思いから『Articles | Riotz.works』の場を作りました。

またエンジニアにとっての最初の挨拶は、何といっても “Hello World !” ですね。
ということで最初のポストのタイトルも “Hello World !!” にしました。

この記念すべき Hello World はプログラム言語でいうと、どんな感じだろうかと「Hello worldプログラムの一覧 - Wikipedia」を眺めていたら、これかな!

1
import __hello__

Python のコードで、”プログラマーに必要な機能をすべて標準ライブラリーとして提供する「バッテリー同梱 (batteries included)」の思想を具現化するジョーク - Wikipedia」なのだとか。

さすがに「すべて」とまではいかないですが、エンジニアにとって必要なものがたくさんアウトプットされている場にしていきたいと考えています。

わたしたちのアウトプットがわずかでもお役に立てられれば、勉強させていただいているコミュニティへ少しでも還元できれば、そして新たな出会いと挑戦につながればと考えております。

どうぞ、よろしくお願いいたします!!

共有: