BACKEND2025-02-15📖 6分

マイクロサービスアーキテクチャ入門:モノリスからの移行戦略と実践ガイド

マイクロサービスアーキテクチャ入門:モノリスからの移行戦略と実践ガイド

スケーラブルなシステム構築のための設計パターンと実装方法。モノリスからの段階的移行、サービス分割の原則、障害対策まで実践的なノウハウを徹底解説。

髙木 晃宏

代表 / エンジニア

👨‍💼

TL;DR

  • マイクロサービスは銀の弾丸ではない。組織の成熟度とシステム規模を見極めて導入判断
  • サービス分割はビジネスドメインを基準に。技術的な理由だけで分割しない
  • 分散システムの複雑さを理解し、適切な障害対策を設計段階から組み込む
  • 段階的な移行が成功の鍵。Big Bang移行は避ける

はじめに:なぜ今マイクロサービスなのか

「モノリスが限界に達した」

この言葉を聞いたことがあるエンジニアは多いでしょう。チームが大きくなり、機能が増え、デプロイのたびに全員が緊張する。テストに何時間もかかり、小さな変更でも予期せぬ影響が出る。

私たちのチームも同じ課題を抱えていました。創業時に作った Ruby on Rails のモノリスアプリケーション。最初は2人で開発していたものが、4年後には15人のチームで開発するようになりました。

結果として:

  • デプロイ頻度:週1回 → 月2回に減少
  • テスト時間:15分 → 2時間に増加
  • 新機能のリリースまで:2週間 → 2ヶ月に増加

この状況を打破するために、私たちはマイクロサービスへの移行を決断しました。本記事では、その過程で学んだ知見を共有します。

マイクロサービスとは何か

定義

マイクロサービスアーキテクチャは、アプリケーションを小さく独立したサービスの集合として構築するアプローチです。各サービスは:

  • 単一のビジネス機能に焦点を当てる
  • 独立してデプロイ可能
  • 独自のデータストアを持つ
  • 軽量なプロトコル(HTTP/REST、gRPC)で通信

モノリスとの比較

【モノリシックアーキテクチャ】
┌─────────────────────────────────────┐
│           単一のアプリケーション          │
│  ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐   │
│  │ UI  │ │ 認証 │ │ 注文 │ │ 在庫 │   │
│  └─────┘ └─────┘ └─────┘ └─────┘   │
│           ┌─────────────┐           │
│           │  単一のDB   │           │
│           └─────────────┘           │
└─────────────────────────────────────┘

【マイクロサービスアーキテクチャ】
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│  UI/BFF │ │ 認証    │ │  注文   │ │  在庫   │
│ Service │ │ Service │ │ Service │ │ Service │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
     │           │           │           │
     │      ┌────┴────┐ ┌────┴────┐ ┌────┴────┐
     │      │  Auth   │ │  Order  │ │  Stock  │
     │      │   DB    │ │   DB    │ │   DB    │
     │      └─────────┘ └─────────┘ └─────────┘

マイクロサービスのメリット・デメリット

観点メリットデメリット
開発速度チームごとに独立して開発・デプロイサービス間の調整が必要
スケーラビリティ必要なサービスだけスケールインフラ管理の複雑化
技術選択サービスごとに最適な技術を選択技術の乱立リスク
障害分離障害が他サービスに波及しにくい分散システムの障害対応は難しい
チーム構成小さなチームで自律的に動けるコミュニケーションコストの変化

移行の判断基準:本当にマイクロサービスが必要か

マイクロサービスは複雑さを伴います。以下のチェックリストで移行の必要性を判断してください。

移行を検討すべき状況

□ チーム規模が10人を超え、コンフリクトが頻発
□ デプロイ頻度が月1回以下に低下
□ テストに1時間以上かかる
□ 特定の機能だけスケールしたい
□ 異なる技術スタックを使いたい機能がある
□ 障害時に全機能が停止してしまう

移行を避けるべき状況

□ チーム規模が5人以下
□ プロダクトが初期フェーズ(PMF未達成)
□ ドメインの理解が浅い
□ 運用経験・インフラ知識が不足
□ 「流行っているから」という理由

重要: 小さなチームや初期プロダクトには、モジュラーモノリスという選択肢もあります。モノリスの単純さを保ちながら、将来の分割に備えた設計です。

