Cloudflare Tunnelで学術サーバを安全に公開する

背景

学術研究用のサーバでElasticsearch(全文検索)やCantaloupe(IIIF画像配信)を運用する場合、通常はサーバのポートを外部に公開する必要がある。しかしポートを開放すると、脆弱性を突いた攻撃のリスクが生まれる。

Cloudflare Tunnelを使えば、サーバのインバウンドポートを一切開けずに、サービスを安全に外部公開できる。

Cloudflare Tunnelとは

通常のサーバ公開では、サーバ側がポートを開けて外部からの接続を待ち受ける(インバウンド接続)。Cloudflare Tunnelはこの構造を逆転させる。

【従来】
外部 → (80/443ポート) → サーバ
  ※ サーバがポートを開けて待ち受ける

【Cloudflare Tunnel】
外部 → Cloudflare → ← サーバ(cloudflared)
  ※ サーバ側からCloudflareに接続しに行く(アウトバウンド)
  ※ インバウンドポートは不要

サーバ上で動作するcloudflaredというエージェントが、Cloudflareに対してアウトバウンド接続を維持する。外部からのリクエストはCloudflareが受け取り、このトンネル経由でサーバに転送される。

メリット

  • ポート開放不要: インバウンドポートを全て閉じられる
  • WAF・DDoS防御: Cloudflareが自動で攻撃を吸収
  • SSL自動化: Let’s Encryptの設定やリバースプロキシ(Traefik等)が不要
  • 無料: Tunnelは無料プランで利用可能

構成

Cloudflare
  ├── iiif-cf.example.jp → Cantaloupe (8182)
  └── es-cf.example.jp   → Elasticsearch (9200)
          │ Tunnel(暗号化済み)
サーバ(Docker)
  ├── cloudflared(Tunnelエージェント)
  ├── elasticsearch(全文検索)
  └── cantaloupe(IIIF画像配信)

手順

1. Cloudflareにドメインを登録

Cloudflareのダッシュボードでドメインを追加し、レジストラ(お名前.com等)のネームサーバをCloudflareに変更する。

2. Tunnelの作成

Cloudflareダッシュボード(Zero Trust → Networks → Tunnels)からTunnelを作成し、トークンを取得する。

CLIでも作成可能:

cloudflared tunnel create my-tunnel

3. Docker Compose

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: always
    command: tunnel --protocol http2 run
    environment:
      TUNNEL_TOKEN: <取得したトークン>
    extra_hosts:
      - host.docker.internal:host-gateway
    networks:
      - tunnel-network

  elasticsearch:
    image: elasticsearch:8.17.0
    container_name: elasticsearch
    restart: always
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - ES_JAVA_OPTS=-Xms512m -Xmx512m
    volumes:
      - es-data:/usr/share/elasticsearch/data
    networks:
      - tunnel-network

  cantaloupe:
    image: mitlibraries/cantaloupe:latest
    container_name: cantaloupe
    restart: always
    volumes:
      - cantaloupe-images:/imageroot
    networks:
      - tunnel-network

volumes:
  es-data:
  cantaloupe-images:

networks:
  tunnel-network:

ポイント:

  • 各サービスはポートを外部に公開しない(ports指定なし)
  • cloudflaredがDocker内部ネットワーク経由で各サービスにアクセス
  • --protocol http2はUDP制限のある環境で必要

4. DNSルーティングの設定

cloudflared tunnel route dns my-tunnel iiif-cf.example.jp
cloudflared tunnel route dns my-tunnel es-cf.example.jp

5. Ingressルールの設定

Cloudflare APIでホスト名とサービスの紐づけを行う:

curl -X PUT "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/cfd_tunnel/<TUNNEL_ID>/configurations" \
  -H "X-Auth-Email: <EMAIL>" \
  -H "X-Auth-Key: <API_KEY>" \
  -H "Content-Type: application/json" \
  --data '{
    "config": {
      "ingress": [
        {"hostname": "iiif-cf.example.jp", "service": "http://cantaloupe:8182"},
        {"hostname": "es-cf.example.jp", "service": "http://elasticsearch:9200"},
        {"service": "http_status:404"}
      ]
    }
  }'

最後のhttp_status:404はキャッチオールルール(必須)。

6. 起動・確認

docker compose up -d
curl https://es-cf.example.jp
# → Elasticsearchの応答が返る

