perf: 프로필 공용 정적 자산 경로를 제공한다

Caddy가 commit-digest 공용 asset과 sourcemap을 immutable URL로 제공하고, runtime에 공용 공개 경로를 명시한다.
This commit is contained in:
2026-08-22 17:54:50 +00:00
parent dcff9eb094
commit 0c2b9632eb
5 changed files with 50 additions and 6 deletions
+14 -6
View File
@@ -152,12 +152,13 @@ Caddy의 runtime logger는 reverse proxy 오류에도 요청 정보를 남길
## 프런트엔드 캐시 정책
Gateway와 profile frontend의 Vite build는 `/gateway/assets/`,
`/<profile>/assets/` 아래 파일명에 8자리 content hash를 붙입니다. 내부 Caddy는
이 형식을 만족하는 JS, CSS, source map과 기타 build asset에
Gateway frontend의 Vite build는 `/gateway/assets/`, profile 공용 Vite build는
`/gateway/profile-assets/<commit-digest>/assets/` 아래 파일명에 8자리 content
hash를 붙입니다. 내부 Caddy는 이 형식을 만족하는 JS, CSS, source map과 기타 build asset에
`Cache-Control: public, max-age=31536000, immutable`을 적용합니다. 새 build는
내용이 달라지면 URL도 달라지므로 배포와 rollback 뒤에도 기존 URL의 장기 cache를
재사용할 수 있습니다.
재사용할 수 있습니다. 같은 commit을 쓰는 일곱 profile은 같은 공용 URL을 사용하므로
브라우저가 profile을 옮겨도 이미 받은 JS, CSS와 source map을 다시 전송하지 않습니다.
`index.html`, router fallback, `terms.*.html`처럼 URL이 고정된 HTML은 이 정책에
포함하지 않습니다. Caddy는 이 응답에 `Cache-Control: no-cache`를 적용하여
@@ -168,13 +169,20 @@ content-hashed URL로 옮겨야 합니다.
## 정적 frontend와 격리 builder
운영 frontend는 `vite preview`로 제공하지 않습니다. Gateway와 profile build는
`frontend-artifacts` named volume의 commit/digest 기반 불변 디렉터리에 복사되고,
운영 frontend는 `vite preview`로 제공하지 않습니다. Gateway build와 profile 공용
build는 `frontend-artifacts` named volume의 commit/digest 기반 불변 디렉터리에 복사되고,
runtime은 검증된 release의 `current` 심볼릭 링크만 원자적으로 교체합니다. Caddy는
이 volume을 read-only로 mount하여 직접 제공합니다. 첫 전환과 명시적 개발 모드를
위한 preview reverse-proxy fallback은 남아 있지만 운영 PM2에는 Vite process가
없습니다.
Profile Vite bundle은 commit과 공개 환경이 같으면 한 번만 생성되어
`game-assets/releases/<commit-digest>`에 게시됩니다. 각 profile의 `current`에는
profile base path, API/SSE URL과 Gateway URL을 담은 JSON `<script>`가 삽입된 작은
`index.html`, `deployment-version.json`과 manifest만 둡니다. 따라서 profile별 설정은
runtime에 결정하면서도 content hash asset과 sourcemap URL은 모두 공유합니다. 이전
profile 전용 정적 artifact도 전환·rollback 호환을 위해 계속 읽을 수 있습니다.
`builder` service는 Core source/worktree volume만 공유하고 release build를 한 번에
하나씩 실행합니다. DB, Redis, game token, OAuth 비밀값과 Docker socket은 받지
않으며, runtime은 공개 `VITE_*`와 heap/thread/cache 설정만 build 요청에
+8
View File
@@ -14,6 +14,8 @@
# Defer this header so it replaces vite preview's default no-cache value.
@immutableFrontendAssets path_regexp immutableFrontendAssets ^/(gateway|che|kwe|pwe|twe|nya|pya|hwe)/assets/.+-[A-Za-z0-9_-]{8}\.[^/]+$
header @immutableFrontendAssets >Cache-Control "public, max-age=31536000, immutable"
@immutableSharedProfileAssets path_regexp immutableSharedProfileAssets ^/gateway/profile-assets/[0-9a-f]{40,64}-[0-9a-f]{16}/assets/.+-[A-Za-z0-9_-]{8}\.[^/]+$
header @immutableSharedProfileAssets >Cache-Control "public, max-age=31536000, immutable"
@frontendRequests path /gateway/* /che/* /kwe/* /pwe/* /twe/* /nya/* /pya/* /hwe/*
header @frontendRequests Cache-Control "no-cache"
@@ -42,6 +44,12 @@
uri strip_prefix /gateway/api
reverse_proxy runtime:15001
}
@sharedProfileAsset path_regexp sharedProfileAsset ^/gateway/profile-assets/[0-9a-f]{40,64}-[0-9a-f]{16}/assets/.+$
handle @sharedProfileAsset {
uri strip_prefix /gateway/profile-assets
root * /srv/frontend-artifacts/game-assets/releases
file_server
}
handle /gateway/* {
@gatewayPublished file {
root /srv/frontend-artifacts/gateway/current
+1
View File
@@ -122,6 +122,7 @@ services:
GATEWAY_FRONTEND_PORT: '15000'
FRONTEND_SERVE_MODE: static
FRONTEND_ARTIFACT_ROOT: /srv/frontend-artifacts
FRONTEND_SHARED_ASSET_PUBLIC_PATH: /gateway/profile-assets
FRONTEND_READINESS_ORIGIN: http://caddy
RELEASE_BUILDER_URL: http://builder:15100
RELEASE_BUILD_NODE_OPTIONS: ${BUILDER_NODE_OPTIONS:---max-old-space-size=3072}
+26
View File
@@ -24,6 +24,26 @@ test('Caddy caches only content-hashed Vite frontend assets as immutable', async
caddyfile,
/header @immutableFrontendAssets >Cache-Control "public, max-age=31536000, immutable"/,
);
const sharedMatcher = caddyfile.match(
/@immutableSharedProfileAssets path_regexp immutableSharedProfileAssets (\S+)/,
);
assert.ok(sharedMatcher, 'immutable shared profile asset matcher must exist');
const sharedAssetPath = new RegExp(sharedMatcher[1]);
const releaseId = `${'a'.repeat(40)}-${'b'.repeat(16)}`;
assert.equal(
sharedAssetPath.test(`/gateway/profile-assets/${releaseId}/assets/index-CcXtLSVk.js`),
true,
);
assert.equal(
sharedAssetPath.test(`/gateway/profile-assets/${releaseId}/assets/index-CcXtLSVk.js.map`),
true,
);
assert.equal(sharedAssetPath.test(`/gateway/profile-assets/${releaseId}/index.html`), false);
assert.match(
caddyfile,
/header @immutableSharedProfileAssets >Cache-Control "public, max-age=31536000, immutable"/,
);
});
test('Caddy serves published frontend artifacts directly and keeps a transition fallback', async () => {
@@ -47,4 +67,10 @@ test('Caddy serves published frontend artifacts directly and keeps a transition
}
assert.match(caddyfile, /try_files \{path\} \/index\.html/);
assert.match(caddyfile, /header @frontendRequests Cache-Control "no-cache"/);
assert.match(
caddyfile,
/@sharedProfileAsset path_regexp sharedProfileAsset \^\/gateway\/profile-assets\//,
);
assert.match(caddyfile, /root \* \/srv\/frontend-artifacts\/game-assets\/releases/);
assert.match(caddyfile, /uri strip_prefix \/gateway\/profile-assets/);
});
+1
View File
@@ -21,6 +21,7 @@ const safeRuntime = {
VITE_IMAGE_PUBLIC_URL: 'https://sam-image.hided.net',
VITE_GATEWAY_USER_ICON_BASE_URL: 'https://sam-image.hided.net/icons',
FRONTEND_SERVE_MODE: 'static',
FRONTEND_SHARED_ASSET_PUBLIC_PATH: '/gateway/profile-assets',
RELEASE_BUILDER_URL: 'http://builder:15100',
GATEWAY_ACTIVE_RELEASE_GIT_REF: 'refs/sammo/active-gateway',
},