ブログの画像管理をGitHubからCloudflare R2へ移行した

このブログの画像をGitHubのリポジトリでの管理からCloudflare R2に移行したのでその備忘録です。

移行前の状態

移行前はリポジトリのsrc/assets/images/ディレクトリの下に、記事ごとにディレクトリを作って保存していました。

src/assets/images/
├── awesome-articel/
│ ├── foo.png
│ └── bar.png
├── cool-na-kiji/
│ ├── piyo.png
│ └── hoge.png
├── ...
├── icon.png
└── logo.png

ちなみに使っているのはAstroです。

画像管理のメリット・デメリット

移行前に、画像の管理方法別にメリットとデメリットを考えました。

リポジトリでの画像管理のメリット・デメリット

リポジトリに画像を入れておけば、ブログ本体や記事と一緒に一括で管理できます。imgタグのsrc属性などを書く時にエディタのパス補完が効くのも便利です(筆者はcmp-pathをブログ用にちょっと変更したものを使っていました)。また、バージョン管理も記事と一緒に行えます。

その反面、画像が増えれば増えるほど重くなる操作があります。
たとえばGitの操作やビルドに時間がかかります。筆者のブログのビルド時間は初期では2分ほどでした。それがいつの間にか4分20秒以上、つまり2倍以上になってしまいました。

これから先もっと時間がかかってしまうのは間違いないため、ストレージサービスで画像を管理することにしました。

ストレージサービスに移行するメリット・デメリット

画像をストレージサービスに移行すれば、前述のビルド時間はぐっと短縮できます。Astroでいえばリモート画像にする、ということです。

一方で新たな料金の発生やエディタのパス補完が効かなくなるといったデメリットもあります。また、画像をアップロードする手間もあります。

選んだのはR2

お金めっちゃかかったらどうしよう、とか考えていましたがとりあえず調べることに。
このブログのホスティングで使っているのはCloudflare Pagesであるため、同じくCloudflareのR2の料金形態を調べてみました。2024年11月時点では次のようになっていました。

無料枠月額
ストレージ10 GB / 月$0.015 / GB ストレージ
クラスA操作(状態の変更)100万 / 月$4.50 / 100万
クラスB操作(既存の状態の読み取り)1000万 / 月$0.36 / 100万
エグレス料金(DLするとき)無料無料

”egress”はネットワークの内部から外部に流れる通信のこと(参考:エグレス(イーグレス)とは - IT用語辞典 e-Words)。エグレス料金は、クラウドストレージから取り出す時にかかるお金のことですね(参考:データエグレス料金とは | Cloudflare)。

自分の使う範囲だと無料枠内で十分運用できそうだと分かりました。

R2はAWSのS3と互換があるようで、APIを叩く手段も多そうです。

ついでに画像のフォーマットも変更

今まではJPEGやPNGなどフォーマットはバラバラでした。せっかくなので容量を減らしてからアップロードしたいと考えました。容量を圧縮できれば、読み込みも早いですし前述の料金の話にも関わってきます。

調べてみると、avifというフォーマットに変換することでめっちゃ圧縮できるようです(参考:Webに最適なメディアフォーマットを整理する - 2024)。
Can I use…によると2024年11月時点での対応率は94%です。このブログの読者層を考えると問題無さそうです。

移行前の準備

移行にあたってやっておくこと・考えておくことがいくつかあります。

バケットの作成

Cloudflareのダッシュボードにアクセスし、R2のバケットを作成します。

バケット作成ボタンの場所

最初にバケット名を決めます。公式ドキュメントによると、使える文字は「小文字アルファベットa-z」「数字0-9」「ハイフン-」です。バケット名はアカウント毎に一意であればいいようです。

バケットの作成画面

その他の設定についてはデフォルトのままにしました。

移行対象の決定

今回はブログの記事で扱う画像のみを移行の対象としました。アイコンなどのページでも使う画像は引き続きリポジトリ内で管理します。

カスタムドメインの設定

サブドメインで画像を配信したかったため、その設定もしました。

バケットの「設定」タブにある「カスタムドメインを追加」から追加できます。

カスタムドメインの設定ボタン

たとえば、media.example.comのように追加します。ここで設定できるのはCloudflareで登録してあるドメインのみです。

サブドメインを指定したい場合、ここで設定するだけで作ってくれます。ドメイン管理画面での操作は不要なので便利です。

これでhttps://meda.example.com/オブジェクトのキーのようにアクセスできます。

実際の移行手順

ここからは実際に画像をR2へアップロードする手順を紹介します。

アップロードスクリプトの作成

画像をアップロードする手段をいくつか載せておきます。

前述のとおりフォーマットの変換もやりたかったため、@aws-sdk/client-s3を使ってNode.jsのスクリプトを書きました。

