every Tech Blog

株式会社エブリーのTech Blogです。

Step Functionsで作るサーバーレスなETL基盤

はじめに

こんにちは。DELISH KITCHEN開発部の村上です。

エブリーが運営しているサービスのDELISH KITCHENやトモニテではプレゼントキャンペーンが定期的に行われており、ユーザーさんは開催中の複数のキャンペーンから気になるものを選んでいくつかの設問に答えることで応募することができるようになっています。

今回はそのプレゼントキャンペーンのETL基盤をStep Functionsを利用してサーバーレスで構築した話を紹介させていただきます。

Step Functions導入の背景

当時の技術選定で意識したことは次のようなことでした。

  • 運用面と費用面で低コストで実現できる
  • データエンジニア以外でも開発・運用できる
  • 将来的なデータの増加や実行頻度の変更に対応してスケールできる

特にこの基盤においては初めは少人数で運用コストをいかに下げてPoCしていけるかが重要だったので運用コストは重要な判断基準になっていました。その中でAWSではワークフローエンジンの選択肢としてGlue WorkflowやMWAAもありましたが、AWSサービスとの連携も豊富でjobの実行基盤が縛られず、当時はデータ量や処理負荷からしてjobとしてlambdaを採用することでコストも最小限にできるStep Functionsを選びました。また、Step FunctionsはGlue jobとも接続可能なので、データ量の増加により処理負荷が高くなってくれば、差し替えも検討でき、柔軟性が高いことも決め手となりました。

ステートマシンの構成

Step Functionsのワークフローはステートマシンと呼ばれています。ここからはエブリーで使っているステートマシンの構成とStep Functionsの機能を使ってどういうふうにETLを実現しているかを説明させていただきます。

全体

全体としては現状は大きく分けて、データ変換とカタログ更新の二つに分けれており、ステートマシンの実行はEventBridgeからスケジュールして定期的に行っています。オンタイムでの別データ基盤への転送をする場合はこのステートマシンを拡張することによって依存関係を制御することができます。次にそれぞれの処理の詳細を説明します。

データ変換

プレゼントキャンペーンの要件として、それぞれのキャンペーンは独自のデータを持っていて、そのデータ変換の要件もキャンペーンやその実施期間によって異なります。また、開催されるキャンペーン数も次第に多くなっていくことが想定されていたのでStep Functionsで提供されているMapステートを用いた動的並列化をしてそれぞれの変換処理からS3の格納までを行なっており、ある程度データ量が多くなってきた時にでもlambdaの実行時間の制限を回避できるようにしています。Mapステートに対してあらかじめ登録してあるキャンペーン内容を以下のような構造で渡して並列実行を行なっており、自身で指定することで特定のキャンペーンの再実行も可能です。

{
  "campaigns": [
    {
      "platform": "service1",
      "campaign": "campaign1"
    },
    {
      "platform": "service2",
      "campaign": "campaign2"
    }
  ]
}

カタログ更新

前のフローで求められた要件でのデータ変換までは満たせていますが、このままだと他の基盤へのデータ転送や分析が困難なため、Athenaで抽出できるようにしています。ただ、先ほど説明させていただいた通り、キャンペーンはそれぞれ独自のスキーマを持っており、自前でやろうとすると毎回自分でデータカタログのスキーマ定義をしないといけません。そこでその運用コストを省くためにGlue Crawlerで変換したS3のデータを読み込ませて自動でカタログが作られるようにしています。Step Functionsには非同期な処理をWaitステートとChoiceステートを活用して待てるようになっており、実行結果が返ってきたらChoiceステートを使ってステートマシンを終了できるようになっています。

活用する上でのTips

このようなステートマシンを構築していく中でStep Functionsを活用する上でのTipsがいくつかあったので紹介させていただきます。

エラー通知

Step Functionsはステートマシン自体がエラーになった時を発火タイミングとしてEventBridgeと連携することができるようになっているのでそれを組み合わせることで簡単に通知自体はできてしまいますが、EventBridgeが受け取れる情報には制約があり、ステートマシンの何がどんな理由で失敗したのかがコンソールに行くまでわかりません。また後述しますが、一部処理が失敗したときにステートマシン自体を失敗とできないケースもあるのでEventBridgeだけでは満たせないこともあり、EventBridgeに加えて他の通知手段を用意しないといけない状況でした。

そこで実行エラーをハンドリングするCatchフィールドを使って、エラー情報を取得し、エラー通知を送るlambdaを独自で用意しています。後続のタスクに以下の情報をlambdaであればeventとして受け取れるのでそれを通知したい内容によって加工することで素早くエラー内容を把握できる体制を作ることができます。

{
  "FunctionName": "step functionsのarn",
  "Payload": {
    "Execution": {
      "Id": "実行ID",
      "Input": {
       // それぞれの環境でのjobの発火内容の詳細が入る
      },
      "StartTime": "実行開始時間",
      "Name": "実行名",
      "RoleArn": "ロールのarn"
    },
    "param": {
      "Error": "Error",
      "Cause": "エラー内容"
    }
  }
}

並列実行時のジョブキャンセル回避

先ほどのデータ変換のフェーズでMapステートによる動的並列処理を行なっていると説明しましたが、実はMapステートを使っただけだと並列する処理の一部が失敗した時に後続の処理は全て停止してしまいます。これは求められる要件にもよると思いますが、ある一部の処理が失敗してもそのエラーを通知しつつ、それ以外の処理は続けて欲しいケースはあると思います。

