マイクロサービスで最初の半年が地獄だった話|3年運用してわかった境界設計の現実

「サービスを細かく分ければ正義」と信じて失敗した話です。障害対応が3倍になった経験から学んだドメイン境界設計・サービスメッシュの実践知見をまとめました。

マイクロサービスの設計を最初に任されたのは3年前のことで、当時の自分はとにかく「サービスを細かく分ければ正義」だと思っていた。結果として最初の半年は地獄だった。デプロイ頻度は上がったけど障害対応時間は3倍になって、チームのモラルは最悪で、SREの同僚に「モノリスに戻したほうがいいんじゃないか」と半ば本気で言われた記憶がある。あれから3年、今は10サービス・3チームが協力して運用できる状態まで持ってこれたので、その過程で学んだことを整理してみる。

ドメイン境界設計がすべての土台になる

マイクロサービスで最初につまずくのって、だいたい「どこで切るか」問題なんですよね。技術的な境界(データベースの種類とか、言語とか)で分けると後で死ぬ。うちのチームも最初はそれをやって、1つの「注文確定」というユーザーアクションが6サービスにまたがって、分散トランザクションの嵐になった。

2026年時点でDDD(ドメイン駆動設計)のコンテキストマッピングが改めて注目されているのは、結局ここに戻ってくるからだと思う。うちが実際に使っているアプローチはこんな感じだ。

# ドメインモデリングワークショップのアウトプット例
# bounded_contexts.yaml

contexts:
  - name: OrderManagement
    responsibilities:
      - 注文受付・変更・キャンセル
      - 注文ステータス管理
    owns_data:
      - orders
      - order_items
    upstream_contexts:
      - CustomerManagement  # 顧客情報参照
      - ProductCatalog      # 商品情報参照
    downstream_contexts:
      - Payment             # 決済起動
      - Fulfillment         # 出荷指示
    integration_pattern: "Event-Driven (Published Language)"
    team: team-commerce

  - name: Payment
    responsibilities:
      - 決済処理・返金
      - 決済ステータス管理
    owns_data:
      - payment_transactions
      - refunds
    upstream_contexts:
      - OrderManagement
    integration_pattern: "Conformist + Anti-Corruption Layer"
    team: team-payment

このYAMLを実際にドキュメントとして管理しているのがポイントで、コンテキスト境界が曖昧になりかけたらここに立ち返るようにしている。特に owns_data の定義が重要で、他のサービスが直接DBを参照するのを防ぐ番人役になる。

境界設計の判断基準として、うちのチームが使っているヒューリスティクスはこんな感じ:

判断基準モノリス向きマイクロサービス向き
変更頻度一緒に変わることが多い独立して変わる
チーム所有同じチームが担当別チームが担当
スケール要件同じスケールでOK独立してスケールしたい
障害分離一緒に落ちてもOK独立した可用性が必要
データ整合性ACIDが必要結果整合性で許容できる

正直これも「絶対の正解」はなくて、チームの成熟度やビジネスの状況によって変わる。小さく始めて育てていくのが現実的だと思う。モノレポ運用ガイドの記事でも触れているけど、最初はモノレポ構成で境界を論理的に分けておいて、必要になったら物理分割するアプローチが失敗が少ない。

サービス間通信の設計で失敗した話

サービス間通信は同期(REST/gRPC)と非同期(イベント駆動)の使い分けが肝で、ここを間違えると後からリファクタリングが大変になる。うちは最初ほぼ全部REST APIで繋いでいたんだけど、サービスが増えるにつれて「連鎖障害」が頻発した。

たとえばOrderServiceがPaymentServiceを同期呼び出しして、PaymentServiceがExternalPSPを呼んで、PSPが遅くなるとOrderServiceまで詰まる、みたいなやつ。Bulkheadパターンを入れる前は本当に地獄だった。

// Circuit BreakerとBulkhead実装例(Go 1.25 + sony/gobreaker)
package payment

import (
    "context"
    "time"
    
    "github.com/sony/gobreaker"
    "golang.org/x/sync/semaphore"
)

type PaymentClient struct {
    cb         *gobreaker.CircuitBreaker
    semaphore  *semaphore.Weighted
    httpClient *http.Client
}

