Unix域套接字上的文件描述符传输

2020-11-02 14:21:43

昨天,我读了一篇现象级的论文,内容是如何无中断地发布使用不同协议并服务于不同类型的请求(长时间的TCP/UDP会话、涉及大量数据的请求等)的服务。在脸书工作。

Facebook使用的技术之一是他们所说的“套接字接管”。

套接字接管通过并行启动接管侦听套接字的更新实例来实现Proxygen的零停机重新启动,而旧实例则进入正常排出阶段。新实例承担服务新连接和响应来自L4LB Katran的运行状况检查探测的责任。旧连接由较旧的实例服务,直到排空周期结束,在此之后,其他机制(例如,下行连接重用)开始发挥作用。

当我们将打开的FD从旧进程传递到新旋转的进程时,传递进程和接收进程共享侦听套接字的相同文件表项,并处理各自接受的连接,它们在这些连接上为连接级事务提供服务。我们利用以下Linux内核功能来实现这一点:

CMSG:sendmsg()中的一个特性允许在本地进程之间发送控制消息(通常称为辅助数据)。在L7LB进程重启期间,我们使用此机制将每个VIP(服务的虚拟IP)的所有活动侦听套接字的FD集从活动实例发送到新旋转的实例。此数据通过UNIX域套接字使用sendmsg和recvmsg进行交换。

SCM_RIGHTS:我们将此选项设置为发送开放式FD,其数据部分包含开放式FD的整数数组。在接收端,这些FD的行为就像它们是使用DUP(2)创建的一样。

我在推特上收到了许多人的回复,他们对这一可能性表示惊讶。事实上,如果您不太熟悉Unix域套接字的一些特性,本文中的前述段落可能会让人费解。

Unix域套接字上的传输环TCP套接字实际上是实现“热重新启动”或“零停机重新启动”的一种久经考验的方法。流行的代理(如HAProxy和特使)使用非常类似的机制将连接从一个代理实例排出到另一个实例,而不会丢弃任何连接。然而,这些功能中的许多并不是非常广为人知的。

在这篇文章中,我想探讨Unix域套接字的一些特性,这些特性使其适合于其中几种用例,特别是在两个进程之间不一定存在父子关系的情况下,将套接字(或任何文件描述符)从一个进程传输到另一个进程。

众所周知,Unix域套接字允许同一主机系统上的进程之间进行通信。UNIX域套接字用于许多流行的系统中:HAProxy、特使、AWS的Firecracker虚拟机监视器、Kubernetes、Docker和Istio等等。

与网络套接字一样,Unix域套接字支持流和数据报套接字类型。但是,与采用IP地址和端口作为地址的网络套接字不同,Unix域套接字地址采用路径名的形式。与网络套接字不同,Unix域套接字之间的I/O不涉及底层设备上的操作(这使得Unix域套接字比在同一主机上执行IPC的网络套接字快得多)。

使用bind(2)将名称绑定到Unix域套接字将在文件系统中创建名为path name的套接字文件。但是,此文件不同于您可能创建的任何普通文件。

创建监听Unix域套接字的“回显服务器”的简单GO程序如下所示:

如果您构建并运行此程序,可以观察到一些有趣的事实。

首先,套接字文件/tmp/uds.sock被标记为套接字。将stat()应用于此路径名时,它在stat结构的st_mode字段的file-type组件中返回值S_IFSOCK。

当使用ls-l列出时,UNIX域套接字在第一列显示类型为s,而ls-F在套接字路径名后附加等号(=)。

Root@1fd53621847b:~/uds#./udds^C root@1fd53621847b:~/uds#ls-ls/tmp总计0 0 srwxr-xr-x 1根根0 8月5 01:45 uds.sock root@1fd53621847b:~/udds#stat/tmp/uds.sock File:/tmp/uds.sock File:/tmp/uds.sock size:0 Block:0 IO Block:4096 Socket Device:71H/113D inode:1835567 link:1 access:(0755/srwxr-xr-x)uid:(0/root)gid:(0/root)。)访问:2020-08-05 01:45:41.650709000+0000修改:2020-08-05 01:45:41.650709000+0000更改:2020-08-05 01:45:41.650709000+0000出生:-ROOT@5247072fc542:~/uds#ls-F/tmp uds.sock=root@5247072fc542:~/uds#。

