本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

以前、Serverless IIIF で1Gピクセル級の高精細画像を配信するときに行った最適化のノートという記事を書きました。その続きにあたります。

新しく 49867 × 39101 px(約 1.95 Gピクセル)の絵図をデジタルアーカイブに追加したところ、ビューアでの初回表示に数十秒かかる状態になりました。原因を追ったところ、前回の記事に自分で書いた手順を飛ばしていたことと、Samvera serverless-iiif の構造上の性質という、二つの要因が重なっていました。

構成

配信側の構成は前回と同じです。

[Mirador] → [CloudFront]
              ├── キャッシュ Hit ─→ そのまま返す
              └── キャッシュ Miss ─→ [Lambda (serverless-iiif 7.0.1)]
                                        └─→ [S3 上の PTIF]

PTIF は Pyramid TIFF(多重解像度のタイル化された TIFF)です。計測はすべて ap-northeast-1(東京リージョン)で、クライアントは日本国内から、CloudFront にキャッシュがない状態のタイル要求で行っています。

計測値について一点補足します。同じ条件でも時間帯によって全体が 1 秒程度上下することがありました。以下の比較はいずれも、比べる対象を数分以内に交互に測ることで、その変動の影響を受けにくいようにしています。絶対値そのものよりも、同じ表の中での相対関係を見てください。

最初の見立ては外れていました

別のセッションで調査したときには「info.json はピラミッドがあると宣言しているが、実際の応答時間はピラミッドが効いていないことを示している」という見立てが出ていました。しかし tiffinfo で確認すると、PTIF は 8 段のピラミッドを持ち、タイルは 512 × 512、Photometric Interpretation も YCbCr で、意図どおりに作られていました。

代わりに、同じバケットにある他の画像と並べて測ると別の傾向が見えました。

画像PTIF サイズタイル取得(キャッシュなし)
A229 MB3.5 - 4.0 秒
B259 MB6.7 - 7.3 秒
C(今回の絵図)373 MB9.8 - 12.3 秒

要求する領域の大小に関係なく一定で、ファイルサイズに対して単調に増える、という形です。前回の記事でも「レンダリング所要時間はピクセル数よりもファイルサイズに比例する」という観察を書いていましたが、今回はその傾向がより極端に出ていました。

一つ目の原因: S3 オブジェクトメタデータの付け忘れ

バケット内の各オブジェクトのメタデータを比べたところ、速い画像 A にだけ width / height / pages が付いていて、他には付いていませんでした。前回の記事で「S3 アップロード時に --metadata "width=W,height=H,pages=N" を付与する」と書いた、まさにその手順を今回飛ばしていました。

serverless-iiif 7.0.1 の src/resolvers.ts を読むと、次のようになっています。

const 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;
};

メタデータがあれば S3 の HEAD 応答だけで寸法が確定します。無ければ null を返し、画像本体を開いて調べ直すことになります。さらに pages があれば reduceByPages() が各ピラミッド段の寸法を計算で求めますが、無い場合は環境変数 pyramidLimit から段数を推測する実装でした。

サーバサイドコピーで後から付与できるので、再アップロードは不要です。

W=$(vipsheader -f width out.tif)
H=$(vipsheader -f height out.tif)
P=$(vipsheader -f n-pages out.tif)
aws s3api copy-object --bucket <bucket> --key clioimg/kyushu.tif \
  --copy-source "<bucket>/clioimg/kyushu.tif" \
  --metadata-directive REPLACE --content-type image/tiff \
  --metadata "width=$W,height=$H,pages=$P"

これで 9.8 - 12.3 秒が 5.0 - 6.1 秒になりました。ほぼ半減ですが、まだ遅い状態です。

なお 8.x では tilewidth / tileheight / tilesize もメタデータとして読まれるようになっています。7.0.1 の resolvers.ts にはこれらのキーは登場せず、実際に付与しても所要時間は変わりませんでした。害はないので付けたままにしています。

二つ目の原因: Range 要求を使っていないこと

残りの 5 秒の内訳を、前回の記事では「sharp が PTIF を開いて IFD をパースする時間」と推測していました。今回ソースを読み直したところ、より単純な理由が見つかりました。

const s3Stream = async (location) => {
  const s3 = new S3Client({});
  const cmd = new GetObjectCommand(location);
  const s3Response = await s3.send(cmd);
  return s3Response.Body as NodeJS.ReadableStream;
};

GetObjectCommandRange が指定されていません。調べた限り、8.0.5 でも同じ構造でした。resolver が返す契約が「ストリーム」であるため、libvips 側はランダムアクセスができず、結果として 1 タイルの要求ごとに PTIF 全体が S3 から Lambda へ転送されることになります。Lambda の CloudWatch ログでも、この画像の処理時 Max Memory Used が 995 MB まで上がっていました。

対照として、同じ PTIF をローカルディスクに置き、ファイルパスを渡して libvips に 1024 × 1024 px を切り出させると次のようになります。

real 0.16
real 0.08
real 0.08

0.08 秒です。フォーマットやピクセル数ではなく、毎回 373 MB を転送しているかどうかが所要時間を決めていた、と考えるのが自然でした。

参考として、serverless-iiif に同梱されている変換スクリプト create-tiled-tiff の仕様も確認しました。ドキュメントによれば「画像を 1 辺 15,000 px 以下に縮小する」「JPEG 品質 75 %、タイルサイズ 256 × 256 で PTIF を作る」となっています。5 万 px 級の画像は、このスタックが標準で想定している範囲の外にあるようです。

対策 1: コンテナで Cantaloupe を動かす

CantaloupeS3Source は、公式マニュアルによればバージョン 4.1 以降ランダムアクセスに対応していて、多重解像度 TIFF や JPEG 2000 であれば必要なチャンクだけを Range 要求で取得します。AWS Fargate 上で試しました。

イメージは Docker Hub の uclalibrary/cantaloupe:5.0.7-0 をそのまま使いました。Cantaloupe 本体のプロジェクトは公式イメージを公開していないようで、選択肢は UCLA Library 版か Islandora 版になります。

設定は環境変数だけで入りました。5.0.7 の ConfigurationFactory を読むと、-Dcantaloupe.config が指定されている場合に EnvironmentConfiguration が properties ファイルより上位に積まれる実装になっており、専用イメージをビルドしなくても上書きできます。

{
  "environment": [
    {"name": "SOURCE_STATIC", "value": "S3Source"},
    {"name": "S3SOURCE_REGION", "value": "ap-northeast-1"},
    {"name": "S3SOURCE_BASICLOOKUPSTRATEGY_BUCKET_NAME", "value": "<bucket>"},
    {"name": "S3SOURCE_CHUNKING_ENABLED", "value": "true"},
    {"name": "ENDPOINT_IIIF_2_ENABLED", "value": "true"},
    {"name": "BASE_URI", "value": "https://<配信ホスト名>"}
  ]
}

Range 要求が実際に効いているかは、コンテナの受信バイト数の差分で確認できます。タイル 1 枚の要求前後で docker stats を取ると 20.3 MB から 26.5 MB へ、つまり 6.2 MB でした。373 MB 全体ではありません。

1 タイル要求あたりに S3 から読み込む量と所要時間の比較。JPEG PTIF 373MB と Lambda の組み合わせでは毎回ファイル全体の 373MB を読み、所要時間は 5.1 から 5.4 秒。WebP PTIF 155MB と Lambda では 155MB を読み、2.6 から 2.8 秒。JPEG PTIF 373MB と Cantaloupe の組み合わせでは Range 要求により 6.2MB だけを読み、1.3 から 1.5 秒。

タスクのサイズを変えて測った結果です。Fargate は CPU とメモリの組み合わせが決まっていて、2 vCPU の最小メモリが 4 GB のため、メモリだけを半分にすることはできません。

Fargate タスク単発 1 タイル20 タイル同時
0.5 vCPU / 1 GB1.8 - 2.9 秒12.6 秒
1 vCPU / 2 GB1.6 - 2.2 秒7.4 秒
2 vCPU / 4 GB1.3 - 1.5 秒5.2 秒

単発では CPU を落としてもあまり変わらず、同時要求では効く、という非対称が出ました。Range 要求で転送量が減った結果、残るコストが JPEG の復号と再エンコード側に移っているためだと考えられます。

ここで気になったのが、Lambda 側の同時要求の値です。ビューアは 1 画面で数十タイルを同時に要求するので、その条件でも測りました。

構成単発 1 タイル20 タイル同時
Lambda(PTIF 373 MB)5.1 - 5.4 秒7.2 秒
Fargate 2 vCPU / 4 GB1.3 - 1.5 秒5.2 秒

単発では 4 倍近い差がありますが、20 タイル同時では 1.4 倍まで縮みました。Lambda は 20 個の要求を並列に実行するのに対し、コンテナは vCPU の数で頭打ちになるためです。単一タスクの構成では、この点は Lambda の利点として残ります。

対策 2: PTIF を WebP 圧縮にする

所要時間がファイルサイズに比例するのであれば、解像度を落とさずにファイルを小さくできれば、それがそのまま効くはずです。libvips の tiffsave--compression webp を受け付けるので、代表領域 8192 × 8192 px を切り出して比較しました。誤差は、元の PTIF から復号した画像を基準にした画素値の平均絶対誤差と最大誤差です。

圧縮サイズ平均絶対誤差最大誤差
JPEG Q85(変更前)14.8 MB0.6826
JPEG Q8013.3 MB1.2728
JPEG Q7512.2 MB1.7129
WebP Q8510.4 MB1.9023
WebP Q807.8 MB2.2827
WebP Q755.9 MB2.6431

WebP Q85 は JPEG Q75 より 15 % ほど小さく、最大誤差はむしろ小さい値でした。基準画像が JPEG 由来の復号結果なので、この数値は JPEG 側に有利に出ている点は割り引いて見る必要があります。等倍で並べて目視した限りでは、筆線も料紙の質感も Q80 で保たれているように見えました。

