From 491d7490c460c2d41d126df59539efc965bda7e0 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Thu, 15 Oct 2026 18:30:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=EC=97=90=20JVM=20=EB=9F=B0=ED=83=80=EC=9E=84?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90(OPS-6)=EC=9D=84=20=EB=8D=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노션 10/15 리뷰 「JVM 대시보드 지표 보강」. 힙 영역별 사용·최대 힙, GC 정지 최대·횟수, 스레드 상태, 클래스 로딩, direct/mapped 버퍼, 프로세스 CPU·업타임을 RuntimeOpsSampler 가 RuleUp/Ops 로 낸다. Micrometer jvm.* 를 통째로 내보내면 태그 조합이 70개를 넘어 필요한 19개만 직접 잰다. 경보는 없다. Co-Authored-By: Claude Opus 5.5 --- infra/monitoring/README.md | 2 +- infra/monitoring/alerting/README.md | 2 +- infra/monitoring/alerting/dashboard.py | 18 ++++- .../observability/ops/RuntimeOpsSampler.java | 81 ++++++++++++++++++- .../ops/RuntimeOpsSamplerJvmPanelTest.java | 68 ++++++++++++++++ 5 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSamplerJvmPanelTest.java diff --git a/infra/monitoring/README.md b/infra/monitoring/README.md index 18f8ab36..601e0954 100644 --- a/infra/monitoring/README.md +++ b/infra/monitoring/README.md @@ -16,7 +16,7 @@ | 감시 Lambda | `lambdas/` | 버지니아 `slo-calculator`(Rule Flag·버짓·이상 탐지) · `resource-monitor`(ECS·RES·RDS·백업), 오리건·버지니아 `edge-probe` | | 경보 | `alerting/catalog.py` → `p0_p1.py` | 카탈로그 ID 그대로. P0 = SLO-01/02·API-01·EDGE-01·NATIVE-01/02·RES-01~04·DB-10/11·OBS-04. 1분 주기 평가, P0 는 충족 후 1~2분 안에 호출(10/15 리뷰) | | 전달 | `alerting/routing.py` · `slack_channels.py` · `p0_mention.py` · `slack_fallback.py` · `security_events.py` | 서울·버지니아 토픽 → Amazon Q Slack, P0 멘션, 전달 실패 → 이메일, 보안 이벤트 | -| 대시보드 | `alerting/dashboard.py` · `observe_dashboard.py` · `business_dashboard.py` | `ruleup--ops`(OPS-1~5) · `ruleup--observe`(OBS-1~4) · `ruleup-prod-business` | +| 대시보드 | `alerting/dashboard.py` · `observe_dashboard.py` · `business_dashboard.py` | `ruleup--ops`(OPS-1~6) · `ruleup--observe`(OBS-1~4) · `ruleup-prod-business` | | 배포 보호 | `.github/scripts/monitoring_preflight.py` | ALARM·stale·EligibleCount 누락·계측 불일치·BudgetFreeze (기본 warn) | | 재무장 | `lambdas/rearm.py` | 저트래픽 예외 — 독립 정상 증거가 있을 때만 P0 Flag=0 1회 + 감사 로그 | diff --git a/infra/monitoring/alerting/README.md b/infra/monitoring/alerting/README.md index afdb4be8..4114b3c0 100644 --- a/infra/monitoring/alerting/README.md +++ b/infra/monitoring/alerting/README.md @@ -20,7 +20,7 @@ Slack 전달은 Amazon Q Developer in chat applications(워크스페이스 `ASM | `catalog.py` · `p0_p1.py ` | 노션 알람 카탈로그의 경보 정의(원본)·적용, 옛 이름 경보 정리. `tests_catalog.py` 가 등급·누락 정책·리전 계약을 시험 | | `routing.py` | 중앙 감시 리전(us-east-1) 토픽·Slack 구성·P0 멘션 규칙 | | `security_events.py` | SEC-02/03/04 EventBridge 규칙 | -| `dashboard.py [prod\|stg]` | 서버(운영) 대시보드 `ruleup--ops` — OPS-1~5 순서(런북이 패널 번호로 가리킨다), 4xx 로그 표, 최근 ERROR 로그 | +| `dashboard.py [prod\|stg]` | 서버(운영) 대시보드 `ruleup--ops` — OPS-1~6 순서(OPS-6 JVM 런타임: 힙 영역·GC 정지·스레드·클래스·버퍼)(런북이 패널 번호로 가리킨다), 4xx 로그 표, 최근 ERROR 로그 | | `observe_dashboard.py [prod\|stg]` | 감시망 대시보드 `ruleup--observe`(us-east-1) — OBS-1~4 | | `business_dashboard.py` | 사용자 지표 대시보드 `ruleup-prod-business` — ① 로그인·가입 ② 챌린지 참여(초안 경로·생성·초안 수정률·탐색/초대 가입) ③ 인증(자동 vs 수동). 묶음마다 기간 합계·비율 숫자 칸. prod 전용 | | `p0_mention.py` | P0 → 긴급 채널 커스텀 알림(멘션)·복구 스레드, 긴급 채널 구성을 `ruleup-prod-p0-slack` 으로 | diff --git a/infra/monitoring/alerting/dashboard.py b/infra/monitoring/alerting/dashboard.py index 7db93f95..724cdb16 100644 --- a/infra/monitoring/alerting/dashboard.py +++ b/infra/monitoring/alerting/dashboard.py @@ -1,4 +1,4 @@ -"""서버(운영) 대시보드 ruleup--ops — 노션 「운영 경보 상세」 §4.2 패널 순서(OPS-1~5). 재실행 안전. +"""서버(운영) 대시보드 ruleup--ops — 노션 「운영 경보 상세」 §4.2 패널 순서(OPS-1~6, OPS-6 JVM 은 10/15 리뷰). 재실행 안전. prod 는 이 화면 + 사용자 지표(business_dashboard.py) + 감시망(observe_dashboard.py), stg 는 business 없이. 런북(RB-01~03)이 패널 번호로 이 화면을 가리킨다 — 순서를 바꾸면 노션 런북도 고친다. 사용: python dashboard.py [prod|stg]""" @@ -170,6 +170,22 @@ def logs(title, query, width, x): + [sli('AvailabilityBadCount', op, id=f'b{i}', visible=False) for i, op in enumerate(OPS)] + [['RuleUp/Traffic', 'HttpRejectCount', *BASE, 'Status', '400', {'stat': 'Sum', 'label': '400', 'yAxis': 'right'}]], {'period': 60})) +# ---------------- OPS-6 · JVM 런타임 (10/15 리뷰) ---------------- +# 원천은 앱 RuntimeOpsSampler(RuleUp/Ops). 태스크 차원이 없어 Maximum = 가장 나쁜 태스크, ProcessUptimeSec 는 Minimum = 막 재기동한 태스크. +text('## OPS-6 · JVM 런타임 — 힙 영역·GC 정지·스레드·클래스·버퍼 (누수·GC 지연·락 경합)') +row(('힙·Non-heap 사용(MB) — 영역별, 기준선 = 최대 힙', [ops_m('JvmMemoryUsedMb', 'Maximum', 'Area', a, label=a) for a in ('eden', 'survivor', 'old')] + + [ops_m('JvmHeapMaxMb', label='최대 힙')] + + [ops_m('JvmMemoryUsedMb', 'Maximum', 'Area', a, label=a, yAxis='right') for a in ('metaspace', 'codecache')], {'period': 60}), + ('GC — 직후 Old(%)·정지 최대(ms)·정지 횟수', [ops_m('OldGenAfterGcPct', label='Old(GC 후, JVM-01)'), ops_m('GcPauseRatio5m', label='정지 비율 5분(JVM-02)'), + ops_m('JvmGcPauseMaxMs', label='정지 최대(ms)', yAxis='right'), + ops_m('JvmGcPauseCount', 'Sum', label='정지 횟수', yAxis='right')], {'period': 60}), + ('스레드 — 상태별', [ops_m('JvmThreads', 'Maximum', 'State', st, label=st) for st in ('live', 'runnable', 'waiting', 'blocked', 'daemon', 'peak')], + {'period': 60})) +row(('클래스 로딩(개)', [ops_m('JvmClassesLoaded')], {'period': 60}), + ('힙 밖 버퍼(MB) — direct·mapped', [ops_m('JvmBufferUsedMb', 'Maximum', 'Pool', pl, label=pl) for pl in ('direct', 'mapped')], {'period': 60}), + ('프로세스 CPU(%)·업타임(초, 최소)', [ops_m('ProcessCpuPct', label='CPU'), ops_m('ProcessUptimeSec', 'Minimum', label='업타임', yAxis='right')], + {'period': 60})) + text('## 최근 오류 로그') # 대시보드를 열 때마다 Logs Insights 가 이 범위를 훑는다(스캔 GB 과금) — 그래서 ERROR 만, 50줄만. widgets.append({'type': 'log', 'x': 0, 'y': y, 'width': 24, 'height': 8, 'properties': {'title': f'ERROR 로그 ({LOG})', 'region': R, 'view': 'table', diff --git a/src/main/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSampler.java b/src/main/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSampler.java index 4b358e8e..d0475f78 100644 --- a/src/main/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSampler.java +++ b/src/main/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSampler.java @@ -16,15 +16,23 @@ import org.springframework.stereotype.Component; import javax.sql.DataSource; + +import java.lang.management.BufferPoolMXBean; import java.lang.management.GarbageCollectorMXBean; import java.lang.management.ManagementFactory; import java.lang.management.MemoryPoolMXBean; import java.lang.management.MemoryType; import java.lang.management.MemoryUsage; +import java.lang.management.ThreadInfo; +import java.lang.management.ThreadMXBean; import java.util.ArrayDeque; import java.util.Deque; +import java.util.EnumMap; import java.util.HashMap; +import java.util.Locale; import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; /** * JVM·커넥션 풀·요청 스레드의 순간값을 30초마다({@link com.ruleup.ruleup_backend.observability.metric.MinuteMetricPublisher} 틱) 재 {@code RuleUp/Ops}(Env, Service[, Pool]) 로 보낸다 — @@ -40,6 +48,15 @@ *
  • MaxPoolActivePct·MaxPoolPending·PoolTimeoutDelta — Hikari active/max, 커넥션 대기 스레드, 획득 timeout 증가분
  • *
  • MaxBusyThreadPct — Tomcat 요청 스레드 busy/max
  • * + * + *

    대시보드 OPS-6(JVM 런타임, 노션 10/15 리뷰) 용 — 경보 없이 화면에만 쓴다. + * Micrometer {@code jvm.*} 를 통째로 내보내면 풀·원인별 태그 조합이 70개를 넘어(이름×차원 하나가 월 과금 단위) 필요한 것만 직접 잰다. + *

      + *
    • JvmMemoryUsedMb(Area=eden·survivor·old·metaspace·codecache) · JvmHeapMaxMb
    • + *
    • JvmGcPauseMaxMs — Micrometer {@code jvm.gc.pause} 의 최근 최대(백분위는 설정이 없어 최대·횟수로 본다) · JvmGcPauseCount — 정지 수집 횟수 증가분
    • + *
    • JvmThreads(State=live·daemon·peak·runnable·blocked·waiting) · JvmClassesLoaded · JvmBufferUsedMb(Pool=direct·mapped)
    • + *
    • ProcessCpuPct · ProcessUptimeSec — 업타임이 내려가면 재기동(Minimum 으로 본다)
    • + *
    */ @Slf4j @Component @@ -56,6 +73,7 @@ public class RuntimeOpsSampler implements MinuteSampler { private final Deque gcSamples = new ArrayDeque<>(); // [채집 시각 ms, 누적 GC 정지 ms] private final Map lastTimeouts = new HashMap<>(); + private long lastGcCount = -1; public RuntimeOpsSampler(MinuteMetricBuffer buffer, MetricContext ctx, ObjectProvider dataSources, MeterRegistry registry, ApplicationContext context) { @@ -71,6 +89,7 @@ public void sample() { try { jvm(); } catch (RuntimeException e) { log.debug("jvm sample failed: {}", e.toString()); } try { pools(); } catch (RuntimeException e) { log.debug("pool sample failed: {}", e.toString()); } try { threads(); } catch (RuntimeException e) { log.debug("thread sample failed: {}", e.toString()); } + try { jvmPanel(); } catch (RuntimeException e) { log.debug("jvm panel sample failed: {}", e.toString()); } } private void jvm() { @@ -133,8 +152,66 @@ private void threads() { } } - private void gauge(String name, MetricKey.Unit unit, double value) { - buffer.gauge(new MetricKey(OpsSignals.OPS, name, ctx.dims(), unit), value); + private void jvmPanel() { + Map areas = new TreeMap<>(); + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) { + String area = memoryArea(pool.getName()); + if (area != null) areas.merge(area, pool.getUsage().getUsed(), Long::sum); + } + areas.forEach((area, used) -> gauge("JvmMemoryUsedMb", MetricKey.Unit.NONE, mb(used), "Area", area)); + long heapMax = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getMax(); + if (heapMax > 0) gauge("JvmHeapMaxMb", MetricKey.Unit.NONE, mb(heapMax)); + + double pauseMax = registry.find("jvm.gc.pause").timers().stream() + .mapToDouble(t -> t.max(TimeUnit.MILLISECONDS)).max().orElse(Double.NaN); + if (!Double.isNaN(pauseMax)) gauge("JvmGcPauseMaxMs", MetricKey.Unit.MILLISECONDS, pauseMax); + long gcCount = 0; + for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) { + if (!isConcurrent(gc.getName())) gcCount += Math.max(0, gc.getCollectionCount()); + } + if (lastGcCount >= 0) buffer.add(MetricKey.count(OpsSignals.OPS, "JvmGcPauseCount", ctx.dims()), Math.max(0, gcCount - lastGcCount)); + lastGcCount = gcCount; + + ThreadMXBean threads = ManagementFactory.getThreadMXBean(); + Map states = new EnumMap<>(Thread.State.class); + for (ThreadInfo info : threads.getThreadInfo(threads.getAllThreadIds(), 0)) { + if (info != null) states.merge(info.getThreadState(), 1, Integer::sum); + } + gauge("JvmThreads", MetricKey.Unit.COUNT, threads.getThreadCount(), "State", "live"); + gauge("JvmThreads", MetricKey.Unit.COUNT, threads.getDaemonThreadCount(), "State", "daemon"); + gauge("JvmThreads", MetricKey.Unit.COUNT, threads.getPeakThreadCount(), "State", "peak"); + gauge("JvmThreads", MetricKey.Unit.COUNT, states.getOrDefault(Thread.State.RUNNABLE, 0), "State", "runnable"); + gauge("JvmThreads", MetricKey.Unit.COUNT, states.getOrDefault(Thread.State.BLOCKED, 0), "State", "blocked"); + gauge("JvmThreads", MetricKey.Unit.COUNT, states.getOrDefault(Thread.State.WAITING, 0) + + states.getOrDefault(Thread.State.TIMED_WAITING, 0), "State", "waiting"); + + gauge("JvmClassesLoaded", MetricKey.Unit.COUNT, ManagementFactory.getClassLoadingMXBean().getLoadedClassCount()); + for (BufferPoolMXBean pool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) { + String name = pool.getName().toLowerCase(Locale.ROOT); + if (name.equals("direct") || name.equals("mapped")) gauge("JvmBufferUsedMb", MetricKey.Unit.NONE, mb(pool.getMemoryUsed()), "Pool", name); + } + if (ManagementFactory.getOperatingSystemMXBean() instanceof com.sun.management.OperatingSystemMXBean os && os.getProcessCpuLoad() >= 0) { + gauge("ProcessCpuPct", MetricKey.Unit.PERCENT, 100.0 * os.getProcessCpuLoad()); + } + gauge("ProcessUptimeSec", MetricKey.Unit.SECONDS, ManagementFactory.getRuntimeMXBean().getUptime() / 1000.0); + } + + private void gauge(String name, MetricKey.Unit unit, double value, String... extraDims) { + buffer.gauge(new MetricKey(OpsSignals.OPS, name, ctx.dims(extraDims), unit), value); + } + + private static double mb(long bytes) { + return bytes / (1024.0 * 1024.0); + } + + /** 메모리 풀 이름 → 대시보드 영역. G1·Parallel·Serial·ZGC 이름을 묶는다. 모르는 풀(Compressed Class 등)은 null — 내보내지 않는다. */ + static String memoryArea(String poolName) { + if (poolName.contains("Eden") || poolName.equals("ZGC Young Generation")) return "eden"; + if (poolName.contains("Survivor")) return "survivor"; + if (isOldGen(poolName)) return "old"; + if (poolName.equals("Metaspace")) return "metaspace"; + if (poolName.startsWith("CodeHeap") || poolName.equals("Code Cache")) return "codecache"; + return null; } static boolean isOldGen(String poolName) { diff --git a/src/test/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSamplerJvmPanelTest.java b/src/test/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSamplerJvmPanelTest.java new file mode 100644 index 00000000..1988b911 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSamplerJvmPanelTest.java @@ -0,0 +1,68 @@ +package com.ruleup.ruleup_backend.observability.ops; + +import com.ruleup.ruleup_backend.config.runtime.RuntimeRole; +import com.ruleup.ruleup_backend.config.runtime.RuntimeRoleProperties; +import com.ruleup.ruleup_backend.observability.metric.MetricContext; +import com.ruleup.ruleup_backend.observability.metric.MinuteMetricBuffer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationContext; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** 대시보드 OPS-6(JVM 런타임) 지표 — 이름·차원이 대시보드와 어긋나면 위젯이 빈 칸이 된다. */ +class RuntimeOpsSamplerJvmPanelTest { + + @Test + @DisplayName("메모리 풀 이름을 대시보드 영역으로 묶고, 모르는 풀은 내보내지 않는다") + void memoryArea() { + assertThat(RuntimeOpsSampler.memoryArea("G1 Eden Space")).isEqualTo("eden"); + assertThat(RuntimeOpsSampler.memoryArea("G1 Survivor Space")).isEqualTo("survivor"); + assertThat(RuntimeOpsSampler.memoryArea("G1 Old Gen")).isEqualTo("old"); + assertThat(RuntimeOpsSampler.memoryArea("Metaspace")).isEqualTo("metaspace"); + assertThat(RuntimeOpsSampler.memoryArea("CodeHeap 'non-nmethods'")).isEqualTo("codecache"); + assertThat(RuntimeOpsSampler.memoryArea("Compressed Class Space")).isNull(); + } + + @Test + @DisplayName("한 번 채집하면 힙 영역·스레드 상태·클래스·업타임이 RuleUp/Ops 로 쌓이고, GC 횟수는 두 번째부터 증가분으로 낸다") + @SuppressWarnings("unchecked") + void samplesPanelMetrics() { + MutableClock clock = new MutableClock(); + MinuteMetricBuffer buffer = new MinuteMetricBuffer(clock); + RuntimeOpsSampler sampler = new RuntimeOpsSampler(buffer, new MetricContext("stg", new RuntimeRoleProperties(RuntimeRole.API, false)), + mock(ObjectProvider.class), new SimpleMeterRegistry(), mock(ApplicationContext.class)); + + sampler.sample(); + sampler.sample(); + clock.now = clock.now.plusSeconds(60); + var data = buffer.drainCompleted(); + + Set names = data.stream().map(d -> d.key().name()).collect(Collectors.toSet()); + assertThat(names).contains("JvmMemoryUsedMb", "JvmHeapMaxMb", "JvmGcPauseCount", "JvmThreads", "JvmClassesLoaded", "ProcessUptimeSec"); + Set states = data.stream().filter(d -> d.key().name().equals("JvmThreads")) + .map(d -> d.key().dimensions().get("State")).collect(Collectors.toSet()); + assertThat(states).containsExactlyInAnyOrder("live", "daemon", "peak", "runnable", "blocked", "waiting"); + Map heapDims = data.stream().filter(d -> d.key().name().equals("JvmMemoryUsedMb")).findFirst().orElseThrow().key().dimensions(); + assertThat(heapDims).containsEntry("Env", "stg").containsKey("Area"); + assertThat(data).allSatisfy(d -> assertThat(d.key().namespace()).isEqualTo(OpsSignals.OPS)); + } + + private static final class MutableClock extends Clock { + Instant now = Instant.parse("2026-10-15T10:00:05Z"); + + @Override public ZoneOffset getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(java.time.ZoneId zone) { return this; } + @Override public Instant instant() { return now; } + } +}