Better Late Than Never.

从又拍云搬家到 Cloudflare R2

为什么要搬

之前一直白嫖又拍云联盟的免费额度,用了挺长时间了。但最近不太稳,搜了下口碑也差了。不将就,干脆迁到 Cloudflare R2 算了。

选 R2 的理由其实就几个:

  • 免出口流量费,这个是真香
  • 全球节点多,速度还行
  • 跟 Cloudflare 其他东西联动起来方便

开工前要准备的东西

  • rclone 装上(要是还没装去 https://rclone.org/downloads/ 下个 Windows 版就行)
  • Cloudflare 账号,R2 功能打开,先建个桶
  • 又拍云的 S3 密钥和 Cloudflare R2 的 API 密钥都准备好

第一步:配 rclone

找到 rclone.conf 文件,Windows 的话一般在 C:\Users\你的用户名\.config\rclone\rclone.conf,没有就新建一个。

打开贴进去:

[upyun]
type = s3
provider = Other
access_key_id = 又拍云给你的access_key_id
secret_access_key = 又拍云给你的secret_access_key
endpoint = s3.api.upyun.com

[r2]
type = s3
provider = Cloudflare
access_key_id = Cloudflare给你的access_key_id
secret_access_key = Cloudflare给你的secret_access_key
region = auto
endpoint = https://你的账户ID.r2.cloudflarestorage.com

有个坑注意一下:又拍云的 access_key_id 和 secret_access_key 要去控制台 → 云产品 → 对象存储 → 对应服务 → S3 API 里找,不是操作员账号密码那玩意,别搞混了。

第二步:试试能不能连上

先跑两条命令看看:

rclone ls upyun:你的桶名字
rclone ls r2:你的桶名字

要是都能列出来文件列表,说明配置没问题,继续往下走。

第三步:开搬

直接跑同步命令:

rclone copy upyun:你的桶名字 r2:你的桶名字 -P --transfers 8

解释一下几个参数:

  • copy 就是拷贝,目标那边多出来的文件不会删,比较安全
  • -P 显示进度条,不然干等着心慌
  • --transfers 8 同时传 8 个文件,速度拉满

文件多的话建议先 dry-run 一下看看要传多少东西:

rclone copy upyun:你的桶名字 r2:你的桶名字 -P --transfers 8 --dry-run

搬完之后检查一下有没有漏的:

rclone check upyun:你的桶名字 r2:你的桶名字 --size-only

第四步:旧图片地址怎么办

以前文章里引用的图片地址都是类似 https://upyun.hzzio.top/images/1.jpg 这种完整路径,不可能一篇篇去改。

我的处理办法是用 Service Worker,在前端拦截图片请求,偷偷把域名换掉。

sw.js 文件

const OLD_DOMAIN = 'xxx.b0.upaiyun.com';
const NEW_DOMAIN = '你的r2域名.r2.cloudflarestorage.com';

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  if (url.hostname === OLD_DOMAIN) {
    url.hostname = NEW_DOMAIN;

    const newRequest = new Request(url, event.request);

    event.respondWith(
      fetch(newRequest).then((response) => {
        const clonedResponse = response.clone();
        caches.open('r2-cache').then((cache) => {
          cache.put(newRequest, clonedResponse);
        });
        return response;
      })
    );
  }
});

self.addEventListener('install', () => {
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});

在页面里注册一下

在 HTML 里加一段:

<script>
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js');
  }
</script>

这样用户访问页面的时候,图片还是旧的地址,但实际请求会被劫持到 R2 上,用户无感知。

第五步:后端 PHP 上传

存量文件搬完了,但之后用户上传图片还是要走后端。不用 SDK,直接手撸 AWS Signature V4,少引入一堆依赖。

r2 配置放 config/r2.php

return [
    'account_id' => env('R2_ACCOUNT_ID'),
    'access_key_id' => env('R2_ACCESS_KEY_ID'),
    'secret_access_key' => env('R2_SECRET_ACCESS_KEY'),
    'bucket' => env('R2_BUCKET'),
    'region' => env('R2_REGION', 'auto'),
    'domain' => env('R2_DOMAIN'), // 绑定的自定义域名,用来返回完整 URL
];

上传方法,用 GuzzleHttp 发请求:

private static function putToR2(string $localPath, string $key, string $contentType = null)
{
    $config = config('r2');
    $accountId = $config['account_id'];
    $accessKey = $config['access_key_id'];
    $secretKey = $config['secret_access_key'];
    $bucket = $config['bucket'];
    $region = $config['region'];
    $domain = $config['domain'];
    $contentType = $contentType ?: 'application/octet-stream';

    $content = file_get_contents($localPath);
    $contentHash = hash('sha256', $content);
    $contentLength = strlen($content);

    $host = "{$bucket}.{$accountId}.r2.cloudflarestorage.com";
    $uri = $key;
    $requestUrl = "https://{$host}{$uri}";

    $service = 's3';
    $timestamp = gmdate('Ymd\THis\Z');
    $date = gmdate('Ymd');

    $canonicalHeaders = "content-type:{$contentType}\nhost:{$host}\nx-amz-content-sha256:{$contentHash}\nx-amz-date:{$timestamp}\n";
    $signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date';
    $canonicalRequest = "PUT\n{$uri}\n\n{$canonicalHeaders}\n{$signedHeaders}\n{$contentHash}";
    $canonicalRequestHash = hash('sha256', $canonicalRequest);

    $credentialScope = "{$date}/{$region}/{$service}/aws4_request";
    $stringToSign = "AWS4-HMAC-SHA256\n{$timestamp}\n{$credentialScope}\n{$canonicalRequestHash}";

    $kSecret = 'AWS4' . $secretKey;
    $kDate = hash_hmac('sha256', $date, $kSecret, true);
    $kRegion = hash_hmac('sha256', $region, $kDate, true);
    $kService = hash_hmac('sha256', $service, $kRegion, true);
    $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
    $signature = hash_hmac('sha256', $stringToSign, $kSigning);

    $authorization = "AWS4-HMAC-SHA256 Credential={$accessKey}/{$credentialScope},SignedHeaders={$signedHeaders},Signature={$signature}";

    $client = new Client();
    $client->put($requestUrl, [
        'headers' => [
            'Content-Type' => $contentType,
            'Content-Length' => $contentLength,
            'x-amz-content-sha256' => $contentHash,
            'x-amz-date' => $timestamp,
            'Authorization' => $authorization,
        ],
        'body' => $content,
    ]);

    return $domain . $key;
}

调用也简单:

$url = putToR2('/tmp/upload.jpg', '/images/2026/07/foo.jpg', 'image/jpeg');

走完这套,用户传的图片就直接存到 R2 了,返回的 URL 配上自定义域名,跟以前用又拍云的时候一样。

最后

搞完收工,总结一下:

  1. 存量数据用 rclone 迁到 R2,没出啥问题
  2. 旧图片地址用 Service Worker 做了个透明跳转,不用改源码
  3. 新上传的走 PHP 直传 R2,SDK 都不用装
  4. 博客所有图片现在走 Cloudflare,速度理论比之前稳(除非墙又发力)

几点提示

  • R2 免费额度是 10GB 存储 + 每月 100 万次 A 类操作 + 1000 万次 B 类操作,小博客基本够用
  • Service Worker 这东西需要浏览器支持,太老的 IE 之类就算了
  • 迁移期间如果又拍云那边还有新文件写入,记得再跑一遍 sync 补一下增量

-- END

写的不错,赞助一下主机费

扫一扫,用支付宝赞赏
扫一扫,用微信赞赏

暂无评论~~