プログラマのための IT 教科書
第5部 サービスをつくる

マイクロサービスの実務

読了目安 45

この章を読むとできるようになること
  • リトライが障害を悪化させる仕組みを説明できる
  • 冪等性が必要な理由を説明できる
  • トレースから遅延の原因を特定できる

ここまでの章で、サービスを書き(第4部)、繋ぎ(第13章)、動かす(第15章)方法を見てきました。

最後は、それらが複数集まった時に何が起きるかです。

マイクロサービスの難しさは、個々のサービスにはありません。 サービスとサービスの「あいだ」 にあります。

なぜ分割するのか、その代償

分けて得られるもの

  • チームごとに独立してデプロイできる(他チームを待たなくていい)
  • 障害の影響範囲を限定できる
  • サービスごとに適した技術を選べる

分けて失うもの

モノリスならマイクロサービスでは
関数呼び出し(絶対に失敗しない)ネットワーク越し(普通に失敗する)
デバッガで1つ追える複数のログを突き合わせる
1つのトランザクションで整合性分散トランザクション(非常に難しい)
起動すれば全部動くローカルで全部動かすのが大変
最初からマイクロサービスにしない

「マイクロサービスが正しい」わけではありません。

小さなチームであれば、モノリスの方が速く、安全です。 分割は「チームが大きくなり、デプロイで衝突するようになった」時の解決策です。

既にマイクロサービスの現場に入ったなら、 なぜこの粒度で分かれているのかを先輩に聞いてみてください。 たいてい歴史的な理由があります。

前提: ネットワークは失敗する

分散システムを考える上での出発点です。

  • パケットは失われる
  • 相手は落ちているかもしれない
  • 遅いだけかもしれない(落ちているのと区別できない
  • 応答が返らなくても、相手は処理を完了しているかもしれない

最後の項目が最も重要です。 「タイムアウトした = 失敗した」ではありません

タイムアウト

すべての外部呼び出しにタイムアウトを設定してください。

設定しないと、相手が応答しない時にこちらのリソースが解放されず、 自分も道連れで落ちます

上流ほど長く

ユーザー ──3秒──→ [API GW] ──2秒──→ [注文] ──1秒──→ [在庫]

呼ぶ側のタイムアウトは、呼ばれる側より長くします。逆にすると、

❌ [注文: 1秒] ──→ [在庫: 3秒]

在庫サービスが2秒かけて正常に処理を終えても、 注文側は1秒で諦めており、その処理は無駄になります。 しかも在庫側は「成功した」と記録しているかもしれません。

タイムアウト値は「予算」で考える

ユーザーに3秒で返したいなら、その3秒をどう配分するかを考えます。

API GW 3秒
 ├ 認証     0.2秒
 ├ 注文     2.0秒
 │   └ 在庫  0.8秒
 └ 整形     0.1秒

各サービスが好き勝手に設定すると、必ず矛盾します。 チーム間で数字を合わせる必要があります。

リトライ

失敗したら再試行する。単純に見えて、危険な機能です。

リトライしてよい条件

条件理由
一時的な失敗UNAVAILABLE DEADLINE_EXCEEDED 503 429
冪等な操作何度実行しても結果が同じ

INVALID_ARGUMENT400 はリトライしても無駄です(何度送っても同じ結果)。

リトライストーム

新人が最も理解しておくべき失敗パターンです。

在庫サービスが遅くなる
      ↓
注文サービスがタイムアウトしてリトライ
      ↓
在庫サービスへのリクエストが 2倍・3倍に
      ↓
在庫サービスがさらに遅くなる
      ↓
もっとリトライされる
      ↓
完全に停止 → 全体が停止

弱っている相手を、善意のリトライで殺します。

正しいリトライ

// 危険: すぐに、何度も
for (let i = 0; i < 5; i++) {
  try { return await call(); } catch { /* すぐ再試行 */ }
}
 
// 安全: 間隔を空け、ばらつかせ、回数を絞る
async function callWithRetry() {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await call();
    } catch (error) {
      if (!isRetryable(error) || attempt === 2) throw error;
      // 指数バックオフ + ジッター
      const base = 100 * 2 ** attempt;          // 100, 200, 400ms
      const jitter = Math.random() * base;      // ばらつかせる
      await sleep(base + jitter);
    }
  }
}
要素目的
指数バックオフ待ち時間を倍々にして、相手に回復の余地を与える
ジッター(ゆらぎ)全クライアントが同時に再送するのを防ぐ
回数制限無限に粘らない
多段リトライに注意

