笔者在前边系列文章中反复强调过Kubernetes是云计算时代应用程序部署的操作系统,这个特殊的操作系统运行在一组节点之上,这些计算节点上通常安装了Linux或者Windows操作系统,因此如果我们要理解Kubernetes的网络实现细节,理解Linux操作系统的网络实现原理是基础,然后在这个基础之上,我们再来讨论Kubernetes对操作系统的网络实现做了哪些抽象。
注:由于在Windows上运行容器应用仍然属于小众场景,并且Windows容器技术处于刚刚成熟阶段,因此笔者在后续的文章中聚焦于Linux操作系统上的容器技术方案。
Kubernetes本质上只是一套软件系统,这套设计精良的应用程序主要功能就是管理一组Linux或者Windows机器,因此无论Kubernetes对外提供了多么丰富的计算和网络等抽象能力,底层还是需要依赖于Linux或者Windows上的操作系统功能来实现,特别是网络部分。笔者在实际的项目中经常遇到同学在讲给客户设计了网络方案,或者网络拓扑方案,但是看了实际方案后,很多时候是一个部署方案,里边呈现的是SLB,网关,WAF,防火墙以及EIP,NAT这样的云原生技术组件。从方案设计角度把这些组件按照逻辑关系组织在一起有没有啥问题,但是笔者认为叫网络方案不合适。
在Kubernetes平台上,网络是对底层工作节点上的网络能力的抽象,集群层级的网络应该是跨越了多个机器节点而形成的overlay网络。在这个网络上,应用程序实例(POD,当然一个POD中可以部署多个应用程序容器实例)之间可以不经过NAT直接进行通信。因此如何解决这层虚拟的overlay网络在底层的underlay网络上的互访,IP地址分配等是Kubernetes网络方案要解决的核心问题。
计算机网络本身就很复杂,由于涉及到大量的协议,规范,分层等,很容易就陷入到各种协议理论层面的说明,这样的信息已经汗牛充栋,也没有必要再复制粘贴。为了让我们的讨论稍微有点生机(笔者在很多项目给C级别做汇报的时候,遵循的一个基本原则就是给客户除了展现静态层面的系统架构之外,会通过一个真实的请求如何在系统中被处理来展示动态的一面),我们后续的讨论会使用如下用Golang编写的极简Web服务,以期通过这个服务如何处理curl发出的请求,来庖丁解牛式的分析请求从应用层,到传输层,到网络层,数据链路层的处理细节。
package main
import (
"fmt"
"net/http"
)
func hello(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "qiwangyue")
}
func main() {
http.HandleFunc("/", hello)
http.ListenAndServe("0.0.0.0:8080", nil)
}
注:在Linux操作系统上,端口号1-1023属于特权端口,需要root权限才能bind。咱们编写的应用程序应该避免使用低于1024的端口号,而应该选择1024-65535之间的值。咱们这个极简Web服务使用的就是大家熟知的8080,如果这个服务被部署在Kubernetes集群中,可以选择Service或者外部负载均衡重定向的能力,来把从80端口上收到的客户端请求,重定向到这个POD上的8080端口服务。
接下来,假设这个Golang的Web服务运行在Linux服务器上,外部用户可以直接通过路径/来访问这个服务,那么当服务启动的时候,在操作系统上具体发生了什么?或者说服务启动的时候,从网络设备和组件的角度,具体发生了哪些动作?计算机专业的同学应该大概知道当我们启动这个编译好的二进制文件时,应用程序会监听某个网络地址(Linux服务器的IP地址)和端口号(8080)。具体来说应用程序会基于IP地址和端口号创建socket结构,并且和机器上的IP地址和端口号绑定(bind),这样就完成了服务的启动工作。
很多同学可能不理解为啥分为创建socket和bind这两个步骤,咱们后续的内容会详细说明,这里你可以简单的理解为创建这个叫socket的逻辑对象,以及将这个逻辑对象和物理设备进行关联这么两步操作。当应用程序运行起来之后,我们的应用就可以收到来自于客户端的请求,具体来说就是目标地址是机器的IP地址和端口号为8080的请求。
注:咱们的极简Web服务监听的IP地址是0.0.0.0这样的IPv4地址,在IPv6应该写成【::】通配符地址,这是个特殊的地址,用来标识这个服务监听这台机器上的所有可用的IP地址。这是一种非常有效的监听和bind机制,因为很多时候我们可能在编写应用程序的时候,不知道应用程序将要运行在哪些IP地址上,大部分的网络服务都是以这种方式启动并绑定到宿主机(容器实例)的网络接口上。
基于上边的信息,我们知道socket对象是运行中的应用程序的入口,那么如何能观察到这个在服务启动时创建的socket对象呢?还记得我们在前边多篇文章中反复提到的Linux至理名言:一切皆文件。实际上咱们可以通过ls -lah /proc/<server proc/fd来罗列相关进程(服务)的网络套接字socket。在笔者的机器上输出如下:
# ps -aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 22 0.3 0.9 928116 19960 pts/1 Sl+ 10:11 0:00 go run web-server.go
root 90 0.0 0.2 477816 5760 pts/1 Sl+ 10:11 0:00 /tmp/go-build957677948/b001/exe/web-server
# ls -lah /proc/90/fd
total 0
dr-x------ 2 root root 0 Dec 17 10:13 .
dr-xr-xr-x 9 root root 0 Dec 17 10:11 ..
lrwx------ 1 root root 64 Dec 17 10:13 0 -> /dev/pts/1
lrwx------ 1 root root 64 Dec 17 10:13 1 -> /dev/pts/1
lrwx------ 1 root root 64 Dec 17 10:13 2 -> /dev/pts/1
lrwx------ 1 root root 64 Dec 17 10:13 3 -> 'socket:[26705]'
lrwx------ 1 root root 64 Dec 17 10:13 5 -> 'anon_inode:[eventpoll]'
当内核从socket上接收到数据包packt之后,会将packet和特定的connection进行管理,并且操作系统通过状态机来管理connection的状态,大白话说就是连接状态。同样咱们有非常丰富的工具供选择来观察连接以及状态,后续文章会详细介绍。在Linux操作系统上,连接也是通过文件来表示,当应用accept一个连接请求后,实质上在操作系统的对应目录创建一个文件,后续的数据写入和读出都是通过这个文件来进行。
有了这些基础之后,咱们回到极简Golang服务上,我们可以通过strace来观察服务的运行情况,通过命令strace ./main来监控咱们的应用程序,由于strace会捕捉到所有在这台机器上进行的系统调用,因此输出的内容会非常丰富。为了能够捕捉到关键信息,咱们对输出结果进行了简化,只保留了和极简Golang服务相关的内容,如下图所示:
从上图我们可以看到web服务在启动的时候,发生了如下四个关键的系统调用:
- 打开一个文件描述符
- 为IPv6协议的连接创建socket
- 在socket上禁用IPV6_V6oNLY,应用可以同时提供IPV4和V6服务
- 将socket绑定(bind)到机器上的所有IP地址的8080端口号
- 等待连接请求
特别是最后一步,当服务启动后,我们从输出的信息中可以看到,strace卡在epoll_wait上等待访问请求。
到这里为止,服务已经启动,并且监听在8080端口上等待请求的到来,请求一般是内核通知socket有符合你处理的packt,这个时候服务受到通知,从内核的缓冲区读取到数据,继续处理。这个时候需要curl给这个极简服务发送一个实际请求了。在相同机器的另外一个窗口上执行curl localhost:8080/命令,如下所示:
# curl http://localhost:8080/
qiwangyue
注:笔者强烈建议大家在开发服务的使用postman或者postwomen这样的测试工具,最好不要使用浏览器,除非你编写的是前端页面。原因是浏览器会发送很多额外的请求给服务器,比如获取favicon文件等,这会让我们调试变得异常的困哪,增加额外的工作量来分析结果。特别是浏览器有缓存功能,有时候我们的请求直接从缓存返回,根本就没有到服务器端,有时候把开发人员折磨的痛不欲生啊。因此选择curl或者telnet这样的轻量级工具,简洁明了,还节省时间,是开发调试之良具。
当服务端接受并处理请求后,strace的输出如下:
[{EPOLLIN, {u32=1714573248, u64=1714573248}}], 128, -1) = 1
accept4(3, {sa_family=AF_INET6, sin6_port=htons(54202), inet_pton(AF_INET6,
"::ffff:10.0.0.63", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0},
[112->28], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5
epoll_ctl(4, EPOLL_CTL_ADD, 5, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET,
{u32=1714573120, u64=1714573120}}) = 0
getsockname(5, {sa_family=AF_INET6, sin6_port=htons(8080),
inet_pton(AF_INET6, "::ffff:10.0.0.30", &sin6_addr), sin6_flowinfo=htonl(0),
sin6_scope_id=0}, [112->28]) = 0
setsockopt(5, SOL_TCP, TCP_NODELAY, [1], 4) = 0
setsockopt(5, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0
setsockopt(5, SOL_TCP, TCP_KEEPINTVL, [180], 4) = 0
setsockopt(5, SOL_TCP, TCP_KEEPIDLE, [180], 4) = 0
accept4(3, 0x2032d70, [112], SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN
(Resource temporarily unavailable)
从strace输出的数据中可以看到,服务器会将响应数据“qiwangyue”写到response数据中,通过http协议来进行封装,并最后写到文件描述符fd中。从Linux操作系统内核将这些数据转换成packet,然后协议栈发现这个数据的目的地址是本机,内核通知curl有数据可以读取,因此curl被唤醒,从内核读取数据,将结果“qiwangyue”在控制台打印出来。
对上边数据的服务器处理客户端请求的处理过程按顺序进行梳理,描述如下:
- Epoll返回,唤醒咱们的极简Web服务应用程序
- 服务从请求信息中获取到客户端的IP地址是::ffff:10.0.0.63
- 服务器端会检查socket的状态以及设置相关的参数
上边就是一个极简的Golang服务从客户端当服务器端的处理过程,特别是在操作系统内核层级发生的各种类型的系统调用。咱们下篇文章继续介绍网络接口以及Linux操作系统如何处理数据包packet,敬请期待!