Clojure 部署无需停机
在uSwitch,我们迷上了微服务架构,这些微服务大部分采用了Clojure Ring库,我们的基础设施托管在Amazon AWS(亚马逊网页服务)。
微服务的一个优点是支持横向扩展,尤其是把它们部署在 EC2(亚马逊弹性计算网云)上,实现这一点很简单: 增加更多的机器! 不幸使用了 Clojure,或者对 java虚拟器有特殊的要求,应用服务启动时性能很低,这些问题说明部署会占用很多不合理的时间。 为了解决这些问题,我们使用热插拔式的部署方式。 从响应的 ELB(弹性负载均衡)中移除一个主机;更新主机上的服务;将主机加入到 ELB中。
我们升级一个服务的过程如下图:
操作步骤是:
1.初始化运行nginx反向代理到服务v1;
2.从ELB中移除第一个主机;
3.停止主机上的服务v1;
4.将主机上的服务升级为v2;
5.把主机重新加入到ELB中;
6.从ELB中移除下一个主机;
7.停止主机上的服务v1;
8.将主机上的服务升级为v2;
9.把主机重新加入到ELB中;
尽管大部分情况这种操作方式是可行的,但是作为一种升级方案我们对此并不满意,原因如下:
1. 如果一个服务只发布在某一个主机上,那么部署的时候,这个服务有一段时间会不可用。
2. 热插拔式的部署意味着全部的部署时间与主机的数量呈线性关系。
3. 如果新发布的服务在主机上不能正常启动,有可能导致失去ELB中整个服务的基础设施。
4. 从 ELB中移除一个主机时,可能移除了多个服务,这样会降低我们系统的响应性能。
作为我们最近黑客生活的一部分,我们决定研究一个解决方案,方案基于一个简单的决定: 如果一个服务监听的是一个随机的端口,那么我们可以运行两个实例,此时一个服务会有两个不同的版本。 复杂之处在于:服务监听的端口是随机的,nginx反向代理端口时,我们要如何处理这个服务?如何整理之前运行的其它版本的这个服务? 使用一个服务注册表可以解决这些问题,例如etcd, 注册表里存储服务的端口号和PID(进程ID),同时我们使用一个类似 confd的进程监视服务的变化。
升级服务的过程更像是这样:
其中的步骤是:
1.初始化运行nginx反向代理到服务v1;
2.启动服务v2,把服务v1的端口和服务v2的端口保存到etcd中;
3.confd监视端口的变化,重新生成nginx配置,重启nginx,断开服务v1,连接服务v2;
4.服务v2关联之前的服务v1的PID和自身的PID,将关联信息保存到etcd中;
5.confd检测到PID变化,生成并运行停止服务的脚本,停止服务v1;
nginx重启时,主进程启动新的线程并终止原来的线程,这种方式意味着服务的不可用时间基本为0。
解决方法
服务
目前,我们准备采用Stuart Sierra优秀的组件项目来管理服务的生命周期,初始化的时候它会存储一个随机的数字,然后它会把数字返回给任意一个请求。 启动Jetty(一个开源的Servlet容器),操作指定的系统,端口就是数据传输所需的端口号。 如果我们以某种方式使用这个端口号进行通信,nginx能够接收到数据,那么我们就可以一次运行服务的多个实例,并转换反向代理。
我们的etcd会运行在某一个主机上,而不是集群上: 在主机之外,我们不需要传输这个服务的信息。 为了将端口号由服务传递给etcd,我们计划使用一个已知的密钥 uswitch/experiment/port/current部署etcd-clojure。
(ns etcd-experiment.system (:require [com.stuartsierra.component :refer [Lifecycle]] [etcd-experiment.util :refer [etcd-swap-value]] [ring.adapter.jetty :refer [run-jetty]])) (defrecord JettyComponent [root-key routes] Lifecycle (start [component] (let [server (run-jetty routes {:join? false :port 0}) server-port (.getLocalPort (first (.getConnectors server)))] (etcd-swap-value (str root-key "/port") server-port) (clojure.pprint/pprint {:service-port server-port}) (-> component (assoc :server server) (assoc :server-port server-port)))) (stop [component] (let [server (:server component)] (.stop server) component))) (defn jetty-component [root-key routes] (map->JettyComponent {:root-key root-key :routes routes}))
我们需要构建一个组件,用来将服务的PID存储到uswitch/experiment/pid/current路径下,并确保它只依赖于服务组件。
(ns etcd-experiment.pid-manager (:require [com.stuartsierra.component :refer [Lifecycle]] [clj-pid.core :as pid] [etcd-experiment.util :refer [etcd-swap-value]])) (defrecord PidManager [root-key service] Lifecycle (start [component] (let [pid (pid/current)] (etcd-swap-value (str root-key "/pid") pid) (clojure.pprint/pprint {:service-pid pid}) (assoc component :pid pid))) (stop [component] component)) (defn pid-manager-component [root-key] (map->PidManager {:root-key root-key}))
我们也需要将之前的键值保存到路径 uswitch/experiment/port/previous 和uswitch/experiment/pid/previous下,这个功能,我们在代码 etcd-experiment.util/etcd-swap-value 中实现。
随机分配端口号的优点:不仅支持一次可以运行多个相同的服务,而且只有在服务启动之后端口号才可以访问。 由于组件的依赖性,当服务成功部署并启动后,我们才将端口号和 PID信息写入 etcd。
基础架构
除了允许将一个新发布服务的响应与服务分离之外,etcd看起来被使用过度了:我们可以监视etcd键变化,并以希望的方式对其进行修改,而不用将etcd与服务紧紧地耦合在一起。 etcd键变化后,confd使用配置生成文件并执行命令,这点我们将在后面用到。服务有一个相关的nginx配置文件,路径为/etc/nginx/sites-enabled/experiment.conf,它实现在一台独立主机上运行多个服务。为了实现基于etcd信息变化的处理机制,我们在系统中添加一个配置文件/etc/confd/conf.d/experiment-nginx.toml,实现监视uswitch/experiment/port/current路径下数据、生成nginx配置文件、当配置变化时重启nginx。nginx配置文件的模板比较简单,我们只需要在输出文件中分配一个随机的端口号。
[template] src = "nginx.conf.tmpl" dest = "/etc/nginx/sites-enabled/experiment-nginx.conf" owner = "root" group = "root" mode = "0644" keys = ["/uswitch/experiment/port/current"] check_cmd = "/usr/sbin/nginx -t -c /etc/nginx/nginx.conf" reload_cmd = "/usr/sbin/service nginx reload"
upstream experiment_app { server 127.0.0.1:{{.uswitch_experiment_port_current}}; } server { listen 80; location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_redirect off; proxy_pass http://experiment_app; } }
nginx重启导致主进程开了一个新的工作进程并杀死老的那个,从客户端应用的角度来看,这意味着我们拥有(几乎为)零的停机时间。正因如此我们不需要为了更新我们的服务,而从ELB中移除我们的主机,因此,我们可以终止这种移除-更新-添加部署(方式),使用并行(方式)部署到所有的机器。
我们可以通过查看uswitch/experiment/pid/previous,配置和产生一个可以被执行的脚本去杀死与其相关联的进程PID,清掉之前的服务。
[template] src = "restart-service.sh.tmpl" dest = "/tmp/restart-service.sh" mode = "0700" keys = ["/uswitch/experiment/pid/previous"] reload_cmd = "/bin/sh /tmp/restart-service.sh"
#!/bin/sh kill -9 {{.uswitch_experiment_pid_previous}}
与其他地方一样,都需要对配置定期检查,我们在首次开启我们服务的时候,查看nginx生成的配置,并且重启nginx。如果服务第二次被启动,nginx配置再次被生成并且nginx重启;之前的服务就会被杀掉;更重要的是,返回的服务数量也改变了!
如果你对于在我们的机器上发生的事情感兴趣,这里有介绍,还包含了hack day工程。
总结
希望这篇颇具深度的服务部署攻略使你相信我们的解决方案:
部署系统时实际宕机时间为0,这点比以前减少很多;
能够并行部署在多台机器上,这意味着部署时间接近一个常量,以前部署时间为一个线性值;
可靠性提高,只有当新的服务启动成功后,旧的服务才会被替换,如果启动失败,以前我们不得不进行回滚;
服务间相互隔离,在同一台机器上部署多个服务时各自不受影响,以前我们降级服务的次数比部署服务的次数还多;
下一步是微服务架构的核心:集群部署etcd,完全移除nginx:如果客户端应用使用注册表定位服务的方式,那么下面这些步骤都是不必要的。实际上,我们也在寻找不使用etcd而建立一个完整的服务注册表的方案,比如consul 或者 zookeeper, 后者在我们的其它项目中已有部署。 然而,这种方法要求客户端应用承担更多的功能,这严重偏离了预期目标!
目前这段代码仍然是我们黑客生活的一部分:它可以使用,但还需要在真实环境中进行验证。假设在多台主机上有很多服务在同时运行,我们定期地部署服务,上面的这个方案可以节省我们相当大一部分时间,它可以应用到我们的生产系统中。
如果你用其它的方案解决了上面提到的问题,请务必留言。