Aurora PostgreSQL の CPU 高負荷時に自動で「インスタンス再起動」する仕組みを作った話

背景

あるシステムで Aurora PostgreSQL(Aurora クラスター + Writer/Reader インスタンス)を利用しています。

アプリケーション側の挙動の問題で、まれに CPU 使用率が 100% 近くまで張り付いた状態が数分続く ことがありました。

  • その間はアプリのレスポンスが悪化する
  • ただしフェイルオーバーで Writer を切り替えるほどではない
  • アプリ側の根本対応がすぐには難しいため、インスタンスだけ再起動してクリーンな状態に戻したい

そこで、

「CPU 使用率が一定時間以上高止まりしたら、Writer/Reader インスタンスを自動で再起動する」

という仕組みを CloudWatch + Lambda + DynamoDB で実装しました。

この記事では、ステージング環境での検証を経て、本番環境に適用するまでの手順をまとめます。



全体構成

やっていることはシンプルで、構成はこんな感じです。

  1. CloudWatch アラーム Aurora インスタンスの CPUUtilization を監視し、 「5/5 データポイントで 90%以上」などの条件で ALARM に遷移
  2. CloudWatch → Lambda アクション アラーム状態に変わったタイミングで Lambda 関数を起動
  3. Lambda 関数
    • イベントから「どのインスタンスのアラームか」を判定
    • DynamoDB で「最近再起動していないか」を確認(ループ防止)
    • 問題なければ RebootDBInstance API を叩いて、そのインスタンスのみ再起動
  4. DynamoDB テーブル インスタンスごとの最終再起動時刻を管理し、短時間の連続再起動を防止

ポイントは、フェイルオーバーではなくインスタンス再起動だけ を自動化しているところです。



前提環境(例)

以下のような前提で説明します(実際の環境に合わせて読み替えてください)。

  • リージョン:ap-northeast-1
  • アカウント ID:123456789012
  • Aurora クラスター:myapp-prod-rds
  • Writer インスタンス:myapp-prod-rdsinst
  • Reader インスタンス:myapp-prod-rdsinst2

Step 1. DynamoDB テーブルで「再起動履歴」を管理する

まず、インスタンスごとの最終再起動時刻を記録する DynamoDB テーブルを作成します。

  1. AWS マネジメントコンソールで DynamoDB を開く
  2. 左メニュー「テーブル」→「テーブルを作成」
  3. 以下のように設定
    • テーブル名:rds-auto-reboot-history
    • パーティションキー
      • 名前:DbInstanceIdentifier
      • 型:文字列 (String)
    • その他のオプションはデフォルト(オンデマンド課金)で OK

これで DbInstanceIdentifier をキーにして再起動時刻を保存できるようになります。

Step 2. Lambda 実行ロールを作成する

Lambda からは次の権限が必要です。

  • 対象 RDS インスタンスに対する DescribeDBInstances と RebootDBInstance
  • DynamoDB テーブルに対する GetItem と PutItem
  • CloudWatch Logs へのログ出力

2-1. ロールの作成

  1. IAM コンソール → 左メニュー「ロール」→「ロールを作成」
  2. 信頼されたエンティティタイプ:AWS のサービス
  3. ユースケース:Lambda を選択 → 次へ
  4. 「許可ポリシーをアタッチ」で マネージドポリシー AWSLambdaBasicExecutionRole を追加
  5. ロール名を lambda-rds-auto-reboot-role などにして作成

2-2. RDS & DynamoDB 用インラインポリシーの追加

  1. 作成したロール lambda-rds-auto-reboot-role を開く
  2. 「許可」タブ → 「インラインポリシーを追加」
  3. JSON タブに切り替えて、以下を貼り付け (アカウント ID とインスタンス名は環境に合わせて書き換え)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "RdsReboot",
      "Effect": "Allow",
      "Action": [
        "rds:DescribeDBInstances",
        "rds:RebootDBInstance"
      ],
      "Resource": [
        "arn:aws:rds:ap-northeast-1:123456789012:db:myapp-prod-rdsinst",
        "arn:aws:rds:ap-northeast-1:123456789012:db:myapp-prod-rdsinst2"
      ]
    },
    {
      "Sid": "DynamoRebootHistory",
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem"
      ],
      "Resource": "arn:aws:dynamodb:ap-northeast-1:123456789012:table/rds-auto-reboot-history"
    }
  ]
}

