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

背景:「一般公開できない画像を限定配信したい」

歴史資料のデジタルアーカイブには、著作権・肖像権・所蔵者契約・倫理的配慮などの理由で一般公開できない画像が一定割合含まれるのが普通です。一方で、

  • 研究者・編集スタッフの内部閲覧
  • 限定された協力機関への提供
  • 校閲・選定作業

といった用途では、「公開はしないが、IIIF の利便性は使いたい」 というニーズがあります。具体的には:

  • 高解像度ビューア(OpenSeadragon / Mirador)でディテール確認
  • IIIF Presentation API の manifest を介した IIIF 互換ツール(認証済みセッション内)での閲覧
  • 永続的な URL での参照・引用(ホスト名・経路の継続を前提)

注意:本記事の構成は 同一組織内・認証済みブラウザセッション での IIIF 利用を前提としています。外部ホストからの非インタラクティブな相互運用(クローラ・収集ボット等)には IIIF Auth API 2.0 の実装が別途必要です(応用:IIIF Auth API 2.0 への拡張 を参照)。

公開コーパスなら CloudFront + S3 で済むのですが、「アクセス制御」と「IIIF」を両立させる構成には少し工夫が要ります。本記事はその実装記録です。

構成

                     ┌──────────────┐
ユーザー ──TLS──▶  Cloudflare       │
                   │  ┌────────┐   │
                   │  │ Access │── 認証ゲート(Email OTP / SSO)
                   │  └────────┘   │
                   │      │         │
                   │  ┌────────┐   │
                   │  │Tunnel  │   │
                   │  └────┬───┘   │
                   └───────│───────┘
                           │ outbound 接続
                   ┌───────┴────────────────────────────┐
                   │  Origin(VPS / 学術クラウド等)      │
                   │  ┌────────────┐  ┌──────────────┐  │
                   │  │ cloudflared│──│ Next.js (web)│──┐
                   │  └────────────┘  └──────────────┘  │
                   │         │              │            │
                   │         │              ▼            │
                   │         │       ┌──────────────┐   │
                   │         │       │Elasticsearch │   │
                   │         │       └──────────────┘   │
                   │         │              ▲           │
                   │         └─path:/iiif───┐           │
                   │                ┌───────▼──────┐    │
                   │                │  Cantaloupe  │────┼─→ S3 互換
                   │                └──────────────┘    │   ストレージ
                   └────────────────────────────────────┘

役割分担

レイヤー何が入っているか生成タイミング
S3 互換ストレージJPEG ピクセルのみ静的(sync で 1 回)
Elasticsearch書誌メタデータ・検索インデックス静的(indexer で 1 回)
Cantaloupe /iiif/3/<id>/info.json画像寸法・タイル配列・サイズリスト動的生成(S3 から HEAD して JSON 組立、derivative cache に保存)
Cantaloupe /iiif/3/<id>/<region>/<size>/.../default.jpg切り出し済タイル動的生成(S3 → デコード → 切出 → エンコード)
Next.js /api/iiif/[id]/manifest.jsonPresentation API 3.0 manifest動的生成(ES から _doc 引いて組立、CDN cache)

つまり manifest や info.json はどこにも事前生成・保存しない。リクエストのたびに ES + Cantaloupe を組み合わせて返す JIT モデル。

アクセス制御の肝:単一ホスト + パスベースルーティング

なぜサブドメイン分割しなかったか

最初は archive.example.org(Web UI)と iiif.archive.example.org(IIIF)の 2 サブドメイン構成で組みました。直感的にはこちらの分離が自然に思えますが、問題が 2 つ:

  1. TLS 証明書の制約:Cloudflare の Universal SSL は *.example.org1 階層しかカバーしないiiif.archive.example.org(2 階層下)は SAN に含まれず、TLS handshake が即時失敗します。Advanced Certificate Manager(有償)を契約すれば *.archive.example.org を覆えますが、月額が要ります。
  2. CORS / Cookie 配布の煩雑さ:別オリジンになると OpenSeadragon が IIIF タイルを取りに行くときに CORS ヘッダ調整が必要になり、Cloudflare Access の CF_Authorization cookie の扱いも複雑化します。

パスベース構成に切替

最終的に 1 ホスト(archive.example.org)に集約 + パスで分岐することで両問題を一掃:

