Docker容器集群配合Consul实现服务注册和负载均衡

前言

上一篇文章中,我们以本地开发环境为背景,完成了一个Flask通过Consul获取运行配置的demo。接下来,我们更进一步,模拟生产环境的需求,把这个demo运行在docker集群中,并完成横向扩容。

在这个过程中,我们会陆续遇到很多在现实生产场景中遇到的问题,比如说:

  • 如何规划一个高效又稳妥的发布方案?

  • 把服务封装成容器之后,要怎样配合consul实现自动注册和健康检查?

  • 横向扩容之后,每个节点的IP地址都不一样,不可以hard code到代码中,如何兼顾灵活和高效?

  • 多节点部署docker容器后,怎样才能方便地实现负载均衡?

而在接下来的内容中,上面的问题都会得到妥善解决。

先来看看架构图:

docker-consul

架构说明

流程介绍

  • Consul集群采用最小化的架构部署,在Consul Server中提前写入Flask应用所需的key:value配置。

  • 开发者在本地环境完成调试后,把代码构建成docker镜像,并push到镜像仓库中。

  • 运维同事接到部署需求后,在Docker宿主机中拉取特定版本的docker镜像,并启动容器。

  • docker容器使用本地网络启动,在启动的时候通过运行在本地的Consul Client Agent向Consul集群注册自己的服务信息,域名为mydemo.service.consul.

  • docker容器通过Consul获取配置数据后,使用flask框架启动http服务,返回redis中的数据。

针对的问题以及解决方案

一次构建,多次复用

在运维工作中,我们都会追求一次构建,多次复用这样的目标,有这几个好处:

  1. 保证集群中各个节点的一致性,我们就不需要担心因为不同节点之间的差异导致业务故障。

  2. 版本管理清晰,代码仓库中的每个版本可以和构建出来的docker镜像版本一一对应,在版本流转的过程中可以清晰知道每个环境正在运行的是哪个版本的代码。

  3. 提高部署效率,每个版本不管有多少个环境需要部署,我们都只需要构建一次,并只需要保存一个镜像文件。

配置分离

因为我们只构建一次,却可能要运行在多个环境,所以我们的服务必须支持配置分离,也就是说在项目文件中不可以包含配置内容。为了满足这一需求,我们要在各个不同的环境中都有独立的一套Consul集群为各个服务提供配置,这样也就保证了同一个服务的不同节点的配置文件也是一致的。当遇到配置更改的需求,我们只需要修改一次,就可以在所有的节点上生效。

水平扩容(高可用性)

在完成了上面两点后,我们的服务自然就具备了水平扩容的能力。我们可以在多个Docker Host上运行同一个容器,暴露同样的端口,只需要在上游部署一个负载均衡就可以轻松实现水平扩容。

更方便的是,Consul还提供了健康检查功能。当其中一个服务节点遇到故障无法提供服务时,Consul会自动把故障节点移出服务,从而实现高可用性。在妥善的配置下,用户不会有任何感知。

服务注册与服务发现

在一个集群中,往往由多个不同的服务组成,通过上下游的数据链提供服务,这就需要我们在各个服务的配置文件中指明上游服务或者是数据库的连接信息。比方说,一个游戏服务可能需要频繁地向一个API调取玩家的个人信息,又或者说,一个Web页面需要从MongoDB中调取文本信息等。在传统的配置方案中,往往会通过IP+端口的形式进行配置,这就需要我们人工去维护一系列的服务IP+端口的信息,以保障服务能正确运行。

而有了Consul的服务注册功能后,所有服务都可以通过Consul去找到自己依赖的上游服务了。而且经过妥善配置后,所有的注册与发现功能都是自动完成的,可以大大减轻运维的工作量。

一些偷懒的地方

为了简化实验集群的工作量,Docker镜像仓库使用的是现成的DockerHub。在正常的生产环境下,都是会部署内部的一套镜像仓库的。

同样是为了简化工作量,Consul Server是单点部署。在正常的生产环境下,Consul Server起码是有3台的,里面还会涉及到Leader的选举流程。

Demo搭建

Redis

这里只是简单起了一个Redis服务,在默认配置的基础上,增加一个密码设置。具体可以参考上一篇文章: 《Python Flask demo配合consul实现配置分离和服务注册功能

Consul集群

