📆

AWS CDK Pipeline × Prisma でRDSに接続するLambdaを実装する

2022/12/21に公開

※本エントリは、AWS CDK Advent Calendar 2022 の21日目の記事となります。

TypeScriptのORMであるPrismaを使っており、CDKでデプロイしようとしたのですが、何点か難しい箇所があったので、構築の流れとポイントをまとめました。
全て書くとトンデモ文量になってしまうため、端折っています。
ソースコード自体はAWSにデプロイすれば動作するため、すべて見たい方はソースコードを見ていただければと思います。

PrismaもCDK Pipelineも触り始めたのが1ヶ月前くらいからなので、変な書き方をしていたらご指摘いただけるとありがたいです。

本記事の概要

今回の記事は、以下の流れで進めていきます。

  1. ローカルでPrismaを動かせるようにする
  2. CDKを使ってLambda, RDSをデプロイする
  3. CDK Pipelineで自動デプロイできるようにする
  4. 動作確認
  5. RDSへのマイグレーションを自動化する(また別の記事)
    (マイグレーションの自動化は、今回力尽きて諦めました。)

今回の完成系コードは以下に上げています。
ある程度分かる方は、コードだけ見てもらった方が良いかもしれません。
https://github.com/engineer-taro/cdk-prisma-public

ちなみに、バックエンドのソースは、簡略化したオニオンアーキテクチャの構造にしました。アプリケーション層とかドメイン層のエンティティは省略しています。
リポジトリの構造が好きなので、使いたかっただけです。

なお、なるべく実際の業務でも取り回しできるように、dev, prd環境があるような想定をして書いています。

ローカル環境でPrismaを動かせるようにする

まずは、ローカルで立ち上げたコンテナのDBと接続できる状態にします。

初期設定

詳しくは、公式チュートリアルを参照(https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases-typescript-postgres)。
バックエンドのディレクトリ配下で、以下を実行しました。

npm init -y 
npm install prisma typescript ts-node @types/node --save-dev
npx tsc --init
npx prisma
npx prisma init

テーブルの追加とマイグレーション

初期設定にて出来上がったschemaに、task modelを定義します。

schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model task {
  id String @id
  text String
}

dockerでDBのコンテナを立ち上げた状態で、マイグレーションを行います。
ソースにはdocker-compose up -dでDBのコンテナを立ち上げられるように用意してあります。

また、DBの接続情報はデフォルトで.envファイルのDATABASE_URLで保持しているため、こちらを修正します。

.env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/cdk_prisma?schema=cdk_prisma"

schema定義に従ったマイグレーションを行うために、以下のコマンドを実行します。

npx prisma migrate dev --name init

primsaディレクトリ配下にマイグレーションのためのSQLコマンドファイルが発行され、ローカルDB側にはtaskテーブルが作成されています。

taskテーブルのDBクライアントツールの画像

ちなみに、これをチーム開発で行う場合、他の開発者は出来上がったマイグレーションのSQLを以下のコマンドで自身のローカル環境に流し込みます。

npx prisma migrate deploy

このように、PrismaとローカルDBの設定を行うことが出来ました。

コードからInsertを実行する

ココマデ、実装の話に触れていませんでした。
ココカラ、Prismaのコードについて触れていきます。

詳細はGitHubのコードを参照してください。ここでは重要な点だけ記載します。

PrismaClientのインスタンスを発行するための関数を外出しします。
後でAWS環境で使うためのコードに書き換える際に、外出しした方が色々都合が良いです。

client.ts
import { PrismaClient } from "@prisma/client";

export async function getPrismaClient() {
  const dbClient = new PrismaClient()
  return dbClient;
}

そして、insertは以下のようなコードになります。
ポイントは、prismaClient.<テーブル名>.createのようにしてテーブルのメソッドにアクセスすることです。

prisma-task-repository.ts
  async insert(text: string) {
    await this.prismaClient.task.create({
      data: {
        id: uuidv4(),
        text: text,
      },
    });
  }

Prismaでは、PrismaClientのモジュールがスキーマ定義に従って動的に生成されます。
モジュールが生成されるタイミングは、prisma migrateprisma generateをしたタイミングになります。
後述しますが、この辺りがAWS環境に上げるうえで少し難しいポイントになってきます。生成されたPrismaClientをAWS環境に上げる必要があるためです。

ここまで書けたら、テストコードの実行によってInsertが出来るか確認します。
※詳細はリポジトリのコードを参照

task-controller.test.ts
import { CreateTaskController } from "./create-task-controller";

describe("テスト(実行のみ)", () => {
  test("insert", async () => {
    const controller = new CreateTaskController();
    await controller.execute();
  });
});

テストコードの実行

npx jest

実際に、タスクテーブルにデータが入っていることを確認できました。
タスクテーブルにデータが入っていることの確認の画像

ココマデの進捗

  1. ローカルでPrismaを動かせるようにする 🌸
  2. CDKを使ってLambda, RDSをデプロイする
  3. CDK Pipelineで自動デプロイできるようにする
  4. 動作確認
  5. RDSへのマイグレーションを自動化する

次は、CDKでLambdaとRDSをデプロイしていきます。

CDKを使ってLambdaでPrismaを動かせるようにする

Prisma側の設定

LambdaにPrismaを動作させるためには、Prisma EngineとPrisma Clientの両方をデプロイする必要があります。
以下の記事には大変お世話になりました。
https://www.prisma.io/docs/guides/deployment/deployment-guides/caveats-when-deploying-to-aws-platforms#aws-lambda-upload-limit
https://kiririmode.hatenablog.jp/entry/20220619/1655622443

Lambdaで動作できる Prisma Engine を用意するために、schemaに設定を追記し、Prisma Engineを再生成します。

binaryTargetsを追記します。

schema.prisma
generator client {
  provider = "prisma-client-js"
  binaryTargets = ["native", "rhel-openssl-1.0.x"]
}

以下のコマンドで、Prisma Engineを生成します。

npx prisma generate

AWS CDKで VPC, RDS まわりの実装

ここで漸く、AWS CDKの話に入っていきます。
まず、VPC, Aurora RDS, RDS Proxyの環境を作るのですが、この辺りは長くなるのと本題とは関係が無いので割愛します。詳細はコードを参照ください。

なお、ほとんどこちらの記事を参考につくらせて頂きました。
https://dev.classmethod.jp/articles/aws-cdk-connect-to-amazon-aurora-db-cluster-from-lambda-function-via-rds-proxy/

※RDS ProxyはPrismaでは必要ない、と公式に記載がありますが、今回は一般的なLambda,RDS構成で使うように RDS Proxy を利用しています。まだRDS Proxy無しの検証が私の方で出来ていないためです。

1点注意するべき点が、データベースのパスワードです。
AWSのSecrets Managerを利用してパスワードを生成しているのですが、Prismaで使うときにエンコーディングが必要な文字列が含まれてしまう可能性があります。
色々と面倒なので、エンコーディングが必要な文字列の使用を回避するために、以下のように使えない文字を指定します。

vpc-rds.ts
    const EXCLUDE_CHARACTERS = ":/?#[]@!$&'()*+,;=%\"";

なお、エンコーディングが必要な文字列は以下に記載があります。
https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding

AWS CDKで Lambda まわりの実装

この章で一番説明したかったのが、この部分になります。

先ほど生成した Prisma Engine をAWS環境に上げる必要があるのですが、普通にやってもデプロイすることはできません。

NodejsFunctionbundlingの設定を利用することで、Prisma Engineをデプロイします。

lambda-function.ts
      bundling: {
        forceDockerBundling: false,
        minify: true,
        sourceMap: true,
        commandHooks: {
          beforeBundling: (i, o) => [`cd ${i} && npm ci`],
          afterBundling(i: string, o: string): string[] {
            return [
              // Prismaクエリエンジンを追加
              `cp ${i}/node_modules/.prisma/client/libquery_engine-rhel-* ${o}`,
              // Prismaスキーマ定義を追加
              `cp ${i}/prisma/schema.prisma ${o}`,
            ];
          },
          beforeInstall: (i, o) => [],
        },
      },

AWS でも利用できるように PrismaClient の取得処理の実装

ローカルでもAWS環境でも動作させられるように、PrismaClientのインスタンス取得処理を実装していきます。

完成系は以下のようになりました。
"dev"がAWS環境の想定で、それ以外であればローカル実行、という想定になっています。

client.ts
import "dotenv/config";
import {
  SecretsManagerClient,
  GetSecretValueCommand,
  GetSecretValueCommandOutput,
} from "@aws-sdk/client-secrets-manager";
import { PrismaClient } from "@prisma/client";

