コンテンツへスキップ

QualiArtsengineer blog

UnityプロジェクトのGitHub Actions実行を高速化する事例紹介

UnityプロジェクトのGitHub Actions実行を高速化する事例紹介

10 min read

はじめに

株式会社QualiArtsの篠木です。 Unityエンジニアとして中途入社し、現在は開発推進室という主にゲーム開発のための基盤を開発する組織に所属しています。

この記事ではGitHub Actionsのself-hosted runner環境で、Unityを使用したActionsの高速化を行なった事例を紹介します。 GitHub Actionsやself-hosted runnerに関しては、UnityプロジェクトにおけるGitHub Actions活用 を読んで頂くと理解が深まると思います。

※この記事内に出てくる処理はMac PC上で動作する事を想定しています

大まかな仕組み

Unityを使用したActionsで共通で時間が掛かる部分がLibraryの生成になります。 このLibraryの生成をActions間で共有する事で、最小限の時間でLibraryの生成を行えるようにし、Unityを使用したActionsの高速化を行なっています 。

Libraryの共有方法は特定ディレクトリにキャッシュしたLibraryをシンボリックリンクでActionsのワークディレクトリへ繋げる事で行なっています。 この機能はComposite Actionとして作成されており、Unityを使用するActionsのUnity実行前に呼び出して使用する形になっています。

キャッシュは次の三点が同一のものが共有されるようになっています。

  • GitHubリポジトリ
  • Unityを実行する際のプラットフォームターゲット
  • Unityのバンドルバージョン(アプリバージョン)

キャッシュが存在しない場合は呼び出されたアクションで作成されたLibraryがそのままキャッシュされ、以降はそのキャッシュが使用されるようになっています。

次の画像がこの機能の仕組みの大まかな図になります。

大まかな図

ポスト処理

ActionsはGitHubリポジトリ毎に同じディレクトリで実行されるため、Libraryを共有するアクションとしてないアクションが混在している場合、シンボリックリンクをそのままにしておくと、間違ったLibraryキャッシュが作成されてしまう可能性があります。 この問題の解決のためにはアクション実行終了時にシンボリックリンクを削除する必要があります。

GitHub Actionsにはアクション終了時に処理を行うポスト機能がありますが、Composite Actionは現状未対応になっているため、Composite Actionから更に別のアクションを呼び出し、ポスト処理を実現しています。 このポスト処理内でシンボリックリンクの削除を行なっています。

Composite Actionsでポスト処理を行う方法は次の記事を参考にさせて頂いています。もし、Composite Actionでポスト処理を行いたい方がいましたら、参考になると思いますので是非ご覧ください。

https://srz-zumix.blogspot.com/2022/08/github-actions-composite-action-post.html

次のコードがポスト処理周りのコードになります。

  • 本体のアクションYAML
name: Unity Library Cache
description: UnityのLibraryフォルダをキャッシュし、共通化する
inputs:
################## 省略 ##################

runs:
  using: "composite"
  steps:
    - name: Link unity cache
################## 省略 ##################

    - name: Post process
      if: always()
      uses: # ポスト処理用のアクションYAMLへのパス
  • ポスト処理用のアクションYAML
name: Unity Library Cache Post Process
description: Unity Library Cache の Post処理

runs:
  using: node16
  main: 'dist/index.js'
  post: 'dist/post-actions.js'
  • post-actions.js の内容

※実際に使用しているのは下記コードを一つのファイルへまとめる処理を行なったものになります

const core = require('@actions/core')
const exec = require('@actions/exec')

async function post() {
    try {
        await exec.exec('echo', [ '削除を開始します' ])
        
        // 環境変数で指定されているファイルを削除しています
        // UNITY_LIBRARY_CACHE_PROJECT_LIBRARY_PATH はLibraryのシンボリックリンク
        // UNITY_LIBRARY_CACHE_META_PATH は後述しているロックファイルを削除しています
        await exec.exec('rm', [ process.env['UNITY_LIBRARY_CACHE_PROJECT_LIBRARY_PATH'] ])
        await exec.exec('rm', [ process.env['UNITY_LIBRARY_CACHE_META_PATH'] ])
    } catch (error) {
        core.warning(error.message)
    }
}

