TCP 端口表示我怎么就被玩坏了呢

为什么要有 TCP 端口?反向代理是个啥?端口会不会不够用?


DARPA 说,要有「端口」

此时天降一只 Alice。它打了个响指,互联网的一大基础 TCP 协议就只剩下一半了 ——「端口」的概念没有了。

这个两台计算机只能靠着 IP 互传数据的世界,需要我们来拯救!

Act 0

Alice 访问了一个网页。
它的计算机找到服务器的 IP 地址,向其发送了一条请求,带有自己的 IP 地址和请求的 URL。
服务器将网页内容传给了 Alice 的计算机。

Alice 灵机不动就能搞定。

Act 1

Alice 同时访问两个网页。
它的计算机向服务器发送了两条请求。

显然,服务器会传回两段数据(响应),Alice 需要区分它们。

Alice 灵机一动 —— 如果服务器愿意配合,事情可以很简单:服务器将 URL 与页面内容一同传送即可。

服务器将两个网页的内容分别标注上 URL,传给 Alice 的计算机。
浏览器将两段数据准确地分配到两个标签页上。

(Alice 的 IP 地址一直是与数据一同传送的,但限于空间,此后的图上都省略啦)

Act 2

Alice 的两个标签页同时需要从服务器的同一个 URL 更新数据。

Alice 灵机一动,决定「帮服务器帮助自己」:给每条请求事先标上一个编号 ReqID。而服务器则需要将一段响应数据所对应的 ReqID 与数据本身一同传送,这样 Alice 便能区分两段数据分别对应哪个标签页。

Alice 的计算机向服务器发送了两条 URL 相同的请求,但分别带上了一个独特的编号 ReqID。
服务器将两段响应数据分别标注上对应请求的 ReqID,传给 Alice 的计算机。
浏览器将两段数据准确地分配到两个标签页上。

Act 3

Alice 访问了一个网页,同时还向同一台服务器发送了一封电子邮件。

出于可扩展性,以及格式的差异,万维网和电子邮件当然应该由两个程序处理。两个程序需要分别找出发给自己的请求。

Alice 灵机一动:只要让它们从所有请求当中选取自己能够理解的即可。

服务器 OS 接收到两条请求,将它们广播给所有网络程序。
两个程序分别读取其中一条自己可以理解的信息,产生返回的数据,并交由 OS 传回给 Alice。
Alice 得到一个网页,以及一条「发送成功」的消息。

Act 4

Alice 有一台服务器,上面运行着两个万维网程序,是两个网站的家。
Bob 希望访问其中一个网站,发送了一条万维网请求。

由于两个程序均能理解万维网请求,分别会产生一条响应。于是……

Bob 收到了两份响应。
它一会儿从响应 A 瞅到响应 B,一会儿又从 B 瞅到 A,最后,又从 A 瞅到 B,它再也分不清哪个是 A,哪个是 B 了。

Alice 灵机一动,注册了两个不同的域名,通过之前区分 URL 的方式解决了这个问题。

这个时候 1000000000 个没有分配到域名的 IP 地址发动了起义:「We need equality!!」

Act 5

Alice 有一台服务器,上面运行着两个万维网程序,是两个网站的家。

Alice 灵机一动……

Alice 将两个程序的 PID 公之于众。
Bob 希望访问其中一个网站 A,发送了一条请求,标有程序 A 的 PID。
服务器 OS 根据这个 PID,将收到的请求交给程序 A。
程序 A 产生一条响应,传回给 Bob。

Act 6

Alice 有一台服务器,上面运行着两个万维网程序,是两个网站的家。
Alice 将两个程序的 PID 公之于众。
Bob 访问其中一个网站 A,同时打开了两个标签页,发送了两条请求。

Alice 灵机一动,想到了之前的 ReqID 机制。

