レガシーシステムを可用性・スケーラビリティ・変更容易性を高める構造へ移行するアプローチを検討する

1. この資料について

以前、大規模なWebサービス(レガシーシステム)のリプレイスに関わる機会がありました。そこで得た経験を、機密情報に触れないよう抽象化しつつ、より汎用的に活用できる形で整理してみました。

本稿は、古いコードをリプレイスする際に、どのような前提を置き、論点を整理し、解決策を設計していくかをまとめた、思考実験形式の技術メモです。

2. テーマ

長年運用されたレガシー SaaS を対象に、可用性・スケーラビリティ・変更容易性を高める構造へ移行するためのアプローチを検討する。

※本資料は経験を一般化した思考実験であり、構成や数値は特定の企業・システムの実データではありません。

3. レガシーシステムの概要(リプレイス対象)

  • サービス:会員機能を持つコンテンツ配信系 SaaS(2010年以前を想定)
  • 全部入りのモノリスで HTML レンダリングと API が未分離。
    • 古い言語バージョン+独自フレームワーク、グローバル状態や薄いテストが積み上がっている。
  • DB は MySQL(Master+Read Replica)。ストアドプロシージャ等に業務ロジックが埋まりがち。
  • memcached はキャッシュ用途(冗長化なし)
  • NFS で画像・サムネイルなど大量のメディアファイルを保存
  • cron/バッチ等が増殖(メール、サムネ生成、予約投稿、課金同期など)
  • 監視はインフラ指標(CPU/メモリ等)程度

アーキテクチャ全体像:

図1

4. 課題

  • 本サービスは事業の中核プロダクトであり、継続的な改善が必要で、サービス停止は許容されない。
  • 古い言語バージョン+独自フレームワーク等の技術的負債の蓄積で、改善のための変更リスク・コストが増大している。
  • コードベースが巨大になり、全体把握や障害時の切り分けが難しい。

5. ゴール設定(到達像)

思考実験としての前提:
今回は技術的な設計論にフォーカスするため、予算・期間などのリソースは現実よりも確保できるという条件を設定し、費用対効果の評価は意図的に除外しています。(費用対効果の観点が入ると、議論が発散すると思ったためです)

信頼性:

  • SLO (30日)・エラーバジェット
    • ログイン成功率:99.9% (約43分/月の障害を許容)
    • 課金成功率:99.9%
    • 主要ページの閲覧成功率:99.5% (約3.6時間/月の障害を許容)
  • 上記 SLO に紐づく SLI を観測可能にし、運用判断・リリース判断に利用する。
  • 障害時対応の標準化を推進する(Runbook など)。

デリバリと運用:

  • コンテナオーケストレーションにより、水平スケール/縮退/自己修復等を実現する。
  • N% リリース(カナリア)を前提に、入口のルーティング制御(新旧振り分け)とロールバック可能性を要件に含める。

アーキテクチャ刷新:

  • SPOF(フェイルオーバーなしのRDB、NFS、キャッシュ等)を段階的に排除。
  • ドメイン境界を明確化し、影響範囲の局所化と開発スケーラビリティの向上を目指す。
  • レガシーコード刷新計画は別項(7)に詳細を記します。

6. アーキテクチャの更新計画

  • モノリス脱却の最初の一歩として、ユーザー・認証/課金/コンテンツ等のドメイン境界を定義する(影響範囲の局所化、スケールと変更容易性の向上が目的)。(注:初期段階では過剰に境界を区切りすぎないこと)
  • 将来のクライアント刷新の可能性を考慮し、API を中心にアーキテクチャを設計し、クライアントから利用できる形にする。
  • HTML レンダリングは SSR として分離し、SSR 内部でも「HTML 組み立て」と「合成(Compose)」を分けて肥大化(API 以外全部入り)を構造的に抑制することを目指す。
  • RDB は HA 構成に移行し、フェイルオーバー前提にする。
  • キャッシュは Redis Cluster へ移行し、冗長性を高める。
  • cron 中心のバッチは、キュー+ワーカーへ移行し、再試行しやすい仕組みへ転換する。
  • NFS は Object Storage へ移行し、CDN 配信を採用。Direct Upload を検討する。

アーキテクチャ全体像:

図2

組織変更:

  • プラットフォーム(インフラ)を共通基盤として提供可能な組織構造に転換する(CI/CD、監視、実行環境)
  • ドメイン境界でグループ化されたクライアント・バックエンド協力体制を構築する。
  • 初期段階では SRE は境界を横断し、全体を横串で見る。SLO 運用・リリース整備等を推進する。

