Featured image of post コード探偵ロックの事件簿【Strangler Fig】外壁に伸びる蔓〜全面更改が新旧を窒息させる夜〜

コード探偵ロックの事件簿【Strangler Fig】外壁に伸びる蔓〜全面更改が新旧を窒息させる夜〜

全面切替で失敗した基幹刷新を、Strangler Fig と ACL、Facade、feature flag で段階移行する Perl/Moo 実装です

赤線で消えた全面切替

全面切替は、計画書の上では美しかったのです。

00:30 に入口を閉じる。

01:10 に向きを変える。

03:00 までに smoke test を終える。

日曜夜の移行演習室で赤線で消されたのは、その美しさの方でした。壁には runbook、endpoint matrix、rollback 条件、依存システム一覧が貼られています。submit_order には緑丸がついていたのに、cancel_ordershipment_callback には赤い付箋が重なり、nightly_settlement の欄には「未移行」の文字が太く残っていました。

ロックさんは、ホワイトボードに貼られた architecture map を前にして、古い箱を消すのではなく、その外側へ細い緑のテープを伸ばしていました。包み込むような線でした。

「外壁を爆破する前に、蔓の伸びる道を決めるべきだったね、ワトソン君」

「工程表にその役割名はありません」

私はそこまで言ってから、壁の赤い付箋へ目を戻しました。

「ですが、言いたいことは分かります。今夜の rehearsal は、バグが一つ見つかったという話ではありません」

実際、submit_order だけを見れば動いていました。Web の注文は新しい RoutingCore へ届いています。問題は裏側でした。cancel は未実装、warehouse callback は旧 status を前提に別ルートで戻り、CSV レポートは旧DBを source of truth にしたままです。入口だけを切り替えても、建物の中で使っている廊下の工事進捗は揃っていませんでした。

「一枚の札で全部の扉を開けようとしたのです」

「結構。それが今夜の事件だよ」

現場検証 - 一枚の切替札に押し込まれた未完成

最初に見せたのは、失敗した rehearsal で実際に使っていた切替の骨組みでした。

Beforeコード: global flag がすべてを一緒に動かす

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package BangCutoverFacade;

use v5.36;
use Moo;
use Types::Standard qw(Bool Object);

has enable_new_stack => ( is => 'ro', isa => Bool, required => 1 );
has legacy           => ( is => 'ro', isa => Object, required => 1 );
has routing_core     => ( is => 'ro', isa => Object, required => 1 );

sub submit_order ($self, $payload) {
    return $self->_target->submit_order($payload);
}

sub cancel_order ($self, $payload) {
    return $self->_target->cancel_order($payload);
}

sub _target ($self) {
    return $self->enable_new_stack ? $self->routing_core : $self->legacy;
}

この設計の狙いは分かります。役員会は「一つの札で戻せること」を求めます。全面切替を掲げるなら、全部を enable_new_stack にぶら下げたくなる。切替責任者の立場から見れば、それは魅力的です。

けれども、魅力的なのは制御できているように見えるからであって、実際に制御できているからではありません。

新しい RoutingCoresubmit_order だけは実装済みでした。ところが cancel_order はまだです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package RoutingCore;

use v5.36;
use Moo;

sub submit_order ($self, $payload) {
    return {
        target   => 'new',
        order_id => $payload->{order_id},
        status   => 'accepted',
    };
}

sub cancel_order ($self, $payload) {
    die "cancel_order is not migrated for $payload->{order_id}";
}

私は腕を組んだまま、言い訳ではなく事実として説明しました。

submit_order は通りました。だから smoke test では緑がついたんです。ですが、同じ札で cancel_order まで新系へ送れば、未実装のまま落ちます」

ロックさんは cancel_order の行を指で叩きました。

「準備のできた扉も、できていない扉も、同じ鍵で開けようとしたわけだね」

それだけではありませんでした。もっと厄介だったのは、そもそも facade を通っていない廊下が残っていたことです。

Hidden consumer は facade を通らない

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package ShipmentCallbackHandler;

use v5.36;
use Moo;
use Types::Standard qw(Object);

has legacy => ( is => 'ro', isa => Object, required => 1 );

sub shipment_callback ($self, $payload) {
    return $self->legacy->shipment_callback($payload);
}

