CDKでAWS Lambdaのパッケージフォーマットにコンテナイメージを指定してデプロイしてみた

2020.12.05

はじめに

先日のre:Invent 2020にて、Lambdaのパッケージフォーマットとして、従来のZIP形式に加えてコンテナイメージがサポートされました。詳しくは下記を参照してください。

【速報】Lambdaのパッケージフォーマットとしてコンテナイメージがサポートされるようになりました!! #reinvent

CDKでLambdaのパッケージフォーマットにコンテナイメージの定義ができると実践投入や構成を考えやすなーと思っていたところ、CDKのv1.76.0で定義できるようになっていたため、今回はそちらを試します。

以下注意点です。ご了承お願いします。

  • 本記事は、CDK v1.76.0ベースで記載した記事となります。CDKのバージョンが上がった場合には、記事通りやっても動かない可能性があります
  • 作成する関数は、Node12系です。別言語で作成したい方は、一部しか参考になりません

構成

概要

今回作る構成は、パッケージフォーマットにコンテナイメージを指定したLambda関数2つ(以降aLambda,bLambdaとします)です

両関数とも設定するコンテナイメージは同じですが、エントリポイントをCMDで切り替えるため処理的には独立した関数となります

記事内で全てソースコードを貼るのは分量が多いので、完成版のリポジトリを作りました。適宜参照して頂けたらと思います

shuntaka9576/lambda-container-image-sample

ディレクトリ構成

ざっくりディレクト構成とファイルの説明を下記に示します

├── bin
│   └── cdk.ts                       // スタックデプロイの起点となるtsファイル
├── lib
│   ├── cdk-ecr-stack.ts             // ECRリポジトリのスタック定義
│   └── cdk-lambda-stack.ts          // aLambda,bLambdaのスタック定義
├── src
│   └── lambda
│       └── handler
│           └── apig-trigger
│               ├── apig-a-handler.ts // aLambdaのエントリポイント
│               └── apig-b-handler.ts // bLambdaのエントリポイント
├── dist
│   ├── a
│   │   └── index.js                  // aLambdaをwebpackでバンドルした資材が配備される(gitignoreされています)
│   └── b
│       └── index.js                  // bLambdaをwebpackでバンドルした資材が配備される(上記同様)
├── Dockerfile                        // Lambdaのパッケージフォーマットとして定義したDockerfile
├── Makefile
├── cdk.json
├── package.json
├── tsconfig.json
├── webpack.config.js
└── yarn.lock

構築

前述の構成を構築していきます

ソースのクローン

git clone https://github.com/shuntaka9576/lambda-container-image-sample.git

ECRの作成

今回作成するCloudFormationのスタックは以下の2つです

  • (A) ECRリポジトリのスタック
  • (B) ECRリポジトリのコンテナイメージを参照した2つのLambdaが定義されたスタック

分けた理由としては、(A)と(B)ではデプロイサイクルが異なるためです。(B)ではcdk.jsonを利用して(A)を参照します。

ECRリポジトリのCDK定義は下記です。

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

    const repository = new ecr.Repository(this, "SampleNodeApp", {
      repositoryName: "sample-node-app",
      imageScanOnPush: true,
    });

    // コンテナイメージのライフサイクル設定
    // (tagのprefixがprodの場合は9999まで保持し、prefixがない場合30日経過後削除する設定)
    repository.addLifecycleRule({
      tagPrefixList: ["prod"],
      maxImageCount: 9999,
    });
    repository.addLifecycleRule({ maxImageAge: cdk.Duration.days(30) });

    // 作成されたECRリポジトリのARNをコマンド実行時に出力する設定
    new cdk.CfnOutput(this, "ecrArn", {
      value: `${repository.repositoryArn}`,
    });
  }
}

assume-roleして、cdkのデプロイします

yarn cdk deploy -c stageName=dev ecr

デプロイすると、ARNが出力されますので、メモしてください

Outputs:
ecr.ecrArn = arn:aws:ecr:ap-northeast-1:[数値12桁くらい]:repository/sample-node-app

AWSコンソールでsample-node-appのリポジトリが出来ていることを確認しましょう

