meltpicというものを作っている
できるだけ低コストで動く画像アップローダーを作ってみたくて、meltpicというサービスを作っています。
画像を長く保存する必要はなくて、ちょっと貼れればそれでいい。そういう用途に絞れば、かなり安くできるんじゃないか、というのが最初の発想でした。
作っていく中で気になったのが画像URLの長さです。初期の60分版は111文字ありました。
別に111文字でも動くんですが、一時的に画像を貼るだけのサービスなのにURLがこんなに長いのはちょっと嫌でした。
今回は、これを39文字まで短くしていった話です。
最初のURLが長かった
初期のURLは、こういう形でした。
https://i.meltpic.com/1/60/AAECAwQFBgcICQoLDA0ODw.webp?v=1787580900-ccMGKt8T7_hQ5_q9zX8Ife1s2Uo2RsRW7ml_j7Xtn1s
これで111文字です。
長い部分には、それぞれ役割がありました。
60 は有効期間の分数。その次の22文字は128ビットのランダムな画像IDです。?v= の後ろには10桁の発行時刻と、43文字にエンコードしたHMAC-SHA-256の認証タグを入れていました。
時刻や有効期間は隠していたわけではありません。値を書き換えられても認証を通らないようにしていました。HMACには画像のパスと発行時刻が入り、そのパスには画像IDと有効期間が含まれます。
この形なら、サーバーはURLだけを見て画像の保存先と正確な有効期限を求められます。通常画像の一覧をデータベースに持たず、R2へ画像を取りに行く前に認証と期限を確認できました。
つまり長かったのは、画像名だけでなく、閲覧に必要な情報までURL自身に持たせていたからです。
最初は「詰めれば短くなる」と思っていた
最初は、単純にエンコードを詰めればかなり短くできると思っていました。
実際、最初にやったのは情報の内容を変えることではなく、並べ方を変えることでした。
バージョン、有効期間、発行時刻を5バイトのヘッダーにまとめ、16バイトの画像IDと32バイトのHMACを続けます。合計53バイトをbase64urlにすると71文字。URL全体では95文字になりました。
この段階では、画像IDの乱数幅もHMACの長さも減らしていません。10進数の時刻、区切り、クエリといった表記上の長さを詰めただけです。
次に、HMACの出力からURLへ載せる部分を32バイトから16バイトへ変え、トークンを50文字にしました。URL全体は74文字。その後 .webp を付ける変更が入り、79文字になっています。
ここまではまだ、「時刻、画像ID、認証タグをまとめてURLで運ぶ」という考え方のままでした。
でも、そこから先は表記を工夫するだけでは大して縮みません。
途中でようやく、「URLに載せている情報そのものを減らさないと無理だな」と気付きました。
現在の12文字のトークンでは、正確な時刻と内部の画像IDを、そのままURLに載せるのをやめています。
削っていいものと、削ってはいけないもの
短くするためなら何でも削っていいわけではありません。
特に気にしていたのは、画像を見るたびに余計なデータベース参照や追加のストレージアクセスを増やしすぎないことでした。
できるだけ低コストで動かしたいのに、URLを短くするためだけに毎回別の対応表を引くようになったら本末転倒です。
なので、「どの情報ならURLから消せるか」と同時に、「消した情報をどこへ移せば取得時のコストを増やしすぎずに済むか」を考えることになりました。
現在のトークンは9バイトです。
| 内容 | ビット数 |
|---|---|
| 期限を扱う時間枠の手掛かり | 4 |
| ランダムな値 | 28 |
| HMACの認証タグ | 40 |
| 合計 | 72 |
base64urlで表すと12文字になります。
まず、有効期間はサーバー側で60分に固定しました。以前の5分・15分・60分という選択値をURLに入れる必要がなくなります。
次に、期限を5分単位の時間枠で扱います。失効時刻を300秒で割って切り上げた番号を使います。
URLへ載せるのは、その番号を16で割った余りだけです。必要なのは4ビット。
もちろん同じ値は繰り返します。ただ、画像は60分で失効するので、現在時刻から候補になる5分枠を絞ると最大13個しかありません。0〜15の値があれば、その範囲では一つに決められます。
つまり、正確な時刻を全部URLに入れなくても、現在時刻と組み合わせれば元の時間枠を復元できます。
ただし、画像の有効期限そのものを5分単位に丸めたわけではありません。
正確な発行時刻と失効時刻は、R2の画像オブジェクトのメタデータに持たせています。画像を取得したときに、時刻の形式、発行から失効までが3600秒か、復元した時間枠と一致するか、まだ有効かを確認します。R2の読み取り中に期限をまたいだ場合も、読み取り後の時刻で判定します。
画像IDも、独立した長い値をURLへ入れるのをやめました。28ビットの乱数値と復元した時間枠などからHMACを計算し、その出力の一部を内部の画像IDとして使います。公開する認証タグとは別の部分です。
これなら、サーバーは正しいURLから同じ保存先を導出できます。短いURLと長いURLの対応表を、別のデータベースに持つ必要はありません。
削ったのは、正確な時刻や内部IDを「そのままURLで運ぶ部分」です。期限確認や閲覧権限の認証まで削ったわけではありません。
一方で、認証タグは実際に短くしています。以前の128ビットから現在は40ビットです。ここは「表記を詰めただけで、安全性は全部同じ」とは言えません。
短くしすぎると困る
12文字は72ビット分の表現ですが、72ビット全部がランダムではありません。時間の手掛かりとHMACも含まれています。
ランダムに選ぶ部分は28ビットなので、同じ時間枠の中では約2億6844万通りです。内部の画像IDが128ビットの形でも、同じ時間枠で独立した128ビット乱数を選んでいるわけではありません。
ここでは、URLを当てられにくくする話と、画像同士の衝突は分けて考える必要がありました。
URLを推測して画像を取得するには、存在する画像に対応する28ビットの値だけでなく、正しい認証タグも必要です。乱数部分や時間成分を書き換えれば、それに対応するHMACも必要になります。
ただし、40ビットのタグには40ビット分の有限な認証余裕しかありません。「推測は絶対に不可能」とも、以前の128ビットタグと同等とも言えません。
衝突の方は、起きないことを前提にしませんでした。同じ時間枠で同じ28ビットの値を選ぶと、同じ保存先になります。
保存には、既にオブジェクトがあれば作成しない条件付きPUTを使っています。衝突したことが確定した場合だけ別の値を選び、試すのは合計4候補まで。通信エラーなどで書き込みの成否が分からないときは、別のIDを作って続行しません。
ここで、削除にも変更が必要になりました。
画像を単純に物理削除すると、その保存先が空きます。後で同じ時間枠で同じ値を引いた場合、古いURLから新しい別の画像が見えてしまう可能性があります。
そこで、期限前に削除した画像は保存先を空にせず、ゼロバイトの「削除済みマーカー」を残すことにしました。
配信側はこれを画像として返さず、作成側は使用済みの保存先として扱います。マーカーは期限に応じた後片付けまで残します。
URLを短くした結果、削除後に識別子を再利用させないことも、はっきり実装する必要が出てきました。
期限についても、短い時刻表現だけで済ませてはいません。HMACが認証するのは4ビットの余りだけではなく、復元した完全な時間枠の番号です。
余りが一周したからといって、古いURLをそのまま新しい世代のURLとして使える仕組みにはしていません。
さらに、正確な期限はサーバーが保存したメタデータで確認します。利用者がURLの数字を書き換えて期限を延ばしたり、有効期間を指定し直したりする機能もありません。
低コストのまま短くしたかった
ここはURLを短くするうえでかなり気にしたところです。
旧方式では、正確な期限切れをR2へ行く前に判定できました。現在は、URLから復元した時間枠としてはまだ候補に残っていても、実際には期限切れ、という短い区間があります。その場合はR2を一回読んでから拒否します。
つまり、期限後の画像を返さない条件は維持していますが、判定する場所は以前と同じではありません。
一方で、生きている画像のGETは以前も現在もR2 GET一回です。短縮URLの対応表を引くためのデータベースや、追加のHEADは入れていません。メタデータも同じ読み取りで確認します。
その代わり、期限前の削除は単純なDELETEからHEADと条件付きPUTに変わり、通報時の対象確認にもHEADが加わりました。
なので、「短くしたら安くなった」という話ではありません。
DBを増やさず、普段の画像取得をR2 GET一回のままにしつつ、URLを短くする。その条件の中で設計を変えました。
最終的にこうなった
初期の60分版は111文字でした。
https://i.meltpic.com/1/60/AAECAwQFBgcICQoLDA0ODw.webp?v=1787580900-ccMGKt8T7_hQ5_q9zX8Ife1s2Uo2RsRW7ml_j7Xtn1s
現在は39文字です。
https://i.meltpic.com/erze8DelLGop.webp
どちらも比較用の例です。
39文字の内訳は、https://i.meltpic.com が21文字、区切りの / が1文字、トークンが12文字、.webp が5文字です。
なぜこの長さで止めたのか
現在の長さは、4・28・40ビットの配分を9バイトにまとめた結果です。
時間の手掛かりは、最大13個の候補を区別するために4ビット必要です。28ビットの乱数部分をさらに削れば、同じ時間枠での衝突が増えます。認証タグを削れば、改ざんや推測に対する余裕が減ります。
この先は、区切り文字を消すような単純な短縮ではありません。
39文字が理論上の最短という話ではないし、40ビットのタグをどんな用途にも十分だと勧める話でもありません。
今の用途で残したい条件と、短縮のために変えていい条件を考えた結果、いまはここで止めています。
おわりに
やってみて一番苦しかったのは、短くする方法を考えることより、どこなら削っていいのかを決めることでした。
文字を1個削るだけなら簡単です。でも、その1個が衝突しにくさだったり、改ざんへの余裕だったり、画像取得時のコストだったりします。
最初は「長いURLを短くしたい」くらいの話だったのに、最後は期限管理や削除方法まで触ることになりました。
URL短縮というより、URLに何を背負わせるかを決め直す作業だった気がします。
この仕組みを使っているのが meltpic です。