ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux 命令 -r 与 -p 区别:递归遍历、属性保留与端口参数避坑

Linux 命令 -r 与 -p 区别:递归遍历、属性保留与端口参数避坑

上周帮一位朋友看他那条半夜跑的备份脚本,里面写着cp -rp /data/app /backup,他一口咬定“-r就是复制目录,-p就是复制权限”,结果恢复的时候发现两件事:一是/backup里多了一层app目录,二是文件属主全变成了执行脚本的那个账号,原本挂在目录上的扩展属性一个没剩。这类问题几乎每个接触 Linux 的人都撞过,因为-r和-p这两个字母太常见、太不起眼,常见到大家默认它们“到处都一样”。实际情况恰恰相反:-r全称 recursive,意思是“遇到目录就往下走”,但每个命令“往下走”的规则并不相同;-p更离谱,它在mkdir里是 parents(连父目录一起建),在cp里是 preserve(保留属性),在ssh里是 port(端口号),同一个字母三种完全不相干的含义。把这两个参数彻底捋清楚,能省掉大量“命令跑了、结果不对、还找不到原因”的时间。下面这些内容来自我这些年写脚本、做迁移、修权限的实际操作记录,适合刚上手命令行的新手对照练习,也适合写运维脚本的人拿来核对细节。

1. 把 -r 和 -p 拆开看:一个是遍历策略,一个是附加动作

1.1 递归的本质:目录树遍历的三条规则

递归参数听着玄,其实就是一句话:命令碰到目录时,不把它当成一个“文件”处理,而是进去继续处理里面的东西,直到没有子目录为止。这个过程本质上是一次目录树遍历,任何一次遍历都必须回答三个问题,而不同命令的答案不一样,这才是坑的来源。

第一个问题是终止条件。理想情况下,叶子节点是普通文件,遍历自然结束。但文件系统里存在符号链接、挂载点、设备文件,它们会让“树”变成环或者延伸到别的文件系统。grep -R遇到指向祖先目录的符号链接时会打印recursive directory loop警告然后跳过,而不是无限循环;find默认不跨文件系统(需要显式-xdev才限制,不加则跨),这些差异都是终止条件的不同实现。

第二个问题是符号链接怎么处理。这是-r相关事故里占比最高的一类。同一棵目录树里放一个指向/的软链接,cp -r可能只复制这个链接本身(几百字节),cp -rL则可能开始把整个根文件系统往外拷。这里的差别不是“递归不递归”,而是“递归时跟不跟随链接”。

第三个问题是错误怎么处理。遇到没有读权限的子目录,是立刻退出、跳过继续、还是记一条警告?grep -r默认打印Permission denied并继续,cp -r默认报错并继续但最终返回非零退出码,脚本里如果不检查$?,就会得到一份“看起来复制完了、其实少了一半”的备份。

提示:判断一个命令的-r行为,别背口诀,直接看它的 man 手册里-r那一段有没有提到 symlink、-L、-P、-H。提到了,就说明链接处理是可配置的,脚本里必须显式写清楚。

理解了这三条,再看具体命令就清楚了:所谓“-r参数不一致”,绝大多数是这三条规则的具体取值不同。

1.2 -p 的三张面孔:parents、preserve、port

-p的问题比-r更隐蔽,因为它压根不是“一个参数”,而是三个互不相干的英文单词缩写,恰好都取了首字母。

含义全称典型命令作用
父目录parentsmkdir -p路径上缺哪层建哪层,已存在不报错
保留preservecp -p、tar -p、rsync -p保留权限、时间戳、属主等元数据
端口portssh -p、sftp -p(部分实现)指定连接端口号

这三种含义之间没有任何逻辑关联,纯粹是字母撞车。更麻烦的是大小写:scp的端口是-P(大写),-p反而是保留时间戳;rsync的-P是“显示进度 + 支持断点续传”的组合选项,跟端口毫无关系。我见过有人写scp -p 2222 file host:/path,本意是换端口,实际执行的是“保留时间戳 + 把2222当成源文件”,然后报No such file or directory,人还以为是网络问题。