# Cloudflare Tunnel ingress(dashboard で設定 or API で更新)
- hostname: archive.example.org
  path: ^/iiif(/.*)?$        # IIIF パスは Cantaloupe へ
  service: http://cf-cantaloupe:8182
- hostname: archive.example.org
  service: http://cf-web:3000     # それ以外は Next.js へ
- service: http_status:404

利点:

  • 同一オリジン → CORS 不要・cookie 共有自動
  • Universal SSL の 1 階層に収まる → 追加コストなし
  • Access ポリシーは 1 つで全パスを保護できる
  • パスは Cantaloupe の URL 体系(/iiif/3/<id>/...)にそのまま素通しなので、書き換えロジックも不要

Cloudflare Access の設定

Zero Trust → Access → Applications で Self-hosted を作成:

  • Application Domainarchive.example.org(単一ホスト)
  • Identity Provider:One-time PIN(メールに 6 桁 OTP)
  • PolicyAllow action、Selector は Emails または Email ends in(例:*.example.ac.jp
  • Session duration:24h など

ユーザーは初回アクセス時にメール OTP で認証 → CF_Authorization cookie が archive.example.org 全体に発行される → 以降は Web UI も /iiif/... も同じ session で透過アクセス可。ブラウザ標準の挙動だけでビューアの裏のタイル要求にも cookie が乗る ので、追加コードは一切不要です。

Cloudflare Tunnel(cf-cloudflared)を使う理由

origin が VPS でも mdx 系の学術クラウドでも、inbound ポートを開けずに公開できる

Cloudflare Tunnel は origin から edge への outbound 永続接続を張る方式なので:

  • public IP 不要(NAT 内・private network OK)
  • inbound 通信を遮断したまま公開できる
  • DNS は Cloudflare の *.cfargotunnel.com に CNAME を貼るだけ

AWS 側でこれを実現するには、CloudFront + Cognito 認証 + 私設経路(Direct Connect / Site-to-Site VPN / VPC Origin + PrivateLink)の組み合わせが必要です。「CDN + 認証ゲート + 私設トンネル」をまとめて 1 SaaS で扱える点が、Cloudflare のこの構成上の利点です。機能カバレッジが完全に等価というわけではないので、要件によって選択肢は変わります。

Cantaloupe + S3 互換ストレージ

なぜ Cantaloupe を選んだか

IIIF Image API サーバの選択肢には IIPImageServer(C++・軽量)、Cantaloupe(Java)、Loris(Python・拡張容易)等があり、用途で選びどころが変わります。今回 Cantaloupe を採用した理由は:

  • S3Source 内蔵:S3 互換ストレージから直接読んで JPEG タイルを生成できる(追加プロキシ不要)
  • 設定がプロパティファイル一枚:Docker Compose だけで完結する運用簡素さ
  • derivative cache:1 回タイル生成すれば以降は静的配信レベルで高速

今回はコミュニティで広く使われている Islandora プロジェクトの Docker イメージを使用しました(islandora/cantaloupe:<version>、Docker Hub にて配布)。confd テンプレで CANTALOUPE_* 環境変数から cantaloupe.properties を生成してくれるので、Docker Compose だけで完結します。

重要な設定

# 動作モード
source.static = S3Source
endpoint.iiif.3.enabled = true

# S3 互換ストレージ
S3Source.endpoint = ${S3_ENDPOINT}
S3Source.region = ${S3_REGION}
S3Source.access_key_id = ${S3_ACCESS_KEY}
S3Source.secret_access_key = ${S3_SECRET_KEY}
S3Source.path_style_access = true   # MinIO 含む S3 互換ストレージで重要
S3Source.lookup_strategy = BasicLookupStrategy
S3Source.BasicLookupStrategy.bucket.name = ${S3_BUCKET}

path_style_access = true を入れないと AWS S3 の virtual-hosted style URL(<bucket>.<host>/key)になってしまい、多くの S3 互換ストレージで失敗します。

ハマったところ:AWS SDK のクレデンシャルチェーン

Cantaloupe 5.x で Custom S3 Endpoint を使う際、S3Source.access_key_id のプロパティが効かず AWS SDK の default credentials chain にフォールバックする挙動が観測されました(環境依存の可能性があり、再現条件は十分に切り分けられていません)。プロパティ単独だと [endpoint: null] [accessKeyID: null] のログとともに SignatureDoesNotMatch が出ることがあります。

回避策:標準の AWS 環境変数も冗長に渡す

environment:
  - CANTALOUPE_S3SOURCE_ACCESS_KEY_ID=${S3_ACCESS_KEY}
  - CANTALOUPE_S3SOURCE_SECRET_ACCESS_KEY=${S3_SECRET_KEY}
  # belt-and-suspenders
  - AWS_ACCESS_KEY_ID=${S3_ACCESS_KEY}
  - AWS_SECRET_ACCESS_KEY=${S3_SECRET_KEY}
  - AWS_REGION=${S3_REGION}

マニフェスト寸法は info.json から取る

Manifest 内の Canvas 寸法は 書誌の数値 ではなく info.json の数値 を採用するのが IIIF 流儀。Cantaloupe は実画像をデコードして寸法を返すので、それが「真値」です:

// Next.js の /api/iiif/[id]/manifest.json
async function fetchImageInfo(iiifId: string) {
  const r = await fetch(`${IIIF_BASE}/${iiifId}/info.json`,
    { next: { revalidate: 300 } });   // Next.js の fetch cache(5 分)
  if (!r.ok) return null;
  return await r.json();   // { width, height, ... }
}

const info = await fetchImageInfo(doc.iiif_id);
const w = info?.width ?? doc.width ?? 1000;   // ES の値はフォールバック
const h = info?.height ?? doc.height ?? 1000;

3 段重ねのキャッシュ(Cantaloupe derivative + Next.js fetch + Cloudflare edge)で実用上のオーバーヘッドはほぼゼロ。

S3 互換ストレージとの実装差異

商用 AWS S3 と S3 互換ストレージ実装の間には 小さな実装差異がしばしば存在します。今回ハマったポイント:

1. AWS CLI v2.23+ の flexible checksum を拒否

エラー:

InvalidArgument: x-amz-content-sha256 must be UNSIGNED-PAYLOAD,
STREAMING-AWS4-HMAC-SHA256-PAYLOAD, or a valid sha256 value.

回避:

export AWS_REQUEST_CHECKSUM_CALCULATION=when_required
export AWS_RESPONSE_CHECKSUM_VALIDATION=when_required

2. CJK 文字を含むキーで SignatureDoesNotMatch

CJK 含むキーを PUT/HEAD すると署名検証が失敗することがあります。原因はクライアント側(古い mc の URL エンコード仕様、boto3 の旧 issue 等)と サーバ側の URL 正規化の差異の双方が絡みます。バージョンの組合せに依るため、互換ストレージごとに再現性が変わります。

→ どの組合せでも安全な対処は キーを ASCII flat に統一

photos/<basename>.jpg

ラウンドや分類は ES の extraction_round 等のメタデータで管理し、S3 のキーは「不変アドレス」だけにする。これが結果的にも

  • iiif_id が永続不変
  • 同一物理写真の重複デジタイズが S3 上で last-write-wins で自然に dedup
  • CJK 互換問題が完全消滅

という副次効果を生みました。

3. IsTruncated=true だが NextContinuationToken を返さない

list-objects-v2 のページングが完全には spec 準拠でなく、1000 件で頭打ちになる実装あり。StartAfter も無視されることがある。

→ 大量オブジェクトを管理するなら 「ES を真理ソース、S3 はバイト置き場、LIST 操作は最後の手段」 の方針が安全。s3_uploaded フィールドを ES に持たせ、sync スクリプトが PUT 成功時に bulk update することで「何が S3 に上がっているか」を ES 側で完結させる:

# scripts/sync_images_to_s3.py
class ESUpdateQueue:
    def enqueue_uploaded(self, basename, size):
        # PUT 成功後にここで item_id を取り出し、
        # ES に s3_uploaded=true を後送する
        ...

ファセットの s3_uploaded:false で「画像未着の doc」を一発で炙り出せるようになり、ガベコレ・差分 sync・健全性チェックが楽になります。

Elasticsearch:プラグインなしの日本語部分一致

production の Elasticsearch クラスタが他チームと共有で、analysis-kuromoji プラグインを入れられない制約がありました。代わりに n-gram bigram analyzer で部分一致を実現:

INDEX_SETTINGS = {
    "settings": {
        "analysis": {
            "tokenizer": {
                "bigram_tokenizer": {
                    "type": "ngram",
                    "min_gram": 2, "max_gram": 2,
                    "token_chars": ["letter", "digit"],
                }
            },
            "analyzer": {
                "ja_text": {
                    "type": "custom",
                    "tokenizer": "bigram_tokenizer",
                    "filter": ["lowercase", "cjk_width"],
                }
            },
        },
    },
    ...
}

そして検索クエリは match_phrase を使用:

const should = Object.entries(searchFields).map(([field, opts]) => ({
  match_phrase: { [field]: { query: term, boost: opts.weight ?? 1 } },
}));

n-gram + match_phrase = substring 検索 に等価。形態素解析 で検索しても 形態素 態素解 素解析 でもヒットする。cjk_width 正規化で全角・半角の揺れも吸収。

メリット:プラグインなしでどこでも動く。デメリット:単漢字検索が機能しない(min_gram=2 のため)が、CJK では「ノイズが減る」と捉えれば許容できる。

データパイプライン:raw → normalize → index → sync

データ提供元から複数バッチに分けて届くデータは バッチごとにフォルダ構造が揺れるのが普通です。たとえば:

  • バッチ識別子の命名揺れ:日付サフィックス有無、全角/半角数字、副番号、命名規則の途中変更
  • 画像フォルダ名のバリエーション:分類別にサブフォルダが切られたり、フラットだったり、全角ハイフン混入したり
  • Excel 命名のバリエーション:修正前修正済み並べ替え済、依頼票(実体はメタデータ管理用)が混入

これを毎回 indexer 側で吸収しようとすると正規化ロジックがどんどん肥大化するので、normalize_rounds.py という中間ステージを切り出しました。

raw データ(提供元から届いた状態のまま、read-only)
       ▼ scripts/normalize_rounds.py
       │   ・バッチ ID を統一フォーマットに正規化(全角→半角・ゼロ埋め等)
       │   ・bibliography Excel を「修正済み > 並べ替え済 > 修正前」の
       │     優先順位で各カテゴリ 1 件だけ選択(依頼票等は除外)
       │   ・本体写真ではない reference 画像(袋・封筒等)は除外
       │   ・メタ情報を meta.json に書き出し
       │   ・**全部 symlink** なので、容量増加ゼロ
clean tree(symlink only、揺れを吸収済の統一フォーマット)
       ▼ indexer/index.py
       │   ・bibliography Excel を読んで ES に bulk upsert
       │   ・update + upsert で `indexed_at` は最初の登録時のみ、
       │     `updated_at` は毎回更新(何度走っても idempotent)
Elasticsearch(メタデータのみ)

clean tree
       ▼ scripts/sync_images_to_s3.py
       │   ・boto3 + ThreadPoolExecutor (16 ワーカ) で並列 PUT
       │   ・既存キー snapshot で size match の重複アップロードを skip
       │   ・PUT 成功時に ES に `s3_uploaded=true` を bulk update
S3 互換ストレージ(JPEG のみ)

中間 staging を symlink で組むと 数万ファイル規模でも数秒で全件再構築できる。idempotent re-run が安全な実装になっており、データ提供フォーマットの揺れに対してロバスト。

設計のポイント:揺れの吸収を indexer / sync 側に持ち込まないこと。raw → clean の正規化を独立した stage に切り出せば、データ提供フォーマットが変わっても正規化ルールだけアップデートすれば済み、indexer / sync は「統一済の clean tree」だけを前提にできて寿命が長くなります。

実装でよく踏むハマりどころ

項目症状対処
Cloudflare Universal SSL の 2 階層TLS handshake failサブドメインを 1 階層に揃える / パスベース ingress
AWS CLI 2.23+ の checksumx-amz-content-sha256 must be …AWS_REQUEST_CHECKSUM_CALCULATION=when_required
S3 キーの CJKSignatureDoesNotMatchキーを ASCII flat に
s3 互換 LIST の 1000 capsync 後の検証ができない真理ソースを ES に置く
Cantaloupe v5 の S3Source 認証[accessKeyID: null]AWS_ACCESS_KEY_ID 環境変数も渡す
Next.jsNEXT_PUBLIC_*クライアント bundle に反映されないDockerfileARG + compose の build.args で渡す
bash の mc mirror walking 速度73k symlink を stat に十数分Python + boto3 で並列化(数十倍速)

公開せずに「公開」する、という運用形態

このアーキテクチャは

  • 完全公開(誰でもアクセス)
  • 完全非公開(社内ファイルサーバー)

の中間的な「メンバー限定公開」を IIIF 規格に沿った形で実現します。これは、

  • 校閲スタッフが校正時に IIIF ビューアでディテール確認
  • 提携機関に manifest URL を共有し、認証済みセッションで Mirador 等から閲覧
  • 研究者の閲覧申請に応じて Email ドメインで動的にアクセス許可

といった運用形態において有効です。デジタルアーカイブにおける「公開可否を二値で考えない」中間層を、IIIF 規格に準拠した形で構築できる点が、本構成の主要な特徴です。

特に 「公開できる範囲だけを切り出した別 IIIF」を別系統で運用する必要がない 点が、本構成の運用上のメリットです。原データを単一系統で扱いながら閲覧者を Access ポリシーで制御できます。

拡張の方向性:IIIF Auth API 2.0 との関係

本節は 設計上の方向性のみ を整理したもので、本記事の構成には未実装です。具体的な実装手順・コード例は、実際に検証した上で別記事として執筆予定です。

ここまで述べた Cloudflare Access による cookie 認証は、本質的に HTTP / ネットワーク層 の制御です。一方、IIIF コミュニティには IIIF Authorization Flow API 2.0 という アプリ(IIIF)層 の認証規格が存在します。両者は重ねて使うことが可能で、用途で役割が分かれます。

2 層の整理

何を守るか何を伝えるか
HTTP / ネットワーク層(Cloudflare Access)URL パス・ホスト名へのリクエスト「このリクエストは認証済か」だけ。未認証時はログイン HTML / 302
IIIF / アプリ層(Auth API 2.0)IIIF リソース(image など)spec 準拠の service[] で「auth エンドポイントはどこか」「どう認証するか」をビューアに教える

重ね方のパターン

パターン用途本記事の位置づけ
HTTP 層のみ同一組織内ブラウザで完結する運用本記事の構成 = これ
IIIF 層のみ公開アーカイブで一部リソースに会員制を載せる(別構成)
両方の重ね外部 IIIF クライアントとの相互運用が必要な場合(拡張余地、未実装)

「両方の重ね」を実装する場合、おおむね次の方向性になります(詳細は別記事で):

  • Cloudflare Access のポリシーを bypass(manifest / info.json / auth エンドポイント)+ allow(image bytes) に分割
  • info.json に Auth API 2.0 の service[] を埋め込み(Cantaloupe には delegate script の機構がある旨は確認済、具体メソッド名は要検証)
  • Probe / Access / Token endpoint を Next.js 側で実装し、内部で Cloudflare Access の cookie / JWT を検証
  • タイル配信側は Bearer トークンを検証するレイヤーを追加(delegate script で完結する想定)

この記事のスコープ

本記事の範囲では HTTP 層のみ(Cloudflare Access) で、同一組織内ブラウザでの運用に必要十分な構成を実装・運用しています。IIIF 層との両立は次のステップで、実装と動作検証を経た上で改めて記事化する予定です。

まとめ

コンポーネント役割
S3 互換ストレージJPEG のバイト置き場(flat ASCII キー)
CantaloupeIIIF Image API(タイル生成・info.json 動的生成)
Elasticsearch検索 + メタデータの SoR(s3_uploaded 等の sync 状態も含む)
Next.jsUI + manifest API(Presentation 3.0 を JIT 生成)
Cloudflare Tunnelinbound 不要・outbound 一本で公開
Cloudflare Access認証ゲート(Email OTP / SSO)— 同一オリジンで Web も IIIF も覆う

本構成の特徴は、アクセス制御を「同一オリジンに Access ポリシーを貼るだけ」で完結させ、Web UI と IIIF 配信の双方を 1 つの認証セッションでカバーできる点にあります。これにより、著作権・契約・倫理上の理由で一般公開できない歴史資料についても、研究・編集の現場で IIIF の利便性(規格準拠の高解像度ビューア・manifest 配信)を認証済みメンバー範囲内で活用できます。組織外システム / 非インタラクティブクライアントからの相互運用は本構成単体では成立せず、IIIF Auth API 2.0 を併用する必要があります。

本記事で採用したのは Cloudflare Access による cookie ベースの認証で、同一組織内・同一ブラウザでの運用に適した構成です。外部 IIIF クライアントとの相互運用性を求めるシナリオでは、本構成の上に IIIF Auth API 2.0(アプリ層の認証規格)を重ねる方向性があり、別記事として実装・検証を予定しています。

何か参考になれば幸いです。