😺

外付けHDDからiCloudへ安全に移行する並列スクリプトを作りました

に公開

1. はじめに

昨年 Mac mini 2024 を購入しました。

今回は メモリを優先して増設し、SSDは 256GB にしました。
その結果、ローカルストレージはかなり小さくなりました。

そこで問題になるのが iCloud Drive へのデータ移行でした。

今回移行したかったのは、外付けHDDに保存していた以下のようなデータです。

  • ラジオ音源
  • 写真
  • 動画
  • バックアップデータ

これらは数百GB規模になります。

しかし 256GBのSSDでは一度にiCloud同期するとローカルキャッシュが溢れてしまいます。

つまり

  • コピーする
  • iCloudにアップロードされる
  • ローカルキャッシュを削除する

という処理を 安全に繰り返す必要がありました。

そこで今回

外付けHDD → iCloud Drive

の移行を安全に行う 並列処理スクリプトを作成しました。

この記事では

  • 設計思想
  • 実装
  • コードの工夫

について詳しく説明します。


2. 目的

このスクリプトの目的は以下の通りです。

  1. 外付けHDDからiCloud Driveへデータ移行する
  2. ハッシュ検証で安全にコピーする
  3. 並列処理で高速化する
  4. iCloudアップロード完了を検知する
  5. ローカルキャッシュを自動削除する
  6. エラー時に再実行できるようにする

特に重要なのは

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