两条请求标有程序 A 的 PID,也分别标上了一个独特编号 ReqID。
服务器 OS 根据 PID,将收到的请求交给程序 A。
程序 A 产生一条响应,与收到的 ReqID 一同传回给 Bob。
Bob 按照两个 ReqID 将两条响应分配到两个标签页上。

Act 7

Alice 重启了其中一个程序,之前公布的 PID 列表就需要更改了。

Alice 灵机一动道:

「此后,『25』就是邮件服务的 ServID,『80』就是万维网服务的 ServID。」
「各位不需要知道这个值是不是真的 PID,只需给定 ServID,我的服务器可以完成这个映射。」

至此,一个全新的,没有「端口」的 TCP —— 诞生啦!

不过,我的朋友,最好管好你的下巴!瞧瞧 ServID、ReqID,这是什么?

这正是刚才被消灭的「端口」

Fin

TCP 的「端口」是一个软件上的概念,任何一个 TCP 包除了发送端和接收端的 IP 地址,还需要标记发送端和接收端的端口编号。

双方的两个端口之间最多只能建立一个连接,不过内核可以通过 IP 包的首部信息来区分 TCP 包和 UDP 包,故不同传输协议的端口是互不干扰的。这样一来,一个连接就由一个五元组 (protocol, server_ip, server_port, client_ip, client_port) 惟一确定。在其中任一端的机器上,此连接由一个套接字(socket)表示。

Unix 说,要有「被动套接字」

此时天降一只 Unix,把 TCP 变成了现实。

客户端视角

客户端要连接服务器,首先需要确定一个自己的端口。它一般是在特定范围内任意选取的一个大数值,称为临时端口(ephemeral port),不过这个部分是 Unix 内核完成的。在这之后找到服务器的地址和端口,就可以建立连接啦。

Unix 的 I/O 都通过文件完成,于是一个连接(套接字)也理所当然地拥有一个文件描述符(file descriptor, fd)。程序从系统获取一个文件描述符,此后所有的网络 I/O 均通过以它作为参数的相关系统调用实现。下图只是一个简单的示意,一切还是以用户手册为准哦。

(虚线表示系统调用的几个重要参数,下同)

服务端视角

服务端的端口往往是固定值,便于客户端访问。与客户端类似,它也首先获得一个描述符。

不仅如此,它还需要将这个描述符转换为监听描述符[或者说,将它对应的套接字标记为被动套接字(passive socket)],并通过它「监听」到达的请求;每当接受一个请求,OS 便会将一个新的描述符返回给程序,这个描述符代表了一个实际的连接。此后,所有的网络 I/O 便与上述一样,通过它来实现。

Why listen?

一个描述符对应一个套接字,而被动套接字和每个实际连接的套接字都是不一样的,故按照这一规范,在同一个描述符上处理所有连接是不合理的。事实上,可以认为被动套接字对应的连接是 (TCP, server_ip, server_port, *, *),而每一个 accept() 返回的描述符都对应一个具体的连接。

另外,这也是为了多线程应用考虑:每当收到一个请求,便可创建一个单独的线程,获得独立的描述符用于处理这一连接,得以同时处理多个请求。

nginx 说,要有「临时端口耗尽」

DARPA 钦定 TCP 端口只有 16 位,也就是说,只有 65536 个不同的端口。

不过不用担心啦!回想之前的五元组 —— 这意味着同一台计算机可以与互联网上的一个端口同时建立至多 65536 个连接,也即两台计算机之间至多可以建立 2322^{32}(数量级)个连接!这可比计算机资源的上限大多了,所以担心端口不够用…… 就是杞人忧天啦。

此时天降一只代理服务器:嘿嘿嘿,还是太 naïve 咯。

反向代理

我们回到 Alice 的两个网站。

Alice 只拥有一台计算机,因此两个域名需要解析到同一个 IP 地址上。通过浏览器访问两个域名时,也都会向 80 端口发送请求。

可是两个网站的技术架构迥异,没法合并成为一个程序,而两个程序只能使用不同的端口,怎么办呢?

答案是「反向代理」

