本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。組織名・ドメイン・バケット名・各種IDは伏せて、構成と手順の本質に焦点を当てて記述しています。
移行前の構成
公開していた IIIF 画像配信は、AWS EC2 (t3.large) 上の Docker で Cantaloupe を動かす構成でした。同じ EC2 上に Drupal とその MariaDB、Traefik も同居しており、複数サービスが相乗りしていたインスタンスです。
画像のソースは別 AWS アカウント上の S3 バケットで、Cantaloupe の S3Source で参照していました。前段に CloudFront を置いて TLS 終端と JP エッジキャッシュを担当させる構成です。
Client → CloudFront (NRT エッジ) → EC2 (us-east-1, Traefik → Cantaloupe)
↓ S3Source (us-east-1)
<legacy-source-bucket>
CloudWatch メトリクスを確認したところ、過去30日で約26万リクエスト・転送量約8.4 GB と、ピーク時でも0.5 req/s を超えない程度のトラフィックでした。
移行を検討した動機
主にセキュリティと、Docker ホスト(EC2)の維持コストの2点でした。
セキュリティ面の負担
EC2 をインターネットに晒している以上、OS のセキュリティパッチ追従、SSH 接続経路の管理、Docker daemon・各コンテナイメージの脆弱性追跡、JVM/Java の CVE 追従、cantaloupe や同居していた Drupal/MariaDB のバージョン追跡など、定期的に手を動かす対象が多くありました。設定ミスや更新漏れがあるとそのまま外部に露出します。
サーバレス化すれば OS や Java ランタイムは AWS が管理する範囲になり、SSH 経路もそもそも存在しなくなります。攻撃面が「Lambda 関数のコード」「IAM ポリシー」「CloudFront / WAF の設定」に絞られるため、見るべき箇所が減ります。
Docker ホストの維持
t3.large 上で Cantaloupe・Drupal・MariaDB・Traefik が同居しており、どれか1つの不調が他に波及する設計でした。
- カーネルアップデートで再起動が必要になると4サービスすべて巻き込む
- ディスク逼迫(cache ディレクトリ、コンテナログ、Docker overlay)の監視を自前で
- Java や Drupal のメモリリークが他サービスの OOM を誘発するリスク
- バックアップ・モニタリング・ログ集約のスクリプトもホスト単位で組まれており、サービス追加時の作業が重い
これらを「ホストを持たない」構成に持っていくと、上記が概ね消えます。
おまけのコスト削減
ピーク時でも 0.5 req/s に届かない程度のトラフィックに対して t3.large を 24/7 動かしているのはオーバースペックで、サーバレス化すれば副次的にコストも下がる見込みでした。試算はコスト試算に書いています。
serverless-iiif の選定
Samvera の serverless-iiif は Node.js + sharp (libvips) で動く IIIF Image API サーバを Lambda にパッケージしたものです。AWS Serverless Application Repository (SAR) で配布されており、SAM/CDK でも利用できます。
SAR から1コマンドでデプロイできました。
aws serverlessrepo create-cloud-formation-change-set \
--application-id arn:aws:serverlessrepo:us-east-1:625046682746:applications/serverless-iiif \
--semantic-version 7.0.1 \
--stack-name serverless-iiif-apne1 \
--capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \
--parameter-overrides \
'[{"Name":"SourceBucket","Value":"<images-tokyo-bucket>"},{"Name":"ResolverTemplate","Value":"%s"}]'
ResolverTemplate=%s を指定しておくと、IIIF ID = S3 キーがそのまま対応するため、Cantaloupe で使っていた URL(例:/iiif/2/clioimg%2Fsho.tif/info.json)と互換になります。
Lambda のスペック
SAR デフォルトの Lambda 設定は以下でした。
| 項目 | 値 |
|---|---|
| Memory | 3008 MB |
| Timeout | 10s |
| Runtime | Node.js 22 |
| Architecture | x86_64 |
Lambda は割り当てメモリに比例して CPU 性能が決まり、3008 MB で約2 vCPU 相当です。sharp/libvips による JPEG エンコードや TIFF デコードがそこそこ重いため、ここを下げると体感が露骨に遅くなります。逆に SAR では Architecture を arm64 に変えるパラメータがなく、Graviton 化したい場合はテンプレートを fork する必要があります。
Cantaloupe と serverless-iiif の特徴比較
両者の方向性はかなり異なるので、検討時に整理した内容を残しておきます。「IIIFサーバが必要」という共通点だけで、設計思想・運用前提が大きく違います。
設定の柔軟性とカスタマイズ余地
Cantaloupe は cantaloupe.properties で 100 以上のパラメータを調整できます。プロセッサ選択、品質、メモリ、認可フックなどが細かく制御可能で、Ruby の Delegate Script でリクエスト毎にロジックを差し込めるのも強力です。複数ソース(S3 / Filesystem / HTTP / JDBC / カスタム)に対応しており、ID解決やメタデータ加工を動的に変えられます。
serverless-iiif は SAR テンプレートで露出している設定が10個程度で、本質的なチューニングはコード改変が必要です。ソースは S3 の単一バケット + ResolverTemplate の printf 風書式のみで、複雑なルーティングはできません。エンジンも sharp(libvips)固定で、TurboJpeg や Kakadu などの選択肢はありません。
キャッシュ戦略
Cantaloupe には Source cache、Derivative cache、Heap cache の独立した3層があり、永続キャッシュをアプリ側で持てます。一方 serverless-iiif は Lambda の性質上ステートレスで、キャッシュは前段の CloudFront に外付けする前提です。つまり初回アクセスの体感は CloudFront の Hit/Miss で大きく変わります。
コールドスタートと長時間処理
Lambda にはコールドスタートがあり、数百ミリ秒〜1秒程度の初回レイテンシが発生します。また実行時間は最大15分・ペイロードはデフォルト6MB(Function URL 経由なら緩和)と上限があり、超巨大画像のフル解像度変換などは serverless-iiif だと厳しいケースがあります。Cantaloupe は実行時間の制約がなく、JVM が温まったあとは安定した応答が得られます。
運用とスケール
serverless-iiif は完全マネージドで、同時実行数まで自動水平スケール、OS パッチもなし。アクセスが少ない時はゼロに近いコストです。逆に Cantaloupe はインスタンスを常時起動させる必要があり、ピーク容量に合わせたサイジング・JVM チューニング・cache ディスク管理・OS と Java と Cantaloupe のバージョン追従が継続的に発生します。可用性も単一インスタンスでは限定的です。
認可・観測性
Cantaloupe は IIIF Authentication API 等を Delegate Script から実装できるなど、認可の柔軟性が高めです。/admin 画面で状態監視もできます。serverless-iiif で同等のことをやるには Lambda コードや CloudFront Function / Lambda@Edge を自作する必要があり、観測も CloudWatch Logs が中心です。
役割の整理
ざっくりまとめると:
| 軸 | Cantaloupe | serverless-iiif |
|---|---|---|
| 思想 | 画像変換ワークステーション | 静的画像をIIIFで配信するCDN補完 |
| 設定の深さ | 深い(100超パラメータ + Delegate) | 浅い(10個程度のSARパラメータ) |
| ソース | S3 / FS / HTTP / JDBC / カスタム | S3 単一バケット |
| プロセッサ | TurboJpeg / OpenJpeg / Kakadu 等選択可 | sharp/libvips 固定 |
| 永続キャッシュ | アプリ内3層 | 外部 (CloudFront) 前提 |
| コールドスタート | なし | あり |
| 実行時間制約 | なし | 15分まで |
| 運用負荷 | OS/Java/JVM/cache | ほぼゼロ |
| スケール | マニュアル | 自動 |
| 最低コスト | EC2 常時稼働分 | ほぼゼロ |
ソース調達・認可・キャッシュ戦略・出力品質を細かく制御したいなら Cantaloupe、S3 に置いた画像を IIIF で公開するのが主目的でアクセスが偏在しているなら serverless-iiif、という棲み分けが見えました。今回のケースは後者にきれいに当てはまっていたので serverless-iiif を選びました。
us-east-1 と Tokyo の両方にデプロイして比較
ソースバケットが us-east-1 にあったため、最初は us-east-1 に Lambda をデプロイしました。Lambda と S3 が同リージョンでないと S3 取得のたびにクロスリージョン RTT(~150 ms) が乗るため、後から ap-northeast-1 に同じ画像をコピーした Tokyo バケットを作り、Tokyo にも別 stack をデプロイして比較しました。
東京から測定した cold(CFバイパス/キャッシュ無効化)の応答時間:
| 操作 | Cantaloupe origin (us-east-1) | serverless us-east-1 | serverless Tokyo |
|---|---|---|---|
| info.json | 550–580 ms | 1207–1314 ms | 790–900 ms |
| thumbnail 260w | 740–1260 ms | 1825–1928 ms | 1310–1412 ms |
| mid 1024w | 1117–3100 ms | 2735–3245 ms | 1453–1533 ms |
| tile 512 (region) | 734–1823 ms | 2026–2117 ms | 1297–2509 ms |
純粋なレンダリング速度は Cantaloupe (Java + TurboJpeg + 内部キャッシュ) のほうが速いものの、JP→Virginia の RTT を含めると serverless Tokyo がほぼ同等になりました。CloudFront の Tokyo エッジを通じた warm 応答(同一 URL の再リクエスト)は両者とも50 ms 前後で実用上ほぼ差がありません。
既存 CloudFront の影響に注意
最初の比較で Cantaloupe の数字が異常に速く出たため疑問に思って Via ヘッダを見たところ、x-cache: Hit from cloudfront x-amz-cf-pop: NRT20-P5 と返っていました。Cantaloupe の前段に CloudFront があり、そこが Tokyo エッジで返していたため計測値が CDN ヒットだったわけです。クエリ文字列ベースのキャッシュバスティングを試しましたが、CloudFront はクエリをキャッシュキーから除外していたため効果なく、サイズや領域オフセットを毎回ユニークにすることでようやく cold パスを測れるようになりました。
セキュリティ強化
serverless-iiif の Lambda は Function URL で公開されますが、デフォルトでは AuthType=NONE で誰でも叩ける状態です。下記のように段階的にロックダウンしました。
Origin Access Control (OAC) for Lambda Function URL
CloudFront の Origin Access Control に Lambda 用のタイプ (OriginAccessControlOriginType: lambda) があるので、これを利用しました。CloudFront から Lambda Function URL に SigV4 署名付きでリクエストするため、Lambda 側で AWS_IAM 認証を有効にしても CloudFront 経由は通せます。
aws cloudfront create-origin-access-control \
--origin-access-control-config '{
"Name":"serverless-iiif-apne1-oac",
"SigningProtocol":"sigv4",
"SigningBehavior":"always",
"OriginAccessControlOriginType":"lambda"
}'
設定後、Lambda Function URL の AuthType を AWS_IAM に変更すると、直接 https://<id>.lambda-url.ap-northeast-1.on.aws/... を curl で叩いた場合は HTTP 403 を返すようになりました。
Reserved concurrency でコスト天井を作る
Lambda の暴走対策として reserved concurrency を 50 に設定しました。同時実行数が50を超えるリクエストは 503 で fail-fast し、cost runaway を物理的に止められます。
aws lambda put-function-concurrency \
--function-name <name> \
--reserved-concurrent-executions 50
50 という数字は、現状のピーク 0.5 req/s × 平均処理時間 1.5s ≒ 0.75 同時実行に対して十分な余裕があり、かつ攻撃時の上限としても妥当な値として選びました。
WAF
組織内で運用している共有 WAF に、ホストヘッダで対象を絞った rate-based ルールが既にあったため、CloudFront に attach するだけで自動的に適用される状態でした。
Priority 0 : AWSManagedRulesAmazonIpReputationList
Priority 1 : AWSManagedRulesKnownBadInputsRuleSet
Priority 2 : AWSManagedRulesCommonRuleSet
Priority 10 : RateLimit-cantaloupe (host=<production-domain>, 5000 req/5min/IP)
CloudFront のセキュリティヘッダ
managed の SecurityHeadersPolicy (67f7725c-6f97-4210-82d7-5512b31e9d03) を attach することで、HSTS / X-Content-Type-Options / X-Frame-Options / Referrer-Policy などが応答に付与されます。
コスト試算
過去30日のメトリクス(約26万リクエスト・8.4 GB 転送)を前提に、CF キャッシュ Hit 率を 80% (保守的) で見積もると:
| 項目 | 計算 | 月額 |
|---|---|---|
| Lambda compute (52,600発火 × 1s × 3008MB) | 158k GB-s × $0.0000166667 | $2.63 |
| Lambda requests | 52,600 × $0.20/M | $0.01 |
| CF requests (HTTPS Japan) | 263k × $0.0090/10k | $0.24 |
| CF data transfer (Japan tier) | 8.35 GB × $0.114 | $0.95 |
| S3 GET | 52,600 × $0.0004/1000 | $0.02 |
| S3 storage | 8 GB × $0.025 | $0.20 |
| 合計 | 約 $4/月 |
t3.large 24/7 の $60.74/月 + EBS と比べると桁違いに安く、10倍に伸びても $30〜40/月、100倍でも標準ガードで $200〜300/月で収まる試算でした。
カットオーバーで詰まった associate-alias
正規ホスト名を旧 CloudFront から新 CloudFront に移すために aws cloudfront associate-alias を使う想定でした。これは alias を別ディストリビューションに「アトミックに移動」できる API で、ダウンタイム最小化のために導入されたものです。
ところが実際に叩くと以下のエラーが返り続けました。
An error occurred (IllegalUpdate) when calling the AssociateAlias operation:
Invalid or missing alias DNS TXT records.
エラーメッセージの records が複数形だったので、_cf-source-id.<alias> _cf-target-id.<alias> _cf-validation.<alias> などいくつかの命名で TXT レコードを Route53 に追加して試しましたが、いずれも同じエラーで通りませんでした。AWS のドキュメント上で同一アカウント間移動には DNS 検証が不要と読める記述もあり、現在の挙動と一致しないため、何が正解の TXT 形式かは特定できませんでした。
最終的には associate-alias を諦めて update-distribution を旧/新両方に発行する手動手順に切り替えました。
1. 旧 CF の Aliases を空にし、cert を CloudFrontDefaultCertificate に戻す
2. 新 CF の Aliases に当該ホスト名を追加する
ただし、ここでもう一つ罠があり、新 CF への alias 追加が CNAMEAlreadyExists で繰り返し弾かれました。
One or more aliases specified for the distribution includes an
incorrectly configured DNS record that points to another CloudFront distribution.
You must update the DNS record to correct the problem.
DNS が旧 CF のドメインを指している間は CloudFront 側が「他のディストリビューションに DNS が向いている」と判定して alias 追加を拒否する仕組みでした。先に DNS を新 CF に切り替えてから alias を追加することで通りました。
つまり、結果的には以下の順序で作業しました:
1. 旧 CF から Aliases を削除(CFは数分かけて edge propagation)
2. Route53: 正規ホスト名の CNAME を 新 CF に変更
3. 新 CF の Aliases に対象ドメインを追加(DNSが新CFを指していれば成功)
4. 新 CF の deploy 完了を待つ(約1〜3分)
実際のサービス断は概ね2〜3分でした。事前に DNS の TTL を 300s から 60s に下げておいたことで、クライアント側のキャッシュが早く失効し、想定どおり短く済んだ感触です。
ForceHost と info.json の @id
serverless-iiif の info.json では、レスポンス内の @id 等の URL を、リクエストのホストヘッダから組み立てます。デフォルトのままだと CloudFront の dxxxx.cloudfront.net ドメインが @id に入ってしまうため、SAR の ForceHost パラメータ(あるいは Lambda の環境変数 forceHost)に正規ホスト名を設定する必要があります。
設定後、CloudFront のキャッシュには古い @id が残っているため、/iiif/* を invalidation しました。
aws cloudfront create-invalidation --distribution-id <dist-id> --paths "/iiif/*"
移行後の片付け
カットオーバー後、Cantaloupe コンテナを停止し、関連する orphan リソースを順に削除しました。
- 旧 CloudFront ディストリビューション(alias空 + origin死亡)
- 旧 ACM 証明書(旧CFが参照しなくなったもの)
- 比較用に作った us-east-1 SAR stack と S3 バケット
- 旧 Cantaloupe ソース S3 バケット(別アカウントの方も含めて 8 GB / 1159 obj 削除)
- 旧 Cantaloupe 用 IAM user・access key・カスタムポリシー
- カットオーバー検証で残っていた TXT レコード3種、
<origin直叩き用>A レコード - 暫定で入れた WAF の rate-limit ルール(恒久ルールに置き換わったので不要に)
旧 CF を消すには先に Enabled=false で再デプロイする必要があり、ここで5〜10分程度の待ちが発生しました。Disable してから delete-distribution の流れです。
# Enabled=false で update → wait → delete
aws cloudfront wait distribution-deployed --id <old-id>
aws cloudfront delete-distribution --id <old-id> --if-match <etag>
別アカウント側の片付け(旧ソースバケット・IAM user)は当然そのアカウントの admin 権限が必要で、今回手元にはR/Oキーしか無かったため、別 profile を有効化してもらってから実施しました。
共有 WAF を「使われていないから削除可」と言われた件
別のチェックを依頼したエージェントから「組織内の共有 WAF は紐付け先ゼロなので削除してコスト削減できる」というレポートをもらいましたが、念のため検証したところ 同一の Web ACL が CloudFront ディストリビューション 20 本にattach されている現役のもの でした。削除すれば本番20サービスのIP Reputation・OWASP Common・Rate Limit が一斉に外れる事態になります。
このとき問題のエージェントは恐らく以下の API を叩いて空配列を見て「未使用」と判定したと推測しています。
# CLOUDFRONT scope の Web ACL に対しては ValidationException が返る
aws wafv2 list-resources-for-web-acl --resource-type CLOUDFRONT ...
CLOUDFRONT scope の Web ACL は list-resources-for-web-acl に CLOUDFRONT を渡すと API 仕様上エラーになり、リソース一覧が空に見えます。実際の attach 状況は CloudFront 側の WebACLId を集計するのが正解です。
aws cloudfront list-distributions \
--query 'DistributionList.Items[?WebACLId==`<arn>`].[Id,Aliases.Items[0]]'
レポートが「ノーリスク」と書かれていても、削除提案は実機で attach 状況を裏取りしてから、というのが教訓でした。
学び
- CloudFront の関与を見落とすとベンチマークが歪む。
Viax-cacheヘッダで CDN 経路を必ず確認する - Lambda Function URL + OAC for lambda で、API Gateway を挟まずに securely 公開できる
- Reserved concurrency はコスト爆発の物理ガードとして有効
associate-aliasはそのままでは通らないことがある。手動update-distributionのフォールバック手順も用意しておく- CloudFront は alias 追加時に DNS 整合性を確認するため、移行は「DNS 切替 → alias 追加」の順序になる場合がある
- 同じ alias を保持する CF が「old」のままの状態で先に DNS だけ動かすと数分間の CF エラーが発生する。事前の DNS TTL 短縮が効く
- 「未使用なので削除可」の助言は実機のattach状況を別系統で裏取りしてから実行する