YAOTU INSIGHTS

Shell脚本交互自动化:Here Document与Expect工具实践详解

Shell脚本交互自动化:Here Document与Expect工具实践详解
Shell脚本详解走到第十二篇前面的篇幅把变量、判断、循环、函数和文本处理都过了一遍但真正碰到实际问题时还有一个绕不开的场景交互。比如用ssh登服务器要输密码、跑某个配置脚本时系统弹出一连串“y/n”确认、用ftp或sftp传文件时要在交互提示符下敲命令。这些场景下脚本常常会卡住等到超时或直接失败。这篇我就专门聊聊处理交互的两套方案——Bash自带的Here Document免交互机制以及专门应对复杂对话流程的Expect自动化交互工具。这两样东西不冲突反而互补Here Document适合“一次性把输入塞给命令”让命令从标准输入里把该读的数据都读走Expect则适合“看清楚程序说了什么再像真人一样逐句回应”。这篇会把底层原理、常用语法、实战案例和踩过的坑都过一遍偏运维和自动化脚本编写的朋友可以直接参考。1. Here Document免交互基础先把输入一次性塞给命令1.1 基本语法定界符选什么、结尾能不能有空格很多朋友初次接触Here Document都会觉得语法很绕其实核心就一行命令 分隔符 内容... 分隔符Shell看到这个重定向符之后会把后面两个“分隔符”之间的所有内容当作标准输入喂给前面的命令。这里的“分隔符”可以随便取常见的有EOF、END、HELP它本质上只是一个标记不是关键字。需要注意的细节有这么几条开头的定界符和结尾的定界符必须完全一样一个字都不能差结尾的定界符必须顶格写在行首前面不能有空格也不能有Tab结尾定界符的后面不能再有其他字符哪怕多一个空格都可能报错正文内容可以包含空行、特殊字符Shell会原样保留直到再遇到那个一模一样的定界符。看个最简单的例子cat EOF Hello, $USER Today is $(date %F) EOF这段脚本执行后cat会把两行内容原样输出。重点是EOF本身不会出现在输出里它只是告诉Shell“输入到这里就结束”。原理上就是把中间内容重定向到命令的标准输入不是把它当命令的参数传进去这也是为什么结尾定界符必须自己占一行顶格写——Shell得靠它判断输入边界。还有一个容易踩的点如果正文里恰好有一行是EOF而且它也顶格写了Shell会认为输入提前结束了。所以选择定界符时尽量避免和正文内容撞车例如内容里经常出现“END”字样那就换EOF1或者DOC_END这种更独特的标记。1.2 变量替换与转义默认会展开不想展开就加引号Here Document一个很实用的特性是默认情况下正文里的变量、命令替换都会被Shell展开。我写过这样一个脚本片段nameTom cat EOF My name is $name, I am running $(uname -s) EOF执行结果会是My name is Tom, I am running Linux这里$name被替换成了“Tom”$(uname -s)也被执行后替换成“Linux”。这个特性非常适合用来做配置文件模板后面实战部分会专门讲。但有些场景恰恰不希望展开。比如你有一段纯文本要原样写入文件文本里包含$HOME字符串你不想让它变成实际的路径。解决方式有两种第一种是给定界符加引号写成 EOF或 EOF单双引号效果一样都会关闭变量替换和命令替换。注意要加在开头的定界符上结尾的定界符保持原样cat EOF $HOME is not expanded here Today is $(date) or not? EOF输出会原样显示$HOME和$(date)Shell不去解析它们。第二种方式是逐个转义在想要保留的$前面加反斜杠例如\$HOME。这种方式适合只想保留少数符号的场景。用引号关闭解析更适合正文中大量$符号的情况比如写SQL模板、Nginx配置片段时如果你不希望里面的变量被提前展开就果断给定界符加单引号。1.3 行首缩进处理-能把Tab剥掉写脚本时有个很实际的问题函数内部写Here Document为了代码可读性正文部分往往自带缩进。但这里的缩进会原样进入输出生成的文件开头多出一堆空格或Tab看起来非常乱。Bash提供了一个变体写法-它会在输出前把每行行首的所有Tab字符删掉。注意这里有个前提删除的是Tab不是空格。如果用空格缩进-不会帮你清理。举个例子在函数里生成一个简单配置write_config() { cat - EOF server { listen 8080; root /var/www; } EOF }如果不用-输出会带着函数缩进的Tab用了-之后那些行首的Tab会被去掉配置文件就干净了。但如果你配置文件本身就依赖行首的Tab缩进这个操作会把它们也删掉所以使用前要判断格式对行首空白是否敏感。我在实际中一般优先用-配合Tab缩进写SQL、提示语、邮件正文这类内容写YAML和Python代码时就不太适合因为YAML和Python对缩进敏感删掉Tab会破坏结构。2. Here Document实战从生成文件到模拟用户输入2.1 生成配置文件cat 与tee的正确用法Here Document最常见的用途之一就是在脚本里生成多行配置文件。最经典的方式是配合cat做重定向domainexample.com cat /tmp/app.conf EOF server { listen 8081; server_name ${domain}; root /var/www/app; } EOF执行后/tmp/app.conf里就会生成一份内容而且${domain}已经被替换成example.com。这种写法的好处是直观、好调试想验证内容就直接cat /tmp/app.conf。但它有个局限当你想写入受保护的系统路径时比如/etc/下的文件命令会写成sudo cat /etc/xxx.conf。这时sudo只作用于cat重定向的却是由当前Shell执行的于是会报“Permission denied”。遇到这种情况更顺手的工具是teesudo tee /etc/rc.local EOF #!/bin/bash echo custom boot script EOFsudo tee让tee以root权限打开目标文件定界符之间的内容会同时输出到文件和一个标准设备。用tee还有一个额外好处内容也会在终端上打印出来相当于边写边看。不想看到回显的话可以把标准输出重定向到/dev/null。2.2 用Here Document喂给read和环境变量赋值除了生成文件Here Document还可以用来给read提供输入。平时我们写交互脚本时会用read从键盘读取内容脚本就会卡在那里等输入。而在自动化测试场景里我们不想等人工输入可以通过Here Document把数据“塞”过去read -r name city age EOF 张三 上海 28 EOF echo name$name, city$city, age$age执行后输出name张三, city上海, age28。read在这里从标准输入读取了一整行按空白符拆分依次赋值给三个变量。这样脚本跑起来不会停也方便在循环里模拟不同用户的输入。另一个很实用的技巧是用命令替换配合Here Document把一段多行文本放进变量greeting$(cat EOF Hello, ${USER}. Today is $(date %F). EOF ) echo $greeting这段脚本里cat把Here Document的内容输出$(...)捕获这些输出并赋值给greeting变量。这种写法在需要构造多行提示、多行日志、动态模板时非常好用比一行行echo拼接容易维护得多。2.3 给mysql这类交互命令批量灌入SQL凡是能读标准输入的命令都可以享受Here Document的便利。比如mysql在自动化初始化数据库时可以直接把一组SQL语句传给客户端mysql -u root -p${DB_PASS} testdb EOF CREATE TABLE IF NOT EXISTS users ( id INT PRIMARY KEY, name VARCHAR(50) ); INSERT INTO users VALUES (1, alice); EOF这样拔掉了手动一条条敲SQL的繁琐步骤而且脚本可以反复执行。需要说明的是密码写在命令行或脚本里本身有安全隐患实际过程中建议用mysql_config_editor配置登录路径或者从环境变量读取避免密码出现在进程列表里也避免记录进shell历史。类似的还有sqlite3、psql、redis-cli等操作思路完全一致。判定的标准很简单只要命令支持从标准输入读数据就能用Here Document送数据。这里有个反例是passwd这类要求从终端设备读取密码的交互命令它比较特殊用Here Document不一定可靠需要下面的Expect出场。3. Expect自动化交互像真人一样看见提示再回应3.1 为什么不用Here Documentstdin和终端设备的区别这里需要先讲清楚一个底层区别很多交互程序并不是从标准输入读取数据而是直接打开当前终端设备如/dev/tty来读写。也就是说你用Here Document把数据送进标准输入程序根本不看。典型就是ssh和passwd它们会强制走终端设备普通重定向骗不过去。另外交互过程可能是有分支的。比如首次ssh连接会问“Are you sure you want to continue connecting?”需要回yes然后才出现密码提示而第二次连接又不会问你信任提示。这种动态逻辑不是单纯“喂一次输入”就能解决的你得根据程序的输出来决定下一步回什么。Expect就是干这个的。它的核心思路是启动一个子进程然后持续监听该进程的输出一旦匹配到预期的提示文字就自动发送预设的输入内容。本质上是模拟一个真人在旁边观察屏幕、敲键盘的过程。3.2 安装Expect与脚本基本形态Expect不是Shell脚本本身的一部分它是一个独立的工具基于Tcl语言。大部分Linux发行版都可以直接从软件源安装# CentOS/RHEL系 yum install expect -y # Debian/Ubuntu系 apt install expect -y安装完成后可以用which expect确认版本。Expect脚本可以写成独立文件也可以直接用expect -c ...在bash里临场执行。独立脚本一般长这样#!/usr/bin/expect -f set timeout 10 spawn ssh root192.168.1.10 expect password: send your_password\r第一行指定解释器是expect不是bash。开头用set timeout设置全局超时秒数spawn启动一个交互程序expect等待输出中出现某段字符串send发送回应。这几个命令就是Expect全部的核心组合起来就能完成大部分自动化交互。3.3 spawn、expect、send人机对话的模拟回路逐个拆开说明一下spawn用来启动一个进程后接要执行的完整命令。命令启动后Expect会接管它的输入输出程序在终端上打印的内容都会被Expect捕获。expect负责等待子进程的输出它后面接一个字符串模式。当输出中匹配到这个模式脚本才会继续往下走。匹配用的是类似shell通配符的glob风格不是正则表达式所以*能匹配任意多个字符?匹配单个字符。send负责发送字符串。务必注意send只是把内容放进程序的输入流不一定触发执行。很多交互程序需要按回车键确认所以send的内容结尾要加\r。这个\r模拟的就是回车键少了它你输入的密码会停在命令行的“输入区”但程序不会认为你已经输入完毕。可以理解成三段式循环看输出、找关键词、回消息。写一个自动登录的入门示例#!/usr/bin/expect -f set timeout 30 spawn ssh root192.168.1.10 expect password: send abc123\r expect # send uptime\r expect # send exit\r expect eof这个脚本做了几次循环匹配密码提示后发送密码匹配到root提示符#后执行uptime再次匹配提示符后执行exit最后用expect eof等待进程退出。注意最后的exit和eof是为了让登录的远程会话正常退出避免脚本结束后遗留后台进程。3.4 多模式匹配、exp_continue与timeout上一节脚本能跑通是建立在对方只会出现“password:”这一个提示的前提下。现实很骨感首次ssh连接会出现信任确认Are you sure you want to continue connecting (yes/no)?如果脚本里只写了expect password:那这次连接就会卡住直到timeout默认10秒还没匹配到然后继续往后执行send就会把密码发给一个没有准备好的程序整个流程就乱了。解决办法是使用多模式匹配expect { yes/no { send yes\r; exp_continue } password: { send $pass\r } }这里expect后面跟了一个花括号块里面可以写多个“模式-动作”对。匹配到yes/no就发送yes\r然后执行exp_continueexp_continue的含义是“继续留在当前expect块中做下一次匹配”。如果不写它匹配到yes/no执行完动作后expect就结束了后续的password:就不会被这个块匹配到。有了exp_continue可以处理连续的交互回合。比如程序先问“y/n”你答完以后又问“y/n”再答完以后才要密码这都可以在同一个expect块里用exp_continue逐轮处理。timeout也是这个体系里的关键选项。Expect默认超时时间为10秒也就是发现匹配不到时会等10秒再继续。超时可以通过set timeout设置全局值也可以在某处写成set timeout -1表示永不超时。实际写自动化脚本时我习惯把全局超时设为30秒单步操作太慢的场景单独加长避免脚本在一个卡死的进程上等待过久。3.5 interact与参数传递Expect脚本如果只用来“自动执行一段命令”一般不需要interact。但很多场景是自动登录后希望把控制权还给用户让用户继续手动敲命令。这时就用interact#!/usr/bin/expect -f set timeout -1 spawn ssh admin192.168.1.20 expect { yes/no { send yes\r; exp_continue } password: { send your_password\r } } interact执行到interact后Expect会暂停自己的自动化逻辑把终端控制权交还给用户。此时你可以正常操作远程主机直到手动退出登录。脚本里的参数传递也很有用。Expect脚本可以像bash脚本一样接收命令行参数使用的是Tcl语法#!/usr/bin/expect -f set user [lindex $argv 0] set host [lindex $argv 1] set pass [lindex $argv 2] spawn ssh $user$host expect password: send $pass\r interact这里的[lindex $argv 0]是Tcl的命令替换argv是一个列表lindex取出第0个元素。调用方式就是expect login.exp root 192.168.1.10 pssw0rd如果你在bash脚本里调用Expect注意引号处理。给expect -c传代码时最外层用单引号包住整段Tcl代码内部的双引号就不用反斜杠转义了expect -c set timeout 10 spawn ssh root192.168.1.10 expect password: send abc123\r interact 4. Expect实操SSH登录、SCP传输与批量改密4.1 自动SSH登录并执行命令把前面各部分组合到一起就能写一个相对完整的、可复用的SSH登录脚本。下面这个脚本支持首次连接时的yes/no确认也兼容普通用户和root用户的不同提示符#!/usr/bin/expect -f set timeout 30 set user [lindex $argv 0] set host [lindex $argv 1] set pass [lindex $argv 2] spawn ssh $user$host expect { yes/no { send yes\r; exp_continue } password: { send $pass\r } } expect * send whoami\r expect * send exit\r expect eofexpect *这个写法的意思是“匹配任意输出”用它来等待上一个命令的输出返回。这种写法不够严谨但胜在通用不会因为提示符不确定而挂死。如果想把输出抓回来给bash用可以在命令末尾加上重定向比如send df -h /tmp/disk.txt\r或者配合log_user 0关闭回显再用expect_out(buffer)捕获。脚本执行时如果结果有异常可以靠exit 0或exit 1把状态传回给调用方。比如在expect eof之后加几个条件判断分支方便外层bash判断成功或失败。4.2 用scp自动传文件并在bash中集成批量部署场景经常要用scp把安装包分发到多台机器。scp同样属于交互命令首次连接也有yes/no确认。用expect写一个独立传输脚本#!/usr/bin/expect -f set timeout 60 set file [lindex $argv 0] set user [lindex $argv 1] set host [lindex $argv 2] set pass [lindex $argv 3] spawn scp $file $user$host:/tmp/ expect { yes/no { send yes\r; exp_continue } password: { send $pass\r } } expect eof这里最后一个expect eof非常关键。scp传输结束后子进程会退出Expect必须等到它退出才算完整结束。如果脚本在send $pass\r之后直接结束scp进程可能还没跑完就被强制终止文件传输不完整。在bash侧可以用循环批量分发for ip in $(cat hosts.txt); do ./scp.exp install.sh root $ip R00tPasswd if [ $? -eq 0 ]; then echo $ip 传输完成 else echo $ip 传输失败 2 fi done这里靠$?拿到expect脚本的退出状态。批量运维时建议做一个简单的执行日志同时把失败的主机单独记下来重试不要中断整个流程。4.3 批量修改系统用户密码的中英文兼容写法用expect批量修改用户密码是很多初始化脚本里的常见需求。passwd命令本身会从终端设备读取新密码两次并且提示文字在不同语言环境下不一样。中文系统会显示“新的密码”和“重新输入新的密码”英文系统则显示“New password”和“Retype new password”。直接写成固定文本很容易挂。更稳妥的做法是用通配模式同时兼容“密码”和“password”#!/usr/bin/expect -f set timeout 10 set user [lindex $argv 0] set newpass [lindex $argv 1] spawn passwd $user expect { *密码* { send $newpass\r } *password* { send $newpass\r } } expect { *密码* { send $newpass\r } *password* { send $newpass\r } } expect eof第一次expect匹配到密码提示发送新密码第二次expect继续等待确认提示再次发送同一个密码最后等待程序退出。这个写法不需要关心系统语言匹配关键词用的是宽松通配。需要提醒的是批量改密这类操作影响面大执行前一定要先在小范围机器上验证脚本确认输出符合预期再推全量。密码的存储和传递也要谨慎尽量用加密的凭据管理方式别直接把明文密码写在分发脚本里。5. 踩坑实录与排查技巧那些让脚本挂住的细节5.1 Here Document常见报错速查我见过很多朋友第一次写Here Document时不是在语法上卡住就是在格式细节上翻车。这里整理出一份快速排查表现象原因处理方式脚本末尾提示unexpected end of file结尾定界符漏写、写错或者没顶格检查结尾定界符是否与开头完全一致确保行首无空格无Tab生成的文本里变量被意外展开Here Document默认执行变量替换和命令替换给开头定界符加引号如 EOF输出文件行首有多余Tab脚本自身缩进被带进了输出使用-让Shell剥掉行首Tab用sudo cat /etc/xx.conf报权限错误重定向由当前Shell执行普通用户无写权限改用sudo tee /etc/xx.conf EOF正文里包含与定界符相同的行导致提前结束定界符撞车换一个更独特的定界符避开正文关键字最后一条最容易忽略。假设定界符是END而正文里恰好有一行写的是END而且顶格了Shell就会把这一行当作输入结束标记后面的内容全部丢失脚本还可能报奇怪的语法错误。所以定界符命名时稍微考究一点不要太“通用”。5.2 Expect匹配不到时的排查顺序Expect脚本最常见的故障是程序没有按预期走下去最后超时。超时往往不是脚本卡死而是expect在等某个模式但那个模式一直没出现。可以先按以下顺序排查先确认实际输出到底是什么。在expect块前后临时加sleep 5或者直接改成interact手动看一眼程序界面上的内容。很多时候你会发现密码提示是Password:但你的脚本写的是password:大小写都写错了。再检查模式写法。Expect默认是glob风格匹配不是正则。*password*表示“包含password的一段字符串”password:表示“必须是有password:的字符串”。如果输出中有控制字符或颜色转义序列精确匹配就很容易失败用*password*这类宽松模式更稳妥。接着检查超时问题。默认timeout是10秒匹配不到会继续执行而不是直接报错。所以你看到脚本“似乎停了一下又继续往下走”很可能就是没匹配到悄悄超时了。此时可以把set timeout 30的数值调大同时打开调试输出观察它到底卡在哪一个expect上。还要考虑编码和中文环境。中英文系统提示不同写死password:在中文系统上必挂。要么同时兼容“密码”要么统一用正则风格匹配关键的英文单词。跨环境执行脚本前多想想目标机器上的语言环境。5.3 三条调试Expect脚本的实用路径第一是用调试模式直接跑。Expect自带-d参数expect -d login.exp root 192.168.1.10 pass123运行后终端会打印大量调试信息包括它读到了什么、当前在匹配哪个模式、超时剩余时间等。这是最直观的排查方式。写完一个交互脚本发现卡住时先别急着改模式用-d跑一遍往往一眼就能看出问题。第二是在脚本里写exp_internal 1。这会开启内部日志效果和-d类似但可以嵌入到脚本的任何位置#!/usr/bin/expect -f exp_internal 1 set timeout 10 spawn ssh root192.168.1.10 ...如果脚本是在生产环境里跑的可以把日志输出到文件exp_internal -f /tmp/expect_debug.log 1这样出了问题不用盯着控制台看事后翻日志也能还原现场。第三是拆分验证。不要一上来就写一个很长的Expect脚本先用expect -c在命令行里做最小验证确认某个模式能匹配到、某个\r能生效再逐步组合成完整脚本。比如临时测一下密码匹配expect -c set timeout 5 spawn ssh root192.168.1.10 expect password: send test123\r interact 能跑通再搬到文件里。这个习惯能省下大量排错时间。另外提一个经常踩的细节发送的密码如果包含特殊字符比如!、$、\在Tcl字符串里可能被特殊处理。稳妥的做法是用花括号包住原样字符串或者用变量接收并严格测试set pass {Pss!w0rd$} send $pass\r如果密码来自外部参数调用脚本时记得用单引号包住避免bash先把特殊字符展开。写到这里说一点我个人的使用体会。在本地方便重定向的场景比如生成配置文件、批量执行SQL我优先用Here Document因为它轻量、好懂无需额外安装任何东西凡是涉及远程登录、动态问答、程序必须从终端设备读取数据的场景就直接上Expect。两者通常不是“二选一”更多是搭配使用。实际操作中我习惯把Expect脚本写得短小、参数化一个脚本只负责一种交互动作这样出了问题定位快也方便在bash外层用循环批量调度。最后分享一个自己的习惯所有交互脚本里都不要写死真实密码优先用参数传入或环境变量读取脚本文件权限也尽量收紧。自动化的目标是省事但前提是安全可靠。如果能在可控的小范围机器上多试几轮再铺开后面整个人都清净许多。