[GitHub Actions] 会社のCIをMac miniのself-hosted runnerに移した

Kenji
Kenji

Engineer / 合同会社irori

公開日
カテゴリ
Web開発
GitHub Actions
Mac mini
CI/CD
Claude Code

こんにちは。

今回は、会社のGitHub ActionsをGitHubホストのランナーからMac miniのself-hosted runnerに移した話です。

Mac miniを買った動機はコストです。GitHub Actionsの課金と、個人で運用しているPlaud脳内WikiのGCP費用。どちらも「常時起動の1台が手元にあれば要らなくなる」種類の出費だったので、1台にまとめることにしました。

移行前の9月分(25日時点)のActionsの請求は、Linuxランナーで9,484分・約$57でした。一番大きいリポジトリはPRを1回pushすると16ジョブ動くので、分数はすぐ積み上がります。

Mac miniが届いたのが2026年9月23日で、その日のうちに6リポジトリ・33ワークフローを調べて、移せるものは移しました。作業はClaude Codeに任せています。この記事は、移行から3週間ほど回した時点で書いています。

早速主題

  • ubuntu-latestのジョブでも、Dockerに依存していなければMacで動きます。6リポジトリのうち5つ、30ジョブほどを移しました。
  • 移した31ジョブを1台で回して、約32分で全部終わりました。
  • 落ちたのは2箇所。setup-terraformのNodeラッパーと、Codecovのgpgです。どちらも「GitHubホストのランナーには最初から入っているもの」が無かった、という原因でした。
  • デプロイ系とClaudeレビュー系はGitHubホストに残しました。
  • その後、ランナーは1台から3台、3週間後に5台に増やしました。macOSでしか動かないジョブ(Electronアプリの署名・公証)も1本増えています。

ランナーの前提

項目 値
マシン Mac mini(Apple silicon、12コア・メモリ24GB)
ランナー Organization irori-dev に登録、ラベル self-hosted, macOS, ARM64
実行ユーザー ci(sudo不可、brew installも不可)
Xcode 27.0
Docker なし
同時実行 移行当日は1台なので1ジョブずつ

ciがsudoもbrewも使えないのは意図的です。ランナーはOrganization内のどのリポジトリのジョブからでも使えるので、ここで権限を絞っておかないとワークフロー1行でマシンをいじれてしまいます。

33ワークフローを読んで判定した

いきなり書き換えずに、まず全部読んで4区分に分けました。

  • 可:runs-onを変えれば動く
  • 条件付き:小さな修正で動く
  • 不可:Docker等に依存していてMacでは動かない
  • 可(据置推奨):動くけど移さないほうがいい

読んでみて分かったのは、33本のうちmacOSのジョブがゼロだったことです。全部ubuntu-latest。iOSアプリのビルドはEASのクラウドでやっていて、Xcodeもキーチェーンも使うジョブがありません。Xcode 27.0が入ったMacがLinux向けのNode CIを回している状態です(この状況は後で変わります。後述)。

不可になった理由はこのあたりです。

  • uses: docker://やDockerイメージで動くアクション(semgrep、trufflehog)
  • docker build / buildx
  • services: postgres
  • sudo mkdir /cloudsql(ciはsudoが使えない)
  • Linux用のcloud-sql-proxy

据置推奨にしたのは2種類です。デプロイ系は、本番の認証情報を作業ディレクトリが次のジョブに残るマシンで扱いたくない。Claudeレビュー系は、API待ちで1台を20〜60分占有するのと、plaud-wikiのレビューはBashを無制限に許可しているので、PRの内容経由でプロンプトインジェクションされると常設Mac上で任意コマンドが動きます。使い捨て環境で動かすべきものだと判断しました。

corporate-site(このサイト)はsetup-gcloud@v0でSA鍵JSONを使うデプロイしかなかったので、1本も移していません。代わりにNode 20がEOLなので24に上げる、というIssueだけ切りました(このIssueは3週間経ってもまだ開いたままです…)。

全ジョブに入れた変更

GitHubホストとの一番の違いは、作業ディレクトリが次のジョブに残ることです。その対策が中心になります。

