マイクロサービスで最初の半年が地獄だった話|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による分散トレーシングはサービス追加前に整備する。後から入れると痛い目を見る |
| 3 | Database per Service + Outboxはセット | Shared Databaseは短期的に楽に見えるが必ずツケが来る |
| 4 | サービスメッシュは段階的に導入する | Linkerdで始めてIstioに移行する道が現実的。最初からIstioを入れようとすると学習コストで死ぬ |
| 5 | Sagaパターンは設計に組み込む | 後から追加しようとすると大規模リファクタリングになる |
次のアクション:
- 既存サービスの境界設計をYAMLで文書化してみる(書いてみると問題が見えてくる)
- OpenTelemetry Collectorを1台立ち上げて、まず1サービスだけ計装する
- Outboxテーブルの実装パターンをチームの標準にする
皆さんのチームではマイクロサービスの境界設計、どうやって決めていますか?ドメインエキスパートとの会話が続かなくて困っている、みたいな話があればコメントで聞かせてほしい。