はじめに
アプリケーションが成長してくると、こんな状況に遭遇することはないでしょうか。
- 機能を1つ変えただけなのに、アプリケーション全体をビルドし直さないといけない
- 自分とは無関係なバグのせいでデプロイがロールバックされ、自分の作業まで巻き戻る
- 別チームのコードに意図せず影響を与えてしまい、障害が広がる
これがいわゆる「モノリスの成長痛」です。多くの開発チームが通る道だと思います。
この痛みを和らげる手段として「責務の分離」というアーキテクチャ上の判断があります。「マイクロサービスにしましょう」とは簡単に言えますが、分離にはコスト(複雑さ)が伴います。コストを正しく理解せずに分離すると、モノリスより悪い状態に陥ることすらあります。
この記事では、分離の「動機」と「コスト」の両面から3つのパターンを整理します。「分離すべきか否か」を判断するための視点を持ち帰っていただければ幸いです。
TL;DR
モノリスの成長痛を解消する3つの分離パターン。共通する判断基準は「複雑さを引き受けるコストに見合うか?」です。
モノリスの成長痛
│ デプロイが重い / チーム間で干渉 / 障害が波及
│
├─ (1) マイクロサービス
│ 得るもの: チームの独立性、独立デプロイ
│ 代償: 分散システムの複雑さ、結果整合性
│
├─ (2) プレーン分離
│ 得るもの: 可用性と一貫性を独立に最適化
│ 代償: 設計の複雑さ、伝播遅延
│
└─ (3) 非同期メッセージング
得るもの: 負荷の平滑化、障害耐性
代償: レイテンシ増加、バックログのリスク
対象読者
- マイクロサービス、メッセージキューなどの用語は聞いたことがあるが、導入判断の根拠を説明できない方
- アプリケーションの成長に伴うアーキテクチャ上の判断に関心がある方(目安: 1〜3年目)
- HTTPによるリクエスト・レスポンスの通信モデルを知っている方
1. モノリスの成長痛 -- なぜ「分離」が必要になるのか
分離パターンの話に入る前に、まず「なぜ分離が必要になるのか」を確認しておきます。分離は目的ではなく手段です。どんな痛みがあるのかを実感しておくと、後の話がスッと入ってきます。
1.1 モノリスとは何か
モノリスとは、複数のコンポーネント(機能の単位)が1つのアプリケーションとして一体でビルド・デプロイされる構成のことです。
┌─────────────────────────────────────┐
│ モノリシックアプリケーション │
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 認証 │ │ 注文│ │ 在庫 │ │ 通知 │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ └───────┴───────┴───────┘ │
│ 共有データベース │
└─────────────────────────────────────┘
→ 全コンポーネントを一体でビルド・デプロイ
初期段階では、これは合理的な選択です。シンプルで、開発・テスト・デプロイが容易だからです。
1.2 成長に伴って現れる問題
しかし、アプリケーションが成長し、機能やチームが増えていくと、モノリスにはいくつかの痛みが現れ始めます。
コンポーネント間の結合が強まる
時間の経過とともに、コンポーネント間の境界が曖昧になっていきます。最初はきれいに分かれていたはずが、気づけば別のコンポーネントの内部実装に依存している、ということが起きます。
開発者同士の干渉
同じコードベースで多くの開発者が作業するため、互いの作業領域に干渉してしまうことが頻繁に起こるようになります。
コードベースの肥大化
やがて、コードベースは誰もその全容を完全には理解できないほど複雑になります。新しい機能の実装やバグの修正には、以前よりもはるかに時間がかかるようになります。
デプロイの重さ
1つのコンポーネントを変更しただけで、アプリケーション全体を再ビルドし、再デプロイしなければならないことがあります。
障害の波及
新バージョンのデプロイでメモリリークやソケットリークといったバグが混入した場合、本来は無関係なはずの他のコンポーネントまで影響を受けてしまう可能性があります。
ロールバックの影響範囲
デプロイを差し戻す判断は、バグを混入させた開発者だけでなく、すべての開発者の作業スピードに影響します。
1.3 分離という選択肢
こうした成長痛を軽減する方法の1つが、アプリケーションを「機能単位」で分解することです。
モノリスのまま内部をコンポーネント化するという選択肢もあります。次のセクション以降で、「何を得て、何を引き受けるのか」を具体的に見ていきます。
ここで強調しておきたいのは、モノリスが「悪い」構成なわけではないということです。一定の規模を超えたときに痛みが出るだけであり、小〜中規模のアプリケーションでは十分に合理的な選択です。
2. マイクロサービス -- 機能単位でサービスを分割する
Section 1で見た成長痛の多くは、「1つのアプリケーションに全てが詰まっている」ことに起因しています。では、機能ごとに分割したらどうなるでしょうか。
2.1 サービス分割の基本的な考え方
マイクロサービスとは、アプリケーションをAPIを通じて通信する、独立してデプロイ可能なサービスの集合に再構成するアーキテクチャのことです。
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ 認証 │ │ 注文 │ │ 在庫 │ │ 通知 │
│サービス│ │サービス│ │サービス│ │サービス│
└──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘
│ API │ API │ API │
└───────────┴───────────┴───────────┘
→ 各サービスが独立してビルド・デプロイ可能
→ APIが「容易には侵害できない境界線」を引く
ポイントは、APIがサービス間に「容易には侵害できない境界線」を引いてくれることです。同じプロセス内で動作するコンポーネント間の曖昧な境界線とは根本的に異なります。プロセス内だと、ついインポートして内部の関数を呼んでしまう、ということが起きますが、APIを跨ぐ場合は公開されたインターフェースしか使えません。
2.2 分割によって得られるもの
サービスを分割すると、いくつかの恩恵が得られます。
小規模チームによる所有と運用
各サービスは小規模なチームによって所有・運用されます。チームの規模が大きくなると、コミュニケーションのオーバーヘッドは急激に増加します(ブルックスの法則)。小規模なチームの方がより効果的に連携できます。
独立したリリーススケジュール
各チームが独自のコードベースを管理し、独自のリリーススケジュールを決定できます。チーム間のコミュニケーションは全体として少なくて済みます。
理解しやすさ
1つのサービスの責任範囲はアプリケーション全体に比べて小さくなるため、特に新しくチームに加わったメンバーにとって理解しやすくなります。
技術スタックの自由
原則として、各チームは自分たちのニーズに適した技術スタックを自由に採用できます。APIの利用者は、その機能がどのように実装されているかを気にする必要がないからです。新しい技術の実験や評価も容易になります。
独立したデータモデルとデータストア
各サービスがそれぞれのユースケースに最適化された、独立したデータモデルとデータストアを持つことができます。
2.3 「マイクロ」の誤解
「マイクロサービス」という名前から、サービスは極小であるべきだと思うかもしれません。しかし、実際にはサービスが小さすぎると、運用上のオーバーヘッドと複雑さが増すだけになってしまいます。
経験則として、APIの表面積(エンドポイントの数など)は小さく保ちつつ、その背後で「意味のあるまとまった機能」をカプセル化するのがよいとされています。
2.4 分割のコスト -- 7つの注意点
ここがこのセクションの核心です。「分割すればOK」ではないことを、7つの観点から見ていきます。
これらを「怖い話」として読むのではなく、「このコストを組織として引き受けられるか」という判断基準として捉えてもらえればと思います。
2.4.1 技術スタックの標準化
各サービスが異なる技術スタックを使えるのはメリットですが、自由にしすぎると別の問題が生まれます。開発者がチーム間を移動することが困難になったり、ロギングのようにすべてのサービスが必要とする共通機能を言語ごとにサポートしなければならなくなったりします。
ある程度の標準化は合理的です。推奨される言語や技術に対して優れた開発体験(SDKやツールなど)を提供し、その使用を緩やかに促すアプローチが現実的です。
2.4.2 リモート通信の複雑さ
リモート呼び出しはコストが高く、ネットワーク越しの通信は成功するか失敗するか、いつ応答が返るかが予測できません(この性質を「非決定性」と呼びます)。
モノリスでも外部のリクエストに応答したり、サードパーティのAPIに依存したりするため、こうした問題は完全に無縁ではありません。ただし、マイクロサービスではその規模が格段に大きくなります。
2.4.3 結合の罠 -- 分散モノリス
マイクロサービスは疎結合であるべきです。1つのサービスを変更しても、他のサービスを変更する必要がないのが理想です。
もしそうなっていない場合、「分散モノリス」と呼ばれる最悪の状態に陥る可能性があります。これは、モノリスが持つ欠点をすべて抱えながら、分散システム特有の複雑さによって難易度が桁違いに上がった状態です。
分散モノリスに陥る原因は多岐にわたります。
- 変更のたびにクライアントの更新を強いる脆弱なAPI
- 複数のサービスで一斉に更新しなければならない共有ライブラリ
- 外部サービスを静的なIPアドレスで参照しているケース
分散モノリスはサービス分割における最大の落とし穴です。以下の兆候が見られたら注意してください: 変更のたびに複数サービスの同時デプロイが必要になる、共有ライブラリの更新が全サービスに波及する。
2.4.4 リソースのプロビジョニング
多数の独立したサービスをサポートするためには、リソースの調達(プロビジョニング)を自動化する仕組みが必要です。
- 新しいマシンやデータストアを、各チームが自分で簡単に用意できること
- リソースの構成(設定)もコードで管理し、手作業を排除すること
これらが整備されていないと、チームごとにバラバラなやり方でリソースを用意する混乱が生じます。
2.4.5 テストの難しさ
個々のマイクロサービスをテストすること自体は、モノリスのテストより必ずしも難しくはありません。しかし、サービス間の統合テストははるかに困難です。サービスが本番環境で大規模に相互作用して初めて明らかになる、非常に繊細で予期せぬ挙動があるためです。
2.4.6 運用の負荷
新しいビルドを安全に本番環境へデリバリーし、デプロイするための共通の仕組みが確立されている必要があります。各チームが同じ仕組みを再発明しなくて済むようにするためです。
さらに、障害のデバッグやパフォーマンスの低下、バグの追跡も格段に難しくなります。アプリケーション全体をローカルマシンにロードして、デバッガーで一行ずつステップ実行する、といったことはできないからです。そのため、優れたオブザーバビリティ(可観測性)プラットフォームを持つことが極めて重要になります。
オブザーバビリティとは、システムの外部出力(ログ、メトリクス、トレースなど)からシステム内部の状態を推測できる度合いのことです。マイクロサービスでは、リクエストが複数のサービスを跨ぐため、各サービスを横断的に追跡できる仕組みが欠かせません。
2.4.7 結果整合性の受け入れ
アプリケーションを別々のサービスに分割した副作用として、データモデルは単一のデータストアには収まらなくなります。データが複数のデータストアに分散すると、それらをアトミック(全部成功か全部取り消しか)に更新するのが難しくなります。「強い整合性」を保証しようとすると、動作が遅く、コストがかかり、正しく実装するのも困難です。
そのため、マイクロサービスアーキテクチャでは「結果整合性」(すぐには一致しないが、いずれ一致する)を受け入れることが通常求められます。
2.5 実践的な指針 -- モノリスから始める
7つの注意点を見てきて、「こんなに大変なら分割しない方がいいのでは」と感じた方もいるかもしれません。実際、基本的にはモノリスから始め、明確な理由がある場合にのみ分解するのが最善の手法とされています。
モノリスのままでも内部をコンポーネント化することは可能です。モジュール間を定義されたインターフェースで通信させる「モジュラーモノリス」と呼ばれるアプローチです。モノリスであれば、アプリケーションの成長に合わせてモジュール間の境界線を動かすことも容易です。
モジュラーモノリス
┌────────────────────────────────────────┐
│ 1つのアプリケーション │
│ │
│ ┌─────────┐ 定義された┌────────┐ │
│ │モジュール │ ←─ IF ─→ │モジュール│ │
│ │ A │ │ B │ │
│ └─────────┘ └────────┘ │
│ ↑ ↑ │
│ │ 境界線は後から動かせる │ │
│ └────────────────────┘ │
└────────────────────────────────────────┘
IF = インターフェース
2026年現在、マイクロサービスに一度分割したものの、一部を統合に戻す動きも見られます。「モジュラーモノリス + 必要に応じて少数のサービスを切り出す」というのが、現実的な選択肢として注目されています。
モノリスが十分に成熟し、成長痛が実際に生じた段階で、1つずつマイクロサービスを切り出していく。これが堅実なアプローチです。
3. APIゲートウェイ -- 内部サービスへの「窓口」を作る
Section 2でアプリケーションをサービスに分割しました。次に考えるべきは、外部のクライアントがこれらのサービスにどうアクセスするかです。
3.1 分割後に生まれるクライアント側の課題
サービスを分割した後、クライアント側にはいくつかの課題が生まれます。
クライアント
┌──────────────┐
│ モバイルアプリ │
└──┬─────────┬─┘
┌────────────┤ ├─────────────────┐
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│認証サービス│ │注文サービス│ │在庫サービス│ │通知サービス│
└──────────┘ └──────────┘ └──────────┘ └──────────┘
問題:
- 1つの操作のために複数サービスに何度もリクエストが必要
- クライアントが内部サービスのDNS名を知っている必要がある
- 内部アーキテクチャを変えるとクライアント側も修正が必要
3.2 APIゲートウェイとは何か
APIゲートウェイとは、内部APIの前に立つ「窓口」(ファサード/リバースプロキシ)として機能するサービスのことです。コンピュータサイエンスの世界でよく言われる「ほとんどの問題は間接化のレイヤーを1つ追加することで解決できる」という考え方の実践です。
クライアント
┌──────────────┐
│ モバイルアプリ │
└─────┬────────┘
│ パブリックAPI(1箇所だけ知っていればOK)
┌─────▼────┐
│ API │
│ゲートウェイ│
└┬───┬───┬─┘
┌──────────┘ │ └──────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│認証サービス│ │注文サービス│ │在庫サービス│
└──────────┘ └──────────┘ └──────────┘
→ 内部の実装詳細はクライアントから隠蔽される
内部のAPIを、パブリックなAPIの背後に隠すことで、クライアントはゲートウェイの存在だけを知っていればよくなります。
3.3 中心的な3つの責任
APIゲートウェイが担う代表的な責任を3つ見ていきます。
3.3.1 ルーティング
最も基本的な機能は、外部からのリクエストを内部の適切なサービスに転送することです。パブリックAPIが内部APIにどのように対応するかを定義した「ルーティングマップ」を使います。
このマッピングのおかげで、内部APIが変わっても、外部クライアントは同じパブリックエンドポイントを使い続けられます。内部の構造変更がクライアントに波及しなくなるわけです。
3.3.2 コンポジション(合成)
モノリスではデータは通常1つのデータストアにありますが、マイクロサービスではデータが複数のサービスにまたがります。そのため、複数のサービスからデータを繋ぎ合わせる必要が出てきます。
APIゲートウェイは、複数のサービスに問い合わせを行い、レスポンスを1つにまとめる「上位のAPI」を提供できます。クライアントのリクエスト回数を減らせるメリットがあります。
ただし、注意点もあります。内部呼び出しの数が増えるほど、それぞれの呼び出しが失敗する確率があるため、合成されたAPIの可用性は低下します。また、更新がすべてのサービスに伝播していない場合、データに一時的な矛盾が生じる可能性があります。
3.3.3 変換(トランスレーション)
APIゲートウェイは、通信プロトコルの変換も行えます。例えば、外部からのRESTful HTTPリクエストを、内部的なgRPC(高効率なバイナリ通信プロトコル)呼び出しに変換するといった具合です。
また、異なるクライアントに対して異なるAPIを公開することも可能です。デスクトップアプリ用のAPIはモバイルアプリ用よりも多くのデータを返すように設定できます。デスクトップは画面が広く、一度に多くの情報を表示できるからです。逆にモバイルでは、バッテリー消費を抑えるためにリクエストを一括化したいことが多いです。
こうした多様な要件を満たすために、グラフベースのAPIというアプローチも広く使われています。クライアントがスキーマ(データの構造定義)に基づいて「自分が必要なデータを正確に宣言」し、ゲートウェイがそれを内部呼び出しに変換する仕組みです。ユースケースごとに個別のAPIを作る必要がなくなります。
グラフベースAPIの代表的な技術としてGraphQLがあります。ただし、API技術の選択肢はGraphQLに限りません。TypeScript環境での型安全なAPI(tRPCなど)やgRPCなど、ユースケースに応じた選定が重要です。ここでは「クライアントが欲しいデータを宣言的に指定する」というパターンの考え方を理解しておくことが目的です。
3.4 横断的関心事の集約
APIゲートウェイはリバースプロキシであるため、本来なら各サービスに個別に実装しなければならない共通機能を、1箇所に集約できます。ここからはその代表例を見ていきます。
3.4.1 キャッシュとレート制限
頻繁にアクセスされるリソースのキャッシュや、内部サービスがリクエスト過多でパンクしないようにレート制限(一定時間あたりのリクエスト数を制限する仕組み)をかけることが可能です。
3.4.2 認証と認可
中でも最も重要な横断的関心事が、認証と認可です。
- 認証(Authentication): リクエストを発行している主体が本人であることを確認するプロセス
- 認可(Authorization): 認証された主体に対して、特定の操作を実行する権限を付与するプロセス
モノリスでの認証: セッション方式
モノリスでは、認証に「セッション」を用いるのが一般的です。
HTTPはステートレス(状態を持たない)なプロトコルなので、リクエスト同士を紐付けるには何かしらの仕組みが必要です。セッション方式では、アプリケーションが「セッションID」を含むセッションオブジェクトを作成し、サーバー側に保存します。このセッションIDはHTTPクッキーとしてクライアントに返され、以降のリクエストに含められます。
マイクロサービスでの認証: ゲートウェイ + セキュリティトークン
マイクロサービスでは、リクエストの処理が複数のサービスにまたがる可能性があるため、「誰が認証に責任を持つか」が明確ではなくなります。
1つのアプローチは、外部リクエストの入り口であるAPIゲートウェイで認証を行い、セキュリティトークンを作成して内部サービスに渡す方法です。内部サービスはさらに下流の依存サービスへとトークンを引き継ぎます。
一方、認可はドメインロジック(業務知識)と密結合するため、APIゲートウェイではなく個々のサービスに任せるのが適しています。「認証はゲートウェイで一元化、認可は各サービスで判断」という役割分担です。
セキュリティトークンの種類
内部サービスがトークンを受け取ったとき、そのトークンを検証して主体の身元とロールを取得する方法は、トークンの種類によって異なります。
| 種類 | 特徴 | メリット | デメリット |
|---|---|---|---|
| 不透明トークン | 中身に情報を持たない | 漏洩時に失効(無効化)が容易 | 検証に外部認証サービスへの呼び出しが必要 |
| 透明トークン(JWT) | 主体の情報をトークン自体に埋め込む | 外部呼び出し不要で検証可能 | 漏洩時の失効が困難 |
| APIキー | カスタムのキーで主体を識別 | シンプルで導入しやすい | 権限管理が粗くなりがち |
JWT(JSON Web Token)は、有効期限・主体の識別情報・ロール・その他のメタデータを含むJSON形式のペイロードです。信頼された証明書で署名されているため、トークンの検証に外部サービスへの問い合わせは不要です。
APIキーは、パブリックAPIで広く採用されているシンプルな方式で、GitHubやTwitterなどのサービスで使われています。
JWTは便利ですが、設定ミスによる脆弱性(アルゴリズム混同攻撃など)も知られています。より安全な代替技術も登場しているため、正しい実装が重要です。認証・認可は奥が深いトピックであり、本格的に取り組む場合は専門書の参照をおすすめします。
3.5 APIゲートウェイの注意点
APIゲートウェイにも注意点があります。
開発のボトルネック化: ゲートウェイは内部サービスのAPIと密結合しているため、内部APIが変更されるたびにゲートウェイも修正が必要になります。
運用コスト: メンテナンスが必要なサービスが1つ増えます。また、背後にあるすべてのサービスへのリクエストレートの合計に耐えられるよう、スケールさせる必要があります。
実装の選択肢: NGINXのようなリバースプロキシを出発点として自作することもできますし、クラウドプロバイダーが提供するマネージドサービスを利用することもできます。
とはいえ、アプリケーションが多くのサービスやAPIを抱えている場合、メリットがデメリットを上回ることが多く、一般的には投資する価値のある仕組みです。
4. コントロールプレーンとデータプレーン -- 非機能要件で役割を分ける
Section 3でAPIゲートウェイを導入しました。しかし、このゲートウェイには1つ大きな問題があります。すべての外部リクエストがここを通過するため、ゲートウェイがダウンするとサービス全体が停止する「単一障害点」になり得るのです。
さらに、もう少し踏み込んで考えると、APIゲートウェイの中には「性質の異なる2つの仕事」が混在しています。この不自然さが、次の分離パターンのきっかけになります。
4.1 相反する要件の発見
APIゲートウェイの仕事を整理してみましょう。
┌──────────────────────────────────────────────────────┐
│ APIゲートウェイ │
│ │
│ 仕事A: 外部リクエストのルーティング │
│ → リクエスト数が膨大 │
│ → 高い可用性・高いパフォーマンスが必要 │
│ → 一貫性は多少犠牲にできる │
│ │
│ 仕事B: 設定やAPIキーの管理 │
│ → リクエスト数は少ない │
│ → 一貫性が重要(設定の不整合は困る) │
│ → 可用性は多少犠牲にできる │
└──────────────────────────────────────────────────────┘
仕事Aと仕事Bは、求められる性質が正反対
1つのサービスにこの相反する要件を詰め込むのは無理があります。そこで、サービスを2つの「プレーン」に分離するパターンが登場します。
4.2 2つのプレーンの定義
ここではAPIゲートウェイを例にしていますが、この分離パターンはそれに限らず広く適用されるものです。
- データプレーン: クライアントのリクエストごとに実行される、クリティカルパス(ユーザーが直接待つ処理経路)上のすべての機能。高い可用性、高速な動作、リクエスト数に応じたスケールが求められます
- コントロールプレーン: クリティカルパス上にはない仕事。メタデータや設定の管理、複雑で頻度の低い操作の調整が主な役割です。可用性よりも一貫性を重視します
┌───────────────────┐ ┌────────────────────────┐
│ コントロールプレーン │ │ データプレーン │
│ │ 設定 │ │
│ 設定/メタデータ │ ─────→ │ リクエストの処理 │
│ の管理 │ │ (ルーティングなど) │
│ │ │ │
│ 一貫性重視 │ │ 可用性・パフォーマンス重視│
│ スケール要件: 低 │ │ スケール要件: 高 │
└───────────────────┘ └────────────────────────┘
この分離パターンはAPIゲートウェイだけのものではありません。例えば、レプリケーション(データの複製)でチェインの構成情報を管理するコントロールプレーン、ストレージシステムでパーティションの割り当てを管理するマネージャー群など、分散システムの様々な場面で使われている非常に一般的なパターンです。
4.3 ハードな依存関係と可用性の計算
2つのプレーンに分離した後、重要なのは「コントロールプレーンが止まったとき、データプレーンはどうなるか」です。
もし、コントロールプレーンが利用できなくなったときにデータプレーンもリクエストの処理を止めてしまうなら、それはデータプレーンがコントロールプレーンに対して「ハードな依存関係」を持っている状態です。
ハードな依存関係がある場合、システム全体の理論的な可用性は、各コンポーネントの可用性の積になります。
例えば、データプレーンの可用性が 99.99% でも、コントロールプレーンの可用性が 99% であれば、システム全体は 98.99% にまで低下します。
$$0.9999 \times 0.99 = 0.9899$$
つまり、システムの可用性は、最も可用性が低いハードな依存先のレベルを超えられません。せっかくデータプレーンを高可用に設計しても、コントロールプレーンがボトルネックになってしまうのです。
4.4 静的安定性 -- 裏方が止まっても表側を動かし続ける
では、どうすればよいでしょうか。コントロールプレーン自体の信頼性を高める努力も必要ですが、それ以上に重要なのは、データプレーンがコントロールプレーンの故障に耐えられるように設計することです。
コントロールプレーンが一時的に利用不能になったとしても、データプレーンは停止するのではなく、「古い設定」のままでも動作を継続すべきです。この性質を**静的安定性(static stability)**と呼びます。
通常時:
コントロールプレーン ──最新設定──→ データプレーン → リクエスト処理
コントロールプレーン障害時:
コントロールプレーン ✗(停止)
データプレーン → 古い設定でリクエスト処理を継続
(止まるのではなく、少し古い情報で動き続ける)
最新の設定が反映されないのは理想的ではありませんが、完全に止まるよりはずっとましです。これが静的安定性の考え方です。
4.5 スケールの不均衡への対処
ここからは、コントロールプレーンとデータプレーンの「スケール要件の違い」から生まれる実践的な課題の話です。
データプレーンは大量のリクエストを処理するためにスケールアウト(インスタンス数を増やすこと)しますが、コントロールプレーンは小規模で済みます。この不均衡が問題を引き起こすことがあります。
例えば、何らかの理由でデータプレーンを構成するプロセスが一斉に再起動され、すべてのプロセスが同時にコントロールプレーンから設定を取得しようとしたら、コントロールプレーンは過負荷に陥ります。
この問題に対して、いくつかのアプローチがあります。
4.5.1 中間ストアをバッファとして使う方法
コントロールプレーンが状態を定期的にファイルストア(S3のようなスケーラブルなストレージ)に書き出し、データプレーンはそこから読み取る方法です。
┌─────────────────┐ 定期書き出し ┌───────────┐ 定期読み取り ┌──────────────────┐
│コントロールプレーン│ ──────────→ │ファイルストア│ ←────────── │ データプレーン │
│ │ │ (S3等) │ │ (多数のインスタンス)│
└─────────────────┘ └───────────┘ └──────────────────┘
コントロールプレーンとデータプレーンが疎結合になり、コントロールプレーンがダウンしていてもデータプレーンは動作や起動が可能です。ただし、変更が反映されるまでの伝播遅延は大きくなります。
4.5.2 コントロールプレーンからのプッシュ方式
設定が変更された瞬間に、コントロールプレーン側からデータプレーンに「プッシュ」する方法です。データプレーンからの定期的な問い合わせを待つ必要がないため、伝播遅延を短縮できます。
送信ペースをコントロールプレーンが制御できるため、処理が追いつかない場合はスローダウンで対応できます。さらに、変更にバージョンを付けて「差分だけ」をプッシュすれば、負荷も伝播遅延もさらに抑えられます。
ただし、大量のデータプレーンインスタンスが一斉に起動した場合、初回の全設定読み取りがコントロールプレーンに集中する問題は残ります。
4.5.3 ハイブリッド方式
実践的には、上記2つのアプローチを組み合わせるのが効果的です。
- 起動時: データプレーンは中間ストアからスナップショット(ある時点の完全なコピー)を読み取る
- 通常時: コントロールプレーンから差分だけをプッシュで受け取る
起動時の大量読み取りをファイルストアが吸収し、通常時は差分プッシュで低遅延を実現する。両方の長所を組み合わせた構成です。
4.6 制御理論の視点 -- フィードバックループを閉じる
プレーン分離を設計するとき、「設定を渡して終わり」にしてしまうと、設定が正しく反映されたかを知る手段がありません。ここでは、分離をより堅牢にするための考え方を紹介します。
制御理論(Control Theory)は、動的なシステムを監視し、「現在の状態」と「理想の状態」を比較して、システムを理想に近づけるための是正処置を行う「コントローラー」を設計する分野です。
この視点をプレーン分離に当てはめると、こうなります。
- データプレーン = 理想の状態に導きたい動的システム
- コントロールプレーン = データプレーンを監視し、理想と比較して、必要に応じて是正処置を実行するコントローラー
┌─────────────────────────────────────────────┐
│ フィードバックループ │
│ │
│ ┌────────────┐ 監視 ┌──────────┐ │
│ │データプレーン│ ─────→ │コントロール│ │
│ │ │ │プレーン │ │
│ │ │ ←───── │ │ │
│ └────────────┘ 処置 └───┬──────┘ │
│ │ │
│ 比較(現在 vs 理想) │
└─────────────────────────────────────────────┘
3つの要素: 監視(Monitor)・比較(Compare)・処置(Action)
→ 最も欠けやすいのは「監視」
フィードバックループは「監視」「比較」「処置」の3要素がすべて揃って初めて閉じます。多くの場合、最も欠けているのは「監視」です。
例えば、CI/CD(継続的インテグレーション/継続的デリバリー)のパイプラインもコントロールプレーンの一種と考えられます。新しいビルドを機械的にデプロイするだけでは、起動時の例外でサービスが立ち上がらない、といった失敗を見逃す可能性があります。理想的には、段階的にリリースしながらサービスの状況を監視し、異常があればロールバックを実行する。こうしてループが閉じます。
プレーン分離の設計をするとき、「このループを閉じるために何が足りないか?」と自問してみてください。設定をデータプレーンに渡すだけでなく、それが実際に適用されたかを監視し、問題があれば是正する。この視点を持つだけで、設計の堅牢さが一段上がります。
5. メッセージング -- サービス間通信を非同期にする
ここまで、サービスの分割(Section 2)と内部構造の分離(Section 4)を学んできました。しかし、サービス間の通信がすべて同期的なリクエスト・レスポンスのままだと、呼び出し先の障害やスローダウンが呼び出し元に直接波及してしまいます。ここでは、もう1つの分離パターンとして「非同期メッセージング」を見ていきます。
5.1 同期通信の限界
具体的な例で考えてみましょう。ユーザーが動画をアップロードし、テレビ・スマートフォン・タブレットなど異なるデバイスに最適化された形式でエンコードする機能があるとします。
APIゲートウェイはクライアントから動画を受け取り、ファイルストアにアップロードした後、エンコーディングサービスに処理を依頼します。しかし、エンコード処理は数分かかることもあります。ゲートウェイがレスポンスを待ち続けるのは非現実的です。
かといって、単純な「投げ放し(fire-and-forget)」では、エンコーディングサービスがクラッシュした場合にリクエストが失われてしまいます。
5.2 メッセージチャネルの基本
この問題を解決するのがメッセージチャネルです。メッセージチャネルとは、プロデューサー(メッセージの送り手)がチャネルにメッセージを書き込み、コンシューマー(メッセージの受け手)がそこから読み取る間接通信の仕組みです。チャネルは受信者にとっての一時的なバッファとして機能します。
これまでの同期的なリクエスト・レスポンスとは異なり、メッセージングは本質的に非同期です。メッセージを送信する際に、受信側のサービスがオンラインである必要がないのが大きな特徴です。
メッセージ自体は「ヘッダー」と「ボディ」で構成されます。ヘッダーには一意のメッセージIDなどのメタデータが含まれ、ボディには実際のコンテンツが含まれます。
メッセージの種類は大きく2つあります。
- コマンド: 受信側に特定の操作の実行を指示するもの(例: 「この動画をエンコードしてください」)
- イベント: 送信側に興味深い事象が発生したことを通知するもの(例: 「注文が確定しました」)
5.3 疎結合がもたらす4つの利点
プロデューサーとコンシューマーをチャネルで切り離すことには、多くの利点があります。
可用性の向上: コンシューマーが一時的に利用不能であっても、プロデューサーはチャネルにメッセージを書き込み続けられます。コンシューマーが復旧すれば、溜まったメッセージを処理できます。
スケーラビリティ: リクエストをコンシューマーインスタンスのプール全体でロードバランスできるため、コンシューマー側のスケールアウトが容易になります。
負荷の平滑化: コンシューマーは自分のペースでチャネルから読み取れるため、チャネルが負荷のスパイク(突発的な増加)を吸収し、システムが過負荷になるのを防ぎます。
バッチ処理: 複数のメッセージを一括で処理できます。個々のメッセージの処理遅延は悪化しますが、全体のスループットを劇的に向上させます。追加のレイテンシを許容できるなら、非常に有効な手法です。
5.4 チャネルの配信方式
メッセージチャネルは、メッセージをどのように配信するかによって2つに分類されます。
ポイント・ツー・ポイント:
プロデューサー → [チャネル] → コンシューマーA(1つだけに届く)
コンシューマーB(受け取らない)
パブリッシュ・サブスクライブ:
プロデューサー → [チャネル] → コンシューマーA(コピーを受け取る)
コンシューマーB(コピーを受け取る)
- ポイント・ツー・ポイント: メッセージは正確に1つのコンシューマーインスタンスに配信されます
- パブリッシュ・サブスクライブ: 各コンシューマーインスタンスがメッセージのコピーを受け取ります
5.5 3つの通信スタイル
メッセージングには、配信方式を組み合わせた3つの代表的な通信スタイルがあります。
5.5.1 ワンウェイ・メッセージング
プロデューサーがポイント・ツー・ポイントのチャネルにメッセージを書き込み、コンシューマーがいずれそれを読み取って処理するスタイルです。動画エンコードの例がまさにこのスタイルです。レスポンスを待たず、「送りっぱなし」ですが、チャネルがバッファとして機能するため、fire-and-forgetとは違いメッセージが失われにくくなっています。
5.5.2 リクエスト・レスポンス・メッセージング
同期的なリクエスト・レスポンスに似ていますが、リクエストとレスポンスのメッセージがチャネルを経由します。
- コンシューマーは専用の「リクエストチャネル」からメッセージを読み取ります
- 各プロデューサーは専用の「レスポンスチャネル」を持ちます
- プロデューサーはリクエストにリクエストIDとレスポンスチャネルへの参照を付けて送信します
- コンシューマーは処理後、プロデューサーのレスポンスチャネルに元のリクエストIDを付けてリプライします
5.5.3 ブロードキャスト・メッセージング
プロデューサーがパブリッシュ・サブスクライブ型のチャネルにメッセージを書き込み、すべてのコンシューマーにブロードキャストするスタイルです。特定のイベントが発生したことをプロセスのグループに通知するために使われます。
5.6 メッセージングの保証とトレードオフ
メッセージチャネルを導入すると決めたら、次は「どのメッセージブローカーを使うか」を選ぶ必要があります。ブローカーごとに提供する保証が異なるため、自分たちのユースケースに合ったものを選ぶことが重要です。
メッセージチャネルは、AWS SQSやKafkaのようなメッセージブローカー(メッセージをバッファリングし、プロデューサーとコンシューマーを仲介するサービス)によって実装されます。各ブローカーは、その設計におけるトレードオフに応じて、異なる保証を提供します。
5.6.1 順序保証の難しさ
チャネルはメッセージの挿入順序を保つべきだと直感的には思えます。しかし、メッセージブローカー自体も水平スケールが必要な分散システムです。複数のノードが関与する場合、順序を保証するには「調整(コーディネーション)」が必要となり、これは非常に困難な課題です。
一部のブローカー(Kafkaなど)は、チャネルを「パーティション」と呼ばれるサブチャネルに分割し、パーティション内では順序を保証する方式を採用しています。ただし、同一コンシューマーグループ内では、1つのパーティションから読み取れるコンシューマーは1つに制限されます。コンシューマーグループとは、同じ処理を分担するコンシューマーのまとまりのことです。
また、パーティショニングには固有の課題も伴います。特定のパーティションが高負荷(ホット)になってコンシューマーが追いつけなくなることがあり、パーティションの追加やリバランスの際にはパフォーマンスが一時的に低下する可能性があります。
順序保証をしない方がブローカーの実装がはるかに単純になる。これもトレードオフの1つです。
5.6.2 その他の保証項目
ブローカーを選定する際には、順序保証以外にも以下のような観点を検討することになります。
- 配信保証: at-most-once(最大1回: メッセージが失われる可能性がある)と at-least-once(最低1回: 重複する可能性がある)。アプリケーションの要件に応じて選択します
- メッセージの耐久性: ブローカーがクラッシュしてもメッセージが失われないかどうか
- レイテンシ: メッセージの書き込みから配信までの遅延
- サポートされるメッセージング標準(AMQPなど)
- 競合コンシューマーのサポート: 複数のコンシューマーが同じチャネルから読み取れるか
- ブローカーの制限: 最大メッセージサイズなど
5.7 正確に一度の処理
ここからは、メッセージの処理に関する重要な問題の話です。
コンシューマーは処理が終わったらチャネルからメッセージを削除しますが、このタイミングにはジレンマがあります。
- 処理前に削除: 削除直後にクラッシュすると、メッセージが永遠に失われる
- 処理後に削除: 処理完了後、削除前にクラッシュすると、同じメッセージが再び読み取られ二重処理になる
このため、「正確に一度(exactly-once)」のメッセージ配信は実現できません。
対策は、メッセージの処理を 冪等(べきとう: idempotent) にすることです。冪等とは「同じ操作を何回実行しても結果が変わらない」性質のことです。処理を冪等にした上で、処理完了後にのみ削除することで、「正確に一度の処理」をシミュレートします。
冪等の身近な例として、「ある口座の残高を1000円にセットする」は冪等(何回やっても1000円)ですが、「残高に1000円を加算する」は冪等ではありません(2回実行すると2000円加算されてしまいます)。メッセージ処理を冪等に設計しておくことで、重複配信されても安全になります。
5.8 失敗への対処
5.8.1 繰り返し失敗するメッセージの問題
特定のメッセージが何度処理しても一貫して失敗し続ける場合、同じメッセージが永久にリトライされてしまいます。
これを防ぐために、同じメッセージをチャネルから読み取れる最大回数を設定します。最大回数に達したメッセージは、処理せずに削除するのではなく(それはデータ損失になるため)、デッドレターチャネルに退避します。
デッドレターチャネルとは、リトライ回数が上限を超えたメッセージを一時的に貯めておくための専用チャネルです。メインチャネルが汚染されてコンシューマーのリソースが浪費されるのを防ぎつつ、メッセージを完全に失うことも避けます。
人間がこれらのメッセージを検査して失敗の原因をデバッグし、根本原因が修正されたら再びメインチャネルに戻して再処理させることができます。
メインチャネル
│
│ メッセージ処理
▼
コンシューマー ──失敗→ リトライ(最大N回まで)
│ │
│ │ N回失敗
│ ▼
│ デッドレターチャネル
│ (人間がデバッグ → 修正後に再投入)
▼
処理成功 → メッセージ削除
5.8.2 障害の隔離
繰り返し処理に失敗する「毒入りの」メッセージを出し続ける特定のプロデューサーがいると、コンシューマー全体のパフォーマンスが低下し、後述するバックログの原因になります。
メッセージに、それを生成したソースを識別するIDが付いていれば、特定のソースからのメッセージだけを低優先度のチャネルに振り分けることができます。コンシューマーは低優先度チャネルからも読み取りますが、メインチャネルより頻度を落とします。
こうすることで、メインチャネルの処理を守りつつ、問題のあるソースも完全には切り捨てないという、バランスの取れた対処が可能になります。
5.9 バックログの監視と対処
メッセージの「到着率」が「削除率(処理完了率)」を上回ると、チャネルに未処理メッセージが溜まっていきます。これがバックログです。
メッセージングチャネルは、システムに「バイモーダル(二峰性)」な挙動をもたらします。
- 正常モード: バックログがなく、すべてが期待通りに動作
- 縮退モード: バックログが蓄積し、処理遅延が増大。蓄積時間が長いほど解消に必要なリソースも膨大に
バックログが発生する主な原因は以下の通りです。
- プロデューサーの増加やスループット向上で、コンシューマーが到着率に追いつけなくなる
- コンシューマーのパフォーマンスが低下し、メッセージ処理に時間がかかるようになる
- 繰り返し失敗するメッセージがデッドレターチャネルに送られるまで何度もリトライされ、コンシューマーのリソースが浪費される
バックログの検出には、メッセージがチャネルに書き込まれてから最初に読み取られるまでの「平均待ち時間」を測定するのが効果的です。書き込みタイムスタンプと読み取りタイムスタンプの差を見ることで、バックログの兆候を早期に捉えられます。完全に正確な値ではありませんが(異なるサーバーの物理クロックには多少のずれがあるため)、警戒サインとしては十分に有効です。
6. まとめ -- 分離の判断基準を手に入れる
6.1 3つの分離パターンに共通する判断軸
3つのパターンを振り返ると、共通する判断軸が見えてきます。それは「今の痛みの深刻さ」と「分離によって引き受ける複雑さ」の比較です。
- 痛みが局所的なら: モノリス内部のコンポーネント化(モジュラーモノリス)で対処できることが多い
- 痛みが組織的・運用的に広がっているなら: 分離パターンの導入を検討する価値がある
- 複数の痛みが重なっているなら: パターンを組み合わせる必要があるが、一度に全てを導入するのではなく、最も深刻な痛みから順に対処する
6.2 スケーラブルなアプリケーションを構成する3つの直交パターン
一歩引いて全体を俯瞰すると、スケーラブルなアプリケーションの構築は、以下の3つの直交する(互いに独立した)パターンの組み合わせに集約されます。
- 機能分割: アプリケーションを別々のサービスに分割し、それぞれに明確な責任を持たせる
- パーティショニング: データをパーティションに分割し、ノード間に分散させる
- レプリケーション: 機能やデータをノード間で複製する
これらは独立したパターンですが、実際のシステムでは組み合わせて使われます。
6.3 マネージドサービスという「合理的なデフォルト」
個人的に驚いたのは、驚くほど多くのアプリケーションが、少数のマネージドサービス(クラウドプロバイダーが運用を代行してくれるサービス)の組み合わせで構築できるということです。マネージドサービスの最大の魅力は、端的に言えば「障害対応を別の誰かがやってくれる」ことです。
スケーラブルなアプリケーションの「合理的なデフォルト」として、以下のサービス群に習熟しておくと、多くのユースケースに対応できます。
| カテゴリ | 役割 | AWSでの例 |
|---|---|---|
| コンピューティング | アプリケーションの実行 | EC2 |
| ロードバランシング | トラフィックの分散 | ELB |
| ファイルストア | ファイルの永続化 | S3 |
| KV/ドキュメントストア | 構造化データの永続化 | DynamoDB |
| メッセージング | 非同期通信 | SQS, Kinesis |
| キャッシング(最適化) | 頻繁なアクセスの高速化 | ElastiCache |
| CDN(最適化) | コンテンツの配信最適化 | CloudFront |
まずステートレス(状態を持たない)なアプリケーションのコアを構築し、状態はこれらの外部サービスに預ける。その上で、キャッシングやCDNといった最適化を必要に応じて追加していく。これが多くの場合に通用するアプローチです。
6.4 この記事を通して伝えたかったこと
アーキテクチャの分離は「銀の弾丸」ではなく、常に複雑さとの引き換えです。大切なのは、「何を分離し、何を引き受けるか」を判断できること。
モノリスから始め、痛みが生じたら1つずつ切り出す。その判断に必要な「動機」「仕組み」「トレードオフ」の3点セットを、この記事でお伝えできていれば嬉しいです。