o2o-site-AEO/solution/backend/services/render_service.py
Mina Choi f2087aad5e feat(solution): 발행 워커·버전 관리와 예약·미리보기 정리
상시 프리렌더와 중복 예약 안내를 없애고, 검수된 발행 버전을 보존한다. 미리보기는 실제 렌더 완료까지 스피너를 표시한다.

사이트 81건, 발행·롤백·서치콘솔 45건, 프로세스 수명 3건 통과. 빌더·사이트 빌드 및 compose 설정 검증 통과.
2026-09-15 16:12:16 +09:00

154 lines
7.6 KiB
Python

"""렌더러 실행 — Python 워커가 미리 컴파일된 Node 렌더러를 subprocess 로 직접 돌린다.
★ 왜 (프리렌더를 워커 실행으로 통합, 2026-09-15)
예전에는 `solution-prerender` 라는 별도 상시 컨테이너가 payloads/ 디렉토리를 2초마다
폴링하며 바뀐 파일을 구웠고, BUILD 잡은 `.status/<slug>.json` 이 나타나기를 또 폴링했다
(render_report.wait_for). 컨테이너 하나·폴링 두 겹이 전부 "굽는 걸 어떻게 아느냐"
같은 문제를 풀고 있었다 — 워커가 굽기를 직접 돌리면 둘 다 필요 없다: subprocess 가
끝나는 순간이 곧 "구워졌다"는 신호다.
★ 이 파일은 payload → 렌더러 실행 → 구조화된 결과, 그 경계만 다룬다.
Python 은 여전히 HTML 을 만들지 않는다(ARCHITECTURE 1절) — 여기서 하는 일은 미리 빌드된
JS 파일 하나를 `node` 로 실행하고 그 결과 보고서(.status/<slug>.json, prerender.ts 가 쓴다)
를 읽어 돌려주는 것뿐이다. 렌더러 코드 자체는 solution/site 소유 그대로다.
★ 워커 이미지에는 컴파일된 렌더러(`dist/client` · `dist/prerender/prerender.js`)와 `public/`
만 있으면 된다 — node_modules 는 필요 없다(vite.config.ts `ssr.noExternal: true`, 실측:
node_modules 를 지우고 실행해도 그대로 돈다). 기동·잡 실행 중에 npm install 을 하지 않는다.
"""
import asyncio
import fcntl
import os
from pathlib import Path
from common.logger import LOG
from services import render_report
# 컴파일된 렌더러 진입점. Dockerfile 의 site-builder 스테이지가 여기에 굽는다.
RENDERER_ENTRY_ENV = "RENDERER_ENTRY"
DEFAULT_RENDERER_ENTRY = "/app/solution/site/dist/prerender/prerender.js"
NODE_BIN_ENV = "NODE_BIN"
DEFAULT_NODE_BIN = "node"
# 렌더러가 굽는 산출물 루트(out/). services/azure_static·indexnow 와 **같은 env** 를 본다 —
# 렌더러가 쓴 자리와 백엔드가 훑는 자리가 어긋나면 "구웠는데 없다"는 조용한 실패가 난다.
OUTPUT_DIR_ENV = "SITE_OUTPUT_DIR"
DEFAULT_OUTPUT_DIR = "/app/solution/site/out"
class RenderFailed(RuntimeError):
"""렌더러 프로세스 자체가 죽었거나(비정상 종료) 보고서를 남기지 못했다.
★ 게이트 반려(구조화 데이터 불일치·고유 콘텐츠 0건)는 여기 안 걸린다 — 그건 exit code
1 이어도 보고서가 정상적으로 남으므로 호출측이 report.ok 로 판단한다. 이 예외는
"무슨 일이 있었는지조차 모른다" 는 경우만 위한 것이다."""
def renderer_entry() -> str:
return os.environ.get(RENDERER_ENTRY_ENV) or DEFAULT_RENDERER_ENTRY
def output_dir() -> str:
return os.environ.get(OUTPUT_DIR_ENV) or DEFAULT_OUTPUT_DIR
async def _run_subprocess(args: list[str], timeout_sec: float) -> tuple[int | None, str]:
"""같은 볼륨의 자산 대장·사이트맵은 한 프로세스만 갱신한다."""
root = Path(output_dir())
root.mkdir(parents=True, exist_ok=True)
with (root / ".render.lock").open("a") as lock:
try:
async with asyncio.timeout(timeout_sec):
while True:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
await asyncio.sleep(0.1)
return await _run_unlocked(args, timeout_sec)
except TimeoutError:
return None, "렌더 대기 또는 실행 시간 초과"
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
async def _run_unlocked(args: list[str], timeout_sec: float) -> tuple[int | None, str]:
"""렌더러 프로세스를 돌리고 (exit_code, stderr 일부) 를 돌려준다.
★ 타임아웃이면 프로세스를 죽이고 회수한다 — 좀비로 남겨 워커 컨테이너의 프로세스 표를
채우면 안 된다. exit_code 는 None 으로 남아 "시간 안에 안 끝났다" 를 구분한다."""
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
_stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
except (asyncio.TimeoutError, TimeoutError):
proc.kill()
# kill 후에도 반드시 wait 한다 — 안 하면 좀비 프로세스로 남는다.
await proc.wait()
return None, f"{timeout_sec:.0f}초 안에 끝나지 않아 강제 종료했다"
except asyncio.CancelledError:
if proc.returncode is None:
proc.kill()
await proc.wait()
raise
# 사유는 앞부분에 나온다(watch-payloads.mjs 의 run() 과 같은 규칙) — 스택 전체를 들 이유가 없다.
return proc.returncode, stderr.decode("utf-8", errors="replace")[:4000]
async def render_site(payload_path: str, site_version: int, timeout_sec: float) -> dict:
"""payload 파일 하나를 굽는다. 끝나면 렌더 보고서(dict)를 돌려준다.
★ 게이트 판정은 여기서 하지 않는다 — 반환된 보고서의 `ok`·`mismatches`·
`uniqueContentCount` 를 build_service.run_build 가 그대로 판단한다(예전과 같은 계약,
render_report.wait_for 가 폴링해서 얻던 것과 같은 모양이다).
★ 멱등이다 — 같은 payload 파일로 다시 부르면 같은 결과가 나온다(재시도 안전). 렌더러
자신이 사이트별로 실패를 격리하므로, 이미 성공한 사이트를 다시 구워도 결과는 같다."""
slug = Path(payload_path).stem
# 재시도에서 같은 버전의 옛 성공 보고서를 이번 실행 결과로 읽지 않는다.
render_report.report_path(slug).unlink(missing_ok=True)
args = [
os.environ.get(NODE_BIN_ENV) or DEFAULT_NODE_BIN,
renderer_entry(),
f"--payload={payload_path}",
f"--out={output_dir()}",
"--stage-only",
]
try:
code, stderr = await _run_subprocess(args, timeout_sec)
except OSError as ex:
raise RenderFailed(f"렌더러 실행 실패: {type(ex).__name__}") from ex
report = render_report.read_report(slug)
if code in (0, 1) and report is not None and report.get("siteVersion") == site_version:
return report
# ★ 프로세스가 죽거나 시간 초과였는데 보고서도 없으면(사이트를 하나도 돌기 전에 죽은 경우)
# 대신 실패로 남긴다 — 안 그러면 build_service 가 "렌더 결과를 못 받았다" 는 것만 알고
# 진짜 사유(모듈 누락·OOM 등)는 워커 로그에만 남는다.
reason = (
f"렌더러가 {timeout_sec:.0f}초 안에 끝나지 않았다"
if code is None
else f"렌더러가 비정상 종료했다(exit {code})" if code != 0 else "보고서를 남기지 못했다(버전 불일치 포함)"
)
detail = f"{reason}" + (f"{stderr.strip()}" if stderr.strip() else "")
LOG.w(f"[render] {slug} v{site_version}: {detail}")
raise RenderFailed(detail)
async def activate_site(slug: str, site_version: int) -> None:
"""두 게이트를 통과한 뒤 Node가 공개 링크만 전환한다."""
args = [
os.environ.get(NODE_BIN_ENV) or DEFAULT_NODE_BIN,
renderer_entry(), f"--out={output_dir()}", f"--activate={slug}:{site_version}",
]
try:
code, _stderr = await _run_subprocess(args, 30)
except OSError as ex:
raise RenderFailed(f"공개 전환 실행 실패: {type(ex).__name__}") from ex
if code != 0:
raise RenderFailed("공개 버전 전환 실패")