CDK+LambdaでTypeScript+TypeORMが使えるまで【苦労】

# 背景

Typescriptでお馴染みのORMの一つーーTypeORMですが、AWS CDK・LambdaでRDS接続できるまで一苦労しましたので共有していきたいと思います。


使用環境：
- CDK: 2.154.1
- Typescript: 5.5.3
- TypeORM: 0.3.20

# 結論

CDKがデフォルトでesbuildを使うトランスパイルをする[^1]ため、TypeORMに必要なデコレーターはトランスパイルの過程でなくなります（現時点、2024年11月では）。デコレーター設定を有効化するために、以下のようなオプションをLambda関数に付け、tscで予めトランスパイルし、生成した.jsをLambda環境に読み込ませる必要があります。

```typescript
    const fn = new NodejsFunction(this, "typeorm-lambda", {
      //...

      // enable emitdecoratormetadata
      bundling: {
        preCompilation: true, 
        esbuildArgs: {
          "--resolve-extensions": ".js",
        },
      },
    });
```

コードはこちら：https://github.com/zhang-hang-valuesccg/example-typeorm-cdk

参考：
CDKとSAMでTypeScriptファイルをLambda関数にデプロイする[^1]
Lambda関数にデコレーター設定を追加する方法[^2][^3]

# 2025/02/25追記

CDKかどうかにかかわらず、Lambdaに上がるためファイルをesbuildを使ってbundleするのが一般化してきた気がします。なので、CDKじゃなくCLIを使うバージョンも作りました。

コードはこちら：https://github.com/zhang-hang-valuesccg/typeorm-bundle-for-lambda

CDKを使う方法と同じく、tsc → esbuildの順番で、デコレーター情報を無くさずに一個のindex.jsを作成してLambadにアップロードします。tsconfig.jsonとpackage.jsonのスクリプトをご参照ください。



# 実装

では実装に参ります。

## 全体設計

今回のインフラはこんな感じ（検証なので多少拙い構成で大目に見てください）：

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/d15eb584-0397-40ac-d89f-ad6fb8d8c371.png">

プライベートサブネットでLambda関数とRDSインスタンスを配置し、NAT Gatewayを通じて外部と通信する仕組みです。（お手元で真似するなら削除もお忘れなく、Nat gatewayお高いよ～）
普段EC2やECSでRDSとやりとりすることの方が多いと思いますが（実際そっちでいいかもしれないが）、今回はLambdaにまつわる特殊な問題を解決するためにLambdaのみ使います。

## 事前準備

まずはCDKで環境を用意します。

```bash
cdk init -l typescript
```

以下のようにVPCとRDSを作ります。Lambda関数がRDSにアクセスできるようにセキュリティグループも作成します。

```typescript:typeormcdk-stack.ts
//... import everything

export class TypeormcdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);


    const vpc = new Vpc(this, "typeorm-test-vpc", {
      vpcName: "typeorm-test-vpc",
      ipAddresses: IpAddresses.cidr("10.2.0.0/16"),
      maxAzs: 2,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "typeorm-test-subnet-public",
          subnetType: SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: "typeorm-test-subnet-private",
          subnetType: SubnetType.PRIVATE_WITH_EGRESS,
        },
      ],
      natGateways: 1,
    });

    const rdsSG = new SecurityGroup(this, "typeorm-rds-sg", {
      vpc,
      description: "Allow Lambda to access RDS",
      allowAllOutbound: true,
    });

    const lambdaSG = new SecurityGroup(this, "typeorm-lambda-sg", {
      vpc,
      description: "Allow Lambda to access the internet",
      allowAllOutbound: true,
    });

    rdsSG.addIngressRule(
      lambdaSG,
      Port.tcp(3306),
      "Allow Lambda to access RDS"
    );

    const dbUsername = "admin";
    const dbPassword = new Secret(this, "typeorm-rds-secret", {
      secretName: "rds-password",
      generateSecretString: {
        secretStringTemplate: JSON.stringify({ username: dbUsername }),
        generateStringKey: "password",
        excludePunctuation: true,
      },
    });

    const rdsInstance = new DatabaseInstance(this, "typeorm-rds-instance", {
      engine: DatabaseInstanceEngine.mysql({
        version: MysqlEngineVersion.VER_8_0_35,
      }),
      vpc: vpc,
      credentials: Credentials.fromSecret(dbPassword),
      instanceType: InstanceType.of(InstanceClass.T3, InstanceSize.MICRO),
      vpcSubnets: {
        subnetType: SubnetType.PRIVATE_WITH_EGRESS,
      },
      storageType: StorageType.GP2,
      securityGroups: [rdsSG],
      multiAz: false,
      allocatedStorage: 20,
      maxAllocatedStorage: 100,
      allowMajorVersionUpgrade: false,
      autoMinorVersionUpgrade: true,
      deleteAutomatedBackups: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY, // Note: not recommended for production
      deletionProtection: false,
    });
}

```

