跳到内容

12.3 可重复的网络诊断实验

这一课只在本机回环接口上制造故障。目标不是记住某台机器的输出,而是练习如何定义阶段、设置 deadline、保留证据并写出有限结论。

实验边界

  • 只使用 127.0.0.1 和本机临时端口;
  • 不扫描公网或未经授权的主机;
  • 不修改防火墙、路由或系统 DNS;
  • 不使用 curl -k 掩盖证书错误;
  • 每个后台进程都记录 PID 并在结束时停止。

不同系统命令选项可能不同。下文 shell 以 Linux/bash 为例,Python 需要 3.11 或兼容版本。

实验一:区分拒绝、连接成功与响应超时

创建 stall_server.py

python
import socket
import time


with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
    listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listener.bind(("127.0.0.1", 0))
    listener.listen()
    host, port = listener.getsockname()
    print(port, flush=True)

    connection, peer = listener.accept()
    with connection:
        print(f"accepted={peer}", flush=True)
        time.sleep(10)

运行它并读取操作系统分配的端口:

bash
python3 stall_server.py >stall.log 2>&1 &
server_pid=$!

for _ in {1..50}; do
  server_port=$(sed -n '1p' stall.log)
  test -n "$server_port" && break
  sleep 0.1
done
test -n "$server_port"

先验证监听:

bash
ss -lnt "sport = :$server_port"

再请求这个“接受连接但不返回 HTTP”的服务:

bash
curl --verbose --connect-timeout 1 --max-time 2 \
  "http://127.0.0.1:$server_port/"

预期:TCP 很快连接成功,HTTP 响应在总 deadline 内没有到达。这里的 timeout 发生在连接之后。

停止服务,再请求同一端口:

bash
kill "$server_pid"
wait "$server_pid" 2>/dev/null || true

curl --verbose --connect-timeout 1 --max-time 2 \
  "http://127.0.0.1:$server_port/"

在普通本机 TCP 栈中,未监听端口通常很快返回 Connection refused。它与前一次“已连接、等不到响应”的阶段不同。

这个实验没有模拟 SYN 被静默丢弃。连接超时依赖路由、防火墙和重传计时,不能安全地用任意公网地址稳定复现。

实验二:用抓包验证错误阶段

终端 A 在回环接口采集,只抓该临时端口:

bash
sudo tcpdump -i lo -nn -s 0 "tcp port $server_port" -c 20 -w loopback-lab.pcap

终端 B 分别执行“服务运行时请求”和“服务停止后请求”。然后读取摘要:

bash
tcpdump -nn -r loopback-lab.pcap

回答这些问题:

  1. 两次请求是否都发出了 SYN?
  2. 哪一次完成了三次握手?
  3. 拒绝场景中是否出现 RST?
  4. 响应超时场景中,客户端是否发送了 HTTP 请求字节?
  5. FIN 或 RST 由哪一端发出?这与 curl 的 deadline 有何关系?

回环接口上的包形态和普通以太网接口不完全相同,但 TCP 状态转换仍可用于本实验。

实验三:测量一个本地 HTTP 请求

启动标准库 HTTP 服务:

bash
python3 -m http.server 0 --bind 127.0.0.1 >http.log 2>&1 &
http_pid=$!

不同 Python 版本的日志格式并不适合作为稳定的端口发现接口。为了得到可重复实验,可以改用下面的小服务输出端口:

python
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler


server = ThreadingHTTPServer(("127.0.0.1", 0), SimpleHTTPRequestHandler)
print(server.server_port, flush=True)
server.serve_forever()

将其保存为 http_server.py 后运行:

bash
kill "$http_pid" 2>/dev/null || true
wait "$http_pid" 2>/dev/null || true

python3 http_server.py >http.log 2>&1 &
http_pid=$!
for _ in {1..50}; do
  http_port=$(sed -n '1p' http.log)
  test -n "$http_port" && break
  sleep 0.1
done
test -n "$http_port"

执行五次请求:

