DevOps

Cloudwatch에 수집된 로그를 Lambda와 연동하여 Slack 메세지 보내기

southouse 2022. 8. 26. 11:31
728x90

애플리케이션 로그를 CloudWatch로 수집하고 로그를 통해서 문제가 생겼음을 감지하기 위해 CloudWatch로 수집된 로그 중 특정 조건(필터)를 통해서 Slack으로 메세지를 보내는 설정을 구성해보고자 한다.

 

Spring Boot에서 CloudWatch 로그를 수집하는 내용은 이전 포스팅에서 다뤘기 때문에 참고하면 된다.

실습 환경

  • AWS
    • EC2
    • CloudWatch
    • Lambda (Node 16)
  • Slack
    • Webhook

구조

AWS 인프라 구조

CloudWatch에 저장된 로그를 Lambda 함수에 특정 필터와 함께 트리거하여, Slack에 메세지를 보내는 구조이다.

수집된 로그 확인

일반적으로 애플리케이션에서 이런 식의 여러 레벨의 로그들이 꾸준히 발생되고 있을 것이다. 애플리케이션이 정상적으로 동작하기 위해 특별한 경우가 아니라면, ERROR 로그는 그냥 넘길 수 없다.

 

ERROR 로그가 발생하면 관리자 혹은 개발자가 알림을 받을 수 있도록 설정해보도록 하겠다.

Slack Webhook 설정

Lambda에서 Slack 메세지를 보내기 위한 연동 Webhook을 설정한다. 자신의 Slack Workspace의 App directory로 들어가서, Incoming WebHooks를 추가해준다.

 

그 다음에 알림을 받을 Channel을 선택하고 Add Incoming WebHooks integration 버튼을 클릭한다.

 

그러면 Webhook URL이 발급된다. 해당 URL을 통해서 우리가 슬랙에 메세지를 보낼 수 있게 된다.

 

Webhook URL을 발급 받았다면, curl 명령어를 통해 메세지가 오는지 확인해보도록 하자. "${WEBHOOK_URL} 부분만 자신의 Webhook URL로 변경하면 된다.

curl -X POST -H 'Content-type: application/json' --data '{"text":"Hello, World!"}' "${WEBHOOK_URL}"

 

기본 설정으로 했다면, Webhook 이라는 이름으로 Hello, World! 라는 메세지가 설정한 채널로 오게 된다.

Webhook Slack API를 통해 받은 채널 메세지

Lambda 함수 작성

AWS 콘솔로 접속하여, Lambda 리소스에 들어가서 함수를 생성한다. 역할은 새로 생성하도록 하겠다. 기존에 CloudWatch 권한을 가진 역할이 있다면, 기존 역할을 사용해도 된다.

AWS > Lambda > 함수 > 함수 생성

 

이제 코드 소스부분에 아래의 코드를 붙여넣기 한다.

const zlib = require("zlib");
const https = require("https");
const SLACK_ENDPOINT =
  "/services/~~"; // don't use this endpoint, I removed it after publish this post
const SLACK_BOT = "Cloudwatch";

function doRequest(log) {
  const KR_TIME_DIFF = 9 * 60 * 60 * 1000;
  const content = log.logEvents[0];
  const payload = {
    username: SLACK_BOT,
    blocks: [
      {
        type: "header",
        text: {
          type: "plain_text",
          text: "Whoops, looks like something went wrong 😞🤕",
          emoji: true,
        },
      },
      {
              "type": "divider"
          },
      {
        type: "section",
        fields: [
          {
            type: "mrkdwn",
            text: "*Log Group:* " + log.logGroup + "\n" + 
            "*Log Stream:* " + log.logStream + "\n"
          },
          {
            type: "mrkdwn",
            text: "*Message:* _" + content.message + "_"
          }
        ],
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "*Stacktrace:*",
        },
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text:
            "```" +
            "{ \n" + 
            // "   \"id\": \"" + content.id + "\" \n" +
            "   \"timestamp\": " + new Date(content.timestamp + KR_TIME_DIFF).toISOString() + "\n" +
            "   \"message\": \"" + content.message.replace("\n", "") + "\" \n" +
            "}" + 
            "```"
        },
      },
      {
        type: "divider",
      },
    ],
  };

  const payloadStr = JSON.stringify(payload);
  const options = {
    hostname: "hooks.slack.com",
    port: 443,
    path: SLACK_ENDPOINT,
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Content-Length": Buffer.byteLength(payloadStr),
    },
  };

  const postReq = https.request(options, function (res) {
    const chunks = [];
    res.setEncoding("utf8");
    res.on("data", function (chunk) {
      return chunks.push(chunk);
    });
    res.on("end", function () {
      if (res.statusCode < 400) {
        console.log("sent!!!");
      } else if (res.statusCode < 500) {
        console.error(
          "Error posting message to Slack API: " +
            res.statusCode +
            " - " +
            res.statusMessage
        );
      } else {
        console.error(
          "Server error when processing message: " +
            res.statusCode +
            " - " +
            res.statusMessage
        );
      }
    });
    return res;
  });
  postReq.write(payloadStr);
  postReq.end();
}

function main(event, context) {
  context.callbackWaitsForEmptyEventLoop = true;

  const payload = Buffer.from(event.awslogs.data, "base64");
  const log = JSON.parse(zlib.gunzipSync(payload).toString("utf8"));

  doRequest(log);

  const response = {
    statusCode: 200,
    body: JSON.stringify("Event sent to Slack!"),
  };
  return response;
}

exports.handler = main;

SLACK_ENDPOINT 변수에 아까 우리가 발급받은 Webhook URL에서 hooks.slack.com제외한 나머지 /services/~~ 값을 넣어준다. 그리고 SLACK_BOT 변수에 슬랙에 메세지가 왔을 때 보낸 사람의 이름을 설정한다.

 

CloudWatch에 있는 로그를 받아, 슬랙에 보낼 메세지를 우리가 보기 쉬운 형태로 가공한 다음에, 메세지를 보내는 코드이다. 슬랙 메세지를 가공하기 위해 슬랙 API 중 Message Payloads 부분을 참고하여 만들었다. 그리고, CloudWatch에 로그가 기록된 시간을 저장하는 timestamp 값이 UTC 기준으로 되어 있어서 한국 시간으로 바꿔주고 ISO 8601 양식으로 변환하여 보여준다.

 

내용이 이해가 되지 않는다면, 실제로 메세지를 받아보고 확인해도록 하고, 코드를 작성했다면, Deploy 버튼을 눌러 배포한다.

Lambda 함수 작성 후 배포

Lambda 트리거 설정

Lambda 트리거를 설정하여, CloudWatch에 로그가 생성될 때마다 함수를 실행하도록 트리거한다.

 

현재 로그를 저장하고 있는 로그 그룹을 선택하고, 해당 로그 메세지에서 필터 패턴이 포함된 내용이 있다면 전부 트리거 된다. 때문에 필터 패턴ERROR로 지정한다. INFO 로그를 트리거하고 싶다면, 필터 패턴을 INFO로 지정하면 된다.

Slack 메세지 확인

우리가 Webhook 연동해놓은 채널로 가면 기존에 애플리케이션에서 ERROR 로그를 1분에 한번씩 출력하도록 했기 때문에, 1분마다 슬랙으로 메세지가 오게 된다.

이처럼 애플리케이션 상 에러가 발생했을 때 따로 모니터링을 해주지 않아도 이런 식으로 알림을 받게 되면, 좀 더 빠른 분석/대응이 가능하다.

Reference

300x250