Claude Codeで開発環境を夜間停止 — EventBridge SchedulerとTerraformで
タグ(auto-stop=true)を持つEC2とRDSだけを平日の夜に止め、朝に起こすTerraformをClaude Codeに書かせる手順です。本番の混入はEC2がIAMとplan検査のhook、RDSがhookで防ぎます。
開発・検証環境の夜間停止は、EventBridge Schedulerの「ユニバーサルターゲット」でEC2とRDSのAPIを直接呼べば、Lambdaなしで組めます。Claude Codeに任せるなら、書かせる範囲と止めてよい対象の線引きを先に決めておくのが要点です。この記事では、タグauto-stop=trueを持つリソースだけを止めるTerraformを書かせ、本番が紛れ込んでいないかをplan時にhookで検査するところまで進めます。
削除してよいリソースの洗い出しはClaude CodeでAWSの未使用リソースを洗い出すが扱っています。ここで作るのは削除ではなく、毎晩止めて朝に戻す仕組みです。
止める対象は「タグを付けた側」から選ぶ
停止対象の決め方は2通りあります。除外リストを書いてそれ以外を止める方式と、タグを付けたものだけを止める方式です。前者は新しい本番リソースが増えた日に、除外リストへの追記漏れがそのまま停止事故になります。後者は付け忘れても止まらないだけで済みます。
この記事は後者で組みます。判定に使うタグは次の2つです。
| タグ | 値 | 意味 |
|---|---|---|
auto-stop | 値true | 意味夜間停止の対象にする |
env | 値prod | 意味付いていれば、auto-stopがあっても止めない |
env=prodは二重の安全網です。auto-stopを誤って本番に付けても、EC2はIAMとhookの両方が、RDSはhookが止めにかかります。RDS側のIAMがタグを見ない理由は、実行ロールの節で説明します。
Schedulerで停止と起動を呼ぶ仕組み
EventBridge Schedulerは、AWS SDKのAPI操作をそのままターゲットにできます。ターゲットのARNはarn:aws:scheduler:::aws-sdk:service:apiActionの形で、実行ロールにそのAPIを呼ぶ権限が要ります。Inputには、呼ぶAPIのリクエストパラメータをJSONで渡します。
EC2ならec2:stopInstancesとec2:startInstances、RDSならrds:stopDBInstanceとrds:startDBInstanceが使えます。なおgetやdescribeで始まる読み取り系のAPIはターゲットにできません。停止と起動だけを呼ぶ今回の用途には影響しません。
時刻はcron式で指定します。タイムゾーンを指定しなければUTCで評価されるため、Terraformではschedule_expression_timezoneにAsia/Tokyoを入れます。
停止対象のIDをTerraformのdata sourceで集める
タグから対象IDを集める部分は、data sourceに任せます。EC2はaws_instancesのinstance_tags、RDSはaws_db_instancesのtagsで絞れます。どちらもタグのキーと値が完全一致したものだけが返ります。
ここに落とし穴が1つあります。aws_instancesのinstance_state_namesは、既定値がrunningです。夜にterraform planを実行すると、停止中のインスタンスは一覧から消え、起動用のscheduleが「不要」と判定されて削除される計画になります。["running", "stopped"]を明示しておけば、停止中でも対象に残ります。
data "aws_instances" "auto_stop" {
instance_tags = { "auto-stop" = "true" }
instance_state_names = ["running", "stopped"]
}
data "aws_db_instances" "auto_stop" {
tags = { "auto-stop" = "true" }
}
locals {
ec2_ids = sort(data.aws_instances.auto_stop.ids)
rds_ids = sort(data.aws_db_instances.auto_stop.instance_identifiers)
rds_arns = data.aws_db_instances.auto_stop.instance_arns
}aws_instancesのドキュメントには、Auto Scalingグループのように入れ替わるインスタンスへの利用は勧めない、という注意書きがあります。ASG配下のインスタンスにはこの方式を使わず、グループの希望台数をスケジュールで変える別の方法を選んでください。
実行ロールとスケジュールを書く
実行ロールの信頼ポリシーには、プリンシパルとしてscheduler.amazonaws.comを指定します。権限側は、EC2をタグ条件で絞り、RDSはdata sourceが返したARNだけに限ります。
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
resource "aws_iam_role" "scheduler" {
name = "dev-auto-stop-scheduler"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "scheduler.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "scheduler" {
role = aws_iam_role.scheduler.id
policy = jsonencode({
Version = "2012-10-17"
Statement = concat(
[{
Effect = "Allow"
Action = ["ec2:StopInstances", "ec2:StartInstances"]
Resource = "arn:aws:ec2:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:instance/*"
Condition = {
StringEquals = { "aws:ResourceTag/auto-stop" = "true" }
StringNotEquals = { "aws:ResourceTag/env" = "prod" }
}
}],
length(local.rds_arns) > 0 ? [{
Effect = "Allow"
Action = ["rds:StopDBInstance", "rds:StartDBInstance"]
Resource = local.rds_arns
}] : []
)
})
}EC2側のConditionは、IAMポリシーのaws:ResourceTag条件キーでタグを見ています。つまり、Schedulerが誤ったIDを受け取っても、タグの合わないインスタンスはAWS側が拒否します。RDS側はタグ条件ではなく、data sourceが返したARNの列挙で絞っています。このARNはauto-stop=trueだけで選ばれているため、env=prodも付いたRDSは、IAMでは止まりません。RDSの本番除外はhookだけが担うので、auto-stopを付ける前にenvタグを確かめる運用が前提になります。data.aws_region.current.regionは、provider v6でnameに代わる属性です。v5以前の環境ではnameに読み替えてください。
スケジュール本体は次のとおりです。EC2は全台を1つのscheduleにまとめ、RDSは識別子ごとに分けます。RDSは起動に時間がかかるため、朝の起動はEC2より30分早めています。
locals {
ec2_schedules = length(local.ec2_ids) == 0 ? {} : {
stop = { cron = "cron(0 21 ? * MON-FRI *)", api = "stopInstances" }
start = { cron = "cron(0 8 ? * MON-FRI *)", api = "startInstances" }
}
rds_schedules = {
for p in setproduct(local.rds_ids, ["stop", "start"]) :
"${p[0]}-${p[1]}" => {
id = p[0]
cron = p[1] == "stop" ? "cron(0 21 ? * MON-FRI *)" : "cron(30 7 ? * MON-FRI *)"
api = p[1] == "stop" ? "stopDBInstance" : "startDBInstance"
}
}
}
resource "aws_scheduler_schedule" "ec2" {
for_each = local.ec2_schedules
name = "dev-ec2-${each.key}"
schedule_expression = each.value.cron
schedule_expression_timezone = "Asia/Tokyo"
flexible_time_window { mode = "OFF" }
target {
arn = "arn:aws:scheduler:::aws-sdk:ec2:${each.value.api}"
role_arn = aws_iam_role.scheduler.arn
input = jsonencode({ InstanceIds = local.ec2_ids })
}
}
resource "aws_scheduler_schedule" "rds" {
for_each = local.rds_schedules
name = "dev-rds-${each.key}"
schedule_expression = each.value.cron
schedule_expression_timezone = "Asia/Tokyo"
flexible_time_window { mode = "OFF" }
target {
arn = "arn:aws:scheduler:::aws-sdk:rds:${each.value.api}"
role_arn = aws_iam_role.scheduler.arn
input = jsonencode({ DBInstanceIdentifier = each.value.id })
}
}平日21時に停止し、翌朝8時に起動する設定です。金曜の夜に止めたものは月曜の朝まで止まったままなので、休日用のscheduleは要りません。週168時間のうち、動いているのは65時間です。
RDSだけは停止の制約を先に確認する
RDSの一時停止には、EC2にない制約がいくつかあります。Claude Codeに書かせる前に、対象のDBが当てはまらないかを見ておきます。
- 停止中も、プロビジョニングしたストレージとバックアップ容量には課金される。止まるのはDBインスタンス時間の分だけ
- 停止から7日連続で手動起動しなければ、RDSが自動で起動する
- リードレプリカを持つインスタンスと、リードレプリカ自身は止められない
- RDS for SQL ServerはMulti-AZ構成の停止に対応していない
7日の自動起動は、年末年始のような長期休暇で効いてきます。休暇明けを待たずに起動してしまうため、止めておきたい期間が7日を超えるなら、別途の手当てが必要です。RDSのドキュメントは、Step Functionsで7日を超えて停止する方法を案内しています。
Claude Codeに頼むときの指示
CLAUDE.mdに、この環境固有の線引きを書いておくと、依頼のたびに繰り返さずに済みます。
## 夜間停止のTerraform
- 停止対象は auto-stop=true のタグを持つEC2とRDSだけ。リソース名やIDの直書きはしない
- env=prod のタグを持つものは対象に含めない。EC2はIAMの条件でも除外する。RDSのIAMはタグを見ないので、除外はhookに任せる
- aws_instances には instance_state_names = ["running", "stopped"] を必ず付ける
- terraform apply は plan -out=tfplan で作った計画ファイルを指定して実行する依頼文は「infra/devに、auto-stop=trueのEC2とRDSを平日21時に止めて朝に起こすSchedulerを追加して。まずterraform plan -out=tfplanまで」程度で足ります。applyまで任せず、planの差分を人が読む段階で止めるのが安全です。
planに本番が混入していないかをhookで検査する
IAMの条件は実行時の防御で、Terraformの変更そのものは防ぎません。しかもEC2にしか効かないので、RDSの本番混入を止めるのはこのhookだけです。applyの前に、保存したplanの中身を検査するhookを足します。
Claude Codeのhookは、PreToolUseのイベントでツール実行の直前に走ります。matcherでツール名を絞り、さらにハンドラのifでBash(terraform apply *)のようにコマンドの形まで絞れます。スクリプトの終了コードが2なら、ツール呼び出しはブロックされ、標準エラー出力の内容が理由として返ります。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(terraform apply *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-stop-targets.sh"
}
]
}
]
}
}スクリプトの例を示します。標準入力のJSONからtool_input.commandを取り出し、保存済みplanを読み、scheduleのinputに含まれるIDのタグをAWSに問い合わせます。
#!/bin/bash
# .claude/hooks/check-stop-targets.sh
cd "${TF_DIR:-infra/dev}" || { echo "Terraformのディレクトリに移れないため止めます" >&2; exit 2; }
CMD=$(jq -r '.tool_input.command') || { echo "hook入力を読めないため止めます" >&2; exit 2; }
case "$CMD" in
*tfplan*) ;;
*) echo "plan -out=tfplan で作った計画ファイルを指定してください" >&2; exit 2 ;;
esac
PLAN=$(terraform show -json tfplan) \
|| { echo "planを読めないため止めます" >&2; exit 2; }
IDS=$(printf '%s' "$PLAN" | jq -r '
.resource_changes[]
| select(.type == "aws_scheduler_schedule" and .change.after != null)
| .change.after.target[0].input | fromjson
| (.InstanceIds[]?, (.DBInstanceIdentifier // empty))') \
|| { echo "planの解析に失敗したため止めます" >&2; exit 2; }
IDS=$(printf '%s\n' "$IDS" | sort -u)
for id in $IDS; do
case "$id" in
i-*) env=$(aws ec2 describe-tags \
--filters "Name=resource-id,Values=$id" "Name=key,Values=env" \
--query 'Tags[0].Value' --output text) ;;
*) arn=$(aws rds describe-db-instances --db-instance-identifier "$id" \
--query 'DBInstances[0].DBInstanceArn' --output text)
env=$(aws rds list-tags-for-resource --resource-name "$arn" \
--query "TagList[?Key=='env'].Value | [0]" --output text) ;;
esac
[ -n "$env" ] || { echo "タグを取得できないため止めます: $id" >&2; exit 2; }
if [ "$env" = "prod" ]; then
echo "本番タグ(env=prod)のリソースが停止対象に入っています: $id" >&2
exit 2
fi
done
exit 0スクリプトの中ほどにある-n "$env"の行は、AWSの認証が切れているときのための歯止めです。hookは、終了コードが0か2以外だと「非ブロッキングのエラー」として扱われ、ツール呼び出しは先に進みます。タグの取得に失敗してenvが空のまま比較に進むと、検査していないのに通ってしまうため、空なら自分から2で止めます。同じ理由で、cdやterraform show、jqが失敗したときも2で止めています。削除されるscheduleは.change.afterがnullになるため、selectで除いています。除かないとjqがエラー終了し、IDが空のまま検査を1回も回さずに通る恐れがあります。
このhookが見ているのは、Claude Codeが実行するBashコマンドだけです。CI/CDのパイプラインから直接applyする経路は対象外で、そちらで守れるのはEC2だけで、IAMの条件が最後の砦になります。RDSには同じ砦がありません。また、tfplanにIDが入るのは、data sourceが計画時に既存のタグ付きリソースを返せた場合に限られます。まだ存在しない、同じapplyで作るインスタンスはdata sourceに現れず、そもそも停止対象になりません。
初回は手元でplanからapplyまで流して確かめる
最初の1回は、停止の時刻になる前に、計画ファイルの作成からapplyまでを順に実行します。hookが効いているかも、このときに確かめられます。
terraform plan -out=tfplan
terraform apply tfplan
aws scheduler get-schedule --name dev-ec2-stop1行目のplanでは、aws_scheduler_scheduleが追加される計画になっていることと、inputのInstanceIdsがauto-stop=trueを付けた機器だけであることを読みます。3行目は、作成されたscheduleのcron式とタイムゾーンを返します。意図した時刻になっていなければ、停止が来る前に直せます。
hookの検査を試すには、env=prodとauto-stop=trueの両方を付けたテスト用のEC2インスタンスを1台用意し、planに入れてからapplyを依頼します。RDSで試すと、hookをすり抜けた場合に本物のDBが夜に止まるため、避けてください。hookが標準エラー出力の理由を返してブロックすれば、検査は機能しています。ブロックされた理由はClaude Codeにも渡るので、タグの付け方を見直す提案がそのまま返ってきます。
夜の停止が実際に動いたかは、翌朝にEC2の状態を見て判断します。aws ec2 describe-instancesに--filters "Name=tag:auto-stop,Values=true"を付けて状態を引けば、停止対象だけの一覧が手に入ります。起動が朝8時までに終わっていなければ、RDSの起動時刻をさらに前倒しします。
運用で引っかかりやすい点
- 対象の増減は、applyし直すまで反映されない: タグを新しく付けたインスタンスは、次のplanとapplyでscheduleに入ります。付けた直後の夜は止まりません
- タグの付け外しは差分に出る:
auto-stopを外したインスタンスはID一覧から消え、InstanceIdsが変わります。差分を見れば、どの機器が夜間停止から外れたかが分かります - IDの変更はscheduleを更新する: インスタンスを作り直すとIDが変わり、
inputの書き換えがplanに出ます。ドリフトとの切り分けはClaudeにTerraformのドリフトを検出させ、取り込むか戻すか分類するの手順が使えます - 停止中のRDSは変更できない: 夜間や休日にRDS自体のパラメータやインスタンスクラスを変えるapplyを流すと、停止中のため失敗します。RDSに触れる変更は、起動している平日の日中に流す運用にすると、失敗の切り分けが要らなくなります
- 起動が間に合わない朝: 朝8時にアプリを使い始める前提なら、RDSが接続を受け付けるまでの所要時間を初週に実測します。7時30分で足りなければ、起動時刻をさらに前に倒します
- 手動で作ったリソース: タグ付き既存リソースをコード管理に移すなら、Claude CodeでTerraformのimportを進めるが先です
まとめ
夜間停止は、停止処理そのものよりも「何を止めないか」の設計が仕事の大半です。タグで選び、EC2はIAMの条件でも拒否し、applyの前にhookで検査する。RDSの守りはタグとhookの2層なので、その分auto-stopを付ける手間は慎重に扱います。この構成なら、Claude Codeが書いたTerraformに任せられる範囲が見えてきます。最初はRDSを外してEC2だけで始め、停止と起動が1週間問題なく回ってから、RDSの制約を見て足していく進め方が堅実です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- code.claude.com/docs/en/hooks
- docs.aws.amazon.com/scheduler/latest/UserGuide/managing-targets-universal.html
- docs.aws.amazon.com/scheduler/latest/UserGuide/setting-up.html
- docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_StopInstance.html
- registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/instances