域名系统底层原理概论

[!warning]说明
由于我对于本站征文投票机制和倾向表达的不满,多次引发某些人群对于我“规则利己主义”的批评,认为我不满于规则没有向自己倾斜。因此我决定不再参与后续的任何征文活动,以便彻底站在利益无关方的立场安静地写作,清理这些阴暗的揣测。

我一直相信,触及底层原理的文章,尽管因为晦涩且无法即时起效,不被大多数人看好,但它的价值是长存的,它永远是你在迷茫困惑时的一盏灯塔。
所以我不满足于“给你一个Docker-compose配置”,我要为你讲清楚Docker的基础知识;我不会仅限于告诉你怎样一步一步配置域名,我要让大家了解它到底是怎样工作的。
授人以鱼不如授人以渔。我相信即使不参与征文竞赛,没有社区推流和那几十美元的奖金,我的文章也总是能够被知音发掘,并在很长的一段时间里都能为有需要的人带来帮助。

本文考证时间约14天,写作时间约2小时

前言

当你在浏览器输入一个域名时,是否有思考过,计算机是如何通过域名查询到服务器IP地址的?全球无数的主机、无数的域名、无数的查询请求,又是如何维持高度的稳定性与可信度的?在这背后,正是域名系统(Domain Name System)这一大互联网基础设施发挥着作用。

域名的构成

域名是一个倒置的树状结构,从右向左,层级逐渐细化
我们常见的域名写法是www.example.com,然而更加标准的写法应当在末尾增加一个点:

www.example.com.

这就是完全限定域名(FQDN, Fully Qualified Domain Name)

[!note]
是的,在Chrome的地址栏里输入www.example.com.,也可以访问到正确的站点。

你可以理解为www.example.com就像是文件系统里的一个相对路径com/example/www,只是系统默认了这个相对路径以根目录/(在域名系统里叫做搜索域)为参照。而www.example.com.相当于直接输入/com/example/www这个绝对路径。

让我们从右往左分析其结构:

区域 级别 作用
. 根域(ROOT) 整个域名系统的起点
com. 顶级域(TLD, Top-Level Domain) com由Verisign负责;TLD分为gTLD(通用顶级域)与ccTLD(国家/地区顶级域)
example.com. 二级域(Second-Level Domain) 由组织或个人持有,需要通过注册商购买
www.example.com. 子域 可自由创建的前缀

全球根与权威名称服务器

全球共有13组根服务器(A~M),它们是DNS查询的最高权威。话虽如此,这13“台”服务器实际上通过任播(Anycast)技术,对应着全球数百个镜像节点,中国虽然没有根服务器(至于“雪人计划”我们后面再聊,新坑+1),但有镜像节点。
全球根服务器对根域(ROOT),也就是我们上文提到的.负责。它管理所有的TLD,并指向它们各自的专门的权威服务器,包括com.,net.,cn.,international.等等。
com./net.会被指向Verisign,而cn.则会被指向CNNIC。

[!note]
现在,你可以打开电脑,输入以下命令,查看它们究竟指向了哪里。

dig com. NS +short
dig net. NS +short
dig cn. NS +short

DNS迭代查询流程

以www.example.com.的查询流程为例,我们需要查询这个域名的A记录,就需要向名称服务器(NS, Name Server)发起请求。
但很遗憾,你现在唯一知道的,就是那13个刻在大脑系统里的全球根服务器。你决定向它们之一求助。

[!note] 用于记录这13个全球根服务器IP的文件,就是根提示文件(Root Hints File)

于是你向198.41.0.4,也就是其中一个全球根发起了请求:

  • 我想知道www.example.com.的A记录

全球根服务器表示很为难:

  • 我并不知道www.example.com.的A记录是什么,不过com.确实归我管,我刚才查询了我的区域(Zone)文件,你现在可以去找com.的NS,它的域名是a.gtld-server.net,它的IP是192.5.6.30

