🦁

CircleCIからGithub Actionsへの移行

2023/05/11に公開

挨拶

ポートのSREを担当している @s.yanada です。

最近、弊社にてCI環境を CircleCI から Github Actions に移行したので、移行時の振り返りをしていきたいと思います。

移行理由

CircleCI から Github Actions へ移行した主な理由は以下の通りです。

  • 価格
    • Github Enterpriseの導入により Github Actions の利用枠が拡大
  • セキュリティ
    • Github のIP制限機能の有効化に、CircleCI の setup_remote_docker が対応していない

移行方法

当初移行方法には、最近 GA になった Github Actions Importer の利用を想定していました。
しかし、実際に活用してみるとGithub Actions Importer によるインポート後のワークフローの修正が多く発生したため1から自分で作り直すことにしました。

移行時のポイント

Github Actions へ移行の際に工夫した点などを紹介したいと思います。

CI実行環境のコンテナ化

CircleCI では CI に必要なサービスをコンテナ化していたため、Github Actionsでも jobs.<job_id>.services を利用して DB などのコンテナを作成する想定でした。
しかし、上記の手段で実行したコンテナに対して entrypoint に対する引数が指定できないという問題に遭遇し、actions の中で docker-compose によりコンテナを起動する手段を取りました。

通常、docker-compose で起動したコンテナは、 xxx_default という名前のネットワークに属していますが、 jobs.<job_id>.conatiner で起動されたコンテナは別のネットワークに存在するため疎通できません。
(以下のログからわかります)

/usr/bin/docker create --name 4d3ab51f1d02438b84c3e09bbc0ea56e_***pj_ci --label 6c0442 --workdir /__w/pj/pj --network github_network_9140afb921b34afda2ab7df33afab164 --network-alias pj_ci_network  -v "/var/run/docker.sock":"/var/run/docker.sock" -v "/home/runner/work":"/__w" -v "/home/runner/runners/2.303.0/externals":"/__e":ro -v "/home/runner/work/_temp":"/__w/_temp" -v "/home/runner/work/_actions":"/__w/_actions" -v "/opt/hostedtoolcache":"/__t" -v "/home/runner/work/_temp/_github_home":"/github/home" -v "/home/runner/work/_temp/_github_workflow":"/github/workflow" --entrypoint "tail" ***/pj_ci:ruby2.7.4_chromedriver "-f" "/dev/null"

そこで、 docker-compose.yml に network の設定を記述し、実際のネットワーク名は actions の中で都度書き換えてからコンテナ起動するようにしました。

version: "3"
services:
  mysql:
    image: mysql:8.0
    container_name: "mysql"
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci --sql-mode=NO_ENGINE_SUBSTITUTION

networks:
  default:
    name: replace_me_with_github_actions_network_name # ここを yq で書き換える
    external: true
steps:
  - name: setup docker
    run: |
      export NETWORK_NAME=$(docker network ls --filter "name=^github_network_" --format "{{.Name}}")
      yq -i '.networks.default.name = env(NETWORK_NAME)' .github/docker/docker-compose.yml
      docker compose -f .github/docker/docker-compose.yml up -d

これで、command 指定と、コンテナ間通信の問題が解決されました。

余談ですが、 docker コマンドは、jobs.<job_id>.conatiner 内で行われているため、一見 docker in docker になっていそうです。しかし、コンテナ起動時のログを見ると -v "/var/run/docker.sock":"/var/run/docker.sock" のようにホスト側のソケットファイルがコンテナ側にマウントされているため、コンテナ内からホスト側のコンテナ操作が可能になっています。

以下の記事が参考になりました。
dind(docker-in-docker)とdood(docker-outside-of-docker)でコンテナを料理する

並列処理

CI ではテストの実行を行っているのですが、これを単体で行うとテストの規模によってはとてつもない時間がかかってしまいます。
そこで、github actions で用意されている jobs.<job_id>.strategy.matrix を基に並列処理をさせたいのですが、github actions には、CircleCI の circleci tests のようなテスト分割機能が組み込まれていません。
そこで、各ジョブで実行対象となるテストファイルの算出は独自で作成する必要がありました。