func NewPaymentClient() *PaymentClient {
    settings := gobreaker.Settings{
        Name:        "payment-service",
        MaxRequests: 3,
        Interval:    10 * time.Second,
        Timeout:     30 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            // 失敗率60%以上 かつ 最低10リクエスト以上でトリップ
            failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
            return counts.Requests >= 10 && failureRatio >= 0.6
        },
        OnStateChange: func(name string, from, to gobreaker.State) {
            // 状態変化をメトリクスに記録
            circuitBreakerStateChanges.WithLabelValues(name, to.String()).Inc()
        },
    }
    
    return &PaymentClient{
        cb:        gobreaker.NewCircuitBreaker(settings),
        // Bulkhead: 同時接続数を20に制限
        semaphore: semaphore.NewWeighted(20),
        httpClient: &http.Client{Timeout: 5 * time.Second},
    }
}

func (c *PaymentClient) ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) {
    // Bulkheadによる同時接続数制限
    if err := c.semaphore.Acquire(ctx, 1); err != nil {
        return nil, fmt.Errorf("payment service: too many concurrent requests: %w", err)
    }
    defer c.semaphore.Release(1)
    
    result, err := c.cb.Execute(func() (interface{}, error) {
        return c.callPaymentAPI(ctx, req)
    })
    if err != nil {
        return nil, fmt.Errorf("payment service: circuit breaker: %w", err)
    }
    
    return result.(*PaymentResponse), nil
}

2026年現在、このあたりのパターンはサービスメッシュ(Istio / Linkerd)に委譲するのがトレンドになってきている。アプリケーションコードにCircuit Breakerを書かなくても、サービスメッシュのレベルで制御できるので、実装の重複がなくなるのはメリットが大きい。

# Istio 1.22 DestinationRule でのCircuit Breaker設定
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: payment-service-circuit-breaker
spec:
  host: payment-service.production.svc.cluster.local
  trafficPolicy:
    connectionPool:
      tcp:
        maxConnections: 100
      http:
        http1MaxPendingRequests: 50
        http2MaxRequests: 200
        maxRequestsPerConnection: 10
    outlierDetection:
      # 5xx エラーが5回続いたらサーキットブレーカー発動
      consecutiveGatewayErrors: 5
      interval: 10s
      baseEjectionTime: 30s
      maxEjectionPercent: 50
      minHealthPercent: 30

ただ正直、サービスメッシュはそれ自体がかなりの運用コストになる。Istioを本番で運用するなら、コントロールプレーンの監視・アップグレード戦略・デバッグの難しさを覚悟した方がいい。うちのチームは最初Linkerdから始めて、機能が必要になったらIstioに移行したけど、移行は結構しんどかった。

アーキテクチャ全体のイメージはこんな感じ:

flowchart TB
    subgraph Client Layer
        WebApp["🌐 Web App"]
        MobileApp["📱 Mobile App"]
    end

    subgraph API Gateway Layer
        Gateway["API Gateway\n(Kong / AWS APIGW)"]
    end

    subgraph Service Mesh Layer [Service Mesh - Istio 1.22]
        subgraph Order Domain
            OrderSvc["Order Service\n(Go 1.25)"]
            OrderDB[("Orders DB\nPostgreSQL 17")]
        end

        subgraph Payment Domain
            PaymentSvc["Payment Service\n(Go 1.25)"]
            PaymentDB[("Payment DB\nPostgreSQL 17")]
        end

        subgraph Fulfillment Domain
            FulfillSvc["Fulfillment Service\n(Kotlin)"]
            FulfillDB[("Fulfillment DB\nMySQL 8.4")]
        end

        subgraph Customer Domain
            CustomerSvc["Customer Service\n(Go 1.25)"]
            CustomerDB[("Customer DB\nPostgreSQL 17")]
        end
    end

    subgraph Event Bus
        Kafka["Apache Kafka\n(MSK 3.7)"]
    end

    subgraph Observability
        Otel["OpenTelemetry\nCollector"]
        Tempo["Grafana Tempo\n(Distributed Tracing)"]
        Prometheus["Prometheus\n+ Alertmanager"]
        Grafana["Grafana 11"]
    end

    WebApp --> Gateway
    MobileApp --> Gateway
    Gateway --> OrderSvc
    Gateway --> CustomerSvc
    OrderSvc --> OrderDB
    OrderSvc -- "同期(gRPC)" --> PaymentSvc
    OrderSvc -- "非同期(Events)" --> Kafka
    PaymentSvc --> PaymentDB
    Kafka --> FulfillSvc
    FulfillSvc --> FulfillDB
    OrderSvc --> CustomerSvc
    
    OrderSvc --> Otel
    PaymentSvc --> Otel
    FulfillSvc --> Otel
    CustomerSvc --> Otel
    Otel --> Tempo
    Otel --> Prometheus
    Tempo --> Grafana
    Prometheus --> Grafana

