NodeJS 速習チュートリアル

Node.js マイクロサービス

1. マイクロサービス入門

マイクロサービスとは、アプリケーションを小さく疎結合(loosely coupled)なサービスの集合体として構築するアーキテクチャスタイルのことです。各サービスは以下の特徴を持ちます:

  • 単一のビジネス機能に特化している
  • 独立してデプロイが可能
  • 独立してスケーリングが可能
  • 異なるプログラミング言語で記述できる可能性がある
  • 異なるデータストレージ技術を使用できる可能性がある

マイクロサービスアーキテクチャは、従来のモノリス(Monolithic)なアプリケーションと比較して、開発サイクルの高速化、優れたスケーラビリティ、そしてレジリエンス(回復力)の向上を実現します。

1.1 モノリス vs マイクロサービス

項目モノリシック・アーキテクチャマイクロサービス・アーキテクチャ
構造単一の統合されたコードベース複数の小さなサービスの集合
デプロイアプリケーション全体を一度にデプロイサービスごとに独立してデプロイ
スケーリングアプリケーション全体をスケーリング個別のサービスごとにスケーリング
開発単一のテクノロジースタックサービスごとに異なる技術を選択可能
チーム構造通常は単一のチームサービスごとに複数のチームが所有
複雑性アーキテクチャは単純だが、コードベースが複雑アーキテクチャは複雑だが、個別のコードベースは単純

2. 主要な設計原則

  • 単一責任(Single Responsibility) - 各マイクロサービスは一つのことをうまく行うことに集中し、単一のビジネス機能を実装すべきです。
  • 分散化(Decentralization) - ガバナンス、データ管理、アーキテクチャ上の決定など、あらゆるものを分散化させます。
  • 自律的なサービス(Autonomous Services) - サービスは他へ影響を与えることなく、独立して変更およびデプロイできる必要があります。
  • ドメイン駆動設計(Domain-Driven Design) - 技術的な機能ではなく、ビジネスドメインを中心にサービスを設計します。
  • レジリエンス(Resilience) - 他のサービスが故障しても、その影響を最小限に抑えるよう設計すべきです。
  • オブザーバビリティ(Observability) - サービス全体にわたる包括的なモニタリング、ロギング、トレーシングを実装します。

       ベストプラクティス: アプリケーションをマイクロサービスに分割する前に、明確なドメインモデルを構築し、コンテキスト境界(Bounded Context)を特定することから始めてください。

3. マイクロサービスにおける Node.js の優位性

Node.js は、以下の理由からマイクロサービスアーキテクチャに非常に適しています:

  • 軽量かつ高速 - Node.js はフットプリントが小さく起動が速いため、迅速なスケーリングが必要なマイクロサービスに理想的です。
  • 非同期かつイベント駆動 - ノンブロッキング I/O モデルにより、サービス間の多数の同時接続を効率的に処理できます。
  • JSON サポート - JSON をネイティブにサポートしているため、マイクロサービス間のデータ交換がスムーズです。
  • NPM エコシステム - 膨大なパッケージエコシステムにより、サービスディスカバリ、API ゲートウェイ、モニタリングなどのライブラリが容易に入手できます。

4. 実装例:シンプルな Node.js マイクロサービス

// user-service.js
const express = require('express');
const app = express();

app.use(express.json());

// デモンストレーション用のインメモリ・ユーザーデータベース
const users = [
  { id: 1, name: 'John Doe', email: '[email protected]' },
  { id: 2, name: 'Jane Smith', email: '[email protected]' }
];

// すべてのユーザーを取得
app.get('/users', (req, res) => {
  res.json(users);
});

// IDでユーザーを取得
app.get('/users/:id', (req, res) => {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) return res.status(404).json({ message: 'ユーザーが見つかりません' });
  res.json(user);
});

// 新規ユーザーの作成
app.post('/users', (req, res) => {
  const newUser = {
    id: users.length + 1,
    name: req.body.name,
    email: req.body.email
  };
  users.push(newUser);
  res.status(201).json(newUser);
});

const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
  console.log(`User サービスがポート ${PORT} で稼働中`);
});

5. サービス間通信

マイクロサービス同士が通信するための手法には、大きく分けて 2 つのアプローチがあります。

