Docker结合Consul实现的服务发现(一)

转自 http://dockone.io/article/1359

【编者的话】这是Docker结合Consul实现服务发现系列文章的第一篇,在本文中,作者介绍了一个基础的前后端服务架构并讲解了如何通过Consul实现服务的注册和发现。

在过去的一年里,我开始变得热衷于使用Consul来实现一切和服务发现相关的东西。如果你正在做微服务的话,你可能会碰到一个问题,那就是当你创建的服务数量越多时,这些服务之间的通信便越难管理。针对这个问题,Consul给出了一份完美的答卷。它提供了一个易于使用,基于开放标准(个人见解)的服务发现解决方案(并且还提供了一大批其他的功能)。

我最近就如何在一个微服务的架构下使用Consul来实现服务发现做了一次演讲,然后有很多人请求我讲解下关于它的更多细节。因此在这篇文章,以及后续的几篇系列文章里,我将会为你介绍具体该如何使用Consul。我将不会纯粹只关注Consul提供的服务发现部分,还会为你展示一系列Consul或者某些围绕它建立的工具所提供的其他特性。请注意文章里所涉及的所有案例,Docker文件等。都可以在如下仓库里找到:
https://github.com/josdirksen/next-build-consul.
因此,与其从本文里”复制 & 粘贴”,还不如直接克隆该仓库。

在这第一篇文章里,我们将创建一个简单的基于Docker的基础设施,这里面一系列的服务将会使用简单的HTTP调用形式与其他服务通信,并且使用Consul来发现其他服务实体。我们最开始的目标架构有点像这个样子:

demo1.png



为了实现这一切,我们首先需要通过以下步骤来建立一个我们可以在里面运行服务的基本环境:

