🔑

キャッシュ可能な署名付きURLを考えてみる

2023/10/10に公開

この記事は2023年10月06日に開催された 「Cloudflare Meetup Nagoya 第3回」 で登壇した資料に基づいて作成されています。

登壇資料も併せてアップロードしていますので、そちらも併せてご覧ください。

今回のコンセプト

今回のコンセプトはタイトルにもある通り、 「キャッシュ可能な署名付きURL」 です。

昨今、画像はCDNを経由して配信されることが当たり前となりました。
ですが、たとえCDNを挟んでいたとしても画像配信は想像以上に金額がかかることは、AWSやGCPなどのストレージサービスを利用している人はご存知の通りでしょう。

そこで、Cloudflareのデータ転送料金は無料ということもあり、画像や動画コンテンツだけはCloudflareを使うケースが増えてきました。
例えば、実データはCloudflare以外のサービス(例えばS3)に配置し、CDNだけCloudflareを利用し 料金を節約する という方法も、その一例でしょう。

DNSでキャッシュさせるパターン
DNSでキャッシュさせるパターン

また、コンテンツ配信には 署名付きURL という技術が存在しています。
これは、許可を得たクライアントにのみ画像を配信したい場合に用いられています。
例えば、NotionやGitHubなどでアップロードした画像は、署名付きURLとなっています。

技術としてはシンプルで、画像URLに HMAC(Hash Based Message Authentication Code) を付与し、そのコードをサーバー側で検証することで、画像配信が可能かどうかのチェックを行なっています。

署名付きURLの検証手順
署名付きURLの検証手順

今回は、この署名付きURLの発行をCloudflare Workersで行い、画像のキャッシュもCloudflare Workersで行うことで、 「キャッシュ可能な署名付きURL」 を実現していきます。

セットアップをする

今回、CDNにはCloudflare、Edge FunctionとしてCloudflare Workers、ストレージにはR2、Webフレームワークは Hono を使用します。(Honoのバージョンは3.7です)
特にHonoは簡単にセットアップでき、Cloudflare Workersにもデプロイできるため、おすすめです。

# Honoのテンプレートを作成
npm create hono@latest my-app

cd my-app
npm i

# サーバーを起動
npm run dev

HonoからCloudflare Workersへのデプロイは、以下のドキュメントを参考にしてください。

Cloudflare外部の画像をWorkersでプロキシして配信する

まずは、Cloudflareでキャッシュを利用するためのコード例を紹介します。

index.ts
import { Hono } from "hono";

const app = new Hono();

app.get("/image", async (c) => {
  const { src } = c.req.query();
  const url = new URL(c.req.url);

  // キャッシュを取得
  const cacheKey = new Request(url.toString(), c.req);
  const cache = await caches.open("image");

  // キャッシュが存在したらレスポンス
  let response = await cache.match(cacheKey);
  if (response) return response;

  response = await fetch(src);
  if (!response.ok) return response;

  // statusが200ならキャッシュしてレスポンス
  const res = c.newResponse(response.body);
  c.executionCtx.waitUntil(cache.put(cacheKey, res.clone()));
  return res;
});

export default app;

初回なので、キャッシュ周りのコードは自前で記述しましたが、Honoの場合はcacheミドルウェアが用意されているため、以下のように記述することも可能です。

index.ts
 import { Hono } from "hono";
+ // ミドルウェアをインポート
+ import { cache } from "hono/cache";
 
 const app = new Hono();
 
