写脚本自动化部署的时候,我最头疼的不是权限问题,不是变量嵌套,而是那一堆“交互式提问”。装软件时它问你y还是n,连服务器时它让你输密码,改配置时它要确认覆盖。新手很容易把Here Document和Expect混为一谈,觉得都是“提前准备好输入内容”,实际上这两者的工作机制和使用场景差别非常大。
这篇文章就把这两个东西掰开揉碎讲清楚:前半部分讲Here Document免交互的完整用法和常见坑,后半部分讲Expect自动化交互的四个核心命令和实战套路。适合正在学shell脚本、写自动化部署脚本、或者被交互式命令卡住的运维和开发朋友。
1. 两种免交互机制的定位差异:heredoc与expect各自该什么时候用
1.1 先从一次部署卡壳说起
我之前写过一批批量初始化服务器的脚本,原本计划得挺好:脚本里探测一下系统版本,装几个包,改几行配置,最后把服务拉起来。结果卡在了一个交互式安装程序上——那玩意装到一半会停下来问“是否需要保留旧的配置文件”,等十秒钟没人回答就按默认选项继续。
一开始我用Here Document去处理,把它需要回答的内容提前塞进去:
install_some_tool <<EOF n n Y EOF结果程序根本不买账,照样卡在那里等输入。后来才发现,这类程序读取输入的时候用的是/dev/tty,根本不读标准输入流。你heredoc写得再周全,人家压根不看你递过去的东西。
这就是Here Document和Expect最本质的区别:heredoc是把内容预先放到标准输入流里,但凡程序是从终端设备读取输入的,heredoc就失效了。而Expect的核心思路是模拟真实用户去“对话”——它看着程序输出什么,就回什么,完全模拟一个真人坐在终端前面敲键盘。
1.2 一个核心判断标准:输入是“预定剧本”还是“即兴对话”
我后来给自己总结了一条判断标准,用到现在基本没出过错:如果这次交互的内容是固定不变的、程序不会根据输入改变提问顺序,就用heredoc;如果程序会根据你的回答产生新的问题、需要你根据回显内容临时决定下一步,就用Expect。
打个比方,heredoc是提前写好一整段台词的提词板,把自己要说的话一次性念完;Expect是和一个活人聊天,对方说什么你接什么,有时候还需要等对方先开口。
比如往文件里写入一段固定配置、给数据库执行一批固定的SQL语句、给FTP传一个文件清单,这类用heredoc就非常合适。但如果是SSH首次登录时的“是否确认主机指纹”确认、执行sudo时提示输密码、或者连上一台网络设备后要根据对方的命令提示符动态下发命令,就得上Expect。
有时候两者的边界会重叠,比如你用heredoc把expect的脚本内容传给expect解释器执行——这也是后文会提到的实用技巧。理解了定位差异,后面学语法就不容易绕晕。
2. Here Document基础语法与常用变形
2.1 最正统的写法:cat配合EOF往文件里写内容
Here Document的语法结构不复杂,核心就是用<<加定界符把一段内容包起来,遇到单独的定界符就结束。最常见的用法是用cat把内容重定向到文件里:
cat <<EOF > /etc/motd 欢迎访问这台服务器 当前登录用户:$USER 系统时间:$(date "+%Y-%m-%d %H:%M:%S") EOF执行完以后去/etc/motd看一眼,$USER和$(date)已经被替换成实际值了。这是因为 heredoc 的内容在交给cat之前,先被当前的bash主进程做了变量展开和命令替换。这一点非常关键——你感觉是在写一份静态文件,但bash早把里面的动态部分处理完了。
于是衍生出一个实用分支:如果你希望把这批内容原封不动地写到目标文件里,包括那些$符号,就要给定界符加引号,最常用的是单引号:
cat <<'EOF' > /tmp/script.sh #!/bin/bash echo "当前时间:$(date)" echo "用户目录:$HOME" EOF这里$和反引号都会被保留,不会产生意外展开。写配置模板、生成脚本、保留正则表达式时,这个习惯能省掉很多麻烦。
2.2 按需关闭扩展:单引号定界符的隐藏价值
前面已经提到,单引号定界符<<'EOF'会关闭整段内容的扩展。但很多人在实际用的时候只关注“变量会不会被替换”,却忽略了另一个场景:内容里同时包含需要展开的部分和不需要展开的部分。这种情况下,单独用EOF或者单独用'EOF'都不完美。
处理办法其实不复杂。如果你只需要拦住其中某一个变量不被展开,可以在它前面加反斜杠,比如\$USER,这样bash只对这个变量跳过展开,其余地方照常处理:
cat <<EOF 当前路径:$PWD 字面变量演示:\$HOME 不会展开 系统信息:$(uname -r) EOF还有一种比较少见但很实用的场景:heredoc内容里要写一个EOF字符串原文。此时如果外层定界符也用EOF,就会提前截断。解决方法是换一个不冲突的定界符,比如EOS、_END_、MARK,定界符本身可以任意取值,只要前后一致且不与正文冲突即可。
2.3 逃不掉的缩进问题:<<-的实用场景
写脚本时为了保持代码整洁,很多人喜欢把heredoc块放进if分支或for循环里,并加上缩进。但bash解析heredoc时,要求在结束定界符那一行不能有任何前置空格或Tab,否则就判定为内容还没结束。于是经常出现一个经典的报错:脚本跑了半天,最后提示warning: here-document delimited by end-of-file。
解决方案是使用<<-:
if [ "$1" = "init" ]; then cat <<-EOF > /tmp/init.txt 初始化配置 第2行内容 EOF fi注意这里的细节:<<-只在内容行和结束行都使用Tab缩进时才有效,连续多个空格它是不会处理的。很多编辑器默认把Tab转成4个空格,这时候<<-照样报错,需要在编辑器里关闭“缩进转空格”的选项,或者干脆写脚本时不缩进heredoc块。
2.4 Here String:比heredoc更迷你的输入手段
除了heredoc,bash还提供一种更简洁的免交互输入方式:Here String,语法是<<<。它能把一个字符串作为标准输入传给命令:
grep "error" <<< "2024-01-01 10:00:00 error: disk full" wc -l <<< "a b c"这里wc -l统计的其实是三行文本。Here String常被用在比较简单的单行输入场景,比如配合read给变量赋值、给passwd以外的命令投喂一行交互内容。它的字符串内容同样遵循变量扩展规则,想要关闭扩展同样可以用引号包住字符串,不过这种方式本质上是字符串,不是文本块,论灵活性远不如heredoc。
3. Here Document在真实运维场景里的几种玩法
3.1 批量创建系统配置:heredoc配合tee追加重定向
cat <<EOF > file是最常见的组合,它的缺陷是每次都会把文件整个覆盖。如果你需要在文件末尾追加内容,写法改成cat <<EOF >> file就行。如果既要保留原有内容,又希望在文件里写入多段内容,更稳妥的组合是配合tee:
tee -a /etc/security/limits.conf <<EOF * soft nofile 65535 * hard nofile 65535 EOF使用tee -a的好处之一是内容写入的同时还会输出到终端,方便在脚本里实时查看写入结果。当然也可以把tee的输出重定向到/dev/null,只在脚本日志里留下痕迹。我第一次批量改几十台服务器配置时就踩了坑——只用了>覆盖模式,结果把原来文件里的默认配置给清了,服务重启后直接出问题。从那以后,凡是修改已有配置文件,我默认都用追加或先备份再覆盖。
3.2 把SQL喂给数据库:MySQL导入不再手动粘贴
运维或者开发环境里经常要执行一批SQL,手一条条粘贴既慢又容易出错。heredoc特别适合这个场景,因为SQL里常常包含单引号和特殊字符,用<<'EOF'可以避免不必要展开:
mysql -uroot -p密码 -h127.0.0.1 <<'EOF' CREATE DATABASE IF NOT EXISTS monitor DEFAULT CHARSET utf8mb4; USE monitor; CREATE TABLE IF NOT EXISTS metrics ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, value DOUBLE NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); INSERT INTO metrics (name, value) VALUES ('cpu_usage', 12.5); EOF注意这里用了单引号定界符,避免SQL里的内容被shell展开。如果你不小心用了不带引号的EOF,哪怕SQL里只有一个$符号,bash就会试图把它当成变量替换,结果要么报错,要么查出来的数据不对。
同样的思路还可以用于给Redis执行批量命令、给MongoDB执行JS脚本、给Hive执行HQL等。核心都是同一个逻辑:把一段固定文本作为标准输入灌给交互程序。
3.3 heredoc的边界:为什么它搞不定动态交互
heredoc处理静态输入很顺手,但遇到“需要等待程序回显再决定下一步”的交互就无能为力。最典型的例子是SSH首次连接某个陌生主机时的确认:
Are you sure you want to continue connecting (yes/no)?如果写成ssh root@host <<EOF ... EOF,这个yes/no的问题会直接显示在终端上等待手动输入,heredoc里的内容并不会自动去回答它。因为ssh这个程序在做二次确认的时候,读的是当前终端的输入,而不是标准输入管道。
类似的还有passwd修改密码、sudo提示密码、各类交互式的安装向导。它们有些是因为安全原因刻意不从标准输入读取,有些是因为程序内部会在打开终端设备后重新读取。这时候就需要Expect出场——它的设计目标就是把这种“动态交互”变成自动化。
4. Expect自动化交互:四个命令吃透整个逻辑
4.1 安装与基本运行方式
Expect是一个基于Tcl语言编写的自动化工具,大多数Linux发行版的软件源里都有。安装很简单:
# Debian/Ubuntu apt install expect -y # CentOS/RHEL yum install expect -y装完之后不需要额外配置。Expect脚本有两种运行方式:一是给脚本加上#!/usr/bin/expect解释器,直接执行;二是在bash脚本里用expect -c "脚本内容"或expect <<EOF方式嵌入。下文会分别演示。
先解释一下Expect的工作原理:它启动一个子进程(比如ssh),然后程序通过伪终端和这个子进程通信,监听子进程的输出。当输出匹配到我们设定的模式时,就向子进程发送指定的输入内容。本质上就是“读出屏幕上的文字,判断后自动按键”。
4.2 spawn、expect、send、interact的分工与配合
Expect有四个核心命令,理解了它们的分工,脚本基本就能写出来。
spawn:启动一个交互程序,可以理解成“打开一个虚拟终端并运行命令”。expect:等待子进程输出的关键字,匹配到之后执行预设动作。send:向子进程发送字符串,模拟用户敲键盘。interact:把控制权交还给用户,之后由真人手动操作。
看一个最基础的登录示例:
#!/usr/bin/expect set timeout 5 spawn ssh root@192.168.1.10 expect "password:" send "mypassword\r" expect "#" send "uptime\r" expect "#" send "exit\r" expect eof解析一下:spawn启动SSH进程,然后脚本在expect "password:"处停下来,等待SSH输出包含password:字样。一旦匹配到,send把密码发送过去。注意发的是\r回车而不是\n换行,因为终端交互程序认回车键。之后同理,发送命令后等待命令提示符#出现,再发下一条命令。
interact命令用于把长期的控制权交还给人。比如你要登录一台机器做点临时操作,可以写一个半自动脚本:自动输密码,登进去以后交给人操作:
#!/usr/bin/expect set host [lindex $argv 0] set pass [lindex $argv 1] spawn ssh root@$host expect "password:" send "$pass\r" interact脚本支持从命令行接收参数,[lindex $argv 0]是第一个参数,[lindex $argv 1]是第二个参数。执行方式:
./auto_login.exp 192.168.1.10 'mypassword'4.3 超时与分支判断:exp_continue的真正价值
expect脚本里第一个容易忽略的就是超时。set timeout 5意思是如果5秒内没匹配到关键字,就继续往下执行。如果不设置,默认超时时间是10秒。等你匹配一个可能根本不存在的关键字,脚本会在10秒后直接跳过。如果希望永不超时,可以设置set timeout -1。
第二个容易忽略的是多分支匹配。实际交互中,程序往往不是只给出一种提示。比如SSH首次连接,可能会出现yes/no确认,而连接过的机器直接就是password:。这种情况下单用expect "password:"就可能出错。
正确的写法是用多分支结构:
#!/usr/bin/expect set timeout 10 spawn ssh root@192.168.1.10 expect { "(yes/no)" { send "yes\r" exp_continue } "password:" { send "mypassword\r" } timeout { puts "连接超时" exit 1 } } expect "#" send "hostname\r" expect "#" send "exit\r" expect eof这里exp_continue是核心关键字,作用是“匹配成功并发送内容后,继续等待下一个模式”。第一次遇到yes/no时,脚本回复yes,然后执行exp_continue,回到expect继续等待。下一次匹配到password:,就输入密码。如果10秒内什么都不匹配,就走进timeout分支,打印提示并退出。
exp_continue还有一个变体用法:你可以把它写进所有匹配分支的末尾,实现循环匹配同一个输出。比如程序连续输出多屏信息,每屏出现--More--时按下空格继续翻页,可以写成:
expect { "password:" { send "123456\r" } "--More--" { send " "; exp_continue } "#" { send "exit\r" } }这段脚本就能应对一边输密码、一边刷出翻页提示、最终出现命令提示符的复杂情况。
5. Expect实战:从免密登录到批量巡检
5.1 最小可用脚本:自动登录远端服务器并执行命令
先搭一个最实用的模板。假设有多台服务器需要定期巡检,手动登录然后挨个敲uptime、df -h、free -m太费时间。写一个expect脚本自动完成这些事:
#!/usr/bin/expect set timeout 10 set host [lindex $argv 0] set pass [lindex $argv 1] spawn ssh -o StrictHostKeyChecking=no root@$host expect { "password:" { send "$pass\r" } timeout { puts "密码提示超时"; exit 1 } } expect "#" send "echo '==== 系统负载 ===='; uptime\r" expect "#" send "echo '==== 磁盘使用 ===='; df -h\r" expect "#" send "echo '==== 内存使用 ===='; free -m\r" expect "#" send "exit\r" expect eof注意这里给ssh加了一个参数-o StrictHostKeyChecking=no,作用是跳过主机指纹确认。平时自己连接机器时不建议关掉这个检查,但脚本里如果不想处理首次登录的yes/no问题,这个参数可以让流程简化不少。如果出于某种原因必须保留指纹确认,就把前面那个多分支expect的写法用起来。
这个脚本的不足之处在于每台机器都要单独执行一次。要批量处理,可以写一个外层bash循环:
#!/bin/bash for ip in 192.168.1.10 192.168.1.11 192.168.1.12; do echo "==== 正在巡检 $ip ====" ./auto_check.exp "$ip" "密码" done5.2 处理yes/no确认与密码提示的完整流程
如果不想关掉主机指纹确认,又希望脚本能处理首次连接时的yes/no,关键就是5.2节提到的多分支+exp_continue写法。完整流程应该是:
#!/usr/bin/expect set timeout 8 set host [lindex $argv 0] set pass [lindex $argv 1] spawn ssh root@$host expect { "yes/no" { send "yes\r" exp_continue } "password:" { send "$pass\r" } timeout { puts "连接超时" exit 1 } } expect "#" send "pwd\r" expect "#" send "exit\r" expect eof这里有个很隐晦的坑:有些系统SSH的提示文本是Are you sure you want to continue connecting (yes/no)?,有些则直接是yes/no短句。正则匹配在expect里默认是子串匹配,写成"yes/no"就能匹配到。但如果你用了-re正则标志,就得处理其中的括号和问号,反而不如直接用精确匹配。
5.3 把expect嵌进bash脚本:两种语言的协作套路
很多人不习惯单独写.exp文件,更希望所有逻辑都放在一个bash脚本里。expect提供了两种嵌入方式,分别适合不同场景。
第一种是用expect -c直接传入脚本内容。单行命令适合极简场景:
expect -c ' spawn ssh root@192.168.1.10 expect "password:" send "123456\r" expect "#" send "hostname\r" expect "#" send "exit\r" expect eof '注意这里外层用了单引号,保证内部的$不会被bash展开。如果你的密码里含有$字符,这种方式就有些危险——单引号内的内容虽然不会被bash展开,但expect/Tcl在处理$pass这类变量时又会做一层解析。稳妥的做法是把密码存成环境变量,在expect内用$env(PASSWORD)读取:
export TARGET_PASS='p@ss$w0rd' expect -c ' set timeout 5 spawn ssh root@192.168.1.10 expect "password:" send "$env(TARGET_PASS)\r" interact '第二种嵌入方式是用heredoc把expect脚本作为内容传给expect解释器,这正好把前文的两个技术点串起来了:
#!/bin/bash expect <<'EOF' set timeout 5 spawn ssh root@192.168.1.10 expect { "yes/no" { send "yes\r"; exp_continue } "password:" { send "123456\r" } } expect "#" send "uptime\r" expect "#" send "exit\r" expect eof EOF这里的<<'EOF'用了单引号定界符,目的是把内部的$、\r等字符原样交给expect解释器。如果你忘了加单引号,bash会先尝试展开$env之类的变量,遇到不存在的变量会被替换成空字符串,脚本就会出错。
我倾向于推荐heredoc嵌入方式,原因是多行、多分支的expect代码在-c参数里写起来很费劲,转义也会变得一团糟,而heredoc可以保持脚本整体的可读性。
6. 排错经验与收尾建议
6.1 最常见的几个坑
把频繁踩到的坑整理成一个表,方便排查:
| 症状 | 原因 | 解决方案 |
|---|---|---|
heredoc报here-document delimited by end-of-file | 结束定界符前有空格/Tab,或结束定界符没单独成行 | 顶格写结束符,或改用<<-配合Tab缩进 |
| heredoc里变量被意外展开 | 定界符没加引号 | 内容含$、反引号时用<<'EOF' |
| expect发送了字符串但程序没反应 | 用了\n而不是\r | send内容用\r结尾模拟回车 |
| expect脚本卡住不动,直到超时 | 匹配的模式写错了,程序输出和预期不一致 | 先手动执行一次命令,查看实际输出文本再改脚本 |
密码含$、[等特殊字符发送错误 | 传给expect时被bash或Tcl做了解释 | 脚本内用$env(变量)读取环境变量,或对特殊字符转义 |
执行expect时报command not found | expect未安装或解释器路径不对 | 先执行which expect确认路径,直接用绝对路径写shebang |
这里重点说一个经常被忽略的细节:expect匹配模式里的括号是有意义的。写expect "(yes/no)"时,括号是普通字符,bash不会管它,Tcl默认也按字面量处理。但如果你在这个位置加了-re正则标志,圆括号就变成分组符号,字符串里的括号会被当成正则语法,匹配可能莫名其妙失败。我的经验是:能不用正则就不用,用子串匹配反而更稳。
6.2 一条务实的使用准则
用熟了之后会发现,Expect也不是万能钥匙。它的问题在于太吃匹配文本,程序输出变了就得跟着调整脚本;而且密码明写在脚本里本身就有安全风险。所以我自己的使用准则是这样的:
能用heredoc解决的,绝不上expect。比如写文件、导SQL、批量执行固定命令,这些都是“预定剧本”,heredoc一句话就搞定。只有面对那种真正动态交互、且没法通过修改程序参数来绕过的场景,才动用expect。而在expect里,尽量把密码用环境变量传进来,脚本本身不落地保存敏感信息,执行完就销毁。
如果交互流程很简单,还可以考虑更轻量的方案,比如用sshpass传密码。但sshpass有个明显的短板:它只能处理密码输入,没法应对yes/no和后续的动态交互,灵活度远不如expect。最终选型还是回到第一个问题的答案:脚本要处理的是定式还是变式。
最后分享一个小技巧:expect脚本在调试时,可以在脚本开头临时加一行exp_internal 1,它会输出详细的交互过程日志,把程序每次显示的内容和expect匹配的进度都打印出来。我第一次写多分支匹配时,就是靠这个命令看清了SSH到底输出的是password:还是Password:,一下就定位到了问题。