CDNの挙動を検証する取組み

https://gdstechnology.blog.gov.uk/2014/10/01/cdn-acceptance-testing/

1 comment | 2 points | by WazanovaNews 2年以上前 edited


Jshiike 2年以上前 edited | ▲upvoteする | link

Code for Americaなど、ネットの力で政府機関を改善していく取組みがここ数年増えていますが、中でもGOV.UKは、英国政府のwebサービスのあるべき仕様をサービスデザインマニュアルに詳細にまとめ、政府関連のウェブサイトを取りまとめています。その活動は、3分ほどのビデオにまとめらてますが、技術関連の情報発信にも力を入れてきています。

さて今回のGOV.UKのエンジニアブログは、受け入れテストを充実させてCDNの挙動を検証した取組み。実装の詳細から、バグのケースまで網羅されていて、かなり詳しく紹介されています。合わせて、Go言語の net/httptesting のパッケージをベースに作成したコードも、Githubで公開されています。

1) 背景

  • CDNの追加に際し、従前のものと同じ挙動を期待できるかテストケースを整備する必要がでてきた。
  • サービスデザインマニュアルにおいて、インフラの設定はコードに落とし込むことでテスト性/再利用性をあげるという “infrastructure as code” の方針に従ってプロジェクトを進行。
  • CDN設定のバージョン管理とワンクリックでのデプロイは既に実現済。
  • 新機能のテストの自動化と既存機能に影響がでないかを確認できる仕組みが必要。主な要件としては、
    • 1. CDNに対してHTTPリクエストを送る。
    • 2. リクエストがCDNからコントロール下のサーバに転送されるのを確認。
    • 3. 条件に合わせたHTTPレスポンスの作成。
    • 4. CDNからクライアントへのレスポンスの確認。
    • 5. 上記をシンプルなテストランナーでラップし、テストをひたすら繰り返し、確認結果をレポートする。

2) 実装詳細

以下の記述において、エッジはCDN側のサーバ、オリジンはGOV.UK側のサーバを示す。

