💸

【検証】マイクロサービス化で通信遅延は倍増?Python/FastAPIでオーバーヘッドを実測比較してみた

に公開

はじめに

「ソフトウェアアーキテクチャの第一法則」をご存知でしょうか。
https://zenn.dev/shayate811/articles/software-architecture-bookreview

"ソフトウェアアーキテクチャはトレードオフがすべてだ"
『ソフトウェアアーキテクチャの基礎』より

最近、アーキテクチャの学習を進める中で、この言葉の重みを実感することが増えました。特に「マイクロサービス」はスケーラビリティや開発の柔軟性をもたらす一方で、「パフォーマンス(レイテンシ)」や「複雑性」という対価を支払う必要があると言われています。

頭では理解していても、「実際、そのオーバーヘッドはどれくらい重いのか?」を肌で感じたことがありませんでした。

そこで今回は、Python (FastAPI) と Docker を使い、「モノリス」と「マイクロサービス」でどれくらい応答速度に差が出るのかを検証(PoC)してみました。

検証環境の構成

検証のために、シンプルな EC サイトの「注文処理」を模した API を構築しました。
https://github.com/shayate811/learning/tree/main/arichitecture-poc

技術スタック

  • 言語: Python 3.11
  • フレームワーク: FastAPI
  • インフラ: Docker Compose
  • 負荷試験: Locust
  • 可視化: Jaeger (OpenTelemetry)

比較するアーキテクチャ

services:
  # --- モノリス ---
  monolith:
    build: .
    volumes:
      - ./monolith:/app
      - ./telemetry.py:/telemetry.py
    ports:
      - "8001:8000"

  # --- マイクロサービス群 ---
  order:
    build: .
    volumes:
      - ./microservices/order:/app
      - ./telemetry.py:/telemetry.py
    ports:
      - "8002:8000"
    depends_on:
      - payment
      - inventory
      - jaeger # (あれば)

  payment:
    build: .
    volumes:
      - ./microservices/payment:/app
      - ./telemetry.py:/telemetry.py
    expose:
      - "8000"

  inventory:
    build: .
    volumes:
      - ./microservices/inventory:/app
      - ./telemetry.py:/telemetry.py
    expose:
      - "8000"

  # 4. 負荷試験ツール (Locust)
  load-test:
    image: locustio/locust
    volumes:
      - ./locustfile.py:/mnt/locust/locustfile.py
    ports:
      - "8089:8089"
    command: -f /mnt/locust/locustfile.py
    depends_on:
      - monolith
      - order

  # --- Jaeger (All-in-one) ---
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686" # 管理画面 (UI)
      - "4317:4317" # データ受信 (OTLP gRPC)
    environment:
      - COLLECTOR_OTLP_ENABLED=true
  1. モノリス構成 (Monolith)
    • 1 つのコンテナ内で、「注文」「決済」「在庫」の処理を関数呼び出しとして実行する。
  2. マイクロサービス構成 (Microservices)
    • 「注文」「決済」「在庫」をそれぞれ別のコンテナに分割。
    • サービス間の連携は HTTP 通信 (httpx) で行う。

測定のポイント

純粋な「通信オーバーヘッド」だけを浮き彫りにするため、ビジネスロジック(DB 処理など)の時間は極限まで短く設定しました。

  • 各処理の擬似的な待ち時間: time.sleep(0.001) (1ms)
  • モノリス: 関数を呼ぶだけなので、オーバーヘッドはほぼゼロ。
  • マイクロサービス: HTTP 通信が発生するため、シリアライズや TCP 接続のコストが乗るはず。

実装の抜粋

違いはこれだけです。

モノリスの場合(メモリ内完結)

@app.get("/buy")
def buy_monolith():
    # 関数を呼ぶだけ
    payment = process_payment()   # 内部関数
    inventory = check_inventory() # 内部関数
    return {"status": "complete"}

マイクロサービスの場合(ネットワーク通信)

@app.get("/buy")
def buy_microservice():
    # わざわざHTTPリクエストを飛ばす
    with httpx.Client() as client:
        payment = client.get("http://payment:8000/pay").json()
        inventory = client.get("http://inventory:8000/check").json()
    return {"status": "complete"}

結果:残酷なまでの「3 倍」の差

Locust を使って、同時接続数 500 ユーザーで負荷をかけた結果がこちらです。

考察

平均レイテンシが約 3 倍悪化:

モノリスが約 5.5ms に対し、マイクロサービスは約 16ms でした。

処理自体はほぼ何もしない(1ms 程度)設定なのに、「分割した」という事実だけで +10ms 以上の税金 が発生しています。

安定性の低下 (95%ile):

マイクロサービス側は、高負荷時に最大 120ms まで跳ね上がっています。ネットワークの詰まりやコネクション確立のコストが、レイテンシのばらつき(Jitter)を生んでいることがわかります。

Jaeger で「見えない壁」を可視化する

「なぜ遅いのか?」を視覚的に確認するために、Jaeger(分散トレーシング)を導入しました。 これが、マイクロサービスにおけるリクエストの裏側です。

このウォーターフォールチャートから、以下のことが読み取れます。

階段状の遅延:

モノリスなら垂直に落ちるはずの処理フローが、右下に向かって階段状になっています。これが「行って、帰ってくる」のを待っている時間です。

Span の隙間(オーバーヘッド):

親のバー(青色:呼び出し側)と、子のバー(茶色:実処理)の間に、左右のズレがあります。

これこそが、JSON のシリアライズ/デシリアライズ、TCP ハンドシェイク、ネットワーク転送にかかっている「純粋な通信コスト」です。

まとめ:アーキテクトとしての学び

今回の PoC を通じて、教科書的な知識だった「トレードオフ」を実測値として体感することができました。

マイクロサービスは「速さ」のための技術ではない。

むしろ、単一リクエストのレイテンシは構造的に悪化する。

分割はコストである。

安易にサービスを分けると、そこには必ず「ネットワーク」という不安定で遅い媒体が介在する。

それでも分割する理由は?

この「遅さ」という税金を払ってでも、「チームの独立性」や「特定機能のスケーラビリティ」が欲しい時だけ、マイクロサービスを選択すべき。

「流行っているからマイクロサービス」ではなく、「この対価を払う価値が、このシステムにあるか?」 を問うのが、アーキテクトの仕事なのだと痛感しました。

GitHubで編集を提案

Discussion