AWS

TypeScriptで書いたLambda関数をAWS CDKでデプロイしてみた

kohachan

はじめに

休憩時間に猫を愛でるのが幸せな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_lambdaFunctionもありますが、こちらは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で書いてみたいと思い軽い気持ちでやってみたところ、想像よりも考慮する事項が多かったため、今回記事として要点をまとめてみました。

必要な機会があれば役立てていただければ幸いです!

AUTHOR
kohachan
kohachan
記事URLをコピーしました