在部署多个服务的时候,我们会遇到一个特别头疼的问题:服务之间的地址总是变来变去。比如我们的网站由一个前端和一个后端组成,前端需要请求后端接口。如果在前端配置里写了后端容器的IP地址,比如172.18.0.5,看起来没问题。但只要后端容器一重启,这个IP就变了,前端又找不到它了。更麻烦的是,当我们把后端服务扩容成三个副本,IP又多了几个,到底哪个有效呢?这些情况几乎每天都会在生产环境发生。

使用 Docker Compose 编排多个容器时,所有服务会默认加入同一个用户自定义网络。在这个网络里,每个服务都有一个固定的“名字”——其实就是服务名。其他服务只要用这个名字去访问,就能自动找到这个服务当前使用的真正IP,完全不用担心IP变化。这个机制,就是我们要讲的服务发现。

其实这种机制和我们平时用手机通讯录一样。我们不需要记住每个人当前的手机号码,只要存了姓名,打电话时手机通讯录会自动找到当前号码。容器也一样,每个服务在通讯录里存的是自己的“姓名”(服务名),而号码(IP)由Docker动态分配。服务发现就是那个通信录。

一、应用场景:当你不得不在容器间找“人”

这种问题最常见的场景是微服务架构。一个简单的电商网站,前端、订单服务、用户服务、数据库,每个服务一个容器。前端要调用订单服务,订单服务要读取数据库,如果这些调用关系里的地址都写成固定的IP,那么只要任何一个服务重启,依赖它的人全都要跟着改配置。这不是开发问题,是运维的噩梦。

尤其是在本地开发时,为了模拟不同环境,我们经常要启动多个Compose项目。每个项目的容器IP每次都不一样。比如今天你启动项目,数据库IP可能是172.20.0.3,明天可能变成172.20.0.7。如果你的代码里写死了172.20.0.3,那明天启动起来一定会崩溃。此时服务发现的价值就体现出来了:用服务名代替IP,容器无论怎么重启,服务名不变,调用关系就永远成立。

另一个典型场景是扩容。假设你的API服务当前只有一个容器,后来为了应付流量,你想把它扩展到三个容器。如果前端是拿着单个IP去访问,扩容之后要么旧IP失效,要么请求只会发到那一个容器上。但如果你写的是服务名,DNS会在内部把服务名解析到这三个容器的IP上,虽然Compose的DNS不是完整的负载均衡,但至少你不需要修改前端配置就能让后端有多个副本。

二、服务发现到底是怎么工作的?

先说明一下,我们这里说的服务发现,不是像Consul或者Eureka那种独立注册中心,而是Docker内置的网络DNS功能。当你启动Compose项目的时候,Compose会创建一个默认的网络(通常是bridge网络),然后把每一个服务对应的容器都加到网络里。Docker自带的DNS服务器会记录每个容器和它的IP地址。只要你知道服务的名字,就可以在同一个网络里用这个名字去访问。

这里的关键点:容器启动多少次,IP怎么变,DNS里的记录都会自动更新。所以应用里不需要写IP。你只需要在代码里写服务名作为主机名,剩下的DNS帮你搞定。每个容器启动时,Docker会在它内部写入一个DNS配置,指向内置的DNS服务器。容器内部的解析配置文件里会记录这个地址。当你用服务名做请求时,系统就会去向它查询。

可能有人会问,那端口呢?容器内的端口也是固定的,比如API服务监听8000端口。通过服务名访问时,就写 http://api:8000,注意这里的 api 是服务名,8000 是容器内部的端口。这跟我们从外部访问不一样,外部访问时要用宿主机端口映射,比如 localhost:8080。后面我们会有例子。

三、最基础的配置:两个服务之间互相找到

现在直接看配置。下面这段 docker-compose.yml 定义了两个服务:一个是 api,一个是 web。为了方便演示,假设 web 就是前端,需要请求 api 接口。

