皆さんご存じの通りPython2系は2020年の1月ごろを持ってサポートが切れてEoLとなりました。
一方で、弊社のプロジェクトではファーストコミットから7年目・現在残存するPythonコードだけで36万行(docstring・テストを含む)のPython2が使われていたプロジェクトが残っていました。
Python2系を使っていると小学生にも馬鹿にされてしまう世知辛いこのご時世(※参考 : エンジニア一同衝撃 Python子ども向けワークショップにやってきた小2開発者のさりげない一言)。
これは、突然2系のプロジェクト担当になったエンジニアがたくさんのコードを頑張って3系移行するまでの泥臭い作業ログの記録です。
2系を使っているとディスられてしまう一方で、それなりの規模のプロジェクトでの移行関係の情報が表に出てこないので書きました(もうほとんどの方が3系だと思いますので、需要はほぼ無さそうですが・・・)。
※恐らく記事内に結構ポエム成分を含みます。
※2系環境から3系移行というよりかは、頑張ってレガシープロジェクトを改善していくという側面が強い記事かもしれません。
※執筆に関しては上長からは許可をいただいています。
TL;DR
- テストが無いプロジェクトコードを部分的にリプレイスしつつ全体的にテストを追加していく作業が必要日数のほとんどの割合を占めた。
- テストやLintなどが整った後はコード量が膨大でも2系/3系互換コード化などは短期間で対応ができた。テストは偉大。
スタート時点の状態・条件など
Python2系を使っていたプロジェクトを引き継いだのは3年前くらいです。
その前段階から3系でお仕事をしていたため、2系のプロジェクトと聞いて当時抵抗感が強く出た記憶があります(また、残りのサポート期間的に移行を何とかしないという認識も)。
プロジェクト自体は社内用のweb上のツールだったため人がほとど割かれておらず、Python2系というだけでなく以下のような問題を抱えていました。
- テストが書かれていなかった。
- 保守されている仕様書が無い / 正確な仕様を完全に把握している人が社内に残っていない / ヒアリングした内容と実際の実装が乖離している。
- 保守されているDB関係などのドキュメントが無い。
- docstringが皆無。
- 前任の方がチームに残っていない(担当が今は増えましたが当時は自分一人)。
- ifやforなどでの意味のあるインデントがものすごい深くなっており(ネスト10個以上など)ものすごい認知的複雑度が高いコードがたくさんある。
- パフォーマンスが遅く、コードの内容把握のために動かそうとしても1つの関数で20分などかかったりしていた。
- 既存の機能で正常に動いていない箇所が結構あった / 計算ミスが多かった。
- メモリリークしていた。
- 密結合になっていて、アップデートすると予期せぬところがデグレしたりする。
- 担当になった私の方が、プロジェクトで利用されていたDjangoやLinux、AWSなどに精通していなかった(それまでは長いことデザイナーやゲームのクライアントエンジニアで演出寄りな仕事をしていた)。
改めて書き出してみると中々つらみに溢れている感じですね…。
一方で、
- 技術的な選択などの裁量はしっかり与えていただいた。
- レガシーコード改善系だったり使ったことの無い技術(大半は使ったことが無いものばかりでしたが)に対する大量の書籍などは会社側が一通り負担してくれて(購入に制限は特に無く)、且つ業務時間中の技術のキャッチアップもできていた(かなり勉強になった)。
- スケジュールに追われることはほぼなく、残業が0に近い点や休みの多さ・有給消化率的に労働環境はとてもホワイト環境だった 。
- 口頭コミュニケーションなども少なく作業に没頭できる時間か多かった。
といったポジティブな面も色々ありました。
3系にしたいけれども・・・
プロジェクトを引き継いだ時点で「2系のままだとアカン・・・」ということは考えていました。ただし結局今年になるまで移行に手を出しませんでした。
何故ここまで遅れた(遅らせた)のかは色々理由があるのですが、ぱっと思いつくところで大きなところだと以下の点が挙げられます。
そもそもツールの利用率がこのままだと下がっていってしまって、3系移行などをしている間にプロジェクト終了もありえるのでは?という感覚が強かった
社内のユーザー向けとはいえ、バグが多い・UIが分かりにくい・パフォーマンスが悪い…といった具合に、お世辞にもUXが良いとは言えませんでした。
これでは頑張って3系に移行しても、利用ユーザーがろくに居なくなってしまっていたらプロジェクト終了という会社の選択も起こり得ます。
そのため移行云々よりもまずはバグを減らす・分かりやすく見栄えるデザインのUIにする(元デザイナーとしてどうしても直したかったり等)・パフォーマンスを良くするといったUX改善を先に注力しました。
ここまで問題を抱えていると、プロジェクト終了してSaaSとかで妥協するのも(人件費などの面的にも)アリなのでは・・・という考えが何度も頭をよぎりましたが、当時の上司の方がこのプロジェクトを熱く推していらっしゃったので、継続して改善頑張っていくかと判断しています。
一般ユーザーは使わず、社内の限られた環境でのみ使われるものなので多少はEoLの期限を超えても致命的ではないと判断した
利用は社内のみで、外部からは繋がらない社内プロジェクトではあったため、なるべく早めに3系移行はしたいとは考えつつも多少は2系のEoLを超えてしまっても一応は致命傷ではない・・・と判断しました(正直言ってEoLの期限までに人的リソースが足りなかった・・・)。
それよりも先に、後述するように機能全体的なテストカバレッジの確保などを優先しました。
テストの拡充を優先した
プロジェクト担当前はPython3系でお仕事していたので、2系/3系で大分書き方が違うなとは感じていました。
実際に2系と3系の差異をまとめられている方もいらっしゃって、大分参考にさせていただきましたが、記事を見てみるとその差異の多さに結構圧倒されます(且つ、実際に移行をしてみたら記事で触れられている点以外も引っかかったりなどしています)。
参考 : Python のバージョン毎の違いとその吸収方法について
スタートしたばかりのような小規模なプロジェクトであればそのままアップデートという選択肢も取れますが、今回のプロジェクトは結構長寿なプロジェクトということもあり、これだけ2系と3系で書き方や挙動が違うとテストが無いと悪影響を抑えつつ移行するのが現実的ではありません。
上記を鑑みてテストでほとんどの部分のカバレッジが確保するという点を3系移行よりも先に対応しました。
テストを追加し始めてからしばらくしてから、3系移行関係の記事でもテストのことが触れられており、この選択は合ってはいたかなとは思っています。実際に普段の業務でテストに救われた数はとても多いですし、3系移行の時でもテストで事前に色々問題を検知できています。
テストコード(とドキュメント)が欠如していることで、Python 3への移行が進まないプロジェクトは数多い。十分な量のテストコードがなければ、自分たちが正しいことをしていることを確信できない以上、そうなるのは当然だ。そうしたプロジェクトをPython 3に移行するには、テストコードを書くところから始める必要がある。
今がPython 2からPython 3へ移行するのにベストなタイミング:トークセッションレポート
テストに関してはDjangoプロジェクトだったというのもあり、Test-Driven Development with Python(私が読んだのは初版ですが)などを(他の古典的なレガシーコード改善系の名著も含め)何冊か買って読んでいきました。
立ちはだかる途中からテスト追加するの難しい問題
先にテストを拡充していこうと決めたはいいものの、やったことがある方は分かると思いますが他の方が書いたテストの無いコードにテストを加えるというのはなかなかにハードです。以下のような壁が立ちはだかります。
- 他人のコードに対して自分が精通していないので、そもそもどんな処理なのかが曖昧でテストが書くのが難しい(docstringなどがあれば楽ではありますがそのあたりも無いため)。
- 認知的複雑度などが高く、巨大な関数やメソッドなどが多く読みづらいだけでなくテストを書き足すのがとても難しい。
- パフォーマンスなどの面でテストを頑張って追加してもテスト時間が問題になりがちになる(単体テストはなるべく速くしたい)。
全部作り直したい症候群に襲われるものの・・・
こうなってくるとプロジェクトはじめから作り直した方がいいのでは?という感覚に大分襲われてきます。フルに作り直すのが正解なこともぼちぼちあるとは思いますが、今回はそこまではやりませんでした(他人の理解が難しいコードだからそう思ってしまうという感情面が強かった気がするため、フルの作り直しはしないようにそこはぐっと抑えつつ)。
ほとんどの場合は、漸進的に今のプログラムを修正・改良していった方が得策なのだ。
スクラッチから書き直したくなるプログラマは、書き直したプログラムもまたスクラッチから書き直したくなる。
全部書き直しにするのはリスクが高くやるべきではないものの、開発体験(DX)が正直良くないのも確かです。そのため、以下のように今回のプロジェクトでバランスが取れるかなと思える形で進めていきました。
- 全て1から書き直すことはせず、既存ユーザーが既存のものを継続して利用できる状態は保つ。
- ただしUIの改善・コード内容の把握度・docstringやテストの追加・パフォーマンスの改善・テストがしやすくなるように関数などを小さくしていくなどの面が既存コードのままだと厳しいので、機能単位で細かくコードのリプレイスは進めていく。
- 細かいリプレイスは進めつつも既存のものも並行して動かし続ける(古いコードはすぐに切り落としはせずにユーザーが利用できる状態にする)。
- 新しいコードではdocstringやらテストやらは一通り追加する方向で進めていく。
- UI上の告知で、新バージョンへ移行している旨を表示し新バージョンの利用ができるリンクを貼っておく。
- 新バージョンについてユーザーに触ってもらってフィードバックを得る。
- しばらく期間を置いたりフィードバックの改善を新バージョン側に反映したのち、ユーザーのメインの(メニューなどによる)UIの導線を新バージョンに切り替え、ユーザーのメインの利用が新バージョンのコードやUIのものになるようにする。
- ただししばらく旧バージョンのものはユーザーから利用できるようにはリンクなどは残しておく。
- しばらくの期間そのまま放置し、特にお問い合わせなども無くなって来たり、利用ログなどを見て旧バージョンがほぼ使われなくなってきたのを確認したのちに旧バージョンのものを切り落とし、古いコードも削除する。
- 上記のようなイテレーションを何度も細かく繰り返し、最終的に機能全体的なコードのリプレイス(古いコードの削除)と全体的なテストやdocstringなどのカバレッジを確保する。
ストラングラーパターンとかがちょっと近いでしょうか。
「ストラングラーパターン」は、既存のシステムを段階的に新規システムに置き換えていく手法のことです。マーティン・ファウラーが2004年の記事「StranglerApplication」で名付けました。
ストラングラーパターン:段階的なシステム移行
このパターンは、移行によるリスクを最小化し、長期にわたって開発を分散させるのに役立ちます。
...
時間の経過とともに、機能が新しいシステムに移行されると、レガシ システムは最終的に "抑圧" されて、必要なくなります。 このプロセスが完了すると、レガシ システムを安全に廃止できます。
ストラングラー パターン - Microsoft Docs
AWSのwebコンソールや、Googleやマイクソフトのサービスなどでもちらほら見かけたりしますね。
一気に全てリプレイスするよりかは、小さい単位でリプレイスを進めて頻繁にデプロイしていくので、一気に全部書き直したものでリプレイス・・・とするよりかはリスクが少なくて済むというメリットがあります。
全ての古いコードのリプレイスと全体的なテストの拡充が終わるまではかなりの長期戦となった
ある程度覚悟していたものではありますが、全ての機能の段階的な移行・切り落とし・テストカバレッジの確保が終わるまではものすごい長期戦になりました。
スタートしてから一通り終わるまで約2年半くらいかかっています。その段階でコミット数9000程度、残存するPythonコードも30万行を超えるくらいにはなっていました。
最近でこそ人数を増やしていただけましたが、1人で全てやらないといけなかったので、こういったリプレイスに全リソースを割くこともできないというのも大きかったように思えます。
例えば社内の方から3人日くらいのライトな要望をいただいたとして、チームが自分一人なのでそうすると3日間はそのタスクだけでチームの人的リソース使用率が100%になってしまいます。社内ツールといえども全社的に横断的に使われているので、3桁人数の方がユーザーになりうるので、要望や質問対応なども含め自分で対応しないといけません。障害が出ればそちらも対応は全て自分で頑張る必要はあります。
そうこうしているうちに、リプレイスが終わって古いコードの切り落としが完了するまでの2年半は正直あっという間でした。
個別の機能のリプレイスが進んでUXもじわりじわりと改善していったところ、利用者もじわりじわりと色々な方が使う形になってきました。機能が改善し会社から少しは評価されたのか、社内でベテランの凄腕エンジニアさんをメンバー(平均年齢の低い若い会社なので自分も社内だと古参エンジニアに該当しますが・・・)に迎えることなどもでき人的リソースもかなり余裕ができました。
ベテランエンジニアさんのプロジェクト参加によってリソースの余裕ができたことによって、より一層改善のペースが上がりました。ポジティブなサイクルに入ったとも言えます。
テストのカバレッジが確保できてからはとても開発体験が良くなった
全体的にテストカバレッジが確保(※カバレッジ100%は目指してはいません)できてからは、かなり開発体験が良くなったように感じます。
プロジェクトを引き継いだ直後だと2週間に1回程度のデプロイ・且つデグレしてお問い合わせが来ることが多い・・・みたいな状態でしたが、現状では1日平均3回程度のデプロイ・ほぼデグレのお問い合わせが来なくなり(稀には来るので0ではないですが)、とても平和な感じになりました。まるでテストという守護神に守られているような感覚です(テストは偉大・・・)。
もちろんテストでカバーできない障害なども稀に発生します(単体テストでは問題無かった一方で、本番で悪影響が出てくるケースなど)。とはいってもそういったケースは今のところ半年に1回程度ですし、8~9割程度はテストで事前に問題を検知できている気はするので大分助かっています。
また、頻繁に(気軽に)デプロイできるようになったことで、結構攻めの改善などにも手が出せるようになってきました。元々の目的であったPythonの2系から3系移行に関しても同様です。
Lintやdocstringも拡充させた
後で触れますが、3系移行をする際の調整などでコードを全体的に読む必要が出てくるため、なるべく瞬時に各コードを理解できるようにするため、テストを整備したのと同様にLintやdocstring関係を整備しました。
まずはコーディング規約は基本的にPEP8に合わせる形としました(コードに一貫性を持たせたかったのでコーディング規約を設けたかったのと、自前のルールではなくPEP8だとLintなどのライブラリやエディタ拡張機能なども充実しているため)。LintとしてはPEP8準拠で楽をするためにisort・autoflake・autopep8・flake8の4つを利用しています。CI的にコードレビューやデプロイ前には各Lintを通してある状態にしています。
参考 : [Pythonコーディング規約]PEP8を読み解く
ルールに準じることで各コードの雰囲気が似たような形となり、読む際の負担が減ったように感じます(人による癖などが)。
docstringにはNumPyスタイルで統一し、基本的に新しいコードには必ず追加する・古いコードに関してもだんだんと追加していくといった形で進めました。
参考 : [Python]可読性を上げるための、docstringの書き方を学ぶ(NumPyスタイル)
また、気を付けていても追加し忘れてしまったり、頻繁にアップデートでコードを変えたりしていると引数内容がずれている・・・みたいなことが発生しがちです。
そういったケースを避けるためにもこの辺りのチェックは自前でライブラリを書いてPyPI(pip)に登録して、こちらもLintとしてコードレビューや本番デプロイ前にチェックが走るようにしていきました。
参考 : NumPyスタイルのdocstringをチェックしてくれるLintを作りました。
この記事を書いている時点で、プロジェクトで残存するPythonモジュール数が約1250・36万行くらいにはなっていますが、じわりじわりとLintのチェック対象となっているモジュールのカバレッジは増えていき、現在各Lintで96%~100%のカバレッジとなっているようです。
Lintは段階的に導入・反映していった
Lintが無いプロジェクトに対して途中からLintを追加していったわけですが、一気に全てのモジュールに対してLintを反映・本番反映・・・とすると、この規模のコード量だと更新されるモジュールが膨大になってしまいます。基本的にはLintを反映しても悪影響が出ないことが大半ですが、稀に悪影響が出るところもあったりするので、一気に全てのモジュールに対して反映はせずにテストが追加済みのモジュールに対して少しずつLint対象に追加としていきました。
基本的にはLintを通してもテストを通ることを確認するため、テストの整備と同時並行でLint関係もじわりじわりと整備していきました。
Lintもなるべく速く終わるように改善
テストなども含め、デプロイまでに必要な処理にかかる時間が長くなってくるとちょっと開発体験が悪くなってきます。Lintの処理時間も同様です。コード量が多いと、全体に複数のLintを流すのが結構時間がかかるようになってきました。
また、後で触れますが2系/3系変換用のものもLint的に組み込んでいったので、作業中何度も何度も流していくことになります。
頻繁にLintを流した方がいい一方で、遅くなってきて待ち時間などが目立ってくると少し辛いものがあります。
そこで、テストの並列化といったようなスローテスト問題の解決と同様に以下のようにLintの処理時間を短くするために以下のような調整を入れていきました。
- 対象とするモジュール単位でのLintの並列化
- Lintの最終実行日時とモジュール更新日時を加味し、更新がされていないモジュールをLint対象から除外する判定の追加(すでにLint反映済みのものに対するチェックのスキップ)
- flake8やnumdoclintなどに関しては、前回チェックしてから更新されていない・且つ前回もチェックで引っかかっていないモジュールは対象外にするように調整
これで大体プロジェクト全体に各Lintを一通り流しても20秒くらいでLintが流れるようになり、大分快適且つ気軽な実行ができるようになりました。
3系移行の作業を進める
ここまででリプレイス、ドキュメント・docstring・テスト・Lintの追加・・・と色々触れてきましたが、これでやっと3系に移行を踏み切っていい感じになってきました(なお、期間的にもここまでが日数のほとんどで、実は3系移行自体の作業は少ない日数で対応が終わったりしています)。
この節からついに3系移行関係が中心とした内容となります。
移行作業は2系と3系両方で動く形のコードにしていく方向性を選択
移行する際には一気に3系に切り替える方法(2系では動かないコードに変換)と、まずは2系と3系両方で動く形にコードをしてから3系に環境を移し、しばらくしてから問題なさそうであれば古い2系環境を切り捨てる・・・といった2つの選択肢があります。
基本的には前者の方が作業自体は少なく、後者の方は段階的に移行していけるのと、3系に切り替えた後に問題が出てきた場合に一旦2系に切り戻したり・・・といった対応が取れるというメリットがあります。
今回のプロジェクトでは以下の理由から後者を選択しました。
- 残存するコード量が多く、並行して機能開発などのアップデートもかけていきたかったので段階的に移行したい。
- テストは通ったとしても、不測の事態が発生する可能性は高いと踏んでいたので、なるべくユーザーに迷惑がかからないようにすぐに2系に戻せるようにしておきたい。
移行の4本柱 six・future・builtins・modernizeパッケージ
2系互換を考えなければ2to3などの便利なものも用意されています。
2to3 - Python 2 から 3 への自動コード変換
ただし今回のプロジェクトでは2系互換を保ったまま進めようと決めたので、2to3は使わずに以下の4つのパッケージを主に使っていきました。
six
2系と3系の書き方の差異などを吸収する形のコードを書くことができるようにするパッケージです。
例えば文字列の型判定が、2系だと
if isinstance(str_value, basestring):
...
な一方で、3系だとbasestringか存在しないため
if isinstance(str_value, str):
...
といった書き方になります。これをsixを使うことで
if isinstance(str_value, six.string_types):
...
といった2系でも3系でも動くコードにできます。そのほかにもリクエスト関係でのパッケージ構成が2系と3系で変わっているものやrangeの挙動の違いを吸収したりと、色々なラッパー的な2系と3系の互換性のための機能が含まれています。
future
futureパッケージのものを対象のモジュールでimportすることで、そのモジュールで将来のPythonバージョンと同じ書き方ができるようになります。
例えばprint関数は2系だと
print 'cat'
としますが、3系だと
print('cat')
といった形で関数呼び出し的に()
の括弧が必要になります。
そこでモジュール内でfrom __future__ import print_function
といったようにfutureパッケージから必要なものをimportすると、2系でも3系と同じ書き方でprint関数を書くことができるようになります。
ただし、注意点として私が使っていたisortのバージョンの影響かもしれませんが、future関係のimportがあるとisortと挙動がおかしくなる・・・といったケースがたまに発生しました(同じモジュールに再度Lintを流せば大丈夫にはなる)。
そのあたりはコミットの際やテストなどで検知ができていましたが、テストが無いとやはりこの辺りの影響が怖いなという印象を受けました。
builtins
builtinsパッケージもfutureパッケージに似たような挙動をします。特定のものをimportすることで、2系と3系で挙動を合わせるといったことが可能になります。
たとえば2系だと整数の型がintとlongと分かれています。64bitなどの大きさの整数だとintではなくlongが必要になってきます。
一方で3系だとintのみです。小さい整数でも大きい整数でもintだけで扱うことができます(大きい整数では自動でサイズが大きくなります)。
そこで、futureパッケージと同じようにfrom builtins import int
といったようにbuiltinsモジュールのものをimportしておくことで、intの挙動が2系でも3系と同じようになります。longは3系では使えないので、builtinsパッケージのものを使うことで2系と3系で同じコードで動く形にすることができます。
modernize
modernizeはPythonのコードを2系と3系の互換のある形に変換してくれるライブラリです。前述までのsixやfutureなどを使った書き方に変換してくれます。
サードパーティーのライブラリとなるので、pipなどでインストールする必要があります。
また、コマンドの実行自体はPython3系が必要です。
sixなどは便利な一方で、機能はたくさんあり最初は中々どう書けばいいのか悩みますが、modernizeを使うことでライブラリに任せる形でsixなどを使ったコードに変換できます。どのように変換されるのかのdiffを見たりしても、sixなどの使い方の勉強になりますし、modernizeを通して初めて「〇〇も2系と3系で挙動が違うのか・・・」といった気づきも結構ありました。
手動で全部やると結構辛かったり、対応漏れなども起こりがちなのでライブラリでやってくれるところはライブラリに頼っていきました。
Docker移行
ファーストコミットから7年ものの古いプロジェクトでもあり、且つ私を含めてメンバーが元デザイナーや元ゲームのクライアントエンジニアといった経歴でインフラ関係にも強く無かったので、昔からのままでVirtualBoxとVMWareを使っていました(web業界と異なって、ゲームエンジンやらAdobeツールなどに詳しくてもDockerを使う機会が無かったというのも大きい気がします)。
しかし3系移行をするに伴って3系の環境を新たに作ったり、メンバー間で開発環境を頻繁にやりとりしたりが発生していく気配がしていたのと、元々新しい方の環境作るのが面倒だったのと、2系と3系環境を両方同時に動かしたりは必要になりそうなもののVirtualBoxなどで2つ同時に起動するのは負荷的にきつい・・・といった状態だったのでDocker対応を進めました。それにもう世の中ではDockerを使うのが当たり前・・・となっている気がするので、キャッチアップしておこうという側面も強かったかもしれません。
まずはDockerのことが良く分かっていなかったので、Docker Deep Dive: Zero to Docker in a single bookという本を買って勉強しました。
当時の記事 : [Docker入門]勉強して得られたDockerの知見を色々まとめてみた
古いものも使っていますし、Dockerのベテランもチームにいないので、開発環境のDocker対応結構難航するかな・・・?と思っていましたが実際にやってみたら案外スムーズに対応が進みました(移行が楽だったのでもっと早めに移行しても良かったなと)。
まずは2系の既存の開発環境をDocker対応し、次いで3系環境も用意し、3系で大分動くようになってきてからメンバーに3系環境を共有しました。
共有ディレクトリを設定して、2系と3系のDocker開発環境それぞれで同じコードを参照するようにした
Dockerにそこまで詳しくないのでこれが正解なのかよく分かっていませんが、開発環境のコードはローカルのWindowsで開発していたのでホスト側に置いて、共有ディレクトリ設定をそれぞれDockerの2系と3系の開発環境のLinuxで行う形にしました。
基本的に2系/3系互換のコードにしていく作業の都合2系と3系環境の同時起動が必要になるので、頻繁にLint反映やテストを流したりするためモジュールファイルを更新したら2系と3系環境両方に即時で反映されるようにしました。
ライブラリ関係の調整
3系環境の対応を進めていて、一部ライブラリの調整が必要になりました。
まずはいい機会なので使われていないライブラリを削除していきました(テストもこの時点では大部分がカバーできていたので攻める形で)。
pipでインストールされるライブラリが90強程度あり、引き継ぐ前からインストールされていたものが使っているのか使われていないのかいまいち分からずそのままにしてあったのですが、1つ1つ精査して使われていないと思われて、且つ切り落とした状態のDockerイメージでテストを流してみても引っかからないといったものを順番に切り落としていきました。
最終的には90件程度あったライブラリが60件程度まで少なくなりました。
続いて残ったライブラリで3系でインストールがうまくいかないものがあったので調整していきました。
具体的には、保守が止まっているライブラリが1つ、アップデートが必要になったライブラリが2つといったところです。
保守が止まっていたライブラリ(3系でインストールができない)ものは他の方がforkして3系対応したものがPyPIにも登録されていたのでそちらに切り替えることで解決できました。
他のアップデートが必要になったライブラリに関しては「Python3.xだったらこのバージョンに対応しているよ」といった互換性的なところの影響でアップデートをせざるを得ないライブラリが存在しました。逆に2系環境ではそのライブラリバージョンがインストールできないといったケースも発生し、仕方無いので2系と3系の環境で一部だけそれぞれ別のライブラリバージョンを使う形で進めました。
ライブラリバージョンの差異によってどちらか片方の環境でテストが引っかかった箇所などは、都度2系と3系両方で動作するようにラッパーなどを設けてそちらを利用する形に書き換えていきました(自前のsixみたいな対応ですね)。
全体としては影響が出たのが3つのライブラリといった程度で、作業を始めるまでは「もっと色々ライブラリが動かなくて詰んだりしないだろうか・・・」と懸念していましたが、この辺りはとてもスムーズで、OSSライブラリでちゃんと3系対応などで保守してくださっていたContributorの皆様には感謝しかありません・・・
3系環境でDjangoのコマンドなどが通る最低限のコードやライブラリの対応を進めていく
Lintの反映(手動・自動共に)はプロジェクトで使っていたDjangoのコマンドを利用していました。
しかしながらmodernizeは3系環境でしか動いてくれない(他の環境のPythonとか使ってしまうのも手ではありますが)ので、まずはDjangoのコマンドが3系環境で最低限実行できるところまで手動で対応して持っていきました。
警告も結構発生していたりテストも通していないような状態ではありましが、3系環境でコマンドが実行できるようになるまでのコードの調整は大した量ではない印象で完了しました(1.5人日くらい?)。
各モジュールにmodernizeを反映していく
3系環境でDjangoのコマンドが実行できるようになったので、約1250個の各Pythonモジュール(__init__.py
などを除く)にmodernizeを反映していきました。
流れとしては他のLintと同じように組み込み、少しずつLintのカバレッジを上げていく・・・としていきました。
一定数(20個ずつなど)のモジュールに対してmodernizeを反映 → 変更された内容を目で確認して、問題がありそうなら手動で調整する → modernize以外のLintを反映する → 2系環境でテストを流し、引っかかれば修正する → 諸々問題なさそうであれば本番にデプロイしていく・・・といった作業をひたすらに繰り返していきました。
modernize反映後のコードは、挙動が変わっていたりがテストで検知されたりは結構ありましたし、テストパターンで検知できない更新があったり(例えば、初期値が小さい値でコード内で値が大きくなるような箇所でlongがそのままintに変換されたもののbuiltinsパッケージのものがimportされない等)、前述のfutureとisortでの変な挙動などの件もありましたので、目視での確認なども挟んで進めました。modernizeがどのように更新していくのかに興味があったというのもあります。
また、modernize反映後のコードはPEP8などが加味されていない形となるので、前述のisortやautoflake、autopep8などの他のLintを通すことも必要になりました。
ひたすら繰り返しの作業ではありますが、思っていたほどこの作業は時間がかかりませんでした
。5人日弱といったところでしょうか。テストカバレッジを上げるための作業などと比べると大分さくっと終わった印象を受けました。
なお、この時点ではまだ3系でテストが一通り通るといった状態にはまだなっていません(3系環境で一通りのモジュールimportなどはできたり、起動等はできるようにはなっています)。
3系環境で一通りテストが通るようにする
modernizeを通しきったおかげで一通りのモジュールのimportやDjango関係の起動などができるようになったので、今度は3系環境で一通りのテストが通るように調整していきます。
案外modernizeを通し、2系環境ですでにテストが通っているコードでも3系環境でテストを流してみるとテストに引っかかったりして、新しく「2系と3系でそんな挙動の違いがあるのか・・・」と気づくような内容であったり、ライブラリバージョンが同じでも2系と3系で挙動が異なっていてテストに引っかかるといったケースが結構出てきました。テストが無かった状態で進めるとするとこの辺りはまず本番デプロイ前に気づくのが難しいので、改めてテスト周りを先に整備しておいて良かったと感じます。
ここもmodernize同様、そこまで工数がかかるといったものでもなく、テストの関数の件数ベースで3000強程度のテストで5人日程度で対応が完了しました。
チームに3系環境を展開する
ここまで来ると、まだ細かい点で直す必要がある点はちらほらあるものの大分2系環境同様に動くようになってきました。
3系環境のイメージをチームに展開したのもこのあたり(実際にはもう少し前でしたが)のタイミングです。
また、このタイミングからは今後のアップデートは2系と3系両方でテストを通してからデプロイする形にしてあります。Lintに関しても3系環境でmodernizeも含め1つのコマンドで一通り実行できる段階になっています(前述の通り、手動でLintを通す場合でも共有ディレクトリのおかげで1回流せば2系環境にも反映されるようになっています)。
発生していた警告をつぶしていった
3系環境を中心に、Python自体やライブラリのDeprecatedWarningやFutureWarningなどが結構発生していました(3系環境で一部ライブラリをアップデートしたのも絡み)。
これらに関しても3系に切り替える前に警告系を一通りつぶしておきたかったので、開発環境ではテストで警告が出ていても通ってしまっていてもテスト失敗にはしていなかったところを警告でも失敗するようにテストランナーを調整したり、開発環境のViewで警告が出ていたらエラーになるようにDjango関係で調整したりして警告を一通りつぶしていきました(これらも小さく分割して、少しずつまめにデプロイしていきました)。
作業自体は約3人日といったところでしょうか。
いざ、本番環境の切り替えへ
幸い社内用のwebのツールということもあり、土日などは利用者がほとんどいません。そのため切り替え作業や検証作業などは土日に休出・代休を平日に取らせていただく形で(何かトラブルが発生しても時間的な余裕がある状態で)進めることができました(作業自体は土曜で完了・日曜はほぼ経過観測で大体他の作業を行っているといった具合でした)。
また、「事前にやれることは色々やりましたがきっとどこかしら影響は出ます」「トラブルなどで2日で終わらなかったら一旦2系に戻して来週の土日にまた作業します」みたいなものはお知らせなどは事前に展開したりして、ゆっくりと落ち着いて作業ができる状況を作り、トラブルがあっても切り戻しがしやすいようにはしておきました。
そのあたりを考慮していても「移行で死ぬときは死ぬ」とはチームのチャットには流していた(良く聞くフェイルオーバーがうまく動いていないとか、ロールバック自体が失敗するとか)ので、「何らか思いっきり失敗しても休日でユーザーがいないのだからゆっくり対応はできる」といった状況は大変助かりました(主に精神的に)。
結果的には本番環境の3系切り替えで大きな障害などは無く(少なくともユーザーに大きな影響の出るものは無く)、無事移行が終わりました。
ただし本番環境でテストを通した時に、開発環境で事前に再現できていなかった条件でテストで引っかかったところが一部見つかりました(環境特有のもの)。そちらもユーザーの利用開始前に修正は終わりましたので、やはりテストがしっかりしていると安心感があるなと(多くのところをユーザーから指摘される前に検知して直せるなど)感じました。
余談 : マイクロサービス化はしなかったの?
今回のプロジェクトではマイクロサービス化はしませんでした。Building Microservices: Designing Fine-Grained Systemsなどの本は買っていただいているのですが、まだ未読でマイクロサービスに詳しくないので判断ができなかったとも言えます(メンバーがインフラとかに強くないというのもあります)。
疎結合感が増えたりでメリットも大きそうで結構使うか使わないか悩んだのですが、チームが1人か2人で回してきている(会社の都合ではあるので、内製なら最低3人・・・といった突っ込みはさておき)ので、チーム人数の多さに起因するコミュニケーションコストは抑えられていますし、1日平均3回くらいのデプロイはできているのでマイクロサービス化までは今回は手を出さなくてもいいかな?という所感に落ち着きました。
将来書籍をしっかり読んで知見が得られたら意見が変わって「やっぱりマイクロサービス化しよう」という判断にはなるかもしれません。ただし、Twitterなどでもちらほらマイクロサービス化による煩雑さというか、つらみを何度か見かけてはいるので「流行っているからマイクロサービス化する」とはしない形にはなると思います(しっかり本を消化してメリットがデメリットよりも大きそうであれば利用していこうかなと)。
今後の展望
今後は段々と、こまめにプログラム言語やライブラリバージョンをアップデートしていきたい
今まではテストやらLintやらDockerやらが整備されていなかったので、Pythonやライブラリのアップデートは慎重に行う必要がありましたが、今回の移行作業で大分整ったので、今後は攻める形でまめにアップデートをかけていけたらいいなと考えています。
まめにバージョンアップすることで影響を小さく1回の対応の工数も下げれますし、一気にバージョンを上げてエラーになるのではなくDeprecatedWarningなども検知小さなバージョンアップで検知しやすくなります。まめにアップデートすることで、アップデート作業自体の訓練にもなります。
それに今回のような大きなバージョンアップだとどうしても気が重くなりがちでアップデートせずに放置しがちで余計に良くないと考えています。
なにかのスライドで見かけた「テストなどが通ることが確認できたら自動でアップデートする」みたいなことをしてもいいかもしれませんね(VS Codeで起動したらいつの間にか自動でアップデートされているような形で)。
型アノテーション関係を充実させていきたい
Python3系にして個人的に特にありがたいのが型アノテーション周りです(2系の文字列などの煩雑さから開放されるといったものも大きいですが)。
2系環境でもインラインコメントをする形で使えたり(実際にある程度便利に使っていたり)、Pylanceなども快適に使えていましたが、やはり3系の書き方だと色々快適です(エディタや拡張機能なども加味し)。
最近のPython3.9や3.10などでも色々便利そうな型アノテーション周りのアップデートが入ってきています。プライベートでは新しいPythonバージョンを使ったりしていて便利さやミスの減り具合は身に染みていたので、今後積極的にこのあたりは折角3系環境になったのですから導入したりPyrightなどをCI的に整備したりもしていきたいと考えています。
新しいライブラリを導入して機能追加などに役立てていきたい
昔からあったライブラリなどであれば、新しいバージョンにアップデートはできなくなったりはして来ている一方で、少しバージョンを最新から下げたりすればまだ結構使える状態です。
しかしながら新しく生まれてきているライブラリなどは2系はそもそも対応されていなかったりというケースが大半になってきました。
今までは「2系だから便利そうだけどこれは使えない・・・」といったケースも結構あったので、それらが解決して選択肢が増えるのは楽しみですし、新しいものも積極的に試したり導入したりしていきたいなと考えています。