Devops/Aws

AWS CloudWatch + SNS + lambda + Slack 알람 연동

Say simple 2023. 7. 18. 12:01
728x90
반응형

EC2의 상태를 감시해 경보가 울렸을 때 Slack을 통해 알람을 받고 싶어서 모니터링을 구축하게 되었다. 이 방법을 통해 알람을 구축하면 AWS에서 제공하는 여러 지표들을 통해서 EC2의 상태가 불안정할 때 Slack을 통해 알람을 받을 수 있고, 그에 따라 적절한 조치를 취할 수 있게 된다.

 

Slack이랑 웹훅 연동

Slack의 앱추가에서 incoming webhook을 검색해 추가 클릭

Slack에 추가를 누름

채널을 선택한 후에 수신 웹후크 통합 앱 추가 버튼을 클릭하면 채널에 추가됨 메시지가 뜸과 동시에 http 요청을 보낼 slack URL이 생성됨.

추가된 URL을 메모장에 잘 복사해둔다.

AWS SNS로 주제 생성

주제 생성 클릭

표준 클릭, 이름을 입력하고 주제 생성

CloudWatch로 알람 구성

CloudWatch에서 경보 생성 클릭

지표 선택 클릭

EC2 선택

인스턴스별 지표 선택

검색에 CPUUtilization 을 입력하면 인스턴스들의 해당 지표 항목만 볼 수 있다. 원하는 지표를 클릭하면 아래와 같이 그래프를 미리 볼 수 있다. 지표 선택 클릭

아래와 같이 값을 입력해주고 다음 클릭

  • CPU 평균 사용량 이므로 평균
  • CPU 사용량은 실시간 값이므로 해당 지표에서 가장 짧게 확인할 수 있는 주기인 1분 선택
  • 정적인  70 보다 크거나 같을 때 경보 울림

SNS 주제를 선택하고 다음 클릭

경보 이름 설정, 규칙은 자유 내가 쓴 규칙은 앱이름-지표이름-조건(greater then and equal -> gte)

미리보기로 확인하고 경보 생성

Slack에 메세지를 보낼 람다 함수 생성

아래와 같이 설정하고 함수 생성, node 16 버전 코드를 사용 할 것이므로 16으로 골라줌

트리거 추가 클릭

SNS를 선택하고 미리 생성해둔 주제 선택 후 추가 클릭

아래와 같이 이벤트 트리거가 생성된 것을 볼 수 있음

위에서 코드 탭을 클릭한 후 index.js에 아래 코드를 추가

// 구성 -> 환경변수로 webhook을 받도록 합니다.
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();
    });
}

구성 탭을 클릭한 후 환경 변수 탭에서 webhook 환경 변수를 추가해줌. 이 변수가 메세지를 보낼 슬랙 URL이 됨

경보 상태가 되면 연동된 SNS에 시그널이 가고 람다 함수가 실행됨

테스트 설정에 들어가서 아래와 같이 테스트 데이터를 입력한다.

{
  "Records": [
    {
      "EventSource": "aws:sns",
      "EventVersion": "1.0",
      "EventSubscriptionArn": "arn:aws:sns:ap-northeast-2:981604548033:alarm-topic:test",
      "Sns": {
        "Type": "Notification",
        "MessageId": "test",
        "TopicArn": "arn:aws:sns:ap-northeast-2:123123:test-alarm-topic",
        "Subject": "ALARM: \"MFC 모바일 테스트 CPU\" in Asia Pacific (Seoul)",
        "Message": "{\"AlarmName\":\"MFC 모바일 테스트 CPU 알람 (80%이상시)\",\"AlarmDescription\":\"MFC 모바일 테스트 CPU 알람 (80%이상시)\",\"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": "9999-99-99T99:99:99.999Z",
        "SignatureVersion": "1",
        "MessageAttributes": {}
      }
    }
  ]
}

테스트 버튼을 누르면 테스트 알람을 보낼 수 있다.

실서버에서 테스트를 하고 싶다면 stress를 이용하면 된다. 설치되어 있지 않다면 아래 명령어로 설치해주자.

yum -y install epel-release // EPEL 레포지터리 활성화
yum -y install stress // stress 패키지 설치

stress를 입력하면 아래와 같이 도움말을 볼 수 있다. 

아래와 같이 각자의 CPU 갯수 등에 맞게 설정해서 부하를 주자.

stress --cpu 4 --io 4 --timeout 600

CloudWatch 지표에 반영되는데는 시간이 약 5분 정도 더 걸릴 수 있다. 반영되고 난 후엔 위에서 설정한 알람에서 부하량을 확인할 수 있으며 정해진 부하량을 넘으면 알람이 오는 것을 확인할 수 있다.

728x90
반응형