自作PythonライブラリのGitHub Actionsの環境のキャッシュ利用やジョブの並列化などをやってみる

趣味で作っている自作のPythonライブラリ（PyPIにアップロードされてpipでインストールできるもの）のGitHub Actionsのジョブを並列化したり環境のキャッシュをしたりができないか・・・と色々検証したり等の備忘録です。GitHub Actionsに精通していないため煩雑なところなど結構あるかもしれませんがご容赦ください。

# 現在の状況

現在までの状態は以前記事にしています。

https://qiita.com/simonritchie/items/629a02fc1ad0fd02d267

フローは大まかに「環境の準備」 → 「各種Lintで引っかからないことをチェック」 → 「テストで引っかからないことをチェック」 → 「PyPIへのアップロード」 → 「README上のバッジなどの更新」 → 「アップロードされたパッケージのインストールなどのチェック」といった具合になっています。テストの環境も特定のPythonバージョンのみ（最低サポートバージョンのPython3.6）で実施されています。

図にすると以下のようになります。順番に直列で進んでいく感じです。

```plantuml
!include https://raw.githubusercontent.com/ptrkcsk/one-dark-plantuml-theme/v1.0.0/theme.puml

agent Pushのイベント
agent Python環境などの準備
agent flake8によるチェック
agent mypyによるチェック
agent numdoclintによるチェック
agent テストの実行とカバレッジの取得
agent PyPIへのアップロード
agent GitHub上へのReleases関係の設定
agent README上のバッジの更新
agent アップロードしたパッケージのインストールやライトな動作確認


Pushのイベント -down-> Python環境などの準備
Python環境などの準備 -down-> flake8によるチェック
flake8によるチェック -down-> mypyによるチェック
mypyによるチェック -down-> numdoclintによるチェック
numdoclintによるチェック -down-> テストの実行とカバレッジの取得
テストの実行とカバレッジの取得 -down-> PyPIへのアップロード
PyPIへのアップロード -down-> GitHub上へのReleases関係の設定
GitHub上へのReleases関係の設定 -down-> README上のバッジの更新
README上のバッジの更新 -down-> アップロードしたパッケージのインストールやライトな動作確認
```

# 今回調整したいこと

直列で処理していたものを以下のように調整したいと考えています。

```plantuml
!include https://raw.githubusercontent.com/ptrkcsk/one-dark-plantuml-theme/v1.0.0/theme.puml

agent Pushのイベント
agent 複数のPython環境などのキャッシュがなければ追加
agent flake8によるチェック
agent mypyによるチェック
agent numdoclintによるチェック
agent 1つのPython環境でのテストの実行
agent 複数のPython環境でのテストの実行
agent カバレッジの取得
agent PyPIへのアップロード
agent GitHub上へのRelease関係の設定
agent README上のカバレッジバッジの更新
agent 各Python環境でのアップロードしたパッケージのインストールやライトな動作確認

Pushのイベント -down-> 複数のPython環境などのキャッシュがなければ追加
複数のPython環境などのキャッシュがなければ追加 -down-> flake8によるチェック
複数のPython環境などのキャッシュがなければ追加 -down-> mypyによるチェック
複数のPython環境などのキャッシュがなければ追加 -down-> numdoclintによるチェック

複数のPython環境などのキャッシュがなければ追加 -down-> 1つのPython環境でのテストの実行
1つのPython環境でのテストの実行 -down-> カバレッジの取得
カバレッジの取得 -down-> README上のカバレッジバッジの更新

複数のPython環境などのキャッシュがなければ追加 -down-> 複数のPython環境でのテストの実行

flake8によるチェック -down-> PyPIへのアップロード
mypyによるチェック -down-> PyPIへのアップロード
numdoclintによるチェック -down-> PyPIへのアップロード
README上のカバレッジバッジの更新 -down-> PyPIへのアップロード
複数のPython環境でのテストの実行 -down-> PyPIへのアップロード

flake8によるチェック -down-> GitHub上へのRelease関係の設定
mypyによるチェック -down-> GitHub上へのRelease関係の設定
numdoclintによるチェック -down-> GitHub上へのRelease関係の設定
README上のカバレッジバッジの更新 -down-> GitHub上へのRelease関係の設定
複数のPython環境でのテストの実行 -down-> GitHub上へのRelease関係の設定

PyPIへのアップロード -down-> 各Python環境でのアップロードしたパッケージのインストールやライトな動作確認
```


ポイントとしては、

- 最初に環境のキャッシュの有無を確認して、キャッシュが無ければ作成・保存しておく。
- 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バージョンを配列で指定するだけでとりあえずは対応ができます。例えば以下のように書きます。

```yaml
...
    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バージョンを指定していた場合、

```yaml
...
      - name: Set the Python version
        uses: actions/setup-python@v2
        with:
          python-version: 3.6.15
