こんにちは。株式会社マイクロアドでソフトウェアエンジニアをしています、入社一年目のid:kobayashi-tomoakiです。
私たちはインターネット広告の配信システムをマイクロサービスアーキテクチャで開発しており、主にScalaを開発言語として使用しています。 この度、当該システムの一部を構成するScala 2を使用した動画広告配信サービス*1について、Scala 3への移行を実施しました。
今回は、その移行プロセスやその作業で得た知見についてレポートします。
導入
インターネット広告の配信処理はアドテク業界で標準化されており*2、基本的にはRTB(Real-Time Bidding、リアルタイム入札) という仕組みに基づいたオークションの形式で行われます。
RTBでは大きく二分して、広告を出す側のDSP(Demand-Side Platform)と広告を載せる側のSSP(Suply-Side Platform)で、システムが構成されます。*3 私が所属するチームでは、前者のDSPを開発しています。
弊社DSPはDDD + Clean Architectureベースのマイクロサービスアーキテクチャで設計されており、メイン言語としてはScalaを使用しています。 なお、マイクロサービス間はgRPCやHTTPなどで通信します。
参考まで:
- Scala、DDD、Akkaで立ち向かう 〜広告配信システムに課せられた100msの制約〜 - Speaker Deck
- 【新卒エンジニア向け】マイクロアドエンジニアの技術スタック(広告配信ユニット編) - MicroAd Developers Blog
- マイクロアドのアドテクを支える技術 - Speaker Deck
Scala 3は、元々Dottyというコードネームで開発が始まり、2021年に正式にリリースされてから3年ほど経過しています。 この間に様々な利用実績が積み重なり、いまや多くの企業がScala 3への移行を進めています。
弊社DSPに関しては、メインサービスでは未だにScala 2を利用しているものの、2024年頃から周辺サービスのScala 3への移行対応を始めています。 今回は、その周辺サービスのうち1つに相当する、VAST(Video Ad Serving Templete)という仕組みに基づいた動画広告配信システムをScala 3へ移行しました。
VASTによる動画広告配信とは
VAST(Video Ad Serving Template)は、XML形式によって(インストリーム形式の)動画広告配信を標準化したものです。
VASTの動画広告配信において、DSP・SSPからなる広告サーバは概ね以下のステップで動作します。
- 動画コンテンツの長さや位置情報などを、動画プレーヤから受け取る
- 以下の項目からなるXMLファイルのレスポンス(以下、VASTレスポンス)を、動画プレーヤに返す
- 動画広告の基本情報(広告タイトル、広告主、動画コンテンツ)
- トラッキングURL
- 広告クリック時遷移先URL
- etc.
VASTレスポンスでは、次のXMLタグが上述の項目を表します。
<AdTitle>
: 広告タイトル<Advertiser>
: 広告主<MediaFiles>
: 動画コンテンツ(のファイル)<Impression>
: トラッキングURL<ClickThrough>
: 広告クリック時遷移先URL
実際に、これらのXMLタグはVASTレスポンス内で次のように利用されます。
<VAST version="4.2" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns="http://www.iab.com/VAST"> <Ad id="20001" > <InLine> <AdSystem version="1">iabtechlab</AdSystem> <Error><![CDATA[https://example.com/error]]></Error> <Impression id="Impression-ID"><![CDATA[https://example.com/track/impression]]></Impression> <AdServingId>a532d16d-4d7f-4440-bd29-2ec05553fc80</AdServingId> <AdTitle>Inline Simple Ad</AdTitle> <AdVerifications></AdVerifications> <Advertiser>IAB Sample Company</Advertiser> <Category authority="https://www.iabtechlab.com/categoryauthority">AD CONTENT description category</Category> <Creatives> <Creative id="5480" sequence="1" adId="2447226"> <Linear> <TrackingEvents> <Tracking event="start" ><![CDATA[https://example.com/tracking/start]]></Tracking> <Tracking event="progress" offset="00:00:10"><![CDATA[http://example.com/tracking/progress-10]]></Tracking> <Tracking event="firstQuartile"><![CDATA[https://example.com/tracking/firstQuartile]]></Tracking> <Tracking event="midpoint"><![CDATA[https://example.com/tracking/midpoint]]></Tracking> <Tracking event="thirdQuartile"><![CDATA[https://example.com/tracking/thirdQuartile]]></Tracking> <Tracking event="complete"><![CDATA[https://example.com/tracking/complete]]></Tracking> </TrackingEvents> <Duration>00:00:16</Duration> <MediaFiles> <MediaFile id="5241" delivery="progressive" type="video/mp4" bitrate="2000" width="1280" height="720" minBitrate="1500" maxBitrate="2500" scalable="1" maintainAspectRatio="1" codec="H.264"> <![CDATA[https://iab-publicfiles.s3.amazonaws.com/vast/VAST-4.0-Short-Intro.mp4]]> </MediaFile> <MediaFile id="5244" delivery="progressive" type="video/mp4" bitrate="1000" width="854" height="480" minBitrate="700" maxBitrate="1500" scalable="1" maintainAspectRatio="1" codec="H.264"> <![CDATA[https://iab-publicfiles.s3.amazonaws.com/vast/VAST-4.0-Short-Intro-mid-resolution.mp4]]> </MediaFile> <MediaFile id="5246" delivery="progressive" type="video/mp4" bitrate="600" width="640" height="360" minBitrate="500" maxBitrate="700" scalable="1" maintainAspectRatio="1" codec="H.264"> <![CDATA[https://iab-publicfiles.s3.amazonaws.com/vast/VAST-4.0-Short-Intro-low-resolution.mp4]]> </MediaFile> </MediaFiles> <VideoClicks> <ClickThrough id="blog"> <![CDATA[https://iabtechlab.com]]> </ClickThrough> </VideoClicks> </Linear> <UniversalAdId idRegistry="Ad-ID">8465</UniversalAdId> </Creative> </Creatives> </InLine> </Ad> </VAST>
今回の対応では、 下図処理フローで「DSP内の動画広告用マイクロサービスでVASTレスポンスを生成」部分を担っているプロダクトのScala 3移行に取り組みました。
動画広告配信システムのScala 3移行の手順
今回移行対象となるのはそこそこ規模感があるプロダクトであり、一気にScala 3移行させるのは難しいです。 したがって、移行準備と本移行という2段階に大まかに分けてアップグレードに望みました。
移行準備:
- Scala 2.12 → 2.13
- Java 8 → 17
- sbt 1.9 → 1.10
- 依存ライブラリの変更
- Specs2 → ScalaTest + scalatestplus-mockito(単体テストフレームワーク)
- JAXB/Java EE → JAXB/Jakarta EE(XML data binding*6ライブラリ)
本移行:
- Scala 2.13 → 3.4
- Java 17 → 21
- 依存ライブラリの変更
- json4s → circe(JSONライブラリ)
- Akka → Apache Pekko(分散並行処理ライブラリ)
移行準備
単体テストの修正
移行準備で一番時間がかかったのは、Specs2からScalaTestへの移行に伴った、単体テストの修正でした。
基本的な方針としては、
A returns B
→ when(A).thenReturn(B)
などの頻出パターンを全て正規表現で機械的に置換してしまい、残った
contain(exactly(<foo>)))
→ contain theSameElementsAs Seq(<foo>)
などの個別ケースは1つずつ手作業で潰していくという感じでした。 あまり記憶が定かではないのですが、ScalaTestではdoRetrun-when構文だとdeep stubbingできない場合があるなどの問題もあり、 勘所を掴むまでそこそこ苦戦した印象があります。
単体テストは128ファイル・336テストケースにも及び、レビュワー泣かせの修正だったのではないでしょうか。
VASTレスポンスに用いるJAXBのJakarta EE移行 VASTレスポンスのXML生成には、JAXBのmarshalingを利用しています。*7 ところで、JAXBはJava 8では標準ライブラリ(Java EE)に付属してきたもののJava 11から外されてしまったので、Jakarta EEのJAXBに移行する対応が必要でした。
やることとしては、まずbuild.sbtの依存ライブラリに以下を追加します。*8
libraryDependencies ++= Seq( "jakarta.xml.bind" % "jakarta.xml.bind-api" % <version>, "org.glassfish.jaxb" % "jaxb-runtime" % <version>, "org.glassfish.jaxb" % "jaxb-xjc" % <version> )
次に、プログラム中の該当import文を
import javax.xml.bind
→ import jakarta.xml.bind
のように、機械的に置換すれば完了です。
結果 移行準備を完了すると、処理時間は平均で元の1/4倍程度に短縮されました。嬉しいですね。*9
本移行
移行準備を終えたら、次は本移行に着手します。
sbt-scala3-migrateの利用
こちらはScala公式のmigrationドキュメントを参考に進めていきました。基本的には、sbt-scala3-migrate
というsbt pluginを使って進めていくことになります。
// project/plugins.sbt addSbtPlugin("ch.epfl.scala" % "sbt-scala3-migrate" % "0.6.1") // build.sbt scalacOptions ++= Seq("-Xsource:3")
例えば、非互換のScala 2シンタックスの修正として、手続き型構文の修正、戻り値の型の明示化などの対応が必要です。 これらは完全にScala 3でdropされた構文なので、以下のコマンドで自動修正ができます。*10
sbt "migrateSyntax <project>"
sbt "migrateTypes <project>"
その他シンタックスの問題は放置してもwarningがでるだけではあるのですが、scalacOptionsの-Xfatal-warnings
と干渉してしまうので手動修正しました。
例えば次の対応をしました。
- importの
foo._
をfoo.*
へ置換 - case classのインスタンス生成時に
.apply
を明示的に呼び出すよう変更 mapValues
メソッドが非推奨となったので、代わりにview.mapValues
などを使うようにする- value classesをopaque typesに直す
- Pureconfigの
pureconfig.generic.auto._
をScala 3のtype class derivationで置き換える
また、依存ライブラリのバーションの衝突に関しては、次のコマンドの出力を参考に気合で直すことになります。
sbt "migrateDependencies <project>"
依存ライブラリの移行 JSONライブラリはjson4sからScala 3対応が進んでいるcirceに移行しました。*11 前準備の段階で行わなかったのは、type class derivationの利用がScala 3移行後でないとできないので、本移行のタイミングでまとめて対処したほうが楽だと判断したからです。
ここで、scalapb-circeのデフォルト設定のserialize出力が微妙にscalapb-json4sと互換性がないという問題がありました。 scalapb-circeの設定をいじるために公式ドキュメントを読破する余裕がなかったので、結局ScalaPB messagesのEncoderに関しては手動定義で出力形式を決め打ちました。
また、Scala 3移行の本筋にはあまり関係ないのですが、ライセンス問題のあるAkkaのApache Pekkoへの移行対応もついでに行いました。 これに伴い、build.sbtで次の設定をすることで、gRPCでScala 3の生成コードを利用できるようにもなりました。
// build.sbt Compile / pekkoGrpcCodeGeneratorSettings ++= Seq("scala3_sources")
Scala 3移行における詰まりどころ
上記手順で着々と移行作業を進めていき、無事Scala 3移行に成功しました。
なお、移行準備は比較的円滑に進んだ一方で、本移行においては何度も困難に遭遇しました。 本章では、記憶しているおよび理解が及んでいる範囲内で、これらの点について断片的に報告させていただきます。
その1:ScalaTestの単体テストが壊れる
単体テストに関しては、移行前はもともとSpecs2 + Mockito Scalaを使用していました。 ここで、Mockito ScalaはSpecs2だけでなくScalaTestとの組み合わせにも対応していますが、Scala 3への対応状況はあまり芳しくないようでした。 したがって、今回はScalaTest + scalatestplus-mockitoに移行しました。
ScalaTest + scalatestplus-mockitoへの移行とそれに伴う単体テストの修正は移行準備の段階で終わっています。 しかしながら、本移行の段階に入り、プロジェクトのscalaVersionをScala 2からScala 3に変更するとモック周りの挙動が変わってしまうようで、単体テストが壊れてしまいます。 したがって、単体テストに再度修正が必要になりました。
具体的には、case classのパターンマッチでスタブが機能しなくなるという問題が発生しました。 すなわち、次のような単体テストを作成すると、Scala 2では通るのにScala 3では通りません。
case class A(value: Int) def testA(a: A): Int = { a match { case A(100) => 100 case _ => 0 } } // OK in Scala 2 / BAD in Scala 3 "A mock" should "stubbing for pattern matching" in { val a = mock[A] when(a.value).thenReturn(100) assert(testA(a) == 100) }
$ sbt test # OK in Scala 2 [info] MockitoTest: [info] A mock [info] - should stubbing for pattern matching $ sbt ++3.4.3 # BAD in Scala 3 [info] MockitoTest: [info] A mock [info] - should stubbing for pattern matching *** FAILED *** [info] 0 did not equal 100 (HelloSpec.scala:31)
(再現性のために、こちらのリポジトリにまとめてあります:GitHub - kobayashi-tomoaki/mockito-test)
結局エレガントな解決策は見当たらず、case classはモックせずに実際のオブジェクトを作成することで回避しました。
その2:scalafmtの設定をScala 3対応するのを忘れる
同プロダクトのbuild.sbtではThisBuild / scalafmtOnCompile := true
になっています。
つまりコンパイル時にscalafmtがかかります。
したがって、.scalafmt.confで
runner.dialect = scala3
を設定しないと、フォーマット時にScala 2のシンタックスでパースしようとするので、本体のmigrateは完了しているのに何故かコンパイルしないという現象に遭遇します。
忘れずに上記設定を加えましょう(自戒)。
その3:何故か処理時間が4倍+αに増加する
諸々の問題を解決してscalaVersion = Scala 3でコンパイルすると、コンパイルはするものの、何故か処理時間が4倍以上に増加してしまいました。
まずflame graphをとって観察してみると、移行前よりもG1GCやJITが全体の処理時間を占有していることに気づきました。 ここで、同プロダクトのJVMの設定をみてみると、チューニングがされておらず殆どデフォルト設定のままになっていました。
すなわち、JVMデフォルト設定の状態でScala 2からScala 3にアップグレードしたらパフォーマンスが劣化したため、 Scala 3ではデフォルト設定でのパフォーマンスが比較的洗練されていないということが判明しました。
こちらに関しては、適当に
JAVA_TOOL_OPTIONS=-Xms10G -Xmx10G
のようにヒープを盛ってやるだけで、悪化した処理時間のうち2倍分を削減できました。
上記で2倍分は解決しましたが、残りの2倍+α(αは無視できない程度に大きい)の分が謎なままです。 複数の入力パターンを試して切り分けてるうちに、どうやらVASTリクエストの生成処理が怪しいというところまでは特定できましたが、 これ以上工数をかけている余裕がなかったので時間切れ。*12
結局私ではなく、先輩エンジニアのid:tobita_yoshikiさんに解決していただきました。 そもそも今回の問題は処理時間が増加してしまうことだったので、移行対応起因で何故か遅くなってしまった部分の速度を改善せずとも、既存の非効率な処理をやってる部分の速度改善で埋め合わせられれば十分でした。
実際、無駄に新規生成していたオブジェクトを使い回すように修正する、とある処理で使っていたデータ構造をSetからMapに変更するなどの対応で、2倍分以上を削減できました。 これによって、許容範囲内(移行前と同等またはそれ以下)のパフォーマンスを達成することができ、無事本番リリースを迎えることができました。
結論
本稿ではScala 2で開発されていた動画広告配信システムについて、システムの概要の説明、Scala 3移行する際にとった手順、Scala 3移行で詰まった点について述べました。 羅列してしまえば比較的シンプルな対応だったように見えますが、アップグレードに伴った各ライブラリの状況把握には根気強い調査が必要で、なかなか大変でした。
今後は動画広告配信システムにとどまらず、2025年にサービス全体でのScala 3移行を完了させたい所存です。*13
*1:ただし、このサービスが担うのは、インストリームという形式の動画広告に限ります。 インストリーム広告とは、サイトやアプリの動画コンテンツを再生するときに、 その再生前、再生中、再生後のいずれかに再生される動画広告のことです。
*2:OpenRTBという。
*3:端的に言うと、DSPが広告枠の需要、SSPが広告枠の供給です。
*4:あくまでイメージ図です。
*5:あくまでイメージ図です。
*6:XML–オブジェクトのマッピングのこと。
*7:正確には、unmarshaling(xjcでVASTのXML Schemaからオブジェクトの生成)にも利用。
*8:jaxb-xjcが示唆するように、正確にはsbt-xjcも使っているので、xjcLibsをこれらを足すことも必要。
*9:当初はJava 17へのアップグレードのお陰だと思っていたましたが、本移行の感じを見ている限りJakarta EEの部分が効いたような雰囲気もあります(詳細不明)。
*10:内部的には、ここに列挙されたパターンでScalafixを適用しているらしいです。
*11:Scala 3のjson4sはいろいろ辛いみたいですね:https://xuwei-k.hatenablog.com/entry/2024/06/17/091340
*12:Scala 3とJAXBのバージョンとの相性とかでしょうか…?
*13:組織やチームではなく、個人の意見です。