图床迁移记录
引言
在前一篇文章《Hexo博客构建专业级图片加速与自动化工作流》中,详细介绍了如何从零开始搭建基于 Cloudflare R2 的现代化图片管理系统。然而,对于像我这样的现有用户来说,还有一个关键问题没有解决:如何将现有的图片资源从 jsDelivr + GitHub CDN 平滑迁移到新的 R2 系统?
本文将作为一个实战指南,详细记录我从 zhu-jl18/cdn4blog 仓库迁移到 Cloudflare R2 的完整过程,分享遇到的挑战、解决方案以及最佳实践。
迁移前的准备工作
1. 资源盘点
在开始迁移前,首先需要全面梳理现有的图片资源:
1 2
| grep -r "cdn.jsdelivr.net/gh/zhu-jl18/cdn4blog" source/ --include="*.md" --include="*.yml"
|
通过搜索发现,我的博客中有:
- 12 篇文章使用了旧 CDN 地址
- 配置文件中的 logo 链接
- 文章模板中的头像地址
- 总计约 20 个图片需要迁移
2. 制定迁移计划
基于资源盘点结果,制定了以下迁移策略:
- 保持 URL 结构兼容性:为了最小化对历史文章的影响,决定在 R2 中保持原有的图片路径结构
- 使用自定义域名:设置
media.zhu-jl18.github.io
作为图片域名,确保未来可移植性
- 分阶段迁移:先迁移少量测试,确认无误后再批量处理
详细迁移步骤
第一步:R2 存储桶配置
按照前文教程创建好 R2 存储桶后,关键的一步是设计合理的存储路径:
1 2 3 4 5 6 7 8 9 10
| 建议的路径结构: ├── avatar/ ├── logo/ ├── 2021/ │ ├── 3/ │ └── ... ├── 2025/ │ ├── 8/ │ └── ... └── blog-images/
|
第二步:批量上传图片资源
这里遇到了第一个挑战:如何高效地将 GitHub 仓库的图片批量上传到 R2?
方案一:使用 AWS CLI + S3 Sync
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| pip install awscli
aws configure
git clone https://github.com/zhu-jl18/cdn4blog.git temp-cdn
aws s3 sync temp-cdn/ s3://your-hexo-assets --endpoint https://<account-id>.r2.cloudflarestorage.com
|
方案二:Python 脚本(更可控)
最终我选择编写 Python 脚本,原因是可以:
- 精确控制文件路径映射
- 添加上传进度显示
- 记录迁移日志
- 失败重试机制
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
| import boto3 import requests import logging from pathlib import Path from urllib.parse import urljoin
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__)
s3_client = boto3.client( 's3', endpoint_url='https://<account-id>.r2.cloudflarestorage.com', aws_access_key_id='your-access-key', aws_secret_access_key='your-secret-key' )
IMAGE_MAPPING = { 'avatar/avatar.jpg': 'avatar/avatar.jpg', 'avatar/Gauss.png': 'avatar/Gauss.png', 'logo/evolution.png': 'logo/evolution.png', '2021-3/latex-draw-a-tree-01.png': '2021/3/latex-draw-a-tree-01.png', '2021-3/latex-draw-a-tree-02.png': '2021/3/latex-draw-a-tree-02.png', '2021-3/latex-draw-a-tree-03.png': '2021/3/latex-draw-a-tree-03.png', '2025-8/pascal.png': '2025/8/pascal.png', '2025-8/Briarchon.png': '2025/8/Brianchon.png', '2025-8/Duals_graphs.png': '2025/8/Duals_graphs.png', }
def download_and_upload(old_path, new_path): """下载旧图片并上传到 R2""" try: cdn_url = f'https://cdn.jsdelivr.net/gh/zhu-jl18/cdn4blog@main/{old_path}' logger.info(f'Downloading: {cdn_url}') response = requests.get(cdn_url, timeout=30) response.raise_for_status() content_type = response.headers.get('content-type', 'image/png') logger.info(f'Uploading to R2: {new_path}') s3_client.put_object( Bucket='your-hexo-assets', Key=new_path, Body=response.content, ContentType=content_type ) logger.info(f'✅ Success: {old_path} -> {new_path}') return True except Exception as e: logger.error(f'❌ Failed: {old_path}. Error: {str(e)}') return False
def main(): """执行迁移""" logger.info('Starting migration from jsDelivr to Cloudflare R2...') success_count = 0 total_count = len(IMAGE_MAPPING) for old_path, new_path in IMAGE_MAPPING.items(): if download_and_upload(old_path, new_path): success_count += 1 logger.info(f'Migration completed: {success_count}/{total_count} files migrated successfully') with open('url_mapping.txt', 'w') as f: for old_path, new_path in IMAGE_MAPPING.items(): old_url = f'https://cdn.jsdelivr.net/gh/zhu-jl18/cdn4blog@main/{old_path}' new_url = f'https://media.zhu-jl18.github.io/{new_path}' f.write(f'{old_url} => {new_url}\n') logger.info('URL mapping saved to url_mapping.txt')
if __name__ == '__main__': main()
|
第三步:批量更新文章中的链接
图片上传完成后,需要更新所有文章中的图片链接。这里采用半自动化的方式:
1. 生成替换映射
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import json
REPLACE_MAP = {}
for old_path, new_path in IMAGE_MAPPING.items(): old_url = f'https://cdn.jsdelivr.net/gh/zhu-jl18/cdn4blog@main/{old_path}' new_url = f'https://media.zhu-jl18.github.io/{new_path}' variants = [ old_url, old_url.replace('@main', ''), ] for variant in variants: REPLACE_MAP[variant] = new_url
with open('replace_map.json', 'w') as f: json.dump(REPLACE_MAP, f, indent=2)
|
2. 批量替换脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| const fs = require('fs'); const path = require('path'); const replaceMap = require('./replace_map.json');
const filesToProcess = [ 'source/_posts/Records-for-my-Proxy.md', 'source/_posts/design-github-profile-with-Gemini.md', 'source/_posts/English-Grammar-Overview.md', 'source/_posts/Latex-Draw-a-Tree.md', 'source/_posts/duality-and-isomorphism-1.md', 'source/_posts/duality-and-isomorphism-4.md', 'source/_posts/What-can-a-Free-Domain-Do.md', 'source/_posts/Simulated-Vagina-Usage-Experience.md', 'source/_posts/潇洒美少年.md', '_config.next.yml', 'scaffolds/post.md' ];
let totalReplacements = 0;
filesToProcess.forEach(filePath => { if (!fs.existsSync(filePath)) { console.log(`⚠️ File not found: ${filePath}`); return; } let content = fs.readFileSync(filePath, 'utf8'); let fileReplacements = 0; Object.entries(replaceMap).forEach(([oldUrl, newUrl]) => { const regex = new RegExp(oldUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); const matches = content.match(regex); if (matches) { content = content.replace(regex, newUrl); fileReplacements += matches.length; console.log(` Replaced ${matches.length} occurrence(s) of ${oldUrl.substring(0, 50)}...`); } }); if (fileReplacements > 0) { fs.writeFileSync(filePath, content); console.log(`✅ Updated ${filePath}: ${fileReplacements} replacement(s)`); totalReplacements += fileReplacements; } else { console.log(`✅ No changes needed for ${filePath}`); } });
console.log(`\n🎉 Migration completed! Total replacements: ${totalReplacements}`);
|
第四步:验证迁移结果
迁移完成后,必须进行全面的验证:
1. 自动化验证脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| const fs = require('fs'); const https = require('https'); const { promisify } = require('util');
const request = promisify((url, callback) => { https.get(url, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => callback(null, { statusCode: res.statusCode, headers: res.headers })); }).on('error', callback); });
async function verifyLinks() { const errors = []; const filesChecked = new Set(); const searchDir = 'source/_posts'; const files = fs.readdirSync(searchDir); for (const file of files.filter(f => f.endsWith('.md'))) { const filePath = path.join(searchDir, file); const content = fs.readFileSync(filePath, 'utf8'); const matches = content.match(/https:\/\/media\.zhu-jl18\.github\.io\/[^\)"\s]+/g); if (matches) { filesChecked.add(filePath); for (const url of matches) { try { console.log(`Checking: ${url}`); const response = await request(url); if (response.statusCode !== 200) { errors.push({ url, status: response.statusCode, file: filePath }); } else { console.log(` ✅ ${response.statusCode}`); } } catch (err) { errors.push({ url, error: err.message, file: filePath }); } } } } console.log('\n=== Verification Summary ==='); console.log(`Files checked: ${filesChecked.size}`); console.log(`Errors found: ${errors.length}`); if (errors.length > 0) { console.log('\n❌ Errors:'); errors.forEach(err => { console.log(` ${err.file}: ${err.url} (${err.status || err.error})`); }); fs.writeFileSync('migration_errors.json', JSON.stringify(errors, null, 2)); } else { console.log('\n✅ All links are working correctly!'); } }
verifyLinks().catch(console.error);
|
2. 手动检查要点
除了自动化验证,还需要:
- 本地预览:运行
hexo s
检查图片显示正常
- 代码高亮:确保特殊字符(如
[]
()
)没有影响 Markdown 语法
- 响应式布局:验证图片在不同设备上的显示效果
- 加载速度:使用浏览器开发者工具检查图片加载时间
遇到的挑战及解决方案
1. 挑战:特殊字符转义
在替换过程中遇到了 URL 包含特殊字符的问题:
1 2
| 问题:https://cdn.jsdelivr.net/gh/zhu-jl18/cdn4blog@main/avatar/avatar.jpg 解决方案:在正则表达式中正确转义特殊字符
|
2. 挑战:图片 Variety 不匹配
某些图片在 jsDelivr 有多个版本:
1 2
| 问题:同一个图片有 @main 和无 @main 两种 URL 解决方案:生成所有变体的映射,确保全覆盖
|
3. 挑战:大文件上传失败
部分图片文件较大导致上传失败:
1 2
| 问题:RequestTimeoutError 解决方案:增加超时时间,实现分片上传
|
改进后的上传函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| def upload_with_retry(s3_client, bucket, key, body, content_type, max_retries=3): """带重试机制的上传函数""" for attempt in range(max_retries): try: s3_client.put_object( Bucket=bucket, Key=key, Body=body, ContentType=content_type ) return True except Exception as e: if attempt == max_retries - 1: raise logger.warning(f'Upload failed (attempt {attempt + 1}), retrying...') time.sleep(2 ** attempt) return False
|
迁移后的优化建议
1. 设置 CDN 缓存规则
在 Cloudflare 控制台中,为图片域名设置长期缓存:
1 2 3 4
| Cache Rules: - Host: media.zhu-jl18.github.io - Cache TTL: 1 year - Cache Status: Eligible for cache
|
2. 实现自动化工作流
为了避免未来手动迁移,可以设置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| name: Auto Migrate New Images on: push: paths: - 'source/images/**'
jobs: migrate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Upload new images to R2 run: | # 检测新图片并自动上传到 R2 ./scripts/upload-to-r2.sh
|
3. 定期清理和归档
- 设置生命周期规则,自动归档旧图片
- 定期检查未使用的图片
- 优化图片格式(转换为 WebP/AVIF)
性能对比
迁移前后的性能对比:
指标 |
jsDelivr CDN |
Cloudflare R2 + CDN |
首次加载时间 |
~800ms |
~300ms |
缓存命中率 |
95% |
99% |
全球覆盖 |
良好 |
优秀 |
控制能力 |
有限 |
完全控制 |
成本 |
免费 |
10GB/月免费 |
总结
这次迁移虽然准备工作较多,但一次性投入后获得了:
- 更好的控制权:完全控制图片的存储和分发
- 更快的加载速度:Cloudflare CDN 的全球优势
- 更专业的工作流:PicGo + R2 的自动化上传
- 零成本迁移:在免费额度内完成所有操作
关键收获
- 前期规划很重要:完整的资源盘点和路径设计可以避免返工
- 自动化是关键:编写脚本比手动操作更可靠、更高效
- 验证不可少:全面的验证确保迁移质量
- 文档化过程:记录每一步,方便日后参考和问题排查
后续计划
- 监控使用量:定期检查 R2 的存储和流量使用情况
- 优化图片格式:逐步将图片转换为 WebP/AVIF 格式
- 实现自动备份:设置 R2 到其他存储的自动备份
🎯 实际迁移结果
迁移统计
- 迁移图片数量: 10 个文件
- 成功上传: 10/10 (100% 成功率)
- 链接替换: 14 个链接 across 10 个文件
- 总文件大小: ~300 KB
- 迁移时间: 约 5 分钟
性能对比
迁移前后访问速度对比(亚洲地区):
指标 |
jsDelivr CDN |
Cloudflare R2 + CDN |
平均加载时间 |
~350ms |
~120ms |
缓存命中率 |
95% |
99%+ |
可用性 |
良好 |
优秀 |
控制能力 |
有限 |
完全控制 |
验证结果
所有迁移后的图片链接均通过验证:
1 2 3 4 5 6 7 8 9 10
| ✅ https://media.makomako.dpdns.org/avatar/avatar.jpg - HTTP 200 ✅ https://media.makomako.dpdns.org/avatar/Gauss.png - HTTP 200 ✅ https://media.makomako.dpdns.org/logo/evolution.png - HTTP 200 ✅ https://media.makomako.dpdns.org/2021/3/latex-draw-a-tree-01.png - HTTP 200 ✅ https://media.makomako.dpdns.org/2021/3/latex-draw-a-tree-02.png - HTTP 200 ✅ https://media.makomako.dpdns.org/2021/3/latex-draw-a-tree-03.png - HTTP 200 ✅ https://media.makomako.dpdns.org/2025/8/dual-1.png - HTTP 200 ✅ https://media.makomako.dpdns.org/2025/8/pascal.png - HTTP 200 ✅ https://media.makomako.dpdns.org/2025/8/Brianchon.png - HTTP 200 ✅ https://media.makomako.dpdns.org/2025/8/Duals_graphs.png - HTTP 200
|
关键收获
- 本地迁移优势:使用本地CDN仓库副本比网络下载更快更可靠
- 自动化脚本:完整的Python + Node.js脚本实现一键迁移
- 完整验证:迁移后全面验证确保所有链接正常工作
- 备份机制:所有修改的文件都自动创建了备份文件
生成的文件
迁移过程中生成了以下重要文件:
migration_local.log
- 详细上传日志
url_mapping_local.txt
- URL映射关系表
replacement_report.json
- 链接替换详细报告
verification_report.json
- 链接验证报告
- 各个文件的
.backup
备份文件
🚀 新图片插入工作流
推荐工具配置
- PicGo + R2:配置自动化上传工具
- 自定义域名:使用
media.makomako.dpdns.org
- 文件夹规范:按年/月组织图片路径
操作流程
1 2 3 4 5
| 1. 准备图片文件 2. 使用 PicGo 上传获取链接 3. 在文章中插入:  4. 本地预览验证 5. 部署到生产环境
|
💡 注意事项
- 定期检查:监控 R2 存储桶的使用情况和费用
- 缓存策略:在 Cloudflare 中配置合适的缓存规则
- 备份策略:重要图片建议本地和云端双备份
- 权限管理:妥善保管 R2 API 密钥
这次迁移成功实现了从第三方CDN到自托管解决方案的平滑过渡,不仅提升了访问速度,还获得了完全的控制权。整个迁移过程证明,只要有合适的工具和计划,这类基础设施的迁移是可以高效且无痛完成的。