API GatewayとStep Functionsを組み合わせた非同期APIが最強だった話


最強というかカンタン?

サーバーレスの構成として一般的な
API Gateway&Lambdaですが

API Gatewayは29秒制限
Lambdaは15分制限があるわけですね

Lambdaの性能を目一杯使いつつ、
そのギャップを埋めるため
なるべくシンプルな構成で非同期APIにしたい ってこと
あると思います。

そのときに必要なのは

  • LambdaをキックするためのAPI
  • キックしたLambdaが完了したか判断し、レスポンスを受け取るためのAPI

だと思いますが、下記Step Functionsにはその両方が備わっています

Step Functions

Step Functionsでは、
マイクロサービスの連携などワークフローを視覚的でイケメンな感じに
構成することができます。

公式例:

やばいですね
なんでもできちゃいそうです

TaskとしてLambdaを定義することで
このワークフローにじゃかじゃか組み込んでいけちゃうんですね

ちなみに今回作るのはこれです(かわいい)

API Gatewayをつくる

こんなかんじでつくります

start-executionが上述のLambdaをキックするためのAPI
describe-executionがLambdaからレスポンスを受け取るためのAPI
(名前はなんでもいいです)

両方POSTです

Serverless Framework

んじゃSLSで実際に作っていきます
CFnで作るより格段に楽なんですが、
プラグインとして serverless-step-functionsserverless-pseudo-parameters を入れる必要があります

serverless.yml
plugins:
  - serverless-step-functions
  - serverless-pseudo-parameters

関数はこんな感じでつくります
ラーメンタイマーです

serverless.yml
provider:
  name: aws
  runtime: python3.8
  stage: ${opt:stage, 'dev'}
  region: ap-northeast-1
  memorySize: 128
  timeout: 900

custom:
  basePath: ramen

functions:
  async-api:
    handler: app.lambda_handler
    name: async-api-${self:provider.stage}
    environment:
      TIMER: 30
app.py
import os
from time import sleep
import logging

logger = logging.getLogger()
logger.setLevel(logging.DEBUG)


def lambda_handler(event, context):
    logger.info(f"event: {event}")

    seconds = int(os.environ.get("TIMER"))
    sleep(seconds)

    return {"message": "らーめんが ゆであがりました はやく たべないと のびて しまいます"}

Step Functionsの定義はこんな感じに
serverless-step-functionsプラグインを使う場合
API Gatewayの定義もstateMachinesブロック内に書きます

serverless.yml
stepFunctions:
  stateMachines:
    state-machine:
      name: state-machine-${self:provider.stage}
      events:
        - http:
            path: ${self:custom.basePath}/start-execution
            method: post
            action: StartExecution
            iamRole:
              Fn::GetAtt: [AsyncApiRole, Arn]
            request:
              template:
                application/json: |-
                  {
                    "input": "$util.escapeJavaScript($input.json('$'))",
                    "stateMachineArn":"arn:aws:states:#{AWS::Region}:#{AWS::AccountId}:stateMachine:state-machine-${self:provider.stage}"
                  }
        - http:
            path: ${self:custom.basePath}/describe-execution
            method: post
            action: DescribeExecution
            iamRole:
              Fn::GetAtt: [AsyncApiRole, Arn]
            response:
              template:
                application/json: |-
                  {
                    "input": $util.parseJson($input.json('$.input')),
                    #if($input.path('$.output') != "")
                      "output": $util.parseJson($input.json('$.output')),
                    #end
                    "status": $input.json('$.status')
                  }
      definition:
        StartAt: async-api-task
        States:
          async-api-task:
            Type: Task
            Resource:
              Fn::GetAtt: [async-api, Arn]
            End: true

ポインツ

  • start-executionにはevents.http.actionStartExecutionを記載
    • events.http.request.templateに実行するStateMachineのArnをJson形式で記載
  • describe-executionにはevents.http.actionDescribeExecutionを記載
    • events.http.request.templateにレスポンスをJson形式で記載
{
    "response": $input.json('$'),
}

とかにすればDescribeExecutionの中身が全部返るんだけど
必要なものだけ返してあげたほうが、インターフェース的にヤサシイでしょう(たぶん)

動かす

じゃあデプロイしてうごかしましょう
sls deployっと

コンソールに出たエンドポイントでお試しします

start-execution

これで取得したexecutionArnをつかって次のAPIを叩きます

describe-execution

なんか返ってきましたね。
statusRUNNINGです。
まだラーメンできていないようです。

少し間をおいて、もう一度叩いてみましょう

お、statusSUCCEEDEDになり、ラーメンができあがったことをおしえてくれましたね

以上

いやーめちゃめちゃ楽ですね
これがなかったらDynamoとかSQSとかをフル活用して自前実装しないといけないとこでした(地獄)