本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
対象と構成
およそ 49000×28000 px、Pyramid TIFF(PTIF)換算で約 450MB の高精細画像を、IIIF(International Image Interoperability Framework)Image API 経由で配信する構成を扱います。利用したのは Samvera serverless-iiif で、AWS Serverless Application Repository(SAR)からデプロイしたものです。
おおまかな構成は次の通りです。
[Viewer (Mirador など)] → [CloudFront]
├── キャッシュHit ─→ そのまま返す
└── キャッシュMiss ─→ [Lambda (Serverless IIIF)]
└─→ [S3 上の PTIF]
- Lambda は AWS Lambda Function URL で受け、CloudFront をフロントに置いています
- PTIF は S3 のオブジェクトとして保管しています
- すべて
ap-northeast-1(東京リージョン) で完結しているため、リージョン間のレイテンシは考慮対象から外しています
最初は素のデフォルト構成で動かしていましたが、画像サイズが大きくなるとレンダリングのタイムアウトや 429 応答が見られたため、複数の観点から調整を行いました。順番に整理します。
PTIF のエンコード設定
vips tiffsave で JPEG ソースから PTIF を作成したところ、元の JPEG(1.37GB)と出力 PTIF(1.30GB)がほぼ同じサイズになり、期待していた縮小が得られませんでした。同じ仕組みで配信している別画像(非圧縮 TIFF から作成した 230MB の PTIF)と比べると、バイト/ピクセル比に 6 倍ほどの差がありました。
最初に使ったコマンドは次の通りです。
vips tiffsave input.jpg output.tif --tile --pyramid --compression jpeg \
--Q 90 --tile-width 256 --tile-height 256
tiffinfo で内部構造を比べると、Photometric Interpretation(測色の種類)が異なっていました。
$ tiffinfo small.tif # 230MB
Photometric Interpretation: YCbCr # 4:2:0 クロマサブサンプリングが効く
$ tiffinfo large.tif # 1.3GB
Photometric Interpretation: RGB color # フル解像度RGBで保存されている
YCbCr で保存できれば 4:2:0 クロマサブサンプリングが適用され、色差信号が 1/4 に圧縮されます。RGB のままだと 3 チャンネルすべてフル解像度になります。
Q 値による分岐
8192×8192 のサンプルを切り出し、Q 値とタイルサイズを変えて検証した結果が次の表です。
| Q | tile | Photometric | サイズ |
|---|---|---|---|
| 90 | 256 | RGB | 75.9 MB |
| 90 | 512 | RGB | 75.8 MB |
| 85 | 256 | YCbCr | 21.0 MB |
| 85 | 512 | YCbCr | 20.9 MB |
libvips の jpegsave 系には subsample_mode というパラメータがあり、デフォルトは auto です。動作としては、Q が 90 以上のときはクロマサブサンプリングを行わず、それ未満のときは YCbCr 4:2:0 でサブサンプリングを行うようです。
調査した限り、tiffsave の CLI には subsample_mode を直接上書きするオプションがなく(libvips 8.18 で確認)、Q 値を 89 以下にするのが実用的な手段になりました。
採用したコマンド
vips tiffsave input.jpg output.tif --tile --pyramid --compression jpeg \
--Q 85 --tile-width 512 --tile-height 512 --strip
--Q 85: 90 未満にすることで YCbCr 4:2:0 が選ばれます--tile-width 512 --tile-height 512: タイル数が 1/4 になり、TIFF の IFD(Image File Directory、内部の目次にあたる構造)も小さくなります--strip: Photoshop 由来の XMP 履歴や ICC プロファイルを削除します
この変更により、対象 3 ファイルのサイズが合計 4,232 MB から 1,158 MB(約 3.7 倍の縮小)になりました。
Lambda のタイムアウト
PTIF を小さくしてもレンダリングが時間内に終わらない場合があり、次のようなレスポンスが返ってくることがありました。
$ curl '.../full/!1024,1024/0/default.jpg'
{"errorType":"Sandbox.Timedout",
"errorMessage":"RequestId: xxx Error: Task timed out after 10.00 seconds"}
Sandbox.Timedout は Lambda のタイムアウトに到達したことを示しています。デフォルトは 10 秒で、初回の大型レンダリングではこの値を超える場合があります。
CloudFront 側はこの応答を 200 OK + application/octet-stream として受け取り、そのままキャッシュに乗せて配り続けるという挙動でした。
調整内容
API Gateway 経由の場合は最大 30 秒、Lambda Function URL 経由の場合も 30 秒程度に揃えるのが運用しやすいと判断し、次のように変更しました。
aws lambda update-function-configuration \
--function-name serverlessrepo-serverless-iiif-apne1-IiifFunction-xxx \
--timeout 30
メモリも初手で 10240 MB に増やしましたが、これは後述の通り過剰でした。
S3 オブジェクトメタデータと dimensions の取得
タイムアウトを伸ばしてもレンダリング時間が長いままで、ログには毎リクエストに次の警告が出ていました。
WARN: Unable to get dimensions for <object-key>
using custom function. Falling back to sharp.metadata().
Lambda の Duration を見ると、リクエストごとに 5000 ms 前後で揃っており、メモリを 3008 MB から 10240 MB に上げても変化がほぼありませんでした。CPU 配分が増えても処理時間が縮まないことから、画像処理そのものよりも何らかの初期化処理が支配的なのではと考えて Lambda のコードを確認しました。
Lambda 内部の dimensionRetriever
Lambda 関数のコードを zip ごと取得して読むと、dimensionRetriever 関数が S3 のオブジェクトメタデータから dimensions を取得する経路を持っていました。
var dimensionRetriever = async (location) => {
const s3 = new S3Client({});
const cmd = new HeadObjectCommand(location);
const response = await s3.send(cmd);
const { Metadata } = response;
if (Metadata?.width && Metadata?.height)
return calculateDimensions(Metadata);
return null; // ここで null だと sharp.metadata() にフォールバック
};
S3 オブジェクトメタデータに width と height が入っていれば即座に値を返し、入っていなければ sharp.metadata() を呼んで PTIF の IFD を読み直す、という構造です。
Samvera serverless-iiif の Image Metadata ドキュメント にもメタデータの仕組みは記載されています。公式リポジトリには npm run create-metadata という生成スクリプトもあり、運用上はこちらを使う設計になっているようでした。
メタデータを設定する
vips で寸法を取得し、aws s3api copy-object で in-place 更新しました。
SRC=ptif/example.tif
KEY=images/example.tif
BUCKET=my-iiif-bucket
W=$(vipsheader -f width "$SRC")
H=$(vipsheader -f height "$SRC")
P=$(vipsheader -f n-pages "$SRC")
aws s3api copy-object \
--bucket "$BUCKET" --key "$KEY" \
--copy-source "$BUCKET/$KEY" \
--metadata-directive REPLACE \
--metadata "width=$W,height=$H,pages=$P" \
--content-type image/tiff
計測した差分
メタデータあり / なしで同じ Lambda 設定(メモリ 2048 MB)で並べて測ったときの所要時間です。
| 操作 | メタデータなし | メタデータあり | 削減 |
|---|---|---|---|
| info.json | 4.31s | 0.05s | −4.26s |
| tile 256→256 | 8.69s | 6.77s | −1.92s |
| region 2048→512 | 9.26s | 6.49s | −2.77s |
| full→1024 | 9.71s | 6.52s | −3.19s |
info.json が大きく短縮されたのが目立ちます。画像レンダリング側でも 2-3 秒の短縮が見られました。
なぜレンダリング側の短縮幅は小さいのか
メタデータなしの場合、sharp.metadata() と本体のレンダリングで PTIF の IFD を 2 回読むことになります。メタデータがあれば、寸法取得は S3 の HEAD 応答で完結し、レンダリング時に sharp が PTIF を開いて IFD を読む 1 回分だけのコストになります。info.json の応答にはレンダリングが含まれないため、メタデータがあれば S3 の HEAD で完結し、所要時間が大きく縮みます。
Lambda メモリの再検討
Lambda のメモリ設定は CPU 配分にも比例するため、計算量が大きい処理であればメモリを上げると速くなるはずです。今回の場合は CPU よりも S3 からのデータ取得が支配的という仮説を立てて、メモリを下げて検証しました。
| 出力 | 10240 MB | 2048 MB |
|---|---|---|
| info.json | 0.04-0.27s | 0.12-0.27s |
| tile 256→256 | 5.64s | 6.87s |
| region 512→256 | 5.53s | 5.22s |
| region 2048→512 | 6.36s | 6.31s |
| region 4096→1024 | 6.17s | 6.44s |
| full→512 | 6.33s | 6.36s |
ユーザから見える curl の時間はほぼ変わりません。CloudWatch の Duration メトリクスを見ると、2048 MB のときは 1.3-3.6 秒、10240 MB のときは 5.5-6.3 秒で、メモリを下げたほうが内部時間が短く出るケースもありました。sharp の内部スレッド数が CPU 配分に応じて増えて競合する、といった可能性も考えられます(こちらは検証していません)。
Max Memory Used は実測 600 MB 程度で、2048 MB の余裕は十分でした。
Lambda の同時実行制限と 429 応答
ある時から、ビューアでタイルが部分的に欠ける現象が見られ、レスポンスを確認すると次のようなヘッダが返っていました。
HTTP/2 429
x-amzn-errortype: TooManyRequestsException
x-cache: Error from cloudfront
content-type: application/json
x-amzn-errortype: TooManyRequestsException は Lambda の同時実行制限に到達したときに付与されるヘッダです。
設定値の確認
$ aws lambda get-function-concurrency --function-name xxx
{
"ReservedConcurrentExecutions": 50
}
ReservedConcurrentExecutions が 50 になっており、これが直接の原因でした。1 リクエストの所要時間が 5-7 秒程度であることを踏まえると、理論上のスループットは 50 / 5 ≒ 10 req/秒となります。Mirador のように地図を開く瞬間に多数のタイルを並列で要求するビューアでは、上限に達しやすい設定値といえます。
直近のスロットル件数を見ると、利用が集中した時間帯に数百〜数千件の単位でスロットルが発生していました。
変更内容
アカウント全体の concurrent quota は 1000 で、ほかに reserved を消費している関数はなかったため、200 に増やしました。
aws lambda put-function-concurrency \
--function-name xxx \
--reserved-concurrent-executions 200
変更後、同時実行が 100 を超える状況でもスロットルが発生しなくなりました。
CloudFront キャッシュの TTL 延長
Lambda の応答に Cache-Control ヘッダが付かないため、CloudFront はマネージドポリシー CachingOptimized のデフォルト 24 時間でキャッシュを失効させていました。IIIF のタイル応答は識別子と座標が同じであれば内容が変わらないため、Time To Live(TTL)を 1 年まで伸ばしても運用上の支障はないと判断しました。
カスタムポリシーを作成
{
"Name": "iiif-long-cache",
"DefaultTTL": 31536000,
"MaxTTL": 31536000,
"MinTTL": 86400,
"ParametersInCacheKeyAndForwardedToOrigin": {
"EnableAcceptEncodingGzip": true,
"EnableAcceptEncodingBrotli": true,
"HeadersConfig": {"HeaderBehavior": "none"},
"CookiesConfig": {"CookieBehavior": "none"},
"QueryStringsConfig": {"QueryStringBehavior": "none"}
}
}
aws cloudfront create-cache-policy --cache-policy-config file://policy.json
# 出力された ID を Distribution の DefaultCacheBehavior.CachePolicyId に設定
一度生成されたタイルは CloudFront エッジから 50 ms 程度で配信されるようになります。Lambda は呼ばれないため、コスト面でも有利です。
PTIF を差し替えた際は、対応する識別子の Invalidation を実行する必要があります。
aws cloudfront create-invalidation \
--distribution-id XXX \
--paths "/iiif/2/<encoded-identifier>*"
ファイルサイズとレンダリング時間の関係
すべての調整を入れた後、メタデータあり・メモリ 2048 MB・concurrency 200 の状態で異なるサイズの PTIF を計測しました。
| 画像 | ファイル | ピクセル | info.json | region 2048→512 |
|---|---|---|---|---|
| A(横長スクロール) | 151 MB | 126 Mpix | 0.43s | 2.41s |
| B(横長スクロール) | 182 MB | 224 Mpix | 0.14s | 3.15s |
| C(大判画像) | 219 MB | 1,317 Mpix | 0.13s | 2.64s |
| D(大判画像) | 342 MB | 1,208 Mpix | 0.14s | 4.09s |
| E(大判画像) | 362 MB | 1,360 Mpix | 0.14s | 4.68s |
| F(大判画像) | 454 MB | 1,416 Mpix | 0.15s | 6.16s |
観察した範囲では、レンダリング所要時間は ピクセル数よりもファイルサイズに比例しているようです。A と B はピクセル数が C の 1/10 程度ですが、ファイルサイズが小さいため速く、逆に C と D を比べるとピクセル数はほぼ同じでもファイルサイズが大きい D のほうが遅くなっています。
体感としては次のような区分になりました。
| ファイルサイズ | レンダリング所要時間 |
|---|---|
| 200 MB 未満 | 2-3 秒 |
| 200-400 MB | 3-5 秒 |
| 400 MB 超 | 5-7 秒 |
残るレンダリングオーバーヘッド
450 MB 級 PTIF の初回レンダリング 5-6 秒の内訳を推測したものが次の表です。
| 区分 | 時間 |
|---|---|
| Lambda コールドスタート(初回のみ) | 500 ms |
| S3 メタデータ取得 (HEAD) | 50 ms |
| sharp が PTIF を開いて IFD パース | 約 5 秒 |
| 該当タイル取得+ JPEG 出力 | 約 500 ms |
「sharp が PTIF を開いて IFD パース」がファイルサイズに比例しており、Lambda の構成では大きく削るのは難しい部分のようです。Lambda コンテナは状態を引き継ぎにくいため、リクエストごとに同じ初期化を繰り返すことになります。
この部分をさらに減らしたい場合、次のような選択肢が考えられます。
| 方式 | 仕組み |
|---|---|
| ECS / EC2 で Cantaloupe を常駐稼働 | 起動時に PTIF を開いてメモリに保持し、以降のリクエストは即座にタイル切り出し |
| Lambda Provisioned Concurrency | コールドスタート分(500 ms 程度)は削減できますが、IFD パースの 5 秒は残ります |
| PTIF のタイルサイズをさらに拡大(1024 や 2048) | IFD エントリ数の削減により短縮の余地がありそうです(未検証) |
| JPEG 2000 などの別形式へ変換 | IFD の構造が異なるため特性が変わる可能性があります |
現在の CloudFront 長期キャッシュにより、2 回目以降の同じ URL へのアクセスは 50 ms 程度で配信されるため、初回の重さは運用パターン次第で許容可能です。1 日に複数のユーザが同じ画像を見る使い方であれば、初回コストは一度だけ支払うことになります。
まとめにあたっての確認項目
調査の結果として、PTIF を S3 と Lambda で配信する際に確認したほうがよいと感じた項目を整理します。
PTIF 作成時:
vips tiffsaveで--Qを 89 以下にする--tile-width 512 --tile-height 512でタイル数を抑える--stripでメタデータを削除するtiffinfoでPhotometric Interpretation: YCbCrを確認する
S3 アップロード時:
--metadata "width=W,height=H,pages=N"でオブジェクトメタデータを付与する--content-type image/tiffを明示する
Lambda 設定:
- Timeout は 30 秒程度に伸ばす
- メモリは 2048 MB 程度で十分そうでした(実測 Max Memory Used が 600 MB 程度のため)
- Reserved Concurrent Executions はビューアの並列要求に合わせて見直す(デフォルト 50 では Mirador のバースト要求でスロットルが発生しやすい)
CloudFront 設定:
- Cache Policy の TTL を長めに設定する
- PTIF 差し替え時には Invalidation を行う
期待される性能の目安:
- info.json: 50-300 ms
- タイル / region レンダリング初回: ファイルサイズに依存し、500 MB 級で 5-7 秒
- タイル / region レンダリング 2 回目以降(CloudFront キャッシュ Hit): 50-100 ms


コメント
…