<-- mermaid -->

入社したらAWSコンソールにCloudWatchアラームが1000個以上あったので整理してる話

こんにちはNewsPicks SREチームの飯野です。

今年の1月入社の新入社員です。そろそろお仕事に慣れてきました。今回は研修と研修の合間に地道に行っていたCloudWatchアラームの整理について話していきたいと思います。ちょっと長くなりますがお付き合いください。

よくわからないしアラームを整理しよう

NewsPicksの本番環境のAWSのアカウントにはCloudWatchアラームは1000個以上あります。

NewsPicksは2013年にサービス開始し10年続いているサービスなので、サービスの成長とともに監視の設定も増えていったのだと思います。積み重ねを感じますね。

「しばらくはみてみぬふりを、、」と考えていたのですが、次の理由から整理、深掘りしようと思い直しました。

  • 序盤で振られたお仕事*1の中で、一部のアラームを整理することになりました。
    • 既存のアラームの確認が必要になりました。
  • 1000個あるアラームのほとんどはIaC化されてません。
    • これだけあればメンテナンスされていないアラームがたくさんありそうです。
  • 今月からSRE*2になったのです。
    • NewsPicksのSREチームはサイト信頼性と開発者体験の向上をミッションとしています。
    • 適切な監視の設定と開発者の認知負荷を減らすことは重要だと考えました。

他チームに説明できないリソースは減らしていきたいですよね。

まずはスプレッドシートで一覧してみよう

1000個のアラームをマネジメントコンソールで一つ一つ確認していくのは骨が折れるのでデータをスプレッドシートで一覧します。

今回は次のスクリプトを作成してTSVを出力し、スプレッドシートで一覧しました。

スクリプト list-alarms.ts を見る

import {
    CloudWatchClient,
    DescribeAlarmsCommand,
    DescribeAlarmsCommandInput,
    DescribeAlarmsCommandOutput,
    MetricAlarm,
} from "@aws-sdk/client-cloudwatch";
import * as ARNParser from "@aws-sdk/util-arn-parser";

async function getAllMetricAlarms(
    cloudWatch: CloudWatchClient,
    input: DescribeAlarmsCommandInput
): Promise<MetricAlarm[]> {
    let metricAlarms: MetricAlarm[] = [];
    let nextToken = undefined;
    do {
        const response: DescribeAlarmsCommandOutput = await cloudWatch.send(
            new DescribeAlarmsCommand({ ...input, NextToken: nextToken })
        );
        metricAlarms = metricAlarms.concat(response.MetricAlarms ?? []);
        nextToken = response.NextToken;
    } while (nextToken);

    return metricAlarms;
}

function getResourceName(arn: string): string {
    return ARNParser.parse(arn).resource;
}

async function main(): Promise<void> {
    const cloudWatch = new CloudWatchClient({});
    const metricAlarms = await getAllMetricAlarms(cloudWatch, {});

    const header = [
        "AlarmName",
        "MetricName",
        "Namespace",
        "DatapointsToAlarm",
        "EvaluationPeriods",
        "ComparisonOperator",
        "Threshold",
        "OKActions",
        "AlarmActions",
        "InsufficientDataActions",
    ].join("\t");
    console.log(header);

    for (const metricAlarm of metricAlarms) {
        const line = [
            metricAlarm.AlarmName,
            metricAlarm.MetricName,
            metricAlarm.Namespace,
            metricAlarm.DatapointsToAlarm,
            metricAlarm.EvaluationPeriods,
            metricAlarm.ComparisonOperator,
            metricAlarm.Threshold,
            (metricAlarm.OKActions ?? []).map(getResourceName),
            (metricAlarm.AlarmActions ?? []).map(getResourceName),
            (metricAlarm.InsufficientDataActions ?? []).map(getResourceName),
        ].join("\t");
        console.log(line);
    }
}

main();

整理の方針を決めよう

一覧できたところで整理の方針を決めます。最初は次の方針で調べていくことにしました。

  • AutoScaling/ApplicationInsightsのアラームは今回の調査の対象外とする。
    • DynamoDBやECSなどにより自動的に作成されるものです。
  • Action(特にAlarmAction)の有無を確認する
    • アラームがアラームとして機能しません。設定が不完全の可能性があります。
  • ActionのSNS TopicのSubscriptionを調べる
    • 適切な場所に通知しているか調べます。

整理していくうちに次の条件も調査することにしました。

  • (データ不足のアラームの)メトリクスの対象の状態を確認する
    • メトリクスの対象が存在しない場合、削除できる可能性があります。

さまざまな問題をかかえたアラームたち

一覧し整理していく中で見つかったアラームたち、順番に事例を紹介していきます。

Case#1 AlarmActionが未設定のアラーム(5個)

Actionが未設定ではアラームとして機能しません。ということで、まず最初にActionが設定されているかを調査しました。

