マーベリックのDSPプロダクトSphereのバックエンド技術構成

マーベリック技術広報担当のリチャード伊真岡です。

今回は弊社のDSP(Demand Side Platform)プロダクトであるSphereバックエンドの技術構成を紹介いたします。Sphereも弊社のもう一つのプロダクトであるCirquaと同様、バックエンドはScalaで書かれています。

採用技術: Scala, Finagle, PlayFramework, ScalikeJDBC, MariaDB, Redis, Kafka, Spark, HDFS, 等

DSPとは

DSPとはDemand Side Platformの省略で、広告枠を「要求する・買う」側つまり広告主のためのサービスです。DSPと対になるサービスがSSP、Supply Side Platformと呼ばれこちらは広告枠を「供給する・売る」側、つまりWebサイトやアプリを運用するメディア側向けのサービスです。

どちらがDemandでどちらがSupplyかわかりづらいかもしれませんが、アドテクノロジーの世界では商品としての広告枠に対してのDemandとSupplyという意味でDSPとSSPという呼び方になっています。

DSPとSSPは別の会社が運営する全く別のサービスの場合もありますが、中には一つの会社がDSPとSSP両方のサービスを運営していることもあります。

さて、DSPとSSPがどう連携しているかをお見せするためにこちらをご覧いただきたいと思います。弊社デザイナ坂東とエンジニアが協力して作成しScalaMatsuri期間中に公開した「あなたのブラウザに広告がでる仕組み」です。この記事では簡単のためADNW(アドネットワーク)に関しては説明しませんが、DSPとSSPが連携することによって広告主の出向した広告がメディアの広告枠に表示される様子を見て取れるかと思います。(今回はアニメーションで表現していますが実際にはLEDデバイスを経路に沿ってひからせるIoTデバイスとして制作しました。)

f:id:maverick-techblog:20190819120115g:plain

Sphereの処理の流れ

DSPとSSPが連携して広告表示する場合、メディアの運営するWebページの広告枠に対してオークションが行われて表示される広告が決定されます。そのオークションに関わる処理の流れをSphereの視点から詳しく見ていきましょう。

f:id:maverick-techblog:20190819121252p:plain

まず、訪問者がメディアの運営するWebサイトを閲覧すると、SSPに対して広告要求が送られます。Sphereは入札サーバを通してSSPから入札要求を受け取り、入札要求に付属するデータに基づいて入札に参加するかどうかを自動で判断します。そして参加する場合入札サーバはSSPに対して入札を送信します。

f:id:maverick-techblog:20190819121334p:plain

ここでSSPはSphereにだけ入札要求を送るのではなくオークションを行うため複数のDSPサービスに対して入札要求を送っています。各DSPサービスはSphereと同様に入札参加すると判断した場合に入札をSSPに対して返します。

f:id:maverick-techblog:20190819121359p:plain

Sphereが広告枠を落札できたときはSphereから送信された広告が表示されます。ただし広告の画像は入札データの中に含まれるのではなく、Sphere内の別の画像配信サーバーから読み込まれます。 アドテクノロジーの世界では広告が表示されたことを「インプレッション」とよび計測の対象としています。Sphereの場合広告に含まれるタグによってトラッキングサーバにインプレッションのデータを送り、さらに訪問者が広告をクリックしてリンク先の広告主のページに遷移したら「広告クリック」を計測します。インプレッションや広告クリックなどの広告効果測定のためのデータはすべてトラッキングサーバからKafkaに送られ、その後HDFSに保存されます。 この記事では詳しく説明しませんが、Sphereの機械学習システムはこのHDFS上のデータに対してSparkジョブを走らせることが出来、その結果が広告配信の最適化などに利用されています。

Sphereの技術構成

ここからはSphereの技術構成を紹介します。

f:id:maverick-techblog:20190819121449p:plain

