SAM(Serverless Application Model) + Node + Flowtype でAWS Lambdaの開発サイクルを作る

  • 31
    いいね
  • 0
    コメント

この投稿は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点あります。

  1. AWS Lambdaのデプロイに必要なリソースの作成、AWS Lambdaのデプロイの2回実行が必要
  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で実行できるようにリソースを定義します。

resources.yml
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

こうすることで、事前に必要なリソースを作成することができます。

もし、事前に作成済みであればこの処理は必要ありません。
しかし、開発者が開発中にリソースを共有することはデメリットもあるので、開発者が自由にリソースの作成/破棄を行えるように定義しておくと便利です。

また、ParameterOutputsの定義は次の話の兼ね合い入出力をできるように定義してあります。

2. ローカル実行ができないので開発/本番の2つの環境へデプロイをできる仕組みが必要

SAMではServerlessのようにローカルで実行することはできません。
AWS Lambdaのコード部分ならlambda-handlerなどを使えば検証は可能ですが、SAM部分の定義や、SAMで作成したリソースとの絡みの部分ではローカルで確認する術はありません(せいぜい文法チェックぐらい)。
そこで、開発用と本番用で同じ設定ファイルを使いつつ、複数回デプロイできるようにします。

今回はこの仕組みをNodeのnpm configを使って実現しています。
npm configではpackage.jsonconfigに値を書くと$npm_package_config_[keyname]で取り出しが可能になり、npm config set [package name]:[keyname]configの値を上書きすることができます。

その特性を使ってpackage.jsonconfigに本番用の設定を書き、本番環境へのデプロイはその値を利用します。
開発環境へのデプロイはnpm config set [package name]:[keyname]で値を設定する仕組みを作って、本番用のデプロイ設定を上書きします。

package.json
{
  "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.jsonconfigやinit、prepareで設定した値を使ってLambdaをデプロイします

cleanprepare-destroydestroyはそれぞれの削除用のタスクです。
encryptnpm run encrypt 'secret key'とすることで暗号化された文字列を取得することができます。

SAMを使ったアプリケーションの開発フロー

デプロイフローを作ったことで開発中でもデプロイして検証することができるようになりました。
しかし、デプロイするのにそこそこの時間がかかるため、あまり高速に検証のサイクルを回すことができません。
そこで、ローカルで高速に検証サイクルを回し、コードレベルでのミスはデプロイ前に発見できるようにします。

ESLintで文法チェック

AWS Lambdaでは2016/12現在ではv4.3.2が動いています。
しかし、Nodeの最新はv6.9.1で、4系では使えない構文がいくつも存在します。
基本的にはローカルのNodeのバージョンもnなどを使って4系で揃えるべきですが、他にNodeの開発を行っているとうっかり6系のままテストが動いてしまうということがあります。
そこで、eslinteslint-plugin-nodeを使って4系の構文のみを使えるようにします。

$ npm install --save-dev eslint eslint-plugin-node
eslintrc.yml
env:
  node: true
plugins:
  - node
extends:
  - eslint:recommended
  - plugin:node/recommended
rules:
  node/no-unsupported-features: [error, {version: 4}]
  no-console: 0
package.json
{

  ...

  "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
package.json
{

  ...

  "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を使ったアプリケーションを構築中なので、最終的にどのような構成になるかは適当にウォッチしていただくと良いです。
今回の記事では煮詰めきれなかったところを詰めていくので何かしら参考になるものになると思います。

この投稿は Serverless(2) Advent Calendar 20168日目の記事です。