🚢

CircleCI から GitHub Actions への移行で気をつけるべきポイント

2023/05/13に公開

はじめに

ある開発プロジェクトにて CI 基盤として CircleCI を使用していましたが、様々な理由があり GitHub Actions への移行を決定しました。

移行に伴って CircleCI の設定ファイルを GitHub Actions 用に書き換える必要がありますが、GitHub Actions Importer を利用すれば概ね自動で移行できます。

https://docs.github.com/ja/actions/migrating-to-github-actions/automated-migrations/automating-migration-with-github-actions-importer

本記事では、 GitHub Actions Importer では対応しきれていなかった課題や苦労を共有します。同様の状況に直面する方々の参考になれば幸いです。

主なトピックは以下の3点です👇

  • ジョブ間のデータ共有
  • 並列化インスタンスからのアーティファクト収集
  • ジョブの途中成功終了

🔄 ジョブ間のデータ共有

ビルド生成物や中間ファイルなど、ジョブ間でデータを共有したいケースがあります。

GitHub Actions 公式の CircleCI からの移行ガイドによると、以下のような対応付けがされています。

GitHub Actions Importer を使用した移行の自動化

しかし、共有対象のデータサイズが大きい場合、この方法は最適とは言えません。

CircleCI の persist_to/attach_workspace に比べると、 GitHub Actions の upload/download artifact アクションは 非常に速度が遅いためです。下記 issue でも取り上げられています。

https://github.com/actions/upload-artifact/issues/199

そこで、代替案として cache アクションを利用する方法を提案します。

cache アクションは upload/download artifact アクションに比べると遥かに高速です(少なくとも筆者の環境では persist_to/attach_workspace と同等の速さでした)。

ただし、 cache アクションは本来ワークフロー間でデータをキャッシュする目的で使用されるものであるため、データのスコープがワークフローに限定されない点に注意が必要です(過去のワークフロー実行時のキャッシュが使用される可能性がある)。

そのため、キャッシュを他のワークフロー実行時には参照不可能にするために、ワークフロー実行IDをキャッシュキーに含めます。具体的には、 github コンテキストの run_id をキーに追加することで、キャッシュのスコープをワークフロー実行単位に限定できます。

action.yml
...
- uses: actions/cache@v3
  with:
    key: build-artifact-${{ github.run_id }}
    path: dist
...

この方法により、ジョブ間でのデータ共有を十分な速度で行うことができます。

💾 並列化インスタンスからのアーティファクト収集

当プロジェクトでは CI 上で Visual Regression Testing(VRT)を行っています。VRTは、ウェブページのレイアウトやデザインの変更を検出するためのテスト手法で、各コンポーネントのスクリーンショットを撮影していく必要があります。以前は CircleCI の並列実行機能を活用して、複数のインスタンスでスクリーンショットの撮影を並列して行い、 persist_to_workspace を用いて撮影結果を一箇所にマージすることで、VRTの高速化を行っていました。(詳細については以下の記事を参照してください。)

https://blog.wadackel.me/2022/vrt-performance-optimize/

さて、 GitHub Actions でも matrix strategy を用いることでジョブの並列実行を行うことができます。たとえば、10並列でジョブを行いたい場合は以下のように記述する必要があります。

strategy:
  matrix:
    INDEX: [0,1,2,3,4,5,6,7,8,9] # 長さが10あれば良い

GitHub Actions でも並列実行自体はこれで問題なく行えますが、各インスタンスの実行結果の集約の難易度が高いです。 この理由とその対策について、 persist_to_workspace, upload_artifact(cache/save でもほぼ同様のため以降 cache への言及は省略)の仕様の違いを踏まえながら説明します。

ここでは具体例として、以下のような2つのジョブを CircleCI, GitHub Actions それぞれで実装する場合を考えます。

  • capture: 並列化してスクリーンショットの撮影を行う
  • compare: capture の全ての結果をもとに処理を行う

CircleCI

persist_to_workspace の動作

  • root に対して、 paths で指定した(ディレクトリ内の)ファイルをコピーする
  • 複数のインスタンスで同一の paths を指定したとしても、その中にあるファイル名が衝突しなければディレクトリ内でマージされる

実装

ワークスペース内でディレクトリがマージされるため、非常にシンプルに書くことができます。

capture:
  parallelism: 10 # 10並列
  steps:
      - ... # スクリーンショットを撮影し、 /screenshots に保存(撮影結果ファイル名は並列インスタンスごとに異なる)
      - persist_to_workspace:
          root: /web
          paths:
            - screenshots # 撮影結果をワークスペースに保存(ワークスペース内で全ての撮影結果がマージされる)

compare:
  steps:
    - attach_workspace: # これだけで全インスタンスの撮影結果を扱える
      at: /web

GitHub Actions

upload_artifact の動作

  • ある特定の場所に対して、 paths で指定したファイル・ディレクトリを name の名前をつけて1ファイルに圧縮して保存する
  • 各並列インスタンスから同一 name でアーティファクトを保存すると、上書きされてしまう

