Compare commits
5 Commits
53a0657027
...
27a6df6cff
| Author | SHA1 | Date | |
|---|---|---|---|
| 27a6df6cff | |||
| 803fdb6278 | |||
| 77e21b54e6 | |||
| 4d0c89ce79 | |||
| 4b60ab34c3 |
@@ -76,3 +76,22 @@ services:
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
task-watcher:
|
||||
build:
|
||||
context: ./task-watcher
|
||||
container_name: task-watcher
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18713:8000"
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
||||
- STOCK_BASE_URL=${STOCK_BASE_URL:-http://192.168.45.54:18500}
|
||||
- TRADING_START=${TRADING_START:-07:00}
|
||||
- TRADING_END=${TRADING_END:-16:30}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
11
services/task-watcher/.env.example
Normal file
11
services/task-watcher/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Plan-B-Infra — task-watcher
|
||||
|
||||
# NAS Redis
|
||||
REDIS_URL=redis://192.168.45.54:6379
|
||||
|
||||
# NAS stock holidays endpoint
|
||||
STOCK_BASE_URL=http://192.168.45.54:18500
|
||||
|
||||
# 트레이딩 윈도우 (KST, HH:MM) — 이 시간대에만 queue:paused
|
||||
TRADING_START=07:00
|
||||
TRADING_END=16:30
|
||||
16
services/task-watcher/Dockerfile
Normal file
16
services/task-watcher/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
83
services/task-watcher/NSSM_SETUP.md
Normal file
83
services/task-watcher/NSSM_SETUP.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# NSSM 자동 시작 설정 (SP-9)
|
||||
|
||||
Windows AI 머신 부팅 시 ai_trade(트레이딩) + WSL2 Docker(render workers + task-watcher) 자동 시작.
|
||||
|
||||
## 1. NSSM 다운로드
|
||||
|
||||
https://nssm.cc/download → nssm-2.24.zip → `C:\nssm\nssm.exe` 배치 (또는 PATH 등록).
|
||||
|
||||
## 2. ai_trade (Native Python, HIGH priority)
|
||||
|
||||
⚠️ spec의 signal_v2는 ai_trade로 rename됨. 경로/포트 확인.
|
||||
|
||||
```powershell
|
||||
# 관리자 PowerShell
|
||||
C:\nssm\nssm.exe install ai_trade "C:\Python312\python.exe" "-m uvicorn main:app --host 0.0.0.0 --port 8001"
|
||||
C:\nssm\nssm.exe set ai_trade AppDirectory "C:\Users\jaeoh\Desktop\workspace\web-ai\ai_trade"
|
||||
C:\nssm\nssm.exe set ai_trade Priority HIGH_PRIORITY_CLASS
|
||||
C:\nssm\nssm.exe set ai_trade Start SERVICE_AUTO_START
|
||||
C:\nssm\nssm.exe set ai_trade AppStdout "C:\Users\jaeoh\nssm-logs\ai_trade.log"
|
||||
C:\nssm\nssm.exe set ai_trade AppStderr "C:\Users\jaeoh\nssm-logs\ai_trade.log"
|
||||
```
|
||||
|
||||
(ai_trade의 실제 진입점이 main:app + port 8001인지 확인. 다르면 조정.)
|
||||
|
||||
## 3. WSL2 Docker (NORMAL priority — render workers + task-watcher)
|
||||
|
||||
```powershell
|
||||
C:\nssm\nssm.exe install wsl_docker "C:\Windows\System32\wsl.exe" "-d Ubuntu-24.04 -- sh -c 'sudo service docker start && cd /workspace/web-ai/services && docker compose up -d'"
|
||||
C:\nssm\nssm.exe set wsl_docker Priority NORMAL_PRIORITY_CLASS
|
||||
C:\nssm\nssm.exe set wsl_docker Start SERVICE_AUTO_START
|
||||
C:\nssm\nssm.exe set wsl_docker AppStdout "C:\Users\jaeoh\nssm-logs\wsl_docker.log"
|
||||
```
|
||||
|
||||
⚠️ 변경점: Ubuntu-22.04 → **Ubuntu-24.04**, web-ai-services → **web-ai/services**. WSL 경로는 박재오 WSL 마운트 기준 (`/workspace`가 web-ai에 매핑되어 있으면 그대로, 아니면 `/mnt/c/Users/jaeoh/Desktop/workspace/web-ai/services`).
|
||||
|
||||
`sudo service docker start`가 비밀번호 요구하면 sudoers에 NOPASSWD 추가:
|
||||
```bash
|
||||
# WSL2 안
|
||||
echo "$USER ALL=(ALL) NOPASSWD: /usr/sbin/service docker start" | sudo tee /etc/sudoers.d/docker-start
|
||||
```
|
||||
|
||||
## 4. 서비스 시작 + 확인
|
||||
|
||||
```powershell
|
||||
C:\nssm\nssm.exe start ai_trade
|
||||
C:\nssm\nssm.exe start wsl_docker
|
||||
|
||||
# 상태 확인
|
||||
C:\nssm\nssm.exe status ai_trade
|
||||
C:\nssm\nssm.exe status wsl_docker
|
||||
sc query ai_trade
|
||||
```
|
||||
|
||||
## 5. 검증
|
||||
|
||||
```powershell
|
||||
# ai_trade
|
||||
curl http://localhost:8001/health # 또는 ai_trade의 실제 health endpoint
|
||||
|
||||
# WSL2 docker 컨테이너 (재부팅 후 자동 시작 확인)
|
||||
wsl -d Ubuntu-24.04 -- docker ps
|
||||
# insta-render, music-render, video-render, task-watcher 4개 Up 확인
|
||||
```
|
||||
|
||||
## 6. 재부팅 테스트
|
||||
|
||||
Windows 재부팅 → 로그인 → 수동 조작 없이:
|
||||
- ai_trade 서비스 자동 시작 (HIGH priority)
|
||||
- WSL2 + Docker + 4 컨테이너 자동 시작 (NORMAL priority)
|
||||
- task-watcher가 trading window에 queue:paused 토글 시작
|
||||
|
||||
## task-watcher 동작 확인
|
||||
|
||||
```bash
|
||||
# WSL2
|
||||
docker logs task-watcher --tail 20
|
||||
# 기대: "task-watcher started" + mode 전환 로그 (trading/free)
|
||||
|
||||
# Redis 큐 상태 (NAS 또는 LAN)
|
||||
docker exec redis redis-cli GET queue:paused
|
||||
# 트레이딩 시간대(평일 07:00-16:30): "1"
|
||||
# 그 외: (nil)
|
||||
```
|
||||
36
services/task-watcher/main.py
Normal file
36
services/task-watcher/main.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""task-watcher FastAPI entry — health + lifespan (watcher loop spawn)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
import watcher
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
watcher_task = asyncio.create_task(watcher.watcher_loop())
|
||||
logger.info("task-watcher lifespan 시작")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
watcher_task.cancel()
|
||||
try:
|
||||
await watcher_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("task-watcher lifespan 종료")
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "task-watcher"}
|
||||
57
services/task-watcher/mode.py
Normal file
57
services/task-watcher/mode.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""시간대 + 휴장일 기반 모드 판정 (idle 감지 생략 — 박재오 결정 2026-05-22).
|
||||
|
||||
trading: 비휴장 평일 07:00–16:30 (장중) → queue:paused SET
|
||||
free: 그 외 (장 전/후, 주말, 휴장) → queue:paused DEL
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import os
|
||||
from typing import Set
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
STOCK_BASE_URL = os.getenv("STOCK_BASE_URL", "http://192.168.45.54:18500")
|
||||
|
||||
# 트레이딩 윈도우 (HH:MM, KST). .env로 조정 가능.
|
||||
TRADING_START = os.getenv("TRADING_START", "07:00")
|
||||
TRADING_END = os.getenv("TRADING_END", "16:30")
|
||||
|
||||
|
||||
def _parse_hhmm(s: str) -> dt.time:
|
||||
hh, mm = s.split(":")
|
||||
return dt.time(int(hh), int(mm))
|
||||
|
||||
|
||||
def current_mode(now: dt.datetime, holidays: Set[str]) -> str:
|
||||
"""now(KST aware) + holidays(ISO date set) → 'trading' | 'free'."""
|
||||
# 주말 (토=5, 일=6)
|
||||
if now.weekday() >= 5:
|
||||
return "free"
|
||||
# 휴장일
|
||||
if now.date().isoformat() in holidays:
|
||||
return "free"
|
||||
# 트레이딩 윈도우 [start, end)
|
||||
start = _parse_hhmm(TRADING_START)
|
||||
end = _parse_hhmm(TRADING_END)
|
||||
t = now.timetz().replace(tzinfo=None)
|
||||
if start <= t < end:
|
||||
return "trading"
|
||||
return "free"
|
||||
|
||||
|
||||
def fetch_holidays() -> Set[str]:
|
||||
"""NAS stock /api/stock/holidays 조회. 실패 시 빈 set (안전 — free로 판정)."""
|
||||
try:
|
||||
r = httpx.get(f"{STOCK_BASE_URL}/api/stock/holidays", timeout=10.0)
|
||||
if r.status_code == 200:
|
||||
return set(r.json().get("holidays", []))
|
||||
logger.warning("holidays fetch returned %d", r.status_code)
|
||||
except Exception:
|
||||
logger.exception("holidays fetch 실패")
|
||||
return set()
|
||||
5
services/task-watcher/requirements.txt
Normal file
5
services/task-watcher/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
redis>=5.0
|
||||
httpx>=0.27
|
||||
pytest>=8.0
|
||||
0
services/task-watcher/tests/__init__.py
Normal file
0
services/task-watcher/tests/__init__.py
Normal file
44
services/task-watcher/tests/test_mode.py
Normal file
44
services/task-watcher/tests/test_mode.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""current_mode — 시간대 + 휴장일 판정 (순수 함수)."""
|
||||
import datetime as dt
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from mode import current_mode
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
HOLIDAYS = {"2026-05-25"} # 가상 휴장일 (월요일)
|
||||
|
||||
|
||||
def _kst(y, m, d, hh, mm):
|
||||
return dt.datetime(y, m, d, hh, mm, tzinfo=KST)
|
||||
|
||||
|
||||
def test_weekday_trading_hours_is_trading():
|
||||
# 2026-05-22 금요일 10:00 — 트레이딩 시간대
|
||||
assert current_mode(_kst(2026, 5, 22, 10, 0), HOLIDAYS) == "trading"
|
||||
|
||||
|
||||
def test_weekday_before_open_is_free():
|
||||
# 평일 06:00 — 장 전
|
||||
assert current_mode(_kst(2026, 5, 22, 6, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_weekday_after_close_is_free():
|
||||
# 평일 17:00 — 장 마감 후
|
||||
assert current_mode(_kst(2026, 5, 22, 17, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_weekend_is_free():
|
||||
# 2026-05-23 토요일 10:00
|
||||
assert current_mode(_kst(2026, 5, 23, 10, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_holiday_weekday_is_free():
|
||||
# 2026-05-25 월요일이지만 휴장일 → 트레이딩 시간대라도 free
|
||||
assert current_mode(_kst(2026, 5, 25, 10, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_trading_boundary_inclusive_start_exclusive_end():
|
||||
# 07:00 정각 = 트레이딩 시작, 16:30 정각 = 마감 (16:30은 free)
|
||||
assert current_mode(_kst(2026, 5, 22, 7, 0), HOLIDAYS) == "trading"
|
||||
assert current_mode(_kst(2026, 5, 22, 16, 29), HOLIDAYS) == "trading"
|
||||
assert current_mode(_kst(2026, 5, 22, 16, 30), HOLIDAYS) == "free"
|
||||
59
services/task-watcher/watcher.py
Normal file
59
services/task-watcher/watcher.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""30초마다 current_mode 판정 → queue:paused 토글.
|
||||
|
||||
trading → SET queue:paused 1 EX 600 (10분 TTL — watcher 죽어도 자동 해제)
|
||||
free → DEL queue:paused
|
||||
holidays는 1시간마다 refresh (매 loop fetch 부하 회피).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import logging
|
||||
import os
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from mode import current_mode, fetch_holidays, KST
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
|
||||
PAUSED_KEY = "queue:paused"
|
||||
LOOP_INTERVAL = 30 # 초
|
||||
HOLIDAYS_REFRESH = 3600 # 1시간
|
||||
PAUSED_TTL = 600 # 10분 (watcher 죽어도 자동 해제)
|
||||
|
||||
|
||||
async def watcher_loop():
|
||||
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||
holidays = fetch_holidays()
|
||||
last_holiday_refresh = dt.datetime.now(KST)
|
||||
last_mode = None
|
||||
logger.info("task-watcher started (trading window 토글)")
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = dt.datetime.now(KST)
|
||||
# holidays 주기적 refresh
|
||||
if (now - last_holiday_refresh).total_seconds() >= HOLIDAYS_REFRESH:
|
||||
holidays = fetch_holidays()
|
||||
last_holiday_refresh = now
|
||||
|
||||
mode = current_mode(now, holidays)
|
||||
if mode == "trading":
|
||||
await redis.set(PAUSED_KEY, b"1", ex=PAUSED_TTL)
|
||||
else:
|
||||
await redis.delete(PAUSED_KEY)
|
||||
|
||||
if mode != last_mode:
|
||||
logger.info("mode 전환: %s → %s (paused=%s)", last_mode, mode, mode == "trading")
|
||||
last_mode = mode
|
||||
|
||||
await asyncio.sleep(LOOP_INTERVAL)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("watcher_loop cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("watcher_loop iteration 실패, 30초 후 재시도")
|
||||
await asyncio.sleep(LOOP_INTERVAL)
|
||||
Reference in New Issue
Block a user