入札・トラッキングサーバはScalaで実装されていて、そのバックエンドにはRedis、さらにScalikeJDBCを通してアクセスするMariaDBがあります。また先程述べたように、トラッキングサーバーからは広告効果測定のためのデータがKafkaへ送られています。機械学習システムについてはこの記事では大まかにしか触れませんが、いずれ別の形で詳しく紹介するつもりです。

f:id:maverick-techblog:20190819121532p:plain

入札サーバはおよそ60インスタンス、トラッキングサーバはおよそ30インスタンスが同時に走っています。これらSphereのサーバー群はほぼ全てオンプレミスのデータセンタ内に配置されていて、機械学習用のインスタンスなどを含めると全体でのサーバーの数は約270台にもなり、そのサーバー郡の監視にはZabbixを利用しています。

実際にはこの記事で紹介する以外にも様々な処理が行われており、機械学習システムがバッチ処理の結果をMariaDBに書き込むケースも有ります。複雑なビジネスの要求に答えるため年月をかけて注意深く実装されたシステムがSphereの面白いところだと思います。

バックエンドはScala!インフィード広告プロダクトCirquaのバックエンド技術構成を紹介します

マーベリック技術広報のリチャード伊真岡です。

今回は弊社のインフィード広告プロダクトCirquaの技術構成及び技術的課題の一部を紹介したいと思います。CirquaのバックエンドはScala、フロントエンドはTypeScriptで書かれており、今回の記事ではバックエンドの紹介をします。

採用技術: Scala, Finagle, PlayFramework, ScalikeJDBC, Amazon RDS(MariaDB), Amazon ElastiCache(Redis), Fluentd, Amazon S3, Amazon Athena 等

Cirquaの仕組み紹介

Cirquaはインフィード広告と呼ばれる種類の広告を主に扱うプロダクトです。

f:id:maverick-techblog:20190801145339p:plain

インフィード広告とはSNSのニュースフィードに代表されるようなタイムライン状に表示されるコンテンツの中に表示される広告を指していて、「フィード」の「中」に表示されるので「インフィード広告」と呼ばれています。

技術構成の紹介の前に、まずCirquaの仕組みを簡単に紹介したいと思います。以下では訪問者がCirquaを導入しているメディアのWebサイトを閲覧したときの動作を簡略化して説明します。

f:id:maverick-techblog:20190801145324p:plain

訪問者がWebページを閲覧すると、ページのHTMLに埋め込まれたCirquaのScriptタグが読み込まれます。そのScriptタグによってマーベリック側のネットワークにある広告配信サーバから広告を取得しWebページ内にインフィード広告が表示されます。

f:id:maverick-techblog:20190801145345p:plain

インフィード広告が表示された時点で訪問者が広告を表示したことがカウントされ、それをアドテクの世界では「インプレッション」とよんで計測しています。インプレッションの発生はトラッキングサーバに送信され、更に訪問者がその広告をクリックして広告主のウェブサイトへ遷移した場合その「クリック」をトラッキングサーバに送信しそれも計測しています。

またトラッキングサーバ側は集計処理も行うので以下のデータ集計用のフローにもつながっています。 計測、集計したデータは機械学習チームにも渡され、広告配信の配信ロジックに利用されます。

f:id:maverick-techblog:20190801145343p:plain

Cirquaのバックエンドで採用している技術スタックを以下のように図示してみました。Webページから送信されたインプレッションとクリックのデータはfluentdを介してAmazon S3に送られ、バッチ処理が定期的に走ってAmazon Athenaを用いて集計しています。Amazon Athenaを使うとS3にため込むデータの形式さえ整えれば集計処理が手軽にできるので運用はとても楽です。

Cirquaバックエンドの技術スタックを以下のように図示してみました。

f:id:maverick-techblog:20190801145352p:plain

広告配信サーバはScalaで書かれており、非同期処理部分はFinagleを用いて実装しています。HTTPのRequest/Responseのパラダイムに従って素直に非同期処理が書ける点がCirquaチームがFinagleに感じる魅力です。トラッキングサーバはPlayFrameworkを使っています。このFinagleとPlayFrameworkの使い分けはチームの両フレームワークに対する慣れや過去のプロダクト開発における知見の積み重ねなどから総合的に判断して使い分けをしています。バックエンドのデータベースはRDS(MariaDB)を使っていてScala側からはScalikeJDBCを使って接続しています。ScalikeJDBCはSQLを素直に書けるところが使いやすいライブラリだと感じています。

