マイクロサービスの実務
読了目安 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_ARGUMENT や 400 はリトライしても無駄です(何度送っても同じ結果)。
リトライストーム
新人が最も理解しておくべき失敗パターンです。
在庫サービスが遅くなる
↓
注文サービスがタイムアウトしてリトライ
↓
在庫サービスへのリクエストが 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つです。
- 相手に回復の時間を与える(叩き続けない)
- 自分のリソースを守る(応答待ちで詰まらない)
遮断した時、何を返すかを決めておきます。
- キャッシュした古いデータを返す
- 「おすすめ」なら空リストを返して、その部分だけ非表示にする
- どうしても必要なら、正直にエラーを返す
一部が壊れても全体は動く(グレースフルデグラデーション)という設計です。 おすすめ機能が落ちただけで商品ページ全体が見られなくなる、 というのは避けたい状況です。
分散トレース
複数サービスにまたがるリクエストを追跡する仕組みです。
[trace: abc123]
├── API Gateway 5ms
│ └── 注文サービス 850ms ← 遅い
│ ├── ユーザー取得 20ms
│ ├── 在庫確認 30ms
│ └── 商品取得 780ms ← 犯人はここ
│ ├── 商品1 26ms
│ ├── 商品2 26ms
│ ├── ...(30回)
このウォーターフォールを見れば、どこで時間を使っているかが一目で分かります。 上の例は、商品取得が N+1 になっているパターンです(第14章)。
よく見るパターン
| 形 | 原因 |
|---|---|
| 同じスパンが大量に並ぶ | N+1。まとめて取得する |
| スパンが階段状に並ぶ | 直列実行。並列化できる |
| 特定のスパンだけ長い | そのサービスの問題 |
| スパンが途中で切れている | トレースの伝播漏れ(ctx を渡していない) |
第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 パターン | 失敗したら「打ち消す操作」を実行する(注文をキャンセルする) |
| 結果整合性を許容する | 一時的にずれることを許し、後で揃える |
| そもそも分割しない | 強い整合性が必要な処理は、同じサービスに置く |
「注文と在庫を別サービスにしたせいで整合性が保てない」なら、 そもそも分割の境界が間違っている可能性があります。
一緒に変更されるものは、一緒に置く。 これが分割の基本原則です。 技術で解決する前に、境界を疑ってください。
新人が最初に気をつけること
実務に入ってすぐ役立つ順に並べます。
ctxを必ず下流に渡す — キャンセルとトレースが伝わる- 外部呼び出しには必ずタイムアウト — 設定しないと道連れで落ちる
- ログに
trace_idを含める — 調査できるかどうかが決まる - リトライを足す前に相談する — ストームと二重実行のリスクがある
- 副作用のある操作は冪等性を考える — 「タイムアウト = 失敗」ではない
実務の落とし穴まとめ
- タイムアウトを設定しない — 相手の不調で自分も落ちる
- タイムアウトが上流 < 下流 — 無駄な処理が走る
- 各層でリトライ — 27倍のリクエストになる
- バックオフなしのリトライ — リトライストームで相手を殺す
- 冪等性を考えずにリトライ — 二重決済
ctxを渡さない — トレースが切れ、キャンセルも効かない
まとめ
- マイクロサービスの難しさは サービスの「あいだ」 にある
- ネットワークは失敗する。「タイムアウト = 失敗」ではない
- すべての外部呼び出しにタイムアウト。上流ほど長く
- リトライは一時的な失敗 × 冪等な操作の時だけ。 指数バックオフ + ジッター + 回数制限、そして1箇所だけ
- 副作用のある操作には冪等キー
- 壊れた相手はサーキットブレーカーで切り離し、フォールバックを返す
trace_idを全ログに。分散トレースで N+1 と直列実行が見える- 整合性が難しいなら、まず分割の境界を疑う
章末問題
下流サービスが遅くなったので、リトライ回数を3回から10回に増やしました。何が起きますか。
分散トレースを見たら、同じ名前のスパンが30個並んでいました。何が起きていますか。
注文サービスのタイムアウトが1秒、呼び出す在庫サービスのタイムアウトが3秒に設定されています。何が問題ですか。
これで第5部は終わりです。
サービスを書き、繋ぎ、動かし、安全に運用する—— ここまでが、Web アプリ開発の現場で必要になる技術の全体像です。