趣味で作っている自作のPythonライブラリ(PyPIにアップロードされてpipでインストールできるもの)のGitHub Actionsのジョブを並列化したり環境のキャッシュをしたりができないか・・・と色々検証したり等の備忘録です。GitHub Actionsに精通していないため煩雑なところなど結構あるかもしれませんがご容赦ください。
現在の状況
現在までの状態は以前記事にしています。
フローは大まかに「環境の準備」 → 「各種Lintで引っかからないことをチェック」 → 「テストで引っかからないことをチェック」 → 「PyPIへのアップロード」 → 「README上のバッジなどの更新」 → 「アップロードされたパッケージのインストールなどのチェック」といった具合になっています。テストの環境も特定のPythonバージョンのみ(最低サポートバージョンのPython3.6)で実施されています。
図にすると以下のようになります。順番に直列で進んでいく感じです。
今回調整したいこと
直列で処理していたものを以下のように調整したいと考えています。
ポイントとしては、
- 最初に環境のキャッシュの有無を確認して、キャッシュが無ければ作成・保存しておく。
- Lintやテストは並列で処理を行う。
- 環境は各Pythonバージョン(3.6, 3.7, 3.8, 3.9, 3.10)で作成し、テストなどもこれらの各バージョンで行うようにする。
- 諸々のLintやテストが終わるのを待ってからPyPIへのアップロードなどを行う。
といった感じです。並列化してデプロイまでの時間を短縮したいと考えています。また、今後処理が増えたときも並列で並べられる箇所はなるべく並べて処理していきたい・・・と考えています(追加すればするほど処理時間が長くなるのをなるべく軽減したいと思っています)。
並列化する際にいちいち環境を作っていると処理時間がぐぐっと伸びてしまうためGitHub Actions上で使えるキャッシュの機能を使って環境のキャッシュをする形にします(自前のDockerコンテナとか使った形でもいいかな?と思いましたがイメージビルド周りまでやっていると結構手間がかかりそうだったため今回はスキップします。将来別途チャレンジするかもしれません)。
また、OSは今回はLinux(Ubuntu)のみのまま進めます(Pythonバージョンのみ複数環境扱う形を想定)。
Python複数環境の設定
今回Pythonの実行環境を複数バージョンにしていくため、キャッシュやテストの箇所ではそのための記述が必要になります。
やり方は簡単で、strategyのmatrixで使用するPythonバージョンを配列で指定するだけでとりあえずは対応ができます。例えば以下のように書きます。
...
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6.15, 3.7.12, 3.8.12, 3.9.9, 3.10.0]
...
こうすることでジョブ内で${{ matrix.python-version }}
と参照することでループをル回すような感じで各バージョン上で実行することができます。例えば今まで固定のPythonバージョンを指定していた場合、
...
- name: Set the Python version
uses: actions/setup-python@v2
with:
python-version: 3.6.15
...
としていたPythonバージョンの指定を以下のようにすることで各Pythonバージョンで実行されるようになります。
- name: Set the Python version
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
実行は特に何か設定しなくても自動で並列化(各環境で同時にジョブがスタートする形に)してくれます。お手軽で良いですね。
キャッシュ設定の基本
キャッシュ制御には以下の提供されているものを使っていきます。
制御の基本は以下のような記述になります。
- uses: actions/cache@v2
with:
path: <キャッシュしたいディレクトリパス>
key: <キャッシュデータのためのキーの文字列>
pathにはキャッシュ・復元したいディレクトリを指定します。keyにはキャッシュデータを扱うためのキーを扱います(キャッシュの有無の判定などに使われます)。ライブラリなどの依存関係のものが更新された場合は古いキャッシュは参照しない形にするべきなので、こちらのキーにはそれらの依存関係の設定ファイルのハッシュ値などを含める形になります。
例えばPythonでpipでインストールした各ライブラリのものをキャッシュしたい場合には以下のような書き方になります。
...
- name: Set the site package path to the environment variables
run: python -c "import site; print(f'site_package_path={site.getsitepackages()[0]}')" >> $GITHUB_ENV
- name: Set the Python package cache
uses: actions/cache@v2
with:
path: ${{ env.site_package_path }}
key: ${{ runner.os }}-pip-${{ env.site_package_path }}-${{ hashFiles('**/requirements.txt') }}
...
まずはキャッシュしたライブラリのディレクトリパス(site_packagesのパス)を取得したいので以下のようにPythonスクリプトを動かして取得しGitHubの環境変数に設定しています。
run: python -c "import site; print(f'site_package_path={site.getsitepackages()[0]}')" >> $GITHUB_ENV
※以下のようなPythonスクリプトでsite_packagesのパスが取れます。
import site
print(f'site_package_path={site.getsitepackages()[0]}')
site_package_path=/usr/local/lib/python3.6/site-packages
※環境変数名=環境変数の値 >> $GITHUB_ENV
とすることでGitHub Actions上での環境変数に値を設定できます。今回の例ではsite_package_pathという環境変数名で扱っています。
設定した環境変数はenv.<環境変数名>
という形で取れるので、今回はパスの指定でpath: ${{ env.site_package_path }}
と指定しています。
キーの設定に関してはkey: ${{ runner.os }}-pip-${{ env.site_package_path }}-${{ hashFiles('**/requirements.txt') }}
という形にしています。
${{ runner.os }}
部分でOSの情報が取れます。現在はUbuntuのみ使っていますが、将来Windowsなどもテストで扱う場合にはキャッシュを別にするという目的で指定しています。
-pip-
部分は任意の値を設定してください。今回はPythonのライブラリパッケージのキャッシュなので-pip-
といったように名前を付けています。
${{ env.site_package_path }}
部分ではPythonバージョンなどが含まれるので結果的にキャッシュのキーにPythonのバージョン情報が含まれるようになります(各バージョンごとにキャッシュが取られます)。
{ hashFiles('**/requirements.txt') }}
部分ではプロジェクトリポジトリ内に含まれる各requirements.txtファイルに応じたハッシュ値が取れます。つまりrequirements.txt内のライブラリの指定やライブラリバージョンが更新されたらキャッシュのキーが変わるのでそのタイミングで新たにキャッシュが取られるようになります(※requirements.txtへの参照などのために事前にGitHub Actions上でリポジトリのチェックアウトしておく必要があります)。
※requirements.txt以外のファイルでライブラリ管理をしている場合(例えばPoetryなどを使っている場合など)にはその辺のファイル名の指定は調整する必要があります。
※キャッシュの保存自体はそのジョブの最後に実行されるようになるため、キャッシュ制御の記述の前にライブラリなどを事前にインストールしておかなくてはいけない・・・といったことはありません。後の節で触れますがキャッシュが存在すればライブラリのインストールなどの諸々をスキップして処理時間をさらに短縮ずる・・・といった制御が可能です。
キャッシュデータの有効期限や制限
公式の資料に以下のように書かれています。
GitHubは、7日間以上アクセスされていないキャッシュエントリを削除します。 保存できるキャッシュ数には上限がありませんが、1つのリポジトリ内のすべてのキャッシュの合計サイズは5GBに制限されます。
古くなって使われなくなったものなど、一定期間以上アクセスされていないキャッシュは自動で削除されるようです。
削除がされていなければそのワークフローが終わってもキャッシュは有効なままです(再度デプロイなどした際にキャッシュが残っていればそのままキャッシュが使われます)。
キャッシュされたデータの利用
キャッシュされているデータに関しては同じようにパスやキーの設定をすることで別の各ジョブなどで復元することができます。
- name: Restore the Python package cache
uses: actions/cache@v2
with:
path: ${{ env.site_package_path }}
key: ${{ runner.os }}-pip-${{ env.site_package_path }}-${{ hashFiles('**/requirements.txt') }}
※事前に必要な環境変数(site_package_pathなど)を設定したり、requirements.txt用にGitHub Actions上でのリポジトリのチェックアウトなどはこちらでも同様に必要になります(キャッシュのキーが変わってしまうとキャッシュを保存する処理となってしまいます)。
今回試していた感じ、site_packagesをキャッシュから復元する形だと4秒くらい(バイナリも復元すると6秒くらい)で処理が完了するようになり1から各Pythonライブラリをインストールしていると30秒くらいかかっていたのが大分改善しました(各ジョブを細かく分けて並列化したりするので地味に開発体験が良くなりました)。
公式資料に書かれていたpipのキャッシュ機能を使ってみたものの・・・
キャッシュの機能のGitHub上の資料では、pipの場合はsite_packagesではなくpipのキャッシュデータをそのままキャッシュする・・・形で記述がされています。
例えばディレクトリパスの指定が以下のようになっています。
...
- uses: actions/cache@v2
with:
path: ~/.cache/pip
...
これだとpipによるインストールを走らせた場合、ライブラリバージョンなどが変わっていなければインストール用のファイルのダウンロードは実行されずにキャッシュファイルを参照してライブラリのインストールが実行されます。
一見するとこれでも速くなりそうだったのですが、GitHub Actions上でのライブラリファイルのダウンロード速度は十分に速くキャッシュから復帰した場合と比べて今回の私のケースではほぼ速度が改善しませんでした。どちらかというとダウンロード時間というよりもインストールなどが終わるまでの時間がボトルネックになっていたようです。
そのため今回はpipのダウンロードキャッシュをキャッシュするのではなくインストール(ビルド)済みのsite_packagesやbinのディレクトリをダイレクトにキャッシュする形で進めています(こちらの場合ぐぐっと実行時間が短縮されました)。
binやエイリアスの調整を行う
site_packagesをキャッシュする形にして高速化したのは良いものの、そのままだとflake8などのコマンドがダイレクトに実行できません(通常のライブラリimportなどは効くものの)。
flake8やmypyなどのコマンド実行用のbinディレクトリの方のキャッシュやエイリアス設定なども必要になります。
binディレクトリは$ which flake8
などのコマンドで表示されるパスが該当します。GitHub Actions上ではUbuntuの場合/opt/hostedtoolcache/Python/<Pythonバージョン>/x64/bin/
というパスが該当するようです。
そのため今回は同じようなキャッシュ設定での保存処理で以下のような記述を別途加えました。
...
- name: Set the bin cache if there is no site-packages cache
if: steps.pip_cache.outputs.cache-hit != 'true'
uses: actions/cache@v2
with:
path: /opt/hostedtoolcache/Python/${{ matrix.python-version }}/x64/bin/
key: ${{ runner.os }}-bin-cache-${{ env.site_package_path }}-${{ hashFiles('**/requirements.txt') }}
...
他のジョブ部分でコマンドの実行が必要なものに関しては以下のようにキャッシュの復帰とエイリアスの設定をしています(flake8のコマンド用の例となります)。
...
- name: Restore the bin cache
uses: actions/cache@v2
with:
path: /opt/hostedtoolcache/Python/${{ env.python_version }}/x64/bin/
key: ${{ runner.os }}-bin-cache-${{ env.site_package_path }}-${{ hashFiles('**/requirements.txt') }}
- name: Set the flake8 alias
run: alias flake8=/opt/hostedtoolcache/Python/${{ env.python_version }}/x64/bin/flake8
...
※この辺はちょっと記述が煩雑には感じたので自前のDockerコンテナを使う形の方がいいのかも・・・?と思いつつ作業しています。
キャッシュがヒットしたかどうかの判定を行う
キャッシュを行うジョブ内で、もしキャッシュが存在すればライブラリのインストールなどをスキップする・・・と制御を行うことで無駄が無くなります。
そういった場合にはYAML上でif: steps.pip_cache.outputs.cache-hit != 'true'
といった感じの制御の指定を書いておくと、キャッシュが存在しない場合のみ処理を行う・・・という制御にすることができます。pip_cache
部分はキャッシュ時にidとして指定します。
例えば以下のようにキャッシュの記述でid: pip_cache
とID設定をしておいて、その後に続く処理でif: steps.pip_cache.outputs.cache-hit != 'true'
という記述を加えつつpip install ...
の指定を行うことで、キャッシュが存在しない場合のみpipのコマンドが実行されるようになります(インストールがされた後に、該当ジョブの最後にキャッシュが保存されます)。
...
- name: Set the Python package cache
uses: actions/cache@v2
id: pip_cache
with:
path: ${{ env.site_package_path }}
key: ${{ runner.os }}-pip-${{ env.site_package_path }}-${{ hashFiles('**/requirements.txt') }}
...
- name: Install each Python library if there is no site-packages cache
if: steps.pip_cache.outputs.cache-hit != 'true'
run: pip install -r requirements.txt
キャッシュの保存を待ってから他のジョブをスタートさせるようにする
キャッシュの保存(既にキャッシュが存在すればスキップ)用のジョブを待ってからflake8やmypyなどのLint、その他テストを走らせたい場合にはneedsの指定で実行が終わるのを待機するジョブを指定します。
今回は最初に実行するキャッシュの保存(有無確認)処理のジョブをCreateCache
という名前にしているため、例えば以下のようにneeds: CreateCache
と指定することでキャッシュの保存処理を待ってからジョブをスタートすることができます。
...
RunFlake8:
needs: CreateCache
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v2
...
同じneedsの指定を持つジョブは同じタイミングで並列でジョブがスタートします。何も考えなくても並列で処理してくれるようになるのはシンプルで素敵です。
複数のジョブの完了を待つ必要がある場合にはneedsの箇所を配列で指定します。例えば各Lintやテストが一通り終わって無事通ったらデプロイ関係のものを動かす・・・としたい場合には以下のneeds: [RunFlake8, RunMypy, RunNumdoclint, ...]
といったように配列で指定します。
...
DeployToPyPI:
needs: [RunFlake8, RunMypy, RunNumdoclint, RunTestsOnPython36AndSaveCoverage, RunTestsOnEachPythonVersion]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v2
...
最終的なフローと現在のYAMLファイル
とりあえずこれでやりたかったことは実現できました。満足です・・・!最後に全体のワークフローの可視化分と各ジョブ全体のYAMLファイルのリンクを貼っておきます(日々更新したりしているので記事中の記述とずれている箇所もあると思いますがご容赦ください)。
メインのワークフロー:
キャッシュなどからの環境復元用のComposite run steps (記述の重複を減らすためファイルを分割してあります):
参考文献・サイト