注意:这一切我是通过在Mac上的Docker-machine实现的。如果你运行的是Windows或者Linux的话,键入的命令内容可能就会略有不同。我们只能寄望于支持Mac(以及Windows)的Docker版本尽快正式发布( https://blog.docker.com/2016/0 … beta/ ),这样一来的话我们便无需再这样麻烦了。

  1. 创建四个Docker-machine:一个将用来运行Consul服务,而另外三个将用来运行单独的服务以及Consul客户端。
  2. 启动主要的Consul服务端:我们将使用一个单一的Consul服务端(以及多个Consul客户端,以后可能会更多)来追踪正在运行的服务以及一些docker相关的东西。
  3. 配置Docker Swarm:为了避免不得不一个个地部署我们的服务,我们将使用Docker Swarm来管理这三个将运行我们服务的节点。在本文的余下部分,我们将使用Docker-Compose来启停单个的服务。
  4. 配置Docker覆盖网络:如果我们想让我们的服务和其他服务以简单的方式通信的话,我们可能需要为此创建一个覆盖网络。这将使得我们部署到Docker的组件能够很轻松地同其他服务通信(因为他们是共享同一个子网)
  5. 启动Consul客户端:每个节点都会有它自己的consul客户端,它会在该节点上监控服务的健康性并且和Consul服务端通信。


因此我们要做的第一件事情便是创建一些Docker-machine。首先,我们将创建一台支撑我们Consul服务端运行的Docker-machine。我们率先运行它的原因在于这样一来我们便可以让其他的Docker-machine配置成指向这台容器里运行的Consul服务,并且使用它来管理Docker-swarm以及我们想要使用的覆盖网络。
docker-machine create nb-consul --driver virtualbox 


而在我们启动Consul服务之前,先让我们快速看一下Consul背后的架构设计。


consul-arch.png



在这张图里,你可以看到Consul可以运行的两种模式。它能够运行在服务端或者客户端模式。所有的服务端节点会相互通信并且决定谁是领导者(leader)。一个客户端节点则会与服务端节点里的其中一个交互,然后一般会运行在同样还运行着应用服务的节点上。需要注意的是同一个集群里的所有服务端和客户端节点之间的状态均是共享的。因此,当一个服务在其中一个客户端上注册它本身时,它的相应信息也就对所有和其他节点连通的服务端以及客户端可见。

在这个系列文章里,我们将不会去配置服务端节点的集群,而是简单的只用一个节点。如今我们已经成功让我们的Docker-machine跑了起来,我们可以启动consul服务了。在我们开始之前请允许我先给你展示一个简单的脚本,它可以帮助我们更加容易地在不同的Docker-machine之间切换,并且通过设置别名以避免键入繁琐的”Docker-machine”。
# 快速切换环境,例如: . dm-env nb-consul
$ cat ~/bin/dm-env
eval docker-machine env $2 $1

# 避免太多不必要的输入
$ alias dm
dm=docker-machine


所以,有了这些替代的别名,首先,我们可以使用”dm-env nb-consul”来选择合适的Docker-machine。

其次,我们拿到了这台服务器的IP地址,然后我们便可以使用类似如下的方式来启动我们的Consul服务。
# 获取IP地址
$
192.168.99.106

# 在advertise里使用该IP地址
docker run -d --restart always -p 8300:8300 -p 8301:8301 -p 8301:8301/udp -p 8302:8302/udp \ 
       -p 8302:8302 -p 8400:8400 -p 8500:8500 -p 53:53/udp -h server1 progrium/consul \
       -server -bootstrap -ui-dir /ui -advertise $(dm ip nb-consul) 


此刻,我们已经成功地运行起来了我们的consul服务容器。现在,让我们再来创建其他三个将要运行我们应用服务的服务器吧。

正如你所能看到的,在如下命令里,我们将会在同一时间内创建一个Docker swarm集群,并且”nb1”节点即swarm的master。
docker-machine create -d virtualbox --swarm --swarm-master \ 
       --swarm-discovery="consul://$(docker-machine ip nb-consul):8500" \
       --engine-opt="cluster-store=consul://$(docker-machine ip nb-consul):8500" \
       --engine-opt="cluster-advertise=eth1:2376" nb1

docker-machine create -d virtualbox --swarm  \
        --swarm-discovery="consul://$(docker-machine ip nb-consul):8500" \
        --engine-opt="cluster-store=consul://$(docker-machine ip nb-consul):8500" \
       --engine-opt="cluster-advertise=eth1:2376" nb2

docker-machine create -d virtualbox --swarm \
          --swarm-discovery="consul://$(docker-machine ip nb-consul):8500"  \
          --engine-opt="cluster-store=consul://$(docker-machine ip nb-consul):8500" \
          --engine-opt="cluster-advertise=eth1:2376" nb3


这时候,我们已经创建了四台Docker-machine并且成功地运行了起来。一个运行了Consul的master节点,其他的则暂时还没做什么事情。
$ dm ls
NAME        ACTIVE   DRIVER       STATE     URL                         SWARM
nb1         -        virtualbox   Running   tcp://192.168.99.110:2376   nb1 (master)
nb2         -        virtualbox   Running   tcp://192.168.99.111:2376   nb1
nb3         -        virtualbox   Running   tcp://192.168.99.112:2376   nb1
nb-consul   *        virtualbox   Running   tcp://192.168.99.106:2376


在我们继续配置slave节点之前,这里还有一个实用脚本可以派上用场:
$ cat addToHost
#!/usr/bin/env bash

update-docker-host(){
# 从/etc/hosts里清理现有的docker.local记录
sudo sed -i "/"1"\.local/d" /etc/hosts

# 获取运行中的docker machine的IP地址
export DOCKER_IP="(docker-machine ip 1)"

# 将docker machine的IP地址信息更新到/etc/hosts里
&& sudo /bin/bash -c "echo \"{DOCKER_IP} 1.local\" >> /etc/hosts"
}
update-docker-host nb1
update-docker-host nb2
update-docker-host nb3
update-docker-host nb-consul


这个脚本将几个Docker-machine的IP地址纷纷添加到了你的本地”hosts”文件里。这意味着我们可以在docker宿主机上简单地通过类似 “http://nb-consul.local:8500” 这样的形式直接访问服务。

在我们的设定里,我们希望我们所有的服务都能够互相通信。我们拥有多台Docker宿主机,因此我们需要找到一个简便的方式使得运行在”nb1”节点上的服务能够和”nb2”通信。想要达成这一点的话,最简单的方法是创建一个单一网络来承载所有运行在Docker容器里的服务。为此我们创建了一个这样的简单”覆盖”网络:
# 选择swarm master
$dm-env nb1 --swarm
# 创建一个名为my-net的覆盖网络


而自从我们在swarm master上创建了这个覆盖网络以后,该网络便可用于swarm集群里的所有成员。在我们后面创建出应用服务后,我们将会让它们均连接到这个网络,如此一来它们便都可以共享同一个子网。

为了启动consul客户端,我们打算利用一下Docker-compose。Docker-Compose文件本身非常直截了当,并且算是一个不用键入全部启动命令(除非当你在做现场demo)的简便方案。
version: '2'

services:
agent-1:
image: progrium/consul
container_name: consul_agent_1
ports:
  - 8300:8300
  - 8301:8301
  - 8301:8301/udp
  - 8302:8302
  - 8302:8302/udp
  - 8400:8400
  - 8500:8500
  - 53:53/udp
environment:
  - "constraint:node==nb1"
command: -ui-dir /ui -join 192.168.99.106 -advertise 192.168.99.110
networks:
  default:
    aliases:
      - agent-1

agent-2:
image: progrium/consul
container_name: consul_agent_2
ports:
  - 8300:8300
  - 8301:8301
  - 8301:8301/udp
  - 8302:8302
  - 8302:8302/udp
  - 8400:8400
  - 8500:8500
  - 53:53/udp
environment:
  - "constraint:node==nb2"
command: -ui-dir /ui -join 192.168.99.106 -advertise 192.168.99.111
networks:
  default:
    aliases:
      - agent-2

agent-3:
image: progrium/consul
container_name: consul_agent_3
ports:
  - 8300:8300
  - 8301:8301
  - 8301:8301/udp
  - 8302:8302
  - 8302:8302/udp
  - 8400:8400
  - 8500:8500
  - 53:53/udp
environment:
  - "constraint:node==nb3"
command: -ui-dir /ui -join 192.168.99.106 -advertise 192.168.99.112
networks:
  default:
    aliases:
      - agent-3

networks:
default:
external:
  name: my-net


这个文件并没有什么特别之处。唯一你可能需要关注的地方便是我们在启动Consul客户端的命令里带上了确切的IP地址。我们当然可以,简单地,只使用一个环境变量来指代它,而这个也可以通过一个简单的bash脚本来设置。然而,在本文中为简单起见我们就只需要指定相关的Docker-machine的IP地址即可。确保你的”DOCKER_HOST”已经指向了Docker swarm master,然后采取类似如下方式启动客户端:
# 启动客户端
$ docker-compose -f docker-compose-agents.yml up -d
Creating consul_agent_3
Creating consul_agent_2
Creating consul_agent_1

# 检查运行状态
$ docker ps  --format '{{ .ID }}\t{{ .Image }}\t{{ .Command }}\t{{ .Names}}'
bf2000882dccprogrium/consul"/bin/start -ui-dir /"nb1/consul_agent_1
a1bc26eef516progrium/consul"/bin/start -ui-dir /"nb2/consul_agent_2
eb0d1c0cc075progrium/consul"/bin/start -ui-dir /"nb3/consul_agent_3 


此时,我们成功在docker-machine “nb-consul”上跑起来了一个consul服务节点,然后我们在其余节点上运行了三个客户端服务。为了验证我们的配置,我们不妨打开Consul服务端的接口看看:http://nb-consul.local:8500

consul_1.png


然后,正如你所见,我们跑起来了一个服务端节点(我们的consul服务),以及三个客户端节点。所以在这之后我们便可以开始添加我们的应用服务,搭建完整的架构:

demo1_0.png


这些服务,在这个例子里,只是一些简单的Golang应用。我写了一个简单的应用,它能以前端(frontend)或者后端(backend)模式运行。在前端模式下,它会提供一个极简的UI,并带有一个按钮可以用来调用后端服务。而在后端模式下它提供了一个简单的API,会返回一些信息给调用方并且还提供了一个简单的UI展现一些统计数据。为了方便起见,我将这个镜像推送到了Docker hub(https://hub.docker.com/r/josdirksen/demo-service/) 上,因此你可以简单地直接使用它而无需再从Github上的源码仓库构建编译。

正如你在之前的架构概述里所看到的那样,我们打算在每个节点上均启动一个前端以及一个后端服务。我们将会手工执行这一操作,但是由于我们配置了Docker-swarm,因此我们可以很轻松地通过一个单一的Docker-compose文件来实现这一点。如果你想查看这个文件具体内容的话,你可以看看这里的源码(https://github.com/josdirksen/next-build-consul)

那么,让我们先把服务运行起来,接着我们看看它们是怎样通过consul来注册自己的:
# 确认你选择了swarm master
$ . dm-env nb1 --swarm

# 现在可以使用docker-compose拉起后端服务
$ docker-compose -f docker-compose-backend.yml up -d
Creating Backend2
Creating Backend3
Creating Backend1

# 然后用docker-compose拉起前端服务
$ docker-compose -f docker-compose-frontend.yml up -d
Creating Frontend1
Creating Frontend3
Creating Frontend2

# 检查docker是否一切都跑起来了
$ docker ps --format '{{ .ID }}\t{{ .Image }}\t{{ .Command }}\t{{ .Names}}'

65846be2e367    josdirksen/demo-service "/entrypoint.sh --typ"  nb2/Frontend2
aedd80ab0889    josdirksen/demo-service "/entrypoint.sh --typ"  nb3/Frontend3
d9c3b1d83b5e    josdirksen/demo-service "/entrypoint.sh --typ"  nb1/Frontend1
7c860403b257    josdirksen/demo-service "/entrypoint.sh --typ"  nb1/Backend1
80632e910d33    josdirksen/demo-service "/entrypoint.sh --typ"  nb3/Backend3
534da0670e13    josdirksen/demo-service "/entrypoint.sh --typ"  nb2/Backend2
bf2000882dcc    progrium/consul "/bin/start -ui-dir /"  nb1/consul_agent_1
a1bc26eef516    progrium/consul "/bin/start -ui-dir /"  nb2/consul_agent_2
eb0d1c0cc075    progrium/consul "/bin/start -ui-dir /"  nb3/consul_agent_3 


正如你在”Docker ps”后面的输出所看到的那样,我们拥有正在运行的三个前端,三个后端,以及三个consul客户端服务。这正是我们所预期的架构。我们还可以打开Consul的界面看看情况

consul_2.png


如你所见,我们在Consul注册了三个前端和三个后端服务。如果我们打开其中一个后端的话我们将可以看到一些通用的信息:

backend_1.png


然后我们可以在前端的UI上,调用其中一个后端服务的接口:

frontend_1.png


然而这里有一些问题我们需要去解答:

  1. 服务的注册:当我们启动一个后端或者前端服务时,我们可以看到它在consul里出现。我们怎么办到的?

  2. 服务发现:再者说,当我们在前端服务上按下按钮时,它会触发一次对一个后端服务的调用。那个前端服务怎么知道它应该调用哪个服务呢?


在下面的内容里,我们将一步步揭开这些问题的答案。

第一关便是服务的注册。为了在consul里注册一个服务,我们得先给我们的本地consul客户端发送一个非常简单的REST调用,它也许看上去会是这个样子:
{
"Name": "service1",
"address": "10.0.0.12",
"port": 8080,
"Check": {
 "http": "http://10.0.0.12:8080/health",
 "interval": "5s"
}
}


正如你所见,我们指定了可以用来发现服务的名字,地址,以及端口,然后我们添加了一个额外的健康检查。当健康检查返回的是某些在200范围内的状态码时,该服务会被标记为是健康的,然后它便可以被其他服务检索到。那么,我们如何让我们的服务做到这一点?如果你看过这个例子里的源码的话,你可以找到一个”script/entrypoint.sh”的文件,它看上去会是这个样子:
#!/usr/bin/env bash

IP=`ip addr | grep -E 'eth0.*state UP' -A2 | tail -n 1 | awk '{print $2}' | cut -f1 -d '/'`
NAME="$2-service"

read -r -d '' MSG << EOM
{
"Name": "$NAME",
"address": "$IP",
"port": $PORT,
"Check": {
 "http": "http://$IP:$PORT",
 "interval": "5s"
}
}
EOM

curl -v -XPUT -d "$MSG"&nbsp;http://consul_agent_$SERVER_ID:8500/v1/agent/service/register && /app/main "$@"


这个脚本的功能便是,它会创建出一个将要发送给consul客户端的JSON,然后在启动主程序前,它会用”curl”命令将该请求发送出去。因此一旦服务启动了,它便会自动将自己注册到本地的consul客户端(注意,你也可以通过一个更加自动的方法来办到这一点,比如使用Consul Registrator)。这的确奏效了,因为它们在同一个容器里,我们只要通过它的名字引用本地客户端即可。如果你看的更仔细些的话,你也许会发现我们在这里用到了一些环境变量。它们通过我们采用的Docker-compose文件传送到了容器里:
...
frontend-1:
image: josdirksen/demo-service
container_name: Frontend1
ports:
  - 8090:8090
environment:
  - "constraint:node==nb1"
  - SERVER_ID=1
  - SERVERNAME=Server1
  - PORT=8090
command: /entrypoint.sh --type frontend
dns: 192.168.99.106
dns_search: service.consul
...


这里面最有趣的部分莫过于DNS记录。你也许还记得192.168.99.106正是我们的consul服务端的IP地址。这就意味着我们可以对consul做DNS查找(我们也可以指向一个consul客户端)。

有了这个配置,我们便可以通过名称来引用服务,然后使用DNS来解析它。如下操作即展示它是如何工作的:
# 从容器外部检查被调用的后端服务所注册的IP地址
$ dig @nb-consul.local backend-service.service.consul +short
10.0.9.7
10.0.9.8
10.0.9.6

# 如果从容器内部的话,我们可以单纯的只做这一件事情
docker exec -ti nb2/Frontend2 ping backend-service
PING backend-service.service.consul (10.0.9.8): 56 data bytes
64 bytes from 10.0.9.8: icmp_seq=0 ttl=64 time=0.809 ms 


看上去很酷,不是吗?我们只用DNS便可以发现该服务。这同样也意味着将此集成到我们现有应用会是一件异常简单的事情,因为我们只需要基本的DNS解析服务即可。举个例子,在前端服务里,我们通过如下代码调用后端:
resp, err := http.Get("http://backend-service:8081")
if err != nil {
// 处理错误
fmt.Println(err)
} else {
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
w.Header().Set("Content-Type",resp.Header.Get("Content-Type"))
w.Write(body)



这里通过DNS服务调用其中一个后端服务。由于consul的DNS TTL被设置成了0,我们也因此得以拥有一些简单的故障转移功能。应用程序也许在它们这层仍旧做了一些缓存,然而这意味着我们至少拥有一些基本的故障转移的支持:
$ curl -s backend-service:8081

    {"result" : {
      "servername" : "Server1",
      "querycount" : 778
      }
    }

# 关闭服务器1然后再做一次, curl会有1分钟的DNS缓存,
# 因此,你可能需要等上一会儿
curlsbackendservice:8081result:servername:Server2,querycount:770 curl -s backend-service:8081
    {“result” : {
      “servername” : “Server2”,
      “querycount” : 771
      }
    }


当然,这对我们的前端/Golang应用同样奏效:

consul_3.png



在后面的文章里,我们还将介绍一些更先进的故障转移特性,通过引入HAProxy作为中间件来实现更为高级的故障切换技术。

以上便是这篇文章的全部内容。那么,不妨总结一下,我们做过的一些事情:

  1. 我们已经建立了一套拥有4个Docker节点的简单架构。1个用作consul的服务端,其他三个则是我们的应用服务。
  2. 这些服务在启动时会自动在Consul里注册自己。
  3. 我们并不需要显式地做些什么来开启服务发现的功能。我们只要使用标准的DNS来查找服务即可。
  4. Consul检索DNS时用的TTL为0,并且返回可用服务时采取的策略是轮询(round-robin)。正如你之前所了解到的那样,当一个DNS查找操作失败时,你本身也就用它完成了基本的故障转移的支持。


后续文章预期在未来几周内出炉,敬请关注。

原文链接:service-discovery-with-docker-and-consul-part-1 (翻译:吴佳兴)

阅读更多
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页