☰
DNS解析成功,网页为什么还是打不开?用四个真实结果拆开DNS、TCP和HTTP
2026/10/7 2:47:36 网站建设 项目流程

DNS解析成功,网页为什么还是打不开?用四个真实结果拆开DNS、TCP和HTTP

遇到网页打不开,常见建议是换 DNS。

但如果 DNS 已经给出了地址,问题还一定在 DNS 吗?反过来,一个网站返回 403,能不能称为“网络不通”?

我们用自建的隔离服务做四个对照。域名和 IP 都一样,改变端口或请求路径,观察不同层次的结果。

1. 先把“打开网页”拆成几个问题

这篇用明文 HTTP/1.x over TCP,把路径缩小到最容易观察的几个阶段:

域名解析:拿到哪个地址? TCP连接:能否连接这个地址的指定端口? HTTP响应:对方怎样回答这次请求? 内容检查:回答是否符合我的预期?

这不是所有现代网页的完整流程图。HTTPS还有TLS,HTTP/3使用QUIC,浏览器还可能使用代理、缓存或不同地址族。这里先把这些变量排除,不把一个小实验冒充完整浏览器诊断。

每一步回答不同的问题,上一层成功不保证下一层成功。

2. 一张只属于实验的网络

WSL 的 Ubuntu-24.04 中,两个新建的网络命名空间通过 veth 连接:

节点地址服务
客户端C192.0.2.20/24查询与请求程序
服务端S192.0.2.1/24dnsmasq本地DNS、Python HTTP服务

服务端把service.test的 A 记录设为192.0.2.1。HTTP只监听8080;8081没有监听程序。它没有互联网或家庭网络上联。

HTTP服务使用 Python 3.12.3 的标准库HTTPServer与BaseHTTPRequestHandler,只定义几个固定响应,没有自己实现HTTP解析器,也没有对外共享文件目录。官方说明该模块不适合生产部署,这里只用于隔离教学实验。Python http.server文档

3. DNS成功,精确地证明了什么?

在客户端命名空间内,指定本地DNS查询:

nslookup -type=A service.test 192.0.2.1 Name: service.test Address: 192.0.2.1

这是本次工具真实输出中的关键行。验证程序从这个答案提取地址,再用于后续连接,不是无视查询结果另写一个目标。

它证明:这次向指定DNS发起的A查询,得到了预期地址。

它不证明:

  • 该IP的8080或8081端口能连接。
  • HTTP服务接受某条路径。
  • HTTPS证书和TLS握手正常。
  • 浏览器使用的也是这个DNS、这条答案或这个地址族。

DNS答案不包含普通网页URL里的路径;这次8080/8081也由请求配置指定,不能从相同A记录推出所有端口行为相同。

4. 四个请求,四种不同结果

实际结果如下:

请求DNS A答案TCPHTTP状态响应体/预期
端口8081192.0.2.1连接被拒绝没有HTTP响应未进入HTTP阶段
8080的/同上成功200lab-ok,符合约定
8080的/private同上成功403lab-denied,实验主动拒绝
8080的/wrong-result同上成功200lab-error,不符合约定

连接被拒绝:不要继续分析网页内容

访问8081时,Python得到ConnectionRefusedError。这个隔离服务端没有监听8081,因此这次没有拿到HTTP响应。

注意,“拒绝”和“超时”不是一个结果。拒绝是明确的连接错误,超时则是截止时间内没有完成预期操作;真实环境中,拒绝也可能来自中间设备的主动拒绝策略,不能看见这个异常就断定目标机器一定关了服务。

本次没有抓TCP包,因此不展示虚构的RST截图,只记录套接字实际返回的错误。

HTTP403:连接已经走到了HTTP响应阶段

访问/private时,TCP连接成功,对端为192.0.2.1:8080,随后收到了403和固定响应体。

403表示服务端理解请求但拒绝执行。HTTP语义 RFC 9110

在本实验里,拒绝由我们定义的处理器直接生成。但真实网站的403也可能由网关、代理、CDN等组件生成,所以“收到403”不能单独定位到最终业务服务,更不能直接归因于某一种账号或权限问题。

这时继续反复换DNS,通常不是由当前证据推出的修复动作。先看谁响应、哪个URL、请求条件和响应内容。

HTTP200:还要对照应用约定

为了暴露另一种误判,实验服务的/wrong-result故意返回状态200,却把正文设为lab-error。

