mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
mobile wallpaper 5
mobile wallpaper 6
6517 字
17 分钟
博客国内外双节点部署与全链路架构解耦
2026-05-19 21:48:21
2026-07-16 09:30:00

特别鸣谢:这次重构借助 OpenAI Codex 的卓越辅助,在短短半天时间内就完成了整套复杂的双节点部署与全链路域名解耦调试。(Codex 真是太好用了,重点是居然还有路子白嫖,实在是太感谢 奥特曼 了 🥳)

最近抽空把博客的部署架构重构了一下。

原来的站点全部挤在国内单机上,海外访问延迟较高;且虽然图床等静态资源一直都在独立的 xxx.online 域名(img.xxx.online)下,没有进行过迁移,但音乐资源子域(music.dawn114514.site)依然绑定在主域名 dawn114514.site 下,导致如果将主域名从 Cloudflare DNS 迁出,静态音乐资源的解析就会随之失效。

经过这次重构,我将博客升级为国内外双节点就近接入 + 音乐子域解耦迁移 + GitHub Actions 自动化发布的架构。

在此过程中扫平了关于 SSH 密钥、Nginx 虚拟主机配置以及网络防火墙的各种隐性坑。这里做个完整的复盘和配置细节记录,方便以后查阅。


改造时间线与实施甘特图#

整个重构过程虽然断断续续,但大致可以分为六个阶段。以下甘特图呈现了各阶段的时间推进关系:

gantt title 博客双节点部署改造时间线 (2026-05-19) dateFormat YYYY-MM-DD HH:mm axisFormat %H:%M section 音乐子域解耦迁移 R2 自定义域名绑定 :done, r2-1, 2026-05-19 13:45, 13m 代码中音乐链接全量替换 :done, r2-2, 2026-05-19 13:59, 5m CORS 策略配置与验证 :done, r2-3, 2026-05-19 14:04, 2m section CI/CD 改造 双端部署 Workflow 编写调试 :done, ci-3, 2026-05-19 14:05, 45m 生成免密 SSH 密钥对 :done, ci-1, 2026-05-19 14:09, 35m GitHub Secrets 配置 :done, ci-2, 2026-05-19 14:44, 4m section 海外服务器 Nginx 安装与站点配置 :done, os-1, 2026-05-19 14:25, 3m iptables + OCI 防火墙放行 :done, os-3, 2026-05-19 15:02, 53m Certbot SSL 证书签发 :done, os-2, 2026-05-19 15:49, 3m section DNS 迁移 域名迁回阿里云 DNS :done, dns-1, 2026-05-19 15:06, 9m 分线路智能解析配置 :done, dns-2, 2026-05-19 15:15, 28m section 国内服务器优化 Nginx 配置审计与整理 :done, cn-2, 2026-05-19 15:56, 15m TLSv1.1 协议移除 :done, cn-1, 2026-05-19 16:08, 2m section 验证与文档 全链路连通性测试 :done, verify-1, 2026-05-19 14:50, 65m 撰写部署文档与博客 :done, verify-2, 2026-05-19 16:13, 12m

最终网络架构与流量分流拓扑#

重构后的系统分为以下四个层次,各层之间职责明确:

  1. 解析分流层:利用阿里云 DNS,对国内和境外流量做智能路由。

  2. 静态资源存储层:将原本绑定在主域名下的音乐子域(music.dawn114514.site)也解耦迁移至独立的 xxx.online 域名(music.xxx.online)代理,与图床一起实现静态资源与主域名在故障域和带宽上的解耦,不再占用主站带宽。

  3. Nginx 接入层:国内和海外节点各自部署 Nginx 并终结 SSL 证书,国内机房额外承载子系统反代。

  4. CI/CD 管线:内容仓库与代码仓库解耦联动 —— 推送内容仓库触发 trigger-build.yml,通过 repository_dispatch 事件分发至代码仓库的 deploy.yml,由后者同步内容、构建并以真正的后台并行 & + wait 聚合退码方式同步投递双端机器。

架构拆分原则

职责单一,解耦至上

无论未来业务节点如何扩充,四层分工的核心原则都应该保持稳定:DNS 负责路由分流,Nginx 负责反代接入,GitHub Actions 负责构建与同步,Cloudflare R2 负责静态数据持久化。

具体的流量和自动部署走向可以参考下图:

flowchart TD %% 流量流向 User[用户访问 dawn114514.site] --> DNS{Alibaba Cloud DNS <br>智能分流} %% 国内分流 DNS -->|中国内地/默认| CN_Node[国内 ECS 服务器 <br>IP: xxx.xxx.xxx.xxx] CN_Node --> CN_Nginx[Nginx + SSL <br>主站目录: /www/wwwroot/MyBlog] CN_Nginx --> CN_Subs[子系统反代 <br>自建服务 A / 自建服务 B] %% 海外分流 DNS -->|中国境外| OV_Node[海外 Oracle 服务器 <br>IP: yyy.yyy.yyy.yyy] OV_Node --> OV_Nginx[Nginx + SSL <br>主站目录: /www/wwwroot/MyBlog] %% 静态资源解耦(浏览器直连 R2,不经 Nginx 中转) Browser[浏览器] -.->|直接请求外链| R2_Music[music.xxx.online <br>Cloudflare R2 音乐源] Browser -.->|直接请求外链| R2_Img[img.xxx.online <br>Cloudflare R2 图床] CN_Nginx -.->|HTML 引用外链 URL| Browser OV_Nginx -.->|HTML 引用外链 URL| Browser %% 自动化持续集成管线(内容/代码双仓库解耦联动) Developer[开发者 git push] --> ContentRepo[内容仓库 MyBlog-Content] ContentRepo -->|push main 触发 trigger-build.yml| Dispatch[repository-dispatch 事件 content-updated] Dispatch -->|DISPATCH_TOKEN 鉴权分发| CodeRepo[代码仓库 MyBlog deploy.yml] CodeRepo -->|浅克隆内容仓库并复制目录 + pnpm build| Build[GitHub Actions 构建管线] Build -->|rsync 同步 后台并行 &| CN_Node Build -->|rsync 同步 后台并行 &| OV_Node