そういった場合に並列処理の中にエラー時の例外処理を追加し、エラー通知を行った上で正常終了させることによって実現可能です。これにより他の処理は後続のタスクに移行させ、影響を最小限にとどめることができます。

ペイロードサイズの制限回避

Step Functionsでは各処理で実行した結果を後続のタスクに渡すためにデータの受け渡しを行うシーンが多いかと思います。ただ、ペイロードサイズには制限があり、遷移時に渡せるデータは256KBまでです。例えばあるMapステートでの並列処理の返り値がサイズを超える場合、以下のようなエラーが出て処理は中断されてしまいます。

The state/task 'Map' returned a result with a size exceeding the maximum number of bytes service limit.

これを防ぐためにステートマシンを作る上で意識したい2つのポイントがあります。

大きいペイロードはS3や外部ストレージを使う

あらかじめ大きくなると予想されるデータは後続のタスクに渡す前にデータをS3やDBに保存して、その格納先の情報を後続に渡すことにより、データサイズを小さくすることができます。 また、同じ問題は並列処理を実行するMapステートに渡すJSON配列で起きる可能性もあります。現在ではStep FunctionsにDistributed MapステートがMapステートとは別で提供されており、S3内の大規模データをもとに並列処理が実行できるようになっているので、並列数や並列時に渡したいデータ量が増える場合には利用を検討すると良いと思います。

出力をフィルタリングして必要なデータに絞って渡す

lambdaなどで挙動を制御できる場合にはその処理の中で出力を最小限にすると思いますが、AWSのサービスとノーコードで連携する場合にその処理が出力するデータ自体は制御することができません。例えば、ステートマシンの中でathenaのクエリ結果を取得しようとすると以下のようなJSONが出力されます。

{
  "QueryExecution": {
    "EngineVersion": {
      "EffectiveEngineVersion": "Athena engine version 2",
      "SelectedEngineVersion": "Athena engine version 2"
    },
    "Query": "発行したクエリ",
    "QueryExecutionContext": {
      "Database": "データベース"
    },
    "QueryExecutionId": "実行ID",
    "ResultConfiguration": {
      "OutputLocation": "s3のロケーション"
    },
    "ResultReuseConfiguration": {
      "ResultReuseByAgeConfiguration": {
        "Enabled": false
      }
    },
    "StatementType": "DML",
    "Statistics": {
      "DataScannedInBytes": 191831,
      "EngineExecutionTimeInMillis": 1704,
      "QueryPlanningTimeInMillis": 796,
      "QueryQueueTimeInMillis": 647,
      "ResultReuseInformation": {
        "ReusedPreviousResult": false
      },
      "ServiceProcessingTimeInMillis": 85,
      "TotalExecutionTimeInMillis": 2436
    },
    "Status": {
      "CompletionDateTime": 1693159279869,
      "State": "SUCCEEDED",
      "SubmissionDateTime": 1693159277433
    },
    "SubstatementType": "SELECT",
    "WorkGroup": "ワークグループ名"
  }
}

ただ、多くの場合で欲しいデータは実際にクエリ結果がどこのs3に保存されているかだと思います。その場合には次のように入出力制御のためのOutputPathパラメータを使ってフィルタリングをすることによって欲しいデータだけを後続に渡すことができます。

$.QueryExecution.ResultConfiguration

実行結果

{
  "OutputLocation": "s3のロケーション"
}

他にも出力制御にはResultSelectorやResultPathなど出力をコントロールするパラメータが提供されており、これらを活用することにより欲しい結果に絞ったデータを作ることができます。

現状の課題

ここまでエブリーにおけるStep Functionsの活用事例について紹介させていただきましたが、しばらく運用している中で次のような課題も出てきました。

ステートマシンのIaC化

ステートマシン自体はASL(Amazon States Language)で記述されているのでコードで管理できないわけではありません。ただ、個人的にこの記述自体の学習コストが高く、ステートマシンが複雑になればなるほどコンソールのビジュアライズされたWorkflow Studioで組んだ方が直感的なケースが多いと感じます。

今はそれほどステートマシン自体の変更を行う機会が少ないので大きな問題にはなっていませんが、今後の運用を考えるとIaC化は行なっておいた方が良いので、AWS CDKやTerraformでの管理方法などチームでPoCを行っていきたいと思っています。

各lambdaの管理

Step Functionsでステートマシンを作る場合にペイロードのちょっとした加工をして後続の処理に渡したいケースもたまにあり、提供される機能では足りない場合に徐々にlambdaの数が増えていってました。特に今回紹介しているステートマシン以外にいくつか別で新しくステートマシンを作っていくとなった時にコードを管理しているリポジトリでどのlambdaがどのステートマシンに影響しているのか見通しが悪くなってきています。

ここについては共通部分とそれぞれのステートマシン独自に使われるlambdaを階層わけして、まとめていきながら、前述したステートマシンのIaC化を推進できればその依存関係も新しいメンバーが理解しやすくなるのではと考えているので、チームで話しながらリファクタリングを進めていきたいです。

おわりに

