Open33

Amplify Rest APIで利用してるLambdaをProvisioned Concurrencyモードで利用するまでの勉強スレッド

ShinkawaShinkawa

Amplify Rest APIのLambdaでNest.jsを使っているが、Lambdaが動いてないときにアクセスすると4-7秒ほどレスポンスに時間がかかる。(コールドスタート)
[1]LambdaをProvisioned Concurrencyモードで起動させて、
ApiGatewayからは上記のLambdaに接続することで改善を試みる

ShinkawaShinkawa

Cloudformationのパラメータどこで見ればいいんだっていつもなってたけどここか!
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html

リソースタイプによって利用できるプロパティは異なるので、公式ドキュメントとにらめっこしながら、指定していきます。
https://dev.classmethod.jp/articles/cloudformation-beginner01/

こりゃ確かににらめっこしながら設定してくやつだ(笑)
ChatGPTも間違いがよくあるからAWSでインフラやるなら自分で覚えるのは必須だな

ShinkawaShinkawa

よし、いったん基礎を詰め込むのはここまでで
ApiGatewayとLambdaでAPIを構築するcloudformationを作成してみよう

ShinkawaShinkawa

普通のlambdaを構築するテンプレート

AWSTemplateFormatVersion: '2010-09-09'
Description: Lambda function Hello World.
Resources:
  primer:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: nodejs18.x
      Role: arn:aws:iam::<accountID>:role/TestLambdaBasicRole // Lambdaに紐づけるロールLambda実行に必要な権限のみ与えてる。このテンプレートではCloudwatchLogsのみ権限を与えてる
      Handler: index.handler
      Code:
        ZipFile: |
          exports.handler = async (event) => {
              const response = {
                  statusCode: 200,
                  body: JSON.stringify('Hello World'),
              };
              return response;
          };
      Description: Hello World Lambda Function
      TracingConfig:
        Mode: Active
ShinkawaShinkawa

Lambdaにversionをつけて、Provisioned Concurrency設定を追加

AWSTemplateFormatVersion: '2010-09-09'
Description: Lambda function Hello World.
Resources:
  MyHelloWorldFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: nodejs18.x
      Role: arn:aws:iam::<accountId>:role/TestLambdaBasicRole
      Handler: index.handler
      Code:
        ZipFile: |
          exports.handler = async (event) => {
              const response = {
                  statusCode: 200,
                  body: JSON.stringify('Hello World'),
              };
              return response;
          };
      Description: Hello World Lambda Function
      TracingConfig:
        Mode: Active
  # Provisioned:
  #     Type: AWS::Lambda::Alias
  #     Properties:
  #       FunctionName: MyHelloWorldFunction
  #       # or 
  #       # Function ARN
  #       FunctionVersion: String
  #       Name: String
  #       ProvisionedConcurrencyConfig: 
  #         ProvisionedConcurrencyConfiguration
  #       RoutingConfig: 
  #         AliasRoutingConfiguration
  MyHelloWorldFunctionVersion:
    Type: AWS::Lambda::Version
    Properties:
      FunctionName: !Ref MyHelloWorldFunction
      Description: v1
      ProvisionedConcurrencyConfig:
        ProvisionedConcurrentExecutions: 3

ShinkawaShinkawa

とりあえず、完成まで少しのところまで来た気がする

まずは、Lambdaの同時実行(Provisioned Concurrency)を設定するコード

amplify/backend/function/${function-name}/${function-name}-cloudformation-template.jsonResourcesセクションにに以下のコードを追加する

    "HogeFunctionVersionName": { // ←自分で名前を決める。Cloudformationの一意な論理名。好きに決めて良い。分かりやすいのが好ましい。
      "Type": "AWS::Lambda::Version",
      "Properties": {
        "FunctionName": {
          "Fn::If": [
            "ShouldNotCreateEnvResources",
            "hogeFunction", // ←function名を入力。やりたいことはFunctionNameに${function-name}-${env}とすること。このあたりの書き方は同じファイルのどこかにあると思う。
            {
              "Fn::Join": [
                "",
                [
                  "hogeFunction", // ←function名を入力。
                  "-",
                  {
                    "Ref": "env"
                  }
                ]
              ]
            }
          ]
        }
      }
    },
    "HogeFunctionAlias": { // ←自分で名前を決める。Cloudformationの一意な論理名。好きに決めて良い。分かりやすいのが好ましい。
      "Type": "AWS::Lambda::Alias",
      "Properties": {
        "FunctionName": {
          "Fn::If": [
            "ShouldNotCreateEnvResources",
            "hogeFunction", // ←function名を入力。
            {
              "Fn::Join": [
                "",
                [
                  "hogeFunction", // ←function名を入力。
                  "-",
                  {
                    "Ref": "env"
                  }
                ]
              ]
            }
          ]
        },
        "FunctionVersion": {
          "Fn::GetAtt": [
            "HogeFunctionVersionName", // ←上で設定したLambdaバージョンの論理名を入力。
            "Version"
          ]
        },
        "Name": "provisioned", // ← 自分で好きに設定する。この項目名がLambdaのAlias名になる。
        "ProvisionedConcurrencyConfig": {
          "ProvisionedConcurrentExecutions": 3
        }
      }
    },

