S3の署名付きURLをキャッシュ可能にする2つの方法【Timekeeper / next/image】
Updated Date: 2026/03/04 23:14
S3の署名付きURLはリクエストのたびに異なるURLが生成されるため、そのままではブラウザキャッシュが効かない。この問題の解決策として Timekeeperライブラリを使って時刻を丸める方法 と next/image を使う方法 の2つを紹介する。
問題:署名付きURLはなぜキャッシュできないのか
S3の署名付きURL(Presigned URL)は、以下2つの値をもとに署名文字列が生成される:
- リクエスト時刻(現在時刻)
- 有効期限(秒)
つまり 1秒でも違えば異なるURLになる。URLが変わるとブラウザは別リソースと判断するため、Cache-Control ヘッダーをつけてもキャッシュが機能しない。
画像表示のたびに新しいURLが生成され、毎回S3からデータが転送される → 転送量がすぐに上限に達する、という問題が起きる。
解決策1:Timekeeperで時刻を丸めて同じURLを使い回す
Timekeeper は時刻を固定・書き換えできるnpmライブラリ。これを使って、署名URL生成時の時刻を「10分単位」「30分単位」などに切り捨てることで、その期間内は常に同じURLが発行されるようにする。
インストール
1
2
3
npm install timekeeper
# または
yarn add timekeeper
TypeScript実装例
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
import tk from "timekeeper";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GetObjectCommand } from "@aws-sdk/client-s3";
const CACHE_MINUTES = 10; // キャッシュしたい時間(分)
const expiresIn = CACHE_MINUTES * 60; // 秒に変換
/**
* 現在時刻をCACHE_MINUTES単位で切り捨てた時刻を返す
* 例:14:27 → 14:20 (10分単位の場合)
*/
const getTruncatedTime = (): Date => {
const currentTime = new Date();
const d = new Date(currentTime);
d.setMinutes(Math.floor(d.getMinutes() / CACHE_MINUTES) * CACHE_MINUTES);
d.setSeconds(0);
d.setMilliseconds(0);
return d;
};
/**
* キャッシュ可能な署名付きURLを生成する
* 同じ10分間内に呼ばれた場合、同じURLが返る
*/
export const getCacheableSignedUrl = async (
s3Client: S3Client,
bucket: string,
key: string
): Promise<string> => {
const getObjectCommand = new GetObjectCommand({ Bucket: bucket, Key: key });
const signedUrl = await tk.withFreeze(getTruncatedTime(), async () => {
return getSignedUrl(s3Client, getObjectCommand, { expiresIn });
});
return signedUrl;
};
仕組み: tk.withFreeze(time, callback) はcallback実行中の new Date() を指定した時刻に固定する。AWS SDK v3はURL生成時に内部で new Date() を使っているため、時刻を切り捨てた時刻に固定すると同じ署名が生成される。
注意事項
tk.withFreezeはcallback実行中にnew Date()を呼ぶ 全ての処理に影響する。他の非同期処理と並行して実行されると意図しない挙動になるため、ユーザーアクション起点など排他的に呼べる箇所でのみ使うこと。- Supabase Storageでは サーバーサイドで時刻計算している ため、この方法は使えない可能性が高い(後述)。
- AWS SDK v2では動作未確認(v3で確認済み)。
解決策2:next/image を使う
Next.jsを使っている場合、next/image コンポーネントは静的ファイルのキャッシュを自動管理してくれる。
1
2
3
4
5
6
7
8
import Image from "next/image";
<Image
src={signedUrl}
alt="アイテム画像"
width={500}
height={500}
/>
next/image は内部でNext.jsのImage Optimization APIを経由するため、同一のsrcに対してキャッシュが効くようになる。ただし、署名付きURLの有効期限が切れる前にキャッシュが更新されるよう 有効期限の設定に注意が必要。
どちらを使うべきか
| 方法 | 向いているケース |
|---|---|
| Timekeeper | 署名URL生成ロジックを直接触れる・他の非同期処理と干渉しない場面 |
| next/image | Next.jsを使っている・シンプルな画像表示 |
| CloudFront | 大量アクセスが想定される・セキュリティを強化したい(最もスタンダードな解決策) |
余談:なぜSupabase StorageではTimekeeperが使えないか
AWS SDK v3は クライアントサイドで new Date() を呼んで署名を生成するため、Timekeeperで時刻を書き換えると効果がある。
一方、Supabase Storageは以下のコード(storage-js より)の通り、サーバー側のAPIに expiresIn を渡して署名を生成する:
1
2
3
4
5
6
let data = await post(
this.fetch,
`${this.url}/object/sign/${_path}`,
{ expiresIn, ... },
{ headers: this.headers }
)
→ クライアント側の時刻を書き換えても、サーバー側の時刻は変わらないため署名が変わる。
余談:Cloudflare R2 という選択肢
S3の代替として Cloudflare R2 も検討に値する。AWS SDK v3とほぼ互換性があり、転送量が無料(S3より格段に安い)。大量の画像を扱うサービスではR2への移行でコストを大きく削減できる可能性がある。
背景(この問題に行き着いた経緯)
URLと画像を保存して後で見返せる小さなサービスを作った。Supabase Storageを使ったところ転送量の無料枠(月2GB)を2〜3日で超過してしまった。原因は画像表示のたびに署名付きURLを再生成していたこと。AWSのS3に移行してTimekeeperで解決した。