🧹

【Xcode】シミュレータのゾンビになった不要ランタイムのディスクイメージ削除

に公開

解決したいこと

macOS 上の Xcode 開発環境において、不要なシミュレータドライブ(APFS Volumes)や関連ファイルが蓄積されることでストレージを圧迫したり、起動時に不要なマウントが発生したりする問題を解決する。
現象としては、Xcodeの iOS / iPadOS / tvOS / watchOS / xrOS シミュレータランタイムのディスクイメージが、Mac のストレージを大量に消費し、特に以下のフォルダ配下にある .dmg ファイルが数10GB 単位で数10個、つまり数100GBを占有することもある。

  • /System/Library/AssetsV2/com_apple_MobileAsset_appleTVOSSimulatorRuntime
  • /System/Library/AssetsV2/com_apple_MobileAsset_iOSSimulatorRuntime
  • /System/Library/AssetsV2/com_apple_MobileAsset_watchOSSimulatorRuntime
  • /System/Library/AssetsV2/com_apple_MobileAsset_xrOSSimulatorRuntime

対策案

  1. Finder から直接削除 → システム影響の懸念があるため不採用。
  2. Xcode → Settings → Components メニューで削除 → .dmg ファイルが残存するため不十分。
  3. xcrun simctl runtime delete を用いて不要ランタイムを削除 → 正規手段で安全に削除可能。

解決方法

以下のコマンドでインストール済みランタイムを一覧化した。

xcrun simctl runtime list

たとえば、こんな感じで一覧される。