バッチ処理については先進的な試みとして一部でScala ZIOを取り入れています。Scala ZIOは関数型を活かしつつもオブジェクト志向プログラマにも馴染みがあるDependency Injectionを取り入れており新たに学ぶ概念が少なくわかりやすいとされています。非同期処理がきれいに書けるところがとても良いと感じています。バッチ処理は広告配信サーバとトラッキングサーバ本体とは切り離されているのでScala ZIOの導入がしやすく、まずはバッチ処理から取り入れてみることにしました。

開発チームの構成

Cirqua開発チームの構成は:

  • バックエンドエンジニア2人
  • フロントエンドエンジニア3人
  • プロダクトオーナー1人

となっており、これ以外にインフラエンジニアとCirquaを含む複数プロダクトのサーバーやネットワークを管理し、Quality Assuranceチームが同じように複数プロダクトを担当しています。

Ciruquaチームでは毎週のリリースをdevelop環境は木曜、production環境は月曜日にリリースを行っています。 develop環境のリリース後はQuality Assuranceチームを交え全員で画面を触り、新機能のチェックを行います。 こうすることで常にチーム全員がプロダクトの新機能、いつどの機能が入ったかなどを把握できる状況を作っています。

リリースはslack botから実行できるよう自動化されています。少人数チームではContinuous Deliveryの仕組みは必須ですね。

今取り組んでいる技術的課題

現在CirquaのインフラについてはAmazon EC2上でそのままプロセスを走らせる形からKubernetesを使った運用に移せないか検証を行っている最中です。もちろん導入が上手くいけばKubernetesエコシステムを活かしたインフラ運用、そしてアプリケーションエンジニアがインフラ運用の一部を担当できるというメリットを狙ってのものですが、なによりKubernetesを触るのはエンジニアとして楽しいですよね!Kubernetesに伴う負担を可能な限り軽減しつつ、楽しさを存分に活かせる導入方法を現在模索中です。

さらにCirquaシステムは一部リファクタリングを行っており、Domain Driven Design的な設計手法を取り入れてアプリケーションのコア部分を書き直しています。scala言語の表現力の高さを活かし、コアドメインを宣言的かつ表現力豊かに再定義することができています。チームメンバは社内で開催されているDomain Driven Design読書会にも参加し、Domain Driven Designに対する理解を深める努力を行っています。

Lens の実用例

ご無沙汰しています。最近ヒトカラがマイブームのあおいの(@aoiroaoino)です。
そういえば ScalaMatsuri 2016 のスポンサー LT でこんなこと言っていたのを思い出しました。

「弊社では Lens(Monocle) を test で使用しています。」

今回はこれの話をしようと思います。

続きを読む

0.7以降のmsgpack-javaを使って古いフォーマットでシリアライズする方法

こんにちは、chaltosです。

今回は、msgpack-javaのバージョンを0.6.Xから上げたいが0.6.Xと同じフォーマットでシリアライズしたい場合の対処についてです。

MessagePackとは何なのかといった部分は省略させていただきます。

続きを読む

JVMのバイトコードの実行時最適化について発表してきた

こんにちは、todeskingです。

ScalaMatsuri 2016将軍スポンサーが合同で開催した「Scala将軍達の後の祭り」 というイベントで、バイトコードの実行時最適化について発表してきました。

抽象化によってオーバヘッドが存在するコードを実行時にバイトコードレベルで最適化すれば、抽象化とパフォーマンスが両立出来てお得、という夢のある話です。

以下、補足など。

続きを読む

Redis Expire Performance

Scala書けますで入社した筈なのにnewrelicみながらDBのチューニングばかりしてる、ぽんこつです。

