少女祈祷中...
一言即诺
踩坑笔记

SSH 远程执行的引号地狱:用脚本流而不是内联命令

阅读 -- 次

背景

我做服务器开通和日常运维,大量命令走 SSH 打到远端执行。很长一段时间,我习惯把所有东西内联进 ssh host 'bash -lc "..."':单引号套双引号再套反斜杠,三层转义,一错就是 bash: 未预期的符号 '(' 之类的语法报错,然后对着报错调半小时「业务逻辑」,最后发现问题根本不在命令本身,在引号。

这篇笔记沉淀的是同一套判据:远程命令失败,先问「这是转义坏了还是逻辑错了」;如果是转义坏了,改投递方式——脚本流、heredoc、base64——而不是改命令内容。下面按我实际踩过的三个坑拆开讲,每个坑给出现象、排查和正反例,最后收束成统一的解法。

坑一:bash -lc 三层转义,括号一出现就炸

现象

最常见的一行式远程检查:

# 反例:远程命令里带中文标签和括号
ssh 203.0.113.10 'bash -lc "echo ===启动检查(全量)===; ps -ef | grep java | wc -l"'
bash: -c:行0: 未预期的符号 `(' 附近有语法错误

触发点非常集中:内联字符串里出现中英文括号、%{...}、或者嵌套引号。最典型的就是 echo 标签里的括号——命令本来跑得好好的,加了一个 (全量) 后缀,立刻炸成语法错误。同理,想内联跑一小段 python3 -c 查询:

# 反例:双引号里再嵌 python 的引号
ssh 203.0.113.10 "bash -lc \"python3 -c 'import json;print(\"data\")'\""

单引号、双引号、反斜杠叠三层,最终怎么被剥开取决于本地和远端两层 shell 各解析掉几层,报错位置和真实问题完全对不上。

排查

最有效的判据是:手动登上去,跑同一条命令。命令在远端 shell 里能通、通过 ssh 内联就失败,说明是转义问题,和逻辑无关——这时候改投递方式,别再改命令内容。早期我不知这条判据,会去怀疑 grep 语法、python 版本,反复绕弯路。

解法

先把两条引号规则立清楚:

  • 远端变量($HOME 这类)永远走单引号,本地 shell 不碰它;
  • 只在本地端展开的变量,用双引号外层加转义内层;
  • echo 字符串保持不带括号,很多一行式命令因此能活下来;
  • 永远不要在 ssh 里再嵌 python3 -c "...",永远不要在双引号内联串里再来一对双引号。

但即便规则都背下来,只要命令超过两行,转义还是会滚失控。真正的解法在第三节。

坑二:heredoc 不引定界符,变量模板被远端吞掉

现象

开始不信内联之后,我改用 heredoc 传内容,以为有了定界符就万事大吉,拿它发带 ${...} 占位符的文件(nginx 配置、envsubst 模板、JS 模板文本),结果:

# 反例:定界符没加引号
ssh 203.0.113.10 'cat > /tmp/app.conf <<EOF
upstream backend { server ${BACKEND_HOST}:8080; }
EOF'
bash: ${BACKEND_HOST}: bad substitution

未加引号的定界符意味着远端 bash 会对 heredoc 内容做变量展开:远端没有定义的 ${...} 直接死在 bad substitution,一半内容在解释器看到它之前就被改掉了,而且改得无声无息——更糟的版本是变量恰好存在,文件里被替换成远端的值,语法全对、语义全错。

排查与解法

定位同样靠「登上去跑一遍」:heredoc 手工执行正常、包进 ssh 就报 substitution,就是外层 shell 多剥了一层。解法是把定界符引起来,让 bash 不做展开:

# 正例:定界符加引号,内容原样落地
ssh 203.0.113.10 'cat > /tmp/app.conf <<"EOF"
upstream backend { server ${BACKEND_HOST}:8080; }
EOF'

更稳的做法是干脆不把内容放进 ssh 命令行:本地写好补丁脚本,scp 上去,远端 python3 /tmp/x.py 执行。脚本文件内部的 heredoc 随便写——投递的是文件,两层 shell 都不再碰它的内容。

坑三:MySQL DDL 内联必碎

现象

从本机通过 ssh 往远端库直接发变更,是最惨烈的一个场景:括号、引号、注释在两层 shell 里同时开打。

# 反例:ALTER 语句的括号和引号嵌在双引号里
ssh 203.0.113.10 "mysql -uroot -e \"ALTER TABLE orders ADD COLUMN remark VARCHAR(64) DEFAULT 'boss'\""

这条能侥幸跑通的次数约等于零。就算跑通了,MySQL 侧还有独立的坑:服务端开着 ANSI_QUOTES 时双引号会被当成标识符引号,字符串字面量必须写单引号;有中文内容必须 --default-character-set=utf8mb4;密码不能走命令行——会留在 ps 里——要用环境变量传:MYSQL_PWD="$PW" mysql ...。

解法

DDL 走 stdin heredoc,定界符加引号,外层用单引号保住远端变量:

# 正例:heredoc 引号定界 + '\'' 转义单引号
ssh 203.0.113.10 'MYSQL_PWD="$PW" mysql --default-character-set=utf8mb4 -h"$DBHOST" -u"$DBUSER" "$DB" <<"SQLEOF"
CREATE TABLE IF NOT EXISTS t_order (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  remark VARCHAR(64) DEFAULT '\''boss'\''
);
SQLEOF'

单引号包大段 SQL 时,表达一个单引号只能靠 '\'',丑但可靠。最稳的还是把 DDL 写成本地 .sql 文件,scp 到 /tmp,远端 mysql ... < /tmp/x.sql——回到「投递文件,不投递参数」。

统一解法:换投递方式,不换命令内容

三个坑收束成一组按安全度排序的规则:

  1. 能传文件就不内联。 脚本本地写、本地校验,从 stdin 灌进去,零转义:
# 正例:脚本流,本地写 + bash -n 预检 + stdin 投递
cat > /tmp/probe.sh <<'SH'
#!/usr/bin/env bash
echo "===启动检查(全量)==="
ps -ef | grep java | grep -v grep | wc -l
SH
bash -n /tmp/probe.sh
ssh 203.0.113.10 'bash -s' < /tmp/probe.sh

外层命令永远是 bash -s,没有可转义的东西,也没有 scp 的远端残留。补一个容易忽略的细节:内联负载就算包在单引号里,最终也是 argv 参数——足够长会在 ssh 执行时死在 Argument list too long。改走 stdin 才是根治,所以超过一行半的内容直接上脚本流。

  1. 必须内联时,先本地 base64。 内容变成纯 ASCII 令牌,完全绕开两层 shell 的解析:
# 正例:base64 传参
B=$(base64 < /tmp/probe.sh)
ssh 203.0.113.10 "echo $B | base64 -d | bash"
  1. 多行远端命令写完,先本地 bash -n 校验再发。 语法错误在本地花三秒暴露,好过在远端花三十分钟猜。

复盘

回头看,真正省时间的不是背转义技巧,而是判据和投递习惯:

  • 失败先手动登上去跑同一条命令:能通=转义问题,改投递方式;不通=逻辑问题,才去看内容。这一步把绝大多数无效调试切掉了。
  • 复杂内容做成文件,不做成参数。ssh host 'bash -s' < local.sh 永远比第四层引号可靠。
  • 长任务别占着 SSH 前台等断线重连。nohup ... & 其实不够——会话还会等子进程的 stdout/stderr——要用 setsid 彻底脱离、三个 fd 全重定向、子 shell 里双 fork,之后的连接轮询日志:
ssh 203.0.113.10 'cd /opt/app && (setsid nohup ./deploy.sh >> deploy.log 2>&1 < /dev/null &); echo started'
ssh 203.0.113.10 'tail -3 /opt/app/deploy.log'
  • 还有一个和引号神似的坑:ssh host 'pkill -f "deploy.sh"; rm -rf ...' 返回 255 或空输出——匹配串同时出现在你刚拉起的命令行里,pkill 把自己的会话杀了,同一串里后面的命令静默不执行。用字符类断开花样(pkill -f "deploy[.]sh"),并且换一条新连接去验证结果。

引号地狱的本质,是你把一段文本同时交给两个解析器处理。脚本流、heredoc、base64 三种方式其实是同一个思想:让内容整体过线——文件、stdin、编码后的令牌——到一边再落地重建,把解析层数减到一。把这条原则想明白之后,我在远程命令上耗的时间回到了正常水平。

评论

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人