Nx Agentsを導入してフロントエンドのCIを約40%高速化しました

ファインディ株式会社でフロントエンドのリードをしている新福(@puku0x)です。

弊社では Nx を活用してCIを高速化しています。この記事では、最近導入した Nx Agents でフロントエンドのCIをさらに高速化した事例を紹介します。

Nxについては以前の記事で紹介しておりますので、気になる方は是非ご覧ください。

tech.findy.co.jp

フロントエンドのCIの課題

これまで「キャッシュの活用」や「並列化」「マシンスペックの向上」といった工夫により、フロントエンドのCIを高速化してきました。

しかし、コードベースの増大により時間のかかるタスクにCI時間が引きずられてしまう問題が顕著になってきました。

次の図は、キャッシュヒットしなかった場合のCI時間の一例です。

他のタスクが早く終わっても一番時間のかかるタスクを待つ必要があるため、結果としてCI時間が伸びる傾向にありました。

Nx Agents

タスク単位の並列化では、CI時間のボトルネックを解消するのが困難です。

「DTE(Distribute Task Execution)」はその問題を解決する手法であり、Nx CloudにはDTEのマネージドなサービスである「Nx Agents」が提供されています。

Nx Agentsの動作イメージ(Nx公式ドキュメントより抜粋)

Nx Agentsは今年2月にリリースされました。費用はGitHub Actionsより安価となっています。

blog.nrwl.io

Nx Agents GitHub Actions(Larger runner)
Linux 2コア $0.0055/min $0.008/min
Linux 4コア $0.011/min $0.016/min

参考:

使い方は非常に簡単で、NxワークスペースをNx Cloudに接続した後、CIのタスク実行前に次のようなコマンドを追加するだけです。

npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js"

利用するマシンの数やスペックは、変更の影響範囲に応じて動的に設定できます。

npx nx-cloud start-ci-run --distribute-on=".nx/workflows/dynamic-changesets.yml"
# .nx/workflows/dynamic-changesets.yml
distribute-on:
  small-changeset:  3 linux-medium-js
  medium-changeset: 6 linux-medium-js
  large-changeset:  9 linux-large-js

DTE自体はGitHub Actionsの matrix の機能を用いて構成することも可能ですが、CIが失敗した場合は 必ずDTEホスト側のマシンをRe-runする必要があります。検証した限りでは誤操作を確実に防ぐ方法が無かったため、Nx Agentsの利用をおすすめします。

Nx Agents導入の結果

Nx Agentsを有効にした場合のCI時間の例を示します。

このワークフローでは、アプリケーションのビルドやテスト、型チェックなどを全てNx Agentsで実行する構成となっています。

npx nx-cloud start-ci-run --distribute-on=".nx/workflows/dynamic-changesets.yml"
npx nx affected --target=build,build-storybook,test,lint,stylelint,typecheck --configuration=ci
npx nx-cloud complete-ci-run

元の構成では約18分かかっていたものが約11分になりました。
CI本体が約10分、後述する事前処理が追加で約1分かかる計算です。

CI時間は約7分削減されており、およそ 40% ほど高速化できたということがわかりました。

各タスクが複数のエージェントで分散実行されたことで、タスク単位で並列化していた場合よりも高速化できました。

Nx Agents利用上の工夫

Nx Agentsは今年リリースされたばかりのサービスであるため、ドキュメントの不備やノウハウの不足といった課題があることに注意しましょう。ドキュメントに示されているセットアップの例は、最低限のものしかないため最適化の余地があります。

ここでは、弊社で実践している利用上の工夫を紹介します。

プロジェクトを細かく分割する

DTEの性質上、単体のタスクの実行時間が長くなるほどエージェントの利用効率が下がります。

