容器既不继承宿主 shell 里 export 的代理变量,也不继承系统代理设置。要把容器流量送进代理,只有有限的几个注入点,作用范围各不相同。这篇在 macOS(OrbStack 29.4.0,arm64)上把每个注入点单独跑了一遍,顺带记下几个会让人得出错误结论的坑。
「请求成功」不能证明流量走了代理:很多环境本来就能直连,唯一可信的证据是代理侧的访问日志。下面的实验全部以代理日志为准,而不是看命令返回码。

1. 先造一把尺子:会记日志的代理

要判断「有没有走代理」,得有一个只在被使用时才留下痕迹的东西。用一个 40 行的记录型 HTTP 代理就够:收到请求先把目标写进日志,再做正常的转发(CONNECT 直接打隧道,明文 HTTP 转发绝对 URI)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#!/usr/bin/env python3
"""记录型 HTTP 代理:记录每条请求后正常转发,用于判断流量是否经过代理"""
import asyncio, sys, time

LOG, PORT = (sys.argv[2] if len(sys.argv) > 2 else "/tmp/proxy.log"), int(sys.argv[1] if len(sys.argv) > 1 else 8899)

async def log(line):
with open(LOG, "a") as f:
f.write(f"{time.strftime('%H:%M:%S')} {line}\n")

async def pump(r, w):
try:
while (data := await r.read(65536)):
w.write(data); await w.drain()
except Exception:
pass
finally:
try: w.close()
except Exception: pass

async def handle(reader, writer):
peer = writer.get_extra_info("peername")
try:
head = await asyncio.wait_for(reader.readline(), 15)
if not head:
return
request_line = head.decode("latin-1").strip()
while (h := await asyncio.wait_for(reader.readline(), 15)) not in (b"\r\n", b"\n", b""):
pass
method, target = request_line.split()[0], request_line.split()[1]
await log(f"{method:6} from={peer[0]}:{peer[1]} target={target}")
if method == "CONNECT":
host, _, port = target.partition(":")
up = await asyncio.wait_for(asyncio.open_connection(host, int(port or 443)), 15)
writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
await writer.drain()
await asyncio.gather(pump(reader, up[1]), pump(up[0], writer))
except Exception as e:
await log(f"ERR {type(e).__name__}: {e}")
finally:
try: writer.close()
except Exception: pass

async def main():
server = await asyncio.start_server(handle, "0.0.0.0", PORT)
await log(f"--- proxy listening on {PORT} ---")
async with server:
await server.serve_forever()

asyncio.run(main())
1
python3 proxy.py 8899 /tmp/proxy.log &     # 宿主上运行,监听 8899

后面的每次实验都先清空日志(: > /tmp/proxy.log),跑完只看日志——日志里没有对应的 CONNECT/GET 行,就说明这次请求没有走代理,无论它是否返回 200。

2. 注入点一:容器环境变量

最直接的方式,作用域最小、最可控:

1
2
3
4
docker run --rm \
-e http_proxy=http://host.docker.internal:8899 \
-e https_proxy=http://host.docker.internal:8899 \
alpine:3.20 sh -c 'apk add --no-cache curl >/dev/null; curl -s -o /dev/null -w "%{http_code}\n" https://example.com'

实测下来,变量名的大小写比想象中重要:

变量 curl 访问 HTTPS curl 访问明文 HTTP
HTTPS_PROXY(大写) 生效
https_proxy(小写) 生效
ALL_PROXY 生效
HTTP_PROXY(大写) 不生效,静默直连
http_proxy(小写) 生效

HTTP_PROXY 大写对明文 HTTP 无效不是 bug:curl 手册里写明明文代理变量只认小写,大写形式在 CGI 一类环境里可能被外部注入,因此被有意忽略。这个坑的隐蔽之处在于——请求照样返回 200,因为它直连出去了:

1
2
3
4
=== 大写 HTTP_PROXY + 明文 http ===
http=200 # 代理日志:无记录
=== 小写 http_proxy + 明文 http ===
http=200 # 代理日志:GET from=127.0.0.1:49467 target=http://example.com/

其他常见客户端的表现(同样的方式逐一验证):git 认大小写两种 HTTPS_PROXY/https_proxy;BusyBox 的 wget、Alpine 的 apk 认小写;apk 通过小写 http_proxy 时,代理日志里能看到 dl-cdn.alpinelinux.org:443 的 CONNECT。结论很简单:两个大小写都写上,别赌。

3. 注入点二:~/.docker/config.json 的 proxies

不想每个 docker run 都敲一遍 -e,可以在 CLI 配置里声明,CLI 会在启动容器时自动把它注入成环境变量:

1
2
3
4
5
6
7
8
9
{
"proxies": {
"default": {
"httpProxy": "http://host.docker.internal:8899",
"httpsProxy": "http://host.docker.internal:8899",
"noProxy": "localhost,127.0.0.1,.internal"
}
}
}

实测 docker run --rm alpine:3.20 env 的输出——注意它把两套大小写都补齐了,这正是第 2 节那些大小写差异的现成解法:

