概要
AWS CDKを使ってLambda FunctionとLayerを構築する。
やりたいこととしては、
- FunctionのソースはTypeScriptで書く
- Functionにはnpmモジュールを含めない
- Layerはnpmモジュールのみ含める
- 開発中は
node_modules
を参照/解決できるようにする - 事前準備のためのスクリプトを用意したくない
- 設定ファイルでパスをゴニョゴニョしたくない
- AWS CDK標準の仕組みを使う(FunctionとLayerへのバンドリングの話)
やってないこと/考慮してないことは、
- Layerにユーザー定義のモジュールを含める
-
sam local invoke
で動作すること -
bash
の動かない環境でのローカルバンドリング
ファイル構成
最終的にこのようなファイル構成になった(主要なファイルのみ)。
project/package.json
の devDependencies
には型定義やら開発時に必要なものが色々入っている。後述の NodejsFunction
がローカルでbundling出来るように esbuild
をインストールしておくこと。
project/src/lambda/package.json
にはLambda Layerに含めるモジュールのみ入っている。
project/
├── bin/
│ └── app.ts
├── lib/
│ └── stack.ts
├── src/
│ └── lambda/
│ ├── handlers/
│ │ └── func1/
│ │ └── main.ts // ← ./libなどあれば、それも含めてLambda Functionにバンドルされる
│ ├── package-lock.json
│ └── package.json // ← こいつの dependencies をLambda Layerに含める
├── test/
│ └── app.test.ts
├── cdk.json
├── package-lock.json
├── package.json // ← AWS CDKとか型定義とか開発時に必要なもの
└── tsconfig.json
デプロイ処理の流れ
おおまかにデプロイ時の処理をまとめるとこうなる。
- Layerのバンドル処理
- AWS CDKの処理内でバンドル処理用のシェルスクリプトを呼び出す
-
project/src/lambda/package.json
を使ってnpm ci
を実行する - ローカルでバンドル処理できない状態であれば、Dockerでのバンドル処理を試みる
- Dockerイメージがなければ
pull
- コンテナを起動し、その内部で
npm ci
を実行する
- Dockerイメージがなければ
- バンドル結果は
project/cdk.out
以下に出力される
- Functionのバンドル処理
- Layerに含めたモジュールは明示的に除外する(
package.json
の dependencies) - TypeScriptからJavaScriptにトランスパイル
- バンドル結果は
project/cdk.out
以下に出力される
- Layerに含めたモジュールは明示的に除外する(
- FunctionからLayerを参照する
実装
Layer
まずLayerを作る。
全体はこんな感じ。
import * as lambda from "aws-cdk-lib/aws-lambda";
:
const layer = new lambda.LayerVersion(this, 'Layer', {
layerVersionName: layerName,
code: lambda.Code.fromAsset('src/lambda', {
bundling: {
local: new LocalBundler(path.resolve(__dirname, '../src/lambda')),
image: lambda.Runtime.NODEJS_16_X.bundlingImage,
command: [
'bash',
'-c',
[
'mkdir -p /asset-output/nodejs',
'cp package.json package-lock.json /asset-output/nodejs/',
'npm ci --omit=dev --prefix /asset-output/nodejs',
].join(' && '),
],
user: 'root',
},
}),
compatibleRuntimes: [lambda.Runtime.NODEJS_16_X],
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
ローカルでのバンドル処理
local
に指定した LocalBundler
は ILocalBundling
インターフェイスの tryBundle
メソッドを実装したクラス。
このクラスでローカルでのバンドル処理を行う。
local: new LocalBundler(absolutePath),
tryBundle
を実装し bundle.sh
(後述)を実行する。
ダメなら false
を返し、Dockerにフォールバックさせる。
引数の outputDir
には project/cdk.out/asset.xxx
のようなパスが渡される。
Lambda Layerは node_modules/nodejs
のような構成を期待しているので、project/cdk.out/asset.xxx/nodejs
以下にバンドル結果を出力するようにする。
出力すればあとはAWS CDKがよいようにしてくれる。
import { BundlingOptions, Duration, ILocalBundling } from 'aws-cdk-lib';
:
export class LocalBundler implements ILocalBundling {
:
tryBundle(outputDir: string, options: BundlingOptions): boolean {
try {
const result = execFileSync(path.resolve(__dirname, 'bundle.sh'), {
env: {
PATH: process.env.PATH,
BUNDLER_OUTPUT_DIR: outputDir,
BUNDLER_ROOT_DIR: path.resolve(__dirname, '../src/lambda'),
},
timeout: Duration.seconds(60).toMilliseconds(),
encoding: 'utf-8',
});
return true;
} catch (e: any) {
console.error(e);
return false;
}
}
}
bundle.sh
は開発中に単体で動作確認できるよう別ファイルにした。
ここで npm ci
を行っている。というかそれしかやってない。
output_dir="$BUNDLER_OUTPUT_DIR"
if [ -z "$output_dir" ]; then
echo '$BUNDLER_OUTPUT_DIR is empty' 1>&2
exit 1
fi
output_dir="$output_dir/nodejs"
mkdir -p "$output_dir"
root_dir="$BUNDLER_ROOT_DIR"
if [ -z "$root_dir" ]; then
echo '$BUNDLER_ROOT_DIR is empty' 1>&2
exit 1
elif [ ! -d "$root_dir" ]; then
echo '$BUNDLER_ROOT_DIR is not found' 1>&2
exit 1
fi
cd "$root_dir"
cp package.json package-lock.json "$output_dir"
npm ci --omit=dev --prefix "$output_dir"
Dockerでのバンドル処理
ローカルでのバンドル処理ができなかった場合、Dockerでのバンドル処理にフォールバックする。
具体的には tryBundle
が false
を返した時にフォールバックする。
/asset-output/
以下に出力する必要があるので、ローカルでのバンドル処理と同様に /asset-output/nodejs
以下に出力する。
lambda.Code.fromAsset('src/lambda')
で指定したパスが /asset-input
にマウントされ、カレントディレクトリになっているのだと思う。
image: lambda.Runtime.NODEJS_16_X.bundlingImage,
command: [
'bash',
'-c',
[
'mkdir -p /asset-output/nodejs',
'cp package.json package-lock.json /asset-output/nodejs/',
'npm ci --omit=dev --prefix /asset-output/nodejs',
].join(' && '),
],
user: 'root',
Function
lambda.Function
を使うとTypeScriptのままデプロイされてしまうので言語に特化したモジュールを使う。
TypeScriptの場合 aws-cdk-lib/aws-lambda-nodejs
の NodejsFunction
を使う。
バンドル処理以外の使い方は lambda.Function
と同じ。
const fn = new NodejsFunction(this, 'Function', {
entry: `src/lambda/handlers/func1/main.ts`,
handler: 'handler',
bundling: {
externalModules: external,
},
runtime: lambda.Runtime.NODEJS_16_X,
layers: [layer],
});
bundling
にはesbuildのオプションを指定する。
externalModules
には配列でバンドル対象外のモジュールを指定する。
ここではLayerに含めたモジュールを除外している。
bundling: {
externalModules: external,
},
除外するモジュールの一覧は src/lambda/package.json
の dependencies
から取得した。
package.json
を import したかったので tsconfig.json
に "resolveJsonModule": true
を追加してある。
import * as manifest from '../../src/lambda/package.json';
:
const external = Object.keys(manifest.dependencies);
先ほど作ったLayerを指定する。
fn.addLayers(layer)
でもいい。
layers: [layer],
実行
実行してみる。
bundle.sh
で set -x
しているので色々でている。
Dockerでのバンドル処理は初回pullが走ったり、2回目以降もそれなりに時間がかかる。
環境的に問題がなければローカルでのバンドル処理が手っ取り早いと思う。
$ cdk synth
Bundling asset xxx/Layer/Code/Stage...
+ output_dir=/xxx/project/cdk.out/asset.xxx
+ '[' -z /xxx/cdk.out/asset.xxx ']'
+ output_dir=/xxx/cdk.out/asset.xxx/nodejs
+ mkdir -p /xxx/cdk.out/asset.xxx/nodejs
+ root_dir=/xxx/src/lambda
+ '[' -z /xxx/src/lambda ']'
+ '[' '!' -d /xxx/src/lambda ']'
+ cd /xxx/src/lambda
+ cp package.json package-lock.json /xxx/cdk.out/asset.xxx/nodejs
+ npm ci --omit=dev --prefix /xxx/cdk.out/asset.xxx/nodejs
Bundling asset xxx/Function/Code/Stage...
cdk.out/bundling-temp-xxx/index.js 1.0kb
⚡ Done in 5ms
Successfully synthesized to /xxx/cdk.out