AWSのLambda(zip)をLambda(Image)として動かす

# この記事について

AWSのLambdaには、`zip`と`Image`の形式があります。
この2つの形式にはいくつかの違いがあります。一部を上げると、下の表のような違いがあります。

||zip|Image|
|:--|:--|:--|
|実行環境|AWSが提供したもの|自由|
|デプロイにかかる時間|数秒、ライブラリによっては数分|数分～数時間|
|Web上でソースを編集|ライブラリが小さければ可能|不可|
|デプロイできるファイルサイズ|50MBまで|10GBまで|
|docker|不要|必要|

この記事で紹介する実行方法は、`zip`と`Image`のいいとこどりをします。

||zip+Image|
|:--|:--|
|実行環境|自由|
|デプロイにかかる時間|数秒|
|Web上でソースを編集|可能|
|デプロイできるファイルサイズ|10GBまで|
|docker|不要|

`zip`としてソースコードをデプロイしながら、`Image`の自作ランタイム上で動かします。

# 実際に動かしているところ

実際に動かしているところをキャプチャで紹介します。
ginza（※数GBある形態素解析用のライブラリ）を使ったLambdaの動きです。

1. ginzaを使った、Lambda(zip)を作成します。  
AWSの画面上でソースを確認して、そのまま編集できます。  
![1-ginza-zip.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/eff22098-04d8-8e38-7cc8-7a2f1a2729e4.png)

2. Lambdaのテストボタンを押して実行します。  
ハードコードしていた「銀座でランチをご一緒しましょう」が処理されました。  
![1-ginza-execute.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/ca0e81f2-292a-3e72-2763-209442ba4521.png)

3. zipのソースコードを書き変えて、デプロイします。  
更新にかかる時間は数秒です。
![2-ginza-zip.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/1e57f4f8-f618-3844-8ae3-fbba53b0bb83.png)

4. Lambdaのテストボタンを押して再実行します。  
先の手順の更新が反映されて、「吾輩は猫である、名前はまだない」が処理されました。
![2-ginza-execute.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/585ec4a1-1402-8b66-46be-622945479c1f.png)

# どうやってやるの

実行用、ソースコード管理用の、2つのLambdaをペアで作ります。

![data.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/8c4ae6eb-b948-3d62-9602-dc692cb7803b.png)

リソースの配置は以下のようになります。Dockerのビルドが必要なECR部分のスタックを分けることで、dockerのない環境でもLambdaが管理できるようになります。

![resources.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/d45ce505-2567-4889-8cd2-1263248d1adf.png)

処理の流れは以下のようになります。実行用Lambdaの起動スクリプトの中で、ソースコード管理用Lambdaのソースコードをダウンロード、展開して、動的にマウントします。

![sequence.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/4875d14e-56ee-617f-a683-6ebf841ebe29.png)

ただのLambda(zip)に比べて、コールドスタートでかかる時間は0.6秒〜ほど長くなります。

# 実装の説明

最終的なソースコードはこちらです。

https://github.com/ShotaOki/ZipLambdaOnImageLambda

また、こちらのブランチにGinzaのサンプルソースを置いています。

https://github.com/ShotaOki/ZipLambdaOnImageLambda/tree/forecast_example

## Dockerイメージを実装する

まず、ECRのDockerイメージを作ります。
下の図の赤点線枠内の部分です。この部分の手順はdockerが必要になります。

![data.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/b0b2224a-2f6e-e5a0-d629-b303ef8f413d.png)

### ステップ1: ECRのソースコードを実装する

ECRにデプロイするソースコードとして、3つのファイルを作成します。

```bash
Dockerfile # イメージをビルドするためのファイル
entrypoint.sh # Pythonのソースをダウンロード、実行するためのファイル
requirements.txt # Pythonの依存ライブラリを定義するためのファイル
```

Dockerfileでは、entrypoint.shを実行するように設定します。
Dockerfileの外ではtmpディレクトリ以外への書き込み権限がなくなるため、パスは`/var/task`と`/tmp/var/task`へ通すようにします。

もし何かしらのインストールが必要なら、`dnf install -y`でインストールすることができます。

```dockerfile: Dockerfile
FROM public.ecr.aws/lambda/python:3.12

COPY entrypoint.sh requirements.txt ./

RUN python3.12 -m pip install -r requirements.txt -t .

WORKDIR /tmp/var/task/
WORKDIR /var/task/

env ENTRYPOINT_HOME=/var/task
env ENTRYPOINT_BIN_PYTHON=/var/lang/bin/python3.12
env ENTRYPOINT_AWS_LAMBDA_RIE=/usr/local/bin/aws-lambda-rie
env EXTRA_PYTHON_PATH=/var/task:/tmp/var/task

ENTRYPOINT [ "/bin/bash", "/var/task/entrypoint.sh" ]
CMD [ "app.lambda_handler" ]
```

