はじめに
こんにちは、そしてこんばんは!
サービスプラットフォーム事業部の大嵩です。
今回は、Amazon EC2 と Amazon RDS の「インスタンスタイプ変更」を AWS Step Functions でワンタッチ実行できるようにして、さらに AWS Systems Manager のメンテナンスウィンドウから時刻指定で自動実行するところまでやってみます!
みなさん、インスタンスタイプの変更ってどうされていますか・・?
EC2 は停止しないとタイプを変えられないので、どうしても深夜や休日の作業になりがちですよね。
RDS も RDS で、「今すぐ適用」なのか「メンテナンスウィンドウで適用」なのかを毎回考えないといけません。
そして EC2 と RDS で手順がまったく違うので、手順書を 2 種類読み直して、深夜に眠い目をこすりながらマネコンをポチポチ。。。
「タイプを間違えた」「起動し忘れた」なんて事故も、人力である限りゼロにはできません。
そこで今回は、Lambda を 1 つも使わず(Lambda レス)、Step Functions の AWS SDK 統合だけで EC2 / RDS のインスタンスタイプ変更を自動化します!
Lambda レス Step Functions シリーズとしては、ALB のローリングアップデート、IAM ユーザー作成に続く第 3 弾です。

なお、本記事に登場するアカウント ID やリソース ID はマスクしています。
本記事のゴール
今回のゴールは以下の 4 点です!
- パラメータを渡すだけで EC2 / RDS のインスタンスタイプを変更する Step Functions を作る(Lambda レス)
- Systems Manager のメンテナンスウィンドウから時刻指定で自動実行する(EC2 / RDS 共通の入口)
- RDS は「メンテナンスウィンドウから即時適用」と「RDS 標準のメンテナンスウィンドウで適用」の 2 方式を試して使い分けを整理する
- 失敗したら EC2 は元のインスタンスタイプに自動で戻す
「深夜のタイプ変更、そろそろ自動化したい・・・」という方、必見です!
今回の構成
構成はこんな感じです。

Systems Manager Maintenance Windows (cron / Asia/Tokyo)
│ Step Functions タスク(states:StartExecution)
▼
Step Functions (Standard)
Choice: resourceType が ec2 か rds か
├─ ec2: 停止 → 停止待ち → タイプ変更 → 起動 → 起動待ち
│ 失敗時: 元のタイプに戻して起動 → 失敗通知
└─ rds: applyMode が immediate か maintenance-window か
├─ immediate: ModifyDBInstance(ApplyImmediately=true) → available 待ち
└─ maintenance-window: ModifyDBInstance(ApplyImmediately=false) → 予約確認 → 予約タグを付けて終了
↓ 実際の適用は RDS のメンテナンスウィンドウで
RDS イベント (RDS-EVENT-0012 / 0014) → EventBridge
→ 通知判定ステートマシン(予約タグがあれば Slack へ、0014 でタグを外す)
通知はすべて SNS → Amazon Q Developer in chat applications → Slack
(成功 / 失敗 / 予約完了 / RDS イベント)
ステートマシンへの入力は、この 3 つだけです。
{
"resourceType": "ec2",
"resourceId": "i-XXXXXXXXXXXXXXXXX",
"targetInstanceType": "t3.small"
}
RDS の場合は applyMode が 1 つ増えます。
{
"resourceType": "rds",
"resourceId": "otake-test-sfn-resize-db",
"targetInstanceType": "db.t3.medium",
"applyMode": "immediate"
}
applyMode を maintenance-window にすると、変更を予約だけして RDS 側のメンテナンスウィンドウで適用させます。
技術方針はこちらです。
| 項目 | 今回の選択 |
|---|---|
| IaC | Terraform(ステートマシン・IAM・メンテナンスウィンドウ・SNS・検証用 EC2 / RDS) |
| ステートマシン | Standard ワークフロー。AWS SDK 統合 + Wait / Choice のポーリングループ。Lambda なし |
| スケジューラ | Systems Manager Maintenance Windows の Step Functions タスク |
| 失敗時 | EC2 は Catch で元のタイプに戻して起動。RDS は失敗通知 |
| 通知 | SNS → Amazon Q Developer in chat applications(旧 AWS Chatbot)→ Slack。成功 / 失敗 / 予約完了と RDS イベント |
| 検証対象 | EC2: t3.micro → t3.small、RDS: db.t4g.micro → db.t3.medium(MySQL 8.4、Single-AZ) |
検証用途なので EC2 はパブリックサブネットに置き、RDS は Single-AZ にしています。
本番では EC2 はプライベートサブネット、RDS は Multi-AZ にして、タイプ変更のダウンタイムをフェイルオーバーの数十秒に抑えるのがベストプラクティスです!
なぜ Step Functions で作るのか
「EC2 のタイプ変更なら、SSM Automation のランブックがあるよね?」と思った方、正解です!
AWS-ResizeInstance というランブックが用意されていて、InstanceId と InstanceType を渡すだけで停止 → 変更 → 起動をやってくれます。
ではなぜ今回 Step Functions で作るのかというと、理由は 3 つあります。
- RDS 版のランブックがない。RDS のインスタンスクラス変更まで含めて、EC2 と同じ入口・同じパラメータ形式で扱いたい
- 失敗したときの戻し方を自分で握りたい。「起動に失敗したら元のタイプで起動し直す」のような分岐を、グラフビューで見える形にしておきたい
- 待ち方を自分で決めたい。EC2 の 2/2 ステータスチェック、RDS の available 復帰など、「どこまで待って成功とみなすか」を明示したい
そして、スケジューラに EventBridge Scheduler ではなく Systems Manager のメンテナンスウィンドウを選んだのは、「所要時間」「カットオフ」「実行履歴」というメンテナンスの枠の概念をそのまま使いたかったからです。
「日曜 3:00 から 2 時間の枠で、残り 1 時間を切ったら新しいタスクは始めない」のような運用ルールを、スケジューラの設定として表現できるのが嬉しいポイントですね!
ステートマシンを作る
それでは、今回の主役のステートマシンを作っていきます!
全体のグラフビューはこちらです。

