| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
- 모임 장소 추천
- 이펙티브자바
- 중간 지점 추천
- 한국대학생it경영학회
- Domain Driven Design
- 약속 장소 추천
- 중간 장소 추천
- kusitms
- AWS
- springboot
- Docker Layer
- 모이삼
- K3S
- Docker cache
- GitHub Actions
- 중간 지점 찾기
- 객체지향 쿼리 언어
- 불변객체
- Container Registry
- ddd
- 도메인 주도 개발 시작하기
- Spring
- 최범균
- java
- JPQL
- 중간 장소 찾기
- JPA
- 자바 ORM 표준 JPA 프로그래밍
- 큐시즘
- cicd
- Today
- Total
코딩은 마라톤
[AWS] 아침 7시, 멀쩡하던 서버가 죽었다? (ECR Lifecycle Policy 장애 회고 및 Discord 알림 구축기) 본문
[AWS] 아침 7시, 멀쩡하던 서버가 죽었다? (ECR Lifecycle Policy 장애 회고 및 Discord 알림 구축기)
anxi 2026. 4. 21. 00:44🚨발단: 운영 서버 접근 불가

회사에서 일하고 있는데 디코 알림이 울렸다. 알림을 보니 모이삼(https://moisam.kr/) 접근이 되지 않는다는 오류였다..
이전에 cert-manager, nginx-ingress 충돌 났을 때랑 동일 증상이라 SSL 인증서 문제인가 했지만.. AWS ACM을 사용 중이었어서 이 경우는 아니었다.

AWS에서 확인해 보니 이벤트가 금일 쭉 쌓여 있었고, 모든 내용이 다음과 같았다.
service <SERVICE_NAME> was unable to place a task.
Reason: CannotPullContainerError: pull image manifest has been retried 7 time(s):
failed to resolve ref <AWS_ACCOUNT_ID>.dkr.ecr.ap-northeast-2.amazonaws.com/<REPO_NAME>:<TAG>@sha256:<DIGEST>: not found.
서비스 태스크를 사용할 수 없는 문제였고 이는 ECR에서 이미지를 pull 하지 못해 발생했다.
🔍 뭐가 문제였을까? 'ECR 수명 주기 정책'
해당 이미지 태그를 확인해보니, 태그를 가진 이미지가 없었다.
이는 비용 절감을 위해 설정해둔 'ECR 수명 주기 정책'이 문제였다. 기존 정책은 최근 10개 이미지만 남기고 모두 제거하는 것이었다..
현재는 하나의 ECR 레포지토리에 운영과 개발 이미지를 태그로만 구분해서 계속 푸시하고 있었다. 운영 배포보다 개발 서버 배포가 훨씬 잦다 보니, 개발 이미지가 계속 쌓이면서 운영 이미지가 '최근 10개' 목록에서 밀려나 기계적으로 삭제되어 버린 것이다.
결국 운영 이미지가 최근 10개 이미지에서 벗어나면서.. ECS 재가동 과정에서 PULL 할 이미지가 없어 장애가 발생했다.
비용 절감을 위해 무심코 만든 정책이 04.20 07시 ~ 04.20 15시 총 8시간 동안 서버가 꺼졌으며.. 8시간 동안 장애가 발생한 사실을 알지 못했다.
🛠 해결 1. ECR 수명 주기 정책 수정
1개의 ECR에서 운영, 개발 이미지를 태그로 분리하고 있어서 최근 10개 이미지만 남기는 건 위험할 수 있다.
따라서 규칙 우선순위에 따라 3개로 나눠 정책을 추가했다.
- 1순위: latest 태그 이미지는 9999개 보관 (절대 삭제 안 함)
- 2순위: 그 외의 태그 이미지는 30개 유지
- 3순위: untagged 이미지는 7일 뒤 제거
{
"rules": [
{
"rulePriority": 1,
"description": "latest 태그는 삭제하지 않음",
"selection": {
"tagStatus": "tagged",
"tagPrefixList": [
"latest"
],
"countType": "imageCountMoreThan",
"countNumber": 9999
},
"action": {
"type": "expire"
}
},
{
"rulePriority": 2,
"description": "나머지 모든 태그 이미지 최근 30개 유지",
"selection": {
"tagStatus": "tagged",
"tagPatternList": [
"*"
],
"countType": "imageCountMoreThan",
"countNumber": 30
},
"action": {
"type": "expire"
}
},
{
"rulePriority": 3,
"description": "태그 없는 이미지 7일 후 삭제",
"selection": {
"tagStatus": "untagged",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 7
},
"action": {
"type": "expire"
}
}
]
}
최신 배포된 운영 이미지는 latest 태그를 붙이고 latest 태그가 붙어 있으면 절대 삭제하지 않도록 했다. 또한 개발 이미지도 같이 올림으로 모든 태그의 이미지는 30개까지만 남기고 나머지는 제거하도록 했다. 추가로 태그 없는 이미지는 7일 후 삭제하도록 했다.
- name: Build and push image
id: build-image
uses: docker/build-push-action@v6
with:
context: .
file: ./deploy/Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
// 추가
${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
JAR_FILE=build/libs/*.jar
운영 배포 스크립트에도 latest 태그를 추가해뒀다.
📈 해결 2. AWS Lambda, SNS, CloudWatch를 사용해 Discord 서버 다운/복구 모니터링
현재 모이삼은 서버가 열려있는 경우에만 모니터링 시스템이 구축되어 있다. (에러 발생 시, 디스코드 알림)
다만 이번과 같이 서버가 닫혀 있으면 모니터링할 수 없다. 만약 현수가 알려주지 않았다면 저녁에나 확인할 수 있었겠지..
그래서 모니터링을 할 수 있는 방법을 찾아보던 중, 서버 다운/복구 시에만 알림 보내면 되기 때문에 AWS Lambda를 사용해서 구축할 수 있었다.
디스코드 서버 다운/복구 알림은 다음과 같이 파이프라인을 구축했다.
ALB (HealthyHostCount) ➡️ CloudWatch Alarm ➡️ AWS SNS ➡️ AWS Lambda ➡️ Discord Webhook
(참고로 Lambda 월 100만 건, SNS 월 100만 건, CloudWatch 알람 10개까지 AWS 12개월 프리티어가 끝나도 평생(Always Free) 0원이다!)
파이프라인은 아래와 같이 동작한다.
ALB에서 서버가 닫혀있음을 확인 -> 커스텀한 Cloud Watch Alarm 발생 -> SNS가 구독한 CloudWatch Alarm을 받아서 Lambda로 내용 전달 -> Lambda에서 Discord Webhook으로 알림
2.1. Lambda 디스코드 알리미 생성

import json
import os
import urllib.request
from datetime import datetime, timezone, timedelta
KST = timezone(timedelta(hours=9))
def lambda_handler(event, context):
webhook_url = os.environ["DISCORD_WEBHOOK_URL"]
for record in event.get("Records", []):
message = json.loads(record["Sns"]["Message"])
payload = build_payload(message)
send_discord(webhook_url, payload)
def build_payload(message):
alarm_name = message.get("AlarmName", "Unknown")
state = message.get("NewStateValue", "Unknown")
reason = message.get("NewStateReason", "")
state_change_time = message.get("StateChangeTime", "")
is_alarm = state == "ALARM"
title = f"🚨 서버 다운: {alarm_name}" if is_alarm else f"✅ 서버 복구: {alarm_name}"
color = 0xFF0000 if is_alarm else 0x00FF00
timestamp = parse_kst(state_change_time)
return {
"embeds": [{
"title": title,
"color": color,
"fields": [
{"name": "상태", "value": state, "inline": True},
{"name": "발생 시각 (KST)", "value": timestamp, "inline": True},
{"name": "원인", "value": reason or "-", "inline": False},
],
}]
}
def parse_kst(iso_string):
try:
dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
return dt.astimezone(KST).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return iso_string
def send_discord(webhook_url, payload):
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
webhook_url,
data=data,
headers={
"Content-Type": "application/json",
"User-Agent": "AWS-Lambda-Moism/1.0"
},
)
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status

AWS Lambda에 함수를 생성한 뒤, 위 코드를 작성한다. Deploy를 해서 적용한다.
※ 위 코드에 DISCORD_WEBHOOK_URL은 Lambda 환경변수에 추가하거나, 문자열로 작성한다.
{
"Records": [
{
"Sns": {
"Message": "{\"AlarmName\": \"moisam-server-down\", \"NewStateValue\": \"OK\", \"NewStateReason\": \"테스트: Task가 다시 1개 이상으로 복구되었습니다.\", \"StateChangeTime\": \"2026-04-20T22:45:00.000+0000\"}"
}
}
]
}

테스트는 Test 버튼을 누른 뒤, 위 JSON을 복사하여 Event JSON에 붙여 넣은 뒤 Invoke 하면 디스코드로 알림이 온다.

2.2. SNS 주제 생성

SNS 주제는 '표준' 유형으로 생성한다. (표준 유형만 무료)

생성한 주제에서 구독을 생성한다. 이때 프로토콜은 'AWS Lambda', 엔드포인트는 2.1에서 만든 Lambda 함수를 선택한다.
2.3. CloudWatch Alarm (경보) 생성



지표 선택은 다양하게 선택할 수 있지만, 모이삼은 ALB > 대상 그룹(TG) 별 지표 > HealthyHostCount로 선택했다.
HealthyHostCount는 ALB에서 헬스체크 시 결과 기반이라 1 미만이면 서버가 정상적으로 뜨지 않았음을 의미한다. 그래서 조건에 1 미만으로 설정한 뒤, 다음으로 넘어간다.

알림은 총 2개로 설정했다.
첫 번째 경보 상태는 HealthyHostCount가 1 미만인 오류 상황인 경우, 두 번째 경보 상태는 정상인 상황의 트리거이다.
그리고 다음으로 알림 전송... 에서는 앞서 (2.2)에서 만들어둔 SNS 주제(Topic)를 선택해 주고 경보를 생성하면 된다.
🚀 결과 및 회고

컨테이너 개수가 1개가 되어서 더 이상 임계값(1.0) 미만이 아니므로 정상 상태 값의 디스코드 알림이 왔다.
앞으로 빨간 알림이 오지 않는 것을 바라지만.. 이전과는 달리 알림으로 받을 수 있어 빠른 처리가 가능할 것 같다.
이번을 계기로 서버가 닫힌 경우에도 모니터링이 필요함을 느꼈다.
앞으로 애플리케이션 오류, Slow API 등의 로그를 위와 같이 설정해서 비용 0원으로 AWS 모니터링 시스템을 구축해 봐야겠다.
'Backend > CI CD' 카테고리의 다른 글
| Jar를 다이어트 시켜보자 (Layered Jar) (0) | 2026.03.10 |
|---|---|
| [Kubernetes] K3s로 운영·스테이징 환경을 단일 서버에서 분리하기 (2) (2) | 2025.07.24 |
| [Kubernetes] K3s로 운영·스테이징 환경을 단일 서버에서 분리하기 (1) (0) | 2025.07.22 |
| [FAIL] Github Actions을 이용해 Docker Cache 관리하고 싶었으나,, (2) | 2024.04.21 |
| Github Actions, Docker, EC2를 이용한 CI/CD 구현하기 (4) | 2024.04.07 |