근묵자흑
AIOps 스터디 6주차 — coroot의 데이터 흐름과 RCA 책임 분담 본문
오픈 소스:
coroot/coroot@bb13d53(2026-05-15),coroot/coroot-node-agent@d9b5c26(2026-05-08),coroot/coroot-cluster-agent@2c7bef0(2026-05-11)
실측 환경: kind v1.35.0, helm chartcoroot/coroot 0.22.0(app version 1.14.3), kube-system + elastic-system + security-system + trivy-system 워크로드가 실제로 도는 환경.
1. 아키텍처 매핑 — Detection·Correlation·RCA × 컴포넌트 흐름
1.1 책의 파이프라인
Hands-on AIOps (Sabharwal & Bhardwaj, Apress 2022)는 AIOps를 이벤트 데이터 deduplication → baselining → anomaly detection → correlation → "probable causal alerts that are the root cause for an issue"로 진행되는 _단계의 연쇄_로 봅니다. 스터디 용어로는 Detection / Correlation / RCA. 5주차에서 정리했듯, RCA가 어려운 이유는 앞 두 단계가 관측된 것을 다루는 데 비해 RCA는 관측되지 않은 인과를 추론해야 하기 때문입니다.
1.2 coroot의 컴포넌트 흐름
coroot는 이 세 축을 다음과 같이 나눠 구현합니다.
flowchart TD
subgraph 수집["수집 (별도 프로세스)"]
NA["coroot-node-agent<br/>(eBPF DaemonSet)"]
CA["coroot-cluster-agent<br/>(K8s 메타·DB 메트릭)"]
end
subgraph 저장["저장"]
PROM["Prometheus<br/>(또는 coroot 내장 TSDB)"]
CH["ClickHouse<br/>otel_logs / otel_traces / profiling_*"]
end
subgraph coroot["coroot 단일 프로세스"]
COL["collector/<br/>OTLP 트레이스·로그·프로파일·메트릭 수신"]
CON["constructor/<br/>LoadWorld(): 34 stage"]
MOD["model/<br/>World·Application·Check"]
AUD["auditor/<br/>Audit(): 19 inspector"]
WAT["watchers/<br/>incidents.go: burn-rate"]
API["api/<br/>뷰 조립 + RCA 핸들러"]
end
CLOUD["Coroot Cloud<br/>/integration/rca (LLM)"]
FRONT["front/<br/>Vue UI"]
NA -->|/metrics scrape| PROM
CA -->|OTLP push| COL
NA -->|OTLP trace/log| COL
COL --> CH
PROM --> CON
CON --> MOD
MOD --> AUD
MOD --> WAT
AUD --> API
WAT -->|incident| API
CH -. "K8s 이벤트·trace" .-> API
API -. "RCARequest" .-> CLOUD
CLOUD -. "model.RCA" .-> API
API --> FRONT
세 축에 대응시키면 다음과 같습니다.
- Detection: 두 곳에 나뉩니다.
auditor/의 19개 inspector는 메트릭을 임계와 비교해Check를 켭니다(구성 요소 단위).watchers/incidents.go는 SLO error budget burn rate로 incident를 엽니다(사용자 영향 단위). RCA를 자동 촉발하는 것은 후자뿐입니다. - Correlation: 명시적 모듈이 없습니다.
constructor/의LoadWorld()가 모든 시계열을model.World라는 그래프 자료구조로 합치는 과정 자체가 topology-aware correlation입니다. 4주차의 SDG가 여기 해당합니다. - RCA: OSS는 _입력 큐레이션_까지만 합니다 — 메트릭·K8s 이벤트·트레이스 두 개를 묶어
cloud.coroot.com/api/integration/rca로 POST. 자연어 응답(RootCause,ImmediateFixes등)은 외부 Cloud의 LLM이 생성하며, 그 코드는 비공개입니다.
본문이 컴포넌트 흐름 순서를 따르는 이유는, 컴포넌트와 축이 1:1로 떨어지지 않기 때문입니다. constructor/는 Correlation의 본체이면서 SLI 계산까지 합니다. auditor/의 slo.go는 _판정을 위임_하고 결과만 옮깁니다. api/rca.go는 RCA 입구이지만 _추론은 외부_입니다.
1.3 데이터 형태 변환 — 한눈에
raw 사건이 화면의 service map 링크 한 줄이 되기까지 데이터는 일곱 번의 형태 변환을 거칩니다. 본문 3·4·5장이 각각 두~세 변환씩 담당합니다.
| # | 형태 | 코드 위치 | 본문 절 |
|---|---|---|---|
| 1 | eBPF tracepoint/syscall 이벤트 | coroot-node-agent/ebpftracer/ebpf/tcp/state.c, l7/l7.c |
3.2 |
| 2 | Event 구조체 (Go) |
ebpftracer/tracer.go:58~72 |
3.3~3.4 |
| 3 | Prometheus 메트릭 (/metrics) |
containers/metrics.go:100~108 + main.go:177 |
3.4 (실측) |
| 4 | model.Connection (instance 단위) |
model/connection.go:70~94 |
3.5 |
| 5 | recording rule 결과 rr_connection_* |
constructor/queries.go:23~33 (Go 함수) |
3.5 |
| 6 | model.AppToAppConnection (app 단위) |
constructor/connections.go:47~55 |
3.6 |
| 7 | service map JSON DTO | api/views/overview/service_map.go:54~82 |
3.7 |
이 일곱 변환의 _5단계까지_가 본 글에서 실측으로 확인 가능합니다(node-agent /metrics 출력 645 line). 6~7단계는 coroot 서버 API를 거치므로 UI 캡처가 필요합니다.
2. 사전 지식 — Observability Engineering 정리
coroot 코드가 전제하는 관측성 개념 세 가지를 Observability Engineering (Majors, Fong-Jones, Miranda, O'Reilly 2022)에서 인용해 정리합니다.
메트릭과 이벤트의 분담. 책 1장은 "시스템 메트릭이 디버깅의 1차 정보원"이라는 전통 가정이 현대 시스템에서 깨진다고 지적합니다. 차원이 풍부한 이벤트(트레이스·로그)가 필요하다는 것입니다. coroot는 이 분담을 저장소 분리로 구현합니다 — 시계열 수치는 Prometheus, 트레이스·로그는 ClickHouse. 4장에서 보겠지만 _판정_은 거의 Prometheus 쪽에서, _RCA의 서술적 증거_는 ClickHouse 쪽에서 옵니다(5장).
자동 계측의 한계와 eBPF. 책은 "자동 계측만으로는 부족하다"고 못박습니다. eBPF는 자동 계측 스펙트럼의 한 극단입니다 — 애플리케이션 코드 변경 없이 커널에서 TCP/L7을 후킹합니다. 누락이 원천적으로 없는 대신, "그 요청이 어떤 비즈니스 트랜잭션이었는지"는 모릅니다. 그 맥락은 애플리케이션이 심은 OTel span에만 있습니다. coroot가 eBPF와 OTLP 둘 다 받는 이유가 여기에 있습니다 — eBPF는 연결의 존재, OTLP는 _연결의 의미_를 채웁니다.
SLO와 burn rate. 책 13장은 error budget burn alert를 "현재 burn rate가 지속될 경우 미래의 SLO 위반을 예고하는 조기 경보"로 정의합니다. multi-window multi-burn-rate가 표준 패턴 — 짧은 윈도우와 긴 윈도우가 동시에 임계를 넘을 때만 발화해 오탐을 줄입니다. coroot의 watchers/incidents.go는 이 패턴을 두 줄로 구현합니다(4.6절).
3. 데이터 흐름 — collector → constructor
이 장은 Correlation 축에 해당합니다. eBPF가 잡은 사건이 어떤 경로로 model.World 그래프가 되는지를 흐름으로 따라가고, kind 클러스터에서 수집한 /metrics로 단계마다 실측 데이터를 붙입니다.
3.1 실습 환경
코드 리딩과 함께 다음 환경을 띄워 두면 본문의 모든 실측 박스를 재현할 수 있습니다. 본 글의 실측은 80일째 운영 중인 kind 클러스터(kind-ctem-local)에 helm으로 coroot 1.14.3을 설치해 채취했습니다.
helm repo add coroot https://coroot.github.io/helm-charts
helm install coroot coroot/coroot -n coroot --create-namespace \
--set clickhouse.enabled=false # bitnami/clickhouse 이미지 정책 변경 회피 (5장 참고)
# node-agent /metrics 직접 보기
kubectl port-forward -n coroot pod/<node-agent-pod> 18080:80
curl -s http://127.0.0.1:18080/metrics | head -40
clickhouse.enabled=false는 2025년 Bitnami 이미지 레지스트리 정책 변경으로 bitnami/clickhouse:24.12.3 태그가 사라진 데에 대한 우회입니다(트레이스·로그 수집은 비활성). 메트릭 경로(3·4장 대부분)는 영향 없이 동작합니다.
3.2 eBPF 후킹 — 한 사건이 세 종류 후킹의 합성으로 만들어집니다
코드를 열면 가장 먼저 보이는 사실은, _한 TCP 연결_이 단일 후킹이 아니라 세 종류 후킹의 합성으로 잡힌다는 점입니다.
flowchart LR
K1["커널 sock<br/>tracepoint/sock/inet_sock_set_state"] --> P1[tcp_listen_events<br/>tcp_connect_events]
K2["syscall<br/>sys_enter_connect / sys_exit_connect / sys_enter_close"] --> P1
K3["syscall<br/>sys_enter_read·write 등 11개"] --> P2[l7_events]
K4["uprobe<br/>OpenSSL / Go TLS / Java TLS / Rustls"] --> P2
K5["커널 sock<br/>retransmit kprobe"] --> P3[tcp_retransmit_events]
P1 --> R[유저공간 perf.Reader<br/>tracer.go:298~318]
P2 --> R
P3 --> R
R --> EV["Event channel"]
세 후킹의 역할은 다음과 같이 나뉩니다.
- _상태 전이_는
tcp/state.c의inet_sock_set_statetracepoint가 잡습니다.SYN_SENT → ESTABLISHED가CONNECTION_OPEN,SYN_SENT → CLOSE가CONNECTION_ERROR,CLOSE → LISTEN이LISTEN_OPEN. 모든 TCP 전이가 한 tracepoint에서 분기됩니다. - _fd ↔ 연결 매핑_은 syscall이 보완합니다. sock state만으로는 fd를 모르기 때문에
sys_enter_connect에서 pid_tgid별 fd를 임시 맵에 저장했다가 close 시점에 traffic stats(bytes_sent/bytes_received)와 함께 출력합니다. - _L7_은 read/write 계열 syscall 11개와 TLS 라이브러리별 uprobe를 더한 형태입니다. TLS 평문 캡처는 4개 라이브러리(OpenSSL, Go crypto/tls, Java TLS, Rustls)의 함수 시그니처에 매여 있으므로, 정적 링크된 OpenSSL이나 비표준 TLS 스택을 쓰면 평문이 비어 보일 수 있습니다.
L7 프로토콜 커버리지는 C 측 18개, Go 측 11개*로 비대칭입니다. HTTP·HTTP2·DNS·Postgres·Redis·Memcached·MySQL·MongoDB·ClickHouse·ZooKeeper는 C 식별과 Go payload 파싱 둘 다 있고, Kafka·Cassandra·RabbitMQ·NATS·Dubbo2·FoundationDB는 C에서 식별·카운트까지만 합니다. 메시지 브로커의 *내용 기반 증거(토픽·라우팅 키)는 coroot가 보지 않는다는 뜻입니다.
3.3 perf event 6개 map과 비대칭 버퍼
커널이 잡은 사건은 BPF_MAP_TYPE_PERF_EVENT_ARRAY 6개를 통해 유저공간으로 흐릅니다.
| perf map | per-CPU 버퍼 | read timeout | 비고 |
|---|---|---|---|
proc_events |
4 pages | 100 ms | 프로세스 시작·종료 |
tcp_listen_events |
4 pages | 100 ms | listen 상태 전이 |
tcp_connect_events |
8 pages | 10 ms | 가장 빈도 높은 TCP 이벤트 |
tcp_retransmit_events |
4 pages | 100 ms | 재전송 |
file_events |
4 pages | 100 ms | 파일 열기 |
l7_events |
32 pages | 100 ms | L7 요청 (필요 시 비활성화 가능) |
L7과 tcp_connect만 _버퍼가 크고/타임아웃이 짧다_는 점이 운영적으로 중요합니다. 버스트 트래픽에서 lost samples 로그(tracer.go:451~458)가 떨어진다면 perCPUBufferSizePages를 올려야 하는 신호입니다. L7 비활성 옵션을 켜면 32 페이지 버퍼와 reader goroutine이 통째로 사라집니다.
유저공간 reader는 perf map마다 goroutine 한 개씩 띄워(go runEventsReader(...)) 디코딩된 Event를 채널로 보냅니다.
3.4 Prometheus 메트릭으로의 노출
Event는 containers/container.go의 핸들러를 거쳐 node-agent 내부 Prometheus registry에 갱신됩니다. registry는 /metrics 엔드포인트에 노출되고, Prometheus가 스크랩합니다. 즉 _dependency map 데이터의 원천은 이 /metrics 한 곳_입니다 — ClickHouse 경로(다음 절)는 별도입니다.
raw TCP 시리즈 9개의 정의 위치는 coroot-node-agent/containers/metrics.go:100~108이고, 실제로 어떤 값이 나오는지는 다음 실측 박스에서 확인할 수 있습니다.
📊 실측 (1): node-agent /metrics에서 TCP 시리즈
kind 클러스터(node-agent 1대)에서 645줄이 수집됐고, 시리즈 분포는 다음과 같습니다.
container_net_tcp_listen_info 39 lines (listen 주소·proxy 라벨)
container_net_tcp_successful_connects 25 lines (도착지별 성공 connect 카운트)
container_net_tcp_connection_time_seconds 25 lines (connect time 누적)
container_net_tcp_bytes_sent 25 lines
container_net_tcp_bytes_received 25 lines
container_net_tcp_active_connections 25 lines
container_net_latency_seconds 15 lines (RTT 게이지)
container_log_messages_total 28 lines
container_application_type 22 lines (eBPF 기반 자동 식별)
container_resources_memory_rss_bytes 24 lines
container_resources_cpu_usage_seconds 24 lines
실제 한 줄을 그대로 보면:
container_net_tcp_successful_connects_total{
actual_destination="10.244.0.16:9200",
app_id="/k8s/security-system/otel-gateway",
container_id="/k8s/security-system/otel-gateway-8489c7b95b-d47vr/otelcol",
destination="10.96.34.137:9200",
machine_id="acc94eaafe9e48b88e29fc62371a62c4",
system_uuid=""
} 4
destination(ClusterIP 10.96.34.137)과 actual_destination(Pod IP 10.244.0.16)이 _서로 다른 라벨로 분리_되어 있다는 점이 4주차의 "topology가 판정의 1차 기준"이라는 원칙과 직접 이어집니다. K8s 환경에서 ClusterIP로 연결한 트래픽이라도, eBPF가 실제 도달한 Pod IP까지 잡아 주기 때문에 backend resolution이 가능합니다.
RTT는 같은 노드 내 통신이면 마이크로초 단위가 잡힙니다.
container_net_latency_seconds{container_id="/k8s/kube-system/kube-apiserver-...", destination_ip="172.18.0.2"} 3.541e-06
container_net_latency_seconds{container_id="/k8s/kube-system/kube-controller-...", destination_ip="172.18.0.2"} 1.4833e-05
container_net_latency_seconds{container_id="/k8s/kube-system/kube-scheduler-...", destination_ip="172.18.0.2"} 4.167e-06
3.5 μs ~ 14.8 μs. 같은 노드 내 통신이라 매우 낮습니다. 본문 4장에서 보겠지만 NetworkRTT Check의 클러스터 내 임계는 0.01초(10 ms)이므로 _세 자릿수 여유_가 있어 Check는 켜지지 않습니다 — 정상.
eBPF는 컨테이너의 binary inspection으로 _언어·런타임도 자동 식별_합니다.
container_application_type{container_id=".../etcd"} application_type="etcd" 1
container_application_type{container_id=".../kube-apiserver"} application_type="golang" 1
container_application_type{container_id=".../containerd.service"} application_type="golang" 1
이 메트릭이 4장의 inspector 진입 가드(IsPostgres(), IsRedis() 등)와 연결됩니다 — container_application_type이 채워지지 않으면 해당 inspector는 즉시 반환합니다.
로그도 같은 경로로 수집됩니다 — node-agent가 컨테이너 stdout/stderr를 읽고 severity·pattern_hash·sample을 라벨로 카운트합니다.
container_log_messages_total{container_id=".../etcd", level="warning", pattern_hash="d41d8cd9...",
sample="{\"level\":\"warn\",...,\"msg\":\"apply request took too long\",\"took\":\"119.756375ms\",...}"
} 5
container_log_messages_total{container_id=".../kube-apiserver", level="error", pattern_hash="496854b9...",
sample="E0516 11:51:41.630529 ... Error on socket receive: read tcp ...: use of closed network connection"
} 1
위 두 줄은 4장의 LogErrors Check(Event-based, 임계 0)가 level="error" 카운트를 _어떻게 보는지_에 대한 실측 입력입니다. error 1건이면 누적 카운트가 0을 넘으므로 LogErrors Check가 켜집니다.
3.5 OTLP 수신과 ClickHouse — 별개의 저장 경로
OTLP 트레이스·로그·프로파일은 _coroot 서버의 collector/_가 받아 ClickHouse에 적재합니다. 핸들러 라우팅은 다음과 같습니다.
flowchart LR
A[node-agent / cluster-agent / 앱] -->|/v1/traces| T[collector/traces.go]
A -->|/v1/logs| L[collector/logs.go]
A -->|/v1/profiles| P[collector/profiles.go]
A -->|/v1/metrics| M[collector/metrics.go]
T --> CH1[(otel_traces)]
L --> CH2[(otel_logs)]
P --> CH3[(profiling_*)]
M --> CH4[(metrics)]
ClickHouse 테이블 목록(ch/client.go)에는 otel_logs, otel_traces, otel_traces_*_mv, profiling_stacks/samples/profiles, metrics, metrics_metadata만 있습니다. TCP 연결 테이블은 없습니다. 즉 v0 초안에서 "eBPF → ClickHouse → dependency_map"으로 적힌 경로 묘사는 코드와 어긋납니다. 정확한 경로는 eBPF → Prometheus 메트릭 → constructor*이고, ClickHouse는 *별개의 책임(트레이스·로그 저장과 RCA 시점 증거 조회)을 맡습니다. 5장에서 다시 등장합니다.
3.6 constructor — LoadWorld()의 34 stage
스크랩된 메트릭은 constructor가 매 audit 주기마다 _전체 월드를 처음부터 다시 구성_해 model.World 그래프로 만듭니다. 단일 클러스터일 때 loadProjectWorld가 호출되고, 그 안에서 34개 stage가 _순차_로 실행됩니다.
flowchart TD
L[LoadWorld] --> S1[get_check_configs · query]
S1 --> S2[load_nodes · load_fqdn · load_*_metadata]
S2 --> S3[load_containers]
S3 --> S4["load_app_to_app_connections<br/>(stage 16)"]
S4 --> S5[load_application_traffic · dns]
S5 --> S6[load_jvm · go_runtime · dotnet · python · nodejs]
S6 --> S7[enrich_instances · calc_app_categories<br/>group_custom_applications · join_db_cluster_components]
S7 --> S8[load_app_settings · load_app_sli]
S8 --> S9[load_container_logs · load_app_logs]
S9 --> S10[load_app_deployments · load_app_incidents · calc_app_events]
stage 의존성 중에서 본 글의 흐름에 가장 중요한 것은 15번 load_containers가 instance-level Connection을 채운 뒤에야 16번 load_app_to_app_connections가 그 결과를 app-level 엣지로 fold할 수 있다는 점입니다. 흐름이 이 한 줄에 압축됩니다.
flowchart LR
M["/metrics<br/>(container_net_tcp_*)"] --> LC[load_containers]
LC --> CN["model.Connection<br/>(instance 단위)"]
CN --> RR["aggConnections<br/>= rr_connection_* recording rule"]
RR --> LATA[load_app_to_app_connections]
LATA --> A2A["model.AppToAppConnection<br/>(app 단위, 양방향)"]
A2A --> APP["Application.Upstreams<br/>Application.Downstreams"]
rr_connection_*은 _PromQL 스트링이 아니라 Go 함수_입니다. constructor/queries.go:453~472에 정의된 11개 recording rule은 PromQL이 아니라 aggConnections(w, func(c *model.Connection) ...) 형태로 model.World 트리에서 직접 fold됩니다. 결과는 coroot 내부 메트릭 캐시에 다시 저장되고, 다음 stage가 그것을 읽습니다.
📊 실측 (2): 외부 Prometheus에 rr_connection_*이 없습니다
이 점은 kind 환경에서 직접 확인할 수 있습니다.
curl -s 'http://prometheus:80/api/v1/label/__name__/values' \
| jq '.data[] | select(startswith("rr_"))'
# (출력 없음)
296개 메트릭 이름 중 rr_ 접두사를 가진 것은 0개입니다. 즉 recording rule은 coroot 프로세스 내부_에서만 존재하고 외부에 노출되지 않습니다. 외부 Prometheus에 보이는 것은 raw 메트릭(`container_net_tcp등)뿐입니다. 운영 관점에서는 — coroot 외부에서 Grafana로 service map을 만들고 싶다면,rr_`이 아니라 raw 메트릭을 PromQL로 다시 집계해야 한다는 뜻입니다.
load_app_to_app_connections의 양방향 엣지 채움은 14줄짜리 함수입니다.
// constructor/connections.go:47~55
conn = &model.AppToAppConnection{
Application: app,
RemoteApplication: dest,
RequestsCount: map[model.Protocol]map[string]*timeseries.TimeSeries{},
RequestsLatency: map[model.Protocol]*timeseries.TimeSeries{},
Endpoints: utils.NewStringSet(),
}
app.Upstreams[destId] = conn
dest.Downstreams[appId] = conn
한 번의 호출이 app.Upstreams[destId]와 dest.Downstreams[appId]를 같은 포인터로 양쪽에 박습니다. SDG가 이 한 번의 양방향 채움으로 완성됩니다. app/dest 라벨이 의미를 가지려면 node-agent가 TCP 상대 IP를 애플리케이션 식별자로 해석*할 수 있어야 하고, 그 해석은 4주차에서 다룬 *Pod IP → owner(Deployment/StatefulSet) → app 라벨 경로를 거칩니다.
3.7 dependency map과 service map — 두 층위
코드를 따라가면 _의존성 맵이 두 층위로 존재_합니다.
- 애플리케이션 단위 service map: overview 페이지의 메인 service map.
api/views/overview/service_map.go:54~82이app.Upstreams를 그대로 읽어 JSON DTO를 만듭니다. - 인스턴스 단위 dependency map:
model/dependency_map.go에Node/Link/UpdateLink자료구조가 정의되어 있지만, 현재 main 기준UpdateLink호출이 검색되지 않습니다.model/widget.go의 widget 필드와front/src/components/Widget.vue의 렌더러가 있지만 채우는 코드는 비어 있어 보입니다(추정 — 향후 PR 또는 다른 브랜치).
서비스 맵의 골격은 따라서 Prometheus 경로_에서만 만들어집니다. v0 초안의 "eBPF → ClickHouse → dependency_map" 묘사를 v1이 한 번 정정했고, v2의 실측(rr\ 0개)이 한 번 더 뒷받침합니다.
4. 판정 흐름 — model → auditor
이 장은 Detection 축입니다. 두 면이 있습니다 — auditor/의 19 inspector가 구성 요소 단위*를 잡고, watchers/incidents.go가 *사용자 영향 단위(SLO burn rate)를 잡습니다. RCA를 자동 촉발하는 것은 후자뿐입니다.
4.1 판정의 4가지 패턴
판정 결과는 모두 model.Check라는 한 구조체로 환원됩니다. Check가 발화하는 패턴은 네 가지뿐입니다.
flowchart TD
INS[inspector] -->|Inc| EB[Event-based<br/>count > Threshold]
INS -->|AddItem| IB[Item-based<br/>items.Len > 0]
INS -->|SetValue| VB[Value-based<br/>value > Threshold]
INS -->|Fire / SetStatus| MA[Manual<br/>fired or pre-set]
EB --> CALC[Calc 판정]
IB --> CALC
VB --> CALC
MA --> CALC
CALC --> WARN[자동 발화 = WARNING<br/>SetStatus로 더 높은 상태도 가능]
코드상 핵심은 model/check.go:592~624의 9줄짜리 switch이고, 자동 발화는 _항상 WARNING으로 시작_합니다. CRITICAL로 올라가려면 inspector가 SetStatus(CRITICAL, ...)를 명시적으로 호출해야 합니다.
운영적 함의는 단순합니다. coroot의 모든 자동 이상 탐지는 이 네 패턴 중 하나로 환원됩니다. 머신러닝 기반 이상 탐지는 없습니다. 임계 비교, 집합 비집합 여부, 명시적 발화뿐입니다. 39개 Default Threshold가 다 컴파일 타임 상수이며, 학습된 임계는 0개입니다.
분포를 보면 _Item-based가 대다수_입니다 — 39개 Check 중 28개. "문제 있는 인스턴스가 1개라도 있는가"가 가장 흔한 발화 조건입니다. Event-based 5개, Value-based 4개, Manual 3개(SLO 두 개 + InstanceAvailability).
4.2 임계의 출처 — 우선순위 5단계
Check.Threshold가 어디서 오는지는 5단계 우선순위가 있습니다.
flowchart TD
Q[Check 생성 시 Threshold 결정] --> A{app-exact 설정<br/>DB에 있는가?}
A -->|yes| AE[사용]
A -->|no| B{app-glob 설정<br/>패턴 매칭?}
B -->|yes| BG[사용]
B -->|no| C{project-level 설정<br/>있는가?}
C -->|yes| CP[사용]
C -->|no| D[DefaultThreshold 상수<br/>check.go:108~443]
SLO[SLO 두 개만<br/>K8s annotation도] -.-> AE
SLO -.-> BG
K8s annotation은 SLO에만 적용됩니다 — slo_availability_objective, slo_latency_objective, slo_latency_threshold 3개 키. 일반 Check threshold(예: PostgresConnections의 90%, NetworkRTT의 0.01초)는 annotation으로 주입되지 않으며, coroot UI 또는 DB 설정으로만 변경됩니다. v0 초안의 "K8s 어노테이션으로도 주입됩니다"는 SLO에 한정해 읽어야 합니다.
39개 DefaultThreshold를 카테고리별로 압축하면 다음과 같습니다(전체 표는 9장 참고).
| 카테고리 | 예 | 임계 (단위) |
|---|---|---|
| 가용성 카운트 | PostgresAvailability, RedisAvailability, LogErrors 등 |
0 (count) |
| 자원 비율 | CPUNode, CPUContainer, StorageSpace, InstanceAvailability |
75~80 (%) |
| DB connection | PostgresConnections, MysqlConnections |
90 (%) |
| 지연 (절대) | PostgresLatency, DnsLatency |
0.1 (s) |
| 지연 (지터/RTT) | NetworkRTT, NetworkRTTOtherClusters, NetworkRTTExternal |
0.01 / 0.1 / 0.2 (s) |
| 누수·press | MemoryLeakPercent, MemoryPressure |
10 / 0.02 |
| 배포 stuck | DeploymentStatus |
180 (s) |
| replication lag | PostgresReplicationLag, MysqlReplicationLag, MongodbReplicationLag |
30 (s) |
| 언어 런타임 | JvmSafepointTime, PythonGILWaitingTime, NodejsEventLoopBlockedTime |
0.05 / 0.05 / 0.7 (s) |
| SLO | SLOAvailability, SLOLatency |
99 (%) |
이 표가 운영의 _튜닝 출발점_입니다. 워크로드별 분포에 맞춰 임계를 재조정하지 않으면 만성 오탐 또는 만성 침묵이 발생합니다.
4.3 auditor 오케스트레이션 — 19 stage, 데이터 의존성 없음
Audit()은 매 주기마다 모든 애플리케이션에 대해 19개 inspector를 고정된 순서로 호출합니다. v0 초안의 "18 stage"는 deployments를 누락한 것으로, 실제는 19개입니다.
flowchart TD
A[Audit] --> APP[for each application]
APP --> AA[appAuditor]
AA --> SLO[slo 첫 번째]
AA --> INS[instances]
AA --> CPU[cpu / memory / storage / gpu]
AA --> NW[network / dns]
AA --> DB["DB 5개:<br/>postgres / mysql / redis / mongodb / memcached"]
AA --> RT["언어 5개:<br/>jvm / dotnet / python / nodejs / logs"]
AA --> DEP[deployments 마지막]
SLO --> R[AuditReport]
INS --> R
CPU --> R
NW --> R
DB --> R
RT --> R
DEP --> R
R --> CALC[Check.Calc 일괄]
CALC --> STATUS[r.Status = max of Check]
STATUS --> APPSTATUS["일부 report만<br/>Application.Status로 승격"]
흐름 관점에서 두 가지가 중요합니다.
첫째, inspector 사이에 데이터 의존성이 없습니다. 각 inspector는 동일한 model.World를 입력으로 받아 자기 AuditReport를 만들 뿐, 다른 inspector의 출력을 입력으로 받지 않습니다. 즉 auditor는 correlation을 하지 않습니다. 19개 inspector는 19개의 병렬적 관점입니다.
둘째, 모든 리포트가 앱 상태를 좌우하지 않습니다. 4개 리포트만 — Postgres, Redis, Instances, SLO — 앱 전체 상태로 승격됩니다. CPU·메모리 Check가 빨개도 앱 자체는 노란색이거나 초록색일 수 있습니다. DB 가용성·지연·연결*과 *인스턴스 가용성, _SLO 위반_만 앱 색깔을 바꿉니다.
stage 순서가 _증상 우선_이라는 점도 운영적 의미를 가집니다. slo가 가장 먼저, instances가 다음 — 둘 다 사용자 영향에 가까운 관점. UI에서 본 리포트 순서가 이 코드 순서에서 옵니다.
4.4 4개 inspector 흐름
본문 깊이를 위해 자주 다뤄지는 4개 inspector를 흐름 중심으로 정리합니다.
Postgres
flowchart LR
PG[postgres inspector] --> GUARD{IsPostgres or<br/>has Postgres type?}
GUARD -->|no| END[리포트 없음]
GUARD -->|yes·no metric| UNK[리포트 = UNKNOWN]
GUARD -->|yes·metric| AVL[availability<br/>Item-based]
GUARD -->|yes·metric| LAT[latency<br/>Item-based · 0.1s]
GUARD -->|yes·metric| REP[replication lag<br/>Item-based · 30s]
GUARD -->|yes·metric| CON[connections<br/>Item-based · 90%]
네 개 모두 Item-based입니다. 각 인스턴스를 순회하며 조건을 만족하면 AddItem(instance.Name)을 호출합니다. _판정 불가(UNKNOWN)와 정상은 다르게 취급된다_는 점이 중요합니다 — 메트릭이 없으면 UNKNOWN으로 두고 끝냅니다.
연결 판정은 6주차 시나리오의 핵심입니다. 코드는 _Postgres 서버 측 max_connections_만 봅니다. 애플리케이션 측 connection pool(HikariCP의 maximumPoolSize, pgbouncer의 pool_size)은 시야 밖입니다. 7.2절의 변형 B-2가 이 사각지대를 드러내는 테스트입니다.
replication lag 판정은 코드를 짐작과 다르게 만드는 함수입니다. WAL LSN 차이(바이트)를 primary LSN 시계열을 역방향으로 훑어 "몇 초어치"로 환산합니다. 즉 임계는 "30 바이트"가 아니라 "30 초"입니다. wraparound(클러스터 전체 재배포로 LSN 초기화) 보호까지 들어 있습니다.
Network
flowchart LR
NW[network inspector] --> GUARD{len Upstreams == 0?}
GUARD -->|yes| END[리포트 없음]
GUARD -->|no| ROUTE{Upstream 분류}
ROUTE -->|ExternalService| EXT["NetworkRTTExternal<br/>임계 0.2s"]
ROUTE -->|타클러스터| OTH["NetworkRTTOtherClusters<br/>임계 0.1s"]
ROUTE -->|내부| INT["NetworkRTT<br/>임계 0.01s"]
NW --> CONN[NetworkTCPConnections<br/>NetworkConnectivity]
내부 통신과 외부 호출에 임계가 20배 차이(0.01s vs 0.2s)나는 것이 코드의 핵심입니다. 같은 "느림"이라도 토폴로지 위치에 따라 기준이 다르며, 이 분류가 4주차의 "topology가 판정의 1차 기준" 원칙과 직접 이어집니다.
HasConnectivityIssues()는 "RTT 시계열이 존재했는데 최근 구간이 비어 있다"로 정의합니다 — 한때 통신하던 상대와 지금은 측정이 안 된다는 뜻이고, 이를 연결 끊김으로 봅니다. 이 정보가 service map 그 링크*의 상태가 됩니다. 다만 — *causal/impacted 구분은 OSS UI에 없습니다. 영향받은 모든 링크가 평등하게 빨개지며, 원인 식별은 사용자 또는 Cloud RCA의 PropagationMap이 합니다.
Deployments
deployments inspector는 DeploymentStatus 하나만 만듭니다 — Value-based, 임계 180초. "롤아웃이 3분 넘게 진행 중"이면 발화합니다. 더 중요한 역할은 _Check 발화가 아니라 타임라인 앵커_입니다 — enrichWidgets()가 모든 차트 위젯에 배포 이벤트를 _annotation_으로 부착합니다. 화면의 모든 차트에 "여기서 v1.2.3이 배포됨"이라는 세로선이 그어집니다. RCA가 "증상이 시작된 시각"과 "변경이 일어난 시각"을 정렬할 _재료_가 됩니다.
SLO
flowchart LR
SLO[slo inspector] --> CR[SLOAvailability·SLOLatency<br/>둘 다 Manual]
CR --> LASTINC{lastIncident<br/>app}
LASTINC -->|있음| COPY[incident burn rate severity를<br/>Check 상태로 복사]
LASTINC -->|없음| OK[Check 상태 = OK]
slo.go 자체는 판정을 하지 않습니다. lastIncident(app)를 호출해 이미 열려 있는 incident가 있는지 보고, 있으면 그 severity를 그대로 가져옵니다. SLO 위반의 진짜 판정은 watchers/incidents.go에서 일어납니다. 파일 이름이 slo.go라고 해서 SLO 판정이 거기 있는 것은 아닙니다 — 코드를 짐작과 다르게 만드는 지점입니다.
4.5 위젯의 순서와 deployment annotation
enrichWidgets()은 두 일을 합니다 — 비어 있는 차트·히트맵을 _제거_하고, 통과한 위젯에 _deployment annotation_을 _부착_합니다. 정렬은 sort.SliceStable(... widgets[i].Table != nil). Go 의미상 true가 앞으로 가므로 테이블 위젯이 앞쪽입니다 — v0 초안의 "테이블 뒤로"는 정정합니다.
4.6 incidents.go — burn rate 두 줄
Detection의 두 번째 면은 두 줄짜리 규칙 상수입니다.
// model/alert.go:19~20
{LongWindow: timeseries.Hour, ShortWindow: 5 * timeseries.Minute, BurnRateThreshold: 14.4, Severity: CRITICAL},
{LongWindow: 6 * timeseries.Hour, ShortWindow: 15 * timeseries.Minute, BurnRateThreshold: 6, Severity: CRITICAL},
multi-window multi-burn-rate. 짧은 윈도우와 긴 윈도우가 동시에 임계를 넘을 때만 severity가 올라갑니다.
flowchart TD
SLI[SLI series<br/>availability·latency]
SLI --> CALC[calcBurnRates<br/>incidents.go:218]
CALC --> R1{1h burn > 14.4<br/>AND 5m burn > 14.4?}
CALC --> R2{6h burn > 6<br/>AND 15m burn > 6?}
R1 -->|yes| INC[incident 발화<br/>severity = CRITICAL]
R2 -->|yes| INC
INC --> SLOCHK[slo.go가 status를 복사]
INC --> RCA[IncidentRCA 자동 트리거]
이 그림이 v2의 핵심을 다시 확인합니다 — RCA를 자동 촉발하는 것은 inspector의 Check가 아니라 incident이고, incident는 SLO burn rate에서만 나옵니다. PostgresLatency Check가 켜졌다고 RCA가 자동으로 도는 것이 아닙니다. _사용자 영향이 측정될 때_만 RCA가 돕니다.
5. RCA 흐름 — api → cloud → front
코드 안에 LLM 호출은 0건입니다. OSS는 RCA의 _입력 큐레이션_까지만 합니다.
rg -i 'openai|anthropic|llm|gpt|completion' \
/Users/kikim/github/aiops/coroot/_src/coroot/ \
--type go -g '!*_test.go' -g '!vendor/*'
# (no output, exit code 1)
비공개 추론과의 경계는 cloud/rca.go:45의 단 한 줄 HTTP POST입니다. 따라서 본 글에서 추적할 수 있는 것은 coroot가 외부 LLM에게 무엇을 어떻게 골라 보내는가 — 즉 입력 큐레이션입니다.
5.1 RCA 핸들러의 흐름
flowchart TD
TRIG[수동 RCA / 자동 IncidentRCA] --> H[api/rca.go: RCA / IncidentRCA]
H --> ST[cloudAPI.RCAStatus]
ST -->|AI disabled / Out of credits| STOP1[종료]
ST -->|OK| MC{프로젝트 멀티클러스터?}
MC -->|yes| STOP2["RCA is not supported<br/>for multi-cluster projects"]
MC -->|no| INPUT[입력 큐레이션 3개]
INPUT --> M["1. ctr.QueryCache<br/>=== 메트릭 12 카테고리"]
INPUT --> K["2. ch.GetKubernetesEvents<br/>=== K8s 이벤트 LIMIT 1000"]
INPUT --> T["3. ch.GetTracesViolatingSLOs<br/>=== error trace 1 + slow trace 1"]
M --> REQ[RCARequest 구성]
K --> REQ
T --> REQ
REQ --> PACK[msgpack 직렬화]
PACK --> ZIP[lz4 압축]
ZIP --> POST["POST cloud.coroot.com<br/>/api/integration/rca"]
POST --> CLOUD["☁ Coroot Cloud<br/>(LLM 추론, 비공개)"]
CLOUD --> RESP[model.RCA 응답]
RESP --> FRONT[front/views/RCA.vue · Incident.vue]
api/rca.go에는 _두 개의 RCA 핸들러_가 있습니다 — 사용자가 UI에서 트리거하는 RCA()와 incident 발화 시 자동 호출되는 IncidentRCA(). 둘은 RCAStatus(incidentsAutoInvestigation=...) 인자만 다릅니다. 멀티클러스터 거부 메시지는 한쪽이 mult-cluster 오타입니다 — 본인이 코드 검색 시 정확한 표기에 유의.
게이트는 reject 14개와 soft fail 4개*로 나뉩니다. ClickHouse 클라이언트 실패, K8s 이벤트 조회 실패, 트레이스 조회 실패는 *soft fail — 즉 그 증거 없이도 RCA가 진행됩니다. 운영적 함의는, RCA 결과가 트레이스를 인용한다면 "트레이스 증거가 있었던 경우"이고, 인용하지 않는다면 "트레이스 수집이 soft fail됐을 수도 있다"는 점을 코드 외부에서는 가를 수 없다는 것입니다.
5.2 입력 ① — 메트릭
constructor.QueryCache가 캐시된 시계열을 그대로 가져와 RCARequest.Metrics에 담습니다. 카테고리는 12개로, 본문 3장에서 본 raw 메트릭들과 K8s 메타데이터, 그리고 응용 L7(HTTP·Postgres)이 모두 포함됩니다. _raw span이 아니라 집계된 시계열_이라는 점이 중요합니다.
flowchart LR
Q[QueryCache] --> N1[node agent up/info]
Q --> N2[node cloud·cpu·memory·disk·net·gpu]
Q --> K[k8s service·workload·pod]
Q --> C1[container resource cpu·memory·oom·restarts]
Q --> C2[container volume·gpu·listen]
Q --> N3[container_net_tcp_* app-to-app]
Q --> L[container_log_messages]
Q --> H[HTTP L7 count·latency·histogram]
Q --> P[Postgres L7 queries·latency]
Q --> RR[rr_connection_*]
Q --> SLI[application_custom_sli]
긴 기간 조회에서는 step이 강제로 커집니다(api/api.go:2355~2369의 increaseStepForBigDurations). 즉 시간 범위가 클수록 분해능이 떨어집니다. cache to가 요청 to보다 과거면 요청이 cache to로 잘립니다. 두 경우 모두 _RCA가 보는 메트릭 해상도가 시간 범위에 의존_한다는 점은 운영 시 알아둘 가치가 있습니다.
5.3 입력 ② — K8s 이벤트, LIMIT 1000
ch.GetKubernetesEvents(ctx, from, to, 1000). 정의는 clickhouse/logs.go:158이고, service.name = "KubernetesEvents" 필터로 otel_logs 테이블에서 읽습니다. 그리고 — SQL에 ORDER BY가 없습니다.
SELECT ServiceName, Timestamp, ..., Body, TraceId, ResourceAttributes, LogAttributes
FROM @@table_otel_logs@@
WHERE ServiceName = 'KubernetesEvents' AND Timestamp BETWEEN @from AND @to
LIMIT 1000
즉 "최신 1000건"이 아니라 _ClickHouse가 임의 순서로 반환한 1000건_입니다. severity 필터도 없으므로 Warning과 Normal이 섞여 들어옵니다. 짧은 시간에 K8s 이벤트가 폭증하면 어떤 1000건이 RCA 입력에 들어갈지는 ClickHouse의 스토리지·파티션 순서에 달려 있습니다. 운영적 함의는, RCA 결과가 K8s 이벤트를 인용한다면 그 인용이 _대표적_인지 _우연히 잡힌 1000건 안에 있었던 것_인지를 OSS 코드만으로는 가를 수 없다는 점입니다.
5.4 입력 ③ — GetTracesViolatingSLOs, LIMIT 1 × 2
ch.GetTracesViolatingSLOs(ctx, from, to, world, app). 정의는 clickhouse/traces.go:165. 반환값이 두 개입니다 — error trace 1개, slow trace 1개.
-- error trace (LIMIT 1, ORDER BY 없음)
SELECT Timestamp, TraceId, SpanId, ..., StatusCode
FROM @@table_otel_traces@@
WHERE ServiceName = <auto-resolved>
AND (SpanKind = 'SPAN_KIND_SERVER' OR SpanKind = 'SPAN_KIND_CONSUMER')
AND Timestamp BETWEEN @from AND @to
AND StatusCode = 'STATUS_CODE_ERROR'
LIMIT 1
-- slow trace (조건: app.LatencySLIs[0].Config.ObjectivePercentage > 0)
-- Duration >= ObjectiveBucket * second 필터, 나머지 동일, LIMIT 1, ORDER BY 없음
LIMIT 1이 두 번. 첫 span의 TraceId로 다시 전체 span을 조회해 한 트레이스를 완성합니다. _Cloud에 가는 trace 증거는 두 개_입니다.
선택 편향을 정리하면:
- 대표성 보장 없음:
LIMIT 1+ORDER BY없음. "가장 최근 에러"도 "가장 오래 걸린 에러"도 아닙니다. - 샘플링과 직접 결합: 입력은
@@table_otel_traces@@. 적재되지 않은 트레이스는 보이지 않습니다. tail-based 샘플링이면 정상 트레이스가 잘리고 에러가 보존되지만 head-based면 반대입니다. ServiceName추정 실패 시 통째로 nil:getOtelTracesServiceName이 빈 문자열이면 두 트레이스 모두 nil. 그리고 게이트 18번이 _soft fail_이므로 호출자는 그 사실을 모릅니다.- 느린 트레이스 임계가 LatencySLI에 묶임: LatencySLI가 없는 앱은 느린 트레이스 증거가 없습니다. SLO를 설정하지 않은 앱의 RCA는 error trace 한 개만으로 추론됩니다.
8장 심화 ④의 답입니다 — RCA의 trace 증거는 "샘플링·정렬 없는 두 점"이고, LLM 추론의 결과는 그 두 점의 우연성에 의존합니다.
5.5 직렬화·전송 — msgpack + lz4, 타임아웃 없음
RCARequest는 12개 필드(Ctx, ApplicationId, CheckConfigs, ApplicationDeployments, ApplicationCategorySettings, CustomApplications, CustomCloudPricing, Metrics, KubernetesEvents, ErrorTrace, SlowTrace). msgpack 직렬화 후 lz4 압축, POST /integration/rca. 헤더 4개 — X-API-KEY, X-DEPLOYMENT-UUID, X-INSTANCE-UUID, X-RETURN-URL.
전송에 타임아웃과 재시도 정책이 없습니다. http.DefaultClient.Do(req). Go의 DefaultClient는 기본 타임아웃이 0이므로 Cloud가 느릴 때 OSS의 RCA 핸들러는 무한 대기에 가깝게 매달립니다(추정 — 운영 환경에서 측정해 볼 가치 있는 항목).
기본 URL은 https://cloud.coroot.com. CLOUD_URL 환경변수로 dev 오버라이드. _기본 설치에서 외부 인터넷 연결이 필수_입니다.
5.6 Cloud 응답 — model.RCA 8개 필드
Cloud는 model.RCA 구조체로 응답합니다 — 자연어 4개(ShortSummary, RootCause, ImmediateFixes, DetailedRootCause), 구조화된 응답 2개(PropagationMap, Widgets), 상태 2개. OSS는 이 구조체를 정의하고 받아서 렌더링할 뿐입니다.
PropagationMap이 4장에서 보류한 _causal vs impacted 구분_의 자리입니다. 다만 자료구조에 causal: bool 같은 명시 enum이 없습니다 — 구분은 그래프 방향(upstreams/downstreams)과 Cloud가 만든 Issues 텍스트, 그리고 Stats 라벨에 묻혀 있습니다. OSS front(PropagationMap.vue)는 그 그래프를 그릴 뿐 구분을 강조하지 않습니다.
front 렌더링 경로:
- 앱 overview RCA 패널:
front/src/views/RCA.vue - incident detail:
views/Incident.vue - incident 리스트의 RCA 상태 아이콘:
views/Incidents.vue RootCause/ImmediateFixes등 markdown의WIDGET-N치환:components/Markdown.vue- propagation 그래프 박스·화살표:
components/PropagationMap.vue
v0 초안에서 짚었던 Check.vue/CheckDetails.vue/Inspections.vue는 RCA 결과를 직접 그리는 컴포넌트가 아닙니다 — inspection·report 패널의 일반 렌더러입니다. RCA 전용 뷰는 views/RCA.vue와 views/Incident.vue입니다.
5.7 MCP — 방향이 반대인 17개 도구
5.1~5.6은 coroot → 외부 LLM 방향입니다. api/mcp.go는 반대 — 외부 LLM 에이전트 → coroot 방향. MCPHandler.registerTools()에 등록된 도구는 17개이고, 대부분 읽기 전용입니다.
| 카테고리 | 도구 | 변경 가능? |
|---|---|---|
| 프로젝트 | list_projects, select_project |
select_project만 세션 상태 변경 |
| 앱 | list_applications, get_application_status |
읽기 전용 |
| 알림·incident | list_alerts, list_incidents, get_incident_details, resolve_alerts |
resolve_alerts만 변경 |
| 노드 | list_nodes, get_node_details |
읽기 전용 |
| 트레이스 | traces_summary, traces_errors, traces_outliers, get_trace |
읽기 전용 |
| 메트릭 | list_metric_names, query_metrics |
읽기 전용 |
| 로그 | query_logs |
읽기 전용 |
get_incident_details는 "incident 상세 + RCA + propagation map"을 한 번에 줍니다. 외부 에이전트는 list_incidents → get_incident_details 두 번 호출로 RCA 답을 얻습니다. 권한은 기존 coroot 사용자 권한을 그대로 따릅니다 — agent가 사용자 세션의 권한 내에서만 동작합니다.
RCA 파이프라인을 agentic으로 확장할 때 (8주차 이후 예상 주제), 이 두 방향의 차이 — coroot가 RCA 서술의 _생산자_인 경로와 관측 데이터의 _공급자_인 경로 — 가 책임 경계 설계의 출발점입니다.
5.8 OSS↔Cloud 경계의 함의 — RCA 신뢰도의 세 항
OSS에 LLM 호출이 0건이라는 사실은 _coroot 설계의 의도_입니다. 입력 큐레이션은 코드로 공개하되, 추론은 닫아 둡니다.
운영 관점에서 RCA 신뢰도는 세 항의 곱으로 보는 것이 맞습니다.
RCA 신뢰도 = (입력 큐레이션 품질) × (LLM 추론 품질) × (프롬프트 설계 품질)
세 항 중 첫 번째만 OSS 코드로 평가 가능합니다. 그리고 첫 번째에는 다음 제약이 있습니다.
- 메트릭은 step이 시간 범위에 따라 자동 확대 → 긴 기간에서 분해능 감소
- K8s 이벤트는
ORDER BY없는LIMIT 1000 - 트레이스는
ORDER BY없는LIMIT 1× 2, ServiceName 추정 실패 시 0개 - 트레이스 수집 실패는 soft fail (RCA가 그 사실 없이 진행)
따라서 RCA 결과를 _조사의 출발 가설_로 다루고, ImmediateFixes를 검증 없이 실행하지 않는 것이 안전합니다.
6. 실무 활용 포인트와 K8s 연계
6.1 어디까지 믿고 쓸 수 있는가
코드와 실측을 보고 나면 _실무에서 어디까지 믿고 쓸 수 있는지_가 또렷해집니다. 세 영역으로 나뉩니다.
_수집과 토폴로지 구성_은 신뢰할 만합니다. eBPF 기반 수집은 계측 누락이 원천적으로 없고, loadAppToAppConnections가 만드는 SDG는 메트릭만 흐르면 코드 변경 없이 채워집니다. 본 글의 3.4절 실측에서 본 것처럼 destination(ClusterIP)과 actual_destination(Pod IP)이 _별도 라벨_로 잡혀 K8s 환경의 backend resolution도 자연스럽게 동작합니다.
_판정_은 신중하게 봐야 합니다. 모든 Check는 상수 임계의 단일 시점 비교(거의 모두 .Last())입니다. PostgresLatency 0.1초, PostgresConnections 90%, NetworkRTT 0.01초 같은 기본값이 _내 워크로드에 맞는지_는 별도 검증이 필요합니다. 4.2절의 카테고리별 표를 출발점으로, 워크로드 분포에 맞춰 _기본 임계 튜닝_을 도입 시 첫 작업으로 잡는 것이 좋습니다. 그리고 4.4 Postgres에서 본 _서버 측 max_connections만 봄_과 같은 사각지대를 알고 시작하는 것이 안전합니다.
_RCA 결과_는 가장 신중해야 합니다. 5장 전체에서 본 것처럼 OSS는 입력 큐레이션까지만 합니다. 입력에는 세 가지 강한 제약(메트릭 step 자동 확대, K8s 이벤트 ORDER BY 없음, trace LIMIT 1)이 있고, soft fail이 운영자에게 드러나지 않습니다. RCA 출력을 검증 없이 받아들이지 않는 운영 규율이 필요합니다.
6.2 K8s 연계 방안
_임계의 GitOps 관리는 SLO에 한정_해서만 자연스럽습니다. slo_availability_objective, slo_latency_objective, slo_latency_threshold 3개 annotation은 ArgoCD/Flux로 매니페스트와 함께 버전 관리할 수 있습니다. 일반 Check threshold는 annotation 경로가 없으므로 coroot 설정 export/import 워크플로가 별도로 필요합니다.
_deployments inspector와 배포 도구의 정렬_은 매끈하게 동작합니다. Argo Rollouts 같은 progressive delivery 도구를 쓴다면 canary 단계 전환과 coroot deployment annotation이 같은 시각 축에 정렬되어 "어느 canary 단계에서 SLO가 흔들렸나"를 확인할 수 있습니다.
HPA/KEDA와의 신호 공유_는 *raw 메트릭 단위_로 가는 것이 안전합니다. coroot Check를 직접 스케일링 신호로 쓰면 단일 시점 비교 때문에 진동(flapping)이 일어납니다. KEDA의 스케일러는 *원천 메트릭(예: container_net_tcp_active_connections)을 직접 받게 하고, 안정화는 KEDA의 cooldownPeriod로 다루는 편이 안전합니다.
_MCP를 통한 incident 응답 자동화_는 ChatOps 통합에 적합합니다. 5.7절의 17개 도구 중 15개가 읽기 전용이라 agent에게 안전한 표면을 제공합니다. resolve_alerts만 alerts edit 권한을 요구하므로, 변경 권한 부여는 신중히.
7. 테스트 시나리오와 검증 방법
코드 리딩을 _행동으로 검증_하는 시나리오를 정리합니다. 3.1절의 실습 환경을 그대로 활용합니다.
7.1 시나리오 A — Check 4-type 발화 조건
Calc() 9줄짜리 코어(4.1절)를 행동으로 확인합니다. 한 서비스에 의도적으로 부하나 에러를 주입하고 어떤 Check가 언제 켜지는지 관찰합니다. Event-based(LogErrors, MemoryOOM)는 누적 카운트가 임계를 넘는 순간 발화, Item-based(NetworkRTT, PostgresLatency)는 문제 인스턴스가 하나라도 생기면 발화, Manual(SLOAvailability, InstanceAvailability)은 자동 임계 비교가 없고 Fire()/SetStatus() 호출이 있어야 발화 — 차이를 화면으로 직접 보는 것이 목표입니다.
7.2 시나리오 B — 6주차 핵심 시나리오의 fault injection
"K8s 새 버전 배포 → DB connection pool 설정 오류 → API latency 급증"을 세 변형으로 나눠서 테스트합니다.
변형 B-1 (Postgres 측 고갈). max_connections를 낮추거나 연결을 인위적으로 점유해 서버 측 연결을 90% 이상으로 만듭니다. 예상 — PostgresConnections Check가 켜집니다. coroot가 잘 잡는 경우.
변형 B-2 (애플리케이션 pool 측 고갈). Postgres max_connections는 충분하되 애플리케이션 connection pool을 너무 작게 설정합니다. 예상 — Postgres 측 연결 수가 낮으므로 PostgresConnections Check는 _침묵_합니다. SLOLatency와 PostgresLatency(pool 대기로 쿼리 왕복이 길어 보임)에 신호가 나타납니다. 이 변형이 coroot 사각지대를 드러내는 테스트입니다 — 증상은 보이는데 원인 Check는 침묵합니다.
변형 B-3 (Chaos Mesh로 DB pool 고갈). StressChaos 또는 네트워크 지연으로 DB 연결을 묶어 둡니다. 예상 순서는 다음과 같습니다.
- eBPF가 늘어난 connection time / 실패한 연결 감지 →
network.go의NetworkConnectivity/NetworkTCPConnectionsCheck - 쿼리 왕복 지연 →
postgres.go의PostgresLatencyCheck - 사용자 영향 누적 → burn rate가 임계 도달 →
incidents.go가 incident 발화 - incident가
slo.go의SLOLatencyCheck 색깔을 바꿈 - incident가
api/rca.go의IncidentRCA자동 트리거
①~⑤가 실제 UI에서 그대로 나타나는지, 특히 ③의 incident가 ①②의 Check보다 늦게 나타나는지(multi-window AND가 윈도우 누적을 요구) 확인합니다.
7.3 시나리오 C — service map의 causal/impacted 미구분
의존성 체인(A → B → C)에서 맨 아래 C*에 장애를 주입합니다. 예상 — service map에서 A→B 링크와 B→C 링크가 *둘 다 빨갛게 됩니다. OSS UI는 "C가 원인"이라고 표시하지 않습니다. Cloud RCA의 PropagationMap에 C를 시사하는 텍스트(Issues 필드 또는 화살표 방향)가 들어가는지까지 확인하면 4주차 correlation과 5장 RCA의 경계가 행동으로 보입니다.
7.4 시나리오 D — burn rate 윈도우의 시간 지연 측정
일정 비율의 에러를 지속적으로 주입하고, 에러 시작 시각과 incident 발화 시각의 _차이_를 측정합니다. multi-window AND 조건 때문에 에러율이 낮으면 incident가 수십 분 늦거나 아예 안 열립니다. 이 지연은 버그가 아니라 오탐 억제를 위한 설계 — 측정값을 2장의 책 내용과 대조합니다.
7.5 시나리오 E — RCA 입력의 대표성 검증
5.3절(K8s 이벤트 ORDER BY 없음, LIMIT 1000)과 5.4절(trace LIMIT 1)이 실측에서도 그렇게 동작하는지 확인합니다. K8s 이벤트를 1500건 이상 발생시킨 뒤(여러 Pod의 빠른 OOMKill 등) RCA를 트리거하고, coroot 로그 또는 ClickHouse에 같은 조건으로 직접 쿼리해 "RCA에 들어간 1000건이 어떤 1000건인가"를 비교합니다. trace 쪽도 같은 방식으로 — 같은 에러를 여럿 만들고 GetTracesViolatingSLOs가 어떤 trace 1개를 선택했는지 확인. 결과는 본인 환경 ClickHouse 스토리지 정렬에 따라 다를 수 있고, 그 비결정성 자체가 _RCA 입력의 한계_에 대한 증거입니다.
8. 추가 심화 주제
원판 5개와 v2에서 새로 발견한 2개를 묶어 7개로 정리합니다.
① model.World 재구성의 비용 구조. LoadWorld()는 매 audit 주기마다 전체 월드를 처음부터 다시 만듭니다. 34 stage가 순차 실행되므로 대규모 클러스터에서 audit 주기 자체가 비용입니다. Profile 구조체의 stage 타이밍으로 측정해 볼 가치가 있습니다 — RCA 신선도는 이 재구성 주기에 묶입니다.
② Check 임계의 상수 vs 설정 vs 학습 스펙트럼. 학습된 임계는 0개입니다. MemoryLeakPercent(시간당 증가율)처럼 이미 추세를 보는 Check는 baselining의 후보입니다. 반대로 PostgresConnections처럼 물리적 상한(max_connections)이 있는 Check는 상수가 적절합니다. _임계가 물리적·계약적 상한에 닻을 내리고 있는가_가 구분 기준입니다.
③ eBPF L7 파싱의 프로토콜 커버리지. _C 측 18개 vs Go 측 11개_의 비대칭이 RCA 증거의 범위를 결정합니다. Kafka·RabbitMQ·NATS는 카운트만, 토픽·라우팅 키는 보이지 않습니다. TLS 평문은 4종 uprobe(OpenSSL/Go/Java/Rustls)에 매여 있어 정적 링크·비표준 스택은 평문 비어 보입니다. 실무 도입 전 점검 필요.
④ GetTracesViolatingSLOs의 선택 편향. LIMIT 1, ORDER BY 없음, 샘플링과 직접 결합. tail-based vs head-based 샘플링에 따라 결과가 뒤집힙니다. RCA 입력의 대표성 문제이며, 본 글 5.4절이 코드로 확정한 내용입니다.
⑤ incident 단위 RCA의 멀티클러스터 미지원. 멀티클러스터 프로젝트는 RCA가 명시적으로 거부됩니다. 클러스터를 넘는 장애 전파(예: 한 리전 DB가 다른 리전 서비스에 영향)는 coroot RCA의 범위 밖입니다. IncidentRCA에서는 같은 메시지가 mult-cluster 오타로 들어가 있다는 부수 발견까지.
⑥ (v2 새 발견) model.DependencyMap의 비활성 상태. 자료구조와 widget 자리는 있지만 UpdateLink() 호출이 검색되지 않습니다. 외부 채움을 기다리는 자리거나 향후 PR 대상(추정). 본인 시점에 새 PR이 추가됐다면 다시 확인하시기 바랍니다.
⑦ (v2 새 발견) Cloud HTTP 호출의 타임아웃·재시도 부재. http.DefaultClient.Do(req)는 기본 타임아웃이 0입니다. Cloud가 느릴 때 OSS RCA 핸들러의 매달림 시간을 측정해 볼 가치가 있습니다.
9. 토론 질문 정리
9.1 짐작과 실제가 가장 크게 달랐던 곳
후보가 셋입니다.
model/dependency_map.go(3.7절): 파일 이름이 의존성 맵 구성을 약속하는 듯하지만 자료구조 그릇만 있고 호출이 없습니다.auditor/slo.go(4.4절): SLO 판정이 거기 있을 줄 알았으나 실제 판정은watchers/incidents.go에서 일어나고slo.go는 결과를 복사만 합니다.enrichWidgets정렬 방향(4.5절): v0 초안에 "테이블 위젯이 뒤로"라고 적었으나widgets[i].Table != nil이true인 쪽이 앞으로 가는 것이 Gosort.SliceStable의 의미입니다.
가장 크게 다른 한 곳은 LLM 호출 site(5장)입니다. api/rca.go가 있고 model.RCA에 RootCause 필드가 있으니 OSS 안에 RCA 추론 골격이 있을 것이라 짐작했지만, 실제로는 LLM 호출이 0건이고 추론 전체가 cloud/rca.go:45의 한 줄 POST 너머로 빠져 있습니다. 그리고 그 POST에는 타임아웃도 재시도도 없습니다. "OSS RCA 도구"라는 통념과 "RCA 추론은 비공개 Cloud + 타임아웃 없는 단순 POST"라는 실제의 간극이 가장 컸습니다.
9.2 임계·baseline·패턴은 코드에서 어떻게 결정되는가
5단계 우선순위입니다 — app-exact → app-glob → project-level → DefaultThreshold 상수, 그리고 SLO에 한정해 K8s annotation. 학습된 임계는 0개입니다. 39개 DefaultThreshold가 자동 판정의 닻입니다.
이 선택이 단서를 만드는 방식과 놓치는 방식은 7.2절의 두 변형이 보여 줍니다. Postgres 측 연결 고갈(B-1)이라면 PostgresConnections의 90% 상수 임계가 결정적 단서를 만듭니다 — max_connections라는 물리적 상한이 있어 상수 임계가 의미를 가집니다. 반대로 애플리케이션 pool 측 고갈(B-2)이라면 같은 상수 임계가 단서를 놓칩니다 — coroot는 애플리케이션 pool 설정을 보지 못하고, 서버 측 연결 수가 정상으로 보이므로 Check가 침묵합니다. 임계의 가치는 물리적·계약적 상한에 닻을 내리고 있는지에 달려 있습니다.
39개 임계의 전체 표는 다음과 같습니다(참고용).
| Check | 값 | 단위 | Type |
|---|---|---|---|
| SLOAvailability | 99 | % | Manual |
| SLOLatency | 99 | % | Manual |
| CPUNode | 80 | % | Item |
| CPUContainer | 80 | % | Item |
| MemoryOOM | 0 | count | Event |
| MemoryLeakPercent | 10 | %/hour | Value |
| MemoryPressure | 0.02 | s | Item |
| StorageIOLoad | 5 | s/s | Item |
| StorageSpace | 80 | % | Item |
| NetworkRTT | 0.01 | s | Item |
| NetworkRTTExternal | 0.2 | s | Item |
| NetworkRTTOtherClusters | 0.1 | s | Item |
| NetworkConnectivity | 0 | count | Item |
| NetworkTCPConnections | 0 | count | Item |
| InstanceAvailability | 75 | % | Manual |
| InstanceRestarts | 1 | count | Item |
| DeploymentStatus | 180 | s | Value |
| RedisAvailability | 0 | count | Item |
| RedisLatency | 0.005 | s | Item |
| MongodbAvailability | 0 | count | Item |
| MongodbReplicationLag | 30 | s | Item |
| MemcachedAvailability | 0 | count | Item |
| PostgresAvailability | 0 | count | Item |
| PostgresLatency | 0.1 | s | Item |
| PostgresReplicationLag | 30 | s | Item |
| PostgresConnections | 90 | % | Item |
| LogErrors | 0 | count | Event |
| JvmAvailability | 0 | count | Item |
| JvmSafepointTime | 0.05 | s | Item |
| DotNetAvailability | 0 | count | Item |
| PythonGILWaitingTime | 0.05 | s | Item |
| NodejsEventLoopBlockedTime | 0.7 | s | Item |
| DnsLatency | 0.1 | s | Value |
| DnsServerErrors | 0 | count | Event |
| DnsNxdomainErrors | 0 | count | Event |
| MysqlAvailability | 0 | count | Item |
| MysqlReplicationStatus | 0 | (status) | Item |
| MysqlReplicationLag | 30 | s | Item |
| MysqlConnections | 90 | % | Item |
9.3 증거 제시의 순서·형식은 어디서 결정되는가
세 곳에서 결정됩니다.
- 리포트 간 순서:
auditor.go:51~69의 stage 호출 순서. SLO가 가장 먼저, 그다음 Instances·CPU·Memory. - 리포트 내 위젯 순서:
enrichWidgets()직후sort.SliceStable로 테이블 위젯을 앞으로 보냅니다. v0 초안과 반대 방향임을 v2에서 정정합니다. - Check와 위젯의 묶임: 각 inspector 안에서
AddWidget(...)호출로 명시적으로 결정.
최종 화면 렌더링은 front/의 Vue 컴포넌트가 맡고, 백엔드의 결정을 시각 형식으로 옮기기만 합니다. RCA 결과는 일반 컴포넌트가 아니라 전용 뷰(views/RCA.vue, views/Incident.vue, components/PropagationMap.vue)가 그립니다.
10. 마무리
6주차 코드 리딩이 남기는 한 문장은 다음과 같습니다.
coroot는 "AIOps 도구"라는 단일 블랙박스가 아니라, Detection·Correlation·RCA가 서로 다른 코드 위치와 책임 주체로 나뉜 분산된 파이프라인입니다. Detection은 auditor의 19개 inspector와 39개 상수 임계, 그리고 watchers의 burn rate 두 줄로 나뉘어 있습니다. Correlation은 constructor의 34 stage LoadWorld()로 일어납니다. RCA는 OSS의 입력 큐레이션(메트릭 12 카테고리 + K8s 이벤트 LIMIT 1000 + trace 1+1개)과 _비공개 Cloud의 LLM 추론_으로 나뉩니다.
이 분담을 코드와 실측으로 확인했기 때문에, coroot의 능력과 한계를 마케팅 언어가 아니라 file:line과 메트릭 값으로 말할 수 있습니다.
- "이상 탐지가 강력하다"가 아니라 — 단일 시점 상수 임계 비교이며, 물리적 상한에 닻을 내린 Check는 강하고 그렇지 않은 Check는 워크로드별 튜닝이 필요하다.
- "토폴로지 인식 correlation"이 아니라 — 14줄짜리
loadAppToAppConnections이 `rr_connection*메트릭의app/dest` 라벨을 양방향 엣지로 박는다. 라벨 규약이 무너지면 그래프도 무너진다._ - "자동 RCA"가 아니라 — 입력 큐레이션(LIMIT 1000/1, ORDER BY 없음)은 OSS, 추론은 비공개 Cloud. 두 책임은 분리되며 입력의 한계가 추론의 상한이다.
인용 자료 및 링크
프로젝트 문서
- Navin Sabharwal, Gaurav Bhardwaj, Hands-on AIOps, Apress, 2022. — 1·6·8장 인용.
- Charity Majors, Liz Fong-Jones, George Miranda, Observability Engineering, O'Reilly, 2022. — 1·7·12·13장 인용.
- Gartner, Market Guide for Event Intelligence Solutions, G00802859, 10 March 2025.
coroot 소스 코드 (GitHub)
coroot/coroot@bb13d53(2026-05-15) —main.go,collector/{collector,traces,logs,profiles}.go,ch/client.go,constructor/{constructor,queries,connections}.go,model/{check,audit_report,alert,annotations,widget,chart,table,application_incident,connection,application,dependency_map}.go,auditor/{auditor,postgres,network,deployments,slo,cpu,memory,storage,gpu,dns,mysql,redis,mongodb,memcached,jvm,dotnet,python,nodejs,logs,instances,node,utils}.go,watchers/incidents.go,api/{rca,mcp,api}.go,api/views/overview/service_map.go,clickhouse/{logs,traces}.go,cloud/{rca,api}.go,front/src/{views/{RCA,Incident,Incidents}.vue, components/{PropagationMap,Markdown,Widget}.vue, api.js}.coroot/coroot-node-agent@d9b5c26(2026-05-08) —main.go,ebpftracer/tracer.go,ebpftracer/ebpf/{tcp/{state,retransmit,conntrack}.c, l7/{l7,http,http2,dns,postgres,redis,memcached,mysql,mongo,kafka,cassandra,rabbitmq,nats,dubbo2,clickhouse,zookeeper,foundationdb,gotls,openssl,java_tls,rustls}.c},ebpftracer/l7/{l7,http,http2,dns,postgres,redis,memcached,mysql,mongo,clickhouse,zookeeper}.go,containers/{container,metrics}.go.coroot/coroot-cluster-agent@2c7bef0(2026-05-11).- coroot 공식 문서: https://docs.coroot.com.
유의 사항
- 위 커밋 해시는 분석 시점 기준이며 main 브랜치는 거의 매일 갱신됩니다. file:line은 시간이 지나면 어긋날 수 있습니다.
- 3.7절의 "dependency map은 ClickHouse가 아니라 Prometheus 경로"는 ClickHouse 테이블 목록(
ch/client.go)에 TCP 연결 테이블이 없고,loadAppToAppConnections가 ClickHouse client를 호출하지 않으며, 외부 Prometheus에rr_*시리즈가 0개라는 세 근거로 확정됩니다. - 4.5절 위젯 정렬 방향(table 우선)은 v0 초안과 반대 방향입니다.
sort.SliceStable(... widgets[i].Table != nil)의 의미를 확인하시기 바랍니다. - 5.2~5.4절의 입력 큐레이션 제약(메트릭 step 자동 확대, K8s 이벤트 ORDER BY 없음 LIMIT 1000, trace LIMIT 1 × 2)은 RCA 신뢰도를 평가할 때 단일 숫자가 아니라 _입력 × 추론 × 프롬프트_의 곱으로 보아야 한다는 결론으로 이어집니다.
'AIOps' 카테고리의 다른 글
| Kubescape 런타임 보안(eBPF/Inspektor Gadget)과 MCP AI 연동 동작 방식 (0) | 2026.05.30 |
|---|---|
| Coroot 코드로 읽는 LLM RCA 컨텍스트 — 한 인시던트가 Cloud 전송까지 가는 경로 (0) | 2026.05.24 |
| AIOps 스터디 5주차 - RCA 평가지표 정리 (1) | 2026.05.10 |
| AIOps 스터디 5주차 - RCA (0) | 2026.05.09 |
| AIOps 스터디 4주차 — Correlation (0) | 2026.04.25 |
