キャッシュにおける歓喜と苦痛

Amazon で長年にわたってサービスを構築してきた中で、新しいサービスを構築するけれども、このサービスはそのリクエストを満たすためにいくつかのネットワーク呼び出しを行う必要があるというシナリオのさまざまなバージョンを経験してきました。おそらく、この呼び出しは、リレーショナルデータベース、Amazon DynamoDB などの AWS のサービス、または別の内部サービスに対するものです。単純なテストまたは低リクエストレートでは、サービスはうまく機能しますが、問題もあることにも気付きました。問題は、この他のサービスへの呼び出しが遅いこと、または呼び出し量が増えるとデータベースのスケールアウトに費用がかかることです。また、多くのリクエストが同じダウンストリームリソースまたは同じクエリ結果を使用していることに気づいたため、このデータをキャッシュすることが問題の解決策になると考えています。キャッシュを追加すると、サービスが大幅に改善されたように見えます。リクエストのレイテンシーが短縮し、コストが削減され、ダウンストリームの可用性がわずかに低下していたのがスムーズになりました。しばらくすると、誰もキャッシュ以前はどうだったかを思い出せなくなるでしょう。依存関係により、それに応じてフリートのサイズが縮小され、データベースが縮小されます。すべてが順調に進んでいるように見えるとき、サービスは災害に見舞われる前夜である可能性もあります。トラフィックパターンの変化、キャッシュフリートの障害などの予期しない状況により、コールドキャッシュまたはその他の方法でキャッシュが使えなくなる可能性があります。これにより、ダウンストリームサービスへのトラフィックが急増し、依存関係とサービスの両方が停止する可能性があります。

キャッシュに依存するようになったサービスについて説明しました。キャッシュの地位は、サービスを有用に高めるものから、操作に必要かつ不可欠なものに意図せずに引き上げられました。この問題の中心にあるのは、キャッシュによって導入されるモーダル動作であり、特定のオブジェクトがキャッシュされるかどうかによって動作が異なります。このモーダル動作の分布の予期しない変化は、潜在的に災害につながる可能性があります。

Amazon でサービスを構築および運用する過程で、キャッシングの利点と課題の両方を経験しました。この記事の残りの部分では、学んだ教訓、ベストプラクティス、およびキャッシュの使用に関する考慮事項について説明します。

キャッシングを使用する場合

いくつかの要因により、システムにキャッシュを追加することを検討します。多くの場合、これは、特定のリクエストレートでの依存関係のレイテンシーまたは効率を観察するところから始まります。たとえば、依存関係が調整を開始したり、予想される負荷に対応できなくなったりする可能性があると判断した場合です。ホットキー/ホットパーティションの調整につながる不均一なリクエストパターンが発生した場合、キャッシュを検討することが役立つことがわかりました。この依存関係のデータは、そのようなキャッシュがリクエスト全体で良好な キャッシュヒット率がある場合に、キャッシュの適切な候補になります。つまり、依存関係の呼び出しの結果は、複数のリクエストまたは操作にわたって使用できます。通常、各リクエストがリクエストごとに一意の結果を持つ依存サービスへの一意のクエリを必要とする場合、キャッシュのヒット率は無視でき、キャッシュは効果がありません。2 番目の考慮事項は、チームのサービスとそのクライアントが 結果整合性 に対してどの程度寛容であるかです。キャッシュされたデータは、時間の経過とともに必然的にソースと矛盾してくるため、サービスとそのクライアントの両方がそれに応じて補正する場合にのみキャッシュが成功します。ソースデータの変更率、およびデータを更新するためのキャッシュポリシーにより、データの一貫性の程度が決まります。これら 2 つは互いに関連しています。たとえば、比較的静的なデータまたは変化の遅いデータはより長い期間キャッシュすることができます。

ローカルキャッシュ

