Serverless Framework を用いて Step Functions Distributed Map による Lambda の大規模並列処理を実現する

# Step Functions Distributed Map が発表されましたね！

先日の AWS re:Invent にて、**Step Functions Distributed Map** が発表（GA）されました！
（Distributed Map の公式リリースは以下。日本語でリリース内容を知りたい方は[DevelopersIOによる速報](https://dev.classmethod.jp/articles/new-feature-step-functions-distributed-map-reinvent2022/)を参照）

https://aws.amazon.com/jp/blogs/aws/step-functions-distributed-map-a-serverless-solution-for-large-scale-parallel-data-processing/

上のリンクの通り、公式リリースでは手動でAWSコンソール上から実現する手順が記載されています。
しかしながら、実際に商用で利用する場合には、AWS CDK や Serverless Framework などを用いて管理したいところです。

そこで本記事では、**Serverless Framework** を用いて、**Step Functions Distributed Map** を構成することを最終目標に、そもそもMapステートってなに？というところから順に書いていきます。

## Step Functions の Mapステートとは

### Step Functions とは

[AWS Step Functions](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/welcome.html) は、AWS の提供するワークフローサービスで、データ処理やマイクロサービスのオーケストレーションなどに活用されます（[参考](https://docs.aws.amazon.com/step-functions/latest/dg/use-cases.html)）。

参考に、[AWS公式が提示する事例](https://aws.amazon.com/jp/blogs/architecture/migrating-a-database-workflow-to-modernized-aws-workflow-services/
)の画像を見てみます（以下）。AWSの様々なサービスを順序よく実行できることがなんとなくイメージできるかなと思います。
<img width="75%" alt="step-functions-example.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/42363e82-96ca-71ac-85c5-ebbae05a0d8f.png">


### Map ステートとは何か ・ 何が嬉しいか
ここで、こういったワークフローの中に **「1000個の要素を持つ配列のそれぞれについて処理を行うタスク」** があると考えてみましょう。
仮にこの処理が1つの要素あたり10秒かかるとすると、**3時間程度** かかってしまうことになります（10秒 x 1,000要素 = 10,000秒）。

この部分を40並列で実行できれば、この3時間ほどかかっていた処理を **約4分** に短縮することができます（10,000秒 / 40 = 250秒）。
それを簡単に実現できるのが [**Map ステート**](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/amazon-states-language-map-state.html)という設定になります。

### 従来の Mapステートの問題点

従来の Mapステートでは、この **並列処理実行数に40程度の制限** がありました。
そのため、40並列以上を達成したい場合、並列処理をネストしてどうにかしろと公式がアナウンスしていました。
（英語版からは既に削除されている記述のため、日本語版ドキュメントをスクショしたものを以下に添付します。自動翻訳なので日本語があれですが）
<img width="75%" alt="step-functions-map-limit.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/1da598c1-b9a1-5545-451d-63440cc33c52.png">

:::note info
ちなみにですが、英語版ドキュメントでは従来バージョンのことを [Inlineモード](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-asl-use-map-state-inline.html) と呼んでおり、将来的には日本語ドキュメントもその呼称になるものと思われます。
:::

## Distributed Mapの何がすごいか

もうおおよそ予想がついてることと思われますが、[**Distributed Map**](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-asl-use-map-state-distributed.html) を用いると、最大で **10,000並列** まで可能になります。
すごい！！！！！！！

:::note warn
並列実行させるサービスでも並列実行可能な上限が変わります。Lambdaの場合はデフォルトの同時実行可能数が1000のため、上限の引き上げを行わなければ、1000より大きい値を設定するとエラーとなります
:::

## Serverless Framework で実現する方法

それでは、Distributed Map のすごさがわかったところで、Serverless Framework と Lambda を使って実際に動かす方法をステップ別に説明していきます（エラーハンドリングとかは今回は無視）。

※ 最近使い始めたTypeScriptで書いていきます（が、動けばいいだけのコードではあるのでご容赦を）
※ AWS CLI や Serverless Frameworkの細かな設定や解説まで書くと長くなりすぎるので要点だけ書いていきます
※ バージョンはこれで試してます
```
$ sls --version
Running "serverless" from node_modules
Framework Core: 3.25.1
Plugin: 6.2.2
SDK: 4.3.2
```

### Step1: Lambda 関数の作成

とりあえず `Hello, world` してくれる関数を用意します。

```typescript:handler.ts
export const hello = (_event, _context, callback) => {
  callback(null, { message: "hello, world" });
};
```

Serverless Frameworkでは、以下のように `functions:` の下に各 Lambda の定義を書いていきます。
（複数 Lambda を使用する場合は、 `hello` と同階層に複数設定が並ぶ形ですね）

```yml:serverless.yml（抜粋）
functions:
  hello:
    handler: handler.hello
```

諸々設定できたら、以下でAWS上にデプロイできます。

```
$ sls deploy
```

AWSコンソールから手動で Lambda をテスト実行するとちゃんと動いてくれます。
<img width="75%" alt="serverless-lambda-sample.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/c1c260cf-83b0-434c-8259-c1f764199e9e.png">

### Step2: Step Functions workflow の作成

それでは、同じ要領で複数の Lambda を作っていき、それを Step Functions workflow でつなげてみます。

今回は、以下の2つの処理を行うワークフローを作ることとします。
1. 0 ~ 9 までの10個の要素を持つ配列を作成
2. 1で作成した配列の各要素を2倍にした配列を作成


まずは、Step1と同様にして２つの Lambda を作ります。

```typescript:handler.ts
/**
 * 0 ~ 9 までの10個の数値を持つ配列を作成
 */
export const firstFunction = (_event, _context, callback) => {
  const values = [...Array(10)].map((_, i) => i);
  callback(null, values);
};

/**
 * 受け取った配列の各要素を2倍した配列を作成
 */
export const secondFunction = async (event, _context, callback) => {
  const result = event.map((x) => x * 2);
  callback(null, { result: result });
};
```

```yml:serverless.yml（抜粋）
functions:
  first-function:
    handler: handler.firstFunction
  second-function:
    handler: handler.secondFunction
```

これを Step Functions で取り扱うには、`serverless-step-functions` というプラグインを導入する必要がありますので、`$ yarn add --dev serverless-step-functions` などとしておきましょう（[このプラグインのGitHubレポジトリ](https://github.com/serverless-operations/serverless-step-functions)）。

あとは、以下のように Serverless Framework の設定ファイルに記述し、`$sls deploy` すればOKです（この後に紹介する図の雰囲気で設定内容は理解できるので詳細は割愛）。

```yml:serverless.yml（抜粋）
plugins:
  - serverless-step-functions

stepFunctions:
  stateMachines:
    sampleStateMachine:
      name: sampleStateMachine
      definition:
        StartAt: FirstState
        States:
          FirstState:
            Type: Task
            Resource:
              Fn::GetAtt: [first-function, Arn]
            Next: SecondState
          SecondState:
            Type: Task
            Resource:
              Fn::GetAtt: [second-function, Arn]
            End: true
```

AWSコンソールから該当のステートマシンを見にいくと、このような図を見ることができます。順序よく実行されそうな雰囲気が漂っていますね。それぞれの State が各 Lambda に対応しています。
<img width="30%" alt="step2-workflow.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/01a2aef8-18f0-4adf-ff21-c723b0c767ec.png">

実際に実行してみると、以下のように実行状態を確認できます（今回はすぐに完了してしまったので全て緑）
<img width="75%" alt="step2-workflow-result.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/fcc290ec-f776-a86e-889a-bde65fcfe722.png">

復習ですが、今回はこのようなワークフローでした。
1. 0 ~ 9 までの10個の要素を持つ配列を作成
2. 1で作成した配列の各要素を2倍にした配列を作成

最終的な実行結果を見てみると、確かにうまくいっていることがわかります。
<img width="75%" alt="step2-workflow-result-output.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/fa95fb1a-fcca-91ab-aaf0-0cf6917a1ed1.png">

ちなみにですが、各ステートの入出力を確認することもできます。
（ `FirstState` の返り値が次の `SecondState` に Step Functions により渡されているのが分かります）

<img width="75%" alt="step2-workflow-immediate-input-data.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/1ce6cc01-8dd4-33c4-40fe-dd4b180a610d.png">

### Step3: Mapステート （Inline モード）の適用

次に並列処理を実装してみましょう。並列処理の威力をわかりやすくするために10秒かかる処理を無理やり入れてみます。

1. 0 ~ 9 までの10個の要素を持つ配列を作成
2. (並列処理) 各要素の値を **10秒待った後** 2倍する
3. 2で実行された結果を表示


まずは例によって各関数を実装します。

```typescript:handler.ts
/**
 * 0 ~ 9 までの10個の数値を持つ配列を作成
 */
export const firstFunction = (_event, _context, callback) => {
  const values = [...Array(10)].map((_, i) => i);
  callback(null, values);
};

/**
 * 受け取った値を10秒待った後に2倍する
 */
export const secondFunction = async (event, _context, callback) => {
  const sleep = async (ms: number) => {
    return new Promise((resolve) => setTimeout(resolve, ms));
  };
  await sleep(10000);

  callback(null, { secondFunctionResult: event * 2 });
};

/**
 * 並列処理された結果をどのように受け取るかを確認するための関数
 */
export const finalFunction = async (event, _context, callback) => {
  callback(null, { finalResult: event });
};

```

※ serverless framework の Lambda 関数の処理時間のデフォルトは6秒であり、10秒間待つ処理を行えないため、今回は適当にタイムアウトの時間を伸ばしておきます

```yml:serverless.yml（抜粋）
functions:
  first-function:
    handler: handler.firstFunction
  second-function:
    handler: handler.secondFunction
    timeout: 30
  final-function:
    handler: handler.finalFunction
```


設定は以下のように行います。ポイントは以下の2つですかね。
- 並列処理を行いたいステートについて `Type: Map`と設定
  - その中に `Iterator` という項目を作り、並列処理させたい Lambda について記載
- `MaxConcurrency` で最大並列実行数を指定

```yml:serverless.yml（抜粋）
stepFunctions:
  stateMachines:
    sampleStateMachine:
      name: sampleStateMachine
      definition:
        StartAt: FirstState
        States:
          FirstState:
            Type: Task
            Resource:
              Fn::GetAtt: [first-function, Arn]
            Next: SecondState
          SecondState:
            Type: Map
            MaxConcurrency: 5
            Iterator:
              StartAt: MyMapTask
              States:
                MyMapTask:
                  Type: Task
                  Resource:
                    Fn::GetAtt: [second-function, Arn]
                  End: true
            Next: FinalState
          FinalState:
            Type: Task
            Resource:
              Fn::GetAtt: [final-function, Arn]
            End: true
```

デプロイしてみると以下の図を見ることができます。並列処理してくれそうな雰囲気が漂っていますね。
<img width="30%" alt="step3-workflow.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/2e055257-a8d9-4bc9-ba9f-a37f694f7203.png">

実行し、結果を見てみましょう。

まずは各処理にかかった時間から見ていきます。
10秒かかるタスクが10個あるので、並列に実行した場合に1分40秒はかかるタスクが、5並列で実行したことで、合計20秒程度で完了したことが分かります。
また、**最初の5個のタスクが完了するまで待ってから** 残りの5つについても処理を行っている様子がよく分かるかと思います。この辺りも勝手に調整してくれるのは楽ですね。

<img width="100%" alt="step3-workflow-time.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/e7a68b37-5280-79f6-da28-b6151b1a3f14.png">

次に肝心の出力結果ですが、こちらもうまく動いてくれたことが分かります。
また、下が見切れていて少し分かりづらいですが、 **各並列処理の返り値を配列にまとめた上で、次のステートに渡される** ことが確認できますね。
<img width="75%" alt="step3-workflow-time.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/60bb7c4b-d905-7863-7b7d-d557e161a77b.png">

:::note warn
試しに1000個のタスクを用意し、MaxConcurrencyを500に設定してみたのですが、50並列相当の時間がかかりました。ドキュメントの通り、40を超えるような設定はあまり意味ないですね。
:::

### Step4: Distributed Map の適用

最後にメインの Distributed Map の設定方法を見ていきましょう。この記事を執筆した2022年12月8日現在では、日本語ドキュメントには項目自体が存在しないため、英語ドキュメントを確認する必要があります（東京リージョン対応してるんだから、項目くらい追加しておいてくれると嬉しいなと思わないではない）。

そのドキュメントはこちらですね。

https://docs.aws.amazon.com/step-functions/latest/dg/concepts-asl-use-map-state-distributed.html

これにより、`ItemProcessor` という項目の中で、`Mode` を `Distributed` に、`ExecutionType` を適当に設定することで使用可能ということがわかります。
<img width="75%" alt="step3-workflow-time.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/98b6e610-bbf8-fd01-7d29-040abe5ee23a.png">

[Inline モードのMapステート（従来型）のドキュメント](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-asl-use-map-state-inline.html)には記載があるのですが、 `Iterator` というオプションが非推奨となっており、その代わりとして新たに作られたもののようです。
（ちなみに、こちらを使いたい場合は `Mode` に `Inline` を設定）

<img width="75%" alt="step3-workflow-time.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/62ba4386-d3f0-7321-1c09-bbac8569791f.png">

というわけで、`Iterator` の代わりに `ItemProcessor` と各種項目を設定してみたくなるのですが、これをやるとまだプラグイン側が対応していないために `$ sls deploy` 実行時にエラーがでてしまいます。

しかし！ここで諦めてはいけません。

試した結果 `Iterator` はそのままに、`Mode: "Distributed"` と `ExecutionType` を設定することで何故か動くことが分かりました。

つまりは、基本的には、このように3行追加するだけで、新機能の恩恵に預かることができるのです！
（と言いつつ、実際には `states:StartExecution` をこのステートマシンに追加で許可しておかないと動かないので注意。もちろん `lambda:InvokeFunction` も忘れずに）

```yml:serverless.yml（抜粋）
         SecondState:
            Type: Map
            MaxConcurrency: 500
            Iterator:
              ProcessorConfig:           ← 追加
                Mode: DISTRIBUTED        ← 追加
                ExecutionType: STANDARD  ← 追加
              StartAt: MyMapTask
```

ということで、最後にこのワークフローを試してみましょう。
並列数は **500** で試してみます！

1. 0 ~ 999 までの **1000個** の要素を持つ配列を作成
2. (並列処理) 各要素の値を **10秒待った後** 2倍する
3. 2で実行された結果を表示

処理中のスクショがこちらです。500個動いてますねー

<img width="100%" alt="step3-workflow-time.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/2f83d704-0f7f-c92f-bafa-9be0e76306b0.png">

処理時間も、並列処理でなければ3時間程度かかるところが、40秒程度で完了しています。すばらしい！
<img width="100%" alt="step3-workflow-time.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531685/c9183d73-7462-a492-0231-834797d0ec34.png">

:::note warn
1000個のタスクを実行する場合、20sec ~ 30sec 程度のオーバーヘッドが発生するようです。そのため、1000タスク × 10sec / 500並列 = 20sec という想定よりは時間がかかります
:::

## 終わりに

AWSからのちょっと早い素敵なクリスマスプレゼントでしたね。
これからも良いサーバレス & 並列処理ライフを〜