今回のStep Functions導入により、コストを最小限まで削減しながらETL基盤構築を行うことができました。今後、運用するチーム体制や扱うデータの規模によってはGlueやMWAA、他のよりデータ処理に特化したサービスを選択した方が良い場合もあると思うので、事業の状況に合わせてシステム改善ができればと考えています。

またエブリーでは日々たくさんのバッチ処理が実行されていますが、その中では依存関係を起動タイミングで暗黙的に制御しているものもあったりするのでデータのETLだけではなく、そういったバッチ処理の実行にも今後ワークフローエンジンの導入を考えていきたいです。

ここまでお読みいただき、ありがとうございました。

Version Catalog への移行

DELISH KITCHEN の Android 版では、ライブラリ名を build.gradle に記載して管理していました。

// こんな感じ
implementation "androidx.media3:media3-exoplayer:$MEDIA3_VERSION"
implementation "androidx.media3:media3-exoplayer-hls:$MEDIA3_VERSION"
implementation "androidx.media3:media3-ui:$MEDIA3_VERSION"

ライブラリのバージョンだけは定数化されて別途取りまとめて管理していましたが、それも全てがまとまっているわけではなかったため、現在どのようなライブラリがどのバージョンで利用されているかが一目でわからない状態でした。
そこで、今後ライブラリの棚卸しを行いやすいように、また関係ライブラリを束ねて扱えるようにするため Version Catalog でとりまとめることにしました。

環境

  • Android Studio Giraffe | 2022.3.1
  • Gradle 8.0

やったこと

Android Developers に移行についての記事(※1)があり、そちらを参考に作業を行いました。
作業の流れは以下の通りです。

  1. libs.versions.toml ファイルとセクションの作成
  2. libs.versions.toml ファイルへライブラリの情報を記載
  3. 記載した情報を利用して gradle を修正

それぞれ詳細を見ていきましょう。

libs.versions.toml ファイルとセクションの作成

まずはルートの gradle ディレクトリ配下に libs.versions.toml という名前のファイルを作成します(※2)。

続いてこのファイル中に versions/libraries/bundles/plugins という 4 つのセクションを作成します。

この後、これらのセクションでは以下のような値を記載していきます。

  • versions
    • ライブラリのバージョン値
  • libraries
    • ライブラリのエイリアス名
  • bundles
    • 関係するライブラリたちを束ねたエイリアス名
  • plugins
    • プラグインのエイリアス名

libs.versions.toml ファイルへライブラリの情報を記載

各モジュールなどにある gradle ファイルを確認し、記載されているライブラリなどの情報を libs.versions.toml ファイルへ書き出していきます。
libs.versions.toml ファイルの各セクションに対して、以下のように記載していきます。

記載した情報を利用して gradle を修正

libs.versions.toml ファイルへ情報を集約した後はいよいよ gradle ファイルへ適用していくことになります。
apply plugin や implementation などをゴリゴリ書き換えていきましょう。

  • before

  • after

この際に利用する名称は libs.{セクション名}.{ハイフンをドットに変換したエイリアス名} となる点に注意が必要です。

どうなったか

これで libs.versions.toml ファイルを見るだけでどんなライブラリがどんなバージョンで使われているかを確認できるようになりました。
モジュールに散った gradle をあちこち開かなくてよくなり、今後のライブラリ棚卸しが楽になりました。
また、bundles のおかげで関連ライブラリをまとめて扱えることにより、必要なライブラリの記載が漏れたりバージョンの指定を誤ったりといったミスがなくなります。

まとめ

ポジティブな意見として、 Version Catalog は Android アプリ開発に必須な対応ではありませんが、対応することで1ファイルにライブラリ情報が集約されるため、今後のライブラリ管理が楽になると思います。
Version Catalog を用いることでは、特に関連ライブラリをbundleとして扱える点が便利と感じています。
今回、 libs.versions.toml ファイルという専用のファイルへまとめた方が gradle とは切り分けできてよいと考えて着手しましたが、他にも settings.gradle へまとめる方法(※3)も存在しています。
どちらの方法でも1ファイルに集約して管理できるという点はメリットであると思っています。

ネガティブな意見としては、ライブラリの管理の際にgradle以外にもtomlという登場人物が増えることが逆にちょっと嫌だという意見がありました。
また、エイリアス名の命名で名前被りを回避するためにハイフン刻みとするため、それによりエイリアス名が長くなることがあったとの意見もありました。
他に、 Version Catalog の補完やジャンプが行えない点が不便・・・だったのですが、この点含む Version Catalog に対するサポートはAndroid Studio Giraffeではいろいろと改善(※4)されていました。

最終的にはチームメンバみんなの理解を得ての採用となりますが、開発作業を楽にすることができる技術は今後も検討して取り入れていければと思っています。

参考

UICollectionViewDiffableDataSource / UITableViewDiffableDataSource のsnapshotをResult Buildersを使って宣言的に書く

はじめに

2023年8月1日、MAMADAYSはトモニテに生まれかわりました。

tomonite.com

iOSアプリもトモニテに名前を変え、これまでのメイン機能である「育児記録」「妊娠週数管理」を軸として、家族やパートナー、家族以外の人や社会との接点を作るためのシェア機能やコミュニティ機能などの拡充をめざしていきます。

トモニテのiOSアプリは新規作成画面を中心にSwiftUIの導入を進めています。一方、既存の画面を全面的にSwiftUIに置き換えることは考えていないため、今後もUIKitの画面のメンテナンスも継続していきます。 今回はUIKitの画面にResult Buildersを導入してメンテナンス性を向上する取り組みをご紹介します。

