最近、数百から数千単位の処理を、順次処理するのではなく並列に処理することでリソースを有効に活用して処理時間を短縮する方法を考えて実装を行った。スレッドや goroutines のようなプログラミング言語内の仕組みで作ったのではなくて、すべてフルマネージドのクラウドで行ったのだが、その際に謎の挙動に苦しんだのでそのメモを残しておく。
基本的な仕組み
以下を利用した。
- Cloud Functions
- Cloud PubSub
- Cloud Firestore
PubSub Functions で fan-out を行った、という表現になるらしい。
もう少し細かいことを言うと並列に function を呼び出すために、
- 順次処理のループを回す function の中で処理そのものを行わずに次の function を起動する message を publish する
- 1 から起動された function が個々の処理を別々に実行する
という構造を n段階(徐々に 2 相当の function が多くなる)に渡って作るというもの。
気をつけていたこと
- 終了条件が分かるようにしつつ多重処理を避ける
Cloud PubSub は at least once で message を配送するらしいので、並列に実行しようとしたら一部の処理が2回走ってしまうことがあり得るなと考えていた。そのため
- Firestore に処理対象のリストを作っておく
- 処理を始める前にリストから取得する
- 処理が終わったらリストから削除する
という実装にした。処理対象リストが空になったらおしまい、次の処理へ進む、という格好だ。
※ 今回は個々の処理全部に対して lock 用のデータを書き込むことはしなかった。さすがにそんなに瞬時に多重になってしまうことはないだろうと踏んでいたのと、実装を端折って結果を見れるようにするのを急いだため。結果、多重配信はなかった。ただし、function の起動と処理が限界を越えると多重配信が起きているかのような挙動になった。
当初ミスっていたこと
PubSub は速い。めちゃくちゃ速い。local の emulator は全然速度が出なくて並列度も2並列くらいしか動かないんだけど、GCP 上の PubSub は何十倍も何百倍も速い。この結果何がミスとして見つかったかというと、
Firestore の読み書きがちゃんと transaction になっていなかった
アホかと思われるかもしれないけど、実装を急いだのと local ではがっちりテストコードでガードしながら実装をしていたけど PubSub emulator が遅すぎて transaction を使っていなくても問題なく動いてしまっていた。その処理が軒並み壊れた。今回のコードは Node.js で書いていたので async/await メンドクセーと思いながら頑張っていたんだけど、そもそも Firestore に transaction を使わせていなかったのでどう気をつけて await していても無駄だった。副作用(?)として write を batch で処理するように書き換えたら個別に write するより逆に速くなったので嬉しかった。
この部分は 外部APIだらけのコードをできるだけTDDっぽく作った話 - あーありがち(2020-03-01) に書いたようにちゃんとレイヤーを分離していたので、対応コストは全体の中では微々たるものだった。原因さえ分かれば。
PubSubは速すぎてBackground Functionsの限界を突破する
割り当て | Cloud Functions のドキュメント | Google Cloud
Cloud Functions は実はものすごい数の request に耐えられるようになっている。少なくとも HTTP function は。ただし background function はいろいろ細かい制限があって、簡単に言うと
同時呼び出しは最大1000までだが、個々の関数の処理時間、中で処理するイベントの数が増えるとそのレートは下がる
ということになっていて、じゃあ具体的にどの程度までイケるのかは動かしてみないと分からない。
※ なお、PubSub の publish 側は恐らく同時 3000 まではいけます。(シングルリージョンで)
割り当てと上限 | Cloud Pub/Sub ドキュメント | Google Cloud
限界を突破すると何が起きるのか
function を起動できなくなる問題よりは function が利用するリソースをちゃんとつかめなくなる、という現象がいろいろ観測された。1
- Could not load the default credentials Error
- Cloud Functions や GAE 上のアプリケーションはその実行中のサービスアカウントの権限情報を Application Default Credentials と呼ばれる方法で取得するが、これがちゃんと機能しなくていきなり死ぬ
- 大量の function を起動している場合、コールドスタートせずに以前に取得した権限情報のまま上の Error が起きずに進むケースが多いが、結局リソースは確保できないので、例えば Firestore から何か取得した結果を確認するようなコードは全部失敗する
- データが存在しないのではなくデータを取得する処理が動くためのリソースが確保できていないのだが、そういうエラーとしては捕捉するのは難しい
- Process exited with code 16
- function がすでに header を送ってるよというエラーっぽいが、エラーは起きたがちゃんと終了を返せずにさらにエラーが立て続けに送られている?
- Error: 6 ALREADY_EXISTS: Document already exists
- これは Firestore のエラーだが、限界を突破した結果 transaction を正しく維持できずに一部二重にデータを保存しようとしてエラーが起きているようだ
- Error: 14 UNAVAILABLE: The datastore operation timed out
- これも Firestore で、これはだいぶ明らかな異常だというのが分かりやすい
今回自分が触った中ではだいたい上のようなエラーになった。
上のようなエラーが fan-out した function それぞれで起きるのでものすごい勢いでエラーが増える。何かが根本的におかしいのは分かるのだが、いずれにせよ原因の特定に繋がるようなメッセージはほぼ得られず、限界を越えるとめちゃくちゃ異常な状態になるので、限界を知っておくことと限界への対処方法を知っておくのは超大事だよ という至極当たり前の感想を抱いてこのメモを書いています。今思うと笑えてくるが、当時は終わらない問題にだいぶ世界が濁って見えていた。
限界を突破しないようにするために何が必要か
PubSub で message を publish する処理に対して一度に publish する数をある程度以下に収めるようにする細工が必要になる。
割り当て | Cloud Functions のドキュメント | Google Cloud
具体的には 1 処理に 10s 掛かる function は単純に同時に 100 までしか起動できなくなる。さらに処理するイベントが多いとどんどん減っていく。ということは fan-out のために publish する側は subscriber function のコストを知ってないといけない。おおう、そんな。decoupled とはなんだったのか。
ということで今回やったのは
- 待ち行列を設定する処理で Array を chunk に分割(ここで呼び出す function のコスト計算が必要になる)
- chunk の中のアイテムについてどんどん publish
- 少なくとも 1000ms 以内の処理にいろいろな制限があるので 1000ms sleep する
- これを chunk がなくなるまでくり返す
という方法。
そして当然ながら、この fan-out を実行する function は他の function よりも実行時間が伸びやすいので timeout には気をつける必要がある。
なんという職人芸。結局インフラの事情に精通した職人は必要だなぁ、おい。ということが分かりましたとさ。
別解
全部 log run process で処理する
たぶんこれが最も安定する中で最も実行しやすい。ただし GAE Standard Env. などフルマネージドに寄せないと functions の代わりにするにはインスタンスやコンテナイメージの管理が必要になるし、料金も割高になりやすい。
料金については GAE Standard Env. の Basic Scale が実行時間は 24h まで耐えられ、かつ 0 instances まで scale down できるので、処理時間が安定しない function の移行先として最もよさそう。
long run process で PubSub を pull で受ける
上と微妙に違うのは PubSub の部分の実装をそのまま活かす点。ただし PubSub function では意識する必要のない pull の処理は追加で書かないといけない。インフラ管理が高コストになりやすいのは上と同じだし、logrun process の中に閉じてしまえば必要なインフラを一つ減らせるので、処理の数が少ないうちは上の方が簡単でよさそう。
投げっぱなしでよい処理が何種類も増えるようだと PubSub を使っておくメリットが出てくると思う。
long run process にせずに function で pull で受ける
これも可能は可能だけど、topic や subscription の管理がアプリケーションコードの中に現れて面倒になるうえに結局同時に扱えるリソースの上限は変らないので、メリットはほとんどない気がする。
PubSubではなくCloud Tasksで実行時刻を調整 & HTTP Function化
Cloud Functions には cold start 問題があり、deploy 直後はさらにパフォーマンスが落ちることがある。こうなると fan-out の数のチューニングが一時的に意味をなさなくなってしまう可能性がある。
この場合、PubSub ではなく Cloud Tasks を利用して HTTP Function に変換しつつ細かく実行時刻を設定することで、 fan-out する Function の実行時間を伸ばすことなく fan-out された Function が Background Function の限界を回避しつついい具合にタイミングを調整して実行することができる。
ただし Cloud Tasks には emulator もなく、サーバ側は最悪 OpenAPI の情報を利用して mock サーバを立てることはできるだろうけど、GUI も CLI も管理ツールが対応していないので、そこも作り込む? いやーだいぶヘビーに使い倒さないとオーバーキル感がすごい。Tasks は GCP のものを使いつつ、Function 自体は HTTP Function なので HTTP tunnel を掘って作っていくのが現実的かな。
安定性第一ならこれがよいと思うけど、少なくとも PubSub だけに閉じるよりはどうしても開発コストは大きくなりそう。
cf.
- Using Cloud Tasks to trigger Cloud Functions
- Choosing between Cloud Tasks and Pub/Sub | Cloud Tasks Documentation
- Improving Cloud Function cold start time - Colt McAnlis - Medium
もしかしたら起動されないこと自体は何も問題として現れないのかもしれない。 ↩