## 一般のDB接続の場合

まず一般的なDB接続をやってみます。本記事ではMySQLを使用します。

以下のようにLambdaを書きます：

```typescript:mysql.ts
// ...import everything

const ENDPOINT = process.env.ENDPOINT || "";

export const handler: Handler = async (event) => {
  let password;
  // ...retrieve password from Secrets Manager

  const dbConfig = {
    host: ENDPOINT,
    user: "admin",
    password: password,
    port: 3306,
  };

  try {
    const conn = await mysql.createConnection(dbConfig);

    const [results, fields] = await conn.query("SHOW TABLES FROM mysql");

    // 試しのアウトプット
    console.log(results);

    // 次のステップのため、ついてにDBも作成する
    await conn.query("CREATE DATABASE IF NOT EXISTS test"); 
  } catch (err) {
    throw Error(`ERROR: ${err}`);
  }
};
```

スタックにLambdaを追加します（必要な権限も付与します）：

```diff_typescript: typeormcdk-stack.ts
// ...in TypeormcdkStack

+   const noOrmFn = new NodejsFunction(this, "typeorm-lambda-no-orm", {
+     entry: "./lambda/workload/mysql.ts",
+     handler: "handler",
+     timeout: cdk.Duration.seconds(10),
+     runtime: Runtime.NODEJS_20_X,
+     securityGroups: [lambdaSG],
+     vpc: vpc,
+     vpcSubnets: {
+       subnetType: SubnetType.PRIVATE_WITH_EGRESS,
+     },
+     environment: {
+       ENDPOINT: rdsInstance.instanceEndpoint.hostname,
+     },
+   });
+   noOrmFn.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY);
+   rdsInstance.grantConnect(noOrmFn);
+   dbPassword.grantRead(noOrmFn);
```

Lambdaを実行して、接続を試してみます。問題ないようですね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/b12a7959-5daf-c0b3-0094-d3c1c5f52583.png)

## TypeORMで接続する場合

以下のように簡単なentityを用意します：

```typescript:User.ts
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
import "reflect-metadata";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  Name: string;

  @Column()
  age: number;
}
```

Lambda関数を用意します：

```typescript:typeorm.ts
// ...import everything

const ENDPOINT = process.env.ENDPOINT || "";

export const handler: Handler = async (event) => {
  let password;
  // ...retrieve password from Secrets Manager

  const AppDataSource = new DataSource({
    type: "mysql",
    host: ENDPOINT,
    port: 3306,
    username: "admin",
    password: password,
    database: "test", // 前の節で作られたDBの名前
    synchronize: true,
    logging: false,
    entities: [User],
    migrations: [],
    subscribers: [],
  });

  try {
    await AppDataSource.initialize();

    // new user entry
    const user = new User();
    user.Name = "test";
    user.age = 1;

    await AppDataSource.manager.save(user);

    // DBからさっき入れたUserを探す
    const users = await AppDataSource.manager.find(User);

    // 探したものをアウトプット
    console.log(`users: ${JSON.stringify(users)}`);
  } catch (err) {
    throw Error(`ERROR: ${err}`);
  }
};
```
まずは先と同様にスタックにLambda関数を追加します：

