Know Your Wisdom

Docker Container DNS 是怎么运行的

2022-04-09

Background

我在家里的树莓派服务器上搭建了一套 prometheuse / granfana 监控系统,用于监控自己网站的服务器、网关状况。

我的目的是想要把这些监控都放在 docker 里,然后用 portainer 直接配置,这样可以省去很多麻烦。

当我将树莓派添加到 portainer 时,直接使用了树莓派的主机名:`raspberrypi.local`,但是这个时候 portainer 却报错: 8.8.8.8 DNS 无法解析该域名。

Inspect

因为我平时查看监控都是直接 http://raspberrypi.local/xxx 看的,一直没有遇到问题。

这里很明显 portainer 并没有使用本地的 DNS 服务器(即局域网网关 192.131.1.1:53 ),导致了无法解析本地主机名。

为了排除是树莓派出问题的可能,我用 python 试着解析了下域名:

import socket
print(socket.gethostbyname('raspberrypi'))
# output: '192.168.31.134'

print(socket.gethostbyname('raspberrypi.local'))
# output: '192.168.31.134'

果然没有任何问题,那就可以确定就是 portainer 实现的问题了。

因为 portainer 是在 docker 里跑的,这里自然而然就会想到是不是因为 docker 内部无法解析本地主机名,导致 portainer 不得不使用 8.8.8.8

登录我的 container 验证一下:docker run -it python:3.9-alpine

import socket
print(socket.gethostbyname('raspberrypi'))
# output: Traceback (most recent call last):
#         File "<stdin>", line 1, in <module>
#         socket.gaierror: [Errno -2] Name does not resolve

查看这个 container 的设置: docker inspect cb09f9a13918 ,只需要关注最后面的网络设置即可:

"Networks": {
                "bridge": {
                    "IPAMConfig": null,
                    "Links": null,
                    "Aliases": null,
                    "NetworkID": "cb218879dbeafdf5fe020940300c9cbf281068f85c2e60dec2833cbfbb2731e1",
                    "EndpointID": "0fb1d0addad4ab2fb471666a2b67ac1c25c755bfd65f2d8a728f40c8e902e61d",
                    "Gateway": "172.17.0.1",
                    "IPAddress": "172.17.0.4",
                    "IPPrefixLen": 16,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "MacAddress": "02:42:ac:11:00:04",
                    "DriverOpts": null
                }
            }

手动给这个网关发 dns 请求,看看会不会收到回复:dig @172.17.0.1 raspberrypi

结果超时了:

; <<>> DiG 9.10.6 <<>> @172.17.0.1 hdcjh.xyz
; (1 server found)
;; global options: +cmd
;; connection timed out; no servers could be reached

作为对比,给我家里路由器再发一个同样的 dns 请求: dig @192.168.31.1 raspberrypi

输出:

; <<>> DiG 9.10.6 <<>> @192.168.31.1 raspberrypi
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 14654
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;raspberrypi.           IN  A

;; ANSWER SECTION:
raspberrypi.        0   IN  A   192.168.31.134

;; Query time: 7 msec
;; SERVER: 192.168.31.1#53(192.168.31.1)
;; WHEN: Fri Mar 31 21:36:12 CST 2023
;; MSG SIZE  rcvd: 56

现在就可以确认是 docker bridge 网关的 DNS 有问题,先进容器看看网关是怎么配置的:

cat /etc/resolv.conf
# Output: 
# nameserver 8.8.8.8
# nameserver 114.114.114.114

这里既没有 bridge 的网关,也没有本地主机的网关,那肯定就访问不到树莓派了。

按照这个方向查查资料:

官方告诉了我两件事:

  1. bridge DNS 继承自本地主机(可明显 container 和本地主机不一样)
  2. 使用 --dns 可以手动添加额外的 dns

Solution

第二条好歹有点用,试试将 bridge 的网关添加到 python container 里看看能不能成功解析本地主机:

docker run -it --dns 172.17.0.1 python:3.9-alpine

发现总是返回 Try Again,这种情况(我猜)是因为 DNS 的 UDP 包发到 bridge 后压根没回复导致的,因为之前直接用 bridge 解析 DNS 的时候就没有成功。

再试试直接将路由器的网关放进去:docker run -it --dns 192.168.31.1 python:3.9-alpine

这一次终于成功获取到了 ip 地址。那 portainer 加上 dns 选项后再试试:

在portainer 原本的 docker run 命令前面加上 --dns `dig anything | grep -E -e 'SERVER: \d+\.\d+\.\d+\.\d+\b' -o -m 1 | sed 's/.\{8\}//'`
这条命令用于自动将本地网关的 IP 地址打印出来。需要注意的是,当更换了局域网络后,需要重新启动 portainer 才能正确获取新的网关地址。