サービスキャッシュは、メモリ内またはサービスの外部に実装される可能性があります。一般的にプロセスメモリに実装されるオンボックスキャッシュは、比較的迅速かつ簡単に実装でき、最小限の作業で大幅な改善を実現できます。多くの場合、オンボックスキャッシュは、キャッシングの必要性が特定されたときに実装および評価される最初のアプローチです。外部キャッシュとは対照的に、オンボックスキャッシュは追加の運用オーバーヘッドがないため、既存のサービスに統合するリスクがかなり低くなっています。多くの場合、オンボックスキャッシュは、アプリケーションロジックを介して管理されるか (たとえば、サービスコールの完了後に結果をキャッシュに明示的に配置して)、またはサービスクライアントに埋め込まれる (たとえば、キャッシング HTTP クライアントを使用して) インメモリハッシュテーブルとして実装されます。

インメモリキャッシュの利点と魅惑的なシンプルさにもかかわらず、いくつかの欠点があります。1 つは、キャッシュされたデータがフリート全体でサーバー間で一貫性がなく、キャッシュの一貫性 の問題が発生することです。クライアントがサービスを繰り返し呼び出すと、どのサーバーがリクエストを処理するかに応じて、最初の呼び出しで使用される新しいデータと 2 番目の呼び出しで古いデータを取得する場合があります。

もう 1 つの欠点は、ダウンストリームの負荷がサービスのフリートサイズに比例するため、サーバーの数が増えても、依存するサービスを圧倒する可能性があることです。これをモニタリングする効果的な方法は、キャッシュのヒット/ミスとダウンストリームサービスに対して行われたリクエストの数に関するメトリクスを出力することです。

また、メモリ内キャッシュは「コールドスタート」問題の影響を受けやすくなっています。この問題は、新しいサーバーが完全に空のキャッシュで起動する場合に発生します。これにより、キャッシュがいっぱいになると依存サービスへのリクエストが急増する可能性があります。これは、デプロイ中、またはキャッシュがフリート全体でフラッシュされる他の状況で重大な問題になる可能性があります。キャッシュの一貫性と空のキャッシュの問題は、多くの場合、リクエストの合体を使用して対処できます。これについては、この記事の後半で詳しく説明します。

外部キャッシュ

外部キャッシュは、今説明した多くの問題に対処できます。外部キャッシュ は、Memcached や Redis などを使用して、キャッシュされたデータを別のフリートに保存します外部キャッシュがフリート内のすべてのサーバーで使用される値を保持するため、キャッシュの一貫性の問題が軽減されます。(キャッシュの更新時にエラーが発生する可能性があるため、これらの問題は完全に排除されないことに注意してください)。 ダウンストリームサービスの全体的な負荷は、メモリ内キャッシュと比較して削減され、フリートサイズに比例しません。デプロイ中は外部キャッシュが入力されるため、デプロイなどのイベント中のコールドスタートの問題は発生しません。最後に、外部キャッシュはメモリ内キャッシュよりも多くの使用可能なストレージスペースを提供し、スペースの制約によるキャッシュの削除の発生を減らします。

ただし、外部キャッシュには独自の欠点があり、考慮する必要があります。1 つは、システム全体の複雑さと運用負荷の増加です。これは、モニタリング、管理、およびスケーリング対象のフリートが増えるためです。キャッシュフリートの可用性特性は、キャッシュとして機能する依存サービスとは異なります。キャッシュフリートは、ゼロダウンタイムアップグレードをサポートしていない場合や、メンテナンスウィンドウが必要な場合など、利用できないことがよくあります。

外部キャッシュによりサービスの可用性が低下するのを防ぐために、キャッシュフリートの使用不可、キャッシュノードの障害、またはキャッシュの書き込み/取得の障害に対処するサービスコードを追加する必要があることがわかりました。1 つの選択肢は、依存サービスの呼び出しにフォールバックすることですが、このアプローチをとるときは注意する必要があることを学びました。キャッシュが長時間停止すると、ダウンストリームサービスへのトラフィックが異常に急増し、その依存サービスの調整または電圧低下が発生し、最終的に可用性が低下します。外部キャッシュが利用できなくなった場合にフォールバックできるメモリ内キャッシュと一緒に外部キャッシュを使用するか、負荷制限を使用してダウンストリームサービスに送信されたリクエストの最大の割合を制限します。キャッシングを無効にしてサービスの動作をテストし、依存関係の電圧低下を防ぐために実施したセーフガードが期待どおりに実際に機能していることを検証します。

