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
マイクロサービスへの移行は、技術的な挑戦であると同時に、組織的な変革でもあります。焦らず、着実に進めることが成功への道です。
リソース
マイクロサービスへの移行でお困りの方は、お気軽にご相談ください。
