はじめに
運営しているサービスの TOP ページが重く、調べてみると原因の大半は画像なので画像を圧縮して軽くしました。
画像は 1080×1080 の WebP(1 枚 260〜430KB)なのに、画面上では約 300pxしか表示していません。TOP だけで画像 78 枚、合計 26MB ほどありました。
そこで、CloudFront で配信するときに Lambda で縮小して返す仕組みを入れました。元の画像はそのままで、縮小した画像は CloudFront のキャッシュにだけ置く構成です。
やったことと、躓いたところを書いていきます。
結果
検証環境での実測です。
| 縮小前 | 縮小後 | |
|---|---|---|
| 画像(78 枚) | 26.8MB | 11.9MB(56% 減) |
※画面に表示されたら画像を読み込むようにしているため一気に読み込むわけではありません。
縮小の設定は 短辺 720px・WebP 画質 80 にしました。いくつかの方式・サイズで実物を並べて見比べ、見た目の差が分からないところで決めています。
圧縮しすぎるとガビガビになるので、比較することが大切です。
構成
ブラウザ → CloudFront /img/w720/* → Lambda 関数 URL(OAC で保護)→ S3 から元画像を読む → 縮小して返す
└ キャッシュにあれば Lambda は呼ばれない
AWS 側で足したのは次の 4 つだけです。既存のビヘイビアと S3 には手を入れていません。
- Lambda 関数(Node.js・arm64・sharp で縮小)
- Lambda 関数 URL
- CloudFront のオリジン(Lambda 関数 URL)と OAC
- CloudFront のビヘイビア(
/img/w720/*→ 上のオリジン)
用語: オリジンと OAC
オリジン
オリジン(origin = 起源・元)は、CloudFront の奥にある、本物のデータを持っている場所です。ブラウザが最初に話しかけるのは CloudFront で、CloudFront はキャッシュに無いときだけオリジンへ取りに行きます。
ブラウザ → CloudFront(最初に通る場所) → オリジン(奥にある元の置き場)
コンビニと倉庫に例えると分かりやすいです。
- CloudFront = 駅前のコンビニ。よく売れる物を棚に置いている(キャッシュ)
- オリジン = 倉庫。全部の在庫がある本拠地
棚にあればすぐ買えて、無ければコンビニが倉庫から取り寄せます。取り寄せた物は棚に置いておくので、次のお客さんはすぐ買えます。
今回のディストリビューションには、オリジンが 3 つあります。
| オリジン | 置いてあるもの |
|---|---|
| S3 | 原寸の画像 |
| ALB | サイト本体(Laravel が作る HTML など) |
| Lambda 関数 URL | 縮小版の画像(注文が来たらその場で作る倉庫) |
どのパスをどのオリジンへ取りに行くかは、ビヘイビアで決めます(/uploads/* → S3、/img/w720/* → Lambda 関数 URL、それ以外 → ALB など)。
OAC(Origin Access Control)
OAC は、CloudFront からのアクセスだけをオリジンに通すための仕組みです。
- CloudFront がオリジンへ取りに行くとき、リクエストに AWS の署名(SigV4)を付ける
- オリジン側は、署名が正しく、しかも許可した CloudFront からのものであるときだけ応じる
- 署名の無いリクエスト(関数 URL をブラウザで直接開く等)は 403 で断る
CloudFront に社員証を持たせて、倉庫は社員証を見せた人だけを通す、というイメージです。
今回は次の 3 か所で設定しています。
| 場所 | 設定 |
|---|---|
| CloudFront のオリジン(関数 URL) | OAC を付ける。CloudFront が署名して呼ぶ |
| Lambda の関数 URL | 認証タイプを AWS_IAM にする。署名の無いリクエストを断る |
| Lambda のリソースポリシー | 「このディストリビューションからの呼び出しだけ許可」と書く |
関数 URL を認証なし(NONE)にすれば OAC なしでも動きますが、URL を知っている人なら誰でも直接叩けてしまいます。CloudFront のキャッシュも WAF も通らないので、叩かれるたびに Lambda が走り、費用と負荷を外から増やされます。
Lambda の動かし方は大きく 2 つ
Lambda を動かす方法は、大きく分けて 2 つあります。違いは「呼んだ側が結果を待つかどうか」です。
① トリガー(イベントで動く)
「何かが起きたら動かす」型です。Lambda のコンソールの「トリガー」欄に設定します。
| 例 | きっかけ |
|---|---|
| S3 | ファイルが置かれた |
| EventBridge | 決まった時刻になった・何かの状態が変わった |
| SQS | キューにメッセージが入った |
- 呼んだ側は結果を待たない。「起きたよ」と知らせて終わり
- 処理の結果は、S3 に保存する・DB に書くなど、別の場所に残す
② 直接呼ぶ(リクエストに応える)
「呼ばれたら答えを返す」型です。
| 例 | 呼び方 |
|---|---|
| 関数 URL | HTTP で URL を叩く(今回) |
| API Gateway・ALB | HTTP(認証や経路を細かく制御したいとき) |
| AWS SDK・CLI | プログラムから Invoke を呼ぶ |
- 呼んだ側は結果を待つ。Lambda の戻り値がそのまま応答になる
使い分け
| 状況 | 選ぶもの |
|---|---|
| 呼んだ側が、その場で結果を必要とする | ② 直接呼ぶ |
| 結果は後で別の場所にあればよい(裏で処理しておく) | ① トリガー |
画像の縮小で言えば、こうなります。
- ② 見られたときに作る(今回): ブラウザは画像をその場で待っているので、CloudFront が関数 URL を呼び、縮小した画像をそのまま応答で返す
- ① 置かれたときに作っておく: 「S3 に画像が置かれたら縮小版を作って S3 に保存する」形。この場合、S3 のファイルが 2 つになり、すでにある画像は別途一括で変換する必要がある
今回は元の画像を 1 つのままにしたかったので、② を選びました(後半の「採らなかった方式」も参照)。
アプリ側は URL を書き換えるだけ
PHP から Lambda を呼ぶことはありません。PHP がしているのは、HTML の <img src> を原寸の URL から縮小版の URL に書き換えることだけです。
// 原寸: https://example.com/uploads/a.webp
// 縮小版: https://example.com/img/w720/uploads/a.webp
$cardImageUrl = rtrim(config('app.media_resize_base_url'), '/') . parse_url($originalUrl, PHP_URL_PATH);
- 環境変数
MEDIA_RESIZE_BASE_URLが空なら原寸のまま。これで dev 環境はなにも変わらず、本番も環境変数を消せば数秒で元に戻せます - 縮小版の読み込みに失敗したら、
onerrorで 1 回だけ原寸に差し替えます。Lambda が落ちても「重いだけ」で済みます
<img src="/img/w720/uploads/a.webp"
data-fallback-src="/uploads/a.webp"
onerror="if(this.dataset.fallbackSrc){this.src=this.dataset.fallbackSrc;delete this.dataset.fallbackSrc;}"
loading="lazy" decoding="async">
Lambda の中身
使っている技術: Node.js・arm64・sharp
Lambda 関数は「Node.js で書いて、arm64 で動かし、画像の縮小は sharp に任せる」構成にしました。
| 項目 | 何か | 選んだ理由 |
|---|---|---|
| Node.js | Lambda の中で動かすプログラムの言語。Lambda は Node.js・Python・Java などから選べる | 画像処理の定番ライブラリ sharp が使えるため |
| arm64 | Lambda を動かす CPU の種類。x86_64(Intel・AMD 系)と arm64(AWS Graviton)から選べる | 同じ処理を x86_64 より 2 割ほど安く動かせるため |
| sharp | Node.js で画像を扱うライブラリ。中身は libvips という C 言語の高速な画像処理エンジン | 縮小・WebP への変換・向きの補正が数行で書けて速いため |
ハマりどころ: sharp は「動かす環境向け」のものを入れる
sharp は C 言語のエンジンを含むので、OS と CPU に合った実体が必要です。Mac で npm install した sharp は Mac 用なので、そのまま zip にして Lambda(Linux・arm64)に上げても動きません。
そこで、Linux の arm64 向けの sharp を指定して入れ直してから zip にしています。
# Linux arm64 向けの sharp を、本番用の依存だけで入れる
npm ci --omit=dev --os=linux --cpu=arm64 --libc=glibc --ignore-scripts
# プログラムと依存を zip にまとめて、Lambda に上げる
zip -qr function.zip package.json src node_modules
AWS SDK(S3 から読むためのライブラリ)は Lambda の Node.js ランタイムに最初から入っているので、zip には入れていません。
処理の流れ
処理の流れはシンプルです。
- GET / HEAD 以外は 405
- パスから
/img/w720/を取り除き、URL デコードして S3 キーにする。不正なパスは 400 - S3 から元画像を読む。無ければ 404
- 縮小しないほうがよい画像は、原寸の URL へ 302
- sharp で縮小(Exif の向きを補正 → 短辺 720px → WebP 画質 80)して 200 で返す
function resize(buffer, { shortSide, quality, maxPixels }) {
return sharp(buffer, { limitInputPixels: maxPixels })
.rotate() // Exif の向きに合わせる
.resize({ width: shortSide, height: shortSide, fit: 'outside' }) // 短辺を shortSide(720)にそろえる
.toColourspace('srgb') // 色を sRGB にそろえる
.webp({ quality }) // 画質 80
.toBuffer();
}
短辺 720px 以下の画像は、この関数に来る前に 302 で原寸へ返しているので、拡大は起きません。
縮小しない画像は 302 で原寸へ
次の画像は縮小せずに、原寸の URL へリダイレクトしています。
- アニメーション GIF・アニメーション WebP・APNG(縮小すると動きが消える)
- もともと短辺 720px 以下の画像
- 25MB 超・2,500 万画素超の画像
- 縮小したほうがかえって大きくなる画像
ここで 302 にしているのは、Lambda 関数 URL の応答には 6MB の上限があり、大きな原寸をそのまま返せないからです。CloudFront は 302 もキャッシュするので、2 回目以降は Lambda を呼ばずにリダイレクトされます。
キャッシュ
| 場合 | Cache-Control |
|---|---|
| 縮小した |
public, max-age=86400, s-maxage=2592000(ブラウザ 1 日・CloudFront 30 日) |
| 原寸へ 302 | public, max-age=86400 |
| 元が無い(404) | public, max-age=60 |
- 1 年にしなかったのは、画像を消した・差し替えたときに、無効化しなくても期限で消えるようにするためです。再変換は画像ごとに月 1 回程度なので、費用は気になりません
- 縮小の条件(サイズ・画質)を変えるときは、パスの
w720を別の名前(w720bなど)に変えます。同じパスのまま中身を変えると、古い結果がキャッシュに残ってしまうためです
躓いたところ
1. 日本語のファイル名と URL エンコード
ファイル名に日本語・空白・括弧が入っていて、しかも Unicode の NFC と NFD が混ざっていました。
- PHP 側は
rawurlencodeで URL を作っている - JavaScript の
encodeURIComponentは! ' ( ) *をエンコードしないので、そのまま比べると括弧入りのファイル名が一致しない
そこで Lambda 側に、PHP の rawurlencode と同じ結果を返す関数を書きました。
const UNRESERVED = /[A-Za-z0-9\-_.~]/;
export function rawurlencode(value) {
let encoded = '';
for (const byte of Buffer.from(String(value), 'utf8')) {
const char = String.fromCharCode(byte);
encoded += byte < 0x80 && UNRESERVED.test(char)
? char
: '%' + byte.toString(16).toUpperCase().padStart(2, '0');
}
return encoded;
}
届いたパスを 1 回だけデコードして S3 キーにし、もう一度エンコードした結果が届いたパスと一致しなければ 400 にしています。%41 と A のような別表記で、同じ画像を何度も変換させないためです。デコードは 1 回だけなので、NFC / NFD のどちらで保存されていても、原寸と同じキーを指します。
2. OAC の権限は 2 つ要る
関数 URL は AuthType: AWS_IAM にして、CloudFront の OAC からだけ呼べるようにしました。このとき Lambda のリソースポリシーで、cloudfront.amazonaws.com に
lambda:InvokeFunctionUrllambda:InvokeFunction
の両方を、対象のディストリビューションに限って許可する必要があります。片方だけだと全件 403 になります。
3. ビヘイビアの順番
既存のビヘイビアに /*.webp → ALB のような拡張子ごとのものがあり、/img/w720/* をその後ろに置くと、/img/w720/xxx.webp が /*.webp に一致して ALB(アプリ本体)へ流れてしまいます。
-
/img/w720/*は拡張子のビヘイビアより前に置く - 念のため、アプリ側にも
img/以下を 404 で返すルートを足しました(設定を誤っても、存在しない URL のリダイレクト先として TOP が何十回も描画されないように)
4. 6MB の上限とタイムアウト
- 先に書いたとおり、関数 URL の応答は 6MB まで。大きな画像は 302 に逃がす
- タイムアウトは 8 秒、CloudFront 側のオリジンの応答タイムアウトも 10 秒に短くしました。変換を待つ間は
onerrorが発火せずカードが空白になるので、空白の最長時間を短くするためです - 予約同時実行を 20 にして、費用と負荷に上限を付けています。超えた分は 429 になり、画面側の
onerrorで原寸に落ちます
戻し方
いざというときの戻し方も先に決めておきました。
| 状況 | やること |
|---|---|
| Lambda の費用・エラーが増えた | 予約同時実行を 0 にする(新しい変換が止まる。キャッシュ済みの縮小版は出続ける) |
| 縮小版の見た目がおかしい | 環境変数 MEDIA_RESIZE_BASE_URL を消す(アプリが原寸の URL を出すようになる) |
まとめ
- Lambda は「トリガー」で動かすのではなく、関数 URL を CloudFront のオリジンにするだけで、配信時の画像変換ができる
- 縮小した画像は CloudFront のキャッシュにだけ置くので、S3 の元画像は 1 つのまま
- 縮小しない画像は 302 で原寸へ逃がす(6MB の上限対策にもなる)
- 日本語のファイル名を扱うなら、URL エンコードの方式を PHP 側とそろえる
感想
画像圧縮は作業者の負担のならない方法でやらないといけないなと思います。
例えば、圧縮してS3にアップロードするフローだと、作業者の工程が増えるので、
- インポート時に自動圧縮
- ユーザーが読み込み時に自動圧縮
の手段を取る必要があります。
ユーザーの読み込みが軽くなったからいいやではなく、作業者の負担も考えて実装できると良いと心がけています。


