[小ネタ]今後のECSのスケジュールプランの内容を可視化する

[小ネタ]今後のECSのスケジュールプランの内容を可視化する

2026.01.08

はじめに

こんにちは。クラスメソッドオペレーションズのあのふじたです。

ECSでスケジュールによるスケールアウト・インを設定しているチーム、結構ありますよね。
特に、アクセスが瞬間的に爆増するようなイベントが事前にわかっている場合は、ECSのスケジュール設定で対応することも多いかと思います。

さて、設定した結果の確認ですが、エンジニア同士であればコンソールやCLIで手軽に確認して終わり、ということも多いですよね。
ただ、イベント対応などの場合には、ビジネスサイドの方々にもわかりやすく設定内容や証跡を共有する必要が出てきます。

ここで困るのが、CloudWatchのタスク数メトリクスは過去のデータしか見られないという点です。

非エンジニアの方向けへの資料作成を考えると、一番手軽なのはシンプルに表やグラフが簡単に作れる

  • Excel
  • スプレッドシート

あたりになるかと思います。

ただ、デメリットとしてCLIから出力されるJSON形式のデータを、表やグラフに整形するために手作業が発生してしまいます。

そこで今回は、「JSONをベースに表や視覚的なグラフを出力できるツールがあれば、作業量を大幅に削減できるのでは?」と考えました。

具体的には、以下のステップで進めていきます。

  1. AWS CLIでスケジュール設定のJSONを取得
  2. 出力されたJSONをもとに、Cronの実行時刻を判定し、日本のタイムゾーンへ変換、さらに指定日のスケジュールを抽出
  3. 時系列順に並び替えて表として出力
  4. 時系列でスケーリングメトリクスのグラフを出力

1. AWS CLIでスケジュール設定のJSONを取得

https://docs.aws.amazon.com/ja_jp/autoscaling/application/userguide/describe-scheduled-scaling.html

aws application-autoscaling describe-scheduled-actions \
  --service-namespace ecs \
  --resource-id ${RESOURCE_ID} \
  [--profile ${PROFILE}] >test.json

test.json の中身

イメージは以下のような出力のJSONを想定しています。

  • 毎朝 5:30 に 10/100 にスケールアウト
  • 月-金 6:30 に 20/100 にスケールアウト
  • 土-日 6:30 に 30/100 にスケールアウト
  • 2026/01/25(日) 09:30 に 40/100 にスケールアウト
  • 月-金 18:00 に 10/100 にスケールイン
  • 日曜日 19:00(UTC設定) に 15/100 にスケールイン
  • 毎日 20:00 に 5/100 にスケールイン

UTC Timezoneでの設定や、土日・平日のみの設定が混在しており、そのままだと認知負荷の高いサンプルのJSONです。

{
    "ScheduledActions": [
{
  "ScheduledActionName": "night-time-scale-in",
  "ScheduledActionARN": "arn:aws:autoscaling:ap-northeast-1:123456789012:scheduledAction:12345678-1234-abcd-efgh-1234567890abc:resource/ecs/service/ecs-fargate/resourceid:scheduledActionName/night-time-scale-in",
  "ServiceNamespace": "ecs",
  "Schedule": "cron(0 20 * * ? *)",
  "Timezone": "Asia/Tokyo",
  "ResourceId": "service/ecs-fargate/resourceid",
  "ScalableDimension": "ecs:service:DesiredCount",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 5,
    "MaxCapacity": 100
  },
  "CreationTime": "2025-12-12T16:00:00.000000+09:00"
},
{
  "ScheduledActionName": "2026-01-25-09-scale-out",
  "ScheduledActionARN": "arn:aws:autoscaling:ap-northeast-1:123456789012:scheduledAction:12345678-1234-abcd-efgh-1234567890abc:resource/ecs/service/ecs-fargate/resourceid:scheduledActionName/2026-01-25-09-scale-out",
  "ServiceNamespace": "ecs",
  "Schedule": "at(2026-01-25T09:30:00)",
  "Timezone": "Asia/Tokyo",
  "ResourceId": "service/ecs-fargate/resourceid",
  "ScalableDimension": "ecs:service:DesiredCount",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 40,
    "MaxCapacity": 100
  },
  "CreationTime": "2025-12-12T16:00:00.000000+09:00"
},
{
  "ScheduledActionName": "everydays-morning-scale-out",
  "ScheduledActionARN": "arn:aws:autoscaling:ap-northeast-1:123456789012:scheduledAction:12345678-1234-abcd-efgh-1234567890abc:resource/ecs/service/ecs-fargate/resourceid:scheduledActionName/weekdays-day-morning-scale-out",
  "ServiceNamespace": "ecs",
  "Schedule": "cron(30 5 * * ? *)",
  "Timezone": "Asia/Tokyo",
  "ResourceId": "service/ecs-fargate/resourceid",
  "ScalableDimension": "ecs:service:DesiredCount",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 10,
    "MaxCapacity": 100
  },
  "CreationTime": "2025-12-12T16:00:00.000000+09:00"
},
{
  "ScheduledActionName": "weekdays-day-morning-scale-out",
  "ScheduledActionARN": "arn:aws:autoscaling:ap-northeast-1:123456789012:scheduledAction:12345678-1234-abcd-efgh-1234567890abc:resource/ecs/service/ecs-fargate/resourceid:scheduledActionName/weekdays-day-morning-scale-out",
  "ServiceNamespace": "ecs",
  "Schedule": "cron(30 6 ? * MON-FRI *)",
  "Timezone": "Asia/Tokyo",
  "ResourceId": "service/ecs-fargate/resourceid",
  "ScalableDimension": "ecs:service:DesiredCount",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 20,
    "MaxCapacity": 100
  },
  "CreationTime": "2025-12-12T16:00:00.000000+09:00"
},
{
  "ScheduledActionName": "evening-time-scale-in",
  "ScheduledActionARN": "arn:aws:autoscaling:ap-northeast-1:123456789012:scheduledAction:12345678-1234-abcd-efgh-1234567890abc:resource/ecs/service/ecs-fargate/resourceid:scheduledActionName/evening-time-scale-in",
  "ServiceNamespace": "ecs",
  "Schedule": "cron(0 18 ? * MON-FRI *)",
  "Timezone": "Asia/Tokyo",
  "ResourceId": "service/ecs-fargate/resourceid",
  "ScalableDimension": "ecs:service:DesiredCount",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 10,
    "MaxCapacity": 100
  },
  "CreationTime": "2025-12-12T16:00:00.000000+09:00"
},
{
  "ScheduledActionName": "weekend-day-time-scale-out",
  "ScheduledActionARN": "arn:aws:autoscaling:ap-northeast-1:123456789012:scheduledAction:12345678-1234-abcd-efgh-1234567890abc:resource/ecs/service/ecs-fargate/resourceid:scheduledActionName/weekend-day-time-scale-outt",
  "ServiceNamespace": "ecs",
  "Schedule": "cron(30 6 ? * SAT-SUN *)",
  "Timezone": "Asia/Tokyo",
  "ResourceId": "service/ecs-fargate/resourceid",
  "ScalableDimension": "ecs:service:DesiredCount",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 30,
    "MaxCapacity": 100
  },
  "CreationTime": "2025-12-12T16:00:00.000000+09:00"
},
{
  "ScheduledActionName": "weekend-day-evening-utctime-scale-out",
  "ScheduledActionARN": "arn:aws:autoscaling:ap-northeast-1:123456789012:scheduledAction:12345678-1234-abcd-efgh-1234567890abc:resource/ecs/service/ecs-fargate/resourceid:scheduledActionName/weekend-day-time-scale-outt",
  "ServiceNamespace": "ecs",
  "Schedule": "cron(0 10 ? * SUN *)",
  "Timezone": "",
  "ResourceId": "service/ecs-fargate/resourceid",
  "ScalableDimension": "ecs:service:DesiredCount",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 15,
    "MaxCapacity": 100
  },
  "CreationTime": "2025-12-12T16:00:00.000000+09:00"
}
    ]
}

