Software Design連載 2022年2月号 大規模Webアプリケーションの開発環境をモダナイズする

こんにちは。モノタロウの八木(t_yagi)です。

モノタロウのECシステムは創業から20年以上ずっと動き続けており、絶え間なくビジネスを支え続けています。 その間、周囲のIT技術も大きく進歩してきました。

そんな中、開発者が増えたり機能も拡張され続けた結果、当初はさほど問題にならなかった開発に関する課題が浮き彫りになってきました。

今回はそんなレガシーな開発環境にモダンなIT技術を取り入れることで「当時は出来なかったことを現代の技術で実現する」を書きました。

流行りのモダンな技術がイケイケだから乗り変えるといったような輝かしいものではなく、長年積まれ続けてきた課題が現代の技術だから解決できたという時代の恩恵にうまく乗れるかを率直に記事にしています。

どうするとデメリットを抑えながらメリットを得ることができるか読んでいただける人に少しでも感じ取っていただければ嬉しいです。

※本記事の初出は、Software Design2022年2月号「Pythonモダン化計画(第7回)」になります。過去の連載記事は以下を参照ください。

サーバアプリの開発環境をいかにして改善するか

モノタロウでは事業者向けの間接資材を販売する大規模なECサイトを運営しています。 ECサイトの規模が拡大するとともに、そのアプリケーション開発に携わるメンバーも増加を続けてきました。 2014年には50人程度だった開発者は、2022年1月現在では250人規模にまで拡大しています。

開発者が増える過程で表面化した課題として、共用の開発環境の整備があります。 モノタロウのECサイトはPython製の独自Webフレームワークとして構築されており、そのサーバアプリケーションを共同で開発するうえで、開発環境に起因する問題が多発するようになったからです。

今回は、モノタロウにおいてサーバアプリケーションの開発環境にどのような課題があり、それをどのように改善してきたかを紹介します。

2014年くらいまでの、のどかな開発環境

モノタロウのサーバアプリケーション群の本番環境はLinuxで動作しています。 一方、開発者がコーディングに使用しているローカルPCのOSは主にWindowsです。 Linuxでないと動かないライブラリもあることから、個々の開発者がコーディングした内容の動作確認はLinuxサーバ上で行うしかありませんでした。 2014年ごろまでは本番環境と開発環境とにかなり大きな乖離があったことになります。

この開発フローには次のようなトラブルや不便な点がありました。

  • 毎回ファイルをアップロードする手間があった。開発内容によっては複数台にファイルをアップロードする必要もあった
  • 動作確認用のサーバには、複数人で利用するためにNginxやApacheに独自の設定をする場合があり、動作確認時に本番環境と挙動が異なることがあった
  • 新しく開発環境が必要なときは、AWSのAMIからコピーして作成していたので、サーバの設定や開発用ツールの挙動が微妙に異なる環境がいくつも存在していた
  • 設定ファイルや開発用ツールのコードをGitで管理していたが、手動で設定する部分も多く、サーバごとの独自設定が秘伝のタレになっていた
  • リモートデバッグの仕組みが途中で導入されたが、告知やドキュメントの整備が不十分で、知っている人だけが使える機能になっていた

ただ、開発者が少なかった時代はこれらが深刻な問題になることもなく、何か不都合があれば時間に余裕がある開発者が気づいた時点で対応していました。

トラブル多発と課題の深刻化

開発者が増えてくると、それまであまり深刻でなかった共用サーバでの開発という課題が次の2つの観点で徐々に顕在化してきました。

  • 開発用のサーバが一気に増えた
  • 開発者が増えた

開発者全員が数台の共用サーバで動作確認をしていたときは、そのサーバで正常な開発環境を維持すればよかったのですが、サーバが増えればそれも難しくなります。 折しもPython2からPython3への移行を進めていたこともあり、両方の環境を維持する必要があったことから、共用サーバを手動で設定することによるトラブルの頻度が上がっていました。

