启用stderr时,subprocess启动后台SSH进程的communicate挂起问题
需求
通过Python的subprocess实现两个功能:
- 执行
ssh -f -M在后台启动共享套接字 - 后续SSH连接复用
/tmp/control-channel套接字实现免密登录
测试代码(test.py)
import subprocess import os import sys import stat ssh_user = "my_user" # 改为你的账号 ssh_passwd = "my_password" # 改为你的密码 try: os.remove("/tmp/control-channel") except: pass # 准备密码文件 file = open("./passwd","w") passwd_content = f"#!/bin/sh\necho {ssh_passwd}" file.write(passwd_content) file.close() os.chmod("./passwd", stat.S_IRWXU) # 配置共享SSH套接字,后台运行 env = {'SSH_ASKPASS': "./passwd", 'DISPLAY':'', 'SSH_ASKPASS_REQUIRE':'force'} args = ['ssh', '-f', '-o', 'LogLevel=ERROR', '-x', '-o', 'ConnectTimeout=30', '-o', 'ControlPersist=300', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'StrictHostKeyChecking=no', '-o', 'ServerAliveInterval=15', '-MN', '-S', '/tmp/control-channel', '-p', '22', '-l', ssh_user, 'localhost'] process = subprocess.Popen(args, env=env, stdout=subprocess.PIPE, # stderr=subprocess.STDOUT, # 取消注释启用stderr会导致subprocess挂起 stdin=subprocess.DEVNULL, start_new_session=True) sout, serr = process.communicate() print(sout) print(serr) # 使用共享套接字 args2 = ['ssh', '-o', 'LogLevel=ERROR', '-o', 'ControlPath=/tmp/control-channel', '-p', '22', '-l', ssh_user, 'localhost', 'uname -a'] process2 = subprocess.Popen(args2, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL) content, _ = process2.communicate() print(content)
正常执行结果
$ python3 test.py b'' None b'Linux shmachine 4.19.0-21-amd64 #1 SMP Debian 4.19.249-2 (2022-06-30) x86_64 GNU/Linux\n'
异常情况
取消注释第一个subprocess中的stderr=subprocess.STDOUT后,进程会挂起:
$ python3 test.py ^CTraceback (most recent call last): File "test.py", line 29, in <module> sout, serr = process.communicate() File "/usr/lib/python3.7/subprocess.py", line 926, in communicate stdout = self.stdout.read() KeyboardInterrupt
环境信息
$ python3 --version Python 3.7.3 $ ssh -V OpenSSH_7.9p1 Debian-10+deb10u2, OpenSSL 1.1.1n 15 Mar 2022 $ cat /etc/issue Debian GNU/Linux 10 \n \l
补充:将communicate()改为wait()可正常运行,但需明确communicate()失效的原因。
解答
问题根源在于ssh -f的行为与subprocess管道的交互逻辑冲突:
ssh -f的工作机制:使用-f参数时,SSH会在认证完成后fork一个后台控制进程,原父进程随即退出。但当你开启stderr=subprocess.STDOUT时,SSH父进程在fork前可能会尝试向stderr写入数据,此时Python端的管道读取缓冲区未被及时消费,导致SSH父进程阻塞,进而触发整个流程挂起。communicate()的特性限制:communicate()会持续读取stdout和stderr的数据,直到收到EOF才会返回。而SSH fork出的后台子进程会继承管道文件描述符,且不会主动关闭这些管道,导致stdout/stderr永远无法触发EOF,communicate()会一直处于等待状态。wait()正常的原因:wait()仅等待SSH父进程退出,不会尝试读取管道数据。SSH父进程完成fork后会正常退出,因此wait()能顺利拿到退出状态,不会阻塞。
解决办法
若需捕获stderr同时避免挂起,可采用以下方案:
- 移除
-f参数:你的代码中已设置ControlPersist=300,该参数已能让控制进程在后台保持连接,无需-f进行fork操作。 - 添加超时机制:使用
process.communicate(timeout=5)强制终止等待,但需额外处理超时异常。
修改后的启动命令(移除-f):
args = ['ssh', '-o', 'LogLevel=ERROR', '-x', '-o', 'ConnectTimeout=30', '-o', 'ControlPersist=300', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'StrictHostKeyChecking=no', '-o', 'ServerAliveInterval=15', '-MN', '-S', '/tmp/control-channel', '-p', '22', '-l', ssh_user, 'localhost']
此时即使开启stderr=subprocess.STDOUT,communicate()也能正常完成——SSH会直接启动控制进程,认证完成后进入后台等待,stdout和stderr会正常关闭,communicate()能收到EOF并返回。
内容的提问来源于stack exchange,提问作者atline