2 番目の考慮事項は、キャッシュフリートのスケーリングと伸縮性です。キャッシュフリートがリクエストレートまたはメモリ制限に達すると、ノードを追加する必要があります。どの指標がこれらの制限の先行指標であるかを判断し、それに応じてモニターとアラームを設定できます。たとえば、最近取り組んだサービスでは、Redis リクエストレートが制限に達すると、CPU 使用率が非常に高くなることがわかりました。現実的なトラフィックパターンによる負荷テストを使用して、制限を判断し、適切なアラームしきい値を見つけました。

キャッシュフリートに容量を追加するときは、停止やキャッシュデータの大量損失を引き起こさないように注意します。さまざまなキャッシングテクノロジーには固有の考慮事項があります。たとえば、一部のキャッシュサーバーは、ダウンタイムなしでクラスターにノードを追加することをサポートしておらず、またキャッシュフリートにノードを追加し、キャッシュされたデータを再配信するために必要なすべてのキャッシュクライアントライブラリが一貫したハッシュを提供しているわけではありません。クライアントによる一貫性のあるハッシュの実装とキャッシュフリート内のノードの発見にはばらつきがあるため、本番環境に進む前にキャッシュサーバーの追加と削除を徹底的にテストします。

外部キャッシュでは、ストレージ形式の変更に伴う堅牢性を確保するために特に注意を払っています。キャッシュされたデータは、永続ストアにあるかのように扱われます。更新されたソフトウェアが、以前のバージョンのソフトウェアが書き込んだデータを常に読み取れるようにし、古いバージョンが新しい形式/フィールドの表示を適切に処理できるようにします (たとえば、フリートに古いコードと新しいコードが混在しているデプロイ中に)。予期しない形式に遭遇したときにキャッチされない例外を防ぐことは、毒薬を防ぐために必要です。ただし、これはすべての形式関連の問題を防ぐのに十分ではありません。バージョン形式の不一致を検出し、キャッシュされたデータを破棄すると、キャッシュが大量にリフレッシュされる可能性があり、依存するサービスの調整または電圧低下につながる可能性があります。シリアル化の問題は、さらに詳しくデプロイ時におけるロールバックの安全性の確保の記事で取り上げます。

外部キャッシュの最後の考慮事項は、サービスフリート内の個々のノードによって更新されることです。キャッシュには通常、条件付き書き込みやトランザクションなどの機能がないため、キャッシュ更新コードが正しく、キャッシュが無効または一貫性のない状態にならないように注意します。

インラインキャッシュとサイドキャッシュ

さまざまなキャッシュアプローチを評価するときに行う必要があるもう 1 つの決定は、インラインキャッシュとサイドキャッシュの間の選択です。インラインキャッシュ、またはリードスルー/ライトスルーキャッシュは、キャッシュ管理をメインデータアクセス API に埋め込み、キャッシュ管理をその API の実装の詳細にします。たとえば、Amazon DynamoDB Accelerator (DAX) などのアプリケーション固有の実装や、HTTP キャッシングなどの標準ベースの実装 (ローカルキャッシングクライアントまたは Nginx や Varnish などの外部キャッシュサーバーで) などの例があります。これに対して、サイドキャッシュは、Amazon ElastiCache (Memcached と Redis) が提供するものや、インメモリキャッシュ用の Ehcache や Google Guava などのライブラリといった汎用オブジェクトストアです。サイドキャッシュでは、アプリケーションコードはデータソースの呼び出しの前後にキャッシュを直接操作し、ダウンストリーム呼び出しを行う前にキャッシュされたオブジェクトをチェックし、それらの呼び出しが完了した後にオブジェクトをキャッシュに入れます。

