Linuxでディレクトリを再帰的にコピーする詳細ガイド

ディレクトリの再帰コピーでは、既存の宛先へ重ねず、コピー元の境界と属性を固定してから新規宛先を一度だけ採用します。app-v2を新規rootとして作るためapp-v1/.をstageへ複製し、既存app-v2へのnest・merge・overwriteを禁止します。隠し項目、通常ファイル、ディレクトリ、シンボリックリンクをNUL区切りのinventoryとSHA-256で比較し、開始前が「宛先なし」だったことまで復旧条件に含めます。

目次

実行前の境界と停止条件

再帰コピー用の検証環境にはGNU cp、find、sha256sum、Python 3とLinuxのrenameat2(RENAME_NOREPLACE)が必要です。app-v2が空に見えても既に存在すれば開始せず、sentinelやdotfile、別runの作業領域をapp-v1の内容で上書き・併合しません。後続の4コードブロックは、変数とrun_idを引き継ぐ同じ管理用Bashセッションで順番どおり実行します。

set -Eeuo pipefail
source=/srv/lab/app-v1
target=/srv/lab/app-v2
parent=$(dirname -- "$target")
test -d "$source" && test ! -L "$source"
test -d "$parent" && test ! -L "$parent"
test ! -e "$target" || { printf 'STOP: destination already exists: %s\n' "$target" >&2; exit 20; }
command -v python3 >/dev/null
if find "$source" -xdev -mindepth 1 ! -type f ! -type d ! -type l -print -quit | grep -q .; then
  printf 'STOP: special file found; design a separate copy policy.\n' >&2
  exit 21
fi
tree_manifest() {
  local tree=$1 output=$2 marker_name=${3-}
  (
    cd -- "$tree"
    find . -xdev -mindepth 1 -type d ! -name "$marker_name" -printf 'd\t%m\t%U\t%G\t%P\0' | sort -z
    find . -xdev -mindepth 1 -type l ! -name "$marker_name" -printf 'l\t%m\t%U\t%G\t%P\t%l\0' | sort -z
    find . -xdev -mindepth 1 -type f ! -name "$marker_name" -print0 | sort -z | xargs -0 -r sha256sum --zero --
  ) > "$output"
}

同一ファイルシステムのステージへコピーする

app-v2の親に作成したrun専用stageだけを再帰コピーの書込み先にし、app-v1/.で最上位dotfileを含む内容を複製します。source側のdirectory entry、link種別、file hashをNUL-safe inventoryで照合し、stageがapp-v1の再帰的な複製と証明できるまでatomic adoptを実行しません。

atomic_noreplace() {
  python3 - "$1" "$2" <<'PY'
import ctypes, os, sys
src, dst = map(os.fsencode, sys.argv[1:])
libc = ctypes.CDLL(None, use_errno=True)
try:
    renameat2 = libc.renameat2
except AttributeError:
    raise SystemExit("renameat2 is unavailable; no non-atomic fallback is allowed")
renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
if renameat2(-100, src, -100, dst, 1) != 0:  # RENAME_NOREPLACE
    number = ctypes.get_errno()
    raise OSError(number, os.strerror(number), os.fsdecode(dst))
PY
}
run_id=$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM
work=$(mktemp -d -p "$parent" ".ittrip-115.$run_id.XXXXXXXX")
printf '%s\n' "$run_id" > "$work/.owner"
stage="$work/stage"
marker=".ittrip-owner-$run_id"
mkdir -- "$stage"
printf '%s\n' "$run_id" > "$stage/$marker"
tree_manifest "$source" "$work/source.manifest" "$marker"
cp --archive -- "$source/." "$stage/"
tree_manifest "$stage" "$work/stage.manifest" "$marker"
cmp --silent "$work/source.manifest" "$work/stage.manifest"
atomic_noreplace "$stage" "$target"
test ! -e "$stage" && test -f "$target/$marker"

採用後の完全一致を検証する

app-v2の採用はRENAME_NOREPLACEで一度だけ行うため、承認後に同名pathが現れた場合も置換せず失敗します。run所有markerを比較対象から外したsource・stage・target照合が一致した後にmarkerだけを除き、再帰コピー後のmanifest SHA-256とreceiptへrun_idを固定して保存します。

tree_manifest "$target" "$work/target.manifest" "$marker"
cmp --silent "$work/source.manifest" "$work/target.manifest"
rm -- "$target/$marker"
tree_manifest "$target" "$work/final.manifest" ''
cmp --silent "$work/source.manifest" "$work/final.manifest"
manifest_sha=$(sha256sum -- "$work/final.manifest"); manifest_sha=${manifest_sha%% *}; manifest_sha=${manifest_sha#\\}
receipt="$parent/.ittrip-115-$run_id.receipt"
( umask 077; set -o noclobber; printf 'target=%s\nmanifest_sha=%s\nrun_id=%s\n' "$target" "$manifest_sha" "$run_id" > "$receipt" )
printf 'accepted target=%s manifest_sha=%s receipt=%s\n' "$target" "$manifest_sha" "$receipt"

今回作成した宛先だけを復旧する

再帰コピーを戻す前に、receiptに記録したapp-v2、run_id、manifest SHA-256を現在値と照合します。第三者変更や未知のentryがあればquarantineへ進まず、開始前から存在したpathには触れません。全条件が一致したrun-owned app-v2だけをno-replaceでreview領域へ移し、コピー先が存在しなかった開始状態を復元します。

# 同じBashセッションで、今回作成したtargetを取り消す場合だけ実行する。
test -f "$receipt" && test ! -L "$receipt"
grep -Fx "target=$target" "$receipt" >/dev/null
grep -Fx "run_id=$run_id" "$receipt" >/dev/null
tree_manifest "$target" "$work/rollback-current.manifest" ''
current_sha=$(sha256sum -- "$work/rollback-current.manifest"); current_sha=${current_sha%% *}; current_sha=${current_sha#\\}
grep -Fx "manifest_sha=$current_sha" "$receipt" >/dev/null
quarantine="$target.failed-$run_id"
test ! -e "$quarantine"
atomic_noreplace "$target" "$quarantine"
test ! -e "$target" && test -d "$quarantine"
rm -- "$receipt"
printf 'restored pre-state: destination is absent; run-owned copy is quarantined at %s\n' "$quarantine"

受入条件

再帰コピーの受入試験は三段階で記録します。既存app-v2を置いた試験ではexit 20とsentinelのbyte・hash不変を確認し、新規宛先の試験ではapp-v1・stage・app-v2のdotfileを含むmanifestを一致させます。復旧試験ではapp-v2が消え、quarantineのmanifestが採用時と同じであることを確認します。source更新が並行する環境では結果を採用せず、snapshot取得またはアプリ停止後にrunを作り直します。

よくある誤り

app-v1直下をcp -r app-v1/*で列挙するとdotfileが欠落するため、本手順はsource/.をcp –archiveでstageへ複製します。既存app-v2への直接コピーはmergeやoverwriteになるので禁止し、renameの原子性を保つためstageは必ずtargetと同じparent filesystemのmktempで作成します。

公式情報・参考資料

ディレクトリの再帰コピーの構文と制約は、本文末の一次資料と対象環境のlocal helpで照合します。ディレクトリの再帰コピーの記事確認日は2026年7月17日で、版が異なる場合はoption、default、終了statusの差を先に確認してください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次