curl https://iiif-cf.example.jp
# → Cantaloupeの管理画面が返る

7. ファイアウォールの閉鎖

Tunnelが正常に動作していることを確認したら、サーバのインバウンドポートを全て閉じる。cloudflaredはアウトバウンド接続のみなので、インバウンドは一切不要。

サービスごとの公開方針

Tunnel経由で全てのサービスを外部公開する必要はない。サービスの性質に応じて公開範囲を使い分ける。

公開してよいサービス

IIIF画像配信(Cantaloupe)のように、不特定多数のクライアントからアクセスされるサービスは、Tunnel経由で外部公開し、CDNキャッシュを活用する。

外部公開すべきでないサービス

Elasticsearchのような内部サービスは、一般的に外部公開しない。認証なしで公開するとデータの漏洩や改ざんのリスクがある。

対応方法はいくつかある。

方法1: Ingressから除外する

Elasticsearchをtunnel ingressに含めず、必要な時だけSSHポートフォワーディングで接続する。

# Zero Trust SSH経由でポートフォワード
ssh -L 9200:elasticsearch:9200 my-server-cf

ローカルの localhost:9200 でElasticsearchにアクセスでき、Next.js等の開発環境からは以下の設定で接続可能:

ELASTICSEARCH_URL=http://localhost:9200

方法2: Cloudflare Accessで保護する

Tunnel経由で公開するが、Cloudflare Accessの認証を必須にする。SSHと同様に、認証済みのユーザーのみアクセスを許可する。APIからのアクセスにはService Tokenを発行する。

方法3: 本番アプリからはTunnel内部で接続する

本番環境のアプリ(Cloudflare Workers等)からElasticsearchに接続する場合は、Tunnel内部のネットワーク(Service Binding等)を利用し、外部公開せずに接続する。

推奨構成

Tunnel経由で外部公開
  └── Cantaloupe(IIIF画像配信)→ CDNキャッシュ有効

非公開(Ingressに含めない)
  └── Elasticsearch(内部サービス)

開発時のアクセス
  └── Zero Trust SSH ポートフォワーディング → localhost:9200

Elasticsearchの外部公開を停止する

Ingressルールからエントリを削除し、DNSレコードも削除する。

# Ingressから除外(ESのエントリを含めない)
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/cfd_tunnel/<TUNNEL_ID>/configurations" \
  -H "X-Auth-Email: <EMAIL>" \
  -H "X-Auth-Key: <API_KEY>" \
  -H "Content-Type: application/json" \
  --data '{
    "config": {
      "ingress": [
        {"hostname": "iiif-cf.example.jp", "service": "http://cantaloupe:8182"},
        {"service": "http_status:404"}
      ]
    }
  }'

# DNSレコードも削除
# Cloudflare API or ダッシュボードから該当CNAMEを削除

ローカル開発でElasticsearchに接続する

Zero Trust SSH経由のポートフォワーディングを使う。

# SSHポートフォワードを開始(ESコンテナのIPを指定)
ssh -L 9200:<ESコンテナIP>:9200 my-server-cf

ESコンテナのIPは以下で確認できる:

ssh my-server-cf "docker inspect elasticsearch --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'"

ポートフォワード中は localhost:9200 でESにアクセスできる。

Next.jsの.env.local

ELASTICSEARCH_URL=http://localhost:9200

APIルート(app/api/search/route.ts)の例:

const ES_URL = process.env.ELASTICSEARCH_URL || "http://localhost:9200";

export async function GET(request: NextRequest) {
  const q = request.nextUrl.searchParams.get("q") || "";
  const category = request.nextUrl.searchParams.get("category") || "";

  const must = [];
  if (q) {
    must.push({ multi_match: { query: q, fields: ["title", "description"] } });
  }
  if (category) {
    must.push({ term: { category } });
  }

  const res = await fetch(`${ES_URL}/documents/_search`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query: must.length > 0 ? { bool: { must } } : { match_all: {} },
      aggs: { categories: { terms: { field: "category" } } },
    }),
  });

  const data = await res.json();
  return NextResponse.json({
    hits: data.hits.hits.map((h) => h._source),
    facets: data.aggregations.categories.buckets,
    total: data.hits.total.value,
  });
}

この構成により、Elasticsearchはインターネットに一切公開せず、開発時はSSHポートフォワード経由でのみアクセスする。

