Amazon Web Services ブログ

AWS CDK による AWS Lambda コードの管理

システムの規模が大きくなるにつれ、インフラストラクチャの状態管理は困難になります。このような場合に AWS CloudFormation などを用いて、インフラ定義をコードで管理する(Infrastructure as Code, IaC)ことは有用です。一方、アプリケーションコードの管理は IaC から外れ、独立して管理されることが多々あります。特に AWS Lambda はシステムの様々な箇所で利用されるため、一般的なアプリケーションコードに比べて散逸しがちで管理が困難です。これにはチームが異なる、ライフサイクルが異なる、などいくつか理由が考えられますが、インフラストラクチャ用コードとアプリケーションコードを統一して管理できるツールセットが不足していたという要因もあります。

本記事では、 AWS CDK で Lambda コードの管理を行う手法について、徐々に CDK の活用を深めながら見てゆきます。 CDK や Lambda 、 TypeScript の基礎は触れませんので、必要があれば各種ドキュメントを参照ください。

CDK の利点

実際の管理方法を見てゆく前に、CDK のもたらすメリットをおさらいしてみましょう。

CDK は、プログラム中の定義に基づいて CloudFormation テンプレートを生成します。TypeScript などの高級言語で記述できるため、言語の持つ記述力や型システムを活かすことができます。アプリケーション開発者からすると、馴染みのある言語で記述できる点もメリットです。また、テストやフォーマットなど、言語側で提供されるツールセットが利用できます。

さらに、ライブラリ化はチーム開発に大きなメリットをもたらします。一例として、「API Gateway と Lambda と DynamoDB を構築するパターン」をライブラリ化し、必要なパラメーターだけ与えられるようになっていれば、他の開発者も素早く構築ができます。

CDK はポリシーの遵守にも役立ちます。例えば、「Lambda のメモリサイズは1024以下」というポリシーを持ったチームがあるとしましょう。 Aspects を利用すると、 Stack で定義されているリソースを一括してチェックすることが可能です。実際のコードでは、次のように表されます。

class LambdaMemoryChecker implements cdk.IAspect {
  public visit(node: cdk.IConstruct): void {
    if (node instanceof lambda.CfnFunction) {
      if ((node.memorySize ?? 256) > 1024)
        cdk.Annotations.of(node).addError(`Lambda memory is too large ${node.memorySize}`);
    }
  }
}

cdk.Aspects.of(stack).add(new LambdaMemoryChecker());

もしメモリサイズが大きすぎる場合は、デプロイ前にエラーとなり、デプロイ先の環境に影響を及ぼしません。このように、ガードレールを容易に定義できます。

$ cdk deploy

[Error at /CdkLambdaStack/NodeLambda/Resource] Lambda memory is too large 2048
Found errors

ここまではインフラ管理のメリットでしたが、さらに Lambda のアプリケーションコードも CDK で管理できると、次の利点が得られます。

  • 同一性: より本番環境に近い状態を表すことができます
  • 統一性: インフラと同一のデプロイ方法で管理できます
  • 可視性: インフラチームとアプリケーションチーム、それぞれの変更がより可視化されます

インフラとその上のアプリケーションではライフサイクルが異なりますが、 CDK デプロイの冪等性により互いの影響を最小限に留めることができます。

管理方法

ここからは、プリミティブな管理方法から初め、徐々に CDK の機能を活かした方法へと進めてゆきます。実際のコードを見ながら、ワークロードに適した管理方法を模索してゆきましょう。

使用するサンプル

次のような TypeScript で記述された AWS Lambda のコード( index.ts )を管理します。

import axios from 'axios';
import { Handler } from 'aws-lambda';

type EmptyHandler = Handler<void, string>;

export const handler: EmptyHandler = async function () {
    const response = await axios.get('https://amazon.co.jp/');
    return JSON.stringify({
        message: `status code: ${response.status}`
    });
}

主要なファイルのディレクトリ構成はこのようになっています。

/cdkのプロジェクトルート
- lib/
   - ckd-lambda-stack.ts  <- 今回 Lambda を定義する Stack
- lambda/
   - index.ts  <- 先ほどの TypeScript ファイル
   - package.json  <- 外部依存などの定義ファイル
...

TypeScript に馴染みのない方向けに補足しますと、Lambda で実行するためには2つの処理が必要です。

  1. axios というライブラリを外部から取得し、同一階層の node_modules というフォルダに配置します。なお、一般に node_modules はリポジトリ管理対象には含めません。サーバーからライブラリを取得し node_modules へインストールするコマンドは npm i です。
  2. TypeScript を JavaScript に変換します。TypeScript を透過的に実行するツールもありますが、少なくとも Lambda の Node ランタイムを利用する場合は、適切に JavaScript へ変換してアップロードする必要があります。様々なツールセットがありますが、今回は esbuild を利用します。

