Unix Domain Socket
2020-09-05
平常工作中,大家或多或少都听过或者使用过 Unix Domain Socket, 但可能没有系统的总结梳理过,比如与 TCP/UDP Socket 差别在哪里,除了普通的数据传递还有哪些玩法,能否利用 tcpdump 抓取流量呢? 该文章主要从是什么、怎么用、性能如何和抓包排障等角度进行介绍。
是什么
Unix Domain Socket 也叫做 IPC Socket,与其他 IPC 的机制 Signal、Pipe、FIFO、Message Queue、Semaphore 和 Shared Memory 类似,都可以用于同一台机器上不同进程间的数据传输。
接口上与我们平常接触到的 Internet Socket 基本相同,用户如果想从 TCP/UDP Socket 切换到 Unix Domian Socket,代码几乎不用做变更。不过,不同的是其底层实现不依赖任何 Network Protocol,发送数据时发送方直接将数据写到接收方的 Socket Buffer, 而不会涉及到任何 TCP/IP 头部添加、校验和计算、Packet 分段、Packet 确认、窗口变化以及路由选择等操作,理论上开销更小,有着更好的性能。
另外,Unix Domain Socket 不使用 Internet Socket 的 ip:port 作为地址,而是用文件系统作为地址命名空间,比如 /var/run/docker.sock 是 redis-server 监听的地址,redis-cli 可以指定该地址与 server 建立连接。
如何用
普通收发
使用方式上与平常接触的 TCP/UDP Socket 基本一致,下面是一些具体的例子:
HTTP
在一个终端启动上面的程序:
go run http_unix.go |
在另外一个终端上用 curl 进行测试:
# query over the unix socket |
TCP
在一个终端上启动上面的程序:
go run tcp_unix.go |
在另外一个终端上使用 netcat 进行测试:
netcat 有两个版本,分别是 netcat-traditional 和 netcat-openbsd,traditional 版本不支持 Unix Domain Socket(-U),测试时要首先确认自己安装的是 openbsd 重写的。
# connect to the socket |
认真查看上面的两段代码,会发现每次在执行 net.Listen
前都会先执行 os.Remove(udsPath)
把 socket 文件删除掉,初次看到的话应该会感到非常奇怪,会好奇为啥要这么玩,是不是写错了?
其实不是写错了,这么写是有意为之,原因是 Unix Socket 不像 Internet Socket, 进程退出(包括 Crash)时操作系统不会自动清理掉创建的 Socket 文件,为了保证本进程能够成功监听,必须确保先前创建的 Socket 文件被删除掉,否则会一直报 bind: address already in use
的错误。
除此之外还可能会有权限的问题,比如在 Linux 当前的实现中,要求 Socket 的创建者需要拥有该文件所在目录的 write 和 search(execute) 权限,连接到 Socket 的一方需要有该文件的 write 权限,否则会抛出 Permission denied 的错误。
因此,如果使用文件系统作为地址命名空间的话,都需要处理这两种情况,不然程序会抛出错误无法运行。但是,在 Linux 上有第二种选择,可以使用 Abstract Socket Namspace。具体来说,Linux 额外开发了一个叫做 Abstract Namespace 的特性,它允许我们创建一个 Socket 而不用绑定文件,甚至在进程退出(无引用)时会被自动清理掉,下面是一个具体的例子:
开启一个终端执行上面程序:
go run tcp_abstract_unix.go |
另开一个终端用 socat 进行测试:
- netcat 不支持 abstract socket
- socat 介绍 https://medium.com/@copyconstruct/socat-29453e9fc8a6
# show tcp-unix.sock |
FD 传递
Unix Socket 除了能够传输普通的数据外,还能够在完全不相关(非父子)的进程间传递 FD, 很多开源的项目 HAProxy、Nginx 和 Envoy 都有用到该特性。下面是一个将 HTTP Listener 通过 Unix Socket 从一个进程传递到另外一个进程的例子:
- fd_send.go: 发送方
- fd_receive.go: 接受方
开启两个终端分别启动 fd_send.go 和 fd_receive.go 程序:
go run fd_send.go |
go run fd_receive.go |
另外开启一个终端进行测试:
# query, receive the response from server1 |
- Unix Socket 不支持 SO_REUSEPORT, 多个进程没法直接监听同一 Socket
- FD 传递,并不是将 FD 的值传递给另一个进程,而是传递的内核同一 File 结构的引用,两者都指向内核 Open File Table 的同一个 File, 两个 FD 的值不要求一样,并且在现实中大概率是不同的
从测试结果看,HTTP Listener 的 FD 成功的传递到了接受方,并且接受方能够正常处理用户请求。之所以能够通过 Unix Socket 能够传递 FD,是因为数据发送调用了 sendmg 接口,其参数 msg 支持携带辅助数据,下面是他们的签名:
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags); |
其中 msg_control 和 msg_controllen 是用来传递辅助数据的,msg_control 对应的数据结构定义如下:
struct cmsghdr { |
其各参数的含义和用法可以参考 https://man7.org/linux/man-pages/man3/cmsg.3.html
最后,除了传递 FD 外,在 Linux 当前实现中还支持传递 credentials 和 selinux context, 服务端可以用来验证客户端的身份,由于涉及到的东西比较多,这里不再详细描述,具体用法参见 https://www.man7.org/linux/man-pages/man7/unix.7.html
性能
前面提到 Unix Domain Socket 发送数据的时候直接将数据写到接收方的 Socket Buffer,不经过网络协议栈,理论上开销更小,不过缺少实际的数据指标,下面以 redis 为例子,测试下在同样环境下 Unix Domain Socket 和 TCP/IP Loopback 的表现。
测试环境
Redis Version: 4.0.9 Linux Kernel: 4.15.0-112-generic CPU: 1 x Intel(R) Core(TM) i7-8557U CPU @ 1.70GHz
测试结果
TCP/IP Loopback
redis-benchmark -t ping,get,set -q -d 256 -n 100000
PING_INLINE: 60060.06 requests per second
PING_BULK: 56211.35 requests per second
SET: 54884.74 requests per second
GET: 54141.85 requests per second
Unix Domain Socket
redis-benchmark -t ping,get,set -q -s /var/run/redis/redis-server.sock -d 256 -n 100000
PING_INLINE: 82169.27 requests per second
PING_BULK: 82712.98 requests per second
SET: 85984.52 requests per second
GET: 82101.80 requests per second
从测试结果上看,Unix Domain Socket 的吞吐量是 TCP/IP Loopback 的 1.5 倍,提升了约 50%。
排障
网络相关的程序在运行过程中,不可避免的会出现预期之外的情况,为了搞清楚原因大多时候我们会用 tcpdump 抓包并进行分析。然而 Unix Socket 底层实现不依赖任何 Network Protocol,其数据不经过任何网络协议栈,tcpdump 和 tshark 完全没法发挥其用处。
并且,虽然 Unix Socket 被很多基础组件 MySQL、Redis、Docker 等支持,但社区却没有一个类似于 tcpdump 的标准抓包工具,对开发者不是很友好,出错排障成本也比较高。不过,没有标准的工具不代表不能分析流量,一定要做的话还是有一些折衷的办法的,下面以 docker 为例子介绍下社区常见的方案:
docker 是 client-server 架构,两者通信默认采用 unix domain socket
Man in the middle
核心思想是创建一个中间的 TCP Socket, 然后在 TCP Socket 上借助 tcpdump 抓包,具体玩法如下:
在一个终端上执行下面的命令
# 1. 获取 docker 监听的 socket 文件
lsof -p $(pgrep dockerd) | grep docker.sock
dockerd 16087 root 6u unix 0xffff9cec3aee4000 0t0 45084 /var/run/docker.sock type=STREAM
# 2. 重命名原来的 socket 文件
sudo mv /var/run/docker.sock{,.orig}
# 3. 创建中间的 tcp socket 并拷贝流量
sudo socat TCP-LISTEN:8080,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock.orig &
# 4. 创建原来的 socket 文件并拷贝流量
sudo socat UNIX-LISTEN:/var/run/docker.sock,fork TCP-CONNECT:127.0.0.1:8080 &
# 5. 使用 tcpdump 在中间的 tcp socket 上抓包
sudo tcpdump -i lo tcp port 8080 -XX
另外开启一个终端执行 docker images 进行测试,下面是 tcpdump 抓包的部分结果,可以看到成功抓取到了通信过程中的 HTTP 请求。
该方案能够利用现成的 tcpdump 工具,比较友好,但缺点是需要重启 client 重建连接,对于没法重启的就玩不转了。
Strace
核心思想 trace 数据收发的 read 和 write 系统调用,获取 read/write 的参数, 具体玩法如下:
# 展示 docker images 涉及到的 write 调用 |
该方案无需重建连接,能够在已有的连接上抓取流量,不过 strace 对程序的性能有比较大的影响,在生产环境使用的时候需要额外慎重。
eBPF
eBPF 是 Linux 内核在 3.18 版本以后引入了的一种扩展的 BPF 虚拟机,允许用户动态的获取、修改内核中的关键数据和执行逻辑,并且有着非常优秀的性能。
核心思想与 strace 类似,都是通过 trace 来分析读写流量,不过 eBPF 有着更小的开销,对应用程序性能影响较小。unixdump 就是这样一个基于 eBPF 和 bcc 开发的工具,能够非常方便地 dump 系统所有的 unix domain sockets 流量,也支持过滤某一个 socket,下面是具体的玩法:
首先开启一个终端启动 unixdump 程序
sudo unixdump -s /var/run/docker.sock |
另外开启一个终端执行 docker iamges 执行测试,下面是 dump 的结果:
该方案无需重建连接,对应用程序性能影响较小,操作方便,有比较强的普适性;不过由于依赖了 eBPF,对内核版本有一定的要求。
注: 上面省略了 bcc 和 unixdump 的安装步骤,在实际测试的时候需要首先进行安装。
总结
Unix Domain Socket 提供了一种单机不同进间程通信的方案,接口上与 Internet Socket 类似,功能上除了支持发送普通数据外,还能在进程间传递 FD、Credentials 和 SELinux Security Context信息,性能上相对于 TCP/IP 本地回环网络有 50% 左右的性能提升,排障上社区没有类似于 tcpdump 的成熟工具,流量分析和排障的成本会比较高。