アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

AWS CDKでLambdaをデプロイしてみた。Layer付き。

morishitaです。

このエントリはアクトインディアドベントカレンダー2019の3日目です。 adventar.org

CDK、便利ですね。
これまで、Lambda は Serverless Framework を使って開発してきたのですが、CDKでやるとどんな感じか試してみました。

作ったもの

習作として、テキストをクエリパラメータで送ったら、QRコードが返ってくるAPIを作ってみました。

QRCode Generator

作成するリソースとしては次の通りです。

  • Lambda 関数
  • Lambda Layer
  • API Gateway

なお、Lambda関数もCDKのコードもすべて Typescript で実装しました。

Lambda 関数の実装

APIの本体である Lambda関数のコードは若干雑ですが次の様に実装しました。

// eslint-disable-next-line import/no-unresolved
import { APIGatewayEvent, Callback, Context } from 'aws-lambda';
import * as QRCode from 'qrcode';

function extractTxt(event: APIGatewayEvent): string|null {
  return event && event.queryStringParameters && event.queryStringParameters.txt;
}

export async function handler(
  event: APIGatewayEvent,
  context: Context,
  callback: Callback,
): Promise<void> {
  const txt = extractTxt(event);

  if (txt) {
    const qr = await QRCode.toString(txt, { type: 'svg' });

    callback(null, {
      statusCode: 200,
      body: qr,
      headers: {
        'Content-Type': 'image/svg+xml',
        'Access-Control-Allow-Origin': '*',
      },
    });
  } else {
    callback(null, {
      statusCode: 400,
      body: JSON.stringify({ error: 'Please add `txt` query parameter!' }),
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
      },
    });
  }
}

クエリパラメータ txt を取り出し、ライブラリqrcodeでSVGのQRコードを生成してレスポンスしているだけです。

QRコードのサイズは表示する側で自由にしてねっということでSVGを採用しました。CSSで伸縮しても潰れたりしないはず。
一応、fetchやXHRでも取得しやすいように 'Access-Control-Allow-Origin': '*' もレスポンスヘッダにセットしています。

ポイントはライブラリqrcodeを使っているところ。 このライブラリがLambda関数の実行環境でも参照できる必要があります。
Lambda Layerを使わない場合、Webpackでワンソースにパッケージするという方法があります。
これまで Serverless でデプロイする場合にはそうしてきました。

今回は Lambda Layer を使います。

CDK のコード

CDK プロジェクトの初期化は適当なディレクトリを作って、その中で次のコマンドを実行します。すると必要なファイル群を作ってくれます。

$ npx cdk init app --language=typescript

CDKのREADMEだと npm i -g aws-cdkcdkコマンドをグローバルインストールするような手順が書いています。しかし、npx を使ったほうがいいと思います。というのも、現状aws-cdk のアップデートが速すぎて、グローバルにインストールしてもすぐ古くなってしまうからです。
必要モジュールを実行時に全部ダウンロードするのでちょっと時間がかかりますが、最新バージョンで初期化できます。

初期化すると次の様にディレクトリとファイル群が生成されます。

.
├── bin/
├── lib/
├── node_modules/
├── test.
├── README.md
├── cdk.json
├── jest.config.js
├── package-lock.json
├── package.json
└── tsconfig.json

lib ディレクトリの下に作られたtsファイルに作成したいリソースの定義を実装し、それをbin ディレクトリに生成されるtsファイルが利用する形になっています。 実行時のエントリファイルは bin ディレクトリに生成されるtsファイルになっています。

Stackクラス

lib ディレクトリの下に作られたtsファイルは CDK プロジェクトの本体とも言えるStackクラスを継承したクラスを実装します。

今回は次のように実装しました。
作成するリソースごとにStackを分けるほどのコードでもないので、1つのスタックで作成します。
デプロイするとスタッククラス単位で CloudFormation のスタックが作成されます。

