LESSON 20分

ストーリー

佐藤CTO
サービスメッシュの可観測性は理解できたな。ここで一つ基本に立ち返ろう
佐藤CTO
マイクロサービスAがサービスBを呼び出す時、Bのアドレスをどうやって知るんだ?
あなた
えっと、設定ファイルにBのIPアドレスを書いておく…ですか?
佐藤CTO
コンテナ環境ではPodは頻繁に作り直される。IPアドレスは毎回変わる。固定IPを設定ファイルに書く方法は通用しない。サービスディスカバリの仕組みが必要だ

サービスディスカバリとは

サービスディスカバリとは、動的に変化するサービスのネットワーク位置(IPアドレス・ポート)を自動的に解決する仕組みです。

なぜサービスディスカバリが必要か

課題説明
動的なIPアドレスコンテナは起動のたびにIPが変わる
スケーリングオートスケールでインスタンス数が動的に増減
障害復旧障害インスタンスの自動検知と除外
デプロイローリングデプロイ中にインスタンスが入れ替わる
graph LR
    subgraph "従来の方法(静的設定)"
        O1["order-service"] -->|"192.168.1.100:8080<br/>← IPが変わったら?"| P1["payment-service"]
    end

    subgraph "サービスディスカバリ"
        O2["order-service"] -->|"payment-service"| D["ディスカバリ<br/>(動的に解決)"]
        D --> PA["192.168.1.105:8080"]
        D --> PB["192.168.1.106:8080"]
        D --> PC["192.168.1.107:8080"]
    end

サービスディスカバリのパターン

1. クライアントサイドディスカバリ

クライアント(呼び出し元)がサービスレジストリに問い合わせて宛先を決定するパターンです。

graph TD
    OS["Order Service<br/>(クライアント)"] -->|"① 問い合わせ"| SR["Service Registry<br/>(Consul, Eureka)<br/>payment-service:<br/>10.0.0.5:8080<br/>10.0.0.6:8080<br/>10.0.0.7:8080"]
    OS -->|"② クライアントがLBアルゴリズムで選択"| PS["Payment Svc<br/>(10.0.0.6)<br/>10.0.0.6:8080 を選択"]

利点: ロードバランシングのカスタマイズが容易 欠点: 各クライアントにディスカバリロジックが必要

2. サーバーサイドディスカバリ

ロードバランサーがクライアントの代わりにサービスレジストリに問い合わせるパターンです。

graph LR
    OS["Order Service<br/>(クライアント)"] --> LB["Load Balancer<br/>(Nginx, ALB)"]
    LB --> PS["Payment Svc<br/>(10.0.0.6)"]
    LB -.->|"問い合わせ"| SR["Service Registry"]

利点: クライアントはシンプルに保てる 欠点: ロードバランサーが単一障害点になる可能性

3. DNSベースディスカバリ

**DNS(Domain Name System)**を使ってサービスのアドレスを解決するパターンです。Kubernetesのデフォルトの仕組みです。

graph TD
    OS["Order Service<br/>DNS query: payment-service.production.svc.cluster.local"]
    DNS["CoreDNS (Kubernetes)<br/>→ 10.96.45.123 (ClusterIP)"]
    KP["kube-proxy<br/>iptables/IPVS で実際のPodにルーティング<br/>10.0.0.5:8080, 10.0.0.6:8080, 10.0.0.7:8080"]

    OS --> DNS --> KP

利点: アプリケーションコードの変更が不要、標準プロトコル 欠点: DNSキャッシュのTTL問題、L7レベルのLBが困難


サービスレジストリ

サービスレジストリは、サービスインスタンスの登録と検索を管理する中央データベースです。

主要なサービスレジストリ

レジストリ提供元特徴
ConsulHashiCorpヘルスチェック内蔵、KVストア、マルチDC
EurekaNetflix/SpringSpring Cloud統合、REST API
etcdCNCFKubernetes内部で使用、強整合性
ZooKeeperApache分散協調サービス、古典的

サービス登録の仕組み

// 自己登録パターン(Self-Registration)
class ServiceRegistration {
  private registryClient: ConsulClient;
  private heartbeatInterval: NodeJS.Timer | null = null;

  constructor(
    private serviceName: string,
    private servicePort: number,
    private serviceHost: string
  ) {
    this.registryClient = new ConsulClient();
  }

  async register(): Promise<void> {
    await this.registryClient.agent.service.register({
      name: this.serviceName,
      address: this.serviceHost,
      port: this.servicePort,
      check: {
        http: `http://${this.serviceHost}:${this.servicePort}/health`,
        interval: '10s',         // 10秒ごとにヘルスチェック
        timeout: '5s',
        deregisterCriticalServiceAfter: '30s', // 30秒間応答なしで登録解除
      },
    });

    // ハートビートの送信(自己申告型ヘルスチェック)
    this.heartbeatInterval = setInterval(async () => {
      await this.registryClient.agent.check.pass({
        id: `service:${this.serviceName}`,
      });
    }, 5000);
  }

