自宅で手軽にグリーンカレーを作りながらAWS CloudFormation StackSetsの自動デプロイの結果通知を受け取る

この記事は[マイナビ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/mynavi) 17日目の記事です。

# 本記事の対象者

- グリーンカレーを作ってみたいけど、ナンプラーを買うほど本格的でなくても良い方
- AWS Organizations + CloudFormation StackSetsで複数AWSアカウントへのリソース展開を管理している方

---

# 2022/12/22追記

### <font color="red">2022年11月、AWSがスタックインスタンスのイベント検知について対応しました。</font>
[Amazon EventBridge で AWS CloudFormation StackSets のイベント通知を使ってイベント駆動型アプリケーションを構築](https://aws.amazon.com/about-aws/whats-new/2022/11/event-driven-applications-cloudformation-stacksets-event-notifications-eventbridge/)

なので1年越しにそっちの方法でやってみたので置いておきます。
[AWS CloudFormation StackSetsの自動デプロイ結果通知を簡単に受け取れるようになったのでやってみた | マイナビエンジニアブログ](https://engineerblog.mynavi.jp/technology/get-cloudformation-stacksets/)

# まえがき

社内では様々なサービスの基盤としてAWSが利用されており、多数存在するAWSアカウントを統制・管理するために**AWS Organizations**の活用を進めています。
私の最近の業務は、そのOrganizationsや関連機能の設計・実装です。

ということで、アドカレの記事を書くにあたりその辺のネタ＋αの候補で周りの社員にSlackでアンケートを取ってみました。
その結果がこちら。

![vote.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184845/43e686ee-6990-8b66-7121-5785310ccb7e.jpeg)


**なんということでしょう。**

仕方が無いので、民意に則って今回は**「手軽なグリーンカレーの作り方」**について書こうと思います。

## 手軽に作るグリーンカレー

グリーンカレーはノーマルな日本カレーと比べて「調理時間が短い」「安い鶏肉でも満足感がある」「野菜を取りやすい」といったメリットがあります。
特に調理時間については、ノーマルな日本カレーが煮込み時間を入れて概ね40分くらいなのに対し、グリーンカレーは半分の20分程度で作れてしまいます。時間が限られる社会人にはありがたい話です。

ただ、折角ならその20分も有効に活用したいところ。
最近弊社では、AWSアカウントを作成してから**CloudFormation StackSetsの自動デプロイ**の完了までに大体20分弱かかっているので、以下の流れで進めていきます。
なんて自然でわかりやすい導入でしょう。

- 下準備
    1. 食材・環境の準備
    1. StackSetsのデプロイ結果通知基盤の作成
- 調理
    1. AWSアカウント新規作成（StackSetsのデプロイ開始）
    1. グリーンカレーの調理開始
    1. グリーンカレー完成
    1. StackSetsのデプロイ結果通知を受け取る
    1. グリーンカレーを食べる


## AWS Organizations + StackSetsによる自動デプロイ

Organizationsには**CloudFormation StackSets**との連携機能があり、組織内または特定のOU下にアカウントが追加された際に、自動でCloudFormationのスタックをアカウントに展開してくれます。
[新機能: AWS CloudFormation StackSets が AWS Organization のマルチアカウントで利用可能に | Amazon Web Services ブログ](https://aws.amazon.com/jp/blogs/news/new-use-aws-cloudformation-stacksets-for-multiple-accounts-in-an-aws-organization/)

### 自動デプロイ（結果を自動で受け取れるとは言ってない）

とても便利な機能で、StackSets様の靴なら喜んで舐めるくらい助かっているのですが。
実はこの自動展開の部分が若干曲者で、[StackSetsの概要](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/what-is-cfnstacksets.html)でさらっとこのように触れられています。

> 管理者アカウントを使用して、AWS CloudFormation テンプレートを定義および管理し、指定のリージョンの選択されたターゲットアカウントにスタックをプロビジョニングする基盤としてテンプレートを使用します。

つまり、StackSetsはあくまでCFnテンプレートを管理して対象のアカウントに配布しているだけに過ぎず、実際にテンプレートからスタックをデプロイするのは対象アカウント内でのイベントとなります（※本記事執筆時点の仕様）。
当たり前といえば当たり前なんですが、これは言い換えれば**StackSetsを管理するアカウント上では【スタックのデプロイ】がイベントとして発生しない**ということです。

なので、スタックのデプロイが何らかの理由で失敗したとしても、StackSets管理アカウントではイベントをトリガーとした検知ができません。
もちろんデプロイ対象のアカウント内ではCloudFormationのスタック作成やその失敗がイベントとして記録されていますが、「作成したばかりのアカウントに自動でリソースを仕込むアクション」の失敗を検知したいのに、その検知をするために対象アカウントに検知用のリソースを作るというのは本末転倒です。

![failed.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184845/c259b082-577f-eee0-fb38-b31a2d0f13d2.jpeg)



一応、受動的なトリガーがなくとも、能動的にStackSetsのデプロイ結果を取得するAPI（DescribeStackSetOperation）はあります。
とはいえ、日次レベルでアカウント追加があるわけでもないので、定期的にLambdaを回すのも無駄が多いです。

### 自動デプロイが失敗するケース

<font color='red'>「そもそも失敗するようなテンプレートが悪いんじゃないの？」</font>という疑問があるかもしれませんが、実際にはテンプレート自体には問題なくてもスタックのデプロイが失敗するケースがあります。

- Organizationsで適用するSCP（Service Control Policy）に変更が入り、既存のテンプレートに記述されたリソースが作成できなくなった
- 新規アカウント作成ではなく既に運用されているアカウントを組織に招待して、既存のリソース名とStackSetsで作るリソース名が重複した
- スタックのデプロイ用で自動作成されるIAMロールが作成完了する前にスタック作成APIが呼ばれ、実行ロールが無いために失敗した
    - レアケースのようですが、弊社ではここ4ヶ月の間に**2度**このバグを踏んでいます（サポートによる調査で判明）。

などなど。
運用でカバーできる部分もありますが、最後のバグに関してはどうしようもないです。

### なければ工夫するしかない

で、「何とか検知する方法ない？」とダメ元でリセラーのサポートに確認したところ、ピンズドな回答ではないですが、

> デプロイのトリガーとなる【組織 or OUへのアカウント追加】はマネージドアカウントのCloudTrailイベントとして検知できるので、その一定時間後にデプロイ結果を取得するAPIを叩けばなんとかできるかもしれません

という回答をいただきました。
その手があったか！

「一定時間後」という点でややスマートさに欠けますが、そこに目を瞑れば確かにある程度の無駄を省いた形で検知が実装できそうですので、ひとまずAWS SAMを使ってStep Functionsで実装してナンとかしてみました。
（カレーだけに）

# 本題

前説が長くなりましたが、今回作るものは以下の通りです。

- グリーンカレー
    - （心が清らかな人には美味しいグリーンカレーの画像が見えます）
- CloudFormation StackSetsの自動デプロイ結果通知基盤
    - ![architect.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184845/54e768c2-c7a7-4302-af7f-4451a83a00f8.jpeg)
    - なお、[StackSetsの管理をマネージドアカウントから別のアカウントに委任している](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/stacksets-orgs-delegated-admin.html)前提です。
    - また、StackSetsのデプロイ先は組織全体ではなくOUを想定しています。組織全体とする場合はEventBridgeで拾うイベントを修正してください。


## 下準備

### 食材・環境の準備

以下を用意してください。

- 鶏肉
    - もも肉の方が美味しくできると思いますが、私はいつもむね肉です。贅沢は敵だ。
- タケノコの水煮
    - 自分で切っても良し、スライス・細切り済みの物でも良し、お好みで。
- お好みの野菜
    - 私はナスとオクラを入れることが多いですが、他にもほうれん草、ピーマン、ズッキーニ、カボチャなど。
- メープロイ グリーンカレーペースト
    - 大きめのスーパーなら売ってると思いますが、無かったら通販か、KALDIのような輸入食品店などあたってみてください。
- ココナッツミルク
    - ユウキ食品の400gが入手しやすく、使いやすいと思います。
- 醤油
    - 本格的に作るならナンプラーですが、サクッと作るなら醤油で充分です。
- コンソメ
    - 固形より粉末タイプがおすすめです。
- 鶏がらスープの素
    - 粉末こぶ茶でも可。
- ごはん or パン
    - お好みで。
- サラダ油
- 水
- 調理器具
    - 包丁、菜箸、大きめの鍋があればOK。
- AWSアカウント3つ
    - AWS Organizations Managementアカウント
    - AWS CloudFormation StackSets管理用アカウント
    - テスト用AWSアカウント
- AWS SAM開発環境
    - AWS CLI
    - AWS SAM CLI

### StackSetsのデプロイ結果通知基盤の作成

AWS SAMで作ったものを、StackSets管理用アカウントにデプロイします。

#### SAMテンプレート

```{yaml:template.yaml}
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  sam-app
#-----------------------------------------------------------------------------------#
Parameters:
#-----------------------------------------------------------------------------------#
  SystemName:
    Type: String
    Description: input System Name.
  Env:
    Type: String
    Description: input Env Name.
    AllowedValues: 
      - dev
      - stage
      - prod
  FirstWaitSeconds:
    Type: Number
    Description: input wait time(Sec) for first check from StackSets Deployment start.
  RetryWaitSeconds:
    Type: Number
    Description: input wait time(Sec) for retry check (because of Deployment not completed).
  TargetOuId:
    Type: String
    Description: "input Deployment Target OU ID"
  OrganizationManagedAccountId:
    Type: String
    Description: "input Organization Managed Account ID"
  SlackWebhookUrl:
    Type: String
    Description: "input Slack Incoming Webhook URL"

#-----------------------------------------------------------------------------------#
Resources:
#-----------------------------------------------------------------------------------#
  #---------------------------------------------------------------------------------#
  # StepFunctions: StateMachine
  #---------------------------------------------------------------------------------#
  StackSetsDeployCheckStateMachine:
    Type: AWS::Serverless::StateMachine
    Properties:
      Name: !Sub ${SystemName}-${Env}-StateMachine
      DefinitionUri: statemachine/stacksets_status_notify.asl.yaml
      DefinitionSubstitutions:
        DeployCheckerFunctionArn: !GetAtt DeployCheckerFunction.Arn
        StatusNotifierFunctionArn: !GetAtt StatusNotifierFunction.Arn
        FirstWaitSeconds: !Ref FirstWaitSeconds
        RetryWaitSeconds: !Ref RetryWaitSeconds
      Events:
        DeployTrigger:
          Type: EventBridgeRule
          Properties:
            Pattern: 
              source: 
                - "aws.organizations"
              detail-type: 
                - "AWS API Call via CloudTrail"
              detail:
                eventSource:
                  - "organizations.amazonaws.com"
                eventName:
                  - "MoveAccount"
                requestParameters:
                  destinationParentId:
                    - !Ref TargetOuId
      Policies:
        - LambdaInvokePolicy:
            FunctionName: !Ref DeployCheckerFunction
        - LambdaInvokePolicy:
            FunctionName: !Ref StatusNotifierFunction

  # ------------------------------------------------------------#
  # EventBridge: Default EventBus Policy
  # ------------------------------------------------------------#
  DefaultEventBusPolicy:
    Type: AWS::Events::EventBusPolicy
    Properties:
      StatementId: allow_masteraccount_to_put_events
      EventBusName: default
      Statement:
        Effect: Allow
        Action: "events:PutEvents"
        Principal: 
          AWS: !Ref OrganizationManagedAccountId
        Resource: !Sub "arn:aws:events:${AWS::Region}:${AWS::AccountId}:event-bus/default"

  #---------------------------------------------------------------------------------#
  # Lambda: Deploy Checker
  #---------------------------------------------------------------------------------#
  DeployCheckerFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/deploy_checker/
      Handler: app.lambda_handler
      Runtime: python3.9
      FunctionName: !Sub ${SystemName}-${Env}-DeployChecker-Func
      Role: !GetAtt DeployCheckerFunctionExecRole.Arn
      Timeout: 120

  DeployCheckerFunctionExecRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${SystemName}-${Env}-DeployChecker-Role
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/AWSCloudFormationReadOnlyAccess
      Policies:
        - PolicyName: DeployCheckerFunctionExecRolePolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Sid: AllowListDelegatedAdministrators
                Action:
                  - organizations:ListDelegatedAdministrators
                Resource: '*'
                Effect: Allow
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Action:
              - sts:AssumeRole
            Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com

  #---------------------------------------------------------------------------------#
  # Lambda: Status Notifier
  #---------------------------------------------------------------------------------#
  StatusNotifierFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/status_notifier/
      Handler: app.lambda_handler
      Runtime: python3.9
      FunctionName: !Sub ${SystemName}-${Env}-StatusNotifier-Func
      Role: !GetAtt StatusNotifierFunctionExecRole.Arn
      Environment:
        Variables:
          SLACK_WEBHOOK_URL: !Ref SlackWebhookUrl
      Timeout: 30

  StatusNotifierFunctionExecRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${SystemName}-${Env}-StatusNotifier-Role
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Action:
              - sts:AssumeRole
            Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
```

#### StateMachine定義

いつの間にかyamlで書けるようになっていたのでyamlで。

```{yaml:stacksets_status_notify.asl.yaml}
StartAt: Init
TimeoutSeconds: 1800
States:
  Init:
    Comment: パラメータセット
    Type: Pass
    Result:
        first-wait: ${FirstWaitSeconds}
        reexec-wait: ${RetryWaitSeconds}
    ResultPath: $.seconds
    Next: FirstWait
  FirstWait:
    Comment: OUへのアカウント移動からStackSetsデプロイ完了見込みまで待機
    Type: Wait
    SecondsPath: $.seconds.first-wait
    Next: ExecDeployCheck
  ExecDeployCheck:
    Comment: StackSetsデプロイ状況チェック実施
    Type: Task
    InputPath: $
    Resource: ${DeployCheckerFunctionArn}
    ResultPath: $.counter
    Next: ChoiceLoopStop
  ChoiceLoopStop:
    Type: Choice
    Choices:
      - Comment: 終了していないStackSetsデプロイオペレーションがあれば待機して再チェック
        Variable: $.counter.RemainNotSucceededCount
        NumericGreaterThan: 0
        Next: ReExecWait
    Default: ExecStatusNotify
  ReExecWait:
    Comment: デプロイ状況再チェック待機
    Type: Wait
    SecondsPath: $.seconds.reexec-wait
    Next: ExecDeployCheck
  ExecStatusNotify:
    Comment: デプロイ結果の通知実行
    Type: Task
    InputPath: $
    Resource: ${StatusNotifierFunctionArn}
    ResultPath: $.notify
    End: True
```

#### Lambda

DeployCheckerFunctionはこんな感じです。
（StatusNotifierFunctionはイベントのデータを加工してSlack通知をするだけなので割愛。）

ざっくり書いただけなので、（エラー処理とかロガーは）ないです。

```{python3:functions/deploy_checker/app.py}
from datetime import datetime
from dateutil.tz import tzutc
import boto3


def get_stack_set_id_lists(client):
    stack_sets_list = []
    paginator = client.get_paginator('list_stack_sets')
    res = paginator.paginate(
        Status='ACTIVE',
        CallAs='DELEGATED_ADMIN'
    )
    for p in res:
        stack_sets_list += [x['StackSetId'] for x in p['Summaries']]
    return stack_sets_list


def get_stack_set_operations_list(
            client, event_timestamp, stack_set_id, account_id
        ):
    paginator = client.get_paginator('list_stack_set_operations')
    operations_list = []
    res = paginator.paginate(
            StackSetName=stack_set_id,
            CallAs='DELEGATED_ADMIN'
    )

    for p in res:
        for operation in p['Summaries']:
            if operation['CreationTimestamp'] < event_timestamp:
                break
            operation_results = get_stack_set_operation_results(
                client, stack_set_id, operation['OperationId']
            )
            operation_info = {
                'StackSetId': stack_set_id, 
                'OperationId': operation['OperationId']
            }
            operations_list += [
                {**x, **operation_info}
                for x in operation_results
                if x['Account'] == account_id
            ]
        else:
            continue
    return operations_list


def get_stack_set_operation_results(client, stack_set_id, operation_id):
    res = client.list_stack_set_operation_results(
        StackSetName=stack_set_id,
        OperationId=operation_id,
        CallAs='DELEGATED_ADMIN'
    )
    return res['Summaries']


def get_stack_set_results(event_timestamp, account_id):
    client = boto3.client('cloudformation', region_name='us-east-1')
    results = []

    all_stack_set_ids = get_stack_set_id_lists(client)
    for stack_set_id in all_stack_set_ids:
        results += get_stack_set_operations_list(
            client, event_timestamp, stack_set_id, account_id
        )
    return results


def lambda_handler(event, context):
    account_id = event['detail']['requestParameters']['accountId']
    event_timestamp = datetime.strptime(
        event['time'], '%Y-%m-%dT%H:%M:%S%z'
    )

    stack_set_results = get_stack_set_results(event_timestamp, account_id)
    runnings = [
        x
        for x in stack_set_results
        if x['Status'] in ['RUNNING', 'PENDING']
    ]
    successes = [
        x
        for x in stack_set_results
        if x['Status'] == 'SUCCEEDED'
    ]
    failures = [
        x
        for x
        in stack_set_results
        if x['Status'] in ['FAILED', 'CANCELLED']
    ]

    return {
        'TotalCount': len(stack_set_results),
        'RemainNotSucceededCount': len(runnings),
        'SuccessCount': len(successes),
        'FailedCount': len(failures),
        'FailedJobList': failures
    }
```

#### マネージドアカウントのEventBridgeルール

イベントパターンは、StackSets管理アカウントのEventBridgeルールと同様。

```{json}
{
  "detail-type": ["AWS API Call via CloudTrail"],
  "source": ["aws.organizations"],
  "detail": {
    "eventSource": ["organizations.amazonaws.com"],
    "requestParameters": {
      "destinationParentId": ["【デプロイ先OUのID】"],
    },
    "eventName": ["MoveAccount"]
  }
}
```

ターゲットは、StackSets管理アカウントのデフォルトイベントバスのARNを指定。
実行ロールで同イベントバスへの`events:PutEvents`を許可しておきましょう。

#### ポイント

- 「StackSetsデプロイのトリガーとなるイベントが起きてから一定時間待機」をStep FunctionsのWaitで行っています。
    - 待機の秒数はパラメータ指定としています。
- DeployCheckerFunction実行時、まだデプロイの終わっていないStackSetsデプロイがある場合にはWait→再実行としています。
    - 再実行までの秒数もパラメータ指定です。
- 何らかの理由でStep Functionsが延々実行されるということを防ぐために、全体のタイムアウトを設定しています。
    - ここはなぜかベタ書きで30分としていますが、パラメータにした方が良いかもしれません。
- デプロイ状況をチェックするLambda関数の実行ロールには、 `organizations:ListDelegatedAdministrators` を許可しています。
    - この権限がないと、自分がStackSetsの管理を委任されたアカウントであることを認識できずエラーになります。
- 委任されたStackSetsの情報を取得するため、APIを叩くときに `CallAs='DELEGATED_ADMIN'` を指定しています。
- 通知結果のノイズを抑えるため、OU移動イベントで拾ったアカウントIDをターゲットとするオペレーションのみ取得しています。
    - オペレーションやその結果を取得するAPIはタイムスタンプの新しい順に結果を返してくるようですので、「OU移動イベント時刻より前」になった時点で結果のページングを終了しています。
- 通知はLambdaでSlackのIncoming Webhookを叩いています。
    - EventBridgeとChatbotでなんとかなるかもしれないと思ったのですが、Step Functions絡みのイベントはChatbotの通知内容が少なすぎたので今回は不採用です。

## 調理

さて、やっと下準備が終わりましたので、調理に入っていきましょう。

### 1. AWSアカウントを新規に作る

Organizationsのマネージドアカウントにログインし、AWSアカウントを作成します。
アカウントを作成したら、StackSetsのデプロイ対象となっているOUに移動させましょう。
今回はテスト用OUとテンプレートを予め用意しておきました。

- 通常利用しているStackSetsテンプレート2件（いずれも単独リージョン）
- 必ず失敗するテンプレート1件（2リージョン）
    - 名前を固定したIAMロールを作成するテンプレート
    - 2リージョンにデプロイすることで、片方がリソース名重複となり失敗する

StackSetsテンプレート3件・4リージョンでデプロイが実施され、うち1リージョンで必ず失敗することになります。

また、StackSetsデプロイ結果通知基盤について、今回は最初の待機時間が長いものと短いものの2パターンをデプロイします。
最初のチェック実行時にデプロイが終わっている場合とそうでない場合の両方の挙動を見るためです。

### 2. グリーンカレーの調理開始

今更ですが、このレシピはかつてネット掲示板で連載されていたAA短編のものがベースになっています。
気になる人は「翠星石がやる夫の為に料理を作るようです」でググりましょう。

1. 鶏肉・タケノコ・野菜を1口サイズに切る。
1. 大きめの鍋にサラダ油を適量垂らし、鶏肉・タケノコ・野菜・粉コンソメを入れて炒める。
1. 鶏肉に火が通ってきたら一度火を止め、ココナッツミルクを入れる。
1. 再度火を点け（中火）、グリーンカレーペーストを入れる。
1. 沸騰する手前くらいで水を入れる。お好みで50～150mlくらい（少ないほど辛口になります）。
1. 醤油を大さじ1.5杯程度入れる。あまり多すぎるとグリーンカレー感が薄れるので注意。
1. 鶏がらスープの素を入れる。
1. あとは中火のまま温める。

### 3. グリーンカレー完成

再度沸騰する手前くらいで火を止めれば完成です。
前述の通り、大体20分くらいで出来上がると思います。

### 4. StackSetsのデプロイ結果通知を受け取る

さて、グリーンカレーができたところで、Step Functionsの実行結果とSlack通知を確認してみます。

#### リトライなしパターン

![test1_history.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184845/15250536-76e2-e842-72ce-cfb9fc6c5a00.jpeg)

![test1_slack.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184845/27ffbb6e-1340-58f9-1c45-8a8fcee9a6aa.jpeg)

念の為、アカウント・スタック・オペレーションのID、不真面目なSlackApp名とアイコンは伏せさせていただいておりますが、いい感じです。


#### リトライありパターン

![test2_history.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184845/6cef06a1-b8b2-006b-7f94-bc7b3c18b3a6.jpeg)

![test2_slack.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184845/53573afd-4a07-1d8a-2381-078cdf84f7e2.jpeg)

やったぜ。

あとはログ回りやStep Function自体のアラートについて足せばとりあえず運用は回りそうです。
デプロイ失敗分は1回くらい自動で再実行させるなど、機能追加の余地はまだまだありそうですが。
ただ、あまり凝ったことをする前にAWS公式が何らかの検知方法を実装してくれる気がしないでもないです。
というか実装してくださいお願いします。そしたら今度こそ靴舐めます。

### 5. グリーンカレーを食べる

無事デプロイ結果を受け取れましたので、美味しくいただきましょう。

# あとがき

まえがきの末尾に

> ひとまずAWS SAMを使ってStep Functionsで実装してナンとかしてみました。
> （カレーだけに）

と書きましたが、ナンはインドカレーにつけるものであり、グリーンカレーを始めとしたタイカレーはごはんで食べるのが基本です。
混同しないように気をつけましょう。