ARTICLE DETAIL

资讯详情

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

Shell脚本后台运行遇密码交互?用Expect和pexpect实现自动化运维

Shell脚本后台运行遇密码交互?用Expect和pexpect实现自动化运维 接手过不少部署和迁移任务也栽过不少跟头。印象最深的一次是帮同事跑一个数据导出任务五个程序要依次启动前四个都是常规脚本扔到后台加个就完事结果第五个是需要连接远端数据库的加密校验工具跑到一半停在终端上光标后面只有一个Password:在闪。当时手忙脚乱地敲完密码它又弹出一个二次确认。整个流程被卡得死死的自动化脚本一旦遇到这种“交互式”输入就成了摆设。后来我才认真琢磨明白“文件式”和“交互式”这两种运行方式的区别也才算彻底解决了一个很常见又很头疼的问题——shell脚本要用放到后台执行但某个程序偏要你现场输入密码。这篇文章就把这套处理逻辑和实操方案完整记录下来适合正在写部署脚本、做自动化运维、或者跑定时任务时被密码交互卡住的朋友。读完你会发现所谓的“运行五个程序”并不复杂真正复杂的是让“想后台运行”和“必须人工交互”这两件事和解。1. 为什么“五个程序”总有一个卡在密码上文件式与交互式的本质差异先说清楚一个底层概念。程序运行大体上有两种形态文件式和交互式。“文件式”指的是程序逻辑写在一个脚本文件里比如deploy.sh、tool.py你把它作为参数交给解释器或执行器程序从文件读代码从头跑到尾做完事就退出。这种情况的好处是全程可以无人值守输入输出都可以重定向特别适合放到后台用去跑。“交互式”则是程序运行到某一步时停下来向标准输入stdin要数据。常见的就是让你输密码、输入y/n确认、或者选择菜单项。程序在等待你录入这个“等待”如果得不到满足它就一直挂着。1.1 你以为的“五个程序”和实际上的“五个程序”很多人规划任务时心里想的是“五个程序不就是一个挨一个跑吗”。等真动手写脚本才发现这五个程序的脾气完全不一样。我一般会把它们分成三类一是纯文件式程序。输入输出都很干净命令行参数给全了就能跑比如日志切割脚本、数据统计脚本、带完整参数的备份命令。二是半交互式程序。它有默认行为但特定条件下会问你问题比如未配置SSH密钥时会让你输密码磁盘空间不足时问你“是否继续”解压加密归档时要求输入密码。三是全交互式程序。天生就是为人在终端前操作设计的比如很多数据库客户端、加密工具、远程管理面板的CLI入口。它从启动那一刻起就等着你的输入。如果“五个程序”里混入了后两类而你还在用对待“纯文件式程序”的思路写调度脚本那卡住几乎是必然的。1.2 卡住那一刻程序到底在等什么当终端停在Password:的时候很多人第一反应是“程序没响应了”其实恰恰相反程序活得好好的只是在等stdin上的数据。常规脚本里你写ssh userhost它发现没有密钥可以免密就自动进入密码验证流程向当前终端请求输入写openssl enc -aes-256-cbc -in a.txt -out a.enc它检测到需要口令就弹出enter aes-256-cbc encryption password:。如果你把这个程序直接丢到后台麻烦就来了——后台进程的标准输入通常被shell重定向到了/dev/null程序读不到任何字符于是挂着。有个朋友和我说他用echo mypassword | ./tool.sh这种方式也不行。原因是有的程序读密码不是简单读一行它要直接操作终端设备检测到标准输入不是终端时干脆拒绝继续。这就是为什么“文件式思路”解决不了“交互式问题”。1.3 一个典型场景拆解五个程序的角色分配我以一个实际做过的数据迁移任务为例让大家看看五个程序混合在一起是什么状态程序作用运行方式问题点程序A生成增量数据文件文件式参数齐全无程序B压缩并归档数据文件式但会问是否覆盖已有文件需处理确认提示程序C加密归档文件交互式等待输入加密口令必须输入敏感信息程序D导入远端数据库交互式等待数据库密码必须输入敏感信息程序E清理临时文件文件式无如果只写一行A B C D E那C跑起来就卡住D更是想都不用想。就算你终于想到用重定向喂密码很多加密和数据库工具会主动检测输入源发现不是“人”就拒绝读。这种场景必须引入专门的交互式自动化工具。2. 解密底层机制后台执行、isatty检测与标准输入的三角关系要彻底解决这个问题不能光靠背命令得知道底层几个机制是怎么配合的。2.1到底带走了什么在shell里写cmd shell会把这个命令放入后台执行。为了不让后台进程和终端抢占输入标准输入默认被重定向到/dev/null。所以当程序真的想读密码时read系统调用拿到的就是一个EOF绝大多数程序的处理方式是直接报错退出也有的程序会抛出类似“inappropriate ioctl for device”的提示然后中止。这就能解释一种很迷惑的现象同一个命令你手工在终端跑得好好的一放到后面就失败或者一写进crontab就失败。它们都是一样的原因——检测不到交互式终端或者说是stdin不被认为是一个“人”在控制。2.2 isatty()判断背后是不是“人”很多程序在读取敏感信息前会调用isatty()函数检查标准输入的文件描述符是否指向一个终端设备TTY。如果返回是0就认为输入来自管道、文件或非终端设备从而拒绝继续。这就是为什么echo pass | ./encrypt_tool有时不灵的原因。程序要的不是“有数据能读”而是“数据必须是终端上敲出来的”。我在写这篇东西之前特意做过一个验证实验用python3 -c import sys;print(sys.stdin.isatty())分别在前台终端、echo | python3 ...、python3 ... /dev/null三种方式下执行结果分别是True、False、False。这个细节直接决定了方案怎么选。2.3 常见误区三种看起来合理但必然失败的喂密码方式很多人第一反应是用管道喂密码几种典型写法如下# 方法1管道符直接喂 echo mypassword | sudo -S apt update # 方法2重定向从文件读 ./some_encrypt_tool password.txt # 方法3变量加管道 echo $DB_PASS | mysql -u root -p这些方法在“程序只需一行明文密码、且没有做TTY检测”的前提下是能工作的。但遇到真正的交互式程序就会碰到这些坎程序要从多个地方读密码比如一次加密操作读两次第一次输密码第二次确认密码。你给的管道数据只够读一次第二次它仍然卡住。程序主动检测TTY发现stdin不是终端时直接给出“只能从终端输入密码”的报错。密码明文出现在shell history或进程列表中安全上完全不合格。我自己刚入门时在这上面浪费了大量时间最后结论就是与其硬碰硬地和isatty去搏斗不如用一个更聪明的中间层——给程序“模拟一个终端”。这就是后面要说的Expect和pexpect等工具干的事它们创建一个伪终端pseudo-terminalPTY让程序以为自己在和真人对话程序输出什么工具就根据规则自动回复。3. 方案一用Expect把“交互式”变成“文件式”Expect是处理这类问题最经典的工具逻辑其实很朴素spawn启动一个程序expect等待程序在终端上输出的某段文字然后再用send发送你准备好的响应。本质上就是把“人看屏幕、人敲键盘”的循环替换成了“程序看屏幕、程序敲键盘”。如果我现在重新跑那个“五程序”任务核心就是把这几个带交互环节的步骤都写出对应的Expect分支。下面展示一个简化的可运行版本。3.1 一个覆盖五程序流程的Expect脚本假设我们的任务是先运行两个普通脚本随后运行需要交互的加密工具和数据库导入工具最后清理临时文件。#!/usr/bin/expect set timeout 30 # 第一步运行传统脚本直接执行就行 spawn bash /opt/tasks/gen_data.sh expect completed { send_user gen_data.sh finished\n } # 第二步压缩脚本会有覆盖确认自动应答 y spawn bash /opt/tasks/archive.sh expect { overwrite { send y\r; exp_continue } finished { send_user archive finished\n } } # 第三步加密工具需要输入两次密码一次实际密码一次确认 spawn openssl enc -aes-256-cbc -salt -in /opt/data/arch.tar -out /opt/data/arch.tar.enc expect enter aes-256-cbc encryption password: send S3cr3tPass\r expect Verifying - enter aes-256-cbc encryption password: send S3cr3tPass\r expect finished # 第四步数据库导入工具需要数据库密码 spawn /opt/tasks/import_db.sh --file /opt/data/import.sql expect Password for database user: send DbPassw0rd\r expect { ERROR { send_user import failed\n; exit 1 } import done { send_user db import ok\n } } # 第五步清理临时文件 spawn bash /opt/tasks/cleanup.sh expect cleaned send_user ALL_TASKS_DONE\n这是最直观的文件式写法。把原来需要“人盯着的交互”全部写成了规则。值得留意几个细节exp_continue很重要。程序可能连续输出多次overwrite你必须循环回应。set timeout 30是整个expect脚本的默认等待时长如果程序某一步比较慢你要单独调大。我们后面会讲超时为什么是最大的坑。send_user是用来向调用者输出的不会发送给被控程序适合打自己的日志。3.2 运行Expect脚本的方式这个Expect脚本本身也是一个文件写好后执行expect /opt/tasks/run_all.exp如果你想把整个流程扔到后台完全可以expect /opt/tasks/run_all.exp /tmp/task.log 21 到这里“交互式”运行已经被包装成了“文件式”运行。后台执行不再有问题因为Expect这个进程本身不需要从stdin读任何东西它会去操作对应的伪终端。密码交互只发生在伪终端那侧天然解决了isatty检测的问题。4. 方案二用Python pexpect统一管理五个程序Expect用起来有一个别扭的地方它是一门独立的脚本语言写复杂逻辑比如需要并发、异常重试、JSON配置驱动时不太顺手。我后来在工作中更倾向于用Python的pexpect库来做这类事情因为可以用同一种语言去组织五个程序的调度逻辑还能灵活地做日志和异常处理。4.1 为什么选pexpect而不是Expect可读性更好Python语法对同事更友好写的人容易看的人也容易。日志处理更灵活可以把每个子程序的全部交互输出写到一个固定文件里排查问题时方便。失败重试更可控pexpect抛出的异常是Python的异常对象可以用try/except围绕任意一段交互去控制重试。单线程并发五个程序里有些可以并行跑比如两个纯文件式的任务完全可以丢给subprocess.Popen后台启动然后主线程再来处理交互式任务。如果你的执行环境没有装pexpect先装pip install pexpect4.2 用pexpect管理全流程的一个实际示例我来写一个完整度比较高的Python调度脚本它对应“五个程序”混合场景并在跑的过程中打印进度和记录日志。import pexpect import subprocess import sys import os LOG_DIR /tmp/five_programs_logs os.makedirs(LOG_DIR, exist_okTrue) def log_output(tag, content): with open(os.path.join(LOG_DIR, tag .log), a) as f: f.write(content) # 任务1纯文件式程序用subprocess后台启动 p1 subprocess.Popen([bash, /opt/tasks/gen_data.sh]) # 任务2压缩工具有覆盖确认 child pexpect.spawn(bash /opt/tasks/archive.sh, encodingutf-8, timeout60) child.logfile_read open(os.path.join(LOG_DIR, archive.log), wb) child.expect(overwrite) child.sendline(y) child.expect(finished) child.close() # 任务3加密工具需要两次密码 child pexpect.spawn( openssl enc -aes-256-cbc -salt -in /opt/data/arch.tar -out /opt/data/arch.tar.enc, timeout120, ) child.logfile_read open(os.path.join(LOG_DIR, encrypt.log), wb) child.expect(enter aes-256-cbc encryption password:) child.sendline(S3cr3tPass) child.expect(Verifying - enter aes-256-cbc encryption password:) child.sendline(S3cr3tPass) child.expect(pexpect.EOF) child.close() # 任务4数据库导入需要密码 child pexpect.spawn(/opt/tasks/import_db.sh --file /opt/data/import.sql, timeout180) child.logfile_read open(os.path.join(LOG_DIR, db_import.log), wb) child.expect(Password for database user:) child.sendline(DbPassw0rd) # 这里提示一下可能导入很久所以要等待特定输出或EOF index child.expect([import done, pexpect.EOF, ERROR]) if index 2: print(database import failed) sys.exit(1) child.close() # 任务5纯文件式收尾 subprocess.run([bash, /opt/tasks/cleanup.sh]) # 等待后台任务1完成 p1.wait() print(FIVE_TASKS_ALL_DONE)这段代码里有几个地方值得认真看pexpect.spawn默认会给子进程创建伪终端所以加密工具和数据库工具都会认为自己在真终端上运行不会再拒绝输入。child.logfile_read可以实时把程序输出追加到日志文件便于事后排查。这一点我在实际项目中深有体会没有日志交互式自动化基本上等于瞎猜。数据库导入这类工具耗时可能非常长所以超时要设得足够大千万不要用默认的30秒。否则导入还没完成pexpect就给你抛个TIMEOUT异常程序直接退出。4.3 并发调度的一个教训不能什么都往后台丢我遇到过一位同学把五个程序全部用subprocess.Popen丢出去然后主进程睡眠三十秒假装等待结果数据库导入迟迟没完成加密工具因为密码确认失败直接退出后面清理脚本把还在使用的文件删了造成不可逆的数据丢失。教训就是纯文件式的程序可以并行有依赖关系的程序必须串行。如果你是第一次跑这套流程我建议先串行跑通之后再考虑哪些任务真正可以并发。上面的代码里我只并行了一个耗时较长的数据生成脚本其他带交互式的都是严格等待结果后再继续这样最稳。5. 选型对比Expect、pexpect、sshpass、手工交互的边界与取舍看完成功案例也得聊聊怎么选型。很多人会问有没有更省事的工具比如sshpass。我问你几个场景你就知道边界在哪了。5.1 四种处理方式的对比方式适用场景优点缺点手工交互一次性操作、很少重复最直观无法自动化、无法后台管道重定向程序不检测TTY、只读一次密码写法简单遇到TTY检测就失效多次输入不行Expect专为交互自动化设计适合单机脚本成熟、轻量、几乎任何Unix都有脚本语法老旧复杂逻辑难维护pexpect需要复杂调度、日志、异常处理的场景与Python生态集成逻辑清晰需要安装Python依赖sshpass这个问题需要单独说明。它专门用于给ssh这类程序喂密码写法简洁sshpass -p mypassword ssh userremote command但它的实现方式本质上是在程序启动前给stdin塞一个伪造的密码输入。这导致一个明显的安全缺陷密码会出现在命令行参数中任何人ps aux都可能看到明文。而且一旦远程命令本身又触发了二次提示比如Host key确认、sudo密码sshpass就管不过来了。所以我的建议是sshpass在个人开发环境临时用可以生产环境不要用。5.2 什么时候不该自动化密码这个角度必须说。密码自动化本身是双刃剑它方便了运维也意味着密码会被写到脚本里、被打进镜像、被同步到代码仓库。以下是我个人认为的红线场景生产服务器批量操作优先配置SSH密钥或托管密钥服务别自动输送密码。数据库管理员密码别写在上述脚本里明文传输建议从环境变量或专用密码管理工具读取后再拼入命令。任何面向公网的入口一旦脚本泄露等同于直接暴露入口凭据。如果你只是在自己本地做实验、跑一个个人数据加密任务把密码写进脚本里我还能接受。但即便如此也最好配合文件权限chmod 600 /opt/tasks/run_all.py并把脚本里的密码通过os.environ.get(ENCRYPT_PASS)从外部加载而不是写成字面量。这个习惯越早养成越好。5.3 更进一步的自动化方向这五个程序的场景再往前走就是“配置驱动的任务编排平台”。比如你可以在YAML里定义任务依赖和交互规则用Python去解析并执行。也可以引入任务队列系统把每个交互式任务包装成服务再由调度器去触发。不过这是后话对于大多数场景先把Expect或者pexpect跑通已经完全够用了。6. 完整可复制的五程序批量运行模板最后上一份可以直接参考的模板。我尽量保持通用性你只需要把命令替换成自己的实际任务即可。6.1 推荐目录结构一份逻辑清晰的目录不仅方便自己调试也方便同事接手。我通常这样组织/opt/tasks/ ├── config.env # 存放环境变量和密码的引用 ├── scripts/ │ ├── gen_data.sh │ ├── archive.sh │ ├── import_db.sh │ └── cleanup.sh ├── batch_runner.py # 主控调度脚本pexpect方案 └── logs/ # 运行日志目录6.2 主调度脚本的结构建议主调度脚本的作用是读取配置、按顺序触发任务、记录状态、汇总结果。核心结构可以抽象成启动前检查磁盘空间、日志目录、加密工具是否存在读入配置从config.env里读取ENCRYPT_PASS、DB_PASS按任务列表依次执行纯文件式用subprocess.run加上合理的超时带交互式用pexpect把超时调大记录日志到对应文件每一步都写状态到logs/status.json失败时置为非零退出码最后生成汇总报告。6.3 那些年我碰到过的真实报错与解法列几个新手必踩的经典案例都是我亲自验证过的。问题1expect: spawn id exp0 not open这个报错出现在Expect脚本里通常是因为spawn的进程已经退出了但后面的expect还在等它输出。原因通常有二一是程序在执行时立刻报错比如拼写错误二是前一步send了一个错误指令导致程序中断。解决方法是先不写后续的expect单独跑一下被调用的命令确认它确实能正常进入交互流程。问题2pexpect.exceptions.TIMEOUT: Timeout exceeded.这个太常见了。原因大多是默认的timeout30太短或者你expect的模式和实际输出不一致。解决方法是先打开child.logfile_read看一眼程序真实的输出再去调整expect的匹配字符串或者调大超时。很多入库类程序在“等待输入密码”前还有很长的初始化过程如果你的expect(Password:)发生在初始化过程中肯定等不到。问题3Inappropriate ioctl for device这是程序在向终端请求信息但当前进程不是一个可交互终端。解决方式就是别再用普通重定向用pexpect这类工具创建伪终端。问题4密码明明正确但程序仍然提示失败个别程序会检测输入焦点或者转义字符密码中包含特殊字符如$、!、空格时用sendline发送时要小心引号被shell吃掉。解决办法是用sendline时直接传原始字符串或者在pexpect构造命令时用列表参数避免经过shell转义。例如pexpect.spawn(/opt/tasks/import_db.sh --file /opt/data/import.sql)而不是pexpect.spawn(/opt/tasks/import_db.sh --file /opt/data/import.sql | cat)后者引入了管道会改变程序的stdin类型反而触发设备检测错误。我自己在实际使用中的体会是遇到交互式自动化先别急着写代码先花五分钟看两样东西程序到底输出了什么提示程序对输入源有没有TTY检测这两点确认了后面百分之九十的坑都不会踩。上面这套流程我反复用过很多轮从最初“手动输入密码输入到手酸”到现在一条命令把所有程序全部管理起来中间差的其实就是对文件式和交互式运行方式的一次深刻理解。你可以先按这份模板把五个程序跑通再往后做并发、告警、可视化步调会稳很多。
返回列表