说说 github.com/pkg/sftp 与 TCP stall

好久没写过东西了,最近一直在折腾 Golang,今天难得想起来了,就稍微写一点。

最近有个需求,要通过 SFTP 把文件传到指定服务器上,于是就用了 github.com/pkg/sftp 这么一个库。然而,这个库存是存在一些坑的,比如某个版本 sftp.File.Write 写超过 4MiB 的文件时,Kill 远程 sshd 后会导致 Close 时死锁;又比如更新代码后,Kill 了远端之后有时 ReadFrom 返回 Writer EOF,有时返回 nil;再比如模拟弱网时发生了 TCP Stall,传输就永远的卡死了,如果此时对端认为 TCP 超时,则该 TCP 将永远不会被释放。

RTT 问题

最初的一版,为了实现传输时的进度报告和超时,我写出了这样的代码:

buf := make([]byte, 64 << 10)
n, _ := fr.Read(buf)
_, _ := fw.Write(buf[1:n])

在测试环境下,这一切看起来都很好,线上表现一直也很正常,直到某天发现有一些海外机器传输奇慢,但用 scp 命令则没这毛病。当时百思不得其解,直到我 ping 了一下,发现延迟足足有 400ms !

那么这会有什么问题呢?这个 sftp.File.Write() 会写入文件,然后报告写入字节数。那么这个字节数和错误怎么来的呢?是不是要对端报告?所有在 400ms 延迟的网络上,写入速率理论上撑死也只能跑到

1s / 400ms * 32 KiB = 160 KiB/s

实际上传输速度确实也在这个水平。那么这个问题要如何解决呢?最简单的是增大缓冲大小,但显然这治标不治本。理想的方式是允许一部分数据 on the fly,就像 TCP 的滑动窗口机制一样。实际上 Write 函数内部也是这么做的,它把要发送的内容切成 32 KiB 长的片段,最多允许 64 个片段 on the fly,此时理论传输速度能够达到 10 MiB/s,大大改善了这种情况。因此我们需要用 sftp.File.ReadFrom() 这一函数,但这一函数又要怎么汇报进度呢?又怎么处理超时?

这里我把 Reader 包装了一层,包进去了一个管道和一个计数器,当调用 Read 时,它先执行超时检查,随后读取、累加计数器、写管道,最后返回 Read 的结果。显然此时的进度显示的是已经读取的字节数,而不是已写入的字节数,不过一般情况下这并不造成问题。

模拟 400ms 延迟的弱网下,该问题确实得到了解决,跑得飞快。

PS. 其实也考虑过 io.Pipe(),但那样错误传播会变得很麻烦,代码看起来很乱。

TCP Stall

那么,问题全部解决了吗?解决了,但引入了新的问题。

为了测试超时的情况,我模拟了一个超级大弱网。这种情况下,传输协程会彻底卡死,由于 ReadFrom 操作不返回,那么 Context 里面的 DeadLine 也不起作用。更严重的是,起一个 goroutine 超时后 sftp.File.Close 这一套路也不管用,因为 Close 也会一起卡死。

此时如果非常不幸,对方发生了重启,或者对方的 TCP 连接超时,问题会更加严重。在测试中,该 TCP 连接即使 12hrs 后也未超时,一直处于 ESTABLISHED 的状态而不会释放。

一开始我对这种情况非常困惑,直到我拿 scp 命令试了一下,scp 命令也卡死了,并提示 TCP stalled。看到这里一下子就明白了,是底层 TCP 出现了问题。还记得 TCP 的滑动窗口机制吗?TCP 也是允许有一部分数据 on the fly,但一旦这个窗口用尽,TCP 就会傻等 ACK。在不考虑 KeepAlive 的情况下,如果对端把这个 TCP 连接关了,而 FIN 又不幸丢掉了,那么这个连接即不会收到任何数据,也不会再发送任何数据(意味着不会收到 REJECT 从而发现问题),永远处于 TCP Stalled 状态并无法恢复。

那么 golang.org/x/crypto/ssh 在建立连接时默认有帮我们打开 KeepAlive 吗?很遗憾并没有,因此我把它的 DialTimeout 函数改了一个本地版本出来,在 net.Dial 之后为连接设置了 5s 的 KeepAlive。另外我还用了 SetDeadline 这样更激进的策略,保证该连接能够正常关断,顺便替代逻辑层的超时检查。测试表明,很好用。

因此,对于一个服务端程序,总是为各种短连接设置 Deadline 是一个不错的选择,长连接则应该把 KeepAlive 打开。

发表评论