インラインキャッシュの主な利点は、クライアント向けの統一された API モデルです。クライアントロジックを変更せずに、キャッシュを追加、削除、または調整できます。また、インラインキャッシュは、アプリケーションコードからキャッシュ管理ロジックを引き出し、潜在的なバグの原因を排除します。HTTP キャッシュは、インメモリライブラリ、前述のようなスタンドアロン HTTP プロキシ、コンテンツ配信ネットワーク (CDN) などのマネージドサービスといった多数の既製のオプションが利用できるため、特に魅力的です。

ただし、インラインキャッシュの透過性も可用性の低下につながる可能性があります。外部キャッシュは、この依存関係の可用性方程式の一部になりました。クライアントが一時的に利用できないキャッシュを補償する機会はありません。たとえば、外部 REST サービスからのリクエストをキャッシュする Varnish フリートがある場合、そのキャッシュフリートがダウンすると、サービスの観点からは、依存関係自体がダウンしたかのようになります。インラインキャッシュのもう 1 つの欠点は、キャッシュするプロトコルまたはサービスに組み込む必要があることです。プロトコルのインラインキャッシュが利用できない場合、統合クライアントまたはプロキシサービスを自分で構築する場合を除き、このインラインキャッシュはオプションではありません。

キャッシュの有効期限

最も困難なキャッシュ実装の詳細には、適切なキャッシュサイズ、有効期限ポリシー、および削除ポリシーの選択があります。有効期限ポリシーは、キャッシュにアイテムを保持する期間を決定します。最も一般的なポリシーは、絶対時間ベースの有効期限を使用します (つまり、ロードされる各オブジェクトに有効期限 (TTL) を関連付けます)。TTL は、緩やかに変化するデータはより積極的にキャッシュできるため、古いデータに対するクライアントの耐性やデータの静的性など、クライアントの要件に基づいて選択されます。理想的なキャッシュサイズは、予測されるリクエストの量のモデルと、リクエストにかけてキャッシュされたオブジェクトの分布に基づきます。そのことから、これらのトラフィックパターンで高いキャッシュヒット率を保証するキャッシュサイズを推定します。削除ポリシーは、キャパシティに達するとキャッシュからアイテムを削除する方法を制御します。最も一般的な削除ポリシーは、Least Recently Used (LRU) です。

これまでのところ、これは単なる思考運動です。実際のトラフィックパターンは、モデルとは異なる可能性があるため、キャッシュの実際のパフォーマンスを追跡します。これを行うための好ましい方法は、キャッシュのヒットとミス、合計キャッシュサイズ、およびダウンストリームサービスへのリクエスト数に関するサービスメトリクスを生成することです。

キャッシュサイズと有効期限ポリシーの値の選択について慎重に検討する必要があることを学びました。開発者が初期実装中にキャッシュサイズと TTL 値を任意に選択し、後戻りして後日その妥当性を検証しないという状況を回避したいのです。一時的なサービス停止や進行中の停止の悪化につながるフォロースルーの欠如の実例を見てきました

ダウンストリームサービスが利用できない場合に回復力を向上させるために使用するもう 1 つのパターンは、ソフト TTL とハード TTL の 2 つの TTL を使用することです。クライアントはソフト TTL に基づいてキャッシュされたアイテムを更新しようとしますが、ダウンストリームサービスが利用できないか、リクエストに応答しない場合、既存のキャッシュデータはハード TTL に達するまで使われ続けます。このパターンの例は、AWS Identity and Access Management (IAM) クライアントで使用されます。

また、ダウンストリームサービスの電圧低下の影響を軽減するために、バックプレッシャーを伴うソフト TTL とハード TTL アプローチを使用します。ダウンストリームサービスは、電圧低下時にバックプレッシャーイベントで応答できます。これは、呼び出しサービスがキャッシュされたデータをハード TTL まで使用し、キャッシュにないデータのみをリクエストすることを通知します。ダウンストリームサービスがバックプレッシャーを解消するまでこれを続けます。このパターンにより、ダウンストリームサービスは、アップストリームサービスの可用性を維持しながら、電圧低下から回復できます。

