コドモン Product Team Blog

株式会社コドモンの開発チームで運営しているブログです。エンジニアやPdMメンバーが、プロダクトや技術やチームについて発信します!

運用作業の自動化で見えた、AWS Step FunctionsをVisual Editorで組む際のコツ

 こんにちは。コドモンStep Functions部の関口です。最近Step Functionsばかりやっていたので自分が開発部の所属であることももはや忘れています......。

 みなさまはStep Functionsを活用されていますか? 私は覚えることが多そうだという理由で今まで敬遠していたのですが、ここ最近、業務処理バッチや運用作業の自動化に携わる中で利用する機会が増えました。比較的規模の大きなStateMachineを作る中で自分なりのTipsが増えてきたので、ここで書いてみようと考えました。

 今回ご紹介するのは、AWSのコンソールからStep Functionsへ飛んでVisual Editorで作る際のTipsをメインにしています。CDKで作る際には当てはまらないものもあるかもしれませんのでご承知おきください。

最初から大きく作らない。テストしやすい単位に分けて組み合わせる

 StateMachineから別のStateMachineを呼び出すことが可能です。さらによいことに、同期的に実行してその出力を「$.Output」から取り出すことも可能です。分けることで、単体で見た場合の実行時間が減ってテストが容易になりますし、認知負荷の低減も期待できます。

 例えば、AuroraのClusterを作成するStateMachineを作るとします(分かりやすくするため、DB Instanceは作らないものとします)。何度同じ操作をしたとしても、同じ結果を得られる冪等(べきとう)性を担保するために、最初に同じIDのClusterが存在していたら削除し、そのあとでClusterを作成するように設計するとします。この場合、論理的なフローは「同じIDのClusterに存在するかチェック->あったら削除、なければそのまま次へ->削除が終わるまで待機->Clusterを作成->作成が終わるまで待機->完了」となると思います。

StateMachineのイメージ

 上記のフローをそのまま作ると、「Clusterを作成」以後にバグがあってFailした場合、修正してリトライすると再び削除の一連のフローの終了を待たねばならず、修正したStateに到達するまで時間を取られることが予想されます。時短のためにInstanceのIDを変えて別インスタンスを立てることも可能ですが、何度も行うと消す手間が大変です。

 では、作成と削除を分けて別々のStateMachineで作るとどうなるでしょうか?

分割したStateMachineのイメージ
分割したStateMachineのイメージお互いを別々にテストできますので、作成のテストで削除の終了を待つ必要もないですし、ClusterのIDを色々変えて作成のテストを流したとしても、後片付けで削除のテストができます。加えて、別のユースケースで削除だけが必要になっても、分けてあるのでそのままStateMachineを呼び出すだけで再利用できます。

 とはいえ、細かく分割しすぎてStateMachineが増えるとそれはそれで検索性が低下したり認知負荷が高まるかもしれません。動作確認できたStateMachineを統合したい場合は、統合元の一連のフローをJSONでコピーして統合先にペーストして統合しましょう(この統合メソッドは弊社Step Functions部の西銘さんに教えていただきました)。統合後は移行してきたフローのつなぎ換えおよびIN/OUTの整合とIamポリシーの差分追加を忘れないようにします。

JSONを使った統合のイメージ

 分けた後の統合は比較的簡単にできます。早く作るために分けましょう。

State名には外から見た振る舞いを書く。やることの詳細を書かない

 例えば、Visual Editorで他のStateMachineを実行するStateをドロップすると、「StartExecution」と表示されます。先ほどのRDSのインスタンスを消すStateMachine名が「delete-rds-cluster」という名前だったとしたら、それを呼び出すState名は「DeleteRdsClusterStartExecution」としたくなるのではないでしょうか?

 思い切ってデフォルトの名前は消しましょう。「DeleteRdsCluster」でいいのです。StartExecutionという詳細を隠すことで、名前は短くなりやることが明快になります。また、Stateの処理の中身が「Invoke Lambda」になろうが、「ECS Run Task」になろうが、名前は「DeleteRdsCluster」のままで済みます。PassやChoice、Failの名前も同様です。「DescribeDbClusterPass」や「DbCluesterStatusChoice」、「Fail(2)」などよりも、「GetStatusFromDescribeDbClusterResult」、「IsDbCluesterAvailable?」、「NoSuchCluster」の方がはるかにいいです。

 デフォルト名を捨てて、振る舞いを書いてください。詳細を晒すのは百害あって一利なしですし、いい名前付けがあればフロー自体が設計を雄弁に語ってくれます。 

