mybook

ストラングラーフィグパターン — レガシーを段階的に置き換える

「全部作り直したい」

「正直に言うと」ユウキが言った。「注文システムを全部作り直したいんです。今のコードはグチャグチャで……」

アヤカは少し間を置いた。

「それをビッグバンリプレイスメントという。聞こえはいいけど、ほぼ失敗する」

「なぜですか?」

「今のシステムが処理している全てのケースを新しいシステムで再現できる保証がない。本番で動かすまでバグが見つからない。移行中の1〜2年、既存システムと並行開発しないといけない。——歴史的にほとんどのプロジェクトが途中で断念する」

「じゃあどうすれば……?」

ストラングラーフィグという木を知ってる?」

ストラングラーフィグとは

ストラングラーフィグ(絞め殺しの木)は、宿主の木に巻きつきながら成長し、最終的に宿主を置き換える植物。種が木の上部に落ち、根を下ろしながら宿主を包んでいく。

ソフトウェアでは、新しいシステムを既存システムの外側に少しずつ構築し、徐々に機能を移していく手法。

Loading diagram...

INFO

ストラングラーフィグの核心は「止めないこと」。既存システムは動き続ける。新機能は新システムに、古い機能は少しずつ移行する。ユーザーは移行に気づかない。Martin Fowlerが2004年に命名した。

なぜビッグバンリプレイスメントは失敗するのか

「具体的に何が問題なんですか?」ユウキが聞いた。

「実際の失敗ケースを話す。10年前のある会社。100万行のモノリスを完全に書き直すプロジェクトを立ち上げた。2年後に新システムが完成して本番に切り替えた。……1週間で戻した」

「なぜ?」

「本番に切り替えてから初めてわかるケースが山ほどあった。請求書のフォーマットが微妙に違う、旧ユーザーの特殊なデータ形式に対応できていない、特定の条件下で計算が違う……。開発中は見えなかった暗黙の仕様が100個あった。新しいシステムでは何も起きていないように見えても、実は正しく処理できていない」

ビッグバンリプレイスメントの問題ストラングラーフィグの解決策
本番稼働まで検証できない機能ごとに段階的に移行・検証
移行中は両方を開発移行済み機能は旧システムが自動停止
失敗時のロールバックが困難ルーティング変更で即座にロールバック
暗黙の仕様を見つけにくい機能ごとに発見・対応できる

移行計画の立て方

実際にレガシーを移行するときの考え方:

# 優先順位付けフレームワーク
class MigrationPriority
  def self.score(feature)
    {
      business_value: feature[:revenue_impact] * 0.4,
      tech_debt:      feature[:maintenance_cost] * 0.3,
      risk:           (10 - feature[:complexity]) * 0.2,
      team_readiness: feature[:team_familiarity] * 0.1
    }.values.sum
  end
end
 
# 優先度の高い順に移行
features = [
  { name: "商品検索", revenue_impact: 9, maintenance_cost: 8, complexity: 3, team_familiarity: 8 },
  { name: "注文作成", revenue_impact: 10, maintenance_cost: 7, complexity: 7, team_familiarity: 6 },
  { name: "ユーザー管理", revenue_impact: 5, maintenance_cost: 6, complexity: 4, team_familiarity: 9 }
]
 
features.sort_by { |f| -MigrationPriority.score(f) }

フェーズ1:ファサードの設置

まず、リクエストを振り分けるファサードを入れる。既存動作は何も変わらない。

# CloudFront ビヘイビア設定(初期状態:全て既存システムへ)
Behaviors:
  - PathPattern: "/api/*"
    TargetOriginId: LegacyRailsApp
    ViewerProtocolPolicy: redirect-to-https
    ForwardedValues:
      QueryString: true
      Headers:
        - Authorization
        - X-Request-Id
 
  - PathPattern: "/*"
    TargetOriginId: LegacyRailsApp
    ViewerProtocolPolicy: redirect-to-https
    ForwardedValues:
      QueryString: true

この時点では何も変わらない。ただしルーティングを制御できる状態になった。

フェーズ2:新機能は新システムへ

新しい機能を追加するとき、既存システムには追加せず新システムに実装。

# 新規エンドポイントを新システムへルーティング
Behaviors:
  # 新しいAPI v2 は新システムへ
  - PathPattern: "/api/v2/*"
    TargetOriginId: NewRailsApp
    ViewerProtocolPolicy: redirect-to-https
    ForwardedValues:
      QueryString: true
      Headers:
        - Authorization
 
  # 既存の v1 は引き続きレガシーへ
  - PathPattern: "/api/v1/*"
    TargetOriginId: LegacyRailsApp
    ViewerProtocolPolicy: redirect-to-https
    ForwardedValues:
      QueryString: true