post()

ロック処理

Libraryが二つのアクションで同時に使用されるとLibraryが壊れてしまうため、Libraryのロック処理を行なっています。 ロック方法はロック用のファイルを作成し、それが存在すればロックされているとしています。 ロックファイルは前項のポスト処理で削除を行い、ロック解除をしています。 アクションが使用するLibraryがロックされていた場合、ロック解除を待ちます。一定時間経過し、ロックが解除されていない場合は異常自体が起きたと判断し、実行を諦め、アクションを失敗としています。

ロックファイルの中には次の情報を書き込んでいます。

  • 実行されたアクションのID(github.run_id)
  • 実行しているself-hosted runnerのプロセスID

「実行されたアクションのID」は何かしら問題が起きた際にどのアクションで問題が起きたのか特定するために書き込んでいます。 「実行しているself-hosted runnerのプロセスID」は何かしらが原因でself-hosted runner自体が強制終了をしてしまった場合、ポスト処理が行われないため、ロックを解除できるように書き込んでいます。 次にロックされているLibraryを使用するアクションがプロセスIDを検索し、実行中のプロセスに該当のIDがなかった場合はロックを解除し、Libraryを使用するようになっています。

次のコードがロック処理周りのコードになります。

  • self-hosted runnerのプロセスIDを取得するコード
function get_runner_pid() {
  pid=$1 # 引数は環境変数の「$PPID」が指定されています
  counter=0
  while [ $((${pid})) -ne 1 ]
  do
    pid=$(ps -o ppid= -p ${pid})
    if [ -z "$pid" ]; then
      break
    fi
    
    result=$(ps -e | grep ${pid} | awk '{ print $4 }')
    if [[ $result =~ .*/self-hosted-runners/.* ]]; then
      echo $pid
      break
    fi
    
    counter=$(($counter+1))
    
    # プロセスIDを探すのは5階層までにしています
    if [ $(($counter)) -ge 5 ]; then
      break
    fi
  done
}

並列処理

使用するLibraryが同時に何個も使用される想定がある場合は、アクションの引数で並列数を指定できるようにしています。 指定された並列数分、Libraryキャッシュを作成し、同時に複数個のアクションが実行できるようにしています。 並列処理の仕組みは単純で、使用しようとしたLibraryがロックされていた場合、サフィックスを付けた別ディレクトリを順に見ていき、並列数内でロックされていないLibraryがあればそれを使用する形になっています。

次のコードが並列処理周りのコードになります。

  • 並列処理&ロック処理を行なっているコード
# Library保存先のホームディレクトリのパスになります。
# 「大まかな仕組み」の項に出てきた要素により変更される部分になります
cache_home="${cache_root}/${repository}/${project}/${target}/${version}"

# フォルダ更新ロック
interval=10
total_interval=0
library_number=0
while [ true ]
do
  # parallel_num は指定された並列数になります。このアクションの引数でユーザーが指定できます
  for i in `seq 1 ${parallel_num}`
  do
    # meta_name はロックファイル名になります
    temp_path="${cache_home}/${i}/${meta_name}"
    if [ ! -e  ${temp_path} ]; then
      library_number=${i}
      break
    fi

    pid=$(sed -n 2P ${temp_path})
    if [ -z "$pid" ]; then
      # 取得したPIDが空
      continue
    fi

    runner_pid=`get_runner_pid ${pid}`
    if [ -z "$runner_pid" ]; then
      file_id=$(sed -n 1P ${temp_path})
      echo ".metaファイルに指定されたPIDのRunnerは見つからないため、削除します : ${pid}(PID), ${file_id}(JobID)"
      rm ${temp_path}

      library_number=${i}
      break
    fi
  done

  if [ ${library_number} -ne 0 ]; then
    break
  fi

  sleep ${interval}
  total_interval=$((total_interval + interval))
  # inputs.lock-wait-timeout は指定されたロック解除の待機時間になります。このアクションの引数でユーザーが指定できます
  if [ $total_interval -ge ${{ inputs.lock-wait-timeout }} ]; then
    echo "${cache_home} (並列数:${parallel_num}) 内の ${meta_name}${total_interval} 秒待機しても削除されなかったため、失敗しました"
    exit 2
  fi

  echo ".meta ファイルが削除されるまで待機中 : ${total_interval}"