Stateのテストをフル活用する

 Stateは一部を除きほとんどが単体でテスト可能です。PassやChoice、Get系のAPIなどの状態変更を伴わないStateのテストにはうってつけなので、StateMachine全体のテストを始める前に、可能な限り多くのStateを単体でテストしましょう。後述するJsonPath構文や組み込み関数の実験なども単体でテストするのが早くて簡単です。

Stateのテスト

副作用を伴うStateは最初から組み込まない。まずはPassでモックする

 私の経験では、Visual Editorで作成するStateMachineが安定した挙動を獲得するまでの間に起きるエラーの90%は、State間のインターフェースの不整合が原因です(私だけかもしれませんが)。副作用がある処理が走った後のStateで処理が失敗すると、手動リカバリしてテストを流し直す必要が生じます。2,3回程度で済めばいいのですが、往々にしてその程度では済まないのが現実です(少なくとも私は)。

 副作用がある処理の戻り値は後続であまり利用することがないか、IDやETagなどのせいぜい2,3項目あれば十分であることが多いです。PassのParametersでmockして、先にState間のインターフェースの整合を確認しましょう。上述した「State名には外から見た振る舞いを書く」練習にもなります。

JSONや配列の操作はLambdaを立てる前にPassやMapでできないかよく検討する

 Lambdaは手軽に作れるので、ちょっとした処理をしたくなった際には「プログラムなら簡単に書ける」と思ってすぐに使いたくなります。しかし、立てたLambdaは定期的なアップデートやちょっとした監視が必要になります。数が増えるとそれだけ管理も大変になるので、作るのがどれだけ手軽であっても極力作らないのが最善です。Lambdaを利用しないことで、多少フローが長くなったり冗長になったり、Step functions固有の記述知識が必要になるデメリットが発生することはありますが、余程のことがない限り運用の手間を負わずに済むというメリットが勝ります。以下、私がLambdaの誘惑を跳ね除けた例を挙げます。 

JSONの一部項目の編集/追記

 APIなどで取得したJSONの一部を書き換えて次のStateに送りたいというユースケースは、Passを使って組み込み関数のStates.JsonMergeを利用することで解決できます。但し、shallow(深さ1)マージしかサポートしていないため、ネストのある項目の編集/追記はひと工夫が必要になります。深さ1以上の項目は2つ目の引数に指定されたJSONで上書きされます。

 以下は深さ3の項目を更新するStateMachineの例です。 (以後のStateMachineの例は、実際にStep FunctionsのVisular Editorに貼り付けて動かせます)更新する項目の深さでStates.JsonMergeした後、トップレベルまでStates.JsonMergeを繰り返して元の項目を残したまま深さ3の項目にのみ変更を適用します。最終状態のmergedが結果です。実際にこのStateMachineを実行して各Stateの出力をご覧いただくと、何をしているかがご理解いただけると思います。

{
  "StartAt": "DescribeSomeResource",
  "States": {
    "DescribeSomeResource": {
      "Type": "Pass",
      "Result": {
        "name": "name",
        "detail": {
          "description": "description",
          "worker": {
            "state": "running",
            "maxCount": 10
          }
        }
      },
      "Next": "DefineItemsToUpdate",
      "ResultPath": "$.current",
      "Comment": "架空のリソースのJSONを出力する"
    },
    "DefineItemsToUpdate": {
      "Type": "Pass",
      "Next": "MergeWorkerUpdatesIntoCurrent",
      "Result": {
        "worker": {
          "state": "pending"
        }
      },
      "ResultPath": "$.update",
      "Comment": "リソースの更新する箇所を定義する"
    },
    "MergeWorkerUpdatesIntoCurrent": {
      "Type": "Pass",
      "Next": "MergeDetailIntoCurrent",
      "Parameters": {
        "worker.$": "States.JsonMerge($.current.detail.worker, $.update.worker, false)"
      },
      "ResultPath": "$.mergedWorker.detail",
      "Comment": "workerの変更をマージする"
    },
    "MergeDetailIntoCurrent": {
      "Type": "Pass",
      "Parameters": {
        "detail.$": "States.JsonMerge($.current.detail, $.mergedWorker.detail, false)"
      },
      "Next": "MergeAllIntoCurrent",
      "ResultPath": "$.mergedDetail"
    },
    "MergeAllIntoCurrent": {
      "Type": "Pass",
      "End": true,
      "Parameters": {
        "current.$": "$.current",
        "update.$": "$.update",
        "merged.$": "States.JsonMerge($.current, $.mergedDetail, false)"
      }
    }
  }
}

 ご覧の通り深いレベルの一部だけを更新する場合は深さの分だけStateを作らねばならず、そこそこ複雑になります。そのため、更新したい項目がネストの深い場所にある場合、まずは「2つ目の引数で上書きする」という性質が利用できないかを検討することをお勧めします。以下は先ほどの例の深さ2以上の項目を決め打ちすることでマージを一回に短縮したバージョンです。

