localhost 背后:一趟没有出门的网络旅行
## 从一个熟悉的地址说起在终端里敲下 npm run devVite 高高兴兴告诉你服务跑在 localhost:5173浏览器一点页面出来了。或者调 Redis 时来一句 redis-cli -h 127.0.0.1 -p 6379连的也是本机。这个场景我们再熟悉不过了不过最近复习 408 的计网学习到了网络分层。于是一个很自然、也很容易被忽略的问题冒出来了**既然我连的是“自己”那它还算网络通信吗是不是根本没走网络协议栈走的话又走了多少层**通过简单的调查得到的答案有点反直觉算而且走了不少。于是想写下这篇文章打算自顶向下的记录下 localhost 的背后流程。简单来说它不像访问外网那样经过网卡、交换机、路由器和网线但在操作系统内部应用层、传输层、网络层这几层依然认真上班。以前以为 localhost 是“程序直接找程序聊天”但其实更像一份没有出小区的快递没有上高速但面单、分拣、签收流程都还在。先说明一下localhost 是一个名字不是一个 IP。它通常会解析到 IPv4 的 127.0.0.1有些系统也可能优先解析到 IPv6 的 ::1。下面为了讲清楚路径我们默认讨论 127.0.0.1 这条 IPv4 回环路径。## 前言网络分层在一切开始前需要先来复习下 408 中的网络分层的知识后面也好依据这个进行分析。 小故事环节 网络分层如上图所示OSI Open System Interconnection是国际标准化组织ISO 在 1977 年成立了一个专门委员会开始研究网络互联的标准框架。他们希望制定一套通用的、开放的网络分层模型让任何遵循该标准的系统都能互相通信。并在 1984 年正式发布的参考模型。其有着最标准的分层不管什么网络都可以套用进来 TCP/IP 的故事要从美国国防部高级研究计划局ARPA说起。 - 1969 年ARPANET阿帕网诞生这是现代互联网的前身。它的最初目的是将大学和研究机构的计算机连接起来实现资源共享。 - 1974 年文特·瑟夫Vint Cerf和鲍勃·卡恩Bob Kahn发表了关于 TCP 协议的论文首次提出了“网关”后来演变为路由器的概念解决了不同网络之间的互联问题。 - 1983 年 1 月 1 日ARPANET 正式将其核心协议从 NCP 切换到 TCP/IP这一天被视为现代互联网的“生日”。 - 随后伯克利大学BSD Unix将 TCP/IP 的实现免费集成到 Unix 系统中随着 Unix 在大学和科研机构的普及TCP/IP 迅速传播开来。 由于 TCP/IP 的标准更简单高效实现成本也更低自然的取代了 OSI 的 方法成为当今主流的网络分层方法。后面我们也将使用 TCP/IP 层进行具体的展开。在继续之前先用几句话把 TCP/IP 五层都在干什么捋明白- 应用层负责“说什么”比如 HTTP、Redis、MySQL 协议。- 传输层负责“交给哪个进程、怎么传”TCP/UDP 都在这里端口号也是这一层的概念。- 网络层负责“去哪个地址”IP 就在这里所以 127.0.0.1 是网络层地址。- 链路层负责同一链路上的传输比如以太网帧、MAC 地址这些通常在这里出现。- 物理层负责把比特变成电信号、光信号或无线信号真的让数据在硬件介质里跑起来。基于上述描述我们可以先来分析localhost:8080 里的 8080 是端口号属于传输层127.0.0.1 是 IP 地址属于网络层。很多网络问题一旦把这两个概念分清脑子里就不会糊成一团。## TLDR!-- table-widths: 16%, 56%, 28% --| 层级 | 做了什么 | 关键点 || ---------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- || **应用层** | 浏览器或客户端发起 HTTP 请求目标地址为127.0.0.1:8080 | 这是数据的起点 || **传输层** | 操作系统根据端口号8080 找到对应的服务进程比如你的 Web 服务器并封装 TCP 段 | 端口号在这一层起作用 || **网络层** | 看到目标 IP 是127.0.0.1操作系统识别出这是一个**回环地址**不走物理网卡直接交给回环接口loopback interface处理 | 这是最关键的一步——数据压根没离开本机 || **链路层** | 回环接口模拟了一个虚拟的链路层把数据假装从网络发回来实际上只是在内存里打了个转 | 没有真实的 MAC 地址交互 || **物理层** | **跳过**。因为没有真实硬件参与数据不会变成电信号或光信号 | 这也是 localhost 比外网快得多的根本原因 |**所以最后的 localhost其实走完了大部分的流程只是没有走物理层。**## 应用层 —— 不经过 DNS 解析我们先从最上层开始看看 curl localhost:6370 的第一步做了什么。实验对比外网 vs localhost我在终端中分别执行了两条命令访问外网域名和自己的本地服务Shellcurl -v http://markxu.icucurl -v localhost:6370并通过 tcpdump 进行抓包Shellsudo tcpdump -i any port 53可以看到在运行访问外网域名的时候终端有相关字样Shell12:26:00.381970 IP 198.18.0.1.56714 public1.114dns.com.domain: 53233 A? markxu.icu. (28)12:26:00.382370 IP public1.114dns.com.domain 198.18.0.1.56714: 53233* 1/0/0 A 198.18.0.172 (44)12:26:00.387550 IP 192.168.20.80.49570 pdns.dnspod.cn.domain: 13415 AAAA? markxu.icu. (28)12:26:00.387656 IP 192.168.20.80.63675 public1.alidns.com.domain: 13415 AAAA? markxu.icu. (28)12:26:00.387763 IP 192.168.20.80.52301 public1.alidns.com.domain: 41468 A? markxu.icu. (28)12:26:00.388014 IP 192.168.20.80.56763 pdns.dnspod.cn.domain: 41468 A? markxu.icu. (28)而在运行访问本地服务的时候什么都没有出现这是因为 localhost 不是一个普通域名。根据 IETF 标准RFC 6761它被保留为特殊主机名。操作系统在处理 localhost 时不会发起 DNS 查询而是直接从 /etc/hosts 文件中读取映射关系你可以自己验证这一点Shellcat /etc/hosts | grep localhost### 应用层还做了什么解析完成后curl 拿到了目标地址 127.0.0.1:6370。接下来它构造 HTTP 请求报文ShellGET / HTTP/1.1Host: localhost:6370User-Agent: curl/8.7.1Accept: */*这段报文和访问外网时构造的报文在格式上没有任何区别。Host 头写的是 localhost:6370但这只是内容不同协议行为完全一致。构造完成后curl 将这个报文作为数据载荷写入与 127.0.0.1:6370 关联的 TCP 套接字。至此应用层的工作结束数据交给下一层处理。## 传输层 —— TCP 仍然会握手应用层把 HTTP 报文写进 TCP 套接字后接力棒交给了传输层。这一步与我一开始想的有所不同**本机通信也需要 TCP 三次握手。**TCP 是一个面向连接的协议无论通信双方是两台远程机器还是同一台机器的两个进程它都会完整执行三次握手客户端发 SYN服务端回 SYN-ACK客户端再回 ACK。连接建立后才开始传输 HTTP 报文数据。唯一的区别在于这个握手过程不会经过任何物理线路而是在操作系统内核内部通过回环接口 lo瞬间完成。RTT 趋近于零不会触发重传也不会经历拥塞控制——但协议状态机、序列号、确认号、端口号一个都没少。我们可以用 tcpdump来验证这一点。打开一个终端监听回环接口上的 6370 端口Shellsudo tcpdump -i lo0 port 6370然后在去运行下 curl localhost 的命令可以得到下面的恢复Shelltcpdump: verbose output suppressed, use -v[v]... for full protocol decodelistening on lo0, link-type NULL (BSD loopback), snapshot length 524288 bytes14:17:36.505024 IP6 localhost.62464 localhost.6370: Flags [S], seq 3529818479, win 65535, options [mss 16324,nop,wscale 6,nop,nop,TS val 2294771787 ecr 0,sackOK,eol], length 014:17:36.505080 IP6 localhost.6370 localhost.62464: Flags [R.], seq 0, ack 3529818480, win 0, length 014:17:36.505206 IP localhost.62465 localhost.6370: Flags [S], seq 1917119642, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 3300697293 ecr 0,sackOK,eol], length 014:17:36.505344 IP localhost.6370 localhost.62465: Flags [S.], seq 4137660183, ack 1917119643, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 4162405884 ecr 3300697293,sackOK,eol], length 014:17:36.505365 IP localhost.62465 localhost.6370: Flags [.], ack 1, win 6380, options [nop,nop,TS val 3300697293 ecr 4162405884], length 014:17:36.505379 IP localhost.6370 localhost.62465: Flags [.], ack 1, win 6380, options [nop,nop,TS val 4162405884 ecr 3300697293], length 014:17:36.505607 IP localhost.62465 localhost.6370: Flags [P.], seq 1:78, ack 1, win 6380, options [nop,nop,TS val 3300697293 ecr 4162405884], length 7714:17:36.505631 IP localhost.6370 localhost.62465: Flags [.], ack 78, win 6379, options [nop,nop,TS val 4162405884 ecr 3300697293], length 014:17:36.514104 IP localhost.6370 localhost.62465: Flags [P.], seq 1:1448, ack 78, win 6379, options [nop,nop,TS val 4162405893 ecr 3300697293], length 144714:17:36.514168 IP localhost.62465 localhost.6370: Flags [.], ack 1448, win 6358, options [nop,nop,TS val 3300697302 ecr 4162405893], length 014:17:36.514408 IP localhost.62465 localhost.6370: Flags [F.], seq 78, ack 1448, win 6358, options [nop,nop,TS val 3300697302 ecr 4162405893], length 014:17:36.514434 IP localhost.6370 localhost.62465: Flags [.], ack 79, win 6379, options [nop,nop,TS val 4162405893 ecr 3300697302], length 014:17:36.514744 IP localhost.6370 localhost.62465: Flags [F.], seq 1448, ack 79, win 6379, options [nop,nop,TS val 4162405894 ecr 3300697302], length 014:17:36.514791 IP localhost.62465 localhost.6370: Flags [.], ack 1449, win 6358, options [nop,nop,TS val 3300697303 ecr 4162405894], length 0^C14 packets captured24 packets received by filter0 packets dropped by kernel我们可以逐行来插接下这个信息里面包含了什么。**请先把上面的代码块调整为横向滚动模式方便对于行号**。### 第二行IPv6 尝试被拒绝Shell14:17:36.505024 IP6 localhost.62464 localhost.6370: Flags [S], seq 3529818479, win 65535, options [mss 16324,nop,wscale 6,nop,nop,TS val 2294771787 ecr 0,sackOK,eol], length 014:17:36.505080 IP6 localhost.6370 localhost.62464: Flags [R.], seq 0, ack 3529818480, win 0, length 0因为服务端端口 6370没有监听 IPv6直接回了 [R.]这说明我的服务只绑定了 127.0.0.1IPv4没有绑定 ::1IPv6### 第三行TCP 三次握手IPv4Shell14:17:36.505206 IP localhost.62465 localhost.6370: Flags [S], seq 1917119642, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 3300697293 ecr 0,sackOK,eol], length 014:17:36.505344 IP localhost.6370 localhost.62465: Flags [S.], seq 4137660183, ack 1917119643, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 4162405884 ecr 3300697293,sackOK,eol], length 014:17:36.505365 IP localhost.62465 localhost.6370: Flags [.], ack 1, win 6380, options [nop,nop,TS val 3300697293 ecr 4162405884], length 0- curl 改用 IPv4127.0.0.1源端口变为 62465- [S] SYN客户端发起连接- [S.] SYN-ACK服务端同意连接- [.] ACK客户端确认注意时间戳三行都是 14:17:36.505同一毫秒内完成中间是 HTTP 的请求和相应### 第六行TCP 的四次挥手Shell14:17:36.514408 IP localhost.62465 localhost.6370: Flags [F.], seq 78, ack 1448, win 6358, options [nop,nop,TS val 3300697302 ecr 4162405893], length 014:17:36.514434 IP localhost.6370 localhost.62465: Flags [.], ack 79, win 6379, options [nop,nop,TS val 4162405893 ecr 3300697302], length 014:17:36.514744 IP localhost.6370 localhost.62465: Flags [F.], seq 1448, ack 79, win 6379, options [nop,nop,TS val 4162405894 ecr 3300697302], length 014:17:36.514791 IP localhost.62465 localhost.6370: Flags [.], ack 1449, win 6358, options [nop,nop,TS val 3300697303 ecr 4162405894], length 0可以看到时间戳速度也很快。## 网络层 —— 路由让它留在本机应用层把 HTTP 报文交给 TCP 套接字传输层封装好 TCP 段后数据进入网络层。这里要做两件事封装 IP 首部以及查路由表决定“这个包往哪送”。我们可以通过Shellroute -n get 127.0.0.1的方式去查看我们的 localhost 在网络层是什么样的- 127.0.0.1的路由目的地就是它自己接口是 lo0flags 包含 LOCAL- 外网 IP 的路由目的地是对应的网段这里的接口不是物理网卡 en0而是 utun1024是因为我开启了代理服务flags 包含 GATEWAY同时注意到的一点可以看到我们的 MTUMaximum Transmission Unit最大传输单元在本地上的上限是 macos 设定的16384而在连接外网的上面是标准的以太网的上限 1500。## 链路层 —— 虚拟的假装传输网络层做完路由决策后数据包被标记为发往本地。按照正常的网络流程下一步应该是链路层的工作封装以太网帧、填充目标 MAC 地址然后交给物理层发送。但对于 127.0.0.1事情在这里变得特殊了。### 没有真正的 MAC 地址交互我们可以用 arp命令来验证这一点Shellarp -a | grep 127.0.0.1你会发现没有任何输出。因为对于回环地址操作系统根本不会发起 ARP 请求来询问谁的 IP 是 127.0.0.1把你的 MAC 地址告诉我。相比之下如果你查局域网内的其他设备Shellarp -a | grep 192.168### 回环接口在做什么操作系统内部有一个特殊的虚拟网络接口叫做 loopback interface在 macOS/Linux 上通常是 lo0。你可以用以下命令看到它的存在Shellifconfig lo0关键信息- LOOPBACK标明这是一个回环接口- inet 127.0.0.1它绑定了这个 IP- mtu 16384最大传输单元比以太网的 1500 大得多网络层决定把数据包发给 127.0.0.1时它实际上做的事情是把数据包交给 lo0这个虚拟接口lo0不会去找网卡驱动而是直接把数据包镜像一份放回操作系统的网络协议栈的接收队列。从接收端的角度看就像是从网络上收到了一个数据包。这个过程在操作系统内部被称为 loopback回环本质上是一次内存拷贝没有任何硬件参与。### 链路层还存在吗严格来说链路层的职责是在同一链路上传输数据帧。对于回环接口操作系统模拟了一个极简的链路层它会封装一个假的链路层头部在 macOS 上是 NULL类型Linux 上是 LOCALBACK但它不会添加真实的 MAC 地址也不会进行 CSMA/CD载波侦听多点接入/碰撞检测所以也不会有任何实际的帧传输延迟。### 物理层呢直接跳过了不用写这部分真是太好了(^∇^)## 所以答案是localhost 走了完整的 TCP/IP 五层中的四层唯独跳过了物理层。它不是程序直接找程序聊天这种简单的概念而是依然经历了协议封装、端口寻址、路由决策、接口调度这一整套流程。只不过所有这些都发生在一台机器内部像是一份永远不出小区门的快递面单照贴、分拣照做、签收照办只是快递员从来没上过马路。