クエリ言語は JSONata を使っています。
ここからは、設計のポイントを順に紹介します。
入力のチェックと種別の分岐
最初の Choice で resourceType を見て、EC2 と RDS に分岐します。
どちらでもなければ Fail です。
"RouteByResourceType": {
"Type": "Choice",
"Choices": [
{
"Condition": "{% $resourceType = 'ec2' and $resourceId != '' and $targetType != '' %}",
"Next": "Ec2DescribeInstance"
},
{
"Condition": "{% $resourceType = 'rds' and $resourceId != '' and $targetType != '' and ($applyMode = 'immediate' or $applyMode = 'maintenance-window') %}",
"Next": "RdsDescribeInstance"
}
],
"Default": "InvalidInput"
}
EC2:停止 → 停止待ち → タイプ変更 → 起動 → 起動待ち
EC2 のインスタンスタイプは、停止状態でないと変更できません。
なので、流れは必ず「停止 → 停止を待つ → 変更 → 起動 → 起動を待つ」になります。
まず DescribeInstances で現在のタイプを取り出し、変数に入れておきます。
これは後でロールバックに使うためです!
"Ec2DescribeInstance": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
"Arguments": {
"InstanceIds": [
"{% $resourceId %}"
]
},
"Assign": {
"currentType": "{% $states.result.Reservations[0].Instances[0].InstanceType %}",
"instanceState": "{% $states.result.Reservations[0].Instances[0].State.Name %}"
},
"Output": {},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Assign": {
"lastError": "{% $states.errorOutput %}"
},
"Next": "NotifyFailure"
}
],
"Next": "Ec2IsAlreadyTargetType"
}
現在のタイプと変更先が同じなら、何もせず正常終了させます。
スケジュール実行の 2 回目以降に「もう変わっているのに停止してしまった」を防ぐためです。
停止待ちは、おなじみの Wait + Choice のループです。
"Ec2WaitStopped": {
"Type": "Wait",
"Seconds": 15,
"Next": "Ec2DescribeStopState"
},
"Ec2DescribeStopState": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
"Arguments": {
"InstanceIds": [
"{% $resourceId %}"
]
},
"Assign": {
"instanceState": "{% $states.result.Reservations[0].Instances[0].State.Name %}",
"attempt": "{% $attempt + 1 %}"
},
"Output": {},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Assign": {
"lastError": "{% $states.errorOutput %}"
},
"Next": "NotifyFailure"
}
],
"Next": "Ec2IsStopped"
},
"Ec2IsStopped": {
"Type": "Choice",
"Choices": [
{
"Condition": "{% $instanceState = 'stopped' %}",
"Next": "Ec2ModifyInstanceType"
},
{
"Condition": "{% $attempt >= 40 %}",
"Next": "Ec2StopTimeout"
}
],
"Default": "Ec2WaitStopped"
}
stopping はまだ停止していないので、stopped になるまでしっかり待ちます。
ここで先走って ModifyInstanceAttribute を呼ぶとエラーで怒られます(ハマりポイント参照)。
タイプ変更は ModifyInstanceAttribute です。
InstanceType は文字列ではなく { "Value": "..." } の形で渡すのがポイントです!
"Ec2ModifyInstanceType": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:modifyInstanceAttribute",
"Arguments": {
"InstanceId": "{% $resourceId %}",
"InstanceType": {
"Value": "{% $targetType %}"
}
},
"Output": {},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Assign": {
"lastError": "{% $states.errorOutput %}"
},
"Next": "Ec2RollbackInstanceType"
}
],
"Next": "Ec2StartInstance"
}
起動後は DescribeInstanceStatus で、インスタンス状態が running、かつシステムステータスとインスタンスステータスが両方 ok(いわゆる 2/2 チェック)になるまで待ってから成功とみなします。
「起動 API が成功した」と「OS が上がってきた」は別物なので、ここはこだわりたいところです!
EC2:起動に失敗したら元のタイプに戻す
本記事の一番のポイントです!
タイプ変更で一番怖いのは、「変更後のタイプで起動できず、インスタンスが停止したまま朝を迎える」ことですよね。
変更先のタイプにキャパシティがない(InsufficientInstanceCapacity)、AMI のアーキテクチャと合わない(x86 のインスタンスに Graviton 系のタイプを指定した)など、原因はいろいろあります。
そこで ModifyInstanceAttribute と StartInstances の両方に Catch を仕込み、失敗したら元のタイプに戻してから起動し直すようにします。
アーキテクチャ不一致のような「そのタイプにはできない」系は ModifyInstanceAttribute の時点で弾かれ、キャパシティ不足のような「今は起動できない」系は StartInstances で失敗するので、両方をカバーしておく必要があります。
"Ec2StartInstance": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:startInstances",
"Arguments": {
"InstanceIds": [
"{% $resourceId %}"
]
},
"Assign": {
"attempt": 0
},
"Output": {},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Assign": {
"lastError": "{% $states.errorOutput %}"
},
"Next": "Ec2RollbackInstanceType"
}
],
"Next": "Ec2WaitRunning"
},
"Ec2RollbackInstanceType": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:modifyInstanceAttribute",
"Arguments": {
"InstanceId": "{% $resourceId %}",
"InstanceType": {
"Value": "{% $currentType %}"
}
},
"Output": {},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Assign": {
"rollbackError": "{% $states.errorOutput %}"
},
"Next": "NotifyRollbackFailure"
}
],
"Next": "Ec2StartAfterRollback"
},
"Ec2StartAfterRollback": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:startInstances",
"Arguments": {
"InstanceIds": [
"{% $resourceId %}"
]
},
"Output": {},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Assign": {
"rollbackError": "{% $states.errorOutput %}"
},
"Next": "NotifyRollbackFailure"
}
],
"Next": "NotifyRolledBack"
}
さっき変数に取っておいた currentType がここで活躍します。
「失敗しても元のタイプで動いている」状態に倒れるので、深夜に失敗しても朝まで耐えてくれます!
RDS:即時適用モード
RDS のインスタンスクラス変更は、EC2 と違って停止操作は不要です。
ModifyDBInstance に DbInstanceClass と ApplyImmediately: true を渡すと、RDS 側が modifying 状態を経て新しいクラスで available に戻してくれます。
"RdsModifyImmediate": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:rds:modifyDBInstance",
"Arguments": {
"DbInstanceIdentifier": "{% $resourceId %}",
"DbInstanceClass": "{% $targetType %}",
"ApplyImmediately": true
},
"Assign": {
"attempt": 0
},
"Output": {},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Assign": {
"lastError": "{% $states.errorOutput %}"
},
"Next": "NotifyFailure"
}
],
"Next": "RdsWaitModifying"
},
"RdsWaitModifying": {
"Type": "Wait",
"Seconds": 30,
"Next": "RdsDescribeAfterModify"
},
"RdsDescribeAfterModify": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:rds:describeDBInstances",
"Arguments": {
"DbInstanceIdentifier": "{% $resourceId %}"
},
"Assign": {
"dbStatus": "{% $states.result.DbInstances[0].DbInstanceStatus %}",
"dbClass": "{% $states.result.DbInstances[0].DbInstanceClass %}",
"attempt": "{% $attempt + 1 %}"
},
"Output": {},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Assign": {
"lastError": "{% $states.errorOutput %}"
},
"Next": "NotifyFailure"
}
],
"Next": "RdsIsApplied"
},
"RdsIsApplied": {
"Type": "Choice",
"Choices": [
{
"Condition": "{% $dbStatus = 'available' and $dbClass = $targetType %}",
"Next": "NotifySuccess"
},
{
"Condition": "{% $attempt >= 60 %}",
"Next": "RdsModifyTimeout"
}
],
"Default": "RdsWaitModifying"
}
完了待ちは、DescribeDBInstances を Wait + Choice で回して、ステータスが available かつ DbInstanceClass が変更先と一致したら成功です。
ステータスだけでなくクラスの一致も見るのがポイントで、実測ではクラスが変わったあとも configuring-enhanced-monitoring というステータスを 1 分ほど経由してから available になりました(後述)。
なお、事前の DescribeDBInstances でステータスが available でなければ Fail にしています。
バックアップ中やすでに modifying の状態では RDS 側が変更を受け付けないので、先に弾いて分かりやすいエラーにするためです。
RDS:メンテナンスウィンドウ適用モード
applyMode が maintenance-window のときは、ApplyImmediately: false で ModifyDBInstance を呼びます。
すると RDS は変更を保留キューに入れ、次のメンテナンスウィンドウで適用してくれます。
ステートマシン側は、DescribeDBInstances の PendingModifiedValues.DbInstanceClass に変更先が入っていることを確認したら「予約完了」として正常終了します。
"RdsDescribePending": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:rds:describeDBInstances",
"Arguments": {
"DbInstanceIdentifier": "{% $resourceId %}"
},
"Assign": {
"pendingClass": "{% $exists($states.result.DbInstances[0].PendingModifiedValues.DbInstanceClass) ? $states.result.DbInstances[0].PendingModifiedValues.DbInstanceClass : '' %}",
"maintenanceWindow": "{% $states.result.DbInstances[0].PreferredMaintenanceWindow %}"
},
"Output": {},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Assign": {
"lastError": "{% $states.errorOutput %}"
},
"Next": "NotifyFailure"
}
],
"Next": "RdsIsReserved"
},
"RdsIsReserved": {
"Type": "Choice",
"Choices": [
{
"Condition": "{% $pendingClass = $targetType %}",
"Next": "RdsTagReservation"
}
],
"Default": "RdsReserveFailed"
}
「実際に適用されるのはいつ?」「本当に適用された?」は、ステートマシンでは待ちません。
最長で 1 週間先になる可能性があるので、Standard ワークフローで待ち続けるのは筋が悪いですよね。
代わりに、RDS が発行するイベントを EventBridge で拾います。
RDS-EVENT-0012: Applying modification to database instance class.(適用開始)RDS-EVENT-0014: Finished applying modification to DB instance class.(適用完了)
ここで 1 つ問題があります。
この RDS イベントは、誰がクラスを変更しても飛びます。
コンソールから手で変更したときも同じイベントが出るので、EventBridge から SNS に直結すると、手動作業のたびに Slack が鳴ることになります(実際に鳴りました笑)。
そこで、予約が成功したら DB インスタンスにタグを付けて「このステートマシンが予約した」ことを記録しておきます。
"RdsTagReservation": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:rds:addTagsToResource",
"Arguments": {
"ResourceName": "{% $dbArn %}",
"Tags": [
{
"Key": "${reservation_tag_key}",
"Value": "${reservation_tag_value}"
},
{
"Key": "${reservation_class_tag_key}",
"Value": "{% $targetType %}"
},
{
"Key": "${reservation_execution_tag_key}",
"Value": "{% $states.context.Execution.Name %}"
}
]
},
"Output": {},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Assign": {
"tagWarning": "{% 'WARNING: reservation tag could not be added, the completion event will not be notified: ' & $string($states.errorOutput) %}"
},
"Next": "NotifyReserved"
}
],
"Next": "NotifyReserved"
}
イベントを受ける側は、EventBridge のターゲットを SNS ではなくもう 1 つの小さなステートマシンにします。
こちらも Lambda レスです!
InitVariables → ListTags(rds:listTagsForResource)
→ IsReservedByStepFunctions
├─ タグなし: Ignored(手動変更などは通知しない)
└─ タグあり: Notify(Slack)→ 0014 なら RemoveReservationTags(予約タグを外す)
タグの読み取りは JSONata のフィルタで 1 行です(${reservation_tag_key} は templatefile で ResizeReservedBy に置き換わります)。
"reservedBy": "{% $exists($states.result.TagList[Key='${reservation_tag_key}'].Value) ? $states.result.TagList[Key='${reservation_tag_key}'].Value : '' %}"
適用完了(0014)でタグを外すので、その後に手動でクラスを変えても通知は飛びません。
「予約は Step Functions、適用の完了検知は RDS イベント、通知の要否判定もタグを見て Step Functions」という分担です!
通知は Slack に「成功・失敗・予約完了」の 3 種類
通知は SNS トピックに publish して、Amazon Q Developer in chat applications(旧 AWS Chatbot)経由で Slack に流します。
前回の ALB ローリングアップデート記事と同じ構成です。
ここで 1 つ大事なポイントがあります。
Chatbot は「ただのテキスト」を Slack に流してくれないので、カスタム通知フォーマットの JSON を Message に入れる必要があります。
JSONata なら $string() でオブジェクトをそのまま JSON 文字列にできるので、Pass ステートで組み立てなくても 1 ステートで書けます!
"NotifySuccess": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:sns:publish",
"Arguments": {
"TopicArn": "${sns_topic_arn}",
"Message": "{% $string({ 'version': '1.0', 'source': 'custom', 'content': { 'textType': 'client-markdown', 'title': '[SUCCESS] ' & $resourceType & ' ' & $resourceId & ' resized', 'description': '*' & $resourceType & '* `' & $resourceId & '`\n' & $currentType & ' -> *' & $targetType & '*' & '\n\nExecution: `' & $states.context.Execution.Name & '`' } }) %}"
},
"Output": {
"status": "SUCCEEDED",
"resourceType": "{% $resourceType %}",
"resourceId": "{% $resourceId %}",
"previousInstanceType": "{% $currentType %}",
"instanceType": "{% $targetType %}"
},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Next": "SucceededWithoutNotification"
}
],
"End": true
}
textType を client-markdown にすると、Slack の *太字* や `コード` が使えます。
失敗通知の description にはエラー内容をそのまま入れていますが、8000 文字を超えるとサイレントに捨てられるので $substring() で切っています(前回ハマったやつですw)。
末尾の Task に Output を明示しているのは、実行出力が SNS の MessageId で上書きされないようにするためです。
Terraform で構築する
環境一式は Terraform で作ります。
ポイントになる部分だけ抜粋します。
ステートマシンと実行ロール
リソース名はすべて変数 prefix から組み立てていて、tfvars で変えられるようにしています(今回の検証では otake-test-sfn-resize)。
ステートマシン定義(ASL)は JSON ファイルに分離して、templatefile で SNS トピックの ARN・ポーリング間隔・予約タグのキー名を注入します。
ASL の中に出てくる ${sns_topic_arn} や ${reservation_tag_key} はこのテンプレート変数です。
resource "aws_sfn_state_machine" "resize" {
name = local.state_machine_name
role_arn = aws_iam_role.sfn.arn
type = "STANDARD"
definition = templatefile("${path.module}/../statemachine/resize-instance-type.asl.json", {
sns_topic_arn = aws_sns_topic.notify.arn
ec2_poll_seconds = var.ec2_poll_seconds
ec2_max_attempts = var.ec2_max_attempts
rds_poll_seconds = var.rds_poll_seconds
rds_max_attempts = var.rds_max_attempts
reservation_tag_key = local.reservation_tag_key
reservation_tag_value = local.reservation_tag_value
reservation_class_tag_key = local.reservation_class_tag_key
reservation_execution_tag_key = local.reservation_execution_tag_key
})
dynamic "logging_configuration" {
for_each = var.enable_logging ? [1] : []
content {
log_destination = "${aws_cloudwatch_log_group.sfn[0].arn}:*"
include_execution_data = true
level = "ALL"
}
}
depends_on = [aws_iam_role_policy.sfn]
}
ステートマシンの名前は state_machine_name_prefix(既定は空)+ prefix で組み立てていて、今回は otake-test-sfn-resize-instance-type です。
名前は自由に付けられるのですが、ここには 1 つ落とし穴があります。
後述しますが、メンテナンスウィンドウをサービスリンクロールで動かす場合、名前が SSM で始まるステートマシンしか起動できません。
今回はカスタムサービスロールを使うので、この制約を回避しています。
実行ロールに付ける権限の要点はこちらです。
ec2:DescribeInstances/ec2:DescribeInstanceStatus(リソースレベル制限不可なので*)ec2:StopInstances/ec2:StartInstances/ec2:ModifyInstanceAttribute(対象インスタンスの ARN に限定。タグ条件で絞るのもおすすめ)rds:DescribeDBInstancesrds:ModifyDBInstance/rds:AddTagsToResource(db:に限定。タグは予約適用の目印用)-* sns:Publish(通知トピックに限定)
通知判定ステートマシンのロールは rds:ListTagsForResource / rds:RemoveTagsFromResource(同じく db:)と sns:Publish だけです。
ec2:ModifyInstanceAttribute は「タイプを変える」以外にもいろいろできる強い権限なので、Resource は必ず絞りましょう!
Slack 通知(Amazon Q Developer in chat applications)
SNS トピックを Amazon Q Developer in chat applications の Slack チャンネル設定に紐付けます。
API のエンドポイントが東京リージョンにないので、us-east-2 のプロバイダエイリアスで作るのがポイントです。
provider "aws" {
alias = "use2"
region = "us-east-2"
default_tags {
tags = {
Project = var.prefix
Purpose = "blog-verification"
}
}
}
resource "aws_chatbot_slack_channel_configuration" "notify" {
provider = aws.use2
configuration_name = "${var.prefix}-slack"
iam_role_arn = aws_iam_role.chatbot.arn
slack_team_id = var.slack_team_id
slack_channel_id = var.slack_channel_id
sns_topic_arns = [aws_sns_topic.notify.arn]
logging_level = "ERROR"
}
Slack ワークスペースとの連携(OAuth 承認)だけは事前にマネジメントコンソールから行っておきます。
logging_level = "ERROR" を入れておくと、届かなかった通知の理由を us-east-1 の /aws/chatbot/ ロググループで確認できます。
Chatbot まわりのハマりポイントは前回記事で詳しく書いているので、そちらも参考にしてください。

