本文只从原理上阐述几种不同方案,对比它们的优劣,讨论一些存在的问题,并不涉及实际配置和操作
一个两难的局面
由于经常需要拉取 docker 镜像(image),而 ghcr (GitHub container registry) 和 gcr (Google container registry) 在国内都没有镜像(mirror)仓库,并且一些应用在运行时会访问 github 检查更新、从 huggingface 下载模型,我需要在服务器上配置网络代理。
代理配置通常不是难事。在桌面操作系统上,不论是 Windows、Linux desktop 还是 macOS,系统设置中都有一个网络代理的选项,大部分软件都会遵循这里的设置。而对于那些不遵循系统代理的软件,一些代理客户端也提供了“TUN模式”,开启一张虚拟网卡,拦截系统网络栈的所有出站流量,从而使应用程序无感,达到“透明代理“的效果。这两种情形下应用程序访问网络的过程如下图所示:
图 应用程序直接使用代理
图 透明代理
没有任何问题,一切运作正常。但是当来到服务器系统上,情况就不太一样了。
首先,不像桌面操作系统,服务器上可没有系统设置这种东西,无法配置全局代理,只能给每一个需要使用代理的软件手动设置。不论是系统服务,还是独立运行的程序,又或是 docker 容器,嗯,别嫌多,一个一个配。其次,如果使用“透明代理”方式,修改系统的路由、拦截网络流量的行为对系统的侵入性太大,假如某一天代理客户端出现故障,我需要通过网络连接进服务器关闭代理,但是只有先关闭代理才能恢复网络从而连上服务器。更别提配置过程中一个不小心就能让服务器失联。
于是卡在一个两难的处境,一边是繁琐,一边是不可靠。
打破困境的 fake-IP
前段时间一个代理工具 Clash 将 "fake IP" 的概念带入人们的视野[1]。"fake IP" 出自 2001 年的 RFC 3089[2],用于让 IPv4 的机器与 IPv6 的机器能够建立连接。而这一机制,恰好成为了打破两难处境的关键。
[1] 使用 fake IP 的代理的工作流程可以参考这两篇文章:浅谈在代理环境中的 DNS 解析行为 | Sukka's Blog、Clash DNS | Clash 知识库
[2] RFC 3089: A SOCKS-based IPv6/IPv4 Gateway Mechanism
域名系统在现代网络中的地位不言而喻,应用程序发起连接的过程几乎都开始于域名解析。得益于这一点,只要在域名解析上动动手脚,将域名服务器设置为开启了 fake IP 功能的代理客户端的内置 DNS,就能在不改变系统默认路由的情况下,实现和“透明代理”类似的效果。在这种情况下,系统网络栈并没有被篡改,因此被送往代理的只有服务器系统中应用程序对外发起的连接,而客户端向服务器建立的连接则不受影响,即使代理出现故障,依旧不影响服务器正常提供服务。
图 fake IP 式代理
这似乎是一个完美的方案,既不危害系统的稳定,配置起来又简单。吗?
域名解析可不是一个系统调用
[3] 此处借用了 Anatomy of a Linux DNS Lookup – Part I 一文中第一部分的标题 “There is no such thing as a ‘DNS Lookup’ call”。这也是一篇很棒的介绍 Linux 系统中 DNS 解析过程的文章
事实证明,让所有应用程序都使用用户指定的 DNS 并没有那么简单。虽然大部分应用程序会使用系统提供的域名解析功能,但在不同 Linux 发行版、甚至同一个发行版的不同版本上,系统域名解析的过程都有所不同。在近几年的 Ubuntu 系统中,域名解析由 systemd-resolved 负责,只需要将它指向代理客户端的内置 DNS,应用程序发起的 DNS 查询请求就会在这里被转交给代理客户端。另外,在实际操作过程中还发现,Ubuntu 20.04 (systemd 245.4-4ubuntu3.22) 的配置文件不支持指定 DNS 的端口号,因此只能使用默认的 53 端口,而在 Ubuntu 22.04 (systemd 249.11-0ubuntu3.7) 中则没有这个限制。
然而 DNS 可不像系统网络栈,不存在“必经之路”,应用程序完全可以自己构造 DNS 查询的 IP 数据包,绕开系统的域名解析服务。即便通常情况下应用程序不会、也没必要这样做,可是当应用程序运行在容器中时,情况又变得更加复杂了。容器是一个隔离的环境,有独立的域名解析流程,不过好在 docker 通过在容器中挂载 /etc/hostname
、/etc/hosts
和 /etc/resolv.conf
三个文件可以控制这一过程[4]。对于使用默认的 bridge 网络的容器,可以通过 docker 命令参数直接修改这些挂载文件中的内容[4],而对于连接到自定义网络的容器,/etc/resolv.conf
文件中设置的域名服务器地址为 127.0.0.11,这是 docker 内置的 DNS,用于提供服务发现功能,它会将外部 DNS 查询转交给宿主机系统的域名解析服务[5]。
[4] Configure container DNS - Docker
[5] Networking overview | Docker Docs (DNS services section): Containers that attach to a custom network use Docker's embedded DNS server. The embedded DNS server forwards external DNS lookups to the DNS servers configured on the host.
不可避免的妥协
至此,一个基于 fake-IP DNS 服务的非侵入式半透明代理,才算是大致说清楚了。
所以它配置起来简单吗?毕竟不侵入网络栈内部,自然就做不到完全的“透明”,应用程序发起 DNS 查询的方式千奇百怪,系统提供域名解析服务的实现也五花八门,面对不同的场景,需要不同的设置来使 DNS 请求发往代理客户端,这可一点也谈不上简单。那它稳定吗?当代理客户端故障时,DNS 也停止工作,服务器内应用程序向外发起的连接将胎死腹中。虽然这个问题可以通过设置备用域名服务器很轻易地解决,但是 systemd-resolved 并没有提供设置主备的功能,就目前情况而言,也算不上很稳定。
但在实际使用场景中,如果运行的软件都调用系统的域名解析服务,容器都连接在自定义的网络上,那只需要让系统的域名解析服务指向代理客户端的内置 DNS 就算完成了配置,相比每一个应用单独设置代理还是简化了许多。更重要的是,它不会增加断联的风险,代理故障并不影响外部对服务器的连接,我随时可以连进去维修、调整。总之,这个方案不尽完美,但还说得过去。
注
本文中的插图使用 Excalidraw 工具制作,图中的一些元素来自 @pratheeshpm 和 @Mateusz Baran 在 Excalidraw Libraries 上的分享。