上周帮一个朋友看他本地的一整套服务,三个容器互相调用,后端代码里把上游地址写死成了172.17.0.3。机器重启一次,容器重新分配 IP,整条链路当场断掉,他排查了快两个小时才想到是地址变了。这种场面我这些年见过太多次,本质上就是没把 Docker 容器之间通过名字相互访问这件事用起来。Docker 的容器网络里其实内置了一套很小但够用的服务发现机制:只要容器接在同一个自定义网络上,容器名、网络别名、Compose 里的服务名就能直接当域名用,IP 变了也不影响调用方。这套机制看着简单,但背后的边界条件不少——默认网络为什么不行、内嵌 DNS 怎么工作、容器内端口和宿主机映射端口的区别、Compose 的隐式网络和依赖顺序、跨项目通信怎么处理、DNS 缓存带来的“幽灵故障”,每一条都能让人卡半天。
下面我按“机制—实操—编排—排障—取舍”的顺序,把这条链路从头到尾捋一遍。不管你是刚接触容器网络、还在被--link和老教程误导的新手,还是已经在用 Compose 编排多服务、想搞清楚为什么偶尔会解析失败的老手,应该都能从里面找到能直接抄的东西。
1. 名字解析的底层机制:先搞清楚谁在给你解析
1.1 默认 bridge 网络的旧账:写死 IP 的习惯是怎么来的
很多人第一次接触容器互联,看的都是几年前的老文章,里面清一色用--link或者直接写 IP。这不是他们不想用名字,而是当年确实没有更好的办法。Docker 最早只有一张默认的bridge网络,所有不带--network参数的容器都会被塞到这张网里,网段通常从172.17.0.0/16开始,网关是172.17.0.1。这张网络的问题在于:它只是一个纯粹的 Linux 网桥,容器之间能通,但没有任何名字解析服务,你在容器 A 里ping 容器B的名字,返回的是bad address。
当年官方给的补丁是--link,原理很粗暴——把目标容器的名字和 IP 写进当前容器的/etc/hosts,顺便加几条环境变量。这个方案的问题一眼就能看出来:单向绑定,A 链接 B 之后 A 能访问 B,B 反过来访问 A 还得再链一次;容器重建 IP 一变,/etc/hosts里那条记录就过期了,因为它是启动时写死的静态条目;更麻烦的是链接关系多了之后,启动顺序会形成一张纠缠不清的依赖网,删一个容器可能连带影响一片。所以--link早就被标记为废弃,官方文档里明确建议不要再用于新项目。
现在还在默认bridge网络上跑多容器服务的,基本都会遇到同一个困境:不知道对方 IP,只能靠docker inspect查出来再手动填进配置,或者写脚本在启动时注入。这就把“服务发现”这件本该由平台负责的事,推给了业务代码,容器的可替换性也就没了。所以第一步要建立的认知是:默认bridge网络只提供连通性,不提供服务发现,想让容器按名字互访,必须换一张网络。
1.2 自定义 bridge 网络的内嵌 DNS:127.0.0.11 到底是谁
只要用docker network create建一张自定义的 bridge 网络,容器接进去之后,你会发现容器里的/etc/resolv.conf内容变了,第一行nameserver指向127.0.0.11。这个地址是一个回环地址,但它在容器自己的网络命名空间里,不属于任何一个真实网卡,作用是把 DNS 查询交给宿主机上的 Docker 守护进程处理。
具体路径是这样:容器里的应用发起 DNS 请求,命中127.0.0.11:53,这个流量被容器网络命名空间里的规则重定向到守护进程内置的解析器;解析器先看查询的名字是不是当前网络内某个容器的名字或别名,是的话直接返回该容器在这张网络里的 IP;不是的话,再按宿主机/etc/resolv.conf里的上游服务器转发出去,把外部域名的结果带回来。所以你会在容器里看到options ndots:0这个配置,它的意思是单标签的名字(比如api、db)直接当成完整域名去查,不要先拼接 search 域再试,省掉一轮无谓的查询延迟。
这套机制的关键约束在于“同一张网络”四个字。内嵌 DNS 是按网络隔离的,它只知道当前网络里有哪些容器、分别是什么 IP。容器 A 在front网络,容器 B 在back网络,两者即使跑在同一台宿主机上,A 也解析不到 B 的名字。这不是 bug,是刻意设计的隔离边界——你可以借此把数据库藏在一个不对外暴露的网络里,只让中间层接进去。
还有一个容易忽略的点:内嵌 DNS 只对运行中的容器返回记录。容器停下来或者被删除,对应的名字立刻解析不到,哪怕它的 IP 还没有被别的容器占用。所以那种“服务没起来先改了配置重启”的操作,经常会看到no such host,不是配置写错了,是对面那个容器压根没在跑。
1.3 容器名、网络别名与服务名:三个名字各自的适用范围
同一个容器在 DNS 里可能对应好几个名字,搞清楚它们的来源,排障时能省很多时间。
容器名来自docker run --name指定的值,或者由守护进程随机生成。这个名字在整台宿主机上是唯一的,重名会直接报错,它会被自动注册到容器加入的每一张自定义网络的 DNS 里。
网络别名来自--network-alias参数,一个容器可以挂多个别名,而且别名只在它加入的那张网络里生效。别名的价值在于解耦:调用方永远访问db这个名字,具体背后是哪个容器,由部署时决定。更实用的一点是,多个容器可以共享同一个别名,此时 DNS 会返回多条 A 记录,客户端自己做轮询,这是在没有编排系统的情况下做简易负载均衡的土办法。
服务名是 Compose 体系里的概念。docker compose up启动时,每个服务的名字会被注册为它所在网络的别名,所以你在api服务里直接访问db就能通,不需要写任何额外配置。注意服务名和container_name是两个东西:前者用于 DNS 解析,后者只影响docker ps里显示的名字和容器的唯一性约束,调用方不该依赖它。
提示:容器名是全局唯一的,服务名不是。同一个 Compose 文件里通过
scale或者deploy.replicas起多个副本时,不能用container_name,但服务名可以正常解析成多个 IP。
2. 从零跑通:两个容器按名互访的完整实操
2.1 建网络、起容器:命令与参数逐个说清
先说最朴素的手工方式,不用 Compose,纯docker run,这样能看清每个参数在干什么。第一步是建网络:
docker network create \ --driver bridge \ --subnet 172.28.0.0/16 \ --gateway 172.28.0.1 \ appnet为什么要显式指定子网?默认情况下 Docker 会从地址池里自动挑一个空闲的/16段,绝大多数时候没问题,但如果你有多个环境跑在同一台机器上,或者需要和公司内网、其他系统的网段做路由规划,自动分配的段很可能撞车。撞车的典型表现是容器起不来,报Pool overlaps with other one on this address space。手工指定一个不太可能被占用的段(比如172.28.x.x这种私有段里的非默认位置),能避免后续很多莫名其妙的冲突。网关参数可以省略,Docker 会自动取子网的第一个可用地址。
接着起两个容器,都接到appnet上:
docker run -d --name backend --network appnet <后端服务镜像> docker run -d --name frontend --network appnet <前端或调试镜像>关键就是--network appnet这一句。省略它,容器会落到默认bridge网络上,前面说的所有名字解析能力全部失效。这里没有-p参数,因为没有必要——容器之间通信走的是容器网络内部,不经过宿主机端口。这一点后面还会细说。
如果想让backend多一个更好记的名字,加别名:
docker run -d --name backend --network appnet --network-alias db --network-alias database <后端服务镜像>这样在frontend里,backend、db、database三个名字指向同一个地址,老配置文件里写的db不用改就能直接迁移过来,这是我在做遗留系统容器化时最常用的技巧。
2.2 验证解析和连通:dig、getent、nc 三件套
容器起来之后别急着改业务代码,先验证名字解析。进到frontend容器里:
docker exec -it frontend sh然后按顺序做三件事。第一,看/etc/resolv.conf:
cat /etc/resolv.conf如果你看到nameserver 127.0.0.11和options ndots:0,说明当前网络是自定义网络,内嵌 DNS 生效了。如果看到的是宿主机那几个上游 DNS 地址,基本可以确定容器落在了默认bridge网络上,后面不用测了,先解决网络接入问题。
第二,测名字解析。getent hosts backend是我最推荐的方式,因为它走的是系统的 NSS 解析链路,和业务程序实际调用的路径一致;nslookup和dig走的是自己的解析器实现,有些精简镜像里压根没装。真要用dig,注意镜像里可能得先装包,用轻量镜像的话这一步经常卡住。
getent hosts backend正常返回一行172.28.0.2 backend,说明解析通了。
第三,测端口连通。解析通不等于端口通,服务没监听、监听在127.0.0.1、防火墙规则拦住了,都会让连接失败:
nc -zv backend 8080nc不一定有,没有就用curl -v http://backend:8080/health或者直接wget -qO-。这一步能明确区分“域名解析问题”和“服务可用性问题”,排障时先做这个二分,能省大量时间。
2.3 端口这件事最容易搞混:容器内端口 vs 宿主机发布端口
这是新手踩得最多、老手也偶尔犯迷糊的地方。假设backend里的服务监听 8080,你在docker run的时候写了-p 9000:8080,那么:
- 从宿主机访问,走
localhost:9000; - 从
frontend容器访问,走backend:8080,不是backend:9000。
原因很直白:-p做的是宿主机端口到容器端口的映射,这条规则只对“从宿主机发起的连接”生效。容器之间的流量在网桥内部直接转发,不经过宿主机那层 NAT 规则,所以看到的是容器自己的监听端口。我见过有团队把映射端口写进服务间调用的配置里,在本地开发时碰巧因为服务跑在宿主机上能通,一上容器编排环境就全挂。
反过来说,容器之间互访根本不需要-p。你甚至可以只给需要对外暴露的入口服务(比如网关或前端)做端口映射,数据库、缓存、内部 API 全部不映射,宿主机上根本扫不到这些端口,攻击面自然就小了。这是用自定义网络做安全收口最直接的一招。
还有一种情况:服务在容器里绑定了127.0.0.1。这时候别的容器访问它的 IP 一定连不上,因为它只监听回环地址,网卡上的地址不接。解决办法是让服务监听0.0.0.0,或者容器里那个具体网卡的地址。这个坑在处理一些默认只绑本地的中间件配置时特别常见。
3. Compose 场景:服务名即域名,以及它的边界
3.1 默认网络与隐式别名:不写 networks 也能通的原因
用docker compose up起一组服务,很多人发现自己在 yaml 里根本没写任何网络配置,api里写http://db:5432却直接能通。原因是 Compose 在没有显式声明网络的情况下,会自动创建一个名为<项目名>_default的网络,把所有服务接进去,并且把服务名注册成网络别名。项目名默认取当前目录名,可以用-p参数或name:字段覆盖。
这套隐式行为很方便,但也带来了两个隐患。第一,项目名依赖目录名,同一个 compose 文件在不同目录下跑,网络名不一样,容器名也不一样;第二,所有服务都在同一张默认网络上,互相之间完全打通,一个做静态资源的小容器也能直连数据库。对于本地开发无所谓,但只要有稍微严肃一点的数据,就该显式划分网络。
顺带说一个常见困惑:用docker compose run起的临时容器,能不能解析到服务名?能,因为它也会被接进项目的默认网络。但用docker run手动起的容器就解析不到,除非你显式--network <项目名>_default把它接进去。这解释了为什么很多人在docker run里调试时一脸懵。
3.2 多网络编排:把数据库藏起来
真正要解决的问题不是“能不能通”,而是“谁能跟谁通”。我的习惯是至少分两张网络:一张给对外暴露或者需要被宿主机访问的服务,一张给数据层。下面这段结构我在好几个项目里都用了,可以直接抄:
services: gateway: image: <网关镜像> ports: - "8080:8080" networks: - front api: image: <后端服务镜像> networks: - front - back db: image: <数据库镜像> networks: - back networks: front: back: internal: trueapi同时接在两张网络上,所以它既能被gateway按名字访问,也能按名字访问db;gateway只在front上,解析不到db;db只在back上,宿主机和front里的服务都碰不到它。internal: true更进一步,把这张网络的对外出口关掉,back里的容器访问不了外网。这个设置适合纯数据层,但要注意副作用:内部网络里的容器拉镜像、访问外部 DNS 都会失败,所以镜像必须提前拉好,而且服务启动时如果依赖外部时间同步或者配置中心,得提前想清楚。
有一点需要留意:internal: true的网络里,内嵌 DNS 仍然能解析同网络内的容器名,但外部域名会解析失败,报出来的是超时而不是NXDOMAIN,如果没意识到是网络配置导致的,很容易往 DNS 配置上查半天。
想给运行中的容器临时加一张网络,不用重启:
docker network connect appnet frontend这条命令我经常用来挂一个调试容器到目标网络上,跑完docker network disconnect摘掉,比改 compose 文件重启整套服务快得多。
3.3 depends_on 与健康检查:启动顺序不等于就绪
depends_on是另一个高频误解点。短语法写的depends_on: [db]只保证启动顺序——Compose 会先启动db容器,再启动api容器。它不保证db已经准备好接受连接。数据库容器从创建到能响应连接,中间有初始化、建库、加载配置一大串事情,几秒到几十秒都有可能。
如果api启动时就要连数据库、连不上就退出,那短语法完全救不了你:api会在db还没就绪的时候冲上去,然后挂掉。restart: always能让它反复重试直到成功,但这属于“用重启解决顺序问题”,日志里一堆失败记录,看着很不优雅。
正确的做法是配合健康检查,用长语法等待:
services: db: image: <数据库镜像> healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres || exit 1"] interval: 5s timeout: 3s retries: 10 networks: - back api: image: <后端服务镜像> depends_on: db: condition: service_healthy networks: - front - backtest里的命令要用目标镜像里真实存在的工具,比如数据库自带的那套客户端命令,别随手写curl,精简镜像里大概率没有。interval和retries相乘出来的时间,要覆盖住服务最坏情况下的启动耗时,宁可给宽一点。还有一个细节:健康检查通过之后,Compose 才启动下一个服务,但下游应用自己的连接池初始化仍然可能因为瞬时并发太高而失败,所以客户端层面的重试逻辑该有还是得有,别指望编排工具包办一切。
docker compose up --wait这个参数值得记住,它会等到所有带健康检查的服务都变成健康状态才返回,写自动化脚本的时候比up -d顺手。
3.4 跨 Compose 项目与跨宿主机:边界在哪里
同一台机器上跑两个 Compose 项目,一个项目里的服务想访问另一个项目,默认是不行的——两边各自在<项目>_default网络里,网络不通,名字也解析不到。解决办法有两个。
一个是显式声明外部网络,让两边都接进去:
networks: shared: external: true name: shared-net先用docker network create shared-net建好,然后两个项目都引用。好处是网络复用、名字直接可用;代价是这张网络的生命周期脱离了任何单个项目,docker compose down不会删它,得手工清理,不然残留一堆没用的网络。
另一个办法是给网络指定固定名字:
networks: default: name: myproject-net这样项目名变化不影响网络名,但要注意不同项目用同一个固定名会导致网络被复用,服务混在一起,名字可能冲突。
跨宿主机的情况完全是另一回事,自定义 bridge 网络只在单机范围内有效,容器名解析不出去。多机部署要么上 Swarm 的 overlay 网络,要么用编排系统自带的服务发现,要么老老实实走外部负载均衡或者服务注册中心。这块超出标题范围,但方向得清楚:名字互访是单机能力,别指望把它当跨机方案用。
4. 故障排查实录:名字解析失败的常见原因清单
4.1 一张速查表覆盖八成问题
排障最怕无头苍蝇,我整理了一张表,按出现频率从高到低排,遇到no such host或connection refused先对着扫一遍。
| 现象 | 大概率原因 | 验证方式 | 处理 |
|---|---|---|---|
容器内/etc/resolv.conf是宿主机 DNS | 容器在默认 bridge 网络 | docker inspect -f '{{json .NetworkSettings.Networks}}' <容器> | 重建容器接自定义网络 |
名字解析报bad address | 两个容器不在同一网络 | docker network inspect <网络>看成员 | docker network connect接入 |
| 解析到了旧 IP | 客户端有 DNS 缓存或长连接 | 对比getent hosts与实际 IP | 缩短 TTL、加连接重试 |
| 名字能解析但连不上 | 端口不对或服务只绑了 127.0.0.1 | nc -zv <名字> <端口> | 改监听地址、核对容器内端口 |
| 偶发解析失败 | 目标容器在重启、DNS 记录短暂缺失 | docker ps看目标状态 | 客户端加重试 |
container name already in use | 同名容器残留 | docker ps -a | 删旧容器或换名 |
| 网络创建报地址池重叠 | 子网与已有网络冲突 | docker network ls逐个 inspect | 换子网段重新创建 |
| 容器起来就退,日志提示连不上依赖 | 依赖未就绪,depends_on只管顺序 | docker compose logs <服务> | 加健康检查与重试 |
这张表用久了会发现,真正的“DNS 坏了”极少,绝大多数是网络没接对、端口对不上、或者目标没在跑。
4.2 三次排障过程复盘
第一次印象很深,一个服务死活连不上db,报no such host。进容器看resolv.conf正常,getent hosts db也能解析出 IP,说明 DNS 没问题。但nc -zv db 5432超时。查了半天发现db容器的配置里监听地址被改成了127.0.0.1,只接受本机连接。这种情况下 DNS 全对,问题在服务本身。定位方法就是那一步端口测试,如果当时跳过直接去查网络,方向就跑偏了。
第二次是容器重建之后,调用方一直报连接超时,getent明明拿到了新 IP。原因是应用用了带 DNS 缓存的 HTTP 客户端,缓存时间设得特别长,容器重建后它还在用旧地址。这类问题最迷惑人,因为你在容器里手工测什么都正常,只有业务代码出错。后来统一规范:客户端连接池加健康检查,DNS 缓存时间压到 30 秒以内,长连接加定期重连。
第三次是docker compose up之后api一直重启,日志里全是连接被拒。db容器是起来了,但初始化脚本要跑将近一分钟。depends_on用的是短语法,api刚创建就去连,当然失败。后来改成健康检查加condition: service_healthy,问题消失。这个坑我后来又遇到过变种:健康检查命令写错了,检查永远失败,api一直等不到启动,日志里什么都没有,只能靠docker inspect看容器状态里的Health字段才发现。
4.3 那些不容易想到的坑:DNS 缓存、MTU 与 prune
先说一个悬案级的:某些 Java 服务在容器里跑,第一次解析正常,服务重启之后就再也解析不到新的容器了。这是 JVM 层面的 DNS 缓存导致的,默认缓存策略会长期持有解析结果。解决办法是在启动参数里加上-Dsun.net.inetaddr.ttl=0之类的设置,或者干脆保证容器名对应的 IP 不轻易变化。这类问题不属于 Docker 的锅,但排查时很容易归错因。
再说 MTU。默认 bridge 网络的 MTU 一般是 1500,容器网络继承这个值。如果宿主机本身的网络链路 MTU 更小(比如某些云环境的覆盖网络),会出现小包正常、大包丢的现象,表现是ping通、curl小接口正常、传大文件或大响应体就超时。这个坑非常隐蔽,因为名字解析完全正常,只有大流量出错。排查方式是ping -M do -s 1472 <名字>逐步缩小包体,找到实际能通过的大小,然后在docker network create时用--opt com.docker.network.driver.mtu=1400之类的方式统一调低。
最后说docker network prune。这个命令会删掉所有没有被运行中容器使用的自定义网络。看着无害,实际很坑:如果某个环境整体停掉了(docker compose stop而不是down),它的自定义网络处于“无容器使用”状态,一执行 prune 就没了,下次start的时候容器找不到原网络,起不来或者被塞进默认网络。我现在的习惯是执行前一定加--filter,或者干脆不用这个命令,改成定期清理时先docker network ls人工过一遍。
5. 生产环境里的取舍:网络怎么划分才不出事
5.1 网络分层与命名规范
前面讲了怎么让容器按名互访,落到实际项目里,更关键的是“允许谁访问谁”。我的做法是按层建网络,命名上带项目前缀和层级语义,比如orders-front、orders-back、orders-monitor。接入规则只有一条:每个容器只接它必须通信的那几张网络,能少接一张就少接一张。
分层的粒度不用太细,三张网络基本能覆盖大多数场景:入口层放网关和需要暴露端口的服务,应用层放业务服务,数据层放数据库和缓存。中间件类的服务(消息队列、注册中心)单独一张网络,只让需要它的应用接进去。这样做的直接好处是爆炸半径小——某个服务被攻破,它能看到的邻居是有限的,而不是整台机器上所有容器。
命名规范上还有个小技巧:把网络名字和 Compose 项目名解耦,用name:字段固定成有意义的名字,这样项目目录改名、重建栈的时候,外部依赖这张网络的组件(比如监控采集、运维脚本)不受影响。代价是网络残留需要手工管理,所以我会在部署脚本里加一步检查,确认网络存在再继续。
5.2 安全边界与可观测性
从名字互访这个能力本身出发,安全的重点不在“能不能解析”,而在“解析出来之后能做什么”。通了网络就意味着全端口可达,不像宿主机那样还有端口映射这层收口。所以靠-p只暴露必要端口这种做法,只保护了宿主机这一侧,容器之间的横向流量是完全敞开的。真要收紧,得靠网络分层本身,加上服务侧的认证。
可观测性这块,我踩过一个坑:容器网络内部流量不走宿主机端口,很多基于端口抓包的监控手段看不到。想看清服务之间的调用关系,要么在应用层打点,要么在每个容器旁边挂一个抓包或者代理组件,接入到同一张网络里。后者在排查疑难问题时特别有用——挂一个带tcpdump的调试容器到目标网络,直接监听网卡,比翻日志快得多。
最后分享一个我在实际项目里固定下来的习惯:任何新服务接入,第一件事不是写业务配置,而是先起两个一次性容器,验证名字解析、验证端口连通、验证网络隔离,三个都过了再动代码。这三个验证加起来不超过五分钟,但能挡掉后面几小时的排查。容器网络这个东西,机制本身不复杂,麻烦的永远是那些没被验证过的假设。