跳到主要内容

追踪一次网络请求:DNS、连接、TLS 与 HTTP

网站打不开时,先看请求走到了哪一步:域名有没有解析出地址,连接有没有建立,TLS 有没有通过,服务器有没有返回 HTTP 响应。收到 404,和根本连不上端口,需要查的地方不同。一次请求还可能经过代理;浏览器连上的服务器,未必就是处理业务的程序。

先用一个假设的地址 https://api.example.com:443/health?full=1 串起这些环节。https 指定安全的 HTTP 访问,api.example.com 是主机名,443 是端口,/health 是路径,full=1 是查询参数。HTTPS 省略端口时默认使用 443,HTTP 默认使用 80;这由 URL 的协议约定决定,DNS 的 A/AAAA 记录不携带这些端口。RFC 9110 §4.2

1. 域名解析:先找到地址,再看缓存​

DNS 把域名与带类型的记录联系起来。应用通常把查询交给系统的解析接口,由配置好的递归解析器继续查询;递归解析器在本地信息不足时,沿域名树的委派找到掌握该区域数据的权威服务器。缓存命中时,一部分查询可以省掉。RFC 1034 §2.4、§5.3

地址查询中,A 记录给出 IPv4 地址;AAAA 记录给出 IPv6 地址,后者见 RFC 3596 §2。一个主机名可以对应多个地址。拿到地址后,还要实际尝试连接,才知道从当前网络能否到达它。

TTL(生存时间)以秒计,通常规定缓存记录在重新查询来源前还能使用多久。它约束缓存副本,不要求全世界在同一时刻切换地址。RFC 1034 §3.6 启用了过期数据服务(serve stale)的解析器,在无法从权威服务器刷新记录时,可以按策略继续返回过期数据。RFC 8767 §4

假设解析器在 12:00:00 缓存了旧地址,收到的 TTL 是 300 秒;12:01:00,权威服务器改成新地址,并把新记录的 TTL 调低到 60 秒。解析器没有提前刷新,也没有额外的过期数据服务策略:

时刻发生什么这个解析器的旧记录
12:00:00收到旧地址,TTL 300缓存到 12:05:00
12:01:00权威数据已改变旧副本仍按原来的 300 秒计时
12:02:00客户端再次查询已过 120 秒,还剩 300 - 120 = 180 秒,可返回旧地址
12:05:00原 TTL 到期剩余 0 秒;下一次查询需要重新取得有效数据

因此,改记录时顺便调低 TTL,无法缩短已经发出去的旧 TTL。迁移前应提前降低 TTL,并留出旧 TTL 到期的时间。不同解析器缓存的起点不同;检查时要记录查询的主机名、记录类型、使用的解析器、地址和剩余 TTL。

系统解析接口也可能利用本地配置。Python 的 socket.getaddrinfo 返回可用于连接的地址信息,不返回 DNS TTL。下方实验用它解析 localhost,观察的是本机名称解析结果,不是公网 DNS 的查询过程。DNS 缓存保存名称记录,HTTP 缓存保存响应;刷新网页也不能当作清空 DNS 缓存。HTTP 缓存模型,RFC 9111

2. 地址、端口、监听器与连接​

IP 地址用于把数据送往网络端点,端口帮助操作系统区分服务。TCP 服务器先绑定地址和端口,再监听新连接;绑定 127.0.0.1 只接收本机 IPv4 回环路径上的连接,绑定 0.0.0.0 则表示本机所有 IPv4 接口。后者是监听用的通配地址,客户端应使用实际可达的地址。Linux 的 IPv4 套接字文档 能否从外部访问,还取决于路由、防火墙和端口映射。

TCP 用一对端点标识连接,每个端点包含地址和端口。例如,假设客户端从 192.0.2.20:53000 连到 203.0.113.10:443:53000 是这次连接的客户端端口,443 是服务端口。另一个客户端可以访问同一个 443,操作系统仍能区分它们。TCP 提供可靠、有序的字节流,用确认和重传处理传输中的丢失;普通建连过程经过 SYN、SYN-ACK、ACK。RFC 9293 §2.2、§3.5

连接建立说明这条传输路径已经可用。TCP 确认收到字节,仍不能证明应用已解析请求、检查权限或提交数据库事务。若提交订单后连接断了,客户端可能不知道订单到底有没有创建。对非幂等请求,不应自动重试,除非能确定其实际语义可安全重复,或能确认原请求未被应用;幂等指重复请求的预期效果与执行一次相同。RFC 9110 §9.2.2

这条 TCP 路径适用于这里的 HTTP/1.1 实验和常见的 HTTPS over TCP。HTTP/3 使用 QUIC,走 UDP,并结合 TLS 握手;诊断 HTTP/3 时,要检查 UDP 路径,不能只凭 TCP 443 可达就认定传输正常。RFC 9114 §3

3. TLS:确认对端身份,再保护传输​