== Disk Images ==
-- iOS --
iOS 18.4 (22E5216h) - EB213AC6-D0C7-494C-B960-A0888D020599 (Ready)
iOS 18.0 (22A3351) - 5D4E1222-3B4D-4FE2-B62D-A1D5000BE5A9 (Ready)
iOS 17.4 (21E213) - 10683CAC-E667-40D3-AB34-B7C508F9A3A1 (Ready)
iOS 17.0.1 (21A342) - CC2ED6CD-A5FB-41F8-A62A-ADA4C43E6C59 (Ready)
iOS 18.2 (22C150) - 256C95AE-0B0D-4B07-95CD-0380F600A568 (Ready)
iOS 12.0 (16A366) - 690226E9-ACA2-417C-A520-4AEDF888E860 (Ready)
iOS 16.4 (20E247) - CDF2D2EE-DDC8-483B-A5C7-AA1F35801FAF (Ready)
iOS 17.0 (21A5326a) - 220E6084-63BB-4D5D-867E-5DAA0EDE31BE (Ready)
iOS 17.0 (21A5291g) - 028C26F1-B385-4DBD-8D13-626C16CCFCC4 (Ready)
iOS 18.0 (22A5346a) - E80E49E1-10CE-4285-9923-F25A7E315481 (Ready)
iOS 11.4 (15F79) - D47B6F35-67DD-4B70-B8F3-0383BFFC89BA (Ready)
iOS 26.0 (23A5308g) - C7631006-779D-483A-AE06-78C007666E49 (Ready)
iOS 18.4 (22E238) - 6405A7E6-DD08-4966-B1A3-CDFA6BCD4EE3 (Ready)
iOS 17.0 (21A5277g) - A66289A0-6F84-414D-B379-0578C16D46F9 (Ready)
iOS 17.5 (21F79) - 112BCC4F-C369-4ED6-A6A9-A3E99DE608C2 (Ready)
iOS 17.2 (21C5029e) - 7E11080E-0182-4CB5-86AE-454FE5B30808 (Ready)
iOS 17.0 (21A5268h) - 6222FA97-552B-41DA-B434-3EA935B9628D (Ready)
iOS 26.0 (23A5260l) - 15C56CD2-A592-4A97-AD4C-07BCEBA2B4BD (Ready)
iOS 17.2 (21C62) - 4CEEA457-94A6-4D3A-A392-3977A3CCC11F (Ready)
iOS 17.2 (21C5046b) - 3E570035-21B4-4D98-922B-595FBD8395FD (Ready)
iOS 17.0 (21A5303d) - 202E5208-EEDA-4F82-B6D7-CE8AF007FCA3 (Ready)
-- tvOS --
tvOS 18.0 (22J356) - 01E63235-64AD-44C5-8D18-DACEC57CB582 (Ready)
tvOS 18.2 (22K154) - 0CF1F4CF-509B-4C73-91E9-384E2391FC03 (Ready)
-- watchOS --
watchOS 10.0 (21R5355a) - C324CC4D-DABF-4867-AFB2-1DA6842AEB70 (Ready)
watchOS 11.0 (22R349) - B03C9E06-BDC6-4618-89D9-858978F11DAA (Ready)
watchOS 10.5 (21T575) - 3EA7C48B-94DC-4E6F-B4B7-05851EDE5C1B (Ready)
watchOS 10.4 (21T214) - D8668730-8EE5-4C6F-9AB2-55A514131803 (Ready)
watchOS 10.0 (21R5320g) - 7CE64B6F-6D2A-4E07-B4C7-28980E0F382D (Ready)
watchOS 11.2 (22S99) - 6B2DF1B5-A7C5-45EA-B426-60188368EAB2 (Ready)
watchOS 10.0 (21R355) - A8A313A3-9B8F-45B7-A79E-F13F7E373C5B (Ready)
-- xrOS --
xrOS 2.4 (22O237) - 12E0EF92-99A8-4FD0-8B2D-5416A43E9E7F (Ready)
xrOS 2.0 (22N318) - C983135E-73B8-45AC-B8D0-62087B1DAA51 (Ready)
xrOS 26.0 (23M5311g) - A53FD054-539A-49DB-8716-06526E03E34A (Ready)
xrOS 2.0 (22N5314a) - 7C4C0037-CDBB-4752-B2CD-CFC19AD03138 (Ready)
xrOS 26.0 (23M5263m) - 5929D270-CB9E-4301-A7EC-9C814CD0C1D8 (Ready)
xrOS 1.1 (21O209) - 4A2DCD8C-95C9-489C-9EB8-1272087E8EEE (Ready)
xrOS 2.2 (22N840) - CA224F4D-C1AB-4351-AD16-E79B71853184 (Ready)
xrOS 1.0 (21N5300a) - 37913758-4475-4F0E-B574-BBE2A9B00475 (Ready)
xrOS 1.0 (21N305) - 5CCEADAF-4C3B-4519-BDDC-61E9479A6530 (Ready)

この中で不要なランタイムを以下のコマンドで削除。
 例)xrOS 1.0 (21N305)を削除する場合

xcrun simctl runtime delete 21N305

削除コマンド発行ごとに /Library/Developer/CoreSimulator/Volumes 内の大容量 .dmg が 1つずつ消える。

結果

  • 数10GB 単位のディスク容量を削減できた。
  • 不要なランタイムを正規の方法で安全に削除できた。
  • Xcode / Simulator の動作に問題なし。

スクリプト化

削除作業を簡略化するため、不要ランタイムを一括削除するスクリプトを作成。

#!/bin/bash
# ============================================================
# 🧹 iOS/tvOS/watchOS/xrOS Simulator Runtime Cleanup Tool
# ============================================================

# List installed runtimes
echo "📋 Listing installed runtimes..."
xcrun simctl runtime list

echo ""
echo "⚠️ This script will delete ALL runtimes except the latest ones."
echo "Press Ctrl+C to cancel or Enter to continue."
read

# Get latest major versions to keep (example: iOS 18, watchOS 11, etc.)
KEEP_VERSIONS=("iOS 18" "tvOS 18" "watchOS 11" "xrOS 26")

