ONTLEはどう動いているか — FastAPI・MySQL・Cloudflareトンネルのアーキテクチャ

少人数チームが多数のサブドメインを安全に運用するための実戦的なスタック構成を、それぞれの選択の原理とトレードオフ、そしてありがちな誤解を正しながら整理した。
← 목록으로
이
이서연
2026년 10월 03일 更新 2026.10.16 読了 20分 閲覧 1

ONTLEは、FastAPI(非同期Python)一つでサーバーレンダリングとJSON APIを同時にこなし、データはMySQLに収めつつ文字セットをutf8mb4で固定し、Cloudflareトンネルでインバウンドポートを開けずに外部へ出て、ワイルドカードDNSでコードのデプロイなしにサブドメインを開通させ、systemdがプロセスを生かし続け、静的・メディアはCDNが分担して処理する、という構造で動いている。 この六つの選択は、「1人あるいは少人数のチームが、多数のサブドメインサービスを支えきれるか」という問いに対するそれぞれの答えだ。本稿では、なぜこの組み合わせなのか、何を得て何を引き受けたのかを順に見ていく。そして、以前の原稿にあった二つの不正確な説明 — トンネルのセキュリティ効果、MySQLのデフォルト文字セット — を正確に書き直す。

なぜアーキテクチャの話から始めるのか

「AI PMがサービスを作ってくれる」と聞くと、誰もがまずモデルの話を知りたがる。だが、サービスが何か月も落ちず、サブドメインが数十個に増えても耐え、深夜にトラフィックが跳ねても応答する理由は、モデルではなくその下のインフラにある。ONTLEはこのインフラの上で、AI PMが要件を受けて新しいサブドメインを発行する構造になっているが、発行が「即時」に行われる理由も、発行されたサービスが隔離される仕組みも、すべて今回取り上げる六つの層から生まれる。

一つたとえを挙げよう。建物を建てるとき、階ごとに扉を作り窓ごとに錠をかける代わりに、正門一つに警備を集中させて残りの出入り口はなくす、という設計を選ぶこともできる。ONTLEのインフラはおおむねこのやり方だ。ただし後で見るように、正門を一か所に絞ったからといって「中で起こりうること」まで消えるわけではない。一つずつ開けていこう。

1. アプリケーション層 — FastAPI一つでレンダリングとAPIを兼ねる

FastAPIは、ASGI(非同期サーバーゲートウェイインターフェース)の上で動くPythonのWebフレームワークだ。非同期とは、あるリクエストがデータベースや外部APIの応答を待っている間、その場を遊ばせずに別のリクエストを処理する、という意味である。従来の同期構造では同時処理量を増やすためにスレッドやプロセスをリクエスト数ぶん増やす必要があるが、非同期構造では一つのイベントループが待機中の複数リクエストを交互にさばく。

ONTLEでこれが重要なのは、サブドメインごとにアプリケーションサーバーを別々に立ち上げる代わりに、一つのFastAPIプロセス(または少数のワーカー)が複数サービスのリクエストをまとめて受けきらなければならないからだ。AI PMがサービスを発行するたびにプロセスを新規に立ち上げていては、サーバーリソースも運用負担もサービス数に比例して増えていく。非同期構造はこの比例関係をなだらかにする。

FastAPIは、HTMLをサーバー側で描いて返すサーバーサイドレンダリングのルートと、クライアントが呼び出すJSON APIのルートを、同じコードベース・同じプロセスで扱う。レンダリングサーバーとAPIサーバーをわざわざ分けない理由は単純だ。サブドメインごとに要求が違うのに、サーバーを分けておくと新規サービスのたびに両サーバー間のデプロイ・認証・ルーティングを合わせ直さなければならない。

弱い例

「サービスごとに別サーバーを立てるほうが安全で、きれいだ。」

隔離を高めてくれる言葉に聞こえるが、実際にはサービスが一つ増えるたびにデプロイパイプライン・ヘルスチェック・ポート割り当て・リバースプロキシ設定が一つずつ増える。三つ四つのうちは持ちこたえても、十個、二十個になると運用負担がサービス数より速く膨らむ。

強い例

「一つのFastAPIアプリケーションでサブドメインごとのルーターを登録し、リクエストヘッダーのホスト値でどのサービスかを見分ける。」

新規サブドメインの追加が「ルーター登録 + DBレコード追加」程度で済み、プロセス管理の負担がサービス数とともに膨らまない。

2. データ層 — MySQLとutf8mb4

なぜMySQLか

MySQLはリレーショナルデータベースで、サービス・ユーザー・サブドメイン・コンテンツのように参照関係がはっきりしたデータに向いている。ONTLEで「このサブドメインは誰のもので、どのテンプレートを使い、いつ発行されたか」は、典型的にはテーブル間の外部キーで表現される。ドキュメント型データベースも選択肢だが、多数のサブドメインが共通スキーマ(発行ステータス・所有者・ルーティング経路)を共有しつつサービスごとの詳細だけが異なる構造では、リレーショナルモデルのほうが一貫性を保ちやすい。

