
AWS CloudWatch는 AWS 서비스를 이용하면서 발생하는 지표들을 모니터링하는 툴이다.
다양한 기능이 있지만, 각 지표에 대해서 경보(Alarm)를 설정하고 알림을 받을 수 있는 기능도 있다. 예를 들면 CPU utilization이 50%를 넘길 경우 이를 즉각적으로 알 수 있는 것이다.
출시한 프로그램에서 어떤 문제가 발생했을 시 이를 얼마나 빨리 알아차리고 대처하느냐는 유저 이탈율에 직접 대응되는 문제이기 때문에 매우 중요하다. (즉각적 대처가 어려운 문제이더라도, 시스템 점검 중이라는 창을 띄우는 것과 그냥 무한 로딩이 되거나 앱이 터지는 것은 UX 상에서 엄청난 차이가 있다.)
그러나 AWS 콘솔을 계속 들여다보며 혹시 문제가 생겼는지 모니터링할 수는 없다. 그래서 오늘은 AWS SNS, AWS Lambda를 대중적인 업무 툴 Slack과 연동하여 경보를 Slack 알림으로 받는 방법에 대해서 기록해보고자 한다.
1. Slack - Webhooks URL 생성
일단 가장 먼저 할 것은 Slack에 알림을 보내기 위해 Webhook URL을 생성하는 작업이다.
Slack 채널에서 앱 추가를 선택하거나, 채널명 > 도구 및 설정 > 앱 관리 를 누른 후 열린 인터넷 창에서 검색을 누른다.

incoming webhooks를 검색한 후 나온 수신 웹후크 앱을 슬랙에 추가한다.

채널을 만들라고 하는 경우가 있는데, 중요한 내용은 아니라서 생략하겠다.
앱 추가를 완료하면 아래 사진처럼 웹 후크 URL이 뜬다. 슬랙 알림을 보내는 데 필요하니 복사하거나 잘 메모해두도록 한다.

2. AWS SNS 주제 생성
CloudWatch가 알림을 넘길 SNS topic을 생성해야 한다.
콘솔에 로그인 후 모든 서비스 > 애플리케이션 통합 > Simple Notification Service > 주제 > 주제 생성


표준 유형을 선택한 후 이름(필수), 표시 이름(선택)을 작성, 주제를 생성한다.

3. AWS Lambda Function으로 슬랙과 연동
앞에서 생성한 SNS 주제를 subscribe하고, cloudwatch에서 경보를 발생할 때마다 실제로 슬랙에 알림을 보낼 람다 함수를 만들어야 한다.
AWS > Lambda > 함수 에서 함수 생성을 선택한다.

코드를 새로 작성할 필요 없이 이미 만들어진 예시 코드를 활용할 수 있다.
블루프린트 사용 > Slack을 검색 > Send CloudWatch alarm notifications via SNS를 선택한다. (언어는 Python 3.10이다.)

아래쪽에 실행 역할을 기본 Lambda 권한을 가진 새 역할 생성을 선택하고 아래로 내리면 예시 코드가 작성되어 있는 것을 확인할 수 있다.
import boto3
import json
import logging
import os
from base64 import b64decode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
HOOK_URL = os.environ['HookUrl']
# The Slack channel to send a message to stored in the slackChannel environment variable
SLACK_CHANNEL = os.environ['slackChannel']
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
logger.info("Event: " + str(event))
message = json.loads(event['Records'][0]['Sns']['Message'])
logger.info("Message: " + str(message))
alarm_name = message['AlarmName']
#old_state = message['OldStateValue']
new_state = message['NewStateValue']
reason = message['NewStateReason']
slack_message = {
'channel': SLACK_CHANNEL,
'text': "%s state is now %s: %s" % (alarm_name, new_state, reason)
}
req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8'))
try:
response = urlopen(req)
response.read()
logger.info("Message posted to %s", slack_message['channel'])
except HTTPError as e:
logger.error("Request failed: %d %s", e.code, e.reason)
except URLError as e:
logger.error("Server connection failed: %s", e.reason)
SNS 트리거 항목에서 방금 생성한 SNS 주제를 선택, 환경 변수 항목에서 slackChannel과 HookUrl 키에 값을 입력한다.

slackChannel은 전용 채널명을 입력해도 되고 하지 않아도 된다. HookUrl은 아까 복사하거나 메모해 두었던 웹 훅 URL을 복사해서 넣어준다. 그리고 함수 생성.

