4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-11-07

背景

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環境に読み込ませる必要があります。

    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関数にデコレーター設定を追加する方法23

実装

では実装に参ります。

全体設計

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

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

事前準備

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

cdk init -l typescript

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

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を書きます:

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を追加します(必要な権限も付与します):

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

TypeORMで接続する場合

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

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関数を用意します:

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関数を追加します:

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

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

image.png

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

image.png

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

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

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

最後にポエムコーナー

TypeScriptのORMについて

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

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

image.png
image.png
image.png
image.png

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

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

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

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

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
    加えて、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

  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 2

  5. https://github.com/typeorm/typeorm/issues/3267

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?