CyberAgentのアドテクチャレンジで正確なDMPを作る
先日株式会社サイバーエージェントの2dayインターン、アドテクチャレンジに参加させて頂きました。
そしてなんと優勝させて頂きました!
私はインフラ設計の経験がなく、常に手探り状態でした💦
今記事では、優勝に至るまでの技術的苦悩をまとめていきます。
問題
概要
開発時間終了後、位置情報やIDFAなどのデータが3時間程度大量に送られてくる(2000QPS)。
1リクエストにつき100ms以内に返さないと無効になる。
送られてきたデータを元に問題を解答し、その精度を元に得点がつけられる。
*ちなみにGoogle Cloud Platform使い放題\(^o^)/
詳細
以下の2つの形式でそれぞれ何問か答える
【バッチ問題】
データ送信が完了した後に問題に答える
ex)○時から○時の間に〇〇から半径10km以内にいた人数は?
【リアルタイム問題】
データ送信中に、特定IDFA(端末ごとのID的なやつ)と時間がjsonで送られてくる。
そのユーザーがその時間にどこにいたか等を答える。
1000ms以内に返さないと無効。
アーキテクチャ
最初の案
リアルタイム問題はレスポンスタイム制限があるため、直近で使うデータだけredisに置いておかないと💦
と思っていたのですが、1000msは余裕すぎました。
しかも直近で使うデータなんか分からないから却下。
二番目の案
1000msは余裕と分かり、今回ネックなのは書き込みであることに気付きました。
正直2000QPSが想像つかなかったのですが、直接書き込むのではなくジョブキューを噛ませるのが安全だと考えました。
GCPのCloud Launcherなら、redisがプリインストールされているインスタンスを素早く立ち上げられるし、
クラスタリングも比較的簡単に出来そうだったので決まりか!?と一瞬笑顔になりました。
しかしredisはインメモリDBなので、ログを1つでも取りこぼしてはいけない今回は不適切でした。
三番目の案
様々なミドルウェアを検討しましたが、フルマネージドサービスで統一した方ががつなぎ込みも楽だと考えたので、Cloud Pub/Subを採用しました。
データストアにはBigtableを採用しました。GCPサービスの中では最も時系列データを扱うのに向いていたからです。(後ほど説明します)
さらにバッチ問題用にBigQueryを採用しました。集計が速く、且つ全員が慣れていて学習コストが低いSQLで操作できるためです。
データをBigtableからBigQueryにエクスポートしようと思っていたのですが、そのためのBigtableのリージョンがus-centralとeurope-westしかありませんでした。。。
そこでログをリアルタイムでエクスポートしてくれるStackdriverLogging採用。(後ほど審査時に突っ込まれます)
完成!・・・
しかし全員が口をそろえて言いました。
時間足りなくね(-.-;)
採用案
とりあえずQPS2000でリクエストを流してみたところ、ジョブキュー噛ませなくても余裕で耐えられることが判明しました。
ということで、APIから直接Bigtableに書き込むアーキテクチャで決定しました。
言語
Go
理由
2日間という短い期間で高トラフィックを捌くには最適
- 実行速度○
- ビルド速度○
- go fmtのお陰で規約がいらない
- GCPサービスとの相性良さそう
- みんなのGoモチベが高い (超大事)
Bigtableのスキーマ設計
問題は時間指定が多かったため、時間順にソートしておきたいと考えていました。
リファレンスを読むとこんなことが書いてありました。
Cloud Bigtable ではデータが非構造化列として行に保存され、各行には行キーがあり、行キーは辞書順で並べ替えられています。
そこで行キーを’timestamp#IDFA'という形式にすることで、探索アルゴリズムの高速化を図りました。
ex)'2017-07-03 10:57:23 +0000 UTC#xxxxxxxxxxxxxx'
StackdriverLoggingとAPIのつなぎ込み
以下の数行コードで通常のstring型ログの書き込みが可能です。
Payloadというinterface{}型フィールドに文字列を渡します。
import "cloud.google.com/go/logging" var logClient *logging.Client logger := logClient.Logger("テストログ") logger.Log(logging.Entry{Payload: "テストテキスト"})
StackdriverLoggingはBigQueryで爆速集計するために採用したため、構造化したログを渡すことが必須でした。
BigQueryにはJsonPayloadという型があるため、Payloadフィールドにjsonで渡すのが最適解なのでは?という仮説のもとリファレンスを漁りましたが、のってませんでした(;´Д`)
GCPのソースコードを読んでみました
cloud.google.com/go/logging/logging.go 506~509行目
// Payload must be either a string or something that // marshals via the encoding/json package to a JSON object // (and not any other type of JSON value). Payload interface{}
jsonオブジェクト渡せってことかな??
早速以下のように構造体をエンコードして[]byte型に変換!
import ( "encoding/json" "cloud.google.com/go/logging" ) type Info struct { Latitude string `json:"latitude"` Longitude string `json:"longitude"` DeviceID string `json:"device_id"` SysName string `json:"sysname"` SysVer string `json:"sysver"` Timestamp string `json:"timestamp"` } info := &Info{} i, err := json.Marshal(info) logger := logClient.Logger(logName) logger.Log(logging.Entry{Payload: i})
エラー!!
深くまでソースを見てみると、
cloud.google.com/go/logging/logging.go 637行目
jb, err := json.Marshal(v)
Payloadフィールドの値を最終的にエンコードしてる!!
普通に構造体渡せば良かったのね!!!
import "cloud.google.com/go/logging" type Info struct { Latitude string `json:"latitude"` Longitude string `json:"longitude"` DeviceID string `json:"device_id"` SysName string `json:"sysname"` SysVer string `json:"sysver"` Timestamp string `json:"timestamp"` } info := &Info{} logger := logClient.Logger(logName) logger.Log(logging.Entry{Payload: info})
無事StackdriverLoggigにjsonログが流れました。
BIgQueryには1カラムに複数のデータを入れることが出来るRECORD型というデータ型があります。
jsonログをStackdriverLoggingに流すことで自動的にRECORD型のカラムを生成してくれます。(設定は必要)
オペレーション
開発時間が終わり、いよいよデータ送信の時間になりました。
すごい速度でリクエストが流れ、興奮していたのも束の間。
http: Accept error: accept tcp [::]:8080: accept4: too many open files
開始30分後、テスト時には発生しなかったエラーが!!
TCPが開いたままになってるのかな??と思いプログラムコードを確認したところ、リクエストボディを閉じ忘れていました。
func collect(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() // some codes... }
すぐにデプロイ!
Goはシングルバイナリを上げればいい!素晴らしい!
http: Accept error: accept tcp [::]:8080: accept4: too many open files
なぜだ、、、
KeepAliveオフにしたら解決するかな、、
net/http/request.go 203~206行目
// For client requests, setting this field prevents re-use of // TCP connections between requests to the same hosts, as if // Transport.DisableKeepAlives were set. Close bool
Closeという公開フィールドをfalseにすればDisableKeepAlives
にできるっぽい!
関数が用意されてない時点で非推奨だと思うけど、やるしかない!
func collect(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() // some codes... r.Close = false }
http: Accept error: accept tcp [::]:8080: accept4: too many open files
1分で死にました。
結局データ送信中は、人間が常にstatusを監視するという力技で乗り切りました笑
大会終了後に社員さんに聞いたところ、linuxのファイルディスクリプタ数(同時に開けるファイル数)の上限を上げれば解決したそうです。
何が起きていたかというと、
- ファイルopenしてはcloseを繰り返す
- リクエストが急増するとcloseがopenに追いつかず、too many open filesが発生
勉強になりました。
このような、知らないと解けない問題を解けるエンジニアになりたいです。
まとめ
とにかく死ぬほど楽しかったので、サーバーサイドに興味ある学生さんは絶対参加するべきだと思います!
技術的なことしか書きませんでしたが、チームビルディング等、多くのことを学びました。
また参加したいと思います!
*インフラ初体験の大学生2年生が書いてるので、間違いがあるかもしれません!
もし見つけたら指摘して頂けるとありがたいです。
優勝やったー!