STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

STORES では GitHub Actions Self-hosted runner を運用しています

STORES 技術推進本部の@White-Greenです。

この記事では、技術推進本部で管理しているGitHub Actions Self-hosted runnerシステムについて紹介します。

GitHub Actions Self-hosted runnerとは

STORESでは、多くのプロジェクトのCI/CD環境にGitHub Actionsを利用しています。GitHub Actionsは通常GitHubが管理しているMicrosoft Azure上のマシンで動作するものですが、自分たちで管理している好きなマシン上で動作させることもできます。これをSelf-hosted runnerといいます。詳細はGitHubのドキュメントをご参照ください。

https://docs.github.com/ja/actions/concepts/runners/self-hosted-runners

STORESのSelf-hosted runner構成

STORESでは、AWS EC2上でSelf-hosted runnerを動作させています。GitHub AppのWebhookを起点にAWS LambdaでGitHub Actions jobの開始や終了を検知、DynamoDBに保存している情報を合わせて必要ランナー台数を計算、ランナーのスペック毎に用意したAutoScalingGroupでEC2インスタンスを増減させています。 大まかな構成はこんな感じですが、Self-hosted runnerとしての動作に必要なトークンの処理を別のLambdaで行っていたりなど実際はもう少し構成要素の多いシステムになっています。

flowchart LR
  GH[GitHub] --Webhook<br/>(queued/completed)--> L[Lambda]
  subgraph AWS
    L --ジョブ/ランナーの情報管理--> D[DynamoDB]
    L --Capacity更新--> ASG1[EC2 Auto Scaling Group 1]
    ASG1 --起動/終了--> EC21[EC2<br/>Self-hosted runners]
    L --Capacity更新--> ASG2[EC2 Auto Scaling Group 2]
    ASG2 --起動/終了--> EC22[EC2<br/>Self-hosted runners]
  end

Self-hosted runnerのメリット

わざわざ専用システムを構築して運用しているSelf-hosted runnerですが、これによって主にCIの速度面でのメリットを得ています。その事例を2つ紹介します。

事例1

STORES内で最も大きなRuby on Railsアプリケーションがあります。rspecを用いたテストを行っていますが、specの数も多いためテストの実行方針にはずっと苦労しており、Self-hosted runner以前はubuntu-latest(2コアCPU 7GBメモリ)のランナーを16台、それぞれのランナー内でparallel_testsを使って2プロセス起動する2段階の並列化により計32並列でテストを実行していました。当時のworkflow定義はこんな感じです。本質的に必要な記述のみを抜き出したつもりですが、これでもかなり大規模な記述が必要になっています。

name: rspec
on: push

jobs:
  rspec:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        # 1回のテストのためにubuntu-latestマシンを16台同時に起動する
        index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
    steps:
      # セットアップなど(省略)

      # 以前に実行した時の実行時間情報をダウンロードしてくる
      - uses: dawidd6/action-download-artifact@v9
        with:
          branch: ${{ github.event.repository.default_branch }}
          name: junit-xml-reports-\d+
          name_is_regexp: true
          path: tmp/junit-xml-reports-downloaded
        continue-on-error: true

      # 以前の実行時間情報を考慮してテストを分割、今のランナーが実行すべきテストを見つける
      - name: split-test
        id: split-tests
        run: |
          curl -L --output /tmp/split-test https://github.com/mtsmfm/split-test/releases/download/v1.1.0/split-test-x86_64-unknown-linux-gnu
          chmod +x /tmp/split-test
          PATHS=$(
            /tmp/split-test \
              --junit-xml-report-dir "${SPLIT_TEST_REPORTS}" \
              --node-index "${SPLIT_TEST_INDEX}" \
              --node-total "${SPLIT_TEST_TOTAL}" \
              --tests-glob "${SPLIT_TEST_GLOB}" |
              sed "s;^$(pwd)/;;" |
              tr '\n' ' '
          )
          echo "paths=${PATHS}"
          echo "paths=${PATHS}" >> $GITHUB_OUTPUT
        env:
          SPLIT_TEST_REPORTS: tmp/junit-xml-reports-downloaded
          SPLIT_TEST_GLOB: spec/**/*_spec.rb
          SPLIT_TEST_INDEX: ${{ matrix.index }}
          SPLIT_TEST_TOTAL: 16

      # 求めたテストを2プロセス並列で実行
      - name: Execute Rspec
        run : |
          bundle exec parallel_rspec \
            --first-is-1 \
            -- \
            --no-fail-fast \
            --format RspecJunitFormatter \
            --out tmp/junit-xml-reports/junit-xml-report-${{ matrix.index }}-\$TEST_ENV_NUMBER.xml \
            -- \
            ${{ steps.split-tests.outputs.paths }}
            
      # 実行時間情報をあとで使えるようにアップロード
      - uses: actions/upload-artifact@v4
        with:
          name: junit-xml-reports-${{ matrix.index }}
          path: tmp/junit-xml-reports

