AWS Lambdaの構成を考える
はじめに
AWS Lambdaによるサーバーレス構成の開発を行う際、アーキテクチャ構成としてLambdalith(モノリスなLambda)や単一責任の比較を考慮することがある。そこで、今回は比較の観点からもう少し深く掘り下げてみる。
対象となる読者は、Lambdaなどのサーバーレスサービスを導入予定、または利用し始めたばかりのアーキテクト、アーキテクチャ構成を検討しているエンジニアを想定している。
またLambdaのランタイム(プログラミング言語)については、PythonやNode.jsなどのzipファイルでデプロイ可能なものを前提としているが、コンテナでのデプロイについても触れる。
Lambdalith(モノリスなLambda)と単一責任とは
Lambdalith(モノリスなLambda)とは、1つのLambda関数に複数の機能を持たせるアーキテクチャ構成である。
これに対して、単一責任は1つのLambda関数に1つの機能(APIエンドポイント、HTTPメソッドごと)を持たせるアーキテクチャ構成である。
また、その中間的な存在として、読み込みと書き込みを分離するアーキテクチャ構成もある。この手法は、コマンドクエリ分離(CQRS)を利用しており、それぞれメモリなどインフラストラクチャの最適化をできる。
データを受け渡すだけのLambdaを作らない
実際にアーキテクチャ構成の詳細に話題を移す前に、あまり広まっていないベストプラクティスを記述しよう。
AWSでサーバーレスアーキテクチャを構成する際に、API Gataway、Lambda、DynamoDBでサービスを構成することを提唱されている。しかしLambdaは利用しない方が良い。
なぜLambdaを利用しない方が良いと考えるようになったのかというと、AWSサービスによるモニタリングのシステム構成についての記事で読んだ内容がきっかけである。
Lambdaを利用する場合、多くの場合はCloudWatch Logsにログを出力するだろう。そこでエラーが発生した場合、SNSから運用・保守用のメールアドレスやSlackに通知するシステムを構築するだろう。
このシステム構築方法について、データを受け渡すだけのLambdaを作らずに、AWSサービスのみで構成することを提唱している。
これはシステム監視に限らず、メインのシステムにおいても同様のはずである。
ではLambdaを利用しない場合、どのようにシステムを構成するのかというと、API Gatewayから直接DynamoDBにアクセスすれば良い。
以下はuser_idを指定してユーザー情報を取得するAPIをswagger形式で記載している。
...
paths:
/user:
get:
summary: ユーザー情報を取得する
parameters:
- name: user_id
in: query
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
type: object
headers:
Access-Control-Allow-Origin:
schema:
type: string
Access-Control-Allow-Methods:
schema:
type: string
Access-Control-Allow-Headers:
schema:
type: string
x-amazon-apigateway-integration:
uri:
Fn::Sub: arn:aws:apigateway:${AWS::Region}:dynamodb:action/GetItem
passthroughBehavior: when_no_templates
httpMethod: POST
type: aws
credentials:
Fn::Sub: arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-dynamodb-execution-role
requestTemplates:
application/json: |
{
"TableName": "users_$stageVariables.Environment",
"Key": {
"user_id": {
"S": "$input.params('user_id')"
}
}
}
responses:
200:
statusCode: 200
responseTemplates:
application/json: |
#set($inputRoot = $input.path('$'))
{
"user": {
"user_id": "$inputRoot.Item.user_id.S",
"mailaddress": "$inputRoot.Item.mailaddress.S",
"created_at": "$inputRoot.Item.created_at.S",
"updated_at": "$inputRoot.Item.updated_at.S"
}
}
...
デプロイ単位
隠れたベストプラクティスを見てきたので、次は具体的なアーキテクチャ構成を見ていこう。
Lambdaをデプロイする場合、AWS SAM、CloudFormation、Serverless FrameworkなどのIaC(Infrastructure as Code)を利用することがほとんどだろう。
その際に全ての関数を1つのプロジェクトにまとめてデプロイするのか、関数ごと/マイクロサービスごとににプロジェクトを分けてデプロイするのかを考える必要がある。
すなわち、大まかに
- 1つのプロジェクト×Lambdalith
- 1つのプロジェクト×単一責任
- マイクロサービスごと×Lambdalith
- マイクロサービスごと×単一責任
の選択肢があるということだ。
1つのプロジェクト
1つのプロジェクトについて、AWS SAMを想定して例を記載する。
template.yaml
...
Resources:
GetUserFunction:
Type: AWS::Serverless::Function
Properties:
Handler: get_user.lambda_handler
Runtime: python3.13
CodeUri: ./src
...
PostUserFunction:
Type: AWS::Serverless::Function
Properties:
Handler: post_user.lambda_handler
Runtime: python3.13
CodeUri: ./src
...
swagger.yaml
...
paths:
/user:
post:
summary: ユーザ情報を登録する
requestBody:
$ref: '#/components/requestBodies/postUser'
responses:
200:
$ref: '#/components/responses/success'
x-amazon-apigateway-integration:
uri:
# PostUserFunction関数を呼び出す
Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PostUserFunction.Arn}/invocations
passthroughBehavior: when_no_templates
httpMethod: POST
type: aws_proxy
credentials:
Fn::Sub: arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-lambda-execution-role
...
上記の例は、POST /userを呼び出すと、PostUserFunction関数が呼び出され、GET /userを呼び出すと、GetUserFunction関数が呼び出される単一責任の形である。
マイクロサービスごと
マイクロサービスごとにプロジェクトを分ける場合は、マイクロサービスごとにsam init
を実行してプロジェクトを作成し、template.yamlをそれぞれ配置することになる。
プロジェクトごとにマイクロサービスでコードが凝縮され、デプロイ時間が長引かなく、新規機能追加やバグ修正の際に、他のマイクロサービスに影響を与えないようにすることができる。
コンテナデプロイ
AWS SAMでは、パッケージタイプとしてImage
とZip
がある。PythonやNode.jsなどのランタイムであれば、Zip
を利用することが多いだろう。
例えば、少し前に形態素解析関数を作成することになったのだが、mecabなどのコンテナへのインストールを行い、AWS ECRにプッシュして、Lambda関数をデプロイすることにした。
また、Rustなどの言語で開発する場合、Zip
ではなくImage
を利用することになるだろう。
APIフレームワークの利用
Lambdalithで開発を行う場合、どの記事を見てもAPIフレームワークを利用することが前提となっている。
その場合、リクエストのチェックをLambda関数内のAPIフレームワークの機能で実施することが多いだろう。
もしもオンプレミスやEC2などのサーバー上からサーバーレスへ移行するのであれば、APIフレームワークを利用することは良い選択肢だろう。
しかし、初めからサーバーレスで開発を行う場合、API Gatewayのリクエストバリデーション機能を積極的に利用することをお勧めする。その上でAPIフレームワークを利用せずに関数内でルーティングすることでLambdalithを実現することもできるだろう。
def lambda_handler(event, context):
if event['httpMethod'] == 'POST':
# POSTメソッドの処理
pass
elif event['httpMethod'] == 'GET':
# GETメソッドの処理
pass
関数のトリガー
Lambda関数のトリガーは、API GatewayやS3、SNSなど様々なものがある。
トリガーの種類によっては、Lambda関数を分ける必要があるかもしれない。
真っ先に思いつくのは、API Gatewayのタイムアウト30秒に合わせてAPI Gatewayをトリガーとする関数のタイムアウトを30秒にし、他の最大15分の関数と分けることだろう。
Lambdalithを採用するとしても、関数を切り出すことを考慮する必要がある。
おわりに
Lambdalith(モノリスなLambda)と単一責任の比較を行う際に考慮すべき観点を記載してきた。
開発体制や設計などによって、これらを組み合わせて利用することもあるだろう。
この記事を通じて、考えをまとめる手助けになれば幸いである。
Discussion