7. レガシーシステム(ソースコード)の刷新計画

7.1. 戦略

対象システムは「全部入りモノリス」「HTMLレンダリングとAPIの未分離」「古い言語バージョン+独自フレームワークに固定」といった特徴があり、改修の影響範囲が読みづらく、変更コストが積み上がっています。一方で、事業の中核プロダクトのためサービス停止や機能開発の停滞は許されません。以上を前提に、ソースコード刷新計画を整理します。

候補となるソースコード刷新の戦略:

  • 段階的バージョンアップ(既存コード中心に改修、リファクタリング)
  • 新旧モジュール併存型(新機能は新モジュール、旧は段階移行・削除)
  • Strangler(新実装を新しいマイクロサービスとして分離、段階切替しつつ旧を撤去)
  • 全面リプレイス(作り切って一括切替)

評価軸:

  • A. 完遂可能性(最終的に旧コード撤去を完遂できるか。旧コードが残るのは失敗と同じ)
  • B. 平行性(刷新中も機能追加・改善を継続できるか)
  • C. 期間(完遂までの時間)
  A. 完遂可能性

B. 平行性

C. 期間
段階的バージョンアップ ○〜△ ×
新旧モジュール併存型 △(old/new が癒着しやすい) ○〜△(癒着による構造複雑化) △〜×
Strangler △(永遠の二重構造の危険性)

全面リプレイス ×(変更停止)

 

方針:

本件では「継続的な機能開発」が重要であり、ソースコードの特性(独自フレームワーク等)から漸進的改善が難しいケースだと判断します。予定しているドメイン境界の定義/分割との相性が良いことからも「Strangler(段階的リプレイス)」を基本的な戦略とします。

Strangler の注意点(出口戦略)
Stranglerは「新実装の追加」と「旧実装の撤去」をセットで進めて初めて完遂します。二重構造が固定化されないように、旧削除に一定の作業枠(N%)を確保し、進捗を指標で追う仕組みを必ず設定します。(例:旧経路のトラフィック比率、旧エンドポイント数、旧コード変更量など)

7.2. ドメイン境界の設定

対象は「会員機能を持つコンテンツ配信系 SaaS」を想定します。コードベースが大きい前提のため、影響範囲を小さくし、変更しやすい構造に寄せる目的でドメイン境界を定義します。足がかりとして、以下の3つを最小セットとし、この境界に対応してマイクロサービスを設計します。

  • 境界1: コンテンツ領域(コンテンツ管理・配信など)
  • 境界2: ユーザー/認証領域
  • 境界3: 課金領域

7.3. 優先順位の方針

優先度を決める際の観点として「SPOF排除によるリスク低減」「ユーザー価値/SLOへの寄与」「段階的に進めやすいか」等を考えていますが、本資料では詳細を確定していません。

大枠としては、SLI を観測できる状態と段階リリースの土台を作り、その後に SPOF の解消を進め、最後にドメイン境界の強化(コード刷新)に踏み込む流れが自然かなと考えています。優先度の議論は、実際の制約やリスクを並べて検証を進めることができればと思います。

 

8. 旧環境から新環境への移行方針

旧環境から新環境への移行は、段階切替とロールバック可能を前提に以下を軸に考えています。

  • 互換性の担保:本番ログ等から主要なユースケースを抽出し、影響範囲の広いものから優先する。
  • 差分検知:旧の入力を新にも流すことで差分を検知し、出力差分やエラー率を比較して問題箇所を修正する。
  • 段階切替とロールバック:N%リリースで段階的に流入を増やし、エラー率の監視や SLI の推移を注視する。
  • 性能・負荷検証:本番想定の負荷試験を設計し、基準性能とリソース見積もりを確認する。

9. 最後に、リプレイスの結果と期待される効果

  • SLI を整備することで、これまで観測できていなかった問題やボトルネックが見えるようになり、改善の優先度がつけやすくなる。
  • SPOF を段階的に排除することで、ログイン・閲覧・課金などの主要 SLO の達成に直結する改善が見込まれる。
  • コード刷新は高コスト・ハイリスクなため、開発速度(デプロイ頻度、変更リードタイム等)と KPI(例:有料会員の継続、主要ステップの離脱率)を継続的に観測し、数値で確認しながらリプレイスを完遂する。

以上により、プロダクトの改善を継続しつつ、測定可能な形でリプレイスの成果を示すことを目指す。