utf8mb4を「明示」する — ありがちな誤解を正す

まず、よく出回る誤った説明から正しておく。「MySQLのデフォルト文字セットは1文字あたり3バイトのutf8だ」という話は、今は正しくない。MySQL 8.0からサーバーのデフォルト文字セットはutf8mb4であり、8.4も同じだ。かつてutf8と呼ばれていたものは実はutf8mb3の別名で、1文字あたり最大3バイトしかサポートせず、現在は廃止予定(deprecated)の状態にある。つまり4バイト文字を収められないのは旧utf8(=utf8mb3)のほうであって、utf8mb4ではない。

では8.0以上では何もしなくてよいのか。そうではない。三つの理由から、ONTLEは文字セットを値で固定する。

第一に、マイグレーションと旧バージョンの問題だ。古いサーバーで作られたか、5.7時代に移ってきたスキーマは、依然としてutf8mb3やlatin1のままかもしれない。サーバーのバージョンが8.xだからといって、既存テーブルが自動で変わるわけではない。

第二に、サーバーのデフォルト値と、コネクション・テーブルの設定は別物だ。サーバーがutf8mb4でも、クライアントのコネクション文字セットや個々のテーブル・カラムが異なることがある。MySQLは文字セットが互いに異なれば両者の間で変換を行うが、変換先の文字セットが表現できない文字(例:utf8mb3カラムに収めようとする4バイトの絵文字)であったり、実際のバイトエンコーディングが宣言された文字セットと食い違っていたりすると、その地点で損失・エラーが生じる。

第三に、明示が安全だ。バージョンや環境に依存しないためには、テーブル・カラム・コネクション・コレーションをすべてutf8mb4で固定するほうがよい。そのためONTLEは新規テーブルをこう作る。

-- サーバーのデフォルト値に頼らず、文字セット・コレーションを明示する
CREATE TABLE ws_domains (
  id          BIGINT       NOT NULL AUTO_INCREMENT,
  subdomain   VARCHAR(63)  NOT NULL,
  owner_id    BIGINT       NOT NULL,
  route_path  VARCHAR(255) NOT NULL,
  created_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uq_subdomain (subdomain)
) ENGINE=InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_0900_ai_ci;   -- 8.0以上のデフォルトコレーション

コネクションにも文字セットを付ける。サーバーがutf8mb4でも、コネクション文字セットがそれを表現できない値だったり、実データのエンコーディングと食い違っていたりすると、往復変換の過程で損失・エラーが生じうるからだ。

# SQLAlchemy(async) + aiomysql — コネクション文字セットもutf8mb4で明示
engine = create_async_engine(
    "mysql+aiomysql://user:[email protected]/ontle_dev?charset=utf8mb4",
    pool_pre_ping=True,
)

こうすれば、韓国語はもちろん、絵文字や拡張漢字のような4バイト文字が、サーバー・コネクション・テーブルのどこでも安全に保存される。運用中に文字セットを変えるにはテーブル全体の変換が必要でコストが大きいので、この値は設計段階で決めるのが正しい。

選択 得るもの 代わりに引き受けるもの
MySQL(リレーショナル) 関係の整合性、使い慣れた運用エコシステム、トランザクション スキーマ変更時にマイグレーションが必要、水平スケールはドキュメント型より手間がかかる
utf8mb4の明示 サーバーバージョン・コネクション・マイグレーション状態と無関係に4バイト文字を保証 utf8mb3に比べ、保存領域・インデックスキー長の計算にわずかに影響

3. 外部入口層 — Cloudflareトンネルでインバウンドポートなしに

ポートフォワーディング・DMZの問題

サーバーを外部に公開するには、従来はルーター・ファイアウォールでポートを開けて内部サーバーへ接続を渡すポートフォワーディングを使うか、隔離されたネットワーク区間(DMZ)にサーバーを置く。両者の共通点は、インターネットから来る接続をサーバーが直接受けるということだ。そのポートはスキャンされ、脆弱なら直接攻撃される。ポートを開けた瞬間、それはインターネット全体に向けて「ここにサーバーがある」と知らせるようなものだ。

トンネルは向きを逆転させる

Cloudflareトンネルは逆に動く。内部サーバーがCloudflareへアウトバウンド接続を先に作って維持する。外部ユーザーがドメインにアクセスすると、リクエストはまずCloudflareのエッジに届き、Cloudflareがすでに開いているそのトンネルを通じて内部サーバーへリクエストを転送する。サーバー側から見ればインバウンドで開けたポートはなく、ルーターのポートフォワーディング規則もDMZ構成も要らない。

