Skip to Content
Blog홈랩에 HyperDX 올리기 — 파드가 뜬 다음부터 시작이었다

홈랩에 HyperDX 올리기

#ClickHouse#HyperDX#RUM#GitOps#SSO#트러블슈팅

홈랩에는 이미 메트릭과 백엔드 트레이스를 보는 도구가 있다.

그런데 블로그를 방문한 사람이 어느 페이지에서 느려졌는지, 화면에서 무슨 일이 있었는지는 그쪽만 봐서는 잘 이어지지 않았다.

그래서 hub 클러스터에 HyperDX를 올리고 브라우저 RUM과 세션 리플레이를 붙였다.

2026년 9월 8일 배포를 시작했고, 이 글은 9월 11일까지 남긴 설정과 수정 기록을 기준으로 쓴다.

설치가 끝나면 화면을 구경할 줄 알았는데, 작은 ClickHouse의 메모리와 페이지를 떠나는 순간의 브라우저 동작부터 보게 됐다.

무엇을 어디에 둘까

hub/edge 홈랩 구조에서 상태를 저장하는 쪽은 hub다.

HyperDX도 여기에 두고, 브라우저에서 발생한 이벤트는 hub의 Collector로 보낸다.

기존 OTel Gateway에서는 백엔드 트레이스를 HyperDX 쪽으로도 분기한다.

VictoriaMetrics와 Grafana가 맡던 메트릭은 그대로 둔다.

수집 키OTLP저장로그인설정조회브라우저 SDK세션 · 리플레이 · 트레이스OTel Gateway백엔드 트레이스 분기Collector/otel/ → OTLP HTTP 4318ClickHouse텔레메트리 · local-path관리자웹 화면 접속oauth2-proxyKeycloak OIDCHyperDX조회 UI · APIMongoDB팀 · 소스 · 대시보드 설정
브라우저의 OTLP 수집은 Collector로, 관리 화면은 oauth2-proxy로 보낸다. ClickHouse는 텔레메트리를, MongoDB는 앱 설정을 저장한다. 기존 메트릭 경로는 그대로 둔다.

이 그림에서 중요한 건 저장소 두 개의 역할이다.

ClickHouse에는 로그·트레이스 같은 텔레메트리가 쌓이고, MongoDB에는 팀과 연결 정보, 소스와 대시보드 같은 앱 상태가 남는다.

MongoDB가 작다고 해서 없어져도 되는 저장소는 아니다.

반대로 MongoDB 용량이 작다는 사실로 ClickHouse 용량까지 작게 잡을 수도 없다.

설치 당시 버전과 지금 문서를 구별한다

기존에 쓰던 차트 구조를 참고하되, 이미지·노드 배치·스토리지는 홈랩에 맞게 바꿨다.

당시 잠근 조합은 아래와 같다.

구성 요소배포 기록의 버전·구성
ClickStack 서브차트3.0.2
HyperDX 앱·Collector2.30.1
ClickHouse25.3 이미지 계열
ClickHouse 관리Altinity Operator 0.27.1
스토리지노드 로컬 local-path
복제ClickHouse 단일 인스턴스, Keeper 비활성

이 표는 설치 기록이다.

최신 권장 버전 목록으로 읽으면 안 된다.

현재 공식 Helm 문서 는 v2.x 차트 구조와 별도 operator 설치 단계를 설명한다.

과거 차트의 버전 숫자나 values 경로를 현재 안내에 그대로 끼워 넣으면, 전혀 다른 리소스를 만들 수 있다.

새로 설치한다면 먼저 사용할 차트 버전을 정하고 그 버전의 values와 CRD를 같이 확인하는 편이 낫다.

내 배포 순서도 operator와 CRD를 먼저 준비하고, 그다음 ClickHouse·MongoDB의 커스텀 리소스와 앱을 올리는 식이었다.

ArgoCD의 sync wave만 맞췄다고 준비가 끝나는 것은 아니다.

CRD가 등록됐는지, operator가 정상 동작하는지까지 확인해야 뒤의 리소스를 처리할 수 있다.

