Article

本当にスケールするマイクロサービス設計

高田晃太郎
本当にスケールするマイクロサービス設計

統計では、企業の約84%が本番でマイクロサービスを採用しつつ、運用の複雑さを主要課題に挙げています¹。CNCFの最新サーベイでは採用率の高さとともに、依存の連鎖と観測性不足が障壁だと報告されました²。DORAの研究でも、デプロイ頻度が高く変更のリードタイムが短い組織ほど顧客満足度と収益性が高いことが示されています³。各種の公開データを総合すると、成果を出す組織が共通しているのはサービスの分割そのものではなく、境界の切り方と計測の設計でした。本稿は、公開情報と一般的な実践に基づく提案であり、特定企業の実績紹介ではありません。

現場で見えてくる本質は、コンポーネントの数ではなく、変更が安全に独立して流れることです。専門用語を使わずに言えば、他のチームに遠慮せずに出荷できるように設計されているかどうかがすべてです。独立デプロイ、データの自律性、観測可能性、そして需要変動に耐える制御。この4点がそろって初めてスケールするアーキテクチャになります。

スケールはチームから始まる:境界づけられたコンテキストと独立デプロイ

サービスの粒度を議論する前に、チームの境界を先に固定するのが合理的です。コンウェイの法則が示す通り、組織構造はソフトウェアの構造に映ります⁴。大規模事例でも小さなオーナーシップ単位を保ち、製品の責任境界とサービス境界を一致させることが徹底されています⁵。ここで言う「境界づけられたコンテキスト」は、ドメインごとに責務を閉じ込める設計上の境界のことです。ビジネス能力ごとに責務を閉じ込め、API契約で外部と接続します。重要なのは、互換性のある契約と、破壊的変更の遮断です。スキーマの進化は常に追加互換(非破壊)で行い、削除や意味変更はバージョンを上げて段階的に移行します。

APIはドキュメントではなく契約です。契約にはバージョニングとスロットリング、そして冪等性(同じ操作を繰り返しても結果が変わらない性質)の要求が含まれます⁶。例えば注文APIで重複作成を防ぐには、冪等性キーを必須にして、同一キーは同一結果で応答するようにします。以下はNode.jsのExpressでの最小例です。OpenAPIでの仕様を先に固定し、実装はそれに従わせます。

openapi: 3.0.3
info:
  title: Order API
  version: 1.2.0
paths:
  /v1/orders:
    post:
      parameters:
        - in: header
          name: Idempotency-Key
          required: true
          schema:
            type: string
      responses:
        '201': { description: Created }
        '409': { description: Conflict }
import express, { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
import { Pool } from 'pg';

const app = express();
app.use(express.json());
const db = new Pool({ connectionString: process.env.DATABASE_URL });

app.post('/v1/orders', async (req: Request, res: Response, next: NextFunction) => {
  const key = req.header('Idempotency-Key');
  if (!key) return res.status(400).json({ error: 'Idempotency-Key required' });
  try {
    const client = await db.connect();
    try {
      await client.query('BEGIN');
      const found = await client.query('SELECT order_id, status FROM idempotency WHERE key = $1', [key]);
      if (found.rowCount) {
        return res.status(200).json({ orderId: found.rows[0].order_id, status: found.rows[0].status });
      }
      const orderId = randomUUID();
      await client.query('INSERT INTO orders(order_id, payload) VALUES($1, $2)', [orderId, req.body]);
      await client.query('INSERT INTO idempotency(key, order_id, status) VALUES($1, $2, $3)', [key, orderId, 'CREATED']);
      await client.query('COMMIT');
      return res.status(201).json({ orderId, status: 'CREATED' });
    } catch (e) {
      await client.query('ROLLBACK');
      if ((e as any).code === '23505') {
        return res.status(200).json({ replay: true });
      }
      return next(e);
    } finally {
      client.release();
    }
  } catch (err) {
    return next(err);
  }
});

app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  console.error(err);
  res.status(500).json({ error: 'internal_error' });
});

app.listen(process.env.PORT || 3000);

独立デプロイを守るために、依存を直接参照せず、契約テストと消費者駆動の契約を活用します⁷。上流の変更は契約で表現され、下流は自動テストで破壊を検知します。こうした仕組みがあると、チームは隣を気にせず高頻度でリリースでき、DORAの指標である変更リードタイムの短縮にもつながりやすくなります³。なお、運用ではモックとスタブの差異や破壊的変更の検出範囲を明確にし、テストの信頼性を維持することが肝要です。

データの自律性と整合性:トランザクション外へ責務を逃がす

スケールしないボトルネックの多くはデータの共有から生まれます。サービスのスキーマやテーブルを跨ぐ同期トランザクションは、レイテンシと故障領域を拡大します。現実的には、最終的整合性(短時間の不整合を許容してやがて整う設計)を受け入れる決断と、失敗に強い再実行可能なフローが必要です。SAGAパターンは分散トランザクションの代替となる設計で、ローカルトランザクションと補償(取り消し)を組み合わせますが、効果を出すにはアウトボックス、冪等性、補償の3点が揃って初めて機能します⁸。

アウトボックスはアプリケーションのトランザクション内にドメインイベントを書き出し、別プロセスが確実に配信します。以下はGoでPostgreSQLと併用する簡易実装です。

package main

import (
  "context"
  "database/sql"
  "encoding/json"
  "log"
  _ "github.com/lib/pq"
)

type Event struct {
  ID      string          `json:"id"`
  Type    string          `json:"type"`
  Payload json.RawMessage `json:"payload"`
}

func CreateOrder(ctx context.Context, db *sql.DB, order any, event Event) error {
  tx, err := db.BeginTx(ctx, &sql.TxOptions{})
  if err != nil { return err }
  defer func(){ if err != nil { _ = tx.Rollback() } }()
  if _, err = tx.ExecContext(ctx, "INSERT INTO orders(data) VALUES ($1)", order); err != nil { return err }
  payload, _ := json.Marshal(event)
  if _, err = tx.ExecContext(ctx, "INSERT INTO outbox(id, type, payload) VALUES ($1, $2, $3)", event.ID, event.Type, payload); err != nil { return err }
  if err = tx.Commit(); err != nil { return err }
  return nil
}

配信側は重複を許容し、少なくとも一度の配送を行います。KafkaやSQSを使う場合でも、コンシューマは冪等に振る舞う必要があります⁹。以下はKafkaでの単純なコンシューマ例です。

import { Kafka } from 'kafkajs';
import { Pool } from 'pg';

const kafka = new Kafka({ clientId: 'inventory', brokers: [process.env.KAFKA_BROKER!] });
const consumer = kafka.consumer({ groupId: 'inventory-g1' });
const db = new Pool({ connectionString: process.env.DATABASE_URL });

async function start() {
  await consumer.connect();
  await consumer.subscribe({ topic: 'order.created', fromBeginning: false });
  await consumer.run({
    eachMessage: async ({ message }) => {
      const id = message.key?.toString() || '';
      const payload = message.value ? JSON.parse(message.value.toString()) : {};
      const client = await db.connect();
      try {
        await client.query('BEGIN');
        const seen = await client.query('SELECT 1 FROM event_log WHERE id=$1', [id]);
        if (!seen.rowCount) {
          await client.query('UPDATE stock SET qty = qty - $1 WHERE sku=$2', [payload.qty, payload.sku]);
          await client.query('INSERT INTO event_log(id) VALUES($1)', [id]);
        }
        await client.query('COMMIT');
      } catch (e) {
        await client.query('ROLLBACK');
        console.error('consume_error', e);
        throw e; // trigger retry
      } finally {
        client.release();
      }
    }
  });
}

start().catch(err => {
  console.error('fatal', err);
  process.exit(1);
});

補償トランザクションは必ずしも元に完全に戻す必要はありません。顧客体験の観点で無害化できれば十分な場合が多いのです。たとえば支払いが成功し在庫で失敗した場合、在庫の再確保を一定時間試み、それでもだめなら自動返金に切り替えます。このような経路分岐は業務SLOに基づく優先度で決めます。アウトボックスと冪等なコンシューマを整えておくと、ピーク時のテールレイテンシが抑制され、再送風暴時の二重処理のリスクも大きく低減できます。実運用では、再実行と補償の設計がユーザー体験とコストに直結するため、監査ログと可観測性とセットで考えるのが安全です。

可観測性が設計を磨く:SLOとトレーシングで制御する

スケールは推測では実現できません。SLO(サービスレベル目標)が示す許容範囲と、トレーシングが描く因果関係が必要です。SLOは顧客中心のメトリクスで定義し、例えば「週次でp95レイテンシ300ms以下、可用性99.9%、エラー率0.1%未満」のように、明確な数値と測定方法を合意します。エラーバジェットは変化の速度を調整するダイヤルとして機能させます¹⁰。観測基盤はメトリクス、ログ、トレースを統合し、サービスグラフとサンプリングポリシーを明示します。

以下はNode.jsでOpenTelemetryを用いたトレーシング初期化の例です。OTLPでコレクタに送信し、ヘッダの伝播を有効にします¹¹。

import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'order-api',
    [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV || 'dev'
  }),
  traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_URL }),
  instrumentations: [getNodeAutoInstrumentations()]
});

sdk.start().then(() => console.log('otel started')).catch(err => console.error(err));