...
```

としていたPythonバージョンの指定を以下のようにすることで各Pythonバージョンで実行されるようになります。

```yaml
      - name: Set the Python version
        uses: actions/setup-python@v2
        with:
          python-version: ${{ matrix.python-version }}
```

実行は特に何か設定しなくても自動で並列化（各環境で同時にジョブがスタートする形に）してくれます。お手軽で良いですね。

# キャッシュ設定の基本

キャッシュ制御には以下の提供されているものを使っていきます。

https://github.com/actions/cache

制御の基本は以下のような記述になります。

```yaml
      - uses: actions/cache@v2
        with:
          path: <キャッシュしたいディレクトリパス>
          key: <キャッシュデータのためのキーの文字列>
```

pathにはキャッシュ・復元したいディレクトリを指定します。keyにはキャッシュデータを扱うためのキーを扱います（キャッシュの有無の判定などに使われます）。ライブラリなどの依存関係のものが更新された場合は古いキャッシュは参照しない形にするべきなので、こちらのキーにはそれらの依存関係の設定ファイルのハッシュ値などを含める形になります。

例えばPythonでpipでインストールした各ライブラリのものをキャッシュしたい場合には以下のような書き方になります。

```yaml
...
      - 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の環境変数に設定しています。

```yaml
run: python -c "import site; print(f'site_package_path={site.getsitepackages()[0]}')" >> $GITHUB_ENV
```

※以下のようなPythonスクリプトでsite_packagesのパスが取れます。

```py
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に制限されます。

https://docs.github.com/ja/actions/advanced-guides/caching-dependencies-to-speed-up-workflows

古くなって使われなくなったものなど、一定期間以上アクセスされていないキャッシュは自動で削除されるようです。

削除がされていなければそのワークフローが終わってもキャッシュは有効なままです（再度デプロイなどした際にキャッシュが残っていればそのままキャッシュが使われます）。

# キャッシュされたデータの利用

キャッシュされているデータに関しては同じようにパスやキーの設定をすることで別の各ジョブなどで復元することができます。

```yaml
      - 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のキャッシュデータをそのままキャッシュする・・・形で記述がされています。

https://github.com/actions/cache/blob/main/examples.md#python---pip

例えばディレクトリパスの指定が以下のようになっています。

```yaml
...
- 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/`というパスが該当するようです。

そのため今回は同じようなキャッシュ設定での保存処理で以下のような記述を別途加えました。

```yaml
...
      - 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のコマンド用の例となります）。

```yaml
...
      - 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のコマンドが実行されるようになります（インストールがされた後に、該当ジョブの最後にキャッシュが保存されます）。

```yaml
...
      - 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`と指定することでキャッシュの保存処理を待ってからジョブをスタートすることができます。

```yaml
...
  RunFlake8:
    needs: CreateCache
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v2
...
```

同じneedsの指定を持つジョブは同じタイミングで並列でジョブがスタートします。何も考えなくても並列で処理してくれるようになるのはシンプルで素敵です。

複数のジョブの完了を待つ必要がある場合にはneedsの箇所を配列で指定します。例えば各Lintやテストが一通り終わって無事通ったらデプロイ関係のものを動かす・・・としたい場合には以下の`needs: [RunFlake8, RunMypy, RunNumdoclint, ...]`といったように配列で指定します。

```yaml
...
  DeployToPyPI:
    needs: [RunFlake8, RunMypy, RunNumdoclint, RunTestsOnPython36AndSaveCoverage, RunTestsOnEachPythonVersion]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v2
...
```

# 最終的なフローと現在のYAMLファイル

とりあえずこれでやりたかったことは実現できました。満足です・・・！最後に全体のワークフローの可視化分と各ジョブ全体のYAMLファイルのリンクを貼っておきます（日々更新したりしているので記事中の記述とずれている箇所もあると思いますがご容赦ください）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/e822f5ba-0ff2-4d56-7c9d-525e05f96bf6.png)

メインのワークフロー:

https://github.com/simon-ritchie/apysc/blob/main/.github/workflows/deploy_to_pypi.yml

キャッシュなどからの環境復元用のComposite run steps （記述の重複を減らすためファイルを分割してあります）:

https://github.com/simon-ritchie/apysc/blob/main/.github/actions/setup_py_dependencies/action.yml

# 参考文献・サイト

https://github.com/actions/cache

https://github.community/t/pip-dependencies-not-getting-cached-even-after-successful-restore/126672

https://qiita.com/ryuichi1208/items/34fb2dda9872cc1fd4bb

https://qiita.com/chatrate/items/eb4b05cd1a6652529fd9

https://blog.ymyzk.com/2019/12/github-actions-for-python-projects/

https://docs.github.com/ja/actions/advanced-guides/caching-dependencies-to-speed-up-workflows