先交代一个场景:接手一台新服务器,或者同事丢过来一个部署脚本,你敲下 ./deploy.sh,屏幕上冷冷地回了一句:
text复制-bash: ./deploy.sh: command not found
或者更诡异的——脚本本身跑起来了,执行到一半,内部调用某个命令时突然报:
text复制kubectl: command not found
这时候人很容易懵:文件明明就在当前目录,权限看起来也对,甚至你手动在终端里敲 kubectl 都好好的,怎么一进脚本就找不到?别急着怀疑服务器被黑,也别迷信"重装系统能解决一切"。command not found 这个错误看似一句话,背后可能藏着六七个完全不同的原因,从 PATH 环境变量、shebang 解释器、文件换行符,到系统哈希缓存、sudo 环境差异,每一种的排查思路都不一样。
这篇文章我按"报错发生在哪一层"来拆解,把 Linux 下脚本报 command not found 的典型原因、判断方法和修复手段整套捋一遍。无论你是刚接触 Linux 的新人,还是写自动化脚本的运维,都可以按文中的链路一步步对照排查。
1. 先定位:command not found 是从哪一层冒出来的
排错的第一步不是去瞎改脚本,而是搞清楚这行报错到底是谁在什么时候喊出来的。Shell 是命令的解释器,它按行读取脚本内容,遇到一个命令就去找、去执行。所以 command not found 可能出现在两个完全不同的时机。
1.1 交互式 Shell 直接提示 vs 脚本执行时提示的区别
你在终端里手动输入一个命令,Shell 找不到它,会在提示符下报错,比如:
text复制-bash: foo: command not found
这一般是"外部命令"没安装,或者安装后不在 PATH 里。
但你执行脚本时报错,情况就复杂了。你敲的是 ./deploy.sh,Shell 先要决定怎么去执行这个文件。它看到的不是你写的 shell 脚本,而是一个普通的可执行文件。这时候有几种完全不同的提示:
| 报错信息 | 真实含义 | 优先排查方向 |
|---|---|---|
bash: ./deploy.sh: No such file or directory |
文件路径不对,或者文件不存在 | 检查文件名、当前目录 |
bash: ./deploy.sh: Permission denied |
文件存在但没有可执行权限 | chmod +x |
bash: ./deploy.sh: command not found |
相对少见,通常是路径/文件实际不可达 | 检查路径、文件类型 |
bash: ./deploy.sh: /bin/bash^M: bad interpreter |
shebang 行有问题,解释器路径错误 | 查 CRLF 换行符 |
bash: kubectl: command not found |
脚本已经跑起来了,内部命令找不到 | 查 PATH、环境变量、哈希缓存 |
这表格值得存一下。因为很多人报修"脚本 command not found",实际是 Permission denied 或者 No such file or directory,三者的修复方式完全不同。先看清楚报错原文,能省掉一大半瞎折腾的时间。
1.2 脚本内部命令 not found 的典型链路
再往深一层看,脚本执行中报 command not found 的机制很有意思。Shell 是按行解释脚本的,每一行本质上都是一条"命令查找"操作。比如脚本里有一句 kubectl apply -f xxx.yaml,Shell 执行到这里时,会按照 PATH 变量里记录的目录顺序,逐个去找有没有叫 kubectl 的可执行文件。全部目录找完都没有,就抛出 kubectl: command not found。
这就是为什么很多人会遇到"手动敲没问题,脚本一跑就报错":你在终端里敲命令时用的是交互式 Shell,它的环境变量是从某个配置文件加载的;而执行脚本时,Shell 可能以非交互、非登录模式启动,读取的配置文件完全不同,PATH 里缺了 /usr/local/bin 或者你自定义的安装目录,自然找不到命令。
1.3 先用最小化实验分清主次
遇到报错,我习惯先做一个最小化实验:新建一个只含一行 echo hello 的脚本,分别用三种方式执行:
bash复制echo 'echo hello' > /tmp/t.sh
sh /tmp/t.sh
bash /tmp/t.sh
chmod +x /tmp/t.sh && /tmp/t.sh
如果三个都能跑通,说明 Shell 本身对脚本文件的解析、对 echo 这类内建命令的查找都没问题,问题大概率出在脚本内容或执行环境上。如果 sh /tmp/t.sh 也报错,那就要怀疑是不是连 /bin/sh 本身都不完整,或者解释器路径出了问题。这一步能帮你把"文件层问题"和"环境层问题"快速切开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PATH 环境变量:命令失踪的第一嫌疑犯
排掉文件本身的问题之后,十次里有八次都会撞上 PATH。这是 Linux 里最基础、也最容易翻车的环境变量,没有之一。
2.1 PATH 的查找机制与"为什么不写全路径就找不到"
PATH 就是一个由冒号分隔的目录列表。Shell 接到一个外部命令时,会按顺序在这个列表里找同名的可执行文件。找到第一个就用它,全部找不到才报 command not found。
生活里可以把它理解成"快递柜地址列表":你要取一个叫 kubectl 的包裹,快递员只按列表上的柜子挨个找,列表里没写这个柜子,哪怕包裹就放在你脚边,他也跟你说"没找到"。所以很多命令其实已经装好了,只是安装目录没被写进这个列表。
验证方法很直接:
bash复制echo $PATH
type kubectl
which kubectl
which 会告诉你 Shell 实际能找到的路径,type 会额外告诉你这个命令到底是外部程序、别名、内建命令还是函数。如果 which kubectl 能输出路径但脚本还是报 not found,几乎可以断定脚本执行时的 PATH 和当前交互 Shell 的 PATH 不一致。
2.2 交互 Shell 能用、脚本/定时任务却找不到的三种典型类型
这类"环境差异"在实操中非常常见,主要有三个场景。
第一个是 cron。Crontab 里跑脚本时,环境变量会被重置成一个极简列表,通常只有 /usr/bin:/bin。你在交互 Shell 里能用的 /usr/local/bin、/snap/bin、自定义脚本目录,cron 一概不认。于是你的备份脚本里写了 restic,手动执行好好的,定时任务一跑就报 restic: command not found。
第二个是 sudo。直接执行 sudo script.sh 时,sudo 出于安全考虑会用一个受限的 secure_path,把 PATH 重置成系统默认值。你自己编译安装到 /usr/local/bin 的命令,在 sudo 环境下直接消失。这个问题我在后面第五节专门复盘。
第三个是非交互式 Shell。Bash 启动时,登录 Shell 会读 /etc/profile 和 ~/.bash_profile,交互式非登录 Shell 会读 ~/.bashrc。但执行脚本时,Bash 通常是以非交互非登录模式启动的,这些文件一个都不读。很多新手把自定义 PATH 写进了 ~/.bashrc,手动开终端生效,脚本里却没这份配置。
2.3 脚本里的 PATH 被覆盖/污染的经典坑
还有一种更隐蔽的坑:脚本自己把 PATH 改了,改的方式还是"覆盖"而不是"追加"。
比如有些初始化脚本里会写:
bash复制export PATH=/opt/mytool/bin
这是一行直接釜底抽薪的写法。它把 PATH 从"系统原本的目录列表"替换成了孤零零一个目录。之后的每一行脚本,都只能在这个目录里找命令。如果脚本下一句调用了 systemctl 或者 curl,而这两个命令在 /usr/bin 下,系统就会毫不犹豫地告诉你 systemctl: command not found。
正确的追加方式应该是:
bash复制export PATH=/opt/mytool/bin:$PATH
把新目录放在最前面,同时把系统原有的目录保留下来。这个细节我在审阅脚本时几乎每次都要提醒。如果你发现自己的脚本里出现过第一种写法,那这次的 command not found 十有八九是它导致的。
3. 藏在文件底部的"隐形杀手":shebang、CRLF 和 BOM
有些 command not found 跟 PATH 一点关系都没有,问题出在脚本文件本身的第一行,甚至前几个字节。这层问题最坑人,因为光看文件内容一切正常。
3.1 shebang 正确写法与解释器路径问题
所有以 ./ 方式直接执行的脚本,都依赖文件第一行的 shebang。它的作用就是告诉内核:"请用后面这个程序来解释本文件内容"。最常见的写法是:
bash复制#!/bin/bash
或者更通用的:
bash复制#!/usr/bin/env bash
这两者的区别在于,前者的解释器路径是硬编码的 /bin/bash,后者则先调用 /usr/bin/env 去 PATH 里查找 bash。在容器、虚拟环境等场景下,env 写法的移植性更好。
但坑也在这。如果 shebang 写成了 #!/bin/basah 这种笔误,或者你的系统里 /bin/bash 因为某种原因不是独立文件而是软链接且目标被删了,执行时就会出现类似:
text复制-bash: ./deploy.sh: /bin/bash: bad interpreter: No such file or directory
有人会把这种情况也归纳成 command not found 一类。排查时直接看第一行:
bash复制head -1 deploy.sh
再验证解释器到底在不在:
bash复制ls -l /bin/bash
/usr/bin/env bash
3.2 跨平台编辑的 CRLF 换行符问题
这个是我见过最经典的"隐形杀手"。很多时候脚本是在 Windows 上写好再传到 Linux 服务器的,Windows 文本文件的换行符是 \r\n(Carriage Return + Line Feed),而 Linux 只认 \n。
于是 shebang 那行在 Linux 眼里实际变成了:
text复制#!/bin/bash\r
内核去查找解释器时,找的是一个名字叫 bash\r 的"幽灵文件",当然找不到。报错也很有特征:
text复制bash: ./deploy.sh: /bin/bash^M: bad interpreter: No such file or directory
看到 ^M 或者 bad interpreter 基本就可以判定是 CRLF 问题。确认方法:
bash复制file deploy.sh
cat -A deploy.sh | head -5
cat -A 会把行尾的 ^M$ 原形逼出来。修复也很简单:
bash复制dos2unix deploy.sh
# 或者用 sed
sed -i 's/\r$//' deploy.sh
3.3 BOM 不可见字符
比 CRLF 更隐蔽的是 BOM(Byte Order Mark)。UTF-8 编码的文件开头有可能被写入三个看不见的字节 EF BB BF。如果你的脚本第一行开头带着 BOM,shebang 就被挤成了 #!/bin/bash 前面多出三个字符,内核同样找不到解释器。
BOM 在当前大多数编辑器里都是"默认不写入"的,但从 Windows 的旧文本编辑器或某些云笔记里复制内容时容易带进来。验证方法是用 hexdump 看文件头部:
bash复制hexdump -C deploy.sh | head -2
如果第一行开头出现 ef bb bf,就去掉它。Vim 里可以用:
vim复制:set nobomb
:w
这些文件头部的细节,报错时从来不会直白地告诉你"你的换行符不兼容",而是伪装成解释器找不到、命令找不到。所以我把它们单独列一节,排查时一定多看一眼。
4. 层层排查:从最小可执行到系统级定位
当我遇到一个顽固的 command not found,我不会去网上搜一堆答案挨个试,而是走一条固定的排查链路。这条链路从"Shell 感知"到"脚本逐行"再到"环境差异",每走一步都能砍掉一拨可能性。
4.1 第一步:确认 Shell 对脚本文件的感知
这一阶段的核心是回答三个问题:文件确实存在吗?Shell 认它是可执行的吗?解释器找得到吗?
依次执行:
bash复制ls -l script.sh
file script.sh
type script.sh
head -1 script.sh
cat -A script.sh | head -1
ls -l 看权限位有没有 x,file 看文件类型和换行符格式,head 看第一行 shebang,cat -A 看有没有隐藏的 ^M 字符。这一套下来,文件本身的问题基本全部暴露。如果权限位没有 x,直接 chmod +x script.sh。
需要说明的是,如果文件存在但不可执行,直接 ./script.sh 通常报的是 Permission denied,而不是 command not found。所以当你看到 command not found 时,反而要先怀疑"这个文件在当前目录里到底存不存在",或者你是不是把文件名拼错了。
4.2 第二步:脚本内部逐行验证
如果文件本身没问题,那就把脚本内容加进排查范围。一种很笨但很有效的方法是"注释大法":把脚本后半段全部用 # 注释掉,只剩第一行命令,然后执行。如果第一行能通,再依次放开后面几行,直到某个命令突然报 not found,问题就聚焦到那一条命令上。
更高效的方式是启用 Shell 的调试跟踪:
bash复制bash -x script.sh
加上 -x 之后,Bash 会把每一条实际执行的命令打印到屏幕上,前置一个 + 号。脚本停在哪一行、哪一行开始找不到命令,一目了然。输出里如果出现:
text复制+ kubectl apply -f xxx.yaml
kubectl: command not found
就说明前面的逻辑全都正常,唯独自变量里的 kubectl 在执行环境里不可见。
如果不想改命令,也可以在脚本开头临时加一行:
bash复制set -x
跑完再删掉。需要注意的是,set -x 会把变量值也打印出来,如果脚本里有密钥之类的敏感信息,记得方式选安全一点,不要在共享终端里长时间开跟踪。
4.3 第三步:环境差异与工具链验证
脚本内部命令本身没问题、单行执行也对,那剩下的可能性基本就是执行环境不同。我一直觉得这个环节是排错的分水岭,它要求你不仅会敲命令,还要理解 Bash 的启动机制。
需要做两个验证。
第一个是验证"在新的子 Shell 里执行脚本时,PATH 到底是什么"。可以在脚本开头加一行:
bash复制echo "PATH=$PATH" >&2
这样执行时会把脚本环境里的 PATH 直接打印出来。拿到之后,跟你在交互终端里 echo $PATH 的结果比对,缺哪些目录一目了然。
第二个是模拟纯净环境。用 env -i 启动一个几乎不带任何环境变量的子进程,然后执行命令:
bash复制env -i bash script.sh
如果它立刻报 not found,而正常执行不报,那就确认脚本对当前环境有强依赖。解决思路不是把所有环境变量都搬进来,而是让脚本自身具备完整的 PATH 声明。我通常在脚本开头写上:
bash复制#!/usr/bin/env bash
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
这一行等于给脚本穿上了一套"标准环境裤",无论它是被 cron 调用、被 systemd 调用、被 sudo 调用,还是被某个 CI 平台调用,至少系统命令都能找到。
5. 真实生产环境的三次 command not found:复盘与修复
前面讲的是方法论,这一节讲我实际踩过的三次坑。每次都是同样一句 command not found,但每次的根因和修法完全不一样。
5.1 第一次:crontab 里的定时任务找不到 restic
当时给一台备份服务器写了 restic 的定时备份脚本。手动执行一切正常,一挂到 crontab 里,日志就报 restic: command not found。我检查了脚本权限、依赖,都没问题。最后才发现 restic 安装在 /usr/local/bin,而 cron 环境默认 PATH 只包含 /usr/bin:/bin。这个坑的本质是"cron 不读你的 Shell 配置"。
修复是在脚本开头补上完整的 PATH 声明:
bash复制export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
后来我养成了一个习惯:凡是写 cron 脚本,开头两行永远是 shebang 加 PATH 声明,跟写 Python 文件要声明 #!/usr/bin/env python3 一样自然。
5.2 第二次:用 sudo 执行安装脚本时 PATH 被替换
还有一次帮同事排查一个自动化部署脚本,脚本里要调用 kubectl。手动执行 bash deploy.sh 没问题,但是用 sudo bash deploy.sh 一跑,立刻报 kubectl: command not found。
原因就是 sudo 的 secure_path 机制。出于安全考虑,sudo 会把 PATH 重置成 /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 这套受信目录。如果你把 kubectl 安装在某个自定义目录,比如 ~/bin 或者 /opt/kubectl,sudo 环境的 PATH 里根本没有它。
了解机制之后,修法就好选了。一是在 sudo 执行时保留当前用户 PATH:
bash复制sudo env "PATH=$PATH" bash deploy.sh
二是直接修改 /etc/sudoers 里的 secure_path 配置,把自定义目录加进去。如果只是临时排查,我推荐第一种,不落盘,改完就走;如果是稳定使用的脚本,每个关键命令直接用绝对路径写死在脚本里,最省心。
5.3 第三次:命令明明装好了,执行还是 not found 的"伪缺失"
这个案例最有迷惑性。同事装了一个新版本的 Java,which java 已经指向了新路径。但执行脚本时,脚本里调用 java 依然报 not found,或者运行版本还是旧版本。
这里涉及 Bash 的哈希缓存机制。Shell 为了提高效率,会把"某个命令名对应哪个路径"缓存到内存里。你安装新版本之后,如果还是用同一个交互 Shell,它可能还记着旧路径,甚至记着"以前的 java 在 /usr/bin/java,现在那边没了"导致直接判定找不到。
修复方法一句话:
bash复制hash -r
这个命令的作用是清空当前 Shell 的哈希缓存,让之后每次查找都重新走一遍 PATH。除了手动 hash -r,重新开一个终端窗口也能解决。如果是脚本内的问题,多数是因为脚本在 PATH 尚未更新的情况下调用了命令,那就用绝对路径。
这一条提醒我们:command not found 不一定代表命令真的不存在,也可能代表"Shell 的记忆"停留在命令存在之前的某个状态。
6. 写脚本前值得养成的三个小习惯
踩过这么多坑之后,我现在的做法归纳起来就是三个习惯,按重要性排序。
第一个习惯是脚本开头声明 PATH,用追加而不是覆盖的方式。哪怕脚本只在本地跑,这一步也能避免将来被 cron 或 systemd 以不同环境拉起来时突然翻车。第二个习惯是写完脚本立刻 bash -n script.sh 做语法检查,再 chmod +x script.sh,保证用 ./script.sh 的直接执行路径可用。第三个习惯是遇到 not found 先看 type 而不是直接 which,前者能看到别名、函数、哈希状态等更多信息,大部分排错思路都是从这里起步的。
另外推荐一个顺手的小工具叫 shellcheck,它能在静态层面检测出不少脚本里的坑,包括未加引号的变量、可疑的 PATH 修改等。虽然它不会直接告诉你"你的 cron 环境缺路径",但可以把很多低级问题提前拦下来。
最后说一点个人体会。command not found 这个报错之所以劝退新人,不是因为它难,而是因为它把"文件系统""环境变量""Shell 机制"好几个层面的问题都压缩成了一句统一的提示。你只要记住一个原则:报错不是在否定你的脚本逻辑,而是在告诉你"Shell 按照它的规则找不到一个东西"。顺着这条思路,先定位环节,再套用前面表格里的排查方向,大多数问题几分钟就能定位。真正要小心的不是这一次报错,而是同样的报错下一次又换个马甲出现在你面前。
