AWS CDK で Lambda Layers をデプロイする（TypeScript / 複数環境対応）

AWS CDKを使ってLambda Layersを複数環境（開発/検証/本番）にデプロイするプロジェクトを実験的に作りました。cdk等のコマンドをShell Scriptでラップして極力シンプルなコマンドでビルドからデプロイまでを実行できるように試行錯誤しています。ちなみに今回は扱っていませんが、Lambda LayersだけでなくVPCやEC2などもデプロイできます。

## リポジトリ

[42milez / cdk-experiment](https://github.com/42milez/cdk-experiment)

## 機能

- 複数Lambda Layersのデプロイに対応
- 複数環境（開発/検証/本番）に対応
- etc.

## 必要なもの

- [AWSアカウント](https://aws.amazon.com/jp/free)
- [Docker Compose](https://docs.docker.com/compose/install/)
- [aws-sam-cli](https://github.com/aws/aws-sam-cli)
- [jq](https://github.com/stedolan/jq)
- [yq](https://github.com/mikefarah/yq)
- OSは`macOS`もしくは`Linux`を想定

## とりあえずデプロイしてみる

デモ的なやつです。Lambda Layersを含むリソースをdevelopment環境にデプロイしてみます。なお、AWSアカウント取得済みで[Named Profile](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html)も設定済みであることが前提になります。

### 1. Named Profileの指定

[cmd/config.yml](https://github.com/42milez/cdk-experiment/blob/master/cmd/config.yml)の`cli.profile`キーにNamed Profileを指定します。

### 2. Dockerイメージ、およびプロジェクトのビルド

```
docker-compose build
docker-compose run --rm npm install
./cmd/code.sh build --env development
```

`--env` オプションは省略可能です。省略された場合は `development` をデフォルト値として使います。また、環境名は[cmd/config.yml](https://github.com/42milez/cdk-experiment/blob/master/cmd/config.yml)で変更できます。

### 3. CDKToolkitスタックの作成

```
./cmd/cdk.sh bootstrap
```

bootstrapプロセスについて詳しく知りたい場合は、オフィシャルリポジトリの[aws-cdk/design/cdk-bootstrap.md](https://github.com/aws/aws-cdk/blob/master/design/cdk-bootstrap.md)を参照してください。

### 4. レイヤーを含むスタックのデプロイ

`src/stack/service2.ts`が対象スタック、`src/resource/service2`に各リソースの定義ファイルが含まれています。`src/function`はLambda関数とレイヤーのソースを含みます。ちなみに`src/stack/service1.ts`は空のスタックです。VPCなどのサンプル定義を書こうと思ってますが、まだ手を付けてません。

```
./cmd/cdk.sh deploy --env development --stack 'service2'
```

こんな感じでデプロイが進みます。

```
❯ ./cmd/cdk.sh deploy --env development --stack 'service2'
Creating cdk-experiment_cdk_run ... done
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬─────────────────────────────────────────────────┬────────┬──────────────────────────────────────────────────────────┬──────────────────────────────┬───────────┐
│   │ Resource                                        │ Effect │ Action                                                   │ Principal                    │ Condition │
├───┼─────────────────────────────────────────────────┼────────┼──────────────────────────────────────────────────────────┼──────────────────────────────┼───────────┤
│ + │ ${LambdaRole.Arn}                               │ Allow  │ sts:AssumeRole                                           │ Service:lambda.amazonaws.com │           │
├───┼─────────────────────────────────────────────────┼────────┼──────────────────────────────────────────────────────────┼──────────────────────────────┼───────────┤
│ + │ *                                               │ Allow  │ xray:PutTelemetryRecords                                 │ AWS:${LambdaRole}            │           │
│   │                                                 │        │ xray:PutTraceSegments                                    │                              │           │
├───┼─────────────────────────────────────────────────┼────────┼──────────────────────────────────────────────────────────┼──────────────────────────────┼───────────┤
│ + │ arn:aws:logs:${AWS::Region}:${AWS::AccountId}:* │ Allow  │ logs:CreateLogGroup                                      │ AWS:${LambdaRole}            │           │
│   │                                                 │        │ logs:CreateLogStream                                     │                              │           │
│   │                                                 │        │ logs:PutLogEvents                                        │                              │           │
└───┴─────────────────────────────────────────────────┴────────┴──────────────────────────────────────────────────────────┴──────────────────────────────┴───────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
service2-development: deploying...
[0%] start: Publishing b6b4159901c4c58ae071d371127d68a28c9e264a3583c7b77013c805a8917605:current
[33%] success: Published b6b4159901c4c58ae071d371127d68a28c9e264a3583c7b77013c805a8917605:current
[33%] start: Publishing e392921aca9408c62afcbbebef20da8635c2c1fbc99fe88aeb87d991a66a17a3:current
[66%] success: Published e392921aca9408c62afcbbebef20da8635c2c1fbc99fe88aeb87d991a66a17a3:current
[66%] start: Publishing f9caa1f9683d6033e1b15d578f01eec056ec166e9aec850c67456e9691e48fee:current
[100%] success: Published f9caa1f9683d6033e1b15d578f01eec056ec166e9aec850c67456e9691e48fee:current
service2-development: creating CloudFormation changeset...
[██████████████████████████████████████████████████████████] (8/8)






 ✅  service2-development
```

マネージメントコンソールを確認すると１つのLambda関数と２つのレイヤーが作成されているのがわかります（レイヤーは２つとも関数にアタッチされています）。

![lambda.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95255/783d665d-76bc-bbd9-2a36-0228701b4a43.png)

### 5. 他の環境へのデプロイする

staging環境へのデプロイ。staging用のレイヤーを用意する必要があるのでビルドから始めます。

```
./cmd/code.sh build --env staging
./cmd/cdk.sh deploy --env staging --stack 'service2'
```

production環境へのデプロイ。こちらもビルドからやります。

```
./cmd/code.sh build --env production
./cmd/cdk.sh deploy --env production --stack 'service2'
```

これで全ての環境へリソースがデプロイされました。

## 解説を少し

Lambda Layersを環境別にデプロイする仕組みがちょっと複雑です。まず、ソースコードを準備します。今回の例だと`src/function/layer`がそれにあたります。開発時はここへ収めたレイヤーへの参照を解決するため、以下のとおり`package.json`にレイヤーのパスを含めています。これを`npm install`すると`node_modules`にシンボリックリンクが作成されます。

```
"base-layer-1-cdk-experiment-development": "file:./src/function/layer/base1",
"base-layer-2-cdk-experiment-development": "file:./src/function/layer/base2"
```

また、Lambda Layersをデプロイする際にはそれ自身のディレクトリ構成が以下のとおりでないといけません。

```
layer.out
└── development
    ├── base1
    │   └── nodejs
    │       ├── node_modules
    │       ├── package-lock.json
    │       └── package.json
    └── base2
        └── nodejs
            ├── node_modules
            ├── package-lock.json
            └── package.json
```

`base1`と`base2`がレイヤーです。`nodejs`ディレクトリの下に`node_modules`ディレクトリを含む上記構造でないとLambda関数からレイヤーを参照できません。なので`./cmd/code.sh build`の実行時にこの構造になるようにゴニョゴニョします。該当するコードは`./cmd/code.sh`の[この部分](https://github.com/42milez/cdk-experiment/blob/4f88010765a9fb08a4974ee6c2725cd7aae9d11a/cmd/code.sh#L98-L146)です。

```shell
: 'PREPARE LAMBDA LAYER' &&
{
  readonly LAMBDA_LAYER_SRC_DIR="${PROJECT_ROOT}/src/function/layer"
  readonly LAMBDA_LAYER_DEST_DIR="layer.out/${ENV}"

  printf 'Lambda Layer Source: %s\n' "${LAMBDA_LAYER_SRC_DIR}"
  printf 'Lambda Layer Target: %s\n' "${LAMBDA_LAYER_DEST_DIR}"

  commands=()

  while read -r dir; do
  {
    install_dir="${LAMBDA_LAYER_DEST_DIR}/${dir}/nodejs"
    mkdir -p "${install_dir}"
    cp "${LAMBDA_LAYER_SRC_DIR}/${dir}/package.json" "${install_dir}"

    # add 'dependencies' member if not exists
    if [ "$(jq '.dependencies?' "${install_dir}/package.json")" = 'null' ]; then
    {
      cat < "${install_dir}/package.json"     \
        | jq ". |= .+ {\"dependencies\": {}}" \
        > "${install_dir}/package-tmp.json"   \
      && mv "${install_dir}/package-tmp.json" "${install_dir}/package.json"
    }
    fi

    layer_name=$(jq -r '.name' "${install_dir}/package.json")

    # add self as a dependency
    cat < "${install_dir}/package.json"                                                                \
      | jq ".dependencies |= .+ {\"${layer_name}-${ENV}\": \"../../../../src/function/layer/${dir}\"}" \
      > "${install_dir}/package-tmp.json"                                                              \
    && mv "${install_dir}/package-tmp.json" "${install_dir}/package.json"

    printf '%s\n' "$(cat "${install_dir}/package.json")"

    # install 'dependencies' without 'devDependencies'
    commands+=("npm --prefix ${install_dir} install --production")
  }
  done < <(find "${LAMBDA_LAYER_SRC_DIR}" -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)

  cmd=$(IFS=',' tmp="${commands[*]}" ; echo "${tmp//,/ && }")

  printf 'Commands To Be Executed: %s\n' "${cmd}"

  $BASH_CMD "${cmd}"

  printf '%s\n' "$(find ${LAMBDA_LAYER_DEST_DIR} -maxdepth 3)"
}
```

ざっくりとした流れは次のとおりです（すべてのレイヤーに対して同様の手続きを繰り返します）。

1. ターゲットディレクトリに`nodejs`ディレクトリを作成して、さらに`src/function/layer`から`package.json`をコピー
2. コピーした`package.json`の`dependencies`にレイヤー自身を追加（レイヤーをパッケージとしてインストールするため）
3. `npm install`でレイヤーをインストール

## おわりに

AWSよりもShell Scriptのスキルレベルが上がった気がする :thinking: