AWS CDK + Typescript 環境で lambda layer を上手く管理する

aws-cdk を使って lambda function の開発をしている際に、良さげに出来た lambda layer 管理の方法をメモしておこうと思ったので記しておきます。
初学者なのでもっと良い方法はあるかもです。。

## 動機
* `import { hoge } from 'layer'` みたいに lambda layer にしたコードを使いたい
* vendor ライブラリも実装者が特に意識せず使いたい(vendor ライブラリも layer にしておく必要があります)
* `package.json` は１つだけで良い

という事を解決し、かつ簡単にその環境を用意したいという背景からです。

## 動作確認環境
* typescript: 3.8.3
* aws-cdk: 1.26.0
* aws-sam-cli: 0.40.0

## まずは簡単な lambda function を用意しておく

以下は、 `cdk init --language typescript` で作成した cdk の環境を基に書いていきます。
プロジェクト名： cdk-project

```sh
$ tree -I node_modules -L 3
.
├── README.md
├── bin
│   └── cdk-project.ts
├── cdk.json
├── jest.config.js
├── lib
│   └── cdk-project-stack.ts
├── package-lock.json
├── package.json
├── test
│   └── cdk-project.test.ts
└── tsconfig.json
```

簡単に `Hello World` を返す lambda function を作成しておきます。

```sh
$ mkdir -p dir lambda/helloWorld
$ vi lambda/helloWorld/index.ts
```

```ts
// lambda/helloWorld/index.ts
import { APIGatewayEvent, Callback, Cotnext } from 'aws-lambda'

exports.handler = async (event: APIGatewayEvent, context: Context, callback: Callback) => {
  callback(null, 'Hello World')
}
```

lambda を作成するために `lib/cdk-project-stack.ts` を以下のように修正する。

```ts
// lib/cdk-project-stack.ts
import * as cdk from '@aws-cdk/core'
import * as lambda from '@aws-cdk/aws-lambda'

export class CdkProjectStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    const helloWorldLambda = new lambda.Function(this, 'helloWorldLambda', {
      code: lambda.Code.fromAsset('lambda/helloWorld'),
      handler: 'index.handler',
      runtime: lambda.Runtime.NODEJS_12_X
    })
  }
}

// bin/cdk-project.ts にも書かれているがコード記載省略のため
const app = new cdk.App()
new CdkProjectStack(app, 'HelloWorldCdkApp')
app.synth()
```

ここで一旦 sam で確認をしてみる。

```sh
$ yarn build
$ cdk synth
$ sam local invoke -t cdk.out/HelloWorldCdkApp.template.json
Invoking index.handler (nodejs12.x)

Fetching lambci/lambda:nodejs12.x Docker container image......
(省略)

"Hello World"
```

`Hello World` が返ってくる事を確認できた。

## lambda layer を用意する

本題です。
lambda layer を deploy するには以下のルールがあります。

* zip で S3 にアップロードされている
* zip 展開後の構造が `nodejs/node_modules/` となっている

aws-cdk を用いた場合、 指定したディレクトリを zip にして S3 へアップロードはしてくれるのですが、 `nodejs/node_modules/` という構造にはこちら側でしておく必要があります。
ここが厄介なのですが、

> `import { hoge } from 'layer'` みたいに lambda layer にしたコードを使いたい

をしようとして

```sh
.
├── lambda
│   └── helloWorld
└── layer
```

と単純に行うと 「vendor ライブラリは？ まとめて lambda layer にするのめんどそう。。」となり

```sh
.
├── lambda
│   └── helloWorld
└── layer
    └── nodejs
        └── nodejs
            └── node_modules
```

とすると、 「え！ vendor ライブラリ含める場合 `nodejs` 以下に `package.json` 用意して管理する？しかも、 import 文気持ち悪い。。どうしよう」となり、

> vendor ライブラリも実装者が特に意識せず使いたい(aws-cdkの場合 vendor ライブラリも layer にしておく必要があります)
`package.json` は１つだけで良い

ここら辺上手く出来ないじゃんとなりました。

結論としては以下のような構造とする事で解決しました。(上記前者の方法を使ってるんですけどねw)

1. 独自共通化処理を `layer` ディレクトリ以下で管理する
2. root の dependencies に `layer` ディレクトリをパス指定で追加する
3. root の dependencies に lambda layer として使いたい vendor ライブラリを追加する
4. root の dependencies に指定したライブラリを `layer.out/nodejs/node_modules/` の構造にして `yarn install` する

`layer.out/nodejs/node_modules/` 膨れるやんって思った方は devDependencies に移すべきものがないか検討してみてください。

### 1. 独自共通化処理を `layer` ディレクトリ以下で管理する

```sh
.
├── lambda
│   └── helloWorld
└── layer
    └── utils
    │   └── index.ts
    ├── ...
    └── index.ts
```