done

キャッシュ監視処理

キャッシュしているLibraryの中にはアプリのバージョンアップ等で使用しなくなったLibraryも出てきます。 そういったキャッシュが残りつづけているとself-hosted runnerが動作しているPCのストレージを圧迫してしまいます。 その対策のため、一定期間使用していないLibraryを削除する処理を作成しています。 この処理はGitHub Actionsのschedule機能で定期的に実行しています。

次のコードが実際の監視処理に使用しているコードになります。

name: Unity Library Cache Check

on:
  schedule:
    - cron: '0 1 * * *'
  workflow_dispatch:

jobs:
  build:
    strategy:
      matrix:
        # self-hosted runnerが動作するPCが複数あるので、matrix機能で同時に実行しています
        pc: [[self-hosted runnerのPCを指定], [self-hosted runnerのPCを指定]] 
    runs-on: ${{ matrix.pc }}
    env:
      CACHE_ROOT: "キャッシュのルートディレクトリ" # この定数は unity-library-cache と同じにする必要があります
    steps:
      - name: Check directory
        id: check-directory
        run: |
          now=$(date "+%s")
          
          # 2ヶ月間使用されていなかったら削除
          threshold=$((3600*24*60))
          for dir in $(ls -d ${{ env.CACHE_ROOT }}/*/*/*/*/*); do
            latest_update=$(find "$dir" -type f | sort -r | { head -1; cat >/dev/null; } | (read -r i; date -r "$i" "+%s"))
            diff=$(($now - $latest_update))
          
            # 比較
            if [[ $diff -gt $threshold ]]; then
              # 削除処理
              echo "削除 : ${dir}"
              rm -rf ${dir}
            fi
          done
        shell: bash

最後に

以上がGitHub Actionsのself-hosted runner環境で、Unityを使用したActionsの高速化を行なった際に書いた処理になります。 この機能はシェルスクリプトで書かれていますが、self-hosted runnerは固定環境のため、好きな言語を選択できます。 もし、似たような処理を書かれる方がいましたら、メンテナンス性の観点から別の言語を選択するのをおすすめします(私も別言語への移行を検討中です)。

最後に今回のアクションの全体コードを貼っておきます。この記事を読んだ方の何かしらの参考になれば嬉しいです。 最後までご覧頂き、ありがとうございます。

  • 今回紹介したアクションの全体コード
name: Unity Library Cache
description: unityのLibraryフォルダをキャッシュし、共通化する
inputs:
  target-platform:
    description: Unity の ビルドターゲット(Android, iOS 等)
    required: true
  project-path:
    description: ジョブルートからUnityプロジェクトのルートディレクトリへのパス
    required: false
  lock-wait-timeout:
    description: Libraryのロックを待機する時間
    required: false
    default: 600
  cache-parallel-num:
    description: 同じLibraryの並列数
    required: false
    default: 1

runs:
  using: "composite"
  steps:
    - name: Link unity cache
      run: |
        function get_runner_pid() {
          pid=$1
          counter=0
          while [ $((${pid})) -ne 1 ]
          do
            pid=$(ps -o ppid= -p ${pid})
            if [ -z "$pid" ]; then
              break
            fi
            
            result=$(ps -e | grep ${pid} | awk '{ print $4 }')
            if [[ $result =~ .*/self-hosted-runners/.* ]]; then
              echo $pid
              break
            fi
            
            counter=$(($counter+1))
            if [ $(($counter)) -ge 5 ]; then
              break
            fi
          done
        }
        
        project=${{ inputs.project-path }}
        project=${project%/}
        target=${{ inputs.target-platform }}
        target=${target%/}
        
        repository=${{ github.repository }}
        repository=${repository%/}
        github_run_id=${{ github.run_id }}
        parallel_num=${{ inputs.cache-parallel-num }}
        
        cache_root="キャッシュするディレクトリを指定"
        library_name="Library"
        meta_name=".meta"
        
        # プロジェクトパスが空なら、「.」を指定する
        if [ -z "$project" ]; then
          project=.
        fi
      
        # アプリバージョン取得
        version=$(sed '/bundleVersion: .*/!d' ${project}/ProjectSettings/ProjectSettings.asset | awk '{ print $2 }')
      
        echo "アプリバージョン : ${version}"
        
        if [ -z "${version}" ]; then
          echo "アプリバージョンが取得できませんでした"
          exit 1
        fi

        # Library保存先のホームディレクトリ
        cache_home="${cache_root}/${repository}/${project}/${target}/${version}"
        
        # フォルダ更新ロック
        interval=10
        total_interval=0
        library_number=0
        while [ true ]
        do
          for i in `seq 1 ${parallel_num}`
          do
            temp_path="${cache_home}/${i}/${meta_name}"
            if [ ! -e  ${temp_path} ]; then
              library_number=${i}
              break
            fi
        
            pid=$(sed -n 2P ${temp_path})
            if [ -z "$pid" ]; then
              # 取得したPIDが空
              continue
            fi
        
            runner_pid=`get_runner_pid ${pid}`
            if [ -z "$runner_pid" ]; then
              file_id=$(sed -n 1P ${temp_path})
              echo ".metaファイルに指定されたPIDのRunnerは見つからないため、削除します : ${pid}(PID), ${file_id}(JobID)"
              rm ${temp_path}
        
              library_number=${i}
              break
            fi
          done
        
          if [ ${library_number} -ne 0 ]; then
            break
          fi
        
          sleep ${interval}
          total_interval=$((total_interval + interval))
          if [ $total_interval -ge ${{ inputs.lock-wait-timeout }} ]; then
            echo "${cache_home} (並列数:${parallel_num}) 内の ${meta_name} が ${total_interval} 秒待機しても削除されなかったため、失敗しました"
            exit 2
          fi

          echo ".meta ファイルが削除されるまで待機中 : ${total_interval}"
        done
          
        # キャッシュパス取得
        cache_path="${cache_home}/${library_number}/${library_name}"
        meta_path="${cache_home}/${library_number}/${meta_name}"
        
        echo "キャッシュパス : ${cache_path}"
        echo "metaパス : ${meta_path}"

        # Libraryディレクトリ作成
        mkdir -pv "${cache_path}"
        
        # metaファイル作成
        echo ${github_run_id} > "${meta_path}"
        runner_pid=`get_runner_pid $PPID`
        echo ${runner_pid} >> "${meta_path}"
        
        sleep 1
        file_id=$(sed -n 1P ${meta_path})
        if [ ${file_id} != ${github_run_id} ]; then
          echo "GitHubRunId(${github_run_id})と.metaのID(${file_id})が不一致だったため失敗しました。ほぼ同時にLibraryにアクセスしようとした可能性があります"
          exit 3
        fi
        
        # 自身の.metaファイルの作成が確定した段階で、変数に入れることにより、後の削除処理時に自分以外の.metaを削除する可能性をなくす
        echo "UNITY_LIBRARY_CACHE_META_PATH=${meta_path}" >> $GITHUB_ENV
        
        # シンボリックリンク作成
        project_library="${project}/${library_name}"
        echo "UNITY_LIBRARY_CACHE_PROJECT_LIBRARY_PATH=${project_library}" >> $GITHUB_ENV
        
        rm -rf ${project_library}
        ln -sf "${cache_path}" "${project_library}"
      shell: bash
    
    - name: Post process
      if: always()
      uses: # ポスト処理用のアクションYAMLへのパス

2017年にQualiArtsにUnityエンジニアとして中途入社。現在は開発推進室に所属し、複数のプロジェクトで使用する基盤開発に携わる。