entrypoint.shでは、Lambdaのzipファイルを動的にダウンロードして展開します
※説明のためにコメントを入れていますが、実際の動作では不要なので、コメントは外すようにしてください

```sh: entrypoint.sh
#!/bin/bash

# /var/taskに移動します
cd ${ENTRYPOINT_HOME}

# 定数を定義します
GET_FUNCTION_SERVICE_NAME="lambda"
SOURCE_ZIP_FILE="/tmp/source.zip"
TEMPORARY_GET_FUNCTION_JSON="/tmp/temporary-get-function.json"

# jqのない環境に合わせて、JSONのパースとzipのダウンロードはpythonで実施します
jq_command='import json;
import argparse;
import urllib.request;

parser = argparse.ArgumentParser();
parser.add_argument("path", type=str);
parser.add_argument("--query", type=str);
parser.add_argument("--output", type=str, default="");
args = parser.parse_args();

with open(args.path, "r") as f:
    data = json.loads(f.read());

query_list = args.query.split(".");
for q in query_list:
    data = data[q];

with urllib.request.urlopen(data) as response:
    body = response.read();
    if len(args.output) >= 1:
        with open(args.output, "wb") as f:
            f.write(body);

print(json.dumps(data));'

# GetFunctionで関数の詳細情報を参照します
# CLIを使わず、curlにAWSの認証情報を設定して実行します
curl "https://${GET_FUNCTION_SERVICE_NAME}.${AWS_REGION}.amazonaws.com/2015-03-31/functions/${LAMBDA_FUNCTION_NAME}?Qualifier=${LAMBDA_FUNCTION_QUALIFIER}" \
  -H "X-Amz-Security-Token: ${AWS_SESSION_TOKEN}" \
  --aws-sigv4 "aws:amz:${AWS_REGION}:${GET_FUNCTION_SERVICE_NAME}" \
  --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" > ${TEMPORARY_GET_FUNCTION_JSON}

# GetFunctionの結果を使って、Lambdaのソースコードをダウンロードします
CODE_LOCATION=`${ENTRYPOINT_BIN_PYTHON} -c "${jq_command}" ${TEMPORARY_GET_FUNCTION_JSON} --query Code.Location --output ${SOURCE_ZIP_FILE}`

# pythonのzipfileライブラリでzipを展開します
# unzipコマンドと同等です
${ENTRYPOINT_BIN_PYTHON} -m zipfile --extract ${SOURCE_ZIP_FILE} /tmp/var/task/${LAMBDA_FUNCTION_ROOT}

# 展開したLambdaのソースコードがあるディレクトリに移動します
cd /tmp/var/task/${LAMBDA_FUNCTION_ROOT}

# PYTHONにパスを通します
export PYTHONPATH="${EXTRA_PYTHON_PATH}:${PYTHONPATH}"

# Pythonのソースを実行します
# ローカル実行はLambda rieを通して実行、クラウドで実行するときは直接実行します
if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
  exec ${ENTRYPOINT_AWS_LAMBDA_RIE} ${ENTRYPOINT_BIN_PYTHON} -m awslambdaric $@
else
  exec ${ENTRYPOINT_BIN_PYTHON} -m awslambdaric $@
fi
```

最近のcurlはAWSの認証に対応していますから、AWS CLIやboto3をインストールする必要はありません。curlだけでAWSのAPIを実行できます。

requirements.txtは、必要なPythonライブラリがあればここに記載します。
※今回のサンプルでは空ファイルにします。

```text: requirements.txt
```

### CDKの実装

作成したDockerfileをビルド、ECRに送信できるように、CDKを実装します。
ファイルの構成は以下のようにします。

```diff
lambda-function
  - python-3-12-custom-runtime
    - Dockerfile # 前の手順で作成したもの
    - entrypoint.sh # 前の手順で作成したもの
    - requirements.txt # 前の手順で作成したもの
bin
+ - lambda-zip-on-docker.ts # CDKの実行
lib
+ - lambda-execute-on-docker-lambda-stack.ts # Dockerでビルドするためのスタック
```

libにある、Dockerでビルドするためのスタックを実装します。
addDockerImageAssetを指定することで、cdk deployでビルドとECRへの送信まで実施しています。

