自定义Docker网络与容器通信实用指南

本指南深入探讨了自定义Docker桥接网络及其在容器通信中的作用。学习如何使用Docker CLI和Docker Compose创建、管理和连接容器。了解自定义网络如何实现自动DNS解析、改善隔离性并简化服务间通信,从而构建更健壮、可扩展的容器化应用。

自定义Docker网络与容器通信实用指南

自定义Docker网络是那种在你运行多个容器之前看似可选的功能。默认桥接网络可以快速测试,但用户定义的桥接网络能提供可预测的服务名称、更清晰的隔离性和更简单的调试。对于一个包含Web容器、API容器和数据库的小型应用,这种差异立竿见影:API可以连接到db:5432,而不是去追踪Docker今天分配的IP地址。

本指南聚焦于单台Docker主机上的用户定义桥接网络。覆盖网络、Kubernetes网络和Swarm服务发现解决了多主机环境中的相关问题,但桥接网络仍然是本地开发、小型部署和Docker Compose项目的日常工具。

为什么默认桥接网络会变得尴尬

Docker会自动创建一个名为bridge的网络。如果你在运行容器时没有指定网络,它们通常会落到这个网络上。它适用于简单场景,但对于多容器应用来说并不理想。

在用户定义的桥接网络上,Docker为容器名称和Compose服务名称提供内置DNS。在默认桥接网络上,基于名称的发现是有限的,而且传统的链接模式也不应作为构建基础。实际结果是,自定义网络让你可以使用稳定的主机名来配置应用:

DATABASE_HOST=db
REDIS_HOST=redis
API_BASE_URL=http://api:3000

这更容易阅读,更容易在不同机器之间迁移,并且比容器IP地址更不容易出错。

自定义网络还创建了更清晰的边界。连接到同一网络的容器可以相互通信。不同网络上的容器则不能,除非你将一个容器同时连接到两个网络,或者通过主机发布端口。这并非完整的安全模型,但这是一个有用的隔离层。

使用Docker CLI创建网络

创建一个用户定义的桥接网络:

docker network create app-net

在该网络上启动两个容器:

docker run -d --name db --network app-net -e POSTGRES_PASSWORD=devpass postgres:16
docker run -d --name adminer --network app-net -p 8080:8080 adminer

adminer容器中,数据库主机名是db。你不需要知道它的IP地址。

检查网络:

docker network inspect app-net

你会看到驱动、子网、网关和连接的容器。调试时,这个命令回答了一个基本问题:这两个容器是否真的在同一个网络上?

你可以连接一个已有的容器:

docker network connect app-net some-container

并断开它:

docker network disconnect app-net some-container

Docker不会在容器仍然连接时删除网络。先断开或删除容器:

docker network rm app-net

发布端口与容器间端口不同

一个常见的混淆点:在同一Docker网络上的容器不需要发布主机端口就能相互通信。发布端口是为了从主机或主机外部进入的流量。

如果一个API容器监听3000端口,而一个Web容器在同一网络上,Web容器可以调用:

http://api:3000

只有当你希望从笔记本电脑浏览器或通过Docker主机的其他主机访问API时,才需要-p 3000:3000

这意味着在类似生产的环境中,你的数据库通常不应发布主机端口,除非外部有需要直接访问的东西。让API通过私有Docker网络访问db:5432

对普通多服务应用使用Compose

即使你没有定义网络,Docker Compose也会为项目创建一个默认网络。服务可以通过服务名称相互访问:

services:
  web:
    image: nginx:latest
    ports:
      - "8080:80"
    depends_on:
      - api

  api:
    image: my-api:latest
    environment:
      DATABASE_HOST: db

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: devpass

在该文件中,api可以通过主机名db访问dbweb可以通过主机名api访问api,前提是应用级配置指向那里。

当你想要更清晰的意图或分离时,也可以定义命名网络:

services:
  web:
    image: nginx:latest
    ports:
      - "8080:80"
    networks:
      - frontend

  api:
    image: my-api:latest
    environment:
      DATABASE_HOST: db
    networks:
      - frontend
      - backend

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: devpass
    networks:
      - backend

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge

这里,web不能直接与db通信,因为它们不共享网络。api是两个应用层之间的桥梁。这是真实服务的一个有用模式:只向主机暴露边缘服务,保持数据库私有,并且只将每个服务连接到它需要通信的地方。

depends_on 不等于就绪状态

Compose的depends_on控制启动顺序,但它不保证数据库已准备好接受连接。你的API可能在db容器进程启动后启动,但仍然会因为PostgreSQL正在初始化而失败。

在应用中通过重试处理就绪状态,或者使用健康检查和尊重服务健康的Compose配置(取决于你的Compose版本和工作流程)。即便如此,应用级重试逻辑仍然是最可靠的习惯,因为数据库在初始启动后也可能重启。

一个实用的API配置使用DATABASE_HOST=db,并在退出并显示明确错误之前重试连接一小段时间。

自定义子网有用,但不要过度使用

你可以选择一个子网:

docker network create --subnet 172.28.0.0/16 app-net

在Compose中:

networks:
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

当Docker的自动子网与VPN、办公网络或主机上的其他路由重叠时,这很有帮助。大多数项目不需要它。硬编码容器IP应该很少见;服务名称通常是更好的约定。

排查网络通信问题

当一个容器无法访问另一个容器时,按以下顺序检查:

  1. 两个容器都在运行吗?
  2. 它们连接到同一个网络了吗?
  3. 客户端使用的是服务/容器名称,而不是localhost吗?
  4. 服务器在预期的端口和接口上监听吗?
  5. 仅在需要主机访问时才发布端口吗?

