周二上午,新来的同事小林在群里发了一张截图,配文:“杰哥,我的代码在本机好好的,一放进容器就 Connection refused,是不是 Docker 有 bug?”
截图里的配置是:jdbc:mysql://localhost:3306/demo。我叹了口气,打字:“容器里的 localhost,是容器自己。”他回了个“???”,我知道这一课该开班了。
Docker 网络的“不通”看起来五花八门,其实翻来覆去就那么几种。这篇把它们按“是谁在访问谁”分类,配一张拓扑图和一套固定的排查命令,当备忘录用。
一、现象:几种典型的“通不了”
先看小林的报错:
$ docker logs --tail 5 demo-app
java.net.ConnectException: Connection refused (Connection refused)
at java.base/sun.nio.ch.Net.connect0(Native Method)
at com.mysql.cj.protocol.a.NativeSocketConnection.connect(NativeSocketConnection.java:65)
...
Communications link failure: The last packet sent successfully to the server was 0 milliseconds ago.“0 milliseconds ago”,说明瞬间就被拒了,不是超时。这是个重要的区分:
- Connection refused(立刻失败):能路由到目标 IP,但那个 IP:端口上没人监听(或者监听在别的接口上);
- Connection timed out(卡住很久):包被防火墙丢了,或者路由不通;
- Name or service not known / Temporary failure in name resolution:DNS 没解析出来。
三种症状对应三条排查线,别混着看。
二、排查路线图:先画清“谁访问谁”
先把典型拓扑画出来,不然一堆 IP 会晕:
看图先问三个问题:①从哪个容器出发?②目标是同网络的另一个容器、宿主机,还是外部?③走的是服务名、IP 还是 localhost?答案不一样,坑就不一样。下面五类分别拆。
三、动手:五类“不通”,逐个击破
坑 1:容器里的 localhost 是容器自己
每个容器有独立的网络命名空间,它自己的 127.0.0.1 就是它自己。宿主机上的 MySQL 监听在宿主机的 127.0.0.1:3306,容器里 localhost:3306 当然没人应答:
$ docker exec demo-app sh -c 'curl -sv --max-time 3 http://localhost:3306 2>&1 | head -4'
* Trying 127.0.0.1:3306...
* connect to 127.0.0.1 port 3306 failed: Connection refused
* Failed to connect to localhost port 3306 after 0 ms: Connection refused这是最容易犯、也最容易解释的一种。那怎么访问宿主机?
坑 2:访问宿主机服务 —— host.docker.internal 与 host-gateway
在 Docker Desktop(macOS / Windows)里,容器可以用特殊域名 host.docker.internal 访问宿主机。但在 Linux 上的 Docker Engine 里,这个域名默认不存在。Linux 上的正确姿势是给容器加一条 hosts 映射,值用特殊字符串 host-gateway(Docker 20.10 起支持),它会被解析为宿主机的网关地址:
$ docker run --rm --add-host=host.docker.internal:host-gateway alpine cat /etc/hosts
127.0.0.1 localhost
...
172.17.0.1 host.docker.internal
172.17.0.2 3c9d1a2b7f10docker-compose 的写法是 extra_hosts:
services:
demo-app:
image: demo/app:1.0
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://host.docker.internal:3306/demo不用域名也行,直接用网关 IP。默认 bridge 的网关通常是 172.17.0.1,用这条命令确认,不要凭记忆:
$ docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
172.17.0.1
$ docker exec demo-app ip route | head -1
default via 172.17.0.1 dev eth0但是!加了 host-gateway 后仍然 Connection refused,八成是下面这一条:宿主服务只监听了 127.0.0.1。回到宿主机上看:
$ ss -lntp | grep 3306
LISTEN 0 151 127.0.0.1:3306 0.0.0.0:* users:(("mysqld",pid=1187,fd=23))127.0.0.1:3306——只监听回环地址,来自 172.17.0.x 网段的连接根本到不了这个 socket。把 MySQL 的 bind-address 改成 0.0.0.0(或者网桥地址 172.17.0.1),并配合防火墙只放行容器网段,否则等于把数据库暴露给整个内网:
$ ss -lntp | grep 3306
LISTEN 0 151 0.0.0.0:3306 0.0.0.0:* users:(("mysqld",pid=1187,fd=23))如果这时变成 超时(不是拒绝),那就是防火墙在丢包(见坑 5)。
坑 3:同一个 compose 里,用服务名,不要用 localhost,也不要用宿主映射端口
Compose 默认会创建一个 <项目名>_default 的 bridge 网络,每个服务自动注册服务名,网络内的容器可以直接用服务名互访(官方文档原话大意:每个服务名都注册到内置 DNS)。另一个官方强调的点:服务间通信用的是容器端口(CONTAINER_PORT),宿主端口(HOST_PORT)只在网络外访问时用。
services:
web:
image: demo/web:1.0
environment:
API_URL: http://api:8080 # 正确:服务名 + 容器端口
# API_URL: http://localhost:18080 # 错:localhost 是 web 自己
# API_URL: http://api:18080 # 错:18080 是宿主端口,容器间不用
api:
image: demo/api:1.0
ports:
- "18080:8080" # HOST_PORT:CONTAINER_PORT验证 DNS 是否通:
$ docker exec demo-web nslookup api
Server: 127.0.0.11
Address: 127.0.0.11:53
Non-authoritative answer:
Name: api
Address: 172.18.0.3
$ docker exec demo-web curl -s -o /dev/null -w '%{http_code}\n' http://api:8080/actuator/health
200注意 Server: 127.0.0.11——这是Docker 内置 DNS,仅存在于用户自定义网络里。这直接引出坑 4。
坑 4:默认 bridge 没有 DNS,自定义网络才有
手动 docker run 起两个容器,不指定网络,它们都在默认的 bridge 上。官方文档明确写了区别:用户自定义网络提供容器名/别名的自动 DNS 解析,默认 bridge 上容器只能靠 IP 互访(除非用已废弃的 --link):
$ docker run -d --name c1 alpine sleep 3600
$ docker run -d --name c2 alpine sleep 3600
$ docker exec c1 ping -c 1 c2
ping: bad address 'c2'
$ docker exec c1 ping -c 1 172.17.0.3
64 bytes from 172.17.0.3: seq=0 ttl=64 time=0.090 ms换成自定义网络就好了:
$ docker network create app-net
$ docker run -d --name c1 --network app-net alpine sleep 3600
$ docker run -d --name c2 --network app-net alpine sleep 3600
$ docker exec c1 ping -c 1 c2
PING c2 (172.18.0.3): 56 data bytes
64 bytes from 172.18.0.3: seq=0 ttl=64 time=0.081 ms自定义网络还有额外好处:隔离(只有同网络的容器能互访)、可在线接入/断开(docker network connect / disconnect)。生产里别再依赖默认 bridge。
坑 5:端口映射 -p 与 EXPOSE 不是一回事;以及 ufw 被绕过
Dockerfile 里的 EXPOSE 8080,官方文档说得很直白:它不会真正发布端口,只是一种“文档”,告诉使用者这个镜像打算暴露哪个端口。真正让外界访问的是 -p(或 -P,发布所有 EXPOSE 的端口到随机高位端口)。
$ docker run -d --name api1 demo/api:1.0 # 只有 EXPOSE,没有 -p
$ docker port api1
(无输出,宿主机无法访问)
$ docker run -d --name api2 -p 18080:8080 demo/api:1.0
$ docker port api2
8080/tcp -> 0.0.0.0:18080
8080/tcp -> [::]:18080看输出里的 0.0.0.0:18080:默认发布到所有网卡,意味着公网/内网都能访问。如果只想给本机访问(比如前面有 Nginx 反代),要写成 -p 127.0.0.1:18080:8080。
现在讲那个“著名的坑”:ufw 拦不住 Docker 发布的端口。你在服务器上 ufw deny 18080,看上去规则已生效,外部却依然能访问 18080。原因(官方文档大意):Docker 通过 iptables 在 nat 表做 DNAT,发往容器的包在到达 ufw 使用的 INPUT 链之前,就被转发(FORWARD)走了——ufw 的 INPUT 规则根本没机会看到它们。
$ sudo ufw status
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
18080 DENY Anywhere # ← 看起来被拒绝了
# 从另一台机器
$ curl -s -o /dev/null -w '%{http_code}\n' http://10.0.0.11:18080/
200 # ← 照样通看真实的 iptables 规则就明白了:
$ sudo iptables -t nat -S DOCKER | head -3
-N DOCKER
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 18080 -j DNAT --to-destination 172.17.0.2:8080
$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -j RETURN正确的处理有三条路:
- 最省事:只发布到回环,
-p 127.0.0.1:18080:8080,外部通过 Nginx 访问; - 在 DOCKER-USER 链里加规则:这是 Docker 专门留给用户的过滤入口,会在 Docker 自己的转发规则之前被评估。例如只允许内网段访问容器端口:
注意:DOCKER-USER 里的包已经过了 DNAT,$ sudo iptables -I DOCKER-USER -i eth0 ! -s 10.0.0.0/8 -p tcp -m conntrack --ctorigdstport 18080 --ctdir ORIGINAL -j DROP--dport看到的是容器端口(8080),想按“对外发布的端口”(18080)匹配,要借助 conntrack 的--ctorigdstport;改完务必从外部实测; - 用社区方案(如 ufw-docker 项目):修改 ufw 的
after.rules,借助 DOCKER-USER 链让 ufw 接管容器流量,之后用ufw route allow proto tcp from any to any port 80这类规则放行。
不要图省事在 daemon.json 里写 "iptables": false——那会让容器出网(NAT/MASQUERADE)也失效,排查起来是另一场灾难。
四、一套固定的排查命令
不管遇到哪种“不通”,我按下面顺序走,基本三五分钟能定位。
1. 看容器接在哪些网络、拿到什么 IP
$ docker inspect demo-app --format '{{json .NetworkSettings.Networks}}' | python3 -m json.tool | head -15
{
"app-net": {
"IPAMConfig": null,
"Aliases": ["demo-app", "app"],
"NetworkID": "9d2c...",
"Gateway": "172.18.0.1",
"IPAddress": "172.18.0.4",
...
$ docker network inspect app-net --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
demo-web 172.18.0.2/16
demo-api 172.18.0.3/16
demo-app 172.18.0.4/16两个容器不在同一个网络里,服务名当然解析不出来——很多“莫名其妙”其实就在这一步暴露。
2. 进容器里三连:解析、连通、路由
$ docker exec -it demo-app sh
/ # nslookup host.docker.internal # 解析(能出 IP 说明 hosts/DNS 正常)
/ # curl -sv --max-time 3 http://172.17.0.1:3306 # 连通(refused / timeout / 有响应)
/ # ip route # 默认网关是谁
default via 172.18.0.1 dev eth0
172.18.0.0/16 dev eth0 scope link src 172.18.0.4结果如何解读:解析失败看网络类型和服务名;refused看宿主服务的监听地址(ss -lntp);timeout看防火墙/iptables。
3. 容器里没有 curl / 网络工具怎么办:nsenter
极简镜像(distroless、scratch)里连 sh 都没有,这时从宿主机用 nsenter 进入容器的网络命名空间,工具用宿主机的:
$ PID=$(docker inspect -f '{{.State.Pid}}' demo-app)
$ sudo nsenter -t $PID -n ip addr show eth0
2: eth0@if15: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP
inet 172.18.0.4/16 brd 172.18.255.255 scope global eth0
$ sudo nsenter -t $PID -n ss -tnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port
SYN-SENT 0 1 172.18.0.4:53422 172.17.0.1:3306-t 指定目标进程,-n 只进入网络命名空间(文件系统还是宿主机的,所以宿主机上的 curl、tcpdump 都能用)。上面输出里 SYN-SENT 一直卡着,配合超时的现象,基本就是防火墙丢了 SYN。要证实,就在宿主机上抓 docker0:
$ sudo tcpdump -ni docker0 port 3306 -c 5
IP 172.18.0.4.53422 > 172.17.0.1.3306: Flags [S], seq 10293822, win 64240, length 0
IP 172.18.0.4.53422 > 172.17.0.1.3306: Flags [S], seq 10293822, win 64240, length 0 # 重传,没有 SYN-ACK4. 最后一步:看防火墙规则计数
$ sudo iptables -L INPUT -v -n --line-numbers | head
num pkts bytes target prot opt in out source destination
1 1204 72K ufw-before-logging-input all -- * * 0.0.0.0/0 0.0.0.0/0
$ sudo ufw allow from 172.18.0.0/16 to any port 3306 proto tcp
Rule added这里 ufw 才真正管用:容器 → 宿主机的流量走 INPUT 链(目标是宿主机自己),所以 ufw 会拦住它,需要显式放行容器网段;而外部 → 容器的流量走 FORWARD,ufw 拦不住。一句话:ufw 对“去宿主机的包”管用,对“去容器的包”不管用。
五、根因总结(小林那个案例)
- 配置写了
localhost,指向容器自身 → refused; - 改成
host.docker.internal但没加extra_hosts,Linux 上解析失败 → Unknown host; - 加了以后还是 refused:MySQL
bind-address=127.0.0.1→ 只在宿主回环监听; - 改成 0.0.0.0 后超时:ufw 默认拒绝了来自 172.18.0.0/16 的入站连接 → 放行容器网段;
- 最后连通。四个坑,一个都没少。
顺带一提,更干净的方案是别让容器访问宿主机上的数据库:把 MySQL 也放进同一个 compose 网络,用服务名访问,端口不发布到宿主机;或者数据库在独立主机上,用内网 IP。宿主机当“中转站”这种设计,迟早还要踩坑。
六、复盘
| 做法 | 为什么 |
|---|---|
| 先分清症状:refused / timeout / 解析失败 | 三者对应“没监听”“被防火墙丢”“DNS 问题”,方向完全不同 |
容器访问宿主:--add-host=host.docker.internal:host-gateway,并确认宿主服务监听 0.0.0.0 | Linux 默认没有这个域名;只监听 127.0.0.1 的服务,容器永远连不上 |
| compose 内部一律用服务名 + 容器端口 | 内置 DNS 只认服务名;宿主端口只给网络外部使用 |
生产不要用默认 bridge,用 docker network create | 自定义网络有 DNS、有隔离,可在线增删容器 |
只想本机访问就 -p 127.0.0.1:port:port | 默认 0.0.0.0,会直接暴露到外网,且 ufw 拦不住 |
| 限制容器端口访问用 DOCKER-USER 链,改完从外部实测 | ufw 只管 INPUT,容器流量走 nat + FORWARD |
工具缺失时用 nsenter -t PID -n | 用宿主机的 ss / curl / tcpdump 进入容器网络命名空间排查 |
一句话备忘:容器里的 localhost 是自己;找宿主用 host-gateway 且服务别只听 127.0.0.1;compose 里用服务名;EXPOSE 只是注释,-p 才是开门;ufw 管不住 Docker 发布的端口,请用 DOCKER-USER 或绑 127.0.0.1。
小林后来把这张拓扑图打印出来贴在了工位上,旁边还手写了一行:“localhost 是相对的。”我看了觉得挺有哲理——毕竟在 Docker 的世界里,每个人的 localhost,都是自己的孤岛。
参考资料
- Docker 官方文档:Bridge network driver——默认 bridge 与用户自定义 bridge 的区别(自动 DNS、隔离、在线接入)。
- Docker 官方文档:Networking in Compose——默认网络、服务名 DNS,HOST_PORT 与 CONTAINER_PORT 的使用场景。
- Docker 官方文档:Port publishing and mapping——-p 默认绑定 0.0.0.0,以及绑定 127.0.0.1 的写法与限制。
- Docker 官方文档:Dockerfile reference - EXPOSE——EXPOSE 不发布端口,只是文档性质的声明。
- Docker 官方文档:Packet filtering and firewalls——Docker 如何使用 iptables,为何与 ufw 不兼容(nat 表在 INPUT 之前)。
- GitHub:chaifeng/ufw-docker——ufw 被 Docker 绕过的问题描述,及通过 DOCKER-USER 链修复的社区方案。
- Hostim:host.docker.internal on Linux——Linux 上使用 extra_hosts 与 host-gateway,服务需监听 0.0.0.0 以及 ufw 放行 Docker 网段。
一起聊聊