客户端约定正常结果必须是lab-ok,所以它能区分“HTTP响应已收到”和“内容检查通过”。

这不是在建议服务端用200表示错误,也不是说200没有意义;它说明应用需要明确的成功契约,不能只判断状态码就忽略响应体。200状态语义

真实接口可能把业务结果放在JSON字段里,网页也可能返回一个200的错误提示页。必须按具体应用协议判断,不把实验中的字符串约定泛化成HTTP标准。

5. 代码中怎样把这几步分开?

测试端使用标准库,先显式建立TCP连接,再发送HTTP请求并读取正文。下面是核心结构,address来自前面的DNS答案,path来自实验用例:

connection = http.client.HTTPConnection(address, 8080, timeout=2) try: connection.connect() peer = connection.sock.getpeername() connection.request('GET', path, headers={'Host': 'service.test:8080'}) response = connection.getresponse() body = response.read().decode('ascii') finally: connection.close()

这不是完整程序,完整脚本还定义了地址、用例和断言。

代码刻意连接DNS答案对应的IP,同时保留HTTPHost为测试域名。这样无需修改宿主的hosts文件或系统解析器,也不丢掉HTTP请求中的主机名语义。

不过,它测试的是“显式DNS查询,再按该答案发请求”,不是操作系统自动解析整条URL。没有TLS,自然也没有测试HTTPS的SNI或证书名称校验;不能把这个方法直接当成HTTPS访问的等价替代。

6. 验证程序怎样避免混淆失败?

本次验证没有把所有异常都当作预期失败:

  • 8081必须明确得到连接拒绝;其他异常会使实验失败。
  • 8080的三个请求必须真正连上,记录对端,并验证状态和正文。
  • 200/403和正文分别断言,不用一个模糊的“请求成功”覆盖全部。
  • HTTP服务在绑定监听后写就绪标记,客户端看到标记才发送,避免启动竞态。

四个用例都得到预期结果,最终passed=true。这个字段的含义是“实验观察符合预设断言”,不是“所有页面都能正常使用”。

服务进程和命名空间在退出时清理;没有公网发布、没有扫描第三方端口,也没有修改正在运行的家庭路由器。

完整源码、依赖准备和运行命令放在文末附录。正文中的命名空间变量由编排脚本创建,不需要在真实网卡上手动制造故障。

7. 实际排障,把“打不开”换成能验证的问题

面对网站故障,我会先整理这些信息:

阶段记录什么不要急着下的结论
解析查询哪个域名、哪个DNS、哪种记录、答案是什么任意浏览器都用了这个答案
选路与连接实际目标IP/端口、出接口、错误类型ping成功就代表TCP端口可用
HTTP/TLS协议、状态码或握手错误、响应来自哪里403一定来自最终业务程序
内容与业务正文、业务字段、预期契约200就代表所有功能正常

不要把本实验的HTTP-over-TCP步骤套到所有协议;也不要仅凭IP可达,就省掉证书、Host、代理和应用层检查。

为什么一次只改一个条件?

本例的两个控制变量很有用:8080和8081的DNS答案相同,只改变端口,就能观察连接阶段的差异;/与/private使用相同IP和端口,只改变路径,就能观察HTTP处理的差异。若同时更换DNS、切换网络、重启服务,再说“换DNS修好了”,就不知道究竟是哪一步起了作用。

在自己的设备或有权排查的服务上,可以先做一份很小的记录,再决定改什么:

已拿到的证据下一步优先核对先不要做什么
指定DNS返回了地址,但连接被拒绝实际IP/端口是否正确,自己的服务是否监听,是否有主动拒绝规则凭这一结果反复换DNS
已收到403响应谁返回响应、路径和请求条件,自己服务的访问日志与规则把HTTP拒绝说成DNS没解析成功
收到200但内容不对响应体和应用成功契约,是否返回了错误页或非预期内容仅以200宣布问题解决
命令行正常,浏览器仍失败两者是否用了相同地址、协议、端口及网络路径用一次命令行结果代替浏览器证据

最后一行只是待核对的问题,不是这次实验已经证明的浏览器故障原因。排障的价值不在于列出更多命令,而在于让每一步结果决定下一步检查,并在修复后重新验证原来的失败条件。

DNS成功,是下一步检查的起点,不是网络排障的终点。同一个名字、同一个IP,仍能在不同端口、路径和应用约定下产生完全不同的结果。

