“cp: omitting directory”这行字,几乎每个在 Linux 终端里干过活的人都被它拦过一次。我第一次撞见它是在一台测试机上,想把一个配置目录整个搬到备份盘,敲完cp回车,屏幕上就回了这么一句,然后提示符干干净净地回来了,好像什么都没发生。当时我以为命令成功了,直到半小时后服务起不来,才发现目标路径下只有一个同名的空壳,一个文件都没进去。后来才明白,这不是 cp 坏了,是它太“守规矩”了:cp 默认只处理单个文件,碰到目录会直接跳过并给出提示。这篇东西就是把这行提示掰开揉碎讲讲清楚——它到底在表达什么、为什么这样设计、-r和-a到底差在哪、目标目录存不存在会带来什么不一样的结果、复制完为什么权限和软链会变样,以及那些只有踩过才记得住的细节。不管你是刚接触命令行的新手,还是写惯了部署脚本、被这行字坑过一次的老手,下面这些内容应该都能对得上号。
1. 报错先读懂:cp 为什么把目录“跳过”了
1.1 cp 的默认模式只认文件
要理解这条提示,得先接受一个前提:cp在没有任何附加参数时,处理的对象是文件,不是目录树。这是它从设计之初就定下来的保守策略,不是缺陷也不是 Bug。你给它两个文件路径,它老老实实读一块写一块;你给它一个目录,它会认为自己没有获得“递归下去”的授权,于是打印omitting directory,然后继续处理下一个参数。
这个“继续处理”很关键。omitting这个词是“略过”“跳过”的意思,不是“失败”。假设你敲的是cp a.txt config/ /backup,cp 会正常把a.txt复制过去,遇到config/时提示一句跳过它,最后整体退出码是 1。也就是说,命令部分成功。这一点害过不少人:屏幕上只有一行提示,很容易被后面的输出淹掉,等你发现的时候,文件确实在,目录确实丢了。
至于为什么 GNU 版本要这么设计,我的理解是两条。第一,递归复制的代价不可控,一个看起来平平无奇的目录可能是几十万个小文件、上百 GB 体积,甚至里面藏着指向父目录的符号链接,一路跟下去就是死循环。第二,cp需要兼容极其古老的脚本习惯,默认行为一旦改成“自动递归”,那些原本依赖它跳过目录的老脚本反而会出事。所以它选择了“让你显式说清楚”。
1.2 一个容易忽略的点:提示走的是 stderr
cp: omitting directory 'xxx'这行文字输出到标准错误流,不是标准输出。这意味着两件事。其一,你在脚本里写了cp -r ... > /dev/null,这行提示照样会出现在屏幕上,因为重定向只吃了 stdout。其二,如果你为了“让日志干净点”顺手写了2>/dev/null,那这行提示就被彻底吞掉了,命令看起来完美无瑕,实际结果残缺不全。
我自己就干过这种事。早期写一个发布脚本,为了让日志好看,把所有命令的 stderr 都丢掉了,结果某次上线后静态资源目录整个没同步过去,页面样式全崩。排查了快一个小时才发现,根源就是那行被我自己丢进/dev/null的omitting directory。从那以后我养成一个习惯:关键路径的复制操作,stderr 绝不静默,宁可日志脏一点。
1.3 退出码 1 会在脚本里引发连锁反应
单独在终端敲,退出码 1 无非是提示你注意一下。但放进脚本里,它能引发的事故就多了。常见的 bash 脚本开头会写set -e,意思是任何命令返回非零就立即终止。cp因为跳过了目录返回 1,脚本直接中断,后面的所有步骤统统不执行。你会在日志里看到脚本莫名其妙停在中途,却找不到任何“报错”,因为那行提示可能被重定向走了。
反过来,如果脚本里用了cp ... || true这种“无论如何都放行”的写法,问题更隐蔽:脚本一路跑到底,看着全是成功,实际缺了东西。所以我的建议是,复制目录这种操作,在脚本里要么明确写对参数,要么显式检查返回值,别让它处于“看着成了其实没成”的灰色地带。判断方式很简单,复制完对源和目标的条目数做个粗略比对,或者干脆用后面会提到的rsync做校验式的同步。
2. 三条主路:-r、-a,以及绕开 cp 的方案
2.1 -r / -R:改动最小的修法
最直接的解法就是在命令里补上一个递归开关。-r和-R在 GNU 版本的实现里是等价的,含义都是“遇到目录就往下走”。你把cp config/ /backup改成cp -r config/ /backup,那行提示立刻消失,目录树连内容一起过去。
这个改动的优点是零学习成本,缺点是你只解决了“能不能复制”这一层,其他属性一概不管。用-r复制出来的东西,默认情况下权限、时间戳、属主、符号链接形态都可能和源不一致。日常搬个文档、拷个日志,无所谓;但如果是搬一个服务目录、一个需要保留执行位的脚本集合,-r就有点不够看了。我在早期部署里用-r搬过一个包含启动脚本的目录,结果可执行位丢了,服务启动时报权限不足,查了半天才发现是复制方式的问题。
顺带说一个历史遗留问题:-r在 POSIX 标准里对符号链接的处理方式其实是“未定义”的,不同平台的实现可能不一致。GNU 版本在较新的 coreutils 里,-r/-R默认不跟随源里的符号链接,但更老的实现和某些其他 Unix 变体可能相反。所以只要目录里可能有软链,我更倾向于直接上-a,把行为钉死,别去赌版本差异。
2.2 -a:归档模式,搬家的正确姿势
-a是--archive的缩写,它不是一个单独的参数,而是一组参数的打包:等价于-dR --preserve=all。拆开看就是:递归(R)、不跟随符号链接(d,即--no-dereference)、保留全部属性(all,含权限模式、属主属组、时间戳、硬链接关系、扩展属性、安全上下文等)。
拿搬家做类比:-r像是把家具一件件搬到新房子,摆是摆进去了,但标签撕了、朝向乱了;-a像是整体打包搬运,到了新地方拆开,位置、朝向、标签一个不差。所以只要涉及“我要把这个目录原样搬到那儿”,我基本都是-a起步。
不过-a有个必须提前知道的行为:它试图保留属主属组。普通用户没有修改文件属主的权限,于是复制过程中会刷出一堆failed to preserve ownership的提示,文件本身是复制成功的,但整体退出码会变成 1。如果你的脚本开着set -e,就会在这里莫名其妙地中断。解决办法有两个:一是用--no-preserve=ownership明确告诉它别管属主;二是如果你的目的只是保留权限和时间戳,就别用-a,改成cp -r --preserve=mode,timestamps 源 目标。这个细节我在正式文档里很少见到强调,但在实际写脚本时踩中的概率相当高。
2.3 什么时候该换个工具
cp 够用,但不是万能。当目录里有几十万个小文件、需要断点续传、需要看到进度、需要复制前后做校验的时候,我会直接换rsync。它的-a同样是归档语义,额外还有--info=progress2看总进度、--dry-run先预演一遍、中断了再跑一次只补差异。这在跨盘搬大数据目录时优势非常明显。
另一种情况是源目录里有极深的嵌套或大量软链,你又不确定会不会绕成环。cp 递归下去如果配上跟随软链的选项,是有可能陷进去的。这时候先find 源目录 -type l摸一遍软链情况,再决定命令怎么写,比事后救火省事得多。
| 方案 | 递归 | 保留权限 | 保留软链 | 保留时间戳 | 适用场景 |
|---|---|---|---|---|---|
cp(无参数) | 否 | 部分 | 视情况 | 否 | 仅单文件 |
cp -r | 是 | 否(新建文件受 umask 影响) | 版本相关 | 否 | 临时拷贝、无关紧要的目录 |
cp -a | 是 | 是 | 是 | 是 | 部署、迁移、备份 |
cp -a --no-preserve=ownership | 是 | 是 | 是 | 是 | 普通用户搬家,避免退出码异常 |
rsync -a | 是 | 是 | 是 | 是 | 大目录、需断点续传与进度 |
3. 递归复制里那些真正会咬人的细节
3.1 目标目录存不存在,结果差一层
这是我在带新人时反复强调的一点:cp -r 源 目标的最终结构,取决于目标路径是否已经存在。分两种情况。
第一种,目标不存在。比如/backup/site这个目录还没有,你执行cp -a /srv/site /backup/site,cp 会创建/backup/site,然后把源目录里的内容放进去。结果就是/backup/site/index.html这种结构。
第二种,目标已经存在,并且是个目录。同样一条命令,cp 会在目标下面新建一个与源同名的子目录,结果是/backup/site/site/index.html。多套了一层,很多人第一次遇到会愣一下:我明明复制过去了,怎么路径里多了一个同名目录?
这两种行为都不算错,但如果你在脚本里没意识到这个差异,就可能出现“第一次跑正常,第二次跑多套一层”的诡异现象。稳妥写法是:先mkdir -p 目标,然后明确用“复制内容”的写法,把意图写死,别依赖目标存不存在的隐式判断。具体怎么写,下一节展开。
3.2 源路径末尾的斜杠:cp 其实不太在意
从rsync转过来的人特别容易在这里翻车。rsync里src和src/是天壤之别,前者复制目录本身,后者复制目录内容。但cp不是这样,GNU 版本基本会忽略源路径末尾的斜杠,cp -a src dst和cp -a src/ dst的结果通常是一样的。
真正决定结构的是上面说的目标是否存在,以及你用不用/.这种写法。想“只复制内容不套壳”,最可靠的方式是cp -a 源/. 目标/,注意这个点号——它表示“源目录自身这个条目”,配合结尾的/展开后,等于告诉 cp“我要的是里面的东西,不是这个目录本身”。这个写法我第一次见的时候觉得挺别扭,用了几次发现确实稳,就一直在用了。
要注意的是,cp -a 源/. 目标/里的目标目录最好先mkdir -p建好。虽然有说法认为目标不存在时 cp 会自动创建,但我不想在关键操作上赌这种边界行为,多敲一条mkdir -p的成本可以忽略不计。
3.3 覆盖已有文件时,权限跟着谁走
这是个很反直觉的细节:不加--preserve=mode的情况下,如果你复制过去的目标文件已经存在,cp 会打开它并写入内容,此时目标文件原有的权限被保留,不会变成源文件的权限。只有当目标文件是新建的时候,才会按源文件的权限配合 umask 推导出一个新权限。
这个行为带来的典型现象是:你更新了一个配置文件,源文件的权限是 600,但目标位置原来那个文件是 644,复制完之后它还是 644。如果你复制的是密钥、证书这类东西,权限没跟着收紧,就是实打实的安全隐患。所以涉及敏感文件,我的习惯是复制完立刻chmod或者直接install -m 600 源 目标,别指望复制动作帮你改权限。
3.4 符号链接:跟随还是保留
这是最容易造成“复制完体积翻倍”或者“复制完链接失效”的地方。默认情况下,cp遇到源里的符号链接,行为取决于你给的参数:-a(内含-d)会把它原样复制成一个链接,指向不变;而不带-d的递归复制,在某些实现里会解开链接、把被指向的真实内容复制一份过来。
举个具体场景:某个目录里放了一个指向共享资源区的软链,那个资源区有 20 GB。你用cp -r复制,结果目标位置多出一份 20 GB 的实体数据,磁盘直接告急。换成cp -a,过去的就是一个几十字节的链接,体积不变。
反过来也有坑。如果你复制的是整个目录树,软链指向的是树内部的相对路径,保留链接通常是正确的;但如果软链指向的是树外部、且目标机器上不存在那个路径,复制过去的链接就是死链,打开时提示找不到文件。这种情况没有通用解法,取决于你的迁移目标:同机搬家用-a保留;跨机部署要么把被指向的内容一起带过去,要么提前把链接换成实体文件再复制。
3.5 时间戳和硬链接,什么时候会在意
时间戳这东西,平时不重要,遇到增量备份、构建缓存、Makefile 依赖判断的时候就变得很要命。用-r复制,文件时间戳变成复制当下,构建系统会认为所有文件都是“新”的,于是全量重编译一次,几分钟变半小时。用-a保留时间戳,缓存就能正常命中。这就是为什么构建环境的目录迁移,我一律用-a。
硬链接关系同理。同一个 inode 被多个路径引用,-a的--preserve=links会在目标端也建成硬链接,省空间也保持语义一致;-r则会变成多份独立文件。不过要注意,跨文件系统时硬链接关系无法保留,cp 会退化成分别复制,这是文件系统的限制,不是参数写错了。
4. 高频场景的完整命令与参数选择
4.1 场景一:把整个目录原样搬到另一个位置
最典型的写法:
mkdir -p /backup/site cp -a /srv/site/. /backup/site/拆解一下每一步的意图。mkdir -p先把目标建好,避免“目标不存在”带来的一层差异,同时-p保证多级路径和已存在的情况都不报错。cp -a走归档语义,权限、时间戳、软链、硬链全部保留。源路径写成/srv/site/.而不是/srv/site,是为了复制“内容”而不是“套壳”。
如果执行者是普通用户,而源目录里有一些属于其他账号的文件,加--no-preserve=ownership避免刷一堆警告和退出码异常:
cp -a --no-preserve=ownership /srv/site/. /backup/site/这样权限位还在,属主不强求,退出码也正常,脚本里可以安心检查$?。
4.2 场景二:批量复制多个目录
一次搬多个目录,标准的写法是把所有源放在前面,目标放最后:
mkdir -p /data/archive cp -a /data/proj-a /data/proj-b /data/proj-c /data/archive/这里有个隐含前提:目标/data/archive必须存在且是目录,否则 cp 会把最后一个源当成目标,出现“把 proj-c 复制成了名为 archive 的目录”这种离谱结果。
另一种常见需求是按通配符批量来。这里必须提醒一句:shell 的通配符*默认不匹配以点开头的隐藏文件。cp -a /data/proj/* /data/archive/会把.git、.env、.config这类东西全漏掉。你以为搬完了,实际最关键的东西都没过去。
解决办法有两个。一是用/.的写法搬整个目录内容(推荐,前面已说);二是如果确实要通配符,把隐藏文件也显式带上:
cp -a /data/proj/. /data/archive/如果坚持用通配符,就得写成两段,第二段匹配点文件,但要注意在没有隐藏文件时通配符不展开会报错,通常配2>/dev/null或者用shopt -s dotglob让*包含点文件。相比之下,/.的写法最省心,我现在基本只用这一种。
4.3 场景三:大目录、跨盘、需要看到进度
cp没有任何进度输出,-v虽然能打印每个文件,但几万个文件时刷屏本身就拖慢速度。我的做法是分情况:
如果只是想确认它在动,把-v输出重定向到文件,用tail -f另开一个窗口看:
cp -av /data/bigdir/. /mnt/target/bigdir/ > /tmp/cp.log 2>&1 & tail -f /tmp/cp.log如果是几十 GB 级别的数据,直接换rsync更合适:
rsync -a --info=progress2 /data/bigdir/ /mnt/target/bigdir/注意这里的斜杠语义和 cp 不同:源结尾带/表示复制内容,不带表示复制目录本身。中断之后重跑同一条命令,只会补差异部分,不会从头再来。
如果底层文件系统支持写时复制(比如 btrfs、部分 xfs 配置),cp --reflink=auto在同一个文件系统内做副本几乎是瞬时的,不占额外空间直到任一侧被修改。这个选项在需要快速做一份“快照式副本”再拿去改的场合非常好用,但跨文件系统时会自动退化成普通复制,所以加了=auto而不是=always,避免在不适用的地方直接报错。
4.4 场景四:覆盖策略要可控
默认情况下 cp 是“静默覆盖”的,目标位置有同名文件,直接盖掉,不留痕迹。这在批量同步时是风险点。三个常用开关:
| 参数 | 行为 | 适用场合 |
|---|---|---|
-i | 覆盖前逐个询问 | 手工操作、目标内容不可再生 |
-n | 已存在则跳过,不覆盖 | 只补充缺失文件,不动已有内容 |
-u | 仅当源更新或目标不存在时才复制 | 增量更新 |
--backup=numbered | 覆盖前把旧文件改名备份 | 配置更新,需要回滚余地 |
配置文件的更新我一般用--backup=numbered,它会把旧文件重命名成config.txt.~1~这种形式,出问题时能直接翻回去对比。纯手工操作时加-i,多花几秒钟确认,比覆盖完了再找备份强。
5. 排查实录:复制完成了,问题才刚开始
5.1 权限丢失导致服务起不来
最典型的一次:把一个服务的运行目录从旧机器搬到新机器,用的是cp -r。复制过程毫无异常,服务起来就报错,日志说某个启动脚本没有执行权限。回头看源目录,脚本是 755;目标目录里变成了 644。原因就是-r不保留权限模式,新文件按 umask 推导,可执行位没了。
这类问题的排查成本其实不高,ls -l一对比就露馅,难的是你一开始根本想不到往这个方向查。所以我现在的习惯是,迁移类操作做完,第一件事不是启动服务,而是拿两边的目录列表做一次对比:
ls -la /srv/site/ | head -30 ls -la /backup/site/ | head -30看权限位、看属主、看软链指向,三秒钟的事,能省掉后面半小时的排查。
5.2 软链变实体,磁盘无声无息被撑爆
另一种情况是复制完发现目标盘空间掉得离谱。原因一般就是软链被解引用了,链接指向的大文件被实打实复制了一份。判断方法很简单,对比两边的文件数量和总体积:
du -sh /srv/site /backup/site find /srv/site -type l | wc -l find /backup/site -type l | wc -l如果目标端的软链数量明显少于源端,基本可以确认是被解引用了。修法是把复制命令换成-a或者显式加-d,然后清理目标重来。这里不要试图在原基础上“补”,直接删干净重新复制更省事也更可靠。
5.3 复制中途中断留下的半成品
大目录复制到一半磁盘满了、连接断了、机器重启了,目标位置会留下一个不完整的目录树。这时候最危险的做法是“接着再跑一遍 cp”,因为 cp 不知道哪些是完整的,会把已有的文件重新覆盖一遍,中途再断又是一次半成品。更麻烦的是,如果第一次是用-r复制的、权限已经错了,第二次用-a覆盖,已存在文件的权限仍然不会被修正(前面说过,目标存在时保留目标权限),错误就被固化了。
正确做法是先用rsync -a --dry-run对比一遍差异,看清楚缺什么、哪些内容不一致,再决定是全删重来还是补齐。如果确认要重来,rm -rf 目标目录之后重新复制,别心疼那点时间。
5.4 排查速查表
| 现象 | 可能原因 | 排查命令 | 处理方式 |
|---|---|---|---|
提示omitting directory | 缺递归参数 | 看命令里有没有-r/-a | 补上-a |
| 目标下多套了一层同名目录 | 目标已存在,语义成了“复制到里面” | ls 目标路径 | 改用源/.写法 |
| 隐藏文件没复制过去 | 用了*通配符 | ls -a 源目录 | 改用源/. |
| 脚本执行到一半停了 | -a保留属主失败,退出码 1 | 看 stderr 有无failed to preserve | 加--no-preserve=ownership |
| 可执行位丢失 | 用了-r而非-a | ls -l对比两边 | 删掉重来,用-a |
| 目标磁盘被撑爆 | 软链被解引用成实体文件 | find 目标 -type l | wc -l | 换-a重来 |
| 配置文件权限没跟着变 | 目标文件已存在,保留了原权限 | ls -l 目标文件 | 手动chmod或install -m |
| 时间戳全变成当前时间 | 未保留时间戳 | stat 文件对比 | 用-a或--preserve=timestamps |
6. 一些用血换来的经验和建议
6.1 脚本里别让复制“静默成功”
写自动化脚本时,我对复制操作有个固定套路:先建目标,再复制,最后验一下条目数。虽然粗糙,但足够抓住绝大多数“看着成了其实没成”的情况。
mkdir -p "$DST" || exit 1 cp -a --no-preserve=ownership "$SRC/." "$DST/" || { echo "copy failed"; exit 1; } src_count=$(find "$SRC" -mindepth 1 | wc -l) dst_count=$(find "$DST" -mindepth 1 | wc -l) [ "$src_count" -eq "$dst_count" ] || { echo "count mismatch: $src_count vs $dst_count"; exit 1; }--no-preserve=ownership是防止非 root 场景下退出码被属主警告污染,|| exit 1是防止复制失败被忽略,最后的条目数比对是兜底。这套写法不优雅,但在我手上拦下过至少三次“目录没搬全就启动服务”的事故。
6.2 复制前先看一眼源目录的“地形”
我现在养成一个条件反射:只要目标目录超过几百 MB,复制之前先跑三条命令,看一眼地形。
du -sh 源目录 find 源目录 -type l | head -20 find 源目录 -type f | wc -l第一条看体积,心里有数大概要多久;第二条看有没有软链,尤其是那种指向外部路径的,提前决定是保留还是替换;第三条看文件数量,几十万个小文件的话,cp会慢得让人怀疑人生,直接换rsync更明智。
6.3 关于-a和-r的选择,我的实际取舍
日常零散操作,比如临时把一个目录拷到/tmp看看,我用-r,快、省事、不关心属性。但只要涉及三件事之一——服务部署、配置迁移、构建产物目录搬移——我一律用-a,并且配上--no-preserve=ownership。这个取舍标准很简单:这三种场景里,权限、时间戳、软链形态任何一项出错,代价都远大于多敲几个字符。
顺带说一句,如果你的系统上有install命令,复制单个文件到指定位置并设定权限,用install -m 755 src dst比“先 cp 再 chmod”少一步,也能避免中间那一小段权限不正确的窗口期。这个习惯对部署脚本里的可执行文件特别有用。
6.4 一个容易被忽略的收尾动作
复制完成之后,如果目标是要直接投入使用的目录,建议顺手做一次sync(在拔盘、卸载卷的场景尤其重要),或者至少确认一下没有残留的写入缓存。曾经有一次搬到移动存储上,看着进度走完了就拔了线,插到另一台机器上发现有几十个文件是 0 字节。原因就是数据还在页缓存里没落盘。
还有一种情况是复制到了挂载点,而那个挂载点其实是另一块盘的临时挂载。df -h 目标路径确认一下目标到底落在哪个文件系统上,能避免“复制了但复制到了本地盘、挂载一卸载就全没了”这种低级但真实的失误。这些动作都不费事,难的是你得先想到。