
Step Functions で開発環境の NAT Gateway・EC2・RDS を夜間停止して料金を削減する
開発環境や検証・保守環境って、常時稼働させておく必要がないケースが多いですよね。
夜間や休日は止めておきたいです。
とくに予算が少ない環境だと、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)
- 次の 2 つを並列で実行する
- NAT Gateway を作成し、ルートを追加する
- RDS を起動する
- 両方の完了後に EC2 を起動する
停止(env-stop)
- EC2 を停止する
- 次の 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 でも、同じものを編集していることになります。
作成手順
マネジメントコンソールで、次の手順で作成します。
- Step Functions のコンソールを開き、「ステートマシンを作成」を選びます。
- 「空白から作成」を選びます。
- ステートマシン名は env-start を入力します。
- ステートマシンのタイプは 標準 を選びます。
- 続行ボタンを押下してデザイン画面に遷移します。
- コード画面を表示し、後述の ASL を貼り付けます。貼り付けると、右側のグラフビューにワークフローの図が表示されます。

- 設定画面を表示し、アクセス許可 実行ロールで「Step Functions 用のロール」章で作成したロールを指定します。
- 「作成」ボタンを押して完了です。
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 式・ターゲットのステートマシンを変えるだけで、同じ手順で作成できます。
スケジュールの作成手順(起動用)
-
EventBridge Scheduler のコンソールを開き、「スケジュールを作成」を選びます。
-
「スケジュール名」に
env-start-scheduleを入力します。スケジュールグループはdefaultのままで問題ありません。 -
「スケジュールのパターン」で、「定期的なスケジュール」を選びます。
-
「スケジュールタイプ」で「cron ベースのスケジュール」を選び、cron 式を入力します。
分 時 日 月 曜日 年 0 8 ? * MON-FRI * 「平日の 8:00」を表す、
cron(0 8 ? * MON-FRI *)になります。cron 式は 6 つのフィールドで、「日」と「曜日」のどちらかには?を指定します。 -
「タイムゾーン」で
Asia/Tokyoを選びます。指定しないと UTC として解釈されるため、8:00 が日本時間の 17:00 になってしまいます。 -
「フレキシブルなタイムウィンドウ」は「オフ」にします。オンにすると、指定した時間枠の中のどこかで実行されるため、時刻を確定させたい今回の用途には合いません。
-
「次へ」を押し、ターゲットの選択画面で「AWS Step Functions」の「StartExecution」を選びます。
-
「ステートマシン」で、
env-startを選びます。 -
「入力」に、ステートマシンへ渡す JSON を入力します。実際の ID を指定してください。
{ "SubnetId": "subnet-xxxxxxxx", "RouteTableId": "rtb-xxxxxxxx", "Ec2InstanceIds": ["i-xxxxxxxxxxxxxxxxx"], "DbInstanceIdentifiers": ["database-1"] }入力の内容がなくても、JSON 形式で入力する必要があります。停止用のスケジュールにも、同じ JSON を指定します。
-
「次へ」を押し、設定画面で「スケジュールの状態」を有効にします。再試行ポリシーとデッドレターキュー(DLQ)は、必要に応じて設定します。
-
「アクセス許可」の実行ロールで、前の章で作成した EventBridge Scheduler 用のロールを選びます。コンソールで新しいロールを自動作成することもできます。
-
内容を確認して、「スケジュールを作成」を押して完了です。
停止用のスケジュール
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 への対応、祝日の対応などを加えると、さらに実用的になると思います。同じような悩みがある方の参考になればうれしいです。