对使用证书的新 HTTPS 连接,TLS 握手协商密钥,服务器出示证书,并证明自己持有对应的私钥。TLS 1.3 的目标包括身份认证、保密性和完整性:建立后的通道数据只能由两端读取,篡改可以被检测到;数据长度仍可能暴露。RFC 8446 §1、§4.4.3

客户端需要分开检查两件事:一是证书能否通过信任链、有效期等校验(RFC 5280 §6.1);二是证书是否代表它原本打算访问的主机。对于 api.example.com,主机名匹配使用证书 subjectAltName 中的 DNS 名称;不能拿服务器随意提供的名称替换预期名称。直接使用 IP 地址访问时,需要相应的 IP 地址标识,不能仅凭同一台机器上的域名证书通过校验。RFC 9525 §6

SNI(Server Name Indication)让客户端在 TLS 握手中告诉服务器自己要访问哪个主机,服务器可据此选择证书;它与随后 HTTP 请求中的 Host 处于不同阶段。RFC 6066 §3 改用 IP 地址测试时,即使连接目的地相同,也可能改变 SNI、证书匹配和 HTTP 路由,所以要逐项比较。

TLS 保护的是两个 TLS 端点之间的通道。若反向代理终止 TLS,它就能读取 HTTP 内容;代理到后端的连接需要单独判断是否加密。证书通过校验之后,应用仍要检查用户凭据和业务权限。DNS 结果或已建立的连接可以复用,不必每次 HTTP 请求都重新解析域名、建立连接和进行完整 TLS 握手。

4. HTTP:方法、状态码、头部和正文​

HTTP 请求带有方法和目标,响应带有状态码;双方都可以携带头部和正文。HTTP/1.1 的文本行,以及 HTTP/2、HTTP/3 的帧,是不同的表达方式,共用这些基本语义。RFC 9110 §6

方法请求的含义
GET取得资源当前选定的表示,例如 JSON 数据
HEAD与 GET 类似,但响应不传正文
POST让资源按自身规则处理提交的内容,例如创建订单
PUT用提交的表示创建或替换目标资源的状态
DELETE移除目标资源与当前功能的关联;不保证底层存储被物理擦除

这些含义见 RFC 9110 §9.3。方法和路径要一起看:某个路径可能接受 POST,却拒绝 GET。

头部补充请求和响应的含义。Host(HTTP/2、HTTP/3 中常用 :authority)标识目标主机及可选端口;Content-Type 说明正文的媒体类型;Content-Length 按字节而非字符计长度,在下方 HTTP/1.1 响应中也帮助客户端判断正文边界;Authorization 携带认证凭据。声明 JSON 媒体类型,仍需要正文符合接口要求。RFC 9110 §7.2、§8.3、§8.6、§11.6.2

状态码的首位区分 1xx 信息性响应、2xx 成功、3xx 重定向、4xx 客户端错误、5xx 服务端错误。但诊断时需要具体代码和正文:RFC 9110 §15。

响应能读出什么
200请求按 HTTP 语义成功;仍要检查正文是否是预期结果
202已接受处理,但尚未完成;最终是否执行、能否成功都不确定
401缺少有效认证凭据;响应必须包含 WWW-Authenticate 挑战
403理解请求但拒绝执行;原因不一定是凭据问题
404找不到当前表示,或服务器不愿透露资源存在
405服务器认识这个方法,但目标资源不支持;响应必须用 Allow 列出目标当前支持的方法
500服务器遇到意外情况,无法完成请求
502 / 504网关收到无效的上游响应 / 未及时收到所需的上游响应

即使收到了响应头,也要检查正文是否完整。响应写到一半时连接断开,或 JSON 无法解析,都是后续的失败;状态码和业务结果不能互相替代。

5. 代理与 localhost:站在哪一端看地址​

正向代理代表客户端发送请求;反向代理(网关)面向客户端接收请求,再转给后端。RFC 9110 §3.7 假设公网入口终止 HTTPS,再用 HTTP 连接后端,请求有两段独立的连接:

一段连接目的地由谁验证什么
浏览器到入口公网地址,443浏览器验证入口为请求的域名提供的证书
入口到应用后端地址,8080入口检查后端响应;这一段采用 HTTP,没有 TLS 加密

浏览器收到入口返回的 502,只能说明到入口这一段已经拿到了 HTTP 响应,后端仍可能故障。定位时要区分“客户端连入口”和“入口连应用”,并结合入口日志确认哪一段失败。

localhost 指向发起连接的环境自己的回环地址,常见的是 IPv4 127.0.0.1 和 IPv6 ::1;名称解析库应把它作为特殊名称处理,通常不向公网 DNS 查询。RFC 6761 §6.3 笔记本浏览器访问 localhost:8080,访问的是笔记本上的服务,不是远程 VPS。

容器中还要确认网络命名空间,也就是进程看到的那套网络接口与路由。容器使用独立网络命名空间时,127.0.0.1 指向容器自身;共享宿主机或另一容器网络时,含义随共享的网络栈改变。Docker 网络文档 因此,反向代理与应用分处独立容器时,代理的 localhost 不会自动指向应用容器,应使用它能访问的后端地址或服务名。

