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チーム1サービス(またはn サービス)
- データの所有権: 各サービスが自分のデータを持つ
アンチパターン:避けるべき分割
// ❌ 技術レイヤーでの分割(やってはいけない) // - 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 │
└───────────┘ └───────────┘ └───────────┘
移行の優先順位
- 依存が少ない機能から始める
- 変更頻度が高い機能を優先
- チームの境界に合わせて分割
- データの移行は慎重に計画
まとめ:成功するマイクロサービス導入
マイクロサービスは強力なアーキテクチャですが、万能ではありません。
導入前に確認すること
- 組織の準備: DevOps文化、自律的なチーム体制
- 技術の準備: コンテナ、CI/CD、監視基盤
- ドメインの理解: ビジネスドメインの深い理解
成功のための原則
- 小さく始める: 1つのサービスから
- 段階的に移行: Big Bangは避ける
- 観測可能性を確保: ログ、メトリクス、トレース
- 障害を前提に設計: Circuit Breaker、Retry、Timeout
マイクロサービスへの移行は、技術的な挑戦であると同時に、組織的な変革でもあります。焦らず、着実に進めることが成功への道です。
リソース
マイクロサービスへの移行でお困りの方は、お気軽にご相談ください。