これらの処理を踏まえつつ、 CDK に統合する必要があります。

方法1. デプロイスクリプトの作成

まずは、最も単純な方法でこの課題に取り組んでみましょう。 CDK では、次のように定義をします。

new lambda.Function(this, 'NaiveLambda', {
    runtime: lambda.Runtime.NODEJS_14_X,
    handler: 'index.handler',
    code: lambda.Code.fromAsset(path.join(__dirname, '../lambda/')),
});

注目すべきは、 code パラメーターの lambda.Code.fromAsset です。この関数では、指定したパスをそのまま Lambda のコードとしてアップロードします。 このフォルダ外にあるファイルに依存していると、アップロードした際に動かなくなってしまうため注意が必要です。また、フォルダ内に余計なファイルがあると、アップロードに時間がかかったり、デプロイ可能な上限サイズを超えてしまいます。

さて、 cdk deploy の前に変換した JavaScript ファイルが必要なので、それらの処理を前段に置いたデプロイスクリプトを作ります。好きな言語が利用できますが、シェルスクリプトでは次のような形になります。

#!/bin/sh
set -eux

pushd lambda/
  npm i
  npx esbuild *.ts --platform=node --bundle --outdir=.
popd

cdk deploy

これを実行すると、 cdk deploy の前に必要な処理が行われ、デプロイされます。実行後は次のようなファイル構成になります。

- lambda
    - node_modules/
    - index.js
    - index.ts
    - package.json

この方法は簡単に実現でき、自由度も高いです。 CDK に依存していないため、既存のビルドスクリプトを流用できる点もメリットです。しかし、 cdk deploy の代わりにこのスクリプトを叩くことを徹底できない場合、デプロイ事故につながります。

例えば、インフラチームに加入した人が、 git clone 後に cdk deploy を叩いてしまったらどうなるでしょうか? node_modules 、および変換した index.js は git リポジトリに含まれないため、 Lambda コードとしてアップロードするフォルダにも含まれません。結果、 Lambda は実行できなくなり、デプロイ先の環境を壊してしまいます。デプロイパイプラインを作り自動化している場合は起こりにくいですが、パイプラインが無い環境や、開発環境で頻繁に起きると開発効率が下がってしまいます。

また、デプロイスクリプトは CDK コードの枠外なので、管理の手間も増えてしまいます。

方法2-1. Asset の利用

CDK には、 Assets というローカルにあるリソースを扱う仕組みがあります。この機能を利用することで、アプリケーションコードを CDK で利用することが容易になります。先程の例でも、 Lambda のコードを指定する際に Asset 機能を利用していました。

code: lambda.Code.fromAsset(path.join(__dirname, '../lambda/')),

仕組みを理解するため、 Asset の作成と指定を2つの処理に分割してみましょう。

import { Asset } from '@aws-cdk/aws-s3-assets';

const assetLambdaCode = new Asset(this, 'AssetLambdaCode', {
    path: path.join(__dirname, '../lambda/')
});

new lambda.Function(this, 'AssetLambda', {
    runtime: lambda.Runtime.NODEJS_14_X,
    handler: 'index.handler',
    code: lambda.Code.fromBucket(assetLambdaCode.bucket, assetLambdaCode.s3ObjectKey),
});

@aws-cdk/aws-s3-assets の Asset を利用すると、指定したファイルやフォルダを CDK 用に用意した S3 バケットにアップロードします。 Lambda 側では、その S3 バケットとキーを指定して利用できます。他にも、 @aws-cdk/aws-ecr-assets を利用すると、 Amazon ECR にコンテナイメージをアップロードすることもできます。

処理を分割しただけですので、方法1の問題点はまだ解消されていません。もし CDK 内のみで参照したいアセットで S3 や ECR へ配置したいものがあれば、この機能が活用できます。

方法2-2. Asset Bundling の利用

Assets には Bundling という機能があります。これを利用すると、デプロイ前に Asset に対して変換処理を行えます。この機能を活用すると、これまでの方法では行えなかった、 CDK の管理による TypeScript の変換が可能になります。

esbuild で TypeScript を変換しアップロードする処理は、次のように CDK を記述します。このようなコードを利用する場合、 cdk deploy を実行するマシンで Docker Engine が動いている必要があります。

const bundlingAssetLambdaCode = new Asset(this, 'BundlingAssetLambdaCode', {
    path: path.join(__dirname, '../lambda/'),
    bundling: {
        image: lambda.Runtime.NODEJS_14_X.bundlingImage,
        command: [
            'bash', '-c', [
            'export npm_config_cache=$(mktemp -d)',
            'npm i',
            'npx esbuild index.ts --platform=node --bundle --outfile=index.js',
            'cp index.js /asset-output/',
            ].join(' && ')
        ],
    },
});