{
  "StartAt": "DescribeSomeResource",
  "States": {
    "DescribeSomeResource": {
      "Type": "Pass",
      "Result": {
        "name": "name",
        "detail": {
          "description": "description",
          "worker": {
            "state": "running",
            "maxCount": 10
          }
        }
      },
      "Next": "DefineItemsToUpdate",
      "ResultPath": "$.current",
      "Comment": "架空のリソースのJSONを出力する"
    },
    "DefineItemsToUpdate": {
      "Type": "Pass",
      "Next": "MergeAllIntoCurrent",
      "ResultPath": "$.update",
      "Comment": "リソースの更新する箇所を定義する",
      "Parameters": {
        "detail": {
          "description.$": "$.current.detail.description",
          "worker": {
            "state": "pending",
            "maxCount.$": "$.current.detail.worker.maxCount"
          }
        }
      }
    },
    "MergeAllIntoCurrent": {
      "Type": "Pass",
      "End": true,
      "Parameters": {
        "current.$": "$.current",
        "update.$": "$.update",
        "merged.$": "States.JsonMerge($.current, $.update, false)"
      }
    }
  }
}

 但し、このバージョンは項目定義に変更があった場合に影響を受ける可能性がありますので、注意して利用してください。

配列をFilterするならJsonPathを使う

 単純なフィルタならPassにJsonPathを利用するだけで可能です。オブジェクトの配列の中からname属性が途中で定義した「param.value」変数(例では「hoge」)と一致するものを抽出しています。

{
  "StartAt": "GenerateObjects",
  "States": {
    "GenerateObjects": {
      "Type": "Pass",
      "Next": "DefineFilteringNameParameter",
      "ResultPath": "$.target",
      "Comment": "架空のリソースのJSONを出力する",
      "Result": [
        {
          "id": 1,
          "name": "hoge"
        },
        {
          "id": 2,
          "name": "fuga"
        },
        {
          "id": 3,
          "name": "piyo"
        },
        {
          "id": 4,
          "name": "hoge"
        }
      ]
    },
    "DefineFilteringNameParameter": {
      "Type": "Pass",
      "Next": "FilterByParameter",
      "Result": {
        "value": "hoge"
      },
      "ResultPath": "$.param"
    },
    "FilterByParameter": {
      "Type": "Pass",
      "Parameters": {
        "filtered.$": "$.target[?(@.name == $.param.value)]"
      },
      "End": true
    }
  }
}

 条件が複雑だったりして単純なJsonPathで解決できない場合はフローで工夫します。以下は先ほどの例をJsonPathを使わない形式に書き換えたものです。Mapしてそれぞれの要素を判定し、該当しないものをマーキングして(例では、空のオブジェクトに変換しています)、最後にマーキングされていないものをフィルタすることで実現しています。

{
  "StartAt": "GenerateObjects",
  "States": {
    "GenerateObjects": {
      "Type": "Pass",
      "Next": "DefineNameFilteringParameterValue",
      "ResultPath": "$.target",
      "Comment": "架空のリソースのJSONを出力する",
      "Result": [
        {
          "id": 1,
          "name": "hoge"
        },
        {
          "id": 2,
          "name": "fuga"
        },
        {
          "id": 3,
          "name": "piyo"
        },
        {
          "id": 4,
          "name": "hoge"
        }
      ]
    },
    "DefineNameFilteringParameterValue": {
      "Type": "Pass",
      "Next": "FilterObjectsByFilteringParameter",
      "Result": {
        "value": "hoge"
      },
      "ResultPath": "$.param"
    },
    "FilterObjectsByFilteringParameter": {
      "Type": "Map",
      "ItemProcessor": {
        "ProcessorConfig": {
          "Mode": "INLINE"
        },
        "StartAt": "Match?",
        "States": {
          "Match?": {
            "Type": "Choice",
            "Choices": [
              {
                "Variable": "$.item.name",
                "StringEqualsPath": "$.param.value",
                "Next": "Pass"
              }
            ],
            "Default": "Omit",
            "OutputPath": "$.item"
          },
          "Pass": {
            "Type": "Pass",
            "End": true,
            "ResultPath": null
          },
          "Omit": {
            "Type": "Pass",
            "End": true,
            "Result": {}
          }
        }
      },
      "ItemsPath": "$.target",
      "ResultPath": "$.filtered",
      "ItemSelector": {
        "item.$": "$$.Map.Item.Value",
        "param.$": "$.param"
      },
      "Next": "SortFilteringResult"
    },
    "SortFilteringResult": {
      "Type": "Pass",
      "End": true,
      "Parameters": {
        "filtered.$": "$..filtered[?(@.id)]"
      }
    }
  }
}

 この他にもAWSだけでなく、サードパーティー製のものも含め様々なAPIを実行できるStateが揃っており、Step Functions単独で実現可能なことはかなり多いです。うまく組み合わせて可能な限りStep Functionsだけで問題を解決しましょう。

