别再用 cp + chmod 装二进制了!一次批量升级翻车,我把脚本全换成了 install

凌晨一点多,批量升级 agent 的脚本跑完,Ansible 输出一片绿色。

第二天早上看监控,两百多台机器里有六十多台版本号一点没变。告警规则是按新版本指标写的,这六十多台的告警全部失效,我们整整一晚都没发现。

罪魁祸首是一行我写了好几年的命令:cp agent /usr/local/bin/; chmod +x /usr/local/bin/agent。今天就把 install 和 cp + chmod 的区别讲透,实验过程、踩坑记录和脚本模板都放在后面,看完可以直接改你的部署脚本。


说实话,这种写法我用了很多年。下载 tar 包,解压,cp 到 /usr/local/bin,再 chmod +x。网上搜一圈教程,十篇里大概八篇也是这么写的。

平时用它确实不出事。装一个新软件时目标路径是空的,cp 怎么写都不会错。

问题都出在替换已有文件和批量执行这两个场景。只有一台机器时,失败了你马上能看到。上百台一起跑,再加上脚本没写好返回值判断,错误就被淹没了。

我现在的看法是这样的:

cp 只负责把字节搬过去,install 负责把一个文件以正确的方式放到正确的位置。

权限、属主、上级目录、替换方式,install 一条命令都能处理。cp + chmod 只做了一半,剩下的全靠你自己记得。

一条 install 顶四条命令

先看最直观的对比:

# 以前的写法
mkdir -p /usr/local/bin
cp node_exporter /usr/local/bin/
chown root:root /usr/local/bin/node_exporter
chmod 755 /usr/local/bin/node_exporter

# install 的写法
install -D -m 0755 -o root -g root node_exporter /usr/local/bin/node_exporter

几个参数的作用如下:

  • -D:目标路径的上级目录不存在时自动创建,作用相当于 mkdir -p。
  • -m:直接指定最终权限,和当前 umask 无关。
  • -o / -g:指定属主和属组,需要 root 权限。

省几行代码只是小好处。更重要的是 cp 和 chmod 之间存在一个时间窗口:文件已经放上去了,权限却还没改好。如果 cp 失败了,chmod 又是用 ; 接在后面的,chmod 依然会执行,并且返回 0。

我那次翻车就是这样。脚本最后一条命令是 chmod,它执行成功,Ansible 收到的退出码是 0,于是显示绿色。

Text file busy 是怎么来的

回到那六十多台机器。我登上去手动执行了一遍 cp:

# cp agent /usr/local/bin/agent
cp: cannot create regular file '/usr/local/bin/agent': Text file busy

后来查清楚了。这批是老机器,agent 当年是写在 rc.local 里用 nohup 拉起来的,不归 systemd 管,所以脚本里的 systemctl stop agent 根本没有停掉它。agent 一直在跑,cp 当然就覆盖不了。

我在测试机上复现了一下,用 strace 看 cp 具体做了什么:

# /usr/local/bin/demo &
[1] 23817

# strace -e trace=openat cp demo-v2 /usr/local/bin/demo 2>&1 | tail -3
openat(AT_FDCWD, "demo-v2", O_RDONLY) = 3
openat(AT_FDCWD, "/usr/local/bin/demo", O_WRONLY|O_TRUNC) = -1 ETXTBSY (Text file busy)
cp: cannot create regular file '/usr/local/bin/demo': Text file busy

可以看到,目标文件已经存在时,cp 会以 O_WRONLY|O_TRUNC 方式打开它,清空原有内容后写入新内容,操作的还是原来那个 inode。Linux 内核不允许写入正在被执行的程序文件,所以直接返回 ETXTBSY。

再看 install:

# ls -i /usr/local/bin/demo
1835021 /usr/local/bin/demo

# strace -e trace=unlinkat,openat install -m 0755 demo-v2 /usr/local/bin/demo 2>&1 | grep demo
unlinkat(AT_FDCWD, "/usr/local/bin/demo", 0) = 0
openat(AT_FDCWD, "/usr/local/bin/demo", O_WRONLY|O_CREAT|O_EXCL, 0600) = 4

# ls -i /usr/local/bin/demo
1835030 /usr/local/bin/demo

install 会先 unlink 旧文件,再创建一个新文件,inode 号从 1835021 变成了 1835030。

旧文件被删除后,正在运行的进程手里还握着旧 inode,数据不会消失,进程继续正常运行。等进程退出,旧 inode 才会被真正回收。

可以用书架来理解:cp 相当于直接在别人正在看的那本书上改字;install 是把旧书从书架上撤下来,换一本新书放上去,正在看旧书的人可以把它看完。