30Gi는 작은 출발점이지 복구 전략은 아니다

ClickHouse PVC에는 30Gi, MongoDB에는 데이터 5Gi와 로그 1Gi를 요청했다.

ClickHouse 보존 기간은 설정상 14일로 잡았다.

다만 local-path PVC의 요청 용량이 디스크에 강제되는 할당량이라는 뜻은 아니다.

노드 파일시스템의 실제 사용량도 같이 봐야 한다.

또 하나, 데이터가 PVC에 있다는 것과 다른 노드에서 복구할 수 있다는 것은 다르다.

이 구성에서는 데이터가 해당 노드의 디스크에 남는다.

노드를 잃으면 새 파드 하나가 다른 노드에서 뜨는 것만으로 저장소가 돌아오지 않는다.

Keeper를 끈 것도 단순히 replica 수가 1이라서 모든 구성이 자동으로 성립한다는 뜻은 아니다.

ReplicatedMergeTree 같은 복제 엔진을 쓰면 한 replica여도 Keeper 의존성이 생긴다.

실제 테이블 엔진은 다음처럼 확인할 수 있다.

SELECT database, name, engine FROM system.tables WHERE database NOT IN ('system', 'INFORMATION_SCHEMA', 'information_schema') ORDER BY database, name;

이 출력에서 복제 엔진을 사용하는 테이블이 있는지 먼저 본다.

단일 노드 홈랩이라는 제약은 받아들였지만, 이 구성을 서비스 운영용 고가용성 예제로 쓰기는 어렵다.

메모리는 남는데 파드는 Pending이었다

설치 때는 메모리 여유를 보고 들어갈 수 있겠다고 판단했다.

그런데 노드의 CPU requests가 이미 거의 찬 상태였다.

여기서 보는 건 kubectl top에 나오는 순간 사용률이 아니다.

스케줄러가 배치할 때 계산하는 요청량이다.

MongoDB는 mongod 컨테이너만 보아서는 계산이 안 맞았다.

operator가 붙이는 agent와 init container의 요청량도 함께 들어갔다.

kubectl describe node node1 kubectl -n hdx get pods -o wide kubectl -n hdx get events --sort-by=.lastTimestamp

Allocated resources와 Pending 파드의 Insufficient cpu 이벤트를 함께 보면, 실제 사용량과 배치 예약량이 다른 문제라는 걸 확인할 수 있다.

홈랩에서는 각 컨테이너의 CPU requests를 크게 낮춰 배치했다.

이건 제한된 노드에 얹기 위한 선택이다.

낮은 requests가 운영에 필요한 CPU를 보장해주는 것은 아니므로, 그대로 복사할 권장값으로 남기지는 않겠다.

수집 경로와 로그인 경로를 나눈다

사람이 보는 관리 화면에는 Keycloak과 oauth2-proxy를 붙였다.

브라우저 SDK가 보내는 OTLP 요청은 Collector로 직접 들어간다.

경로목적지확인할 인증
/otel/Collector의 OTLP HTTP 포트수집 키와 수신 설정
관리 UIoauth2-proxy → HyperDXOIDC 로그인과 허용 그룹
별도로 연 기계 API해당 API앱이 검사하는 API 자격증명

OTLP 경로를 로그인 프록시 뒤로 보내면 SDK가 로그인 화면을 받거나 CORS preflight부터 막힐 수 있다.

그렇다고 수집 키를 관리 권한으로 써도 된다는 뜻은 아니다.

브라우저 번들에 들어간 키는 방문자가 읽을 수 있으므로 수집에 필요한 권한과 관리 권한을 구별해야 한다.

공개 수집 엔드포인트의 허용 origin과 유입량도 별도로 관리할 대상이다.

trusted-ip를 프록시 주소를 신뢰한다는 의미로 읽으면 곤란하다.

oauth2-proxy 설정 문서 에서 이 옵션은 해당 IP의 인증을 건너뛰는 설정이다.

프록시 뒤에서 어떤 헤더를 실제 클라이언트 IP로 읽는지까지 맞물리므로, 앞에 SSO를 붙였다는 사실만으로 모든 경로에 로그인이 걸렸다고 판단하면 안 된다.