レイテンシ分布はヒストグラムで持つのが実用的です。GoでPrometheusのヒストグラムを使ってp95をモニタし、バックプレッシャーの判断材料にします¹²。

package metrics

import (
  "net/http"
  "time"
  "github.com/prometheus/client_golang/prometheus"
  promhttp "github.com/prometheus/client_golang/prometheus/promhttp"
)

var RequestLatency = prometheus.NewHistogram(prometheus.HistogramOpts{
  Name:    "http_request_latency_ms",
  Help:    "Request latency",
  Buckets: []float64{50, 100, 200, 300, 500, 1000, 2000},
})

func Instrument(h http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    h.ServeHTTP(w, r)
    elapsed := time.Since(start).Milliseconds()
    RequestLatency.Observe(float64(elapsed))
  })
}

func Init() {
  prometheus.MustRegister(RequestLatency)
  http.Handle("/metrics", promhttp.Handler())
}

トレースとメトリクスが揃うと、ボトルネックの正体が露わになります。たとえば外部支払いゲートウェイがp95で大きな割合を占めているなら、キャッシュやプリオーソライズ、あるいはキューイングでピークを平準化する選択肢が見えます。運用では、スロットリングで外部依存の同時呼び出しを制限し、内部のタイムアウトを短めに設定することで、タイムアウト由来のエラー率を抑制できるケースが多く報告されています。重要なのは、観測の結果に基づいて意思決定をするという当たり前のサイクルを、仕組みとして常態化させることです。

需要変動に勝つネットワーク設計:バックプレッシャー、レート制限、セルベース

本当にスケールする設計は、負荷を受け止めずに断る勇気を持っています。バックプレッシャーはシステムを守るための最初の盾です。リソースが飽和し始めたら受付を遅延または拒否し、キューで平準化し、優先度に応じて落とすべきものは落とします。トークンバケット(一定レートで補充されるトークンの消費でリクエストを制御する方式)ベースのレート制限と、サーキットブレーカーは必須のプリミティブです¹³。以下はGoのHTTPミドルウェアでの簡易実装例です。

package ratelimit

import (
  "net/http"
  "time"
  "golang.org/x/time/rate"
)

type Limiter struct {
  r *rate.Limiter
}

func New(rps int, burst int) *Limiter {
  return &Limiter{r: rate.NewLimiter(rate.Limit(rps), burst)}
}

func (l *Limiter) Middleware(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    if !l.r.Allow() {
      w.WriteHeader(http.StatusTooManyRequests)
      w.Write([]byte("rate_limited"))
      return
    }
    c := make(chan struct{}, 1)
    go func() { next.ServeHTTP(w, r); c <- struct{}{} }()
    select {
    case <-c:
      return
    case <-time.After(300 * time.Millisecond):
      w.WriteHeader(http.StatusGatewayTimeout)
      w.Write([]byte("timeout"))
      return
    }
  })
}

セルベースアーキテクチャは、同一のスタックを複製して水平に並べる思想です。顧客群やリージョンでセルを分割し、故障範囲を限定します。各セルは独立にデプロイされ、グローバルルーターはヘルスに応じてルーティングを切り替えます。セルが増えるほどキャパシティは上がりますが、各セルは小さいままなので認知負荷は増えません¹⁴。Kubernetesで実現する場合、オートスケールの信号はCPUだけでなく、キュー長やp95レイテンシなどのSLOに紐づくメトリクスを使うべきです¹⁵。以下はカスタムメトリクスによるHPAの例です。

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-api
  minReplicas: 3
  maxReplicas: 50
  metrics:
    - type: Pods
      pods:
        metric:
          name: http_request_latency_p95_ms
        target:
          type: AverageValue
          averageValue: 300m

ネットワークの堅牢性はリリースの安全性にも直結します。ゼロダウンタイムのために、逐次ロールアウトとヘルスチェック、コネクションドレイン、古いバージョンとの並走時間を十分に確保します。リバースプロキシやサービスメッシュのレイヤで、コネクションタイムアウト・リトライ・サーキットブレークの三点を明示的に設定し、アプリ側は適切なタイムアウトとキャンセル伝播を実装します。Envoyでアウトバウンドのタイムアウトを設定する例を示します¹⁶。

static_resources:
  clusters:
  - name: payment
    connect_timeout: 0.1s
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: payment
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: { address: payment, port_value: 8080 }
    circuit_breakers:
      thresholds:
      - max_connections: 1000
        max_pending_requests: 1000
        max_requests: 2000
    typed_extension_protocol_options:
      envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
        '@type': type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
        common_http_protocol_options:
          idle_timeout: 10s
    outlier_detection:
      consecutive_5xx: 5
      interval: 2s
      base_ejection_time: 30s

