Review 《File Descriptor Transfer over Unix Domain Sockets》


12/31/2020 更新

如果你使用的是较新的内核(Linux 5.6以上),随着一个新的系统调用 pidfd_getfd 的引入,这种复杂性大部分已经被消除了。更多细节请参考 2020年 12-31 发表的文章《用 pidfd 和 pidfd_getfd 在进程间无缝传输文件描述符》

昨天,我读了一篇惊人的论文,介绍了使用不同协议而且服务许多不同类型请求(长寿的TCP/UDP会话,涉及大块数据的请求等)的服务在Facebook是如何免中断发布的。

Facebook 使用的一种它们叫做 Socket Takeover 的技术。

Socket Takeover 实现了 Proxygen 的零停机重启,它以并行方式启动了一个更新的实例,接管了监听的套接字,当旧的实例进入了优雅关闭阶段。新实例负责为新连接提供服务,并响应来自 L4LB Katran 的健康检查探针。老的连接由老的实例提供服务,直到完全关闭,之后其他机制(例如下游连接重用)开始发挥作用。

当我们把一个打开的FD从旧的进程传递给新的进程时,传递和接收的进程都共享监听套接字的同一个文件表项,并各自独立地处理接收的连接,在这些连接上提供连接级事务。我们利用了以下Linux内核的特性来实现这一点。

CMSGsendmsg() 的一个功能允许在本地进程之间发送控制信息(通常被称为辅助数据)。在 Level7 LB 进程的重启过程中,我们使用这一机制将所有 VIP ( Virtual IP of service) 的活动的监听套接字的 FD 集合从活动的实例发送至新启动的实例。这些数据是通过 sendmsgrecvmsg 在 UNIX 域套接字上交换的。

SCM_RIGHTS: 我们设置这个选项来发送打开的文件描述符,其数据部分包含一个打开的FD的整数数组。在接收方,这些 文件描述符的行为就像它们是用 dup(2) 创建的一样。

我在 Twitter 上收到了一些人的回复,他们对这竟然是可能的表示惊讶。事实上,如果你对Unix域套接字的一些特性不是很熟悉,那么论文中的上述段落可能就很难理解了。

实际上,在 Unix 域套接字上传输 TCP 套接字是一种久经考验的方法,来实现 热重启零停机时间重启 。流行的代理,如 HAProxy 和 Envoy ,使用非常类似的机制,将连接从代理的一个实例引流到另一个实例,而不丢弃任何连接。然而,许多类似的功能却并不广为人知。

在这篇文章中,我想探讨 Unix 域套接字的一些特性,这些特性使它成为这些场景下的合适候选者,特别是将套接字(或任何文件描述符)从一个进程转移到另一个进程,在这两个进程之间不一定存在父子关系。

Unix 域套接字

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

一个简短的教程

就像网络套接字一样,Unix 域套接字也支持流和数据报文类型。然而,与采用 IP 和端口作为地址的网络套接字不同,Unix域套接字使用路径名作为地址。与网络套接字不同,Unix域套接字的 I/O 不涉及对底层设备的操作(这使得Unix域套接字与网络套接字相比,在同一主机上执行IPC要快很多)。

bind(2) 将一个名字绑定到一个Unix域套接字,在文件系统中创建一个名为 pathname 的套接字文件。然而,这个文件与你可能创建的任何普通文件不同。

一个简单的Go程序在 Unix 域套接字上创建一个监听的 “Echo 服务器”,如下所示:

package main

import (
	"io"
	"log"
	"net"
	"syscall"
)

func main() {
	const addr = "/tmp/uds.sock"
	syscall.Unlink(addr)

	l, err := net.Listen("unix", addr)
	if err != nil {
		log.Fatal(err)
	}
	defer l.Close()

	for {
		c, err := l.Accept()
		if err != nil {
			log.Fatal(err)
		}

		go func(c net.Conn) {
			io.Copy(c, c)
			c.Close()
		}(c)
	}
}

如果你建立并运行这个程序,可以观察到几个有趣的事实。

Socket 文件 != 普通文件

首先,套接字文件 /tmp/uds.sock 被标记为一个套接字。当使用 stat() 调用查看这个路径名时,它在stat结构的st_mode字段的文件类型部分返回值 S_IFSOCK

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

root@1fd53621847b:~/uds# ./uds
^C
root@1fd53621847b:~/uds# ls -ls /tmp
total 0
0 srwxr-xr-x 1 root root 0 Aug  5 01:45 uds.sock
root@1fd53621847b:~/uds# stat /tmp/uds.sock
File: /tmp/uds.sock
Size: 0          Blocks: 0          IO Block: 4096   socket
Device: 71h/113d Inode: 1835567     Links: 1
Access: (0755/srwxr-xr-x)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2020-08-05 01:45:41.650709000 +0000
Modify: 2020-08-05 01:45:41.650709000 +0000
Change: 2020-08-05 01:45:41.650709000 +0000

Birth: -

root@5247072fc542:~/uds# ls -F /tmp
uds.sock=
root@5247072fc542:~/uds#

对文件起作用的普通系统调用在套接字文件上不起作用:这意味着像 open()close()read() 这样的系统调用不能用于套接字文件。相反,像 socket()bind()recv()sendmsg()recvmsg() 等套接字相关的系统调用可以在 Unix域套接字上工作。

另一个关于套接字的有趣事实是,它不是在套接字被关闭的时候删除,而是通过系统调用来删除

  • 在 MacOS 上调用 unlink(2)
  • 在 Linux 上调用 remove() 或使用地更普遍的 unlink(2)

在 Linux 上,Unix 域套接字的地址通过如下的结构来表示:

struct sockaddr_un {
      sa_family_t sun_family; /* Always AF_UNIX */
      char sun_path[108]; /* Pathname */
};

在 MacOS 上,地址通过如下的结构表示:

struct sockaddr_un {
     u_char  sun_len;
     u_char  sun_family;
     char    sun_path[104];
};

使用 bind(2) 绑定一个已经存在的路径将会失败

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

SO_REUSEPORT 的支持是在 Linux 3.9及以上版本中引入的。然而,在 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 域套接字无法绑定相同的路径。

SOCKETPAIR

socketpair() 函数创建了两个套接字,然后将其连接在一起。从某种程度上说,这与 管道 非常相似,只是它支持数据的双向传输。

socketpair 只对 Unix 域套接字起作用。它返回两个已经连接在一起的文件描述符(所以我们不必在开始传输数据之前 , 执行 socket -> bind -> listen -> accept的流程来建立一个监听套接字。也不必在开始传输数据之前,执行 socket -> connect 的流程创建一个连接到监听套接字的客户端)。

在 Unux 域套接字上传输数据

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

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

此外,特殊的系统调用 sendmsgrecvmsg 允许在Unix域套接字上发送一个特殊的消息。这个消息由内核特别处理,它允许从发送者向接收者传递打开的 File Descriptions

File Descriptors(文件描述符) VS File Description (文件描述)

注意上一段我使用了术语 文件描述(File descripTION),而不是文件描述符 (file descripTOR)。它们两者的区别是微妙的而且往往不被人理解。

文件描述符实际上只是一个进程内(不可跨进程使用)指向底层内核数据结构的指针,该结构被称为文件描述(File Description)。内核维护着一个所有打开的文件描述的表格,称为打开文件表(open file table)。如果两个进程(A和B)试图打开同一个文件,这两个进程可能有自己独立的文件描述符,它们指向开放文件表中的同一个文件描述。

img

所以,在 Unix 域套接字上使用 sendmsg 发送文件描述符实际上意味着发送 文件描述 的引用。如果进程 A 向 进程 B 发送文件描述符 0 (fd0),该文件描述符在进程 B 中很可能被数字3(fd3) 所引用。

发送进程在 Unix 域套接字上调用 sendmsg 发送文件描述符,接收进程在 Unix 域套接字上调用 recvmsg 来接受文件描述符。

发送进程通过 sendmsg 发送文件描述给接收进程,接收进程通过 recvmsg 接收该文件描述。即使发送进程在发送完成后关闭了该文件描述所对应的文件描述符,而接收进程还未调用 recvmsg 接收它,该文件描述依然对接收进程保持打开状态。

发送文件描述符时,文件描述的引用次数会+1,直到文件描述的引用次数下降到0, 内核才会将文件描述从 打开文件表(open file table) 中删除该文件描述。

即使发送进程在接收进程调用 recvmsg 之前关闭了引用通过 sendmsg 传递的文件描述的文件描述符,该文件描述符仍然对接收进程开放。发送描述符时,描述符的引用次数会增加1。内核只有在引用计数下降到0时才会从其开放文件表中删除文件描述。

sendmsg 和 recvmsg

在 Linux 中 sendmsg 函数的签名如下:

ssize_t sendmsg(
    int socket,
    const struct msghdr *message,
    int flags
);

sendmsg 对应的是 recvmsg:

ssize_t recvmsg(
     int sockfd,
     const struct msghdr *msg,
     int flags
);