その他の考慮事項

重要な考慮事項は、ダウンストリームサービスからエラーを受信したときのキャッシュの動作です。これに対処する 1 つのオプションは、最後にキャッシュされた有効な値を使用してクライアントに応答することです。たとえば、前述のソフト TTL /ハード TTL パターンを活用します。採用するもう 1 つのオプションは、ポジティブキャッシュエントリとは異なる TTL を使用してエラー応答をキャッシュし (つまり、「ネガティブキャッシュ」を使用し)、クライアントにエラーを伝播することです。特定の状況で選択するアプローチは、サービスの詳細と、クライアントが古いデータとエラーのどちらを見るのが良いかを評価することによって異なります。どのアプローチを採用するかに関係なく、エラーが発生した場合にキャッシュに何かがあることを確認することが重要です。これが当てはまらず、ダウンストリームサービスが一時的に利用できない場合、または特定のリクエストを処理できない場合 (ダウンストリームリソースが削除された場合など)、アップストリームサービスは引き続きトラフィックでそれを攻撃し、潜在的に停止を引き起こすか、既存のものを悪化させます。否定的な応答のキャッシュに失敗すると、失敗率と障害が増加する実際の例を見てきました。

セキュリティは、キャッシュのもう 1 つの重要な側面です。サービスにキャッシュを導入するとき、もたらされる追加のセキュリティリスクを評価し、軽減します。たとえば、外部キャッシングフリートには、多くの場合、シリアル化されたデータの暗号化とトランスポートレベルのセキュリティがありません。これは、重要なユーザー情報がキャッシュに保持されている場合に特に重要です。この問題は、転送中および保管中の暗号化をサポートする Amazon ElastiCache for Redis のようなものを使用することで軽減できます。また、キャッシュはポイズニング攻撃の影響を受けやすく、ダウンストリームプロトコルの脆弱性により、攻撃者は制御下の値をキャッシュに追加できます。これにより、攻撃の影響が増幅されます。これは、この値がキャッシュに残っている間に行われたすべてのリクエストが悪意のある値を認識するためです。最後の例として、キャッシュはサイドチャネルタイミング攻撃の影響も受けます。キャッシュされた値は、キャッシュされていない値よりも速く返されるため、攻撃者は応答時間を利用して、他のクライアントがまたは信条により行っているリクエストに関する情報を取得できます。

最後の考慮事項の 1 つは、「Thundering Herd」の状況です。この状況では、多くのクライアントが、キャッシュされていない同じダウンストリームリソースをほぼ同時に必要とするリクエストを行います。これは、サーバーが起動し、空のローカルキャッシュでフリートに参加するときにも発生する可能性があります。これにより、各サーバーから多数のリクエストがダウンストリームの依存関係に送られ、調整/電圧低下が発生する可能性があります。この問題を解決するために、リクエストの合体を使用します。この場合、サーバーまたは外部キャッシュは、キャッシュされていないリソースに対して 1 つの保留中のリクエストのみを送信します。一部のキャッシングライブラリは、リクエストの合体をサポートし、一部の外部インラインキャッシュ (Nginx や Varnish など) もサポートしています。さらに、既存のキャッシュの上にリクエストの合体を実装できます。 

Amazon のベストプラクティスと考慮事項

この記事では、Amazon のいくつかのベストプラクティスと、キャッシングに関連するトレードオフとリスクについて触れました。以下は、キャッシュを導入するときにチームが使用する Amazon のベストプラクティスと考慮事項をまとめたものです。