簡単に数値を加算する関数を layer に追加しておく。

```ts
// layer/utils/index.ts
export const sum = (num1: number, num2: number): number => num1 + num2
```

`layer` 以下で共通化処理を管理し、 `layer/index.ts` でまとめて export します。

```ts
// layer/index.ts
export * from './utils'
...
```

### 2. root の dependencies に `layer` ディレクトリをパス指定で追加する

`package.json` を以下のように修正。

```json
{
  ...
  "dependencies": {
    "layer": "file:./layer"
  }
}
```

### 3. root の dependencies に lambda layer として使いたい vendor ライブラリを追加する

vendor コードの例として `dayjs` も dependencies に追加しておきます。

```json
{
  ...
  "dependencies": {
    "dayjs": "^1.8.22",
    "layer": "file:./layer"
  }
}
```

### 4. root の package.json の dependencies に指定したライブラリを `layer.out/nodejs/node_modules/` の構造にして `yarn install` する

dependencies に指定したライブラリを指定の構造で install する preprocess script を作成します。

```ts
// lib/layerSetup.ts
import * as childProcess from 'child_process'

const LAMBDA_LAYER_DIR_NAME = './layer.out/nodejs/node_modules/'
export const bundleLayer = () => childProcess.execSync(`yarn install --production --modules-folder ${LAMBDA_LAYER_DIR_NAME}`)
```

```ts
// bin/cdk-project.ts
import ...
import { bundleLayer } from '../lib/layerSetup'

// pre-process
bundleLayer()

const app = new cdk.App()
new CdkProjectStack(app, 'HelloWorldCdkApp')
```

あとは tsc でトランスパイルする前に `layer` を upgrade しておけば、 layer 更新した際も反映が楽になるので、 `npm scripts` を

```json
{
  ...
  "scripts": {
    "build": "yarn upgrade layer && tsc",
    ...
  }
}
```

としておけば良さそう。

あとは stack に lambda layer を追加してあげて

```ts
import * as cdk from '@aws-cdk/core'
import * as lambda from '@aws-cdk/aws-lambda'

export class CdkProjectStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    const layer = new lambda.LayerVersion(this, 'layer', {
      compatibleRuntimes: [lambda.Runtime.NODEJS_12_X],
      code: lambda.Code.fromAsset('layer.out'),
    })

    const helloWorldLambda = new lambda.Function(this, 'helloWorldLambda', {
      code: lambda.Code.fromAsset('lambda/helloWorld'),
      handler: 'index.handler',
      runtime: lambda.Runtime.NODEJS_12_X,
      layers: [layer]
    })
  }
}
```

```sh
$ yarn build
$ cdk synth
```

とすれば、 preprocess で `layer.out/nodejs/node_modules/...` が作成され、 `layer.out` を layer として設定してくれる。

## sam で lambda layer の動作確認

helloWorldLambda を雑に lambda layer の関数を使うように修正しておく。

```ts
import { APIGatewayEvent, Callback, Context } from 'aws-lambda'
import * as dayjs from 'dayjs'
import { sum } from 'layer'
dayjs.locale('ja')

exports.handler = async (event: APIGatewayEvent, context: Context, callback: Callback) => {
  console.log('Hello World')
  console.log(sum(1, 2)) // 3
  callback(null, dayjs('2020-01-01'))
}
```

```sh
$ yarn build
$ cdk synth
$ sam local invoke -t ./cdk.out/HelloWorldCdkApp.template.json
Invoking index.handler (nodejs12.x)
layer27F209B1 is a local Layer in the template
Building image...
Requested to skip pulling images ...

(省略)
...	INFO	Hello World
...	INFO	3
(省略)

"2020-01-01T00:00:00.000Z"
```

出来た。

ちなみに vendor code を lambda layer にしておかないと

```sh
{"errorType":"Runtime.ImportModuleError","errorMessage":"Error: Cannot find module 'dayjs'\nRequire stack:\n- /var/task/index.js\n- /var/runtime/UserFunction.js\n- /var/runtime/index.js"}
```

みたいになる。

## まとめ

* dependencies で lambda layer を管理できる体制にする。(dependencies と devDependencies を区別する意識をつけてこう)
* preprocess で dependencies を `nodejs/node_modules` の形式にする。
* npm scripts で synth までのコマンドをあまり意識しなくて良いようにする。

synth する前に `cdk.out` や `layer.out` なんかを削除しておかないと上手く更新しない場合もあったりするので `cdk:synth` みたいな npm scripts 用意して `cdk synth` 前に clean しておく処理挟むと良さそうです。（ここら辺の aws-cdk の挙動はまだ調べられていない）

以上です。
もっとこうしたら良いよ！っていうのがあったら教えてください！