Serverless Framework で StepFunctions 作成とログ監視を設定する(1)

今回は Serverless Framework で StepFunctions を作成し、エラー時の Cloudwatch Alarm も併せて定義します。

構築する環境

今回構築する環境を超適当に書くとこんな感じです。

Step Functions の Map を使って後続の関数をスケールします。SQS 使わずに実装できてとても便利です。

また、Step Functions の実行がコケたときに通知するため、Alarm と SNS を実装しています。

プラグイン準備

今回は2つのプラグインを利用します。

$ npm install -g serverless-step-functions
$ npm install -g serverless-prune-plugin

www.serverless.com

www.serverless.com

Serverless プロジェクト作成

こんな感じでディレクトリを作成します。

$ tree stepfunctions-tutorial/
stepfunctions-tutorial/
├── email.yml
├── handler1.py
├── handler2.py
└── serverless.yml

0 directories, 4 files

email.yml は SNS トピックに設定するメールをブログでうっかり公開しないように serverless.yml から外出しするために作りました。

email.yml
address: hogehoge@fugafuga.com

Lambda 実装

適当に Lambda を実装します。

handler1.py
import json

def func1(event, context):
    body = {
        "message": "Go Serverless v3.0! Your function executed successfully!",
        "input": event,
        "targets": [0,1,2]
    }
    response = {"statusCode": 200, "body": body} # json.dumps しない。
    return response
handler2.py
import json

def func2(event, context):
    print(event)
    body = {
        "message": "Go Serverless v3.0! Your function executed successfully!",
        "input": event,
    }
    response = {"statusCode": 200, "body": json.dumps(body)}
    return response

handler1.py から body.targets 配列を返しています。Lambda の初期レスポンス値は json.dumps() されているのですが、dump するとエスケープされてしまうので json.dumps() しないように注意。

qiita.com

ちなみに、json.dumps() した値が後続処理に渡ると以下のようにマッピング失敗となります。

Serverless 設定

serverless.yml を書いていきます。

まずは全体像。

serverless.yml
org: talkeyboid
service: stepfunctions-tutorial
frameworkVersion: '3'

plugins:
  - serverless-step-functions
  - serverless-prune-plugin

provider:
  name: aws
  runtime: python3.9
  region: ap-northeast-1
  memorySize: 128
  timeout: 3
  logRetentionInDays: 14
  apiKeys:
    - ${self:service}-key
  usagePlan:
    quota:
      limit: 100
      offset: 0
      period: MONTH
    throttle:
      burstLimit: 3
      rateLimit: 1

custom:
  prune:
    automatic: true
    number: 3
  env:
    email: ${file(./email.yml)}

functions:
  func1:
    handler: handler1.func1
  func2:
    handler: handler2.func2

stepFunctions:
  stateMachines:
    myStateMachine1:
      name: myStateMachine1
      # role: "設定する場合はここにARN"
      loggingConfig:
        level: ALL
        includeExecutionData: true
        destinations:
        - Fn::GetAtt: [StepFunctionLogGroup, Arn]
      alarms:
        topics:
          alarm:
            Ref: EmailTopic
        metrics:
        - executionTimedOut
        - executionFailed
        - executionsAborted
        - executionThrottled
        treatMissingData: missing
      events:
      - http:
          path: hoge
          method: POST
          private: true
          # iamRole: "設定する場合はここにARN"
      definition:
        StartAt: Task1
        States:
          Task1:
            Type: Task
            Resource:
              Fn::GetAtt: [Func1LambdaFunction, Arn]
            Next: MapTask
          MapTask:
            Type: Map
            ItemsPath: "$.body.targets"
            Iterator:
              StartAt: Task2
              States:
                Task2:
                  Type: Task
                  Resource:
                    Fn::GetAtt: [Func2LambdaFunction, Arn]
                  End: true
            End: true

resources:
  Resources:
    EmailTopic:
      Type: AWS::SNS::Topic
      Properties:
        TopicName: EmailTopic
    EmailSubscription:
      Type: AWS::SNS::Subscription
      Properties:
        Protocol: email
        TopicArn: !Ref EmailTopic
        Endpoint: ${self:custom.env.email.address}
    StepFunctionLogGroup:
      Type: AWS::Logs::LogGroup
      DeletionPolicy: Delete
      Properties:
        LogGroupName: /aws/states/${self:service}-${self:stepFunctions.stateMachines.myStateMachine1.name}-Logs
        RetentionInDays: 14

ポイントをいくつか。

provider

serverless.yml
provider:
  name: aws
  runtime: python3.9
  region: ap-northeast-1
  memorySize: 128
  timeout: 3
  logRetentionInDays: 14
  apiKeys:
    - ${self:service}-key
  usagePlan:
    quota:
      limit: 100
      offset: 0
      period: MONTH
    throttle:
      burstLimit: 3
      rateLimit: 1

provider で メモリサイズとタイムアウト等を明示的に指定しています。serverless のデフォルト値だとオーバースペックすぎるためです。

apiKeys, usagePlan で API Gateway のキーと使用量プランを設定しています。これは明示的に設定しない限り作成されないので自分で書く必要があります。API キーが不要な場合は記載しなくていいです。

custom

serverless.yml
custom:
  prune:
    automatic: true
    number: 3
  env:
    email: ${file(./email.yml)}

custom.prune で Lambda のバージョン世代保持数を指定しています。デフォルトでは無限に増殖するため、抑えないといけません。今回は検証なので特に指定する必要はなかったですが、後学のために設定しました。

