分散システムの耐障害性テストの取り組み

こんにちは、Necoチームの池添(@zoetro)です。

サイボウズが提供するクラウドサービスcybozu.comでは、アーキテクチャを刷新すべく「Neco」というプロジェクトを実施しています。 そのプロジェクトでは、サーバのライフサイクルを管理するsabakanや、Kubernetesクラスタを構築するためのCKE(Cybozu Kubernetes Engine)などの分散システムを開発しています。 安定してサービスを提供するためには、このようなインフラを支える分散システムの耐障害性が重要になってきます。

本記事では、我々のチームが分散システムに対してどのような耐障害性テストを実施しているのかを紹介します。

耐障害性を高めるために

機材故障

サイボウズの管理するデータセンターでは1,000台規模のサーバを扱っており、日常的にハードウェアの故障が発生します。 例えば、以下のような機材故障が発生する可能性があります。(参考: 本当は恐ろしい分散システムの話)

  • 電源やサーバが故障して、サーバが利用できなくなる
  • ストレージが故障して、アクセスできなくなったり遅延が発生したりする
  • NICが故障して、通信障害や遅延やパケットロスが発生する
  • スイッチが故障して、通信障害や遅延やパケットロスが発生する
  • ラックが故障して、ラック内のサーバが利用できなくなる

f:id:cybozuinsideout:20180830140950p:plain
ハードウェア故障

このような故障はどれだけ信頼性の高いハードウェアを使ったとしても回避することができないため、ハードウェアの冗長化とソフトウェアでの対応の両面から対策をおこなう必要があります。

故障への対策

機材故障に対してどのような対策をおこなうべきかを、サービスレベル目標(SLO: Service Level Objective)から考えていきます。 例えば、下記のようなSLOを設定しているとしましょう。

  • 機材に単一の障害が発生した場合、サービスの停止が一分以内に復旧すること
  • 機材に単一または二重の障害が発生した場合でもデータを紛失しないこと

このSLOを達成するために、機材故障に対する対策をいくつかのレイヤーに分けて考えてみます。

まずはハードウェアの冗長化による対策となります。ラックの電源は二系統用意し、NICはサーバごとに二枚、ToR(Top of Rack)スイッチやSpineスイッチも二重化します。

次にネットワークレイヤーでの対策を考えます。Necoプロジェクトのアーキテクチャでは、ECMP(Equal-Cost Multi-Path routing)による冗長経路とBFD(Bidirectional Forwarding Detection)による障害検知により、ネットワーク障害が発生しても即座に経路収束するような構成となっています。 障害発生時にネットワークの経路が収束する様子については、Necoのネットワーク - アーキテクチャと設計編で紹介しています。

上記の対策により、電源故障やネットワーク障害などをある程度防ぐことができるでしょう。 しかし、サーバの故障やネットワークの遅延、ラックがまるごと故障するケースは防ぐことができません。 このような状況ではソフトウェアレイヤーで対策をおこなう必要があります。

例えば我々の開発しているCKE(Cybozu Kubernetes Engine)では、以下のような対策をおこなっています(具体的な実装方法は今回は割愛します)。

  • 3つ以上のプロセスを異なるラック上のサーバに分散配置してクラスタを構築する
  • クラスタ内のいずれかのプロセスからリーダーを選出し処理を進める
  • リーダーにアクセスできなくなったり不正終了した場合、別のプロセスがリーダーを引き継いで処理を続行する
  • データは分散KVSに格納する

耐障害性テスト

どのようなテストをすべきか

耐障害性テストでは、上述したような対策をおこなうことでSLOを達成することができるかどうかを確認することになります。

ここで機材の故障をやみくもに発生させても効果的なテストをおこなうことはできません。 どういう故障が発生した時に分散システムに問題が起きるのかを考えます。 例えば以下のような状況が考えられます。

  • ネットワークの障害により、リーダープロセスにアクセスできなくなる
  • サーバの故障などにより実行中のリーダープロセスが突然終了する
  • ネットワーク障害によりクラスタが複数に分断されてしまう
  • ストレージ故障により、データの複製の一部が失われる

f:id:cybozuinsideout:20180830140955p:plain
クラスタ故障

これらの障害が発生した時に下記の項目を確認するテストを用意することになります。

  • リーダープロセスにアクセスできなくなっても一分以内に復旧すること
  • リーダープロセスが途中終了した処理が、別のリーダーに引き継がれて実行されること
  • クラスタ分断によりスプリットブレイン問題が発生しないこと
  • データを紛失したり、データの不整合を発生させたりしないこと

サーバやネットワーク障害のエミュレート