5.1 同期通信

サービスが互いの API を直接呼び出し、リアルタイムのリクエスト・レスポンスフローを作成します。

  • REST: シンプルで広く使われているステートレスな通信
  • GraphQL: 単一のエンドポイントによる柔軟なクエリ
  • gRPC: Protocol Buffers を使用した高性能な RPC フレームワーク

例:サービス間の REST 通信

// order-service.js から user-service を呼び出す
const axios = require('axios');

async function getUserDetails(userId) {
  try {
    const response = await axios.get(`http://user-service:3001/users/${userId}`);
    return response.data;
  } catch (error) {
    console.error(`ユーザー ${userId} の取得中にエラーが発生しました:`, error.message);
    throw new Error('User サービスが利用不可です');
  }
}

// Order サービスのルートハンドラ
app.post('/orders', async (req, res) => {
  const { userId, products } = req.body;
  
  try {
    // User サービスからユーザーデータを取得
    const user = await getUserDetails(userId);
    
    // Product サービスから商品の在庫状況を確認
    const productStatus = await checkProductAvailability(products);
    
    if (!productStatus.allAvailable) {
      return res.status(400).json({ error: '一部の商品が在庫切れです' });
    }
    
    // 注文を作成
    const order = await createOrder(userId, products, user.shippingAddress);
    
    res.status(201).json(order);
  } catch (error) {
    console.error('注文の作成に失敗しました:', error);
    res.status(500).json({ error: '注文の作成に失敗しました' });
  }
});

       注意: 同期通信はサービス間に直接的な依存関係を作ります。呼び出し先のサービスがダウンしたり遅延したりすると、呼び出し元も影響を受け、連鎖的な失敗(cascading failure)を引き起こす可能性があります。

5.2 非同期通信

サービスはメッセージブローカーやイベントバスを介して通信し、即時のレスポンスを待ちません。

  • メッセージキュー: RabbitMQ, ActiveMQ など、ポイント・ツー・ポイントのメッセージング
  • Pub/Sub: Kafka, Redis Pub/Sub など、複数のサブスクライバーへのメッセージ配信
  • イベントストリーミング: Kafka, AWS Kinesis など、データストリームの処理

例:イベントバスを使用したイベント駆動型通信

// order-service.js がイベントを発行する例
const axios = require('axios');

async function publishEvent(eventType, data) {
  try {
    await axios.post('http://event-bus:3100/events', {
      type: eventType,
      data: data,
      source: 'order-service',
      timestamp: new Date().toISOString()
    });
    console.log(`イベントを発行しました: ${eventType}`);
  } catch (error) {
    console.error(`イベント ${eventType} の発行に失敗しました:`, error.message);
    // リトライのために失敗したイベントを保存
    storeFailedEvent(eventType, data, error);
  }
}

// 注文を作成しイベントを発行
app.post('/orders', async (req, res) => {
  try {
    const order = await createOrder(req.body);
    
    // 他のサービスのためにイベントを発行
    await publishEvent('order.created', order);
    
    res.status(201).json(order);
  } catch (error) {
    res.status(500).json({ error: '注文の作成に失敗しました' });
  }
});

6. サービス障害のハンドリング

マイクロサービスでは、通信障害を処理するための戦略が必要です。

パターン説明使用場面
サーキットブレーカー故障中のサービスへのリクエストを一時的に停止し、連鎖的な失敗を防ぐ依存しているサービスが故障した際にシステムを保護する場合
リトライ (Backoff 付)失敗したリクエストを、遅延時間を増やしながら自動的に再試行する短時間で解決する可能性のある一時的な障害の場合
タイムアウト・パターンレスポンスを待機する最大時間を設定する遅いサービスによってスレッドがブロックされるのを防ぐ場合
バルクヘッド・パターン障害を隔離し、リソース全体が使い果たされるのを防ぐコンポーネント内で障害を封じ込める場合
フォールバック・パターンサービスが失敗した際に代替のレスポンスを提供する障害時でも最低限の機能を維持する場合

例:サーキットブレーカーの実装

const CircuitBreaker = require('opossum');

