Tablize は 1 つの Rust バイナリとして出荷します。そのバイナリの中に:エージェントランタイム、HTTP サーバー、5 つのプロダクトドメイン(Data、IoT、App、Media、Platform)、38 のサードパーティ連携、LLM プロバイダークライアント、ツールレジストリ、認証層、課金台帳。docker run でデプロイ。1 プロセス。1 イメージ。
これは 2026 年では不流行な選択です。私たちのようなプロダクトの主要パターンは:5 つ以上のマイクロサービス、ドメイン別の独立したデプロイ、サービスメッシュ、サービスごとの別データベース、それらを結びつけるイベントバス。私たちはそうしませんでした。この記事はその理由です。
(エンジニアリングブログ記事には、誰かが対抗的なアーキテクチャ選択を擁護するために利点をリストし、欠点を払いのける伝統があります。これはそんな記事にはなりません。選択には実コストがあります。それも扱います。)
セットアップ
最適化していた制約:
1. セルフホスト可能。 ユーザーが docker compose up できて完全機能の Tablize インスタンスを持てるべき。「Tablize Lite」ではない。本物。
2. 小規模チームで運用可能。 お客様(Kubernetes を運用しない DTC オペレーター)にも私たち(12 サービスをメンテするプラットフォームチームを持たない)にも。
3. コードでドメイン分離。 Data と IoT ロジック間のもつれたクロスインポートなし。各ドメインは独立して理解可能であるべき。
4. 速い反復。 私たちは速く動いています。機能追加が 4 サービスに触れて 4 デプロイを調整することを要求すべきではない。
5. 低いコールドスタートコスト。 マネージドクラウドはユーザーごとにワークスペースを起動します。コールドスタートが重要 — ワークスペースごとに 5 コンテナを起動するのは遅すぎる。
そのうち 3 つの制約(1、2、5)はモノリスへ強く押します。制約 3 はマイクロサービス分割が解決すると思うものです。制約 4 も モノリスへ押す — クロスサービス変更の調整がマイクロサービスチームの最大の税。
そこで私たちは尋ねました:運用の複雑さなしにマイクロサービスの コード組織化 の利益(制約 3)を得られるか?
代わりに何を構築したか
答えは 17 クレートを持つ単一 Rust ワークスペース:
server ← HTTP エントリーポイント + ルート登録
rusty-claude-cli ← CLI / 対話シェル
console-server ← 管理 / 課金 / ワークスペース管理
└── domain-data ← 分析、取り込み、CSV/JSON/Parquet
└── domain-iot ← MQTT、デバイス、空間インデックス
└── domain-app ← アプリ生成、コントラクト
└── domain-media ← S3 ストレージ
└── domain-platform ← 認証、RBAC、課金、ジョブ
└── domain-integrations ← 38 コネクター
└── tools ← 全ドメインの tool_specs() を集約
└── runtime → plugins → commands
└── api ← LLM プロバイダークライアント
ルール:ドメインはランタイムでクロス依存ゼロ。 Data は IoT を import しない。IoT は App を import しない。各々が公開するのは:
- ドメインのリソース(PG プール、MQTT クライアントなど)を所有する
{Domain}State構造体 - このドメインがエージェントに提供するツールを返す
tool_specs()関数 - 実際にツールを実行するためランタイムが呼ぶ
execute_tool_async()関数
tools クレートだけが 5 つのドメインすべてを import し、それは 1 つのグローバルツールレジストリに集約するためです。エージェントランタイムは要求されたツールを所有するドメインの execute_tool_async() を呼びます。
これが マイクロサービス風のコード分離 と モノリスデプロイ です。
なぜ Rust か
3 つの理由。
GC ポーズなしのメモリ安全性。 多くの並行 I/O を実行します:エージェントからの WebSocket 接続、MQTT サブスクリプション、LLM プロバイダーへの HTTP リクエスト、Postgres クエリ、S3 ストリーミング。間違った瞬間の世界停止 GC はメッセージをドロップしクエリをタイムアウトさせます。Rust のコンパイル時メモリ安全性 + ゼロコスト async により、プロセスあたり数千の並行接続を実行できます。
1 つのバイナリ、ランタイムなし。 Node や Python と比較して、デプロイのストーリーが劇的にシンプル。「libffi バージョン X がインストールされているか確認」や「Python 3.12 ではなく 3.11 を使用」なし。バイナリは必要なものすべてを静的リンク。Docker イメージは 55 MB。
コンパイラがコードレビューで捕捉するものを捕捉。 これは軽薄に聞こえますが、そうではありません。出荷していたバグだったケースの約 30% がコンパイル時に捕捉されました — 通常は共有状態のライフタイム正確性周り。ペアプログラマーとしてのコンパイラは本物です。
コスト:Rust は Python より書くのが遅く、チームは async Rust パターンに慣れる必要がありました。Tablize は根本的にインフラプロダクトであり、このコードと長年付き合うことになるので、このトレードオフを選びました。
なぜ 1 つの PG インスタンスか
各ドメインは 1 つの Postgres インスタンス内に自分の スキーマ を持ちます(iot.*、data.*、app.* など) — 別のデータベースでも、別のサーバーでもありません。5 スキーマ、1 PG。
理由:
ドメイン横断結合が一般的。 「顧客がアクティブな IoT アラートも持つ注文を見せて」 — それは data.orders と iot.alerts を横断する結合。1 つの PG なら、これは 1 つのクエリ。ドメインごとに別データベースなら、2 つのサービスから取得後にメモリ内結合をすることになります。
1 つのバックアップは 5 つよりずっとシンプル。 セルフホストユーザーは pg_dump してすべて持ちます。マネージドユーザーはワークスペースごとに 1 つの論理バックアップを得ます。
TimescaleDB 拡張はどこでも適用。 iot.sensor_readings と data.event_logs の時系列加速は同じ拡張、同じハイパーテーブル機構、同じ保持ポリシーを使用。
接続プールがシンプル。 1 つのプール、一度サイズされ、全ドメインで共有。
コスト:1 つのドメインで暴走したクエリが他に影響する可能性。ドメインごとのステートメントタイムアウトと接続制限で軽減。問題になったか? 1 回、ユーザーの IoT サブスクリプションが秒間 50K メッセージを受信し始め、insert が接続プールを詰まらせたとき。ドメインごとのプールサイジングを追加し、問題は再発していません。安い修正。
なぜドメイン間にイベントバスがないか
ほとんどのマイクロサービスアーキテクチャは Kafka / NATS / Redis pub-sub をサービス間通信に使います。私たちは使いません。IoT ドメインが分析をトリガーする必要があるとき(例:センサー異常 → データクエリを実行)、エージェントランタイム経由のツール呼び出しでそれを行います。
これは直接イベントバスより遅く聞こえます。そうではありません。エージェントランタイムは同じプロセスだから。ツール呼び出しはマイクロ秒でディスパッチ。ネットワークホップなし、シリアル化なし、ブローカーなし。
欠点:エージェントのツール呼び出しトレース以外には「ドメイン A がドメイン B にイベント X を送った」の監査ログがない。それを受け入れる。エージェントトレース が ドメイン横断ログ。
ドメイン分離が何をもたらすか
実際には、「ドメインは互いに import できない」というルールが私たちが持つ最も価値あるアーキテクチャ制約です。
意味:開発者が IoT ドメインで作業するとき、Data ドメインがどう機能するか知る必要がない。IoT クレートは自分の Cargo.toml、自分のテスト、PG に対する自分の統合テストを持つ。data::orders への依存を誤って取れない。import がコンパイルできないから。
意味:1 つのドメインにコントラクターをオンボードするとき、コードベース全体ではなく 1 つのドメインのコードでウォームアップ。メンタルモデルが頭に入る。
私たちの経験では、強制された分離はデプロイ分離より重要です。コード がよく整理されていればモノリスを実行できる;コードが絡まっていればマイクロサービスをうまく実行できない。私たちは 2 つのうち簡単な方の問題を選びました。
違ったやり方をするとしたら
率直なリスト:
過剰分離したものがある。 早期に domain-media(S3 ストレージ)を独自クレートにしました。振り返ると、S3 ストレージは非常に薄い抽象なので、どのドメインもインポートする util クレートに住めたはずです。本当のドメインではないものにドメインパターンを過剰適用しました。
エージェントランタイムクレートが大きすぎる。 runtime → plugins → commands は 1 つの論理単位ですが、混乱した境界を持つ 8 サブクレートに成長しました。徐々に統合中;進行中。
WebSocket セッション管理が間違った場所にある。 今は server にあります。HTTP 層ではなくエージェントライフサイクルに紐づくので、おそらく runtime にあるべき。リスト上のリファクタリング。
1 つの PG が永遠にスケールするわけではない。 現在のスケール(単一 PG での 1 億行未満のワークスペース)には大丈夫。数十億に成長するワークスペースには、高ボリュームの IoT データを別 TimescaleDB に分割するかシャードする必要があるでしょう。ブレークポイントを観察中。
マイクロサービスをしなかったコスト
マイクロサービスが与えるが私たちが諦めたもの:
独立したデプロイケイデンス。 新しい IoT 機能を出荷するとき、バイナリ全体が再デプロイ。マイクロサービスなら IoT だけデプロイできた。実際には週 2〜3 回デプロイし、全ロールアウトは速い(Fly.io ローリング再起動、2 分未満)ので、これは痛んでいません。
ドメインごとのスケーリング。 マイクロサービスでは、IoT コンシューマが分析層から独立してスケール可能。私たちの世界では、ワークスペースごとにバイナリ全体をスケール。私たちのコストモデル(ワークスペースごとの Fly.io マシン)では、これで結構 — ワークスペースがスケーリング単位、ドメインではない。
言語の柔軟性。 マイクロサービスは IoT コンシューマを Rust で、LLM ルーターを Python で書くことを許す。私たちはオール Rust。私たちには勝利(1 言語、1 ツールチェーン)ですが、強い既存 Python/Go 投資を持つチームには欠点。
ブラスト半径分離。 エージェントランタイムがクラッシュすると、そのワークスペースの Tablize 全部がクラッシュ。マイクロサービスなら、エージェントランタイムコンテナだけがクラッシュ。Rust の広範なパニックハンドリングとワークスペースごとのプロセス(1 ワークスペースのクラッシュが他に影響しない)で軽減。本物のトレードオフ。
あなたが私たちのやったことをすべきとき
すべてのプロダクトが単一 Rust バイナリとして出荷すべきではありません。この形がフィットするシグナル:
- セルフホストを些細にする必要がある。 「この Docker イメージを実行」は「この Kubernetes マニフェストを実行」より劇的にシンプル。
- 当面小規模チーム(20 エンジニア未満)。 マイクロサービスの運用税は、複数チームが複数サービスを所有するまで元を取らない。
- ドメインがほとんど共有永続状態への読み取り中心。 私たちのドメインはすべて同じ PG に読み書き。本当に別状態のドメインなら、分割の方が理にかなう。
- コールドスタートレイテンシを最適化。 ワークスペースごとプロセスごとマシンごとの分離は、ワークスペースごとコンテナごとデプロイごとよりずっと速い。
フィットしないシグナル:
- 独立したリリースケイデンスを持つ独立サービスを所有する複数チーム。 ならマイクロサービスが組織複雑性をスケールする方法。
- ドメインが極端に異なるトラフィックプロファイル。 1 つのドメインが残りより 100 倍トラフィックを処理するなら、それだけを分割するとスケール可能。
- ポリグロットが必要。 サービスごとに異なる言語ならマイクロサービス。
ここから何を取るか
アーキテクチャ選択は永続的ではありません。スケールが要求するなら、いつか Tablize を複数バイナリに分割するかもしれません。現在の形は 今 と次の 12〜24 か月の成長に対する正しい形。ドメインクレートとしてよく分離されたコード構造を保つことで、もし分割が起きるなら、それは書き換えではなくリファクタリングです。
似たプロダクトを今日始めるなら、尋ねることを勧めます:「サービスに分割することで解決している コード組織化 問題は何か、規律あるクレート / パッケージ / モジュールで代わりに解決できないか?」 ほとんどの場合、答えはイエス。マイクロサービスのデプロイ複雑性は、コードがよく組織化されているかどうかとはほぼ直交です。
関連記事:
- Data Agent とは?
- 9 ステップ Verifiable Reasoning Protocol を解説
- Tablize アーキテクチャ概要は llms-full.txt に