Attempt #2
並列ビルドと🏃のイメージバージョン
ビルドを早く終わらせるために、並列にビルドするのは常套手段だ。
GitHub Actionsでもワークフロー中に複数のジョブを並列に実行することが可能である。
鍵はneeds
で、次のように用いる:
jobs:
job1:
⋮
job2:
⋮
job3:
needs: [job1, job2]
⋮
job3
はjob1
とjob2
が終了すると開始する。job1
、job2
、job3
はそれぞれ異なるランナーのインスタンスで実行するので、job1
とjob2
はビルド結果をactions/upload-artifact
でそれぞれ保存して、job3
はactions/download-artifact
でそれらを取得するようにする。これが典型的な並列ジョブの実行方法だ。
iOS向けライブラリの並列ビルド
より具体的な例を紹介しよう。次のワークフローはAES 128ビットCBCモードを復号するライブラリをiOS向けにxcframeworkとしてビルドするものだ:
jobs:
build_non_fat:
runs-on: macos-latest
timeout-minutes: 30
strategy:
max-parallel: 3
matrix:
abi: [x86_64, arm64]
sdk: [iphoneos, iphonesimulator]
exclude:
- abi: x86_64
sdk: iphoneos
steps:
⋮
install_and_test:
runs-on: macos-latest
timeout-minutes: 30
needs: build_non_fat
steps:
⋮
build_non_fat
の全てのジョブが終わると、install_and_test
ジョブが開始する。ワークフローを可視化すると次のようになる:
最初のbuild_non_fat
ジョブは次の構成†1で並列に三つのジョブを実行する:
ABI | SDK |
---|---|
x86_64 |
iphonesimulator |
arm64 |
iphonesimulator |
arm64 |
iphoneos |
CMakeを用いる場合、iphonesimulator
のSDK向けにx86_64
とarm64
のABIを両方とも含むfatライブラリを一度に作成することもできる。その場合はジョブの数は二つにできるだろう。しかし、このプロジェクトはアセンブラ命令(イントリンシックス)を用いるため、ABIによってソースファイルのセットやコンパイルオプションが異なる。そのため、build_non_fat
ジョブではバラバラにnon fatライブラリをビルドしておく。それぞれのランナーではCMakeでビルドした成果物を含むビルドディレクトリをactions/upload-artifact
で保存する。ビルドディレクトリの名前はABIとSDKを含むので、次のジョブでダウンロードしても衝突しない。
二番目のinstall_and_test
ジョブは成果物であるビルドディレクトリをactions/download-artifact
を用いてすべてダウンロードし、次の処理を逐次実行する:
xcrun
を用いてiOSシミュレータを実行し、ctest
でiphonesimulator
とx86_64
†2のnon fatライブラリのテストを実行lipo
を用いてiphonesimulator
向けのfatライブラリを作成xcodebuild
を用いてxcframeworkの作成- xcframework、
iphoneos
向けnon fatライブラリ、iphonesimulator
向けのfatライブラリ、ヘッダファイルのインストール
やや複雑だが、詳細を理解する必要はいっさいない。重要なのは、これらのビルドはシェルスクリプトと、そこから呼び出されるCMakeで実現しているということだ。CMakeは最初のジョブだけではなく、二番目のジョブでも使用している†3が、二番目のジョブではCMakeはビルドディレクトリを作成しない。最初のジョブが作成したものをダウンロードしたので、それらに対してctest
やcmake --target install
をそれぞれ実行する。
†1
x86_64
とiphoneos
の組み合わせはexclude
で除外している。
†2 IntelアーキテクチャのmacOSランナーを使用しているため。
†3 一旦
cmake --target install
でビルドしたライブラリをテンポラリな場所にインストールして、それに対してlipo
やxcodebuild
を用いるため。
ランナーのイメージの「バージョン」
ところが、このようなワークフローファイルを運用していると、たまにビルドに失敗することがあった。ビルドの失敗は、二番目のジョブでcmake
やctest
を実行した時に起きていた。なぜビルドは失敗したのだろう。
GitHub Actionsのビルドログの最初の部分には、実行しているランナーの情報が記録されている。 例えば、あるビルドログの “Set up job” の項目を展開すると次のようになる:
これを見ると、ランナーのイメージはmacos-12
、バージョンは20231216.1
で、イメージのリリースに関するリンクも表示されている。そのランナーにどのようなソフトウェアがインストールされているのか、どのような変更があったのか、これらのリンクより知ることができる。イメージのリリースにはタグが付与されているので、どれくらいの頻度でイメージが更新されているかを、次のように簡単に知ることができる:

これを見ると、どうやら、およそ1〜2週間で更新があるのが当たり前のようだ。
そして、そのランナーイメージのリポジトリのREADMEには、次のような最新の利用可能なイメージの一覧が記載されている:
表の右端には「Rollout Progress of Latest Image Release」という列がある。この列は最新バージョンのイメージがどれだけの割合で利用されているかを示す。例えばmacos-12
でこれが100%であれば、どのインスタンスも同じ(最新の)バージョンのイメージを用いることになる。実際、上記の表はすべての行で100%なので、そうなっている。
しかし、いつでも必ず100%になっているわけではない。次の表は別の日のものである:
この画像のように、例えばmacos-12
でロールアウト進捗が9.79%だったとすると、インスタンスは90.21%の確率で一つ前の(古い)バージョンのイメージを用いる。そして、イメージのバージョンが異なると、インストールされているコンパイラ、ツールチェイン、SDKなども、更新されて違うバージョンになっている可能性がある。
☕
複数のインスタンスがすべて同じバージョンになる確率はどうだろう。ロールアウト進捗が10%のイメージのランナーで2つのインスタンスの場合、(1 − 0.1)2 + 0.12 = 0.82である。古いバージョン同士と、新しいバージョン同士の確率の和となる。4つのインスタンスの場合、(1 − 0.1)4 + 0.14 = 0.6561 + 0.001 = 0.6562となる。2/3くらいの確率でインスタンスのイメージは全部同じバージョンだ。
ビルドが失敗したのはこれが原因だった。ビルドで走っている複数のインスタンスのうちの一つが古いイメージのものであり、その古いイメージと最新のイメージでは、(運が悪いことに)CMakeのバージョンが異なっていた。古いCMakeで作成したビルドディレクトリを、新しいCMakeでテストやインストールしようして、うまくいかないのは不思議でもなんでもない。
一番の問題は、ロールアウトされているどのランナーのイメージも同一であるに違いない、という自分の思い込みであった。
ランタイムでのバージョンの取得
さて、ワークフローを実行中のイメージのバージョンを取得するにはどうすれば良いのだろうか。調べてみると、文書化されていないようだが、次のように環境変数ImageVersion
から取得するようだ:
といっても、イメージのバージョンを取得できたところで、どうしようもない。「並列に実行するジョブにおいて実行中のイメージのバージョンを保存しておいて、後段のジョブ(前のセクションで説明した、ビルドディレクトリをダウンロードするジョブ)で使用したイメージのバージョンをチェックして、同じでなければ実行を中止する」ことは可能だが、先ほど見たようにイメージは頻繁に更新されているので、その度にCIが止まるのは不便でしかない。そうするとしても、先ほどの例であれば、イメージのバージョンではなく、インストールされているCMakeのバージョンをチェックするほうが良い。
指定のバージョンのCMakeをインストール
CMakeはそれほど巨大なツールでもなく、Windows、Linux、macOSそれぞれにバイナリ配布のリリースもある。イメージにプリインストールされているCMakeを使うのではなく、好きなバージョンのCMakeを自分でインストールしてみるのはどうだろう。
実際、マーケットプレイスにサードパーティ製のCMakeをインストールするためのアクションが公開されている。ワークフローの最初のステップで、次のようにバージョンを指定してCMakeをインストールしてみる:
これで、異なるバージョンのイメージが混ざっていても、エラーは起きなくなった。
しかし、CMake以外にも依存しているツールはある。例えば、Xcodeはどうだろう。Xcodeのような大きなものになってくると、流石にバージョンを指定してインストールはしたくない。現時点のmacos-12
のイメージにインストールされているXcodeは次のようになっている:

例えば、環境変数XCODE_VERSION
の値でXcodeのバージョンを指定するなら、次のようにすればよい:
- name: Select Xcode
run: sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer"
指定したバージョンのXcodeが、イメージの更新で利用できなくなる可能性もあるが、その頻度は年に1回程度だろう(13.1のリリースは2021年10月、14.2のリリースは2022年12月、であることを目安とした)。
ひとつのジョブでCMakeの利用を完結させる
もうひとつ、考え方として押さえておきたいのは、ひとつのツールをインスタンスを跨いで使わないようにすることだ。この例では「CMakeを使うインスタンスはそのインスタンスでCMakeの使用を完結させる」ことを意味する。そのためには、CMakeで作成したビルドディレクトリを、次のジョブのインスタンスに引き継ぐことはやめる。代わりに、最初のジョブで「テストを実行、テンポラリなディレクトリにnon fatライブラリをインストール、それをアップロード」するところまでを同じインスタンスで実行する。そして、最後のジョブで、すべてのnon fatライブラリをダウンロードして、xcframeworkを作成する。ワークフローファイルは次のようなイメージになる:
jobs:
build_non_fat:
runs-on: macos-latest
timeout-minutes: 30
strategy:
max-parallel: 3
matrix:
abi: [x86_64, arm64]
sdk: [iphoneos, iphonesimulator]
exclude:
- abi: x86_64
sdk: iphoneos
steps:
⋮
- name: Run simulator and then test
if: ${{ matrix.sdk == 'iphonesimulator' && matrix.abi == 'x86_64' }}
run: |
⋮
ctest --test-dir build/iphonesimulator-x86_64 -C $BUILD_TYPE
⋮
- name: Install non fat files temporally
run: |
cmake --build ${{matrix.sdk}}-${{matrix.abi}} --target install \
$HOME/tmp ...
- name: Archive artifacts
uses: actions/upload-artifact@v3
with:
name: non-fat-files-${{matrix.sdk}}-${{matrix.abi}}
path: ~/tmp
if-no-files-found: error
install:
runs-on: macos-latest
timeout-minutes: 30
needs: build_non_fat
steps:
⋮
- name: Download artifacts
uses: actions/download-artifact@v3
⋮
- name: Create xcframework
⋮
もちろん、古いコンパイラで作成したライブラリを新しいコンパイラで操作するとエラーになることもあり得るが、分断されるポイントを減らすことで耐性が上がることを意識しておきたい。それが無理なら、並列ビルドはセルフホスティッドランナーでやろう。