📖

re:Invent 2024: AWSが語るレジリエントシステムの技術と手法

に公開

はじめに

海外の様々な講演を日本語記事に書き起こすことで、隠れた良質な情報をもっと身近なものに。そんなコンセプトで進める本企画で今回取り上げるプレゼンテーションはこちら!

📖 AWS re:Invent 2024 - Try again: The tools and techniques behind resilient systems (ARC403)

この動画では、AWSのDistinguished EngineerのMark Crockerが、大規模システム運用から得た教訓を共有しています。特にRetryの問題点とベストプラクティスがシステムにもたらす影響、Circuit Breakerのメリット・デメリット、Erasure Codingやテールレイテンシー削減のテクニック、そしてシステムの安定性理論について解説しています。3,000-4,000件のポストモーテムの経験から、Token Bucketを用いたアダプティブなRetryアルゴリズムの有効性や、AWS LambdaでのErasure Codingの実装例など、具体的な解決策を示しながら、シミュレーションを活用したシステム設計の重要性を説いています。
https://www.youtube.com/watch?v=rvHd4Y76-fs
※ 動画から自動生成した記事になります。誤字脱字や誤った内容が記載される可能性がありますので、正確な情報は動画本編をご覧ください。
※ 画像をクリックすると、動画中の該当シーンに遷移します。

re:Invent 2024関連の書き起こし記事については、こちらのSpreadsheet に情報をまとめています。合わせてご確認ください!

本編

AWSエンジニアが語る大規模システム運用の教訓

こんにちは。Amazon のDistinguished Engineer の Mark Crocker です。主にデータベースサービスに携わっていますが、AI や生成 AI チームとも協働しています。今日のトークは、それらの内容に直接関係するものではありません。代わりに、AWS で大規模および小規模なサービスを運用してきた中で学んだ厳しい教訓についてお話しします。サービスを運用し、システムを構築してきた方なら、最も多くを学ぶのは最悪の日々からだということをご存知でしょう。この講演には、私たちが自分自身とお客様を失望させてしまった、とてもストレスフルな時間から学んだ教訓が詰まっています。

AWS でのキャリアを通じて、業界全体でどれくらいのポストモーテム(事後分析)を読んできたか計算してみました。最も妥当な推定で3,000から4,000件ほどになります。この1時間で、リトライの問題点と、特にリトライに関する一般的なベストプラクティスがシステムにもたらす問題について説明します。また、サーキットブレーカーについて、そのメリットとデメリット、優れている点、そしてサーキットブレーカーをシステムに実装する際に引き起こす予期せないダウンタイムなどの問題についても触れます。

イレイジャーコーディングやテールレイテンシーを削減するその他のテクニック、そしてテールレイテンシーのモデリングとシミュレーションに関する統計的手法についてお話しします。その後、安定したシステムの理論について、私が特に気に入っている安定性とメタ安定性のフレームワークを用いて説明します。このフレームワークは、こういった問題を考える上で非常に役立つメンタルモデルだと考えています。最後に、統計について議論したいと思います。より多くの人に統計を活用してほしいのですが、私たちは不必要に難しくしてしまっているように思います。

リトライの落とし穴:システム安定性への影響

それでは、リトライから始めましょう。分散システムの構築を始めた人なら誰もが、リトライの実装について考えると思います。システムがダウンしている時、エラーが発生した時、パケットロスが起きた時に、リトライが助けになるからです。この考え方自体は根本的に間違っているわけではありませんが、隠れたリスクとデメリットがあります。これを理解するために、例を見てみましょう。スライドの左側には、クライアント群があります。これはウェブサイトのクライアントだと考えてください。完全に独立してランダムにアクセスしてくるユーザーです。

クライアントの動作は、多くの人がベストプラクティスと考えるようなものです。サーバーを呼び出す際にタイムアウトを実装し、サーバーやネットワークからエラーが返ってきた場合、3回リトライを行います。ただし、多くの記事を読んでいるので、指数バックオフとジッターも実装しています。これらは良い実践とされているからです。スライドの右側にはサーバーがあります。サーバーには特に変わった点はありません。常に成功し、常に高性能ですが、一つ興味深い特性があります。それは、レイテンシーが負荷に比例して増加するということです。

これは現実世界の完璧なモデルではありませんが、決して不合理なモデルでもありません。大規模システムの構築経験から、競合、調整、スレッド間の協調、ロック、ラッチング、その他同様の要因により、実際のシステムでは並行性が上がるにつれてレイテンシーが上昇する傾向があることがわかっています。このシステムでは、まず処理可能な範囲内のベースラインとなる負荷をかけます。その後、1秒間だけの短いオーバーロードのスパイクを加えます。これはシステムが処理できる量を超え、クライアントがタイムアウトを起こすほどレイテンシーを上昇させる負荷です。そして、この場合にシステムで何が起こるのかを理解しようとします。まずは、リトライを行わない場合の1秒あたりの成功数を見てみましょう。これはクライアントのリトライ機能を無効にした場合です。通常の量のトラフィックで、まだタイムアウトが発生していない状態では、正常に動作しているのが分かります。スパイクが発生すると、

