mybook

マイクロサービス入門 — サービスを分割する

モノリスの苦しみ

EchoTaskのコードベースはこうなっていた。

$ find app -name "*.rb" | wc -l
847  # Rubyファイル847個
 
$ cloc app/
Language   Files  Blank  Comment  Code
Ruby         847   4821     1203  42891 4万行超

モデルは150個。コントローラーは80個。Gemfileには120個の依存関係。

問題が顕在化してきた:

- テストが遅い: RSpecの全テストで45分
- デプロイが怖い: どこかに影響が出るかもしれない
- 採用が難しい: 「どこから読めばいい?」と新人が途方に暮れる
- チームが増えた: 5チームが同じコードに変更を加えて競合が多発
- スケールの問題: 通知機能だけ爆発的に増やしたいが、全体をスケールするしかない

ハルトは決断した——「サービスを分割する時が来た。」


モノリスとマイクロサービスの比較

Loading diagram...
観点モノリスマイクロサービス
開発速度(初期)速い遅い
デプロイ全体が1回サービスごとに独立
スケール全体をスケール必要なサービスだけ
障害の影響範囲全体に及ぶ該当サービスのみ
チーム独立性低い(コード共有)高い
運用の複雑さ低い高い
技術選択統一サービスごとに最適化可能

WARNING

マイクロサービスは銀の弾丸ではない

Martin Fowlerは「モノリス第一」を推奨している。明確な境界が見えていないうちにマイクロサービスに分割すると、「分散モノリス」という最悪の結果になる。ユーザー数が数十万〜百万規模、チームが数十人を超えてから検討するのが現実的だ。


移行判断基準チェックリスト

ハルトはホワイトボードに以下のチェックリストを書いた。全て「Yes」の場合だけ移行を進める。