今回はApplicationInsightsで8個、人が作成したもので5個見つかりました。

AlarmActionが未設定のアラーム

  • 対応
    • アラームを設定したチームに連絡後、AlarmActionを設定しました。
    • 5件中1件については別途対処中とのことで、一時的にアラームを無効化することに決定しました。

Case#2 ActionのSNSトピックが存在しないアラーム(16個)

Actionが未設定のアラームに対応できたので、次はActionの中身を調べていきます。スプレッドシートでフィルターを作成し、フィルターの項目を眺めます。すると面白いActionが見つかりました。

SNSトピック: CloudWatchくん

CloudWatch 。強そうな名前です。

「へーこういうSNSトピックがあるのかー」と思って調べてみたところ、存在しませんでした。

存在感はすごいです。

  • 対応
    • アラームを設定したチームに現状を共有後、Actionを通知できるSNS Topicに差し替えました。

Actionを差し替えるのはちょっと手間

「Actionを差し替える」、口で言うのは簡単ですが、実施するのは意外と手間です。aws cliではではCloudWatchアラームの部分更新は行えません。

今回は次のようなスクリプトを用意してActionの追加・削除を行うことにました*3

スクリプト replace-actions.ts を見る

import {
    CloudWatchClient,
    DescribeAlarmsCommand,
    MetricAlarm,
    PutMetricAlarmCommand,
    PutMetricAlarmCommandInput,
} from "@aws-sdk/client-cloudwatch";
import { Command } from "commander";

async function getMetricAlarms(
    cloudWatch: CloudWatchClient,
    alarmName: string
): Promise<MetricAlarm[]> {
    console.log([alarmName]);
    const response = await cloudWatch.send(
        new DescribeAlarmsCommand({ AlarmNames: [alarmName] })
    );
    return response.MetricAlarms ?? [];
}

function replaceActions(
    actions: string[],
    oldAction: string,
    newAction: string
): string[] {
    return actions.map((action) => (action == oldAction ? newAction : action));
}

async function replaceMetricAlarmAction(
    alarmName: string,
    oldAction: string,
    newAction: string
): Promise<void> {
    const cloudWatch = new CloudWatchClient({});
    const metricAlarms = await getMetricAlarms(cloudWatch, alarmName);

    for (const metricAlarm of metricAlarms) {
        const newOkActions = replaceActions(
            metricAlarm.OKActions ?? [],
            oldAction,
            newAction
        );
        const newAlarmActions = replaceActions(
            metricAlarm.AlarmActions ?? [],
            oldAction,
            newAction
        );
        const newInsufficientDataActions = replaceActions(
            metricAlarm.InsufficientDataActions ?? [],
            oldAction,
            newAction
        );

        console.log("before: ----------");
        console.log(metricAlarm);

        let input: PutMetricAlarmCommandInput = {
            AlarmName: metricAlarm.AlarmName ?? "",
            AlarmDescription: metricAlarm.AlarmDescription,
            ActionsEnabled: metricAlarm.ActionsEnabled,
            OKActions: newOkActions,
            AlarmActions: newAlarmActions,
            InsufficientDataActions: newInsufficientDataActions,
            //MetricName: metricAlarm.MetricName,
            Namespace: metricAlarm.Namespace,
            Statistic: metricAlarm.Statistic,
            ExtendedStatistic: metricAlarm.ExtendedStatistic,
            //Dimensions: metricAlarm.Dimensions,
            Period: metricAlarm.Period,
            Unit: metricAlarm.Unit,
            EvaluationPeriods: metricAlarm.EvaluationPeriods ?? 5,
            DatapointsToAlarm: metricAlarm.DatapointsToAlarm,
            Threshold: metricAlarm.Threshold,
            ComparisonOperator: metricAlarm.ComparisonOperator ?? "",
            TreatMissingData: metricAlarm.TreatMissingData,
            EvaluateLowSampleCountPercentile:
                metricAlarm.EvaluateLowSampleCountPercentile,
            //Metrics: metricAlarm.Metrics,
            // Tags : nothing
            ThresholdMetricId: metricAlarm.ThresholdMetricId,
        };

        if (metricAlarm.MetricName) {
            input = Object.assign(input, {
                MetricName: metricAlarm.MetricName,
                Dimensions: metricAlarm.Dimensions,
            });
        } else {
            input = Object.assign(input, {
                Metrics: metricAlarm.Metrics,
            });
        }

        console.log("update request: ----------");
        console.log(input);
        const result = await cloudWatch.send(new PutMetricAlarmCommand(input));
        console.log("result: ----------");
        console.log(result);
    }

    const afterMetricAlarms = await getMetricAlarms(cloudWatch, alarmName);
    for (const metricAlarm of afterMetricAlarms) {
        console.log("after: ----------");
        console.log(metricAlarm);
    }
}

