Files
web-page-backend/docs/superpowers/plans/2026-05-19-plan-b-insta-render.md
gahusb 24229d00ae docs(plan): Plan-B-Insta — insta-render Windows worker + NAS 분할
16 task, 5 phase. NAS insta-lab의 Playwright Chromium 100% Windows로 이전.

Phase 1 (NAS 수신부): verify_internal_key + /api/internal/insta/update
  + main.py에 redis client + docker-compose env (Task 1-4)
Phase 2 (Windows worker 신설): web-ai/services/insta-render Docker
  컨테이너 (Dockerfile, requirements, card_renderer, worker, main, tests)
  (Task 5-10)
Phase 3 (NAS cutover): _bg_render·_bg_create_slate를 Redis push로
  + card_renderer.py stub + Dockerfile 슬림화 (Task 11-13)
Phase 4 (nginx 3-layer 차단): /api/internal/* IP 화이트리스트 (Task 14)
Phase 5 (end-to-end 검증): 폴링 + PNG 생성 확인 (Task 15-16)

NAS Redis + WSL2 Docker + SMB mount (Plan-B-Base) prerequisite 완료.
다음 plan은 Plan-B-Music (Suno+MusicGen), Plan-B-Video (외부 API gateway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:47:41 +09:00

1888 lines
62 KiB
Markdown

# Plan-B-Insta — NAS insta-lab 분할 + Windows insta-render Worker Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** NAS insta-lab의 Playwright Chromium 렌더링을 Windows AI 머신(WSL2 Docker)으로 완전 이전. NAS는 게이트(메타·DB·Redis push)만 담당, Windows worker가 Chromium pool + 10장 PNG 렌더 + NAS SMB 볼륨에 저장 + webhook으로 NAS DB 업데이트.
**Architecture:** NAS gateway → Redis RPUSH `queue:insta-render` → Windows BLPOP → Browser pool reuse + Semaphore(1) → SMB direct write to `/mnt/nas/webpage/data/insta/{slate_id}/01.png~10.png` → HTTP POST `/api/internal/insta/update` (X-Internal-Key 인증, 3-layer 차단). 사용자는 폴링 (`GET /api/insta/tasks/{task_id}`).
**Tech Stack:** Python 3.12 / FastAPI / Playwright async / Jinja2 / `redis>=5.0` (async) / `httpx` (webhook) / Docker Engine in WSL2 Ubuntu 24.04 / cifs SMB to NAS.
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-3·SP-4, §5 Windows Render Worker 패턴, §6 Redis 큐, §8 internal webhook + auth, §10 SP-3/SP-4 상세
**Prerequisites (✅ 모두 완료):**
- Plan-A: web-ai/NAS 캐시 강화
- Plan-B-Base: NAS Redis 컨테이너 + Windows WSL2/Docker/SMB
- web-ai 리네임: signal_v2 → ai_trade + legacy/
---
## Phase 구조
| Phase | 내용 | Task |
|-------|------|------|
| **1. NAS gateway 측 준비** (수신부 먼저) | webhook endpoint + X-Internal-Key 인증 + Redis 통신 환경 | 1~4 |
| **2. Windows worker 신설** | services/insta-render 컨테이너 + Chromium pool + BLPOP worker | 5~10 |
| **3. NAS insta-lab 분할 (cutover)** | Playwright 제거 + Redis push로 전환 | 11~13 |
| **4. nginx 3-layer 차단** | /api/internal/* IP 화이트리스트 | 14 |
| **5. 통합 검증** | end-to-end 렌더 한 번 + 폴링 확인 | 15~16 |
**중요 순서:** Phase 1·2 먼저 (수신부 + worker 준비) → Phase 3 (전환) → Phase 4·5 (보안 + 검증). Phase 3을 먼저 하면 큐에 쌓이고 worker 미존재로 처리 안 됨.
---
## File Structure
### Phase 1·3·4 — NAS web-backend
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-backend/insta-lab/app/auth.py` (Create) | `verify_internal_key` dependency | X-Internal-Key 검증 |
| `web-backend/insta-lab/app/internal_router.py` (Create) | `POST /api/internal/insta/update` | Windows webhook 수신 |
| `web-backend/insta-lab/app/main.py` | redis client 추가 + `_bg_create_slate`/`_bg_render` 분할 + router include | Redis push 전환 |
| `web-backend/insta-lab/app/card_renderer.py` | Playwright 코드 전체 제거 (1줄 stub만 유지) | 더 이상 NAS에서 렌더 안 함 |
| `web-backend/insta-lab/requirements.txt` | playwright 제거, `redis>=5.0` 추가 | 의존성 |
| `web-backend/insta-lab/Dockerfile` | Chromium runtime dep 라인 + `playwright install` 제거 | image 절반 이하 |
| `web-backend/insta-lab/.env` (예시) | `REDIS_URL`, `INTERNAL_API_KEY` 추가 | 환경 |
| `web-backend/docker-compose.yml` | insta-lab service에 REDIS_URL, INTERNAL_API_KEY env 추가 + depends_on redis | compose |
| `web-backend/nginx/default.conf` | `location /api/internal/` IP allow + deny all | 3-layer 차단 |
### Phase 2 — Windows web-ai/services/
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-ai/services/docker-compose.yml` (Create) | insta-render service + network | compose |
| `web-ai/services/insta-render/Dockerfile` (Create) | python:3.12-slim + Chromium deps + playwright install | image |
| `web-ai/services/insta-render/requirements.txt` (Create) | fastapi, uvicorn, playwright, redis, httpx, jinja2, pillow | deps |
| `web-ai/services/insta-render/main.py` (Create) | FastAPI app + lifespan (Browser pool init + worker task) | entry |
| `web-ai/services/insta-render/worker.py` (Create) | Redis BLPOP 루프 + queue:paused 체크 + webhook 호출 | dispatcher |
| `web-ai/services/insta-render/card_renderer.py` (Create) | NAS card_renderer.py 이식 + Browser pool reuse | renderer |
| `web-ai/services/insta-render/templates/` (Copy) | NAS insta-lab/app/templates/ 복사 (`default/card.html.j2` + `hedgy75/...` 등) | Jinja |
| `web-ai/services/insta-render/tests/test_worker.py` (Create) | mocked Redis + 가짜 webhook 검증 | TDD |
| `web-ai/services/insta-render/.env.example` (Create) | `REDIS_URL`, `INTERNAL_API_KEY`, `NAS_BASE_URL` 플레이스홀더 | secrets |
---
## Task 1: NAS insta-lab — `verify_internal_key` dependency
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/auth.py`
- Test: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/tests/test_auth.py`
- [ ] **Step 1: 실패하는 테스트 작성**
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/tests/test_auth.py`:
```python
"""verify_internal_key dependency — Windows webhook 인증."""
import os
import pytest
from fastapi import HTTPException
from app.auth import verify_internal_key
def test_valid_key_passes(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
# dependency가 raise 안 하면 통과
verify_internal_key(x_internal_key="secret123")
def test_invalid_key_raises_401(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
with pytest.raises(HTTPException) as exc:
verify_internal_key(x_internal_key="wrong")
assert exc.value.status_code == 401
def test_missing_env_key_raises_401(monkeypatch):
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
with pytest.raises(HTTPException) as exc:
verify_internal_key(x_internal_key="any")
assert exc.value.status_code == 401
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest tests/test_auth.py -v`
Expected: FAIL — `app.auth` 모듈 미존재.
- [ ] **Step 3: `auth.py` 작성**
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/auth.py`:
```python
"""SP-4 — Windows worker → NAS internal webhook 인증.
X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교.
서버 측 키 미설정 시 401 (안전한 기본값).
"""
from __future__ import annotations
import os
from fastapi import Header, HTTPException
def verify_internal_key(x_internal_key: str = Header(...)):
expected = os.getenv("INTERNAL_API_KEY")
if not expected:
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
if x_internal_key != expected:
raise HTTPException(401, "Invalid X-Internal-Key")
```
- [ ] **Step 4: 테스트 통과**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest tests/test_auth.py -v`
Expected: 3 PASS.
- [ ] **Step 5: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add insta-lab/app/auth.py insta-lab/tests/test_auth.py
git commit -m "$(cat <<'EOF'
feat(insta-lab): verify_internal_key auth for Windows webhook (SP-4)
X-Internal-Key 헤더 검증 dependency. .env의 INTERNAL_API_KEY와 비교.
미설정 시 401 (fail-safe). Plan-B-Insta Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: NAS insta-lab — `/api/internal/insta/update` endpoint
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/internal_router.py`
- Test: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/tests/test_internal_router.py`
- [ ] **Step 1: 실패하는 테스트 작성**
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/tests/test_internal_router.py`:
```python
"""POST /api/internal/insta/update — Windows worker webhook."""
import os
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.internal_router import router
from app import db
@pytest.fixture(autouse=True)
def _set_key(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
@pytest.fixture
def client(tmp_path, monkeypatch):
# SQLite in-memory test
monkeypatch.setenv("INSTA_DATA_PATH", str(tmp_path))
db.init_db()
app = FastAPI()
app.include_router(router)
return TestClient(app)
def _make_task():
return db.create_task("slate_render", {"slate_id": 42})
def test_update_with_valid_key_updates_db(client):
tid = _make_task()
r = client.post(
"/api/internal/insta/update",
headers={"X-Internal-Key": "test-secret"},
json={"task_id": tid, "status": "processing", "progress": 30},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "processing"
assert task["progress"] == 30
def test_update_with_invalid_key_returns_401(client):
tid = _make_task()
r = client.post(
"/api/internal/insta/update",
headers={"X-Internal-Key": "wrong"},
json={"task_id": tid, "status": "processing", "progress": 30},
)
assert r.status_code == 401
def test_update_succeeded_sets_result_path(client):
tid = _make_task()
r = client.post(
"/api/internal/insta/update",
headers={"X-Internal-Key": "test-secret"},
json={
"task_id": tid,
"status": "succeeded",
"progress": 100,
"result_path": "/media/insta/42/01.png",
},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "succeeded"
assert task["result_id"] is not None # slate_id from input_data
def test_update_failed_records_error(client):
tid = _make_task()
r = client.post(
"/api/internal/insta/update",
headers={"X-Internal-Key": "test-secret"},
json={"task_id": tid, "status": "failed", "progress": 0, "error": "Chromium crashed"},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "failed"
assert "Chromium" in (task.get("error") or "")
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `python -m pytest insta-lab/tests/test_internal_router.py -v`
Expected: FAIL — `app.internal_router` 미존재.
- [ ] **Step 3: `internal_router.py` 작성**
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/internal_router.py`:
```python
"""SP-4 — Windows insta-render → NAS internal webhook.
POST /api/internal/insta/update
- X-Internal-Key 인증 필수
- task DB row update (status, progress, result_path, error)
- result_path는 nginx 서빙 경로 (예: /media/insta/{slate_id}/01.png)
- succeeded 시 input_data에서 slate_id 추출 → result_id 세팅
"""
from __future__ import annotations
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from . import db
from .auth import verify_internal_key
logger = logging.getLogger(__name__)
router = APIRouter()
class UpdatePayload(BaseModel):
task_id: str
status: str = Field(..., description="processing|succeeded|failed")
progress: int = Field(..., ge=0, le=100)
result_path: Optional[str] = None
error: Optional[str] = None
@router.post(
"/api/internal/insta/update",
dependencies=[Depends(verify_internal_key)],
)
def insta_update(payload: UpdatePayload):
task = db.get_task(payload.task_id)
if task is None:
raise HTTPException(404, f"task not found: {payload.task_id}")
result_id = None
if payload.status == "succeeded":
try:
input_data = json.loads(task.get("input_data") or "{}")
result_id = input_data.get("slate_id")
except (ValueError, TypeError):
pass
db.update_task(
payload.task_id,
payload.status,
payload.progress,
detail=payload.result_path or "",
result_id=result_id,
error=payload.error,
)
logger.info(
"internal/insta/update task=%s status=%s progress=%d",
payload.task_id, payload.status, payload.progress,
)
return {"ok": True}
```
- [ ] **Step 4: 테스트 통과**
Run: `python -m pytest insta-lab/tests/test_internal_router.py -v`
Expected: 4 PASS.
> **참고:** `db.update_task` signature는 기존 main.py 호출(`db.update_task(task_id, status, progress, detail, result_id=, error=)`)에서 확인 가능. `error` 파라미터를 키워드로 받아야 함. 만약 기존 시그니처가 `error`를 지원 안 하면 다음 Task에서 추가:
```python
# db.py 확장 (필요 시)
def update_task(task_id, status, progress, detail="", result_id=None, error=None):
...
```
- [ ] **Step 5: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add insta-lab/app/internal_router.py insta-lab/tests/test_internal_router.py
git commit -m "$(cat <<'EOF'
feat(insta-lab): internal webhook /api/internal/insta/update (SP-4)
Windows insta-render worker가 작업 진행률·완료·실패를 보고할 수신부.
X-Internal-Key 인증 필수. 4건의 단위 테스트로 status·error·result_path 검증.
Plan-B-Insta Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: NAS insta-lab — main.py에 internal_router include + Redis client 준비
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/main.py`
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/requirements.txt`
- [ ] **Step 1: requirements.txt에 redis 추가**
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/requirements.txt``playwright==1.48.0` 라인은 Task 11에서 제거. 지금은 redis만 추가:
기존 마지막 줄 다음에 추가:
```
redis>=5.0
```
- [ ] **Step 2: main.py 상단 import 추가**
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/main.py` 상단 import 블록에 추가 (다른 `from .` 들과 같은 위치):
```python
import redis.asyncio as aioredis
from .internal_router import router as internal_router
```
- [ ] **Step 3: app.include_router(internal_router)** 호출 추가
`app = FastAPI(...)` 정의 직후 (보통 line 25 근처) include 추가:
```python
app.include_router(internal_router)
```
- [ ] **Step 4: 모듈 레벨 redis client 생성**
`app = FastAPI(...)` 직전 또는 직후에:
```python
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
```
- [ ] **Step 5: 빠른 import sanity 체크**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -c "from app import main; print('OK')"`
만약 `ModuleNotFoundError: No module named 'redis'`는 로컬에 redis 미설치 (정상). 운영 image에선 requirements.txt로 install. CI/로컬 검증 위해선 한 번:
```bash
pip install redis>=5.0
```
- [ ] **Step 6: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add insta-lab/app/main.py insta-lab/requirements.txt
git commit -m "$(cat <<'EOF'
feat(insta-lab): wire internal_router + Redis client (SP-4 prep)
main.py에 internal_router include + 모듈 레벨 redis client.
requirements.txt에 redis>=5.0 추가 (playwright 제거는 Task 11에서).
Plan-B-Insta Phase 1 마무리. Task 11에서 _bg_render를 Redis push로 전환.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: NAS docker-compose에 REDIS_URL · INTERNAL_API_KEY env 주입
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
- [ ] **Step 1: insta-lab service의 environment에 추가**
`web-backend/docker-compose.yml`의 insta-lab service 블록에서 `environment:` 아래에 추가 (다른 env 변수들과 같은 위치):
```yaml
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
```
또 같은 service 블록에 `depends_on:` 추가 (없으면 신규, 있으면 redis 추가):
```yaml
depends_on:
- redis
```
(들여쓰기는 다른 옵션과 동일하게 4 space 또는 service 블록 시작 기준.)
- [ ] **Step 2: NAS .env에 INTERNAL_API_KEY 생성 안내**
박재오가 NAS의 `/volume1/docker/webpage/.env`에 다음 라인 추가:
```
INTERNAL_API_KEY=<32자 이상 random secret>
```
생성 명령 예시 (NAS bash 또는 WSL2):
```bash
openssl rand -hex 32
```
> 이 값은 Windows insta-render의 `.env`에도 동일 값으로 보관 필요 (Task 9에서).
- [ ] **Step 3: yaml 검증**
Run:
```bash
python -c "import yaml; d=yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml', encoding='utf-8')); env=d['services']['insta-lab'].get('environment', []); print([e for e in env if 'REDIS' in e or 'INTERNAL' in e])"
```
Expected: `['- REDIS_URL=...', '- INTERNAL_API_KEY=...']` 형태로 2 entries.
- [ ] **Step 4: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add docker-compose.yml
git commit -m "$(cat <<'EOF'
chore(compose): insta-lab REDIS_URL + INTERNAL_API_KEY env + depends_on redis
박재오: NAS .env에 INTERNAL_API_KEY=$(openssl rand -hex 32) 추가 필요.
같은 값을 Windows insta-render .env에 보관 (대칭).
Plan-B-Insta Phase 1 완료.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Windows insta-render — 디렉토리 생성 + Dockerfile + requirements.txt
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/Dockerfile`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/requirements.txt`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/.env.example`
- [ ] **Step 1: 디렉토리 생성**
```bash
mkdir -p C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render
mkdir -p C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/templates
mkdir -p C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/tests
```
- [ ] **Step 2: Dockerfile 작성**
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/Dockerfile`:
```dockerfile
FROM python:3.12-slim-bookworm
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Korean fonts + Chromium runtime deps (Debian 12 / bookworm)
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-cjk fonts-noto-cjk-extra \
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
libxfixes3 libxrandr2 libgbm1 libxshmfence1 libpango-1.0-0 \
libcairo2 libasound2 libatspi2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
RUN playwright install chromium
COPY . .
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
```
- [ ] **Step 3: requirements.txt 작성**
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/requirements.txt`:
```
fastapi==0.115.6
uvicorn[standard]==0.34.0
playwright==1.48.0
jinja2>=3.1.4
Pillow>=10
redis>=5.0
httpx>=0.27
pytest>=8.0
pytest-asyncio>=0.24
```
- [ ] **Step 4: .env.example 작성**
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/.env.example`:
```
# Plan-B-Insta — Windows insta-render worker
# NAS Redis 큐
REDIS_URL=redis://192.168.45.54:6379
# NAS internal webhook
NAS_BASE_URL=http://192.168.45.54:18700
INTERNAL_API_KEY=__copy_from_nas_dotenv__
# NAS SMB mount 안의 미디어 디렉토리 (/mnt/nas/webpage/data/insta/)
INSTA_MEDIA_ROOT=/mnt/nas/webpage/data/insta
# nginx 서빙 prefix (NAS webhook payload에 보낼 result_path 만들 때)
INSTA_MEDIA_URL_PREFIX=/media/insta
# Jinja 템플릿 디렉토리 (이 컨테이너 안)
CARD_TEMPLATE_DIR=/app/templates
```
- [ ] **Step 5: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/insta-render/Dockerfile services/insta-render/requirements.txt services/insta-render/.env.example
git commit -m "$(cat <<'EOF'
feat(services/insta-render): Dockerfile + requirements + env.example (SP-3 scaffold)
Windows WSL2 Docker용 Chromium 워커 컨테이너 기본 골격.
다음 task에서 main.py, worker.py, card_renderer.py 작성.
Plan-B-Insta Phase 2 시작.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: Windows insta-render — `card_renderer.py` 이식 + Browser pool
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/card_renderer.py`
- [ ] **Step 1: card_renderer.py 작성**
기존 NAS `web-backend/insta-lab/app/card_renderer.py`를 기반으로, 단 NAS DB 의존성(`from . import db`)을 제거하고 결과물을 `INSTA_MEDIA_ROOT`에 직접 저장 (SMB volume).
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/card_renderer.py`:
```python
"""Jinja → HTML → Playwright headless screenshot (Windows worker version).
NAS DB·db.py 의존성 제거. slate 데이터는 worker가 NAS HTTP API에서 fetch해서
인자로 전달. 결과 PNG는 INSTA_MEDIA_ROOT (/mnt/nas/webpage/data/insta/)에 직접 저장.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any, Dict, List
from jinja2 import Environment, FileSystemLoader, select_autoescape
from playwright.async_api import async_playwright
CARD_TEMPLATE_DIR = os.getenv("CARD_TEMPLATE_DIR", "/app/templates")
INSTA_MEDIA_ROOT = os.getenv("INSTA_MEDIA_ROOT", "/mnt/nas/webpage/data/insta")
logger = logging.getLogger(__name__)
# Chromium 동시 1개 (CPU·GPU 보호)
_RENDER_SEMAPHORE: asyncio.Semaphore | None = None
def _render_semaphore() -> asyncio.Semaphore:
global _RENDER_SEMAPHORE
if _RENDER_SEMAPHORE is None:
_RENDER_SEMAPHORE = asyncio.Semaphore(1)
return _RENDER_SEMAPHORE
# Browser pool — 매 슬레이트마다 launch X. 모듈 레벨 lazy + reuse.
_PLAYWRIGHT = None
_BROWSER = None
async def init_browser() -> None:
global _PLAYWRIGHT, _BROWSER
if _BROWSER is not None and _BROWSER.is_connected():
return
_PLAYWRIGHT = await async_playwright().start()
_BROWSER = await _PLAYWRIGHT.chromium.launch()
logger.info("Chromium browser pool 초기화 완료")
async def shutdown_browser() -> None:
global _PLAYWRIGHT, _BROWSER
if _BROWSER is not None:
try:
await _BROWSER.close()
except Exception:
logger.exception("browser close 중 예외 (무시)")
_BROWSER = None
if _PLAYWRIGHT is not None:
try:
await _PLAYWRIGHT.stop()
except Exception:
logger.exception("playwright stop 중 예외 (무시)")
_PLAYWRIGHT = None
async def _get_browser():
global _BROWSER
if _BROWSER is None or not _BROWSER.is_connected():
await init_browser()
return _BROWSER
def _env() -> Environment:
return Environment(
loader=FileSystemLoader(CARD_TEMPLATE_DIR),
autoescape=select_autoescape(["html", "j2"]),
)
def _build_pages(slate: dict) -> List[dict]:
"""slate dict → 10 page specs."""
cover = json.loads(slate["cover_copy"] or "{}")
bodies = json.loads(slate["body_copies"] or "[]")
cta = json.loads(slate["cta_copy"] or "{}")
accent = cover.get("accent_color") or "#0F62FE"
pages: List[dict] = []
pages.append({
"page_type": "cover", "page_no": 1, "total_pages": 10,
"headline": cover.get("headline", ""), "body": cover.get("body", ""),
"accent_color": accent, "cta": "",
})
for i, b in enumerate(bodies[:8]):
pages.append({
"page_type": "body", "page_no": i + 2, "total_pages": 10,
"headline": b.get("headline", ""), "body": b.get("body", ""),
"accent_color": accent, "cta": "",
})
pages.append({
"page_type": "cta", "page_no": 10, "total_pages": 10,
"headline": cta.get("headline", ""), "body": cta.get("body", ""),
"accent_color": accent, "cta": cta.get("cta", ""),
})
return pages
def _slate_dir(slate_id: int) -> str:
out = os.path.join(INSTA_MEDIA_ROOT, str(slate_id))
os.makedirs(out, exist_ok=True)
return out
async def render_slate(slate: dict, slate_id: int, template: str = "default/card.html.j2") -> List[str]:
"""slate 데이터 + slate_id로 10장 PNG 렌더. 결과 path list 반환."""
async with _render_semaphore():
return await _render_slate_locked(slate, slate_id, template)
async def _render_slate_locked(slate: dict, slate_id: int, template: str) -> List[str]:
env = _env()
template_full = Path(CARD_TEMPLATE_DIR) / template
if not template_full.exists():
logger.warning("Template '%s' 없음 → default/card.html.j2 폴백", template)
template = "default/card.html.j2"
tmpl = env.get_template(template)
pages = _build_pages(slate)
out_dir = _slate_dir(slate_id)
paths: List[str] = []
browser = await _get_browser()
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
try:
page = await ctx.new_page()
for spec in pages:
html_str = tmpl.render(**spec)
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
f.write(html_str)
html_path = f.name
try:
await page.goto(f"file://{html_path}", wait_until="networkidle")
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
await page.screenshot(path=out_path, full_page=False, omit_background=False)
paths.append(out_path)
finally:
try:
os.unlink(html_path)
except OSError:
pass
finally:
await ctx.close()
return paths
```
- [ ] **Step 2: templates 디렉토리 복사**
NAS의 templates를 Windows 측에 복사:
```bash
# NAS 측 path: web-backend/insta-lab/app/templates/
# Windows 측 path: web-ai/services/insta-render/templates/
cp -r C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/templates/* \
C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/templates/
ls C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/templates/
```
Expected: `default/`, `hedgy75/` 등 NAS의 모든 테마 디렉토리.
> **주의:** NAS의 templates 변경 시 Windows 측에도 같은 변경 필요 (수동 sync). 향후 별도 SMB share로 templates 노출하면 single source 가능 (out of scope).
- [ ] **Step 3: syntax check**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
python -c "import ast; ast.parse(open('services/insta-render/card_renderer.py', encoding='utf-8').read()); print('OK')"
```
- [ ] **Step 4: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/insta-render/card_renderer.py services/insta-render/templates/
git commit -m "$(cat <<'EOF'
feat(services/insta-render): card_renderer.py + templates (SP-3)
NAS insta-lab/app/card_renderer.py 이식 + DB 의존성 제거.
slate 데이터는 worker가 NAS API에서 fetch해 인자로 전달.
결과 PNG는 INSTA_MEDIA_ROOT (/mnt/nas/webpage/data/insta/)에 직접 저장.
Browser pool + Semaphore(1) reuse (동시 Chromium 1개).
templates는 NAS와 동기화 (현재 default theme만).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 7: Windows insta-render — `worker.py` (Redis BLPOP + webhook)
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/worker.py`
- [ ] **Step 1: worker.py 작성**
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/worker.py`:
```python
"""Redis BLPOP worker — queue:insta-render → render_slate → NAS webhook.
queue:paused가 set이면 대기 (task-watcher가 박재오 활동 감지 시 set).
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
import httpx
import redis.asyncio as aioredis
from card_renderer import render_slate
logger = logging.getLogger(__name__)
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
NAS_BASE_URL = os.getenv("NAS_BASE_URL", "http://192.168.45.54:18700")
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "")
INSTA_MEDIA_URL_PREFIX = os.getenv("INSTA_MEDIA_URL_PREFIX", "/media/insta")
QUEUE_KEY = "queue:insta-render"
PAUSED_KEY = "queue:paused"
async def _post_update(client: httpx.AsyncClient, task_id: str, status: str, progress: int,
result_path: str | None = None, error: str | None = None) -> None:
"""NAS internal webhook 호출."""
url = f"{NAS_BASE_URL}/api/internal/insta/update"
payload: dict[str, Any] = {"task_id": task_id, "status": status, "progress": progress}
if result_path:
payload["result_path"] = result_path
if error:
payload["error"] = error
try:
r = await client.post(
url,
headers={"X-Internal-Key": INTERNAL_API_KEY},
json=payload,
timeout=10.0,
)
if r.status_code != 200:
logger.error("webhook %s returned %d: %s", task_id, r.status_code, r.text[:200])
except Exception:
logger.exception("webhook %s 호출 실패", task_id)
async def _fetch_slate(client: httpx.AsyncClient, slate_id: int) -> dict:
"""NAS /api/insta/slates/{id} GET. (인증 불필요 — 기존 endpoint)"""
r = await client.get(f"{NAS_BASE_URL}/api/insta/slates/{slate_id}", timeout=10.0)
r.raise_for_status()
return r.json()
async def _process_one(client: httpx.AsyncClient, payload: dict) -> None:
"""단일 작업 처리: fetch slate → render → webhook."""
task_id = payload["task_id"]
params = payload.get("params", {})
slate_id = params.get("slate_id")
theme = params.get("theme", "default")
template = f"{theme}/card.html.j2"
try:
await _post_update(client, task_id, "processing", 20)
slate = await _fetch_slate(client, slate_id)
await _post_update(client, task_id, "processing", 50)
paths = await render_slate(slate, slate_id, template=template)
# 결과 URL은 첫 페이지의 nginx 경로
first_url = f"{INSTA_MEDIA_URL_PREFIX}/{slate_id}/01.png"
await _post_update(
client, task_id, "succeeded", 100, result_path=first_url
)
logger.info("rendered task=%s slate=%s count=%d", task_id, slate_id, len(paths))
except Exception as e:
logger.exception("render task=%s 실패", task_id)
await _post_update(client, task_id, "failed", 0, error=str(e))
async def worker_loop():
"""무한 루프 — paused 체크 → BLPOP → process_one."""
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
async with httpx.AsyncClient() as client:
logger.info("insta-render worker started (queue=%s)", QUEUE_KEY)
while True:
try:
paused = await redis.get(PAUSED_KEY)
if paused == b"1":
await asyncio.sleep(10)
continue
item = await redis.blpop(QUEUE_KEY, timeout=1)
if item is None:
continue
_, raw = item
try:
payload = json.loads(raw)
except json.JSONDecodeError:
logger.error("invalid queue payload: %r", raw[:200])
continue
await _process_one(client, payload)
except asyncio.CancelledError:
logger.info("worker_loop cancelled")
raise
except Exception:
logger.exception("worker_loop iteration 실패, 5초 후 재시도")
await asyncio.sleep(5)
```
- [ ] **Step 2: syntax check**
```bash
python -c "import ast; ast.parse(open('C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/worker.py', encoding='utf-8').read()); print('OK')"
```
- [ ] **Step 3: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/insta-render/worker.py
git commit -m "$(cat <<'EOF'
feat(services/insta-render): Redis BLPOP worker + NAS webhook (SP-3)
queue:insta-render에서 BLPOP → NAS API에서 slate 조회 → render →
internal webhook으로 NAS DB 업데이트. queue:paused 체크 (task-watcher 연동).
Plan-B-Insta Phase 2 진행 중.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 8: Windows insta-render — `main.py` (FastAPI entry + lifespan)
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/main.py`
- [ ] **Step 1: main.py 작성**
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/main.py`:
```python
"""insta-render FastAPI entry — health + lifespan (Browser pool + worker loop)."""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
import card_renderer
import worker
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Browser pool 초기화 (Chromium launch)
await card_renderer.init_browser()
# 큐 워커 백그라운드 시작
worker_task = asyncio.create_task(worker.worker_loop())
logger.info("insta-render lifespan 시작")
try:
yield
finally:
worker_task.cancel()
try:
await worker_task
except asyncio.CancelledError:
pass
await card_renderer.shutdown_browser()
logger.info("insta-render lifespan 종료")
app = FastAPI(lifespan=lifespan)
@app.get("/health")
def health():
return {"ok": True, "service": "insta-render"}
```
- [ ] **Step 2: syntax check**
```bash
python -c "import ast; ast.parse(open('C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/main.py', encoding='utf-8').read()); print('OK')"
```
- [ ] **Step 3: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/insta-render/main.py
git commit -m "$(cat <<'EOF'
feat(services/insta-render): FastAPI entry + lifespan (SP-3)
lifespan에서 Browser pool init + worker_loop spawn. shutdown 시 정상 cleanup.
GET /health (LivenessProbe용).
Plan-B-Insta Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 9: Windows services/docker-compose.yml + .env 운영 셋업
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/.env` (NOT committed — 박재오 머신 로컬만)
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-ai/.gitignore`
- [ ] **Step 1: docker-compose.yml 작성**
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`:
```yaml
name: web-ai-services
services:
insta-render:
build:
context: ./insta-render
container_name: insta-render
restart: unless-stopped
ports:
- "18710:8000"
environment:
- TZ=Asia/Seoul
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
- NAS_BASE_URL=${NAS_BASE_URL:-http://192.168.45.54:18700}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
- INSTA_MEDIA_ROOT=${INSTA_MEDIA_ROOT:-/mnt/nas/webpage/data/insta}
- INSTA_MEDIA_URL_PREFIX=${INSTA_MEDIA_URL_PREFIX:-/media/insta}
- CARD_TEMPLATE_DIR=/app/templates
volumes:
- /mnt/nas/webpage/data/insta:/mnt/nas/webpage/data/insta
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
```
- [ ] **Step 2: .gitignore에 .env 추가**
`C:/Users/jaeoh/Desktop/workspace/web-ai/.gitignore`에 다음 라인 (없으면) 추가:
```
services/.env
services/*/.env
```
- [ ] **Step 3: 박재오가 .env 실 작성**
Windows AI 머신의 WSL2 또는 PowerShell에서:
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai/services
cp insta-render/.env.example .env
nano .env
# REDIS_URL, NAS_BASE_URL는 기본값 OK
# INTERNAL_API_KEY는 NAS .env의 같은 값으로 채움
```
또는 한 줄로:
```bash
cd /mnt/c/Users/jaeoh/Desktop/workspace/web-ai/services
echo "INTERNAL_API_KEY=$(openssl rand -hex 32)" > .env.tmp
echo "REDIS_URL=redis://192.168.45.54:6379" >> .env.tmp
echo "NAS_BASE_URL=http://192.168.45.54:18700" >> .env.tmp
echo "INSTA_MEDIA_ROOT=/mnt/nas/webpage/data/insta" >> .env.tmp
echo "INSTA_MEDIA_URL_PREFIX=/media/insta" >> .env.tmp
mv .env.tmp .env
cat .env
```
> **이 INTERNAL_API_KEY 값을 NAS .env에도 같은 값으로 설정.** 위 명령은 새 random 키 생성이므로 NAS와 미일치. 박재오가 Task 4의 NAS .env 작성과 같은 키 보장해야 함.
- [ ] **Step 4: docker compose build + up (Windows WSL2에서)**
박재오 WSL2 bash:
```bash
cd /mnt/c/Users/jaeoh/Desktop/workspace/web-ai/services
docker compose build insta-render
docker compose up -d insta-render
docker compose ps
```
Expected:
- build: ~10분 (Chromium + Korean fonts install)
- ps: `insta-render` Up (healthy)
- [ ] **Step 5: 헬스 확인**
```bash
curl http://localhost:18710/health
```
Expected: `{"ok": true, "service": "insta-render"}`
- [ ] **Step 6: 커밋 (docker-compose만, .env 제외)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/docker-compose.yml .gitignore
git commit -m "$(cat <<'EOF'
feat(services): docker-compose for insta-render worker (SP-3)
Windows WSL2 Docker용. NAS Redis 6379 + NAS API 18700 호출.
/mnt/nas SMB 볼륨 마운트. INTERNAL_API_KEY는 NAS .env와 같은 값.
.env는 .gitignore (박재오 머신 로컬 보관).
Plan-B-Insta Phase 2 마무리.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 10: Windows insta-render — 단위 테스트 (TDD)
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/tests/test_worker.py`
- [ ] **Step 1: 테스트 작성 (mock Redis + httpx)**
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/tests/test_worker.py`:
```python
"""worker.py — Redis BLPOP + webhook 단위 테스트."""
import json
import pytest
import httpx
from unittest.mock import AsyncMock, patch
import worker
@pytest.fixture
def fake_slate():
return {
"id": 42,
"cover_copy": json.dumps({"headline": "테스트 H", "body": "테스트 B", "accent_color": "#FF0000"}),
"body_copies": json.dumps([{"headline": "본문1", "body": "..."} for _ in range(8)]),
"cta_copy": json.dumps({"headline": "CTA", "body": "...", "cta": "Click"}),
}
@pytest.mark.asyncio
async def test_post_update_sends_correct_payload(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
monkeypatch.setenv("NAS_BASE_URL", "http://nas.test")
# worker 모듈 환경변수 재로딩
worker.NAS_BASE_URL = "http://nas.test"
worker.INTERNAL_API_KEY = "test-secret"
captured = {}
async def fake_post(self, url, headers=None, json=None, **kw):
captured["url"] = url
captured["headers"] = headers
captured["json"] = json
class R:
status_code = 200
text = "ok"
return R()
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
async with httpx.AsyncClient() as client:
await worker._post_update(client, "t-1", "processing", 30)
assert captured["url"] == "http://nas.test/api/internal/insta/update"
assert captured["headers"]["X-Internal-Key"] == "test-secret"
assert captured["json"]["status"] == "processing"
assert captured["json"]["progress"] == 30
@pytest.mark.asyncio
async def test_process_one_success_calls_webhook_twice(monkeypatch, fake_slate):
"""processing(50) → succeeded(100) 두 번 호출 + render 한 번."""
calls: list = []
async def fake_post(self, url, headers=None, json=None, **kw):
calls.append({"status": json["status"], "progress": json["progress"]})
class R:
status_code = 200
text = "ok"
return R()
async def fake_get(self, url, **kw):
class R:
status_code = 200
def json(self_inner): return fake_slate
def raise_for_status(self_inner): pass
return R()
async def fake_render(slate, slate_id, template="default/card.html.j2"):
return [f"/tmp/{slate_id}/{i:02d}.png" for i in range(1, 11)]
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
monkeypatch.setattr(worker, "render_slate", fake_render)
worker.INTERNAL_API_KEY = "test"
worker.NAS_BASE_URL = "http://nas.test"
async with httpx.AsyncClient() as client:
await worker._process_one(client, {
"task_id": "t-2",
"params": {"slate_id": 42, "theme": "default"},
})
statuses = [c["status"] for c in calls]
assert "processing" in statuses
assert "succeeded" in statuses
assert calls[-1]["progress"] == 100
@pytest.mark.asyncio
async def test_process_one_render_failure_reports_failed(monkeypatch, fake_slate):
"""render 예외 시 failed webhook 호출."""
calls: list = []
async def fake_post(self, url, headers=None, json=None, **kw):
calls.append(json)
class R: status_code = 200; text = "ok"
return R()
async def fake_get(self, url, **kw):
class R:
status_code = 200
def json(self_inner): return fake_slate
def raise_for_status(self_inner): pass
return R()
async def fake_render(*a, **k):
raise RuntimeError("Chromium crashed")
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
monkeypatch.setattr(worker, "render_slate", fake_render)
worker.INTERNAL_API_KEY = "test"
worker.NAS_BASE_URL = "http://nas.test"
async with httpx.AsyncClient() as client:
await worker._process_one(client, {
"task_id": "t-3",
"params": {"slate_id": 99},
})
last = calls[-1]
assert last["status"] == "failed"
assert "Chromium" in last["error"]
```
- [ ] **Step 2: pytest.ini 작성 (asyncio mode)**
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/pytest.ini`:
```ini
[pytest]
asyncio_mode = auto
```
- [ ] **Step 3: 테스트 실행**
WSL2 또는 박재오 PC에 pip install 필요. 빠른 검증을 위해 docker 내부 시도:
```bash
cd /mnt/c/Users/jaeoh/Desktop/workspace/web-ai/services
docker compose run --rm insta-render python -m pytest tests/ -v
```
Expected: 3 PASS.
> **alt**: 박재오 Windows 시스템 Python에 직접 — `pip install fastapi playwright redis httpx pytest pytest-asyncio jinja2` 후 services/insta-render/ 안에서 pytest.
- [ ] **Step 4: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/insta-render/tests/test_worker.py services/insta-render/pytest.ini
git commit -m "$(cat <<'EOF'
test(services/insta-render): worker unit tests (3 cases)
- _post_update payload·헤더 검증
- _process_one 정상 흐름 (processing + succeeded)
- _process_one 예외 시 failed webhook
Plan-B-Insta Phase 2 mature. Phase 3 cutover 준비 완료.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 11: NAS insta-lab cutover — `_bg_create_slate` + `_bg_render`를 Redis push로
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/main.py`
- [ ] **Step 1: 현재 두 render 호출 위치 확인**
```bash
grep -n "card_renderer\.render_slate" C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/main.py
```
Expected: 2 곳 (line 157 근처 `_bg_create_slate`, line 197 근처 `_bg_render`).
- [ ] **Step 2: 첫 번째 호출 (`_bg_create_slate`)을 Redis push로 변경**
`web-backend/insta-lab/app/main.py``_bg_create_slate` 함수 본체에서 `await card_renderer.render_slate(sid, ...)` 라인을 다음으로 교체:
변경 전:
```python
db.update_task(task_id, "processing", 70, "카드 렌더 중")
await card_renderer.render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
db.update_slate_status(sid, "rendered")
```
변경 후:
```python
db.update_task(task_id, "processing", 70, "Redis 큐 푸시 → Windows worker 대기 중")
from datetime import datetime, timezone, timedelta
kst = timezone(timedelta(hours=9))
payload = {
"task_id": task_id,
"kind": "insta",
"params": {"slate_id": sid, "theme": INSTA_DEFAULT_THEME},
"submitted_at": datetime.now(kst).isoformat(),
}
await redis_client.rpush("queue:insta-render", json.dumps(payload))
# 사용자는 GET /api/insta/tasks/{task_id}로 폴링 (worker가 webhook으로 status update)
```
- [ ] **Step 3: 두 번째 호출 (`_bg_render`)도 동일 변경**
`_bg_render(task_id, slate_id)` 함수 본체:
변경 전:
```python
async def _bg_render(task_id: str, slate_id: int):
try:
db.update_task(task_id, "processing", 30, "재렌더 중")
await card_renderer.render_slate(slate_id, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
db.update_slate_status(slate_id, "rendered")
db.update_task(task_id, "succeeded", 100, "완료", result_id=slate_id)
except Exception as e:
logger.exception("render failed")
db.update_task(task_id, "failed", 0, "", error=str(e))
```
변경 후:
```python
async def _bg_render(task_id: str, slate_id: int):
"""Redis 큐에 push. 실 렌더는 Windows insta-render worker."""
try:
from datetime import datetime, timezone, timedelta
kst = timezone(timedelta(hours=9))
payload = {
"task_id": task_id,
"kind": "insta",
"params": {"slate_id": slate_id, "theme": INSTA_DEFAULT_THEME},
"submitted_at": datetime.now(kst).isoformat(),
}
await redis_client.rpush("queue:insta-render", json.dumps(payload))
db.update_task(task_id, "processing", 30, "Redis 큐 푸시 → Windows worker 대기 중")
except Exception as e:
logger.exception("queue push failed")
db.update_task(task_id, "failed", 0, "", error=str(e))
```
- [ ] **Step 4: db.update_slate_status 호출 위치 정리**
기존 `db.update_slate_status(sid, "rendered")`는 Windows webhook이 succeeded 보고 시점에 NAS가 처리해야 함. 즉 `internal_router.py`에서 status="succeeded" 시 slate_status도 업데이트:
`web-backend/insta-lab/app/internal_router.py``insta_update` 함수에 추가 (succeeded 분기):
```python
if payload.status == "succeeded":
# input_data에서 slate_id 추출
try:
input_data = json.loads(task.get("input_data") or "{}")
result_id = input_data.get("slate_id")
except (ValueError, TypeError):
pass
# slate status도 rendered로 갱신
if result_id is not None:
try:
db.update_slate_status(result_id, "rendered")
except Exception:
logger.exception("update_slate_status %s 실패 (무시)", result_id)
```
- [ ] **Step 5: 빠른 import sanity 체크**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -c "from app import main, internal_router; print('OK')"
```
- [ ] **Step 6: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add insta-lab/app/main.py insta-lab/app/internal_router.py
git commit -m "$(cat <<'EOF'
feat(insta-lab): cutover to Redis push, Playwright 렌더 호출 제거 (SP-4)
_bg_create_slate, _bg_render의 await card_renderer.render_slate(...)
호출을 Redis RPUSH queue:insta-render 로 전환.
NAS는 task_id 발급 + 큐 푸시 + 30% 진행률 보고만. Windows insta-render
워커가 BLPOP → 렌더 → webhook으로 succeeded 보고 시 NAS가
update_slate_status('rendered') 트리거.
Plan-B-Insta Phase 3 (cutover).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 12: NAS insta-lab — card_renderer.py stub + Dockerfile 슬림화
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/card_renderer.py`
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/Dockerfile`
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/requirements.txt`
- [ ] **Step 1: card_renderer.py 전체 제거**
`web-backend/insta-lab/app/card_renderer.py`을 다음 한 줄로 교체 (NAS에서 더 이상 렌더 안 함):
```python
"""DEPRECATED 2026-05-19 — NAS에서 카드 렌더 안 함. Windows insta-render 워커로 이전됨.
기존 render_slate, init_browser, shutdown_browser는 모두 web-ai/services/insta-render/card_renderer.py로 이식.
NAS insta-lab은 Redis push (queue:insta-render)만 담당.
이 파일은 임포트 호환성 위해서만 존재. 새 코드는 이 모듈을 import하지 말 것.
"""
```
- [ ] **Step 2: main.py에서 `import card_renderer` 라인 제거**
```bash
grep -n "import card_renderer\|from .card_renderer\|from . import card_renderer" C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/main.py
```
해당 라인 찾으면 그 라인 삭제. Task 11에서 모든 `card_renderer.render_slate` 호출이 Redis push로 바뀌어서 import 불필요.
- [ ] **Step 3: requirements.txt에서 playwright 라인 제거**
`web-backend/insta-lab/requirements.txt`에서 `playwright==1.48.0` 라인 삭제.
남는 내용:
```
fastapi==0.115.6
uvicorn[standard]==0.34.0
requests==2.32.3
httpx>=0.27
anthropic==0.52.0
jinja2>=3.1.4
Pillow>=10
pytest>=8.0
pytest-asyncio>=0.24
redis>=5.0
```
- [ ] **Step 4: Dockerfile 슬림화**
`web-backend/insta-lab/Dockerfile`을 다음으로 교체 (Chromium runtime 의존성 + `playwright install` 제거):
```dockerfile
FROM python:3.12-slim-bookworm
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Korean fonts (insta-lab가 자체 텍스트 처리는 안 하지만 향후 thumbnail 생성 등 위해 유지)
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-cjk fonts-noto-cjk-extra \
&& 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 ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
```
이전 ~30개 Chromium dep 라인 + `playwright install chromium` 라인 모두 제거됨. image 크기 ~50% 감소 예상.
- [ ] **Step 5: pytest로 NAS insta-lab tests 회귀 확인**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab
python -m pytest tests/ -v 2>&1 | tail -10
```
Expected: 모든 tests PASS (test_card_renderer.py는 모듈 stub이 됐으니 일부 skip되거나 fail 가능 — 그 경우 test 파일을 deprecated 처리하거나 unit이 mock으로 변경되었는지 확인).
만약 `test_card_renderer.py``from app.card_renderer import render_slate`로 import 시도해서 fail이면, 그 test 파일도 deprecated:
```bash
mv tests/test_card_renderer.py tests/test_card_renderer.py.deprecated
```
또는 git rm:
```bash
git rm tests/test_card_renderer.py
```
- [ ] **Step 6: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add insta-lab/app/card_renderer.py insta-lab/app/main.py \
insta-lab/requirements.txt insta-lab/Dockerfile \
insta-lab/tests/
git commit -m "$(cat <<'EOF'
refactor(insta-lab): remove Playwright + slim Dockerfile (SP-4)
NAS에서 더 이상 카드 렌더 안 함 → Windows insta-render 워커로 완전 이전.
- card_renderer.py를 1줄 deprecation stub로 교체
- main.py의 import card_renderer 제거
- requirements.txt에서 playwright 삭제
- Dockerfile에서 Chromium 30+ dep 라인 + playwright install 제거 → image ~50% 감소
- test_card_renderer.py 폐기 (Windows 측 test_worker.py가 대체)
Plan-B-Insta Phase 3 완료.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 13: NAS push + deployer rebuild + 헬스 확인
**Files:** 없음 (git push + 운영 검증)
- [ ] **Step 1: web-backend push (Gitea webhook → NAS deployer)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git push origin main
```
(자격증명 prompt 1회. 1회 실패 시 재시도.)
- [ ] **Step 2: deployer rebuild 대기 (~3분, Chromium dep 제거로 빌드 더 빠름)**
```bash
for i in 1 2 3 4 5 6 7 8 9 10; do
code=$(curl -s -o /dev/null -w "%{http_code}" https://gahusb.synology.me/api/stock/news -m 5)
echo "[try $i, $(date +%H:%M:%S)] stock $code"
if [ "$code" = "200" ]; then break; fi
sleep 15
done
```
- [ ] **Step 3: insta-lab 헬스 + 신 endpoint 확인**
```bash
curl -s -o /dev/null -w "/api/insta/status: %{http_code}\n" https://gahusb.synology.me/api/insta/status
# Internal webhook은 X-Internal-Key 필요 — LAN에서 시도 (인증 X면 401)
curl -s -X POST https://gahusb.synology.me/api/internal/insta/update \
-H "Content-Type: application/json" \
-d '{"task_id":"test","status":"processing","progress":1}' \
-o /dev/null -w "/api/internal/insta/update no key: %{http_code}\n"
# Expected: 401 또는 422 (key 없거나 task_id 없음)
```
- [ ] **Step 4: 커밋 없음, 검증만**
NAS deployer가 정상 작동했으면 다음 Phase 4 (nginx 차단) 진행.
---
## Task 14: nginx에 `/api/internal/*` LAN allow + deny all
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf`
- [ ] **Step 1: insta-lab routing 블록 근처에 internal 차단 추가**
`web-backend/nginx/default.conf`에서 `location /api/insta/` 블록 찾기. 그 위 또는 아래에 다음 새 블록 추가:
```nginx
# Plan-B-Insta — Windows worker → NAS internal webhook (3-layer 차단)
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
# Layer 3: X-Internal-Key (FastAPI dependency)
location /api/internal/insta/ {
allow 192.168.45.0/24; # LAN 화이트리스트
allow 100.64.0.0/10; # Tailscale CGNAT
allow 127.0.0.1; # NAS 내부
deny all;
resolver 127.0.0.11 valid=10s;
set $insta_backend insta-lab:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Internal-Key $http_x_internal_key;
proxy_pass http://$insta_backend$request_uri;
}
```
> 들여쓰기는 기존 location 블록과 같은 수준 (보통 `server {}` 블록 안 2-space).
- [ ] **Step 2: nginx config syntax 검증 (NAS bash, 박재오 SSH)**
```bash
ssh -p 2300 bgg8988@gahusb.synology.me
sudo -i
docker exec frontend nginx -t
```
Expected: `nginx: configuration file /etc/nginx/nginx.conf test is successful`
- [ ] **Step 3: nginx reload (커밋 후 deployer가 자동 처리하지만 빠른 적용 위해 수동 가능)**
수동 (즉시):
```bash
docker exec frontend nginx -s reload
```
또는 다음 deployer cycle에서 자동 reload (deploy.sh의 `docker exec frontend nginx -s reload || true`).
- [ ] **Step 4: 외부에서 차단 확인**
박재오 머신 외부 connection (예: 휴대폰 LTE)에서:
```
curl -X POST https://gahusb.synology.me/api/internal/insta/update -d '{}'
```
Expected: **403 Forbidden** (nginx deny).
LAN에서는 (key 없이):
```bash
curl -X POST https://gahusb.synology.me/api/internal/insta/update -d '{}'
```
Expected: **401 또는 422** (nginx 통과, FastAPI에서 인증·validation).
- [ ] **Step 5: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add nginx/default.conf
git commit -m "$(cat <<'EOF'
feat(nginx): 3-layer block for /api/internal/insta/ (SP-4)
Layer 1·2: IP 화이트리스트 (192.168.45.0/24 LAN + 100.64.0.0/10 Tailscale).
Layer 3: X-Internal-Key 헤더 (FastAPI dependency, 별도 검증).
외부에서 직접 호출 시 403 (nginx deny), LAN에서 키 없으면 401 (FastAPI).
Windows insta-render만 호출 가능.
Plan-B-Insta Phase 4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git push origin main
```
(push 후 deployer가 자동 reload.)
---
## Task 15: 통합 검증 — end-to-end 렌더 한 번
**Files:** 없음 (운영 검증)
- [ ] **Step 1: Windows insta-render 컨테이너 확인**
박재오 WSL2:
```bash
docker compose -f /mnt/c/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml ps
docker compose -f .../services/docker-compose.yml logs insta-render --tail 30
```
Expected:
- ps: `insta-render` Up (healthy)
- logs: `insta-render worker started`, `Chromium browser pool 초기화 완료`
만약 안 떠 있으면:
```bash
docker compose up -d insta-render
```
- [ ] **Step 2: NAS에 카드 슬레이트 생성 + 렌더 트리거**
LAN에서 박재오 PC:
```bash
# 1) 슬레이트 ID 임의 (이전 작업한 slate가 있으면 그 ID 사용)
# 슬레이트 목록 조회
curl -s https://gahusb.synology.me/api/insta/slates | python -m json.tool | head -30
# 2) 임의 slate ID (예: 1)로 재렌더 트리거
SLATE_ID=1
curl -X POST "https://gahusb.synology.me/api/insta/slates/${SLATE_ID}/render" -o /tmp/render-resp.json
cat /tmp/render-resp.json
# Expected: {"task_id": "xxxxx-..."}
TASK_ID=$(cat /tmp/render-resp.json | python -c "import json,sys; print(json.load(sys.stdin)['task_id'])")
echo "task_id=$TASK_ID"
```
- [ ] **Step 3: 폴링으로 진행률 확인**
```bash
for i in 1 2 3 4 5 6 7 8 9 10; do
curl -s "https://gahusb.synology.me/api/insta/tasks/${TASK_ID}" | python -c "import json,sys; d=json.load(sys.stdin); print(f'[try $i] status={d[\"status\"]} progress={d[\"progress\"]}')"
sleep 5
done
```
Expected 진행:
- try 1~2: `status=processing progress=30` (NAS push 직후)
- try 3~4: `status=processing progress=50` (worker fetch + render 시작)
- try 5~6: `status=succeeded progress=100`
- [ ] **Step 4: 생성된 PNG 확인 (NAS 측 + 브라우저)**
NAS:
```bash
ls /volume1/docker/webpage/data/insta/${SLATE_ID}/
# Expected: 01.png ~ 10.png (각 ~50~150KB)
```
브라우저:
```
https://gahusb.synology.me/media/insta/${SLATE_ID}/01.png
```
→ 카드 이미지 보이면 end-to-end 성공.
- [ ] **Step 5: Windows worker 로그 확인**
```bash
docker compose -f .../services/docker-compose.yml logs insta-render --tail 50 | grep -E "task=|rendered"
```
Expected:
```
... task=xxxxx slate=1 count=10
```
---
## Task 16: 최종 정리 + Plan-B-Insta 마무리
**Files:** 없음
- [ ] **Step 1: Phase 2 services repo 진입 (cd web-ai)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git status -sb
git log --oneline 26ef660..HEAD
```
이번 Plan-B-Insta web-ai 측 commits 확인. push 안 했으면:
```bash
git push origin main
```
- [ ] **Step 2: 모든 변경 점검 + commit이력**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git log --oneline | head -10
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git log --oneline | head -10
```
- [ ] **Step 3: 후속 trace — task-watcher (SP-10) 연동 확인 (옵션)**
queue:paused 메커니즘 검증. 박재오가 task-watcher 컨테이너 신설 전이면 manually 토글 가능:
NAS bash:
```bash
docker exec redis redis-cli SET queue:paused 1 EX 60
# 60초 안에 insta render 시도 → 큐 쌓이지만 worker idle
docker compose -f .../services/docker-compose.yml logs insta-render --tail 5
# Expected: 60초간 BLPOP 안 함
docker exec redis redis-cli DEL queue:paused
# worker가 즉시 처리 재개
```
> task-watcher는 별도 plan (Plan-B-Infra)에서 신설. 이번 plan은 paused 키만 인지.
---
## Self-Review
### Spec 커버리지
| Spec 요구사항 | 구현 Task |
|---------------|-----------|
| §4 SP-3: insta-render Windows 서비스 | Task 5~10 |
| §4 SP-4: NAS insta-lab 분할 (Playwright 제거 + Redis push) | Task 1~4, 11~12 |
| §5 Windows Render Worker 통합 패턴 (BLPOP + webhook + SMB) | worker.py 구조 (Task 7) |
| §6 Redis 키 `queue:insta-render`, `queue:paused` | Task 11 payload + Task 7 paused check |
| §7 NAS 볼륨 `/mnt/nas/webpage/data/insta/` | Task 6 INSTA_MEDIA_ROOT |
| §8 internal webhook + X-Internal-Key + 3-layer 차단 | Task 1·2·14 |
| §9 키 분리 (X-WebAI-Key vs X-Internal-Key) | Task 1 (auth.py 독립) |
| §11 데이터 플로우 검증 시퀀스 | Task 15 |
### Placeholder 스캔
- 모든 step에 구체 명령·코드 포함 ✓
- "박재오NAS비밀번호" 같은 placeholder 자리 — `INTERNAL_API_KEY=$(openssl rand -hex 32)` 자동 생성 명령 명시 ✓
- 한 가지 예외: Task 15 Step 2의 `SLATE_ID=1`은 박재오 환경에 존재하는 임의 slate. 박재오가 실 ID로 교체 — 환경 의존 placeholder, 정상
### Type consistency
- `verify_internal_key` dependency — Task 1 정의, Task 2·14에서 사용 일치 ✓
- `UpdatePayload` Pydantic 모델 — Task 2 정의 필드(status, progress, result_path, error) ↔ Task 7 worker `_post_update` payload 키 일치 ✓
- Redis key `queue:insta-render`, `queue:paused` 모든 task에서 동일 명명 ✓
- env 변수 `REDIS_URL`, `INTERNAL_API_KEY`, `NAS_BASE_URL`, `INSTA_MEDIA_ROOT`, `INSTA_MEDIA_URL_PREFIX` — Task 5·7·9에서 일관 ✓
- `card_renderer.render_slate(slate, slate_id, template=)` 시그니처 — Task 6 정의, Task 7·10에서 동일 호출 ✓
### 위험·주의
| 위험 | 완화 |
|------|------|
| NAS·Windows INTERNAL_API_KEY 불일치 | Task 4·9에서 같은 값 명시 (openssl rand 1회 + 양쪽 copy) |
| Windows worker SMB 마운트 끊김 | fstab `_netdev,nofail` (Plan-B-Base Task 7) + worker가 webhook에 result_path를 nginx 경로로 보고 |
| templates 비동기 (NAS vs Windows) | 현재 manual sync. 변경 시 양쪽 commit. SMB share로 통합은 별도 plan |
| Chromium image 크기 (~500MB) | 첫 build만 시간 소요. 이후 layer cache 활용 |
| Phase 3 cutover 시 큐 worker 미존재 | Phase 2를 먼저 완료 + 검증 후 Phase 3. Task 순서로 강제됨 |
| webhook 401 (키 불일치) | Task 4 단계에서 키 검증 + Task 14 직후 curl로 401 확인 |
| Plan-B-Base의 SMB 마운트 의존 | Plan-B-Base 완료 검증됨 (이미 prerequisite) |
---
## 완료 후 다음 단계
Plan-B-Insta 완료 후 spec §14 권장 순서대로:
1. **Plan-B-Music** — SP-5 + SP-6 (Suno API + MusicGen Windows worker)
2. **Plan-B-Video** — SP-7 + SP-8 (외부 영상 API gateway + 새 video-lab 컨테이너)
3. **Plan-B-Infra** — SP-9 + SP-10 (NSSM 자동 시작 + task-watcher)
각 후속 plan은 본 plan과 같은 패턴 반복 (worker pattern §5). 첫 번째인 인스타가 가장 비용 큼; 음악·영상은 복붙 + 외부 API 변경.