SNS 트리거가 추가된 함수가 생성되었다. SNS 주제 페이지에 가면 아무것도 없던 구독 항목에 방금 생성한 Lambda 함수가 추가된 것이 보일 것이다.

4. Slack에 알림 가는지 Test
이제 슬랙과의 연동이 끝났으니 테스트 코드로 알림이 잘 가는지 확인해보자.
Lambda에서는 테스트 이벤트를 생성해 함수가 잘 실행되는지 확인할 수 있는 기능을 제공한다.
Lambda > 함수 > 방금 생성한 함수를 선택하여 Test 박스 > Configure test event를 클릭한다.

새 이벤트 생성을 선택하고, 이벤트 이름을 입력한 후 아래 코드를 이벤트 JSON에 입력한다.
이 코드는 CloudWatch에서 보내는 Alarm과 동일한 형태로 제작된 이벤트 메시지로 원문은 여기서 확인 가능하다.

{
"Records": [
{
"EventSource": "aws:sns",
"EventVersion": "1.0",
"EventSubscriptionArn": "arn:aws:sns:eu-west-1:000000000000:cloudwatch-alarms:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"Sns": {
"Type": "Notification",
"MessageId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"TopicArn": "arn:aws:sns:eu-west-1:000000000000:cloudwatch-alarms",
"Subject": "ALARM: \"Example alarm name\" in EU - Ireland",
"Message": "{\"AlarmName\":\"Example alarm name\",\"AlarmDescription\":\"Example alarm description.\",\"AWSAccountId\":\"000000000000\",\"NewStateValue\":\"ALARM\",\"NewStateReason\":\"Threshold Crossed: 1 datapoint (10.0) was greater than or equal to the threshold (1.0).\",\"StateChangeTime\":\"2017-01-12T16:30:42.236+0000\",\"Region\":\"EU - Ireland\",\"OldStateValue\":\"OK\",\"Trigger\":{\"MetricName\":\"DeliveryErrors\",\"Namespace\":\"ExampleNamespace\",\"Statistic\":\"SUM\",\"Unit\":null,\"Dimensions\":[],\"Period\":300,\"EvaluationPeriods\":1,\"ComparisonOperator\":\"GreaterThanOrEqualToThreshold\",\"Threshold\":1.0}}",
"Timestamp": "2017-01-12T16:30:42.318Z",
"SignatureVersion": "1",
"Signature": "Cg==",
"SigningCertUrl": "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.pem",
"UnsubscribeUrl": "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:000000000000:cloudwatch-alarms:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"MessageAttributes": {}
}
}
]
}
테스트 이벤트를 생성한 후 테스트 버튼을 다시 누르면 테스트가 수행되며 아래와 같은 결과가 나온다.


슬랙에서도 알림이 잘 왔는지 확인한다.

알림이 잘 왔다. 그런데 무슨 내용인지 너무 직관적이지 못한 것 같다. 함수를 수정해서 형식을 바꿔보자.
5. Slack에 보내는 메세지 형식 수정
형식에 따라 어떤 내용이 메세지로 오면 좋을지 먼저 작성하고 > 그 각각의 값을 어떻게 가져오는지 확인하고 > 메세지를 수정할 것이다.
형식을 먼저 작성해보자.
필자는 아래 형식으로 메세지를 작성하고 싶었다.

먼저 제목 앞에 []안에는 현재 상태(경보/정상/데이터 부족)를, 제목에는 alarm name을 굵은 글씨로 넣었다.
가독성을 위해 2줄 띄고, 설명, 상태 변화, 발생 시간 (kst), 기준, 상세 내용을 설명했다.
이 형식에 맞추기 위해서 알아야 하는 값은 다음과 같다.
- 현재 상태
- alarm name
- 설명
- 이전 상태
- 발생 시간(kst)
- 기준 시간 (사진에서 5분)
- 기준 횟수 (사진에서 1회)
- 측정값 (사진에서 DeliveryErrors)
- 비교 기준 (>, >=, <, <=)
- 임계치 (사진에서 1.0)
- 상세
이제 이 값들을 어떻게 가져오는지 CloudWatch의 로그를 통해 알아볼 것이다.
CloudWatch > Log groups로 가 보면, 방금 생성한 슬랙 연동 람다 함수의 로그를 확인할 수 있다.

로그 스트림에서 가장 최신의 로그 스트림을 선택한다.

타임스탬프로 정렬되어 있는 로그 메세지 중, 메세지에 [INFO]가 붙어 있는 곳을 선택한다.