Result Buildersとは

Result BuildersはSwift 5.4で導入されました。 Result Buildersを使うと、リストやツリーなどの構造化されたデータを、より自然で宣言的な構文で作成することができます。

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/advancedoperators/#Result-Builders

Result BuildersはSwiftUIの@ViewBuilderや、RegexBuilderで使われています。

ArrayBuilderを作る

Result Buildersを使って、任意の型の配列を出力する関数を宣言的に書けるようにするArrayBuilderというものを考えてみようと思います。

以下のようなIntの配列を出力する関数を、

var numbers: [Int] {
    var numbers: [Int] = [
        1,
        2
    ]
    if xxxx {
        numbers.append(3)
    }
    return numbers
}

ArrayBuilderを用いて以下のように書けるようにします。

@ArrayBuilder<Int>
var numbers2: [Int] {
    1
    2
    if xxxx {
        3
    }
}

ArrayBuilderを適用した場合、以下のような点で改善されていると思います。

  • 変数宣言、append、returnのような手続き的な記述が不要で、コードが簡潔になった
  • コードの構造と出力する値の構造が一致していて理解しやすい
  • 値の追加、削除などの変更がしやすい

ArrayBuilderの実装は以下のようになります。

@resultBuilder
struct ArrayBuilder<OutputModel> {

    static func buildBlock(_ components: [OutputModel]...) -> [OutputModel] {
        return components.flatMap { $0 }
    }

    static func buildExpression(_ expression: OutputModel) -> [OutputModel] {
        return [expression]
    }

    static func buildExpression(_ expression: ()) -> [OutputModel] {
        return []
    }

    static func buildOptional(_ component: [OutputModel]?) -> [OutputModel] {
        return component ?? []
    }

    static func buildEither(first component: [OutputModel]) -> [OutputModel] {
        return component
    }

    static func buildEither(second component: [OutputModel]) -> [OutputModel] {
        return component
    }

    static func buildArray(_ components: [[OutputModel]]) -> [OutputModel] {
        Array(components.joined())
    }
}

UICollectionViewDiffableDataSource / UITableViewDiffableDataSourceのsnapshotに適用

次に、UICollectionViewDiffableDataSource / UITableViewDiffableDataSourceによみこませるためのsnapshotを作成する処理にArrayBuilderを使うことを考えました。

snapshotを生成する処理は以下のようなイメージです。

class ViewModel {
    
    enum Section: Hashable {
        case .section1
        case .section2
    }

    enum Row: Hashable {
        case .row1
        case .row2
        case .row3
        case .row4
        case .row5
    }

    public var snapshot: NSDiffableDataSourceSnapshot<SectionType, RowType> {
        var snapshot = NSDiffableDataSourceSnapshot<SectionType, RowType>()
        snapshot.appendSections([.section1, .section2])
        snapshot.appendItems([.row1, .row2, .row3], toSection: .section1)
        snapshot.appendItems([.row4, .row5], toSection: .section2)
        return snapshot
    }
}

ArrayBuilderを使用したDiffableTableViewModelプロトコルを作成し、以下のような書き方をできるようにします。

class ViewModel: DiffableTableViewModel {
    typealias SectionType = Section
    typealias RowType = Row

    enum Section: Hashable {
        case .section1
        case .section2
    }

    enum Row: Hashable {
        case .row1
        case .row2
        case .row3
        case .row4
        case .row5
    }

    // この関数が改善されています
    var tableSections: [TableSection<Section, Row>] {
        TableSection(.section1) {
            .row1
            .row2
            .row3
        }
        TableSection(.section2) {
            .row4
            .row5
        }
    }
}

ここでは使用していませんが、tableSections関数の中でfor文やif文を用いることもできます。

DiffableTableViewModelを導入することで以下のような効果が得られたと思います。

  • 手続き的な記述が不要でコードが簡潔
  • 画面要素の構造とコードの構造が一致していて理解しやすい
  • 画面要素の追加/削除/並べ替えなどに容易に対応できる

DiffableTableViewModelプロトコルは以下のように定義しています。

import UIKit

protocol DiffableTableViewModel {
    associatedtype SectionType: Hashable
    associatedtype RowType: Hashable

    @ArrayBuilder<TableSection<SectionType, RowType>>  var tableSections: [TableSection<SectionType, RowType>] { get }
}

extension DiffableTableViewModel {

    var snapshot: NSDiffableDataSourceSnapshot<SectionType, RowType> {
        var snapshot = NSDiffableDataSourceSnapshot<SectionType, RowType>()
        snapshot.appendSections(tableSections.map {$0.sectionType})
        tableSections.forEach { tableSection in
            snapshot.appendItems(tableSection.rowTypes, toSection: tableSection.sectionType)
        }
        return snapshot
    }
}

struct TableSection<SectionType, RowType> {
    let sectionType: SectionType
    let rowTypes: [RowType]

    init(_ sectionType: SectionType, @ArrayBuilder<RowType> rowsBuilder: () -> [RowType]) {
        self.sectionType = sectionType
        self.rowTypes = rowsBuilder()
    }
}

以上参考になれば幸いです。

Node.js v18.16.1 への バージョンアップを行っています

はじめに