warehouse callback は古い status code を吐く前提で、旧系へ直結していました。enable_new_stack を上げても、この handler には何の影響もありません。入口だけを新しくしても、建物の裏口が古いままなら、切替は成立しません。

私はそこでようやく、自分が嫌っていた違和感の名前を口にできました。

「Facade を置いたのに、交通整理がありませんでした」

「その通り。窓口はあった。だが順路表がない」

Big Bang Rewrite が失敗しやすいのは、古いコードが存在するからではありません。未移行の責務、隠れた依存、意味の違う payload を、一夜の切替へ圧縮してしまうからです。問題は legacy の存在ではなく、進捗の違う廊下を一枚の札で同時に開けることでした。

推理披露 - 蔓は壁ごとではなく継ぎ目ごとに伸ばす

「では、全面切替をやめても複雑さが残るだけではありませんか」

私は最初にそこを確認しました。temporary component を増やして、借金の置き場を変えるだけに見えたからです。

「残るとも」

ロックさんは、あっさり認めました。

「違いは、月曜朝の全館停止として噴くか、route table と map に隔離されるかだよ」

この返事で、ようやく話が技術の話になりました。複雑さを消すのではなく、置き場を制御する。それが Strangler Fig でした。

切替単位を system から slice に戻す

まず必要なのは、「何をどこで切るのか」を system 全体ではなく slice 単位へ戻すことです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
my $map = MigrationMap->new(
    slices => {
        'submit_order:web' => MigrationSlice->new(
            slice_key        => 'submit_order:web',
            candidate_target => 'new',
            flag_key         => 'submit_order_web',
            hidden_consumers => [],
        ),
        'cancel_order:web' => MigrationSlice->new(
            slice_key        => 'cancel_order:web',
            candidate_target => 'legacy',
            hidden_consumers => [],
        ),
        'report_export' => MigrationSlice->new(
            slice_key        => 'report_export',
            candidate_target => 'legacy',
            hidden_consumers => ['finance_csv'],
        ),
    },
);

ここで大事なのは、クラスの境界ではなく責務の境界です。submit_order:web のように、入口、source of truth、rollback の単位が一緒に決められる切れ目で切る。cancel_orderreport_export は readiness が違うのだから、同じ札に乗せてはいけません。

「module ではなく責務単位で切るんですね」

「結構。system を置き換えるのではない。責務の担当を、継ぎ目ごとに移すのだよ」

Strangler Fig は old と new を長く共存させます。だからこそ、共存の単位を狭くしなければなりません。ここで雑に切ると、今度は「安全に小さく失敗する」代わりに「間違いを小分けに量産する」だけになります。

Facade に持たせるのは dispatch だけ

次に、Facade の責務を絞ります。切替期の Facade は必要です。しかし、必要だからといって何でも背負わせると、今度は migration 中の God Class になります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package FulfillmentFacade;

use v5.36;
use Moo;
use Types::Standard qw(Object);

has legacy    => ( is => 'ro', isa => Object, required => 1 );
has new_stack => ( is => 'ro', isa => Object, required => 1 );
has decisions => ( is => 'ro', isa => Object, required => 1 );
has acl       => ( is => 'ro', isa => Object, required => 1 );
has map       => ( is => 'ro', isa => Object, required => 1 );

sub submit_order ($self, $payload) {
    my $slice_key = $self->_slice_key( 'submit_order', $payload );
    my $target    = $self->decisions->target_for($slice_key);
    return $self->_dispatch( $target, 'submit_order', $payload );
}

sub cancel_order ($self, $payload) {
    my $slice_key = $self->_slice_key( 'cancel_order', $payload );
    my $target    = $self->decisions->target_for($slice_key);
    return $self->_dispatch( $target, 'cancel_order', $payload );
}

Facade 自体は、入口を固定し、route decision を一箇所へ寄せるだけです。translation も exposure policy も retirement judgment も、ここへは入れません。

route decision は別オブジェクトへ逃がします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package MigrationDecisions;

use v5.36;
use Moo;
use Types::Standard qw(HashRef Object);

has migration_map => ( is => 'ro', isa => Object, required => 1 );
has feature_flags => ( is => 'ro', isa => HashRef, default => sub { {} } );