これでLambdaFunctionにAliasを設定し、ProvisionedConcurrentExecutionsで同時実行数を3に設定している。3はプロジェクトによって適時変えればよい。

ShinkawaShinkawa

次にAPIGatewayの統合リクエストで紐づけるLambdaを通常のLambda($latestのLambda)ではなく、同時実行設定のあるAlias付きのLambdaに接続するための手順

amplify override apiを実行する。
amplify/backend/api/${functionName}/override.tsが生成されるはず。
以下のように編集する

amplify/backend/api/${functionName}/override.ts
// This file is used to override the REST API resources configuration
import { AmplifyApiRestResourceStackTemplate } from '@aws-amplify/cli-extensibility-helper'

export function override(resources: AmplifyApiRestResourceStackTemplate) {
  const accountId = 'xxx' // AWSアカウントID
  const functionName = 'hogeFunction' // 関数名
  const env = 'devb' // TODO: 動的に取得する方法を探す
  const region = 'ap-northeast-1' // リージョンを指定
  const alias = 'provisioned' // amplify/backend/function/${function-name}/${function-name}-cloudformation-template.json で指定したエイリアス名
  const lambdaArn = `arn:aws:lambda:ap-northeast-1:${accountId}:function:${functionName}-${env}:${alias}`
  const integrationUri = `arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations`

  // REST APIの各メソッドをループして、新しいLambda関数に紐づける
  for (const path in resources.restApi.body.paths) {
    resources.restApi.body.paths[path]['x-amazon-apigateway-any-method'] = {
      ...resources.restApi.body.paths[path]['x-amazon-apigateway-any-method'],
      'x-amazon-apigateway-integration': {
        ...resources.restApi.body.paths[path]['x-amazon-apigateway-any-method'][
          'x-amazon-apigateway-integration'
        ],
        uri: integrationUri,
      },
    }
  }
}

これでamplify pushを実行した際にAPIGatewayから呼び出されるLambdaがAlias付きのLambdaになる。

ShinkawaShinkawa

これでamplify pushすると自動的にAPIGatewayに同時実行設定のあるLambdaAliasを接続することができたわけだが、実際の運用ではamplify pushするたびにLambdaが最新の状態になってほしい。
それを実現するのが以下のファイル。
説明は省く。

amplify/hooks/post-push.sh
#!/bin/bash

# Lambda 関数名
FUNCTION_NAME="hogeFunction-env"

# 最新バージョンを発行
NEW_VERSION=$(aws lambda publish-version --function-name $FUNCTION_NAME --query 'Version' --output text)

# エイリアス 'provisioned' に新バージョンを紐づけ
aws lambda update-alias --function-name $FUNCTION_NAME --name provisioned --function-version $NEW_VERSION

echo "New version $NEW_VERSION published and aliased to 'provisioned'"

実行後すぐに新しいバージョンでLambdaが動くわけではなく同時実行設定が終わってから新しいバージョンに処理を裁くようにAliasがうまいことやってるみたい。イメージはECSのローリングアップデート?

ShinkawaShinkawa

そして、amplify push するときに権限が必要になるかも。
以下のみの設定で一発でうまくいくか分からないが、自分は以下の設定で最終的にはうまくいった。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "lambda:PutFunctionConcurrency",
                "lambda:DeleteFunctionConcurrency",
                "lambda:ListVersionsByFunction",
                "lambda:CreateAlias",
                "lambda:DeleteAlias",
                "lambda:UpdateAlias"
            ],
            "Resource": [
                "arn:aws:lambda:ap-northeast-1:${accountId}:function:${functionName}-${env}"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "lambda:PutProvisionedConcurrencyConfig",
                "lambda:GetProvisionedConcurrencyConfig"
            ],
            "Resource": [
                "arn:aws:lambda:ap-northeast-1:${accountId}:function:${functionName}-${env}:provisioned" // エイリアスを指定する
            ]
        }
    ]
}
ShinkawaShinkawa