Consul服务的安装也很简单,在官方文档中已经列出了各个系统下的安装方法。官方文档: https://learn.hashicorp.com/collections/consul/production-deploy

Consul Client 配置

配置要点:

  • 通过auto_encrypt配置,自动完成和Consul Server的TLS加密配置。
  • 为了实验方便,配置一条默认的ACL规则,允许所有的接入。
  • 考虑到实际生产环境中有可能会存在容器之间互相有业务依赖的请求,在本demo中,应用会通过consul域名redis.service.consul连接Redis。基于这种需求,需要在Docker Host中需要使用Consul Client为本地提供DNS服务,所以在Consul Client的端口配置中,配置使用了53端口号。
  • 在Consul占据了53端口号提供DNS服务的时候,同时我们还要给Consul添加recursors的配置项,为Consul指定一个或多个上游的DNS服务提供商。当遇到.consul之外的域名的时候,Consul就可以向上游DNS服务进行查询。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# cat /etc/consul.d/consul.hcl 
datacenter = "dc1"
data_dir = "/opt/consul"
encrypt = "xxxxxx"
verify_incoming = true
verify_outgoing = true
verify_server_hostname = true
ca_file = "/etc/consul.d/certs/consul-agent-ca.pem"
auto_encrypt {
tls = true
}
bind_addr = "0.0.0.0"

performance {
raft_multiplier = 1
}
retry_join = ["192.168.0.248"]

acl = {
enabled = true
default_policy = "allow"
enable_token_persistence = true
}

client_addr = "0.0.0.0"

ports {
dns = 53
}

recursors = ["114.114.114.114", "4.2.2.2"]

(可选)允许Consul Agent使用53端口提供DNS服务

在标准安装配置中,会创建一个Consul用户去启动Consul服务,但在Linux系统默认配置下,非root用户是不可以调用1024以下的端口号的。为了解决这个问题,我们需要给Consul的运行文件赋权。赋权完成后,就算是普通用户启动的Consul进程,都可以绑定1024以下的端口号。

1
2
3
4
5
6
sudo setcap CAP_NET_BIND_SERVICE=+eip /usr/bin/consul

# 查看consul程序文件的capability
getcap /usr/bin/consul
/usr/bin/consul = cap_net_bind_service+eip

最后还要在/etc/resolv.conf中的首行制指定使用127.0.0.1作为Nameserver.

关于Consul和DNS相关的其他内容, 可以参考官方文档:Forward DNS for Consul Service Discovery

Consul Server 配置

和Consul Client相比,在部署Consul Server的时候,我们需要额外考虑一些Server相关的配置项,以及TLS配置,这些在官方文档里面都有详细说明。在本Demo中,使用的配置文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# cat /etc/consul.d/consul.hcl  | grep -v ^# | grep -v ^$
datacenter = "dc1"
data_dir = "/opt/consul"
encrypt = "xxxxxx"
verify_incoming = true
verify_outgoing = true
verify_server_hostname = true
ca_file = "/etc/consul.d/certs/consul-agent-ca.pem"
cert_file = "/etc/consul.d/certs/dc1-server-consul-0.pem"
key_file = "/etc/consul.d/certs/dc1-server-consul-0-key.pem"
auto_encrypt {
allow_tls = true
}
performance {
raft_multiplier = 1
}
acl = {
enabled = true
default_policy = "allow"
enable_token_persistence = true
}

# -----------------------

# cat /etc/consul.d/server.hcl
server = true
bootstrap_expect = 1
client_addr = "0.0.0.0"

connect {
enabled = true
}

addresses {
grpc = "192.168.0.248"
}

ports {
grpc = 8502
}

ui_config {
enabled = true
}

通过Consul UI增加Key-Value配置

Demo中的Flask app使用的配置文件内容如下:

1
2
3
4
5
6
7
8
9
{
"redis_config": {
"redis_host": "redis.service.consul",
"redis_port": 6379,
"redis_db": 0,
"redis_password": "aaaa1111"
},
"show_message": "this is a python-consul-redis demo"
}

进入Consul的Web UI后,可以很简单地把配置内容添加进去:

consul-key-value

向Consul集群注册Redis服务