# 新しいシステムの routes.rb
# /api/v2/ 配下に新機能を追加
namespace :api do
  namespace :v2 do
    resources :orders, only: [:index, :show, :create]
    resources :products, only: [:index, :show] do
      collection { get :search }
    end
    resources :recommendations, only: [:index]  # 新機能
    resources :wishlists, only: [:index, :create, :destroy]  # 新機能
  end
end

フェーズ3:機能を段階的に移行

既存機能を一つずつ新システムに移行し、ルーティングを切り替える。

まず、データを移行する:

# db/migrate/20240101_create_migration_checkpoints.rb
class CreateMigrationCheckpoints < ActiveRecord::Migration[8.0]
  def change
    create_table :migration_checkpoints do |t|
      t.string :entity_type, null: false
      t.bigint :last_migrated_id, default: 0
      t.integer :total_count, default: 0
      t.integer :migrated_count, default: 0
      t.string :status, default: "pending"
      t.timestamps
    end
  end
end
# app/jobs/user_migration_job.rb
class UserMigrationJob < ApplicationJob
  queue_as :migration
  sidekiq_options retry: 3
 
  BATCH_SIZE = 500
 
  def perform(offset: 0)
    checkpoint = MigrationCheckpoint.find_or_create_by(entity_type: "User")
 
    users_batch = LegacyUser.where("id > ?", checkpoint.last_migrated_id)
                            .order(:id)
                            .limit(BATCH_SIZE)
 
    return checkpoint.update!(status: "completed") if users_batch.empty?
 
    migrated_count = 0
    failed_count = 0
 
    ActiveRecord::Base.transaction do
      users_batch.each do |legacy_user|
        migrate_user(legacy_user)
        migrated_count += 1
      rescue => e
        failed_count += 1
        Rails.logger.error("ユーザー移行失敗 id=#{legacy_user.id}: #{e.message}")
        MigrationError.create!(
          entity_type: "User",
          entity_id: legacy_user.id,
          error_message: e.message,
          error_class: e.class.name
        )
      end
    end
 
    checkpoint.update!(
      last_migrated_id: users_batch.last.id,
      migrated_count: checkpoint.migrated_count + migrated_count
    )
 
    Rails.logger.info("ユーザー移行バッチ完了: #{migrated_count}件成功, #{failed_count}件失敗")
 
    # 次のバッチをキューイング
    UserMigrationJob.perform_later(offset: offset + BATCH_SIZE) if users_batch.size == BATCH_SIZE
  end
 
  private
 
  def migrate_user(legacy_user)
    # 冪等性:既に移行済みならスキップ
    return if NewSystem::User.exists?(legacy_id: legacy_user.id)
 
    NewSystem::User.create!(
      legacy_id: legacy_user.id,
      email: legacy_user.email.downcase.strip,
      name: normalize_name(legacy_user.name),
      created_at: legacy_user.created_at,
      password_digest: legacy_user.encrypted_password,  # Deviseの形式をそのまま
      migrated_at: Time.current
    )
  end
 
  def normalize_name(name)
    name&.strip || "Unknown"
  end
end

フェーズ4:トラフィックの段階的切り替え

// Lambda@Edge: origin-request でトラフィック分割
// CloudFront のオリジンリクエスト時に実行される
exports.handler = async (event) => {
  const request = event.Records[0].cf.request;
  const uri = request.uri;
 
  // 段階的な切り替え:注文一覧APIを5%→新システムへ
  if (uri.startsWith('/api/v1/orders') &&
      request.method === 'GET' &&
      shouldRouteToNew(request, 0.05)) {
 
    // 新システムへのルーティング
    request.origin = {
      custom: {
        domainName: 'new-api.myapp.internal',
        port: 443,
        protocol: 'https',
        path: '/api/v2',
        sslProtocols: ['TLSv1.2'],
        readTimeout: 30,
        keepaliveTimeout: 5
      }
    };
 
    // どちらのシステムで処理したかをヘッダーで記録
    request.headers['x-routing-target'] = [{ key: 'X-Routing-Target', value: 'new' }];
  }
 
  return request;
};
 
function shouldRouteToNew(request, percentage) {
  // ユーザーIDを使って一貫したルーティング(同じユーザーは常に同じシステムへ)
  const userId = request.headers['x-user-id']?.[0]?.value;
  if (userId) {
    const hash = parseInt(userId) % 100;
    return hash < (percentage * 100);
  }
 
  // ユーザーIDがない場合はランダム
  return Math.random() < percentage;
}

二重書き込みパターン

移行中、データ整合性を保つために新旧両方に書き込む。

