计算机科学中困难的事情有两件:缓存管理、取名,以及差一错误。
刚刚上期我们讨论了如何在 11ty 中将对资产文件的修改传播到内容页。但是结尾发现 CDN 会缓存页面文件本身,使得我们必须要手动刷新页面缓存才能实际应用这个更改。
之前的架构中,由于涉及到 Docker 镜像的推送和拉取,对 CDN 的操作需要异步地进行。由于 Watchtower 已经终止维护,再加上感觉并没有必要专门为了一个静态博客上这么复杂的架构,所以我决定撤掉 Docker, 直接使用 Nginx 返回静态页面。
通过 rsync 部署站点
如果没有需要重写以前 WordPress 站点 URL 的历史包袱的话,完全可以将整个站点通过 rclone 放到对象存储中;或者直接用各种 Pages 方案托管整个站点,这样连 Nginx 都不需要设置。
但是拖着需要用一堆 rewrite 保持不知道在哪的老 URL 还能用的我最终选择了用 rsync 将编译产物推到服务器上。至少比 scp 来的强一点。
首先,先在服务器上安装 rsync. 没错,是在服务器上安装 rsync. 在通过 rsync 同步文件时,需要起一个服务端的 rsync 来比对和解压文件。
新建一个系统用户作为 rsync 的登录用户:
sudo useradd -r rsync
为 rsync 设置登录密钥:
sudo mkdir -p /home/rsync/.ssh
sudo chown -R rsync:rsync /home/rsync
sudo chmod 700 /home/rsync/.ssh
生成一份 SSH 密钥供 rsync 用户使用:
ssh-keygen -t ed25519 -C 'rsync@myserver.arpa'
为了配置方便,私钥密码 (passphrase) 部分可以留空(直接敲回车):
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/user/.ssh/id_ed25519): /home/user/rsync_key
Enter passphrase for "/home/user/rsync_key" (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/user/rsync_key
Your public key has been saved in /home/user/rsync_key.pub
The key fingerprint is:
SHA256:jeDIoknecb4KHf8Zu9PavXoBf1j5jzuwC935zGi69t4 rsync@dsstudio.tech
The key's randomart image is:
+--[ED25519 256]--+
| |
| |
| . . |
| . o . + o |
| ..oo.. S + o . |
|ooo.* =.o o |
|oo o o .. . +oo..|
| . o.=..oo +*.|
| ... *+oo+=B=oE|
+----[SHA256]-----+
我们将公钥的内容写入服务器端的 rsync 用户授权的密钥中:
cat rsync_key.pub | sudo tee /home/rsync/.ssh/authorized_keys
sudo chmod 600 /home/rsync/.ssh/authorized_keys
同时,我们需要限制一下 rsync 用户通过 SSH 的登录权限:
# 追加到 /etc/ssh/sshd_config 尾部
Match User rsync
PubKeyAuthentication yes # 打开公钥登录
X11Forwarding no # 禁止 X11 流量转发
AllowTcpForwarding no # 禁止 TCP 流量转发
PermitTTY no # 禁止交互式登录
编辑 CI 流程。由于我在用 Forgejo, 所以我需要明确地指定一些步骤来自于 GitHub 镜像;如果你在用 GitLab 或者 Gitea 则 uses 项目应该写 burnett01/rsync-deployments@v9.
name: deploy-site
on:
push:
branches:
- main
jobs:
builder:
runs-on: ubuntu-latest
steps:
# 前略,之前的步骤构建好了站点
- name: Deploy the Thing
uses: https://github.com/burnett01/rsync-deployments@v9
with:
switches: -avzr --delete
path: _site/
remote_path: $
remote_host: $
remote_user: $
remote_key: $
为 CI 设置以下秘密变量:
RSYNC_REMOTE_PATH: 要推送到的绝对路径,包含末尾斜杠,比如/srv/http/RSYNC_REMOTE_HOST: 你的服务器 IP 地址RSYNC_REMOTE_USER: 通过 SSH 登录的用户名,就是刚刚设置好的rsyncRSYNC_REMOTE_KEY: 登录 SSH 使用的私钥
你的私钥应该有如下的格式:
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
(中间内容省略)
Q/3FHReMNjmJStfqUxzpAAAAE3JzeW5jQGRzc3R1ZGlvLnRlY2gBAg==
-----END OPENSSH PRIVATE KEY-----
添加完成后,如果你不计划再配置其他项目,则最好删除私钥文件:
shred -u rsync_key
完成之后便可以推到 Git 上验证 CI 流程能否正常将站点推送到服务器上了。
刷新 EdgeOne 的缓存
我是用腾讯云的 EdgeOne 作为站点 CDN 的。如果你在使用其他云服务,则需要自行接入对应的 API SDK.
安装腾讯云的 NodeJS SDK:
npm i tencentcloud-sdk-nodejs
按照 ESM 的方式导入并调用 EdgeOne 的缓存清除任务:
// in scripts/teo-purge-pages.js
import tencentcloud from 'tencentcloud-sdk-nodejs'
const TeoClient = tencentcloud.teo.v20220901.Client
const clientConfig = {
credential: {
secretId: process.env.TENCENTCLOUD_SECRET_ID,
secretKey: process.env.TENCENTCLOUD_SECRET_KEY,
},
region: '',
profile: {
httpProfile: {
endpoint: 'teo.tencentcloudapi.com',
},
},
}
const client = new TeoClient(clientConfig)
let params = {
ZoneId: '*',
Type: 'purge_prefix',
Method: 'invalidate',
Targets: [ 'https://blag.dsstudio.tech' ]
}
client.CreatePurgeTask(params).then(
(data) => {
console.log(data)
},
(err) => {
console.error('error', err)
}
)
执行此脚本时,需要配置环境变量 TENCENTCLOUD_SECRET_ID 和 TENCENTCLOUD_SECRET_KEY. 这两个 API 密钥可以在账户配置里获取。推荐是新建一个 RAM 子账户、仅分配 EdgeOne 的操作权限并禁止管理后台登录。
要在 CI 中调用,则可以通过秘密变量传递到对应的环境变量:
jobs:
builder:
runs-on: ubuntu-latest
steps:
# 前略
- name: Purge the CDN Cache
run: npm run purge-pages
env:
TENCENTCLOUD_SECRET_ID: $
TENCENTCLOUD_SECRET_KEY: $
提交成功后,会输出类似如下信息:
{
RequestId: 'e4db7ad5-315c-470c-b3c4-02fe78d1b780',
FailedList: [],
JobId: '3tlugj3jud1f'
}
获取实际更新的文件表
由于每次编译都会导致产物的文件时间和服务器上存放的文件时间不同,这导致了每次构建站点都会全量替换所有的文件。这肯定不是我们需要的。
修改 rsync 的调用方式:要求检查文件摘要 (-c) 并忽略时间戳 (--no-times)
jobs:
builder:
steps:
# 前略
- name: Deploy the Thing
uses: https://github.com/burnett01/rsync-deployments@v9
with:
switches: -avczr --delete --no-times
# 后略
但目前我们仍然是会刷新全站缓存,未免有点浪费,能不能只刷新真正被修改的文件呢?
要求 rsync 输出修改摘要到文件以便之后处理 (--log-file):
jobs:
builder:
steps:
# 前略
- name: Deploy the Thing
uses: https://github.com/burnett01/rsync-deployments@v9
with:
switches: -avczr --delete --no-times --log-file=rsync.log
# 后略
rsync.log 会包含类似如下的输出:
2026/08/10 08:05:56 [22] building file list
2026/08/10 08:05:57 [22] .d..t...... ./
2026/08/10 08:05:57 [22] .f..t...... feed.xml
2026/08/10 08:05:57 [22] .f..t...... index.html
2026/08/10 08:05:57 [22] <fc.t...... sitemap.xml
2026/08/10 08:05:58 [22] *deleting pagefind/pagefind.zh_fa64fcd6ad.pf_meta
2026/08/10 08:05:58 [22] *deleting pagefind/index/zh_facb98c.pf_index
2026/08/10 08:05:58 [22] *deleting pagefind/index/zh_f5e7107.pf_index
2026/08/10 08:05:58 [22] *deleting pagefind/index/zh_e5caa93.pf_index
2026/08/10 08:05:58 [22] *deleting pagefind/index/zh_e51ff77.pf_index
最小化地刷新缓存
有了 rsync.log, 就可以从中解读哪些文件受到影响了。
对于每行日志信息,抛去时间戳前缀后,我们需要关心以下两种输出:
- 以
<f开头的,表示传输到服务器的文件 - 以
*delete开头的,表示从服务器上移除
那么,我们只需要按行读取日志、按照上述条件筛选出受影响的文件即可:
function compileAffectedFileList(content) {
const modifiedFiles = rsyncLogLines
.map((line) => {
const parts = line.split(' ')
const flags = parts[3]
const file = parts.slice(4).join(' ')
return { flags, file }
})
.filter((line) => !!line.flags && !!line.file)
.filter((line) => line.flags.startsWith('<f'))
.map((line) => line.file)
const deletedFiles = rsyncLogLines
.map((line) => {
const parts = line.split(' ')
const flags = parts[3]
const file = parts.slice(4).join(' ')
return { flags, file }
})
.filter((line) => !!line.flags && !!line.file)
.filter((line) => line.flags.startsWith('*deleting'))
.map((line) => line.file)
const affectedFiles = [...modifiedFiles, ...deletedFiles]
return affectedFiles
}
读取日志文件:
import { readFileSync } from 'fs'
import { exit } from 'process'
const rsyncLog = readFileSync('rsync.log', 'utf-8')
const affectedFiles = compileAffectedFileList(rsyncLog)
if (affectedFiles.length === 0) {
// 没有受到影响的文件,可以直接跳过刷新
exit(0)
}
然后组织并提交:
const TeoClient = tencentcloud.teo.v20220901.Client
const clientConfig = { /* 从略 */ }
const client = new TeoClient(clientConfig)
let params = {
ZoneId: '*',
Type: 'purge_prefix',
Method: 'invalidate',
Targets: affectedFiles.map(
(path) => `https://blag.dsstudio.tech/${path}`
)
}
client.CreatePurgeTask(params).then(
(data) => {
console.log(data)
},
(err) => {
console.error('error', err)
}
)
容易想到,或许 Targets 一次可以提交的数量是有限的;我们会需要分批提交这个数据:
const batchSize = 100
const batches = Array.from({ length: Math.ceil(affectedFiles.length / batchSize)}, (_, i) => affectedFiles.slice(i * n, (i + 1) * n))
const jobs = batches.map(batch => client.CreatePurgeTask({
ZoneId: '*',
Type: 'purge_prefix',
Method: 'invalidate',
Targets: batch.map(
(path) => `https://blag.dsstudio.tech/${path}`
)
}))
Promise.all(jobs).then(console.log).catch(console.error)
这样,每次就只会刷新收到影响的页面啦!