各層が3回ずつリトライすると、3 × 3 × 3 = 27倍のリクエストになります。

GW(3回) → 注文(3回) → 在庫(3回)

リトライはどこか1箇所で行うのが原則です。 チームで「どの層がリトライするか」を決めてください。

冪等性

リトライを安全にするための鍵です。

同じ操作を何回実行しても、結果が1回分と同じになる性質

GET    /items/42        冪等(何回読んでも同じ)
DELETE /items/42        冪等(2回目は「もう無い」で同じ結果)
POST   /orders          冪等でない ← 呼ぶたびに注文が増える

冪等キー

POST を冪等にするには、クライアントが一意なキーを付けます

POST /payments
Idempotency-Key: 7f3c1e2a-...
 
{ "orderId": "42", "amount": 1200 }

サーバー側の処理はこうなります。

1. このキーで処理済みか確認する
2. 済みなら、前回の結果をそのまま返す(再処理しない)
3. 未処理なら実行し、キーと結果を保存する

これで、ネットワークが不安定でも二重決済が起きません

「失敗したから作り直す」が二重登録を生む
1. 注文APIを呼ぶ → タイムアウト
2. 「失敗したはず」と考えて、もう一度呼ぶ
3. 実は1回目も成功していた → 注文が2件できる

決済・注文・メール送信など、副作用のある操作では必ず冪等性を考えてください。 「タイムアウト = 失敗」ではない、が出発点です。

決済APIがタイムアウトしました。リトライする前に必要なことは?

サーキットブレーカー

相手が明らかに壊れている時、呼ぶのをやめる仕組みです。

Closed(通常)  ──失敗が閾値を超えた──→  Open(遮断)
      ↑                                    │
      │                              一定時間後
      └────  成功したら戻る  ──── Half-Open(試しに1本流す)

Open の間は、相手を呼ばずに即座に失敗を返します

意味があるのは2つです。

  1. 相手に回復の時間を与える(叩き続けない)
  2. 自分のリソースを守る(応答待ちで詰まらない)
フォールバックを用意する

遮断した時、何を返すかを決めておきます。

  • キャッシュした古いデータを返す
  • 「おすすめ」なら空リストを返して、その部分だけ非表示にする
  • どうしても必要なら、正直にエラーを返す

一部が壊れても全体は動く(グレースフルデグラデーション)という設計です。 おすすめ機能が落ちただけで商品ページ全体が見られなくなる、 というのは避けたい状況です。

分散トレース

複数サービスにまたがるリクエストを追跡する仕組みです。

[trace: abc123]
├── API Gateway            5ms
│   └── 注文サービス       850ms   ← 遅い
│       ├── ユーザー取得    20ms
│       ├── 在庫確認         30ms
│       └── 商品取得        780ms  ← 犯人はここ
│           ├── 商品1        26ms
│           ├── 商品2        26ms
│           ├── ...(30回)

このウォーターフォールを見れば、どこで時間を使っているかが一目で分かります。 上の例は、商品取得が N+1 になっているパターンです(第14章)。

よく見るパターン

原因
同じスパンが大量に並ぶN+1。まとめて取得する
スパンが階段状に並ぶ直列実行。並列化できる
特定のスパンだけ長いそのサービスの問題
スパンが途中で切れているトレースの伝播漏れ(ctx を渡していない)
context を渡さないとトレースが切れる

