我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。
标签 #计算机网络

为什么我无法访问一个网站

这是从我开始经营 RP 主机 以来,被问及的最多的一个问题。

本文将以诊断「为什么我无法访问一个网站」的方式,讲述 Web 服务的大致工作流程。

目录

  • URL
  • DNS
  • TCP/IP 连接
  • 加载资源
  • 渲染

URL

通常来讲,所谓访问一个网站,我们输入给浏览器的是类似于这样的一个地址:

http://jysperm.me/note/1519

用术语来讲,这是一个 URI, 一个 URI 有两个部分,一是协议,二是定位符。
比如「mailto:jysperm@gmail.com」这是一个邮箱的标识符,其中「mailto」是协议,「jysperm@gmail.com」是定位符。

再比如「urn:isbn:9787115281487」这是一本书(HTTP 权威指南)的标识符。

用来表示网页的 URI 有一个比较特殊的名字叫 URL, 它大致分成这几个部分:

  • http - 协议
  • jysperm.me - 主机名
  • /note/1519 - 路径

除此之外还可能会有类似于「?id=1519」的查询字符串。

这样的一个 URL, 便可以定位到一个网页。

DNS

首先我们要找到为我们提供服务的服务器,即 URL 中的主机名,在这里是 jysperm.me.

TCP/IP 网络中通过 IP 地址来唯一地定位一台主机,更确切地说,每一块网卡,会有一个唯一的 IP 地址。

IP 地址形如 117.121.25.186, 它本身是存在一定结构性的,比如 117.121.25.186 和 117.121.25.187,就有非常大的可能性两者的物理距离很近。

这样的结构性方便于计算机之间互相联络,但不便于人类记忆,于是我们用由字母组成的域名来表示主机。

我们通过一个叫 DNS 的系统,来将域名翻译成 IP 地址,DNS 系统本身说简单也简单,说复杂也复杂,在这里我们只讲述如何使用,而不研究其原理。

如果浏览器给你的提示是类似于「无法找到 jysperm.me」或者「无法连接到 jysperm.me」,那么有很大可能性是 DNS 查找失败或者出错。

我们可以使用 nslookup 这个命令来手动进行 DNS 查询

> nslookup jysperm.me
Server: 8.8.8.8
Address:    8.8.8.8#53

Non-authoritative answer:
Name:   jysperm.me
Address: 117.121.25.186

可以看到,我们向 8.8.8.8(Google 的免费公众 DNS), 查询 jysperm.me 这个域名,它返回给我们的结果是 117.121.25.186.

这里是 8.8.8.8 是系统的默认 DNS 服务器,我们还可以自己来指定 DNS 查询服务器:

> nslookup jysperm.me 114.114.114.114
Server: 114.114.114.114
Address:    114.114.114.114#53

Non-authoritative answer:
Name:   jysperm.me
Address: 117.121.25.186

这里使用了国内的 114.114.114.114 进行查询,返回的结果是一样的。

在某些网络封锁比较严重的国家,DNS 查询的结果可能会被篡改,因为 DNS 本身几乎没有任何安全措施,所以查询到的结果可能并不是可靠的。

TCP/IP 连接

知道了服务器的 IP 地址,下一步是建立 TCP 连接。这一步最常遇到的问题有:

  • 无法连接到服务器/连接超时
  • 连接被拒绝
  • 连接被重置

首先 IP 层面,要向一台主机发送数据,这个主机必须是「可达的」,换句话说就是对方必须在线。

最简单的,我们可以用 ping 命令验证一个主机是否在线:

> ping 117.121.25.186
PING 117.121.25.186 (117.121.25.186): 56 data bytes
64 bytes from 117.121.25.186: icmp_seq=0 ttl=128 time=138.651 ms
64 bytes from 117.121.25.186: icmp_seq=1 ttl=128 time=71.345 ms
64 bytes from 117.121.25.186: icmp_seq=2 ttl=128 time=286.960 ms

ping 使用的是 ICMP ECHO 指令,就好比你跟对方打招呼,问它是否在线,如果对方回应了,就说明它在线。但是,并非所有服务器都会回应 ICMP ECHO.

如果发现 ping 命令没有回应,类似于下面的情况:

> ping 59.24.3.173
PING 59.24.3.173 (59.24.3.173): 56 data bytes
Request timeout for icmp_seq 0
Request timeout for icmp_seq 1
Request timeout for icmp_seq 2

那么就说明你和服务器直接没有建立连通的连接,但是,连接是在那里断开的呢?这时我们可以用 traceroute 来探测数据包所走过的路径:

> traceroute www.qq.com
traceroute to www.qq.com (180.96.86.192), 64 hops max, 52 byte packets
 1  * * *
 2  100.64.0.1 (100.64.0.1)  10.224 ms  5.379 ms  6.794 ms
 3  218.4.12.229 (218.4.12.229)  8.342 ms  6.817 ms  8.169 ms
 4  218.94.165.2 (218.94.165.2)  8.128 ms  8.259 ms  7.173 ms
 5  61.155.133.121 (61.155.133.121)  11.152 ms  9.288 ms  9.990 ms
 6  202.102.69.254 (202.102.69.254)  11.464 ms  12.463 ms  11.027 ms
 7  180.96.51.94 (180.96.51.94)  13.674 ms  13.192 ms  12.364 ms
 8  180.96.48.202 (180.96.48.202)  13.564 ms  11.213 ms  13.945 ms
 9  * * *

traceroute 依赖于 ICMP, 并非所有服务器都开启了 ICMP, 因此会有一些路径点被显示为星号。

我们再来看另一个不可达的例子:

> traceroute twitter.com
traceroute to twitter.com (59.24.3.173), 64 hops max, 52 byte packets
 1  * * *
 2  100.64.0.1 (100.64.0.1)  4.694 ms  4.502 ms  8.044 ms
 3  218.4.12.237 (218.4.12.237)  8.874 ms  5.177 ms  8.133 ms
 4  218.4.13.49 (218.4.13.49)  15.205 ms  11.400 ms  12.011 ms
 5  202.97.55.41 (202.97.55.41)  14.011 ms  15.794 ms  16.518 ms
 6  202.97.50.254 (202.97.50.254)  12.207 ms  12.614 ms  13.165 ms
 7  202.97.35.78 (202.97.35.78)  12.246 ms
    202.97.35.22 (202.97.35.22)  13.337 ms
    202.97.34.126 (202.97.34.126)  13.701 ms
 8  202.97.60.33 (202.97.60.33)  58.292 ms  12.432 ms  18.093 ms
12  * * *

可以看到,最后一个可以追踪到的路径点是 202.97.60.33, 通过一些 IP 库可以查到,这个地址属于「广东省广州市 互联网交换中心」,可以打个电话问问他们那边出了什么情况。

加载资源

访问一个网页会发起不止一个请求,除了网页本身还会加载一些例如样式,图片,脚本之类的资源,甚至很多时候还会加载来自其他网站的资源,通过 Chrome 的开发人员工具中的「Network」选项卡,可以看到加载一个页面所发起的所有请求。

这是一个正常的网站, 所有的资源都加载成功了:

正常网站的加载情况

再看下面这张图,其中一个资源没有加载完成,导致整个页面都无法完成,这时我们就可以通过上面的方法单独排查这一个请求。

没有加载完全的情况

通过浏览器来检查一个资源究竟能不能下载在一些复杂情况下并不可靠,因为浏览器会为你的请求加上大量的参数,而且在得到资源后,还会进行渲染。

因此为了「纯粹」地下载一个资源,我们可以用 curl:

> curl http://pomotodo.com -I
HTTP/1.1 302 Moved Temporarily
Server: nginx
Date: Fri, 01 Aug 2014 03:51:35 GMT
Content-Type: text/html
Content-Length: 154
Connection: keep-alive
Location: https://pomotodo.com/

可以看到 http://pomotodo.com 并非像我们看到的那样显示了一个页面,而是发起了一个到 SSL 版本地址的重定向,SSL 可以保证传输的私密性,目前国内只有很小一部分有节操的网站在使用。

渲染

等到所有资源都加载完了,但是页面还是没有内容,或者乱七八糟怎么办?

在正确地得到所有资源后,浏览器会执行网站提供的脚本,来对资源进行加工,以便像网站制作者期望的那样来展现页面。

在这个过程中,脚本需要使用浏览器提供的 API 来进行渲染,而不同的浏览器所支持的 API 是有差异的,因此一些制作不是很精良的网站会在不同的浏览器中呈现不同的效果,甚至直接出错。