そのため、 GitHub Actions では CircleCI のように簡単に書くことはできません。

capture:
  strategy:
    matrix:
      INDEX: [0,1,2,3,4,5,6,7,8,9] # 10並列
  steps:
    - uses: actions/upload-artifact@v3.1.1
      with:
        name: "screenshots" # 🥺名前が衝突するので最後にアップロードされたもので上書きされてしまう
        path: "screenshots"

この問題に対して、さまざまな回避策を試しましたが、 artifact アクションを使ってスマートに解決できる方法は見つけられませんでした。(もしご存知の方はコメントいただけると嬉しいです。)

試した回避策

インスタンスごとに一意の名前を指定

並列インスタンスごとに一意の name を指定してアップロードし、ダウンロード時にそれらを指定してダウンロードする方法です。
しかし、現状の download-artifact アクションはワイルドカードなどを用いた複数のアーティファクトダウンロードが行えないため、愚直にダウンロードを直列で行う必要があり、全く DRY ではない書き方をする必要があります。(issue

capture:
  strategy:
    matrix:
      INDEX: [0,1,2,3,4,5,6,7,8,9]
  steps:
    - ...
    - uses: actions/upload-artifact@v3.1.1
      with:
        name: "screenshots-${{ matrix.INDEX }}"
        path: "screenshots/${{ matrix.INDEX }}"

compare:
  steps:
    - name: download vrt artifact 0
      uses: actions/download-artifact@v3
      with:
        name: "tmp-0"
        path: "/tmp/0"
    - name: download vrt artifact 1
      uses: actions/download-artifact@v3
      with:
        name: "tmp-1"
        path: "/tmp/1"
    - ...
    - ...(/tmp/9 まで書く必要がある)

ちなみに、 name を指定せずに download-artifact すると、全てのアーティファクトを一括ダウンロードすることができます。
しかし、もし、他のジョブでもアーティファクトをアップロードしている場合、それらもまとめてダウンロードされてしまう可能性があります。

実装

そこで、今回は GitHub Actions の機能やアクションを用いるのではなく、 S3 や GCS などの外部ストレージを利用することで解決を図りました。

capture:
  steps:
    - ...
    - name: Upload screenshots to gcs
      run: "gsutil cp ~~~"

compare:
  steps:
    - ...
    - name: Download screenshots
      run: "gsutil cp ~~~"

オブジェクトストレージの使い勝手は CircleCI の workspace と似ており、ファイル名さえ衝突しなければ全てのデータがバケットに展開され、それらを一括ダウンロードすることも容易です。

ただし、この方法には注意点が一つあります。ワークフローの実行のたびに、CIの中間ファイルがバケットに保管されますが、ワークフロー実行後は中間ファイルは基本的に不要になります。ストレージの容量を効率的に使うためにも、ストレージのライフサイクル設定を用いて削除処理を自動化する、もしくは、ジョブ内部でクリーンアップ処理を行うと良いでしょう。

以上の方法で、並列化インスタンスからのアーティファクト収集を実現することができました。これにより、GitHub Actions 上での VRT の高速化とともに、並列化インスタンスからのアーティファクト収集も効率的に行うことができるようになりました。

⏹️ ジョブの途中成功終了

特定の条件を満たした場合に、ジョブを失敗させずにその場で終了させたいケースがあります。例えば、特定の条件でのみデプロイを実行したい場合などです。

CircleCI では circleci-agent step halt を使用すること対応できますが、 GitHub Actions では同等の API が提供されていないため、このままだと後続の全てのステップに対して if を書いて実行をスキップさせる必要があります。

CircleCI と GitHub Actions でのジョブ中断のやり方の比較

この書き方だと、後続のステップ増加や、output の変数名の変更に非常に弱い状態になってしまいます。
この問題を解決するために、条件判定部分と処理実行部分を別々のジョブに分けて実装します。

jobs:
  needs_deploy:
    output:
      # デプロイが必要かどうかを output に出力
      needs_deploy: ${{ steps.check.outputs.needs_deploy }}
    steps:
      - name: Check if the deploy is needed
        id: check
        run: |
          if [ デプロイが必要な条件 ]; then
            echo "needs_deploy=true" >> $GITHUB_OUTPUT
          else
            echo "needs_deploy=false" >> $GITHUB_OUTPUT
          fi

  deploy:
    needs: needs_deploy
    # ジョブの実行条件節で判定する
    if: needs.needs_deploy.outputs.needs_deploy == 'true'
    steps:
      - name: Build image
        run: ...

      - name: Deploy
        run: ...

以上のように条件判定を行うジョブを別のジョブに切り出すことによって、 circleci-agent step halt で実現していたようなジョブの途中で成功扱いとして中断する動作を GitHub Actions でも達成することができました。

おわりに

本記事では、 CircleCI から GitHub Actions への移行の際に直面した課題について、対策とともに紹介しました。今後同じような境遇の方の助けになれば嬉しいです。
今回取り上げた課題や解決策以外にも、皆さんが遭遇した苦労や対策があれば、ぜひコメントで共有していただけると幸いです。

Discussion