mybook

API ゲートウェイ — サービスの入り口

バラバラになった入り口

マイクロサービスへの移行完了から3ヶ月が経った。EchoTaskのサービス群は順調に稼働していたが、ある月曜日の朝、ハルトのSlackに緊急メッセージが飛び込んだ。

「iOSアプリが起動直後に500エラーを返している。ユーザーから大量のレポートが来ている!」

ハルトはログを追った。問題の根は単純だった。Notification Serviceを新しいホストに移行した際、iOSアプリに埋め込まれたURLをハードコードで持っていたのだ。

Task Service:         https://task-api.echotask.internal:8001
Auth Service:         https://auth-api.echotask.internal:8002
Notification Service: https://notification-api.echotask.internal:8003  ← 旧URL
Analytics Service:    https://analytics-api.echotask.internal:8004

「クライアントが各サービスのエンドポイントを直接持っている設計が根本的な問題だ」とハルトは気づいた。サービスが増えるたびに、モバイルアプリのアップデートが必要になる。認証はどのサービスも個別に実装している。レートリミットも重複コードだらけだ。

APIゲートウェイはこれらすべての問題を解決するアーキテクチャパターンだ。


APIゲートウェイの役割

クライアントはAPIゲートウェイという唯一の窓口だけを知ればよい。ゲートウェイが認証・ルーティング・変換などの横断的関心事(Cross-cutting Concerns)を一手に引き受ける。

Loading diagram...

APIゲートウェイが担う横断的関心事:

機能内容
認証・認可JWTの検証、Cognitoとの統合、パーミッションチェック
レートリミットAPIの過剰利用を制限、DoS対策
ルーティングURLパスに基づくサービス振り分け
SSL終端HTTPSの終点として証明書を集中管理
リクエスト変換ヘッダーの追加・削除・VTLによる変換
ログ・モニタリングアクセスログの集中管理・CloudWatchとの統合
キャッシュレスポンスのキャッシュでバックエンド負荷軽減
WebSocketリアルタイム通信のサポート

API Gateway vs ALB vs CloudFront の選択基準

AWSでは「APIの前に何を置くか」について3つの主要な選択肢がある。ハルトのチームは以下の比較表を作って議論した。

観点API Gateway (HTTP API)ALB (Application Load Balancer)CloudFront + Lambda@Edge
主な用途マネージドAPI管理L7ロードバランシングCDN + エッジ処理
認証統合Cognitoネイティブ対応手動実装Lambda@Edgeで実装
レートリミット組み込みWAF連携WAF連携
コストリクエスト課金(百万件あたり$1)時間課金($0.008/h〜)+ LCUリクエスト課金 + 転送量
レイテンシ低(1〜5ms追加)最低(1ms未満追加)最低(エッジで処理)
VTLテンプレートあり(REST API)なしなし
WebSocketネイティブ対応ネイティブ対応非対応
gRPC非対応対応非対応
カスタムドメイン対応対応対応
適しているケースマイクロサービスAPI管理高トラフィック・WebSocket・gRPC静的コンテンツ + APIキャッシュ

EchoTaskのケースでは、マイクロサービスの認証・ルーティング管理が主目的だったため HTTP API(API Gateway V2) を選択した。将来的にはCloudFrontを前段に置いてグローバルキャッシュを追加する計画だ。

INFO

HTTP API vs REST API の使い分け

API Gateway には旧世代の「REST API(V1)」と新世代の「HTTP API(V2)」がある。

  • REST API: ステージ変数・VTLマッピング・使用量プランが必要な場合
  • HTTP API: それ以外の大多数のケース。コストが約65%安く、レイテンシも低い

新規プロジェクトでは HTTP API を選ぶのがデフォルトの正解。


OpenAPI / Swagger によるAPI定義の管理

APIゲートウェイを導入したとき、「どのエンドポイントが存在するか」「どんなリクエスト/レスポンスか」を一元管理する必要が生じた。ハルトは OpenAPI 仕様(旧 Swagger)でAPI定義を管理することにした。