需要实际配置时,继续读 VPS 总览和架构与安全。托管运行时怎样接收请求和返回响应,见 Cloudflare Workers;使用 HTTP 传输时怎样交换工具消息与处理授权,见 MCP。

6. 本地实验:连接正常,也能得到 404​

把下面代码保存为 request_trace.py,用 python3 request_trace.py 运行。它只依赖 Python 标准库,在 IPv4 回环地址启动一个临时 HTTP 服务,再发出两个 GET 请求,最后尝试连接一个已绑定但未监听的端口。服务端口设为 0,让操作系统选择空闲端口;每次运行的端口数字可能不同。

服务端使用 http.server,客户端使用 http.client。实验走明文 HTTP,展示本机解析、TCP 和 HTTP;TLS 的证书与握手对应前面的 HTTPS 路径。

这个处理器只实现 GET:路径为 /health 时返回 200,其他路径返回 404。BaseHTTPRequestHandler.path 包含查询字符串,因此用 urlsplit 分离路径和查询;这个实验忽略查询参数,/health?full=1 也返回相同的健康检查结果。

import socket
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from urllib.parse import urlsplit


class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"

def do_GET(self):
if urlsplit(self.path).path == "/health":
status, body = 200, b'{"ok":true}\n'
else:
status, body = 404, b'{"error":"not_found"}\n'
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)

def log_message(self, format, *args):
pass


server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
port = server.server_address[1]
worker = Thread(target=server.serve_forever, daemon=True)
worker.start()
conn = HTTPConnection("localhost", port, timeout=2)
try:
results = socket.getaddrinfo(
"localhost", port, socket.AF_INET, socket.SOCK_STREAM
)
print("resolve localhost (IPv4):", sorted({r[4][0] for r in results}))
print(f"listen: 127.0.0.1:{port}")
conn.connect()
print(f"TCP: {conn.sock.getsockname()} -> {conn.sock.getpeername()}")
for path in ("/health", "/missing"):
conn.request("GET", path, headers={"Host": f"localhost:{port}"})
response = conn.getresponse()
body = response.read()
print(f"GET {path} -> {response.status}; "
f"type={response.getheader('Content-Type')}; "
f"length={response.getheader('Content-Length')}; read={len(body)}")
print(body.decode("utf-8"), end="")
finally:
conn.close()
server.shutdown()
server.server_close()
worker.join()

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as unused:
unused.bind(("127.0.0.1", 0))
try:
with socket.create_connection(unused.getsockname(), timeout=2):
print("unexpected connection")
except (ConnectionRefusedError, TimeoutError) as error:
print("bound port without listen:", type(error).__name__)

一次在 Python 3.13.15 上运行得到的输出:

resolve localhost (IPv4): ['127.0.0.1']
listen: 127.0.0.1:64242
TCP: ('127.0.0.1', 64244) -> ('127.0.0.1', 64242)
GET /health -> 200; type=application/json; length=12; read=12
{"ok":true}
GET /missing -> 404; type=application/json; length=22; read=22
{"error":"not_found"}
bound port without listen: TimeoutError

64242 是服务器的监听端口,64244 是客户端这次连接的源端口。两个请求复用了这条连接;请求都没有正文,响应正文分别是 12 和 22 个 UTF-8 字节,都含结尾的换行。length 是服务器声明的长度,read 是客户端实际读到的长度。/missing 的 404 表明 HTTP 已经正常交换,应该检查资源路径,而不是把它当作连接失败。

最后一步在这次运行中超时,其他系统也可能立即拒绝连接;两种情况都没有 HTTP 状态码。这里已知测试端口没有监听器,才能确定原因。在真实网络中,仅凭超时无法区分监听器、防火墙或路由问题。

7. 按最后成功的一层排查​

先记录发起请求的位置、URL、实际连接的对端、最后成功的阶段和错误原文。用同一个客户端逐项检查,避免把笔记本、容器和服务器上的结果混成一条路径。

观察到的现象下一步查什么
名称解析失败,或地址与预期不同主机名拼写、A/AAAA、当前解析器、本地配置与缓存;还未证明目标端口可达
TCP 建连被拒绝地址与端口是否正确、是否有监听器、是否存在主动拒绝规则
建连超时路由、丢包、防火墙、监听地址;只查当前连接的那一段
连接建立后 TLS 失败是否连到了 TLS 服务、证书链、有效期、本机时间、预期主机名与 SNI
收到 401、403、404、405认证、权限、主机路由、路径与方法;先看响应头和正文
收到 502 或 504谁生成了响应,该代理访问哪个后端,以及后端连接和处理是否正常
状态码正常,但正文不完整或业务结果错误消息边界、正文解析、接口约定和业务日志;涉及写操作时先确认是否已经执行

拿到 200 的健康检查,只能证明被检查的路径工作。如果实际任务是创建订单,还要验证订单接口的认证、输入和持久化结果。每一步都应回答一个具体问题,再继续检查后面的层。

探索关联打开关联网络