サービス分割の設計原則

ドメイン駆動設計(DDD)に基づく分割

サービスの境界はビジネスドメインを基準に決めます。技術的な理由(「このサービスはGoで書きたい」)だけで分割すると失敗します。

【ECサイトの境界付けられたコンテキスト(Bounded Context)】

┌─────────────────┐    ┌─────────────────┐
│   商品カタログ    │    │     注文       │
│  ・商品情報      │    │  ・注文作成    │
│  ・カテゴリ      │    │  ・注文履歴    │
│  ・検索         │    │  ・キャンセル   │
└─────────────────┘    └─────────────────┘

┌─────────────────┐    ┌─────────────────┐
│      在庫       │    │     決済       │
│  ・在庫数量      │    │  ・支払い処理   │
│  ・入出庫       │    │  ・返金        │
│  ・アラート      │    │  ・明細        │
└─────────────────┘    └─────────────────┘

┌─────────────────┐    ┌─────────────────┐
│      配送       │    │     顧客       │
│  ・配送手配      │    │  ・会員情報    │
│  ・追跡         │    │  ・認証        │
│  ・配達完了      │    │  ・ポイント    │
└─────────────────┘    └─────────────────┘

分割の指針

  1. 凝集度を高く: 関連する機能は同じサービスに
  2. 結合度を低く: サービス間の依存を最小限に
  3. チームの境界と一致: 1チーム1サービス(またはn サービス)
  4. データの所有権: 各サービスが自分のデータを持つ

アンチパターン:避けるべき分割

// ❌ 技術レイヤーでの分割(やってはいけない) // - API Gateway Service // - Business Logic Service // - Data Access Service // ✅ ビジネスドメインでの分割(正しい) // - Order Service(注文の全レイヤーを含む) // - Inventory Service(在庫の全レイヤーを含む) // - Payment Service(決済の全レイヤーを含む)

サービス間通信パターン

1. 同期通信(REST / gRPC)

即時のレスポンスが必要な場合に使用します。

// REST API の例 // Order Service から Inventory Service を呼び出し async function createOrder(orderData: CreateOrderDto): Promise<Order> { // 在庫確認(同期呼び出し) const stockResponse = await fetch( `${INVENTORY_SERVICE_URL}/api/stock/${orderData.productId}` ); const stock = await stockResponse.json(); if (stock.quantity < orderData.quantity) { throw new Error('在庫不足'); } // 注文作成 const order = await orderRepository.create(orderData); // 在庫引き当て(同期呼び出し) await fetch(`${INVENTORY_SERVICE_URL}/api/stock/reserve`, { method: 'POST', body: JSON.stringify({ productId: orderData.productId, quantity: orderData.quantity, orderId: order.id }) }); return order; }

gRPCの選択基準:

  • 内部サービス間の高速通信
  • 型安全なインターフェースが必要
  • 双方向ストリーミングが必要
// inventory.proto syntax = "proto3"; service InventoryService { rpc CheckStock(StockRequest) returns (StockResponse); rpc ReserveStock(ReserveRequest) returns (ReserveResponse); } message StockRequest { string product_id = 1; } message StockResponse { int32 quantity = 1; bool available = 2; }

2. 非同期通信(メッセージキュー)

即時のレスポンスが不要な場合、疎結合を実現できます。

// イベント駆動アーキテクチャの例 // Order Service がイベントを発行 async function completeOrder(orderId: string): Promise<void> { const order = await orderRepository.findById(orderId); order.status = 'completed'; await orderRepository.save(order); // イベント発行(非同期) await messageQueue.publish('order.completed', { orderId: order.id, customerId: order.customerId, items: order.items, totalAmount: order.totalAmount }); } // Inventory Service がイベントを購読 messageQueue.subscribe('order.completed', async (event) => { // 在庫の確定処理 for (const item of event.items) { await inventoryService.confirmReservation(item.productId, item.quantity); } }); // Notification Service がイベントを購読 messageQueue.subscribe('order.completed', async (event) => { // メール送信 await emailService.sendOrderConfirmation(event.customerId, event.orderId); });

通信パターンの選択指針