トラフィックが増加してシステムのレイテンシーが大幅に上昇したため、すべてのクライアントがタイムアウトします。クライアントから見ると、成功率がゼロに落ちたように見えます。その後、過剰なリクエストがシステムを通過し、システムは回復して、再び正常な状態に戻ります。クライアントは再び成功を確認し、グラフの右側に向かって続いていきます。これは、クライアントがトラフィックスパイクの約2倍の長さのダウンタイムを経験するという点で悪いことですが、トラフィックスパイクの2倍の時間で完全に回復したという点では良いことでもあります。

では、3回のリトライをシステムに追加してみましょう。すると、今度は全く回復しなくなります。これは大きな問題です。なぜなら、リトライによってオーバーロード期間中の可用性は改善されず、むしろシステムが全く回復しないという状態を引き起こしてしまったからです。クライアントは回復を見ることができません。なぜなら、クライアントが最初のリクエストを行い、リクエストのスパイクが発生した時点で全クライアントがタイムアウトし、リトライが開始されるからです。システムは、すべてのクライアントが来て、タイムアウトし、リトライし、再びタイムアウトするという循環を4回繰り返す状態になっています。サーバーが処理しているトラフィック量は4倍に増加し、システムはタイムアウト時間内にこの作業を完了できなくなっています。

クライアントに組み込んだレジリエンシーメカニズムは状況を改善するはずでしたが、逆に永続的なダウンタイムを引き起こしてしまいました。このシステムを改善する唯一の方法は、サーバーの処理能力を増強して回復を促すか、一時的にクライアントをブロックしてオーバーロード期間を収束させてから、徐々にクライアントをシステムに戻すかのどちらかです。これは一部の方にはSFのように聞こえるかもしれませんが、これは業界の歴史における大規模システムの最大の障害の一部の根本的な原因となっています。時にはリトライが具体的な原因となることもありますが、システムが最も苦しい状況にある時に、より多くの作業を引き起こすような様々な影響が原因となることもあります。

これらのシステムには2種類の障害があります。1つ目は、冗長システムやネットワークでよく見られる一時的な障害です。これは個々の失敗したリクエストやパケットロスの期間など、相関のない方法で失敗するものです。これらの一時的な障害の間、リトライは大きな助けとなり、わずかなレイテンシーの増加を除いて、ほとんどのケースをクライアントにとってほぼ完全に見えなくすることができます。2つ目は、同じ原因によって多数の障害やタイムアウトが引き起こされるシステム的な障害です。これらは負荷、ロックの競合、ソフトウェアのバグ、運用上の問題などによって引き起こされる可能性があります。システム的な障害の期間中、リトライはベストプラクティスとされているにもかかわらず、システムの動作と可用性を積極的に害します。これには、ネットワークレベルのリトライからアプリケーションレベルのリトライ、さらにはウェブサイトが読み込めない時に顧客が繰り返しF5を押すことまで、あらゆるレベルのリトライが含まれます。

Token Bucketアルゴリズム:適応型リトライの解決策

解決策はあるのでしょうか?実は、さまざまな解決策やアルゴリズムが存在します。その中でも、私が特に気に入っているのが、AWSで採用しているToken Bucketと呼ばれる方式です。

この10年ほどの間に、私たちはこのアダプティブなリトライアルゴリズムをほぼすべてのシステムに組み込んできました。リトライを一切行わないアルゴリズムと比較すると、障害時間が若干長くなり、過負荷の期間もわずかに延びますが、容量を増やしたりクライアントを締め出したりすることなく、システムの復旧を可能にします。

このアルゴリズムの仕組みを説明しましょう。まず、呼び出しを試みる際、クライアントは自身のバケット(コインが入ったかごのようなもの)を確認し、リトライトークンがあるかどうかチェックします。バケットからコインを取り出してリトライの支払いができれば、リトライを実行します。できなければ、諦めます。成功した場合は、バケットにトークンの一部を入れます。つまり、リトライを行うにはバケットから1ドル取り出す必要があり、成功するたびに1セントを入れる、という具合です。このシンプルな変更により、安定したクライアント群からの総トラフィックを通常負荷の101%に抑えることができます。これは、3回リトライの場合に通常負荷の400%まで上昇する可能性があるのと比べると大きな違いです。サーキットブレーカーを使用せずとも、リトライ時のシステムの最大処理量を効果的に制限できるのです。

3つの解決策を比較してみましょう。リトライなしの方式は、システム全体の障害に対しては状況を悪化させないという点で優れていますが、一時的な障害に対しては何の助けにもなりません。標準的な3回リトライ(バックオフとジッターを含む)は、システム全体の障害に対しては最悪で、多くの場合状況を著しく悪化させます。一時的な障害に対しては優れていますが、実際のシステムでは単一サーバーの障害やパケットロス、マシンの再起動など、一時的な障害が日常的に発生するため、3回リトライが良い選択に見えてしまいます。

アダプティブリトライは、この問題を完全に解決するわけではありませんが、システム全体の障害と一時的な障害の両方に効果的な解決策を提供します。システム全体の障害をなくすことはできませんが、通常負荷で回復可能な場合であれば、ほぼ確実にシステムの回復を可能にします。私のアルゴリズム例では、エラー率が1%未満の場合、単純な3回リトライアルゴリズムと全く同じ動作と効果を一時的な障害に対して発揮します。このちょっとした変更により、研究コミュニティのシステム安定性に関する素晴らしい研究のおかげで「準安定状態」と呼ばれるようになった、さまざまな種類の問題を回避することができます。

オープンシステムvsクローズドシステム:バックオフの効果