CDNをモックする

  • 同一のサーバから複数名が同時にCDNサービスに対してテストするやり方では、それがすぐにボトルネックになるので、モックすることで、オフラインで独立してテストスイートを実行できるようにする。
  • CDNは実のところ外部の分散HTTPキャッシュなので、Varnishでほとんどは事が足る。TLSサポートについては、Nginxとstunnelで対応。
  • VagrantのVM(該当コード)で、Puppetモジュール(該当コード)を使って、一連のコンポーネントがCDNのごとく振る舞うように設定。(構成図
  • 新規テストについても、CIテストを実行する際に同じメカニズムを再利用できる。

バックエンドのリクエストとレスポンスの対応

  • テストケースにおいて、オリジンサーバの挙動を適宜変更する必要がでてきた。例えば、
    • CDNから受取るリクエストのコンテンツについてアサーションを作成する。
    • 特定のステータスコード、ヘッダー、ボディにレスをする。
    • いつリクエストを受取るべきではないか(例えば、キャッシュ時)についてのアサーションを作成する。
  • ローカルポートに対するバックエンドバウンドを表すCDNBackendServer Struct(該当コード)を作成。複数のインスタンスが異なるポートにバウンドすることが可能。goroutinesを利用したテストにおいて並列で実行され、各リクエストで呼び出されるCDNBackendServer.ServerHTTP()を実装することで、http.Hnadlerインターフェースに対応することができる。
  • オリジンのヘルスチェックをするためにCDNプロバイダが利用するHEAD /に対するリクエストは、常に 200レスポンスを供する。他のリクエストは、ハンドラーファンクションを介して渡されるが、ハンドラーファンクションは、テストにおいて、CDNBackendServer.SwitchHandler()ファンクションリタラルを渡すことで代替できる。
  • 「ファンクションリタラルは、クロージャーである。周りのファンクションで定義された変数を参照することができる。変数は周りのファンクションとファンクションリタラルで共有され、アクセス可能な限りは有効でありつづける。」というGo言語の仕様を活用することもある。HTTPクライアントリクエストは、成功した場合はハンドラーが実行されるまでは、何も返してこない同期オペレーションであるという事実と組み合わせた。これにより、テストの最初で変数を宣言でき、リクエストを受取る際、例えば、リクエストヘッダーの値を記録(該当コード)したり、受取ったリクエストのカウントを増やし(該当コード)たりするのに値をアサインするということ。

バックエンドを止める

  • CDNのエラー対応やフェールオーバーをテストするために、リクエストの接続を受け入れないようにバックエンドを止める(アサインされたポートからバインドを外す。)必要があった。
  • http.listenAndServeはバックエンドを止める機能は何も提供してくれないので、GoでHTTPサーバを起動する最もシンプルで一般的な方法を利用してこれは実現できない。幸い、Goの標準ライブラリには、自前でつくれるようにコンポーネントがまとまっている。標準ライブラリのソースはかなり読みやすいし、オンラインドキュメントとうまくリンクしているので、どのように構成されているかも理解しやすい。
  • http.Serverをつくり、http.Server.ListenAndServe()を呼び出すhttp.ListenAndServeを再現するところから手をつけた。http.Server.ListenAndServe()は、その元でnet.Listner(これを閉じたい。)をつくり、http.Server.Serve()に渡す。しかし、リスナーを閉じることは、期待した効果をあげられなかった。理由は、既存のコネクション、特にHTTP keepaliveを利用したものが、アクティブのままだったからである。
  • Goの標準ライブラリから見つけた、httptest.Serverは、手頃なラッパー。クライアントのコネクションをトラックし、全ての残りのコネクションが閉じるまで待ってくれるhttp.Server.Close()メソッドを提供してくれる。これをうまく利用するために、自前のリスナーを用意し、Close()メソッドをラップした。

TLSバックエンド

  • HTTPS (HTTP + TLS) 環境の用意には、httptest.Serverが役に立った。自前の証明書を用意してくれ、Start()のコールをStartTLS()とスワップする(該当コード)ことができる。後日TLSを必須化し、コマンドラインから証明書を上書きできるように(該当コード)した。

バックエンドのヘルスチェック

  • CDNプロバイダが問題ないと判断できる量のレスポンスをバックエンドがしてないため、ヘルスチェックのテストに落ちるケースが頻発。
  • エッジから直接ヘルスチェックの情報をクエリすることはできない。できたとしても、全てのCDNプロバイダから同じ情報を入手できる見込みはない。その替わり、エッジはヘルシーだと思うバックエンドにしかクライアントのリクエストを転送しないことを利用したソリューションを用意した。
  • waitForBackendがエッジに対してリクエストをし、バックエンドが正しい識別子を返すことをチェックする。一定量の正しいレスポンスを受取れば、問題ないと判断できる。
  • 優先順位のある複数のバックエンドをエッジで利用するケースは少し複雑になる。1台目が起動し、全てのリクエストを受けてしまうと、2台目、3台目の状況が確認できない。ResetBackendsは、逆順に起動することで、この問題を解決してくれる。テストスイートの起動時とテスト開始前に呼び出し、期待とおり稼働していないバックエンドがあれば、優先順位の低いものを一旦全て停止し、順に起動させる。

DNSキャッシング

  • キャッシュを満たしてから、アイテムがまだあるかどうかチェックなど、複数のリクエストをした際に、断続的にテストが落ちるケースが起きた。
  • 多くのCDNプロバイダが利用しているGlobal Server Load Balancer (GSLB)は、ユーザを近くのエッジサーバに誘導するのにDNSを利用している。どこが、最も近く、早く対応でき、かつ現在利用可能な場所かという情報は頻繁に変更されるので、DNSのレスポンスはTTL (time-to-live) が短い。
  • テストの途中で場所が変更しないように、DNSルックアップを自らキャッシュする必要がある。Goだと、ブロックをつくるように対応できる。net.Dialファンクションリタラル(ネットワークコネクションを担う。)をhttp.Transport(HTTPトランザクションを担う)に渡すかたちにする。CacheHostLookupはDNSルックアップをショートサーキットし、ストックのnet.Dialファンクションに渡す。

リクエストヘルパー

  • ヘルパーファンクションとして共有できるよにしたのは、
    • NewUniqueEdgeURL: 全てのテストがキャッシュされていないリソースで開始できるように、エッジのホスト名とランダムなUDIDを含んだクエリパラメータを使って、新しいURLを生成する。
    • RoundTripCheckError: リクエストを実行し、エラーチェックをする。完了するはずの閾値を超えて時間がかかれば、テストエラーとし、トランザクションにエラーがあれば呼び出しているテストを中断する。
    • testThreeRequestsNotCached: リクエストを繰り返し、ユニークなレスポンスを確認する。オプションとして、レスポンスヘッダーも同時に修正する。このおかげで、TestNoCache*のコードをかなり減らせた。
  • 反省点としては、当初はヘルパーを早めに準備しようとしたこと。重複をなくすパターンをうまく抽出できず、個別のテストでうまくいってるパターンにも邪魔になった。やはりヘルパーは、ユースケース全体を把握した後に書くべきであった。

3) バグの発見

  • テストケースの整備により、CDNプロバイダも認識していなかったようなバグも見つけることができた。
  • HTTP/1.1の正しい実装を確認するのであれば、かなり読みやすく、詳細に書かれているRFC723Xを参考にするとよい。