4.「ポリシーの確認」→ 名前(例:lambda-rds-auto-reboot-policy)を付けて保存


Step 3. Lambda 関数を作成する

3-1. 関数の作成

  1. Lambda コンソール →「関数の作成」
  2. 「一から作成」を選択し、以下のように設定
    • 関数名:rds-auto-reboot-on-high-cpu
    • ランタイム:Python 3.12
    • アーキテクチャ:x86_64(デフォルト)
    • 実行ロール:lambda-rds-auto-reboot-role を選択
  3. 「関数の作成」をクリック

3-2. Lambda コード

lambda_function.py に以下のコードを貼り付けて「デプロイ」します。

import os
import json
from datetime import datetime, timezone, timedelta

import boto3

rds = boto3.client("rds")
dynamo = boto3.client("dynamodb")

DDB_TABLE_NAME = os.environ["DDB_TABLE_NAME"]
MIN_REBOOT_INTERVAL_MINUTES = int(os.environ["MIN_REBOOT_INTERVAL_MINUTES"])


def lambda_handler(event, context):
    """
    CloudWatch アラームから呼び出されて RDS インスタンスを再起動する Lambda。

    対応イベント形式:
      1) 新しい CloudWatch アラーム → Lambda 形式
         {
           "source": "aws.cloudwatch",
           "alarmArn": "...",
           "alarmData": { "state": {...}, "configuration": {...} }
         }

      2) EventBridge (CloudWatch Alarm State Change) 経由
         { "detail-type": "...", "detail": { "state": {...}, "configuration": {...} } }

      3) 古い CloudWatch → Lambda 形式
         { "AlarmName": "...", "NewStateValue": "ALARM", "Trigger": { "Dimensions": [...] } }
    """
    print("Event:", json.dumps(event))

    db_instance_id = None

    # --- パターン1: 新しい CloudWatch アラーム → Lambda 形式 ---
    if event.get("source") == "aws.cloudwatch" and "alarmData" in event:
        alarm_data = event["alarmData"]
        state_value = alarm_data.get("state", {}).get("value")
        if state_value != "ALARM":
            print(f"[CloudWatch new] State is {state_value}. Do nothing.")
            return

        db_instance_id = extract_db_instance_id_from_metric_container(alarm_data)

    # --- パターン2: EventBridge (CloudWatch Alarm State Change) 経由 ---
    elif "detail-type" in event and "detail" in event:
        detail = event["detail"]
        state_value = detail.get("state", {}).get("value")
        if state_value != "ALARM":
            print(f"[EventBridge] State is {state_value}. Do nothing.")
            return

        db_instance_id = extract_db_instance_id_from_metric_container(detail)

    # --- パターン3: 古い CloudWatch → Lambda 形式 ---
    elif "AlarmName" in event:
        new_state = event.get("NewStateValue")
        if new_state != "ALARM":
            print(f"[LegacyAlarm] NewStateValue is {new_state}. Do nothing.")
            return

        db_instance_id = extract_db_instance_id_from_alarm_trigger(event)

    else:
        print(f"Unknown event format. keys={list(event.keys())}")
        return

    if not db_instance_id:
        print("DBInstanceIdentifier not found. Abort.")
        return

    print(f"Target DB instance: {db_instance_id}")

    # --- ループ防止: 直近で再起動していないか確認 ---
    if is_recently_rebooted(db_instance_id):
        print("Recently rebooted. Skip reboot.")
        return

    # --- RDS インスタンス状態確認 ---
    inst = rds.describe_db_instances(DBInstanceIdentifier=db_instance_id)["DBInstances"][0]
    status = inst["DBInstanceStatus"]
    print(f"Current status: {status}")
    if status != "available":
        print("Status is not 'available'. Skip reboot.")
        return

    # --- 再起動実行 (ForceFailover=False でフェイルオーバーはしない) ---
    resp = rds.reboot_db_instance(
        DBInstanceIdentifier=db_instance_id,
        ForceFailover=False
    )
    print("Reboot started:", resp["DBInstance"]["DBInstanceIdentifier"])

    # --- DynamoDB に再起動時刻を記録 ---
    now = datetime.now(timezone.utc).isoformat()
    dynamo.put_item(
        TableName=DDB_TABLE_NAME,
        Item={
            "DbInstanceIdentifier": {"S": db_instance_id},
            "LastRebootTime": {"S": now}
        }
    )

    return {"status": "reboot_started", "instance": db_instance_id}