• コスト、レイテンシー、可用性の向上の観点から正当化されるキャッシュの正当な必要性があることを確認します。データがキャッシュ可能であることを確認します。これは、複数のクライアントリクエストで使用できることを意味します。キャッシュがもたらす価値に懐疑的であり、キャッシュがもたらす追加のリスクを上回るメリットがあることを慎重に評価してください。
• サービスフリートおよびインフラストラクチャの残りに使用されるのと同じ厳密さとプロセスでキャッシュを操作することを計画します。この努力を過小評価しないでください。キャッシュが適切に調整されるように、キャッシュの使用率とヒット率に関するメトリクスを生成します。重要なインジケータ (CPU やメモリなど) を監視して、外部キャッシュフリートが正常であり、適切にスケーリングされていることを確認します。これらのメトリクスにアラームを設定します。ダウンタイムや大量のキャッシュの無効化なしにキャッシングフリートをスケールアップできることを確認します (つまり、一貫したハッシュが期待どおりに機能していることを検証します)。
• キャッシュサイズ、有効期限ポリシー、および削除ポリシーの選択については、慎重かつ経験に基づいて行ってください。テストを実行し、前の箇条書きで述べたメトリクスを使用して、これらの選択を検証および調整します。
• キャッシュが使用できない場合にサービスが回復力を持つようにします。これには、キャッシュされたデータを使用してリクエストを処理できないさまざまな状況が含まれます。これには、コールドスタート、キャッシングフリートの停止、トラフィックパターンの変化、またはダウンストリームの長期停止が含まれます。多くの場合、これは可用性の一部をトレードして、サーバーと依存サービスが電圧低下しないようにすることを意味します (たとえば、負荷の制限、依存サービスへのリクエストの制限、古いデータの提供など)。これを検証するには、キャッシュを無効にして負荷テストを実行します。
• 暗号化、外部キャッシングフリートと通信する際のトランスポートセキュリティ、キャッシュポイズニング攻撃およびサイドチャネル攻撃の影響など、キャッシュデータの維持に関するセキュリティ面を考慮してください。
• キャッシュオブジェクトのストレージ形式を設計して、時間の経過とともに進化し (バージョン番号を使用するなど)、古いバージョンを読み取ることができるシリアル化コードを記述します。キャッシュシリアル化ロジックのポイズンピルに注意してください。
• キャッシュがダウンストリームエラーを処理する方法を評価し、個別の TTL でネガティブキャッシュを維持することを検討してください。同じダウンストリームリソースを繰り返し要求し、エラー応答を破棄して、停止を引き起こしたり増幅したりしないでください。

Amazon の多くのサービスチームは、キャッシュ技術を使用しています。これらの手法の利点にもかかわらず、キャッシングを組み込む際は慎重を期します。これは、多くの場合、マイナス面がプラス面を上回るためです。独自のサービスのキャッシングを評価するときに、この記事が役立つことを願っています。


著者について

Matt は、Amazon のエマージングデバイスのプリンシパルエンジニアであり、今後の消費者向けデバイスのソフトウェアとサービスに取り組んでいます。以前は、AWS Elemental で勤め、ライブおよびオンデマンドビデオ用のサーバー側のパーソナライズされた広告挿入サービスである MediaTailor を立ち上げたチームを率いていました。途中で、彼は PrimeVideo の最初のシーズンのストリーミング番組である『NFL サーズデーナイトフットボール』の立ち上げを助けました。Amazon に入社する前はセキュリティ業界で 15 年間過ごし、McAfee、Intel、数社のスタートアップで勤務し、エンタープライズセキュリティ管理、マルウェア対策およびエクスプロイト対策技術、ハードウェア支援セキュリティ対策、DRM を手がけました。

Jas Chhabra は AWS のプリンシパルエンジニアです。彼は、2016 年に AWS に入社し、AWS IAM で数年間経験した後、AWS Machine Learning を担当しています。AWS の前は、Intel で IoT、ID、およびセキュリティ分野のさまざまな技術的な役割を果たしてきました。現在の関心事は、機械学習、セキュリティ、大規模な分散システムです。以前は、IoT、ビットコイン、アイデンティティ、暗号化などに関心を持っていました。彼はコンピューターサイエンスの修士号を取得しています。

分散システムでのフォールバックの回避 負荷制限を使用して過負荷を回避する デプロイ時におけるロールバックの安全性の確保