このアルゴリズムは、AWS SDKsやAmazonの社内サービスフレームワーク、そしてネットワークスタックの一部に組み込まれており、その結果、安定性が大きく向上しているのを実感しています。誰かが単純な「for i equals 0 to 3」のようなリトライループを書いているのを見かけるたびに、このドキュメントを読むように勧めています。大規模システムを構築する場合、これらの知識は必須です。「指数バックオフで解決できるのでは?」と思われるかもしれません。しかし実際には、完全には解決できません。あるいは、特定の場合にのみ有効です。これが有効な場合と無効な場合を理解するために、2つの異なるシステムクラスについて説明する必要があります。

この概念は「Open versus Closed: A Cautionary Tale」という素晴らしい研究論文から来ています。この講演の後にぜひ読んでみることをお勧めします。私たちには2つの異なるシステムクラスがあります。1つは、本質的にランダムにリクエストが到着するオープンシステムです。これはWebサイトやモバイルバックエンド、Webサービスでよく見られます。数千、数万、数百万、あるいは数十億の顧客がいて、彼らがいつWebサイトを読み込むかをコントロールできない - 本質的に気が向いたときにランダムにアクセスするようなシステムです。

もう1つのクラスは、固定された作業者セット、つまりシステム内で作業を行う固定された人々やシステムを持つクローズドシステムです。例えば、5台のマシンがストリームからデータを取得し、処理を行い、次の作業項目に移るようなストリーミングデータシステムを想像してください。ここでの重要な違いは、オープンシステムではクライアントをバックオフで遅くしても次のクライアントがいつ来るかには影響しないため、クライアントを遅くしてもシステムの負荷は減少しないということです。一方、クローズドシステムでは逆のことが起こり、クライアントを遅くするとシステム全体の負荷が減少します。

ほとんどのシステムでは、これら2つの異なる動作が混在しています。純粋にオープンなシステム(例えばWebサーバー)もあれば、純粋にクローズドなシステムもあります。また、AWSのコントロールプレーンAPIのように、新しい作業が入ってくるオープンな動作と、顧客がCloud Formationテンプレートを実行したりAPIフローを実行したりするようなクローズドな動作が混在するシステムもあります。バックオフはクローズドシステムでは非常に効果的な戦略ですが、オープンシステムではほとんど効果がありません。Jitterは常に良いアイデアです - リトライを行う場合にJitterを使用しない理由はないと思います。Jitterとは、N秒間スリープする代わりに、0から2N秒の間のランダムな時間スリープすることを意味します。これにより、相関のある動作を分散させ、負荷のスパイクを時間的に平坦化するのに役立ちます。私たちはこの動的な挙動をシミュレーションして理解する上で、Amazon Builder's Libraryで確認できる素晴らしい成果を上げてきました。

Circuit Breakerの功罪:システム安定性への影響

次にCircuit Breakerについて話しましょう。Circuit Breakerは、人によって定義が少しずつ異なるアイデアの1つです。教科書やWebサイト、参考文献によっても、Circuit Breakerの定義は異なります。私にとって、これは本当に「いつ、どこで最初の試行を行うべきか」という問題です - つまり、試行すべきか、あるいはダウンストリームシステムが過負荷や不健全な状態にあることを観察して、1回の試行さえも送信しないと判断すべきかということです。システムにおけるCircuit Breakerの役割を理解するために、私たちはCircuit Breakingタスクをさらに2つの区分に分けるべきだと考えています。1つは、古典的な分散システム論文から来ているHarvestの削減です。ここで意味しているのは、可用性を維持するためにUI要素などの非必須機能を削除することです。例えば、Amazon.comやAWSコンソール、あるいは現代の主要なWebサイトには、オプショナルなUI要素があり、この追加情報ボックスや他の機能が読み込めなくてもWebサイトの読み込みをブロックしません。システムが過負荷状態にある場合や、オプショナルな機能が利用できない場合に機能を削減するというこのアイデアは、うまく実装できれば素晴らしいものです。そして、人間は比較的そのような状況に対して柔軟に対応できる傾向があります。

お気に入りのウェブサイトのウィジェットが読み込まれない場合でも、ユーザーは完全にダウンするわけではありません。しかしAPIクライアントは逆です。APIクライアントに対してフィールドの3分の2しか返さないと、90%の確率で問題が発生し、時には予期せぬ悪い方向に進むことがあります。

Circuit Breakerのもう一つのケースは、負荷を拒否し、上流の可用性を犠牲にすることで、下流のシステムを正常な状態に戻すことです。これはおそらく最も一般的な分散システムのパターンです。例えば、TCPがまさにこれを行っています。TCPが基本的に行っているのは、通信しているリンクの過負荷の兆候を監視し、それをレイテンシーやパケットロスとして検出して、負荷を減らすことです。このような同じパターンは、アプリケーションレベルや、物理レベルからすべてのネットワークプロトコルで一般的に見られます。

負荷を拒否するCircuit Breakerは良いアイデアですが、負荷を拒否するのに適したアルゴリズムは、Harvestを減らすのに適したアルゴリズムとは異なることが多いです。基本的に、これらはクライアントへのサービス品質を低下させます。システムに負荷がかかりすぎている場合、回復を助け、リソースを平等に共有するために速度を落とすという寛容な考え方です。このようなCircuit Breakerにより、システムは輻輳を回避し、高い輻輳に伴う過度のレイテンシーや障害を回避し、輻輳崩壊から素早く回復することができます。

