开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情
如果你也遇到了 Mac 中使用 Docker Compose调试微服务的问题,那么推荐你继续往下看。本文介绍一种通过特殊域名
host.docker.internal
解决Mac下Docker
"内外网"服务相互通信的解决方案(Docker 在 Mac 中不支持 host 网络模式)。
引言
如果你的开源项目使用了微服务架构,动不动5-10个服务,你肯定会遇到一系列拆分后带来的新问题,比如作者最近在Mac上调试微服务项目时,就遇到了一个很痛的问题:
使用 Docker Compose 一键启动所有服务后,某天当我想Debug某一个服务时,我发现死活调不通它的依赖(运行在容器中)。
你肯定想,这么简单,你做个端口映射不就行了吗?实际上我也是这么想的,但这个事情没有那么简单。
拆分后的服务结构
按照go-zero框架的指导思想:
我把系统划分成了3层:
- API网关:使用envoy统一汇聚所有BFF的http接口,对外提供一个域名即可访问所有服务。
- BFF层:业务聚合层,依赖RPC层的多个服务,对外提供http接口,客户端可以直接调,也可以走API网关代理。
- RPC层:功能实现层,提供grpc接口供BFF层调用。
BFF和RPC层之间使用了 ETCD 做服务注册和发现,redis、mysql、mongo、kafka等中间件都跑在Docker中。
拆出来后,目前已初步实现的服务如下(总计6个):
├── api # BFF层,提供HTTP接口,都是go mod项目
│ ├── apichat
│ ├── apiuser
│ └── wspush
├── pkg # 所有服务共用的包,go mod 项目
├── rpc # rpc层,提供grpc接口,也是 go mod项目,BFF会调用多个grpc服务获取前端需要的所有数据后返回
│ ├── chat
│ ├── pushjob
│ └── user
├── go.work # 使用go 1.18的go work功能,管理所有子项目
├── go.work.sum
我们看到,现在总共有6个服务要启动:
- BFF层:apichat、apiuser、wspush
- rpc层:chat、pushJob、user
按照依赖关系:如果要调试apichat,需要启动user、chat、pushjob、wspush4个服务,于是我开始遇到一系列微服务拆分带来的问题,其中最优先需要解决的就是Debug调试的问题
。
拆分后,我遇到的调试问题
这个问题是这样的,我们来上一张图,还原一下当时的场景:
- 宿主机上,我打开了一个 goland,准备使用Debug模式调试一下 wspush 这个服务。
- 而wspush服务,依赖的 user-rpc 和 chat-rpc 运行在Docker中,因为Mac不支持 host 模式,所以在Mac中Docker创建了一个子网
- 另外,user和chat2个rpc服务,会把自己注册到 etcd 上(同样在Docker内),以便被 wspush 发现并且访问。
- wspush 访问 etcd 已经做了端口映射
此时,我发现上面红X的那条线,也就是 wspush 要调用 user-rpc服务的 grpc 接口,始终调不通。
当然,调试问题只是微服务拆分后我遇到的问题之一,但是限于篇幅和从严重级别看,这里只着重说明 Mac中Docker网络隔离带来的调试问题和解决方案实操
!完整内容请移步:Mac本地微服务调试实战和踩坑记录
原因
首先排除了和etcd的通信问题,初始化的时候没有报错。那么很可能就是 user-rpc 注册到etcd上的ip地址,宿主机不认识导致的了。
我们确认下 user-rpc
注册到 etcd
上的IP(使用 kratos 框架,它会把服务注册到 /microservices
下):
$ etcdctl --endpoints=127.0.0.1:2379 get /microservices --prefix
输出:
/microservices/rpc-user/db5b60b4559a
{"id":"db5b60b4559a","name":"rpc-user","version":"e1eba40","metadata":{},"endpoints":["[grpc://172.16.0.2:9000]()"]}
我们看到rpc-user服务注册的ip是 172.16.0.2:9000
,也就是Docker容器的本地IP,通过翻 kratos 注册etcd的源码也证实了这一点:
func (a *App) buildInstance() (*registry.ServiceInstance, error) {
endpoints := make([]string, 0, len(a.opts.endpoints))
for _, e := range a.opts.endpoints {
endpoints = append(endpoints, e.String())
}
// 如果没有指定 etcd 注册的endpoint,则使用 grpc 服务监听的 endpoint注册
// 也就是容器的IP
if len(endpoints) == 0 {
for _, srv := range a.opts.servers {
if r, ok := srv.(transport.Endpointer); ok {
e, err := r.Endpoint()
if err != nil {
return nil, err
}
endpoints = append(endpoints, e.String())
}
}
}
return ®istry.ServiceInstance{
ID: a.opts.id,
Name: a.opts.name,
Version: a.opts.version,
Metadata: a.opts.metadata,
Endpoints: endpoints,
}, nil
}
宿主机是192网段,Docker容器是172网段,相当于2个子网,故没有增加路由的情况下,宿主机192网段是不认识 172.16.0.2 这个IP的,也就解释了为什么服务会调不通。
解决方法
找到了问题,我们也能很快想到方案:
宿主机的路由表增加IP的转发规则
:可以参考这篇文章:Docker 多服务器网络配置(简单路由)host网络
:直接让Docker使用宿主机IP,大家同在一个局域网,也就能通信了。奈何 Mac 不支持,所以行不通!使用外网IP注册到ETCD中
:因为我们配置了端口映射,宿主机通过 192.168.200.197:8000 是能连接到容器内服务的。
路由表相对复杂,建议通过使用外网IP(宿主机IP)注册到ETCD的方式解决,考虑到团队内每一个人IP不一样,故最好通过 host
的方式暴露,幸好Docker为我们提供了这个特殊的host,它就是 host.docker.internal。
host.docker.internal 的作用
其实我已经想到了另外一个问题:如果我调试的是grpc服务,api服务运行在容器中呢?
也就是说容器里的api服务依赖宿主机的grpc服务,那么如何从容器访问宿主机呢
?
stack overflow上有人问,我要从Docker中连接宿主机的mysql(From inside of a Docker container, how do I connect to the localhost of the machine?),要怎么实现呢?答案就是使用 host.docker.internal
代替 127.0.0.1,也就是说通过这个域名,我们能通过容器访问宿主机上的服务。
所以,这是为什么上面推荐使用域名注册服务的第二个原因。
简单总结下:
- 宿主机 -> Docker容器:
直接使用本机IP+容器映射端口访问
- Docker容器 -> 宿主机:
通过host.docker.internal+宿主机端口访问
因为这个问题挺影响开发和调试,于是下面再详细介绍一下具体的操作步骤。
Docker和宿主机通信配置
环境
假设环境如下:
- 宿主机IP
- 服务:apiuser(依赖user)
- IP:192.168.1.100
- 端口:8100
- Docker容器
- user
- IP:172.16.0.2
- 端口:9100
- etcd
- IP:172.16.0.6
- 端口2379
- user
更改宿主机hosts文件
先更改宿主机 host 配置(解决宿主机通过域名访问容器的通信问题):
$ sudo vim /etc/hosts
192.168.200.197 host.docker.internal
配置文件
然后上面说了,服务需要以域名的方式注册。宿主机和容器的grpc服务,都需要使用 host 方式暴露服务,故配置文件中需要能指定注册服务的域名:
registry:
etcd:
endpoints: [ 127.0.0.1:2379 ]
register_server_name: rpc-chat
# 不管是宿主机还是容器,都经过这个host进行通信
register_end_point: "host.docker.internal:9100"
代码
另外,代码中也需要增加支持:
func newApp(logger kratoslog.Logger, config *conf.Bootstrap, gs *grpc.Server, registry *etcd.Registry) *kratos.App {
var endpoint = kratos.Endpoint([]*url.URL{}...)
// 这里读取配置文件中的服务注册地址,然后注册到 etcd 中
if config.Registry.Etcd.RegisterEndPoint != "" {
endpoint = kratos.Endpoint(&url.URL{Host: config.Registry.Etcd.RegisterEndPoint, Scheme: "grpc"})
}
return kratos.New(
kratos.ID(id),
kratos.Name(Name),
kratos.Version(Version),
kratos.Metadata(map[string]string{}),
kratos.Logger(logger),
kratos.Server(
gs,
),
kratos.Registrar(registry),
endpoint,
)
}
查看etcd
现在查看 etcd,服务的注册地址变成 grpc://host.docker.internal:9100
:
$ etcdctl --endpoints=127.0.0.1:2379 get /microservices --prefix
输出:
/microservices/rpc-user/4fc113cd7436
{"id":"4fc113cd7436","name":"rpc-user","version":"7c5a0e6","metadata":{},"endpoints":["grpc://host.docker.internal:9100"]}
经过这个域名通信后,本地调试一般会遇到4种场景:
- 宿主机 -> 宿主机:通过 host.docker.internal 通信
- 宿主机 -> 容器:通过 host.docker.internal 通信
- 容器 -> 容器:通过 host.docker.internal 通信
- 容器 -> 宿主机:通过 host.docker.internal 通信
下面挨个验证一下,是否可行!
验证
宿主机到宿主机通过 host.docker.internal 通信
本机启动apiuser和其依赖grpc服务user,user注册到etcd的地址为:grpc://host.docker.internal:9100
发送请求后得到响应,验证通过!
宿主机到容器
宿主机启动apiuser,其依赖grpc服务部署到容器中。
发送请求后同样得到响应,验证通过!
容器到容器通过 host.docker.internal 通信
发送请求后同样得到响应,验证通过!
容器到宿主机
发送请求后同样得到响应,验证通过!
总结
本文介绍了通过Docker Compose单机编排微服务可能遇到的一些问题和解决方案,特别是对使用mac开发的同学。如何解决mac docker不支持host网络模式,使用etcd场景下服务调试的宿主机和容器的网络通信问题,希望能对您有所帮助!
参考: