💿

AWS CDK 管理下で DB を含むサーバーレスアーキテクチャのローカル開発環境を構築する

2024/03/26に公開

概要

API Gateway + Lambda + DynamoDB で構成される一般的なサーバーレスアーキテクチャを Serverless Framework ではなく、 AWS CDK でローカル開発環境を構築する方法を紹介する。公式の AWS SAM CLI や DynamoDB local を利用することがポイントとなる。

前提

  • 実際にこの管理手法を用いて運用しているのはエンタープライズ向けのWebアプリケーション
  • E2Eなローカル開発環境を理想とする(FrontとAPIで分業体制ができているチームには過剰設計になり得る)
  • macOSを想定

利用するライブラリ

ライブラリ バージョン
aws-cdk(TypeScript) 2.93.0
aws-sam-cli 1.98.0

AWS CDK vs Serverless Framework

なぜサーバーレスアーキテクチャに特化した Serverless Framework を用いずに、AWS CDK を用いるのかを両者を比較することで簡単に説明する(※言われなくても分かるよという方は読みとばしてください)。

メリット デメリット
AWS CDK ・あらゆるリソースをシンプルに記述できる
・AWSが公式で提供している
・ローカル開発環境構築の知見が少ない
・ベンダーロックインになる
Serverless Framework ・サーバーレスリソースのシンプルな記述
・容易にローカル開発環境を構築できる
・サーバーレスリソース以外はCloudFormationライクな記述が必要になる
・他のIaCツールのダブルメンテになることが多い

ここで特に注意すべきは、Serverless Framework のデメリットである サーバーレスリソース以外はCloudFormationライクな記述が必要になる という点だ。CloudFormation的記述は、以下のような辛みがある。

  • AWS CDKと比較してコードが肥大化しやすい
  • 肥大化を抑えるためのファイル分割が難しい
  • ファイルを分割するとリソース間の関係が見えづらくなる

これを嫌ってサーバーレスリソースのみ Serverless Framework を利用し、その他のリソースには AWS CDK など別のIaCツールを利用するという方法も取れるが、他のIaCツールのダブルメンテになることが多い というデメリットに直面する。ダブルメンテは以下のような問題を孕んでいる。

  • 学習コストとメンテナンスコストがかかる
  • デブロイ機構が複雑化しやすい
  • 新規に追加するリソースをどちらで管理すべきか都度考える必要がある

逆に AWS CDK を選択した場合、特に ローカル開発環境構築の知見が少ない ことが問題になりやすい。サーバーレスアーキテクチャの開発は、クラウドで確認することが主流かもしれない。ただ、クラウドで確認するということは都度デプロイも必要であり、ローカルで完結する開発と比較すると速度は間違いなく落ちる(前提にも書いたように、FrontとAPIでチームが分かれ、ローカルでモック開発ができる等の場合はこの限りでない)。

もし、AWS CDK のデメリットであるローカル開発環境の問題をクリアできれば、サーバーレスアーキテクチャであっても、AWS CDK がベターな選択肢になり得ると私は考える。

ここからは、快適な開発環境を AWS CDK のみで構築する方法を説明する。

API Gateway + Lambda のローカル開発環境構築

以下の3ステップで構築する。

  1. $cdk synth で CloudFormation テンプレートを出力する
  2. $sam build でビルドする
  3. $sam local start-api で起動する

サンプルとして、AWS CDK ソースコードで ApiStack がこのように定義されているとする。

// ...省略...
export class ApiStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);
    const { stage } = props;
    // ...省略...
    const api = new apigateway.RestApi(this, "Api", {
        // ...省略...
    });

    const v1 = api.root.addResource("v1");
    const internal = v1.addResource("internal");

    new UsersApi(this, "UsersApi", {
      stage,
      parentResource: internal,
    });
    // ...省略...
  }
}

ApiStack が参照する UsersApi は Lambda を定義し、API Gateway と紐付ける。

// ...省略...
export class UsersApi extends Construct {
  constructor(scope: Construct, id: string, props: UsersApiProps) {
    super(scope, id);
    const { stage, parentResource } = props;

    const usersCurrentGet = new lambda.DockerImageFunction(this, "UsersCurrentGet", {
      functionName: `${stage}-users-current-get`,
      // ...省略...
    });

    const usersCurrent = users.addResource("current");
    usersCurrent.addMethod(
      "GET",
      new apigateway.LambdaIntegration(usersCurrentGet),
    );
    // ...省略...
  }
}

1. $cdk synth で CloudFormation テンプレートを出力する

ApiStack に対して実行する。

$ cdk synth ApiStack --exclusively --no-staging > cdk.out/api.template.yml
オプション 説明
--exclusively Stack間の依存を無視して対象のStackのみをsynthする
--no-staging SAM CLIでローカルデバッグするため

2. $sam build でビルドする

1.で出力した api.template.yml に対して実行する。

$ sam build --template cdk.out/api.template.yml --parallel
オプション 説明
--parallel ビルドを並列化する(Lambda関数が増加した場合の速度対策)