1
2
3
4
5
6
HTTPS_PROXY=http://host.docker.internal:8899
HTTP_PROXY=http://host.docker.internal:8899
NO_PROXY=localhost,127.0.0.1,.internal
http_proxy=http://host.docker.internal:8899
https_proxy=http://host.docker.internal:8899
no_proxy=localhost,127.0.0.1,.internal

同一个配置对构建也生效,这是它比 -e 更值钱的地方:Dockerfile 里 RUN apk addRUN curl 这类命令会拿到同样的变量。用一份最小 Dockerfile 验证,代理日志里出现了两条记录:

1
2
FROM alpine:3.20
RUN apk add --no-cache curl >/dev/null && curl -s -o /dev/null -w 'curl_in_build=%{http_code}\n' https://example.com
1
2
3
4
5
=== 配了 ~/.docker/config.json 的 proxies,--no-cache 构建 ===
12:32:44 CONNECT from=127.0.0.1:49582 target=dl-cdn.alpinelinux.org:443
12:32:48 CONNECT from=127.0.0.1:49589 target=example.com:443
=== 没有配置,--no-cache 构建 ===
(代理日志为空)

不写 config.json 而想在单次构建里指定,用 --build-arg 即可:docker build --build-arg HTTPS_PROXY=... --build-arg http_proxy=... .

两个我自己踩到的坑:

  1. 构建缓存会伪装成「配置没生效」。第一次跑上面的对照实验时,配了代理的那次构建直接命中缓存、什么都没执行,日志自然是空的——看起来就像 proxies 不起作用。验证这类行为时一律加 --no-cache,否则你测的是缓存。
  2. 不要用 DOCKER_CONFIG 指向临时目录来「干净地」测试。它覆盖的是整个 CLI 配置目录,连 currentContext 一起丢;在 OrbStack 上 CLI 会回落到 unix:///var/run/docker.sock,直接报 failed to connect to the docker API。要临时隔离配置,记得把 context 也带上,或者直接改真实文件再改回来。

4. 注入点三:守护进程级(Linux 服务器上的做法)

上面两个注入点都只管「运行容器」和「构建镜像」。拉镜像(docker pull)由守护进程发起,它读不到你 shell 里的变量,也不理会 ~/.docker/config.json。在服务器上这一步最常见,官方给的做法是给 systemd 加环境变量:

1
2
3
4
5
# /etc/systemd/system/docker.service.d/proxy.conf
[Service]
Environment="HTTP_PROXY=http://127.0.0.1:10809/"
Environment="HTTPS_PROXY=http://127.0.0.1:10809/"
Environment="NO_PROXY=localhost,127.0.0.1,127.0.0.0/8,docker-registry.example.com,.corp"
1
sudo systemctl daemon-reload && sudo systemctl restart docker

这组配置作用于守护进程自己发起的出网请求docker pull 是最常见的受益者。流传很广的一句话是「守护进程的环境变量会传给容器」——在 Linux 上不成立,我在一台 Ubuntu 虚拟机上实测:

1
2
sudo docker pull nginx:alpine          # 守护进程侧
sudo docker run --rm alpine:3.20 env # 容器侧
1
2
3
4
5
6
7
=== 代理侧日志(拉镜像)===
12:43:26 CONNECT from=127.0.0.1:57970 target=registry-1.docker.io:443
12:43:27 CONNECT from=127.0.0.1:57972 target=auth.docker.io:443
12:43:29 CONNECT from=127.0.0.1:57984 target=registry-1.docker.io:443

=== 容器的完整 env ===
HOME=/root HOSTNAME=0e2cf1ee29c1 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

docker build 同理:BuildKit 里的 RUN 不继承守护进程的变量。给守护进程配好代理之后,构建里的 wget https://example.com 在代理日志里查不到任何记录(只有守护进程自己的 registry 流量在走代理),构建期要另外用第 3 节的 ~/.docker/config.json--build-arg

NO_PROXY 仍然要认真写,它服务的是守护进程:漏掉私有 registry 域名时,docker pull 会拿代理去访问一个只能内网到达的地址,然后超时。

这一节在一次性 Ubuntu 虚拟机(OrbStack machines,arm64)上完整跑过:docker pull 经过代理、容器与构建拿不到变量。之所以没有用本机 macOS 验证,是因为 dockerd 跑在 Linux 侧,systemd drop-in 只在那里生效。

5. macOS:容器和宿主代理的关系

