Step Functions で開発環境の NAT Gateway・EC2・RDS を夜間停止して料金を削減する

Step Functions で開発環境の NAT Gateway・EC2・RDS を夜間停止して料金を削減する

Step Functionsを使ってNAT Gateway・EC2・RDSをまとめて制御し、開発環境を自動で起動・停止する方法を紹介します。特に予算が限られた環境で、夜間や休日の料金を削減したい方必見です。
2026.10.23

開発環境や検証・保守環境って、常時稼働させておく必要がないケースが多いですよね。
夜間や休日は止めておきたいです。

とくに予算が少ない環境だと、NAT Gateway の料金も意外と無視できません。しかも NAT Gateway は EC2 や RDS のように「一時停止」ができないので、止めるなら削除して、使うときに作り直すしかありません。

さらに、起動時にはRDSが完全に起動してからEC2を起動する必要がある… といった順序の制御も必要です。

自分が開発の支援をしていた頃、これらの要望が意外に多いことに気づきました。

そこで今回は、Step Functions を使って、NAT Gateway・EC2・RDS をまとめて制御し、環境そのものを自動で起動・停止する方法をまとめました。

なぜ Step Functions を使うのか

NAT Gateway の作成・削除と、EC2・RDS の起動・停止を自動化するだけなら、スクリプトや Lambda でも実現できます。それでも Step Functions を選んだのは、やりたいことと相性が良かったからです。

1. 順序・並列・待機をワークフローとして書ける

今回の処理には、次のような特徴があります。

  • NAT Gateway が available になってから、ルートを追加する
  • RDS が available になってから、EC2 を起動する
  • 待ち時間が長い NAT の作成と RDS の起動は、並列に進めたい

「作成して、完了を待って、次へ進む」「並列に進めて、全部終わるまで待つ」といった流れは、Step Functions で簡単に実現できます。スクリプトや Lambda で書く場合は、ポーリングや並列実行を自前で実装する必要があります。

2. Lambda なしで AWS の API を直接呼べる

Step Functions の AWS SDK 統合を使うと、Task ステートから EC2 や RDS などの API を、Lambda を挟まずに直接呼び出せます(AWS SDK integrations)。コードの実行環境や、ランタイムのバージョン管理を気にする必要がありません。

3. 実行状況が見える

実行履歴とグラフビューで、どのステートまで進んだか、どこで失敗したかを確認できます。夜間や朝に自動で動く処理は、あとから「何が起きたか」を追えることが大事なので、これは大きなメリットです。

今回の例の制御対象

  • NAT Gateway
  • EC2 インスタンス
  • RDS インスタンス

全体像

Step Functions のステートマシンを 2 つ作り、EventBridge Scheduler から平日の決まった時刻に実行します。

ステートマシン 実行タイミング(例) 役割
env-start 平日 8:00 環境を起動する
env-stop 平日 20:00 環境を停止する

処理の順序は次のとおりです。

起動(env-start)

  1. 次の 2 つを並列で実行する
    • NAT Gateway を作成し、ルートを追加する
    • RDS を起動する
  2. 両方の完了後に EC2 を起動する

停止(env-stop)

  1. EC2 を停止する
  2. 次の 2 つを並列で実行する
    • RDS を停止する
    • NAT Gateway を削除し、ルートを削除して、EIP を解放する

EC2 を最後に起動しているのは、EC2 上のアプリが起動時に外部通信や DB 接続を行う場合に備えて、NAT と RDS の準備が整ってから起動するためです。

制御対象の EC2 と RDS は、タグではなく ID 指定にしました。ステートマシンの実行時に渡す入力 JSON で、インスタンス ID と DB 識別子を指定します。

IAM の準備

Step Functions 用のロール

ステートマシンに付ける実行ロールを用意します。信頼ポリシーでは、states.amazonaws.com に引き受けを許可します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "states.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}

権限ポリシーには、今回使う API の権限を許可します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ec2:AllocateAddress",
        "ec2:ReleaseAddress",
        "ec2:CreateNatGateway",
        "ec2:DeleteNatGateway",
        "ec2:DescribeNatGateways",
        "ec2:DescribeAddresses",
        "ec2:CreateRoute",
        "ec2:DeleteRoute",
        "ec2:CreateTags",
        "ec2:StartInstances",
        "ec2:StopInstances",
        "rds:StartDBInstance",
        "rds:StopDBInstance",
        "rds:DescribeDBInstances"
      ],
      "Resource": "*"
    }
  ]
}

