From 114a6b47d35ec924e863c09c1930d30a06912643 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 18 Sep 2026 11:10:38 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[fix]=20deploy:=20GSC=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B3=84=EC=A0=95=20=ED=82=A4=EB=A5=BC=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=EC=97=90=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=88=EC=9A=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GSC_ENABLED=1 로 킹서버에 켜져 있었는데도 search-console 스윕(10분마다)이 ValueError(GSC_CONFIG_MISSING) 로 매번 조용히 실패하고 있었다 — 로그엔 잡혀 있던 BATCH_FAILED 문구만 남고 원인은 안 보이는 종류였다. 원인: search_console_settings.load_settings() 는 GSC_CREDENTIALS_FILE(컨테이너 경로)을 읽는데, docker-compose.yml 이 GSC_CREDENTIALS_HOST_FILE(.env 의 호스트 경로)을 컨테이너로 마운트하는 줄도, GSC_CREDENTIALS_FILE 을 채워주는 줄도 없었다 — 두 변수 다 문서(.env.example · SEARCH_CONSOLE.md)에만 있고 컴포즈에 실제로 연결된 적이 없었다. 킹서버엔 서비스 계정 키 파일(/home/o2oadmin/.secrets/search-console.json)도 이미 있었다 — 배선만 빠져 있었다. 고침: solution-backend 서비스에 볼륨 마운트 + 고정 컨테이너 경로 env 추가. 호스트 경로가 비어 있으면(GSC 를 안 켠 환경) /dev/null 을 대신 마운트해 컴포즈 문법이 안 깨지게 했다. 검증: docker compose config --quiet 통과. 킹서버에서 GSC_CONFIG_MISSING 없이 배치가 도는지는 배포 후 로그로 재확인. Co-Authored-By: Claude Sonnet 5 --- docker-compose.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index c91ec96..d37cc6d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,13 @@ services: <<: *common-env # ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다. SCHEDULER_ENABLED: "1" + # ★ GSC_CREDENTIALS_HOST_FILE(호스트 경로, .env)을 아래 볼륨으로 이 컨테이너 안에 마운트한 + # 고정 자리다. search_console_settings.load_settings() 가 실제로 읽는 건 이 값이다 — + # 호스트 경로를 코드에 그대로 넘기면 컨테이너 안에서 그 경로가 없어 실패한다. + # 실측(2026-09-18): 이 줄이 없어서 GSC_ENABLED=1 인데도 10분마다 + # ValueError(GSC_CONFIG_MISSING) 로 조용히 실패하고 있었다 — 로그엔 BATCH_FAILED 만 남아 + # 원인이 안 보였다. + GSC_CREDENTIALS_FILE: /app/secrets/gsc-credentials.json volumes: - ./solution/site/payloads:/app/solution/site/payloads # 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 워커가 렌더러를 @@ -59,6 +66,9 @@ services: # ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고, # 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다. - ./postgres-init:/app/postgres-init:ro + # ★ GSC_CREDENTIALS_HOST_FILE 이 비어 있으면 /dev/null 을 마운트한다 — 빈 문자열을 그대로 + # 쓰면 컴포즈 볼륨 문법이 깨진다. GSC_ENABLED=0 이면 이 파일은 아예 안 읽으므로 무해하다. + - ${GSC_CREDENTIALS_HOST_FILE:-/dev/null}:/app/secrets/gsc-credentials.json:ro ports: - "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800" extra_hosts: From 13cc8cc830425bbc7e4fa4ec1d8d50d806ef4b7e Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 18 Sep 2026 11:16:30 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[fix]=20site:=20sitemap.xml=20=EC=98=A4?= =?UTF-8?q?=EB=A6=AC=EC=A7=84=EC=9D=B4=20=EC=9E=84=EC=9D=98=EC=9D=98=20?= =?UTF-8?q?=EC=98=9B=20=EC=82=AC=EC=9D=B4=ED=8A=B8=EB=A1=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=EB=90=98=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 킹서버에서 방금 발행한 사이트도 sitemap.xml 에 죽은 옛 호스트(w4ai.o2o.kr)로 나가고 있었다 — Google Search Console 스윕에 URL_NOT_IN_SITEMAP 으로 잡혀서 발견했다. 원인: solution-worker 는 기동할 때마다(=배포할 때마다) `--seed-assets` 로 prerender 를 한 번 돌리는데, 이 경로는 payload 를 안 읽어서 오리진을 얻을 곳이 없다 — 그래서 findBakedOrigin() 이 out/s/ 를 훑어 **가장 먼저 발견한** 사이트의 baked canonical 을 그대로 쓴다. readdirSync 순서는 보장이 없고, SITE_PUBLIC_HOST 를 web4ai.o2osolution.ai 로 옮긴 뒤 한 번도 재발행 안 한 옛 사이트(목업 포함) 하나가 그 자리에 걸리면, 통합 sitemap.xml 전체가 죽은 옛 호스트로 통째로 구워진다 — 오늘 새로 발행한 사이트(sono, 자기 페이지의 canonical 은 정상)까지 사이트맵에서는 옛 호스트로 나갔다. 고침: 처음 찾은 것 대신 **가장 최근에 구워진(mtime 최신)** 사이트의 오리진을 쓴다 — 최근에 발행된 사이트일수록 지금 SITE_PUBLIC_HOST 를 반영했을 확률이 높다. 검증: tsc --noEmit 통과. 킹서버 solution-worker 재시작 후 sitemap.xml 재확인 예정. Co-Authored-By: Claude Sonnet 5 --- solution/site/scripts/prerender.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 0513cbc..299c843 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -1056,21 +1056,31 @@ function writePreviewShell(outRoot: string, assets: {script: string; css: string /** * 이미 구워져 있는 사이트에서 오리진을 알아낸다. 한 장도 없으면 빈 문자열. * - * ★ payload 를 읽지 않는 경로(`--refresh-assets` 기동)에서 루트 색인 파일을 다시 쓰려면 + * ★ payload 를 읽지 않는 경로(`--seed-assets` 기동)에서 루트 색인 파일을 다시 쓰려면 * 오리진이 필요한데, 그 값은 payload 에만 있다. 디스크에 있는 페이지가 자기 canonical 로 * 선언해 둔 것을 쓴다(readBakedOrigin 주석). + * ★ **가장 최근에 구워진 페이지**의 오리진을 쓴다 — 처음 만난 것을 쓰면(readdirSync 순서는 + * 보장이 없다) 호스트를 옮긴 뒤 한 번도 재발행 안 한 옛 사이트(목업 포함) 하나가 걸릴 때 + * 전체 sitemap.xml 이 죽은 옛 호스트로 통째로 구워진다. 실측(2026-09-18): SITE_PUBLIC_HOST + * 를 web4ai.o2osolution.ai 로 옮긴 지 오래인데, solution-worker 가 재시작(배포)될 때마다 + * 이 함수가 옛 호스트(w4ai.o2o.kr)로 구운 사이트 하나를 집어 사이트맵 전체가 죽은 주소로 + * 덮였다 — 오늘 새로 발행한 사이트까지 사이트맵에서 옛 호스트로 나갔다. 갓 구운 페이지일수록 + * 지금 SITE_PUBLIC_HOST 를 반영했을 확률이 높다. */ function findBakedOrigin(outRoot: string): string { const sitesDir = join(outRoot, SITE_DIR); if (!existsSync(sitesDir)) return ''; + let newest: {origin: string; mtimeMs: number} | null = null; for (const entry of readdirSync(sitesDir, {withFileTypes: true})) { if (!entry.isDirectory()) continue; const file = join(sitesDir, entry.name, 'index.html'); if (!existsSync(file)) continue; const origin = readBakedOrigin(readFileSync(file, 'utf-8')); - if (origin) return origin; + if (!origin) continue; + const mtimeMs = statSync(file).mtimeMs; + if (!newest || mtimeMs > newest.mtimeMs) newest = {origin, mtimeMs}; } - return ''; + return newest?.origin ?? ''; } function writeRootMachineFiles(outRoot: string, origin: string) {