コンテンツに移動
サーバーレス

フルマネージド型のアプリケーションで長期的な費用管理を最適化

2020年6月16日
https://storage.googleapis.com/gweb-cloudblog-publish/images/Serverless_computing.max-2600x2600.jpg
Google Cloud Japan Team

※この投稿は米国時間 2020 年 6  月 9 日に、Google Cloud blog に投稿されたものの抄訳です。

数週間前に、Google Cloud のフルマネージド型サーバーレス コンピューティング プラットフォームである App Engine、Cloud Run、Cloud Functions で実行するアプリケーションの費用を抑える具体的な方法を複数ご紹介しました。しかし、最大インスタンス数や予算アラートを設定するなどのすぐに実施できる対策のほかにも、フルマネージド型サーバーレス アプリケーションの全体的な効率を向上させる一般的な最適化手法がいくつかあります。たとえば、CPU 使用率と RAM 使用量を最適化する、コールド スタート時間を改善する、ローカル キャッシュを実装するといったことで、全体的な費用を削減できるようになります。詳しくは、記事全文をご覧ください。

CPU と RAM の最適化

通常、アプリケーションで必要なインスタンス数に最も関係するのが CPU 使用率です。リクエストによって使用される CPU が多いほど、必要となるインスタンス数が多くなり、費用も高くなります。また、ガベージ コレクションの RAM(ヒープ)を過剰に使用するランタイムはガベージ コレクションの過度な実行につながり、CPU 使用率がさらに増えることにもなります。

CPU 使用率で重要となるもう一つの側面が「CPU の座礁」です。この状態は、特定のインスタンスで、リクエストが多すぎるために過負荷となってこれ以上リクエストを処理できなくなり(同時実行数の設定が低いことなどが原因)、CPU リソースが十分に使用されないままインスタンスがさらに作成されている場合に発生します。また、I/O ボトルネックによるリクエストのバック プレッシャーが原因で発生することもあります。CPU の座礁は通常、全体的な CPU 使用率が低いのが特長です。この問題を軽減するためにお試しいただける手法がいくつかあります。

  1. 同時実行数を手動で設定している場合は、その値を増やす。GCP のフルマネージド サービスは、デフォルトで同時実行の最適化を試みます。ただし、同時実行数に小さい値を手動で設定した場合は、その設定が最適かどうか確認することをおすすめします。現時点で Cloud Functions にはマルチ同時実行モードがありません。ここで紹介する他の手法で十分な効果がない場合、CPU 使用率の低い関数を Cloud Run に移動することも検討してみてください。それを容易にするため、Google Cloud では最近、関数をビルドして Cloud Run にデプロイする一連のオープンソース フレームワークとビルドパックを公開しました。

  2. 低速な I/O オペレーションをオフロードする。場合によっては、Pub/SubCloud Tasks などのキューシステムを使用して、低速な I/O オペレーションを非同期で処理することもできます。そうすることで、これらの非同期タスクの全体的なレイテンシが高くなりますが、サービスの全体的なスループットは改善されます。この好例がメールの送信です。メール送信リクエストのオペレーションが低速な場合でもその実行をブロックする必要はなく、代わりに、わずかな遅延を生じながらメール送信を非同期で処理できます。

  3. 低速の I/O オペレーションを回避する。キャッシュを実装することで、リクエストのクリティカル パスから低速の I/O オペレーションを取り除くこともできます。キャッシュの実装については後述します。

  4. I/O オペレーションのパフォーマンスを改善する。低速の I/O オペレーションが CPU 使用率の低さの原因かどうか、はっかりわからないこともあるでしょう。その場合は、カスタム指標を使用してこれらの I/O 呼び出しに関するインストゥルメンテーションを追加すると、詳しく把握できる可能性があります。

コールド スタートの最適化

「コールド スタート」は、アプリケーションへのリクエストが既存のインスタンスでは処理できず、新しいインスタンスの作成が必要な場合に発生します。これが起こるのは、サービスのインスタンスがゼロの場合(0-1 スケーリング)と、トラフィックが急増し、負荷に対応するためにインスタンスを増やす必要がある場合(1-N スケーリング)です。