パターンユースケースメリットデメリット
REST外部API、シンプルなCRUD広く普及、デバッグしやすいオーバーヘッド
gRPC内部通信、高性能要求高速、型安全学習コスト
メッセージキュー非同期処理、イベント駆動疎結合、スケーラブル複雑性増加

データ管理戦略

Database per Service パターン

各サービスが独自のデータベースを持ちます。

# docker-compose.yml version: '3.8' services: order-service: build: ./services/order environment: DATABASE_URL: postgresql://order-db:5432/orders order-db: image: postgres:15 volumes: - order-data:/var/lib/postgresql/data inventory-service: build: ./services/inventory environment: DATABASE_URL: postgresql://inventory-db:5432/inventory inventory-db: image: postgres:15 volumes: - inventory-data:/var/lib/postgresql/data payment-service: build: ./services/payment environment: DATABASE_URL: postgresql://payment-db:5432/payments payment-db: image: postgres:15 volumes: - payment-data:/var/lib/postgresql/data volumes: order-data: inventory-data: payment-data:

データ整合性の課題と解決策

分散システムでは、ACIDトランザクションが使えません。代わりに**結果整合性(Eventual Consistency)**を受け入れます。

Sagaパターン

複数サービスにまたがる処理を、一連のローカルトランザクションとして実装します。

// 注文作成のSaga(Choreography方式) // 1. Order Service: 注文を「保留」状態で作成 // 2. Inventory Service: 在庫を引き当て // 3. Payment Service: 決済を実行 // 4. Order Service: 注文を「確定」に更新 // 失敗時の補償トランザクション // Payment失敗 → Inventory: 在庫引き当て解除 → Order: 注文キャンセル class OrderSaga { async execute(orderData: CreateOrderDto): Promise<Order> { const sagaId = generateId(); try { // Step 1: 注文作成(保留状態) const order = await this.orderService.createPending(orderData, sagaId); // Step 2: 在庫引き当て await this.inventoryService.reserve(orderData.items, sagaId); // Step 3: 決済 await this.paymentService.charge(order.totalAmount, sagaId); // Step 4: 注文確定 return await this.orderService.confirm(order.id); } catch (error) { // 補償トランザクション await this.compensate(sagaId, error); throw error; } } private async compensate(sagaId: string, error: Error): Promise<void> { // 実行済みの処理を逆順で取り消し await this.paymentService.refund(sagaId).catch(() => {}); await this.inventoryService.releaseReservation(sagaId).catch(() => {}); await this.orderService.cancel(sagaId).catch(() => {}); } }

障害対策とレジリエンス

分散システムでは、ネットワーク障害やサービスダウンは「起こるもの」として設計します。

Circuit Breaker パターン

連続した失敗を検出し、障害のあるサービスへの呼び出しを一時停止します。

class CircuitBreaker { private failureCount = 0; private lastFailureTime: Date | null = null; private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED'; constructor( private threshold: number = 5, private timeout: number = 30000 ) {} async call<T>(fn: () => Promise<T>): Promise<T> { if (this.state === 'OPEN') { if (Date.now() - this.lastFailureTime!.getTime() > this.timeout) { this.state = 'HALF_OPEN'; } else { throw new Error('Circuit is OPEN'); } } try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } private onSuccess(): void { this.failureCount = 0; this.state = 'CLOSED'; } private onFailure(): void { this.failureCount++; this.lastFailureTime = new Date(); if (this.failureCount >= this.threshold) { this.state = 'OPEN'; } } } // 使用例 const inventoryCircuit = new CircuitBreaker(5, 30000); async function checkStock(productId: string) { return inventoryCircuit.call(() => fetch(`${INVENTORY_SERVICE_URL}/api/stock/${productId}`) ); }

Retry with Exponential Backoff

一時的な障害からの回復を試みます。

async function retryWithBackoff<T>( fn: () => Promise<T>, maxRetries: number = 3, baseDelay: number = 1000 ): Promise<T> { let lastError: Error; for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error as Error; if (attempt < maxRetries - 1) { // 指数バックオフ + ジッター const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000; await sleep(delay); } } } throw lastError!; }

Timeout設定

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

async function fetchWithTimeout( url: string, options: RequestInit = {}, timeout: number = 5000 ): Promise<Response> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timeoutId); } }

監視とオブザーバビリティ

分散システムでは、問題の特定が難しくなります。3つの柱で可観測性を確保します。

