상시 프리렌더와 중복 예약 안내를 없애고, 검수된 발행 버전을 보존한다. 미리보기는 실제 렌더 완료까지 스피너를 표시한다. 사이트 81건, 발행·롤백·서치콘솔 45건, 프로세스 수명 3건 통과. 빌더·사이트 빌드 및 compose 설정 검증 통과.
154 lines
7.6 KiB
Python
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("공개 버전 전환 실패")
|