比起使用IP地址进行上下游服务的配置,我们往往会更倾向使用可读性更强的域名。在本例中,Redis服务运行于IP地址为192.168.0.251的服务器中,我们需要把Redis服务注册到Consul集群中。方法如下:

  1. 创建一个JSON文件描述Redis服务,并使用TCP方式对Redis进行健康检查

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    "service": {
    "name": "redis",
    "address": "192.168.0.251",
    "port": 6379,
    "check": {
    "tcp": "192.168.0.251:6379",
    "interval": "10s",
    "timeout": "3s"
    }
    }
    }
  2. 注册到Reids

    1
    2
    3
    4
    # 在调用Consul API之前,需要在环境变量中指定TOKEN
    [root@consul-server ~]# export CONSUL_HTTP_TOKEN=xxxxxxxxx
    [root@consul-server ~]# consul services register redis.json
    Registered service: redis
  3. 查看结果,域名redis.service.consul就可以解释为192.168.0.251.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    [root@consul-server ~]# consul catalog services             
    consul
    redis
    [root@consul-server ~]# dig @127.0.0.1 -p 8600 redis.service.consul

    ; <<>> DiG 9.11.4-P2-RedHat-9.11.4-26.P2.el7_9.8 <<>> @127.0.0.1 -p 8600 redis.service.consul
    ; (1 server found)
    ;; global options: +cmd
    ;; Got answer:
    ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 30933
    ;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
    ;; WARNING: recursion requested but not available

    ;; OPT PSEUDOSECTION:
    ; EDNS: version: 0, flags:; udp: 4096
    ;; QUESTION SECTION:
    ;redis.service.consul. IN A

    ;; ANSWER SECTION:
    redis.service.consul. 0 IN A 192.168.0.251

    ;; Query time: 0 msec
    ;; SERVER: 127.0.0.1#8600(127.0.0.1)
    ;; WHEN: Mon Dec 06 14:52:33 CST 2021
    ;; MSG SIZE rcvd: 65

    register-redis-service

Docker镜像制作

正如前面在架构图部分阐述的,这个demo模拟的是一个开发人员完成开发后,把代码构建成镜像提交到镜像仓库的过程。

Demo代码

在上一篇文章中我们有介绍过这个app,就是一个简单的flask应用,返回Redis中的数据。在这次的Demo中,添加了向Consul自动注册和健康检查功能,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import time
import os
import redis
import consul
import json
from flask import Flask

try:
c = consul.Consul(host='127.0.0.1', port=8500, scheme = 'http' )
config_json = json.loads(c.kv.get('redis')[1]['Value'].decode('utf-8'))

except:
exit()

redis_config = config_json['redis_config']
show_message = config_json['show_message']

app = Flask(__name__)
cache = redis.Redis(host=redis_config['redis_host'], port=redis_config['redis_port'], db=redis_config['redis_db'], password=redis_config['redis_password'])

LAN_IP = os.environ['LAN_IP']
check_demo = consul.Check.http('http://%s:5000/health' %LAN_IP ,interval='2s')
c.agent.service.register('mydemo', address=LAN_IP, port=5000,check=check_demo)

def get_hit_count():
retries = 5
while True:
try:
return cache.incr('hits')
except redis.exceptions.ConnectionError as exc:
if retries == 0:
raise exc
retries -= 1
time.sleep(0.5)

@app.route('/')
def hello():
count = get_hit_count()
return '{}! I have been seen {} times.\n'.format(show_message,count)

@app.route('/health')
def health():
return "i am fine"

if __name__ == "__main__":
app.run()

关键设计思路

  • 在程序运行的时候,先通过127.0.0.1:8500连接到Consul Client Agent,获取配置文件。之所以这么设计,是考虑到当实施水平扩容的时候,这个镜像可能会运行在多个Docker Host中。只要每个Docker Host中都运行一个Consul Client,我们可以在代码中很方便地使用127.0.0.1:8500去连接consul。而为了让容器中的Python程序能通过127.0.0.1:8500访问到宿主机里面的Consul服务,我们在运行容器的时候,需要加上--network=host 参数,让容器使用宿主机的网络。
  • 在Consul使用服务注册以及健康检查功能的时候,需要向Consul提供服务的访问IP,在本Demo中,是使用Docker Host的IP加上端口实现的。考虑到这个程序是运行在Docker容器中,所以我们使用环境变量的方式让python程序知道LAN IP。这样设计的好处是,当这个镜像在多个不同的Docker Host中运行的时候,只需要在docker run的时候通过环境变量传入docker宿主机的内网IP地址就可以完成向Consul的注册,不需要修改代码。而且这个docker run的操作中添加内网地址作为环境变量这个操作,也可以很轻松地使用shell脚本实现自动化。
  • 同样是为了健康检查功能,在flask的路由中,增加了/health接口,简单返回一句i am fine.