[!note]
a.gtld-server.net是威瑞信(Verisign)的一个权威名称服务器。
这里会同时返回一个IP,叫做“胶水记录”,毕竟.net的NS也是a.gtld-server.net,要是不直接给的IP,那就会出现“想要知道a.gtld-server.net在哪,得去问a.gtld-server.net,至于a.gtld-server.net在哪得问a.gtld-server.net”的抽象问题。

言归正传,你决定遵从全球根服务器的信息,向a.gtld-server.net询问:

  • 我想知道www.example.com.的A记录

Verisign也表示很为难:

  • 我也不知道www.example.com.的A记录是什么,但是我管理着example.com.。com.的区域文件提示,example.com.的NS记录是a.iana-servers.net.

于是你继续找a.iana-servers.net.询问:

  • 我想知道www.example.com.的A记录

这下对了!IANA的名称服务器表示:

  • 我确实管理着example.com.的区域文件,www子域的A记录是95.101.27.204

就这样,经过多次迭代,你终于找到了www.example.com指向的ipv4地址。

[!note]
实际上,DNS服务器会递归地帮助你直接完成上述迭代,你只需要向某个DNS服务器发起委派查询,它会自动、高效地帮助你完成上述过程(如果它没有缓存这个域名的记录),这并不需要你的个人电脑向多个NS发起多次查询。

这也回答了另一个问题:我用中国移动的DNS服务器上网,解析托管在阿里云DNS的域名,会比解析托管在Cloudflare的域名更快吗?答案是基本没有区别。

你发现了吗?无论是.根域,还是com.TLD、example.com.这个你拿到手的SLD,都通过一个预设的NS记录指向其本身以及其子域名的托管服务器。而CNAME则是“优先级高于一切”的别名,这意味着它天然与NS记录冲突,因此你不能直接在自己购买的域名上为根@设置CNAME解析。

小芝士:最长匹配原则

我们都知道,cn.和com.cn.等许多二级ccTLD都归CNNIC管理,并且共用权威名称服务器。
那么,如果我们向CNNIC的某个权威NS(比如a.dns.cn)查询markdown.com.cn.的记录,服务器会直接返回markdown.com.cn.的NS,还是经历com.cn. → markdown.com.cn.两跳?

实际答案是前者:

查询 198.41.0.4
cn                        NS    a.dns.cn
cn                        NS    b.dns.cn
cn                        NS    c.dns.cn
cn                        NS    d.dns.cn
cn                        NS    e.dns.cn
cn                        NS    ns.cernet.net
a.dns.cn                  A     203.119.25.1
b.dns.cn                  A     203.119.26.1
c.dns.cn                  A     203.119.27.1
d.dns.cn                  A     203.119.28.1
e.dns.cn                  A     203.119.29.1

查询 a.dns.cn (203.119.25.1)
markdown.com.cn           NS    sixty.dnspod.net
markdown.com.cn           NS    medicine.dnspod.net

这是因为RFC 1034明确了最特定区域匹配的原则(或者理解为最长匹配原则)。CNNIC的权威NS同时管理着com.cn.zone和cn.zone两个区域文件,对于markdown.com.cn.而言前者显然完全匹配且更加具体。

[!note]
思考题:假如权威NS没有按照最特定区域匹配的原则进行响应,会出现什么问题?

要注意的是,edu.cn.的权威NS与cn.不同:

查询 198.41.0.4
cn                        NS    a.dns.cn
cn                        NS    b.dns.cn
cn                        NS    c.dns.cn
cn                        NS    d.dns.cn
cn                        NS    e.dns.cn
cn                        NS    ns.cernet.net
a.dns.cn                  A     203.119.25.1
b.dns.cn                  A     203.119.26.1
c.dns.cn                  A     203.119.27.1
d.dns.cn                  A     203.119.28.1
e.dns.cn                  A     203.119.29.1

