この投稿はServerless(2)アドベントカレンダーの8日目の記事です。
去年に AWS Lambdaのデプロイフロー管理 という記事を書きましたが、あれからの一年でAWS Lambdaの状況がいろいろ変わりました。
待望の環境変数対応や、Step FunctionによるAWS Lambdaのバッチ化、Serverless Application ModelというAWS公式のデプロイツール、ServerlessやApex、LamveryなどのLambda関連のフレームワークの充実など、一年前に比べAWS Lambdaが便利になっています。
そんな状況の中、1年前の記事のビュー、ストック数が増えて行くのは申し訳ない。ということで2016年版という形で、新しい開発フローの話をしようと思います。
Serverless Application Model
SAMはAWSが新しく出したAWS Lambdaのデプロイツールです。
機能としてはCloudFormationのラッパーで、ローカル→S3にコードをアップロードし、CloudFormationでLambda Functionや他リソースの作成を行うことができます。
AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: A starter AWS Lambda function.
Parameters:
LambdaExectionRoleArn:
Type: String
Description: Role Arn of Lambda execution
Resources:
MyFunction:
Type: 'AWS::Serverless::Function'
Properties:
Handler: src/index.handler
Runtime: nodejs4.3
CodeUri: .
Description: A starter AWS Lambda function.
MemorySize: 1536
Timeout: 10
Role:
Ref: LambdaExectionRoleArn
$ aws cloudformation package --template-file template.yml --output-template-file .dist/template.yml --s3-bucket bucket_name
$ aws cloudformation deploy --template-file .dist/template.yml --stack-name stack1 --capabilities CAPABILITY_IAM
競合ツールとしてはとしてはServerless、Apex、Lamveryなどがあります。
私見ですが用途としては
- APIを作りたい → Serveless
- 複数のAWS Lambdaを管理したい → Apex
- 単独のAWS Lambdaを管理したい → Lamvery
というようにツールによってどういった時に使うと良いのか最適解が変わっている印象です。
SAMではどの競合ツールとも同様の用途のことをできますが、特化していない分若干機能が足りないところはあります。
それではSAMを選ぶメリットは? というと
- AWSの公式ツールであること
- CloudFormationと変わらないので学習コストが低い
- どのランタイム、どの用途でも同じように扱える
というところでしょうか。
逆にデメリットを上げるならCloudFormationということで中〜大規模になると辛みしか感じなくなります。そこだけは注意が必要です。
(もし、CloudFormationを親の仇のように憎んでる人がいたら止めた方が良いです)
SAMを使ったアプリケーションのデプロイフロー
SAMでデプロイフローを作るあたって注意すべき点が2点あります。
- AWS Lambdaのデプロイに必要なリソースの作成、AWS Lambdaのデプロイの2回実行が必要
- ローカル実行ができない(特にSAM部分のテストはできない)ので開発/本番の2つの環境へデプロイする仕組みが必要
それぞれについて解説していきます。
1. AWS Lambdaのデプロイに必要なリソースの作成、AWS Lambdaのデプロイの2回のデプロイが必要
SAMでAWS Lambdaのデプロイを行う場合、事前にいくつかリソースを作っておくべきものがあります。
- S3バケット
- ZIP圧縮したAWS Lambdaのコードをアップロードするために必要
- KMS Key
- 環境変数に暗号化したキーを設定するために必要(必須ではない)
- IAM Role
- AWS LambdaのExecution Role
- 作成タイミングとしてはAWS Lambdaのデプロイタイミングでも良いが、KMS Keyで許可が必要なのでKMS Keyと一緒に作る必要がある
今回はSAMに合わせてCloudFormationで実行できるようにリソースを定義します。
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
BucketName:
Type: String
Description: Bucket for uploading Lambda code
Resources:
Bucket:
Type: "AWS::S3::Bucket"
Properties:
BucketName:
Ref: BucketName
DeletionPolicy: "Delete"
LambdaExectionRole:
Type: "AWS::IAM::Role"
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
-
Effect: "Allow"
Principal:
Service:
- "lambda.amazonaws.com"
Action:
- "sts:AssumeRole"
Path: "/"
Policies:
-
PolicyName: "root"
PolicyDocument:
Version: "2012-10-17"
Statement:
-
Effect: "Allow"
Action:
- "logs:CreateLogGroup"
- "logs:CreateLogStream"
- "logs:PutLogEvents"
Resource: "arn:aws:logs:*:*:*"
-
Effect: "Allow"
Action:
- "kms:Decrypt"
Resource: "*"
LambdaExectionInstanceProfile:
Type: "AWS::IAM::InstanceProfile"
Properties:
Path: "/"
Roles:
-
Ref: "LambdaExectionRole"
Key:
Type: "AWS::KMS::Key"
Properties:
Description: "Key used for Lambda environment variables"
KeyPolicy:
Version: "2012-10-17"
Statement:
-
Sid: "Allow administration of the key"
Effect: "Allow"
Principal:
AWS:
"Fn::Join": [ ":", [ "arn:aws:sts:", {"Ref" : "AWS::AccountId"}, "root" ] ]
Action:
- "kms:*"
Resource: "*"
-
Effect: "Allow"
Principal:
AWS:
- !GetAtt LambdaExectionRole.Arn
Action:
- "kms:Decrypt"
Resource: "*"
Outputs:
KeyId:
Value: !Ref Key
LambdaExectionRoleArn:
Value: !GetAtt LambdaExectionRole.Arn
$ aws cloudformation create-stack --stack-name stack01-prepare --template-body file://resources.yml --parameters ParameterKey=BucketName,ParameterValue=bucket_name --capabilities CAPABILITY_IAM
こうすることで、事前に必要なリソースを作成することができます。
もし、事前に作成済みであればこの処理は必要ありません。
しかし、開発者が開発中にリソースを共有することはデメリットもあるので、開発者が自由にリソースの作成/破棄を行えるように定義しておくと便利です。
また、Parameter
、Outputs
の定義は次の話の兼ね合い入出力をできるように定義してあります。
2. ローカル実行ができないので開発/本番の2つの環境へデプロイをできる仕組みが必要
SAMではServerlessのようにローカルで実行することはできません。
AWS Lambdaのコード部分ならlambda-handlerなどを使えば検証は可能ですが、SAM部分の定義や、SAMで作成したリソースとの絡みの部分ではローカルで確認する術はありません(せいぜい文法チェックぐらい)。
そこで、開発用と本番用で同じ設定ファイルを使いつつ、複数回デプロイできるようにします。
今回はこの仕組みをNodeのnpm configを使って実現しています。
npm config
ではpackage.json
のconfig
に値を書くと$npm_package_config_[keyname]
で取り出しが可能になり、npm config set [package name]:[keyname]
でconfig
の値を上書きすることができます。
その特性を使ってpackage.json
のconfig
に本番用の設定を書き、本番環境へのデプロイはその値を利用します。
開発環境へのデプロイはnpm config set [package name]:[keyname]
で値を設定する仕組みを作って、本番用のデプロイ設定を上書きします。
{
"name": "EBA7CE416DD416B0E91442E9D5C72EF70B0111E365DF861D9A4B89BA7DAD1E3C",
"version": "0.0.0",
"private": true,
"author": "k-kinzal",
"description": "Example for AWS Lambda + SAM.",
"licenses": "MIT",
"engines": {
"node": "4.3.2"
},
"config": {
"resource_prefix": "",
"s3_bucket": "xxx",
"stack_name": "xxx",
"key_id": "xxxx-xxxx-xxxx-xxxx",
"iam_role_arn": "aws:arn:iam:xxx:role/xxx"
},
"scripts": {
"init:mkdir": "mkdirp .dist",
"init:prefix": "read -p \"Please input prefix of resources[]:\" p; npm config set ${npm_package_name}:resource_prefix \"${p}\"",
"init:bucket": "p=$(npm config get ${npm_package_name}:resource_prefix);read -p \"Please input bucket name[${p}eba7ce4]:\" s; npm config set ${npm_package_name}:s3_bucket \"${p}${s:-eba7ce4}\"",
"init:stack": "p=$(npm config get ${npm_package_name}:resource_prefix);read -p \"Please input stack name[${p}eba7ce4]:\" s; npm config set ${npm_package_name}:stack_name \"${p}${s:-eba7ce4}\"",
"init": "npm run init:mkdir && npm run init:prefix && npm run init:bucket && npm run init:stack",
"prepare:stack": "aws ${npm_config_profile:+--profile $npm_config_profile} ${npm_config_region:+--region $npm_config_region} cloudformation create-stack --stack-name ${npm_package_config_stack_name}-prepare --template-body file://resources.yml --parameters ParameterKey=BucketName,ParameterValue=${npm_package_config_s3_bucket} --capabilities CAPABILITY_IAM",
"prepare:wait": "aws ${npm_config_profile:+--profile $npm_config_profile} ${npm_config_region:+--region $npm_config_region} cloudformation wait stack-create-complete --stack-name ${npm_package_config_stack_name}-prepare",
"prepare:config:key-id": "npm config set ${npm_package_name}:key_id <<<EOS `aws ${npm_config_profile:+--profile $npm_config_profile} ${npm_config_region:+--region $npm_config_region} cloudformation describe-stacks --stack-name ${npm_package_config_stack_name}-prepare | jq -r '.Stacks[].Outputs | map(select(.OutputKey == \"KeyId\")) | .[].OutputValue'` EOS",
"prepare:config:iam-role-arn": "npm config set ${npm_package_name}:iam_role_arn <<<EOS `aws ${npm_config_profile:+--profile $npm_config_profile} ${npm_config_region:+--region $npm_config_region} cloudformation describe-stacks --stack-name ${npm_package_config_stack_name}-prepare | jq -r '.Stacks[].Outputs | map(select(.OutputKey == \"LambdaExectionRoleArn\")) | .[].OutputValue'` EOS",
"prepare": "npm run prepare:stack --profile=$npm_config_profile --region=$npm_config_region && npm run prepare:wait --profile=$npm_config_profile --region=$npm_config_region && npm run prepare:config:key-id --profile=$npm_config_profile --region=$npm_config_region && npm run prepare:config:iam-role-arn --profile=$npm_config_profile --region=$npm_config_region",
"encrypt": "aws ${npm_config_profile:+--profile $npm_config_profile} ${npm_config_region:+--region $npm_config_region} kms encrypt --key-id ${npm_package_config_key_id} --output text --query CiphertextBlob --plaintext",
"deploy:archive": "bestzip .dist/src.zip src/*",
"deploy:package": "aws ${npm_config_profile:+--profile $npm_config_profile} ${npm_config_region:+--region $npm_config_region} cloudformation package --template-file template.yml --output-template-file .dist/template.yml --s3-bucket ${npm_package_config_s3_bucket}",
"deploy:lambda": "aws ${npm_config_profile:+--profile $npm_config_profile} ${npm_config_region:+--region $npm_config_region} cloudformation deploy --template-file .dist/template.yml --stack-name ${npm_package_config_stack_name} --capabilities CAPABILITY_IAM --parameter-overrides LambdaExectionRoleArn=${npm_package_config_iam_role_arn}",
"deploy": "npm run deploy:archive && npm run deploy:package --profile=$npm_config_profile --region=$npm_config_region && npm run deploy:lambda --profile=$npm_config_profile --region=$npm_config_region",
"destroy": "aws ${npm_config_profile:+--profile $npm_config_profile} ${npm_config_region:+--region $npm_config_region} cloudformation delete-stack --stack-name ${npm_package_config_stack_name}",
"prepare-destroy:s3-objects": "aws s3 ${npm_config_profile:+--profile $npm_config_profile} ${npm_config_region:+--region $npm_config_region} rm s3://${npm_package_config_s3_bucket} --recursive",
"prepare-destroy:stack": "aws ${npm_config_profile:+--profile $npm_config_profile} ${npm_config_region:+--region $npm_config_region} cloudformation delete-stack --stack-name ${npm_package_config_stack_name}-prepare",
"prepare-destroy:config:iam-role-arn": "npm config delete ${npm_package_name}:iam_role_arn",
"prepare-destroy:config:key-id": "npm config delete ${npm_package_name}:key_id",
"prepare-destroy": "npm run prepare-destroy:s3-objects --profile=$npm_config_profile --region=$npm_config_region && npm run prepare-destroy:stack --profile=$npm_config_profile --region=$npm_config_region && npm run prepare-destroy:config:iam-role-arn && npm run prepare-destroy:config:key-id",
"clean:stack": "npm config delete ${npm_package_name}:resource_prefix",
"clean:bucket": "npm config delete ${npm_package_name}:s3_bucket",
"clean:prefix": "npm config delete ${npm_package_name}:stack_name",
"clean:dir": "rimraf .dist",
"clean": "npm run clean:stack && npm run clean:bucket && npm run clean:prefix && npm run clean:dir"
},
"dependencies": {},
"devDependencies": {
"bestzip": "^1.1.3",
"mkdirp": "^0.5.1",
"rimraf": "^2.5.4"
}
}
そうして出来たpackage.json
はこちらです(人間の読むものじゃない・・・)。
簡単に説明すると
-
npm run init
(開発用)- initを使ってS3バケット名や作成するスタック名をconfigに設定します
-
npm run prepare
(開発用)- prepareを使って事前に作成する必要のあるリソースを作成し、そのIDやARNをconfigに設定します
-
npm run deploy
(開発/本番用)-
package.json
のconfig
やinit、prepareで設定した値を使ってLambdaをデプロイします
-
clean
、prepare-destroy
、destroy
はそれぞれの削除用のタスクです。
encrypt
はnpm run encrypt 'secret key'
とすることで暗号化された文字列を取得することができます。
SAMを使ったアプリケーションの開発フロー
デプロイフローを作ったことで開発中でもデプロイして検証することができるようになりました。
しかし、デプロイするのにそこそこの時間がかかるため、あまり高速に検証のサイクルを回すことができません。
そこで、ローカルで高速に検証サイクルを回し、コードレベルでのミスはデプロイ前に発見できるようにします。
ESLintで文法チェック
AWS Lambdaでは2016/12現在ではv4.3.2が動いています。
しかし、Nodeの最新はv6.9.1で、4系では使えない構文がいくつも存在します。
基本的にはローカルのNodeのバージョンもnなどを使って4系で揃えるべきですが、他にNodeの開発を行っているとうっかり6系のままテストが動いてしまうということがあります。
そこで、eslintとeslint-plugin-nodeを使って4系の構文のみを使えるようにします。
$ npm install --save-dev eslint eslint-plugin-node
env:
node: true
plugins:
- node
extends:
- eslint:recommended
- plugin:node/recommended
rules:
node/no-unsupported-features: [error, {version: 4}]
no-console: 0
{
...
"engines": {
"node": "4.3.2"
},
"scripts": {
...
"lint": "eslint src/"
}
...
}
こうすることでnpm run lint
を実行するとv6系の構文を使った場合にエラーを出すことができます。
module.exports = (a = 1) => {
console.log(a);
}
$ npm run lint
> EBA7CE416DD416B0E91442E9D5C72EF70B0111E365DF861D9A4B89BA7DAD1E3C@0.0.0 lint /Users/Kinzal/Dropbox/Projects/EBA7CE416DD416B0E91442E9D5C72EF70B0111E365DF861D9A4B89BA7DAD1E3C.xyz
> eslint src/
/Users/Kinzal/Dropbox/Projects/EBA7CE416DD416B0E91442E9D5C72EF70B0111E365DF861D9A4B89BA7DAD1E3C.xyz/src/index.js
3:19 error Default Parameters are not supported yet on Node v4 node/no-unsupported-features
✖ 1 problem (1 error, 0 warnings)
Flowtypeで型チェック
先にも書いた通りSAMではローカルで実行ができないため、いかにローカルで書いたコードをチェックするかが肝になってきます。
そこで、型の力を借りてコードを静的型チェックするためにFlowを使います。
Nodeでは型を使うのに他にもAltJSでTypeScriptや、Scala.jsがありますが、なぜFlowを採用するかというとFlow Commentというトランスパイルせずに静的型チェックを行う機能があるからです。
トランスパイルしないことによって、デプロイしたコードをAWSのマネジメントコンソール上から修正することができます。
そして、その修正したコードをローカルにコピー&ペーストで持ってくることで、デプロイ頻度を下げデバッグを容易にすることができます。
もちろんマネジメントコンソールで修正できるということはローカルと差分が発生しやすくなるということなので注意は必要です。
$ npm install --save-dev flow-bin eslint-plugin-flowtype
{
...
"scripts": {
...
"check": "flow check"
}
...
}
こうすることでnpm run check
とすると静的型チェックを行えます。
/* @flow */
'use strict';
/*::
type LambdaEvent = {
key1?: ?string;
key2?: ?string;
key3?: ?string;
};
type LambdaContext = {
};
type LambdaCallback = (error: ?Error, result: ?Object) => void;
*/
exports.handler = (event/*: LambdaEvent */, context/*: LambdaContext */, callback/*: LambdaCallback */) => {
callback(null, 'success'); //-> Error
};
過去に書いた記事ですが Flowtype+Atom+Nuclideで安全にEventを扱う にあるようにAtom上でコード補完や、エラーチェックを行う方法もあります。
開発中はAtomを使ってチェックし、CI上ではnpm run check
を使ってチェックするように使い分けると開発を高速化することができます。
ユニットテストでロジックチェック
lintと静的型チェックである程度コードの品質を担保することができますが、やはり振る舞いを一度見ておきたいところです。
と、言いたいのですが、SAMで定義したKMSで暗号化済みの環境変数をどうするかという悩ましい問題があります。
AWS LambdaのEnvironment Variableで表示されるSnippetでは
const AWS = require('aws-sdk');
const encrypted = process.env['TEST'];
let decrypted;
function processEvent(event, context, callback) {
// TODO handle the event here
}
exports.handler = (event, context, callback) => {
if (decrypted) {
processEvent(event, context, callback);
} else {
// Decrypt code should run once and variables stored outside of the function
// handler so that these are decrypted once per container
const kms = new AWS.KMS();
kms.decrypt({ CiphertextBlob: new Buffer(encrypted, 'base64') }, (err, data) => {
if (err) {
console.log('Decrypt error:', err);
return callback(err);
}
decrypted = data.Plaintext.toString('ascii');
processEvent(event, context, callback);
});
}
};
という形で、handler
の実行回数によって挙動が変わります。
暗号化した環境変数が一つならまだなんとかテストを書けなくはないですが、二つ、三つと増えていくに従い複雑になるため、テストを書くのが面倒になってきます。
なんで実行時に復号してくれなかったんだ…という感じですね。
もし、暗号化された環境変数を使っていない場合は、テストのさいにSAMのYAMLを読み込んでprocess.env
に差し込めば上手くテストができます。
また、テストのさいにhandlerの呼び出すにはlambda-handlerを、AWS SDKの処理をモック化したいならfakemockを使えば簡単にユニットテストを作れるます。
(最近、lambda-handlerとfakemockを更新してないので新しいAPIは動かないかもしれません。PRお待ちしております)
このあたりのユニットテスト部分は、まだいろいろと検討段階なところがあるのでもう少し煮詰めたら新しく記事を書こうと思います。
おわりに
いかがでしたでしょうか。Node寄りの構成の話にはなってしまいましたが、要点さえ押さえれば他言語でも同様に開発サイクルを回せるようになります。
今回、使用したコードはこちらになりますので、AWS Lambdaの開発の足しにしてください。
ちょうどこのリポジトリでAWS Lambdaを使ったアプリケーションを構築中なので、最終的にどのような構成になるかは適当にウォッチしていただくと良いです。
今回の記事では煮詰めきれなかったところを詰めていくので何かしら参考になるものになると思います。