其他测试框架 使用 jepsen 进行分布式系统测试

唐潇唐 · 2022年04月22日 · 1006 次阅读

jepsen 是什么

Jepsen 是一个用clojure编写的,用于测试分布式系统的混沌工程测试框架。Jepsen 允许用户编写一系列的读写事件,以及故障注入组合来模拟分布式系统遇到的各种异常,最终通过验证数据的线性一致性或者事务一致性来帮助用户确认分布式系统是否具备一致性读写。

业界主流的数据库,都使用了 jepsen 作为它们的测试框架,例如MySQL,etcd,TiDB等。

jepsen 架构

jepsen 通过一个控制节点和多个 node 组成的被测集群组成,多个 node 节点形成一个分布式系统。jepsen 通过SSH的方式来与 node 节点进行通信。而 control 节点本身通常由以下几个部分组成:

  • DB:通常用于初始化和收尾操作,比如setup方法一般用于安装和启动数据库,teardown方法用于测试完成后销毁数据库,另外提供logFiles用于挂载每个 node 节点的日志文件
  • Client:具体的读写操作,用户编写具体的读写代码。jepsen 提供类似 java 接口的 client,用户通过继承实现自定义的业务逻辑。比如在open!方法用于初始化连接,setup!用于前置操作,invoke!执行具体的读写
  • Nemesis:具体的故障注入。jepsen 提供了丰富的故障注入类型,包括随机网络分区,随机增加删除节点,网络丢包,时钟偏移等等。另外用户也可以自定义扩展故障
  • Checker:检查器,jepsen 在读写完成后,会根据使用线性一致性还是事务一致性进行检查,判断读写请求是否符合预期,并生成测试结果
  • Generator:生成器,用户通过自定义使用多少的线程进行读写,读写组合方式,故障注入方式等,来控制 client 如果对节点进行控制

在运行的时候,我们通常会并发的生成多个生成器用于对同一个 key 进行读写,然后对多个 key 来进行统计判断,查看分布式系统的一致性要求。jepsen 的检查器,通过内置的elle检查事务一致性,通过knossos检查线性一致性

使用 jepsen 进行测试

我们使用公司内部基于jraft的强一致 KV 存储引擎作为分布式测试系统。一共使用 7 个节点,5 个节点作为 leader 或 follower,2 个节点作为 learner。因为 jraft 是强一致存储,所以我们使用 jepsen 来进行强一致存储验证。

通过db模块初始化我们的 node 集群

(defn db
  "mdb-store DB for a particular version."
  [version]
  (reify db/DB
    (setup! [_ test node]
      (info node "installing mdb-store" version)
      (c/su
        (c/exec :sh mdb-store-download version)
        (c/exec :mkdir :-p (str mdb-store-home (str/replace node "." "")))
        (c/exec :echo (mdb-store-cfg-servers test)
                :> (str mdb-store-home (str/replace node "." "") "/member.conf"))
        (c/exec :cp mdb-store-raft-cfg (str mdb-store-home (str/replace node "." "") "/"))
        (Thread/sleep 5000)
        (c/exec :sh mdb-store-start)
        (info node "started")
        (Thread/sleep 20000)))

    (teardown! [_ test node]
      (info node "tearing down mdb-store")
      (c/su
        (c/exec :sh mdb-store-stop)
        (c/exec :rm :-f (str mdb-store-path "mdbStore.jar"))
        (c/exec :rm :-rf "/data")))

    db/LogFiles
    (log-files [_ test node]
      [mdb-store-log-file])))

通过Client模块进行读写操作

(defrecord Client [client]
  client/Client
  (open! [this test node]
    (let [client (str "http://" node ":"  mdb-store-port)]
      (assoc this :client client)))

  (setup! [this node]
    (create-table client))

  (invoke! [this test op]
    (let [[k v] (:value op)
          crash (if (= :read (:f op)) :fail :info)]
      (try+
        (case (:f op)
          :read (let [value (get-mdb-store client (pr-str k))]
            (assoc op :type :ok, :value (independent/tuple k value)))
          :write (do
                   (write-mdb-store client (pr-str k) v)
                   (assoc op :type :ok)))
        (catch KeyHasExistException e
          (assoc op :type :info, :error :key-has-exist))
        (catch KeyNotFoundException e
          (assoc op :type :info, :error :key-not-found))
        (catch IllegalStateException e
          (let [^String msg (.getMessage e)]
            (assoc op :type :fail, :error msg)))
        (catch Exception e
          (let [^String msg (.getMessage e)]
            (cond
              (and msg (.contains msg "TIMEOUT")) (assoc op :type crash, :error :timeout)
              :else
              (assoc op :type crash :error (.getMessage e))))))))
  (teardown! [this test])
  (close! [_ test]))