export async function getPrismaClient() {
  let dbClient: PrismaClient;
  if (process.env.ENV_NAME === "dev") {
    const secretsManagerClient = new SecretsManagerClient({
      region: process.env.AWS_REGION!,
    });
    const getSecretValueCommand = new GetSecretValueCommand({
      SecretId: process.env.SECRET_ID,
    });
    const getSecretValueCommandResponse = await secretsManagerClient.send(
      getSecretValueCommand
    );

    const secret = JSON.parse(getSecretValueCommandResponse.SecretString!);
    const dbUrl = `postgresql://${secret.username}:${secret.password}@${process.env.PG_HOST}:${secret.port}/${secret.dbname}?schema=cdk_prisma&connection_limit=1&socket_timeout=3`;
    dbClient = new PrismaClient({
      datasources: {
        db: {
          url: dbUrl,
        },
      },
    });
  } else {
    dbClient = new PrismaClient();
  }
  return dbClient;
}

AWS環境下ではRDSの情報をSecretsManagerから取得します。RDSの情報をSecretsManagerで管理する方法は、CDKのコードを見ていただければと思います。

こちらのコードの中で動的にエンドポイントを組み立てることで、Lambda -> RDSのアクセスを可能にしています。

また、Lambdaの環境変数に、環境名を与えることで、AWS環境/ローカル環境を判断できるようにしています。
ローカルでは、先ほどから引き続き.envのDATABASE_URLを参照していきます。

AWS CDKでデプロイを実行

ここまで出来たら、後はデプロイするだけです。

sh
cdk bootstrap --profile <プロフィール名>
cdk deploy DevAppStack --profile <プロフィール名>

と、ここで私の環境では、以下のdepsLockFilePathbundlingの設定があると、エラーが発生してデプロイが出来なくなりました。
Windows, WSL2, Ubuntuの環境です。

※NodejsFunctionsのpropsの中身です。

ts
      depsLockFilePath: path.join(__dirname, backendPath, "package-lock.json"),
      bundling: {
        forceDockerBundling: false,
        commandHooks: {
          beforeBundling: (i, o) => [`cd ${i} && npm ci`],
          afterBundling(i: string, o: string): string[] {
            return [
              // Prismaクエリエンジンを追加
              `cp ${i}/node_modules/.prisma/client/libquery_engine-rhel-* ${o}`,
              // Prismaスキーマ定義を追加
              `cp ${i}/prisma/schema.prisma ${o}`,
            ];
          },
          beforeInstall: (i, o) => [],
        },
      },

エラー内容

Failed to bundle asset DevAppStack/TaskFunction/Code/Stage

こちら、原因解消に2時間以上かけても分からなかったため、ローカルでのデプロイを一旦諦めました。私が使っている環境がWSL2ということもあり、他で再現するか謎なため、ここに時間をかけるのは勿体ないと思いました。また、WSL2とNodejsFunctionsにまつわるエラーは多少あるようで、それであれば解決できないため、後回しにします。
解明できたら、この記事に追記しようと思います。
後述するCDK Pipelineを利用することで、デプロイできるようにしていきます。

ここでは、先ほどの設定をコメントアウトしてデプロイを成功させました。

ココマデの進捗

  1. ローカルでPrismaを動かせるようにする 🌸
  2. CDKを使ってLambda, RDSをデプロイする 🌸
  3. CDK Pipelineで自動デプロイできるようにする
  4. 動作確認
  5. RDSへのマイグレーションを自動化する

bundlingの設定があるとデプロイが出来ない、という問題が発生していますが、そこ以外はデプロイすることが出来ました。
次に、CDK Pipelineを設定し、自動デプロイと先ほどのエラー解消を行っていきます。

CDK Pipelineで自動デプロイできるようにする

CDK Pipelineの詳細な説明は省きます。

CDKコードの修正

CDK pipelineを使うように、スタックを用意します。
パイプラインのスタックの完成系は以下のようになりました。

pipeline-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import {
  CodePipeline,
  CodePipelineSource,
  ShellStep,
} from "aws-cdk-lib/pipelines";
import * as iam from "aws-cdk-lib/aws-iam";
import { PipelineAppStage } from "./app-stage";
import { CommonProps } from "./settings/common-props";

interface PipelineProps extends CommonProps {
  repository: string;
  branch: string;
  connectionArn: string;
}

export class PipelineStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: PipelineProps) {
    super(scope, id, props);

