Shell 生产事故复盘:for i in $(ls) 是怎么删错路径的
一起 CI/CD 数据丢失事故,起因是这种常见写法:
for i in $(ls /data/logs/*.log); do
rm -rf "${i}"
done
某天日志目录里出现了一个带空格的文件名:access 2024-01.log。$(ls ...) 命令替换后,for 循环按默认 IFS(空格、Tab、换行)做词分割,这一行输出被拆成两个词:/data/logs/access 和 2024-01.log。第一个词恰好是归档目录 access,于是 rm -rf 把这个目录整个删了。数据直接丢失。
这个现象可以自己验证:
mkdir -p /tmp/demo && cd /tmp/demo
mkdir old_access
touch "old_access 2024-01.log"
for i in $(ls *.log); do echo "rm -rf ${i}"; rm -rf "${i}"; done
输出里会出现 rm -rf old_access,然后该目录被删。
1. 循环文件用 find -print0,不要用 for + ls
安全做法是让分隔符不再依赖空格或换行:
find /data/logs -maxdepth 1 -type f -name "*.log" -print0 | xargs -0 rm -f
-print0 用空字符分隔文件名,xargs -0 按空字符读取,因此空格、换行、特殊字符都不会破坏参数。如果需要在循环里执行更复杂操作,用:
while IFS= read -r -d '' file; do
echo "delete: $file"
rm -f "${file}"
done < <(find /data/logs -maxdepth 1 -type f -name "*.log" -print0)
2. 变量引用必须加双引号
rm -rf $file 在 file="foo bar" 时会变成 rm -rf foo bar,等于删除两个路径。"${file}" 把整个变量内容作为一个整体,空格注入无效。这是最基本的保命符。
3. 用 set -euo pipefail 替代手工检查 $?
很多老脚本写 command; if [ $? -eq 0 ]; then ...,但链条一长就容易漏检。成熟做法是脚本开头直接声明:
#!/usr/bin/env bash
set -euo pipefail
-e:任意命令失败立即退出,避免一个报错后脚本继续执行,扩大破坏范围。-u:使用未定义变量直接报错,避免空变量变成危险路径,比如rm -rf "${UNDEFINED_VAR}/"。-o pipefail:管道中任何一个环节失败,整体都算失败。没有它时,mysqldump db | gzip > backup.gz中即使 dump 失败,gzip 成功,管道返回码也是 0,后续误以为备份成功。- 调试时加
-x,会打印每一条实际执行的命令。
4. 管道右侧是子进程,变量修改带不回来
这个陷阱非常隐蔽:
count=0
cat log | while read line; do
count=$((count+1))
done
echo "$count" # 输出 0
管道右侧的 while 在子 shell 中执行,count 的修改只发生在子进程内,循环结束子进程销毁,主 shell 里的 count 还是 0。正确写法是使用输入重定向:
count=0
while read -r line; do
count=$((count+1))
done < log
echo "$count"
这样循环留在当前 shell 进程,变量修改才能保留。
5. 文本处理多使用 awk
简单的 grep 加 cut 链在处理列数不定、空白不规则的日志时很脆弱。awk 天然按列处理,也更易保证逻辑正确:
ps aux | awk '$11 ~ /nginx$/ {print $2}'
这比 ps aux | grep nginx | grep -v grep | awk '{print $2}' 少很多坑。
6. trap 保证异常退出时清理
脚本中途退出,临时文件可能残留,甚至被后续任务误用。用 trap 统一清理:
TMP_FILE=$(mktemp)
cleanup() {
rm -f "${TMP_FILE}"
echo "清理资源完成"
}
trap cleanup EXIT INT TERM
EXIT 捕获脚本正常或异常退出,INT 和 TERM 捕获 Ctrl+C 与 kill 信号。这样即使 set -e 触发强制退出,也能收尾。
7. crontab / Jenkins 里 PATH 不一致
脚本手动运行正常,放到 crontab 或 Jenkins 里却提示 command not found,多半是环境变量 PATH 不同。cron 环境下 PATH 往往只有 /usr/bin:/bin,而你的命令行里有 /usr/local/bin。解决办法:
- 脚本开头显式设置
export PATH="/usr/local/bin:/usr/bin:/bin" - 外部命令全部使用绝对路径
- 排查时先执行
env | sort打印实际环境 - 未开启
set -e的情况下,每个可能失败的命令后都要处理$?,不能跳到下一个命令才判断
生产级脚本模板
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
TMP_FILE=$(mktemp)
cleanup() {
rm -f "${TMP_FILE}"
echo "清理资源完成"
}
trap cleanup EXIT INT TERM
# 主逻辑
把每段脚本都当作会运行在带空格文件名、空变量、坏管道、无 PATH 的环境来写。for i in $(ls) 这类写法,应该永远留在个人测试目录,而不是生产。