Serverless FrameworkのStep Functions用プラグインでAWSサービスプロキシを設定してみた

eyecatch-step-functions

はじめに

こんにちは、中山です。

先日積読になっていたAWS Computeのブログを読むという徳の高い行為をしていました。該当のブログは以下です。

Step FunctionsのアクティビティとAPI Gatewayを組み合わせて手動の承認処理を導入するアーキテクチャについて紹介したものになります。大分前の話になりますが、API GatewayのAWSサービスプロキシにStep Functionsが対応した発表を受けて、具体的なユースケースを紹介してくれたようです。

私は文章を読むだけだとあまり理解が進まないので、新しいことを勉強する際には実際に自分で作ってみるようにしてます。後述しますが、アーキテクチャがサーバレスな形になっていたのでServerless Frameworkで作ってみました。内部的にStep Functionsを使っているので、以前ブログで紹介したhorike37/serverless-step-functionsを使ったのですが、機能がかなりアップデートされていたのでそちらを中心にご紹介したいと思います。

検証環境

  • Node.js: 8.2.1
  • Serverless Framework: 1.19.0
  • serverless-step-functions: 1.1.0

アーキテクチャ

今回作成するアーキテクチャを説明します。先程のブログから構成図を引用します。

image-1

まとめると以下のようなフローになります。

  1. API GatewayのAWSサービスプロキシでStep Functionsを呼び出せるようにしておく
  2. CloudWatch Eventsで定期的にLambdaをInvoke
  3. InvokeされたLambdaがStep Functionsのタスクの状態を監視
  4. タスクが存在した場合はInputで渡された情報を元にSESでメール送信
  5. メール本文に記載されたAPI Gatewayのエンドポイントに応じて後続のタスクへ遷移するか選択

この仕組みを利用することで、ステートマシーンの中に手動の承認処理をサーバレスな形で導入することが可能です。全て自動化されたフローにするのはまずいような処理をStep Functionsで行いたい場合に便利なのではないでしょうか。

やってみた

今回作成したリポジトリは以下です。ご自由にお使いください。こちらの主要なコードを元に解説します。

service: serverless-manual-approval

frameworkVersion: ">=1.19.0 <2.0.0"

custom:
  config: ${file(config.yml)}

provider:
  name: aws
  runtime: nodejs6.10
  stage: ${self:custom.config.stage}
  region: ap-northeast-1
  memorySize: 128
  timeout: 60
  iamRoleStatements:
    - Effect: Allow
      Action: states:GetActivityTask
      Resource: "arn:aws:states:#{AWS::Region}:#{AWS::AccountId}:activity:${self:custom.config.stepFunctions.activityName}"
    - Effect: Allow
      Action: ses:SendEmail
      Resource: "*"

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

package:
  individually: true
  exclude:
    - "**"

