静态博客资源存储新思路
信息
未雨绸缪的重要性:2024 年 10 月至今我的网站一直在被 DDoS 攻击,至今还未结束...
好在采用了本文的 Cloudflare R2 部署方案,我至今都无感知,月账单开销毫无变化。
如果是采用传统的后付费 CDN 方案,估计这次攻击大几万就掏出去了。
我的博客是一个纯静态的网站,所有的内容都是基于 Markdown 文件生成的,相关源码托管在 GitHub 上。 这种方式的好处是不需要服务器端的额外支持,只需要一个静态文件托管环境就可以了,我选择了 Cloudflare Pages. 当 main
分支有新的提交时,Cloudflare Pages 会自动构建并部署新的网站,无需手写 Action Workflow 配置。 源码管理和版本控制是方便了,但对于一些静态资源文件,比如图片、音频、视频等,如何进行存储和管理呢?
先直接抛结论:使用对象存储服务,结合各种预取和缓存策略来减少对 Bucket 的操作次数。 将所有 Markdown 文件中的静态资源地址预处理获取并缓存在本地,以减少对对象存储的请求次数。 同样地,在部署环境中也需要开启缓存功能(一般都有途径进行设置)。
想看具体实践的请跳到最后一节,接下来是我从 2016 年至今的一些心路历程。
注:实际上 Cloudflare Pages 不仅仅是静态网站托管服务,我们可以借助 Cloudflare D1 来实现动态网站的功能。比如阅读量统计,评论系统,甚至是一个简单的后端服务...
我很懒,完全本地化,一切交给 Git
最朴素的做法是,所有文件都无脑放在 GitHub 仓库里,这样可以确保所有的资源都在一个地方,方便管理。 但涉及到版本控制的时候(比如你修改了一张图片的内容),就会显得有些麻烦了。 Git 在处理二进制文件的版本控制时,会将每个版本的文件完整地存储下来,而不是只存储文件的差异, 这是因为二进制文件通常不能像文本文件那样进行差异比较。 如果你频繁地修改这种类型的文件,那么你的仓库很快就会变得庞大,而且拉取速度也会变慢。
我曾经采用的做法是使用 Git LFS(Large File Storage)来管理这些二进制文件, LFS 的原理是:在 Git 仓库中只存储指针文件,而真正的文件存储在 LFS 服务器上,按需获取。
version https://git-lfs.github.com/spec/v1
oid sha256:4cac19622fc3ada9c0fdeadb33f88f367b541f38b89102a3f1261ac81fd5bcb5
size 84977953
但是这种方式也有一些问题 —— 对于 GitHub 私有仓库,每月仅提供免费 1GiB 的 LFS 存储和带宽, 如果你的部署工作流不采取缓存策略,很快就会用完。
以下是我当时使用 GitHub Actions 局部 Wofkflow 配置 (参考出处):
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Create hash for LFS files
run: git lfs ls-files -l | cut -d' ' -f1 | sort > .lfs-assets-id
- name: Fetch LFS files from cache
uses: actions/cache@v3
with:
path: .git/lfs/objects
key: ${{ runner.os }}-lfs-${{ hashFiles('.lfs-assets-id') }}
restore-keys: |
${{ runner.os }}-lfs-
- name: Pull missed LFS files
run: git lfs pull
不难发现,由于多了 LFS 的管理,整体系统变复杂,相关的工作流也变得复杂了。 而且你经历过 GitHub 能用,但是 LFS 服务无法正常访问的情况吗? 在不开全局代理的情况下,你需要自己找出 LFS 的实际请求地址(而非网关地址),并手动添加到代理规则中。
我们这里暂且不考虑自建 LFS 服务的做法,它和后面提到的对象存储服务没有本质区别。
第三方图床?相信我,白嫖并不靠谱
另一种经典策略是采用第三方图床:将图片等资源上传到第三方图床,然后通过外链的方式引用。 最早的图床是泛用新浪微博,但是从 2019 年左右开始新浪就开启了防盗链,导致很多博客的图片失效。 所以第三方图床最大的问题就是不稳定,你永远不知道哪天它就挂了。
目前大部分人选择白嫖 GitHub,毕竟 GitHub 短期内看不到倒闭的可能性。 为了加速国内访问,有人进一步使用了 jsDelivr CDN 加速,这样可以确保资源的稳定性和访问速度:
[Migrating from GitHub to jsDelivr](https://www.jsdelivr.com/github)
https://github.com/<username>/<repo>/blob/<branch>/<path>
https://cdn.jsdelivr.net/gh/<username>/<repo>@<branch>/<path>
但是在 2021 年 12 月,jsDelivr 备案被注销,从而失去了在中国大陆的 CDN 节点。 等同于说,你的资源还是要从国外拉取,访问速度会变慢,而且有可能被墙。 规模稍大的互联网企业一般都会选择自建镜像站和 CDN 服务,但对于个人博客来说,这显然是不现实的。
即使有镜像站和 CDN 服务,也停止不了白嫖的欲望。这是来自几天前的最新案例:
npmmirror 镜像站(原 CNPM)核心开发者在社交平台表示, 有人利用 npm 包的机制,将刚开播的《庆余年 2》整套高清盗版资源搬运到了 npmmirror
原理很简单,把视频分段封装成多个 .bin
格式,这就伪装成了包资源文件, 在公开平台 npmjs
上线,等待镜像站同步过去后,利用镜像站在阿里云的 CDN 加速访问。 如今这个引起轩然大波的 lyq2
包已经被删除了,但是这个事件还是给我们敲响了警钟。
另外,从安全角度来看,使用 CDN 服务存在着被进行供应链攻击的风险, 没有人能保证外链的有效性和安全性(不仅适用于 JS 库的 CDN), 保不齐将来某天你博客中的外链就指向了黄赌毒等违法网站。 我亲身经历了新浪图床的挂掉,曾经的 WordPress 博客也使用 JsDelivr 加速... 类似的剧情屡见不鲜。 谁能保证 GitHub 不会开启防盗链呢?所以白嫖第三方图床并非一个长久之计。 我更倾向于所有的资源文件都是从本地服务器直接获取的稳定版。
对象存储,在某些条件下,或许是最佳实践
对象存储(Object Storage)是一种存储架构,它管理数据为对象(包括数据本身、元数据和唯一标识符),而不是传统的文件系统或块存储方式。 它的主要优点是可以大规模扩展,因此特别适合于存储大量的非结构化数据,如图片、视频和其他多媒体内容。
2006 年,亚马逊推出了 S3(Simple Storage Service),这是第一个商用的对象存储服务。 国内也有很多云服务商提供对象存储服务,比如阿里云的 OSS、腾讯云的 COS、华为云的 OBS 等等。 由于我的博客是托管在 Cloudflare Pages 上的,所以我选择了 Cloudflare 提供的对象存储服务 R2. 根据存储的数据总量以及 A/B 类操作进行收费, 关键是 出口流量免费。
选择 Cloudflare R2 的理由
简而言之,只需要你的 R2 Bucket 设置了支持公有读的域名,就可以通过这个域名直接访问到你的资源。 这样你就可以在 Markdown 文件中直接外链这个域名下的资源,而不用担心资源的稳定性和访问速度。 正常用户通过浏览器访问网站时,浏览器会根据响应头中的 Cache-Control
等字段来判断是否需要缓存资源到本地。
另外,Cloudflare Pages 本身也会将静态资产缓存到全球(环大陆) CDN 节点,以提高访问速度。 对于浏览器缓存,Pages 始终会发送 200 OK
响应的 Etag
标头, 然后浏览器会在后续请求该资源时以 If-None-Match
标头的形式返回这些标头。 Pages 会将请求中的 If-None-Match
标头与 Etag
计划发送的标头进行比较, 如果它们匹配,Pages 会以一个 304 Not Modified
作为响应,告诉浏览器使用本地缓存中存储的内容是安全的。
但我们需要考虑极端情况,在独立访问用户量过高或禁用缓存的情况下,每次访问都会直接请求 Bucket, 这就提供了恶意攻击的途径(类似 DDOS 和 CC 攻击),这时候就需要考虑一些防御措施了,否则费用爆炸。 当年很多人在新浪图床挂掉后选择转移阵地到七牛云 CDN, 但七牛云当时没有支持欠费自动冻结服务的机制, 如果不采用固定带宽计费,一些不怀好意的人可以在半夜刷爆你的图床流量。 可能你第二天起来发现收到短信通知:要卖房了(XD)。
虽说我选择的 R2 的公有 Bucket 与 Cloudflare 的其它功能是可以结合使用的,比如 WAF、Rate Limiting、Access Control 等等。 但是如果我没有使用 R2,而是使用了其它对象存储服务(连出口流量都要收费了!),为了安全性,可能就要换个思路了。
预处理静态资源,减少对 Bucket 的请求
如果我们引入一个预处理工具,在构建时将所有 Markdown 文件中的静态资源地址预处理获取并缓存在本地,以减少对对象存储的请求次数。 在渲染 HTML 的过程中,这些静态资源就等于是直接从本地获取的,它们将被一同打包并部署。 且由于 Markdown 源码是私有化托管在 GitHub 上的,用户看到的是渲染后的 HTML 页面,所以不用担心暴露你的 Bucket 域名。
!()[https://s3.bucket.example.com/path/to/example.ext]
!()[/.cache/local-random-hashed-name.ext]
把这些资源的存放路径添加到 .gitignore
文件中,就避免了影响 Git 的体积。
一个参考工具是 Anthony Fu 开发的 Vite 插件 remote-assets .
它的基本原理是:通过 Vite Plugin 的 transformIndexHtml
钩子(Hook)进行预处理, 在渲染之前,预先将待处理文件中符合匹配正则表达式的静态资源地址找出来, 通过 Axios
请求资源并缓存到本地,并用 MagicString
替换为本地路径用于后续步骤。 关键在于设计了 tasksMap
与固定的哈希算法计算 filepath
, 对于已经获取过的静态资源,不会重复请求,而是直接使用缓存到本地的资源。 这样我们在本地开发时,就不用担心每次预览渲染后的 HTML 文件时都会请求对象存储服务了。 部署环境也可以选择开启缓存功能,避免每次部署都重新请求文件, 可惜目前 Cloudflare Pages 并不支持自定义缓存路径。
<audio controls>
<source type="audio/mpeg" src="https://s3.bucket.example.com/sound.ext" >
假装这是一个音频文件,我把后缀故意从 .mp3 改成了 .ext, 以免被转换。
</source>
</audio>
警告
- 如果采用
<audio controls src="...">
这样的写法,可能出现问题; - 我在 v0.4.1 版本遇到了
dev
和build
时的行为差异,自己进行了一些修改。
我在《贪食蛇 》中引入了一个音效文件进行测试,你可以尝试玩一玩。 这个音效源文件实际上是存储在 R2 中的,但由于预处理工具的存在, 你向 Cloudflare Pages 请求的资源地址应该是打包后的静态文件存放地址, 默认存放在 assets
目录下。
如果你的 R2 Bucket 返回了 403 Forbidden, 请修改域名 WAF 规则
后来为了处理来自美国 IP 的大规模 DDoS 攻击,我修改了 CF 的 WAF 规则,这导致了一些问题。 如果你的请求返回了 403 Forbidden 错误,且确认跨域(CORS)设置是正确的,之前一切正常。 那么可以合理怀疑这是你的 WAF 规则在作祟,白名单思路的解决方案是: 在 WAF 的规则中对上述 Bucket 的域名添加一个规则,允许有关代理(如 axios)的请求。
总结:方案对比和适用情景
假定你具备基本运维常识,至少不会出现把 build
文件夹上传到 GitHub 的情况。
存储策略 | 适用情景 | 缺点 |
---|---|---|
GitHub All in One | 几乎没有什么媒体文件 | 上限太低了 |
Large File System | 有少量媒体文件的项目 | 需要一定的运维能力 |
第三方图床 | 媒体文件 404 也无所谓 | 非常不稳定 |
对象存储 + 自建图床 | 媒体文件较多,访问量不大 | 可能被恶意攻击 |
👆 + 预取缓存 | 媒体文件较多,访问量较大 | 需要一定的写代码能力 |
最后提醒一下,不论你采取什么方案,做好备份是最重要的,我已经吃过亏了。
番外:PicGo 使用 R2 Bucket
假定你已经开启了 Cloudflare R2 并创建了一个 Bucket, 为了方便图片的上传和管理, 可以使用 PicGo 这个开源工具。 你需要用到安装 PicGO 的 S3 插件 (R2 兼容 S3 API,所以这个插件也是可以用的),在 图床设置 中添加新的 Amazon S3 图床, 将某个支持对象读写(Object Read & Write)的 R2 API Token(没有则自己创建)中的信息填入:
- 应用密钥 ID 对应 Token 的 Access Key ID
- 应用密钥 对应 Token 的 Secret Access Key
- 自定义节点 对应 Token 的 Use jurisdiction-specific endpoint
- 自定义域名 对应 Bucket 的自定义域名
设置好后,你就可以通过 PicGo 上传图片到 R2 Bucket 了, 链接格式选择 Markdown,然后在 Markdown 文件中引用这些图片。 比如以下这个巨大的狗头,实际上是存储在 R2 中的,你看到的源地址应该是打包后的静态文件存放地址。
要说还有什么问题,那就是对象存储不支持动态的图片处理,比如压缩、裁剪等操作——狗头太大啦。 要么你在上传的时候就处理好,要么就需要在前端处理,这就需要 额外的工作 了。