JenkinsからGitHub Actionsへの移行で実現したマルチプラットフォームCIの改善

こんにちは。ソフトウェアエンジニアのすぎしーです。ClientCI WG (Client Continuus Integration Working Group)というclusterのクライアントアプリのCI環境を社内向けに提供するWGのオーナーも務めています。

clusterアプリではWindows版(VR含む)、Mac版、Android版、iOS版、MetaQuest版の5つが現在提供されていて、基本的に週次リリースを実施しているため安定したリリースフローが求められます。また、開発版アプリのビルドから検証までの迅速なイテレーションを提供することも、アプリの機能改善や品質向上において重要なポイントとなっています。

今回はこれらのリリースフローや開発版アプリのビルドに欠かせないクライアントアプリのCIをJenkinsからGitHub Actionsに移行して、どのような改善を実現したかについて紹介します。

用語の紹介

CIを説明するにあたり多用することになる用語について、予め補足させていただきます。

背景: Jenkins時代の課題

2024年初頭までは、すべてのclusterアプリのCIはJenkinsを用いていました。そのJenkinsのワークフロー改修を重ねていくうちにいくつかの課題が発生していました。

本項ではJenkins時代に発生していた課題をいくつかピックアップして紹介します。

masterノードの障害時のリスクが大きかった

Jenkinsではmasterノードを利用者側で構築して運用する必要がありますが、そのJenkinsのmasterノードで以下のリスクが発生していました。ちなみにmasterノードはAWSのEC2で構築していました。

  • masterノードの構築マニュアルが整備されておらず、障害時の復旧方法が確立されていなかった
  • バックアップフローが用意できておらず、masterノードが破損するとJenkins環境の復旧が困難になる恐れがあった
  • masterノードの運用が属人化しており、管理できる人が限られていた

特に復旧方法が確立されていなかった問題は、後にJenkinsが一切稼働しなくなる事件を引き起こすのですが、幸運なことにその時はGitHub Actionsへの移行準備を進めていたおかげで週次リリースへの影響を最小限にできました。復旧フローは必ず確立しておきましょうという戒めですね。

リリース版と開発版で同一ジョブを無理矢理に再利用していた

リリース版と開発版でプラットフォームごとのアプリをビルドするジョブを再利用したくなりますが、Jenkinsでは開始ジョブもリリース版と開発版で共通で再利用していたため、いくつかの弊害が発生していました。

以下はワークフローのイメージで、枠それぞれはJenkinsのジョブを表しています。

弊害1. リリース版と開発版のジョブのキューが同列になっていた

以下はJenkins時代のキューが積まれている状態の画像です。時によってはこの中に優先度の高いリリース版ビルドのキューが積まれている場合も有り、それが開発版のキューに阻まれることが度々ありました。

リリース版のビルドを急ぎたいときは、キューのパラメータから開発版を選定してキャンセルする作業を実施する必要がありました。余談ですが、パラメータが多いとパラメータ表示が点滅するJenkinsのバグもあったので、選定作業が余計に大変でした。

弊害2. ビルド対象外のプラットフォームまでキューに積まれていた

開発版では開始ジョブでビルド対象のプラットフォームを指定できました。しかしJenkinsジョブの組み方の問題で、ビルド対象ではないプラットフォームまでキューに積まれていたため、実際にビルドを必要としているキューが邪魔される状況が多々発生していました。

以下はWindowsとiOSのビルドキューを並べたときのイメージで、非ビルド対象のキューはグレーにしています。

非ビルド対象のキューはgit cloneしてexit 0するのみでしたが、ビルドマシンを専有して終了まで数分かかることもあったので、実際にビルドを必要とするジョブが開始されるまでに結構な時間のロスが発生していました。

ちなみに以下はJenkinsのジョブ画面に表示されていた実行時間の推移ですが、スキップされるだけの実行も集計に含まれてしまっていたため、この様にトゲトゲしたグラフになっていました。

ログ出力がジョブ全体で1つのプレーンテキストに出力されており、失敗原因の調査が大変だった

1つのアプリをビルドするまでは、ビルドコマンド以外にもいくつかのステップを実施すると思いますが、Jenkinsではその全ステップのログが1つのプレーンテキストに出力されていました。