可観測性なしのマイクロサービスは目隠し運転

3年前の自分に最も強く言いたいのがこれだ。マイクロサービスを導入するなら可観測性(Observability)への投資はケチらない方がいい。モノリスのときは console.log とAPMツール1個で何とかなっていたのに、マイクロサービスにした途端に「どのサービスで何が起きているのか全くわからない」状態になった。あのときの無力感は今でも覚えている。

2026年時点では OpenTelemetry が業界標準として定着していて、ベンダーロックインなしに計装できるのは本当によかった。うちのチームの計装例:

// OpenTelemetry 1.34 + Go 1.25 の計装例
package main

import (
    "context"
    "log"
    
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)

func initTracer(ctx context.Context, serviceName string) (*sdktrace.TracerProvider, error) {
    exporter, err := otlptracegrpc.New(ctx,
        otlptracegrpc.WithEndpoint("otel-collector:4317"),
        otlptracegrpc.WithInsecure(),
    )
    if err != nil {
        return nil, err
    }
    
    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceName(serviceName),
            semconv.ServiceVersion(version),
            semconv.DeploymentEnvironment(env),
        ),
    )
    if err != nil {
        return nil, err
    }
    
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(res),
        // Tail-based sampling: エラーは100%、正常は1%サンプリング
        sdktrace.WithSampler(
            sdktrace.ParentBased(
                newTailSampler(1.0, 0.01), // error_ratio, normal_ratio
            ),
        ),
    )
    
    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(
        propagation.NewCompositeTextMapPropagator(
            propagation.TraceContext{},
            propagation.Baggage{},
        ),
    )
    
    return tp, nil
}

特に効いたのが Tail-based Sampling の導入で、エラーのトレースは100%捕捉しつつ正常系は1%に間引くことで、ストレージコストを大幅に削減しながら問題のあるリクエストを漏れなく記録できるようになった。これ、最初は設定が面倒だと思ってスキップしていたんだけど、本番障害が起きてから「なぜサンプリングしてないんだ」と後悔した。地味に痛い経験だった。

実際に改善されたMTTR(平均修復時間)の変化がこれ:

xychart-beta
    title "MTTR(平均修復時間)の変化(分)"
    x-axis ["2023 Q1", "2023 Q2", "2023 Q3", "2023 Q4", "2024 Q1", "2024 Q2", "2026 Q1"]
    y-axis "MTTR (分)" 0 --> 180
    bar [165, 140, 95, 72, 45, 38, 18]
    line [165, 140, 95, 72, 45, 38, 18]

最初の165分超から今は18分程度まで下がってきた。全部可観測性だけの成果ではないけど、分散トレーシングの導入が転換点になったのは間違いない。インシデント対応の記事でも書いたけど、根本原因の特定速度がMTTRを決める。

可観測性の3本柱(メトリクス・ログ・トレース)に加えて、2026年では Profiling を加えた「4本柱」という考え方も広まってきている。Grafana Pyroscopeを使った継続的プロファイリングは、特にパフォーマンスデグレの根本原因特定に役立っているのでおすすめだ。

APIゲートウェイとサービス間認証で忘れがちな落とし穴

マイクロサービスのセキュリティで意外と見落とされるのが、サービス間の認証だ。「社内ネットワークだから大丈夫」という発想は2026年時点ではもう通用しない。OWASP Top 10の記事でも触れているけど、横移動(Lateral Movement)のリスクは実際にある。

