Repository navigation
Legacy Asset Directory
ddddd edited this page Aug 6, 2026
·
1 revision
SPA 容器化部署的经典问题:容器更新后,旧版本客户端仍然缓存的 hash chunk(chunk.abc123.js)在新容器中已不存在,导致 dynamic import 404 报错。
This page describes the multi-source legacy directory design that allows SPACK to serve assets from previous deployments, eliminating chunk-404 errors after container redeployment.
sequenceDiagram
participant Client as 浏览器 / Browser
participant CDN
participant Container as 容器 / Container
Note over Client,Container: 部署 v1 / Deploy v1
Client->>Container: GET /index.html → 200
Client->>Container: GET /chunk.abc123.js → 200 (immutable cache)
Note over Client,Container: 部署 v2 / Deploy v2
Note over Container: v1 文件被替换 / v1 files replaced<br/>chunk.abc123.js 已删除 / deleted
Client->>Container: lazy-load chunk.abc123.js → 404 💥
Note over Client: ChunkLoadError / 白屏 / white screen
- 零感知恢复:旧客户端请求旧 chunk 时,正常返回内容,而不是报错或刷新
- 运维简单:部署流程只需在更新 dist 前把旧文件拷贝到 legacy 目录
- 自动清理:按版本数或 TTL 自动淘汰过期 legacy 资产
- 资源开销可控:legacy 目录大小有上限,不影响主数据平面性能
Design goals:
- Zero-impact recovery: Old clients requesting stale chunks receive the actual content, not an error or reload
- Simple operations: Deployment only needs to copy old files to a legacy directory before updating
- Automatic cleanup: Evict expired legacy assets by version count or TTL
- Bounded resource cost: Legacy directory size is capped, with no impact on primary data-plane performance
flowchart TB
subgraph Deploy["部署流程 / Deployment"]
Script["部署脚本 / Deploy script"]
Script -->|cp -r| LegacyFS["/app/legacy/<timestamp>"]
Script -->|更新 / update| PrimaryFS["/app/dist"]
end
subgraph Startup["SPACK 启动 / Startup"]
PrimaryFS -->|scan| PrimaryCatalog["Primary Catalog"]
LegacyFS -->|scan, skip existing in primary| LegacyCatalog["Legacy Catalog"]
end
subgraph Runtime["请求处理 / Request Handling"]
Request["HTTP Request"]
Request --> Resolve{"resolver"}
Resolve -->|found| PrimaryCatalog
PrimaryCatalog -->|"Cache: immutable + long max-age"| Response["200 + body"]
Resolve -->|miss| LegacyCatalog
LegacyCatalog -->|found| LegacyResponse["200 + body<br/>Cache: max-age=3600, no immutable"]
LegacyCatalog -->|miss| NotFound["stale-asset recovery<br/>or 404"]
end
subgraph Janitor["清理 / Janitor"]
Scheduler["scheduler (per 24h)"]
Scheduler -->|remove expired| LegacyFS
Scheduler -->|trim to max_versions| LegacyFS
Scheduler -->|update| LegacyCatalog
end
/app/
├── dist/ # 当前版本 (primary)
│ ├── index.html
│ ├── assets/
│ │ ├── chunk.xyz789.js # v2 的新 chunk
│ │ └── main.xyz789.js
│ └── ...
│
└── legacy/ # 持久化卷 / persistent volume
├── 20250701-120000/ # 部署批次 1 / deployment batch 1
│ ├── index.html
│ ├── assets/
│ │ ├── chunk.abc123.js # ← 旧客户端请求的旧 chunk
│ │ └── main.abc123.js
│ └── ...
│
└── 20250702-090000/ # 部署批次 2 / deployment batch 2
└── ...
# spack.yaml (新增 / new section)
assets:
root: /app/dist # 当前版本资产 / current version
legacy:
enable: true # 启用 legacy 目录 / enable legacy scan
dir: /app/legacy # legacy 根目录 / legacy root directory
max_age: "168h" # 单个批次的 TTL (7 天) / per-batch TTL
max_versions: 3 # 最多保留批次 / max deployment batches
scan_on_start: true # 启动时扫描 / scan at startup配置结构体设计(Go side):
type LegacyAssets struct {
Enable bool `koanf:"enable" validate:"required"`
Dir string `koanf:"dir" validate:"required,dirpath"`
MaxAge string `koanf:"max_age" validate:"required,spack_flexible_duration"`
MaxVersions int `koanf:"max_versions" validate:"gte=1,lte=32"`
ScanOnStart bool `koanf:"scan_on_start"`
}resolver.Resolve(request)
│
├─ 1. Primary Catalog lookup
│ found → serve (immutable cache policy for fingerprinted paths)
│ miss → fall through
│
├─ 2. Legacy Catalog lookup
│ found → serve (short cache TTL, no immutable)
│ miss → fall through
│
└─ 3. Stale-asset recovery (fingerprinted .js)
└─ 或 / or 404
关键规则:
- Primary 优先:同名路径优先从 primary 返回,legacy 只做 fallback
-
Hash 天然不冲突:
chunk.abc123.js和chunk.xyz789.js是不同的文件名,不存在覆盖 - Legacy 不生成变体:legacy 资产不进入 pipeline,不生成压缩/图片变体
-
Legacy 缓存策略:短 TTL (
max-age=3600),不带immutable标记 - 只读:legacy 目录在运行时只读,不写入新文件
Key rules:
- Primary wins: Same-path assets always resolve from primary; legacy is fallback only
- No hash collision: Fingerprinted file names are naturally disjoint — no overwrites
- No variant generation: Legacy assets skip the pipeline entirely; no compression/image variants generated
-
Legacy cache policy: Short TTL (
max-age=3600), noimmutabledirective - Read-only: Legacy directory is read-only at runtime; no new files written
# 持久化 legacy 目录 / persist legacy directory
volumes:
spack-legacy:
driver: local
services:
spack:
image: ghcr.io/lyonbrown4d/spack:latest
volumes:
- ./dist:/app/dist # 当前版本 / current version
- spack-legacy:/app/legacy # 持久化跨容器 / persistent across containers
environment:
SPACK_ASSETS_ROOT: /app/dist
# SPACK_ASSETS_LEGACY_DIR: /app/legacy#!/bin/sh
# Pre-deploy: backup current dist to legacy
LEGACY_ROOT="/app/legacy"
DIST="/app/dist"
TIMESTAMP=$(date -u +%Y%m%d-%H%M%S)
# 1. copy current dist into legacy
if [ -d "$DIST" ] && [ "$(ls -A "$DIST")" ]; then
mkdir -p "$LEGACY_ROOT"
cp -r "$DIST" "$LEGACY_ROOT/$TIMESTAMP"
echo "legacy: saved $TIMESTAMP"
fi
# 2. deploy new dist (e.g. unzip artifact, or container restart with new volume)
# ... deployment tooling here ...
# 3. start SPACK — it scans /app/dist (primary) + /app/legacy/* (legacy batches)flowchart LR
Timer["Timer (per 24h)"] --> Age{"max_age expired?"}
Age -->|yes| Remove["remove batch directory"]
Age -->|no| Count{"batches > max_versions?"}
Count -->|yes| Trim["remove oldest batches"]
Count -->|no| Skip["keep all"]
Remove --> Rebuild["trigger catalog update"]
Trim --> Rebuild
- 基于目录名:按目录名字典序(即时间戳)确定新旧,最旧的先删除
- 粒度是批次:整个 batch 目录一起删除,不逐文件清理
- 先删后重建:删除后触发 catalog update,legacy catalog 重建
Eviction details:
- Directory-name-based: Batch age is determined by directory name lexicographic order (i.e. timestamp); oldest removed first
- Batch granularity: Entire batch directory is removed at once, not file-by-file
- Rebuild after delete: Catalog update triggered after removal; legacy catalog rebuilt
| 模块 / Module | 改动 / Change | 复杂度 / Effort |
|---|---|---|
config.Assets |
新增 LegacyAssets 结构体 |
小 / Small |
source |
新增 MultiSourceFactory,封装 primary + legacy 扫描 |
中 / Medium |
sourcecatalog |
两遍扫描:primary → legacy(跳过已注册路径) | 中 / Medium |
catalog |
可选:asset 标记 Source 字段;或两个 catalog 实例 |
中-大 / Medium-Large |
resolver |
查找链:primary → legacy → stale-recovery → 404 | 小 / Small |
cachepolicy |
legacy 资产专用缓存策略(短 TTL,no immutable) | 小 / Small |
pipeline |
legacy 资产跳过 pipeline | 小 / Small |
task |
新增 legacy janitor 定时任务 | 小 / Small |
metrics |
新增 legacy hit/miss/size 指标 | 小 / Small |
┌──────────────────────────┐
│ Stale-Asset Recovery │
│ (reload script, 已实现) │
│ Ultimate fallback │
└──────────┬───────────────┘
│ 终极兜底
┌──────────▼───────────────┐
│ Legacy Asset Directory │
│ (本方案 / this design) │
│ 正常情况下的主食 │
│ Primary path │
└──────────────────────────┘
- Legacy Directory 是正常路径:旧 chunk 直接命中,无感知
- Stale-Asset Recovery(已实现) 是终极兜底:legacy 清理后或未配置 legacy 时,返回 reload 脚本避免僵死
两层互补,不互相替代。
Relationship:
- Legacy Directory is the primary path: stale chunks resolve transparently from legacy
- Stale-Asset Recovery (implemented) is the ultimate fallback: after legacy eviction or when legacy is not configured, the reload script prevents the client from being stuck in a broken state
The two layers complement each other; neither replaces the other.
| 决策 / Decision | 原因 / Reason |
|---|---|
| 目录时间戳作为版本标识 | 不依赖外部元数据,部署脚本只需 cp -r
|
| legacy 不进入 pipeline | 旧文件不需要额外处理变体,源文件本身已满足需求 |
| legacy 缓存策略更保守 | 旧版本内容生命周期短,不应被 CDN/浏览器长期缓存 |
| 批次粒度清理 | 时间戳目录天然按批次组织,逐个文件清理复杂且无必要 |
| 持久化卷而非容器内路径 | 容器重建后 legacy 内容需要存活,必须挂载外部卷 |
| 与 stale-asset recovery 共存 | legacy 是正常路径,recovery 是兜底,分层防御 |
Key tradeoffs:
| Decision | Reason |
|---|---|
| Directory timestamp as version identifier | No external metadata needed; deploy script only needs cp -r
|
| Legacy skips pipeline | Old files don't need variant processing; source files suffice |
| Conservative legacy cache policy | Legacy content is short-lived; should not be cached long-term by CDN/browser |
| Batch-granularity eviction | Timestamp directories naturally organize by batch; per-file eviction is unnecessary complexity |
| Persistent volume, not container path | Legacy content must survive container rebuilds; external volume required |
| Coexists with stale-asset recovery | Legacy is primary path, recovery is fallback; layered defense |