はじめまして。DELISH KITCHEN 開発部 の 羽馬(@NaokiHaba)と申します。

この記事では、DELISH KITCHEN 開発部 で 行っている Node.js のバージョンアップの手順と、その際に発生した問題とその対応についてご紹介します。

対象読者

この記事は、Node.js のバージョンアップを行いたいが、どのような手順で行えばよいかわからない方や、Node.js のバージョンアップを行った際に発生した問題の対応方法を知りたい方を対象としています。

この記事で紹介する環境

この記事で紹介する環境は以下の通りです。

  • Node.js v16.13.1
  • npm v8.1.2
  • nodenv v1.4.1

バージョンアップの背景

DELISH KICTHEN WEB では フロントエンドのランタイムとして Node.js を採用しています。

tech.every.tv

現在利用している Node.js のバージョンは v16.13.1 ですが、2023 年 9 月 11 日 に EOL が予定されているため、今回 Node.js のバージョンアップを行うことにしました。

バージョンアップの手順

リリースノートの確認

Node.js のバージョンアップを行う際は、リリースノートを確認し、バージョンアップによって発生する可能性のある問題を事前に把握する必要がありました。

nodejs.org

nodejs.org

OpenSSL に関する変更が多く含まれていることから、Node.js のバージョンアップによって発生する可能性のある問題として、以下のようなものが考えられました。

  • 変更前のバージョンの Node.js で利用していた OpenSSL のバージョンと、変更後のバージョンの Node.js で利用している OpenSSL のバージョンが異なることによって、OpenSSL に関する問題が発生する可能性がある

Node.js のバージョンアップ

Node.js のバージョンアップは、nodenvinstall コマンドを利用して行います。

# 現在のバージョン情報を確認
$ node -v
16.18.0

# .node-version を更新
$ vi .node-version

# .node-version
18.16.1

# nodenv で 18.16.1 をインストール
$ nodenv install $(cat .node-version) # 18.16.1

$ node -v
18.16.1

.node_modules・ package-lock.json を再作成する

Node.js v16.18.0 でインストールした node_modules を、Node.js v18 で利用すると依存関係の不整合等が発生する可能性が高いことから、node_modules を削除し、package-lock.json を再作成します。

$ rm -rf node_modules package-lock.json

# npm のキャッシュで古いバージョンのパッケージが残っている可能性があるため、キャッシュを削除
$ npm cache clean --force

# キャッシュがクリアされたかどうかを確認
$ npm-cache verify

# package-lock.json を再作成
$ npm install

OpenSSL の 互換性エラー

Node.js v18 でアプリケーションが正常に動作するか確認すると、以下のようなエラーが発生しました。

$ npm run dev

# 以下のようなエラーが発生する場合は、後述の「`Node.js` v18 での `crypto` モジュールの変更」をご確認ください。
Error: error:0308010C:digital envelope routines::unsupported
    at new Hash (node:internal/crypto/hash:71:19)
    at Object.createHash (node:crypto:133:10)

Node.js v18 での crypto モジュールの変更

Node.js v18 では、crypto モジュールのデフォルトの暗号化方式が変更されたことにより、Node.js v16 で正常に動作していたアプリケーションが正常に動作しなくなる可能性があります。

対象方法としては、以下の 2 つが考えられます。

  • webpack のバージョンアップ
  • 暫定措置として package.jsonscriptsNODE_OPTIONS=--openssl-legacy-provider を追加する

本来は webpack のバージョンアップを行うことが望ましいですが、webpack のバージョンアップには時間がかかるため、暫定措置として package.jsonscriptsNODE_OPTIONS=--openssl-legacy-provider を追加することで対応しました。

Error when running build-storybook with Node 17 · Issue #16555 · storybookjs/storybook · GitHub

nodejs.org

{
  "scripts": {
    "dev": "NODE_OPTIONS=--openssl-legacy-provider app/server/index.js --watch"
  }
}

上記の設定を追加することで、Node.js v18 での crypto モジュールのデフォルトの暗号化方式を変更することができます。

以上の手順で、Node.js v18 へのバージョンアップを完了することができました。

Dockerfile の変更

Node.js のバージョンアップに伴い、DockerfileFROM で指定している Node.js のバージョンを変更します。

# 変更前
FROM node:14.17.3-alpine3.14

# 変更後
FROM node:18-alpine3.16

最後に

Node.js のバージョンアップは、Node.js のバージョンアップによって発生する可能性のある問題を事前に把握し、対応する必要があります。

この記事が、Node.js のバージョンアップを行う際の参考になれば幸いです。

『DELISH KITCHEN』におけるバンディットアルゴリズムの取り組み紹介

はじめに

こんにちは。DELISH KITCHEN開発部でデータサイエンティストをやっている山西です。
今回は、

  • DELISH KITCHENへバンディットアルゴリズムを採用した経緯
  • バンディットサーバーおよびそのAWSインフラ構築

をテーマに紹介いたします。

経緯

現在DELISH KITCHENでは、サービスをより良くするために、デザインの改善施策を継続的に行っています。 その手段として、これまでは主にA/Bテストによる効果検証を行ってきました。

参考記事

tech.every.tv

tech.every.tv