Circuit Breakerには大きなデメリットもいくつかあります。その一つは、シャードシステムの可用性を低下させる可能性があることです。例えば、あるサービスと通信するCircuit Breaker付きのクライアントがあり、失敗率が10%を超えるとCircuit Breakerを作動させてリクエストの送信を停止するというロジックを実装したとします。このシャードシステムでは、データを複数の部分に分割し、そのうち3つは完全に正常に動作していて1つが失敗している状況です。クライアントはこれを見て、25%の失敗率を検出し、このサービスへのトラフィックを完全に停止すべきだと判断します。結果として、25%の失敗率を100%の失敗率に変えてしまうことになります。

このようなシャードシステムは、大規模な分散システムでは非常に一般的です。例えば、DynamoDBチームの論文を読むことができます。これは2022年のATCで発表された素晴らしい論文で、DynamoDBのアーキテクチャと設計の選択について深く掘り下げています。DynamoDBはまさにこのようなシャードシステムの一つです。昨日発表したDynamoDBやAurora SQLのような、これらのシャード型データベースでは、単純なCircuit Breakerを使用するとクライアントの可用性が低下してしまいます。

もう1つの悪いケースを見てみましょう:マイクロサービスアーキテクチャにおいて、Circuit Breakerが障害の影響範囲を拡大させてしまうことがあります。ここに、マイクロサービスのツリーを通じてAPI A、B、C、Dを提供するサービスがあり、それぞれのAPIには依存関係があります。API Cの依存関係にのみ問題があることがわかっています。A、B、Dは正常に動作していますが、このシステムの上に単純なCircuit Breakerを置くと、障害を検知してしまいます。各APIの負荷が同じだとすると、システムは後退してしまいます。つまり、ここでは不必要にAPI Aへのトラフィックを完全に停止させてしまったのです。これがCircuit Breakerを使用する際のリスクと欠点です。では、ここからどのような教訓を得られるでしょうか?私は二値的なオン・オフのCircuit Breakerは避けるようにしています。閾値Xを超えたら完全にトラフィックを停止し、Yに戻ったら再開するというような方式は、動的な振る舞いが悪く、Circuit Breakerの欠点をより深刻にしてしまいます。私はTCPに組み込まれているような加法的増加・乗法的減少アルゴリズムを好みます。これは下流のキャパシティに自動的に適応します。

これらのアルゴリズムは理解して考えるのは少し難しいですが、実装自体はとても簡単です。そして、シミュレーションやテストは、システムの可用性を実際に向上させる形で実装できているかを理解するための優れた方法です。

単純なエラー率やレイテンシーに基づいてシステムの大部分を停止させてしまう、影響範囲の大きいCircuit Breakerは避けるべきです。なぜなら、停止する必要のない部分まで停止させてしまう可能性が高いからです。効果的なCircuit Breakerを実現するには、適切な判断を下すために下流のサービスの内部詳細を知る必要があるかもしれません。これは明らかなレイヤー違反のように聞こえて気分が悪くなるかもしれません。はい、その通りで良くないことですが、実際にはシステムの助けになることがあります。Circuit Breakerを実装する際に深く考えるべきことは、レイヤーの一部を破り、サービスの実装の一部をクライアントに漏らすことで、クライアントがより良い判断を下せるようになるかもしれないということです。

テールレイテンシー対策:Hedgingとイレイジャーコーディング

嵐の最中、特に強風の時に家で過ごしていて、照明が暗くなったり明るくなったりする経験をしたことがあるかもしれません。これは電力配電網内の「Recloser」と呼ばれる自動Circuit Breakerによるものです。これらが効果的に機能する唯一の理由は、下流のトポロジーと保護対象について深く理解しているからです。これは物理的インフラから学べる素晴らしい教訓です。

では、サービスのテールレイテンシーとその対処方法について話を進めましょう。これは実際のAWSマイクロサービスのレイテンシーの累積密度プロットです。X軸はレイテンシー、つまりクライアントの視点からサービスが応答するまでの時間を示しています。Y軸は、その時間以下で処理される要求の割合を示しています。このようなプロットの優れている点は、見たいパーセンタイルから水平線を引いて赤線との交点を見つけることで、直接パーセンタイルを読み取れることです。これは、読み取りが難しいヒストグラムよりも、サービスのレイテンシーを可視化して考えるのに私が好む方法です。

このサービスはかなり高速です - ほとんどの場合、中央値で1ミリ秒強の応答時間で、99.99パーセンタイル(つまり10,000リクエストのうち最も遅いもの)でも8.5ミリ秒で応答します。かなり速いと言えますが、若干の裾野があります - この裾野とは、10,000リクエストに1回は中央値の約8倍も遅くなるということを意味します。サービスの裾野の挙動について、直感的な理解を深めていきましょう。このサービスを直列で100回呼び出した場合はどうなるでしょうか - 1回呼び出して応答を待ち、2回目を呼び出して応答を待つ、という具合です。統計学に詳しい方なら、期待値の線形性を適用して、平均値は以前の100倍になると言うでしょう。

パーセンタイルについては、推論がより難しくなります。分布が正規分布でも対数正規分布でもなく、うまく扱える分布ではないため、きれいな法則は存在しません。そこで、別のアプローチを取る必要があります。直列で100回呼び出した場合、平均値と中央値は上昇しています - 平均値はちょうど100倍、中央値もほぼ100倍になっていますが、P99.99は24倍しか上昇していません。つまり、この特定の分布では、100回呼び出した場合の裾野は、1回呼び出した場合よりもやや平坦になっているのです。

