fix(nginx-152): корень редиректит на активную сборку — закрыта кэш-мина (ассеты Unity зовутся относительным путём)

- index.html: buildUrl="Build" -> без редиректа ассеты шли как /Build/WebGL.wasm (без номера сборки)
  под immutable 7д -> браузер неделю отдавал старую сборку
- publish-release.sh --activate пишет /etc/nginx/game-active.inc: return 302 /<N>/
- immutable сужен до путей с номером сборки; остальное no-store
- приёмка: / -> 302 /29/, браузер тянет /29/Build/* (все 200), autoclose-check 9/9, testfront цел
- сборка 29 (fix webgl: дым падения и PC URP) опубликована на nginx
This commit is contained in:
Porfiry
2026-09-15 18:34:12 +03:00
parent 85f092ba14
commit 5087c67201
5 changed files with 137 additions and 40 deletions
+35
View File
@@ -258,3 +258,38 @@
пересборку образа на каждый билд игры и не решает вопрос кэша; (б) поднять в LXC не nginx, а пересборку образа на каждый билд игры и не решает вопрос кэша; (б) поднять в LXC не nginx, а
podman-контейнер с nginx — лишний слой на 10 строках конфига; (в) отдавать статику прямо с k3s-Traefik podman-контейнер с nginx — лишний слой на 10 строках конфига; (в) отдавать статику прямо с k3s-Traefik
по PVC — не то, о чём просил владелец (он хотел nginx на проксмоксе). по PVC — не то, о чём просил владелец (он хотел nginx на проксмоксе).
## ADR-011-доп — корень редиректит на активную сборку (закрыта кэш-мина ADR-010) (2026-09-15)
**Что нашли на сборке 29 (проверка живьём, не по документам).** `index.html` Unity ссылается на
ассеты **относительным** путём (`var buildUrl = "Build"`). Пока корень отдавал файлы напрямую из
`/opt/game/current`, игрок получал ассеты по URL **без номера сборки** — `/Build/WebGL.wasm`,
`/Build/WebGL.data` и т.д. Эти URL между релизами **не меняются**, а в конфиге они попадали под
маску `immutable` (7 суток). Итог: у игравшего игрока браузер неделю держал бы ассеты старой сборки
и молча игнорировал новый билд. То есть моя собственная маска кэша воспроизводила ровно ту мину,
которую ADR-011 объявлял закрытой. Версионирование каталогами **само по себе** её не лечило —
лечило только то, чтобы URL ассетов реально содержали номер сборки.
**Решение.** Корень больше не раздаёт файлы — он **302-редиректит на активную сборку**:
- `publish-release.sh --activate` пишет `/etc/nginx/game-active.inc`:
`location = / { return 302 /<N>/; }` + то же для `/index.html`;
- `include /etc/nginx/game-active.inc;` подключён в server-блок;
- после редиректа `location.pathname` = `/29/`, относительный `buildUrl = "Build"` резолвится
в `/29/Build/…` — версионированный URL, старый URL перестаёт использоваться вовсе;
- маска `immutable` сужена до путей **с номером сборки** (`~^/[0-9]+/…`); всё остальное,
включая неверсионированные ассеты, теперь `no-store` (страховка от отравления кэша).
**Проверено живьём (сборка 29):** `/` → **302 → `/29/`**; браузер приземляется на `/29/` и тянет
`/29/Build/WebGL.wasm`, `/29/Build/WebGL.data`, `/29/Build/WebGL.framework.js` — все 200, ни одной
ошибки в консоли; `/28/`,`/27/` живы; неверсионированный `/Build/WebGL.wasm` → 200 с `cache-control:
no-store`; `/api/state`,`/api/price`,`/solana-rpc/` → 200; сквозная ставка `autoclose-check` — **9/9**;
`testfront` не задет (200).
**Почему не «отдавать корень без редиректа».** Это работало бы (неверсионированные ассеты получили бы
`no-store`), но теряется весь смысл `immutable`-кэширования: ассеты в 52 МБ + 18 МБ перекачивались бы
при каждом заходе. Редирект даёт и корректность, и кэш.
**Остаточный риск.** Игрок, у которого в кэше уже лежат ассеты по **старому** неверсионированному URL
(`/Build/WebGL.wasm`, полученные до этого фикса), один раз может получить старый билд — URL теперь
`no-store`, так что после первой перезагрузки браузер сходит на сервер и получит свежее. Новые
версии этой проблемы не имеют вовсе.
+10 -2
View File
@@ -49,8 +49,16 @@ cd ~/WORK/games/pump-game-ops && BASE=https://smart-updown.binom.pw/api \
относительным путём (`Build/WebGL.loader.js`), поэтому без слэша `Build/...` уедет в корень → 404. относительным путём (`Build/WebGL.loader.js`), поэтому без слэша `Build/...` уедет в корень → 404.
- **`skopeo ... dir:` пишет OCI-манифест**, где `layers` — словари. `unpack-release.sh` берёт - **`skopeo ... dir:` пишет OCI-манифест**, где `layers` — словари. `unpack-release.sh` берёт
`digest` каждого слоя и распаковывает по имени файла `<hex>` (не `sha256:...`). `digest` каждого слоя и распаковывает по имени файла `<hex>` (не `sha256:...`).
- **Кэш больше не прилипает между релизами**: URL содержит номер сборки → `immutable` корректен. - **Корень — 302 на активную сборку** (`location = / { return 302 /<N>/; }` в
Внутри версии каталог не перезаписывается, поэтому ассеты действительно неизменяемы. `/etc/nginx/game-active.inc`, пишет `publish-release.sh --activate`). Нужен потому, что
`index.html` Unity зовёт ассеты **относительным** путём (`buildUrl = "Build"`): без редиректа они
запрашивались бы как `/Build/WebGL.wasm` — без номера сборки, а такой URL между релизами НЕ
меняется → `immutable` заставлял браузер неделю отдавать старый билд. С редиректом путь становится
`/29/Build/…` и кэш корректен.
- **`immutable` только для путей с номером сборки** (`~^/[0-9]+/…`). Всё остальное, включая
неверсионированные ассеты, — `no-store` (страховка от отравления кэша).
- ⚠️ Если правишь `game.nginx.conf` — не забудь `include /etc/nginx/game-active.inc;`, иначе корень
снова начнёт раздавать файлы напрямую и мина вернётся.
- **Сборки копятся** (~68 МБ на версию) — ретеншен пока ручной, см. `TASKS.md`. - **Сборки копятся** (~68 МБ на версию) — ретеншен пока ручной, см. `TASKS.md`.
- **Откат схемы целиком**: вернуть `/opt/traefik/static/smart-updown.yaml` из - **Откат схемы целиком**: вернуть `/opt/traefik/static/smart-updown.yaml` из
`/root/bak-smart-updown-nginx-2026-09-15/` на VDS — домен снова пойдёт целиком в куб. `/root/bak-smart-updown-nginx-2026-09-15/` на VDS — домен снова пойдёт целиком в куб.
+39 -36
View File
@@ -5,9 +5,18 @@
# /opt/game/releases/<N>/ — файлы сборки N (Build/, TemplateData/, index.html) # /opt/game/releases/<N>/ — файлы сборки N (Build/, TemplateData/, index.html)
# /opt/game/current -> releases/<N> — активная сборка (симлинк, переключает publish-release.sh) # /opt/game/current -> releases/<N> — активная сборка (симлинк, переключает publish-release.sh)
# #
# https://smart-updown.binom.pw/ -> активная сборка (current) # https://smart-updown.binom.pw/ -> 302 на активную сборку (/29/)
# https://smart-updown.binom.pw/28/ -> конкретная сборка 28 # https://smart-updown.binom.pw/29/ -> конкретная сборка 29
# https://smart-updown.binom.pw/28/Build/WebGL.data -> ассет сборки 28 # https://smart-updown.binom.pw/29/Build/WebGL.data -> ассет сборки 29
#
# ⚠️ ПОЧЕМУ КОРЕНЬ РЕДИРЕКТИТ, А НЕ ОТДАЁТ ФАЙЛЫ:
# index.html сборки ссылается на ассеты ОТНОСИТЕЛЬНЫМ путём (`buildUrl = "Build"`).
# Если отдавать его прямо с корня, ассеты запрашиваются как /Build/WebGL.wasm — БЕЗ номера
# сборки. Такой URL между релизами НЕ меняется, а мы отдаём его `immutable` на 7 суток →
# браузер игрока неделю держит ассеты старой сборки и игнорирует новый билд (мина из ADR-010).
# При редиректе на /<N>/ все ассеты получают версионированный URL (/29/Build/...), старый URL
# просто перестаёт использоваться, и `immutable` становится корректным.
# Номер активной сборки пишет publish-release.sh в отдельный include-файл /etc/nginx/game-active.inc.
# #
# API и WebSocket nginx НЕ обслуживает: их забирает Traefik на VDS # API и WebSocket nginx НЕ обслуживает: их забирает Traefik на VDS
# (router smart-updown-api: PathPrefix /api,/ws -> куб 192.168.76.120). # (router smart-updown-api: PathPrefix /api,/ws -> куб 192.168.76.120).
@@ -15,11 +24,10 @@
# которые всё же дойдут до nginx (прямой заход по IP, старые сборки). # которые всё же дойдут до nginx (прямой заход по IP, старые сборки).
# Ассеты Unity внутри каталога версии неизменяемы -> immutable. # Ассеты Unity внутри каталога версии неизменяемы -> immutable.
# index.html и всё остальное -> no-store. # Всё, что НЕ под номером сборки, и index.html -> no-store.
map $uri $game_cache_control { map $uri $game_cache_control {
default "no-store"; default "no-store";
~^/[0-9]+/.+\.(js|wasm|data|mem|symbols|css|png|jpg|jpeg|gif|svg|woff2?)$ "public, max-age=604800, immutable"; ~^/[0-9]+/.+\.(js|wasm|data|mem|symbols|css|png|jpg|jpeg|gif|svg|woff2?)$ "public, max-age=604800, immutable";
~^/.+\.(js|wasm|data|mem|symbols|css|png|jpg|jpeg|gif|svg|woff2?)$ "public, max-age=604800, immutable";
} }
server { server {
@@ -39,60 +47,55 @@ server {
add_header Cache-Control $game_cache_control; add_header Cache-Control $game_cache_control;
# --- корень: 302 на активную сборку ---------------------------------------
# Файл переписывает publish-release.sh при --activate.
include /etc/nginx/game-active.inc;
# --- версионированные сборки: /<число>/... ------------------------------- # --- версионированные сборки: /<число>/... -------------------------------
# /28/index.html -> /opt/game/releases/28/index.html # /29/index.html -> /opt/game/releases/29/index.html
location ~ ^/(?<build>[0-9]+)(?<subpath>/.*)?$ { location ~ ^/(?<build>[0-9]+)(?<subpath>/.*)?$ {
root /opt/game/releases; root /opt/game/releases;
try_files /$build$subpath /$build$subpath/ =404; try_files /$build$subpath /$build$subpath/ =404;
} }
# --- ассеты активной сборки ----------------------------------------------
location ~* \.(js|wasm|data|mem|symbols|json|css|png|jpg|jpeg|gif|svg|woff2?)$ {
root /opt/game/current;
try_files $uri =404;
}
# --- страховка: API ------------------------------------------------------- # --- страховка: API -------------------------------------------------------
# Нормальный /api идёт мимо nginx (Traefik VDS -> куб). Если запрос всё же # Нормальный /api идёт мимо nginx (Traefik VDS -> куб). Если запрос всё же
# пришёл сюда — уходим в куб на k3s-Traefik с правильным Host, дальше # пришёл сюда — уходим в куб на k3s-Traefik с правильным Host, дальше
# ingress smart-updown-api ведёт в шлюз :8895. # ingress smart-updown-api ведёт в шлюз :8895.
location /api { location /api {
proxy_pass http://192.168.76.120; proxy_pass http://192.168.76.120;
proxy_http_version 1.1;
proxy_set_header Host smart-updown.binom.pw; proxy_set_header Host smart-updown.binom.pw;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade; proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 10s;
proxy_read_timeout 90s;
}
# страховка: WebSocket шлюза
location /ws {
proxy_pass http://192.168.76.120;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host smart-updown.binom.pw;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
proxy_buffering off;
} }
# страховка: тонкий CORS-прокси до Solana RPC (старые сборки игры) # --- страховка: WebSocket -------------------------------------------------
location /ws {
proxy_pass http://192.168.76.120;
proxy_set_header Host smart-updown.binom.pw;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_buffering off;
}
# --- страховка: Solana RPC -------------------------------------------------
# /solana-rpc/ НЕ ловится PathPrefix /api на VDS, поэтому уходит в корневой
# router, т.е. сюда. Старые сборки игры стучатся именно в этот путь.
location /solana-rpc/ { location /solana-rpc/ {
proxy_pass http://192.168.76.181:8899/; proxy_pass http://192.168.76.181:8899/;
proxy_set_header Host $host;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host 192.168.76.181:8899; proxy_read_timeout 300s;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Headers "content-type" always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
if ($request_method = OPTIONS) { return 204; }
}
# --- корень = активная сборка --------------------------------------------
location / {
try_files $uri $uri/ /index.html;
} }
} }
+22 -2
View File
@@ -7,10 +7,11 @@
# 1. тянет образ images.binom.pw/fun/game-front2:<тег> из реестра (skopeo copy в dir); # 1. тянет образ images.binom.pw/fun/game-front2:<тег> из реестра (skopeo copy в dir);
# 2. распаковывает статику в /opt/game/releases/<тег>/; # 2. распаковывает статику в /opt/game/releases/<тег>/;
# 3. без --activate оставляет активной текущую сборку (корень не меняется); # 3. без --activate оставляет активной текущую сборку (корень не меняется);
# с --activate переключает симлинк /opt/game/current на новую версию; # с --activate переключает симлинк /opt/game/current на новую версию
# И пишет /etc/nginx/game-active.inc (редирект корня на /<тег>/);
# 4. печатает проверку. # 4. печатает проверку.
# #
# Итог: https://smart-updown.binom.pw/ -> активная сборка # Итог: https://smart-updown.binom.pw/ -> 302 на активную сборку
# https://smart-updown.binom.pw/<тег>/ -> конкретная сборка # https://smart-updown.binom.pw/<тег>/ -> конкретная сборка
set -euo pipefail set -euo pipefail
@@ -19,6 +20,11 @@ ACTIVATE="${2:-}"
REG="images.binom.pw/fun/game-front2" REG="images.binom.pw/fun/game-front2"
CREDS="subochev:Drovosek319" CREDS="subochev:Drovosek319"
IMG="/tmp/gameimg-${TAG}" IMG="/tmp/gameimg-${TAG}"
ACTIVE_INC="/etc/nginx/game-active.inc"
case "$TAG" in
''|*[!0-9]*) echo "тег должен быть числом (номер сборки), получено: $TAG" >&2; exit 1 ;;
esac
echo "=== 1/4 тяну образ ${REG}:${TAG} ===" echo "=== 1/4 тяну образ ${REG}:${TAG} ==="
rm -rf "$IMG" rm -rf "$IMG"
@@ -34,14 +40,28 @@ if [ "$ACTIVATE" = "--activate" ]; then
PREV="$(readlink -f /opt/game/current || echo none)" PREV="$(readlink -f /opt/game/current || echo none)"
rm -f /opt/game/current rm -f /opt/game/current
ln -s "/opt/game/releases/${TAG}" /opt/game/current ln -s "/opt/game/releases/${TAG}" /opt/game/current
# корень редиректим на /<тег>/: тогда ассеты получают версионированный URL
# и immutable-кэш перестаёт «прилипать» между релизами (мина из ADR-010).
cat > "$ACTIVE_INC" <<EOF
# АВТОГЕНЕРАЦИЯ — пишет publish-release.sh при --activate. Не править руками.
location = / {
return 302 /${TAG}/;
}
location = /index.html {
return 302 /${TAG}/;
}
EOF
echo "было: $PREV" echo "было: $PREV"
echo "стало: $(readlink -f /opt/game/current)" echo "стало: $(readlink -f /opt/game/current)"
echo "корень -> /${TAG}/ (${ACTIVE_INC})"
else else
echo "=== 3/4 активную сборку НЕ трогаю (нет --activate) ===" echo "=== 3/4 активную сборку НЕ трогаю (нет --activate) ==="
echo "активна: $(readlink -f /opt/game/current)" echo "активна: $(readlink -f /opt/game/current)"
fi fi
echo "=== 4/4 проверка ===" echo "=== 4/4 проверка ==="
nginx -t
systemctl reload nginx systemctl reload nginx
for u in "/" "/${TAG}/" "/${TAG}/index.html" "/${TAG}/Build/WebGL.framework.js"; do for u in "/" "/${TAG}/" "/${TAG}/index.html" "/${TAG}/Build/WebGL.framework.js"; do
printf ' %-42s %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1${u}")" printf ' %-42s %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1${u}")"
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Ждём завершения последнего прогона CI fun-game-front2 и печатаем итог + УРЛ образа.
AUTH="-u subochev:Drovosek319"
BASE="https://git.binom.pw/api/v1"
REPO="subochev/fun-game-front2"
for i in $(seq 1 120); do
OUT=$(curl -s $AUTH "$BASE/repos/$REPO/actions/runs?limit=1")
ST=$(echo "$OUT" | python3 -c "import sys,json;r=json.load(sys.stdin)['workflow_runs'][0];print(r['id'],r['run_number'],r['status'],r.get('conclusion'))")
set -- $ST
RUN_ID=$1; NUM=$2; STATUS=$3; CONC=$4
echo "$(date +%H:%M:%S) run #$NUM статус=$STATUS вывод=$CONC"
if [ "$STATUS" = "completed" ]; then
echo "=== ИТОГ run #$NUM: $CONC ==="
if [ "$CONC" != "success" ]; then
echo "--- хвост лога провалившихся шагов ---"
curl -s $AUTH "$BASE/repos/$REPO/actions/runs/$RUN_ID/jobs" | python3 -c "
import sys,json
for j in json.load(sys.stdin).get('jobs',[]):
print('job:', j['name'], '|', j.get('conclusion'))
for s in j.get('steps',[]):
if s.get('conclusion') not in ('success','skipped',None):
print(' FAILED step:', s['name'], s.get('conclusion'))
"
fi
exit 0
fi
sleep 30
done
echo "=== ТАЙМАУТ ожидания (60 мин) ==="
exit 1