[従来の方式: ポートフォワーディング]
インターネット ---(公開されたインバウンドポート)---> ルーター ---> 内部サーバー
              ↑ ポートスキャン・直接攻撃の標的

[トンネル方式]
内部サーバー ---(アウトバウンド接続、サーバーが先に張る)---> Cloudflareエッジ
インターネットユーザー ---(ドメインアクセス)---> Cloudflareエッジ ---(既存トンネル)---> 内部サーバー
              ↑ 開いたインバウンドポートはないが、ドメインに来たリクエストはアプリまで届く

何が減り、何はそのままか(訂正)

ここが以前の原稿で間違っていた部分だ。以前の記事は「トンネル構造では攻撃者が接続を試みる地点そのものがない」と書いたが、これは事実ではない。正確に区別しよう。

減るのはネットワーク層の攻撃面だ。 開いたインバウンドポートがないので、サーバーIPを狙ったポートスキャンや直接攻撃、誤って公開された管理ポート(SSH・DB)の探索といった攻撃が成立しにくくなる。インターネットからサーバーへ「直接差し込む」経路が消えるのだ。

そのまま残るのはアプリケーション層の攻撃面だ。 公開ドメインabc.ontle.krに来た正常なリクエストは、トンネルを通ってアプリケーションまでそのまま届く。ドメインがすなわち接続地点であり、そのリクエストはアプリに到達する。したがってSQLインジェクション、XSS、認証バイパス、権限昇格、ビジネスロジックの悪用といったWeb脆弱性は、トンネルがあってもなくても有効だ。トンネルはこうしたリクエストを防いでくれる装置ではない。

弱い例

「トンネルを使うから攻撃する地点がない。Webセキュリティは気にしなくていい。」

強い例

「トンネルはネットワーク層の露出を減らす。Web脆弱性の防御(入力検証・認証・権限チェック・WAF)はアプリケーション側で別途やらなければならない。」

CloudflareのエッジがTLS終端、DDoS緩和、そして設定によってはWAFのような防御を代わりに担い、一部の層を補強してくれるのは事実であり、実際のメリットだ。だがそれは、アプリケーション自体の入力検証・認証・権限チェックを代替しない。正門のたとえに戻れば、扉を一つに絞ってその前に専門の警備を立てたようなものだ。壁に開いた穴は減ったが、正門から通した客が中で何をするかは、依然として中で管理しなければならない。

トレードオフ

ただではない。外部トラフィックはすべてCloudflareのエッジを経由するので、Cloudflareの障害やポリシー変更に可用性が縛られる。サーバーが内部網にあるという前提なので、そのネットワークの電源・回線の安定性がそのままサービスの可用性に直結する。エッジを一度多く経由するぶん遅延も生じる。それでも少人数のチームにとっては、「ポートを開けて侵入検知まで自前で運用する負担」より「トンネル一つに依存する負担」のほうがおおむね軽い。

4. ルーティング層 — ワイルドカードDNSでサブドメインを即時開通

ワイルドカードDNSレコード(*.ontle.kr)は、あらかじめ定めていないすべてのサブドメインのリクエストを一か所で受ける、という意味だ。abc.ontle.krでもxyz.ontle.krでも、DNSの段階では同じ場所へ向かう。肝心なのは、DNSがあらかじめ知っておく必要がないということだ。新しいサブドメインのたびにレコードを追加する代わりに、ワイルドカードがすでにすべてのケースを受けているので、「どのサブドメインが実際に存在するか」はアプリケーションとデータベースが決める。

FastAPIサーバーは入ってきたリクエストのホストヘッダーを見て、DBで照会してルーティングを判断する。新規サービスを開ける作業が、DNS変更や別途のデプロイではなく、DBレコードを一つ追加することで終わる。

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

@app.middleware("http")
async def route_by_host(request: Request, call_next):
    host = request.headers.get("host", "").split(":")[0]
    # ワイルドカードで全部入ってくるが、存在するかどうかはここでDBが決める
    ws = await lookup_workspace(host)   # 未登録ならNone
    if ws is None:
        return JSONResponse({"detail": "未開通のサブドメイン"}, status_code=404)
    request.state.workspace = ws
    return await call_next(request)

これが、AI PMがサブドメインを発行するとき、別途のインフラ作業なしに数秒でサービスが開く根本的な理由だ。

トレードオフもある。ワイルドカードは存在しないサブドメインのリクエストもいったんサーバーまで到達させる。したがって未登録のリクエストを素早く正確に弾き出す判別ロジックが必要だ。またTLSはワイルドカード証明書で処理されるが、証明書の範囲を超える深さ(例:a.b.ontle.kr)は別途の考慮が要る。

5. プロセス管理層 — systemdユーザーサービス