.github/workflows/ci.yml
jobs:
  test:
    runs-on: [self-hosted, macOS, ARM64]
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
        with:
          clean: true
      # ... 本来のステップ ...

      # self-hosted runner では作業ディレクトリが次のジョブに残るため、生成物・依存を消しておく
      - name: 後片付け
        if: always()
        working-directory: ${{ github.workspace }}
        run: git clean -ffdxq
  • git clean -ffdxqで後片付け。if: always()で失敗時も消す。actions/cacheを使っているジョブは、cacheのpostステップが後片付けの後に走るので-e .next/cacheや-e .terraform-plugin-cacheで除外しています。
  • timeout-minutesを未設定のジョブ全部に足しました。1台しかないので、ハングしたジョブが居座ると全リポジトリのCIが止まります。
  • concurrencyで同じブランチの古いrunはキャンセル。releaseだけはcancel-in-progress: falseにしています(Releaseが中途半端に残るので)。
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

個別には、npm install -gをnpxに変えた(ciはグローバルに書けない)、Pythonのバージョンを明示した、リリース時の版数照合をlinux-x64からdarwin-arm64のバイナリに向け直した、くらいです。

Mac miniで回して落ちたところ

PRを出して走らせたら、31ジョブ中28成功・3失敗でした。失敗3つの原因は2種類で、1つはterraform、残り2つはsunaoのUnit TestsとIntegration Testsが同じ理由(Codecov)で落ちたものです。

terraformがenv: node: No such file or directory

hashicorp/setup-terraformは既定でterraformコマンドをNodeのラッパーで包みます。GitHubホストにはNodeが入っているので気づかないのですが、Mac miniのciユーザーのPATHにはNodeがいません。terraform_wrapper: falseにして解決しました。もう1つのリポジトリは元からこの設定が入っていて、それは過去の自分に感謝しました。

Codecovがgpgを要求する

Unit TestsとIntegration Testsの2ジョブで、テスト自体は全件通っています(379ファイル・3,801件)。落ちたのはその後のcodecov-actionです。

Error: The following required dependencies are missing: gpg

CodecovはアップローダーのバイナリをGPG署名で検証していて、gpgが無いと止まります。これもGitHubホストには最初から入っています。「管理者がbrew install gnupgする」か「検証をスキップする」の二択で、前者にしました。ciはbrew installできないので、ここだけ管理者ユーザーの手が要ります。

原因は2つとも、GitHubホストが最初から入れてくれているものに知らずに頼っていた、という同じ形です。ランナーを自前で持つとこの手の暗黙の依存が全部見えます。

後日、初めてのmacOSジョブで踏んだもの

10月1日に、Electron製のデスクトップアプリ(gijiroku desktop)のmacOS向けリリースワークフローを初めてMac miniで走らせました。universalビルド → Developer ID署名 → 公証 → GCSへ配布、という内容で、これが33本の中には無かった「macOSでしか動かないジョブ」の1本目です。初回リリースは4回目の実行でようやく成功しました。落ちた理由は3つで、全部Mac mini固有です。

  1. gcloudが起動しない。 Mac miniにはCommand Line ToolsのPython 3.9しか無く、gcloudは3.10〜3.14を要求します。他のgcloudを使うワークフローは全部ubuntuなので、ここで初めて表に出ました。
  2. actions/setup-pythonが使えない。 macOS版は/Users/runner/hostedtoolcacheに展開する前提で、ランナーのユーザーがciだとmkdir: /Users/runner: Permission deniedになります。結局gcloudもPythonもやめて、WIFで借りたアクセストークンでGCSのAPIをcurlで直接叩く形にしました。
  3. electron-builderの一時キーチェーン。 CSC_LINKから一時キーチェーンを作る処理で、set-key-partition-listにキーチェーンではなく.p12のパスワードを渡していてSecKeychainUnlockで落ちます。ワークフロー側でキーチェーンを自分で作ってCSC_KEYCHAINで渡すようにしました。あわせて、キーチェーンアクセスから書き出した.p12には中間証明書が入らないので、Developer ID G2の中間CAをフィンガープリント照合して取り込んでいます。これは別のリポジトリ(kango)のリリースでも同じ理由で1回落ちました。

GitHubホストのmacOSランナーなら、Pythonもgcloudもsetup-pythonの前提も中間CAも揃っています。ここでも「最初から入っているもの」の話でした。

実測

PRを出した直後にキューに溜まった31ジョブを、1台で直列に処理した結果です。全体で04:41〜05:13 UTC、約32分。

リポジトリ ジョブ 所要
sunao Unit Tests 約1分30秒
sunao Security Scan 約3分
tabiyaku Run Tests 約2分10秒
movie-lms e2e 約1分30秒
plaud-wiki build 約25秒
kango Python 約1分15秒

1ジョブあたりは軽いです。ただ、一番大きいリポジトリ(sunao)は1pushで16ジョブ走るので、1pushでランナーを30分近く占有します。GitHubホスト時代との速度比較は取っていないので、速くなったかどうかは分かりません。