これで一番困るのはジョブが失敗したときで、数万行ある1つのプレーンテキストから失敗原因を探す必要がありました。そのため、ログから探す作業が職人芸みたいになってしまい、一部のエンジニアしか失敗原因を調査できない状態になっていました。

その他の課題

前述したもの以外でもいくつか課題がありました。

  • ワークフロー定義がファイル(コードベース)でリビジョン管理されていなかった
  • Jenkinsジョブ設定がPullRequestなどのレビューを通すことなく即時反映されていた
  • ジョブが連鎖的に後続ジョブをトリガーしていたため、ワークフローの全容の把握が難しかった
  • Jenkins自体のバージョン更新が難しく、またプラグインの追加削除も安易に実施できなかった
  • etc…

細かい課題を挙げるとキリがありませんが、これらのCI環境における課題をできるだけ早く解決できればと考えていました。

GitHub Actionsを選択した理由

ここで、GitHub Actionsを選んだ理由についてお話します。

アプリCIにGitHub Actionsを導入することを提案したのは僕だったのですが、理由としてはclusterにジョインする前から業務とプライベートのどちらでもGitHub Actionsを使用していて、以下が実現できることをわかっていたからです。

  • masterノードが不要で、GitHubのリポジトリのページからワークフローを実行できる
  • ワークフローのレシピをymlで定義して、GitHubでリビジョン管理できる
  • self-hosted runnerにより独自に構築したオンプレミスのマシンも扱える
  • 個別ジョブ単位だけでなくワークフロー全体を単一ymlで定義できる
  • Reusable Workflowsにより部分的なワークフローを可変パラメータ込みで、複数のワークフローで再利用できる
  • ステップごとに成否が表示され、ログも分割され、さらにステップに名前を付けられる
  • サードパーティ製も含めて、様々なActionが利用できる
  • いくつかのGitHub APIが用意されており、オペレーションや集計などに活用できる

また、clusterでは以下のように導入の敷居が下がっていたことも、GitHub Actionsの選択を後押しする要因となりました。

  • GitHub Actions自体はすでに開発フローで利用されていた
  • GitHub Actions自体がオープンなツールであり、知見のある人が複数人いた

これらの要素を踏まえて「Jenkinsでの課題を解決するならGitHub Actionsだ!」と思いdesign docを書いて提案し、無事合意を得られたので移行が決定しました。

余談: GitHub Actions以外で候補になったCIツール

GitHub Actions以外にも候補になったCIツールなどがあったので、参考程度にお話します。

Jenkins + Jenkinsfile

Jenkinsfileというgroovy形式でワークフローを定義してJenkinsで実行する戦略ですね。こちらを見送った理由は主に以下です。

  • Jenkins環境の構築ノウハウを持っているメンバーが限られていた
  • Jenkinsfileはリビジョン管理できても、Jenkinsのmasterノードのリビジョン管理は独自に用意する必要があった

CircleCI

GitHub Actionsと並んで有名なCIサービスですね。CircleCIを見送った理由は、unityのビルド環境がうまく構築できるかの確証がなかったことが大きいです。

GameCI

GitHub ActionsでunityビルドといえばGameCIを思い浮かべる方もいると思いますが、clusterでは特に使用はしていません。

主な理由は以下となります。

  • self-hosted runnerとの相性が良くない
  • 独自のビルドメソッドがすでにあったので、GameCIを使うメリットがあまりない
  • (移行開始当初は)フローティングライセンスのUnity Build Serverとの相性が悪かった
    • 現在はUnity Build Serverもサポートされるようになったみたいですね

GitHub Actionsへの移行に伴う懸念事項

GitHub Actionsへの移行を進めるにあたって、懸念事項もいくつか挙げられていました。

懸念1: GitHub側の障害によるCIの停止

GitHub Actionsはワークフローの実行周りもWebツールになっている関係で、GitHub側で障害が発生するとCIが実行できなくなる恐れがありました。

そこで2022年10月から2023年03月の6ヶ月間の障害発生数と障害時間を集計してみたところ、1時間以上の障害は5回ほどの発生だったため、リリースができなくなるリスクは低いと判断しました。

