最近做一个需求:生成一串数据流,能够同时传到2台远程的机器上对数据流做一些操作。发现了tee和ssh结合的坑需要特别注意。
最早的写法
定义一个函数叫做duplicate_data_stream. 它做的事情是处理1台机器的远程操作,然后把数据流继续往下传。其中使用了tee和ssh的结合
function duplicate_data_stream() {
local remote_host=$1
tee >( ssh "${remote_host}" "cat > /tmp/receive_file.txt" )
}
这样整个程序框架可以这样写:
generate_data_stream |
duplicate_data_stream "${machine1}" |
duplicate_data_stream "${machine2}" > /dev/null
当时考虑这样设计接口,是因为如果以后还要对数据流做其它处理,只要管道后面继续接处理的程序就可以了。
发现问题
上面的程序,实际运行下来,发现了问题:
执行上面的程序之后。它会报错:
tee : standard output: Resource temporarily unavailable
Resource temporarily unavailable 是C的系统错误码:EAGAIN 的文字描述
经过定位,确认数据流从第二段发送到后面第三段的stdout存在问题,发送不了。经过分析后认为,往stdout写数据出现EAGAIN可以说明:1)当时tee的stdout是非阻塞模式;2)生成的数据填入管道buffer太快,导致管道buffer还没被另一端足够快地消费之前就被打满了。
我们观察到的另外一些现象:
- 出现过EAGAIN报错之后,machine2的数据就停止增长了,使得machine2的数据一直是不完整的。但是,每次向machine1传送的数据都是完整的。说明我们系统使用的tee版本遇到EAGAIN的错误,直接就把对应的文件描述符关掉了,不会做重试处理
- 就算把duplicate_data_stream "${machine2}"换成gzip之类的本地操作,也会报错EAGAIN
- 如果在generate_data_stream 之后能够接一个限速的进程,比如:
generate_data_stream | dd bs=128 | ...
有时候能够成功,但是非常看运气
解决问题(V1)
为了解决这个问题,采取了几个方案:
- 一开始怀疑是tee版本太老了,最新的tee可能修复了这个问题。于是下载了最新的coreutils编译,结果出来的tee还是有这个问题,后来我们翻阅了tee的源码,证实了之前的判断,tee确实没有处理EAGAIN。
- 然后使用Golang写了一个tee的实现,发现Golang的版本不会报EAGAIN,不会报任何错误。但是去machine1和machine2上面确认数据,发现传送的数据都是不完整的。这个可运维性不如原生的tee。所以这个方案也不能采用
- 最后,做了一些实验,发现只要tee不去使用stdout,而是使用command substitution的虚拟文件,那么多个文件也可以同时发送并且不会报错。利用这个特性,写出了一个替代的方案
替代方案是这样的:
定义了一个函数叫做handle_data_stream。它做的事情就是纯粹处理1台机器的远程操作:
function handle_data_stream() {
local remote_host=$1
ssh "${remote_host}" "cat > /tmp/receive_file.txt"
}
整个程序框架变成这样:
generate_data_stream |
tee >( handle_data_stream "${machine1}" ) >( handle_data_stream "${machine2}" ) > /dev/null
采用这种方案之后,相关的程序就能够顺利运行,并且也不需要对数据流限速,没有性能问题。暂时能够满足需求了
找到根因
经过前面的分析,其实核心原因是tee进程的stdout变成了非阻塞模式,才会出现EAGAIN错误。但是tee默认的stdout是阻塞模式,遇到管道的buffer被打满的情况,它会等待并继续,也就能够完整传输所有内容。那问题就是:为什么tee的stdout会变成非阻塞模式?
经过网上一些资料的搜索,找到了核心原因!原因是:tee命令里面的ssh有问题!!!
ssh启动时,会把进程的stdin, stdout, stderr都设置成非阻塞模式。进程在各种fork过程中,stdout被各种共享,比较我们前面duplicate_data_stream那个复杂的tee命令中,所有子命令,只要不是接管道或者重定向,其实是和tee进程共享stdout的设置的。而我们发现,我们在tee里面使用的ssh命令没有接管道或者重定向,也就是说:tee里面的ssh对它的stdout设置了非阻塞模式,实际上把tee进程的stdout也设置成了非阻塞模式!
而为什么我们前面的替代方案不会有问题呢?
这是因为替代方案中,tee的stdout的内容不会被任何进程使用,直接丢到/dev/null,所以就不会出现写满buffer的情况,绕过了tee的stdout被变成非阻塞模式的问题困扰。而使用command substitution的虚拟文件(实际是/dev/fd/xxxx)被tee打开进行写操作时,是使用阻塞模式打开的。所以tee向虚拟文件做写操作都是阻塞操作,也就没有EAGAIN的问题。
最终的解决方案(V2)
找到根因之后,解决方法非常简单!只要把tee当中的ssh命令接一个管道或者重定向一下就可以了。最简单的方法就是ssh后面接一个cat就可以了。它的目的是:让ssh的stdout被管道消费,而cat的stdout和tee的stdout共享,两者都保持阻塞模式
具体来说,改成下面这样:
function duplicate_data_stream() {
local remote_host=$1
tee >( ssh "${remote_host}" "cat > /tmp/receive_file.txt" | cat ) #最后的cat是关键
}
或者
function duplicate_data_stream() {
local remote_host=$1
tee >( ssh "${remote_host}" "cat > /tmp/receive_file.txt" > /dev/null )
}
就可以了