今回はRedisをどうやって叩くと早くなるのかを、主にexpireまわりで適当にベンチしたりした。

私のDBの経験値がMySQLに偏っているのであまり手出ししてこなかった弊社のRedis環境だが、パフォーマンス絡みの問題を聞いたり見かけるようになってきたので、Redisのパフォーマンスを引き出す方法について調査をした。

弊社のサーバアプリケーションは主にScalaで書かれているので、ScalaからRedisに繋いだときのベンチマークが中心になる。

JMH

JMHはマイクロベンチマークツールの一種。JVMJITによって、何度も同じ箇所を実行すると、徐々にコードを最適化して早く動くようになる、等々の要素を排除し、できるだけ正しいベンチマークをすることができるツール。

ScalaでJMHを使う場合はsbt-jmhを使うと良い。sbtのpluginとして使うことができる。今回はsbt-jmhを使っている。(ただしRedisの場合I/Oがからむので、JMH本来の正確さは期待できないと思われる)

BulkInsert

とりあえず30万件データを突っ込むのに掛かる時間を調べる。jedisのオーバヘッドが大きい可能性を考慮してまずは最速パターン(と思われる)を計測してからjedisの計測に入る。

shell

for i in `seq 1 10000`
do
    redis-cli set ${i}key ${i}value
done

適当に作ったが、あまりにも時間掛かり過ぎて計測不能だった。1コマンド毎にredis-cliを立ち上げるのは無謀だった模様。

shell (redis protocol)

一旦コマンドリストを生成して流し込んでもいいけど、今回はredis protocolを使ってみる。

http://redis-documentasion-japanese.readthedocs.org/ja/latest/topics/mass-insert.html

redis protocolをpipeで流せばいいらしい。該当ページのRubyスクリプトを参考に組んでみる。

def gen_redis_proto(*cmd)
  proto = ""
  proto << "*"+cmd.length.to_s+"\r\n"
  cmd.each{|arg|
    proto << "$"+arg.to_s.bytesize.to_s+"\r\n"
    proto << arg.to_s+"\r\n"
  }
  proto
end

count = ARGV.size > 0 ? ARGV[0].to_i : 300000

(1..count).each { |i|
  puts gen_redis_proto("SET","key%d" % i,"value%d" % i)
}
puts gen_redis_proto("FLUSHDB")

これで生成したredis protocolをredis-cliに渡す

ruby gen_proto.rb | time redis-cli --pipe

結果は1.303s。はやい。

jedis

Javaに対応したバインディングは幾つもあるが、今回は一番メジャーっぽいjedisを使う。社内で使ってるからという話もある。

@Benchmark
@BenchmarkMode(Array(Mode.AverageTime))
def jedis(): Unit = {
  val client = new Jedis()
  (1 to 300000).foreach { i =>
    client.set(s"key${i}", s"value${i}")
  }
  client.flushDB()
}

Warm upを3回、Iterationを5回やって平均を出す。

jmh:run -i 5 -wi 3 -f1 -t1 org.mvrck.BulkInsertTest

結果は4.274s。ちょっと遅過ぎる気がする

jedis multi

コマンドを毎回発行してるから遅いと思われるので、Transactionを使ってみる

https://github.com/xetorthio/jedis/wiki/AdvancedUsage

今回からannotationは省略して書く

def setMulti(): Unit = {
  val client = new Jedis()
  multi(client) { t =>
    (1 to 300000).foreach { i =>
      t.set(s"key${i}", s"value${i}")
    }
  }
  client.flushDB()
}

def multi[T](jedis: Jedis)(f: Transaction => T): T = {
  val multi = jedis.multi()
  val res = f(multi)
  multi.exec()
  res
}

0.541s。アイエェ!?redis protocolより早いナンデ!?timeの時間計測に問題あるのかな…?まぁ早いならいいかな…(適当)

expire遅くないか

と本番環境のnewrelic見て思ったので調査する。

setWithExpire

まずはset時にexpireを設定する

