博客CDN缓存自动化:从发现到修复
博客 CDN 缓存自动化:从发现到修复
起因:Chat 页面缓存问题
事情是这样的,我给博客加了个 AI 聊天功能,页面放在 /chat 路径下。按理说这是个 "use client" 的客户端渲染页面,CDN 不应该缓存才对。
结果发现——CDN 不仅缓存了,还缓存了一年。
排查过程
先看响应头:
x-nextjs-cache: HIT
x-nextjs-prerender: 1
Cache-Control: s-maxage=31536000
31536000 秒,差不多就是一年。关键是这个 header 是 Next.js 自己返回的,不是 CDN 加的。
原因很简单:虽然 /chat 是 "use client" 组件,但 Next.js 仍然对它做了预渲染(生成初始 HTML shell)。页面没有声明 export const dynamic = 'force-dynamic',所以被当成静态页处理了。prerender-manifest.json 里白纸黑字写着:
json
"/chat": {
"initialRevalidateSeconds": false,
"srcRoute": "/chat"
}
false 意味着永不 revalidate,Next.js 就给了个一年的 s-maxage。
修复:revalidate + CDN 自动刷新
最简单的修复是加一行:
ts
export const revalidate = 3600; // 1小时
这样 Next.js 还是预渲染(保留 SEO 优势),但 s-maxage 从一年变成了 3600 秒。
但这只是治标。部署新版本后,CDN 还是会继续缓存旧内容直到 TTL 过期。我需要的是:部署完成后自动刷新 CDN 缓存。
搭建 CDN 自动刷新
方案选型
一开始想在 GitHub Actions 里做,但发现服务器上的腾讯云 SecretId/SecretKey 只在 Docker 容器的环境变量里,GitHub Actions 拿不到。也不想为了这事单独在 GitHub Secrets 里再加一组凭据。
最终方案:服务器上执行。部署脚本里直接调腾讯云 CDN Purge API,凭据从 .env 文件读取。
一开始用 Python 手写了 TC3-HMAC-SHA256 签名,结果踩了一堆坑——服务器 Python 3.6.8 不支持新语法、shell 转义把签名搞坏、PurgeUrlsCache 的 FlushType 参数名和文档不一致。后来换成腾讯云官方 Node.js SDK(tencentcloud-sdk-nodejs),几行代码搞定,本地测试一次通过。
变更感知:不是一把梭
最粗暴的做法是每次部署都全站刷新 CDN。但这样太浪费了——改了个页面组件就得刷全站,没必要。
所以我做了一个变更范围检测:
| 变更路径 | CDN 刷新策略 |
|---|---|
src/components/** |
全站 / |
src/app/layout.tsx / globals.css |
全站 / |
src/app/chat/page.tsx |
只刷 /chat/ |
public/** |
精确 URL |
docs/** / *.md |
跳过 |
GitHub Actions 在 skip-check 阶段已经拿到了 CHANGED_FILES(git diff --name-only HEAD~1 HEAD),把这个列表通过 SSH 环境变量传到服务器,写入临时文件,再交给 purge-cdn.mjs 分析。
实现细节
整个流程是:
push → skip-check (获取 CHANGED_FILES)
→ build-and-deploy
→ SSH 部署 (git pull → 写入变更文件 → deploy.sh)
→ purge-cdn.mjs --changed-file /tmp/.deploy_changed_files
→ analyzeChanges() 按规则匹配
→ 精准刷新对应路由
→ rm /tmp/.deploy_changed_files
purge-cdn.mjs 用腾讯云官方 Node.js SDK 调 CDN Purge API,不用手写签名,代码干净可靠。项目本身是 Next.js,服务器有 Node.js,不需要额外装运行时。
一个意外发现
测试的时候发现服务器上居然没有 .env 文件?凭据全在 Docker 容器的环境变量里。最后发现 .env 在 /www/wwwroot/react.nnnnzs.cn/.env,是 docker-compose.prod.yml 里 env_file 引用的那个路径。之前 find 命令没找到是因为搜索深度不够。
踩坑记录:CDN 刷新脚本在容器里跑
第一版 purge-cdn.mjs 是在服务器宿主机上直接 node 执行的。结果部署时报错:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'tencentcloud-sdk-nodejs'
服务器上没装 npm 依赖,也不想装——它是纯 Docker 部署,宿主机保持干净。所以必须把 CDN 刷新脚本挪到容器里执行。
第一次尝试:挂载 scripts 目录
最直觉的做法:在 docker-compose.prod.yml 里挂载 scripts 目录到容器。
yaml
volumes:
- ./scripts:/app/scripts:ro
然后 docker exec 在容器内执行。结果还是找不到包——purge-cdn.mjs 用的是 ES modules(import),NODE_PATH 环境变量对 ES modules 不生效。它只认脚本所在目录的 node_modules,而挂载进来的 scripts 目录下没有。
第二次尝试:复制脚本到 /app
不挂载了,直接 docker cp 把 purge-cdn.mjs 复制到容器的 /app 目录:
bash
docker cp scripts/purge-cdn.mjs $CONTAINER_NAME:/app/purge-cdn.mjs
docker exec -w /app $CONTAINER_NAME node purge-cdn.mjs --changed-file /tmp/.deploy_changed_files
这次脚本确实在 /app 下了,但还是报错——Next.js standalone 模式打包的容器里,node_modules 是精简过的,只有运行 Next.js 所需的最小依赖,tencentcloud-sdk-nodejs 根本不在里面。
第三次尝试:运行时 npm install
在容器里临时装:
bash
docker exec -u root -w /app $CONTAINER_NAME npm install tencentcloud-sdk-nodejs --no-save
结果超时了。容器里 npm install 要解析整个依赖树,standalone 模式的 node_modules 不完整,解析直接卡死。10 分钟都没装完,SSH 命令超时。
最终方案:构建时预装
不在运行时装了,直接在 Dockerfile.prod 的 runner 阶段预装:
dockerfile
# 在临时目录安装,避免污染 standalone 的 node_modules 解析
RUN cd /tmp && npm init -y > /dev/null 2>&1 && \
npm install tencentcloud-sdk-nodejs && \
cp -rn node_modules/* /app/node_modules/ && \
cd /app && rm -rf /tmp/node_modules /tmp/package.json /tmp/package-lock.json && \
chown -R nextjs:nodejs node_modules
关键点:在 /tmp 独立目录安装,装完再 cp -rn 合并到 /app/node_modules。如果直接在 /app 下 npm install,会因为 standalone 的依赖树不完整而报 ETARGET 错误(找不到 @next/font@16.0.10)。
Docker 拉取重试
在排查 CDN 刷新的过程中,还发现一个问题:从 DockerHub 拉镜像偶尔会超时。GitHub Actions 构建时拉基础镜像超时,服务器部署时拉最新镜像也超时。
给两边都加了重试机制:
GitHub Actions(docker-release.yml):
yaml
- name: 拉取基础镜像(带重试)
run: |
MAX_RETRIES=3
RETRY_DELAY=15
ATTEMPT=1
while [ $ATTEMPT -le $MAX_RETRIES ]; do
if docker pull node:22-alpine; then exit 0; fi
sleep $RETRY_DELAY
RETRY_DELAY=$((RETRY_DELAY * 2))
ATTEMPT=$((ATTEMPT + 1))
done
exit 1
服务器部署(deploy.sh):
bash
docker_pull_with_retry() {
local image="$1" max_retries="${2:-3}" retry_delay="${3:-10}" attempt=1
while [ $attempt -le $max_retries ]; do
if docker pull "$image"; then return 0; fi
sleep $retry_delay
retry_delay=$((retry_delay * 2))
attempt=$((attempt + 1))
done
return 1
}
指数退避,初始 10-15 秒,最多重试 3 次。
git pull 失败:stash 处理
服务器上的 docker-compose.prod.yml 不知道什么时候有了本地修改(可能是之前手动调试时改的),导致 git pull 被拒绝:
error: Your local changes to the following files would be overwritten by merge
在 workflow 的 SSH 脚本里加了 git stash / git stash pop:
bash
git stash || true
git pull origin main || echo "⚠️ git pull 失败"
git stash pop || true
不管服务器上有什么本地改动,先暂存、pull、再恢复。
总结
第一版改动涉及 5 个文件:
src/app/chat/page.tsx—export const revalidate = 3600scripts/purge-cdn.mjs— 新增,腾讯云 CDN 缓存刷新 + 变更检测(Node.js SDK 版)scripts/deploy.sh— 新增purge_cdn函数.github/workflows/docker-release.yml—CHANGED_FILES传递链路.gitignore— 排除部署临时文件
后来又修了一轮容器内执行的问题,额外改了 4 个文件:
Dockerfile.prod— runner 阶段预装tencentcloud-sdk-nodejs(/tmp 独立安装再合并)scripts/deploy.sh— CDN 刷新改为容器内执行(docker cp + docker exec)、docker pull 重试、清理文件用 root 权限.github/workflows/docker-release.yml— 基础镜像拉取重试、git pull 前 stashdocker-compose.prod.yml— 去掉 scripts 挂载
踩坑总结:
- ES modules 的
NODE_PATH不生效,只有 CommonJS 的require才认 - Next.js standalone 模式的
node_modules是精简的,运行时装依赖会解析失败 - 在 standalone 的
node_modules上直接npm install会因为依赖树不完整报错,要在独立目录装完再合并 - 容器以非 root 用户运行时,文件操作注意权限
这种「部署后自动清理 CDN」的模式,感觉每个用 CDN 的项目都应该有一套。毕竟用户不应该为你的部署流程买单——你更新了,他就应该看到最新的。