Demo目录结构

1
2
3
4
python-consul-redis-demo/
├── app.py
├── Dockerfile
└── requirements.txt

Docker镜像构建

使用一个python3镜像,把代码复制到镜像中,安装必要的依赖即可。

1
2
3
4
5
6
7
8
9
10
11
# syntax=docker/dockerfile:1
FROM python:3.10-alpine
WORKDIR /code
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
RUN apk add --no-cache gcc musl-dev linux-headers
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
EXPOSE 5000
COPY . .
CMD ["flask""run"]

其中requirements.txt内容如下:

1
2
3
4
# cat requirements.txt 
flask
redis
python-consul

在项目目录下,使用docker build即可完成构建:

1
2
3
4
5
6
7
8
# 构建docker 镜像 
docker build -t rondochen/python-consul-redis-demo:v1.3 .

# 如果因为网络问题速度很慢,可以使用`--build-arg`参数,在build的时候增加http代理设置:
docker build --build-arg "HTTPS_PROXY=http://xxx.xxx.xxx.xxx:xxxx" -t rondochen/python-consul-redis-demo:v1.3 .

# 把镜像上传到镜像仓库
docker push rondochen/python-consul-redis-demo:v1.3

Demo部署到生产环境

前面铺垫了那么多,主要都是解释这个demo的设计和编排思路,接下来,终于要部署了。

在这里,我们使用主流的Nginx提供负载均衡方案。在Nginx的视角下,这个Demo的数据链是这样:nginx-proxy-pass

启动Docker容器

前面说过,在启动Docker容器的时候,需要使用宿主机的网络,并且要向docker容器传入一个环境变量,指定内网IP地址:

1
2
LAN_IP=`ifconfig|grep 192|awk -F ' ' '{print $2}'`
docker run -d --name="mydemo" --network=host -e "LAN_IP=${LAN_IP}" rondochen/python-consul-redis-demo:v1.3

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
root@docker:~# LAN_IP=`ifconfig|grep 192|awk -F ' ' '{print $2}'`
root@docker:~# echo $LAN_IP
192.168.0.250
root@docker:~# docker run -d --name="mydemo" --network=host -e "LAN_IP=${LAN_IP}" rondochen/python-consul-redis-demo:v1.3
WARNING: Published ports are discarded when using host network mode
209af56ca07cf11b6d8224775fd86c8fd7f2b3c95526d6cc5a59ffd9938701f0
root@docker:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
209af56ca07c rondochen/python-consul-redis-demo:v1.3 "flask run" 9 seconds ago Up 8 seconds mydemo
root@docker:~# docker logs -f 209af56ca07c
* Serving Flask app 'app.py' (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
* Running on all addresses.
WARNING: This is a development server. Do not use it in a production deployment.
* Running on http://192.168.0.250:5000/ (Press CTRL+C to quit)
192.168.0.250 - - [05/Dec/2021 03:50:52] "GET /health HTTP/1.1" 200 -
192.168.0.250 - - [05/Dec/2021 03:50:54] "GET /health HTTP/1.1" 200 -
192.168.0.250 - - [05/Dec/2021 03:50:56] "GET /health HTTP/1.1" 200 -
192.168.0.250 - - [05/Dec/2021 03:50:58] "GET /health HTTP/1.1" 200 -
192.168.0.250 - - [05/Dec/2021 03:51:00] "GET /health HTTP/1.1" 200 -
192.168.0.250 - - [05/Dec/2021 03:51:02] "GET /health HTTP/1.1" 200 -
192.168.0.250 - - [05/Dec/2021 03:51:04] "GET /health HTTP/1.1" 200 -
192.168.0.250 - - [05/Dec/2021 03:51:06] "GET /health HTTP/1.1" 200 -
192.168.0.250 - - [05/Dec/2021 03:51:08] "GET /health HTTP/1.1" 200 -
^C

当有两个Docker Host启动了这个容器后,我们可以在Consul的Web UI上看到mydemo服务下面有两个节点:consul-service

Nginx负载均衡配置

只需要简单的配置即可实现负载均衡:

1
2
3
4
5
6
7
8
9
10
resolver 192.168.0.248:8600;

server {
listen 8080;
server_name mydemo.rondochen.com;
location / {
set $endpoint mydemo.service.consul;
proxy_pass http://$endpoint:5000;
}
}
  • 因为在Nginx机器上没有安装Consul,所以在配置里通过resolver指定了Consul Server的地址,这样Nginx就可以识别到mydemo.service.consul这个域名了。

  • 多个不同Docker Host下面的同一个容器,都可以使用同一个服务名注册到Consul。在本例中,mydemo.service.consul会解释出两个IP地址:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    # dig @192.168.0.248 -p 8600 mydemo.service.consul

    ; <<>> DiG 9.11.4-P2-RedHat-9.11.4-26.P2.el8 <<>> @192.168.0.248 -p 8600 mydemo.service.consul
    ; (1 server found)
    ;; global options: +cmd
    ;; Got answer:
    ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 52973
    ;; flags: qr aa rd; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
    ;; WARNING: recursion requested but not available

    ;; OPT PSEUDOSECTION:
    ; EDNS: version: 0, flags:; udp: 4096
    ;; QUESTION SECTION:
    ;mydemo.service.consul. IN A

    ;; ANSWER SECTION:
    mydemo.service.consul. 0 IN A 192.168.0.250
    mydemo.service.consul. 0 IN A 192.168.0.252

    ;; Query time: 1 msec
    ;; SERVER: 192.168.0.248#8600(192.168.0.248)
    ;; WHEN: Sun Dec 05 11:55:01 CST 2021
    ;; MSG SIZE rcvd: 82
  • 因为我们是通过域名解释实现动态反向代理,所以我们直接使用proxy_pass即可,不需要使用upstream

  • 笔者在测试的时候,发现直接使用proxy_pass http://mydemo.service.consul:5000;的时候,Nginx无法识别出consul域名,在Stack Overflow 上也有网友提过这个问题,具体原因不明确,但通过变量指定目标地址就可以绕开这个问题。

测试

因为这是实验环境,没有放到公网上。针对mydemo.rondochen.com这个域名的解释工作,就偷个懒,在/etc/hosts中写一条就完事了。

笔者用一个循环脚本,每一秒请求一次demo,同时用docker logs -f命令观察两个docker容器中的访问日志:

1
2
3
4
5
6
7
#!/bin/sh

while true
do
curl http://mydemo.rondochen.com:8080
sleep 1
done

docker-logs

可以看到,两个docker容器都会收到来自Nginx(192.168.0.108)的反向代理请求,同时也会每隔2秒收到本地Consul Agent的健康检查请求。当有再多的Docker容器进行负载均衡,也是一个样的实现原理。

总结

感谢你有耐心看到这里,笔者也没想到这么一个简单的demo竟然也可以引出这么多的讨论。正如在前文的思考和论述中我们看到的,在运维人员进行微服务改造的探索过程中,在运维的角度里面,考虑的主要就是如何兼顾高效和灵活。在这个demo中,我们主要展示的就是当Consul加入后,如何通过Consul实现配置分离和服务注册这些功能。当然这里只是展示了实际工作的一小部分,而在现实的工作中,制定一个部署方案要考虑的事情还远远不止这些。 比如说:业务负载、性能指标、容器监控、日志收集、CI/CD平台选型等,这些问题都还没有讨论到。

听说用Kubernetes可以一站式地解决这些需求,有空再弄个demo出来给大家看看。

诚然,要拟定一个方案往往要结合项目的现状以及充分考虑组内大部分人的意见,也不会有一个处处通用的方案。本文旨在通过这个小demo介绍这个方案以及展示其可行性,过程中涉及到很多细节的调试问题,囿于篇幅也无法一一展开说明。如有疑惑,欢迎在评论区留言或者邮件联系。

吐槽:因为不能把公司的项目成果搬上来讨论,我只好重新搞一套小东西出来说。It reminds me of the the old time in school where I struggled to finish the code work and paper work before deadline.

扩展阅读

本demo的docker镜像已发布到Docker Hub,各位读者可以自行下载:

1
docker pull rondochen/python-consul-redis-demo:v1.3

Day1: Deploy Your First Datacenter

Consul Configuration

Stack Overflow

Forward DNS for Consul Service Discovery

systemd-resolved.service

Linux manual page - setcap