メンテナンスウィンドウと Step Functions タスク
メンテナンスウィンドウは、cron 式・タイムゾーン・所要時間・カットオフを指定して作ります。
resource "aws_ssm_maintenance_window" "resize" {
name = "${var.prefix}-window"
description = "Resize EC2 / RDS instance types via Step Functions"
schedule = var.mw_schedule
schedule_timezone = var.mw_schedule_timezone
duration = var.mw_duration
cutoff = var.mw_cutoff
enabled = var.mw_enabled
allow_unassociated_targets = false
}
タスクは task_type = "STEP_FUNCTIONS" で登録します。
RDS は SSM のマネージドノードではないので、メンテナンスウィンドウのターゲットにはできません。
そのため、ターゲットなし(targetless)のタスクとして、Input に対象を直接書きます。
resource "aws_ssm_maintenance_window_task" "resize_rds" {
window_id = aws_ssm_maintenance_window.resize.id
name = "${var.prefix}-rds"
description = "Resize RDS instance class (targetless task)"
task_type = "STEP_FUNCTIONS"
task_arn = aws_sfn_state_machine.resize.arn
service_role_arn = local.mw_service_role_arn
priority = 1
cutoff_behavior = "CONTINUE_TASK"
task_invocation_parameters {
step_functions_parameters {
input = jsonencode({
resourceType = "rds"
resourceId = aws_db_instance.this.identifier
targetInstanceType = var.mw_rds_target_instance_class
applyMode = var.mw_rds_apply_mode
})
}
}
}
step_functions_parameters には実行名(name)も指定できますが、固定の文字列を入れると 2 回目以降の実行で名前が重複してしまいます。
今回は指定せずにおき、メンテナンスウィンドウ側に UUID の実行名を付けてもらっています(実測では毎回別の UUID になりました)。
EC2 の方は、もう少し面白い書き方ができます。
EC2 は SSM のマネージドノードなので、タグでターゲットを登録し、Input に擬似パラメータ {{RESOURCE_ID}} を書くと、タグの付いたインスタンスの台数分だけ実行が自動で起動されます!
resource "aws_ssm_maintenance_window_target" "ec2" {
window_id = aws_ssm_maintenance_window.resize.id
name = "${var.prefix}-ec2-targets"
description = "EC2 instances tagged ${var.ec2_tag_key}=true"
resource_type = "INSTANCE"
targets {
key = "tag:${var.ec2_tag_key}"
values = ["true"]
}
}
resource "aws_ssm_maintenance_window_task" "resize_ec2" {
window_id = aws_ssm_maintenance_window.resize.id
name = "${var.prefix}-ec2"
description = "Resize tagged EC2 instances one by one"
task_type = "STEP_FUNCTIONS"
task_arn = aws_sfn_state_machine.resize.arn
service_role_arn = local.mw_service_role_arn
priority = 2
max_concurrency = "1"
max_errors = "1"
cutoff_behavior = "CONTINUE_TASK" # カットオフで実行中の SFn を止めると EC2 が停止したまま残るので継続させる
targets {
key = "WindowTargetIds"
values = [aws_ssm_maintenance_window_target.ec2.id]
}
task_invocation_parameters {
step_functions_parameters {
input = jsonencode({
resourceType = "ec2"
resourceId = "{{RESOURCE_ID}}"
targetInstanceType = var.mw_ec2_target_instance_type
})
}
}
}
max_concurrency = "1" にしておけば、複数台でも 1 台ずつ順番にタイプ変更されます。
ALB 配下の Web サーバーを 1 台ずつ、という使い方もできそうですね!
RDS イベントを拾う EventBridge ルール
RDS 標準メンテナンスウィンドウ方式の完了検知用です。
ターゲットは通知判定用のステートマシンで、イベントをそのまま入力として渡します。
resource "aws_cloudwatch_event_rule" "rds_class_change" {
name = "${var.prefix}-rds-class-change"
description = "Notify when RDS applies a DB instance class modification reserved by the resize state machine"
event_pattern = jsonencode({
source = ["aws.rds"]
"detail-type" = ["RDS DB Instance Event"]
detail = {
EventID = ["RDS-EVENT-0012", "RDS-EVENT-0014"]
SourceIdentifier = [aws_db_instance.this.identifier]
}
})
}
resource "aws_cloudwatch_event_target" "rds_class_change_to_notifier" {
rule = aws_cloudwatch_event_rule.rds_class_change.name
target_id = "rds-event-notifier"
arn = aws_sfn_state_machine.rds_event_notifier.arn
role_arn = aws_iam_role.eventbridge_to_notifier.arn
}
実は最初、ターゲットを SNS に直結して input transformer で Chatbot のフォーマットに整形していました。
それで 2 回ハマった話はハマりポイント集で紹介します。。。
通知判定ステートマシンでは、イベントの detail.EventID / detail.Message / detail.SourceIdentifier / detail.SourceArn と time を使っています。
実際に動かしてみる!(ワンタッチ編)
それでは、実際に動かしてみましょう!
まずはメンテナンスウィンドウを使わず、手動で実行して挙動を確かめます。
EC2 を t3.micro → t3.small に
aws stepfunctions start-execution \
--state-machine-arn arn:aws:states:ap-northeast-1:XXXXXXXXXXXX:stateMachine:otake-test-sfn-resize-instance-type \
--input '{"resourceType":"ec2","resourceId":"i-XXXXXXXXXXXXXXXXX","targetInstanceType":"t3.small"}'
結果は・・・SUCCEEDED!所要時間は 3 分 20 秒でした!