耐障害性テストを実施するためには、サーバやネットワークに故意に障害を発生させる必要がありますが、実機上で実施することは簡単ではありません。 そこで我々は placemat というツールを開発し、VMやコンテナやnetns, iptablesなどを駆使して、データセンター環境の仮想化を実施しています。

このplacematにより、サーバの故障、NICの故障、スイッチの故障などを仮想環境上で再現することができるようになっています。 スイッチはBIRDやdnsmasqを利用してソフトウェア的に再現しているので、本物の機材と若干の挙動の差異が発生する場合もありますが、多くのケースは再現できています。

なお、placematでデータセンター環境を再現するためには大量のVMを立ち上げる必要があります。 そのようなテストをCircleCI上でそのまま実行することは難しいため、GCP上にテスト用の大きめのインスタンスを用意し、CircleCIからテストをキックする仕組みを構築しています。

Failure Injection Test

分散システムでは、特定の処理をおこなっているタイミングで障害が発生した場合に問題が起きる可能性が高いです。

例えばCKEはetcdクラスタを自動構築する機能を持っているのですが、etcdクラスタにメンバを追加する際に以下のような処理をおこなっています。

  1. etcdのコンテナイメージを取得する
  2. etcd用のデータボリュームを作成する
  3. etcdのMember Add APIを利用してetcdクラスタにメンバの追加を要求する
  4. Member Add APIの結果を利用してetcdコンテナを起動する

ここで手順3と4の間で何か障害が発生しプロセスが終了してしまった場合、etcdクラスタにはメンバ追加のリクエストを受けているのに、実際にはetcdのメンバプロセスが起動していないという中途半端な状態になってしまいます。 CKEではこのような状況が発生した場合、別のCKEプロセスがリーダーとなって処理を続行し、etcdクラスタを正しく構築します。

さて、このような挙動をテストしたい場合、手順3と4の間というとても短いタイミングでサーバやネットワークの障害を発生させる必要があり、狙っておこなうことは困難でしょう。

そこで、システム内に意図的にエラーを発生させるコードを埋め込んでテストするFailure Injection Testingという手法を使います。 Go言語であればgofailというツールが利用できます。gofailの利用方法は別記事にまとめたのでそちらをご覧ください。

blog.cybozu.io

Failure Injection Testの例を紹介したいと思います。可読性をあげるため一部処理を簡略化しています。 なおテストコードの記述にはGinkgoというBDDテストフレームワークを利用しています。

var _ = Describe("etcd cluster deployment", func() {
    It("should change the leader and continue to deploy etcd cluster", func() {
        // 現在のリーダーを確認
        firstLeader := ckecli("leader")
        Expect(firstLeader).To(Or(Equal("host1"), Equal("host2")))

        // etcdにmember addした直後に障害を発生させるように、firstLeaderにfailure pointを設定
        injectFailure(firstLeader, "etcdAfterMemberAdd")

        // etcdクラスタにメンバを追加するようにCKEを設定する
        ckecliClusterSet(cluster)

        // etcdクラスタが構築されたことを確認
        Eventually(func() bool {
            return checkEtcdClusterStatus()
        }).Should(BeTrue())

        // リーダーが切り替わっていることを確認
        newLeader := ckecli("leader")
        Expect(newLeader).To(Or(Equal("host1"), Equal("host2")))
        Expect(newLeader).NotTo(Equal(firstLeader))
    }
})

injectFailure()が、故意にエラーを発生させるコードを埋め込むための関数になっています。 gofailを利用するとREST API経由でエラーコードの埋め込みをおこなうことができます。

ここでは、現在のリーダープロセスがetcdクラスタにmember addした直後にpanicを起こすコードを埋め込んでいます。 その後に別のプロセスがリーダーを引き継ぎetcdクラスタを正しく構築していることを確認しています。

おわりに

近年ではサービスを提供するためのインフラとしてAWSなどのパブリッククラウド環境を利用することが当たり前になってきています。 サイボウズでもUS市場向けクラウドサービスのAWS移行プロジェクトを実施しています。 AWSを利用すれば容易に信頼性・可用性の高いインフラを手に入れることができます。

一方で我々は、サーバスペックの自由度の高さ、顧客のセキュリティ要件への対応、コストメリットなどを考慮して、自社データセンターで運用しています。 自社データセンター上のインフラで高い信頼性を実現するためには、今回紹介したような耐障害性試験の取り組みが重要になります。 今後はより安定したサービスを提供できるよう耐障害性テストを強化し、Chaos Engineering的な取り組みにも挑戦していきたいと考えています。

なお本プロジェクトは人員募集中です。信頼性の高いインフラづくりに関わってみたい方はぜひお声掛けください!