# openapi/task-service.yaml(抜粋)
openapi: "3.0.3"
info:
  title: EchoTask Task Service API
  version: "1.0"
components:
  securitySchemes:
    BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
  schemas:
    Task:
      type: object
      required: [id, title, status]
      properties:
        id:         { type: integer }
        title:      { type: string }
        status:     { type: string, enum: [todo, in_progress, done] }
        due_date:   { type: string, format: date, nullable: true }
        created_at: { type: string, format: date-time }
security: [{ BearerAuth: [] }]
paths:
  /tasks:
    get:
      summary: タスク一覧取得
      parameters:
        - { name: status, in: query, schema: { type: string } }
        - { name: page,   in: query, schema: { type: integer, default: 1 } }
      responses:
        "200": { description: タスク一覧 }
        "401": { description: 認証エラー }
    post:
      summary: タスク作成
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [title], properties: { title: { type: string } } }
      responses:
        "201": { description: 作成成功 }

rswag-specs gem を使うとRSpecのテストコードからOpenAPI仕様を自動生成できる。コードとドキュメントが常に同期される点が最大のメリットだ。

# spec/requests/api/v1/tasks_spec.rb
require 'swagger_helper'
 
RSpec.describe 'Task API', type: :request do
  path '/api/v1/tasks' do
    get 'タスク一覧取得' do
      tags 'Tasks'; security [BearerAuth: []]; produces 'application/json'
      parameter name: :status, in: :query, type: :string, required: false
 
      response '200', 'タスク一覧' do
        schema type: :object,
               properties: { tasks: { type: :array, items: { '$ref' => '#/components/schemas/Task' } } }
        let(:Authorization) { "Bearer #{token_for(create(:user))}" }
        run_test!
      end
 
      response '401', '認証エラー' do
        let(:Authorization) { 'Bearer invalid' }
        run_test!
      end
    end
  end
end
# bundle exec rake rswag:specs:swaggerize → swagger/v1/swagger.yaml を自動生成

APIバージョニング戦略

「v1 の /tasks の仕様を変えたい。でも既存のモバイルクライアントが壊れる」という問題はAPIの永遠の悩みだ。ハルトはチームと3種類のバージョニング戦略を比較した。

方法メリットデメリット
URLパス(推奨)GET /api/v2/tasksルーティングが簡潔・ログが読みやすいURLが変わる
カスタムヘッダーAccept-Version: v2URLを汚さないクライアント実装が複雑・ブラウザで扱いにくい
クエリパラメータGET /api/tasks?version=2デバッグが楽キャッシュしにくい・本番非推奨

EchoTaskはURLパスバージョニングを採用した。

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :tasks, only: [:index, :show, :create, :update, :destroy]
    end
    namespace :v2 do
      resources :tasks, only: [:index, :show, :create, :update, :destroy]
    end
  end
end
 
# v2: カーソルページネーションとフィルタリングを追加
module Api::V2
  class TasksController < ApplicationController
    def index
      tasks = current_user.tasks
                          .filter_by(params[:filters])
                          .cursor_paginate(after: params[:cursor])
      render json: TaskV2Serializer.new(tasks).serializable_hash
    end
  end
end

AWS API Gateway の設定

EchoTaskではHTTP APIを選択した(低コスト・低レイテンシ)。