後はoverrideやpost-pushで動的に環境変数を取得する方法の調査
それが終わったら一度新しい環境を生成して検証する

ShinkawaShinkawa
  const projectInfo = getProjectInfo()

を入れるとamplify push時にoverrideファイルが検知されない🤔

- No override file found
- To override cofamiRestApi run "amplify override api"

どんだけはまりポイントあんねん

ShinkawaShinkawa

https://github.com/aws-amplify/amplify-cli/issues/9063#issuecomment-1490911445

override.ts
// This file is used to override the REST API resources configuration
import {
  AmplifyApiRestResourceStackTemplate,
  AmplifyProjectInfo,
} from '@aws-amplify/cli-extensibility-helper'

export function override(
  resources: AmplifyApiRestResourceStackTemplate,
  amplifyProjectInfo: AmplifyProjectInfo
) {
  console.log(amplifyProjectInfo) // undefinedになる

amplify --version

11.0.0

upgradeしてみる。

root@e929bc0bdd4f:/usr/src/app# amplify upgrade
Downloading latest Amplify CLI
100% [========================================] 0.0 seconds left
Successfully upgraded to Amplify CLI version 12.8.2!
はっ!!12まで上がってる!大丈夫か?

再度amplify push実行

⠙ Fetching updates to backend environment: devb from the cloud.hello from override.ts
{ envName: 'xxx', projectName: 'xxxxx' }

おー!!取れたー!!

でもなんか以下のエラーも出るようになったから見なきゃだな

⠼ Generating UI components...🛑 Codegen job status is failed
No UI components to generate
✖ Failed to sync UI components

ShinkawaShinkawa

post-push.shは以下のコードでenvを参照することにする

CURRENT_AMPLIFY_ENV=$(amplify status | grep 'Current Environment:' | awk '{print $4}')
ShinkawaShinkawa

一旦新しい環境を生成してみる
これで最初から同時実行が設定されてたらうまく構築できてそう。
上手くできてたら本番環境にのみ同時実行設定をする方法の調査をする。

ShinkawaShinkawa

新しい環境生成

amplify env addで環境を追加して

amplify push -y

でインフラ構築

policyで新しい環境を使うから設定を更新する必要があることに注意が必要
lambdaの件でcloudformationの修正が必要そう

Reason: Resource handler returned message: "Function not found: arn:aws:lambda:ap-northeast-1::function:-devc (Service: Lambda, Status Code: 404, Request ID: c699cdc9-0ee7-4007-b7dd-475341897dd7)" (RequestToken: 32e0032d-c779-4195-eb58-62975fd53364, HandlerErrorCode: NotFound)

🛑 Resource is not in the state stackUpdateComplete
Name: HogeFunctionVersion (AWS::Lambda::Version), Event Type: create, Reason: Resource handler returned message: "Function not found: arn:aws:lambda:ap-northeast-1::function:-devc (Service: Lambda, Status Code: 404, Request ID: c699cdc9-0ee7-4007-b7dd-475341897dd7)" (RequestToken: 32e0032d-c779-4195-eb58-62975fd53364, HandlerErrorCode: NotFound), IsCustomResource: false

cloudformationのlambda versionとalias 設定のところにdependsOnを設定する必要があるみたい

amplify push はバックエンドだけか
amplify publish でバックエンドとフロントの両方をデプロイするから最初はamplify publishを使うべ加茂な。

ShinkawaShinkawa

う~ん、amplify pushとpublishをした後に、cloudfrontからAPI呼び出すときに{"message": "Internal server error"}になる。なぜだ。

ShinkawaShinkawa

調査用にAPIGatewayのCloudwatchLogsをオンにしてみる
ログとトレースを編集
ステージ > ログとトレースを編集でいろいろオンにする

ShinkawaShinkawa

その前に権限設定が必要だった

以下の手順でAPIGatewayがCloudwatchLogsに書き込めるロールを作成して、それをAPIGatewayにアタッチする。そしてログをオンにすればできる。
https://repost.aws/ja/knowledge-center/api-gateway-cloudwatch-logs

APIごとに設定するのではなく、Apigatewayに対して設定するのか🤔
→ロールの名前はそれっぽいのがわかるようにつけた方がよさそうだな
 NG: ${appName}-apigateway-role
Good: apigateway-role

ShinkawaShinkawa

再びcloudfrontのURLにアクセスしてAPIを実行してみるとログが出力された
ログのグループ名はAPI-Gateway-Execution-Logs_${hoge}/${hoge}となっていた
Apigatewayにリンクないのか・・・?

ShinkawaShinkawa

Lambdaのリソースベースのポリシーを以下のように修正したらAPIGateway -> Lambda 呼び出しは成功するようになった

{
 "ArnLike": {
-  "AWS:SourceArn": "arn:aws:execute-api:ap-northeast-1:${accountId}:${apiGatewayId}/*/*/hogeFunction-${stage}"
+  "AWS:SourceArn": "arn:aws:execute-api:ap-northeast-1:${accountId}:${apiGatewayId}/*/*/*"
 }
}
ShinkawaShinkawa

がしかし、今度はpost-push.shでエラーが起きている...

----- 🪝 post-push execution start -----
🛑 node:internal/modules/cjs/loader:1078
  throw err;
  ^

Error: Cannot find module '/usr/src/app/status'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1075:15)
    at Function._resolveFilename (pkg/prelude/bootstrap.js:1951:46)
    at Module._load (node:internal/modules/cjs/loader:920:27)
    at Function.runMain (pkg/prelude/bootstrap.js:1979:12)
    at node:internal/main/run_main_module:23:47 {
  code: 'MODULE_NOT_FOUND',
  requireStack: []
}