これは、Webサービスのレイテンシーの実際の分布で、直感に反するかもしれません。直列で100回何かを実行する場合、遅いリクエストが多く発生し、全体としてより重い裾野になるはずだと考えるかもしれません。これは、裾野の挙動を理解するには数学的な計算が重要であることを示しています。

では、別の質問をしてみましょう:これを並列で100回実行した場合はどうなるでしょうか?完全に独立した100個の作業単位があり、それらをすべてサービスに送信し、すべての応答が返ってくるのを待つとします。結果として、平均値は以前の4〜5倍に上昇し、P99は以前の12倍にしか上昇せず、P99.99も同様に約12倍になります。ここで見られる分布は、より重い裾野ではなく、より軽い裾野を持ち、外れ値が少なくなっています。これは、並列で多くのリクエストを送信して最も遅い応答を待つ場合、最も遅いものは確かに遅いかもしれませんが、並列で作業を行っているため全体としては速くなるという直感とよりマッチします。

裾野を平坦化する最初のツールについて説明しましょう。2つのリクエストを送信し、最初に返ってきた応答を使用します。これは時にHedgingと呼ばれます。実際には、世の中にはHedgingと呼ばれる2つの手法がありますが、これは単純なパターンで、すべてを2回送信し、最初に返ってきたものを使用して、2番目のものは無視するというものです。これは平均値と中央値にはあまり影響を与えず - 1ミリ秒強のままで、20-30%程度の改善に留まります。しかし、Four Nines(99.99パーセンタイル)の性能は60%改善されており、これは大きな改善です。外れ値のレイテンシーがクライアントやカスタマーにとって懸念事項となるサービスでは、この66%の削減は価値があるものとなります。

この66%の削減と引き換えに2倍の作業をしているわけですが、それでも他の方法で同じ削減を達成するよりも安上がりかもしれません。コストは、スループットとリクエスト量が2倍になることです - これが重要な場合もあれば、そうでない場合もあります。システムの経済性を考慮して、tail latencyを大幅に抑えることと引き換えに、それだけの価値があるかどうかは、皆さん自身で判断する必要があります。これらのグラフではP99.99までしか示していませんが、100万回に1回といった、さらに極端な外れ値の場合、この手法はさらに効果的で、クライアントにはほとんど影響が見えなくなります。

2つ目のツールをご紹介します:毎回2倍のコストを払うのではなく、時々だけ2倍のコストを払います。1つのリクエストを送信し、それが一定時間内に返ってこない場合、もう1つのリクエストを送信して、先に返ってきた方を使用します。平均値や中央値には影響がありません。なぜなら、それらに対しては1つのリクエストしか送信しないからです。90パーセンタイルの時点で、すでに平坦化が実現されています。トラフィックが10%増加する代わりに、99.99パーセンタイルを54%削減できています。

ここでの問題は、安定性とmetastableな障害モードに関連しています。特に非適応型のバージョンでこの手法を使用した場合、サービスが何らかの理由で遅くなったときに何が起こるでしょうか?少し過負荷になると、先ほど説明したように遅くなり、突然、10%の追加リクエストを送信する代わりに、トラフィックが2倍になってしまいます。

そうなると、すでに過負荷状態のサービスにさらに多くの作業を要求することになり、さらに過負荷になって、ダウンしたまま復旧できなくなってしまいます。サービスがダウンしているときにさらに追い打ちをかけるのは、私たち全員にとって逆効果です。一見とても魅力的に見えるこの手法ですが、このような動作特性のため、私はあまり好きではありません。Retryを修正するために使用したToken Bucketアプローチを使用してこれを修正することはできますが、それによってシステム全体が複雑になってしまいます。ただし、これらのmetastableな障害モードの最悪のケースは回避できます。

もう1つのtail latency対策ツールをご紹介します:Erasure Codingです。これは任意のAPIでは使用できませんが、ストレージシステムやキャッシュでは使用できます。Erasure Codingを使用すると、システムにNリクエストを送信し、最初のKから応答を組み立てることができます。例えば、システムに6つのリクエストを送信し、そのうちの4つがあれば正しい結果を再構築できるようにシステムを設計することができます。これはストレージシステムやキャッシュシステムでは一般的なパターンです。昔からある手法ですが、分散システム、特に不変データの分散キャッシュでは十分に活用されていないと思います。

ここでのコストはリクエスト数に比例します。なぜなら、N個のリクエストを送信しているからです。これが高コストかどうかは、システムがリクエストレート制限なのか、スループット制限なのかによって変わってきます。帯域幅はN/K倍、追加のストレージもN/K倍必要です。データの異なるエンコーディングを保存する必要があるためですが、これは一定の作業量です。NとKは任意に制御できます。ただし、KはNより小さいか等しくなければならないというルールだけです。そのため、システムの経済性とパフォーマンスの目標に応じて自由に調整できます。この方法は非常に効果的です。少し追加の作業と引き換えに、6つのリクエストを送信し、最も遅い2つを待つ必要がないため、テイルレイテンシーを大幅に削減できます。

