website: 어드민에 시스템 지표(CPU·메모리·디스크) 대시보드 추가
어드민 `/admin/infra` 페이지에 배포 이력 아래로 CPU·메모리·디스크 시계열 패널을 붙인 PR이다.
배포 이력 대시보드(#451)를 만들 때 썼던 패턴이 있었다. 호스트에 크론을 하나 심어서 필요한 값을 직접 읽고 로컬 JSON Lines 파일에 append, 백엔드는 그 파일을 읽기만 하는 구조. 이번에도 그대로 가져다 썼다. 시스템 지표를 보여주려면 크게 두 갈래 선택지가 있었는데, 하나는 백엔드가 OCI Monitoring API를 직접 조회하는 거고 다른 하나는 컨테이너 안에서 호스트의 /proc, /sys를 마운트해서 읽는 거였다. 둘 다 새 IAM 정책이거나 컨테이너 격리 범위를 넓혀야 하는 일이라 굳이 이번 작업에서 건드릴 이유가 없었다. 이미 검증된 패턴이 있는데 새 권한 구조를 여는 건 배보다 배꼽이 큰 선택이라 기각했다. (기각 경위는 infra/docs/observability.md에 남겨뒀다.)
그래서 infra/scripts/snapshot-system-metrics.py를 새로 만들었다. 5분마다 크론으로 돌면서 CPU·메모리·디스크 사용률을 읽어 logs/system-metrics/snapshot.jsonl에 한 줄씩 쌓는 스크립트다. 순수 stdlib만 써서 venv 없이도 돌아가게 했는데, 다른 push 스크립트들과 크론 등록 방식을 맞추려고 같은 venv를 쓰더라도 무해하다는 정도로만 신경 썼다.
값을 읽는 방식에서는 두 가지를 의식적으로 골랐다. CPU는 /proc/stat을 한 번 읽고 1초 뒤 다시 읽어서 그 사이 idle+iowait 대비 busy 비율을 계산했다. 순간 스냅샷이 아니라 구간 평균이라 순간적으로 튀는 로드에 흔들리지 않는다. 메모리는 MemFree가 아니라 MemAvailable 기준으로 계산했다. MemFree만 보면 회수 가능한 캐시까지 "사용 중"으로 잡혀서 실제보다 훨씬 높게 나온다.
프론트는 echarts로 시계열 차트를 그렸다. CPU·메모리·디스크 세 라인에 메모리 85%, 디스크 80% 임계치를 markLine으로 얹었는데, 두 임계치 라인이 5%p 차이라 라벨을 기본 위치(오른쪽 끝)에 두면 서로 겹쳤다. 메모리는 왼쪽 위, 디스크는 왼쪽 아래로 위치를 갈라서 해결했다. 색상 팔레트는 dataviz 스킬의 카테고리 팔레트를 그대로 썼고, 어두운 서페이스 기준으로 CVD·명도·대비 검증을 통과한 조합이라 새로 고민할 필요는 없었다.
기간 선택(6시간/24시간/7일) 토글을 붙이면서 처음엔 신경 안 썼던 부분이 하나 있었다. 6시간을 눌렀는데 화면엔 순간적으로 이전 7일치 수치가 그대로 남아있는 경우가 생겼다. range를 바꿀 때 새 응답이 올 때까지 이전 데이터를 계속 그리고 있었던 거다. setRangeIndex 호출 직전에 setSamples(null)을 먼저 해서, 새 데이터가 도착하기 전까지는 로딩 상태를 보여주도록 고쳤다.
커밋 자체는 하나로 묶였지만 그 안에 6개 관점으로 코드리뷰를 돌리고 나온 것들을 반영하는 과정이 있었다. 스냅샷 트림 로직이 그중 제일 신경 쓰인 부분이었는데, 처음엔 파일을 "w"로 열어서 통째로 다시 쓰는 식으로 30일치(8640줄)를 넘으면 앞부분을 잘라내려 했다. 근데 이 방식이면 재작성 도중 백엔드가 그 파일을 읽으면 빈 파일이나 잘린 줄을 볼 수 있고, 재작성 중 프로세스가 죽으면 이력이 통째로 날아간다. 임시 파일에 새 내용을 쓰고 os.replace()로 교체하는 방식으로 바꿨다. 같은 파일시스템 안에서 rename은 원자적이라 중간 상태가 노출될 여지가 없다. 그 외에 임계치 상수(85%, 80%)가 프론트 여러 곳에 흩어져 있던 걸 systemMetricsThresholds.ts 하나로 모았고, run-fullstack 스킬 문서에 로컬에서 샘플 데이터를 만들어 테스트하는 절차도 추가했다.
배포 이력 때 한 번 겪었던 함정이 하나 더 있어서 이번엔 미리 문서에 남겨뒀다. CD가 머지 시점에 마운트 디렉터리가 없으면 자동으로 만들어주는데, 이때 root 소유로 생성된다. 배포 이력 때 이걸 놓쳐서 크론이 파일에 쓰지 못하는 문제를 겪었던 적이 있다. 이번엔 infra/CLAUDE.md의 "미결 사항"에 크론 등록 전 logs/system-metrics 디렉터리 소유자를 반드시 확인하라고 적어뒀다. 코드로 막을 수 없는 배포 순서상의 함정이라 문서로 남기는 게 최선이었다.
백엔드는 SystemMetricsService가 JSONL을 파싱해서 최신순으로 정렬해 돌려주는 정도로 단순하게 짰다. 테스트는 파일이 없을 때 빈 리스트를 주는지, 중간에 깨진 줄이 있어도 그 줄만 건너뛰고 나머지는 파싱하는지, limit이 최대치(2016 — 7일치)를 넘으면 캡핑되는지를 확인했다. 프론트 typecheck/lint를 돌리고 로컬 풀스택으로 실제 렌더링과 기간 전환, 임계치 기준선이 제대로 보이는지 스크린샷으로 검토한 뒤 병합했다.