现在使用 OpenResty 作为反向代理,结合 Redis Cluster 进行服务发现,来动态管理 API 请求,是一种很常见的方案。我们工作中出于测试需要,希望快速搭建一套最小集群,期间出现过一个小问题,我想正好借此讨论一下 Docker 的网络架构。

场景描述

计划的部署方案如下:

  • Redis 服务:通过独立的 docker-compose 配置启动,作为服务发现的存储后端
  • OpenResty 服务:通过另一个 docker-compose 配置启动,作为反向代理,需要从 Redis 中读取上游服务信息。

两个服务都部署在一台 EC2,项目目录结构大致如下:

1
2
3
4
5
project/
├── redis/
│ └── docker-compose.yml
└── openresty/
└── docker-compose.yml

Redis 的 docker-compose.yml 示例:

1
2
3
4
5
6
version: '3'
services:
redis:
image: redis:latest
ports:
- "6379:6379"

OpenResty 的 docker-compose.yml 示例:

1
2
3
4
5
6
7
8
version: '3'
services:
openresty:
image: openresty/openresty:latest
ports:
- "80:80"
volumes:
- ./nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf

问题现象:127.0.0.1 无法访问 Redis

在 OpenResty 的 Lua 代码中,我们尝试连接 Redis:

1
2
3
4
5
6
7
8
9
local redis = require "resty.redis"
local red = redis:new()

-- 尝试连接本地 Redis
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "failed to connect to redis: ", err)
return
end

运行后,日志中出现连接失败的错误:

1
failed to connect to redis: connection refused

这让人感到困惑:明明 Redis 已经通过 -p 6379:6379 映射到了宿主机的 6379 端口,为什么在容器内访问 127.0.0.1:6379 却连接不上呢?

Docker 网络架构解析

Docker 的网络隔离原理

要理解这个问题,我们需要了解 Docker 的网络架构。Docker 使用 Linux namespace 技术实现网络隔离,每个容器都有自己独立的网络命名空间,包括:

  • 独立的网络接口
  • 独立的 IP 地址
  • 独立的路由表
  • 独立的端口空间

当一个容器启动时,Docker 会为其创建一个虚拟网络环境。在这个环境中:

  • 127.0.0.1 (localhost) 指向的是容器自身,而不是宿主机
  • 容器有自己的 IP 地址,通常在 Docker 创建的虚拟网桥(默认是 docker0)的子网中
  • 端口映射(-p 6379:6379)只是在宿主机上建立了一个转发规则

网络架构示意图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────────────────────────────────────────────┐
│ 宿主机 (Host) │
│ │
│ 127.0.0.1:6379 ← Redis 容器端口映射 │
│ ↓ │
│ ┌──────────────────┐ ┌─────────────────┐ │
│ │ Redis Cont. │ │ OpenResty Cont. │ │
│ │ │ │ │ │
│ │ 127.0.0.1 → Self │ │ 127.0.0.1 → Self│ │
│ │ CIP: 172.17.0.2 │ │ CIP:172.18.0.2 │ │
│ └──────────────────┘ └─────────────────┘ │
│ ↑ ↑ │
│ └──────────┬───────────┘ │
│ Docker 网络层 │
└─────────────────────────────────────────────┘

为什么 127.0.0.1 不工作?

当 OpenResty 容器内的代码尝试连接 127.0.0.1:6379 时:

  1. 这个请求在 OpenResty 容器的网络命名空间内处理
  2. 127.0.0.1 指向 OpenResty 容器自己,而不是宿主机
  3. OpenResty 容器内并没有运行 Redis 服务
  4. 因此连接失败

虽然宿主机的 6379 端口映射到了 Redis 容器,但这个映射对于 OpenResty 容器来说是”看不见”的,因为它们在不同的网络命名空间中。

正确的连接方式

方案一:使用宿主机网关 IP

每个 Docker 网络都有一个网关 IP,通常是该网络的第一个 IP。对于默认的 bridge 网络,这个 IP 通常是 172.17.0.1

在容器内,可以通过以下方式获取网关 IP:

1
2
# 在容器内执行
ip route | grep default | awk '{print $3}'

或者查看 /etc/hosts:

1
2
3
cat /etc/hosts
# 通常会看到类似:
# 172.18.0.1 host.docker.internal

修改 OpenResty 的连接代码:

1
2
3
4
5
6
7
8
9
local redis = require "resty.redis"
local red = redis:new()

