🐧

安全に倒し切るリリースをするために:15年来のレガシーシステムのフルリプレイス挑戦記

2024/11/06に公開

はじめに

こんにちは!あっという間に秋が終わり、クリスマスソングが流れる季節がきましたね。
今回は、どうやら多くの人が苦しんでいると聞くレガシーシステムと向き合う話です。

弊チームでは先日、15年来のレガシーシステムを、バグ0でリプレイス&新機能の追加リリースを実施することに成功しました!既存機能の改修ではなく、既存を大きく作り替えつつI/Oも変えるというものだったので、新しい検証方法やリリース手法を組み合わせてリスクを最小限に抑える方法をとりました。

今回は、その予期せぬトラブルを未然に防ぐことができた「安全に倒し切ったリリース」についてお話します。他のプロダクトでも応用可能な方法なので、システム刷新を検討している方の参考になれば幸いです!

なぜ安全に倒し切りたいか

私たちが今回触ったのは、前述の通り15年前から存在する、プロダクトの中でも最もレガシーに類される場所です。以前からパフォーマンスの問題や、要望機能が追加できないなどの問題はありつつも、リスクと影響範囲が大きさ故に、15年間微細な修正を当てるのみで大掛かりなリファクタはできていませんでした。

よく聞く話ではありますが、安全に倒し切りたい理由としては以下があります。

  • システムの根幹機能であり、最重要な処理の一つである
    • 1時間で約10万リクエストを処理している
    • プロダクトの3大機能の1つに数えられる、中核処理である
  • 失敗した時のリカバリが困難
    • DB汚染になるので、ソースの差し戻しだけで解決できない
  • 影響範囲が広すぎる
    • 思いつくだけのパターンをやっても、網羅性のあるテストを作るのは難しい
    • 「ここまで検証したら大丈夫だろう」のラインが不透明だった

顧客のビジネスを支えるうえで大規模なインシデントは許されず、もちろん大きなリスクではありました。しかしそれでも我々のプロダクトが持つ大きな責任を考えた結果、今回のフルリプレイスに踏み切ることとなりました。しかし万が一にも止めるわけにはいかないため、今回の「安全に倒し切るリリース」という現実解が生まれたというわけです。

何が問題だったか

一番の問題は、やはり「ミスは許されないのに、見落としのないテストが作れない」ことでした。あらゆるパスを網羅した見落としのないテストを作ることは難しく、ドメイン知識のあるメンバーが集まっても、その場で思いつくパターンだけでは不完全でした。

また網羅性の高い大規模なテストの作成には、将来的なメンテナンスの問題もあります。属人的な負の遺産を残さないという観点でも、非常に難しいところでした。

何をしたか

今回は実装完了後、こちらの順番で検証・リリースを行いました。

  1. ペンギンテスト
  2. 手検証
  3. テストユーザーへの先行公開(通知あり)
  4. カナリアリリース(通知なし)
  5. UIリリース

1. ペンギンテスト

まずは前述の通り、「見落としのないテスト」をどう実現するかを考えました。そこで思いついたのがペンギンテスト(命名:弊チームリーダー)です。

ペンギンテストとは

ペンギンテストは、こちらの記事からインスピレーションを受けて、少し改変したものです。

https://www.publickey1.jp/blog/12/_1040.html

上記の記事を読んでいただくとわかりますが、膨大な組み合わせをミスなくテストするための仕組みということで、今回我々が求めていたものとかなり近いですね。(ちなみにペンギンテストという名前の由来は、自動改札機→suica→ペンギンという安直なものです。)

ここからインスピレーションを受けて発案したペンギンテストとは、ユーザー影響を出さずに、本番環境で実際のユーザーデータを使ってテストすることで漏れをなくす検証手法です。簡単に説明すると、以下のような仕組みになっています。

ペンギンテストの仕組み

  1. 既存の通り、旧ロジックを実行
     ----- ここからペンギンテスト -----
  2. 新ロジックを実行
  3. 新旧の結果を比較
    • 新旧の結果に差異がない場合:差異がない旨のログを出力
    • 新旧で結果が異なる場合:バグってる可能性があるので、差異がある旨と差異の中身(どう異なるか)をログに出力
  4. 差異があろうがなかろうが、採用されるのは旧処理とする(新処理の結果は捨てる)
     ----- ペンギンテストここまで -----
  5. 既存の旧処理の続きに戻る