```typescript: lambda-execute-on-docker-lambda-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { createHash } from "crypto";
import { readFileSync } from "fs";

export type RuntimeDefines = "python-3-12-custom-runtime";

export interface DynamicRuntime {
  repositoryName: string;
  imageTag: string;
}

export class LambdaExecuteOnDockerLambda extends cdk.Stack {
  readonly runtime: Record<RuntimeDefines, DynamicRuntime>;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const stack = cdk.Stack.of(this);

    // Dockerに関係するソースのハッシュを計算する
    // 「lambda-function/python-3-12-custom-runtime」にDockerのソースを置いています
    const directoryName = `${__dirname}/../lambda-function/python-3-12-custom-runtime`;
    const hash = createHash("sha256");
    hash.update(readFileSync(`${directoryName}/Dockerfile`));
    hash.update(readFileSync(`${directoryName}/entrypoint.sh`));
    hash.update(readFileSync(`${directoryName}/requirements.txt`));

    // Dockerイメージを作成する
    // ※ハッシュが更新されない限り、deployをしても再ビルドされません
    const dockerLocation = this.synthesizer.addDockerImageAsset({
      directoryName: directoryName,
      sourceHash: `${stack.stackName}-${hash.digest("hex")}`,
    });

    // 別のスタックから参照できるよう、メンバ変数を通して公開する
    // ※CDK deployの実行時に毎回ハッシュが再計算されます
    // もしハッシュが変わるような編集をしたのなら、このスタックを再デプロイする
    this.runtime = {
      "python-3-12-custom-runtime": {
        repositoryName: dockerLocation.repositoryName,
        imageTag: dockerLocation.imageTag ?? "latest",
      },
    };
  }
}
```

## Dockerのイメージを利用する

前の手順で作成したイメージを利用して、Lambdaを作成します。
下の赤点線内を実装していきます。赤点線内の更新にdockerは不要です。

![data.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/3d3bb084-d6d2-7257-47d8-438abb388760.png)

前の手順でAWSにスタックを作ってあるのなら、CDKで利用することも、SAMで利用することもできます。

### 共通の手順

CDKで利用する場合もSAMで利用する場合も、まずLambda(zip)を作成します。

#### Lambda(zip)のソースコードを作成する

実行したいLambdaのソースコードを作成します。
Lambdaはzipで公開します。

```python: app.py
def lambda_handler(event, context):
    print("called zip-lambda")
    return {"statusCode": 200, "body": "Hello from zip lambda"}
```

### CDKで利用する

CDKで利用する場合、もう一つスタックを作成します。
ファイル構成図の中の`lambda-project-stack.ts`を実装します。

```diff
lambda-function
  - python-3-12-custom-runtime
    - Dockerfile # 前の手順で作成したもの
    - entrypoint.sh # 前の手順で作成したもの
    - requirements.txt # 前の手順で作成したもの
  - zip-lambda
    - app.py # 前の手順で作成したもの
bin
  - lambda-zip-on-docker.ts # CDKの実行
lib
  - lambda-execute-on-docker-lambda-stack.ts # 前の手順で作成したもの
+ - lambda-project-stack.ts # 【ビルドしたイメージを利用するためのスタック】
```

CDKでスタック同士を参照することで、ECRの依存ファイルの変更をすぐに反映させることができます。