GitHub Actions 双端部署的详细时序交互如下:

sequenceDiagram participant Dev as 开发者 participant CR as 内容仓库 MyBlog-Content participant TR as trigger-build.yml participant DR as 代码仓库 deploy.yml participant CI as Actions Runner participant CN as 国内 ECS (Aliyun) participant OS as 海外 Oracle Dev->>CR: git push main (改动 posts/data 等) CR->>TR: 触发 trigger-build.yml TR->>DR: repository-dispatch 发送 content-updated 事件 Note over TR,DR: 携带 DISPATCH_TOKEN 鉴权 DR->>CI: 触发 deploy.yml 工作流 CI->>CI: checkout + setup pnpm/node 22 + 恢复 Astro 缓存 CI->>CI: pnpm install --frozen-lockfile CI->>CI: pnpm run sync-content (浅克隆内容仓库并复制目录, 失败即标红) CI->>CI: pnpm run build (注入 BILI_SESSDATA / UMAMI_SHARE_URL 等构建期密钥) Note over CI: 构建产物生成于 dist/ 目录 par 后台并行部署到国内 CI->>CI: 写入 DEPLOY_SSH_KEY_CN CI->>CN: ssh-keyscan 写入 known_hosts (每次扫描直接信任, 非核验指纹) CI->>CN: mkdir -p /www/wwwroot/MyBlog/dist CI->>CN: rsync -az --delete dist/ CN-->>CI: 同步完成 and 后台并行部署到海外 CI->>CI: 写入 DEPLOY_SSH_KEY_OVERSEA CI->>OS: ssh-keyscan 写入 known_hosts (每次扫描直接信任, 非核验指纹) CI->>OS: mkdir -p /www/wwwroot/MyBlog/dist CI->>OS: rsync -az --delete dist/ OS-->>CI: 同步完成 end Note over CI: wait 聚合双端后台进程退出码

改造步骤与细节#

1. 音乐子域解耦与迁移(Cloudflare R2)#

在此之前,我的图床静态资源其实一直托管在独立的 xxx.online 域名(img.xxx.online)下,并未进行迁移。

但是,我的音乐多媒体资源却仍然直接绑定在主域名的子域 music.dawn114514.site 下。这就导致如果我想把主域名 dawn114514.site 的 Nameservers 从 Cloudflare DNS 切走,静态音乐资源的解析就会直接跟着完蛋(因为 R2 绑定自定义域名需要域名托管在 CF)。

解决办法:

为了彻底将静态资源与主域名剥离,我决定将音乐子域名迁移至独立托管在 Cloudflare 上的 xxx.online 之下(即 music.xxx.online),使资源域名与主站域名分属不同顶级域,实现故障域解耦——主站 DNS 怎么改动都不影响资源解析。

不要手动拼 CNAME

Cloudflare 官方明确不建议手动添加指向 pub-xxx.r2.dev 的 CNAME 作为生产接入,这种接入方式不在支持范围内,可能失效。

正确做法是在 R2 存储桶 → Settings → Custom Domains 中点击 “Connect Domain” 连接域名,由 Cloudflare 自动创建并通过 R2 网关解析那条自定义域名,无需手工干预 DNS 记录。

也就是说:直接在两个 R2 存储桶里分别连接 img.xxx.onlinemusic.xxx.online,连接成功后控制台会显示绿色 Active。Cloudflare 会在该域名的 DNS 区页中自动生成对应的 CNAME 记录(自动开启 CF 代理),不要自己去拼 pub-xxxx.r2.dev 的目标值。

Cloudflare R2 自定义域名已激活

随后,在博客内容仓库中进行一次全量正则替换,将所有旧的资源引用替换为新的解耦域名:

# 将旧的引用地址清洗为 music.xxx.online
find . -type f -name "*.md" -exec sed -i 's/music.dawn114514.site/music.xxx.online/g' {} +

对于代码仓库中的硬编码资源路径(如音乐播放器组件),同样需要做全量替换。

我的音乐播放器常量文件 src/components/widgets/music-player/constants.ts 中 32 首歌曲的资源 URL 均从 https://music.dawn114514.site/... 迁移至统一的 ${MUSIC_BASE_URL}/... 模式,其中 MUSIC_BASE_URL 定义为 https://music.xxx.online

资源解耦原则

切记:主站的归主站,资源的归资源

主站域名和资源域名尽量分开,不要交叉使用同一个顶级域名。资源独立出去之后,后续主站不管怎么折腾 DNS 或更换云厂商,文章里的静态资源链接都不需要动。

R2 CORS 跨域配置#

域名迁移后还需配置 CORS 策略,确保浏览器端对 R2 桶的跨域请求不被拦截。在 Cloudflare R2 控制台 → 存储桶 → Settings → CORS Policy 中,为两个存储桶分别添加如下 CORS 规则(整体是一个规则数组,注意最外层的 [ ]):

[
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": [],
"MaxAgeSeconds": 3600
}
]
音乐桶的原有自定义域名

原本绑定在音乐桶上的 music.dawn114514.site 自定义域名在迁移完成后即可删除 —— 因为此时所有引用地址已经改为 music.xxx.online,旧域名不再有任何流量。


2. DNS 大陆/境外智能解析分流#

主域名 dawn114514.site 迁离 Cloudflare,Nameservers 改为阿里云的 dns19.hichina.comdns20.hichina.com

在阿里云云解析后台,我对国内和国外线路做了 A 记录拆分,配置如下:

主机记录记录类型解析线路记录值TTL
@A默认 (兜底必配)xxx.xxx.xxx.xxx600s
@A中国内地xxx.xxx.xxx.xxx (国内 ECS)600s
@A中国境外yyy.yyy.yyy.yyy (境外 Oracle)600s
wwwA默认xxx.xxx.xxx.xxx600s
wwwA中国内地xxx.xxx.xxx.xxx600s
wwwA中国境外yyy.yyy.yyy.yyy600s

阿里云 DNS 境内外分线路解析记录

DNS 配置误区

配置智能解析时,“默认”解析线路是绝不可省的。如果只添加了”中国内地”和”中国境外”,当遇到某些无法识别来源的特定边缘节点请求时,可能会因为没有匹配记录而返回解析失败。默认线路直接解析到国内 ECS 即可。

为什么不用 Cloudflare DNS 分流?

虽然 Cloudflare 也有基于 智能地域解析 的流量导向功能,但阿里云 DNS 在中国大陆的分线路解析更精细(可按运营商、省份等粒度拆分)。对于国内/境外双线这种场景,阿里云的线路拆分能力完全是免费可用的,不需要额外付费。


3. GitHub Actions 自动化双端同步发布#

内容发布如果靠手动同步肯定会出问题。

我现在的 CI/CD 是内容/代码双仓库解耦的设计:内容仓库(MyBlog-Content)只管理文章、结构化数据与静态资源(posts/spec/data/images/),推送后由一个极简的 trigger-build.yml 通过 repository_dispatch 事件”摇醒”代码仓库(MyBlog)里负责实际构建和双端投递的 deploy.yml

这样一份内容推送,触发一次构建,并真正地以 Shell 后台 & + wait 的方式并行投递两台机器。

Dawn6666666
/
MyBlog-Content
Waiting for api.github.com...
00K
0K
0K
Waiting...
Dawn6666666
/
MyBlog
Waiting for api.github.com...
00K
0K
0K
Waiting...
为何这俩仓库卡片无法加载

因为我的博客仓库和内容仓库是Private的,所以 GitHub API 返回 404,导致卡片无法加载。

内容仓库触发器:trigger-build.yml#