Lambdaにデプロイするコンテナイメージの作成

コンテナイメージのディレクトリ構成は、下記の通りです。

/ (ルート)
├── functions
    ├── a
    │   └── index.js // aLambdaをwebpackでバンドルした資材
    └── b
        └── index.js // bLambdaをwebpackでバンドルした資材

コンテナイメージのFromには、AWSが提供しているLambdaのAWSベースイメージを指定します。

Dockerfileは下記になります

FROM public.ecr.aws/lambda/nodejs:12

ARG FUNCTION_DIR="/functions"
COPY dist/ $FUNCTION_DIR

(Lambdaで動かせるコンテナイメージには要件があります。詳しくは公式ドキュメントを参照してください)

コンテナをビルドします。AWSコンソールのECRリポジトリURIを取得して指定します。

yarn build # webpackでtsファイルをビルド、バンドル
docker image build . -t [AWSアカウントID].dkr.ecr.ap-northeast-1.amazonaws.com/sample-node-app:latest # コンテナイメージの作成とlatestタグの付与
docker tag [AWSアカウントID].dkr.ecr.ap-northeast-1.amazonaws.com/sample-node-app:latest [AWSアカウントID].dkr.ecr.ap-northeast-1.amazonaws.com/sample-node-app:d504fc5 # 過去のイメージを追えるようにリビジョンタグの付与

正しくコピーされていることを確認するためコンテナにexecします

$ docker run -it --entrypoint '' [AWSアカウントID].dkr.ecr.ap-northeast-1.amazonaws.com/sample-node-app:latest /bin/sh
sh-4.2# cd /functions/a/
sh-4.2# ls
index.js
sh-4.2# cd /functions/b/
sh-4.2# ls
index.js

コンテナイメージの/functions配下にwebpackでバンドルしたファイル(ローカルの/distのディクトリ・ファイル)が入っていることが確認できました。

ローカルでコンテナイメージの動作確認をする

ランタイムインターフェイスエミュレーターを使用して、アプリケーションをローカルでテストすることができます。 /functions/a/index.js/functions/b/index.js に関数がありますので、それぞれCMDに指定して動作確認します。

コンテナが起動。待機状態となる

$ docker run -p 9000:8080 [AWSアカウントID].dkr.ecr.ap-northeast-1.amazonaws.com/sample-node-app:latest /functions/a/index.handler
time="2020-12-04T16:16:13.08" level=info msg="exec '/var/runtime/bootstrap' (cwd=/var/task, handler=)"

別のshellを起動して、curlを実行

curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"eventName": "test"}'

下記のようにいつもCloudWatchで見るログが、ローカルのシェル上から確認できます。感動ですね。

$ docker run -p 9000:8080 [AWSアカウントID].dkr.ecr.ap-northeast-1.amazonaws.com/sample-node-app:latest /functions/a/index.handler
time="2020-12-04T16:16:13.08" level=info msg="exec '/var/runtime/bootstrap' (cwd=/var/task, handler=)"
time="2020-12-04T16:17:08.183" level=info msg="extensionsDisabledByLayer(/opt/disable-extensions-jwigqn8j) -> stat /opt/disable-extensions-jwigqn8j: no such file or directory"
time="2020-12-04T16:17:08.183" level=warning msg="Cannot list external agents" error="open /opt/extensions: no such file or directory"
START RequestId: 45bc127a-ca3e-4f3f-985f-2dd3f5c5f413 Version: $LATEST
2020-12-04T16:17:08.275Z        45bc127a-ca3e-4f3f-985f-2dd3f5c5f413    INFO    event: {"eventName":"test"}
2020-12-04T16:17:08.279Z        45bc127a-ca3e-4f3f-985f-2dd3f5c5f413    INFO    start a handler
END RequestId: 45bc127a-ca3e-4f3f-985f-2dd3f5c5f413
REPORT RequestId: 45bc127a-ca3e-4f3f-985f-2dd3f5c5f413  Init Duration: 0.29 ms  Duration: 98.05 ms      Billed Duration: 100 ms Memory Size: 3008 MB    Max Memory Used: 3008 MB

