把住宅代理接进一个容器,看起来微不足道,直到它不是。你设了 HTTP_PROXY、跑起镜像,请求却仍然从宿主机 IP 出去。或者代理是通了,但你的健康检查开始失败,因为内部服务调用现在被隧道送到了另一个国家的住宅出口。又或者,最糟的那种,你的凭据被烤进了一层镜像里。
这些都不稀奇。它们正是把代理放进容器的团队会稳定踩到的四件事:配置在哪一层生效、什么该绕过它、密钥怎么进来,以及身份如何映射到容器。 这份指南逐一覆盖,配上能用的配置,写给 DevOps 和平台工程师。
能解释掉大部分困惑的那个区分
Docker 有两个完全分开的代理概念,把它们搞混,是大多数”我的代理为什么不工作”工单的根源:
- 构建时 / daemon 代理 —— 配在
~/.docker/config.json或通过--build-arg。它管的是 Docker daemon(拉镜像)和构建过程(RUN apt-get install)。它与你应用的运行时流量毫无关系。 - 运行时代理 —— 运行中容器内部的环境变量。这才是你的应用代码看得到的东西。
如果你在 ~/.docker/config.json 里配了代理,却指望你的 Python 抓取器用上它,那就是这个 bug。那些设置永远到不了容器的运行时环境。
还有一层:即便在运行时,HTTP_PROXY 也是一个约定,而非强制。内核并不会把流量往那儿路由。每个库自己决定是否遵守它:
| 客户端 | 是否遵守 HTTP_PROXY/HTTPS_PROXY? |
|---|---|
curl、wget | 是 |
Python requests、httpx | 是(默认) |
Node fetch / undici | 否,需要显式的 dispatcher/agent |
Go net/http | 是,通过 http.ProxyFromEnvironment(默认 transport) |
| Chromium / Playwright / Puppeteer | 否,需要启动参数或代理选项 |
所以第一个调试问题从来不是”环境变量设了吗?“,而是”这个客户端读它吗?“。
运行时代理:基本配置
在运行时传代理,而不是构建时,并用把定位编码进用户名的方式引用 gateway:
docker run --rm \ -e HTTP_PROXY="http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" \ -e HTTPS_PROXY="http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" \ -e NO_PROXY="localhost,127.0.0.1,postgres,redis,.internal,169.254.169.254" \ my-scraper:latest如果你对自己用的库没把握,大写和小写(HTTP_PROXY 和 http_proxy)都设上;约定各不相同,有些工具只读其中一种。注意 HTTPS_PROXY 指向的仍是一个 http:// URL,这是对的:这个 scheme 描述的是你如何与代理对话,而 HTTPS 是通过 CONNECT 在它里面被隧道传输的。
在 Compose 里,把值本身留在文件之外:
services: scraper: image: my-scraper:latest environment: HTTP_PROXY: "http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" HTTPS_PROXY: "http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" NO_PROXY: "localhost,127.0.0.1,postgres,redis,.internal" depends_on: [postgres, redis]这些变量会从你的 shell 或一个不提交进版本库的 .env 文件里插值。
NO_PROXY:人们会跳过、然后后悔的那一步
正是它造成那些让人困惑的故障。一旦设了 HTTP_PROXY,每一个遵守它的客户端都会把一切都发过代理,包括对你数据库、缓存、内部 API 以及云元数据端点的调用。后果是:内部流量离开你的网络再绕回来(慢,有时还是坏的)、健康检查失败,而且你在本不该离开宿主机的流量上烧掉了按 GB 计费的带宽。
务必设置 NO_PROXY 来覆盖:
localhost、127.0.0.1、::1- Compose/Kubernetes 的服务名(
postgres、redis、api.default.svc.cluster.local) - 内部域名和私有网段(
.internal、10.0.0.0/8) - 云元数据:
169.254.169.254
有两点值得知道:NO_PROXY 的匹配在各个库之间并不一致(尤其是 CIDR 支持参差不齐,有些按后缀匹配,有些则要求前导点),所以要去验证,而不是假定。另外在 Kubernetes 里,.svc.cluster.local 以及你的 pod/service CIDR 也该放进 NO_PROXY。
密钥:别把凭据烤进镜像
代理凭据是密钥。规则如下:
绝不要通过 ENV 或 ARG 把它们放进 Dockerfile,两者都会留在镜像层里,而 docker history 会乐呵呵地把它们打印出来。任何能拉到这个镜像的人,都拿到了你的凭据。
改为在运行时注入。Compose 用一个不进 git 的 env 文件;编排环境则用真正的密钥存储:
# Kubernetes:凭据来自 Secret,而不是清单文件env: - name: SHIFTER_USER valueFrom: secretKeyRef: { name: proxy-creds, key: username } - name: SHIFTER_PASS valueFrom: secretKeyRef: { name: proxy-creds, key: password }然后在应用内部用这两个变量拼出代理 URL,这样完整的凭据串就永远不会出现在清单、日志行或 docker inspect 里。如果你在构建时需要代理(装包),用 BuildKit secrets(--mount=type=secret)而不是 ARG,好让什么都不落进镜像层。
另外:把日志里的代理 URL 清洗掉。一次打印了生效配置的崩溃转储,会把 user:pass@host 直接泄进你的日志聚合系统。
把身份映射到容器
这里正是容器架构与代理架构相遇之处。因为定位就活在用户名里,每个容器都可以带上它自己的身份,只需拿到一个不同的环境变量,不需要单独的端点,也不需要 IP 列表。
两个模式覆盖大多数需求:
一个容器,一个地理。 通过改变国家标志来运行按市场的 worker:
docker run -d -e HTTP_PROXY="http://${U}-country-us:${P}@p.shifter.io:443" scraper:latestdocker run -d -e HTTP_PROXY="http://${U}-country-de:${P}@p.shifter.io:443" scraper:latest一个容器,一个粘性会话。 给每个副本一个不同的 sid,好让它守住自己的出口 IP,这在一个容器拥有一整套多步流程时很有用:
services: worker: image: scraper:latest environment: # {{.Task.Slot}} 给每个 Swarm 副本一个唯一且稳定的会话 id HTTP_PROXY: "http://${U}-country-us-sid-w{{.Task.Slot}}-ttl-600:${P}@p.shifter.io:443" deploy: replicas: 4一句提醒:容器级的环境变量,是这个容器生命周期内的一个静态身份。如果你的工作负载需要按请求或按工作单元轮换,别靠重启容器去假装,把代理放到应用代码里,在那里你可以按 job 变更 sid(就是负载均衡那篇里的模式)。环境变量是做粗粒度、按容器身份的正确工具;代码才是做细粒度轮换的正确工具。
会无视环境变量的客户端
在容器里你会撞上的两个常见情形:
Node 的 fetch/undici 不读代理环境变量。要显式接上:
import { ProxyAgent, setGlobalDispatcher } from "undici";setGlobalDispatcher(new ProxyAgent(process.env.HTTP_PROXY));无头浏览器同样会无视它们,Chromium 需要在启动时传入代理,凭据则要通过框架自己的机制处理(Playwright 讲了细节,包括为什么内联凭据在 Chromium 里会失败)。Python 的 requests/httpx 确实遵守环境变量,不过显式传 proxies 更清楚(Python 指南)。
还值得一提:在容器里,通过环境变量走 SOCKS5 在各客户端之间并不可靠。对大多数容器工作负载,HTTP 代理是正确的默认(SOCKS5 的取舍)。
从容器内部去验证
永远别假定,从运行中的容器内部去查出口 IP:
docker exec -it my-scraper sh -c \ 'curl -s http://ip-api.com/json | head -c 200'# 期望是目标国家的一个住宅 IP,而不是你的宿主机 IP。如果它返回的是你的宿主机 IP,那就是客户端没有遵守环境变量(见上面的表)。把这一条作为启动断言加进 staging,好让一次配错的部署大声失败,而不是悄悄地用你的数据中心 IP 去抓取;并通过确认一次内部服务调用没有穿过代理,来验证 NO_PROXY 是生效的。更广的测量方法见如何测试代理的速度、成功率与位置准确度。
会浪费你一个下午的容器坑
localhost指的是容器自己。 宿主机上的代理在容器内用127.0.0.1是够不着的;用host.docker.internal(Docker Desktop)或宿主机的网络地址。- 构建时代理和运行时代理是两回事。 设了一个不等于设了另一个。
- 改环境变量需要重建容器。 在 Compose 里改 environment 需要
up --force-recreate,而不是 restart。 - DNS 解析发生的位置各不相同。 有些客户端本地解析,有些让代理去解析。如果地理敏感的结果看起来不对,这是个嫌疑点。
- 镜像可能缺 CA 证书。 slim/alpine 基础镜像有时需要装上
ca-certificates,HTTPS 经代理才能通过校验。 - 按 GB 计费是按容器算的。 十个副本各自拉整页,会把你的带宽乘以十,削减带宽成本是按副本适用的。
常见问题
我明明设了 HTTP_PROXY,容器为什么不走代理?
因为 HTTP_PROXY 是一个约定,不是路由。得由库去遵守它。Node 的 fetch 和无头浏览器不遵守;curl、Python 的 requests、Go 的默认 transport 则遵守。先查你的客户端,然后从容器内部确认出口 IP。
我该把代理写进 Dockerfile 吗?
不。ENV/ARG 的值会留在镜像层里并出现在 docker history 中,把凭据泄露给任何能拉到镜像的人。改为在运行时通过来自密钥存储的环境变量注入;若构建时确实需要代理,用 BuildKit secrets。
我怎么阻止内部流量走代理?
设置 NO_PROXY,包含 localhost、你的服务名、内部域名、私有网段,以及 169.254.169.254。否则数据库和健康检查的流量会被隧道送出、经由一个住宅出口,既慢、又脆,还要计费。
每个容器能有不同的 IP 或国家吗? 能。定位编码在代理用户名里,所以每个容器一个不同的环境变量,就给了各自的国家或粘性会话,不需要额外端点。要按请求轮换,则把代理放进应用代码。
代理对 docker build 生效吗?
只有当你单独配置了构建/daemon 代理(~/.docker/config.json 或 build args)才生效。容器的运行时环境变量不影响构建,而构建时凭据也不该用 ARG 传。
底线
大多数 Docker 代理问题归结为四件事。搞清配置在哪一层生效(构建 vs 运行时,以及哪些客户端根本就不读环境变量)、设好 NO_PROXY 让内部流量留在内部、把凭据挡在镜像层之外并在运行时注入,以及有意识地决定身份如何映射到容器(环境变量用于粗粒度的按容器身份,应用代码用于按请求轮换)。然后从容器内部去验证出口 IP,而不是信任配置。
把这些做对,容器化采集就会以最好的方式变得无聊。住宅 gateway 在这里帮得上忙,因为定位是随用户名一起走的:一个端点,任何容器的地理或会话,就只是一个不同的环境变量而已。池的质量依然决定着你到底会不会频繁重试(IP 信誉);定价页面有按 GB 计费的套餐,值得记住的是,带宽会随你的副本数一起扩大。