macOS 上的 Docker(OrbStack 或 Docker Desktop)都跑在一个 Linux 虚拟机里,容器出网走的是虚拟机的网络栈,再经宿主的网络出口。本机(OrbStack 29.4.0)实测:

  • 容器不继承任何代理环境变量:docker run --rm alpine:3.20 env 里没有任何 *_proxy,即使宿主 shell 里 export 过;
  • 容器可以直连外网(wget https://example.com 成功),并原生解析 host.docker.internal 到宿主。

Docker Desktop 的等价物是 Settings → Resources → Proxies,它配置的是虚拟机与守护进程层面的代理,同样会影响镜像拉取;我没有在这台机器上运行 Docker Desktop,这一条未做对照实测。

还有一个常被问到的问题:宿主开了 TUN 模式的代理(如 sing-box 的 tun inbound)之后,容器会跟着走吗?

  • Linux 上的答案是会,而且有决定性证据:宿主 TUN 里写一条 example.org => reject,容器内访问该域名同样失败,sing-box 日志还留下了 sniffed ... domain: example.orgmatch[2] ... => reject(见《sing-box 客户端 TUN 落地》第 6 节)。
  • macOS 上只能观察到「容器出口 IP 与宿主同步变化」(TUN 连接时两者一起变、断开后一起复原),无法做出同样强度的判定:容器出网经由宿主网络栈,但是否被隧道接管没有决定性证据。

如果你要在 macOS 上依赖这个行为,按上面的方法在你自己的环境里验一次,别照抄结论。

6. 坑清单:四个让人误判的现场

6.1 容器里的 127.0.0.1 是容器自己

宿主的代理监听在 127.0.0.1:8899,容器里照抄这个地址,请求发给的是容器自己的 loopback,必然失败:

1
2
docker run --rm -e http_proxy=http://127.0.0.1:8899 ... 
# http=000

正确写法是从容器看宿主的地址。macOS 上(OrbStack / Docker Desktop)直接可用 host.docker.internal;Linux 上通过 --add-host=host.docker.internal:host-gateway 也能拿到同一个名字。更稳的做法是用宿主的局域网 IP,但容器重启后 IP 变化会让配置失效。

6.2 代理自己也解析不了 host.docker.internal

把代理跑在宿主上时,host.docker.internal 这个名字只有容器侧的网络栈认识,宿主的代理进程不认识。明文 HTTP 请求又是把目标原样交给代理去连的,于是代理侧报解析失败:

1
2
12:30:02 GET    from=127.0.0.1:49435 target=http://host.docker.internal:8900/
12:30:02 ERR gaierror: [Errno 8] nodename nor servname provided, or not known

结论:访问宿主本机的服务(数据库、本地 HTTP 服务)必须写进 no_proxy,让请求直连而不是交给代理。实测把 no_proxy=host.docker.internal,127.0.0.1,localhost 加上之后,请求成功且代理日志中没有记录——正是想要的效果:

1
2
3
docker run --rm -e http_proxy=http://host.docker.internal:8899 -e no_proxy=host.docker.internal \
alpine:3.20 sh -c 'apk add --no-cache curl >/dev/null; curl -s -o /dev/null -w "%{http_code}\n" http://host.docker.internal:8900/'
# http=200,代理日志无记录

6.3 判断「是否走代理」看的是代理日志,不是返回码

这一条贯穿全文。直连成功、代理成功,返回码一模一样;wget 这类工具甚至会在不支持代理的情况下照样成功。

6.4 测时延/吞吐前先核验镜像架构

顺带一个测量纪律:curlimages/curl:8.11.1 在 arm64 机器上拉下来是 amd64 镜像,靠模拟运行——

1
2
$ docker run --rm --entrypoint curl curlimages/curl:8.11.1 -V
curl 8.11.1 (x86_64-pc-linux-musl) libcurl/8.11.1 OpenSSL/3.3.2 ...

代理注入这类语义测试不受影响,但任何带时间的数字都会失真。做性能对比前先 docker inspect <img> --format '{{.Architecture}}' 核验,或者直接选明确提供 arm64 的镜像(alpinedebian 都提供)。

7. 该用哪个注入点

场景 注入点 作用范围
单个容器临时走代理 docker run -e http_proxy=... -e https_proxy=... 该容器
本机所有容器与构建 ~/.docker/config.jsonproxies.default 本机 CLI 启动的容器与构建,不含 pull
服务器上的 docker pull /etc/systemd/system/docker.service.d/proxy.conf 守护进程自己发起的请求(不含容器,也不含构建)
只给某次构建 docker build --build-arg HTTPS_PROXY=... 该次构建

三条实践经验:变量大小写两套都写;NO_PROXY 里必须包含内网服务与宿主地址;任何结论都以代理日志为准。

总结

  • 容器不继承宿主代理,-econfig.json、守护进程配置、--build-arg 是四个注入点,作用范围依次放大
  • HTTP_PROXY 大写对明文 HTTP 无效(curl 有意忽略),http_proxy 小写才生效;git 两种都认,wget/apk 认小写
  • ~/.docker/config.jsonproxies 会自动补齐大小写两套变量,且对构建生效;docker pull 只受守护进程配置影响,而守护进程的变量不会传进容器,也不进构建
  • 四个注入点的作用范围别记混:-e 只管一个容器,config.json 管本机 CLI 启动的容器与构建,守护进程配置只管 pull 这类守护进程自己发起的请求
  • 容器里的 127.0.0.1 指容器自己;宿主上的代理解析不了 host.docker.internal,所以宿主服务要进 no_proxy
  • 判断是否走代理看代理日志;测时延前先核验镜像架构

参考资料

系列索引:网络与自建服务