bash
for run in {1..5}; do
  curl --silent --show-error --output /dev/null \
    --write-out "run=$run code=%{http_code} connect=%{time_connect} starttransfer=%{time_starttransfer} total=%{time_total}\n" \
    "http://127.0.0.1:$http_port/"
done

在这个本地 HTTP/1.x 实验里,DNS 与 TLS 不参与。即使如此,首次请求、文件系统缓存、调度和服务日志也会带来波动。不要用五个样本宣称某项优化普遍有效;本实验只训练测量字段和阶段解释。

结束时清理:

bash
kill "$http_pid"
wait "$http_pid" 2>/dev/null || true

一个带 deadline 的 Python 诊断器

下面的程序分别记录 DNS、TCP 和 TLS。它不是监控系统,但比一个没有 timeout 的 socket.connect() 更适合作为教学骨架。

python
from __future__ import annotations

import socket
import ssl
import time
from dataclasses import dataclass


@dataclass(frozen=True)
class Stage:
    name: str
    milliseconds: float
    detail: str


def diagnose(host: str, port: int = 443, timeout: float = 3.0) -> list[Stage]:
    stages: list[Stage] = []

    started = time.monotonic()
    addresses = socket.getaddrinfo(
        host,
        port,
        type=socket.SOCK_STREAM,
        proto=socket.IPPROTO_TCP,
    )
    stages.append(Stage(
        "dns",
        (time.monotonic() - started) * 1000,
        f"answers={len(addresses)}",
    ))

    family, socktype, proto, _, sockaddr = addresses[0]
    started = time.monotonic()
    raw = socket.socket(family, socktype, proto)
    raw.settimeout(timeout)
    try:
        raw.connect(sockaddr)
        stages.append(Stage(
            "tcp",
            (time.monotonic() - started) * 1000,
            f"peer={sockaddr}",
        ))

        context = ssl.create_default_context()
        started = time.monotonic()
        with context.wrap_socket(raw, server_hostname=host) as tls:
            stages.append(Stage(
                "tls",
                (time.monotonic() - started) * 1000,
                f"version={tls.version()} alpn={tls.selected_alpn_protocol()}",
            ))
    except BaseException:
        raw.close()
        raise

    return stages


if __name__ == "__main__":
    for stage in diagnose("example.com"):
        print(stage)

它仍有明确限制:

  • 只尝试 getaddrinfo() 返回的第一个地址,没有实现 Happy Eyeballs;
  • DNS 调用本身没有独立的 Python socket deadline;
  • 没有发送 HTTP 请求;
  • 一次样本不能代表长期性能;
  • 异常输出需要在真实工具中进一步结构化。

教学程序应主动说明这些边界,而不是因为“运行成功”就把它包装成生产级探针。

关于端口扫描

nmap 对资产清点和故障核查很有用,也可能触发告警、违反授权范围或给脆弱服务造成压力。只扫描明确归你管理或书面授权的目标,并约定源地址、目标网段、端口范围、时间窗和速率。

即使扫描结果写着 openclosedfiltered,它也是基于探测响应的分类,不是应用健康证明。生产服务还需用协议级请求、身份校验和服务端证据确认。

验收清单

  • [ ] 能把 connect timeout 与 response timeout 分开;
  • [ ] 能用 ss 证明本地监听地址,而不是笼统说“端口开了”;
  • [ ] 能从本地 pcap 指出 SYN、SYN-ACK、ACK、RST/FIN;
  • [ ] 能说明一次抓包的接口、namespace、时间窗和过滤器;
  • [ ] 能解释 curl 各时间点是累计值;
  • [ ] 能为每个结论写出至少一个尚未排除的替代解释。

本章总结

第 12 章建立了一条完整诊断链:先定义症状与阶段,再用名称解析、路由、传输、TLS 和 HTTP 证据逐层收敛;需要深入时,在正确观测点做有界抓包;最后用本地实验验证自己是否真的能区分错误阶段。

下一章进入 IPv6。届时同样的诊断思路仍然适用,但地址选择、邻居发现、ICMPv6 和双栈回退会带来新的变量。

Built with VitePress | Software Systems Atlas