1. ログ(Logs)

構造化ログと相関IDで、リクエストを追跡可能にします。

// 相関IDを含む構造化ログ const logger = { info: (message: string, context: object) => { console.log(JSON.stringify({ level: 'info', message, timestamp: new Date().toISOString(), correlationId: getCorrelationId(), service: process.env.SERVICE_NAME, ...context })); } }; // 使用例 logger.info('Order created', { orderId: order.id, customerId: order.customerId, totalAmount: order.totalAmount });

2. メトリクス(Metrics)

Prometheusでサービスの健全性を監視します。

import { Counter, Histogram, Registry } from 'prom-client'; const registry = new Registry(); // リクエストカウンター const httpRequestsTotal = new Counter({ name: 'http_requests_total', help: 'Total number of HTTP requests', labelNames: ['method', 'path', 'status'], registers: [registry] }); // レスポンスタイム const httpRequestDuration = new Histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration in seconds', labelNames: ['method', 'path'], buckets: [0.1, 0.5, 1, 2, 5], registers: [registry] }); // ミドルウェアで計測 app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { const duration = (Date.now() - start) / 1000; httpRequestsTotal.inc({ method: req.method, path: req.path, status: res.statusCode }); httpRequestDuration.observe({ method: req.method, path: req.path }, duration); }); next(); });

3. トレース(Traces)

OpenTelemetryでリクエストの流れを可視化します。

import { trace, SpanKind } from '@opentelemetry/api'; const tracer = trace.getTracer('order-service'); async function createOrder(orderData: CreateOrderDto): Promise<Order> { return tracer.startActiveSpan('createOrder', async (span) => { try { span.setAttribute('order.customer_id', orderData.customerId); // 在庫確認(子スパン) const stock = await tracer.startActiveSpan('checkInventory', { kind: SpanKind.CLIENT, }, async (childSpan) => { const result = await inventoryService.checkStock(orderData.productId); childSpan.end(); return result; }); // 注文作成 const order = await orderRepository.create(orderData); span.setAttribute('order.id', order.id); return order; } catch (error) { span.recordException(error as Error); throw error; } finally { span.end(); } }); }

段階的移行の実践

Strangler Fig パターン

モノリスを一度に置き換えるのではなく、機能ごとに段階的に移行します。

【Phase 1: 並行運用】
                    ┌─────────────┐
                    │  API Gateway │
                    └──────┬──────┘
                           │
            ┌──────────────┼──────────────┐
            ▼              ▼              ▼
    ┌───────────┐   ┌───────────┐   ┌───────────┐
    │ Monolith  │   │ Monolith  │   │ New Order │
    │ (認証)    │   │ (在庫)    │   │ Service   │
    └───────────┘   └───────────┘   └───────────┘

【Phase 2: 機能移行】
                    ┌─────────────┐
                    │  API Gateway │
                    └──────┬──────┘
                           │
            ┌──────────────┼──────────────┐
            ▼              ▼              ▼
    ┌───────────┐   ┌───────────┐   ┌───────────┐
    │ New Auth  │   │ New Stock │   │   Order   │
    │ Service   │   │ Service   │   │  Service  │
    └───────────┘   └───────────┘   └───────────┘

移行の優先順位

  1. 依存が少ない機能から始める
  2. 変更頻度が高い機能を優先
  3. チームの境界に合わせて分割
  4. データの移行は慎重に計画

まとめ:成功するマイクロサービス導入

マイクロサービスは強力なアーキテクチャですが、万能ではありません。

導入前に確認すること

  1. 組織の準備: DevOps文化、自律的なチーム体制
  2. 技術の準備: コンテナ、CI/CD、監視基盤
  3. ドメインの理解: ビジネスドメインの深い理解

成功のための原則

  1. 小さく始める: 1つのサービスから
  2. 段階的に移行: Big Bangは避ける
  3. 観測可能性を確保: ログ、メトリクス、トレース
  4. 障害を前提に設計: Circuit Breaker、Retry、Timeout

マイクロサービスへの移行は、技術的な挑戦であると同時に、組織的な変革でもあります。焦らず、着実に進めることが成功への道です。

リソース


マイクロサービスへの移行でお困りの方は、お気軽にご相談ください。

ArchitectureDockerKubernetesAPIマイクロサービス