そのLambda、本当に必要ですか…?Step Functionsのすゝめ

本記事はANGEL Dojo 2024参加者によるアドベントカレンダー「ANGEL Calendar」の最終日の記事になっております。
他のみなさんが書かれた記事はこちらからご覧ください!
※…ANGEL Dojo 2024に関しましてはAWS JAPAN APNブログをご覧ください。

こんにちは、ひるたんぬです。
今日で2024年度上半期が終わりますね。これを書いてふと思ったのですが、なぜ「YYYY年度」は4月始まりなのでしょうか?
1月から始めてくれていれば色々スッキリするのに…と思い、なぜ4月からになったのか調べてみました。

(前略)当初から4月始まりだったわけでなく、明治政府により会計年度が初めて制度化された明治2年(1869)は、10月始まり。続いて、西暦を採用した明治6年からは、1月始まりになりました。つまり、暦年と年度の始まりが同じ時代があったのです。明治8年からは、地租の納期にあわせるという目的で、7月始まりになりました。

次に会計年度を変更したのは、明治17年(1884)。その頃の日本は、国権強化策から軍事費が激増し、収支の悪化が顕著になっていました。当時の大蔵卿である松方正義は、任期中の赤字を削減するために、次年度の予算の一部を今年度の収入に繰り上げる施策を実施。この施策は珍しくなく、当時はよく行われていました。そして、予算繰り上げによるやりくりの破綻を防ぐため、松方は一策を講じました。明治19年度の会計年度のスタートを7月始まりから4月始まりに法改正したのです。(後略)
引用…国立公文書館ニュース「会計年度はなぜ4月始まりなのか

何度も変更されており、その理由も予算の兼ね合いだったんですね。ちょっと予想外でした。

さて、今回はタイトルにもあります通り、Lambda関数を使わずにStep Functionsのみで完結できるケースについて簡単なアプリケーションの比較を通してご紹介します。
この記事は、ANGEL Dojo活動中に紹介された以下の記事に着想を受けて執筆しました。

参考記事でも注意書きとして記載がありますが、本記事においても「全てにおいてStep Functionsを使え!Lambdaは悪!!」という主張はいたしません。私自身LambdaもStep Functionsも大好きです。
「Step Functionsだけでも処理が書けるケースがあるんだ~」くらいの感覚で読んでいただけますと幸いです。

Step Functionsとは?

AWSのサーバレスサービスの一つです。複数のアプリケーションやサービスをつなぎ合わせる(オーケストレーション)機能を提供しています。また、コンソール上でビジュアルプログラミングのようにアプリケーションをブロックとして直感的に組み合わせることができるので、プログラミング言語が苦手な人や経験がない人でも触りやすいと思います。
より詳細な特徴などにつきましては、下記サイトをご覧ください。

Lambdaをなくしてみた

今回は簡単に、以下のアプリケーションを作ってみました。

ここでご紹介しているアプリケーションは本ブログ執筆のためだけに作ったものです。
Angel Dojoなどで作っているものではありません。

お出かけアシスタント

このアプリケーションは今日お出かけしたい場所を入力として与えると、

  • お出かけしたい場所の最寄り駅
  • 今日の気温に適した服装

を教えてくれます。最寄り駅や気温の取得には外部APIを使用し、気温に適した服装はBedrockに教えてもらうことにします。

Lambda

まずはこれをPythonを使ってゴリゴリ書いていきましょう。
今回はStep Functionsの魅力を伝えるため上記要件の処理を並列で処理するようにしています。

Pythonの並列処理についてはいろいろな種類があります(厳密には並列処理でないものも)が、私はひとまずconcurrentというライブラリで書いています。
個人的には以下のサイトで各ライブラリの特徴を学びました。
このようにして出来上がったのが以下のコードです。
import boto3
import requests
import json
from concurrent.futures import ThreadPoolExecutor

from botocore.exceptions import ClientError

def lambda_handler(event, context):
    # 並列処理
    with ThreadPoolExecutor() as executor:
        near_station = executor.submit(get_near_station, event)
        outfit_prompt = executor.submit(suggest_outfit_flow, event)
    near_station_response = near_station.result()
    outfit_prompt_response = outfit_prompt.result()
    
    station = near_station_response["response"]["station"]
    outfit = outfit_prompt_response["content"]
    
    return {
        "station": station,
        "outfit": outfit
    }


# 最寄り駅の取得
def get_near_station(location:dict) -> dict:
    url = "https://www.heartrails-express.com/api/json"
    params = {
        "method": "getStations",
        "x": location["longitude"],
        "y": location["latitude"]
    }

    try:
        response = requests.get(url, params=params)
    except requests.exceptions.RequestException as e:
        raise("Error: ",e)

    return json.loads(response.content.decode("utf-8"))