内容仓库的 Workflow 极薄,只做一件事:在 posts/**spec/**data/**images/** 有变更时(也支持手动 workflow_dispatch),把 content-updated 事件 dispatch 给代码仓库。

name: Trigger Code Repo Build
on:
push:
branches:
- main
paths:
- "posts/**"
- "spec/**"
- "data/**"
- "images/**"
workflow_dispatch: {}
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Dispatch to code repo
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.DISPATCH_TOKEN }}
repository: Dawn6666666/MyBlog
event-type: content-updated

代码仓库部署器:deploy.yml#

代码仓库的 deploy.yml 同时承接三类触发:自身 custom-main 分支的 push、内容仓库 dispatch 来的 content-updated 事件、以及手动 workflow_dispatch

注意它加了 concurrency 取消旧运行、permissions 收紧权限、timeout-minutes 兜底,并在构建期注入内容同步令牌与媒体源密钥。

name: Deploy to Server
on:
push:
branches: [ custom-main ]
paths-ignore:
- "docs/**"
- "README*.md"
- ".github/**"
repository_dispatch:
types: [ content-updated ]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build-and-deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Restore Astro cache
uses: actions/cache@v4
with:
path: |
.astro
node_modules/.astro
key: ${{ runner.os }}-astro-${{ hashFiles('pnpm-lock.yaml', 'astro.config.mjs', 'src/config/**/*.ts') }}
restore-keys: |
${{ runner.os }}-astro-
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: Sync content repo
run: pnpm run sync-content
env:
ENABLE_CONTENT_SYNC: true
CONTENT_REPO_URL: https://x-access-token:${{ secrets.CONTENT_REPO_TOKEN }}@github.com/Dawn6666666/MyBlog-Content.git
CONTENT_SYNC_PULL: false
GIT_TERMINAL_PROMPT: 0
- name: Build site
run: pnpm run build
env:
ENABLE_CONTENT_SYNC: true
CONTENT_REPO_URL: https://x-access-token:${{ secrets.CONTENT_REPO_TOKEN }}@github.com/Dawn6666666/MyBlog-Content.git
CONTENT_SYNC_PULL: false
GIT_TERMINAL_PROMPT: 0
BILI_SESSDATA: ${{ secrets.BILI_SESSDATA }}
UMAMI_SHARE_URL: ${{ secrets.UMAMI_SHARE_URL }}
- name: Deploy to dual servers
env:
DEPLOY_PORT: 22
DEPLOY_DIR: /www/wwwroot/MyBlog
DEPLOY_HOST_CN: ${{ secrets.DEPLOY_HOST_CN }}
DEPLOY_USER_CN: ${{ secrets.DEPLOY_USER_CN }}
DEPLOY_SSH_KEY_CN: ${{ secrets.DEPLOY_SSH_KEY_CN }}
DEPLOY_HOST_OVERSEA: ${{ secrets.DEPLOY_HOST_OVERSEA }}
DEPLOY_USER_OVERSEA: ${{ secrets.DEPLOY_USER_OVERSEA }}
DEPLOY_SSH_KEY_OVERSEA: ${{ secrets.DEPLOY_SSH_KEY_OVERSEA }}
run: |
set -euo pipefail
deploy_one() {
local name="$1"
local host="$2"
local user="$3"
local key="$4"
if [ -z "$host" ] || [ -z "$user" ] || [ -z "$key" ]; then
echo "Missing deploy secret(s) for $name"
exit 1
fi
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$key" > ~/.ssh/deploy_key_"$name"
chmod 600 ~/.ssh/deploy_key_"$name"
ssh-keyscan -p "$DEPLOY_PORT" -H "$host" >> ~/.ssh/known_hosts
ssh -i ~/.ssh/deploy_key_"$name" -p "$DEPLOY_PORT" "$user@$host" \
"mkdir -p '$DEPLOY_DIR/dist'"
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key_$name -p $DEPLOY_PORT" \
--exclude=".user.ini" \
--exclude=".well-known" \
dist/ "$user@$host:$DEPLOY_DIR/dist/"
}
# 后台并行投递两台机器,最后用 wait 聚合退出码
deploy_one cn "$DEPLOY_HOST_CN" "$DEPLOY_USER_CN" "$DEPLOY_SSH_KEY_CN" &
pid_cn=$!
deploy_one oversea "$DEPLOY_HOST_OVERSEA" "$DEPLOY_USER_OVERSEA" "$DEPLOY_SSH_KEY_OVERSEA" &
pid_oversea=$!
status=0
wait "$pid_cn" || status=$?
wait "$pid_oversea" || status=$?
exit "$status"
显式同步是为了不吞掉内容同步失败

这里有个坑差点踩到:项目 package.json 里把内容同步挂在了 prebuild 钩子上,写的是 node scripts/sync-content.js || true

那个 || true 在本地开发时方便(同步失败也不挡你 build),但在 CI 里会把内容克隆/复制失败静默吞掉,于是构建会用缺失或旧内容继续出包、还部署上去,极其难排查。

因此 Workflow 里单独加了 Sync content repo 这一步,直接 pnpm run sync-content(这只跑 node scripts/sync-content.js,没有 || true 兜底),让 PAT 失效或内容仓库克隆失败时直接把任务标红,而不是把错误带进 build 和部署。

GitHub Actions 显式同步内容后成功部署

代码仓库静态检查:lint.yml#

代码仓库还配了一个手动触发的 lint.yml,用于发布前自检:用 Biome 跑 src/ 的 lint、pnpm run sync-content 同步内容做 Astro Check,最后再跑一次 pnpm build 验证整站能正常出包。

它只在 workflow_dispatch 下触发,不会干扰正常发布管线,适合改完前端代码不确定会不会炸时先手动跑一遍兜底。

这是动态扫描并直接信任,不是指纹核验

deploy_one 里用 ssh-keyscan 拉取远端公钥写入 known_hosts,这只是”无条件采信每次扫到的公钥”,并没有跟已核验的指纹比对。

注意这甚至比标准 TOFU(trust-on-first-use) 还弱——GitHub Runner 每次运行都是全新无状态环境,每次都重新扫描、重新信任,相当于”trust on every run”,若某次解析或链路遭遇中间人,伪造的公钥会被原样采信。

更稳妥的做法是把服务器本地用 ssh-keyscan 拿到的公钥、人工核对一遍后,以 base64 形式存成 GitHub Secret(如 SSH_KNOWN_HOSTS_CN),在 Workflow 中直接 appendknown_hosts,跳过自动扫描。这样即便某次连接遇到 MITM 也不会被欺骗。

个人博客可以接受这层风险敞口,但应知道它存在。

GitHub Secrets 配置清单#

双仓库解耦后,密钥分两处存放:内容仓库只持有一把用于跨仓库 dispatch 的令牌;代码仓库则承担真正的部署凭证与构建期注入密钥。

内容仓库 MyBlog-Content

Secret 名称说明示例值
DISPATCH_TOKEN用于跨仓库触发 repository_dispatch 的 PAT。Classic PAT 选 repo(私有仓库)或 public_repo(公开仓库);Fine-grained PAT 则需对目标代码仓库授予 Contents: writeghp_xxxxxxxxxxxxxxxxxxxxxxxx

