GitHub Actions の Workflow を高速化する

## はじめに

Github Actions の Workflow を高速化する方法を解説します。
解説はフロントエンドのデプロイワークフローを例にしますが、他にも転用できると思います。

## 前提

実行環境として Node.js、パッケージマネージャに Yarn を使ったプロジェクトを想定をします。
フロントエンドでは、デプロイするまでに行うこととして、主に以下の事項があります。

1. モジュールのインストール
2. リンターによる静的チェック
3. 各種テスト（Unit, E2E）
4. ビルド
5. デプロイ

基本的には、これらを事項について、ワークフローで上から行っていけばいいわけですが、プロジェクトが大きくなると、パッケージのインストールは遅くなり、静的チェックも時間がかかるようになります。よってこれらを高速化することを目指しましょう。

## モジュールのインストールを高速化する

モジュールのインストールを高速化することは、手軽かつ確実に速度に繋がります。他の手法を差し置いても、これだけは是非しておくといいと思います。

Node.js プロジェクトでは、パッケージマネージャに Yarn、Yarn2 や NPM 、pnpm など利用しますが、基本的にはどの言語のパッケージマネージャでも高速化の恩恵を預かれると思います。

以下、インストールのワークフローの例です。

```yml
name: deploy
on: push

jobs:
  setup:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [latest]
        node: [latest]
    steps:
      - uses: actions/setup-node@v2-beta
        with:
          node-version: ${{ matrix.node }}

      - name: Checkout
        uses: actions/checkout@v2

      - name: Cache node_modules
        id: node_modules_cache_id
        uses: actions/cache@v2　// 1
        with:
          path: node_modules
          key: ${{ matrix.os }}-node-v${{ matrix.node }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }}

      - name: Install
        if: steps.node_modules_cache_id.outputs.cache-hit != 'true'　// 2
        run: yarn --check-files --frozen-lockfile --non-interactive
```

ポイントは２つあり、

1. Github の公式 Action である `actions/cache` を使い、node_modules をキャッシュすること。
2. パッケージのインストール時に `actions/cache` の戻り値を確認すること。

です。1 では、キャッシュの`key`として yarn.lock ファイルのハッシュ値を使っています。一意であればなんでも構いませんが、`matrix.os`などの環境情報を組み合わせるといいでしょう。

そして、yarn.lock ファイルの内容に変更があった、もしくは初回のワークフロー時には、

```bash
Run actions/cache@v2
Cache not found for input keys: ubuntu-latest-node-v12-deps-xxxxxxxxxxxxx
```

となり、`actions/cache`の戻り値は **false** が返ります。また、`actions/cache`の戻り値は `cache-hit` というキーに Boolean が入ります。取得するには`steps.node_modules_cache_id.outputs.cache-hit`のようにします。

反対に yarn.lock ファイルに変更がなければ、

```bash
Cache restored from key: ubuntu-latest-node-v12-deps-xxxxxxxxxxxxx
```

となり、`steps.node_modules_cache_id.outputs.cache-hit`は **true** を返します。

また、自動的にリストアが行われるので、同一 steps 内で、他の step を行う場合は、モジュールがインストールされた状態と同じになります。分割された job でモジュールのキャッシュを利用する場合は、追って解説します。

2 では、if の条件として、`steps.node_modules_cache_id.outputs.cache-hit`を指定しています。つまり、キャッシュが有った場合は、モジュールのインストール自体をスキップしています。

以上によって、変更ない場合のモジュールのインストールを回避し、ワークフローの高速化をすることができます。

余談ですが、キャッシュのポストは、steps の終わりに自動的に行われます。
つまり、上記のワークフローであれば、

```bash
Cache node_modules
Install
Post Cache node_modules
```

のようになり、自動的にキャッシュのポストが割り込まれます。キャッシュヒットした場合は、キャッシュのポストは行われません。

## step の並列化で高速化する

step を job に分割することで、待ち時間を短縮し高速化が期待できます。Github Actions では job はランナーごとの単位であり、並列に動作します。そのため、例えばリントとテストのアクションは別の job にすることで、並列化することができます。

```yml
jobs:
  lint-script:
    needs: setup
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [latest]
        node: [latest]

    steps:
      - uses: actions/setup-node@v2-beta
        with:
          node-version: ${{ matrix.node }}

      - name: Checkout
        uses: actions/checkout@v2

      - name: Restore node_modules // 4
        id: node_modules_cache_id
        uses: actions/cache@v2
        with:
          path: node_modules
          key: ${{ matrix.os }}-node-v${{ matrix.node }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }}

      - name: lint by ESlint
        run: yarn lint:script

  test-unit:
    needs: setup
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [latest]
        node: [latest]

    steps:
      - uses: actions/setup-node@v2-beta
        with:
          node-version: ${{ matrix.node }}

      - name: Checkout
        uses: actions/checkout@v2

      - name: Restore node_modules // 4
        id: node_modules_cache_id
        uses: actions/cache@v2
        with:
          path: node_modules
          key: ${{ matrix.os }}-node-v${{ matrix.node }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }}

      - name: Test unit
        run: yarn test:unit --ci

  build:
    needs: [lint-script, test-unit] // 3
```

リントとテストの終了を待つには、3 のように `jobs.<job_id>.needs` に job を指定する必要があります。

ここで、モジュールの利用法について触れます。job に分割したとき、各 step がモジュールを利用する場合には、step のはじめにモジュールをインストールするか、キャッシュからモジュールのリストアをする必要があります。上記で、モジュールをキャッシュしているので、それをリストアして使いましょう。

4 では、キャッシュのリストアをしています。見るとわかりますが、キャッシュのストアとリストアは全く同じインターフェイスになっています。

## まとめ

ワークフローの高速化には、モジュールのキャッシュ化と、step の並列化によって達成できます。

step を job に分割するのは有効ですが、時間がかからない step の場合は、モジュールのリストアのほうが時間がかかってしまい、かえってワークフローが遅延してしまう可能性があります。また、現在の Github Actions では、 Free Plan アカウントの job の同時実行が 20 個までとなっています。

そのため、各 step を見定めて並列化を行うといいと思います。それでは。


*本記事は @qualitia_cdevの中の一人、宮内さんに書いていただきました。