在此建议大家选择一个现代而强大的浏览器,比如 Chrome, 这样可以保证浏览器支持网页所需要的功能。

零毫秒的图景一下子清晰起来了

去中心化的零毫秒计划了很久,一直都没能开始,原因很简单,就是我无法想象这样一个项目应该如何设计,都有哪些部分,从哪开始。

即使这两年来我学习了不少有关公钥加密和证书体系,Bitcoin 的实现,一些 DHT 网络的实现等,但依旧如此。

这段时间有很多目标类似的项目出现,我所知道的就有 BitMessage, BitTorrent Chat, Tox.

所以我以为这个项目就要这么坑掉了。

但前一阵,我一直想着重写 ZeroMS-1x, 即两年前我初学 Qt 的时候,写的零毫秒的第一个版本,一个十分简易的中心化,C/S 结构的聊天工具。

重写的目的也很简单,只是希望当初花了好大功夫写的东西不至于不能运行——虽然重写的时间应该不会小于当初花费的时间。

于是我开始设想如何设计这个重写版本。

首先不能再使用之前那丑陋的通讯协议载体,转而使用 JSON.

然后就是之前那蛋疼的帐号机制。

之前的帐号机制是使用 PHPWind 论坛(后来是 esoTalk)系统的帐号系统,服务器会请求论坛上的一个 PHP API 来验证登录信息。

我决定使用公钥加密(RSA), 的密钥对代替帐号系统。

一对公私玥就是一个帐号,公钥是帐号的唯一 ID, 私钥是持有帐号的凭证。

登录时,客户端用私钥为登录信息签名,同时提供一个短的,不唯一,可变的昵称作为友好的显示名。

再进一步,可以让发信人对所有发出的消息进行签名,以认证身份。

再进一步,可以让发信人对所有签名过的消息,用收信人的公钥进行加密,使只有拥有私钥的收信人才能解密。

至此,我们惊奇地发现,虽然整体仍是 C/S 结构的网络,但是我们似乎已经剥夺了服务器的大部分权力——服务器无法查看消息的内容,也无法篡改或伪造消息。

于是,服务器似乎变成了一个非必须的部分,因为作为服务器,不需要什么资格,也没有什么权力,任何人都可以当服务器!

甚至可以让多个服务器接力地完成一个消息的送达过程。只需要送达就可以了!无论中间是谁来传递的,也无论中间有多少人经手,因为它们看不了消息也改不了消息,就算你写在纸上飞鸽传书也没有什么不可以。

这时的服务器已经不能叫做服务器了,应该叫网关或者路由,就像 IP 中的网关一样,工作是将 IP 数据包送达指定的地址。IP 网络的网关各自维护了自己的路由表,同时基于 IP 地址的 IP 网络也是一个结构化的网络,所以这很简单。

而在零毫秒的网关之间,可以维护一个分布式散列表(DHT), 如类似 Kademlia 协议的 DHT, 储存网络上每个用户(公钥)和所对应的地址。

这样一来,原来我想要的去中心化即时通讯就是这么简单!之前一直把它想得过于复杂了,原来就是这么简单的一个构造而已!

既然图景已经清晰,我们还可以讨论一些更为细节的话题。

首先是公钥交换,通过上面的设计,要与一个人通讯,必须知道他的公钥,当然,获取公钥的过程很简单,问题是如何保证这个过程的安全性呢?如果密钥在通信的途中被替换了怎么办?这在 HTTP 环境下很容易发生。

有人提出应成立一个证书颁发机构(CA), 对用户的公钥进行签名,但这似乎有悖于去中心化的精神。

我认为公钥交换应该由用户自行解决,用户可以自行选择渠道,如 HTTP, HTTPS, 其他 IM 如 QQ, 当面交换纸质(二维码)公钥。而事实上也有提供公钥交换服务的网站,如 pgp.mit.edu (我不得不吐槽一下这个网站居然只有 HTTP 版本), 这些望着本来是为了交换 PGP 公钥而设计的,不过对零毫秒也是适用的。

因为用户可以自行选择渠道,用户的选择越多样,「信任链」的构成就越分散,攻击者发起攻击的成本就越高,整个系统就越安全。

