Multi-AZ構成におけるネットワーク遅延を考慮して参照クエリを振り分ける

[SRE Advent Calendar 2018](https://qiita.com/advent-calendar/2018/sre) の 9日目を担当させていただきます。

この記事では Single-AZ 構成だったサービスを Multi-AZ 構成に変更する際、 AZ 間のネットワーク遅延の影響を軽減するために行った工夫について紹介させていただきます。

## 前提
まず前提となるサービスの構成について簡単に説明します。

- インフラストラクチャはすべてAWS
- Perlで実装されたアプリケーションサーバ
- 永続ストレージは Aurora MySQL compatible
- DBノードは4台構成
  - db01~db03 の 3台と管理画面や調査といった別ワークロード向けのadminノード
- Single-AZ 構成
  - 例外として LB は Cross-Zone balancing がすでに有効

Aurora のクラスターは4台で構成されていますが、4台のうち1台は管理画面や分析といった別ワークロードのために用意している小さいサイズのadminノードで、こちらにはアプリケーションのクエリが飛ばないようにする必要があります。
これを実現するために各インスタンス上でHAProxyを立て、adminノードを除いた参照エンドポイント `localhost:3306` を提供しています。
特定の reader ノードに proxy するためのHAProxyの設定は以下のようになっています。

```
listen  mysql-slave
        bind            127.0.0.1:3306
        mode            tcp
        option          external-check
        external-check  path "usr/bin:/bin:/usr/local/bin"
        external-check  command /usr/local/bin/is_aurora_slave.sh
        balance         roundrobin
        server          slave1  db02:3306 check
        server          slave2  db03:3306 check
        server          master  cluster-endpoint:3306 check backup
```

HAProxy は external-check で任意のスクリプトをヘルスチェックに使えますので、シェルスクリプトで疎通確認の他に backend のノードが reader かどうかをチェックしています。
また、すべての reader がダウンしたときのため backup 指定でクラスターエンドポイントを設定しています。
（ちなみに、 `server master` のヘルスチェックが落ちてしまわないように、 `backup` が指定されている場合、スクリプトは reader かどうかをチェックしません）

## 課題
構成するアプリケーションサーバ等のEC2インスタンス、Aurora ノードはすべて同一のAZに立ち上げていましたが、可用性向上のため、それらを複数のAZに分散させることとしました。

Single-AZ | Multi-AZ
:--: | :--:
<img src="https://qiita-image-store.s3.amazonaws.com/0/347/91857f34-0ad1-ce04-2b8e-b171a6e65ce5.png" height="350"> | <img src="https://qiita-image-store.s3.amazonaws.com/0/347/d56b5410-3035-1aee-58eb-9df335aa6436.png" height="350">

これまで az-a に配置していたアプリケーションサーバと Aurora ノードをそれぞれ az-a, az-c に分散して配置すると上記の Multi-AZ の図のようになります。

アプリケーションサーバは HAProxy がラウンドロビン式で接続を振り分けている以上、 az-a, az-c どちらに配置された場合でも参照系のクエリの半分は AZ をまたいだものとなります。 加えて az-c に配置された場合は同じ AZ に writer が無いため、 writer に向けたいクエリは当然 AZ をまたぐ必要があります。
各 AZ は地理的に離れているため、 これらの通信にはネットワーク遅延が乗ります。

ここで、Multi-AZ 構成にする際に、ネットワーク遅延が影響してアプリのレスポンスタイムが悪化するのではないかという懸念が出てきました。

## 検証
本番環境で用いているものよりも小さいサイズのインスタンスを用意し、各 AZ にEC2インスタンスを1台ずつ、Aurora ノードも1台ずつ配置しました（writer は az-a にいます）。

HAProxyの設定を切り替えて全てのパターンについてリクエストタイムを計測します。組み合わせは次の表のとおりとなります。表についてはレスポンスタイムの優劣を◎から✗で示しました。

EC2 \ Aurora(writer:reader) | az-a:az-a | az-a:az-c |
:--: | :--: | :--: |
az-a | ◎ | ○（参照系ヘビーの時△） |
az-c | ✗ | △（更新系ヘビーのとき○） |

※ 列について補足
　az-a:az-a → writer も reader も az-a を参照したとき
　az-a:az-c → writer は az-a, reader は az-c を参照

基本的に writer, reader が同一AZに存在する az-a のEC2インスタンスが最も高速になりますが、APIエンドポイントによって参照系がヘビーだったりするため、常に az-a に立てたEC2インスタンスのレスポンスが速いわけではありません。
このためレスポンスタイムはAPIエンドポイントごとに集計しました。

参照系がヘビーなAPIエンドポイントに注目して、最良ケース(現在のSingle-AZ構成)と最悪ケースを比較したところ、最大380msほどレスポンスタイムが悪化するということがわかりました。最も影響の大きいAPIエンドポイントでこの結果になりましたが、それ以外でも 300ms 程度悪化するエンドポイントが複数個ありました。

各APIエンドポイントの特性を知るためにクエリ発行数、レスポンスタイムを調べました。
@tkuchiki さん作の [alp](https://github.com/tkuchiki/alp)、@acidlemon さん作の [Plack::Middleware::QueryCounter](https://metacpan.org/pod/Plack::Middleware::QueryCounter) を使用させていただきました。とても便利で助かりました。

## 改善方法
writer がいない az-c の更新系クエリが遅いのは受け入れるしかありませんが、az-a, az-c ともに reader ノードはあるので、できれば参照系クエリは同じ AZ の reader に振り分けたいと思うところです。
ですから HAProxy がラウンドロビンで各 reader に proxy しているところを、通常時は同じ AZ の reader に proxy すれば良さそうです。

そこでHAProxy の設定ファイルは chef で管理しているので、 chef のレシピに自分のIPアドレスとAuroraのノードのIPアドレスを比較し、同じ subnet にいない場合は `backup` 指定し、通常時は proxy しないこととしました。

chefのレシピ内に次のようなlambdaを記述し、 template 内で `<% if is_same_az.call(node[:ipaddress], slave %>backup <% end %>` というふうに呼び出して backup かどうかを切り替えています。
こうすることで同一AZのreaderノードが落ちてしまった場合は、 backup 指定の別AZのreaderノードへクエリを逃がせます。

```
is_same_az_lambda = Proc.new do |node_ip, slave|
  begin
    host = URI.parse('mysql://' + slave).host # host:port 形式の文字列から host 部分を取り出す
    ip = Socket.getaddrinfo(host, nil)[0][3]  # Aurora ノードの名前解決
    IPAddr.new(node_ip).mask(24).include?(ip) # 同じ subnet かどうか
  rescue
    false
  end
end
```

db02 が az-a、db03 が az-c、az-a に立っているEC2で chef を回した場合では、以下のようになります。

```
        server          slave1  db02:3306 check
        server          slave2  db03:3306 check backup
        server          master  cluster-endpoint:3306 check backup
```

先述の通り reader ノードが全滅した場合のためにクラスターエンドポイントを backup として組み込んでいますが、このように backup な接続先が複数あった場合に `slave1` が落ちたらどうなるのか、という疑問がありました。
こちらについては、 `slave1` が落ちた際にはまず `slave2` がアクティブになり、 `slave2` が落ちると `master` がアクティブになる、という挙動でした。
backup を起こさないといけない時に HAProxy がどれを選ぶかについては、各バックエンドの `id` が小さい順になります。設定ファイルで id を指定しなかった場合には自動的に上から順に id が振られるためこうなります。

## 結果
参照系がヘビーなケースにおいて 380ms 程度のレスポンス悪化が見られましたが、同一AZの reader ノードを参照するように工夫することで 150ms に程度に抑えられることを確認しました。

本番環境へ投入後も問題なく動いており、当初懸念していたレスポンスタイムの悪化もほどほどに抑えることができました。

## 現在
現在はこの構成を廃止しており、HAProxy による reader ノードへの振り分けは単純なラウンドロビン式に戻しています。

これには az-d という新しい AZ が利用可能になったこと、c4系からc5系への移行によって処理能力の向上でクエリのネットワーク遅延の影響が軽減されたという2点があります。

az-d を利用して 3AZ 構成とする場合、 reader ノードが存在しない AZ ができるため、それを考慮する必要が出てきました。
`backup` 指定にしているノードが使われるのでそのままで動かないこともないはずですが、1度にアクティブになる backup な接続先は1つのみですので、reader ノードの負荷が偏る恐れがあります。
これを解決するには chef のレシピに変更を加える必要ですが、やりすぎ感があります。

c4/m4系からc5への移行を検討した際にパフォーマンスを測定したところ、処理能力の向上でリクエストタイムが全体的に速くなり、AZ間のネットワーク遅延の影響が隠蔽できることがわかりました。

今後は Aurora のカスタムエンドポイントに置き換えて、HAProxy の廃止を検討中です。
ミドルウェアは少ないほうが良いというのは社内でよく言われており、無くせるものはどんどんなくしていって、管理するコストや障害点となりうる箇所を減らしていこうという方針に沿っています。


次回は @kusuwada さんです。