def extract_db_instance_id_from_metric_container(container: dict) -> str | None:
    """
    alarmData / detail など、CloudWatch アラームの configuration.metrics[]
    を含むコンテナから DBInstanceIdentifier を取り出す。

    dimensions の形が
      - {"DBInstanceIdentifier": "..."} という dict
      - [{"name": "...", "value": "..."}] という list
    の両方をサポートする。
    """
    cfg = container.get("configuration", container)
    metrics = cfg.get("metrics", [])

    for m in metrics:
        metric_stat = m.get("metricStat", {})
        metric = metric_stat.get("metric", {})
        dims = metric.get("dimensions")

        if not dims:
            continue

        # パターンA: dict 形式 {"DBInstanceIdentifier": "..."}
        if isinstance(dims, dict):
            for k, v in dims.items():
                if k == "DBInstanceIdentifier":
                    return v

        # パターンB: list 形式 [{"name": "...", "value": "..."}]
        elif isinstance(dims, list):
            for d in dims:
                if not isinstance(d, dict):
                    continue
                name = d.get("name") or d.get("Name")
                value = d.get("value") or d.get("Value")
                if name == "DBInstanceIdentifier":
                    return value

    return None


def extract_db_instance_id_from_alarm_trigger(event: dict) -> str | None:
    """
    古い CloudWatch メトリクスアラームの Trigger.Dimensions[] から
    DBInstanceIdentifier を取得。
    """
    trigger = event.get("Trigger", {})
    dims = trigger.get("Dimensions", [])
    for d in dims:
        name = d.get("name") or d.get("Name")
        value = d.get("value") or d.get("Value")
        if name == "DBInstanceIdentifier":
            return value
    return None


def is_recently_rebooted(db_instance_id: str) -> bool:
    """
    DynamoDB に記録された最終再起動時刻と現在時刻を比較し、
    MIN_REBOOT_INTERVAL_MINUTES 以内なら True を返す。
    """
    res = dynamo.get_item(
        TableName=DDB_TABLE_NAME,
        Key={"DbInstanceIdentifier": {"S": db_instance_id}},
        ConsistentRead=True
    )
    item = res.get("Item")
    if not item:
        return False

    last_str = item.get("LastRebootTime", {}).get("S")
    if not last_str:
        return False

    last_dt = datetime.fromisoformat(last_str)
    now = datetime.now(timezone.utc)

    diff = now - last_dt
    print(f"Last reboot diff minutes: {diff.total_seconds() / 60:.1f}")
    return diff < timedelta(minutes=MIN_REBOOT_INTERVAL_MINUTES)

3-3. 環境変数

Lambda の「設定」タブ →「環境変数」で以下を設定します。

キー
DDB_TABLE_NAMErds-auto-reboot-history
MIN_REBOOT_INTERVAL_MINUTES30(本番は 30〜60 を推奨)

Step 4. CloudWatch アラームを作成する(Writer/Reader)

4-1. Writer 用アラーム

  1. CloudWatch →「アラーム」→「アラームを作成」
  2. メトリクスの選択
    • 名前空間:AWS/RDS
    • メトリクス:CPUUtilization
    • DBInstanceIdentifier = myapp-prod-rdsinst を選択
  3. しきい値の設定
    • アラーム名:rds-myapp-prod-rdsinst-high-cpu-auto-reboot
    • 統計:平均
    • 期間:60 秒
    • 条件:CPUUtilization が > 90
    • 評価データポイント:5/5
  4. アクションの設定
    • 「その他のアクション」→「Lambda 関数」を追加
    • 関数:rds-auto-reboot-on-high-cpu

4-2. Reader 用アラーム

同様に Reader インスタンス向けにアラームを作成します。

アクションは同じ Lambda 関数

DBInstanceIdentifier = myapp-prod-rdsinst2

アラーム名:rds-myapp-prod-rdsinst2-high-cpu-auto-reboot

Step 5. Lambda のリソースベースポリシーで CloudWatch からの起動を許可

ここが一番ハマったポイントです。