検証用なので Resource は * にしています。本番に近い環境で使う場合は、対象の ARN で絞り込むことをおすすめします。

EventBridge Scheduler 用のロール

スケジュールに付ける実行ロールを用意します。信頼ポリシーでは、scheduler.amazonaws.com に引き受けを許可します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "scheduler.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}

権限ポリシーには、ステートマシンを起動する権限を許可します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "states:StartExecution",
      "Resource": "*"
    }
  ]
}

こちらも検証用なので Resource は * にしています。

起動用ステートマシン(env-start)の作成

ASL とは

ASL(Amazon States Language)は、Step Functions のワークフローを定義する JSON ベースの記述言語です。Workflow Studio で作る図は、ASL の JSON として保存されます。GUI でも JSON でも、同じものを編集していることになります。

作成手順

マネジメントコンソールで、次の手順で作成します。

  1. Step Functions のコンソールを開き、「ステートマシンを作成」を選びます。
  2. 「空白から作成」を選びます。
  3. ステートマシン名は env-start を入力します。
  4. ステートマシンのタイプは 標準 を選びます。
  5. 続行ボタンを押下してデザイン画面に遷移します。
  6. コード画面を表示し、後述の ASL を貼り付けます。貼り付けると、右側のグラフビューにワークフローの図が表示されます。
  7. 設定画面を表示し、アクセス許可 実行ロールで「Step Functions 用のロール」章で作成したロールを指定します。
  8. 「作成」ボタンを押して完了です。

ASL のコード

{
  "Comment": "NATゲートウェイ作成とRDS起動を並列実行し、完了後にEC2を起動する",
  "QueryLanguage": "JSONata",
  "StartAt": "Init",
  "States": {
    "Init": {
      "Type": "Pass",
      "Assign": {
        "subnetId": "{% $states.input.SubnetId %}",
        "routeTableId": "{% $states.input.RouteTableId %}",
        "ec2InstanceIds": "{% $states.input.Ec2InstanceIds %}",
        "dbInstanceIdentifiers": "{% $states.input.DbInstanceIdentifiers %}"
      },
      "Next": "StartNatAndRds"
    },
    "StartNatAndRds": {
      "Type": "Parallel",
      "Branches": [
        {
          "StartAt": "AllocateAddress",
          "States": {
            "AllocateAddress": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:allocateAddress",
              "Arguments": {
                "Domain": "vpc"
              },
              "Assign": {
                "allocationId": "{% $states.result.AllocationId %}"
              },
              "Next": "CreateNatGateway"
            },
            "CreateNatGateway": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:createNatGateway",
              "Arguments": {
                "SubnetId": "{% $subnetId %}",
                "AllocationId": "{% $allocationId %}",
                "ConnectivityType": "public"
              },
              "Assign": {
                "natGatewayId": "{% $states.result.NatGateway.NatGatewayId %}"
              },
              "Next": "WaitForNatAvailable"
            },
            "WaitForNatAvailable": {
              "Type": "Wait",
              "Seconds": 30,
              "Next": "DescribeNatGateway"
            },
            "DescribeNatGateway": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:describeNatGateways",
              "Arguments": {
                "NatGatewayIds": ["{% $natGatewayId %}"]
              },
              "Assign": {
                "natState": "{% $states.result.NatGateways[0].State %}"
              },
              "Next": "IsNatAvailable"
            },
            "IsNatAvailable": {
              "Type": "Choice",
              "Choices": [
                {
                  "Condition": "{% $natState = 'available' %}",
                  "Next": "CreateRoute"
                },
                {
                  "Condition": "{% $natState = 'failed' %}",
                  "Next": "NatCreationFailed"
                }
              ],
              "Default": "WaitForNatAvailable"
            },
            "CreateRoute": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:createRoute",
              "Arguments": {
                "RouteTableId": "{% $routeTableId %}",
                "DestinationCidrBlock": "0.0.0.0/0",
                "NatGatewayId": "{% $natGatewayId %}"
              },
              "End": true
            },
            "NatCreationFailed": {
              "Type": "Fail",
              "Error": "NatGatewayCreationFailed",
              "Cause": "NATゲートウェイの状態が failed になりました"
            }
          }
        },
        {
          "StartAt": "StartRdsInstances",
          "States": {
            "StartRdsInstances": {
              "Type": "Map",
              "Items": "{% $dbInstanceIdentifiers %}",
              "ItemProcessor": {
                "ProcessorConfig": {
                  "Mode": "INLINE"
                },
                "StartAt": "SetDbId",
                "States": {
                  "SetDbId": {
                    "Type": "Pass",
                    "Assign": {
                      "dbId": "{% $states.input %}"
                    },
                    "Next": "StartDbInstance"
                  },
                  "StartDbInstance": {
                    "Type": "Task",
                    "Resource": "arn:aws:states:::aws-sdk:rds:startDBInstance",
                    "Arguments": {
                      "DbInstanceIdentifier": "{% $dbId %}"
                    },
                    "Catch": [
                      {
                        "ErrorEquals": ["Rds.InvalidDbInstanceStateException"],
                        "Next": "WaitForRdsAvailable"
                      }
                    ],
                    "Next": "WaitForRdsAvailable"
                  },
                  "WaitForRdsAvailable": {
                    "Type": "Wait",
                    "Seconds": 60,
                    "Next": "DescribeDbInstance"
                  },
                  "DescribeDbInstance": {
                    "Type": "Task",
                    "Resource": "arn:aws:states:::aws-sdk:rds:describeDBInstances",
                    "Arguments": {
                      "DbInstanceIdentifier": "{% $dbId %}"
                    },
                    "Assign": {
                      "dbStatus": "{% $states.result.DbInstances[0].DbInstanceStatus %}"
                    },
                    "Next": "IsRdsAvailable"
                  },
                  "IsRdsAvailable": {
                    "Type": "Choice",
                    "Choices": [
                      {
                        "Condition": "{% $dbStatus = 'available' %}",
                        "Next": "RdsStarted"
                      }
                    ],
                    "Default": "WaitForRdsAvailable"
                  },
                  "RdsStarted": {
                    "Type": "Pass",
                    "End": true
                  }
                }
              },
              "End": true
            }
          }
        }
      ],
      "Next": "StartEc2Instances"
    },
    "StartEc2Instances": {
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:ec2:startInstances",
      "Arguments": {
        "InstanceIds": "{% $ec2InstanceIds %}"
      },
      "End": true
    }
  }
}