# 気温の取得
def get_temperture(location:dict) -> dict:
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "longitude" : location["longitude"],
        "latitude" : location["latitude"],
        "hourly": "temperature_2m",
        "timezone": "Asia/Tokyo",
        "forecast_days": "1"
    }

    try:
        response = requests.get(url, params=params)
    except requests.exceptions.RequestException as e:
        raise("Error: ",e)
    
    return json.loads(response.content.decode("utf-8"))

# 服装の考案
def suggest_outfit(temp_data: dict) -> dict:
    client = boto3.client("bedrock-runtime")
    # プロンプトの生成
    temp = str(temp_data["hourly"]["temperature_2m"])
    prompt = "あなたは私の友人で、コーディネーターとして働いています。私は気温に応じて着たり脱いだりすることが嫌いです。今日0時から23時まで1時間ごとの気温は別のリストに与えるとおりです。1日通して私が外出するのに適した上下のコーディネートを簡潔に教えて下さい。"

    native_request = {
        "anthropic_version": "bedrock-2023-05-31",
        "max_tokens": 600,
        "temperature": 0.5,
        "messages": [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": prompt},
                    {"type": "text", "text": temp}
                    ],
            }
        ]
    }
    request = json.dumps(native_request)
    try:
        response = client.invoke_model(
            body=request,
            modelId="arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-3-haiku-20240307-v1:0"
        )
    
    except (ClientError, Exception) as e:
        print(f"ERROR: Can't invoke '{model_id}'. Reason: {e}")
        raise(e)
    
    return json.loads(response["body"].read().decode("utf-8"))

# 気温の取得 -> 服装の考案
def suggest_outfit_flow(location:dict) -> dict:
    temp_data = get_temperture(location)
    suggest_result = suggest_outfit(temp_data)
    return suggest_result

…読めなくはないけど、めんどくさい。というのが正直な感想でしょうか。
プログラミングにあまり触れていない方は、この時点で手を離してしまうかもしれません。

また、今回のコードでは「requests」というライブラリを使用しており、これはLambdaで標準では使用できないため、レイヤーなどでライブラリが使用できるようにする必要もあります。
…これも面倒です。

Cloud9につきましては使用できない方もいらっしゃると思います。
経緯や代替策につきましては公式サイトなどをご確認ください。
※ 本記事では本筋と逸れてしまうため割愛させていただきます。

Step Functions

次に同じ処理をStep Functionsで実装してみます。

…ん?それだけ??
そうなんです。こんなにシンプルに書けるのです。

出力結果は…?

両者で同じ入力を与えてみました。

{
  "latitude": "35.656315",
  "longitude": "139.795486"
}

Lambda

戻り値として以下が得られます。

{
  "station": [
    {
      "name": "豊洲",
      "prefecture": "東京都",
      "line": "東京メトロ有楽町線",
      "x": 139.79621,
      "y": 35.654908,
      "postal": "1350061",
      "distance": "170m",
      "prev": "月島",
      "next": "辰巳"
    },
    {
      "name": "豊洲",
      "prefecture": "東京都",
      "line": "新交通ゆりかもめ",
      "x": 139.795414,
      "y": 35.653792,
      "postal": "1350061",
      "distance": "280m",
      "prev": "新豊洲",
      "next": null
    },
    {
      "name": "新豊洲",
      "prefecture": "東京都",
      "line": "新交通ゆりかもめ",
      "x": 139.789996,
      "y": 35.648718,
      "postal": "1350061",
      "distance": "980m",
      "prev": "市場前",
      "next": "豊洲"
    },
    {
      "name": "越中島",
      "prefecture": "東京都",
      "line": "JR京葉線",
      "x": 139.792713,
      "y": 35.667946,
      "postal": "1350044",
      "distance": "1320m",
      "prev": "八丁堀",
      "next": "潮見"
    }
  ],
  "outfit": [
    {
      "type": "text",
      "text": "はい、分かりました。気温の変化に合わせて、1日中快適に過ごせるようなコーディネートをご提案します。\n\n朝から夕方にかけては、半袖Tシャツに薄手のシャツやカーディガンを羽織るのがおすすめです。\n午後からは、Tシャツ1枚でも問題ありません。\n夕方から夜にかけては、長袖の薄手のシャツやブラウスを着用するのがよいでしょう。\n\n気温の変化に合わせて、上下を調整しながら過ごせば、1日中快適に過ごせると思います。\n軽めの上着を持参するなど、状況に応じて調整できるようにするのがポイントです。"
    }
  ]
}

Step Functions

処理の流れを視覚的に確認することができます。

また、出力「output」として以下が得られます。