SSL証明書の注意点

Cloudflareの無料プランでは*.example.jp(1階層)のワイルドカード証明書が自動発行される。しかしes.cf.example.jpのような2階層のサブドメインはカバーされない。

サブドメイン証明書
es-cf.example.jp(1階層)✅ カバーされる
es.cf.example.jp(2階層)❌ カバーされない

2階層を使いたい場合はAdvanced Certificate Manager($10/月)が必要。

従来構成(Traefik)との比較

アーキテクチャの違い

【Traefik構成】
インターネット
  → サーバ (80/443ポート開放)
    └── Traefik(リバースプロキシ)
        ├── Cantaloupe
        ├── Elasticsearch
        └── ModSecurity(WAFが必要な場合)

【Cloudflare Tunnel構成】
インターネット
  → Cloudflare(WAF・DDoS防御・SSL)
    → Tunnel(暗号化)
      → サーバ(ポート開放なし)
        └── cloudflared
            ├── Cantaloupe
            └── Elasticsearch

機能比較

項目Traefik + Let’s EncryptCloudflare Tunnel
ポート開放80, 443が必要不要
SSL管理Let’s Encrypt自動更新Cloudflareが完全管理
WAFなし(別途構築が必要)あり(無料)
DDoS防御なしあり(無料)
コストサーバ負担のみ無料
設定量Traefik設定 + ラベルDocker Compose + API

TraefikでWAFを実現する場合

Traefik単体にはWAF機能がない。WAFが必要な場合、ModSecurityコンテナを別途起動してTraefikのミドルウェアとして組み込む必要がある。

# Traefik + ModSecurity構成(参考)
services:
  traefik:
    image: traefik:latest
    ports:
      - "80:80"
      - "443:443"

  modsecurity:
    image: owasp/modsecurity-crs:apache
    environment:
      - PARANOIA=1

  cantaloupe:
    labels:
      - "traefik.http.routers.cantaloupe.middlewares=waf@docker"

この構成には以下の課題がある:

  • ModSecurityの設定・チューニングが複雑
  • 誤検知(false positive)への対応が必要
  • CRS(Core Rule Set)のアップデート管理
  • パフォーマンスへの影響

Cloudflare Tunnelでは、これらが全てCloudflare側で管理されるため、運用負荷が大幅に軽減される。

移行の容易さ

TraefikからCloudflare Tunnelへの移行は比較的容易。

  1. Traefikのラベル設定を削除
  2. docker-compose.ymlにcloudflaredコンテナを追加
  3. ポートの外部公開を削除
  4. Cloudflare側でIngressルールを設定

既存のサービスコンテナ(Elasticsearch、Cantaloupe等)はそのまま使える。

CDNキャッシュの設定

Cloudflare Tunnelで公開したサービスは、デフォルトではCDNキャッシュが効かない(cf-cache-status: DYNAMIC)。IIIFの画像タイルのように同じレスポンスが繰り返しリクエストされる場合、CDNキャッシュを有効にすることでオリジンサーバの負荷を大幅に軽減できる。

なぜデフォルトでキャッシュされないのか

Cloudflareは.jpg.png等の静的ファイル拡張子を自動キャッシュするが、Tunnel経由のレスポンスではオリジンのCache-Controlヘッダや動的コンテンツの判定によりDYNAMIC(キャッシュ対象外)と判定されることがある。Cache Rulesで明示的にキャッシュを有効にすることで確実にCDNキャッシュが効くようになる。

Cache Rulesの設定

Cloudflare APIでホスト名単位のキャッシュルールを設定する:

curl -X POST "https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/rulesets" \
  -H "X-Auth-Email: <EMAIL>" \
  -H "X-Auth-Key: <API_KEY>" \
  -H "Content-Type: application/json" \
  --data '{
    "name": "IIIF Cache Rules",
    "kind": "zone",
    "phase": "http_request_cache_settings",
    "rules": [
      {
        "expression": "(http.host eq \"iiif-cf.example.jp\")",
        "description": "Cache IIIF responses",
        "action": "set_cache_settings",
        "action_parameters": {
          "cache": true,
          "edge_ttl": {
            "mode": "override_origin",
            "default": 86400
          },
          "browser_ttl": {
            "mode": "override_origin",
            "default": 86400
          }
        }
      }
    ]
  }'