CloudWatch アラーム → Lambda 連携の principal は lambda.alarms.cloudwatch.amazonaws.com です。

cloudwatch.amazonaws.com では動きません。

5-1. アラームの ARN を確認

CloudWatch アラーム詳細画面に ARN が表示されます。例:

arn:aws:cloudwatch:ap-northeast-1:123456789012:alarm:rds-myapp-prod-rdsinst-high-cpu-auto-reboot
arn:aws:cloudwatch:ap-northeast-1:123456789012:alarm:rds-myapp-prod-rdsinst2-high-cpu-auto-reboot


5-2. CLI で Lambda に許可を追加

# writer アラームからの Invoke を許可
aws lambda add-permission \
  --region ap-northeast-1 \
  --function-name rds-auto-reboot-on-high-cpu \
  --statement-id AllowCloudWatchInvokeWriterRdsCpuAlarm \
  --action lambda:InvokeFunction \
  --principal lambda.alarms.cloudwatch.amazonaws.com \
  --source-account 123456789012 \
  --source-arn arn:aws:cloudwatch:ap-northeast-1:123456789012:alarm:rds-myapp-prod-rdsinst-high-cpu-auto-reboot

# reader アラームからの Invoke を許可
aws lambda add-permission \
  --region ap-northeast-1 \
  --function-name rds-auto-reboot-on-high-cpu \
  --statement-id AllowCloudWatchInvokeReaderRdsCpuAlarm \
  --action lambda:InvokeFunction \
  --principal lambda.alarms.cloudwatch.amazonaws.com \
  --source-account 123456789012 \
  --source-arn arn:aws:cloudwatch:ap-northeast-1:123456789012:alarm:rds-myapp-prod-rdsinst2-high-cpu-auto-reboot


statement-id は既存のものと重複しない任意の文字列で OK です。

Step 6. 本番での動作確認

6-1. まずは「Lambda が呼ばれる」だけ確認する

  1. アラームのしきい値を一時的に
    • 閾値:> 1
    • 評価データポイント:1/1 に変更
  2. 数分待ってアラーム状態になるのを確認
  3. CloudWatch の「アクション」タブに 「正常に実行されたアクション arn:aws:lambda:…」 と出ること
  4. Lambda の「モニタリング」タブで呼び出し回数が増えていること
  5. CloudWatch Logs に
Event: {"source": "aws.cloudwatch", "alarmArn": "..."}
Target DB instance: myapp-prod-rdsinst
...

が出ていれば、イベント解析までは成功です。

※ 本番でいきなり再起動させたくない場合は、事前に DynamoDB に「最近再起動した」時刻を書き込んでおき、is_recently_rebooted で True を返すようにしておけば実際の Reboot は走りません。

6-2. メンテナンス時間帯に、実際の再起動テストを 1 回だけ実施

  1. DynamoDB テーブル rds-auto-reboot-history から対象インスタンスのレコードを削除
  2. アラームのしきい値を > 1(1/1)に下げ、ALARM 状態に遷移させる
  3. Lambda ログで
Reboot started: myapp-prod-rdsinst

と出ることを確認

  1. RDS コンソールで、対象インスタンスが「再起動中」→「利用可能」と遷移することを確認
  2. テスト後はしきい値を本番値(> 90、5/5 など)に戻す

Reader 側も同様に 1 回だけテストしておくと安心です。

運用してみての所感・注意点

  • 本記事の仕組みはあくまで「延命・自動復旧」のための仕掛けであり、 根本的にはアプリケーション側の CPU 高負荷の原因を解消するべき です。
  • 再起動時には短時間の接続断が発生するため、アプリ側の再接続ロジックなどは事前に確認が必要です。
  • MIN_REBOOT_INTERVAL_MINUTES を短くしすぎると、負荷が下がらないまま何度も再起動を繰り返す可能性があります。 本番環境では 30〜60 分程度を目安に設定しました。
  • インスタンス名を変更したり、新しいインスタンスを追加した場合は、
    • IAM ロールのリソース ARN
    • CloudWatch アラームの対象メトリクス
    • Lambda の DynamoDB レコード などのメンテナンスも忘れずに行う必要があります。


以上、Aurora PostgreSQL の CPU 高負荷時に自動でインスタンス再起動を行う仕組みを、CloudWatch アラーム + Lambda + DynamoDB で実装した手順の紹介でした。