AWS CDK を使って node_modules を AWS Lambda Layers にデプロイするサンプル
AWS CDK のワークショップ などで サンプルアプリケーションを組んでいたのですが、より実践的な Lambda Function で node_modules
を利用することになります。そこで AWS CDK を使って Lambda Layers に node_modules
をデプロイし、それを Lambda Function から使ってみました。
本題の前に…なんで Lambda Layers を使うの?
前提を確認しておきましょう。Lambda Function から node_modules
のパッケージを使う方法は、大きく2つあります。
- Lambda Function のデプロイパッケージに
node_modules
も含める node_modules
を Lambda Layers にデプロイし、そこを参照する
Lambda Layers を使うモチベーションとしては、ソフトウェアアーキテクチャ上の話で、単純に手元で開発する状態と一致しているからわかりやすい点があります。つまり node_modules
のパッケージ群は参照されるものであり、Lambda Function はパッケージを参照し変動するものであるという関係のことです。Lambda Layers を使えば、実行環境でも同じ状態を維持できるので開発者としてはうれしいですね。このあたりは BlackBelt でも言及があるので確認してみてください。
さて、 Lambda Layers 自体は AWS CDK がGAとなる前から存在するもので、共有コードを Lambda Layers へ デプロイするサンプルは AWS SAM や Serverless Framework を使った例がすでにあります。今回は AWS CDK を使って同じことをやります。
- Serverless Frameworkのserverless-layersプラグインを使って超お手軽にnode_modulesをAWS Lambda Layers化する | DevelopersIO
- AWS Lambda ( Typescript ) の Lambda Layers 活用、開発、デプロイ考察 | DevelopersIO
なお本稿は阿部のアドバイスおよび実装実績を多分にもらっています。感謝します。
やってみた感想: AWS CDK で Lambda Layers を使ってみてどうだったか
node_modules
をデプロイ可能で、活用する価値があるが、課題がある という結論です。課題とは次のようなものです。
- デプロイサイクルの違いを考慮して LayerStack と LambdaFunctionStack というように Stacks を分けたいが、CloudFormation の Export/ImportValue の仕様により不可能
- Lambda Layers の仕様を考慮したプリプロセスが必要で、それは現状開発者がやるしかない
ただし、この課題はどちらかというと Lambda Layers と CloudFormation のしくみに起因するものです。将来的に AWS CDK が隙間を埋めてくれるとうれしいな、と思っています。
環境
用途 | 利用ツール | バージョン |
---|---|---|
AWSリソースデプロイ | AWS CDK | 1.14.0 |
Lambda Function ランタイム | Node.js | 10.x |
AWS CDK 実装言語 | TypeScript | 3.6.4 |
Lambda Function 実装言語 | TypeScript | 3.6.4 |
Lambda Function からAWSサービス利用 | aws-sdk-js | 2.555.0 |
Lambda Layers へデプロイする流れ
Lambda Layers へ node_modules
をデプロイするにあたり、ベースとなるプロジェクトをcloneしておきます。これは私が自分の手元で AWS CDK のワークショップ を実施してプッシュした状態のリポジトリです。
1 | git clone git@github.com:cm-wada-yusuke /eval-cdk .git -b v0.1.0 |
ワークショップによって構築されるアプリケーションは、
- AWS CDK Stacks: エンドポイントにアクセスすると DynamoDB のカウントを増やすアプリケーション
- AWS CDK Stacks: DynamoDB のテーブルビューア( cdk-dynamo-table-viewer )
- Lambda Function のソースコード
というコンポーネントで構成されています。これらは次のようにしてデプロイ・利用できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | > cd /path/to/eval-cdk > cdk ls --context env =stg HelloApiStack HitCounterViewerStack > npm run build eval -cdk@0.1.0 build /Users/wada .yusuke/.ghq /github .com /cm-wada-yusuke/eval-cdk tsc > cdk deploy HelloApiStack HitCounterViewerStack --context env =stg --profile your-deploy-target-profile ...deploy console output... > curl https: //hello-api-stack-endpoint .execute-api.ap-northeast-1.amazonaws.com /prod/ Hello, CDK! You've hit / > open https: //hit-counter-viewer-stack-endpoint .execute-api.ap-northeast-1.amazonaws.com /prod/ |
このように、エンドポイントに対するリクエスト回数を記録し、それをブラウザで閲覧できるというサンプルです。このサンプルを修正し、Lambda Layers を使っていきます。
作業の流れ
- サンプルプログラムを修正して
node_modules
のパッケージが必要なコードにする - Lambda Layers へデプロイする方法を整理する
- プリプロセスを定義してデプロイ
サンプル修正 & node_modules
に追加
サンプルプログラムの Lambda Function は、エンドポイントにアクセスすると DynamoDB の値を更新するのでした。コードは次のようなものです。
1 2 3 4 5 6 | await dyanmo . update ( { TableName : process . env . HITS_TABLE_NAME ! , Key : { path : event . path } , UpdateExpression : 'ADD hits :incr' , ExpressionAttributeValues : { ':incr' : 1 } } ) . promise ( ) ; |
ここを修正します。新しく属性を用意し、更新時に
- ランダム値をセット
- 更新日時をセット
します。
必要なライブラリをインストールしましょう。
1 2 | > npm install --save luxon uuid > npm install --save-dev @types /luxon @types /uuid |
その後、 DynamoDB 更新処理部分を修正します。
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 | import { DynamoDB , Lambda } from 'aws-sdk' ; import * as Console from 'console' ; import * as uuid from 'uuid' ; import * as luxon from 'luxon' ; export const handler = async ( event : any ) = > { Console . log ( 'request:' , JSON . stringify ( event , undefined , 2 ) ) ; const dyanmo = new DynamoDB . DocumentClient ( ) ; const lambda = new Lambda ( ) ; await dyanmo . update ( { TableName : process . env . HITS_TABLE_NAME ! , Key : { path : event . path } , UpdateExpression : 'ADD hits :incr SET updateId = :updateId, updatedAt = :updatedAt' , ExpressionAttributeValues : { ':incr' : 1 , ':updateId' : uuid . v4 ( ) , ':updatedAt' : luxon . DateTime . utc ( ) . toMillis ( ) } } ) . promise ( ) ; // call downstream function and capture response const resp = await lambda . invoke ( { FunctionName : process . env . DOWNSTREAM_FUNCTION_NAME ! , Payload : JSON . stringify ( event ) } ) . promise ( ) ; Console . log ( 'downstream response:' , JSON . stringify ( resp , undefined , 2 ) ) ; return JSON . parse ( resp . Payload as string ) ; } ; |
さて、この状態でまずは何も考えずにデプロイしてみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | > npm run build eval -cdk@0.1.0 build /Users/wada .yusuke/.ghq /github .com /cm-wada-yusuke/eval-cdk tsc > cdk deploy HelloApiStack HitCounterViewerStack --context env =stg HelloApiStack HelloApiStack: deploying... Updated: asset.3d931757608189f37665c353f1ca02801fc18b8aafae59b1790c418ecf8b97e5 (zip) HelloApiStack: creating CloudFormation changeset... 0 /10 | 5:34:18 PM | UPDATE_IN_PROGRESS | AWS::Lambda::Function | HelloHandler (HelloHandler2E4FBA4D) 1 /10 | 5:34:19 PM | UPDATE_COMPLETE | AWS::Lambda::Function | HelloHandler (HelloHandler2E4FBA4D) HelloApiStack HitCounterViewerStack (no changes) |
デプロイ自体はできました。これで、さきほどと同じようにカウントアップエンドポイントに対しリクエストを送ってみます。たぶんうまくいきません。
1 2 | > curl https: //hello-api-stack-endpoint .execute-api.ap-northeast-1.amazonaws.com /prod/ { "message" : "Internal server error" } |
予想どおりエラーとなりました。CloudWatch Logs を覗いてみると、やはり Import 周りでエラーになっていました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | 2019-10-25T08:36:00.894Z undefined ERROR Uncaught Exception { "errorType": "Runtime.ImportModuleError", "errorMessage": "Error: Cannot find module 'uuid'", "stack": [ "Runtime.ImportModuleError: Error: Cannot find module 'uuid'", " at _loadUserApp (/var/runtime/UserFunction.js:100:13)", " at Object.module.exports.load (/var/runtime/UserFunction.js:140:17)", " at Object.<anonymous> (/var/runtime/index.js:45:30)", " at Module._compile (internal/modules/cjs/loader.js:778:30)", " at Object.Module._extensions..js (internal/modules/cjs/loader.js:789:10)", " at Module.load (internal/modules/cjs/loader.js:653:32)", " at tryModuleLoad (internal/modules/cjs/loader.js:593:12)", " at Function.Module._load (internal/modules/cjs/loader.js:585:3)", " at Function.Module.runMain (internal/modules/cjs/loader.js:831:12)", " at startup (internal/bootstrap/node.js:283:19)" ] } |
ちなみに… uuid
と luxon
を使う前から aws-sdk
を使っていますが、これは Lambda Function のランタイムにデフォルトで含まれているので特に何もせずに利用できます。それ以外のライブラリは基本的にランタイムには含まれていないので、このように ImportModuleError が発生します。
Lambda Layers へデプロイする方法を整理する
そのままデプロイしたのでは、追加したライブラリを参照できないことがわかりました。そこで node_modules
を Lambda Layers にデプロイしましょう。 AWS CDK では @aws-cdk/aws-lambda
に Lambda Layers をデプロイする Constructs が用意されています。これを使います。
1 2 3 4 5 6 | const nodeModulesLayer = new lambda . LayerVersion ( this , 'NodeModulesLayer' , { code : lambda . AssetCode . fromAsset ( '????' ) , compatibleRuntimes : [ lambda . Runtime . NODEJS_10_X ] } ) ; |
ここで code
として node_modules
を含めたいわけですが、いったん Lambda Layers の仕様について整理します。
ライブラリをレイヤーに含めるには、ランタイムでサポートされているいずれかのフォルダにそれらを配置します。
Node.js – nodejs/node_modules、nodejs/node8/node_modules (NODE_PATH)
ということで、 Lambda Layers にデプロイする node_modules
は、
- zip ファイルで S3 にアップロードされていなければならない
- zip ファイルを展開すると、
nodejs/node_modules
というフォルダ構成になっていなければならない
これらの要件を満たす必要があります。結論からいうと、AWS CDK は 1をやってくれますが、2はやってくれません。 つまり、nodejs/node_modules
というディレクトリ構成は開発者側でお膳立てする必要があります。今回は プリプロセス という形でプログラムを組んでこのお膳立てをやってみます。
プリプロセスを定義してデプロイ
事前にやることは nodejs/node_modules という構成になるように package.json
をインストールする ことです。lib/process/setup.ts
に書いていきます。
1 | > npm install --save-dev fs-extra @types /fs-extra |
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 | # ! / usr / bin / env node import * as childProcess from 'child_process' ; import * as fs from 'fs-extra' ; export const NODE_LAMBDA_LAYER_DIR = `$ { process . cwd ( ) } / bundle` ; export const NODE_LAMBDA_LAYER_RUNTIME_DIR_NAME = `nodejs` ; export const bundleNpm = ( ) = > { // create bundle directory copyPackageJson ( ) ; // install package.json (production) childProcess . execSync ( `npm - - prefix $ { getModulesInstallDirName ( ) } install - - production` , { stdio : [ 'ignore' , 'inherit' , 'inherit' ] , env : { . . . process . env } , shell : 'bash' } ) ; } ; const copyPackageJson = ( ) = > { // copy package.json and package.lock.json fs . mkdirsSync ( getModulesInstallDirName ( ) ) ; [ 'package.json' , 'package-lock.json' ] . map ( file = > fs . copyFileSync ( `$ { process . cwd ( ) } / $ { file } ` , `$ { getModulesInstallDirName ( ) } / $ { file } ` ) ) ; } ; const getModulesInstallDirName = ( ) : string = > { return `$ { NODE_LAMBDA_LAYER_DIR } / $ { NODE_LAMBDA_LAYER_RUNTIME_DIR_NAME } ` ; } ; |
copyPackageJson()
で バンドル用のディレクトリを作り、そこでnodejs/node_modules
を構成してpackage.json
をコピーbundleNpm()
で--production
をインストール
次に、このプリプロセスが CDK Apps 作成時に実行されるよう修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 | # ! / usr / bin / env node import cdk = require ( '@aws-cdk/core' ) ; import { HitCounterApiStack } from '../lib/hit-counter-api-stack' ; import { ViewCounterTableWebStack } from '../lib/view-counter-table-web-stack' ; import { bundleNpm } from '../lib/process/setup' ; // pre-process bundleNpm ( ) ; // create app const app = new cdk . App ( ) ; const hitCounter = new HitCounterApiStack ( app , 'HelloApiStack' ) ; new ViewCounterTableWebStack ( app , 'HitCounterViewerStack' , { counterTable : hitCounter . counterTable } ) ; |
さいごに、Lambda Layers が bundle
を参照するように AWS CDK のコードを修正します。
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 | import * as cdk from '@aws-cdk/core' ; import * as lambda from '@aws-cdk/aws-lambda' ; import * as dynamodb from '@aws-cdk/aws-dynamodb' ; import { NODE_LAMBDA_LAYER_DIR } from '../process/setup' ; import { RemovalPolicy } from '@aws-cdk/core' ; export interface HitCounterProps { downStream : lambda . IFunction ; } export class HitCounter extends cdk . Construct { public readonly handler : lambda . Function ; public readonly table : dynamodb . Table ; constructor ( scope : cdk . Construct , id : string , props : HitCounterProps ) { super ( scope , id ) ; const nodeModulesLayer = new lambda . LayerVersion ( this , 'NodeModulesLayer' , { code : lambda . AssetCode . fromAsset ( NODE_LAMBDA_LAYER_DIR ) , compatibleRuntimes : [ lambda . Runtime . NODEJS_10_X ] } ) ; const table = new dynamodb . Table ( this , 'Hits' , { partitionKey : { name : 'path' , type : dynamodb . AttributeType . STRING } , removalPolicy : RemovalPolicy . DESTROY } ) ; this . table = table ; this . handler = new lambda . Function ( this , 'HitCounterHandler' , { runtime : lambda . Runtime . NODEJS_10_X , handler : 'hitcounter.handler' , code : lambda . Code . fromAsset ( 'src/lambda' ) , layers : [ nodeModulesLayer ] , environment : { DOWNSTREAM_FUNCTION_NAME : props . downStream . functionName , HITS_TABLE_NAME : table . tableName } } ) ; table . grantReadWriteData ( this . handler ) ; props . downStream . grantInvoke ( this . handler ) ; } } |
準備OKです。デプロイします。
1 2 3 4 5 6 7 8 9 10 11 12 13 | cdk deploy HelloApiStack HitCounterViewerStack --context env =stg npm WARN eval -cdk@0.1.0 No description npm WARN eval -cdk@0.1.0 No repository field. npm WARN eval -cdk@0.1.0 No license field. added 20 packages from 67 contributors and audited 1756767 packages in 13.054s found 0 vulnerabilities HelloApiStack HelloApiStack: deploying... HelloApiStack: creating CloudFormation changeset... ...(後略) |
コンソールを見るに、どうやらデプロイ処理が走る前に 無事 npm インストール処理が走っているようです。APIを叩いてみます。
1 2 3 4 | > curl https: //hello-api-stack-endpoint .execute-api.ap-northeast-1.amazonaws.com /prod/ Hello, CDK! You've hit / > open https: //hit-counter-viewer-stack-endpoint .execute-api.ap-northeast-1.amazonaws.com /prod/ |
カウントアップAPIが実行でき、DynamoDB に uuid と タイムスタンプ が記録されました。つまり、Lambda Layers へ node_modules
がデプロイされ、 Lambda Function から Lambda Layers を参照できているということになります。目的達成です。
課題と改善点
課題1: Lambda Layers と クロススタック参照の相性が悪い
今回は Lambda Function と同一 Stacks に Lambda Layers を定義し、デプロイしました。ですが、今後 Stacks が増え、node_modules
をもつ Lambda Layers をいろいろな Stacks から参照したいというシーンが出てくると想像できます。この状況に対処するためすぐ思い付くのは、Lambda Layers と Lambda Function の Stacks を別々にする ことです。が、単純にやったのではうまくいきません。なぜならば、AWS CDK における Stacks 間の値渡しは CloudFormation の世界でいう スタック間の Export / ImportValue に相当します。Export / ImportValue の仕様上、参照されている側、Exportしている側は、Export値を更新するような Update Stack は実行できません。この仕様が Lambda Layers と相性が悪く、というのは Lambda Layers が ARN にバージョンを含むため、node_moduels
を更新した場合は Lambda Layers のARNも更新されます。しかし、古いバージョンのARNが Lambda Function から参照されていると、Export / ImportValue の仕様にひっかかるため Lambda Layers を更新できないといった具合です。
ただこの話は AWS CDK だからどうこうという話ではなく、CloudFormation / AWS SAM でも同じ話があります。実際岩田がブログで議論しています。
課題2: Lambda Layers のためにプリプロセスが必要
前処理が必要なこと自体はよのですが、AWS CDK として前処理・後処理をどのように考えているかを示してくれるとうれしいと感じています。
- AWS CDK の責務ではない。現場で解決するべき
- AWS CDK の責務ではないが、前処理と後処理は必ず発生するだろうから、cdkの各コマンドの前後でhookできるようにしてあげる
- 将来的にはソースコードアセットの整形も AWS CDK の責務になる
改善点1: Export / ImportValue よりも疎結合な Parameter Store を利用した連携方法があるとよい
Issue:
- Lambda Layer redeployment fails · Issue #1972 · aws/aws-cdk
- Loosely couple stacks with a SSM parameter wrapper · Issue #4014 · aws/aws-cdk
Issueも Lambda Layers を別の Stacks にしようとしたことがきっかけみたいですね。Paramter Store で Stacks どうしを疎結合にできると、Lambda Layers の ARN だけではなくいろいろな使いどころがありそうです。
改善点2: デプロイに対して Lambda Function Hook できるようにしたい
Issue:
Issue は Lambda Function を利用した hook ですが、ローカル環境でもアセットを準備やビルドの手順を統括できるとありがたいです。
おわりに
AWS CDK を利用して Lambda Layers をデプロイしてみました。引き続きクロススタック参照での課題が残りますが、 Issue にもあったような案を使って解決できないか試みていきます。まずは、Lambda Function と同一 Stacks であれば、簡単に利用できることがわかりました。参考になれば幸いです。