下記のコマンドを実行すれば、functions/b/index.handlerも実行可能です。

$ docker run -p 9000:8080 [AWSアカウントID].dkr.ecr.ap-northeast-1.amazonaws.com/sample-node-app:latest /functions/b/index.handler

作成したコンテナイメージをECRへpushする

まずデプロイ環境へassume-roleを済ませた後、ECRにログインする必要があります。下記の画像を参考にコピーしてください。

ECRへイメージをpushします

aws ecr get-login-pasword ... # 上記でコピーしたECRのログインコマンドを実行
docker push [AWSアカウントID].dkr.ecr.ap-northeast-1.amazonaws.com/sample-node-app:latest
docker push [AWSアカウントID].dkr.ecr.ap-northeast-1.amazonaws.com/sample-node-app:d504fc5

pushされていることと、タグにlatestd504fc5が付与されていることを確認します

Lambdaスタックをデプロイする

ECRの作成の項で出力されたARNをcdk.jsonのrepositoryArnのvalueに記述してください

{
  "app": "npx ts-node bin/cdk.ts",
  "context": {
    "dev" : {
      // arn:.. の部分を変更する
      "repositoryArn":  "arn:aws:ecr:ap-northeast-1:XXXXXXXXXXXX:repository/sample-node-app"
    },
    "prd" : {
    }
  }
}

Lambdaが定義されたスタックは下記の通りです

import * as cdk from "@aws-cdk/core";
import * as lambda from "@aws-cdk/aws-lambda";
import * as ecr from "@aws-cdk/aws-ecr";
import { Role, ServicePrincipal, ManagedPolicy } from "@aws-cdk/aws-iam";

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

    const stageName: string = this.node.tryGetContext("stageName");
    const env: { repositoryArn: string } = this.node.tryGetContext(stageName);

    // ECRリポジトリをARN経由で参照
    const sampleNodeAppRepository = ecr.Repository.fromRepositoryArn(
      this,
      id,
      env.repositoryArn
    );

    const execLambdaRole = new Role(this, "execRole", {
      roleName: `${stageName}lambdaExecRole`,
      assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AWSLambdaBasicExecutionRole"
        ),
      ],
    });

    new lambda.Function(this, "aLambda", {
      code: lambda.Code.fromEcrImage(sampleNodeAppRepository, {
        cmd: ["/functions/a/index.handler"],  // リクエストのエントリーポイント
        tag: "latest", // コンテナイメージのタグ
        entrypoint: ["/lambda-entrypoint.sh"],
      }),
      role: execLambdaRole,
      functionName: `${stageName}-a-lambda`,
      runtime: lambda.Runtime.FROM_IMAGE,
      handler: lambda.Handler.FROM_IMAGE,
      timeout: cdk.Duration.seconds(10),
    });

    new lambda.Function(this, "bLambda", {
      code: lambda.Code.fromEcrImage(sampleNodeAppRepository, {
        cmd: ["/functions/b/index.handler"],
        tag: "latest",
        entrypoint: ["/lambda-entrypoint.sh"],
      }),
      role: execLambdaRole,
      functionName: `${stageName}-b-lambda`,
      runtime: lambda.Runtime.FROM_IMAGE,
      handler: lambda.Handler.FROM_IMAGE,
      timeout: cdk.Duration.seconds(10),
    });
  }
}

Lambdaが定義されたスタックをデプロイします

yarn deploy -c stageName=dev dev-lambdas

AWSコンソールでLambdaが2つ作成されていることを確認できればOKです。

動かしてみる

dev-a-lambda

元ソースapig-a-handler.tsの内容が実行されていることを確認できました

dev-b-lambda

元ソースapig-b-handler.tsの内容が実行されていることを確認できました

最後に

Lambdaのパッケージフォーマットにコンテナイメージがサポートされることで、今までのLambdaアプリケーションのデプロイやロールバックの仕方がより効率的になる場合があると感じました。

またローカルでコンテナを起動することで、実際に環境へデプロイしない分、効率よく「テストを書く->失敗したらテストを修正する」のサイクルが出来そうだとも感じました。(ある程度工夫は必要ですが...)