サーバが増えたことで、たとえば「対象のWebページがどのサーバで処理されるか」もわかりづらくなります。 「ファイルをアップロードしたのに修正が反映されない」といった問題も頻発するようになりました。

一部の不具合によって全体の開発がストップしてしまうリスクも増大し、影響を受ける人も増えました。 そもそも「ローカルPCで動作確認できない」ことも課題として鮮明になりつつありました。

開発環境の改善にむけて

上記のような課題が顕在化した背景にあったのは、「独自設定や手動で対応していた従来の開発環境が、拡大する開発の規模に見合わなくなっていた」という現実です。 そこで、「独自設定をデファクトスタンダードにあわせる」「手動で対応していた部分を自動化する」という方針で解決を目指しました。 これらの方針のもと、具体的には次のような対策に取り組みました。

  • 動作環境のDockerコンテナ化
  • デバッグやユニットテストでIDE(統合開発環境)とコンテナの連携機能を活用する
  • コードフォーマッタを導入する

動作環境のDockerコンテナ化

まずは開発者のローカルPCで動作確認ができるように、共用開発環境のDockerコンテナ化に取り組みました。 コンテナの構築にあたっては、すでに確立していたインフラ構築の手順で素直にアプリケーションを動作させることを優先し、既存のVMベースの動作環境構築をそのままDockerに載せ替えました。 つまり、最適なベースイメージ選びやイメージサイズの最適化といったことはあまり意識せず、レイヤー数を少なく抑える最小限の工夫だけにとどめました。

Dockerコンテナで動作環境を構築することには、開発者個人のローカル環境で動作確認ができるようになっただけでなく、さらに別のメリットもありました。 開発しているWebアプリケーションの同じバージョンのソースコードを参照して、Python2ベースとPython3ベースのコンテナを両方作っておき、その両方をdocker-composeで起動できるようにしたことで(リスト1)、Python3への移行作業の助けになったことです。

リスト1: 同じバージョンのWebアプリをPython2とPython3で起動するdocker-compose設定

version: "3"
services:
  nginx:
    image: nginx:latest
    ports:
      - "443:443"
    volumes:
      - ../nginx.conf:/etc/nginx/nginx.conf
  app-py2:
    build:
      context: ../
      dockerfile: app-py2/Dockerfile
    image: app-py2
  app-py3:
    build:
      context: ../
      dockerfile: app-py3/Dockerfile
    image: app-py3

本番環境では、Python3への移行作業にあたり、「Python3対応済のURLへのリクエストはPython3のホストに振り分ける」という仕組みをとっていました。 開発環境でも、同様にリクエストを振り分ける仕組みが必要になります。 そこで次のような仕組みを用意しました(図1)。

  1. DockerのNginxは「Python2のコンテナに優先的にアクセスする」ように設定
  2. Python3対応済みの場合はリスト2のようなコードがあるので503エラーが発生する
  3. DockerのNginxには「HTTPステータス503を受け取った際にPython3コンテナへ振り分ける」というリバースプロキシを設定しておく

f:id:t_yagi:20220222091008p:plain
図1: Dockerネットワーク内でPython2とPython3を振り分け

リスト2: Python3対応済みの場合に503エラーを発生させるコード

from six import PY3

def continue_processing(is_ported):
   """Py3 サーバにリダイレクト(503 を返却し Nginx でリダイレクトする)"""
    if (not PY3 and is_ported) or (PY3 and not is_ported):
        raise_http_service_unavailable()

def raise_http_service_unavailable():
   """HTTP Service Unavailable (503) を送出する"""
    raise HTTPServiceUnavailable

コンテナ技術を用いることで複数人が扱う環境に依存しないローカルに閉じた開発をすることが可能になりました。またその恩恵としてPython3への移行を容易に扱うことができました。

デバッグやユニットテストでIDEの機能をもっと活用する