# cloudformation/api-gateway.yaml(抜粋)
Resources:
  HttpApi:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: echo-task-api
      ProtocolType: HTTP
      CorsConfiguration:
        AllowHeaders: ["Content-Type", "Authorization"]
        AllowMethods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
        AllowOrigins: ["https://app.echotask.com"]
        MaxAge: 86400
 
  JwtAuthorizer:
    Type: AWS::ApiGatewayV2::Authorizer
    Properties:
      ApiId: !Ref HttpApi
      AuthorizerType: JWT
      Name: echo-task-jwt
      IdentitySource: "$request.header.Authorization"
      JwtConfiguration:
        Audience: ["echo-task-api"]
        Issuer: !Sub "https://cognito-idp.ap-northeast-1.amazonaws.com/${UserPool}"
 
  # 認証必須ルート
  TasksRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref HttpApi
      RouteKey: "ANY /api/v1/tasks/{proxy+}"
      AuthorizationType: JWT
      AuthorizerId: !Ref JwtAuthorizer
      Target: !Sub "integrations/${TaskServiceIntegration}"
 
  # 認証不要ルート(ログイン・サインアップ)
  AuthRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref HttpApi
      RouteKey: "POST /api/v1/auth/{proxy+}"
      AuthorizationType: NONE
      Target: !Sub "integrations/${AuthServiceIntegration}"
 
  TaskServiceIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref HttpApi
      IntegrationType: HTTP_PROXY
      IntegrationUri: !Sub "http://${TaskServiceALB.DNSName}/api/v1/tasks/{proxy}"
      IntegrationMethod: ANY
      PayloadFormatVersion: "1.0"
      RequestParameters:
        # 検証済みのユーザーIDをダウンストリームに転送
        "append:header.X-User-ID":    "$context.authorizer.claims.sub"
        "append:header.X-User-Email": "$context.authorizer.claims.email"
 
  ApiStage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      ApiId: !Ref HttpApi
      StageName: "$default"
      AutoDeploy: true
      DefaultRouteSettings:
        ThrottlingBurstLimit: 500
        ThrottlingRateLimit: 100
      AccessLogSettings:
        DestinationArn: !GetAtt ApiAccessLogGroup.Arn
        Format: '{"id":"$context.requestId","ip":"$context.identity.sourceIp","uid":"$context.authorizer.claims.sub","method":"$context.httpMethod","path":"$context.path","status":"$context.status","ms":"$context.responseLatency"}'
 
  ApiAccessLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /aws/apigateway/echo-task
      RetentionInDays: 30

VTLマッピングテンプレート(REST APIのレスポンス変換)

REST API(V1)ではVelocity Template Language(VTL)でリクエスト・レスポンスをサーバーレスに変換できる。DynamoDBを直接バックエンドに使う場合に活用されることが多い。

# DynamoDBレスポンス → JSON 変換の例(VTL)
ResponseTemplates:
  application/json: |
    #set($item = $input.path('$.Item'))
    {
      "id":         "$item.id.S",
      "title":      "$item.title.S",
      "status":     "$item.status.S",
      "created_at": "$item.created_at.S"
    }

Cognito User Pool との統合

ハルトは認証基盤としてAmazon Cognitoを選んだ。JWT発行・リフレッシュトークン管理・MFA・メール検証をすべてマネージドサービスに委譲できる点が決め手だ。

# cloudformation/cognito.yaml(抜粋)
Resources:
  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: echo-task-users
      UsernameAttributes: [email]
      AutoVerifiedAttributes: [email]
      Policies:
        PasswordPolicy: { MinimumLength: 8, RequireUppercase: true, RequireNumbers: true }
      MfaConfiguration: OPTIONAL
      EnabledMfas: [SOFTWARE_TOKEN_MFA]
 
  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref UserPool
      ClientName: echo-task-app
      GenerateSecret: false
      ExplicitAuthFlows: [ALLOW_USER_PASSWORD_AUTH, ALLOW_REFRESH_TOKEN_AUTH, ALLOW_USER_SRP_AUTH]
      AccessTokenValidity: 1    # 1時間
      RefreshTokenValidity: 30  # 30日
      TokenValidityUnits: { AccessToken: hours, RefreshToken: days }

RailsでのCognitoトークン検証

CognitoのJWTはRS256で署名されており、/.well-known/jwks.json で公開された公開鍵で検証する。