人们可以用 sendmsg 在 Unix 域套接字上传输的特殊消息是由 msghdr 指定的。希望将文件描述发送给另一个进程的进程创建一个 msghdr 结构,其中包含要传递的描述。

struct msghdr {
    void            *msg_name;      /* optional address */
    socklen_t       msg_namelen;    /* size of address */
    struct          iovec *msg_iov; /* scatter/gather array */
    int             msg_iovlen;     /* # elements in msg_iov */
    void            *msg_control;   /* ancillary data, see below */
    socklen_t       msg_controllen; /* ancillary data buffer len */
    int             msg_flags;      /* flags on received message */
};

msghdr 结构的 msg_control 成员,其长度为 msg_controllen ,指向一个如下形式的消息缓冲区:

struct cmsghdr {
    socklen_t cmsg_len;    /* data byte count, including header */
    int       cmsg_level;  /* originating protocol */
    int       cmsg_type;   /* protocol-specific type */
    /* followed by */
    unsigned char cmsg_data[];
};

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

辅助数据传输

虽然这种数据传输有大量的陷阱,但如果使用得当,它可以成为一个相当强大的机制来实现一些目标。

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

  • SCM_RIGHTS
  • SCM_CREDENTIALS
  • SCM_SECURITY

这三种类型的辅助数组仅应该通过下面的宏定义来访问,而不应该直接使用

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);
unsigned char *CMSG_DATA(struct cmsghdr *cmsg);

我从来没有使用后两者的需要,SCM_RIGHTS 是我希望在这篇文章中进一步探讨的。

SCM_RIGHTS

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

cmsghdr 结构体的成员 cmsg_data 可以包含一个进程想要发送的文件描述符的数组。

struct cmsghdr {
    socklen_t cmsg_len;    /* data byte count, including header */
    int       cmsg_level;  /* originating protocol */
    int       cmsg_type;   /* protocol-specific type */
    /* followed by */
    unsigned char cmsg_data[];
};

接收进程使用 recvmsg 接收数据。

《Linux Programming Interface》有一个使用 sendmsgrecvmsg 函数的良好示例

SCM_RIGHTS 的陷阱

正如上文所讲,使用 Unix 域套接字传输辅助数据有很多的陷阱。

Need to send some “real” data along with the ancillary message

需要在发送辅助数据的同时发送一些真实的数据

在 Linux 上,如果想要成功地在 流式(stream) Unix 域套接字上发送辅助数据,至少要发送一个字节的真实数据。

然而,如果想要在 数据报式(datagram) 的 Unix 域套接字上发送辅助数据,不需要发送任何附带的真实数据。也就是说,在通过数据报套接字发送辅助数据时,便携式应用程序还应该包括至少一个字节的真实数据。

File Descriptors can be dropped

文件描述符可以被丢弃

如果用于接收包含文件描述符的辅助数据的缓冲区 cmsg_data 太小(或没有),那么辅助数据被截断(或丢弃),多余的文件描述符在接收进程中被自动关闭。

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

recvmsg quirks

recvmsg 的怪异情况

sendmsgrecvmsg 的作用类似于 sendrecv 系统调用,在每个 send 调用和每个 recv 调用之间没有1:1的映射。

一个 recvmsg 调用可以从多个 sendmsg 调用中读取数据。同样地,它可能需要多个recvmsg调用来消耗一个sendmsg 调用所发送的数据。这有严重的、令人惊讶的影响,其中一些已经在这里报告。

Limit on the number of File Descriptions

文件描述符的数量限制

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

试图发送一个大于这个限制的数组会导致 sendmsg 失败,错误是 EINVAL

什么时候发送文件描述符是有用的

一个非常具体的现实世界的使用案例是零停机时间的代理重载。

任何曾经使用过HAProxy的人都可以证明,“零停机时间的配置重载 “在很长一段时间内并不是一个真正的东西。通常,大量的Rube Goldberg-esque Hack 被用来实现这一目标。

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

2018年底,Cloudflare 在博客 中介绍了其使用的将文件描述符从nginx转移到Go TLS 1.3代理。

促使我写下这篇博文的关于Facebook如何实现零停机发布的论文,使用了与 CMSG + SCM_RIGHTS 相同的技巧,将活的文件描述符从将要结束的进程传递到新发布的进程。

总结

如果使用得当,通过Unix域套接字传输文件描述符可以被证明是非常强大的。我希望这篇文章能让你对Unix域套接字和它的功能有一个更好的理解。

References:

2021年11月30日 / 11:58