// サーキットブレーカーの設定
const options = {
  failureThreshold: 50,        // リクエストの 50% が失敗したらオープンにする
  resetTimeout: 10000,         // 10秒後に再試行
  timeout: 8080,               // 失敗とみなすまでの時間
  errorThresholdPercentage: 50 // サーキットをオープンにするエラー率
};

// User サービス用のサーキットブレーカーを作成
const getUserDetailsBreaker = new CircuitBreaker(getUserDetails, options);

// 状態変化のリスナー
getUserDetailsBreaker.on('open', () => {
  console.log('Circuit OPEN - User サービスがダウンしているようです');
});

getUserDetailsBreaker.on('halfOpen', () => {
  console.log('Circuit HALF-OPEN - User サービスをテスト中');
});

getUserDetailsBreaker.on('close', () => {
  console.log('Circuit CLOSED - User サービスが復旧しました');
});

// ルートハンドラでの使用
app.get('/orders/:orderId', async (req, res) => {
  const orderId = req.params.orderId;
  const order = await getOrderById(orderId);
  
  try {
    // サーキットブレーカー経由で User サービスを呼び出す
    const user = await getUserDetailsBreaker.fire(order.userId);
    res.json({ order, user });
  } catch (error) {
    // サーキットがオープンの場合や呼び出しに失敗した場合はフォールバックデータを返す
    console.error('ユーザー情報の取得に失敗しました:', error.message);
    res.json({
      order,
      user: { id: order.userId, name: 'ユーザー情報利用不可' }
    });
  }
});

7. API ゲートウェイ・パターン

API ゲートウェイは、クライアントからマイクロサービスアーキテクチャへのすべてのリクエストの単一のエントリポイントとして機能します。

API ゲートウェイの責務:

  • リクエストルーティング: クライアントのリクエストを適切なサービスに転送
  • API 集約: 複数のサービスからのレスポンスを統合
  • プロトコル変換: HTTP から gRPC への変換など
  • 認証と認可: セキュリティ機能の一元管理
  • レート制限: API の乱用を防止
  • モニタリングとロギング: API 使用状況の可視化

例:API ゲートウェイの実装

const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');

const app = express();
const PORT = 8080;

// セキュリティヘッダーの追加
app.use(helmet());

// レート制限の適用
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分
  max: 100,                // 各 IP を 15 分間に 100 リクエストに制限
  message: 'リクエストが多すぎます。しばらくしてから再度お試しください。'
});
app.use('/api/', apiLimiter);

// 認証ミドルウェア
function authenticate(req, res, next) {
  const token = req.headers.authorization;
  if (!token) {
    return res.status(401).json({ error: '未認証' });
  }
  // 本来はここでトークンの検証ロジックを実装
  next();
}

// サービスレジストリ(簡略化のためハードコード)
const serviceRegistry = {
  userService: 'http://localhost:3001',
  productService: 'http://localhost:3002',
  orderService: 'http://localhost:3003'
};

// 各サービス用のプロキシミドルウェア定義
app.use('/api/users', authenticate, createProxyMiddleware({
  target: serviceRegistry.userService,
  changeOrigin: true,
  pathRewrite: { '^/api/users': '/users' }
}));

app.use('/api/products', createProxyMiddleware({
  target: serviceRegistry.productService,
  changeOrigin: true,
  pathRewrite: { '^/api/products': '/products' }
}));

app.use('/api/orders', authenticate, createProxyMiddleware({
  target: serviceRegistry.orderService,
  changeOrigin: true,
  pathRewrite: { '^/api/orders': '/orders' }
}));

app.listen(PORT, () => console.log(`API Gateway がポート ${PORT} で稼働中`));

       ベストプラクティス: 本番環境では、自作する代わりに Kong, Netflix Zuul, あるいはクラウドソリューションの AWS API Gateway などの専用の API ゲートウェイを使用することを検討してください。

8. サービスディスカバリ

サービスディスカバリを使用すると、マイクロサービスがエンドポイントをハードコードすることなく、動的に互いを見つけて通信できるようになります。

メソッド説明
クライアントサイド・ディスカバリクライアントがサービスレジストリに問い合わせて場所を特定し、自ら負荷分散を行う
サーバーサイド・ディスカバリクライアントはルーター/ロードバランサーを呼び出し、それがサービスインスタンスの発見を行う
DNS ベース・ディスカバリDNS SRV レコードなどを介してサービスを発見する

