CloudWatch + Lambda + Discord로 CPU 알림 만들기

크래시 루프 장애 이후 CloudWatch + Lambda + Discord 웹훅으로 CPU 알림 시스템을 구축한 과정

sillysillyman··15분 읽기

들어가며

이전 글에서 @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 (복구) 알림도 보낼 수 있다

인프라 — 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
핵심 설정:
  • Period: Parameters에서 기본값을 300으로 설정했다. 5분 단위로 평균 CPU를 측정한다
  • EvaluationPeriods: Parameters에서 기본값을 3으로 설정했다. 3회 연속(15분) 초과해야 경보가 발생한다. 일시적인 스파이크에 경보가 발생하지 않도록 여유를 뒀다
  • AlarmActions + OKActions: 경보 진입뿐 아니라 정상 복귀 시에도 SNS에 알린다. 복구 여부도 확인할 수 있어야 한다
  • TreatMissingData: missing: 데이터가 없는 구간은 판단하지 않는다. EC2가 꺼져 있으면 불필요한 알림이 오지 않는다

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 }),
    });
  }
};
  • 상태별 이모지: ALARM은 🔴, OK는 🟢, 그 외(INSUFFICIENT_DATA 등)는 🟡. 디스코드에서 한눈에 상태를 구분할 수 있다.
  • 역할 멘션: <@&${ROLE_ID}> 형식으로 Discord 역할을 멘션한다. 팀원 전체에게 알림이 간다.
  • ST 변환: CloudWatch가 보내는 StateChangeTime은 UTC다. toLocaleString("ko-KR", { timeZone: "Asia/Seoul" })로 한국 시간으로 변환했다. 새벽에 알림을 받으면 UTC를 머릿속에서 변환할 여유가 없다.
  • 2000자 제한: Discord 메시지 최대 길이가 2000자다. NewStateReason이 길어질 수 있어서 .slice(0, 2000)으로 잘라낸다.
  • Node.js 22 내장 fetch: 별도 HTTP 라이브러리 없이 Node.js 내장 fetch를 사용했다. 의존성이 @types/aws-lambdatypescript뿐이라 패키지가 가볍다.

배포 — GitHub Actions

`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' }}
mainserver-health-alarm/ 하위 파일이 변경되면 자동 배포된다. workflow_dispatch도 추가해서 수동 배포도 가능하다.
OIDC 인증 — AWS 자격 증명을 시크릿에 저장하는 대신 GitHub OIDC를 사용했다. 액세스 키를 주기적으로 교체할 필요가 없다.
fork 보호if: github.repository == '...' 조건을 걸어서 fork된 레포에서는 워크플로우가 실행되지 않는다. fork에는 시크릿이 없으니 어차피 실패하겠지만, 불필요한 실행 자체를 방지하는 것이 명확하다.

트러블슈팅

처음부터 순조롭게 진행된 건 아니다. 커밋 히스토리가 증거다.

esbuild 참조 오류

sam build가 esbuild를 찾지 못해서 빌드가 실패했다. 해결까지 커밋 4개가 필요했다.
처음에는 esbuild가 devDependencies에 빠져 있어서 추가하고, 워크플로우에 npm ci를 넣었다. 하지만 SAM이 로컬 node_modules의 esbuild를 제대로 참조하지 못했다.
그래서 CodeUrisrc/에서 ./로 바꾸고 EntryPointssrc/index.ts로 변경하는 등 경로를 이리저리 조정해봤지만, 한쪽을 맞추면 다른 쪽이 깨졌다. Handlersrc/index.handler로 바꿔보기도 했다.
결국 경로 조정을 전부 되돌리고, npm ci 대신 npm install -g esbuild로 글로벌 설치하는 것이 해결책이었다. SAM의 esbuild 빌드 메서드는 글로벌에 설치된 esbuild를 기대한다.

한글 인코딩

SAM 템플릿의 DescriptionAlarmDescription에 한글을 사용했더니 배포 시 인코딩이 깨졌다. "EC2 서버 상태 CloudWatch 경보""CPU 사용률 임계값 (%)"처럼 작성했던 설명을 모두 영문으로 변경했다. CloudFormation 파라미터 설명도 마찬가지였다. Lambda 코드 내부의 한글(Discord 메시지 본문)은 문제없었고, 템플릿 메타데이터 영역만 영향을 받았다.

돌아보며

이전 글의 크래시 루프는 우연히 발견한 것이었다. 이 알림 시스템이 있었다면 CPU가 치솟는 순간 디스코드로 알림을 받았을 것이고, 더 빠르게 대응할 수 있었을 것이다.
구현 비용은 크지 않았다. Lambda 코드 60줄, SAM 템플릿 90줄, 워크플로우 50줄. 전체 코드가 200줄 남짓이다. 그런데 이 200줄이 "서버가 죽었는데 아무도 모르는" 상황을 방지한다.
모니터링은 장애를 겪기 전에 만드는 게 맞다. 하지만 장애를 겪은 후라도 안 만드는 것보다는 낫다.

참고