```typescript: lambda-project-stack.ts
import * as cdk from "aws-cdk-lib";
import { Repository } from "aws-cdk-lib/aws-ecr";
import { Code, Runtime, Function, Handler } from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";
import {
  RuntimeDefines,
  DynamicRuntime,
} from "./lambda-execute-on-docker-lambda-stack";

// もう一つのスタックから参照するパラメータを定義する
interface LambdaProjectStackProps extends cdk.StackProps {
  runtime: Record<RuntimeDefines, DynamicRuntime>;
}

// ランタイムを読み込む
function readRuntime(
  runtime: Record<RuntimeDefines, DynamicRuntime>,
  runtimeName: RuntimeDefines
) {
  return (
    runtime[runtimeName] ?? {
      repositoryName: "",
      imageTag: "",
    }
  );
}

export class LambdaProjectStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: LambdaProjectStackProps) {
    super(scope, id, props);

    // もう一つのスタックで定義したECRの情報を参照する
    const python312Runtime = readRuntime(
      props.runtime,
      "python-3-12-custom-runtime"
    );

    const stack = cdk.Stack.of(this);

    // 別のスタックで作成したECRのリポジトリを取得する
    const python312RuntimeRepository = Repository.fromRepositoryAttributes(
      this,
      "Python312RuntimeRepository",
      {
        repositoryName: python312Runtime.repositoryName,
        repositoryArn: `arn:aws:ecr:${stack.region}:${stack.account}:repository/${python312Runtime.repositoryName}`,
      }
    );

    // Lambdaの実行ロールを定義する
    // S3の参照権限、Lambdaの参照権限が必要です
    const lambdaRole = new cdk.aws_iam.Role(this, "LambdaRole", {
      assumedBy: new cdk.aws_iam.ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [
        // ログの出力など、基本的な権限を設定する
        cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AWSLambdaBasicExecutionRole"
        ),
        // zipのLambdaの設定を参照するための権限を設定する
        cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName(
          "AWSLambda_ReadOnlyAccess"
        ),
        // Lambdaのソースをダウンロードするための権限を設定する
        cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName(
          "AmazonS3ReadOnlyAccess"
        ),
      ],
    });

    // Lambdaのソースを作成する
    // ソースを置いておくだけで、実行しない
    const dynamicZip = new Function(this, "ZipLambdaFunction", {
      code: Code.fromAsset(`${__dirname}/../lambda-function/zip-lambda`),
      handler: "app.handler",
      runtime: Runtime.PYTHON_3_12,
    });

    // Lambdaを作成する
    // このLambdaを通して、ZipLambdaにあるソースを実行する
    new Function(this, "DockerLambdaFunction", {
      // LambdaのイメージをECRの共通イメージから参照する
      code: Code.fromEcrImage(python312RuntimeRepository, {
        cmd: ["app.lambda_handler"],
        tag: python312Runtime.imageTag,
      }),
      // ランタイムを指定する
      runtime: Runtime.FROM_IMAGE,
      handler: Handler.FROM_IMAGE,
      // タイムアウトを指定する
      timeout: cdk.Duration.seconds(30),
      // ロールを指定する
      role: lambdaRole,
      // 環境変数を設定する
      environment: {
        // Lambdaの関数名、バージョンを指定する
        LAMBDA_FUNCTION_NAME: dynamicZip.functionName,
        LAMBDA_FUNCTION_QUALIFIER: "$LATEST",
        // Lambdaのassetのディレクトリ名を指定する
        // 指定しておくと、Lambdaの関数内で、
        // from ${アセットのディレクトリ名}.app import handler
        // で、ライブラリをimportできる
        LAMBDA_FUNCTION_ROOT: "zip-lambda",
      },
    });
  }
}
```

このスタックを呼び出すbinは以下のように実装します。

```typescript: lambda-zip-on-docker.ts
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { LambdaExecuteOnDockerLambda } from "../lib/lambda-execute-on-docker-lambda-stack";
import { LambdaProjectStack } from "../lib/lambda-project-stack";

const app = new cdk.App();
const baseStackName = "LambdaExecuteOnDockerLambda";

// ECRにイメージを公開する
const baseStack = new LambdaExecuteOnDockerLambda(app, baseStackName, {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

// イメージを利用、Lambdaを作成する
new LambdaProjectStack(app, "LambdaProjectStack", {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  runtime: baseStack.runtime,
});
```

このスタックを実装して、

```bash
# 初回は必要、ECRにDockerイメージを作成する
cdk deploy LambdaExecuteOnDockerLambda

# Lambdaを作成する
cdk deploy LambdaProjectStack
```

を実行すれば、実行用とソースコード管理用のLambdaが作られます。

### SAMで利用する

SAMで利用する場合は、以下のようなテンプレートを作成します。

```yaml: template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Parameters:
  RoleArn:
    Type: String
  ImageUri:
    Type: String

Resources:
  SamSourceFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: zip-lambda/
      Handler: app.lambda_handler
      Runtime: python3.12
      Architectures:
        - x86_64

  SamExecuteFunction:
    Type: AWS::Lambda::Function
    Properties:
      Timeout: 30
      MemorySize: 2048
      Role: !Ref RoleArn
      PackageType: Image
      Code:
        ImageUri: !Ref ImageUri
      Environment:
        Variables:
          LAMBDA_FUNCTION_NAME: !Ref SamSourceFunction
          LAMBDA_FUNCTION_QUALIFIER: "$LATEST"
          LAMBDA_FUNCTION_ROOT: "zip-lambda"
```

実行用Lambdaには、SAM用の`AWS::Serverless::Function`ではなく、CloudFormationの`AWS::Lambda::Function`を利用します。

# まとめ

Lambdaが大きくなるとDockerを検討するしかないのですが、わずかな変更でも更新に時間がかかること、更新のためにWSLが必要になることがネックでした。

今回、Dockerfileで動的にLambdaを組み立てるとネックが解消できるのではないか、解消できるとしてどのくらいコールドスタートに時間がかかるのかを記事にしました。

動的に組み立てたとしても、Lambdaのコールドスタートにかかる時間は0.73秒でした。このくらいの時間であれば許容できるのではないかと思っています。

![duration.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/5b01feee-9ca3-cd70-b0cf-75e4b73005d3.png)