1. 一次 CI 流水线构建失败,把 Docker 网络配置这件事彻底摊开了
去年下半年,我帮一个团队梳理他们的容器化构建流程。事情本身不复杂:本地笔记本上docker build一路绿灯,推到 CI 机器上,卡在第一步FROM拉基础镜像,日志里只有一行 timeout,重试三次之后流水线直接挂掉。负责的同学第一反应是"网络抖动",加了重试脚本,结果第二天又挂。等到我介入的时候,他们已经在流水线里堆了七层重试和三次换源,问题依然是偶发的。
这个场景在容器化落地的过程中太常见了。Docker 的网络配置不是"配一次就完事"的东西,它至少分散在四个彼此不通信的层次上:宿主机 shell、容器守护进程、构建上下文、容器运行时。你改对了其中一层,另外三层可能还在用旧值,于是表现出来就是"明明配了却不生效"。更麻烦的是,这类问题往往不是硬失败,而是偶发超时、间歇性拉取失败、某个镜像能拉某个不能拉——排查成本极高,因为它看起来像"网络不稳定",实际上是配置分层没对齐。
这篇内容我想做的事情很具体:把 Docker 网络出口配置这件事从"抄一段配置贴上去"提升到"知道每一层分别在管什么"。我会讲清楚配置到底有哪几层、每一层改动的生效范围和副作用、构建期和运行期为什么是两套完全不同的逻辑,以及几种我在实际项目里踩过、也确实让同事熬过夜的坑。适合已经有基本 Docker 使用经验、正在处理企业内网环境或者 CI 流水线网络问题的读者。如果你只是本地跑跑 demo,前面的部分可以快速扫过,直接从第 4 节开始看,那里的坑和本地环境同样相关。
需要先说明一点:本文讨论的"出口配置"限定在企业内网访问公共镜像仓库、以及容器访问私有镜像仓库这两个场景,也就是常见的 HTTP_PROXY / HTTPS_PROXY / NO_PROXY 这一套环境变量体系。所有的配置方法和排查思路都建立在标准的企业网络环境假设之上,不涉及任何其他用途。
2. 配置分散在四层,先认清你改的是哪一层
在动手改任何文件之前,我建议先建立一个心智模型:Docker 世界里,网络出口相关的配置至少存在于四个独立的层次,每一层的生效范围、优先级、以及"改完之后需要做什么才能生效"都不一样。很多人出问题,根本原因不是配置写错了,而是把某一层的配置误以为会影响到另一层。
2.1 宿主机 shell 层:只影响命令行客户端
你在终端里export HTTPS_PROXY=...之后,这个变量的作用域是当前 shell 会话及其子进程。docker命令行工具是子进程,所以它能读到;dockerd守护进程通常是由 systemd 拉起的独立进程,跟你的 shell 没有父子关系,所以它读不到。
这一层最典型的误用是这样:有人在终端里 export 了变量,docker pull成功了,就以为"配置好了"。结果换了台机器、或者把命令写进 crontab 里跑,立刻失败。因为 crontab 的执行环境是干净的,没有那些 export。
还有一种是docker build的情况。Docker CLI 有一个预定义构建参数的机制:如果客户端环境里存在HTTP_PROXY、HTTPS_PROXY、NO_PROXY、FTP_PROXY、ALL_PROXY这几个变量,CLI 会自动把它们作为构建参数传给构建过程。这个行为很容易让人产生"构建期会自动继承环境变量"的错觉——真相是只有这几个特定名字被特殊处理了,你自定义的MY_REGISTRY_TOKEN之类完全不会传进去。
2.2 守护进程层:管拉取镜像、管容器默认网关
dockerd负责的事情比很多人想象的多:它负责从仓库拉取镜像、负责创建容器网络、负责把容器的 DNS 配置写进/etc/resolv.conf。所以这一层的配置包含两类内容——出口转发配置(用于拉镜像)和 DNS 配置(用于容器内部解析)。
守护进程层有两个可能的位置可以配置出口,一个是 systemd 的 service 覆盖文件,一个是/etc/docker/daemon.json。这两者的关系在第 3 节会细讲,这里先记住一点:它们不是等价的,覆盖文件里的环境变量优先级更高,而且 daemon.json 里的proxies字段需要较新版本的 Docker 才支持。
守护进程层还有一个容易被忽略的点:改完配置必须重启服务。systemctl reload docker在某些发行版上并不是完整重启,proxies配置只在守护进程启动时读取一次。我见过不止一次"改完配置没生效"的案例,最后发现就是只 reload 没有 restart。
2.3 构建上下文层:跟宿主机环境彻底隔离
docker build执行RUN指令的时候,命令跑在一个临时容器里,这个容器的网络环境跟你宿主机完全无关。宿主机能访问的地址,容器里不一定能访问;宿主机上的环境变量,容器里一个都没有。
这一层最需要理解的概念是构建时网络模式。默认情况下构建容器使用 bridge 网络,跟普通容器一样需要出口配置。docker build --network=host会让构建容器直接使用宿主机网络栈,这时候容器里的网络行为跟宿主机一致,某些内网场景下能省掉一堆配置——但代价是失去了构建环境的隔离性,构建步骤里的网络操作可能影响到宿主机。
2.4 容器运行时层:完全独立,必须显式传递
docker run启动的容器,环境变量需要显式用-e传,网络出口配置也一样。很多人有一个思维惯性:既然 Docker 守护进程配了出口,容器应该也能用吧?不行。守护进程的出口配置只作用于守护进程自己(拉镜像),容器内部的进程走的是容器网络,出口配置需要另行传递。
把这四层整理成一张表,对比一下会清晰很多:
| 层次 | 配置位置 | 影响范围 | 生效条件 |
|---|---|---|---|
| 宿主机 shell | export/.bashrc | 命令行客户端及其子进程 | 新开终端或 source |
| 守护进程 | systemd override /daemon.json | 拉取镜像、容器默认 DNS | 重启 dockerd |
| 构建上下文 | CLI 环境变量 /--build-arg/--network | RUN指令内的网络行为 | 每次构建重新计算 |
| 容器运行时 | docker run -e/ composeenvironment | 容器内进程 | 容器重建 |
这张表我建议直接贴到团队文档里。后面所有的坑,本质上都是"以为改了 A 层就能影响 B 层"。
3. 守护进程层的两套配置路径,以及它们的优先级关系
守护进程层的出口配置是整个体系里最绕的部分,因为它有两条完全不同的配置路径,写法不同、生效机制不同、支持版本也不同。搞清楚这块,能省掉后面一大半的困惑。
3.1 systemd 覆盖文件:最通用但也最容易配错
主流 Linux 发行版上,Docker 由 systemd 托管。给守护进程注入环境变量的标准做法是创建一个 override 文件:
sudo mkdir -p /etc/systemd/system/docker.service.d sudo tee /etc/systemd/system/docker.service.d/http-proxy.conf > /dev/null <<'EOF' [Service] Environment="HTTP_PROXY=http://gateway.internal:8080" Environment="HTTPS_PROXY=http://gateway.internal:8080" Environment="NO_PROXY=localhost,127.0.0.1,*.internal,10.0.0.0/8,192.168.0.0/16" EOF sudo systemctl daemon-reload sudo systemctl restart docker这段配置有几个细节值得单独说。
daemon-reload和restart docker两步都不能省。前者让 systemd 重新读取 unit 文件,后者让 dockerd 进程重新启动并加载新环境。只做前者,进程还在跑旧环境;只做后者不做前者,systemd 还没看到新文件。我踩过这个坑:当时改完文件直接systemctl restart docker,以为 restart 会自动 reload,结果发现某些 systemd 版本下确实不会。
Environment=每行写一个变量,不要尝试在一行里写多个用空格分隔,那样会被当做一个整体值。这个语法细节看起来很小,但错了之后表现为"变量存在但值不对",排查起来很费时间。
NO_PROXY里我用的是精确列举而不是*通配。这一点在第 6 节会展开讲,简单说就是通配符的匹配规则在 Go 的httpproxy实现里有不少边界情况,用*图省事的后果可能是内网地址反而被转发出去。
3.2 daemon.json 的 proxies 字段:更干净但有版本门槛
从 Docker 23.0 开始,/etc/docker/daemon.json支持直接写proxies配置:
{ "proxies": { "http-proxy": "http://gateway.internal:8080", "https-proxy": "http://gateway.internal:8080", "no-proxy": "localhost,127.0.0.1,*.internal,10.0.0.0/8" } }这套写法的好处是集中管理。daemon.json里还可以写dns、registry-mirrors、insecure-registries等等,把容器相关的配置都放在一个文件里,比散落在 systemd override 里更清爽。
但要注意两点。第一是版本门槛,老版本 Docker 会直接忽略这个字段,不报错,也不生效——这个行为特别坑,因为配置文件看起来完全正常。改动之前先docker version确认守护进程版本。第二是proxies里的 key 用的是连字符形式(http-proxy),而 systemd 里用的是下划线大写形式(HTTP_PROXY),两套命名不要混。
还有一个小细节:daemon.json里如果 JSON 格式写错了,比如多了个尾逗号,dockerd 启动会失败。所以改完这个文件之后,一定要在重启之前用python -m json.tool /etc/docker/daemon.json或者jq验证一遍,别等重启失败导致整个 Docker 服务起不来。
3.3 两套配置同时存在时会怎样
这是最需要说清楚的一点。如果 systemd override 里配了HTTP_PROXY,daemon.json里也配了proxies,结果是什么?
实测下来的结论是:环境变量优先。守护进程在初始化时会先读取proxies配置,但如果环境里已经存在对应的变量,环境变量会覆盖配置文件的值。也就是说 systemd override 赢。
这个优先级设计本身是合理的(环境变量通常被视为更"临时"、更"上层"的覆盖手段),但实际运维里会带来一个经典问题:某次临时排查问题,在 override 里加了一行Environment="HTTPS_PROXY=...",问题解决了,文件忘记删,半年之后另一个人改daemon.json怎么改都不生效,因为一直有一行遗留的 override 在压着。
我的做法是:团队里只允许一套路径。要么所有人在daemon.json里配,要么统一用 systemd override。混着来一定会出事。检查是否有多余配置的命令很简单:
systemctl show docker --property=Environment docker info | grep -i -A5 proxy第一条看 systemd 注入了什么,第二条看守护进程实际认为自己在用什么(较新版本 Docker 的docker info会输出 Proxy 相关字段)。
4. 构建期才是重灾区:环境变量不会自动继承
docker build的过程涉及两个不同的网络主体:Docker CLI 本身,和构建容器内部的RUN指令。这两者的网络环境是分裂的,而很多人把它们当成一个。
4.1 CLI 的预定义构建参数是个甜蜜的陷阱
前面提过,Docker CLI 会把环境里的HTTP_PROXY、HTTPS_PROXY、NO_PROXY、ALL_PROXY、FTP_PROXY自动作为构建参数传给构建过程。这个机制设计得很好,让本地开发时不用额外配置就能构建。
问题在于它太"隐式"了。你写了一个 Dockerfile:
FROM internal-registry.example.internal/base:1.2 RUN curl -fsSL https://some-cdn.internal/tool.sh | sh本地跑,因为你的终端里 export 过那些变量,一切正常。扔到 CI 上,CI 的执行环境里没有这些变量,于是FROM直接失败,或者RUN里的 curl 卡住。报错信息是"connection timed out",看起来像网络问题,实际上就是变量没传。
所以我的建议是:任何会进入 CI 的 Dockerfile,都不要依赖隐式继承。显式在构建命令里传:
docker build \ --build-arg HTTP_PROXY="$HTTP_PROXY" \ --build-arg HTTPS_PROXY="$HTTPS_PROXY" \ --build-arg NO_PROXY="$NO_PROXY" \ -t myapp:latest .同时在 Dockerfile 开头声明这些 ARG:
ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY注意ARG声明的位置。如果写在FROM之前,只能用于FROM指令本身;要让它对后续的RUN生效,必须在每个 stage 里重新声明一次。多阶段构建时这个细节很容易漏,表现为"构建阶段一能用,阶段二又失败"。
4.2 ARG 和 ENV 别搞混,尤其是涉及敏感值
ARG是构建时的参数,不会留在最终镜像的配置里;ENV会写进镜像元数据,docker inspect就能看到。所以出口地址这类不算机密的信息用哪个都行,但如果你的构建过程需要传仓库凭据,务必用ARG,否则凭据会永久留在镜像里。
有个进阶技巧值得说一下:RUN指令里临时使用出口地址,用完立刻清掉,可以避免地址信息留在镜像层里:
RUN export HTTPS_PROXY="$HTTPS_PROXY" \ && curl -fsSL https://internal-cdn.internal/pkg.tar.gz -o /tmp/pkg.tar.gz \ && tar -xzf /tmp/pkg.tar.gz -C /opt \ && rm -f /tmp/pkg.tar.gz \ && unset HTTPS_PROXY把export、使用、unset写在同一个RUN里,这样它们在同一层,不会残留到后续层。如果拆成多个RUN,export的效果在下一个RUN里就消失了,因为每个RUN是独立的 shell。
4.3 构建缓存会让你误判配置是否生效
这是我在好几个项目里都遇到过的场景:改了配置,重新docker build,发现行为没变。第一反应是配置写错了,查半天,最后发现是缓存命中——那条RUN指令的输入没有变化,Docker 直接复用了上次的层,根本没重新执行。
RUN指令的缓存键里包含的只有指令文本本身和前置层的哈希,不包含构建参数的值。所以即使你改了--build-arg HTTPS_PROXY=...,只要 Dockerfile 里那行RUN文本没变,它照样命中缓存。
排查时用--no-cache或者--no-cache-filter强制重建。更精细的做法是给关键指令加一个会随配置变化的标记:
ARG PROXY_CONFIG_HASH=default RUN echo "proxy-config:${PROXY_CONFIG_HASH}" && \ curl -fsSL https://internal-cdn.internal/pkg.tar.gz -o /tmp/pkg.tar.gz构建时传一个不同的值进去,缓存自然失效。这个手法有点土,但在 CI 里非常实用,比全程--no-cache快得多。
4.4--network参数是另一条路
如果构建环境本身就能直连目标地址(比如在同一个内网里),其实根本不需要出口配置,用--network=host让构建容器共享宿主机网络栈就行:
docker build --network=host -t myapp:latest .这条路在 CI 里很常见,因为 CI runner 通常已经配好了网络环境,再套一层容器网络反而多此一举。但要注意安全边界:--network=host下构建容器能访问宿主机上的所有网络服务,包括那些只监听在127.0.0.1的本地服务。如果构建的 Dockerfile 来自不可信来源,这条路不要走。
--network=none则是另一个极端,完全断网。适合那种已经把所有依赖提前准备好的构建场景,可以显著加速并杜绝意外的外部请求。我个人的习惯是在最终发布构建时用--network=none验证一遍,如果这时候构建失败了,说明 Dockerfile 里有隐藏的下载行为,趁早补上。
5. 容器跑起来之后的网络:DNS 和出口是两件事
容器启动之后的问题,往往比构建期更隐蔽,因为构建失败会立刻报错,而运行期的容器可能"看起来正常",只是某个接口偶尔超时。
5.1 bridge 模式下容器的出网路径
默认的 bridge 网络下,容器通过 NAT 访问外部。容器内的进程发起连接时,源地址被替换成宿主机地址,这个转换由宿主机内核的 netfilter 完成。所以"容器能不能出网"这件事,首先取决于宿主机本身的出网能力,其次取决于 NAT 规则是否正常。
理解这一点很重要,因为很多人的排查方向错了:容器连不上外部服务,第一反应是去容器里查,其实应该先在宿主机上验证网络是否通。宿主机都不通,容器里查一天也没用。
容器内部的出口配置,跟宿主机完全独立。正确的传递方式是:
docker run -d \ -e HTTP_PROXY=http://gateway.internal:8080 \ -e HTTPS_PROXY=http://gateway.internal:8080 \ -e NO_PROXY=localhost,127.0.0.1,.internal \ --name myapp myapp:latest注意NO_PROXY里.internal前面没有星号。这是有意的,Go 和 curl 对.internal这种写法都按后缀匹配处理,而*.internal的星号形式在部分实现里反而不生效。关于这点第 6 节会详细说。
用 Compose 的话写在environment或者env_file里都行。我的习惯是用env_file,把出口配置单独放一个文件,方便不同环境替换:
services: app: image: myapp:latest env_file: - .env.proxy.env.proxy不进版本库,由部署流程注入。这样开发环境、测试环境、生产环境可以用不同的出口地址,而 compose 文件本身不用改。
5.2 DNS 解析失败比出口不通更难定位
有一种故障特别迷惑人:容器里ping 8.8.8.8通,curl https://internal-service.internal却报 "Could not resolve host"。这说明出口是通的,问题在 DNS。
容器的 DNS 配置默认继承宿主机的/etc/resolv.conf,但 Docker 会对它做一次过滤——把127.0.0.1这类回环地址替换成8.8.8.8之类的公共 DNS。这个行为的初衷是好的(回环地址在容器网络命名空间里没有意义),但在企业内网环境里会造成灾难:宿主机用的是本地 DNS 服务器解析内网域名,容器里被替换成公共 DNS,内网域名全部解析失败。
解决办法是在daemon.json里显式指定 DNS:
{ "dns": ["10.0.0.53", "10.0.0.54"] }或者每次运行单独指定:
docker run --dns 10.0.0.53 --dns-search internal myapp:latest--dns-search也别漏。内网环境里很多服务是用短域名访问的(比如db.internal写成db),没有 search domain 的话解析不出来。
还有一个更隐蔽的情况:自定义 bridge 网络里,Docker 内嵌了一个 DNS 服务器(监听在127.0.0.11),负责解析容器名和网络别名。这个内嵌 DNS 在解析失败时会向上游转发,而它上游用的正是容器的 DNS 配置。所以如果你发现"容器之间互相解析正常,但解析外部域名失败",问题一定在上游 DNS 配置上,不在内嵌 DNS。
5.3 host 模式和 none 模式的选择标准
--network=host让容器直接用宿主机网络栈,容器里的127.0.0.1就是宿主机的127.0.0.1。这条路在需要访问宿主机本地服务时非常方便,也省掉了所有出口配置——因为容器用的就是宿主机的配置。
代价是失去网络隔离。容器监听的端口直接占用宿主机端口,两个容器不能监听同一个端口。而且一旦容器内的进程有安全风险,它能直接访问宿主机上的所有网络服务。
我的判断标准是这样的:开发调试用 host,生产环境用 bridge 或自定义网络。开发时追求配置简单,生产环境追求隔离性和可预测性。生产环境用 host 模式,虽然能省掉一堆网络配置的麻烦,但会把网络依赖变成隐式的,后面换环境的时候问题全暴露出来。
--network=none适合那些完全不需要网络的容器,比如纯计算任务、批处理作业。用这个模式的好处是彻底消除了网络相关的故障可能,出问题就一定是代码逻辑问题。我有个做数据清洗的服务全程用 none,跑了大半年没出过一次网络相关的故障。
6. 四个真实会让人熬夜的坑,以及完整的排查链路
前面讲的是"应该怎么配",这一节讲"配了还是会出问题的地方"。这几个坑我都实际踩过或者帮别人查过,排查过程值得完整记录下来,因为大部分时候你面对的故障现象和根本原因之间隔得很远。
6.1 NO_PROXY 的匹配规则,比你想的复杂得多
现象:某个服务的健康检查接口在容器里一直超时,但手动curl同一个地址又是通的。查了半天,发现健康检查用的是内网地址10.20.30.40:9090,而这个地址被错误地走了出口。
根因在NO_PROXY的写法。当时配的是:
NO_PROXY=*.internal,10.*10.*这种写法看起来合理,实际上在很多实现里不生效。Go 标准库的httpproxy包对NO_PROXY的解析规则是:支持 CIDR 形式(10.0.0.0/8)、支持域名后缀(.internal)、支持精确 IP(10.20.30.40),但对10.*这种"部分通配"不支持,会当成普通域名去匹配,自然匹配不上。
正确的写法应该是:
NO_PROXY=localhost,127.0.0.1,.internal,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16CIDR 形式在主流实现里支持得最好。域名后缀用前导点(.internal),不用星号。这两条规则记住了能省很多事。
排查这个问题的链路值得记一下。第一步,在容器里确认实际生效的变量:
docker exec myapp env | grep -i proxy第二步,用 curl 的详细输出看实际走的哪条路:
docker exec myapp curl -v --max-time 5 http://10.20.30.40:9090/health如果输出里出现Uses proxy env variable HTTP_PROXY,说明 NO_PROXY 没匹配上。第三步,在宿主机上用同样的变量测试:
NO_PROXY="10.0.0.0/8" curl -v --max-time 5 http://10.20.30.40:9090/health这一步是为了区分"是 NO_PROXY 规则问题"还是"是容器网络问题"。如果宿主机上用正确写法能通,那就是规则写错了;如果宿主机上也不通,那要往上查出口网关。
6.2 容器里 ping 通但 curl 不通,问题可能在 TLS
现象:容器内ping internal-service.internal通,curl http://internal-service.internal也通,但curl https://internal-service.internal报证书错误。
这类问题的本质是 TLS 握手和 TCP 连通性是两回事。ping 通只证明 ICMP 能到,curl 走 HTTP 能通证明 TCP 和 HTTP 层没问题,HTTPS 失败通常有三种原因:出口网关做 TLS 拦截导致证书链变了、容器内的 CA 证书库不完整、或者容器没有正确地传递证书。
第一种情况在内网环境里很常见,判断方法是看报错里的证书签发者。如果不是你预期的 CA,那就是链路上有中间人。这种情况需要把网关的根证书导入到容器的信任库:
COPY internal-ca.crt /usr/local/share/ca-certificates/ RUN update-ca-certificatesAlpine 系的基础镜像要用apk add ca-certificates加手动复制到/usr/local/share/ca-certificates/。Debian/Ubuntu 系用上面那段就行。
第二种情况更隐蔽:某些精简基础镜像(比如scratch或者极简的 alpine)根本没装 CA 证书包,任何 HTTPS 请求都会失败。判断方法很简单:
docker run --rm myapp:latest ls /etc/ssl/certs/ | head如果目录是空的,那就是这个问题。
第三种是容器里的应用不读系统 CA 库。Java 应用读自己的cacerts,Node.js 在某些版本下读NODE_EXTRA_CA_CERTS,Python 的 requests 会优先用certifi包里的证书。所以即使系统 CA 装对了,应用还是可能报证书错误。这个坑需要针对具体运行时单独处理,没有通用方案。
6.3 改完 daemon.json 没重启,或者重启失败被静默吞掉
现象:改了/etc/docker/daemon.json,systemctl restart docker返回 0(成功),但配置不生效。
这个坑的原因有两层。第一层是改完之后没有systemctl daemon-reload,某些发行版下 restart 不会重新读 unit 文件——不过这影响的是 systemd 环境变量,不影响daemon.json。第二层是daemon.json本身格式有问题,dockerd 启动时解析失败,但因为配置了自动重启,systemd 反复拉起重试,最终状态变成activating而不是failed,systemctl status看起来像是"正在启动",很容易被误判成"已经启动但还没就绪"。
判断方法:
systemctl status docker --no-pager journalctl -u docker --since "5 minutes ago" --no-pager | tail -50重点看 journal 里有没有failed to parse或者unable to configure the Docker daemon之类的字样。有的话就是配置解析失败。
防御手段是在重启之前先校验 JSON:
python3 -m json.tool /etc/docker/daemon.json > /dev/null && echo "JSON OK"更进一步,可以在修改前备份,用一个脚本把"校验、重启、验证"串起来:
#!/bin/bash set -e cp /etc/docker/daemon.json /etc/docker/daemon.json.bak python3 -m json.tool /etc/docker/daemon.json > /dev/null systemctl daemon-reload systemctl restart docker sleep 3 docker info > /dev/null && echo "docker healthy"set -e让脚本在任何一步失败时立刻退出,不会带着坏配置继续往下走。这个脚本很短,但能挡住 90% 的"改配置把自己锁在门外"的情况。
6.4 镜像仓库地址在 NO_PROXY 里,但拉取还是走出口
这个坑最阴。配置看起来完全正确,NO_PROXY里明明白白写了内网仓库的域名,但docker pull的日志显示请求还是发到了出口网关。
根因是守护进程层的 NO_PROXY 和构建/运行层的 NO_PROXY 是三套独立的东西。你在daemon.json里配的no-proxy只影响dockerd拉镜像的行为;你在 shell 里 export 的NO_PROXY影响 CLI 和构建;你在docker run -e里传的只影响容器内部。三者互不干扰。
所以"我在容器里配了 NO_PROXY,为什么 docker pull 还是走出口"这个问题的答案是:因为docker pull是守护进程干的活,跟容器里的环境变量没关系。
排查顺序应该是:
systemctl show docker --property=Environment看守护进程的环境变量docker info看守护进程实际生效的配置- 如果是用
daemon.json配的,确认 Docker 版本支持proxies字段 - 前三条都对了还不行,检查是否有遗留的 systemd override 在覆盖
我遇到的那次,最后发现是两年前一个同事写的 override 文件还在/etc/systemd/system/docker.service.d/里躺着,里面写着一个已经下线的网关地址,把daemon.json的配置全压住了。找这种问题的办法是把所有可能的配置点列出来挨个看:
ls -la /etc/systemd/system/docker.service.d/ cat /etc/systemd/system/docker.service.d/*.conf 2>/dev/null cat /etc/docker/daemon.json systemctl show docker --property=Environment四条命令跑完,配置的全貌就清楚了。这个检查建议写进运维手册,每次网络相关问题排查的第一步就做这个。
7. 一套可以直接抄的配置模板和验证顺序
前面讲的都是原理和坑,这一节给一套可以直接落地的配置。我把它整理成"改什么、怎么验证"的顺序,照着走基本不会漏。
7.1 服务端配置:守护进程层
/etc/docker/daemon.json:
{ "proxies": { "http-proxy": "http://gateway.internal:8080", "https-proxy": "http://gateway.internal:8080", "no-proxy": "localhost,127.0.0.1,.internal,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,registry.internal" }, "dns": ["10.0.0.53", "10.0.0.54"], "registry-mirrors": ["https://mirror.internal"] }几个选择的理由说明一下。no-proxy里同时写了.internal和registry.internal,前者是后缀匹配覆盖所有内网域名,后者是显式声明保证优先级。CIDR 段全部列上,避免私有地址被绕出去。dns显式指定内网 DNS 服务器,绕开 Docker 对回环地址的替换逻辑。registry-mirrors指向内网镜像缓存,能让大部分拉取请求根本不走出口,这是最有效的优化——流量少一半,故障也少一半。
如果不支持proxies字段(老版本),换成 systemd override:
[Service] Environment="HTTP_PROXY=http://gateway.internal:8080" Environment="HTTPS_PROXY=http://gateway.internal:8080" Environment="NO_PROXY=localhost,127.0.0.1,.internal,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"7.2 构建侧:显式传参 + 缓存键
CI 的构建脚本里:
docker build \ --build-arg HTTP_PROXY="${HTTP_PROXY}" \ --build-arg HTTPS_PROXY="${HTTPS_PROXY}" \ --build-arg NO_PROXY="${NO_PROXY}" \ --build-arg CONFIG_HASH="$(sha256sum /etc/docker/daemon.json | cut -c1-8)" \ -t app:${GIT_SHA} .CONFIG_HASH那个参数是我自己的小技巧,用守护进程配置文件的哈希作为构建参数,配置一改缓存自然失效,不用全程--no-cache。
Dockerfile 里对应的写法:
FROM base:1.2 ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY ARG CONFIG_HASH=default RUN echo "cfg:${CONFIG_HASH}" \ && curl -fsSL https://internal-cdn.internal/tool.tar.gz -o /tmp/tool.tar.gz \ && tar -xzf /tmp/tool.tar.gz -C /opt \ && rm -f /tmp/tool.tar.gz多阶段构建的话,每个FROM之后都要重新写一遍那几行ARG。
7.3 验证顺序:从下往上,逐层确认
配置改完之后,按这个顺序验证,能最快定位问题在哪一层:
| 步骤 | 命令 | 验证什么 |
|---|---|---|
| 1 | systemctl show docker --property=Environment | 守护进程是否注入了变量 |
| 2 | docker info | grep -i proxy | 守护进程实际使用的配置 |
| 3 | docker pull public-image:tag | 守护进程能否通过出口拉公共镜像 |
| 4 | docker pull registry.internal/app:tag | 内网仓库是否被正确豁免 |
| 5 | docker run --rm -e HTTPS_PROXY=... curlimages/curl -v https://target | 容器内出口是否可用 |
| 6 | docker run --rm --network=none app:latest /bin/true | 镜像是否真的自包含 |
| 7 | docker build --no-cache -t test . | 构建期是否还有隐藏的外部依赖 |
这个顺序的关键是从下往上。第 1、2 步确认守护进程,3、4 步确认拉取行为,5 步确认容器内部,6、7 步确认构建自包含性。前一步没过就别看后面,因为后面的失败一定是前面引起的。
第 4 步特别值得单独做。因为内网仓库被豁免这件事,只有真正拉一个内网镜像才能验证。很多人只测了公共镜像拉取成功,就以为配置对了,实际上NO_PROXY里漏了内网仓库,所有内网拉取都绕了一大圈,慢但能用,问题藏得很深。
7.4 我自己的几条经验
最后分享几条不在文档里、但实际用起来很有价值的做法。
把网络配置和应用配置彻底分开管。应用镜像里不带任何出口地址,全部靠运行时注入。这样同一个镜像能在不同网络环境里跑,不需要为每个环境重新构建。我见过把出口地址写死在Dockerfile的ENV里的做法,换环境就得重新打镜像,非常痛苦。
在 CI 里加一个"网络配置自检"的前置步骤,跑几条简单的curl和docker pull,任何一条失败就直接失败并打印详细的诊断信息。这比等构建跑到一半再失败要好得多——至少你知道问题出在网络上,不会去代码里瞎找。
给NO_PROXY写单元测试。这话听起来有点怪,但确实有用。写一个小脚本,用几个典型地址(内网 IP、内网域名、公网域名、localhost)分别测试是否走了出口,断言结果符合预期。这个脚本在改网络配置之后跑一遍,能挡住大部分的回归问题。
最后一点,任何网络配置的改动都要记录变更原因和验证过程。这类问题的特点是"改的时候很急,忘了很自然,出问题的时候完全想不起来"。我自己维护一个简单的变更日志,每次改动记三行:改了什么、为什么改、怎么验证的。半年之后翻出来,能省掉大量重新排查的时间。