CDKで作成したLambdaとAPI GatewayをSAMでローカルに展開する時の躓きポイント

## はじめに

CDKで作成したLambdaとAPI GatewayをローカルでテストするためにSAMを使ってみたら躓きポイントが多かったので備忘録として。

なお、この記事は2024年11月時点での情報です。多くの課題がissueとして報告されており、将来的に解決される可能性があります。

CDKとSAMを組み合わせる基本的な方法については、以下の公式ドキュメントをご参照ください。

https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/serverless-cdk-getting-started.html

検証用のコードは [GitHub](https://github.com/sun-yryr/lambda-cdk-e2e-testing) で公開しています。


## 躓きポイントと解決方法の概要

これらの点に気をつけたスタックを作るとうまくいきました。

- `new aws_lambda.Function` に渡すコンストラクトIDが重複してはいけない。論理IDが異なっていてもダメ
- API Gatewayごとにスタックを分ける
- LayerはLambda関数と同じスタックで作成する
- Lambda aliasを利用しない
- LocalStackへの接続にはLambda Runtimeを自作する

以下、それぞれの躓きポイントと解決方法を詳しく説明します。

## コンストラクトIDが重複してはいけない

CDKでは通常、Scope内でコンストラクトIDが重複しないように構築します。
しかし、SAMでテストするにはこのパターンから外れる必要があります。

```typescript
// ❌ SAMで問題が発生する構成
class LambdaConstruct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const fn = new lambda.Function(this, "Function", {
      runtime: lambda.Runtime.PYTHON_3_12,
      handler: "handler.lambda_handler",
      // ... その他の設定
    });
  }
}

// 使用例
new LambdaConstruct(stack, "1");  // 論理ID: 1Function
new LambdaConstruct(stack, "2");  // 論理ID: 2Function
```

通常、CDKではこうするべきです。
しかし、この構成でローカルに展開するとAPI自体は起動するのですが、リソースが存在しないというエラーが出ます。

```sh
$ sam local start-api -c xxxx.template.json
...
1Function2CE9CA17 not found. Possible options in your template: ['Function']                                                       
```

解決策として、Lambda関数のコンストラクトIDにプレフィックスを含めます。

```typescript
// ✅ SAMで動作する構成
class LambdaConstruct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const fn = new lambda.Function(this, `${id}Function`, {
      runtime: lambda.Runtime.PYTHON_3_12,
      handler: "handler.lambda_handler",
      // ... その他の設定
    });
  }
}

// 使用例
new LambdaConstruct(stack, "Lambda1");  // 論理ID: Lambda1Lambda1Function
new LambdaConstruct(stack, "Lambda2");  // 論理ID: Lambda2Lambda2Function
```

論理ID的には `Lambda1Lambda1Function`、 `Lambda2Lambda2Function` と重複した情報が入ってしまうのですが、ここは割り切りです。

issueは以下で、どうやらCDKがまとめたアセットフォルダをSAMが見つけられなくなるみたいです。

https://github.com/aws/aws-sam-cli/issues/4034


## API Gatewayごとにスタックを分ける

SAMは指定したスタックに含まれる全てのAPI Gatewayを`localhost:3000`に展開します。そのため、複数のAPIを1つのスタックに定義するとパスが衝突する可能性があります。
管理のしやすさでもスタックを分けておくのが良いと思っています。

```typescript
// ✅ APIごとに別スタックを作成
export class Api1Stack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const api = new apigateway.RestApi(this, "Api", {
      deployOptions: { stageName: "dev" },
    });
    api.root.addMethod("GET", new apigateway.LambdaIntegration(lambda1));
  }
}

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

    const api = new apigateway.RestApi(this, "Api", {
      deployOptions: { stageName: "dev" },
    });
    api.root.addMethod("GET", new apigateway.LambdaIntegration(lambda2));
  }
}
```


## LayerはLambda関数と同じスタックで作成する

SAMでLayerをローカルディレクトリから読み込むためには、LayerとLambda関数を同じスタックで定義する必要があります。

```typescript
// ✅ LayerとLambda関数を同じスタックで定義
export class LambdaStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // 同じスタック内でLayerを定義
    const layer = new lambda.LayerVersion(this, "CommonLayer", {
      code: lambda.Code.fromAsset("layer"),
      compatibleRuntimes: [lambda.Runtime.PYTHON_3_12],
    });

    const fn = new lambda.Function(this, "Function", {
      runtime: lambda.Runtime.PYTHON_3_12,
      handler: "handler.lambda_handler",
      layers: [layer],
    });
  }
}
```

なお、別のスタックで構築したLayerであっても、AWSからダウンロードすることで利用できます。（一度AWSにデプロイする必要があるので、開発時のテストには向いていません）

```sh
$ sam local start-api \
    -c xxxx.template.json \
    --parameter-overrides "ParameterKey=<Layerの論理ID>,ParameterValue=<LayerのARN>"
```

issueはこの辺りですね。

https://github.com/aws/aws-sam-cli/issues/4457

https://github.com/aws/aws-sam-cli/issues/3306

## Lambda alias を利用しない

SAM が対応していないため、Lambda alias を利用しないようにします。
context などを利用してテストの時はエイリアスを定義しないようにすると良いです。

```typescript
// ✅ SAM用ではLambda Aliasを作成しない
export class LambdaStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const fn = new lambda.Function(this, "Function", {
      runtime: lambda.Runtime.PYTHON_3_12,
      handler: "handler.lambda_handler",
      // ... その他の設定
    });

    // contextによってLambda Aliasを作成するかどうかを切り替える
    let fnAlias: lambda.Alias | undefined;
    if (this.node.tryGetContext("sam") === "true") {
      fnAlias = new lambda.Alias(this, "Alias", {
        aliasName: "test-alias",
        version: fn.currentVersion,
      });
    }

    const api = new apigateway.RestApi(this, "Api", {
      endpointTypes: [apigateway.EndpointType.REGIONAL],
    });
    // Aliasがあればそれを使い、なければ$LATESTを使う
    api.root.addMethod("GET", new apigateway.LambdaIntegration(fnAlias ?? fn));
  }
}
```

https://github.com/aws/aws-sam-cli/issues/2227

## sam local start-apiからLocalStackに接続する

さて、ここまでの内容に気をつけてスタックを作ると、SAMでローカルサーバーが起動できます。
ただ、多くの場合で他のAWSサービスとの連携が必要になります。LocalStackへ接続できるようにしましょう。
<!-- textlint-disable ja-technical-writing/ja-no-weak-phrase -->
今回はPythonなのでboto3で説明しますが、他のSDKでも同じようにできると思います。
<!-- textlint-enable ja-technical-writing/ja-no-weak-phrase -->

### 1. ネットワーク設定

まず、Lambda関数とLocalStackを同じネットワークに接続します。SAMはDockerで関数を起動するので、LocalStackのネットワークをSAMで指定します。

```yaml:compose.yaml
services:
  localstack:
    image: localstack/localstack
    ports:
      - 4566:4566
    networks:
      - sam-test # カスタムネットワークを指定
networks:
  sam-test:
    driver: bridge
    name: sam-test # SAMから参照する際のネットワーク名
```

```sh
# LocalStackを起動
$ docker compose up -d

# SAMをLocalStackを同じネットワークを指定して起動
$ sam local start-api \
    -c xxxx.template.json \
    --docker-network sam-test
```

### 2. 認証情報の設定

LocalStack用のAWS認証情報を設定します。LocalStackなので適当で大丈夫です。

```sh
# プロファイルの作成
$ aws configure --profile localstack-docker
AWS Access Key ID [None]: dummy
AWS Secret Access Key [None]: dummy
Default region name [None]: ap-northeast-1
Default output format [None]: json

# 作成したプロファイルを利用
$ export AWS_PROFILE=localstack-docker
```

### 3. カスタムランタイムの作成

LocalStackへの接続には `AWS_ENDPOINT_URL` の設定が必要ですが、SAMの環境変数設定では対応できません。
そのため、カスタムランタイムイメージを作成し、そこに `AWS_ENDPOINT_URL` を設定します。

なお、SAMのドキュメントには変数に関連するパラメータがありますが、これらは以下の理由で今回は利用できません。

- `--env-vars [path/to/json]`
    - Lambdaに渡る環境変数。テンプレートに未定義のものはエラーになるため渡せない
- `--container-env-vars`
    - 不明。現状はデバッグ専用のオプションで、環境変数を当てる目的には使えなさそう。
    - [\`local start-api --container-env-vars\` option does not work · Issue #3795 · aws/aws-sam-cli · GitHub](https://github.com/aws/aws-sam-cli/issues/3795)
- `--parameter-overrides`
    - CDK 経由だとかなり使いそう。 `Ref` で参照してるものはこれで置き換える。今回はCDKで定義していないので使えない。

```dockerfile:Dockerfile
# ベースイメージは合うものを選んでください。
FROM public.ecr.aws/lambda/python:3.12-x86_64

# LocalStackのエンドポイントを環境変数として設定。
# Docker Network経由でアクセスするため、ホストはlocalstackになります。
ENV AWS_ENDPOINT_URL=http://localstack:4566
```

### 4. ローカルレジストリの設定

SAMの引数にはイメージのURLを渡す必要があるので、ローカルにRegistryを作ってそこにpushします。

```yaml:compose.yaml
services:
  registry:
    image: registry:2
    ports:
      - 5000:5000
```

```sh
# Registryを起動
$ docker compose up -d

# イメージのビルド
$ docker build -t localhost:5000/sam-test-python:latest -f [作成したDockerfile] .

# イメージのpush
$ docker push localhost:5000/sam-test-python:latest
```

### 5. カスタムランタイムを使用してSAMを起動

最後に、作成したカスタムランタイムを指定してSAMを起動します。

```sh
# 全ての関数で同じランタイムを利用する場合
$ sam local start-api \
    -c xxxx.template.json \
    --docker-network sam-test \
    --invoke-image localhost:5000/sam-test-python:latest

# 関数ごとに別のランタイムを利用することも可能です
$ sam local start-api \
    -c xxxx.template.json \
    --docker-network sam-test \
    --invoke-image <Lambda関数に設定したコンストラクトID>=localhost:5000/sam-test-python:latest \
    --invoke-image <Lambda関数に設定したコンストラクトID>=localhost:5000/sam-test-nodejs:latest
```

これで、Lambda関数がLocalStackに接続された状態でローカルサーバーが起動します。
動作確認は以下の通りです。

```sh
# ローカルサーバーの起動
$ sam local start-api \
    -c xxxx.template.json \
    --docker-network sam-test \
    --invoke-image localhost:5000/sam-test-python:latest

# 別のターミナルでリクエストする
$ curl http://localhost:3000/python
{"message": "\u30ed\u30b0\u304c\u6b63\u5e38\u306b\u4fdd\u5b58\u3055\u308c\u307e\u3057\u305f", "file_name": "logs/request_20241124_123815.json"}
# {"message": "ログが正常に保存されました", "file_name": "logs/request_20241124_123815.json"}
```

## 感想

実際の案件ではこれ+APIのシナリオテストを構築して、正常形がデグレしていないことを自動テストできるようにしています。

詰まりどころさえどうにかなればかなり便利でした。
SAMは最近Terraformを読めるようになったみたいなので、今度はそちらも試してみたいですね。