sub target_for ($self, $slice_key) {
    my $slice = $self->migration_map->slice($slice_key);

    return 'legacy' if $slice->candidate_target eq 'legacy';
    return 'new' if !defined $slice->flag_key;

    return $self->feature_flags->{ $slice->flag_key } ? 'new' : 'legacy';
}

私はこの分離を見て、ようやく feature flag の居場所も理解しました。flag は migration の計画そのものではありません。準備のできた slice を、今この利用者へ見せてよいかを決めるだけです。切替順序や hidden consumer の棚卸しまで flag に背負わせると、if 文が工程表の代わりを始めます。

役割は次のように分けるのが素直です。

要素持つ責務持たせない責務
Facade安定した入口と dispatchbusiness rule、semantic translation、retire judgment
MigrationDecisionsroute selection と exposure 制御hidden consumer の一覧管理、翻訳責務
Feature flagcanary、即時退避、露出可否migration order そのもの
ACLold/new の語彙と意味の翻訳traffic exposure の判断
MigrationMapslice、依存、exit condition の記録runtime if 文の代用

ここで初めて、Facade が「単なる窓口」ではなく「交通整理役」になります。整理役であり続ける条件は、荷物の中身まで抱え込まないことでした。

ACL は移行途中の新館を守る防波堤になる

「ACL は前回の話でした。今回は route の問題なのに、まだ必要ですか」

私がそう聞くと、ロックさんは warehouse callback の票を一枚持ち上げました。

「route が決めるのは、どちらへ送るかだ。ACL が守るのは、新しい側がどの言葉で考えるかだよ」

今回の RoutingCore は、新しい建物です。けれども移行の途中では、まだ legacy の callback や status code と接続せざるを得ません。そのとき、新しい側が old vocabulary で育ってしまうと、置換したはずなのに新館の中で古い都合が生き延びます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package LegacyFulfillmentAcl;

use v5.36;
use Moo;

sub normalize_shipment_callback ($self, $payload) {
    my %status_map = (
        P => 'pending',
        S => 'shipped',
        X => 'cancelled',
    );

    my ($order_id) = $payload->{legacy_order_id} =~ /^L-(.+)$/;

    return {
        order_id => $order_id,
        status   => $status_map{ $payload->{state_code} } // 'unknown',
    };
}

この ACL がやっていることは派手ではありません。L-20012001 に直し、Sshipped に直しているだけです。ですが、移行期にはそれで十分です。新しい側が old payload を直接読まなくて済む。つまり route と translation を分けられる。それが大きい。

私はここで、前夜の rehearsal がなぜ嫌だったのかをもう少し正確に言えるようになりました。私は old system が嫌だったのではありません。new system の中にまで old system の言葉が入り込むことが嫌だったのです。

撤去条件を書かなければ temporary は permanent になる

残る疑問は一つでした。

「temporary component を、いつ消せると判断するんですか」

これに答えられない限り、私はどんな仮設も受け入れられません。Strangler Fig が安全な移行戦略だと言われても、temporary が無期限なら、それは新しい遺構です。

ロックさんは、ここで初めて wall map ではなく、撤去条件の方を指しました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package MigrationMap;

use v5.36;
use Moo;
use Types::Standard qw(HashRef);

has slices => ( is => 'ro', isa => HashRef, required => 1 );

