ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux cp命令复制文件避坑:目录递归、软硬链接与覆盖策略

Linux cp命令复制文件避坑:目录递归、软硬链接与覆盖策略 第一次被cp坑是给一台跑了三年多的日志服务器做归档。当时我敲的是cp -r /data/logs /backup/logs路径写得挺完整心想不会有事复制完打开/backup/logs一看里面躺着一个叫logs的子目录真正的日志在第三层。第二天换了个写法用cp -r /data/logs/* /backup/logs/层级总算对了可核对文件数时发现少了一大截——.archive这种点开头的目录被通配符漏掉了而且因为没保留时间戳后面所有靠 mtime 判断增量的备份工具又老老实实全量重来了一遍。就是这两次让我把 Linux 命令行下复制文件这件事重新当回事。cp看起来是个人人都会的命令但它在目标是目录还是文件要不要跟软链接属性保不保留覆盖还是跳过这几个维度上的语义分支比大多数人记忆里的版本复杂得多。这篇内容围绕 Linux 命令行复制文件展开把我在实际运维和脚本里最常遇到的六个场景拆开讲清楚每个场景用哪条命令、为什么这么选、容易在哪一步翻车、翻车之后怎么查。不管你是刚接触 Linux 命令行的新手还是写了几年 shell 脚本但总觉得cp有点玄学的老手都能从里面找到能直接抄走的写法。1. 复制文件这件事为什么命令行版本值得花时间搞懂1.1 cp 的参数位置决定了它是复制还是覆盖cp的语法短到只有cp [选项] 源 目标看上去没什么可讲的但它的行为完全取决于目标当时的状态目标不存在就新建目标是一个已存在的普通文件就静默覆盖目标是一个已存在的目录就把源复制进去、文件名沿用源文件名。这三条规则是所有后续问题的总根源也是我见过的绝大多数文件被莫名其妙改掉事故的直接原因。最典型的翻车是把参数写反。cp /data/config.yml /backup/和cp /backup/config.yml /data/在语法上都成立、都不会报错但一条是备份另一条是拿旧文件把生产配置覆盖掉。更麻烦的是cp没有回收站、没有确认、失败也不会回滚一条命令敲下去原来那份文件就没了。所以我给自己定了个习惯涉及覆盖的复制先在脑子里把源和目标念一遍再回车脚本里则一律先写-b或者显式做一次备份。还有一个容易被忽略的点cp只需要对源有读权限对目标目录有写权限它完全不修改源文件。这一点和mv有本质区别也是把文件复制一份再改别直接改原文件这种保命思路能成立的原因。1.2 一次复制在文件系统里究竟发生了什么cp做的事拆开看大概是按路径找到源文件、打开它、在目标位置创建或截断一个新文件、把数据从源读出来写进去、按需设置权限与时间戳、关闭两个文件句柄。数据路径是磁盘读 → 内核页缓存 → 磁盘写复制 10 GB 的文件实际要过 20 GB 的 IO这还没算元数据写入。理解这一点后面很多为什么这么慢为什么复制完磁盘就满了的疑问就自然有答案了。判断一次复制是不是产生了新文件最直接的证据是 inode 变了。执行ls -i src.file dst.file两个数字不同说明目标是一个全新的文件对象跟源文件只是内容相同如果数字相同那就是硬链接或者干脆是同一个文件。这个技巧在后面讲硬链接和覆盖行为时还会用到。顺带说一个细节新建文件时权限通常是源文件权限去掉 umask 屏蔽掉的位而不是原样照搬。要原样照搬必须显式-p或者-a。很多复制过去的脚本没有执行权限、跑不起来的问题根子就在这里。另外别忘了cp是进程级操作中途被 CtrlC 打断目标目录里会留下一个大小不对、但名字完全正常的半成品文件这一点在排查复制不完整时非常关键。1.3 六个案例的路线图与它们各自解决什么问题下面这六个场景按从简单到复杂、从单文件到批量的顺序排列每个都是独立可用的可以按需跳读。案例典型场景核心命令形态最容易踩的坑案例一单文件复制到指定目录cp -av -- src dst/目录不存在、相对路径基准错案例二复制同时改名、留备份cp -b、for 参数扩展通配符自我匹配、无匹配时字面量案例三整目录搬家cp -a src/. dst/层级多一层、属性丢失案例四软链接、硬链接、特殊文件cp -a/-P/-l链接被解引用、inode 行为案例五只挑一部分文件复制find-exec ... 空格文件名、参数超长案例六覆盖策略、进度与增量-i/-n/-u、rsync别名干扰、时间戳判断不准需要提前说明的是这六个案例里我用得最多的是-a和-v这两个选项几乎成了肌肉记忆。-a保证属性不丢-v保证出问题时有据可查。至于为什么这么组合后面每个案例里都会展开。2. 案例一把单个文件复制到指定位置——最基础的写法也是翻车率最高的2.1 三种最朴素的写法与它们的实际效果看似最简单的单文件复制其实有三种写法效果完全不同值得逐条对比# 写法一目标是目录文件名保持不变 cp -v access.log /backup/ # 结果/backup/access.log # 写法二目标写成一个具体的新文件名等于复制并改名 cp -v access.log /backup/access.log.20240601 # 结果新文件叫 access.log.20240601 # 写法三目标写在当前目录容易被误伤 cp -v access.log access.log.bak # 结果如果 access.log.bak 已存在它会被静默覆盖写法三是我最不推荐的因为它特别容易在反复执行时相互覆盖。第一次跑生成的是当天的备份第二次跑就把第一次的备份盖掉了而且不会有任何提示。要保留多个历史版本就用带时间戳的文件名或者干脆进写法二的思路把日期拼进目标路径cp -v access.log /backup/access.log.$(date %F_%H%M%S)这里还有个小细节值得注意cp的-v输出会打印出每一次复制动作。单文件时它只是让人安心批量复制时它就是唯一的操作记录。我在所有涉及生产文件的脚本里都会加上-v并把标准错误和标准输出一起重定向到日志事后追查时能省大量时间。2.2 目标目录不存在时 cp 会怎么处理cp有一个很多人以为是bug的行为它不会自动创建中间目录。执行cp -v app.log /backup/2024/06/app.log如果/backup/2024/06这一级不存在命令直接失败并报No such file or directory而且这个报错信息指向的是目标路径的一部分不存在新手很容易看成源文件找不到。正确的做法是先用mkdir -p铺好目录或者用install一步到位# 方式一先建目录再复制 mkdir -p /backup/2024/06 cp -av app.log /backup/2024/06/ # 方式二install 会自动创建缺失的父目录还能顺手指定权限 install -D -m 0644 app.log /backup/2024/06/app.loginstall -D这个用法我在写部署脚本时用得特别多因为它把建目录 复制 设权限三件事合成了一条命令减少了脚本里的分支判断。需要留意的是install默认会覆盖已存在的目标文件也不会保留源文件的时间戳它的定位是安装文件不是备份文件用在配置文件分发场景最合适。还有一种报错值得单独说cp: target x is not a directory。这通常发生在你给了多个源文件、但最后一个参数不是目录的时候。比如cp a.log b.log backup.logshell 会把backup.log当目标可它不是一个目录命令直接拒绝。这时候要么确认目标目录名写对了要么改成一次只复制一个文件。2.3 脚本里我坚持用绝对路径和显式选项的理由相对路径的解析基准是进程的当前工作目录而不是脚本文件所在的目录。这个区别在手工执行时几乎看不出来因为你在哪个目录敲命令cwd 就是哪个目录但一旦交给计划任务或者持续集成流水线cwd 可能变成/也可能是某个系统目录于是白天手动跑得好好的脚本晚上自动执行就报找不到文件。我现在的固定写法大概长这样#!/bin/bash set -euo pipefail SRC/data/logs/access.log DST/backup/$(date %F)/ mkdir -p $DST cp -av -- $SRC $DST sync几个细节解释一下。set -euo pipefail让脚本在任一命令失败时立即退出避免复制失败了但后面的清理步骤照样执行-a保留属性避免下游的增量工具因为时间戳变化而全量重传--用来终止选项解析万一文件名以短横线开头也不会被当成参数结尾的sync把页缓存里的脏数据刷到磁盘防止机器在几秒后断电时文件还在内存里没落盘——这一步在很多教程里被忽略但我在虚拟机里遇到过不止一次。最后一个提醒源路径尽量写成绝对路径目标路径也写绝对路径。脚本里唯一允许用相对路径的地方是我在脚本开头显式cd到某个固定目录之后。这样任何人在任何地方调用这个脚本行为都完全一致。3. 案例二复制的同时改名——重命名式复制的几种写法3.1 目标位置直接写成新文件名改名式复制的本质就是目标写成一个具体文件名。最简单的用法是给配置做快照改配置文件之前先把当前版本复制成一份带日期的备份。cp -v app.conf app.conf.$(date %F).bak不过这条路走多了会发现一个问题备份文件越积越多而且如果目标名已经存在cp会直接覆盖起不到留一手的作用。这时候用cp自带的备份机制更省事# -b 在覆盖前把已存在的目标文件改名成 xxx~ cp -bv src/app.conf /etc/app/app.conf # --backupnumbered 让备份文件自动编号 cp -v --backupnumbered src/app.conf /etc/app/app.conf # 已存在的旧文件会变成 app.conf.~1~再覆盖一次变成 .~2~这两个选项的意义在于它把备份这个动作绑在了覆盖发生的那一刻只要能覆盖就一定有备份不用额外写判断。相比手写if [ -f dst ]; then cp dst dst.bak; fi再复制出错概率低得多。补充一句-b默认后缀是波浪号~这个后缀在不同系统上可能被某些工具当成临时文件清理掉重要配置我一般用--backupnumbered或直接把日期写进文件名。3.2 用参数扩展做批量改后缀要给一批文件做复制并改后缀for循环加参数扩展是最可靠的方式因为它不依赖任何外部工具任何 Linux 上都一样mkdir -p bak for f in *.conf; do [ -e $f ] || continue cp -v -- $f bak/${f%.conf}.conf.bak done这里用了${f%.conf}含义是从 $f 的右边删掉最短的.conf匹配。参数扩展的几种常见写法值得记一下它们在做批量重命名时比sed更安全因为它不会因为正则写错而改出奇怪的名字写法含义例子fapp.conf${f%.conf}从右删最短匹配app${f%%.*}从右删最长匹配app${f#app.}从左删最短匹配conf${f##*.}从左删最长匹配取后缀conf${f/conf/cfg}替换第一处app.cfg${f//./_}全部替换app_conf两个必须注意的点文件名的变量引用一定要加双引号$f否则碰到带空格的文件名会被拆成两个参数循环体开头那行[ -e $f ] || continue是防御性的防止通配符一个都没匹配到时f变成字面量*.conf然后被当成文件名传给cp报一个让人莫名其妙的文件不存在。如果用的是 bash 4 以上也可以直接shopt -s nullglob让未匹配的通配符展开成空。3.3 通配符自我匹配与覆盖前的备份策略这里有一个我见过无数次的经典翻车想给当前目录所有 txt 文件生成 bak 副本顺手写了cp *.txt *.bak。shell 会先把两个通配符都展开源列表变成a.txt b.txt目标列表变成a.txt.bak b.txt.bak最终命令变成三个以上参数的形态最后一个参数被当成目标。如果它恰好是个已存在的文件夹还算走运否则直接报target b.txt.bak is not a directory。更隐蔽的一种是把目录自己拷进自己。比如cp -a * /backup/执行时如果/backup正好在当前目录下*展开就会包含backup这个目录而它又是目标的一部分于是要么报cannot copy a directory into itself要么在某些版本上真的递归复制把磁盘写满。我现在的习惯是批量复制时源和目标至少有一个写成绝对路径绝不让两者都在当前目录下裸奔。还有一个跟覆盖有关的小技巧值得分享cp覆盖已存在的文件时默认行为是打开原文件并截断重写而不是删掉重建。这意味着目标文件的 inode 不会变。这个特性对硬链接和正在被进程打开的文件影响很大后面案例四会展开讲在这里只需要记住如果你希望目标是全新的一个文件要显式加--remove-destination。4. 案例三整个目录搬家——递归复制与属性保留4.1 为什么我基本只用 cp -a复制目录时最常被拿出来比较的三个选项是-r、-rp、-a。从结果上看它们的关系是这样的选项展开后的含义保留内容cp -r递归复制内容属性基本不管cp -rp递归 保留权限、属主、时间戳cp -a-dR --preserveall上面全部外加链接结构和扩展属性我几乎只用-a最重要的原因不是属性好看而是时间戳。不用-a时复制出来的每个文件 mtime 都变成现在下游任何靠 mtime 判断是否需要同步的工具rsync 是最典型的都会认为目标端是新文件、或者源端全部被改过于是第一次增量同步退化成全量重传。这个坑在处理几十 GB 的目录时非常致命而且症状是同步很慢不会报任何错很难往复制这一步去想。另一个原因是软链接。-a隐含了-d也就是不解引用链接目录树里的链接会被原样搬过去。这一点在部署带链接结构的应用目录时很关键少一个参数可能几百个软链接会全部变成实体文件磁盘占用直接翻几倍。4.2 源目录尾部那个斜杠到底影响什么目录复制最容易搞混的是目标里会不会多一层。规则其实只有两条但和很多人的直觉相反第一种情况目标不存在。此时cp -a src dst的结果是dst成为src的副本内容直接躺在dst下面不会出现dst/src这种结构。很多人第一次看到这个结果都会愣一下因为它和目标存在时的行为不一样。第二种情况目标已存在。此时cp -a src dst会在dst下面新建一个src目录也就是dst/src/...。想要把 src 里的内容平铺进 dst必须用点号或者尾部斜杠配合# 目标存在想要内容平铺进去 cp -a src/. dst/ # 最稳的写法隐藏文件也不会漏 cp -a src/ dst/ # 部分场景可用但语义依赖版本至于源路径尾部的斜杠在源是不是软链接这一件事上影响很大。如果linkdir是一个指向目录的软链接cp -a linkdir dst/在-a的作用下复制的是链接本身而cp -a linkdir/ dst/因为尾部斜杠强制把它按目录解析就会跟进去、复制真实内容。这个差异在同步 Web 根目录、上传目录这类经常用软链接做版本切换的场景里经常是复制完了但少了几百兆的原因。我自己总结的固定写法是目标目录一定先mkdir -p建好源路径一定带/.这样不管目标存不存在行为都是确定的把内容平铺进去。4.3 大目录复制慢在哪小文件、元数据与写放大复制 10 万个 4 KB 小文件用时往往远超复制一个 400 MB 的大文件哪怕总字节数一样。原因在于每个文件都要经历一次 open/create/close每次都要写新的 inode、更新目录项机械盘上还伴随随机寻道网络存储上则是成千上万次往返请求。这也是为什么复制一个包含大量小文件的目录和复制一个大文件是两种完全不同的性能场景。在线上机器上做大目录复制我一般会主动降优先级避免把业务 IO 打满ionice -c 3 nice -n 19 cp -a /data/src /backup/dstionice -c 3把 IO 调度到空闲类nice -n 19把 CPU 优先级压到最低。这两个组合起来的效果是业务忙时复制几乎让路业务空闲时自动提速不需要人工盯着。另外-v虽然好用但在十万级文件量下不要加标准输出的开销会明显拖慢整体速度。如果确实要搬一个文件数量极大的目录树tar管道有时候比cp更快tar -C /data/src -cf - . | tar -C /backup/dst -xf -它把大量小文件打包成连续流减少了随机 IO同时在解包时保留权限和时间戳。缺点是中断后无法续传只能重来所以更适合同一台机器内部的目录搬迁。4.4 --reflink 与稀疏文件两种让复制变快的现代玩法如果你的数据盘是 btrfs 或者开启了 reflink 的 XFScp有一个几乎作弊的能力cp -a --reflinkauto /data/src /backup/dst支持写时复制的文件系统上这个复制几乎是瞬间完成的而且不额外占用空间因为新文件只是共享了原有的数据块直到某一方被修改才真正分配新块。我来回测过几次语义上完全等价于普通复制所以在脚本里加上--reflinkauto是安全的遇到不支持的文件系统它会自动退化成普通复制不会报错。另一个值得关心的是稀疏文件。有些文件尤其是虚拟机磁盘镜像、数据库的数据文件看起来几百 GB实际占用只有几十 GB因为里面大量是空洞。cp默认会尽量保持稀疏但当目标是某些不支持稀疏的文件系统时空洞会被实体化复制出来的文件真的占用几百 GB。判断方法是比对两个数字du -h --apparent-size disk.img # 表观大小 du -h disk.img # 实际占用如果两者差距悬殊就要用cp --sparsealways强制保持稀疏。这个细节在搬虚拟机镜像的时候必须留意不然备份盘被写满的锅是甩不掉的。提示复制正在被写入的文件数据库文件、活动日志、虚拟机磁盘本质上是复制某一个瞬间的不一致状态大小看起来对得上用起来可能是坏的。正规做法是停服务、加锁或者先打快照再复制。5. 案例四软链接、硬链接和特殊文件的复制5.1 软链接默认会被解引用软链接是复制过程中最容易被误解的一类对象。假设当前目录下有个链接config.yml - /data/app/config.yml执行cp config.yml /backup/结果/backup/config.yml是一个普通文件内容是那个真实配置文件的内容而不是一个链接。也就是说cp默认会把链接跟到底。想复制链接本身要显式加-P或-d、-acp -P link.yml /backup/ readlink /backup/link.yml # 输出 /data/app/config.yml说明链接结构保住了 stat -c %F /backup/link.yml # symbolic link这个默认行为带来的风险有两个方向。往坏了说如果那个链接指向的是一个几百 GB 的数据文件你以为只是复制了一个配置文件实际上会把备份盘写满反过来如果链接本身是悬空的目标已经被删cp会直接报错退出在批量脚本里可能导致整个流程中断。所以我养成了一个习惯复制之前先ls -l看一眼源路径确认有没有链接、链接指向哪里。5.2 -d、-P、-a 的取舍与跨版本差异这三个选项的关系不是并列的而是层层叠加-P只表示不解引用-d等于-P --preservelinks-a等于-dR --preserveall。需要单独说明的是不同版本的 coreutils、不同发行版在处理命令行上给出的软链接和递归树内部的软链接时默认行为历史上发生过变化-r与-R的语义差异也一度引发过很长的讨论。这些差异在网上能查到各种互相矛盾的说法很容易把人绕晕。我的处理办法是干脆不猜需要保留链接结构就用-a或显式-P需要跟到底就用-L永远不用裸的cp -r。复制完成后做一次验证命令很简单find /backup/dst -type l | head如果有输出说明链接被保留了如果目的本来就是保留链接但输出是空的那就是选项写错了。这个一行验证能避免大量部署完了才发现链接全变成实体文件的返工。5.3 硬链接为什么复制后变成两份独立文件硬链接和软链接是两种完全不同的东西。硬链接不是指向文件的指针而是同一个 inode 在多处目录里各有一个名字。cp复制的是文件内容会分配新的 inode所以复制完之后两个文件毫无关系改其中一个另一个纹丝不动。想要保留硬链接关系也就是复制出来的目录里原来互相链接的文件依然共享数据要用-a它包含的--preservelinks会重建链接结构如果只是想在另一处快速生成一份只读副本可以用-l硬链接或者-s软链接来代替复制# 生成一份几乎不占空间、瞬间完成的硬链接副本 cp -al /data/releases/v1.2 /backup/releases/v1.2-linkcp -al是做快照式副本的一个土办法速度极快、空间占用极低但必须记住改动任何一边都会同时影响另一边因为它俩本来就是一个文件。它只适合只读参考临时对照这类场景千万别当成真正的备份。这里还牵出前面提过的那个反直觉行为cp覆盖已存在的文件时默认保留目标的 inode。所以如果/backup/app.jar有硬链接或者正被某个进程打开着被覆盖之后硬链接关系和进程句柄依然有效看到的是新内容但对象没换。想让目标变成一个全新的文件对象必须加--remove-destination它会先删除再创建。5.4 设备文件、FIFO 与扩展属性归档场景的注意事项复制特殊文件时-a不只是保留属性这么简单它还在避免卡死。最典型的是 FIFO命名管道普通cp复制 FIFO 时会尝试从里面读数据而管道里可能根本没有写入方命令就那样一直挂着脚本也跟着卡住。要复制 FIFO 这类特殊文件必须用-a内部走的是-R遇到特殊文件会原样重建复制设备节点还需要 root 权限普通用户会直接报权限错误。扩展属性和安全上下文是另一个容易出问题的角落。用getfattr -d -m - 文件名可以看到扩展属性ls -Z 文件名可以看到安全上下文。-a会尝试保留这些信息但如果目标文件系统是 FAT、exFAT 这类不支持扩展属性的类型cp会打印failed to preserve ...之类的警告但退出码仍然是 0复制本身是成功的。很多人被这串警告吓到以为复制失败其实文件内容没问题。真正需要警惕的是相反的处境复制过去了服务却起不来日志里是权限被拒。这种情况我一般先对比两边的上下文如果源上有、目标上没有多半是复制时上下文没带过去用restorecon -Rv /目标路径按系统策略重新打一遍上下文即可。6. 案例五只挑一部分文件复制——通配符、find 与 xargs 的组合拳6.1 shell 通配符的能力边界通配符是 shell 在把命令交给程序之前就展开的cp收到的其实是一长串已经展开好的文件名。理解这一点很重要因为它解释了通配符的两个典型限制。第一通配符的展开结果受命令行长度上限约束文件数量一多就会报Argument list too long这个上限可以用getconf ARG_MAX查通常是几十万到两百万字节量级。第二*不匹配点开头的隐藏文件这也是cp -r src/* dst/之后发现.env、.gitignore没有过去的根本原因。想要连隐藏文件一起复制用/.的写法最干脆cp -a src/. dst/常见的通配符语法顺手列一下*匹配任意长度不含隐藏文件?匹配单个字符[abc]匹配集合中的一个[!abc]反向匹配{a,b}是花括号展开生成多个字符串不是字符集合**需要先shopt -s globstar才能递归匹配。这里最容易混淆的是[abc]和{a,b}前者匹配a 或 b 或 c 这一个字符后者展开成a 和 b 这两个独立的词用在cp file.{txt,md} dst/这种写法上很好用。6.2 find 加 -exec 的两种写法与性能差异当筛选条件比后缀是什么更复杂时就该请find出场了。它和cp配合有两种写法性能差距非常明显# 写法一每个文件启动一次 cp简单直观文件多时慢 find /data/logs -type f -name *.log -exec cp -av {} /backup/logs/ \; # 写法二攒够一批再传一次性能好得多 find /data/logs -type f -name *.log -exec cp -avt /backup/logs {} 区别就在结尾的\;和。前者对每个匹配结果单独 fork 一个cp进程一万个文件就是一万次进程创建后者会把结果攒成尽量大的一批一次传过去内部按命令行长度自动分批。实测下来十万级文件量的差距是分钟级别的。注意写法二里用了-t把目标目录提前因为会把多个文件名追加到最后如果目标写在末尾cp会把第一个源文件当成目标方向就反了。按时间、大小、类型组合筛选的写法也很有用# 复制 7 天前修改、且大于 100M 的日志 find /data/logs -type f -mtime 7 -size 100M -exec cp -avt /backup/big {} # 排除 node_modules 目录后再找 js 文件 find /data/app -type d -name node_modules -prune -o -type f -name *.js -print0 \ | xargs -0 -r cp -avt /backup/js-mtime 7表示修改时间在 7 天以前-mmin -30表示最近 30 分钟内改过-newer 参考文件表示比某个文件更新-prune用来剪掉不想进去的目录。这几个参数配上-exec ... 基本上能覆盖绝大多数挑选式复制的需求。6.3 xargs 与文件名里的空格、换行、引号find ... | xargs cp ...是最常见的组合也是最容易出错的组合。问题出在默认分隔符上find默认按换行输出文件名而xargs默认按空白空格、制表符、换行切分输入。一旦文件名里带空格一个文件名就会被切成两个参数cp收到的路径就不存在了报一堆cannot stat。错误写法典型症状正确写法find . | xargs cp -t dst带空格的文件名报找不到find . -print0 | xargs -0 -r cp -avt dstfind . | xargs cp dst目标被当成源复制方向反了用-t或-I {}find . | xargs cp -t dst且无结果报缺操作数加-r空输入不执行正确写法里有三个关键点-print0让find用 NUL 字符分隔输出xargs -0用 NUL 作为分隔符读入这样任何空格、引号、甚至换行都不会破坏文件名-r保证没有匹配结果时不去执行一次空命令。这三个字母的组合我基本是条件反射级别的记忆。另外一个容易忽略的细节是-I {}和-t的取舍。-I {}会对每个输入单独执行一次命令相当于find -exec ... \;文件名位置灵活但速度慢-t让cp支持批量源 统一目标速度快但要求目标写在源前面。文件量大的时候优先选-t。6.4 cp --parents 与 -t保留目录结构和批量接收的利器如果要把散落在不同层级的一批文件集中备份同时又想保留原来的目录结构--parents特别省事cd /data/app find . -type f -name *.conf -exec cp -av --parents {} /backup/conf/ \; # 结果/backup/conf/etc/app/app.conf层级完整保留它的语义是在目标位置重建源文件的相对路径前提是源路径传的是相对路径。配合-t也可以写成cp -a --parents -t /backup/conf {}。这个用法在做配置文件集中归档时特别好用因为它避免了手工mkdir -p每一级目录。再补一个替代方案install -D也能一步到位地建目录加复制还顺便能设权限install -D -m 0640 etc/app/app.conf /backup/conf/etc/app/app.conf区别在于install不保留时间戳、默认覆盖它的定位是把一个文件放到该放的位置并配上权限适合部署而不适合归档。选哪个取决于你是想备份一份带属性的原件还是想把文件安装到位。7. 案例六覆盖、跳过、增量与大文件进度7.1 -i、-n、-u 三种覆盖策略各自适合什么场景覆盖行为是复制里最需要提前想清楚的一环。cp提供了几个专门的选项来控制它我把它们整理成了一张对照表选项行为适合场景潜在风险默认无条件覆盖明确就是要替换手滑即失去原文件-i覆盖前询问手工操作重要文件脚本里会挂住等输入-n已存在就跳过补文件、合并目录源更新了也不会同步-u源更新才复制简单增量只看时间戳同秒修改判断不准-b/--backupnumbered覆盖前自动备份改配置、改脚本备份文件越积越多-n在把 A 目录合并进 B 目录但不要覆盖已有文件这种场景特别顺手比如部署时补一批模板文件希望已存在的配置保持不动。-u则适合我大概知道哪边新但不想全量重来的场合。需要提醒的是-u只比较时间戳不看内容如果两个文件时间戳相同比如从压缩包里解出来、或者在把时间精度只到秒的文件系统之间搬过它的判断和你的预期可能不一致。真正的增量同步还是交给rsync更靠谱。另外-i和-n同时出现时GNU 版本的语义是-n优先并且这个行为在较新的 coreutils 里还调整过。所以脚本里不要同时写这两个选一个然后用cp --help确认一下当前系统的语义。7.2 交互确认与别名RHEL 上那个 cp -i 的坑很多 RHEL、CentOS 系发行版给 root 用户默认配了alias cpcp -i。手工敲cp时它会在覆盖前问一句overwrite?这是保护机制可一旦你在交互式终端里粘贴一条批量复制命令几百个文件会挨个问你非常难受。\cp -a /data/src /backup/ # 反斜杠绕过别名 command cp -a /data/src /backup/ # 显式调用命令本体 unalias cp # 当前会话取消别名反斜杠前缀是最短的解法command语义更明确。反过来理解别名只在交互式 shell生效、脚本非交互 shell不受影响能解释一类很迷惑的现象手动测试时因为-i被拦下没覆盖写进脚本之后没人拦就把文件直接盖了。所以在脚本里必须自己把-n、-b这类保护性选项显式写出来别指望环境帮你兜底。7.3 看得到进度rsync --progress 与 pv 的用法标准 coreutils 里的cp没有进度条网上流传的cp --progress实际是第三方补丁不是原生命令。复制几十 GB 的时候没有任何反馈既难判断是不是卡住了也不好估时间。我一般用下面这几种方式替代# 用 rsync 看总体进度 rsync -ah --infoprogress2 src/big.iso /backup/ # 用 pv 在管道里显示进度进度输出到 stderr不污染数据 pv -pterb src/big.iso /backup/big.iso # 用 dd 复制镜像类文件顺便让内核把数据落盘 dd ifsrc/disk.img of/backup/disk.img bs8M convfsync statusprogresspv的那串参数值得记一下-p显示进度条-t显示已用时间-e显示预计剩余时间-r显示速率-b显示已传字节数。dd的convfsync会在结束前强制同步statusprogress是较新版 coreutils 才支持的参数老系统上不能直接用。大文件复制中断后续传用rsync --partial最省心rsync -ah --partial --progress src/big.iso /backup/--partial会把没传完的部分保留下来下次重跑接着传。还有一个--append系列会假设目标已有的前半段和源一致不做校验直接追加。这个假设在网络不稳或者源文件变动过的情况下大概率不成立我更倾向用--append-verify它会校验已有部分。7.4 cp 还是 rsync按文件数量和大小的选择表工具没有绝对的好坏只有场景匹配。下面这张表是我实际选型时的判断依据场景首选理由脚本里复制一两个文件cp -av语义简单、依赖最少几十万文件的目录同步rsync -a增量、可续传、有统计大文件且有进度需求rsync --progress/pv可见进度、支持续传同文件系统内快速生成副本cp -al/--reflinkauto几乎不占额外空间磁盘整盘或分区镜像dd/ddrescue按块复制、可跳过坏块跨主机搬运rsync/scp增量或简单直接有一个坑必须单独提醒rsync的尾部斜杠语义和cp不一样。rsync -a src/ dst/同步的是 src 的内容rsync -a src dst/会在 dst 下建一个 src 目录。从cp切到rsync的人几乎都在这里栽过一次我自己是把源带斜杠 只搬内容当成口诀背下来的。另外顺嘴提一个容易搞混的scp的大写-P指定端口小写-p才是保留时间戳写反了不报错但行为完全不同。8. 复制出问题时我一般怎么查从报错到根因的排查链路8.1 四类高频报错的逐个拆解遇到复制失败我一般先按报错信息分类再顺着对应路径查比盲目ls有效率得多报错真实原因怎么查cannot stat x: No such file or directory源不存在、路径基准不对、通配符没匹配到被当字面量ls -l目标路径echo看一眼展开结果确认当前工作目录omitting directory x忘了递归选项加-a或-rtarget x is not a directory多源复制但目标不是目录确认目标目录存在或改成单文件复制Permission denied源不可读、目标目录不可写、路径某一级缺 x 权限namei -l /完整/路径一次看清每一级权限No space left on device目标所在挂载点满了或 inode 用尽df -h看空间df -i看 inodeArgument list too long通配符展开超限换成find -exec ... cannot copy a directory into itself目标是源的子目录移动目标位置或改用临时目录are the same file源和目标指向同一 inode含软链接指向同一个文件ls -i对比 inode这几个里我想单独强调namei -l。它会把路径上每一级目录的权限逐行列出来对于明明目标目录是777为什么还写不进去这类问题特别有效——通常答案是某一级父目录缺少执行权限。同样容易误判的是磁盘满了报No space left on device时大家第一反应是df -h但如果目标文件系统上是海量小文件很可能是 inode 先耗尽这时候df -h显示还有空间得用df -i才看得出来。8.2 复制不完整的三种真实来源复制不完整是个很笼统的说法实际遇到的根因基本落在三类里。第一类是复制过程被打断留下一个半成品文件而它的名字和正常文件完全一样别人无法分辨。解决办法是先生成临时文件再改名落地cp -a src/app.jar /backup/app.jar.tmp mv /backup/app.jar.tmp /backup/app.jar同一个文件系统内的mv是原子的重命名不存在半个文件的中间状态发布脚本里这个写法非常常见。反过来要注意跨文件系统的mv实际是复制加删除没有原子性可以用stat -c %d比较两边的设备号来判断是不是同一个文件系统。第二类是源文件本身在复制过程中被写入。数据库文件、正在写的日志、虚拟机磁盘镜像都属于这类直接复制出来的是一个不一致的瞬间状态大小看起来没问题用起来就是坏的。正确做法是先停服务、加锁或者打快照再复制。第三类是稀疏文件被实体化或者目标文件系统不支持大文件、不支持扩展属性复制过程中刷了一堆警告但退出码是 0脚本判断成功就往下走了。这类问题不会以报错的形式出现只能靠复制后的校验发现。8.3 复制前后各做一件事校验与原子落地复制前做一件事确认目标空间。df -h看空间、df -i看 inode再用du -sh估一下体积。看体积时注意稀疏文件du -sh和du -sh --apparent-size给出的数字可能差好几倍按大的那个准备空间比较安全。复制后做一件事校验。小文件直接比对校验和目录比对内容目录同步类的场景用rsync的空跑模式最省事# 单个大文件比对 sha256sum /data/big.iso /backup/big.iso # 目录内容比对 diff -r /data/src /backup/src # 按内容校验和做一次空跑体检只报告差异、不改动任何文件 rsync -an -c --itemize-changes /data/src/ /backup/src/rsync -an -c是我最推荐的复制后体检方式-n是空跑-c让它按内容算校验和而不是只看大小和时间戳输出里带f前缀的就是两边内容不一致的文件。它的开销比diff -r大一些但结果更可靠尤其在跨文件系统、时间戳精度不一致的场景下。最后再分享一个小技巧脚本里给cp带上-v把输出重定向到日志文件再配合set -euo pipefail和一个明确的失败分支出问题时至少能知道是哪一条复制没成功。我在实际使用中的体会是一个能自动校验、失败会吼一声的脚本比事后靠ls目测文件数靠谱得多——真正让人头疼的从来不是报错而是那些悄悄少了一半、却安安静静躺在目录里的数据。
返回列表