# app/services/cognito_token_verifier.rb
class CognitoTokenVerifier
  ISSUER   = "https://cognito-idp.ap-northeast-1.amazonaws.com/#{ENV['COGNITO_USER_POOL_ID']}"
  JWKS_URI = "#{ISSUER}/.well-known/jwks.json"
 
  def self.verify(token)
    # JWKSをRailsキャッシュに1時間保持(起動のたびにHTTPしない)
    jwks = Rails.cache.fetch('cognito_jwks', expires_in: 1.hour) do
      JSON.parse(Faraday.get(JWKS_URI).body)
    end
    header   = JWT.decode(token, nil, false).last
    key_data = jwks['keys'].find { |k| k['kid'] == header['kid'] }
    raise AuthError, '公開鍵が見つかりません' unless key_data
 
    rsa_key = JWT::JWK.import(key_data).keypair
    payload, = JWT.decode(token, rsa_key, true,
                          algorithms: ['RS256'], iss: ISSUER, verify_iss: true)
    payload.with_indifferent_access
  rescue JWT::ExpiredSignature  then raise AuthError, 'トークンの有効期限が切れています'
  rescue JWT::DecodeError => e  then raise AuthError, "無効なトークン: #{e.message}"
  end
end
 
# app/controllers/concerns/cognito_authenticatable.rb
module CognitoAuthenticatable
  extend ActiveSupport::Concern
  included { before_action :authenticate_request! }
 
  private
 
  def authenticate_request!
    token = request.headers['Authorization']&.delete_prefix('Bearer ')
    raise AuthError, 'トークンが指定されていません' unless token
    @current_claims = CognitoTokenVerifier.verify(token)
  rescue AuthError => e
    render json: { error: e.message }, status: :unauthorized
  end
 
  def current_user_id    = @current_claims[:sub]
  def current_user_email = @current_claims[:email]
end

認証フロー(Cognito + JWT)

Loading diagram...

レートリミット

API Gatewayのスロットリングはアカウント全体を守るが、ユーザー個別の制限はアプリケーション層で実装する。

# config/initializers/rack_attack.rb
class Rack::Attack
  # Redisをストアとして使用
  Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
    url: ENV['REDIS_URL']
  )
 
  # IPアドレスによる制限(未認証含む全アクセス)
  throttle('api/ip', limit: 300, period: 5.minutes) do |req|
    req.ip if req.path.start_with?('/api/')
  end
 
  # 認証済みユーザーによる制限(ユーザーIDで識別)
  throttle('api/user', limit: 1000, period: 1.hour) do |req|
    # API Gatewayがヘッダーに付けたユーザーID
    req.env['HTTP_X_USER_ID']
  end
 
  # ログイン試行の制限(ブルートフォース対策)
  throttle('login/ip', limit: 5, period: 20.seconds) do |req|
    req.ip if req.path == '/api/v1/auth/login' && req.post?
  end
 
  # レートリミット超過時のレスポンス
  self.throttled_responder = lambda do |env|
    match_data = env['rack.attack.match_data']
    now        = match_data[:epoch_time]
    period     = match_data[:period]
    retry_after = period - (now % period)
 
    [
      429,
      {
        'Content-Type'      => 'application/json',
        'Retry-After'       => retry_after.to_s,
        'X-RateLimit-Reset' => (now + retry_after).to_i.to_s
      },
      [{ error: 'リクエスト数の上限に達しました。しばらく待ってから再試行してください。',
         retry_after: retry_after }.to_json]
    ]
  end
end

BFFパターンの実装詳細(GraphQL BFF)

クライアントの種類(Web・iOS・Android)によって必要なデータが異なる場合、BFF(Backend for Frontend) パターンが有効だ。EchoTaskでは graphql-ruby を使いGraphQL BFFを構築した。

Loading diagram...

GraphQL BFFの最大のメリットは「クライアントが必要なフィールドだけを宣言的に取得できる」点だ。RESTではエンドポイントごとに返すフィールドが固定されるが、GraphQLではクエリで指定できる。

