外付けHDDからiCloudへ安全に移行する並列スクリプトを作りました
1. はじめに
昨年 Mac mini 2024 を購入しました。
今回は メモリを優先して増設し、SSDは 256GB にしました。
その結果、ローカルストレージはかなり小さくなりました。
そこで問題になるのが iCloud Drive へのデータ移行でした。
今回移行したかったのは、外付けHDDに保存していた以下のようなデータです。
- ラジオ音源
- 写真
- 動画
- バックアップデータ
これらは数百GB規模になります。
しかし 256GBのSSDでは一度にiCloud同期するとローカルキャッシュが溢れてしまいます。
つまり
- コピーする
- iCloudにアップロードされる
- ローカルキャッシュを削除する
という処理を 安全に繰り返す必要がありました。
そこで今回
外付けHDD → iCloud Drive
の移行を安全に行う 並列処理スクリプトを作成しました。
この記事では
- 設計思想
- 実装
- コードの工夫
について詳しく説明します。
2. 目的
このスクリプトの目的は以下の通りです。
- 外付けHDDからiCloud Driveへデータ移行する
- ハッシュ検証で安全にコピーする
- 並列処理で高速化する
- iCloudアップロード完了を検知する
- ローカルキャッシュを自動削除する
- エラー時に再実行できるようにする
特に重要なのは
256GB SSD環境で安全にiCloud移行を行うこと
です。
3. 環境
今回の前提環境は以下です。
| 項目 | 内容 |
|---|---|
| OS | macOS |
| マシン | Mac mini 2024 |
| SSD | 256GB |
| 外付けHDD | USB接続 |
| iCloud | iCloud Drive |
| Shell | bash (Homebrew) |
Homebrew bashを使うため、shebang は以下にしています。
#!/opt/homebrew/bin/bash
4. スクリプトの設計思想
このスクリプトは以下の設計思想で作られています。
4.1. 安全性最優先
単純に cp でコピーすると
- コピー失敗
- 転送途中エラー
- 破損
などを検知できません。
そのため
SHA256ハッシュを使った完全一致確認
を行っています。
src_hash=$(shasum -a 256 "$src")
dst_hash=$(shasum -a 256 "$dst")
4.2. 並列処理で高速化
ファイル数が多い場合、逐次コピーでは非常に時間がかかります。
そのため
GNU Parallel
を利用して並列コピーを行います。
parallel -j 4
4.3. 再実行可能な設計
途中で
- ネットワークエラー
- iCloud同期問題
が発生する可能性があります。
そのため
失敗ファイルログを保存
し、再実行できるようにしています。
4.4. iCloudアップロード完了を検知
iCloud同期中にキャッシュ削除を行うと
同期に失敗する可能性があります。
そこで
brctl status
を監視し、
Uploading が 0 になるまで待機
するようにしています。
brctl とは
macOS には brctl という CloudDocs 制御コマンドが存在します。
これは iCloud Drive の同期状態を確認したり、
ローカルキャッシュを削除するための内部ツールです。
例えば次のコマンドで現在の同期状態を確認できます。
brctl status
詳しくは以下を参照してください。
man brctl
4.5. ローカルキャッシュ削除
iCloud Driveはローカルキャッシュを保持します。
SSD容量を節約するため、
brctl evict
で削除しています。
a
5. スクリプト解説
5.1. スクリプト全体
まず全体コードです。
#!/opt/homebrew/bin/bash
set -euo pipefail
set -euo pipefail は
- エラー時停止
- 未定義変数エラー
- パイプエラー検知
を有効にする安全設定です。
5.2. ディレクトリ設定
以下の変数を自分の環境に合わせて変更してください。
| 変数 | 説明 |
|---|---|
| SRC_DIR | コピー元ディレクトリ(外付けHDDなど) |
| DST_DIR | iCloud Drive内のコピー先 |
| TARGET_DIR | iCloud Driveのルート |
# コピー元(外付けHDDなど)
SRC_DIR="/path/to/source"
# コピー先(iCloud Drive)
DST_DIR="$HOME/Library/Mobile Documents/com~apple~CloudDocs/path/to/destination"
# iCloud Drive root
ICLOUD_ROOT="$HOME/Library/Mobile Documents/com~apple~CloudDocs"
ここでは
- 外付けHDD
- iCloud Drive
を指定しています。
5.3. ログ設計
ログはすべて時刻付きで保存します。
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOG_FILE="$BASE_LOG_DIR/icloud_migrate_$TIMESTAMP.log"
FAIL_LOG="$BASE_LOG_DIR/icloud_migrate_failed_$TIMESTAMP.log"
ログの種類は以下です。
| ログ | 用途 |
|---|---|
| LOG_FILE | 通常ログ |
| FAIL_LOG | コピー失敗 |
| OVERWRITE_LOG | 上書き |
| JOB_LOG | 並列ログ |
5.4. iCloudアップロード待機
wait_for_icloud_upload() {
uploading=$(brctl status | grep -c "Uploading")
}
Uploading が 0になるまで待機します。
さらに
2回連続で0
になることを確認しています。
これは
瞬間的に0になるケースを防ぐためです。
5.5. コピー処理
コピー処理の中核は以下です。
copy_file() {
file="$1"
src="$SRC_DIR/$file"
dst="$DST_DIR/$file"
5.5.1. ディレクトリ作成
mkdir -p "$(dirname "$dst")"
コピー先ディレクトリを事前に作成します。
5.5.2. ハッシュ一致チェック
src_hash=$(shasum -a 256 "$src")
dst_hash=$(shasum -a 256 "$dst")
一致する場合は
SKIP
します。
5.5.3. コピー
コピーには rsync を使用します。
rsync -a -- "$src" "$dst"
理由は以下です。
- 権限を保持できる
- 安定している
5.5.4. コピー後ハッシュ検証
コピー後にも
hash check
を行います。
これは
コピー破損を防ぐためです。
5.6. 並列コピー
並列処理は以下のように行います。
printf '%s\0' "${files_to_process[@]}" | \
parallel -0 -j "$PARALLEL" copy_file {}
ポイントは以下です。
5.6.1. NULL区切り
\0
にすることで
スペースを含むファイル名にも対応できます。
5.7. キャッシュ削除
コピー後は以下でキャッシュ削除を行います。
delete_cache() {
brctl evict "$file"
}
5.8. .icloudファイル削除
iCloudは
xxx.icloud
というファイルを作成します。
これを削除します。
find "$DST_DIR" -type f -name "*.icloud"
6. このスクリプトの工夫点
6.1. 並列処理 + 安全コピー
通常は
cp
でコピーしますが、
- rsync
- hash
- parallel
を組み合わせています。
6.2. 再実行可能設計
FAIL_LOG を使うことで
再コピーが可能になります。
6.3. iCloud同期監視
brctl status
を監視しています。
6.4. ローカルキャッシュ削除
brctl evict
を利用してSSD容量を節約しています。
7. 実行方法
bash icloud_migrate.sh [LIMIT] [PARALLEL]
例
bash icloud_migrate.sh 100 4
意味は以下の通りです。
| 引数 | 意味 |
|---|---|
| LIMIT | 処理ファイル数 |
| PARALLEL | 並列数 |
8. まとめ
今回、
外付けHDD → iCloud Drive
移行のために
- ハッシュ検証
- 並列コピー
- iCloud同期監視
- キャッシュ削除
を組み合わせたスクリプトを作成しました。
特に
小容量SSD環境でのiCloud移行
には非常に有効だと思います。
同じ問題に困っている方の参考になれば嬉しいです。
9. 完成スクリプト
#!/opt/homebrew/bin/bash
set -euo pipefail
########################################
# 設定
########################################
# コピー元(外付けHDDなど)
SRC_DIR="/path/to/source"
# コピー先(iCloud Drive)
DST_DIR="$HOME/Library/Mobile Documents/com~apple~CloudDocs/path/to/destination"
# iCloud Drive root
ICLOUD_ROOT="$HOME/Library/Mobile Documents/com~apple~CloudDocs"
BASE_LOG_DIR="$(pwd)/logs"
mkdir -p "$BASE_LOG_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOG_FILE="$BASE_LOG_DIR/icloud_migrate_$TIMESTAMP.log"
FAIL_LOG="$BASE_LOG_DIR/icloud_migrate_failed_$TIMESTAMP.log"
OVERWRITE_LOG="$BASE_LOG_DIR/icloud_migrate_overwritten_$TIMESTAMP.log"
JOB_LOG="$BASE_LOG_DIR/parallel_$TIMESTAMP.log"
COPIED_FILE_LIST="$BASE_LOG_DIR/copied_files.txt"
touch "$LOG_FILE" "$FAIL_LOG" "$OVERWRITE_LOG" "$JOB_LOG" "$COPIED_FILE_LIST"
LIMIT="${1:-0}"
PARALLEL="${2:-4}"
{
echo "===== iCloud Migration (Parallel) ====="
echo "Start: $(date)"
echo "Source: $SRC_DIR"
echo "Destination: $DST_DIR"
echo "Limit: $LIMIT"
echo "Parallel: $PARALLEL"
echo "----------------------------------------"
} | tee -a "$LOG_FILE"
cd "$SRC_DIR"
########################################
# iCloudアップロード待機
########################################
wait_for_icloud_upload() {
echo "Waiting for iCloud upload to finish..." | tee -a "$LOG_FILE"
zero_count=0
while true; do
uploading=$(brctl status 2>/dev/null | grep -c "Uploading" || true)
if [ "$uploading" -eq 0 ]; then
zero_count=$((zero_count+1))
else
zero_count=0
fi
if [ "$zero_count" -ge 2 ]; then
echo "iCloud upload finished." | tee -a "$LOG_FILE"
break
fi
echo "Uploading: $uploading items..." | tee -a "$LOG_FILE"
sleep 15
done
}
########################################
# コピー処理
########################################
copy_file() {
file="$1"
src="$SRC_DIR/$file"
dst="$DST_DIR/$file"
mkdir -p "$(dirname "$dst")"
if [ -f "$dst" ]; then
src_hash=$(shasum -a 256 "$src" | awk '{print $1}')
dst_hash=$(shasum -a 256 "$dst" | awk '{print $1}')
if [ "$src_hash" = "$dst_hash" ]; then
echo "SKIP (hash match): $src" >> "$LOG_FILE"
echo "$dst" >> "$COPIED_FILE_LIST"
return
else
echo "OVERWRITE (hash differ): $src" >> "$OVERWRITE_LOG"
fi
fi
if ! rsync -a -- "$src" "$dst"; then
echo "FAIL COPY: $src" >> "$FAIL_LOG"
return
fi
# コピー後ハッシュ確認
src_hash=$(shasum -a 256 "$src" | awk '{print $1}')
dst_hash=$(shasum -a 256 "$dst" | awk '{print $1}')
if [ "$src_hash" != "$dst_hash" ]; then
echo "HASH MISMATCH: $src" >> "$FAIL_LOG"
return
fi
echo "$dst" >> "$COPIED_FILE_LIST"
echo "DONE: $src" >> "$LOG_FILE"
}
########################################
# キャッシュ削除処理(並列)
########################################
delete_cache() {
file="$1"
brctl evict "$file" >/dev/null 2>&1 || true
echo "EVICTED: $file" >> "$LOG_FILE"
}
export -f copy_file delete_cache
export SRC_DIR DST_DIR LOG_FILE FAIL_LOG OVERWRITE_LOG BASE_LOG_DIR COPIED_FILE_LIST
########################################
# ファイルリスト生成
########################################
recopy_files=()
if [ -f "$FAIL_LOG" ]; then
mapfile -t recopy_files < <(
sed 's/^FAILED: //' "$FAIL_LOG" | grep -v '^$'
)
fi
new_files=()
mapfile -t new_files < <(
find . -type f -not -path "*/.*" -print0 |
while IFS= read -r -d '' f; do
f="${f#./}" # ./ を削除
dst_file="$DST_DIR/$f"
if [ -f "$dst_file" ]; then
src_hash=$(shasum -a 256 "$SRC_DIR/$f" | awk '{print $1}')
dst_hash=$(shasum -a 256 "$dst_file" | awk '{print $1}')
[ "$src_hash" != "$dst_hash" ] && printf '%s\n' "$f"
else
printf '%s\n' "$f"
fi
done
)
if [ "$LIMIT" -gt 0 ]; then
recopy_files=("${recopy_files[@]:0:$LIMIT}")
remaining=$((LIMIT - ${#recopy_files[@]}))
[ "$remaining" -gt 0 ] && new_files=("${new_files[@]:0:$remaining}") || new_files=()
fi
files_to_process=("${recopy_files[@]}" "${new_files[@]}")
########################################
# 並列コピー
########################################
printf '%s\0' "${files_to_process[@]}" | parallel -0 -j "$PARALLEL" --joblog "$JOB_LOG" copy_file {}
########################################
# iCloud の同期完了待機
########################################
wait_for_icloud_upload
########################################
# キャッシュ削除(コピー済み + 既存ファイル)
########################################
mapfile -t copied_files < "$COPIED_FILE_LIST"
mapfile -t existing_files < <(find "$DST_DIR" -type f)
all_files_to_evict=("${copied_files[@]}" "${existing_files[@]}")
printf '%s\0' "${all_files_to_evict[@]}" | parallel -0 -j "$PARALLEL" --lb delete_cache {}
########################################
# 最終的に .icloud ファイル
########################################
echo "Deleting iCloud local cache in: $DST_DIR"
find "$DST_DIR" -type f -name "*.icloud" | while read -r file; do
echo "Evicting local cache for: $file"
if ! brctl evict "$file" 2>/dev/null; then
echo "WARN: failed to evict $file" >> "$LOG_FILE"
fi
done
echo "iCloud local cache cleanup completed."
########################################
# 終了ログ
########################################
{
echo ""
echo "----------------------------------------"
echo "Completed: $(date)"
echo "Main Log: $LOG_FILE"
echo "Parallel Job Log: $JOB_LOG"
echo "Failed List: $FAIL_LOG"
echo "Overwrite Log: $OVERWRITE_LOG"
echo "===== DONE ====="
} | tee -a "$LOG_FILE"
Discussion