查询 a.dns.cn (203.119.25.1)
edu.cn                    NS    dns.edu.cn
edu.cn                    NS    ns2.cernet.net
edu.cn                    NS    ns4.cernet.net
edu.cn                    NS    ns5.cernet.net
edu.cn                    NS    dns2.edu.cn
edu.cn                    NS    dns3.edu.cn
dns.edu.cn                A     202.38.109.35
dns.edu.cn                TYPE28 20010250c00600000000000000000035
dns2.edu.cn               A     202.112.0.13
dns2.edu.cn               TYPE28 20010da8000101000000000000000013
dns3.edu.cn               A     101.4.62.35
Zone: edu.cn

查询 dns.edu.cn (202.38.109.35)
sysu.edu.cn               NS    ns3.sysu.edu.cn
sysu.edu.cn               NS    ns2.sysu.edu.cn
sysu.edu.cn               NS    ns1.sysu.edu.cn
ns3.sysu.edu.cn           A     202.116.64.6
ns2.sysu.edu.cn           A     202.116.64.2
ns1.sysu.edu.cn           A     202.116.64.1
ns3.sysu.edu.cn           TYPE28 20010250300200100000000000000006
ns2.sysu.edu.cn           TYPE28 20010250300200100000000000000002
Zone: sysu.edu.cn

查询 ns3.sysu.edu.cn (202.116.64.6)
ns1.sysu.edu.cn           A     202.116.64.1
ns2.sysu.edu.cn           A     202.116.64.2
ns3.sysu.edu.cn           A     202.116.64.6
ns1.sysu.edu.cn           TYPE28 20010250300200100000000000000001
ns2.sysu.edu.cn           TYPE28 20010250300200100000000000000002

附录

上述查询记录的源代码(Python原生):

希望它可以作为你亲自动手的开始
import socket
import struct
import random
from typing import List, Tuple, Optional, Dict