こうした制御は机上の理屈ではなく、計測で裏づける必要があります。例えば、同等のリソース構成でレート制限とキュー平準化を導入すると、一般にp95レイテンシの改善やスループット向上が観測されますが、効果はワークロード特性と外部依存に大きく左右されます。指標を可視化し、変更前後の差分をトレースで確認することが、継続的な最適化の近道です。

コストとROI:スケールの経済性を設計に織り込む

最後に、ビジネス価値です。スケールの定義を「ピークに耐える」から「最小コストで可用性と俊敏性を両立させる」に置き換えます。セルごとのコストとSLO違反ペナルティを一枚の表に落とし、エラーバジェット消費率が高い領域に投資を集中します。セル分割やアウトボックス導入は、数スプリント規模の投資で回し始められることが多く、DevOpsの実践が顧客満足や収益性の改善と相関するという報告もあります³。大きな経済的リターンが示された公開事例もあります¹⁷。変更の速さが競争優位を生むという当たり前の事実を、設計と運用の仕組みに焼き付けることが、最終的なROIを最大化します。

実装を継続可能にするガバナンス:標準・テンプレート・安全な既定値

マイクロサービスは多様性を飲み込みますが、無制限の自由はチームを疲弊させます。標準を固定し、テンプレート化し、安全な既定値で始めることで、変えてよいところと変えてはいけないところを明確にします。API契約、Observability、リリース戦略、レート制限、タイムアウトはプラットフォーム側でプリセットします。サービス作成時に既にOpenTelemetry、Prometheus、ヘルスエンドポイント、緩やかなデフォルトタイムアウトが組み込まれていれば、個々のチームはドメインの価値に集中できます。以下はサービス雛形のヘルスチェックと終了フックの例です。

import express from 'express';
import http from 'http';

const app = express();
app.get('/healthz', (_req, res) => res.status(200).send('ok'));

const server = http.createServer(app);
server.listen(process.env.PORT || 3000, () => console.log('up'));

process.on('SIGTERM', () => {
  server.close(err => {
    if (err) {
      console.error('graceful_shutdown_error', err);
      process.exit(1);
    }
    console.log('graceful_shutdown_complete');
    process.exit(0);
  });
});

こうした雛形とプラットフォームの整備は、個々のサービスの設計論よりも、実はスケールに効きます。標準化された足場の上で、各チームが独立して進化できるからです。ガバナンスはレビュー文化と相性がよく、軽量なアーキテクチャレビューを定期運用に組み込むのが効果的です。レビューの目的は統制ではなく、学習とリスクの早期検出にあります。

まとめ:小さく速く、安全に繰り返す設計へ

スケールするマイクロサービスは、技術的なトリックの集合ではありません。独立デプロイを前提にした境界の設計、データの自律と冪等性、SLOと観測に基づく意思決定、そして需要変動に耐える制御。この4点が連携したとき、チームは自信を持って小さく速く出荷できます。今日できる最初の一歩として、契約テストの導入、アウトボックスの実装、OpenTelemetryの有効化、そしてタイムアウトとレート制限の明文化から始めてください。

あなたの組織では、どのボトルネックが最も価値の毀損を引き起こしていますか。計測し、合意し、改善する。このシンプルな循環を回しやすくする設計こそが、本当にスケールするアーキテクチャです。次のスプリントで、ひとつだけでも仕組みを仕込み、数値で効果を確かめてみましょう。

参考文献

  1. Kong. Digital Innovation Benchmark (2021)
  2. CNCF. Cloud Native Observability Microsurvey: Prometheus leads the way, but hurdles remain (2022)
  3. DORA. Accelerate State of DevOps Reports (2018–2023)
  4. Conway, M. How Do Committees Invent? (1968)
  5. InfoQ. Microservices at Amazon (2015)
  6. Stripe. Idempotent Requests (Idempotency Keys) (Docs)
  7. Pact. Contract testing and Consumer-Driven Contracts (Docs)
  8. InfoQ. Saga, Orchestration, and the Outbox Pattern (2020)
  9. Apache Kafka. Transactional/Exactly-Once Semantics (Docs)
  10. Google SRE. Service Level Objectives (SRE Book)
  11. OpenTelemetry. JavaScript/Node.js instrumentation (Docs)
  12. Prometheus. Histogram and Summary best practices (Docs)
  13. AWS Builders’ Library. Timeouts, retries, and backoff with jitter (2020)
  14. AWS Builders’ Library. Cell-based architectures (2019)
  15. Kubernetes. Horizontal Pod Autoscaling with custom metrics (Docs)
  16. Envoy Proxy. Circuit breaking, outlier detection, and timeouts (Docs)
  17. Google Cloud. Show me the money: returns up to $259M with a DevOps transformation (2021)