sub ready_to_retire ($self, $slice_key, $telemetry) {
    $self->slice($slice_key);

    return 0 if ( $telemetry->{divergence_count} // 0 ) > 0;
    return 0 if ( $telemetry->{rollback_count} // 0 ) > 0;
    return 0 if ( $telemetry->{direct_callers_remaining} // 0 ) > 0;
    return 0 if !( $telemetry->{flag_removed} // 0 );
    return 1;
}

divergence が残っているか。

rollback が発生していないか。

facade を通らない direct caller が消えたか。

flag を抜いたか。

この四つが揃って初めて、その slice は legacy route を外せます。私はこの関数を見て、ようやく temporary という言葉に期限が入った気がしました。

「一時的という言葉は、撤去条件を持って初めて意味を持つのですね」

「そうだ。期限なき仮設は、ただの新しい古墳だよ」

テストで確認する - 一枚札からカード単位へ

Strangler Fig を説明だけで終わらせると、どうしても「設計の雰囲気がいい話」に見えます。そこで今回は、Before と After をテストで固定しました。

Before では、次の二つが起きます。

  • enable_new_stack を上げると submit_order だけでなく cancel_order まで新系へ送られる
  • facade を通らない ShipmentCallbackHandler が old vocabulary をそのまま運び込む

After では、逆に次の四つが通ります。

  • submit_order:web だけを new へ route できる
  • cancel_order:web は legacy に残せる
  • feature flag を下ろせば prepared slice でも legacy へ戻せる
  • callback payload の translation と temporary route の retirement を独立に管理できる

この差は小さく見えて、実務では決定的です。smoke test の緑が「全部通った」を意味しなくなります。代わりに「このカードは通った」が言えるようになる。失敗しても、赤く塗り戻すのは建物全体ではなく、そのカードだけで済みます。

	flowchart LR
    Client[Web Client] --> Facade[FulfillmentFacade]
    Facade --> Decisions[MigrationDecisions]
    Flags[Feature Flags] --> Decisions
    Map[MigrationMap] --> Decisions
    Decisions -->|submit_order:web| New[RoutingCorePort]
    Decisions -->|cancel_order:web| Legacy[LegacyFulfillmentPort]
    Callback[Legacy Callback] --> ACL[LegacyFulfillmentAcl]
    ACL --> New

私はこの図を見ながら、ようやく「段階移行」と「先送り」の違いを説明できるようになりました。先送りは、判断を future の cutover に積み残します。段階移行は、判断を今ここで map と telemetry に書き出します。書き出したものだけを、順に消していきます。

壁に残ったのは、撤去条件つきの蔓

話が終わったあと、私は壁の中央から赤いプレートを外しました。

ALL TRAFFIC TO NEW

その代わりに、submit_order:webcancel_order:webshipment_callbackreport_export と書いたカードを並べました。今夜の時点で緑にできるのは一枚だけです。けれども、それでよいのだと初めて言えました。全部を一度に緑へ変えられないことは、失敗ではありません。順番を持たずに全部を動かそうとしたことが失敗だったのです。

「これなら、切替判定の会議で説明できます」

「説明できる設計は、たいてい戻し方も持っている」

ロックさんは、外した赤いプレートを眺めてから、軽く持ち上げました。

「立派な証拠品だ。次の事件でも、全面切替を正義と呼ぶ者がいたら見せるとしよう」

私は笑いませんでした。あれは冗談というより、運用ルールでした。

全面切替の一行は消えました。壁に残ったのは、ようやく現実の順序で並んだ切替のカードでした。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
全 operation を一枚の全面切替フラグに押し込むことStrangler Fig による slice 単位の段階移行失敗半径を system 全体から slice 単位へ縮められる
route、flag、translation を facade 一つへ混載することFacade + MigrationDecisions + ACL の責務分離移行中の God Class 化を防げる
hidden consumer を棚卸ししないことMigrationMap による依存の見える化smoke test の外にある依存を切替前に扱える
temporary component に期限を付けないことexit condition による retirement 判断仮設を legacy と一緒に永住させずに済む

推理のステップ

  1. facade を通らない hidden consumer を含め、入口と裏口を棚卸しする
  2. system 全体ではなく capability / channel 単位で migration slice を定義する
  3. stable な facade を置き、dispatch だけを一箇所へ集約する
  4. route decision と feature flag を分け、露出制御を if 文の沼にしない
  5. 新しい側が old vocabulary で汚れないよう ACL を seam に置く
  6. divergence、rollback、direct caller、flag 削除を exit condition として記録する
  7. 緑になった slice から順に legacy route と temporary component を撤去する

ロックより

古い館を壊すなと言っているのではない。壊す順を誤るなと言っているのだよ、ワトソン君。一夜で外壁を吹き飛ばせば、支えていた蔓まで失われる。ならば逆に、新しい蔓を一枝ずつ伸ばしたまえ。

route を決める者、言葉を翻訳する者、退出条件を書く者。その三者を同じ机へ縛りつけるな。役を分けておけば、崩れるのは建物全体ではなく一本の枝で済む。移行とは、正しい順番で縮小する混沌なのだよ。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。