Node.js v18.15.0


🛑 post-push hook script exited with exit code 1

🛑 exiting Amplify process...

post-push.shに書いているCURRENT_AMPLIFY_ENV=$(amplify status | grep 'Current Environment:' | awk '{print $4}')のamplify statusでエラーになってるみたい。
amplify コマンド打てないの・・・?

とりあえずこの際node.jsに置き換えようかと思う
https://docs.amplify.aws/javascript/tools/cli/project/command-hooks/#executing-different-logic-based-on-environment

ShinkawaShinkawa

雑なメモ
post-pushは以下のようにすればうまくいくことを確認

amplify/hooks/post-push.js
#!/usr/bin/env node

console.log('post-push script is running...')

const runCommandFile = (args) => {
  const { spawnSync } = require('child_process')

  // .sh スクリプトファイルのパス
  console.log(`\nexecuting ${args[0]}...`)

  // spawnSync を使用してスクリプトを同期的に実行
  const result = spawnSync('bash', args, { encoding: 'utf8' })

  // 標準出力と標準エラー出力を出力
  console.log(`\nstdout: \n${result.stdout}`)
  console.error(`\nstderr: \n${result.stderr}`)

  // プロセス終了コードをログ出力
  console.log(`\nchild process exited with code ${result.status}`)
}

/**
 * @param data { { amplify: { environment: { envName: string, projectPath: string, defaultEditor: string }, command: string, subCommand: string, argv: string[] } } }
 * @param error { { message: string, stack: string } }
 */
const hookHandler = async (data, error) => {
  // 渡したい引数
  const envName = data.amplify.environment.envName
  // .sh スクリプトファイルのパス
  const scriptPath = 'amplify/hooks/post-push-commands.sh'
  runCommandFile([scriptPath, envName])
}

const getParameters = async () => {
  const fs = require('fs')
  return JSON.parse(fs.readFileSync(0, { encoding: 'utf8' }))
}

getParameters()
  .then((event) => {
    console.log('event', JSON.stringify(event))
    return hookHandler(event.data, event.error)
  })
  .catch((err) => {
    console.error(err)
    process.exitCode = 1
  })

amplify/hooks/post-push-commands.sh
#!/bin/bash

# エラー発生時にスクリプトを終了する
set -e

echo "Received argument: $1"

# Amplify CLI を使用して現在の環境を取得
# CURRENT_AMPLIFY_ENV=$(amplify env list | grep '*' | awk '{print $2}')
# CURRENT_AMPLIFY_ENV=$(amplify status | grep 'Current Environment:' | awk '{print $4}')
# amplify status

# エラーになるのかちゃんと取れずにただの空文字が格納される
# CURRENT_AMPLIFY_ENV=$(amplify status | grep 'Current Environment:' | awk '{print $4}' | tr -d ' ')
# echo "Current Amplify environment from amplify status: $CURRENT_AMPLIFY_ENV"
CURRENT_AMPLIFY_ENV=$1
echo "Current Amplify environment: $CURRENT_AMPLIFY_ENV"

# Lambda 関数名
FUNCTION_NAME="hogeFunction-"$CURRENT_AMPLIFY_ENV
echo "FUNCTION_NAME: $FUNCTION_NAME"