実行履歴の DescribeInstances / DescribeInstanceStatus の結果を並べると、EC2 側で何が起きていたかがよく分かります。
| 経過 | イベント |
|---|---|
| 0:00 | DescribeInstances(t3.micro / running を確認)→ StopInstances |
| 0:16 | 停止待ち 1 回目: まだ stopping |
| 0:31 | 停止待ち 2 回目: stopped → ModifyInstanceAttribute(t3.small) |
| 0:33 | StartInstances |
| 0:48〜2:04 | 起動待ち: もう running だが、ステータスチェックは instance / system とも initializing |
| 2:19〜3:05 | system は ok、instance はまだ initializing |
| 3:20 | instance / system とも ok(2/2 チェック)→ [SUCCESS] 通知 |
注目は 0:48 の行です。
StartInstances の 15 秒後にはもう running なので、「起動 API が成功したら完了」にしていたら、OS が上がりきる 2 分半前に成功扱いになっていたことになります。
2/2 チェックまで待つ設計にしておいて正解でした!
なお、同じ流れで 2 台目も実行しており、そちらは 2 分 50 秒でした。
停止に約 30 秒、起動から 2/2 チェックまで 2 分 15 秒〜2 分 45 秒、というのが t3 系の目安のようです。
じゃあ、ほんとにタイプは変わったんでしょうか・・・?
EC2 コンソールで確認してみましょう!