移行する前に確認すること:
□ チームが6名以上いるか?(Conway's Law: 組織構造がアーキテクチャを決める)
□ デプロイが週1回以上あるか?
□ モノリスのデプロイに30分以上かかっているか?
□ 複数チームが同じコードを並行して変更しているか?
□ 特定の機能だけ異なるスケール要件があるか?
□ ドメインの境界が明確に定義できているか?
□ サービス間のネットワーク遅延を許容できるか?
□ 分散トレーシング・ログ集約・サーキットブレーカーを実装できるか?

全項目をクリアして初めて、分割を検討する段階に入れる。


分散モノリスの失敗パターン

移行で最も恐れるべき失敗が「分散モノリス」だ。サービスは分かれているのに、実態はモノリスと同じ問題を抱えている状態を指す。

よくある間違い1: データベースの共有

# NG: 複数サービスが同じテーブルを参照する
# task_service/app/models/user.rb
class User < ApplicationRecord
  # task_service が auth_service の users テーブルを直接参照している!
  establish_connection :shared_production_db
end
 
# 何が問題か:
# - auth_service がスキーマを変更すると task_service が壊れる
# - 2つのサービスが同じDBに依存するので独立デプロイできない
# - これは「分散モノリス」の典型的な症状

よくある間違い2: 同期チェーン

# NG: A → B → C → D と同期的に呼び出す
# analytics_service/app/controllers/reports_controller.rb
def create_report
  # 各サービスが順番に呼び出される
  user = AuthService.get_user(params[:user_id])          # 50ms
  tasks = TaskService.get_tasks(params[:project_id])     # 80ms
  billing = BillingService.get_plan(user[:plan_id])      # 60ms
 
  # 合計190ms + 自分の処理 = レイテンシが積み重なる
  # しかも BillingService が落ちたら全体が失敗する
  generate_report(user, tasks, billing)
end

WARNING

分散モノリスになっていないか確認するサイン

  • サービスをまたいで同期的なAPI呼び出しが3段以上になっている
  • DBのマイグレーションを複数サービスで同時に実行する必要がある
  • あるサービスのデプロイに、別サービスのデプロイを先に行う必要がある
  • このいずれかに該当するなら、分割の仕方を見直す必要がある

サービス境界の見つけ方(ドメイン駆動設計)

どこで分割するかは**ドメイン駆動設計(DDD)**の「境界づけられたコンテキスト」という考え方が参考になる。

EchoTaskのドメインを分析する:

EchoTaskのコア概念:
├── タスク管理 (Core Domain)
│   ├── タスク・プロジェクト・コメント
│   └── → Task Service
├── ユーザー・認証 (Supporting Domain)
│   ├── ユーザー登録・ログイン・プロフィール
│   └── → Auth Service
├── 通知 (Generic Subdomain)
│   ├── メール・プッシュ・Webhook
│   └── → Notification Service
├── 分析・レポート (Supporting Domain)
│   ├── ダッシュボード・レポート生成
│   └── → Analytics Service
└── 課金 (Supporting Domain)
    ├── プラン管理・Stripe連携
    └── → Billing Service

境界を見つける質問

  1. このデータは他のデータと独立して変更されるか?
  2. このチームはこの機能を独自にリリースしたいか?
  3. このコンポーネントだけ急激にスケールする可能性があるか?
  4. 他のサービスに同期的に依存しなくてもよいか?

Strangler Figパターンによる段階的移行

いきなり全部分割するのは危険だ。Strangler Figパターン(イチジク絞め殺しパターン)で段階的に移行する。名前の由来は、宿主となる木に絡みついて徐々に置き換えていくイチジクの植物から来ている。

フェーズ1: プロキシ層の導入(週1〜2)

まずリクエストを透過的に転送するプロキシを置く。この段階ではまだ何も変わらない。

# config/routes.rb(モノリス)
# 全ルートをそのまま通過させ、まずプロキシ層を挿入
Rails.application.routes.draw do
  # 通知関連のエンドポイントだけプロキシに向ける準備をする
  constraints(NotificationProxy.new) do
    match '/api/v1/notifications/*path',
          to: 'proxy#forward',
          via: :all
  end
 
  # 他はすべてモノリスで処理
  resources :tasks
  resources :projects
end
 
# app/controllers/proxy_controller.rb
class ProxyController < ApplicationController
  NOTIFICATION_SERVICE_URL = ENV.fetch('NOTIFICATION_SERVICE_URL', nil)
 
  def forward
    if NOTIFICATION_SERVICE_URL
      forward_to_new_service
    else
      # 新サービスがまだなければモノリス内で処理
      legacy_notifications_action
    end
  end
 
  private
 
  def forward_to_new_service
    response = Faraday.new(NOTIFICATION_SERVICE_URL).send(
      request.method.downcase.to_sym,
      request.path,
      request.body.read,
      request.headers.select { |k, _| k.start_with?('HTTP_') }
    )
    render plain: response.body, status: response.status
  end
end

フェーズ2: 新サービスの構築と並行稼働(週2〜4)

新しい通知サービスを構築しながら、モノリスとの並行稼働を行う。

# notification-service/app/controllers/api/v1/notifications_controller.rb
class Api::V1::NotificationsController < ApplicationController
  before_action :authenticate_service!
 
  def create
    NotificationJob.perform_later(
      user_id: params[:user_id],
      type: params[:type],
      payload: params[:payload]
    )
    render json: { status: 'queued' }, status: :accepted
  end
 
  private
 
  def authenticate_service!
    token = request.headers['Authorization']&.split(' ')&.last
    head :unauthorized unless valid_service_token?(token)
  end
 
  def valid_service_token?(token)
    ActiveSupport::SecurityUtils.secure_compare(
      token.to_s,
      ENV.fetch('SERVICE_TOKEN')
    )
  end
end
# notification-service/app/jobs/notification_job.rb
class NotificationJob < ApplicationJob
  queue_as :notifications
  retry_on Net::SMTPServerBusy, wait: :exponentially_longer, attempts: 5
 
  def perform(user_id:, type:, payload:)
    user = UserFetcher.fetch(user_id) # Auth Serviceに問い合わせ
 
    case type
    when 'task_completed'
      NotificationMailer.task_completed(user, payload).deliver_now
    when 'mention'
      PushNotifier.send(user, payload)
    when 'deadline_reminder'
      NotificationMailer.deadline_reminder(user, payload).deliver_now
    end
  end
end

フェーズ3: トラフィック切り替えと検証(週4〜6)

モノリス側のコードが新サービスを呼ぶよう変更する。通知サービスが落ちても rescue でメインアプリを動かし続けるのがポイントだ。

# app/services/notification_service.rb
class NotificationService
  BASE_URL = ENV['NOTIFICATION_SERVICE_URL']
 
  def self.send(user_id:, type:, payload: {})
    HTTParty.post(
      "#{BASE_URL}/api/v1/notifications",
      body: { user_id: user_id, type: type, payload: payload }.to_json,
      headers: {
        'Content-Type'  => 'application/json',
        'Authorization' => "Bearer #{Rails.application.credentials.notification_service_token}"
      },
      timeout: 5
    )
  rescue Net::ReadTimeout, Errno::ECONNREFUSED => e
    Rails.logger.error("Notification service error: #{e.message}")
    nil  # 通知サービスが落ちても、メインアプリは止めない
  end
end

フェーズ4: モノリス側コードの削除(週6〜8)

新サービスが安定したら、モノリスから通知関連コードを削除する。これで移行完了だ。

# モノリスから通知関連を削除
$ git rm app/mailers/notification_mailer.rb
$ git rm app/workers/notification_worker.rb
$ git rm app/models/notification.rb
$ rails db:migrate  # notifications テーブルを削除
 
# 削除前後のコード量比較
Before: 847 Ruby files, 42891 lines
After:  791 Ruby files, 38642 lines 通知関連4249行を削除

コントラクトテスト(Consumer-Driven Contract)

サービスが増えると「このAPIのレスポンス形式、変えても大丈夫?」という不安が生まれる。コントラクトテストはその不安を解消する。

Pact(コントラクトテストのデファクトライブラリ)を使い、Consumer(呼び出し側)が期待するAPIの形を定義し、Provider(提供側)がそれを満たすか自動検証する。

# Consumer側(task-service): 期待するAPIの形を定義
# spec/pacts/task_notification_pact_spec.rb
describe "Task → Notification API Contract" do
  include Pact::Consumer::Minitest
 
  mock_service :notification_service do
    port 1234
  end
 
  it "sends task completed notification" do
    notification_service
      .upon_receiving("a task completion notification request")
      .with(
        method: :post,
        path: '/api/v1/notifications',
        body: {
          user_id: Pact.like(42),
          type: 'task_completed',
          payload: { task_id: Pact.like(100) }
        }
      )
      .will_respond_with(status: 202, body: { status: 'queued' })
 
    result = NotificationService.send(
      user_id: 42, type: 'task_completed',
      payload: { task_id: 100 }
    )
    assert_equal 202, result.code.to_i
  end
end
# Provider側(notification-service): コントラクトを満たすか検証
Pact.service_provider "Notification Service" do
  honours_pact_with "Task Service" do
    pact_uri './pacts/task_service-notification_service.json'
  end
end

コントラクトファイル(pacts/のJSON)はCIで共有され、Provider側が変更を加えるたびに自動検証される。


サービス間通信

マイクロサービスはどう通信するか?

同期通信(HTTP / gRPC)

まずはgRPCのプロトコル定義から。

// proto/task/v1/task.proto
syntax = "proto3";
package task.v1;
option go_package = "github.com/echOtask/task-service/gen/task/v1";
 
service TaskService {
  rpc GetTask(GetTaskRequest) returns (GetTaskResponse);
  rpc CompleteTask(CompleteTaskRequest) returns (CompleteTaskResponse);
}
 
message Task {
  int64  id          = 1;
  string title       = 2;
  string status      = 3;
  int64  project_id  = 4;
  int64  assignee_id = 5;
}
 
message GetTaskRequest  { int64 task_id = 1; }
message GetTaskResponse { Task task = 1; }
message CompleteTaskRequest  { int64 task_id = 1; int64 user_id = 2; }
message CompleteTaskResponse { Task task = 1; }
// task-service/internal/server/task_server.go
package server
 
import (
	"context"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	taskv1 "github.com/echOtask/task-service/gen/task/v1"
	"github.com/echOtask/task-service/internal/repository"
)
 
type TaskServer struct {
	taskv1.UnimplementedTaskServiceServer
	repo repository.TaskRepository
}
 
func (s *TaskServer) GetTask(ctx context.Context, req *taskv1.GetTaskRequest) (*taskv1.GetTaskResponse, error) {
	task, err := s.repo.FindByID(ctx, req.TaskId)
	if err == repository.ErrNotFound {
		return nil, status.Errorf(codes.NotFound, "task %d not found", req.TaskId)
	}
	if err != nil {
		return nil, status.Errorf(codes.Internal, "internal error: %v", err)
	}
	return &taskv1.GetTaskResponse{Task: toProtoTask(task)}, nil
}
 
func (s *TaskServer) CompleteTask(ctx context.Context, req *taskv1.CompleteTaskRequest) (*taskv1.CompleteTaskResponse, error) {
	task, err := s.repo.Complete(ctx, req.TaskId, req.UserId)
	if err != nil {
		return nil, status.Errorf(codes.Internal, "failed: %v", err)
	}
	// 非同期でSNSにイベント発行(通知サービスが購読する)
	go s.publishTaskCompletedEvent(task)
	return &taskv1.CompleteTaskResponse{Task: toProtoTask(task)}, nil
}
 
// cmd/server/main.go: 認証・ロギング・リカバリのインターセプターチェーンでサーバー起動
func main() {
	lis, _ := net.Listen("tcp", ":50051")
	grpcServer := grpc.NewServer(
		grpc.ChainUnaryInterceptor(loggingInterceptor, recoveryInterceptor, authInterceptor),
	)
	taskv1.RegisterTaskServiceServer(grpcServer, &TaskServer{repo: repository.NewPostgres(initDB())})
	reflection.Register(grpcServer)
	log.Fatal(grpcServer.Serve(lis))
}

非同期通信(イベント駆動)

# タスクが完了したらSNSにイベントを発行
# task-service/app/models/task.rb
class Task < ApplicationRecord
  after_update :publish_event, if: :saved_change_to_status?
 
  private
 
  def publish_event
    EventPublisher.publish('task.updated', {
      task_id:         id,
      project_id:      project_id,
      status:          status,
      previous_status: status_before_last_save,
      occurred_at:     Time.current.iso8601
    })
  end
end
 
# task-service/app/services/event_publisher.rb
class EventPublisher
  def self.publish(event_type, payload)
    sns_client.publish(
      topic_arn: ENV.fetch('SNS_TOPIC_ARN'),
      message: payload.merge(event_type: event_type).to_json,
      message_attributes: {
        'event_type' => {
          data_type: 'String',
          string_value: event_type
        }
      }
    )
  end
 
  def self.sns_client
    @sns_client ||= Aws::SNS::Client.new(region: 'ap-northeast-1')
  end
  private_class_method :sns_client
end
 
# 通知サービスがSNS/SQSを通じてイベントを受け取る
# notification-service/app/workers/task_event_worker.rb
class TaskEventWorker
  include Shoryuken::Worker
 
  shoryuken_options queue: 'notification-service-tasks',
                    auto_delete: true,
                    body_parser: :json
 
  def perform(_sqs_msg, body)
    case body['event_type']
    when 'task.updated'
      handle_task_update(body)
    when 'task.completed'
      handle_task_completion(body)
    end
  end
 
  private
 
  def handle_task_completion(body)
    NotificationJob.perform_later(
      user_id: body['assignee_id'],
      type: 'task_completed',
      payload: body.slice('task_id', 'task_title')
    )
  end
end

データの独立性

マイクロサービスの重要な原則:サービスはデータベースを共有しない

悪い例(データベース共有):
  Task Service ──┐
                 ├── 共有DB(危険: 結合が生まれる)
  Auth Service ──┘

良い例(各サービスが独自DB):
  Task Service         → Tasks RDS
  Auth Service         → Users RDS
  Notification Service → Notifications RDS

デプロイ戦略: Blue-Green と Canary

サービスが独立してデプロイできるようになったら、次は安全なデプロイ方法を考える。

Blue-Greenデプロイ(ECS + CodeDeploy)

ECSサービスに DeploymentController: Type: CODE_DEPLOY を設定すると、Blue(現行)とGreen(新バージョン)の2つのターゲットグループ間でトラフィックを切り替えられる。

# ECSサービスのDeploymentController設定
TaskECSService:
  Type: AWS::ECS::Service
  Properties:
    Cluster: !Ref ECSCluster
    DeploymentController:
      Type: CODE_DEPLOY          # CodeDeployによるBlue-Green制御
    LoadBalancers:
      - ContainerName: task-service
        ContainerPort: 3000
        TargetGroupArn: !Ref BlueTargetGroup  # 初期はBlueへ
 
# CodeDeployのデプロイグループ
TaskServiceDeployGroup:
  Type: AWS::CodeDeploy::DeploymentGroup
  Properties:
    DeploymentStyle:
      DeploymentType: BLUE_GREEN
      DeploymentOption: WITH_TRAFFIC_CONTROL
    BlueGreenDeploymentConfiguration:
      TerminateBlueInstancesOnDeploymentSuccess:
        Action: TERMINATE
        TerminationWaitTimeInMinutes: 5  # 旧バージョンを5分後に終了
    LoadBalancerInfo:
      TargetGroupPairInfoList:
        - ProdTrafficRoute:
            ListenerArns: [!Ref ALBProdListener]
          TargetGroups:
            - Name: !GetAtt BlueTargetGroup.TargetGroupName
            - Name: !GetAtt GreenTargetGroup.TargetGroupName

Canaryデプロイ(重み付きルーティング)

# まず10%のトラフィックを新バージョンへ、5分後に残り90%を切り替え
TaskServiceCanaryConfig:
  Type: AWS::CodeDeploy::DeploymentConfig
  Properties:
    DeploymentConfigName: task-service-canary-10
    ComputePlatform: ECS
    TrafficRoutingConfig:
      Type: TimeBasedCanary
      TimeBasedCanary:
        CanaryPercentage: 10
        CanaryInterval: 5

ECS Service Connect によるサービスディスカバリ

マイクロサービスが増えると「どのサービスがどのIPアドレスで動いているか」の管理が必要になる。ECS Service Connectはこれを自動化する。

# ECSクラスターにService Connect名前空間を設定
EchoTaskCluster:
  Type: AWS::ECS::Cluster
  Properties:
    ClusterName: echOtask-production
    ServiceConnectDefaults:
      Namespace: echOtask.local  # 内部DNS名前空間
 
# Task ServiceにService Connect設定を追加
TaskECSService:
  Type: AWS::ECS::Service
  Properties:
    ServiceConnectConfiguration:
      Enabled: true
      Namespace: echOtask.local
      Services:
        - PortName: http
          DiscoveryName: task-service
          ClientAliases:
            - Port: 3000
              DnsName: task-service  # http://task-service:3000 でアクセス可能

Service Connect により、task-servicehttp://notification-service:3000 という固定ホスト名でNotification Serviceにアクセスできる。IPアドレスやポートの変更はECSが自動で吸収する。


サービスメッシュの概念(Istio / Envoy)

サービスが10を超えると、Service Connectだけでは足りなくなる。サービスメッシュが登場する。

各サービスのそばにEnvoyプロキシ(サイドカー)を配置し、サービス間通信をすべてEnvoy経由にする。これにより以下が実現できる。

機能説明
mTLSサービス間の相互TLS認証。平文通信を拒否
サーキットブレーカー障害サービスへのリクエストを遮断し連鎖障害を防ぐ
リトライ制御アプリコードを変えずにリトライポリシーを設定
分散トレーシングどのサービスで遅延が発生したかを可視化
カナリアルーティング特定サービスへの重み付きトラフィック制御

EchoTaskはまだECS Service Connectで十分だったが、サービスが15を超えたタイミングでIstio導入を検討することにした。


EchoTaskの移行計画

Loading diagram...

ハルトはフェーズ1(通知サービスの分離)から着手した。

2ヶ月後:

- 通知サービス: 独立してデプロイ可能
- 通知のスケール: ECSタスク1台 → 10台(他に影響なし)
- デプロイ時間: 45分 → 8分(通知サービス単独)
- モノリスのテスト: 45分 → 38分(通知関連テスト削除)
- Canaryデプロイにより本番障害を未然に検知: 2件

しかし、サービスが増えた分、**「各サービスの入り口をどう管理するか」**という新たな問題が浮上した。

次の章では、APIゲートウェイを学ぶ。

INFO

この章のキーポイント

  • モノリスはチームが5〜10人以上になるまで維持する
  • 分散モノリスに陥る前にチェックリストで移行の準備を確認する
  • 分割はDDDの境界づけられたコンテキストを参考に
  • Strangler Figパターンで4フェーズに分けて段階的に移行する
  • コントラクトテスト(Pact)でサービス間のAPI互換性を自動検証する
  • サービス間はHTTP/gRPC(同期)またはSNS/SQS(非同期)で通信
  • Blue-Green・CanaryデプロイをECS CodeDeployで実現する
  • ECS Service ConnectでIPアドレスを意識しないサービスディスカバリを実現する
  • サービスが10を超えたらIstio/Envoyによるサービスメッシュを検討する