+ // cacheミドルウェアを追加
- app.get("/image", async (c) => {
+ app.get("/image", cache({ cacheName: "image" }), async (c) => {
   const { src } = c.req.query();
 
   const response = await fetch(src);
   if (!response.ok) return response;
 
   return c.newResponse(response.body);
 });
 
 export default app;

今後のコードでは積極的にミドルウェアで記述していきます。

このコードをデプロイすることで、 https://<workers-domain>/image?src=<image-url> から画像をリクエストすることが可能です。
そして、2回目のアクセスから レスポンスはキャッシュ されます。

DNSのCloudflareでプロキシできる場合、この手法はあまり有効ではないのですが、そのような環境でない場合、このWorkersを利用することで、 転送量を大幅に削減 できるため、場合によっては検討すると良いでしょう。(Cloudflare Workersの呼び出し料金には注意)

Workersでキャッシュさせるパターン
Workersでキャッシュさせるパターン

R2に保存された画像をCloudflare Workers経由で取得する

次は、ストレージに関してもCloudflareのR2を利用し、その画像をCloudflare Workers経由で配信する例を紹介します。

index.ts
import { Hono } from "hono";

type Bindings = {
  PRIVATE_R2: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();

app.get("/:image/no-cache", async (c) => {
  // <:image>に指定された画像名に基づいて、R2からデータを取得
  const image = await c.env.PRIVATE_R2.get(
    `images/${c.req.param("image")}.jpg`,
  );
  if (!image) return c.newResponse("Not found", { status: 404 });

  c.header("Content-Type", "image/jpeg");
  return c.newResponse(image.body);
});

export default app;

今回の例では、キャッシュさせていないので、毎回データをR2へ取得しにいっています。
なので、 レスポンスタイムが比較的長くなってしまう のが難点であります。
https://<workers-domain>/<image-name>/no-cache でアクセスすることが可能です。

R2に保存された画像をCloudflare Workers経由でキャッシュする

先の問題を解決するために、Cloudflare Workersでキャッシュしてみましょう。

index.ts
 import { Hono } from "hono";
+import { cache } from "hono/cache";
 
 type Bindings = {
   PRIVATE_R2: R2Bucket;
 };
 const app = new Hono<{ Bindings: Bindings }>();
 
 app.get(
-  "/:image/no-cache",
+  "/:image/with-cache",
+  cache({ cacheName: "with-cache" }), // キャッシュミドルウェアを適用
   async (c) => {
     const image = await c.env.PRIVATE_R2.get(
       `images/${c.req.param("image")}.jpg`,
     );
     if (!image) return c.newResponse("Not found", { status: 404 });
 
     c.header("Content-Type", "image/jpeg");
     return c.newResponse(image.body);
   },
 );
 
 export default app;

画像をCloudflare Workersにキャッシュさせることで、 転送スピードが早くなることに加え、R2の読み取り料金の節約 にもなります。
https://<workers-domain>/<image-name>/with-cache でアクセスすることが可能です。

速度計測をしてみる

ここで、それぞれのパターンにおいて、速度計測を行ってみましたので、紹介します。
計測パターンは以下の4通りです。

  • R2の画像をWorkers経由で配信(Workers with no cache)
  • Workersを使わず、R2の機能を用いて署名URL経由で画像配信(Presigned URL)
  • R2の画像をWorkers経由でキャッシュして配信(Workers with cache)
  • Workersを使わず、R2だけで画像配信(Public bucket)

速度計測結果
速度計測結果

Cloudflareでキャッシュをしている場合としていない場合で顕著な差が出ています。
ですが、 Workersの処理時間はそこまで影響していない というのは、エッジに置かれているだけあって、さすがですね。

ちなみに、R2の「Public bucket」機能は、署名付きURLである必要がなく、全世界公開で良いデータの場合は、Workersを使わずとも自動的にキャッシュしてくれるのでとても便利です。

署名付きURLを実装する

ここからは、署名付きURLをCloudflare Workersで実装していきます。
なぜ、R2の機能で署名付きURLを利用しないで、Workersで自前実装するのかというと、 R2の署名付きURLではデータがキャッシュされない ためです。

今回のコンセプトでもある、「キャッシュ可能な署名付きURL」を実現するために、自前で署名付きURLの機能をWorkersで実装していきます。

今回のコードは、公式のサンプルに従ってますので、併せてご確認ください。

署名付きURLの発行

まずは、署名付きURLを発行するためのコードを紹介します。

index.ts
import { Hono } from "hono";

const app = new Hono();

app.get("/:image/generate", async (c) => {
  const url = new URL(c.req.url);

  // 署名付きURLのベースとなるURLを作成
  const image = c.req.param("image");
  const prefix = `/${image}/generate`;
  // `/generate` を `/verify` に変換
  url.pathname = `/${image}/verify${url.pathname.slice(prefix.length)}`;

  // 署名付きURLの生成
  const verificationUrl = await generateSignedUrl(url);
  return c.text(verificationUrl.toString());
});

export default app;
utils.ts
export async function generateSignedUrl(url: URL) {
  const encoder = new TextEncoder();

  // 署名に使う秘密鍵を定義
  const secretKeyData = encoder.encode("my secret symmetric key");
  const key = await crypto.subtle.importKey(
    "raw",
    secretKeyData,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );

  // 今回はキャッシュを分かりやすくするために有効期限を10秒にしている
  const expirationMs = 10000;
  const expiry = Date.now + expirationMs;
  
  // 署名に含めるメッセージを作成
  const dataToAuthenticate = `${url.pathname}@${expiry}`;

  // 署名
  const mac = await crypto.subtle.sign(
    "HMAC",
    key,
    encoder.encode(dataToAuthenticate),
  );

  // base64に変換(+はスペースに変換されるケースがあるため-に変換)
  const base64Mac = btoa(
    String.fromCharCode(...new Uint8Array(mac)),
  ).replaceAll("+", "-");

  // URLにセット
  url.searchParams.set("mac", base64Mac);
  url.searchParams.set("expiry", expiry.toString());

  return url;
}

コード的には特段難しいことはしていません。
/<image-name>/generate でリクエストを待ち、そのURLの pathname と 有効期限である expiry を署名に含めています。
そして、署名結果である MACexpiry をURLに付与してレスポンスをしています。
レスポンスの形式としては /<image-name>/verify?mac=<mac>&expiry=<number> となります。

この処理自体は、Cloudflare Workers以外で行われることも多いと思います。
実際、私が過去に携わっていたプロジェクトでは、 署名付きURLの生成のためにDBアクセスが必要となっていたこともあり、Cloud Runで生成していたこともあります。

署名付きURLの検証

次は、URLに含めた MACexpiry を用いて、署名付きURLの検証をしていきます。

index.ts
import { Hono } from "hono";

type Bindings = {
  PRIVATE_R2: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();

app.get("/:image/verify", async (c) => {
  const url = new URL(c.req.url);
  if (!url.searchParams.has("mac") || !url.searchParams.has("expiry")) {
    return c.newResponse("Missing query parameter", { status: 403 });
  }
  const expiry = Number(url.searchParams.get("expiry"));

  // 署名付きURLの検証
  const verified = await verifySignedUrl(url, expiry);

  // MACの検証が失敗した場合
  if (!verified) {
    const body = "Invalid MAC";
    return c.newResponse(body, { status: 403 });
  }

  // 有効期限が切れていた場合
  if (Date.now() > expiry) {
    const body = `URL expired at ${new Date(expiry)}`;
    return c.newResponse(body, { status: 403 });
  }

  // 署名が有効なら、画像をR2から取得
  const image = await c.env.PRIVATE_R2.get(
    `images/${c.req.param("image")}.jpg`,
  );
  if (!image) {
    return c.newResponse("Not found", { status: 404 });
  }

  c.header("Content-Type", "image/jpeg");
  return c.newResponse(image.body);
});

export default app;
utils.ts
function byteStringToUint8Array(byteString: string) {
  const ui = new Uint8Array(byteString.length);
  for (let i = 0; i < byteString.length; ++i) {
    ui[i] = byteString.charCodeAt(i);
  }
  return ui;
}

export async function verifySignedUrl(url: URL, expiry: number) {
  const encoder = new TextEncoder();

  // 署名を検証するための共通鍵を作成
  const secretKeyData = encoder.encode("my secret symmetric key");
  const key = await crypto.subtle.importKey(
    "raw",
    secretKeyData,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"],
  );

  // 受け取ったMACをBase64から元に戻す
  const receivedMacBase64 = (url.searchParams.get("mac") ?? "").replaceAll(
    "-",
    "+",
  );
  const receivedMac = byteStringToUint8Array(atob(receivedMacBase64));

  // 署名した時と同じメッセージを作成
  const dataToAuthenticate = `${url.pathname}@${expiry}`;

  // 署名の検証
  return await crypto.subtle.verify(
    "HMAC",
    key,
    receivedMac,
    encoder.encode(dataToAuthenticate),
  );
}

署名を検証する側もシンプルなコードになっています。
URLに含まれている MACexpiry を署名した時と同じメッセージを作成し、それを元に署名の検証行っています。
その署名が有効で、expiry の期限内であれば、R2から画像をレスポンスする内容になっています。

キャッシュ可能な署名付きURL

そもそも、「キャッシュ可能」とはどのような状態を指すのでしょうか?
キャッシュ可能な状態にするには、今回の場合 「URLが同一」 でなければなりません。
もちろん「URLが同一でない」場合でも、Workersの中でキャッシュヒットさせる手法もあるとは思いますが、今回のケースだと実装ヘビーになってしまいます。
なので、今回は 「URLを同一」にすることで「キャッシュ可能な署名付きURL」を発行する ことにします。

先ほど紹介したコード例では、署名生成へのリクエストの度に expiry が変化してしまうため、URLが毎回変化し、キャッシュしづらい状況になっています。
裏を返せば、この 生成される署名付きURLの expire を同じ にできれば、「キャッシュ可能」となるわけです。

署名付きURLのキャッシュ戦略
署名付きURLのキャッシュ戦略

今回のキャッシュ戦略は以下の記事を参考にさせていただきました。

「署名付きURLの発行」を更新する

では、実際にコードに変更を加えていきます。

utils.ts
 export async function generateSignedUrl(url: URL) {
   const encoder = new TextEncoder();
   const secretKeyData = encoder.encode("my secret symmetric key");
   const key = await crypto.subtle.importKey(
     "raw",
     secretKeyData,
     { name: "HMAC", hash: "SHA-256" },
     false,
     ["sign"],
   );
   // 直近の「x時0分」のオブジェクトを生成
+  const day = dayjs().startOf("hour").add(1, "hour");
   const expirationMs = 10000; // 10 seconds
-  const expiry = Date.now + expirationMs;
+  const expiry = day.valueOf() + expirationMs;
   const dataToAuthenticate = `${url.pathname}@${expiry}`;
 
   const mac = await crypto.subtle.sign(
     "HMAC",
     key,
     encoder.encode(dataToAuthenticate),
   );
 
   const base64Mac = btoa(
     String.fromCharCode(...new Uint8Array(mac)),
   ).replaceAll("+", "-");
 
   url.searchParams.set("mac", base64Mac);
   url.searchParams.set("expiry", expiry.toString());
 
   return url;
 }

変更点は僅かで、generateSignedUrl 関数内部の expiry を生成していた箇所のみです。
Date.now を直近の「x時0分」にすることで、例えば「13時15分に生成される署名付きURL」と「13時59分に生成される署名付きURL」が同一になります。

「署名付きURLの検証」を更新する

あとは、検証側で画像をキャッシュさせるコードを追加すれば完成です。
Honoであれば、ミドルウェアを追加するだけですので、簡単ですね。

index.ts
- app.get("/:image/verify", async (c) => {
+ app.get("/:image/verify", cache({ cacheName: "verify" }), async (c) => {

おわりに

「署名付きURL」の需要は増えてきていると噂で聞きました。
名前からすると結構難しそうなイメージではありますが、やっていることはシンプルですので、この機会に実装してみると理解が深まると思います。

また、親戚みたいな機能として「署名付きCookie」も存在しています。
興味がある人はぜひ調べてみると良いでしょう。

アップロード周りも紹介したかったのですが、記事が長くなってしまうのでまたの機会とします。

Discussion