t3.micro → t3.small にしっかり変わっています!
Slack にも [SUCCESS] 通知が届きました。

ちなみに、同じ入力(t3.small)でもう一度実行すると、停止せずに 0 秒で SKIPPED として正常終了します。
{
"status": "SKIPPED",
"resourceType": "ec2",
"resourceId": "i-XXXXXXXXXXXXXXXXX",
"instanceType": "t3.small",
"message": "already the requested instance type"
}
スケジュール実行で毎週同じタスクが走っても、すでに目的のタイプなら停止も通知もしない、という挙動です。
RDS を即時適用で db.t4g.micro → db.t3.medium に
aws stepfunctions start-execution \
--state-machine-arn arn:aws:states:ap-northeast-1:XXXXXXXXXXXX:stateMachine:otake-test-sfn-resize-instance-type \
--input '{"resourceType":"rds","resourceId":"otake-test-sfn-resize-db","targetInstanceType":"db.t3.medium","applyMode":"immediate"}'
結果は SUCCEEDED!所要時間は 5 分 35 秒でした!

こちらも実行履歴の DescribeDBInstances の結果を並べてみます。
| 経過 | DBInstanceStatus | DBInstanceClass | PendingModifiedValues |
|---|---|---|---|
| 0:00 | available | db.t4g.micro | なし(ここで ModifyDBInstance) |
| 0:31 | modifying | db.t4g.micro | DbInstanceClass: db.t3.medium |
| 〜4:03 | modifying | db.t4g.micro | 同上(30 秒おきに 8 回) |
| 4:34 | configuring-enhanced-monitoring | db.t3.medium | なし |
| 5:04 | configuring-enhanced-monitoring | db.t3.medium | なし |
| 5:34 | available | db.t3.medium | なし → [SUCCESS] 通知 |
面白いのは 4:34 の行です。
クラスはもう db.t3.medium になっているのに、ステータスは configuring-enhanced-monitoring。
拡張モニタリングは有効にしていないのですが、クラス変更の後処理で必ずここを通るようです。
「クラスが一致していて、かつ available」の両方を見る判定にしておいたので、ここで早とちりせずに 1 分待ってから成功にできました!
ダウンタイムは RDS のイベントから読み取れます。
15:01:27 [configuration change] Applying modification to database instance class 15:02:58 [availability] DB instance shutdown 15:05:19 [availability] DB instance restarted 15:05:31 [configuration change] Finished applying modification to DB instance class
Single-AZ なので、shutdown から restarted までの約 2 分 20 秒は DB に接続できない時間です。
本番で使うときは Multi-AZ にして、ダウンタイムをフェイルオーバーの数十秒に抑えたいところですね!

