diff --git a/docs/superpowers/specs/2026-06-29-distributed-worker-observability-design.md b/docs/superpowers/specs/2026-06-29-distributed-worker-observability-design.md new file mode 100644 index 0000000..57be6c2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-distributed-worker-observability-design.md @@ -0,0 +1,207 @@ +# 분산 워커 관측 시스템 (Distributed Worker Observability) — 설계 문서 + +> 작성일: 2026-06-29 · 작성 세션: BE (web-backend 소유) +> 대상 repo 3종: `web-ai`(워커) · `web-backend`(NAS 집계/경보) · `web-ui`(Three.js 대시보드) + +--- + +## 1. 문제 정의 (Problem) + +NAS 백엔드의 음악/영상/이미지/인스타 생성은 **무거운 작업을 Windows AI 머신(192.168.45.59)의 WSL2 Docker 워커**에 위임한다. NAS 게이트웨이(`music/video/image/insta-lab`)가 Redis 큐(`queue:-render`)에 job을 push하면, Windows 워커가 BLMOVE로 꺼내 처리하고 `/api/internal//update` webhook으로 결과를 회신한다. 트레이딩봇 `ai_trade`(:8001)는 별도로 NAS stock(:18500)에서 HTTP pull을 한다. + +**핵심 문제: 이 분산 워커들이 살아있는지 NAS·사용자가 알 길이 없다.** +- 각 워커에 로컬 `/health` 엔드포인트가 있으나 Windows 머신 안에서만 접근 가능. +- 실제 사고: `insta-render` 워커가 redis 블로킹 read 버그로 **2026-05-22 ~ 06-08 약 2주간 사일런트로 죽어 있었고**(모든 슬레이트 draft 정지) 아무도 몰랐다. 일감이 없을 때의 "한가함"과 "죽음"을 구분할 수단이 없었던 것이 근본 원인. + +## 2. 목표 / 비목표 (Goals / Non-goals) + +**목표 (Phase 1)** +- G1. 6개 워커(`music/video/image/insta-render` + `task-watcher` + `ai_trade`)의 생사·상태를 NAS에서 인지. +- G2. 큐 깊이·실패(dead-letter)·고아작업(processing)·일시정지(paused) 상태를 집계. +- G3. 상태 전이(다운/복구/실패누적)를 텔레그램으로 자동 경보. +- G4. web-ui 신규 페이지 `/infra`에서 NAS↔Windows 파이프라인을 **Three.js로 시각화** — 정상이면 통신이 흐르는 애니메이션, 장애면 해당 구간을 끊김/빨강으로 표시. + +**비목표 (Phase 2 이후로 보류)** +- 원격 제어(워커 재시작, 큐 pause/resume, dead-letter 재처리) — Windows 머신 제어가 필요해 보안·구현 복잡도 큼. +- GPU 사용률(VRAM) 모니터링, stuck-task 자동 감지, WebSocket 라이브 푸시. +- 다중 노드 확장(현재 Windows 노드 1대). + +## 3. 아키텍처 & 토폴로지 + +``` + web-backend (NAS, 192.168.45.54) Windows 노드 (192.168.45.59) + ┌──────────────────────────────────┐ ┌────────────────────────────────────┐ + │ music-lab ─┐ │ ① job │ WSL2 Docker: │ + │ video-lab ─┤ │ push │ ┌─ music-render │ + │ image-lab ─┼─► [ Redis 큐 버스 ]═╪══════════╪══►├─ video-render (ReliableQueue) │ + │ insta-lab ─┘ queue:*-render │ │ ├─ image-render │ + │ queue:paused │◄═════════╪═══├─ insta-render │ + │ │ ② webhook│ └─ task-watcher (paused 토글) │ + │ agent-office │◄─────────╪── 각 워커 → worker::heartbeat│ + │ ├─ node_monitor (집계) │◄─heartbeat (Redis SET, TTL 45s) │ + │ └─ scheduler (1분 경보 cron) │ │ │ + │ │ │ Windows 호스트(WSL 밖): │ + │ stock (:18500) ◄── HTTP pull ────╪──────────╪── ai_trade (:8001) ─ heartbeat ───►│ + └──────────────┬───────────────────┘ └────────────────────────────────────┘ + │ GET /api/agent-office/nodes (FE 2~3초 폴링) + ▼ + web-ui /infra ← Three.js 파이프라인 시각화 +``` + +**설계 기반(이미 존재하는 자산)** +- 워커들은 이미 NAS Redis(`redis://192.168.45.54:6379`)에 BLMOVE로 연결 → heartbeat도 같은 Redis에 SET하면 방화벽/인바운드 포트 불필요, `queue:paused`여도 heartbeat는 계속 뛰므로 "정지 중이지만 살아있음"과 "죽음"을 구분 가능. +- `_shared/reliable_queue.py`(ReliableQueue)가 이미 `processing:queue:-render:` 리스트와 `dead_letter:queue:-render` 리스트를 Redis에 남김 → 집계기가 **신규 워커 코드 없이** 큐 깊이·실패·고아작업을 읽을 수 있음. + +**채택하지 않은 대안** +- 집계기를 게이트웨이 중 하나에 배치 → "어느 게이트웨이가 전체 노드 상태를 소유하나"가 의미상 어색. `agent-office`가 ops 브레인(텔레그램·스케줄러·WebSocket·서비스 로그 수집 보유)이라 의미상 정확. +- NAS→워커 HTTP `/health` 폴링 → 워커별 포트 노출 + NAS→Windows 인바운드 접속 필요. Redis heartbeat가 단방향(워커→Redis)이라 더 단순. +- 라이브 갱신을 WebSocket으로 → Phase 1은 2~3초 폴링으로 충분(단순). WebSocket은 Phase 2 강화. + +## 4. 컴포넌트 설계 + +### 4.1 web-ai — heartbeat 생산자 (AI 세션 소유) + +**4.1.1 render 워커 4종 (`services/*-render/`)** +- 신규 공용 모듈 `services/_shared/heartbeat.py`: + - `async def heartbeat_loop(redis, name, stats, interval=15, ttl=45)` — `interval`초마다 `worker::heartbeat` 키에 JSON 값을 `SET ... EX ttl`. + - 값 스키마는 §5.1 참조. 죽으면 키가 TTL 만료 → 집계기가 "missing = dead" 판정. +- 각 워커 `main.py` lifespan에서 `worker_loop`와 함께 `heartbeat_loop` 태스크 spawn. +- `state` 산정: `queue:paused`가 set이면 `paused`, 현재 job 처리 중이면 `busy`, 아니면 `idle`. 처리 중 여부와 카운터(`jobs_done`/`jobs_failed`/`last_job_at`)는 `poll_once`가 갱신하는 모듈 레벨 `stats` 객체로 추적. +- TTL=45s = interval(15s)의 3배 → 1~2회 누락은 dead로 오판하지 않음. + +**4.1.2 task-watcher (`services/task-watcher/`)** +- `watcher_loop`에 동일 heartbeat 추가. `worker:task-watcher:heartbeat`에 `state` + 현재 `mode`(`trading`/`free`)를 함께 발행 → 대시보드가 paused의 **이유**("작업중(트레이딩)")를 표시. + +**4.1.3 ai_trade (`ai_trade/`) — 다른 런타임** +- ai_trade는 Windows **호스트**에서 직접 uvicorn 실행(WSL Docker 아님), NAS Redis 큐에 연결되어 있지 않음(현재 NAS stock으로 HTTP pull만). +- 변경: `redis.asyncio` 의존성 추가 → `main.py` lifespan에 heartbeat 태스크 추가 → 같은 NAS Redis(`192.168.45.54:6379`)에 `worker:ai_trade:heartbeat` SET. + - Redis는 Windows 머신에서 이미 도달 가능(render 워커들이 같은 호스트에서 BLMOVE 중). + - heartbeat 로직은 ~10줄이므로 `ai_trade` 자체 미니 헬퍼로 둔다(`_shared` import 경로 의존 회피 — render 워커는 컨테이너 PYTHONPATH로 `_shared` 접근, ai_trade는 호스트 실행이라 경로가 다름). **계약(키 스키마)만 동일**하면 코드 공유 불필요. +- `state` 의미가 다름: render 워커의 idle/busy/paused가 아니라 `market_open`(poll_loop 활성·신호 생성 중) / `market_closed`(휴장·장외 idle). **task-watcher의 `queue:paused`와 무관**(트레이딩은 일시정지 대상 아님). +- 토폴로지 표현: Redis 큐 버스가 아니라 **HTTP pull 파이프라인**(ai_trade ⇄ NAS stock :18500)으로 별도 표시. + +### 4.2 web-backend / agent-office — 집계기 + 경보 (이 BE 세션 소유) + +**4.2.1 Redis 클라이언트 추가** +- `agent-office`는 현재 Redis 미사용 → `requirements.txt`에 `redis>=5.0`(asyncio) 추가, `docker-compose.yml` agent-office 블록에 `REDIS_URL` 환경변수 + `depends_on: redis` 추가. + +**4.2.2 `app/node_monitor.py` 신규** +- 워커 레지스트리(상수): 각 워커의 `name`, 연관 `queue`(있으면), `internal webhook` 경로, 토폴로지 link 타입(`redis-queue` | `http-pull`). +- `async def collect_status() -> dict`: + - 각 워커: `GET worker::heartbeat` → 존재하면 `alive=True` + JSON 파싱 + `last_beat_age_s = now - ts`; 없으면 `alive=False`(dead). + - 각 render 큐: `LLEN queue:-render`(depth), `LLEN dead_letter:queue:-render`, `processing:queue:-render:*` 키 스캔으로 in-flight 수. + - `GET queue:paused` + TTL → paused 플래그 + reason(task-watcher heartbeat의 mode). + - Redis 연결 실패 → `redis_ok=False`(전 구간 degrade). + - link 상태 합성(§5.2). +- 응답 스키마는 §5.2. + +**4.2.3 엔드포인트** +- `GET /api/agent-office/nodes` → `collect_status()`. nginx `/api/agent-office/` 이미 라우팅됨 → **nginx 변경 불필요**. + +**4.2.4 경보 cron (scheduler)** +- `_run_node_health_check` (APScheduler, 1분 간격): + - 직전 상태 `_node_state`(인메모리 dict)와 비교: + - `alive → dead`: 🔴 ` 워커 다운 (last beat Xs ago)` + - `dead → alive`: 🟢 ` 워커 복구` + - `dead_letter` 카운트가 임계(`NODE_ALERT_DEADLETTER_THRESHOLD`, 기본 1) 신규 초과: ❌ ` 실패 누적 N건` + - `_notified` 패턴(기존 `youtube_publisher.poll_state_changes` 재사용)으로 스팸 방지, 복구 시 재알림 가능하도록 set 차집합. + - 텔레그램 발송은 agent-office 기존 봇 재사용. + +### 4.3 web-ui — Three.js 대시보드 (FE 세션 소유) + +- 신규 의존성: `three` + `@react-three/fiber` + `@react-three/drei`(React 코드베이스이므로 r3f가 관용적). +- 신규 라우트 `/infra`(Router.jsx) + Nav 등록. +- `pages/infra/InfraMonitor.jsx`: + - r3f `` 토폴로지 — 좌측 NAS(게이트웨이 sub-node) / 중앙 Redis 큐 버스(글로우 코어) / 우측 Windows 노드(워커 sub-node). ai_trade는 별도 HTTP-pull 파이프라인. + - 노드 간 파이프라인(튜브) + 상태별 머티리얼/애니메이션(§6). + - `useNodeStatus` 훅: `GET /api/agent-office/nodes`를 2~3초 폴링 → 상태를 시각 상태로 매핑(`src/api.js`에 헬퍼 추가). + - **2D 폴백**: WebGL 미지원/모바일 대비 카드·테이블 요약 뷰 토글. + - 실제 구현 시 `designer` 스킬 활성화(브레인스토밍 단계에서는 금지). + +## 5. 잠그는 계약 (Contracts) + +> 3 세션이 독립 병렬 작업하려면 이 두 스키마만 고정하면 된다. + +### 5.1 Heartbeat 키 스키마 + +- **키**: `worker::heartbeat` (name ∈ `music-render`, `video-render`, `image-render`, `insta-render`, `task-watcher`, `ai_trade`) +- **값**(JSON 문자열), `SET ... EX 45`: +```json +{ + "name": "image-render", + "kind": "render", // "render" | "watcher" | "trader" + "state": "idle", // render: idle|busy|paused / watcher: trading|free / trader: market_open|market_closed + "ts": "2026-06-29T12:34:56Z", // UTC ISO8601 (heartbeat 발신 시각) + "last_job_at": "2026-06-29T12:30:00Z", // nullable + "jobs_done": 42, + "jobs_failed": 1, + "mode": "free" // task-watcher 전용(paused 이유), 그 외 생략 가능 +} +``` + +### 5.2 `/api/agent-office/nodes` 응답 스키마 +```json +{ + "redis_ok": true, + "paused": false, + "paused_reason": "trading", // queue:paused가 set일 때 task-watcher mode + "generated_at": "2026-06-29T12:34:57Z", + "workers": [ + { + "name": "image-render", "kind": "render", + "alive": true, "state": "idle", "last_beat_age_s": 3, + "queue_depth": 0, "dead_letter": 0, "processing": 0, + "jobs_done": 42, "jobs_failed": 1, "last_job_at": "2026-06-29T12:30:00Z" + } + ], + "links": [ + { "from": "nas", "to": "image-render", "type": "redis-queue", "status": "healthy" }, + { "from": "ai_trade", "to": "nas-stock", "type": "http-pull", "status": "healthy" } + ] +} +``` +- `link.status` ∈ `healthy` | `paused` | `down` | `degraded`. 산정: 워커 dead → `down`; paused → `paused`; dead_letter>0 → `degraded`; redis_ok=false → 전 링크 `down`. + +## 6. 시각화 상태 (Three.js) + +| 상태 | 파이프라인(튜브) | 노드 | +|------|------------------|------| +| **정상 idle** | 시안/그린, 파티클이 NAS→워커→NAS 루프로 흐름(느림) | 초록 글로우 + 큐깊이/처리수 HUD | +| **정상 busy** | 파티클 빠르게 흐름 | "처리 중 N" | +| **일시정지 paused** | 앰버, 파티클 느려짐/정지 | "⏸ 작업중(트레이딩)" 라벨 | +| **장애 dead / link down** | 빨강, 흐름 멈춤, 끊긴 지점 스파크/단절 | 빨강 + ⚠ 경고, "last beat Xs ago" | +| **실패누적 dead-letter>0** | 해당 튜브 ❌ 뱃지 | dead-letter 카운트 강조 | +| **Redis/집계기 다운** | 중앙 버스 전체 빨강 | "집계 서버 연결 끊김" 오버레이 | + +- ai_trade의 HTTP-pull 파이프라인은 큐 흐름이 아닌 pull 방향(ai_trade→NAS stock) 파티클로 구분 표현. `market_closed`는 정상 idle과 동일 톤(휴장은 장애 아님). + +## 7. 에러 처리 + +- heartbeat TTL 만료 = dead 판정(권위 신호). 큐가 비어 일감이 없어도 heartbeat가 살아있으면 alive로 정확 판정(2주 사일런트 사고 재발 방지). +- Redis 다운 → `/nodes`가 `redis_ok=false` 반환(500 아님) → 대시보드가 전 구간 degrade 표시. +- agent-office 다운 → FE 폴링 실패 → "집계 서버 연결 끊김" 오버레이. +- 집계기는 read-only(Redis에 쓰지 않음) → 워커 동작에 영향 0. + +## 8. 테스트 + +- **web-ai**: `heartbeat.py` 단위 테스트(fakeredis/mock) — 발신 주기·TTL·state 전이·카운터. ai_trade heartbeat 별도 테스트. +- **web-backend**: `node_monitor.collect_status` 테스트(mock redis: 키 존재/만료/큐 깊이/dead-letter 케이스) + 경보 전이 테스트(alive→dead→alive, dead-letter 증가). TDD 적용. +- **web-ui**: `InfraMonitor` 컴포넌트가 mock 상태로 렌더 + 상태→색상 매핑 단위 테스트(r3f는 렌더 스모크 수준). + +## 9. 단계 (Phasing) + +- **Phase 1 (본 스펙 전체)**: 6 워커(render 4 + task-watcher + ai_trade) heartbeat / `/nodes` API / 텔레그램 경보 / Three.js `/infra` 대시보드. +- **Phase 2 (후속)**: GPU 사용률(VRAM 16GB 경합 가시화), stuck-task 감지, WebSocket 라이브 푸시, 원격 제어(워커 재시작·pause/resume·dead-letter 재처리). + +## 10. 세션 분담 & 협업 (co-gahusb) + +- **소유권**: BE(이 세션)=web-backend, AI 세션=web-ai, FE 세션=web-ui. 각자 자기 repo만 커밋. +- **선행 게이트**: §5의 두 계약(heartbeat 키 스키마 + `/nodes` 응답 스키마)을 먼저 확정·공유 → 3 세션 병렬 진행. +- **공유 리소스 락**: agent-office 의존성/compose 변경은 `compose` 락, nginx 무변경(불필요). 배포는 `nas-deploy` 락. +- BE 작업: agent-office redis 추가 + `node_monitor.py` + `/nodes` + 경보 cron + 본 메모리 기록. AI/FE 작업은 co-gahusb 태스크로 배분. + +## 11. 메모리 갱신 계획 + +- 신규 cross-cutting 메모리 `infra_distributed_workers.md` 작성: 큐 계약 / webhook 계약 / ReliableQueue 키 / heartbeat 키 스키마 / task-watcher paused / node_monitor·`/nodes`·경보. `MEMORY.md` 인덱스 등재. +- 관련 서비스 메모리(`service_video/image/music/insta`)에 heartbeat·관측 추가 사실을 cross-link. +```