Pythonプロセスをターミナルからそのまま実行すると、セッションが切れたりサーバーが再起動したりするときに一緒に死ぬ。systemdはLinuxのプロセス管理の標準ツールであり、ユーザーサービスとして登録すれば、プロセスが予期せず死んだときに再び立ち上げるポリシーをかけられる。ただしユーザーサービスの場合、ログイン前のブート時自動起動やログアウト後の常駐にはloginctl enable-lingerなどの設定が別途必要になる。

専門のオーケストレーションツール(コンテナクラスターなど)は強力だが、学習・運用のコストがかかる。サービス数が数百個、トラフィックが毎秒数千件規模でないなら、ディストリビューションに標準で組み込まれているsystemdだけでも「死んだら再び立ち上がる」という核心の要求は十分に満たせる。ツールを載せるより、すでにあるツールを正確に使うほうが複雑さを下げる。

ただし無限の再起動は根本原因(コードのバグ、リソース枯渇)を覆い隠しうる。しかもRestart=alwaysも無条件ではなく、一定時間内の再起動回数がStartLimitIntervalSec・StartLimitBurstの上限を超えると、再起動そのものが打ち切られる。Restart=alwaysだけかけて終わりにすると、サービスが繰り返し死んでは生き返る状態に気づかないまま放置したり、ある瞬間に再起動が止まっていたことに気づかなかったりしかねない。だから再起動ポリシーはログ・通知とともに置く。

6. 静的・メディア層 — CDN

画像、動画、CSS、JavaScriptのような静的ファイルは、リクエストのたびに中身が変わるわけではない。こうしたファイルをアプリケーションサーバーが毎回直接読んで応答すると、静的な転送に使われるリソースが、本来は動的なロジック(DB照会、レンダリング)に充てるべきリソースを食いつぶす。CDNは静的リソースを複数の場所にキャッシュしておき、ユーザーに近い場所からすぐに配る。アプリケーションサーバーは動的処理に集中し、帯域の大きい静的トラフィックはCDNが吸収する。少人数のチームほど、この分離がサーバー1台の余裕を大きく変える。

六つの層を貫く原理は一つだ。チームの規模が小さいほど、各選択は「自前で運用しなければならないものの数」を減らす方向でなければならない。ただし、その減らしがどこまで有効かを正確に知ることも、それと同じくらい重要だ。トンネルがネットワークの露出を減らしてもWeb脆弱性の防御は残り、文字セットのデフォルト値が良くなっても明示は依然として必要だ。

よくある質問

Cloudflareトンネルさえ使えばWebセキュリティは気にしなくていいですか?

いいえ。トンネルはサーバーの開いたインバウンドポートをなくし、ネットワーク層の露出を減らしてくれるだけです。公開ドメインに来たリクエストはトンネルを通じてアプリケーションにそのまま届くので、SQLインジェクション・XSS・認証バイパスといったWeb脆弱性は依然として有効です。入力検証・認証・権限チェックはアプリケーション側で必ず別途行わなければなりません。

MySQL 8.0を使っていても、わざわざutf8mb4を明示すべきですか?

推奨します。8.0・8.4のサーバーデフォルト文字セットはすでにutf8mb4ですが、マイグレーションされたり旧バージョンから移ってきたりしたテーブルはutf8mb3(旧utf8)やlatin1かもしれません。MySQLは文字セットが互いに異なれば変換を行いますが、変換先の文字セットが表現できない文字だったり、実際のエンコーディングが宣言された値と食い違っていたりすると、その地点で損失・エラーが生じます。テーブル・カラム・コネクション・コレーションをすべてutf8mb4で明示すれば、バージョン・環境と無関係に4バイト文字が安全です。

サブドメインごとにサーバーを別々に立てないと、サービス間の隔離はどうするのですか?

ONTLEは一つのアプリケーションでホストヘッダーからワークスペースを見分け、リクエストコンテキストにワークスペースを載せてルーティングします。データは外部連携の収集物・クレデンシャルをプロジェクト専用DBでサイロ化して分離します。プロセスを分けなくても、データ・権限の境界をアプリケーション層で立てる方式です。

systemdのRestart=alwaysだけで運用は十分ですか?

十分ではありません。`Restart=always`は死んだプロセスを立ち上げ直そうとしますが、「必ず立ち上がる」という無条件の保証ではありません。一定時間内の再起動回数が`StartLimitIntervalSec`・`StartLimitBurst`の上限を超えると、再起動は打ち切られます。またユーザーサービスのブート時自動起動・ログアウト後の常駐には`loginctl enable-linger`などの条件も要ります。くわえて、繰り返しの再起動は根本原因(バグ・リソース枯渇)を覆い隠しうるので、再起動回数と失敗ログを観察し、通知もあわせて置いてこそ、サービスが静かに死んでは生き返る状態を放置せずに済みます。

参考資料