# Loop through all runtimes and delete if not in keep list
xcrun simctl runtime list | grep -E "Ready" | while read -r line; do
    UUID=$(echo "$line" | awk '{print $NF}' | tr -d '()')
    NAME=$(echo "$line" | cut -d'-' -f1 | sed 's/ *$//')
    if [[ " ${KEEP_VERSIONS[*]} " =~ " $NAME " ]]; then
        echo "✅ Keeping: $NAME"
    else
        echo "🗑️ Deleting: $NAME ($UUID)"
        xcrun simctl runtime delete "$UUID"
    fi
done

echo "🎉 Cleanup complete."

スクリプトの使い方と注意点

  1. スクリプトを cleanup_sim_runtimes.sh として保存。
  2. 実行権限を付与:
    chmod +x cleanup_sim_runtimes.sh
    
  3. 実行:
    ./cleanup_sim_runtimes.sh
    
  4. KEEP_VERSIONS 配列を編集し、残したい最新バージョンを指定すること。
  5. 誤削除を防ぐため、削除前に確認プロンプトあり。

もっと細かく削除操作を指定できるスクリプト

#!/usr/bin/env bash
set -euo pipefail

# ==========================================================
# SimRuntime Pruner: bulk-delete unnecessary Simulator runtimes
# - Lists installed runtimes via `xcrun simctl runtime list`
# - Supports interactive selection or non-interactive flags
# - Uses official deletion: `xcrun simctl runtime delete <build>`
#
# Requirements:
# - macOS with Xcode command line tools
# - Close Xcode / Simulator before running
#
# Usage (examples):
#   bash simruntime-prune.sh                # interactive mode
#   bash simruntime-prune.sh --list         # show runtimes only
#   bash simruntime-prune.sh --delete-build 22A3351,21C62 --yes
#   bash simruntime-prune.sh --keep-latest-per-platform --yes
#   bash simruntime-prune.sh --platform ios,watchos --keep-latest-per-platform --yes
#   bash simruntime-prune.sh --dry-run --delete-build 22A3351
#
# Flags:
#   --list                         Only list runtimes and exit
#   --platform p1,p2               Filter platforms: ios,tvos,watchos,xros
#   --delete-build b1,b2           Delete specified build IDs
#   --keep-latest-per-platform     Keep newest version per platform, delete others
#   --dry-run                      Show what would be deleted
#   --yes                          Skip confirmation prompts
# ==========================================================

# ---------- helpers ----------
err() { echo "ERROR: $*" >&2; exit 1; }
info() { echo "$*"; }

require_cmd() { command -v "$1" >/dev/null 2>&1 || err "Command not found: $1"; }
require_cmd xcrun
require_cmd awk
require_cmd grep
require_cmd sed
require_cmd sort

PLAT_FILTER=""          # csv of platforms to include: ios,tvos,watchos,xros
DELETE_BUILDS=""        # csv of build IDs to delete
LIST_ONLY=false
KEEP_LATEST_PER_PLATFORM=false
DRY_RUN=false
ASSUME_YES=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    --list) LIST_ONLY=true ;;
    --platform) shift; PLAT_FILTER="${1:-}";;
    --delete-build) shift; DELETE_BUILDS="${1:-}";;
    --keep-latest-per-platform) KEEP_LATEST_PER_PLATFORM=true ;;
    --dry-run) DRY_RUN=true ;;
    --yes) ASSUME_YES=true ;;
    -h|--help)
      sed -n '1,60p' "$0" | sed -e 's/^# \{0,1\}//'
      exit 0
      ;;
    *) err "Unknown argument: $1";;
  esac
  shift
done

# Convert platform filter to regex if provided
PLAT_RE=""
if [[ -n "$PLAT_FILTER" ]]; then
  # normalize to lowercase, split commas
  lower="$(echo "$PLAT_FILTER" | tr '[:upper:]' '[:lower:]')"
  # validate tokens
  IFS=',' read -r -a toks <<< "$lower"
  ok_re='^(ios|tvos|watchos|xros)$'
  tmp=""
  for t in "${toks[@]}"; do
    [[ "$t" =~ $ok_re ]] || err "Invalid platform: $t (allowed: ios,tvos,watchos,xros)"
    tmp+="$t|"
  done
  PLAT_RE="^($(echo "$tmp" | sed 's/|$//'))$"
