さくらのクラウド「エンハンスドLB」を作った話(後編)

この記事は [さくらインターネット Advent Calendar 2019](https://qiita.com/advent-calendar/2019/sakura) 5日目の記事です。

さくらインターネット研究所の大久保です。

昨日公開した[前編](https://qiita.com/jh1vxw/items/11b00ecb1626ac8603b8)では、弊社のIaaSであるさくらのクラウド上で提供している「エンハンスドロードバランサ」機能について、概要と全体構成の紹介をさせていただきました。

後編となる本稿では、システムを構成する各パーツの詳細について引き続き紹介したいと思います。

# VIPフェイルオーバ機能について

今回まずはじめに、DDoS攻撃対策として実装した機能について紹介します。

が、あらかじめ申し上げておきますと、これでどんなDDoSにも完璧に対応できます！ **というものではありません** 😇

一般的なDDoSミティゲーションの仕組みでは、DPI(Deep Packet Inspection)を用いて攻撃トラフィックのみを抽出破棄し、正常なトラフィックのみを通過させることが行われます。一方、昨今発生しているDDoS攻撃は規模が大きく、弊社で観測されるものでも数十Gbpsから100Gbpsを超えるようなケースもあります。バックボーン回線が埋め尽くされるような場合は、攻撃先に対する全ての通信を上流トランジットISPにて遮断せざるを得ない状況となります。

そのような背景から、エンハンスドLBではフィルタリングの仕組みでは対応せず、攻撃により通信遮断がかかったら **一旦あきらめます。** そのかわり、ロードバランサのVIPを変更し、DNSのAレコード更新までを自動的に行う仕組みを実装しました。それがこの **VIPフェイルオーバ機能** となります。

もちろん、変更したIPアドレスに攻撃が追従された場合はどうにもならず、完全な対策というわけではありません。あくまでも、ある程度の効果は見込めるかもしれない緩和策、というふうに捉えていただければ幸いです :bow:

前編で紹介した構成図のうち以下の `vipmonitor` がこれを行っているパートとなります。

![vipmonitor.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108990/31cc3732-6e13-3305-8c7d-5797a4c8de4d.png)

弊社バックボーンにはDDoS攻撃の検知と通信遮断を行うシステムがあります。通信遮断はRTBH(Remotely Triggered Black Hole Filtering)というBGPを用いた仕組みがあり、発動するとブラックホール経路がネットワーク全体に広告されます。

vipmonitorは、このブラックホール経路情報のフィードを受け、エンハンスドLBのお客様のVIPが通信遮断の対象になっているかどうか、ping疎通性確認と合わせて判定してます。通信遮断を検出した場合は、MQを通じてAPIサーバに対してVIPフェイルオーバ処理をトリガーします。

完全に余談ですが、RTBHについては自分がバックボーンのオペレーションやってた ~~若き時代の~~ 発表資料が以下に残っていたのでご参考までに😇

RTBH実装例の紹介～AS9370編～
http://irs.ietf.to/past/docs_20071011/

# HTTPS(TLS)への対応について

続いては ~~闇の~~ 奥の深いTLSについて触れていきたいと思います😇

エンハンスドLBは、TLS1.2に加え最新のTLS1.3にも対応しています。一方、TLS1.1以下はサービスリリース当初から無効化することにしました。レガシープロトコルにおけるセキュリティイシューがその大きな側面ですが、TLS1.2も仕様が公開されてから10年以上経過しています。2019年現在において、クライアントのTLS1.2実装も充足しており、互換性維持のためにTLS1.1をサポートする意義は薄れてきているからです。

ところで筆者はこれまでの人生において、下位レイヤー(L1～L4くらい)のネットワークをメインに扱ってきたこともり、HTTPやTLSに関してはド素人でした。こうやってサービス提供するとなると、当然深い知識が必要となります。今回開発と並行して半年くらいTLSと暗号化技術についてゼロから勉強することになりました😇 (いまだに調べれば調べるほど、暗号全くわからん状態ですが😇

### 暗号スイートどうするか問題

さて、エンハンスドLBのような万人向けのTLSを実装するにあたって、どの暗号スイートを有効化し、優先順をどうするかが一番の悩みどころです。先の通り、暗号技術について相当の専門の知識が必要なので、お客様にチューニングいただかなくてもいいようにしたいところです。

TLS1.2では仕様上、鍵交換、サーバ認証、対称暗号、MAC/鍵導出において、多数の組み合わせが規定されています。その中から安全に使えてパフォーマンスもよく、相互接続性も問題ない、 **いい感じの暗号スイート群** を選ぶ必要があります。

様々な文献をあたった結果、エンハンスドLBでは以下のポリシーで選ぶことにしました。

- PFSを満たす鍵交換方式のみ
  - DHEは重いので除外、その結果ECDHEのみ
- RSAサーバ証明書に加え、楕円曲線暗号を用いたECDSA証明書にも対応
- AEAD(認証付き暗号)を優先
  - AESだとGCMがポピュラーでパフォーマンスが良いのでこれを採用
- AESはパフォーマンスの観点で128bitを優先

結果、以下の優先順で6種類(×2認証方式)を有効化することにしました。

RSA証明書用

~~~
ECDHE-RSA-AES128-GCM-SHA256
ECDHE-RSA-AES256-GCM-SHA384
ECDHE-RSA-AES128-SHA
ECDHE-RSA-AES256-SHA
ECDHE-RSA-AES128-SHA256
ECDHE-RSA-AES256-SHA384
~~~

ECDSA証明書

~~~
ECDHE-ECDSA-AES128-GCM-SHA256
ECDHE-ECDSA-AES256-GCM-SHA384
ECDHE-ECDSA-AES128-SHA
ECDHE-ECDSA-AES256-SHA
ECDHE-ECDSA-AES128-SHA256
ECDHE-ECDSA-AES256-SHA384
~~~

### TLS1.3対応について

TLS1.3は、これまで約10年に渡って使用されてきたTLS1.2をアップデートする形で、2018年8月にRFC8446として仕様が定義された最新のセキュリティプロトコルです。詳しくは弊社の山田健一が以下ブログで解説していますので、是非ご一読ください :bow:

SSL/TLSとは何なんだ？ 今こそ知ってもらいたいSSL/TLSのお話 〜 2回目 〜 TLS1.3 HTTP/2 のお話
https://knowledge.sakura.ad.jp/21470/

エンハンスドLBでは、HAproxyと合わせてOpenSSLを利用しています。OpenSSLはバージョン1.1.1からTLS1.3に対応していますが、比較的新しめのものとなるため、当方ではHAproxyと共に独自にコンパイルしてインストールしています。

さて、TLS1.3の暗号スイートですが、そもそも仕様上安全なもののみが採用されているためTLS1.2のように悩まなくてよくなりました。エンハンスドLBではCCMを除く、以下の優先順で3つの暗号スイートを有効化しています。

~~~
TLS_AES_128_GCM_SHA256
TLS_AES_256_GCM_SHA384
TLS_CHACHA20_POLY1305_SHA256
~~~~

TLS1.2と合わせて以下のような暗号スイートのHAproxy設定を生成しています。記述はちょっと長くなりますが、暗号スイートを優先順に全て愚直に書くのが決定的でよいかと思います。

~~~
global
        ssl-default-bind-options no-sslv3 no-tlsv10 no-tlsv11

        # TLS1.2
        ssl-default-bind-ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA:ECDHE-ECDSA-AES128-SHA256:ECDHE-ECDSA-AES256-SHA384

        # TLS1.3
        ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
~~~

暗号スイート設定に関するOpenSSLの内部インターフェイスが、TLS1.2までとTLS1.3で分かれているそうで、HAproxyも別の設定になっています。TLS1.2は `ssl-default-bind-ciphers` にて、TLS1.3は `ssl-default-bind-ciphersuites` にて指定します。ちょっと紛らわしいですね😇

あと細かい話ですが、HAproxyはPrioritizeChaChaというオプションが有効化されています。TLSハンドシェイクの中で基本的にはサーバサイドで指定した優先順に暗号スイートの選択を行うのですが、クライアントがCHACHAを最優先に提示してきた場合はそれを用いるという挙動です。

Intel製をはじめとするサーバやPCで使用されるCPUには、たいていAES-NIというハードウェアオフロードによりAESの暗号化/復号処理を高速化できる機能があります。一方、スマートフォンなどのモバイル端末のCPUにはAES-NIが載っていないことがあり、端末側にとって比較的負荷の軽いストリーム暗号であるCHACHAの利用が好まれます。このようなシチュエーションにて最適化するためのオプションといえるでしょう。

参考までに、HAproxyやOpenSSLのマニュアルには以下のような当該オプションの記載があります。

http://cbonte.github.io/haproxy-dconv/1.9/configuration.html#5.1-prefer-client-ciphers

~~~
prefer-client-ciphers

Use the client's preference when selecting the cipher suite, by default
the server's preference is enforced. This option is also available on
global statement "ssl-default-bind-options".
Note that with OpenSSL >= 1.1.1 ChaCha20-Poly1305 is reprioritized anyway
(without setting this option), if a ChaCha20-Poly1305 cipher is at the top of
the client cipher list.
~~~

https://www.openssl.org/docs/man1.1.1/man3/SSL_CTX_clear_options.html

~~~
SSL_OP_PRIORITIZE_CHACHA

    When SSL_OP_CIPHER_SERVER_PREFERENCE is set, temporarily reprioritize
    ChaCha20-Poly1305 ciphers to the top of the server cipher list if a
    ChaCha20-Poly1305 cipher is at the top of the client cipher list.
    This helps those clients (e.g. mobile) use ChaCha20-Poly1305 if that
    cipher is anywhere in the server cipher list; but still allows other
    clients to use AES and other ciphers.
    Requires SSL_OP_CIPHER_SERVER_PREFERENCE.
~~~

### TLSセッションリザンプションへの対応について

クライアントがサーバに接続する際、TLSのハンドシェイクプロセスにおいて、実際の暗号通信につかう暗号化キーを鍵交換方式(ECDHEなど)により決定(合意)します。この鍵交換を伴うやりとりはフルハンドシェイクと呼ばれ非常に処理が重いのです。そこで、暗号化キー(の元となるマスターシークレット)をクライアント、サーバ両サイドでキャッシュし、2回目以降の接続時に使い回せる仕組みが2つあります。

1つは最初からTLSの仕様で定められているセッションID方式です。1回目の接続時にサーバ側が発行したセッションIDを、クライアントが2回目以降の接続時に提示し、セッションIDに紐づくキャッシュされたマスターシークレットを使う回すことで鍵交換プロセスをスキップします。

しかし、エンハンスドLBのような多数のサーバでTLSの終端を行う場合、残念ながらこの方式には弱点があります。全てのサーバ間でセッションIDとキャッシュするマスターシークレットを共有する必要があるのです。1回目と2回目の接続で着信するサーバが異なる可能性があるからですが、これを実装するのは結構複雑です。

このような分散環境を想定し、サーバサイドをステートレスにできる方式として、**TLSセッションチケット方式** がRFC5077にて定義されています。これは、サーバがマスターシークレット等を暗号化した **チケット** をクライアントに発行します。クライアントはこのチケットを2回目以降の接続時にサーバに提示します。サーバはそれを復号しマスターシークレットを取り出し、鍵交換を経ることなく暗号通信を開始できます。この手法からわかるように、複数のサーバ間ではチケット生成/復号のための暗号化キーを揃えておく必要があります。

エンハンスドLBでは、スケーラビリティを確保するためにこのTLSセッションチケット方式を採用することにしました。

ただこの方式では、暗号化しているとはいえマスターシークレットの情報がネットワーク上を流れるんですね😇 チケットを窃用して、即攻撃に使用できるものではないのでですがPFSは崩れます😇

そこでエンハンスドLBでは、なるべくPFSを崩さないように以下の点にケアしています。

- チケットの有効期限を適切に設定する
 - エンハンスドLBでは2時間にしています。
- チケット生成のための暗号化キー(以下tls-ticket-keys)を適切に設定する
 - エンハンスドLBでは暗号論的安全な疑似乱数列を生成するようにしています。
- tls-ticket-keysを定期的に変更する
 - エンハンスドLBでは24時間毎に変更しています。
- 過去設定されたtls-ticket-keysを推測不可能にする

実際のtls-ticket-keysの設定フローは以下のように行っています。

![tls-ticket-keys2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108990/b6c25603-7eaa-1ee3-e4ae-16cdcc68abe8.png)

APIサーバにて24時間毎に変化するシード(乱数)を生成し、MQを通じてHAproxyサーバ群に配信します。HAproxyサーバ内では受け取ったシードを元に、DRBG-HMACを用いてお客様(リソースID)ごとに異なるtls-ticket-keysを生成します。

ちなみに、HAproxyではチケット作成時の暗号化にAES256を用いる場合、80バイトの乱数列を3行設定する必要があるので、お客様毎にDRBG-HMACにて240バイトの乱数列を生成しています。

# Let's Encryptへの対応について

TLSに関するネタとしてSSL証明書にも触れておこうと思います。エンハンスドLBでは、Let's Encryptを用いたSSL証明書の自動発行と、その後の自動更新機能を設けています。以下コンパネの画面イメージとなります。エンハンスドLBで運用しているFQDNを入力するだけで設定完了です！

![letsencrypt.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108990/aad2b0ff-9cea-35cc-723c-211c1eb9ebb2.png)

Let's Encryptでは証明書発行の際、ドメインの所有者チェックがACMEチャレンジによって行われます。エンハンスドLBでは、ACME HTTP-01チャレンジに対応する実装を行っています。

これはどういうプロセスか簡単に説明すると、

- Let's Encryptに対してSSL証明書発行リクエストを送信
- 認証用のトークンが発行されるので、手元のWebサーバに特定パスでトークンをレスポンスするように設定
- Let's Encryptが証明書発行対象のドメイン名に対して特定のパスでHTTPリクエストを送信
- ちゃんとトークンがレスポンスされれば、そのドメインの所有者は正しいと認証され、実際に証明書が発行される

というフローになっています。以下のようなイメージです。

![letsencrypt2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108990/52b532bd-d82e-4a98-cb73-9b2b4742619e.png)

エンハンスドLBは、この一連の証明書発行プロセスの中でトークンのレスポンスも行ないます。が、HAproxyは静的コンテンツの配信機能がありません。HAproxyはプロキシ処理しかできないんですよね😇

トークンをレスポンスするためだけに、別途Webサーバを立てる必要があるのかとも考えました。が、いろいろ調べていく中で、503レスポンスを書き換えて静的コンテンツのレスポンスを行うハックがあることがわかりました。

下記のようなレスポンス内容を記載したファイル(acme.http)を用意しておき、

~~~
HTTP/1.1 200 OK
Cache-Control: no-cache
Connection: close
Content-Type: text/plain

hmGvhxlTb6hO4pJngaQM...(トークンの文字列)
~~~

HAproxyの設定に以下のように記載します。

~~~
frontend 192.0.2.1:80
    mode http
    log global
    option httplog

    bind 192.0.2.1:80
    default_backend backend-default
    acl acme-challenge-acl path /.well-known/acme-challenge/hmGvhxlTb6hO4pJngaQM...
    use_backend acme-challenge if acme-challenge-acl

backend acme-challenge
    mode http
    errorfile 503 acme.http
~~~

最後の行で、本来は実サーバの存在しないbackendで503応答を行うところを、無理やり200応答に書き換え、かつトークンをレスポンスボディに含めることが可能です。これで別途Webサーバを立てる必要がなくなったので、プロビジョニングが幾分シンプルになりした :-)

# 終わりに

もっと書きたいネタはあるのですが、前編、後編の2回に渡ってさくらのクラウド「エンハンスドロードバランサ」の実装について紹介してきました。アドベントカレンダーとしては一旦ここまでとしたいと思いますが、また機会があれば続編の執筆を検討したいと思います😇

本サービスがお客様のシステム構築のお役にたてると幸いです。また、機能追加などの要望がありましたら以下サイトから投稿できるようになっています。是非ご意見、ご要望お待ちしております。

さくらのユーザーフィードバックβ
https://sakura.uservoice.com/