import { Construct, Stack, StackProps } from '@aws-cdk/core';
import {
  Code,
  Function as LambdaFunc,
  LayerVersion,
  Runtime,
} from '@aws-cdk/aws-lambda';
import { LambdaRestApi } from '@aws-cdk/aws-apigateway';
import * as path from 'path';

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

    // Lambda Layer
    const layer = new LayerVersion(this, 'QRCodeGeneratorLayer', {
      compatibleRuntimes: [Runtime.NODEJS_12_X],
      code: Code.fromAsset('dist'),
    });

    // Lambda Function
    const func = new LambdaFunc(this, 'QRCodeGenerator', {
      runtime: Runtime.NODEJS_12_X,
      handler: 'index.handler',
      code: Code.fromAsset(path.join(__dirname, '..', 'src')),
      layers: [layer],
    });

    // API Gateway
    const api = new LambdaRestApi(this, 'QRCodeGeneratorAPI', {
      handler: func,
      restApiName: 'qrcode',
      proxy: false,
    });
    api.root.addMethod('GET');
  }
}

上から、Lambda Layer、Lambda関数、API Gateway を作成しています。
初見でも何やってるかだいたい分かるくらいシンプルだと思うのですがいかがでしょう?

import@aws-cdk/aws-lambdaFunction の名前を LambdaFunc と付け替えています。これはJavaScript の組み込みの Function とかぶって、eslint の no-new-funcに引っかかってしまうためです1

Lambda Layer のための前処理

前述のスタッククラスのコードで Lambda Layer の生成はたったの4行ですが、実は前処理が必要です。
というのも、Layerとしてアップロードしたいもの一式を用意する必要があるのです。

このコードではdist ディレクトリがLayerとしてアップロードしたいディレクトリです。

その前処理を行う関数を次の様に実装しました。 これを bin ディレクトリに生成されるtsファイルでスタッククラスのインスタンス生成前に実行しています。

import * as childProcess from 'child_process';
import * as path from 'path';

/**
 * Layer用の node_module ディレクトリを準備する
 * @param distDir
 */
export function prepareLayerModules(distDirName = 'dist'): string {
  const distDir = path.join(__dirname, '..', distDirName);
  const moduleDir = path.join(distDir, 'nodejs', 'node_modules');
  childProcess.execSync(`yarn install --production --modules-folder ${moduleDir}`);
  return moduleDir;
}

Lambda Layerの仕様で次のようなディレクトリ構造を作る必要があります。

nodejs
  └ node_modules

上記コードではこれを作った上で、yarnコマンドを実行して必要なモジュールを node_modules に含めるようにしています。 Lambda Layer専用の package.json を別途管理する手法もありますが、漏れが発生しそうですし何より2つ管理するのは面倒です。
今回はプロジェクトで1つの package.json を使って yarn install --production とすることで、dependencies のモジュールのみをLayerに含めます。Lambda関数には不要なCDK関連のモジュールは含まれてしまうのですが、eslintやjestなど開発環境でのみ必要な devDependencies なモジュールはLayerに含まれず、容量を減らしつつ管理コストも減らせる中庸な解かなと思います。

なお、CDKのコードのスナップショットテスト行うときにはLayerのディレクトリを空にしてテストするようにしないとスナップショットがしょっちゅう変わって安定したテストにならないのでご注意を。

まとめ

かんたんなアプリケーションでしたが、CDKを使ってLambda関数とともに Lambda Layerを簡単にデプロイできることが確認できました。
何より面倒で読みづらいYAMLがなく、CDKもLambdaもTypescriptで実装でき、eslintやJestによるテストなどTypescriptで使いなれたツールを使えるのがいいですね。

Webpackからも開放されるので、Alexaスキルのバックエンドなど既存の Lambdaアプリケーション(Serverless +Webopack)のCDKへの移行も検討したいと思います。

使ったことないのですが、同様のユースケースに対応したAWS謹製ツールにはSAMもあります。これも試してみたほうがいいのかなぁ。
でも、今からYAMLの世界には戻れそうにないなぁ。

最後に

アクトインディではエンジニアを募集しています。 actindi.net


  1. eslint側の設定で対処もできたかもしれないのですが、調べるの面倒だったので。でも、どうしてこんな真正面からぶつかる名前をつけたのかなぁ。