サービスメッシュ / サービスディスカバリ ── マイクロサービスの通信制御
扱うCS概念:サービスディスカバリ(クライアントサイド / サーバーサイド)、サイドカーパターン、mTLS、Gossip プロトコル、ヘルスチェックの一貫性

この章で何ができるようになるか:マイクロサービスが「相手のIPアドレスを知らない状態」でどう通信するかを説明できる。サービスメッシュがなぜ必要になり、どんな問題を解決しているかを理解できる。
問題設定
モノリスからマイクロサービスに移行すると、新しい問題が生まれる。
モノリス時代:
UserService.getUser(id) → 同一プロセス内のメソッド呼び出し
→ アドレス解決不要、暗号化不要、リトライはフレームワーク任せ
マイクロサービス時代:
user-service はどの IP:port で動いている?
→ 3台のうちどれに送る? 1台が落ちたら?
→ 通信は暗号化されている?
→ タイムアウトとリトライのポリシーは?
→ 各サービスのトラフィック量の可視化は?
これらの「横断的関心事(cross-cutting concerns)」を各サービスに個別実装するのは現実的でない。
サービスディスカバリ:相手をどう見つけるか
方式1:クライアントサイドディスカバリ
graph LR
Client[Order Service] -->|1. 問い合わせ| Registry[(サービスレジストリ<br/>Consul / etcd)]
Registry -->|2. user-svc の IP一覧| Client
Client -->|3. LB して直接通信| US1[user-svc :8080]
Client -->|3.| US2[user-svc :8081]
# Consul を使ったクライアントサイドディスカバリ
import consul
import random
class ServiceDiscovery:
def __init__(self):
self.consul = consul.Consul(host='consul-server', port=8500)
self._cache: dict[str, list] = {} # ローカルキャッシュ
def get_instance(self, service_name: str) -> str:
"""サービス名からインスタンスのアドレスを返す"""
# キャッシュを確認(TTL 30秒)
if service_name in self._cache:
instances = self._cache[service_name]
else:
_, instances = self.consul.health.service(
service_name, passing=True # ヘルスチェックが通っているものだけ
)
self._cache[service_name] = instances
# 30秒後にキャッシュ無効化
threading.Timer(30, lambda: self._cache.pop(service_name, None)).start()
if not instances:
raise ServiceUnavailableError(f"No healthy instances of {service_name}")
# クライアント側でロードバランシング
instance = random.choice(instances)
addr = instance['Service']['Address']
port = instance['Service']['Port']
return f"http://{addr}:{port}"
# 使用例
discovery = ServiceDiscovery()
user_svc_url = discovery.get_instance("user-service")
response = requests.get(f"{user_svc_url}/users/123")
方式2:サーバーサイドディスカバリ
graph LR
Client[Order Service] -->|1. user-service/users/123| LB[ロードバランサー / DNS]
LB -->|2. レジストリ参照| Registry[(レジストリ)]
LB -->|3. ルーティング| US1[user-svc]
LB -->|3.| US2[user-svc]
- AWS ALB + ECS:ECS がサービスを登録、ALB がルーティング
- Kubernetes Service:kube-dns が
user-service.default.svc.cluster.localを解決
# Kubernetes の Service 定義
apiVersion: v1
kind: Service
metadata:
name: user-service
spec:
selector:
app: user-service
ports:
- port: 80
targetPort: 8080
type: ClusterIP # クラスタ内DNSで解決
---
# → curl http://user-service/users/123 で到達可能
# → kube-proxy がバックエンドの Pod に LB
2方式の比較
| クライアントサイド | サーバーサイド | |
|---|---|---|
| LB のカスタマイズ性 | ✅ 高い(アプリ側で制御) | △ LB の機能に依存 |
| クライアントの複雑さ | ❌ ディスカバリライブラリが必要 | ✅ DNS/LB に任せてシンプル |
| 障害点 | レジストリ | レジストリ + LB |
| 代表例 | Consul + Envoy, Netflix Eureka | K8s Service, AWS ALB |
Gossip プロトコル:メンバーシップ管理
Consul や Cassandra は Gossip プロトコルでクラスタのメンバーシップを管理する。
Gossip のアイデア(噂話プロトコル):
1. 各ノードが定期的にランダムな相手に「自分が知っている状態」を送る
2. 受信者は自分の情報と統合して更新する
3. 繰り返すと、全ノードが最終的に同じ情報を持つ(結果整合性)
特性:
- 全ノードが全ノードと通信する必要がない(O(log N) ラウンドで収束、総メッセージ数は O(N log N))
- 中央のコーディネーターが不要(分散型)
- 障害ノードの検知も Gossip で伝播する
class GossipNode:
def __init__(self, node_id: str, peers: list[str]):
self.node_id = node_id
self.peers = peers
# 各ノードの状態:{node_id: (status, version)}
self.membership: dict[str, tuple[str, int]] = {
node_id: ("alive", 0)
}
def gossip_round(self):
"""定期的に実行:ランダムなピアに状態を送信"""
target = random.choice(self.peers)
remote_state = self.send_state(target, self.membership)
# 受信した状態とマージ(バージョンが高い方を採用)
for nid, (status, version) in remote_state.items():
local = self.membership.get(nid)
if local is None or version > local[1]:
self.membership[nid] = (status, version)
def mark_suspicious(self, node_id: str):
"""ハートビート未応答 → suspicious → 一定時間後に dead"""
current = self.membership.get(node_id)
if current:
self.membership[node_id] = ("suspicious", current[1] + 1)
サービスメッシュ:サイドカーパターン
サービスディスカバリ、LB、mTLS、リトライ、サーキットブレーカー、メトリクス収集……これらを各サービスに実装するのではなく、サイドカープロキシに任せるのがサービスメッシュだ。
graph LR
subgraph Pod A
AppA[Order Service] <--> ProxyA[Envoy Proxy<br/>サイドカー]
end
subgraph Pod B
ProxyB[Envoy Proxy<br/>サイドカー] <--> AppB[User Service]
end
ProxyA -->|mTLS| ProxyB
ControlPlane[Istio Control Plane] -.->|設定配信| ProxyA
ControlPlane -.->|設定配信| ProxyB
サイドカーが担う責務:
1. サービスディスカバリ → 相手の IP:port を解決
2. ロードバランシング → 複数インスタンスに分散
3. mTLS → サービス間通信を暗号化(ゼロトラスト)
4. リトライ / タイムアウト → 失敗時の自動リトライ
5. サーキットブレーカー → 障害伝播の防止
6. メトリクス / トレーシング → リクエスト量・レイテンシの収集
7. トラフィック制御 → カナリアデプロイ(5%だけ新バージョンへ)
アプリケーションは「localhost にリクエストを送る」だけ。横断的関心事は全てサイドカーが処理する。
# アプリケーション側のコード(メッシュ導入前)
response = requests.get(
"https://user-service.prod:8443/users/123",
headers={"Authorization": f"Bearer {token}"},
timeout=5,
verify="/certs/ca.pem" # TLS 証明書の検証
)
# アプリケーション側のコード(メッシュ導入後)
response = requests.get("http://localhost:8080/users/123")
# → Envoy サイドカーが:
# 1. user-service の実アドレスを解決
# 2. mTLS で暗号化
# 3. LB して送信
# 4. リトライ・タイムアウト・メトリクス収集
Istio のトラフィック制御
# Istio VirtualService: カナリアデプロイ
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: user-service
spec:
hosts:
- user-service
http:
- match:
- headers:
x-canary:
exact: "true"
route:
- destination:
host: user-service
subset: v2 # 新バージョン
- route:
- destination:
host: user-service
subset: v1 # 安定版
weight: 95
- destination:
host: user-service
subset: v2 # 新バージョン
weight: 5
サービスメッシュが必要なとき / 不要なとき
必要なケース:
- マイクロサービスが 20 以上(横断的関心事を個別管理できない)
- マルチ言語環境(Go, Python, Java が混在し、共通ライブラリが作れない)
- ゼロトラストセキュリティ(mTLS を全通信に必須)
- 細かなトラフィック制御(A/Bテスト、カナリア、フォールトインジェクション)
不要なケース:
- サービスが 5 つ以下(オーバーヘッドの方が大きい)
- 単一言語(共通ライブラリで横断的関心事を解決できる)
- レイテンシに極端にシビア(サイドカーの追加レイテンシが問題になる)
まとめ
| 課題 | 解決策 | CS概念 |
|---|---|---|
| サービスの IP を知らない | サービスディスカバリ(Consul/K8s DNS) | 名前解決・レジストリ |
| 障害ノードの検知 | Gossip プロトコル | 結果整合性・噂話伝播 |
| 横断的関心事の重複 | サイドカーパターン(Envoy) | プロキシ・関心の分離 |
| サービス間暗号化 | mTLS(自動証明書ローテーション) | 公開鍵暗号基盤 |
| カナリアデプロイ | トラフィック重み付けルーティング | 段階的ロールアウト |