開発・運用しているアプリケーションでServerlessFrameworkを利用したバックエンド(API)の管理をしていますが、1スタックのリソースが500(以前は200)を超えることによりデプロイできないエラーが生じました。
実は、同様のエラーは以前にも遭遇しており、その際はAWS Lambdaのバージョンを削除することとserveless-plugin-split-stacksプラグインを利用することで対応しました。
この時の対応も別記事でエントリーしています。
serveless-plugin-split-stacksを入れることで今後も安泰なのかと思っていましたが、そういうわけではなく今回再びエラーが生じました。
そこで、今回はAPI Gatewayのスタックを分割する方式でリソース数の削減に取り組みます。
ただし、すでに運用中のアプリケーションですので、スタックを分けるにしてもAPIのURLが変わることがないようにするなど既存アプリの改修である点を考慮して進めていきます。
エラーの概要
ServerlessFrameworkを利用し、CloudFormationでAWSサービスを展開しています。
ServelessFrameworkで作成しているサービスは主にAPI GatewayとAPI Gatewayに紐づくAWS Lambdaサービスです。
serveless-plugin-split-stacksプラグインを用いることで、スタックの分割は実施していましたが、そもそものAPI Gatewayが1スタックで構築されていることから、APIパスが増えることによって、rootのリソース数が500を超えてしまています。
他のプロジェクトでは、AWS CDKを利用して、1APIパス×1lambdaの構成ではなく、API Gatewayのパスをanyで対応し、Lambda側で制御することが出来ています。
しかし、現在エラーが発生しているプロジェクトは、AWS CDKを学習する前に開発開始したもので、1APIパス×1lambdaという構成になっておりスタックのリソースを圧迫していました。
API設計がよくないことは明白ではありますが、このようにAPIパスがものすごい数になっています。
エラー解消の方針
今回は、APIGatewayの定義を親スタックで作成していきます。
さらに、lambdaの定義は子スタックに分割します。子スタックは、親スタックであるAPIGatewayのリソース情報を定義したうえで構築することとします。
親スタックであるAPIGatewayはスタック名を既存のスタック名と同じにすることでAPIGatewayのURLが変更されないように作成予定です。
既存のserveless.ymlの改修
まずはすでに作成しているserveless.ymlを改修していきます。
このserveless.ymlをAPI Gatewayのサービス作成のみを定義して、APIパスの作成とlambda関数は作成しないようにします。
また、この後作成するAPIパスとlambda関数のスタックからこのスタックを参照できるようにOutputの設定を行います。
現状の構成
現状の構成は、Cognitoを利用したAPI Gateway AuthorizerのRecources構成と各種APIパスとLambdaを紐づけたfunctionによる構成でした。
現状の構成はあまり重要ではないので、サラッと読み飛ばしてください。
service: My-app
frameworkVersion: '3'
provider:
name: aws
runtime: python3.9
region: ${opt:region, "ap-northeast-1"}
versionFunctions: false
stage: ${opt:stage, self:custom.defaultStage}
custom:
defaultStage: dev
plugins:
- serverless-python-requirements
- serverless-plugin-split-stacks
resources:
Resources:
ApiGatewayWithAuthorizationAuthorizer:
Type: AWS::ApiGateway::Authorizer
DependsOn:
- ApiGatewayRestApi
Properties:
Name: ApiGatewayWithAuthorizationAuthorizer
RestApiId:
Ref: ApiGatewayRestApi
IdentitySource: method.request.header.Authorization
Type: COGNITO_USER_POOLS
ProviderARNs:
- '{{ cognito_arn }}'
- '{{ cognito_arn }}'
functions:
getUser:
handler: src/API/get_user.handler
memorySize: 256
timeout: 30
events:
- http:
path: /user
method: GE
cors: true
authorizer:
type: COGNITO_USER_POOLS
authorizerId: !Ref ApiGatewayWithAuthorizationAuthorizer
layers:
- {{ layer_arn }}
environment: - {{ fileに定義 }}
vpc: {{ fileに定義 }}
role: {{ fileに定義 }}
# ※以降、他function記載。
# ※実際には、外部ファイル参照としています。
スタックの修正
まずは、このスタックはAPI Gatewayの定義を行っていきます。resoucesに対して、ResourcesのほかにOutputを定義していきます。
resources:
Resources:
ApiGatewayWithAuthorizationAuthorizer:
# ---ApiGatewayWithAuthorizationAuthorizerは既存のまま
# ---ApiGatewayWithAuthorizationAuthorizerの定義
Outputs:
apiGatewayRestApiId:
Value:
Ref: ApiGatewayRestApi
Export:
Name: ${self:service}-restApiId-${self:provider.stage}
apiGatewayRestApiRootResourceId:
Value:
'Fn::GetAtt': [ApiGatewayRestApi, RootResourceId]
Export:
Name: ${self:service}-rootResourceId-${self:provider.stage}
apiGatewayWithAuthorizationAuthorizer:
Value:
Ref: ApiGatewayWithAuthorizationAuthorizer
Export:
Name: ${self:service}-CognitoAuthorizerId-${self:provider.stage}
これで、このスタックで定義したAPIGatewayのIDとルートパスとなる/のIDをアウトプットしています。
また、別途定義したApiGateway Authorizer定義も子スタックで利用するためアウトプットで定義しました。
ちなみにAPI GatewayのIDとルートパスのIDはコンソール画面から確認も可能です。
functionsの定義は子スタックで対応するため、不要なのですがServelessFrameworkの仕様上、functionsが定義されていることで、RestApiを自動作成してくれます。
そこで、functionsにパスを一つだけ追加しました。
ただこのパスはlambda関数と紐づける必要もないためAPIにアクセスするとスタック名を返すだけのAPI作っておきました。
functions:
versionEndpoint:
handler: handler.version
events:
- http:
path: version
method: get
integration: mock
request:
template:
application/json: "{\\"statusCode\\": 200}"
response:
headers:
Content-Type: "'text/plain'"
template: ${self:service}
statusCodes:
200:
pattern: ''
そのほかの設定としては、lambda関数定義用に作成していたfunctionsを削除したり、lambda関数のコードであるpythonファイルなどを削除していきます。
Lambdaスタックの新定義とフォルダ分け
次に親スタックで作成したAPI Gatewayの設定を引き継ぎ、APIパスとLambda関数を定義するスタックを作っていきます。
まずは、これまで一つのserveless.ymlに記載していたものを分割していきます。
従ってこれまで一つのserveless.ymlファイルで管理していたのですが、これが複数ファイルになるためサーバーサイドアプリのディレクトリに構成を変更する必要があります。
対応としては二つあります。
ちなみに今回は方法2で実装しています。
- ルートディレクトリにserverless-apigateway.yml, serverless-lambda.ymlのように構成ファイルを複数作成する。
- ルートディレクトリにapigateway/ディレクトリとlambda/ディレクトリを作成し、その直下にそれぞれserveless.ymlファイルを作成する。
【方法1】serveless.ymlの名称を変更する
まずは、sereverless.ymlを複数に分けるパターン。
アプリ構成の変化が少ないため対応自体は簡略化できますが、いくつか問題が生じます。よって最終的には採用していないです。
例えば、APIGatewayのスタックをserveless-apigateway.ymlとして、Lambdaのスタックをserverless-lambda.ymlとします。
そして、sls deployコマンドを実行するときにconfigオプションを付与して、serveles定義のファイルをしています。
sls deploy --config serveless-apigateway.yml --statge dev
sls deploy --config serveless-lambda.yml --stage dev
この方法は、オプションが増えるもののサーバーサイドのフォルダ構成も簡潔になります。
ただし、ファイル名を正確に記載しないといけなかったり、スタックの依存関係が明確でなくなってしまうため最良の方法ではありません。
さらにVS Codeなどを利用して、フォルダ管理していると、serveless.ymlとserveless-***.ymlではファイルアイコンが変わってしまいます。
これは大問題です!!
【方法2】ディレクトリを分ける
今回採用した方法です。
これまでルートディレクトリ下にserveless.ymlを構築していますが、スタックごとにディレクトリを作成します。
API Gateway用のリソースとLambda用のリソースがこれまで一つのディレクトリ下で構成していましたが、それぞれに必要なリソースだけを定義すればいいのでやや改修に手間がかかります。
メリットとして、スタックの設定、依存関係、リソースの明確さからスタックごとにディレクトリを分けることになるためより明快な構成となります。
/my-app
/apigatewayStack
- serverless.yml
- ...(その他のファイルやディレクトリ)
/lambdaStack
- serverless.yml
- ...(その他のファイルやディレクトリ)
こうすることで、デプロイ時のconfigオプションは不要になります。
ただし、デプロイするときは、ディレクトリの移動が必要になることには注意が必要です。
cd apigatewayStack
sls deploy --statge dev
cd ..
cd lambdaStack
sls deploy --stage dev
方法2を採用した理由
方法1は特定のS3バケットなど一度定義すれば、お役御免になる場合など単発利用には適しているかもしれません。
基本のスタックはserveless.ymlとして、単発で必要な場合のみ、serveless-s3.ymlなどをconfigオプションを付けてデプロイするなどです。
ただ、今回はあくまで親と子で分けることになるため、明確化することが必要だと感じました。
また、API Gatewayのスタックにはlambda関数を記載いしているpythonファイルは不要になります。
さらに将来を考慮すると、API Gatewayには依存しないイベント駆動Lambdaなどの構築も考えられます。
実際にはスタックごとにディレクトリを分け以下のような構成としています。serveless.yml以外の設定ファイルがいくつかありますが、ご了承ください。
Lambda定義のserverless.ymlの作成
それでは、先ほど作成したlambdaディレクトリ下にserveless.ymlファイルを作成していきます。
まずは、スタック名をこれまでのものと変える必要があります。
この時スタック名=service名をlambda関数の名前などに利用してると変更されますので注意が必要です。
また、ファイル名が異なるとLambda関数のArnなどが変更になります。Lambda Invokeなどで参照しているLambdaであったり、他のサービスからイベント駆動している場合は、参照元の変更も忘れないようにする必要があります。
親スタック(API Gatewayスタック)から引き継ぎ
まずは、このスタックで親スタックで定義したAPI GatewayのIDを引き継ぎます。
そのため、providerとcustomの設定を以下のように修正しました。
service: Myapp-api-lambda
frameworkVersion: '3'
provider:
name: aws
runtime: python3.9
region: ${opt:region, "ap-northeast-1"}
versionFunctions: false
stage: ${opt:stage, self:custom.defaultStage}
apiGateway:
restApiId:
${cf:${self:custom.apigatewayStackName}.apiGatewayRestApiId}
restApiRootResourceId:
${cf:${self:custom.apigatewayStackName}.apiGatewayRestApiRootResourceId}
custom:
apigatewayStackName:
Myapp-apigateway-${self:provider.stage}
customの設定は任意ですが、API Gatewayのスタックの名称は手入力し、provider内にapiGateway設定を追加し、restApiId
とrestApiRootResourceId
を引き継いでいます。
内容としては、cfでCloudFormationを参照しており、すでにデプロイ済みのAPI Gatewayスタック名を参照し、アウトプットで定義した値を引き継いでいます。
functions定義の修正
これで、あとはfunctionsにパスとLambda関数を定義することで、API Gatewayにぶら下げてLambda定義が可能です。
パスも変更する必よがないため、基本的には既存のLambda定義をコピーすることが可能です。
ただ、一つ必要な修正があり、ApiGateway Authorizer設定は、Lambda定義内で記載が必要になります。
例えば、冒頭でも掲載したgetUserのAPIは以下のようになりました。
custom:
apigatewayAuthorizer:
Myapp-apigateway-CognitoAuthorizerId-${self:provider.stage}
functions:
getUser:
handler: src/API/get_user.handler
memorySize: 256
timeout: 30
events:
- http:
path: /user
method: GET
cors: true
authorizer:
type: COGNITO_USER_POOLS
authorizerId:
Fn::ImportValue: ${self:custom.apigatewayAuthorizer}
layers:
- {{ lambda_layer_Arn }}
environment: {{ fileにて管理 }}
vpc: {{ fileにて管理 }}
role: {{ fileにて管理 }}
まず、親スタックで定義した、apiGatewayWithAuthorizationAuthorizerの設定から、Export:
Name:
で定義した値を利用するため、この値をcustomに定義しています。
そして、events内のauthorizerId
の設定をやや見慣れない形ですが、Fn::ImportValue: ${self:custom.apigatewayAuthorizer}
として親スタックの定義を参照しています。
スタック変更によるlambda関数のコード修正
lamda定義のスタックもこれまで設定してきたような変更で対応は完了していますが、一部Lambdaのコード内で修正が必要なる場合もあるので、必要に応じてlambda関数のコード修正していきます。
lambda Invokeがある場合
まずは、lambda incvokeしている場合で、FunctionNameを既存のlambda関数から定義していた場合です。
今回は、スタック名-ステージ名-関数名というlambdaの命名規則としているため、スタック名に変更が生じます。
My-appとハードコードされている箇所を親スタックに合わせて、My-app-apigatewayなどに変更していきました。
他サービスからイベント駆動がある場合
他の注意点としては、ApiGateway駆動ではない、イベント駆動のlambda関数を定義している場合です。
S3のcreate objectをトリガーにしたLambda関数も既存スタックでは定義していました。lambda invokeの時と同様ですが、FunctionNameに変更が生じているため、トリガー先を見直していきます。
slsデプロイの実施
ここまで、作成できればあとはデプロイするだけです。
注意点としては、親スタックを先にデプロイしておく必要があることです。子スタックは親スタックのIDを引き継ぐため、すでに構築済みである必要があります。
親スタック(API Gatewayスタック)のデプロイ
特段これまでとコマンドに違いはありません。
ただ、ディレクトリの階層が異なったため、チェンジディレクトリが必要になります。
cd apigateway
sls deploy --aws-profile {{profileName}} --stage {{stageName}}
子スタック(Lambdaスタック)のデプロイ
親スタックが出来た後に子スタックをデプロイします。
cd api-lambda
sls deploy --aws-profile {{profileName}} --stage {{stageName}}
最終的なスタックとリソース数
最終的には、API Gatewayの定義を行うapigateway
スタック、APIGatewayのパスとlambda関数を定義するapi-lambda
スタック、API Gateway駆動以外のイベント駆動型lambda関数を定義するevent-lambda
スタックの三つに分けました。
相変わらず、api-lambdaスタックが300越えのリソース数ではありますが、500以下にすることが出来ました!!
apigatewayスタック
api-lambdaスタック(API駆動またはその中で利用するinvoke lambda)
event-lambdaスタック(イベント駆動)
おわりに
今回は、既存の巨大スタックを親子関係のスタックに分ける方法の備忘録でした。
参考にしたサイトにも以下のように書かれています。
スタック(=serverless.yml)の再構成というのは、アプリがそれなりの規模になればなるほど困難になってしまうので、たとえ面倒でも始めのうちにしっかり考えて、最適な構成にしておきたいものです。
echo(“備忘録”);
約1年半前に開発開始したプロジェクト。
当時は脱AWS コンソールでの直接のサービスを掲げたはじめてのServelessFramworkでした。
なんちゃってアジャイル開発だと言い張り、結局は見切り発車になっていたかなと思います。まだまだ継続開発中の既存アプリだと新規機能の開発と既存コードのリファクタリングせめぎ合いに会うことが多々あります。
失敗を恐れては、何も生み出せないので、今回のエラーもいい勉強になったと次につなげていきたいです。
参考サイト
このサイト以外にもアドバイスいただける先生にたくさん教わりました。
API Gatewayスタック定義の全コード
service: My-app-apigateway
frameworkVersion: '3'
provider:
name: aws
runtime: python3.9
region: ${opt:region, "ap-northeast-1"}
versionFunctions: false
stage: ${opt:stage, self:custom.defaultStage}
custom:
defaultStage: dev
resources:
Resources:
ApiGatewayWithAuthorizationAuthorizer:
Type: AWS::ApiGateway::Authorizer
Properties:
Name: ApiGatewayWithAuthorizationAuthorizer
RestApiId:
Ref: ApiGatewayRestApi
IdentitySource: method.request.header.Authorization
Type: COGNITO_USER_POOLS
ProviderARNs:
- '{{arn:aws:cognito-idp:}}'
- '{{arn:aws:cognito-idp:}}'
Outputs:
apiGatewayRestApiId:
Value:
Ref: ApiGatewayRestApi
Export:
Name: ${self:service}-restApiId-${self:provider.stage}
apiGatewayRestApiRootResourceId:
Value:
'Fn::GetAtt': [ApiGatewayRestApi, RootResourceId]
Export:
Name: ${self:service}-rootResourceId-${self:provider.stage}
apiGatewayWithAuthorizationAuthorizer:
Value:
Ref: ApiGatewayWithAuthorizationAuthorizer
Export:
Name: ${self:service}-CognitoAuthorizerId-${self:provider.stage}
functions:
versionEndpoint:
handler: handler.version
events:
- http:
path: version
method: get
integration: mock
request:
template:
application/json: "{\\"statusCode\\": 200}"
response:
headers:
Content-Type: "'text/plain'"
template: ${self:service}
statusCodes:
200:
pattern: ''
Lambdaスタック定義の全コード
service: My-app-api-lambda
frameworkVersion: '3'
provider:
name: aws
runtime: python3.9
region: ${opt:region, "ap-northeast-1"}
versionFunctions: false
stage: ${opt:stage, self:custom.defaultStage}
apiGateway:
restApiId:
${cf:${self:custom.apigatewayStackName}.apiGatewayRestApiId}
restApiRootResourceId:
${cf:${self:custom.apigatewayStackName}.apiGatewayRestApiRootResourceId}
custom:
apigatewayStackName:
My-app-apigateway-${self:provider.stage}
apigatewayAuthorizer:
My-app-apigateway-CognitoAuthorizerId-${self:provider.stage}
defaultStage: dev
functions:
getUser:
handler: src/API/get_user.handler
name: ${self:service}-${self:provider.stage}-get-user
memorySize: 256
timeout: 30
events:
- http:
path: /user
method: GET
cors: true
authorizer:
type: COGNITO_USER_POOLS
authorizerId:
Fn::ImportValue: ${self:custom.apigatewayAuthorizer}
layers:
- {{ lambda_layer_arn }}
environment: {{ file内で定義 }}
vpc: {{ file内で定義 }}
role: {{ file内で定義 }}
コメント