Redis
 Computer >> コンピューター >  >> プログラミング >> Redis

サーバーレスの課題:データベース接続問題とその解決策

サーバーレス向けデータベースを設計する上で、私たちにとって最大の課題は、リクエスト単位の課金を収益性のある形で実現できるインフラを構築することでした。Upstashはこれを達成したと考えていました。しかし製品ローンチ後、もう一つの重大な課題が浮上しました。それがデータベース接続です。

なぜサーバーレスでデータベース接続が問題になるのか

ご存知の通り、サーバーレス関数は0から無限大までスケールします。トラフィックが急増すると、クラウドプロバイダーは新しいコンテナ(Lambda関数)を並行して作成し、バックエンドをスケールアウトします。関数内で新しいデータベース接続を確立すると、あっという間にデータベースの接続上限に達してしまうのです。

一方、Lambda関数の外で接続をキャッシュしようとすると、今度は別の問題が発生します。AWSがLambda関数をフリーズさせる際、接続は閉じられません。その結果、多数のアイドル状態・ゾンビ状態の接続が残存し、システムへの潜在的な脅威となります。

この問題はRedis特有のものではなく、TCP接続に依存するすべてのデータベース(MySQL、PostgreSQL、MongoDBなど)に共通する課題です。サーバーレスコミュニティではserverless-mysqlのようなソリューションが生まれていますが、これらはクライアント側の対策に過ぎません。Upstashにはサーバー側を実装・保守できるという強みがあります。そこで私たちは、接続を監視し、アイドル状態の接続を排除することで問題の緩和を図ることにしました。

接続排除アルゴリズム

各データベースには、最大同時接続数として「ソフトリミット」と「ハードリミット」の2段階の制限を設けています。データベースがソフトリミットに達すると、アイドル接続の切断を開始します。ハードリミットに達するまでは新しい接続リクエストを受け付け続け、ハードリミットを超えた時点で新規接続を拒否します。

if( current_connection_count < SOFT_LIMIT ) {
    ACCEPT_NEW_CONNECTIONS
}

if( current_connection_count > SOFT_LIMIT && current_connection_count < HARD_LIMIT ) {
    ACCEPT_NEW_CONNECTIONS
    START_EVICTING_IDLE_CONNECTIONS
}

if( current_connection_count > HARD_LIMIT ) {
    REJECT_NEW_CONNECTIONS
}

なお、Upstashドキュメントに記載されている最大同時接続数の制限値は、このソフトリミットを指しています。

エフェメラル接続(使い捨て接続)

上記のアルゴリズムを導入した結果、全リージョンで拒否された接続数は大幅に減少しました。とはいえ、さらに安全を期したい場合は、アプリケーション側でも対策が可能です。接続を再利用する代わりに、関数内でRedis接続を開き、使用が完了したら都度クローズする方法です。

exports.handler = async (event) => {
  const client = new Redis(process.env.REDIS_URL);
  /*
    do stuff with redis
    */
  await client.quit();
  /*
    do other stuff
    */
  return {
    response: "response",
  };
};

このコードにより、同時接続数を最小化できます。「新しい接続の確立によるレイテンシオーバーヘッドはどうなのか?」という質問をよく受けますが、Redis接続は非常に軽量なことで知られています。

Redis接続は本当に軽量なのか?

Redis接続がどれほど軽量なのかを検証するため、ベンチマークテストを実施しました。比較対象は以下の2つのアプローチです。

  • エフェメラル接続: 接続を再利用せず、コマンドごとに新しい接続を作成して即座にクローズします。クライアント生成、ping()、client.quit()の合計レイテンシを計測します(下記コードのbenchEphemeral()メソッド)。
  • 接続再利用: 接続を一度だけ作成し、全コマンドで同一の接続を使い回します。ping()操作のレイテンシを計測します(下記コードのbenchReuse()メソッド)。