こちらの写真が、3で出力していた実際のログです。

類似のテスト手法

これはシャドーテストというテスト手法に近いものではありますが、シャドーテストには「本番トラフィックをミラーリングしたテスト環境を作成する」という大きな特徴があります。対してペンギンテストではテスト環境を別途用意するのではなく、本番環境の中でテストを実行しました。
それによりシャドーテストの「テスト環境分の追加コストがかかる」というデメリットを避け、「本番環境で検証できる」というメリットだけを享受することはできましたが、その分この後あげるような問題も発生しました。

https://zenn.dev/hi_ka_ru/articles/deploy-test-pattern-20240513

問題発生と対処方法

ペンギンテストのポイントは「ユーザー影響を出さない」という点ですが、ここでいくつか問題がありました。

【問題①】ペンギンテスト内部でFATALが発生すると処理が止まる

上記「ペンギンテストの仕組み」の2~4の内部で何らかの考慮不足があり、FATALが発生した場合、絶対に止めたくない処理が止まってしまいます。実はペンギンテスト中にこれで一度小さな障害を起こしており、幸いすぐ気付くことができたので以下の対処をしました。

【対処方法①】FATALを検知しつつ握り潰す

大前提、実装時点で見えているFATALの可能性はもちろん潰しますが、考慮漏れというのは見えていないから考慮漏れなのであって、「事前に考慮漏れに気付けばいい」などという対策は考慮漏れの定義に反します。
しかし事前に見えていないところだとしても既存の処理は止めたくないので、あえて上位クラスのThrowableで握り潰し、処理を続行させるという選択をしました。つまり上記「ペンギンテストの仕組み」の2~4のどこかでFATALが発生した場合、ペンギンテストをなかったことにして5まで飛びます。

もちろん握り潰して終わりではなく、FATALが発生した旨のログは出力するので、ユーザー影響を出さずにゆっくり調査することができます。

//旧ロジックの実行

//ペンギンテスト
try {
    //新ロジックの実行
    //新旧ロジックの比較
} catch (Throwable $e) {
    getLogger()->fatal("❗ペンギンテスト中にエラーが発生しましたが、握りつぶして継続します。");
    getLogger()->fatal($e);
}

//旧ロジックの続き


【問題②】新旧両方の処理をすることになるので、処理時間が増加 & メモリを食う

ペンギンテストは新旧ロジックの比較をするため、シンプルに処理の量が2倍になります。そのため処理時間が数倍になったり、CPUが張り付いたり、メモリが溢れたりというトラブルが続出しました。
今回のペンギンテストでも、インフラ周りを見てくれている部署と連携して、パフォーマンスログを監視しながら進めました。

【対処方法②】

ペンギンテストを実施する確率(我々は当選確率と呼びます)と稼働時間をコントロールをしながらパフォーマンス改善をすることで、事なきを得ました。

今回の処理は止めたくないとは言っても、バッチ処理のため、一度失敗しても次で成功すれば許容できるものでした。つまりペンギンテストによる処理が失敗し続けることを防げれば良かったのです。

そこでペンギンテストの当選確率をコントロールするためのメソッドを作成し、連続でペンギンテスト実施対象ユーザーにならない工夫をしました。最初は当選確率1/100から始めて、徐々に1/50、1/30、1/10、1/5、1/3と高めていき、最終的には100%でペンギンテストを通るようにしました。

当選確率が高まってきた後期は、処理失敗が続く可能性も高まっていたため、ペンギンテストの稼働時間も指定しました。何かあった時にすぐ対応できるように、最初は平日の10:00-17:00のみ稼働させ、安定した後24時間営業に切り替えました。

またこれらのコントロールと同時に、メモリ使用量の改善やクエリのチューニング、設計の見直しなどを行い、処理失敗率を下げていくことで、最終的に24時間100%ペンギンテストを回しても問題ないところまでパフォーマンスが改善しました。

