迁移方案-脱离Cloudflare代理依赖

迁移总方案:博客 + 播客脱离共享 CDN 代理 IP 依赖

背景与目标(2026-08-29 更新)

当前问题:博客(Cloudflare Pages)和播客音频(Cloudflare R2)都跑在 Cloudflare 的代理网络上,2026/27 赛季 LaLiga 反盗版封锁几乎天天有比赛日触发,导致这两块服务频繁被 O2 误伤。

如果你的目标是 “尽量利用现成互联网基础设施,少自己维护服务器,同时降低西班牙 LaLiga IP 封锁对博客和私人播客的影响”,我不建议把方案重心放到 Linode VPS + 自己维护 Caddy / UFW / DDNS 上。那属于“技术上可行,但运维成本偏高”的路线。

我重新按这个目标筛了一遍。核心问题是:不能简单地把 Cloudflare 换成 Vercel / Netlify / Bunny / AWS CDN,就认为安全了。 OONI 2026 年的实测显示,LaLiga 相关 IP 封锁已经波及至少 36 家基础设施/托管提供商、7441 个 IP,包括 Cloudflare、Amazon、Akamai、Meta、Microsoft 等;单个时间窗口封 4–20 个 IP,就可能影响几十万共享基础设施上的域名。OONI

重要更正:原方案曾建议博客迁往 Vercel,但经查证,Vercel、Netlify、GitHub Pages、BunnyCDN 等主流共享 IP CDN/托管平台,同样被 LaLiga 这轮封锁波及过——本质上都是"几十万个域名挤在同一批共享 IP 上",只是被点名的频率和时间点不同,不是安全选项。因此博客的迁移目标改为自建到已有的 Linode VPS,用独立静态 IP 避开"共享大池子"这个结构性风险。

目标架构:

博客        → 自建到 Linode VPS(独立静态 IP,不与任何共享 CDN 池混用)
播客音频     → Backblaze B2(独立网络出口,目前未见被波及)
              + archive.org(可选长期镜像)
RSS feed    → 跟博客一起自建到 VPS
DNS         → Nameserver 仍留在 Cloudflare(不受影响),但相关记录改成
              灰云 DNS-only 或直接指向新服务商,不再走 Cloudflare 代理

总体原则:稳字当头

  1. 分阶段,不并行操作多个系统——一次只动一件事,出问题能立刻定位是哪一步
  2. 新旧并行运行,验证通过再切流量——旧服务在确认新服务稳定前,一律不下线、不删除
  3. 先低风险资产练手,后核心资产——博客先行,播客音频(涉及历史存量数据)放后面
  4. 每一步都有明确的验证方法,不是"感觉应该好了"就往下走
  5. 每个阶段都留回滚预案——任何一步出问题,能在几分钟内退回上一个稳定状态
  6. 不动现有的关键基础设施——VPS 上已有的 Xray Reality 代理是日常依赖的服务,迁移过程优先选不影响它的方案,即使多花点小钱或多绕一步

Phase 0:准备阶段(不影响任何线上服务,随时可以开始)

  • 确认 Linode VPS 当前的资源状况(磁盘剩余空间、443 端口已被 Xray 占用——处理方式见 Phase 1 第二步之后的说明)
  • 注册 Backblaze B2 账号,创建一个 bucket(比如 yemushi-podcast),生成 Application Key(记得勾选读写权限)
  • (可选)注册 archive.org 账号,在 archive.org/account/s3.php 生成 IAS3 密钥,留到 Phase 3 用
  • 把 Cloudflare 后台当前所有相关 DNS 记录截图或导出备份(类型、名称、值、TTL、是否代理),这是最重要的一步——出问题时靠这份记录能几分钟内手动改回原样
  • 记录当前博客和播客涉及的所有域名/子域名清单,避免迁移时漏掉某个不常用的子域名
  • 备份现有 Xray 配置:cp /usr/local/etc/xray/config.json /usr/local/etc/xray/config.json.bak,不管后面选哪个方案处理 443 冲突,先留一份备份

这一步做完,什么都还没变,可以慢慢来,不用赶。


Phase 1:博客自建到 Linode VPS(先拿这个练手,风险最低)

1.1 VPS 基础检查与安全加固

# SSH 登录 VPS
ssh root@你的VPS_IP

# 更新系统
apt update && apt upgrade -y

# 检查磁盘空间,确认够用
df -h

# 确认 443 端口被谁占用(预期会看到 xray)
ss -tlnp | grep -E ':80|:443'

安全加固(比基础版更完整,因为这台机器要长期对公网暴露 80/443):

a) SSH 加固

# 配置 SSH 密钥登录(如果还没有)
ssh-keygen -t ed25519 -C "your_email@example.com"
ssh-copy-id root@你的VPS_IP

nano /etc/ssh/sshd_config

修改这几项:

PasswordAuthentication no
PermitRootLogin no          # 彻底禁 root 直接登录,比 prohibit-password 更严格
PubkeyAuthentication yes
MaxAuthTries 3               # 限制单次连接的认证尝试次数
ClientAliveInterval 300
ClientAliveCountMax 2         # 空闲连接自动断开
systemctl restart sshd

由于 PermitRootLogin no 需要先有一个非 root 的管理账号能 sudo,先建好:

adduser admin
usermod -aG sudo admin
mkdir -p /home/admin/.ssh
cp /root/.ssh/authorized_keys /home/admin/.ssh/
chown -R admin:admin /home/admin/.ssh
chmod 700 /home/admin/.ssh
chmod 600 /home/admin/.ssh/authorized_keys

先用 admin 账号登录测试成功之后,再改 sshd_config 禁用 root 登录,避免把自己锁在门外。

b) 防火墙(IPv4 + IPv6 分别确认)

apt install -y ufw

# 确认 IPv6 支持已开启
grep IPV6 /etc/default/ufw
# 应该看到 IPV6=yes,没有的话改成 yes

ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

# 确认规则同时覆盖了 v4 和 v6
ufw status verbose

ufw status verbose 的输出里每条规则应该同时出现 (v6) 版本,确认新的 IPv6 地址上的 80/443 确实被放行、且没有意外放开其他端口。

c) fail2ban 加固,并扩展监控 Caddy 日志

apt install -y fail2ban

# 给 sshd 单独配置更严格的封禁策略
cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3

[sshd]
enabled = true

[caddy-auth]
enabled = true
port = http,https
filter = caddy-auth
logpath = /var/log/caddy/access.log
maxretry = 10
EOF

# Caddy 默认不带 fail2ban 过滤规则,手动加一个简单版本,抓异常高频请求
cat > /etc/fail2ban/filter.d/caddy-auth.conf << 'EOF'
[Definition]
failregex = ^<HOST> .* "(GET|POST|HEAD).*" (404|444|400) .*$
ignoreregex =
EOF

systemctl restart fail2ban
fail2ban-client status

d) 自动安全更新(装完就不用天天惦记打补丁)

apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
# 选"是",之后系统会自动安装安全补丁

e) (强烈建议)SSH 管理入口收进你已有的 WireGuard 隧道,不对公网暴露 22 端口

你已经有 WireGuard 基础设施,这是最有效的一步——与其在公网上防守 SSH 暴力破解,不如干脆不让 22 端口对公网可见:

# 把 SSH 只允许通过 WireGuard 内网网段访问,公网防火墙直接拒绝 22
ufw delete allow OpenSSH
ufw allow in on wg0 to any port 22 proto tcp   # wg0 换成你实际的 WireGuard 接口名
ufw status verbose   # 确认 OpenSSH 那条公网规则已经不在了

这样即使全网扫描 IP 找开放的 22 端口,也扫不到你这台机器——管理入口完全收敛到你自己的 WireGuard 网络里,比任何 fail2ban 规则都更彻底。如果不方便这么做(比如临时需要在没连 WireGuard 的设备上应急登录),可以保留公网 22 但改成非标准端口,降低被扫描到的概率,两者都做也可以。

⚠️ 补充说明:这条对 GitHub Actions 的自动部署没有影响。 现在采用的是 1.5 里"出站轮询拉取"的部署方式——VPS 主动连出去拉取产物仓库,全程不需要任何入站 SSH 连接,所以这里把 22 端口完全收进 WireGuard、对公网彻底关闭,跟自动部署流程没有任何冲突。

1.2 安装 Caddy

apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update
apt install -y caddy

systemctl status caddy
mkdir -p /var/www/blog
chown -R www-data:www-data /var/www/blog

1.3 处理 443 端口冲突(用 IPv6,零成本、不改 Xray)

Reality 协议的伪装原理是:不匹配 Reality 密钥的流量会被原样转发给配置里的 dest(伪装目标),由 dest 自己完成真实的 TLS 握手。Xray 进程独占了原 IPv4 的 443 监听权,不能简单叠加另一个服务。

采用方案:给 VPS 用免费分配的 IPv6 地址,Caddy 绑定这个新地址的 443,Xray 完全不动。

  • 确认 Linode 已经给这台实例分配了 IPv6(Linode 默认每台实例免费带一个 /64 段):
ip -6 addr show

看到类似 2600:xxxx:xxxx:xxxx::1/64 这样的地址就是可用的公网 IPv6,记下这个具体地址。

  • 在 Caddyfile 里显式绑定这个 IPv6 地址:
nano /etc/caddy/Caddyfile
博客域名.com {
    bind 2600:xxxx:xxxx:xxxx::1
    root * /var/www/blog
    file_server {
        hide .*          # 不暴露隐藏文件
    }
    encode gzip

    # 安全响应头,见下方 1.3.1
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server            # 去掉响应头里的 Server 标识,减少信息暴露
    }
}
systemctl reload caddy
  • Cloudflare DNS 里博客的记录改成 AAAA 记录,指向这个 IPv6 地址,代理状态灰云 DNS-only

注意:只加 AAAA、不加 A 记录,意味着只有支持 IPv6 的网络才能访问你的博客。 目前西班牙主流运营商(Movistar、Vodafone、Orange 等)以及绝大多数移动网络都已支持 IPv6,覆盖率很高,但极少数老旧网络环境可能访问不了。如果不放心,可以在确认稳定后,再考虑要不要额外申请一个付费 IPv4(Linode 一般 $1-2/月)做双栈兜底,这一步不急,可以观察一段时间再决定。

这个方案安全的原因需要说准确:不是"IPv6 技术上封不了"(详见文末附注 2,运营商确实可以精确封锁具体 IPv6 地址),而是这个 IPv6 地址是你独享的,不会因为跟别人共享而被连坐。 只要不把这台 VPS 上的服务和任何共享 CDN/托管平台混用,这个地址就没有理由出现在封锁清单上。

Xray 那边完全不用动,它继续用原来的 IPv4 地址监听 443,两边互不干扰,直接进入 1.3.1。

1.3.1 新 IPv6 地址本身也要单独确认防火墙范围

因为 ufw 的规则默认是端口级别、不区分具体 IP,前面 1.1 里配置的 ufw allow 80/tcp / 443/tcp 会同时对 IPv4 和 IPv6 生效,这正是我们想要的(博客要在新 IPv6 上提供服务)。但要确认没有把 SSH 或其他内部服务意外暴露在这个新 IPv6 上:

# 确认新 IPv6 地址上只有 80/443 在监听,没有其他服务
ss -tlnp | grep '2600:xxxx'   # 换成你实际的 IPv6 前缀

# 确认 SSH 已经按前面 1.1(e) 收进 WireGuard,公网 IPv4/IPv6 都看不到 22 端口
nmap -6 -p 22 2600:xxxx:xxxx:xxxx::1   # 从外部机器测试,预期结果是 filtered/closed

方案 B(备选,需谨慎,会改动 Xray 配置):把博客设为 Reality 的伪装目标

核心思路:把 Xray Reality 的 dest 改成指向本机 Caddy 监听的内部端口,让"伪装身份"变成你自己的真实博客。

# 务必先确认已经备份过(Phase 0 提到的那步)
cat /usr/local/etc/xray/config.json.bak > /dev/null || echo "还没备份,先去备份!"

编辑 config.json,找到 realitySettings 部分:

"realitySettings": {
    "dest": "127.0.0.1:8443",
    "serverNames": ["你的博客域名.com"]
}

Caddy 需要改成监听 8443 并自己完成 TLS termination(因为 dest 收到的是真实的 TLS 连接,必须有真实证书):

你的博客域名.com:8443 {
    root * /var/www/blog
    file_server
    encode gzip
}
systemctl restart xray
systemctl reload caddy

# 立刻测试 Xray 本身是否还正常工作(比如从客户端连一下),确认没有搞挂代理

如果测试后发现 Xray 连接异常,立刻用备份恢复:

cp /usr/local/etc/xray/config.json.bak /usr/local/etc/xray/config.json
systemctl restart xray

1.4 先用测试子域名跑通整条链路(不动线上域名)

1.4.1 编辑 Caddy 配置

nano /etc/caddy/Caddyfile
test-blog.你的域名.com {
    bind 2600:xxxx:xxxx:xxxx::1   # 换成你实际的 IPv6 地址
    root * /var/www/blog
    file_server {
        hide .*
    }
    encode gzip
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
}
systemctl reload caddy

1.4.2 在 Cloudflare DNS 后台加一条测试记录

新建 AAAA 记录:test-blog → 你的 IPv6 地址,代理状态设为灰云(DNS only),TTL 设短一点(比如 300 秒)。

1.4.3 先手动放一个测试页面验证

echo "<h1>Hello from VPS</h1>" > /var/www/blog/index.html

访问 https://test-blog.你的域名.com,能看到内容且证书有效,说明 Caddy 的自动 HTTPS 和静态文件服务、以及端口共存方案都跑通了。

1.5 配置自动构建 + 部署(按对外风险最小原则重新选型)

在动手之前,先把三种可行方式摆在一起权衡,而不是直接采用之前随口提的自托管 Runner——upload 的那份摘要提醒得对,需要重新审视一遍。

三种方式的风险对比

A. 自托管 Runner(之前的推荐)B. 强制命令 SSH(之前的备选)C. 出站轮询拉取(重新分析后的推荐)
入站端口暴露无需入站端口需要公网暴露 22 端口,24 小时被扫描器探测无需入站端口
一旦密钥/凭证泄露,攻击者能做什么能在 VPS 上执行整个 workflow 的任意代码(npm install、构建脚本、任何依赖)——Runner 本质是把"运行任意 CI 代码"的权限交给了本机仅能把文件写入 /var/www/blog/ 目录,无法执行任何代码、无法读取目录外内容仅能读取一个不含任何个人信息的独立仓库里已经构建好的静态文件,无写入、无执行权限
本地常驻攻击面有一个常驻后台进程(Runner 服务),本身也是潜在攻击目标无常驻进程,只有被动监听的 sshd(本来就需要)无常驻进程,只有一个定时触发的轻量脚本
供应链风险较高——构建过程中用到的任何 npm/pip 包如果被投毒,直接在 VPS 本地以 deploy 用户权限执行不涉及构建,风险不适用低——构建完全在 GitHub 云端的隔离临时环境里进行,VPS 只拉取已经生成好的静态 HTML,不安装、不执行任何构建依赖
复杂度中(要维护一个常驻服务)低低(一个 cron + 几行脚本)

结论:方案 C(出站轮询拉取)在"对外暴露"和"本地执行风险"这两个维度上都优于另外两个,是重新分析后的推荐方案。 方案 A 虽然不开放入站端口,但把"执行任意构建代码"这个更大的权限交给了本机常驻进程,一旦 GitHub 生态的某个依赖包被投毒(供应链攻击在这几年并不罕见),影响面会直接落到你的 VPS 上;方案 B 虽然把执行权限锁得很死,但入站 22 端口本身就是一个 7x24 小时被互联网扫描器持续探测的暴露面。方案 C 把两边的短板都避开了:没有入站端口,也没有在 VPS 本地执行任何不可控代码。

方案 C 的核心思路:私有源码仓库负责构建,独立的"只含产物"仓库负责分发,VPS 只做只读拉取

私有仓库(含个人信息的源码)
      │
      │ GitHub Actions(云端,隔离的临时容器里构建,
      │   不落地到你自己的任何机器)
      ▼
独立的"产物仓库"(只放渲染好的静态 HTML,
      不含任何个人信息,可以继续保持私有,
      只是和源码仓库物理分开)
      │
      │ VPS 每隔几分钟主动出站轮询一次(git fetch),
      │   全程 VPS 是发起连接的一方,不接受任何入站连接
      ▼
/var/www/blog(本地静态文件目录)

关键的权限隔离:VPS 上存放的拉取凭证,只对"产物仓库"有只读权限,对含个人信息的源码仓库完全没有访问权限——即使这把凭证意外泄露,攻击者能读到的也只是已经公开渲染过的博客页面本身,不会碰到任何你想保护的原始资料。

1.5.0 先建好部署专用系统用户(如果还没有)

adduser deploy
usermod -aG www-data deploy
mkdir -p /home/deploy/.ssh
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh

chown -R deploy:www-data /var/www/blog
chmod -R 775 /var/www/blog

1.5.1 创建独立的产物仓库

在 GitHub 上新建一个仓库,比如 你的用户名/blog-dist。这次要设为 Public(跟之前版本的建议不同)——因为这份产物仓库同时要挂 GitHub Pages 作为免费冗余出口,GitHub Pages 免费版只支持公开仓库。公开完全没问题,因为这个仓库从设计上就只包含构建产物,不含私有源码仓库里的任何个人信息。这个仓库以后只会被自动化流程写入,你自己不需要手动碰它。

1.5.2 源码仓库的 Actions:构建并推送到产物仓库

在源码私有仓库里创建一个有权限写入 blog-dist 的 Fine-grained Personal Access Token(GitHub 设置里 → Developer settings → Fine-grained tokens),权限只勾选 blog-dist 这一个仓库、只给 Contents: Read and write,不要给任何其他权限。把这个 token 存进源码仓库的 Secrets(比如叫 DIST_REPO_TOKEN),注意这个 token 只存在于源码仓库的 Secrets 里,VPS 上永远不会出现这个 token。

.github/workflows/build.yml(在源码私有仓库里,按你实际用的 Hugo 构建流程改写,触发分支用你仓库实际的 master):

name: Build and Publish

on:
  push:
    branches: ["master"]   # 你的默认分支是 master,不是 main
  workflow_dispatch:        # 保留手动触发入口,方便调试

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      HUGO_VERSION: 0.144.0
    steps:
      - name: Install Hugo CLI
        run: |
          wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
          && sudo dpkg -i ${{ runner.temp }}/hugo.deb

      - name: Cache Hugo
        uses: actions/cache@v4
        with:
          path: ${{ runner.temp }}/hugo_cache
          key: ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}

      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Build with Hugo
        env:
          HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache
          HUGO_ENVIRONMENT: production
        run: |
          hugo \
            --minify \
            --baseURL "https://你的博客域名.com/"

      - name: Add CNAME for GitHub Pages
        run: echo "你的博客域名.com" > ./public/CNAME

      - name: Push to dist repo
        run: |
          cd ./public
          git init
          git config user.name "blog-bot"
          git config user.email "bot@你的域名.com"
          git add -A
          git commit -m "deploy: ${{ github.sha }}"
          git push --force "https://x-access-token:${{ secrets.DIST_REPO_TOKEN }}@github.com/你的用户名/blog-dist.git" HEAD:main

跟原始 hugo.yml 相比改动的几处,说明一下原因:

  • 去掉了 actions/configure-pages、actions/upload-pages-artifact、actions/deploy-pages 这几步:这套是 GitHub 官方"直接部署本仓库 Pages"的标准方式,但部署目标是当前这个仓库自己——如果留着私有仓库不动,一样会撞上付费墙,所以整个替换成"推送到独立公开仓库"这条路
  • baseURL 从 steps.pages.outputs.base_url 改成了写死的自定义域名:原来的写法依赖 configure-pages 动态识别当前仓库的 Pages 地址(项目页/自定义域名等情况),但现在部署目标变成了另一个仓库,这个动态识别不再适用,直接写死你的正式域名即可
  • 新增了 Add CNAME for GitHub Pages 这一步:GitHub Pages 靠产物目录里的 CNAME 文件识别自定义域名,原始 workflow 没有这步是因为它用的是 Settings 页面手动填域名的方式,现在改成推送到独立仓库后,域名识别信息需要跟着产物一起走,所以用这个文件带过去
  • 保留了 Hugo 安装、缓存、submodules: recursive、--minify 这些实质构建逻辑不变——这些跟部署到哪里无关,是你原来 workflow 里已经调好的部分,不需要动

1.5.3 VPS 端:只读部署密钥,只对产物仓库有效

在 GitHub 的 blog-dist 仓库里 → Settings → Deploy keys → Add deploy key,不要勾选"Allow write access",只生成一把只读密钥:

# 在 VPS 上,用 deploy 账号操作
sudo -u deploy bash
ssh-keygen -t ed25519 -f /home/deploy/.ssh/blog_dist_deploy_key -N ""
cat /home/deploy/.ssh/blog_dist_deploy_key.pub
# 把这个公钥贴到 blog-dist 仓库的 Deploy keys 页面,不勾选写权限

配置 SSH,让这把 key 只用于访问这一个仓库:

cat >> /home/deploy/.ssh/config << 'EOF'
Host github-blog-dist
    HostName github.com
    User git
    IdentityFile /home/deploy/.ssh/blog_dist_deploy_key
    IdentitiesOnly yes
EOF
chmod 600 /home/deploy/.ssh/config

克隆一次产物仓库:

git clone github-blog-dist:你的用户名/blog-dist.git /home/deploy/blog-dist

1.5.4 定时轮询脚本

cat > /home/deploy/pull-deploy.sh << 'EOF'
#!/bin/bash
cd /home/deploy/blog-dist
git fetch origin main
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [ "$LOCAL" != "$REMOTE" ]; then
    git reset --hard origin/main
    rsync -a --delete /home/deploy/blog-dist/ /var/www/blog/ --exclude '.git'
    echo "[$(date)] 部署了新版本 $REMOTE"
fi
EOF
chmod +x /home/deploy/pull-deploy.sh
chown deploy:deploy /home/deploy/pull-deploy.sh

用 deploy 用户的 crontab 定时跑(每 3 分钟检查一次,够用且不会给 GitHub API 造成压力):

crontab -u deploy -e

加入:

*/3 * * * * /home/deploy/pull-deploy.sh >> /home/deploy/pull-deploy.log 2>&1

这样整条链路里,VPS 全程只做"每 3 分钟主动连一次 GitHub、看看有没有更新"这一件事,不需要任何入站端口,也不在本地执行任何构建相关的代码——供应链风险和网络暴露风险都降到了这三个方案里最低的水平。1.1(e) 提到的「SSH 完全收进 WireGuard、公网 22 端口彻底不开放」在这套方案下可以直接放心执行,不再有任何冲突需要权衡。

备选:如果嫌 Cron 轮询不够"实时"

*/3 * * * * 意味着最多有 3 分钟的部署延迟。如果你希望 push 之后立刻生效,可以把 A 方案(自托管 Runner)作为"更快但风险稍高"的备选保留——两种方式的技术权衡已经在前面的表格里说清楚了,博客更新这种场景对"实时性"通常没有硬性要求,多等 3 分钟换来明显更小的攻击面,是这次"稳字当头"原则下更合理的取舍。

1.5.5 验证自动部署

改动一篇文章,git push 到源码私有仓库,等构建完成(Actions 标签页可以看进度)后,最多等 3 分钟,刷新测试子域名确认改动生效,同时检查 /home/deploy/pull-deploy.log 里有没有对应的部署记录。

1.6 正式切换域名

1.6.1 修改 Caddy 配置,加上正式域名

nano /etc/caddy/Caddyfile
你的正式博客域名.com {
    bind 新IP地址   # 方案A需要;方案B不需要
    root * /var/www/blog
    file_server
    encode gzip
}
systemctl reload caddy

1.6.2 修改 Cloudflare DNS

正式博客域名的记录改成指向对应 IP,代理状态设为灰云(DNS only)。

⚠️ 先不要删除 Cloudflare Pages 项目,保留至少一到两周作为回滚后备。

1.7 验证清单

  • curl -I https://你的正式博客域名 返回 200,响应头显示 Caddy 而不是 Cloudflare
  • 家宽直连和手机流量各测一次
  • 挑一个当天有比赛的时段专门测一次
  • 改一篇文章 push 上去,确认自动部署链路在正式域名上也生效
  • 重点:确认 Xray 代理服务依然正常工作,不管用的是方案 A 还是方案 B
  • 检查 HTTPS 证书是否正常

1.8 观察期

稳定运行 3-7 天,覆盖至少 2-3 个比赛日。确认无异常后再考虑清理旧的 Cloudflare Pages 项目。

回滚预案

  • 博客本身:把 Cloudflare DNS 记录改回原来指向 Cloudflare Pages 的配置即可,几分钟内恢复,旧项目全程没删除
  • 如果用了方案 B 且 Xray 出问题:cp /usr/local/etc/xray/config.json.bak /usr/local/etc/xray/config.json && systemctl restart xray 立刻恢复

Phase 2:播客音频从 R2 迁移到 B2(核心资产,最谨慎的一步)

这一步涉及历史存量数据,是整个方案里唯一"有数据丢失风险"的环节。下面按更细的粒度拆分,每一步都留了检查点,任何一步没通过就不要往下走。核心原则:在真正动"删除/切断"这类不可逆操作之前,始终让 R2、B2、以及一份额外的本地/VPS 备份三处同时保有完整数据,不是简单的"从 A 搬到 B"。

⚠️ 一个容易踩的坑,先说在前面:不要在 B2 前面挂 Cloudflare CDN。 网上很多 B2 相关的教程会推荐一个"省钱技巧"——通过 Cloudflare、Fastly 或 Bunny.net 这类 CDN 下载 B2 内容时(Backblaze 管这个叫 Bandwidth Alliance),出口流量费可以完全免掉,比直接从 B2 下载(超出免费额度后 $0.01/GB)更省钱。这个技巧对你的场景是个陷阱,绝对不能用——一旦在 B2 前面挂上 Cloudflare CDN,播客音频的访问入口就又变回了 Cloudflare 的共享 IP 池,跟这整个迁移要摆脱的问题完全是同一回事,等于白做。后面所有步骤里,B2 的公开访问都直接用它自己的原生域名,不额外挂任何 CDN。

2.0 准备:安装 rclone + 最小权限凭证 + 配置数据源

第一步,安装 rclone:

curl https://rclone.org/install.sh | sudo bash
rclone version   # 确认安装成功,顺手看一眼版本号

第二步,生成最小权限的 API 凭证(不要直接用管理员级别的密钥做迁移):

  • Cloudflare R2:后台 → R2 → Manage API Tokens → 创建一个新 Token,权限只勾选目标 bucket 的 Object Read(只读),不要用你 pipeline 日常上传用的那个读写 Token——迁移脚本本身没有必要拥有写入/删除 R2 的权限。创建后记下 Access Key ID 和 Secret Access Key,另外去 R2 概览页右上角记下你的 账号 ID(Account ID),下一步 endpoint 要用
  • Backblaze B2:后台 → App Keys → Add a New Application Key,权限限定在目标 bucket 上,勾选读写(这个是迁移的目标端,需要写权限)。记下 keyID 和 applicationKey——注意 B2 这边不要用账号页面顶部那个 Master Account ID,必须用你刚创建的这个 Application Key 对应的 keyID,两者不是一回事,混淆了会导致认证失败

第三步,配置 R2 remote(本质是 S3 兼容协议,需要手动指定 Cloudflare 的 endpoint):

rclone config

按交互式向导依次填写:

n) New remote
name> r2-source
Storage> s3                          # 选 Amazon S3 兼容存储(输入 s3 或对应数字)
provider> Cloudflare                 # 从列表里找 Cloudflare,不要选 Other 或随便一个 S3 厂商
env_auth> false                      # 手动输入密钥,不用运行环境变量
access_key_id> 刚才记下的 R2 Access Key ID
secret_access_key> 刚才记下的 R2 Secret Access Key
region> auto                         # R2 固定填 auto
endpoint> https://<你的账号ID>.r2.cloudflarestorage.com
acl> private

其余出现的选项一路默认(直接回车)即可,最后 y 确认保存。

第四步,配置 B2 remote(rclone 原生支持 B2 协议,不用走 S3 那一套):

rclone config
n) New remote
name> b2-target
Storage> b2                          # 直接选 Backblaze B2 类型(输入 b2)
account> 刚才记下的 Application Key 对应的 keyID(不是 Master Account ID)
key> 刚才记下的 applicationKey
endpoint>                            # 留空,直接回车,B2 原生方式不需要手动填
hard_delete>                         # 默认即可(false),迁移阶段用不到删除操作

一路默认到最后 y 确认保存。

第五步,验证两边连通性,不要跳过这一步就往下走:

rclone lsd r2-source:      # 应该能列出你的 R2 bucket 名称
rclone lsd b2-target:      # 应该能列出你的 B2 bucket 名称

如果任何一边报错(常见的是认证失败 401,或者 endpoint 拼写错误),先在这一步排查清楚,不要带着连不上的 remote 进入下一步——后面的所有校验环节都建立在"两边 remote 本身是可靠的"这个前提上。

2.1 建立迁移前的基准清单

remote 配置好、验证通过之后,先给 R2 上现有的内容拍一份"快照",作为后续所有校验的对照基准:

# 导出 R2 上当前所有文件的清单,包含大小和哈希,存成一份带时间戳的记录
rclone lsjson r2-source:你的bucket名 --recursive --hash > r2_manifest_$(date +%Y%m%d).json

# 顺手统计一下文件总数和总大小,跟你自己记忆里的集数对一下
rclone size r2-source:你的bucket名
  • 把这份 r2_manifest_日期.json 的文件数量,跟你自己 pipeline 的发布记录(比如 RSS feed 里的集数、或者你本地 Archive/JSON 状态文件里记录的集数)核对一遍,确认没有出现"R2 上比你预期少几集"这种在迁移开始前就该发现的异常
  • 把这份清单文件本身备份到至少两个地方(比如同时存一份在 VPS 和你本地电脑),它是后续判断"迁移有没有漏东西"的唯一基准,丢了就没法比对

2.2 先建一份独立于两个云的本地/VPS 备份(额外保险,不是必须但强烈建议)

在真正开始"云对云"传输之前,先把 R2 上的全部内容额外下载一份到 VPS 本地磁盘(或者你本地的存储设备)。这一步看似多余,但意义在于:一旦后面的 R2→B2 传输过程中出现意外(比如 rclone 中途崩溃、网络异常导致部分文件在两边都不完整),你手上还有一份独立于云端 API 状态的完整拷贝可以兜底,不用完全依赖"云端数据当时到底传成什么样"这一个信息源。

# 确认 VPS 磁盘空间够用(参考 Phase 1.1 里已经检查过的 df -h)
mkdir -p /home/deploy/podcast-backup
rclone copy r2-source:你的bucket名 /home/deploy/podcast-backup -v --progress

# 校验这份本地备份跟 R2 源完全一致
rclone check r2-source:你的bucket名 /home/deploy/podcast-backup --checksum

rclone check 最后应该显示 0 个不匹配(0 differences found)。如果不是 0,先把这里的问题解决掉,不要带着不确定性继续往下走。

这份本地备份可以一直保留到整个迁移彻底完成、观察期结束、确认清理 R2 之后再考虑删除,占用的磁盘空间换来的是"任何一步出问题都有第三份独立拷贝兜底"。

2.3 dry-run(只打印计划,不传输)

rclone copy r2-source:你的bucket名 b2-target:yemushi-podcast --dry-run -v > dryrun_log_$(date +%Y%m%d).txt 2>&1

打开 dryrun_log_日期.txt,核对:

  • 文件数量是否跟 2.0 的基准清单一致
  • 有没有意外出现的文件名(比如编码问题导致的乱码文件名,这种问题在 dry-run 阶段发现远比传完了才发现好处理)

2.4 正式同步(只拷贝,不删除任何源文件)

rclone copy r2-source:你的bucket名 b2-target:yemushi-podcast \
  -v --progress \
  --log-file=copy_log_$(date +%Y%m%d).txt \
  --retries 5 \
  --low-level-retries 10

要点:

  • 全程用 copy 不用 sync——sync 会把目标端"多出来的"文件删掉,copy 只增量添加,语义上更安全,不会有任何删除行为
  • --retries 和 --low-level-retries 让网络抖动时自动重试,而不是一次失败就中断整个任务
  • --log-file 把完整日志落盘,方便传输完之后回头核对有没有报错的条目

如果文件总量比较大、担心中途网络中断,可以用 screen 或 tmux 跑在后台,SSH 断开也不影响传输继续:

tmux new -s r2b2migrate
# 在 tmux 会话里跑上面那条命令,Ctrl+B 再按 D 可以退出会话但不中断任务

2.5 完整性校验(全量哈希比对,不只是抽样)

rclone check r2-source:你的bucket名 b2-target:yemushi-podcast --checksum -v > check_log_$(date +%Y%m%d).txt 2>&1
  • 确认日志结尾显示 0 differences found
  • 如果有差异,check 的日志会明确列出具体哪个文件不匹配——针对这些文件单独重新 copy 一次,再重新 check 一遍,直到差异清零为止,不要带着已知的不一致进入下一步

这一步做的是全量哈希校验,比之前版本里"随机抽 3-5 集"更保险——播客音频文件数量通常不会大到全量校验跑不动,既然工具本身支持全量核对,没有理由只做抽样。

2.6 人工抽样试听验证

哈希校验证明的是"两边字节完全一致",但不能排除"文件本身在源头就有问题、只是被原样复制过去了"这种情况,或者 B2 端的公开访问配置(权限、CORS、Content-Type)有问题导致虽然文件对但访问不了。所以还要做一轮实际的人工验证:

  • 随机挑 3-5 集(建议覆盖最早期、中期、最近这几个时间段各挑一集,而不是只挑最近的),用 B2 生成的公开访问链接,在浏览器或播放器里实际打开播放,确认音频能正常播放、没有截断或者杂音
  • 检查这几个文件在 B2 上的 Content-Type 是否正确识别为 audio/mpeg(或者你实际用的格式),不正确的话某些播客客户端会拒绝播放——如果发现不对,检查上传/迁移工具有没有正确保留 MIME 类型,rclone 默认会保留源文件的 content-type,但如果之前 R2 上的元数据本身就有问题,会被原样带过去

2.7 Pipeline 代码改成双写,并接入你已有的失败告警

def upload_episode(file_path):
    try:
        upload_to_r2(file_path)   # 保留原有逻辑
    except Exception as e:
        notify_telegram(f"R2 上传失败: {e}")   # 复用你 pipeline 里已有的 Telegram 通知
        raise   # R2 失败依然要能感知到,不要静默忽略

    try:
        upload_to_b2(file_path)   # 新增
    except Exception as e:
        notify_telegram(f"B2 上传失败: {e}")
        # B2 失败先不影响主流程往下走(R2 那份已经成功,播客发布不受阻),
        # 但必须有告警,人工介入补传,不能让新集数出现"只在R2、没在B2"的情况被忽略

你之前的 pipeline 已经做过 R2 上传失败的容错和 Telegram 通知集成,这里只是把同样的模式复用到 B2 这一侧——双写阶段最怕的不是"B2 偶尔失败",而是"失败了但没人知道",导致新发布的几集悄悄漏了同步,观察期结束时才发现缺了几集补不回来(虽然还能从 R2 补,但双写的意义就是要让这种遗漏第一时间被发现)。

双写阶段建议至少维持 4 周(比之前版本建议的 2-4 周取上限),完整覆盖一个月的发布节奏和多个比赛日。

2.8 RSS feed 切换(分批次,不要一次性全量替换)

第一批:只切一集做验证

  • 挑最新发布的一集,<enclosure url="..."> 换成 B2 链接,其余所有历史集数暂时不动
  • 观察这一集在 Apple Podcasts、小宇宙等平台上是否正常抓取、显示正确的时长和文件大小、能正常播放
  • 观察至少 3-7 天,确认没有平台方面的异常(比如某些平台对 enclosure 变化敏感,可能需要重新触发一次 feed 抓取)

第二批:切换最近的一批新集数

  • 确认第一批没问题后,把最近 4-8 周内发布的集数也切换成 B2 链接
  • 再观察一段时间

第三批(可选,不着急):历史存量集数

  • 历史链接留在 R2 完全可以继续正常工作,不是必须迁移的——只是长期看仍然带着被封的风险。可以按自己的节奏,比如每周批量迁移一部分,不用一次性改完
  • 每次批量替换后,记得同步检查一下 RSS 文件本身的合法性(比如用 xmllint 或在线 RSS 校验工具跑一遍,确保批量替换脚本没有引入格式错误)

2.9 观察期

覆盖至少 4-6 周、多个比赛密集的周末,重点关注:

  • B2 链接在比赛日访问是否正常(这是这次迁移最核心的验证目标)
  • Telegram 告警里有没有出现过 B2 上传失败的记录,如果出现过,确认对应的那几集是否已经手动补传并重新校验
  • 定期(比如每周)重新跑一次 rclone check r2-source:你的bucket名 b2-target:yemushi-podcast --checksum,确认双写期间新增的文件两边依然保持一致

2.10 清理前的最终确认清单

只有以下全部满足,才考虑停止双写、清理 R2 上的旧文件:

  • 双写阶段满 4 周以上,期间没有出现过未被发现/未被补传的 B2 同步失败
  • 全量哈希校验最近一次结果是 0 differences found
  • RSS feed 里所有集数(包括历史集数,如果你选择了全部迁移)都已经指向 B2,且在至少一个完整赛季周末观察下访问正常
  • 本地/VPS 的那份额外备份(2.2 建的)依然完整保留,作为清理 R2 之后的最后一道保险——即使决定清理 R2,也不建议同时删除这份本地备份,它的存在成本很低(就是占一点磁盘),换来的是"万一 B2 以后也出问题"时还有退路

即使满足以上条件,也不建议立刻删除 R2 上的文件——可以先把 R2 bucket 的访问权限收紧(比如取消公开访问、只保留你自己账号能看到),观察一段时间完全没有依赖之后,再考虑真正删除,这样即使漏掉了什么依赖这份数据的地方(比如某个你忘记的旧链接),也不会直接导致数据丢失,只是暂时不可公开访问。

回滚预案(分层次)

  • RSS 层面:某一批切换后发现问题,把对应的 <enclosure> 链接改回 R2 地址即可,几分钟生效,因为 R2 原文件全程没有被删除
  • Pipeline 层面:如果 B2 上传持续失败,先把双写代码里 B2 那部分注释掉,只保留 R2 上传,不影响播客正常发布,B2 那边等问题排查清楚再重新补传
  • 数据层面:如果发现 B2 上的文件损坏或者丢失,从 2.2 建立的本地/VPS 备份重新上传即可,不需要依赖 R2(虽然这个阶段 R2 应该也还在)

Phase 3(可选):archive.org 长期镜像

优先级最低,不阻塞前两个阶段,建议在 Phase 2 完整走完观察期、确认稳定之后再动手。核心定位是免费的公开镜像 + 长期备份,不作为主力分发渠道,所以这里的步骤都按"锦上添花、失败也不影响主流程"的原则设计。

3.0 准备

  • 注册 archive.org 账号
  • 打开 https://archive.org/account/s3.php,生成 IAS3 密钥(Access Key / Secret Key)
  • 在 VPS 上安装官方 Python 库:
pip install internetarchive
  • 配置凭证(存到环境变量或专门的凭证文件,不要硬编码进脚本):
ia configure
# 按提示输入你的 archive.org 账号邮箱和密码,会在 ~/.config/internetarchive/ia.ini 生成配置

3.1 命名规则设计

在批量上传之前先定好规则,避免上传一半发现命名不规范要返工:

  • Item identifier 统一用小写字母 + 连字符,不要用大小写混合(前面提到过,底层 boto 模块对大小写混合的 identifier 有兼容性问题,省事的做法就是从一开始就不用)
  • 采用"每集一个 Item"的粒度,比如 yemushi-jiangdao-ep001、yemushi-jiangdao-ep002……这样每集在 archive.org 上会有独立的详情页,方便以后被单独搜索到、分享
  • 提前列一份"集数编号 → identifier"的映射表(哪怕就是一个简单的 CSV),批量脚本按这份表跑,比现场拼字符串更不容易出错

3.2 批量上传历史音频

写一个简单的批量脚本,注意控制节奏,不要对这个非营利机构的服务器发起过于密集的并发请求:

import csv
import time
from internetarchive import upload

with open('episode_mapping.csv') as f:
    reader = csv.DictReader(f)
    for row in reader:
        identifier = row['identifier']
        filepath = row['local_path']
        title = row['title']

        try:
            r = upload(
                identifier,
                files={f'{identifier}.mp3': filepath},
                metadata={
                    'title': title,
                    'mediatype': 'audio',
                    'collection': 'opensource_audio',
                    'creator': '叶牧',
                },
                verbose=True,
            )
            print(f'{identifier} 上传完成: {r[0].status_code}')
        except Exception as e:
            print(f'{identifier} 上传失败: {e}')
            # 记录失败的条目,方便后面单独重试,不要让一次失败中断整个批次

        time.sleep(5)   # 每次上传间隔几秒,给对方服务器留余量,也避免触发限流
  • 先拿 3-5 集小批量跑一遍,确认脚本逻辑没问题、命名和元数据符合预期,再放开跑全部历史存量