OpenAPI ドキュメント(OAS 3) から静的HTMLを作成する

モチベーション

OpenAPI (もしくは Swagger) で API ドキュメントを作成し、チームに共有したい。OpenAPI バージョン 3 以降を使いたい。

 

お手軽なのは SwaggerHub を利用することですが、これはちょっと高い。Swagger UI のコンテナを立ち上げるのもありです。しかし、そこまでしなくとも、可読性のある静的HTMLが得られるだけでいい。

その index.html を共有できればよい。

 

コンセプト

OpenAPI ドキュメント  (OpenAPI の仕様に則った openapi.yaml などのファイル)を静的HTMLとして出力する方法は、以下の Stack Overflow の記事に詳細があります。

Generate static docs with swagger - Stack Overflow

 

swagger-codegen コマンドに OpenAPI ドキュメントを与えれば良いのですが、swagger-codegen の環境を整えるのは、けっこう面倒だったため、こちらに手順を整理しました。

 

環境

以下の環境を事前に準備してください。

  • OpenJDK 8 以上($ javac -version が使える)
  • maven ($ mvn -v が使える)

 

サンプルデータ

今回は OpenAPI バージョン 3 の yaml をビルドします。もし必要があれば、公式のサンプルデータを利用してください。

OpenAPI-Specification/petstore.yaml at master · OAI/OpenAPI-Specification · GitHub 

 

 

ビルド手順

swagger-codegen の jar ファイルを作成する

OpenAPI ドキュメントから静的 HTML を生成するために swagger-codegen を利用します。

GitHub - swagger-api/swagger-codegen

 

現在(2019-05-13)、swagger-codegenv2 系と v3 系が並行してリリースされていますOpenAPI バージョン 3 をサポートしているのは swagger-codegen v3 のみです。OpenAPI バージョン 3 のビルドには、必ず swagger-codegen v3 以降を利用してください。

 

以下のコマンドを実行して、swagger-codegen の jar ファイルを作成してください。

 

初回ビルドでは、ライブラリのダウンロードなどに10分前後の時間がかかってしまうと思いますが、2回目以降はキャッシュされるので、高速に実行できるようになります。

 

OpenAPI ドキュメント (petstore.yaml) から静的HTMLを作成する

以下のコマンドを実行してください。

 

これにより out/index.html が作成されたら成功です。

f:id:komiyak:20190514095740p:plain

出力された index.html の例です。

 

まとめ

これまでの手順を、下記にまとめました。

 

開発時は Swagger Editor で yaml を編集し、フロントエンドエンジニアに API 仕様を連絡するときには、上記のコマンドを実行して、生成された HTML を配布します。

クラウドストレージへのファイル転送は、クライアントサイドで行うべき

ウェブサービスの開発で、画像などの大きなデータを Amazon S3 にアップロードする実装を行った。API サーバーは Heroku に構築している。

 

画像データなどを Amazon S3 に転送するのは、これまではバックエンドサーバーの役割であった。なぜかと言うと、クラウドストレージに接続するための API キー は秘密情報であり、クライアントが直接それを利用することはできないので、ユーザー認証機能を持つ API の一部に、ファイルアップロードが含まれていた。

 

f:id:komiyak:20190418232804p:plain

 

仕方がないこととはいえ、構造上、同じファイルをクライアントからサーバーへ、サーバーからクラウドストレージへと、2度転送する必要があり、これは少々無駄であった。

 

 

クライアント・ファイルアップロード

シンプルに考えると、大きなデータの転送は一度で済むほうが、無駄が少ない。しかしそれを、安全に、簡単に実現する方法がこれまでなかった。

 

AWS に「Browser-Based Upload」という、非常に重要な機能がある。

Authenticating Requests in Browser-Based Uploads Using POST (AWS Signature Version 4) - Amazon Simple Storage Service

  

下記の左の図が従来のファイルアップロード、右の図がクライアントサイドのファイルアップロードだ。ファイル転送の2重送信の無駄がなくなっている。

f:id:komiyak:20190418234046p:plain