うちのチームが採用しているのはSPIFFE/SPIREを使ったワークロードIDで、サービス間通信でもmTLSを強制している。

# SPIRE Server 設定の抜粋
# spire-server-config.yaml
server:
  bind_address: "0.0.0.0"
  bind_port: "8081"
  trust_domain: "production.example.com"
  data_dir: "/run/spire/data"
  log_level: "INFO"
  
  ca_ttl: "24h"      # CA証明書の有効期限
  default_x509_svid_ttl: "1h"  # SVID(サービス証明書)は1時間で自動更新
  
plugins:
  DataStore:
    - sql:
        plugin_data:
          database_type: postgresql
          connection_string: "postgresql://spire:xxx@spire-db/spire?sslmode=require"
  
  NodeAttestor:
    - k8s_psat:  # Kubernetes Projected Service Account Tokenで認証
        plugin_data:
          clusters:
            - name: production-cluster
              service_account_allow_list:
                - spire:spire-agent

これと合わせてAPIゲートウェイでの認証(JWT検証)を組み合わせると、外部→内部はJWT、内部→内部はmTLSで保護できる。最初の設定は結構しんどいんだけど、一度整備してしまえばサービスが増えても自動的に証明書が発行・更新されるので楽になる。個人的には「設定コストを払っておいてよかった」と思っているやつのひとつだ。

認証・認可の全体フロー:

sequenceDiagram
    actor User as ユーザー
    participant GW as API Gateway
    participant Auth as Auth Service
    participant Order as Order Service
    participant Payment as Payment Service
    participant SPIRE as SPIRE Server

    User->>GW: リクエスト (Bearer JWT)
    GW->>Auth: JWT検証
    Auth-->>GW: 検証OK (User Claims)
    GW->>Order: リクエスト転送 (X-User-ID, X-Roles)
    
    Note over Order,Payment: サービス間通信はmTLS
    Order->>SPIRE: SVID取得 (自動更新)
    SPIRE-->>Order: X.509 SVID
    Order->>Payment: gRPC呼び出し (mTLS)
    Payment->>SPIRE: SVID検証
    SPIRE-->>Payment: 検証OK
    Payment-->>Order: レスポンス
    Order-->>GW: レスポンス
    GW-->>User: レスポンス

ここは好みが分かれるかもしれないけど、Istioのサービスメッシュを使っているなら、SPIFFEとIstioの証明書管理を統合することもできる。うちはIstioの証明書ローテーションの挙動を信頼しきれなかったので独立させているが、管理するコンポーネントが増えるのはデメリットでもある。まだ正直「これが正解」とは言い切れない部分だ。

データ管理の原則:Shared Databaseは絶対にやってはいけない

マイクロサービスを長く運用してきて、振り返ると「DB共有の廃止」が最も効いた意思決定だったかもしれない。最初は「同じPostgreSQLに別スキーマで分ければOK」と思っていたんだけど、これが後々本当に痛かった。

問題は単純で、別スキーマでも直接JOIN・外部キー制約が使えるので、最初は便利に見えるんだけど、サービスが独立してデプロイできなくなる。スキーマ変更のたびに全サービスの影響範囲を確認する必要が出て、結局デプロイが怖くなって頻度が下がる。マイクロサービスにした意味がなくなる。

当時の状況を数値で見ると、こんな感じの直接DB参照が横行していた:

xychart-beta
    title "サービス間の直接DB参照数(廃止前)"
    x-axis ["Order→Payment", "Order→Customer", "Payment→Order", "Fulfillment→Order", "Customer→Order"]
    y-axis "直接DB参照数" 0 --> 30
    bar [24, 18, 12, 28, 8]

これが全部0になったのが今の状態だ。当時はこの数だけ「変更の連鎖」リスクがあって、1つのスキーマ変更が平均3〜4サービスに影響していた。今思うと、よくあの状態で運用できていたと思う。

Database per Serviceパターンに移行するときに使ったのがSagaパターンで、分散トランザクションをイベント駆動で実現している。イベント駆動アーキテクチャの記事イベントソーシング×CQRSの記事で詳しく書いているので参考にしてほしい。