3. $sam local start-api で起動する

1.で出力した api.template.yml に対して 実行する。このコマンドはv.1.80.0(2023年4月リリース)より、Lambda Authorizer に対応している。

$ sam local start-api \
    --template cdk.out/api.template.yml \
    --port 5000 \
    --container-host host.docker.internal
オプション 説明
--container-host ローカルでエミュレートされた Lambda コンテナのホストを指定。macOS 上の Docker コンテナで AWS SAM CLI を実行する場合は、host.docker.internal を指定。

これにより、AWS CDK で定義した API Gateway + Lambda に対し、 localhost:5000 でアクセス可能になる。

DynamoDB のローカル開発環境構築

以下の3ステップで構築する。

  1. 公式のDockerイメージから DynamoDB local を起動する
  2. $cdk synth で CloudFormation テンプレートを出力する
  3. DynamoDB local にテーブルを作成する

サンプルとして、AWS CDK ソースコードで DynamoDBStack がこのように定義されているとする。

// ...省略...
export class DynamoDBStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);
    const { stage } = props;
    // ...省略...
    const usersTable = new dynamodb.Table(this, "UsersTable", {
      tableName: `${stage}_users`,
      // ...省略...
    });
  }
}

1. 公式のDockerイメージから DynamoDB local を起動する

公式のSetting up DynamoDB local (downloadable version)を参照してローカルで起動する。
※DockerイメージでなくてもOK

2. $cdk synth で CloudFormation テンプレートを出力する

DynamoDBStack に対して実行する。

$ cdk synth DynamoDBStack --exclusively --no-staging > cdk.out/dynamodb.template.yml
オプション 説明
--exclusively Stack間の依存を無視して対象のStackのみをsynthする
--no-staging SAM CLIでローカルデバッグするため

3. DynamoDB local にテーブルを作成する

2.で出力した dynamodb.template.yml に対して自作スクリプトを用いて実行する。Pythonだとこのようなスクリプトになる。CLI の --endpoint に DyanmoDB local のエンドポイントを指定すること。

このスクリプトは、テーブル定義をテンプレートから取得し、一度Jsonファイルに出力、最後にJsonファイルを使って aws dynamodb create-table でテーブルを作成している。

import json
import os
import subprocess

import yaml


TEMPLATE_PATH = "../cdk.out/dynamodb.template.yml"

def get_dynamodb_tables(template_file):
    with open(template_file, "r") as f:
        template = yaml.safe_load(f)
        resources = template["Resources"]
        dynamodb_tables = []
        for _, resource_details in resources.items():
            resource_type = resource_details.get("Type")
            if resource_type == "AWS::DynamoDB::Table":
                properties = resource_details.get("Properties")
                # DynamoDB LocalではPointInTimeRecoverySpecificationがサポートされていないため削除する
                del properties["PointInTimeRecoverySpecification"]
                dynamodb_tables.append(properties)
        return dynamodb_tables


def create_table(table_name, table_file_name):
    command = f"""
        aws dynamodb create-table \
            --cli-input-json file://{table_file_name} \
            --no-cli-pager \
            --endpoint http://host.docker.internal:8000
    """
    child = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    _, stderr = child.communicate()
    rt = child.returncode

    if rt == 0 and not stderr:
        print(f"Succeeded to create table. Table Name: {table_name}")
        return

    print(f"Failed to create table. Table Name: {table_name}")
    print(stderr.decode("utf-8"))
    return


def wait_table_exists(table_name):
    command = f"""
        aws dynamodb wait table-exists \
            --table-name {table_name} \
            --endpoint http://host.docker.internal:8000
    """
    child = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    _, stderr = child.communicate()
    rt = child.returncode

    if rt == 0 and not stderr:
        print(f"Table is active. Table Name: {table_name}")
        return

    print(f"Failed to wait table active. Table Name: {table_name}")
    print(stderr.decode("utf-8"))
    return


if __name__ == "__main__":
    tables = get_dynamodb_tables(TEMPLATE_PATH)

    for table in tables:
        table_name = table["TableName"]
        temp_file_name = f"{table_name}.json"
        with open(temp_file_name, mode="w") as f:
            json.dump(table, f)

        create_table(table_name, temp_file_name)
        os.remove(temp_file_name)

    for table in tables:
        table_name = table["TableName"]
        wait_table_exists(table_name)

Lambda から DynamoDB local にアクセスする

言語やOSにもよるが、DynamoDB アクセス時のエンドポイントに、http://host.docker.internal:8000 といった DynamoDB local のエンドポイントを指定することでアクセスできる。もちろんローカル実行時とクラウド実行時でエンドポイントは変わるので、ローカル実行状態かを AWS_SAM_LOCAL 環境変数で判定し分岐する必要がある。

最後に

こちらのスライドで NestedStack への対応などさらに踏み込んだ内容に触れているのでご興味ある方は見てみてください!
https://speakerdeck.com/whisaiyo/developers-summit-2024

Discussion