author: GeneralD
UTMでmacOSアプリ検証を別船に逃がした話

macOS のアプリを書いていると、ある
で手が止まります。ビルドした debug build を
して、「ちゃんと
が出るか」を
たい。
バー
でも、
でも、
でも。やることは
なはずなのに、
の
機でこれをやると、なぜか
甲板が
ます。
気になったので、なぜのかを一つずつ
で
てみました。
が見えてくると、
道も見えてきます。
解決したい問題
は lyra という macOS
アプリ(再生中の
と
をデスクトップに重ねる
)を書いていて、この
でいくつも
ました。
機を
して、debug build を
て、
を
。それだけのことが、なかなか
に進みません。
してみると、
さの
は三つに分かれていました。一つは launchd に飼われた
が、
ても
こと。二つは
と debug が
を
こと。三つは debug build が
の
と
を
食うこと。
同じごとに
ている人がいるかもしれないので、
でやってみたことを置いておきます。
機を一切
ずに OS
の
をする、その
の話です。
この記事を読み終えたら手に入るもの
機を
ずに macOS アプリを OS
で
る
- なぜ「
のマシンでの
」が
だらけなのか、その
- UTM の Apple
バックエンドで
utmctl execが使えないというと、その
boot/run/capture/restoreといったの
方と、その
にある
- GUI
に
を挿す
launchctl asuserの方と、それが
と
が出ない
前提
- ホスト: macOS、Apple Silicon。UTM を入れてあること(
brew install --cask utm) - ゲスト: UTM 上に macOS 15+ の VM を Apple 仮想化バックエンドで
済み。Remote Login (SSH) を
にし、
adminを
てある前提
:
ssh/scpと bash がること
- スコープ外: VM の
そのもの(SSH
の
込み、passwordless sudo の
など)はここでは
しません。
は
で組んだ
の
です
なぜ開発機での確認が厄介なのか
三つの罠を、一つずつ並べて潰す
は三つ。
で
る前に、一個ずつ
てみます。
罠その一: launchd KeepAlive の蘇生
Homebrew でとして入れたアプリは、launchd に
homebrew.mxcl.lyra としてされます。
なのは、こいつの
KeepAlive が true だということ。
つまり SIGTERM だろうが SIGKILL だろうが、た
に launchd が
に
します。
lyra stop(は
kill)をても、また
出てきます。
には、KeepAlive の
そのものを
するしかありません。
brew services stop lyra
# 中身はこれ:
# launchctl bootout gui/$UID/homebrew.mxcl.lyra
launchctl bootout で GUI から
出して、ようやく
になります。
はこれを
ずに
kill をしていて、しばらく
ない
を過ごしました。
罠その二: single-instance flock の奪い合い
lyra は ~/.cache/lyra/lyra.pid に flock を掛けて、に二
が
ないようにしています。
のいい
です。とはいえ
時には
を
ます。
が
ていると、debug build は
を
ずに
します。
に
を
やり
できたとしても、今度は
が
に
れて、どちらを
いるのか分からなくなります。
の
か、たった今
した
か。
のつかない
は、
になりません。
罠その三: 本番の設定とキャッシュを食う
debug build も ~/.config/lyra/ のの
と
~/.cache/lyra/ をます。
をして、
に
を
ます。
のつもりで
をいじって、
を
。あるいは debug build が
た
たキャッシュが
て、次に
が
したときにその
を
。
が
場と
を
ている
、この
は
られません。
並べてみてたのは、この三つは
の
に見えて、
一点に
を持つ、ということでした。
する
と
する
が
だから
。なら、
を物理的に
ればいい。
機は
だけして、
はクリーンな
マシンへ
。とはいえ、もう一台 Mac を買うのは
です。だから VM を使ってみました。
なぜ UTM の VM なのか
UTM は macOS の上で VM を回すです。Apple Silicon なら Apple 仮想化バックエンド(AVF)が使えて、これは
て
。ゲストが
たらスナップショットを
だけ。
機は
のまま。ちょうどよさそうだったので、まず
で
てみました。
ところが、に
ていない
を一つ
ました。
Apple
バックエンドでは
utmctlのip-addressもexecもません。 これらは QEMU バックエンド
の
です。AVF ゲストに
utmctl execをても、
も
てきません。
ていたのと
ました。
ではありません。
と
の
がずれていた、ただそれだけのことです。じゃあどうやってゲストを
か。
は
で、全部 SSH
でやる です。ゲストに
の SSH
(
~/.ssh/vm_rsa)をで、
の
も、
の
も、スクリーンショットの
も、ぜんぶ
ssh と scp でます。
utmctl には、こいつにしかできない
start / stop / status だけ。
手順 — ハーネスの設計判断
ここからがです。
lyra-vm-harness.sh という一本のスクリプトにました。サブコマンド
で、
ごとに
を分けています。
lyra-vm-harness.sh boot <vm> # utmctl start + SSH ポーリング (120s timeout)
lyra-vm-harness.sh run <vm> # swift build → scp → /tmp install → daemon 起動
lyra-vm-harness.sh exec <vm> -- <cmd> # SSH でコマンド実行
lyra-vm-harness.sh capture <vm> [dir] # スクリーンショット + unified log + process sample
lyra-vm-harness.sh restore <vm> # daemon 停止 + brew service 状態を復元
lyra-vm-harness.sh shutdown <vm> # graceful shutdown
で
でいくうちに、四つの
が
ました。「なんでそうなっているのか」が
ないと
しづらい
なので、一つずつ
にしていきます。
1. utmctl exec ではなく SSH を土台にする
になる
はこれだけです。SSH の
を一個の
に
で、あとは
こいつを
ます。
ssh_run() {
local ip="$1"; shift
ssh -i "$LYRA_VM_SSH_KEY" -p "$LYRA_VM_SSH_PORT" \
-o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=10 \
"${LYRA_VM_SSH_USER}@${ip}" "$@"
}
なぜ utmctl exec をないか。
は二つです。
一つはの
、AVF バックエンドでそもそも
ない。これは
的です。もう一つ。
に
たとしても、
utmctl exec は GUI を
します。Homebrew も launchd も AppKit も、
(
PATH や TMPDIR)がていないと
ません。SSH で
として入れば、その
が
から
ています。GUI アプリを
たいなら、GUI
の
ごと
のが
でした。
2. launchctl asuser で GUI にデーモンを挿す
窓を出すには、GUI セッションの中に挿し込む必要がある
これが一番た
です。
SSH で入って、ゲストの中で nohup lyra daemon & とやってみました。PID はてきます。
も
ています。なのに——
が一枚も出ません。
な
に、
ているはずの
だけが
ています。
は、SSH
には window server への
が
からでした。AppKit の
NSWindow は、の GUI
の
でないと
に
を出せません。SSH で
した
は、どの GUI
にも
ない
の
でした。
はこれです。
launchctl asuser で、の GUI bootstrap namespace に
を
ます。まず
/tmp/lyra-vm-launch.sh というスクリプトをゲスト
に
ます。
# /tmp/lyra-vm-launch.sh の内容 (ゲスト側に生成して渡す)
#!/bin/sh
export UV_CACHE_DIR=/tmp/lyra-vm-uv-cache
nohup /tmp/lyra-vm-test/lyra daemon > /tmp/lyra-vm-daemon.log 2>&1 &
echo "$!" > /tmp/lyra-vm-daemon.pid
UV_CACHE_DIR を /tmp にのは、
sudo でした root
が
の
~/.cache に root の
を
ないためです。
と PID を
の
/tmp に
のにも
があって、
sudo は $HOME を /var/root にので、
$HOME を使うと SSH から
ない
になります。
次に id -u での
UID を
て、
sudo launchctl asuser <uid> でそのの GUI namespace の
で
します。
local uid
uid=$(ssh_run "$ip" "id -u")
ssh_run "$ip" "sudo launchctl asuser $uid /tmp/lyra-vm-launch.sh"
これでて
NSWindow が window server にて、
に
が出ました。UID を
せず
id -u でているのは、ゲストの
が
ても
ないようにです。
が
ても
にしておきます。
3. /tmp への隔離インストール
ゲストにも brew でた
の lyra がいます。これを debug build で**
に
しない。**
したら、ゲストの clean state が
て、restore の
が
ます。だから debug build は
/tmp のディレクトリに
install します。
# ホストでビルドしてゲストの /tmp/lyra-drop に転送
swift build -c release
scp -i "$LYRA_VM_SSH_KEY" .build/release/lyra "${LYRA_VM_SSH_USER}@${ip}:/tmp/lyra-drop/"
# Bundle.module lookup に必要な *.bundle も転送
for bundle in .build/release/*.bundle; do
[ -d "$bundle" ] && scp -r -i "$LYRA_VM_SSH_KEY" "$bundle" "${LYRA_VM_SSH_USER}@${ip}:/tmp/lyra-drop/"
done
# /tmp の隔離ディレクトリにインストール(brew 管理バイナリは絶対に触らない)
ssh_run "$ip" "rm -rf /tmp/lyra-vm-test && mkdir -p /tmp/lyra-vm-test"
ssh_run "$ip" "install -m 755 /tmp/lyra-drop/lyra /tmp/lyra-vm-test/lyra"
/tmp にば、ゲストを
するか restore すれば
もなく
ます。brew が
している
には
一本
ていません。
が
の
で
します。
4. kill -0 による起動直後の生死確認
nohup ... & ですると、PID は
に
てきます。とはいえ、
した
に
こともあります。
ミス、
の
、なんでも。PID はあるのに、
はもう
でいる。
ここでの「
」を
したら、
に向かってスクリーンショットを
ことになります。だから 3
て、
だけ
ます。
sleep 3
if ! ssh_run "$ip" "kill -0 '$pid' 2>/dev/null"; then
ssh_run "$ip" "tail -n 40 /tmp/lyra-vm-test/lyra.log" >&2
echo "daemon は起動直後に沈んだ" >&2
exit 1
fi
kill -0 はに
てシグナルを
ません。
が
するかどうかだけを
ます。
ていれば
、
でいれば
。3
の
を
てから
ことで、「
はしたが
た」を
ます。
だなら、
てクラッシュ
を
せて
ます。
は
なほうがいいので。
検証 — スクリーンショットを回収する
が
ていることを
たら、
を
ます。
capture のです。ここでも一つ
を
ました。
うとした
にゲストのディスプレイが
していると、
のは
な
だけでした。
だからに
caffeinate -u -t 3 でディスプレイをて、UTM
の ID を
CGWindowListCopyWindowInfo でて、
screencapture -l <wid> でそのだけ
ます。これはホスト
の
です。
# ホスト側で UTM ウィンドウの window id を取得
utm_wid="$(swift - <<'SWIFT'
import CoreGraphics
let wins = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as! [[String:Any]]
let candidates = wins.compactMap { w -> (Int, Int)? in
guard let owner = w["kCGWindowOwnerName"] as? String, owner == "UTM",
let num = w["kCGWindowNumber"] as? Int,
let bounds = w["kCGWindowBounds"] as? [String:Any],
let w2 = bounds["Width"] as? CGFloat, let h2 = bounds["Height"] as? CGFloat
else { return nil }
return (num, Int(w2 * h2))
}.sorted { $0.1 > $1.1 }
if let best = candidates.first { print(best.0) }
SWIFT
)"
# 撮影直前にディスプレイを起こして、UTM ウィンドウだけキャプチャ
caffeinate -u -t 3 &
screencapture -l "$utm_wid" "$out_dir/screenshot.png"
caffeinate -u -t 3 で 3 だけ
を
てディスプレイを
ます。Swift の
は UTM
を「
」で
ます——VM
の
がいちばん
からです。
capture はだけでなく、
log show の unified log と sample による process sample もに
します。
は「
が出たか」だけでなく、「どう
ているか」まで
てこそ、と
で
てみて
ました。
落とし穴
後始末を trap に縛りつけて、必ず clean state へ戻す
症状: utmctl exec が無言で何も返さない
: Apple
バックエンドでは
utmctl exec / ip-address が。QEMU
です。
:
の
を SSH
に
ます。IP は
で
するか、Bridged networking で DHCP
にすると
でした。
症状: デーモンは生きているのに窓が一枚も出ない
: SSH
には window server
が
。
NSWindow は GUI の
でないと
できません。
:
sudo launchctl asuser $(id -u) <起動スクリプト> で、の GUI namespace に
ます。
症状: スクリーンショットが真っ黒
: キャプチャの
にゲストのディスプレイが
していた。
:
caffeinate -u -t 3 & でにディスプレイを
ます。
症状: 確認の途中で落ちて、状態が散らかったまま残る
ゲストをないのが
なら、
も
くらい
です。一番
のは、
の
でスクリプトが
て、
が
しっぱなし・brew
が
たまま
されることでした。次の
で「あれ、
がおかしい」が始まります。
だから trap で restore を EXIT につけます。
# 確認の典型的な航海:
.claude/scripts/lyra-vm-harness.sh boot "lyra-test-vm"
trap ".claude/scripts/lyra-vm-harness.sh restore 'lyra-test-vm'" EXIT
.claude/scripts/lyra-vm-harness.sh run "lyra-test-vm"
.claude/scripts/lyra-vm-harness.sh capture "lyra-test-vm" ./artifacts
.claude/scripts/lyra-vm-harness.sh shutdown "lyra-test-vm"
restore は二つやります。/tmp の debug を
て、ゲストの brew
を
の
に
ます。これを EXIT trap に
ておけば、スクリプトが
に
ろうが、
で
うが、Ctrl-C で
れようが、
ゲストは clean state に
ます。
すると、
boot のでは
utmctl start のに SSH が
まで
します。macOS ゲストは SSH が
まで 60〜90
かかることがあるので、タイムアウトは 120
おくと
でした。
く
と、まだ
ていないゲストに「
でいる」と
を
ことになります。
次の一手
ここまでで、機を一切
ずに macOS アプリを OS
で
は
ました。
のアプリでやってみるなら、まず
の「
源」を三つ
てみてもいいかもしれません。launchd の
は
か、single-instance
はどこか、
とキャッシュはどの
を
か。それが
ば、
run と restore のを
のアプリ
に
だけです。
てみて
たのは、
さの
は
たより
だった、という一点でした。
そうに見えていたのは、
と
が
で
ていた、ただそれだけ。
を
の
に
たら、三つの
は
に
ました。とはいえ、この VM ハーネスは
が出るところまでで、クリックやキー
といった UI の
までは
ていません。そこまで
なら、また
の
です。
図は
ておきました。あとは
の
で、
に
てみてください。