# 技术栈:Docker Compose
# 定义两个服务:api 和 web
services:
  api:
    # 一般使用你自己的镜像,这里用nginx做简单演示
    image: nginx:alpine
    # api 容器内部监听80端口,我们这里模拟它是后端接口
    ports:
      - "8081:80"        # 宿主机8081映射到容器80,外部可访问
    networks:
      - app-net

  web:
    image: nginx:alpine
    ports:
      - "8080:80"        # 宿主机8080映射到容器80
    networks:
      - app-net

# 定义一个自定义网络,这样两个服务才能互相解析
networks:
  app-net:

这段配置最关键的地方是:两个服务都加入同一个自定义网络 app-net。有了这个网络,web 容器内部就可以用 api 这个服务名去访问API了。我们来做个实验,先启动:

# 技术栈:Docker Compose CLI
# 后台启动所有服务
docker compose up -d

然后进入 web 容器,试着 ping 一下 api:

# 技术栈:Docker Compose CLI
# 进入web容器
docker compose exec web sh

# 在容器内用ping测试服务名对应的IP
ping api

如果一切正常,你会看到类似下面的输出:

PING api (172.19.0.2): 56 data bytes

这里 172.19.0.2 就是当前 api 容器的IP。下一次重启,这个地址很可能会变成别的,但没关系,只要 api 这个服务名不变,ping 和请求都能正常。我们再看一个更接近真实项目的例子:用 curl 请求 api 里的页面。

# 技术栈:Docker Compose CLI
# 在web容器内,用服务名访问api的80端口
curl http://api:80

在真实项目中,web 里的代码只需要把请求地址写成 http://api:80/data 这种方式。注意这里的 api 是服务名,而不是IP,代码不知道也不关心容器的IP到底是多少。这种用法下,我们根本不需要在代码里做任何特殊处理,只要坚持用服务名替换IP即可。

3.1 外部访问和内部访问的区别

这里要特别强调内外之分。外部浏览器访问我们的服务,用的是宿主机IP和映射出去的端口,比如 http://localhost:8080。而服务之间通信,是在同一个内部网络里进行的,用的是服务名和容器内部端口,比如 http://api:80。这两个端口不一样,不能混淆。

四、环境变量里也能“动态”指向服务

有时候我们需要在启动时给服务注入一些配置,比如数据库连接地址。如果直接在环境变量里写死IP,那就和写死没什么两样。正确的做法是,在环境变量里填服务名。来看下面的配置:

# 技术栈:Docker Compose
# 一个应用服务,一个数据库服务
services:
  app:
    image: myapp:latest
    # 关键:数据库地址用服务名 db,而不是IP
    environment:
      - DATABASE_URL=postgres://user:password@db:5432/mydb
    networks:
      - app-net
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: mydb
    networks:
      - app-net

networks:
  app-net:

在这个例子里,应用容器里的连接串 db:5432,这里的 db 是Compose服务名。当应用启动去连接数据库时,会先通过DNS解析 db,得到数据库容器的当前IP。就算数据库容器重启,IP变了,DNS也会立刻更新,应用重新连接就能连上。你完全不用每次重启后去改配置。

替换思路也简单:以后环境变量里凡是出现主机名的地方,只要目标容器和当前容器在同一个网络里,就填对应的服务名,而不是IP。这样动态IP的问题就解决了。

五、服务别名:给服务起多个“小名”

有时候同一个服务需要被多个服务以不同名字访问,或者由于历史原因,代码里写死的域名不能改。这时候可以使用 aliases 给服务设置额外的别名。看这个例子:

# 技术栈:Docker Compose
# 三个服务:user-service、order-service、legacy-client
services:
  user-service:
    image: user-service:latest
    networks:
      app-net:
        aliases:
          - user-api

  order-service:
    image: order-service:latest
    networks:
      - app-net

  legacy-client:
    image: legacy-client:latest
    networks:
      - app-net

networks:
  app-net:

配置中 user-service 的别名是 user-api。这样 legacy-client 里如果写的是 http://user-api:8080,也能访问到 user-service。这个功能在迁移老系统的时候特别有用,老代码不用改,只要增加一个别名就能对接新服务。

六、技术优缺点

6.1 优点

首先,简单直接。不需要搭建任何额外的注册中心,Compose自带的DNS就能完成。这对小项目、本地开发、测试环境非常友好。

其次,配置成本低。只要把服务放进同一个网络,并互相使用服务名,就完成了服务发现。没有任何代码侵入,也不需要引入新的开发框架。

还有,更新及时。容器重启、扩容、缩容时,DNS记录会自动刷新,应用侧一般不会感知到IP变化。这对弹性伸缩非常有利。

6.2 缺点

第一,作用范围有限。这种服务发现只限于同一个Compose管理的网络里。如果服务分布在不同的宿主机,或者不同的编排系统里,单靠Compose的网络DNS就不够了,得用Swarm、Service Mesh或者Kubernetes里的服务发现机制。

第二,不支持复杂的健康检查。DNS服务发现主要负责解析地址,不会自动剔除不健康的容器实例。如果你有多个副本,其中某个挂了,DNS不会把它从解析结果中移除,可能会把请求发给已经不工作的容器。这在高可用要求较高的场景下是个问题。

第三,对跨语言和动态端口有限制。Compose网络内的服务端口是固定的,一般使用容器内部端口。如果服务每次使用的端口都不一样,那就需要额外的机制来处理,这是Compose的短板。

七、注意事项

7.1 一定要记得放进同一个自定义网络

如果两个服务不在同一个网络,服务名是无法解析的。比如一个在默认网络,一个在自定义网络,即使Compose都认识它们,它们之间也找不到彼此。所以在写配置时,要明确指定每个服务都加入同一个网络。

7.2 不要硬编码IP地址

哪怕现在看起来IP没变,也要用服务名。特别是配置文件、环境变量、代码里的域名,一律不要写具体的IP。如果版本管理里不小心写了IP,后面排查起来会很麻烦。

7.3 注意端口映射对内部通信没有影响

你的API服务可能映射了宿主机的8081端口,但内部网络里访问它使用的还是容器内的80端口。在设置环境变量时,要填 http://api:80,而不是 http://api:8081。今天同事就踩过这个坑,大家引以为戒。

7.4 用 depends_on 控制启动顺序时要理解它的局限

depends_on 只是控制容器启动的先后顺序,并不表示依赖的服务已经完全就绪。比如数据库容器启动了,但可能还没准备好接受连接。所以应用代码里最好要有重试机制,或者等待数据库真正可用的探针。不要把 depends_on 和服务发现混淆了,它们是两回事。

7.5 多副本场景下要了解解析行为

如果你把某个服务扩成多个副本,同一个服务名会解析出多个IP。某些客户端会反复使用第一个IP,导致请求集中在一个副本上。这时候你可能需要客户端侧的负载均衡能力,或者考虑使用专用的服务发现组件。Compose的DNS只给了最基础的轮询或者随机解析,并不能替代真正的负载均衡器。

八、总结

Docker Compose内置的服务发现功能,用一个“服务名”彻底解决了多容器之间IP动态变化的痛苦。在同一个网络里,你不需要知道容器到底在哪个IP上,只要用服务名去访问,一切都会自动更新。这个机制非常适合微服务开发、本地联调和中小型部署。

但这种简单方案也有边界。跨节点、大规模、高可靠场景,还是需要更专业的服务发现框架。不过对于大多数使用Compose的项目来说,在代码和配置里坚持写服务名,已经是最好、最省心的解决了。下次遇到容器重启后服务连不上的问题,先检查一下是不是又写死了IP,换成服务名,问题往往立刻消失。

如果在你的项目里还没有使用服务名,而是一堆IP地址,现在就可以花几分钟把它们改成服务名。相信我,下次容器重启你会感谢这个决定。