async function main(): Promise<void> {
    const program = new Command();
    program
        .argument("alarmName")
        .argument("oldAction")
        .argument("newAction")
        .action(replaceMetricAlarmAction);

    await program.parseAsync(process.argv);
}

main();

Case#3 ActionのSNSトピックの通知先が退職した社員のメールアドレス(97個)

CloudWatch 以外にも強そうな名前のトピックがありました。

その名も dynamodbです。

SNSトピック: dynamodbくん

こちらは大文字が使われていないので CloudWatch よりもひかえめな名前です。名前通りDynamoDBのテーブルに対するアラームに設定されいます。

「どうせ存在しないSNSトピックだろう」と思って調べてみると、こちらはしっかり存在しているSNSトピックでした。通知先は個人名のメールアドレスです。

個人宛のサブスクリプション

すぐにslackで質問しようと検索したところ、解除済みのユーザーでした。

解除済みのユーザー。ちょっと寂しいですね
時の流れを感じます。

チームと相談した結果、

  • アラームとして機能していない
  • DynamoDBは別の手段(NewRelicなど)でも監視している

などの理由からアラームは削除することになりました。

ところで、見つかったアラームにはStg-という接頭語がついているものがありました。しかし現在はSTG環境は廃止*4されています。この部分についてはCase#4に続きます。

  • 対応
    • 先にCase#4の対応を行いました。
    • 残りについては、DynamoDBは別(NewRelicなど)でも監視しているためCloudWatchアラームとしては削除することになりました。
    • トピックとサブスクリプションも削除しました。

Case#4 監視先のDynamoDBのテーブルがすでに存在しないアラーム(97個中の85個)

STG環境は廃止されているため、Stgから始まるアラームは対象のテーブルが存在していない可能性が高いです。もしかしたらアラーム名と監視対象のテーブルが一致していない場合もありそうです。

今回は次のスクリプトで存在しないことを確認し、チームに状況を共有してから削除を行いました。

スクリプト check-alarm-dynamodb-table.ts を見る