新旧比較部分のサンプルコード

新旧ロジックの比較部分のサンプルコードとして、超簡略化した例を置いておきます。
例えば、今まで乗算で計算していたのに、ある日突然「乗算は今後使用禁止とする」と言われて泣く泣く加算を使うよう改修した場合です。本番環境でペンギンテストをしたことで、負の整数という想定外の値が入ってくるパスがあることに事前に気付たので、障害にすることなくデバッグすることができます。(そもそも想定外の値が入らないようにせえよ!はご尤もですが、前述「なぜ安全に倒し切りたいか」の前提なので一旦スルーでお願いします。)

<?php
/**
* 旧ロジック。乗算を使用する。
* TODO: ペンギンテスト完了時に削除する。
*/
function multiple(int $a, int $b): int
{
    return $a * $b;
}

/**
* 新ロジック。加算を使用する。
* TODO: ペンギンテスト完了時にmultipleにリネームする。
*/
function multiple2(int $a, int $b): int
{
    $sum = 0;
    for ($i = 1; $i <= $b; $i++) {
        $sum += $a;
    }
    return $sum;
}

/**
* 新旧ロジックの結果を比較し、差異の有無を判断する。
*/
function compare(int $a, int $b): void
{
    if ($a !== $b) {
        var_dump("🔥 差異があります。旧:" . $a . "、新:" . $b);
    } else {
        var_dump("🐧 差異はありませんでした");
    }
}

/**
 * 営業時間内であれば、10分の1の確率でtrueを返す。
*/
function isPenguinTestUser(): bool
{
    date_default_timezone_set('Asia/Tokyo');
    $currentDay = date('N'); //現在の曜日(1=月曜日, 7=日曜日)
    $currentHour = date('G'); //現在の時間(0-23)
    if ($currentDay > 5 || $currentHour < 9 || $currentHour > 17) {
        return false;
    }
    return rand(0,9) === 0;
}

$arr_a = [1, 2, 3];
$arr_b = [4, 0, -1];

// 旧ロジックの実行
$multiple = array_map('multiple', $arr_a, $arr_b);

// ペンギンテストの実施
if (isPenguinTestUser()) {
    var_dump('ペンギンテストのプロセスに当選しました');
    $multiple2 = array_map('multiple2', $arr_a, $arr_b);
    array_map('compare', $multiple, $multiple2);
} else {
    var_dump('ペンギンテストのプロセスに当選しませんでした');
}

// 旧ロジックの結果を採用して、アップデート文を打つ
// 新ロジックの結果は破棄する

ペンギンテスト当選時の実行結果はこのようになります。実際はidやDBの値、ユーザー情報などを一緒に出すので、それらと差異内容を突き合わせながらデバッグを進めていきます。

string(57) "ペンギンテストのプロセスに当選しました"
string(38) "🐧 差異はありませんでした"
string(38) "🐧 差異はありませんでした"
string(43) "🔥 差異があります。旧:-3、新:0"

ペンギンテストに当選しなかった場合の実行結果はこのようになります。

string(66) "ペンギンテストのプロセスに当選しませんでした"

ちなみに今回ログに絵文字を入れていたのは、他箇所のログでは絶対出ないであろう文字を使うことで絶対的な印とし、ログ検索を容易にするためでした。

🐧 penguin genocide party🐧

これらの問題を乗り越えて、ペンギンテストの稼働を安定させつつ、実際にログに出力された差異を見ながらデバッグを行います。我々はこれを「ペンギン潰し」と呼び、チームでは日夜 🐧penguin genocide party🐧 が開催されたため、チーム外から「あそこは毎日ペンギンを殺してて物騒だ」と言われることとなりました。


カレンダーに登録された🐧penguin genocide party🐧


怯える開発部Mgr

ペンギンテストまとめ

我々はこのようにして、本番環境で実際のユーザーデータを利用することで「見落としのないテスト」を実現し、新ロジックの正しさを担保しました。

