トップ 最新 追記

2020-03-01 [長年日記]

_ 外部APIだらけのコードをできるだけTDDっぽく作った話

画像の説明

基本的に外部 API 呼び出ししかないコードをいろいろ分解してだいたい TDD っぽく作ったよという話。今回はフレームワークに頼れる部分がなく、全体のパーツを自覚的に整理しながら作り上げていく必要があったので頭の整理をしておく。自分のやっていることはゼロかイチかではなく、概ねどこかしらにテストコードはあるけど網羅はしていなくて、だいたい今回のような感じで進めているような気がする。

だいたいの流れ

クリーンアーキテクチャっぽい円の外側から攻めていった。何しろ思ったように API が動くのか動かないのか分からないと作りようがないので、まずはそこから。*1

  • (非TDD)実際の API の動作確認
  • (非TDD)API の response を再利用しやすいようにある程度パターン別に収集(テストコードを利用してアプリケーションの一部だけを動かす)
  • (TDD)API アクセスのコードの一部を stub out しながら API Client 部分を実装
    • できる範囲で API の動作を mock サーバや emulator で閉じ込めて「分離」
  • (TDD)API に依存しつつアプリケーション全体の文脈を強く表すボキャブラリーとインターフェイスを用いた Facade 的なものを用意*2

ここまでは stub / mock / emulator でどうにかなった。

気をつけていたのは Gateway 的な部分で、例えばストレージ系の API を呼ぶ必要のある API Client の部分は Repository パターンのような汎用的なインターフェイスを用意しつつ、もう一つ内側の Gateway の部分では Repository をあまり意識させず、単にやりたいことをメソッドとして持つようにしておいた*3

どうにかならない部分は処理の流れ的にほぼ後行程にしかないので文字通り後回しにした。これはそうなるようにアーキテクチャを選んでいたからできたことでもある。

そしてようやくアプリケーション全体の流れ。

  • (非TDD) Application Logic (API の初期化と処理全体の流れ)の実装。最低限の初期化周り以外はほとんど自動テストしていない。

今回これで回せた理由は以下の二つだと思っている。

一つめは、そもそも今回はアプリケーションをサーバレスのピタゴラスイッチで実現することにしたこと。一つ一つのアプリケーションの役割は十分に小さいのでミスが入りにくい。

もう一つは、流れに関するロジックを図で言うところの Gateway か Domain に押し付けるようにしたから。

例えばアプリケーションロジックのように見えるものでも API 呼び出しの比率が高いものは Gateway の中に閉じ込めることで API 側で回せている TDD の中で完結させることができるならできるだけそうしたのだ。全体の流れとしては何らかのメソッドを呼んでいるだけにする。

逆に完全にプレーンなオブジェクトだけで表現可能なものは、

  • (TDD)プレーンなオブジェクトに対して API に依存するオブジェクトからメソッドを通じて値オブジェクトを渡す形で独立させてロジックを実装

していった。

この部分が最初から見えていてプレーンなオブジェクトに抽出できていたらドメイン駆動っぽいのだけど、残念ながら自分の場合は API call 部分からどうやって testable にするか順番に引き剥がしていって、全体の流れのロジックを書く段階になって完全に逆サイドにロジックを押し付けるという戦術に結果的になった。

まとめ

アプリケーション全体を以下のように分解して考えることで、外部 API call だらけのアプリケーションでも開発の中盤はだいたい TDD できることが分かった。

  • そもそものアプリケーションの流れを小さく分解
  • API call 部だけの TDD
  • API call とアプリケーション全体の流れの中間に入る部分の TDD(Gateway)
  • 一切の API call と距離を置くプレーンなオブジェクトでロジックを記述する部分の TDD

今回は手元で再現できる emulator や mock サーバ、抽象化するライブラリのある API を自分で選ぶことができたが、そうでない場合はテスト環境を TDD のための装置として

  • まずは deploy と環境構築の自動化

からやる必要があるだろう。それすらできない場合は…どうするのがいいんだろう。

Tags: TDD

*1 逆にここに一切不安がないなら中心からやってもよいと思う

*2 クリーンアーキテクチャっぽく言うと Gateway のようなもの

*3 例えばの話であって、Gateway 相当の部分が Repository パターンの方がよい場合もあるだろう。


2020-03-02 [長年日記]

_ PubSub Functionのfan-outの限界

最近、数百から数千単位の処理を、順次処理するのではなく並列に処理することでリソースを有効に活用して処理時間を短縮する方法を考えて実装を行った。スレッドや goroutines のようなプログラミング言語内の仕組みで作ったのではなくて、すべてフルマネージドのクラウドで行ったのだが、その際に謎の挙動に苦しんだのでそのメモを残しておく。

基本的な仕組み

以下を利用した。

  • Cloud Functions
  • Cloud PubSub
  • Cloud Firestore

PubSub Functions で fan-out を行った、という表現になるらしい。

もう少し細かいことを言うと並列に function を呼び出すために、

  1. 順次処理のループを回す function の中で処理そのものを行わずに次の function を起動する message を publish する
  2. 1 から起動された function が個々の処理を別々に実行する

という構造を n段階(徐々に 2 相当の function が多くなる)に渡って作るというもの。

気をつけていたこと

  • 終了条件が分かるようにしつつ多重処理を避ける

Cloud PubSub は at least once で message を配送するらしいので、並列に実行しようとしたら一部の処理が2回走ってしまうことがあり得るなと考えていた。そのため

  1. Firestore に処理対象のリストを作っておく
  2. 処理を始める前にリストから取得する
  3. 処理が終わったらリストから削除する