A/Bテストにより、複数のデザイン案の良し悪しを統計的に解釈し、”良い”デザインを見極めたうえでユーザーに展開することが出来ます。
適切なA/Bテストの設計によってその恩恵を最大限に享受できる一方、トレードオフとして以下のようなデメリットにも直面することとなります。

  • 手動操作が多く、”良い”デザインを採用するプロセスを自動化出来ない
      • ユーザーに展開する割合の計算(サンプルサイズの決定)
      • サービス内部へのデザイン実装の手間
  • 「良くないデザイン案」を一定期間露出してしまうリスクがある
  • 複数のデザイン案を同時にテストしようとすると、サンプルサイズが足りなくなる

そこで、「複数のデザイン案の中から”良い”ものを見つけ出し、自動で表示する」という場面で採用できないかと試したのがバンディットアルゴリズムになります。

バンディットアルゴリズムとは

バンディットアルゴリズムは、探索と活用のジレンマに陥る多腕バンディット問題を効率的に解くために考案されているアルゴリズムです。 まず以下に、これらの用語について解説します。

多腕バンディット問題

以下のような問題設定に当てはまるものを多腕バンディット問題と定義します。

  • 「複数の選択肢の中から一つを選択する」試行を逐次的に行い続ける
  • 選択を繰り返した結果、得られる総報酬を最大化したい
  • 選択試行を繰り返すためのリソースには制約がある(時間的制約、試行回数の上限など)
  • どの選択肢が良いか(=多くの報酬を得られるか)は未知である

その具体例として、「当たりの確率が異なる複数のスロットマシンのアーム(腕)を複数回引く中で、最大の報酬を得られるように模索する」問題が挙げられます。

図1 バンディットアルゴリズム スロットマシンの例

※ スロットマシンがプレイヤーからどんどんお金を奪い取っていく様を盗賊(bandit)に見立てたのが語源となり、多腕バンディット問題と呼ばれるらしいです。

Webサービスの運用においても、「バナーや広告等の表示コンテンツをCTR、 CVRが高くなるように最適化したい」という問題設定等が、多腕バンディット問題に当てはまります。

探索と活用のジレンマ

多腕バンディット問題では「どの選択肢が”良い”(=より多くの報酬が期待できる)のかは事前にわからない」ため、それを試しながら確かめる必要があります。 しかしここで、探索と活用のジレンマに陥ることとなります。

  • 複数の選択肢を代わる代わる試すような”探索”ばかり行っていると、有用な選択肢を発見するまでに機会損失を被るリスクがある

    • ex. 新しいスロットマシンを試し続ける、その中には”悪い”スロットも含まれる
  • とりあえず、とある選択肢を”良い”とみなして活用し続けると、もっと”良い”選択肢があった場合、それを発見できないという機会損失が生じる

    • ex. 現時点で一番”良い”スロットマシンを引き続けることになるが、もしかしたら他にもっと良い選択肢があるのを見過ごしているかもしれない

バンディットアルゴリズム

バンディットアルゴリズムは、このような多腕バンディット問題、およびそれに起因する探索と活用のジレンマを解決するためのアルゴリズムの総称です。

これらのアルゴリズムは主に統計的なアプローチを用いて、探索と活用のバランスをうまく取り扱いつつ、総報酬の最適化を図るように設計されています。

代表的なバンディットアルゴリズム
  • ε-greedy: シンプルに実装可能なアルゴリズム。小さい確率εをパラメータとし、確率εで選択肢をランダムに選ぶ(探索フェーズ)。残りの確率(1-ε)で、その時点で得られた報酬の期待値が最大となる選択肢を選ぶ(活用フェーズ)。
  • Thompson sampling: ベイズによるアプローチ。”良い”選択肢を選ぶために、各選択肢の事後分布(これまでの選択回数と報酬が得られた回数を元に更新)を使用する。
  • UCB(Upper Confidence Bound): 試行回数が少ない選択肢の「不確かさ≒”良い選択肢かもしれないというポテンシャル”」も考慮しつつ、”良い”選択肢を探索する。そのために、各選択肢の報酬の期待値の信頼区間の上限(UCB値)を利用する。

本記事では詳細な説明を割愛しますが、詳しく知りたい方は以下の書籍を読んでみてください。

www.oreilly.co.jp

A/Bテストとの違い

A/Bテスト、バンディットアルゴリズムは最終的なゴールとして、「複数の選択肢の中から、最も高い報酬が期待できるものを選びたい」という同じ目標を掲げているように見えます。 しかし、主眼の置き方はそれぞれ、

  • A/Bテスト: 検証としての振り返りに重きを置く
  • バンディットアルゴリズム: 報酬の最大化そのものを目的とする

という差異があり、それぞれに得意、不得意があるとも解釈できます。
以下、私たちなりの解釈にはなりますが、選択肢を選定するプロセスにおける違いをまとめてみたものになります。

選択肢選定プロセスの比較

バンディットアルゴリズム A/Bテスト
目的 選定の最適化による報酬の最大化 どの選択肢が優れているかの判断
自動化 される されない※1
属人性 無し 有り
効果検証 やりにくい やりやすい
環境に対する感度※2 高い 低い

※1 施策として実施した後に、結果を解釈してどの選択肢を採用するか意思決定することになる
※2 流行の変化によって、”良い”選択肢が変化した場合、バンディットアルゴリズムはその変化を追従できる可能性があるが、A/Bテストはやり直しによってしか対処できない