这里有个细节很多人会忽略:用 install 替换之后,进程跑的仍然是旧版本,必须重启才会生效。

# ls -l /proc/23817/exe
lrwxrwxrwx 1 root root 0 Nov  8 10:21 /proc/23817/exe -> '/usr/local/bin/demo (deleted)'

看到 (deleted) 就说明这个进程还在跑旧文件。升级完以后,可以用下面任意一条命令批量找出忘记重启的进程:

ls -l /proc/*/exe 2>/dev/null | grep deleted
# 或者
lsof +L1 | grep -E 'bin|lib'

我们现在的升级巡检里就有这一项。好几次业务方说“已经升级过了”,一查进程还在跑旧版本。

比 Text file busy 更隐蔽的坑:.so 被原地覆盖

Text file busy 虽然烦人,至少会直接报错。更危险的情况是 cp 执行成功了。

内核的这层保护只针对通过 execve 执行的程序文件。动态库 .so 是通过 mmap 加载的,现在的内核不会阻止你往里写。也就是说,用 cp 覆盖一个正在使用的 .so,命令会执行成功,然后正在运行的进程就会出问题。

去年我们升级一个 C++ 服务的公共库 libcommon.so,同事直接执行了:

cp -f libcommon.so /opt/svc/lib/

不到十秒,服务进程挂了一片,日志里全是:

*** Signal 7 (SIGBUS) received ***
Segmentation fault (core dumped)

原因是进程已经把旧 .so 映射进了内存。cp 原地清空并改写了文件内容,进程再访问那些内存页时,读到的要么是被截断的部分,要么是新旧混在一起的代码,SIGBUS 和 segfault 就是这么来的。

很多人以为加了 -f 就安全了。cp -f 的实际行为是:只有目标文件打不开时,才删掉它再重试。.so 本来就能正常打开,-f 的这个兜底逻辑根本不会触发,cp 还是原地覆盖。

换成 install,会先删除再新建,旧进程继续使用旧 inode,等下次重启时再加载新库,就不会出事。

权限问题:umask 会让你栽跟头

还有一个很常见的坑。不少公司做安全基线加固时会把 umask 改成 027 甚至 077,这时 cp 新建文件的权限就会出问题:

# umask
0077
# cp agent /usr/local/bin/agent
# ls -l /usr/local/bin/agent
-rwx------ 1 root root 18M Nov  8 10:40 /usr/local/bin/agent

cp 新建文件时,权限等于源文件权限再减去 umask。权限只剩 700,如果 systemd 服务里配置的是 User=app,启动时就会报:

agent.service: Failed at step EXEC spawning /usr/local/bin/agent: Permission denied
agent.service: Main process exited, code=exited, status=203/EXEC

还有一种情况更容易被忽略:如果目标文件已经存在,cp 会沿用目标文件原来的权限和属主,因为它没有新建文件,只是把内容写进了原来的 inode。旧文件当初权限设错了,新文件也会继承这个错误。

install -m 0755 不受 umask 影响,也不受旧文件影响。你写的是多少,最终权限就是多少。

属主:tar 包里带出来的陌生 UID

这个坑跟安全有关,我是在一次安全审计中发现的:

# tar xf app-1.2.3-linux-amd64.tar.gz
# ls -ln app-1.2.3/app
-rwxr-xr-x 1 1001 1001 23M Oct 30 14:02 app

以 root 身份解压 tar 包时,默认会保留打包时的 UID,这里就是打包机器上的 1001。如果你接着用 cp -a 或 cp -p 把文件放到系统目录,属主还会是 1001。

如果这台机器上 1001 恰好对应某个普通用户,这个用户就能替换这个二进制。而这个二进制是由 root 启动的。剩下的风险就不用我多说了。

install 默认把属主设成执行命令的用户,通常是 root,也可以用 -o/-g 明确指定,不会把打包机器上的 UID 带进来。

顺带说一下 SELinux

这个和 cp 关系不大,但我见过很多人用 mv 代替 cp,所以也提一下。在开启了 SELinux 的 RHEL/CentOS 上,把文件从家目录或 /tmp mv 到 /usr/local/bin,mv 会保留原来的安全上下文:

# ls -Z /usr/local/bin/agent
unconfined_u:object_r:user_home_t:s0 /usr/local/bin/agent

结果 systemd 启动时又会报 203/EXEC,而且文件权限看起来完全正常,排查起来很费时间。GNU install 默认会按照 SELinux 策略给目标文件设置默认上下文。如果已经用 mv 搬过来了,可以执行 restorecon -v /usr/local/bin/agent 修复。

几个好用但很少有人用的参数

# -C:内容相同就不复制,修改时间也不变,适合需要反复执行的脚本
install -C -m 0755 app /usr/local/bin/app

# -b:替换前先备份旧文件,回滚时直接改回原名就行
install -b -S .$(date +%F) -m 0755 app /usr/local/bin/app
# 执行后会多出 /usr/local/bin/app.2026-11-08

# -d:创建目录,同时设置权限和属主
install -d -m 0750 -o app -g app /data/app/{logs,conf,run}

# -t:把多个文件一次放进目标目录
install -m 0755 -t /usr/local/bin/ kubectl helm k9s

# -p:保留源文件的时间戳
install -p -m 0644 app.conf /etc/app/app.conf

-s 用来去掉调试符号,可以减小文件体积。但别人发布的二进制最好别随便 strip,出了问题连可用的堆栈都拿不到。部分软件还会校验自身完整性,strip 之后可能根本启动不了。

避坑清单,建议收藏

install 也不是万能的,下面这些坑我基本都踩过:

  • 配置文件千万别直接用默认权限。 install 默认权限是 0755,配置文件要写 -m 0644,密钥和证书要写 -m 0600。
  • 不要指望 install 递归复制目录。 install 没有 -r 参数。整个目录可以用 rsync 或 cp -r 复制,复制完再统一修正权限和属主。
  • 目标路径是软链接时要小心。 install 会删掉软链接,换成一个普通文件。像 /usr/bin/java 这种由 alternatives 管理的路径,直接 install 上去,alternatives 就失效了。
  • 源文件是软链接时,install 复制的是链接指向的实际文件内容。这一点要心里有数。
  • install 不是原子操作。 unlink 和新建文件之间有一个极短的窗口,这期间文件不存在。如果有进程会高频调用这个文件,比如每秒触发一次的任务,最好先 install 到同一目录下的临时文件,再用 mv 原子替换。
  • 不要只看退出码就认定升级成功。 一定要执行 --version 确认版本,再检查 /proc/*/exe 里有没有 (deleted)。
  • Alpine 镜像里的 install 是 busybox 版本,参数比 GNU 版本少。写 Dockerfile 之前先执行 install --help 看看支持哪些参数。