実行時の入力 JSON

ステートマシンは、実行時に次の JSON を受け取ります。

{
  "SubnetId": "subnet-xxxxxxxx",
  "RouteTableId": "rtb-xxxxxxxx",
  "Ec2InstanceIds": ["i-xxxxxxxxxxxxxxxxx"],
  "DbInstanceIdentifiers": ["database-1"]
}
項目 内容
SubnetId NAT Gateway を置くパブリックサブネット
RouteTableId NAT Gateway へのルートを追加するプライベート側のルートテーブル
Ec2InstanceIds 制御する EC2 の ID(複数指定可)
DbInstanceIdentifiers 制御する RDS の DB 識別子(複数指定可)

最初のステート(Init)で、これらを変数に保存し、後続のステートから参照しています。

手動での実行

作成したステートマシンは、ステートマシン画面より「実行を開始」ボタンで、上記の入力 JSON を指定して手動でも実行できます。
実行状態はワークフロー上でグラフィカルに見れます。

停止用ステートマシン(env-stop)の作成

作成手順は、env-start と同じです。ステートマシン名を env-stop にして、次の ASL を貼り付けます。

{
  "Comment": "EC2を停止し、RDS停止とNATゲートウェイ削除を並列実行する",
  "QueryLanguage": "JSONata",
  "StartAt": "Init",
  "States": {
    "Init": {
      "Type": "Pass",
      "Assign": {
        "subnetId": "{% $states.input.SubnetId %}",
        "routeTableId": "{% $states.input.RouteTableId %}",
        "ec2InstanceIds": "{% $states.input.Ec2InstanceIds %}",
        "dbInstanceIdentifiers": "{% $states.input.DbInstanceIdentifiers %}"
      },
      "Next": "StopEc2Instances"
    },
    "StopEc2Instances": {
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:ec2:stopInstances",
      "Arguments": {
        "InstanceIds": "{% $ec2InstanceIds %}"
      },
      "Next": "StopRdsAndDeleteNat"
    },
    "StopRdsAndDeleteNat": {
      "Type": "Parallel",
      "Branches": [
        {
          "StartAt": "StopRdsInstances",
          "States": {
            "StopRdsInstances": {
              "Type": "Map",
              "Items": "{% $dbInstanceIdentifiers %}",
              "ItemProcessor": {
                "ProcessorConfig": {
                  "Mode": "INLINE"
                },
                "StartAt": "SetDbId",
                "States": {
                  "SetDbId": {
                    "Type": "Pass",
                    "Assign": {
                      "dbId": "{% $states.input %}"
                    },
                    "Next": "StopDbInstance"
                  },
                  "StopDbInstance": {
                    "Type": "Task",
                    "Resource": "arn:aws:states:::aws-sdk:rds:stopDBInstance",
                    "Arguments": {
                      "DbInstanceIdentifier": "{% $dbId %}"
                    },
                    "Catch": [
                      {
                        "ErrorEquals": ["Rds.InvalidDbInstanceStateException"],
                        "Next": "WaitForRdsStopped"
                      }
                    ],
                    "Next": "WaitForRdsStopped"
                  },
                  "WaitForRdsStopped": {
                    "Type": "Wait",
                    "Seconds": 60,
                    "Next": "DescribeDbInstance"
                  },
                  "DescribeDbInstance": {
                    "Type": "Task",
                    "Resource": "arn:aws:states:::aws-sdk:rds:describeDBInstances",
                    "Arguments": {
                      "DbInstanceIdentifier": "{% $dbId %}"
                    },
                    "Assign": {
                      "dbStatus": "{% $states.result.DbInstances[0].DbInstanceStatus %}"
                    },
                    "Next": "IsRdsStopped"
                  },
                  "IsRdsStopped": {
                    "Type": "Choice",
                    "Choices": [
                      {
                        "Condition": "{% $dbStatus = 'stopped' %}",
                        "Next": "RdsStopped"
                      }
                    ],
                    "Default": "WaitForRdsStopped"
                  },
                  "RdsStopped": {
                    "Type": "Pass",
                    "End": true
                  }
                }
              },
              "End": true
            }
          }
        },
        {
          "StartAt": "FindNatGateway",
          "States": {
            "FindNatGateway": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:describeNatGateways",
              "Arguments": {
                "Filter": [
                  {
                    "Name": "subnet-id",
                    "Values": ["{% $subnetId %}"]
                  },
                  {
                    "Name": "state",
                    "Values": ["available"]
                  }
                ]
              },
              "Assign": {
                "found": "{% $count($states.result.NatGateways) > 0 %}",
                "natGatewayId": "{% $states.result.NatGateways[0].NatGatewayId %}",
                "allocationId": "{% $states.result.NatGateways[0].NatGatewayAddresses[0].AllocationId %}"
              },
              "Next": "IsNatFound"
            },
            "IsNatFound": {
              "Type": "Choice",
              "Choices": [
                {
                  "Condition": "{% $found %}",
                  "Next": "DeleteRoute"
                }
              ],
              "Default": "NatAlreadyDeleted"
            },
            "NatAlreadyDeleted": {
              "Type": "Pass",
              "End": true
            },
            "DeleteRoute": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:deleteRoute",
              "Arguments": {
                "RouteTableId": "{% $routeTableId %}",
                "DestinationCidrBlock": "0.0.0.0/0"
              },
              "Next": "DeleteNatGateway"
            },
            "DeleteNatGateway": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:deleteNatGateway",
              "Arguments": {
                "NatGatewayId": "{% $natGatewayId %}"
              },
              "Next": "WaitForNatDeleted"
            },
            "WaitForNatDeleted": {
              "Type": "Wait",
              "Seconds": 30,
              "Next": "DescribeNatGateway"
            },
            "DescribeNatGateway": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:describeNatGateways",
              "Arguments": {
                "NatGatewayIds": ["{% $natGatewayId %}"]
              },
              "Assign": {
                "natState": "{% $states.result.NatGateways[0].State %}"
              },
              "Next": "IsNatDeleted"
            },
            "IsNatDeleted": {
              "Type": "Choice",
              "Choices": [
                {
                  "Condition": "{% $natState = 'deleted' %}",
                  "Next": "ReleaseAddress"
                }
              ],
              "Default": "WaitForNatDeleted"
            },
            "ReleaseAddress": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:releaseAddress",
              "Arguments": {
                "AllocationId": "{% $allocationId %}"
              },
              "End": true
            }
          }
        }
      ],
      "End": true
    }
  }
}