앱이 뜨고도 데이터가 안 보일 수 있다

앱의 연결 설정과 소스 설정은 서로 이름으로 이어진다.

배포 설정을 정리하면서 connections.json의 이름과 sources.json이 참조하는 연결 이름을 같은 값으로 맞췄다.

호스트와 비밀번호가 맞아도 소스가 다른 연결을 가리키면 원하는 조회 화면이 생기지 않을 수 있다.

확인 순서는 단순하게 잡았다.

  1. Collector가 요청을 받는지 확인한다.
  2. ClickHouse 테이블에 최근 행이 있는지 확인한다.
  3. HyperDX 소스가 그 연결과 테이블을 가리키는지 확인한다.
  4. 시간 범위와 서비스 필터를 확인한다.
SELECT ServiceName, count() AS spans, max(Timestamp) AS latest_span FROM otel_traces WHERE Timestamp > now() - INTERVAL 1 HOUR GROUP BY ServiceName ORDER BY latest_span DESC;

이 SQL은 이 배포에서 사용한 otel_traces 스키마 기준이다.

최근 데이터가 여기에는 있는데 화면에 없다면, 수집보다 조회 설정 쪽부터 좁힐 수 있다.

진짜 데이터보다 시스템 로그가 더 컸다

배포 다음 날 기록에서 눈에 띈 숫자가 있다.

가동 약 5.4시간 동안 system.* 로그는 82MiB였고, 실제 otel_* 텔레메트리는 3.5MiB였다.

작은 홈랩에서는 ClickHouse가 자기 자신에 대해 남기는 기록이 본래 받으려던 데이터보다 훨씬 컸다.

더 급했던 건 metric_log 머지였다.

당시 3Gi 메모리 상한에서 머지가 실패했고, 재시도와 오류 로그가 이어졌다.

기록에는 같은 기간 2,739회 재시도와 오류 로그 약 24MB가 남아 있다.

이 값은 당시 관측값이지, 모든 작은 ClickHouse에서 나오는 비율은 아니다.

SELECT database, table, count() AS active_parts, formatReadableSize(sum(bytes_on_disk)) AS disk_size FROM system.parts WHERE active GROUP BY database, table ORDER BY sum(bytes_on_disk) DESC;

먼저 테이블별 사용량을 나누면, 앱 데이터와 시스템 로그 중 어느 쪽이 커지는지 보인다.

홈랩에서는 사용하지 않던 시스템 로그 수집을 줄이고, ClickHouse 메모리 상한을 6Gi로 올렸다.

이때 두 설정 경로도 구별해야 했다.

로그 테이블 설정은 config.d, 쿼리 프로파일러 주기는 사용자 프로파일을 두는 users.d 쪽이다.

로그 저장을 끄는 것과 샘플링 작업 자체를 끄는 것이 같은 설정은 아니었다.

다만 query_logpart_log까지 줄이면 이후 진단에 쓸 기록도 사라진다.

서비스 운영 환경에서는 소비량을 확인하고 TTL·수집 주기·보존 대상을 먼저 조절하는 편이 낫다.

이 홈랩의 설정을 그대로 일반화할 수 없는 이유다.

브라우저 계측은 공용 로더로 붙였다

블로그마다 SDK 초기화를 따로 관리하기보다는 작은 로더 하나를 만들어 공통으로 불러오게 했다.

로더 내부에서는 @hyperdx/browser를 초기화하고, 사이트별로 서비스 이름만 넘긴다.

<script defer src="https://cdn.example.com/rum/hyperdx-rum.js" data-service="my-blog" ></script>

여기서 URL은 예시다.

이 스크립트가 SDK의 공식 배포 URL인 것은 아니고, 직접 만든 로더를 올려둔 위치를 넣는 자리다.

홈랩 로더는 입력 마스킹을 켜고, 상세 네트워크 본문 수집은 껐다.

콘솔 수집도 명시적으로 요청한 사이트에서만 켜도록 했다.