async function benchReuse() {
  const client = new Redis(options);
  const hist = hdr.build();
  for (let index = 0; index < total; index++) {
    let start = performance.now() * 1000; // to μs
    client.ping();
    let end = performance.now() * 1000; // to μs
    hist.recordValue(end - start);
    await delay(10);
  }
  client.quit();
  console.log(hist.outputPercentileDistribution(1, 1));
}

async function benchEphemeral() {
  const hist = hdr.build();
  for (let index = 0; index < total; index++) {
    let start = performance.now() * 1000; // to μs
    const client = new Redis(options);
    client.ping();
    client.quit();
    let end = performance.now() * 1000; // to μs
    hist.recordValue(end - start);
    await delay(10);
  }
  console.log(hist.outputPercentileDistribution(1, 1));
}

このベンチマークは、AWS EU-WEST-1リージョンで2種類の構成にて実行しました。1つ目はクライアントとデータベースが同一アベイラビリティゾーン内にある「SAME ZONE」構成、2つ目はクライアントがデータベースとは異なるゾーンで稼働する「INTER ZONE」構成です。データベースサーバーにはUpstash Standardタイプを使用しました。

その結果、新規接続の作成とクローズにかかるオーバーヘッド(エフェメラル方式)は、わずか75マイクロ秒(99パーセンタイル)であることが判明しました。インターゾーン構成でも80マイクロ秒と、ほぼ同等の結果でした。

しかし、同じテストをAWS Lambda関数内で実行すると、結果は大きく異なりました。特にLambda関数のメモリを最小の128MBに設定した場合、Redis接続のオーバーヘッドが顕著に増大し、最大6〜7ミリ秒のレイテンシオーバーヘッドが観測されたのです。

これらの結果から、Redis接続について以下の結論を得ました。

  • 適度なCPUパワーを持つシステムでは、Redis接続は本当に軽量です(t2.microでさえも)。
  • AWS Lambdaのデフォルト設定におけるCPUパワーは非常に貧弱で、Lambda関数の総実行時間に占めるTCP接続のコストが大幅に増加します。
  • デフォルトまたは最小メモリのLambda関数を使用する場合は、Redis接続を関数の外でキャッシュする方が有利です。

フリーズしたコンテナが引き起こすゾンビ接続問題

一部のAWS Lambda構成において接続に顕著なオーバーヘッドが存在すると判明したことを受け、私たちはLambdaにおける「接続再利用」についてさらなる検証を行いました。その過程で、まだ誰も報告していなかったエッジケースの問題を発見したのです。

問題が発生するまでのタイムラインは以下の通りです。

STEP1(0秒): リクエストを送信し、Lambda関数の外で接続をキャッシュします。

if (typeof client === "undefined") {
  var client = new Redis("REDIS_URL");
}

module.exports.hello = async (event) => {
  let response = await client.get("foo");
  return { response: response + "-" + time };
};

STEP2(5秒): AWSが短時間のうちにコンテナをフリーズさせます。

STEP3(60秒): Upstashにはアイドル接続に対して60秒のタイムアウトが設定されています。サーバーは接続を切断しますが、クライアントがフリーズしているためACKを受け取れず、サーバー側の接続はFIN_WAIT_2状態に入ります。

STEP4(90秒): Upstashサーバーが接続を完全に切断し、FIN_WAIT_2状態から抜けます。

STEP5(95秒): クライアントが同じリクエストを送信すると、ETIMEDOUT例外が発生します。クライアントは接続が開いたままだと認識していますが、実際には既に閉じられているためです。

STEP6(396秒): 最後のリクエストから5分後、AWSがコンテナを完全に破棄します。

STEP7(400秒): クライアントが再度同じリクエストを送信すると、今度は正常に動作します。コンテナがゼロから新規作成されるため初期化処理がスキップされず、新しい接続が確立されるからです。

つまり、AWSはフリーズしたコンテナを解凍して接続を再利用しますが、その接続はサーバー側で既に閉じられており、関数がフリーズしていたために通知を受け取ることもできなかった、というわけです。これは「Upstashがアイドル接続を排除するタイミング」と「AWSがアイドル関数を処理するタイミング」の間に存在する同期問題と言えます。AWSが関数を終了した後にのみアイドル接続を切断すれば、この問題は発生しません。