代表的なサービスディスカバリツール:

  • Consul: サービス発見と構成管理
  • etcd: 分散キーバリューストア
  • Kubernetes Service Discovery: Kubernetes 内蔵の機能

9. データ管理戦略

マイクロサービスアーキテクチャでは、データ管理においてモノリスとは異なるアプローチが必要です。

9.1 サービスごとのデータベース (Database Per Service)

各マイクロサービスが専用のデータベースを持ち、疎結合と独立したスケーリングを確保します。各サービスは、ニーズに合わせて最適な DB 技術(SQL, NoSQL, Graph など)を選択できます。

9.2 分散トランザクション(Saga パターン)

ACID トランザクションなしでサービス間のデータ一貫性を維持するためのパターンです。ローカルトランザクションを連鎖させ、各ステップでイベントを発行して次の処理をトリガーします。失敗した場合は「補償トランザクション」を実行して元の状態に戻します。

9.3 イベントソーシングと CQRS

  • イベントソーシング: アプリケーションの状態変更をすべてイベントのシーケンスとして保存します。
  • CQRS: 読み取り(Query)と書き込み(Command)の操作を分離し、パフォーマンスとスケーラビリティを向上させます。

10. デプロイ戦略

  • コンテナ化 (Docker): 各サービスに一貫した環境を提供します。
  • オーケストレーション (Kubernetes): コンテナ化されたサービスのデプロイ、スケーリング、管理を自動化します。
  • CI/CD: 個別のサービスごとにテストとデプロイを自動化します。
  • Infrastructure as Code (Terraform): インフラを宣言的に定義します。
ベストプラクティス: デプロイ時のリスクとダウンタイムを最小限にするため、ブルーグリーンデプロイやカナリアデプロイ戦略を採用してください。

11. 高度なマイクロサービス・パターン

11.1 Saga パターンの実装例

// order-saga.js
class OrderSaga {
  constructor(orderId) {
    this.orderId = orderId;
    this.steps = [];
    this.compensations = [];
  }

  addStep(execute, compensate) {
    this.steps.push(execute);
    this.compensations.unshift(compensate); // 補償処理は逆順で実行
    return this;
  }

  async execute() {
    const executedSteps = [];
    try {
      for (const [index, step] of this.steps.entries()) {
        await step();
        executedSteps.push(index);
      }
      return { success: true };
    } catch (error) {
      console.error('Saga の実行に失敗しました。補償処理を開始します...', error);
      await this.compensate(executedSteps);
      return { success: false, error };
    }
  }

  async compensate(executedSteps) {
    for (const stepIndex of executedSteps) {
      try {
        await this.compensations[stepIndex]();
      } catch (compError) {
        console.error('補償処理に失敗しました:', compError);
      }
    }
  }
}

12. マイクロサービスのセキュリティ

  1. サービス間認証: JWT (JSON Web Token) などを使用して、信頼できるサービス間のみの通信を許可します。
  2. レート制限 (Rate Limiting): Redis などをストアとして使用し、過剰なリクエストからサービスを保護します。

13. モニタリングとオブザーバビリティ

  1. 分散トレーシング (OpenTelemetry): 複数のサービスにまたがるリクエストの軌跡を可視化します。
  2. 構造化ロギング (Winston/Pino): JSON 形式でログを出力し、ELK スタックなどで解析しやすくします。
// logger.js の例
const winston = require('winston');
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  defaultMeta: { service: 'user-service' },
  transports: [new winston.transports.Console()],
});

14. まとめ

Node.js によるマイクロサービスアーキテクチャは、スケーラブルでレジリエントなシステムを構築するための強力な手段です:

  • サービスをビジネスドメインに基づいて分割する。
  • API ゲートウェイでエントリポイントを一元化する。
  • 非同期通信を活用して疎結合を維持する。
  • サーキットブレーカーなどのパターンで障害耐性を高める。
  • 分散トレーシングと構造化ロギングで可視性を確保する。

これらを適切に組み合わせることで、現代的なクラウドネイティブアプリケーションの要求に応える高性能なシステムを実現できます。