Attempt #2
並列ビルドと🏃(ランナー)のイメージバージョン

ビルドを早く終わらせるために、並列にビルドするのは常套手段だ。 GitHub Actionsでもワークフロー中に複数のジョブを並列に実行することが可能である。 鍵はneedsで、次のように用いる:

jobs:
  job1:
    ⋮

  job2:
    ⋮

  job3:
    needs: [job1, job2]
    ⋮

job3job1job2が終了すると開始する。job1job2job3はそれぞれ異なるランナーのインスタンスで実行するので、job1job2はビルド結果をactions/upload-artifactでそれぞれ保存して、job3actions/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_64arm64のABIを両方とも含むfatライブラリを一度に作成することもできる。その場合はジョブの数は二つにできるだろう。しかし、このプロジェクトはアセンブラ命令(イントリンシックス)を用いるため、ABIによってソースファイルのセットやコンパイルオプションが異なる。そのため、build_non_fatジョブではバラバラにnon fatライブラリをビルドしておく。それぞれのランナーではCMakeでビルドした成果物を含むビルドディレクトリactions/upload-artifactで保存する。ビルドディレクトリの名前はABIとSDKを含むので、次のジョブでダウンロードしても衝突しない。

二番目のinstall_and_testジョブは成果物であるビルドディレクトリをactions/download-artifactを用いてすべてダウンロードし、次の処理を逐次実行する:

  • xcrunを用いてiOSシミュレータを実行し、ctestiphonesimulatorx86_64†2のnon fatライブラリのテストを実行
  • lipoを用いてiphonesimulator向けのfatライブラリを作成
  • xcodebuildを用いてxcframeworkの作成
  • xcframework、iphoneos向けnon fatライブラリ、iphonesimulator向けのfatライブラリ、ヘッダファイルのインストール

やや複雑だが、詳細を理解する必要はいっさいない。重要なのは、これらのビルドはシェルスクリプトと、そこから呼び出されるCMakeで実現しているということだ。CMakeは最初のジョブだけではなく、二番目のジョブでも使用している†3が、二番目のジョブではCMakeはビルドディレクトリを作成しない。最初のジョブが作成したものをダウンロードしたので、それらに対してctestcmake --target installをそれぞれ実行する。

†1 x86_64iphoneosの組み合わせはexcludeで除外している。

†2 IntelアーキテクチャのmacOSランナーを使用しているため。

†3 一旦cmake --target installでビルドしたライブラリをテンポラリな場所にインストールして、それに対してlipoxcodebuildを用いるため。

ランナーのイメージの「バージョン」

ところが、このようなワークフローファイルを運用していると、たまにビルドに失敗することがあった。ビルドの失敗は、二番目のジョブでcmakectestを実行した時に起きていた。なぜビルドは失敗したのだろう。

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から取得するようだ:

このワークフローファイルをGitHubで見る

といっても、イメージのバージョンを取得できたところで、どうしようもない。「並列に実行するジョブにおいて実行中のイメージのバージョンを保存しておいて、後段のジョブ(前のセクションで説明した、ビルドディレクトリをダウンロードするジョブ)で使用したイメージのバージョンをチェックして、同じでなければ実行を中止する」ことは可能だが、先ほど見たようにイメージは頻繁に更新されているので、その度に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
      ⋮

もちろん、古いコンパイラで作成したライブラリを新しいコンパイラで操作するとエラーになることもあり得るが、分断されるポイントを減らすことで耐性が上がることを意識しておきたい。それが無理なら、並列ビルドはセルフホスティッドランナーでやろう。