反向代理的一大任务,便是观察某个公开端口上的请求数据,并将它们分发到对应的几个不同的端口(可以是本地,也可以是内网)上。如果说「端口」实现了同一个地址下的多路复用,那么「反向代理」则实现了同一个端口下的多路复用。

Alice 可以令两个网站分别监听 3000 和 4000 端口,此后在 80 端口上放置一个反向代理(如 nginx),它接受所有到达 80 端口的 HTTP 请求,并按照 HTTP 头信息中的域名,通过一个临时端口,将其转发至 3000 和 4000 两个端口中的一个。事实上,每一个请求成为了两个:由客户端发出、反向代理接受;由反向代理发出、服务程序接受。也正是因此,这些端口发出的响应会到达反向代理的临时端口,而它们最后需要由反向代理来转发给原本连接的客户端。

小实验

下面是一个 Unix/Linux 下实现的超级简单的客户端和服务端程序。客户端程序向服务端的 80 端口发送 HTTP GET / 请求,而服务端则返回一条随机选取的文字。

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>

static void _ensure(int line, _Bool cond)
{
if (!cond) {
fprintf(stderr, "From l. %d: errno %d > <\n", line, errno);
exit(1);
}
}

#define ensure(__cond) _ensure(__LINE__, __cond)

static const char REQ_STR[] =
"GET / HTTP/1.1\r\n"
"Host: kotoba.kawa.moe\r\n\r\n";

int main()
{
int sock_fd = socket(PF_INET, SOCK_STREAM, 0);
ensure(sock_fd != -1);

struct sockaddr_in addr = { 0 };
addr.sin_family = AF_INET;
addr.sin_port = htons(80);
/* Can be replaced with inet_aton() call */
addr.sin_addr.s_addr = inet_addr("66.42.69.75");

int conn_result = connect(sock_fd, (struct sockaddr *)&addr, sizeof addr);
ensure(conn_result != -1);

socklen_t addr_len = sizeof addr;
int gpn_result = getpeername(sock_fd, (struct sockaddr *)&addr, &addr_len);
ensure(gpn_result != -1);
char *peer_addr = inet_ntoa(addr.sin_addr);
fprintf(stderr, "Peer address: %s\n", peer_addr);
fprintf(stderr, "Peer port: %hu\n", ntohs(addr.sin_port));

ssize_t bytes_written = write(sock_fd, REQ_STR, sizeof REQ_STR - 1);
ensure(bytes_written != -1);
fprintf(stderr, "Bytes sent: %zd\n", bytes_written);

char resp_buf[4096];
ssize_t bytes_read = read(sock_fd, resp_buf, sizeof resp_buf);
ensure(bytes_read != -1);
fprintf(stderr, "Bytes received: %zd\n", bytes_read);

write(STDOUT_FILENO, resp_buf, bytes_read);

int close_result = close(sock_fd);
ensure(close_result != -1);

return 0;
}

服务端

(都说了是超级简单啦 > <)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>

static void _ensure(int line, _Bool cond)
{
if (!cond) {
fprintf(stderr, "From l. %d: errno %d > <\n", line, errno);
exit(1);
}
}

#define ensure(__cond) _ensure(__LINE__, __cond)

static const char RESP_FMT[] =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html\r\n"
"Content-Length: %lu\r\n"
"Connection: close\r\n"
"\r\n"
"<html><body>%s</body></html>\r\n";
static const int LEN_EXTRA = 28;

static const char *H[] = {
"This is it, Madeline.<br>Just breathe.",
/* ... */
"There must be something wrong with me.",
/* ... */
};

static void write_hitokoto(int fd)
{
char buf[65536];

ssize_t bytes_read = read(fd, buf, sizeof buf);
ensure(bytes_read != -1);
fwrite(buf, 1, bytes_read, stderr);
fputc('\n', stderr);

const char *hitokoto = H[rand() % (sizeof H / sizeof H[0])];
puts(hitokoto);
snprintf(buf, sizeof buf, RESP_FMT, strlen(hitokoto) + LEN_EXTRA, hitokoto);

ssize_t bytes_written = write(fd, buf, strlen(buf));
ensure(bytes_written != -1);
}