def withExpire(): Unit = {
  val client = new Jedis()
  multi(client) { t =>
    (1 to 300000).foreach { i =>
      t.set(s"key${i}", s"value${i}", "NX", "EX", 100)
    }
  }
  client.flushDB()
}

0.792s。割と遅くなる。

別でExpireする

挿入する物によっては1コマンドでexpire設定できないこともあるので、それを模してみる

def splitExpire(): Unit = {
  val client = new Jedis()
  multi(client) { t =>
    (1 to 300000).foreach { i =>
      t.set(s"key${i}", s"value${i}")
      t.expire(s"key${i}", 100)
    }
  }
  client.flushDB()
}

1.031s。更に遅くなる。ので、setするときにexpireが設定できないリスト型などで速度が気になりそう。

expire time

expireを設定したkeyが実際に削除されるタイミングで負荷が上がるのでは、という仮説があったので調査する。1msで揮発するようにすればそれっぽいテストができそう。

def withLittleExpire(): Unit = {
  val client = new Jedis()
  multi(client) { t =>
    (1 to 300000).foreach { i =>
      t.set(s"key${i}", s"value${i}", "NX", "PX", 1)
    }
  }
}

1.002s。単純にexpireを設定した場合が0.792sなので遅くなってる。瞬間的に揮発のタイミングが重なるとちょっとあぶないかもしれない。

read時にexpire

Redisの使用用途は主にcacheなので、使用頻度が低い奴は消えて欲しいけど高いやつは残って欲しい。良くある。そこでreadするときにexpireすればいいのでは!本当か?

read only

比較対象としてreadの速度を計測する。

def read(): Unit = {
  val client = new Jedis()
  multi(client) { t =>
    (0 until 100).foreach { i => t.set(s"key${i}", s"value${i}") }
  }
  multi(client) { t =>
    (1 to 300000).foreach { i => t.get(s"key${i%100}") }
  }
  client.flushDB()
}

0.286s。流石危険な程早いと言われるRedis。

read with expire

expireを付けるとどうなるか。

def readWithExpire(): Unit = {
  val client = new Jedis()
  multi(client) { t =>
    (0 until 100).foreach { i => t.set(s"key${i}", s"value${i}", "NX", "EX", 100)}
  }
  multi(client) { t =>
    (1 to 300000).foreach { i =>
      val key = s"key${i % 100}"
      t.get(key)
      t.expire(key, 100)
    }
  }
  client.flushDB()
}

0.618s。倍ぐらい掛かるようになった。

倍ならまぁ、という気もするが、可能ならばRedisサーバ側の設定を変更し、maxmemory-policyのallkeys-lruなどで設定した方が良いと思われる。

まとめ

Redisのexpireすごく遅くないか、という疑惑があり色々比較してみたけど、大体倍ぐらいだったので、maxmemory-policyの検討をしつつ必要なら使えば良いのでは、という結論になった。あとtransactionは強力なのでできるだけ使うようにしたい。

弊社での実測の話をすると、getの度にexpireした結果として、expireの処理時間が結構長いことがnewrelicで判明した。このため、getと同時にttlコマンドで残り期間を確認して、伸ばす必要があるときだけ伸ばすようにした結果、expireが激減して全体として高速化した。ただしその場合transactionが使えなくなるので今回は計測しなかった。

今回テストに使ったコードは以下にある。

https://github.com/mvrck-inc/sandbox/tree/master/redis-performance

計測方法にあまり自信が無いので、問題がありそうなら教えて欲しい。

今回の環境

OS: Arch Linux
Redis: 3.0.7
JDK: OpenJDK 1.8.0_74
CPU: Core i5 4690T
Memory: 16GB

SSL更新

今回はssl更新の記事です。

インフラの方々は作業されたことがあると思いますが私は初挑戦です!

macで作業します。

バージョンを確認

[astel@astel-no-MacBook-Pro ] $ openssl version
OpenSSL 0.9.8zg 14 July 2015

秘密鍵作成のための乱数ファイルを生成します。

