TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

938

こんにちは、ZOZO CTOブロックの @ikkou です。 ZOZOでは、4/20に Data Engineering Meetup をGMOペパボさんと共催しました。 zozotech-inc.connpass.com 本イベントではto C向けサービスを提供する2社が、各社のData Engineering事情や直近の取り組みについて発表しました。 登壇内容 まとめ 前半にGMOペパボさんから2名が、後半に弊社から2名が登壇しました。 「データ"抽出"基盤 Yeti をつくっている話」 (GMOペパボ / 堤 利史) 「BigQuery の日本語データを Dataflow と Vertex AI でトピックモデリング」 (GMOペパボ / 財津 大夏) 「ゴリゴリのBigQuery活用!メール・Push配信データ生成の仕組み」 (ZOZO / 辻岡 温子) 「タイムトラベル始めました 〜時をかけるBigQuery〜」 (ZOZO / 塩崎 健弘) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、FAANS部の中島 ( @burita083 ) です。2021年10月に中途入社し、FAANSのiOSアプリの開発を行なっています。 FAANSの由来は「Fashion Advisors are Neighbors」で、「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」です。現在正式リリースに向けて、 WEAR と連携したコーディネート投稿機能やその成果を確認できる機能など開発中です。 はじめに FAANS iOSでは非同期処理にCombineを利用しています。Combine自体は本記事では詳しく解説をしませんが、RxSwiftを利用したことがある方なら特に違和感なく使えるかと思います。全く馴染みがない場合だと覚えることも多く、難しいところもあるかと思いますので、Swift Concurrencyを利用する方が理解しやすいかもしれません。ただし、ViewとPresenterの値のバインディング処理にも利用していますので、FAANS iOSでは当面、Combineも利用していくと思われます。 今回、async/awaitで書き換えた理由として、主に2つの理由があります。 非同期処理をシンプルに書けるようになるため Combineのコードは、コールバックで受け取る必要があり、コールバックの中でさらに別のAPIを叩く場面もあります。async/awaitで手続型のように書けるので、シンプルな記述が可能です。本記事で実際のコード例を元に説明します。 Swiftのアップデートに追従しつつ、チームとして継続的に新しい技術に触れることで成長していきたいため URLSession等、Apple標準のAPIでasync/awaitがすでに使われており、今後も様々な機能がアップデートされます。キャッチアップした内容を業務で積極的に活用できる環境づくりをチームで心がけています。 本記事ではCombineでの非同期通信の処理に対しasync/awaitで書き換えたユースケースを紹介し、実装のポイント等、説明します。 目次 はじめに 目次 FAANS iOSの構成 async/await概要 API Clientをasync/awaitで書き換え PresenterのCombineの利用箇所をasync/awaitで書き換え Case 1: 固定数の複数のAPIリクエストを並行して実行 Combineによる実装 async/awaitで書き換え Case 2: 数が可変の複数のリクエスト(1〜5個) Combineでの書き方 async/awaitで書き換え Taskについて 並行実行のTask Groupについて まとめ さいごに FAANS iOSの構成 Combineを使用している箇所を中心に図示しました。Combineは非同期処理の他に、View/PresenterのBindingで利用しております。 async/await概要 本記事で登場するasync/awaitのキーワードは以下の通りです。 async await async let withCheckedThrowingContinuation Task Task Group AsyncSequence Swift Concurrencyでは新たな概念が色々出てきます。学習する際、何のキーワードについての説明かをマッピングしていくと理解しやすいです。 Swift Concurrency チートシート で、キーワード毎に整理されていますので、とても参考になります。 async/awaitの基本として、 async キーワードの理解が大事ですので説明します。一般的にコールバック関数をasync/awaitで書き換えると次のようなコードになります。 コールバックを返すコード func downloadData (from url : URL , completion : @escaping ( Data ) -> Void ) downloadData(from : url ) { data in // コールバックでdata を使う処理 } async/await書き換え後のコード func downloadData (from url : URL ) async -> Data // コールバックで受け取ることなく、data変数に結果が格納され、手続型のように後続処理をかける let data = await downloadData(from : url ) API Clientをasync/awaitで書き換え FAANS iOSではAPIを実行するクラス、API ClientでCombineを利用しており、AnyPublisher型を返す関数があります。これをSubscribeすることでコールバックが返ってきますので、 withCheckedThrowingContinuation を利用し、コールバック関数をラップします。また、開発の効率性を高めるためにすでに実装済みのコードを再利用している箇所があり、Combineによるリクエストのインタフェースが存在している状況です。 // CombineのみのAPI通信ではこの関数を利用 func responsePublisher < Response > ( for requestBuilder : RequestBuilder < Response > ) -> AnyPublisher < Response , APIClientError > { requestBuilder.executeWithIDToken() .mapError { . init ( $0 ) } .eraseToAnyPublisher() } // 上記のCombineのコードをwithCheckedThrowingContinuationでラップするだけで、async/awaitの書き換えが可能 func response < Response > ( for requestBuilder : RequestBuilder < Response > ) async throws -> Response { let canceller = Canceller() return try await withTaskCancellationHandler { // Cancel処理の詳細は省く。 // withCheckedThrowingContinuation使用箇所 return try await withCheckedThrowingContinuation { continuation in if Task.isCancelled { continuation.resume(throwing : CancellationError ()) return } // responsePublisherの結果をSubscribeして、continuation.resumeに渡す。 // 確実に1回、continuation.resumeを実行する必要がある。 canceller.cancellable = responsePublisher( for : requestBuilder ) .handleEvents(receiveCancel : { continuation.resume(throwing : CancellationError ()) }) .sink { completion in switch completion { case .failure( let error ) : // エラーの場合はthrowingの方を利用 continuation.resume(throwing : error ) case .finished : break } } receiveValue : { value in // returningの方を利用し、結果を渡す continuation.resume(returning : value ) } } } onCancel : { canceller.cancel() } } // 利用例 // Combineのまま self .apiClient.responsePublisher( for : MemberAPI.getMember ()) .sink { [ weak self ] (completion) in switch completion { case .failure( let error ) : // エラー処理 case .finished : break } } receiveValue : {[ weak self ] member in // 結果をコールバックで受け取る } .store( in : & cancellables) self .apiClient.responsePublisher( for : MemberAPI.getMember ()) // async/await書き換え後 do { // 結果がmemberに格納され、エラーの場合はcatchの方にいく // Combineではコールバックで結果を受け取る形になる let member = try await APIClient().response( for : MemberAPI.getMember ()) catch { // エラー処理 } responsePublisher関数は、引数のリクエストを実行し、AnyPublisher型を返します。これを withCheckedThrowingContinuation のクロージャ内で利用し、subscribeした結果をcontinuation.resumeに渡します。 withCheckedThrowingContinuation ではエラーを扱うので、エラー時の処理も忘れないようにしましょう。また、Cancel処理を行うためにwithTaskCancellationHandlerを利用していますが、詳細は本記事では省きます。 PresenterのCombineの利用箇所をasync/awaitで書き換え 次に実際にPresenterで使用するケースをみていきます。PresenterがAPI Clientを保持し、API通信を非同期で実行します。実際の業務でのユースケース毎に、Combineのコードをasync/awaitで書き換えて説明します。 Case 1: 固定数の複数のAPIリクエストを並行して実行 1つ目の例として固定数の複数のAPI、ここでは3個のAPIを利用し、結果を後続で利用する例を説明します。3個のAPIを並行して実行し、それぞれのリクエスト結果を待ち合わせ、全てのリクエストが完了した段階で後続処理に進みます。 Combineによる実装 Combineでは、Zipを利用することで、複数のAPIリクエスト処理の結果をまとめて受け取れます。また、リクエストの結果を利用してさらに別のAPIリクエストを行う場合も多いです。慣れていないと理解が少し難しいという欠点があるものの、Combineでやりたいことは特に問題なく実装できています。今回は、Publishers.Zip3を利用し、3個のAPIのリクエストを並行して実行する例を示します。 Combineを利用した実装 (実際のコードはもっと長いですが簡単のためにコメントで補足しております) private func fetchItems () { let coordinateDetailZip = Publishers.Zip3( apiClient.responsePublisher( for : CoordinateAPI.getCoordinateById (coordinateId : String ( self .coordinate.id))) apiClient.responsePublisher( for : CoordinateAPI.getCoordinatesSales (coordinateIds : String ( self .coordinate.id), ecMallKey : .zozotown)) apiClient.responsePublisher( for : CoordinateItemAPI.getCoordinateItems (coordinateId : String ( self .coordinate.id))) ) // リクエストの結果をコールバックで受け取る coordinateDetailZip .flatMap { [ weak self ] coordinateWrapper, coordinateSales, coordinateItems -> AnyPublisher < GetCoordinateReviewResponse , RepositoryError > in // 3つのリクエストの結果を利用し、必要な型を返す } .receive(on : DispatchQueue.main ) .sink { [ weak self ] completion in switch completion { case .finished : break case .failure( let error ) : // エラー処理 self ?.alertMessage.value = error.localizedDescription } } receiveValue : { [ weak self ] coordinateReview in guard let self = self else { return } // flatMapの結果を利用 } .store( in : & cancellables) } async/awaitで書き換え Publishers.Zip3を利用して固定数(3個)のリクエストを行いましたが、async/awaitではどのように書けるのでしょうか。まずはコードで説明します。 // Presenterに実装されている同期関数 // PresenterはapiClientを保持 private func fetchItems () {          // Task キーワードを利用することで、async/awaitの非同期関数を同期関数の中で呼び出せる Task { [ weak self ] in guard let self = self else { return } // async letで3つのAPIリクエストを手続き型のように記述 // async letのタイミングでリクエストは実行されるが、処理の完了自体は待たず次の行へ async let coordinateResponse = self .apiClient .response( for : CoordinateAPI.getCoordinateById (coordinateId : String ( self .coordinate.id))) async let coordinateSalesResponse = self .apiClient .response( for : CoordinateAPI.getCoordinatesSales (coordinateIds : String ( self .coordinate.id), ecMallKey : .zozotown)) async let coordinateItemsResponse = self .apiClient .response( for : CoordinateItemAPI.getCoordinateItems (coordinateId : String ( self .coordinate.id))) do { // awaitキーワードで、処理の完了を待つ(リクエスト自体は3つ並行で実施済) let coordinateWrapper = try await coordinateResponse let coordinateSales = try await coordinateSalesResponse let coordinateItems = try await coordinateItemsResponse // 以下、3つの結果を利用。特にネスト等なく、手続き型のように書ける } catch { // エラー処理 self .alertMessage.value = error.localizedDescription } } } async letを利用することで複数個のAPIリクエストを並行して実行できます。リクエストの結果はasync letの行では受け取らず、 await の箇所で受け取ることになります。 つまり複数の非同期処理を並行に行いますが、結果を使いたいタイミングの時にリクエストが完了されていることを、 await キーワードによって保証されています。 コールバックで完了を受け取る必要がなく、後続処理で結果を利用して別のAPIリクエストを実施するにしてもネストをさせることなく、手続き型の様に書けるのでコードが複雑になることを防げます。 また、並行処理ではなく、順番に実行したい場合の例も記載します。async letとの対比で理解しておくと良いですし、async/awaitの基本のコード例としては順番に実行する例も多いです。 private func fetchItems () { Task { [ weak self ] in guard let self = self else { return } do { // async letを使わずに、上から順に結果を受け取る。 // await キーワードをそれぞれのリクエストで記載。 // 1つのリクエストが終われば次のリクエスト、という形になる。 // 当然全ての処理の完了は並行処理に比べて遅くなる。 let coordinateResponse = try await self .apiClient .response( for : CoordinateAPI.getCoordinateById (coordinateId : String ( self .coordinate.id))) let coordinateSalesResponse = try await self .apiClient .response( for : CoordinateAPI.getCoordinatesSales (coordinateIds : String ( self .coordinate.id), ecMallKey : .zozotown)) let coordinateItemsResponse = try await self .apiClient .response( for : CoordinateItemAPI.getCoordinateItems (coordinateId : String ( self .coordinate.id))) // 結果を変数に格納 let coordinateWrapper = coordinateResponse.coordinateWrapper let coordinateSales = coordinateSalesResponse.coordinateSalesAmounts let coordinateItems = coordinateItemsResponse.coordinateItems // 以下、3つの結果を利用。特にネスト等なく、手続き型のように書ける } catch { // エラー処理 self .alertMessage.value = error.localizedDescription } } } Case 2: 数が可変の複数のリクエスト(1〜5個) Case 1では固定数のリクエストの実装例を示しました。基本的にその対応で問題なさそうではありますが、Case 2で示す以下の仕様が出てきました。 仕様: 端末に保存されている写真の中から、1〜5枚までの写真をユーザーが選択し、選んだ順番に写真を投稿。 サーバーへのリクエストの手順は次の通りです。 ユーザーが1〜5枚まで写真を選択 保存先のURLを1枚ごとにサーバーが発行(1〜5回リクエストを送る) 2で取得したURLのうち、1つ目のURLをメイン、それ以外をサブ(2〜5つ目のURLの配列)で分けて、それぞれリクエストパラメータとして利用する 生成したパラメータを元に投稿のリスエストを実施 ここでポイントとなるのが、ユーザーが任意の枚数の写真を選択できるという点で、Case 1のようにZip3等で固定の数のリクエストにはなりません。選択枚数が2枚の場合はZip、3枚の場合はZip3と場合分けをすることで、Case1のように固定数で書けそうですが、同じようなコードを何回も書くことになりそうです。 Combineでの書き方 PublishersにZipManyを追加し、任意の個数のリクエストの結果を配列で受け取るようにして1〜5個のどのパターンにも対応できるようにしました。ZipManyに関しては How to zip more than 4 publishers )を参考に実装しました。 Combineを利用した実装 (実際のコードはもっと長いですが簡単のために一部省略し、コメントで補足しております) // ユーザーが選択した写真を格納した配列(inputCoordinate.selectedImages)から、アップロードURLを生成するリクエスト(AnyPublisher型) let requests : [ AnyPublisher < PresignedUrlResponse , Error >] = inputCoordinate.selectedImages.map { imageUploadUseCase.uploadedPresignedURLPublisher(jpegData : $0 .jpegData, type : .image) } if requests.isEmpty { return } // ZipManyにrequestsを渡し、結果は配列で受け取る Publishers.ZipMany(requests) .flatMap { [ weak self ] presignedURLResponse -> AnyPublisher < CoordinateResponse , Error > in guard let self = self else { return Empty(completeImmediately : true ).eraseToAnyPublisher() } // presignedURLResponseには、リクエストの結果が配列で格納されているので、 // 要素数に関係なく、配列の1番目をメイン、それ以外をサブに、filterできる。 } .receive(on : DispatchQueue.main ) .sink { [ weak self ] (completion) in self ?.isLoading.value = false switch completion { case .failure( let error ) : // エラー処理 self ?.alertMessage.value = error.localizedDescription case .finished : break } } receiveValue : { [ weak self ] coordinate in // flatMapで変換した結果を利用 }.store( in : & cancellables) async/awaitで書き換え async/awaitで書き換えましょう。Case 1で固定数の場合はasync letを複数個書くことで対処しましたが、可変数の場合はどのように実装したらいいでしょうか。具体的にコードを見てみましょう。 async/awaitで書き換えたコード (実際のコードはもっと長いですが簡単のために一部省略し、コメントで補足しております) // Taskで囲むことで、同期関数のコードの中で利用可能になる Task { [ weak self ] in // ユーザーが選択した写真が配列に格納されており、その配列の要素の数で初期化 var images : [ CoordinateImage ] = Array(repeating : . init (url : "" ), count : inputCoordinate.selectedImages.count ) do { guard let self = self else { return } try await withThrowingTaskGroup(of : ( Int , PresignedUrlResponse ) . self ) { group in for image in inputCoordinate.selectedImages.enumerated() { group.addTask { // addTaskで並行に実行したい非同期処理をループ毎に登録 return (image.offset, try await self .apiClient.response( for : ImageAPI.issuePresignedURL (presignedUrlRequest : . init (objectType : .image))) ) } } // for try await in でTask Groupで実施した結果を受け取る for try await (index, presignedURL) in group { let coordinateRequestImages = CoordinateImage(url : presignedURL.downloadUrl ) // 結果の順序を維持したいので、Task Groupの返り値のindexを、 // 要素数を指定して初期化した配列(images)のindexに代入 // appendにすると、結果が終わった順に格納されてしまう images[index] = coordinateRequestImages } } // 後続処理でimagesを利用してリクエストを行う } } catch { // エラー処理 } } Taskについて viewDidLoad等の同期関数からasync/awaitの関数を呼び出せないので、Task {}を利用して呼び出します。上記の例では、Presenterが保持する同期関数の中でTaskを利用し、async/awaitの関数を呼び出して結果を返します。Presenterを保持するViewControllerは変わらずそのPresenterの同期関数を呼び出すだけなので特に改修することはありません。Presenterが保持するasync/await対応の関数をViewController側で呼び出す場合は、ViewController側にTaskを書いて、その関数を呼び出す形になります。また、Presenter自体は同期的に扱う必要があり、@MainActorを指定する必要がありますが、詳細は省きます。 並行実行のTask Groupについて ユーザーの選択した写真が格納された配列(inputCoordinate.selectedImages)は、1〜5個の要素を持ちます。もし並行に実行しないのであれば、for文の中でリクエスト処理を実行し、 await でリクエスト毎に結果を待つことで手続き型のfor文のように書けます。 コードイメージ for image in inputCoordinate.selectedImages { // 要素数は1〜5の可変で可能) let response = try await APIリクエスト(image) // responseを利用 } しかしこの書き方ですと、ループ毎にリクエストの結果を待つことになり、時間がかかってしまいます。Combineと同様に並行でリクエストを実行し、結果を待ち合わせるためにはどうすれば良いでしょうか。 それには Task Group を利用します。Task GroupにはwithTaskGroupとエラー対応の可能なwithThrowingTaskGroupがあります。今回はエラー処理も実施していますので、withThrowingTaskGroupを使います。 Task Groupの実装手順は以下の通りです。 withThrowingTaskGroupの引数に結果の型を定義 クロージャの引数にタスクグループを受け取る(ここではgroup) forループ内でgroup.addTaskを実行し、1で指定した型の結果を返す(addTaskで並行に実行したい非同期処理をループ毎に登録するイメージ) AsyncSequenceのfor try await in groupで結果を受け取る Task Groupのコード抜粋 // 引数にaddTaskで返す型を指定。ここでは配列のindexとリクエストの結果を指定。 try await withThrowingTaskGroup(of : ( Int , PresignedUrlResponse ) . self ) { group in for image in inputCoordinate.selectedImages.enumerated() { group.addTask { // addTaskで並行に実行したい非同期処理をループ毎に登録 return (image.offset, // 配列のindexを順序の維持に利用するために返す try await self .apiClient.response( for : ImageAPI.issuePresignedURL (wearConnectPresignedUrlRequest : . init (objectType : .image))) ) } } } ここで注意すべきことは、それぞれの非同期処理の完了タイミングが選択した写真の順にならないので、配列のindexも結果に含めます。 for try await in groupのコード抜粋 // AsyncSequenceのfor try await in でTask Groupで実行した結果を非同期的に受け取る for try await (index, presignedURL) in group { let coordinateRequestImages = CoordinateImage(url : presignedURL.downloadUrl ) // 結果の順序を維持したいので、Task Groupの返り値のindexを、 // 要素数を指定して初期化した配列(images)のindexに代入 // appendにすると、結果が終わった順に格納されてしまう images[index] = coordinateRequestImages } } 以上のように、Task Groupを利用することで可変の要素数のリクエストを並行に実行できました。将来的に6つ以上のリクエストが必要になっても特に大きな変更をすることなく対応が可能です。 まとめ 今までCombineでの書き方に慣れていましたが、async/awaitのほうが少ない記述量で、直感的に書けると思いました。まだ書き換えが全ての箇所でできていないので、これからも実装していく中で、知見を貯めて発信できたらと思います。 さいごに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。カジュアル面談もお待ちしております。 corp.zozo.com hrmos.co
アバター
はじめに こんにちは。ブランドソリューション開発本部 WEAR部 SREの笹沢( @sasamuku )です。 FAANS はショップスタッフの効率的な販売をサポートするスタッフ専用ツールです。FAANSの一部機能は既にリリースされており全国の店舗で利用いただいております。正式リリースに向け、 WEAR と連携したコーディネート投稿機能やその成果をチェックできる機能などを開発中です。 FAANSのコンテナ基盤にはCloud Runを採用しており、昨年に SREとしての取り組み をテックブログでご紹介しました。しかし、運用していく中で機能需要や技術戦略の変遷があり、Cloud RunからGKE Autopilotへリプレイスすることを決めました。本記事ではリプレイスの背景と、複数サービスが稼働している状況下でのリプレイス方法についてご紹介します。 目次 はじめに 目次 リプレイスの背景 なぜCloud Runだったのか なぜGKEに移行したいのか なぜGKE Autopilotなのか リプレイスの全体像 制約条件 アーキテクチャ 段階的リプレイス Phase1: 社内連携API Phase2: 非同期タスクAPI Phase3: 外部公開API リプレイスにあたって苦労したこと 認証方法の違いによる追加構成 GKE Autopilotの制限事項への対応 おわりに リプレイスの背景 なぜCloud Runだったのか そもそもなぜCloud Runを採用していたのか簡単に振り返ります。詳細は 昨年の記事 をご覧ください。 コンテナ化されたアプリケーションを利用する前提で、開発・運用両面の要件を満たせるサービスには、Cloud RunとGKEがありました。ここでの要件とは、「Goのバージョン1.16をサポートしていること」と「サーバレスなコンテナ基盤であること」の2つです。Cloud Run採用の決め手は構築・運用コストの低さです。リリース当初、Kubernetesを運用できるほど人員が潤沢ではなく、時間的な制約もありました。実際、Cloud Runは非常にシンプルで、素早く利用開始でき、GCPサービスとの連携も容易にできます。おかげでリリースまでに構築を終えられましたし、追加要件にもスピーディーに対応できました。 なぜGKEに移行したいのか GKEに移行する理由は2つあります。 Cloud Runはサイドカーコンテナ非対応である コンテナ運用におけるチーム標準をKubernetesにする まず、「1.」について説明します。Cloud Runはserviceと呼ばれる単位で管理されます。1 serviceにデプロイできるのは1コンテナのみとなっており、サイドカーコンテナは利用できません 1 。弊社では主な監視ツールにDatadogを利用しています。サイドカーコンテナとしてDatadog Agentを構成できないことはトレース取得の観点で痛手でした 2 。Datadog Agentを直接アプリケーションコンテナにインストールする方法も考えられますが、イレギュラーな対応 3 を要するため見送りました。 次に、「2.」についてです。私達のチームは、FAANSの他に2つのプロダクトを運用しています。これらはECSを採用していましたが、デプロイやスケールのさらなる柔軟さを求め、EKSへの移行が検討されていました 4 。FAANSも足並みを揃えてGKEに移行することにより、チームが利用するコンテナ基盤をKubernetesに統一できます。こうすることで、プロダクト毎の技術差異が抑えられ、運用負荷の軽減、ノウハウ横展開による効率化を実現できると考えました 5 。 なぜGKE Autopilotなのか GKEにはStandardとAutopilotの2つの運用モードがありますが後者を採用しました。その主な理由は、運用負荷が軽減されるためです。 Standardは、マスターノードはマネージドであるものの、ワーカーノードの管理はユーザに委ねられます。一方のAutopilotは、ワーカーを含む全てのノード管理がマネージドです 6 。そのため、マシンタイプ選定やオートスケーラー構成といったノードに関する対応は必要ありません。Pod仕様に応じて自動的に必要サイズのノードが必要数プロビジョニングされます 7 。 ただ、大規模トラフィックや急峻なスパイクが見込まれるケースではStandardの利用が推奨されています 8 。FAANSはtoBアプリケーションであり、そうした傾向は現在のところありませんでした。そのため、Autopilotの恩恵をありがたく享受することにしました。 Autopilotの 制限事項 により利用できるHelm Chartに制限が生じることもありましたが、現在のところクリティカルな影響は生じていません。これについては後述します。 リプレイスの全体像 制約条件 リプレイスを進める上で、まず始めに制約条件として下記を掲げました。 サービスをメンテナンスモードにしない アプリケーションのリリースを停止させない アプリケーションコードの改修は最小限にする FAANSはリリース済みのサービスであり、メンテナンスモードにするには各所へ調整が要ります。そのため無停止で完了させる方針としました。また、機能開発が盛んな状況だったため、コードフリーズは行わず開発の足かせとなる改修も極力避ける方針としました。 アーキテクチャ それでは、新旧アーキテクチャを説明します。 Cloud Runを活用した既存のアーキテクチャがこちらです。 稼働しているCloud Run serviceは3つあります。 「外部公開API」: 一般ユーザからリクエストを受け付ける 「非同期タスクAPI」: Cloud Tasksからタスクを受け付ける 「社内連携API」: 連携システムからPub/Sub経由でメッセージを受け付ける そして、リプレイス後のアーキテクチャがこちらになります。 アプリケーションの改修を避けるためアーキテクチャはほとんど変更しません。Cloud TasksやPub/Subといったサービスは継続利用し、各APIのエンドポイントをCloud RunからGKEに置き換えます。リクエストはIngressのhostベースルーティングで各APIに振り分けます。 段階的リプレイス 問題発生時の影響を最小限にするため段階的にリプレイスを進めます。具体的には、影響度の小さい順にCloud Run serviceをGKEへ置き換えます。検討の結果、社内連携API、非同期タスクAPI、外部公開APIの順でリプレイスする方針にしました。 Phase1: 社内連携API 社内連携APIは、Pub/SubからPush型でリクエストを受け取るため、サブスクリプションに設定しているPushエンドポイントをCloud RunからGKEに変更します。なお、無停止で移行させるため、移行先のGKEには最新バージョンのコンテナがデプロイされている状態になっています。 Phase2: 非同期タスクAPI 外部公開APIは処理時間の長いデータベース更新といった一部の処理を非同期タスクAPIに任せます。このとき、Cloud Tasksを挟むことで疎結合に連携しています。Cloud Tasksがタスクを送るエンドポイントは、外部公開APIが送るタスクデータに埋め込まれます。そのため、アプリケーションの設定ファイルを書き換えることでエンドポイントを移行します。 Phase3: 外部公開API 最後に外部公開APIです。こちらはユーザにダイレクトに影響が出るためカナリアリリースします。DNSにはRoute53を利用しているため、 加重ルーティング を利用します。Cloud Runのドメイン(CNAMEレコード)とGKE IngressのIPアドレス(Aレコード)の加重を徐々に変えていきます。初回は10%程度とし、最終的にGKE Ingressに100%のトラフィックを振ります。 最後にはCloud Runへの導線が0の状態になります。 リプレイスにあたって苦労したこと GKE Autopilotへのリプレイスで苦労した点もいくつかありました。 認証方法の違いによる追加構成 Cloud Runは機能として認証・認可を具備しています。一方のGKEには存在しなかったため代替手段として Identity-Aware Proxy (IAP) と呼ばれるサービスを活用しました。 Cloud Run serviceでは、社内連携APIと非同期タスクAPIの2つでサービスアカウントによる認証を実施していました。これらのAPIは外部公開APIとは異なり、不特定多数のユーザからのアクセスを想定していません。そのため、Cloud TasksやPub/Subに紐づけているサービスアカウントからのみリクエストを許可する設定をしていました。 Pub/Subの場合、次のようなTerraformのコードでCloud Runへのアクセスを制限します。 resource "google_cloud_run_service_iam_member" "example_api" { location = google_cloud_run_service.example_api.location project = google_cloud_run_service.example_api.project service = google_cloud_run_service.example_api.name # 認証を挟みたい Cloud Run サービス role = "roles/run.invoker" # Cloud Run の呼び出しに必要なロール member = "serviceAccount:${google_service_account.example_sa.email}" # 認証に使うサービスアカウント } resource "google_pubsub_subscription" "example_subscription" { ... push_config { push_endpoint = "https://cloudrun-api.com/push" # メッセージの送信先エンドポイント (Cloud Run) oidc_token { service_account_email = google_service_account.example_sa.email # 認証に使うサービスアカウント } } ... } GKEにはこのような組み込みの認証がないため、冒頭に記述のIAPを利用しています。IAPはCompute EngineやCloud Load Balancingといったサービスに認証・認可のフローを提供できます。GKEのエンドポイントは、GKE Ingress Controllerが作成したCloud Load Balancingで公開されるため、IAPを活用した構成が利用できました。 これにより、Cloud Runを利用していたときと同様に、意図しないアクセスをブロックできます 9 。 GKE Autopilotの制限事項への対応 公式ドキュメント から抜粋すると、GKE AutopilotにはStandardと比較して次のような制限が存在します。 hostPath ボリュームが使用できない( /var/log/ 配下のみ可) hostPortとhostNetworkが使用できない ワークロード内のコンテナに対する Privileged mode が使用できない したがって、これらの使用を前提としているHelm Chartは利用できません。 例えば、 Secret Manager のシークレットをPodで取得可能にする secrets-store-csi-driver は利用できません。hostPathやPrivileged modeの制限に引っ掛かるためです 10 。幸い、同様の機能を提供する External Secrets Operator は利用可能となっています。 また、Datadog Agentの Helm Chart において一部機能が制限されます。具体的には、ワークロードからのトレース送信にhostPortの使用を想定していますが 11 、これはAutopilotによって制限されています。公式のサポートではありませんが、こちらの Issue で案内されている対応で回避できました。 各ChartにおいてもAutopilotの制約は認知されているため近い将来には改善している可能性もあります。 おわりに FAANSにおけるコンテナ基盤リプレイスの背景と事例を紹介しました。コンテナ基盤がKubernetesに統一されることはプロダクト横断のSREチームにとって大きなメリットとなります。複雑なマニフェストやHelmの管理が大変であることも事実ですが、反面、様々な要件に応えられる柔軟さと捉えることもできます。今後運用を重ねながらさらなる拡張・改善に取り組んでいきたいと思います。 最後に、ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 公式ブログ ではサイドカーを必要としないケースでのCloud Run利用を薦めています。 ↩ 最新のサポート状況は 公式ドキュメント をご確認ください。 ↩ 1コンテナで複数プロセスを起動する方法は 公式ドキュメント に記載されています。wrapper scriptまたはsupervisordのどちらの場合もいわゆる PID1問題 への考慮が必要となります。 ↩ 組織内でもEKSを利用しているチームが多数あったため人員流動性や採用の観点からもEKSが有利でした。 ↩ External Secret や ArgoCD など、利用するHelmを統一することでプロダクト間のスイッチングコストが抑えられます。 ↩ 2つの運用モードの詳細については 公式ドキュメント および 公式ブログ をご覧ください。 ↩ Autopilotでは Cluster autoscaler と Node auto-provisioning がデフォルトで有効化されています。 ↩ https://cloudonair.withgoogle.com/events/google-cloud-day-digital-21?talk=d1-appdev-01 ↩ 拙稿 にてCloud RunとGKEの認証について簡単にまとめております。 ↩ https://github.com/kubernetes-sigs/secrets-store-csi-driver/issues/672 ↩ Helm Chartでデプロイされるdatadog agent(Pod)はhostPortでNodeの8126ポートにマッピングされます。アプリケーションは status.hostIP でNodeのIPアドレス宛にデータを送るように 案内 されています。 ↩
アバター
はじめに こんにちは。WEAR部Androidチームの半澤です。普段は、 「ファッションコーディネートアプリ WEAR」 のAndroidアプリ開発を担当しております。 今回は、WorkManagerを使ったバックグラウンドでのAPI呼び出しについて紹介いたします。WorkManagerは時間がかかる処理や永続的な処理などをバックグラウンドで実行するために推奨されるソリューションです。例えばサイズの大きいデータのアップロード処理や定期的なタスクをバックグラウンドで実行したいといったケースで利用されます。 背景 「WorkManagerを使ってアプリのプロセスの状況に依存せずAPI通信をしたい」というのが今回の背景となります。RetrofitなどのAPI通信を簡単に行うためのライブラリなどがありますが、画面遷移のタイミングで通信処理をキャンセルさせるといった仕組みを導入していることも多いかと思います。今回、こういったアプリのライフサイクルの状況に依存させずAPI通信をしたいケースと遭遇し、WorkManagerを利用したところシンプルに実現できました。 概略を説明すると別画面へ遷移するボタンの押下後にAPIを呼び出す必要がありましたが、ボタン押下後は画面を破棄するため、現状のままでは通信処理がキャンセルされてしまうという課題がありました。 基本的な登場人物とアクセス方法 WorkManagerの基本的な処理の流れは以下のとおりです。 Requestを作成 WorkManagerに処理の実行を依頼 Requestの作成方法として下記の2つの実装が標準で提供されています。 OneTimeWorkRequest PeriodicWorkRequest OneTimeWorkRequestはその名の通り、1度のみの処理のスケジュールを設定します。PeriodicWorkRequestは一定間隔で繰り返すようなスケジュールを設定する場合に適してます。今回は失敗した場合のリカバリーを考慮せず、1度のみの送信処理が実行できれば良いという要件としたため、OneTimeWorkRequestを採用しました。このRequestが処理を実行するキューとして積まれ、WorkManagerがRequestをスケジュール実行します。また、今回は特に設定していませんが、Wi-Fiに接続したときやバッテリーが十分あるときに処理を実行する 制約 を追加で指定できます。 Workerにデータを渡す はじめに、WorkerはKotlin Coroutinesを使った CoroutineWorker を利用しています。本記事で説明しているバージョンは2.6.0です。 ここからは実際のコードを例に挙げて説明していきます。Workerとのデータのやり取りは Data で行います。このクラスはデータをMapで保持しています。基本的なやりとりとしてOneTimeWorkRequestBuilderで用意されている setInputData() にデータを渡します。また、 workDataOf はBuilder処理をラップした拡張関数です。 fun createRequest( someData: String , ): OneTimeWorkRequest { return OneTimeWorkRequestBuilder<MyWorker>() .setInputData(workDataOf( IN_KEY_DATA to someData, )) .setBackoffCriteria( BackoffPolicy.LINEAR, OneTimeWorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS) .build() } そして、 doWork() 内で必要な処理を記述します。以下の例ではWorkerのinputDataからrequest時に渡した引数を取得して処理を実行します。 override suspend fun doWork(): Result = withContext(Dispatchers.Default) { launch { val someData = inputData.getString(IN_KEY_DATA) ?: error( "invalid someData" ) //~~ 何らかの処理を実行 ~~// } return @withContext Result .success() } 呼び出し元はWorkManagerへrequestのenqueueを依頼し、処理を実行します。 val request = MyWorker.createRequest(someData) WorkManager.getInstance(context) .beginWith(request) .enqueue() 基本的なWorkManagerでの処理実行に関しては以上で、特に複雑な処理などはありません。 プリミティブ型以外のデータをWorkerに渡す 今回は実装の見通しを良くしたいという目的から、データクラスをJSON文字列として渡す方法もあるという一例をあわせて紹介します。例えば、Workerで実行されるAPIに次のようなデータ型を含んだパラメータを渡したいといったケースがあったとします。 data class SomeApiParameter( val someData: String , val someId: Int , val somePayload: Payload ) このpayloadは以下のようなパラメータを渡すとします。 data class SomePayload( val item1: Item1, val item2: Item2, ) : Payload { data class Item1( val id: Long , val itemName: String , ) data class Item2( val id: Long , ) } 上記のようなデータ型を上の例で示したものと同じくRequestに setInputData(workDataOf(...)) で渡します。ここでworkDataOfを見てみると引数が Pair<String, Any?> なので、なんでも受け付けてくれるかのようにみえます。 public inline fun workDataOf(vararg pairs: Pair<String, Any?>): Data { val dataBuilder = Data.Builder() for (pair in pairs) { dataBuilder.put(pair.first, pair.second) } return dataBuilder.build() } しかし、workDataOfの実装の中身を見てみると実はプリミティブ型以外の型は受け付けておらず、これ以外の型を渡すと例外でクラッシュすることがわかります。 public Builder put(@NonNull String key, @Nullable Object value) { if (value == null) { mValues.put(key, null); } else { Class<?> valueType = value.getClass(); if (valueType == Boolean.class || valueType == Byte.class || valueType == Integer.class || valueType == Long.class || valueType == Float.class || valueType == Double.class || valueType == String.class || valueType == Boolean[].class || valueType == Byte[].class || valueType == Integer[].class || valueType == Long[].class || valueType == Float[].class || valueType == Double[].class || valueType == String[].class) { mValues.put(key, value); } else if (valueType == boolean[].class) { mValues.put(key, convertPrimitiveBooleanArray((boolean[]) value)); } else if (valueType == byte[].class) { mValues.put(key, convertPrimitiveByteArray((byte[]) value)); } else if (valueType == int[].class) { mValues.put(key, convertPrimitiveIntArray((int[]) value)); } else if (valueType == long[].class) { mValues.put(key, convertPrimitiveLongArray((long[]) value)); } else if (valueType == float[].class) { mValues.put(key, convertPrimitiveFloatArray((float[]) value)); } else if (valueType == double[].class) { mValues.put(key, convertPrimitiveDoubleArray((double[]) value)); } else { throw new IllegalArgumentException( String.format("Key %s has invalid type %s", key, valueType)); } } return this; } このままではAPIのパラメータであるデータ型を渡すことはできません。これをどのように解決したかというとRequest時にデータ型をJSON文字列に変換し渡すことで解決しました。 fun createRequest( someData: String , someId: Int , somePayload: Payload, ): OneTimeWorkRequest { val jsonStr: String = gson.toJson(somePayload) //JSON文字列へ return OneTimeWorkRequestBuilder<MyWorker>() .setInputData(workDataOf( IN_KEY_DATA to someData, IN_KEY_ID to someId, IN_KEY_PAYLOAD to jsonStr, )) .setBackoffCriteria( BackoffPolicy.LINEAR, OneTimeWorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS) .build() } WorkerにJSON文字列としてpayloadを渡し、実行時はSomePayloadクラスに再度戻すことでデータ型への対応も問題なくできました。 override suspend fun doWork(): Result = withContext(Dispatchers.Default) { launch { val someData = inputData.getString(IN_KEY_DATA) ?: error( "invalid someData" ) val someId = inputData.getInteger(IN_KEY_ID) ?: error( "invalid someId" ) val jsonStr = inputData.getString(IN_KEY_PAYLOAD) ?: error( "invalid jsonStr" ) val payload = gson.fromJson(jsonStr, SomePayload :: class .java) // データ型に戻す client.postAPI(SomeApiParameter(someData, someId, payload)) } return @withContext Result .success() } 今回の例では、WorkManagerでの処理結果は無視していますが、成功や失敗を通知したい場合は以下の例のようにworkDataOfの結果をData型で返してあげれば良さそうです。また、処理経過をハンドリングしたい場合には WorkQuery を使って実行されている処理をモニタリングするのも良さそうです。 response = client.postAPI(SomeApiParameter(someData, someId, payload)) if (response.isSuccessful) { val outputData = workDataOf(OUT_KEY_JSON_STRING to gson.toJson(response?.body())) return @withContext Result .success(outputData) } else { val outputData = workDataOf(OUT_KEY_JSON_STRING to gson.toJson(response?.body())) return @withContext Result .failure(outputData) } さいごに 今回紹介した事例で、WorkManagerを使ったバックグラウンドでのAPI通信について解説させていただきました。バックグラウンドで処理を実行するのであればServiceクラスなどもありますが、WorkManagerを使うとよりシンプルにやりたいことが実現できたと思います。 さいごに、ZOZOでは、一緒にモダンなサービス作りをしてくれる方を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com もちろんAndroidエンジニアの採用も積極的に行っています。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
アバター
はじめに こんにちは。WEAR部フロントエンドブロックの藤井です。 WEAR では現在、Webサイトのリプレイスを進めています。本記事では、リプレイスに至った背景や課題と、課題解決のために行ったリプレイスのアーキテクチャ選定についてご紹介します。 なぜリプレイスするのか WEARはサービスローンチしてから約10年が経ちます。これまでローンチ当時の技術スタックのまま開発を続け、サービスを成長させてきました。今後もより継続的にスピード感を持ってユーザーへ価値を届けていくにあたってさまざまな課題があったため、新たな技術スタックでリプレイスを開始することにしました。 リプレイス前の環境 リプレイス前の環境はオンプレミスの環境にロードバランサー、Windowsサーバー(IIS)があり、そこでVBScriptが動いています。VBScriptでテンプレートHTMLにデータを流し込み、ブラウザに表示する仕組みで動いています。フロントエンド部分はjQueryを使用し、インタラクションの実装、Ajaxで取得したデータを元に後読みでコンテンツの描画などを行っていました。 課題 上記の環境で長らく開発を進めていましたが、サービスの成長や機能の増加に伴って以下のように生産性、保守性に関する課題がでてきました。 レガシー技術を使っており、使用できるライブラリが少ない CI/CDの環境が整備されていないため、非効率な部分がある 約10年間の技術的負債による実装や仕様の把握コストが高い jQueryやVBScriptのエンジニアの採用が難しい 今後、WEARのサービス改善をスピード感を持って実行していくためにはこれらの課題を改善する必要がありました。このような背景からリプレイスを始めることにしました。 どのようにリプレイスを進めたか リプレイスを進めるにあたって課題を解決するだけではなく、リプレイスで実現すべきミッションについて改めて整理しました。そして、そのミッションを元にアーキテクチャを選定しました。 リプレイスのミッション リプレイスのミッションを考えるときに軸となったのは、WEARのプロダクトとしてのミッションでした。WEARはミッションとして、”ファッションデータを集めて、人々のファッションの悩みを解決する”を掲げています。より多くの人にコーディネート投稿をしていだき、より多くの人にファッションデータを届けることで、このミッションを実行しようとしています。 このミッションの元、WEARのWebサイトで何をすべきかをチームで話し合い、以下の方針を決めました。 【MUST】 開発生産性の向上 最低限必要な機能の選択 現在の利用状況を考慮した取捨選択 【WANT】 SEO改善 パフォーマンス UX MUSTの要件としてはまず、課題であった開発生産性を向上させることを挙げました。 次に、できるだけ早くユーザーに快適なリプレイス環境を利用してもらうために必要な機能を取捨選択することを決めました。機能を取捨選択する際にはユーザーの利用状況を確認し、数値を元に判断しています。 WANTの要件にはSEO改善を目的として、パフォーマンス、UXの向上を挙げました。 WEARのWebサイトはSEO流入で閲覧してくださるユーザーがとても多いです。そのためWEARのMissionのうち、”より多くの人にファッションデータを届ける”ということを重視してSEOへの注力をミッションに決めました。 アーキテクチャ選定の軸 リプレイスのミッションに沿って、開発生産性の向上と、SEO改善目的でパフォーマンスを重視した技術選定をしました。 リプレイス後のアーキテクチャ フレームワーク フレームワークは当初、Preact+Fastifyで検証を進めていたのですが、最近になってNext.jsを選択することにしました。それぞれの選定理由や選定までの経緯を説明します。 Preact+Fastify まず初めに選定したアーキテクチャは以下のような構成で、Preact+Fastifyを使用していました。 SEO改善目的でパフォーマンスを重視した選定になっています。FastifyはNode.jsフレームワークとしてよく使われるExpressよりも高いパフォーマンスであることから採用しました。Preactはファイルサイズが大きいReactDOMを使用せず軽量化されているため採用しました。 処理の流れとしてはまず、Build時のSSG(Static Site Generator)やサーバー上でのSSR(Server Side Rendering)で生成したHTMLをブラウザに返します。基本的にSSGやSSRではSEOに重要な情報について扱います。そして、SEOに必要のない部分やログインユーザーの情報に関わる部分はCSR(Client Side Rendering)用のJavaScriptを生成し、ブラウザ側でレンダリングするようにしています。まずこちらのアーキテクチャで簡単なページから検証とリプレイスを進めることにしました。 パフォーマンス目的でこのようにCSR部分を分離した構成にしていたのですが、リプレイスを進めたところ以下のような考慮点がでてきました。 SSRで取得したデータをCSRで使用したいときの受け渡しにPropsが使えないため、自前で実装する必要がある SSRで描画した要素に対してインタラクションをつけたい場合、イベントリスナで実装しなければならずDOM操作をベタ書きする必要がある CSRで描画する部分の高さ確保の考慮が必要である SSR、SSGする際にCSR部分の高さを考慮する必要があり、高さを二重で管理するなど煩雑になってしまう部分があった 上記を踏まえ、WEARのWebサイトにおいてこのアーキテクチャで進めるべきか考えたところ、以下の課題がありました。 WEARのWebサイトではブラウザ上のインタラクションが多いため、SSRした箇所にイベントリスナで実装しなければならない箇所も多く、開発効率の悪い部分がある SEO重視のためにシンプルな作りにはしたいが、それでも既存のブラウザ上のインタラクション部分など削除できない機能・実装が多い Next.js 先述したような課題がわかったため、アーキテクチャを見直して以下のようにNext.jsを使用することにしました。 Next.jsを選定した理由は以下のメリットがあったためです。 先述のPreact+Fastifyの構成において自前で実装しようとしていた部分をフレームワークに頼れる Next.jsがWebフロントエンドのスタンダードになりつつあり、ドキュメントや採用事例が多く、コミュニティも活発である ZOZOTOWNもNext.jsでリプレイスを進めているので互いにナレッジシェアできる もともと、Preactのファイルサイズが軽量であることの恩恵としてCore Web Vitalsの指標にあるFirst Input Delayの短縮を見込んでいました。しかし、上記に挙げたようなメリットや開発効率を優先させてNext.jsへの移行を決めました。 エッジサーバー リプレイスするにあたってエッジサーバーとしてFastlyを導入しました。一番の目的はパスベースルーティングをさせるためです。 パスベースルーティング WEARは日々新しい機能追加や機能改善をしているサービスなので、1回で全機能を切り替えるのは難しいという背景ありました。そのため、以下のようにFastlyを用いて、パスベースでリプレイス前の環境とリプレイス環境に割り振ってルーティングし、段階的にリプレイスを進めています。 Fastlyを選定した理由や詳しい活用方法については以下の記事で紹介されているので、併せてご覧ください。 techblog.zozo.com CDNキャッシュ Fastlyのもう1つの活用方法として、CDNキャッシュを利用して素早くページを表示することによるユーザー体験の向上とSEO改善を検討しています。安全にキャッシュを利用できるようにログインしているユーザー情報に関わらない部分のみをビルド時やサーバー側で生成します。そして、ユーザー依存情報はuseEffectなどのフックを利用してCSRで描画されるようなコンポーネント設計にしています。 その他ライブラリ・SaaS Mock Service Worker WEARではWebのリプレイスと並行してバックエンドチームにWebリプレイスで必要なAPIをリプレイスを進めてもらっています。リプレイス前の環境ではAPIの開発を待つか、モックAPIを作成してもらってAPIデータ取得部分の実装をしていました。そのため、フロントエンドとバックエンドを同時並行で開発を進めることがなかなか難しい環境でした。しかし、Mock Service Workerを使うことによって、Swagger定義があればAPIの開発を待たずにAPIデータ取得部分の実装を進められるようになりました。 Tailwind CSS スタイリングについては以下のような理由でTailwind CSSを選定しました。 CSS設計やクラス名を考える時間の削減 クラス名の名付けを考える時間を削減できる JSX内で記述できるので、別でCSSを管理する必要がなく書きやすい レスポンシブ対応のコスト削減 将来的にレスポンシブ化するにあたって、レスポンシブユーティリティが用意されているフレームワークを利用したかった CSSのバンドルサイズ縮小 purgeオプションを使用することで未使用のスタイルが削除され、最終的なビルドサイズを最適化される UIライブラリより自由度が高い Chakra UI等のUIライブラリも検討しましたが、既存のWEARのデザインやUIを実現するために比較的、自由度の高いTailwind CSSを選択 StoryBook UIのカタログ化のために導入しました。リプレイス前は共通UIがあることに気づかず個別に実装してしまうということなどもあったのですが、カタログ化することでこの問題が解消されました。また、初めは個別に実装していた箇所についてもカタログ化していることで共通性に気づいて後から共通コンポーネントに移すということもあり、コンポーネント整理に役立っています。 Chromatic Chromatic、Mock Service Worker、Storybookを連携させてビジュアルリグレッションテストを行なっています。スタイルの差分を検知してくれるようになったので、今まで目視で細かくチェックしていた時間を削減でき、開発やレビュー時間の短縮につながりました。 Chromatic、Mock Service Worker、Storybookを使った取り組みについてはFAANS部の田中が以下の記事で紹介しているので、併せてご覧ください。 techblog.zozo.com まとめ WEARではプロダクトのミッションやサービス特性を元にアーキテクチャ選定をしました。アーキテクチャ選定の際にはいくつかの観点でメリット・デメリットを挙げて検討しましたが、特に重きをおいたのは以下の3点です。どれか1つに偏ることなく、サービスとして欠かせない点や許容できる範囲を見極めることが大切だと思います。 開発効率 必要な機能を実装しやすい技術選定になっているか SEO観点でのパフォーマンス パフォーマンスがでる技術選定になっているか 既存の仕様の実現可能性 既存の仕様、要件を満たせる技術選定となっているか さいごに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com hrmos.co
アバター
はじめに こんにちは。ECプラットフォーム部カート決済ブロックの曽根です。 ZOZOTOWNでは、リプレイスの一環として、2021年4月からカート決済機能のマイクロサービス化を開始しました。 ZOZOTOWN カート決済機能リプレイス Phase1 〜 キャパシティコントロールの実現 - ZOZO TECH BLOG 本記事では、上記で紹介しているCart Queuing SystemのAmazon Kinesis Data Streams(以下、KDS)にフォーカスし、Javaの実装を交えて事例をご紹介します。また、開発中にAWS SDK for Javaをv1からv2へバージョンアップしたため、合わせて変更点もお伝えします。 KDSとは KDSは、ログやイベントデータの収集、リアルタイム分析などで活用可能なストリーミングデータサービスです。 KDSに格納されるデータの単位は、レコードです。レコードは、以下で構成されています。 シーケンス番号 パーティションキー データBLOB パーティションキーはKDSにデータを組み込む時に使用され、レコードをストリームのシャードにルーティングします。 シャードとはKDS内で識別されたレコードのシーケンスです。シャードへのルーティングは以下のルールで行われます。 パーティションキーをMD5ハッシュ関数でハッシュ化して、128bitの整数値にマッピングを行う ハッシュ化された整数値が割り当てられたシャードにデータを送る 128bitなので、シャードには0から340282366920938463463374607431768211455の値が割り振られています。この値をシャードの数に応じて分割します。 具体的には、シャードが1つの場合は以下のようになります。 シャード名 値 シャード1 0 - 340282366920938463463374607431768211455 シャードが2つの場合は340282366920938463463374607431768211455を2で割り、以下のようになります。 シャード名 値 シャード1 0 - 170141183460469231731687303715884105727 シャード2 170141183460469231731687303715884105728 - 340282366920938463463374607431768211455 本来、各シャードへの振り分けはKDSが自動で行います。しかし、開発時に確認したところ想定以上の偏りが出てしまいました。そこで、意図的にシャードを振り分ける検証をしました。 以下は、レコードを100個に分割したシャードに対してランダムかつ均等に振り分ける例です。最大値をシャードの数で割り、それにシャードの数を最大とした乱数を掛けた値をハッシュ値にしています。意図的にシャードを指定するためには、explicitHashKeyにハッシュ値を設定する必要があります。partitionKeyとexplicitHashKeyの両方が設定されていた場合、explicitHashKeyが優先されます。 BigDecimal sortingShard = new BigDecimal( new BigInteger( "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" , 16 )) .divide( BigDecimal.valueOf( 100 ), 4 , RoundingMode.HALF_UP); String explicitHashKey = sortingShard .multiply(BigDecimal.valueOf( new SecureRandom().nextInt( 100 ))) .setScale( 0 , RoundingMode.UP) .toString(); PutRecordRequest putRecordRequest = PutRecordRequest.builder() .streamName(streamName) .data(SdkBytes.fromByteArray(json.getBytes())) .explicitHashKey(explicitHashKey) .partitionKey(partitionKey) .build(); 各シャードは、読み取りに対して最大5トランザクション/秒をサポートします。また、最大合計データ読み取りレートは2MB/秒、最大合計データ読み取りレートは1,000レコード、最大合計データ書き込みレートは1秒あたり1MB(パーティションキーを含む)までサポートできます。より詳しい説明は 公式ドキュメント をご参照ください。 アーキテクチャ設計 ZOZOTOWNでは、カート投入時に商品の在庫引き当てを行う仕様があります。そのため、複数のユーザーが同じ商品をカート投入しようとした場合、FIFO(First-In First-Out)で処理を行い、投入順を維持する必要があります。KDSはシャードごとに順序保証がされているため、同一のシャードにリクエストを振り分ける必要がありました。そこでパーティションキーを商品ごとに振られている商品IDにしました。より詳しい説明は以下の記事をご参照ください。 ZOZOTOWN カート投入の分散キューイングシステム 〜 プロダクションレディまでの歩み - ZOZO TECH BLOG 過熱商品への対応 福袋や限定品など、ユーザーから大量のアクセスが来る商品のことを弊社では「過熱商品」と呼んでいます。 過熱商品を先程までのアーキテクチャで処理してしまうと、下図のように過熱商品ではない商品を購入したいユーザーが巻き込まれて商品をカートに追加できなくなってしまいます。 そこで、この問題を解決するために、下図のようにストリームを分けるようにしました。 過熱商品になりそうな商品をあらかじめDBに登録しておき、カートに商品を追加する際にそのDBを参照します。これによりデータが取得できた場合は、過熱商品用のストリームにデータを流します。 このようにストリームを分けることにより、過熱商品による大量のアクセスが他の商品を購入したいユーザに影響を及ぼすことはなくなりました。 AWS SDKとは ここからはAWS SDKのバージョンアップについて説明します。AWS SDKはAWSのサービスをプログラムなどから操作できるようにするための開発キットです。AWS SDKを使用することでWebアプリケーションを介さずに直接AWSサービスとやり取りできるアプリケーションを開発できます。AWS SDKは各種言語に対応しており、様々なAWSのサービスに対応しています。今回のプロジェクトはJavaで開発しているため、AWS SDK for Javaを使用しています。 SDKのバージョンアップ 2018年の11月にAWS SDKの2.xがリリースされました。 プロダクトでは最初に1.x系のライブラリを使用していましたが、途中から2.x系にバージョンアップを行いました。変更点をいくつかご紹介します。 クライアントの生成 1.xではコンストラクタによる生成だったのが、2.xではbuilderによる生成になりました。 1.x AmazonKinesis kinesisClient = AmazonKinesis.defaultClient(); AmazonKinesisClient kinesisClient = new AmazonKinesisClient(); 2.x KinesisClient kinesisClient = KinesisClient.create(); KinesisClient kinesisClient = KinesisClient.builder().build(); クライアントの設定方法 1.xでは ClientConfiguration ですべて設定していましたが、2.xでは別々の設定クラスに分割されています。 設定内容 1.x メソッド - 2.x クラス 2.x メソッド 接続タイムアウトまでの時間 withConnectionTimeout → ApacheHttpClient connectionTimeout クライアントがAPI呼び出しの実行を完了するのにかかる時間 withClientExecutionTimeout → ClientOverrideConfiguration apiCallTimeout リクエストをタイムアウトするまでの時間 withRequestTimeout → ClientOverrideConfiguration apiCallAttemptTimeout ソケット通信をタイムアウトするまでの時間 withSocketTimeout → ApacheHttpClient socketTimeout HTTP接続の最大数 withMaxConnections → ApacheHttpClient maxConnections 最大リトライ数 withMaxErrorRetry → ClientOverrideConfiguration retryPolicy 認証情報の設定 2.xでは環境変数名やメソッドなどが変更になっています。また一部のメソッドがサポート外になりました。 環境変数名の変更 1.x 2.x AWS_ACCESS_KEY AWS_ACCESS_KEY_ID AWS_SECRET_KEY AWS_SECRET_ACCESS_KEY AWS_CREDENTIAL_PROFILES_FILE AWS_SHARED_CREDENTIALS_FILE メソッド名の変更 1.x 2.x AWSCredentialsProvider.getCredentials AwsCredentialsProvider.resolveCredentials DefaultAWSCredentialsProviderChain.getInstance サポート外 AWSCredentialsProvider.getInstance サポート外 AWSCredentialsProvider.refresh サポート外 システムプロパティ名の変更 1.x 2.x aws.secretKey aws.secretAccessKey com.amazonaws.sdk.disableEc2Metadata aws.disableEc2Metadata com.amazonaws.sdk.ec2MetadataServiceEndpointOverride aws.ec2MetadataServiceEndpoint DynamoDBへのアクセス方法 メソッドチェーンでより直感的に記載できるようになりました。 1.x public void register(Id id) { CartRequests cartRequests = new CartRequests(id.getValue()); DynamoDBSaveExpression dynamoDBSaveExpression = new DynamoDBSaveExpression() .withExpectedEntry(ID, new ExpectedAttributeValue().withExists( false )); dynamoDBMapper.save( cartRequests, dynamoDBSaveExpression ); } 2.x public void register(Id id) { CartRequests cartRequests = new CartRequests(id.getValue()); Expression expression = Expression.builder() .expression( "attribute_not_exists(#id)" ) .expressionNames(Map.of( "#id" , ID)) .build(); PutItemEnhancedRequest<CartRequests> putItemEnhancedRequest = PutItemEnhancedRequest.builder(CartRequests. class ) .item(cartRequests) .conditionExpression(expression) .build(); getCartRequestsTable().putItem(putItemEnhancedRequest); } 例外クラス名の変更 1.x 2.x com.amazonaws.SdkBaseException com.amazonaws.AmazonClientException software.amazon.awssdk.core.exception.SdkException com.amazonaws.SdkClientException software.amazon.awssdk.core.exception.SdkClientException com.amazonaws.AmazonServiceException software.amazon.awssdk.awscore.exception.AwsServiceException クライアントや例外クラスの変更などがあるため、アップデートする際にある程度コードの修正が発生してしまいます。時間に余裕を持って行いましょう。また、現状ではまだ1.x系のみにしかない機能がいくつかあります。その機能を使う場合は1.x系と2.x系を両方同時に使用して、処理によってライブラリを使い分けましょう。 ほかにも様々な変更があるので、詳しくは 公式ドキュメント や changelog をご参照ください。 まとめ 今回はZOZOTOWNのカート決済機能のリプレイスで使用したKDSの事例とAWS SDKのバージョンアップについて紹介しました。 KDSやAWS SDKを使用することで、ユーザーに安定したカート投入を提供できるようになりました。今後もサービス向上のため、さらなる改善を進めていきます。 最後に ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co hrmos.co
アバター
はじめに こんにちは、ZOZOアプリ部でZOZOTOWN iOSアプリを開発している小松です( @tosh_3 )。ZOZOTOWN iOSチームでは、M1 Pro / M1 Max発売のタイミングでチーム内の開発環境をApple siliconへと移行しました。スムーズに移行するためにどのようなことを実践したのかと実際に移行することでどのような恩恵を受けることができたのかを紹介します。 Apple siliconについて WWDC 2020にてAppleはIntelプロセッサーからApple siliconと呼ばれるAppleによってデザインされたプロセッサーへと移行していくことを発表しました。開発者用にDTK(Developer Transition Kit)が配布されたのち、2020年の11月に一般用としてM1プロセッサーが、そして2021年の10月にはアップデートされたM1 Pro / M1 Maxのプロセッサーが発表されました。Apple siliconでは、ARM64と呼ばれるCPUアーキテクチャを採用していたことから、今までのIntelのプロセッサーとはアーキテクチャが異なります。そのため開発者はApple silicon下でも問題なく動くのかを確認する必要がありました。 検証機の導入 ZOZOTOWN iOSチームではM1 Proよりもさらに前、M1が発売されたタイミングで、M1のMacBook Airをチーム内に検証機として一台導入しました。 検証機を導入した背景は以下の通りです。 Apple siliconでも今まで通り業務ができるのか担当部門で検証中であったため、業務用PCの置き換えよりも検証機としての導入の方が好ましかった メモリを16GBまでしか積めず、32GB or 64GBまで積める、よりプロフェッショナルなモデルのリリースが予想されていた チーム全体として開発環境の移行にかかる時間を減らしたかった 検証にあたって意識した点は以下の通りです。 ZOZOTOWN iOSアプリを問題なくビルドすることが可能か 通常の業務で使用にあたり不自由ない環境であるか 導入することによってどのような効果を得ることができるのか ZOZOTOWNのビルドについて 結論から言ってしまうとApple silicon環境下ではZOZOTOWNのアプリはビルドできませんでした。正確にいうとRosettaの使用なしにはビルドができなかったのです。 ZOZOTOWNはアプリ内で、ZOZOSUITやZOZOMAT、ZOZOGLASSのような計測機能を備えています。これらの計測機能はフレームワークとしてZOZOTOWN内に入っています。また、これらのうちZOZOSUITとZOZOMATはCarthageで管理されており、ZOZOGLASSのみCocoaPodsで管理されています。 Apple siliconの導入にあたって、Carthageで管理されているフレームワークをXCFrameworksとして扱う必要があります。しかし、このZOZOSUITとZOZOMATをXCFrameworkの形式にすることが困難を極めました。というのもZOZOSUITの中にはOpenCVが使用されており、そのバージョンは3系であったためにXCFramework対応の入った4系へのアップデートが必要だったのです。また、ZOZOSUITのXCFranework化については上記とは別にビルドフェーズそのものを見直す必要もありました。 これらの理由から自分達のチームに収まらない範囲での対応が必要でした。加えて、全体としての方針でCarthageからCocoapodsへの移行も同時に進めていたこともあり、今回のタイミングではRosettaありでApple siliconへと移行することに決めました。こういった判断をあらかじめ行うことができたのも、検証機導入のおかげだと思います。 RosettaとXCFrameworks Apple siliconではARM64というCPUのアーキテクチャを採用しましたが、Intel Macではx86_64というアーキテクチャが採用されていました。Rosettaとは、ARM64で動いているApple silicon下において、x86_64用のバイナリを動かすことができる翻訳プロセスになります。詳細は下記を参考にしてください。 developer.apple.com では一体なぜ、XCFrameworkへの変更がApple siliconの導入に伴い必要なのかを説明します。Carthageはframeworkというファイルを生成し、それをプロジェクトへと入れています。Carthageによって生成されるframeworkファイルは複数のアーキテクチャに対応できるUniversal Binaryという仕組みを使用していました。 Intel Macでは、Simulator用にはx86_64のバイナリを作成し実機用にはARM64のバイナリを作成して、Universal Binaryとしています。一方でApple siliconではSimulatorもARM64として存在するため、Simulator、実機共にARM64向けのバイナリが必要になります。 Apple siliconの場合、2つの同じアーキテクチャ用のバイナリが発生してしまうため今までのUniversal Binaryの仕組みではうまくいきませんでした。 そこで登場したのが、XCFrameworksです。XCFrameworksは複数のframeworkをまとめることができるため同じARM64の向けのsimulator用と実機用のframeworkを共存させることができます。 しかし、ZOZOTOWNではCarthageで管理しているライブラリの全てをXCFramework化することが難しかったため(OpenCV 3系、ビルドフェーズの問題)先ほどあげたRosettaを使用しています。そのためx86_64向けのバイナリをRosettaを通すことによってARM64のsimulatorでも動かすことができるようになっているというわけです。 # 本導入 M1 Pro / M1 Max発表後にこのタイミングでチーム内の開発環境をApple siliconへと移行するのがベストであると判断しました。 理由は下記の通りです。 - M1を使用した検証や他社事例も含めだいぶ知見が溜まってきた - 明らかに開発効率を上げることができると確信できた Intel版のMacBookの販売がなくなり、会社としてApple siliconを標準機にする方針になった メンバーごとのマシンスペックによる開発効率の面や構成管理の整合の面からも開発環境を統一しておきたかった MacBookのスペックについて 迷わずM1 Maxといきたいところですが、ZOZOではiOSエンジニアの支給端末をM1 Proとしました。スペックは下記の通りです。 10コアCPUのM1 Pro 32GBユニファイドメモリ 1TB M1 MaxではなくM1 Proの上記のスペックを選択した理由としては10コアCPUのM1 ProとM1 Maxとの違いとしてはGPUのみであり、ビルド時間には大きな影響がないこと。また、メモリが32GBか64GBでの違いもありますが、今までZOZOTOWNを開発していく中でメモリが不足するということはなかったため32GBでも問題なく動作するという判断をしました。 容量については複数のバージョンのXcodeを管理することもあるので、少し余裕を持たせて1TBとしています。Intel Macで動かしていて、CPU起因以外のマシンパワーの律速は発生していなかったというのも上記スペックを決める上で参考にしました。現状、上記のスペックで運用していて、スペックが不足していると感じたことはないです。 Apple silicon移行期間 2日間で移行完了 チーム内でスムーズに環境を変化させられるように、あらかじめ検証機で自分が詰まったところなどを全てメモしておきました。 上の画像のような手順として忘れがちな証明書周りまで、細かいことではあるのですが、意外と失念していることなどもあるのでまとめておくことで皆が詰まることなくスムーズに移行する手助けになります。こういった知見をあらかじめまとめたことにより、チーム全体で移行は2日間程度で完了できました。 検証機を用いて移行のためのPR作成 実は、チーム全体にApple siliconがくる1か月前に、検証機を用いてZOZOTOWNのアプリをApple siliconでも動かせるようにしたPRを作成しておきました。ここでも検証機が役に立ったのはいうまでもありません。手元にM1 Proが届いた段階で、チーム内で行うべきことは環境構築と動作確認のみという状態にできました。これもスムーズな移行のための施策です。 Intelプロセッサーを手放すタイミング Apple siliconでビルドしたアプリをリリースした後に、問題が出ていないかを確認しました。このタイミングで、Intel Macから完全にApple siliconへと移行が完了しました。リグレッションが発生する可能性を考え、念のためまだ手元においてありますが、リリースから1か月半の間の運用の中で問題が出ていないためそろそろ手放し時だと思っています。参考までに自分達の場合、Apple silicon導入からIntel返却の期間は2か月を見込んでおきました。 効果 ここが、皆さんの一番気になるところだと思います。ビルド速度検証については下記ライブラリを使用しました。 github.com 今回はより正確な計測をするために、全てのケースにおいて下記の条件で統一しました。 リリースビルドでのビルド時間計測 計測前にDerivedDataを削除する Intel Core i9 Apple M1 Pro 6分25秒 3分12秒 Intel Macと比較すると、なんと半分までビルド時間を減らせました。ちなみに、Apple silicon導入後に複数ブランチが乱立するような大きなプロジェクトがあったのですが、このビルド時間の大幅短縮によってかなり効率が上がったことを体感できました。まだ導入を悩んでいる方がいましたら、Apple siliconの導入を強くお勧めします。 まとめ いかがでしたでしょうか。Apple siliconの導入によってZOZOTOWNの開発がどのくらい向上したのかが少しでも伝わっていれば光栄です。ビルドフェーズを見直してビルド時間を短縮するのも大切ですが、Apple siliconの導入によってビルド時間を短縮させるのも1つの手なのかもしれません。 最後に ZOZOでは、一緒に大規模なサービス作りをしてくれる方を募集しています。ご興味のある方は、以下のリンクから是非ご応募ください! hrmos.co
アバター
はじめに こんにちは。マイグレーションブロックの藤本です。 ZOZOのマイクロサービスの開発では、以前の「 OpenAPI3を使ってみよう!Go言語でクライアントとスタブの自動生成まで! 」や「 Go言語におけるOpenAPIを使ったレスポンス検証 」の記事にもあるように、OpenAPI(Swagger)を使ってAPIの仕様を管理しています。そして私たちのチームでは、OpenAPIのYAMLからControllerのInterfaceとレスポンスオブジェクトのコードを生成して、それを実装することでAPIの開発を進めています。 この記事では、OpenAPI Generatorを使ったOpenAPI定義からのコード生成と、Spring Framework(以下、Spring)のカスタムデータバインディング 1 を共存させるために実施したことをご紹介します。 先に結論から 今回はテンプレートを編集する方法で実現させました。概要は次のとおりです。 OpenAPI Generatorのテンプレートをエクスポート エクスポートしたテンプレートに独自の設定を追加できるようにする Springのカスタムデータバインディングを設定する また今回使用した主な言語、フレームワーク、ライブラリのバージョンは次のとおりです。 Java 11 https://adoptium.net/?variant=openjdk11&jvmVariant=hotspot Spring Boot 2.6.4 https://github.com/spring-projects/spring-boot springdoc-openapi v1.6.6 https://github.com/springdoc/springdoc-openapi OpenAPI Generator 5.4.0 https://github.com/OpenAPITools/openapi-generator それでは手順を説明していきます。 OpenAPIの定義からコードを生成する サンプルAPIの定義 まずはYAMLでAPIを定義します。サンプルとして用意したOpenAPIの定義は次のとおりです。Userの id をパラメーターとして受け取って、Userオブジェクトとして id と name を返すAPIになっています。 # openapi.yaml openapi : 3.0.3 info : title : Sample API description : Sample API version : 1.0.0 contact : name : Sample email : sample@example.com servers : - url : http://localhost:8080 paths : '/v1/user' : get : operationId : get-user summary : User API description : Userを取得します parameters : - in : query name : user_id description : ユーザーID required : true schema : type : integer format : int32 example : 123 tags : - User responses : '200' : $ref : ./schemas/user-response.yaml '400' : description : 400 (Bad Request) headers : http_status : description : HTTPステータス schema : type : integer # schemas/user-response.yaml description : 200 (OK) content : application/json : schema : $ref : ./user.yaml headers : http_status : description : HTTPステータス schema : type : integer # schemas/user.yaml type : object properties : id : type : integer format : int32 example : 123 name : type : string example : name コードの生成 準備したOpenAPIの定義からコードを生成します。私たちのチームではプラグイン等を使わず、 Download JAR の手順に従ってJARファイルを取得して、javaコマンドで実行する方法を採用しています。表題のとおりSpringでAPIを開発しているので、 g オプション( --generator-name )で spring を指定しています。 java -jar openapi-generator-cli.jar generate \ -i ./openapi.yaml \ -g spring \ -o generated \ -c ./openapi.config \ --group-id com.example \ --artifact-id sample-api-generated \ --artifact-version 0 . 0 .1-SNAPSHOT \ --api-package com.example.api.controller \ --model-package com.example.api.model 先ほどのOpenAPIの定義から生成したコードの抜粋は次のとおりです。Interfaceとレスポンス用のUserクラスが生成されています。 // Interface public interface UserApi { // ...省略... /** * GET /v1/user : User API * Userを取得します * * @param userId ユーザーID (required) * @return 200 (OK) (status code 200) * or 400 (Bad Request) (status code 400) */ @Operation ( operationId = "getUser" , summary = "User API" , tags = { "User" }, responses = { @ApiResponse ( responseCode = "200" , description = "200 (OK)" , content = @Content (mediaType = "application/json" , schema = @Schema (implementation = User. class )) ), @ApiResponse (responseCode = "400" , description = "400 (Bad Request)" ) } ) @RequestMapping ( method = RequestMethod.GET, value = "/v1/user" , produces = { "application/json" } ) default ResponseEntity<User> getUser( @NotNull @Parameter (name = "user_id" , description = "ユーザーID" , required = true , schema = @Schema (description = "" )) @Valid @RequestParam (value = "user_id" , required = true ) Integer userId ) { // ...省略... } } // response public class User implements Serializable { private static final long serialVersionUID = 1L ; @JsonProperty ( "id" ) private Integer id; @JsonProperty ( "name" ) private String name; // ...省略... Interfaceを実装したControllerは次のとおりです。 @RestController public class UserApiController implements UserApi { @Override public ResponseEntity<User> getUser(Integer userId) { // TODO 仮実装 User user = new User() .id(- 1 ) .name( "dummy" ); return new ResponseEntity<>( user, HttpStatus.OK ); } } 引数に追加したい 先ほどのControllerでUserというオブジェクトはユーザーを表しているのですが、おそらく他のエンドポイントでも欲しくなります。必要だからといってそれぞれのエンドポイントに同じような取得処理を書くのは、設計の観点からも保守の観点からもよくありません。このユーザーのように、「Controllerに処理が移った時に欲しいもの」をSpringで渡せるようにするには、Controllerの引数として定義が必要です。メソッドに引数を追加した例は次のとおりです。 public ResponseEntity<User> getUser(Integer userId, User user) { // ...省略... ただし、Controllerに引数を追加するためにはInterfaceにも追加されている必要があり、Interfaceに追加するためにはYAMLに定義が必要です。 クライアントから受け取らないものは隠しておきたい YAMLに定義してコードを生成するのは簡単ですが、クライアントから受け取るパラメーターとして定義することになるので、本来はクライアントから受け取るつもりの無いものが外から見える状態になります。「引数として定義したい」と「クライアントから受け取らないものは隠しておきたい」という2つの要件が衝突するという問題が発生してしまいます。 どのようにして解決するか 今回はOpenAPI Genratorのテンプレートを編集することで、この問題の解決を目指しました。 テンプレートのエクスポート 編集するにはテンプレートの準備が必要です。OpenAPI Generatorはテンプレートをエクスポートする機能があるので、デフォルトのテンプレートが欲しいときはこれを使います。コード生成の時と同様に、 g オプションでSpring用のテンプレートを指定しています。テンプレートは非常に多くのファイルで構成されているので、テンプレート置き場としてディレクトリを用意することをおすすめします。 java -jar openapi-generator-cli.jar author template -g spring -o templates パラメーターの準備 次に、エクスポートしたテンプレートを編集していきます。OpenAPI Generatorには独自に定義した値をテンプレートにわたす仕組みが備わっています。この仕組みを使って、内部からはSpringが参照できるが、外部には公開していない状態を表現できるようにします。 openapi-generator.tech 今回は他のパラメーターと区別するため、Cookieで受け取るパラメーターかのように定義しました。UserオブジェクトをControllerの引数に追加したいので、 user-param.yaml としています。ここで独自の設定として x-hidden-parameter を追加しておきます。隠しておきたいパラメーターなので値は true です。 # schemas/user-param.yaml in : cookie name : user description : ユーザー情報 required : false x-hidden-parameter : true schema : $ref : ./schemas/user.yaml テンプレートの編集 パラメーターの準備ができたら、テンプレートに制御を追加します。Cookieのパラメーターとして定義したので、対象のファイルは templates/cookieParams.mustache になります。わかりやすさのために改行とインデントを追加していますが、元のファイルは1行で書かれています。 {{#isCookieParam}} {{#useBeanValidation}} {{>beanValidationQueryParams}} {{/useBeanValidation}} {{>paramDoc}} @CookieValue("{{baseName}}"){{>dateTimeParam}} {{>optionalDataType}} {{paramName}} {{/isCookieParam}} 先ほどの user-param.yaml に追加した設定値をCookie用のテンプレートで読み込みます。読み込むときは vendorExtensions に続けてドットとプロパティ名を記述します。今回追加した設定では vendorExtensions.x-hidden-parameter になります。 編集後のテンプレートは次のとおりです。先ほどと同様に改行とインデントを入れています。 {{#isCookieParam}} {{^vendorExtensions.x-x-hidden-parameter}} {{#useBeanValidation}} {{>beanValidationQueryParams}} {{/useBeanValidation}} {{>paramDoc}} @CookieValue("{{baseName}}") {{/vendorExtensions.x-x-hidden-parameter}} {{#vendorExtensions.x-x-hidden-parameter}} @Parameter(hidden = true) {{/vendorExtensions.x-x-hidden-parameter}} {{>dateTimeParam}} {{>optionalDataType}} {{paramName}} {{/isCookieParam}} コードを再生成 テンプレートの編集が終わったらコードを再生成します。このときテンプレートが置いてあるディレクトリを t オプション( --template-dir )で指定します。再生成したInterfaceの引数にUserオブジェクトが追加されるので、これを実装していたControllerも修正します。 // Interfaceのメソッド定義だけ抜粋 default ResponseEntity<User> getUser( @NotNull @Parameter (name = "user_id" , description = "ユーザーID" , required = true , schema = @Schema (description = "" )) @Valid @RequestParam (value = "user_id" , required = true ) Integer userId, @Parameter (hidden = true ) User user ) {} カスタムデータバインディングの設定 ここまでくればあとはカスタムデータバインディングを設定するだけです。まずは HandlerMethodArgumentResolver の実装クラスを作って、必要な処理を実装します。 supportsParameter メソッドで適用する条件を指定して、 resolveArgument メソッドで実際に取得したい値を生成します。 実装例は次のとおりです。 //UserArgumentResolver.java public class UserArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return User. class .isAssignableFrom(parameter.getParameterType()); } @Override public Object resolveArgument( MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory ) throws Exception { var httpServletRequest = webRequest.getNativeRequest(HttpServletRequest. class ); if (httpServletRequest == null ) { return null ; } var userId = httpServletRequest.getParameter( "user_id" ); if (!StringUtils.hasLength(userId)) { return null ; } // 例外処理などは省略 var id = Integer.parseInt(userId); return new User().id(id).name( "resolved user" ); } } そして、このクラスをSpringが管理している`HandlerMethodArgumentResolver`のListに追加すると、ControllerクラスでUserオブジェクトを取得できるようになります。 @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add( new UserArgumentResolver()); } } ## 実際に動かした結果 以上の内容を実装してSpring Bootアプリとして動かすと、Userオブジェクトのidとnameに、指定した値と`resolved user`が設定されていることを確認できます。 ![curlコマンドによるAPIの実行結果]( https://cdn-ak.f.st-hatena.com/images/fotolife/v/vasilyjp/20220311/20220311091412.jpg ) Swagger UIで確認しても、ちゃんとUserオブジェクトは外から見えなくなっています。 ![ブラウザによるswagger-uiの表示]( https://cdn-ak.f.st-hatena.com/images/fotolife/v/vasilyjp/20220311/20220311091418.jpg ) # まとめ やや強引な方法ではありますが、OpenAPI Generatorでコードを生成しながら、Springのカスタムデータバインディングの仕組みを使えました。これでコード生成の恩恵を受けつつ、共通の情報の取得を各エンドポイントで意識しなくてもよくなります。今回ご紹介した方法以外にも解決方法はあるはずなので、より良いやり方がないかは引き続き検討していきたいです。 # さいごに ZOZOでは一緒にサービスを成長させていく仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co 任意の型のオブジェクトをControllerに差し込む仕組みのことを カスタムデータバインディング と呼んでいます。 ↩
アバター
こんにちは、ZOZO NEXTで新規プロダクトの開発を担当している木下です。先日、3Dバーチャル試着に関する実証実験の取り組みが発表されました。3Dバーチャル試着ではユーザーが入力した体型データを基に3Dアバターが作成され、好みのアイテムを選んで着丈やサイズ感を確認できます。 zozonext.com この実証実験のために開発したアプリは、 Unity as a Library (UaaL)という技術を利用して実装されています。今回はUaaLをiOSアプリに組み込むにあたって工夫した点を、UX観点も交えながらご紹介します。 Unity as a Libraryとは 背景 UaaLをSwiftで利用するに当たって Unityクラスの実装 AppDelegateでUnityを呼び出す UnityのWindowからViewだけを利用する Unityを一時停止する CollectionViewCellのselect時のアニメーション ドロワーメニューからWebViewを開いた場合 Unityとのやりとりを一方向にする Build後の設定を自動化する まとめ Unity as a Libraryとは Unity as a Library (UaaL)はUnityのARや3D/2Dのリアルタイムレンダリングといった機能をネイティブアプリに組み込むことができる技術です。Unityの2019.3.0a2から導入されたもので、これによってUnityをネイティブアプリの一部として公式に組み込めるようになりました。 画像のキューブや背景と青枠内のボタンがUnityによるもの、赤枠内のボタンがネイティブアプリによるものです( サンプルプロジェクト より)。 背景 3Dシミュレーション技術は、パートナー企業からUnityのSDKとして提供されました。Unityを用いたiOSアプリの開発に当たっては、今回のような(1)UaaLを用いる方法と(2)Unityのみを用いる方法の2つがあります。今回はUXを担保するためにAppleの Human Interface Guidelines に則るという方針のもと、(1)の手法を採用しました。 UXを考慮すると、シームレスにUnityを組み込むことが重要になります。今回のバーチャル試着では、お客様ひとりひとりの体型を反映したアバターに、リアルタイムシミュレーションで服を着装します。これはモバイルアプリとしては比較的重い処理であり、負荷によってはUXに大きく関わります。これらの課題に対して、以下のような工夫をしました。 Unityのロードに若干時間がかかる→ AppDelegateでUnity呼び出す Unityとネイティブの画面切り替えが不自然→ UnityのWindowからViewだけを利用する Unityの負荷によってネイティブのアニメーションが不安定になる→ Unityを一時停止する Unityとネイティブでのデータのやりとりが複雑→ Unityとのやりとりを一方向にする UnityのBuild後の設定が複数あって手間になる→ Build後の設定を自動化する UaaLをSwiftで利用するに当たって UaaLを使うに当たって、Swiftで実装したい方が多いかと思います。しかしながら、公式のサンプルプロジェクト Unity-Technologies/uaal-example はObjective-Cで書かれています。幸い先人のおかげで様々な日本語記事が充実しています。私もこれらの記事を大いに参考にさせていただきました。 qiita.com note.com Unityクラスの実装 工夫を1つ1つ説明する前に、UaaLをネイティブアプリのプロジェクトから利用する方法について説明します。UaaLはUnityFrameworkというObjective-CのClassから 操作することができます 。そのクラスを呼び出しやすくするため、以下のようにUnity.swiftというクラスをシングルトンオブジェクトとして実装します。 class Unity : NSObject , UnityFrameworkListener { static let shared = Unity() private let unityFramework : UnityFramework override init () { let bundlePath = Bundle.main.bundlePath let frameworkPath = bundlePath + "/Frameworks/UnityFramework.framework" let bundle = Bundle(path : frameworkPath ) ! if ! bundle.isLoaded { bundle.load() } // It needs disable swiftlint rule due to needs for unwrapping before calling super.init() // swiftlint:disable:next force_cast let frameworkClass = bundle.principalClass as! UnityFramework.Type let framework = frameworkClass.getInstance() ! if framework.appController() == nil { var header = _mh_execute_header framework.setExecuteHeader( & header) } unityFramework = framework super . init () } func application ( _ application : UIApplication , didFinishLaunchingWithOptions launchOptions : [ UIApplication.LaunchOptionsKey : Any ] ? ) { unityFramework.register( self ) unityFramework.setDataBundleId( "com.unity3d.framework" ) unityFramework.runEmbedded(withArgc : CommandLine.argc , argv : CommandLine.unsafeArgv , appLaunchOpts : launchOptions ) } // UnityのWindowからViewだけを返す var view : UIView { unityFramework.appController() ! .rootView ! } // ネイティブ側からUnityのメソッドを呼び出す func sendMessageToUnity (objectName : String , functionName : String , argument : String ) { unityFramework.sendMessageToGO(withName : objectName , functionName : functionName , message : argument ) } func applicationWillResignActive (_ application : UIApplication ) { unityFramework.appController()?.applicationWillResignActive(application) } func applicationDidEnterBackground (_ application : UIApplication ) { unityFramework.appController()?.applicationDidEnterBackground(application) } func applicationWillEnterForeground (_ application : UIApplication ) { unityFramework.appController()?.applicationWillEnterForeground(application) } func applicationDidBecomeActive (_ application : UIApplication ) { unityFramework.appController()?.applicationDidBecomeActive(application) } func applicationWillTerminate (_ application : UIApplication ) { unityFramework.appController()?.applicationWillTerminate(application) } } AppDelegateでUnityを呼び出す 簡易に計測したところ、Unity起動時のロードには0.2-0.3秒かかります。これを任意のタイミングで呼び出すと、ロードしている間は真っ暗な画面が表示されます。軽微であるとは言え、UXに関わる部分です。そこで、AppDelegateの application(_:didFinishLaunchingWithOptions:) の中で呼び出すこととしました。こうすることで、ネイティブアプリのスプラッシュ画面が表示されているタイミングでUnityをロードでき、不要な画面遷移を減らすことができます。 import Firebase import UIKit @main class AppDelegate : UIResponder , UIApplicationDelegate { var window : UIWindow? func application ( _ application : UIApplication , didFinishLaunchingWithOptions launchOptions : [ UIApplication.LaunchOptionsKey : Any ] ? ) -> Bool { // Unityを呼び出す Unity.shared.application(application, didFinishLaunchingWithOptions : launchOptions ) // 最初に表示する画面を呼び出す let singInViewController = SignInViewController(nibName : nil , bundle : nil ) let navigationController = UINavigationController(rootViewController : singInViewController ) let model = SignInModel() let presenter = SignInPresenter(view : singInViewController , model : model ) singInViewController.inject(presenter : presenter ) window = UIWindow(frame : UIScreen.main.bounds ) window?.rootViewController = navigationController window?.makeKeyAndVisible() return true } } この方法で実装すると、結局ローディングの時間をそのまま待つ必要があります。それを解決するべく、並列処理によってバックグラウンドでのUnityのロードを検討しました。しかしその方法では、スプラッシュ画面が表示されたあと、Unityをロードする真っ暗な画面が表示されました。 結果的に、起動時間そのものは変わらないものの、不要な画面遷移を減らしスプラッシュ画面1つにまとめるという方法に落ち着きました。 UnityのWindowからViewだけを利用する UaaLの仕組みとしては、ネイティブ(ホスト)側のiOSアプリのUIWindowとは別に、Unity側でUIWindowを生成しています。ホスト側からUnity側のWindowに切り替える際には、前述したUnityFrameworkの showUnityWindow という関数を呼び出す必要があります。この関数はアニメーションもなく、単にUnityのUIWindowをアプリの最前面に表示する仕様となっています。 一方で今回のアプリでは、NavigationControllerによるプッシュ遷移に組み込む必要がありました。そのため、 Unity側のWindowからViewだけを呼び出し、アプリの画面を表示しているViewControllerにaddSubViewする という方法を取りました。 UnityのWindowのViewにアクセスできるようプロパティを実装しました。先ほどの、 Unity.swift から抜粋しています。 var view : UIView { unityFramework.appController() ! .rootView ! } ホスト側ViewController(HostViewController)へのaddSubViewと、そのsubViewを背面へ移動します。 import UIKit class HostViewController : UIViewController { // UnityのViewの読み込み private let unityView = Unity.shared.view private var presenter : HostPresenterInput! func inject (presenter : HostPresenterInput ) { self .presenter = presenter } override func viewDidLoad () { super .viewDidLoad() // addSubView view.addSubview(unityView) // 追加したsubViewのサイズをViewControllerのViewのサイズに合わせる unityView.frame = view.bounds // 追加したsubViewを背面へ(addSubViewは最前面に追加するため、ViewControllerのViewの後ろに設定する必要がある) view.sendSubviewToBack(unityView) } ... } 実際の画面は画像のようになり、アバターと背景からなるUnityの画面の前に、ネイティブ側で実装したボタンやリストなど(赤枠で囲った部分)を配置しています。 Unityを一時停止する 背景 でも説明した通り、今回のバーチャル試着アプリはリアルタイムシミュレーションで、モバイルアプリとしては比較的重い処理をUnityで実行しています。そのためUnityの負荷で、ネイティブアプリのアニメーションやWebViewのスクロールがカクつく事象が発生していました。少しでもUnityからの影響を抑えられるよう、ネイティブアプリのアニメーションがある場合には、それが終わるまでUnityをpauseしました。またUnityが表示されない、試着画面以外の画面に遷移する際にもUnityのpauseを実行しました。 CollectionViewCellのselect時のアニメーション 今回のアプリには、選択されたアイテムのセルの幅が大きくなり、画面中央部に移動するアニメーションがありました。 performBatchUpdates(_:completion:) を利用して、アニメーションが発生する前にUnityをpause、アニメーションが完了後Unityのpauseを解除しました。 Unity.shared.pause( true ) collectionView.performBatchUpdates { collectionView.collectionViewLayout.invalidateLayout() collectionView.scrollToItem(at : indexPath , at : .centeredHorizontally, animated : true ) } completion : { _ in Unity.shared.pause( false ) } ドロワーメニューからWebViewを開いた場合 基本的にはバーチャル試着の画面以外に遷移する際にはUnityをpauseし、試着画面のViewControllerのviewWillAppearでUnityのpauseを解除するように実装していました。しかし、ここで問題となったのは以下のドロワーメニューです。 画像のようにUIModalPresentationStyleが .fullScreen ではない場合、そのモーダルを閉じても試着画面のviewWillAppearは呼ばれません。そのため、モーダルを開く際にUnityのpauseを、そしてモーダルのviewDidDisappearでUnityのpauseの解除を実装しなければなりません。 このドロワーメニューからは、利用規約やプライバシーポリシーなどをWebViewで開くことができます。利用規約などの項目をタップしてWebViewを開く際には、ドロワーメニューの上ではなく、ドロワーメニューを一旦閉じた後に試着画面の上にモーダルとして表示します。これは、Gmailなどのメジャーなアプリの挙動を参考にさせていただきました。 この場合、ドロワーメニューを閉じて試着画面を表示した時点でUnityのpauseが解除されてしまいます。そうすると、WebViewのスクロールがカクついてしまいます。そのため、ドロワーメニューのdismissのコールバックで再度Unityをpauseしました。さらにカスタム実装したWebViewのViewControllerが閉じた時点でUnityのpauseを解除できるようにしました。 ドロワーメニューからWebViewへの切り替えを行う部分は次の通りです。 guard let presentingViewController = presentingViewController, let url = URL(string : termsUrl ) else { return } dismiss(animated : true ) { // 試着画面で解除されるUnityを再度pauseする Unity.shared.pause( true ) } // このpresentWebViewのメソッドの詳細は割愛 presentWebView(navigationBarTitle : "利用規約" , URL : url , presentingOn : presentingViewController ) { // WebViewを閉じる際に呼ばれる処理を渡す Unity.shared.pause( false ) } カスタムWebViewController内でのコールバックの部分は次の通りです。 ... class WebViewController : UIViewController , WKNavigationDelegate { // MARK : - Properties // このプロパティに先程の処理が渡される private var onCloseHandler : (() -> Void ) ? ... // WebViewが閉じられた際にUnityのpauseを解除する override func viewDidDisappear (_ animated : Bool ) { super .viewDidDisappear(animated) onCloseHandler?() } ... } 試着画面から画面遷移するたびにUnityをpauseし、試着画面に戻ってきた際にUnityのpauseを毎回解除する必要があり、少し手間にはなります。しかしUnityの負荷が高い時にはとても効果的です。 Unityとのやりとりを一方向にする UaaLではネイティブとUnityがそれぞれやりとりを行えるプラグインがあります。最初に挙げたサンプルプロジェクトでは、それぞれネイティブ→Unity、Unity→ネイティブのプラグインを利用することでデータのやりとりを行なっています。具体的には色情報のやりとりを行なっています。 双方向のやりとりを行うことはできますが、一方向に絞った方がデータの流れは理解しやすくなります。今回は可能な限り一方向に絞ることを目指して工夫しました。ネイティブ側から呼び出すメソッドとして、 MethodFirst と MethodSecond という2つのメソッドがあるとします。また MethodFirst が完了した後に、 MethodSecond を実行する必要があります。これをネイティブからUnityという一方向で実現するには、仮に2つのメソッドが同時に呼び出されても、Unity内で MethodFirst から MethodSecond という順に処理させる必要があります。そのためUnity内でEventとCoroutineを活用し、これを実現しました。ただし、Unity内のデータをネイティブ側に伝えたい場合や、Unityの描画が完了したタイミングなどのイベントをネイティブ側に渡したい場合は一方向での実装はできません。 SwiftによるネイティブからUnityの呼び出しは、簡易な UnitySendMessage を用いて実装しました。複雑なデータであっても、Unity側でStringをJSONに変換することでデータのやりとりが可能です。 func sendData (data : [ String ] ) { // 引数はStringなので、それに合わせる let dataText = """ {\"data0\": \( data[ 0 ] ) , \"data1\": \( data[ 1 ] ) , \"data2\": \( data[ 2 ] ) , \"data3\": \( data[ 3 ] ) , \"data4\": \( data[ 4 ] ) , \"data5\": \( data[ 5 ] ) } """ // UnityFrameworkのメソッドを呼び出し、 // Unityのシーン内のオブジェクトの名前、実行したい関数の名前、引数を渡す Unity.shared .sendMessageToUnity(objectName : "ObjectNameInUnity" , functionName : "MethodFirst" , argument : dataText ) } データを受け取って、Unity内でJSONに変換します。また、EventとCoroutineで MethodFirst と MethodSecond を順に処理することを実現しました。 using UnityEngine; private Data data; private bool hasSet = false ; void Awake() { data.SettingComplete += OnSettingComplete; } void OnSettingComplete() { hasSet = true ; } public void MethodFirst(strig inputDataString) { // ネイティブ側のstringをJSONに変換 var inputData = JsonUtility.FromJson<Data>(dataString); hasSet = false ; data.Set(inputData); // ここでSetされると、OnSettingCompleteが実行される ... } public IEnumerator MethodSecond() { while ( ! hasSet) { yield return new WaitForSeconds( .1f ); } // MethodFirstが完了するとwhileを抜けて処理を続行 ... } Build後の設定を自動化する 公式のサンプルプロジェクトでも 説明されています が、Unityをネイティブアプリから読み込めるよう、Build後のフォルダのターゲットにUnityFrameworkを指定する必要があります。開発において何度も発生する作業は自動化すると効率が上がるので、以下のようにスクリプトを作成し、ビルド後に処理が走るように設定しました。このファイルは、Unityのプロジェクト内のAssets/Editorに配置します。 using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEngine; using UnityEditor.iOS.Xcode; class PostBuildProcess : IPostprocessBuildWithReport { public int callbackOrder { get { return 0 ; } } public void OnPostprocessBuild(BuildReport report) { var outputPath = report.summary.outputPath; EditProject(outputPath); } static void EditProject( string outputPath) { var projectPath = PBXProject.GetPBXProjectPath( outputPath ); var pbx = new PBXProject(); pbx.ReadFromFile(projectPath); // UnityFrameworkを取得 var guidTarget = pbx.GetUnityFrameworkTargetGuid(); // DataフォルダのターゲットにUnityFrameworkを追加 var guidData = pbx.FindFileGuidByProjectPath( "Data" ); var guidResPhase = pbx.GetResourcesBuildPhaseByTarget(guidTarget); pbx.AddFileToBuildSection(guidTarget, guidResPhase, guidData); pbx.WriteToFile(projectPath); } } まとめ 本記事では、Unity as a Libraryを用いたiOSアプリ開発における、実用的な工夫についてご紹介いたしました。Unityを組み込むに当たって、ネイティブアプリとシームレスに馴染むように、そしてUnityの負荷がネイティブアプリに与える影響を最小限にすることを目指しました。皆様の参考になれば幸いです。 ZOZO NEXTでは、先端技術を取り入れ、デザイナーと一緒になってUXを最大化しながらプロダクト開発を行なっております。世界からヤバいと言われるプロダクトづくりを目指して、絶賛仲間を募集中です! https://hrmos.co/pages/zozo/jobs/0000143 hrmos.co hrmos.co
アバター
こんにちは。技術戦略部の廣瀬です。 弊社ではサービスの一部にSQL Serverを使用しています。SQL Serverの各バージョンにはMicrosoftのサポート期間が設定されています。直近ではSQL Server 2012のサポートが、2022年7月12日に終了します。サポートが切れる前にSQL Serverのバージョンを上げる必要がありますが、既存環境で実行中のSQLがバージョンアップ後も正常に動作するか事前検証が必要です。 本記事では、このクエリ互換性に関する検証精度を向上させた事例を紹介します。 クエリ互換性の検証方法 SQL Serverをバージョンアップする際のクエリ互換性を検証するための補助ツールとして、 Data Migration Assistant(以下、DMAと呼ぶ) というツールが提供されています。このツールを使うと、例えば以下のようなクエリ互換性に関するアドバイスを確認できます。 移行元のバージョン及び互換性レベルから、移行先のバージョンで各互換性レベルを選択した場合のクエリ互換性に関する問題を自動で検出してくれます。画像の例では互換性レベル110のSQL Server 2012から、SQL Server 2019にバージョンアップする場合の分析結果です。互換性レベル110と120では4項目、130以上だと5項目の指摘事項があると分かります。このように、バージョンだけでなく指定する互換性レベルによっても指摘事項数が変わってきます。 「Unqualified Join(s) detected」という指摘では、明示的に「JOIN」を指定しないと稀にスロークエリ化することがあるという問題が説明されています。このように、バージョンアップの際に対応が必要な項目を自動で検出してくれるため便利なツールですが、課題も存在します。 DMAの課題 DMAでは、ストアドプロシージャや関数など、SQL Serverが持っているオブジェクトは互換性の有無を検証してくれます。ですが、アプリケーション側に記述されているSQLについては検証してくれません。アプリケーション側で記述されているクエリは、拡張イベントで「sql_batch_completed」を取得して結果ファイルをDMAに入力することで互換性の検証が可能です。しかし、プロダクション環境で実行されている全てのクエリを拡張イベントで収集することは負荷的なオーバーヘッドの面で許容できない場面があるかと思います。そのため、アプリケーション側で記述されているクエリの互換性をDMAを使ってより安全に検証するためには、別の方法が必要となります。以降では、私たちがとった手段をご紹介します。 アプリケーション側に記述されたクエリ互換性をDMAで検証する方法 DMAでは、アセスメントを開始する前にアドホッククエリのデータを入力できる箇所があります。 「Learn more」のリンク先の記事 では、ファイルの生成方法が説明されています。 リンク先の記事によると、Visual Studio Codeの拡張機能である「Data Access Migration Toolkit」を使用します。この機能を使うと、DMAにインプットするjsonファイルを生成できます。「Data Access Migration Toolkit」がサポートしているファイル形式は以下の通りです。 Java C# XML JSON Properties SQL files Plain text / Unstructured 今回調査したいアプリケーションのファイル形式はサポート対象外だったため、プログラムファイルを直接入力に使うことはできません。したがって、以下の手順をとることにしました。 実際に実行されたクエリテキストを収集 収集したクエリテキストを「Data Access Migration Toolkit」に入力 生成されたjsonファイルをDMAに入力して互換性を検証 以降で順番に説明します。 1. 実際に実行されたクエリテキストを収集 拡張イベントは前述の通りオーバーヘッド増加の懸念が理由で使用できません。代りに、DMVの一種である「sys.dm_exec_query_stas」を使用します。このDMVは実行されたクエリのパフォーマンス統計を保持しているDMVなので、アプリケーション側に記述されているクエリも収集が可能です。まず、収集用のテーブルを作成します。 select max (dbid) as dbid ,query_hash , cast ( max (qt.text) as nvarchar( max )) as query_text , max (execution_count) as max_execution_count , 1 as updated_count ,getdate() as created_at ,getdate() as updated_at into dm_exec_query_stats_dump from sys.dm_exec_query_stats qs outer apply sys.dm_exec_sql_text(qs.plan_handle) as qt where qt.text is not null and objectid is null --procedure / function / trigger等を除外 and qt.text not like ' %api_cursor% ' group by query_hash 今回の調査で「何回実行されたか」はそこまで重要な情報ではありません。1回でも実行されたクエリは互換性をDMAで検証すべきです。そのため、テーブルのサイズ増大を抑制するために「query_hash」でgroup byを行います。また、ストアドプロシージャなどのオブジェクトは今回取得する必要はないため、objectidがnullなデータだけを収集対象とします。あとは以下のクエリをSQL Serverのエージェントジョブで実行して、1分間ごとにキャッシュの情報をupsertしていきます。 set nocount on set lock_timeout 1000 set transaction isolation level read uncommitted while ( 1 = 1 ) begin merge dm_exec_query_stats_dump as target using ( select max (dbid) as dbid ,query_hash , cast ( max (qt.text) as nvarchar( max )) as query_text , max (execution_count) as max_execution_count , 1 as updated_count from sys.dm_exec_query_stats qs outer apply sys.dm_exec_sql_text(qs.plan_handle) as qt where qt.text is not null and objectid is null --procedure / function / trigger等を除外 and qt.text not like ' %api_cursor% ' group by query_hash ) as source on target.query_hash = source.query_hash when matched then update set max_execution_count = ( case when source.max_execution_count > target.max_execution_count then source.max_execution_count else target.max_execution_count end ) ,updated_count = target.updated_count + 1 ,updated_at = getdate() when not matched then insert (dbid, query_hash, query_text, max_execution_count, updated_count, created_at, updated_at) values (source.dbid, source.query_hash, source.query_text, source.max_execution_count, 1 , getdate(), getdate()) option (maxdop 1 ); waitfor delay ' 00:01:00 ' if (getdate() >= ' 2022/04/01 ' ) return end 収集期間は数日から、最長でも1か月間収集すれば月次で実行されるレアなクエリも収集できるかと思います。収集後のテーブルの中身はこのようになっています。 弊社の環境では、1DBあたり5000種類ほどのクエリを収集できたケースもありました。 2. 収集したクエリテキストを「Data Access Migration Toolkit」に入力 続いて、収集したデータを「Data Access Migration Toolkit」に入力し、DMAが解釈可能なjson形式に変換します。サポートファイルとして「SQL files」とあったため、収集したSQLを1まとめにしたファイルを作成して入力してみました。ファイルの中身は以下のようになっていました。 (@P1 int)select * from table_1 where ... (@P1 int,@P2 datetime,@P3 int,@P4 int)select col_1, col_2 from table_2 where ... ... (@P1 int)select col_n from table_n where ... jsonファイルは正常に出力されましたが、中身は以下のようになっていました。 { " SqlDialect " : " t-sql " , " Workspaces " : [ { " Path " : " SOME_PATH\DMA\\sql " , " Issues " : [ { " File " : " file:///SOME_PATH/DMA/sql/input.sql " } ] } ] } この形式では正しいjsonファイルを生成できないようです。したがって、別のサポート対象のファイル形式であるXMLに変換してみました。まずはシンプルにタグでクエリ全体を囲ってみました。 <xml> (@P1 int)select * from table_1 where ... (@P1 int,@P2 datetime,@P3 int,@P4 int)select col_1, col_2 from table_2 where ... ... (@P1 int)select col_n from table_n where ... </xml> このxmlファイルを入力したところ、where句などに不等号が入っていることでxmlのパースでエラーとなり、上手くいきませんでした。そこで、各ステートメントをCDATAセクションで囲うことにしました。これにより「]]>」という文字列以外は通常の文字として解釈してくれます。ファイルは以下のようになります。 <xml> <![ CDATA [ (@1 int)SELECT * form some_table_1 where ... ]]> <![ CDATA [ (@P1 int)SELECT * form some_table_2 where ... ]]> </xml> このxmlファイルを入力したころ、以下のようなjsonファイルが生成されました。 { " SqlDialect ": " t-sql ", " Workspaces ": [ { " Path ": " c: \\ SOME_PATH \\ DMA \\ xml \\ ng ", " Issues ": [ { " File ": " file://SOME_PATH/DMA/xml/ng/test.xml ", " Queries ": [ { " Text ": " \r\n (@P1 int)SELECT * from **** \r\n ", " LineNum ": 2 , " ColNum ": 10 , " ConfidenceLevel ": 2 } , { " Text ": " \r\n (@P1 int)SELECT * from *** \r\n ", " LineNum ": 5 , " ColNum ": 10 , " ConfidenceLevel ": 2 } ] } ] } ] } DMAへの入力用のjsonとして良さそうです。しかし、こちらのファイルをDMAに入力したところ、以下のエラーが出てしまいました。 構文エラーが出ています。DMVで取得したクエリテキストは、そのままだとSQL Serverの構文解釈時にエラーとなってしまうことが分かりました。例えば、 (@P1 int)SELECT... は declare @p1 int;select... に書き換える必要があります。そのため、「sys.dm_exec_query_stats」をもとに取得したクエリテキストを、構文解析が可能になるように変換するスクリプトを弊社エンジニア( @_itito_ )が実装してくれました。 こちら で公開されております。動作確認できている入力ファイルの形式は文字コードがUTF-16LE、改行コードがLFの組み合わせとなっております。このスクリプトに「sys.dm_exec_query_stats」をもとに取得したクエリテキストを入力すると、以下のようなxmlファイルが生成されます。 <xml> <![ CDATA [ declare @1 int;SELECT * FROM table_1 WHERE table_1.col_1 = @1 ]]> <![ CDATA [ declare @P1 int;declare @P2 int;declare @P3 nvarchar(10);declare @P4 datetime; insert into table_2 (col_1,col_2,col_3,col_4) values (@P1,@P2,@P3,@P4) ]]> </xml> このxmlを「Data Access Migration Toolkit」に入力することで、DMAで解析可能なjsonファイルを生成できました。 3. 生成されたjsonファイルをDMAに入力して互換性を検証 あとはDMAに生成したjsonファイルを入力して、互換性を検証すればOKです。「sys.dm_exec_query_stas」は実行されたクエリしかキャッシュしません。そのためアプリケーション側に記述があっても、データ収集期間に一度も実行されないクエリはjsonファイルに記載されません。そのためアプリケーション側に記述された全てのクエリの互換性レベルを確認できるわけではありません。ですが、実質未使用のクエリの互換性をチェックする必要はないと思います。そのため今回の方法は互換性を検証できるクエリ数を増やせるという点で有用と考えております。 あらためて今回紹介した手順をまとめると、以下の通りです。 一定期間「sys.dm_exec_query_stas」を使用して実行されたクエリテキストを収集 自作ツールを使用して「Data Access Migration Toolkit」で解析可能なxmlファイルを生成 xmlファイルをVisual Studio Codeの拡張機能「Data Access Migration Toolkit」を使用してjson形式に変換 jsonファイルをDMAに入力してクエリ互換性を検証 まとめ 本記事では、DMAを用いてアプリケーション側に記述されているクエリの互換性を検証する方法について紹介しました。移行前の検証に役立てていただけたら幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは。計測プラットフォーム開発本部SREブロックの西郷です。普段はZOZOSUITやZOZOMAT、ZOZOGLASSなどの計測技術に関わるシステムの開発、運用に携わっています。先日私達のチームでは、シングルクラスタ・マルチテナントを前提として構築したEKSクラスタにZOZOMATシステムを移行しました。本記事では移行ステップや作業時に工夫した点について紹介したいと思います。 目次 はじめに 目次 移行の概要とそのアプローチ 前提 要件 移行方針 各移行ステップとその詳細 STEP1:移行先CFnスタックへのAWSリソース作成、インポート STEP2:移行先へのデータマイグレーション S3 注意点 RDS 注意点 STEP3:移行先のクラスタにkubernetesリソースを追加 STEP4:EKSクラスタの切り替え external-dnsのdomain-filterをマルチドメインに変更する 既存EKSクラスタのexternal-dnsのpolicyをcreate-onlyに変更する ingressのhost指定を有効にし、 Route53レコードのALBの値を変更する 振り返り 終わりに 参照記事 移行の概要とそのアプローチ マルチテナントのEKSクラスタに移行した理由は、EKSクラスタが多く存在することで管理コストの高さが目立ってきたためです。 というのも、EKS導入当初はまだ手探りな部分が多かったため、各サービスごとにEKSクラスタを作成していました。しかしながら当初の想定以上にサービスが増えてしまったため、今回単一のクラスタで複数のサービスを運用するマルチテナントのEKSクラスタへ移行するに至りました。 この辺については先だって公開したこちらの記事 (1) で詳しく書かれているので、是非併せてご覧ください。 まずは今回の前提と要件を整理します。 前提 AWSリソースはCloudFormation(以降CFnと記載します)で管理している 要件 無停止で切り替えること ロールバックが容易であること コンピューティングタイプをEC2からFargateに変える コンピューティングタイプを変える点について少し補足すると、マルチテナント化に伴うマシンリソースの競合および権限の分離や、運用負荷の軽減を目的としたEC2からの脱却が背景にあります。 移行方針 以上を踏まえ、EKSクラスタ間の切り替えについてはマルチテナントのEKSクラスタにZOZOMATリソースを追加した上で、Route 53に設定済みのALBの値を変更しました。図で示すと以下のようになります。 また、ZOZOMATで利用しているAWSリソースについても一部を除いて新しいCFnスタックに新規作成し、管理するCFnスタックを切り替えることにしました。理由は日々の運用作業と干渉せず移行作業をしやすかったことや、リソースの命名則を変更したかったためです。各AWSリソースのスタック間での移行方針はリソースごとに異なるため、わかりやすくまとめると以下のようになります。 AWSサービス 移行方針 特記事項 EKSクラスタ 既存のマルチテナントのEKSクラスタを利用 - FargateProfile IAMRole等 新規作成 - CodePipeline CodeDeploy CodeBuild 新規作成 - ECR 新規作成 作業時点でCFnのResource Importが未対応だったため。1世代分のイメージのみ移行 Redis 新規作成 - DynamoDB Resource Import - RDS Snapshotを元に新規作成&データ同期 - S3 新規作成&データ同期 - そもそものZOZOMATの構成については以前の記事 (2) に詳しく書かれているので、興味がある方は是非そちらをご覧ください。 まとめると、今回の移行のポイントは以下のようになります。 ZOZOMATが利用するEKSクラスタをマルチテナントのEKSクラスタに切り替える 付随して、ZOZOMATが利用するAWSリソースも新しいCFnスタック管理に切り替える 各移行ステップとその詳細 今回の移行作業は大きく4段階に分けて行いました。 STEP1:移行先CFnスタックへのAWSリソース作成、インポート STEP2:移行先へのデータマイグレーション STEP3:移行先のEKSクラスタにkubernetesリソースを追加 STEP4:EKSクラスタの切り替え ここから先は各ステップについて、工夫した点や注意点を交えて説明いたします。 STEP1:移行先CFnスタックへのAWSリソース作成、インポート まずは前述のとおり、新しいCFnスタックに必要なAWSリソースを作成していきます。 ECRは、この作業を行なった2021年5月時点ではCFnの Resource Import に未対応でした。そのため、既存のECRを一度削除して再作成し、あらかじめ取得しておいた最新1世代分のイメージをpushすることにしました。現在はResource Importに対応している (3) ようです。 DynamoDBは命名則の修正が必要なく、Resource Importも対応していたため、既存のテーブルをそのまま新しいスタックにインポートしました。 移行の流れをわかりやすくするため、現時点の状態を図で示します。 STEP2:移行先へのデータマイグレーション このステップでデータのマイグレーションが発生するデータソースはRDSとS3です。まずはZOZOMAT環境に存在するデータソースとその中に含まれるデータについて簡単に説明します。 DynamoDB 計測データ IDやメタデータを含むsession 足のサイズや3Dデータを含むscan(左、右) RDS 計測データ DynamoDBの計測データ(session、左右のscan)を統合し、1レコードとして管理 S3 計測データ RDSと同じ形式のデータをJSON形式で保存 ログデータ ALBやCloudFront等のログ ネイティブアプリのデバッグログ 計測データは1次データソースとしてDynamoDBに投入された後、以下のようにDynamoDBStreamと後続のLambdaを通してRDSとS3にも保存されます。 この構成について、RDSにデータを保存している理由を説明します。ZOZOMATでは1つのsessionに対して左右の足それぞれの計測値と3Dデータを持つため、計測結果を参照する際にはこれらのデータの結合が必要になります。DynamoDBには結合するためのAPIがないため、アプリケーションで結合処理を行うとsessionで1回、scanで左足、右足の2回、合計で3回クエリの発行が必要になります。ZOZOMATではこれらのデータを1つのレコードに結合した状態でRDSに保存しているのですが、これによってアプリケーションからのクエリ発行は1回で済み、通信コストを抑えることができます。 こういった背景もありZOZOMATではCQRSを採用し、更新系(Command)をDynamoDBに、参照系(Query)をRDSに分離しています。より詳しくはこちらの記事 (4) を参照ください。 S3についてはデータ連携の観点からです。分析やサイズ推奨のモジュール開発に役立てるため、計測結果のデータを関連チームに連携する必要がありました。そのため、S3にJSON形式で保存しています。 さて、STEP1で説明した通りDynamoDBはCFnのResource Importによって既存のテーブルをそのまま利用するため、データのマイグレーションは発生しませんでした。一方、RDSとS3は新規作成するため、過去のデータはもちろんのこと、継続的に同期し続ける必要がありました。ここからはどのようにデータ同期を実現したのかについて説明していきます。 S3 S3に保管されているデータはオブジェクトの最終更新日時を保持したまま同期したかったこと、工数を割かずに継続的なデータ同期を実現する仕組みが必要だったこと、リアルタイムでなくとも同期されていればよかったことから、S3の レプリケーション機能 を利用しました。これはS3間でオブジェクトを自動で非同期的にコピーしてくれる仕組みです。 最終更新日時を保持したかった背景としては、普段の監視業務やユーザからの問い合わせ対応等において、それらの情報が重要だったことが挙げられます。 s3 sync や s3 cp コマンド等では最終更新日時が変わってしまいますが、レプリケーション機能であればオブジェクトのメタデータを保持したまま同期が可能です。 さて、デフォルトではレプリケーション対象のオブジェクトはレプリケーション有効化後にputされたオブジェクトのみです。しかしながら、移行完了後は移行前に利用していたS3を削除する予定だったため、今回は有効化前にputされたオブジェクトの同期も必要でした。解決策としてはサポートケースを起票することで既存オブジェクトのレプリケーション有効化が可能だったため、そちらで対応しました。申請時に必要な情報は下記AWSのドキュメントを御覧ください。 既存のオブジェクトのレプリケーション 注意点 レプリケーションを利用する際、特に注意が必要だと感じた点は次のとおりです。 既存オブジェクトのレプリケーション有効化の際、サポートケース起票から機能の有効化が完了するまでに3週間程かかる 利用者は有効化されるタイミングを指定できない 今回はスケジュールに猶予があったため問題にはならなかったが、考慮した上でのスケジュール設定が必要 オブジェクトのバージョンIDを指定せず削除した際、レプリケーション設定が最新バージョンでない場合は削除マーカーをレプリケートする 最新バージョンの場合、削除マーカーはレプリケートされない オブジェクトバージョンを指定して削除した場合は削除マーカーをレプリケートしない 既存オブジェクトのレプリケーションルールはCFnでは設定できない それ以外にもレプリケーション機能の利用についてはいくつか制約があるため、利用の際にはAWSのドキュメント (5) を確認することをおすすめします。 RDS 次にRDSのデータの同期についてです。こちらはRDSをSnapshotから復元し、その後DynamoDBStreamに既存と同じLambdaをもう1つ紐付けることでデータ同期を実現しました。 ただし、Snapshot作成から新しいRDSがRunningになるまでの間、新たに作成されたデータをどのように同期するか、という点は一工夫必要でした。というのもDynamoDBStreamへ紐づけているLambdaの開始位置がLATESTになっており、最新のレコードから読み込む設定になっていたためです。 これについてはDynamoDBStreamへ紐づけているLambdaの開始位置をLATESTからTRIM_HORIZONに変更することで解消しました。図にすると以下のとおりです。 TRIM_HORIZONの場合は、ストリームに保存されている24時間以内のレコードを古いものから順に読みとる挙動となります。全ての項目を処理し追えるまで実行され、その後は新しいレコード分に対して処理が実行されます。この時すでにRDSに含まれるデータも処理対象になる可能性はあるのですが、Lambdaで行っている処理は冪等なため問題ないと判断しました。 CFnテンプレート上は以下のように指定します。 LambdaEventSourceMappingDynamoDBStreamSessions : Type : 'AWS::Lambda::EventSourceMapping' Properties : EventSourceArn : !GetAtt DynamoDBTableSessions.StreamArn FunctionName : !GetAtt LambdaFunctionDynamoDBStreamSessions.Arn StartingPosition : 'TRIM_HORIZON' #ここをLATESTから変更 注意点 DynamoDBStreamレコードが保持されるのは24時間のため、DynamoDBStreamにLambdaを紐付けるまでの時間がそれ以上になる場合は適さない DynamoDBStreamに3つ以上のLambdaを紐付けるとリクエスト失敗の可能性が高くなる この場合はファンアウトパターンが推奨されている ファンアウトパターンについてはAWSのブログ (6) で詳しく説明されているので、興味のある方は是非ご覧ください。 ここまででAWSリソースの対応は完了です。 STEP3:移行先のクラスタにkubernetesリソースを追加 まずはkubernetesのマニフェストファイルに対して、今回の移行に際して必要な以下の修正を加えていきます。これらの作業の背景や詳細はこちら (1) に詳しく書かれているので、本記事では割愛させていただきます。 ZOZOMAT専用のEKSクラスタ独自で管理していたmetrics-serverやexternal-dns等の廃止 ZOZOMAT用ネームスペースの作成及び指定 Fargate化に伴うIRSA対応 上記の修正をし、マルチテナントのEKSクラスタにkubernetesリソースをデプロイするのですが、ingressのspec.rules.host部分が指定されているとexternal-dnsによってRoute53のAレコードの値が上書きされてしまいます。そのため、以下のようにコメントアウトした状態でデプロイすることにより回避しました。 apiVersion : networking.k8s.io/v1beta1 kind : Ingress metadata : name : mat-api-ingress # --------- omit spec : rules : - #host: mat-api.zozo.com http : paths : - path : /healthz backend : serviceName : api-server-service servicePort : 8000 # --------- omit この対応を含め、本ステップによりzozomatネームスペースにFargateで稼働するpod群が作成されます。 STEP4:EKSクラスタの切り替え 当初はingressのkubernetesマニフェストでhost指定を有効化するだけの作業を想定していたのですが、最終的に行なった手順は以下になります。 external-dnsのdomain-filterをマルチドメインに変更する 既存EKSクラスタのexternal-dnsのpolicyをcreate-onlyに変更する ingressのspec.rules.hostのコメントアウトを解除し、有効にする 以降は各作業が必要になった背景を踏まえながら、詳しく見ていきたいと思います。 external-dnsのdomain-filterをマルチドメインに変更する ZOZOGLASSとZOZOMATは利用するHosted Zoneが異なります。しかしながらマルチテナントのEKSクラスタに存在するexternal-dnsのdomain-filterは、ZOZOGLASSが利用するHosted Zoneのみ指定していました。ZOZOMATが利用するHosted Zoneは指定されていないため、そのままだとingressのspec.rules.hostを有効にしてもAレコードに設定されているALBの値は変わりません。そのため、予めdomain-filterを複数指定することで解消しました。 apiVersion : apps/v1 kind : Deployment metadata : name : external-dns spec : template : spec : containers : - name : external-dns args : - --source=service - --source=ingress - --domain-filter=glass-domain.zozo.com - --domain-filter=mat-domain.zozo.com # ここを追加 - --provider=aws - --policy=upsert-only - --aws-zone-type=public - --registry=txt - --txt-owner-id=my-hostedzone-identifier 既存EKSクラスタのexternal-dnsのpolicyをcreate-onlyに変更する 既存のEKSクラスタとマルチテナントのEKSクラスタの両方に、それぞれexternal-dnsが存在します。external-dnsのpolicyにはsync、upsert-only、create-onlyがあり、それぞれ変更を検知すると次のように動作します。 sync レコードの作成、変更、削除全てを行う upsert-only レコードの作成、変更のみ行う create-only レコードの作成のみ行う 私達の環境では誤ってレコードを削除してしまう事態を防ぐため、両external-dnsのpolicyをupsert-onlyにしていました。そのため、片方でingressのspec.rules.hostを有効化しRoute53のレコードの値を上書きすると、もう片方が変更を検知し値を更に上書きする事態が発生してしまいます。 これについては既存のEKSクラスタのexternal-dnsで、予めpolicyをcreate-onlyに変更することで解消しました。 apiVersion : apps/v1 kind : Deployment metadata : name : external-dns spec : template : spec : containers : - name : external-dns args : - --source=service - --source=ingress - --domain-filter=glass-domain.zozo.com - --domain-filter=mat-domain.zozo.com - --provider=aws - --policy=create-only #ここを変更 - --aws-zone-type=public - --registry=txt - --txt-owner-id=my-hostedzone-identifier ingressのhost指定を有効にし、 Route53レコードのALBの値を変更する 最後にingressのkubernetesマニフェストでspec.rules.hostのコメントアウトを解除し、デプロイします。これによってRoute53のAレコードの値が移行元のALBのDNS名から移行先のALBのDNS名に切り替わりました。 apiVersion : networking.k8s.io/v1beta1 kind : Ingress metadata : name : mat-api-ingress # --------- omit spec : rules : - host : mat-domain.zozo.com #コメントアウトを外す http : paths : - path : /healthz backend : serviceName : api-server-service servicePort : 8000 # --------- omit 以上の作業を踏まえ、ZOZOMATが利用するEKSクラスタをマルチテナントのEKSクラスタに切り替えることができました。 振り返り 今回のような稼働中サービスのシステム移行は個人的に初めてだったので、当初は完遂出来るか不安な部分もありました。特にデータ同期というとスクリプトを書いて定期実行するイメージでしたが、S3レプリケーションやDynamoDBStreamといったAWSの仕組みや、それらを利用したZOZOMATのデータ投入の仕組みをフル活用して大きなトラブルなく終えることができたのは良い経験になりました。また、事あるごとに躓いていましたが、リーダーとのオフィスアワーやチームメンバーとのペアプロ等、周りの力を借りやすかったチーム環境も非常に有り難かったです。 終わりに 計測プラットフォーム開発本部では今後も ZOZOSUIT 2 等の新しいサービスのローンチを予定しています。更にスピード感を持った開発が求められますが、このような課題に対して楽しんで取り組み、サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co また、カジュアル面談も随時実施中ですのでお気軽にご応募ください。 hrmos.co 参照記事 1: EKSのマルチテナント化を踏まえたZOZOGLASSのシステム設計 2: ZOZOMATにおけるEKSやgRPCを用いたシステム構成と課題解決 3: AWS::ECR::Repository support for importing into existing stack 4: ZOZOSUITからZOZOMATへ - CQRSによる解決アプローチ 5: レプリケーションの要件 6: Amazon DynamoDB ストリームを使用して、順序付けされたデータをアプリケーション間でレプリケーションする方法
アバター
こんにちは、MA基盤チームの田島です。私達のチームでは複数のワークフローエンジンを利用し、メールやLINEなどへの配信を含むバッチ処理を行っていました。今回それらのワークフローエンジンをすべてDigdagに統一しました。そして実行環境としてGKEのAutopilot環境を選択したことにより、柔軟にスケールするバッチ処理基盤を実現しましたのでそれについて紹介します。 また、その中で得られた運用Tipsについても合わせて紹介します。 目次 目次 Digdag on GKE Autopilotの構成 Digdagの4つの役割 Worker Scheduler Web API Kubernetes Command Executor Workerでのタスク実行の問題 Command Executor Kubernetes Command Executorの利用 GKE Autopilot環境でのKubernetes Command Executorの利用 Kubernetes Command Executorの使い方 Workerのオートスケーリング Custom Metricsを利用したオートスケーリング スケーリングの設定 スケールイン時の問題 PrometheusAdapterの利用の注意点 運用上の工夫と注意点 ノードの立ち上げの待ち時間が発生する タスクはすべて冪等にする たまにログが出ない 終了したPodが消えない 今後の展望 まとめ Digdag on GKE Autopilotの構成 今回構築したDigdag on GKE Autopilot環境の最終構成は次のとおりです。 GKE Standard環境における、Digdagの構築はすでに弊社の別チームで行われており、スケーリング部分以外はほぼそれを踏襲する形で構築しました。以下は当時の発表資料です。 参考にした構成から一部拡張した部分について、またAutopilot環境だからこその利点についてなどを含め、改めて構成を紹介します。 Digdagの4つの役割 Digdagは役割ごとに以下の「Worker」「Scheduler」「Web」「API」のDeploymentを作成し、クラスタを構成しています。 Worker Digdagではワークフローのなかの1つ1つの処理のことをタスクと呼びます。Workerは実際にタスクを実行する役割を担います。DigdagのタスクはPostgreSQL(CloudSQL)に一度キューという形で登録され、Workerは登録されているタスクのうち実行可能なタスクを取得して実行します。 WorkerのDeploymentのマニフェストは次のとおりです。Digdag起動時に disable-scheduler を指定することで、次で紹介するSchedulerの役割を除外しています。Kubernetes関連のオプションに関しては後ほど紹介します。 apiVersion : apps/v1 kind : Deployment metadata : labels : run : digdag-worker name : digdag-worker namespace : digdag spec : progressDeadlineSeconds : 600 revisionHistoryLimit : 10 selector : matchLabels : run : digdag-worker strategy : rollingUpdate : maxSurge : 2 maxUnavailable : 0 type : RollingUpdate template : metadata : labels : run : digdag-worker spec : serviceAccountName : digdag dnsPolicy : ClusterFirst restartPolicy : Always schedulerName : default-scheduler securityContext : {} terminationGracePeriodSeconds : 5400 volumes : - name : digdag-config-volume configMap : name : digdag-config containers : - name : digdag-worker image : <YOUR_DIGDAG_IMAGE> imagePullPolicy : Always volumeMounts : - name : digdag-config-volume mountPath : /etc/config command : [ "/bin/bash" ] args : - "-cx" - | digdag server \ --disable-scheduler \ --log-level <LOG_LEVEL> \ --max-task-threads <MAX_TASK_THREADS> \ --config /etc/config/digdag.properties \ -p environment=<ENVIRONMENT> \ -X database.host=<POSTGRES_IP> \ -X database.password=$POSTGRES_PASSWORD \ -X digdag.secret-encryption-key=$SECRET_ENCRYPTION_KEY \ -X archive.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> \ -X log-server.gcs.bucket=<DIGDAG_LOG_BUCKET> \ -X agent.command_executor.type=kubernetes \ -X agent.command_executor.kubernetes.config_storage.in.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> \ -X agent.command_executor.kubernetes.config_storage.out.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> \ -X agent.command_executor.kubernetes.name=<KUBERNETS_CLUSTER_NAME> \ -X agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.master=$KUBERNETS_MASTER \ -X agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.certs_ca_data=`cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt | base64 -w 0` \ -X agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.oauth_token=`cat /var/run/secrets/kubernetes.io/serviceaccount/token` \ -X agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.namespace=digdag \ -X agent.command_executor.kubernetes.config_storage.in.type=gcs \ -X agent.command_executor.kubernetes.config_storage.out.type=gcs \ -X agent.command_executor.kubernetes.config_storage.out.gcs.direct_upload_expiration=<GCS_DIRECT_UPLOAD_EXPIRATION> \ -X executor.task_ttl=<TASK_TTL> resources : requests : cpu : 1000m memory : 2Gi limits : cpu : 1000m memory : 2Gi また、configmapは以下のように定義しています。 configMapGenerator : - name : digdag-config namespace : digdag files : - config/digdag.properties server.bind=0.0.0.0 server.port=8080 database.type=postgresql database.port=5432 database.user=digdag database.database=digdag archive.type=gcs log-server.type=gcs Scheduler Digdagでは、ワークフローごとに実行のスケジューリングを行うことができます。これもまた、PostgreSQLにスケジュールが登録されます。そして、Schedulerは実行時間になったワークフローの実行を開始します。 SchedulerのDeploymentのマニフェストは次のとおりです。Digdag起動時に disable-executor-loop を指定することでWorkerの役割を除外しています。 apiVersion : apps/v1 kind : Deployment metadata : labels : run : digdag-scheduler name : digdag-scheduler namespace : digdag spec : progressDeadlineSeconds : 600 revisionHistoryLimit : 10 selector : matchLabels : run : digdag-scheduler strategy : rollingUpdate : maxSurge : 2 maxUnavailable : 0 type : RollingUpdate template : metadata : labels : run : digdag-scheduler spec : serviceAccountName : digdag dnsPolicy : ClusterFirst restartPolicy : Always schedulerName : default-scheduler securityContext : {} volumes : - name : digdag-config-volume configMap : name : digdag-config containers : - name : digdag-scheduler image : <YOUR_DIGDAG_IMAGE> imagePullPolicy : Always volumeMounts : - name : digdag-config-volume mountPath : /etc/config command : [ "/bin/bash" ] args : - "-cx" - | digdag server \ --disable-local-agent \ --disable-executor-loop \ --log-level <LOG_LEVEL> \ --config /etc/config/digdag.properties \ -p environment=<ENVIRONMENT> \ -X database.host=<POSTGRES_IP> \ -X database.password=$POSTGRES_PASSWORD \ -X digdag.secret-encryption-key=$SECRET_ENCRYPTION_KEY \ -X archive.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> \ -X log-server.gcs.bucket=<DIGDAG_LOG_BUCKET> resources : requests : cpu : 200m memory : 300Mi limits : cpu : 200m memory : 300Mi Web DigdagにはDigdag UIと言って、ワークフローをGUIから確認・実行できるものがあります。そのDigdag UIを提供するのがWebになります。また、Digdag UI上からリクエストされるAPIの処理もこのWebが担います。 WebのDeploymentのマニフェストは次のとおりです。Digdag起動時に disable-scheduler と disable-executor-loop を指定することで、SchedulerとWorkerの役割を除外しています。そして、外部からアクセスできるようポートの設定をしています。 apiVersion : apps/v1 kind : Deployment metadata : labels : run : digdag-web name : digdag-web namespace : digdag spec : progressDeadlineSeconds : 600 revisionHistoryLimit : 10 selector : matchLabels : run : digdag-web strategy : rollingUpdate : maxSurge : 2 maxUnavailable : 0 type : RollingUpdate template : metadata : labels : run : digdag-web spec : serviceAccountName : digdag dnsPolicy : ClusterFirst restartPolicy : Always schedulerName : default-scheduler terminationGracePeriodSeconds : 20 securityContext : {} volumes : - name : digdag-config-volume configMap : name : digdag-config containers : - name : digdag-web image : <YOUR_DIGDAG_IMAGE> imagePullPolicy : Always volumeMounts : - name : digdag-config-volume mountPath : /etc/config command : [ "/bin/bash" ] args : - "-cx" - | digdag server \ --disable-local-agent \ --disable-scheduler \ --disable-executor-loop \ --log-level <LOG_LEVEL> \ --config /etc/config/digdag.properties \ -p environment=<ENVIRONMENT> \ -X server.http.io-idle-timeout=60 \ -X server.http.no-request-timeout=30 \ -X database.host=<POSTGRES_IP> \ -X database.password=<POSTGRES_PASSWORD> \ -X digdag.secret-encryption-key=<SECRET_ENCRYPTION_KEY> \ -X archive.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> \ -X log-server.gcs.bucket=<DIGDAG_LOG_BUCKET> ports : - containerPort : 8080 protocol : TCP resources : requests : cpu : 1000m memory : 4Gi limits : cpu : 100m memory : 4Gi readinessProbe : httpGet : path : / port : 8080 initialDelaySeconds : 5 periodSeconds : 5 timeoutSeconds : 4 successThreshold : 1 failureThreshold : 3 livenessProbe : tcpSocket : port : 8080 initialDelaySeconds : 5 periodSeconds : 5 timeoutSeconds : 4 successThreshold : 1 failureThreshold : 3 API DigdagはDigdagClientを利用したり先程紹介したDigdag UIを利用してコントロールします。クライアントやUIはDigdagサーバへのAPIリクエストをすることでDigdagを操作します。そのAPIを直接利用したいというケースがあったため、API専用のDigdagを今回作成しました。例えば私達のチームではAPIを利用して、特定のワークフローの完了待ちをすると言った処理を別のDigdagや、同一のDigdagから行っています。 最初はWebと同じサーバーを利用していましたが、Webの処理によりPodが落ちるということがたまに発生していました。Webだけであれば画面が使えなくなるだけですから、数秒から数分でPodが復旧すれば問題ありませんでした。しかし、APIの場合では他のアプリケーションから参照されるため、それでは困るケースがあり役割を分離しました。APIのDeploymentはWebのものとほぼ同じ構成となります。 Kubernetes Command Executor Workerでのタスク実行の問題 タスクの実行はWorkerで処理すると説明しました。私達のチームではメールやLINE・PUSH通知などの配信をしたり、データマートの集計をしたりと様々な種類のバッチ処理がDigdagで実行されます。中には大量のデータを処理するようなものもあれば、単純にHTTPリクエストするだけのものなどワークロードがバラバラです。そのため、Workerは高負荷なタスクに合わせて作成しておく必要があります。それにより、高負荷なタスクが無い場合にはWorkerのPodがオーバースペックになるため、コスト的にかなりのデメリットになります。 Command Executor この課題を解決するためにKubernetes Command Executorを利用しました。Digdagは Command Executor といってKubernetes等の環境でShellやRuby/Pythonといった処理を実行できる機能があります。 2022年1月にリリースされた Digdag v0.10.4 にて、 Command Executorのプラグイン化 がリリースされました。それにより設定ファイル等でどのCommand Executorを利用するかが選択できるようになりました。Command Executorには現在以下の種類が存在します。 Docker Command Executor ECS Command Executor Kubernetes Command Executor Docker Command ExecutorはWorker内でDockerコンテナを起動しタスクを実行します。また、ECS Command ExecutorはECSでタスクを実行します。そして、Kubernetes Command Executorでは、Kubernetes上にPodを作成しタスクを実行します。 Kubernetes Command Executorの利用 それでは、なぜKubernetes Command ExecutorでWorkerの問題が解決できるのかを紹介します。Kubernetes Command Executorを利用することでWorkerはタスク実行用のPodを作成し、作成したPodの処理完了をポーリングするだけとなります。よって、Worker自体の処理はすごく軽く、負荷は均一となります。それによりPodのサイズは小さくかつ無駄なく利用できるようになります。 GKE Autopilot環境でのKubernetes Command Executorの利用 さらにKubernetes Command Executorを利用するときに、実行するPodのマニフェストを指定できるため、ワークロードに合わせてタスクのPodを作成・実行できます。このときAutopilot環境だと事前にNodePoolを作っておく必要がないため、アプリケーション側ではノードのサイズのことを気にせず必要なキャパシティを指定し処理を実行できます。 Kubernetes Command Executorの使い方 Kubernetes Command Executorを利用するには以下のように設定ファイルを記述することで利用できます。 agent.command_executor.type=kubernetes agent.command_executor.kubernetes.config_storage.in.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> agent.command_executor.kubernetes.config_storage.out.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> agent.command_executor.kubernetes.name=<KUBERNETS_CLUSTER_NAME> agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.master=$KUBERNETS_MASTER agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.certs_ca_data=`cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt | base64 -w 0` agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.oauth_token=`cat /var/run/secrets/kubernetes.io/serviceaccount/token` agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.namespace=digdag agent.command_executor.kubernetes.config_storage.in.type=gcs agent.command_executor.kubernetes.config_storage.out.type=gcs agent.command_executor.kubernetes.config_storage.out.gcs.direct_upload_expiration=<GCS_DIRECT_UPLOAD_EXPIRATION> この状態で以下のようなタスクを定義することで、タスクがPodとして作成され実行されます。 +set_use_rate : _export : docker : image : ${docker_image} kubernetes : Pod : resources : requests : cpu : 100m memory : 0.2Gi limits : cpu : 100m memory : 0.2Gi sh> : echo 'hello' また、設定ファイルにKubeterntes Command Executorの設定をしなければ、上記のように定義されたタスクはKubernetes Command ExecutorではなくDocker Command Executorが利用されます。そのため、ローカルでの開発ではわざわざKubernetesクラスタの構築をすることなくタスクの実行ができるため効率的に開発を進めることができます。 Workerのオートスケーリング Kubernetes Command Executorを利用することで、ワークロードに合わせたタスクの実行を実現していることを紹介しました。ただし、Kubernetes Command Executor実行時にPodを作成し、Podの完了を待つという処理に関してはWorkerが担います。Workerはノードごとに max_task_threads で指定した数しか並列でタスクを実行できません。このとき、Workerのノード数を固定にしてしまうと、並列数を上げることができません。そこで、Workerノード自体のスケーリングも必要となってきます。 Custom Metricsを利用したオートスケーリング Workerのスケーリングに関しては弊社エンジニアの繁谷が考案したスケーリング手法を参考にしました。以下がその構成です。 github.com また、その説明を以下の記事で行っています。 qiita.com 構成を図示したものは次のとおりです。 Digdagでは、PostgreSQL利用しワークフローやタスクを管理しています。その中で現在実行中または実行待ちのタスクキュー数が取得できるため、それを利用することでWorker数を決定できます。そこで、PostgreSQL Exporterを利用しそのキューサイズをPrometheusに溜め込みます。そして、Prometheus Adapterを利用し、KubernetesのCustom Metrics APIに登録します。APIに登録できたら、その値をHPA(Horizontal Pod Autoscaler)で利用しスケーリングを実現します。実際の実装に関しては紹介したリポジトリを参照いただければと思います。 スケーリングの設定 私達のチームでは、紹介した構成からHPAのメトリクスの条件に「averageValue」の項目を追加しています。これを各ノードの max_task_threads の数を設定することで、現状のクラスター全体のスレッド数が足りなくなった分だけスケールアウトできるようになります。そして逆に使われているスレッド数が少なくなった場合にスケールインを行います。 実際のHPAの設定は次のとおりです。 apiVersion : autoscaling/v2beta1 kind : HorizontalPodAutoscaler metadata : name : digdag-worker namespace : digdag spec : scaleTargetRef : apiVersion : apps/v1 kind : Deployment name : digdag-worker minReplicas : 1 maxReplicas : 10 metrics : - type : Object object : target : kind : Service name : postgres-exporter metricName : queued_tasks targetValue : <MAX_TASK_THREADS> averageValue : <MAX_TASK_THREADS> スケールイン時の問題 このような設定ではスケールインのタイミングで弊害が生じます。例えば以下のように各Workerで実行中のタスクが一部終了しスケールインしたとします。その時まだ実行中のタスクは処理が中断されてしまいます。 そこでDeploymentに「terminationGracePeriodSeconds」を設定することでタスクの中断を回避しました。このパラメータはpreStopのタイムアウト時間を指定するものです。詳細に関しては以下のドキュメントをご参照ください。 kubernetes.io Workerの動きとしてはSIGTERMを受け取ると新たなタスクをキューから取得しない状態になります。また、動いているタスクは完了するまで動き続けます。よって、スケールイン時に動いているタスクが中断しないようにするには「terminationGracePeriodSeconds」をDigdag上で動作しうるすべてのタスクの中で最大の時間以上にします。タスクの中で最大の実行時間が90分以下の場合の設定例は次のとおりです。 terminationGracePeriodSeconds: 5400 そうすることで確実にタスクが終了してからノードがスケールインされます。 PrometheusAdapterの利用の注意点 Prometehus AdapterでCustom Metrics APIにAPIを登録するときに、Kubernetes Masterへの 6443 ポートのアクセス許可が必要となります。ファイアウォール等でアクセスを絞っている場合は注意が必要です。 github.com 運用上の工夫と注意点 実際に運用してみると工夫をしないと運用しづらい面や注意点などがあったので、それらについて紹介します。上記で紹介したDigdag APIを独立した構成にしたことも、実際に運用して得られた運用Tipsの1つです。 ノードの立ち上げの待ち時間が発生する 今回GKE Autopilotを利用しているため事前に必要な数だけノードの準備をしておくといったことができません。そのため、タスクのPod作成時に最大で1分ほどのノードの作成待ちが発生します。Webなどの場合HPAの閾値を緩めることで、早めにPodを用意するなどで問題を回避できます。しかし今回の場合タスクの開始時にPodが作成されるためその制御はできません。バッチ処理として利用しているため私達のユースケースにおいてはノードの起動時間は誤差の範囲であって問題とはなっていません。よって、もし数十秒・数秒といった起動時間が許容できない場合はAutopilot環境ではなくStandard環境を選ぶなどをしたほうが良いでしょう。 タスクはすべて冪等にする 各種タスクの実行はKubernetes Command Executorを利用して、毎回Podを作成していると説明しました。運用してみてわかったのですが、Kubenetes Command Executorを利用せずにタスクを実行する場合に比べてPodの起動が失敗するなどタスクの失敗頻度が高くなりました。 そこで、すべてのタスクを冪等にしておくことで安心してリトライ処理を行うことができます。また、Digdagでは、タスクごとにRetryの設定を入れることができるため、すべての処理にリトライ処理を入れることで安定したワークフローの実行ができるようになります。 docs.digdag.io 私達のチームではまだすべてのタスクが冪等になっているというわけではないため、やはりそこのリカバリ処理の運用コストが高くなっています。そのため、現在すべての処理の冪等化と自動リトライの導入を進めています。Digdagに限らず、バッチ処理では冪等化することによって安定性がかなり変わってくるので積極的に取り入れると良いでしょう。 たまにログが出ない Kubernetes Command Executorでは、処理の最後にDigdag UIなどからログが確認できるよう起動したPodからログを取得しています。しかしタスクのPodが何らかの原因で異常終了した場合、それらのログが取得できずにタスクが終了してしまいDigdag UIからログが確認できません。その場合は、タスクに紐づくPod Nameがタスク実行前に決定されるため、Cloud LoggingでPod Nameを指定してログを確認しています。実際以下のようにDigdag UIからPod Nameを確認できます。 commandStatus : cluster_name : ma-autopilot-stg executor_state : log_offset : 590 io_directory : .digdag/tmp/digdag-py-70356-1834671494158116161 pod_creation_timestamp : 1646198947 pod_name : digdag-pod-70356-0-280485a4-a27a-4e70-bb5c-c557ffc0b7f3 ただしDigdagでログを確認できることがベストであるため、これの回避方法が実装等できないかを検討しています。 終了したPodが消えない Kubernetes Command Executorでは、Podを作成しその中でタスクを実行すると説明しました。成功したPodはステータスがSuccessになりますが、いつまでたってもPodが消えずに残るということが発生しました。それにより、Podのために用意したIPを使い尽くし新たなPodが作成できないという事態が発生しました。そこで以下のようなスクリプトをCronJobで実行し、終了したPodを定期削除するようにしました。 apiVersion : batch/v1beta1 kind : CronJob metadata : name : pod-cleaner-cronjob spec : schedule : "*/30 * * * *" jobTemplate : spec : template : spec : serviceAccountName : pod-cleaner containers : - name : pod-cleaner image : YOU_SHOULD_OVERWRITE imagePullPolicy : Always command : [ "/bin/bash" , "-c" ] args : - | curl -s https://kubernetes.default.svc/api/v1/namespaces/digdag/pods?fieldSelector=status.phase=Succeeded \ --header "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" --insecure \ | jq -r --arg target_date `date +%s -d '30 minutes ago' ` \ '.items[] | { name: .metadata.name, finishedAt: .status.containerStatuses[0].state.terminated.finishedAt|fromdate} | select(.finishedAt < ($target_date|tonumber)) | .name' \ | xargs -i \ curl -X DELETE -s https://kubernetes.default.svc/api/v1/namespaces/digdag/pods/{} \ --header "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" --insecure \ | jq .metadata.name restartPolicy : OnFailure 上記スクリプトで削除対象Podを30分より前に絞っています。その理由としては、タスク成功後にDigdag Workerが成功したPodのステータスを確認し ログを取得する ためです。ステータス確認完了前にPodを消してしまうとタスクが失敗したとDigdagは判断してしまいます。よってそれを防ぐために確実にステータスチェックが終わっているであろう30分より前のPodに条件を絞っています。 ただし、終了した直後にPodがちゃんと消えてくれることがベストなため、以下のようなPRをDigdagに提案をしています。 github.com 今後の展望 最初に説明したように、Digdag以外のワークフローエンジンはすべて廃止しました。しかし、Digdag自体は複数のクラスターを運用しています。それらDigdagも今回作成したDigdagに処理をすべて統一させ運用負荷を下げたいと考えています。 また、Digdagはすごく素晴らしいツールでかなり愛用しています。ですがまだ改善の余地が残されているとも考えています。そこで、私達のチームとしても積極的にDigdagの発展に貢献できたらと考えています。 まとめ 今回、DigdagをGKE Autopilot環境に作成することで柔軟にスケールするバッチ処理基盤ができましたのでその紹介をしました。また、実際に運用してみて分かった注意点や運用Tipsについて紹介しました。 上記で挙げたように、まだまだ改善の余地は残っています。また、今回作った基盤上で動かすMAアプリケーションは他にもたくさんあります。興味があれば以下のリンクからご応募ください。 hrmos.co
アバター
はじめに こんにちは、技術本部SRE部ZOZOSREチームの堀口/柳田です。普段はZOZOTOWNのオンプレミスとクラウドの構築・運用に携わっています。 ZOZOTOWNではSQL Serverを中心とした各種DBMSが稼働しています。 その中で検索処理における参照に特化された役割を持つデータベース群をReadOnlyデータベース(以下、RODB)と呼んでいます。これらは日々増加するZOZOTOWNのトラフィックに耐えられるよう定期的にオンプレミスサーバを増台することでスケールしています。 これらのRODBは日々トラフィックの増減が激しいZOZOTOWNのサービスにおいて、オンデマンドでスケール可能なクラウド基盤上に構築した方が望ましいと判断し、クラウド化を実現しました。 本記事では、オンプレRODBをAWS RDS for SQL Server(以下、RDS)へクラウドリフトする中で解決すべき課題とそれをどのように解決したのかを紹介させて頂きます。 目次 はじめに 目次 クラウド化する上での課題 アクセス元のアプリケーションロジックを変更せずに実現する必要がある AWS Managed Microsoft ADの落とし穴 オンプレ⇔クラウド間の通信量を削減する必要がある Webサーバ上で動作するアプリケーションからの参照処理(select)における通信量削減 データ更新のためのデータレプリケーション(update/insert/delete)における通信量削減 クラウド側障害でもサービス影響を最小限に抑える必要がある マルチAZ マルチAZの特徴 マルチAZに関する時間測定 データ連携経路のAZ分散 コストを最小化する必要がある まとめ おわりに クラウド化する上での課題 RODBをクラウド化するにあたって以下の課題がありました。 アクセス元のアプリケーションロジックを変更せずに実現する必要がある オンプレ⇔クラウド間の通信量を削減する必要がある クラウド側の障害でもサービス影響を最小限に抑える必要がある コストを最小化する必要がある これらの課題を1つずつ説明していきます。 アクセス元のアプリケーションロジックを変更せずに実現する必要がある RODBのアクセス種類は2種類あります。 Webサーバ上で動作するアプリケーションからの参照処理(select) データ更新のためのデータレプリケーション(update/insert/delete) アプリケーションからの参照処理におけるRODBへ接続する際のDBログイン方式は、ユーザ名+パスワード指定による一般的なSQL Server認証方式ではなく、Windows認証方式となっています。 今回のクラウド化で置き換えるサービスはAWSのマネージド型データベースであるRDS for SQL Serverと決定していました。しかし、RDSはPaaSであるゆえに既存のRODBが所属するWindowsドメインに所属させることができません。 これではWindowsログオンユーザでDB接続するWindows認証方式が使用できないことになります。 RODBへのログイン方式を変更することは、Webサーバ上で動作するアプリケーションソースを書き換えることになります。これではオンプレRODB向けなのかクラウドRODB向けなのかをアプリケーション側に意識させる必要性が生まれてしまいます。 これはインフラとアプリケーションが密結合してしまい、本来目指すべき方向から外れてしまいます。ましてやアプリケーションソースをオンプレRODB向け/クラウドRODB向けと二重管理することは絶対に避けたいものです。 RDSへのログイン方法をWindows認証でログインさせる方法がないか模索したところ、AWS側に用意されていました。 「AWS Managed Microsoft AD」というマネージド型のディレクトリサービスにRDSを所属させ、それと既存のドメイン間で信頼関係を作成するというものです。 これによりRDSへのログイン時に既存ドメイン上のユーザがWindows認証によりSQL Serverにログインが可能となりました。 より詳細な情報は下記に記載されていますので興味のある方はこちらを参照してください。 docs.aws.amazon.com この仕組みによりアプリケーション側のログインロジックを変更することなく、オンプレDBとRDSへのログインを実現できました。 AWS Managed Microsoft ADの落とし穴 AWS Managed Microsoft ADについては当初知見がなく、構築時にハマった箇所を少し紹介します。 AD自身のアクセス制御を行うSecurityGroupについてです。AWS Managed Microsoft AD上にディレクトリを作成すると、AWSの命名規約に則ってSecurityGroupが自動的に生成されます。このSecurityGroupはAWSマネジメントコンソールから該当ディレクトリを参照しても変更できません。その存在すら見当たりません。SecurityGroupを変更したい場合は、自分で該当SecurityGroupをEC2サービス側から探し出し、変更する必要があります。 また、上記SecurityGroupを発見できたとしても、信頼関係を結ぶ他のドメインコントローラーとの通信で必要なポートが不明瞭です。AWSのマニュアルで指定されたポートだけでは不十分で、実際には他のポートも開ける必要があります。不足分はトライ&エラーで調査しました。 オンプレ⇔クラウド間の通信量を削減する必要がある ZOZOTOWNではオンプレミスサーバ群が配置されているデータセンターからAWSサービスまでの通信はAWS Direct Connect(以下、DX)を利用しております。 今回も既設のDX回線を利用するのですが、RODBへのアクセスによりネットワーク帯域を枯渇させることがないように、また枯渇させないまでも通信量の増加を最小限にするよう配慮する必要がありました。 繰り返しになりますが、RODBのアクセス種類は2種類あります。 Webサーバ上で動作するアプリケーションからの参照処理(select) データ更新のためのデータレプリケーション(update/insert/delete) Webサーバ上で動作するアプリケーションからの参照処理(select)における通信量削減 ZOZOTOWNのWebサーバ群はオンプレミス、AWSの両方で稼働しています。 Webサーバ群はオンプレミス上で稼働しておりそこからRDSへアクセスすると、発行されたクエリ自身とその結果セットの通り道はDXとなります。 これを削減するためRDSへアクセスするWebサーバ群はEC2で稼働するWebサーバのみに限定しました。 オンプレミスのWebサーバからのアクセスはオンプレRODBにするといった棲み分けを行うことでオンプレ⇔RDS間の通信をゼロにしました。 データ更新のためのデータレプリケーション(update/insert/delete)における通信量削減 RODBのデータはSQL Serverのトランザクションレプリケーション機能により他のデータベース群より取得し最新化しています。 RODBにデータを供給するデータベースはオンプレミスで稼働しているため、RDSへ同期するデータはDXを通ってくることになります。 そして、RODBはRDSの台数の増減によりスケールさせる運用を考えているため、RDSが増えていくごとにDXの通信が増えてしまうことになります。 この対策として、オンプレミスのデータベースとRDSとの間に中継用のSQL Serverを立てることによりRDS台数の増減に影響されず、通信量を一定に担保するような構成としました。なお、中継用のSQL ServerはEC2上でSQL Serverを稼働させるIaaS型としました。 クラウド側障害でもサービス影響を最小限に抑える必要がある 今回のRDSはAZ障害時に被害を最小化するためいくつかの対策をしています。 マルチAZ データ連携経路のAZ分散 マルチAZ RDSの機能であるマルチAZ方式を採用しています。マルチAZ方式を採用した理由は以下のとおりです。 人間(運用担当者)よりも迅速に切り替えるため クライアント側から見た場合に透過的に切り替えるため マルチAZの特徴 RDS for SQL ServerにおけるマルチAZの挙動は以下のとおりです。 Active/Standby構成でのフェイルオーバー型 フェイルオーバーが発動してもRDSのエンドポイント名は変わらない IPアドレスは変更されるため一時的に接続断が発生する フェイルオーバー発動のトリガーはRDS側で判断する Active/Standby構成でのフェイルオーバー型について説明します。通常時はActive側のみでサービスしStandby側へのアクセスはできないが、障害時にはStandbyがActiveに切り替わりサービスを継続するというものです。Standby側はデータベースとしてのサービスは行えません。それにも関わらずActiveと同額のランニングコストが発生することは大きなネックとなります。 Active側の障害によりフェイルオーバーが発生してもクライアントが接続時に指定するエンドポイント名(DNS名)は変らずにアクセスできるので、切り替わり時にクライアント側の操作は不要となります。 ただしエンドポイント名で解決されるIPアドレスは変更されるため、クライアント側でIPアドレスを指定したDB接続をすると、接続先の切り替え作業が必要となります。また切り替えの際に発行中のクエリはロールバックされRDS内でのリトライは行われないため、必要な場合はクライアント側でエラーとなったクエリを再実行する必要があります。 フェイルオーバー発動条件は、Active側のホスト異常やネットワーク異常などRDS側のルールによって定められています。 詳しくはこちらを参照して下さい。 docs.aws.amazon.com マルチAZに関する時間測定 弊社環境で計測したマルチAZに関わる作業/処理時間について以下にまとめます。 処理 時間 手動フェイルオーバー 1分以内 オンライン中のSingleAZのRDSをMultiAZに変更 30分 オンライン中のMultiAZのRDSをSingleAZに変更 10分 ただしSingleAZ⇔MultiAZに変更する作業については、同時に実施するRDSの数、タイミングによっては上記の3倍程度の時間がかかったこともありました。 データ連携経路のAZ分散 過去に発生したアベイラビリティゾーン(以下、AZ)障害対策として、特定のAZで何かしらの障害が発生してもサービス継続可能にするため、データ連携経路を複数のAZに分散しています。 上図の通り、AZ#1で障害が発生してAWSサービスがダウンしても、AZ#2でサービスを継続可能な仕組みとしています。 コストを最小化する必要がある RDSに関するAWSのコストを考えるために必要な要素は次のとおりです。 要素 内容 インスタンスタイプ キャパシティが大きい程コスト増 ストレージ キャパシティとIOPSが大きいほどコスト増 マルチAZ シングルAZと比較してマルチAZはほぼ倍額 バックアップ バックアップ対象サイズが大きいほどコスト増 ※マルチAZの場合は最低1世代のバックアップが必要 弊社の環境では、セール時などRODBへのアクセス増が見込まれる時に、インスタンスタイプのキャパシティを事前に増加させマルチAZ化を行います。また、アクセス減となる際には、インスタンスタイプのキャパシティを最小化しマルチAZをシングルAZ化する等の作業をすることでコストの最小化を行っております。 なお上記変更は、IaCによりコード化されたyamlを変更することで管理しており、人的な作業コストの削減も実現しております。 まとめ 本記事では、ZOZOTOWNで本番稼働するReadonlyデータベースをクラウドリフトした際の課題と対策を中心に事例を紹介しました。 弊社のReadonlyデータベースはその性質上、柔軟なスケールが可能なクラウドに適しているためクラウド化を行いました。しかし、全てをクラウド化するということを推奨するものではなく、それをクラウド化する価値があるかどうかはしっかりと判断するべきだと考えています。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは。ブランドソリューション開発部プロダクト開発ブロックの岡元です。普段は Fulfillment by ZOZO とZOZOMOのブランド実店舗の在庫確認・在庫取り置きサービスの開発、保守をしています。 本記事では、ブランド実店舗の在庫確認・在庫取り置きサービスで実装したCQRSアーキテクチャについて紹介させていただきます。 CQRSの実装においては、データベース(以下、DB)分割まで行い、コマンド側DBにはAmazon DynamoDB(以下、DynamoDB)、クエリ側DBにはAmazon Aurora MySQL(以下、Aurora MySQL)を用いています。また、コマンド側DBとクエリ側DBの橋渡しを担うメッセージングにおいてはOutboxパターンと変更データキャプチャを用いました。DBとメッセージングシステムへの二重書き込みを避けることで障害などのタイミングで顕在化する潜在的なデータ不整合を回避しています。本記事がCQRS実装の一事例として参考になれば幸いです。 目次 目次 ブランド実店舗の在庫確認・在庫取り置きサービスとは CQRSの概要 店舗在庫連携サービスにおけるCQRSの利点 DBを分割することによる柔軟なクエリの実現、処理効率の向上 モデルを分割することによるモデルの保守性、処理効率の向上 店舗在庫連携サービスにおけるCQRSの実装 CQRSにおけるコマンド側の構成概要 メッセージングのためのDynamoDB DynamoDBでOutboxパターンを実現する Outboxパターンとは 店舗在庫連携サービスにおけるDynamoDBを用いたOutboxパターンの実現方法 変更データキャプチャを用い、メッセージを送出する 変更データキャプチャとは 店舗在庫連携サービスにおける変更データキャプチャの利用方法 ドメインイベントのスキーマ定義にProtocol Buffersを利用する まとめ ブランド実店舗の在庫確認・在庫取り置きサービスとは ブランド実店舗の在庫確認・在庫取り置きサービス(以下、店舗在庫連携サービス)は、2021年11月に発表したOMOプラットフォーム「ZOZOMO」が展開するサービスの1つです。お客様は、ZOZOTOWN上でブランド実店舗の在庫を確認できることに加え、在庫の取り置きもできます。ZOZOMOのサービスに詳細ついては、こちらの プレスリリース で紹介しております。 CQRSの概要 店舗在庫連携サービスでは、アーキテクチャにCQRSを採用しました。CQRSは、コマンド(書き込み)とクエリ(読み取り)の操作を分離するパターンです。 CQRSを用いることで、コマンド側のモデルとクエリ側のモデルを分けて管理できます。複雑なビジネスロジックが必要とされることの多い書き込み操作を読み取り操作の関心事から分離することで、それぞれのモデルを比較的シンプルに保つことができます。 コマンドとクエリで共通のDBを用いることもできますが、別々のDBを用いることも可能です。コマンドとクエリでDBを分割した場合はコマンド側のDBからクエリ側のDBに更新を同期する必要があり、システム全体としてはより複雑になります。 CQRSパターンの詳細については、Microsoft社の以下記事で詳しく説明されているので、そちらを参照してください。 docs.microsoft.com 店舗在庫連携サービスにおけるCQRSの利点 店舗在庫連携サービスではドメイン駆動設計(以下、DDD)を参考に開発しており、ビジネスロジックを反映したドメインモデルをなるべくシンプルに保つという思想で設計を進めました。また、DDDにおける集約の状態の保存にDynamoDBを用いています。DynamoDBを用いている理由についてはメッセージングが関係しています。詳細は後述します。 また、店舗在庫連携サービスは既存のZOZOTOWN内に組み込む形でサービスを提供しており、以下のように利用目的が異なる3種類のサービスから利用される予定がありました。そのため、将来的にも多種多様なクエリを処理できる必要があると予想されました。 ZOZOTOWN - お客様が、実店舗の在庫確認、在庫の取り置き依頼などを行う FAANS - 店舗スタッフが、確保が必要な在庫などを確認する ZOZOTOWNのバックオフィスシステム - カスタマーサポートが、お客様や店舗スタッフからの問い合わせを受け、取り置き依頼状況の確認などを行う これを踏まえ、店舗在庫連携サービスでは、主に以下の点が利点になると考えCQRSを採用しました。 DBを分割することによる柔軟なクエリの実現、処理効率の向上 モデルを分割することによるモデルの保守性、処理効率の向上 DBを分割することによる柔軟なクエリの実現、処理効率の向上 前述の通り、店舗在庫連携サービスでは、DDDにおける集約の状態の保存にDynamoDBを用いています。 しかし、多種多様で複雑なクエリを処理するのが困難なDynamoDBでこのようなクエリを実現しようとすると非効率な実装をせざるを得ない懸念がありました。同様に、モデルの観点からも、集約を処理の基本単位とするドメインモデルで集約をまたぐクエリを実現しようとすると非効率な実装となる懸念がありました。 CQRSを用いることでコマンド側とクエリ側のDBを分割し、それぞれ別々のDBを用いることができるようになります。クエリ側のDBにはAurora MySQLを用いることで、柔軟なクエリを実現できるだけでなく効率的にクエリを処理できると考えました。 モデルを分割することによるモデルの保守性、処理効率の向上 前述の通り、店舗在庫連携サービスでは、ビジネスロジックを反映したドメインモデルをなるべくシンプルに保つという思想で設計を進めました。 しかし、多種多様な要件を持つクエリの関心事にドメインモデルが巻き込まれることで、必要以上にモデルが複雑となる恐れがありました。 CQRSを用いることで、ドメインモデルからクエリの関心事を分離し、コマンドとクエリでそれぞれ別々のモデルを作成できます。モデルを分割することで、それぞれ以下のような利点があると考えました。 ドメインモデル(コマンド側)- ビジネスロジックに集中することで、モデルをより洗練させることができる クエリモデル(クエリ側)- シンプルなモデルになり、効率的に処理を行うことができる 店舗在庫連携サービスにおけるCQRSの実装 以下に店舗在庫連携サービスの構成図を示します。 上図の通り、店舗在庫連携サービスでは、コマンド側DBとクエリ側DBにそれぞれDynamoDBとAurora MySQLを用いています。DynamoDBからAurora MySQLへのデータの同期には Amazon Kinesis Data Streams for DynamoDB (以下、Kinesis Data Streams for DynamoDB)を用いました。 各モデルについては、コマンド側のドメインモデルはクエリの関心事を気にせず実装できたことで、より洗練されたモデルにできました。クエリ側のモデルもDBにAurora MySQLを用い、リクエストに対応するSQLをほぼそのまま実行し、結果を返すような構成にすることでよりシンプルで効率的なモデルになりました。 また、今回、非同期的な処理ではDDDにおけるドメインイベントを参考にしました。ドメインイベントを用いることで、ドメイン内で発生する何かの出来事についても重要なドメインモデルの一部として扱うことができました。ドメインイベントはKinesis Data Streams for DynamoDBによって送出され、クエリ側DBの更新に用いられています。それだけでなく、メール送信や外部サービスへの連携などのドメインイベントを契機に発生する処理の実行にも利用できました。 CQRSにおけるコマンド側の構成概要 以降では、店舗在庫連携サービスの開発で特に工夫したコマンド側の構成について紹介させていただきます。 メッセージングのためのDynamoDB 店舗在庫連携サービスで行う処理には以下のようなものがあります。 在庫の取り置きがされたとき お客様へ、取り置き依頼を受け付けた旨のメールを送信する FAANSのシステムへ、店舗在庫の確保を依頼する旨の通知を送る 外部サービスへ、取り置き依頼された商品の在庫情報を連携する 店舗で商品の在庫が確保されたとき お客様へ、来店の準備が完了した旨のメールを送信する これらはドメイン内で発生する出来事であるドメインイベントが契機となり、非同期的に実行される処理です。こういったイベントの発生を元に実行される処理は、店舗在庫連携サービスにおいて多く存在しました。 そこで、DynamoDBの変更データキャプチャ機能(今回はKinesis Data Streams for DynamoDB)と トランザクション書き込み を利用したOutboxパターンによってメッセージングを実現しました。 コマンド側DBにDynamoDBを用いたのは、以下のような理由からです。 変更データキャプチャが第一級のインタフェースとしてサポートされており、容易に利用できる 複数テーブルをまたぐトランザクション書き込みをサポートしているため、Outboxパターンと変更データキャプチャによるメッセージングを容易に実現できる 変更データキャプチャやOutboxパターンは他のDBでも実現できます。しかし、変更データキャプチャを第一級のインタフェースとしてサポートしないDBでは、効率的な変更データキャプチャを利用するために Debezium などのツールや追加のサービスを導入する必要があります。このようなツールやサービスの導入により追加のメンテナンスコストが発生してしまう懸念があったため、店舗在庫連携サービスではDynamoDBを利用しました。 DynamoDBでOutboxパターンを実現する Outboxパターンとは Outboxパターン は分散トランザクションをサポートしないDB、メッセージングシステムにおいて、データの更新とメッセージの書き込みをアトミックに行うためのパターンです。実際には後述する変更データキャプチャを組み合わせて利用します。 分散トランザクションをサポートしないDBとメッセージングシステムに変更を加える場合、それぞれに書き込みを実行する 二重書き込み(Dual Writes) を利用する方法が考えられます。しかし、二重書き込みではどちらかの書き込みは成功し、もう一方の書き込みは失敗するような場合や障害が発生するタイミングなどが原因でデータの不整合が生じます。 二重書き込みの問題点については以下の記事が参考になります。 thorben-janssen.com 一方、分散トランザクションを用いる場合は、DBとメッセージングシステム両方で分散トランザクションのサポートが必要となるため、利用できる技術が限定されるという問題があります。実際、本記事の執筆時点でDynamoDBやKinesis Data Streams、Apache Kafkaも分散トランザクションをサポートしていません。 Outboxパターンでは、DBとメッセージングシステムへの分散トランザクションを用いた書き込みは行わず、DB上に追加で作成したOutboxテーブルにメッセージの内容を書き込みます。Outboxテーブルへの書き込みにはDBがサポートするローカルトランザクションを用いることができます。 Outboxテーブルへ書き込まれたメッセージは、後述の変更データキャプチャを利用することでメッセージングシステムへ書き込まれます。こうすることで、DBへの書き込みとメッセージングシステムへのメッセージの書き込みをアトミックに実行できます。 Outboxパターンと変更データキャプチャはSagaパターンにおいても、安全なメッセージングを実現するための重要な構成要素として機能するようです。Outboxパターンと変更データキャプチャをSagaパターンに適用する話は以下の記事が参考になります。こちらの記事でも二重書き込みについて触れられています。 www.infoq.com 店舗在庫連携サービスにおけるDynamoDBを用いたOutboxパターンの実現方法 店舗在庫連携サービスではDynamoDB上に以下の2種類のテーブルを用意し、DynamoDBのトランザクションを用いて書き込みを行っています。 集約ステートテーブル ドメインイベントテーブル 集約の状態を保存する 集約の状態が変更する際に発生したドメインイベントを保存する レコードにはバージョン(数値)を持たせておき、集約の状態が変わるたびに1インクリメントする レコードには保存する集約と同じバージョンをもたせておく バージョンはDynamoDBの条件付き書き込みによって楽観的ロックを実現するために利用する バージョンはコンシューマの重複除去、順序のチェックに利用する(詳細は後述) ドメインイベントテーブルに加えられた変更は、Kinesis Data Streams for DynamoDBによってメッセージとして送出されます。こうすることで、分散トランザクションを利用すること無く、集約の状態の更新とメッセージングシステムへのメッセージの書き込みをアトミックに実行できます。 また、DynamoDBを用いた楽観的ロックの実装方法については以下のAWSのドキュメントが参考になります。 docs.aws.amazon.com 店舗在庫連携サービスではDynamoDBMapperを使用していませんが、同等の実装を 低レベルインタフェース を用いて行っています。 変更データキャプチャを用い、メッセージを送出する 変更データキャプチャとは 変更データキャプチャ(Change Data Capture、CDC) は、DBの変更を追跡し処理を行うためのパターンです。前述のOutboxパターンと組み合わせて利用することでOutboxテーブルの内容をメッセージングシステムに書き込むことができます。 CDCの種類には様々なものがありますが、ここではQuery-based CDCとLog-based CDCの特徴を取り上げます。それぞれの特徴は以下のとおりです。 Query-based CDC(Timestamp-based CDC、 Polling publisherパターン ) DBテーブルの内容を定期的にポーリングし、変更が追加されていれば処理を実行する ポーリングを行うためDBに余計な負荷をかける場合がある DBテーブルのタイムスタンプを見て変更を追跡する場合、範囲クエリのサポートが必要になる Log-based CDC( Transaction log tailingパターン ) DBのトランザクションログを追跡し処理を実行する ポーリング、範囲クエリのサポートが必要ない トランザクションログを利用するのでDB固有のソリューションが必要になる CDCの種類とそれぞれの特徴については以下の記事が参考になります。 datacater.io 店舗在庫連携サービスにおける変更データキャプチャの利用方法 店舗在庫連携では、DynamoDBが提供しているKinesis Data Streams for DynamoDBという機能を用いています。前述の分類に当てはめると、Log-based CDCのような特徴を持った機能です。 DynamoDBがサポートするCDCの機能には、DynamoDB StreamsとKinesis Data Streams for DynamoDBの2つがあります。それぞれのメリット、デメリットの概要は以下のとおりです。 DynamoDB Streams メリット:レコードが更新順に現れ、順序が保証される デメリット:許容されるコンシューマ数が少ない(1シャードあたり2つまで) Kinesis Data Streams for DynamoDB メリット:許容されるコンシューマ数が多いなど拡張性が高い デメリット:レコードが変更順に現れない その他の特徴については以下の記事に記載されています。 docs.aws.amazon.com このようなメリットとデメリットがありますが、店舗在庫連携サービスでは、拡張性の高さからKinesis Data Streams for DynamoDBを採用しています。ただし、レコードの順序保証がされないデメリットについての対策が必要です。そこで、店舗在庫連携サービスではドメインイベントテーブルのレコードに1ずつ増加するバージョンを含めるようにしています。メッセージの処理順序が重要なタスクではコンシューマ側でレコードの順序が崩れていないか(取得されたレコードのバージョンが1ずつ増加しているか)のチェックを行っています。また、同様にメッセージの重複除去もバージョンを用いて行っています。 店舗在庫連携サービスのユースケースでは、同じ集約に対して短時間にリクエストが集中することはまれなためレコードの順序の崩れが起きる可能性は低いと考えています。そのため、万が一順序の崩れが発生した場合は、アラートを発火しリカバリを行うような運用を考えています。 ドメインイベントのスキーマ定義にProtocol Buffersを利用する 店舗在庫連携サービスでは、メッセージ(ドメインイベント)のスキーマの定義にProtocol Buffersを用いています。これは、DDDの「公表された言語」を参考にしました。店舗在庫連携サービス内で発生したイベントに関心のある他サービスや今後の機能拡張もこの公表された言語を利用し、ドメインイベントを介することで、店舗在庫連携サービスとは独立して開発できると考えています。 実際の定義は以下のようなものです。 syntax = "proto3" ; package order.events; import "google/protobuf/timestamp.proto" ; message OrderEvent { string order_id = 1 ; int32 version = 2 ; google.protobuf.Timestamp created_at = 3 ; oneof body { OrderAccepted order_accepted = 4 ; OrderCanceled order_canceled = 5 ; } } message OrderAccepted { string order_id = 1 ; string order_status = 2 ; int32 goods_id = 3 ; } まとめ 本記事では、店舗在庫連携サービスで実装したDynamoDBを用いたCQRSの実装について紹介しました。DB分割したCQRSの実装で肝となるメッセージングにはOutboxパターンとCDCを利用することで、障害などのタイミングで顕在化する潜在的な不整合を回避できました。 今回、非同期的な処理ではドメインイベントの考えを取り入れたことで、モデルにより豊かな表現を取り込むことができました。それだけでなく、システムを疎結合に保つことができたおかげで機能の追加についても比較的容易に対応できました。噂通りそれなしでは生きられなくなるほどの強力なツールだと実感しています。 ブランドソリューション開発部では仲間を募集しています。ご興味のある方は、以下のリンクからご応募ください! hrmos.co
アバター
はじめに こんにちはZOZOデータサイエンス部MLOpsブロック松岡です。 本記事では先日リリースされたGCP( Google Cloud Platform ) Cloud Composer の最新バージョンCloud Composer 2について紹介します。 ZOZOTOWNでは、多種多様な商品が毎日新たに出品されています。現在MLOpsブロックでは、機械学習で商品情報の登録を補佐するシステムを開発しています。 このシステムでは商品情報を保存するデータベースへ大量の書き込み処理が発生します。このアクセスによる負荷が日常業務に影響を及ぼすリスクを最小限に抑えるため、推論処理は夜間に行います。夜間に処理を完了させるには強力なマシンリソースを使用する必要があります。コストの観点から処理が行われていない時間はマシンリソースを使用停止する必要もあります。また、人手を介さずに安定して稼働出来る仕組みも求められます。 上記の要件を満たすためにワークフローエンジンを使用することになりました。 MLOpsブロックでは当初 Vertex AI Pipelines を検討しました。 しかし、 類似アイテム検索機能にCloud Composerを採用していた ことや、スケジューリング機能やリトライ処理が充実していることからCloud Composerについても検討することとしました。 類似アイテム検索ではCloud Composer 1を使用していたため、そのバージョンアップにおける技術調査を兼ねて、Cloud Composer 2における変更点について調査しました。併せて Apache Airflow (以下Airflowと記述)2についても調査しています。 目次 はじめに 目次 Airflowはワークフローエンジン ワークフローエンジンを使うメリット Airflowの強み ワークフローの定義にPythonを使用 多種多様なOperator スケジューリング機能を備えている ワークフローの流れ Cloud Composer 2について 柔軟なマシンスペックの指定 より細かいマシンスペックを指定可能 ワーカーの水平スケールが可能 Airflow 2での強化点 スケジューラーの強化 スケジューラーのパフォーマンス改善 スケジューラーがHA(High Availability)構成に対応 更新されたAIRFLOW UI DAGs一覧画面 DAG詳細画面 シンプルなDAGの記法に対応 RBAC UIに標準対応 デフォルトで作成されたロールの設定 カスタムロールの作成 RBAC UIはAIRFLOW UI上でのみ有効 最後に Airflowはワークフローエンジン Cloud ComposerはGCP上にてAirflowをマネージドに提供するサービスです。Cloud Composerについて説明する前にまずAirflowについて簡単に紹介します。 ワークフローエンジンを使うメリット Airflowは様々な処理を行うワークフローをスケジュールして実行出来るワークフローエンジンです。例えばデータを推論するワークフローを次のような複数のタスクに分けて協調動作させることができます。 外部APIから必要なデータを取得するタスク 取得したデータに対して分類ごとに並列で推論処理を行うタスク 推論の結果を出力するタスク 複数のタスクが依存関係に基づく順序で実行され、各タスクを異なるワーカーインスタンスが処理し、エラー時にはリトライさせることが出来ます。これによりネットワークエラーのような不測の事態が発生した場合でも、人手を介さずにワークフローを復旧させることが出来ます。 Airflowの強み ワークフローの定義にPythonを使用 AirflowはPythonにより実装されており、ワークフローの定義にもPythonを使用します。そのためXMLなどの設定ファイルでワークフローを記述する方式に比べてプログラマーにとって理解しやすく感じます。 多種多様なOperator タスクはOperatorをインスタンス化することで定義します。Airflowには 標準的なOperator に加え GKEインスタンスを起動するOperator など多様なOperatorがすでに用意されていることも魅力です。適切なOperatorを使用することでタスクを簡単に記載できます。 スケジューリング機能を備えている Airflow自身にスケジューリング機能を備えており、特定の時間や一定時間ごとにワークフローを自動実行することが出来ます。 ワークフローの流れ ワークフローはタスクを組み合わせてDAG(有向非巡回グラフ)として定義します。実行可能となったタスクはスケジューラーによりワーカーと呼ばれる実行用のマシンインスタンスに割り当てられます。ワーカーがタスクを完了するとDAGに基づいて次のタスクが実行可能となります。実行中のタスクと実行可能なタスクが全て無くなればDAGが終了します。 Cloud Composer 2について Cloud ComposerはAirflowをGCP上で実行するマネージドなサービスです。詳細な アーキテクチャ はCloud Composerのバージョンによって異なりますが、どちらも大部分はGKE( Google Kubernetes Engine )上で動作しています。 Cloud Composer 2では更にGKEに寄ったアーキテクチャとなっています。例えばAirflow Web Serverは GAE(Google App Engine) からGKE上のDeployment上で動作するよう変更されています。 現在はCloud Composer 1とCloud Composer 2が提供されています。Cloud Composer 2になっての変更点は Cloud Composer のバージョニングの概要 に記載されていますが、ここではその中でも特に便利に感じた部分を紹介します。 柔軟なマシンスペックの指定 Cloud Composer 2では実行環境がより柔軟に指定出来るようになりました。 より細かいマシンスペックを指定可能 Cloud Composer 1のGKE環境は GKE Standard 上に構築されていました。そのため、 Kubernetes のNodeを実行するvCPUやメモリーを予め決められたマシンタイプの中から選ぶ必要がありました。 Cloud Composer 2のGKE環境は GKE Autopilot 上に構築されています。スケジューラー/ウェブサーバー/ワーカーのPodsで使うvCPU、メモリ、ストレージをそれぞれ個別に指定出来るようになりました。 Cloud Composer 1では環境構築後は変更不可であったワーカー、管理用Webサーバー、データベースのマシンスペックも後から変更出来るようになりました。このため環境構築時に将来のスケーリングについて正確に見積もる必要がなくなります。 また、ワークフローの性質に基づいて次のような運用も可能です。 安定稼働しているワークフローではウェブサーバーの性能を落とし、緊急対応時のみウェブサーバーの性能を上げる DAGは単純だが個別の処理が重たい場合、スケジューラーの性能を落としてワーカーの性能を上げる ワーカーの水平スケールが可能 ワーカー数を負荷に応じて自動でスケールさせられるようになりました。特別な操作や設定は不要で、必要な性能に応じて性能を保ったまま低負荷時のコストを削減できます。 実際にCloud Composer 2の環境を用意して水平スケールを試してみます。Cloud Composer 2の水平スケールは Custom Metrics - Stackdriver Adapter を用いて得られる、未割り当てタスクと現在のワーカー状況を指標として使用します。 未割り当てタスク数とワーカーにより実行可能なタスク数が一致しなくなると 次のようにスケーリングが実行されます。 cluster-autoscaler と node-auto-provisioning によりノード数とノードサイズをスケーリング HPA(HorizontalPodAutoscaler) によってPods数をスケーリング 今回はワーカーに0.5個のvCPU、1.875GBメモリ、1GBストレージを使用します。ワーカーの自動スケーリングは、ワーカーの最小数を1、最大数が3で試します。 Cloud Composer 2ではワーカー1vCPUあたり デフォルトで12個のタスクを同時に実行します 。これではワーカーへの割当が0.5vCPUの場合でさえ、6タスクまで同時に実行可能となり、なかなかスケーリングが発生しません。 そこで1vCPUあたりの同時実行タスク数を減らしてスケーリングが起こりやすくします。この設定は「AIRFLOW構成のオーバーライド」タブから celery の worker_concurrency を書き換えることで変更ができます。 値に 1 を設定することでそれぞれのワーカーは一度に1つのタスクしか処理しなくなり、ワーカーが枯渇しやすくなります。 実行するDAGは次のとおりです。タスクを12並列で実行するDAGを用意しました。スケーリングは時間がかかるためスケール前にDAGが完了しないよう各タスクで60秒待機します。 import logging import time from airflow.operators.python_operator import PythonOperator from airflow.utils.dates import days_ago from airflow import models default_dag_args = { "start_date" : days_ago( 2 )} from airflow.operators.dummy_operator import DummyOperator from airflow.operators.python_operator import PythonOperator with models.DAG( "parallel_tasks" , default_args=default_dag_args, schedule_interval= None , ) as dag: def task_method (i: int , **context): time.sleep( 60 ) logging.info(f "Hello: {i} Task" ) start_task = DummyOperator(task_id= "start" , dag=dag) for i in range ( 0 , 12 ): task = PythonOperator( task_id=f "hello{i}" , python_callable=task_method, provide_context= True , dag=dag, op_kwargs={ "i" : i}, ) start_task >> task DAGのGraphは次のとおりです。 上記のDAGを実行すると、最初はワーカーが1つしかないので1タスクずつ実行されます。 しばらくするとワーカーがスケーリングされ3つのタスクを同時実行するようになります。 kubectlの kubectl get -w pods --all-namespaces を実行して airflow-worker で始まるワーカーPodsが増えるのを確認できます。 実行前はワーカーのPodsは1つだけです。 未割り当てのタスクが増えるとともにPodsも増加します。 タスクが消化され未割り当てのタスクが減ると、次第に過剰となったワーカーPodsは破棄されます。 Cloud Composerで測定する環境を選びモニタリングでアクティブワーカー数を見て、ワーカーがオートスケールされたことを時系列で確認することも出来ます。 注意点として、ワーカーの立ち上がりと終了には時間がかかります。 今回の検証ではPodsが立ち上がって実際にタスクが振られるまでに3分程度かかりました。試しに上記のDAGの内容を書き換え、各タスクの待ち時間を60秒から10秒に短縮して試してみました。この場合オートスケールは行われますが、オートスケールされたワーカーへタスクが割り振られる前にDAG全体が終了してしまいました。これではオートスケールの恩恵を受けられず、料金だけ掛かってしまうことになります。オートスケールが効率よく働くようにするには同時実行するタスク数が短時間で増減しないようにDAGを組むと良さそうです。 Airflow 2での強化点 Cloud Composer 2へ移行する時に考慮すべき点はCloud Composer 2がAirflow 2しかサポートしていないことです。Cloud Composer1は当初Airflow 2系をサポートしていなかったので、Cloud Composer 2へ移行するにはAirflow 2への移行も必要となる場合が多いと思われます。 Airflow2への移行コストを理由にCloud Composer 2の移行を悩まれている人、移行コストを払ってでもAirflow2に移行したくなるAirflow2の改善点をいくつか紹介します。 スケジューラーの強化 Airflowのスケジューラは、DAGを解析して実行可能なタスクをワーカーに割り当てます。Airflow2になってからこのスケジューラが大幅に強化されています。 スケジューラーのパフォーマンス改善 Airflow1ではスケジューラーが一定時間ごとに未割り当ての実行可能なタスクを探しワーカーを割り当てており、タスクが終了後次のタスクにワーカーが割り当てられるまで一定の待ち時間が発生していました。タスクを分割するほどワーカーの割り当て待ち時間が増えるため、タスクの粒度をあまり細かくできませんでした。 Airflow2ではワーカーがタスクを終了時に後続の実行可能なタスクの存在を確認するようになりました。実行可能なタスクがあった場合 mini scheduling でワーカーが自身をそのまま即時スケジューリングします。 これによりワーカーがスケジュールされるのを待つ必要がなくなり、ワーカーはすでにDAGを解析済みであることから再解析も不要となるため後続タスクを速やかに実行できます。 実際に、どれくらいスケジューラーのパフォーマンスが向上されているかを調べてみます。Cloud Composerのバージョンにより使用するマシン性能の指定方法が変わっているので可能な限り性能を合わせました。Cloud Composer 1ではノード数3、マシンタイプはn1-standard-1を指定します。Cloud Compsoer 2ではマシンスペックをスケジューラー、ウェブサーバー、ワーカーの性能をそれぞれで選べるのでn1-standardに合わせて1vCPU、メモリ3.75GBとします。ワーカーの最大数は1としてオートスケールによるメリットが発生しないようにしています。 実行するDAGは次のとおり、ログを出力するだけの簡単なタスクを10回繰り返します。 import logging from airflow import models from airflow.utils.dates import days_ago from airflow.operators.dummy_operator import DummyOperator from airflow.operators.python_operator import PythonOperator default_dag_args = { "start_date" : days_ago( 2 )} with models.DAG( "serial_tasks" , default_args=default_dag_args, schedule_interval= None , ) as dag: def task_method (i: int , **context): logging.info(f "Hello: {i} Task" ) start_task = DummyOperator(task_id= "start" , dag=dag) latest_task = start_task for i in range ( 0 , 10 ): task = PythonOperator( task_id=f "hello{i}" , python_callable=task_method, provide_context= True , dag=dag, op_kwargs={ "i" : i}, ) latest_task >> task latest_task = task 測定は5回繰り返して、平均と最速、最遅のデータを取得しました。 環境 最速 平均 最遅 Cloud Composer1.17.7 Airflow1.10.15 03:46 04:10 04:47 Cloud Composer 2.0.0 Airflow2.1.4 00:28 00:31 00:35 Cloud Composer 1とCloud Composer 2の処理時間の差(少ないほうが高速) 約8倍も高速にDAGを完了することが出来ました。今回の例では3分半程度の差ですが、タスクの数が増えるほどこの差は比例して広がっていくことになります。 例えばタスク数を20に増やすと7分ほどの差が付きました。 環境 最速 平均 最遅 Cloud Composer1.17.7 Airflow1.10.15 07:39 08:08 08:29 Cloud Composer 2.0.0 Airflow2.1.4 00:56 00:59 01:08 スケジューラーがHA(High Availability)構成に対応 Airflow2ではスケジューラーを複数起動して、HA構成を取ることが出来るようになりました。各スケジューラーはデータベースのロック機能を使って作業を同期しているため、スケジューラ同士は直接通信せず独立して動作します。このためスケジューラーの1つが障害をおこしても、別スケジューラーへは影響なく、別スケジューラーにより作業を継続できます。これにより可用性を向上させられます。 実際にスケジューラーをクラッシュさせてHA構成が有効に働くか試してみます。10秒待つタスクを20回繰り返すワークフローを用意しました。 import logging import time from airflow.utils.dates import days_ago from airflow.operators.python_operator import PythonOperator from airflow import models default_dag_args = { "start_date" : days_ago( 2 )} from airflow.operators.dummy_operator import DummyOperator from airflow.operators.python_operator import PythonOperator from airflow.operators.dummy_operator import DummyOperator from airflow.operators.python_operator import PythonOperator with models.DAG( "serial_wait_tasks" , default_args=default_dag_args, schedule_interval= None , ) as dag: def task_method (i: int , **context): time.sleep( 10 ) logging.info(f "Hello: {i} Task" ) start_task = DummyOperator(task_id= "start" , dag=dag) latest_task = start_task for i in range ( 0 , 20 ): task = PythonOperator( task_id=f "hello{i}" , python_callable=task_method, provide_context= True , dag=dag, op_kwargs={ "i" : i}, ) latest_task >> task latest_task = task ※注意:この操作は最悪の場合、環境を壊す可能性があります。自己責任でお試しください。特に全てのスケジューラーを同時に落とさないように注意してください。 ワークロードの構成でスケジューラーの数に2を設定してスケジューラーのHA構成を有効にします。 airflow-scheduler podsが2つ起動するのを待ちます。 ワークフローを実行します。黄緑色の点がスケジューリングされて実行しているタスクです。 ワークフローを実行中に kubectl delete pod でairflow-schedulerを1つ止めます。 スケジューラーが1つ止まった後も、別のスケジューラーによりタスクが正常にスケジューリングされます。 たまたま動作していなかったスケジューラーを止めてしまっただけではないことを確認するために、もう片方のスケジューラーも停止します。これで当初スケジューリングしていたスケジューラーはいなくなったことになります。 それでもタスクはスケジュールされ続けます。新たに起動したスケジューラーによりタスクが正常に動作し続けていることがわかります。 このように、HA構成を取ることでタスクスケジューラーの可用性が上がるのを確認できました。 先程はスケジューラーを明示的に停止しましたが、スケジューラーを停止しなくても複数のスケジューラーは常に動作しています。 スケジューラーのログを見るとDAGを実行時にスケジューラーを停止しなくても複数のスケジューラーがタスクをスケジューリングしていることがわかります。 このことからスケジューラーはActive-Active構成で動いていることがわかります。これはスケジューラーを複数建てることで可用性だけでなく性能を向上させることが出来ることを意味します。 更新されたAIRFLOW UI AirflowはWebでワークフローの管理ができるAIRFLOW UIを備えています。Airflow2ではこのAIRFLOW UIが全面的に作り直されモダンな構造となりました。新しいAIRFLOW UIは単に見た目が良くなっただけでなく、よく使う機能がより探しやすくなっています。それでいながらAirflow1を使い慣れているユーザーが違和感ない操作性を実現できており、秀逸なデザインとなっています。 実際に見比べてみましょう。 DAGs一覧画面 Airflow1では、Linksに大量のアイコンが並んでいました。アイコンは直感的と言い難く、アイコンの説明もカーソルを乗せるまで表示されませんでした。 Airflow2では、アイコンが整理されよく使うタスクの実行/更新/削除のみがActionsとして表示されるようになりました。 その他の機能はLinks内に収められアイコンとラベル表示でわかりやすくなりました。直感的ではなかったアイコン表示も改められ初見でも機能を理解しやすくなっています。 また、管理画面のタイムゾーンを指定することが出来るようになったのも見逃せないポイントです。Airflow1ではAIRFLOW UIの時刻表示がUTCから変更できずJSTで動いている業務とのマッチングが手間でした。Airflow2はAIRFLOW UIで表示するタイムゾーンを指定出来るようになりJSTも指定可能になりました。 DAG詳細画面 DAGの詳細画面もモダンになっています。 やはりここでもDAGの実行/更新/削除のアイコンが分離して見やすくなりました。加えて Auto-refresh 機能が追加されDAG実行時にリロード不要でタスクの進行具合がリアルタイム更新されるようになりました。 シンプルなDAGの記法に対応 DAGを構築する上では、シンプルに記述出来るようになったのも嬉しいポイントです。特に Python Operator の記載法は一般的なPythonで関数を記載するスタイルに近くなりわかりやすくなりました。 それでは、実際にDAGを作って見比べてみます。次のようにそれぞれのタスク間で依存関係があるDAGを作ります。 get_week :曜日のCSVを作成する。 parse_week_(0〜6) : get_week が作成したCSVから曜日ごとに文字列を作成する。 print_week : parse_week_(0〜6) が作成した曜日の文字列を表示する。 Airflow1では次のように記載していました。 from airflow import models from airflow.operators.python_operator import PythonOperator from airflow.utils.dates import days_ago default_dag_args = { "start_date" : days_ago( 2 ), "provide_context" : True } with models.DAG( "parallel_tasks_dag1" , default_args=default_dag_args, schedule_interval= None , ) as dag: def get_week (**kwargs): return "Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday" def parse_week (**kwargs): ti = kwargs[ "task_instance" ] week = ti.xcom_pull(task_ids= "get_week_task" ) return week.split()[kwargs[ "index" ]] def print_week (**kwargs): ti = kwargs[ "task_instance" ] for i in range ( 7 ): day = ti.xcom_pull(task_ids=f "parse_week_{i}" ) print (day) get_week_task = PythonOperator( task_id= "get_week_task" , python_callable=get_week, provide_context= True , dag=dag, ) print_week_task = PythonOperator( task_id= "print_week" , python_callable=print_week, provide_context= True , dag=dag, ) for i in range ( 7 ): parse_week_task = PythonOperator( task_id=f "parse_week_{i}" , python_callable=parse_week, provide_context= True , dag=dag, op_kwargs={ "index" : i}, ) get_week_task >> parse_week_task >> print_week_task タスクを記述するには、 Python Operator のインスタンスを生成して行います。タスクに値を渡したり、他タスクの戻り値を受け取るには dict を使う必要があります。このようにやや特殊で冗長な記法が必要でした。また、タスクの順序は >> で明示する必要がありました。 同じタスクをAirflow2では次のように記述出来ます。 from airflow.decorators import dag, task from airflow.utils.dates import days_ago @ dag (default_args={ "owner" : "airflow" }, schedule_interval= None , start_date=days_ago( 2 )) def parallel_tasks_dag2 (): @ task def get_week () -> str : return "Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday" @ task def parse_week (week, index): return week.split()[index] @ task def print_week (day_list: dict ): for day in day_list: print (day) week = get_week() day_list = [] for i in range ( 7 ): day_list.append(parse_week(week=week, index=i)) print_week(day_list=day_list) dags = parallel_tasks_dag2() タスクと引数や戻り値をやり取りする際も、まるで普通にPythonの関数を呼ぶように記述できます。タスクの順序は明示する必要がなく、変数のやり取りを通じて自動的に解析されます。タスク間で依存するデータが見やすいこと、冗長で特殊な記法が減ったことでDAGが書きやすく読みやすくなりました。 RBAC UIに標準対応 Cloud Composer上では IAM(Identity and Access Management) によるAIRFLOW UIまでのアクセス権限がおこなわれます。 Airflow2では加えてRBAC(Role Based Access Control) UI機能が追加されました。RBAC UIにより AIRFLOW UI上でのユーザーの権限を個別にロールとして付与できる ようになりました。 たとえば Viewer ロールのみが付与されたユーザーの場合、DAGの実行状況を見れるだけで、実行や停止は出来なくなります。 デフォルトで作成されたロールの設定 試しに Viewer ロールを付与してみます。 Cloud Composer 2では新規アカウントに標準でOpロールが付与される ため Op ロールを剥奪します。 ロールを剥奪するには gcloud コマンドを使用します。 gcloud beta composer environments run ENVIRONMENT_NAME \ --location LOCATION \ users remove-role -- -e USER_EMAIL -r ROLE 大文字部分は次のように置き換えます。 ENVIRONMENT_NAME:Cloud Composerの環境名 LOCATION:Cloud Composerのロケーション USER_EMAIL:ユーザーのメールアドレス ROLE:剥奪するロール 今回はROLEに Op を指定します。 ロールを付与するには剥奪時と同様に gcloud コマンドを実行します。指定方法はロールの剥奪時と同じで remove-role の代わりに add-role とします。 gcloud beta composer environments run ENVIRONMENT_NAME \ --location LOCATION \ users add-role -- -e USER_EMAIL -r ROLE ROLE にデフォルトで用意されている Viewer を指定します。 Viewer ロールのみを設定したユーザーでAIRFLOW UIを開くと、DAGの実行状況は見えますがActionsはグレーアウトされ実行などは出来なくなっています。 カスタムロールの作成 Op や Viewer のようなデフォルトで設定されているロールだけでなく、 より細かな制御を行えるカスタムロール の作成もできます。 次のDAGsから normal_tasks のみが表示可能なカスタムロールを作ってみます。 カスタムロールを作るにはまず、 gcloud コマンドを使用して管理者ユーザーに Admin ロールを付与します。 Admin権限が付与されたユーザーでAIRFLOW UIの Security - List Roles を開きます。 Permissions に次の権限を追加します。 Viewer ロールを付与したときと同じ要領で作成したロールを対象ユーザーへ付与します。 このロールが付与されたユーザーでAIRFLOW UIにアクセスすると normal_tasks が表示はできるが実行は出来ないこと、他のタスクはその存在自体が見えなくなっていることがわかります。 RBAC UIはAIRFLOW UI上でのみ有効 RBAC UIによるロールの制御はAIRFLOW UI上でのみで有効であることに注意が必要です。 そのため上記ユーザーであってもIAMで roles/composer.environmentAndStorageObjectViewer を保持していた場合。 gcloud コマンドを使って、次のコマンドを実行すると非表示にしたかったDAGsも見えてしまいます。 gcloud beta composer environments storage dags list --environment=ENVIRONMENT_NAME --location LOCATION ユーザーを管理するにはユーザーに対して roles/composer.user のみを設定するというようにIAMの権限設定も必要です。IAMでの権限設定については、 IAM を使用したアクセス制御 が参考になります。 最後に 本記事では新しくなったCloud Composer 2とAirflow2の特性を紹介しました。 Cloud Composer 2とAirflow2を組み合わせることで、可用性が高く低コストで高速なワークフロー環境を簡単に作ることが出来るようになりました。 ZOZOではこの他にも Vertex AI Pipelines など複数の仕組みを採用してワークフローを実装しています。 ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com hrmos.co
アバター
はじめに こんにちは。検索基盤部 検索基盤チームの佐藤( @satto_sann )です。検索基盤チームでは、 ZOZOTOWNの検索周りのシステム開発に日々取り組んでいます。 本記事では、ZOZOTOWNの検索マイクロサービスにおけるキャッシュ導入で得られた知見や工夫点について紹介します。検索に限らずマイクロサービスへキャッシュの導入を検討されている方の参考になれば幸いです。 目次 はじめに 目次 キャッシュの導入背景 負荷とレイテンシの悪化 ABテストの仕組みをマイクロサービスへ移設する上での問題 キャッシュ導入の検証 Cache Stampede Cache Stampedeの対策 2重キャッシュ 分散していたキャッシュの統合 キャッシュの有効期限 定められたタイミングでの情報反映 キャッシュ導入後の構成 キャッシュ制御の設計 【A案】アプリケーション層とドメイン層の間 【B案】ドメイン層とインフラ層の間 【C案】A案とB案の両方を採用する キャッシュ導入による効果 レイテンシの減少 APIへのリクエスト数が少ない場合 負荷への耐久性の向上 工夫した取り組み API毎にTTLを個別設定 キャッシュキーのハッシュ化 キャッシュの圧縮 キャッシュ圧縮や解凍の実現方法 実装 キャッシュ圧縮による効果 まとめ おわりに キャッシュの導入背景 なぜ検索マイクロサービスにキャッシュを導入する必要があったのかについて紹介します。 負荷とレイテンシの悪化 現在ZOZOTOWNの検索システムは、既存の肥大化したシステムから検索に関連したAPI(以下、検索APIと呼ぶ)を切り離し、検索機能に特化したマイクロサービスで構成されています。 マイクロサービス化への道のりについては、以下の記事をご参照ください。 techblog.zozo.com 検索APIのマイクロサービス化に伴い、ZOZOTOWN以外のマイクロサービスから直接呼び出される機会が増加していました。 ZOZOTOWNからリクエストされる場合には、ZOZOTOWNを配信しているWebサーバ上にキャッシュする機構が以前からあったため、特に性能的な問題はありませんでした。 一方で他のサービスから直接呼び出された場合、検索APIはキャッシュ処理を行うような機構(以下、キャッシュ機構と呼ぶ)を持っていないため、負荷やレイテンシの悪化が課題となっていました。 ABテストの仕組みをマイクロサービスへ移設する上での問題 ZOZOTOWNではより良い検索機能を提供するためにABテストを実施しています。以下では、ABテストが振り分けられる様子を表しています。 これまでのABテストの仕組みでは、ZOZOTOWN上でABテストを設定していたため、以下のようなロジックの修正や設定の変更が必要でした。 ZOZOTOWNのABテストの設定 既存キャッシュ処理のロジック 検索マイクロサービスのパラメータ 検索マイクロサービスの内部ロジック ABテスト毎にこれらの変更作業が発生するため、短期的にABテストを実施する上で課題となっていました。 そこで、以下の図のように検索マイクロサービス上でABテストを実施する仕組み(以下、ABテスト基盤と呼ぶ)を構築して、ABテストの設定をこの基盤上で行えるよう変更します。 マイクロサービスに完結したABテストの実施が可能になり、設定や改修は以前と比べ少なくなります。 ABテスト基盤の設定 検索マイクロサービスの内部ロジック 検索APIを直接利用していたネイティブアプリについては、これまでABテストを実施出来ていませんでしたが、ABテスト基盤が出来たことでWebと合わせてABテストが実施可能になります。 この仕組を実現するためにも検索APIにキャッシュ機構を導入する必要がありました。 キャッシュ導入の検証 検索APIにキャッシュ機構を導入すると前述した複数の課題が解決できますが、下記のような懸念事項がありました。 Cache Stampede(キャッシュスタンピード) 2重キャッシュ キャッシュの有効期限 定められたタイミングでの情報反映 以下では、これらの懸念事項とその対策について詳しく説明します。 Cache Stampede キャッシュが有効期限切れなどで破棄された際に、データ提供元(以下、オリジンと呼ぶ)へのアクセスが瞬間的に集中することで、APIやデータベースの負荷が高まります。 このような現象を Cache Stampede(キャッシュスタンピード) と呼びます。 アクセスが少ない場合は特に問題になりません。しかし、検索APIが提供する検索機能はZOZOTOWNの多くのユーザが利用するため、キャッシュが破棄されたタイミングで Cache Stampede の発生が予想されました。 Cache Stampedeの対策 Cache Stampedeの対策はいくつかあります。 別プロセスで事前にキャッシュを生成する(事前作成) 期限切れ前に一定の確率で期限を更新する(期限更新) 裏側のAPIへアクセスするプロセスを絞る(ロック) 今回は、ロック方式を採用しました。任意のタイミングで更新情報を商品結果に反映する検索要件を実現しやすいことと、他マイクロサービスにて安定して運用している実績があったためです。 ロック方式の利点やその他Cache Stampede対策については以下の記事をご参照ください。 techblog.zozo.com 2重キャッシュ 背景で述べたとおり、現状リクエスト元のWebサーバ上で検索APIのレスポンスがキャッシュされています。検索APIにキャッシュ機構を導入すると、既存のキャッシュ処理を廃止するまで両方でキャッシュ処理される2重キャッシュ状態となり、キャッシュ効率の観点で懸念がありました。 しかし、どちらも一度キャッシュしてしまえば次の更新まで高速にレスポンスを返却できるため、この問題は許容出来ると判断しました。実際にリリース後の計測では2重キャッシュ状態でレイテンシは10ミリ秒増加しましたが、運用上は許容範囲内でした。 リリース後、リクエスト元で行われるキャッシュ処理をエンドポイント単位で無効化していき、段階的に2重キャッシュ状態を解消しました。これによりパフォーマンスとしてはレイテンシが数十ミリ秒減少し、大きな効果が見られました。 分散していたキャッシュの統合 パフォーマンス以外にも、ZOZOTOWNではWebとアプリの両方のリクエスト元でキャッシュが分散管理されている状態でしたが、検索APIに統合したことで運用が容易になるといった副次的効果も得られました。 キャッシュの有効期限 キャッシュの有効期限(以下、TTLと呼ぶ)は長ければ長いほどキャッシュヒット率は向上します。一方で、新たな商品やショップが頻繁に追加されるといったオリジンの更新頻度が高い場合には、APIはキャッシュのTTLが切れるまで更新された新しい情報を返せなくなってしまいます。 そこで、API毎に扱うオリジンのデータ更新頻度を調査し、更新頻度に応じた適切なTTLを検討・設定しました。 定められたタイミングでの情報反映 ZOZOTOWNでは、10時や12時などの特定の時間にセールや商品の販売が開始されます。開始直後にこれらの情報を検索結果に反映させるには、意図的にキャッシュを切り替える必要がありました。 例えば、セールの開始時間が12時でキャッシュの有効期限が5分だった場合、11時59分に新たにキャッシュが作成されると、12時04分までセール情報を含まない結果を返してしまいます。 そこで、キャッシュの有効期限とは別にキャッシュキーに有効期間(以下、タイムセクションと呼ぶ)を加えて意図的に特定の時間で切り替えるように工夫しました。 例えば、現在時刻が0分から14分の間であれば、 FROM0TO14 を加えたキャッシュキーを生成します。15分から29分の間であれば FROM15TO29 を加えます。このように15分ごとに異なるキャッシュキーが生成されるようにタイムセクションを付与します。 タイムセクションがあることで11時59分にキャッシュが生成された後、12時に同様のリクエストがあっても生成されるキャッシュキーが異なるためキャッシュミスを誘発できます。結果として開始時刻に合わせて最新の情報を反映したキャッシュに切り替えが可能となります。 キャッシュ導入後の構成 キャッシュ導入後の検索マイクロサービスの構成イメージは下図の通りです。一部詳細は省略しています。 キャッシュストアは 他のマイクロサービスでも導入実績 があったAWS ElastiCacheを採用しました。同様の理由で、キャッシュエンジンはRedisを使用します。 キャッシュ制御の設計 検索APIはSpring Bootフレームワークを使用しており、"アプリケーション層"と"ドメイン層"、"インフラ層"からなる3層アーキテクチャで構成されています。 各層について、以下の通り責務を分割しています。 - "アプリケーション層":クライアントとの入出力とビジネスロジックを繋ぐ。 - "ドメイン層":複数のビジネスロジックを集約。 - "インフラ層":Elasticsearchやその他マイクロサービスとのやり取り。 このアーキテクチャ内にキャッシュ制御をどのように取り入れるか検討しました。 【A案】アプリケーション層とドメイン層の間 【B案】ドメイン層とインフラ層の間 【C案】A案とB案の両方を採用する 結論として、B案を採用しました。以下では、採用に至った経緯について説明します。 【A案】アプリケーション層とドメイン層の間 A案では、リクエスト毎にキャッシュの有無を問い合わせ、ヒットすればキャッシュからデータを返却できます。一方で、キャッシュが存在しない場合はドメイン層へと処理が移り、その後処理の結果を返却すると同時にキャッシュを保存します。 この案を採用した場合、1リクエストあたり1キャッシュの関係となり設計はシンプルになりそうです。しかし、アプリケーション層が複数のドメイン層でやり取りする場合、複数の処理結果が1つのキャッシュに保存されるため肥大化が懸念されました。 【B案】ドメイン層とインフラ層の間 この案を採用した場合、ドメイン層で処理された結果ごとにキャッシュを保存できます。そのため、A案で問題となっていたキャッシュの肥大化を防ぐことが可能です。 例えば、2つの異なるリクエストがあり、一部を同様のビジネスロジックで処理していたとします。B案であれば、ビジネスロジック毎にキャッシュできるため、共通する処理の結果は1つのキャッシュを流用してそれぞれのリクエストに対して返却できます。このようにキャッシュ対象の粒度が小さくなることでA案の課題を解決すると同時にキャッシュミス減少も期待できます。 【C案】A案とB案の両方を採用する この案はB案をベースに複数のビジネスロジックを利用する箇所にはA案を採用する良いところ取り設計になります。 この方法であればより効率的にキャッシュを運用できそうです。しかし、3層を横断した煩雑な設計となり、今後の運用コストが高まると考え採用しませんでした。 キャッシュ導入による効果 検索APIにキャッシュ機構を導入したことで、以下の効果が得られました。 レイテンシの減少 負荷への耐久性の向上 レイテンシの減少 キャッシュ導入によって、ZOZOTOWNが配信されているWebサーバから検索APIへリクエストされた後、レスポンスが返却されるまでのレイテンシが減少しました。 以下の画像は、リリース前後のレイテンシの様子を表しています。 リリースは15時頃行われ、その後レイテンシはリリース前と比較すると、どのパーセンタイルでも減少が見られました。特に、p99では約30%減少と大きな効果が得られました。 このような効果が得られた要因としては、以下が考えられます。 似たような条件で検索される割合が高い リクエストの量が多い 似たような条件で検索される割合が高い というのは、言い換えるとキャッシュのヒット率が高いことを意味します。実際に、弊チームで利用しているモニタリングツールからヒット率を確認すると高い割合で推移していました。 また、検索APIでは常に膨大なリクエストを受けるため、キャッシュが効果的に働いたことも要因として挙げられます。 APIへのリクエスト数が少ない場合 一方で、APIへのリクエスト数が少ない場合、キャッシュ処理が新たに加わったことで実行時間が増加してレイテンシに影響を与える可能性があります。 この場合、解決策の1つとしてAPI毎にキャッシュ適用の有無を切り替えられるような仕組みを導入する方法が考えられます。幸い検索APIでは今のところこの影響を受けませんが、一部APIでキャッシュ処理に不備があったなどの障害対策としてもこの方法は有効であると考え、導入しました。 負荷への耐久性の向上 検索APIがどの程度の負荷に耐えられるか検証するための負荷試験を実施しました。負荷の規模は、年間で一番大きい正月セールを想定しました。このセールでは、通常時の数倍のリクエストが発生します。 試験の結果、キャッシュ未使用時と比べてレイテンシが大幅に減少し、負荷への耐久性の向上が確認できました。また、一部APIではp50が約60%減少するなど大きな効果が得られました。 工夫した取り組み キャッシュ機構を導入する上で工夫した取り組みについて、いくつか紹介します。 API毎にTTLを個別設定 キャッシュの有効期限 で述べた通り、API毎にオリジンのデータ更新頻度が異なるため、TTLを環境変数上で個別設定できるようにしました。 application.yaml に記述したTTLの例を紹介します。 ttl : default-millis : ${REDIS_TTL_DEFAULT_MILLIS:60000} api-1-mills : ${REDIS_TTL_API_1_MILLIS:600000} api-2-mills : ${REDIS_TTL_API_2_MILLIS:3600000} default-millis は、TTLの初期値を表しています。単位はミリ秒なので 60000 は1分を意味します。次に、 api-1-mills や api-2-mills はAPI毎のTTLの設定になります。 これらの値は、キャッシュ機構を導入した後に延長対応するなど、キャッシュストアの状況やヒット率を鑑みて最適化を図っています。 キャッシュキーのハッシュ化 キャッシュのキーが長すぎると、メモリ消費やキャッシュ検索の観点で良くないとされています。詳しくは、 Redis公式ドキュメント をご参照ください。 そこで以下の実装の通り、MD5を利用してキャッシュキーのハッシュ化を行い、常に固定長となるようにしました。 import org.springframework.util.DigestUtils; /* 中略 */ String beforeHsahedKey = "/v1/search/sample_param1&param2_FROM0TO29" ; String hashedKey = DigestUtils.md5DigestAsHex(beforeHsahedKey.getBytes(StandardCharsets.UTF_8)); // hashedKey = ea7da06d698ebb8beb84c230e84d698f 実装では、 DigestUtils のクラスの md5DigestAsHex メソッドを利用してハッシュ値を16進数で表現しています。 この例では、 beforeHsahedKey の値をハッシュ化して ea7da06d698ebb8beb84c230e84d698f が得られました。 beforeHsahedKey の値が大きくなっても常に32文字に抑えられます。 キャッシュの圧縮 検索APIは数百件の商品結果やショップ情報など大きなデータを頻繁に扱います。また、リクエスト量も常に多いため、必然的にキャッシュのメモリ消費が問題となりえます。そこで、キャッシュをgzipによって圧縮する仕組みを構築して、この問題を解決しました。 キャッシュ圧縮や解凍の実現方法 次に、実現方法について説明します。通常、Spring Bootではキャッシュを扱う場合、データをシリアライズ化した後にキャッシュとして保存します。一方で、キャッシュからデータを返却する場合、保存されたデータに対してデシリアライズ化します。この中でキャッシュの圧縮や解凍を実現するには、データをシリアライズ化した後に圧縮し、デシリアライズ化する前に解凍処理を行う必要があります。 まとめると、キャッシュ圧縮までの大まかな流れは以下の通りです。 対象オブジェクトをシリアライズ化 gzipを使ってシリアライズ化されたデータを圧縮 圧縮したデータを返却 解凍については、圧縮の反対の流れになります。 圧縮されたデータを解凍 解凍したデータをデシリアライズ化 デシリアライズ化したデータを返却 実装 これらを実装すると以下のようになります。また、実装は こちらのサイト を参考にしました。 import org.apache.commons.io.IOUtils; import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import javax.annotation.Nonnull; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; @Component public class RedisCacheGzipSerializer extends JdkSerializationRedisSerializer { private final CacheParameter cacheParameter; public RedisCacheGzipSerializer(CacheParameter cacheParameter) { this .cacheParameter = cacheParameter; } @Override public Object deserialize( byte [] bytes) { if (cacheParameter.isGzipEnabled()) { return super .deserialize(decompress(bytes)); } else { return super .deserialize(bytes); } } @Override public byte [] serialize(Object object) { if (cacheParameter.isGzipEnabled()) { return compress( super .serialize(object)); } else { return super .serialize(object); } } @Nonnull private byte [] compress( @Nullable byte [] content) { if (content == null ) { return new byte [ 0 ]; } ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream)) { gzipOutputStream.write(content); } catch (IOException ex) { throw new SerializationException( "Unable to compress data" , ex); } return byteArrayOutputStream.toByteArray(); } @Nullable private byte [] decompress( @Nullable byte [] contentBytes) { if (contentBytes == null || contentBytes.length == 0 ) { return null ; } ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); try { IOUtils.copy( new GZIPInputStream( new ByteArrayInputStream(contentBytes)), byteArrayOutputStream); } catch (IOException ex) { throw new SerializationException( "Unable to decompress data" , ex); } return byteArrayOutputStream.toByteArray(); } } 実装では、 対象オブジェクトをシリアライズ化 や 解凍したデータをデシリアライズ化 などの圧縮解凍に関係ない処理については、既存のキャッシュ処理で用いられていたものを流用します。つまり、既存のクラス JdkSerializationRedisSerializer が提供する serialize や deserialize メソッドを利用しています。今回作成した RedisCacheGzipSerializer クラスでは、これらのメソッドをラップして圧縮処理を行う compress や解凍処理を行う decompress メソッドを追加しています。 注意点として、参考にしたサイトのコードでは decompress メソッドの引数 contentBytes がNullだった場合に問題が起きました。 具体的には、 new ByteArrayInputStream(contentBytes) で NullPointException が発生したので、これを回避する処理を追加しています。 if (contentBytes == null || contentBytes.length == 0 ) { return null ; } また、 decompress メソッドでは圧縮されたデータの解凍と、解凍されたデータをバイト配列へ書き込む処理を IOUtils クラスが提供する copy メソッドで実装しています。このメソッドを利用することで、煩雑化しやすい InputStream から読み込んだデータを OutputStream に書き込む一連の処理を簡略化できます。 このクラスを利用するために、以下の依存関係を追加しています。 <!-- https://mvnrepository.com/artifact/commons-io/commons-io --> <dependency> <groupId> commons-io </groupId> <artifactId> commons-io </artifactId> <version> 2.11.0 </version> </dependency> 今回、作成した RedisCacheGzipSerializer は RedisTemplate で呼び出しました。 @Bean public RedisTemplate<String, Object> redisTemplate( RedisCacheGzipSerializer redisCacheGzipSerializer) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); /* 中略 */ redisTemplate.setValueSerializer(redisCacheGzipSerializer); /* 中略 */ return redisTemplate; } 使用するSpring BootのRedisプロバイダーによって呼び出し元は異なるので、適宜 JdkSerializationRedisSerializer から置き換えください。 キャッシュ圧縮による効果 キャッシュ圧縮によって、以下の効果が得られました。 メモリ使用率の減少 ネットワーク通信量の減少 gzipの圧縮率は、データや使用するアルゴリズムにより異なりますが60%から80%ほどです。メモリ使用率も同程度の減少が期待出来ます。事実、リリース後のメモリ使用率は以下の通り大幅な減少が見られました。 また、キャッシュの保存や参照時に発生するネットワーク通信量もキャッシュ圧縮に伴って、メモリ使用率と同程度の減少が見られました。 一方で、キャッシュ圧縮処理が加わったことによるレイテンシやCPU使用率の増加を懸念していました。レイテンシについては、前述したネットワーク通信量の減少によって相殺されるため問題になりませんでした。CPU使用率についても同様にリリース前後と比較して変化は見られませんでした。 まとめ これまでZOZOTOWNの検索機能を提供するマイクロサービスでは、ABテストの実施や負荷の面で課題がありました。本記事では、これらの課題を解決する方法の内の1つとして、マイクロサービスへのキャッシュ導入事例を紹介しました。キャッシュの導入により、課題が解決されただけではなく、レイテンシが大幅に減少するなど大きな改善が得られました。 また、「キャッシュの圧縮」や「API毎にTTLを設定」などの工夫もいくつか紹介しました。これらを取り入れることで、効率的なキャッシュの運用が可能になりました。 その他、効果としてAPIのレイテンシをより意識するようになりました。数ミリ秒を改善するため想定したレイテンシに至らなかった場合はTTLを見直して、最も効果が得られそうな値を模索します。その過程で、レイテンシやヒット率といった指標も以前に比べて確認する機会が増えました。 ZOZOTOWNの検索機能はユーザにとって「求める商品を見つける」ための重要な機能です。日々多くのユーザが利用するため、リクエスト数は膨大です。ほんの少しの改善により大きな効果を得られる可能性があります。キャッシュの導入によって大きな効果が得られましたが、まだまだ工夫の余地は残っています。今後も、より高速な検索をユーザへ提供するために改善を続けたいと思います。 おわりに ZOZOでは、検索機能を開発・改善していきたいエンジニアを全国から募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
アバター
こんにちは、FAANS部の田中です。普段は、WebのフロントエンドエンジニアとしてFAANSの開発を行なっています。 FAANSの由来は「Fashion Advisors are Neighbors」で、「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」です。現在正式リリースに向けて、 WEAR と連携したコーディネート投稿機能やその成果を確認できる機能など開発中です。プラットフォームとしてはWeb、iOS、Androidが存在し、今回取り上げるWebはショップ店長をはじめとした管理者向けツールという立ち位置です。 本記事では、FAANSのWebにおけるStorybook × MSW × Chromaticを使ったUIの影響範囲を自動検知するための取り組みを紹介します。 はじめに FAANSのWebはReact、TypeScriptで構成されています。設計に関しては、ロジックとビューの責務を分けるためにContainer Presenterパターンを採用しています。ContainerでAPIのレスポンスやユーザー情報などの共通の状態をContextから取得してPresenterに注入し、ページを表示させています。 FAANSのWebの課題 FAANSのWebでは以下の2つの課題がありました。 各状態ごとのUIを把握しきれない。 デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない。 それぞれの課題に対して取り組んできたことについて紹介します。 1. 各状態ごとのUIを把握しきれない プロジェクトの振り返り会にて、UI改修が頻繁に発生するプロジェクトの現状において、「各状態ごとのUIを把握しきれない」という課題が挙がりました。具体的には、一覧取得APIがエラーレスポンスを返した場合のUIという具合です。 また、FAANSにはショップスタッフやショップ店長のような権限が存在します。権限によって機能が異なり、権限ごとのページのUIも把握しづらい状況でもありました。 その問題を解決するために、 Storybook を導入しました。Storybookとは独立してUIコンポーネントを管理できるツールで、それを使って各状態ごとのUIを管理して一覧表示しようと試みました。 そのための設計として、以下の図のようにPages Presenterを作成し、その中でTemplates、Organisms、Molecules、Atoms層のコンポーネントを呼ぶ設計にしていました。これは、UI設計のメンタルモデルである Atomic Design を参考にしています。 Storybook上で各storyごと、Pages Presenterのpropsに必要な値を注入して、その値に応じたページのUIを表示させていました。 ただこの「Pages Presenterに値を注入する」方法だと以下のような壁がありました。 Storybookで各状態のページのUIを表示させるためにContainerからPages Presenterに注入している値をすべて用意する必要がある 各Pages Presenterにヘッダーやフッターなどを含むTempletesを都度定義する必要があるので冗長 Mock Service WorkerとMSW Storybook Addonについて そこで、それらの壁を乗り越えるために Mock Service Worker (以下、MSW)と MSW Storybook Addon を導入しました。 MSWとはネットワークレベルでAPIのリクエストをインターセプトしてmockのレスポンスを返すライブラリです。 以下のように、handlerでAPIのパスとmockのレスポンスを定義すれば、その定義したパスにリクエストが送られて来たタイミングでmockレスポンスを返すことができます。 // handler export const handlers = { mockGetMyClosetItemsForWear: rest.get( ` ${process.env.REACT_MSW_DOMAIN} /v1/wear/members/@me/coordinate_items` , mockGetMyClosetItemsForWear, ), } ; また、MSW Storybook Addonとはstory単位でMSWのmockのレスポンスを定義できるライブラリです。 以下のように、storyに定義すれば、MSWのhandlerで定義されているmockレスポンスと別のレスポンスを返すことが可能です。 // Storybook export const Test: Story = { parameters: { msw: { handlers: { mockGetMyClosetItemsForWear: rest. get( ` ${process .env.REACT_MSW_DOMAIN } /v1/wear/members/@me/coordinate_items` , ( req , res , ctx ) => { return res ( ctx. status( 500 ), ctx.json ( '' )); } , ), } , } , } , } ; MSWとMSW Storybook Addonの導入した結果 storyごとにmockのレスポンスを定義して、そのレスポンスに応じたページのUIをStorybook上で一覧表示させることができました。「Pages Presenterに値を注入する」方法とは違い、必要なのはmockのレスポンスになるため、Containerの値をすべてを用意する必要はなくなりました。また、各Pages Presenterにヘッダーやフッターなどを含むTempletesを都度定義する必要はなくなり、TemplatesをRouter側に書くことができました。 一部抜粋したStorybookのソースコードとしては以下のようになります。 1 // Storybook 2 const BaseStory = () => { 3 return ( 4 < MemoryRouter 5 initialEntries = {[ '/coordinates/:coordinateId/edit/items/new/edit' ]} 6 > 7 < CoordinateTemplate > 8 < CoordinateProvider initialCoordinate = { initialMockCoordinate } > 9 < CoordinateItemProvider initialCoordinateItem = { initialMockCoordinateItem } > 10 < CoordinateItemEditContainer / > 11 < /CoordinateItemProvider > 12 < /CoordinateProvider > 13 < /CoordinateTemplate > 14 < /MemoryRouter > 15 ); 16 } ; 17 18 export default { 19 title: 'pages/コーデ投稿/着用アイテム登録画面' , 20 component: BaseStory , 21 } ; 22 23 type Story = ComponentStoryObj <typeof BaseStory >; 24 25 export const Case1: Story = { 26 storyName: 27 'モーダルを表示させ、一覧が表示できた(200)場合' , 28 play: async ( { canvasElement } ) => { 29 const canvas = within ( canvasElement ); 30 const coordinateItemOpenModalButton = await canvas.findByTestId ( 31 'coordinateItemOpenModalButton' , 32 ); 33 userEvent.click ( coordinateItemOpenModalButton ); 34 } , 35 } ; 36 37 export const Case2: Story = { 38 storyName: 39 'モーダルを表示させ、一覧が表示できなかった(500)の場合' , 40 parameters: { 41 msw: { 42 handlers: { 43 mockGetMyClosetItemsForWear: rest. get( 44 ` ${process .env.REACT_MSW_DOMAIN } /v1/wear/members/@me/coordinate_items` , 45 ( req , res , ctx ) => { 46 return res ( ctx. status( 500 ), ctx.json ( '' )); 47 } , 48 ), 49 } , 50 } , 51 } , 52 play: async ( { canvasElement } ) => { 53 const canvas = within ( canvasElement ); 54 const coordinateItemOpenModalButton = await canvas.findByTestId ( 55 'coordinateItemOpenModalButton' , 56 ); 57 userEvent.click ( coordinateItemOpenModalButton ); 58 } , 59 } ; MSWのhandlerで /v1/wear/members/@me/coordinate_items のAPIは200レスポンス返すように定義されており、Case1ではその定義通り200を返します。よって、Case1は200を返した場合のUIを確認できます。それに対して、Case2に関しては500を返すように定義しているため、エラーのUIを確認できます。 以下、Case1とCase2のページの表示となります。 実際に動いているWebではAPIが500エラーを返すことは稀なので、このようにUI上で確認が難しいイベントを確認できるのはメリットです。 *上のページはコンポーネント間でグローバルに共有している状態をContextから取得しています。Storybookで表示させるためその状態もmockする必要があります。それはソースコードの8行目と9行目で、Provider経由でmockした状態を注入することで実現しています。 *上のページはあるボタンをクリックしてモーダルを開いた後のページです。これはStorybookのPlay functionの機能を使っており、それを使うとレンダリング後のページに対してイベントを発火させることができます。ソースコードの33行目と57行目の userEvent.click でクリックイベントを発火させてモーダルを開いた後のページを表示しています。 このようにMSWとStorybookを組み合わせることで「各状態ごとのUIを把握しきれない」問題を解決できました。付随して、バックエンド側でSwaggerにリクエストとレスポンスが定義されていれば、MSWがそれを元にmockレスポンスを返すため、APIが実装されていなくても並列で開発ができるメリットもありました。 2. デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない デザインの修正時やライブラリのアップデート時において、UIに影響がでていないか検知する仕組みがありませんでした。 この問題を解決するために Chromatic を利用しました。ChromaticとはStorybook上のUIのコミットごとの差分を取れるツールで、GitHubのようにその差分に対してレビューできたり、StorybookのHosting機能も備わっています。Storybookで作成した多様なUIに対して、変更前と変更後でスナップショットを撮影・差分比較して予期せぬUIの影響がでていないかを確認できます。 具体的な例として、以下の画像の赤枠のように、検索窓が追加したときに他の箇所で影響が出ていないか確認してみましょう。 差分がある箇所は緑で表示されるので、確認する際は緑の箇所だけ注目すれば良くなります。確認したところ、それは検索窓の追加による差分であることが分かります。また、ヘッダーなど別の箇所をみると緑の差分は表示されていないので、検索窓を追加した前と後では予期せぬUIの影響は出ていないことが分かります。 また、同じページでモーダルを開いた後の差分を確認したところ、赤枠の箇所には緑の差分がないので、影響が出ていないのがわかります。 このChromaticの導入によって、「2. デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない」課題を解決できました。 まとめ 「1. 各状態ごとのUIを把握しきれない」課題の解決策は以下の通りです。 MSWでAPIのレスポンスをmockする グローバルな状態はProvider経由でmockした状態を注入する イベント発火後のUIはPlay functionを使う これらの方法によって、Storybook上で多様なページを確認できます。 図で表すと以下の通りです。 また、「2. デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない」課題の解決策は以下の通りです。 1で作成した多様なページに対して、Chromaticを使ってスナップショットを撮影・差分比較する。 この方法によって、UIの影響範囲を検知できます。 さいごに Storybook × MSW × Chromaticを使ったUIの影響範囲を自動検知するための取り組みついて紹介しました。デザインレビューの効率化に興味がある皆さんの参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com hrmos.co
アバター
はじめに こんにちは、 ZOZO NEXT ZOZO Research のSai Htaung Khamです。ZOZO NEXTは、ファッション領域におけるユーザーの課題を想像しテクノロジーの力で解決すること、より多くの人がファッションを楽しめる世界の創造を目指す企業です。 ZOZO NEXTでは多くのアルゴリズムを研究開発しており、その中で JAX というライブラリを使用しています。JAXは高性能な機械学習のために設計されたPythonのライブラリです。NumPyに似ていますが、より強力なライブラリであると考えることができます。NumPyとは異なり、JAXはマルチGPU、マルチTPU、そして機械学習の研究に非常に有用な自動微分(Autograd)をサポートしています。 JAXはNumPyのAPIのほとんどをミラーリングしているので、NumPyライブラリに慣れている人なら非常に導入しやすいです。Autogradを使えば、Pythonのネイティブ関数とNumPyの関数を自動的に微分できます。JAXの詳細な機能については、JAXの公式 GitHub リポジトリを参照してください。 はじめに そもそも、なぜJAXなのか? 本記事を読むことで分かること データって本当に大きいの? いつ、どこで、どうやって処理するの? ボトルネックに要注意! TF Dataってヒーローなの? 処理を高速化してみよう! 環境設定 まとめ そもそも、なぜJAXなのか? 機械学習アルゴリズムを構築する際、多くのMLエンジニアはTensorflowやPyTorchといった信頼性の高いMLフレームワークを利用することでしょう。成熟したMLフレームワークには成熟したエコシステムがあり、本番環境への統合や保守が容易になるため、良い決断です。当研究所では、これらのフレームワークを用いて実装された多くのアルゴリズムを持っています。 しかし、いくつかのアルゴリズムはNumPyライブラリを用いて、純粋なPythonで実装されています。その中には例えば、研究者やMLエンジニアが社内用に設計した埋め込みアルゴリズムがあります。埋め込みアルゴリズムは類似商品を効率よく抽出できるため、商品推薦などに有用です。実装がPythonであるため、このアルゴリズムは計算の実行時間にボトルネックがあります。お気づきのように、フレームワークを使用しない場合、パラメータの更新やモデルのダンプ等も自前で実装する必要があります。そのため、新しいアイデアをすぐに試すことが難しく、なかなか前に進めません。また、ライブラリや学習プロセスもCPUデバイスに限定されるため、拡張性がありません。共有メモリアーキテクチャを利用してマルチプロセスでアルゴリズムを実行できましたが、GPUやTPUなどの複数のホストやデバイスで実行し、垂直方向・水平方向にスケールできる状態が望ましいです。 そこで、拡張性・保守性の高い別のフレームワークにプログラムを移植する方法を検討した結果、以下のような特徴を持つJAXを採用しました。 Single Program Multiple Dataアーキテクチャによる水平方向のスケーラビリティ NumPyのAPIをミラーリング Pythonに対応 Autogradのサポート エコシステムまでオープンソース化されている(FlaxやHaikuなど) 特に(2)の性質によってNumPyで書かれたアルゴリズムを効率よく移植できるという点が、既存の他のフレームワークにはない利点でした。 本記事を読むことで分かること 本記事では、実世界のデータを使った機械学習を、JAXライブラリで実行する方法について説明します。通常、機械学習の理論を学び問題を解くときには、理解を深めるために小さなデータを使用します。しかし、実世界のデータに応用するとデータ量、モデルを格納するメモリサイズ、学習と評価のスケーラビリティなど多くの困難に直面することになります。ありがたいことに、現代のクラウドコンピューティングの革命と価格設定により、スケーラブルな機械学習は誰でも利用できるようになりました。 典型的な機械学習プロジェクトはデータの準備からモデルのサービングまで多くのステージで構成されますが、本記事で取り扱うのはデータの準備とモデルの学習に当たる部分です。特にJAXライブラリのパフォーマンスと、クラウドコンピューティング上でのスケーラビリティを実現する方法について説明します。 データって本当に大きいの? いつ、どこで、どうやって処理するの? データと質の高いデータ変換が機械学習プロジェクトの成功の中心であることは、すべてのMLエンジニアが理解していることです。現実の機械学習プロジェクトでは、1台のマシンでETL(抽出、変換、ロード)プロセスを行えるような量のデータを扱うことは稀です。当研究所では、Google CloudやAWSなどのクラウドコンピューティングリソースに広く依存しており、通常、クラウドストレージやクラウドデータウェアハウスを使用してデータを管理しています。 ボトルネックに要注意! クラウドストレージは、1台のマシンに収まりきらない大量のデータを保存するのにとても役立ちます。しかし、モデルの学習に利用するためには、ストレージからデータを読み出す効率的な方法を見つける必要があります。多くのMLエンジニアがGPUデバイスを使った学習中に遭遇する問題の1つは、GPUデバイスが十分に活用し切れず、学習プロセスに必要以上の時間がかかってしまうことです。次のTensorFlowモデルのプロファイリング結果をご覧ください。 参照:[ モデルのプロファイリング ] よく観察すると、ディスクからデータを取得している間、GPUデバイスはほとんどの時間、アイドル状態であることに気づかれると思います。一般的には、学習中はGPUデバイスをビジー状態にしたいものです。これは、データ入力パイプラインにボトルネックがあることを示しています。 TF Dataってヒーローなの? データ入力パイプラインのボトルネックを解消するために、TF DataというTensorFlowが提供する便利なツールを利用することにします。従来の方法では、下図のようにディスクからデータを順次読み込んでいました。下図のMapは、正規化、画像補強などのデータの変換処理です。 参照:[ モデルへのデータの順次取り込み ] しかし、この方法では学習処理にデータ転送待ちが発生し、GPUデバイスがアイドル状態になってしまうというボトルネックが発生しています。そこで下図のように読み込みとデータ変換を並列に行うことで、学習の待ち時間が少なくなります。 参照:[ TF Data Pipelineで効率的なデータ変換 ] TFデータパイプラインのコンポーネントは再利用可能です。トレーニングやサービングフェーズに適用できます。TF DataライブラリはホストCPUを利用してデータを並列に処理しているので、CPUの性能が高ければ高いほど、データの読み込みや前処理が高速になることを念頭に置いておくことが重要です。 データ前処理パイプラインとして、Apache BeamやTFX Transformを使用する方法もありますが、今回は説明しません。本記事では、TF DataとJAXを使用して、スケーラブルな機械学習を共有します。 処理を高速化してみよう! 効果的なデータ前処理パイプラインを手に入れたことで、モデルの学習と評価のステップに移行します。JAXの便利なライブラリにvmapとpmapがあります。本記事では、vmapとpmapを使用してマルチGPUデバイスでの学習処理を高速化します。 #vmapによるauto-vectorization import numpy as np import jax.numpy as jnp import jax def convolve (x, w): output = [] for i in range ( 1 , len (x)- 1 ): output.append(jnp.dot(x[i- 1 :i+ 2 ], w)) return jnp.array(output) x = np.arange( 5 ) w = np.array([ 3. , 1. , 3. ]) batch_size = 10 xs = np.arange( 5 * batch_size).reshape(- 1 , 5 ) ws = np.stack([w] * batch_size) print (f "The shape of the x and w : {xs.shape, ws.shape}" ) print ( "Process each sample." ) for sample in xs: print (convolve(sample, w)) print ( "Auto-vectorization with vmap:" ) print (jax.vmap(convolve)(xs, ws)) #vmap処理とサンプル単位処理の比較結果 The shape of the x and w : ((10, 5), (10, 3)) Process each sample. [ 7. 14. 21.] [42. 49. 56.] [77. 84. 91.] [112. 119. 126.] [147. 154. 161.] [182. 189. 196.] [217. 224. 231.] [252. 259. 266.] [287. 294. 301.] [322. 329. 336.] Auto-vectorization with vmap: [[ 7. 14. 21.] [ 42. 49. 56.] [ 77. 84. 91.] [112. 119. 126.] [147. 154. 161.] [182. 189. 196.] [217. 224. 231.] [252. 259. 266.] [287. 294. 301.] [322. 329. 336.]] まずはvmapに関して説明します。vmapはコードを変更することなく関数をベクトル化(auto-vectorization)するものです。auto-vectorizationにより、vmap APIで関数をラップする以外にコードを変更することなく処理を高速化できます。これは、特にバッチ処理の際に非常に便利です。vmapの機能はまだまだあるので、以下のリンクから確認してください。 jax.readthedocs.io pmapの使い方は、vmapとよく似ています。しかし、pmapはMPIのようなCollective operationを提供し、プログラムが複数のデバイス上で通信しデバイスをまたいで合計や平均などの演算「MapReduce」を実行できます。このAPIにより、プログラムはスケールアウトできます。 #マルチデバイスでpmapを適用する @ partial (jax.pmap, axis_name= "num_devices" ) def update (params: Params, x: jnp.ndarray, y: jnp.ndarray) -> Tuple[Params, jnp.ndarray]: loss, grads = jax.value_and_grad(loss_func)(params, x, y) grads = jax.lax.pmean(grads, axis_name= "num_devices" ) loss = jax.lax.pmean(loss, axis_name= "num_devices" ) new_params = jax.tree_multimap( lambda param, g: param - g * step_size, params, grads ) return new_params, loss 上記のコードサンプルでは、異なるデバイスでloss_func関数から返された結果に対してCollective meanを実行し、パラメータを更新しています。このコードブロックは、アクセラレータデバイスの数を気にすることなく、どのマシン上でも実行できます。バックグラウンドでJAXによって自動的にスケールアウトし、管理されます。ただし、アクセラレータデバイスの数に応じて、デバイスの次元を一致させる必要があります。デバイスの次元とは、デバイス間でデータを均等に分割するために使用される仮想的なメトリックディメンジョンのことです。例えば、8台のデバイスがある場合、同時に処理するサンプルは少なくとも8個必要です。 環境設定 本記事では、JAXライブラリを用いて2つのデータセットを検証します。1つ目はMNISTの手書き数字データセット、2つ目はカスタムデータセットです。まずはMNIST手書き数字データセットのためのシンプルな多層パーセプトロン(MLP)を構築しました。 以下の図は使用したインフラ設定です。 MLPのハイパーパラメータ設定です。 以下の図はアクセラレータ毎の平均実行の時間、異なるアクセラレータでのアルゴリズム実行時間の比較です。 JAXが提供するpmap APIを使えば、簡単に複数のデバイスでモデルを実行し、学習と配信のためにスケールアウトさせることができました。CPUでは各エポックに約3.34秒かかるのに対し、4GPUでは1.09秒であることが確認されました。この図は、より多くの並列処理を行うほど、この特定のアルゴリズムの実行時間が短縮することを示しています。 以下の4GPUでの学習と各エポックでの実行時間図は、4つのGPUアクセラレータを用いた場合の、各エポックにおけるモデル学習と実行時間の性能を示しています。 上の図から、モデルはトレーニングデータセットでうまく学習し、バリデーションデータセットでもうまくいっていることが確認できました。また、学習処理は4つのGPUデバイス全てに均等に分散されています。最初のエポックを終えるのに約1.8秒、その後のエポックでは約1.09秒かかっています(左下図)。最初のエポックでは、クラウドストレージから画像をリモートで読み込んで、パイプラインデータ変換に応じた前処理を行う必要があります。その後、パイプラインのキャッシュ機能を使ってデータをローカルにキャッシュし、次のエポックに備えることで、実行時間を大幅に短縮しています。 GPU使用率の観点から、GPUは最初からビジー状態であり、トレーニングの最初のエポックの終わりである1.87秒付近にいくつかのピークあることがわかりました(右下図)。これは、GPU(特にgpu_2)がパイプラインからのデータロードと変換同時にいくつかの処理を保持していることを物語っています。データパイプラインがリモートストレージデバイスからデータをロードするのと並行して、学習処理が開始されていることがわかります。GPUデバイスは約40%のピークにあり、すべてのGPUを100%利用するにはMLPのレイヤーがかなり小さいので妥当なところです。 私たちが発見した興味深い事実は、クラウドストレージの場所は私たちのアルゴリズムをホストしているマシンと異なる場合、リモートデータ取得により最初のエポックに余分な時間が追加されるということです。これは通常、クラウドインフラの仕様で、ユーザーがアクセスするエッジロケーションにデータをダウンロードする必要があるためです。ホストマシンから初めてアクセスした後、データはエッジロケーションに保存され、トレーニングの実行時間が大幅に改善されます。 以下の図はトレーニングマシンとクラウドストレージが異なる地域または場所にある場合の結果です。 最初のアクセス後、エッジロケーションのキャッシュにより、ランタイムが改善されました。 計算量が大きいほどGPUのデバイス使用率が高くなることを検証するため、より大きなMLPレイヤーと大きな画像でテストを行いました。ハードウェアの仕様は、前回のMNIST手書きデータセットでの実験と同じにしています。 以下の図は各エポックにおける異なるアクセラレータのアルゴリズム実行時間の比較です。 このシナリオでは、シングルGPUでの学習が最高のランタイムパフォーマンスをもたらすことが観察され、興味深いです。シングルGPUでは、CPUよりも約12倍高速になります。この特定のデータセットとマルチGPUデバイスの場合、マルチデバイスで実行する際のMap-Reduce操作のオーバーヘッドが原因だと思われます。 予想通り、層数と中間素子数が増えれば増えるほど、計算負荷が大きくなります。最初のエポック(0-28秒)の間(右下図)、ホスト上で起きているデータの前処理とGPU上のトレーニングステップが同時に実行されていることが観察されます。もちろん、MLPのレイヤーに生のピクセルを入力しているため、モデルの学習にはあまり期待できません。より良い結果を得るためには、畳み込みニューラルネットワークを使用することが望ましいでしょう。 まとめ 結論として、並列処理とキャッシュを備えたTF Dataライブラリを使用することでGPUデバイスのポテンシャルを引き出し、より高速な学習が可能になることが確認できました。GoogleやAWSのような大手ベンダーのクラウドストレージにデータを保存しつつ、データ取得を高速に行うことを説明しました。TF Dataではクラウドストレージだけでなく、BigQueryやBigtableなどからもリモートでデータを読み込むことができます。詳しい使い方はドキュメントをご覧ください。 また、マルチデバイス処理に関するvmapやpmapなど、JAXの便利な機能のデモンストレーションをしました。JAXは、NumPyのAPIのほとんどがJAXでミラーリングされているため、NumPyに慣れている人であれば簡単に使用できます。さらに、Autogradは、Pythonのネイティブ関数とNumPyの関数の微分を自動化できます。pmapの使用に適合するようにプログラムを開発すれば、JAXがバックグラウンド処理を引き受けてくれるのでCPUやマルチGPUデバイスに関係なくどこでもこのプログラムを実行できます。 私見ですが、JAXは非常に柔軟な使い方ができ、PoCを素早く行うためのコーディングが容易です。しかし、機械学習アルゴリズムを複数のプラットフォームで運用することを目指すのであれば、JAXはまだ成熟していないと言えます。TensorflowやPyTorchのような、強力なエコシステムを持ち、広く採用されている他のフレームワークに目を向けたほうがよいと思います。 さらに、JAXによるスケーラブルなインフラを実証するため、シンプルなMLPアルゴリズムを採用しました。JAXの複雑で高度なモデルを使って、MLの問題を解決出来ます。コードを入れ替えるだけ、本記事で取り上げたことはほとんど同じです。私は、FlaxやHaikuのような深層学習用のJAXフレームワークを使用することをお勧めします。JAXの公式GitHubリポジトリのチェックを忘れないでください。JAXを使ったハンズオンを楽しんでください。 本記事をシンプルにするため、コードブロックでの説明を省略し、すべて Jupyter Notebook にまとめました。ぜひご覧ください。 ZOZO NEXTでは、機械学習を適切に使用して課題を解決できるMLエンジニアを募集しています。今回は、JAXを使ってレガシーアルゴリズムを改善した方法を紹介しました。MLアルゴリズムをファッションビジネスへ応用することに興味がある方は、以下のリンクからぜひご応募ください。 hrmos.co hrmos.co hrmos.co
アバター
こんにちは。SRE部の川崎( @yokawasa )、巣立( @tmrekk_ )です。私たちは、ZOZOTOWNのサイト信頼性を高めるべく日々さまざまな施策に取り組んでおり、その中の1つに負荷試験やその効率化・自動化があります。本記事では、私たちが負荷試験で抱えていた課題解決のために開発、公開したOSSツール、Gatling Operatorを紹介します。 github.com はじめに ZOZOTOWNは非常にピーク性のあるECシステムであることから、常にそのシステムが受けうる負荷の最大値を意識しております。想定しうる最大規模の負荷を受けてもユーザー体験を損なうことなくサービス継続できることをプロダクションリリースの必須条件としています。したがって、新規リリースやアップデート、大規模セールなどのシステム負荷に影響を与えうるイベント前など、比較的頻繁に負荷試験を実施しています。そして、社内でもっとも利用実績のある負荷試験ツールが Gatling になります。 Gatlingとは、Webアプリケーション向けのOSS負荷試験フレームワークです。テストシナリオをScala( Gatling 3.7 からはJavaやKotlinもサポート)のDSLで記述でき、結果レポートをHTMLで自動生成してくれます。 本記事で紹介するGatling Operatorは、このGatlingをベースとした分散負荷試験のライフサイクルを自動化するKubernetes Operatorです。 Kubernetes Operatorとは、カスタムリソース(以下、CR)とそのCRにリンクされたカスタムコントローラーによりKubernetesを拡張するための仕組みであり、Kubernetes上で稼働するワークロードのライフサイクル管理の自動化を可能にします。ワークロードの目的の状態を定義したCRをクラスターにデプロイすると、カスタムコントローラーが 制御ループ を通じてその目的の状態に近づくように制御します。 Gatling Operatorの場合は、分散負荷試験の内容を定義したGatling CRがクラスターにデプロイされると、Gatling CRにリンクされたコントローラーが目的の状態に近づくように制御することで一連の分散負荷試験のタスクが自動化されます。 なぜ開発したのか? 開発の発端は、ZOZOTOWN冬セール対策の負荷試験における課題感からでした。 冬セールはZOZOTOWNにおいて一年でもっともユーザーアクセスが多いイベントです。これを安定的に乗り越えるべく、2021年冬セールから事前にオンプレ・クラウドを横断した大規模な負荷試験を本番相当の環境を使って実施しております。この負荷試験は、機能ごとの単体の負荷試験ではなくユーザー導線に合わせてZOZOTOWNにセール同等のトラフィックを再現し、ボトルネックとなりうる箇所を事前に潰すことを目的としています。なお、今年の2022年冬セール向け負荷試験の詳細については別記事にて紹介される予定です。 さて、この冬セール向けの負荷試験ですが、当然ながら目標スループットを再現するためにはGatling実行用ノードを大量に並べて並列実行させる必要があります。これが単一システムの負荷試験であれば、試験用にチューニングされた一台の仮想マシンからの実行で事足りることが多く、多くても数台並べてタイミングを合わせて実行することで目標スループットを再現できます。ただし、冬セール規模となればそうも行かず、大量のGatling実行用ノードの準備、大量ノードからの実行タイミング調整やレポート生成などさまざまな運用面での課題感がありました。 そこで、2021年冬セール向けの負荷試験では、運用面での課題感を解決すべくAmazon ECSからAWS Fargateをデータプレーンとして利用する方式を採用しました。そこに大量のGatling実行用ノードを並べて分散負荷試験の実行やレポート生成などを自動化しました。これにより当初感じていた運用面での課題はある程度解消されました。ただし、逆にFargateの制約から生ずる課題に直面しました。 Fargateはオンデマンドでコンピューティングリソースを提供する仕組みであり、タスク実行毎にホストリソース確保と準備処理が行われるため、EC2と比べPod起動までの待ち時間が長くなりがちでした。 タスク用に予約可能なvCPUとメモリの選択の幅が狭く、したがって目標スループットを再現するためには必要ノード数が多くなりがちになりました。これによりFargateの同時に実行可能なタスク数の上限に達しやすくなり、目標スループットを安定的に再現できないという課題がありました。なお、当時と比べるとFargateのインスタンスあたりの性能は向上し、同時に実行可能なタスク数の上限も上がっていることから問題は緩和されているといえます。 これらの課題を解消すべく、2022年冬セール対策負荷試験に向けてGatling Operatorを開発することになりました。これにより、分散負荷試験の自動化はもとより、Gatling用Podに柔軟にノードリソースの配分ができるようになりました。また、分散負荷試験がマニフェストで宣言的に定義できるようになったことも大きなメリットといえます。 Gatling Operatorの処理概要 Gatling Operatorの処理概要を簡単に説明します。利用者が分散負荷試験の内容を定義したGatling CR(後述)をクラスターにデプロイすると、カスタムコントローラーにより、次のような一連のタスクが自動実行されます。 Gatling Runner Jobの作成 Gatling Runner Jobは、指定された並列実行数(Parallelism)分のGatling Runner Podを作成します 各Gatling Runner Podでは、Gatlingテストシナリオを実行して、出力された結果レポート用ログファイル(simulation.log)をクラウドストレージにアップロードします。次の「Gatling Runner Podのマルチコンテナー構成」でGatling Runner Podについてさらに詳しく解説します Gatling Reporter Jobの作成(オプショナル) Gatling Runner Jobが完了すると、Gatling Reporter Jobを作成し、そのJobがGatling Reporter Podを作成します Gatling Reporter Podはすでにクラウドストレージにアップロードされた全Pod分の結果レポート用ログファイルをローカルファイルシステムにダウンロードします。そして、すべてのログファイルを元に集約したHTML結果レポートを生成し、それをクラウドストレージにアップロードします 試験結果をメッセージ通知プロバイダーに送信(オプショナル) 前のすべてのステップが完了すると、試験の実行結果をメッセージ通知プロバイダー用Webhookに送信します 関連リソースのクリーンアップ(オプショナル) すべてのステップが完了すると、Gatling CRとその関連リソースであるJobやPodを削除します Gatling Runner Podのマルチコンテナー構成 分散負荷試験のメインワークロードであるGatling Runner Podのコンテナー構成について解説します。 上図のようにGatling Runner Podはマルチコンテナーで構成されています。gatling-runnerによるGatling負荷試験の実行以外に、gatling-waiterとgatling-result-transfererでそれぞれ次のような前処理と後処理が実行されます。 gatling-waiterコンテナー Gatling Runner Jobにより作成される並列実行数(Parallelism)分のすべてのGatling Runner Podが開始されるまで待機します Gatling Runner Podが複数作成される場合、すべてのPodが同じタイミングでスケジューリングされる保証がないため、待機処理によりgatling-runner実行のタイミングを同期させます gatling-runnerコンテナー Gatlingテストシナリオを実行します 生成された結果レポート用ログファイルは共有Volume(emptyDir)に出力します gatling-result-transfererコンテナー gatling-runnerで生成された結果レポート用ログファイルを共有Volumeより読み込み、クラウドストレージにアップロードします gatling-waiterとgatling-runnerはinitコンテナーとして、gatling-result-transferはメインコンテナーとして作成していることが特徴として挙げられます。initコンテナーはPod内でメインコンテナーの前に実行されます。また、initには1つ以上のコンテナーを定義でき、それらは1つずつ順番に実行されます。 なお、結果レポート生成を選択しない場合はgatling-result-transfererによる処理は不要であるため、gatling-waiterがinitコンテナーとして、gatling-runnerがメインコンテナーとして作成されます。 使い方(Quickstart) ここでは、Gatling OperatorのインストールとGatling CRのデプロイ手順を説明します。 事前準備 kubectl と kind のインストール gatling-operator リポジトリのクローン クラスターの構築 今回使用するクラスターはkindを使って構築します。まずは、kindを使ってクラスターを構築します。 なお、kindで構築するクラスターは、1.18以上を推奨します。また、kindで使用するNodeのImageバージョンは リリースノート から確認できます。 $ kind create cluster $ kubectl config current-context kind-kind Gatling Operatorのインストール kindで構築したクラスターにGatling Operatorをインストールします。 $ kubectl apply -f https://github.com/st-tech/gatling-operator/releases/download/v0. 5 . 0 /gatling-operator.yaml namespace/gatling-system created customresourcedefinition.apiextensions.k8s.io/gatlings.gatling-operator.tech.zozo.com created serviceaccount/gatling-operator-controller-manager created role.rbac.authorization.k8s.io/gatling-operator-leader-election-role created clusterrole.rbac.authorization.k8s.io/gatling-operator-manager-role created rolebinding.rbac.authorization.k8s.io/gatling-operator-leader-election-rolebinding created clusterrolebinding.rbac.authorization.k8s.io/gatling-operator-manager-rolebinding created deployment.apps/gatling-operator-controller-manager created 以上を実行することにより、CRDやManagerなどのリソースがデプロイされGatling CRを実行する準備ができます。 $ kubectl get crd NAME CREATED AT gatlings.gatling-operator.tech.zozo.com 2022-02-02T06:00:25Z $ kubectl get deploy -n gatling-system NAME READY UP-TO-DATE AVAILABLE AGE gatling-operator-controller-manager 1 / 1 1 1 44s 今回はv0.5.0のマニフェストを使用しています。必要に応じてバージョンを変更してください。なお、バージョンはリリース一覧ページより確認できます。 github.com Gatling CRのデプロイ 続いて、Gatling CRをデプロイします。 ここでは、gatling-operatorリポジトリの サンプル を使用します。 $ git clone https://github.com/st-tech/gatling-operator.git $ cd gatling-operator $ kustomize build config/samples | kubectl apply -f - serviceaccount/gatling-operator-worker unchanged role.rbac.authorization.k8s.io/pod-reader unchanged rolebinding.rbac.authorization.k8s.io/read-pods configured secret/gatling-notification-slack-secrets unchanged gatling.gatling-operator.tech.zozo.com/gatling-sample01 created 上記を実行することでGatling Runner Podの実行に必要なServiceAccountやGatling CRがデプロイされます。 Gatling CRのデプロイ後、Gatling CR、Gatling Runner Job、Gatling Runner Podが生成され、Gatlingテストシナリオが実行されます。 $ kubectl get gatling NAME AGE gatling-sample01 16s $ kubectl get job NAME COMPLETIONS DURATION AGE gatling-sample01-runner 0/3 19s 19s $ kubectl get pod NAME READY STATUS RESTARTS AGE gatling-sample01-runner-4dk6z 0/1 PodInitializing 0 22s gatling-sample01-runner-nlxcm 0/1 PodInitializing 0 22s gatling-sample01-runner-zdqgq 0/1 PodInitializing 0 22s PodのログからもGatlingが実行されていることが確認できます。 $ kubectl logs gatling-sample01-runner-4dk6z -c gatling-runner -f Wait until 2022-02-03 09:00:12 GATLING_HOME is set to /opt/gatling Simulation MyBasicSimulation started... ================================================================================ 2022-02-03 09:01:42 5s elapsed ---- Requests ------------------------------------------------------------------ > Global (OK=2 KO=0 ) > request_1 (OK=1 KO=0 ) > request_1 Redirect 1 (OK=1 KO=0 ) ---- Scenario Name ------------------------------------------------------------- [--------------------------------------------------------------------------] 0% waiting: 0 / active: 1 / done: 0 ================================================================================ ================================================================================ 2022-02-03 09:01:47 10s elapsed ---- Requests ------------------------------------------------------------------ > Global (OK=3 KO=0 ) > request_1 (OK=1 KO=0 ) > request_1 Redirect 1 (OK=1 KO=0 ) > request_2 (OK=1 KO=0 ) ---- Scenario Name ------------------------------------------------------------- [--------------------------------------------------------------------------] 0% waiting: 0 / active: 1 / done: 0 ================================================================================ ================================================================================ 2022-02-03 09:01:51 14s elapsed ---- Requests ------------------------------------------------------------------ > Global (OK=6 KO=0 ) > request_1 (OK=1 KO=0 ) > request_1 Redirect 1 (OK=1 KO=0 ) > request_2 (OK=1 KO=0 ) > request_3 (OK=1 KO=0 ) > request_4 (OK=1 KO=0 ) > request_4 Redirect 1 (OK=1 KO=0 ) ---- Scenario Name ------------------------------------------------------------- [##########################################################################]100% waiting: 0 / active: 0 / done: 1 ================================================================================ Simulation MyBasicSimulation completed in 14 seconds このサンプルではGatlingの結果レポートの通知やクラウドプロバイダーへの結果レポートの保存は行われません。 以降の章で説明する .spec.cloudStorageSpec や .spec.notificationServiceSpec を設定することで可能になります。 設定例の紹介 Gatling CRの設定項目についてサンプルを用いて説明します。なお、Gatlingカスタムリソースの定義についてはこちらの CRDリファレンスページ を参照ください。 Gatling CRについて Gatling CRでは大きく次の5つを定義します。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : ## 実行フラグ generateReport : true notifyReport : true cleanupAfterJobDone : true ## Gatling Runner PodのSpec定義 podSpec : ## 結果レポート格納用のクラウドストレージの定義 cloudStorageSpec : ## 結果レポート通知先の定義 notificationServiceSpec : ## Gatlingテストシナリオと実行方法の定義 testScenarioSpec : 5つの定義について詳しく説明していきます。 実行フラグの設定 Gatling CRでは、Gatlingの実行に関する設定やGatling CRの挙動の設定が可能です。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : generateReport : true generateLocalReport : true notifyReport : true .spec.generateReport ではGatlingの実行結果レポートを生成するかどうかを指定できます。 .spec.generateReport が true の場合、後述する .spec.cloudStorageSpec の設定も必要になります。 .spec.generateLocalReport ではGatlingの実行結果レポートをPod毎に生成するかどうかを指定できます。 .spec.notifyReport ではGatlingの実行結果を通知するかどうかを指定できます。 .spec.notifyReport が true の場合、後述する .spec.notificationServiceSpec の設定も必要になります。 他にも、 .spec.cleanupAfterJobDone ではGatling Operatorが生成するJobの実行完了後の挙動を設定できます。 true の場合、Runner Jobの実行が完了するとGatling CRは削除されます。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : cleanupAfterJobDone : true Gatling Runner Podをカスタマイズする podSpecでは、Runnner Jobが生成するPodの設定が可能です。 podSpecでは、 .spec.serviceAccountName が必須項目となっています。 このサービスアカウントはgatling-waiterコンテナーがGatling実行タイミングの同期目的で他のPodの状態を取得するために必要となります。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : testScenarioSpec : serviceAccountName : "gatling-operator-sa-sample" --- apiVersion : v1 kind : ServiceAccount metadata : name : gatling-operator-sa-sample --- apiVersion : rbac.authorization.k8s.io/v1 kind : Role metadata : name : pod-reader rules : - apiGroups : [ "" ] resources : [ "pods" ] verbs : [ "get" , "list" , "patch" ] --- apiVersion : rbac.authorization.k8s.io/v1 kind : RoleBinding metadata : name : read-pods subjects : - kind : ServiceAccount name : gatling-operator-sa-sample apiGroup : "" roleRef : kind : Role name : pod-reader apiGroup : "" 以下が、 .spec.podSpec の例になります。 .spec.podSpec.serviceAccountName にてさきほどのServiceAccountを指定しています。 他にも、 resources や affinity など標準のPodと同様の設定が可能です。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample01 spec : podSpec : serviceAccountName : "gatling-operator-sa-sample" gatlingImage : ghcr.io/st-tech/gatling:latest rcloneImage : rclone/rclone resources : limits : cpu : "500m" memory : "500Mi" affinity : nodeAffinity : requiredDuringSchedulingIgnoredDuringExecution : nodeSelectorTerms : - matchExpressions : - key : kubernetes.io/os operator : In values : - linux tolerations : - key : "node-type" operator : "Equal" value : "non-kube-system" effect : "NoSchedule" Gatlingテストシナリオと実行方法を設定する testScenatioSpec では、Gatlingを実行する上で必要になるリソースの配置場所や定義方法などの設定が可能です。 .spec.testScenarioSpec.startTime ではGatlingの実行開始時間の設定が可能です。 フォーマットは %Y-%m-%d %H:%M:%S となっており、UTCで設定します。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : testScenarioSpec : startTime : 2022-01-01 12:00:00 .spec.testScenarioSpec.parallelism ではGatlingの同時実行数、すなわちRunner Jobが生成するPod数の設定が可能です。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : testScenarioSpec : parallelism : 4 続いて、Gatlingのテストシナリオ、テストデータ、gatling.confの設定方法について説明します。 以下の2種類のデプロイ方法が用意されています。 Gatlingコンテナーにまとめてパッケージ化してデプロイ ConfigMapとしてデプロイ まず、Gatlingコンテナーにまとめてパッケージ化してデプロイする方法を説明します。 .spec.testScenarioSpec.simulationsDirectoryPath では、Gatlingのテストシナリオのファイルパスの設定が可能です。 設定されていない場合は、デフォルトで /opt/gatling/user-files/simulations が使用されます。 .spec.testScenarioSpec.resourcesDirectoryPath では、テストに使用するデータのファイルパスの設定が可能です。 設定されていない場合は、デフォルトで /opt/gatling/user-files/resources が使用されます。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : testScenarioSpec : simulationsDirectoryPath : "dir-path-to-simulation" resourcesDirectoryPath : "dir-path-to-resources" 上記で設定したデータをGatlingコンテナーにまとめてパッケージ化する方法についてはこちらのGatling Operatorユーザーガイドをご覧ください。 github.com 続いて、GatlingテストシナリオをConfigMapとしてデプロイする方法を説明します。Gatlingが使用するテストシナリオやテストデータ、gatling.confなどをGatling CRのマニフェストに直接記述できます。 .spec.testScenarioSpec.simulationData では、シナリオファイルを記述できます。 .spec.testScenarioSpec.resourceData では、テストデータを記述できます。 .spec.testScenarioSpec.gatlingConf では、gatling.confを記述できます。 ここで記述されたものは、ConfigMapへと変換されControllerによって処理されます。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : testScenarioSpec : simulationData : MyBasicSimulation.scala : | # scalaファイルをここに書く resourceData : sample.csv : | # テストデータをここに書く gatlingConf : gatling.conf : | # gatling.confをここに書く Gatling OperatorリポジトリにGatling CRマニフェストへ直接記述する サンプル も用意されています。 結果レポート格納用クラウドストレージプロバイダーを設定する Gatling OperatorではGatligが生成したレポートを任意のクラウドプロバイダーへ格納できます。 執筆時点では、AWS(S3)・GCP(GCS)に対応しています。 cloudStorageSpec では、格納するクラウドプロバイダーの情報を設定します。 以下の例では、Amazon S3にて ap-northeast-1 の gatling-operator-reports という名前のバケットにレポートを格納します。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : cloudStorageSpec : provider : "aws" bucket : "gatling-operator-reports" region : "ap-northeast-1" なお、レポートの格納には他にもPodからAmazon S3にアクセスできるようにAWSクレデンシャルの設定が必要になります。詳しくは、 ユーザーガイド を参照ください。 通知用メッセージプロバイダーを設定する Gatlingの実行終了後にレポートのリンクと共に通知サービスプロバイダーに通知できます。 notificationServiceSpec にて通知先の設定が可能です。 以下の例では、Slackに完了通知を送信します。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : notificationServiceSpec : provider : "slack" secretName : "gatling-notification-slack-secrets" .spec.notificationServiceSpec.secretName では、通知先であるSlackのWebhook URLが登録されたSecret名を指定します。 apiVersion : v1 data : incoming-webhook-url : # base64-encoded webhook URL for slack kind : Secret metadata : name : gatling-notification-slack-secrets type : Opaque base64暗号化するとはいえWebhook URLをマニフェストに直接記載したくない場合もあります。そのような場合は、AWS Secrets Managerのような外部の秘匿情報を管理できるサービスに保存することを検討ください。 外部サービスに登録した秘匿情報からKubernetesのSecretリソースを生成するツールはいくつかあります。その中の1つの External Secrets の利用例を紹介します。 以下の例では、AWS Secrets Managerにnotification-slack-gatling-noticeという名前でシークレットを作成し、Webhook URLを保存しています。そのSecretをExternal Secretsを経由して取得するようにしています。 apiVersion : "kubernetes-client.io/v1" kind : ExternalSecret metadata : name : gatling-notification-slack-secrets spec : backendType : secretsManager data : - key : notification-slack-gatling-notice name : incoming-webhook-url property : incoming-webhook-url 実際に送られたメッセージがこちらです。 Report URLへアクセスするとGatlingが生成した結果レポートを確認できます。 まとめ 本記事では、Gatlingをベースとした分散負荷試験のライフサイクルを自動化するGatling Operatorを紹介しました。 Gatling Operatorにより、分散負荷試験の自動化を始め、Gatling用ノード選択、マニフェストによる分散負荷試験の宣言的管理が実現可能になりました。 今後は、AWSやGCP以外のクラウドプロバイダーへのレポート送信や、S3などの外部リソースのクリーンアップなどの機能を追加予定です。詳しくは、 Issue をご確認ください。また、Gatling OperatorではIssueやPull Requestを歓迎しています。興味を持った方は、ぜひ使ってみてください。 さいごに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 https://hrmos.co/pages/zozo/jobs/0000010 hrmos.co
アバター
はじめに こんにちは、検索基盤部 検索研究ブロックの真鍋です。ヤフー株式会社から一部出向していて、主にZOZOTOWNの検索機能へのランキングモデルの導入に従事しています。 本記事では、Elasticsearch上でランキングモデルを扱うための有名なプラグインの仕組みと、同プラグインにZOZOが実装した機能を紹介します。 まず、本記事の背景を説明します。ZOZOTOWNでキーワード検索すると、結果の商品が並びます。結果の商品は非常に多数になることも多いので、ユーザ体験を損なわないためには、その並び順も重要です。ここで言うランキングモデルとは、この並び順の決定のために、商品のスコアを計算する式のことを指します。このような式は機械学習によって生成され、非常に複雑になることもあります。そのため、検索エンジンの標準機能では実行できず、プラグインを導入して初めて実行できることもあります。 ZOZOTOWNでは検索エンジンとしてElasticsearchを使用しています。そして、Elasticsearch上でランキングモデルを実行するために、OpenSource Connectionsが提供するLearning to Rank pluginを使用しています。以下、このプラグインを指して、単に本プラグインと呼びます。 github.com 本記事の前半では、本プラグインの仕組みを説明します。まずランキングモデルを実行する仕組みを紹介し、次にランキングモデルを学習するための特徴量の値を出力する仕組みを紹介します。後半では、ZOZOが本プラグインに実装した、特徴量キャッシュの機能を紹介します。これは、ランキングモデルの実行と特徴量の値の出力を併用する際に、後者を効率化するための機能です。 具体的なコードとしては、本プラグインのバージョンv1.5.8-es7.16.2を例に説明します。 github.com 本記事では本プラグインの詳しい使い方は紹介しませんが、過去の記事で紹介しておりますので、ぜひ合わせてご覧ください。 techblog.zozo.com 目次 はじめに 目次 ランキングモデルの実行の仕組み LtrQueryParserPluginによるクエリのパースと、RankerQueryオブジェクトの生成 StoredLtrQueryBuilder RankerQuery Query#rewrite RankerQuery#createWeightによるRankerWeightオブジェクトの生成 RankerWeight#scorerによるRankerScorerオブジェクトの生成 RankerScorerによるドキュメントのスコアリング RankerScorer#score 特徴量ロギングの仕組み LtrQueryParserPlugin#getFetchSubPhasesによるLoggingFetchSubPhaseの挿入 LoggingFetchSubPhase#getProcessorにおける特徴量ロギングの下準備 RankerQuery#toLoggerQuery HitLogConsumer LoggingFetchSubPhaseProcessor#processにおける実際のロギング 特徴量キャッシュの仕組み StoredLTRQueryBuilderがcache要素をサポートするように拡張 未指定であったことを覚えておく キャッシュ本体の設計 ドキュメントIDについて キャッシュの受け渡し キャッシュへのエントリの挿入 キャッシュからのエントリの引き当て DisjunctionDISI#advanceでの引き当て RankerScorer#scoreでの引き当て まとめ ランキングモデルの実行の仕組み まず、本プラグインでランキングモデルを実行する仕組みを紹介します。本プラグインでランキングモデルを実行するには、例えば以下のクエリをElasticsearchに送信します(本プラグインの公式ドキュメントより引用)。 { " query ": { " match ": { " _all ": " rambo " } } , " rescore ": { " window_size ": 1000 , " query ": { " rescore_query ": { " sltr ": { " params ": { " keywords ": " rambo " } , " model ": " my_model " } } } } } https://elasticsearch-learning-to-rank.readthedocs.io/en/latest/searching-with-your-model.html#rescore-top-n-with-sltr このクエリでは、以下のことが指定されています。 クエリキーワードが rambo 既存の検索結果の上位1,000件をランキングモデルで並べ替える その際に使うランキングモデルの名前が my_model LtrQueryParserPluginによるクエリのパースと、RankerQueryオブジェクトの生成 例のクエリは、まず本プラグインのコードのうち LtrQueryParserPlugin に入力されます。 LtrQueryParserPlugin はElasticsearch本体が提供するインタフェース SearchPlugin を実装しています。このため、 LtrQueryParserPlugin はElasticsearch本体の SearchModule から見えるようになっています。 SearchModule はクエリの各要素(例のクエリで言うと match や sltr )をどのクラスにパースさせるかを管理しています。具体的には、組み込みのクラスのほか、各プラグインが SearchModule#getQueries で指定してくるクラスも考慮します。 LtrQueryParserPlugin#getQueries では、以下の通り sltr 要素を StoredLtrQueryBuilder にパースさせるという指定をしています。ただし、 StoredLtrQueryBuilder.NAME は sltr であることに注意してください。この指定のため、次は本プラグイン独自の StoredLtrQueryBuilder に制御が移ります。 new QuerySpec<>(StoredLtrQueryBuilder.NAME, (input) -> new StoredLtrQueryBuilder(getFeatureStoreLoader(), input), (ctx) -> StoredLtrQueryBuilder.fromXContent(getFeatureStoreLoader(), ctx)), https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/LtrQueryParserPlugin.java#L144-L146 StoredLtrQueryBuilder StoredLtrQueryBuilder はクエリのJSONをパースし、メモリ上の表現(後述の RankerQuery )をビルドするのに使います。ビルドのために、以下の主要なメンバーを持っています。 ランキングモデル名 ストア名 ランキングモデルが保存されているElasticsearchインデックスの名前(デフォルトは .ltrstore ) Map<String, Object> クエリの params に対応するオブジェクト(例のクエリを参照) StoredLtrQueryBuilder#doToQuery を呼ぶと、 RankerQuery が返ります。このとき、ランキングモデル本体もメモリにロードされます。 StoredLtrQueryBuilder のコードは以下にあります。 https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/StoredLtrQueryBuilder.java RankerQuery Elasticsearchは検索ライブラリLuceneに依存しています。 RankerQuery は、Luceneが提供する抽象クラス Query の実装です。これはクエリのメモリ上の表現にあたります。以下の主要なメンバーを持っています。 FeatureSet ランキングモデルで使用する特徴量のリスト LtrRanker ランキングモデルのうち、 FeatureSet 以外の部分に対応するオブジェクト List<Query> 子 Query のリスト 詳しくは説明しませんが、ランキングモデルにおける特徴量とは、スコアを計算するためのクエリキーワードやドキュメントに関する値です。例えば、ドキュメント中でクエリキーワードが出現する回数などです。 LtrRanker は、具体的な特徴量を覚えておくオブジェクト FeatureVector の用意とスコアの計算を責務とします。 List<Query> が必要なのは、本プラグインでは、1つの特徴量は1つの子 Query に対応するためです。 RankerQuery のコードは以下にあります。 https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java Query#rewrite ここで、クエリ1つに Query オブジェクト1つが対応するのであれば分かりやすいです。しかし、実際にはそうではありませんので注意してください。すなわち、同一のクエリでも処理の途中で Query オブジェクトが変わることもあります。具体的には、 Query#rewrite を呼ぶと、別の Query オブジェクトが返ります。これは、抽象的なクエリを具体的で実行可能なクエリに書き換えたり、実行の効率が悪いクエリを良いクエリに書き換えたりするメソッドです。 RankerQuery そのものに書き換えるべきところはありません。ただし、 RankerQuery#rewrite は子 Query の rewrite も呼び出します。そして、そこで書き換えが行われた場合は、新しい RankerQuery を生成して返します。 @Override public Query rewrite(IndexReader reader) throws IOException { List<Query> rewrittenQueries = new ArrayList<>(queries.size()); boolean rewritten = false ; for (Query query : queries) { Query rewrittenQuery = query.rewrite(reader); rewritten |= rewrittenQuery != query; rewrittenQueries.add(rewrittenQuery); } return rewritten ? new RankerQuery(rewrittenQueries, features, ranker, featureScoreCache) : this ; } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L122-L132 RankerQuery#createWeightによるRankerWeightオブジェクトの生成 ここまで、クエリに対応する Query オブジェクトの生成を説明してきました。ここから、クエリを実行するためのオブジェクトの生成を説明していきますが、そのためにはElasticsearchにおけるクエリの実行の基本を知る必要があります。まず、Elasticsearchのインデックスは複数のセグメントに分かれています。そして、複数のセグメントに分かれたインデックス上でクエリを実行するために、異なる役割を持つ以下のオブジェクトを生成していきます。 Query クエリに対応するオブジェクト(インデックスの状態とは独立) Weight あるクエリをある時点のインデックスに対して実行するためのオブジェクト Scorer あるクエリをある時点のインデックスのあるセグメントに対して実行するためのオブジェクト さて、本節では Weight を説明します。前述の通り、あるクエリをある時点のインデックスに対して実行するためのオブジェクトが Weight です。 Weight の生成は、 Query#createWeight を IndexSearcher を引数として呼び出すことで行われます。これは、 IndexSearcher が、ある時点のインデックスの状態に対応するためです。 RankerQuery#createWeight の実装は以下です。ただし、スコアが不要という特殊な場合の処理が入っていますので、そこは省略しました。 @Override public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost) throws IOException { (省略) List<Weight> weights = new ArrayList<>(queries.size()); // XXX : this is not thread safe and may run into extremely weird issues // if the searcher uses the parallel collector // Hopefully elastic never runs MutableSupplier<LtrRanker.FeatureVector> vectorSupplier = new Suppliers.FeatureVectorSupplier(); FVLtrRankerWrapper ltrRankerWrapper = new FVLtrRankerWrapper(ranker, vectorSupplier); LtrRewriteContext context = new LtrRewriteContext(ranker, vectorSupplier); for (Query q : queries) { if (q instanceof LtrRewritableQuery) { q = ((LtrRewritableQuery) q).ltrRewrite(context); } weights.add(searcher.createWeight(q, ScoreMode.COMPLETE, boost)); } return new RankerWeight( this , weights, ltrRankerWrapper, features, featureScoreCache); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L182-L214 特徴的な処理としては、子 Query についても、それぞれ対応する Weight を生成しています。 FeatureVectorSupplier と LtrRewritableQuery は、あまり本記事の主題の処理に関わっていないと思われますので、これらの説明は省略します。 RankerWeight#scorerによるRankerScorerオブジェクトの生成 Elasticsearch (Lucene) のインデックスはインデックスセグメントに分かれています。そして、 IndexSearcher や Weight は、ある時点での検索対象のインデックスセグメントのリストを保持しています。これに対して、単一のインデックスセグメントに関するクエリ処理を行うのが Scorer です。 Weight#scorer を単一のインデックスセグメントに対応する LeafReaderContext を引数として呼び出すことで Scorer を生成できます。 RankerWeight#scorer の場合は、以下の通り RankerScorer を生成します。 @Override public RankerScorer scorer(LeafReaderContext context) throws IOException { List<Scorer> scorers = new ArrayList<>(weights.size()); DisiPriorityQueue disiPriorityQueue = new DisiPriorityQueue(weights.size()); for (Weight weight : weights) { Scorer scorer = weight.scorer(context); if (scorer == null ) { scorer = new NoopScorer( this , DocIdSetIterator.empty()); } scorers.add(scorer); disiPriorityQueue.add( new DisiWrapper(scorer)); } DisjunctionDISI rankerIterator = new DisjunctionDISI( DocIdSetIterator.all(context.reader().maxDoc()), disiPriorityQueue, context.docBase, featureScoreCache); return new RankerScorer(scorers, rankerIterator, ranker, context.docBase, featureScoreCache); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L270-L286 例によって、子 Query に対応する子 Weight についても、それぞれ対応する子 Scorer を生成する必要があります。このとき、子 Scorer については2通りに保持しておきます。 List<Scorer> DisiPriorityQueue 通常は List<Scorer> で持っておけば良さそうですが、あえて DisiPriorityQueue でも持っています。詳しくは後述しますが、 Scorer はスコアリング対象のドキュメントのイテレータを持ちます。 DisiPriorityQueue は、 Scorer の優先度付きキューで、 Scorer のイテレータが注目しているドキュメントのIDを優先度とします。これは、全ての子 Scorer のイテレータを効率的に対象のドキュメントまで進める ( advance ) ために用意されているデータ構造だと思われます。ちなみにここで、Disi = Doc ID set iteratorです。 以下、 RankerScorer による具体的なスコアリングについて見ていきます。 RankerScorerによるドキュメントのスコアリング Scorer によるドキュメントのスコアリングは、スコアリング対象の各ドキュメントについて、以下を繰り返すことで行われます。 Scorer#iterator で DocIdSetIterator を取得し、 DocIdSetIterator#advance を、スコアリング対象のドキュメントのIDを引数として呼び出します。 advance の返り値は、イテレータが実際に注目しているドキュメントのIDです。これがスコアリング対象のドキュメントのIDと異なる場合には、スコアリング対象のドキュメントは Scorer になった元の Query とマッチしなかったということです。このとき、実際にはスコアリングの対象外になります。 Scorer#score を呼び出すと、イテレータが実際に注目しているドキュメントのスコアが返ります。 この処理は本プラグインの外で行われます。例えば例のクエリのように rescore_query に sltr を入れた場合は、 QueryRescorer#rescore で行われます。この場合、実際にはスコアリングの対象外になったドキュメントのスコアは0です。 具体的な Scorer の実装である RankerScorer の場合は、 RankerScorer#iterator で独自実装の DisjunctionDISI が返ります。 そして DisjunctionDISI#advance (下記)では、まず、 RankerScorer 自身が Scorer ですので、 main.advance として自身のイテレータを進めます。 RankerScorer にとってはどんなドキュメントもスコアリング対象ですので、自身のイテレータは全ドキュメントのイテレータ(無名クラス。 DocIdSetIterator#all で取得)です。その後、全ての子 Scorer のイテレータも進める必要があります ( advanceSubIterators )。このとき、先ほどの DisiPriorityQueue を使います。 この処理は、特徴量キャッシュにヒットした場合には行われませんが、この工夫については後にZOZOの取り組みの説明で詳しく説明します。 @Override public int advance( int target) throws IOException { int docId = main.advance(target); if (featureScoreCache != null && featureScoreCache.containsKey(docBase + target)) { return docId; // Cache hit. No need to advance sub iterators } advanceSubIterators(docId); return docId; } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L414-L422 RankerScorer#score RankerScorer#iterator の advance の処理が終わったら、 RankerScorer#score を呼ぶことができます。ここでランキングモデルが実行され、結果がスコアとして返ります。具体的には以下の処理が行われます。ただし、特徴量キャッシュについては後でまとめて説明しますので、ここでは特徴量キャッシュが無効の場合の処理を説明します。 FeatureVector を初期化します。 子 Scorer (1つの特徴量に対応)ごとに: スコアリング対象のドキュメントが元のクエリとマッチするか確認します。 マッチするなら(必ずマッチする想定ですが)、 FeatureVector に子 Scorer のスコア(1つの特徴量の値に対応)をセットします。 FeatureVector に基づいて、ランキングモデルを実行します。 @Override public float score() throws IOException { fv = ranker.newFeatureVector(fv); if (featureScoreCache == null ) { // Cache disabled int ordinal = - 1 ; // a DisiPriorityQueue could help to avoid // looping on all scorers for (Scorer scorer : scorers) { ordinal++; // FIXME : Probably inefficient, again we loop over all scorers.. if (scorer.docID() == docID()) { // XXX : bold assumption that all models are dense // do we need a some indirection to infer the featureId? fv.setFeatureScore(ordinal, scorer.score()); } } } else { (省略) } return ranker.score(fv); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L315-L358 特徴量ロギングの仕組み ここまで、ランキングモデルの実行について説明してきました。本プラグインの主要な機能として、他に特徴量ロギング (feature score logging) があります。これは、特徴量の値をレスポンスに含めるという機能です。Elasticsearchの外部のツールでランキングモデルを機械学習するためには、Elasticsearchから特徴量の値を出力する必要があります。特徴量ロギングは、これを実現するための機能です。 特徴量ロギングのためには、例えば以下のクエリをElasticsearchに送信します(本プラグインの公式ドキュメントに基づき作成)。これは、前の例のクエリをベースとして、特徴量ロギングの指定を加えたものです。具体的には、0はじまりで0番目の rescore_query について、そこで使用しているランキングモデル ( my_model ) の特徴量の値をレスポンスに含めるという指定です。 ここからは、以下のようなクエリに対して、本プラグインがどのように動作しているかを説明します。 { " query ": { " match ": { " _all ": " rambo " } } , " rescore ": { " query ": { " rescore_query ": { " sltr ": { " params ": { " keywords ": " rambo " } , " model ": " my_model " } } } } , " ext ": { " ltr_log ": { " log_specs ": { " name ": " log_entry1 ", " rescore_index ": 0 } } } } https://elasticsearch-learning-to-rank.readthedocs.io/en/latest/logging-features.html#logging-values-for-a-live-feature-set LtrQueryParserPlugin#getFetchSubPhasesによるLoggingFetchSubPhaseの挿入 Elasticsearch本体の SearchModule は、前述の通りクエリの各要素をどのコンポーネントにパースさせるかの他、 FetchSubPhase のリストも管理しています。これは、ドキュメントの情報を集めて検索結果を組み立てるためのfetch phaseで行う処理のリストです。 本プラグインでは、検索結果に特徴量を挿入する必要があるので、fetch phaseに処理を追加する必要があります。このために、 LtrQueryParserPlugin では、 SearchPlugin#getFetchSubPhases というAPIを実装しています。 LtrQueryParserPlugin#getFetchSubPhases で独自実装の LoggingFetchSubPhase を返し、 SearchModule がそれを読みます。そして、fetch phaseに LoggingFetchSubPhase の処理が追加されます。 @Override public List<FetchSubPhase> getFetchSubPhases(FetchPhaseConstructionContext context) { return singletonList( new LoggingFetchSubPhase()); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/LtrQueryParserPlugin.java#L153-L156 LoggingFetchSubPhase#getProcessorにおける特徴量ロギングの下準備 FetchSubPhase における処理は、 FetchSubPhase#getProcessor から、 FetchSubPhaseProcessor#process へと流れます。本記事では詳しくは触れませんが、この流れ自体はElasticsearch本体のコードで記述されていますので、詳しくは FetchPhase#execute あたりを参照してください。 さて、 FetchSubPhase#getProcessor はクエリごとに一度だけ走る処理です。その実装である LoggingFetchSubPhase#getProcessor では特徴量ロギングの下準備をします。具体的な下準備は以下の通りです。 特徴量ロギング対象のクエリごとに、ロギング用のクエリ(後述します)に書き換え、対応する HitLogConsumer (これも後述します)も生成します。 特徴量ロギング対象のクエリがnamed queryの場合は LoggingFetchSubPhase#extractQuery で行います。 Rescore queryの場合は、同 extractRescore で行います。 ロギング用のクエリを収集し、全てを BooleanQuery (いわゆるORクエリ)としてまとめたものを用意します。以下のコードで言うと BooleanQuery.Builder builder = new BooleanQuery.Builder() から builder.build() までです。これがメインのクエリになります。 メインの BooleanQuery に対応する Weight もここで用意します。 最終的にはメインの Weight と、 HitLogConsumer のリストを LoggingFetchSubPhaseProcessor に渡して、処理が終了します。 @Override public FetchSubPhaseProcessor getProcessor(FetchContext context) throws IOException { LoggingSearchExtBuilder ext = (LoggingSearchExtBuilder) context.getSearchExt(LoggingSearchExtBuilder.NAME); if (ext == null ) { return null ; } // NOTE: we do not support logging on nested hits but sadly at this point we cannot know // if we are going to run on top level hits or nested hits. // Delegate creation of the loggers until we know the hits checking for SearchHit#getNestedIdentity CheckedSupplier<Tuple<Weight, List<HitLogConsumer>>, IOException> weigthtAndLogSpecsSupplier = () -> { List<HitLogConsumer> loggers = new ArrayList<>(); Map<String, Query> namedQueries = context.parsedQuery().namedFilters(); BooleanQuery.Builder builder = new BooleanQuery.Builder(); ext.logSpecsStream().filter((l) -> l.getNamedQuery() != null ).forEach((l) -> { Tuple<RankerQuery, HitLogConsumer> query = extractQuery(l, namedQueries); builder.add( new BooleanClause(query.v1(), BooleanClause.Occur.MUST)); loggers.add(query.v2()); }); ext.logSpecsStream().filter((l) -> l.getRescoreIndex() != null ).forEach((l) -> { Tuple<RankerQuery, HitLogConsumer> query = extractRescore(l, context.rescore()); builder.add( new BooleanClause(query.v1(), BooleanClause.Occur.MUST)); loggers.add(query.v2()); }); Weight w = context.searcher().rewrite(builder.build()).createWeight(context.searcher(), ScoreMode.COMPLETE, 1.0F ); return new Tuple<>(w, loggers); }; return new LoggingFetchSubPhaseProcessor(Suppliers.memoizeCheckedSupplier(weigthtAndLogSpecsSupplier)); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/logging/LoggingFetchSubPhase.java#L50-L80 RankerQuery#toLoggerQuery LoggignFetchSubPhase#extractQuery や extractRescore を辿っていくと、 RankerQuery#toLoggerQuery に辿り着きます。これは、特徴量ロギング対象のクエリを、特徴量ロギング用に書き換える処理です。具体的には以下のように書き換えます。 まず、特徴量ロギングのためには特徴量の値が出れば良く、モデルのスコアは不要です。 そこで、モデルのスコアを計算する部分である Ranker をダミーの NullRanker に置き換えます。 さらに、 Ranker は特徴量の値を受け取れるので、それを利用して特徴量ロギングを行います。なので、その動作をする LogLtrRanker で NullRanker をラップする形にします。 public RankerQuery toLoggerQuery(LogLtrRanker.LogConsumer consumer) { NullRanker newRanker = new NullRanker(features.size()); return new RankerQuery(queries, features, new LogLtrRanker(newRanker, consumer), featureScoreCache); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L117-L120 HitLogConsumer HitLogConsumer は、大まかにいうと LogLtrRanker で受け取った特徴量の値を保存しておくためのオブジェクトです。 HitLogConsumer は、ドキュメントごとに呼ばれる HitLogConsumer#nextDoc でドキュメントへフィールドを追加し、そこへの参照を維持しておきます。そして、特徴量ごとに呼ばれる HitLogConsumer#accept で得られた特徴量の値を、参照を通じてフィールドへ追加します。 HitLogConsumer は LogLtrRanker.LogConsumer インタフェースを実装しており、この名前で呼ばれることもあるので注意が必要です。例えば、前述の RankerQuery#toLoggerQuery の引数の型は LogLtrRanker.LogConsumer ですが、その実装は HitLogConsumer です。 LoggingFetchSubPhaseProcessor#processにおける実際のロギング FetchSubPhaseProcessor#process はドキュメントごとに走る処理です。 LoggingFetchSubPhaseProcessor#process では、ここで実際のロギングを行います。基本的には特徴量ロギング抜きのランキングモデルの実行と同様で、イテレータを対象のドキュメントまで進めて、スコアを計算するという流れになります。このとき、これまで述べてきた通り、以下の流れで特徴量ロギングの処理が走ります。 特徴量ロギング対象のクエリそれぞれについて、 HitLogConsumer#nextDoc でドキュメントにフィールドを追加します。 メインの Scorer#score ( BlockMaxConjunctionScorer#score ) で、全ての子 Scorer ( RankerScorer ) につき以下を行います。ただし、以下2点に注意してください。(1) メインのクエリは BooleanQuery ですが、対応するメインの Scorer は BooleanScorer ではなく BlockMaxConjunctionScorer です。(2) 1つの子 Scorer が特徴量ロギング対象の1つの Query に対応しますが、これらは必ず RankerScorer と RankerQuery になるはずです。 子の RankerScorer#score において: LogLtrRanker#newFeatureVector を呼びます。ここで HitLogConsumer を FeatureVector に見せかけるため LogLtrRanker.VectorWrapper でラップします。 全ての孫 Scorer (これ1つが1つの特徴量に相当します)について: Scorer#score を呼び、具体的な特徴量の値を計算します。 その値を引数として、 LogLtrRanker.VectorWrapper#setFeatureScore を呼びます。 HitLogConsumer#accept で、特徴量の値をドキュメントのフィールドに追加します。 LogLtrRanker#score を呼びます。 NullRanker#score を実行し、モデルのスコアの計算をスキップします。 LoggingFetchSubPhaseProcessor#process の実装は以下の通りです。 public void process(HitContext hitContext) throws IOException { if (hitContext.hit().getNestedIdentity() != null ) { // we do not support logging nested docs return ; } Tuple<Weight, List<HitLogConsumer>> weightAndLoggers = loggersSupplier.get(); if (scorer == null ) { scorer = weightAndLoggers.v1().scorer(currentContext); } List<HitLogConsumer> loggers = weightAndLoggers.v2(); if (scorer != null && scorer.iterator().advance(hitContext.docId()) == hitContext.docId()) { loggers.forEach((l) -> l.nextDoc(hitContext.hit())); // Scoring will trigger log collection scorer.score(); } } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/logging/LoggingFetchSubPhase.java#L146-L162 また参考のため、 RankerScorer#score の実装も以下に再掲します。 @Override public float score() throws IOException { fv = ranker.newFeatureVector(fv); if (featureScoreCache == null ) { // Cache disabled int ordinal = - 1 ; // a DisiPriorityQueue could help to avoid // looping on all scorers for (Scorer scorer : scorers) { ordinal++; // FIXME : Probably inefficient, again we loop over all scorers.. if (scorer.docID() == docID()) { // XXX : bold assumption that all models are dense // do we need a some indirection to infer the featureId? fv.setFeatureScore(ordinal, scorer.score()); } } } else { (省略) } return ranker.score(fv); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L315-L358 特徴量キャッシュの仕組み ここまでランキングモデルの実行と、特徴量ロギングについて説明してきました。ランキングモデルを継続的に改善する場合、既存のランキングモデルを実行して検索結果を返しつつ、新しいランキングモデルを学習するために特徴量ロギングを行うことになります。そしてこのとき、ランキングモデルの実行と、特徴量ロギングのためには、それぞれ特徴量の値を計算することになります。 ZOZOでは、この重複する特徴量の値の計算を効率化するため、特徴量キャッシュの機能を実装しました。ここでいう特徴量キャッシュとは、以下の動作により特徴量の値の計算を一度で済ませ、クエリ処理を高速化する機能です。 ランキングモデルの実行の際に、計算した特徴量の値をキャッシュする 特徴量ロギングの際には、特徴量の値をキャッシュから取り出して返す(つまり、計算しない) 本記事の残りでは、本プラグインへの特徴量キャッシュ機能の実装について説明します。この機能はすでに追加されていますが、この機能の追加による変更点は以下のプルリクエストにまとまっていますので、適宜このプルリクエストを参照しながら説明します。 github.com StoredLTRQueryBuilderがcache要素をサポートするように拡張 まず、特徴量キャッシュ機能のエントリーポイントとして、 StoredLTRQuerybuilder に独自パラメータ cache を実装しています。プルリクエストで言うとこのファイルの変更です。 https://github.com/o19s/elasticsearch-learning-to-rank/pull/397/files#diff-2a71488e163f2d8274bb9bb2ae27b4583eb12986fcaaf07c7b6def85cc603149 この変更により、特徴量ロギングの対象のクエリに以下の指定を入れることで、特徴量キャッシュ機能が有効になります。 "sltr": { "model": "...", + "cache": true, "params": { ... 未指定であったことを覚えておく 他の既存のパラメータと同様に実装しているため、注意点は少ないです。ただし、このパラメータが未指定(デフォルト値 false が使われる)だったのか、明示的に false 指定だったのかは覚えておく必要があることには注意してください。これは、 StoredLTRQuerybuilder をクエリのJSONとして書き出すことがあり、未指定か明示するかでJSON上の表現が変わるからです。 具体的には、 boolean 型ではなく Boolean 型で持っておくということです。 JSONのパラメータの値 Javaの Boolean の値 未指定 null 明示的に false 指定 false 明示的に true 指定 true キャッシュ本体の設計 キャッシュのデータ構造は、シャード別ドキュメントIDから特徴量の値の配列への連想配列としました。具体的には Map<Integer, float[]> (ただし、特徴量キャッシュ機能が無効の際は null )です。 RankerQuery.build で生成して、 RankerQuery のコンストラクタに渡す実装としています。 private static RankerQuery build(LtrRanker ranker, FeatureSet features, LtrQueryContext context, Map<String, Object> params, Boolean featureScoreCacheFlag) { List<Query> queries = features.toQueries(context, params); Map<Integer, float []> featureScoreCache = null ; if ( null != featureScoreCacheFlag && featureScoreCacheFlag) { featureScoreCache = new HashMap<>(); } return new RankerQuery(queries, features, ranker, featureScoreCache); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L100-L108 ドキュメントIDについて 上で シャード別 ドキュメントIDに言及しましたが、Lucene/ElasticsearchにおけるドキュメントIDは少なくとも3種類あります。 _id フィールドの値 シャードをまたいでもユニークなドキュメントID 通常、ユーザの目に触れるのはこれ シャード別ドキュメントID 特定のシャード内のドキュメントの通し番号 セグメント別ドキュメントID 特定のシャード内の、さらに特定のインデックスセグメント内のドキュメントの通し番号 この値と、インデックスセグメントごとの docBase という値との和が、シャード別ドキュメントID Query オブジェクトは特定のシャード内のドキュメントだけを扱いますので、扱うドキュメントはシャード別ドキュメントIDで一意に特定できます。ですので、今回はシャード別ドキュメントIDをキャッシュのキーにすれば良いと考えられます。 キャッシュの受け渡し キャッシュへのエントリの挿入時とキャッシュからのエントリの引き当て時には、もちろん同一の Map を使う必要があります。本プラグインにおいて、各種オブジェクトの生成フローは下図のようになっています。図中左側がランキングモデルの実行に関わるオブジェクト群で、右側が特徴量ロギングに関わるオブジェクト群です。 ですので、まず起点の RankerQuery (図中左上)に Map を持たせておいて、各メソッドで適切に受け渡していく必要があります。エントリの挿入は左側の RankerScorer#score で、引き当ては右側の DisjunctionDISI#advance と RankerScorer#score で行います。これらのオブジェクトは、偶然ですが全て同一のファイル RankerQuery.java に実装されており、プルリクエストで言うと以下のファイルに全ての受け渡しの処理が含まれます。 https://github.com/o19s/elasticsearch-learning-to-rank/pull/397/files#diff-07788001c91b0b5c03be973de2a368900204bab6c6fc6d3255ec34bcf6184c09 キャッシュへのエントリの挿入 ここからは、挿入と引き当てに分けて具体的な処理を説明していきます。キャッシュへのエントリの挿入は、初回の RankerScorer#score の呼び出し(ランキングモデルの実行時になるはず)で行います。キャッシュが有効の場合、ランキングモデルの実行時に、前述の既存の処理に加えて、以下の処理を行います。 まず、特徴量の値の配列を実際に確保します。 次に、そこに特徴量の値を詰めます。 最後に、 Map にシャード別ドキュメントIDと配列のペアを入れます。 例外処理として、もし対象ドキュメントが孫 Scorer (ある特徴量に対応する Scorer )のイテレータに含まれていなかった場合は、 NaN を詰めておくことにしています。ただし、既存の処理のコメントにもある通り、この状況は本プラグイン全体を通して起こらない想定です。 } else { // Cache miss int ordinal = - 1 ; float [] featureScores = new float [scorers.size()]; for (Scorer scorer : scorers) { ordinal++; float score = Float.NaN; if (scorer.docID() == docID()) { score = scorer.score(); fv.setFeatureScore(ordinal, score); } featureScores[ordinal] = score; } featureScoreCache.put(perShardDocId, featureScores); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L342-L355 キャッシュからのエントリの引き当て キャッシュからのエントリの引き当ては、2回目以降の DisjunctionDISI#advance および RankerScorer#score の呼び出し(特徴量ロギング時になるはず)で行います。 DisjunctionDISI#advanceでの引き当て 特徴量ロギングの対象ドキュメントまで全ての孫 Scorer (ある特徴量に対応する Scorer )のイテレータを進める処理にはコストがかかります。しかし、キャッシュにエントリが含まれている場合、そもそも特徴量を改めて計算する必要もイテレータを進める必要もないため、いわゆるearly returnを実装しました。以下に当該のソースコードを再掲します。 @Override public int advance( int target) throws IOException { int docId = main.advance(target); if (featureScoreCache != null && featureScoreCache.containsKey(docBase + target)) { return docId; // Cache hit. No need to advance sub iterators } advanceSubIterators(docId); return docId; } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L414-L422 RankerScorer#scoreでの引き当て 2回目以降の RankerScorer#score の呼び出し(特徴量ロギング時になることを想定)では、特徴量の値を計算する必要はなく、キャッシュから引き当てることができます。具体的には、前述の既存の処理に比べて、孫 Scorer の score メソッドを呼ばなくなっています。ここが特徴量キャッシュの主要な効果になると期待されます。コードでは以下の箇所です。 if (featureScoreCache.containsKey(perShardDocId)) { // Cache hit float [] featureScores = featureScoreCache.get(perShardDocId); int ordinal = - 1 ; for ( float score : featureScores) { ordinal++; if (!Float.isNaN(score)) { fv.setFeatureScore(ordinal, score); } } } else { // Cache miss https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L333-L342 まとめ 本記事では、以下を解説しました。 OpenSource Connections Elasticsearch Learning to Rank pluginの仕組み ランキングモデルの実行の仕組み 特徴量ロギングの仕組み 本プラグインの処理の効率化のためにZOZOで実装した、特徴量キャッシュ機能 ZOZOでは、検索機能を開発・改善していきたいエンジニアを全国から募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
アバター