コード(クリックで開きます)
migrate-to-r2.mjs
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import sharp from "sharp";
const isDryRun = process.argv.includes("--dry-run");
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const targetDir = path.join(__dirname, "../src/assets/images/");
const ACCOUNT_ID = process.env.ACCOUNT_ID ?? "";
const ACCESS_KEY_ID = process.env.ACCESS_KEY_ID ?? "";
const SECRET_KEY_ID = process.env.SECRET_KEY_ID ?? "";
const BUCKET_NAME = process.env.BUCKET_NAME ?? "";
let fileCount = 0; // ファイル数カウント
const startTime = Date.now(); // 実行開始時間
if (
ACCOUNT_ID === "" ||
ACCESS_KEY_ID === "" ||
SECRET_KEY_ID === "" ||
BUCKET_NAME === ""
) {
console.log("envの値でどこかが未指定");
process.exit(1);
}
const r2Client = new S3Client({
region: "auto",
endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: ACCESS_KEY_ID,
secretAccessKey: SECRET_KEY_ID,
},
});
async function processImageFile(filePath, parentDir) {
const fileName = path.parse(filePath).name;
const avifBuffer = await sharp(filePath).toFormat("avif").toBuffer();
const key = `${parentDir}/${fileName}.avif`;
await uploadToR2(avifBuffer, key, "image/avif");
}
async function processVideoFile(filePath, parentDir) {
const fileName = path.parse(filePath).name;
const key = `${parentDir}/${fileName}.mp4`;
const fileData = fs.readFileSync(filePath);
await uploadToR2(fileData, key, "video/mp4");
}
async function uploadToR2(data, key, contentType) {
if (isDryRun) {
console.log(`Dry run: Skipping actual upload for ${key}`);
return;
}
try {
const uploadParams = {
Bucket: BUCKET_NAME,
Key: key,
Body: data,
ContentType: contentType,
};
await r2Client.send(new PutObjectCommand(uploadParams));
console.log(`Uploaded: ${key}`);
} catch (err) {
console.error(`Failed to upload ${key}:`, err);
}
}
async function walkDir(dir, callback) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await callback(fullPath, entry.name);
}
}
}
async function processFilesInDirectory(dir, parentDir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
const filePath = path.join(dir, entry.name);
if (ext === ".jpg" || ext === ".png") {
await processImageFile(filePath, parentDir);
} else if (ext === ".mp4") {
await processVideoFile(filePath, parentDir);
} else {
console.warn(`Unexpected file extension: ${entry.name}`);
}
fileCount++;
}
}
}
async function main() {
console.log(
isDryRun
? "Running in dry-run mode. No files will be uploaded."
: "Starting actual upload.",
);
await walkDir(targetDir, async (subDir, parentDir) => {
await processFilesInDirectory(subDir, parentDir);
});
const endTime = Date.now();
const elapsedTime = (endTime - startTime) / 1000;
const minutes = Math.floor(elapsedTime / 60);
const seconds = Math.floor(elapsedTime % 60);
console.log(`Processed ${fileCount} files in ${minutes}分${seconds}秒`);
}
main();

sharpで画像を変換しています。もし使いたい人がいたらライブラリのインストールを忘れずに。

npm install -D sharp @aws-sdk/client-s3

.envは次のように設定します。

.env
ACCOUNT_ID=xxxxxxxx
ACCESS_KEY_ID=xxxxxxxxx
SECRET_KEY_ID=xxxxxxxxxxxxxxxx
BUCKET_NAME=example

最初はdry-runで指定したとおりのオブジェクトキーになるかどうか確認しましょう。

node --env-file=.env migrate-to-r2.mjs --dry-run

想定どおりであれば--dry-runを外して実行します。

筆者の場合、ファイル数は403、実行には9分37秒かかりました。AVIFへの変換に時間がかかるため、変換しない場合はもっと短いと思います。

移行後にやったこと

ここからはR2に画像をアップロードした後の作業内容です。

タグのsrcの書き換え

imgタグやvideoタグのsrcを書き換えます。筆者はNeovimを使って一括置換で完了しました。これについては後日記事を出そうと思います。

2024年11月27日追記:書きました!
複数ファイルを一括で書き込めるquicker.nvimの使い方 | eiji.page

これからの画像を扱うフローを決める

人によってまったく違うと思いますが、筆者の例を載せておきます。

  1. 画像の作成(スクショ・録画・加工など)
  2. ローカル画像を使ったプレビューするためのディレクトリへ移動
  3. ローカル画像を使ったプレビューにて、記事内で表示するサイズを調整
  4. その記事の画像をまとめてAVIFかmp4に変換・所定のディレクトリへ移動
  5. まとめてR2へアップロード
  6. プレビューをローカル画像からリモート画像へ切り替えて、反映を確認

アップロードにはrcloneを使っています。バックアップも兼ねているため、別のドライブのディレクトリに移動させてからアップロードしています。

まだ試験段階なので固まったら記事にするかもしれません。


以上、ブログの画像管理をR2へ移行した話でした。

PageSpeed Insightsのパフォーマンスが、移行前は94点でしたが、移行後は100点になりました。モバイルの方は56点から81点なのでまだ別のところで改善の余地ありです。

移行後のビルド時間は3分30秒でした。移行前よりも減っていはいますが予想より短くなっていません。
ライブラリが初期よりも増えている&OGP画像の作成にはまだまだ時間がかかっているため、要改善です。