dev.classmethod.jp

また、env.emailemail.yml ファイルを設定しています。そのため、メールアドレスを取得したければ ファイル内のプロパティと結合し、${self:custom.env.email.address} で取得できます。

stepFunctions

今回の本題、StepFunctions の部分です。

ロググループについて

serverless.yml
stepFunctions:
  stateMachines:
    myStateMachine1:
      name: myStateMachine1
      # role: "設定する場合はここにARN"
      loggingConfig:
        level: ALL
        includeExecutionData: true
        destinations:
        - Fn::GetAtt: [StepFunctionLogGroup, Arn]

StepFunctions のロググループは明示的に設定しないと作成されないようなので、後述する resources でロググループを作成し、それを loggingConfig.destinationsFn::GetAtt で Arn を取得し設定しています。

Alarm について

serverless.yml
      alarms:
        topics:
          alarm:
            Ref: EmailTopic
        metrics:
        - executionTimedOut
        - executionFailed
        - executionsAborted
        - executionThrottled
        treatMissingData: missing

Cloudwatch Alarm の設定もここでできます。alarms プロパティの中で、後述する resources で定義した SNS トピックを設定しています。メトリクスは以下を参照。

docs.aws.amazon.com

欠損データの取り扱いについては以下を参照。

docs.aws.amazon.com

ここでは書いていませんが、スレッショルドも設定できると思うので次やるときには設定したいと思います。

API Gateway について

API Gateway の設定は Lambda の定義と同じく、events に設定します。API キーを利用する場合、private=true に設定します。

serverless.yml
      events:
      - http:
          path: hoge
          method: POST
          private: true
          # iamRole: "設定する場合はここにARN"

データフローについて

serverless.yml
      definition:
        StartAt: Task1
        States:
          Task1:
            Type: Task
            Resource:
              Fn::GetAtt: [Func1LambdaFunction, Arn]
            Next: MapTask
          MapTask:
            Type: Map
            ItemsPath: "$.body.targets"
            Iterator:
              StartAt: Task2
              States:
                Task2:
                  Type: Task
                  Resource:
                    Fn::GetAtt: [Func2LambdaFunction, Arn]
                  End: true
            End: true

StepFunctions の中身を definition に設定します。ここはマネジメントコンソールのビジュアルエディタで作成した json の内容をほぼそのまま転記する形で記載できそうです。

ただし、どの Lambda 関数を実行するかは若干のハックが必要です。Arn を指定する必要があるため、Fn::GetAtt をしていますが、ここへは functions へ定義した関数名をパスカルケースにし、後ろに LambdaFunction を付けます。

functions:
  func1: # これをパスカルケースにする -> Func1、+ LambdaFunction = Func1LambdaFunction
    handler: handler1.func1
  func2: # これをパスカルケースにする -> Func2、+ LambdaFunction = Func2LambdaFunction
    handler: handler2.func2

これは serverless の仕様で、serverless deploy(package) するときに作成される CloudFormation テンプレートにそのように定義されるためです。

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Description": "The AWS CloudFormation template for this Serverless application",
  "Resources": {
  ...
    "Func1LambdaFunction": {
      "Type": "AWS::Lambda::Function",
      "Properties": {
        ...
        "Handler": "handler1.func1",
        "Runtime": "python3.9",
        "FunctionName": "stepfunctions-tutorial-dev-func1",
        ...
  }
}

resources

serverless.yml
resources:
  Resources:
    EmailTopic:
      Type: AWS::SNS::Topic
      Properties:
        TopicName: EmailTopic
    EmailSubscription:
      Type: AWS::SNS::Subscription
      Properties:
        Protocol: email
        TopicArn: !Ref EmailTopic
        Endpoint: ${self:custom.env.email.address}
    StepFunctionLogGroup:
      Type: AWS::Logs::LogGroup
      DeletionPolicy: Delete
      Properties:
        LogGroupName: /aws/states/${self:service}-${self:stepFunctions.stateMachines.myStateMachine1.name}-Logs
        RetentionInDays: 14

Email 通知用の SNS トピック・サブスクリプションと、StepFunctions 用のロググループを作成しています。StepFunctions のデフォルトのロググループ名は /aws/vendedlogs/states/<statemachine name>-Logs のようになっているため、それに合わせています。※AWSで作成しているわけではないので、vendedlogs は抜いています。

RetentionInDays は完全に適当です。実務で使うときには考えましょう。

実行確認

マネジメントコンソールからエンドポイントとAPI キーを取得し、Postman に設定し、実行します。

項目
メソッド POST
URL ステージのエンドポイント + パス(https://~/dev/hoge
HTTPヘッダ x-api-key=APIキー

私はよくルートURLの後にパスをつけ忘れて 403 になってしまうことがあります。404 なら気づきようがあるのですが、403 だと「キー設定してるのにおかしいな...」となるので備忘として残しておきます。

実行結果は以下のように返ってきます。executionArn は ステートマシン名 + 実行ID(ランダム値)です。

{
    "executionArn": "arn:aws:states:ap-northeast-1:XXXXXXXXXXXX:execution:myStateMachine1:0fbeb2e9-581c-49ed-88b8-1c70e200a609",
    "startDate": 1.676463213988E9
}

感想

少々ハックはありますが、簡単に実装することができました。

Alarm の確認もしようと思ったのですが、結構時間がかかったのでまた次回。