Palworld 동시접속자 메트릭 — 어드민 앱을 Prometheus 익스포터로 만들기
홈랩에서 Palworld 서버와 admin 페이지를 어떻게 올렸는지는 1편에 정리했습니다.
admin 페이지는 거기서 붙인 모니터링으로 이미 vmselect의 Prometheus 호환 query_range API(METRICS_PROMETHEUS_URL)로 CPU/메모리 그래프를 그리고 있었습니다.
그런데 그 그래프를 보다가 문득 궁금해졌습니다.
그래서 지금 몇 명이 접속해 있는데…?
CPU/메모리는 보이는데, 정작 게임 서버의 본질인 “지금 몇 명이 붙어 있나”는 대시보드에 없었습니다.
이번 목표는 그 숫자를 같은 파이프라인에 태우는 것이었습니다.
방법 후보 세 가지
REST API로 결론 내리기 전에, 사실 후보가 세 개 있었습니다.
RCON은 왜 버렸나
Palworld RCON은 Pocketpair가 공식적으로 deprecated를 선언했습니다.
(“scheduled to stop functioning” 이라고 딱 못박아뒀습니다.)
게다가 ShowPlayers 명령의 CSV 출력이 멀티바이트 문자 닉네임(한글 포함)이 섞이면 깨지는 게 꽤 알려진 버그입니다.
지금도 흔들리는 방식 위에 새 파이프라인을 얹을 이유는 없다고 봤다.
기존 익스포터는 왜 못 쓰나
bostrt/palworld-exporter: RCON 기반이고 2024년 2월 이후로 커밋이 없습니다.jimmysharp/palworld_exporter: REST 기반이라 방향은 맞지만basecampnum,days같은 필드는 지원하지 않고, 마지막 릴리스가 2024년 4월입니다.
둘 다 지금 당장은 동작은 하겠지만…
이후 유지보수를 떠안는 게 부담이었습니다.
그래서 남은 건 공식 REST API
thijsvanloef 이미지는 v0.35.0부터 REST API를 기본 활성화합니다.
GET :8212/v1/api/metrics에 Basic auth(admin:<ADMIN_PASSWORD>)로 붙으면 이런 응답이 돌아옵니다.
{
"currentplayernum": 0,
"serverfps": 59,
"serverfpsaverage": 59.66,
"serverframetime": 16.735,
"days": 1177,
"maxplayernum": 16,
"basecampnum": 8,
"uptime": 63489
}serverfpsaverage는 공식 문서에는 없는 필드인데 실제로는 내려옵니다.
문서보다 실측을 믿기로 했습니다.
설계 결정 네 가지
REST API를 쓰기로 정한 다음부터는 “어떻게 붙이느냐”가 더 중요한 문제였습니다.
새 컴포넌트를 왜 안 만들었나
새 Deployment나 사이드카, 서드파티 이미지를 추가하는 대신
Next.js 앱에 GET /metrics 라우트(App Router의 src/app/metrics/route.ts)를 하나 추가해서
Prometheus 텍스트 포맷으로 노출하기로 했습니다.
이미 k8s API로 게임 서버 파드를 조회하고 있는 앱이니,
거기에 REST 호출 하나 얹는 게 새 컴포넌트를 통째로 배포하는 것보다 훨씬 쌉니다.
왜 굳이 파드 IP로 직접 붙나
REST 포트(8212)는 어떤 Service에도 노출하지 않았습니다.
게임 서버 Service가 NodePort 타입이라, 여기에 포트를 하나 추가하면 REST API가 그대로 노드에 열려버립니다.
공식 문서도 REST API를 인터넷에 직접 노출하지 말라고 명시하고 있습니다.
⇒ Service에 태우는 순간 그대로 보안 구멍이 됩니다.
대신 어드민 앱이 이미 갖고 있는 k8s API 클라이언트로 Running 상태 파드를 조회하고,
그 파드의 status.podIP로 직접 REST를 호출합니다.
RBAC 변경은 전혀 필요 없었다.
single-flight 캐시 — 성공 15초 / 실패 10초 TTL
이 서버에서는 예전에 사고가 하나 있었다.
이미지에 내장된 player-logging 기능이 5초마다 REST를 폴링해서 접속 로그를 남기는데,
그게 로그를 너무 많이 찍어대서 결국 꺼버린 적이 있다.
그 기억이 있어서 이번엔 처음부터 REST 호출 빈도를 신경 썼습니다.
/api/status는 5초 폴링, /metrics는 Prometheus 30초 스크레이프 주기지만,
실제 REST 히트는 분당 최대 4회로 눌러놓았습니다.
동시에 들어오는 호출은 진행 중인 promise에 합류(in-flight dedup)시키고,
실패했을 때의 TTL(10초)을 폴링 주기(5초)보다 길게 잡아서
REST 소켓이 행에 걸려도 매 폴링이 다시 타임아웃을 물지 않도록 했습니다.
let inFlight: Promise<Stats | null> | null = null
let cache: { value: Stats | null; expiresAt: number } | null = null
async function getStats() {
if (cache && cache.expiresAt > Date.now()) return cache.value
if (inFlight) return inFlight // 동시 호출은 진행 중인 요청에 합류
inFlight = fetchFromRest()
.catch(() => null) // 실패해도 절대 throw 하지 않음
.then((value) => {
const ttl = value !== null ? 15_000 : 10_000
cache = { value, expiresAt: Date.now() + ttl }
inFlight = null
return value
})
return inFlight
}VMServiceScrape를 어드민 헬름차트에 내장
victoria-metrics-operator가 selectAllByDefault로 떠 있어서,
앱 네임스페이스에 VMServiceScrape CRD만 두면 vmagent가 자동으로 수집 대상에 잡습니다.
이 클러스터는 GitOps(Argo CD app-of-apps)라 클러스터에 직접 apply하는 건 금지고,
모든 리소스가 차트를 거쳐야 합니다.
그래서 VMServiceScrape 템플릿을 어드민 차트 안에 넣고 values로 게이팅한 다음,
실제 활성화는 환경 오버라이드(cvalues)에서만 켰습니다.
ADMIN_PASSWORD는 게임서버 차트가 이미 만들어둔 palworld-rcon-password Secret을
secretKeyRef로 그대로 재사용합니다.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMServiceScrape
metadata:
name: palworld-admin
spec:
selector:
matchLabels:
app.kubernetes.io/name: palworld-admin
endpoints:
- port: http
path: /metrics
interval: 30s노출하는 메트릭
모두 {server="palworld"} 라벨을 붙여서 노출합니다.
지금은 서버가 하나뿐이지만, 나중에 여러 대로 늘어날 걸 감안했습니다.
| 메트릭 | 원본 필드 | 실측값 |
|---|---|---|
palworld_up | (REST 도달·인증 성공 여부, 항상 노출) | 1 |
palworld_player_count | currentplayernum | 0 |
palworld_player_max | maxplayernum | 16 |
palworld_server_fps | serverfps | 59 |
palworld_server_fps_average | serverfpsaverage | 59.76 |
palworld_server_frame_time_ms | serverframetime | 16.7 |
palworld_uptime_seconds | uptime | 64973 |
palworld_base_camp_count | basecampnum | 8 |
palworld_in_game_days | days | 1178 |
/metrics 실제 출력은 이런 모양입니다.
# HELP palworld_up Palworld REST API 도달 및 인증 성공 여부
# TYPE palworld_up gauge
palworld_up{server="palworld"} 1
# HELP palworld_player_count 현재 접속중인 플레이어 수
# TYPE palworld_player_count gauge
palworld_player_count{server="palworld"} 0
# HELP palworld_player_max 서버 최대 접속 인원
# TYPE palworld_player_max gauge
palworld_player_max{server="palworld"} 16
# HELP palworld_server_fps 서버 FPS
# TYPE palworld_server_fps gauge
palworld_server_fps{server="palworld"} 59파이프라인
REST 호출부터 대시보드까지 데이터가 흘러가는 경로는 대략 이렇습니다.
실시간 경로(/api/status, 5초 폴링)와 시계열 경로(/metrics → VictoriaMetrics)가 같은 캐시를 공유하되,
그 이후는 완전히 갈라집니다.
여기서 하나 짚어야 할 게 있습니다.
REST 호출도, 캐시도, /metrics 라우트도 전부 palworld-admin 파드 하나 안에 있습니다.
⇒ 이 파드가 죽으면 접속자 그래프도 같이 끊깁니다.
← 이게 괜찮은가.
프로덕션이면 당연히 별도 익스포터로 분리해서 SPOF를 없애야겠지만,
홈서버 하나 돌리는 수준에서는 이 정도는 타협하기로 했습니다.
대시보드에 반영한 부분
- 상단 StatCard: 기존에는 “최대 인원”만 보여주던 카드를 “접속자 N / 16 명”으로 바꿨습니다. REST 호출이 실패하면 기존 표시로 폴백합니다.
- “서버 메트릭” 섹션: 동시 접속자 추이 차트를 CPU/메모리 차트보다 위, 풀폭으로 배치했습니다. 값이 이산적이라 선을 매끄럽게 잇는 대신
stepAfter계단형으로 그렸고, Y축은 정수 눈금만 씁니다. 상한은 16명이지만 실제 접속자는 0~2명 수준이라, Y축을 상한이 아니라 실제 데이터 기준으로 스케일하고 최대 인원 기준선은 만석에 가까워질 때만 표시합니다 ← 안 그러면 그래프가 늘 바닥에 깔려서 변화가 안 보입니다. 레인지는 1h/6h/24h 세 가지이고, 원본은 30초 간격 스크레이프에 레인지별 스텝은 각각 1분/5분/15분입니다.
검증
vmselect에 직접 쿼리해서 라벨이 붙은 채로 값이 들어오는 걸 확인했습니다.
{
"metric": {
"__name__": "palworld_player_count",
"server": "palworld",
"job": "palworld-admin-service",
"namespace": "palworld"
},
"value": [1784210777, "0"]
}이 출력에서 metric 안에 server, job, namespace 라벨이 다 붙어 있는 걸 보면,
vmagent가 서비스 디스커버리로 이 파드를 정확히 찾아서 스크레이프하고 있다는 뜻입니다.
value의 두 번째 원소 "0"은 실측 당시 접속자가 없었다는 것뿐이고,
파이프라인 자체는 문제없이 끝까지 돌고 있다는 걸 보여줍니다.
결론 : REST → 캐시 → /metrics → vmagent → vmselect까지, 라벨이 붙은 시계열이 끊기지 않고 도착합니다!
배포는 main에 push하면 GitHub Actions가 ghcr.io 이미지를 빌드하고 chart/tag.yaml을 bump하는 커밋을 [skip ci]로 올리고,
Argo CD가 그걸 감지해서 자동 sync합니다.
CI는 1분 47초 걸렸습니다.
작은 함정이 하나 있었습니다.
Next.js standalone 서버가 HOSTNAME(파드명) 기준으로 바인딩을 해서,
파드 안에서 curl localhost:3000을 치면 connection refused가 납니다.
kubelet 프로브는 파드 IP로 붙기 때문에 이 문제를 겪지 않고 그냥 통과합니다.
그래서 직접 검증할 때는 localhost 대신 파드 IP로 호출해야 합니다.
마무리
지금은 동시접속자 수, FPS, 프레임타임, 베이스캠프 수, 게임 내 일수 정도만 수집합니다.
앞으로 생각하는 것들입니다.
/v1/api/players로 접속자별 상세(닉네임, 레벨, 핑)까지 수집하는 것. 다만 닉네임을 라벨로 쓰면 카디널리티가 커지는데, 이걸 어떻게 다뤄야 할지는 아직 답이 없습니다.- 두 번째 서버(
palworld2)에도 같은 구조를 그대로 적용하는 것.server라벨을 처음부터 넣어둔 이유가 여기 있습니다. - Grafana 대시보드. 이 클러스터는
vm_grafana_dashboard라벨을 붙인 ConfigMap을 두면 자동으로 로드되는 방식을 쓰고 있어서, 여기에 맞춰 대시보드 하나를 추가하면 될 것 같습니다.
결국 이번에 한 일은 새 컴포넌트를 추가한 게 아니라,
이미 있던 어드민 앱과 이미 있던 모니터링 스택 사이에 얇은 연결 하나를 낸 것에 가깝습니다.
SPOF도 있고 카디널리티 답도 아직 없지만, 일단 지금 몇 명이 접속해 있는지는 이제 바로 보입니다.