int main()
{
srand(6251);

int sock_fd = socket(PF_INET, SOCK_STREAM, 0);
ensure(sock_fd != -1);

struct sockaddr_in addr = { 0 };
addr.sin_family = AF_INET;
addr.sin_port = htons(6251);
addr.sin_addr.s_addr = INADDR_ANY;

int bind_result = bind(sock_fd, (struct sockaddr *)&addr, sizeof addr);
ensure(bind_result != -1);

int lsn_result = listen(sock_fd, 1024);
ensure(lsn_result != -1);

struct sockaddr_in cli_addr;
socklen_t cli_addr_len;
while (1) {
cli_addr_len = sizeof cli_addr;
int conn_fd = accept(sock_fd, (struct sockaddr *)&cli_addr, &cli_addr_len);
ensure(conn_fd != -1);
char *peer_addr = inet_ntoa(cli_addr.sin_addr);
fprintf(stderr, "Peer address: %s\n", peer_addr);
fprintf(stderr, "Peer port: %hu\n", ntohs(cli_addr.sin_port));
write_hitokoto(conn_fd);
close(conn_fd);
}

return 0;
}

天降一只 nginx

在服务器上运行服务端程序,在自己的计算机上直接用对应端口(66.42.69.75:6251)访问,观察服务器控制台的输出。

1
2
3
4
5
6
7
8
Peer address: *.*.*.* (it's you!)
Peer port: 54486

Peer address: *.*.*.* (it's you!)
Peer port: 54487

Peer address: *.*.*.* (it's you!)
Peer port: 54488

It’s you! 恭喜你,IP 暴露啦(线上运行的版本是关掉 log 的请大家放心食用……)

然后开启 nginx 反向代理,将特定域名的 HTTP 请求重定向至 6251 端口:

1
2
3
4
5
6
7
8
9
http {
server {
server_name kotoba.kawa.moe;
location / {
proxy_pass http://0.0.0.0:6251/;
}
listen 80;
}
}

现在通过域名(kotoba.kawa.moe)就可以直接访问啦。观察控制台输出:

1
2
3
4
5
6
7
8
Peer address: 127.0.0.1
Peer port: 41674

Peer address: 127.0.0.1
Peer port: 41676

Peer address: 127.0.0.1
Peer port: 41678

可以想象,41674 是 nginx 发起请求时使用的临时端口。至于为什么增量是 2?嘿嘿嘿不知道啦【逃

其实这个程序在 c9.io 上看到了更玄学的端口选择……

1
2
3
4
5
6
7
8
Peer address: 127.0.0.1
Peer port: 44494

Peer address: 127.0.0.1
Peer port: 44524

Peer address: 127.0.0.1
Peer port: 44560

所以看上去是跟正在运行的各种服务有关叭。

nginx 在这个过程中的来龙去脉,是不是越发清晰了呢?

临时端口耗尽

现在 Bob 打开了 70000 个标签页,访问 Alice 的网站。

回顾反向代理的这个过程,问题便显现了 —— 临时端口不够用!这就是所说的「临时端口耗尽」,一般在有反向代理的高并发情形下出现。

解决方法?改配置可以解决一些,加代理层数也有效果,终极招数是虚拟网络接口 —— 不过那都是后话咯。

参考

感谢下面几篇内容,很受启发~

  1. CS:APP3e 第 11 章
  2. Beej’s Guide to Network Programming Using Internet Sockets. 4 System Calls or Bust
  3. Ephemeral port exhaustion and how to avoid it
作者:Shiqing
链接:https://kawa.moe/2019/01/tcp-ports-redesigned/
版权:文章在 CC BY-NC-SA 4.0 许可证下发布。转载请注明来自 quq