UnityプロジェクトのGitHub Actions実行を高速化する事例紹介
はじめに
株式会社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へのパス