本記事は生成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 値とタイルサイズを変えて検証した結果が次の表です。

QtilePhotometricサイズ
90256RGB75.9 MB
90512RGB75.8 MB
85256YCbCr21.0 MB
85512YCbCr20.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 オブジェクトメタデータに widthheight が入っていれば即座に値を返し、入っていなければ 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.json4.31s0.05s−4.26s
tile 256→2568.69s6.77s−1.92s
region 2048→5129.26s6.49s−2.77s
full→10249.71s6.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 MB2048 MB
info.json0.04-0.27s0.12-0.27s
tile 256→2565.64s6.87s
region 512→2565.53s5.22s
region 2048→5126.36s6.31s
region 4096→10246.17s6.44s
full→5126.33s6.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.jsonregion 2048→512
A(横長スクロール)151 MB126 Mpix0.43s2.41s
B(横長スクロール)182 MB224 Mpix0.14s3.15s
C(大判画像)219 MB1,317 Mpix0.13s2.64s
D(大判画像)342 MB1,208 Mpix0.14s4.09s
E(大判画像)362 MB1,360 Mpix0.14s4.68s
F(大判画像)454 MB1,416 Mpix0.15s6.16s

観察した範囲では、レンダリング所要時間は ピクセル数よりもファイルサイズに比例しているようです。A と B はピクセル数が C の 1/10 程度ですが、ファイルサイズが小さいため速く、逆に C と D を比べるとピクセル数はほぼ同じでもファイルサイズが大きい D のほうが遅くなっています。

体感としては次のような区分になりました。

ファイルサイズレンダリング所要時間
200 MB 未満2-3 秒
200-400 MB3-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 でメタデータを削除する
  • tiffinfoPhotometric 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

参考