動作環境のDockerコンテナ化に合わせて、開発環境の使い勝手を上げるという課題にも取り組みました。 具体的には、デバッグとユニットテストをやりやすくするという取り組みです。 最近のIDEではDockerコンテナとの連携機能が強化されており、以下のような応用ができます。

  • IDE上からマウス操作でコンテナの起動や停止ができる
  • コンテナ内のPythonが簡単に利用できるので、IDEからWebアプリケーションのデバッグやユニットテストの実行ができる

なお、モノタロウではIDEとしてIntelliJを利用していますが、Visual Studio Codeでも同様のことは可能です。

コンテナとIDEを使ったユニットテスト

IntelliJでは、コンテナ内のPythonを使ったユニットテストの実行ができます。 これには、IntelliJでPython SDKおよびモジュールのSDKとしてコンテナ内のPythonを登録するだけです*1

これにより、IntelliJでユニットテストのファイルを開くと緑色の▶印が表示され、これをクリックしてユニットテストの実行ができるようになります。

f:id:t_yagi:20220222091215p:plain
図2: IntelliJでコンテナ内のソースコードをユニットテスト

コンテナとIDEを使ったデバッグ

IntelliJでは、コンテナ内のソースに対するユニットテストだけでなくデバッグも可能です。 ただ、モノタロウのWebアプリケーションのデバッグではIntelliJの設定だけでなくもう少し工夫が必要でした。

もともとモノタロウでは、以前から「ローカルPC上のIDEでデバッグサーバを動かしておき、Apache + mod_wsgi上のWebアプリケーションがpydevdモジュールを利用してデバッグ情報をIDEに送信する」という形でリモートデバッグができるようにしていました。 しかしこの方法には、IDE向けの特殊なライブラリやデバッグモジュールを有効化させるためのコードをWebアプリケーションに組み込まなければならないという不便さがあります。

また、Apache + mod_wsgiがWebアプリケーションとは別プロセスになることから、それが原因でリモートデバッグがうまく機能しない状況も発生していました。 この問題は、「開発時にはシンプルなWSGIサーバ上でシングルプロセス・シングルスレッドの構成に切り替える」ことができれば解消できます。 そこで、WerkzeugというWSGIのためのライブラリを使って簡単なWSGIサーバを起動するスクリプト(リスト3)を用意し、IntelliJのDockerプラグイン*2の機能も利用することで、「IDEからのデバッグ時にはコンテナをWerkzeug版に置き換える」という仕組みを作りました。

リスト3: wsgi-server.py

from werkzeug.serving import run_simple
import sys
 
# Webアプリ用ライブラリのパスを追加
if "/opt/app" not in sys.path:
    sys.path.append("/opt/app")
 
# WSGIサーバ起動(Apache + mod_wsgi代替)
import app
 
run_simple(
    "0.0.0.0",
    80,
    app.application,
    use_reloader=True,
    use_debugger=False,
)

なお、モノタロウのWebアプリケーションを実行するには複数のコンテナが必要ですが、このWSGIサーバが必要になるのは、そのうちでリモートデバッグをしたいコンテナのみです。 そこで、このWSGIサーバの起動を実行するIntelliJのタスクを作成しておき、他のコンテナをすべて起動してから、そのタスクを実行してリモートデバッグをしたいコンテナを切り替えられるようにしています。 これにより、他の開発環境には一切影響をあたえずにデバッグ環境へと切り替えを可能にしています。 デバッグをやめたいときも、通常のコンテナの起動タスクを実行してそれに置き換えることで実現できるようにしています。

デバッグに必要な環境はIntelliJが裏側で調整してくれるので、開発者は特に意識しないで済むようになっています。 リモートデバッグを手動で設定していた時に比べてかなり楽になりました。 最終的な構成は図3のようになります。

f:id:t_yagi:20220222091301p:plain
図3: IDEとコンテナを利用したリモートデバッグの構成

20万行あるソースコードにコードフォーマッタを導入