// Choreography-based Saga の実装例
// OrderService: 注文作成→Payment起動の流れ

type OrderCreatedEvent struct {
    OrderID     string          `json:"order_id"`
    CustomerID  string          `json:"customer_id"`
    Items       []OrderItem     `json:"items"`
    TotalAmount decimal.Decimal `json:"total_amount"`
    CreatedAt   time.Time       `json:"created_at"`
}

type OrderSagaOrchestrator struct {
    kafkaProducer *kafka.Writer
    orderRepo     OrderRepository
    tracer        trace.Tracer
}

func (o *OrderSagaOrchestrator) CreateOrder(ctx context.Context, cmd CreateOrderCommand) error {
    ctx, span := o.tracer.Start(ctx, "saga.order.create")
    defer span.End()
    
    // 1. ローカルトランザクション(注文を PENDING 状態で保存)
    order, err := o.orderRepo.CreatePending(ctx, cmd)
    if err != nil {
        span.RecordError(err)
        return fmt.Errorf("create pending order: %w", err)
    }
    
    // 2. Outbox Patternでイベント発行(アトミックに)
    event := OrderCreatedEvent{
        OrderID:     order.ID,
        CustomerID:  cmd.CustomerID,
        Items:       order.Items,
        TotalAmount: order.TotalAmount,
        CreatedAt:   time.Now(),
    }
    
    // OutboxテーブルへのINSERTはorderと同一トランザクション内
    if err := o.orderRepo.AppendOutbox(ctx, "order.created", event); err != nil {
        span.RecordError(err)
        return fmt.Errorf("append to outbox: %w", err)
    }
    
    span.SetAttributes(
        attribute.String("order.id", order.ID),
        attribute.String("saga.step", "order_created"),
    )
    
    return nil
}

// PaymentService: order.created イベントを受信して決済処理
func (p *PaymentSagaHandler) HandleOrderCreated(ctx context.Context, event OrderCreatedEvent) error {
    ctx, span := p.tracer.Start(ctx, "saga.payment.process")
    defer span.End()
    
    result, err := p.pspClient.Charge(ctx, event.TotalAmount, event.CustomerID)
    if err != nil {
        // 補償トランザクション: payment.failed イベントを発行
        p.publishPaymentFailed(ctx, event.OrderID, err.Error())
        return nil // エラーをSagaとして扱う(retryしない)
    }
    
    // payment.completed イベントを発行
    p.publishPaymentCompleted(ctx, event.OrderID, result.TransactionID)
    return nil
}

Outboxパターンは地味だけど本当に重要で、「DBへの書き込み」と「Kafkaへのパブリッシュ」の二重コミット問題を解決できる。これを入れる前は、DBに書いたのにKafkaへの発行が失敗して注文が詰まる事象が月1回くらいあった。今は皆無になった。地味に、でもマジで助かっている。

まとめ

3年間のマイクロサービス運用で学んだことをまとめるとこうなる。当時の自分に送り返したい内容だ。

#教訓一言で言うと
1ドメイン境界設計が最重要技術境界ではなくビジネス境界で切る。DDDのコンテキストマッピングは今でも最も有効なアプローチ
2可観測性への先行投資を惜しまないOpenTelemetryによる分散トレーシングはサービス追加前に整備する。後から入れると痛い目を見る
3Database per Service + OutboxはセットShared Databaseは短期的に楽に見えるが必ずツケが来る
4サービスメッシュは段階的に導入するLinkerdで始めてIstioに移行する道が現実的。最初からIstioを入れようとすると学習コストで死ぬ
5Sagaパターンは設計に組み込む後から追加しようとすると大規模リファクタリングになる

次のアクション:

  • 既存サービスの境界設計をYAMLで文書化してみる(書いてみると問題が見えてくる)
  • OpenTelemetry Collectorを1台立ち上げて、まず1サービスだけ計装する
  • Outboxテーブルの実装パターンをチームの標準にする

皆さんのチームではマイクロサービスの境界設計、どうやって決めていますか?ドメインエキスパートとの会話が続かなくて困っている、みたいな話があればコメントで聞かせてほしい。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事