スケール前提で Cloud Run へデプロイするときは最大インスタンス数を多め(150くらい)に設定しておいたほうがいいかも
Cloud Run の自動スケーリングに関するドキュメントを読んでいて、気になる箇所がありました。
最大インスタンス数の超過
通常の状況では、受信トラフィックの負荷を処理するために、新しいインスタンスを作成して、リビジョンをスケールアウトします。ただし、最大インスタンス数の上限を設定すると、トラフィック負荷を処理できるインスタンスが不足することがあります。その場合、受信リクエストは最大 10 秒間キューに入る場合があります。この時間枠内に、インスタンスがリクエストの処理が完了すると、キューに入れられたリクエストを処理できるようになります。この時間枠内で使用可能になるインスタンスがない場合、リクエストは失敗し、429 エラーコードが表示されます。
https://cloud.google.com/run/docs/about-instance-autoscaling?hl=ja#exceed-max
最大インスタンス数はデフォルトで100です。ですので実質、最大インスタンス数をどのように設定してもこの状況が発生しうることになります。気になるのは「トラフィック負荷を処理できるインスタンスが不足する」という状況です。Cloud Run は既存のインスンタンスに処理させるのか、それとも新しいインスタンスを起動してそちらに任せるのか、どのように判断しているのでしょうか。
- 最大同時実行数を超えた場合
- CPU利用率が上がった場合
前者はわかりやすいです。同時実行数は厳密に守られるようなので、1インスタンスあたりに設定されている同時実行数を超えた場合、新しいインスタンスが起動、そちらを待ってから振り分けられる動きになるはずです。
問題はCPU利用率が上がった場合の振り分けです。60%を超えるとスケールされるようですが、このときリクエストがどのようにジャッジされて新旧インスタンスへ振り分けられるのか、ドキュメントに明記されていません。
何を気にしているのか
どんなときにCloud Runが429エラーとするのか気にしています。 少々お金はかかってもいいので、できるだけ429エラーコードは発生させたくないです。最大同時実行数
と最大インスタンス数
の設定が、高負荷なときにどのようなスケールのふるまいを見せるかが不明瞭です。
そこで、Cloud Run のパラメータを変えて、負荷をかけたときのスケールやレイテンシについて検証することにしました。
検証
擬似的にCPU負荷が高いアプリを用意し、それをCloud Runへデプロイします。
サンプルアプリの用意
CPU負荷が重めの、起動に時間がかかるサーバーAPIを擬似的に用意します。Express で実装しました。
const express = require('express');
const app = express();
const port = 8080;
app.get('/', (req, res) => {
// 1億回ランダム計算
for (let i = 0; i < 100000000; i++) {
Math.random();
}
res.send('Hello World!');
});
app.listen(port, () => {
console.log(`Example app listening on port ${port}`);
});
FROM node:16-stretch-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
RUN yarn install --frozen-lockfile --production=true
COPY . .
# 起動に時間がかかるアプリを再現
CMD ["bash", "-c", "sleep 20 && node app.js"]
調整するデプロイパラメータ
4パターン用意します。最大同時リクエスト数=concurrency
、最大インスタンス数=max-instances
です。
- concurrency=1000, max-instances=150
- concurrency=1000, max-instances=5
- concurrency=50, max-instances=150
- concurrency=50, max-instances=5
たとえば concurrency=1000, max-instances=150
でデプロイするときのコマンドは以下のようになります。
gcloud run deploy \
heavy-cpu-express \
--quiet \
--region asia-northeast2 \
--cpu=1 \
--memory=128Mi \
--no-use-http2 \
--allow-unauthenticated \
--ingress=all \
--min-instances=1 \
--concurrency=1000 \
--max-instances=150 \
--source .
--concurrency
と --max-instances
の値を変えてデプロイします。
hey で負荷をかける
負荷テストツールのhey
を使って次のようなリクエストを投げます。
hey -n 500 -c 100 -q 100 -t 0 https://heavy-cpu-express-xxx-dt.a.run.app/
- -n 500: 合計500リクエスト投げます
- -c 100: 100リクエストごと同時に投げます
- -q 100: 秒間100リクエストのペースで投げます
- -t 0: クライアント側でタイムアウトさせないようにします
同時リクエスト数を100とすることで、Cloud Run 側の設定 concurrency=50
を超えるようにしています。
結果
concurrency=1000, max-instances=150
どちらのパラメータも限りなく大きくしたときです。
hey -n 500 -c 100 -q 100 -t 0 https://heavy-cpu-express-xxx-dt.a.run.app/
Summary:
Total: 255.2774 secs
Slowest: 142.9443 secs
Fastest: 1.4086 secs
Average: 31.7823 secs
Requests/sec: 1.9587
Status code distribution:
[200] 500 responses
考察
リクエストはすべて成功していますが、500リクエスト完了させるのに4分かかってしまいました。また、高負荷を検知してインスタンスが30ほど立ち上がっているのですが、50パーセンタイルのCPU利用率はほぼ0%です。つまり、多くのインスタンスはリクエストが割り当てられず暇をしており、負荷に偏りがあります。少ないインスタンスで、がんばって1000リクエストさばこうとしている結果とみえます。
concurrency=1000, max-instances=5
最大インスタンス数を極端に小さくしたときはどうなるでしょうか。
hey -n 500 -c 100 -q 100 -t 0 https://heavy-cpu-express-xxx-dt.a.run.app/
Summary:
Total: 100.0558 secs
Slowest: 18.7232 secs
Fastest: 1.9158 secs
Average: 10.4956 secs
Requests/sec: 4.9972
Status code distribution:
[200] 400 responses
[504] 100 responses
考察
100リクエスト分が 504 Gateway Timeout となってしまいました。負荷の高い処理を複数インスタンスで処理したいが、最大インスタンス数が5であるため処理しきれなくなったと考えています。さきほどと違い、50パーセンタイルのCPU利用率も100%までいっていることがわかります。
concurrency=50, max-instances=150
同時実行数を小さくしました。
Summary:
Total: 90.2311 secs
Slowest: 67.2446 secs
Fastest: 1.3976 secs
Average: 12.7915 secs
Requests/sec: 5.5413
Status code distribution:
[200] 500 responses
考察
すべてのリクエストが完了し、なおかつ全体で90秒と比較的早く完了しています。インスタンスあたりの担当分は50リクエストなので、超過分は別のインスタンスにまかせているということですね。インスタンスが50立ち上がっており、数でカバーしていることがわかります。アプリの起動にわざと20秒かかるよう遅延を入れていますが、このケースでは429エラーは発生しないようです。
concurrency=50, max-instances=5
どちらも小さくしたパターンです。
Summary:
Total: 132.4735 secs
Slowest: 72.3538 secs
Fastest: 1.4157 secs
Average: 16.5823 secs
Requests/sec: 3.7743
Status code distribution:
[200] 344 responses
[429] 156 responses
考察
ここでついに429エラーが発生しました。ひとつのインスタンスでさばけるリクエストは50までで、最大でも5インスタンスしか立ち上げられないため、超過分が429として返されているようです。超過分はただちに429エラーとなるわけではなく、ドキュメントにあった 受信リクエストは最大 10 秒間キューに入る場合があります
このキューに入れられるはずです。10秒待っている間にどこかのインスタンスの枠が開けばそこへリクエストし、10秒まってどこも開かなければ429エラーになると考えます。
整理
- 同時実行数
- 超過した分はCPU利用率にかかわらずただちに新しいインスタンスへ振り分けられる
- 1インスタンスあたりで可能な限り最大同時実行数まで処理しようと努力する
- CPU利用率
- 同時実行数にかかわらず、たしかに60%を超えるとスケールする
- ただし、負荷の偏り上等、できるだけCPU利用率が均一になるようなロードバランシングはしていないように見える
- 429エラー
- 処理中のリクエストが [最大同時実行数 × 最大インスタンス数] を超過しており、待機キューで10秒待っても振り分けられない場合に発生する
- 同時実行数を超過して、新しいインスタンスの起動を待っている間は、10秒を超えても429エラーにはならない。ただしユーザーリクエストは起動を待っており、レイテンシに影響はある
どういう設定にすればよいか
得られた検証結果から、アプリをCloud Runへデプロイするときのパラメータについて案をだします。
CPU利用率70%〜80%になるような最大同時実行数を攻める
リクエストの振り分けは最大同時実行数ベースが濃厚です。同時実行数を小さくして、リクエストが増えたら早めにスケールさせる戦略も良いと思いますが、リソース効率を考えるとレイテンシやメモリ利用量に影響がないギリギリのところまでCPU利用率を上げるような同時実行数が理想です。こればかりは、実際にデプロイしてメトリクスを観察するしかないと思います。
最大インスタンス数は理由がなければ大きめに設定しておく
Cloud Run 全体のキャパシティは、[最大同時実行数 × 最大インスタンス数]で表せます。これを超えた分が、10秒待機キューに入り、待っていても処理されなかったら429エラーになります。考えかたによっては、システム全体の負荷に上限を設ける防御装置でもあります。一方でもし、「多少お金はかかってもいいから、429エラーを発生させたくない」という前提があるならば、最大インスタンス数を大きめに設定しておくことをオススメします。デフォルトは100ですが、迷ったらそれより大きめの 150~200 くらいに設定し、メトリクスを観察するのが良いと思います。
また、Cloud Runのアプリを利用するクライアントは、429エラーが発生したときのユーザーへの見せ方を考えておくと体験の向上につながりそうです。
大量に立ち上がりっぱなしになるのが怖いときは
最大インスタンス数を大きめに設定すると、負荷をさばけるようになる一方、料金がかかります。そこで、平常時の負荷から状況がかわったときに、気付けるようにしておくと多少安心できます。こちらの記事を参照ください。
まとめ
Cloud Run のデプロイパラメータを変えてスケールの動きを検証してみました。「なんだか The request was aborted because there was no available instance.
ってエラーが出るなあ」というときは、ユーザー体験とお金を天秤にかけて最大同時実行数および最大インスタンス数を調整してみてください。
Discussion