所以记-p不能靠“感觉”,得靠“命令 + 位置”。判断方法很简单:看这个参数后面跟的是什么。跟路径,多半是 parents;什么都不跟、只是开关,多半是 preserve;跟一个纯数字,多半是 port。

1.3 一个三十秒就能做完的对照实验

空讲容易忘,直接做一遍。准备一个源目录,把文件权限设成666、时间改成 2020 年,然后分别用cp -r和cp -rp复制一份对比:

mkdir -p /tmp/lab/src/sub echo hello > /tmp/lab/src/sub/a.txt chmod 666 /tmp/lab/src/sub/a.txt touch -d '2020-01-01 08:00' /tmp/lab/src/sub/a.txt umask 022 cp -r /tmp/lab/src /tmp/lab/dst_r cp -rp /tmp/lab/src /tmp/lab/dst_rp ls -l --time-style=long-iso /tmp/lab/dst_r/sub/a.txt /tmp/lab/dst_rp/sub/a.txt

实测结果:dst_r/sub/a.txt的权限是644、时间是刚才的当前时间;dst_rp/sub/a.txt的权限是666、时间还是 2020-01-01 08:00。原因不复杂——不带-p时,cp创建新文件走的是“源文件权限与~umask相与”的规则,666 & ~022 = 644;带-p时直接把源的 mode 和 mtime 写过去。这个实验只用三十秒,但能让你以后看到-p就条件反射地想到“元数据要不要带过去”,而不是含糊地觉得“加了更保险”。

顺手再加一步,看看递归里最容易翻车的部分:

ln -s /tmp/lab/src/sub /tmp/lab/src/sub_link cp -r /tmp/lab/src /tmp/lab/dst_link ls -l /tmp/lab/dst_link/

dst_link/sub_link大概率还是一个软链接,指向原来的/tmp/lab/src/sub。这意味着你把备份拷到另一台机器上,这个链接就废了。要的是“把链接指向的内容也复制过去”,就必须显式加-L;要的是“保留链接结构”,就加-P(GNU 下递归复制默认接近这个行为,但别赌默认值)。这个细节决定了你的备份到底能不能在别的机器上直接用。

2. -r 家族:递归参数在常见命令里的真实行为

2.1 cp -r:目标目录存在与不存在,结果完全不同

cp -r是新手第一个踩的坑,而且踩了往往不自知。它的规则是:如果目标路径不存在,就创建它,并把源目录的内容放进去;如果目标路径已存在且是目录,就在目标目录里再建一层与源同名的目录。两种情况的差别是整整一层目录结构。

mkdir -p /tmp/lab/a /tmp/lab/b touch /tmp/lab/a/f.txt cp -r /tmp/lab/a /tmp/lab/b # 结果:/tmp/lab/b/a/f.txt cp -r /tmp/lab/a /tmp/lab/c # 结果:/tmp/lab/c/f.txt

同一句cp -r,只因为第二个参数存不存在,产出路径就差了a这一层。放备份脚本里,如果/backup/app目录事先被别的流程创建过,你的备份就会变成/backup/app/app/...,跑第二遍变成/backup/app/app/app/...,越套越深。这种问题不会报错,只会让磁盘慢慢变满。

规避办法有两个,我常用第一种:一是目标目录写成确定的已存在目录,源目录后面加/.,语义变成“复制这个目录的内容”,绕开同名嵌套:

mkdir -p /backup/app cp -a /data/app/. /backup/app/

二是干脆用rsync -a,它的语义更明确(下一小节细说)。另外注意cp -r对硬链接的处理:默认会把一个 inode 拆成多个独立文件,空间占用会变大,想保留硬链接关系得用-a(它内含--preserve=links)。

还有一个常被忽略的点:cp -r没有--exclude。想排除node_modules、.git、日志目录,cp做不到,只能靠rsync --exclude或tar --exclude。这个限制决定了“大项目迁移用 cp”基本是自找麻烦。

2.2 rm -r:为什么它删不掉"看起来在里面"的东西

rm -r最值得记住的一条是:它不跟随符号链接。目录里有个软链接指向别处,rm -r删除的是这个链接本身,目标目录里的内容一根毛都不会动。这是安全设计,不是 bug。

