“谁能想到,一个下划线 _ 竟让我的 Docker 容器‘失联’了?”这句话是我上周排查一个故障之后发自内心的感叹。当时我用 Docker Compose 把一个后端服务和 MySQL 拆成两个容器,负责管理的服务叫user_api,数据库叫user_db——名字里都带下划线。结果 API 服务一路启动,一路在日志里刷连接数据库失败,提示lookup user_db: no such host,两个容器明明都是 Up 状态,却像是互相“失联”了一样。
如果你也遇到过 Docker 容器之间互相访问时不时超时、换 IP 直连就好、用服务名就不行这类怪事,这篇排查过程很值得看一遍。我会把定位问题的每一个步骤、背后的命名的规范、以及最终的解决方案都写清楚,适合所有用 Docker Compose 组织多容器应用的朋友,尤其是刚把服务拆开部署、开始踩网络坑的新手。
1. 事故现场:两个容器都活着,却互相“失联”
先描述一下当时的部署情况。项目结构非常简单,docker-compose.yml里定义了两个服务:
services: user_api: image: my-backend:latest environment: DB_HOST: user_db DB_PORT: "3306" ports: - "8080:8080" networks: - default user_db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 networks: - default networks: default: driver: bridge两个服务都在同一个自定义网络myapp_default里跑着。docker ps 一看,状态如下:
| 容器 | 镜像 | 状态 | 容器 IP | 映射端口 |
|---|---|---|---|---|
| myapp_user_api_1 | my-backend:latest | Up | 172.18.0.2 | 0.0.0.0:8080->8080/tcp |
| myapp_user_db_1 | mysql:8.0 | Up | 172.18.0.3 | 0.0.0.0:3306->3306/tcp |
从宿主机访问这两个端口都正常,MySQL 也能通过mysql -h 127.0.0.1 -P 3306连上。但user_api容器里的进程就是连不上user_db,日志永远停在这样一行:
2025/03/02 10:12:44 dial tcp: lookup user_db on 127.0.0.11:53: no such host这句日志非常关键。它不是connection refused,不是timeout,而是lookup user_db ... no such host。到了这一步,基本可以断定问题出在名字解析,而不是端口、防火墙、账号权限。可问题也来了:两个容器明明确认在同一网络里,MySQL 的 IP 也查得到,为什么名字解析会失败?
我后来复盘,最大的干扰项就是“下划线”。因为 Docker 允许容器名、服务名里带_,Compose 自动生成的一长串容器名本身就带着下划线,比如myapp_user_api_1。平时你docker exec -it myapp_user_api_1 sh进容器、用docker logs myapp_user_api_1看日志,全都正常工作,所以你根本不会把“下划线”和“网络失联”联系在一起。它就像一个潜伏的刺客,平时不声不响,一到关键访问就给你一刀。
2. 排查链路:从 ping 不通一路查到下划线
这类故障最忌讳上来就猜,最好按层一层层剥。我当时的排查顺序是:网络拓扑 → IP 直连 → DNS 配置 → 名字解析对比 → 最小化复现。每一步都有明确结论,最后才锁定下划线。
2.1 先确认网络拓扑,排除“不在同一网络”的低级错误
多容器互访失败最常见的原因,其实是它们压根不在同一个自定义网络里。比如一个容器挂在bridge默认网络,另一个挂在myapp_default,双方各走各的虚拟网桥,自然互相不可见。这个问题使用习惯越好越不容易踩,但排查时仍然要第一个排除。
我执行了:
docker network inspect myapp_default输出里能看到子网是172.18.0.0/16,下面挂着两个容器的条目,而且两个容器的 IPv4Address 一个是172.18.0.2/16,一个是172.18.0.3/16。确认它们就在同一个 L2 网络里。这一步排除掉“网段隔离”和“错挂网络”后,问题范围缩到了 DNS/主机名解析。
这里我多说一句:很多人习惯用docker inspect <容器名>看 IP,但更推荐直接看整个网络,因为能把所有同网段容器放在一张视图里,谁连上了、谁没连上、IP 有没有重复,一眼就能扫出来。
2.2 用 IP 直连,确认容器本身和端口都没问题
既然网络拓扑没问题,我再验证应用级连通性。进入myapp_user_api_1,尝试用 IP 直连数据库端口:
docker exec -it myapp_user_api_1 sh # 容器里没有 nc 时,用 wget 或者 bash 的 /dev/tcp 也可以 # 我用的是连接测试工具: / # wget -qO- http://172.18.0.3:3306MySQL 的 3306 端口即使不认 HTTP 协议,只要监听正常,连接动作本身会成功,wget 会报 HTTP 相关错误,但不会报连接失败。这一步的结论是:TCP 层面完全能接通,MySQL 进程在监听,容器之间的三层路由和二层转发都没有问题。
这就把范围进一步缩到了“为什么名字 user_db 不被解析”。因为如果 IP 能通但名字不通,问题只可能是 DNS 解析链路上的某个环节。
2.3 查看容器 DNS 配置,确认走的是 Docker 内置 DNS
再看容器里的 DNS 配置和 hosts 文件:
docker exec -it myapp_user_api_1 cat /etc/resolv.conf输出:
nameserver 127.0.0.11 options ndots:0这个127.0.0.11就是 Docker 内置 DNS 服务。在自定义网络里,每个容器的/etc/resolv.conf都会被指向它,容器名、网络别名、Compose 服务名的解析都靠它完成。解析不了的域名,它会转发给宿主机配置的上游 DNS。
同时我也看了/etc/hosts:
docker exec -it myapp_user_api_1 cat /etc/hosts里面只写了容器自身的主机名和两个 IP 相关条目,并没有user_db。这是一个非常常见的认知误区:很多人以为 Docker 会把同一网络里所有容器名都写进/etc/hosts,实际上默认并不会。容器之间互相访问依赖的是内置 DNS,不是静态 hosts 文件。这个机制差异对后面理解故障至关重要。
2.4 nslookup 对比测试,发现“工具说能解析,应用却不行”
因为瘦身镜像里通常没有 nslookup,我直接起了一个临时网络排查容器nicolaka/netshoot,加入同一个网络来查询:
docker run --rm --network myapp_default nicolaka/netshoot nslookup user_db结果很微妙:在 netshoot 容器里,user_db能够被解析成172.18.0.3。我当时愣了一下,因为 API 服务的 Go 进程明明报no such host。同一个名字、同一个网络、同一个 DNS 服务器,一个能解析,一个不行。
再试user-db(把下划线换成连字符),也能解析到同一个 IP。这已经强烈暗示问题不在 DNS 服务端,而在“客户端对主机名字符的接受程度”。有些工具会比较宽容,有些工具会严格执行主机名规范,下划线就是那个被严格拒绝的字符。真正排查到这里时,我已经有八分把握是下划线惹的祸,但还差最后一个实锤。
2.5 最小化复现:只把下划线改成连字符,故障消失
我回到docker-compose.yml,把服务名user_db全部改成user-db,包括环境变量里的DB_HOST=user-db,然后执行:
docker compose down docker compose up -d两个容器重建后,API 服务日志立刻恢复正常,数据读写得非常顺畅。整个故障前后只改了一个字符,现象就完全消失。到了这里不需要再看别的,结论就是:容器间服务名/主机名里的下划线,直接破坏了部分客户端库对主机名的合法性校验,导致“失联”。
3. 根因拆解:下划线是如何“合法地”变成定时炸弹的
找到凶手只是第一步,把它背后的机制讲清楚,才能确保以后不再犯。
3.1 Docker 内置 DNS 的工作原理,没你想的那么“严格”
在用户自定义网络模式下,Docker 会给每个容器注入一个监听在127.0.0.11:53的嵌入式 DNS 代理。这个代理负责三件事:
- 解析同一个网络里的容器名,比如
myapp_user_db_1 - 解析 Compose 服务名和网络别名,比如
user_db、user-db - 解析外部域名,转发到宿主机配置的上游 DNS
它对“名字长什么样”的容忍度其实相当宽松。Docker 在创建容器、创建网络别名时,只做了很基础的字符检查,_是允许出现在容器名里的,否则 Compose 自动生成的myapp_user_api_1这种名字本身就活不下去。这带来了一个盲区:凡是 Docker 管理层面允许的名字,用户就会默认它是安全的、可解析的,但应用层未必这么想。
所以你会看到一种分裂现象:docker exec能进、docker ps正常、nslookup 也能出结果,偏偏业务进程连不上。问题不在 Docker 本身,而在业务进程使用的语言运行时或网络库,它们对主机名字符的校验比 Docker 严得多。
3.2 主机名规范 RFC 952 / RFC 1123:下划线从来不是合法主机名
这里涉及一个几十年前就定下的规则。传统 DNS 主机名规范由 RFC 952 定义:主机名只能包含字母、数字、连字符,并且应当以字母或数字开头。后来的 RFC 1123 放宽了数字开头的限制,但始终没有把下划线列为合法字符。
下划线并不是完全不可以出现在 DNS 世界里,它被允许用于 SRV 记录和 TXT 记录这类“服务记录”,例如_http._tcp.example.com。但那是用于表示服务类型和协议,不是用来给某台主机当名字的。一台真正的主机,它的 A/AAAA 记录对应的主机名里出现下划线,在严格校验的客户端里就是不合法。
Go 语言的标准库net包就是一个典型。它内部对主机名有isDomainName这类合法性检查,遇到带_的名字,会直接判定为非法域名,拒绝发起后续解析流程。所以 Go 进程访问user_db时,即使 Docker 内置 DNS 能查到记录,客户端那层已经先把名字“毙”了。表现到日志上,有的版本是no such host,有的版本是invalid domain name,本质都是同一个原因。
3.3 从“能启动、能 ping IP”到“服务连不上”,缺的是哪一环
很多人会被“容器能启动、IP 直连也通”迷惑,觉得网络一定没问题。这里我重新梳理一下链路,你会发现失败点非常靠前:
- 容器创建、路由转发、TCP/IP 连接:完全不关心名字里有没有下划线,所以
docker ps是绿的,端口映射是通的,IP 直连也是通的。 - 业务进程发起连接时:先对目标主机名做字符串校验,这里下划线就可能直接出局。
- DNS 解析:即使查询发出去了,某些客户端也会因为校验失败而放弃。
- TCP 连接建立:只要走到这里,基本就成功了,但前两步已经拦住。
所以“容器失联”这个说法要打引号:网络其实没有断,断的是“名字信任链”。容器管理层面很宽容,应用层很严格,下划线正好卡在两者之间。这类问题最坑的地方在于它不是每次都失败,工具、语言、版本一变,表现就不一样,很容易让人觉得是玄学。
4. 解决方案:改名、别名还是 extra_hosts,按场景取舍
定位到问题之后,方案其实很清晰:让服务间的访问名称符合主机名规范。但具体怎么改,要看你的存量情况。
4.1 最推荐:把服务名、容器名统一改成小写字母加连字符
干净、彻底、一劳永逸。修改后的 Compose 文件大致如下:
services: user-api: image: my-backend:latest environment: DB_HOST: user-db DB_PORT: "3306" ports: - "8080:8080" networks: - default user-db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 networks: - default networks: default: driver: bridge改完后一定要docker compose down再docker compose up -d。注意:如果只执行docker compose up -d,Compose 可能因为检测不到服务定义变化而不重建配置,或者新老容器并存,导致你以为改坏了。最稳妥的办法是 down 干净,再把旧容器清掉:
docker compose down -v这里-v会删除容器关联的卷,如果数据库有初始化数据,执行前务必确认数据已经备份或者可以接受重建。不放心的话可以只docker compose down不加-v。
为什么连字符是最优解?因为 RFC 952/1123 允许、Docker 允许、主流语言校验全部通过,而且你在 Compose 文件里写DB_HOST: user-db也好、写成环境变量注入也好,都不会再撞上字符校验问题。连字符在 Docker 自动生成环境变量时会有一次“翻译”(连字符变成下划线、字母变大写),但只要你显式传入环境变量,就不用依赖那些自动生成的变量名,不会踩坑。
4.2 不想改容器名:用网络别名给服务一个“干净身份”
如果某些遗留系统已经把user_db这个名字写死在配置文件里,批量改名成本高,你可以不改容器名,而是给容器增加一个合规的网络别名,比如user-db。Docker 内置 DNS 会把别名解析到同一个容器 IP,其他容器用user-db访问即可:
services: user_db: image: mysql:8.0 container_name: user_db networks: default: aliases: - user-db关键是 YAML 的缩进,networks后面跟着的是你创建的网络名(默认就是default),aliases是这个网络下该容器的别名列表。配好之后,在user_api里连user-db:3306就和连user_db:3306效果一样。
这个方案我实际用过多次,最适合存量项目微调。它只改 Compose 文件的一小段,业务代码里把连接地址换一下就行,不用动容器名、不用改镜像、不用重建目录结构。需要注意一点:别名只在同一个网络内有效,多个容器如果都声明了同一个 alias,解析结果会有随机性,千万别重复。
4.3 兜底方案:extra_hosts 和应用层环境变量
第三个思路是绕过 DNS,直接往容器的/etc/hosts里写映射:
services: user-api: extra_hosts: - "user-db:172.18.0.3"这个方案适合临时排查,不适合长期使用。因为容器 IP 会随重建变化,写死 IP 等于把“易变信息”变成了“静态配置”,容器一旦重建就失效。真要长期用的话,还得靠脚本动态更新 IP,反而更麻烦。
更实用的兜底,是把业务代码里的数据库地址完全改成环境变量驱动:
environment: DB_HOST: user-db这样即便哪天主机名再出新问题,比如证书匹配不上、需要切换域名,你只需要改 Compose 里的一行配置,不用重新构建镜像。很多框架默认就从环境变量读取配置,比如 Spring Boot 的SPRING_DATASOURCE_URL、Go 项目里常见的DB_HOST,使用起来非常顺手。
5. 同类隐形命名坑:这些字符也不让人省心
下划线既然能坑人,其他“看起来合法、用起来暗雷”的命名字符同样值得列一列。我整理了一张表,都是我实际见过或者亲手踩过的:
| 字符/命名风格 | Docker 容器名是否允许 | 典型故障现象 | 建议 |
|---|---|---|---|
下划线_ | 允许 | 部分客户端主机名校验失败,服务间解析失败 | 用连字符替代 |
| 大写字母 | 允许 | 主机名大小写错乱,DNS/证书匹配不一致 | 全部小写 |
点号. | 允许 | DNS 把名字当成 FQDN 的一部分,搜索域拼接后解析到错误节点 | 除非模拟真实域名,否则避免 |
| 驼峰命名 | 允许 | 自动生成环境变量时被改写,配置对不上 | 全小写加连字符 |
| 空格/中文/emoji | 大多数场景不允许 | 创建失败或解析乱码 | 直接不用 |
连字符- | 允许且最安全 | 几乎没有 | 默认选项 |
5.1 点号的坑:你以为在访问服务名,实际可能被当成了完整域名
点号在 DNS 语义里是层级分隔符。如果你给 Compose 服务起了user.db这种名字,某些解析工具会把它当作一个完整的 FQDN 处理,而不是当前网络里的服务别名。它会在搜索域里拼来拼去,最终很可能解析到宿主机 DNS 返回的外部记录,或者干脆解析失败。而且名字带点号后,和 SSL 证书、TLS SNI 的域名匹配逻辑也容易纠缠在一起,平白给排查增加难度。
5.2 --link 时代的自动环境变量:连字符也会被“翻译”
在比较老的 Docker 使用方式里,--link会给容器注入很多自动生成的环境变量,例如:
MYSQL_PORT_3306_TCP_ADDR=172.18.0.3 MYSQL_PORT_3306_TCP_PORT=3306这个命名规则会把-转换成_,字母全部大写。如果你在 Compose 里给服务命名用了连字符,然后想当然地在代码里引用“和配置一模一样的自动环境变量名”,很容易对不上。现代 Compose 部署我建议显式通过environment传入自定义变量,不要依赖--link遗留的自动环境变量体系,既清晰又可控。
5.3 Compose 项目名和目录名下划线:它不是第一个雷
除了服务名,Compose 的项目名默认取自目录名。如果你的项目目录叫my_shop,容器名会带上这个前缀,比如my_shop_user_api_1。虽然不影响容器间访问的解析逻辑,但会让docker ps的输出乱七八糟,脚本批量操作时容易眼花。解决方式很简单,在.env文件里加一行:
COMPOSE_PROJECT_NAME=myshop或者启动时显式指定:
docker compose -p myshop up -d让项目名干干净净,不光看着舒服,排查问题时也更省力。
6. 排查类似“失联”问题的操作清单
最后把我这次的经验浓缩成一张排查清单,下次你再遇到容器间互访异常,按这个顺序走,比逐个工具乱试高效得多。
- 看业务日志,区分是解析失败(
no such host、invalid domain name)、连接拒绝(connection refused)还是超时(timeout)。解析失败直接跳去检查名字和 DNS,别先动防火墙。 docker network inspect确认两个容器在同一个自定义网络。- 用 IP 直连目标端口,确认网络层和应用层正常。
- 进入容器看
/etc/resolv.conf,确认 nameserver 是127.0.0.11,明确走的是 Docker 内置 DNS。 - 用带网络工具镜像的临时容器跑
nslookup,多次对比带下划线和连字符的解析结果。 - 把可疑服务名临时改成规范名称,重启容器验证。这一步是终极实锤。
我个人的习惯是,服务名、容器名、网络别名全部使用[a-z0-9-]这个字符集,也就是小写字母、数字、连字符,坚决不用下划线。这套规则我已经用了很久,容器间访问再没有出现过因为名字字符导致的“失联”。
还记得那次故障解决后,我笑着跟同事说,如果有人再跟我说“下划线也能出现在主机名里”,我会让他先跑一次 Go 项目连接user_db试试。名字这东西,看起来是个小事,但越是小地方,越容易在关键时刻给人上一课。