爆速でGo!

GoGo!

CyberAgentのアドテクチャレンジで正確なDMPを作る

先日株式会社サイバーエージェントの2dayインターン、アドテクチャレンジに参加させて頂きました。
そしてなんと優勝させて頂きました!
私はインフラ設計の経験がなく、常に手探り状態でした💦
今記事では、優勝に至るまでの技術的苦悩をまとめていきます。

developers.cyberagent.co.jp

問題

概要

開発時間終了後、位置情報やIDFAなどのデータが3時間程度大量に送られてくる(2000QPS)。
1リクエストにつき100ms以内に返さないと無効になる。
送られてきたデータを元に問題を解答し、その精度を元に得点がつけられる。
*ちなみにGoogle Cloud Platform使い放題\(^o^)/

詳細

以下の2つの形式でそれぞれ何問か答える

【バッチ問題】

データ送信が完了した後に問題に答える
ex)○時から○時の間に〇〇から半径10km以内にいた人数は?

【リアルタイム問題】

データ送信中に、特定IDFA(端末ごとのID的なやつ)と時間がjsonで送られてくる。
そのユーザーがその時間にどこにいたか等を答える。
1000ms以内に返さないと無効。

アーキテクチャ

最初の案

f:id:NakaWatch:20171024183052p:plain

リアルタイム問題はレスポンスタイム制限があるため、直近で使うデータだけredisに置いておかないと💦
と思っていたのですが、1000msは余裕すぎました。
しかも直近で使うデータなんか分からないから却下。

二番目の案

f:id:NakaWatch:20171024184503p:plain

1000msは余裕と分かり、今回ネックなのは書き込みであることに気付きました。
正直2000QPSが想像つかなかったのですが、直接書き込むのではなくジョブキューを噛ませるのが安全だと考えました。
GCPのCloud Launcherなら、redisがプリインストールされているインスタンスを素早く立ち上げられるし、
クラスタリングも比較的簡単に出来そうだったので決まりか!?と一瞬笑顔になりました。
しかしredisはインメモリDBなので、ログを1つでも取りこぼしてはいけない今回は不適切でした。

三番目の案

f:id:NakaWatch:20171024185718p:plain

様々なミドルウェアを検討しましたが、フルマネージドサービスで統一した方ががつなぎ込みも楽だと考えたので、Cloud Pub/Subを採用しました。
データストアにはBigtableを採用しました。GCPサービスの中では最も時系列データを扱うのに向いていたからです。(後ほど説明します)
さらにバッチ問題用にBigQueryを採用しました。集計が速く、且つ全員が慣れていて学習コストが低いSQLで操作できるためです。
データをBigtableからBigQueryにエクスポートしようと思っていたのですが、そのためのBigtableのリージョンがus-centralとeurope-westしかありませんでした。。。
そこでログをリアルタイムでエクスポートしてくれるStackdriverLogging採用。(後ほど審査時に突っ込まれます)
完成!・・・
しかし全員が口をそろえて言いました。
時間足りなくね(-.-;)

採用案

f:id:NakaWatch:20171024190746p:plain

とりあえず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年生が書いてるので、間違いがあるかもしれません!
もし見つけたら指摘して頂けるとありがたいです。

優勝やったー!

f:id:NakaWatch:20171024221118j:plain

f:id:NakaWatch:20171024221317j:plain