fi

# ---------- fetch & parse runtimes ----------
# We parse lines like:
#   iOS 18.4 (22E5216h) - EB21... (Ready)
# Extract fields: platform|version|build|uuid
RAW_LIST="$(xcrun simctl runtime list || true)"

PARSED="$(
  echo "$RAW_LIST" \
  | grep -E '^(iOS|tvOS|watchOS|xrOS) ' \
  | sed -E 's/^(iOS|tvOS|watchOS|xrOS) ([^ ]+) \(([A-Za-z0-9]+)\) - ([A-F0-9-]+).*/\1|\2|\3|\4/gI' \
  | awk -F'|' '
      {
        # Normalize platform to lowercase: ios,tvos,watchos,xros
        p=tolower($1)
        # Keep version as-is, but ensure sortable form exists as key
        v=$2
        b=$3
        u=$4
        print p "|" v "|" b "|" u
      }'
)"

if [[ -z "$PARSED" ]]; then
  err "No runtimes found by simctl. Is Xcode installed?"
fi

# Apply platform filter if requested
if [[ -n "$PLAT_RE" ]]; then
  PARSED="$(echo "$PARSED" | awk -F'|' -v re="$PLAT_RE" '$1 ~ re')"
  [[ -n "$PARSED" ]] || err "No runtimes match platform filter: $PLAT_FILTER"
fi

# ---------- listing ----------
print_table() {
  # Pretty print with index
  awk -F'|' '
    BEGIN { printf("%-4s %-8s %-12s %-12s %-36s\n", "No.", "Plat", "Version", "Build", "UUID");
            print "--------------------------------------------------------------------------" }
    { printf("%-4d %-8s %-12s %-12s %-36s\n", NR, $1, $2, $3, $4) }
  ' <<< "$PARSED"
}

if $LIST_ONLY; then
  info "Installed Simulator Runtimes:"
  print_table
  exit 0
fi

# ---------- build deletion set ----------
to_delete_builds=()

# Mode A: explicit build IDs
if [[ -n "$DELETE_BUILDS" ]]; then
  IFS=',' read -r -a bs <<< "$DELETE_BUILDS"
  for b in "${bs[@]}"; do
    b_trim="$(echo "$b" | xargs)"
    [[ -n "$b_trim" ]] && to_delete_builds+=("$b_trim")
  done
fi

# Mode B: keep-latest-per-platform
# We select the highest semantic version per platform and mark others for deletion.
semver_key() {
  # Convert "18.4" or "17.0.1" to comparable key: 00018.00004.00001
  local v="$1"
  IFS='.' read -r a b c <<< "$v"
  a=${a:-0}; b=${b:-0}; c=${c:-0}
  printf "%05d.%05d.%05d" "$a" "$b" "$c"
}

if $KEEP_LATEST_PER_PLATFORM; then
  # Build arrays per platform
  mapfile -t rows < <(echo "$PARSED")
  declare -A best_key
  declare -A best_build
  declare -A best_line

  for row in "${rows[@]}"; do
    plat="${row%%|*}"
    rest="${row#*|}"
    ver="${rest%%|*}"
    rest="${rest#*|}"
    build="${rest%%|*}"
    key="$(semver_key "$ver")"

    if [[ -z "${best_key[$plat]+x}" || "$key" > "${best_key[$plat]}" ]]; then
      best_key[$plat]="$key"
      best_build[$plat]="$build"
      best_line[$plat]="$row"
    fi
  done

  # Anything not the best per platform → delete
  while IFS='|' read -r plat ver build uuid; do
    if [[ "${best_build[$plat]:-}" != "$build" ]]; then
      to_delete_builds+=("$build")
    fi
  done <<< "$PARSED"
fi