{
  "output": [
    {
      "outfit": [
        {
          "type": "text",
          "text": "はい、気温の変化に合わせて以下のようなコーディネートをおすすめします。\n\n午前中(0時~11時)は長袖Tシャツとジーンズが適しています。\n午後(12時~17時)は半袖Tシャツとスカートやショートパンツがよいでしょう。\n夕方(18時~23時)は長袖Tシャツとジーンズに戻すのがおすすめです。\n\nコーディネートのポイントは通気性のよい素材を選び、日中は涼しげな服装で、外出時の気温の変化にも対応できるよう、外羽織りを持参することです。\n天候や活動内容によって微調整が必要かもしれませんが、この提案で1日中快適に過ごせると思います。"
        }
      ]
    },
    {
      "station": [
        {
          "name": "豊洲",
          "prefecture": "東京都",
          "line": "東京メトロ有楽町線",
          "x": 139.79621,
          "y": 35.654908,
          "postal": "1350061",
          "distance": "170m",
          "prev": "月島",
          "next": "辰巳"
        },
        {
          "name": "豊洲",
          "prefecture": "東京都",
          "line": "新交通ゆりかもめ",
          "x": 139.795414,
          "y": 35.653792,
          "postal": "1350061",
          "distance": "280m",
          "prev": "新豊洲",
          "next": null
        },
        {
          "name": "新豊洲",
          "prefecture": "東京都",
          "line": "新交通ゆりかもめ",
          "x": 139.789996,
          "y": 35.648718,
          "postal": "1350061",
          "distance": "980m",
          "prev": "市場前",
          "next": "豊洲"
        },
        {
          "name": "越中島",
          "prefecture": "東京都",
          "line": "JR京葉線",
          "x": 139.792713,
          "y": 35.667946,
          "postal": "1350044",
          "distance": "1320m",
          "prev": "八丁堀",
          "next": "潮見"
        }
      ]
    }
  ],
  "outputDetails": {
    "truncated": false
  }
}

Bedrockの返答に差はあるものの、同じ構造で回答が得られていることが分かりますね。
脱いだり着たりするのが嫌いなのに、Step Functionsではパンツの履き替えまで要求されてますが…
プロンプトに関する勉強がまだまだ足りていない模様です。

Step Functionsのここがすごい!

ここからは、Step Functionsの個人的な魅力について、先程のアプリケーションをベースにご説明します。

直感的に作ることができる

先程のキャプチャでお分かりいただけたかもしれませんが、Step Functionsはデザインエディタを用いてブロックを並べるだけで処理が実現してしまいます。
特に各種AWSサービスを呼び出して行うものについては220のAWSサービス・10,000以上のAPIと直接統合されています。

▼ 統合されているAWSサービスの一部

また、この他にも外部APIを叩く機能もあるので、AWSに閉じていないサービスであってもStep Functions上で完結してしまうケースもあります。

外部APIを使用するためには、「Amazon EventBridge Connection」の設定が必要です。
設定は煩雑ではなかったので、詳細は省略いたします。下記参考記事をご覧ください。

プログラミングのような処理も可能

各種AWSサービスや外部APIとの統合に加え、Step Functionsでは「フロー」という項目でプログラミングのような以下の処理を行うことができます。

  • Choice
    if-then-elseロジックが実装できます。
  • Parallel
    並列処理が実装できます。
  • Map
    繰り返し処理が実装できます。
  • Pass
    入出力のフィルタや書き換えを実装できます。
    ※ 入出力のフィルタやマッピングなどは各処理ブロックでも行えます。
  • Wait
    指定時間フローを停止できます。
  • Success / Fail
    フローの成功・失敗を定義できます。

私がLambdaの方でわざわざ並列処理を加えた理由も、このParallelを紹介したかったからです…
この他にも各フローでエラー発生時の処理を定義することができますので、エラーハンドリングも実装可能です。

初心者にも上級者にも優しい

デザインエディタで直感的に作ることができるメリットは先程ご説明したとおりですが、Step Functionsではフローを指定の言語で記載することもできます。この言語を「Amazon States Languages (ASL)」といいます。
これによりコードベースでの管理も可能ですし、これを利用してCloudFormationでのIaCも実現可能です。

さらに、コードで作成・編集したものとデザインエディタで作成・編集したものは相互に変換可能です。
個々人にスキルや好みに応じてスタイルを自在に選べるのは嬉しいポイントですね。

本記事の最後に本アプリケーションのStep Functionsで記述したコードを載せます。

ランタイム・ライブラリを気にしなくて良い

このポイントが個人的に大きなメリットかなと感じています。
特にPythonなどのプログラミング言語はアップデートも盛んで、当時最新のランタイムで実装していたアプリケーションが、いつの間にか数世代前…なんてことも。
ライブラリについても、標準で入っていないものについては自身で追加が必要など、少し手順が複雑になってしまいます。

Lambdaではレイヤーを導入することにより柔軟性が実現される、という考え方ももちろんできます。