ちなみに、GitHub Actionsでの運用を始めて4ヶ月以上経ちましたが、幸運なことに今のところGitHubの障害に起因して週次リリースが停止したことはありません。ただ、いずれにしてもGitHubの障害によりCI停止が起こり得ることは認識しておくべきかと思います。

懸念2: GitHub Actionsで発生する従量課金

もう一つ大きな懸念としてはGitHub Actionsで発生する従量課金です。
参考: GitHub Actions の課金について

従量課金は主に「GitHub-hosted runnerの使用」と「ストレージ」 で発生します。ストレージは成果物(artifacts)に使用され、ジョブ間でファイルを受け渡す時やシンプルにファイルを保存したい時に使用します。

これに関してはドキュメントを参考に見積もりを実施して、許容範囲内であると判断しました。

参考程度ですが、従量課金については以下のような特性も判断の基準になるかと思います。

  • self-hosted runnerでのジョブ実行は従量課金が発生しない
  • GitHub Actionsでトリガーされるデータ転送は従量課金が発生しない

GitHub Actions移行で改善したこと

「Jenkins時代の課題」を解消したという話になりますが、GitHub Actionsへの移行により恩恵が大きかったことを4点ほどピックアップして紹介します。

masterノードの運用保守が不要となった

Jenkinsのmasterノードに相当する機能がGitHubから提供されているため、masterノード周りの運用保守まわりがごっそり削減されました。Jenkinsのmasterノードは性能が結構良さげなEC2インスタンスを使っていたのですが、その費用も合わせて削減できました。

ワークフローのレシピがyml定義のリビジョン管理となり、コードレビューを通すフローになった

GitHub Actionsに移行したことで、ワークフローymlがGitHubでリビジョン管理されることになったため、あらゆるワークフローの改修はGitHubのPullRequestレビューを通すことになりました。

これによりワークフロー改修に伴う変更内容がレビューを通して共有されるとともに、ワークフローの変更履歴とその経緯も追従しやすくなりました。常にGitHubでリビジョン管理されている状態のため、ジョブ設定のバックアップフローを構築する必要もありません。

Reusable Workflowsの活用により、プラットフォームごとのワークフローが再利用しやすくなった

Reusable Workflowsはinputsという入力パラメータを設定できるため、リリース版と開発版で異なるパラメータを指定して部分的ワークフローを再利用することが可能です。GitHub Actionsに移行して最も有効活用できている機能の一つだと思います。

clusterでは主にプラットフォームごとのワークフローを個別のymlにReusable Workflowsで定義して、開発版やリリース版といった統合的なワークフローで再利用しています。以下はイメージで、色付きのグラフはそれぞれがReusable Workflowsになっています。

開発版のワークフロー

リリース版のワークフロー

GitHub Actionsに慣れている人向けになりますが、開発版のymlも一部省略して紹介します。以下は開発版ワークフローの定義になりますが、セットアップ処理や各プラットフォームのビルドでReusable Workflowsを活用しています。

name: 'ClientCI Build App Dev'


on:
  workflow_dispatch:
    inputs:
      buildWindows:
        description: 'Build Windows'
        type: boolean
        default: true
      buildMac:
        description: 'Build Mac'
        type: boolean
        default: true
      buildIos:
        description: 'Build iOS'
        type: boolean
        default: true
      buildAndroid:
        description: 'Build Android'
        type: boolean
        default: true
      buildQuest:
        description: 'Build Quest'
        type: boolean
        default: true
      useCache:
        description: 'Use Cache'
        type: boolean
        default: true