この手法は、AWSの多くの場所で使用されています。ストレージシステムでは普遍的に使用されていますが、特に私が実装を楽しんだのは、AWS Lambdaのコンテナローディングシステムでした。これは昨年のUNESIX ATCで発表した論文です。Lambdaにコンテナサポートを追加し、コールドスタートレイテンシーを増やすことなく、40倍大きなアーティファクトをサポートできるデータプレーンを構築した詳細について説明しています。これを実現した主要な方法の1つが、階層化されたキャッシュにイレイジャーコーディングを組み込んだシステムを構築することでした。これによってテイルレイテンシーを大幅に平坦化することができました。

AWS Lambdaのコンテナデータプレーンにイレイジャーコーディングを組み込むことで、テイルレイテンシーの大きな改善が見られただけでなく、素晴らしい回復性の利点も得られました。これは、2つのリクエストを送信する手法やイレイジャーコーディング手法、さらには長い待機時間後にリクエストを送信する手法など、これらの技術の追加的な利点です。1台のサーバーや1つのコンポーネントが停止した場合でも、システムの回復性が大幅に向上します。比較的シンプルなクライアントサイドのアルゴリズム変更と引き換えに、デプロイメントの作業が格段に容易になります。問題のあるサーバーを発見して対応するまでの運用上の時間的余裕も大幅に増えます。クライアントに影響が及ぶまでの時間的余裕も増えます。大規模な高可用性サービスを運用する上で最も難しいのは、このようなデプロイメントなどの運用タスクです。ストレージフリートを新規に立ち上げた場合、デプロイメントは非常に困難です。新しいソフトウェアをインストールする間、ストレージサーバーをミリ秒から数分の間停止させる必要があるからです。しかし、イレイジャーコーディングを使用していれば、それを最適化する必要はありません。なぜなら、1つのシステムが停止している間も、クライアントはシステムの他の場所から必要な結果を取得できるからです。

メタ安定性:システムの負荷と回復のダイナミクス

私がイレイジャーコーディングを気に入っているのは、常に一定の作業量だからです。成功時も失敗時も同じ量の作業を行います。注意深く聞いていただいた方はお気づきかもしれませんが、これがこのトークのテーマです。失敗時により多くの作業を行うのではなく、成功時も失敗時も常に同じ量の作業を行いたいのです。そして可能であれば、失敗時にはより少ない作業を行いたいのです。

Lambdaは非常にシンプルな「5つ中4つ」のコードを使用しています。これは5つのリクエストを送信し、最初の4つを使用することを意味します。これはパリティのような単純なコードであることが判明しました。あらゆるシステムやサイズのニーズに対応するイレイジャーコードが存在し、計算コストも比較的安価なので、とても優れたツールだと言えます。

このトークでは「メタ安定性」という言葉について多く触れてきましたが、この言葉が何を意味するのか気になっているかもしれません。システムが負荷を受けた時の振る舞いについて説明しましょう。システム研究の論文やシステムベンダーの資料でよく目にするのが、パフォーマンスが右肩上がりに上昇していくスライドです。負荷を加えれば加えるほど成功率が上がっていく、というものです。私たちのシステムがこのような振る舞いをしてくれたらいいのですが。

しかし、ほぼすべての分散システムと、文字通りすべてのシングルマシンシステムにおいて、現実は異なります。ある一定のポイントを超えると、負荷を加えれば加えるほど成功率が下がってしまうのです。これは、競合状態の増加、インターフェースでのパケットロス、キャッシュのスラッシング、スケジューラーのスラッシング、そして過剰なスレッド数が原因です。このポイントでは、負荷を増やせば増やすほど、システムの成功率は下がってしまいます。理想的には、この時点で負荷を下げれば、同じパスを逆にたどって成功率が回復することを期待したいところです。

残念ながら、多くのシステムではそうはなりません。代わりに、負荷が上がった後、システムが回復するためには負荷を大幅に下げなければならないのです。先ほどお見せしたシンプルなリトライのスライドでは、システムが回復するためには、クライアントからの負荷を当初成功していた水準の30%まで下げる必要がありました。ほとんどのシステムは、このような形で、通常の動作状態に戻るためには最適な負荷よりもさらに低い水準まで負荷を下げる必要があるという特徴を持っています。

具体的にはこのような状況です:通常の運用では上下の変動があり、これが日々の運用パターンです。常にこのモードで運用したいところです。そして、システムが飽和状態に達するポイントがあります。すべてのコアがビジー状態になり、キャッシュが最適に満たされている状態です。この時点で、失敗率の上昇やレイテンシーの増加が見られ、成功率が低下し始めます。そして真の飽和状態に入り、より多くの作業、より多くの負荷、クライアントからのより多くのリクエストが、逆に進捗の低下を招きます。

これは、キャッシュのスラッシング、スケジューラーのスラッシング、過剰なスレッド数、ネットワークインターフェースでのパケットロス、ストレージデバイスの飽和など、様々なパターンやシステム現象によって引き起こされます。この悪い状態に陥ると、最初のスライドで見たように、リトライによってシステムが飽和状態に留まるか、スラッシングや競合、ロック競合、ラッチ競合によってシステムが飽和モードに留まってしまいます。そして、リトライが減少し、成功率が回復し、システムが通常の動作に戻るためには、負荷を大幅に下げる必要があります。一部の人にとってはSFのように聞こえるかもしれませんが、これは大規模システムの長時間の障害の背後にある、おそらく最も普遍的なパターンです。

