AWS CloudWatch Metric Alert to Slack Message using Terraform


AWS CloudWatch Metric에 기록되는 모든 지표에 대해서 CloudWatch Alarm를 이용하여 Alert 시스템을 구현할 수 있다. AWS 주요 resource들의 monitoring에 나타나는 모든 것은 CloudWatch Metric에 기록이 되는 것이므로 가능하다. 그 결과를 Slack 메세지로 발송하면 되는데 그 원리는 간단하다.

  • AWS 주요 Resource들은 CloudWatch LogCloudWatch Metric을 모니터링으로 활용한다.
  • CloudWatch AlarmCloudWatch Metric에 기록된 것을 특정 조건과 비교하여 경고 발송이 가능하다.
  • CloudWatch Alarm은 Email과 AWS SNS에 경고를 발송할 수 있다.
  • AWS SNS TopicAWS Lambda가 subscribe할 수 있다. (다른 말로는 SNS가 Labmda를 Invoke 할 수 있다.)
  • AWS Lambda에서 Javascript등의 코드를 활용하여 Slack으로 메세지를 발송 할 수 있다.

Terraform을 이용해서 위에 설명한 것이 동작하는 코드를 작성해보자. 참고로 2022년 01월 기준으로 동작하는 코드이다. 3년전에 작성했던 테라폼 코드로 처음 시도했을때 동작하지 않았다. 그 이유는 예전에는 없었던 또는 강제시 되지 않았던 정책/허용 같은 것들이 필수화 되어서였다.

1. Slack 발송을 위한 Javascript 코드

AWS Lambda에서 실행한 Javascript코드를 먼저 살펴보자.

'use strict';
let https = require('https');

const options = {
  host: 'hooks.slack.com',
  port: '443',
  path: '/services/웹훅주소/웹훅주소',
  method: 'POST',
  headers: { 'Content-Type': 'application/json' }
};

const parseSnsRecord = (event) => {
  if (!(event.Records && event.Records[0] && event.Records[0].EventSource && event.Records[0].EventSource === 'aws:sns'))
    return '';

  const data = event.Records[0].Sns;
  const dataMessage = JSON.parse(data.Message);
  console.log(dataMessage);
  
  const statusValue = dataMessage.NewStateValue;
  const channelMention = statusValue == 'OK' ? '' : '<!channel>'
  const name = dataMessage.AlarmName;
  
  let message = `${channelMention} ${statusValue} ${name}\n`;
  message += `- ${dataMessage.NewStateReason}\n`;
  message += `- ${dataMessage.Trigger.Namespace} ${dataMessage.Trigger.MetricName}\n`
  message += `- ${data.Timestamp}\n`;
  message += `- ${dataMessage.AWSAccountId}\n`;

  return message
}

exports.handler = (event, context, callback) => {
  const req = https.request(options, (res) => {
    let body = '';
    console.log('Status:', res.statusCode);
    console.log('Headers:', JSON.stringify(res.headers));
    res.setEncoding('utf8');
    res.on('data', (chunk) => body += chunk);
    res.on('end', () => {
      console.log('Successfully processed HTTPS response');
      if (res.headers['content-type'] === 'application/json') {
        body = JSON.parse(body);
      }
      callback(null, body);
    });
  });
  
  const payload={ "text": parseSnsRecord(event) }
  console.log(payload.event);

  req.on('error', callback);
  req.write(JSON.stringify(payload));
  req.end();
};

간단히 설명하자면 SNS에서 전달해오는 JSON 형식의 메세지를 파싱하여 원하는 정보만 추려서 메세지를 만든다. 해당 메세지를 Slack Webhook으로 발송한다. CloudWatch Alarm에서 OK -> Alert, Alert -> OK 두 가지 상황에 대해서 모두 발송하도록 설정을 하였다. 그래서 OK -> Alert로 되는 상황에서만 @channel 멘션을 사용한다.

2. AWS Lambda 생성

먼저 Lambda에 부여할 role을 생성한다. CloudWatchLog를 생성할수 있는 CloudWatchLogsFullAccess를 함께 부여했다.

resource aws_iam_role lambda {
  name = "lambda"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
  tags = local.module_tags
}

