Clojure 部署无需停机

jopen 10年前

uSwitch,我们迷上了微服务架构,这些微服务大部分采用了Clojure Ring库,我们的基础设施托管在Amazon AWS(亚马逊网页服务)。

微服务的一个优点是支持横向扩展,尤其是把它们部署在 EC2(亚马逊弹性计算网云)上,实现这一点很简单: 增加更多的机器! 不幸使用了 Clojure,或者对 java虚拟器有特殊的要求,应用服务启动时性能很低,这些问题说明部署会占用很多不合理的时间。 为了解决这些问题,我们使用热插拔式的部署方式。 从响应的 ELB(弹性负载均衡)中移除一个主机;更新主机上的服务;将主机加入到 ELB中。

我们升级一个服务的过程如下图:

Clojure 部署无需停机

操作步骤是:

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的进程监视服务的变化。

升级服务的过程更像是这样:

Clojure 部署无需停机

其中的步骤是:

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, 后者在我们的其它项目中已有部署。 然而,这种方法要求客户端应用承担更多的功能,这严重偏离了预期目标!

目前这段代码仍然是我们黑客生活的一部分:它可以使用,但还需要在真实环境中进行验证。假设在多台主机上有很多服务在同时运行,我们定期地部署服务,上面的这个方案可以节省我们相当大一部分时间,它可以应用到我们的生产系统中。

如果你用其它的方案解决了上面提到的问题,请务必留言。