AWS CDK(TypeScript) + AWS SAMでImageタイプのLambdaをローカル実行する

## 概要

- `sam local ***`(invokeやstart-apiなど)を実行する前に、`sam build -t cdk.out/***.template.json`を実行することでローカルでDockerイメージをビルド出来る
- TypeScriptやJavaScriptのイメージを作成したい場合、pnpmのワークスペース機能を有効＋[shared-workspace-lockfile](https://pnpm.io/ja/npmrc#shared-workspace-lockfile)設定を`false`にする、という選択肢がある


## 0. 動機など

まず、極力ローカル環境でLambdaなどの開発を進めたい状況とする。

その際、AWS CDKとAWS SAMを組み合わせることでLambdaなどをローカル実行する機能があり、それを使うと開発が便利になる。
ZipタイプのLambdaの場合、公式デベロッパーガイドの[AWS SAMとAWS CDKの開始方法](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/serverless-cdk-getting-started.html)や、DevelopersIOの[SAM を使って CDK + Typescript で実装した Lambda をローカル環境で実行する](https://dev.classmethod.jp/articles/sam-local-cdk-typescript/)などを参考にすると良い。
が、**「ImageタイプのLambdaはどうするのか？」** については意外とハマったので備忘録にメモする。

気づいてしまえば、結論としては[概要](#概要)に書いた通りでそれ以上のことは特に無いが、一応まとめておくことにする。

## 1. 前提条件

### 1-1. 前提知識

cdkおよびsam、およびnodeによるプロジェクト作成・管理のごく基本の部分は既知としている。

### 1-2. 検証した環境やツールのバージョンなど

- WSL2@Windows11
- Ubuntu 22.04LTS
- nodejs v18.12.1(ホストOS)
- pnpm@7.17.0
- sam-cli@1.65.0
- aws-cdk@2.51.1
- Docker version 20.10.21, build baeda1f

## 2. 検証

### 2-1. samによるimageタイプのLambdaのサンプルの生成

本質的ではない部分だが、ImageタイプのLambdaサンプルを生成するのには多分最適？なのでsamで作っておく。
詳細は長いので折りたたみ表示にするとして、大まかには

- Hello World Example
- ランタイム: Nodejs 18.x
- Imageタイプ

を選択している。

<details><summary>（sam initのオプション選択詳細）</summary><div>

```sh
sam init

You can preselect a particular runtime or package type when using the `sam init` experience.
Call `sam init --help` to learn more.

Which template source would you like to use?
        1 - AWS Quick Start Templates
        2 - Custom Template Location
Choice: 1

Choose an AWS Quick Start application template
        1 - Hello World Example
        2 - Multi-step workflow
        3 - Serverless API
        4 - Scheduled task
        5 - Standalone function
        6 - Data processing
        7 - Infrastructure event management
        8 - Serverless Connector Hello World Example
        9 - Multi-step workflow with Connectors
        10 - Lambda EFS example
        11 - Machine Learning
Template: 1

Use the most popular runtime and package type? (Python and zip) [y/N]: N

Which runtime would you like to use?
        1 - aot.dotnet7 (provided.al2)
        2 - dotnet6
        3 - dotnet5.0
        4 - dotnetcore3.1
        5 - go1.x
        6 - graalvm.java11 (provided.al2)
        7 - graalvm.java17 (provided.al2)
        8 - java11
        9 - java8.al2
        10 - java8
        11 - nodejs18.x
        12 - nodejs16.x
        13 - nodejs14.x
        14 - nodejs12.x
        15 - python3.9
        16 - python3.8
        17 - python3.7
        18 - ruby2.7
        19 - rust (provided.al2)
Runtime: 11

What package type would you like to use?
        1 - Zip
        2 - Image
Package type: 2

Based on your selections, the only dependency manager available is npm.
We will proceed copying the template using npm.

Would you like to enable X-Ray tracing on the function(s) in your application?  [y/N]: N

Project name [sam-app]:

Cloning from https://github.com/aws/aws-sam-cli-app-templates (process may take a moment)

    -----------------------
    Generating application:
    -----------------------
    Name: sam-app
    Base Image: amazon/nodejs18.x-base
    Architectures: x86_64
    Dependency Manager: npm
    Output Directory: .

    Next steps can be found in the README file at ./sam-app/README.md


    Commands you can use next
    =========================
    [*] Create pipeline: cd sam-app && sam pipeline init --bootstrap
    [*] Validate SAM template: cd sam-app && sam validate
    [*] Test Function in the Cloud: cd sam-app && sam sync --stack-name {stack-name} --watch
```

</div></details>

生成されたプロジェクトは以下のような感じ：

```sh
cd sam-app
tree .

.
├── README.md
├── events
│   └── event.json
├── hello-world
│   ├── Dockerfile
│   ├── app.mjs
│   ├── package.json
│   └── tests
│       └── unit
│           └── test-handler.mjs
└── template.yaml

4 directories, 7 files
```

`hello-world`ディレクトリ以下が目当ての部分(今回はcdkでインフラ部分を記述する)となる。

参考に、Lambdaの本体である`app.mjs`は

```javascript:app.mjs
export const lambdaHandler = async (event, context) => {
    try {
        return {
            'statusCode': 200,
            'body': JSON.stringify({
                message: 'hello world',
            })
        }
    } catch (err) {
        console.log(err);
        return err;
    }

    return response
};
```

のようになっており、API Gateway経由で呼ぶと`{"message": "hello world"}`を返す。

また、初期状態では`package.json`しかなく、lockファイルが無いので適宜追加する。
個人的にはpnpmを使っている(+後述の通り、workspace機能との連携が良い)ので、
必要に応じてDockerfileを以下のような感じで書き換えておく

```dockerfile:Dockerfile
FROM public.ecr.aws/lambda/nodejs:18

COPY app.mjs package*.json pnpm-lock.yaml ./

# RUN npm install
RUN corepack enable pnpm
RUN pnpm install --prod --frozen-lockfile
CMD ["app.lambdaHandler"]
```

後で、`hello-world`ディレクトリ以下をcdkプロジェクト内に移植する。

### 2-2. CDKプロジェクトの作成

#### 2-2-1. ワークスペースの設定

今回は`cdk-app`という名前で作成する。

まずは`cdk-app`という名前のディレクトリを作成し、そのディレクトリに移動して`cdk init`を実行する。

```sh
mkdir cdk-app
cd cdk-app

# 執筆時点でのcdk最新版は2.51.1
npx aws-cdk@2.51.1 init --language typescript

# 今回はpnpmでパッケージ管理をしたいので、package-lock.jsonは削除してpnpmで再インストールしておく
rm package-lock.json
pnpm install
```

この時点でプロジェクト内に作成されたファイル・ディレクトリは以下のようになっている。

```sh
tree -a -I ".git|node_modules"
.
├── .gitignore
├── .npmignore
├── README.md
├── bin
│   └── cdk-app.ts
├── cdk.json
├── jest.config.js
├── lib
│   └── cdk-app-stack.ts
├── package.json
├── pnpm-lock.yaml
├── test
│   └── cdk-app.test.ts
└── tsconfig.json

3 directories, 11 files
```

ここで、先ほどsamの方で作ったImageタイプのLambdaのコードをcdkプロジェクト内で管理したいが、
設定を変えずにそのまま持ってこようとするとパッケージ管理がうまく動作しないため、[workspace](https://pnpm.io/ja/workspaces)機能を使ったモノレポ構成にする。

まずはcdkプロジェクトのルートディレクトリ直下に`pnpm-workspace.yaml`を作成する。
内容については[公式](https://pnpm.io/ja/pnpm-workspace_yaml)を参考に例えば:

```yaml:pnpm-workspace.yaml
packages:
  # all packages in direct subdirs of packages/
  - 'packages/*'
```

のようにしておく。
この場合、例えば`packages`ディレクトリを作成してその下にnodeプロジェクトを置いていくとwork

次に、このままの設定だとロックファイル: `pnpm-lock.yaml`はcdkプロジェクトのルート直下のみに生成されることになる。
LambdaのイメージをDockerfileで作成する際に難しくなるため、Lambdaのファイルで使うパッケージ分のロックファイルはcdkプロジェクト本体とは別に、Lambda側で出来ていると容易になる。

pnpmだと、[`.npmrc`を作成して設定をデフォルトから変える](https://pnpm.io/ja/npmrc#shared-workspace-lockfile)ことでワークスペース毎にロックファイルを生成することが可能になる。

すなわち、

```ini:.npmrc
shared-workspace-lockfile=false
```
の内容で`.npmrc`を作成して、cdkプロジェクトのルートディレクトリに置いておけば良い。

あとは先ほどsam cliで作ったプロジェクトの`hello-world`ディレクトリを`packages`以下に置き、更にlockファイルを作っておく。

```sh
# workspace用ディレクトリ作成
mkdir packages

# sam-cliで作ったImageタイプのLambdaのプロジェクトを持ってくる
cp -r /path/to/sam-app/hello-world/ packages/

# 追加したworkspace分のパッケージのインストール＋lockファイル作成
pnpm install 
```

ここまでで、cdkプロジェクトにおけるディレクトリ・ファイルは以下のような形になる。

```sh
tree -a -I ".git|node_modules"
.
├── .gitignore
├── .npmignore
├── .npmrc ★ 追加
├── README.md
├── bin
│   └── cdk-app.ts
├── cdk.json
├── jest.config.js
├── lib
│   └── cdk-app-stack.ts
├── package.json
├── packages ★ 追加
│   └── hello-world ★ 2-1.で作成したものから移植
│       ├── .npmignore
│       ├── Dockerfile
│       ├── app.mjs
│       ├── package.json
│       ├── pnpm-lock.yaml ★ 追加(自動生成)
│       └── tests
│           └── unit
│               └── test-handler.mjs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml ★ 追加
├── test
│   └── cdk-app.test.ts
└── tsconfig.json

7 directories, 19 files
```

#### 2-2-2. スタックの作成と実行

次に、追加したImageタイプLambda部分に相当するcdkのコードを記述していく。

今回の例ではAPI Gatewayから呼び出されて使われる想定のLambdaなので、例えば以下のようにしておく。

```typescript:lib/cdk-app-stack.ts
import {
  Stack,
  StackProps,
  aws_apigateway as apigateway,
  aws_lambda as lambda,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';

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

    const imageTypeLambda = new lambda.DockerImageFunction(this, 'DockerImageFunction', {
      // ↓cdkプロジェクトのルートから見て、対象のDockerfileが置いてあるパスを指定
      code: lambda.DockerImageCode.fromImageAsset('packages/hello-world'),
    });

    const api = new apigateway.RestApi(this, 'example api');
    const hello = api.root.addResource('hello');
    hello.addMethod('GET', new apigateway.LambdaIntegration(imageTypeLambda))
  }
}
```

元々のsamによって作ったサンプルプロジェクトに倣って、
`/hello`へのGETリクエストでLambdaが動作するようにしている。

（詳細は公式リファレンスの[aws_apigateway.RestApi](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_apigateway.RestApi.html)および[aws_apigateway.LambdaIntegration](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_apigateway.LambdaIntegration.html)、[aws_lambda.DockerImageFunction](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda.DockerImageFunction.html)部分なども参考のこと。）


ここで、

```sh
pnpm cdk synth
```

などとすることで、`lib/cdk-app-stack.ts`の内容を元にCloudFormationテンプレートを生成出来る。
この場合は`cdk.out/CdkAppStack.template.json`にテンプレートファイルが作成されている。


この状態で、[公式ガイド](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/serverless-cdk-getting-started.html)のステップ4: Lambda関数をテストする に倣って、

```sh
sam local start-api -t ./cdk.out/CdkAppStack.template.json
```
を実行するとローカルでapiの立ち上げとLambdaの実行が出来る。。。はずなのだが、Imageタイプの場合は後述の準備をしておかないと以下のような感じでエラーが出る。

```sh
# localhost:3000 でapi gatewayの模擬環境に相当するローカルサーバーが立ち上がる
sam local start-api -t cdk.out/CdkAppStack.template.json

# Lambdaを呼び出すため、lib/cdk-app-stack.tsで設定したパス /helloをGETで叩く
curl -X GET localhost:3000/hello
```

<details><summary>(エラー内容詳細)</summary><div>

```sh
2022-11-21 23:54:23  * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)
Invoking Container created from dockerimagefunction28b773e6
Image was not found.
Removing rapid images for repo dockerimagefunction28b773e6
Building image......
Failed to build Docker Image
NoneType: None
Exception on /hello [GET]
Traceback (most recent call last):
  File "flask/app.py", line 2073, in wsgi_app
  File "flask/app.py", line 1518, in full_dispatch_request
  File "flask/app.py", line 1516, in full_dispatch_request
  File "flask/app.py", line 1502, in dispatch_request
  File "samcli/local/apigw/local_apigw_service.py", line 361, in _request_handler
  File "samcli/commands/local/lib/local_lambda.py", line 144, in invoke
  File "samcli/lib/telemetry/metric.py", line 277, in wrapped_func
  File "samcli/local/lambdafn/runtime.py", line 177, in invoke
  File "samcli/local/lambdafn/runtime.py", line 88, in create
  File "samcli/local/docker/lambda_container.py", line 94, in __init__
  File "samcli/local/docker/lambda_container.py", line 236, in _get_image
  File "samcli/local/docker/lambda_image.py", line 165, in build
  File "samcli/local/docker/lambda_image.py", line 279, in _build_image
samcli.commands.local.cli_common.user_exceptions.ImageBuildException: Error building docker image: pull access denied for dockerimagefunction28b773e6, repository does not exist or may require 'docker login': denied: requested access to the resource is denied
2022-11-21 23:56:24 127.0.0.1 - - [21/Nov/2022 23:56:24] "GET /hello HTTP/1.1" 502 -
```
</div></details>

ローカルでImageタイプのLambdaをエミュレートしようとしているため、当然Lambdaのコンテナイメージが必要であり、デフォルトでは実際のAWS環境から取得してこようとする様子。
ただ、今回の例では一切実際のクラウド環境は使っておらず、イメージのPullも出来ないため当然失敗する。

対応策として、[sam build](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-build.html)コマンドでローカルでイメージをビルドしておけば問題は解決する。（クラウド上にイメージを置いてPullする、などの必要はなくローカルで完結出来る）

この際、`sam local start-api`や`sam local invoke`などを実行するときと同様、buildを行うときもcdkで作ったテンプレートを指定することに注意。（デフォルトではカレントディレクトリの`template.yml`を見に行こうとする）

```sh
sam build -t cdk.out/CdkAppStack.template.json
```

これによってDockerのビルドプロセスが走り、最終的に

```
Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided
```

のような表示が出てきたら、無事にイメージのビルドが完了したことが確認出来る。

この後に再度`sam local`コマンドでローカル実行を試すと今度はLambdaを問題無く実行出来る。

```sh
# localhost:3000 でapi gatewayの模擬環境に相当するローカルサーバーが立ち上がる
sam local start-api -t cdk.out/CdkAppStack.template.json

# Lambdaを呼び出すため、lib/cdk-app-stack.tsで設定したパス /helloをGETで叩く
curl -X GET localhost:3000/hello
# {"message":"hello world"}
# ↑今度は無事にLambdaの返したレスポンスを受け取れる
```

## 補足、感想など

- 個人的には基本TypeScriptで統一して書きたいところがあったので、cdkプロジェクトも実行するLambdaもnodejsでプロジェクト作成しており、その関係でワークスペース機能（モノレポ）を使っている。が、例えばcdkプロジェクトはTypeScriptでLambda（Image）のランタイムはPythonなどnode以外ということであればモノレポ周りの考慮は必須では無くなるので、その辺に関しては楽かもしれない
    * あまりちゃんと調べていないが、少し眺めた程度だと`npm`や`yarn v1`ではlockファイルをワークスペース毎に作る設定は見つからなかった？ので、pnpmを使わない場合だとやりづらい部分が出てくるかもしれない（調べ足りないだけの可能性もあるのでそこはご容赦）。