yarn workspacesとCDKでLambda Layersを管理する

2020.06.02

CDKでLambda Layersをデプロイする方法は色々と模索されており, 弊社でもプリプロセスでいい感じに処理するブログも出てたりします.
Node.jsの場合はnode_moduelesをレイヤに固めるのは大抵の開発で必要になるのでCDKでも楽に扱いたいですよね.
今回はyarn workspacesと組み合わせることである程度楽に管理できるようになった気がするので紹介します.

概要

yarn workspacesは複数パッケージを同一リポジトリで管理するツールです.
1つのリポジトリで複数のLambda関数を複数のパッケージとして, そしてnode_modulesをroot配下で1つに管理できます.
なのでこれを利用して下記のような対応を行います.

  • node_modulesを1つのディレクトリで管理してlayersにデプロイする
  • @lambdaディレクトリ配下でlambda関数を管理して, buildをnpm scriptsに登録する
  • ルートディレクトリでパッケージやnode_modules, CDKの管理を頑張る

大まかなディレクトリ構成は下記のようになります.

.
├── @lambda // workspaces でパッケージとして管理するファイル群
│   └── handler
│       ├── __tests__
│       │   └── index.test.ts
│       ├── index.ts
│       ├── package.json
│       └── tsconfig.json
├── .build // Lambda Layersで利用するnode_modulesが入るディレクトリ
├── bin
│   └── index.ts
├── lib
│   └── lambda.ts
├── cdk.json
├── package.json
├── readme.md
├── test
│   ├── basic.test.d.ts
│   ├── basic.test.js
│   └── basic.test.ts
├── tsconfig.json
└── yarn.lock

言葉では伝わらない思いを伝えるためにコードベースで紹介していきます.

環境について

下記環境を想定しています.

name value
@aws-cdk 1.41.0
typescript 3.7.2
node 14.3.0

プロジェクトの指定

まずはルートディレクトリ配下で通常通りCDKの設定をしていきます.

$ npx cdk init app --language=typescript

yarn workspacesを利用するためにpackage.jsonに変更を加えます.
privateをtrueにした上で「@lambda」ディレクトリ配下にあるものを全てyarn workspacesで管理するパッケージとして扱います.
ついでにCDKで利用する依存関係については全てdevDependenciesに寄せておいてください. 後ほど説明しますが, lambda layersに一緒にデプロイされて無駄に容量を圧迫してしまいます.

package.json

{
  "name": "cdk",
  "private": true,
  "workspaces": ["@lambda/*"],
  "version": "0.1.0",
  "scripts": {},
  "devDependencies": {
    "@aws-cdk/assert": "1.41.0",
    "@aws-cdk/core": "1.41.0",
    "@aws-cdk/aws-lambda": "^1.41.0",
    "@aws-cdk/aws-logs": "^1.41.0",
    "@types/jest": "^25.2.1",
    "@types/node": "10.17.5",
    "aws-cdk": "1.41.0",
    "jest": "^25.5.0",
    "ts-jest": "^25.3.1",
    "ts-node": "^8.1.0",
    "typescript": "~3.7.2",
    "source-map-support": "^0.5.16"
  },
  "dependencies": {}
}

さらにpackage.jsonの中身を変更します.
具体的にはnpm scriptsに, パッケージとなるLambda関数をビルドするスクリプトと, node_modulesをディレクトリ指定してダウンロードするスクリプトを追加します.
「@lambda:build」ではパッケージの中にあるpackage.jsonで設定したnpm scripts buildを実行します.
「layer:build」では「.build/nodejs/node_modules」配下に依存関係をダウンロードした後にシンボリックリンクを削除します.
依存関係のダウンロードではプロダクションインストールを指定することで, devDependenciesで指定した依存関係を無視することができます. これがあるのでCDKで利用する依存関係をdevDependenciesに寄せてます.
yarn workspacesを利用するとnode_modules配下にシンボリックリンクができるのでそれを削除します. あとはbuildディレクトリ配下にLambda Layersで必要な情報が集まるので処理ができます.

package.json

{
  "name": "cdk",
  "private": true,
  "workspaces": ["@lambda/*"],
  "version": "0.1.0",
  "scripts": {
    "@lambda:build": "yarn workspaces run build",
    "layer:build": "yarn install --modules-folder .build/nodejs/node_modules --production=true && find .build/nodejs/node_modules  -type l | xargs rm -f"
  },
  "devDependencies": {
    "@aws-cdk/assert": "1.41.0",
    "@aws-cdk/core": "1.41.0",
    "@aws-cdk/aws-lambda": "^1.41.0",
    "@aws-cdk/aws-logs": "^1.41.0",
    "@types/jest": "^25.2.1",
    "@types/node": "10.17.5",
    "aws-cdk": "1.41.0",
    "jest": "^25.5.0",
    "ts-jest": "^25.3.1",
    "ts-node": "^8.1.0",
    "typescript": "~3.7.2",
    "source-map-support": "^0.5.16"
  },
  "dependencies": {}
}