コードを全て apps/** 側に置くとキャッシュヒット率が下がりCI時間も延びるため、まずは libs/** に分離することから始めると良いと思います。

共有ライブラリについては、コンポーネント系、ユーティリティ系で別のライブラリとして作ると良いでしょう。

弊社の場合、例えばビルド時間が 2分 を超えるようなものを確認した場合は、モジュールの移動や分割を実施しました。

分割が難しい場合は、Nx Agentsの実行対象から外すといった工夫が必要かもしれません。

Node.jsのバージョンを揃える

nx-cloud-workflowsworkflow-steps/install-nodenvm に対応しています。nvm以外の管理ツールを利用している場合は自前でセットアップ用のスクリプトを書く必要があります。

例として asdf を用いる場合のスクリプトを示します。

# .tool-versions
nodejs 20.18.0
# .nx/workflows/agents.yml
launch-templates:
  custom-linux-medium-js:
    resource-class: 'docker_linux_amd64/medium'
    image: 'ubuntu22.04-node20.11-v10'
    init-steps:
      - name: Checkout
        uses: 'nrwl/nx-cloud-workflows/v4/workflow-steps/checkout/main.yaml'
      - name: Setup Node.js
        script: |
          git clone https://github.com/asdf-vm/asdf.git $HOME/.asdf --branch v0.14.1
          source $HOME/.asdf/asdf.sh
          asdf plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git
          asdf install nodejs

CI時間は5〜10秒ほど延びますが、Node.jsのバージョン不一致によるエラーを回避できます。

キャッシュの活用

workflow-steps/cache を利用した依存ライブラリのキャッシュはほぼ必須と言えるでしょう。node_modules をキャッシュすることで大幅な時間削減が可能です。

# .nx/workflows/agents.yml
launch-templates:
  custom-linux-medium-js:
    (中略)
      - name: Restore NPM Cache
        uses: 'nrwl/nx-cloud-workflows/v4/workflow-steps/cache/main.yaml'
        inputs:
          key: '.tool-versions'
          paths: ~/.npm
          base_branch: '<デフォルトブランチ名>'
      - name: Restore Node Modules Cache
        uses: 'nrwl/nx-cloud-workflows/v4/workflow-steps/cache/main.yaml'
        inputs:
          key: '.tool-versions|package-lock.json|yarn.lock|pnpm-lock.yaml'
          paths: node_modules
          base_branch: '<デフォルトブランチ名>'
      - name: Restore Browser Binary Cache
        uses: 'nrwl/nx-cloud-workflows/v4/workflow-steps/cache/main.yaml'
        inputs:
          key: '.tool-versions|package-lock.json|yarn.lock|pnpm-lock.yaml|"browsers"'
          paths: |
            ~/.cache/ms-playwright
          base_branch: '<デフォルトブランチ名>'

ここで示した例では、以前の記事と同様に ~/.npm をキャッシュすることで、node_modules がキャッシュヒットしなかった場合でも可能な限り高速動作するようにしています。

tech.findy.co.jp

workflow-steps/cache の使い勝手は actions/cache とほぼ同じです。デフォルトブランチ以外にキャッシュを共有する場合は、デフォルトブランチへのpush時にキャッシュを更新すると良いと思います。

# .github/workflows/update-cache.yml
on:
  push:
    branches:
      - '<デフォルトブランチ名>'
    paths:
      - package-lock.json

jobs:
  cache:
    runs-on: ubuntu-latest
    steps:
      (中略)
      - uses: actions/cache@v4
        id: cache
      (中略)
      - run: npx nx-cloud start-ci-run --distribute-on="3 linux-small-js"
      - name: Install dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci
      - run: npx nx-cloud complete-ci-run

設定可能な最小エージェント数は 3 である点に注意しましょう。
安く済ませたいところではありますが...

特定のステップの省略

2024年10月現在、Nx AgentsではGitHub Actionsのような条件分岐の構文はサポートされていません。適宜スクリプトを書いて対応しましょう。

# .nx/workflows/agents.yml
launch-templates:
  custom-linux-medium-js:
    (中略)
      - name: Install Node Modules (if needed)
        script: |
          if [ ! -d node_modules ]; then
            npm ci
          fi

高度なエージェント割り当て

--distribute-on=".nx/workflows/dynamic-changesets.yml" は記述が簡潔である一方、現状では small medium large の3段階しか設定できません。また、負荷に応じてマシンスペックを上げるといった高度な割り当てもサポートされていません。

nx.dev

Nx Agentsの今後のアップデートに期待しても良いですが、待ちきれないという場合は次のように、メインジョブの前段で nx show projects --affected を実行し、outputs 経由でオプションを渡すと良いでしょう。

jobs:
  check:
    runs-on: ubuntu-latest
    outputs:
      distribute_on: ${{ steps.output.outputs.distribute_on }}
      parallel: ${{ steps.output.outputs.parallel }}
    steps:
      (中略)
      - uses: nrwl/nx-set-shas@v4
        with:
          main-branch-name: ${{ github.base_ref }}
      - name: Get affected projects
        id: get_affected_projects
        run: |
          length=$(npx nx show projects --affected --json | jq '. | length')
          echo "length=$length" >> $GITHUB_OUTPUT
      - name: Output
        id: output
        run: |
          if [ ${{ steps.get_affected_projects.outputs.length }} -gt 20 ]; then
            echo 'distribute_on="4 custom-linux-large-js"' >> $GITHUB_OUTPUT
            echo 'parallel=4' >> $GITHUB_OUTPUT
          elif [ ${{ steps.get_affected_projects.outputs.length }} -gt 10 ]; then
            echo 'distribute_on="3 custom-linux-medium-plus-js"' >> $GITHUB_OUTPUT
            echo 'parallel=3' >> $GITHUB_OUTPUT
          else
            echo 'distribute_on="3 custom-linux-medium-js"' >> $GITHUB_OUTPUT
            echo 'parallel=2' >> $GITHUB_OUTPUT
          fi

  main:
    needs: check
    runs-on: ubuntu-latest
    steps:
      (中略)
      - name: Setup Nx Cloud
        run: npx nx-cloud start-ci-run --distribute-on=${{ needs.check.outputs.distribute_on }}
      - run: npx nx affected --target=build,test,lint -parallel=${{ needs.check.outputs.parallel }}

この手法は dynamic-changesets.yml による判定と nx affected で検出された影響範囲に乖離がある場合にも有効です。

まとめ

この記事では、Nx Agentsの導入によりフロントエンドのCIを高速化した事例を紹介しました。

Nx Agentsの提供するDTEの仕組みは、タスク単位の並列化を超えた高速化が可能です。

一方で、プロジェクトの細分化が十分でない場合や、単一のタスクが非常に時間のかかる場合では期待する効果が得られないため、適材適所で利用するのが良いでしょう。

国内のNx Agents導入事例はまだ少ないと思われますが、検証中に得られた知見は今後も発信していきますので、是非お役立てください。私たちの取り組みが少しでも皆様の助けとなれば幸いです。

現在、ファインディでは一緒に働くメンバーを募集中です。

ご興味がある方は↓こちらからご応募ください。 herp.careers