EventBridge Scheduler で定期実行する

ステートマシンを定期実行するために、EventBridge Scheduler でスケジュールを 2 つ作成しました。

スケジュール cron 式 実行対象
起動 cron(0 8 ? * MON-FRI *) env-start
停止 cron(0 20 ? * MON-FRI *) env-stop

ここでは、起動用のスケジュール(env-start)を例に、設定手順を説明します。停止用は、スケジュール名・cron 式・ターゲットのステートマシンを変えるだけで、同じ手順で作成できます。

スケジュールの作成手順(起動用)

  1. EventBridge Scheduler のコンソールを開き、「スケジュールを作成」を選びます。

  2. 「スケジュール名」に env-start-schedule を入力します。スケジュールグループは default のままで問題ありません。

  3. 「スケジュールのパターン」で、「定期的なスケジュール」を選びます。

  4. 「スケジュールタイプ」で「cron ベースのスケジュール」を選び、cron 式を入力します。

    分 時 日 月 曜日 年
    0 8 ? * MON-FRI *

    「平日の 8:00」を表す、cron(0 8 ? * MON-FRI *) になります。cron 式は 6 つのフィールドで、「日」と「曜日」のどちらかには ? を指定します。

  5. 「タイムゾーン」で Asia/Tokyo を選びます。指定しないと UTC として解釈されるため、8:00 が日本時間の 17:00 になってしまいます。

  6. 「フレキシブルなタイムウィンドウ」は「オフ」にします。オンにすると、指定した時間枠の中のどこかで実行されるため、時刻を確定させたい今回の用途には合いません。

  7. 「次へ」を押し、ターゲットの選択画面で「AWS Step Functions」の「StartExecution」を選びます。

  8. 「ステートマシン」で、env-start を選びます。

  9. 「入力」に、ステートマシンへ渡す JSON を入力します。実際の ID を指定してください。

    {
      "SubnetId": "subnet-xxxxxxxx",
      "RouteTableId": "rtb-xxxxxxxx",
      "Ec2InstanceIds": ["i-xxxxxxxxxxxxxxxxx"],
      "DbInstanceIdentifiers": ["database-1"]
    }
    

    入力の内容がなくても、JSON 形式で入力する必要があります。停止用のスケジュールにも、同じ JSON を指定します。

  10. 「次へ」を押し、設定画面で「スケジュールの状態」を有効にします。再試行ポリシーとデッドレターキュー(DLQ)は、必要に応じて設定します。

  11. 「アクセス許可」の実行ロールで、前の章で作成した EventBridge Scheduler 用のロールを選びます。コンソールで新しいロールを自動作成することもできます。

  12. 内容を確認して、「スケジュールを作成」を押して完了です。