[astel@astel-no-MacBook-Pro ] $ dd if=/dev/urandom of=rand.dat bs=1k count=4
4+0 records in
4+0 records out
4096 bytes transferred in 0.000492 secs (8327615 bytes/sec)

[astel@astel-no-MacBook-Pro ] $ ls
rand.dat

作成した乱数ファイルから秘密鍵を生成します。

途中で秘密鍵のパスワードを入力します。

[astel@astel-no-MacBook-Pro ] $ openssl genrsa -rand rand.dat -des3 2048 > private_key.pem
4096 semi-random bytes loaded
Generating RSA private key, 2048 bit long modulus
..........+++
.......+++
e is 65537 (0x10001)
Enter pass phrase: (パスワード入力
Verifying - Enter pass phrase: (もういちど入力

[astel@astel-no-MacBook-Pro ] $ ls
private_key.pem rand.dat

CSRを作成します。

[astel@astel-no-MacBook-Pro ] $ openssl req -new -key private_key.pem -out csr.pem
Enter pass phrase for private_key.pem: (上記で入力した秘密鍵のパスワードを入力
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:JP
State or Province Name (full name) [Some-State]:Tokyo
Locality Name (eg, city) []:Shinjuku-ku
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Maverick Inc.
Organizational Unit Name (eg, section) []:Development
Common Name (e.g. server FQDN or YOUR name) []:*.mvrck.co.jp
Email Address []:

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:

[astel@astel-no-MacBook-Pro ] $ ls
csr.pem     private_key.pem rand.dat

作成したCSR(csr.pem)を申請サイトへ貼り付けて申し込みます。

今回発行されたSSL証明書_.mvrck.co.jp.crtと名前を変えてサーバへ設置していきます。

対象サーバは全てnginxで動いてます。設定を見てみましょう。

$ grep ssl_certificate /etc/nginx/conf.d/mvrck.conf 
    ssl_certificate /etc/ssl/nginx/_.mvrck.co.jp.crt;
    ssl_certificate_key /etc/ssl/nginx/_.mvrck.co.jp.key;

ふむふむ /etc/ssl/nginx/ 以下に置かれているようですね。

ここで_.mvrck.co.jp.keyなんて作ってないぞ と思いましたがssl_certificate_key ... なるほど 秘密鍵??

疑問に思いながら最初の方に作成した秘密鍵 private_key.pem をリネームして設置しました。

$ mv private_key.pem _.mvrck.co.jp.key

また権限がnginxにしないとうまいこと動いてくれなかったのでchown nginxで設定します。

よしこれでとりあえず$ nginx -t で確かめてみよう!

$ nginx -t
Enter PEM pass phrase:

秘密鍵の入力を求められました。パスワードを入力して

syntax is ok
test is successful

と設定自体はよさそう。restartさせてみる。

$ /etc/init.d/nginx restart
 * Checking nginx' configuration ...
Enter PEM pass phrase:                                                                                                                                                                                                 [ ok ]
 * Stopping nginx ...                                                                                                                                                                                                  [ ok ]
 * Starting nginx ...
Enter PEM pass phrase:   

と,これでweb上で確認するとしっかり更新されていました!

このEnter PEM pass phrase:ですが毎回入力せずにも設定できるようです。

例としてパスフレーズを聞かれないようにするには

$ openssl rsa -in private_key.pem -out _.mvrck.co.jp.key
Enter pass phrase for private_key.pem:
writing RSA key

とすることで_.mvrck.co.jp.keyというパスフレーズなしのkeyが作成されます。

設置し直して$ nginx -t等で試してみるとパスフレーズなしでもできました。

私はweb上でssl証明書をみて更新されていると確認したのですがcuiでも出来るよということで教えてもらいました。

$ openssl s_client -connect www.mvrck.co.jp:443 < /dev/null 2> /dev/null | openssl x509 -noout -startdate -enddate

これで無事更新完了です。

無事といいつつ実はCommon Nameを間違えて発行して色々あったのですがそれはまた別のお話.....