← 개발 로그 목록

website: 관측·알림 기반 구축 — UptimeRobot 외부 감시 + OCI Monitoring/Alarms

/ 10분 분량 / 개발 로그

멋사 경희대 사이트 프로젝트에서 서버 장애를 사용자보다 먼저 알아챌 수 있도록 외부 가동 감시와 리소스·백업 모니터링 체계를 구축한 PR입니다. 검증 과정에서 실제로 이틀간 조용히 실패하고 있던 백업 장애를 발견해 함께 고쳤습니다.

요약

이 PR은 2026-07-09에 열려 07-12에 dev 브랜치로 병합됐습니다. 목표는 단순했습니다. prod에 아직 실사용자 트래픽은 없지만 곧 어드민이 올라오고 실사용이 시작되면, "무슨 일이 나면 방문자보다 우리가 먼저 안다"는 상태를 미리 만들어두는 것. 코드 diff 자체는 크지 않은 편(+403줄, 삭제 없음)인데, 실제 작업의 대부분은 클라우드 콘솔에서 직접 만든 UptimeRobot 모니터와 OCI Alarm 리소스들이라 레포에는 그 재현 기록(문서)과 메트릭 전송 스크립트만 남아 있습니다. 그리고 이 검증을 하다가 진짜 장애(백업 스크립트 CRLF 문제)를 하나 잡아낸 게 이 PR의 하이라이트입니다.

배경 및 목적

관련 이슈는 #83입니다. PR 설명에 나온 문제의식이 명확한데, 앱과 DB가 한 서버에 같이 살고 있어서 조용한 실패가 그냥 넘어가면 데이터까지 잃을 수 있다는 것이었습니다. 그런데 지금은 뭐가 잘못돼도 알아챌 눈이 하나도 없는 상태였고요.

요구사항을 4가지로 쪼갰습니다.

  1. 사이트가 밖에서 안 열리면 알게 된다
  2. 서버가 통째로 멈추는 상황도 알아챈다
  3. 디스크·메모리가 한계에 다다르기 전에 미리 경고받는다
  4. 백업이 그동안 멀쩡히 쌓이고 있었는지 확신할 수 있다

①②는 서버가 죽어도 감지돼야 하니 감시 주체가 서버 밖에 있어야 하고, ③④는 서버 안에서만 정확히 알 수 있는 값(디스크 사용률, 백업 성공 여부)이라 서버 스스로 보고하는 방식이 맞다고 판단해서, 아예 도구를 분리했습니다.

구현 내용

외부 감시 — UptimeRobot

uptime-monitoring.md에 정리된 대로, 서버 밖에서 감시해야 한다는 요구사항 때문에 대안을 몇 가지 검토했습니다. OCI Health Checks는 유료라 제외했고, OCI Load Balancer를 헬스체크 전용으로 붙이는 방법은 무료지만 nginx 재구성까지 번져서 범위 초과라 보류했고, 자체 self-host 스택은 같은 인스턴스 위에 올리면 인스턴스가 죽을 때 감시도 같이 죽는 구조적 문제가 있어 제외했습니다. 결국 외부 무료 SaaS인 UptimeRobot으로 결정했습니다.

백엔드 prod/stage, 프론트 prod/dev 총 4개 모니터를 5분 간격으로 등록했습니다. 백엔드는 루트가 아니라 /actuator/health로 등록했는데, 루트는 인증 없이 찌르면 401/403이 정상 응답이라 다운으로 오탐할 소지가 있었기 때문입니다.

리소스·백업 관측 — OCI Monitoring/Alarms

observability.md에 아키텍처가 정리돼 있습니다. 디스크 사용률은 OCI의 기본 Compute Instance Monitoring 플러그인이 제공하지 않는 값이라(I/O 처리량만 제공, 용량%는 미제공) push-disk-metric.py를 만들어 custom metric으로 직접 전송하게 했습니다.

def instance_metadata():
    """인스턴스가 자기 자신의 OCID/compartment를 IMDS에서 런타임에 조회.

    하드코딩하지 않는 이유: 이 값들을 소스에 박아두면 gitleaks가 OCI
    OCID로 탐지해 CI를 막고(#83 PR에서 실제로 걸림), 인스턴스가 교체되면
    코드도 같이 고쳐야 함 - 둘 다 IMDS 조회로 피할 수 있음.
    """

인증은 instance principal 방식을 썼습니다. 서버가 별도 자격증명 파일 없이 자기 자신의 identity로 API를 호출하는 방식인데, 이를 위해 Dynamic Group과 Policy를 IAM에 등록했습니다.

백업 쪽은 좀 더 흥미로운 설계입니다. backup-db.sh가 백업 성공 직후 push-backup-metric.py를 호출해서 신호를 보내는데, 이 알람은 dead man's switch(무응답 감시) 패턴입니다.

값 자체(=1)엔 의미가 없고, 신호가 26시간 동안 안 들어오는 것 자체가 이상 신호. cron이 안 돌았든, 서버가 죽었든, 백업 스크립트가 중간에 실패했든 원인 불문하고 다 잡힘.

디스크 80%, 메모리 85%, 백업 부재 26시간(prod/stage 각각) 이렇게 4개 알람을 만들고 전부 ONS(Notifications) 토픽으로 이메일 발송하게 연결했습니다.

실측 검증 — 그리고 발견한 실제 장애

이 PR에서 가장 인상적인 부분은 07-09에 진행한 실발동 검증입니다. 합성 테스트로 알람이 잘 울리는지 확인하려던 건데, 백업 부재 알람 상태를 조회해보니 테스트를 시작하기도 전에 이미 FIRING 상태였습니다.

