Skip to Content
Blog쿠버네티스에서 Palworld 서버 운영하기 — 배포부터 어드민 페이지까지

쿠버네티스에서 Palworld 서버 운영하기 — 배포부터 어드민 페이지까지

요새 재밌게 하고 있는 걸 하나 소개합니다.

친구들이랑 홈랩 Kubernetes 클러스터에 Palworld 데디케이티드 서버를 올려서 같이 하고 있습니다.

처음엔 그냥 서버 하나 띄우는 걸로 끝날 일이었습니다.

그런데 서버를 올리고 나니 상태가 궁금해졌고,

상태를 보려고 매번 kubectl을 치는 게 귀찮아져서 어드민 페이지를 만들었고,

어드민 페이지를 만들고 나니 리소스 그래프도 있으면 좋겠다 싶어 모니터링을 붙였습니다.

⇒ 그렇게 서버 하나가 배포 → 운영 페이지 → 모니터링까지 이어지는 꽤 그럴듯한 홈랩 프로젝트가 됐습니다.

여기에 마지막으로 커스텀 메트릭까지 붙인 이야기가 사실 이 글의 원래 출발점이었는데,

이번엔 그 앞단 — 서버를 어떻게 올렸고, 어드민 페이지는 왜 만들었고, RBAC은 어떻게 최소로 잡았는지까지 — 전체 여정을 순서대로 적어봅니다.


서버 올리기

서버 이미지는 thijsvanloef/palworld-server-docker,

배포는 Twinki14/palworld-server-chart 헬름차트를 그대로 씁니다.

palworld 네임스페이스에 palworld-server라는 이름의 Deployment 하나로 띄웠습니다.

클러스터에 직접 apply하는 방식은 아예 쓰지 않습니다.

Argo CD app-of-apps 구조라, montstrap 리포에 Application 정의를 두고 mont-helm 리포에 환경별 values(cvalues) 오버라이드를 둡니다.

git에 커밋하면 Argo CD가 그 상태를 감지해서 자동으로 sync합니다.

⇒ 클러스터에 뭔가 바꾸고 싶으면 무조건 git을 거쳐야 합니다. 급하다고 kubectl edit으로 손대는 순간 다음 sync에서 그대로 되돌려집니다.

게임 트래픽은 UDP라서

Palworld 게임 포트도, 쿼리 포트도 UDP입니다.

Ingress는 L7(HTTP) 기준으로 동작하니, UDP 게임 트래픽은 애초에 받아줄 수가 없습니다.

그래서 Service를 NodePort 타입으로 열어서 게임 포트와 쿼리 포트를 UDP로 노출하고,

집 공유기에서 그 NodePort로 WAN 포트포워딩을 걸어 친구들이 접속하게 했습니다. (공인 IP나 실제 포트 번호까지 밝히긴 그러니, 이 정도로만 적어둡니다.)

RCON은 TCP라서 얘기가 다릅니다.

굳이 밖으로 열 이유가 없어서 RCON은 클러스터 내부 통신용으로만 씁니다.

안전하게 내리고 올리기

게임 서버를 그냥 죽였다가 살리면, 접속 중인 친구들 화면에는 아무 경고도 없이 그냥 튕겨버립니다.

그래서 preStop 훅에 rcon-cliShutdown 15 <메시지>를 날리도록 걸어뒀습니다.

인게임 화면에 경고와 카운트다운이 뜨고, 그 사이에 세이브 백업 스크립트가 돌아갑니다.

terminationGracePeriodSeconds도 60초로 넉넉하게 잡아서 이 절차가 끝날 시간을 보장합니다.

여기에 운영 장치를 몇 가지 더 얹었습니다.

  • 매일 새벽 5시(KST) CronJob으로 자동 재부팅 (인게임 카운트다운 방송 포함)
  • liveness probe는 pgrep PalServer-Linux로 프로세스 생존만 확인
  • 세이브 PVC는 preventDelete로 걸어서 prune 대상에서 보호
  • 자동 백업은 6시간마다, 14일 지난 백업은 자동 삭제

update_on_boot 함정

지금은 update_on_boot=false로 운영 중입니다.

여기엔 사연이 있습니다.

update_on_boot=true로 켜두면 부팅할 때마다 DepotDownloader가 게임 파일(5GB 정도)을 통째로 재검증하는데,

이 과정에서 PalServer.sh의 실행권한(x bit)을 복원해주지 않습니다.

결과 → 파드가 “not executable”로 CrashLoop.

처음엔 뭐가 문제인지 한참 헤맸다. 설정 하나 잘못 건드렸나 싶었는데, 원인은 훨씬 단순한 파일 권한 문제였다.

지금은 업데이트가 필요할 때만 잠깐 true로 켜서 부팅하고, 파드 안에서 chmod +x로 실행권한을 되살린 다음, 다시 false로 내려서 운영합니다.

← 매번 이 스텝을 손으로 밟는 게 귀찮았던 것도, 뒤에 나올 “업데이트 잡”을 만든 이유 중 하나입니다.


어드민 페이지 만들기

게임 하다가 서버 상태 한 번 보려고 노트북을 열어서 kubectl get pods -n palworld를 치는 게, 생각보다 자주 귀찮았습니다.

그래서 Next.js로 작은 운영 페이지를 하나 만들었습니다. 이름은 그냥 palworld-admin.

App Router 기반이고, @kubernetes/client-node로 in-cluster ServiceAccount를 통해 k8s API에 붙습니다.

게임 서버를 조작하는 방식은 전부 k8s API 경유입니다. 파드에 exec로 들어가는 기능은 아예 없습니다.

보여주는 것은 이렇습니다.

  • 디플로이/파드 상태
  • 게임 서버 버전 (파드 로그에서 Game version is vX.Y.Z 문자열을 파싱)
  • 게임 월드 설정 (ConfigMap, read-only)
  • 업데이트 잡 이력과 로그

