はじめに
この記事では一つのプロジェクトで複数のLambdaが必要なとき、どのようなプロジェクト構成にしたら良いのかについて提案します。先日の記事でLambda Layerに自作のコードを置かないことを提案しました。複数Lambdaでコードを共有するというのがLambda Layerの一つのユースケースのため、それを禁止した場合にどのような構成で開発すると良いのかについて解説します。
フォルダ構成
このフォルダ構成を提案します。左が先日の記事の構成、それを複数Lambdaの構成に変化させたのが右です。
(※なおb1というのはバックエンドのバージョン1という意味です)
サンプルバケットの準備バケットを作成し、適当にファイルを作ります。今回はライトにCLIから作成します。
aws s3 mb s3://dev-multiple-lambda-sample-script
date | aws s3 cp - s3://dev-multiple-lambda-sample-script/date1.txt
date | aws s3 cp - s3://dev-multiple-lambda-sample-script/date2.txt
date | aws s3 cp - s3://dev-multiple-lambda-sample-script/date3.txt
確認します。
$ aws s3 ls s3://dev-multiple-lambda-sample-script --recursive
2023-11-15 10:27:30 29 date1.txt
2023-11-15 10:27:34 29 date2.txt
2023-11-15 10:27:38 29 date3.txt
3ファイルできていることが確認できました。
Pydanticによる環境変数管理
サンプルなので何を実装しても良いのですが、複数のLambdaで共有する例として環境変数の管理をするモジュールを書きます。
環境変数の管理にもpydanticが利用できます。ただし、別パッケージに切り出されているため、利用するためにはインストールする必要があります。
poetry add pydantic-settings
そして app/function/b1/src/config.py を以下の内容で保存します。
import pydantic_settings
class Settings(pydantic_settings.BaseSettings):
model_config = pydantic_settings.SettingsConfigDict(env_file='.env')
bucket_script: str
settings = Settings()
if __name__ == '__main__':
print(settings.model_dump_json(indent=2))
pydantic-settingsはローカル実行のために.envを読むことができます。.envを以下の内容で用意します
BUCKET_SCRIPT=dev-multiple-lambda-sample-script
以下のコマンドでこのモジュールだけを実行できます
$ poetry run python -m app.function.b1.src.config
{
"script_bucket": "dev-multiple-lambda-sample-script"
}
.envがロードされ、意図通りに動作していることが確認できました。
共通モジュールの実装
さらに実践的な例としてutilフォルダも作ってみます。ここは雑多な共通関数置き場です。以下の内容で app/function/b1/util/lambda-decorator.py を用意します。
from typing import Any, Callable, ParamSpec, TypeVar
import logging
logger = logging.getLogger(__name__)
P = ParamSpec('P')
R = TypeVar('R')
def lambda_decorator(fn: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
logger.info(f'[lambda_decorator: start] args: {args}')
res = fn(*args, **kwargs)
logger.info(f'[lambda_decorator: end] res: {res}')
return res
return wrapper
if __name__ == '__main__':
logger.setLevel(logging.INFO)
logging_handler = logging.StreamHandler()
logging_handler.setLevel(logging.INFO)
logger.addHandler(logging_handler)
@lambda_decorator
def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
return {
"status": "ok",
}
handler({"a": "1"}, None)
以下のコマンドで実行できます
$ poetry run python -m app.function.b1.util.lambda_decorator
[lambda_decorator: start] args: ({'a': '1'}, None)
[lambda_decorator: end] res: {'status': 'ok'}
意図通りに動作しています。これでLambdaの入出力をロギングできるようになりました。このデコレータは非常に有用です。
お約束としてapp/function/b1/util/__init__.pyを以下の内容で用意します。
from . import lambda_decorator as lambda_decorator
Lambdaの実装
Lambdaを実装します。以前のLambdaについてもmainフォルダの中に移動させます。
mkdir app/function/b1/src/main
mv app/function/b1/src/main.py app/function/b1/src/main/print_yaml.py
boto3を使用するのでboto3をインストールします
poetry add boto3
poetry add -D 'boto3-stubs[s3]'
app/function/b1/src/main/s3_list_files.py で以下の内容を保存します。
import logging
from typing import Any
import boto3
from .. import util
from ..config import settings
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
logging_handler = logging.StreamHandler()
logging_handler.setLevel(logging.INFO)
logger.addHandler(logging_handler)
@util.lambda_decorator.lambda_decorator
def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
s3_handler = boto3.client('s3')
response = s3_handler.list_objects_v2(Bucket=settings.script_bucket)
return {
"files": [
elm['Key'] for elm in response['Contents']
]
}
if __name__ == '__main__':
logger = logging.getLogger('app')
logger.setLevel(logging.INFO)
logging_handler = logging.StreamHandler()
logging_handler.setLevel(logging.INFO)
logger.addHandler(logging_handler)
handler({}, None)
こちらもローカルで起動できます。
$ poetry run python -m app.function.b1.src.main.s3_list_files
[lambda_decorator: start] args: ({}, None)
[lambda_decorator: end] res: {'files': ['date1.txt', 'date2.txt', 'date3.txt']}
とくにLambda内ではロギング等を実装していないのですが、デコレータのおかげで最低限、入出力は出力されています。
Lambda Layerの更新デプロイの前に、Lambda Layerを更新する必要があります。まず、現在のルートのpyproject.tomlを確認しましょう。
[tool.poetry]
name = "aws-lambda-layer-sample"
version = "0.1.0"
description = ""
authors = ["yamashita-optarc <yamashita@optarc.net>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "~3.11.0"
pyyaml = "^6.0.1"
pydantic-settings = "^2.1.0"
boto3 = "^1.29.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.4.2"
boto3-stubs = {extras = ["s3"], version = "^1.29.0"}
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
そしてLambda Layerのpyproject.tomlはこちらです。
[tool.poetry]
name = "b1"
version = "0.1.0"
description = ""
authors = ["yamashita-optarc <yamashita@optarc.net>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "~3.11.0"
pyyaml = "^6.0.1"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
dev.dependencyについてはローカルで使用するモジュールのため、Lambdaにデプロイする必要はありません。Lambda Layerには容量制限がありますし、大きなLayerを作成するとコールドスタートの時間が増えます。必要ないモジュールはバンドルしないようにしましょう。
さて、結局必要なのはpydantic-settingsとboto3ですね。これをインストールしましょう。
cd app/function-layer/b1/
poetry add pydantic-settings boto3
レイヤーの再作成はmakeから可能です。
make clean
make init
cd –
Lambdaのデプロイこのプロジェクトをデプロイするためには全てのLambdaについて同じソースで、エントリーポイントだけ違うという設定にしなければいけません。samにはこの構成でも自然にデプロイできます。
GlobalにCodeUriが設定できるため、このsamテンプレート全体で同じソースを参照するように設定できます。
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Parameters:
Prefix: { Type: String }
Globals:
Function:
Runtime: python3.11
CodeUri: ./app/function/b1
Resources:
FunctionLayerB1:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: !Sub ${Prefix}-b1
ContentUri: ./app/function-layer/b1/dist
FunctionB1PrintYaml:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub ${Prefix}-B1PrintYaml
Handler: src.main.print_yaml.handler
Layers:
- !Ref FunctionLayerB1
FunctionB1S3ListFiles:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub ${Prefix}-B1ListFiles
Handler: src.main.s3_list_files.handler
Layers:
- !Ref FunctionLayerB1
デプロイはいつもの通り以下のコマンドです。
sam deploy --stack-name dev-multiple-lambda-sample --capabilities CAPABILITY_NAMED_IAM --resolve-s3 --parameter-overrides Prefix=dev-multiple-lambda-sample
実行
Lambdaコンソールからdev-multiple-lambda-sample-B1ListFilesを実行します。そうすると以下のようなエラーが出ます。
Pydanticのエラーです。たしかに環境変数の設定をしていませんでした。この様になにかおかしい状態になったら速やかに終了するという原則をpydanticを使うことで自然に構築できます。
Parameters:
Prefix: { Type: String }
BucketScript: { Type: String }
Globals:
Function:
Runtime: python3.11
CodeUri: ./app/function/b1
Environment:
Variables:
BUCKET_SCRIPT: !Ref BucketScript
ParametersにBucketScriptを追加し、Globalsに参照を追加します。改めてデプロイします。BucketScriptのパラメータが増えたので、その値も与える必要があります。
sam deploy --stack-name dev-multiple-lambda-sample --capabilities CAPABILITY_NAMED_IAM --resolve-s3 --parameter-overrides Prefix=dev-multiple-lambda-sample BucketScript=dev-multiple-lambda-sample-script
改めて実行しますが、今度はs3の権限がないようです。
対象の権限を見つけるのは難しいですが、代表的な権限についてはsamのテンプレートが参考になります。ただしこのポリシーテンプレート機能を使うのではなく、これを参考にして全てベタ書きしたほうが柔軟性が上がると思います。
Resources:
PolicyFunctionB1:
Type: AWS::IAM::ManagedPolicy
Properties:
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- s3:GetObject
- s3:ListBucket
- s3:GetBucketLocation
- s3:GetObjectVersion
- s3:GetLifecycleConfiguration
Resource:
- !Sub arn:aws:s3:::${BucketScript}
- !Sub arn:aws:s3:::${BucketScript}/*
FunctionB1S3ListFiles:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub ${Prefix}-B1ListFiles
Handler: src.main.s3_list_files.handler
Layers:
- !Ref FunctionLayerB1
Policies:
- !Ref PolicyFunctionB1
改めてデプロイすると今度こそ実行に成功します。
print_yaml Lambda実装
print_yamlのLambdaもlambda_decoratorを使うように改修します。
import logging
from typing import Any
import yaml
from .. import util
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
src_logger = logging.getLogger('src')
src_logger.setLevel(logging.INFO)
def yaml_loads(target: str) -> dict[str, Any]:
return yaml.safe_load(target)
@util.lambda_decorator.lambda_decorator
def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
target_yaml = '''\
a: 1
b: 2
c: 3
'''
print(yaml_loads(target_yaml))
return {
"status": "ok",
}
デプロイして実行すると意図通りに動作します。
まとめ
Lambda Layerを使わずに複数Lambdaで共通のコードを利用する方法について解説しました。全てのLambdaが同じコードを持っていることについて違和感がある方もいると思いますが、速度とメンテナンス性を考えたときにこの構造をおすすめします。Lambdaの構築方法に悩んだ際は思い出していただけると幸いです。
Comments