DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

GitHub Actionsのセルフホストランナーでジョブごとにディレクトリを分離する方法

CI/CDマニアの加瀬(@Kesin11)です。先日、DeNA/setup-job-workspace-action をOSSとして公開しました。これは、GitHub Actionsでは本来1つのリポジトリに対して作業用のディレクトリは1つだけになるところを、ジョブごとにディレクトリを分離できるActionです。大規模なリポジトリをセルフホストランナーで扱うというニッチ環境用ですが、当てはまるユースケースではとても便利です。

本記事ではDeNA/setup-job-workspace-actionを作成した経緯や仕組みについて紹介しますが、これに関連する内容も含む発表を2023/03/02開催のDeNA TechCon 2023においてSWETの加瀬とゲーム事業部SREチームの白柳が次の2つをテーマに登壇する予定です。

  • SWETがこれまで注力してきたゲーム開発向けのJenkinsの整備
  • ゲーム事業部にSREチームが誕生するまで

ぜひこちらもご参加頂けると嬉しいです!

techcon2023.dena.dev

JenkinsからGitHub Actionsへ移行する際の課題

DeNAのゲーム開発ではCI/CD基盤として主にJenkinsを利用しており、SWETではゲーム開発にてJenkinsを効果的に活用してもらうための仕組みづくりや運用コストの省力化に取り組んできました。一方でここ数年知名度が急上昇しているGitHub Actionsにも注目しており、JenkinsからGitHub Actionsに乗り換えることを視野に入れて調査・検討をしています。

検討する中で大きな懸念であったのは、ゲーム開発のような大規模なリポジトリの場合、ビルドキャッシュを次のビルドにおいて利用できないことでビルド時間が多くかかってしまうという問題でした。

一般的なGitHub Actionsの使い方ではGitHubがホストしているランナーのVMを毎回のビルドのタイミングで立ち上げるため、ビルドキャッシュなどを次のビルドに持ち越すためにはactions/cacheなどでキャッシュするというのが基本的な戦略です。
一方で、DeNAではほとんどのチームがGitHub Enterprise Server(GHES)を利用しており、GitHubがホストしているランナーを利用できないためセルフホストランナーを利用することになります。セルフホストランナーでは前回ビルドしたときのディレクトリの状態がそのまま維持されてしまうのですが、actions/checkoutがデフォルトの挙動として git clean -ffdx も実行してくれるためセルフホストランナーであっても基本的には毎回のビルドはほぼクリーンな環境で実行されます1

逆にこの挙動があるため、次回ビルドの効率化のためにビルドキャッシュを残したい場合にはactions/checkoutのオプションにある clean: false を指定することで自動的に git clean -ffdx が実行されないようにする必要があります。JenkinsでUnityのゲームをビルドするパイプラインを作成したときには、Unityが利用しているUPMパッケージやプラットフォーム向けに最適化されたアセットのキャッシュが含まれる Library/ ディレクトリを.gitignoreに追加した上で明示的に git clean -ffd(-x無し)を実行することでこのディレクトリが削除されないように工夫して上手くビルドキャッシュを効かせることを実現していました。

当初はGitHub Actionsでもこの戦略を実行していたのですが、ある日気がついたら Library/ ディレクトリが意図せずに削除されてしまう現象が発生しました。原因を調べたところ、Unityのビルド別のWorkflowが実行された際に Library/ ディレクトリが削除されていたことが分かりました。