このグラフの内側の空白部分は、私が「メタ安定性」と呼んでいるものを示しています。システムのオーバーロードの軌跡、つまりシステムをオーバーロードに追い込んでから回復させた時のグラフの内側に空白部分がある場合、そのシステムにはメタ安定な振る舞いが存在し、自己回復が困難な長期的な負荷関連の障害が発生するリスクがあります。特に適応的でないRetryを実装するというベストプラクティスに従っているシステムのほとんどが、このような振る舞いを示します。システムのレジリエンスを考える上で、これは最も重要な理解ポイントの一つです。

シミュレーションの力:システム挙動の理解と最適化

では、システムの振る舞いをより深く理解する方法について見ていきましょう。私が好んで使うのは、シミュレーションという手法です。RやPythonなどの言語を使って、非常にシンプルな小規模のイベントシミュレーターを作成し、システムの振る舞いのさまざまな側面を探索します。システムのダイナミックな振る舞いに関する直感は実はあまり当てにならないということを学んできたので、過度に信頼しないようにしています。シミュレーションは、システムの振る舞いを探索するためのツールとして役立ちます。

このグラフがどこから来たのか、そして66%という低下をどのように計算したのか、いくつか例を見てみましょう。一つの方法としては、パラメトリック統計を使用することができます。このレイテンシーカーブに分布をフィッティングすることもできます。このような任意の分布に対して強力な手法を使用することもできますが、シミュレーション手法を使うともっと簡単な方法があります。実際のデータ、つまり実際のCDF、モニタリングインフラストラクチャやObservabilityインフラストラクチャから得られた実際のレイテンシーサンプルを使用します。分布へのフィッティングは一切行わず、そのままのデータを使用します。

ここで私が行ったのは、データから5つのサンプルを選ぶことです。サービスから得られた全サンプルデータの配列から、ランダムに5つを選び、その中で4番目に良い値を選択し、これを繰り返して結果を記録し、出力CDFを作成しました。分布へのフィッティングを行うことなく、実際のレイテンシー測定値を使用してシステムの実世界での振る舞いを計算することができました。レイテンシーが対数正規分布に従うといった仮定を立てる必要はありません。実際の測定値を使用できるのです。これはPythonでたった3行のコードで実現でき、分布の選択やフィッティング、適合度検定の実装も必要ありません。

私は統計的手法を否定しているわけではありません - それらには何も問題はありませんが、仮定を立てる必要があり、残念ながらほとんどのソフトウェアエンジニアが大学で学んだことを覚えていないようなスキルが必要です。業界として、私たちは「統計的なツールの使い方を覚えていない」「統計が怖い」という理由で、これらの測定や考察を避けようとする停滞モードに陥っています。これらのシミュレーション手法は、同じレベルの数学的な洗練さを必要とせずに、システムの振る舞いをより良く理解するための素晴らしい方法です。繰り返しになりますが、私はシンプルなシステムシミュレーション - 約100行のPythonコード(50行ほどのコメントと100行ほどの実際のコード)を書き、システムを直接モデル化しました。

これはオブジェクト指向で、サーバーオブジェクトとクライアントオブジェクトがお互いにリクエストを送り合う仕組みになっています。特に素晴らしいのは、レビューが非常に簡単だということです。とてもシンプルなコードで、シンプルなことを実行するだけです。チームの誰にレビューを依頼しても、十分に理解して確認できる内容です。リトライを追加したり、アダプティブな動作を加えたり、リトライ回数を3回から2回に変更したりといった調整も、コードのほんの数行を変更するだけで済みます。システム設計者として、システムのパラメータや動作の可能性を動的に探索できるため、大幅な時間節約になります。

このようなシミュレーション技法は、実はメタな手法なのですが、あまりにも巧妙に隠れているため、人々はこれを本物とは思わないかもしれません。シミュレーションが活きる別の例として、「Nudge」という素晴らしい論文があります。これはFirst Come First Servedのキューシステムにおける、テールレイテンシーを改善する方法について研究したものです。アルゴリズムの詳細には触れませんが - キューやワークキューシステムを扱うシステムを構築している方は、ぜひ読んでみる価値のある面白い論文です。ただ、私の頭の中で浮かぶ疑問は:システム論文で読んだこの手法が、実際に自分のシステムやカスタマーにとって役立つのか、それとも大量のコードを書いて実装してみたものの、デプロイしても改善が見られないのか、ということです。

そんなことはしたくありません。本番コードの作成には時間がかかり、デプロイにはリスクと時間を伴います。もし状況が悪化したらどうでしょう?その場合、私は最悪の一日を過ごすことになり、カスタマーを失望させることになります。誰もそんなことは望みません。ここでも、非常にシンプルなオブジェクト指向のシステムシミュレーションを構築することで - このコードはGitHubで公開していますので興味のある方はご覧ください - 自分のシステムのパラメータ、実際のレイテンシー、クライアントの実際の動作分布やリクエストサイズで、この手法が役立つかどうかを判断できます。このシミュレーションを実行して分かったことは、確かにこの手法は様々な面で私のシステムに役立ち、テールを平坦化できるということでした。そのため、システムへの実装は価値があると判断しました。

