S3の署名付きURLをキャッシュ可能にする
Updated Date: 2024/01/01 14:47
ポートフォリオではないが、自分で作って運用するサービスというものをしばらく作ってこなかったので、久しぶりにやってみた。
URLと画像とタイトルを保存しておいてあとで見返せるというだけのサービスなのだが、これを実現するために画像置き場とデータのリレーションを管理するためのデータベースが必要になる。
なるべく無課金でサービス作れたらいいなーと思っていたので、Herokuも有料化してしまった昨今、Supabaseを前提になんとかできないかと検討。
検討事項含めてアーキテクチャ周りや実装はほぼChatGPT君に考えてもらった。考えてもらったことを列挙する。
- CSSデザイン(Tailwind CSSを提案された)
- Supabaseのテーブル設計とCREATE TABLE文
- Supabaseのクライアント実装
- Next.js全体の実装
あとは全体的にChatGPT君が作ったものに対して「それじゃ動かんやろ」とか「いや、ちょっと趣旨が違う」とか文句を付けながら修正してもらった。
それでも直らなかったところは自分で整合性を取って対処。
使ってるうちにChatGPTは3つ前くらいの相談は覚えてるので主語や目的語、過去の背景などの文脈を省いてもいい感じに返してくれることが分かった。 が、それ以上は特殊なプロンプトを生成しない限り忘れてしまうようだ。解決策はちょっと僕には分からないので得意な人だれか教えて欲しい。
S3の署名付きURLがキャッシュできない問題
ここからが本題。Supabaseで完成したと思ってたサービスだったが、使ってるうちにすぐに無料枠の限界がきた。
限界が来たのはSupabase Storageの無料枠。画像を保存するのは1GまでOKなので制限に引っかかると思ってなかったのだが、
転送量の制限が1ヶ月で2Gまでとなっており、テストがてら色々イジってたら2、3日で超過してしまった。
原因は画像を表示するためにSupabase Storageの署名付きURLを生成していたからである。
なるべくセキュアなサービスを目指すためにStorageは基本ログイン済みユーザーのみ読み書き可能なようにしたことで、Storageの静的URLでは40xのエラーが返ってきてしまう。
それゆえに署名付きURLを発行するわけだが、署名時に生成される文字列はクライアントの現在時刻と、失効時間(秒)の2つを用いて生成されるため、1秒でも再描画のタイミングがあると毎回違う署名付きURLが発行される。そうなるとURLが異なるので、ヘッダーにCache-Controlを付けてもキャッシュが効かない…というわけである。
解決策
ということで無課金は早々に諦めて、微課金を許容することにした。
今回選択したのはAWSのS3。Supabase Storageと同じように署名付きURLも発行できるし、以下のWebサイトでコスト試算をしたところ、たかだか数百円もいかないレベルだったので、いけると判断した。
ざっくりAWS
ただし、そのまま実装したのでは結局Supabase Storageと同様署名付きURLがキャッシュできない問題が発生する。
これを解決するためのデファクトスタンダードはおそらくCloud Front(CDN)によるキャッシュだろう。しかし、この解決策だとCloud Frontの利用料が発生してしまう。
冒頭のざっくりAWSで試算したところ、Cloud Frontを利用すると月額数十円〜数百円のコストになるようなので、これも微課金として許容できる範囲だと思ったが、一旦別の方法を考えることにした。
1. timekeeperライブラリを使う
AWSはネットに大量に情報があると思ったのですぐに解決策があるだろうとググってみるも、ベストな解決策は見つからなかったが、とりあえず自分で使う分にはなんとかなりそうな解決策を見つけた。→https://advancedweb.hu/cacheable-s3-signed-urls/
ちょっと記事が古いが、Timekeeperという古めのnpmライブラリを使って時間を丸めてしまい、指定した期間は同じ時間が使われる=同じ署名URLが発行されるようにしてしまうというハックである。誰が考えたか知らないがすごい。
TypeScriptで書くとこんな感じ。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import tk from "timekeeper";
const getTruncatedTime = (): Date => {
const currentTime = new Date();
const d = new Date(currentTime);
d.setMinutes(Math.floor(d.getMinutes() / 10) * 10); // ここで10分単位で時間を丸めている。
// 30分にしたいときはこんな感じ
// d.setMinutes(Math.floor(d.getMinutes() / 30) * 30);
d.setSeconds(0);
d.setMilliseconds(0);
return d;
};
const signedUrl = tk.withFreeze(getTruncatedTime(), async () => {
const presignedUrl = await getSignedUrl(s3Client, getObjectCommand, {expiresIn: expiresIn});
return presignedUrl;
});
なお、参考サイトの注意書きにも書かれている通り、callbackやPromise内でTimekeeperを使ってしまうと他の機能にも影響が出てしまうので、処理の流れ的に問題が起きる場合は使用できない。今回は「登録ボタンをクリック」というアクションの後にしかこの処理は実行されず、他の処理と並行処理されることもないため問題ないと判断して実装している。
2. Next.jsの next/image を使う
こっちはユーザーの閲覧端末によってwebpとかを生成していい感じに画像最適化してくれるやつ。キャッシュも静的ファイルなら勝手にやってくれるらしいので、署名付きURLの画像ファイルとかでなければこれだけで色々解決してしまう。
1
2
3
4
5
6
<Image
src={url}
alt="Picture of the author"
width={500}
height={500}
/>
余談
他の解決策
キャッシュの更新日とURLとをデータベースに保存しておき、それを元にキャッシュの有効期限を判定すると良いかもしれない。
実際過去仕事で似たような要件があり、その時は他のエンジニアがDBに各ユーザーがいつキャッシュしたかを保存しておいて、それを元にキャッシュの有効期限を判定するような実装をしていた気がする。有効期限内であれば署名付きURLを発行せず、HTTPステータス304を返してキャッシュを利用する的なやつだ。
ただしこの実装は大量の画像・動画を扱っている場合、ユーザー数*画像数分のデータを保存する必要があるので割と大変。
(バッチで定期的にキャッシュが失効したタプルを削除するとかすればいいのかもしれないが、ちょっと面倒)
Supabase Storageでも同じ解決策が使えるのでは?
多分いけるかもしれない。が、以下のSupabase clientの実装をみる限りはサーバーサイド側で時間の計算をやって署名を生成しているようなので、クライアントサイドの時間をイジっても無理かもしれない。(誰か試してくれると助かるかも)
1
2
3
4
5
6
let data = await post(
this.fetch,
`${this.url}/object/sign/${_path}`,
{ expiresIn, ...(options?.transform ? { transform: options.transform } : {}) },
{ headers: this.headers }
)
-- 該当行
AWS SDK v3でもうまくいく理由は、クライアントサイドで new Date()が使われているからだと思われる。
(このあたりか?https://github.com/aws/aws-sdk-js-v3/blob/33700a2612cd5809fd2a75c954343524ead2bf74/packages/signature-v4-crt/src/CrtSignerV4.ts#L89)
Cloudflare R2
他の代替策として調べるとでてきたのがCloudflareのR2。S3と互換性がかなりあるようで、AWS SDK v3もそのまま使えるっぽい。 転送量がS3よりも安いらしいので、こちらを使うのもありかもしれない。検討中。