2. 出力されたJSONをもとに、Cronの実行時刻を判定し、日本のタイムゾーンへ変換、さらに指定日のスケジュールを抽出

jq に慣れているので AIに支援してもらいつつ以下のようになりました。

cat test.json | jq -r --arg target_date "2026-01-25" '
def day_of_week(date_str):
  date_str | strptime("%Y-%m-%d") | strftime("%a") | ascii_upcase;

def next_day(date_str):
  date_str | strptime("%Y-%m-%d") | mktime + 86400 | strftime("%Y-%m-%d");

def parse_cron_days(cron_expr):
  cron_expr | split(" ")[4] |
  if . == "MON-FRI" then ["MON", "TUE", "WED", "THU", "FRI"]
  elif . == "SAT-SUN" or . == "SAT,SUN" then ["SAT", "SUN"]
  elif . == "*" or . == "?" then ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
  else [.]
  end;

def convert_utc_to_jst(schedule_expr):
  if (schedule_expr | contains("cron(")) then
    (schedule_expr | gsub("cron\\(|\\)"; "") | split(" ")) as $parts |
    ($parts[0] | tonumber) as $minute |
    ($parts[1] | tonumber) as $hour |

    # UTC -> JST (+9時間)
    (($hour + 9) % 24) as $jst_hour |
    (if ($hour + 9) >= 24 then 1 else 0 end) as $day_offset |

    {
      schedule: "cron(\($minute) \($jst_hour) \($parts[2]) \($parts[3]) \($parts[4]) \($parts[5]))",
      day_offset: $day_offset
    }
  elif (schedule_expr | contains("at(")) then
    # at(2026-01-25T09:30:00) 形式を処理
    (schedule_expr | gsub("at\\(|\\)"; "")) as $datetime |
    ($datetime | split("T")) as $parts |
    ($parts[0]) as $date |
    ($parts[1] | split(":")) as $time_parts |
    ($time_parts[0] | tonumber) as $hour |
    ($time_parts[1]) as $minute |
    ($time_parts[2]) as $second |

    # UTC -> JST (+9時間)
    (($hour + 9) % 24) as $jst_hour |
    (if ($hour + 9) >= 24 then 1 else 0 end) as $day_offset |

    # 日付を調整
    (if $day_offset == 1 then
      ($date | strptime("%Y-%m-%d") | mktime + 86400 | strftime("%Y-%m-%d"))
    else
      $date
    end) as $jst_date |

    {
      schedule: "at(\($jst_date)T\($jst_hour | tostring | if length == 1 then "0" + . else . end):\($minute):\($second))",
      day_offset: $day_offset
    }
  else
    {schedule: schedule_expr, day_offset: 0}
  end;

