概要
こんにちは、SNS ピリカ開発チームの冨田です。
今年の1月にAPI サーバをPython3に移行するプロジェクトを完遂しました。
本プロジェクトは、SNS ピリカ開発チームのメンバーはもちろん、それ以外のメンバー、業務委託で一時的に関わってくださった方々、テストで関わってくださった方々、すでに退社された方々など、たくさんの方々の知恵が詰まっています。
目次
背景
SNS ピリカ1 は、2011年から稼働しているサービスです。従来API サーバはAppEngine/Python2.7上で稼働していました。
Python2.7は2020年初にPython 公式のサポートが終了しました。ピリカでも少しずつマイグレーション を進めていましたが、ビジネス上の理由から他の開発に時間と人員を割かなくてはならず、マイグレーション はあまり進められずにいました。
そんな中、2024年1月末のサポートの終了 がアナウンスされ、2023年7月よりプロジェクトとして進めることになりました。
結果、無事2024年1月上旬に、無事無停止でリリースすることができました。
全体の流れ
移植作業は、大まかには3つのフェーズに分けられます。
全体のインフラ構造とその前後
全体として、1つのAppEngineだったものをそれぞれ用途ごとに異なるアプリケーションにしました。
移植前後のインフラ
Phase1: マイグレーション の基盤の作成(2020年〜)
システムが巨大な一方、2~3名体制の中で移植を取り組むメンバーの確保が難しかったため、少しずつ移行する方法を検討しました。
そこで、既存のPython2.7のAPI とは別に、新しいPython3で書かれたAPI のアプリケーションを立ち上げ、クライアントからのアクセスはPython3へ、未移植であればPython3 API → Python2.7のAPI にリダイレクトさせるようにしました。
Phase1
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のサーバーにリダイレクトされることになるので、レイテンシが高く、費用も高くなってしまうことでした。これは移行期間中は仕方のないこととして許容することにしました。
/userの例
Phase1-b: 使用できなくなるGAEのバンドルサービスの移行
今回、Python2のAppEngine(第1世代)からPython3のAppEngine(第2世代)に機能がなく、代替サービスへの移行が必要な機能がいくつかありました。(公式のドキュメント )
それぞれの移行先は以下の通りです。
非同期実行: queue -> Cloud Tasks (公式の移行ガイド )
定期実行: cron -> Cloud Scheduler (+Cloud Tasks・Cloud Functions)
エッジキャッシュ: memcache -> Cloud Memorystore for Redis
管理者認証: Users API -> Cloud Load Balancing + Identity-Aware Proxy(IAP)
最初に、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を使用していました。
この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を設置
Pub/Subによるランタイムの異なるAppEngineのキャッシュ削除
ちなみに、移植中はNDBキャッシュを停止する方法も検討しました。
実際に試したのですが、NDBキャッシュを停止すると、全体的に応答速度が0.5~1sほど増加したので、この方法は採用しませんでした。
Phase2-b: 見える化 ページサービスの移行
SNS ピリカには、Web版フロントエンドとは別に、見える化 ページ2 という別のWebサービス がありました。
これは、自治 体や企業が、ごみ拾いの活動を行った際に、その活動を記録し、その記録を見える化 するサービスです。
このサービスも同じPython2のAppEngineにデプロイされていました。
しかしながら、本サービスは2015年頃に初期開発していた関係で以下の技術的課題がありました。
企業・自治 体・団体向けに開発していた関係上、PCにのみ適合している。レスポンシブ対応ができていない
契約が増えるたびに開発メンバーがAPI を追加する必要があった
レイアウトをコード上手動で設定する必要があった
などの問題がありました。そのため移植プロジェクトとは別に、このサービスを2021年ごろに別のサービスに移行しました。
結果、見える化 ページはSNS ピリカ本体から分離され、耐障害性が改善しました。
また、設定データにより柔軟にページをレイアウトできるようになり、スマホ 等からも遜色なく閲覧できるよう改善しました。
見える化 ページを別のAppEngineのサービスにする
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つずつ移植を進めていきました。
企画書は、移植が進むにつれて変更があったため、進捗に合わせて更新を行いました。
上記のように、時間が限られる中で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サービスを持つ必要があり、以下のようにリクエス トがルーティングされます。
App Engineのルーティング
SNS ピリカのAppEngineにはAPI とWeb版フロントエンド以外にも、データベースを共有している別のサービスをデプロイしており、かつ、このサービスでは、検証用環境でAppEngineのデフォルトのURLを使用していました。
そこで、App EngineのデフォルトのURLを使用できるようにするために、以下のような方法を採用しました。
Web版フロントエンドの配信部分を、defaultサービスに移行し、Python3に移植する
同時に、defaultサービスにデプロイしていたPython2のAPI は、完全に廃止とする。Python3のAPI からのリダイレクトは削除する
Web版フロントエンドの移植後の新しいルーティング
失敗した方法
元々は、新しい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: admin API の移行
Python2のAPI の中には、運営メンバーが利用するadmin API がありました。これはAppEngine/Python2のUsers API を利用しており、権限を持つ社内のメンバーのみがアクセスできるようになっていました。
admin API には2種類ありました。1つは運営メンバーがユーザーサポートを行うためのAPI で、もう1つは定期実行されるCloud Schedulerのハンドラーでした。
前者の運営メンバーがユーザーサポートを行うためのAPI は、社内管理画面にUIを含めて実装することにしました。
社内管理画面自体は、2022年ごろに別のプロジェクトで必要になり、同じAppEngineにCloud Load Balancing + Identity-Aware Proxyで作成してありましたので、そちらに新たにUI・API を設置しました。
後者のCloud Schedulerのハンドラーは、Cloud Functionsに移行しました。
admin API の移植
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への移行
元々は、モノレポ管理を検討していました。重複したコードが多く、保守工数 が高いことが課題になっていたためです。歴史的敬意からAppEngineの各種サービスとFunctionsを別リポジトリ で管理しており、データモデルやモジュールを各所で定義する必要がありました。
また、費用面や運用上の課題からCloud Runへの移行を検討していました。
しかしながら、以下の課題があり今回は断念しました。
Cloud Load Balancingの設定が必要である
稼働中のAppEngineサービスを移植する必要がある
AppEngineに依存する処理がいくつかあり、実装コストもテストコストも高い
とはいえ開発生産性およびコスト改善の観点から、今後はモノレポにし、Cloud Runに移行したいと考えています。
最後に
2024年1月末にPython2のサポートが終了することを受け、2020年から開始したマイグレーション 作業は、2023年7月に本格的に移行を開始し、2024年1月上旬に完了することができました。
10年の歴史のあるアプリを、メンテナンスしやすいコードにマイグレーション することができたことは、今後の開発においても大きな財産となると思います。
もちろん、リリース後、不具合もありました。が、すぐに修正し、大きな問題なく移行を完了することができました。
また、ここに書いてること以外にも、多くの大小の壁に当たりました。システムを分離させたり、アプリの仕様を検討し直す必要があったり、UIを作る必要があったり、他のチームとの連携が必要だったり、などなど… (今でも、仕様バグがいくつか残っています)
また、最後のPhase3の移行プロジェクトでは、限られた人数でマイグレーション を完遂させなければならないプレッシャーの中で、仕様を検討したり、他のチームと調整をしつつ、実装も進めなければならなかったことが大変でした。
その一方で、納期通りに完了させることができたことは、素直にとても嬉しかったです。
改めて、このプロジェクトに関わってくださった全てのメンバーに感謝を申し上げます。