AWS CloudWatch 경보 Slack으로 전송하기
반응형

인스턴스의 상태를 모니터링 할 수 있는 최소한의 수단을 만들기 위해 간단하게 빠르게 해볼 수 있는 것 중에 경보 설정을 통한 slack알림 받기를 해보려고 한다.

서버에 문제가 발생하기 전에 사전 경보를 받아 더 빨리 대처하고 대비할 수 있게 하려는 의도를 가지고 AWS에서 제공하는 모니터링 지표를 활용하여 간단하게 설정해보았다.

SNS(Simple Notification Service) 주제 생성

먼저 주제 생성 하기 AWS 콘솔 접속 후 - SNS로 이동 - 주제 생성

Slack으로 전송하려면 Lambda를 사용해야 하기 때문에 표준을 선택하고 이름입력 후 생성한다.

아래 상세 설정은 보고 설정하고자 하는대로 설정하면 된다.

 

CloudWatch 경보 생성

이제 알림을 발생시키고자 모니터링을 할 경보를 설정해준다.

AWS 콘솔 접속 후 - CloudWatch로 이동 - 경보 생성

지표 및 조건 지정

내가 모니터링 하고자 하는 지표를 선택한다. 나는 현재 ECS에서 메모리사용량이 90 이상일 경우를 경보로 설정해보겠다.

SNS를 연결해준다.

이렇게 이름까지 설정해주고 경보 생성해주면 된다.

 

Slack WebHook URL 생성

이제 슬랙에서 알림 받기 원하는 채널에 웹훅URL을 생성해준다.

  • 슬랙 상단 채널명 클릭
  • 통합 탭 클릭
  • 앱 추가 클릭
  • webhooks 검색 후 추가
  • 수신 웹훅 URL 복사

이렇게 수신받을 웹훅을 생성했다면 Slack에 경보를 전달해줄 Lambda만 연결해주면 끝이다.

 

Lambda 함수 생성

AWS 콘솔 접속 후 - Lambda로 이동 - 함수 생성

기본 정보 입력 후 생성.

 

index.js 파일 수정

함수 코드는 잘 되어 있는 다른 포스팅을 참고하여 조금 수정 후 거의 그대로 사용하였습니다.

// 람다 구성탭 -> 환경변수 설정에서 웹훅 URL을 가져오도록 함.
const ENV = process.env
if (!ENV.webhook) throw new Error('Missing environment variable: webhook')

const webhook = ENV.webhook;
const https = require('https')

const statusColorsAndMessage = {
    ALARM: {"color": "danger", "message":"위험"},
    INSUFFICIENT_DATA: {"color": "warning", "message":"데이터 부족"},
    OK: {"color": "good", "message":"정상"}
}

const comparisonOperator = {
    "GreaterThanOrEqualToThreshold": ">=",
    "GreaterThanThreshold": ">",
    "LowerThanOrEqualToThreshold": "<=",
    "LessThanThreshold": "<",
}

exports.handler = async (event) => {
    await exports.processEvent(event);
}

exports.processEvent = async (event) => {
    const snsMessage = event.Records[0].Sns.Message;
    const postData = exports.buildSlackMessage(JSON.parse(snsMessage))
    await exports.postSlack(postData, webhook);
}

exports.buildSlackMessage = (data) => {
    const newState = statusColorsAndMessage[data.NewStateValue];
    const oldState = statusColorsAndMessage[data.OldStateValue];
    const executeTime = exports.toYyyymmddhhmmss(data.StateChangeTime);
    const description = data.AlarmDescription;
    const cause = exports.getCause(data);

    return {
        attachments: [
            {
                title: `[${data.AlarmName}]`,
                color: newState.color,
                fields: [
                    {
                        title: '발생일시',
                        value: executeTime
                    },
                    {
                        title: '설명',
                        value: description
                    },
                    {
                        title: '원인',
                        value: cause
                    },
                    {
                        title: '이전 상태',
                        value: oldState.message,
                        short: true
                    },
                    {
                        title: '현재 상태',
                        value: `*${newState.message}*`,
                        short: true
                    },
                    {
                        title: '바로가기',
                        value: exports.createLink(data)
                    }
                ]
            }
        ]
    }
}

// CloudWatch 알람 바로 가기 링크
exports.createLink = (data) => {
    return `https://console.aws.amazon.com/cloudwatch/home?region=${exports.exportRegionCode(data.AlarmArn)}#alarm:alarmFilter=ANY;name=${encodeURIComponent(data.AlarmName)}`;
}

exports.exportRegionCode = (arn) => {
    return  arn.replace("arn:aws:cloudwatch:", "").split(":")[0];
}

exports.getCause = (data) => {
    const trigger = data.Trigger;
    const evaluationPeriods = trigger.EvaluationPeriods;
    const minutes = Math.floor(trigger.Period / 60);

    if(data.Trigger.Metrics) {
        return exports.buildAnomalyDetectionBand(data, evaluationPeriods, minutes);
    }

    return exports.buildThresholdMessage(data, evaluationPeriods, minutes);
}