DELISH KITCHENでは、「複数のデザイン案から”良い”ものを選定する」A/Bテスト以外の選択肢としてバンディットアルゴリズムならではの強みを活かせるのではないかと考え、採用に至りました。

DELISH KITCHENへのバンディットアルゴリズムの適用

これから、DELISH KITCHENにおけるバンディットアルゴリズムの適用事例を簡単にですが紹介します。

ボタン文言の出し分け事例

DELISH KITCHEN(モバイルブラウザ版)のトップ画面には、アプリ版へのダウンロードを促すボタンが設定されています(下図参照)。

ここを、

  • 選択肢: ボタンに表示させる文言 (5パターンを準備)
  • 報酬: ボタンクリック

という多腕バンディット問題に落とし込みます。
そして、バンディットアルゴリズムによって、ここの文言がクリック率の高いものに寄っていくか実験してみました。 ※ Thompson samplingというアルゴリズムを利用

図2 ボタンとその文言の配信面

delishkitchen.tv

結果

実際に配信してみた結果、最初のうちは複数の選択肢を試し、一定期間経過後、一つの文言(赤色部分)を集中的に表示する様子を観察することが出来ました。

図3 表示文言の時系列変化
※ 縦軸のビン(棒)は、その時々のボタン100回表示の中で、5パターンの文言(凡例の0~4)のどれが何回表示されたかを色分けしています。
そして、横軸は時系列を表し、右に進むほど時間が経過したことを意味します。

全選択肢(文言)の中で、1番の文言(グラフ上の赤色部分)のクリック率が最も高い結果となりました。
このクリック率は、元々ページ上に表示していた文言(グラフ上の0番=青色部分)よりも1.4倍ほど高いものとなります。
よって、アルゴリズムの探索によって、クリック率観点で”良い”文言を見つけ出したことになります。

バンディットサーバーをAWSに構築

ここからは、バンディットアルゴリズムの実装について紹介します。

  1. Pythonでバンディットアルゴリズムを実装
  2. FastAPIにて↑をサーバー化
  3. AWS上にインフラ構築(FastAPIをコンテナ化してECRにアップロード&ECS-Fargateとして立てる)

という流れを取りました。

そして、

  • 既存DELISH KITCHENバックエンドサーバーがこのバンディットサーバーに対してAPIリクエストを送信
  • 「バンディットアルゴリズムの判断結果として、どの選択肢を表示すれば良いか」の情報をレスポンスで返す
  • Databricks(データ集計基盤)を経由して、配信に向けたマスタ情報や、バンディットアルゴリズムが必要とする報酬情報等のバッチ更新を実施

という構成にしています。

以下がその構成図になります。

図4 バンディットインフラ構成図

FastAPIコードの例

以下、一部抜粋にはなりますが、バンディットサーバーのコードの実装の雰囲気を紹介します。

内部実装ではMVCアーキテクチャを意識しており、

  • service層
    • バンディットアルゴリズムのロジックそのもの
    • APIとして実現したい操作(コード部分)
  • model層
    • 必要なデータソース(上記構成図におけるElasticache-redis)を操作する部分

といった具合に各々の責務を分離し、見通しよく開発できるようにしています。

service層のget_arm(APIとしてどの選択肢をリクエスト元に返すかを操作する部分)の実装↓

from typing import List, Dict, Tuple
from model.arm import select_arm_score as redis_select_arm_score
from model.environment import select_environment_all_arms as redis_select_environment_all_arms
from services.bandit_environment import select_bandit_environment
from redis.exceptions import RedisError
from exception import InvalidIdentifierTypeException, IdentifierNotExistsException,  InvalidArmTypeException, ArmNotExistsException

# あるidentifier(配信面)が持つ全てのarm(選択肢)のcount(選択回数)とreward(報酬を受け取った回数)を取得
def select_arms_score(identifier:str, arms:List[int]) -> Dict[int, Tuple[int, int]]:
    try:
        arms_score = {}
        for arm in arms:
            # model層から必要な情報を取得
            count, reward = redis_select_arm_score(identifier, arm)  

            # 各選択肢(arm)ごとに、何回選択されたか(count)と、何回報酬を受け取ったか(reward)を格納
            arms_score[arm] = (count, reward) 
    except (RedisError, InvalidIdentifierTypeException, IdentifierNotExistsException, InvalidArmTypeException, ArmNotExistsException):
        raise
    else:
        return arms_score

# あるidentifier(配信面)が持つ全てのarm(選択肢)の一覧を取得
def select_environment_all_arms(identifier:str) -> List[int]:
    try:
        # model層から必要な情報を取得
        arms = redis_select_environment_all_arms(identifier)  
    except (RedisError, InvalidIdentifierTypeException, IdentifierNotExistsException):
        raise
    else:
        return arms
    
# 配信面に対してどのarm(選択肢)を返せば良いか、バンディットアルゴリズムに判断させる。
# その際に、count(選択回数)とreward(報酬を受け取った回数)の情報を用いる
def get_arm(identifier:str, algorithm:str) -> int:
    try:
        # バンディットアルゴリズムのインスタンスを立ち上げる
        bandit_environment = select_bandit_environment(identifier, algorithm)

        # identifier(配信面)が持つarm(選択肢)の一覧情報
        # およびそれら選択肢に対するscore情報(選択回数:countと報酬回数:reward)を取得
        arms = select_environment_all_arms(identifier)
        arms_score = select_arms_score(identifier, arms)

        # バンディットアルゴリズムのインスタンスに対して、どのarm(選択肢)を返せば良いかを判断させる
        arm_number = bandit_environment.get_arm(arms_score)
    except (RedisError, InvalidIdentifierTypeException, IdentifierNotExistsException, InvalidArmTypeException, ArmNotExistsException):
        raise
    else:
        return arm_number  # どのarm(選択肢)にするか、番号で返す