RDS をメンテナンスウィンドウ適用で予約する
今度は applyMode を maintenance-window にして実行します。
いったん手動で db.t4g.micro に戻してから(このときに事件が起きるのですが、それはハマりポイント集で。。。)、同じ db.t3.medium への変更を今度は予約で行います。
検証なので、RDS 標準メンテナンスウィンドウは 10 分後に始まる 30 分の枠に変更しておきました。
aws stepfunctions start-execution \
--state-machine-arn arn:aws:states:ap-northeast-1:XXXXXXXXXXXX:stateMachine:otake-test-sfn-resize-instance-type \
--input '{"resourceType":"rds","resourceId":"otake-test-sfn-resize-db","targetInstanceType":"db.t3.medium","applyMode":"maintenance-window"}'
こちらは 2 秒で SUCCEEDED になります。
予約しただけなので当然ですね!
出力には status: RESERVED と、適用予定のメンテナンスウィンドウ(UTC)が入っています。
{
"status": "RESERVED",
"resourceType": "rds",
"resourceId": "otake-test-sfn-resize-db",
"currentInstanceType": "db.t4g.micro",
"pendingInstanceType": "db.t3.medium",
"preferredMaintenanceWindowUtc": "mon:06:50-mon:07:20"
}
Slack にも [RESERVED] が届きました。

RDS コンソールを見ると、「保留中の変更」にインスタンスクラスが表示されています。

DB インスタンスのタグにも予約情報が付いています。

CLI でも PendingModifiedValues に入っていることが確認できます。
aws rds describe-db-instances --db-instance-identifier otake-test-sfn-resize-db \ --query 'DBInstances[0].[DBInstanceClass,DBInstanceStatus,PendingModifiedValues,PreferredMaintenanceWindow]'
❯ aws rds describe-db-instances --db-instance-identifier otake-test-sfn-resize-db --query 'DBInstances[0].[DBInstanceClass,DBInstanceStatus,PendingModifiedValues,PreferredMaintenanceWindow]'
[
"db.t4g.micro",
"available",
{
"DBInstanceClass": "db.t3.medium"
},
"mon:06:50-mon:07:20"
]
そして、メンテナンスウィンドウの時刻になると・・・

来ました!
今度はプレースホルダではなく、予約したクラスと予約した実行の名前が入っています。
時系列を並べるとこうなります。
| 時刻 | 起きたこと |
|---|---|
| 15:41:48 | 予約実行(2 秒で RESERVED) |
| 15:50:00 | RDS のメンテナンスウィンドウ開始 |
| 15:50:01 | RDS-EVENT-0012(適用開始)→ 1 秒後に通知判定ステートマシンが NOTIFIED → 4 秒後に Slack |
| 15:51:47 | DB instance shutdown |
| 15:54:12 | DB instance restarted |
| 15:54:20 | RDS-EVENT-0014(適用完了)→ 1 秒後に NOTIFIED_AND_CLEARED(予約タグ削除)→ 4 秒後に Slack |
今回はウィンドウが開いた 1 秒後に適用が始まりました(「枠内のどこか」なので毎回この速さとは限りません)。
ダウンタイムは shutdown から restarted までの約 2 分 25 秒で、即時適用のときとほぼ同じです。
イベント発生から Slack に届くまでは 4 秒。EventBridge → Step Functions → SNS → Chatbot と 4 段経由していますが、十分速いですね!
通知判定ステートマシンの実行を見ると、0012 で NOTIFIED、0014 で NOTIFIED_AND_CLEARED になっています。


DB インスタンスのタグを確認すると、予約タグ 3 つはきれいに消えていて、クラスは db.t3.medium になっていました!
ついでに、タグがない状態で CLI から手動でクラスを変更してみます。
aws rds modify-db-instance --db-instance-identifier otake-test-sfn-resize-db --db-instance-class db.t3.micro --apply-immediately
RDS-EVENT-0012 は同じように発生しましたが、通知判定ステートマシンは IGNORED で終わり、Slack には何も届きませんでした!
{
"status": "IGNORED",
"reason": "the DB instance has no reservation tag (change was not initiated by the resize state machine)",
"eventId": "RDS-EVENT-0012",
"source": "otake-test-sfn-resize-db"
}

手動作業のたびに Slack が鳴る問題も、これで解決です!
わざと失敗させてみる
自動化で一番大事なのは「失敗したときにどうなるか」ですよね!
EC2 のロールバックを確かめるため、わざと変更できないタイプを指定してみます。
今回の検証インスタンスは x86 の AMI なので、Graviton(arm64)系の t4g.small を指定すればアーキテクチャ不一致で失敗するはずです。
aws stepfunctions start-execution \
--state-machine-arn arn:aws:states:ap-northeast-1:XXXXXXXXXXXX:stateMachine:otake-test-sfn-resize-instance-type \
--input '{"resourceType":"ec2","resourceId":"i-XXXXXXXXXXXXXXXXX","targetInstanceType":"t4g.small"}'
実行結果は・・・想定どおり FAILED!
ですが中身を見ると、欲しかった動きが全部入っています。

| 経過 | イベント |
|---|---|
| 0:00 | DescribeInstances(t3.small / running)→ StopInstances |
| 0:16 | stopped を確認 → ModifyInstanceAttribute(t4g.small)→ 失敗 |
| 0:16 | Catch → Ec2RollbackInstanceType(t3.small に戻す) |
| 0:18 | StartInstances → [FAILED] 通知 → Fail |
失敗したのは StartInstances ではなく ModifyInstanceAttribute の時点でした。
EC2 が「x86_64 のインスタンスに t4g.small は指定できない」と即座に弾いてくれています。
{
"Error": "Ec2.Ec2Exception",
"Cause": "'t4g.small' is not a valid instance type for instance 'i-XXXXXXXXXXXXXXXXX' of architecture 'x86_64'. (Service: Ec2, Status Code: 400, ...)"
}
Slack にも、エラー内容と「t3.small に戻して起動した」ことが届いています。

そして最重要ポイント、実行後の EC2 を確認すると・・・

失敗したのに元のタイプで起動しています!
停止から復帰まで 18 秒、「タイプ変更に失敗して停止したまま放置」が起きない、これがやりたかったことです!
Modify の時点で弾かれるケースはこれで確認できました。
StartInstances で失敗するケース(キャパシティ不足など)は狙って再現するのが難しいのですが、同じ Catch でロールバックに入る作りになっています。
スケジュール実行してみる(Systems Manager Maintenance Windows 編)
手動実行がうまくいったので、いよいよメンテナンスウィンドウから起動させてみます!
検証では数十分後に一度だけ発火するよう、cron 式を書き換えました。
schedule = "cron(45 14 7 9 ? 2026)" # 2026-09-07 14:45 JST に 1 回だけ
メンテナンスウィンドウの画面で、タスクが 2 つ(RDS のターゲットなしタスク、EC2 のタグターゲットタスク)登録されていることを確認します。

時刻になると・・・