ケースセンシティビティ

  • なぜか当初、キャッシュリクエストパスをケースインセンシティブに設定していた。オリジンはwebの標準であるケースセンシティブにしていたのでミスマッチが起きた。
  • https://www.gov.uk/modのように、GOV.UKサイトは組織の略称にあわせてトップレベルでいくつかリダイレクトしている。MoDのような大文字小文字パターンはオリジンではリダイレクトされてなかったが、/modのキャッシュされたオブジェクトに該当するのでほとんどのケースは問題なく機能していた。しかし、キャッシュオブジェクトの期限が切れて、/MoDへのリクエストと一致してしまうと、オリジンはそれを認識できないので、404がキャッシュされ、/modへの「正しい」リクエストとして供される。この障害は起きる頻度が低いので、デバックが大変であった。
  • /mod /MoD /MODなどの略称の違いを正確に認識し、ケースインセンシティブのオプションを外すことで、エッジとオリジンの挙動が同じになるように修正した。また、どのCDNプロバイダでも同じ単語の大文字小文字違いが正確に判別されることをチェックするため、テストケースにTestCacheUniqueCaseSensitiveを追加した。

プロトコルのリダイレクト

  • GOV.UKはHTTPSだけを利用している。オリジンをヒットすることなくCDN側で、http://www.gov.ukのユーザをhttps://www.gov.ukにリダイレクトしている。再訪ユーザについては、Strict Transport Securityが適用される。
  • このリダイレクトの仕組みはクエリパラメータを保存してなかったので、対応を依頼されたことがあった。オリジンの検索ワードが外され、http://www.gov.uk/search?fishhttps://www.gov.uk/searchにリダイレクトされていた。単にCDNの設定ミスであり、修正はしたものの、別のCDNプロバイダを追加する際に再発するかたちになっていた。
  • 再発しないように、TestMiscProtocolRediretを用意した。更に、リダイレクトの際にURLのフラグメント識別子が保存されるべきかどうかの確認をしてみると、RFC7231で定められているのは、locationはフラグメントを含める必要はなく、もし取り除かれた際には、クライアント側が再度付加する必要がある。

PURGEリクエストを拒否する

  • エッジからキャッシュコンテンツを無効にし、エッジにPURGEリクエストを発行することで、オリジンからの新しいレスポンスを強制できることがわかっていた。
  • これは許容したくなかったので、ホワイトリストのIPアドレス以外からのリクエストは403を返す仕組みとした。実装はややトリッキーで、クライアントに正しいレスポンスを返すために、リクエストメソッドの内部での表現をPURGEからGETに変更。
  • ホワイトリスト外からのIPからリクエストを発行し、403が返ってくるアサーションをしたテストを用意。リクエストがオリジンで効かないように確認するアサーションも追加できるようになった。しかし、テストは落ちた。
  • 前回のバグ修正後に挙動が変更してしまっていた。結局リクエストメソッドの修正は必要ないことがわかり、そうすることで、エッジはオリジンへの通常のGETリクエストだと認識してしまっていた。再度修正し、キャッシュを満たして、オブジェクトが無効になってないことを確認するかたちにテストを改善した。TestMiscRestricPurgeRequestsで完成したテストが確認できる。

ヘッダーの期限切れ

  • Expiresヘッダーは、レスポンスのTTLの期限切れを日付と時間で指定するする方法の一つである。正確なマシンの時計に依存することができ、Cache-Controlで指定すると無効にできるが、それがなければ有効なディレクティブとなる。
  • TestCacheExpiresを用意した結果、この仕様をサポートしていないCDNプロバイダが存在することがわかった。ディレクティブは無視され、デフォルトのTTLが新しいCache-Controlヘッダーとともに適用される。

Cache-controlを使ってキャッシュをしないようにする

  • Cache-Controlヘッダーには、リクエストやレスポンスがどのようにキャッシュされるかを指定できるディレクティブがある。キャッシュされたTTLが有効かどうか確認できるTestCacheCacheControlMaxAgeに加えて、TestNoCacheHeaderCacheControl*において、privateno-storeno-cachemax-age=0を利用することで、エッジにキャッシュさせないようにもできる。
  • 挙動はCDNプロバイダによってかなりマチマチなので、実はいくつか設定変更するだけで期待通りにできるものの、なぜまとめて全部指定されているケースが多いかという理由がわかるかと。