GitHub Actionsは1つのリポジトリに対してWorkflow(リポジトリ中の .github/workflows/*.yml)を複数登録可能で、さらに1つのWorkflowの中に複数のJobを登録できるという構造です。しかし実はセルフホストランナー内では1つのリポジトリに対して1つのディレクトリ(ワークスペース)しか持てないディレクトリ構造になっています。この制約により、たとえば次のような状況で意図せずにキャッシュが削除されるということが発生していました。

  1. 手動トリガーによる build.yml でUnityビルドが行われ、ビルドキャッシュが Library/ に残る
  2. 2回目以降の build.yml でUnityのビルドが行われたときは Library/ が存在するためキャッシュが効く
  3. 別のpull-reqが作られたタイミングでlint.yml によるLintのWorkflowが実行される
    • このときにactions/checkoutのデフォルトオプションにより、意図せずに Library/ が削除されてしまう
  4. 3以降のタイミングで初回の手動トリガーによる build.yml のUnityビルドでは Library/ が存在しないためキャッシュが効かない

問題は3の lint.yml の中でactions/checkoutに clean: false のオプションを渡していないことなのですが、GitHub Actionsに慣れた人ほどこのオプションに馴染みがないでしょうからWorkflowを編集する際に忘れないことを徹底してもらうのは無理があります。また今回の例のように異なるWorkflowではなく、同じWorkflow内でも新たなJobを追加した際のactions/checkoutにオプションを忘れてしまえば同じ問題が発生します。そこまで意識を徹底するのは自分でも難しいと思いました。

別の方法として clean: false を諦め、セルフホストランナーでもGHES v3.5から利用可能となったactions/cacheで Library/ をキャッシュさせるという戦略も考えたのですが、Library/ は軽くGB単位の大きさであったことからキャッシュの上限や転送時間を考えると現実的ではないと諦めざるを得ませんでした。ここまでの事情はQualiArtsさんの 【Unity】ゲーム開発の現場でなぜJenkinsが利用され続けるのか で紹介されていたGitHub ActionsよりJenkinsの方がゲーム開発には向いている理由とほぼ同じとなります。

QualiArtsさんの記事にあるように "ジョブごとにワークスペースが維持される" というJenkinsの挙動をGitHub Actionsのセルフホストランナーで再現させる、あるいは別の代替案がないことにはJenkinsから本格的にGitHub Actionsに移行することは難しいと考えました。

シンボリックリンクを用いたワークスペースの分離

そもそも根本的な問題はセルフホストランナーのディレクトリ構造が1リポジトリに対して1ワークスペースしか持てないことです。Jenkinsでは同様の問題が起きなかった理由をディレクトリ構造を比較して説明します。

Jenkinsではまずジョブというリポジトリに紐付かない概念を作成し、ジョブ1つに対して基本は1つのディレクトリ(ワークスペース)が作成されます。したがって次の図のようにjob_AAAjob_BBBという2つのジョブは別々のワークスペースで実行されますので、同じリポジトリrepo_Aを利用していたとしてもGitHub Actionsのように前ビルドで生成されビルドキャッシュとして残しておきたいファイルなどがうっかり消されてしまうという問題は発生しません。さらにいえば、プラグインを利用したりジョブの中でcheckout処理を工夫することでジョブとワークスペースの関係も1:Nにさらに拡張できる柔軟性がありました。

# JenkinsのAgentマシンのディレクトリ構造

$HOME/jenkins-agent/workspace/
├── job_AAA
│   └── repo_Aをcheckoutした中身
├── job_BBB
│   └── repo_Aをcheckoutした中身
└── job_CCC
    └── repo_Bをcheckoutした中身

一方、GitHub Actionsのセルフホストランナー内のディレクトリ構造はこのようになっています。

# GitHub Actionsセルフホストランナーのマシンのディレクトリ構造

$HOME/actions-runner/_work/
├── repo_A
│   └── repo_A
│       └── repo_Aをcheckoutした中身
└── repo_B
    └── repo_B
        └── repo_Bをcheckoutした中身

repo_A はリポジトリの名前の文字列で、なぜか2回同じディレクトリ名でネストされていました2

$HOME/actions-runner/_work/repo_A/repo_A までのパスはGitHub Actionsの実行中に環境変数の$GITHUB_WORKSPACEとして参照可能であり、すべてのactionはこのパスを起点に動作します。たとえばactions/checkoutを実行するとこのディレクトリの中に.gitやその他のファイルが生成されます。色々なドキュメントやランナーのオプションなども調べたのですが $GITHUB_WORKSPACE 自体のパスを変更する方法は発見できませんでした。

しかし逆にいえば $GITHUB_WORKSPACE は必ずこのパス構造になっていると思われるので、シンボリックリンクという古典的な方法で擬似的にディレクトリを変更するという方法をひらめきました。コマンドとしては次のようになります。

# 以下2つの環境変数はジョブ実行中にランナーに自動的にセットされているもの
# GITHUB_WORKSPACE = _work/repo_A/repo_A までの絶対パス
# RUNNER_WORKSPACE = _work/repo_A までの絶対パス

# デフォルトのワークスペースを.bakという名前で一時的に退避
mv ${GITHUB_WORKSPACE} ${GITHUB_WORKSPACE}.bak

# 差し替える予定のディレクトリを WorkflowとJob のペアでユニークなるような名前で作成
TMP_DIR="${RUNNER_WORKSPACE}/${WorkflowのYAMLのファイル名}-${Job}"
mkdir -p ${TMP_DIR}

# 作成したディレクトリをGitHub Actionsの元々のディレクトリの位置にシンボリックリンクを作成
ln -s "${TMP_DIR}" ${GITHUB_WORKSPACE}

この時点でのディレクトリ構造はこのようになります。本来ワークスペースとして使用されるはずの repo_A ディレクトリのパスを新たに作成した new-workspace ディレクトリへのシンボリックリンクで置き換えます。

# リポジトリ名 = repo_A
# {WorkflowのYAMLのファイル名}-{Job名} = new-workspaceとした場合

$HOME/actions-runner/_work/
└── repo_A
    ├── new-workspace
    ├── repo_A -> $HOME/actions-runner/_work/repo_A/new-workspace へのシンボリックリンク
    └── repo_A.bak

このままではシンボリックリンクで置き換えた repo_AGITHUB_WORKSPACE) が次のJobでも使われてしまうので、Jobが完了したタイミングで次のコマンドによって元に戻す必要もあります。

# シンボリックリンクを削除
unlink ${GITHUB_WORKSPACE}

# .bakに退避させていたデフォルトのワークスペースを元々のパスに戻す
mv ${GITHUB_WORKSPACE}.bak ${GITHUB_WORKSPACE}

この一連のコマンドをJobの開始時と終了時に実行できれば、若干無理やりではありますが元々のGitHub Actionsのリポジトリとワークスペースが1:1である制約を突破して1:Nに拡張できると考えました。

actionとして実装(DeNA/setup-job-workspace-action

bashコマンドはすでに用意できているのでcomposite actionsで実装しようと考えたのですが、Job終了時に.bakを元に戻す一連のコマンドが確実に実行される必要があるため、Jobの終了時に確実に実行されるpost処理を利用できるJavaScript actionとして実装しました。JavaScriptで実装したといってもNode.jsには元々ファイル操作系のAPIが用意されているため、ほとんど先述の一連のコマンドを愚直に置き換えただけのシンプルな実装になっています。

このシンボリックリンクを用いた方法で実装したactionの特に優れている点は、このactionを使わない場合は元々のセルフホストランナーのワークスペースの構造が維持されるのでこのactionを利用してディレクトリを分けたいJobとそうでないJobが共存できる点です。実は別の手段として最近セルフホストランナーに追加されたjob hook scriptも検討していたのですが、こちらで実現しようとした場合はすべてのJobで一律にディレクトリを分けることを強制させる実装になったと思います。actionとして提供することで、ワークスペースを分けたい場合のみ明示的にYAMLで呼び出すようにユーザー側でコントロールできる方がシンプルで分かりやすいと今では思っています。

DeNA/setup-job-workspace-actionの使い方

基本(オプション無し)

setup-job-workspace-actionは先述の仕組みでディレクトリを作成してシンボリックリンクなどを追加するため、actions/checkoutよりも前に実行されないと意味がありません。必ず先に呼ばれるように順番に気をつけてください。

jobs:
  default:
    runs-on: [self-hosted]
    steps:
      # 必ずactions/checkoutより前にsetup-job-workspace-actionを追加してください
      - uses: DeNA/setup-job-workspace-action@v2

      - uses: actions/checkout@v3

      # ... 通常のビルド処理のステップを記述

いずれのオプションも指定しないデフォルトの挙動では {WorkflowのYAMLのファイル名}-{Job名} という名前のディレクトリが作成されてここが新たなワークスペースとなります。WorkflowとJobのペアでユニークなディレクトリ名ですので、Jobごとに独立したワークスペースが与えられていたJenkinsに近いディレクトリ構造となります。

GHESのバージョンが多少古い場合のデフォルト挙動の違い

具体的にはセルフホストランナーのactions/runner@2.300.0未満の場合なのですが、作成されるディレクトリの名前が {Workflow名}-{Job名} と若干変わります。これはYAMLのファイル名を特定するための GITHUB_WORKFLOW_REF という環境変数が用意されるようになったのがactions/runner@2.300.0からであるためです。セルフホストランナーのバージョンによってディレクトリ名が変化することを回避したい場合は、後述の workspace-name オプションで任意のディレクトリ名で固定してください。

prefix, suffix

jobs:
  with_prefix_and_suffix:
    runs-on: [self-hosted]
    steps:
      - uses: DeNA/setup-job-workspace-action@v2
        with:
          prefix: "foo-"
          suffix: "-bar"
      - uses: actions/checkout@v3

prefix, suffixのオプションを指定するとディレクトリ名の前後に特定の文字列を追加可能です。仮にこのYAMLのファイル名がbuild.ymlの場合は先述のデフォルト名の挙動と合わせて、foo-build-with_prefix_and_suffix-bar というディレクトリ名になります。
このオプションが想定している用途はWorkspace名やJob名に加えて追加のパラメーターによってワークスペースの使い分けをさらに細分化したいケースです。たとえば workflow_dispatch で手動トリガーした際の何らかのパラメーターの値を context.inputs で取得してそれを prefix に与えることで、パラメーターに応じてワークスペースを細かく切り替えることが可能になります。

workspace-name

jobs:
  given_dir_name:
    runs-on: [self-hosted]
    steps:
      - uses: DeNA/setup-job-workspace-action@v2
        with:
          workspace-name: foo_bar_workspace
      - uses: actions/checkout@v3

workspace-name を指定すると作成されるディレクトリの名前を完全にコントロール可能です。この例では foo_bar_workspace というディレクトリ名になります。
このオプションが想定している用途はデフォルトよりも荒い粒度としてWorkspace名だけでワークスペースを分けたい、あるいは逆にさまざまな変数などを駆使してフルカスタムしたい場合です。たとえばactions/github-scriptなどと組み合わせることで、jsを駆使して動的に組み立てた文字列を渡すといったことも可能です。次の例では手動トリガーの場合のみブランチ名が含まれるディレクトリ名を動的に生成しています。

jobs:
  given_dir_name_dynamically:
    runs-on: [self-hosted]
    steps:
      - uses: actions/github-script@v6
        id: set-workspace-name
        with:
          result-encoding: string
          script: |
            const branch = context.ref.split('/').slice(2).join('/')
            return (context.eventName === "workflow_dispatch")
              ? `manual_trigger_${branch}`
              : "default"
      - uses: DeNA/setup-job-workspace-action@v2
        with:
          workspace-name: ${{ steps.set-workspace-name.outputs.result }}
      - uses: actions/checkout@v3

DeNA/setup-job-workspace-actionを利用するメリット・デメリット

最後に自分が考えているDeNA/setup-job-workspace-actionのメリットと、デメリットについて紹介しておきます。

メリット

  • Jenkins時代と同様にジョブ単位でディレクトリが分かれるため、ビルドキャッシュを活かしやすい
    • Unityのビルドキャッシュとして重要な Library/ ディレクトリを確実に維持できる
  • リリース候補となるビルドの作成時など、完全にクリーンな環境からビルドする場合に開発用ビルドのためのキャッシュを削除してしまうことがなくなる
    • 特殊な事情があるジョブは workspace-name でディレクトリ名を固定するなどの工夫で他のジョブのワークスペースとは完全に隔離が可能
  • このactionsを使わないジョブでは元々の挙動が維持されるため、GitHub Actionsに詳しくない人にも負担を強いることがない

デメリット

まとめ

JenkinsからGitHub Actionsに移行するにあたって障壁のひとつであったワークスペースの分離をGitHub Actionsでも可能にするDeNA/setup-job-workspace-actionを紹介しました。

特にゲーム開発のような大規模なリポジトリでJenkinsからGitHub Actions + セルフホストランナーの構成に移行する場合には便利なはずです。実際にDeNA内で導入してくれたチームから嬉しいコメントをもらいました!

  • キャッシュ管理がかなり楽になりました
  • UnityのLibraryディレクトリが毎回消されなくなったので、Unityのテストがめっちゃ早く終わるようになりました

最後に宣伝ですが、2023/03/02のDeNA TechCon 2023で「ゲーム開発のJenkins整備から横断SREチームが誕生するまで」という発表をいたします。自分からはSWETグループによるこれまでのJenkinsについての取り組みを、ゲーム事業部SREチームの白柳からSREチーム誕生までの経緯とその取り組み、そして最後にゲーム開発においてGitHub Actionsを利用し始めたという内容を紹介いたします。

techcon2023.dena.dev

本記事の内容に興味を持った方には面白い発表だと思いますのでぜひ当日DeNA TechCon 2023にお越しください!


  1. -ffd のオプションによりgit管理されているファイル以外は削除され、さらに通常は削除されない.gitignoreで定義されているファイルも-xのオプションにより削除されます。
  2. 推測としては、今回の動作確認で使用したOrganizationレベルのセルフホストランナーであればリポジトリ名だけでユニークであることが保証できるからかもしれません。Enterpriseレベルのセルフホストランナーではもしかしたら {ORG}/repo_A のような構造かもしれませんが、試していないので完全な推測です。
  3. Jenkinsでは一定期間以上ビルドされていないジョブのディレクトリは自動で削除されるなど、ディスク容量の消費を抑える機構が存在しています