よかったこと

次に、インフラ構築、およびバンディットサーバー内部実装それぞれで責務の分離を意識することで得られたメリットについてまとめます。

DELISH KITCHENサーバーとの責務の分離

DELISH KITCHEN自体のバックエンドサーバーと、バンディットサーバーを切り離して実装したことによって、実装の柔軟性や耐障害性の観点で以下のメリットが得られました。

実装の柔軟性

データサイエンティストがバンディットのロジックのみに責務を持ち、開発リソースを集中できる

  • 新しいバンディットアルゴリズムを追加する場面等での拡張性が高い
  • DELISH KITCHEN自体のバックエンド実装を意識せず、ロジックに集中してメンテナンスを行うことができる
耐障害性

万が一問題が発生した時にも、事業に与える悪影響を最小限に抑えやすくなる

  • 予めDELISH KITCHENバックエンド側にフェールセーフ機構※を備えておくことで、バンディットサーバーへのAPIリクエストに失敗した際でも、ユーザー影響無くサービス運用を継続できる

※ バンディットサーバーとの疎通に失敗した場合、複数選択肢の出し分けは中止し、予め決め打ちしたデザインを表示するように設定しておく等

MVC化による責務の分離

さらに、バンディットサーバー内部実装でMVCアーキテクチャによる責務の分離を意識したことで、可読性、およびコード全体のメンテナンス性が向上しました。
Model層、Service層などの各層の役割が明確に分かれ、各々に対する変更が局在化されるため、システム全体の安定性を保ちつつ

  • 新規バンディットアルゴリズムを追加実装する
  • データソースを変更する

ことが可能となりました。

大変だったこと

“探索と活用”のテストの難しさ

バンディットアルゴリズムは確率的に生成される情報を取り扱うため、実装したコードの挙動をテストする際はその性質も考慮する必要があります。

以下は、「バンディットアルゴリズムの試行が100回進んだ際、最も”良い”選択肢が最も多く選ばれることを期待する」unittestのコード例となります。

# 試行を進めた結果、最も報酬が得られる確率が高いarm(選択肢)の選択回数が多くなることを確認する。
# 良いarmを探索し、その結果を活用できているかの観点で簡易テスト
def assert_most_selected_arm_is_best_arm(self, agent: Union[EpsilonGreedyAgent, ThompsonSamplingAgent, UCBAgent], thetas: Dict[int, float], num_trials=10000):
    # 最も報酬が得られる確率が高いarm
    best_arm = max(thetas, key=thetas.get)

    most_selected_arm = self.get_most_selected_arm_from_bandit_simulation(agent, thetas, num_trials)

    with self.subTest(test_input=most_selected_arm, expected=best_arm):
        self.assertEqual(most_selected_arm, best_arm)
def test_exploit_best_arm(self):
    agent = ThompsonSamplingAgent("test_agent")
    # 後のシミュレーションにおいて、armから報酬が得られる確率を定義
    thetas = {0: 0.1, 1: 0.5, 2: 0.8}  # {選択肢の番号:その選択肢から報酬が得られる確率}

    self.assert_most_selected_arm_is_best_arm(agent, thetas)

このように、アルゴリズムが「良い選択肢によっていく性質」を簡易的にテストしたい場合は、確率的な振る舞いを前提にし、ある種の曖昧さを許容するような視点を求められることがあります。

一方、この粒度の単体的なテストではバンディットアルゴリズムが時間経過と共に、探索と活用のバランスを取るように設計されている(図3で示したような様子)ことを確かめるのは困難です。
さらに、ビジネスシーンで実運用する際は、上記のテストとは異なり、各選択肢から報酬が得られる確率は未知となります。
 
こうした難しさがあるため、本番環境に投入して経過観察してみないと時間推移的な挙動の確からしさが判別しづらいのが現状です。
そのため、オフライン評価手法をもっと整備しなければならないという課題感を感じているところではあります。

最後に

バンディットの要件整理&アルゴリズムの実装はもちろん、それに留まらずサーバー実装〜AWSインフラを一気通貫で構築した今回の取り組みは、データ職責として挑戦的な機会となりました。
慣れない技術スタック、苦労も多くあったものの、

  • MLをどうサービスに組み込むか
  • そのために如何にして開発サイドのエンジニアと連携するか 等の視座を上げることができ、非常に有意義な経験となりました。

また、バンディットアルゴリズムの導入により、「サービス内の表示コンテンツを最適化する手段」としてA/Bテスト以外の選択肢が加わることとなりました。
今後は、検証の目的によってA/Bテストとは使い分けつつ、バンディットアルゴリズムの利用拡大や文脈付きバンディットへの拡張などに取り組んでいきたいと思います。

参考書籍

飯塚 修平. 2020. ウェブ最適化ではじめる機械学習―A/Bテスト、メタヒューリスティクス、バンディットアルゴリズムからベイズ最適化まで. オライリージャパン.