はじめに
公開前にAWSの費用を実測で計算し直したら、インフラ(Lambda / S3 / CloudFront / API Gateway)の費用は当初見積の41%でした。ここまでは見積が多めだっただけで、何もしていません。そこから手を入れて、実際の費用をさらに4割ほど(1万回生成あたり ¥1,360→¥770)下げました。当初見積に対しては23%です。
題材は、弊社で開発したLINE上の画像生成キャンペーンのサイトです。ユーザーがLIFFで写真をアップロードすると、AIで写真をチェックして背景を切り抜き、デザイン素材と合成した完成画像を返します。完成画像は画面に表示され、端末への保存やLINEでのシェアができます。
下げた分の大半は、完成画像の保存形式を変えただけで済みました。画質や使い勝手は落ちていないので、画像を配るサービスを作る方の参考になればと思い、書いておきます。
金額は「月に数百万回生成される」想定での試算です。案件の規模が出ないよう、本文では1万回生成あたりの金額に直しています(為替は ¥155/$)。
結果
| 項目 | 着手前 | 実装後 |
|---|---|---|
| CloudFront | ¥878 | ¥348 |
| S3 | ¥84 | ¥40 |
| Lambda | ¥382 | ¥366 |
| API Gateway | ¥16 | ¥16 |
| 合計(1万回生成あたり) | ¥1,360 | ¥770 |
| 1回生成あたりの転送量 | 約5.3MB | 約1.7MB |
| 当初見積に対する割合 | 41% | 23% |
やったことは次の3つです。
- 完成画像を PNG から JPEG(品質90)にした(削減分の約75%)
- フロントの静的画像を WebP にした(約20%)
- x86_64 のまま残っていた Lambda を arm64 にした(約3%)
実測で計算し直した
見積の段階では構成も画像サイズも決まっていないので、多めに見積もるのは仕方ないと思います。今回は公開前に構成が固まったところで、実測値を使って次のように計算し直しました。
- Lambda:CloudWatch の平均実行時間 × メモリ × 1生成あたりの呼び出し回数
- CloudFront:ファイルサイズ × 1生成あたりの取得回数 × 転送単価(日本向け 10TBまで $0.114/GB、以降 $0.089/GB)+ リクエスト単価
- S3:保存量 × 保存日数 + リクエスト数
計算し直すと、全体の64%が CloudFront の転送でした。Lambda は構成を変えても金額がほとんど変わりません。転送量は、ほぼ「1枚あたりのサイズ × 配信する回数」で決まっていました。
完成画像がPNGだった
合成処理(Pillow)が完成画像を PNG で書き出していました。dev環境の完成画像444枚を測ると、平均1,583KBです。
この画像は、1回の生成で何度か配信されます。
1.5MBの画像を1生成あたり2.5回配信すると、それだけで約4MBになります。静的画像も合わせた約5.3MBの大半はこれでした。
形式ごとに測った
同じ完成画像8枚(1080×1920)を変換して比べました。
| 形式 | 平均サイズ | PNG比 | PSNR | エンコード時間 | LINEで送れるか |
|---|---|---|---|---|---|
| PNG(変更前) | 1,364KB | 100% | – | 47ms | ○ |
| WebP q85 | 118KB | 9% | – | 71ms | × |
| WebP q92 | 179KB | 13% | 37.6〜46.3dB | – | × |
| JPEG q90 | 346KB | 25% | 41.5〜45.9dB | 5ms | ○ |
※PNGの平均が全体の平均(1,583KB)と違うのは、8枚に絞った標本の差です。
PSNR(ピーク信号対雑音比)とは
圧縮した画像が、元の画像とどれくらい違うかを表す数値です。単位は dB(デシベル)で、大きいほど元の画像に近いことを表します。
元画像と圧縮後の画像で同じ位置の画素の値の差を取り、2乗して画像全体で平均します(平均二乗誤差、MSE)。それを画素の最大値(8bit なら 255)と比べて対数で表したものです。
\mathrm{PSNR} = 10 \log_{10} \frac{255^2}{\mathrm{MSE}}
まったく同じ画像なら差が0なので、無限大になります。数字の目安は次のとおりです(よく使われる経験則です)。
| PSNR | 見え方 |
|---|---|
| 40dB 以上 | 並べて比べても、ほぼ違いがわからない |
| 30〜40dB | 拡大したり注意して見たりすると、違いがわかる |
| 30dB 未満 | ひと目で劣化がわかる |
たとえば 41.5dB は、0〜255 の画素の値が平均で約2段階ずれている状態です。表の「37.6〜46.3dB」は、8枚のうち一番悪かった画像と一番良かった画像の値です。
ただし画像全体の平均なので、小さい文字のまわりだけがにじむような局所的な劣化は数字に表れにくいです。今回も PSNR だけで決めず、一番小さい文字を拡大して目でも確かめています。
サイズだけなら WebP が一番小さいのですが、今回は使えませんでした。
LINEの画像メッセージは JPEG と PNG だけ
完成画像は、LIFF の shareTargetPicker で画像メッセージとして送っています。
await liff.shareTargetPicker([
{ type: 'image', originalContentUrl: url, previewImageUrl: url },
])
Messaging API リファレンスの画像メッセージでは、originalContentUrl と previewImageUrl のどちらも「画像フォーマット:JPEGまたはPNG」となっています。WebP ではシェアに使えません。
そこで JPEG(品質90)にしました。
- PSNRは8枚すべて41.5dB以上で、並べて見ても違いはわかりませんでした。画像に焼き込んだ一番小さい文字(著作権表記)も、4倍に拡大して読めました
- サイズは約1/4です
- エンコードも 47ms から 5ms になり、合成Lambdaの実行時間も少し減りました
また、previewImageUrl には最大1MBという制限もあります。PNGのころは平均1.5MBの画像をそのまま渡していたので、この上限を超えていました(目に見える不具合は確認していません)。JPEGにしてからは上限に収まっています。
JPEGに変えるときに気をつけたこと
コードはこのような形です。
import io
import os
from PIL import Image
_OUTPUT_FORMAT = os.environ.get("OUTPUT_FORMAT", "jpeg").strip().lower()
_JPEG_QUALITY = int(os.environ.get("OUTPUT_JPEG_QUALITY", "90"))
def encode(image: Image.Image) -> tuple[bytes, str, str]:
"""完成画像を保存用のバイト列にする。→ (bytes, 拡張子, Content-Type)"""
buf = io.BytesIO()
if _OUTPUT_FORMAT == "png":
image.convert("RGBA").save(buf, format="PNG")
return buf.getvalue(), "png", "image/png"
# 透明部分が残っていると convert("RGB") はそこを黒にするので、白地に載せてから落とす
rgba = image.convert("RGBA")
flat = Image.new("RGB", rgba.size, (255, 255, 255))
flat.paste(rgba, mask=rgba.split()[3])
# subsampling=0 は色差を間引かない設定。既定の 4:2:0 だと細い文字の縁ににじみが出る
flat.save(buf, format="JPEG", quality=_JPEG_QUALITY, subsampling=0)
return buf.getvalue(), "jpg", "image/jpeg"
- 環境変数で PNG に戻せるようにしました。画質について指摘があっても、Lambda の環境変数を変えるだけで戻せます。受託開発では、すぐ戻せると言えるだけでクライアントの判断が早くなります
- 透明な部分は白地に載せてから RGB にしています。
convert("RGB")は透明な画素を黒にするので、背景素材に少しでも透過が残っていると、そこが黒くなります -
optimize=Trueは付けていません。実際の画像では4%しか小さくならず、ノイズの多い小さな画像(400×600のランダムノイズで再現)では Pillow がOSError: broken data streamで落ちました。4%のために生成が失敗するのは割に合いません
静的画像は WebP にした
初めて開いたときに必ず読み込まれる画像も、13点で合計1,629KBありました。こちらはブラウザで表示するだけで LINE には送らないので、WebP にできます。
変換と参照(import や CSS の url())の書き換えをまとめて行うスクリプトを用意し、デザイナーから新しい素材が来たら src/assets/ に置いて実行するだけにしました。
結果は 1,629KB から 646KB に、ビルド成果物全体でも 4.8MB から 1.3MB になりました。
素材によって変換の仕方を変えています。
| 素材 | 方式 | 理由 |
|---|---|---|
| 元が JPEG | 非可逆 q85 | 元が既に非可逆。可逆にするとむしろ膨らむ(655KB → 1,828KB になった) |
| 元が PNG | 可逆のほうが小さければ可逆、そうでなければ q85 → q92 | 元画像と比べて PSNR 38dB を満たした時点で採用 |
| ノイズの多い写真の PNG | どの品質でも非可逆 | 品質を上げても PSNR が頭打ちになる(q98でも37.5dB)。可逆にすると 67KB が 292KB になる |
透過PNGの画質を PSNR で測るときは、透明な部分を同じ色の地に載せてから比べてください。
完全に透明な画素の RGB 値は、WebP が別の色に置き換えます。見た目は同じなのに、RGB のまま比べると PSNR が10dB台まで落ちます(SNSのロゴ画像で実際に起きました)。
def flatten(img: Image.Image) -> Image.Image:
"""α を灰色地に載せて RGB にする(PSNR の比較用)"""
if img.mode not in ("RGBA", "LA"):
return img.convert("RGB")
rgba = img.convert("RGBA")
base = Image.new("RGB", rgba.size, (128, 128, 128))
base.paste(rgba, mask=rgba.split()[3])
return base
JS / CSS / SVG は、CloudFront の圧縮(Compress: true)で gzip 後 136KB に収まっていたので触っていません。
Lambda はあまり削れなかった
Lambda は着手前の費用の約3割を占めていましたが、ほとんど下げられませんでした。
- x86_64 のまま残っていた関数が3本ありました。依存が純粋な Python(boto3 はランタイムに同梱)だったので、
architectureを arm64 に変えるだけで済みました。単価は約20%下がりますが、金額としては小さいです - 一番時間がかかっていた関数(平均6.5秒)は、ほとんどが Bedrock の応答待ちでした。待っている間も Lambda の料金はかかりますが、同期で結果を返す作りなので避けられません。Step Functions に移す手もありましたが、下がる金額のわりに作りが複雑になるので見送りました
公開後に測り直した
公開後、本番の完成画像を測り直しました(完成画像は1日で消える設定なので、測れたのは直近1日分の26枚です)。平均は465KBで、PNGのころの平均1,583KBの29%でした。事前に見込んだ25%とほぼ同じです。
おわりに
費用を下げようとすると、サーバーの台数やインスタンスの種類から見直しがちですが、今回一番効いたのは完成画像の形式でした。画像を配るサービスなら、完成画像の平均サイズと、1回の利用で何回配信しているかを一度数えてみると、削れるところが見つかるかもしれません。