之前我们讨论过,网关无法阅读或修改流经它的消息,但是网关可以选择丢弃消息,不予转发,那么如何应对这种消息丢失的情况呢?

事实上 IP 的网关也有类似的特点,即它可以随意丢弃消息,IP 对此的解决方案就是不予考虑,将这个工作交由上层协议来实现,比如 TCP.

TCP 会在通讯的双方,也就是两个端点来进行一些操作,而中间的 IP 网关不必考虑,甚至不必知道这是一个承载 TCP 协议的包。

「在端点实现功能」也是 TCP/IP 网络体系的一大特点。

由此,零毫秒中的两个客户端之间,应该自行协商,防止消息丢失。

最简单的办法就是在每一条消息中,嵌入上一条消息的散列值(Hash), 当中间的某个消息丢失时,双方可以察觉到,自行协商,对丢失的消息进行重传。

这样一来,客户端需要自行维护很多状态,例如对于每个联系人的上一条消息,这导致用户在更换设备时需要一并携带这些信息,否则就会导致通讯不正常,这是目前很难解决的问题,最理想的就是使用同步盘服务(可以是自建的)来同步这些数据。

另一方面是接收离线消息,客户端可以指定一个长期在线的网关作为离线代理,由这个网关来代收离线消息,上线后再从这个网关抓取离线消息,这符合在端点实现功能的原则。

这个网关可以是用户自建的,也可以是公共的「离线代理网关」。

最后要讨论的一个话题就是组群。

建立一个组群就是生成一个新的密钥对,公钥即为该组群的ID, 私钥由管理员掌握,用于签发新成员加入和移除现有成员的通知。

然后组群的成员根据管理员签发的公告,计算出目前的成员列表,逐个发送消息。

这个实现似乎很不完善,既无法阻止成员把消息发到非组群成员那,也无法阻止成员忽略组群中的一些成员,全靠成员的自觉。

而且如果有其他 10 个成员,那么每条消息就需要发送 10 遍,因为要保证网关不能阅读消息的内容,每一条消息都需要用不同的,收信人的公钥来加密。

零毫秒:Kademlia 笔记

Kademlia协议(模型)是被电驴,BitTorrent所采用了的,基于异或距离算法的分布式散列表(DHT), 它实现了一个去中心化的信息储存与查询系统。

Kademlia将网络设计为一个具有160层的二叉树,树最末端的每个叶子便是一个节点,节点在树中的位置由同样是160bit的节点ID决定。

每个bit的两种可能值(0或1), 决定了节点在树中属于左面还是右面的子树,160层下来,每个节点ID便都有了一个确定的位置。

Kademlia使用独特的异或距离算法来计算节点间的距离,异或是一种简单的数学计算,它有很多独特的性质,这些性质在之后会为我们带来方便:

自己与自己的距离为0:
x ^ x = 0
不同的节点间必有距离:
x ^ y > 0
交换律,x到y的距离等于y到x的距离:
x ^ y = y ^ x
从a经b绕到c, 要比直接从a到c距离长:
a ^b + b ^ c >= a ^ c
下面两个是资料上提到的,似乎很重要,但我不大理解他们的含义:
a + b >= a ^ b
(a ^ b) ^ (b ^ c) = a ^ c

在Kademlia中,异或(距离)算法具有单向性(或者说一一对应关系),即给定一个节点和一个距离,必定存在唯一一个相对应节点。包括距离算法在内的,Kademlia中大部分的概念,都既有算术上的意义,又可以在节点树上表现实际意义。

事实上,节点间距离反映的就是节点ID中比特的差异情况,而且越靠前的比特权值越大。或者说是反映节点在树中相隔了多少个分支,需要向上多少个树节点才能找到共同的祖先节点。

Kademlia中使用了名为K-桶的概念来储存其他(临近)节点的状态信息,这里的状态信息主要指的就是节点ID, IP, 和端口。

对于160bit的节点ID, 就有160个K-桶,对于每一个K-桶i, 它会储存与自己距离在区间 [2^i, 2^(i+1)) 范围内的节点的信息,每个K-桶中储存有k个其他节点的信息,在BitTorrent的实现中,k的取值为8.

下表反映了每个K-桶所储存的信息