```diff_typescript:typeormcdk-stack.ts
// ...in TypeormcdkStack

+   const ormFn = new NodejsFunction(this, "typeorm-lambda-orm", {
+     entry: "./lambda/workload/typeorm.ts",
+     handler: "handler",
+     runtime: Runtime.NODEJS_20_X,
+     timeout: cdk.Duration.seconds(10),
+     vpc: vpc,
+     vpcSubnets: {
+       subnetType: SubnetType.PRIVATE_WITH_EGRESS,
+     },
+     securityGroups: [lambdaSG],
+     },
+     environment: {
+       ENDPOINT: rdsInstance.instanceEndpoint.hostname,
+     },
+   });
+   ormFn.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY);
+   rdsInstance.grantConnect(ormFn);
+   dbPassword.grantRead(ormFn);
```
実行してみますと、以下のようにエラーが出てきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/45324d50-b53c-37cc-b620-2d973e133b4f.png)

内容を解読しますと、デコレーター（＠Entiry、＠Columnなど）が読まれていないのと"reflect-metadata"がインポートされていないようですが、tsconfigに関連オプションをtrueにしていますし、"reflect-metadata"もちゃんとインポートしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/da65333b-64ff-ae85-6cf3-794bc7580f73.png)

こうなった原因は、CDKでTypescriptファイルをLambda環境にアップロードする際、デフォルトでesbuildを使ってトランスパイルするからです。加えて、こちらのGithubイッシュー[^4]によると、少なくとも2020年と2023年時点で、esbuildの作者はデコレーターflagをサポートしないと宣言されました。理由としては、保守の難易度とesbuildの方向性が挙げられました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/b3e7bde8-50d3-e1bc-6c12-26f93281b91d.png)

（同イッシューの中に本記事と違うプラグインを使った回避策が提示されたが、それでうまくいった人はぜひコメント欄へ）

回避策として、こちらのイッシュー[^4]で提示されたように、Lambda関数にbundlingオプションを付けます：

```diff_typescript:typeormcdk-stack.ts
    const ormFn = new NodejsFunction(this, "typeorm-lambda-orm", {
      entry: "./lambda/workload/typeorm.ts",
      handler: "handler",
      runtime: Runtime.NODEJS_20_X,
      timeout: cdk.Duration.seconds(10),
      vpc: vpc,
      vpcSubnets: {
        subnetType: SubnetType.PRIVATE_WITH_EGRESS,
      },
      securityGroups: [lambdaSG],
+     bundling: {
+       preCompilation: true, // enable emitdecoratormetadata
+       esbuildArgs: {
+         "--resolve-extensions": ".js",
+       },
+     },
      environment: {
        ENDPOINT: rdsInstance.instanceEndpoint.hostname,
      },
    });
```

こちらのオプションの意味（公式解釈じゃなくあくまでコミュニティで主張されたもの）ですが、tscで予めトランスパイル（tsconfigのデコレーターflagを適用）を行い、同時に.tsファイルが読まれないようにtscで生成した.jsをLambda環境に上がるのです。

これでデコレーターもLambda環境で動くようになります。試しにアウトプットします：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/23f94286-ae96-a7f6-8027-b1f57ee78f76.png)

# 最後にポエムコーナー

## TypeScriptのORMについて

**TypescriptのORMは、これといった答えはまだない**

メジャーのもので言うと以下四つありますが、どれもメリデメがあり、とりあえずこれ！とはなかなか言えなくて困る人も多いでしょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/42c4ab5c-12b3-3f2b-e4b1-dc1844854f73.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/dfb12e83-b3a1-b0b9-fa08-fd0b163f8110.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/028df9cf-8446-cd2f-ac51-57ea672841d9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/f19f2534-6bda-017a-2477-04e5d6709540.png)

