先说个前阵子的事。某开发在群里甩了张构建日志截图,一条RUN apk add --no-cache卡在百分之三十几,三分钟后报Temporary failure in name resolution退出。他在群里信誓旦旦地说,宿主机上curl一下就能通,凭什么容器不行。我让他把docker info的输出贴出来,果然,HTTP Proxy那一栏是空的;再看他改的那个~/.docker/config.json,写的是容器运行期的默认环境变量,跟守护进程拉镜像这条链路压根不是一回事。
这类问题在容器化落地的团队里出现频率极高。标题里的“Docker 网络代理配置”听起来像个五分钟的小活儿,实际上它是一条横跨三层、每层各有各的坑的链路:守护进程自己去拉基础镜像是一条路,docker build构建过程中跑的RUN指令是一条路,容器真正跑起来之后里面那个进程访问网络又是第三条路。三条路的配置落点完全不同,配错一层,症状看起来却差不多,都是“超时”“解析失败”“连不上”。这篇东西就是把这根链路彻底拆开,把每层的配置方法、验证手段,以及我这些年实打实踩过的坑,一条条摊开讲清楚。适合已经能把容器跑起来、但一遇到内网出网问题就抓瞎的开发者,也适合要给团队定规范、写 CI 模板的运维同学。
1. 为什么“宿主机能通”在容器里经常不成立
1.1 一条出网请求要跨过的三道门
很多人脑子里的模型是“容器就是宿主机上的一个进程,宿主机能上网容器当然能上网”。这个模型对了一半。容器确实共享宿主机的网络栈(用默认 bridge 模式时是通过 NAT 出去的),但网络能不能通和程序知不知道该往哪走是两件完全不同的事。出网配置本质上是告诉程序“你要发请求的时候,先连到某个网关地址上,由它替你转发”,这是一份配置,不是一个能力。宿主机上你的终端会话里 export 过这份配置,或者系统级配置文件里有,容器里默认是没有的。
具体拆开,一条出网请求至少过三道门。第一道是域名解析:容器默认继承宿主机的/etc/resolv.conf,但如果你自己建了自定义网络、或者跑在某些桌面版环境里,DNS 的走向会变,解析失败和出网失败的症状长得几乎一样,这是排查时第一个要分清的岔路口。第二道是路由与转发:容器发出的包要通过 bridge 网桥、经过宿主机的 NAT 规则出去,这一步通常不用管,但如果宿主机上动过 iptables 或者防火墙策略,这里会静默丢包。第三道才是出口网关的配置:程序得知道把请求发给谁。绝大多数“宿主机能通、容器不通”的案例,卡在第三道门。
我习惯在排查的时候按这个顺序问三个问题:容器里nslookup一个外网域名能不能出结果?容器里ping一个公网 IP 通不通?容器里curl -v一个 HTTPS 地址是卡在连接阶段还是 TLS 握手阶段?三个答案组合起来,基本能定位到是哪道门的问题,比漫无目的地改配置高效得多。
1.2 三种运行形态下的配置落点差异
同样是“Docker”,在不同环境里跑,配置落点完全不一样,这一点经常被忽略。我用一张表把常见形态和对应落点列出来,这张表建议直接贴在团队 wiki 里。
| 运行形态 | 守护进程配置落点 | 容器默认环境变量落点 | 备注 |
|---|---|---|---|
| Linux 原生 Docker(systemd 管理) | systemd drop-in 或 daemon.json | ~/.docker/config.json的 proxies | 最标准,也是本文重点 |
| Linux 原生 Docker(rootless 模式) | ~/.config/systemd/user/docker.service.d/ | 同上,但用户不同 | 路径容易搞错,服务名是docker不是docker.service之外的变体 |
| 桌面版容器环境 | 由虚拟机内的守护进程管理,宿主机配置不生效 | 界面的设置项或虚拟机内配置文件 | 宿主机export一百遍也没用 |
| CI 流水线里的 DinD | 起 DinD 那个容器自身的环境变量 | 通过.docker/config.json挂载,或构建参数透传 | 最隐蔽的一类,容易配成“只对构建生效、对拉镜像不生效” |
这张表里最后一行值得多说两句。CI 里用 Docker-in-Docker 的时候,其实存在两层容器:外层是那个跑着 dockerd 的“构建环境容器”,内层才是你真正docker run起来的业务容器。外层的环境变量会传给内层的守护进程,但不会自动传给内层的业务容器。很多人只在外层配了一遍,结果拉镜像能通、业务容器里跑起来一片超时,然后又回头怀疑是 DNS 的问题。
还有一种情况是network_mode: host。这种模式下容器直接用宿主机的网络命名空间,路由和 DNS 都跟宿主机一致,但它依然不会继承宿主机的环境变量。容器的环境变量只来自镜像里的ENV指令、-e参数、env_file这几处。这个点我第一次遇到的时候也懵了一下,明明网络都一样了,为什么应用还是不走网关?后来才想明白,环境变量是进程自己读的,跟网络命名空间不是一回事。
2. 守护进程层:让 docker pull 真正走得通
2.1 systemd drop-in 的老办法与它的几个前提
最经典的做法是给 dockerd 这个 systemd 服务加一份 drop-in 配置。路径是/etc/systemd/system/docker.service.d/http-proxy.conf(注意目录不存在要自己建),内容大致长这样:
[Service] Environment="HTTP_PROXY=http://10.20.30.40:3128" Environment="HTTPS_PROXY=http://10.20.30.40:3128" Environment="NO_PROXY=localhost,127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,.internal,.local"写完之后必须执行两步,少一步都白搭:
sudo systemctl daemon-reload sudo systemctl restart docker这里的坑集中在三个地方。第一个是只做了一半,systemctl restart docker之前忘了daemon-reload,systemd 根本没重新读文件,服务重启的还是老配置,你会觉得“我明明改了却没生效”,然后开始怀疑人生。第二个是服务名写错,某些发行版上 Docker 的服务单元叫docker.service,有些自定义安装的可能是docker-ce.service,用systemctl list-units | grep -i docker确认一下比较稳。第三个是改错了作用域,在/etc/environment或者 shell 的 profile 里 export,那只对当前会话有效,守护进程是由 systemd 从 PID 1 拉起来的,压根读不到你 profile 里的东西。
我个人更倾向于把NO_PROXY写得宽一点,而不是一开始就精确。原因很现实:精确的白名单需要你对整个内网拓扑有完整认知,而大部分人接手一个存量项目时是没有的。先宽后窄,比先窄后宽安全得多——先窄的话,内网服务被强行绕到出口网关上,表现是慢、偶尔超时,这种半死不活的状态最难排查。
2.2 daemon.json 的 proxies 字段到底管什么
从容器引擎 23.0 版本开始,多了一种更“原生”的写法:把出口配置写进/etc/docker/daemon.json的proxies字段里。
{ "proxies": { "http-proxy": "http://10.20.30.40:3128", "https-proxy": "http://10.20.30.40:3128", "no-proxy": "localhost,127.0.0.1,.internal" } }这里最容易产生误解的地方是:很多人以为写了这个就不用管 systemd 了。实际上这个字段主要影响的是“给新建容器注入默认环境变量”这件事,守护进程自己拉镜像走的还是它自身的进程环境(也就是 2.1 那套)。两者是互补关系,不是替代关系。我在实际项目里通常是两处都写:systemd 那层保证docker pull和docker build的外层网络通,daemon.json这层保证业务容器起来后默认就能出网,少一层都有可能出现“拉得下来跑不起来”或者反过来的怪状。
还有一点,daemon.json是整份文件解析的,多一个逗号、少一个括号,守护进程就直接起不来。改之前先备份,改完用docker info验证。另外这份文件的格式检查不像代码那样有编译期兜底,写错了只会在重启时报错,如果是在远程机器上操作,把守护进程搞挂了会相当被动。我一般会先本地用python -m json.tool过一遍格式,再往服务器上推。
2.3 改完之后怎么验证才算数
验证这件事,很多人做得很敷衍——docker pull成功一次就认为搞定了。这个结论不靠谱,因为镜像是分层的,可能你拉的镜像全都命中本地缓存,根本没走网络。真正有效的验证要做三层:
第一层,看守护进程自己怎么想的:
docker info | grep -i -A2 proxy正常情况下应该能看到HTTP Proxy、HTTPS Proxy、No Proxy三行都填上了。这里空着,说明 systemd 那层没生效,后面不用测了,先回去改。
第二层,确认容器默认环境变量有没有被注入:
docker run --rm alpine env | sort | grep -i proxy注意这里alpine如果本地没有就会触发一次拉取,正好顺带验证了拉镜像这条路。如果docker info里有、但这步输出为空,说明daemon.json的proxies那段没生效,或者你用的版本不支持这个字段。
第三层,做一次真实的端到端访问,而且要看“走没走网关”而不是“通没通”:
docker run --rm curlimages/curl -sv https://example.com 2>&1 | head -30-v的输出里会明确打印出Connected to 10.20.30.40 port 3128这样的行,看到网关地址才算真走通了。如果打印的是目标站点的真实 IP,说明配置没生效但网络恰好是直连可通的,这种情况下你以为配好了,换个网络环境立刻崩。
3. 构建期:docker build 的出网链路最容易出岔子
3.1 build-arg 的传参姿势与镜像层里的痕迹
构建期的网络配置有个特殊性:RUN指令跑在一个临时容器里,这个容器的环境变量得靠ARG传进去。标准写法是这样:
ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY RUN apk add --no-cache curl git然后在构建命令里显式传:
docker build \ --build-arg HTTP_PROXY=http://10.20.30.40:3128 \ --build-arg HTTPS_PROXY=http://10.20.30.40:3128 \ --build-arg NO_PROXY=localhost,127.0.0.1,.internal \ -t myapp:dev .用 BuildKit 的话,HTTP_PROXY、HTTPS_PROXY、FTP_PROXY、NO_PROXY、ALL_PROXY这几个是预定义参数,会自动透传,Dockerfile 里不写ARG也能用。但我还是建议写上,一是兼容老的构建后端,二是让读 Dockerfile 的人一眼看到这里涉及网络配置,是个“显式优于隐式”的问题。
这里埋着一个安全层面的坑,很值得单独说:--build-arg传的值会留在镜像历史里。构建完执行docker history --no-trunc myapp:dev,你能看到完整的那条RUN命令以及参数值。如果你的出口网关地址、甚至带认证信息的地址被这么写进去,镜像再推到内部仓库,等于把基础设施信息全公开了。更麻烦的是,有些认证信息是嵌在 URL 里的,那就直接是凭据泄露。
规避方式有几种,我常用的是把构建期的网络配置放在buildkitd的配置里而不是命令行参数里,或者用构建密钥(build secret)的方式挂载认证信息:
RUN --mount=type=secret,id=netrc,target=/root/.netrc \ ./build.sh这样认证信息不会固化进任何一层。至于网关地址本身,属于内网常识性信息,风险等级低一些,但如果要发布到外部镜像仓库,也建议在最后阶段清理掉相关痕迹。
3.2 缓存把错误的配置“固化”进镜像
这是我认为整个构建链路里最阴的一个坑。ARG参与缓存计算,也就是说——你改一次网关地址,从这一行往后所有层的缓存全部失效。反过来,如果你没声明ARG、而是把网关地址硬编码在RUN里,那改配置就得改 Dockerfile,同样会断缓存。
更隐蔽的是另一种情况:你在RUN里装了包,构建成功,镜像做出来了。后来网关地址换了,你重新构建,发现构建秒过——因为缓存命中了,那些RUN根本没重新执行。这时候你手里这个镜像,是用旧网络环境构建出来的,里面可能有已经从内部仓库下架的依赖、可能有旧版本的软件包。等你在干净环境里(比如 CI)重建的时候,问题才暴露,而那时候你已经很难回忆起“这台机器上曾经配过什么”。
我自己的习惯是,涉及网络配置的构建参数统一放到一个build.env文件里,通过--build-arg逐条传入,同时给镜像打上包含构建时间戳的 tag,方便事后追溯。CI 里则强制加--no-cache跑一遍“干净构建”,作为发布前的最后一道关卡。虽然慢,但能把“本地能构建、CI 构建失败”这类问题挡在发布之前。
有一类场景必须提一下:多阶段构建。FROM ... AS builder阶段配了ARG,到了FROM ... AS runtime阶段,那些ARG是不继承的,得重新声明一遍。同样地,--from=builder拷过去的只有文件,环境变量不跟着走。我见过一个项目在 builder 阶段配好了网络,runtime 阶段启动时要做一次健康检查回调,结果因为没配网络一路超时,容器一直处于不健康状态被人反复重启。
3.3 企业根证书:比配置本身更容易翻车的地方
这个坑我敢说每个在中大型内网里做过容器化的团队都踩过。内网出口网关通常会对 HTTPS 流量做拦截和重新签发证书,也就是说,你实际拿到的证书是网关签的,签发根证书是公司自己的 CA。宿主机上这个根证书早就装好了,浏览器里一切正常,所以根本想不到这回事。但容器用的是自己的证书信任库——alpine基础镜像里就是/etc/ssl/certs/ca-certificates.crt那一套,里面当然没有你公司的根证书。
症状非常典型:curl -v输出里能看到连接建立了、请求发出去了,然后卡在 TLS 握手阶段,报SSL certificate problem: unable to get local issuer certificate。很多人看到“证书问题”第一反应是服务端配置有问题,其实客户端压根不认这张证书。
解决办法是把根证书打进镜像:
COPY corp-ca.crt /usr/local/share/ca-certificates/corp-ca.crt RUN update-ca-certificatesDebian 系用update-ca-certificates,RHEL 系用update-ca-trust,alpine用apk add ca-certificates && update-ca-certificates。这里有个细节:证书文件的扩展名必须是.crt,update-ca-certificates只处理这个后缀,放个.pem进去它理都不理你,这个坑我至少见过三次。
还有一种更麻烦的情况:某些语言运行时压根不读系统的证书库。比如 Node.js 用的是自己打包的 CA 列表,得通过NODE_EXTRA_CA_CERTS环境变量指定;Python 的requests默认走certifi包,要传REQUESTS_CA_BUNDLE或者SSL_CERT_FILE。这就意味着“系统证书装好了”不等于“应用能识别”,得按语言生态分别处理。我在一个多语言混布的项目里被迫整理了一张对照表,后来成了团队的标准物料。
4. 运行期:环境变量注入了不等于应用会用
4.1 四种注入方式与它们的优先级
运行期的注入方式有这么几种,按我个人的推荐度排序:
| 方式 | 写法 | 适用场景 | 主要问题 |
|---|---|---|---|
命令行-e | docker run -e HTTP_PROXY=... | 临时调试 | 容易忘记,不可复现 |
compose的environment | 文件里明文写 | 单个项目的固定环境 | 明文进版本库,敏感信息风险 |
compose的env_file | 指向一个.env文件 | 多环境切换 | 变量名大小写、格式要求多 |
| 守护进程默认值 | daemon.json的proxies | 全机器统一兜底 | 全局生效,局部难以覆盖 |
这里插一个特别常见的误解:docker compose用的.env文件只用于 compose 文件内部的变量替换,它不会自动变成容器里的环境变量。这个区别我解释过无数次:
services: app: image: myapp:dev env_file: - app.env # 这个才进容器 environment: - HTTP_PROXY=${CORP_GATEWAY} # ${} 替换来自 .envapp.env里的东西会进容器,项目根目录的.env里写的东西只会被用来替换${},不进容器。这个设计很多人觉得反直觉,但它是有道理的——.env里通常还放着数据库密码、端口号这类只给 compose 自己用的东西,全塞进容器确实不合适。
优先级方面,命令行-e覆盖env_file,env_file覆盖镜像里的ENV,镜像里的ENV覆盖daemon.json的默认值。清楚这个链条之后,遇到“我明明在 compose 里配了为什么没生效”时,就知道该往哪个方向找了——大概率是镜像ENV里写死了一个空值,或者 Dockerfile 里ENV HTTP_PROXY=""这种把变量“占位”了。
4.2 NO_PROXY 写不对,内网调用全绕远路
NO_PROXY这一行,是我见过最容易被整段复制粘贴然后长期没人维护的一行配置。它的作用是一份豁免名单,名单里的目标不走网关,直接连。写漏了的后果很微妙——不是不通,是慢。
想象一个典型的微服务集群:订单服务调库存服务,两者在同一个宿主机上跑着,走 Docker 的内部网络,域名是inventory-svc。如果NO_PROXY里没有这个域名,请求会被发到内网出口网关那里,网关再想办法转回来,或者干脆转不过去。表现就是本来 2 毫秒的调用变成 200 毫秒,加超时、加重试,链路上多绕一圈还容易带出各种偶发错误。这种问题不会进监控告警,只会体现在 P99 延迟上,往往要排查好几天才能揪出来。
我的做法是给NO_PROXY划三类:本地回环(localhost,127.0.0.1,::1)、私网网段(10.0.0.0/8,172.16.0.0/12,192.168.0.0/16)、内部域名后缀(.internal,.local,.svc之类,按自己环境定)。这三类覆盖了绝大多数内部调用的场景。
但这里有一个必须提醒的兼容性问题:NO_PROXY对 CIDR 网段的支持,各语言实现不一致。Go 标准库的实现支持 CIDR,curl 新版本也支持,但 Python 的requests是字符串后缀匹配,写10.0.0.0/8它根本不认。所以在多语言混布的环境里,最保险的做法是同时写上明确的 IP 段和域名后缀,并且在每个语言的容器里实测一遍,别只看文档。我个人的保守策略是:宁可把内部域名后缀列全一点,也不要指望 CIDR 在所有地方都生效。
4.3 大小写、空值与 httpoxy 那点历史包袱
环境变量的大小写问题,是个典型的历史遗留。http_proxy和HTTP_PROXY在大部分工具里等价,但在某些工具里不等价。curl 就只认小写的http_proxy(读大写是因为历史上有个安全问题),但HTTPS_PROXY大小写都认。不同语言的 HTTP 客户端库行为又有各自的小九九。结果就是你可能遇到“某个工具能出网、另一个不行”的怪现象,排查半天发现是大小写的事。
我的处理方式是大小写两套都写一遍,占不了多少字符,能省掉多少排查时间算多少:
environment: - HTTP_PROXY=http://10.20.30.40:3128 - http_proxy=http://10.20.30.40:3128 - HTTPS_PROXY=http://10.20.30.40:3128 - https_proxy=http://10.20.30.40:3128 - NO_PROXY=localhost,127.0.0.1,.internal - no_proxy=localhost,127.0.0.1,.internal至于 httpoxy,这个稍微了解一下就行:早期的 CGI 规范会把请求头Proxy:自动转成大写的HTTP_PROXY环境变量,攻击者只要在请求头里塞一个Proxy,就能让后端的出网请求走他控制的地址。虽然现在的框架基本都修了,但在把请求头转发给下游服务的场景里,我仍然建议在入口处直接丢掉Proxy这个头,成本几乎为零。
还有一个特别隐蔽的坑:空值覆盖。daemon.json里配了默认值,结果镜像里有一行ENV HTTP_PROXY=,空的,但确实存在,直接把默认值顶掉了。这种情况在那些“为了兼容而随手加了一行”的 Dockerfile 里特别多。排查的时候执行docker inspect <image> | grep -i proxy,能看到镜像层面到底声明了什么。
5. 埋坑实录:六类典型故障的排查路径
5.1 症状对照表
先上一张表,按症状快速定位。这张表是我这些年攒下来的,遇到问题时按行扫一眼,命中率大概七成。
| 症状 | 最可能的落点 | 第一手验证命令 |
|---|---|---|
docker pull直接超时 | 守护进程层(systemd) | docker info | grep -i proxy |
pull正常但build卡在RUN | 构建参数未透传 | docker build --progress=plain看实时输出 |
build正常但容器启动后连不上 | 运行期注入缺失 | docker run --rm <img> env | grep -i proxy |
| 报证书错误而非超时 | 根证书未装或未更新信任库 | 容器内curl -v看握手阶段 |
| 内部服务调用变慢但不断 | NO_PROXY缺域名 | 抓包或在容器内curl -v看连接目标 |
| 某一个工具不通、其他不通 | 变量大小写差异 | 分别用两个工具测同一地址 |
表的用法很简单:从左上往右下走,先确认最外层,再往内层钻。千万别一上来就怀疑应用代码,那是最浪费时间的方向。
5.2 三个真实排查过程复盘
案例一:改了配置但不生效的“幽灵问题”。某同学在服务器上加了 systemd drop-in,重启服务,docker info里确实有了。但第二天早上又没了。查了半天发现是他写的 drop-in 文件名是docker.service.d/http-proxy.conf.bak,当时为了保险留了个备份,结果 systemd 只加载.conf后缀,那次生效其实是因为手动重启时恰好命中了另一次临时 export 的会话。这个案例的教训是:备份文件千万别放在配置目录里,systemd 对这玩意的容错比你想的要多,但它只认.conf。我现在的习惯是备份统一扔到/root/backup/下面去。
案例二:CI 里构建成功的镜像,到了测试环境启动就挂。这个项目在 CI 里用 DinD 构建,构建日志一切正常。到了测试环境,容器启动后有个初始化脚本要回调一个内部服务,直接超时。查了半小时,发现是 CI 的.docker/config.json挂载进去配了默认环境变量,而测试环境用的是直接docker run没配任何东西。更深一层的原因是,这个镜像的 Dockerfile 在很早以前写过一行ENV NO_PROXY=localhost,把整个内网段都排除了,导致容器里访问内部服务走的是直连,而测试环境的网络策略不允许直连。最后是把那行ENV删掉,改成在部署模板里统一注入。教训很明确:镜像里不要写环境相关的 ENV,那是部署层的事。
案例三:偶发的 TLS 握手失败。有个服务大概每几百次请求失败一次,报证书验证失败。第一反应是网关在轮换证书,但查下来所有节点用的是同一张证书。最后定位到是容器里跑了个定时任务,每隔一段时间重新加载一次配置,加载时会 new 一个 HTTP 客户端,而那个客户端的证书读取路径是certifi包的默认路径,跟系统证书库是两套。平时走系统库的请求正常,定时任务那个客户端就间歇性失败。这个案例的麻烦在于“间歇性”,很容易被归到网络抖动上。我的经验是,只要是和 TLS 相关的偶发失败,优先怀疑客户端实现不一致,而不是网络。
5.3 一套可以直接抄的排查命令清单
我把常用的排查命令整理成一个可以按顺序执行的清单,出问题时从上往下跑一遍,基本能定位到层。
# 1. 守护进程自身认知 docker info | grep -i -E 'proxy|registry' # 2. 守护进程的实际环境(宿主机上执行) sudo systemctl show docker --property=Environment # 3. 镜像层面声明了什么 docker image inspect <image> --format '{{json .Config.Env}}' | tr ',' '\n' | grep -i proxy # 4. 容器实际拿到什么 docker run --rm <image> env | grep -i proxy # 5. 端到端连通与路径确认 docker run --rm curlimages/curl -sv https://example.com 2>&1 | grep -E 'Connected|issuer' # 6. 内部域名是否被正确豁免 docker run --rm curlimages/curl -sv http://internal-svc:8080/health 2>&1 | head -15 # 7. 构建期的实时输出 docker build --progress=plain --no-cache -t test:probe . 2>&1 | tail -40第 5、6 条最关键,-v输出里的Connected to那一行是判断“走没走网关”的铁证。第 7 条的--progress=plain也很值得记,默认的折叠式输出在卡住的时候什么信息都不给你,plain模式能看到每一行RUN的完整输出,卡在哪一步一目了然。
6. 一份可以照着落地的配置清单与个人经验
6.1 分层配置清单
我把整套配置整理成一份可以直接对照执行的清单,新环境部署时按这个走一遍就够了。
| 层级 | 配置项 | 落点 | 验证方式 |
|---|---|---|---|
| 守护进程 | HTTP_PROXY/HTTPS_PROXY/NO_PROXY | /etc/systemd/system/docker.service.d/http-proxy.conf | docker info显示三行 |
| 守护进程(新版) | proxies字段 | /etc/docker/daemon.json | 新建容器有默认变量 |
| 构建期 | build-arg 或 BuildKit 预定义变量 | 构建命令 / CI 模板 | --progress=plain看日志 |
| 构建期证书 | 企业根证书 | Dockerfile 里COPY+ 更新信任库 | 容器内curl -v看 issuer |
| 运行期 | 环境变量(大小写两套) | compose 的env_file/ 部署模板 | docker run env | grep proxy |
| 运行期豁免 | NO_PROXY三段式白名单 | 同上 | 内部服务调用延迟正常 |
| 安全 | 清理镜像历史里的敏感信息 | 构建后docker history --no-trunc | 无明文凭据 |
这份清单的重点不在条目多,而在于每一行都有对应的验证动作。没有验证动作的配置,等于没配。
6.2 我个人踩出来的几条经验
第一条,镜像里永远不要写环境相关的ENV。镜像应该是环境无关的,同一个镜像能跑在开发、测试、生产。一旦你在 Dockerfile 里写了一行ENV HTTP_PROXY=xxx,这个镜像就被焊死在某个网络环境里了,后面所有环境用它都得覆盖一遍,覆盖漏一个地方就是事故。
第二条,NO_PROXY从宽不从窄。刚开始配置的时候把私网段全放进去,运行稳定之后,如果发现某些内部服务确实需要走统一出口,再一点点收窄。反过来做的代价是频繁遇到“慢但不断”的幽灵问题,排查成本极高。
第三条,证书问题优先怀疑客户端而不是服务端。判断方法很简单,宿主机上openssl s_client -connect host:443 -showcerts看一下证书链,再在容器里跑同样命令,两边结果一对就清楚了。如果容器里能看到证书但验证失败,那就是信任库的问题,不是服务端的。
第四条,给每台机器的出口配置加个“自检”。我在几个长期维护的项目里加了个定时任务,每小时在容器里跑一次curl到内部和外部各一个地址,失败就发通知。这个成本极低,但能把“某台机器的配置被谁改坏了”这种事在半小时内发现,而不是等到业务报障。配置类问题的特点就是平时看不出来,出事就是一片,提前兜住比事后补救划算得多。
第五条,把出口配置当成代码管起来。systemd 的 drop-in、daemon.json、compose 模板、CI 配置,这几份文件放在同一个仓库里,改动走评审。这套东西平时没人看,出事的时候就是唯一的线索来源。我见过太多团队,配置只存在于某台服务器的/etc目录里,机器一重装,知识就永久丢失了。
还有一个经验是关于排查心态的。容器网络问题有个特点,症状高度雷同但根因千差万别,所以最忌讳的就是“我记得上次是这么解决的”然后直接上手改。老老实实按层验证,先确认docker info,再确认容器环境变量,再看curl -v的连接目标,三步下来基本就定位了。这套流程看着笨,但比凭直觉猜要快得多。