如图所示配置 portainer 后即可成功访问树莓派。

portain 配置
portain 配置

这里需要注意的是,mDNS 的端口是 5353,DNS 是 53,所以在 docker 中连接树莓派时必须使用主机名 raspberrypi 而不是 mDNS 名 raspberrypi.local

DNS

DNS 路由方式
DNS 路由方式

DNS(Domain Name System)其实和 linux 文件目录完全一样,只不过 linux / 代表根目录,例如 /root/jiahao.chen/workspace/xxx ;而 DNS 用 . 来代表根目录,例如 www.hdcjh.xyz.

  • 根目录: .
  • 一级目录: xyz
  • 二级目录: hdcjh
  • 三级目录: www

注意:最后面的 . 并不是多写的,而是本来就存在,不信可以试试访问 https://www.hdcjh.xyz. ,照样可以访问本网站。只不过大多数浏览器都省略了尾缀的 . 而且 DNS 也默许而已。

发展到今天,DNS 有非常多的类型,当发出某个 DNS 请求后,DNS 服务器就会返回一个或多个 DNS 记录,下表列出了一些常用的 DNS 响应的记录类型和他们的使用场景,完整的记录类型可以在这里找到,或者参考 Cloudflare 的文档(推荐)。

Response TypeResponse Data
A域名对应的 ipv4 地址
AAAA域名对应的 ipv6 地址
CNAME将域名转为另一个域名
PTRip 地址对应的域名
SVR服务器的类型
TXT文本信息,想存啥都行

手把手解析 DNS

  1. 使用 tcpdump 监听 53 端口,并把监听到的数据存到本地文件中: sudo tcpdump -nAs 0 'port 53' -w dns_record.pcap
  2. 使用 dig 或者 python.socket.gethostbyname 来发送一条 DNS 请求,例如: dig raspberrypi
  3. 用 wireshark 打开1⃣️中保存的文件,即可看到记录的 UDP DNS 请求。
wireshark 抓包
wireshark 抓包

这里只需要关注第 5、6 条关于 raspberry 的请求即可。

第五条,本机向路由器发送的 DNS 请求:

wireshark 抓包
wireshark 抓包

第六条,路由器返回的 DNS 响应:

wireshark 抓包
wireshark 抓包

另一个重要的协议是 rDNS,用来将 ip 地址转为域名。试试找到一条 IP 对应的域名: dig -x 192.168.1.16

mDNS

支持情况

Apple 全系列都双手双脚支持 mDNS,每当我手机亮屏的时候都会发 mDNS 告诉别人他在这,看看附近有啥服务可以用。然后 iPad 就会告诉他我可以投屏,Mac M1 也会站出来显示他的存在。

tcpdump 抓包
tcpdump 抓包

从上图中可以看到,我的名字(iPhone账户的姓名,包括汉字,图中没打印出来而已)赤裸裸的暴露在网络中。这其实给撩妹提供了可能,假设你在学校碰到了一个女生,然后就可以通过监听 5353 端口知道她的名字,然后上去和他打招呼:“好久不见“。由此可见学好 tcpdump 对脱单非常重要‼️

关于 docker 中访问 mDNS 地址 raspberrypi.local 是否可识别,可参考下表:

EnvironmentDNS SupportmDNS Support
本地主机
Docker alpine
Docker alpine ping
Portainer

由此可见,在日常使用中能使用 DNS 就尽量使用 DNS。

实现原理

关于如何用 mDNS 搭建局域网服务可以参考这篇

mDNS 就是在局域网没有 DNS 服务器的时候,利用 IP 多播来实现互相发现的一种机制,特点就是所有的 hostname 后缀都是 .local.

IP 多播 本质上其实就是 UDP 多播,IPv4 和 v6 都有专门定义的多播 IP 段,局域网内的机器可以自己选择是否加入多播组,从而接收相应消息。而UDP 多播 就是在 IP 多播的机制上使用了 UDP 而已,IP 多播是 UDP 多播的超集。

下表列出了 mDNS 的一些特性:

PropertyValue
ipv4 多播地址224.0.0.251
ipv6 多播地址ff02::fb
port5353

测试自己的电脑是否支持 mDNS: ping <hostname>.local 或者 ping <hostname>.local. 都可以,最后在请求的时候都会变成 .local

参考文档

  • RFC 6762
    • 单播响应、单响应多 service、请求去除响应冗余、请求限速
    • 具体的设计思路、原因,非常值得一看。
  • zeroconf
    • 第一个专门用于实现上述标准的组织
  • Apple Bonjour
    • 苹果的文档写的挺齐全,图文并茂
  • github.com/hashicorp/mdns
    • 具体的 mDNS golang 实现