들어가며
이전 글에서
@TransactionalEventListener와 @Transactional 충돌로 크래시 루프가 발생했고, CPU가 95%까지 치솟았던 경험을 다뤘다. 문제는 해결했지만 해결되지 않은 문제가 남았다.그날 밤 내가 우연히 대시보드를 보지 않았다면?
런칭조차 안 한 사이드 프로젝트였기에 모니터링 알림 같은 건 미뤄두고 있었다. 하지만 크래시 루프를 직접 겪고 나니, 서버에 이슈가 발생했을 때 즉시 인지할 수 있는 알림 체계가 필요하다는 걸 체감했다.
팀 내 소통 채널이 디스코드였기 때문에, CloudWatch → SNS → Lambda → Discord 웹훅 구조로 CPU 알림을 만들기로 했다.
구조
- CloudWatch: AWS 리소스의 메트릭을 수집하고 경보를 설정할 수 있는 모니터링 서비스
- SNS(Simple Notification Service): 메시지를 발행하면 구독자에게 전달하는 pub/sub 메시징 서비스
- Lambda: 서버 없이 코드를 실행할 수 있는 서버리스 컴퓨팅 서비스
CloudWatch가 EC2의 CPU 사용률을 주기적으로 확인하고, 임계값을 연속으로 초과하면 SNS에 메시지를 보낸다. Lambda가 그 메시지를 받아 Discord 웹훅으로 전달한다.
왜 Lambda인가
CloudWatch 경보를 SNS로 보내는 것까지는 AWS 기본 기능이다. 하지만 SNS에서 Discord로 직접 보낼 수는 없다. SNS가 HTTP 엔드포인트로 보내는 메시지 형식과 Discord 웹훅이 기대하는 형식이 다르기 때문이다.
중간에 Lambda를 두면:
- SNS 메시지를 파싱해서 사람이 읽기 좋은 포맷으로 가공할 수 있다
- 경보 상태에 따라 이모지나 멘션을 넣는 등 커스터마이징이 자유롭다
- ALARM 뿐 아니라 OK (복구) 알림도 보낼 수 있다
IaC: SAM Template
모든 리소스를 AWS SAM 템플릿 하나로 관리했다. 이른바 IaC(Infrastructure as Code) - 인프라를 콘솔 클릭이 아닌 코드로 정의하는 방식이다. 콘솔에서 만들면 나중에 뭘 설정했는지 잊을 수 있고 다른 환경에서 재현도 안 된다. 코드로 관리하면 git 히스토리에 변경 이력이 남고, PR 리뷰도 가능하다.
yaml
Parameters:
DiscordWebhookUrl:
Type: String
NoEcho: true
Description: Discord Webhook URL
InstanceId:
Type: String
Description: EC2 instance ID to monitor
DiscordMentionRoleId:
Type: String
Description: Discord role ID to mention
CpuThreshold:
Type: Number
Default: 80
Description: CPU utilization threshold (%)
EvaluationPeriods:
Type: Number
Default: 3
Description: Number of consecutive breaches
Period:
Type: Number
Default: 300
Description: Evaluation period in seconds
CpuThreshold, EvaluationPeriods, Period는 배포 시 조정할 수 있도록, DiscordWebhookUrl, InstanceId, DiscordMentionRoleId는 민감하거나 배포 대상에 종속되는 값이므로 파라미터로 분리했다. 웹훅 URL은 NoEcho: true로 CloudFormation 이벤트 로그에 노출되지 않도록 했다.CloudWatch Alarm
yaml
CpuAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: ████-ec2-cpu-high
Namespace: AWS/EC2
MetricName: CPUUtilization
Statistic: Average
Period: !Ref Period
EvaluationPeriods: !Ref EvaluationPeriods
Threshold: !Ref CpuThreshold
ComparisonOperator: GreaterThanThreshold
Dimensions:
- Name: InstanceId
Value: !Ref InstanceId
AlarmActions:
- !Ref AlarmSnsTopic
OKActions:
- !Ref AlarmSnsTopic
TreatMissingData: missing
💡 이 프로젝트는 단일 EC2 인스턴스 환경이라Dimensions에InstanceId를 직접 지정했다. 운영 환경에 따라 ASG 이름, ECS 서비스, ELB 타겟 그룹 등 더 적절한 디멘션을 선택하는 것이 일반적이다. 인스턴스가 교체되거나 스케일링되는 환경에서는 인스턴스 ID에 직접 바인딩하면 알람이 무효화될 수 있다.
핵심 설정:
Period: Parameters에서 기본값을 300으로 설정했다. 5분 단위로 평균 CPU를 측정한다.EvaluationPeriods: Parameters에서 기본값을 3으로 설정했다. 3회 연속(15분) 초과해야 경보가 발생한다. 일시적인 스파이크에 경보가 발생하지 않도록 여유를 뒀다.AlarmActions+OKActions: 경보 진입뿐 아니라 정상 복귀 시에도 SNS에 알린다. 복구 여부도 확인할 수 있어야 한다.TreatMissingData: missing: 데이터가 없는 구간은 판단하지 않는다. EC2가 꺼져 있으면 불필요한 알림이 오지 않는다.
Period 설정에 대한 AWS 공식 권고사항:“When creating an alarm, select an alarm monitoring period that is greater than or equal to the metrics resolution. For example, basic monitoring for Amazon EC2 provides metrics for your instances every 5 minutes. When setting an alarm on a basic monitoring metric, select a period of at least 300 seconds (5 minutes).”(번역) 알람을 생성할 때 메트릭 해상도 이상의 모니터링 주기를 선택하라. 예를 들어 EC2 기본 모니터링은 5분마다 메트릭을 제공하므로, 기본 모니터링 메트릭에 알람을 설정할 때는 최소 300초(5분)를 선택하라.
Lambda 함수
yaml
ServerHealthAlarmFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: ████-server-health-alarm
Handler: index.handler
Runtime: nodejs22.x
Timeout: 30
CodeUri: src/
Environment:
Variables:
DISCORD_WEBHOOK_URL: !Ref DiscordWebhookUrl
DISCORD_MENTION_ROLE_ID: !Ref DiscordMentionRoleId
Events:
SnsEvent:
Type: SNS
Properties:
Topic: !Ref AlarmSnsTopic
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: es2022
EntryPoints:
- index.ts
SAM의
esbuild 빌드 메서드를 사용해서 TypeScript를 직접 빌드한다. 별도의 빌드 스크립트 없이 sam build 한 번이면 된다.Lambda 코드
typescript
import { SNSEvent } from "aws-lambda";
interface CloudWatchAlarmMessage {
AlarmName: string;
NewStateValue: string;
NewStateReason: string;
Region: string;
StateChangeTime: string;
Trigger: {
MetricName: string;
Namespace: string;
Threshold: number;
Period: number;
Dimensions: { name: string; value: string }[];
};
}
export const handler = async (event: SNSEvent): Promise<void> => {
for (const record of event.Records) {
const message: CloudWatchAlarmMessage = JSON.parse(record.Sns.Message);
const { AlarmName, NewStateValue, NewStateReason, Region, StateChangeTime, Trigger } = message;
const instanceId =
Trigger.Dimensions?.find(({ name }) => name === "InstanceId")?.value ?? "N/A";
const emoji =
NewStateValue === "ALARM" ? "🔴" : NewStateValue === "OK" ? "🟢" : "🟡";
const content = [
`## ${emoji} CloudWatch 경보: ${NewStateValue}`,
`<@&${DISCORD_MENTION_ROLE_ID}>`,
`**경보 이름**: ${AlarmName}`,
`**시각**: ${new Date(StateChangeTime).toLocaleString("ko-KR", { timeZone: "Asia/Seoul" })}`,
`**리전**: ${Region}`,
`**인스턴스**: \`${instanceId}\``,
`**메트릭**: ${Trigger.Namespace} / ${Trigger.MetricName}`,
`**임계값**: ${Trigger.Threshold}% (측정 주기: ${Trigger.Period}초)`,
"",
`**상세**: ${NewStateReason}`,
]
.join("\n")
.slice(0, 2000);
await fetch(DISCORD_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
}
};
CloudWatchAlarmMessage인터페이스: SNS가 전달하는Message필드는 JSON 문자열이라JSON.parse로 파싱해야 한다. 이 메시지의 타입은@types/aws-lambda에 포함되어 있지 않아서, AWS 공식 문서의 SNS 알림 스키마를 참고해 필요한 필드만 뽑아서 직접 정의했다.- 상태별 이모지:
ALARM은 🔴,OK는 🟢, 그 외(INSUFFICIENT_DATA등)는 🟡. 디스코드에서 한눈에 상태를 구분할 수 있다. - 역할 멘션:
<@&${ROLE_ID}>형식으로 Discord 역할을 멘션한다. 해당 역할의 멤버에게 알림이 간다. - KST 변환: CloudWatch가 보내는
StateChangeTime은 UTC다.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" })로 가독성을 위해 한국 시간으로 변환했다. - 2000자 제한: Discord 메시지 최대 길이가 2000자다.
NewStateReason이 길어질 수 있어서.slice(0, 2000)으로 잘라낸다. - Node.js 22 내장 fetch: 별도 HTTP 라이브러리 없이 Node.js 내장
fetch를 사용했다. 의존성이@types/aws-lambda와typescript뿐이라 패키지가 가볍다.
GitHub Actions 워크플로우
yaml
name: Deploy server-health-alarm
on:
push:
branches:
- main
paths:
- "server-health-alarm/**"
workflow_dispatch:
jobs:
deploy:
if: github.repository == '██████/██████'
runs-on: ubuntu-latest
environment: server-health-alarm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install esbuild
run: npm install -g esbuild
- uses: aws-actions/setup-sam@v2
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::████████████:role/████-oidc-role
aws-region: ap-northeast-2
- name: Build
working-directory: server-health-alarm
run: sam build
- name: Deploy
working-directory: server-health-alarm
run: |
sam deploy \
--stack-name ████-server-health-alarm \
--resolve-s3 \
--no-confirm-changeset \
--no-fail-on-empty-changeset \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
DiscordWebhookUrl=${{ secrets.DISCORD_WEBHOOK_URL }} \
InstanceId=${{ secrets.EC2_INSTANCE_ID }} \
DiscordMentionRoleId=${{ secrets.DISCORD_MENTION_ROLE_ID }} \
CpuThreshold=${{ vars.CPU_THRESHOLD || '80' }}
main에 server-health-alarm/ 하위 파일이 변경되면 자동 배포된다. workflow_dispatch도 추가해서 수동 배포도 가능하다.- OIDC 인증: AWS 자격 증명을 시크릿에 저장하는 대신 GitHub OIDC를 사용했다. 액세스 키를 주기적으로 교체할 필요가 없다.
- fork 보호:
if: github.repository == '...'조건을 걸어서 fork된 레포에서는 워크플로우가 실행되지 않는다. fork에는 시크릿이 없으니 어차피 실패하겠지만, 불필요한 실행 자체를 방지하는 것이 명확하다.
테스트 결과
실제로 경보가 오는지 확인하기 위해, GitHub Environment Variables에서
CPU_THRESHOLD를 1로 낮추고 워크플로우를 수동 실행했다. EC2 idle 상태에서도 CPU는 일반적으로 1% 이상을 찍으니, 사실상 즉시 경보가 발생하는 조건이다.
배포 후 얼마 지나지 않아 디스코드 채널에 ALARM 상태의 경보 메시지가 수신됐다.

이후
CPU_THRESHOLD를 80으로 원복해 재배포했고, OK 메시지도 정상적으로 수신됐다.
돌아보며
이전 글의 크래시 루프는 우연히 발견한 것이었다. 이 알림 시스템이 있었다면 CPU가 치솟는 순간 디스코드로 알림을 받았을 것이고, 더 빠르게 대응할 수 있었을 것이다.
구현 비용은 크지 않았다. Lambda 코드 60줄, SAM 템플릿 90줄, 워크플로우 50줄. 전체 코드가 200줄 남짓이다. AWS 비용도 마찬가지다. CloudWatch 알람 1개, SNS, Lambda 모두 프리 티어 범위 안에 들어온다. CPU 경보가 하루에 수십 번 울려도(애초에 이러면 사고다) 한도를 넘기는 건 불가능하다. 정확한 단가는 리전마다 다르지만, 이 구성에서 월 청구액이 발생하는 상황은 사실상 없다. 그런데 이 200줄이 "서버가 죽었는데 아무도 모르는" 상황을 방지한다.
모니터링은 장애를 겪기 전에 만드는 게 맞다. 하지만 장애를 겪은 후라도 안 만드는 것보다는 낫다.