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 を使って同じことをやります。

なお本稿は阿部のアドバイスおよび実装実績を多分にもらっています。感謝します。

やってみた感想: 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 のワークショップ を実施してプッシュした状態のリポジトリです。

git clone git@github.com:cm-wada-yusuke/eval-cdk.git -b v0.1.0

ワークショップによって構築されるアプリケーションは、

  1. AWS CDK Stacks: エンドポイントにアクセスすると DynamoDB のカウントを増やすアプリケーション
  2. AWS CDK Stacks: DynamoDB のテーブルビューア( cdk-dynamo-table-viewer
  3. Lambda Function のソースコード

というコンポーネントで構成されています。これらは次のようにしてデプロイ・利用できます。

> 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/

hello-hits-view.png

このように、エンドポイントに対するリクエスト回数を記録し、それをブラウザで閲覧できるというサンプルです。このサンプルを修正し、Lambda Layers を使っていきます。

作業の流れ

  1. サンプルプログラムを修正して node_modules のパッケージが必要なコードにする
  2. Lambda Layers へデプロイする方法を整理する
  3. プリプロセスを定義してデプロイ

サンプル修正 & node_modules に追加

サンプルプログラムの Lambda Function は、エンドポイントにアクセスすると DynamoDB の値を更新するのでした。コードは次のようなものです。

  await dyanmo.update({
    TableName: process.env.HITS_TABLE_NAME!,
    Key: {path: event.path},
    UpdateExpression: 'ADD hits :incr',
    ExpressionAttributeValues: {':incr': 1}
  }).promise();

ここを修正します。新しく属性を用意し、更新時に

  • ランダム値をセット
  • 更新日時をセット

します。

必要なライブラリをインストールしましょう。

> npm install --save luxon uuid
> npm install --save-dev @types/luxon @types/uuid

その後、 DynamoDB 更新処理部分を修正します。

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);
};

さて、この状態でまずは何も考えずにデプロイしてみます。

> 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)

デプロイ自体はできました。これで、さきほどと同じようにカウントアップエンドポイントに対しリクエストを送ってみます。たぶんうまくいきません。

> curl https://hello-api-stack-endpoint.execute-api.ap-northeast-1.amazonaws.com/prod/
{"message": "Internal server error"}

予想どおりエラーとなりました。CloudWatch Logs を覗いてみると、やはり Import 周りでエラーになっていました。

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)"
    ]
}

ちなみに… uuidluxon を使う前から aws-sdk を使っていますが、これは Lambda Function のランタイムにデフォルトで含まれているので特に何もせずに利用できます。それ以外のライブラリは基本的にランタイムには含まれていないので、このように ImportModuleError が発生します。

Lambda Layers へデプロイする方法を整理する

そのままデプロイしたのでは、追加したライブラリを参照できないことがわかりました。そこで node_modules を Lambda Layers にデプロイしましょう。 AWS CDK では @aws-cdk/aws-lambda に Lambda Layers をデプロイする Constructs が用意されています。これを使います。

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 は、

  1. zip ファイルで S3 にアップロードされていなければならない
  2. zip ファイルを展開すると、nodejs/node_modules というフォルダ構成になっていなければならない

これらの要件を満たす必要があります。結論からいうと、AWS CDK は 1をやってくれますが、2はやってくれません。 つまり、nodejs/node_modules というディレクトリ構成は開発者側でお膳立てする必要があります。今回は プリプロセス という形でプログラムを組んでこのお膳立てをやってみます。

プリプロセスを定義してデプロイ

事前にやることは nodejs/node_modules という構成になるように package.json をインストールする ことです。lib/process/setup.ts に書いていきます。

> npm install --save-dev fs-extra @types/fs-extra

#!/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 作成時に実行されるよう修正します。

#!/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 のコードを修正します。

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です。デプロイします。

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を叩いてみます。

> 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/

updateId_updatedAt.png

カウントアップ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:

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 であれば、簡単に利用できることがわかりました。参考になれば幸いです。

ソースコード