그러면 이벤트에 대한 데이터가 키 값 형태로 선언되어 있다. ‘Message’: ‘{ ...로 시작하는 부분을 찾아 필요한 데이터를 잘 정리한다.

message 기준으로 정리하면 된다. 예를 들어 위에서 정리했던 필요한 값 중에 현 상태는 message안의 NewStateValue: ALARM이라고 되어 있으니 message[‘NewStateValue’]라고 정리하면 된다.
파이썬에서 이 값들을 변수로 저장하고 메세지에 넣어야 한다.
alarm_name = message['AlarmName']
time = message['StateChangeTime'][:19]
# 한국 시간대로 설정
kst_time = datetime.strptime(time, '%Y-%m-%dT%H:%M:%S').astimezone(timezone(timedelta(hours=9))).strftime('%Y-%m-%d %H:%M:%S')
old_state = message['OldStateValue']
new_state = message['NewStateValue']
description = message['AlarmDescription']
period = message['Trigger']['Period']
minutes = period // 60
cnt = message['Trigger']['EvaluationPeriods']
metricName = message['Trigger']['MetricName']
comp = message['Trigger']['ComparisonOperator']
if comp == 'GreaterThanThreshold':
compar_state = '>'
elif comp == 'GreaterThanOrEqualToThreshold':
compar_state = '>='
elif comp == 'LowerThanOrEqualToThreshold':
compar_state = '<='
elif comp == 'LessThanThreshold':
compar_state = '<'
reason = message['NewStateReason']
threshold = message['Trigger']['Threshold']
이제 slack_message를 형식에 맞게 잘 써주면 된다.
slack_message = {
'channel': SLACK_CHANNEL,
'text': "*[%s]* %s\n\n*설명*\n%s\n*상태*\n%s -> %s\n*발생 시간*\n%s\n*기준*\n%s분에 %s회 %s %s %s\n*상세*\n%s"
% (new_state, alarm_name, description, old_state, new_state, kst_time, minutes, cnt, metricName, compar_state, threshold, reason)
}
(람다 코드 소스 전문)
import boto3
import json
import logging
import os
from base64 import b64decode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from datetime import date, datetime, timezone, timedelta
HOOK_URL = os.environ['HookUrl']
# The Slack channel to send a message to stored in the slackChannel environment variable
SLACK_CHANNEL = os.environ['slackChannel']
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
logger.info("Event: " + str(event))
message = json.loads(event['Records'][0]['Sns']['Message'])
logger.info("Message: " + str(message))
alarm_name = message['AlarmName']
time = message['StateChangeTime'][:19]
# 한국 시간대로 설정
kst_time = datetime.strptime(time, '%Y-%m-%dT%H:%M:%S').astimezone(timezone(timedelta(hours=9))).strftime('%Y-%m-%d %H:%M:%S')
old_state = message['OldStateValue']
new_state = message['NewStateValue']
description = message['AlarmDescription']
period = message['Trigger']['Period']
minutes = period // 60
cnt = message['Trigger']['EvaluationPeriods']
metricName = message['Trigger']['MetricName']
comp = message['Trigger']['ComparisonOperator']
if comp == 'GreaterThanThreshold':
compar_state = '>'
elif comp == 'GreaterThanOrEqualToThreshold':
compar_state = '>='
elif comp == 'LowerThanOrEqualToThreshold':
compar_state = '<='
elif comp == 'LessThanThreshold':
compar_state = '<'
reason = message['NewStateReason']
threshold = message['Trigger']['Threshold']
slack_message = {
'channel': SLACK_CHANNEL,
'text': "*[%s]* %s\n\n*설명*\n%s\n*상태*\n%s -> %s\n*발생 시간*\n%s\n*기준*\n%s분에 %s회 %s %s %s\n*상세*\n%s"
% (new_state, alarm_name, description, old_state, new_state, kst_time, minutes, cnt, metricName, compar_state, threshold, reason)
}
req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8'))
try:
response = urlopen(req)
response.read()
logger.info("Message posted to %s", slack_message['channel'])
except HTTPError as e:
logger.error("Request failed: %d %s", e.code, e.reason)
except URLError as e:
logger.error("Server connection failed: %s", e.reason)
이미 올라간 람다 함수를 수정하는 것이기 때문에 Deploy를 눌러 저장하고

다시 Test해주면 슬랙에 형식에 맞게 잘 온 것을 확인할 수 있다.

6. CloudWatch Alarm 설정
이제 CloudWatch에서 경보를 설정하여, 특정 상황에 저 메세지가 오도록 트리거해주기만 하면 된다.
CloudWatch > 모든 경보 > 경보 생성

지표 선택

회사에서 람다 함수를 사용하여 기능을 구현하는 편이라 다른 람다 함수의 오류율을 지표로 설정하기로 했다.
방금 생성한 람다 함수가 아니다. 헷갈리면 안됨

지금 선택한 람다 함수는 예를 들어 보여주기 위해 선택한, 방금 생성한 슬랙 연동 람다 함수다. 슬랙 연동 람다 함수에서 에러가 나면 경보가 발생해도 슬랙에 알림이 못 갈테니 이렇게 선택하면 안된다.

오류율을 기준으로 하기 때문에 0.5를 임계치로 잡고, 기간 5분, 추가 구성에서 데이터 포인트를 2로 잡음으로써 위에 써있는 말처럼 10분 내에 2개의 데이터 포인트에서 오류율이 0.5를 넘기면 트리거되도록 설정했다.
참고로 누락된 데이터를 처리하는 방식은 4가지이다.
1. 누락으로 처리
2. 양호 (임계값 위반 안함)로 처리
3. 무시 (경보 상태 유지)
4. 불량 (임계값 위반 안함)으로 처리
어떤 지표를 가지고 보느냐에 따라 전략을 달리 선택할 수 있는데, 예를 들어 CPU utilization이라면 누락된 데이터에 불량한 값이 있을 수 있으니 4 불량을 선택하는 전략을 선택할 수도 있다. 지금 필자처럼 람다 함수 오류율에 경보를 설정한 경우에는 특정한 기능을 사용할 때만 호출되기 때문에 누락으로 처리한다면 만성 데이터 부족 (데이터 부족 -> OK 알림만 죽도록 받게 될 수도 있다)에 시달릴 수 있기 때문에 양호로 처리하는 전략을 선택할 수도 있다.
다음을 누르면 알림을 선택할 수 있다.

경보 상태가 될 때 트리거할 것이므로 경보 상태를 선택한다. 다만 경보를 한 번 더 생성해서 정상 상태가 되었을 때도 알림이 울리도록 할 것이다. 정상이 되었다는 알림도 와야 지금 현재 상태가 정상인지 문제인지를 직관적으로 판단할 수 있기 떄문이다.
SNS 주제 선택에서 아까 만든 SNS 주제를 선택하고 다음을 누른다.
이름과 설명을 추가한다.

필자는 아래와 같은 형식으로 설정했다.
CloudWatch-함수명-Error
CloudWatch-함수명-Normal
CloudWatch: ~함수 설명~에서 10분 안에 2개의 에러가 발생하였습니다.
CloudWatch: ~함수 설명~ 에러가 정상 범위입니다.
경보를 생성하고 나면 모든 과정이 끝났다! 아마 처음에는 데이터 부족 상태가 될 것이다.

정상 상태가 되었을 때도 알림이 오도록 잘 설정했다면, 조금의 시간이 지나서 데이터 부족 -> 정상 상태로 바뀌면서 동시에 슬랙으로 알림이 올 것이다.

기나긴 여정이 드디어 끝났다!
출시한 어플리케이션의 문제 발생을 즉각 감지하는 것은 문제 없이 출시하기 위해 테스트를 얼마나 세심하게 진행했느냐만큼 중요한 이슈이다.
설정해둔 채널의 슬랙으로 알림이 왔을 때 [ALARM]으로 시작하는 경우 서둘러 원인을 찾고 UX를 먼저 설정함으로써 사용자의 에러 경험을 개선해보자.
'개발 및 알고리즘 > 알고리즘' 카테고리의 다른 글
| [React Native] 리액트 네이티브에서 지문 인식 구현하기 (react-native-biometrics) (0) | 2024.08.14 |
|---|---|
| [Python] Selenium으로 웹 크롤링 (이메일 긁어오기) (1) | 2024.08.08 |
| [React Native] 리액트 네이티브에서 커스텀 폰트 일괄 적용하기 (0) | 2024.07.30 |
| [React Native] 리액트 네이티브에서 구글로그인 구현하기 (@react-native-google-signin/google-signin) (2) | 2024.07.23 |
| [JS/typescript] Null 병합 연산자 ?? (물음표 2개) (1) | 2024.07.23 |