Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infra/monitoring/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<env>-ops`(OPS-1~5) · `ruleup-<env>-observe`(OBS-1~4) · `ruleup-prod-business` |
| 대시보드 | `alerting/dashboard.py` · `observe_dashboard.py` · `business_dashboard.py` | `ruleup-<env>-ops`(OPS-1~6) · `ruleup-<env>-observe`(OBS-1~4) · `ruleup-prod-business` |
| 배포 보호 | `.github/scripts/monitoring_preflight.py` | ALARM·stale·EligibleCount 누락·계측 불일치·BudgetFreeze (기본 warn) |
| 재무장 | `lambdas/rearm.py` | 저트래픽 예외 — 독립 정상 증거가 있을 때만 P0 Flag=0 1회 + 감사 로그 |

Expand Down
2 changes: 1 addition & 1 deletion infra/monitoring/alerting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Slack 전달은 Amazon Q Developer in chat applications(워크스페이스 `ASM
| `catalog.py` · `p0_p1.py <env>` | 노션 알람 카탈로그의 경보 정의(원본)·적용, 옛 이름 경보 정리. `tests_catalog.py` 가 등급·누락 정책·리전 계약을 시험 |
| `routing.py` | 중앙 감시 리전(us-east-1) 토픽·Slack 구성·P0 멘션 규칙 |
| `security_events.py` | SEC-02/03/04 EventBridge 규칙 |
| `dashboard.py [prod\|stg]` | 서버(운영) 대시보드 `ruleup-<env>-ops` — OPS-1~5 순서(런북이 패널 번호로 가리킨다), 4xx 로그 표, 최근 ERROR 로그 |
| `dashboard.py [prod\|stg]` | 서버(운영) 대시보드 `ruleup-<env>-ops` — OPS-1~6 순서(OPS-6 JVM 런타임: 힙 영역·GC 정지·스레드·클래스·버퍼)(런북이 패널 번호로 가리킨다), 4xx 로그 표, 최근 ERROR 로그 |
| `observe_dashboard.py [prod\|stg]` | 감시망 대시보드 `ruleup-<env>-observe`(us-east-1) — OBS-1~4 |
| `business_dashboard.py` | 사용자 지표 대시보드 `ruleup-prod-business` — ① 로그인·가입 ② 챌린지 참여(초안 경로·생성·초안 수정률·탐색/초대 가입) ③ 인증(자동 vs 수동). 묶음마다 기간 합계·비율 숫자 칸. prod 전용 |
| `p0_mention.py` | P0 → 긴급 채널 커스텀 알림(멘션)·복구 스레드, 긴급 채널 구성을 `ruleup-prod-p0-slack` 으로 |
Expand Down
18 changes: 17 additions & 1 deletion infra/monitoring/alerting/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""서버(운영) 대시보드 ruleup-<env>-ops — 노션 「운영 경보 상세」 §4.2 패널 순서(OPS-1~5). 재실행 안전.
"""서버(운영) 대시보드 ruleup-<env>-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]"""
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]) 로 보낸다 —
Expand All @@ -40,6 +48,15 @@
* <li>MaxPoolActivePct·MaxPoolPending·PoolTimeoutDelta — Hikari active/max, 커넥션 대기 스레드, 획득 timeout 증가분</li>
* <li>MaxBusyThreadPct — Tomcat 요청 스레드 busy/max</li>
* </ul>
*
* <p>대시보드 OPS-6(JVM 런타임, 노션 10/15 리뷰) 용 — 경보 없이 화면에만 쓴다.
* Micrometer {@code jvm.*} 를 통째로 내보내면 풀·원인별 태그 조합이 70개를 넘어(이름×차원 하나가 월 과금 단위) 필요한 것만 직접 잰다.
* <ul>
* <li>JvmMemoryUsedMb(Area=eden·survivor·old·metaspace·codecache) · JvmHeapMaxMb</li>
* <li>JvmGcPauseMaxMs — Micrometer {@code jvm.gc.pause} 의 최근 최대(백분위는 설정이 없어 최대·횟수로 본다) · JvmGcPauseCount — 정지 수집 횟수 증가분</li>
* <li>JvmThreads(State=live·daemon·peak·runnable·blocked·waiting) · JvmClassesLoaded · JvmBufferUsedMb(Pool=direct·mapped)</li>
* <li>ProcessCpuPct · ProcessUptimeSec — 업타임이 내려가면 재기동(Minimum 으로 본다)</li>
* </ul>
*/
@Slf4j
@Component
Expand All @@ -56,6 +73,7 @@ public class RuntimeOpsSampler implements MinuteSampler {

private final Deque<long[]> gcSamples = new ArrayDeque<>(); // [채집 시각 ms, 누적 GC 정지 ms]
private final Map<String, Double> lastTimeouts = new HashMap<>();
private long lastGcCount = -1;

public RuntimeOpsSampler(MinuteMetricBuffer buffer, MetricContext ctx, ObjectProvider<DataSource> dataSources,
MeterRegistry registry, ApplicationContext context) {
Expand All @@ -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() {
Expand Down Expand Up @@ -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<String, Long> 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<Thread.State, Integer> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> names = data.stream().map(d -> d.key().name()).collect(Collectors.toSet());
assertThat(names).contains("JvmMemoryUsedMb", "JvmHeapMaxMb", "JvmGcPauseCount", "JvmThreads", "JvmClassesLoaded", "ProcessUptimeSec");
Set<String> 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<String, String> 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; }
}
}
Loading