14:45:00 の設定に対して 14:45:05 に開始し、ウィンドウの実行は 6 分 28 秒で SUCCESS になりました!
メンテナンスウィンドウの実行履歴を開くと、タスクの中に invocation が 2 つ並んでいます。
タグの付いたインスタンス 2 台に対して、{{RESOURCE_ID}} にそれぞれのインスタンス ID が入った Step Functions の実行が 1 つずつ起動されたということです。
| invocation | 開始 | 終了 |
|---|---|---|
| 1 台目 | 14:45:05 | 14:48:28 |
| 2 台目 | 14:48:28 | 14:51:33 |
1 台目が終わった瞬間に 2 台目が始まっていますね。
max_concurrency = "1" の効果で、1 台ずつ順番に処理されています!
invocation ごとに Step Functions の実行 ARN も記録されているので、失敗したときに追いかけるのも簡単です。


ちなみに、メンテナンスウィンドウから起動された実行の名前は UUID になります。
手動実行と見分けたいときは、実行の入力や MW の履歴側から辿るのがよさそうです。

注意!
実はこの回、RDS のタスクは実行されていません。
terraform apply の最中に発火時刻を迎えたため、RDS タスク(Input が RDS の識別子に依存するので、RDS 作成後に登録される)がまだ登録されていなかったのが原因です。
apply 直後に発火させて検証するときは、describe-maintenance-window-tasks でタスクが揃っているのを確認してから時刻を迎えるようにしましょう!
RDS タスクも含めてもう一度発火させる
タスクが 2 つ揃った状態で、改めて発火させます。
今度は EC2 を t3.small → t3.micro、RDS を db.t3.micro → db.t3.medium(即時適用)に変える設定です。
schedule = "cron(30 16 7 9 ? 2026)" # 2026-09-07 16:30 JST に 1 回だけ
結果はこうなりました。
| タスク | priority | invocation | 開始 | 終了 | 所要 |
|---|---|---|---|---|---|
| RDS(ターゲットなし) | 1 | db.t3.micro → db.t3.medium | 16:30:15 | 16:36:47 | 5 分 35 秒 |
| EC2(タグターゲット) | 2 | 1 台目 t3.small → t3.micro | 16:36:47 | 16:39:30 | 2 分 35 秒 |
| EC2(タグターゲット) | 2 | 2 台目 t3.small → t3.micro | 16:39:30 | 16:42:27 | 2 分 50 秒 |
ポイントは 2 つあります。
1 つ目は priority の効き方です。
RDS タスクを priority 1、EC2 タスクを priority 2 にしていたところ、RDS タスクが終わってから EC2 タスクが始まりました。
メンテナンスウィンドウのタスクは priority の小さい順に実行され、同じ priority のタスクだけが並列になる、という仕様どおりの動きです。
「DB を先に変えてから Web サーバーを変える」のような順序制御が、priority だけで表現できるのは便利ですね!
2 つ目は RDS の即時適用では RDS イベントの通知が出ないことです。
即時適用でも RDS-EVENT-0012 / 0014 は発生しますが、予約タグがないので通知判定ステートマシンは IGNORED になり、Slack にはステートマシン本体の [SUCCESS] だけが届きます。
予約モード用に作ったタグの仕組みが、即時モードの二重通知防止にもそのまま効いています。




発火の遅延は 16:30:00 に対して 16:30:15、ウィンドウ全体の実行は 12 分 11 秒で SUCCESS でした。
これで「毎週日曜 3:00 の枠で、EC2 と RDS のインスタンスタイプを自動で変更する」仕組みが完成です!
SSM メンテナンスウィンドウ vs RDS 標準メンテナンスウィンドウ、どっちを使う?
RDS については 2 方式を試したので、整理しておきます。
| 観点 | SSM メンテナンスウィンドウから即時適用 | RDS 標準メンテナンスウィンドウで適用 |
|---|---|---|
| 適用時刻 | cron 式で分単位に指定できる(タイムゾーン指定可) | 週 1 回・30 分以上の枠のどこか。RDS 任せ |
| EC2 との統一 | 同じ枠・同じ入口で扱える | RDS 専用 |
| 完了の把握 | Step Functions の実行結果で分かる | RDS イベント(0012 / 0014)を EventBridge で拾う |
| 他の保留変更 | 影響しない | 保留キューの変更がまとめて適用される(OS パッチ等) |
| ダウンタイムの制御 | 自分たちが決めた時刻に発生 | 枠内のどこかで発生 |
| 制約 | Standard のみ、ステートマシン名 SSM*(SLR 利用時) |
時刻は UTC 表記(ddd:hh24:mi-ddd:hh24:mi) |
私の結論はこうです。
- 「この時刻に確実に変えたい」「EC2 と一緒に管理したい」なら、SSM メンテナンスウィンドウから即時適用
- 「RDS の他の保留変更(OS パッチなど)と 1 回の停止でまとめて済ませたい」なら、RDS 標準メンテナンスウィンドウで適用
RDS 標準方式は「変更を予約しておくと RDS 様がいい感じのタイミングでやってくれる」のが楽な一方、保留キューに入っている他の変更も一緒に適用される点には注意が必要です!
逆にこれを「メンテナンスを 1 回にまとめられる」と捉えるなら、むしろ利点になりますね。
ハマりポイント集
今回もいくつかハマりポイントがあるので、共有します!
1. サービスリンクロールだと、ステートマシン名が SSM で始まらないと起動できない
メンテナンスウィンドウのタスクにサービスロールを指定しないと、サービスリンクロール AWSServiceRoleForAmazonSSM が使われます。
このロールに付いている AmazonSSMServiceRolePolicy を見ると、states:StartExecution の Resource が arn:aws:states:*:*:stateMachine:SSM* に限定されています。
{
"Effect": "Allow",
"Action": [
"states:DescribeExecution",
"states:StartExecution"
],
"Resource": [
"arn:aws:states:*:*:stateMachine:SSM*",
"arn:aws:states:*:*:execution:SSM*"
]
}
つまり、名前が SSM で始まらないステートマシンは、サービスリンクロールでは起動できません。
Lambda 関数名に SSM プレフィックスが必要なのと同じ理屈ですね。
対策は 2 つです。
- ステートマシン名を
SSM-で始める states:StartExecutionを対象ステートマシンに限定したカスタムサービスロールを作って、タスクのservice_role_arnに指定する(今回はこちら)
公式ドキュメントでもカスタムサービスロールが推奨されているので、本番ではこちらが素直です。
今回は states:StartExecution を対象のステートマシンだけに絞ったカスタムサービスロールを Terraform で作り、ステートマシン名は自由に付けています。
今回はカスタムサービスロールで問題なく起動できることを確認しています。
サービスリンクロールで運用したい場合は、ステートマシン名を SSM で始めておきましょう。
2. Express ワークフローはメンテナンスウィンドウに登録できない
メンテナンスウィンドウの Step Functions タスクは Standard ワークフローのみ対応です。
今回のように数分〜数十分待つ処理なら Standard 一択なので実害はありませんが、「短い処理だから Express で」と作ってしまうと登録時に選べません。
3. ターゲットなしのタスクに MaxConcurrency / MaxErrors を書くとエラー
RDS 用のタスクのようにターゲットを指定しない場合、max_concurrency と max_errors は指定できません。
公式ドキュメントにも「Maintenance window tasks without targets don’t support MaxConcurrency values」というエラーになると明記されています。
Terraform で EC2 用の定義をコピーして作ると、うっかり残してしまいがちなので、ターゲットなしタスクの定義からは両方とも消しておきましょう。
4. SDK 統合の RDS パラメータは DbInstanceIdentifier
RDS の API リファレンスでは DBInstanceIdentifier / DBInstanceClass ですが、Step Functions の SDK 統合では DbInstanceIdentifier / DbInstanceClass と、DB の B が小文字になります。
レスポンスも同様に DbInstances[0].DbInstanceStatus / PendingModifiedValues.DbInstanceClass の形で返ってくるので、JSONata の式を書くときに API リファレンスをそのまま写すとハマります。
今回の検証は DbInstanceIdentifier / DbInstanceClass で通っているので、この表記で確定です。
5. stopping と stopped は別物
StopInstances の直後は stopping で、この状態では ModifyInstanceAttribute は受け付けられません。
Wait は必ず stopped を確認してから抜けるようにしましょう。
実測では、StopInstances の 16 秒後はまだ stopping、31 秒後に stopped になる回と、16 秒後にはもう stopped になっている回がありました。
Wait の間隔を 15 秒にしているのはこのためで、停止待ちは 1〜2 回のループで抜けています。
6. RDS の ApplyImmediately: false は「次のメンテナンスウィンドウ」
メンテナンスウィンドウは週 1 回なので、タイミングによっては適用まで最長 1 週間待つことになります。
さらに、その間に別の変更を「今すぐ適用」で行うと、保留していたインスタンスクラス変更も一緒に適用されます。
「予約したのを忘れて別の変更を即時適用したら、想定外にクラスも変わってダウンタイムが発生した」というのは十分ありえる事故なので、予約したことはチームで共有しておきましょう!
7. RDS のメンテナンスウィンドウは UTC
PreferredMaintenanceWindow は sun:18:00-sun:18:30 のように UTC で指定します。
日曜 3:00 JST に適用したければ sat:18:00-sat:18:30 と、曜日までずれるのがハマりどころです。
今回の検証でも、月曜 15:50 JST の枠を作るために mon:06:50-mon:07:20 と指定しました。
SSM メンテナンスウィンドウの方はタイムゾーンを指定できるので、この点は SSM 側の方が親切ですね!
8. Terraform の jsonencode() で EventBridge の入力テンプレートを書くと < が化ける
これは今回いちばん「なんで?」となったやつです。
RDS イベントの通知が Slack に届いたのはいいのですが、中身がこうなっていました。