~/backup.log를 확인해보니 07-07까지는 정상 업로드됐는데 07-08 정기 cron부터 즉시 실패하고 있었습니다.

uploaded: prod/prod-2026-07-07.db
uploaded: stage/stage-2026-07-07.db
/usr/bin/env: 'bash\r': No such file or directory
/usr/bin/env: use -[v]S to pass options in shebang lines

원인은 backup-db.sh가 CRLF 줄바꿈으로 서버에 올라가면서 shebang이 깨진 것이었습니다.

$ file infra/backup-db.sh
infra/backup-db.sh: Bourne-Again shell script, Unicode text, UTF-8 text executable, with CRLF line terminators

cp backup-db.sh backup-db.sh.bak-crlf && sed -i 's/\r//' backup-db.sh && file backup-db.sh
backup-db.sh: Bourne-Again shell script, Unicode text, UTF-8 text executable

git 저장소의 blob 자체는 LF로 정상이었는데, infra/ 디렉토리가 CD 파이프라인 대상이 아니라 수동 배포 경로였던 게 문제였습니다. Windows(core.autocrlf=true)에서 체크아웃된 스크립트가 git을 거치지 않고 서버로 복사되면서 로컬에서 변환된 CRLF가 그대로 올라간 것입니다. 서버 스크립트를 고치고 밀린 백업을 수동으로 돌린 뒤, 재발 방지로 infra/.gitattributes를 추가해 *.sh/*.py는 항상 LF로 고정했습니다.

디스크·메모리 알람도 임계치를 임시로 낮춰서(10%/5%) 실제 FIRING 전환과 이메일 수신을 확인한 뒤 원복했습니다. 재부팅 복구력 테스트에서는 재부팅 후 20초 만에 컨테이너가 기존 이미지 태그 그대로 자동 재기동하는 걸 확인했지만, 동시에 이 재부팅이 UptimeRobot 5분 체크 틈새에 통째로 들어가 DOWN/UP 알림이 하나도 안 왔다는 것도 실측으로 확인했습니다. 복구가 빠를수록 오히려 놓칠 확률이 높아지는 구조적 한계인데, 무료 플랜이라 이 정도는 감내하기로 했습니다.

기술적 의사결정

Discord 웹훅 연동 — 시도했지만 보류

UptimeRobot은 이미 Discord 웹훅이 붙어 있어서 같은 채널을 OCI Alarm에도 붙이려고 두 가지 우회 방법을 시도했습니다.

  • SLACK 프로토콜로 Discord의 Slack 호환 URL을 쓰려고 했으나, OCI가 구독 생성 시점에 endpoint URL이 hooks.slack.com으로 시작하는지 서버 단에서 검증해서 바로 거부됨
  • CUSTOM_HTTPS로 Discord 웹훅 URL에 직결하려 했으나, OCI가 확인용 POST를 보내는 핸드셰이크 payload가 Discord가 기대하는 JSON 스키마가 아니라서 조용히 버려짐(영원히 PENDING)

결론적으로 OCI ONS는 Discord 포맷을 전혀 모르기 때문에, 제대로 연결하려면 페이로드를 변환해주는 중계(Oracle Functions 등)가 필요합니다. 이건 새 리소스를 추가하는 별도 작업 범위라 판단해 이번엔 보류하고, 이메일로만 수신하는 걸로 확정했습니다.

알림 이메일 품질 개선

검증 중 두 가지를 추가로 손봤습니다. 메시지 포맷을 기본값 RAW(원시 JSON)에서 사람이 읽기 좋은 ONS_OPTIMIZED로 바꿨고, 알람당 이메일이 두 번씩 온다는 보고가 있어 알람 히스토리를 확인해봤더니 실제 상태 전환은 1회뿐이었습니다. 원인은 알람 설정 문제가 아니라 OCI Notifications가 "at-least-once" 배달 방식이라 가끔 중복 전송될 수 있는 플랫폼 레벨 특성이었습니다.

배운 점 및 개선점

이 PR을 통해 확인한 것 중 가장 값진 건 감시 체계를 만든 목적 자체가 "조용한 실패를 먼저 알아채기"였는데, 그 감시 체계를 검증하러 갔다가 진짜 조용한 실패를 잡았다는 점입니다. pm/docs/learnings.md에도 이 경험이 기록됐는데, 로컬 checkout 환경(Windows autocrlf)과 git 저장소 상태(LF)가 서로 달라도 blob만 보고는 문제를 알 수 없고, git을 거치지 않는 수동 배포 경로에서는 이런 문제가 특히 잘 숨는다는 걸 배웠습니다.

또 하나는 "동시에 명령을 내렸다고 동시에 반응하는 게 아니다"라는 점입니다. prod+stage를 같은 명령으로 동시에 stop했는데, Spring Boot의 graceful shutdown 때문에 prod가 stage보다 약 1분 먼저 죽었습니다. 장애 시나리오를 설계할 때 이런 타이밍 차이를 감안해야 한다는 걸 실측으로 확인했습니다.

알려진 한계로는 프론트 다운 감지가 아직 미검증 상태로 남아있다는 것, UptimeRobot 무료 플랜의 5분 체크 간격보다 짧게 끝나는 장애는 구조적으로 놓칠 수 있다는 것, ONS 구독자가 개인 이메일 2명뿐이라 인원 의존 리스크가 있다는 것이 PR 설명에 명시돼 있습니다. 이슈 #83은 프론트 검증이 남아있어 이 PR 병합으로 자동 클로즈되지 않고 열려 있는 상태입니다.