def format_schedule_display(schedule; timezone):
  if (timezone == null or timezone == "" or timezone == "Etc/UTC") then
    (schedule | convert_utc_to_jst(.)) as $jst_data |
    "\($jst_data.schedule) [JST換算]"
  else
    schedule
  end;

def check_skip_conditions(schedule; start_time; target_date; timezone):
  # Timezone未設定の場合はJST換算したスケジュールで判定
  (if (timezone == null or timezone == "" or timezone == "Etc/UTC")
   then (schedule | convert_utc_to_jst(.))
   else {schedule: schedule, day_offset: 0}
   end) as $effective_schedule |

  # day_offsetを考慮した実際の対象日を計算
  (if $effective_schedule.day_offset == 1
   then (target_date | next_day(.))
   else target_date
   end) as $actual_target_date |

  (start_time and (start_time | split("T")[0] > $actual_target_date)) as $start_after |
  (if ($effective_schedule.schedule | contains("cron")) then
    ($effective_schedule.schedule | parse_cron_days(.)) as $allowed_days |
    ($actual_target_date | day_of_week(.)) as $target_day |
    ($allowed_days | contains([$target_day]) | not)
  else false end) as $day_not_allowed |

  if $start_after then "スキップ - StartTimeが指定日翌日以降 (" + start_time + ")"
  elif $day_not_allowed then "スキップ - 曜日が範囲外 (対象: " + ($actual_target_date | day_of_week(.)) + ")"
  else "実行対象"
  end;

# 翌日の日付を計算
($target_date | next_day(.)) as $next_date |

.ScheduledActions[] |
# 指定日のat()スケジュール、または翌日0時台のat()スケジュール、またはcronスケジュールを選択
select(
  (.Schedule | contains($target_date)) or
  (.Schedule | contains("cron")) or
  (
    (.Schedule | contains($next_date)) and
    (.Schedule | test("T0[01]:"))  # 翌日の00:xx または 01:xx のみ
  )
) |
{
  ScheduledActionName,
  Schedule: format_schedule_display(.Schedule; .Timezone),
  OriginalSchedule: (if (.Timezone == null or .Timezone == "" or .Timezone == "Etc/UTC") then .Schedule else null end),
  Timezone: (if .Timezone == "Asia/Tokyo" then "Asia/Tokyo"
             elif (.Timezone != null and .Timezone != "" and .Timezone != "Etc/UTC") then .Timezone + " → 要変更"
             else "未設定 (UTC) → JST換算済み" end),
  StartTime,
  ScalableTargetAction,
  Status: check_skip_conditions(.Schedule; .StartTime; $target_date; .Timezone),
  Note: (if (.Timezone == null or .Timezone == "" or .Timezone == "Etc/UTC")
         then "実際のスケジュールはUTC、表示はJST換算値"
         else null end)
}'

このスクリプトを実行すると、2026年1月25日(日曜)に実行されるスケジュールアクションが一覧表示されます。

jq で以下の対応を含んでいます。

  • タイムゾーン対応: UTC/JST の変換を自動処理
  • 日付ずれ対応: UTC→JST 変換で日付が変わる場合も正確に判定
  • 曜日判定: day_offset を考慮した正確な曜日チェック
  • 視認性: 元の UTC 値と JST 換算値の両方を表示

以下のような出力になります

{
  "ScheduledActionName": "night-time-scale-in",
  "Schedule": "cron(0 20 * * ? *)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 5,
    "MaxCapacity": 100
  },
  "Status": "実行対象",
  "Note": null
}
{
  "ScheduledActionName": "2026-01-25-09-scale-out",
  "Schedule": "at(2026-01-25T09:30:00)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 40,
    "MaxCapacity": 100
  },
  "Status": "実行対象",
  "Note": null
}
{
  "ScheduledActionName": "everydays-morning-scale-out",
  "Schedule": "cron(30 5 * * ? *)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 10,
    "MaxCapacity": 100
  },
  "Status": "実行対象",
  "Note": null
}
{
  "ScheduledActionName": "weekdays-day-morning-scale-out",
  "Schedule": "cron(30 6 ? * MON-FRI *)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 20,
    "MaxCapacity": 100
  },
  "Status": "スキップ - 曜日が範囲外 (対象: SUN)",
  "Note": null
}
{
  "ScheduledActionName": "evening-time-scale-in",
  "Schedule": "cron(0 18 ? * MON-FRI *)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 10,
    "MaxCapacity": 100
  },
  "Status": "スキップ - 曜日が範囲外 (対象: SUN)",
  "Note": null
}
{
  "ScheduledActionName": "weekend-day-time-scale-out",
  "Schedule": "cron(30 6 ? * SAT-SUN *)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 30,
    "MaxCapacity": 100
  },
  "Status": "実行対象",
  "Note": null
}
{
  "ScheduledActionName": "weekend-day-evening-utctime-scale-out",
  "Schedule": "cron(0 19 ? * SUN *) [JST換算]",
  "OriginalSchedule": "cron(0 10 ? * SUN *)",
  "Timezone": "未設定 (UTC) → JST換算済み",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 15,
    "MaxCapacity": 100
  },
  "Status": "実行対象",
  "Note": "実際のスケジュールはUTC、表示はJST換算値"
}
  • UTCで設定されている、もしくはTimezoneが空欄の時刻はJSTに変換し、Noteを追記
  • Cronでの曜日指定判定により、当日スキップされるかを判断
  • StartTimeが指定日以降の場合はスキップと判断

が追記されたJSONが出力されます。