  async deregister(): Promise<void> {
    if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
    await this.registryClient.agent.service.deregister(this.serviceName);
  }
}

ヘルスチェック

サービスディスカバリにおいて、ヘルスチェックは不健全なインスタンスを検知してトラフィックから除外するための重要な仕組みです。

ヘルスチェックの種類

種類方式説明
アクティブプル型定期的にヘルスエンドポイントをポーリング
パッシブ観測型実際のトラフィックのエラー率を監視
ハートビートプッシュ型サービスが定期的に生存を通知

Kubernetesのヘルスチェック

apiVersion: v1
kind: Pod
metadata:
  name: order-service
spec:
  containers:
    - name: order-service
      image: myapp/order-service:v1.0
      ports:
        - containerPort: 8080

      # Liveness Probe: コンテナが生きているかを確認
      # 失敗するとコンテナが再起動される
      livenessProbe:
        httpGet:
          path: /health/live
          port: 8080
        initialDelaySeconds: 15  # 起動後15秒待ってから開始
        periodSeconds: 10        # 10秒間隔
        failureThreshold: 3      # 3回連続失敗で再起動

      # Readiness Probe: トラフィックを受け入れ可能か確認
      # 失敗するとServiceのエンドポイントから除外される
      readinessProbe:
        httpGet:
          path: /health/ready
          port: 8080
        initialDelaySeconds: 5
        periodSeconds: 5
        failureThreshold: 3

      # Startup Probe: 起動完了を確認
      # 起動が遅いアプリケーション向け
      startupProbe:
        httpGet:
          path: /health/started
          port: 8080
        failureThreshold: 30  # 30回まで待つ
        periodSeconds: 10     # 10秒間隔 = 最大300秒
// ヘルスチェックエンドポイントの実装
// /health/live: プロセスが正常か(デッドロックしていないか)
app.get('/health/live', (req, res) => {
  // イベントループが動いていれば200を返す
  res.status(200).json({ status: 'alive' });
});

// /health/ready: リクエストを処理できる状態か
app.get('/health/ready', async (req, res) => {
  const dbHealthy = await checkDatabaseConnection();
  const cacheHealthy = await checkRedisConnection();

  if (dbHealthy && cacheHealthy) {
    res.status(200).json({ status: 'ready' });
  } else {
    res.status(503).json({
      status: 'not ready',
      checks: { database: dbHealthy, cache: cacheHealthy },
    });
  }
});

// /health/started: 初期化が完了したか
app.get('/health/started', (req, res) => {
  if (appInitialized) {
    res.status(200).json({ status: 'started' });
  } else {
    res.status(503).json({ status: 'starting' });
  }
});

ロードバランシングアルゴリズム

主要なアルゴリズム比較

アルゴリズム説明適したケース
ラウンドロビン順番にリクエストを分配インスタンスのスペックが均一
重み付きラウンドロビン重みに応じて分配比率を変更インスタンスのスペックが不均一
最小接続数接続数が最も少ないインスタンスに送信処理時間にばらつきがある場合
ランダムランダムに選択シンプルで十分な場合
ハッシュベースリクエスト属性のハッシュ値で振り分けセッション親和性が必要な場合
P2C(2択の力)ランダムに2つ選び、負荷が低い方に送信大規模クラスタ
graph LR
    subgraph ラウンドロビン
        R1["Request 1"] --> IA1["Instance A"]
        R2["Request 2"] --> IB1["Instance B"]
        R3["Request 3"] --> IC1["Instance C"]
        R4["Request 4"] --> IA1
        R5["Request 5"] --> IB1
    end
graph LR
    subgraph "最小接続数"
        IA2["Instance A<br/>接続数: 5"]
        IB2["Instance B<br/>接続数: 2"]
        IC2["Instance C<br/>接続数: 8"]
        REQ["新規リクエスト"] -->|"← ここに送信"| IB2
    end
graph LR
    subgraph "ハッシュベース(セッション親和性)"
        U1["user-123"] -->|"hash"| HA["Instance A"]
        U2["user-456"] -->|"hash"| HC["Instance C"]
        U3["user-789"] -->|"hash"| HB["Instance B"]
    end

Istioでのロードバランシング設定

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: payment-service
spec:
  host: payment-service
  trafficPolicy:
    loadBalancer:
      # シンプルなアルゴリズム
      simple: LEAST_REQUEST  # ROUND_ROBIN | LEAST_REQUEST | RANDOM | PASSTHROUGH

