在这篇中,会继续接着前次的话题,通过具体的案例,介绍CoreOS为分布式和集群服务带来的便利。在前一个案例中,为了完成采集和管理分布在集群各个节点上的服务状态信息,我们通过 Etcd 的分布式存储特性,设计了一种解决分布式服务中应用运行的节点和时间均不确定的问题的监控方法。在这次的案例中,会在这种服务监控方案的基础上,继续实现将监控结果作为自动配置的反馈,从而配合 CoreOS 的内置服务实现另一个典型的服务器自动配置场景——用于负载均衡的反向代理服务。
应用层负载均衡,又称七层负载均衡(因为其作用在TCP/IP协议栈的第七层),是用于在用户访问到达真正的Web服务之前,通过一个反向代理服务器将请求均匀分发到多个相同的后端逻辑服务器上,从而减轻单个服务节点压力的方法。由于这种方案相对廉价且能够对访问用户透明的扩展网络设备和服务器的带宽、增加吞吐量、加强网络数据处理能力、提高网络的可用性,它已经是数据分流的最常见做法。事实上,我们几乎可以在任何大型网络服务上发现应用层负载均衡的运用。
考虑到例子的实用和延续性,这个案例中选择目前较为流行的 Nginx 作为HTTP反向代理服务。后端的服务使用与前篇文章相同的 Apache Http Server 并通过 Etcd 存储收集到的服务状态信息。从下面张结构图就能够清晰看出整个服务结构的全景,图中的每个服务器图标代表一个系统服务,其中后端的三个Apache服务是运行在各种不同的集群节点上的,而 Nginx 服务运行在这三个节点中的其中一个上。
在这个结构中,Nginx 反向代理的分发策略配置是我们此次需要研究的对象。这个策略主要包括,流量分发的节点地址,各个节点的流量分发比例,访问出错的处理等等。初始状态下,我们假设这个策略加入了所有的三个后端节点,并且平均分配各个节点的流量。然而,后端的 Apache 服务节点并不总是一年365天的每一分钟总是运行正常并且数量恒定不变的。访问过量、软件BUG、硬件故障都有可能使得其中部分节点服务出现暂时性的失效或奔溃,使得实际可用节点减少。反之,当网站服务的用户访问量变得越来越大时,则需要适当增加后端服务节点的数量来缓解集群的负担。但常见的负载均衡服务器(如典型的 Nginx)的请求分发规则并不会根据后端节点的状态和数量变化而自动修改。因此,这个案例的关键部分在于使得 Nginx 依据后端服务节点的数量和可用性,自动适配其收到HTTP请求的分发策略。
PS:其实说Nignx不会自动调节并不十分严谨,虽然Nginx不能自动修改其策略配置,但遇到后端服务连续多次返回错误状态时,Nginx能够自动暂时屏蔽向故障节点转发请求,在指定的时间后再次尝试启用被屏蔽的节点,如果还是连续出错又再次屏蔽,如此反复。然而,这种反复使用实际链接请求来测试节点失效的做法会带来一定的效率损失,同时使得部分用户获得错误响应,不如更新策略配置删除失效节点来得可靠。并且对于后端节点增加的情况更是完全无法自动处理。
通过 CoreOS 实现自适应反向代理的思路是比较清晰的。首先,为了让反向代理服务及时的将失效的后端节点从转发列表中移除,每个后端服务需要有自己的状态监控,也就是上篇文章里使用到的秘书服务,将服务的状态信息放入 Etcd 的固定位置。这样当有新的服务节点进入集群时,它的信息也会出现在 Etcd 中并让反向代理服务发现。
有了每个后端服务的状态信息,剩下的就是让反向代理监听 Etcd 数据的变化,然后自动更新策略配置的内容了。这个任务可以通过 Etcd 的 Restful API 或者 etcdctl 工具再写一个新的服务来实现,并不算太麻烦。不过,对于配置文件自动更新这个任务实在是太常见了,社区里已经有了许多成熟的方案提供了更统一和便于管理的做法,其中比较常用的一个是 Confd。
其实呢,介绍到这一步,这篇文章的案底也已经泄露得差不多了。后面的内容在 Google 搜索关键字 “Confd + Nginx” 就可以找到不少相关的介绍,但其中文章质量有些良莠不齐,这里推荐的一篇是 Paul Dixon 博客,后面的内容有些就参考了他的代码。好吧,既然已经走到这里,还是别半途而废啦,一起来继续实现完这个案例的场景吧。
Apache 服务节点的结构和上一篇中介绍的基本一致。分别使用 Apache 应用服务的 Unit 模板文件 [email protected] 和监控 Apache 应用的秘书 Unit 模板文件 [email protected] 来启动。顺便在下面的Unit文件中加了些注释说明,应该不用多说了。
# [email protected] [Unit] Description=Apache web server service listening on port %i # 依赖的其他服务项 Requires=etcd.service Requires=docker.service Requires=apache-secretary@%i.service # 依赖启动顺序 After=etcd.service After=docker.service Before=apache-secretary@%i.service [Service] # 取消启动超时,防止第一次运行时Docker下载镜像导致启动过超时错误 TimeoutStartSec=0 # 让Systemd在服务实例结束时,不要尝试杀死启动服务实例的进程 KillMode=none # 载入 CoreOS 系统环境变量 EnvironmentFile=/etc/environment # 启动命令 ExecStartPre=-/usr/bin/docker kill apache.%i ExecStartPre=-/usr/bin/docker rm apache.%i ExecStartPre=/usr/bin/docker pull eboraas/apache ExecStart=/usr/bin/docker run --name apache.%i -p ${COREOS_PUBLIC_IPV4}:%i:80 eboraas/apache # 结束命令 ExecStop=/usr/bin/docker stop apache.%i [X-Fleet] # 将每个服务实例分布在不同的服务器节点上 Conflicts=apache@*.service
这里顺便回答一个上篇中遗留的问题。我们通过引入一个额外的秘书服务来监控一个应用服务的可用性,那么秘书服务本身的可用性如何保证呢?是否需要给秘书服务也配备一个附加的监控服务,但这样岂不就陷入了无限的监控链?事实上,由于秘书服务的逻辑非常简单,不会存在并发或内存访问的压力,无故崩溃的可能性很低。通常秘书服务的可用性是自保证的,不过为了避免秘书真的意外私奔(额..我是说..失联),可以给它加上Restart=on-failure配置,见下面的Unit文件。
# [email protected] [Unit] Description=Monitoring the Apache web server running on port %i # 依赖的其他服务项 Requires=etcd.service Requires=apache@%i.service # 依赖启动顺序 After=etcd.service After=apache@%i.service BindsTo=apache@%i.service [Service] # 载入 CoreOS 系统环境变量 EnvironmentFile=/etc/environment Restart=on-failure # 启动命令 # 测试服务实例是否可用,并更新信息到 Etcd 存储 ExecStart=/bin/bash -c '\ while true; do \ curl -f ${COREOS_PUBLIC_IPV4}:%i; \ if [ $? -eq 0 ]; then \ etcdctl set /services/apache/${COREOS_PUBLIC_IPV4} \'{"host": "%H", "ipv4_addr": ${COREOS_PUBLIC_IPV4}, "port": %i}\' --ttl 30; \ else \ etcdctl rm /services/apache/${COREOS_PUBLIC_IPV4}; \ fi; \ sleep 20; \ done' # 结束命令 ExecStop=/usr/bin/etcdctl rm /services/apache/${COREOS_PUBLIC_IPV4} [X-Fleet] # 让这个服务运行在与其监控的实例同一个服务器节点 ConditionMachineOf=apache@%i.service
然后快速的启动三个服务后台 Apache 服务 Unit。
$ fleetctl start "[email protected]" \ "[email protected]" \ "[email protected]" \ “[email protected]" \ "[email protected]" \ "[email protected]"
这两个Unit文件的内容在系列的前面已经讲解过。接下来,一起快速浏览一下 Nginx 反向代理节点和 Confd 工具的配置。
作为 CoreOS 最佳实践的一部分,我们应该将 Confd 服务和 Nginx 都运行在各自的容器实例中(当然可以把这两个服务打包到一个容器实例里面,那样会给当下节省不少事情,但带来的将是容器可复用性的降低)。由于 Confd 需要更新 Nginx 的配置,因此需要为这两个容器提供一个共享的目录来存放这个配置文件。最简单的做法是直接在本地创建一个目录,将 Nginx 的所有配置拷贝进去,分别映射这个目录到两个容器里面作为 Nginx 的配置目录和 Confd 的配置生成目录。但这样做就引入了额外手工的操作,为未来可能的自动化部署埋下隐患。一种改进的做法是将这个操作写成一个特别的服务文件,通过 Fleet 统一管理,这样就消除了手工操作的引入。但是这样还是需要对容器暴露一个本地目录,这个目录可能会在容器外被人为有意或无意的被更改或删除。要解决这个问题,可以将容器化精神发扬彻底,索性将这个目录放到一个第三者容器实例里面,这样就真的是“360度无侧漏”了 : D。
根据以上思路,来看下面这个在容器中提供存储空间的服务文件。
# confdata.service [Unit] Description=Configuration Data Volume Service After=docker.service Requires=docker.service [Service] EnvironmentFile=/etc/environment Type=oneshot RemainAfterExit=yes ExecStartPre=-/usr/bin/docker rm conf-data ExecStart=/usr/bin/docker run -v /etc/nginx --name conf-data nginx echo "created new data container"
这个服务的特别之处在于 Type=oneshot 和 RemainAfterExit=yes 这两个地方。前者确保了不论对这个服务执行多少次 start 操作,其内容在每个节点上永远仅仅会被执行一次(如果重新执行就要丢数据了)。而后者使得即便这个服务ExecStart启动的进程退出了,Fleet 依然认为服务正在运行,从而方便其他服务配置依赖。还有一个巧妙的地方是,ExecStart中的命令使用的是 nginx 的 Docker 镜像,其 /etc/nginx 目录已经包含了所有的 Nginx 默认配置文件,这样就省去了拷贝配置文件到共享目录的步骤,一举多得。
Confd 是与 CoreOS 无关的一个独立的集群配置监控工具,它可以与多种集群数据存储服务集成,包括 Etcd、Consul等。Confd 通过监控指定的数据来源内容和配置模板生成应用配置,并会在配置内容更新时,自动通知应用重新加载配置。
简单来说,使用它只需要提供两件东西:Confd的配置文件(包括数据源、配置文件路径和通知方式等)和生成配置文件的模板。但鉴于在CoreOS上需要通过容器运行服务,我们还需要两个东西:创建容器的 Dockerfile 和运行容器实例的 Unit 文件。
Confd 的配置文件通过一种名为 TOML 格式声明,其格式有些类似Windows的 ini 配置格式,但支持配置域的嵌套。
[template] # 应用的配置文件模板名,Confd 会自动到 /etc/conf.d/templates 下面去找这个文件 src = "nginx.conf.tmpl" # 更新以后的配置文件存放路径 dest = "/etc/nginx/sites-enabled/app.conf" # 监听的 Etcd 路径,这个路径中的键的内容将可以在配置模板中被访问 keys = [ "/services/apache" ] # 应用配置文件应有的所有者和权限 owner = "root" mode = "0644" # 用来通知应用更新配置的命令 reload_cmd = "echo -e "POST /containers/nginx.service/kill?signal=HUP HTTP/1.0\r\n" | nc -U /var/run/docker.sock"
文件注释已经比较清晰了。只是最后的命令需要结合后面启动 Confd 容器的地方,/var/run/docker.sock 文件被映射到了主机的Docker套接字文件,参考这篇文章,这条命令的作用相当于 “/usr/bin/docker kill -s HUP nginx.service” ,发送一个HUB信号给 Nginx 的服务,但更加可靠。
根据前面的配置,这个模板生成的文件会被放置到容器的 /etc/nginx/sites-enabled/app.conf 路径下面。模板的内容中大括号中的部分会被实际数据相应的替换掉,关于 Confd 模板的详细介绍,可以参考其文档。
# nginx.conf.tmpl upstream apache_pool { {{ range getvs "/services/apache/*" }} server {{ . }}; {{ end }} } server { listen 80 default_server; listen [::]:80 default_server ipv6only=on; access_log /var/log/nginx/access.log upstreamlog; location / { proxy_pass http://apache_pool; proxy_redirect off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }
有了这个两个文件,Confd 的准备工作就已经就绪了。接下来,就将这些文件和 Confd 服务一起打包成一个镜像吧。
Confd 默认会加载 /etc/confd/conf.d/ 目录下的所有配置文件,并且从 /etc/confd/templates 目录中寻找模板文件。我们需要将前面的两个文件放置到容器中正确的目录里。
在当前目录新建名为 confd/conf.d/ 的目录,将 nginx.toml 配置文件复制进去,再新建一个 confd/templates/ 目录,将 nginx.conf.tmpl 模板复制进去。最后在当前目录创建一个内容如下的 Dockerfile 文件。
# Confd Dockerfile FROM ubuntu:14.04 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get -y install curl && \ curl -o /usr/bin/confd -L https://github.com/kelseyhightower/confd/releases/download/v0.7.1/confd-0.7.1-linux-amd64 && \ chmod 755 /usr/bin/confd ADD confd/ /etc/confd #this environemtn variable needs to be passed in CMD /usr/bin/confd -interval=60 -node=http://$COREOS_PRIVATE_IPV4:4001
使用这个Dockerfile生成Image并提交到镜像仓库中。
$ CONFD_IMAGE="用户名/confd-for-nginx" $ docker build --tag=$CONFD_IMAGE '.' $ docker push $CONFD_IMAGE
下面这个服务文件将启动一个刚刚创建的Confd容器的实例。
# confd.service [Unit] Description=Configuration Service # 声明依赖,共享数据的服务必须已经存在 After=confdata.service Requires=confdata.service [Service] EnvironmentFile=/etc/environment ExecStartPre=-/usr/bin/docker kill %n ExecStartPre=-/usr/bin/docker rm %n ExecStartPre=/usr/bin/docker pull lordelph/confd-for-nginx # 注意这里的 volumes-from 参数 ExecStart=/usr/bin/docker run --rm \ -e COREOS_PRIVATE_IPV4=${COREOS_PRIVATE_IPV4} \ -v /var/run/docker.sock:/var/run/docker.sock \ --volumes-from=conf-data \ --name %n \ lordelph/confd-for-nginx ExecStop=/usr/bin/docker stop -t 3 %n Restart=on-failure [X-Fleet] # 必须与共享数据容器在同一个节点 MachineOf=confdata.service
这里需要特别注意的是容器启动时的几个参数,
-e COREOS_PRIVATE_IPV4=${COREOS_PRIVATE_IPV4}
将容器外的变量传递到了容器内部,在这个容器启动时会需要它
-v /var/run/docker.sock:/var/run/docker.sock
将容器外的 Docker 套接字映射到了容器中的同名文件,这样就可以给其他容器发信号了
--volumes-from=conf-data
引入 conf-data 容器(由数据共享服务创建的)暴露的所有分区,这样容器内部的 /etc/nginx 目录其实就被替换成 conf-data 容器中的共享目录了
一切就绪,现在启动 Confd 服务吧。
$ fleet start confd.service
Docker 官方已经提供了 Nginx 的容器镜像,只需要写个服务Unit,直接使用这个镜像就可以了。
# nginx.service [Unit] Description=Nginx Service After=confd.service [Service] EnvironmentFile=/etc/environment ExecStartPre=-/usr/bin/docker kill %n ExecStartPre=-/usr/bin/docker rm %n ExecStartPre=/usr/bin/docker pull nginx ExecStart=/usr/bin/docker run --name %n -p 80:80 --volumes-from=conf-data nginx ExecStop=/usr/bin/docker stop -t 3 %n Restart=on-failure [X-Fleet] # 同样的必须运行在与数据共享容器相同的节点上 MachineOf=confdata.service
从 ExecStart 这行的参数可以看出,这个服务同样引用了数据共享容器中的共享分区,并且对外暴露了80端口,用于通过反向代理访问后端的服务。
将Nginx服务也启动起来,现在整个反向代理加后端负载均衡的架构就完成了,所有的服务均运行在容器中。
$ fleetctl start nginx.service $ fleetctl list-units UNIT MACHINE ACTIVE SUB [email protected] 14ffe4c3... /10.132.249.212 active running [email protected] 1af37f7c... /10.132.249.206 active running [email protected] 9e389e93... /10.132.248.177 active running [email protected] 14ffe4c3... /10.132.249.212 active running [email protected] 1af37f7c... /10.132.249.206 active running [email protected] 9e389e93... /10.132.248.177 active running confd.service 9e389e93... /10.132.248.177 active running confdata.service 9e389e93... /10.132.248.177 active exited nginx.service 9e389e93... /10.132.248.177 active running
通常我们还会需要在Unit文件里限制 Nginx 服务运行的节点,以便为它绑定域名,这里就去不指定了。可以直接访问 nginx 服务 IP 所在节点的 80 端口,获得后端服务的内容。
在这一篇中,介绍通过使用集成到 CoreOS 的内置服务(例如 Etcd)的第三方扩展(例如 Confd),实现特定分布式应用管理的案例。事实上,这个例子中为了演示 CoreOS 实践中的一些技巧,所采用的设计方案并不是所有可行方案中最简单的。对于反向代理这个场景,同样基于 Etcd 的 Vulcand 工具只需要短短的几行设置就能够搞定。
同时,这个例子也意在说明,使用 CoreOS 系统时,我们应当尽量养成一些正确的习惯,例如将所有应用服务放置到容器中运行,使用 Dockerfile 创建和管理容器镜像,每个容器镜像尽可能的只提供单一服务,将共享数据的存储同样容器化、服务化等等。总结来说,CoreOS 系统为了方便集群服务的操控,一方面提供了标准的数据存储、服务调度机制,另一方面也限制了将服务不加隔离的运行于系统之上带来的应用间强耦合的隐患。如果我们能够恰当的遵循这些好的实践方法,在 CoreOS 中设计出具备原生的分布式特性的服务将变为一件理所当然的事情。