functions:
  func:
    name: ManualStepActivityWorker
    handler: src/handlers/func/index.handler
    environment:
      ACTIVITY_ARN: "arn:aws:states:#{AWS::Region}:#{AWS::AccountId}:activity:${self:custom.config.stepFunctions.activityName}"
      SERVICE_ENDPOINT:
        Fn::Join: [ "", [ "https://", Ref: "ApiGatewayRestApi", ".execute-api.", Ref: "AWS::Region", ".amazonaws.com/${self:custom.config.stage}" ] ]
    package:
      include:
        - src/handlers/func/*.js
    events:
      - schedule: rate(1 minute)

stepFunctions:
  stateMachines:
    promotionApproval:
      events:
        - http:
            path: fail
            method: GET
        - http:
            path: succeed
            method: GET
      definition:
        Comment: "Employee promotion process!"
        StartAt: ManualApproval
        States:
          ManualApproval:
            Type: Task
            Resource: "arn:aws:states:#{AWS::Region}:#{AWS::AccountId}:activity:${self:custom.config.stepFunctions.activityName}"
            TimeoutSeconds: 3600
            End: true
  activities:
    - ${self:custom.config.stepFunctions.activityName}

resources: ${file(resource.yml)}

46 - 66行目でStep Functionsのステートマシーンとアクティビティを定義しています。さらにこちらがすごいのですが、 events を指定することでAPI GatewayのREST APIリソースなどを作成し、Step FunctionsをAWSサービスプロキシとして設定可能です。Serverless FrameworkはLambdaとひも付ける形でAPI Gatewayを定義することは容易ですが、今回のようにAWSサービスプロキシの設定をする場合は自分でテンプレートを作成しなければならず、少々面倒でした。それが一般的な serverless.yml と同じ形式で設定できるのはかなり便利ですね。今回はタスクを承認するために成功/失敗用のメソッドを作成しました。

  • resource.yml
---
AWSTemplateFormatVersion: "2010-09-09"
Description: Serverless Manual Approval Stack

Parameters:
  LogGroupRetentionInDays:
    Type: Number
    Default: ${self:custom.config.logGroup.retentionInDays}

Resources:
  FuncLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays:
        Ref: LogGroupRetentionInDays

  ApiGatewayRestApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: StepFunctionsAPI
  ApiGatewayMethodFailGet:
    Type: AWS::ApiGateway::Method
    Properties:
      Integration:
        Uri:
          Fn::Join: [ "", [ "arn:aws:apigateway:", Ref: "AWS::Region", ":states:action/SendTaskFailure" ] ]
        PassthroughBehavior: WHEN_NO_TEMPLATES
        RequestTemplates:
          application/json: |
            {
               "cause": "Reject link was clicked.",
               "error": "Rejected",
               "taskToken": "$input.params('taskToken')"
            }
      RequestParameters:
        method.request.querystring.taskToken: false
  ApiGatewayMethodSucceedGet:
    Type: AWS::ApiGateway::Method
    Properties:
      Integration:
        Uri:
          Fn::Join: [ "", [ "arn:aws:apigateway:", Ref: "AWS::Region", ":states:action/SendTaskSuccess" ] ]
        PassthroughBehavior: WHEN_NO_TEMPLATES
        RequestTemplates:
          application/json: |
            {
               "output": "\"Approve link was clicked.\"",
               "taskToken": "$input.params('taskToken')"
            }
      RequestParameters:
        method.request.querystring.taskToken: false
  ApigatewayToStepFunctionsRole:
    Type: AWS::IAM::Role
    Properties:
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSStepFunctionsFullAccess

主に horike37/serverless-step-functions で作成したAPI Gatewayの設定をしています。プラグインのREADMEに記載されているようにある程度 serverless.yml 側でAPI Gatewayの設定が可能ですが、執筆時点(2017/08/04)ではすべてのパラメータに対応しているわけではないようです。そのため、Serverless Frameworkのオーバーライドを利用して、プラグインで定義されたリソースを上書きしています。オーバーライドを軽く説明すると、Serverless Frameworkで作成されるCloudFormationのリソースと同じ名前でリソースを定義することにより、パラメータをカスタマイズできる機能です。今回の場合であれば以下のリソース名になるようなので、同じ名前で設定してあげれば上書きが可能です。

  • ApiGatewayMethodFailGet
  • ApiGatewayMethodSucceedGet
  • ApigatewayToStepFunctionsRole
    • API GatewayからStep Functionsを操作する際に利用するIAM Role

API Gatewayのメソッドについて少し触れると、 Integration - RequestTemplatesRequestParameters パラメータでtaskTokenをパラメータ形式で処理できるように定義させています。

  • src/handlers/func/index.js
const AWS = require('aws-sdk');

class Worker {
  constructor(event, context, callback) {
    this.event = event;
    this.context = context;
    this.callback = callback;
    this.stepfunctions = new AWS.StepFunctions();
    this.ses = new AWS.SES({ region: 'us-east-1' });
  }

  getActivityTask() {
    const params = {
      activityArn: process.env.ACTIVITY_ARN,
    };
    return this.stepfunctions.getActivityTask(params).promise();
  }

  sendEmail(data) {
    const input = JSON.parse(data.input);
    const params = {
      Destination: {
        ToAddresses: [
          input.managerEmailAddress,
        ],
      },
      Message: {
        Subject: {
          Data: 'Your Approval Needed for Promotion!',
          Charset: 'UTF-8',
        },
        Body: {
          Html: {
            Data: 'Hi!<br />' +
              `${input.employeeName} has been nominated for promotion!<br />` +
              'Can you please approve:<br />' +
              `${process.env.SERVICE_ENDPOINT}/succeed?taskToken=${encodeURIComponent(data.taskToken)}<br />` +
              'Or reject:<br />' +
              `${process.env.SERVICE_ENDPOINT}/fail?taskToken=${encodeURIComponent(data.taskToken)}`,
            Charset: 'UTF-8',
          },
        },
      },
      Source: input.managerEmailAddress,
      ReplyToAddresses: [
        input.managerEmailAddress,
      ],
    };
    return this.ses.sendEmail(params).promise();
  }

  start() {
    this.getActivityTask()
      .then((data) => {
        console.log(data);
        return this.sendEmail(data);
      })
      .then((data) => {
        console.log(data);
        this.callback(null, data);
      })
      .catch((err) => {
        console.log(err);
        this.callback(err);
      });
  }
}

module.exports.handler = (event, context, callback) => {
  new Worker(event, context, callback).start();
};

AWSのブログに記載されているコードを元に作成しました。やってることは単純です。Step FunctionsのGetActivityTask APIを呼び出してタスクの状態を監視、データを取得できた場合はSESのSendEmail APIでメールを送信しているだけです。

32 - 39行目でメール本文に記載するAPI Gatewayのエンドポイントへのリンクを作成しています。GetActivityTask APIのレスポンスで返されたtaskTokenを含めることにより、API Gatewayからタスクに対してAPIを発行できるようにしています。

9行目でSESのクライアントを設定していますが、東京リージョンにまだ来ていないので北部バージニアリージョンを利用しました。現時点でCloudFormationがSESに未対応ということもあり、SES周りは手動で環境を作成しています。サンドボックス内の場合は送信するメールアドレスに対して事前に承認する必要がある点はご注意ください。

動作確認

いつものようにServerless Frameworkをデプロイしたら動作確認してみます。まずリポジトリのトップディレクトリにある input.json を以下のように修正してください。

  • input.json
{
  "managerEmailAddress": "<SESからメール送信可能なメールアドレス>",
  "employeeName" : "<適当な名前>"
}

続いてステートマシーンを実行します。以下のコマンドで実行可能です。

$ yarn invoke:stepf

しばらくするとメールアドレスに以下のようなメールが届くと思います。

image-2

承認/否認用リンクのどちらかをクリックするとステートマシーンの実行が完了します。

まとめ

いかがだったでしょうか。

horike37/serverless-step-functions を中心にステートマシーンへ手動の承認処理を導入する方法をご紹介しました。一般的にサーバレスアーキテクチャはEC2等のインスタンスを管理せずに済むという点が強調されがちですが、アーキテクチャ全体をコードという形で管理できるという点も魅力の1つです。その際、Serverless Frameworkやそのプラグインを利用することで簡単にコードを定義できるのは便利ですね。引き続きサーバレス分野の動向を追っていきたいと思います。

本エントリがみなさんの参考になれば幸いに思います。