TypeScriptで書いたLambda関数をAWS CDKでデプロイしてみた
はじめに
休憩時間に猫を愛でるのが幸せなkohachanです!
先日参加した社内ハッカソンでちょっとした機能を開発した際に、Lambda関数のコードとAWS CDKをどちらもTypeScriptで書いてみました。
Lambda関数のコードをTypeScriptで書いてCDKでデプロイするまでは思ったよりも色々と考慮すべきポイントがあったので、今回はデプロイまでの手順とポイントについてまとめたいと思います。
最終的なディレクトリ構造
この記事の通りに進めると、最終的には以下のようなディレクトリ構造が出来上がります。
.
├── README.md
├── bin
│ └── lambda-typescript-cdk.ts
├── cdk.json
├── jest.config.js
├── lambda
│ └── node_modules
│ └── index.ts
│ └── package-lock.json
│ └── package.json
├── lib
│ ├── constructors
│ │ └── dynamodb.ts
│ │ └── lambda.ts
│ ├── lambda-typescript-cdk-stack.ts
│ └── process
│ ├── cleanup.ts
│ └── setup.ts
├── node_modules
├── package-lock.json
├── package.json
├── test
│ └── lambda-typescript-cdk.test.ts
└── tsconfig.json
AWS CDKプロジェクトの作成
まずはCDK CLIでAWS CDKプロジェクトを作成します。
aws-cdkがインストールされていない場合はこちらを参考にインストールしておきましょう。
mkdir lambda-typescript-cdk && cd lambda-typescript-cdk
cdk init sample-app --language typescript
Lambda関数のコンストラクタを作成
lib/constructors/lambda.ts
を作成し、以下のようにLambdaの定義を記述します。
この定義によって、以下のリソースが作成されます。
- Lambda関数
- Lambdaレイヤー
import { Construct } from "constructs";
import { Code, LayerVersion, Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { RetentionDays } from "aws-cdk-lib/aws-logs";
import { NODE_LAMBDA_LAYER_DIR, bundleNpm } from "../process/setup";
import { removeBundleDir } from "../process/cleanup";
import { Duration } from "aws-cdk-lib";
export interface LambdaProps {
readonly functionName: string;
readonly logRetentionDays: RetentionDays;
}
export class Lambda extends Construct {
readonly fn: NodejsFunction;
constructor(scope: Construct, id: string, props: LambdaProps) {
super(scope, id);
bundleNpm();
const modulesLayer = new LayerVersion(this, `${id}ModulesLayer`, {
compatibleRuntimes: [Runtime.NODEJS_18_X],
code: Code.fromAsset(NODE_LAMBDA_LAYER_DIR),
description: "node_modules layer",
});
this.fn = new NodejsFunction(this, `${id}NodejsFunction`, {
functionName: props.functionName,
entry: "lambda/index.ts",
handler: "handler",
runtime: Runtime.NODEJS_18_X,
memorySize: 1024,
timeout: Duration.seconds(15),
layers: [modulesLayer],
bundling: {
// layerに含まれるライブラリはbundleから省く
externalModules: ["*"],
},
logRetention: props.logRetentionDays,
});
removeBundleDir();
}
}
上記のコードの中では、Lambdaレイヤーの定義前後でnpmパッケージのインストールを行なっています。
そのため、パッケージをインストールする処理(process/setup.ts
)、インストールしたパッケージを削除する処理(process/cleanup.ts
)も以下のようにそれぞれ追加します。
process/setup.ts
#!/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 = () => {
// package.jsonとpackage.lock.jsonをlambdaディレクトリからバンドル用のディレクトリにコピー
copyPackageJson();
// package.jsonの内容をもとにパッケージをインストール
childProcess.execSync(`npm install --production`, {
cwd: getModulesInstallDirName(),
stdio: ["ignore", "inherit", "inherit"],
env: { ...process.env },
shell: "bash",
});
};
const copyPackageJson = () => {
fs.mkdirsSync(getModulesInstallDirName());
["package.json", "package-lock.json"].map((file) =>
fs.copyFileSync(
`${process.cwd()}/lambda/${file}`,
`${getModulesInstallDirName()}/${file}`
)
);
};
const getModulesInstallDirName = (): string => {
return `${NODE_LAMBDA_LAYER_DIR}/${NODE_LAMBDA_LAYER_RUNTIME_DIR_NAME}`;
};
process/cleanup.ts
#!/usr/bin/env node
import { NODE_LAMBDA_LAYER_DIR } from "./setup";
import * as fs from "fs-extra";
export const removeBundleDir = () => {
// バンドル用のディレクトリを削除
fs.removeSync(NODE_LAMBDA_LAYER_DIR);
};
さらに、setup.tsとcleanup.tsでは追加のパッケージが必要になるので、CDKプロジェクトのルートディレクトリで、以下のようにパッケージをインストールします。
npm i fs-extra
npm i -D @types/fs-extra
Lambda構築時のポイント
ここでのポイントは以下の点になるかと考えています。
- node_modulesはレイヤー化する
- Lambda関数の作成には
NodejsFunction
を使用する - レイヤーに含まれるライブラリはLambda関数にバンドルしないようにする
node_modulesはレイヤー化する
npmパッケージを使用しているLambda関数をCDKでデプロイすると、node_modulesもそのままLambdaに入ってしまいます。
パッケージの量によっては、Lambdaのブラウザ上のエディタからコードが見れなくなってしまうこともあります。
これを避けるために、node_modules内にインストールされているパッケージはLambdaレイヤー化し、Lambda関数にバンドルしない(後述)ようにしておきます。
また、レイヤーはデプロイするとき毎回最新の状態にしておきたいので、CDKでレイヤーを定義する前に、Lambda用のpackage.jsonからnode_modulesにインストールする処理を入れています。(process/setup.ts
)
ちなみに、node_modulesをレイヤー化する際には{レイヤーに指定するディレクトリ}/nodejs/node_modules
というようなディレクトリ構造にしなければいけないので、ご注意ください。
node_modulesのレイヤー化にあたっては、以下の記事を参考にしました。
AWS CDK を使って node_modules を AWS Lambda Layers にデプロイするサンプル
Node.js での Lambda の「Cannot find module」または「Cannot find Package」エラーを解決する
Lambda関数の作成にはNodejsFunctionを使用する
TypeScriptでLambda関数のコードを記述したときには、NodejsFunction
を使用するとLambda関数を楽に定義することができます。
Lambda関数を作成できるクラスには@aws-cdk-lib/aws_lambda
のFunction
もありますが、こちらはTypeScriptをトランスパイルする処理を自前で用意したりする必要があります。
レイヤーに含まれるライブラリはLambdaにバンドルしないようにする
NodejsFunction
の定義で、レイヤーに含まれるパッケージはLambda関数にバンドルされる対象から除外するように定義することができます。
以下のように書くことで除外することが可能です。
new NodejsFunction(this, `${id}NodejsFunction`, {
// 他のパラメータは割愛
bundling: {
// layerに含まれるライブラリはbundleから省く
externalModules: ["*"],
},
Lambda関数のコードを作成
ルートからlambda
ディレクトリを作成し、lambda/index.ts
にデプロイするLambda関数の処理を書いていきます。
今回はTypeScriptで作っており、サンプルのコードは以下のようにします。
内容としては、Lambdaで受け取ったイベントをDynamoDBに保存するというシンプルなものです。
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall } from "@aws-sdk/util-dynamodb";
type EventToSave = {
EventId: string;
Title: string;
Description: string;
};
const clientConfig = {
region: "ap-northeast-1",
};
export const handler = async (event: EventToSave) => {
if (event === undefined) return;
await putItemsToDynamoDB(event);
};
const putItemsToDynamoDB = async (event: EventToSave) => {
const client = new DynamoDBClient(clientConfig);
const input = {
TableName: 'LambdaTypescriptCdkTable',
Item: marshall(event, { removeUndefinedValues: true })
};
const command = new PutItemCommand(input);
await client.send(command);
};
注意点としては、handlerという名前の関数をexportしておく必要があります。
こちらの関数名は先ほどCDKで宣言していた、Lambda関数のハンドラー名と同じ名前になります。
Lambdaディレクトリ内でnpmパッケージをインストール
Lambda関数のみで必要とするnpmパッケージをまとめるため、ルートディレクトリにあるpackage.jsonとは別に、lambda
ディレクトリ内でもpackage.jsonを作っておきます。lambda
ディレクトリ内でnpm initをして、パッケージ名などを適当に入力します。
cd lambda
npm init
Lambda関数のコードではDynamoDBのSDKを使っているので、以下のパッケージをlambda
ディレクトリ配下でインストールしておきましょう。
また必須ではないですが、devDependencies
で以下の型を追加で入れておくとNode.js用の型定義などを補完することができます。
npm i @aws-sdk/client-dynamodb @aws-sdk/util-dynamodb
npm i -D @types/aws-lambda @types/node
DynamoDBテーブルのコンストラクタ作成
テスト用のDynamoDBテーブルもlib/constructors/dynamodb.ts
に以下のように定義をしておきます。
import { AttributeType, Billing, TableV2 } from "aws-cdk-lib/aws-dynamodb";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";
export interface DynamoDBProps {
readonly tableName: string;
readonly granteeLambda: NodejsFunction;
}
export class DynamoDB extends Construct {
constructor(scope: Construct, id: string, props: DynamoDBProps) {
super(scope, id);
const table = new TableV2(this, `${id}Table`, {
billing: Billing.onDemand(),
tableName: props.tableName,
partitionKey: {
name: "Id",
type: AttributeType.STRING,
},
});
table.grantReadWriteData(props.granteeLambda);
}
}
スタックの作成
Lambda関数とDynamoDBのコンストラクタを使用して、lib/lambda-typescript-cdk-stack.ts
に以下のようにスタックを定義します。
import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { RetentionDays } from "aws-cdk-lib/aws-logs";
import { Lambda } from "./constructors/lambda";
import { DynamoDB } from "./constructors/dynamodb";
export class LambdaTypescriptCdkStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const lambda = new Lambda(this, "LambdaTypescriptCdk", {
functionName: "LambdaTypescriptCdk",
logRetentionDays: RetentionDays.ONE_MONTH,
});
new DynamoDB(this, "DynamoDB", {
tableName: "LambdaTypescriptCdkTable",
granteeLambda: lambda.fn,
});
}
}
デプロイしてみる
初めて対象のAWSアカウントにデプロイする場合は、cdk bootstrapを実行しておきます。
cdk bootstrap
それでは、CDKプロジェクトのルートディレクトリに移動し、デプロイを実施してみます。
デプロイが成功したメッセージが表示されればOKです!
cdk deploy
動作確認
実際にLambdaを動かして動作を確認してみます。
対象のLambda関数でテストイベントを作成し、(Id
はパーティションキーに指定しているため)Id
のフィールドを含む適当なJSONを入力します。
テストイベントを実行して、処理が成功していることを確認します。
DynamoDBの画面に移動すると、対象のテーブルにデータが入っていることが確認できると思います!
おわりに
以上でTypeScriptで書いたLambdaをAWS CDKでデプロイすることができました。
CDKもLambdaも全てTypeScriptで書いてみたいと思い軽い気持ちでやってみたところ、想像よりも考慮する事項が多かったため、今回記事として要点をまとめてみました。
必要な機会があれば役立てていただければ幸いです!