概要
こんにちは、SNSピリカ開発チームの冨田です。
今年の1月にAPIサーバをPython3に移行するプロジェクトを完遂しました。
本プロジェクトは、SNSピリカ開発チームのメンバーはもちろん、それ以外のメンバー、業務委託で一時的に関わってくださった方々、テストで関わってくださった方々、すでに退社された方々など、たくさんの方々の知恵が詰まっています。
背景
SNSピリカ1は、2011年から稼働しているサービスです。従来APIサーバはAppEngine/Python2.7上で稼働していました。
Python2.7は2020年初にPython公式のサポートが終了しました。ピリカでも少しずつマイグレーションを進めていましたが、ビジネス上の理由から他の開発に時間と人員を割かなくてはならず、マイグレーションはあまり進められずにいました。
そんな中、2024年1月末のサポートの終了がアナウンスされ、2023年7月よりプロジェクトとして進めることになりました。
結果、無事2024年1月上旬に、無事無停止でリリースすることができました。
全体の流れ
移植作業は、大まかには3つのフェーズに分けられます。
- Phase1: マイグレーションの基盤の作成 (2020年)
- Python3のAPIへの立ち上げ・Python2へのAPIへのリダイレクトを設定
- 使用できなくなるGAEのバンドルサービスの移行
- クリーンアーキテクチャの導入
- Phase2: APIのマイグレーションの開始(2020年〜2022年)
- API単位での移行・リリース
- 見える化ページサービスの移行
- Phase3: APIのマイグレーションプロジェクト(2023年〜)
- 全てのAPI移行・リリース
- Web版フロントエンドの移行
- admin APIの移行
全体のインフラ構造とその前後
全体として、1つのAppEngineだったものをそれぞれ用途ごとに異なるアプリケーションにしました。
Phase1: マイグレーションの基盤の作成(2020年〜)
システムが巨大な一方、2~3名体制の中で移植を取り組むメンバーの確保が難しかったため、少しずつ移行する方法を検討しました。
そこで、既存のPython2.7のAPIとは別に、新しいPython3で書かれたAPIのアプリケーションを立ち上げ、クライアントからのアクセスはPython3へ、未移植であればPython3 API → Python2.7のAPIにリダイレクトさせるようにしました。
Phase1-a: Python3のAPIへの立ち上げ・Python2へのAPIへのリダイレクト
SNSピリカは、AppEngineにデプロイされています。
AppEngineは以下のような特徴があります。
- 1つのプロジェクトにつき、1つのAppEngineのみを持てます。その中に複数の「サービス」をデプロイすることができます。
- サービスは複数デプロイできますが、最低一つ(default)というサービスが必須です。
- AppEngine全体のルーティングを担うのが、dispatch.yamlというファイルです。このファイルでどのパスをどのサービスにルーティングするかを判定します。
移植前Phase1の時点では、defaultサービスにランタイムがPython2のAPIがデプロイされていました。
クライアントから、こちらのAPIにGET example.com/users
のようにルートでアクセスされていました。
サービスごとに1つのランタイムのみを持てるので、ランタイムがPython3の別のAppEngineのサービス (python3-api
) を立ち上げて、APIのルートを/api
のようにルーティングすることにしました。
このようなルーティングは、AppEngineのdispatch.yamlで以下のように設定することで可能です。
dispatch:
- url: "*/api/*"
service: python3-api
Python3のAPIの処理では、移植前の最初の段階では、そのままルートのPython2のAPIにリダイレクトさせました。
つまり、この時点では、「GET example.com/api/users
-> Python3のAppEngine -> GET example.com/users
-> Python2のAppEngine」のようにルーティングされています。
これにより、クライアントからのアクセスはPython3のAPIにルーティングされますが、実際の処理はPython2のAPIにリダイレクトされるようになりました。
その後、API単位で移植をし、リダイレクトさせるのではなく、Python3のサーバーからレスポンスを返すようにしました。
この方法であれば、API単位で分割して実装・リリースができるようになりました。
また、この方法であれば、クライアントから直接ルート (Python2のサーバー) へのアクセスも残しつつ、新しいパス/api
へのアクセスは新しいPython3のサーバーへ同時にアクセスできるということも可能になりました。
SNSピリカにはiOSとAndroid版があり、これらのアップデートが行き渡るのには時間がかかり、ルートへの直接アクセスも一定期間は必要になるため、ここも大きなポイントでした。
デメリットとしては、移植が完了するまではPython3のサーバーからPython2のサーバーにリダイレクトされることになるので、レイテンシが高く、費用も高くなってしまうことでした。これは移行期間中は仕方のないこととして許容することにしました。
Phase1-b 使用できなくなるGAEのバンドルサービスの移行
今回、Python2のAppEngine(第1世代)からPython3のAppEngine(第2世代)に機能がなく、代替サービスへの移行が必要な機能がいくつかありました。(公式のドキュメント)
それぞれの移行先は以下の通りです。
- 非同期実行: queue -> Cloud Tasks (公式の移行ガイド)
- 定期実行: cron -> Cloud Scheduler (+Cloud Tasks・Cloud Functions)
- エッジキャッシュ: memcache -> Cloud Memorystore for Redis
最初に、queueとcronの移行を行いました。
Python2のサーバー内の呼び出し箇所を、Cloud TasksのAPI・Cloud SchedulerのAPIに変更しました。
また、CloudTasksの非公式のエミュレーターであるcloud-tasks-emulatorを導入し、ローカルでの開発を行えるようにしました。
memcacheはPython2では引き続き使用し、Python3ではCloud Memorystore for Redisを使用することにしました。 (後述しますが、これがPhase2で課題となりました。)
Phase1-b-a: 検証環境でのCloud Memorystore for Redisの代わりにGCEのプリエンプティブルインスタンスの活用
なお、Cloud Memorystore for Redisは最低費用でも30ドル程度と費用が高かったため、本番環境のみ利用しました。
それ以外の環境ではGoogle Compute EngineのプリエンプティブルインスタンスにRedisをインストールして使用するようにしました。
プリエンプティブルインスタンスは安価な分、Googleが強制的にインスタンスを停止することがある仮想マシンサービスです。
そのため、インスタンスが落ちていないかを確認し、落ちている場合は再起動させる下記のFunctionsを日中10分ごとにSchedulerで定期実行させています。
import compute from "@google-cloud/compute";
const request = {
project: "project-name",
zone: "region-name-x",
instance: "instance-name",
};
export const startRedis = async () => {
const client = new compute.InstancesClient();
const [instance] = await client.get(request);
const vmStatus = instance.status;
let started = false;
if (vmStatus === "TERMINATED") {
await client.start(request);
started = true;
}
return {
status: vmStatus,
started,
};
};
Phase1-c: クリーンアーキテクチャの導入
Python2のAPIは、1つのファイルに7000行以上のコードが書かれており、またLintも導入されておらず可読性が高くはありませんでした。そこで、API処理、ビジネスロジックの処理、外部サービスで処理レイヤーを分け、内部のロジックが外部サービスに影響しないようにすることを目指しました。
いくつかレイヤードアーキテクチャがある中で、ピリカではクリーンアーキテクチャを導入しました。2020当初クリーンアーキテクチャによる導入事例が各所で見られていたこと、またSNSピリカのAPIサーバの処理構造と整合することから決定しました。
具体的には、View, UseCase, Repository, Datastoreの4つのレイヤーに分け、それぞれの責務を明確にしました。
- View: Web APIの処理箇所。APIの定義や、リクエストのパースやレスポンスの生成を行う
- Flask-RESTXを導入し、Swagger UIでAPI仕様書を自動生成するよう改善しました。これにより、APIの開発体験が改善しました
- UseCase: ビジネスロジックを記述する
- Repository: データベースへのアクセスを行う
- Datastore: データベースの実体 (Cloud Datastoreを利用しており、ndbというライブラリを使用していたので、そのndbをDatastoreとして扱いました)
また、Pipfileを導入し、ライブラリやスクリプトを管理した他、flake8を導入し、Lintを行うようにしました。(最近はblack, isort, mypy等を導入して静的解析の品質をより向上しています)
Phase2: APIのマイグレーションの開始(2020年〜2022年)
Phase1でPython3のAPIを立ち上げ、Python2のAPIと共存させることができるようになりました。
ここからAPI単位で、少しずつマイグレーションを進めていきました。
キャッシュの共有ができずに、Python2・Python3で別のキャッシュが残る問題をPubSubを使って解決しました。
Phase2-a: Python2・Python3のAppEngineで別のキャッシュが残る問題をPubSubで解決
起きたこと
SNSピリカでは、以下のようにPython2のAPIではmemcacheを使用し、Python3のAPIではCloud Memorystore for Redisを使用していました。
- Python2のAPIのキャッシュ: memcache
- Python3のAPIのキャッシュ: Cloud Memorystore for Redis
- データベース: Cloud Datastore
- Python2のデータベース接続ライブラリ: バンドル サービス用の App Engine Datastore API
- Python3のデータベース接続ライブラリ: Python 2 App Engine NDB クライアント ライブラリ
このPython3のNDBではデフォルトでNDBキャッシュというものがあり、自動的にテーブルのキャッシュが行われていました。
つまり、Python2には明示的にキャッシュを設定していましたが、Python3には明示的に指定したキャッシュの他に、テーブルのキャッシュが存在していました。
途中まで実装してリリースしたところで、これだとPython2のAPIとPython3のAPIでキャッシュが共有されずに、別々のキャッシュが残ってしまう不具合に気づきました。
特に、NDBのキャッシュは、自動的にテーブルのキャッシュが行われるため、API全体的に意図せずにキャッシュが残ってしまっていることがわかりました。
例えば、ユーザーの表示APIをPython3のサーバーに移植し、ユーザーの編集機能はPython2に残したままにした場合、Python2のAPIでユーザーの編集を行い、その後Python3のAPIでユーザーの表示を行った場合、Python3のAPIではキャッシュが残っているため、編集前のデータが表示されてしまいました。
これはPython2のサーバーとPython3のサーバーを同時に稼働させていたために、起きた問題でした。
解決策
同じRedis・NDBを使用すれば問題はないのですが、Python2のAPIでは別のシステムであるmemcacheを使用しており、データベースのライブラリもNDBではなく別のAPIを使用して接続していたため、同じシステムに切り替えることができませんでした。
そこで、データベースの操作をしたときに、相互のキャッシュを削除するシステムを作成しました。
これにはCloud Pub/Subを使いました。
具体的には、以下のようなシステムを導入しました。
- Python3のサーバーにはNDBとRedisのキャッシュを削除するAPIを作成
- Python2のサーバーにはmemcacheのキャッシュを削除するAPIを作成
- 一方のサーバーでPOST・PUTをした時に、その旨をPublishし、Subscriberがどちらのサーバーからかを判定し、相手のキャッシュを削除するAPIを呼び出すPub/Sub・Functionsを設置
ちなみに、移植中はNDBキャッシュを停止する方法も検討しました。
実際に試したのですが、NDBキャッシュを停止すると、全体的に応答速度が0.5~1sほど増加したので、この方法は採用しませんでした。
Phase2-b 見える化ページサービスの移行
SNSピリカには、Web版フロントエンドとは別に、見える化ページ2という別のWebサービスがありました。
これは、自治体や企業が、ごみ拾いの活動を行った際に、その活動を記録し、その記録を見える化するサービスです。
このサービスも同じPython2のAppEngineにデプロイされていました。
しかしながら、本サービスは2015年頃に初期開発していた関係で以下の技術的課題がありました。
- 企業・自治体・団体向けに開発していた関係上、PCにのみ適合している。レスポンシブ対応ができていない
- 契約が増えるたびに開発メンバーがAPIを追加する必要があった
- レイアウトをコード上手動で設定する必要があった
などの問題がありました。そのため移植プロジェクトとは別に、このサービスを2021年ごろに別のサービスに移行しました。
結果、見える化ページはSNSピリカ本体から分離され、耐障害性が改善しました。
また、設定データにより柔軟にページをレイアウトできるようになり、スマホ等からも遜色なく閲覧できるよう改善しました。
Phase3: APIのマイグレーションプロジェクト始動(2023年〜)
Phase.2での足回り改善後、新規開発に集中していました。この関係で、2023年7月時点でPython2のAPIが7〜8割程度残っていました。2024年1月末でPython2のサポートが終了することを考えて、2023年7月にプロジェクトチームを立ち上げました。残り期間が6ヶ月と迫っている中、本格的に移行を進めることとなりました。エンジニアは約5名体制で、実装を進めるために業務委託メンバーの方にもお手伝いいただきました。
Python2のAppEngineにはAPIのほか、Web版フロントエンドとその配信APIもデプロイされていました。
これらはデフォルトのサービスに含まれていたため、ここまで移植せずにいました。
ただPython2のAppEngineを廃止する前、APIをすべてPython3に移植してからでなければ廃止できません・時間的制約もありこれらを同時に進める必要があったため、以下の順序でリリーススケジュールを決めました。
- 1/4頃: Python3のAPIの全ての移植・Webフロントエンドの移植 リリース
- 1月末: Python2のAppEngineの廃止
Phase3-a: プロジェクトの遂行
Phase3-a-a: API移植管理
最初に、全体のインフラ構成を整理した詳細な企画書をメンバー全員で推敲しながら作成しました。
後述しますが、当時はApp EngineからCloud Runへの移行も検討していたため、その部分も含めた全体のインフラ構成をまとめました。
その後、スプレットシートで全てのAPIを一覧にし、難易度と工数をつけ、全てのAPIをタスクボードに落とし込みました。
タスクボードだと開発以外のメンバーが全体感が掴みにくいので、全社向けの進捗共有として、こちらのブログのスプレッドシートで全体のガントチャートも作成しました。
そこから、各APIを担当するメンバーを決め、1つずつ移植を進めていきました。
企画書は、移植が進むにつれて変更があったため、進捗に合わせて更新を行いました。
Phase3-a-b: ユニットテスト・システムテスト・シナリオテストの実施
上記のように、時間が限られる中でPython2のAPIを完全に廃止し、全てのAPIを一気にマイグレーションすることになったため、安全に移行するためにも、厚めにテストを実施しました。
ユニットテストをかけるようにするために、公式のCloud Datastoreエミュレーターを利用し、アーキテクチャごと、特にテーブルの更新系の処理があるRepository層に対しては厚めにテストを書くようにしました。
システムテストに関しては、時間が限られており、マイグレーション後に十分なテスト期間が取れないことがわかっていたため、マイグレーションの実装と同時にシステムテストを行う必要がありました。
そこで、APIを機能ごとに分割し、機能ごとに移植を進め、機能の移植が終わると、その機能に対してシステムテストを行うという方法を取りました。
AppEngineは、バージョンをつけてデプロイすることができるため、バージョンを切り替えることで、移行前と移行後のAPIを切り替えることができました。
切り替え前のAPIを使用したアプリと、切り替え後のAPIを使用したアプリの2つを用意し、実機でテストしました。
全てのマイグレーションが終わったところで、シナリオテストも行いました。
テストを通して、不具合を事前に検知できたことはもちろんですが、以前からあった仕様バグもいくつか見つけることができました。
Phase3-a-c: 仕様の変更と機能の廃止
限られた時間・人員での対応だったため、一部利用頻度が低いものや、将来的に廃止を予定していた機能に関してはこのタイミングで廃止することにしました。
また、一部のAPIはPython2のAPIの時から不具合や仕様バグを抱えていました。それらがかえって移植を複雑にしている場合は、移植と一緒にAPI・アプリの修正も行いました。この際も、ユニットテストで正常な仕様・挙動を明確にしてから移植を進めていました。
Phase3-b: Web版フロントエンドの移行
Python2のAPIと同じく、Python2のdefaultサービスにWeb版フロントエンドの配信部分がデプロイされていました。
つまり、defaultサービスにはAPIとWeb版フロントエンドの両方がデプロイされていました。
このWeb版フロントエンドはAPIと同様にルートにアクセスがあり、どのAPIにも一致しない場合は、Web版のフロントエンドの配信API(Reactのindex.htmlにOGPを加えたり、HTMLを配信するAPI)にアクセスされていました。
またその上で、AppEngine特有の機能・制限がありました。
AppEngineは必ずdefaultサービスを持つ必要があり、以下のようにリクエストがルーティングされます。
- dispatch.yamlで定義されているパス・カスタムURLによるアクセスの場合は、dispatch.yamlに従ってルーティングされます
- dispatch.yamlで定義されていない場合、かつ、AppEngineのデフォルトのURL({AppEngineサービス名}-dot-{GCPプロジェクト名}.appspot.com)の場合は、そのURLによって定義されたサービスに自動的にルーティングされます
- 上記いずれでもない場合は、defaultサービスにルーティングされます
SNSピリカのAppEngineにはAPIとWeb版フロントエンド以外にも、データベースを共有している別のサービスをデプロイしており、かつ、このサービスでは、検証用環境でAppEngineのデフォルトのURLを使用していました。
そこで、App EngineのデフォルトのURLを使用できるようにするために、以下のような方法を採用しました。
- Web版フロントエンドの配信部分を、defaultサービスに移行し、Python3に移植する
- 同時に、defaultサービスにデプロイしていたPython2のAPIは、完全に廃止とする。Python3のAPIからのリダイレクトは削除する
失敗した方法
元々は、新しいPython3のAppEngineのサービス(web-frontendサービス)を立ち上げ、dispatch.yamlで/api
のパスを持たないリクエストをすべて受け取るようにすることを検討しました。
ただし、ここの場合は、AppEngineのデフォルトのURLである-dot-を使用できないため、dispatch.yamlで残りの全てのリクエストをweb-frontendサービスに流すことができないことに気づきました。
ルートのURLを利用する場合は、defaultのサービスを利用する必要があるようです。
他の方法
他にも、いくつか案がありましたが、限られた時間の中で、安全に移行するためには、上記の方法が最適だと判断しました。
- 案1: dispatch.yamlでルーティングするのではなく、defaultサービス内で各サービスにルーティングさせる
- この場合、全てのリクエストがdefaultサービスにルーティングされるため、応答速度が遅くなる可能性がありました。また、新たにルーティング用のdefaultサービスを設計・実装する必要があり、工数がかかるので採用しませんでした
- 案2: AppEngineのデフォルトのURLを廃止して、全てカスタムURLにする
- 以前から、AppEngineではなく、Cloud Runに移行したいと思っており、デフォルトのURLを廃止する案は以前からあったので、この機会に移行することも検討しました。ただ、この場合はCloud Load Balancingの設定をする必要があったため、時間のない中で対応するのは難しいと判断しました
Phase3-c: adminAPIの移行
Python2のAPIの中には、運営メンバーが利用するadmin APIがありました。これはAppEngine/Python2のUsers APIを利用しており、権限を持つ社内のメンバーのみがアクセスできるようになっていました。
admin APIには2種類ありました。1つは運営メンバーがユーザーサポートを行うためのAPIで、もう1つは定期実行されるCloud Schedulerのハンドラーでした。
前者の運営メンバーがユーザーサポートを行うためのAPIは、社内管理画面にUIを含めて実装することにしました。
社内管理画面自体は、2022年ごろに別のプロジェクトで必要になり、同じAppEngineにCloudLoadBalancing+Identity-Aware Proxyで作成してありましたので、そちらに新たにUI・APIを設置しました。
後者のCloud Schedulerのハンドラーは、Cloud Functionsに移行しました。
Phase3-d: 許容したリスク: 段階的なリリースができない
一気に2024年1月にマイグレーションをリリースするのはリスクだと考え、段階的なリリースを行いたいと考えました。
具体的には、全てのリクエストのうち、10%を新しいPython3のサーバーに、それ以外を古いPython2サーバーに処理に流し、徐々に新しいPython3への処理の割合を増やすことを考えていました。
通常、AppEngineの同じサービス内のリリースであれば、AppEngineのサービスのバージョンのトラフィックを分割で行うことができますが、今回は別のサービスにリリースするため、この方法は使えませんでした。
また、アプリ側で段階的リリースをすることも考え、Androidの段階的な公開という機能を使って可能になる予定でした。Python3のAPIにて2つのバージョンを用意し、/api/v1
のアクセスの時は引き続きPython2にリダイレクトさせる、/api/v2
のアクセスの時はPython3にリダイレクトさせるように実装をし、新しい方のバージョン設定したURLのアプリを段階的に公開することを考えていました。
ただ、上記のようにPython2のAPIは2024年1月で完全廃止することになり、段階的リリースはできませんでした。
ただ、今振り返ってみると、Python2のAPIをlegacyサービスとしてデプロイし直し、dispatch.yamlで*/legacy/*
を設定し、Python3のAPIからPython2へのリダイレクトのURLを/legacy/*
に変更するという方法をとれば、Python2のAPIを残したまま、段階的に移行することもできたかもしれません。
できなかったこと: モノレポ・Cloud Runへの移行
元々は、モノレポにしCloud Runへの移行を検討していました。
モノレポに移行したかったのは、SNSピリカのアプリがAppEngineと複数のFunctionsを使用しており、それぞれは別のレポジトリにあるため、重複したコードが多く、保守工数が高いためでした。
また、Cloud Runに移行したかったのは、モノレポを実現でき、またAppEngineよりも費用を抑えつつ、defaultサービスを持つ必要があるといったAppEngine特有の制約もなくなるためでした。
ただ、そのためにはCloud Load Balancingの設定が必要だったり、安定稼働している他のAppEngineのサービスの移植も必要で、実装コストもテストコストも高く、今回は断念しました。
今後はモノレポにし、Cloud Runに移行したいと考えています。
最後に
2024年1月末にPython2のサポートが終了することを受け、2020年から開始したマイグレーション作業は、2023年7月に本格的に移行を開始し、2024年1月上旬に完了することができました。
10年の歴史のあるアプリを、メンテナンスしやすいコードにマイグレーションすることができたことは、今後の開発においても大きな財産となると思います。
もちろん、リリース後、不具合もありました。が、すぐに修正し、大きな問題なく移行を完了することができました。
また、ここに書いてること以外にも、多くの大小の壁に当たりました。システムを分離させたり、アプリの仕様を検討し直す必要があったり、UIを作る必要があったり、他のチームとの連携が必要だったり、などなど… (今でも、仕様バグがいくつか残っています)
また、最後のPhase3の移行プロジェクトでは、限られた人数でマイグレーションを完遂させなければならないプレッシャーの中で、仕様を検討したり、他のチームと調整をしつつ、実装も進めなければならなかったことが大変でした。
その一方で、納期通りに完了させることができたことは、素直にとても嬉しかったです。
改めて、このプロジェクトに関わってくださった全てのメンバーに感謝を申し上げます。
-
SNSピリカやタカノメ等の詳細については、https://corp.pirika.org/ をご覧ください。 ↩
-
見える化ページについては、https://corp.pirika.org/service/pirika/ をご覧ください。 ↩