Wintersalmon | Blog

augmented chess의 self-play 학습이 홈 클러스터 RAM 한계에 부딪히다

5 min read

augmented chess가 self-play 파이프라인을 강제했고, 클러스터가 반격했습니다

augmented chess는 매 게임마다 규칙을 바꾸는 augment의 작은 집합을 다시 굴립니다 — king처럼도 움직이는 knight, 한 번 뒤로 잡는 pawn 등이 그것입니다. Stockfish는 이런 규칙을 모르므로, 진짜 상대를 마련할 유일한 방법은 직접 학습시키는 것이었습니다. 4월의 3주 동안 5단계 self-play 파이프라인, 약 24M 파라미터 네트워크, 두 번의 OOM이 있었고, liveness와 readiness가 서로 다른 것을 검사해야 한다는 교훈을 얻었습니다. 엔진은 augment를 event log에 미리 굴려 넣어 두기 때문에, 끝난 게임은 seed와 action stream만으로 재생됩니다 — 결정론과 이벤트 소싱에서 의지했던 바로 그 속성입니다.

TL;DR

  • 5단계, 5개 PR (#350–357), 94개 소스 파일, 104개 테스트, 약 24M 파라미터.
  • 첫 번째 OOM: doc-search-api RAG 파이프라인 누수 — 256Mi 한계에 대해 피크 237Mi였고, 수정은 PR #359로 Bun 서비스를 0으로 스케일하고 Go 재작성 버전에 자리를 넘겨주는 것이었습니다.
  • 두 번째 OOM: chess-ai-service가 512Mi 한계 대비 484Mi로 idle 상태였고, 16일에 걸쳐 1283번 OOMKilled 되었습니다.
  • ws-relay-go-stag가 probe의 교훈을 가르쳐 주었습니다. 두 probe 모두에 단일 /health를 연결한 결과, 느린 Mongo upstream이 무한 재시작 루프로 변했습니다.
  • 수정은 양쪽 모두 적용했습니다. 한계를 512Mi → 1024Mi로 올리고, 그리고 게임 사이에 MCTS root에 명시적 delete를 추가했습니다. 누수 있는 프로세스의 한계를 올리는 것은 다음 페이지 아웃을 미루는 것에 불과합니다.

5단계, 하나의 플러그형 엔진 인터페이스

파이프라인은 PR #350, #351, #352, #356, #357에 걸쳐 도착했습니다. @cloudnest/chess-ai-core는 순수 TypeScript이며, AlphaZero 스타일의 4708-슬롯 action space(64×73 piece moves + 35 augment selections + resign)와 plugin engine 인터페이스를 갖추고 있어, 하나의 MCTS 루프가 Stockfish(Bun.spawn을 통한 UCI 바이너리), 랜덤 baseline, 그리고 학습된 ONNX 모델을 감싼 MCTS-Neural 엔진 사이를 자유롭게 오갈 수 있습니다.

apps/chess-ai-service는 Bun API입니다. 동시성은 단 한 번의 MongoDB 호출로 처리합니다:

const job = await jobs.findOneAndUpdate(
  { status: "pending" },
  { $set: { status: "claimed", workerId, claimedAt: new Date() } },
  { sort: { createdAt: 1 }, returnDocument: "after" },
);

같은 row를 두고 경쟁하는 두 worker는 모두 동일한 findOneAndUpdate를 실행하고, 하나가 이기고 다른 하나는 재시도합니다. Redis도, broker도 없습니다. apps/chess-ai-worker는 작업을 가져와서 PGN과 position별 tensor를 돌려보냅니다. training/chess-ai/는 pyenv 3.11.9 위의 PyTorch transformer입니다 — 12 layers, hidden 384, 8 heads, 약 24M 파라미터, policy와 value head, AdamW, MongoDB IterableDataset, ONNX export. 123개의 fixture가 TS와 Python encoder가 바이트 단위로 동일한 tensor를 만들어 내는지 검증합니다. apps/chess-ai-dashboard는 학습 실행을 모니터링하기 위한 7페이지 React 앱입니다.

첫 번째 OOM: 이틀 동안 누수가 난 RAG 파이프라인

4월 4일: doc-search-api가 2일 5시간 가동 후 256Mi 한계에 도달했습니다. 4월 5일: game-ws-relay-service-stag도 같은 일이, 역시 2일 5시간 만에 일어났습니다. 같은 형태였습니다 — 요청별 tensor, 문서 캐시, Mongo pool, Qdrant client buffer가 누적되어 steady-state가 237Mi(92.6%)까지 슬금슬금 올라갔고 커널이 죽였습니다. PR #359는 Bun 서비스를 0으로 스케일하여 Go 대체 서비스가 인계받게 했습니다. ws-relay-stag에 대해서는 한계를 256Mi → 512Mi로 올렸습니다.

liveness와 readiness는 서로 다른 것을 검사해야 합니다

더 흥미로운 실패는 ws-relay-go-stag였습니다. MongoDB change-stream client가 02:00 UTC 무렵 실패했고 재연결할 수 없었습니다. csm.isHealthy()가 false였기 때문에 GET /health가 503을 반환했습니다. 그 endpoint는 readiness와 liveness 둘 다에 연결되어 있었습니다 — 그래서 Kubernetes가 pod를 재시작했고, 새 pod도 같은 Mongo 이유로 /health를 실패했고, 이 루프가 반복되었습니다. 느린 upstream에 대한 무한 재시작이었습니다.

PR #362로 runbook에 적어 둔 분리는 다음과 같습니다:

livenessProbe:
  httpGet: { path: /healthz, port: 8080 }   # process alive only
readinessProbe:
  httpGet: { path: /health, port: 8080 }    # deps healthy (Mongo, change stream)

liveness는 프로세스를 재시작합니다. readiness는 pod를 load balancer에서 빼냅니다. 느린 upstream은 트래픽을 빼야지, 그것을 서빙하는 pod를 죽여서는 안 됩니다.

두 번째 OOM: 한계를 올리는 것과 누수를 고치는 것 사이의 트레이드오프

2주 뒤인 4월 22일. chess-ai-service가 self-play 도중에 OOMKilled 되었습니다. restartCount는 16일 동안 1283이었고, 사용량은 512Mi 한계 대비 484Mi(94.5%)였으며, inference 호출 한 번이면 한계를 넘기에 충분했습니다. OOM 직전 로그는 깨끗했습니다 — Bun 시작, MongoDB 연결, baseline 등록 — 그래서 이건 스파이크가 아니라 steady-state 압박이었습니다.

steady-state만으로도 — Bun 런타임, MongoDB client, chess-ai-core, augmented-chess-engine, 30개 라우트 모두 마운트 — 약 480Mi였습니다. 512Mi 한계는 10% 미만의 여유밖에 주지 못했습니다. 모든 MCTS 호출이 새로운 search tree를 할당했고, 게임별로 캐시된 activation은 게임 사이에 해제되지 않았습니다.

수정은 양쪽 모두 적용했습니다. 한계 인상: requests를 256Mi → 512Mi로 steady-state에 맞추고, limits를 512Mi → 1024Mi로 올려 MCTS 버스트에 대비했습니다. 누수 수정: 게임마다 끝나면 MCTS root에 명시적 delete, 학습 게임 사이에 torch.cuda.empty_cache(), worker에서 주기적인 GC를 추가했습니다. 누수 있는 프로세스의 한계를 올리는 것은 다음 OOM을 미루는 것에 불과합니다 — 한계 인상은 시간을 사주고, 누수 수정이 그 시간을 벌어 줍니다.

backstop은 Stage 2의 큐 모델입니다. 작업 도중 OOMKilled 된 worker는 자기 row를 stale claimedAt을 가진 채 claimed 상태로 남깁니다. janitor query가 stale row를 pending으로 되돌리고 다른 worker가 집어 갑니다. 크래시는 데이터가 아니라 throughput을 잃습니다. 알려진 누수가 있는 채로 production에 배포할 수 있었던 유일한 이유입니다.

앞으로 무엇이 바뀌는가

메모리 한계는 테스트 하네스가 측정한 값이 아니라, steady-state에 피크 버스트를 더한 값을 커버해야 합니다. liveness와 readiness는 서로 다른 것을 검사합니다 — 프로세스 대 의존성 — 둘을 뭉뚱그리면 느린 upstream이 새벽 2시 재시작 루프로 변합니다. self-play 워크로드는 큐 덕분에 크래시가 복구 가능하기 때문에 누수에 유난히 관대하지만, 관대하다는 것이 공짜라는 뜻은 아닙니다. 누수는 고치십시오.

#ai #kubernetes

AI 워크플로우 메모

Claude는 코드가 배포되기 전에 planner agent를 통해 5단계 분해를 초안 잡아 주었고, 덕분에 Stage 4(Python 학습)와 Stage 2(TS API)가 서로의 발을 밟지 않고 병렬 sub-agent에서 진행될 수 있었습니다. pytorch-build-resolver agent는 cross-language fixture 단계에서 tensor shape와 CUDA 오류를 다루며 제 몫을 했습니다 — encoder mismatch는 12 layer 깊이에서 조용한 shape 오류로 드러납니다. 가장 중요했던 규율은 배포 전에 worker를 로컬에서 한 시간 풀로 돌려본 것입니다. OOM 추세는 45분 시점의 top에서 보였고, Alertmanager 페이지보다는 제 노트북에서 그것을 보는 편이 낫습니다.


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.