-- 使用 Docker 网关 IP 访问宿主机的 Redis 端口
local ok, err = red:connect("172.18.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "failed to connect to redis: ", err)
return
end

原理:网关 IP 是容器访问宿主机的桥梁。通过网关 IP,容器可以访问宿主机上暴露的端口,从而通过端口映射访问到 Redis 容器。

方案二:使用 host.docker.internal (推荐用于开发环境)

Docker Desktop 提供了一个特殊的 DNS 名称 host.docker.internal,它会自动解析为宿主机的 IP:

1
local ok, err = red:connect("host.docker.internal", 6379)

这种方式更加便携,但需要注意:

  • Docker Desktop for Mac/Windows 默认支持
  • Linux 上需要在启动容器时添加 --add-host=host.docker.internal:host-gateway

方案三:使用共享 Docker 网络(最佳实践)

最优雅的解决方案是让两个 docker-compose 项目使用同一个 Docker 网络:

首先创建一个外部网络:

1
docker network create shared-network

修改 Redis 的 docker-compose.yml:

1
2
3
4
5
6
7
8
9
10
version: '3'
services:
redis:
image: redis:latest
networks:
- shared-network

networks:
shared-network:
external: true

修改 OpenResty 的 docker-compose.yml:

1
2
3
4
5
6
7
8
9
10
11
12
version: '3'
services:
openresty:
image: openresty/openresty:latest
ports:
- "80:80"
networks:
- shared-network

networks:
shared-network:
external: true

然后在 OpenResty 中直接使用服务名连接:

1
2
-- 直接使用服务名,Docker DNS 会自动解析
local ok, err = red:connect("redis", 6379)

Docker Compose 的服务名连接机制

为什么服务名可以直接使用?

当你在同一个 docker-compose.yml 文件中定义多个服务时:

1
2
3
4
5
6
7
version: '3'
services:
redis:
image: redis:latest

openresty:
image: openresty/openresty:latest

在 OpenResty 容器中,你可以直接使用 redis 作为主机名连接 Redis。这是因为:

1. Docker 内置 DNS 服务器

Docker 为每个自定义网络(非默认 bridge 网络)提供了一个内置的 DNS 服务器。当容器启动时:

  • 容器的 /etc/resolv.conf 会被配置为使用 Docker 的 DNS 服务器(通常是 127.0.0.11)
  • Docker DNS 会维护一个服务名到容器 IP 的映射表

查看容器内的 DNS 配置:

1
2
3
4
5
# 在容器内执行
cat /etc/resolv.conf
# 输出类似:
# nameserver 127.0.0.11
# options ndots:0

2. 服务发现机制

Docker Compose 会为 compose 文件中定义的每个服务:

  • 创建一个 DNS 条目,名称就是服务名
  • 将服务名解析为该服务对应容器的 IP 地址
  • 如果服务有多个副本(scale),会采用轮询(round-robin)方式返回不同的 IP

示例:

1
2
3
4
5
6
7
8
# 在 OpenResty 容器内
nslookup redis
# 输出:
# Server: 127.0.0.11
# Address: 127.0.0.11:53
#
# Name: redis
# Address: 172.18.0.2 ← Redis 容器的实际 IP

3. 网络别名 (Network Aliases)

Docker 还支持为服务设置网络别名:

1
2
3
4
5
6
7
8
9
10
11
12
version: '3'
services:
redis:
image: redis:latest
networks:
app-network:
aliases:
- redis-master
- cache-server

networks:
app-network:

这样,除了 redis 之外,redis-mastercache-server 也都可以解析到同一个容器。

4. 跨 Compose 项目的服务发现

默认情况下,不同 docker-compose 项目的服务无法通过服务名互相访问,因为它们在不同的网络中。这就是为什么我们需要:

  • 使用共享的外部网络(方案三)
  • 或者通过宿主机的端口映射(方案一、二)

使用共享网络后,Docker DNS 会在该网络范围内解析所有加入该网络的服务名。

最佳实践建议

开发环境

对于本地开发,推荐使用共享网络方案:

1
2
3
4
5
# 创建共享网络
docker network create dev-network

# 所有开发服务都加入这个网络
# 可以直接使用服务名互相访问

生产环境

生产环境中,建议:

  1. 使用 Docker Swarm 或 Kubernetes:它们提供了更强大的服务发现机制
  2. 使用服务网格(Service Mesh):如 Istio、Linkerd,提供更高级的流量管理
  3. 使用独立的服务发现组件:如 Consul、Etcd,不依赖于容器编排平台

配置管理

将 Redis 连接地址配置化:

1
2
3
4
5
6
7
8
-- config.lua
local _M = {}

-- 从环境变量读取 Redis 地址
_M.redis_host = os.getenv("REDIS_HOST") or "host.docker.internal"
_M.redis_port = tonumber(os.getenv("REDIS_PORT")) or 6379

return _M

在 docker-compose.yml 中设置环境变量:

1
2
3
4
5
6
services:
openresty:
image: openresty/openresty:latest
environment:
- REDIS_HOST=redis
- REDIS_PORT=6379

总结

Docker 的网络隔离是通过 Linux namespace 实现的,每个容器都有独立的网络栈。关键要点:

  1. 容器内的 127.0.0.1 指向容器自身,而不是宿主机
  2. 端口映射是宿主机层面的,容器之间无法直接通过 localhost 访问
  3. 容器间通信的正确方式:
    • 使用容器 IP(不推荐,IP 可能变化)
    • 使用宿主机网关 IP + 端口映射(适合跨 compose 项目)
    • 使用共享 Docker 网络 + 服务名(最佳实践)
  4. Docker Compose 的服务名解析依赖于 Docker 内置的 DNS 服务器,自动将服务名映射到容器 IP

理解 Docker 的网络模型,不仅能解决容器间通信问题,也能帮助我们更好地设计微服务架构,希望这篇文章能帮你更好理解 Docker 的网络隔离。

参考资料