余談:手元のMacで移行を検証しているときにも小さく躓きました。pnpm v11がNode 20非対応で入らない、zshで変数が単語分割されずbashでやり直し、変換スクリプトがジョブ直前のコメントを前のジョブの一部として扱う。CIの話ではないですが、移行作業はこういうのの積み重ねですね。

並列化

1つのランナーは同時に1ジョブしか動かしません。並列にしたければ、同じMacに別ディレクトリでランナーを2〜3個登録して同じラベルを付けるのが一番素直です。目安は「メモリ16GBなら2個、32GB以上なら3個」と言われました。Next.jsのビルドがメモリを食うためです。

Firestoreエミュレータのようにポートを固定で掴むジョブは、emulatorのようなラベルを1ランナーだけに付けて直列化すれば避けられます。

移行当日は1台で進めましたが、2日後に3台にしました。そして10月12日、直近2週間のジョブを集計してから5台にしています。

集計(9/28〜10/12、1,090ジョブ) 値
待ち時間の中央値 15秒
待ち時間の90パーセンタイル 約7.7分
最長の待ち 約25分
5分以上待ったジョブ 208件(約19%)
3台とも埋まっていた時間 11.6時間中5.8時間

待ちが出るのはほぼsunaoのCIが一度に大量のジョブを出すときで、ジョブの3/4がsunaoです。12コア・24GBなのでCPUには余裕があり、制約になるのはメモリだろうと見て、「5台で始めてメモリの警告が続くなら4台に戻す」ことにしました。5台にした直後の5分平均負荷は21.79で、しきい値の24に近いところにいます。ここは監視の記事のほうで続きを書きます。

余談:4・5台目を登録している最中に、別リポジトリのジョブが「The self-hosted runner lost communication with the server」で1件落ちました。時間帯は重なっていますが、因果は確かめていません。再実行は通っています。

移行後に他のリポジトリも同じ方式に乗せていて、10月に入ってからkangoとlivedeli-appも一部のジョブをMac miniに移しました。Dockerやサービスコンテナが要るジョブは相変わらず移せません。

セキュリティ面で確認したこと

self-hosted runnerで一番怖いのは、外から来たコードが常設マシンで動くことです。

  • 6リポジトリ全部private。forkからのPRでワークフローを動かす設定はオフ
  • pull_request_targetを使っているワークフローは無し
  • Organizationのランナーグループは「この5リポジトリだけ許可、publicリポジトリは不許可」
  • Bashを無制限に許可しているClaudeレビューは載せない
  • 本番の認証情報を扱うデプロイは載せない
  • リリースをテスト用タグで動かさない(DockerHubにlatestをpushするジョブがある)

まとめ

  • 33ワークフローを「可 / 条件付き / 不可 / 据置推奨」で棚卸し。不可はDocker依存、据置はデプロイとClaudeレビュー
  • 共通の変更はgit clean -ffdxqの後片付け、timeout-minutes、concurrency
  • 落ちたのはsetup-terraformのNodeラッパー(terraform_wrapper: false)とCodecovのgpg(brew install gnupg)。後日のmacOSジョブでもPython・setup-python・中間CAで同じ形の失敗をした
  • 31ジョブを1台で約32分。大きいリポジトリは1pushで30分占有するので、3台、そして5台に増やした

載せるものより、載せないものを決めるほうに時間がかかりました。Mac mini側の構成管理と監視の話は次の記事に書きます。

この記事を書いた人

Kenji
Kenji(Engineer)

合同会社iroriのエンジニア。Rails / Next.js / AWS を中心とした受託開発と、生成AIを使った開発フローの整備を担当しています。このブログでは、実際に自分の環境で試した手順や、詰まったポイントと解決までの記録を書いています。

Related Posts

[Mac mini] 社内サーバーの構成をリポジトリで管理する。mac-mini-infraを作った

[Mac mini] 社内サーバーの構成をリポジトリで管理する。mac-mini-infraを作った

[個人開発] Plaud脳内WikiをGCPからMac miniに移した。サーバーレスをやめた理由とGCP撤去までの記録

[個人開発] Plaud脳内WikiをGCPからMac miniに移した。サーバーレスをやめた理由とGCP撤去までの記録

[Claude Code] 検索から消えたサイトの再発防止に、記事のリリース判定をClaudeのCIに任せた(ハマり2つ付き)

[Claude Code] 検索から消えたサイトの再発防止に、記事のリリース判定をClaudeのCIに任せた(ハマり2つ付き)