停止用のスケジュール

env-stop-schedule という名前で、cron 式を cron(0 20 ? * MON-FRI *)、ターゲットのステートマシンを env-stop にして、同様に作成します。

料金

Step Functionの料金

サービス 料金 今回の使用量(目安)
Step Functions(Standard) 状態遷移ごとに課金。$0.025 / 1,000 遷移。月 4,000 遷移まで無料 各ステートマシン1実行あたり40遷移程度。月当たり 1,700 遷移ほど

削減できる料金

今回の構成を東京リージョンのオンデマンド料金で常時稼働した場合と、平日 8:00〜20:00 だけ稼働した場合を比較します。

リソース 常時稼働(月 730 時間) 平日 8〜20 時のみ(月約 264 時間)
NAT Gateway 約 $45.3 約 $16.4
パブリック IPv4(EIP) 約 $3.7 約 $1.3
EC2 t2.micro 約 $11.1 約 $4.0
RDS db.t3.micro(PostgreSQL、シングル AZ) 約 $20.4 約 $7.4
合計 約 $80.5 約 $29.1

今回の構成では、月あたり約 $51(約 64%)の削減になります。

まとめ

Lambda を書かずに、Step Functions と EventBridge Scheduler だけで、NAT Gateway・EC2・RDS をまとめて起動・停止できました。

NAT Gateway の作成・削除と、EC2・RDS の起動・停止を、順序つきで自動化することで、開発・検証・保守環境の夜間や休日の料金を抑えられます。

今回は最小限の構成にしましたが、失敗時の通知、複数 AZ の NAT Gateway への対応、祝日の対応などを加えると、さらに実用的になると思います。同じような悩みがある方の参考になればうれしいです。

この記事をシェアする

関連記事