マイクロサービス入門 — サービスを分割する
モノリスの苦しみ
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チームが同じコードに変更を加えて競合が多発
- スケールの問題: 通知機能だけ爆発的に増やしたいが、全体をスケールするしかない
ハルトは決断した——「サービスを分割する時が来た。」
モノリスとマイクロサービスの比較
| 観点 | モノリス | マイクロサービス |
|---|---|---|
| 開発速度(初期) | 速い | 遅い |
| デプロイ | 全体が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)
endWARNING
分散モノリスになっていないか確認するサイン
- サービスをまたいで同期的な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
境界を見つける質問
- このデータは他のデータと独立して変更されるか?
- このチームはこの機能を独自にリリースしたいか?
- このコンポーネントだけ急激にスケールする可能性があるか?
- 他のサービスに同期的に依存しなくてもよいか?
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.TargetGroupNameCanaryデプロイ(重み付きルーティング)
# まず10%のトラフィックを新バージョンへ、5分後に残り90%を切り替え
TaskServiceCanaryConfig:
Type: AWS::CodeDeploy::DeploymentConfig
Properties:
DeploymentConfigName: task-service-canary-10
ComputePlatform: ECS
TrafficRoutingConfig:
Type: TimeBasedCanary
TimeBasedCanary:
CanaryPercentage: 10
CanaryInterval: 5ECS 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-service は http://notification-service:3000 という固定ホスト名でNotification Serviceにアクセスできる。IPアドレスやポートの変更はECSが自動で吸収する。
サービスメッシュの概念(Istio / Envoy)
サービスが10を超えると、Service Connectだけでは足りなくなる。サービスメッシュが登場する。
各サービスのそばにEnvoyプロキシ(サイドカー)を配置し、サービス間通信をすべてEnvoy経由にする。これにより以下が実現できる。
| 機能 | 説明 |
|---|---|
| mTLS | サービス間の相互TLS認証。平文通信を拒否 |
| サーキットブレーカー | 障害サービスへのリクエストを遮断し連鎖障害を防ぐ |
| リトライ制御 | アプリコードを変えずにリトライポリシーを設定 |
| 分散トレーシング | どのサービスで遅延が発生したかを可視化 |
| カナリアルーティング | 特定サービスへの重み付きトラフィック制御 |
EchoTaskはまだECS Service Connectで十分だったが、サービスが15を超えたタイミングでIstio導入を検討することにした。
EchoTaskの移行計画
ハルトはフェーズ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によるサービスメッシュを検討する