この記事について
AWSのLambdaには、zip
とImage
の形式があります。
この2つの形式にはいくつかの違いがあります。一部を上げると、下の表のような違いがあります。
zip | Image | |
---|---|---|
実行環境 | AWSが提供したもの | 自由 |
デプロイにかかる時間 | 数秒、ライブラリによっては数分 | 数分~数時間 |
Web上でソースを編集 | ライブラリが小さければ可能 | 不可 |
デプロイできるファイルサイズ | 50MBまで | 10GBまで |
docker | 不要 | 必要 |
この記事で紹介する実行方法は、zip
とImage
のいいとこどりをします。
zip+Image | |
---|---|
実行環境 | 自由 |
デプロイにかかる時間 | 数秒 |
Web上でソースを編集 | 可能 |
デプロイできるファイルサイズ | 10GBまで |
docker | 不要 |
zip
としてソースコードをデプロイしながら、Image
の自作ランタイム上で動かします。
実際に動かしているところ
実際に動かしているところをキャプチャで紹介します。
ginza(※数GBある形態素解析用のライブラリ)を使ったLambdaの動きです。
-
Lambdaのテストボタンを押して実行します。
ハードコードしていた「銀座でランチをご一緒しましょう」が処理されました。
-
Lambdaのテストボタンを押して再実行します。
先の手順の更新が反映されて、「吾輩は猫である、名前はまだない」が処理されました。
どうやってやるの
実行用、ソースコード管理用の、2つのLambdaをペアで作ります。
リソースの配置は以下のようになります。Dockerのビルドが必要なECR部分のスタックを分けることで、dockerのない環境でもLambdaが管理できるようになります。
処理の流れは以下のようになります。実行用Lambdaの起動スクリプトの中で、ソースコード管理用Lambdaのソースコードをダウンロード、展開して、動的にマウントします。
ただのLambda(zip)に比べて、コールドスタートでかかる時間は0.6秒〜ほど長くなります。
実装の説明
最終的なソースコードはこちらです。
また、こちらのブランチにGinzaのサンプルソースを置いています。
Dockerイメージを実装する
まず、ECRのDockerイメージを作ります。
下の図の赤点線枠内の部分です。この部分の手順はdockerが必要になります。
ステップ1: ECRのソースコードを実装する
ECRにデプロイするソースコードとして、3つのファイルを作成します。
Dockerfile # イメージをビルドするためのファイル
entrypoint.sh # Pythonのソースをダウンロード、実行するためのファイル
requirements.txt # Pythonの依存ライブラリを定義するためのファイル
Dockerfileでは、entrypoint.shを実行するように設定します。
Dockerfileの外ではtmpディレクトリ以外への書き込み権限がなくなるため、パスは/var/task
と/tmp/var/task
へ通すようにします。
もし何かしらのインストールが必要なら、dnf install -y
でインストールすることができます。
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ファイルを動的にダウンロードして展開します
※説明のためにコメントを入れていますが、実際の動作では不要なので、コメントは外すようにしてください
#!/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ライブラリがあればここに記載します。
※今回のサンプルでは空ファイルにします。
CDKの実装
作成したDockerfileをビルド、ECRに送信できるように、CDKを実装します。
ファイルの構成は以下のようにします。
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への送信まで実施しています。
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は不要です。
前の手順でAWSにスタックを作ってあるのなら、CDKで利用することも、SAMで利用することもできます。
共通の手順
CDKで利用する場合もSAMで利用する場合も、まずLambda(zip)を作成します。
Lambda(zip)のソースコードを作成する
実行したいLambdaのソースコードを作成します。
Lambdaはzipで公開します。
def lambda_handler(event, context):
print("called zip-lambda")
return {"statusCode": 200, "body": "Hello from zip lambda"}
CDKで利用する
CDKで利用する場合、もう一つスタックを作成します。
ファイル構成図の中のlambda-project-stack.ts
を実装します。
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の依存ファイルの変更をすぐに反映させることができます。
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は以下のように実装します。
#!/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,
});
このスタックを実装して、
# 初回は必要、ECRにDockerイメージを作成する
cdk deploy LambdaExecuteOnDockerLambda
# Lambdaを作成する
cdk deploy LambdaProjectStack
を実行すれば、実行用とソースコード管理用のLambdaが作られます。
SAMで利用する
SAMで利用する場合は、以下のようなテンプレートを作成します。
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秒でした。このくらいの時間であれば許容できるのではないかと思っています。