augmented chess의 self-play 학습이 홈 클러스터 RAM 한계에 부딪히다
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-apiRAG 파이프라인 누수 — 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 워크플로우 메모
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 페이지보다는 제 노트북에서 그것을 보는 편이 낫습니다.