3. 時系列順に並び替えて表として出力

  1. で出力されたJSON(temp-ecs.json)をduckdb cli で markdown の表の出力に変更します。
duckdb -markdown -c '
SELECT
ScheduledActionName,
REPLACE(Schedule, '\''*'\'', '\''\*'\'') as Schedule,
StartTime,
CONCAT('\''{min:'\'',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), '\''MinCapacity.*?(\d+)'\'', 1),'\''/max:'\'',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), '\''MaxCapacity.*?(\d+)'\'', 1),'\''}'\'') as ScalableTargetAction,
Status,
CASE
WHEN Status = '\''実行対象'\'' THEN
CONCAT('\''{min:'\'',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), '\''MinCapacity.*?(\d+)'\'', 1),'\''/max:'\'',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), '\''MaxCapacity.*?(\d+)'\'', 1),'\''}'\'')
ELSE '\''スキップ'\''
END as Simulation
FROM read_json('\''temp-ecs.json'\'')
ORDER BY
CASE
WHEN Schedule LIKE '\''at(%2026-01-26%'\'' THEN 1440
WHEN Schedule LIKE '\''cron%'\'' THEN
CAST(regexp_extract(Schedule, '\''cron\(\d+ (\d+)'\'', 1) AS INTEGER) * 60 +
CAST(regexp_extract(Schedule, '\''cron\((\d+)'\'', 1) AS INTEGER)
WHEN Schedule LIKE '\''at%'\'' THEN
CAST(regexp_extract(Schedule, '\''T(\d+):(\d+)'\'', 1) AS INTEGER) * 60 +
CAST(regexp_extract(Schedule, '\''T\d+:(\d+)'\'', 1) AS INTEGER)
END'

以下のような markdown の表出力となります。

|          ScheduledActionName          |             Schedule             |         StartTime         | ScalableTargetAction |               Status                |    Simulation    |
|---------------------------------------|----------------------------------|---------------------------|----------------------|-------------------------------------|------------------|
| everydays-morning-scale-out           | cron(30 5 \* \* ? \*)            | 2025-12-23T20:00:00+09:00 | {min:10/max:100}     | 実行対象                            | {min:10/max:100} |
| weekdays-day-morning-scale-out        | cron(30 6 ? \* MON-FRI \*)       | 2025-12-23T20:00:00+09:00 | {min:20/max:100}     | スキップ - 曜日が範囲外 (対象: SUN) | スキップ         |
| weekend-day-time-scale-out            | cron(30 6 ? \* SAT-SUN \*)       | 2025-12-23T20:00:00+09:00 | {min:30/max:100}     | 実行対象                            | {min:30/max:100} |
| 2026-01-25-09-scale-out               | at(2026-01-25T09:30:00)          | 2025-12-23T20:00:00+09:00 | {min:40/max:100}     | 実行対象                            | {min:40/max:100} |
| evening-time-scale-in                 | cron(0 18 ? \* MON-FRI \*)       | 2025-12-23T20:00:00+09:00 | {min:10/max:100}     | スキップ - 曜日が範囲外 (対象: SUN) | スキップ         |
| weekend-day-evening-utctime-scale-out | cron(0 19 ? \* SUN \*) [JST換算] | 2025-12-23T20:00:00+09:00 | {min:15/max:100}     | 実行対象                            | {min:15/max:100} |
| night-time-scale-in                   | cron(0 20 \* \* ? \*)            | 2025-12-23T20:00:00+09:00 | {min:5/max:100}      | 実行対象                            | {min:5/max:100}  |

Backlogに貼ることを想定しているため特定の文字のエスケープ処理も含んでいます。

4. 時系列でスケーリングメトリクスのグラフを出力

  1. で出力されたJSON(temp-ecs.json)duckdb CLI + Youplot を利用してグラフ化します

https://duckdb.org/docs/stable/guides/data_viewers/youplot

duckdb -csv -noheader -c '
WITH ordered_data AS (
SELECT
CASE
WHEN Status = '\''実行対象'\'' THEN
CONCAT(
ScheduledActionName,'\'' '\'',
CASE
WHEN Schedule LIKE '\''at(%2026-01-26%'\'' THEN '\''2026-01-26 '\''
ELSE '\'''\''
END,
LPAD(CAST(CASE
WHEN Schedule LIKE '\''cron%'\'' THEN regexp_extract(Schedule, '\''cron\(\d+ (\d+)'\'', 1)
WHEN Schedule LIKE '\''at%'\'' THEN regexp_extract(Schedule, '\''T(\d+):'\'', 1)
END AS VARCHAR), 2, '\''0'\''),'\'':'\'',
LPAD(CAST(CASE
WHEN Schedule LIKE '\''cron%'\'' THEN regexp_extract(Schedule, '\''cron\((\d+)'\'', 1)
WHEN Schedule LIKE '\''at%'\'' THEN regexp_extract(Schedule, '\''T\d+:(\d+)'\'', 1)
END AS VARCHAR), 2, '\''0'\''),'\'' '\'',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), '\''MinCapacity.*?(\d+)'\'', 1),'\''/'\'',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), '\''MaxCapacity.*?(\d+)'\'', 1)
)
ELSE
CONCAT(
ScheduledActionName,'\'' '\'',
CASE
WHEN Schedule LIKE '\''at(%2026-01-26%'\'' THEN '\''2026-01-26 '\''
ELSE '\'''\''
END,
LPAD(CAST(CASE
WHEN Schedule LIKE '\''cron%'\'' THEN regexp_extract(Schedule, '\''cron\(\d+ (\d+)'\'', 1)
WHEN Schedule LIKE '\''at%'\'' THEN regexp_extract(Schedule, '\''T(\d+):'\'', 1)
END AS VARCHAR), 2, '\''0'\''),'\'':'\'',
LPAD(CAST(CASE
WHEN Schedule LIKE '\''cron%'\'' THEN regexp_extract(Schedule, '\''cron\((\d+)'\'', 1)
WHEN Schedule LIKE '\''at%'\'' THEN regexp_extract(Schedule, '\''T\d+:(\d+)'\'', 1)
END AS VARCHAR), 2, '\''0'\''),'\'' SKIP'\''
)
END as time_label,
CASE
WHEN Status = '\''実行対象'\'' THEN
CAST(regexp_extract(CAST(ScalableTargetAction AS VARCHAR), '\''MinCapacity.*?(\d+)'\'', 1) AS INTEGER)
ELSE NULL
END as min_capacity,
CASE
WHEN Schedule LIKE '\''at(%2026-01-26%'\'' THEN 1440  -- 翌日分は24:00 (1440分) として扱う
WHEN Schedule LIKE '\''cron%'\'' THEN
CAST(regexp_extract(Schedule, '\''cron\(\d+ (\d+)'\'', 1) AS INTEGER) * 60 +
CAST(regexp_extract(Schedule, '\''cron\((\d+)'\'', 1) AS INTEGER)
WHEN Schedule LIKE '\''at%'\'' THEN
CAST(regexp_extract(Schedule, '\''T(\d+):(\d+)'\'', 1) AS INTEGER) * 60 +
CAST(regexp_extract(Schedule, '\''T\d+:(\d+)'\'', 1) AS INTEGER)
END as sort_time
FROM read_json('\''temp-ecs.json'\'')
),
with_lag AS (
SELECT
time_label,
COALESCE(
min_capacity,
LAST_VALUE(min_capacity IGNORE NULLS) OVER (
ORDER BY sort_time
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
0
) as final_capacity
FROM ordered_data
ORDER BY sort_time
)
SELECT time_label, final_capacity FROM with_lag' |
uplot bar -d, -t '2026-01-25 service/ecs-fargate/resourceid AutoScale Status'

画像ではありませんが...スケジュールによるスケールアウト設定が時系列順にグラフ化できているのがわかります

                                                      2026-01-25 service/ecs-fargate/resourceid AutoScale Status
                                                      ┌                                        ┐
             everydays-morning-scale-out 05:30 10/100 ┤■■■■■■■■■ 10.0
            weekdays-day-morning-scale-out 06:30 SKIP ┤■■■■■■■■■ 10.0
              weekend-day-time-scale-out 06:30 30/100 ┤■■■■■■■■■■■■■■■■■■■■■■■■■■ 30.0
                 2026-01-25-09-scale-out 09:30 40/100 ┤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 40.0
                     evening-time-scale-in 18:00 SKIP ┤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 40.0
   weekend-day-evening-utctime-scale-out 19:00 15/100 ┤■■■■■■■■■■■■■ 15.0
                      night-time-scale-in 20:00 5/100 ┤■■■■ 5.0

1~4. をスクリプトとしてまとめます。

#!/bin/bash
#
# ECS Scheduled Scaling Actions の可視化スクリプト
# 
# 必要なツール:
#   - jq (1.6以上推奨)
#   - aws-cli (v2推奨)
#   - duckdb
#   - youplot (uplot)
#

set -eou pipefail

TARGET_DATE="${1:-}"
RESOURCE_ID="${2:-}"
PROFILE="${3:-}"  # 3つ目の引数(オプショナル)
TEMP_FILE=temp-ecs.json

# Usage
function usage() {
        cat <<EOF
Usage:
  $0 yyyy-mm-dd resource-id [profile]

Description:
   yyyy-mm-dd: Target date
   resource-id: ECS resource ID
   profile: (Optional) AWS CLI profile name
EOF
        exit 1
}

# 引数チェック
if [ $# -lt 2 ]; then
        echo "Error: Required arguments are missing."
        usage
fi

# 必須コマンドのインストールチェック
REQUIRED_COMMANDS=("jq" "aws" "duckdb" "uplot")
for cmd in "${REQUIRED_COMMANDS[@]}"; do
        if ! command -v "$cmd" >/dev/null 2>&1; then
                echo "$cmd is not installed. please install."
                usage
        fi
done

export LC_ALL=C

# 翌日の日付を計算(Linux/macOS両対応)
NEXT_DATE=$(date -d "${TARGET_DATE} +1 day" +%Y-%m-%d 2>/dev/null || date -v+1d -j -f "%Y-%m-%d" "${TARGET_DATE}" +%Y-%m-%d)

# AWS CLIコマンドの構築
AWS_CMD="aws application-autoscaling describe-scheduled-actions --service-namespace ecs --resource-id ${RESOURCE_ID}"
if [ -n "${PROFILE}" ]; then
    AWS_CMD="${AWS_CMD} --profile ${PROFILE}"
    echo "Using AWS profile: ${PROFILE}"
fi

# AWS CLIコマンドの実行
eval "${AWS_CMD}" | \
jq -r --arg target_date "${TARGET_DATE}" '
def day_of_week(date_str):
  date_str | strptime("%Y-%m-%d") | strftime("%a") | ascii_upcase;

def next_day(date_str):
  date_str | strptime("%Y-%m-%d") | mktime + 86400 | strftime("%Y-%m-%d");

def parse_cron_days(cron_expr):
  cron_expr | split(" ")[4] |
  if . == "MON-FRI" then ["MON", "TUE", "WED", "THU", "FRI"]
  elif . == "SAT-SUN" or . == "SAT,SUN" then ["SAT", "SUN"]
  elif . == "*" or . == "?" then ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
  else [.]
  end;

def convert_utc_to_jst(schedule_expr):
  if (schedule_expr | contains("cron(")) then
    (schedule_expr | gsub("cron\\(|\\)"; "") | split(" ")) as $parts |
    ($parts[0] | tonumber) as $minute |
    ($parts[1] | tonumber) as $hour |

    # UTC -> JST (+9時間)
    (($hour + 9) % 24) as $jst_hour |
    (if ($hour + 9) >= 24 then 1 else 0 end) as $day_offset |

    {
      schedule: "cron(\($minute) \($jst_hour) \($parts[2]) \($parts[3]) \($parts[4]) \($parts[5]))",
      day_offset: $day_offset
    }
  elif (schedule_expr | contains("at(")) then
    # at(2026-01-25T09:30:00) 形式を処理
    (schedule_expr | gsub("at\\(|\\)"; "")) as $datetime |
    ($datetime | split("T")) as $parts |
    ($parts[0]) as $date |
    ($parts[1] | split(":")) as $time_parts |
    ($time_parts[0] | tonumber) as $hour |
    ($time_parts[1]) as $minute |
    ($time_parts[2]) as $second |

    # UTC -> JST (+9時間)
    (($hour + 9) % 24) as $jst_hour |
    (if ($hour + 9) >= 24 then 1 else 0 end) as $day_offset |

    # 日付を調整
    (if $day_offset == 1 then
      ($date | strptime("%Y-%m-%d") | mktime + 86400 | strftime("%Y-%m-%d"))
    else
      $date
    end) as $jst_date |

    {
      schedule: "at(\($jst_date)T\($jst_hour | tostring | if length == 1 then "0" + . else . end):\($minute):\($second))",
      day_offset: $day_offset
    }
  else
    {schedule: schedule_expr, day_offset: 0}
  end;

def format_schedule_display(schedule; timezone):
  if (timezone == null or timezone == "" or timezone == "Etc/UTC") then
    (schedule | convert_utc_to_jst(.)) as $jst_data |
    "\($jst_data.schedule) [JST換算]"
  else
    schedule
  end;

def check_skip_conditions(schedule; start_time; target_date; timezone):
  # Timezone未設定の場合はJST換算したスケジュールで判定
  (if (timezone == null or timezone == "" or timezone == "Etc/UTC")
   then (schedule | convert_utc_to_jst(.))
   else {schedule: schedule, day_offset: 0}
   end) as $effective_schedule |

  # day_offsetを考慮した実際の対象日を計算
  (if $effective_schedule.day_offset == 1
   then (target_date | next_day(.))
   else target_date
   end) as $actual_target_date |

  (start_time and (start_time | split("T")[0] > $actual_target_date)) as $start_after |
  (if ($effective_schedule.schedule | contains("cron")) then
    ($effective_schedule.schedule | parse_cron_days(.)) as $allowed_days |
    ($actual_target_date | day_of_week(.)) as $target_day |
    ($allowed_days | contains([$target_day]) | not)
  else false end) as $day_not_allowed |

  if $start_after then "スキップ - StartTimeが指定日翌日以降 (" + start_time + ")"
  elif $day_not_allowed then "スキップ - 曜日が範囲外 (対象: " + ($actual_target_date | day_of_week(.)) + ")"
  else "実行対象"
  end;

# 翌日の日付を計算
($target_date | next_day(.)) as $next_date |

.ScheduledActions[] |
# 指定日のat()スケジュール、または翌日0時台のat()スケジュール、またはcronスケジュールを選択
select(
  (.Schedule | contains($target_date)) or
  (.Schedule | contains("cron")) or
  (
    (.Schedule | contains($next_date)) and
    (.Schedule | test("T0[01]:"))  # 翌日の00:xx または 01:xx のみ
  )
) |
{
  ScheduledActionName,
  Schedule: format_schedule_display(.Schedule; .Timezone),
  OriginalSchedule: (if (.Timezone == null or .Timezone == "" or .Timezone == "Etc/UTC") then .Schedule else null end),
  Timezone: (if .Timezone == "Asia/Tokyo" then "Asia/Tokyo"
             elif (.Timezone != null and .Timezone != "" and .Timezone != "Etc/UTC") then .Timezone + " → 要変更"
             else "未設定 (UTC) → JST換算済み" end),
  StartTime,
  ScalableTargetAction,
  Status: check_skip_conditions(.Schedule; .StartTime; $target_date; .Timezone),
  Note: (if (.Timezone == null or .Timezone == "" or .Timezone == "Etc/UTC")
         then "実際のスケジュールはUTC、表示はJST換算値"
         else null end)
}' >"${TEMP_FILE}"

# ファイル内容をカラー付きで表示
echo ""
echo "=== Scheduled Actions for ${TARGET_DATE} (including next day 00:00-00:59) ==="
echo ""
jq -C '.' "${TEMP_FILE}"

# ファイル内容を Markdown 表で表示
echo ""
echo "=== Scheduled Markdown Table for ${TARGET_DATE} ==="
echo ""
duckdb -markdown -c "
SELECT
ScheduledActionName,
REPLACE(Schedule, '*', '\\*') as Schedule,
StartTime,
CONCAT('{min:',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), 'MinCapacity.*?(\\d+)', 1),'/max:',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), 'MaxCapacity.*?(\\d+)', 1),'}') as ScalableTargetAction,
Status,
CASE
WHEN Status = '実行対象' THEN
CONCAT('{min:',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), 'MinCapacity.*?(\\d+)', 1),'/max:',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), 'MaxCapacity.*?(\\d+)', 1),'}')
ELSE 'スキップ'
END as Simulation
FROM read_json('${TEMP_FILE}')
ORDER BY
CASE
WHEN Schedule LIKE 'at(%${NEXT_DATE}%' THEN 1440
WHEN Schedule LIKE 'cron%' THEN
CAST(regexp_extract(Schedule, 'cron\\(\\d+ (\\d+)', 1) AS INTEGER) * 60 +
CAST(regexp_extract(Schedule, 'cron\\((\\d+)', 1) AS INTEGER)
WHEN Schedule LIKE 'at%' THEN
CAST(regexp_extract(Schedule, 'T(\\d+):(\\d+)', 1) AS INTEGER) * 60 +
CAST(regexp_extract(Schedule, 'T\\d+:(\\d+)', 1) AS INTEGER)
END"

# ファイル内容を youplot graphで表示
echo ""
echo "=== Scheduled Youplot Graph for ${TARGET_DATE} ==="
echo ""
duckdb -csv -noheader -c "
WITH ordered_data AS (
SELECT
CASE
WHEN Status = '実行対象' THEN
CONCAT(
ScheduledActionName,' ',
CASE
WHEN Schedule LIKE 'at(%${NEXT_DATE}%' THEN '${NEXT_DATE} '
ELSE ''
END,
LPAD(CAST(CASE
WHEN Schedule LIKE 'cron%' THEN regexp_extract(Schedule, 'cron\\(\\d+ (\\d+)', 1)
WHEN Schedule LIKE 'at%' THEN regexp_extract(Schedule, 'T(\\d+):', 1)
END AS VARCHAR), 2, '0'),':',
LPAD(CAST(CASE
WHEN Schedule LIKE 'cron%' THEN regexp_extract(Schedule, 'cron\\((\\d+)', 1)
WHEN Schedule LIKE 'at%' THEN regexp_extract(Schedule, 'T\\d+:(\\d+)', 1)
END AS VARCHAR), 2, '0'),' ',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), 'MinCapacity.*?(\\d+)', 1),'/',
regexp_extract(CAST(ScalableTargetAction AS VARCHAR), 'MaxCapacity.*?(\\d+)', 1)
)
ELSE
CONCAT(
ScheduledActionName,' ',
CASE
WHEN Schedule LIKE 'at(%${NEXT_DATE}%' THEN '${NEXT_DATE} '
ELSE ''
END,
LPAD(CAST(CASE
WHEN Schedule LIKE 'cron%' THEN regexp_extract(Schedule, 'cron\\(\\d+ (\\d+)', 1)
WHEN Schedule LIKE 'at%' THEN regexp_extract(Schedule, 'T(\\d+):', 1)
END AS VARCHAR), 2, '0'),':',
LPAD(CAST(CASE
WHEN Schedule LIKE 'cron%' THEN regexp_extract(Schedule, 'cron\\((\\d+)', 1)
WHEN Schedule LIKE 'at%' THEN regexp_extract(Schedule, 'T\\d+:(\\d+)', 1)
END AS VARCHAR), 2, '0'),' SKIP'
)
END as time_label,
CASE
WHEN Status = '実行対象' THEN
CAST(regexp_extract(CAST(ScalableTargetAction AS VARCHAR), 'MinCapacity.*?(\\d+)', 1) AS INTEGER)
ELSE NULL
END as min_capacity,
CASE
WHEN Schedule LIKE 'at(%${NEXT_DATE}%' THEN 1440  -- 翌日分は24:00 (1440分) として扱う
WHEN Schedule LIKE 'cron%' THEN
CAST(regexp_extract(Schedule, 'cron\\(\\d+ (\\d+)', 1) AS INTEGER) * 60 +
CAST(regexp_extract(Schedule, 'cron\\((\\d+)', 1) AS INTEGER)
WHEN Schedule LIKE 'at%' THEN
CAST(regexp_extract(Schedule, 'T(\\d+):(\\d+)', 1) AS INTEGER) * 60 +
CAST(regexp_extract(Schedule, 'T\\d+:(\\d+)', 1) AS INTEGER)
END as sort_time
FROM read_json('${TEMP_FILE}')
),
with_lag AS (
SELECT
time_label,
COALESCE(
min_capacity,
LAST_VALUE(min_capacity IGNORE NULLS) OVER (
ORDER BY sort_time
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
0
) as final_capacity
FROM ordered_data
ORDER BY sort_time
)
SELECT time_label, final_capacity FROM with_lag" |
uplot bar -d, -t "${TARGET_DATE} ${RESOURCE_ID} AutoScale Status"

rm -f ${TEMP_FILE}

実行すると以下のような出力になります

# 実行例
# bash ecs-schedule.sh 2026-01-25 "service/my-cluster/my-service"
# bash ecs-schedule.sh 2026-01-25 "service/my-cluster/my-service" my-profile

$ bash ecs-schedule.sh 2026-01-25 "service/ecs-fargate/resourceid"
=== Scheduled Actions for 2026-01-25 (including next day 00:00-00:59) ===

{
  "ScheduledActionName": "night-time-scale-in",
  "Schedule": "cron(0 20 * * ? *)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 5,
    "MaxCapacity": 100
  },
  "Status": "実行対象",
  "Note": null
}
{
  "ScheduledActionName": "2026-01-25-09-scale-out",
  "Schedule": "at(2026-01-25T09:30:00)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 40,
    "MaxCapacity": 100
  },
  "Status": "実行対象",
  "Note": null
}
{
  "ScheduledActionName": "everydays-morning-scale-out",
  "Schedule": "cron(30 5 * * ? *)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 10,
    "MaxCapacity": 100
  },
  "Status": "実行対象",
  "Note": null
}
{
  "ScheduledActionName": "weekdays-day-morning-scale-out",
  "Schedule": "cron(30 6 ? * MON-FRI *)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 20,
    "MaxCapacity": 100
  },
  "Status": "スキップ - 曜日が範囲外 (対象: SUN)",
  "Note": null
}
{
  "ScheduledActionName": "evening-time-scale-in",
  "Schedule": "cron(0 18 ? * MON-FRI *)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 10,
    "MaxCapacity": 100
  },
  "Status": "スキップ - 曜日が範囲外 (対象: SUN)",
  "Note": null
}
{
  "ScheduledActionName": "weekend-day-time-scale-out",
  "Schedule": "cron(30 6 ? * SAT-SUN *)",
  "OriginalSchedule": null,
  "Timezone": "Asia/Tokyo",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 30,
    "MaxCapacity": 100
  },
  "Status": "実行対象",
  "Note": null
}
{
  "ScheduledActionName": "weekend-day-evening-utctime-scale-out",
  "Schedule": "cron(0 19 ? * SUN *) [JST換算]",
  "OriginalSchedule": "cron(0 10 ? * SUN *)",
  "Timezone": "未設定 (UTC) → JST換算済み",
  "StartTime": "2025-12-23T20:00:00+09:00",
  "ScalableTargetAction": {
    "MinCapacity": 15,
    "MaxCapacity": 100
  },
  "Status": "実行対象",
  "Note": "実際のスケジュールはUTC、表示はJST換算値"
}