处理文件的正常系统调用不适用于套接字文件:这意味着诸如open()、close()、read()之类的系统调用不能用于套接字文件。相反,套接字特定的系统调用(如Socket()、bind()、recv()、sendmsg()、recvmsg()等)用于处理Unix域套接字。

关于套接字文件的另一个有趣事实是,它不是在套接字关闭时删除的,而是通过调用以下命令来关闭的:

SO_REUSEPORT选项允许任何给定主机上的多个网络套接字连接到相同的地址和端口。尝试绑定到给定端口的第一个套接字需要设置SO_REUSEPORT选项,并且任何后续套接字都可以绑定到同一端口。

Linux 3.9及更高版本中引入了对SO_REUSEPORT的支持。但是,在Linux上,希望共享相同地址和端口组合的所有套接字必须属于共享相同有效UID的进程。

Int fd=socket(domain,socktype,0);int optval=1;setsockopt(SFD,SOL_SOCKET,SO_REUSEPORT,&;optval,sizeof(Optval));bind(sfd,(struct sockaddr*)&;addr,addrlen);

但是,两个Unix域套接字不可能绑定到同一路径。

函数的作用是:创建两个套接字,然后将它们连接在一起。从某种意义上说,这与PIPE非常相似,不同之处在于它支持双向数据传输。

SocketPair仅适用于Unix域套接字。它返回两个已经彼此连接的文件描述符(因此,在开始传输数据之前,不必执行整个套接字→绑定→Listen→Accept舞蹈来设置侦听套接字,并执行套接字→连接舞蹈来创建侦听套接字的客户端!)。

既然我们已经确定Unix域套接字允许同一台主机上的两个进程之间进行通信,那么现在是时候探索可以通过Unix域套接字传输什么类型的数据了。

由于Unix域套接字在许多方面类似于网络套接字,因此通常通过网络套接字发送的任何数据都可以通过Unix域套接字发送。

此外,特殊的系统调用sendmsg和recvmsg允许跨Unix域套接字发送特殊消息。此消息由内核专门处理,允许将打开的文件描述从发送方传递到接收方。

请注意,我提到的是文件描述,而不是文件描述符。这两者之间的区别是微妙的,通常并不是很好地理解。

文件描述符实际上只是一个指向底层内核数据结构的每个进程的指针,该数据结构称为(令人困惑的)文件描述。内核维护一个包含所有打开文件描述的表,称为打开文件表。如果两个进程(A和B)尝试打开同一文件,则这两个进程可能有各自单独的文件描述符,这些描述符指向打开的文件表中的相同文件描述。

因此,使用sendmsg()从一个Unix域套接字向另一个Unix域套接字“发送文件描述符”实际上意味着发送对文件描述的引用。如果进程A向进程B发送文件描述符0(Fd0),则该文件描述符很可能在进程B中由数字3(Fd3)引用。但是,它们将引用相同的文件描述。

发送进程调用sendmsg通过Unix域套接字发送描述符。接收进程调用recvmsg来接收Unix域套接字上的描述符。

即使发送进程在接收进程调用recvMsg之前关闭其引用通过sendMSG传递的文件描述的文件描述符,该文件描述对于接收进程也保持打开。发送描述或将描述的引用计数加1。如果引用计数降至0,则内核仅从其打开的文件表中删除文件描述。

可以使用sendmsg在Unix域套接字上传输的特殊“消息”由msghdr指定。希望将文件描述发送到另一个进程的进程创建包含要传递的描述的MSGHDR结构。

Struct msghdr{void*msg_name;/*可选地址*/socklen_t msg_namelen;/*地址大小*/struct iovec*msg_IOV;/*散布/聚集数组*/int msg_iovlen;/*#msg_iov中的元素*/void*msg_control;/*辅助数据见下文*/socklen_t msg_control len;/*辅助数据缓冲区*/int msg_flag;/*接收消息的标志*/};

Msghdr结构的msg_control成员的长度为msg_control len,它指向以下形式的消息缓冲区:

Struct cmsghdr{socklen_t cmsg_len;/*数据字节数,包头*/int cmsg_level;/*发起协议*/int cmsg_type;/*协议具体类型*//*后跟*/unsign char cmsg_data[];};