브라우저 SDK 문서 maskAllInputs, advancedNetworkCapture, consoleCapture가 각각 다른 범위를 제어한다.

입력을 가렸다고 화면의 모든 텍스트나 URL까지 자동으로 가려지는 것은 아니니, 실제 리플레이에 무엇이 남는지는 별도로 확인해야 한다.

페이지를 떠날 때 마지막 기록이 사라졌다

정적 사이트는 페이지를 이동할 때 문서 자체가 종료된다.

이때 마지막 리플레이 배치를 보내기 시작해도 브라우저가 요청을 끝내주지 않을 수 있었다.

당시 인그레스 기록에는 preflight만 있고 실제 POST가 없는 경우가 남았다.

로컬에서는 @hyperdx/browser 0.26.0에 포함된 리코더 번들을 패치해, flush 시점을 visibilitychangepagehide로 넓혔다.

작은 요청에는 keepalive도 사용했다.

배치 하나가 작다는 조건만으로 페이지 종료 시 전송이 보장되는 건 아니다.

브라우저의 전체 keepalive 요청 제한과 네트워크 상태도 남는다.

같은 방향의 논의는 unload 리스너 이슈 #219 리코더 수정 PR #306 에 있다.

내 패치 기록은 9월 9일 기준이며, SDK를 올릴 때는 실제 배포 번들에 수정이 들어왔는지 확인하고 중복 패치를 제거해야 한다.

Web Vitals의 빈 값을 0으로 세고 있었다

마지막으로 고친 것은 설치 설정이 아니라 SQL이었다.

webvitals 이벤트 하나에 LCP·CLS·INP가 항상 함께 들어오는 것은 아니다.

그런데 없는 속성까지 toFloat64OrZero로 바꿔 분위수를 구하면, 측정하지 않은 값을 0점짜리 관측으로 섞게 된다.

그래프가 좋아 보이는 방향으로 틀어지는 셈이다.

LCP 하나를 확인하는 SQL은 다음처럼 쓸 수 있다.

SELECT toStartOfInterval(Timestamp, INTERVAL 5 MINUTE) AS time, ServiceName, count() AS samples, quantile(0.75)(lcp) AS lcp_p75 FROM ( SELECT Timestamp, ServiceName, toFloat64OrNull(SpanAttributes['lcp']) AS lcp FROM otel_traces WHERE Timestamp > now() - INTERVAL 24 HOUR AND ResourceAttributes['rum.sessionId'] != '' AND SpanName = 'webvitals' ) WHERE lcp IS NOT NULL GROUP BY time, ServiceName ORDER BY time, ServiceName;

배포 대시보드에서는 속성이 있는 행만 quantileIf에 넣도록 고쳤고, 위 예시는 숫자로 읽을 수 없는 값도 제외하도록 풀어 쓴 형태다.

samples가 줄어든 구간은 값이 좋아졌다고 보기 전에 표본부터 확인한다.

LCP와 INP의 시간 단위, 단위가 없는 CLS도 같은 축에 무심코 겹치지 않게 나눠야 한다.

파드가 뜬 뒤에 확인할 것

이번에 남은 확인 순서는 이렇다.

  • 파드가 뜨는가: 노드 requests와 operator가 만든 컨테이너까지 본다.
  • 데이터가 도착하는가: Collector, 테이블의 최신 행, 앱 소스를 차례로 본다.
  • 오래 받을 수 있는가: 시스템 로그·머지·메모리와 실제 디스크 사용량을 본다.
  • 화면의 숫자가 맞는가: 누락값·단위·표본·샘플링 조건을 본다.
  • 장애 뒤에 돌아오는가: 로컬 디스크의 위치와 백업·복구 경로를 따로 본다.

결론 : 설치 완료는 파드가 뜬 시점이고, 관측 도구를 믿을 수 있는지는 그다음 문제였다.

지금 구성은 단일 노드 홈랩의 한계를 받아들인 출발점이다.

다음에 규모를 키운다면 replica 숫자부터 늘리기보다, 배포 전에 실측할 항목과 복구 경로부터 다시 확인하려고 한다.