これに関してはスクリプトを掲載している記事がいくつか存在しそれを参考に作成しました。

また、デフォルトでは jobs.<job_id>.strategy.matrix で実行されるジョブのうち1つでも失敗した場合に、他のジョブらが全てキャンセルされてしまうのですが、テストの場合は全て最後まで実行されて欲しいので、jobs.<job_id>.strategy.fail-fastfalse にしています。

高速化

並列処理で大分速くなるのですが、それでも CircleCI 時代と比べると遅いです。
高速化できるポイントはいくつかあると思いますが、まず思いつくのがキャッシュを利用したものです。
rails を例に上げると、Gemfile は他のファイルより変更頻度が低いためほとんどの CI では毎回同じようなパッケージをインスールすることになります。
またテストを並列実行させている際、各ジョブ全てでパッケージのインストールやビルド実行するのは無駄になります。

actions/cacheを利用しながら改善していきます。

steps:
  - uses: actions/checkout@v3
  # 依存パッケージのキャッシュ
  - name: Cache bundle gem
    uses: actions/cache@v3
    with:
      path: vendor/bundle
      key: ${{ env.bundle-cache-name }}-${{ hashFiles('Gemfile.lock') }}
      restore-keys: |
        ${{ env.bundle-cache-name }}-
  - run: bundle install --path vendor/bundle
  - name: Cache node modules
    uses: actions/cache@v3
    with:
      path: node_modules
      key: ${{ env.node-cache-name }}-${{ hashFiles('**/package-lock.json') }}
      restore-keys: |
        ${{ inputs.node-cache-name }}-
  - run: npm ci

  # ビルド結果のキャッシュ
  - name: Cache build
    uses: actions/cache@v3
    with:
      key: build-${{ github.sha }}
      path: public/packs
  - run: npm run build

flaky test

github actions に移行した際、画面に関連したテスト(rails でいう feature spec)でたまに落ちるテストがかなり増えました。

落ちるテストが多いかつ原因の特定が難しかったため、一度失敗したテストは以下の方法で再実行させることでいい感じに解消されました。

    steps:
      # ...
      - name: Exec Rspec
        run: |
          bin/rspec_file_splitter -p ${{ matrix.parallelism }} -i ${{ matrix.id }} | \
            xargs bundle exec rspec --failure-exit-code 0
      - name: Retry Rspec Only Failures
        run: bundle exec rspec --only-failures

rspec の各オプションについてはこちらを参照。

可読性

CI では lint、test、build ... など色々実行するケースがあると思いますが、これらを全て1ファイル(1ワークフロー)に詰め込むと行数がかなり多くなります。基本は lint、test、build などをそれぞれのワークフローとして分割しつつ、それでも1ワークフローでの処理内容が多い場合は、github actions の以下の機能を利用することである程度分割可能になります。

  • on.workflow_call
    • CI実行前の準備段階(ビルドなど)をこちらで記述
  • on.workflow_run
    • あるワークフロー成功後に実行したい処理を記述

name: Prepare CI
on:
  workflow_call:

jobs:
  prepare:
    runs-on: ubuntu-latest
    steps:
      # CI環境の準備
      # パッケージのインストール・ビルドなど
      # ...
name: CI
jobs:
  prepare:
    uses: ./.github/workflows/prepare.yml
  main:
    needs: prepare
    runs-on: ubuntu-latest
    steps:
     # prepare workflow からパッケージやビルド結果のキャッシュを基にメインの処理を実行
     # ...
on:
  workflow_run:
    workflows: [CI]
    types:
      - completed

jobs:
  post-ci:
    steps:
      # ...

まとめ

いくつか Github Actions に移行時のポイントを記載しましたが、特に高速化の部分ではまだまだ工夫の余地があると思います。
何かしら参考になれば幸いです。

Discussion