代码仓库 MyBlog

Secret 名称说明示例值
CONTENT_REPO_TOKEN构建期拉取内容仓库的 PAT(含内容仓库 contents:readghp_xxxxxxxxxxxxxxxxxxxxxxxx
DEPLOY_HOST_CN国内服务器 IPxxx.xxx.xxx.xxx
DEPLOY_USER_CN国内服务器 SSH 用户admin
DEPLOY_SSH_KEY_CN国内服务器免密私钥(完整内容)-----BEGIN OPENSSH PRIVATE KEY-----...
DEPLOY_HOST_OVERSEA海外服务器 IPyyy.yyy.yyy.yyy
DEPLOY_USER_OVERSEA海外服务器 SSH 用户ubuntu
DEPLOY_SSH_KEY_OVERSEA海外服务器免密私钥(完整内容)-----BEGIN OPENSSH PRIVATE KEY-----...
BILI_SESSDATA构建期抓取 B 站数据所需的 SESSDATAxxxxxxxxxxxxxxxx
UMAMI_SHARE_URL构建期注入的 Umami 统计分享链接https://umami.xxx/share/xxxx

GitHub Actions Secrets 密钥配置管理面板截图。

为什么 DEPLOY_DIR 和 DEPLOY_PORT 不作为 Secret?

端口固定 22、部署目录固定 /www/wwwroot/MyBlog,这两项属于基础设施约定而非敏感信息。将它们硬编码在 Workflow 中有助于减少 Secret 数量,降低配置出错的概率。如果需要灵活性,可以在 Workflow 文件中以环境变量形式定义。

坑 1:国内机密钥带 Passphrase 导致部署失败与 CI 挂起#

我原来图省事,直接拷贝了国内阿里云服务器上现有的私钥文件(~/.ssh/id_ed25519)内容并填入 GitHub Secrets 的 DEPLOY_SSH_KEY_CN。然而,在 Actions 执行部署时,一直卡住并最终报错失败。(当时前 3 次全部红灯报错,急哭了说是😡)

后来在服务器本地自测该私钥才发现,原来这把密钥在初次生成时被设置了密码保护。

在 GitHub Actions 这种非交互式的 CI 自动化环境下,SSH 握手因为需要输入密码而无法继续,最终导致部署直接被拒或挂死。

这份 Workflow 里的私钥必须免密

GitHub Actions Runner 是一个完全无头的自动执行环境。当 SSH 私钥设置了密码保护后,SSH 客户端会尝试弹出密码提示框或读取终端 /dev/tty

我这版 Workflow 没有配 ssh-agent/askpass,所以在 CI 中完全行不通,最终会导致进程因为等待凭证输入而直接失败或无限挂起。

因此在我这套没接 ssh-agent 的部署脚本里,写入 GitHub Secret 的那把私钥本身必须是免密的(追求更安全的话,可改为短时效、最小权限、定期轮换的免密部署密钥,或者用 ssh-agent/SSH_ASKPASS 方案把 passphrase 也注入,这里为简化没做)。

解决办法:

重新在本地生成一对免密的专属 Ed25519 密钥对,把公钥追加到服务器的授权名单中:

Terminal window
# 本地生成免密 Ed25519 密钥对(PowerShell)
ssh-keygen -t ed25519 -f $env:USERPROFILE\.ssh\myblog-cn -C "github-actions-deploy-cn"
# 注意:提示输入 passphrase 时直接回车留空!

指纹审计核对:国内节点公网密钥的指纹为 SHA256…,部署前务必核对确认与服务器 authorized_keys 中写入的公钥指纹一致。指纹不匹配是 CI 连接失败的常见原因之一。

将私钥内容存入 GitHub Secrets 的 DEPLOY_SSH_KEY_CN,公钥则要从本地 Windows 机器传到服务器并追加进 authorized_keys

注意:上面 ssh-keygen 是在本地生成的,服务器上并没有 ~/myblog-cn.pub,不能直接 cat

Terminal window
# 在本地 Windows 上:把公钥管道推给远端服务器追加进 authorized_keys
type $env:USERPROFILE\.ssh\myblog-cn.pub | ssh admin@xxx.xxx.xxx.xxx "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
NOTE
  • 不想用 PowerShell 管道的话,也可以先用 scp $env:USERPROFILE\.ssh\myblog-cn.pub admin@xxx.xxx.xxx.xxx:~/myblog-cn.pub 把公钥传上去,再 ssh 进去执行 cat ~/myblog-cn.pub >> ~/.ssh/authorized_keys
  • mkdir -p ~/.ssh 在目录已存在时不会报错,也不影响已有的 authorized_keys 文件,安全无害。
Secret 更新后记得触发重跑

这个坑导致我前 3 次部署全部失败,因为密钥替换后没有及时更新 GitHub Secret。在 GitHub 仓库的 Settings → Secrets and variables → Actions 中直接编辑(Update)对应 Secret、粘贴完整的新私钥内容即可,GitHub 会覆盖旧值,不需要删除后重建。更新后手动触发一次 Workflow 验证连通性。

坑 2:rsync 擦除宝塔锁文件 .user.ini 导致权限报错#

国内机用了宝塔面板,默认会在网站根目录下放一个防跨站的锁文件 .user.ini (宝塔面板:“我是为了你好!”;rsync:“你妨碍我全删全清了!”) (系统用 chattr +i 给文件加了 不可变属性防护,连 root 默认都改不动)。

Actions 里的 rsync -az --delete 机制在同步时发现本地没有这个文件,试图在远端将其强制抹除,结果直接权限报错导致发布管线崩溃。

解决办法:

rsync 投递命令中加入 --exclude=".user.ini" --exclude=".well-known",排除这些系统级只读文件以及 Let’s Encrypt 的验证目录。

坑 3:PowerShell 变量展开破坏 Nginx 配置#

在通过 SSH 远程写入 Nginx 配置文件时,如果使用双引号字符串或未加引号的 here-document,PowerShell 会把 $uri$uri/$host 等 Nginx 内置变量当作 PowerShell 变量展开,导致写入服务器的配置文件变成乱码。

跨平台字符串传递的隐形地雷

Windows 端的 PowerShell 会展开 $ 开头的 token,而 Nginx 配置中大量使用这些变量。一旦被展开,try_files $uri $uri/ /index.html 可能变成 try_files / /index.html(空值替换),导致 Nginx 语法检查失败。

解决办法:

PowerShell 没有 Bash 的 << 'EOF' heredoc,要用单引号 here-string(@'...'@,禁止 $ 展开)把整段脚本拼好,再经管道交给远程的 bash -s

Terminal window
# PowerShell:单引号 here-string + 管道传给远端 bash
$config = @'
cat > /tmp/nginx.conf << 'NEOF'
location / {
try_files $uri $uri/ /index.html;
}
NEOF
'@
$config | ssh user@host "bash -s"

要点:

  • 本地用 @'...'@(单引号)hold 住字符串,PowerShell 不会展开里面的 $uri/$host
  • 远端 bash -s 从 stdin 读到内容后再用 << 'NEOF'(单引号)把 $uri 留给远端 bash 不展开,从而原样写进 Nginx 配置

4. 两端服务器环境配置与安全优化#

国内机:移除老旧的 TLSv1.1 协议#

国内节点使用的是宝塔环境,Nginx 版本为 1.28.1。在排查站点配置时,我发现旧的站点文件里还在支持已经不安全的 TLSv1.1 协议。

直接编辑对应的虚拟主机配置文件 /www/server/panel/vhost/nginx/html_xxx.xxx.xxx.xxx.conf,将老旧的 ssl_protocols 过滤收紧:

# 剔除 TLSv1.1,仅放行主流安全的版本
ssl_protocols TLSv1.2 TLSv1.3;
宝塔面板 Nginx 配置的查找方式

宝塔面板生成的站点配置文件位于 /www/server/panel/vhost/nginx/ 目录下,文件名格式为 html_xxx.xxx.xxx.xxx.conf。修改后务必执行 nginx -t 做语法检查,再 systemctl reload nginx 使配置生效。不要直接在宝塔面板 UI 中编辑后再手动改文件,容易产生冲突。

国内服务器优化后的典型 Nginx 站点配置参考:

server {
listen 80;
listen 443 ssl;
listen 443 quic;
http2 on;
server_name www.dawn114514.site dawn114514.site;
index index.html index.htm;
root /www/wwwroot/MyBlog/dist;
include /www/server/panel/vhost/nginx/extension/xxx.xxx.xxx.xxx/*.conf;
include /www/server/panel/vhost/nginx/well-known/xxx.xxx.xxx.xxx.conf;
ssl_certificate /www/server/panel/vhost/cert/xxx.xxx.xxx.xxx/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/xxx.xxx.xxx.xxx/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_tickets on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# 启用 HTTP/3 (QUIC) 支持以降低连接延迟
add_header Strict-Transport-Security "max-age=31536000";
add_header Alt-Svc 'quic=":443"; h3=":443"';
error_page 497 https://$host$request_uri;
location / {
try_files $uri $uri/ /index.html;
}
# 针对自建服务 A 与自建服务 B 的 WebSocket 反向代理转发
# 依赖外部 0.websocket.conf 的全局 Upgrade map 指令
}

海外机:零面板环境下的安全加固与配置#

海外机用的是纯净 Ubuntu 系统,没有任何面板辅助。操作步骤如下:

第一步:安装基本服务

sudo apt-get update
sudo apt-get install -y nginx certbot python3-certbot-nginx

第二步:创建站点配置文件并测试

/etc/nginx/sites-available/myblog 中编写初始配置(仅 HTTP),确认 nginx -t 通过后 systemctl reload nginx

第三步:利用 certbot 一键申请并修改配置

sudo certbot --nginx -d dawn114514.site -d www.dawn114514.site \
--agree-tos --register-unsafely-without-email --non-interactive

该命令会自动在 /etc/nginx/sites-available/myblog 中添加 Let’s Encrypt 证书引用和 HTTP 强制跳转 HTTPS。

Certbot 非交互模式

--non-interactive 标志让 certbot 在无终端环境下自动完成证书申请,不需要手动输入邮箱或同意协议。加上 --register-unsafely-without-email 可以跳过邮箱注册步骤(适用于个人项目,生产环境建议填写真实邮箱以便接收证书过期提醒)。

第四步:突破防火墙双重过滤限制

海外机有两层防火墙需要穿透:

层级位置配置方式
OS 级别Ubuntu iptables命令行 iptables -I INPUT
云平台级别Oracle OCI Security ListWeb 控制台添加入站规则

OS 级别 iptables 配置:

Iptables 尾部追加规则失效陷阱

Ubuntu 镜像在 iptables 中默认有一行拒绝规则:-A INPUT -j REJECT --reject-with icmp-host-prohibited,位于规则链的最底部。如果你习惯性地使用 iptables -A 往表尾追加 80/443 放行命令,这些命令排在 REJECT 后面会彻底失效。必须使用 -I INPUT 明确插到那个 REJECT 规则之前的位置才会起作用

# 先查看当前规则和行号,定位 REJECT 行所在的位置(注意用 -L 而不是 -S,-L 才支持 --line-numbers)
sudo iptables -L INPUT -n --line-numbers
# 假设查到 REJECT 在第 6 行,就把 80/443 的 ACCEPT 插到它前面
# 先在第 6 行处插入 80 放行(这条会顶替原第 6 行的位置,REJECT 顺延到第 7 行)
sudo iptables -I INPUT 6 -p tcp --dport 80 -j ACCEPT
# 再在第 7 行处插入 443 放行(在 REJECT 之前)
sudo iptables -I INPUT 7 -p tcp --dport 443 -j ACCEPT
# 实操时请把行号替换为你机器上 REJECT 所在的真实编号,不要照抄
# 验证规则顺序是否正确(ACCEPT 必须在 REJECT 前面)
sudo iptables -L INPUT -n --line-numbers
# 重启后规则会丢失,需要持久化
sudo apt-get install -y iptables-persistent
sudo netfilter-persistent save

云服务器平台防火墙放行:

除了系统内部的 iptables,甲骨文云在云平台外层还有一道安全规则守护。

我们需要登录 Oracle Cloud Web 控制台,在机器关联的网络安全列表中直接添加两条简单的入站规则来放行流量:

协议目的端口范围源 IP CIDR说明
TCP800.0.0.0/0允许 HTTP 流量访问博客
TCP4430.0.0.0/0允许 HTTPS 加密流量访问博客

Oracle Cloud 的 Always Free 资源包括每月 10TB 出站流量,对于个人博客来说完全够用。

海外节点优化后的站点配置文件(/etc/nginx/sites-available/myblog)结构:

server {
server_name dawn114514.site www.dawn114514.site yyy.yyy.yyy.yyy _;
root /www/wwwroot/MyBlog/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# 对静态资源文件启用 30 天超长缓存,优化首屏加载
location ~* \.(?:css|js|mjs|json|xml|txt|ico|png|jpg|jpeg|gif|webp|avif|svg|woff2?|ttf|otf|mp3|flac|ogg)$ {
try_files $uri =404;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}
error_page 404 /404.html;
# Certbot 自动化注入的 SSL 配置
listen [::]:443 ssl ipv6only=on;
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/dawn114514.site/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dawn114514.site/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
if ($host = www.dawn114514.site) {
return 301 https://$host$request_uri;
}
if ($host = dawn114514.site) {
return 301 https://$host$request_uri;
}
listen 80 default_server;
listen [::]:80 default_server;
server_name dawn114514.site www.dawn114514.site yyy.yyy.yyy.yyy _;
return 404;
}

运维验证百宝箱#

重构阶段使用最频繁的几个高频调试命令:

1. 绕过 DNS 缓存强制回源测试#

当域名刚切完 DNS,本地运营商的解析还没生效时,我们可以使用 curl--resolve 参数,强行将目标请求解析到特定的物理 IP 上,用于检验证书配置是否正常:

# 回源到海外机房测试
curl -I --resolve dawn114514.site:443:yyy.yyy.yyy.yyy https://dawn114514.site/
# 回源到国内机房测试
curl -I --resolve dawn114514.site:443:xxx.xxx.xxx.xxx https://dawn114514.site/
--resolve 的工作原理

curl --resolve 会在本地覆盖 DNS 解析结果,将指定域名的请求直接发送到指定 IP。这条命令不依赖系统 DNS,非常适合在 DNS 切换后的过渡期做证书和连通性验证。测试通过后,可以放心等待全球 DNS 缓存自然过期(实际生效时间取决于记录本身的 TTL、NS 迁移情况,以及递归解析器缓存——可能从几分钟到几小时不等)。

2. 证书基本指纹和 SAN 主机名查看#

各自签好证书后,检查本地配置物理文件的发行商及有效期限,防止低级配置错误:

sudo openssl x509 -in /etc/letsencrypt/live/dawn114514.site/fullchain.pem \
-noout -subject -issuer -dates -ext subjectAltName

3. 验证两端服务器返回的 HTTP 头#

确认两端 Nginx 配置正确、证书有效后,还可通过比较响应头验证两端配置一致性:

# 比较两端响应头(Server / Cache / HSTS 等)
diff <(curl -sI --resolve dawn114514.site:443:xxx.xxx.xxx.xxx https://dawn114514.site/) \
<(curl -sI --resolve dawn114514.site:443:yyy.yyy.yyy.yyy https://dawn114514.site/)

通过 Bash 的 进程替换 特性,我们无需创建临时文件,便可直接比对过滤两端服务器返回的 Header 差异。

4. 测试 DNS 分流是否生效#

从不同地理位置的节点执行解析测试:

# 在境外机器上查询(境外真实环境解析)
nslookup dawn114514.site
# 在国内机器上查询阿里 DNS(境内解析)
nslookup dawn114514.site 223.5.5.5
不要用 “向 8.8.8.8 查询” 当作”模拟境外”

Google Public DNS 是 Anycast,并且会通过 EDNS Client Subnet (ECS) 把客户端网段带给权威 DNS。因此同一台国内机器向 8.8.8.8 发起查询,阿里云 DNS 收到的 ECS 子网仍是国内的,往往仍返回国内线路 IP,并不能”模拟境外解析”。

要真正验证境内外分流,应该用境外的真实探针(比如海外 VPS 直接 nslookup),或用支持显式指定 edns_client_subnet 的 DoH 测试:

# 用 Google DoH JSON API,URL 参数指定一个真实位于境外的公网 IP 网段做 ECS 测试
# (把 REAL_OVERSEAS_IP 换成一台已知在境外的真实公网 IP,不要用 203.0.113.0/24 这类文档保留网段)
curl -sG "https://dns.google/resolve" \
--data-urlencode "name=dawn114514.site" \
--data-urlencode "type=A" \
--data-urlencode "edns_client_subnet=REAL_OVERSEAS_IP/24"
别用 1.1.1.1 做 ECS 测试

Cloudflare 1.1.1.1 默认向权威 DNS 转发 ECS,且其 DoH JSON API 也不支持通过 HTTP Header 传 EDNS-Client-Subnet,用 1.1.1.1/dns-query 测分线路是测不出来的。要做这种”指定客户端网段模拟地理来源”的测试,得用 Google DoH 的 edns_client_subnet 参数,且网段用一个真实境外公网地址,203.0.113.0/24 这种文档保留网段是不会被识别为境外的。

预期结果:境外真实解析返回 yyy.yyy.yyy.yyy(落 Oracle),境内解析返回 xxx.xxx.xxx.xxx(落 ECS)。


改造后状态与双节点分流总结#

通过本轮的架构优化,博客构建了就近接入的双节点分流发布网络:阿里云 DNS 按境内外分线路把请求导到不同节点,国内访客落 ECS、境外访客落 Oracle,各自降低首屏延迟。

这不是高可用,是手动容灾

需要诚实说明的是:当前架构没有健康检查、没有自动故障转移。阿里云智能解析本身也明确”分线路解析不会自动剔除故障 IP、不是故障转移方案”。

因此这套属于双节点分流 + 手动容灾:当某节点不可用时,需要人工到 DNS 控制台把该线路的 A 记录指向另一健康节点,再等 TTL(600s)过期生效。

要真正实现高可用,还需补上健康检查、原子发布目录切换与自动 DNS 切换器,这些留作后续优化。

并发部署的一致性边界

deploy.ymlconcurrency.cancel-in-progress: true 取消旧运行,而 rsync 是直接覆盖线上 dist 目录。

如果一次部署被中途取消,或双端中只有一端 rsync 成功,可能出现线上目录半更新,或双端版本不一致。

更稳妥的方案是 rsync 到带版本号的目录再用软链接原子切换,当前我暂未做这层,接受手动回滚的成本。

重构后的最终架构对比:

评估维度重构前状况重构后状况
主站域名解析Cloudflare DNS (国内响应偏慢)Alibaba Cloud DNS
就近分流策略大陆与境外一律兜底打到国内 ECS智能双线:国内落 ECS,境外落 Oracle
音乐静态资源music.dawn114514.site (与主域名强绑定)music.xxx.online (解耦至 xxx.online,资源域名与故障域独立)
图床静态资源独立域名 img.xxx.online (未迁移)保持独立,仅把音乐子域一并迁出
持续集成效率单台机器手动/脚本部署,易出版本差内容/代码双仓库解耦:repository_dispatch 联动 + 后台并行 &/wait 双端同步投递
传输层安全级别允许 TLSv1.1 老旧协议接入完全淘汰 TLSv1.1,强制 TLSv1.2 + TLSv1.3
抗灾能力机器挂掉则全网直接瘫痪双节点分流 + 手动容灾:单节点宕机时人工切换 DNS 解析至另一健康节点,TTL 过期后生效

结语#

在服务架构设计中,保持职责单一和适度解耦是一条黄金法则。

这次把博客拆成双节点就近接入的架构,虽然在期初多配置了几个域名解析和免密部署规则,但换来的是主站应用较强的迁移弹性、国内外首屏延迟的降低,以及多媒体资源带宽不再挤占主站的保障。

回顾整个过程,真正耗时最多的不是写配置本身,而是排查那些跨平台的隐性陷阱——非交互式环境的 SSH passphrase 部署失败与挂起、PowerShell 的变量展开污染、iptables 的规则顺序陷阱、以及云服务器平台外层安全规则拦截。

这些坑每个单拎出来都不复杂,但它们组合在一起的时候,就构成了部署效率的真正瓶颈。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

博客国内外双节点部署与全链路架构解耦
https://github.com/Dawn6666666/MyBlog
作者
黎明
发布于
2026-05-19 21:48:21
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录