X-Forwarded-For

  • X-Forwarded-Forリクエストヘッダーは、公式なスペックではないが、オリジナルのクライアントとプロキシのIPアドレス伝えるのによく使われている。認証の目的に使われるべきではないが、情報としては役に立つ。TestReqHeaderXFFCreateAndAppendを用意したことで、二つの問題を発見した。
  • 同じIPアドレスが二度表示される問題。こちらのCDN設定でのコピペミスが原因であった。
  • また、stringのホワイトスペースが一致していなかった。どう表示するのが正しいかについては、RFC7239でにおいてForwardedヘッダーが定義されており、RFC7230では、リストアイテムはカンマとオプションでホワイトスペースで区別されるとされている。stringの比較を個別のリストアイテムのチェックに替えることで修正した

VaryとAccept-Encoding

  • Varyレスポンスヘッダーは、全ての適用されるヘッダーがオリジナルのリクエストとマッチすれば、キャッシュのレスポンスは別のリクエストを満たすためだけに使われるべきだと指定することに使える。一般的なユースケースは、Vary: Accept-Encodingで、gzipで圧縮されたレスポンスが、Accept-Encoding: gzipとともにリクエストされていないクライアントに対して送られないように指定することができる。
  • TestCacheVaryを用意することで、この仕様をサポートしていないCDNプロバイダがあることがわかった。gzip圧縮が期待とおりワークするかどうかは、TestCacheAcceptEncodingGzipで確認できたが、必ずしもレスポンスにおいてVaryに依存していないようであった。
  • スペックでもVary: *はけっしてキャッシュされるべきではないと書いてあるが、TestNoCacheHeaderVaryAsteriskのおかげで、実はほとんど実装されていないことがわかったので、今のところテストでは無効にしている。

認証とクッキー

  • HTTPキャッシュはしばしばプライバシーの懸念を生じる。個人アカウントの詳細を他のユーザに送るようなレスポンスはしたくないはず。よって、適切なCache-Controlヘッダーを発行するべきである。
  • Authorizationリクエストヘッダーは、認証のクレデンシャルを提供するのに使われる。成功したレスポンスを盲目的にキャッシュすると他のクライアントに認証をバイパスされる。RFC7234には、そのようなレスポンスは明示的にオプトインのうえキャッシュすべしと記載されている。TestCacheHeaderAuthorizationを利用してわかったのは、理由はわからないとのことだが、ほとんどのCDNプロバイダはデフォルトのTTLで保存している。
  • Set-Cookieレスポンスヘッダーは、クライアントが従前にセットしたクッキーをサーバに提供するのに使われる。クライアントサイドのスクリプトで対処していると、サーバサイドのアプリはクッキーをセットしたり、読んだりしてないかもしれない。しかし、いずれにしてもリクエストヘッダーは提供される。キャッシュしてないのであれば、CDNを使うメリットはあまりない。TestCacheHeaderCookieで確認できる。
  • どのケースでも、モックをCDNプロバイダに合わせるために、Varnishのデフォルトの挙動を変更しなくてはいけない。ラウンドトリップタイムのほうがかなり大きいので、ヒット/ミス率を改善するニーズなど、エッジのキャッシングと比較してローカライズされた(オリジンで、もしくはオリジン内で)キャッシュの要件は様々だということに起因すると考えられる。

Ageヘッダー

  • Ageヘッダーは、オリジンがレスポンスを生成、もしくは再バリデートしてからどれくらい経過したかを示している。レスポンスに対してキャッシュがどれくらい長くあるか、Cache-Controlディレクティブとの関連でいつ無効になるか、ということでデバッグに役立つ。この値を計算するメカニズムは、RFC7234に示されている。オリジンから提供された初期値が適用され、そこから増えていく。
  • TestRespHeaderAgeによって、これをサポートしていないCDNプロバイダを見つけることができた。レスポンスにヘッダーがセットされず、修正(加算)もされない。本件は、先方で修正予定。

古い情報への対処

  • 障害復旧のソリューションとして別のロケーションにある静的なミラーにフォールバックする対応の一貫として、利用可能であれば、通常のTTLを超えた古いレスポンスを供する試みをしている。静的なミラーは24時間ごとに更新され、検索等の動的なコンテンツは含まない。よって、静的なミラーには情報の古いレスポンスが適切である可能性が高い。
  • プロバイダごとに実装はかなり異なる。過去にはvarnishtestも書いてみたが、テストでは多いにトラブった。全てのテストが、ストップ / スタート / 適切なレスポンスを返すようにwebサーバを再設定、という手動作業でコーディネートしなくてはいけなかった。
  • TestFailover*で、プロバイダの仕様に関係なく、初めて現実のシナリオでワークさせることができた。結果は、期待とおりではなかったが、まったく予想外というわけでもなかった。ヘルスチェックの期限に依存しており、オリジンに対してのリクエストがリトライされる頻度が多い。実装を改善する途中であるが、テストは許容できるレベルになってきた。

#golang #テスト #cdn #キャッシュ #gov.uk


ワザノバTop200アクセスランキング


Back