# app/services/order_creation_service.rb(移行期間中)
class OrderCreationService
  DUAL_WRITE_ENABLED = ENV["DUAL_WRITE_ENABLED"] == "true"
  DUAL_WRITE_ASYNC = ENV["DUAL_WRITE_ASYNC"] == "true"
 
  def create_order(params)
    # レガシーシステムに書き込み(常に・本番の真実のソース)
    legacy_order = create_in_legacy(params)
 
    # 新システムにも書き込み(移行期間中)
    if DUAL_WRITE_ENABLED
      if DUAL_WRITE_ASYNC
        NewSystemSyncJob.perform_later("Order", legacy_order.id, params)
      else
        sync_to_new_system(legacy_order, params)
      end
    end
 
    legacy_order
  end
 
  private
 
  def create_in_legacy(params)
    # 既存の注文作成処理
    LegacyOrderCreationService.new(params).call
  end
 
  def sync_to_new_system(legacy_order, params)
    NewSystem::Order.create!(
      legacy_id: legacy_order.id,
      user_id: params[:user_id],
      total_amount: params[:total_amount],
      status: legacy_order.status,
      created_at: legacy_order.created_at
    )
  rescue => e
    # 新システムへの書き込み失敗はログのみ(本番動作を止めない)
    Rails.logger.error("新システム書き込み失敗: #{e.message} (order##{legacy_order.id})")
    Sentry.capture_exception(e, extra: { order_id: legacy_order.id })
    # DLQに入れて後で再試行
    DualWriteRetryJob.perform_later("Order", legacy_order.id)
  end
end

検証:シャドウモード

新システムの処理結果を旧システムと比較する(ユーザーには見せない)。

# app/services/shadow_mode_service.rb
class ShadowModeService
  COMPARISON_ENABLED = ENV["SHADOW_MODE_ENABLED"] == "true"
  SAMPLE_RATE = ENV.fetch("SHADOW_SAMPLE_RATE", "0.1").to_f  # デフォルト10%
 
  def self.compare(entity_type, entity_id)
    return unless COMPARISON_ENABLED
    return if rand > SAMPLE_RATE
 
    ShadowComparisonJob.perform_later(entity_type, entity_id)
  end
end
 
# app/jobs/shadow_comparison_job.rb
class ShadowComparisonJob < ApplicationJob
  queue_as :low_priority
 
  def perform(entity_type, entity_id)
    legacy_data = fetch_from_legacy(entity_type, entity_id)
    new_data = fetch_from_new_system(entity_type, entity_id)
 
    if new_data.nil?
      Rails.logger.warn("シャドウ: 新システムに#{entity_type}##{entity_id}が存在しない")
      return
    end
 
    diff = deep_diff(legacy_data, new_data)
 
    if diff.any?
      ShadowDiscrepancy.create!(
        entity_type: entity_type,
        entity_id: entity_id,
        diff: diff.to_json,
        legacy_snapshot: legacy_data.to_json,
        new_snapshot: new_data.to_json
      )
      Rails.logger.warn("シャドウ: 不整合検出 #{entity_type}##{entity_id}")
    else
      ShadowComparisonMetric.increment("match")
    end
  end
 
  private
 
  def fetch_from_legacy(entity_type, entity_id)
    entity_type.constantize.find(entity_id).as_json
  end
 
  def fetch_from_new_system(entity_type, entity_id)
    "NewSystem::#{entity_type}".constantize.find_by(legacy_id: entity_id)&.as_json
  end
 
  def deep_diff(a, b, path = "")
    diffs = {}
    (a.keys | b.keys).each do |key|
      full_path = path.empty? ? key.to_s : "#{path}.#{key}"
      va, vb = a[key], b[key]
 
      if va.is_a?(Hash) && vb.is_a?(Hash)
        diffs.merge!(deep_diff(va, vb, full_path))
      elsif va != vb
        diffs[full_path] = { legacy: va, new: vb }
      end
    end
    diffs
  end
end
Loading diagram...

ロールバック戦略

# CodeDeploy でのカナリアリリース設定
Resources:
  NewSystemDeploymentGroup:
    Type: AWS::CodeDeploy::DeploymentGroup
    Properties:
      DeploymentConfigName: CodeDeployDefault.ECSCanary10Percent5Minutes
      # 10%に5分間デプロイ → エラー率確認 → 全切り替え
 
  HighErrorRateAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: NewSystemHighErrorRate
      MetricName: 5XXErrorRate
      Namespace: AWS/ApplicationELB
      Threshold: 1.0  # 1% でアラーム → 自動ロールバック
      ComparisonOperator: GreaterThanThreshold
      EvaluationPeriods: 2
      Period: 60
      Statistic: Average
      AlarmActions:
        - !Ref RollbackSNSTopic