その点Step Functionsではランタイムを気にする必要がなくなるので、その点においては運用上のコストやセキュリティ上のリスク低減に貢献するのではないかと考えています。

Step Functionsは銀の弾丸…?

ここまで読むと「Step Functions最高!!」「Lambdaいらね!!!」と思われた方もいらっしゃるかもしれません。。
残念ながらそんなことはありません。

上記のアプリケーションの例でいいますと、Step Functionsを用いて1時間ごとの気温から平均気温を出すことはできないのです。
組み込み関数の機能により、数学的な処理も行えるようになっているものの、まだ加減算のみしかできません。

ここの部分に関しましては今後のアップデートで対応される可能性も大いにあると思いますが、個人的には

  • AWSサービスとの連携にはStep Functions
  • データの加工や処理にはLambda

と、用途に応じて適切にサービスを組み合わせることが良いと感じました。

おわりに

今回はStep Functionsの魅力についてお伝えしました。
「思ったよりできること多い!」とというのが個人的な感想です。

今回利用させていただいた外部API

会員登録不要・かつ無料で利用できるAPIを使用しました。
このような便利なサービスが無料で使えることはとてもありがたいことだと実感しています。

Step FunctionsのフローのASL

Amazon EventBridge ConnectionのARNについてはマスキングを行っています。

{
  "Comment": "A description of my state machine",
  "StartAt": "並列処理",
  "States": {
    "並列処理": {
      "Type": "Parallel",
      "Branches": [
        {
          "StartAt": "気温の取得",
          "States": {
            "気温の取得": {
              "Type": "Task",
              "Resource": "arn:aws:states:::http:invoke",
              "Parameters": {
                "ApiEndpoint": "https://api.open-meteo.com/v1/forecast",
                "Method": "GET",
                "Authentication": {
                  "ConnectionArn": "arn:aws:events:ap-northeast-1:XXXXXXXXXXXX:connection/Connect_name/abc123-456-7890"
                },
                "QueryParameters": {
                  "latitude.$": "$.latitude",
                  "longitude.$": "$.longitude",
                  "hourly": "temperature_2m",
                  "timezone": "Asia/Tokyo",
                  "forecast_days": "1"
                }
              },
              "Retry": [
                {
                  "ErrorEquals": [
                    "States.ALL"
                  ],
                  "BackoffRate": 2,
                  "IntervalSeconds": 1,
                  "MaxAttempts": 3,
                  "JitterStrategy": "FULL"
                }
              ],
              "Next": "服装の考案"
            },
            "服装の考案": {
              "Type": "Task",
              "Resource": "arn:aws:states:::bedrock:invokeModel",
              "Parameters": {
                "ModelId": "arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-3-haiku-20240307-v1:0",
                "Body": {
                  "anthropic_version": "bedrock-2023-05-31",
                  "max_tokens": 600,
                  "messages": [
                    {
                      "role": "user",
                      "content": [
                        {
                          "type": "text",
                          "text": "あなたは私の友人で、コーディネーターとして働いています。私は気温に応じて着たり脱いだりすることが嫌いです。今日0時から23時まで1時間ごとの気温は別のリストに与えるとおりです。1日通して私が外出するのに適した上下のコーディネートを簡潔に教えて下さい。"
                        },
                        {
                          "type": "text",
                          "text.$": "States.JsonToString($.ResponseBody.hourly.temperature_2m)"
                        }
                      ]
                    }
                  ]
                }
              },
              "End": true,
              "ResultSelector": {
                "outfit.$": "$.Body.content"
              }
            }
          }
        },
        {
          "StartAt": "最寄り駅の取得",
          "States": {
            "最寄り駅の取得": {
              "Type": "Task",
              "Resource": "arn:aws:states:::http:invoke",
              "Parameters": {
                "ApiEndpoint": "https://www.heartrails-express.com/api/json",
                "QueryParameters": {
                  "method": "getStations",
                  "x.$": "$.longitude",
                  "y.$": "$.latitude"
                },
                "Method": "GET",
                "Authentication": {
                  "ConnectionArn": "arn:aws:events:ap-northeast-1:XXXXXXXXXXXX:connection/Connect_name/abc123-456-7890"
                }
              },
              "Retry": [
                {
                  "ErrorEquals": [
                    "States.ALL"
                  ],
                  "BackoffRate": 2,
                  "IntervalSeconds": 1,
                  "MaxAttempts": 3,
                  "JitterStrategy": "FULL"
                }
              ],
              "End": true,
              "ResultSelector": {
                "station.$": "$.ResponseBody.response.station"
              }
            }
          }
        }
      ],
      "End": true
    }
  }
}

入力の場所は…?

今回は「35.656315,139.795486」を与えましたが、ここは…弊社の本社ビルです。
駅近が魅力的ですね!

タイトルとURLをコピーしました