処理が終わることの先、StateMachine全体のOutputを意識する

 StateMachineが、なんらかの副作用がある処理として定義されている場合、忘れがちなのが最後の出力の形式の確認です。途中の処理に必要となるデータが増えるほどに、最後の出力は肥大化しがちですので、結果をサマリーするPassを一つ定義して余計なものを削ぎ落としましょう。簡潔な出力は結果確認にかかる認知負荷を低減してくれます。

Event Bridgeを利用して関心事を切り離す

 Event Bridgeを利用すると、例えばStateMachineの実行状態の変化を契機として様々なことができます。StateMachine内に、主たる関心事でないStateが紛れていたら、これを利用できないか検討してください。切り離せるものは以下のような特徴を持っています。

  1. StartやEnd(もしくはSuccessやFail)付近にいる
  2. その(単体あるいは一連の)Stateがエラーになっても論理的にStateMachine自体はFailではない
  3. StateMachineの途中で得られる変数となんら関係なく実行できる

 具体例の代表的なものは通知です。実行の成功や失敗を通知するのであれば、Event Bridgeの利用が圧倒的におすすめです。例えば通知であればAPI destinationを利用してLambdaなど別のリソースを必要とせずにEvent Bridge単独で実装できます。

 Event Bridgeは非常に柔軟で強力なツールです。使わない手はありません。StateMachineを簡潔に保つためにEvent Bridgeを活用しましょう。

おまけ

 Visual Editorで作ったStep Functionsを他の環境に展開するにはどうしているんだろうという疑問をお持ちの方もいるかもしれません。私のやり方を書かせていただきます。以下の4ステップです。

  1. Visual Editorで単一の環境で動作するStateMachineを完成させる
  2. JSONをそのままCDKのtsファイル(Pythonならdict)にobjectとして書き出す
  3. アカウントIDやリージョンなど環境ごとに異なる部分を変数として定義し、CDKコマンド実行時に変数の部分を埋め込んでいく
  4. 同様の方法でIamについてもCDKに移植する

これにより、StateMachineを展開先の環境に応じてdeployできるようにします。以下に、StateMachineのJSONとCDKのコードを例示します。(Iamについては割愛します)

<StateMachineのJSONの例>

{
  "StartAt": "DeleteDBCluster",
  "States": {
    "DeleteDBCluster": {
      "Type": "Task",
      "Resource": "arn:aws:states:::states:startExecution.sync:2",
      "Parameters": {
        "StateMachineArn": "arn:aws:states:ap-northeast-1:ACCOUNT_ID:stateMachine:DeleteDBCluster",
        "Input": {
          "StatePayload": {
            "DbClusterIdentifier.$": "$.DbClusterIdentifier"
          },
          "AWS_STEP_FUNCTIONS_STARTED_BY_EXECUTION_ID.$": "$$.Execution.Id"
        }
      },
      "Next": "CreateDBCluster"
    },
    "CreateDBCluster": {
      "Type": "Task",
      "Resource": "arn:aws:states:::states:startExecution.sync:2",
      "Parameters": {
        "StateMachineArn": "arn:aws:states:ap-northeast-1:ACCOUNT_ID:stateMachine:CreateDBCluster",
        "Input": {
          "StatePayload": {
            "DbClusterIdentifier.$": "$.DbClusterIdentifier"
          },
          "AWS_STEP_FUNCTIONS_STARTED_BY_EXECUTION_ID.$": "$$.Execution.Id"
        }
      },
      "End": true
    }
  }
}

<CDK(Typescript)に移植したコード>

import {ScopedAws, Stack, StackProps} from "aws-cdk-lib";
import {DefinitionBody, StateMachine} from "aws-cdk-lib/aws-stepfunctions";
import {Construct} from "constructs";