# CloudFrontのルーティングを即座にロールバックするスクリプト
class CloudFrontRollback
  def self.revert_to_legacy(distribution_id)
    client = Aws::CloudFront::Client.new
 
    config = client.get_distribution_config(id: distribution_id)
    etag = config.etag
    dist_config = config.distribution_config
 
    # 全ビヘイビアをレガシーに向ける
    dist_config.cache_behaviors.items.each do |behavior|
      behavior.target_origin_id = "LegacyRailsApp"
    end
 
    client.update_distribution(
      id: distribution_id,
      if_match: etag,
      distribution_config: dist_config
    )
 
    Rails.logger.info("CloudFront ロールバック完了")
  end
end

WARNING

移行期間中は二重書き込みによりDBの書き込み負荷が増加する。RDSのインスタンスサイズとIOPSを事前にスケールアップしておく。また、移行期間が長期化しないよう、明確なタイムラインと完了基準を定める。「いつかやる」では永遠に終わらない。

移行の完了判定

# 移行完了チェックリスト
class MigrationCompletionChecker
  THRESHOLDS = {
    data_sync_ratio: 0.999,     # 99.9%のデータが移行済み
    inconsistency_rate: 0.001,   # 0.1%未満の不整合
    error_rate: 0.001,           # 1%未満のエラー率
    legacy_traffic_ratio: 0.05   # 5%未満のレガシートラフィック
  }.freeze
 
  def ready_to_complete?
    results = {
      data_migrated: data_sync_ratio >= THRESHOLDS[:data_sync_ratio],
      no_inconsistencies: recent_inconsistency_rate < THRESHOLDS[:inconsistency_rate],
      new_system_stable: new_system_error_rate < THRESHOLDS[:error_rate],
      legacy_traffic: legacy_traffic_ratio < THRESHOLDS[:legacy_traffic_ratio]
    }
 
    Rails.logger.info("移行完了チェック結果: #{results}")
    results.all? { |_, v| v }
  end
 
  def status_report
    {
      data_sync_ratio: data_sync_ratio,
      recent_inconsistencies: ShadowDiscrepancy.where("created_at > ?", 24.hours.ago).count,
      new_system_error_rate: new_system_error_rate,
      legacy_traffic_ratio: legacy_traffic_ratio,
      estimated_completion: estimated_completion_date
    }
  end
 
  private
 
  def data_sync_ratio
    legacy_count = LegacyOrder.count.to_f
    return 1.0 if legacy_count.zero?
 
    new_count = NewSystem::Order.count
    new_count / legacy_count
  end
 
  def recent_inconsistency_rate
    total = ShadowComparisonMetric.total_comparisons(since: 24.hours.ago)
    return 0.0 if total.zero?
 
    ShadowDiscrepancy.where("created_at > ?", 24.hours.ago).count.to_f / total
  end
 
  def new_system_error_rate
    CloudWatchMetrics.get_average("NewSystem/5XXErrorRate", period: 3600)
  end
 
  def legacy_traffic_ratio
    total = CloudFrontMetrics.total_requests(period: 3600)
    return 0.0 if total.zero?
 
    legacy = CloudFrontMetrics.requests_to_origin("LegacyRailsApp", period: 3600)
    legacy.to_f / total
  end
 
  def estimated_completion_date
    # 現在の移行速度から残り時間を計算
    migration_rate = MigrationCheckpoint.find_by(entity_type: "Order")
    return nil unless migration_rate
 
    remaining = LegacyOrder.count - migration_rate.migrated_count
    rate_per_hour = migration_rate.migrated_count / (Time.current - migration_rate.created_at) * 3600
 
    return nil if rate_per_hour.zero?
 
    Time.current + (remaining / rate_per_hour).hours
  end
end

まとめ

フェーズ作業リスク期間目安
1. ファサード設置ルーティング層を追加低(既存動作変わらず)1週間
2. 新機能は新システム新機能から始める低(新機能なので比較対象なし)進行中
3. 二重書き込み新旧両DB更新中(書き込みコスト増)2〜4週間
4. シャドウモード読み取りを並列実行・比較低(ユーザー影響なし)2〜4週間
5. カナリアリリース5%→50%→100%に段階切替中(監視が必要)2〜8週間
6. レガシー廃止Legacy停止低(十分な検証後)1週間

「2年かかりますね」ユウキが言った。

「そう。でも止めずに動かし続けながら移行できる。それがビッグバンリプレイスメントより優れている点。各フェーズにチェックポイントがある。うまくいかなければ前のフェーズに戻れる」

「完全移行しなくてもいいんですね」

「そう。パターンの名前を覚えておいて。あとでリファレンスになる。『このシステムはストラングラーフィグ移行中』と言えば、チームメンバーに瞬時に状況が伝わる」


次章では、外部サービスの障害がシステム全体に波及しないようにするサーキットブレーカーパターンを学びます。