# 最新バージョンを発行
NEW_VERSION=$(aws lambda publish-version --function-name $FUNCTION_NAME --query 'Version' --output text)

# エイリアス 'provisioned' に新バージョンを紐づけ
aws lambda update-alias --function-name $FUNCTION_NAME --name provisioned --function-version $NEW_VERSION

echo "New version $NEW_VERSION published and aliased to 'provisioned'"

bashとjsで分ける必要もないと思うけど、いったんこれでOK

ShinkawaShinkawa

amplify/backend/function/hogeFunction/hogeFunction-cloudformation-template.jsonに以下を追加してamplify pushすればOK

    "HogeFunctionAliasResourceBasedPolicy": {
      "Type": "AWS::Lambda::Permission",
      "Properties": {
        "FunctionName": {
          "Fn::Sub": [
            "${LambdaFunctionArn}:${AliasName}",
            {
              "LambdaFunctionArn": {
                "Fn::GetAtt": [
                  "LambdaFunction",
                  "Arn"
                ]
              },
              "AliasName": "provisioned" // TODO: パラメータで管理する
            }
          ]
        },
        "Action": "lambda:InvokeFunction",
        "Principal": "apigateway.amazonaws.com",
        "SourceArn": {
          "Fn::Sub": [
            // 本当は以下のようにApigatewayのIDをつけたいが妥協案。Apigatewayが別の管理のされ方をしているのでFn::GetAttなどで取ってくるのが難しい。変数を使うならシステムマネージャとかありだけど、そこまで管理しなくても良いと思った。自分のアカウントからしかアクセスできないことは担保できてるから大丈夫なはず。
            // "arn:aws:execute-api:${region}:${account}:hogeHugaId/*/*/*",
            "arn:aws:execute-api:${region}:${account}:*/*/*/*", 
            {
              "region": {
                "Ref": "AWS::Region"
              },
              "account": {
                "Ref": "AWS::AccountId"
              }
            }
          ]
        }
      },
      "DependsOn": "HogeFunctionAlias"
    },
ShinkawaShinkawa

再度新しい環境を追加して環境構築挑戦する

amplify add env # devd を追加

root@e929bc0bdd4f:/usr/src/app# amplify add env # devd を追加
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the environment devd
Using default provider  awscloudformation
Adding backend environment devd to AWS Amplify app:

role の権限に新しい環境分の権限を追加する

json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "lambda:PutFunctionConcurrency",
                "lambda:DeleteFunctionConcurrency",
                "lambda:ListVersionsByFunction",
                "lambda:CreateAlias",
                "lambda:DeleteAlias",
                "lambda:UpdateAlias"
            ],
            "Resource": [
+                "arn:aws:lambda:ap-northeast-1:${accountId}:function:hogeFunction-devd"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "lambda:PutProvisionedConcurrencyConfig",
                "lambda:GetProvisionedConcurrencyConfig"
            ],
            "Resource": [
+                "arn:aws:lambda:ap-northeast-1:${accountId}:function:hogeFunction-devd:provisioned"
            ]
        }
    ]
}

amplify push でバックエンドをデプロイ

amplify push

lambda エイリアスのトリガーの apigateway で以下のエラーが出てるのが気になる...

API Gateway:
arn:aws:execute-api:${region}:${accountId}:*/*/*/*
The API with ID * could not be found in ${region}.

詳細
Service principal: apigateway.amazonaws.com
Statement ID: amplify-cofami-devd-52706-functioncofamiRes-HogeFunctionAliasResourceBased-XYfxGGPLgSEs

あーリソースベースのポリシーでAPI IDを*にしていて、そんなID見つかりませんてことか。
じゃあ、どうやって自動化すればいいんじゃい!?
→環境ごとにAPIIDを手動でcloudformationに入力か!?

amplify publish でフロントエンドをデプロイ

amplify push で cors 対応済みのバックエンドをデプロイ

cloudfront の URL がでるから、cors に追加する(自分は restapi の node.js に書いてるからそこの処理を更新する)

この時点でフロントエンドにアクセスしたが、エラーが出る

cors のエラー?まだデプロイ中なのか?
Access to fetch at 'https://xxx.execute-api.ap-northeast-1.amazonaws.com/devd/me' from origin 'https://xxx.cloudfront.net' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

→ デプロイして最新バージョンへの反映が完全に終わってなかった!
数秒後にアクセスしたら無事に動くことを確認した!!!ついに、、、!!

ShinkawaShinkawa

ということで本番に反映する
同時実行は本番だけで適用させたいから、本番のみで同時実行するように設定等を見直す。