할 수 있는 건 딱 하나, 서버 업데이트 잡 실행입니다. (뒤에서 자세히 다룹니다.)

외부에서는 Istio VirtualService를 거치고, 그 앞에 전용 oauth2-proxy(Keycloak) 인증을 하나 더 세워뒀습니다.

배포 흐름도 게임서버와 똑같습니다.

main에 push하면 GitHub Actions가 이미지를 빌드하고 chart/tag.yaml을 bump하는 커밋을 [skip ci]로 올리고,

Argo CD가 그걸 보고 자동 sync합니다.


ConfigMap으로 서버 상태 들여다보기

Palworld 서버의 월드 설정 — EXP 배율, 포획률 같은 것들 — 은 차트에서 ConfigMap으로 관리됩니다.

어드민 페이지는 이 ConfigMap을 읽기만 합니다. 수정 기능은 아직 없습니다.

EXP 배율·포획률 같은 월드 파라미터뿐 아니라, 운영/네트워크 관련 키들도 같은 화면에서 볼 수 있게 했습니다.

⇒ “지금 이 서버 EXP 몇 배로 돌아가고 있지?” 같은 질문에, ConfigMap을 직접 kubectl get cm -o yaml로 열어보지 않아도 됩니다.

수정까지 열어주는 건 RBAC을 늘리는 문제라, 일단은 read-only로 묻어뒀습니다.


RBAC과 업데이트 잡

이 페이지가 할 수 있는 걸 최소한으로 잡았습니다.

최소 권한 Role

rules: - apiGroups: [""] resources: ["configmaps"] verbs: ["get"] - apiGroups: [""] resources: ["pods"] verbs: ["get", "list"] - apiGroups: [""] resources: ["pods/log"] verbs: ["get"] - apiGroups: ["apps"] resources: ["deployments"] verbs: ["get"] - apiGroups: ["batch"] resources: ["cronjobs"] verbs: ["get"] - apiGroups: ["batch"] resources: ["jobs"] verbs: ["get", "list", "create"]

pods/exec는 없습니다. secrets 읽기 권한도 없습니다.

admin 비밀번호는 차트가 env secretKeyRef로 파드에 직접 주입해주기 때문에, 어드민 앱이 Secret을 읽어야 할 이유가 없습니다.

⇒ 이 페이지가 뚫려도 할 수 있는 최악의 일은 “Job을 하나 더 만드는 것” 정도입니다.

CronJob을 복제해서 Job 만들기

업데이트를 실행하는 방식은 이렇습니다.

기존 CronJob(palworld-update)의 jobTemplate을 그대로 복제해서 Job을 하나 만들고,

그 Job에 ownerReference로 CronJob을 연결합니다.

⇒ 어드민 앱 안에 별도의 Job 스펙을 새로 정의하지 않고, 이미 검증된 CronJob 정의를 그대로 재사용하는 셈입니다.

이 Job은 steamcmd로 서버 파일을 업데이트합니다.

앞에서 얘기한 update_on_boot 함정 이후로, 업데이트는 되도록 이 Job으로 처리하고 update_on_boot은 계속 false로 묻어둡니다.


모니터링 붙이기

모니터링은 VictoriaMetrics operator 기반 VMCluster + vmagent로 구성돼 있습니다.

어드민 페이지는 vmselect의 Prometheus 호환 query_range API를 그대로 씁니다. 환경변수 하나(METRICS_PROMETHEUS_URL)로 엔드포인트를 넘겨줍니다.

이걸로 CPU/메모리 그래프를 그립니다. 레인지는 1h/6h/24h, limit/request 기준선도 같이 그립니다.

이 기능은 옵셔널로 설계했습니다.

METRICS_PROMETHEUS_URL을 설정하지 않으면 그래프 섹션 자체가 렌더링되지 않습니다.

← 모니터링 스택이 없는 환경에도 같은 이미지를 그냥 배포할 수 있게 하려는 선택입니다.

그런데 이 CPU/메모리 그래프를 한참 보다가 문득 궁금해졌습니다.

그래서 지금 몇 명이 접속해 있는데…?

CPU/메모리는 보이는데, 정작 게임 서버의 본질인 “지금 몇 명이 붙어 있나”는 대시보드 어디에도 없었습니다.


마무리

정리하면, 이번에 한 일은 이렇습니다.

Palworld 서버 하나를 컨테이너 이미지로 열어서 GitOps로 배포했고,

kubectl 치기 귀찮아서 어드민 페이지를 만들었고,

ConfigMap을 읽어 월드 설정을 보여주고,

최소권한 RBAC으로 업데이트 Job만 실행할 수 있게 열어줬고,

마지막으로 VictoriaMetrics로 CPU/메모리까지 모니터링에 얹었습니다.

돌아보면 새 컴포넌트를 하나씩 추가한 느낌보다는, 이미 있던 조각들 사이에 얇은 연결을 하나씩 낸 느낌입니다.

admin 페이지는 k8s API 하나로, RBAC은 Role 몇 줄로 —

작은 결정들인데, 모아놓고 보니 게임 서버 하나가 꽤 그럴듯한 운영 대상이 됐습니다.

사실 이 글을 쓰게 된 진짜 계기는 여기서 한 발 더 나간 질문이었습니다.

CPU/메모리는 그래프로 보이는데, 정작 몇 명이 접속해 있는지는 같은 화면 어디에도 없었다는 것.

그 숫자를 같은 파이프라인에 태우는 이야기는 분량이 있어서 별도 글로 뺐습니다.

Palworld 동시접속자 메트릭 — 어드민 앱을 Prometheus 익스포터로 만들기