という実装にした。処理対象リストが空になったらおしまい、次の処理へ進む、という格好だ。

※ 今回は個々の処理全部に対して 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 には気をつける必要がある。

なんという職人芸。結局インフラの事情に精通した職人は必要だなぁ、おい。ということが分かりましたとさ。

別解

long run process で PubSub を pull で受ける

たぶんこれが最も簡単。なんだけど、そうすると function だけでは完結せず、VM インスタンスやら Docker イメージやらの管理が必要になる。たぶん。あと処理する数によるけど料金も上がりやすいと思う。

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.

Tags: GCP

*1 もしかしたら起動されないこと自体は何も問題として現れないのかもしれない。


2020-03-03 [長年日記]

_ gcloudコマンドでFunctionsを利用しているプロジェクトのCloudBuild APIを有効化する

なんか blog とか発表出ないんかなと思って待ってたんだけど出ないっぽいのでリンクはないけど、Google Cloud Functions が deploy の仕組みに Cloud Build 使うことにしたからちゃんと API 有効にしとけよと言われたので対応しますた。

Functions APIを利用しているプロジェクトの一覧を作る

for i in `gcloud projects list | awk '{if (NR > 1) print $1}'`
do
  gcloud services list --project $i 2>&1 | awk -v proj=$i '/Functions/ {print proj}'
done

対象のプロジェクトのCloudBuild APIを有効にする

for i in `cat projects.txt`
do
  gcloud services enable cloudbuild.googleapis.com --project $i
done
Tags: GCP

2020-03-04 [長年日記]

_ 安心、安全なPubSub Functionの作り方

PubSub Function でピタゴラスイッチを作り安定稼働させることができたので、作る前から気をつけていたことや実際に動かしてみて気づいたことなどを羅列しておく。

全体

安易に例外で死んではいけない

これは一般的な Web アプリと一緒と言えば一緒。

  • 手元の開発では例外で死ぬと PubSub に ack が返らず retry されるが、例外で死ぬのをくり返すだけ
  • production (GCP) の場合は PubSub には nack は返らないので PubSub からは retry されない。これを retry するには Function そのものの設定で retry するしかないが、無条件 retry は上と同じ問題を抱えている

「例外」だけど例外的にではなく計算して死ぬ、くらいの気持ち。

cf. バックグラウンド関数の再試行 | Google Cloud Functions に関するドキュメント

機能をできるだけバラす

あくまで Function なので一つ一つの機能はできるだけシンプルに。なんらかのストレージや PubSub を利用しつつ次の Function を call する程度に留めるようにしておく。

バラしたFunctionを個別に実行、スキップできるようにしておく

何らかの仕組みを作っていると、

ここまでの処理は OK だけどここからが NG

みたいな状況は非常によくあるので、常に最初から全部を実行するしかない、という状況は避けておくと開発効率は比較的よくなる。

APIアクセス専用のclassだらけにする

上のバラし方はシーケンスでの分解だけど、もう一つのバラし方の基本はレイヤーで分けること。Function を書く際には OS で提供されている機能、言語内の標準機能だけを使うケースは稀で、ほとんどのケースで GCP の用意した API や外部の API を叩く処理が多く含まれる。ということは言い方を変えると API アクセス部分で死ぬ可能性だらけである。

この部分をそれぞれ専用の class に分割しておくとよい。

  • これらの class で専用の retry の処理を仕込む
  • 失敗の拾い方を個別に決めておく
    • 例えば HTTP GET で 404 が「あり得る」なら「成功」として処理するなど
  • mock や emulator が利用できるなら積極的に利用できるようにする
  • できるだけ TDD を回す

API 頼みのコードを そのまま mock / emulator なしで書くと production 環境依存になりやすい。production 環境依存だと 1) 書いて、2) 実行して、3) テスト、デバッグする、までのサイクルが大きく重たくなってしまうので、開発速度が上がりにくく保守性も悪くなりやすいので、これを避ける。

外部APIだらけのコードをできるだけTDDっぽく作った話 - あーありがち(2020-03-01) なんかも参照してもらえれば。

必要なリソースをできるだけ早期に確認しておく

周知の通り Function にはメモリにも処理時間にも制限がある。そしてこれらの制限に引っかかった場合の timeout などの異常終了はアプリケーションレベルでは捕捉できず、GCP 内部の status では ok 以外の情報として取得できても log は debug level のものしかなく、PubSub 側でも異常を検知できない。

そこで Function の Execution time や Memory usage をちゃんと確認しておく必要がある。もちろんデータ量が増えた際に変わってくる部分はあるにせよ、ある程度設定可能なリソースの計画を立てておく必要があるし、場合によっては設計の変更も行う。

監視重要

Cloud Functions や AWS Lambda などのサーバレスサービスはとにかく「めちゃくちゃ簡単にプログラムが動かせる」かのようなイメージを抱いてもらおうと頑張っているが、サーバレスでは伝統的なサーバと違ってほとんとすべての情報が /var/log 以下から辿れるといったことはなく、後から何かを知ろうにも「何を使ったら何を知ることができるのか」を分かっていないといけない。

そこで Stackdriver のサービス群への習熟が極めて重要になる。

  • Stackdriver Monitoring で ok 以外の status が発生していないか
  • 同じく Memory usage や Execution times の数値が異常になっていないか
  • 実行頻度、実行回数に異常がないか
    • 多すぎる場合もそうだけど、例えば1日1回の定期実行が動いていません、も異常。

これらを適切に監視できるよう設定しておくと安心。

Tags: GCP