# Mode C: interactive (default when nothing specified)
if [[ ${#to_delete_builds[@]} -eq 0 ]]; then
  info "Installed Simulator Runtimes:"
  print_table
  echo
  read -r -p "Enter No. to delete (e.g. 1,3-5) or press Enter to cancel: " sel
  if [[ -z "${sel// }" ]]; then
    info "No selection. Exit."
    exit 0
  fi
  # expand selection like "1,3-5"
  expand_selection() {
    local s="$1"
    local out=()
    IFS=',' read -r -a parts <<< "$s"
    for p in "${parts[@]}"; do
      if [[ "$p" =~ ^[0-9]+-[0-9]+$ ]]; then
        IFS='-' read -r a z <<< "$p"
        for ((i=a; i<=z; i++)); do out+=("$i"); done
      elif [[ "$p" =~ ^[0-9]+$ ]]; then
        out+=("$p")
      fi
    done
    printf "%s\n" "${out[@]}"
  }

  mapfile -t idxs < <(expand_selection "$sel")
  # get selected builds
  n=0
  while IFS='|' read -r plat ver build uuid; do
    n=$((n+1))
    for i in "${idxs[@]}"; do
      if [[ "$i" -eq "$n" ]]; then
        to_delete_builds+=("$build")
      fi
    done
  done <<< "$PARSED"

  if [[ ${#to_delete_builds[@]} -eq 0 ]]; then
    info "No valid selection. Exit."
    exit 0
  fi
fi

# Deduplicate builds
if [[ ${#to_delete_builds[@]} -gt 0 ]]; then
  mapfile -t to_delete_builds < <(printf "%s\n" "${to_delete_builds[@]}" | awk '!seen[$0]++')
fi

# Show summary
summary_table() {
  local builds_set="$1"
  awk -F'|' -v list="$builds_set" '
    BEGIN {
      split(list, arr, ",");
      for (i in arr) want[arr[i]]=1;
      printf("Will delete:\n");
      printf("%-8s %-12s %-12s %-36s\n", "Plat", "Version", "Build", "UUID");
      print "---------------------------------------------------------------"
    }
    want[$3]==1 { printf("%-8s %-12s %-12s %-36s\n", $1, $2, $3, $4) }
  ' <<< "$PARSED"
}

builds_csv="$(IFS=','; echo "${to_delete_builds[*]}")"
if [[ -z "$builds_csv" ]]; then
  info "Nothing to delete. Exit."
  exit 0
fi

summary_table "$builds_csv"
echo

if $DRY_RUN; then
  info "[DRY-RUN] No changes will be made."
  exit 0
fi

if ! $ASSUME_YES; then
  read -r -p "Proceed with deletion? [y/N] " ans
  [[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]] || { info "Cancelled."; exit 0; }
fi

# ---------- deletion ----------
for b in ${builds_csv//,/ }; do
  info "Deleting runtime build: $b"
  if ! xcrun simctl runtime delete "$b"; then
    err "Failed to delete build: $b (try closing Xcode/Simulator and retry)"
  fi
done

info "Done."

スクリプトの使い方

  1. インタラクティブ(番号で選んで一括削除)
    bash simruntime-prune.sh

  2. 一覧だけ表示
    bash simruntime-prune.sh --list

  3. ビルド番号で一括削除(確認省略)
    bash simruntime-prune.sh --delete-build 22A3351,21C62 --yes

  4. プラットフォームごとに「最新だけ残す」(他は削除)
    bash simruntime-prune.sh --keep-latest-per-platform --yes

  5. iOS と watchOS だけ対象にして「最新だけ残す」
    bash simruntime-prune.sh --platform ios,watchos --keep-latest-per-platform --yes

  6. まずはドライラン(削除せずに対象確認)
    bash simruntime-prune.sh --delete-build 22A3351 --dry-run

考察

  • xcrun simctl runtime delete は公式に用意された手段のため、システムへの悪影響がない。
  • .dmg を直接削除するよりも安全で、依存関係の整合性も保たれる。
  • Xcode のベータ利用などでランタイムが大量に溜まる前に、定期的にスクリプトを実行して整理するのが望ましい。

Discussion