K-桶储存的距离区间储存的距离范围储存比率
0[20, 21)1100%
1[21, 22)2-3100%
2[22, 23)4-7100%
3[23, 24)8-15100%
4[24, 25)16-3175%
5[25, 26)32-6357%
10[210, 211)1024-204713%
i[2i, 2i+1)/0.75i-3

放在节点树上,即每个节点都更倾向于储存与自己距离近的节点的信息,形成 储存的离自己近的节点多, 储存离自己远的节点少 的局面。

从上表可以看出,在1-15这个范围内的节点,只要发现,就会被100%地储存下来,而离自己距离在1000左右的节点,只会储存13%.

对于一个节点而言,K-桶就代表着节点树上那些未知的节点(其实除了自己都是未知的)构成的子树,160个K桶分别是具有1到160层的子树,由小至大。对于节点ID, 160个K-桶分别储存着节点ID前0到159个bit和自己一致的节点。

K-桶中的条目(其他临近节点的状态信息)排序的,每当收到一个消息(如查询)时,就要更新一次K桶。

首先计算自己与对方的距离,然后储存到对应的K-桶中,但如果K-桶已满(前面提到每个K-桶储存有k=8个条目), 则会倾向放弃储存,继续保持旧的节点信息(如果还有效的话). 除了距离外,Kademlia更倾向于与在线时间长,稳定的节点建立联系。

这是因为实践证明,累积在线时间越长的节点越稳定,越倾向于继续保持在线,即节点的失效概率和在线时长成反比。

这样还可以在一定程度上抵御攻击行为。因为当大量恶意的新节点涌入时,大家都会选择继续保持旧的节点信息,而不是接受新的。

除此之外,还需要定时检查K-桶中的节点是否任然在线,及时删去已失效节点。

Kademlia协议仅定义了四种操作:

  • PING: 探测一个节点是否在线
  • STORE: 令对方储存一份数据
  • FIND NODE: 根据节点ID查找一个节点
  • FIND VALUE: 根据键查找一个值(数据)

当查找一个节点时,首先计算自己与目标节点的距离d, 然后将 log2d 向下取整,找到对应的K-桶,从这个K-桶中选取a个节点(在BitTorrent的实现中取值为3), 向它们发送查询。

收到查询的节点同样计算距离后从自己的对应K-桶中选取a个节点返回给查询者。查询者不断重复这个过程,知道找到目标节点,或无法再找到更近的结果。

很多资料将这个过程描述成是递归的,但我觉得这里认为它是迭代的更为恰当。

因为每个节点都更倾向于储存距自己近的节点的信息,而整个网络又是一个二叉树,因此每次查询都会至少取得距离减半的结果,对于有N个节点的网络,至多只需要 log2N 次查询。

当进行 FIND VALUE 操作时,查询操作是类型的,每份数据都有一个同样是160bit的键,每份数据都倾向于储存在与键值距离较近的节点上。

当上传者上传一份数据时,上传者首先定位几个较为接近键值的节点,用STORE操作要求他们储存这份数据。

储存有数据的节点,每当发现比自己与键值距离更为接近的节点时,便将数据复制一份到这个节点上。

当一个新节点欲加入网络时,只需找到一个已在网络中的节点,借助它对自己的节点ID进行一次常规查询即可,这样便完成了对自己信息的广播,让距自己较近的节点获知自己的存在。而离开网络不必执行任何操作,一段时间后,你的信息会自动地从其他节点被删除。

毫无疑问,Kademlia要比我之前为零毫秒设想的网络模型优秀得多,更为彻底地实现了去中心化,弱化了关键节点失效对整个网络的影响。而电驴和BitTorrent的实践也证明了kademlia是具有相当的可行性的。

Kademlia的精妙之处在于它选择了异或运算作为计算距离的依据,异或运算不仅具有算术的意义,在二叉树式的网络模型中,同样具有实际意义,同时任何情况下都在强调距离的概念,让节点间通过距离来聚合起来。

在上一篇日志的末尾,我便在思考如何来聚合节点,现在通过Kademlia, 我算是找到了。下一步我想思考的是,既然以上是Kademlia的优势,那么它的弱势在哪?哪些地方存在不足?

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过 Telegram Channel 订阅我的博客日志、产品和项目的动态:

王子亭的博客 @ Telegram


通过邮件订阅订阅我的博客日志、产品和项目的动态(历史邮件):

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2023-12-20.