設定後のレスポンスヘッダ

cf-cache-status: HIT     ← CDNキャッシュから配信
age: 24                   ← キャッシュされてからの秒数
cf-ray: xxxxx-NRT         ← 成田のエッジサーバから配信
ヘッダ値意味
MISSキャッシュなし。オリジンから取得してキャッシュに保存
HITキャッシュから配信。オリジンにアクセスしない
DYNAMICキャッシュルール対象外

コスト

CDNキャッシュは無料プランで容量・帯域ともに無制限。IIIFの画像タイル配信に制限はない。

無料枠でどこまで使えるか

今回の構成で使用したCloudflareの機能は、全て無料プランの範囲内で利用できる。

機能用途無料枠
DNSドメインのネームサーバ無制限
Tunnelサーバへの安全な接続50本まで
CDN画像タイルのキャッシュ容量・帯域ともに無制限
WAF攻撃防御基本ルール含む
SSL証明書自動発行・更新無制限
Zero Trust AccessSSH認証等50ユーザーまで

研究用途で無料枠を超えることはまずない。費用が発生するのはサーバ(VPSやmdx等)の運用コストのみ。

なぜこれだけの機能が無料なのか

Cloudflareのビジネスモデルは、無料プランで大量のユーザーを獲得し、大企業向けのEnterprise/Proプランで収益化する構造になっている。個人・研究者・小規模プロジェクトは無料プランの恩恵を受ける側にいる。

今回の構成で得られたもの

従来構成と比較して、何が変わったかを整理する。

【従来(Traefik + VPS直接公開)】
- ポート開放が必要 → 攻撃リスク
- SSL証明書の管理が必要
- WAFなし → フレームワークの脆弱性が直撃
- CDNなし → オリジンに負荷集中
- サーバ管理者の運用負荷が大きい

【Cloudflare Tunnel + mdx】
- インバウンドポート全閉鎖 → 攻撃経路なし
- SSL/WAF/CDN/DDoS防御が全て自動
- SSHもZero Trustで保護
- 内部サービス(ES等)は外部非公開のまま開発可能
- Cloudflare側の費用ゼロ

実践例: Next.js + Elasticsearch + IIIF のデモアプリ

Cloudflare Tunnelの構成を活用して、検索・画像閲覧を備えたWebアプリを構築する実践例。

全体構成

ブラウザ
  ├── app-cf.example.jp  → Next.js(検索UI)
  │     Tunnel経由で公開
  └── iiif-cf.example.jp → Cantaloupe(IIIF画像配信)
        Tunnel経由で公開 + CDNキャッシュ

サーバ(Docker network内部)
  ├── cloudflared(Tunnelエージェント)
  ├── app(Next.js)
  │     → elasticsearch:9200 に直接接続(Docker内部、<1ms)
  ├── elasticsearch(全文検索)← 外部非公開
  └── cantaloupe(IIIF画像配信)
        → S3互換ストレージから画像取得

ポイント:

  • Next.jsとElasticsearchは同じDockerネットワークに配置し、ES名前解決(elasticsearch:9200)で直接接続。外部公開不要
  • Cantaloupeの画像配信はCDNキャッシュを活用し、同じタイルの再リクエストはCloudflareのエッジから応答
  • ブラウザからはTunnel経由のみでアクセス。サーバのインバウンドポートは全て閉鎖

docker-compose.yml

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: always
    command: tunnel --protocol http2 run
    environment:
      TUNNEL_TOKEN: <トークン>
    extra_hosts:
      - host.docker.internal:host-gateway
    networks:
      - tunnel-network

  elasticsearch:
    image: elasticsearch:8.17.0
    restart: always
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - ES_JAVA_OPTS=-Xms512m -Xmx512m
    volumes:
      - es-data:/usr/share/elasticsearch/data
    networks:
      - tunnel-network

  cantaloupe:
    image: islandora/cantaloupe:6.3.12
    restart: always
    environment:
      CANTALOUPE_SOURCE_STATIC: S3Source
      CANTALOUPE_S3SOURCE_ENDPOINT: https://s3.example.jp
      CANTALOUPE_S3SOURCE_REGION: us-east-1
      AWS_ACCESS_KEY_ID: <アクセスキー>
      AWS_SECRET_ACCESS_KEY: <シークレットキー>
      CANTALOUPE_S3SOURCE_BASICLOOKUPSTRATEGY_BUCKET_NAME: my-bucket
      CANTALOUPE_S3SOURCE_LOOKUP_STRATEGY: BasicLookupStrategy
      CANTALOUPE_CACHE_SERVER_DERIVATIVE_ENABLED: "true"
      CANTALOUPE_CACHE_SERVER_DERIVATIVE: FilesystemCache
    volumes:
      - cantaloupe_cache:/data
    networks:
      - tunnel-network

  app:
    build: ./es-search
    restart: always
    environment:
      ELASTICSEARCH_URL: http://elasticsearch:9200
    networks:
      - tunnel-network