附录:完整源码与复现

以下是完整文件,不需要先下载仓库。环境限定为 Ubuntu 24.04 amd64(本次为 WSL2),需要 Bash、iproute2、ping、Python 3、sysctl、timeout 和 dpkg-deb;不能把 Windows PowerShell 当成 Bash。实验需要 root 和创建网络命名空间的权限,普通受限容器可能无法运行。不要在生产路由器上运行。

在 Linux 中建立任意工作目录,例如:

mkdir -p ~/network-lab-dns-tcp-http/scripts cd ~/network-lab-dns-tcp-http

将下面每个代码块按标题文件名放入 scripts 目录,使用 UTF-8 和 LF 换行。脚本按自身位置寻找文件,不依赖我的 D 盘路径。先执行bash scripts/prepare-network-lab-tools.sh,仅下载并解包软件包,不安装系统服务。该辅助程序准备这组网络实验共用的工具,部分工具本篇不会调用。需要正常的软件源、网络连接和包索引;索引过期时先自行更新索引。在线下载分支尚未在本轮重新测试,本轮使用已有官方 deb 缓存验证解包和运行。

文件:scripts/prepare-network-lab-tools.sh

#!/usr/bin/env bash set -euo pipefail base=$(cd -- "$(dirname -- "$0")/.." && pwd) tools="$base/tmp/dhcp-tools-20261004" . /etc/os-release test "$ID" = ubuntu test "$VERSION_ID" = 24.04 test "$(dpkg --print-architecture)" = amd64 for tool in ip ping python3 sysctl dpkg-deb timeout; do command -v "$tool" >/dev/null; done packages=(dnsmasq-base busybox-static libnetfilter-conntrack3 libnftables1 libnfnetlink0 libmnl0 libnftnl11 libxtables12 nftables conntrack) mkdir -p "$tools" stage=$(mktemp -d "$tools/download.XXXXXX") if test "$#" = 2 && test "$1" = --from-cache; then test -d "$2" for deb in "$2"/*.deb; do cp -- "$deb" "$stage/"; done elif test "$#" = 0; then (cd "$stage" && apt-get download "${packages[@]}") else echo 'Usage: prepare-network-lab-tools.sh [--from-cache PACKAGE_DIRECTORY]' >&2 exit 1 fi for package in "${packages[@]}"; do found=0 for deb in "$stage"/*.deb; do if test "$(dpkg-deb -f "$deb" Package)" = "$package"; then found=1; break; fi done test "$found" = 1 || { echo "Missing package: $package" >&2; exit 1; } done : > "$tools/package-versions.txt" for deb in "$stage"/*.deb; do dpkg-deb -x "$deb" "$tools/extracted" dpkg-deb -f "$deb" Package Version Architecture >> "$tools/package-versions.txt" cp -- "$deb" "$tools/" done export LD_LIBRARY_PATH="$tools/extracted/usr/lib/x86_64-linux-gnu:$tools/extracted/lib/x86_64-linux-gnu" "$tools/extracted/usr/sbin/dnsmasq" --version | head -n 2 "$tools/extracted/usr/sbin/nft" --version "$tools/extracted/usr/sbin/conntrack" --version echo 'Tools ready. No package installation or service startup was performed.'

文件:scripts/verify-network-http-layers.sh

