ARTICLE DETAIL

资讯详情

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

Shell脚本“No such file”报错排查与数组传参实践

Shell脚本“No such file”报错排查与数组传参实践 连载数据库巡检脚本的时候最让人头疼的不是 SQL 写得有问题而是 Shell 脚本本身莫名其妙地给你来一个 “No such file or directory”然后整个任务就在那干瞪眼。这个报错长得特别像文件路径不存在但你去查文件、查目录全都好好的。最近帮同事排查一个批量连库查询的脚本正好把这个问题和另一个高频坑——函数传参数组——一起撞上了。这篇文章就把这两个问题从现象、原理到解决方案完整拆一遍都是踩过坑之后的实操记录。先交代一下背景。脚本本身不复杂就是从一台跳板机循环连多个业务库执行几条查询语句然后把结果汇总。部署到新服务器上之后一执行就报No such file or directory没有任何多余信息。新手遇到这个报错第一反应是去检查 SQL 文件、日志目录、数据文件是不是不存在但我可以负责任地说在这个场景里九成以上是脚本文件或执行环境本身出了问题而不是数据库目录里的文件缺失。下面我按实际排查顺序把“文件不存在”这个误报的几种根源先拆清楚再单独说函数传参数组的问题最后给出一套可以直接抄的完整方案。1. 报错现场还原先看清“No such file or directory”到底卡在哪里1.1 最容易翻车的第一现场脚本自身的行尾符我第一次在这类报错上栽跟头是在 Windows 上写完脚本、通过 Git 传到 Linux 服务器后执行的。写的时候用的是记事本风格的编辑器行尾符默认是 CRLF\r\n而 Linux 只认 LF\n。执行./check_db.sh的时候内核读取脚本第一行#!/bin/bash却发现实际解析出来的是#!/bin/bash\r。它去系统中找/bin/bash\r这个“解释器文件”当然找不到于是报错-bash: ./check_db.sh: /bin/bash^M: bad interpreter: No such file or directory注意这里报错信息里的^M那就是\r回车符的可视化表示。你盯着脚本内容看觉得完全正常可内核眼里解释器路径就是带着一个看不见的尾巴。这个问题在连接数据库脚本里尤其阴险因为脚本本身可能已经被 dos2unix 处理过但后来编辑的过程中又引入了新的 CRLF导致反复发作。判断最直接的方法是file命令file check_db.sh如果输出里包含with CRLF line terminators那就实锤了。修复也很简单dos2unix check_db.sh # 或者用 sed不依赖额外工具 sed -i s/\r$// check_db.sh处理完再执行一次file确认输出变成Bourne-Again shell script, ASCII text executable就没有行尾符问题了。1.2 第二现场解释器路径与执行方式的隐性坑排除掉 CRLF 之后下一个要看的点是脚本的解释器声明和执行方式。很多人习惯写#!/usr/bin/env bash这本身没问题但它在某些受限环境里会失效。env需要从 PATH 环境变量里去定位 bash如果 PATH 里没有 bash 所在目录同样会报No such file or directory而且报错信息里可能不带^M让定位更难。我自己在 crontab 里跑脚本时遇到过好几次这种问题。cron 环境的最小 PATH 通常只有/usr/bin:/bin如果 bash 装在其他位置脚本就无法启动。解决办法是脚本开头直接写死解释器绝对路径比如#!/bin/bash而不是#!/usr/bin/env bash。虽然牺牲了一点可移植性但换来了确定性和稳定。另外还有一个容易被忽略的脚本文件编码如果带了 BOMByte Order Mark第一行的#!前面会多出三个不可见字节同样会让内核找不到解释器。可以用sed -i 1s/^\xEF\xBB\xBF// check_db.sh把 BOM 去掉。提示排查执行类报错强烈建议先用bash -x ./check_db.sh强制跑一遍。这样能跳过执行权限和 shebang 解析的干扰直接暴露脚本内部逻辑错误是区分“脚本本身问题”和“环境问题”最快的手段。1.3 第三现场脚本内部调用的命令或文件缺失当脚本能正常启动、却仍然报No such file or directory时问题就转移到脚本内部了。在数据库查询场景里最常见的三类内部命令问题第一mysql、mysqldump等命令不在 PATH 中。交互式终端里你敲mysql -u... -p... -e select 1能跑通是因为用户的.bash_profile或.bashrc里加了 MySQL 的 bin 目录。但脚本执行时不一定继承这个环境尤其是通过 cron、systemd timer、CI 任务来调度的时候。这时候 shell 会报mysql: command not found注意这个报错通常不是No such file or directory但人慌起来容易把两者混为一谈。第二重定向目标目录不存在。比如脚本里写mysqldump ... /backup/$(date %F).sql而/backup目录根本没建shell 会报-bash: /backup/db_2025-01-01.sql: No such file or directory很多人误以为 MySQL 没起来实际就是目录没创建。第三读取外部 SQL 文件时文件不存在比如mysql -uapp -p*** app_db /opt/sql/init.sql文件缺失时同样报错。这个不用展开提醒一句就够重定向前先mkdir -p并检查源文件是否存在。用一套固定的定位流程能省很多时间# 1. 检查脚本格式 file check_db.sh # 2. 语法与跟踪 bash -n check_db.sh bash -x check_db.sh # 3. 查数据库命令位置 which mysql echo $PATH2. 数据库连接场景下“文件不存在”的隐藏根源2.1 socket 文件与连接配置的坑均匀排查完脚本自身真正的“数据库相关文件不存在”才会浮出水面。其中第一个经典坑是 MySQL 的 socket 文件。客户端连localhost时默认不走 TCP 网络而是去读 Unix socket 文件路径通常约定为/tmp/mysql.sock或/var/run/mysqld/mysqld.sock。如果服务端的 socket 路径和客户端默认路径不一致或者客户端通过--socket/custom/path/mysql.sock显式指定了错误路径报错往往长这样ERROR 2002 (HY000): Cant connect to local MySQL server through socket /tmp/mysql.sock (2)这个(2)就是系统错误码 ENOENT对应“No such file or directory”。很多人的第一反应是 MySQL 服务没启动systemctl status mysql查半天服务明明正常。真正的解法是确认 socket 文件的真实位置可以用find / -name *.sock 2/dev/null搜索或者在 MySQL 配置里查socket参数。脚本里推荐显式指定 socket 路径或者干脆用 TCP 方式连接-h 127.0.0.1绕开 socket 文件这个不确定性。如果只是为了跑查询我个人的习惯是在脚本里加mysql --socket/tmp/mysql.sock -h localhost ...如果-h写了127.0.0.1那么客户端会强制走 TCP就不会有 socket 文件相关的报错。两者选其一即可不要混着写。2.2 SQL 文件导入导出时的路径歧义前面提过重定向路径但这里值得再展开一点因为数据库脚本里太常见了。比如说做每日备份BACKUP_DIR/data/backup mkdir -p $BACKUP_DIR mysqldump --single-transaction -uapp -p*** app_db $BACKUP_DIR/app_db_$(date %F).sql三步少一步都不行。有些脚本写得很随意没建目录直接重定向。目录不存在时 shell 在打开目标文件之前就失败了。反过来导入场景也一样mysql -uapp -p*** app_db /tmp/import.sql/tmp/import.sql如果不存在想都不用想又是“No such file or directory”。这种问题定位很快但容易让人误入歧途跑去看数据库权限。我建议脚本里统一使用变量保存路径然后在执行前做一次文件判断SQL_FILE/opt/sql/init.sql if [[ ! -f $SQL_FILE ]]; then echo SQL file not found: $SQL_FILE 2 exit 1 fi mysql -uapp -p*** app_db $SQL_FILE这样至少报错信息是自己写的可读性比 shell 原生的报错好太多。2.3 环境变量丢失与 cron 调度场景数据库命令在交互式终端里能跑、在脚本里死活找不到这个“非交互环境”的坑在 crontab 里几乎是必踩。cron 执行脚本时环境变量基本是空的PATH只有系统默认值。如果你的 MySQL 装在/usr/local/mysql/bin那么 cron 里的脚本执行mysql时就是找不到命令。最稳妥的做法不是在 crontab 里写一堆PATH而是在脚本开头显式设置export PATH/usr/local/mysql/bin:/usr/local/bin:/usr/bin:/bin同时有些脚本会依赖~/.my.cnf来存储数据库账号密码。cron 执行时HOME不一定是你登录用户的 home 目录密码文件读不到连接数据库时就会跳出来交互式密码提示然后脚本卡死。这个问题表面上是密码错误实际是环境变量和文件路径问题和No such file同源。3. Shell函数传参数组到底怎么传才稳3.1 数组直接传函数的翻车现场数据库脚本第一步搞定之后第二个高频坑又在“函数封装”上等着你。写过一段时间 Shell 后你会发现查库逻辑一旦变成“对一组库批量执行同样的操作”自然就会想写一个函数然后把数据库名列表作为参数传进去。而数组传参的坑基本是每个脚本人都会踩一遍的。先看典型的错误写法#!/bin/bash DB_LIST(order_db user_db log_db) check_db() { echo 第一个参数: $1 echo 第二个参数: $2 } check_db ${DB_LIST[]}这样调用$1拿到order_db$2拿到user_db看起来好像没问题。但函数内部根本不知道数组一共有多少个元素如果数组是动态生成的长度不确定你要在函数里拿完整列表就得用$把所有位置参数重新收集一遍。这种做法有个严重的隐含风险一旦函数还需要接收其他业务参数数组元素和普通参数混在一起就再也分不清了。还有一种更隐蔽的翻车是用${DB_LIST}传check_db ${DB_LIST}在 Bash 里${arr}等价于${arr[0]}只取第一个元素。如果数组有 100 个库名函数里永远只看到第一个其余 99 个静默丢失。这种错误不会报任何错排查起来比报错坑得多因为逻辑上看着完全合理。3.2 方案一展开传参函数内重建数组最直观的兼容方案是调用时让数组展开成独立的多个参数函数内部再重新收集成数组check_dbs() { local dbs($) for db in ${dbs[]}; do echo checking $db done } DB_LIST(order_db user_db log_db) check_dbs ${DB_LIST[]}关键点是双引号不能丢。${DB_LIST[]}会保留每个元素中的空格而${DB_LIST[]}不带引号则会被 shell 分词一个元素可能被拆成两个。函数内部$同样要加引号然后再赋值给数组。这个方案 Bash 3.x 也能用兼容性最好缺点是数组很大时参数列表会膨胀同函数里的其他普通参数也容易搞混。如果函数里还有其他普通参数我的建议是先传普通参数再传数组展开函数内部用shift把普通参数消费掉剩下的全部收进数组check_dbs() { local mode$1 shift local dbs($) echo mode$mode, db count${#dbs[]} }3.3 方案二推荐传数组名加 nameref 引用比展开传参更优雅的方式是直接传数组的名字然后在函数内建一个引用。Bash 4.3 引入了nameref用declare -n或者函数内local -n声明check_dbs() { local -n db_ref$1 for db in ${db_ref[]}; do echo checking $db done } DB_LIST(order_db user_db log_db) check_dbs DB_LIST调用方传的是变量名字符串DB_LIST函数内部local -n db_ref$1建立了一个引用之后访问db_ref就等于访问外部的DB_LIST。名字传进来数据不复制函数内可以直接用${#db_ref[]}获取长度、按下标访问、甚至给数组追加元素。用这个方案有三个注意事项第一函数内的引用变量名不能和传入的数组名相同。如果外部数组叫db_ref函数内部又用local -n db_ref$1Bash 会报circular name reference。建议函数内部统一用_ref、_arr这类不太会和业务变量冲突的名字。第二nameref 是引用不是拷贝。函数内给db_ref重新赋值会直接修改外部数组。如果只想读取不要对引用变量做赋值操作如果想做副本可以在函数内先复制一份local -a tmp_arr(${db_ref[]})第三需要确认运行环境 Bash 版本不低于 4.3。macOS 自带的老版本 Bash 3.2 不支持declare -n这个改动没法用。生产环境建议统一维护一份较新的 bash或者用下面的兼容方案。3.4 方案三老版本 Bash 的兼容打法如果很倒霉线上环境是 Bash 3.x不能上nameref。退而求其次有两种办法。方法一是走全局变量数组定义在函数外函数内直接引用配合注释约定好职责。这个最简单但封装性差函数不通用换个数组名就得改函数没法做成公共函数库。方法二是用eval做间接展开。思路是先拿到数组名再从数组名反推出数组内容check_dbs() { local arr_name$1 eval local dbs(\\${$arr_name[]}\) for db in ${dbs[]}; do echo checking $db done }这一行的含义是先在外面构造一个字符串local dbs(${DB_LIST[]})然后让eval在当前位置执行它等于把目标数组复制成了函数内的dbs。好处是函数内后续操作都正常了坏处是eval对传入的$1完全不设防。如果数组名来自外部输入里面塞了一段恶意命令就会直接被 eval 执行。所以这个方案只适合自己内部明确可控的场景绝不建议对不可信的参数使用。3.5 三种方案怎么选做了张表方便生产环境直接对照方案Bash 版本要求数据复制主要风险推荐场景展开传参 $重建3.x是普通参数和数组元素易混淆数组较小一次性脚本nameref 传数组名4.3否circular name reference生产环境首选eval 间接展开3.x是命令注入风险老版本紧急兼容另外一个更省事的办法如果你的数组只是循环遍历其实不一定要传进函数。把for循环留在外层函数只接收单个元素比如check_db $db这就完全绕开数组传参问题。很多场景下这种“函数处理单值 外层循环”的结构比传数组更清晰也更符合 KISS 原则。4. 实战案例批量数据库巡检脚本的完整排查修复4.1 一个同时踩中两个坑的典型脚本下面这个脚本组合了前文所有坑的精华你可以先体会一下#!/bin/bash HOST_LIST(10.0.0.1 10.0.0.2 10.0.0.3) check_cluster() { local first$1 echo 第一个主机: $first mysql -h $first -uapp -psecret -e SELECT 1 21 } check_cluster ${HOST_LIST[]}这个脚本在部署过程中暴露了三个层级的错误CRLF 引发的解释器找不到、mysql 命令不在 PATH 里、数组传参后函数里只拿到了第一个主机。前两个报错是“No such file or directory”系的第三个是静默逻辑错误。4.2 从报错到修复的逐级排查先遇到的是第一个报错/bin/bash^M: bad interpreter。执行file check_db.sh看到with CRLF line terminators用sed -i s/\r$// check_db.sh处理掉。再次执行报错变成了mysql: command not found这说明脚本本身已经能跑了但mysql命令没被找到。which mysql没有任何输出说明 PATH 里不包含 MySQL bin 目录。检查发现 MySQL 是编译安装在/usr/local/mysql下的交互式终端里能跑是因为.bash_profile里 export 了 PATH脚本运行环境却没有。修复方式是在脚本开头export PATH/usr/local/mysql/bin:$PATH第三个问题就是在功能验证时发现的。脚本执行后循环并没有报错但输出里始终只显示10.0.0.1。检查了函数调用方式发现${HOST_LIST[]}展开后传给函数按位置参数看$1就是第一个元素函数里只用了$1后面的主机全部被忽略。这里改成 nameref传入数组名而不是展开内容。4.3 修复后的完整可运行版本修复之后的脚本长这样#!/bin/bash # 功能批量巡检数据库主机连通性 # 用法bash check_cluster.sh export PATH/usr/local/mysql/bin:/usr/local/bin:/usr/bin:/bin HOST_LIST(10.0.0.1 10.0.0.2 10.0.0.3) DB_USERapp_user DB_PASSapp_pass check_cluster() { local -n host_ref$1 local connect_timeout5 for host in ${host_ref[]}; do echo checking $host mysql --connect-timeout$connect_timeout \ -h $host \ -u$DB_USER -p$DB_PASS \ -e SELECT ok AS status; 21 \ || echo FAILED: $host done } main() { check_cluster HOST_LIST } main $这个版本有几个细节值得说。第一数组按名字传入用local -n host_ref$1绑定外部数组函数内部循环遍历时能拿到完整列表。第二mysql命令失败时用|| echo FAILED兜底而不是直接让脚本退出。这样才能完整巡检所有主机不会因为一台故障中断整批任务。第三脚本开头显式设置 PATH避免 cron 环境变量稀疏导致命令找不到。第四外层用main $包一层入口函数将来想通过命令行参数指定主机列表只需要在main里把$转成数组再传给函数即可改动很小。如果后续需要在巡检时同时传入“主机列表”和“端口列表”两个数组nameref 方案也能轻松扩展check_cluster() { local -n host_ref$1 local -n port_ref$2 ... } check_cluster HOST_LIST PORT_LIST一次传两个名字也没有参数拆分问题这就是传名字比传内容的优势。5. 避坑手册与经验心得5.1 报错排查的固定四步走结合这次排查过程我把固定动作总结成了四步先用file命令看脚本格式确认有没有 CRLF、BOM。这两个问题就算你看一百遍源码也看不出来只有工具能检测。用bash -n做语法检查再用bash -x跟踪执行定位报错发生的具体行。检查依赖的外部命令路径which mysql、which mysqldump在脚本开头显式 export PATH。数据库层面的连接报错优先看错误码和 socket 路径区分 TCP 和 socket 两种连接方式。特别是第一步我见过不少人在bad interpreter的报错下纠结了半小时最后把脚本删了重写。其实知道原理之后这只是一个 30 秒的修复。5.2 常见问题速查表报错现象根本原因定位方法修复手段/bin/bash^M: bad interpreter脚本行尾符是 CRLFfile script.sh看输出dos2unix或sed -i s/\r$//mysql: command not foundMySQL bin 不在 PATHwhich mysql、echo $PATH脚本开头export PATH或写死命令绝对路径ERROR 2002 (HY000) #2socket 文件不存在find / -name *.sock确认路径指定--socket或改-h 127.0.0.1走 TCP-bash: xxx.sql: No such file or directory导入文件或输出目录不存在ls -l、mkdir -p先建目录、先检查文件存在再执行函数只拿到数组第一个元素${arr}只取下标 0打印$#和所有参数用${arr[]}展开或传数组名用 namerefcircular name referencenameref 与循环变量同名看报错行号内部引用变量用独立命名5.3 长期有效的三条习惯第一个习惯编辑器统一配置 LF。我在 VSCode 里把默认行尾符改成了 LF同时项目根目录放.editorconfig写脚本时end_of_line: lf直接固化。Git 仓库里再加.gitattributes声明*.sh text eollf这样团队协作时不管谁在什么系统上编辑提交到库里都是 LF。第二个习惯写函数前先想清楚参数协议。小数量参数用位置参数批量数据优先传数组名或用外层循环单值传入。不要一边写一边改协议否则函数越改越乱。第三个习惯公共脚本上线前过一遍 ShellCheck。这个工具能检查出未加引号的变量、误用eval、权限问题等大量隐患比人工 review 靠谱得多。我在实际处理这类问题时还保留了一个不算优雅但很有效的习惯函数入口处临时加一行调试输出打印所有收到的参数个数和值。等确认逻辑无误再删掉。看起来笨但对定位“以为传了数组实际只传了一个值”这种静默问题比任何技巧都直接。这两类坑本质上都有共性Shell 脚本的边界条件比想象中多报错信息往往指向的不是真正的问题函数参数的传递不像其他语言那么直白。理解了内核加载脚本的方式、理解了 Bash 参数展开的语义再遇到它们就不会慌。我更推荐的方式是把这些经验沉淀成自己的一份脚本模板开头的 PATH 导出、函数传参约定、错误检查逻辑全都固化进去以后写新脚本直接从模板抄实测下来比每次都从零开始省心得多。
返回列表