全体を WebP Q80 で変換したコマンドと結果です。

vips tiffsave input.jpg output.tif --tile --pyramid --compression webp \
  --Q 80 --tile-width 512 --tile-height 512 --strip
146.07s user 1.22s system 105% cpu 2:19.07 total
155 MB

373 MB が 155 MB になりました。解像度は 49867 × 39101 px のままです。差し替えた後の実測です。

構成単発 1 タイル20 タイル同時追加の月額費用
JPEG 373 MB + Lambda5.1 - 5.4 秒7.2 秒なし
WebP 155 MB + Lambda2.6 - 2.8 秒4.5 秒なし
JPEG 373 MB + Fargate 2 vCPU / 4 GB1.3 - 1.5 秒5.2 秒約 90 ドル(概算)

費用は 2026 年 8 月時点の ap-northeast-1 の Fargate 料金からの概算です(常時稼働 1 タスクを想定。Fargate Spot を使えばさらに下がります)。

20 タイル同時の条件では、WebP 化した Lambda がコンテナ構成を上回りました。追加のインフラは不要です。

8.x に上げると速くなるか

使っているのは 7.0.1 なので、8.x に上げれば改善するのかも確かめました。まずソースの差分です。

項目7.0.18.0.5
S3 からの読み方GetObjectCommand(Range 指定なし)同じ
寸法の取得HEAD の width / height / pages同じ、加えて tilewidth / tileheight / tilesize
該当ページの選択ピラミッド段を選んで sharp に page を渡す同じ(pageThreshold オプションが追加)

リリースノートを見ると、8.0.0 は「iiif-processor 8.x に向けた geometry 関数の更新」、8.0.2 は状態のずれの修正と create-metadata の Lambda 化でした。転送方法に手は入っていないようです。

実際に測るため、AWS Serverless Application Repository から 8.0.5 を別スタックとしてデプロイし、同じバケットを参照させました。既存の 7.0.1 と同じメモリ(3008 MB)・同じタイムアウト(30 秒)に揃え、同一クライアントから交互に測った結果です。

領域8.0.57.0.1
10000,10000,1024,10244.13 秒4.11 秒
32000,26000,1024,10244.13 秒4.12 秒
45000,35000,1024,10243.76 秒3.73 秒
20000,12000,1024,10244.14 秒4.10 秒

差は 0.03 秒程度で、測定のばらつきの範囲でした。8.x で追加された tilewidth / tileheight をオブジェクトメタデータに付けても、同じく変化は見られませんでした(4.10 - 4.14 秒)。

タイル情報を調べる手間は、全体を転送するコストの前では小さいということのようです。バージョンを上げる理由は他にあるかもしれませんが、この構成の速度に関しては期待できないと考えています。検証に使ったスタックは削除しました。

WebP 圧縮 TIFF が読めない実装があります

一方で、採用にあたって確認が要る制約があります。同じ WebP 圧縮 PTIF を Cantaloupe に読ませると、次のエラーになりました。

javax.imageio.IIOException: Unsupported compression type (tag number = 50001)!

Java ImageIO の TIFF リーダーが WebP 圧縮に対応していないためで、設定では回避できないようです。つまり WebP を選ぶことは、少なくとも現時点では Cantaloupe への移行路を狭めることを意味します。

このデジタルアーカイブでは、元の JPEG 原本を手元に残してあるので、必要になれば再変換で JPEG 版の PTIF に戻せます。その前提で WebP を採用しました。将来の移行可能性を重視する場合は、JPEG Q75 に落とすだけでも 18 % ほど小さくなり、こちらは互換性を損ないません。

手順として残したこと

同じ取りこぼしを繰り返さないよう、変換からアップロードまでを 1 本のスクリプトにまとめ、リポジトリの CLAUDE.md にも理由とセットで書きました。前回の記事に手順を書いていたにもかかわらず今回飛ばしてしまったので、文章として残すだけでは足りず、実行経路そのものを 1 つにする必要がある、という反省です。

作業時の確認項目としては次のようになりました。

PTIF を作るとき:

  • 既定は --compression webp --Q 80(Cantaloupe への移行可能性がある画像は JPEG のまま)
  • JPEG を使う場合、--Q は 89 以下にする(90 以上は Photometric が RGB になりサイズが約 3.6 倍になる)
  • --tile-width 512 --tile-height 512 --strip

S3 に置くとき:

  • --content-type image/tiff を明示する
  • width / height / pages をオブジェクトメタデータに付ける(付け忘れると所要時間が約 2 倍になる)
  • 差し替えた場合は CloudFront の invalidation を行う

配信の性質として意識しておくこと:

  • 所要時間はピクセル数ではなくファイルサイズに比例する
  • Lambda のメモリを増やしても改善しない(2048 MB と 3008 MB で有意差は見られませんでした)
  • serverless-iiif を 8.x に上げても改善しない(7.0.1 と同条件で比較して差は 0.03 秒程度)
  • 2 回目以降は CloudFront のキャッシュから 50 ミリ秒程度で返るため、遅いのは初回だけ

参考