また、テスト実行用のランナー16台で実行されるテストの実行時間はある程度均されるようになっていますが、単一ランナー内で実行される2プロセスの実行時間は不均一な状態で妥協していました。このときのworkflow全体の実行時間は15分程です。

同じアプリケーションのテストをSelf-hosted runnerでの実行に差し替えた現行のworkflow定義がこんな感じです。大幅に簡素化したことがわかると思います。

name: rspec (Self-hosted)
on: push

jobs:
  rspec:
    runs-on: [self-hosted, x64-192c384gb, ubuntu22]
    steps:
      # セットアップなど(省略)

      # テストの実行時間情報をactions/cacheで管理
      - name: Cache rspec timing log
        uses: actions/cache@v4
        with:
          path: runtime.log
          key: rspec-runtime-log-${{ github.sha }}
          restore-keys: rspec-runtime-log-

      - name: Execute Rspec
        run : |
          bundle exec parallel_rspec \
            --first-is-1 \
            --runtime-log runtime.log \
            -- \
            --no-fail-fast \
            --format ParallelTests::RSpec::RuntimeLogger --out runtime.log \
            -- \
            spec

runs-onがubuntu-latestから[self-hosted, x64-192c384gb, ubuntu22]に差し替わっています。複数性能のマシンをランナーとして提供する都合上マシンスペックとOSバージョンを組み合わせたタグを指定する方式になっていて、この場合はx86_64アーキテクチャ192コアのCPU、384GBメモリのマシン(c7i.48xlargeのEC2インスタンス)でUbuntu 22.04が動いているランナー上でテストを実行するという指定になります(GitHub-hostedのlarger runnerは最大96コアです)。これによりテスト実行時間情報の管理などがシンプルに済むようになりworkflowが簡素化されたほか、テスト本体が192プロセス並列で実行されるようになったことでテストの実行時間も9分程まで削減されました。

事例2

事例1とは別のRuby on Railsアプリケーションです。こちらは比較的小規模なものなのでubuntu-latest1台の環境下でparallel_testsを使って2並列でテストを行っていました。そのときの実行時間は7分程です。このテストではubuntu-latestにインストールされていないソフトウェアが必要なのでworkflow内でsudo apt-get update sudo apt-get install ***を毎回実行しており、所用時間を1分程伸ばす要因になっていました。そこで、Self-hosted runnerの動作するUbuntu環境AMIに当該ソフトウェアをあらかじめインストールしておくことによりインストール時間を削減、ついでに16コアのマシンを使うようにすることで所要時間を4分程まで縮められました。

まとめ

この記事では、STORESで利用しているGitHub Actions Self-hosted runnerシステムと、それによって得られたCI環境の改善事例について紹介しました。どちらの事例でもworkflowをGitリポジトリ内で管理するという大枠の仕組みは変わっていないため、CI workflowのメンテナンスしやすさを保ったまま速度面のメリットを享受できています。開発においてCIの速度に課題を感じている方、Self-hosted runnerも一度検討してみてはいかがでしょうか。