jobs:
  pre-check:
    uses: ./.github/workflows/client_ci_reusable_build_check.yml
    with:
      useCache: ${{ inputs.useCache }}


  setup:
    needs: pre-check
    uses: ./.github/workflows/client_ci_reusable_setup_app_version.yml


  build-windows:
    if: ${{ inputs.buildWindows }}
    uses: ./.github/workflows/client_ci_reusable_build_app_windows.yml
    needs: setup
    with:
      productName: cluster_dev
      appVersion: ${{ needs.setup.outputs.appVersion }}
      useCache: ${{ inputs.useCache }}


  build-mac:
    if: ${{ inputs.buildMac }}
    uses: ./.github/workflows/client_ci_reusable_build_app_mac.yml
    needs: setup
    with:
      productName: cluster_dev
      appVersion: ${{ needs.setup.outputs.appVersion }}
      useCache: ${{ inputs.useCache }}


  build-android:
    if: ${{ inputs.buildAndroid }}
    uses: ./.github/workflows/client_ci_reusable_build_app_android.yml
    needs: setup
    with:
      productName: cluster_dev
      appVersion: ${{ needs.setup.outputs.appVersion }}
      useCache: ${{ inputs.useCache }}
    secrets:
      androidKeyStorePassword: ${{ secrets.ANDROID_KEY_STORE_PASSWORD_DEV }}
      androidKeyAliasPassword: ${{ secrets.ANDROID_KEY_ALIAS_PASSWORD_DEV }}


  build-ios:
    if: ${{ inputs.buildIos }}
    uses: ./.github/workflows/client_ci_reusable_build_app_ios.yml
    needs: setup
    with:
      productName: cluster_dev
      appVersion: ${{ needs.setup.outputs.appVersion }}
      clusterEntitlements: cluster-dev.entitlements
    secrets:
      appStoreConnectKeyId: ${{ secrets.IOS_KEY_ID_DEV }}
      appStoreConnectIssuerId: ${{ secrets.IOS_ISSUER_ID_DEV }}


  build-quest:
    if: ${{ inputs.buildQuest }}
    uses: ./.github/workflows/client_ci_reusable_build_app_quest.yml
    needs: setup
    with:
      productName: cluster_dev
      appVersion: ${{ needs.setup.outputs.appVersion }}
      questMobileAppChannel: ALPHA
    secrets:
      androidKeystorePass: ${{ secrets.QUEST_KEY_STORE_PASSWORD_DEV }}
      androidKeyaliasPass: ${{ secrets.QUEST_KEY_ALIAS_PASSWORD_DEV }}


  notification-success:
    if: ${{ !failure() && !cancelled() }}
    needs: [setup, build-windows, build-mac, build-ios, build-android, build-quest]
    uses: ./.github/workflows/client_ci_reusable_build_notification.yml
    with:
      notificationTitle: 'Runが完了しました'
      notificationColorCode: '#36a64f'


  notification-failure:
    if: ${{ failure() }}
    needs: [setup, build-windows, build-mac, build-ios, build-android, build-quest]
    uses: ./.github/workflows/client_ci_reusable_build_notification.yml
    with:
      notificationTitle: 'Runが失敗しました'
      notificationColorCode: '#a30200'

以下はandroidビルドのReusable Workflowの定義です。UaaL(Unity as a Libraryの略)のビルドとアプリ本体のビルドを分けているのは、unityのビルドで生成されるUaaLを中間ファイルとして保存するためと、ストアへのアップロード失敗時にUaaLのビルドをスキップしてアプリ本体のビルドから再開可能にするためです。inputsはほとんど省略していますが、実際にはもっとたくさんあったりします。

name: 'ClientCI Reusable Build App Android'


on:
  workflow_call:
    inputs:
      productName:
        required: true
        type: string
      appVersion:
        required: true
        type: string
      useCache:
        required: false
        type: boolean
        default: false
    secrets:
      androidKeyStorePassword:
        required: true
      androidKeyAliasPassword:
        required: true