=== Scheduled Markdown Table for 2026-01-25 ===

|          ScheduledActionName          |             Schedule             |         StartTime         | ScalableTargetAction |               Status                |    Simulation    |
|---------------------------------------|----------------------------------|---------------------------|----------------------|-------------------------------------|------------------|
| everydays-morning-scale-out           | cron(30 5 \* \* ? \*)            | 2025-12-23T20:00:00+09:00 | {min:10/max:100}     | 実行対象                            | {min:10/max:100} |
| weekdays-day-morning-scale-out        | cron(30 6 ? \* MON-FRI \*)       | 2025-12-23T20:00:00+09:00 | {min:20/max:100}     | スキップ - 曜日が範囲外 (対象: SUN) | スキップ         |
| weekend-day-time-scale-out            | cron(30 6 ? \* SAT-SUN \*)       | 2025-12-23T20:00:00+09:00 | {min:30/max:100}     | 実行対象                            | {min:30/max:100} |
| 2026-01-25-09-scale-out               | at(2026-01-25T09:30:00)          | 2025-12-23T20:00:00+09:00 | {min:40/max:100}     | 実行対象                            | {min:40/max:100} |
| evening-time-scale-in                 | cron(0 18 ? \* MON-FRI \*)       | 2025-12-23T20:00:00+09:00 | {min:10/max:100}     | スキップ - 曜日が範囲外 (対象: SUN) | スキップ         |
| weekend-day-evening-utctime-scale-out | cron(0 19 ? \* SUN \*) [JST換算] | 2025-12-23T20:00:00+09:00 | {min:15/max:100}     | 実行対象                            | {min:15/max:100} |
| night-time-scale-in                   | cron(0 20 \* \* ? \*)            | 2025-12-23T20:00:00+09:00 | {min:5/max:100}      | 実行対象                            | {min:5/max:100}  |