や は EventBridge の input transformer のプレースホルダで、本来はイベントの値に置き換わるはずのものです。
デプロイされたテンプレートを aws events list-targets-by-rule で見てみると・・・
"title":"[RDS] \u003csource\u003e \u003ceventId\u003e"
犯人はお前か!!
Terraform の jsonencode() は < > & を \u003c \u003e \u0026 にエスケープします(Go の JSON エンコーダの仕様です)。
JSON としては等価なのですが、EventBridge はテンプレートの文字列をそのまま見てプレースホルダを探すので、\u003csource\u003e はプレースホルダとして認識されず、そのまま Slack に届いてしまったわけです。
対策は、input_template をヒアドキュメントで素の JSON として書くことです。
input_template = <<-EOT
{
"version": "1.0",
"source": "custom",
"content": {
"textType": "client-markdown",
"title": "[RDS] <source> <eventId>",
"description": "<message>\n\nTime: <time>"
}
}
EOT
ちなみに terraform plan では、この修正が jsonencode( # whitespace changes と表示されます。
Terraform は JSON として等価だと判断しているので「空白の変更だけ」と言ってくるのですが、実際には中身が変わる大事な修正です。惑わされないようにしましょう!
9. RDS イベントは手動変更でも飛ぶ
8 を直して一安心・・・と思いきや、検証でクラスを手動で戻したときにも Slack に通知が飛んできました。。。
考えてみれば当然で、RDS-EVENT-0012 / 0014 は「誰が」変更したかを含みません。
EventBridge のイベントパターンでも区別できないので、SNS 直結の構成では手動作業のたびに通知が鳴ってしまいます。
結局、EventBridge のターゲットを SNS から通知判定用のステートマシンに変え、予約時に付けたタグを見て通知するかどうかを判断する構成にしました(設計の章で紹介したものです)。
「イベントには意図が乗らない」ので、意図はリソースのタグに残しておく、というのは他の自動化でも使える考え方だと思います!

まとめ
今回は、EC2 / RDS のインスタンスタイプ変更を Step Functions でワンタッチ化し、Systems Manager のメンテナンスウィンドウから自動実行するところまでやってみました!
やってみてわかったことはこちらです。
- Step Functions の SDK 統合だけで、EC2 の「停止 → 変更 → 起動 → 起動待ち」も RDS の「変更 → available 待ち」も Lambda なしで書ける
- EC2 は変更前のタイプを変数に取っておき、起動失敗時に Catch で戻す。これだけで「停止したまま朝を迎える」事故がなくなる
- メンテナンスウィンドウの Step Functions タスクは Standard のみ、サービスリンクロールで動かすならステートマシン名は
SSM*(カスタムサービスロールなら自由)。ターゲットなしタスクでは MaxConcurrency / MaxErrors を書かない - EC2 はタグターゲット +
{{RESOURCE_ID}}で台数分の実行が自動起動する。RDS はターゲットにできないので Input に直書き - RDS 標準メンテナンスウィンドウ方式は楽だが、保留キューの他の変更も一緒に適用される。時刻を握りたいなら SSM 側、まとめて 1 回で済ませたいなら RDS 側
- EC2 は StartInstances の 15 秒後には running になるが、2/2 チェックまでは 2 分以上かかる。RDS はクラスが変わったあとも configuring-enhanced-monitoring を経由する。「何をもって完了とするか」を明示的に待つ設計が大事
- Terraform の
jsonencode()は<を\u003cにエスケープする。EventBridge の input_template はヒアドキュメントで書く - RDS イベントには「誰が変更したか」が乗らない。自動化の意図はリソースのタグに残し、イベントを受ける側でタグを見て判断する
スケジュールでリソースを賢く使うネタは、過去にも Aurora のリードレプリカで書いていますので、こちらもどうぞ!

ここまで読んでいただき、ありがとうございました!
この記事がどなたかの参考になりますと幸いです!