LWN:使用SW_REUSEPORT时避免错误地拒绝连接!
共 3319字,需浏览 7分钟
·
2021-05-14 01:40
关注了就能看到更多这么棒的文章哦~
Avoiding unintended connection failures with SO_REUSEPORT
By Jonathan Corbet
April 23, 2021
DeepL assisted translation
https://lwn.net/Articles/853637/
我们中的许多人都认为,我们自己运营的网络服务器都是非常繁忙了。例如,LWN 的服务器在处理任何跟 Rust 语言有关的文章下的众多评论的时候,都会忙得不可开交。但其实有一些服务器才是真正地非常繁忙,以至于他们不得不采取一些特别的措施来支撑这些极高的流量。SO_REUSEPORT socket option 选项就是为了这些使用场景来添加到网络协议栈里的许多功能之一。不过,SO_REUSEPORT 的实现中有一个问题,可能会导致连接失败。Kuniyuki Iwashima 发布了一组 patch 来解决这个问题,但是人们对于它是否是一个正确的 fix 方法,还有一些疑问。
正常使用情况下,只有一个进程被允许绑定到某个 TCP 端口,从而用来接受外部连接。在繁忙的系统中,这个进程可能会成为一个瓶颈,尽管它所做的只是把接受到连接传递给其他进程来处理而已。SO_REUSEPORT socket option 是在 2013 年被添加到 3.9 内核中的,希望能解决这个瓶颈。这个选项允许多个进程来接收处理相同端口的连接。只要收到新的请求,内核就会选择其中一个进程来作为接收者。使用 SO_REUSEPORT 的系统可以不再需要这个分发进程的存在了,这样就提高了整体可扩展性。
SO_REUSEPORT 在收到最初的 SYN 包(代表 connection request)时开始工作。此时会创建一个临时的 socket 并分配给一个监听进程。新的连接将首先等待握手完成,然后它会排队等待,直到最终选定的进程来调用 accept() 接受并开始会话(session)。在繁忙的服务器上,可能会有相当数量的连接在等待被接受,这个等待队列的最大长度由 listen() 系统调用来指定。
When SO_REUSEPORT misbehaves
大多数情况下,SO_REUSEPORT 都能工作得很好。但如果其中一个监听进程退出,情况就会有所变化。如果一个进程退出时它仍有一些网络连接未被关闭,那么这些连接将被强制关闭。这是一个很自然的结果,但与此同时,内核也会 "关闭"(通过 resetting 方式)任何仍在 accept queue 队列中的那些等待接受的连接。在没有 SO_REUSEPORT 的情况下,这种行为是合理的,毕竟(唯一一个)监听进程消失了,就不会再有人可以接受并处理这些连接。
如果使用了 SO_REUSEPORT,并且有多个监听进程,传入的连接就不应该以这种方式关闭了。毕竟还有其他进程在运行,它们会完全可以处理这些后续等待的连接。但是,一旦内核将一个传入连接提交给某个特定的进程之后,就不会再改变主意,也就是说要么该连接被这个选定的进程所接受并处理,要么就会被关闭。
在一个繁忙的系统中,一个监听进程有很多可能原因会导致退出。也许它是发生了 crash。但更有可能的是,服务进程因为要改变配置或切换到一个新的证书上而重新启动了。这种重启可能一组服务器进程池中会分阶段逐个进行,这样就不会导致所有服务进程一次性全部退出,理论上来说传入的连接就不会感受到任何服务中断的体验。但是,当上面描述的行为出现时,用户会被发现自己被拒之门外了,这当然不会让他们感到满意。对网站经营者来说,可能就错过了了解到这个潜在用户正在为了购买一双新袜子而进行搜索的信息,因此网站经营者更加不希望出现这种情况。
有一些方法可以解决这个问题,比如使用一个 BPF 程序来引导进入的连接,避免它们被分配到那些即将退出的服务进程,然后确保该进程先处理完队列中已经分配给它的连接了,然后再退出。但 Iwashima 指出,有一个更好的方法:当一个进程退出时,只需将队列中所有在排队的传入连接重新分配给另一个进程来处理,那就可以了。毕竟,这些尚未被接受的请求中并没有什么内容是必须要指定进程才能处理的,也就是说,一个进程和另一个进程处理起来都是一样的,而且排在另一个队列里面就可以避免请求失败的情况了。
Migrating the accept queue
不过,要达到这个目的,需要合入 11 个 patch。首先需要添加一个新的 sysctl 开关(net.ipv4.tcp_migrate_req,这里似乎还没有考虑 IPv6 的情况),来控制是否应将这个进入的连接转移到新的监听服务进程(在原来分配的服务进程退出的情况下)。默认来说,这个选项是被禁用的,从而避免干扰那些其他进程的原有计划。
实际上,将传入连接从一个正在退出的服务进程中迁移出去,比人们想象的要复杂,因为 "accept queue(接受队列)" 实际上比我们在本文中描述的还要更加复杂。请记住,TCP 连接在建立之前要经过三次握手:连接发起方发出 SYN 包,服务端用 SYN+ACK 回应,然后发起者用 ACK 包来完成连接的建立。整个过程必须完成之后,才能通过 accept() 将传入连接交给服务进程。
完全建立好的连接——那些已经完成了三次握手但尚未被 accept 的连接——相对来说很容易被转移到新的服务进程上去,它们只是从一个队列转移到另一个队列而已。不过,那些仍在握手过程中的连接就比较麻烦了。它们只能在这个流程中的一些特定时间点才可以被移动,比如最明显的一个时间节点就是握手完成的时候。如果有必要的话,也可以在重新发送(retransmit) SYN+ACK 的时候来切换服务进程。无论哪种方式,原来的那个服务进程的 socket structure 必须要能保留足够长的时间,确保完成握手操作,这就增加了一定的复杂性。
还剩下一个问题:如何选择应该由哪个服务进程来接收这个迁移过去的连接?通常情况下,内核会沿用它起初用来挑选接收者的相同算法,基本上是一种轮流(round-robin)方法。但是,肯定会有用户知道更多信息,从而希望能够更明确地对这些连接指定迁移对象。对于这些用户或者使用场景,那么就只能依赖一个新增的 BPF program type(BPF_PROG_TYPE_SK_REUSEPORT),从而指定要把这些连接重定向给哪个服务进程。
Unacceptable?
截至本文写作时,对 Iwashima 这组 patch 的唯一评论来自 Eric Dumazet,他比较怀疑这种做法是否正确。他说,自从添加了 SO_REUSEPORT 之后,TCP accept 逻辑的这部分代码已经被重新修改为 locklessly 方式了,因此这应该可以解决 SO_REUSEPORT 最初希望解决的大部分扩展性问题了。因此,他说,对于应用程序来说,回到以前的唯一监听服务进程模式可能更好,也许可以新增一种形式的 accept() 来帮助让进入的连接可以迅速被引导到相应的服务器进程。
当然,这是一个与 Iwashima 目前采取的道路非常不同的发展方向,因此对他来说可能不是一个好消息。可能会有争论说,虽然使用一个新的 accept() 系统调用可能是对整个问题来说更加满意的解决方案,但这些让内核的现有功能继续正常工作并且避免误杀进入的连接的 patch,应该也是有价值的。因此,目前还不清楚这组 patch 后续会如何发展,请继续关注。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~