返回博客

管道数据被 heredoc 静默吞掉:python3 -<<'PY' 不能共存

一个 systemd 定时任务每分钟读取日志增量、用内嵌 python 解析成 NDJSON 后上报。日志里明明有记录,监控台永远显示「本轮 delta 无可解析条目」。把 python 代码单独抽成 .py 文件,用相同数据管道进去——解析完全正常。脚本本身是唯一的嫌疑人。

现象

脚本大致结构如下:

TAIL=$(tail -c "+$((OFFSET + 1))" "$LOG_FILE")
NDJSON=$(printf '%s' "$TAIL" | python3 - "$INSTANCE_ID" "$ENV" <<'PY'
import sys
raw = sys.stdin.read()           # 预期:拿到 TAIL 的内容
blocks = re.split(r'(?=^# Time: )', raw, flags=re.M)
# 解析 0 条,因为 raw 是 ""
PY
)

现象清单:

  • $TAIL 有 385 字节,$NDJSON 为空
  • python 不报错$? 为 0,set -e 不触发
  • 往 python 里加 print(f"raw_len={len(raw)}", file=sys.stderr) → 输出 raw_len=0
  • 把同一段 python 写进独立 parser.pyprintf '%s' "$TAIL" | python3 parser.py → 正常解析

根因

python3 -- 是「从 stdin 读取程序源码」的信号。这条命令上同时存在两个 stdin 重定向:

  1. 管道printf ... | python3):想把 TAIL 送进 python 的 stdin。
  2. heredoc<<'PY' ... PY):同样接管 python 的 stdin,把脚本源码送进去。

bash 在命令解析阶段就把 heredoc 绑定为 stdin;管道的另一端写入的数据,根本没有文件描述符去接收,静默丢弃。python 从 stdin 读到了自己的源代码(heredoc 内容),执行完毕,sys.stdin.read() 返回空字符串,一切正常退出。

坑:heredoc 与管道争夺同一个 stdin,heredoc 必胜,数据无声消失

cmd arg <<'DELIM'... | cmd arg 一旦写在同一行,heredoc 的重定向优先级高于管道。python/perl/awk 等解释器使用 - 旗标表示「程序源码来自 stdin」时,就会触发这个陷阱。

最险的地方在于:没有任何报错。python 确实读到了「合法的 python 源码」(heredoc 内容),执行后输出为空,而下游把「空输出」当成「没有新数据」——这条静默的失败路径很可能是业务的正常分支,永远不会报警。

正解

把 heredoc 换成 bash 变量持有脚本源码,再用 python3 -c "$SCRIPT" 执行。这样 stdin 没有被 heredoc 占用,管道数据可以正常到达 sys.stdin.read()

# 用 read 把源码吸进变量;-d '' 表示读到 EOF 为止
# || true 是必要的:read 在 EOF 返回 1,set -e 会误杀脚本
read -r -d '' PYSCRIPT <<'PY' || true
import sys, json, re
raw = sys.stdin.read()           # 现在真的能收到管道数据了
blocks = re.split(r'(?=^# Time: )', raw, flags=re.M)
# ... 正常解析 ...
PY
 
# heredoc 已被 read 消耗;python3 -c 从参数拿脚本,stdin 留给管道
NDJSON=$(printf '%s' "$TAIL" | python3 -c "$PYSCRIPT" "$INSTANCE_ID" "$ENV")

两个细节不能省:

  • <<'PY'(带引号的定界符):heredoc 内容原样保留,bash 不展开 $VAR——嵌入 python/perl 时几乎总想要这个行为。
  • || trueread -d '' 到达 EOF 时以退出码 1 结束;若脚本有 set -e,不加 || true 会在 python 运行前就中止。
正解:read -r -d '' VAR <<'PY' || true,然后 python3 -c "$VAR"

同样的修法适用于所有「- 表示从 stdin 读程序」的解释器:

# perl
read -r -d '' PERLSCRIPT <<'PL' || true
while (<STDIN>) { print uc($_) }
PL
printf '%s' "$DATA" | perl -e "$PERLSCRIPT"

把 heredoc 从命令重定向改为 read 赋值,stdin 就解放给了管道。

快速确认是否中招

加一行 stderr 探针,不改逻辑:

NDJSON=$(printf '%s' "$TAIL" | python3 - "$ARG" <<'PY'
import sys
print(f"raw_len={len(sys.stdin.read())}", file=sys.stderr)
PY
)
  • 输出 raw_len=0$TAIL 非空 → 确认命中此坑
  • 输出 raw_len>0 → 是解析逻辑的问题,不是这个坑

一句话外卖

python3 - <<'PY' 里的 -<< 都要 stdin,heredoc 必胜;要嵌入解释器脚本同时读管道,用 read -r -d '' VAR <<'PY' || true 先把源码存进变量,再用 -c "$VAR" 执行。