コールド スタートの総所要時間を減らすと、コールド スタートに関連する費用とともに、コールド スタートの実行回数も削減できます。これは、新しいインスタンスが実行可能になるまでの時間が短縮するので、特にトラフィックの急増時に必要なインスタンス数が少なくなるためです。コールド スタートの時間を改善するには、まず、ローカル(開発環境)で実行したときのコールド スタートの時間を確認します。絶対的な時間は異なるかもしれませんが、ランタイム スタートアップ全体にコードが占める割合(言語ランタイムとは異なる)は、コールド スタートのオーバーヘッドの良い指標です。

以下は、サーバーレス環境でアプリケーションを設計する際の、コールド スタートの一般的な注意点です。

過剰な CPU 使用率、起動時の低速な処理

「グローバル スコープ」で実行されるコード(ランタイムの読み込み時など)は、コールド スタートの時間に影響します。グローバル スコープ内に、アプリケーションの起動時に CPU を多く消費するタスクや長時間実行されるタスク(グローバル状態の読み込みや低速データベースからの状態の読み込みに関連する重い計算など)がある場合、全体的なアプリのパフォーマンスと費用に悪影響を及ぼすことがあります。以下のような方法でこの影響を軽減できます。

  •  グローバル スコープ内で CPU を大量に消費するタスクや時間のかかるタスクのうち、可能な部分を減らします(常に可能とは限りません)。
  • タスクを小さなオペレーションに分け、リクエストのコンテキストで「遅延読み込み」を行います。

なお、コードがグローバル スコープで実行されていると、多くの場合、インスタンスの全体的な起動が遅れます。CPU の大部分がアイドル状態である I/O バウンドのオペレーションでも、インスタンスはこのオペレーションが完了するまで「使用可能」であることを報告しません。つまり、同時実行用に構成されていてもリクエストを処理できないということです。これにより、トラフィックが急増すると必要以上に多くのインスタンスが新しく作成されることがあります。

読み込んでいます...

メモリ不足エラーなどのプロセスのクラッシュ

アプリケーションの予期しない障害によりランタイム全体(ルートプロセス)が異常終了(クラッシュ)すると、通常は、それに伴いコールド スタートを行い、障害が発生したインスタンスを新しいインスタンスに置き換えます。インスタンスを自動的に再作成すると、アプリケーション全体の信頼性を改善できる一方で、障害自体が見つかりにくくなります(インスタンスがクラッシュしたことに気づかない可能性があります)。多くの場合、これは断続的または予期しないレイテンシの増大を招きます。Google では、プロセスのクラッシュに関連するメッセージを Cloud Logging でモニタリングし、クラッシュを示すログのアラートを構成することをおすすめしています。これを行うには、ログベースの指標を作成し、Cloud Monitoring を使用してそれらの指標に基づくアラートを作成します。

過剰または未使用の依存関係

多くの言語では、慣用的にライブラリの依存関係をグローバル スコープで宣言します(例: Node.js での require ステートメントや import ステートメント)が、これにより、コールド スタート時に悪影響を及ぼす場合があります。特に、解釈言語におけるライブラリの依存関係やネイティブ ライブラリの読み込みにより、CPU 使用率が大きくなり、ファイル システムでの操作が増大します。どちらも、スタートアップ パフォーマンスに悪影響を及ぼします。

読み込んでいる依存関係がすべて使用されていることを確認します。必要に応じて、すべてのパスの依存関係をグローバルに読み込むのではなく、特定の依存関係の初期化を関連するコードパスの「リクエスト スコープ」に移動する(つまり、スタートアップ中ではなくリクエスト中に読み込む)ようにします。重い依存関係の参照が、コールド スタートに影響することはほとんどありません。依存関係が小さなサブセットのリクエストでのみ使用されている場合でも同様です。

ローカル キャッシュの実装

特定のデータを対象とした基本的なローカル キャッシュを実装すると、劇的な費用削減とパフォーマンス向上を実現できることがあります。Memcache や Redis などの高速のソリューションでも、アクセス頻度が高ければ、かなりのオーバーヘッドが発生します。このような場合、頻繁に生成されるデータや頻繁に読み込まれるデータのコピーをローカルのメモリ内に(おそらく簡単な有効期限ポリシーとともに)保持すると、大幅な改善を達成できることがあります。

