Docker 容器走代理:注入点、作用范围与实测踩到的坑
容器既不继承宿主 shell 里
export的代理变量,也不继承系统代理设置。要把容器流量送进代理,只有有限的几个注入点,作用范围各不相同。这篇在 macOS(OrbStack 29.4.0,arm64)上把每个注入点单独跑了一遍,顺带记下几个会让人得出错误结论的坑。
「请求成功」不能证明流量走了代理:很多环境本来就能直连,唯一可信的证据是代理侧的访问日志。下面的实验全部以代理日志为准,而不是看命令返回码。
1. 先造一把尺子:会记日志的代理
要判断「有没有走代理」,得有一个只在被使用时才留下痕迹的东西。用一个 40 行的记录型 HTTP 代理就够:收到请求先把目标写进日志,再做正常的转发(CONNECT 直接打隧道,明文 HTTP 转发绝对 URI)。
1 | #!/usr/bin/env python3 |
1 | python3 proxy.py 8899 /tmp/proxy.log & # 宿主上运行,监听 8899 |
后面的每次实验都先清空日志(: > /tmp/proxy.log),跑完只看日志——日志里没有对应的 CONNECT/GET 行,就说明这次请求没有走代理,无论它是否返回 200。
2. 注入点一:容器环境变量
最直接的方式,作用域最小、最可控:
1 | docker run --rm \ |
实测下来,变量名的大小写比想象中重要:
| 变量 | curl 访问 HTTPS | curl 访问明文 HTTP |
|---|---|---|
HTTPS_PROXY(大写) |
生效 | — |
https_proxy(小写) |
生效 | — |
ALL_PROXY |
生效 | — |
HTTP_PROXY(大写) |
— | 不生效,静默直连 |
http_proxy(小写) |
— | 生效 |
HTTP_PROXY 大写对明文 HTTP 无效不是 bug:curl 手册里写明明文代理变量只认小写,大写形式在 CGI 一类环境里可能被外部注入,因此被有意忽略。这个坑的隐蔽之处在于——请求照样返回 200,因为它直连出去了:
1 | === 大写 HTTP_PROXY + 明文 http === |
其他常见客户端的表现(同样的方式逐一验证):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 | { |
实测 docker run --rm alpine:3.20 env 的输出——注意它把两套大小写都补齐了,这正是第 2 节那些大小写差异的现成解法:
1 | HTTPS_PROXY=http://host.docker.internal:8899 |
同一个配置对构建也生效,这是它比 -e 更值钱的地方:Dockerfile 里 RUN apk add、RUN curl 这类命令会拿到同样的变量。用一份最小 Dockerfile 验证,代理日志里出现了两条记录:
1 | FROM alpine:3.20 |
1 | === 配了 ~/.docker/config.json 的 proxies,--no-cache 构建 === |
不写 config.json 而想在单次构建里指定,用 --build-arg 即可:docker build --build-arg HTTPS_PROXY=... --build-arg http_proxy=... .
两个我自己踩到的坑:
- 构建缓存会伪装成「配置没生效」。第一次跑上面的对照实验时,配了代理的那次构建直接命中缓存、什么都没执行,日志自然是空的——看起来就像 proxies 不起作用。验证这类行为时一律加
--no-cache,否则你测的是缓存。 - 不要用
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 | # /etc/systemd/system/docker.service.d/proxy.conf |
1 | sudo systemctl daemon-reload && sudo systemctl restart docker |
这组配置作用于守护进程自己发起的出网请求,docker pull 是最常见的受益者。流传很广的一句话是「守护进程的环境变量会传给容器」——在 Linux 上不成立,我在一台 Ubuntu 虚拟机上实测:
1 | sudo docker pull nginx:alpine # 守护进程侧 |
1 | === 代理侧日志(拉镜像)=== |
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.org与match[2] ... => reject(见《sing-box 客户端 TUN 落地》第 6 节)。 - macOS 上只能观察到「容器出口 IP 与宿主同步变化」(TUN 连接时两者一起变、断开后一起复原),无法做出同样强度的判定:容器出网经由宿主网络栈,但是否被隧道接管没有决定性证据。
如果你要在 macOS 上依赖这个行为,按上面的方法在你自己的环境里验一次,别照抄结论。
6. 坑清单:四个让人误判的现场
6.1 容器里的 127.0.0.1 是容器自己
宿主的代理监听在 127.0.0.1:8899,容器里照抄这个地址,请求发给的是容器自己的 loopback,必然失败:
1 | docker run --rm -e http_proxy=http://127.0.0.1:8899 ... |
正确写法是从容器看宿主的地址。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 | 12:30:02 GET from=127.0.0.1:49435 target=http://host.docker.internal:8900/ |
结论:访问宿主本机的服务(数据库、本地 HTTP 服务)必须写进 no_proxy,让请求直连而不是交给代理。实测把 no_proxy=host.docker.internal,127.0.0.1,localhost 加上之后,请求成功且代理日志中没有记录——正是想要的效果:
1 | docker run --rm -e http_proxy=http://host.docker.internal:8899 -e no_proxy=host.docker.internal \ |
6.3 判断「是否走代理」看的是代理日志,不是返回码
这一条贯穿全文。直连成功、代理成功,返回码一模一样;wget 这类工具甚至会在不支持代理的情况下照样成功。
6.4 测时延/吞吐前先核验镜像架构
顺带一个测量纪律:curlimages/curl:8.11.1 在 arm64 机器上拉下来是 amd64 镜像,靠模拟运行——
1 | $ docker run --rm --entrypoint curl curlimages/curl:8.11.1 -V |
代理注入这类语义测试不受影响,但任何带时间的数字都会失真。做性能对比前先 docker inspect <img> --format '{{.Architecture}}' 核验,或者直接选明确提供 arm64 的镜像(alpine、debian 都提供)。
7. 该用哪个注入点
| 场景 | 注入点 | 作用范围 |
|---|---|---|
| 单个容器临时走代理 | docker run -e http_proxy=... -e https_proxy=... |
该容器 |
| 本机所有容器与构建 | ~/.docker/config.json 的 proxies.default |
本机 CLI 启动的容器与构建,不含 pull |
服务器上的 docker pull |
/etc/systemd/system/docker.service.d/proxy.conf |
守护进程自己发起的请求(不含容器,也不含构建) |
| 只给某次构建 | docker build --build-arg HTTPS_PROXY=... |
该次构建 |
三条实践经验:变量大小写两套都写;NO_PROXY 里必须包含内网服务与宿主地址;任何结论都以代理日志为准。
总结
- 容器不继承宿主代理,
-e、config.json、守护进程配置、--build-arg是四个注入点,作用范围依次放大 HTTP_PROXY大写对明文 HTTP 无效(curl 有意忽略),http_proxy小写才生效;git两种都认,wget/apk认小写~/.docker/config.json的proxies会自动补齐大小写两套变量,且对构建生效;docker pull只受守护进程配置影响,而守护进程的变量不会传进容器,也不进构建- 四个注入点的作用范围别记混:
-e只管一个容器,config.json管本机 CLI 启动的容器与构建,守护进程配置只管pull这类守护进程自己发起的请求 - 容器里的
127.0.0.1指容器自己;宿主上的代理解析不了host.docker.internal,所以宿主服务要进no_proxy - 判断是否走代理看代理日志;测时延前先核验镜像架构
参考资料
- Docker CLI 配置:proxies(自动注入容器与构建)
- Configure the Docker daemon to use a proxy(systemd 环境变量)
- curl:环境变量中代理变量的大小写规则
- Docker Desktop:Resources → Proxies
系列索引:网络与自建服务





