1. 交互式命令是怎么把你的脚本"卡死"的——免交互的动机剖析
1.1 一个典型的运维事故:脚本在密码提示符前停住
先说个我自己遇到过的事。某次半夜处理一批测试服务器的批量初始化,写了个Shell脚本打算循环SSH到每台机器上执行统一命令。脚本逻辑本身没问题,循环、命令、日志都安排得明明白白。结果一跑,卡在第一次登录的密码提示符上,脚本死等了四十多分钟,等我第二天早上看日志才发现:它一直在等一个永远不会被输入的人。
这种事在Shell编程里太常见了。很多人把脚本写成了"半自动"——前半段自动跑,遇到密码、确认、选项这类提示就停下来等人。这在偶尔跑一次的时候没什么感觉,一旦涉及批量处理、定时任务、无人值守场景,脚本一卡就是一整夜。所谓免交互(Here Document 和 Expect 相关技术),本质上就是把"人在终端前敲键盘"这件事,变成"程序自己把回答准备好,照着剧本往下演"。
1.2 免交互工具的定位:让程序在"无人值守"下把活干完
我要先把"免交互"这个概念说清楚,因为市面上很多文章把EOF和Expect混为一谈,好像它们解决的是同一个问题。其实不是。
- **EOF免交互(Here Document)**解决的是"输入内容已知、固定"的场景,你只需要把一段文字、一堆参数或者一个配置文件内容送进命令的标准输入,不需要程序额外做什么判断。
- Expect免交互解决的是"输入内容取决于程序输出"的动态场景,比如SSH登录时问你"Are you sure you want to continue connecting?",有时又问密码,有时直接通过,你需要根据它的回复来决定下一步回什么。
这两套工具定位不同、适用边界不同、踩坑姿势也完全不同。把这两件事分开吃透,才是Shell免交互的正确打开方式。这篇文章我就围绕EOF和Expect这两条路线,把原理、用法、选型边界和我在实际项目里踩过的坑一次性讲透。
2. EOF免交互:用一份"临时文件"喂饱命令行
2.1 EOF为什么叫"免交互",核心语法一次说清
EOF在Shell里的正规名字叫Here Document(中文一般译作"此处文档"或"内嵌文档")。它做的事情很朴素:把两个定界符之间的内容,当作标准输入送给前面的命令。最常见的写法长这样:
cat << EOF Hello, world This is a test file. EOF这里<< EOF的意思就是"下面开始是输入内容,直到单独一行的 EOF 为止"。Shell把中间的三行原文不动地作为stdin传给cat,cat再把它们原样打到屏幕上。所以很多人说EOF是"免交互",其实它不是真的不需要人,而是把"交互时人会敲的内容"提前写死在脚本里了,程序跑起来时不会再等你。
登录环境里最典型的应用是给交互式的passwd命令喂新密码。平时你要连续输两次新密码才能改掉一个用户的密码,而用EOF可以直接这样写:
passwd $USER << EOF NewPass123 NewPass123 EOFpasswd会从stdin读取输入,两次读到的都是同一行内容,密码自然就设置成功了。注意这里必须保证两个定界符之间的内容是"用户原本会在终端上敲的完整回答顺序",一旦顺序不对,命令照样报错。
2.2 引号定生死:变量展开与字面输出的区别
EOF有个非常容易踩的坑:定界符到底加不加引号。
看这两条命令:
name="alice" cat << EOF Hello, $name EOF cat << 'EOF' Hello, $name EOF第一次执行输出的是Hello, alice,第二次执行输出的是字面的Hello, $name。原因很简单:定界符EOF不带引号时,Shell会对文档内容里的变量、命令替换、转义字符先做一轮展开;带了单引号'EOF'后,Shell就当它是普通文本,完全不碰内部内容。
这两个行为各有用处。我在写临时配置文件时通常会带上单引号,因为配置文件里的$符号(比如环境变量占位符、模板字符串)太多了,一旦被Shell抢先展开,整个文件的内容就乱了。反过来,如果你想借用Shell的展开能力生成动态内容,就故意不加引号。
写入配置文件的经典例子:
cat > /etc/rsyslog.d/example.conf << 'EOF' $template CustomLog,"/var/log/custom/%fromhost%.log" *.* ?CustomLog EOF这里的$template是rsyslog自己的语法,如果我不加单引号,$template就会被Shell展开成空字符串(因为没有名叫template的变量),配置直接坏掉。这是我实际踩过坑的场景,那时候排查了很久才发现是引号的问题。
2.3 配合cat、tee和管道:解放双手的进阶玩法
EOF配合cat是最常用组合,但也不是唯一选择。我整理几个高频用法,大家可以直接抄:
| 用法 | 命令示例 | 适用场景 |
|---|---|---|
| 写入文件 | cat > file << 'EOF' | 生成静态配置文件 |
| 追加入文件 | cat >> file << 'EOF' | 追加多行内容 |
| 写入并保留原权限 | tee file << 'EOF' | 避免>重定向导致文件属主变化 |
| 作为命令输入 | some_cmd << EOF | 给交互式命令喂固定回答 |
| 配合上一条命令 | cmd1 | cmd2 << EOF | 先组织文本再交给管道处理 |
tee可能有些人用得少,我说个具体场景。用普通用户执行cat > /etc/some.conf,会因为没有写权限失败;用sudo执行时,涉及到的文件会被创建为root属主。某些程序要求配置文件得属于某个特定用户组,这时候就可以用sudo tee file << 'EOF'——tee会以root权限写文件,但文件原本的属主信息在处理得当的情况下不会被动,比单纯重定向更可控。
管道这里也给个小例子,把一段内容交给grep做筛选:
grep -E "error|warning" << 'EOF' INFO: task ok ERROR: task failed WARNING: disk full EOF比如编写日志分析脚本时,先手工列一些测试数据,再用管道命令做处理,调试起来比反复创建临时文件干净得多。
2.4 EOF的固有短板:它只认识固定剧本
EOF再好用,也得认清它的边界。它要求输入内容在脚本运行之前就是完全确定的,如果你需要根据程序输出的内容决定下一步输入什么,EOF就无能为力了。
举个例子,写一个自动SSH登录的脚本:
ssh user@192.168.1.10 << EOF ls /opt EOF这个脚本在免密登录配好的机器上能正常跑,但如果目标机器第一次建立连接、提示确认指纹(Are you sure you want to continue connecting (yes/no)?),EOF就傻了——它把准备好的ls /opt一股脑发给ssh进程yes?之前,登录流程根本走不下去。
另一个典型的例子是MySQL登录时提示Enter password:,如果数据库之前记录过密码或者密码没位,提示出现的次数和时机都是浮动的。固定输出内容给浮动交互过程,就像拿着旧剧本去接一场即兴话剧,接不住的。
这时候就该上Expect了。
3. Expect免交互:能对话的自动化脚本才是真免交互
3.1 Expect的"对话式"工作逻辑:spawn、expect、send三位一体
Expect是个来自Unix世界的经典自动化工具,它能启动一个外部命令,监视命令的输出,发现特定字符串后自动发送预先写好的应答内容。整个过程就像一个"机器人替身"坐在终端前,一边看屏一边敲键盘。
核心就三个命令:
spawn:启动一个进程,比如spawn ssh user@hostexpect:等待进程输出中的某个字符串,匹配到就继续往下走send:向进程发送字符串,等价于在终端里敲入内容
你没看错,整个Expect脚本本质上就是一个"输出的字符串到匹配信号到响应"的状态机。用最基础的登录场景来演示:
#!/usr/bin/expect spawn ssh root@192.168.1.10 expect "password:" send "MyPassword123\r" expect "$ " send "uptime\r" expect "$ " send "logout\r" expect eof这里expect "password:"的意思是"只要屏幕上出现了password:这个字符串,就执行下一步",send "MyPassword123\r"中的\r是回车键——这一步等价于人敲密码之后按回车。它跟EOF的核心差异就在这儿:Expect是根据进程反馈来决定何时发送什么内容,不是提前把所有内容一次性倒进去。
3.2 处理不稳定提示:timeout与exp_continue的正确打开方式
真实环境中的交互永远有噪音。SSH登录时,第一台机器直接要密码,第二台机器先问Are you sure you want to continue connecting (yes/no)?再来要密码;改了端口、目标机响应慢、$提示符延迟出现……这些情况都导致上面的简单写法不够用。
处理不确定分支的标准写法是expect块配合exp_continue:
#!/usr/bin/expect set timeout 10 spawn ssh root@192.168.1.10 expect { "yes/no" { send "yes\r" exp_continue } "password:" { send "MyPassword123\r" } "*assword" { send "MaybeAnotherPass\r" } timeout { puts "连接超时" exit 1 } } expect "# " send "df -h\r" expect "# " send "exit\r" expect eofexp_continue的意思是"匹配到这个分支后,不要停下,继续等待下一轮输出"。用它可以优雅地处理第一次询问确认指纹、之后又要密码的情况——第一个分支匹配完发送yes后继续等password:,等到了再发密码,整个登录过程一气呵成。
set timeout 10则是全局超时设定,expect在10秒内没等到目标字符串就执行timeout分支。我喜欢在脚本开头设置一个合理的timeout,原因很实际:Expect脚本一旦写错分支,进程就会在交互端卡到地老天荒。设置超时等于给脚本上了保险,最多10秒就能失败退出,绝不会像纯Shell脚本那样无限期卡住。
3.3 把Expect包进Shell脚本:两种吃法都有讲究
Expect有两种用法,新手经常分不清。
第一种是直接把脚本保存为expect文件,用expect解释器执行,就像上面那样。第二种是在Shell脚本里内嵌Expect代码,我身边的老运维更常用这种写法。因为它能把Shell循环和Expect的交互能力混在一起,做批量操作非常顺手。
内嵌方式一:here doc提供给expect
#!/bin/bash hosts="192.168.1.10 192.168.1.11" for host in $hosts; do expect << EOF spawn ssh root@$host "uptime" expect { "yes/no" { send "yes\r"; exp_continue } "password:" { send "MyPassword123\r" } } expect eof EOF done注意到问题没有?expect << EOF用的是不带引号的定界符,所以$host会被Shell提前展开成具体IP,这是好事——因为Shell知道循环变量。而如果我在这里写成expect << 'EOF',$host就变成字符串原样传给expect,expect反而会把它当Tcl变量去解析。这一条是内嵌Expect时最容易踩的坑。
内嵌方式二:临时expect文件
#!/bin/bash cat > /tmp/tmp_expect.exp << 'EXP' set timeout 5 spawn ssh root@$host "uptime" expect { "yes/no" { send "yes\r"; exp_continue } "password:" { send "MyPassword123\r" } } expect eof EXP expect /tmp/tmp_expect.exp这种写法先通过单引号定界符把set timeout 5和$host写成expect自己的变量逻辑,再在expect文件内部通过命令行传参方式注入主机名。两种方式我都在用,如果脚本只是临时跑一跑,我倾向用内嵌here doc;如果这段expect逻辑会被多个脚本复用,就拆成独立文件再传参调用。
3.4 实际跑过之后才懂:那些卡死的expect进程都是为什么
按理说Expect解决了动态交互,应该天下太平了,实际跑起来还是有个很隐蔽的问题——expect的提示符匹配和interact的取舍。
先说提示符匹配。很多人在expect "# "这一步栽过跟头。根用户提示符确实通常是#,但有些机器PS1被定制过,提示符变成了[root@host:/home]#,你的脚本写expect "# "也能匹配上;但万一机器上某个命令输出里恰好也包含了#,expect就会过早匹配、提前发消息,把交互节奏打乱。所以我的经验是:匹配提示符尽量选择足够独特的字符串,比如]\\$或者一个你echo出来的特殊标记,而不是泛化匹配。
再说interact。Expect做完自动操作之后,有时你需要把控制权交还给人来继续操作。这时候用interact命令,让"机器人"把终端让给真实用户。但要注意,interact一把控制权交出去,expect脚本就不会自动退出了,会话将一直挂着直到人为退出。如果这个expect脚本是被别的调度程序调用的,会话挂起会导致整个任务链卡住。我的解决办法是在interact前后加上明确的expect eof和exit 0,保证即便用户不操作,脚本也有一个可预期的结束路径。
4. EOF与Expect怎么选:从"交互稳定性"出发的决策表
4.1 一个决策表和一个真实场景拆解
网上很多文章把EOF和Expect并列介绍,但没有说明什么时候用哪个。我根据自己做自动化运维的经验,整理了一张决策表,基本能覆盖90%的场景:
| 判断维度 | 选EOF | 选Expect |
|---|---|---|
| 输入内容 | 固定、可预知 | 取决于程序输出 |
| 对话轮次 | 一轮输入,不回头 | 多轮问答,有分支 |
| 典型命令 | passwd、grep、cat、mysql初始化 | ssh、su、telnet、交互式安装程序 |
| 依赖环境 | Shell内置,无需额外安装 | 需要安装expect包 |
| 调试难度 | 低,看输出就行 | 中,需要理解"匹配-响应"逻辑 |
| 性能消耗 | 低 | 中等,有spawn子进程和匹配负担 |
既然决策表有了,我拿一个真实场景来拆解:批量创建用户并设置初始密码。
用EOF的方法,前提是用户列表提前写好,密码也提前决定,脚本里循环执行useradd,再用上一章提到的here doc给passwd喂密码:
#!/bin/bash for user in alice bob carol; do useradd "$user" 2>/dev/null echo "User $user created" passwd "$user" << EOF InitialPass123 InitialPass123 EOF done这种场景输入内容完全确定,用EOF干净利落,不需要Expect。但如果需求改成"登录到远程服务器,查看每台机器的数据盘使用率,超过阈值就在线扩容",这里面的交互就复杂了——你要先登录、可能要sudo提权、可能要回答确认、可能要等待动态的输出内容。用EOF就非常脆弱,Expect才是正解。
另一个选型细节:脚本的依赖环境。EOF是Shell内置语法,任何有bash或sh的机器都能用,不需要额外安装。Expect是个独立程序包,部分精简系统没预装,需要提前确认或在脚本里做依赖检查。在只有基础系统的环境中跑自动化,which expect常常返回空,所以我习惯在脚本开头先做个检测:
if ! command -v expect > /dev/null 2>&1; then echo "expect 未安装,无法执行自动化交互任务" exit 1 fi4.2 密码不落盘:免交互脚本里的安全底线
最后必须单独说安全,因为我在很多同事的脚本里见过密码直接写在明文里。EOF喂密码也好,Expect里send "MyPassword123\r"也好,明文密码都会出现在脚本文件里,谁拿到脚本谁就拿到密码。有几个降级风险的手段,按推荐顺序排列:
- 优先使用免密钥方案。SSH场景尽量配置公私钥认证,从根本上回避密码交互,这也是我处理自动登录类需求时的首选。常见做法是用
ssh-copy-id把公钥分发到目标机器,之后脚本再也不需要密码了。 - 使用环境变量或外部文件传递密码。比如执行脚本前
export MY_PASS="xxx",Expect里写send "$env(MY_PASS)\r",把密码从脚本内容里剥离出去。 - 使用带超时值的expect,禁止在日志里打印密码。有些人在调试时会加
exp_internal 1看详细日志,里面会带上send出去的密码内容——调试完成后记得关闭,同时脚本里设置set timeout 3这类短超时,避免会话长时间悬挂。
我自己写免交互脚本时的底线是:密码信息绝不写死在要交付给团队共用的脚本文件里。要么以参数传入,要么以环境变量注入,要么干脆走密钥认证。自动化程度再高,也不能牺牲资产安全。
EOF和Expect分别解决了"固定内容的无人输入"和"动态应答的自动对话",两套工具相辅相成,熟练之后几乎能扛住九成以上的交互式命令场景。实际项目里把它们和循环、条件判断、日志重定向组合起来,才是真正把Shell自动化做到位的状态。至于更复杂的组合技巧,那又是另一个话题了。