=== Scheduled Youplot Graph for 2026-01-25 ===

                                                      2026-01-25 service/ecs-fargate/resourceid AutoScale Status
                                                      ┌                                        ┐
             everydays-morning-scale-out 05:30 10/100 ┤■■■■■■■■■ 10.0
            weekdays-day-morning-scale-out 06:30 SKIP ┤■■■■■■■■■ 10.0
              weekend-day-time-scale-out 06:30 30/100 ┤■■■■■■■■■■■■■■■■■■■■■■■■■■ 30.0
                 2026-01-25-09-scale-out 09:30 40/100 ┤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 40.0
                     evening-time-scale-in 18:00 SKIP ┤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 40.0
   weekend-day-evening-utctime-scale-out 19:00 15/100 ┤■■■■■■■■■■■■■ 15.0
                      night-time-scale-in 20:00 5/100 ┤■■■■ 5.0

最後に

小ネタと言いつつ長文になってしまいました...
このブログがどなたかの役に立てば幸いです。

クラスメソッドオペレーションズ株式会社について

クラスメソッドグループのオペレーション企業です。

運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AIをフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。

当社は様々な職種でメンバーを募集しています。

「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイト をぜひご覧ください。※2026年1月 アノテーション㈱から社名変更しました。

この記事をシェアする

FacebookHatena blogX

関連記事