ストラングラーフィグパターン — レガシーを段階的に置き換える
「全部作り直したい」
「正直に言うと」ユウキが言った。「注文システムを全部作り直したいんです。今のコードはグチャグチャで……」
アヤカは少し間を置いた。
「それをビッグバンリプレイスメントという。聞こえはいいけど、ほぼ失敗する」
「なぜですか?」
「今のシステムが処理している全てのケースを新しいシステムで再現できる保証がない。本番で動かすまでバグが見つからない。移行中の1〜2年、既存システムと並行開発しないといけない。——歴史的にほとんどのプロジェクトが途中で断念する」
「じゃあどうすれば……?」
「ストラングラーフィグという木を知ってる?」
ストラングラーフィグとは
ストラングラーフィグ(絞め殺しの木)は、宿主の木に巻きつきながら成長し、最終的に宿主を置き換える植物。種が木の上部に落ち、根を下ろしながら宿主を包んでいく。
ソフトウェアでは、新しいシステムを既存システムの外側に少しずつ構築し、徐々に機能を移していく手法。
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ロールバック戦略
# 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
endWARNING
移行期間中は二重書き込みにより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年かかりますね」ユウキが言った。
「そう。でも止めずに動かし続けながら移行できる。それがビッグバンリプレイスメントより優れている点。各フェーズにチェックポイントがある。うまくいかなければ前のフェーズに戻れる」
「完全移行しなくてもいいんですね」
「そう。パターンの名前を覚えておいて。あとでリファレンスになる。『このシステムはストラングラーフィグ移行中』と言えば、チームメンバーに瞬時に状況が伝わる」
次章では、外部サービスの障害がシステム全体に波及しないようにするサーキットブレーカーパターンを学びます。