另外,Ansible 的 copy 模块本身就是先写临时文件再 rename,比在 shell 模块里执行 cp 稳妥得多。我们当初是为了省事才用了 shell 模块,算是自己挖的坑。

可以直接用的升级脚本模板

#!/usr/bin/env bash
set -euo pipefail

NAME=node_exporter
SRC=./${NAME}
DST=/usr/local/bin/${NAME}
TS=$(date +%Y%m%d%H%M%S)

# 1. 校验安装包,避免装上损坏的文件
sha256sum -c "${NAME}.sha256"

# 2. 备份旧版本。这里是复制备份,不影响正在运行的进程
if [ -f "$DST" ]; then
  install -p -m 0755 "$DST" "${DST}.bak.${TS}"
fi

# 3. 先装到同目录下的临时文件,再原子替换
install -m 0755 -o root -g root "$SRC" "${DST}.new"
mv -f "${DST}.new" "$DST"

# 4. 重启服务并确认结果
systemctl restart "$NAME"
sleep 2
systemctl is-active "$NAME"
"$DST" --version

# 5. 检查还有没有进程在跑旧文件
if ls -l /proc/*/exe 2>/dev/null | grep -q "${DST} (deleted)"; then
  echo "WARN: 还有进程在跑旧版本" >&2
fi

临时文件必须放在同一个目录下。只有在同一个文件系统内,mv 才是原子的 rename;跨文件系统时,mv 实际上会退化成复制再删除,原子性就没了。


那次事故之后,我在团队里定了一条规矩:脚本里替换任何二进制文件或 .so,一律禁止使用 cp。code review 时只要看到 cp ... && chmod +x,就直接打回去修改。

这么做主要是因为 install 已经把替换文件时容易出错的地方都处理好了:先删除再新建、显式指定权限和属主、自动创建目录。cp + chmod 能不能用好,全看执行的人当时细不细心。凌晨一点跑批量脚本的时候,不能指望人一直细心。

今晚有空的话,可以搜一下你们的部署脚本里有多少处 cp 后面跟着 chmod,再检查一下线上机器上有没有 (deleted) 的进程,结果可能会出乎你的意料。

如果这篇文章对你有用,欢迎点赞、在看、转发给天天写部署脚本的同事。关注 @运维躬行录,下期聊聊 systemd 服务文件里那些容易配错、而且会导致服务半夜挂掉的参数。

公众号:耕云躬行录

个人博客:躬行笔记

参与讨论