jobs:
  build-uaal:
    runs-on: [self-hosted, OSX, ARM64, ventura]
    env:
      UNITY_PATH: /Applications/Unity/Hub/Editor/<Unity version>/Unity.app/Contents/MacOS/Unity
      UNITY_PROJECT_PATH: <path to project>
      CACHE_IDENTIFIER: android
    steps:
    - name: 'Checkout'
      uses: actions/checkout@v4
      with:
        lfs: true
        submodules: recursive
        token: ${{ secrets.patToken }}


    - name: 'Load Cache'
      if: ${{ inputs.useCache && always() }}
      uses: ./.github/composite/client_ci_cache
      with:
        cacheIdentifier: ${{ env.CACHE_IDENTIFIER }}


    - name: 'Set Environment Variables'
      uses: ./.github/composite/client_ci_set_environment_variables
      with:
        appVersion: ${{ inputs.appVersion }}


    - name: 'Build UaaL'
      uses: ./.github/composite/unity_execute_method
      with:
        unityPath: ${{ env.UNITY_PATH }}
        projectPath: ${{ env.UNITY_PROJECT_PATH }}
        buildTarget: Android
        executeMethod: Editor.BuildAndroid
      env:
        PRODUCT_NAME: ${{ inputs.productName }}


    - name: 'Store Cache'
      if: ${{ inputs.useCache && always() }}
      uses: ./.github/composite/client_ci_cache
      continue-on-error: true
      with:
        cacheIdentifier: ${{ env.CACHE_IDENTIFIER }}
        store: true


    - name: 'Upload UaaL'
      uses: actions/upload-artifact@v4
      with:
        name: build-uaal-android
        retention-days: 1
        if-no-files-found: error
        path: <path to UaaL>


  build-app:
    needs: build-uaal
    runs-on: [self-hosted, OSX, ARM64, ventura]
    env:
      ANDROID_PROJECT_PATH: <path to gradle project>
    steps:
    - name: 'Checkout'
      uses: actions/checkout@v4
      with:
        lfs: true
        submodules: recursive
        token: ${{ secrets.patToken }}


    - name: 'Download Artifact'
      uses: actions/download-artifact@v4
      with:
        name: build-uaal-android
        path: <path to UaaL>


    - name: 'Setup Android Environment'
      shell: bash
      run: |
        # 略


    - name: 'Fastlane "bundle"'
      uses: ./.github/composite/client_ci_android_fastlane
      with:
        lane: bundle
      env:
        ANDROID_KEY_STORE_PASSWORD: ${{ secrets.androidKeyStorePassword }}
        ANDROID_KEY_ALIAS_PASSWORD: ${{ secrets.androidKeyAliasPassword }}


    - name: 'Fastlane "lane_upload_to_play_store"'
      uses: ./.github/composite/client_ci_android_fastlane
      with:
        lane: lane_upload_to_play_store

実行キューが開発版とリリース版で分割された

前述したReusable Workflowsを活用して統合的なワークフローのymlを開発版とリリース版で分けたことで、実行キューが各ワークフローごとに分割されるようになりました。そのおかげで、週次リリースのビルド状況も把握しやすくなりました。

ワークフロー失敗時の調査が捗るようになった

実行後のSummaryページでは以下のようにワークフロー全体が表示されています。失敗したジョブがあった場合はそのジョブのアイコンが赤く表示されるので、エラー調査も捗るようになりました。

ジョブ単位のログも見やすくなりました。以下はMac版アプリビルドのジョブが失敗した場合の画面ですが、失敗が起きたステップが赤く表示されて一目で分かるようになりました。

ログもステップごとに確認できるので、失敗時の原因調査もJenkins時代より格段にやりやすくなりました。

ワークフローで承認フローが導入できるようになった

GitHub Actionsには Environments という機能が提供されています。この機能を使うと、enviromentごとにreviewer(承認者)を設定でき、approve(承認)を必須とするフローを簡単に構築できます。

Jenkinsでも独自に開発すれば承認フローを導入できるかもしれませんが、GitHub ActionsではGitHubの有料プランにさえ入っていれば、とても簡単に承認フローを構築することができます。

このブログ執筆時点ではまだEnvironmentsを正式にワークフローへ組み込めてはいないのですが、リリースワークフローのビルド完了後に承認フェーズを設けて、QAが完了したらリリース担当者がapproveしてリリース処理が進行するように組み込む予定となっています。

まとめ

アプリのCIをJenkinsからGitHub Actionsに移行して、いくつかの改善を実現した事例について紹介してみました。Jenkins時代の悩みがほとんど解消されて、ワークフローの改修もさらに加速できるようになりました。

ClientCI WG以外からも少しずつワークフロー改修のPullRequestが発行されるようになってきたので、開発者たちがCIにアプローチしやすい環境になってきたのではないかと感じています。

GitHub Actionsへの移行が完了したことでClientCI WGの作業が減ったかと言えば全くそんなことはなくて、むしろできることが増えた関係でタスクがさらに積まれている状態になりました。特にテストやリリース作業において自動化したい部分がまだまだあるので、GitHub Actionsを活用してさらにイケてる感じにしていければと思います!