ason.top 部署在 Vercel 上。
某天打开面板,Deployment Storage 这条线已经从 0 爬到 11GB 出头,而配额是 10GB——超了。

翻记录找原因,四次各不相同,唯一的共同点是:每一次都是 自己 放进去的。按时间顺序记一遍。
第一次:330MB 的构建缓存被当成产物上传
最先怀疑的是文章配图、node_modules、public/,查了一圈都不是。
最终 culprit 藏在一行 du 里:
du -sh .next/* | sort -h
结果是 next/cache 330MB,排在 server(几十 MB)和 static 前面好几个量级。同一个账户下另一个 Next 项目 jia_blog 同样位置 206MB。
.next/cache 是 webpack / Next 给「下一次构建」留的增量缓存,本地第二次构建能省下大半时间。
但这个前提是 构建在同一台机器上重复发生。Vercel 每次部署都是全新容器,从零拉代码、从零构建,上一次的缓存基本不可能被复用。
它稳定兑现的只有一件事:让每一次部署都多打包、多上传、多存几百 MB。
所以直接在 webpack 钩子里关掉,只关生产构建:
// next.config.js
webpack: (config, { dev }) => {
// 关闭生产构建的 webpack 文件系统缓存:Vercel 每次构建都会把 .next/cache
// (约 330MB)打包上传并留存,命中收益远小于打包 + 上传 + 存储成本。
// 只作用于生产构建,不影响 `npm run dev`。
if (!dev) config.cache = false
return config
}
留着 dev 判断是必要的:本地反复改代码时,这个缓存命中率是真的高,关了每次都是 全量重编。
判断标准也很简单——构建环境不复用,缓存就没有意义。
CI 里跑一次就丢的机器同理。
改完之后删掉 .next 重新全量构建,效果直接看得见:.next/cache 从 330MB 变成 0.4MB,整个 .next 约 41MB(server 34MB、static 5.5MB)。
而这个站 211 个静态页面,本地冷构建只要 65 秒——为了保证这点事后可能都用不上的加速,每次部署多打包上传几百 MB,账算不平。
第二次:一张封面图 836KB
给文章做封面时顺手存了 PNG,856463 字节。
绝大部分体积用在了社交平台上根本看不出来的细节里。
封面和 og: image 的实际用途只有一件事:被聊天软件、X、Telegram 渲染成一张社交卡片。
这类卡片的渲染尺寸上限基本就是 1200×630,再大也是被缩放。PNG 也只有在需要透明通道时才必要。
处理方式:导出 1200×630 的 JPG,同一张图 836KB → 39KB,缩了 20 倍,卡片上肉眼无区别。现在 public/static/cover/ 下两张封面加一起不到 120KB。
一条简单规则:进 public/ 的每张图,先问它最终以多大尺寸呈现。
文章内容里的截图同理,一次性 check:
du -sh public/static/* | sort -h
find public -type f -size +300k
第三次:Git 历史里的大文件删不掉
.gitignore 里补上 .trash/、/drafts/ 之后,我又翻了一遍历史对象,命令长但值:
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '$1=="blob" && $3>500000 {print $3/1048576 " MB\t" $4}' \
| sort -rn | head -15
排在最前面的不是图片,而是一个 Typora 插件包 zip,3.3MB;
第二是一个中文手写体字体 woff2,1.7MB。
两个文件现在都不在站上了,但 每个 clone 依然要完整拉一遍 —— Git 只增不减,删除文件并不会从历史里抹掉那一份 blob。
容易被忽略的连带成本:public/ 下的东西会原样进入部署产物。
一个提供下载的 zip 放在 public/static/files/,意味着它既躺在 Git 历史里,又躺在每一次部署的输出里。
下载类资源放对象存储或网盘外链,博客仓库里只留真正的静态资源。
同理的细节还有:字体。
整包中文字体动辄几 MB,只为了标题那几个字不值当,按字形子集化,或者直接用系统字体栈。
第四次:浏览器那边也会攒满
第四次不是 Vercel 报警,是我自己发现:除了服务端,浏览器端也会攒出一坨存不下的东西。
站点接了 Service Worker 做离线阅读(见 《把网站装进主屏幕:PWA 的实践之路》),缓存配额同样有限,跨图床返回的 opaque 响应尤其占地方——浏览器按响应可能的最大体积计入配额,几条大图就吃掉不少;
而 versions 迭代还会在 Storage 里留下好几份旧缓存。
public/sw.js 里补了三条:
const PAGE_CACHE_MAX = 80
const IMAGE_CACHE_MAX = 80
async function putLimited(cache, request, response, max) {
await cache.put(request, response)
if (!max) return
const keys = await cache.keys()
if (keys.length > max) {
await Promise.all(keys.slice(0, keys.length - max).map((key) => cache.delete(key)))
}
}
// 下载文件路径直接跳过
if (url.pathname.startsWith('/static/files/')) return
两个 cache 各限 80 条,超出按插入顺序淘汰旧的;
putLimited 是「先入再裁」,保证一定能读到刚写入的响应。
下载路径直接不进缓存——前面那个 3.3MB 的 zip 一旦被 SW 缓存住,配额会被一口吃掉一大块。
还有一条容易忘:改缓存策略必须把 VERSION +1,否则老用户那几份旧缓存永远清不掉,Storage 里一直堆着。
一份可以直接用的清单
体积问题按「谁在存」分三层,从上往下查:
- 构建产物:
rm -rf .next && npm run build && du -sh .next/* | sort -h,重点看cache和server。 - 仓库与静态资源:
du -sh public/static/* | sort -h,再跑一遍上面那条git rev-list命令看历史包袱。 - 运行时:DevTools → Application → Storage,看 Cache Storage 里每个 cache 的条目数和体积。
对应的取舍其实只有三句话:
- 缓存只在会被复用的地方才有价值(本机 dev 有,Vercel 容器没有)。
- 资源按最终呈现尺寸给,不要按原始素材给。
- Git 里删文件不等于变小,进历史之前先想清楚。
三次服务端 + 一次浏览器端,四次下来最大的收获是:「部署太大」从来不是一个问题,是一堆不同层的问题共用一个症状。 先 du,再动手。
附:用 Vercel CLI 批量清掉历史部署
前面四步管的是「下一次部署别再这么大」,但 已经上传的那些历史部署还压在项目里,每改一次 CSS 就多一份,CLI 里没有「一键清空」,只能逐个删。
开头那张图里最后一次陡降,就是删掉历史部署的效果;
下面这版脚本把这个动作变成可重复执行的。
先确认三件事(这几条的 flag 在 vercel CLI 59 上都实测过):
vercel projects ls # 列出项目,找到名字
vercel project inspect ason-blog # 输出里 ID 一行就是 prj_xxx
vercel ls ason-blog --json --limit 100 # 当前项目的部署列表(注意:没有 uid)
vercel api "/v6/deployments?limit=100" --paginate # 全账号部署,含 uid / projectId / target
vercel ls 的输出里没有部署 ID,删不了;能拿 ID 的是 vercel api。脚本用后者拉列表,再用 vercel remove <id> 逐个删:
// scripts/prune-vercel-deployments.mjs
// 批量清理 Vercel 旧预览部署。dry-run 是默认值,加 --apply 才真删。
//
// node scripts/prune-vercel-deployments.mjs --project-id prj_xxx
// node scripts/prune-vercel-deployments.mjs --project-id prj_xxx --apply
//
// 需要登录态:已 `vercel login`,或设置 VERCEL_TOKEN。
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
// 保留策略
const KEEP_LATEST = 20 // 无论多旧,最近 N 条一律保留
const KEEP_DAYS = 14 // 最近 N 天内的部署不动
const PAGE_LIMIT = 100 // /v6/deployments 的 limit 上限
const SLEEP_MS = 200 // 删除之间的间隔,别打爆 API
const args = process.argv.slice(2)
const apply = args.includes('--apply')
const idIndex = args.indexOf('--project-id')
const projectId = idIndex >= 0 ? args[idIndex + 1] : process.env.VERCEL_PROJECT_ID
if (!projectId) {
console.error('缺少 --project-id prj_xxx(也可以设置 VERCEL_PROJECT_ID)')
process.exit(1)
}
// Windows 下 cmd 会把 & 当命令分隔符,含特殊字符的参数要包引号;同时只有 npx.cmd
const escapeArg = (a) =>
process.platform === 'win32' && /[&^|<>()" ]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a
async function vercel(cliArgs) {
const bin = process.platform === 'win32' ? 'npx.cmd' : 'npx'
const { stdout } = await execFileAsync(
bin,
['--yes', 'vercel@latest', ...cliArgs].map(escapeArg),
{
shell: process.platform === 'win32',
maxBuffer: 64 * 1024 * 1024,
}
)
return stdout.trim()
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// --paginate 会一路翻完 next 游标,返回一个扁平数组(每项含 uid / target / createdAt)
const all = JSON.parse(
await vercel(['api', `/v6/deployments?projectId=${projectId}&limit=${PAGE_LIMIT}`, '--paginate'])
)
console.log(`拉取到 ${all.length} 条部署`)
const cutoff = Date.now() - KEEP_DAYS * 86_400_000
const targets = all
.map((d) => ({ ...d, ts: d.createdAt ?? d.created ?? 0 }))
.sort((a, b) => b.ts - a.ts) // 结果按时间倒序排一次,保险
.filter((d) => d.target !== 'production') // 域名指向的是生产部署,永不自动删
.slice(KEEP_LATEST) // 最近的 N 条先保护下来
.filter((d) => d.ts < cutoff)
.filter((d) => ['READY', 'ERROR', 'CANCELED'].includes(d.readyState ?? d.state))
console.log(
apply
? `将删除 ${targets.length} 条预览部署`
: `dry-run:命中 ${targets.length} 条,加 --apply 才真删`
)
let done = 0
let failed = 0
for (const d of targets) {
const days = Math.floor((Date.now() - d.ts) / 86_400_000)
const line = `${d.uid} ${days}d ${d.target ?? '-'} ${d.url}`
if (!apply) {
console.log(` [dry] ${line}`)
continue
}
try {
// --safe 跳过仍挂着别名的部署,--yes 跳过二次确认
await vercel(['remove', d.uid, '--safe', '--yes'])
done++
console.log(` [删除] ${line}`)
} catch (e) {
failed++
console.warn(` [失败] ${line} -> ${String(e.message).split('\n')[0]}`)
}
await sleep(SLEEP_MS)
}
console.log(`完成:删除 ${done} 条,失败 ${failed} 条`)
跑起来的样子(我自己的项目这个月重建过,只剩两条生产部署):
拉取到 2 条部署
dry-run:命中 0 条,加 --apply 才真删
完成:删除 0 条,失败 0 条
四条保护逻辑,别嫌啰嗦,删掉部署是不可恢复的:
target !== 'production':生产部署被域名指着,删了站点立刻 500。--safe:还挂着 alias 的预览部署跳过,避免别人在用 preview 链接时把它抽掉。KEEP_LATEST+KEEP_DAYS:留出回滚窗口,vercel rollback需要它。默认 dry-run:
--apply才是删除开关,先看清命中列表。
另外还有两个坑
一是 别用 vercel remove <项目名>,CLI 文档里这写法是「删掉该项目的全部部署」,跟 rm -rf 一个性质;
二是删除有速率限制,几百条的项目跑完要一会儿,中途报 429 就调大 SLEEP_MS 重跑。