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

62 KiB

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:

"""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:

"""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: 커밋
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:

"""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:

"""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에서 추가:

# db.py 확장 (필요 시)
def update_task(task_id, status, progress, detail="", result_id=None, error=None):
    ...
  • Step 5: 커밋
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.txtplaywright==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 . 들과 같은 위치):

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 추가:

app.include_router(internal_router)
  • Step 4: 모듈 레벨 redis client 생성

app = FastAPI(...) 직전 또는 직후에:

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/로컬 검증 위해선 한 번:

pip install redis>=5.0
  • Step 6: 커밋
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 변수들과 같은 위치):

      - REDIS_URL=${REDIS_URL:-redis://redis:6379}
      - INTERNAL_API_KEY=${INTERNAL_API_KEY:-}

또 같은 service 블록에 depends_on: 추가 (없으면 신규, 있으면 redis 추가):

    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):

openssl rand -hex 32

이 값은 Windows insta-render의 .env에도 동일 값으로 보관 필요 (Task 9에서).

  • Step 3: yaml 검증

Run:

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: 커밋
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: 디렉토리 생성

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:

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: 커밋
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:

"""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 측에 복사:

# 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
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: 커밋
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:

"""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
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: 커밋
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:

"""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
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: 커밋
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:

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에서:

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의 같은 값으로 채움

또는 한 줄로:

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:

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: 헬스 확인

curl http://localhost:18710/health

Expected: {"ok": true, "service": "insta-render"}

  • Step 6: 커밋 (docker-compose만, .env 제외)
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:

"""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:

[pytest]
asyncio_mode = auto
  • Step 3: 테스트 실행

WSL2 또는 박재오 PC에 pip install 필요. 빠른 검증을 위해 docker 내부 시도:

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: 커밋
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 호출 위치 확인

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, ...) 라인을 다음으로 교체:

변경 전:

        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")

변경 후:

        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) 함수 본체:

변경 전:

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))

변경 후:

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.pyinsta_update 함수에 추가 (succeeded 분기):

    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 체크
cd C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -c "from app import main, internal_router; print('OK')"
  • Step 6: 커밋
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에서 더 이상 렌더 안 함):

"""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 라인 제거
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 제거):

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 회귀 확인
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.pyfrom app.card_renderer import render_slate로 import 시도해서 fail이면, 그 test 파일도 deprecated:

mv tests/test_card_renderer.py tests/test_card_renderer.py.deprecated

또는 git rm:

git rm tests/test_card_renderer.py
  • Step 6: 커밋
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)
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git push origin main

(자격증명 prompt 1회. 1회 실패 시 재시도.)

  • Step 2: deployer rebuild 대기 (~3분, Chromium dep 제거로 빌드 더 빠름)
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 확인
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/ 블록 찾기. 그 위 또는 아래에 다음 새 블록 추가:

  # 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)
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가 자동 처리하지만 빠른 적용 위해 수동 가능)

수동 (즉시):

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 없이):

curl -X POST https://gahusb.synology.me/api/internal/insta/update -d '{}'

Expected: 401 또는 422 (nginx 통과, FastAPI에서 인증·validation).

  • Step 5: 커밋
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:

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 초기화 완료

만약 안 떠 있으면:

docker compose up -d insta-render
  • Step 2: NAS에 카드 슬레이트 생성 + 렌더 트리거

LAN에서 박재오 PC:

# 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: 폴링으로 진행률 확인
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:

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 로그 확인
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)
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git status -sb
git log --oneline 26ef660..HEAD

이번 Plan-B-Insta web-ai 측 commits 확인. push 안 했으면:

git push origin main
  • Step 2: 모든 변경 점검 + commit이력
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:

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 14, 1112
§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 변경.