そこで私たちは、AWSが約300秒でアイドル関数を終了させるという前提のもと、Upstashの接続タイムアウトを310秒に変更しました。この変更後、問題は消失しました。ただし、AWSはアイドル関数の終了に関して透明性が低いため、今後も継続的にテストを行い、問題が再発しないか監視していく必要があります。

この問題は、serverless-mysqlライブラリで報告されている問題と非常によく似ています。同ライブラリのコメント欄では、ETIMEDOUT例外発生時にリクエストを再試行することが提案されていました。しかし再試行には2つの欠点があります。第一に、実際のネットワーク障害によって処理済みの書き込みリクエストがタイムアウトしていた場合、意図せず二重書き込みを起こす恐れがあります。第二に、失敗したリクエスト分の余計なレイテンシが発生することです。

GraphQL APIという選択肢

接続問題を根本的に回避する方法の一つが、コネクションレスなAPIを採用することです。UpstashはRedisプロトコルに加えてGraphQL APIもサポートしています。GraphQLはHTTPベースであるため、接続数上限の問題が一切ありません。対応コマンドの詳細についてはドキュメントをご確認ください。ただし、GraphQL APIはRedisプロトコルと比較して約5ミリ秒程度のレイテンシオーバーヘッドが発生する点にはご留意ください。

まとめ

Upstashは、サーバーレスアプリケーションにとって快適な開発体験を実現するべく、データベースをカスタマイズしています。今回導入したサーバー側アルゴリズムにより、AWS Lambdaが大量に生成する非アクティブな接続を自動的に除去できます。また、Lambda関数内でRedisクライアントを開閉することで接続数を最小化することも可能ですが、関数のメモリが1GB未満の場合はレイテンシオーバーヘッドが生じる可能性があります。

以上を踏まえた、サーバーレスユースケースにおける推奨事項は以下の通りです。

  • レイテンシに敏感なユースケースの場合(6ミリ秒でも大きいと感じるなら)、Redisクライアントを再利用してください。
  • 同時クライアント数が非常に多い場合(1,000超)、Redisクライアントを再利用してください。
  • レイテンシに敏感でない場合は、関数内でRedisクライアントを開閉してください。
  • 関数のメモリが1GB以上ある場合は、関数内でRedisクライアントを開閉してください。

フィードバックがございましたら、ぜひTwitterやDiscordでお聞かせください。

  1. サーバーレスデータベースのレイテンシー徹底比較:DynamoDB vs FaunaDB vs Upstash

    はじめに本記事では、典型的なWebアプリケーションのユースケースを想定し、3つのサーバーレスデータベース——DynamoDB、FaunaDB、Upstash(Redis)——のレイテンシーを実測データに基づいて比較します。検証のためにニュースサイトのサンプルを構築し、サイトへの各リクエスト時にデータベース関連のレイテンシーを記録する仕組みを実装しました。実際のサイトとソースコードは公開しているので、興味のある方はぜひ参照してください。各データベースには、New York Times Archive APIから収集した7,001件の記事(2021年1月分の全記事)を挿入済みです。さらに、各記事に

  2. LinkedInで人脈リストの公開をオフにする方法|接続収集からネットワークを守る

    LinkedInは、時に本当に煩わしく感じられることがあります。ほとんど面識のない人から次々と接続依頼が届いたり、まったく聞いたことのない人物につながりを求められたりします。中には、何かを売り込むためだけ――多くの場合、相手が提供しているサービスの宣伝のために――あなたのネットワークに加わろうとする人もいます。彼らはしつこく返信を迫ってくることもあります。また、接続申請の中にはリクルーターも含まれており、彼らはあなたに仕事を紹介したいわけではなく、あなたのプロフィールを目に留めて、自社の人材候補をあなたの会社へ売り込みたいだけというケースもあります。とはいえ、ビジネスの成功にはLinkedIn