<dl>
  <dt>TypeORM</dt>
  <dd>本記事で使われるTypeORMはオープンソースであり、近年では結構人気が出ていますが、やはりデコレーター嫌！の人もいれば、今年に入って更新が滞ってしまい未来が不明瞭になってやはり採用を躊躇する人もね… </dd>
  <dt>Prisma</dt>
  <dd>OSSコミュニティでなく会社運営ですが、一番人気のあるPrismaもやはり無視できません。使う人も多く作りもモダンなので今後スタンダードになるポテンシャルはありそうですね</dd>
  <dt>Sequelize</dt>
  <dd>この中で一番の古株であり数年前にTypescriptサポートになりました！参考資料も多く、ORMとしては一番成熟してるかもしれません</dd>
  <dt>Drizzle</dt>
  <dd>こちらは新参ものですが、極力存在感を無くす思想を持ち、サーバレスへのサポートも充実しており、使う機会はまだないですがその志に大いに同意です</dd>
</dl>

味の薄い感想で申し訳ございません…TypescriptのORM選定について見解のある方のご意見をお待ちしております。

ちなみに、TypeORMを**渋々**採用した理由としては、デコレーターが必要だからです。現存のインターフェースからメンバーを引っ張り出してそのままDBのスキーマにできるのは、やはりデコレーターですね。TypeORM以外にも、MikroORMという同じくデコレーターベースのORMがありますのでこちらもいけるのかなと思います。

もっと具体的に言うと、`class [Class] implements [Interface]`でインターフェースのメンバーをアクセスすることができ、タイプの恩恵を享受できます。以下の例みたいに：

```typescript:example.ts
interface ExampleInterface {
  mem1: string;
  mem2: {
    nested1: number;
    nested2: string;
  };
}

@Entity()
class ExampleEntity implements ExampleInterface {
  @PrimaryColumn()
  mem1: string;
  @Column({ type: "json" })
  mem2: { nested1: number; nested2: string };
}
```

自動補完だけでなく、めんどくさいObjectもとりあえずjsonとしてDBにぶち込めて大変助かります。とはいえ、筆者自身もデコレーター好きではないので、もっと薄く、主張の弱いORM（かその類のもの、sqlcなど）があれば大歓迎ですね。

## TypeORMについて

- **Lambda向けでないかもしれない：**
非公式の回避策を使わないとCDKと相容れない上に、bundleのサイズがとんでもなく大きいです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/7badbb50-cc06-b29e-a1dd-fa5d5ff20f9e.png)
加えて、bundlingオプションの影響で、強制的にtscを使ったトランスパイルが行われ、デプロイの速度がかなり低下します。あとnoEmitフラグと関係なく.jsが排出され、フォルダが汚れます。

- **TypeORMのコミュニティ**
少し上から目線で申し訳ないが、上手くいっているプロジェクトと比べてイッシューとプルリクが溜まっていく傾向が見られます。もちろんOSS貢献者を責めるつもりは一切ないですが、ORMである故とにかく仕事の量が膨大なので、いずれ個人開発者の手に負えなくなってもおかしな話じゃありません。実際、こちらのTypeORMの未来を論するイッシュー[^5]では、TypeORMを組織化運営する議題が上がっておりますし、すでに水面下でいろいろ起きています（2024年11月時点）。

3万starも超えたオープンソースORMプロジェクトなので、輝かしい未来を期待しております。


[^1]: https://docs.aws.amazon.com/lambda/latest/dg/typescript-package.html#aws-cdk-ts

[^2]: https://stackoverflow.com/questions/74169542/how-to-enable-decorators-in-nodejs-lambda-function-that-are-built-and-deployed-u

[^3]: https://github.com/aws/aws-cdk/issues/21168

[^4]: https://github.com/evanw/esbuild/issues/257

[^5]: https://github.com/typeorm/typeorm/issues/3267