一次真实的博客迁移:从阿里云到腾讯云,顺手把图片搬进了 COS
这篇文章记录了我把「渣渣辉的小站」从阿里云迁到腾讯云的全过程:Halo 2.21 重新部署、域名卡在备案、小程序接口从 1.x 迁到 2.x、141 张封面图搬到对象存储、插件配置、定时备份。
全程踩了不少坑,代码和命令都留在下面,希望能让后来的人少走两步弯路。
一、起因:为什么要搬
原来的架构很简单,也很"经典":阿里云一台服务器跑 Halo,图片单独扔在七牛云,绑了个自定义域名做图床,小程序直接调 Halo 的接口。
问题出在到期续费和备案这两件事上。旧服务器不打算续了,新服务器在腾讯云(轻量应用服务器,北京区),于是整站迁移被提上日程。
我最初以为这是"复制粘贴 + 改个 IP"的活,实际做下来发现,真正耗时间的从来不是搬家本身,而是搬家时暴露出来的所有历史欠账。
最终的目标架构是这样:
| 层次 | 迁移前 | 迁移后 |
|---|---|---|
| 服务器 | 阿里云 ECS | 腾讯云轻量应用服务器(北京) |
| 博客程序 | 旧版 Halo | Halo 2.21(1Panel + Docker 部署) |
| 数据库 | 旧库 | MySQL 容器,独立数据卷 |
| 图片存储 | 七牛云 + 自定义域名 | 腾讯云 COS |
| 小程序接口 | Halo 1.x API | Halo 2.x 公开内容 API |
| 备份 | 手动 | 每日 03:30 自动备份到 COS |
二、第一步:把 Halo 2.21 跑起来
新服务器上用 1Panel 的应用商店装 Halo,本质上是起两个容器:一个 halo,一个 mysql,数据目录挂载到宿主机。
这一步没什么技术含量,但有两个习惯我建议你养成:
1. 数据库单独起容器,不要用 Halo 内置的 H2。 H2 文件库在容器里一旦挂载出问题,恢复起来非常痛苦;MySQL 至少可以随时 mysqldump。
2. 数据目录一定要挂到宿主机。 容器的生命周期是不可靠的,/opt/1panel/apps/halo/halo/data 这种宿主机路径才是你真正的家底。
装完之后,把旧站导出的备份(Markdown + 附件 + 数据库)导入,站点就"活"过来了。
三、第二步:域名卡在备案,只能先用 IP 顶着
这是整个迁移里唯一一个纯技术解决不了的问题。
域名解析到新服务器后,访问会 302 跳到一个"网站未备案"的拦截页;HTTPS 更干脆,TLS 握手阶段直接 ERR_CONNECTION_RESET。
原因不复杂:境内服务器 + 未备案域名 = 80/443 被合规拦截。这不是配置问题,也不是证书问题,等备案下来之前,任何折腾都是白费。
我的临时方案:
- 网站端:直接走
http://<服务器IP>,能用,但不体面。 - 小程序端:
app.js里的baseUrl从域名改成 IP,并在开发者工具里勾选「不校验合法域名、TLS 版本以及 HTTPS 证书」。
// app.js —— 备案期间的临时配置
baseUrl: 'http://49.233.54.78',
// 备案通过后换回:
// baseUrl: 'https://你的域名',
这里有个坑值得记一笔: 小程序真机预览和开发者工具的行为并不完全一致。工具里勾了"不校验合法域名"能跑,真机上依然会被拦。所以备案期间只适合自己调试,不适合给别人体验。
另外一个容易忽略的点:网站的 ICP 备案和小程序的备案是两件事。 网站备案下来之后,小程序还需要在小程序后台单独提交一次备案,别以为一次搞定就万事大吉。
四、第三步:小程序白屏 —— 1.x 到 2.x 的接口断层
网站能访问了,小程序却调不出数据。这是迁移里最有价值的一段经验。
Halo 从 1.x 升到 2.x,不只是界面换了,公开 API 整个重写了。旧代码里的接口路径和字段结构,在 2.x 上全部失效。
4.1 接口路径变了
| 用途 | 1.x | 2.x |
|---|---|---|
| 文章列表 | /api/content/posts |
/apis/api.content.halo.run/v1alpha1/posts |
| 文章详情 | /api/content/posts/{id} |
/apis/api.content.halo.run/v1alpha1/posts/{name} |
2.x 的路径是 Kubernetes 风格的资源路径,又长又丑,但好处是语义清晰:api.content.halo.run 是 API 组,v1alpha1 是版本,posts 是资源。
还有一个细节:详情接口的参数从 id 变成了 name,也就是列表项里的 metadata.name(等同于文章的 slug)。拿 1.x 的 id 去请求 2.x 的详情,只会得到 404。
分页参数倒是友好,page 从 0 开始,size 是每页条数:
function getPosts(page, size) {
return request('/apis/api.content.halo.run/v1alpha1/posts', {
page: page || 0,
size: size || 10
})
}
4.2 返回结构也变了
1.x 的列表项里套着一个 post 对象,字段都在里面;2.x 把这个嵌套层去掉了,metadata / spec / status / content / stats / categories 全部平铺在每一项的顶层。
// 2.x 的结构(注意没有 post 这一层)
{
metadata: { name: 'hello-world' }, // slug
spec: {
title: '文章标题',
cover: 'https://...',
publishTime: '2026-10-28T10:00:00Z',
owner: { displayName: '渣渣辉' }
},
status: { excerpt: '摘要...' },
content: { content: '<p>正文 HTML</p>' }, // 列表接口里为 null
stats: { visit: 128 },
categories: [{ spec: { displayName: '技术' } }],
tags: []
}
两个必须知道的点:
- 列表接口返回的
content.content是null,正文只在详情接口里给。所以别指望列表页能拿到正文做摘要,摘要要用status.excerpt,或者自己从详情里截。 - 分类和标签是对象数组,名字在
spec.displayName里,不是直接一个字符串。
改造方式很朴素:写一层归一化函数,把 2.x 的结构转换成小程序页面认识的老字段,这样 wxml 一行都不用改。
function normListPost(item, base) {
var spec = item.spec || {}
var status = item.status || {}
return {
slug: (item.metadata || {}).name || '',
title: spec.title || '(无标题)',
cover: absolutize(spec.cover, base),
summary: stripHtml(status.excerpt || ''),
date: (spec.publishTime || '').replace('T', ' ').slice(0, 10),
visits: (item.stats && item.stats.visit) || 0,
categories: (item.categories || [])
.map(function (c) { return (c.spec && c.spec.displayName) || '' })
.filter(Boolean)
}
}
小提示:2.x 的
publishTime是带时区的 ISO 字符串,直接slice(0, 10)取到的是 UTC 日期。对博客来说差这几小时通常无所谓,但如果你的文章经常在深夜发布,最好显式做一次时区转换。
五、第四步:图片还躺在七牛云,封面全挂了
数据通了之后,最刺眼的问题来了:所有封面图都是裂的。
图片当年上传在七牛云,绑的是旧站时期的一个自定义域名。这个域名现在指向的是早已下线的阿里云旧站,解析过去全站 403 —— 也就是说,链接还在,图没了。
5.1 为什么不能"换个域名就行"
很多人第一反应是:把图片域名换一个新的不就好了?
不行。因为你换的只是域名,图片本体还在七牛的存储空间里。老域名一停,等于图的"门牌号"作废了,而新门牌号必须指向同一批文件。
所以正确的顺序是:先把文件搬走,再改链接。
七牛当时还有一个限制:自定义域名必须备案,而临时测试域名只有 30 天有效期。这等于给了个倒计时——要么在 30 天内把图搬完,要么等域名失效后彻底没辙。
5.2 迁移的实操步骤
- 在腾讯云 COS 建桶,选北京区(和服务器同地域,走内网免流量费),权限设为「公有读私有写」。博客图片是给所有人看的,私有读会让小程序和网页都加载不出来。
- 从七牛导出:用控制台的批量操作把原空间的文件移动到一个归档空间,再从归档空间批量下载。
- 上传到 COS:直接
PUT,保留原始文件名(这点很重要,见下一步)。 - 改数据库里的链接。
第 4 步是重点。全站所有图片引用都存在数据库里,分散在文章正文、封面字段、设置项等地方。我是直接在库里做替换的:
UPDATE extensions
SET spec = REPLACE(spec, 'https://旧图床域名/', 'https://新桶域名/')
WHERE spec LIKE '%旧图床域名%';
执行前务必先备份:
mysqldump -u root -p halo_db > /root/extensions_backup_$(date +%Y%m%d_%H%M%S).sql
这次一共处理了 230 行记录,成功替换 229 行。剩下那 1 行不是替换失败,而是早期就存了一个截断的坏链——这种历史遗留问题,只能等哪天肉眼看到图裂了再手工修。
另外还有一个隐蔽的坑:图片文件名里如果带括号,用正则批量提取链接时很容易被截断,导致 URL 变成半截。这次 146 处引用里,最后只有 141 张真正搬到位,剩 5 处全是这种带括号的截断链接。批量处理文件名时,用解析器不要用正则。
5.3 顺手把"以后上传"也解决掉
图片搬完了,但如果后台发文时上传还是往本地磁盘塞,那过不了多久又得搬一次。
Halo 2.x 用插件解决这个问题:plugin-s3。装它的过程,比想象中曲折。
第一个坑:版本兼容。
| 插件版本 | 要求 Halo 版本 |
|---|---|
| 1.17.0 | ≥ 2.24 |
| 1.14.0 | ≥ 2.22 |
| 1.13.2 | ≥ 2.21 |
我的站点是 2.21.0,只能用 1.13.2。新版插件在低版本 Halo 上装不上,报的错还不明显,很容易以为是插件坏了。装插件前先看版本要求,这是硬门槛。
第二个坑:上传 403。
插件装好、密钥填好,测试上传却报 403 Forbidden,错误信息指向 HeadObject。
排查后发现是两个原因叠加:
- 子账号权限不全。 COS 的权限分两层:桶级(列出桶、访问桶元信息)和对象级(读写具体文件)。只给对象级权限,插件在初始化时去查桶信息就会 403。策略里两级都要写:
{
"version": "2.0",
"statement": [
{
"effect": "allow",
"action": ["cos:*"],
"resource": [
"qcs::cos:ap-beijing:uid/1250000000:bucket-name/*",
"qcs::cos:ap-beijing:uid/1250000000:bucket-name/"
]
}
]
}
注意第二个 resource 结尾是 / 而不是 /* —— 这就是桶本身的权限。
- 访问方式选错了。 插件里有个
enablePathStyleAccess开关,默认走 Path Style(域名/桶名/文件)。腾讯云 COS 只认 Virtual Hosted Style(桶名.域名/文件)。这个开关必须关掉,否则签名永远对不上。
第三个坑:插件启用了,但不起作用。
后台显示插件已启用,上传依然落在本地。原因是没设默认附件策略。插件只是提供了"可以用 COS"的能力,但博客的默认存储还是本地。
Halo 2.x 的默认附件策略存在一个系统级 ConfigMap 里,后台界面上不一定能直接改。我是直接改数据库,然后重启容器生效的:
docker restart 1Panel-halo-lGUB
改完之后传了一张测试图,公网访问 200,才算真正跑通。最后把测试附件删干净,媒体库回到 139 个文件的干净状态。
六、第五步:备份是最后一根安全绳
前面所有操作里,我最怕的不是失败,是不可逆的失败。所以备份是和迁移同步做的,不是等"有空再说"。
方案很简单:每天凌晨 3:30,用 cron 跑一个脚本,把数据库和 Halo 数据目录打包,传到 COS 的不同前缀下。
# /etc/cron.d/halo-backup
30 3 * * * root /root/halo_backup.sh >> /var/log/halo_backup.log 2>&1
脚本里做三件事:
mysqldump导出数据库,gzip 压缩tar打包 Halo 的 data 目录(文章、附件、配置)- 用
coscli上传到backup/db/和backup/halo2/
coscli cp "/tmp/db_backup.sql.gz" \
"cos://bucket-name/backup/db/$(date +%Y%m%d).sql.gz"
有两个细节值得说:
- 备份用的密钥,权限只要"能写进 backup 目录"。 绝对不要图省事用主账号密钥,更不能给
cos:*全桶权限。备份脚本是长期自动运行的,它是整个系统里最容易被忽略的提权入口。 - 备份要验证,不是跑完就算成功。 我第一次跑完看着日志全绿,还是去 COS 控制台把文件下载下来解压了一遍,确认能还原才算数。从来没验证过的备份,等于没有备份。
同时做了一次密钥收尾:
- 创建子账号,只给 COS 的最小权限
- 插件、备份脚本全部换成子账号密钥
- 确认一切正常后,把主账号的两把密钥禁用
顺序很重要——先建立替代方案,再收回旧权限。反过来做,中间那段时间你就是拿不到钥匙但站在门外的状态。
七、第六步:小程序界面重做
功能通了之后,界面还是原样:白底卡片、纯文字、连 tabBar 都是光秃秃的。
老实说功能能用的时候,视觉是最容易被推迟的部分。但小程序这个场景不太一样——用户第一眼看到的就是界面,丑就是丑,没有第二次机会。
这次重做的几个点:
- 配色统一到一套变量。之前颜色是散落在各个 wxss 里的硬编码,改一处要翻五个文件。现在全部抽到
app.wxss的 CSS 变量里。 - tabBar 补上图标。没有图标的 tabBar 看起来像个半成品。
- 首页改成杂志式布局:第一篇用大图精选卡,其余用"左文右图"紧凑卡片。这样既保证首屏有视觉冲击,长列表又不会因为全是巨图而滚动疲劳。
- 详情页做沉浸式:封面大图打底、标题压图上、正文白卡圆角叠压,顶部加阅读进度条。
这里遇到一个小程序特有的问题,值得单独说:rich-text 组件内部的元素,不受 wxss 控制。
也就是说,你写 .content p { line-height: 1.8 },对 rich-text 渲染出来的 <p> 完全无效。它只认内联样式。
解决办法是在把 HTML 交给 rich-text 之前,先遍历给每个标签注入样式:
function decorateHtml(html) {
// 代码块单独处理,避免内部 code 被行内样式污染
var pres = []
html = html.replace(/<pre([^>]*)>([\s\S]*?)<\/pre>/gi, function (m, attrs, inner) {
pres.push('<pre' + attrs + ' style="background:#1e2130;color:#e8eaf4;' +
'padding:12px;border-radius:8px;overflow-x:auto;">' + inner + '</pre>')
return '\u0000PRE' + (pres.length - 1) + '\u0000'
})
html = html.replace(/<p([^>]*)>/gi, '<p$1 style="margin:0 0 13px;line-height:1.85;">')
html = html.replace(/<h2([^>]*)>/gi, '<h2$1 style="font-size:18px;font-weight:700;' +
'border-left:4px solid #4f46e5;padding-left:10px;">')
html = html.replace(/<img([^>]*?)\/?>/gi, function (m, attrs) {
return '<img' + attrs + ' style="max-width:100%;height:auto;display:block;margin:14px auto;border-radius:8px;">'
})
return html.replace(/\u0000PRE(\d+)\u0000/g, function (m, i) { return pres[+i] })
}
用占位符先"隔离"代码块,是为了避免给 <pre> 里的 <code> 又加一层行内代码的背景色和 padding,那样渲染出来会非常难看。
八、现在的状态
- 网站和小程序都正常访问,58 篇文章、141 张图片全部迁到新环境
- 图片上传走 COS,后台发文直接存对象存储
- 每天 03:30 自动备份,已验证可还原
- 主账号密钥已禁用,日常运行全部使用最小权限子账号
- 小程序界面完成改版
还没做完的只有一件事:备案。
这是唯一一个技术手段解决不了的问题。备案下来之后,还需要:
- 把
app.js的baseUrl从 IP 换回域名 - 取消开发者工具的"不校验合法域名"选项
- 在小程序后台单独提交一次小程序备案
- 把 COS 域名加入小程序的
downloadFile合法域名列表
SSL 证书 2027 年 1 月到期,记得提前开自动续期——这种一年才提醒一次的事情,靠记性是最不可靠的。
九、复盘:五条经验
1. 迁移的难点从来不在"搬",而在"搬的时候暴露出来的旧债"。
真正花时间的不是部署 Halo,是七牛的老域名、截断的图片链接、1.x 时代的接口调用。平时能跑的代码,换个环境就全是坑。
2. 版本兼容是插件生态的第一道坎。
Halo 插件有明确的版本要求,"最新版"往往是你装不上的那个。先查兼容矩阵,再动手。
3. 云服务的权限是分层的。
桶级和对象级、账号级和资源级,少给一层就是 403。报错信息通常不会告诉你少了哪一层,只能一层层试。
4. 先建立替代,再收回旧权限。
密钥轮换、域名切换、服务迁移,都是这个顺序。反过来做,中间会出现一段"旧的不能用、新的还没好"的空窗期,而这段时间往往正好出问题。
5. 备份要验证,还要用最小权限的密钥。
没验证过的备份只是心理安慰,权限过大的备份脚本是长期存在的安全隐患。
迁移这件事有个特点:过程很琐碎,回头看又很清晰。
真正让我印象深刻的不是某个技术点,而是"所有以为没问题的地方都会出问题"这件事本身。旧域名、旧接口、旧链接、旧权限——它们在原地待了两年没出过事,一旦换了环境,全部同时爆发。
所以如果让我给一句建议:迁移之前,先把"哪些东西是隐式依赖"列一遍。 图片域名、接口路径、定时任务、硬编码的 IP、没写进文档的手工配置——这些才是真正决定迁移要花多久的东西。
本文记录于 2026 年 10 月,Halo 2.21.0 / 腾讯云轻量应用服务器 / COS 北京区。