Docker 与容器排查 列表全部文章
Docker 与容器排查

容器里 curl localhost 永远拒绝连接:Docker 网络的五个经典“不通”与 ufw 那个著名的坑

容器访问宿主机 MySQL 报 Connection refused,同一 compose 里 localhost 也不通。梳理 host.docker.internal 与 host-gateway、服务名 DNS、-p 与 EXPOSE、默认 bridge 无 DNS、ufw 被 Docker 绕过,附 docker network inspect 与 nsenter 排查命令和网络拓扑图。

更多

周二上午,新来的同事小林在群里发了一张截图,配文:“杰哥,我的代码在本机好好的,一放进容器就 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 会晕:

宿主机 (10.0.0.11) 宿主机上的服务MySQL :3306监听 127.0.0.1 还是 0.0.0.0? iptables / ufwnat: DNAT / MASQUERADE docker0 网桥网关 172.17.0.1(默认 bridge) 自定义网络 app-net (172.18.0.0/16, 网关 172.18.0.1) —— 内置 DNS web (172.18.0.2)访问 http://api:8080不是 localhost api (172.18.0.3)进程监听 0.0.0.0:8080-p 18080:8080 发布到宿主 服务名 默认 bridge (docker0) —— 没有服务名 DNS,只能用 IP 或 --link c1 (172.17.0.2)ping c2 → 解析失败 c2 (172.17.0.3)ping 172.17.0.3 → 通 容器 → 宿主服务:目标是网关 IP(172.17.0.1 / host-gateway),不是 localhost 外部 → 容器:只有 -p 发布的端口才能进来(EXPOSE 只是声明)

看图先问三个问题:①从哪个容器出发?②目标是同网络的另一个容器、宿主机,还是外部?③走的是服务名、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      3c9d1a2b7f10

docker-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

正确的处理有三条路:

  1. 最省事:只发布到回环,-p 127.0.0.1:18080:8080,外部通过 Nginx 访问;
  2. 在 DOCKER-USER 链里加规则:这是 Docker 专门留给用户的过滤入口,会在 Docker 自己的转发规则之前被评估。例如只允许内网段访问容器端口:
    $ sudo iptables -I DOCKER-USER -i eth0 ! -s 10.0.0.0/8 -p tcp -m conntrack --ctorigdstport 18080 --ctdir ORIGINAL -j DROP
    注意:DOCKER-USER 里的包已经过了 DNAT,--dport 看到的是容器端口(8080),想按“对外发布的端口”(18080)匹配,要借助 conntrack 的 --ctorigdstport;改完务必从外部实测;
  3. 用社区方案(如 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-ACK

4. 最后一步:看防火墙规则计数

$ 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 对“去宿主机的包”管用,对“去容器的包”不管用。

五、根因总结(小林那个案例)

  1. 配置写了 localhost,指向容器自身 → refused;
  2. 改成 host.docker.internal 但没加 extra_hosts,Linux 上解析失败 → Unknown host;
  3. 加了以后还是 refused:MySQL bind-address=127.0.0.1 → 只在宿主回环监听;
  4. 改成 0.0.0.0 后超时:ufw 默认拒绝了来自 172.18.0.0/16 的入站连接 → 放行容器网段;
  5. 最后连通。四个坑,一个都没少。

顺带一提,更干净的方案是别让容器访问宿主机上的数据库:把 MySQL 也放进同一个 compose 网络,用服务名访问,端口不发布到宿主机;或者数据库在独立主机上,用内网 IP。宿主机当“中转站”这种设计,迟早还要踩坑。

六、复盘

做法为什么
先分清症状:refused / timeout / 解析失败三者对应“没监听”“被防火墙丢”“DNS 问题”,方向完全不同
容器访问宿主:--add-host=host.docker.internal:host-gateway,并确认宿主服务监听 0.0.0.0Linux 默认没有这个域名;只监听 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,都是自己的孤岛。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人