モノタロウのWebアプリケーションは、2002年から現在に至るまでに、総計20万行の大規模なコードベースへと成長してきました。 そのコーディング規約は、初期には独自色の強いものでしたが、2016年ごろにPEP8に近いものへと変更されています。

新しい規約が導入された時点では、それ以前のコードもチェックしてツールによりある程度リライトすることが検討されたのですが、一括修正にはデグレーションのリスクがあります。 そのため、「新しく書かれるコードについてレビューを経て規約に添わせる」という方針がとられました。 新しい規約の解釈や適用範囲についての認識の違い、指摘漏れ、そもそもPython2をターゲットにした規約だったことなどから、結果として全体にコーディング規約が時代遅れで統一されていないという状況になっていました。

最近になって、OSSのコードフォーマッタによっては構文構造を変えないものがあることが判明したことから、コーディング規約を安全に移行できる可能性が出てきました。 中でも、スタイルが比較的厳格でPython3にも対応しており採用OSSも多いコードフォーマッタとして、モノタロウではBlack*3を選択しました。

ここでは、リポジトリにチェックインされる時点でBlackを自動適用する方法と、20万行あるコードベースを人手に頼らず「Black化」してその状態を維持する事例をご紹介します。

Blackの自動適用

モノタロウではコードベースをGitで管理しています。 コードフォーマッタが走るタイミングとしては、コミットの直前が望ましいので、Git hooksスクリプトを用いることにしました。

ただ、そのためには各個人の.gitディレクトリ配下にスクリプトを設置する必要があります。 そこで便利なのが、hooksスクリプトを管理するためのpreーcommitというPythonパッケージです。

pre-commitを利用する際は、.pre-commit-config.yamlというファイルに設定を記述し、それをリポジトリに含めます。 これは公式で公開されているもの*4があるので、これを利用します。 Blackを適用するhooksスクリプトを設置する設定はリスト4のようになります。

リスト4: Blackを適用するhooksスクリプトのためのpre-commitの設定

repos:
 - repo: https://github.com/psf/Black
   rev: 20.8b1
   hooks:
     - id: Black
       language_version: python3

Blackで適用するフォーマットの規則は、ルートディレクトリに置いたpyproject.tomlというファイルにより、リスト5のように指定します。

リスト5: Blackで適用するフォーマットの規則の例

line-length = 99  # 1行の最大文字数
target-version = ['py36'] # ターゲットのPythonバージョン

以上までの準備が整ったら、リスト6のように、pre-commitによるhooksスクリプトを作成するコマンドを実行します。 これで自動フォーマットできる環境が用意できます。

リスト6: pre-commitによるhooksスクリプトを作成するコマンド

python3 -m pipenv run pre-commit install --install-hooks

なお、Blackのようなコードフォーマッタを導入すると、git blameで確認できる各行の変更履歴がほとんどフォーマット変換のコミットになってしまい、本来知りたい変更履歴を確認するのが難しくなる問題があります。 この問題の回避には、Git 2.23以上で利用できるblame.ignoreRevsFileという機能が便利です。 この機能を利用するには、git blameを実行する環境でリスト7のようなコマンドを実行します。

リスト7: blame.ignoreRevsFileを有効にするコマンド

git config blame.ignoreRevsFile .git-blame-ignore-revs

リスト7のコマンドを実行すると、.git-blame-ignore-revsというファイルができるので、そこにgit blameで無視するコミットのハッシュを記述していきます。

すべてのコードベースを移行するまでの段取り

ここまでで自動コードフォーマッタの準備はできましたが、これを20万行のコードベースに導入しようとすると以下のような課題に直面します。

  • フォーマット変換されたソースコードをそのままリリースすると、同日にリリースされるコミットとコンフリクトしてしまう
  • 同日リリースでなくても、実装途中の作業ブランチに対してフォーマット変換後のdevelopブランチをマージすると、実装途中の変更がコンフリクトする
  • 自動コードフォーマッタの導入が個人の環境に依存するので、適用されずにコミットされる可能性もある