# app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    # ダッシュボード: 複数サービスを1リクエストで集約
    field :dashboard, Types::DashboardType, null: false
 
    def dashboard
      uid = context[:current_user_id]
      {
        tasks:         TaskServiceClient.new.recent_tasks(user_id: uid, limit: 10),
        notifications: NotificationServiceClient.new.unread(user_id: uid),
        analytics:     AnalyticsServiceClient.new.weekly_summary(user_id: uid)
      }
    end
 
    field :tasks, [Types::TaskType], null: false do
      argument :status, String, required: false
      argument :page,   Integer, required: false, default_value: 1
    end
 
    def tasks(status: nil, page: 1)
      TaskServiceClient.new.list(
        user_id: context[:current_user_id], status: status, page: page
      )
    end
  end
end
 
# スキーマ定義(graphql-batchでN+1を防ぐ)
class EchoTaskSchema < GraphQL::Schema
  query    Types::QueryType
  mutation Types::MutationType
  use      GraphQL::Batch
end

GolangでのHTTPミドルウェア実装例

ハルトのチームにはGoが得意なメンバーもいた。Notification ServiceはGoで書かれており、ミドルウェアチェーンで認証・レートリミット・ログ記録を実装した。

// middleware/auth.go — JWKSキャッシュ + JWT検証
func JWTAuth(jwksURL string) func(http.Handler) http.Handler {
    cache := jwk.NewCache(context.Background())
    cache.Register(jwksURL)
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            h := r.Header.Get("Authorization")
            if !strings.HasPrefix(h, "Bearer ") {
                http.Error(w, `{"error":"認証が必要です"}`, http.StatusUnauthorized)
                return
            }
            keys, _ := cache.Get(r.Context(), jwksURL)
            tok, err := jwt.Parse([]byte(strings.TrimPrefix(h, "Bearer ")),
                jwt.WithKeySet(keys), jwt.WithValidate(true))
            if err != nil {
                http.Error(w, `{"error":"無効なトークンです"}`, http.StatusUnauthorized)
                return
            }
            next.ServeHTTP(w, r.WithContext(
                context.WithValue(r.Context(), ctxUserID, tok.Subject())))
        })
    }
}
 
// middleware/rate_limit.go — インメモリ(本番はRedisに差し替え)
func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler {
    var mu sync.Mutex
    type entry struct{ n int; reset time.Time }
    m := map[string]*entry{}
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            k, _ := r.Context().Value(ctxUserID).(string)
            if k == "" { k = r.RemoteAddr }
            mu.Lock()
            e, ok := m[k]
            if !ok || time.Now().After(e.reset) { e = &entry{reset: time.Now().Add(window)}; m[k] = e }
            e.n++; n := e.n
            mu.Unlock()
            if n > limit { http.Error(w, `{"error":"レートリミット超過"}`, http.StatusTooManyRequests); return }
            next.ServeHTTP(w, r)
        })
    }
}
 
// middleware/logging.go — slogで構造化ログ
func Logging(log *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            rw := &statusWriter{ResponseWriter: w, code: 200}
            next.ServeHTTP(rw, r)
            uid, _ := r.Context().Value(ctxUserID).(string)
            log.Info("req", "method", r.Method, "path", r.URL.Path,
                "status", rw.code, "ms", time.Since(start).Milliseconds(), "uid", uid)
        })
    }
}
 
// チェーンの組み立て
// handler := Logging(log)(RateLimit(1000, time.Hour)(JWTAuth(jwksURL)(mux)))

WARNING

Goのレートリミットはインメモリ実装に注意

上記の実装はシングルインスタンスでは動くが、ECSで複数タスクが並ぶと各インスタンスが独立したカウンターを持ってしまう。本番では go-redis/redis_rate を使った分散レートリミットに切り替えること。


WebSocket APIの設定

ハルトが次に取り組んだのはリアルタイム通知だ。タスクのステータスが変わったとき、担当者のブラウザに即座に反映したい。API GatewayのWebSocket APIを使えば、Lambda + DynamoDBで完全サーバーレスなプッシュ通知を実現できる。

