StepFunctionsのStateMachineをCDKを使って生成しよう

![aws-step-functions-960x504.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/692706/55045fa8-2a4e-83b0-be42-d2b414fb46e8.png)
仕事で開発しているプロダクトのアーキテクチャをわかりやすく制御するため、StepFunctionsを導入しようとして調査しました。
Lambdaを主体としたマイクロサービスという形で作成しているのですが、Lambdaを細分化する方針なので、細分化された機能群をStepFunctionsで統合して機能として動かそうかと。
この記事ははオフィシャルで出ているチュートリアルを実行して概要を理解し、StepFunctionsをCDKで作成してデプロイできるところまで進みます。

## StepFunctionsの概要
[**ドキュメント**](https://aws.amazon.com/jp/step-functions/)を見ると複雑な用語が並んでいますが、今回は目的に鑑みて**StateMachine**に特化して解説していこうと思います。
かなりざっくり説明すると、複数のtipsとして作成した機能体を統合して処理とデータの流れを規定、管理するマネジメントツールです。それゆえスケーラビリティの調整も容易であり、より調整しやすく運用しやすいアーキテクチャを実現できます。単一責任原則に従ってコードを細分化して管理する手法という感じで、Lambdaのような関数でなくてもちょっとした調整を行うPassとかChoiceなどの機能体、またそれらの機能体をMap処理に乗せるようなことも簡単なコードでさっくりと実現できます。
このようなツールなので、使い方次第で山ほど価値を生んでくれます。ドキュメントを見ながらご自身の用途を探してみてください。
個人的には結果として仕上がるフロー図と、その処理の進行状況をその図の中で示してくれるUIが気に入っています。
ローカルテストのための[**SAM**](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/what-is-sam.html)というシステム上でも稼働させられるので、ローカル検証も非常に簡単です。
![スクリーンショット 2020-10-21 2.19.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/692706/76e6f7dc-5f72-5e46-846e-5e03c2b974f3.png)

## 触って理解を深めよう
では早速[**公式のチュートリアル**](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorials.html)を触ってみましょう！いっぱいあるので、興味あるやつを触ってみてください。
ここでは基本的に[**Amazon States Language**](https://states-language.net/spec.html)を使って処理の流れを規定しています。ここでAmazon States Languageに慣れておけば、CDKデプロイで作成したStateMachineをリモート上で詳細に検証できるので、やり込んでみてください！

## CDKでループのチュートリアルを再現しよう
![スクリーンショット 2020-10-21 3.58.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/692706/d5e7b957-0994-d63e-ab63-73f95e467c91.png)

[**Github**](https://github.com/MorijunHack/StepFunctions_tutorial)にアクセスしてmainブランチをクローンして開いてみてください。
このコードが実現するのは、[このチュートリアル](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-create-iterate-pattern-section.html)で作成するものと全く同様のものです。制作物をAmazon States Languageに変換したjsonコードは下記になります。

```json
{
  "StartAt": "ConfigureCount",
  "States": {
    "ConfigureCount": {
      "Type": "Pass",
      "Result": {
        "count": 10,
        "index": 0,
        "step": 1
      },
      "ResultPath": "$.iterator",
      "Next": "Iterator"
    },
    "Iterator": {
      "Next": "IsCountReached",
      "Type": "Task",
      "ResultPath": "$.iterator",
      "Resource": "arn:aws:lambda:ap-northeast-1:333005747499:function:IteratorLambda"
    },
    "ExampleWork": {
      "Type": "Pass",
      "Result": {
        "success": true
      },
      "ResultPath": "$.result",
      "Next": "Iterator"
    },
    "IsCountReached": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.iterator.continue",
          "BooleanEquals": true,
          "Next": "ExampleWork"
        }
      ],
      "Default": "Done"
    },
    "Done": {
      "Type": "Pass",
      "End": true
    }
  }
}
```

さて、ではリポジトリの内容を見ていきましょう。
Lambdaのスクリプトコードはsrcの中を見ていただければわかるはずですので飛ばし、今回はCDKに特化して説明します。

下準備としてルートディレクトリのターミナルで下記を実行しておいてください。これにより、`.dist`ディレクトリにデプロイするLambdaのコードが準備され、CDK周りのモジュールもインストールされます。

```terminal:terminal
npm install
npm run dist
cd aws-cdk
npm install
```

CDKにおいて、エントリポイントは`aws-cdk/cdk.json`にて宣言します。ここでは`aws-cdk/bin/index.ts`を指定しています。
ここでもしJSファイルを指定する感じにすると、tscでコンパイルしてからでなければデプロイできなくなります。
従って、まずは`aws_cdk/bin/index.ts`の内容から見ていきます。

```typescript:aws-cdk/bin/index.ts
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { AwsStack } from '../lib'; // デプロイするパッケージの中身は../lib/aws-stackにて定義されている

const account = 'AWS_ID';
const app = new cdk.App();

new AwsStack(app, 'CdkLoopDev', {tags: {stage: 'dev'}, env: {region: 'ap-northeast-1', account}}); // stg環境をデプロイする際のCloudFormationパッケージ
```

このファイルにはあまりごちゃごちゃ書かずに何をデプロイするのかだけ大づかみに記述するのが通例です。ここでは`'AWS_ID'`となっているaccount定数にAWSのIDとして持っているアカウントID（数字の文字列）を代入してください。ここで大事なのは、デプロイするまとまりのことを僕が`CdkLoopDev`と呼ぶと定義づけている点のみです。

ここから本番です。コードは下記になります。

```typescript:aws-cdk/lib/index.ts
import * as sfn from '@aws-cdk/aws-stepfunctions';
import * as tasks from '@aws-cdk/aws-stepfunctions-tasks';
import * as lambda from '@aws-cdk/aws-lambda';
import * as iam from '@aws-cdk/aws-iam';
import * as cdk from '@aws-cdk/core';


export class AwsStack extends cdk.Stack {
    constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);

        const configCount = new sfn.Pass(this, 'ConfigureCount', {
            result: sfn.Result.fromObject({
                count: 10,
                index: 0,
                step: 1,
            }),
            resultPath: '$.iterator'
        });

        const role = new iam.Role(this, `LambdaExecuteRole`, {
            assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
        });
        role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'));

        let iterlambda = new lambda.Function(this, 'IteratorLambda', {
            functionName: 'IteratorLambda',
            code: lambda.Code.fromAsset('../.dist/iterator'),
            handler: 'index.handler',
            runtime: lambda.Runtime.NODEJS_12_X,
            tracing: lambda.Tracing.ACTIVE,
            role
        });

        const iterator = new tasks.LambdaInvoke(this, 'Iterator', {
            lambdaFunction: iterlambda,
            resultPath: '$.iterator',
            payloadResponseOnly: true,
        });

        const isCountReaced = new sfn.Choice(this, 'IsCountReached');

        const exampleWork = new sfn.Pass(this, 'ExampleWork', {
            result: sfn.Result.fromObject({
                success: true
            }),
            resultPath: '$.result'
        });
        const done = new sfn.Pass(this, 'Done', {});

        const definition = configCount
            .next(iterator)
            .next(isCountReaced
                .when(sfn.Condition.booleanEquals('$.iterator.continue', true), exampleWork.next(iterator))
                .otherwise(done)
            );
        
        const stateMachine = new sfn.StateMachine(this, 'CdkLoop', {
            definition,
        });
    }
}
```

私がまず最初に思ったのが、Amazon States Languageじゃないんだ。。ということでした。
せっかく覚えたのに…というところですが、ご安心ください。どのみちCDKが結果としてどのようなJSONになるかを確認することになるので、努力は無駄になりません。
さて、ざっくりとコードを解説していきます。
● インストールするモジュールは`@aws-cdk/aws-stepfunctions` `@aws-cdk/aws-stepfunctions-tasks` `@aws-cdk/aws-lambda` `@aws-cdk/aws-iam` `@aws-cdk/core`になります。
● 次に下記のコードです。

```Typescript:aws-cdk/lib/index.ts/12-19行目
const configCount = new sfn.Pass(this, 'ConfigureCount', {
    result: sfn.Result.fromObject({
        count: 10,
        index: 0,
        step: 1,
    }),
    resultPath: '$.iterator'
});
```

sfn.Passという構造体は、AWS States Languageでいうところの`"Type": "Pass"`に対応しています。今回のコードでは上のJSONでいう`"ConfigureCount"`というPassに対応しています。ループ処理で徐々にカウントアップさせていく `$.iterator`内のオブジェクトの初期宣言になります。
● そして次のコードです。

```typescript:aws-cdk/lib/index.ts/21-39行目
const role = new iam.Role(this, `LambdaExecuteRole`, {
    assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
});
role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'));

let iterlambda = new lambda.Function(this, 'IteratorLambda', {
    functionName: 'IteratorLambda',
    code: lambda.Code.fromAsset('../.dist/iterator'),
    handler: 'index.handler',
    runtime: lambda.Runtime.NODEJS_12_X,
    tracing: lambda.Tracing.ACTIVE,
    role
});

const iterator = new tasks.LambdaInvoke(this, 'Iterator', {
    lambdaFunction: iterlambda,
    resultPath: '$.iterator',
    payloadResponseOnly: true,
});
```

これはJSON内の`"Iterator"`に対応するコードになります。まず`role`及び`iterlambda`に関して。これはlambdaを生成するためのコードになります。必要な権限を付与したIAMロールを定義し、lambdaの宣言と同時にそのロールをアタッチしています。
ここで重要なのは`iterator`です。`Task`ステートに関しては、cdk上ではモジュールごと切り分けられています。このタスクは`iterlambda`をinvokeし、返却されるデータを`'$.iterator'`というresultPathで受け取ります。ここで`payloadResponseOnly: true,`が必要な理由ですが、これがないとresultPathを`'$.iterator'`としていても、そのパスの中にPayloadなどのプロパティが複数生成されてしまうので、このStateMachineで変数的に利用する`'$.iterator'`をシンプルに保つためにこのプロパティを指定するわけです。このおかげで、lambdaの返り値がそのまま`'$.iterator'`に保存されます。

● そして次。

```typescript:aws-cdk/lib/index.ts/41-56行目
const isCountReached = new sfn.Choice(this, 'IsCountReached');

const exampleWork = new sfn.Pass(this, 'ExampleWork', {
    result: sfn.Result.fromObject({
        success: true
    }),
    resultPath: '$.result'
});
const done = new sfn.Pass(this, 'Done', {});

const definition = configCount
    .next(iterator)
    .next(isCountReached
        .when(sfn.Condition.booleanEquals('$.iterator.continue', true), exampleWork.next(iterator))
        .otherwise(done)
    );
```

まずChoiceステート`isCountReached`ですが、これは条件に従って分岐させるためのステートになります。`'$.iterator.continue'`のboolean値がtrueなら`exampleWork`Pass、falseなら`done`Passに続きますが、宣言の時点では分岐を記述せず、後続のPassを宣言した後で記述します。（ここでは`definition`定数内で記述しています）
`exampleWork`は`'$.result'`に`{success: true}`を格納して`iterator`に接続するためのPassであり、`done`はEndに続くためだけのPassです。

また、ここまではJSONでいうところの`"Next":"〇〇"`みたいな定義文を書いていませんでしたが、これを`definition`で定義しています。これは後続のStateMachineの宣言にて使用する`definition`プロパティとして使用するものです。

● 最後の宣言文はご覧の通り。

さて解説は以上になりますが、もっと試したいことがある場合にはぜひ[**CDKのリファレンス**](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-construct-library.html)を覗いてみて試してください。

## CDKデプロイ
では早速デプロイしてみましょう！まずはpackage.jsonを編集します。
８行目の、スクリプト定義の`"bootstrap"`をご覧ください。文字列の中に`AWS_ID`とあると思いますので、ご自身のAWSアカウントIDと書き換えてください。

で、下記のコマンドを実行します。

```terminal:terminal
npm run build
npm run bootstrap
npm run deploy
```
これでデプロイは完了です。

awsのマネジメントコンソールにログインし、AWS StepFunctionsのコンソールにアクセスしてください。
ステートマシンの中に`CdkLoop`で始まる名称のものがあると思いますので、それを開いて実行してみます。
成功すると思いますが、もし失敗したら定義のタブからJSONをチェックし、エラー文が出ていればトラブルシュートしてみてください。

## 最後に
とまあ手元でのテストの一部を公開してみましたが、StepFunctionsの雰囲気を把握できてきたんじゃないかと思います。StepFunctionsにはアクティビティなどの便利機能がたくさんあるので、やりたいことに従って色々と拡張してみてください。トリガーやMap、Choiceなどを状況に合わせてうまく使えば相当強力な武器になると思います。