// 이상 지표 중 Band를 벗어나는 경우
exports.buildAnomalyDetectionBand = (data, evaluationPeriods, minutes) => {
    const metrics = data.Trigger.Metrics;
    const metric = metrics.find(metric => metric.Id === 'm1').MetricStat.Metric.MetricName;
    const expression = metrics.find(metric => metric.Id === 'ad1').Expression;
    const width = expression.split(',')[1].replace(')', '').trim();

    return `${evaluationPeriods * minutes} 분 동안 ${evaluationPeriods} 회 ${metric} 지표가 범위(약 ${width}배)를 벗어났습니다.`;
}

// 이상 지표 중 Threshold 벗어나는 경우 
exports.buildThresholdMessage = (data, evaluationPeriods, minutes) => {
    const trigger = data.Trigger;
    const threshold = trigger.Threshold;
    const metric = trigger.MetricName;
    const operator = comparisonOperator[trigger.ComparisonOperator];

    return `${evaluationPeriods * minutes} 분 동안 ${evaluationPeriods} 회 ${metric} ${operator} ${threshold}`;
}

// 타임존 UTC -> KST
exports.toYyyymmddhhmmss = (timeString) => {

    if(!timeString){
        return '';
    }

    const kstDate = new Date(new Date(timeString).getTime() + 32400000);

    function pad2(n) { return n < 10 ? '0' + n : n }

    return kstDate.getFullYear().toString()
        + '-'+ pad2(kstDate.getMonth() + 1)
        + '-'+ pad2(kstDate.getDate())
        + ' '+ pad2(kstDate.getHours())
        + ':'+ pad2(kstDate.getMinutes())
        + ':'+ pad2(kstDate.getSeconds());
}

exports.postSlack = async (message, slackUrl) => {
    return await request(exports.options(slackUrl), message);
}

exports.options = (slackUrl) => {
    const {host, pathname} = new URL(slackUrl);
    return {
        hostname: host,
        path: pathname,
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
    };
}

function request(options, data) {
    return new Promise((resolve, reject) => {
        const req = https.request(options, (res) => {
            res.setEncoding('utf8');
            let responseBody = '';

            res.on('data', (chunk) => {
                responseBody += chunk;
            });

            res.on('end', () => {
                resolve(responseBody);
            });
        });

        req.on('error', (err) => {
            reject(err);
        });

        req.write(JSON.stringify(data));
        req.end();
    });
}

 이 코드를 람다의 index.js파일에 넣어주고 Deploy버튼 클릭

 

구성탭 -> 환경변수 설정

아래와 같이 설정해준다 값에는 아까 생성해서 복사해둔 슬랙 웹훅 URL 입력

 

sns 트리거 추가 

함수 개요에서 트리거 추가 클릭

SNS 선택하고 아까 만들어둔 SNS 선택하고 추가해주면 끝이다.

 

테스트

간단하게 람다에서 JSON을 이용해서 테스트를 해볼 수 있다.

아래 JSON 양식을 이용하면 된다.

{
  "Records": [
    {
      "EventSource": "aws:sns",
      "EventVersion": "1.0",
      "EventSubscriptionArn": "arn:aws:sns:ap-northeast-2:981604548033:alarm-test:test",
      "Sns": {
        "Type": "Notification",
        "MessageId": "test",
        "TopicArn": "arn:aws:sns:ap-northeast-2:123123:test-alarm",
        "Subject": "ALARM: \"RDS-MemoryUtilization-high\" in Asia Pacific (Seoul)",
        "Message": "{\"AlarmName\":\"Memory High Alarm(90%이상시)\",\"AlarmDescription\":\"Memory High Alarm(90%이상시)\",\"AWSAccountId\":\"981604548033\",\"NewStateValue\":\"ALARM\",\"NewStateReason\":\"Threshold Crossed: 3 out of the last 3 datapoints [8.891518474692088 (14/07/21 23:18:00), 9.72 (14/07/21 23:17:00), 9.18241509182415 (14/07/21 23:16:00)] were greater than or equal to the threshold (7.0) (minimum 3 datapoints for OK -> ALARM transition).\",\"StateChangeTime\":\"2021-07-14T23:20:50.708+0000\",\"Region\":\"Asia Pacific (Seoul)\",\"AlarmArn\":\"arn:aws:cloudwatch:ap-northeast-2:981604548033:alarm:Aurora PostgreSQL CPU 알람 (60%이상시)\",\"OldStateValue\":\"OK\",\"Trigger\":{\"MetricName\":\"CPUUtilization\",\"Namespace\":\"AWS/RDS\",\"StatisticType\":\"Statistic\",\"Statistic\":\"MAXIMUM\",\"Unit\":null,\"Dimensions\":[{\"value\":\"aurora-postgresql\",\"name\":\"EngineName\"}],\"Period\":60,\"EvaluationPeriods\":3,\"ComparisonOperator\":\"GreaterThanOrEqualToThreshold\",\"Threshold\":7,\"TreatMissingData\":\"- TreatMissingData:                    ignore\",\"EvaluateLowSampleCountPercentile\":\"\"}}",
        "Timestamp": "2024-01-07T11:31:17.380Z",
        "SignatureVersion": "1",
        "MessageAttributes": {}
      }
    }
  ]
}

람다 테스트 탭에서 테스트를 생성하고 실행하면 끝

슬랙에 잘 전송된 것을 확인할 수 있다.

반응형