3.3 验证

  • archive.org 上传后不是立即生效,文件会先进入临时存储,再由系统正式收录,这个过程通常几分钟到十几分钟。批量上传完成后,等半小时到一小时再开始验证,不要上传完立刻判断"失败了"
  • 抽样访问几个 https://archive.org/download/<identifier>/<文件名> 链接,确认能正常下载播放
  • 检查 archive.org 详情页(https://archive.org/details/<identifier>)显示的标题、描述这些元数据是否正确

3.4 接入日常 pipeline(新集数同步上传)

在 B2 上传成功之后,加一步同步上传到 archive.org,同样设计成失败不阻塞主流程、但要能被感知到:

def upload_episode(file_path, identifier, title):
    # ... 前面 R2/B2 双写逻辑不变 ...

    try:
        upload(identifier, files={...}, metadata={...})
    except Exception as e:
        notify_telegram(f"archive.org 同步失败(不影响主发布): {e}")
        # 不 raise,因为这只是锦上添花的镜像,不应该因为它失败就阻塞整个发布流程

3.5(可选)在 show notes 里加一条镜像链接

不建议把 archive.org 链接直接替换进 RSS 的 <enclosure>(那个位置应该继续用 B2 链接,是主链路),可以考虑在每集的简介文字末尾加一行"本集也可以在 archive.org 上收听:<链接>",作为读者可见的备用入口,纯粹是多给听众一个选择,不涉及技术架构改动。

回滚 / 风险提示

  • archive.org 这一步全程是新增操作,不涉及删除或修改任何 R2/B2 上的数据,出问题的最坏情况就是这个镜像没建成,对主链路(B2)完全没有影响
  • 如前面附注里提到的,archive.org 没有 SLA 承诺,不要让任何关键路径依赖它——它的定位应该始终是"多一份免费保险",不是"主力存储"

Phase 4:DNS 收尾检查

这是整个迁移方案的最后一步,目的是把 Cloudflare DNS 后台彻底梳理一遍,确认该灰云的都灰云了,同时不要在这个收尾阶段手滑改错东西影响到还在正常工作的服务(比如 Xray 的相关记录,如果有的话)。

4.0 前置条件(确认这些都满足了再开始)

  • Phase 1(博客)已经过完至少 3-7 天观察期,覆盖多个比赛日,确认稳定
  • Phase 2(播客音频)已经满足 2.10 里"清理前的最终确认清单"的全部条件
  • Phase 3(如果做了)已经跑通,不影响本阶段

4.1 完整记录审计

  • 打开 Cloudflare DNS 后台,把当前所有记录导出一份完整清单(后台有 Export DNS records 功能,直接导出 BIND 格式文件保存一份)
  • 逐条记录分类,建议列一张表:
记录类型当前指向当前代理状态应该的状态备注
博客域名AAAAVPS IPv6灰云灰云(已经是对的)Phase 1 已改
播客音频子域名CNAMEB2 域名灰云灰云(已经是对的)Phase 2 已改
test-blog(测试用)AAAAVPS IPv6灰云可以删除Phase 1 验证阶段用的,已经不需要
(如果有 Xray 相关子域名)AVPS IPv4视情况不要动跟这次迁移无关,保持原状
其他遗留记录———逐条确认是否还在用常见于博客换过几次平台后留下的历史记录

4.2 逐条处理

  • 对确认不再需要走 Cloudflare 代理(橙云)的记录,改成灰云 DNS-only——这一步之前 Phase 1、Phase 2 里其实已经针对博客和播客各自的记录做过了,这里主要是查漏补缺,确认没有遗漏的记录还留着橙云
  • 对测试阶段建的临时记录(比如 test-blog 这种),确认正式域名已经稳定运行后,直接删除,避免后台留一堆无用记录增加以后排查的复杂度
  • 对 Xray 使用的记录(如果 Xray 是通过域名而不是纯 IP 连接的),不要做任何改动——这次迁移的范围始终只是博客和播客,跟 Xray 无关的记录不要顺手"整理"掉

4.3 检查 SSL/TLS 相关设置

因为博客和播客现在都已经不走 Cloudflare 代理、不依赖 Cloudflare 边缘的证书终止了,顺手确认一下 Cloudflare 后台的 SSL/TLS 模式设置(比如 Full / Full strict)是否还对其他仍在使用代理的服务有影响:

  • 如果 Cloudflare 账号下还有别的服务在用橙云代理,确认 SSL/TLS 模式设置没有因为这次调整被误改
  • 博客和播客现在的 HTTPS 证书分别由 Caddy(自动 Let’s Encrypt)和 B2/GitHub Pages 自己的证书体系管理,跟 Cloudflare 的证书设置已经没有关系,这里只是做一次确认,不需要额外操作

4.4 旧服务下线(分阶段,不要一步到位删除)

  • 第一步:先收紧访问权限,不直接删除——Cloudflare Pages 项目可以先在后台暂停/归档;R2 上的旧 bucket 可以先取消公开访问权限(如果之前是公开的),只保留你自己账号能看到
  • 观察至少 1-2 周,确认没有任何遗漏的地方还在依赖这些旧服务(比如某个你忘记的旧链接、某个别人转发过的历史链接)
  • 确认完全没有依赖后,再考虑真正删除 Cloudflare Pages 项目和 R2 bucket——即使到这一步,也不建议着急删,可以再放一段时间,删除操作本身没有回滚可能,晚一点删除的成本几乎为零,误删的成本很高

4.5 迁移专用凭证收尾

  • 回顾整个迁移过程中生成过的各种临时/迁移专用凭证:Phase 2.0 里生成的只读 R2 Token、迁移用的 B2 Application Key、各种 Fine-grained Token
  • 迁移全部完成、确认不再需要之后,去对应后台把这些一次性用途的凭证吊销,只保留日常 pipeline 真正需要长期使用的那些密钥,减少长期挂着的、权限范围不再必要的凭证数量

4.6 最终验证清单

  • 用一个跟这次迁移完全无关的全新浏览器 / 设备,访问博客和播客涉及的所有域名,逐一确认正常
  • 专门挑一个比赛日,从头到尾测一遍博客访问、播客音频播放、RSS 订阅刷新,确认整个链路在这种"最容易出问题的窗口"下依然稳定
  • 检查 Cloudflare 账单,确认迁移之后 R2 的用量、费用符合预期地下降(如果之前有产生费用的话),侧面印证流量确实已经切换过去了

至此整个迁移方案的四个阶段全部细化完成。


建议时间线

时间任务
第 1 周Phase 0 准备 + Phase 1.1-1.4(VPS 加固、装 Caddy、处理 443 冲突、测试子域名跑通)
第 2 周Phase 1.5-1.7(GitHub Actions 自动部署、正式切换域名、验证)
第 3 周Phase 1 观察期 + Phase 2.0-2.6(凭证准备、基准清单、本地备份、正式同步、全量校验、试听验证)
第 4 周Phase 2.7 双写上线
第 5-10 周Phase 2.7 双写持续 4 周以上 + Phase 2.8 RSS 分批切换 + Phase 2.9 观察期,覆盖多个比赛日
第 11 周起满足 Phase 2.10 清理前确认清单后,收紧 R2 权限(先不删除)
之后Phase 3.0-3.5 archive.org 镜像(不赶时间,慢慢批量上传) + Phase 4.0-4.6 DNS 收尾(记录审计、旧服务分阶段下线、凭证清理、最终验证)

哪个阶段观察下来还有疑虑,就多留观察期,不用赶进度。


通用验证清单

  1. curl -I <新地址>,确认返回 200 且响应头显示自建服务标识
  2. 家宽直连和手机流量各测一次
  3. 专门挑比赛日测试
  4. 检查旧服务是否还保持可用,作为回滚后路
  5. 涉及 VPS 改动的步骤,额外确认 Xray 代理服务未受影响

附注:为什么放弃 Vercel/Netlify,选择自建 VPS

经查证,LaLiga 这轮反盗版 IP 封锁自 2025 年初开始已陆续波及 Cloudflare、Vercel、Netlify、GitHub Pages、BunnyCDN 等主流共享 IP 的 CDN/托管平台,本质原因是这些平台都是"大量域名共享少量 IP"的结构,非法 IPTV 转播服务混在同一批 IP 里,导致误伤范围随之扩大。Vercel 官方甚至专门发博客说明此事,CEO 也公开表示即便开通了专门的举报通道,还是持续被无差别封锁。

相比之下,自建到独立静态 IP 的 VPS,不与任何大型共享 IP 池混用,被"连坐"的概率结构性更低——虽然不是 100% 的官方保证,但目前是几个选项里风险最低的一个。


附注 2:重新核实——IPv6 是否天然躲得开这类封锁?(2026-08-30 补充分析)

在最终确定用 IPv6 方案之前,专门查证了一个关键问题:运营商是不是技术上没法把 IPv6 地址全部封掉,所以 IPv6 天然更安全? 查证结果需要纠正一个想当然的假设——答案是否定的,IPv6 一样会被精确封锁,安全性的来源不是"协议层面封不了",而是别的原因。

实际情况:西班牙网友论坛上已经有人明确抱怨过这个问题——原文大意是"为什么 IPv6 也被封?理论上每个服务不是应该能有自己独立的 IPv6 地址、不用像 Cloudflare 的 IPv4 那样挤在共享池里吗?“这条留言本身就证实了两件事:第一,运营商确实在封锁清单里精确点名封禁具体的 IPv6 地址,技术上完全做得到(对防火墙来说封一个 IPv6 地址和封一个 IPv4 地址没有本质难度差异,不存在"地址空间太大封不过来"这种技术豁免);第二,IPv6 真正的优势不是"封不了”,而是"每个服务可以有自己独立的地址,不必和别人共享"——这跟我们选它的理由其实是同一个逻辑,只是需要把"封不了"这个错误理解纠正成"没有理由被封"。

这对你的方案意味着什么:

  1. 你的 Linode IPv6 地址安全,不是因为它是 IPv6,而是因为它是你独享的、跟任何 Cloudflare/Vercel 服务完全无关的地址——这跟之前的核心判断完全一致,只是要澄清"protocol 本身天然安全"这个说法不准确,真正起作用的是"地址不共享、不会被别的网站牵连"
  2. 不会无缘无故被封,但也不是绝对不可能——封锁清单本质是 LaLiga 举报 + 运营商执行的动态名单,只要你的 IPv6 地址不出现在任何被举报的服务列表里(这是大概率事件,因为你的 VPS 只是自己用),就不会被点名。真正的风险不是"IPv6 遲早被扫描到",而是"万一将来 VPS 上又混进了某个跟别人共享/容易被牵连的服务",这也是为什么方案里反复强调"不要把博客和任何共享 CDN 混用"这个原则比"用 IPv4 还是 IPv6"本身更关键
  3. 规则的扩张趋势值得留意:查到的最新信息显示,法院授权的封锁范围已经从最初判决书列出的 123 个 IP,扩大到目前号称最多同时封锁约 3000 个 IP(其中 35-45% 属于 Cloudflare),而且已经开始出现"不局限于比赛日"的封锁案例,2026 年 3 月还有一份新判决把范围扩大到了欧冠、网球、高尔夫等其他赛事期间。这说明这套机制本身还在扩张,不是收敛趋势——独立 IP 这个策略眼下有效,但不代表以后完全不用再关注这个问题

结论:不用改变已经定下的 IPv6 方案,这仍然是目前几个选项里最合理的一个;只是把决策依据修正为准确的——“独享、不共享"才是关键,而不是"IPv6 协议本身封不了”。