このコードを書くのに1時間もかからず、システムに新しいキュー規律を実装する数週間の作業の方向性を決めることができました。もし結果が「これは役に立たない」というものだったら、数週間の作業を節約できたことになります。これは本当に素晴らしいことです - より迅速に、定量的で正確な答えを得ることができます。そして幸運なことに、最近ではAIを使ってさらに速く実行する方法があります。私が学んだことの一つは、このようなシミュレーションの説明を書いて、Amazon BedrockのSonnet 35のようなモデルに渡すと、シミュレーションを作成してくれるということです。

これが私が書いたプログラムの全容です。書くのにたった30秒しかかからず、今週の別の講演用のスライドに使うグラフを生成しました。これを手作業でコードを書いてデバッグし、グラフを見栄えよく整え、Rで適切なグラフを描画するためのライブラリを思い出すのに、おそらく30分か1時間はかかったでしょう。しかし、これはたった30秒、とてもゆっくり入力したとしても1分程度で済みました。これこそが生成AIの素晴らしい点です - 過去と比べてはるかに迅速に、そして低コストでこのような作業を行うことができるのです。

いくつかの重要なポイントについて振り返ってみましょう。プレゼンテーションを振り返って、皆さんが学んだと思われることを見ていきます。まず、Retryから始めましょう。Jitterは良いものです - 常にJitterを使うべきです。Jitterとは、ランダム性を加えることです - システムにとってランダム性は良いものです。スパイクを分散させるのに役立ちます。Retryは、メタステーブルな状態や転換点での障害を引き起こす可能性があり、システムがダウンしたまま復旧できなくなることがあります。しかし、AWS SDKで使用されているようなToken Bucketアルゴリズムを使用した指数バックオフを伴うベストプラクティスのRetryでも、ほとんどのタイプのシステムでは、AWS SDKに組み込まれたわずか数行のクライアントサイドコードでこの種の障害モードを完全に回避できます。その実装方法はご覧の通りです。

もう一つの教訓は、オープンシステムではバックオフはあまり効果的ではないということです。多数のクライアントがランダムにアクセスするクローズドシステムでは効果的ですが、Webサイトのようなオープンシステムを構築している場合、クライアントでのバックオフは過負荷の軽減にほとんど役立ちません。

Circuit Breakerは非常に強力なツールです。UIの機能やシステムのオプション機能を減らすHarvestingに使用できる他、クライアントが下流のシステムに配慮し、過負荷状態と分かっている時に余分なトラフィックを送信しないようにするのに役立ちます。ただし、特にキャッシュシステムや深いサービスアーキテクチャでは注意が必要です。Circuit Breakerをそれらのアーキテクチャの下層に押し下げるか、アーキテクチャの詳細をクライアントの実装により多く反映させることで、これらの問題を回避できますが、それぞれに欠点もあります。

Hedging、二重呼び出し、Erasure Codingなど、システムにより多くの作業を行わせることで、より平坦なテイル(外れ値の少ない遅延)を得るためのいくつかのテクニックがあります。私は、正常時も異常時も同じ量の作業を行う一定作業のテクニックを好みます。そのため、Erasure Codingと常に二重に実行するテクニックが特に効果的だと考えています。Retryの時に話したようにToken Bucketを使用して追加作業を制限できますが、それでもある程度の追加作業は必要になります。Erasure Codingは素晴らしいものです - ストレージシステムでは50年来の定番ですが、分散キャッシュではいくつかの正当な理由と、主に認知度の低さから広く使用されていません。

シミュレーションは、ほとんどのプログラマーの思考モデルにとてもよく合う、強力な数学的ツールだと思います。統計的な数式のページをレビューすることに不安を感じる人々(そして実際にそう感じるべき人々)も多いですが、同じような答えを得る小規模なシミュレーションのレビューなら、とても快適で十分な能力を発揮できるでしょう。より洗練されたシミュレーションやシミュレーションフレームワークを否定しているわけではありませんが、人々はそれらを早急に求めすぎる傾向があり、シミュレーションを行うなら最先端の分散シミュレーションフレームワークを使用する必要があると感じてしまいがちです。

それらのシミュレーションフレームワークは非常に強力で素晴らしいものですが、多くのシステム作業ではそれらは必要ありません。シミュレーションはコードのように見え、コードのように感じ、コードのような性質を持っています。コードのようにレビューし、コードのようにテストしますが、実際には強力な数学が裏で動いているのです。つまり、数学が姿を変えたものなのです。私たちはもっとシミュレーションを書くべきです。私ももっとシミュレーションを書くべきです。この講演から一つだけ持ち帰っていただきたいのは、皆さんのシステムの振る舞い、クライアントの振る舞い、サーバーの振る舞いについてのシミュレーションを書いてみるべきだということです。そうすることで、将来起こりうる長期的で苦痛を伴うシステム障害を未然に防げる何かを発見できるかもしれません。

本日は参加していただき、ありがとうございました。この講演から、皆さんのシステムやアーキテクチャをより耐障害性の高いものにし、特に最悪の種類の障害を引き起こす転換点での障害を回避するために、何か興味深く、実践できることを見つけていただければ幸いです。ありがとうございました。


※ こちらの記事は Amazon Bedrock を利用することで全て自動で作成しています。
※ 生成AI記事によるインターネット汚染の懸念を踏まえ、本記事ではセッション動画を情報量をほぼ変化させずに文字と画像に変換することで、できるだけオリジナルコンテンツそのものの価値を維持しつつ、多言語でのAccessibilityやGooglabilityを高められればと考えています。

Discussion