こんにちは。
前回、会社のCIをMac miniのself-hosted runnerに移した話を書きました。今回はそのMac mini自体の構成をどう管理しているかの話です。
社内サーバーは「とりあえず動いた」で始まると、気づいたときには誰も再現できなくなっています。なので今回は最初からmac-mini-infraというリポジトリを作って、故障で入れ替えるときや2台目を足すときに、ここから同じ環境を作れる状態を保つことにしました。
リポジトリの中身
mac-mini-infra/
├── README.md
├── docs/RUNBOOK.md # 運用手順書
├── install.sh # 監視スクリプトと LaunchDaemon を配置・登録する
├── monitor/
│ ├── check.sh # リソース監視スクリプト
│ └── slack_webhook.example # Webhook ファイルの見本(実物はコミットしない)
├── launchd/
│ └── jp.irori.resource-monitor.plist
├── runners/
│ └── add-runner.sh # ランナーを追加登録する
└── .github/workflows/shellcheck.yml # PR ごとに shellcheck
小さいです。ただ「再現できる」を目標にすると、監視を誰が動かすか、秘密情報をどこに置くか、変更をどう反映するかを決めないと書けないので、そこが決まったのが一番の収穫でした。
Mac miniの構成
RUNBOOKの「現在の構成」から抜粋します。
| 項目 | 値 |
|---|---|
| 機種 | Mac mini(Apple silicon、12コア・メモリ24GB) |
| ネットワーク | 有線。Wi-Fiはオフ |
| 電源 | pmset -a sleep 0 displaysleep 10 disksleep 0 autorestart 1 |
| FileVault | オフ |
| リモート | Tailscale(CLI版をbrew servicesで常駐)、SSH、画面共有 |
| ランナー | 5台(irorinoMac-mini、-2〜-5)、svc.shで作ったLaunchAgent |
ユーザーは3つあります。
| ユーザー | 種別 | 用途 |
|---|---|---|
irori |
管理者 | 運用・保守。このリポジトリのclone先 |
ci |
通常ユーザー | ランナーの実行。自動ログイン |
_irorimon |
監視専用(非表示・ログイン不可) | リソース監視の実行 |
FileVaultがオフなのはciを自動ログインさせるためです。ランナーはsvc.shが作るLaunchAgentなので、ciがログインしている間しか動きません。停電から復帰したときに無人でランナーが上がってほしいので自動ログインは必須で、FileVaultが有効だと再起動後にプリブート認証で止まるので切りました。Apple siliconはFileVault無しでもSSDは暗号化されています。
移植するはずのcheck.shが存在しなかった
最初にClaude Codeに渡した依頼文には「Mac mini上にある~/monitor/check.shをそのまま移植すること。動作を変えないこと」と書いていました。既に動いている監視スクリプトをリポジトリに取り込む作業だと思っていました。
Mac miniで取り込み手順を実行すると、
cp: /Users/ci/monitor/check.sh: No such file or directory
launchdのジョブも、
Could not find service "jp.irori.resource-monitor" in domain for system
sudo launchctl listで出てくるのはcom.apple.thermalmonitordだけでした。
依頼文に書いた「現在の構成」は、現在ではなく「こうなっていてほしい姿」でした。ランナーやTailscaleは実在していたので、監視も当然あるものとして書いてしまっていたようです。「というか手順でまだクローンしただけだよ」と伝えた時点で、Claude Code側も「取り込む原本はありませんね」と切り替えてくれて、監視は新規に作ることになりました。
自分のサーバーの現状を自分が把握していない、というのがこの時点でばれたのは良かったと思っています。
監視は専用ユーザー_irorimonで動かす
新規に作るなら実行ユーザーはちゃんと決めよう、となりました。
ciで動かすのは駄目です。ciはOrganization内のどのリポジトリのCIジョブからでも使えるので、監視をciで動かすとワークフローに1行書けばWebhook URLが読めるし、監視スクリプトも書き換えられます。
irori(管理者)も駄目です。/opt/homebrewの所有者なので、Homebrew配下のファイルを通じて実質rootと同じ権限を持っています。
なので専用ユーザーを作りました。_irorimonはログイン不可・非表示・adminではない、シェルは/usr/bin/falseのユーザーで、install.shが作ります。監視スクリプト本体はroot:wheelの755で置くので_irorimon自身も書き換えられません。Webhook URLは_irorimon所有の600で置いています。
監視スクリプト1本のためにユーザーを作るのは大げさに見えますが、ciと同居している以上ここを雑にすると、CIに紛れ込んだ1行でSlackに何でも投げられるようになります。
監視の中身
check.shはLaunchDaemonから60秒ごとに動いて、状態が変わったらSlackに通知します。
| 項目 | 警告 | 重大 | 猶予 |
|---|---|---|---|
| ディスク(APFSコンテナ使用率) | 85%以上 | 95%以上 | なし |
| メモリプレッシャー | warning | critical | 2分 |
| CPU負荷(5分平均) | コア数×2以上 | — | 5分 |
ランナー(各Runner.Listenerプロセス) |
— | 見つからない | 3分 |
ciのGUIセッション |
ない | — | 2分 |
猶予は一時的な揺れで通知しないためです。ランナーは自動アップデートで勝手に再起動するので、3分は見ないと誤報になります。通知は「状態が変わったとき」と「異常が続く間は1時間ごと」、同じ回の複数の変化は1通にまとめ、送信に失敗したら通知済みにしません。
しきい値は実機の値を見て決めました。12コアなのでCPUのしきい値は24で、作った当初の5分平均は17前後でした。
2週間回して、実際に鳴ったもの
監視を入れてから2週間ちょっとで、Slackに来た通知はこんな具合です。
- CPU負荷の警告:十数回。sunaoのCIがまとめて走ると5分平均が25〜40になり、数分〜数十分で復旧通知が来ます。10月2日の午後には121.44という値も出ました。
- メモリプレッシャーの警告:10月2日と10月7日に、warningとnormalを十数回往復しました。10月7日の夕方から翌朝まで、約17時間warningのまま(1時間ごとに「継続N分」の再通知)という日もあります。
- ディスク、ランナープロセス、
ciのセッションの通知:なし。
「鳴ったけど何もしなくていい」通知が多いので、10月12日にランナーを5台に増やしたタイミングで、しきい値を見直すPRを出しました。CPUはコア数×2から×1.5(しきい値18)に、ディスクの重大は95%から90%に下げています。95%だと空きが25GB前後しか無く、CIのワークスペースとキャッシュですぐ埋まります。CPUのほうは逆で、5分平均に5分の猶予が乗っているので、10分近く高負荷が続かないと鳴りません。5台にした直後の負荷は21.79で、旧しきい値では黙っているのに「5台は多すぎないか」を知りたい、という状況でした。LOAD_FACTORに小数を入れるため、整数演算の$(( ))をawkに変えています。1〜2週間様子を見て、多すぎれば1.75に戻します。
一方で、この監視が拾えなかったこともあります。10月1日にランナー3台が同時に固まった(Listenerプロセスは生きているのにジョブを取りに来ない)ときは、プロセスの有無しか見ていないので何も鳴りませんでした。これはトラブルの短編に書いています。
画面共有しただけで警告が飛んだ
最後の「ciのGUIセッション」は、ランナーがLaunchAgentである以上ciがログインしていないと動かないので入れた項目です。最初はstat -f %Su /dev/consoleで、画面の前面にいるユーザーがciかどうかを見ていました。
すると、私が画面共有でiroriとしてログインした瞬間に警告が1件飛びました。前面がiroriになったからです。ciのセッションは裏で生きていてランナーは動いているので、誤報です。
判定を「ciのloginwindowプロセスが存在するか」に変えました。GUIログイン中のユーザーごとに1つ、そのユーザーで動くプロセスなので、前面に誰がいても影響しません。
if /bin/ps -axww -o user=,comm= \
| /usr/bin/awk -v u="$CI_USER" '$1 == u && $2 ~ /\/loginwindow$/ { found = 1 } END { exit !found }'; then
add_result console ok "${CI_USER} の GUI セッションあり"
else
add_result console warn "${CI_USER} の GUI セッションがありません。${CI_USER} がログインしていないとランナーは動きません"
fi
Webhook URLを2回漏らした
これは完全に自分のミスです。
1回目。Claude Codeに「Webhookは発行済みですか」と聞かれて、URLそのものをチャットに貼りました。すぐに「会話記録に残ったので、漏れた扱いにして作り直してください」と言われました。
2回目。作り直したURLを登録しようとして、Mac miniで./install.sh --set-webhook <URL>と引数に付けて実行しました。しかも古いブランチにいたので旧install.shがusageを出して終わり、その端末出力をまたチャットに貼りました。zshの履歴にも残り、チャットにも2回目です。
unset HISTFILEしてから~/.zsh_historyと~/.zsh_sessions/*から該当行を消し、SlackでWebhookを削除して再発行。3回目でようやく、引数なしの./install.sh --set-webhookで、画面に表示されない入力から登録しました。
これを受けて、install.shは引数にURLらしきものが渡されたら値を表示せずに中止して、履歴の削除と再発行の手順を表示するようにしました。RUNBOOKにも「Webhook URLが漏れたとき」の節を足しています。
秘密情報は「コミットしない」だけでは足りず、チャットに貼らない、コマンドの引数に渡さない、までルールにしないと自分は守れないと分かりました。
Mac miniを直接いじらないためのルール
RUNBOOKの6章にこう書きました。
- 手元でブランチを作って変更する(スクリプト、plist、RUNBOOK)
- PRを作る。shellcheckがCIで走る
- マージ
- Mac miniで
git pull --ff-only→./install.sh --dry-run→./install.sh
--dry-runは、実行予定の操作を[dry-run]付きで表示して既存ファイルとのdiffを出すだけで、何も変えません。sudoも要りません。install.sh自体は冪等で、登録済みのジョブはbootoutしてからbootstrapし直すので、何度実行しても同じ結果になります。
緊急でMac miniを直接変更した場合は、その日のうちに同じ内容をPRで反映する、という逃げ道も書いてあります。
余談:スクリプトは全部bashで、
set -euo pipefail、コマンドは絶対パス、shellcheck警告ゼロにしています。macOS標準の/bin/bashが3.2なので連想配列もmapfileも使えず、check.shのチェック結果は添字を揃えた複数の配列で持っています。Homebrewのbashに依存するのも嫌なので我慢しています。
ランナーの追加
ランナーを1台から3台に増やしたときのスクリプトも入っています。番号を渡すと~/actions-runner-<番号>を作り、1台目のときに残しておいたtar.gzを使い回して展開し、登録トークンは非表示の対話入力で受け取ります。
/bin/bash /Users/irori/mac-mini-infra/runners/add-runner.sh 2
/bin/bash /Users/irori/mac-mini-infra/runners/add-runner.sh 3
10月12日に5台にしたときも同じスクリプトで、4と5を渡しただけです。待ち時間の集計と判断はCI移行の記事に書きました。
ランナーを増減したらcheck.shのRUNNERS配列も同じPRで更新する、というのもRUNBOOKに書いてあります。順番にも注意が要って、先にランナーを登録してからPRを反映しないと、check.shがまだ存在しない4・5台目を「プロセスが見つからない」としてSlackに流します。
まとめ
- 「再現できる」を目標にすると、実行ユーザー・秘密情報の置き場・変更手順を決めざるを得ない
- 移植するはずの監視スクリプトは存在しなかった。「現在の構成」を自分が把握していなかった
- 監視は専用ユーザー
_irorimonで。ciではワークフロー1行で漏れ、iroriでは実質root - 画面共有で前面ユーザーが変わって誤報。
loginwindowプロセスの有無で判定するようにした - Webhook URLを2回漏らした。チャットにも引数にも渡さない。スクリプト側でも拒否する
- 2週間回して鳴ったのはCPUとメモリの警告ばかり。しきい値を下げて様子見中。プロセスが生きたまま固まるランナーは拾えなかった
ランナーが止まった話とアーティファクトの話はトラブルの短編に分けて書きました。

![[GitHub Actions] 会社のCIをMac miniのself-hosted runnerに移した](/assets/ogp/github-actions-self-hosted-runner-on-mac-mini.webp)
![[GitHub Actions] self-hosted runnerにして最初の2週間で起きたこと。ランナー停止2回とアーティファクト128GB](/assets/ogp/mac-mini-runner-offline-and-artifact-quota.webp)
![[個人開発] Plaud脳内WikiをGCPからMac miniに移した。サーバーレスをやめた理由とGCP撤去までの記録](/assets/ogp/plaud-llm-wiki-gcp-to-mac-mini.webp)