はじめに
これは Angular Advent Calendar 2020 7日目の記事 です。
こんにちは (。・ω・。)
Angular と Firebase で CGM サービス(一般ユーザー投稿型サービス)を開発している者です。
2017 年に Angular で新規サービスを立ち上げて以来、毎年その年に身につけた主な知識をサービス規模と併せて体系的にまとめてきました。
| 年| 記事 | 月間PV
|:----------:|:-----------:|:------------:|:------------:|:------------:|
| 2017 | AngularFire V5 + Firestore 環境で Virtual Scroll を導入する | - |
| 2018 | SSR の知識ゼロから始める Angular Universal | 4,000万 |
| 2019 | Angular と Firebase で月間PV1億超えの PWA を作った話 | 1.2億 |
| 2020 | 本記事 | 3.5億 |
と、振り返ってみると毎年綺麗に 300% 成長でこれていますね。
(流石に来年はきついと思います^^;
今回は、そんな サービスを拡大する上で欠かせない収益の話
と 成長したからこそ表面化したコストの話
をまとめていきたいと思います。
収益の話
PWA は稼げない
昨年の Advent Calendar にて、私はたいそう嘆いておりました(TДT)
- Web 側の広告は MPA(マルチページアプリ)を前提にしているものがほとんど
- Web 側では広告の表示を最適化できない(MPA 前提の規約上バックグラウンドで読み込んでおけない
- それに加えて、SPA のページ遷移が早すぎて広告が表示される前に通過してしまう
- アプリ側にはインタースティシャル広告やリワード広告といった単価の高い広告商材が豊富に存在する
そういった要因から、PWA におけるページあたりの収益性はアプリはもちろん一般的な WEB(MPA) よりも悪い可能性がありました。
特にほとんどの広告商材は MPA 用に作られており、script タグの設置により広告表示を行います。
SPA でそれをそのまま実行してしまうと、最初のページでしか表示されなかったりブラウザリソースを占有し続けてしまったりという問題が生じます。
コンテンツの合間に表示されるインライン広告は iframe に閉じ込めることもできますが、そのままではうまく動作せず調整が必要なタイプがあったり、全画面に表示されるオーバーレイ広告などには使えません。
また、一昨年くらいから出回り始めた Google の自動広告やインタースティシャルは WEB としてはかなりの高単価で有名です。
しかし SPA での導入は非常にハードルが高く、残念ながらその採用を見送らざるを得ませんでした。
一方で MPA の場合はそういったソリューションを簡単に導入できるため、両者の収益性にはますます差が開いていました。
それが 2018 ~ 2019 年でした...
PWA 収益化の救世主、ヘッダービディング
そういった背景から暫く収益性が芳しくないサービス運営を続けていたのですが、とあるソリューションの導入で一気に風向きが変わりました。
それが ヘッダービディング広告 です。
広告の基礎知識について
すぐにヘッダービディングとは何かについて説明したいのですが、まずはその説明を理解するにあたって必要な用語や知識を覚えておかなければなりません。
アドテク分野の知識がある方にとっては当たり前の内容となってしまうため、読み飛ばしても大丈夫です。
WEB 広告の成り立ち
その昔、WEB サイト上の広告枠は直接広告主と契約する純広告が主でした。
インターネット老人会の方ならブログ等で「この枠を月○○円で売ります!」というバナーを見たことがあるのではないでしょうか。
しかし媒体(広告を掲載するメディア)ごとにそのような契約をしていては両者がなかなかマッチングしませんし、手間賃の分広告掲載料も高くなります。
反対に、媒体側も常に広告枠を埋め続けるための営業努力をしないといけません。
そこで登場したのがアドネットワークです。
ある程度のカテゴリごとに媒体と広告を集約し、媒体側は script タグを貼るだけで面倒な手続きなしに広告枠を埋められるようになりました。
一件落着と思いきや、人間の欲望はとどまることを知りません。
アドネットワークを複数束ねたアドエクスチェンジが登場し、更にそれらへの入稿をまとめるための DSP(Demand Side Platform) が出てきます。
DSP は広告主側がより効率的に広告配信を行うためのツールで、枠や媒体ではなく配信セグメントにフォーカスするのが特徴です。
例えば、20 代女性に絞って化粧品の広告を表示したい...等の需要を満たします。
時代とともに、狙った属性を持つ人に見せる方が広告効果が良いということが広まり始めたんですね。
一方で媒体側も複数のアドネットワークを管理する手間がかかっていました。
こちらも それらをまとめる SSP(Supply Side Platform) というツールで一括管理をするようになります。
そして DSP と SSP は直接繋がり RTB(Real Time Bidding)という仕組みを生み出します。
つまり RTB ルートはセグメントを絞って広告を配信したい、媒体はどこでも良い...という需要を満たすことに特化しています。
参照:https://www.ever-rise.co.jp/dx-blog/ssp
そしてなんと、その DSP や SSP もまた数多く存在するのです。
なんだかドラゴンボールのインフレコピペのようですね...
武術の神と言われる亀仙人より強い天津飯でも歯が立たないドラムを瞬殺した悟空が8年修行して、 同じ位の強さのピッコロさんと二人がかりでやっと倒した軽い気弾で山脈を消すラディッツと匹敵する戦闘力を持つ栽培マンを あっさり倒した天津飯・ピッコロ等が束になっても敵わないナッパを悠々倒した悟空の 2倍以上強いベジータがかなりパワーアップしても全く相手にならない強さのリクームを一撃で倒した悟空が 更にパワーアップしても、それを半分の力で殺せるフリーザをあっという間にバラバラにして消した未来のトランクスでさえ 仲間と束になっても敵わない人造人間17・18号に匹敵する強さを持った神コロ様でも敵わない程に 生体エネルギーを吸って強くなったセルと互角の16号を大きく越える17号吸収態セルを子供扱い出来る 精神と時の部屋パワーアップ後のベジータと随分差がある悟空でも勝てない完全体セルを 一方的に痛めつけることが出来るブチ切れ悟飯をも越えたベジータが命を賭けても倒せなかった魔人ブウが 更に凶悪になったブウと互角以上の戦いをしたゴテンクスよりも強くなった悟飯でも全く歯が立たない ゴテンクス&ピッコロ吸収ブウが更に悟飯を吸収して強化しても全く歯が立たないベジット。
ヘッダービディングとは
そんなインフレ極まるアドテクにおける現時点での極地がヘッダービディングです。
媒体側の接点である SSP(ビッダー) を更にまとめて比較します。
とりあえず本職の方の解説を拝借すると...
ヘッダービディングは「ヘッダー入札」とも呼ばれ、専用タグをWebサイトのHTMLに設置し、通常用いているアドサーバーへの広告リクエストより先にヘッダービディング用サーバーに広告リクエストを行って高単価な広告を決定し、その広告を通常のアドサーバー内にある広告と競わせて単価が高いほうの広告を配信する仕組みのことを指します。
参照:https://magazine.fluct.jp/2017/01/19/3206
リンク先により詳しい説明がありますが、自分なりにも説明すると...
ヘッダービディングとは様々な広告ネットワークや SSP を競わせることにより、広告枠の価値を最大限高める手法です。
例えば、SSP-A の平均単価が最も高いとしましょう。
普通に考えれば、これを使い続ければ済む話です。
しかし、何事にも例外はあります。
ネットワーク障害等で A の単価が一時的に崩れたり、別のアドネットワーク B に大型案件が入り単価が飛躍的に伸びることもあるでしょう。
ヘッダービディングは様々な事業者をリアルタイムで比較し、常に最大値を拾っていきます。
その時々に最適な単価を選択し続けることで、小さな積み重ねが結果として大きな収益につながるのです。
SSP-A | SSP-B | SSP-C | MAX | |
---|---|---|---|---|
平均単価 | 55円 | 54円 | 53円 | 73円 |
適当に作ったグラフですが、この例だと SSP-A を使い続けるのと比較し 30% 以上も収益性アップ!
図を見ればわかるようにビッダーの繋ぎこみをすればするほど凹が凸に変わる確率が高まります。
(ただしビッダーを増やした分レイテンシが増加するため、単に増やし続けるのが正解ではありません。
これがヘッダービディングにより収益が改善できる大まかな理由です。
DFP について
参照した図の中に DFP(DoubleClick for Publishers) という表記がありますが、これは Google の AdManager というツールのことです。
(旧名称が DFP。DSP やら DFP やらで本当にややこしいですね^^;
AdManager は Google が提供しているだけあって、広告配信ツールとしての完成度が非常に高く、その前身であった GoogleAdExchange や SSP 等の繋ぎ込みもできます。
多くの媒体で使われるようになるのも必然です。
しかし一極集中をしてしまえば、適切な競争引力が働かなくなるのが世の常です。
この状況に対抗するために、各広告事業者が連携して作り上げた仕組みがヘッダービディングだと言います。
様々なヘッダービディング
このヘッダービディングですが、複数業者がソリューションとして提供しています。
おいおい、またかよ!と思った方も安心してください。2020 年現在はここで打ち止めです。
つまり このヘッダービディングを更に並列比較するのが、現時点での「ヘッダービディングを導入する」という概念になるかと思います。
そして現在主流となっているのは次の 3 つです。
TAM(Transparent Ad Marketplace)/UAM(Unified Ad Marketplace)
Amazon が提供するヘッダービディングソリューション。
Amazon という大規模で購買に直結する広告在庫があるのが強み。
20 種類以上のビッダーを繋ぎ込めます。
Amazonプライムデーやサイバー・マンデー時は特に強いです。
Open Bidding/EBDA(Exchange Bidding in Dynamic Allocation)
GAM(Google Ad Manager)に対抗して作られたヘッダービディングに対抗するための Google 製ヘッダービディング。Google 管轄のアドネットワーク群と単価を競わせることができます。
GAM を利用していれば(つまり殆どの媒体で)非常に簡単に導入が可能。
Prebid.js
前者2つと異なり、オープンソースのソリューション。
オープンソースなので使用に際して手数料はかかりませんが、繋ぎこむ SSP との契約や大量の枠調整を自社で行っていると結局それ以上のコストが発生してしまいます。
そのため、一般的には専用の Wrapper ソリューションを提供している事業者と契約し各種手続きを代行してもらうことになることが多いです。
オープンソースだけあって多くのビッダーに対応しており、その数は TAM/UAM の 10 倍となる 200 種類以上にもなります。
導入結果
次のグラフは担当サービスにてヘッダービディングを導入してからの収益変化の推測値です。
ヘッダービディング対応した枠は、平均すると 60 ~ 70% 程度の収益増となりました。
(実はこのグラフの先も更に好調な数値を出しているので、実際はもう少し伸びていると思います。
またグラフが示す通り、導入後は一度も 100%(導入以前の推定値)を下回っていません。
つまりヘッダービディングによってレイテンシが増えようとも、常にそのマイナス分を補う以上の単価アップが見込めるということです。
まだ導入して日が浅いのと、2020 年度はコロナの影響で広告単価が乱高下していたため、上記グラフは参考程度に考えてもらえればと思います。
ただ、他社の導入事例を見てみても 20 ~ 100% 程度の向上は見込めるようですので今回の結果も一過性のものではないと考えています。
Angular(SPA) での実装
そんなヘッダービディングですがSPA での導入事例はまだまだ少ないらしく、今回パートナー提携した Wrapper 事業者にとっても弊社が初めてのケースだったようです。
そのため最初に受け取った Wrapper スクリプトは、SPA の挙動に完全対応できておらず、何度かの話し合いを経て現在リリースされている構造に落ち着きました。
この際必要なのが、前章までの広告知識 + αです。これがないと事業者側との会話がままなりません。(私も知識不足から多々ご迷惑をおかけしてしまいましたorz
もしこの記事を読んで SPA のヘッダービディングに挑戦してみようという方がいましたら、事前に広告周りの知識を学んでおくことをおすすめします。
SPA ではない場合の実装を理解する
SPA 版ヘッダービディングは、その基本となる MPA 版を理解しなければ実装することができません。
まずはそれについて、順を追ってみていきましょう。
Google Publisher Tags (GPT)
GPT は、現在のアドテクで最も基本となってくる Google Ad Manager(GAM) のライブラリです。
前節のヘッダービディング概念図で
広告リクエスト → ヘッダービディングサーバー → DFP(アドサーバー) → 配信
となっていましたが、DFP に当たる部分のオペレーションを行います。
前述した通り、GAM だけでも広告配信は行なえ Google のネットワーク内でヘッダービディングが可能です。これが最小構成となります。
基本的には
googletag.defineSlot("/1234/travel/asia", [728, 90], "div-gpt-ad-123456789-0")
という見るからにそれっぽいコードで枠を定義したり
googletag.display("div-gpt-ad-123456789-0")
で表示したりします。
詳しく説明するとかなりの尺をとってしまうので今回は割愛します。
apstag(Amazon Publisher Services) タグ
その名の通り Amazon の TAM/UAM を利用するためのライブラリです。
キモとなるのがこのリクエスト部分で
apstag.fetchBids(
{},
(bids) => {
apstag.setDisplayBids();
googletag.pubads().refresh();
}
);
広告情報をした後に setDisplayBids
した後に googletag.pubads().refresh()
することで Amazon の落札結果を Google 側と競わせることができます。
より詳しい情報は契約後にログインできる管理画面のドキュメンテーションに詳しく記載されていますのでそこでご覧ください。
有効にするまでの手順(疎通確認)やデバッグ方法がスクリーンショット付きで丁寧にまとめられており、非常にわかりやすかったです。
Prebid.js
これは先程出てきた名前のまんまなのでわかりやすいですね。
前述した通り、Prebid.js はオープンソースプロジェクトなのでそのまま使う分には無料です。
ただし、各種 SSP とのひも付けや SSP ごとのチューニングを行うためのアダプターコードが必要だったり、膨大な数存在するオプションやモジュールを運用し続けなくてはいけません。
そのため、一般的にはそういった変動しやすい設定部分は Wrapper 事業者側の管理するスクリプトに内包し運用を代行してもらいます。
そしてリフレッシュやタグ定義に関する部分のみをクライアントサイドで記述することになるでしょう。
こちらのキモも apstag と同じです。
広告情報を取得した後に setTargetingForGPTAsync
でセットし、その結果を GAM サイドと比較できるようにします。
pbjs.requestBids({
bidsBackHandler: (bids) => {
pbjs.setTargetingForGPTAsync();
googletag.pubads().refresh();
}
});
3 つのソリューションを組み合わせる
つまり GAM のみの場合は、
GPT で広告枠の定義
↓
googletag.pubads().refresh()
⇓ 広告取得(gpt
描画
GPT + apstag だと
GPT で広告枠の定義
↓
apstag.fetchBids
⇓ 広告取得(apstag
apstag.setDisplayBids()
↓
googletag.pubads().refresh()
⇓ 広告取得(gpt vs apstag
描画
GPT + Prebid.js だと
GPT で広告枠の定義
↓
pbjs.requestBids
⇓ 広告取得(prebid
pbjs.setTargetingForGPTAsync()
↓
googletag.pubads().refresh()
⇓ 広告取得(gpt vs prebid
描画
GPT + apstag + Prebid.js だと
GPT で広告枠の定義
↓
ーーーーーーーーーーーーーーーーーーーー
↓ ↓
apstag.fetchBids pbjs.requestBids
⇓ 広告取得(apstag ⇓ 広告取得(prebid
apstag.setDisplayBids() pbjs.setTargetingForGPTAsync()
↓ ↓
ーーーーーーーーーーーーーーーーーーーー
↓
googletag.pubads().refresh()
⇓ 広告取得(gpt vs apstag vs prebid
描画
の流れを実行する必要があります。
このリクエストのタイミングコントロールは、一般的にはグローバル上にフラグを持って行っているようです。
window.headerBiddingRequestManager = {
apstag: false,
prebid: false,
}
上記のようなフラグを用意しておき、apstag や prebid のリクエストが終わるたびに true を代入。その時点で両者が true であれば、どちらの事前リクエストも完了しているとみなし、googletag.pubads().refresh()
で最終的な広告を取得およびレンダリングします。
上記の流れは MPA の場合の場合は特に意識する必要はなく、Wrappter 事業者のコード内に隠蔽されていると思います。
ただし、SPA を考える場合には必要となってきます。
Angular での実装パターン例
ここからは、今回私が Angular で実装したヘッダービディング処理の概要について紹介してきます。
おそらく Wrapper 事業者やアドテク界隈の変化で随時最適解が変わると思いますので、あくまでも一例とお考えください。
コンポーネントについて
新たにコンポーネントを設計する際、それをどうやって使いたいか?という最終形から考えることが多いと思います。
前節にチラッと出てきましたが、広告枠を定義するために最低限必要な情報は「アドユニットバス」、「枠サイズのリスト」、「表示する要素のHTMLにおける ID)」の 3 つです。
<app-ad adUnitPath="xxx/xxx" [sizes]="[[728, 90]]" optDivId="xxx">
</app-ad>
こんな感じで使えると良いですね。
SPA は構成によっては閲覧者が長時間、同一ページにとどまることもあります。
そういったページは一定時間ごとに広告枠をリフレッシュすると広告効果を高められます。
そのようなオプションも追加で付けられるようにするとなお良いです。
モジュールについて
Angular では関連機能をまとめたモジュールを定義できます。
以前は Core モジュールと Shared モジュールに分けるのが主流とされ、公式のガイドラインもそうなっていました。
しかし現在は bundle 最適化の観点から役割ごとに分割する方法がベストプラクティスとされています。
今回は広告関連処理をまとめた AdModule を作りました。
次に、広告を表示するにあたって行わなければいけない事前準備について考えます。
ヘッダービディングを行うには GPT、Prebid.js、apstag と、並列に実行する分だけ関連ライブラリが必要になってきます。
GPT のオペレーションは googletag.cmd
にキューイングしておけるため、これらのライブラリがローディングされていない状態で実行しても大丈夫です。
そのため、これらのローディングは AdModule がロードされたタイミングで独立して実行するのが良いかと思います。
具体的には、広告関連処理を統括するサービスの constructor か、NgRx のイニシャライズアクションで実行すると良いでしょう。
もし複数カ所に AdModule をインポートしていたとしても最初の一度しか実行されないため、重複読み込みを防ぐことができます。
リクエストをまとめる
GPT には複数の枠リクエストをまとめる SRA(Single Request Architecture) という仕組みが存在します。
リクエストをまとめることでパフォーマンスが向上するのに加えて、確実なロードブロッキングも行えるようになります。
ロードブロッキングはもともと TV 業界の用語らしく、狙った時間帯に全ての局で同一案件を放映する一斉放送のこと。
WEB の場合は、ページ内の枠全て(配信側が設定している数まで)を同じ案件で埋めつくす手法を指すようです。
稀にページ内のヘッダーもサイドも、何なら背景とかも同じ広告で統一されているのを見ませんか?
イメージとしてはそれに近いと思います。
更に今回はヘッダービディングのリクエストも付随します。
SRA を使わなければ枠一つに付き 3 回(GPT, apstag, prebid)のリクエストが発生。つまり 1 ページに 5 枠あったら 15 リクエストです。
クライアントの帯域やリソースを圧迫し、結果として単価も下がりますので必ず SRA を使いましょう。
そうすれば何枠あっても 3 リクエストにまとまります。
参照:https://oko.uk/blog/single-request-architecture
ただ、SPA でそれを実装しようとするとコンポーネント間の連携がとれないためにリクエストが上手にまとまりません。
愚直にやるなら、この枠はこの枠とセットみたいな定義が必要です。
しかしページ内に定義されている広告は、必ずしも一緒にリクエストするのではありません。
コンテンツ量が多いページは、閲覧者がそこまでスクロールして初めて表示されるコンテンツ及び広告があるからです。
閲覧者のディスプレイサイズによっても変わってきます。
正直どうやって実装したものか困りました...
結局、一定間隔ごとのリフレッシュや差分ローディングなどの影響を完全に事前定義することは難しいと判断し、実際にリクエスト依頼があった枠をある程度の時間ごとにマージしてリクエストすることにしました。
数十ミリ程度の遅延は発生してしまいますが、あらゆるタイミングの差異に対応でき単価への影響もないため、今のところはこれが丸いのかなと考えています。
(どちらにしろその先の通信 * 2 で数百ミリ以上のブレが出ますし...
ヘッダービディング 3 種のリクエストコントロール
何度も出てきているように GPT でリフレッシュする前には apstag と prebid のリクエストを完了させなければなりません。
しかし、そのリクエストは失敗するかもしれませんし たまたま時間がかかるかもしれません。
こういった状況に対して何も対策せずにシーケンシャルなフロー一本にしておくと、広告枠に何も出すことができずに終わってしまいます。
一般的な回避策は 1,000 ~ 3,000 程度のタイムアウト値を設定し、その時間が過ぎたら apstag や prebid の処理を打ち切って gpt 単体でリクエストを行うことです。
雑にコード化してみると...
window.headerBiddingRequestManager = {
apstag: false,
prebid: false,
}
apstag.fetchBids({},
(bids) => {
apstag.setDisplayBids();
window.headerBiddingRequestManager.apstag = true;
}
);
pbjs.requestBids({
bidsBackHandler: (bids) => {
pbjs.setTargetingForGPTAsync();
window.headerBiddingRequestManager.prebid = true;
}
});
window.timerId = setTimeout(() => {
timeoutFunc();
}, 2_000);
function refreshWithHb (hbType: 'apstag' | 'prebid') {
window.headerBiddingRequestManager[hbType] = true;
if (Object.values(window.headerBiddingRequestManager).some(v => !v)) {
return;
}
clearTimeout(window.timerId);
googletag.pubads().refresh();
}
抜粋したコードでこれなので、実際はコールバックとタイムアウトでより追いにくいコードになっていることでしょう。
広告関連のコードは一般的に ie8 のようなレガシーブラウザでも安定した動作を求められるため、可読性よりも対応環境の広さに重きを置きます。
しかしそういった環境は SPA や Angular を配信している我々からするとサポート外ですので、思う存分リファクタできます。
特に Angular では非同期ライブラリの RxJS を標準採用しているため、各種オペレーターを組み合わせることでより明快に記述できます。
const safeTimeout$ = timer(2_000);
race(zip(this.apstagFetchBids$(), this.pbjsRequestBids$()), timer$).subscribe(() => googletag.pubads().refresh());
zip...値が発生する回数を合わせつつ並列実行する
race...値が先に生じた方を採用し、それ以外はキャンセルする
public pbjsRequestBids$() {
return new Observable(observer => {
pbjs.requestBids({
bidsBackHandler: () => {
pbjs.setTargetingForGPTAsync();
observer.next();
observer.complete();
});
});
}
apstag と prebid の広告リクエストをストリーム化したらあとは zip で並列化して、それをタイムアウト値と race させるだけです。
やりたいことがそのまま文章のようになるので、可読性が段違いに向上します。
不要なフラグ管理やグローバル変数がなくなり、コードを読む際に複数ファイルを飛び回らなくて済むようになりました。この 2 行だけで全てが理解できます。
以上で、一通りの要所は押さえられたかと思います。
再三となってしまいますが、この辺の実装は提携事業者によって異なります。
基本部分だけ読み取って細部は状況に応じて調整していただければ幸いです。
収益化まとめ
WEB 広告の成り立ちからヘッダービディングの実装までを かいつまんで紹介いたしました。
結局 MPA もヘッダービディングできるし、実装がめんどくさくなるだけでは?
と感じる方もいると思いますが、SPA とヘッダービディングは MPA よりも相性が良い可能性があります。
ヘッダービディングは、これまで見てきた通り、その実行までに多くの準備を必要とします。
まずそれらのイニシャライズで遅延が発生し、各々のリクエストでも遅延が発生します。
しかし SPA は御存知の通りページ遷移時にコンテンツ部分だけを差し替える手法ですので、各種イニシャライズを何度もする必要がありません。
つまり 2 ページ目以降はおおよそ 0.5秒~ のイニシャライズ時間を短縮して表示させ続けられるのです。
媒体の傾向やサイト構造によって本当になんとでも言えますが、とりあえずこの部分だけ考えると単価は向上するはずです。
実際、弊社で同時期に実装した MPA と SPA では、SPA 側の方が効果が出ています。
これまで柔軟にアドソリューションを採用することができず、単価の面で MPA に遅れをとっていた SPA ですが、ついに輝ける場所を見つけました。
ヘッダービディング + SPA はそれぞれの短所を補い合えるため、まさにベストな組み合わせと言えそうです!
今回の導入によって、インラインにおいての売上はアプリに肉薄するまでになりました。
SPA での収益化に悩んでいる方はぜひ一度お試しください。
コストカットの話
サービスを存続させていくためのお金、粗利益は売上の増加だけではなくコストカットによっても改善します。
本年度はサービスの規模に加えて粗利をより重視される年だったため、インフラ周りのチューニングも必要となりました。
その際にいろいろと考えたことをまとめておきます。
Firestore のデータ構造
インフラのコストカットと聞いて真っ先に思い浮かんだ改善候補が、最もよく閲覧されているコンテンツページの構造です。
例えるなら togetter さんが近いかもしれません。
参照:https://gigazine.net/news/20111228-togetter-matome/
togetter でいう引用ツイートの一つ一つを 1 Document にするか否か。
最初期の設計時も悩んだ点です。
結局、
- 数千万PV/月でも大した額にならない
- もしそれを上回るヒットになったら、エンジニアも追加されてインフラごと再構築されているだろう
- 構造として自然
- 保存やアップデート処理が書きやすい
- 長くなった場合、任意の数を遅延読み込みできる
といった理由から 1 ツイート 1 Document にしました。
このプロジェクトの立ち上げエンジニアは自分一人だったため、なるべくシンプルな設計を心がけていたという背景もあります。
~ 時は流れて 2020 ~
月間 PV は 3 億を超え、にもかかわらずエンジニアは 1 人しか増えず、私はプロダクトマネージャーも兼任するようになったためインフラ/コスト周りについてを時間をかけて考える余裕はしばらくありませんでした。
しかし徐々に膨らんでいく Firestore のコストが無視できないレベルに達していたため、このページの構造について改めて考え直すことにしました。
まず、DB のモデルクラスにログを仕込んで、1 ページで平均どの程度の Document 取得があるのか計測してみました。
ここでは、前述のメインコンテンツページ以外は 40(■)。
コンテンツページはツイート部分 50(■) + その他のおすすめコンテンツ等が 20(■) の計 70 とします。
また、コンテンツページのみの PV は月間 2 億とします。
(これ以降登場する媒体数値は全て仮のものです
この場合の(■)コストは...
0.038$/10万(Firestore Tokyo Region 1 Doc) * 110円 * 50 Doc * 2億PV => 約41.8万円/月
コストの割合をグラフにするとこんな感じ。
この 50% を占めるメインコンテンツ部分を改善するために、例えば 20 ツイートを 1 Document にまとめる構造変換を行うとします。
つまり、1 ページ 3 Document(20 + 20 + 10)になります。
これで計算してみると...
0.038$/10万(Firestore Tokyo Region 1 Doc) * 110円 * 3 Doc * 2億PV => 約2.5万円/月
で、なんと 94% OFF に!
(閲覧系コスト全体では 47% の削減となります。
そんなこんなでまずは 40万/月 の削減に成功しました。
大量の Read 処理が発生するクエリの回避
日頃から大量の課金をしないようなクエリを心がけていましたが、一箇所だけ再考に値するクエリが見つかりました。
それはユーザーが保持している通知の一覧を取得するというクエリです。
ユーザーごとに保持している通知の累計が既定値をオーバーした場合、最も古い通知を削除するというよくある要件の一部です。
Firestore のサーバーサイドには OFFSET 句が存在しますが、コスト的には変わらないのでこの部分の最適化は一旦後回しにしていました。
これは、一定数のドキュメントをスキップできます。
カーソル、ページトークン、および制限を使用する場合、追加コストは発生しません。これらの機能は実際に必要なドキュメントだけを読み取るので、コストを節約できます。
一方、オフセットを含むクエリを送信すると、スキップされるドキュメントごとに読み取り料金が課金されます。たとえば、クエリでオフセット 10 を使用している場合、このクエリが 1 つのドキュメントを返すと、11 回の読み取りとして課金されます。このような追加コストが発生することから、可能な場合は常にオフセットではなくカーソルを使用してください。
しかしそのコストも徐々に無視できるものではなくなってきたため、前節の施策と同じタイミングで保存条件を期限に変更してコストカットすることにしました。
今回は、ユーザーごとに保持する通知の数を 100 件。月間発行件数を 4,000 万として計算してみます。
0.038/10000011040000000*100 = 16.7万
(もちろん 100 件に達していない人もいるので最大値です
これを期限条件に変更すれば、クエリごとに 1 ~ 数件分(あってもなくても)引けば良くなるので、この要件の費用は約 99% カットできます。
期限条件にする分ストレージ費用は増えますが、GCP のストレージ費用はめちゃくちゃ安いので 4 億分の通知データを保持しても月 2,500 円程度です。
これにより、続いて 15万円/月の削減です。
Firestore のエクスポート/バックアップ
前節までで一旦思いつく限りの Firestore コスト削減はやりきったのですが、一つ大きな謎が残っていました。
(細かい削減施策は他にも多々行いました
アプリや CloudFunctions のどこでどれだけ Document が Read されているかを徹底的に洗い出し、料金をシミュレートしきったのにもかかわらず、残りの 3 ~ 4 割はどの機能要因で発生しているのか一向にわからなかったのです。
最初の頃は、自分が忘れている or ミスっただけでどこかに無駄な処理をしている部分があるのだろう。特にリアルタイムリスナー部分とかが怪しいな!とか思っていたのですが、全ての機能を精査してもコストの合計値に合わないため途方に暮れました。
そんな状況で Firestore のドキュメントやダッシュボードを見直しているとあることに気が付きました。
Firestore の使用量は Firebase および GAE のダッシュボードに表示されているのですが、どうも GAE に表示されている数値が Firebase 側より多い...
よくよく見てみると丁度行方不明だった 3 ~ 4 割分くらいの差異があるではありませんか。
どうやらこの差が何によって生まれているのかを突き止めれば良さそうです。
Google のサポートに聞いてみると、現時点では下記の事象が GAE 側のみにカウントされるよ!との返答をいただきました。
1 データベースのエクスポート
エクスポート操作の一環として読み込まれたドキュメントはすべて、請求可能な読み取り量にカウントされます。今はFirebaseコンソールには表示されませんが、App Engineのクォータページでカウントされます。
2 失敗したクエリ
すべての読み込みリクエストには、1回の読み込みの最小コストがあります。これには、空の結果を返すクエリや、セキュリティ ルールに失敗する読み取りリクエストが含まれます。アプリがクエリが失敗したときにリトライ ロジックを使用している場合は、リクエストを積極的にリトライしすぎていないかどうかを確認する必要があります。しかし、アプリで異常な動作を見つけることができず、上記の使用原因がすべて除外されている場合は、プロジェクトが不正なクエリに襲われている可能性があります。観察された使用量の増加がサービス拒否攻撃によるものではないかと思われる場合は、できるだけ早くお知らせください。
あ〜〜〜 エクスポート!バックアップね〜〜〜!
わかってみればバカバカしいほどに単純なのですが、この頃の私は恥ずかしながらそれらを失念しておりました...
そして全てが繋がりました。
担当しているサービスには、例えるなら月に億近くのペースで増加するチャットログのようなデータがあります。
特にこの部分が積もりに積もって雪だるま式に増えていることが明らかでした。
それらのデータは毎月 1 億増えるとしたら、次の月は 1 + 1 = 2 億 * 30 日のエクスポート、更に翌月は 3 億 * 30 のエクスポートが発生します。
仮のデータですが、グラフ化するとよくわかりますね。
すごい勢いでエクスポート料金が上がっていることがわかると思います。
左端(2019/01)と右端(2020/08)を実数値で比較してみると...
■ 2019/01 のエクスポート料金/月
0.038/100000 * 110 * 4000000 * 30 = 5千
■ 2020/08 のエクスポート料金/月
0.038/100000 * 110 * 579600000 * 30 = 72.6万
なんと 72 万円! 2 年近くで 145 倍に膨れ上がっていました。
対策として、不要なコレクションはバックアップおよび BigQuery へのエクスポートをしないことを徹底するよう変更を行いました。
...
return client.exportDocuments({
name: databaseName,
outputUriPrefix: bucket,
// Leave collectionIds empty to export all collections
// or set to a list of collection IDs to export,
// collectionIds: ['users', 'posts']
collectionIds: []
})
...
参照:https://firebase.google.com/docs/firestore/solutions/schedule-export?hl=ja
公式ドキュメントにもあるバックアップ方法は上記のコードで設定できるのですが、collectionIds が空のままだと全ての Document を対象にエクスポートしてしまいます。
当初はコレクションを追加しても抜けもれなくバックアップしてくれる便利な仕様でしたが、サービス規模が大きくなってくると逆に罠となり得ますね...
データの持ち方等を整備して 72万円/月の削減!
悪質な BOT の遮断
実際に大規模サービスを管理するようになって、驚いたことの一つは不正アクセス、クローラー、ボットの多さです。
担当サービスは SPA/PWA のため、ユーザーの PV がダイレクトにアプリケーション・サーバーに来ることはないという背景もありますが、それにしても多すぎです。
常時人間のアクセス量をボット(Bots + Blocked)が上回っています。
ひどいボットだと月 5,000万 ~ 1 億PVほどのペースでクローリングしてきます...
robots.txt の crawl-delay も設定していますが、スルーされるケースがあまりにも多いこと多いこと...
対処法は様々だと思いますが、弊社では横断的に Incapsula が提供する WAF を利用しているのため、今回はそれにあやかることにしました。
Webアプリケーションへの攻撃は高度化・巧妙化しており、多数の企業が被害にあっています。OWASP(Open Web Application Security Project) Top10に代表されるWebアプリケーション脆弱性への攻撃はもちろん、2016年以降では1Tbpsを超えるDDoS攻撃が発生するなど、攻撃が大容量化、巨大化、高トランザクション化、長期化しており、より強固なセキュリティ対策が必要です。
特に公開されているWebサイトは、攻撃者からは格好の的になります。Webアプリケーションの脆弱性はなくなることはなく、2018年のNRIセキュアの調査では、当社のWebアプリケーション診断サービスを提供しているシステムのうち、約30%は重要情報に不正にアクセスできることが分かりました。定期的なアプリケーション診断を実施するセキュリティ意識の高い企業でも、脆弱性はなかなかゼロにはなりません。もちろん、セキュリティ対策が十分とられていないWebアプリケーションも多数あるため、実際はそれ以上の割合で脆弱性が潜んでいると考えられます。
さらに近年ではデジタルトランスフォーメーション(DX)の推進で、Webアプリケーションの高速開発が増加しています。Webアプリケーションを変更する機会が多くなればその分、脆弱性が混入する機会も増えることが想定されます。 このため、アプリケーション層の防御であるWAF(Web Application Firewall)は欠かせないセキュリティ対策です。
Incapsula の WAF はセキュリティ観点からの保護はもちろん、オリジナルのアクセスルール定義もできます。
例えば広告や bing ボットが高頻度でアクセスしてきた場合に crawl-delay のような定義を WAF 側で行い過剰なアクセスの遮断が可能です。
SSR のレンダリングは要求されるインスタンスレベルが高く、それに伴って料金も割高になります。
不正なボットに対してノーガードでレスポンスし続けてしまうと流血するようにコストを垂れ流してしまいます。
そのため何らかの仕組みを導入して、できる限り正常なリクエストに対してのみレンダリングを行っていきましょう。
WAF 利用料分を引いて 約 10 万円カット。
コストカットまとめ
- ページの構造は多少無理矢理でもコストを削減するように作ると将来的には楽
- OFFSET 句はスキップした分の Read 料金も発生する
- Firestore の全コレクションエクスポート/バックアップには気をつける
- Firebase はどこでいくらかかっているのか確認できなくてツラい...
- 悪質な BOT や高頻度過ぎるクローラーは WAF で対処できる
- CGM サービスでは BOT やクローラーのアクセスが非常に多くなるため、Firestore のコスト予測は想定 PV * 2 程度で考えておくと良いかも
おわりに
巷でちょくちょく話題になりますが、どんな技術も最終的にはビジネスのために存在します。
(と、私は考えています
今回の話をまとめたのも「Angular/PWA でも収益化なんとかなるよ!サービス運営うまくいってるよ!」という情報からより一層の普及に貢献できると考えてのことでした。
実は昨年の記事で地味に PWA は儲からない と広めてしまった自責の念があり、この一年なんとかそのレッテルを払拭できないかと考えていました。
そんな中でヘッダービディングという相性抜群の技術と出会い、そしてそれを同様に周知することで この流れを断ち切りたいという思いがありました。
本記事が収益化やサービスの Firebase のコストカットに悩んでいるという方にとって、少しでもヒントになれば幸いです。
Angular Advent Calendar 2020 8 日目は @Quramy さんです。よろしくお願いしますm(__)m