由于我们的业务原因,读写请求都是通过 http 接口对外进行暴露。在系统内部分为多张表,所以初始化会创建一张表。写操作时,会首先查询一下这个 key 是否存在,如果不存在会新增,如果存在会修改,这是调用的不同接口,所以这里我 catch 住了KeyHasExistExceptionKeyNotFoundException两种异常,并且将:type置为:info,即该部分报错不参加最终的一致性检查,也不算报错。不过,如果是其他错误类型,将被判断为:fail类型,会计算为错误

最后,我使用默认的随机网络分区来进行测试

(defn mdb-store-test
  "Given an options map from the command line runner (e.g. :nodes, :ssh,\n  :concurrency ...), constructs a test map."
  [opts]
  (merge tests/noop-test
         opts
         {:name "mdb-store"
          :os debian/os
          :db (db (:version opts))
          :pure-generators true
          :client (Client. nil)
          :nemesis (nemesis/partition-random-halves)
          :checker (checker/compose
                     {:perf (checker/perf)
                      :indep (independent/checker
                               (checker/compose
                                 {:linear (checker/linearizable
                                            {:model (model/cas-register)
                                             :algorithm :linear})
                                  :timeline (timeline/html)}))})
          :generator (->> (independent/concurrent-generator
                            10
                            (range)
                            (fn [k]
                              (->> (gen/mix [r w])
                                   (gen/stagger (/ (:rate opts)))
                                   (gen/limit 100))))
                          (gen/nemesis
                            (cycle [(gen/sleep (:interval opts))
                                    {:type :info, :f :start}
                                    (gen/sleep (:interval opts))
                                    {:type :info, :f :stop}]))
                          (gen/time-limit (:time-limit opts)))}))

我使用 10 个生成器来对相同的 key 进行读写操作,并最终会产生多个 key 来构成测试数据。使用 20 个并发数,以每秒 20 个请求的频率,故障间隔时间为 60s,一共运行 10 分钟。通过漫长的分析,最终输出

Everything looks good! ヽ(‘ー`)

说明我们的读写是满足线性一致性的

根据最终生成的测试报告

由于我们设置的rpc_request_timeout是 3s,我们可以看出,耗时大于 1000ms 的都是 3s 超时的报错。且网络恢复后,集群都能在 1 分钟内收敛完成,不过还是有少量的耗时较长的请求

结语

总体来看,jepsen 提供了丰富的功能,我们可以自定义修改我们 raft 的配置 (比如可以修改election_timeout,以模拟快速超时的场景),集群配置,以及把混沌测试接入 CI 流程。jepsen 帮助了我们完成自动化混沌实验,且功能足够强大,后续我们也会基于该脚本进行优化,扩展更多,更复杂的故障注入和检查方式。不过 jepsen 并不是完美的,也存在以下问题

  • jepsen 使用 clojure 脚本语言编写的,clojure 学习成本过于陡峭
  • 由于 jepsen 将读写结果保存在 history 中,然后会去检查每个 key 是否满足线性一致性,随意运行时间过长就容易产生 OOM,我们很多的测试都需要长时间运行才能观察到 bug,比如我们的 snapshot 时间设置为 30 分钟,我们的运行时间甚至不能大于 30 分钟,不然会产生大量 OOM 报错
  • 因为 jepsen 的运行环境需要拉取一些依赖包,而我们的网络环境比较拉胯的话,就很难完成环境搭建,直接再第一步卡死

由于 jepsen 的学习成本,所以近几年有不同的厂商基于 jepsen 实现其他语言的版本,例如 pingcap 的chaos,openmessaging 的openchaos。不过从系统的成熟度,还是业界认可度,jepsen 目前还是处于业界最主流的分布式测试框架。

官网:http://jepsen.io/

github 仓库:https://github.com/jepsen-io/jepsen

中文学习文档:https://jaydenwen123.gitbook.io/zh_jepsen_doc/

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册