resource aws_iam_role_policy_attachment lambda_CloudWatchLogsFullAccess {
  role       = aws_iam_role.lambda.name
  policy_arn = "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
}

이제 Lambda Function을 생성하면서 위에 작성한 javascript 코드를 업로드 하고, SNS에서 Invoke가 가능하도록 권한을 부여하자. 참고로 모든 SNS에 대해서 Invoke 가능하도록 설정하였다.

variable account {
  type = number
  default = 1234567890
}

resource aws_lambda_function slack_notify {
  function_name = "slack_notify"
  role          = aws_iam_role.lambda.arn
  filename      = "./script/slack_notify.zip"
  handler       = "index.handler"

  source_code_hash = filebase64sha256("./script/slack_notify.zip")

  runtime = "nodejs14.x"
}

resource aws_lambda_permission allow_sns {
  statement_id  = "AllowExecutionFromSns"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.slack_notify.function_name
  principal     = "sns.amazonaws.com"
  source_arn    = "arn:aws:sns:ap-northeast-2:${var.account}:*"
}

var.account는 해당 AWS Account(숫자값)을 저장한 변수이다. 위에 작성한 Javascript 코드는 ./script/slack_nofify/index.js라는 이름으로 작성했다면 slack_notify.zip파일을 생성하기 위해서 아래의 명령어를 수행하면 된다.

cd ./script/slack_notify
zip ../slack_notify.zip index.js

3. SNS Topic & Subscription 생성

resource aws_sns_topic slack_notification {
  name            = "slack_notification"
  display_name    = ""
  policy          = <<POLICY
{
  "Version": "2008-10-17",
  "Id": "__default_policy_ID",
  "Statement": [
    {
      "Sid": "AllowPublishAlarms",
      "Effect": "Allow",
      "Principal": {
        "Service": [
          "cloudwatch.amazonaws.com"
        ]
      },
      "Action": "sns:Publish",
      "Resource": "arn:aws:sns:ap-northeast-2:${var.account}:slack_notification",
      "Condition": {
        "ArnLike": {
          "aws:SourceArn": "arn:aws:cloudwatch:ap-northeast-2:${var.account}:alarm:*"
        }
      }
    }
  ]
}
POLICY
}

resource aws_sns_topic_subscription sns_subs_slack_notify_to_lambda {
  topic_arn                       =  aws_sns_topic.slack_notification.arn
  protocol                        = "lambda"
  endpoint                        =  aws_lambda_function.slack_notify.arn
  raw_message_delivery            = "false"
}

위에서 Lambda Function 생성시 SNS에서 Invoke가능하게 설정을 해준것처럼, SNS Topic생성시 CloudWatch Alarm에서 publish가 가능하도록 설정해주어야 한다. 생성한 topic을 subscribe하여 lambda function을 invoke하도록 생성하였다.

4. CloudWatch Alarm 생성

예전에는 AWS 콘솔에서 각 리소스별 모니터링 탭에서 여러가지 그래프를 보는 화면에서 바로 Alarm 설정 버튼이 보였는데, 요즘은 많이 보이지 않는다. 그래서 CloudWatch 서비스로 들어가서 Alarm -> Create Alarm -> Select Metric 등의 과정을 거쳐야 한다. 테라폼 공식 문서의 aws_cloudwatch_metric_alarm을 보고 바로 작성하기에도 막막하다.

필자가 추천하는 방법은 일단 콘솔에서 Create Alarm을 수행하면서 화면에 나오는 항목들을 보고 테라폼으로 옮기는 것이다. 다른 부분들은 어렵지 않은데, Metric관련 부분들은 선택가능한 값들에 대해서 정리된 문서가 AWS에서 제공해주기는 하지만, 그것만 보고 작성하기에는 불편하다.

콘솔을 이용해서 Metric 설정을 완료한 후 그 화면을 보고 작성하면 훨씬 쉽다.

작성된 예제를 보자.

resource aws_cloudwatch_metric_alarm rds {
  alarm_name                = "RDS_testdb_CPU"
  comparison_operator       = "GreaterThanOrEqualToThreshold"
  evaluation_periods        = 1
  threshold                 = 60
  alarm_description         = ""
  insufficient_data_actions = []

  metric_query {
    id = "cpu"
    return_data = true

    metric {
      metric_name = "CPUUtilization"
      namespace   = "AWS/RDS"
      period      = 60
      stat        = "Maximum"

      dimensions = {
        DBClusterIdentifier = "testdb"
      }
    }
  }

  actions_enabled     = true
  alarm_actions       = [aws_sns_topic.slack_notification.arn]
  ok_actions          = [aws_sns_topic.slack_notification.arn]

  tags = local.module_tags
}

위 예제는 Alarm -> OK, OK -> Alarm으로 변하는 2가지 경우에 대해서 같은 SNS Topic으로 publish한다. 해당 Topic을 subscribe해서 invoke하는 labmda function에서 status를 보고 메세지에 @channel멘션 포함여부를 변경하도록 작성하였다.

그림에 있는 Namespace, Metric name, Statistic, Period항목을 보고 테라폼으로 그래도 값을 옮긴다. 참고로 period에는 초단위로 환산해서 적어주어야 한다. DBClusterIdentifier: testdb는 각 metric마다 key도 함께 바뀌는데 그 값을 dimensions에 적어주면 된다.

CloudFront는 AWS 내의 resource를 target으로 가져야한다. 모든 요청들을 다 다른 곳으로 redirect하더라도 아무것도 없는 빈 website라도 하나 만들어야 CloudFront를 띄울 수가 있다. 만약 이미 운영중인 static website가 있다면 이번 단계를 건너뛰고 바로 다음 단계로 넘어가면 된다. 이번 단계는 상세한 설명없이 바로 Terraform코드로 대체하겠다.

테라폼 공식문서 aws_cloudwatch_metric_alarm를 보면 여러 metric 값들을 연산해서 적용하는 예제도 있다.

resource "aws_cloudwatch_metric_alarm" "foobar" {
  alarm_name                = "terraform-test-foobar"
  comparison_operator       = "GreaterThanOrEqualToThreshold"
  evaluation_periods        = "2"
  threshold                 = "10"
  alarm_description         = "Request error rate has exceeded 10%"
  insufficient_data_actions = []

  metric_query {
    id          = "e1"
    expression  = "m2/m1*100"
    label       = "Error Rate"
    return_data = "true"
  }

  metric_query {
    id = "m1"

    metric {
      metric_name = "RequestCount"
      namespace   = "AWS/ApplicationELB"
      period      = "120"
      stat        = "Sum"
      unit        = "Count"

      dimensions = {
        LoadBalancer = "app/web"
      }
    }
  }

  metric_query {
    id = "m2"

    metric {
      metric_name = "HTTPCode_ELB_5XX_Count"
      namespace   = "AWS/ApplicationELB"
      period      = "120"
      stat        = "Sum"
      unit        = "Count"

      dimensions = {
        LoadBalancer = "app/web"
      }
    }
  }
}

app/web이라는 이름의 ALB의 2분동안의 RequestCount합을 m1, 5XX Error Count를 m2로 하여 m2/m1*100의 값이 10이 넘는 경우 Alarm을 발송하는 예제이다. 이것을 참조해서 좀 더 복잡한 지표를 원할 경우 작성이 가능하다.

맺음말

요즘 AWS Infrastructure Monitorting을 지원하는 서비스들이 다수 존재한다. Datadog, NewRelic 이 두가지 서비스에서도 AWS Account를 연동이 가능하다. 정확하게는 우리 AWS에 그들(Datadog, NewRelic)의 AWS Account에 ReadOnly권한을 주어서 CloudWatch Metric값들을 crawling해가는 형식으로 보인다. 여기에 해당 서비스 alert을 이용하면 이런 수고를 할 필요가 없다. 하지만, 필자가 느끼기에는 거기서 제공해주는 편의기능 대비 비용이 너무 비싸다는 생각이 들었다. 해당 서비스에서 제공해주는 Infrastructure Monitoring의 기능들을 모두 잘 활용하는 경우라면 몰라도 단순히 이런 abnormal 상황에 대한 alert만을 위해서 그런 서비스를 사용하는건 비용낭비가 심하다는 생각이 들었다.

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)