    const pipeline = new CodePipeline(this, "Pipeline", {
      pipelineName: "BackendPipeline",
      synth: new ShellStep("Synth", {
        input: CodePipelineSource.connection(props.repository, props.branch, {
          connectionArn: props.connectionArn,
        }),
        commands: [
          "cd Backend",
          "npm ci",
          "npm run prisma:generate",
          "cd ..",
          "cd Infra",
          "npm ci",
          "npm run build",
          "npx cdk synth",
        ],
        primaryOutputDirectory: "Infra/cdk.out",
      }),
      codeBuildDefaults: {
        rolePolicy: [
          new iam.PolicyStatement({
            actions: ["*"],
            resources: ["*"],
          }),
        ],
      },
    });

    pipeline.addStage(new PipelineAppStage(this, "backendStage", props));
  }
}

ポイントは、commandsの部分です。

        commands: [
          "cd Backend",
          "npm ci",
          "npx prisma generate",
          "cd ..",
          "cd Infra",
          "npm ci",
          "npm run build",
          "npx cdk synth",
        ],

Backend配下でnpx prisma generateを行うことで、Prisma ClientとPrisma Engineを生成しています。
詳細はリポジトリをご参照ください。

ここまで出来たら、cdk deploy で CodePipelineのデプロイを行います。

cdk deploy --profile <プロフィール名>

bundlingの設定も反映する

先ほど、ローカルで失敗していたbundling部分のコメントアウトを解除し、 CodePipelineのターゲットとなっているブランチにプッシュします。
すると、無事にデプロイ成功していることが確認できました。

ココマデの進捗

  1. ローカルでPrismaを動かせるようにする 🌸
  2. CDKを使ってLambda, RDSをデプロイする 🌸
  3. CDK Pipelineで自動デプロイできるようにする 🌸
  4. 動作確認
  5. RDSへのマイグレーションを自動化する

動作確認

ここまで出来たら、動作確認です。

DBにテーブルを作成する

まず、RDSにテーブルが無い状態なので、接続してtaskテーブルを作成します。
今回作成している環境では、踏み台サーバーをパブリックサブネットに置いているので、pemキーを利用してローカルからRDSに接続できます。

今回のコードではCloudFormationのアウトプットに、公開鍵を取得できるコマンドを用意していますので、そちらで取得したpem情報をもとに、ローカルからDBに接続します。

ローカルからDBに接続出来たら、以下のSQLでスキーマの作成とテーブルの作成を行います。

CREATE SCHEMA cdk_prisma;
SET search_path TO cdk_prisma;

-- CreateTable
CREATE TABLE "task" (
    "id" TEXT NOT NULL,
    "text" TEXT NOT NULL,

    CONSTRAINT "task_pkey" PRIMARY KEY ("id")
);

こんな感じで、テーブルが作成されていることがDBクライアントツールから確認できました。
テーブルの作成

Lambdaの実行をためす

ようやく、Lambdaの実行です。
今回は簡単に、マネジメントコンソールから試しましょう。

Lambdaの実行

そしてテーブルの中身を確認。
テーブルの中身

しっかり入ってます!

ココマデの進捗

これで、今回やろうとしていたことは全て完了しました。
マイグレーションの自動化は、続編で書こうと思っています。

  1. ローカルでPrismaを動かせるようにする 🌸
  2. CDKを使ってLambda, RDSをデプロイする 🌸
  3. CDK Pipelineで自動デプロイできるようにする 🌸
  4. 動作確認 🌸
  5. RDSへのマイグレーションを自動化する(続編を書く予定)

おわりに

感想

ローカルからデプロイが出来なかったり、RDS Proxy無しでの検証が出来ていなかったり、まだまだ検証すべきところはあるのですが、詰まった点は沢山あったので、伝えられたら良いなと思い記事にしました。

また、もっと重要なポイントのみを抜き出して書ければ、読みやすい記事になったかと思いますが、なるべく時系列が分かりやすいように書いてみました。
コードはGitHubに上げていますので、そちらを見ればわかる人にとっては、冗長な記事になったかもしれません。

https://github.com/engineer-taro/cdk-prisma-public

おそらく、誰かの役に立つ記事が出来たかと思います!
また、CDKとPrisma関連で追加の情報があれば記事にしていく予定です。

AWS CDK Advent Calendar 2022に参加してみて

今回の記事は、AWS CDK Advent Calendar 2022で投稿するために書きました。
初めてAdvent Calenderに参加してみたのですが、いつも書くものより気合が入って長くなってしまい、逆に冗長な記事が出来てしまった感があります。

アドベントカレンダーではポエミーな記事も多くなるので、私もその方向の記事が書けるようになりたいな、と思いました!
その道に精通していないとポエミーな記事は書けないので、もっと詳しくならないと...
また、来年CDKでもほかのカレンダーでも、参加してみようと思います!

Discussion