第12章の ctx の話がここに繋がります。

ctx を下流に渡さないと、トレース ID が伝わらず、 そこから先が別のトレースとして記録されます

障害調査の時に「途中から追えない」となる原因です。 Go で ctx context.Context が第1引数にあるのは、これも理由の1つです。

構造化ログと相関 ID

トレースがなくても、相関 ID があればログを繋げられます。

{"level":"info","msg":"order created","trace_id":"abc123","order_id":"42","service":"order"}
{"level":"info","msg":"stock reserved","trace_id":"abc123","item_id":"7","service":"stock"}
{"level":"error","msg":"payment failed","trace_id":"abc123","service":"payment"}

trace_id で検索すれば、1つのリクエストの全経路が一列に並びます。

これがないと、「10時5分ごろのエラー」を各サービスのログから 目視で突き合わせることになります。障害対応の速度が桁違いに変わります。

データの整合性

マイクロサービスで最も難しい部分です。

注文サービス: 注文を作成した
在庫サービス: 在庫を減らそうとして失敗した
        ↓
   注文だけ残ってしまう

1つの DB なら、トランザクションで両方なかったことにできます。 サービスが分かれていると、それができません。

現実的な対処

方法内容
Saga パターン失敗したら「打ち消す操作」を実行する(注文をキャンセルする)
結果整合性を許容する一時的にずれることを許し、後で揃える
そもそも分割しない強い整合性が必要な処理は、同じサービスに置く
境界の設計が一番大事

「注文と在庫を別サービスにしたせいで整合性が保てない」なら、 そもそも分割の境界が間違っている可能性があります。

一緒に変更されるものは、一緒に置く。 これが分割の基本原則です。 技術で解決する前に、境界を疑ってください。

新人が最初に気をつけること

実務に入ってすぐ役立つ順に並べます。

  1. ctx を必ず下流に渡す — キャンセルとトレースが伝わる
  2. 外部呼び出しには必ずタイムアウト — 設定しないと道連れで落ちる
  3. ログに trace_id を含める — 調査できるかどうかが決まる
  4. リトライを足す前に相談する — ストームと二重実行のリスクがある
  5. 副作用のある操作は冪等性を考える — 「タイムアウト = 失敗」ではない

実務の落とし穴まとめ

  1. タイムアウトを設定しない — 相手の不調で自分も落ちる
  2. タイムアウトが上流 < 下流 — 無駄な処理が走る
  3. 各層でリトライ — 27倍のリクエストになる
  4. バックオフなしのリトライ — リトライストームで相手を殺す
  5. 冪等性を考えずにリトライ — 二重決済
  6. ctx を渡さない — トレースが切れ、キャンセルも効かない

まとめ

  • マイクロサービスの難しさは サービスの「あいだ」 にある
  • ネットワークは失敗する。「タイムアウト = 失敗」ではない
  • すべての外部呼び出しにタイムアウト。上流ほど長く
  • リトライは一時的な失敗 × 冪等な操作の時だけ。 指数バックオフ + ジッター + 回数制限、そして1箇所だけ
  • 副作用のある操作には冪等キー
  • 壊れた相手はサーキットブレーカーで切り離し、フォールバックを返す
  • trace_id を全ログに。分散トレースで N+1 と直列実行が見える
  • 整合性が難しいなら、まず分割の境界を疑う

章末問題

下流サービスが遅くなったので、リトライ回数を3回から10回に増やしました。何が起きますか。

分散トレースを見たら、同じ名前のスパンが30個並んでいました。何が起きていますか。

注文サービスのタイムアウトが1秒、呼び出す在庫サービスのタイムアウトが3秒に設定されています。何が問題ですか。

これで第5部は終わりです。

サービスを書き、繋ぎ、動かし、安全に運用する—— ここまでが、Web アプリ開発の現場で必要になる技術の全体像です。

読み終わったら記録しておくと、目次で進み具合が分かります。