ローカル キャッシュの対象となるデータの候補を見つけるには、通常、同じ操作が頻繁に発生する状況を調査します。以下の特徴があるデータは、一般的にキャッシュの対象となる可能性が高いものです。

  • 外部システムまたはデータベースから頻繁に読み込まれる
  • ほとんど、またはまったく変更されない
  • 最新である必要がない
  • 頻繁に繰り返される、べき等のアウトバウンド操作(単一のデータベース項目の更新など)

キャッシュ可能な操作の例を次に示します。

  • ウェブアプリの全ページで使用される画像 URL を取得する
  • 10 秒間の遅延が許容されるゲームのリーダーボード
  • ページの読み込みごとに一連のプレーヤーの平均スコアを計算する
  • スーパーマーケットの全在庫商品の平均価格を計算する
  • 一連のワーカーが、バッチエントリと Cloud Task の重複タスクを生成し、バッチの起動を通知する。タスク名をキャッシュすると、リクエストの重複送信を減らすことができます。

変更の効果を測定する

加えた変更が全体的な改善につながるのか、それとも状況の悪化につながるのかを判断するのは容易なことではありません。Google Cloud のサーバーレス プロダクトならすぐに使用できる Cloud Monitoring が備わっているため、変更の影響を評価できます。また、カスタムの Cloud Monitoring 指標により、アプリケーションの動作に対する詳細な分析情報も提供できます。

全体的な改善があることを確認したうえで、必ずしもすべてのユーザーに変更をロールアウトするとは限らない場合があります。ロールアウトの信頼性を高めるには、コントロールとテストグループの間で受信トラフィックを分割することをおすすめします。Cloud Run では、変更を新しいリビジョン(テスト)にデプロイし、トラフィック管理を使用してトラフィックの一部(例: 50%)をそれに送信して、残りのトラフィックをコントロール グループに保持します。これら 2 つのリビジョンはその後、Cloud Monitoring の Metrics Explorer でモニタリングされ、変更に実際に効果があるのかどうかが判断されます。変更によって他の測定に悪影響を与えることなく状況が改善されることを確認したら、トラフィックの 100% を変更が含まれているリビジョンに移行できます。また、App Engine のバージョン間でも、同様のトラフィック分割が行えます。

ゲームのリーダーボードを例に挙げて考えてみましょう。ユーザー向けの機能では、リーダーボードの更新が約 1 分ほど遅れることは許容されますが、この場合、タイムアウトのあるキャッシュを実装すると、リクエストのレイテンシが短縮され、場合によっては CPU 使用率も削減されます。これにより、1 つのインスタンスで処理できるリクエストが増えるため、結果的に料金が低くなります。また、キャッシュのヒットやミスに対するカスタム指標を使用することで動作を理解でき、標準指標を使用することでローカル キャッシュの効果を確認できます。トラフィックを 50% にした、キャッシュが有効なバージョンのリーダーボードをデプロイし、以前のバージョンと比較すると、同一条件での比較が可能になるため、キャッシュ変更の有効性を評価できます。

サーバーレスをうまく活用する

Google Cloud のサーバーレス コンピューティング プロダクトは、構成に適したデフォルト値を選択し、アプリケーションのスケーリングや負荷分散を自動的に行いますが、ワークロードはそれぞれ異なります。上述の手法を使用することで、アプリケーションを調整して最適化し、最大限のコスト パフォーマンスを得ることができます。フルマネージドの Google Cloud Platform でのアプリケーション設計に関するより詳細な情報については、サーバーレス ブログチャネルにご登録ください。

このブログ投稿に協力してくれたソフトウェア エンジニアの Nicholas Hanssens、プロダクト マネージャーの Steren Gianini、ソフトウェア エンジニアの Ben Marks、プロダクト マネージャーの Matt Larkin に感謝します。

- By プロダクト マネージャー Jason Polites、ソフトウェア エンジニア Greg Block

投稿先