(引用元:https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-UsingHTTPPOST.html

 

これを支えているのが Amazon S3 の presigned post という仕組みだ。

Uploading Objects Using Presigned URLs - Amazon Simple Storage Service

ユーザーはAPIサーバーから、S3 のアップロード先(バケットとキー)と署名を発行してもらう。これによってユーザーは、限定された条件で S3 に直接アップロードする権利を与えられる。

 

クライアントサイドでのファイル転送が完了したら、後は完了したことを API サーバーにおって知らせればよいのだ。

 

 

Heroku が推奨している方法

Heroku についての話であると断ってはいるものの、Heroku はコンテナである。これからのコンテナ世代の API サーバーにも、ほぼ同じことが言えるだろう。

 

Heroku ではファイルアップロードについて2つの方法を示している。

  1. Direct Upload
  2. Pass-through upload

Using AWS S3 to Store Static Assets and File Uploads | Heroku Dev Center

前者は、つまり前述のクライアント・ファイルアップロード。後者はいったん API サーバーを仲介する昔ながらのファイルアップロード。

 

Heroku は前者を推奨している。

一方、後者は、利用するに当たっては注意事項が添えられている。

 

API サーバーは効率的にリクエストを処理し、可能な限りスループットを最大化するという役目を持っている。大きなデータをクラウドストレージに送信する処理は、API スレッドを長時間ブロックしてしまい、スループットを大きく落としてしまう可能性がある。

 

もちろん、これまではそうするしか方法がなかったわけだが、Direct Upload という新しい選択肢が現実的になったおかげで、API サイドのファイル転送を行う欠点が見過ごせなくなってきた、というのが今日の流れではないだろうか。

 

 

それでも、サーバーサイドでファイルアップロードしてみた 

それでもサーバーサイドでのファイルアップロードを Rails で実装してみた。これは私が関わったプロジェクト特有の制約によって、これをやるしかなかったからだ。

 

実装することはできたのだけど、結果的には、問題点ばかりが目についた。

 

サーバーサイドのファイルアップローダーを実装してしばらくすると、ウェブサーバー(Heroku dynos)が明らかに問題を起こし始めた。以下は、アップローダー実装後のサーバーメモリの推移だ。

f:id:komiyak:20190419002115p:plain

普段 50% 程度のメモリ使用率で安定するように調整していたのだけど、実装後は使用率が 100% を突破して R14 - Memory quota exceeded (OOM)が多発することになった。

 

原因を調べたところ、おそらく API サーバーにアップロードされたファイルを S3 に転送する際に、バイナリデータをメモリロードしなければならないので、それがメモリリソースを圧迫しているようだ。

 

単純計算では 1 dyno に 10 スレッド程度動いているとしたら、10MB 程度のファイルが同時に 10 個アップロードされた場合、瞬間で 100MB のメモリを消費する。(そして残念なことに、実際にはそれ以上にメモリ消費ペースが早かった)

 

 

サーバーメモリの過剰消費問題に取り組む

Rails のアップロード済みファイル(ActionDispatch::Http::UploadedFile)を、S3 API の put_object で愚直に送信するというやり方だったので、メモリ消費量については、すこし工夫の余地がある。

 

バイナリデータを一度に S3 へ送信するのではなく、ちょっとずつデータをロードして、分割して送信するということが可能だ。

 

これを Multipart Upload という。

Uploading Objects Using Multipart Upload API - Amazon Simple Storage Service

f:id:komiyak:20190419004018p:plain

(引用元:https://www.slideshare.net/AmazonWebServices/amazon-s3-multipartuploadwebcast111710

 

Rails でファイルアップロードすると、ActionDispatch::Http::UploadedFile オブジェクトが取得できるが、この実体は ruby core の IO#read なので、任意のサイズで逐次ロードできる。

 

AWS の Multipart Upload は最小転送ファイルサイズが決められており、それは現時点では 5MB である。そのため、小さくすると言っても 5MB が限度なのだ。

 

実際に Multipart Upload を試してみたけど、メモリ消費の面では大きく貢献しなかった。

 

Multipart Upload はギガバイトになるようなデータを、効率的に転送するための仕組みなので、これは見当違いということになった。

 

実装は、下記のサイトを参考にさせていただきました。

Multipart uploads to s3 using aws-sdk v2 for ruby…

 

 

見えてきたものは

どうしてもサーバーサイドでのファイルアップロードを行いたいならば、次の決断をしなければならない。

  • API サーバーのスループットを犠牲にしてでも、ファイル転送を強行する。この場合、サーバーのメモリリソースをかなり多めに確保する。場合によっては、Multipart Upload を行う。
  • もしくは、別の込み入った手段を考える。

 

こんな苦労は買って出ることはなくて、現在ではまず第一に、素直にクライアントサイドからダイレクトアップロードできないか考えるのが筋がよいと思います。