そこでモノタロウでは次の2つのルールに基づく運用で影響範囲を最小限にとどめるようにしました。

  • Blackによるフォーマット変換を適用するファイルを徐々に増やす
  • CI(Continuous Integration)にもBlackを組み込む

現在ではデリケートなソース以外ほぼコーディング規約の統一が完了しており、新旧のコーディング規約が混在して何が正解かわからない状況からは脱しています。

フォーマット変換を適用するファイルを徐々に増やす

Blackでは、フォーマット変換を適用しないファイルをpyproject.tomlで指定できます。 指定方法にはexcludeとforce-excludeがありますが、excludeでは開発者がBlackを実行する際にうっかり除外すべきファイルを引数に指定してしまうとフォーマットされてしまいます。 確実にフォーマット変換が適用されないようにするにはforce-excludeを使います。

ここで問題になるのは、「force-excludeに指定するファイルをどう見定めるか」です。 いろいろな考え方があると思いますが、モノタロウでは基本的に次のような方針をとりました。

  1. まず「直近の作業ブランチで変更されていそうなファイル」をforce-excludeに追加し、それ以外の大半のファイルをフォーマット変換した状態でリリースして
  2. その後、作業途中だったコミットがリリースされたタイミングを見計らって、残りのファイルを変換してリリース

CIで適用漏れをチェック

Blackによるフォーマット変換は開発者が実行して適用しますが、人によっては適用を忘れることがあります。 そこで、CIにBlackを組み込み、フォーマット変換の適用漏れを検出できるようにしています(CIでリフォーマットはしていません)。 モノタロウではCIにJenkinsを使っていて、そこでpipenvを呼び出しているので、PipfileにBlackをキックするスクリプトの定義を追加しています(リスト8)。

リスト8: PipfileでBlackをキック

[dev-packages]
Black = "==20.8b1"
pre-commit = "*"

[scripts]
Black-check = "Black . --check --diff --exclude \\.local"

開発環境の改善は終わらない

本記事では、サーバアプリケーションの開発環境をいかにして改善しているか、モノタロウにおける具体的な事例をいくつか紹介しました。

もちろん、開発環境の課題がこれですべて解消したわけではなく、モノタロウでは現在も開発環境の改善にメンバー7人ほどで取り組んでいます。 とくに、現在ではDockerイメージを個人のローカル環境でビルドしているので、今後はDockerRegistryなどのイメージを管理できるように整えていったり、Kubernetesのようなクラスタ構成で動作可能な環境を作り上げていったりすることが課題としてあります。 また、時代の移り変わりは速いので、課題に取り組む最中にデファクトスタンダードもまた進化し続けていきます。 現状に満足せず、開発者体験をさらに向上させる施策を常に考え続け、スピーディーに開発環境を更新できる体制を維持することも大切になります。

最後に、モノタロウにおいて本記事で紹介したような「開発環境の改善」を実現できた背景には、これが「業務として取り組む課題である」という合意が早くから社内にあったことが寄与していると考えています。 そのおかげで手を動かすメンバーが集まり、周囲の理解が得られる中で施策の検討や実施、あるいは告知やドキュメントの整備、トラブルシューティングに取り組んでこられました。

現在もモノタロウでは数多くのエンジニアがこうした課題に対して前向きに取り組んでおり、アイデアを持ち寄って改善に取り組むメンバーも日々増えています。一から新しいIT技術を取り入れていく経験はそう簡単にできないと思いますが、モノタロウでは真向に向き合える課題が山ほど存在し、いくらでもチャレンジするチャンスがあります。

カオスな状況を楽しみたい方、新しいIT技術を試してみたい方はぜひ一度モノタロウで働いてみませんか? カジュアル面談も実施していますので、ご興味がありましたら、ぜひカジュアルMTG登録フォームよりご応募ください!