class DNSQuery:
    """DNS查询工具类"""
    
    # 根服务器列表(使用部分根服务器)
    ROOT_SERVERS = [
        "198.41.0.4",      # a.root-servers.net
        "199.9.14.201",    # b.root-servers.net
        "192.33.4.12",     # c.root-servers.net
        "199.7.91.13",     # d.root-servers.net
    ]
    
    def __init__(self):
        self.query_log = []
    
    def build_dns_query(self, domain: str, qtype: int = 2) -> bytes:
        """
        构建DNS查询包
        :param domain: 要查询的域名
        :param qtype: 查询类型 (1=A, 2=NS, 15=MX, 16=TXT)
        :return: DNS查询包的字节数组
        """
        # 生成随机事务ID
        transaction_id = random.randint(0, 65535)
        
        # DNS Header (12字节)
        # ID (2字节) + Flags (2字节) + 4个计数字段 (各2字节)
        flags = 0x0100  # 标准查询,期望递归
        header = struct.pack('>HHHHHH', 
                           transaction_id,  # ID
                           flags,           # Flags
                           1,               # QDCOUNT (问题数)
                           0,               # ANCOUNT (回答数)
                           0,               # NSCOUNT (授权记录数)
                           0)               # ARCOUNT (附加记录数)
        
        # Question Section
        question = self._encode_domain(domain)
        question += struct.pack('>HH', qtype, 1)  # QTYPE, QCLASS (1=IN)
        
        return header + question
    
    def _encode_domain(self, domain: str) -> bytes:
        """将域名编码为DNS格式"""
        encoded = b''
        for label in domain.split('.'):
            length = len(label)
            encoded += bytes([length]) + label.encode('ascii')
        encoded += b'\x00'  # 以0结尾
        return encoded
    
    def parse_dns_response(self, response: bytes) -> Dict:
        """
        解析DNS响应包
        :param response: DNS响应包
        :return: 解析后的字典
        """
        # 解析Header
        header = struct.unpack('>HHHHHH', response[:12])
        transaction_id, flags, qdcount, ancount, nscount, arcount = header
        
        result = {
            'transaction_id': transaction_id,
            'flags': flags,
            'questions': qdcount,
            'answers': ancount,
            'authority': nscount,
            'additional': arcount,
            'answer_records': [],
            'authority_records': [],
            'additional_records': []
        }
        
        # 跳过Question Section
        offset = 12
        for _ in range(qdcount):
            offset, _ = self._skip_name(response, offset)
            offset += 4  # QTYPE + QCLASS
        
        # 解析Answer Section
        for _ in range(ancount):
            offset, record = self._parse_record(response, offset)
            result['answer_records'].append(record)
        
        # 解析Authority Section (NS记录通常在这里)
        for _ in range(nscount):
            offset, record = self._parse_record(response, offset)
            result['authority_records'].append(record)
        
        # 解析Additional Section (可能包含NS的A记录)
        for _ in range(arcount):
            offset, record = self._parse_record(response, offset)
            result['additional_records'].append(record)
        
        return result
    
    def _skip_name(self, data: bytes, offset: int) -> Tuple[int, str]:
        """跳过并解析域名,返回新的offset和域名"""
        labels = []
        jumped = False
        jump_offset = offset
        
        while True:
            if offset >= len(data):
                break
                
            length = data[offset]
            
            if length == 0:
                offset += 1
                break
            elif (length & 0xC0) == 0xC0:  # 指针压缩
                if not jumped:
                    jump_offset = offset + 2
                    jumped = True
                pointer = struct.unpack('>H', data[offset:offset+2])[0]
                offset = pointer & 0x3FFF
                continue
            else:
                offset += 1
                labels.append(data[offset:offset+length].decode('ascii', errors='ignore'))
                offset += length
        
        if jumped:
            return jump_offset, '.'.join(labels)
        return offset, '.'.join(labels)
    
    def _parse_record(self, data: bytes, offset: int) -> Tuple[int, Dict]:
        """解析单条DNS记录"""
        offset, name = self._skip_name(data, offset)
        
        if offset + 10 > len(data):
            return offset, {}
        
        rtype, rclass, ttl, rdlength = struct.unpack('>HHIH', data[offset:offset+10])
        offset += 10
        
        record = {
            'name': name,
            'type': rtype,
            'class': rclass,
            'ttl': ttl,
            'data': None
        }
        
        if offset + rdlength > len(data):
            return offset, record
        
        # 解析RDATA
        if rtype == 1:  # A记录
            if rdlength == 4:
                record['data'] = '.'.join(str(b) for b in data[offset:offset+4])
        elif rtype == 2:  # NS记录
            _, ns_name = self._skip_name(data, offset)
            record['data'] = ns_name
        elif rtype == 5:  # CNAME记录
            _, cname = self._skip_name(data, offset)
            record['data'] = cname
        elif rtype == 6:  # SOA记录
            record['data'] = 'SOA'
        else:
            record['data'] = data[offset:offset+rdlength].hex()
        
        offset += rdlength
        return offset, record
    
    def query_dns(self, server: str, domain: str, qtype: int = 2, port: int = 53) -> Optional[Dict]:
        """
        向指定DNS服务器发送查询
        :param server: DNS服务器IP
        :param domain: 要查询的域名
        :param qtype: 查询类型
        :param port: 端口号
        :return: 解析后的响应
        """
        try:
            query = self.build_dns_query(domain, qtype)
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            sock.settimeout(5)
            sock.sendto(query, (server, port))
            response, _ = sock.recvfrom(4096)
            sock.close()
            return self.parse_dns_response(response)
        except Exception as e:
            return None
    
    def recursive_query(self, domain: str):
        """
        递归查询域名,从根服务器开始
        :param domain: 要查询的域名
        """
        print(f"\n查询 {self.ROOT_SERVERS[0]}")
        
        response = self.query_dns(self.ROOT_SERVERS[0], domain, qtype=2)
        
        if not response:
            return
        
        self._print_response(response)
        
        # 从Authority Section获取下一级NS服务器
        ns_servers = self._extract_ns_servers(response)
        
        if not ns_servers:
            return
        
        # 第二步及后续:向TLD服务器查询
        current_server = ns_servers[0]
        
        while True:
            print(f"\n查询 {current_server['ns']} ({current_server['ip']})")
            
            response = self.query_dns(current_server['ip'], domain, qtype=2)
            
            if not response:
                break
            
            self._print_response(response)
            
            # 检查是否有Answer Section (表示找到了最终答案)
            if response['answers'] > 0:
                break
            
            # 否则继续从Authority Section获取下一级服务器
            ns_servers = self._extract_ns_servers(response)
            
            if not ns_servers:
                break
            
            # 检查返回的zone是什么
            if response['authority_records']:
                zone = response['authority_records'][0].get('name', '')
                print(f"Zone: {zone}")
            
            current_server = ns_servers[0]
    
    def _print_response(self, response: Dict):
        """打印DNS响应的详细信息"""
        for record in response['authority_records']:
            self._print_record(record)
        for record in response['additional_records'][:5]:
            self._print_record(record)
    
    def _print_record(self, record: Dict):
        """打印单条记录"""
        type_map = {1: 'A', 2: 'NS', 5: 'CNAME', 6: 'SOA'}
        type_str = type_map.get(record.get('type'), f"TYPE{record.get('type')}")
        print(f"{record.get('name'):25s} {type_str:5s} {record.get('data')}")
    
    def _extract_ns_servers(self, response: Dict) -> List[Dict]:
        """从响应中提取NS服务器及其IP地址"""
        ns_servers = []
        
        # 从Authority Section获取NS记录
        ns_names = []
        for record in response['authority_records']:
            if record.get('type') == 2:  # NS记录
                ns_names.append(record.get('data'))
        
        # 从Additional Section获取对应的A记录
        for ns_name in ns_names:
            for record in response['additional_records']:
                if record.get('type') == 1 and record.get('name') == ns_name:
                    ns_servers.append({
                        'ns': ns_name,
                        'ip': record.get('data')
                    })
                    break
        
        return ns_servers