new lambda.Function(this, 'AssetLambda', {
    runtime: lambda.Runtime.NODEJS_14_X,
    handler: 'index.handler',
    code: lambda.Code.fromBucket(bundlingAssetLambdaCode.bucket, bundlingAssetLambdaCode.s3ObjectKey),
});

bundling では、 cdk deploy を実行したマシンにある Docker Engine を利用したコンテナ内でのビルドと、 Docker を利用せず fsexec などを用いてローカル環境上で行うビルドを選択できます。デフォルトでは Docker コンテナ内でのビルドとなっており、 NODEJS_14_X.bundlingImage を指定してビルドをしています。ここではカスタムイメージを指定することもでき、要求に合わせてさまざまな処理が可能です。

command には実際の変換処理を記述します。 /asset-inputpath で指定したフォルダの中身が展開され、 /asset-output に配置したファイルがアップロード対象となります。

このように記載すると、 cdk deploy 時に変換が走り、先程述べたデプロイ時の環境による不整合を防ぐことができます。注意点として、 cdk deploy を実行する場合はビルド環境も持つ必要があります。 Docker コンテナ内でのビルドであれば Docker が必要です。

また、ビルド時間によりデプロイ速度は落ちるため、ビルドキャッシュを活かすことも意識しましょう。今回のケースでは、 node_modules はホストマシンにあるものが再利用されるため、パッケージの取得時間が短縮できます。逆に、プロダクション用にはキャッシュを使わないなどの工夫も可能です。さらに、独自のビルド用 Docker イメージを使う場合は、工夫の余地が多くあります。

方法2-3. lambda.Code.fromAsset の利用

方法1から使っていた lambda.Code.fromAsset ですが、実はこれにも bundling パラメーターがあります。多くのケースではアップロード先を意識する必要がないため、これが最も便利な記述となるでしょう。

new lambda.Function(this, 'BundleLambda', {
    runtime: lambda.Runtime.NODEJS_14_X,
    handler: 'index.handler',
    code: lambda.Code.fromAsset(path.join(__dirname, '../naive_lambda/'), {
        bundling: {
            image: lambda.Runtime.NODEJS_14_X.bundlingImage,
            command: [
                'bash', '-c', [
                    'export npm_config_cache=$(mktemp -d)',
                    'npm i',
                    'npx esbuild index.ts --platform=node --bundle --outfile=index.js',
                    'cp index.js /asset-output/',
                ].join(' && ')
            ],
        }
    }),
});

コンテナイメージでデプロイしたい場合も lambda.Code.fromAssetImage が用意されているため、スムーズに利用できます。

方法3. 言語固有モジュールの利用

方法2-3 を採用する箇所が増えてくると、いずれは TypeScript 用の Lambda をライブラリ化して使い回したい、という要求が生まれてきます。CDK ではこのようなニーズに備え、3種類の言語(NodeJS, Golang, Python)では特別なモジュールを用意しています。

注意点として、本ブログ執筆時では Golang および Python 用のモジュールは Preview 段階となっています。破壊的変更が加わる可能性がありますので、本番環境など重要な環境では利用を控えるか、内容を完全に理解した状態でご利用ください。

そのうちの1つ、 @aws-cdk/aws-lambda-nodejs を利用すると、 esbuild で bundling を行い、アップロードして Lambda を作成する、という処理を暗黙的に行ってくれます。

import * as nodeLambda from '@aws-cdk/aws-lambda-nodejs';

new nodeLambda.NodejsFunction(this, 'NodeLambda', {
    entry: path.join(__dirname, '../naive_lambda/index.ts'),
    handler: 'handler',
});

また、小さなプロジェクトでは命名規則を限定することで、より短い記述が可能です。例えば api という関数であれば、 cdk-lambda-stack.api.ts という形で、関数定義を行う CDK Construct に . で続けたファイル名を付け、同一階層に配置します(参考ドキュメント)。

/cdkのプロジェクトルート
- lib/
   - cdk-lambda-stack.ts
   - cdk-lambda-stack.api.ts  <- Lambda コード
- package.json  <- Lambda コードの外部依存も含めた定義ファイル
...
new nodeLambda.NodejsFunction(this, 'api');

Layer に含まれている module を含めない、 logLevel の指定などのパラメーターも備えており、実践的です。実装されている機能については、ドキュメントをご覧ください。

言語は限定されますが、要件が合う場合は最も推奨できる方法です。

開発用 Tips