ln -s /data/important /tmp/lab/target_link rm -r /tmp/lab # /data/important 依然完好,删掉的只是链接

但这条安全规则在参数细节上会露出破绽。如果给链接加上尾部斜杠写成rm -r link/,GNU 下的行为会变得反直觉,可能直接报错拒绝执行,也可能被 shell 或命令解析成“进入目录”。保险做法是:删目录不加尾部斜杠,删软链接用rm -f link而不是rm -r。

第二条是删除不等于释放空间。文件被进程打开着,rm只是移除了目录项,磁盘块要等进程关闭句柄才回收。表现出来就是“删了几个 G,df一点没变”。排查方式:

lsof +L1 # 列出 link count 为 0 的已打开文件

处理办法要么重启对应进程,要么用: > /proc/<pid>/fd/<n>的方式清空(谨慎操作,只适合日志类文件)。这个坑在日志目录上极其常见,我第一次遇到时盯着du和df的差值看了半天。

第三条是参数过长。目录里几十万个文件时,rm -rf dir/*可能直接Argument list too long,因为通配符是 shell 展开的,展开结果受ARG_MAX限制。正确姿势是交给find或xargs分批:

find /data/logs -type f -name '*.log' -mtime +30 -delete find /data/tmp -mindepth 1 -delete

-mindepth 1是为了不连目录自己一起删掉,这个小细节漏了会很难受。

2.3 grep -r 与 grep -R:一字之差,可能是半小时的等待

grep的大小写差异是递归参数里最经典的一道题,而且官方文档写得很明确,只是很少有人去翻。

  • grep -r pattern DIR:递归搜索,但只跟随命令行上直接给出的符号链接,遍历过程中遇到的符号链接一律不跟。
  • grep -R pattern DIR:递归搜索,跟随所有遇到的符号链接。

差别从哪来?假设DIR本身是个软链接,-r会进去;假设DIR/sub是软链接指向上层目录,-r不进去,-R会进去,然后撞上循环,打印warning: recursive directory loop并跳过。在/etc、/usr这种满是链接的目录里,-R的耗时会明显高于-r,而且会莫名其妙扫进一堆本来不想看的地方。

实际用法我一般是这样组合:

# 只搜配置文件,跳过二进制,带行号 grep -rn --include='*.conf' --include='*.yaml' 'timeout' /etc # 排除指定目录,避免扫进 .git 和缓存 grep -rn --exclude-dir=.git --exclude-dir=node_modules 'TODO' ./src # 二进制文件直接跳过,输出更干净 grep -rI 'pattern' .

-I(大写 i)表示把二进制文件当作文本不匹配处理,这个参数在混合目录里非常好用,能省掉一堆Binary file xxx matches的干扰行。另外grep -P是启用 PCRE 正则,跟-p无关,也别和--include混着记。

注意:在仓库根目录直接grep -r是常见陋习。先确认目录下有没有巨大的node_modules、.git、日志目录,否则一次搜索能把机器 IO 打满。养成先--exclude-dir再搜索的习惯,成本很低。

2.4 rsync -r 不够用:-a 到底多做了什么

rsync的-r只做一件事:递归进入目录。它不保留权限、时间戳、属主、属组,也不复制符号链接(默认直接跳过链接)。所以拿rsync -r做迁移,拷过去的文件时间全是拷贝时间,权限按 umask 重算,软链接一个不剩,很多应用会因为缺链接直接起不来。

-a是-rlptgoD的合集,拆开看:

短参数作用
-r递归
-l保留符号链接(作为链接复制)
-p保留权限
-t保留 mtime
-g保留属组
-o保留属主(需 root)
-D保留设备文件和特殊文件

结论很直接:做备份、做迁移,用-a,不要用-r。-r只在“我明确只想搬内容、元数据无所谓”的场景下用,比如把编译产物同步到测试机。

rsync还有两个必须记住的细节。第一是源路径尾部斜杠:rsync -a src/ dst/复制的是src里的内容;rsync -a src dst/复制的是src这个目录本身,结果变成dst/src。这个差别和cp -r的目标存在与否属于同一类坑,但规则完全相反,很容易记混。第二是--delete,它会删除目标端多出来的文件,方向写反就是灾难;建议第一次先加--dry-run(或-n)看一遍文件清单再真跑。

rsync -avn --delete /data/app/ /backup/app/ # 先干跑 rsync -av --delete /data/app/ /backup/app/ # 确认后执行

2.5 那些没有 -r 的命令:mv、ln、unzip

不是所有命令都认-r,硬加会直接报invalid option,这类错误看着低级,但在脚本里出现频率不低。

mv移动目录不需要递归,它改的是目录项本身,天然“整棵树搬走”。写mv -r a b会得到mv: invalid option -- 'r'。同理ln也没有-r,unzip解压本身就是递归展开,没有-r这个参数(zip才有-r,因为压缩时需要显式说明“进入子目录”)。

mv -r a b # 报错,正确写法是 mv a b zip -r out.zip dir/ # 压缩才需要 -r unzip out.zip # 解压不需要 -r

这里有个记忆抓手:递归参数只在“需要决定要不要往下走”的命令上才有意义。mv的任务是“把整个对象换个位置”,不存在“走不走下去”的选择;zip的任务是“把一堆东西打进一个包”,必须知道要不要深入子目录,所以有-r。按这个逻辑推,ls -R(列出整棵树)、chmod -R(对整棵树改权限)、chown -R都合理,而mv -r、cat -r就不该存在。

3. -p 家族:同一个字母,三种完全不同的意思

3.1 mkdir -p:幂等创建与中间目录权限

mkdir -p的核心价值是幂等:目录已存在时不报错,直接返回成功。这让它成为脚本里的标配——mkdir -p /data/app/logs可以放心写在脚本开头,跑一百遍结果一样,退出码都是 0。不带-p时,路径上任何一层缺失都会报No such file or directory,目录已存在则报File exists。

mkdir /data/app/logs # 父目录不存在就失败 mkdir -p /data/app/logs # 缺哪层建哪层,已存在不报错

有一个细节值得单独说:-p配合-m时,权限只作用于最后一层,中间自动创建出来的目录按0777 & ~umask走。

umask 022 mkdir -p -m 750 /tmp/lab/x/y stat -c '%a %n' /tmp/lab/x /tmp/lab/x/y # 755 /tmp/lab/x # 750 /tmp/lab/x/y

这个行为很容易被误解成“整条路径都是 750”。如果安全策略要求整条路径收紧权限,建完之后必须再补一刀:

mkdir -p /srv/secure/data chmod 750 /srv/secure /srv/secure/data

还有一个边界:如果路径上某一层已经存在但是个普通文件,mkdir -p会报File exists并失败。脚本里最好在mkdir -p之后跟一句[ -d "$DIR" ] || exit 1,别假设它一定成功。

3.2 cp -p:保留哪些属性,保留不了哪些

cp -p在 GNU 下等价于--preserve=mode,ownership,timestamps,也就是保留权限位、属主属组、访问时间和修改时间。听起来很全,但实际用起来有四类东西它管不了。

属性cp -pcp -a/--preserve=all说明
权限位 mode保留保留受 umask 影响的部分会被覆盖
属主属组尽力保留尽力保留非 root 只能保留自己拥有的
时间戳保留保留atime/mtime 都写回
符号链接不涉及保留为链接需-d或-P
硬链接关系不保留保留需--preserve=links
扩展属性 xattr不保留保留需--preserve=xattr
SELinux 上下文不保留保留需--preserve=context
ACL不保留保留需--preserve=all或显式指定

表格里最后三行是最容易出事的。做过一次带 SELinux 的迁移就明白:文件权限、属主、时间全都对,服务就是起不来,ls -Z一看上下文是default_t,原来的httpd_sys_content_t全丢了。这种问题不看日志根本想不到是cp造成的。所以只要目标环境启用了 SELinux 或依赖 ACL、xattr,就别用-p,直接-a,或者更明确地写cp -a --preserve=all。

另外要清楚-p的“保留属主”是有前提的:普通用户复制别人的文件,属主只能变成自己,cp会静默忽略这个失败,不报错。想验证到底保留了没有,别靠感觉,用stat对比源和目标:

stat -c '%a %U:%G %y %n' /data/app/conf.ini /backup/app/conf.ini

3.3 ssh -p 与 scp -P:端口参数的大小写陷阱

这一组参数我见过太多人栽跟头,原因是ssh用小写-p表示端口,而scp用大写-P表示端口、小写-p表示保留时间戳。同一个工具集,规则不统一。

ssh -p 2222 user@host # 正确:连接 2222 端口 scp -P 2222 file user@host:/path # 正确:连接 2222 端口 scp -p file user@host:/path # 注意:这是保留时间戳,不是端口 scp -p 2222 file user@host:/path # 灾难:把 2222 当成源文件

最后一条会报2222: No such file or directory,然后有人以为是网络不通,开始查防火墙,白白浪费半小时。判断技巧很朴素:看报错里提到的是“文件找不到”还是“连接失败”,前者八成是把端口写成了文件名。

rsync又是另一套规则,它没有端口参数,得通过-e传递:

rsync -av -e 'ssh -p 2222' /data/app/ user@host:/backup/app/

sftp在多数实现里用大写-P指定端口,但也有版本接受小写,脚本里最好显式查一次sftp -h,别照抄。这类“同一个字母在不同命令里含义不同”的情况,最可靠的做法不是背,而是写脚本时对每个陌生参数先man一眼,或者用--help快速确认。

3.4 tar、rsync、chmod 里的 -p

tar的-p是保留权限(--preserve-permissions),在解包时生效,作用是让解出来的文件用归档里记录的权限,而不是按当前 umask 重算。以 root 解包时tar默认就会保留权限,普通用户解包才需要显式加。所以“tar -p到底要不要加”取决于你是谁、在干什么:

tar -cpf backup.tar -C /data app # 打包时 -p 表示保留权限(配合 -c) tar -xpf backup.tar -C /restore # 解包时 -p 表示按归档权限还原

tar还有个容易混淆的大写-P,含义是“允许绝对路径”,也就是打包时保留开头的/。这个参数我基本不用,因为带绝对路径的归档在解包时会直接覆盖原位置,风险太高。默认去掉开头的/反而是保护。

rsync -p保留权限,rsync -P则是--partial --progress的组合,表示“保留未完成的临时文件 + 显示进度”,常用于大文件断点续传。跟端口、跟父目录都没关系。

chmod -R严格说不属于-p家族,但它和-r的关系值得提一句:-R是递归改权限,会把目录和文件一视同仁。chmod -R 644 dir会让所有子目录失去执行位,结果谁也进不去。正确做法是分开处理,后面第 4 章会给出完整命令。

4. 组合与顺序:-rp、-rf、-a 怎么选

4.1 备份场景:cp -rp 与 rsync -a 的取舍

单次、少量、同机复制,cp -a就够用,它比-rp更完整(多了符号链接、硬链接、xattr、上下文)。跨机、增量、大目录,rsync -a更合适,因为它支持断点、增量、排除规则、带宽限制,还能先 dry-run。

# 同机整目录复制,语义最接近"完整克隆" cp -a /data/app /backup/app_$(date +%F) # 跨机增量同步,带排除和删除 rsync -av --delete \ --exclude='logs/*.log' \ --exclude='tmp/' \ /data/app/ user@host:/backup/app/

这里有个判断标准,我用它来决定选哪个:只要目标端可能已经存在旧数据,就用rsync。因为cp不会删除目标端多出来的文件,做“镜像”场景会留下历史垃圾,久而久之备份目录比源目录还大;而rsync --delete能让两端保持一致,代价是方向写反就完蛋,所以必须配--dry-run先看清单。

如果确实只能用cp,至少把目标路径写成固定存在的目录配合源目录/.写法,避免同名嵌套,同时用--no-clobber(-n)防止意外覆盖:

mkdir -p /backup/app cp -a -n /data/app/. /backup/app/

-n的语义是“目标已存在就跳过”,做增量式补拷时很有用,但它不会告诉你跳过了哪些文件,需要自己对比。

4.2 清理场景:rm -rf 的安全边界与替代方案

rm -rf的危险不用多说,值得说的是几个能实际降低风险的操作习惯。

第一,变量一定要加引号并做非空校验。脚本里rm -rf $DIR/*,如果DIR因为上游失败变成空值,命令就等价于rm -rf /*。写成rm -rf "${DIR:?DIR is empty}"/*,变量为空时命令直接中止,这是bash内置的保护,比任何注释都管用。

第二,善用--终止选项解析。文件名以-开头时,rm -rf -weird会被当成参数解析。正确写法是rm -rf -- -weird。

第三,大批量删除用find控制范围,别靠通配符。通配符不匹配隐藏文件(*不含.开头的文件),而且容易一次性展开过多参数:

# 只删 30 天前的日志,范围明确 find /data/logs -type f -name '*.log' -mtime +30 -delete # 清空目录内容但保留目录本身 find /data/tmp -mindepth 1 -delete

第四,确认路径拼写。我现在的习惯是危险命令先打一遍ls -ld看目标,再按上箭头改命令执行;多花两秒钟,能避免绝大多数误删。另外 GNU 的rm默认带--preserve-root,拒绝递归删除/,但这个保护只对根目录生效,对/data这种业务目录没有任何拦截作用,别指望它。

4.3 权限修复:chmod -R 的正确打开方式

chmod -R的问题在于它不区分文件类型。目录需要执行位才能进入,普通文件通常不需要执行位,一条chmod -R 755会让所有脚本、配置、数据文件都变成可执行,chmod -R 644则让所有目录都进不去。两种都是常见事故。

正确做法是分开处理,用find按类型批量改:

# 先修目录,保证可进入 find /data/app -type d -exec chmod 755 {} + # 再修文件,去掉执行位 find /data/app -type f -exec chmod 644 {} + # 单独把需要执行的脚本加回来 find /data/app/bin -type f -name '*.sh' -exec chmod 755 {} +

-exec ... {} +比-exec ... \;效率高得多,因为前者会把多个文件拼成一批传给chmod,后者每个文件启动一次进程。几千个文件时差距非常明显。

chown -R也有类似的注意点:GNU 下chown -R默认不跟随符号链接(等价于-P),改的是链接本身的属主;要让属主变化作用到链接指向的文件,得加-L。这个语义和chmod一致,但很多人误以为chown -R会跟着链接走。

注意:chown -R user:group /data这种命令在容器环境里会顺手改掉挂载卷的属主,导致宿主机上其他服务读写失败。执行前先确认目录下哪些是挂载点,用findmnt或df看一眼,必要时用-xdev限制不跨文件系统。

4.4 参数顺序与解析规则

GNU 的getopt默认支持参数重排,所以cp src dst -r和cp -r src dst效果一样。BSD 系(比如某些精简环境)不支持重排,选项必须放在操作数前面。写可移植脚本时,统一把选项写在前面,这是零成本的保险。

cp -r src dst # 推荐写法,到哪都能跑 cp src dst -r # GNU 能跑,别的地方可能报错

--的作用是终止选项解析,后面的内容一律当操作数。处理用户输入的文件名时,这个符号几乎是必须的:

rm -f -- "$file" grep -n -- "$pattern" file.txt

还有个容易被忽略的行为:短选项可以合并,-rp等价于-r -p,-rf等价于-r -f。合并本身没问题,问题在于合并后语义容易看错——cp -rp是“递归 + 保留”,rm -rf是“递归 + 强制”,两个都带r,但一个是复制一个是删除,扫一眼脚本时容易看串。写危险命令时我倾向于拆开写:rm -r -f比rm -rf多几个字符,但读起来更清楚,尤其在 code review 时。

5. 常见问题速查与踩坑记录

5.1 问题速查表

现象大概率原因排查与处理
备份目录越套越深cp -r目标已存在,又建了一层同名目录改用cp -a src/. dst/或 rsync
拷完文件时间全是当前时间只用了-r,没带-p/-t改-a,或用rsync -a
拷完软链接全没了rsync -r默认跳过链接,或cp -L解引用rsync -a或cp -a
服务起不来,权限看着都对SELinux 上下文丢失cp -a --preserve=all,或restorecon -R
删了文件空间没释放有进程持有已删除文件的句柄lsof +L1,重启或截断对应 fd
rm -rf dir/*报参数过长通配符展开超过ARG_MAX用find ... -delete分批
scp -p 2222 file host:/x报文件不存在-p是保留时间戳,端口要用-P改成scp -P 2222 ...
mkdir -p -m 750 a/b中间目录仍是 755-m只作用于最后一层建完单独chmod中间层
grep -R跑很久还进了一堆奇怪目录-R跟随所有符号链接换-r,并加--exclude-dir
mv -r a b报无效选项mv没有递归参数直接mv a b

5.2 几个真实踩过的坑

说三个印象最深的,都是看着不起眼、后果不小的事。

第一个是cp -r的嵌套。早年写部署脚本,目标目录由另一段逻辑创建,我以为它不存在,写了cp -r ./app /opt/app。第一次跑正常,第二次跑变成/opt/app/app,第三次/opt/app/app/app。问题不是复制错了,而是老版本文件一直留在里面,新版本文件在更深的层级,服务加载的永远是旧配置。查了两小时才发现目录结构不对。从那之后,只要目标目录是我自己创建的,一律用cp -a src/. dst/的写法。

第二个是软链接引发的无限展开。某次用grep -R在仓库里搜关键字,仓库里有个current -> .的链接(部署目录常见的写法),结果grep一直在同一个目录里打转,输出刷了几十屏recursive directory loop警告,最后只能 Ctrl+C。换grep -r立刻正常。这件事让我彻底记住了-r和-R的差别,也养成了在仓库里搜索先加--exclude-dir=.git的习惯。

第三个是chmod -R的连带伤害。给一个上传目录改权限,顺手写了chmod -R 644 uploads,本意是“文件统一只读”,结果所有子目录失去执行位,网站立刻 403,因为 Web 服务进不去目录。修复时又犯了第二个错:直接chmod -R 755 uploads,把用户上传的图片、脚本全变成可执行。最后是用find -type d和find -type f分两遍才收拾干净。现在我的习惯是:只要涉及目录树权限,绝不单用-R。

5.3 脚本里的防御性写法

写脚本和敲命令是两回事,命令错了人能看到报错,脚本错了可能半年后才发现。几条我现在默认会加的保护。

第一,set -euo pipefail放脚本开头。-e让命令失败即退出,-u让未定义变量报错,pipefail让管道中间环节失败也能被发现。这一行能拦掉大量“复制失败但脚本继续跑”的问题。

第二,变量一律加引号并做必填校验:

: "${SRC:?SRC is required}" : "${DST:?DST is required}" mkdir -p "$DST" cp -a "$SRC"/. "$DST"/

第三,危险操作前打印将要执行的内容,并留一个 dry-run 开关:

if [ "${DRY_RUN:-0}" = "1" ]; then echo "[dry-run] rm -rf -- $TARGET" else rm -rf -- "$TARGET" fi

第四,关键操作后做校验,别假设成功就等于正确。复制完比对文件数量,改完权限抽查几个目录:

src_count=$(find "$SRC" -type f | wc -l) dst_count=$(find "$DST" -type f | wc -l) [ "$src_count" -eq "$dst_count" ] || echo "count mismatch: $src_count vs $dst_count"

这几条写下来只多十几行代码,但能把“静默失败”变成“立刻报警”。我在实际项目里吃过太多次静默失败的亏,尤其是跨文件系统复制和磁盘快满的时候——cp可能中途失败,退出码非零,但如果不检查,后续流程照样跑,最后表现成“数据缺失”,排查成本极高。

最后一个个人习惯:遇到不熟悉的参数组合,先--help或者man看一眼再敲。-r和-p这类短参数在不同命令里含义差异太大,靠记忆硬猜的成本远高于查一次手册。特别是-p,只要看到它,先问自己一句:这里到底是父目录、保留属性,还是端口?问清楚了,命令基本就不会写错。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进