def main():
    """主函数"""
    import sys
    
    dns = DNSQuery()
    
    # 如果有命令行参数,使用参数作为域名
    # python3 main.py markdown.com.cn
    if len(sys.argv) > 1:
        domain = sys.argv[1]
    else:
        domain = "sysu.edu.cn"
    
    dns.recursive_query(domain)


if __name__ == "__main__":
    main()
114 个赞

因为大量黑洞查询全球根域名服务器压力奇大,clcloudflare特别推一项计划来缓解这个问题呢w

6 个赞

好文无人,顶一下喵

1 个赞

谢谢分享 :hand_with_index_finger_and_thumb_crossed:

1 个赞

支持,我感觉L站或者if站对我来说,最有用的就是各种教程,虽然学的零零碎碎:rofl:。

好文 学习一下

现在不是有http dns了,直接去问权威域名要IP,传统的方式可以留着兜底用

感谢科普 :folded_hands:

DoH,DoT只是终端用户的DNS查询方式,描述终端用户如何向域名系统(DNS)发起查询,属于DNS协议。
而本文主要描述的是DNS,也就是域名系统本身。DoH、DoT的出现并不会改变域名系统基于全球根服务器开始,树状结构、分层授权、迭代查询的底层逻辑。

2 个赞

大佬 就是大佬 感谢科普

支持砂糖

知识+1

感谢分享,学习ing

砂糖太强了! 马上学习dns

1 个赞

不错,学习了,感谢佬友的分享。

受教了,感谢大佬的精彩分享。

感谢大佬的精彩分享

感谢佬友抽出时间来科普知识。mark下,晚点来看

给佬友点个赞了!

谢谢,学到了