#!/usr/bin/env bash set -euo pipefail test "$(id -u)" = 0 base=$(cd -- "$(dirname -- "$0")/.." && pwd) tools="$base/tmp/dhcp-tools-20261004/extracted" busy="$tools/usr/bin/busybox" dnsmasq="$tools/usr/sbin/dnsmasq" export LD_LIBRARY_PATH="$tools/usr/lib/x86_64-linux-gnu:$tools/lib/x86_64-linux-gnu" test -x "$busy" test -x "$dnsmasq" out="$base/data/verification/network-http-layers-$(date +%Y%m%d-%H%M%S)" mkdir -p "$out" exec > >(tee "$out/stdout.txt") 2>&1 python3 --version token=$(cat /proc/sys/kernel/random/uuid) c="ht-${token:0:8}-c" s="ht-${token:0:8}-s" created=() pids=() cleanup() { for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; done for ns in "${created[@]}"; do ip netns del "$ns"; done } trap cleanup EXIT for ns in "$c" "$s"; do ip netns add "$ns"; created+=("$ns"); ip -n "$ns" link set lo up; done ip -n "$c" link add eth0 type veth peer name eth0 netns "$s" ip -n "$c" link set eth0 up ip -n "$s" link set eth0 up ip -n "$c" addr add 192.0.2.20/24 dev eth0 ip -n "$s" addr add 192.0.2.1/24 dev eth0 ip netns exec "$s" "$dnsmasq" --keep-in-foreground --conf-file=/dev/null \ --interface=eth0 --bind-interfaces --no-resolv --no-hosts --user=root \ --address=/service.test/192.0.2.1 --pid-file="$out/dns.pid" > "$out/dns-server.txt" 2>&1 & pids+=("$!") ip netns exec "$s" python3 "$base/scripts/lab-http-layers.py" serve "$out" > "$out/http-server.txt" 2>&1 & pids+=("$!") for attempt in $(seq 1 100); do if test -f "$out/http.ready"; then break; fi sleep 0.05 done test -f "$out/http.ready" for pid in "${pids[@]}"; do kill -0 "$pid"; done ip netns exec "$c" "$busy" nslookup -type=A service.test 192.0.2.1 | tee "$out/dns-query.txt" ip netns exec "$c" python3 "$base/scripts/lab-http-layers.py" probe "$out" echo "Evidence: $out"

文件:scripts/lab-http-layers.py

import http.client import ipaddress import json import re import socket import sys from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path class Handler(BaseHTTPRequestHandler): def do_GET(self): cases = {'/': (200, b'lab-ok'), '/private': (403, b'lab-denied'), '/wrong-result': (200, b'lab-error')} status, body = cases.get(self.path, (404, b'lab-missing')) self.send_response(status) self.send_header('Content-Type', 'text/plain') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body) mode, directory = sys.argv[1:] out = Path(directory) if mode == 'serve': with HTTPServer(('192.0.2.1', 8080), Handler) as server: (out / 'http.ready').touch() server.serve_forever() elif mode == 'probe': dns_output = (out / 'dns-query.txt').read_text() match = re.search(r'Name:\s+service\.test\s+Address:\s+(\d+\.\d+\.\d+\.\d+)', dns_output) if not match: raise SystemExit('No expected A answer from DNS tool') address = str(ipaddress.IPv4Address(match.group(1))) assert address == '192.0.2.1' results = {'resolved_address': address, 'cases': []} try: with socket.create_connection((address, 8081), timeout=2): raise AssertionError('Closed lab port unexpectedly accepted connection') except ConnectionRefusedError: results['cases'].append({'port': 8081, 'tcp': 'refused', 'http': None}) for path, expected_status, expected_body in ( ('/', 200, 'lab-ok'), ('/private', 403, 'lab-denied'), ('/wrong-result', 200, 'lab-error')): connection = http.client.HTTPConnection(address, 8080, timeout=2) try: connection.connect() peer = connection.sock.getpeername() connection.request('GET', path, headers={'Host': 'service.test:8080'}) response = connection.getresponse() body = response.read().decode('ascii') assert response.status == expected_status assert body == expected_body results['cases'].append({'port': 8080, 'path': path, 'tcp': 'connected', 'peer': list(peer), 'http': response.status, 'body': body, 'expected_business_value': body == 'lab-ok'}) finally: connection.close() results['passed'] = True results['scope'] = 'isolated plaintext HTTP; explicit A lookup, no browser/TLS/proxy test' (out / 'result.json').write_text(json.dumps(results, indent=2)) print(json.dumps(results, indent=2)) else: raise SystemExit('Unknown mode')

运行与结果判断

在上述工作目录执行:

bash scripts/prepare-network-lab-tools.sh sudo bash scripts/verify-network-http-layers.sh

输出保存在工作目录的 data/verification 下,以运行时间命名。正常完成会显示断言通过;还应检查本次目录的 result.json 中 passed 为 true。预设的失败对照属于实验内容,不意味着脚本整体失败;依赖缺失、权限不足或断言失败则不能算通过。不要使用 python -O 或 PYTHONOPTIMIZE 禁用断言。

脚本只修改自己新建的随机名称网络命名空间,正常退出及普通错误退出会执行清理。断电、强制终止或 kill -9 不能保证清理;可用 ip netns list 检查,只处理确认属于本次实验的资源,不要批量删除其他命名空间。输出中的地址来自隔离实验,不需要填写家庭账号、真实设备地址或认证信息。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询