import {
    CloudWatchClient,
    DescribeAlarmsCommand,
    MetricAlarm,
} from "@aws-sdk/client-cloudwatch";
import { DescribeTableCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";

async function getMetricAlarms(
    cloudWatch: CloudWatchClient,
    alarmName: string
): Promise<MetricAlarm[]> {
    const response = await cloudWatch.send(
        new DescribeAlarmsCommand({ AlarmNames: [alarmName] })
    );
    return response.MetricAlarms ?? [];
}

// アラーム名をスプレッドシートからコピペする
const alarmNames = `
Stg-xxxxx
Stg-yyyy
Stg-zzzz
`
    .split("\n")
    .filter((x) => x != "");

async function main(): Promise<void> {
    const cloudWatch = new CloudWatchClient({});
    const dynamoDb = new DynamoDBClient({});
    for (const alarmName of alarmNames) {
        const metricAlarms = await getMetricAlarms(cloudWatch, alarmName);
        for (const metricAlarm of metricAlarms) {
            if (metricAlarm.Namespace != "AWS/DynamoDB") {
                console.log(`${alarmName}\t\tskip`);
                continue;
            }

            const dimension = (metricAlarm.Dimensions ?? [])[0] ?? {};
            //console.log(dimension)
            if (dimension.Name == "TableName") {
                const tableName = dimension.Value;

                try {
                    const response = await dynamoDb.send(
                        new DescribeTableCommand({ TableName: tableName })
                    );
                    if (response.Table) {
                        console.log(`${alarmName}\t${tableName}\texist`);
                    } else {
                        console.log(`${alarmName}\t${tableName}\tnotfound`);
                    }
                } catch (Error) {
                    console.log(`${alarmName}\t${tableName}\tnotfound`);
                }
            }
        }
    }
}

main();

  • 対応
    • アラームの監視対象のDynamoDBテーブルの状況確認後、削除しました。

Case#5 監視先のEC2インスタンスがすでに存在しないアラーム(27個)

Case#4で監視対象がすでに存在しないアラームを扱いました。他にも同様のケースがあるかもしれません。次はCloudWatchアラームの一覧で状態がデータ不足のアラームを調べてみます。

データ不足

今回はEC2を監視するデータ不足のアラームが見つかりました。

これらについてもDynamoDBと同様に対象のインスタンスが存在しないことを確認し、チームに状況を共有してから削除を行いまいた。

スクリプト check-alarm-ec2-instance.ts を見る

import {
    CloudWatchClient,
    DescribeAlarmsCommand,
    MetricAlarm,
} from "@aws-sdk/client-cloudwatch";
import { DescribeInstancesCommand, EC2Client } from "@aws-sdk/client-ec2";

async function getMetricAlarms(
    cloudWatch: CloudWatchClient,
    alarmName: string
): Promise<MetricAlarm[]> {
    const response = await cloudWatch.send(
        new DescribeAlarmsCommand({ AlarmNames: [alarmName] })
    );
    return response.MetricAlarms ?? [];
}

// アラーム名をスプレッドシートからコピペする
const alarmNames = `
Stg-xxxxx
Stg-yyyy
Stg-zzzz
`
    .split("\n")
    .filter((x) => x != "");

async function main(): Promise<void> {
    const cloudWatch = new CloudWatchClient({});
    const ec2 = new EC2Client({});
    for (const alarmName of alarmNames) {
        const metricAlarms = await getMetricAlarms(cloudWatch, alarmName);
        for (const metricAlarm of metricAlarms) {
            if (metricAlarm.Namespace != "AWS/EC2") {
                console.log(`${alarmName}\t\tskip`);
                continue;
            }

            const dimension = (metricAlarm.Dimensions ?? [])[0] ?? {};
            //console.log(dimension)
            if (dimension.Name == "InstanceId") {
                const instanceId = dimension.Value ?? "";

                try {
                    const response = await ec2.send(
                        new DescribeInstancesCommand({
                            InstanceIds: [instanceId],
                        })
                    );
                    if ((response.Reservations ?? []).length == 0) {
                        console.log(`${alarmName}\t${instanceId}\texist`);
                    } else {
                        console.log(`${alarmName}\t${instanceId}\tnotfound`);
                    }
                } catch (Error) {
                    console.log(`${alarmName}\t${instanceId}\tnotfound`);
                }
            }
        }
    }
}

main();

  • 対応
    • アラームの監視対象のEC2インスタンスの状況確認後、削除しました。

Case#6 通知先が誰も監視していないSlackチャンネルのアラーム(97個)

ここまでの作業で、通知設定を行っていないアラームや通知先が存在しないアラームに対応できました。最後に通知先のSlackチャンネルが適切か調べます。

この時点で通知先が6種くらいに絞られているので地道に調べていき、チームに確認をとります。

確認の結果、あるTopicの通知先が現在運用されていないSlackチャンネルと判明したので、通知先を差し替えることにしました。

  • 対応
    • アラームを設定したチームに現状を共有後、Actionを通知できるSNSトピックに差し替えました。
      • Case#2で作成したスクリプトを利用しました。

整理した結果

  • すべてのアラームが機能するようになり、適切な場所に通知が届くようになりました。
  • 管理者不在のアラームを削除することができました。
    • 100個以上≒10%を削除できました。

すべてのアラームが機能していると自信をもって言えるのは気持ちがいいです。ノイズが消えることで認知負荷はかなり軽減されたと思います。

やってみての学び

  • データを一覧するだけでも効果があります。
    • Case#1、Case#2、Case#3などは眺めているだけで怪しさに気づけました。
    • 「Actionの組み合わせはN件」とわかるだけでも精神的な負荷が減り対応しやすくなります。
  • 調査するほど対応方法の知識が深まっていきます。
    • 簡単なところから手をつけていきだんだん深掘りできるようになります。
      • Case#3 → Case#4
    • データ不足は監視対象がいない→削除できるかもしれないなど。
  • 話しかけるきっかけになります。
    • それぞれのシステムの担当者に話しかけるきっかけになるのはよかったです。
    • あまりメンテナンスされていないシステムを掘り起こすのは少し面白いです。
  • 整理はどのタイミングで始めても効果がありそうです。
    • 新入社員が研修の合間に始めても、通知の不具合を見つけるなど、思ったより成果が出てしまいました。

と、こんなことを書いている間にも新しいデータ不足アラームが登場していました。

Case#7 監視先のEC2インスタンスが作り直されてしまったアラーム(3個)

監視先のEC2インスタンスが作り直されてしまったアラーム

インスタンスの作り直しで、インスタンスIDが変更されたようです。そうですよね、当然ありますよね。

チームに確認したところ、SREで管理しているインスタンスに対するアラームでした!!1

  • 対応
    • 監視対象のインスタンスIDを更新

最後に

注意していても発生してしまう割れ窓。継続的にお掃除していきます。

*1:「既存のElastiCache向けアラーム(200個以上)の閾値を調整したい」のでこのタイミングでアラームをIaCで定義し直す

*2:前職はWebアプリのバックエンドエンジニアでした

*3:もっと簡単な方法があればブクマコメントなどで教えてもらえると嬉しいです

*4:STG 環境は世間一般で言うステージング環境、本番と同等の環境で動作確認を行うための用途ではなく開発の用途で使用されていた経緯がありました。現在は開発用のAWSアカウントに開発環境を用意しているため、本番用のAWSアカウントのSTG環境は不要になりました。

Page top