初めての検証手法だったことや、偽陽性(幻のペンギンということでファントムペンギンと呼ばれた)に悩まされたこともあり、ペンギンテストだけで約1ヶ月を要し、我々は途方も無い数のペンギンを潰しました。
しかしこれは言い換えれば、もしもペンギンテストを実施していなければ、この1ヶ月で潰したペンギンの数だけ障害が発生していた可能性があるということです。ペンギンテストのおかげで重大な考慮漏れが見つかったこともあり、また結果的に全体的なパフォーマンス改善にも繋がり、やっといてよかった...!と思えた検証でした。


ファントムペンギンというワードの誕生


チャットに飛び交うファントムペンギンに困惑する事業部長


真面目にファントムペンギンの定義を説明する私

2. 手検証

ペンギンテストで内部ロジックの正しさが担保できたら、次は画面から手動で一通り検証します。

今回はそもそもが新機能実装のためのフルリプレイスのため、新機能の設定画面UIを公開しない限り、内部的な算出ロジックが変わるだけで、算出される結果の値は変わりませんでした。
前段のシャドーテスト段階ではUIを提供しないので、シャドーテストだけでは通っていないパス=未検証のパスが存在します。そこで実際に設定画面をいじって算出結果が変わるようにし、シャドーテストでは未検証のパスを通していきます。この際、設定画面のUI検証も一緒に進めます。こちらが事実上の結合テスト扱いになります。

3. テストユーザーへの先行公開(通知あり)

検証が済んでユーザーに公開できる(はずの)形になったら、この段階に入ります。事前にご連絡のうえ協力いただいた一部企業にのみ、設定画面のUI含め新機能を先行公開します。 設定画面を提供するので、算出される結果は変わり得ります。

この一部企業は、こちらを条件に選定しています。

  • 以前の問い合わせなどから、新機能に興味を持っていただけていること
  • 十分な検証はしたがバグの可能性は捨てきれないことを承知のうえで、新機能を使っていただけること
  • 平均リクエスト数が、万が一何かあった際に取り返しのつかないレベルまで発展しない運用・規模であること

今回は上記の条件に合う3ユーザー様に、テストユーザーとしてご協力いただきました。この状態でログ監視ツールを使って、1週間ほどモニタリングをして異常がないかを確認します。

また、今回は諸都合により省くことになりましたが、本来はここでテストユーザーにUIアンケートをお願いするつもりでした。フィールドワークやユーザビリティテストの文化がある組織なら、もっと早い段階、たとえばUIの実装前などに実施できると理想的ですね。

4. カナリアリリース(通知なし)

テストユーザーへの先行公開が問題なく終わったら、いよいよ内部ロジックを全体へリリースしていきます。

ここでも設定画面のUIは提供せず、内部的なロジックの変更のみのリリースとなるため、ユーザーへの通知は行いません。
最初はごく一部の企業にのみリリースし、段階を踏んで対象企業を広げていきます。
今回は、3日目まではリクエスト数順に10社ずつ足して様子を見て、問題なさそうだったので4日目から100社、500社、1000社と足すユーザー数を増やしていきました。最終的に1週間ほどかけて、全ユーザーの内部ロジックをリプレイスされた新しいものへ置き換えました。

5. UIリリース

最後に、設定画面UIを全体にリリースします。
ここまできてやっと、内部的なフルリプレイスが完了した状態で、新機能が日の目を浴びることができます。これで全ユーザーが、完全体の新機能を自由に使えるようになりました🎉

おわりに

今回はこのように検証手法・リリース手法を組み合わせたことで、大きな障害を出すことなく無事リプレイスを大成功で終えられました。

ペンギンテストは「俺たちが考える最強検証メソッド!」というよりは、当時の私たちがとれたベストな現実解に近いものではあります。しかしペンギンテストのおかげで、15年来のレガシーシステムをほぼバグ0で作り替えられたのもまた事実です。いま同じような壁に直面している方には、有用な解決策の一つなのではないでしょうか。

また今回でリスクを最小限に抑えられた実績ができたので、現在は同じ方法で上記以上にハイリスクな箇所のリプレイスに着手中です。そちらについても、また完遂した暁には記事にしたいと思いますのでお楽しみに。

ありがとうございました!

おまけ

事業部長が生成したファントムペンギン

NE株式会社の開発ブログ

Discussion