# cloudformation/websocket-api.yaml
Resources:
  WebSocketApi:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: echo-task-realtime
      ProtocolType: WEBSOCKET
      RouteSelectionExpression: "$request.body.action"
 
  ConnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebSocketApi
      RouteKey: "$connect"
      AuthorizationType: NONE
      Target: !Sub "integrations/${ConnectIntegration}"
 
  DisconnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebSocketApi
      RouteKey: "$disconnect"
      Target: !Sub "integrations/${DisconnectIntegration}"
 
  ConnectIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebSocketApi
      IntegrationType: AWS_PROXY
      IntegrationUri: !Sub "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/${WebSocketConnectFunction.Arn}/invocations"
 
  WebSocketStage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      ApiId: !Ref WebSocketApi
      StageName: production
      AutoDeploy: true

接続時はLambdaでDynamoDBに connection_id → user_id を保存する。Task Serviceがタスク更新を検知したとき、DynamoDBからコネクションIDを引いて ApiGatewayManagementApi.post_to_connection でプッシュする。

# app/services/websocket_notifier.rb
class WebSocketNotifier
  def self.notify_task_update(user_id:, task:)
    db    = Aws::DynamoDB::Client.new
    apigw = Aws::ApiGatewayManagementApi::Client.new(endpoint: ENV['WEBSOCKET_ENDPOINT'])
    msg   = { action: 'task_updated', task: TaskSerializer.new(task).as_json }.to_json
 
    db.query(table_name: ENV['CONNECTIONS_TABLE'], index_name: 'UserIdIndex',
             key_condition_expression: 'user_id = :uid',
             expression_attribute_values: { ':uid' => user_id }).items.each do |conn|
      apigw.post_to_connection(connection_id: conn['connection_id'], data: msg)
    rescue Aws::ApiGatewayManagementApi::Errors::GoneException
      db.delete_item(table_name: ENV['CONNECTIONS_TABLE'],
                     key: { connection_id: conn['connection_id'] })
    end
  end
end

EchoTaskの新しい全体構成

全ての仕組みが揃ったところで、EchoTaskのAPIレイヤーを俯瞰してみよう。

Loading diagram...

INFO

この章のキーポイント

  • APIゲートウェイ: 横断的関心事(認証・レートリミット・ルーティング)を集中管理。クライアントはURLを1つ知るだけでよい
  • 選択基準: HTTP API(V2)が低コスト・低レイテンシでほとんどのケースに最適。gRPCが必要なときはALBを選ぶ
  • OpenAPI + rswag: テストコードからドキュメントを自動生成することでAPI仕様とコードの乖離を防ぐ
  • バージョニング: URLパスバージョニングが最もシンプルで運用しやすい
  • Cognito統合: JWKSによる公開鍵検証で、各サービスが独立してトークンを検証できる
  • GraphQL BFF: クライアント種別ごとに最適なレスポンスを返し、オーバーフェッチを排除する
  • Goミドルウェア: 認証・レートリミット・ログ記録をチェーンとして組み合わせる
  • WebSocket API: API Gatewayのマネージド機能でリアルタイム通信を低コストで実現

まとめと次のステップ

APIゲートウェイの導入でEchoTaskのアーキテクチャは大きく改善された。

  • 認証ロジックが一元化され、各サービスは X-User-ID ヘッダーを信頼するだけでよい
  • クライアントは https://api.echotask.com という1つのURLだけを知ればよい
  • レートリミットはRack::Attack + API Gatewayで二重管理
  • 新サービスの追加はCloudFormationにルーティング設定を追加するだけ
  • WebSocket APIでリアルタイム通知が実現できた

しかし新たな問題が浮上してきた。タスクの完了を記録するとき、Task ServiceとNotification Serviceの両方に書き込む処理がある。もし途中でNotification Serviceがダウンしたら? タスクは完了したのに通知は送られない——これはデータの不整合だ。

「分散システムでどうやってデータの一貫性を保証するか?」

次の章では、Sagaパターンとイベント駆動アーキテクチャを使った分散トランザクション管理を学ぶ。