volumes:
  es-data:
  cantaloupe_cache:

networks:
  tunnel-network:

ESにはポート公開(ports)を設定していない。app コンテナからDocker内部ネットワーク経由でのみアクセスする。

Tunnel Ingressの設定

curl -X PUT "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/cfd_tunnel/<TUNNEL_ID>/configurations" \
  -H "Content-Type: application/json" \
  --data '{
    "config": {
      "ingress": [
        {"hostname": "app-cf.example.jp", "service": "http://app:3000"},
        {"hostname": "iiif-cf.example.jp", "service": "http://cantaloupe:8182"},
        {"service": "http_status:404"}
      ]
    }
  }'

Elasticsearchはingressに含めない。

Next.jsからElasticsearchへの接続

APIルート(app/api/search/route.ts)でESにアクセスし、検索結果にIIIF画像のURLを付与する:

const ES_URL = process.env.ELASTICSEARCH_URL; // http://elasticsearch:9200
const IIIF_BASE = "https://iiif-cf.example.jp";

export async function GET(request: NextRequest) {
  const q = request.nextUrl.searchParams.get("q") || "";
  const category = request.nextUrl.searchParams.get("category") || "";

  // Elasticsearchに検索クエリを発行
  const res = await fetch(`${ES_URL}/documents/_search`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query: ...,
      aggs: { categories: { terms: { field: "category" } } },
    }),
  });

  const data = await res.json();

  // IIIF画像URLを付与して返却
  return NextResponse.json({
    hits: data.hits.hits.map((h) => {
      const source = h._source;
      if (source.iiif_id) {
        source.iiif_info = `${IIIF_BASE}/iiif/2/${encodeURIComponent(source.iiif_id)}/info.json`;
        source.iiif_thumbnail = `${IIIF_BASE}/iiif/2/${encodeURIComponent(source.iiif_id)}/full/200,/0/default.jpg`;
      }
      return source;
    }),
    facets: data.aggregations.categories.buckets,
    total: data.hits.total.value,
  });
}

ELASTICSEARCH_URL はDocker内部の名前解決(http://elasticsearch:9200)を使うため、外部に公開する必要がない。

OpenSeadragonによるIIIF画像ビューア

検索結果のIIIF画像をクリックすると、OpenSeadragon(OSD)で高解像度ビューアが開く。OSDはクライアントサイドで動作し、iiif-cf.example.jp からタイルを取得する。タイルはCloudflareのCDNにキャッシュされるため、同じ領域の再表示は高速。

// OSDはクライアントサイドでのみ動作するため、dynamic importを使う
useEffect(() => {
  import("openseadragon").then((OSD) => {
    OSD.default({
      element: viewerRef.current,
      tileSources: [infoUrl],  // IIIF info.json URL
    });
  });
}, [infoUrl]);

S3互換ストレージの注意点

CantaloupeがS3互換ストレージ(AWS以外)に接続する場合、AWS_REGION 環境変数の設定が必要。未設定だとAWS SDK v2がリージョン解決を繰り返しリトライし、タイル取得がタイムアウトする。実際のリージョンは問わないので us-east-1 等のダミー値を設定する。

まとめ

Cloudflare Tunnelを使うことで、サーバのインバウンドポートを完全に閉じた状態でサービスを安全に公開できる。従来必要だったリバースプロキシ(Traefik等)やSSL証明書の管理も不要になり、WAF・DDoS防御・CDNキャッシュも追加コストなしで利用できる。

サーバ上のDockerで動かすアプリ(Next.js等)とデータベース(Elasticsearch等)を同じネットワークに配置すれば、内部サービスを外部に公開することなく高速に連携でき、開発時はSSHポートフォワーディングでローカルから接続できる。