---
# ハッシュベース(セッション親和性)
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: session-service
spec:
  host: session-service
  trafficPolicy:
    loadBalancer:
      consistentHash:
        httpHeaderName: "x-user-id"  # ユーザーIDでハッシュ
        # 他の選択肢:
        # httpCookie:
        #   name: "session-id"
        #   ttl: 3600s
        # useSourceIp: true

クライアントサイドロードバランシング

gRPCのロードバランシング課題

gRPCはHTTP/2の長期接続を使用するため、従来のロードバランサーでは接続単位でしか分散できず、リクエスト単位の分散ができないという課題があります。

graph LR
    subgraph "問題: L4ロードバランシング"
        C1["Client"] --> L4["L4 LB"]
        L4 -->|"全リクエストが偏る"| A1["Instance A"]
        L4 -.-> B1["Instance B"]
        L4 -.-> C1a["Instance C"]
    end
graph LR
    subgraph "解決策1: L7ロードバランシング"
        C2["Client"] --> L7["L7 LB (Envoy)"]
        L7 -->|"リクエスト単位で分散"| A2["Instance A"]
        L7 --> B2["Instance B"]
        L7 --> C2a["Instance C"]
    end
graph LR
    subgraph "解決策2: クライアントサイドLB"
        C3["Client"]
        C3 -->|"直接振り分け"| A3["Instance A"]
        C3 --> B3["Instance B"]
        C3 --> C3a["Instance C"]
    end

Istioによる解決

Istioのサイドカープロキシ(Envoy)はL7レベルでgRPCリクエストを処理するため、HTTP/2の長期接続の問題を透過的に解決します。

# Envoyがリクエスト単位でgRPCのロードバランシングを実施
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: grpc-service
spec:
  host: grpc-service
  trafficPolicy:
    loadBalancer:
      simple: ROUND_ROBIN  # gRPCでもリクエスト単位で分散
    connectionPool:
      http:
        h2UpgradePolicy: DEFAULT  # HTTP/2接続を管理

Kubernetesのサービスディスカバリ

Kubernetesには組み込みのサービスディスカバリが提供されています。

Serviceリソース

# Kubernetes Service
apiVersion: v1
kind: Service
metadata:
  name: payment-service
  namespace: production
spec:
  selector:
    app: payment-service  # このラベルを持つPodをバックエンドに
  ports:
    - port: 80            # Serviceのポート
      targetPort: 8080    # Podのポート
  type: ClusterIP         # クラスタ内部からのみアクセス可能

DNS解決

# Kubernetes DNS命名規則
<service-name>.<namespace>.svc.cluster.local

# 例:
payment-service.production.svc.cluster.local
→ ClusterIP: 10.96.45.123
→ 実際のPod: 10.0.0.5:8080, 10.0.0.6:8080, 10.0.0.7:8080

# 同じNamespace内ではサービス名だけで解決可能
payment-service
→ 10.96.45.123

Headless Service(直接Pod IP取得)

# Headless Service: ClusterIPなし、Pod IPを直接返す
apiVersion: v1
kind: Service
metadata:
  name: payment-service-headless
spec:
  clusterIP: None  # Headless
  selector:
    app: payment-service
  ports:
    - port: 8080

# DNS問い合わせ結果:
# payment-service-headless.production.svc.cluster.local
# → 10.0.0.5, 10.0.0.6, 10.0.0.7(Pod IPが直接返る)

まとめ

ポイント内容
サービスディスカバリ動的に変化するサービスアドレスを自動解決する仕組み
3つのパターンクライアントサイド、サーバーサイド、DNSベース
ヘルスチェックLiveness/Readiness/Startupの3種類で健全性を管理
LBアルゴリズムラウンドロビン、最小接続数、ハッシュベースなど用途に応じて選択
gRPC課題HTTP/2長期接続のLB問題はIstio/EnvoyのL7 LBで解決
KubernetesService + CoreDNSで組み込みのディスカバリを提供

チェックリスト

  • サービスディスカバリが必要な理由を説明できる
  • クライアントサイド/サーバーサイド/DNSベースの違いを説明できる
  • Kubernetesの3種類のヘルスチェック(Liveness/Readiness/Startup)を理解した
  • 主要なロードバランシングアルゴリズムの特徴を説明できる
  • gRPCのロードバランシング課題とIstioによる解決を理解した
  • KubernetesのServiceとDNS解決の仕組みを理解した

次のステップへ

サービスディスカバリとロードバランシングの仕組みを理解したところで、次は演習に入ります。ここまで学んだサービスメッシュの知識を総合して、実際のマイクロサービスシステムに対するサービスメッシュ構成を設計しましょう。


推定読了時間: 20分