CDKの指定

次にCDKのConstruct Librariesをみていきます.
先ほどまでの内容をみていけば指定する内容は自明だと思いますがLayersのcodeプロパティに対して「.build」ディレクトを, Lambda Functionのcodeプロパティでも同様に「@lambda/dir_name」を指定します.
関数名とパッケージ名についてはPropsで渡すことができるので, Constructorの呼び出し元で指定します.

lib/lambda.ts

import * as cdk from '@aws-cdk/core';
import {Code, Function, LayerVersion, Runtime} from '@aws-cdk/aws-lambda'
import {LogGroup, RetentionDays} from '@aws-cdk/aws-logs'
import {join} from 'path'

export interface LambdaProps extends cdk.StackProps {
  name: string,
  dirname: string,
}

export class LambdaStack extends cdk.Stack {
  public readonly functionName: string;
  constructor(scope: cdk.Construct, id: string, props: LambdaProps) {
    super(scope, id, props);
    this.functionName = props.name

    const layer = new LayerVersion(this, 'layer', {
      code: Code.fromAsset(join(__dirname, '../.build')),
      compatibleRuntimes: [Runtime.NODEJS_12_X],
      description: 'A layer for sdx handlers',
    })

    const func = new Function(this, this.functionName, {
      functionName: this.functionName,
      handler: 'index.handler',
      runtime: Runtime.NODEJS_12_X,
      code: Code.fromAsset(join(__dirname, `../@lambda/${props.dirname}`)),
      layers: [layer]
    })

    const log = new LogGroup(this, 'log', {
      logGroupName: `/aws/lambda/${this.functionName}`,
      retention: RetentionDays.THREE_DAYS,
    });
  }
}

Constructorの利用はおおよそ下記のように実行します.

bin/index.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { LambdaStack } from '../lib/lambda';

const app = new cdk.App();

new LambdaStack(app, 'LambdaStack', {
  name: 'handler',
  dirname: 'handler',
});

Lambda 関数の管理

次にyarn workspacesでパッケージとして管理するLambda関数群を書いていきます.
群といいつつサンプルなので1つしか書きませんが...

まずはpackage.jsonを定義していきます.
rootディレクトリで各々パッケージのbuild scriptを実行するので, npm scriptsでbuild方法を指定しておきます.
layersで利用するnode_modulesを.build配下に配置したので, パッケージについても同様に「.build」配下にtscでコンパイルしたものをおきます.

package.json

{
  "name": "handler",
  "version": "1.0.0",
  "scripts": {
    "build": " rm -rf ./.build && tsc"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.51",
    "@types/faker": "^4.1.12",
    "@types/jest": "^25.2.1",
    "@types/node": "10.17.5",
    "jest": "^25.5.0",
    "ts-jest": "^25.3.1",
    "ts-node": "^8.1.0",
    "typescript": "~3.7.2"
  },
  "dependencies": {
    "faker": "^4.1.0"
  }
}

出力先ディレクトリを決めるためにtsconfigを設定します.

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2018",
    "module": "commonjs",
    "lib": ["es2018", "DOM"],
    "declaration": true,
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": false,
    "inlineSourceMap": false,
    "inlineSources": false,
    "experimentalDecorators": true,
    "strictPropertyInitialization": false,
    "typeRoots": ["./node_modules/@types"],
    "outDir": "./.build"
  },
  "exclude": ["__tests__"]
}

最後にハンドラ関数を適当に定義します.

index.ts

import {Context, APIGatewayProxyEvent, APIGatewayProxyResult} from 'aws-lambda'
import {address,name, random} from 'faker';

async function handler(event: APIGatewayProxyEvent, context: Context):Promise<APIGatewayProxyResult>{
  console.log(`got event: ${event}`)
  console.log(`got context: ${context}`)
  const members = {
    members: [
      {
        id: random.uuid(),
        name: name.findName(),
        country: address.country(),
      },
      {
        id: random.uuid(),
        name: name.findName(),
        country: address.country(),
      },
      {
        id: random.uuid(),
        name: name.findName(),
        country: address.country(),
      },
      {
        id: random.uuid(),
        name: name.findName(),
        country: address.country(),
      },
    ]
  }

  const response: APIGatewayProxyResult = {
    statusCode: 200,
    body: JSON.stringify(members),
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*',
    }
  }

  return response;
}

export {
  handler
}

layersを利用するために無理やりfakerを使って適当にレスポンスを返させます.

デプロイ

パッケージのビルド, Layersのビルド, そしてCDK自体のビルドを行った後にデプロイを実行します.

$ yarn run @lambda:build
$ yarn run layer:build
$ yarn run build
$ yarn run cdk deploy

さいごに

これ自体も銀の弾丸ではないので, ユースケースに合わせてプリプロセスの手法を取り入れたり変えていけば良いかと思います. この記事がお役にたったら幸いです.