localhost错误尤其常见。在容器内部,localhost指的是同一个容器,而不是Docker主机或其他服务。如果API尝试连接到localhost:5432,它是在API容器内部寻找PostgreSQL。当数据库服务名为db时,使用db:5432

检查网络:

docker network inspect app-net

在同一网络上运行一个临时的诊断容器:

docker run --rm -it --network app-net alpine sh

在其中,根据需要安装或使用可用工具:

getent hosts db
nc -vz db 5432

最小化镜像可能没有安装nccurl或DNS工具。一个短暂的调试容器通常比向应用镜像添加故障排除包更干净。

一个合理的默认模式

对于大多数单主机应用,使用Compose并让它创建项目网络。当你需要分离时,添加显式网络,例如frontendbackend。对内部流量使用服务名称。只发布人类、反向代理或外部系统需要访问的端口。

这样你就得到了一个易于解释的设置:

  • 浏览器访问localhost:8080,因为web发布了一个端口。
  • web通过Docker网络访问api
  • api通过后端网络访问db
  • db没有主机端口,除非有真正的操作原因。

自定义Docker网络不仅仅是一个漂亮的功能。它们是那些碰巧在同一台机器上运行的容器与具有清晰通信模型的服务之间的区别。

网络别名可以使迁移更容易

有时应用期望一个你不想用作Compose服务名称的主机名。你可以在网络上添加一个别名:

services:
  postgres:
    image: postgres:16
    networks:
      backend:
        aliases:
          - database

networks:
  backend:
    driver: bridge

backend上的容器现在可以通过postgresdatabase访问该服务。这在迁移一个已经使用DATABASE_HOST=database的旧应用时很方便,但我不会到处使用别名。当你控制应用配置时,服务名称更简单。

主机访问是一个单独的问题

一个容器与另一个容器通信不同于一个容器与Docker主机通信。在Docker Desktop上,host.docker.internal通常可用。在Linux上,支持取决于Docker版本和配置;许多团队在需要时显式添加它:

docker run --add-host=host.docker.internal:host-gateway ...

谨慎使用。如果一个容器严重依赖直接在主机上运行的服务,你的设置可能更难在CI或其他开发者的机器上重现。对于数据库和缓存,将依赖项作为同一Docker网络上的另一个服务运行通常更干净。

内部端口应与进程匹配,而不是Dockerfile注释

Docker网络不关心Dockerfile中的EXPOSE行,除非工具将其用作元数据。应用程序必须实际在你调用的端口上监听。如果一个Node应用在3000上监听,其他容器应该使用api:3000,即使有人错误地写了EXPOSE 8080

还要检查绑定地址。一个服务在其容器内部监听127.0.0.1可能无法从其他容器访问。对于容器间流量,进程通常需要在0.0.0.0或容器的网络接口上监听。

保持网络设计简单

因为存在这个功能,很容易创建许多网络。从你实际需要的通信路径开始。一个小型应用可能只需要默认的Compose网络。一个更现实的Web应用可能需要frontendbackend。除此之外,每个新网络都应该有一个在事故中有人能解释的理由。

良好的网络设计使故障排除更容易。当web无法访问db,并且你知道它们故意不共享网络时,答案是架构性的,而不是神秘的。当每个服务都连接到每个网络时,网络就不再记录任何东西。

在发布前进行一次真实世界的审查

在完成脚本或容器设置之前,以第二天凌晨2点需要调试它的人的身份读一遍。这会改变你注意到的内容。编写脚本时合理的提示在CI日志中可能变得模糊。一个感觉明显的Docker服务名称可能与应用中的变量名不匹配。一个Bash默认值可能对开发安全,但对生产危险。

我喜欢用故意别扭的值做一个简短的试运行。使用带空格的路径。使用一个空的可选值。尝试一个以破折号开头的文件名。从不同的工作目录运行脚本。在没有一个预期环境变量的情况下启动容器。这些测试并不花哨,但它们能捕捉到通常首先出错的假设。

还要检查失败消息。如果唯一的输出是failed,那么文章的建议还没有进入实现。一个有用的失败消息会说明使用了什么值,哪个检查失败了,以及操作员可以更改什么。这并不意味着转储每个环境变量或打印秘密。它意味着在具体有帮助的地方具体:配置路径、缺失的命令名称、网络名称、服务主机名或进程尝试绑定的端口。

最后一个习惯是让示例接近系统实际运行的方式。如果生产使用Compose,就用Compose测试。如果脚本由systemd启动,就用systemd或类似的最小环境测试。如果命令应该安全地复制粘贴,就在示例本身中包含引号、--分隔符和验证。读者复制工作模式的频率高于复制警告。

那个审查过程不是官僚主义。它是让小型自动化保持无聊的方式。无聊正是你希望从shell提示符、配置加载器、变量扩展、容器诊断和Docker网络中得到的。行为越不令人惊讶,下一个操作员就越容易信任它。

对于Docker网络,在Compose文件旁边或服务README中记录预期的流量路径。一个简短的注释如web -> api:3000 -> db:5432可以避免很多混淆。它也使审查更容易:如果有人发布了数据库端口或将web连接到后端网络,这个更改必须针对预期的路径进行自我辩护。

当应用增长时,重新审视网络映射。旧的别名、未使用的发布端口以及连接到不再需要的网络的服务是操作风险的安静来源。