const stateMachineJson = (args: {
    accountId: string,
    region: string,
}) => {
    const {accountId, region} = args
    return {
        "StartAt": "DeleteDBCluster",
        "States": {
            "DeleteDBCluster": {
                "Type": "Task",
                "Resource": "arn:aws:states:::states:startExecution.sync:2",
                "Parameters": {
                    "StateMachineArn": `arn:aws:states:${region}:${accountId}:stateMachine:DeleteDBCluster`,
                    "Input": {
                        "StatePayload": {
                            "DbClusterIdentifier.$": "$.DbClusterIdentifier"
                        },
                        "AWS_STEP_FUNCTIONS_STARTED_BY_EXECUTION_ID.$": "$$.Execution.Id"
                    }
                },
                "Next": "CreateDBCluster"
            },
            "CreateDBCluster": {
                "Type": "Task",
                "Resource": "arn:aws:states:::states:startExecution.sync:2",
                "Parameters": {
                    "StateMachineArn": `arn:aws:states:${region}:${accountId}:stateMachine:CreateDBCluster`,
                    "Input": {
                        "StatePayload": {
                            "DbClusterIdentifier.$": "$.DbClusterIdentifier"
                        },
                        "AWS_STEP_FUNCTIONS_STARTED_BY_EXECUTION_ID.$": "$$.Execution.Id"
                    }
                },
                "End": true
            }
        }
    }
}

export class DataMaskingToolsStack extends Stack {
    constructor(scope: Construct, id: string, props: StackProps) {
        super(scope, id, props);
        const {accountId, region} = new ScopedAws(this);
        new StateMachine(this, 'CreateMaskingDB', {
            stateMachineName: 'CreateMaskingDB',
            definitionBody: DefinitionBody.fromString(
                JSON.stringify(
                    stateMachineJson({accountId, region})
                )
            )
        })
    }
}

 Step FunctionsのStateMachineはグラフ構造です。そのため、CDKで直接プログラムのコードとして書こうとすると、フローのつながりを視覚的に確認することができません。しかしながら、Visual Editorでは視覚的にフローのつながりを確認しながら進められるので、認知負荷が低く取り組めます(当てはまらない方もいると思いますが)。一方で、Visual Editorで書いたものは移植性においてCDKに劣ります。StateMachineを各環境にJSONで展開するのは非常に面倒です。環境ごとの差異に起因する修正やIamの設定、そしてテストのやり直しも必要になるので、StateMachineの数や展開先の環境数が増えるほどに手間が大きくなります。

 そこで、最初の段階では一つの環境に特化させてVisual Editorで書きあげ、上記方法でCDKに移植してデプロイしていくことで両方のいいとこ取りをしようというのがこの発想のモチベーションになります。色々試行錯誤した結果この方法に辿り着いたのですが、ややダーティーなやり方ではあるので、他にいい方法があれば是非ご教示ください。

まとめ

 Visual Editorで作成するStep Functionsは、プログラムを組むのと同等あるいはそれ以上に、早く失敗して早く改善していけることが開発効率/体験上で重要になってきます。以下の特性があるからです。

  • Step FunctionsのVisual Editorはチェックや補完があまり強くないので、実行するまで顕在化しないヒューマンエラーが混入しがち
  • AWS標準のAPIであってもドキュメントと乖離(かいり)があったりして、やはり実行しないとわからないことがある
  • 普段あまり馴染みのないJsonPath構文や「"Key.$": "$.Variable"」のようなStep Functions特有の構文などもあり、作る過程でいくつかの実験が必要になることが多い

 今回ご紹介したTipsはそのためのプラクティスとして私なりに考えたことを書いていますが、内実はプログラミングにおけるプラクティス集の焼き直しのようなものになっています。Step Functionsを作ることは本質的にプログラムを作るのと同じですので、ちょっと特殊な言語でプログラムを書いているんだと考えると、色々気づいたり改善できるポイントが出てきます。

 多少の不自由さを感じることはありますが、Step Functionsは文句なしに柔軟で強力なツールです。特にAWSリソースの一連の操作を自動化するというようなユースケースにおいて非常に強力です。活用しない手はないと思います。

 色々偉そうに書いてしまいましたが、以上が「絶対に成功する確信を持って実行->40分経過->Fail->絶望->修正->絶対に......」のサイクルを数多く繰り返した結果、辿り着いた私のTipsです。私が溶かした多くの時間、苛まれた無数のストレスをみなさまがセーブできる一助になれば幸いです。