最後に、開発効率の向上に役立つ Tips を紹介します。最新版の CLI に更新してお試しください ( npm install -g aws-cdk )。

–hotswap で高速デプロイ

cdk deploy は Asset をアップロードし、 CloudFormation の定義を作成し、差分を更新することでアプリケーションコードが更新されます。しかし、 CloudFormation による更新には多少の時間がかかります。開発時には手元の環境と差分が出ても良いので、アプリケーションコードを素早く更新したい場面があります。

そのような場合のため、 hotswap deployments 機能が実装されました。 cdk deploy --hotswap とすると、 Lambda の API を利用して Lambda のコードのみを直接更新し、 CloudFormation の操作は行わないため、高速に変更を反映できます。

警告文にも書かれていますが、本番環境に対するデプロイには非推奨です。挙動を把握しており、かつ開発環境の場合のみご利用ください。

CDK モジュールバージョン の一括バージョンアップ

TypeScript による CDK (version 1)は各機能がモジュールごとに分かれており、必要なモジュールを後から追加した場合は他と異なるバージョンが入ってしまうことが頻繁に発生します。次の例では、 @aws-cdk/aws-lambda-nodejs を後から追加した結果、バージョンが食い違ってしまっている pakcage.json の例です。

  "dependencies": {
    "@aws-cdk/aws-iam": "^1.120.0",
    "@aws-cdk/aws-lambda-nodejs": "^1.122.0",
    "@aws-cdk/core": "1.120.0",
    ...
  }

バージョンが異なると、一見問題が無いのにコンパイル時にエラーが出てしまい、原因追求に時間がかかってしまいます。

開発環境では常に最新のモジュールに合わせれば良いことが多いため、 npm-check-updates などのツールを使い、まとめて最新版に合わせると良いでしょう。本番環境への適用はテストが通るか、 cdk diff で変更点が無いか、など十分確認の上行ってください。

また、現時点では Preview 段階ですが、 CDK version2 ではモジュールが統合されており、この問題は解消される予定です。

(Preview) SAM CLI の CDK 対応

AWS SAM は Serverless アプリケーション開発に適したデプロイツールです。 SAM の強力な機能として、次のような機能があります。

  • Lambda Handler の呼び出しコマンド: sam local invoke というコマンドで Lambda Handler を呼びだすことができます。また、 Lambda へ渡すイベントパラメーターを JSON ファイルで指定することも可能です。
  • SDK 経由の Lambda 呼び出し: sam local start-lambda というコマンドで、ローカルに Lambda を呼びだすエンドポイントを作成できます。 AWS CLI や AWS SDK から Lambda を呼びだすスクリプトのテストが容易になります。
  • API Gateway との連携: sam local start-api というコマンドでは、 localhost に API Gateway のモックを立てることができます。 curl や他のアプリケーションから HTTP で呼びだすことで、 API Gateway 経由の Lambda が正しく動くか、 API の定義が正しいかなどをテストしやすくなります。

CDK でもこれらの機能を利用すべく、現在 SAM CLI の CDK 対応が Preview で行われています。CDK で定義した Lambda や API Gateway に対してこれらのテストが利用できるようになり、開発効率の向上が期待されます。

まとめ

CDK では、インフラ定義と Lambda のアプリケーションコードを同一に扱うことができます。 Assets 機能によって、デプロイ前にビルドが必要なアプリケーションであっても、簡単に管理対象に含めることができます。これにより、より本番環境を反映した IaC や、ライブラリ化による再利用性の向上、チームの協働を推進するなど、さまざまなメリットが得られます(方法2)。

NodeJS, Golang, Python をご利用の方は、まず @aws-cdk/aws-lambda-<lang> と名付けられた各言語固有のモジュールが利用できないか確認してみましょう(方法3)。パラメーターを変更しても要件に合わない場合は、 CDK の提供する Assets 機能と bundling が利用できます。さらに要件に合わない場合は、デプロイスクリプトなどを用意して構築しましょう(方法1)。

Next Step

アプリケーションコードを CDK で管理できるようになった後は、 CDK 自体のデプロイ方法を考えることも重要です。 CDK では CDK Pipelines を使って素早くパイプラインを構築することが可能です(解説記事ドキュメント)。また、サーバーレスの CI/CD パイプラインに関しては コンテナ Lambda の CI/CD パイプラインの考え方#5 に記載されていますので、 SAM での構築を参考にしつつ、要件に応じたパイプラインを構築することができます。

また、 CDK はオープンソースであり、日々進化を続けています。どのようなバグも小さすぎることはなく、どのような機能要望も大きすぎることはありません。ご利用になって感じたことは、ぜひ issuePR でフィードバックください。

ソリューションアーキテクト 木村 秀平