在POSIX中,带有附加数据的struct cmsghdr结构的缓冲区称为辅助数据。在Linux上,可以通过修改/proc/sys/net/core/optmem_max来设置每个套接字允许的最大缓冲区大小。

虽然这样的数据传输有太多的问题,但如果使用得当,它可以成为实现许多目标的一种非常强大的机制。

在Linux上,有三种类型的“辅助数据”可以在两个Unix域套接字之间共享:

所有三种形式的辅助数据都只能使用下面描述的宏来访问,并且绝对不能直接访问。

Struct cmsghdr*CMSG_FIRSTHDR(struct msghdr*msgh);struct cmsghdr*CMSG_NXTHDR(struct msghdr*msgh,struct cmsghdr*cmsg);size_t CMSG_ALIGN(Size_T Length);size_t CMSG_space(Size_T Length);size_t CMSG_LEN(Size_T Length);unsign char*CMSG_data(struct cmsghdr*cmsg);

虽然我从来不需要使用后两者,但SCM_RIGHT是我希望在这篇文章中更多地探讨的内容。

Scm_right允许一个进程使用sendmsg从另一个进程发送或接收一组打开的文件描述符。

Cmsghdr结构的cmsg_data组件可以包含一个进程想要发送给另一个进程的文件描述符的数组。

Struct cmsghdr{socklen_t cmsg_len;/*数据字节数,包头*/int cmsg_level;/*发起协议*/int cmsg_type;/*协议具体类型*//*后跟*/unsign char cmsg_data[];};

“Linux编程接口”一书对如何使用sendmsg和recvmsg有一个很好的编程指南。

如前所述,当尝试通过Unix域套接字传递辅助数据时,有许多问题。

在Linux上,至少需要一个字节的“真实数据”才能通过Unix域流套接字成功发送辅助数据。

但是,在Linux上通过Unix域数据报套接字发送辅助数据时,不需要发送任何附带的实际数据。也就是说,当通过数据报套接字发送辅助数据时,便携式应用程序还应该包括至少一个字节的实际数据。

如果用于接收包含文件描述符的辅助数据的缓冲区cmsg_data太小(或不存在),则辅助数据被截断(或丢弃),并且在接收过程中自动关闭多余的文件描述符。

如果在辅助数据中接收的文件描述符的数量导致进程超过其RLIMIT_NOFILE资源限制,则在接收进程中会自动关闭多余的文件描述符。不能在多个recvmsg调用上拆分该列表。

Sendmsg和recvmsg的行为类似于send和recv系统调用,因为在每个send调用和每个recv调用之间不存在1:1的映射。

单个RecvMSG调用可以从多个SendMSG调用读取数据。同样,可能需要多个recvmsg调用才能使用通过单个sendmsg调用发送的数据。这具有严重而令人惊讶的影响,其中一些已经在这里报道过。

内核常量SCM_MAX_FD(253(在2.6.38之前的内核中为255))定义了数组中文件描述符的数量限制。

尝试发送大于此限制的数组会导致sendmsg失败,并显示错误EINVAL。

使用此功能的一个非常具体的实际用例是零停机代理重新加载。

任何曾经使用过HAProxy的人都可以证明,“零停机时间配置重新加载”在很长一段时间内都不是真正意义上的事情。通常,过多的Rube Goldberg风格的黑客就是用来实现这一点的。

在2017年末,HAProxy 1.8发布时支持通过将侦听套接字文件描述符从旧的HAProxy进程转移到新的进程来实现无中断重新加载。特使使用类似的机制进行热重新启动,其中文件描述符通过Unix域套接字传递。

在2018年末,Cloudflare在博客中介绍了它将文件描述符从nginx传输到Go TLS 1.3代理的情况。

这篇关于Facebook如何实现零停机释放的文章促使我撰写了整篇博客文章,它使用了selfame CMSG+SCM_RIGHT技巧,将实时文件描述符从排出进程传递到新发布的进程。

如果使用得当,可以证明通过Unix域套接字传输文件描述符非常有效。我希望这篇文章能让你对Unix域套接字及其支持的特性有一个更好的理解。

LWN.net有一篇有趣的文章,介绍了在Unix域套接字上传递文件描述时创建循环,以及对新的令人难以置信的io_uring内核API的影响。Https://lwn.net/Articles/779472/