株式会社ZOZOのブログ - TECH PLAY

TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

1050

こんにちは、ZOZO NEXTでウェブエンジニアを担当している 木下 です。先日、弊社が運営するオウンドメディアのFashion Tech Newsにおいて、記事リストのパーソナライズを行いました。本記事ではパーソナライズ導入における、要件定義、レコメンドエンジンの比較、実装での知見や注意点についてまとめます。 fashiontechnews.zozo.com 背景 解決方法の検討 課題の分析 パーソナライズ手法の検討 レコメンド方式について サービスの比較 Amazon Personalizeの実装 実装の流れ アーキテクチャ 実装での工夫点 採用したアルゴリズム アイテムデータの更新頻度 ユーザーの識別 注意点 AWS Personalizeのサンプルリポジトリが古い データの収集には時間がかかる まとまった料金が発生する まとめ 背景 「Fashion Tech News」とは、2018年に運用を開始したZOZO NEXTのオウンドメディアです。ファッションテック領域へ挑戦を続けるZOZO NEXTが、独自の視点でファッション×テクノロジーのニュースを提供しています。 記事本数の増加や継続的なサイトの改善などを進めていましたが、以下の課題がありました。 直帰率が高い 単独の記事を目当てにした新規ユーザーが多い そのためPV数の増加には、回遊率を増やしたり、再訪問率を上げたりすることが必要でした。 解決方法の検討 上記の解決方法として、記事下部にある関連記事のパーソナライズを検討しました。 ©️Fashion Tech News 課題の分析 ユーザーの行動を分析した結果、利用するユーザーの大半は新規ユーザーであり、検索やTwitterから各記事に直接訪問することがわかりました。そこで回遊率向上のために、記事を読み終わった後にある関連記事をパーソナライズすることにしました。ユーザーに合った記事を表示することで、好みの記事が見つかるサイトであるという体験も提供でき、再訪問率の向上も期待できます。 パーソナライズ手法の検討 レコメンド方式について まずはレコメンド方式について調査し、主に以下の方式があることを把握しました。 コンテンツベースフィルタリング 協調フィルタリング それぞれ長所と短所があり、状況に応じて使い分けることが必要です。 コンテンツベースフィルタリングは、アイテムの属性情報をもとに類似度を計算することで、ユーザーの好みに合わせたレコメンドを作成します。一方協調フィルタリングは、ユーザー同士の評価値の相関関係を分析することで、ユーザーの行動履歴から類似ユーザーを見つけ、そのユーザーが好んだアイテムをレコメンドするアルゴリズムです。 今回は新規ユーザーが多いため、コンテンツベースフィルタリングを採用する方向で進めていました。一方でより様々なアイテムのレコメンドを行うために、協調フィルタリングも併せて活用できるのではと考えました。結果的に、協調フィルタリングとコンテンツベースフィルタリングを組み合わせたレコメンドアルゴリズムを採用することとなりました。 サービスの比較 3つのサービスを項目ごとに比較しました。今回比較したサービスはどれも前項で説明したレコメンド方式の両方を提供していました。 比較項目 Amazon Personalize Google Recommendations AI Algolia Recommend レコメンドアルゴリズム - 一緒に購入される - 関連 - 類似 - あなたへのおすす - トレンド - 人気 - 最近見た - ユーザーセグメント - パーソナライズランキング - 新しいアイテムのレコメンド - 一緒に購入される - 関連 - 類似 - あなたへのおすすめ - 最近見た - もう一度購入 - セール中 - 一緒に購入される - 関連 - 類似 - トレンド ハイパーパラメータの調整 ○ ○ × トレーニング頻度の調整 ○ ○ × 月間20万リクエストでの概算料金 300USD 400USD 120USD 特徴 - AWSの他のサービスと連携しやすい - モデルの最初のトレーニングに2-5日必要 - 検索機能も同時に導入可能 - UIライブラリがある - 検索機能も同時に導入可能 比較検討の結果、次の理由でAmazon Personalizeを選択しました。まずは様々な ユースケース に対応していることです。ウェブメディアでも様々なアルゴリズムが試せると考えました。2つ目はMLモデルの調整ができることです。パーソナライズを導入するのが初めてのことであり、効果検証をする中でパーソナライズの精度を調整する可能性も考えたためです。それぞれのサービスに特徴があるので、実装したい機能に応じて使い分けると良いと思います。 Amazon Personalizeの実装 実装の流れ 上記の通りAmazon Personalizeを採用しました。理解しやすいように、大まかな実装の流れを挙げます。 データセットグループ の作成 インタラクションデータ の収集 アイテムデータ のアップロード ソリューションとソリューションバージョン の作成 キャンペーン の作成 レコメンド の取得 ※個人的に理解につまづいた用語の説明を加えます。 用語 説明 ソリューション アルゴリズムやパラメータの管理 ソリューションバージョン トレーニング済みのMLモデル キャンペーン APIでレコメンドを取得できるようにデプロイ 実装の結果、以下のようにレコメンドが取得できました。左が従来通りで、右がレコメンドです。この結果は、「 「ゲーム業界が考えるメタバースとは全く異なる」日本でも話題の「AGLET」が描く戦略とビジョン 」という記事の関連記事であり、記事内容に沿ったレコメンドが確認できます。 ©️Fashion Tech News アーキテクチャ 実装に当たって主に以下のサービスを採用しました。 Amazon API Gateway AWS Lambda Amazon Personalize Amazon PersonalizeのSDKをJavaScriptで直接呼ぶことも可能ですが、AWS LambdaやAPI Gatewayを用いたアーキテクチャを採用した理由は、以下の通りです。 PersonalizeのイベントトラッカーやキャンペーンのARNを隠蔽できる API GatewayのCORSの設定によりブラウザからの不要なリクエストを除ける 実装での工夫点 採用したアルゴリズム Amazon Personalizeでは レシピ と呼ばれる、ユースケースごとのアルゴリズムが用意されています。コールドスタートの新規ユーザーを考慮し、インタラクションデータに加えてアイテムデータを利用したレコメンドを行う、 Similar-Items のレシピを選択しました。これは先述の、協調フィルタリングとコンテンツベースフィルタリングのハイブリッドを意味します。 アイテムデータの更新頻度 新規ユーザーが多いことを踏まえると、アイテムデータの類似度によるレコメンドが役立ちます。そのため新規で公開された記事もレコメンドへ反映されるよう、記事の公開に合わせて毎日再トレーニングが実行されるよう設定しました。処理は定時にGitHub Actionsで実行されます。 ユーザーの識別 ユーザーのインタラクションを記録するには、IDなどで区別する必要があります。しかしサイトにはログイン機能がないため、ユーザーを識別する手段としてGoogle Analytics 4 (GA4)のClient IDを利用しました。一方でGA4が無効になっているユーザーに対しては、従来通りの記事リストが表示されるようにします。 注意点 AWS Personalizeのサンプルリポジトリが古い AWS LambdaやAPI Gatewayの実装には、CFnの拡張機能で利便性が高い AWS SAM を活用しました。AWS SAMはCLIから操作をするのですが、 AWS Quick Start Templates というコマンドがあります。このコマンドにより様々な実装例が確認でき大いに参考になりました。 当初はAmazon Personalizeのサンプルリポジトリにある、 streaming_events というコードを参考にしていました。しかしこのコードはAPI Gatewayの書き方などが古く、大部分を書き直す必要がありました。アーキテクチャを考える上で参考になりますが、お気をつけください。 データの収集には時間がかかる ソリューションを作成するために 必要なデータ量 は、2回以上のインタラクションがあるユニークな25人以上のユーザーによる1,000回のインタラクションで、推奨は50,000回です。そのため、計画を立てる上でデータの収集期間をしっかりと見積もることが大切です。もしくは既にあるアナリティクスのデータなどを用いることができれば、早く済ませることができます。 まとまった料金が発生する Amazon Personalizeを利用する上で主に発生する 料金 は、(1)MLのトレーニングと(2)レコメンドがデプロイされている期間です。 トレーニングにはトレーニング時間(4v CPUと8GiBメモリを使用する1時間のコンピューティング性能)という単位で料金が発生します。トレーニングをする頻度は、料金も踏まえて検討する必要があります。仮に毎日10トレーニング時間を利用した場合、1か月で72USDかかることになります。 レコメンドがデプロイされている期間、つまりキャンペーンが存在する期間は料金が発生します。TPS時間という単位で料金が発生しますが、これが最低でも1時間あたり0.20USD、1か月で144USD程度かかります。 まとめ Amazon Personalizeを採用することで、関連記事リストのパーソナライズを実現しました。Amazon Personalizeは、様々なアルゴリズムを利用でき、一部の設定は微調整もできます。上記注意点に挙げたデータの収集や、サンプルリポジトリが古いことに気づくまでに時間がかかったこともあり、運用開始までに2人で約1.5か月かかりました。ただAWSに日常的に携わって詳しい方なら、1-2週間で運用開始できるのではないでしょうか。 MLの実装知識がなくても利用できるのも魅力の1つです。もちろんどのレシピを選択すべきかなどはMLの仕組みを踏まえて検討するため、その知識は必要になります。現在は結果を踏まえ分析をし、より良いレコメンドが行えるように調整しています。レコメンドの効果までお伝えできなかったのは残念ですが、実装の参考になれば幸いです。 ZOZO NEXTでは、様々な技術を取り入れUXを最大化しながらプロダクト開発に取り組んでいます。絶賛仲間を募集しておりますので、興味を持ってくださった方は以下をご確認ください。 カジュアル面談はこちらからご応募ください。 hrmos.co 募集している職種はこちらからご確認ください。 hrmos.co hrmos.co hrmos.co
はじめに こんにちは、SRE部カート決済SREブロックの伊藤です。普段はZOZOTOWNのカート決済機能のリプレイス・運用・保守に携わっています。また、チームを跨いだ横断活動としてデータベース(以下DB)周りの運用・保守・構築に関わっています。 ZOZOTOWNではSQL Serverを中心とした各種DBMSが稼働しています。本記事はSQL Serverのパフォーマンスを調査する上で進めた可視化についての取り組みをご紹介します。 はじめに 従来の方法 DMV運用の課題 Splunkによるダッシュボード化 DMVの可視化例 インストールされているServerのログ情報の送信 DatadogのDatabase Monitoringについて Database Monitoringを使用して改善した例 CPU使用率の高いクエリの検出と改善 パフォーマンスが急に悪化した場合の原因調査 今後の展望 最後に 従来の方法 以前下記のテックブログで紹介させていただきましたが、弊社では 動的管理ビュー (Dynamic Management View:以下、DMV)や拡張イベントの情報をロギングしています。 techblog.zozo.com これらの情報を用いることで何かトラブルが起こった際には後追いできる状況を整えています。 例えば特定の時間帯にクエリが滞留した際には次のクエリを実行することで、滞留していたクエリの詳細な情報を調べることができます。 SELECT collect_date, count (*) AS [クエリの滞留数(全体)] FROM [dbo].[dm_exec_requests_dump_per_several_seconds_20230502] WHERE collect_date between @start_date and @end_date GROUP BY collect_date ORDER BY collect_date ; SELECT collect_date, wait_type, count (*) AS [クエリの滞留数(wait毎)] FROM [dbo].[dm_exec_requests_dump_per_several_seconds_20230502] WHERE collect_date between @start_date and @end_date GROUP BY collect_date, wait_type ORDER BY collect_date, wait_type ; SELECT collect_date, current_running_stmt, count (*) AS [クエリの滞留数(statement毎)] FROM [dbo].[dm_exec_requests_dump_per_several_seconds_20230502] WHERE collect_date between @start_date and @end_date GROUP BY collect_date, current_running_stmt ORDER BY collect_date, current_running_stmt ; 他にも、ストアドプロシージャ(以下、ストアド)の実行統計 sys.dm_exec_procedure_stats もDumpしています。 ストアドに修正を行なった際の監視や、特定のストアドが突然パフォーマンス劣化した際などはこちらを確認することで具体的なパフォーマンスを調べることができます。 オリジナル情報は累積値となっているため、LAG関数を使用して1分前の情報と差分を取ることで1分間の実行回数や実行時間を出力しています。 SELECT object_name, collect_date, execution_count AS ' 実行回数(累積値) ' , -- リセットされていたらキャッシュアウトされた可能性あり CASE WHEN cached_time = LAG(cached_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date) THEN CONVERT (nvarchar, execution_count - LAG(execution_count, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) ELSE ' - ' END AS ' 実行回数(1分間の合計) ' , CASE WHEN cached_time = LAG(cached_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date) THEN CONVERT (nvarchar, total_worker_time - LAG(total_worker_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) ELSE ' - ' END AS ' CPU時間(1分間の合計) ' , CASE WHEN cached_time = LAG(cached_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date) THEN CONVERT (nvarchar, total_elapsed_time - LAG(total_elapsed_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) ELSE ' - ' END AS ' 実行時間(1分間の合計) ' , CASE WHEN cached_time = LAG(cached_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date) THEN CONVERT (nvarchar, total_logical_reads - LAG(total_logical_reads, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) ELSE ' - ' END AS ' 論理読み込み量(1分間の合計) ' , CASE WHEN cached_time = LAG(cached_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date) THEN CONVERT (nvarchar, total_logical_writes - LAG(total_logical_writes, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) ELSE ' - ' END AS ' 論理書き込み量(1分間の合計) ' FROM dbo.dm_exec_procedure_stats_dump WITH (NOLOCK) WHERE collect_date BETWEEN @start_date AND @end_date AND object_name = ' <絞り込みたいストアドの名前> ' ORDER BY object_name, collect_date DMV運用の課題 ロギングしたDMVは上記のようにトラブルシューティング時に役立てることができますが、周りのメンバーに対応してもらうにあたってハードルの高さを課題として感じていました。 まず、上記のSQLを作るにはDMVについて理解を深める必要があります。SREの全員がDBに精通しているわけではなく、普段SQLを書かないメンバーもいるので学習コストが必要となります。 また、DBに接続するためにはプライベートなネットワークを経由する必要があるなど一手間かかります。本番環境で稼働しているDBサーバーに対してSQLを実行する必要があるので、日常的な運用には向いていません。 SQL Server 2016からはクエリの実行履歴を保存して可視化できる クエリストア の機能も追加されました。トラブルシュートする上で有用ではありますが、上記と同様の課題や表示速度なども含めて、運用の利便性が高いものとは言えませんでした。 そのためパフォーマンス悪化の徴候があったとしても後手に周り、問題が発生してからでないと気付きにくい側面がありました。 Splunkによるダッシュボード化 DMVの課題を解決するために取り組んだのがまずSplunkによるダッシュボード化です。 弊社ではさまざまな場所でSplunkを活用しています。過去のテックブログにもSplunkに関する記事がありますので、興味のある方は是非ご覧ください。 techblog.zozo.com 今回使用したのは Splunk DB Connect というアドオンです。Splunk DB Connectではデータベースの情報を直接インポートできる他に、カスタムクエリを定期的に実行して結果をSplunkに送信できます。 Splunk DB Connect自体のインストール方法やDBとの接続方法に関しましては 公式ドキュメント をご参照ください。 DMVの可視化例 冒頭でDMVの活用例としてストアドのパフォーマンス調査を挙げましたが、まずはこちらをSplunk DB Connectで毎分実行し、Splunk側へ蓄積されるようにしました。作成したのが下記クエリです。 ストアド毎に、3分前から1分前の間にdumpとして保存された2つのレコードを取得し、差分を出力しています。SQL内のコメント文はSplunk DB Connectで設定する際に動作影響が出るため削除しています。 DECLARE @start_date DATETIME2 = dateadd(mi, -3 , GETDATE()); DECLARE @end_date DATETIME2 = dateadd(mi, -1 , GETDATE()); SELECT object_name AS ' stored_procedure ' , collect_date, exec_count_sum_1m, cpu_time_sum_1m, exec_time_sum_1m, logical_read_sum_1m, logical_write_sum_1m FROM ( SELECT object_name, collect_date, CASE WHEN (object_id = LAG(object_id, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) and (cached_time = LAG(cached_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) THEN CONVERT (nvarchar, execution_count - LAG(execution_count, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) ELSE ' - ' END AS ' exec_count_sum_1m ' , CASE WHEN (object_id = LAG(object_id, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) and (cached_time = LAG(cached_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) THEN CONVERT (nvarchar, total_worker_time - LAG(total_worker_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) ELSE ' - ' END AS ' cpu_time_sum_1m ' , CASE WHEN (object_id = LAG(object_id, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) and (cached_time = LAG(cached_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) THEN CONVERT (nvarchar, total_elapsed_time - LAG(total_elapsed_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) ELSE ' - ' END AS ' exec_time_sum_1m ' , CASE WHEN (object_id = LAG(object_id, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) and (cached_time = LAG(cached_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) THEN CONVERT (nvarchar, total_logical_reads - LAG(total_logical_reads, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) ELSE ' - ' END AS ' logical_read_sum_1m ' , CASE WHEN (object_id = LAG(object_id, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) and (cached_time = LAG(cached_time, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) THEN CONVERT (nvarchar, total_logical_writes - LAG(total_logical_writes, 1 , 0 ) OVER ( ORDER BY object_name, collect_date)) ELSE ' - ' END AS ' logical_write_sum_1m ' FROM dbo.dm_exec_procedure_stats_dump WITH (NOLOCK) WHERE collect_date BETWEEN @start_date AND @end_date AND object_name not like ' sp[_]% ' AND exists ( select * from sys.objects ob with (nolock) where ob.object_id = object_id(object_name) and is_ms_shipped = 0 ) ) AS sample WHERE NOT exec_count_sum_1m= ' - ' ORDER by collect_date, object_name 上記のクエリで収集した情報を次のようなサーチ文で可視化できます。 index = " heavy_forwarder_db " sourcetype= " dbconnect " host=XXX source= " XXX-procedure-stats " | timechart span=1m useother= false limit= 20 sum (exec_count_sum_1m) by stored_procedure 収集に使用した時間とSplunkが受信する時間に差があるので表示上少しのずれは発生してしまいますが、許容範囲としています。 また、上記のサーチ文では上位20件の情報を表示させていますが、別途テキスト入力欄を設けて特定のストアドを追えるダッシュボードも提供しています。 インストールされているServerのログ情報の送信 Splunk DB Connectとは別に、サーバーにSplunk Universal Forwarderをインストールすることでパフォーマンスモニタやイベントログを送信できます。 同一ダッシュボード内でイベントログとクエリのパフォーマンス情報を表示することで両者の相関関係を結びつけることができ、以下の切り分けが容易になります。 サーバー自体の問題なのか サーバー上で動いているSQL Serverの問題なのか SQL Server上で実行された特定のクエリの問題なのか Splunkによるダッシュボード化を行うことで、DMVやSQLに精通していないメンバーがトラブルシュートのために必要な情報を容易に取得できるようになりました。またプライベートなネットワークを経由して本番環境のDBサーバーにSQLを実行する必要がなくなり、運用の利便性と安全性の向上を実現しました。 DatadogのDatabase Monitoringについて 弊社ではDatadogも活用しています。 techblog.zozo.com 2022年8月、DatadogのDatabase Monitoring機能がSQL Serverに対してもサポートされるようになったため、オンプレミス環境の主要DBへの導入を進めました。 Datadog Agentのインストール方法については公式サイトをご参照ください。 docs.datadoghq.com データ収集は以下の理由からKubernetesクラスタ上にDatadog Agentのpodを立ててDBにアクセスする方法を採用しました。 元々RDSに接続してメトリクスを取得するための雛形を用意していたこと 何か問題があった場合にAgentのアンインストールが不要であること オンプレの場合は直接Agentをインストールした方がOSのメトリクスなど取得できる情報は増えるが別手段で収集済みであり、そこまで重要視しないこと Database Monitoringを使用して改善した例 CPU使用率の高いクエリの検出と改善 Database Monitoringを有効化することで使用できるようになる クエリメトリクスビュー ではクエリ毎のリソース使用率を見ることができます。 次の画像はWORKER TIME(CPU)順で並び替えた画像であり、特定のクエリでCPUを多く使っていることがわかります。 クエリの詳細を確認した結果が次の画像となりますが、グラフから実行時間が安定していないことがわかりました。それぞれの時間帯で記録されていた実行計画を見たところ、遅い時間帯のみ特定のテーブルでスキャンが発生していました。 問題のクエリの平均レイテンシ パフォーマンスが悪い時の実行計画 パフォーマンスが良い時の実行計画 スキャンするプランは望んでいないためFORCESEEKヒントを追加したところクエリパフォーマンスが改善し、DB全体のCPU使用率の改善も確認できました。 Database Monitoringの導入により、視野が広がったことで今まで問題視していなかった部分に対しても先回りして修正できるようになりました。手動で実施していたパフォーマンス情報の収集や キャッシュからの実行計画の収集 もクエリメトリクスビューから閲覧可能なので不要となり、作業の効率化へと繋げられました。 パフォーマンスが急に悪化した場合の原因調査 実行計画の変化や特定の負荷がかかった場合などパフォーマンスが急激に悪化することが度々ありました。その場合はDMVを利用して深掘りを行なっていましたが、最初のアクションとしてDatabase Monitoringを確認するという手段が取れるようになりました。 画像はハードウェア起因のトラブルが発生し、エラーが多発してしまった際のものです。 WriteLogのWaitが大量に発生してしまっており、何らかの要因でトランザクション書き込みが待たされていることがわかります。 緊急時には一刻も早い原因特定が求められるため、簡単に確認ができ、また対応できるメンバーを増やせることは非常に嬉しいポイントです。 今までだと問題に応じて様々なDMVを使い分ける必要があり、対応メンバーにはDMVに対する知見が必要でした。Database Monitoringを活用することで様々な角度から初期調査ができ、属人化の削減に繋げられました。 今後の展望 以上のように、SplunkとDatadogを用いてDBのパフォーマンスを可視化する取り組みを進めました。 現状の両者の使い分けとしては、Datadogによって自動でパフォーマンスを取得し、カバーしきれていない範囲をSplunkのダッシュボードにまとめています。 ただしその理由は時系列的な側面が強く、例えばDatadogでもカスタムクエリを使用したメトリクス化は実現可能であるためそれらをDatadog側に寄せていくことも可能です。 一方でSplunkは自社ではDBに関連する様々なリソースのログも蓄積されてきているため、相関的に情報を得やすいというメリットが存在します。 両者のメリットを活かしつつ、さらに最適なDBパフォーマンスの可視化戦略を今後考えていきたいと思います。 また、現状一部のDBにしか対応できていないため、他のDBに対しても同様の可視化を進めていきたいです。 最後に ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
こんにちは、バックエンドエンジニアの 近 です! 2023/5/11〜13に長野県にて開催されたRubyKaigi 2023でプラチナスポンサーとして協賛し、スポンサーブースを出展しました。 また、今年は我々が運営しているファッションコーディネートアプリ「WEAR」のサービス紹介CMを作成し、RubyKaigiの会場にて放映させていただきました。 technote.zozo.com technote.zozo.com 実際に放映されたCMは以下になります! www.youtube.com 我々が運営・開発しているファッションコーディネートアプリ「 WEAR 」のバックエンドはRuby on Railsで開発しています。2013年にVBScriptで作られたシステムですが、2020年頃からVBScriptのシステムをコードフリーズし、リプレイスをはじめました。現在もリプレイスを進めながら、新規の機能もRubyで開発しています。 次に、セッションの紹介とブースでの取り組み、その他RubyKaigiの様子をお届けします。 今年はバックエンドエンジニア9人の参加となったため、盛り沢山の内容となっております! エンジニアによるセッション紹介 Multiverse Ruby @tsuwatch です!  Multiverse Ruby を紹介しようと思います。このタイトルだけ見るとなにかよくわからず、もしかしたら候補から外れていた方もいるかもしれません。 内容としては、グローバルなネームスペースを利用せずにコードを共有できる Im (イム)というGemの紹介です。Isolated Module Loaderと紹介されています。Rubyはグローバルな名前空間を利用するしかなく、名前の衝突が問題になります。 解決策として、Rubyの匿名モジュールという特徴を利用して名前空間を作り出し、利用したいモジュールをロードできるというものです。 mod = Module .new mod.name #=> nil mod:: Foo = Module .new mod:: Foo .name #=> "#<Module:0x0…>::Foo" MyFoo = mod:: Foo mod:: Foo .name #=> "MyFoo" 実装としては Kernel#load の wrap オプションにモジュールを指定することで、そのモジュール配下に load できる機能の活用をしていました。また、 Module#const_added を prepend することで、匿名モジュールを命名したタイミングで処理を挟むことなどをしていました。 :: Module .prepend( Im :: ModuleConstAdded ) こういう感じで使えます。 require ‘im’ loader = Im :: Loader .for_gem loader.setup loader:: MyGem # my_gem.rbが自動的に読み込まれる こういったRubyの特徴を活かしてハックできるのがRubyの楽しいところで、こういうGemが大好きなので最高でした。 Power up your REPL life with types 天春です。去年はオンライン参加でしたが、今年は初めての会場参加でした。思った以上に楽しかったです。 Power up your REPL life with types を紹介しようと思います。 内容としては、irb 1 ではできない型分析に基づいたオートコンプリートを提供する katakata_irb というGemの紹介でした。 katakata_irb をインストールしてrequireを書くだけですぐ使えます。 gem install katakata_irb % irb irb(main): 001 : 0 > require ' katakata_irb ' => true .irbrc に以下の内容を追加しておくと毎回requireしなくても利用できます。 require ' katakata_irb ' rescue nil 今までのirbではメソッドチェーンやブロックパラメーターなどのオートコンプリートは表示されていませんでした。 katakata_irb を使うことで型に合わせてオートコンプリートが表示されるので便利ですね。 メソッドチェーンやブロックパラメーターにオートコンプリートを実現するため、何をしたかの説明もありましたが、難しくて理解はできませんでした。 簡単に説明すると Ripper を使って以下の3段階で実装したとのことでした。 1 . 不完全なコードの構文ツリーを取得する 2 . RBSを使用してメソッドチェーンを評価する 3 . IRBの完了ロジックをオーバーライドする 今回をきっかけにパーサーについて興味が湧いたのでもっと理解できるように勉強したいと思います! Developing Chrome Extension with ruby.wasm 近 です! 自分からはYuma Sawai氏の「Developing Chrome Extension with ruby.wasm」というセッションの紹介をしたいと思います。 このセッションではYuma Sawai氏が作成した、ruby.wasmを使って簡単にChrome拡張機能の開発ができるunloosenというフレームワークの紹介をしていました。 このフレームワークを作成した背景として、以下のように語っていました。 昨年発表されたruby.wasmだが、それを使って作られたアプリケーションが少ない ruby.wasmにgemsを使った記事はない ruby.wasmを使った開発環境がまだ万全ではない 開発のしやすさは、開発者の増加に繋がるのではないか 次に、unloosenのメリットとして、以下が紹介されていました。 Simple Syntax フレームワークのTopLevelにdocumentやalertなどのエイリアスを読み込んでいるため、JavaScriptの機能が簡単に使用できる Live Reload コードの変更があった際に拡張機能の再読み込みをしなくてよい Chrome拡張機能の実装に必要なファイルの管理が少なくなる Simple Syntaxについてですが、通常ruby.wasmを使ってJSライブラリを呼び出す場合、以下のように記述します。 JS .global[ :document ][ :body ][ :style ][ :backgroundoColor ] = JS .try_convert( ' red ' ) unloosenではRuby上でJavaScriptのようにコードを書くことが可能となっています。 document.body.style.backgroundColor = ' red ' また、unloosenの使い方も簡単で、以下の手順にてインストール・実装が可能となっています。 unloosen-ruby-loaderという起動用スクリプトをnpm installする インストールした起動用スクリプトをChrome拡張機能用の設定ファイルであるmanifest.jsonにて指定 これにより、ruby.wasmとunloosen本体が読み込まれる メインの処理を記述するapp.rbファイルを作成し、実装する あとは、app.rbをunloosenが読み込み、ruby.wasmにて実装される 自分も実際にunloosenにてChrome拡張機能を実装してみましたが、ruby.wasmを使った実装周りで躓いた箇所は少しあったものの、比較的簡単に作成できました。 皆さんも是非試してみてください! 今回紹介したスライドは以下になります。 speakerdeck.com Learn Ractor 笹沢( @sasamuku )です。趣味はポケモンカードです。私からはMasatoshi Seki氏による Learn Ractor をご紹介します 2 。恐らくはRubyKaigiでポケモンカードに触れていた唯一の発表でした。 発表は「Ractorの紹介」と「ケーススタディ」の2部構成でした。前半ではサンプルコードとともにRactorの次のような特徴が取り上げられました。 Ractor.new に渡すブロック内では外部の変数(グローバル変数含む)にアクセスできない Ractor.new の引数経由であれば外部の変数を渡せるがディープコピーになる Ractor.make_shareable で外部の変数を同一オブジェクトとして Ractor.new の引数に渡せる 3 つまりRactor間でのオブジェクトの共有は基本的にできません。これはRactorがスレッドセーフな並列処理を簡単に書くことを志向しているためです。 手元でも動作を確認してみました。 # `Ractor.new`に渡すブロック内では外部の変数(グローバル変数含む)にアクセスできない a = " hoge " #=> "hoge" Ractor .new { puts a } #=> <internal:ractor>:267:in `new': can not isolate a Proc because it accesses outer variables (a). (ArgumentError) ... # `Ractor.new`の引数経由であれば外部の変数を渡せるがディープコピーになる Ractor .new(a) { |x| puts x } #=> hoge a.object_id #=> 1587220 Ractor .new(a) {|x| puts x.object_id } #=> 1668420 # `Ractor.make_shareable`で外部の変数を同一オブジェクトとして`Ractor.new`の引数に渡せる Ractor .make_shareable(a) #=> "hoge" a.object_id #=> 1587220 Ractor .new(a) {|x| puts x.object_id } #=> 1587220 # 当然ではありますがSymbolなどのイミュータブルなオブジェクトは例外でした b = :hoge #=> :hoge b.object_id #=> 3036508 Ractor .new(b) {|x| puts x.object_id } #=> 3036508 後半ではRactorを活用した高速化事例としてSeki氏が運営されるポケモンカードのデッキ解析サイトが紹介されました。サイトの機能の1つに、デッキの類似度を計算してクラスタリングすることで、ある週のデッキの分布、つまり流行っているデッキを可視化できるというものがありました。デッキの類似度を週ごとに計算する処理をRactor化することで40%ほど処理速度を改善していました。 笹田氏による "Ractor" reconsidered では、Ractorの普及状況が嘆かれていましたが、こうした実例が増えていくことで利用者が増え性能向上のサイクルが回り始めるのだと理解しました。私もこれからはRactorを使った高速化ができないか常に目を光らせていこうと思います。 発表の最後にSeki氏が「今日はデッキを持ってきています」と話されていたのですが、生憎私は持ってきておらず後悔しました。来年は持っていこうと思います! Revisiting TypeProf - IDE support as a primary feature 小島です。私からは 「Revisiting TypeProf - IDE support as a primary feature」 の発表を紹介します。 TypeProfは型注釈のないRubyのコードを型解析してくれます。今回の発表ではこのTypeProfのv2の紹介でした。 発表では、初めに現在のTypeProf v1の課題として型推論だけでは開発者体験の向上に不十分であったとし、TypeProf v2ではIDEサポートをゴールとして開発していると述べていました。TypeProf v1はIDEサポートを考えて作られていなかったこともあり、型解析の速度が遅く、TypeProf v1でそのままIDEサポートを実現することが難しかったようです。そこで、大幅なパフォーマンス改善をすることで、IDEサポートを目標としてTypeProf v2を開発しているとのことでした。 パフォーマンスの改善度合いは数値でも示されていました。TypeProf v1では解析に約3sec掛かっていたところを、v2では初回の解析で約1.003sec、コード編集ごとの追加解析では約0.029secで解析が完了するようでした。 発表ではデモがあり、メソッドに入れる引数の値によって即時に型が推論されVSCode上に表示されるところや、型が間違っている値を代入しようとした場合に警告が出るところなどをデモで見ることができました。 デモを見た感想としては、タイムラグなく型が推論されて表示されておりとてもストレスなく開発できそうでした。 最後に、今回紹介したTypeProf v2はRuby 3.3までに利用可能にすることを目指しているようです。楽しみですね! 今回紹介した発表資料のリンクは以下になります。 speakerdeck.com Ruby + ADBC - A single API between Ruby and DBs 伊藤です。私は今年初めてRubyKaigiに参加しましたが、内容が幅広く、興味深いセッションばかりでした! 私からはSutou Kouhei氏による Ruby + ADBC - A single API between Ruby and DBs を紹介させていただきます! このセッションでは、 A rrow D ata b ase C onnectivity (ADBC) を用いてRubyでも大量のデータを読み書きしようという試みを紹介されていました。 既にEmbulkがあるのではと考えた方もいらっしゃるかと思いますが、Embulkは(J)Rubyのサポートを徐々に縮小していく計画だと発表しています。そこで、Embulkとは異なるアプローチとして、ADBCを用いてみようとのことです。 ADBCは、以下の特徴を持っています。 各種DBにアクセスするための共通API ActiveRecordやSequelも同様 多言語対応 ActiveRecordではRubyでAdapterを実装する必要があるが、ADBCでは他の言語で実装されたAdapterも使える 大きな列指向データに最適化 高速で大量のデータを処理できる Apache Arrow データフォーマットに特化 並列処理が可能 ADBCは大量のデータの読み書きが得意とのことですが、実際どのくらい早いのか気になりますよね? セッション内で紹介されていました! Sutou氏の実測によると、整数値カラム1つだけのテーブルからレコードをSELECTする場合、1000万レコードを参照する際にlibpqの2倍の速度が出るようです。 ただし、libpqの2倍の速度が出るのは Apache Arrow Flight SQL というプロトコルを用いた場合で、libpqをドライバーとして用いた場合はADBCの方が現時点では遅くなるようです。 Apache Arrow Flight SQLとは、Apache Arrow Flight上でSQLを使えるようにしたもので、以下の特徴を持っています。 Arrowフォーマットを使った高速RPCフレームワーク データ交換コストが低い 並列転送 ストリーム処理 Apache Arrow Flight SQLを用いれば、ADBCが高速になるとのことでした。 Apache Arrow Flight SQLを用いると高速になることはわかりましたが、PostgeSQLはApache Arrow Flight SQLを使えるのでしょうか? なんと、Sutou氏は Apache Arrow Flight SQL adapter for PostgreSQL を開発されていました! PostgreSQLでApache Arrow Flight SQLを使用するためのAdapterです。このプロダクトが実用的になると、ADBCを使ってPostgreSQLから高速に大量データを取り込んだり取り出したりできるようになるとのことです。 また、RubyからADBCにアクセスするためのAPIはありますが、ActiveRecord用のAdapter( Active Record ADBC adapter )の開発も始められたとのことです! Ruby on Railsを使用している身としては非常にありがたいです。 まとめますと、以下のような内容でした。 ADBCを使うとRubyで高速に大量データを読み書きできる PostgreSQLでApache Arrow Flight SQLを使えるようにする Apache Arrow Flight SQL adapter for PostgreSQL を開発中 ActiveRecord経由でADBCを使えるようにする Active Record ADBC adapter を開発中 私達が開発しているWEARは今年で10周年を迎え、大量のデータが蓄積されています。それらのデータをRuby on Rails上で高速処理できるようになるかもしれないとのことで、非常に夢の広がるお話だと思いました。 Sutou氏は開発メンバーを募集されていたので、興味のある方は是非参加してみてはいかがでしょうか? 私もこれを機にADBCやApache Arrow Flight周りについてもっと勉強してみようと思います! Gradual typing for Ruby: comparing RBS and RBI/Sorbet 小山です。私からは Gradual typing for Ruby: comparing RBS and RBI/Sorbet のセッションを紹介します。 このセッションではまずはじめに型定義のエコシステムの誕生を時系列で振り返りました。その後、型定義に使われる言語(RBS, RBI)、Type Checker(Steep, Sorbet)といった複数の手段で型定義にアプローチができるものに対する各特徴が解説されました。 個人的に、Rubyの型定義は言語やツールが複数存在していてそれぞれの役割を把握できていなかったのですが、このセッションのおかげで整理されてとても感謝しています。 話者がSorbetを開発しているShopifyで働いていることもあって、セッション中Shopify社内におけるSorbetや型定義に関するサマリーとアンケートが発表され、その内容も興味深かったです。 Shopifyのモノリスのうち98%のファイルに対して型付けがされており61%のメソッドに対してsigが付与されている Shopifyの400を超えるプロジェクトがSorbetを採用している より多くのコードに型付けされていることを望むかという質問に対して、Shopifyのエンジニアが2019年7月時点では57%がyesだったが、2022年9月時点では79%がyesと回答している Sorbetを他のShopifyのプロジェクトに導入することを望むかという質問に対して、2019年7月時点では39%がyesだったが、2022年9月時点では70%がyesと回答している これらからRubyの型定義を積極的に現場に導入していて、その結果ポジティブな反応が得られていることがわかりました。 また、発表の中で一番印象的だったのが、SteepとSorbetでType Checkingの速度を比較してみた結果でした。大規模なShopifyの本体のアプリケーションに対してそれぞれでType CheckをしたところSteepは完了に45分要したのに対し、Sorbetは10分で完了したとのことでした。 実際に運用しているアプリケーションでType Checkをしてみたベンチマーク結果が聞けたのは貴重でした。 Steepは型定義にRBSを使い、Sorbetは型定義にRBIを使うのですが、RBS, RBIそれぞれで、現状どのRubyの文法に対応できているかの対応表もとてもわかりやすかったです。 このセッションはRubyの型定義をキャッチアップできていなかった自分にとってとても良い学びになりました。今回の学びを足がかりにして、プロダクトに導入できるように調査を進めていきたいと思います! Implementing "++" operator, stepping into parse.y 三浦 です。 今年のRubyKaigiはパーサーに関するセッションがたくさんありました。 その中でも印象に残ったShioiさんのセッション「Implementing "++" operator, stepping into parse.y」についてご紹介します。 speakerdeck.com Rubyで実装するとき「なぜインクリメント演算子が使えないのか?」という疑問を持ったことがあるのではないでしょうか。 このセッションではMRIの字句解析器(スキャナ)と構文解析器(パーサー)で i++ はどのように解釈されているのかを探り、試行錯誤しながらインクリメントの実装をしていました。 ruby コマンドでは -y のオプションをつけることで構文解析のログを出力してくれます。 $ruby -ye 'i=0;i++' ... Next token is token '+' (1.5-1.6: ) Shifting token '+' (1.5-1.6: ) // 1つ目の'+'を解析 ... Next token is token "unary+" (1.6-1.7: ) Shifting token "unary+" (1.6-1.7: ) // 2つ目の'+'を解析 Entering state 48 Stack now 0 2 71 313 88 367 48 Reading a token parser_dispatch_scan_event:9857 (1: 7|1|0) // 2つ目の'+'の後に文字がないかを解析 Now at end of input. -e:1: syntax error, unexpected end-of-input i=0;i++ (※こちらはRuby 3.2.2で実行しました) このログを見ると、 i++ の2つめの + は単行演算子として判断されます。 MRIでは + の後には数字が来ることを期待していますが、実際はここでコードは終了しているためシンタックスエラーが発生してしまいます。 インクリメントを実現するために4つの方法を試していました。 - ++の挙動をInteger#succに置き換える - ++ 専用の構文ルールを追加し、この構文ルールに一致した場合 Integer#succ を呼ぶようアクションを追加 - ++ を Integer#succ のエイリアスのような感じで扱えるようになる - しかし Integer#succ はレシーバーの値を+1した結果を返しますがレシーバーに結果の代入はしてくれないので、 i++ としても変数iの値自体は更新されない ++の挙動を自前のメソッドで置き換える Integer#succ に変わる自前メソッド Integer#__plusplus__ を作成し、同じ方法で呼び出す レシーバーの変数名を取得し、その変数名に対して値を代入して返す しかし 1++ といったレシーバーにリテラルが来るとシンタックスエラーとなってしまう ++をスキャナで+=1に置き換える スキャナを改造して、 ++ が来た時に += と同じ構文木になるよう記号を返す しかし i++ * 2 といったインクリメントの後に他の演算子が来た時に演算子の優先度が変わってしまい、iに想定外の値が代入されてしまう ++をパーサで+=1に置き換える i++ 専用の構文ルールを追加し、この構文ルールに一致した場合 i+=1 と同じ挙動になるようアクションを追加 しかし既存の構文が1つ壊れてしまい、 i++ 1 といった予期しない値が来た場合に本来発生しないシンタックスエラーが発生するように パーサーの仕組みから試行錯誤しながら実装した流れまで丁寧に説明されており非常に分かりやすかったです。 動いた、しかしこんな問題が〜という流れの繰り返しは笑いを誘い面白かったです。 Shioiさんは鹿児島Ruby会議02の際に構文解析についての詳しい解説をされておりこちらも非常に勉強になりましたので、興味ある方は是非読んでみてください! たのしいRubyの構文解析ツアー The Adventure of RedAmber - A data frame library in Ruby 高久です。私からはHirokazu SUZUKIさんの 「The Adventure of RedAmber - A data frame library in Ruby」 についてご紹介します。 このセッションでは、Rubyでデータフレームを扱うためのライブラリであるRedAmberの機能紹介やどのように開発したかをデモを交えてお話しされていました。 データフレームとは行と列からなる表形式のデータ構造のことで、スプレッドシートやRDBのテーブルの構造に似ています。RedAmberを使うことで、Rubyらしい書き方で様々なデータ処理を行うことができます。 デモでは、RubyKaigiの過去の開催地リストとGeoloniaの住所データをデータソースとして、最終的には日本地図にRubyKaigiの過去の開催地をマッピングするまでの過程を紹介していました。内容としては両データを結合するためにKeyとなるデータを文字列加工したり、両データをleft_joinをして結合させていたり、高校生ぶりに見たtanを使った簡単な計算をしていました。 自分は業務で大規模なデータ処理を行うことが少ないこともあり、こういったデータ処理を行う時は今まではGoogleのスプレッドシート一択でした。ただオンラインでの処理になるため、データ量が多いとデータの受け渡しや描画処理に時間がかかってしまうこともありました。RedAmberを使うことで書き慣れているRubyで、わかりやすくデータ処理の記述ができるので、今後データ処理を行うことがあれば使ってみようと思いました。 以下発表スライドです。 speakerdeck.com スポンサーブース 今年も去年に続き、スポンサーブースを出展しました。 今年は、去年のTシャツに加えてWEARのロゴやQRコードがプリントされているクッキーや、「一合一会」という洒落の効いたお米、加えてZOZOMATやZOZOGLASSなどを配布しました。 中でもTシャツとお米は好評で、「ZOZOさんのTシャツお洒落ですよね!」や「ブースでお米配ってましたよね!」など、色々なところで感想を言っていただきました。 また、今年はブースにて『エンジニアのファッション事情を大調査!』というアンケートを実施しました。 リモートワーク時の服装は? 全身部屋着 97票 トップスだけ着替える 33票 全身着替える 47票 リモートワークをしたことがない 3票 個人的には「トップスだけ着替える」が一番多くなると予想していましたが、「全身部屋着」派が一番多く、驚きました。また、意外にもちゃんと「全身着替える」派がけっこうな割合いますね。 春に着たいアウターは? コート 29票 ジャケット 126票 パーカー 180票 カーディガン 84票 こちらは予想通り、プログラマーの制服とも言われている(※諸説あり)パーカーが一番多いですね! 僕もパーカー派です。 ノベルティで欲しいファッションアイテムは? Tシャツ 22票 パーカー 51票 靴下 18票 その他 トートバッグ キャップ ハンカチ サコッシュ ウィンドブレーカー 傘 ビーチサンダル マイクロファイバークリーナー 皆さんに「その他」の項目で様々な回答をいただきました。ありがとうございます! 自分達では出ないようなアイテムもあって面白いですね。次回の参考とさせていただきます。 結果としては、ここでもパーカーがかなりの人気となりました。確かにWEARロゴ入りパーカー欲しいです! ブース企画も大勢に参加して頂き、ノベルティも全て配布できました。ありがとうございました! 最後に ZOZOではセミナー・カンファレンスへの参加を支援する福利厚生があり、カンファレンス参加に関わる渡航費・宿泊費などは全て会社に補助してもらっています。ZOZOでは引き続きRubyエンジニアを募集しています。 以下のリンクからぜひご応募ください。 https://hrmos.co/pages/zozo/jobs/0000026 hrmos.co おまけ ブースを設置し、ラーメン屋のようなポーズで記念撮影している様子。 文化祭みたいで楽しいですね。 WEARポーズで集合写真を撮りました。 OfficialPartyの様子。 大勢が参加していました。いろんな人と交流できて楽しかったです! 今年はコロナも落ち着いて、セッションだけでなくOfficialPartyなど色々な人と交流できる場が多くなっていてとても楽しかったです。 Matzさんとも記念撮影できました! 来年はなんと沖縄開催で、会場は大盛り上がりでした。 自分も既にテンションが上がっています。待ち遠しいですね! 今年はセッションだけでなく、交流会も多くあって色々な人と関わることができたのでとても楽しかったです。また来年沖縄でお会いしましょう! irb はInteractive Rubyの略です。対話的に実行 (REPL) するためのシェルです ↩ 発表資料は こちら で公開されています。 ↩ make_shareable できるオブジェクトには制限があります。また make_shareable されたオブジェクトは freeze されます。 ↩
はじめに こんにちは。ZOZO DevRelブロックの @wiroha です。5/25にオンラインイベント「 ZOZO物流システム今昔物語〜モノリスからマイクロサービスへ〜 」を開催しました。ZOZOの開発において「物流システムリプレイス」にフォーカスした技術選定や設計手法、設計時の考え方などを紹介するイベントです。 登壇内容まとめ 弊社から次の3名が登壇しました。 ZOZOTOWN物流システム20年史 (基幹システム本部 物流開発部 / 矢野 敏明) 現在のZOZOTOWN物流システムの概要紹介 (基幹システム本部 物流開発部 / 武信 一平) モノリスからの脱却に向けた物流システムリプレイスの概要紹介 (基幹システム本部 物流開発部 / 矢部 佑磨) 当日の発表はYouTubeのアーカイブで視聴可能です。 www.youtube.com ZOZOTOWN物流システム20年史 矢野より物流拠点・サービス・システムの歴史を紹介 speakerdeck.com 矢野からはZOZOTOWNの物流システムの歴史について発表しました。2004年のサービス開始時からと長い間継続しているシステムです。VBScriptを使用しており当時は適していた技術であるものの、現在では技術者が不足しているといった課題が出てきています。物流拠点「ZOZOBASE」と開発側双方の課題を解決するため、発送業務からリプレイスを開始することになりました。「大事なのは温故知新」ということで「今のシステムへのリスペクト」「新しいことに取り組む姿勢」は大事なメッセージだと感じました。 社内公募制度についてもご紹介し、質問では興味を持っていただいていました。 質疑応答の補足 時間内に回答しきれなかったご質問について、こちらで回答いたします。 質問1. 発送業務がデータの分離をしやすいと分かった経緯をもう少し教えて頂けないでしょうか?既存システムを知っている有識者の知識のみで実施できたのでしょうか?それともデータモデリングなどを実施したのでしょうか? 質問2. 切り出しやすい。ってどう導きだしたのでしょうか? 回答: 2件まとめて回答します。今回はほぼ既存システムを知っている有識者の意見を参考にしました。また、データに付いて分離という言葉を使っていますが意味合いとしてはシステム間が疎結合になっているという意味で分離という言葉を使っています。発送作業の元データは基幹サービスから持ってくるので基幹と発送サービスで同じデータを持っていることになりますがそこにひも付きはありませんので「分離」と表現しています。 分離の観点としまして、発送作業(ピッキング、梱包)において次の観点で話を進めました。 在庫管理が必要であれば分離は困難 在庫管理が必要なければ分離できる可能性あり 今回は発送サービスをあくまで発送作業を行うツールというような形で捉えました。例えば、発送サービス側で商品バーコードと格納ロケーションのみ知っていればピッキング作業は可能です。在庫管理は発送作業完了データを基幹システムに流し、基幹側で非同期に行う仕組みとしました。 上記の理由から次の判断をしました。 発送作業では在庫管理の必要はない 複雑なひも付きのない単体データで発送作業が可能 これらの判断から発送に必要なデータのみを分離しました。 現在のZOZOTOWN物流システムの概要紹介 武信より発送システムの概要を紹介 speakerdeck.com 武信からは発送システムの概要を紹介しました。図解によりさまざまな手順を経ていることがわかります。システム障害リスクの増大、機能追加の労力の増大という課題を解決するためリプレイスをすることになりました。開発案件の起案フローや開発フロー、リリースフローもご紹介しました。非常に多くのご質問をいただき、物流へ興味を持つ方がこんなにもいるのかと嬉しく思います。 質疑応答の補足 質問: アジャイル開発を実施していない理由はありますか? 回答: ZOZOTOWNでは当初からウォーターフォール開発の手法を採用していたのでそれを継続しているというのが一番大きいです。基幹システムという特性上、速く開発する事よりも正確な処理をする事を重視しているという側面もあります。軽微な修正等はアジャイル開発に近い手法で進める事もあります。 モノリスからの脱却に向けた物流システムリプレイスの概要紹介 矢部よりリプレイスの概要を紹介 speakerdeck.com 矢部からはリプレイスの概要・工程について発表しました。超えてきた障壁には技術習得、人員確保、現行システム開発案件との並列化などがあげられました。リプレイスにあたり、独自の物流システムを持っており膨大なビジネスドメインがあるため、ドメイン駆動設計を導入しました。導入により既存よりも大幅に読みやすいコードにできメリットを感じているそうです。リプレイス後のインフラ構成はさまざまな組み合わせで要件やコストを満たすか比較されていました。現段階では正解かわからない点もあるとのことで、今後また聞ける機会を設けられればと思います。 質疑応答の補足 質問: 分析で出てきたメタデータと実装のメタデータはどのように管理されていますか? 回答: 分析で出てきたメタデータはホワイトボードツールを使っていましたので付箋などで表していました。実装のメタデータは基本的にコードで表現しますが、できないものはコメントまたはGitHubリポジトリのWikiに書くなどして使い分けています。 最後に 今回は実際の物がかかわる物流という特殊なドメインにフォーカスしたイベントを開催しました。非常に多くのご質問・ご参加をいただきありがとうございました。質疑応答も含んでおりますので、ぜひ YouTubeのアーカイブ をご覧ください! ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは。FAANSバックエンドエンジニアの浜口( @xlgorbylx )です。普段はFAANSのバックエンドシステムの開発をしています。 FAANSとは、弊社が2022年8月に正式ローンチした、アパレル店舗のショップスタッフの販売サポートツールです。例えば、ZOZOTOWN上で実店舗の在庫取り置きができる機能や、コーディネート投稿の機能などを備えています。投稿されたコーディネートはZOZOTOWNやWEAR、Yahoo!ショッピング、ブランド様のECサイト等に連携が可能です。これによりお客様のコーディネート選びをサポートし、購買体験をより充実したものにします。機能の詳細に関しましては、下記プレスリリースをご覧ください。 corp.zozo.com 本稿では、Go言語で実装されたFAANSのバックエンドシステムについて、SonarSource社の提供するSaaSである「 SonarCloud 」を用いてテストカバレッジ推移を可視化できるようにした経緯と方法、また導入に際して顕在化した課題とその解決方法についてご紹介します。 静的コード解析やテストカバレッジの可視化、ソフトウェア品質の向上に興味をお持ちの方のご参考になれば幸いです。 なお、FAANSの利用技術に関連する記事として「 Cloud FirestoreからPostgreSQLへ移行したお話 」も合わせてご覧いただくとより深くご理解いただけます。 techblog.zozo.com 目次 はじめに 目次 テストカバレッジ推移を可視化した背景 コード解析ツール選定の過程 SonarCloudとは 解析対象となるコード行数について SonarCloud導入までの手順 CIの実行時間について テストカバレッジ計測対象となるテスト CI/CDワークフロー上のジョブ設定 ジョブの分割 SonarCloudにUnit Testの成果物を共有する ジョブの並列実行 SonarCloudの設定ファイルについて SonarCloud設定ファイルの自動生成 まとめ さいごに テストカバレッジ推移を可視化した背景 FAANSのバックエンドシステムでは、Unit Testや各APIエンドポイント単位のIntegration Testが既に実装されています。しかし、テスト対象のソースコードのうち、どの程度の割合のコードがテストされたかについては可視化されておらず、本来テストするべきコードが網羅されていない可能性が否定できない状況でした。 この問題を解決するため、「テストの網羅率(カバレッジ)」を可視化可能なコード解析ツールの導入を検討していました。また、FAANS開発チームは「ソフトウェア品質の更なる向上」を大きな目標としています。コード解析ツールの導入によって「テストカバレッジがどの程度改善されたのか」について定量的に評価できるようになることも期待されていました。 コード解析ツール選定の過程 まず、テストカバレッジを確認したいタイミングについて開発チーム内で認識を合わせました。その結果、「A. テスト対象となるソースコードを新規実装した時」と「B. 既存実装のテストカバレッジを確認したい時」の2パターン存在することが分かりました。 このAのパターンについては「Pull Requestが作成された時」と換言可能であり、CI/CDに用いているGitHub Actions上で実行可能なコード解析ツールであることが求められます。また、Bのパターンについては、コード解析対象とするGitHubリポジトリの任意のブランチのコード解析結果を、任意のタイミングで閲覧可能であれば解決しそうです。 この要件を念頭に複数のコード解析ツールを選定した結果、SonarSource社の提供するSaaSである「SonarCloud」を導入することに決めました。弊社が既に法人契約を結んでおり、社内のいくつかのチームで参考となる導入実績が存在していたため、導入のハードルが相対的に低かったことが決め手となりました。 SonarCloudとは SonarCloud は、 CI/CDワークフロー上で動作するクラウドベースの静的コード解析ツール です。CI/CDワークフローに組み込むことで、GitHub上でのPull Request作成時やブランチへのPush時などに解析対象となるソースコードを下記の観点で解析可能です。 Reliability(コードの信頼性) Maintainability(コードの保守性) Security, Security Review(コードのセキュリティ) Coverage(コードのテストカバレッジ) Duplications(コードの重複) なお、解析済みのPull Requestやブランチについては、SonarCloudの管理画面上にて、Pull Request単位やブランチ単位で解析結果を閲覧可能です。そのため、SonarCloudであれば上記のA, Bどちらのパターンの場合にも適した利用が可能であると判断し採用に踏み切りました。 また、契約プランについては、SonarCloudへ登録しているGitHubリポジトリ内の解析対象となるコード行数に応じて料金が変動する仕組みとなっています。該当のコード行数はSonarCloud側で自動的に計測されますが 1 、利用料金の見積もりのため事前に解析対象となるコード行数を計測することにしました。 解析対象となるコード行数について GitHubリポジトリ内の解析対象となるコード行数について、今回はコード行数計測ツールである「 cloc 」を利用して計測しました。clocは Count Lines of Code の略称であり、対象リポジトリ内で下記のように実行すると言語別にファイル数やコード行数を出力してくれます。 SonarCloudは対象リポジトリ内のソースコードのみを解析対象とするため、ここでは --vcs=git オプションを付与することでGit管理下のソースコードのみをカウントするように指定しています。 cloc --vcs=git 3157 text files. 3141 unique files. 15 files ignored. github.com/AlDanial/cloc v 1.96 T=3.45 s (910.2 files/s, 187794.9 lines/s) ------------------------------------------------------------------------------- Language files blank comment code ------------------------------------------------------------------------------- Go 2458 60059 38064 312271 YAML 531 168 392 120504 JSON 54 0 0 110973 SQL 43 108 8 1747 Markdown 16 400 0 1075 TOML 6 100 0 482 JavaScript 3 54 15 446 Bourne Shell 5 56 32 361 Smarty 3 29 0 201 make 1 47 13 171 Dockerfile 5 17 1 55 Text 7 30 0 53 HTML 6 32 0 45 Properties 2 11 2 21 CSV 1 0 0 4 ------------------------------------------------------------------------------- SUM: 3141 61111 38527 548409 ------------------------------------------------------------------------------- 上記の通り、解析対象となるコード行数が判明したら適切な契約プランを指定して利用を開始します。 SonarCloud導入までの手順 まず、SonarCloud上にOrganization情報を登録します。弊社の場合は、GitHub OrganizationをSonarCloudのOrganizationとして既に連携済みの状態でした。そのため、公式ドキュメントの Getting Started With GitHub を参考にしながら手順を進めました。 次に、Organization配下に対象リポジトリをProjectとして追加し、SonarCloud利用ユーザのGitHubアカウントをOrganizationに紐付けることで利用権限を付与します。 ここまでの手順により、各ユーザのSonarCloudへのログイン及びProjectの閲覧が可能となります。その後、GitHub ActionsによるCI-basedなソースコード解析が実行可能となるように 公式ドキュメント の手順通りに設定します。 以上により、CI/CDワークフロー上でのPull Request単位およびブランチ単位の静的コード解析が実現できました。しかし、私たちFAANS開発チームの場合は下記のような課題が発生しました。 SonarCloudの静的コード解析に要する時間分、CIの実行時間が長くなってしまう SonarCloudの設定ファイル( sonar-project.properties )のメンテナンス性が低い ここからは上記2点の課題について、その詳細とどのように解決したかをご紹介します。 CIの実行時間について まず、テストカバレッジの計測対象となるテストを整理し、そのテストをGitHub Actions上でどのように実行しているかを紹介します。その後、CIの実行時間をどのように抑制・削減したかについて説明します。 テストカバレッジ計測対象となるテスト FAANSバックエンドシステムにはUnit TestとIntegration Testが既に実装されていますが、SonarCloudによるカバレッジ計測が可能な対象はUnit Testのみとなります。 Integration Testが計測対象に含まれない理由は、Go言語で起動したAPIサーバにリクエストを投げ、期待したレスポンスが返却されるか否かという「結果」のみに注目したテストであるためです。テスト自身は「どのように実装されているか」という詳細については把握していないため、カバレッジ計測の対象外となります。 一方で、Unit Testの場合はテスト実行時に解析対象となるソースコードのどの程度の割合がテストされたかが把握可能なため、SonarCloudによるカバレッジ計測が可能となります。 CI/CDワークフロー上のジョブ設定 これまでの既存実装においては、Pull Request作成時およびメインブランチへのpush時に、GitHub ActionsによるUnit TestおよびIntegration Testを 1つのジョブ内で かつ 順次的に 実行していました。 そのため、実装量の増加に比例してCIのテスト実行時間が増長しており、開発効率が次第に低下している状態でした。 この状況を改善することなくSonarCloudの静的コード解析をCIに追加する場合、計測対象となるUnit Testの完了を待つということは即ち、待つ必要のないIntegration Testの実行完了も無駄に待機し、さらに静的コード解析に要する時間もCIの実行完了までに上乗せさせることとなります。 この状況を改善させるため、Unit TestとIntegration Testで ジョブを分割すること と 並列でテストを実行すること を決めました。 ジョブの分割 Unit TestとIntegration Testのジョブを分割するにあたり、Go言語や依存パッケージのインストール、DBのセットアップなどの両者で同一のステップについては、記述内容の重複により保守性が低下してしまうことを予防するため、YAMLで定義された1つの設定内容を共通の定義として利用するようにしました。 ジョブ間で同じステップの設定内容を共通化させるためには、作成したステップに対して「 composite action 」を定義する必要があります。 composite actionを利用すると、任意のステップの処理を別のYAMLファイルに切り出すことが可能です。この別のYAMLファイルに切り出した処理を呼び出す形式で、異なるジョブ間でも記述内容の重複を発生させずにステップの実行内容を設定可能となります。 一方で、ジョブを分割した影響により1点追加の対応が必要になります。SonarCloudによる解析を実行するジョブに対して、Unit Testによって出力されるテストレポート等の成果物を共有する必要があります。 Unit Testを実行するジョブとSonarCloudによる解析を実行するジョブが異なるジョブである場合、どのようにすれば成果物を共有できるでしょうか? SonarCloudにUnit Testの成果物を共有する 同一ワークフロー内の異なるジョブに成果物を共有したい場合、GitHub Actionsの upload-artifact と download-artifact の利用が考えられます。 これらを利用すると、GitHub上のストレージ領域に任意の成果物を保存することが可能となり、同一ワークフロー内の任意のジョブからダウンロードして利用できます。 具体的には、Unit Test実行ジョブの完了後にupload-artifactを用いてテストレポートをGitHub上のストレージ領域に保存します。その後、SonarCloudによる解析を実行するジョブではdownload-artifactを用いてこのUnit Testの成果物を読み込ませます。 このように対応することで、ジョブを分割した場合にも異なるジョブの成果物を利用して次のジョブの処理を実行可能となります 2 。 ジョブの並列実行 GitHub Actionsの公式ドキュメント に記載の通り、異なるジョブはデフォルトでは相互に並列で実行されます。これまでUnit TestとIntegration Testが順次実行されるようになっていた理由は、同一のジョブ内で異なるステップとして定義していたためでした。そのため、上述の通り両者のジョブを分割しただけでUnit TestとIntegration Testの並列実行が実現されます。 以上により、「ジョブの分割と並列実行」が実現できました。Integration Testの実行完了を待たずにUnit Testが実行され、完了次第SonarCloudのコード解析が始まるように改善されたため、CI実行時間の肥大化という課題は解消されました。 次は「SonarCloudの設定ファイル( sonar-project.properties )のメンテナンス性が低い」という課題について見ていきましょう。 SonarCloudの設定ファイルについて SonarCloudは、デフォルトではリポジトリのルート直下にある sonar-project.properties を設定ファイルとして読み込みます 3 。 この設定ファイルにはOrganization情報やプロジェクトキー、解析対象としたいソースコード等を記述します。なお、静的コード解析およびテストカバレッジを適切に取得するためには、解析対象に含めたくないファイルをこの設定ファイル上で個別に除外指定する必要があります。最終的に記述したい内容は下記のようなイメージです。 # sonar-project.properties sonar.organization=ORGANIZATION_NAME sonar.projectKey=PROJECT_KEY sonar.sources=. sonar.exclusions=**/*_test.go,**/hoge/**,**/huga/**,**/openapi/**,**/mock/**,**/auto_generated_model/**,**/vendor/**,**/*.js sonar.tests=. sonar.test.inclusions=**/*_test.go sonar.test.exclusions=**/vendor/** sonar.coverage.exclusions=**/hoge/**,**/huga/**,**/openapi/**,**/mock/**,**/auto_generated_model/**,**/vendor/**,**/*.js,**/integration/**,**/testutil/** sonar.go.tests.reportPaths=./test-results/report.json sonar.go.coverage.reportPaths=./test-results/coverage.out 上記をご覧の通り、除外したいファイルが増えた場合に適宜カンマ区切りで対象ファイルを追記する必要があります。しかし、多くのファイルを指定していくと、次第に見通しが悪くなりメンテナンス性を著しく低下させてしまいます。 この課題を解決すべく、Go言語の標準パッケージである text/template を利用して、この設定ファイルをmakeコマンド1つで自動生成できるように工夫しました。 SonarCloud設定ファイルの自動生成 関連するディレクトリ・ファイル構成は下記のようなイメージです。 . ├── Makefile ├── (sonar-project.properties) #makeコマンドで自動生成されるSonarCloud設定ファイル └── sonarcloud ├── README.md └── properties_generator ├── config │ ├── config.go │ └── file_list.go ├── main.go └── template └── sonar-project.properties まず、makeコマンドを下記の通り定義します。 sonar-project.properties ファイルを自動生成するためのmain関数を実行させるだけのシンプルなコマンドです。 # Makefile .PHONY: generate-sonar-project-properties generate-sonar-project-properties: go run ./sonarcloud/properties_generator/main.go 次に、自動生成コマンド実行時のテンプレートとなるファイルを作成します。内容は下記のイメージで、SonarCloudの設定項目に対して値をプレースホルダーで定義します。 # sonarcloud/properties_generator/template/sonar-project.properties sonar.organization={{ .Organization }} sonar.projectKey={{ .ProjectKey }} sonar.sources={{ .Sources }} sonar.exclusions={{ .Exclusions }} sonar.tests={{ .Tests }} sonar.test.inclusions={{ .TestInclusions }} sonar.test.exclusions={{ .TestExclusions }} sonar.coverage.exclusions={{ .CoverageExclusions }} sonar.go.tests.reportPaths={{ .GoTestsReportPaths }} sonar.go.coverage.reportPaths={{ .GoCoverageReportPaths }} makeコマンド内で実行されるmain関数の中身は、一部省略・簡素化してますが下記のようになります。 sonar-project.properties ファイルを生成し、テンプレートファイルのプレースホルダーに対して値を書き出す処理となっています。 // sonarcloud/properties_generator/main.go package main import ( "fmt" "os" "text/template" "github.com/*****/sonarcloud/properties_generator/config" ) func main() { t, err := template.ParseFiles( "./sonarcloud/properties_generator/template/sonar-project.properties" ) if err != nil { panic (err) } // 生成したpropertiesファイルはルート直下に配置する targetFile, err := os.Create( "./sonar-project.properties" ) defer func (f *os.File) { if err := f.Close(); err != nil { fmt.Println(err) } }(targetFile) if err != nil { panic (err) } c := config.NewConfig() if err = t.Execute(targetFile, c); err != nil { panic (err) } } 上記の config.NewConfig() の部分を詳細に見ていきましょう。下記の通り NewConfig() はテンプレートファイルに定義したプレースホルダーと一致するFieldを持つ構造体を返却します。そのため、 t.Execute(targetFile, c) が実行されると、プレースホルダー部分に実際の値が書き出されます。 // sonarcloud/properties_generator/config/config.go package config import "strings" const ( organization = "ORGANIZATION" projectKey = "PROJECT_KEY" sources = "." tests = "." goTestsReportPaths = "./test-results/report.json" goCoverageReportPaths = "./test-results/coverage.out" ) type Config struct { Organization string ProjectKey string Sources string Exclusions string Tests string TestInclusions string TestExclusions string CoverageExclusions string GoTestsReportPaths string GoCoverageReportPaths string } func NewConfig() *Config { return &Config{ Organization: organization, ProjectKey: projectKey, Sources: sources, Exclusions: convertToCommaSeparatedList(exclusions()), Tests: tests, TestInclusions: convertToCommaSeparatedList(testInclusions()), TestExclusions: convertToCommaSeparatedList(testExclusions()), CoverageExclusions: convertToCommaSeparatedList(coverageExclusions()), GoTestsReportPaths: goTestsReportPaths, GoCoverageReportPaths: goCoverageReportPaths, } } func convertToCommaSeparatedList(list [] string ) string { return strings.Join(list, "," ) } // exclusions関数には、コード解析の対象(sonar.sources)から除外したい要素を指定します。 func exclusions() [] string { list := make ([] string , 0 ) list = append (list, TestFiles()...) // テストコード list = append (list, AutoGeneratedFiles()...) // 自動生成コード list = append (list, VendorFiles()...) // 外部ライブラリ list = append (list, NonGoFiles()...) // Go言語以外のコード return list } // testInclusions関数には、テストコード解析の対象を指定します。 func testInclusions() [] string { list := make ([] string , 0 ) list = append (list, TestFiles()...) return list } // testExclusions関数には、テストコード解析の対象から除外したい要素を指定します。 func testExclusions() [] string { list := make ([] string , 0 ) list = append (list, VendorFiles()...) return list } // coverageExclusions関数には、テストカバレッジ解析の対象から除外したい要素を指定します。 // 指定しない場合、全てのコードがテストカバレッジの解析対象となり正確なカバレッジが取得できません。 func coverageExclusions() [] string { list := make ([] string , 0 ) list = append (list, AutoGeneratedFiles()...) // 自動生成コード list = append (list, VendorFiles()...) // 外部ライブラリ list = append (list, NonGoFiles()...) // Go言語以外のコード list = append (list, TestUtilFiles()...) // テストにのみ使用するhelperやfactoryのコード list = append (list, IntegrationTestFiles()...) // 結合テストのコード return list } ここまででmakeコマンド、makeコマンドで実行されるmain関数、main関数実行時に実際に値を書き出すための構造体を返却する関数が登場しました。 最後に、実際に人間がメンテナンスする必要のあるファイルは下記になります。ファイルパスの文字列を要素とする配列のsliceを返却する関数をファイルの種類ごとに定義し、該当するファイルパスをその配列の要素として指定するだけです。 ファイルパスを追加・削除・修正したい場合は下記のファイルを更新し、 make generate-sonar-project-properties を実行する運用となります。 // sonarcloud/properties_generator/config/file_list.go package config // TestFiles関数には、テストコードのファイルパスを指定します。 func TestFiles() [] string { return [] string { "**/*_test.go" , } } // VendorFiles関数には、外部ライブラリのファイルパスを指定します。 func VendorFiles() [] string { return [] string { "**/vendor/**" , } } // AutoGeneratedFiles関数には、自動生成されるファイルパスを指定します。 func AutoGeneratedFiles() [] string { return [] string { "**/openapi/**" , "**/mock/**" , } } // NonGoFiles関数には、Go言語以外のファイルパスを指定します。 func NonGoFiles() [] string { return [] string { "**/*.js" , } } // IntegrationTestFiles関数には、結合テストのファイルパスを指定します。 func IntegrationTestFiles() [] string { return [] string { "**/integration/**" , } } // TestUtilFiles関数には、テスト利用目的のヘルパー関数系のファイルパスを指定します。 func TestUtilFiles() [] string { return [] string { "**/testutil/**" , } } いかがでしょうか。「SonarCloudの設定ファイル( sonar-project.properties )のメンテナンス性が低い」という課題は、ある程度見通しのよい状態で管理できるように改善されました。 要点を端的に伝えるため実際の設定内容をかなり簡素化し記載していますが、実際にはその他の自動生成コード等の影響で除外指定ファイルがとても多い状況でした。カンマ区切りの文字列をベタ書きしていた当初は、コード解析対象を適切に指定できているか極めて不透明な状態でした。 今回の改善により、意図した通りの適切なテストカバレッジが取得されていることがある程度担保されるようになりました。 まとめ 上記一連の対応により、FAANSではSonarCloudを用いた静的コード解析によるソフトウェア品質向上のための第一歩を踏み出すことができました。SonarCloudの導入に際して、CI実行時間の肥大化防止やメンテナンスコストの抑制、適切な解析対象の設定が課題となりましたが、上述の通り1つ1つ丁寧に問題解決しながら課題を解消できました。 なお、SonarCloudの導入はあくまでソフトウェア品質向上のための第一歩であり、導入後もコード解析結果を継続的に意識する必要があります。 私たちFAANS開発チームでは、SonarCloudがPull Requestに対して発行するコード解析後のコメントを確認する運用としており、日頃から静的コード解析の恩恵を享受するようにしています。 例えば下記画像の例では、解析対象のソースコード内にTODOコメントが1点残存しているため「1 Code Smell」と表示されています。これは極めて簡単な一例ですが、静的コード解析が無ければ見逃してしまっていたようなバグやテストの実装漏れ、コードの重複、脆弱性等に対して常日頃からアンテナを張れるようになりました。 FAANSはまだ歴史の浅い新進気鋭のサービスですが、新規機能の開発・リリースのみならず、ソフトウェア品質の維持・向上についても強い関心を寄せながらチーム一丸となって日々取り組んでいます。 さいごに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com https://hrmos.co/pages/zozo/jobs/0000175 hrmos.co SonarCloud公式サイト のFAQにて、コード行数に関する詳細な説明を確認できます。 ↩ より詳細な説明は GitHub公式ドキュメント をご参考ください。 ↩ sonar-project.properties ファイルに設定可能な項目は SonarCloud公式ドキュメント に記述されています。 ↩
こんにちは。ZOZO DevRelブロックの @wiroha です。RubyKaigiではじめて協賛ブースに立ち、知り合いも増えて嬉しく感じている今日この頃です。 はじめに 5/18に After RubyKaigi 2023〜メドピア、ZOZO、Findy〜 をオフライン・オンラインのハイブリッドで開催しました。RubyKaigi 2023のスポンサー企業であるメドピア株式会社、株式会社ZOZO、ファインディ株式会社の3社合同でのRubyKaigi Afterイベントです。 イベント概要 3社の社員によるLT、RubyKaigi SpeakerによるLT、パネルディスカッションを行い、その後は懇親会で盛り上がりました! LT REPLとデバッガを取り巻く環境の変化 -Pry, IRB, そしてdebug.gem- / メドピア株式会社 古川健二 @frkawa_ ruby.wasm + unloosenでChrome拡張機能を作ってみた / 株式会社ZOZO 近海斗 @Ver3Alt そうだ RubyKaigi、行こう。 〜初めてのRubyKaigiの歩き方〜 / ファインディ株式会社 遠藤薫 @aiandrox Speakers LT Road to RubyKaigi Speaker (case sue445) / Go Sueyoshi @sue445 After RubyKaigi 2023〜メドピア、ZOZO、Findy〜 / unasuke(Yusuke Nakamura) @yu_suke1994 パネルディスカッション登壇者 Go Sueyoshi @sue445 unasuke(Yusuke Nakamura) @yu_suke1994 メドピア株式会社 平川弘通 @arihh 株式会社ZOZO 諏訪智大 @tsuwatch ファインディ株式会社 神谷健 @k_m_y_ 当日の発表はYouTubeのアーカイブでご覧下さい。 www.youtube.com 発表詳細 REPLとデバッガを取り巻く環境の変化 -Pry, IRB, そしてdebug.gem メドピア株式会社 古川健二さま speakerdeck.com メドピア株式会社の古川さまより、デバッガについての発表が行われました。だんだんとデバッガの機能が拡充され、Rubyのバージョンが上がると選択肢も増えていることがわかります。irbを使ったデモも行われました。 ruby.wasm + unloosenでChrome拡張機能を作ってみた 株式会社ZOZO 近海斗 speakerdeck.com 弊社ZOZOの近からはunloosenを使ったChrome拡張開発の発表が行われました。実際に開発してみた中でのハマりポイントが共有されるのはありがたいですね。デモ動画に対してはあたたかい拍手が送られていました。 そうだ RubyKaigi、行こう。 〜初めてのRubyKaigiの歩き方〜 ファインディ株式会社 遠藤薫さま speakerdeck.com ファインディ株式会社の遠藤さまからは、はじめてRubyKaigiに行ってみた経験談が発表されました。実際に参加してみてのアドバイスとして「予習をする」「目標を立てる」「公式イベントに参加する」「写真を撮る」といった点をあげていました。「次回はコントリビュートしたいという熱が高まった」というお話には、皆さん共感したのではないでしょうか。 Road to RubyKaigi Speaker (case sue445) Go Sueyoshiさま speakerdeck.com Go SueyoshiさまはこれまでRubyKaigiに5本応募して3本採択されたという高い採択率を誇っていました。プロポーザルを書くときのコツは「イベントの趣旨に合ったことを出す」「1年考え続ける」「自分が第一人者であることを出す」「早めに出してレビューをもらう」など、説得力のあるアドバイスだと感じました。「それってsueさんがすごいだけでは?」と疑問があるかもしれませんが、「すごいと言ったらその人とに壁を作って成長しなくなる」という言葉が印象的でした。 After RubyKaigi 2023〜メドピア、ZOZO、Findy〜 unasukeさま slide.rabbit-shocker.org unasukeさまの発表は事前に受け付けていた質問への回答からはじまりました。RubyアソシエーションGrantという開発助成金の制度に応募した体験談はなかなか聞けない話だと思いました。新卒の頃RubyKaigiに初参加し、その後proposalを出したりhelperとして参加するようになったりしたそうです。採択されるには自分でコードをめっちゃ書く、採択されなくても楽しんだもの勝ち、という明るくなるメッセージをいただきました。 パネルディスカッション 楽しかった話に花が咲きました まずは各社のブースが紹介され、数年越しの思いが込められていたり工夫が感じられました。来年は那覇ということで行きたい方がたくさんいるのは各社同じのようでした。楽しみですね! RubyKaigiの感想としては、人が想像以上にたくさん来ており情熱を感じたという話がありました。登壇者視点では、オフラインだとトークの後にそのまま登壇の感想をもらえるのが嬉しかったそうです。 懇親会 乾杯! Rubyメソッドかるたで盛り上がるみなさま 懇親会ではドリンク片手にみなさん盛り上がっていました。登壇者に質問をする方々、参加者持参のRubyメソッドかるたで遊ぶ方々など、楽しんでいただけてよかったです! 最後に 登壇者全員でRubyのポーズ 登壇者のみなさまありがとうございました。今回の発表を聞いて来年は参加しようと思った方、登壇したいと思った方が増えたはずです。引き続きRubyコミュニティを盛り上げていければ幸いです。 ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは。ZOZOTOWN開発本部アプリ部バックエンドの髙井です。普段は筋肉のビルドが趣味のエンジニアをやっています。私のチームではZOZOTOWNアプリのバックエンド全般の開発から運用までを行っています。 突然ですが、皆さんご存知でしょうか? ZOZOTOWNはカスタマーサポートセンターの運営管理や従業員のマネジメント等を総合的に評価する「HDI五つ星認証プログラム」にて、五つ星認証を4回連続で取得しています。これは、CS(カスタマーサポート)対応をする弊社社員の皆さんの愛あるサポートの賜物で、同じサービスに携わる身としてもとても誇らしい気持ちです。 そんなCS対応ですが、問い合わせによっては原因調査をエンジニアが行っています。本記事では、CSからエンジニアに来たお問い合わせ(以後、CS問い合わせと呼ぶ)をまとめたレポート作成の自動化についての事例を紹介します。運用コストを抑えながら様々なデータを見やすくまとめる参考になれば幸いです。 目次 はじめに 目次 CS問い合わせとは CSレポートとは 課題点 Looker Studio お金がかからない 簡単操作でリッチなUIが作れる 連携可能なデータが豊富 データの自動反映 レポート作成の手順 CSレポート自動化で工夫したポイント データの集計とソート 過去データとの比較ができる 過去の問い合わせを検索できる 未回答の問い合わせを検索できる 自動化後の効果 まとめ CS問い合わせとは CS問い合わせについてもう少し詳しく説明します。「はじめに」でも触れましたが、CS問い合わせとはCSからエンジニアへのお問い合わせのことです。お客様からのお問い合わせの中でも、CSが調べられないデータの調査が必要なお問い合わせやZOZOTOWNの仕様に関するお問い合わせなどがCS問い合わせとして寄せられます。 問い合わせをすることはお客様にとっても手間のかかることです。手間をかけてでもお問い合わせをしているということや、お客様〜CS〜エンジニアとやり取りが多いので、CS問い合わせの回答スピードもなるべく早くする必要があります。 また、CS問い合わせは新たなバグの発見にも繋がります。お問い合わせを調査していく中で、機能改善にも役立てています。 CSレポートとは CS問い合わせに対してCSレポートとは、CS問い合わせの各種データを集計した月次レポートのことです。具体的には以下のような情報をまとめています。 問い合わせ傾向の総評 MAUと問い合わせ数の比較 注文者数と問い合わせ数の比較 各チームへのエスカレーション数 エスカレーション割合 各チームのリードタイム カテゴリ別問い合わせ数 これらは全て、Slackのワークフローから取得した回答データを基に生成していました。そして、これらのデータを手動でGoogleスライドにまとめることでCSレポートを作成しています。 また、作成したCSレポートは、CS、開発者間で共有することで、問い合わせの傾向の把握やリードタイム(エンジニアが問い合わせを受けてから回答を作成するまでの時間)の改善などに役立てています。 一方で、運用を続けるうちに課題も見えてきていました。 課題点 出てきた課題は以下のような点です。 毎月のレポート作成に工数がかかる グラフや表を毎回作成する必要がある 過去のレポートと比較しにくい これらの課題を解決するために、Looker Studioを導入しました。 Looker Studio Looker Studio とは、さまざまなデータソースから自動でグラフや表を作成できるツールです。Looker Studioのいくつかのメリットを紹介します。 お金がかからない Looker StudioはGoogleが提供する無料のツールなので、基本的にお金はかかりません。Googleアカウントさえあれば簡単にレポート作成を始めることができます。 簡単操作でリッチなUIが作れる LoockerStudioはとても簡単な操作で見やすいグラフや表を作成できます。SQLの知識やプログラミングスキルは不要なので、個人的にはGoogleスライドと同じくらい簡単で使いやすいツールだと思っています。 画像の例は、Looker Studioが提供する サンプルレポート です。 これだけクオリティの高いレポートでも簡単な操作で作成できます。 連携可能なデータが豊富 Looker Studioでは800以上のデータソースに簡単に接続してデータを結合できます。CS問い合わせのデータをまとめているGoogleスプレッドシートにも接続できることが、CSレポートにLooker Studioを使用する決め手の1つとなりました。 データの自動反映 Looker Studioでは、基となるデータソースから取得したデータを見やすい形式に書き換えて表示します。Looker Studioへのデータ連携は自動で行われるので、基のデータソースを更新するとLooker Studioにも自動で変更内容が反映されます。そのため、一度Looker Studioでレポートを完成させてしまえば、それ以降作成したレポートに手を加える必要がなくなります。 結果として、課題であった「グラフや表を毎回作成する必要」がなくなり、その分の「工数削減」ができます。 レポート作成の手順 Looker Studioを使ったレポート作成の手順は以下のようになります。 Looker Studioにログインします。 レポートの追加 空のレポートを選択し、レポートを作成します。 データソースの選択 レポートのデータソースを選択します。利用可能なデータソースの一覧が表示されるので、適切なデータソースを選択します。 ビジュアライゼーションの作成 レポートの編集画面が開きます。ここで、ビジュアライゼーション(グラフやチャートなど)を作成します。 データソースから必要なフィールドを選択し、グラフのタイプや設定を選択します。 必要に応じてフィルターや集計の設定を追加し、データの表示をカスタマイズします。 フィルターの追加 レポートにフィルターを追加して、データを絞り込むことができます。例えば、日付範囲や地域などの条件を設定します。 レポートの設定 レポートのタイトルや説明を追加します。 レポートの表示形式やサイズを調整します。 レポートの保存 レポートが完成したら、保存ボタンをクリックして変更を保存します。 CSレポートを作成する際も同様の流れで作成しました。 CSレポート自動化で工夫したポイント Looker Studioでは、様々なカスタマイズができます。本記事ではCSレポートを作成するにあたって活用したLooker Studioの機能と、工夫したポイントを紹介します。 データの集計とソート Looker Studioでは、データを集計でき、集計方法を選択するだけで意図した値を簡単に集計できます。 CSレポートでは各チームのリードタイムを集計するために、中央値と平均値を表に記載しました。 また、チームごとの問い合わせ数も集計し、グラフに表示させています。 問い合わせ総数の推移と問い合わせに対する対応チーム比率がわかりやすく表示されています。 過去データとの比較ができる これまでのCSレポートでは毎月新しくスライドを作成してレポートをまとめていました。そのため、「去年の同じ月はどんな傾向があったんだっけ?」などと思ったときには、その都度対象のレポートを探し出して見比べる必要がありました。 一方で、自動化後のレポートでは複数レポートを見比べる必要はありません。年月での絞り込み機能を追加したので、1つのレポート内で確認したい年月を絞り込むことができます。 この機能によって、課題として挙げていた「過去レポートとの比較」を可能にし、レポートを見る側にとっての利便性を高めることができました。 過去の問い合わせを検索できる これまで過去の問い合わせを調べるときには、スプレッドシートで調べるかSlack上で調べるしか方法がなく、特定の条件で問い合わせを絞り込む際には工夫が必要でした。 この問題を自動化後のCSレポートで解決しました。同じレポート内で過去問い合わせ検索用のページを作成し、問い合わせを検索できる機能を追加しました。各種絞り込みだけでなく、フリーワード検索もできるところがGoodなポイントです。 実際に使ってみて、Slack上での検索やスプレッドシートでの検索よりもとても便利な機能だと感じています。検索機能の作成もLockerStudioでは容易に実現できました。 未回答の問い合わせを検索できる お客様への返信漏れがないよう、未回答の問い合わせを検索できる機能を追加しました。既存運用ではCSがアラートを上げて初めて気づくパターンが多く、ここをエンジニア内で検知できるようになりました。 具体的には、スプレッドシートに記録される問い合わせ回答一覧の中で、”回答内容”という欄が空欄(null)となっているデータのみを抽出するフィルタを作成し、過去問い合わせをフィルタリングしています。 Looker Studioでは新規フィルターを画像のように簡単な設定だけで作成できます。”nullである”以外にも以下の画像のようにフィルターの種類を選択できます。 この機能によって、未回答の問い合わせのみを表示するグラフを作成でき、未回答の問い合わせを調べる時間を削減できました。 自動化後の効果 CSレポートを自動化した結果、以下のような効果が得られました。 レポート作成に掛ける工数削減 レポートの見やすさの向上 データのリアルタイム性向上 データ調査の自由度向上 元々は工数削減が大きなメリットだと考えていましたが、レポートを作成してみて、「見やすさ」という点もLooker Studio導入の大きなメリットだと感じました。 まとめ 本記事では、Looker Studioを活用したCSレポート自動化の事例を取り上げました。社内での活用事例がほとんどないツールでしたが、実際に使ってみるとたくさんのメリットがあり、導入による効果を感じています。本記事で紹介したCSレポート自動化で、Looker Studioのナレッジを社内で蓄えることができました。今後も新たなツールのナレッジを蓄えていくことで、業務効率化とより高い品質のサービス提供に役立てていきたいと思っています。 ZOZOでは、そんなサービスを一緒に作り上げてくれる方を募集中です。ご興味のある方は、下記のリンクからぜひご応募ください。 hrmos.co hrmos.co hrmos.co
こんにちは。カート決済部カート決済基盤ブロックの高橋です。 カート決済部では、現在Spring BootのJavaプロジェクトを運用しています。今回Spring Bootのバージョンアップを実施した際に発生した問題点と対応内容、注意点をご紹介します。加えて、使用しているライブラリなどのバージョンも上げているのでご紹介します。 アップデート前後のバージョン 種類 前バージョン 後バージョン Java 17 17 Spring Boot 2.7 3.0 Gradle 7.x 8.x SpringFox 3.0.0 - Springdoc-openapi - 2.1 openapi-generator 5.1 6.5 Spock Framework 2.1-groovy-3.0 2.4-M1-groovy-4.0 JavaはSpring Bootのバージョンアップ前からJava 17を使用しており、今回は変更していません。 Spring Bootのバージョンアップ 今回はSpring Bootの2.7から3.0にバージョンアップしています。 以下の表は 公式発表されているSpring BootのバージョンごとのOSSサポート期間 です。 バージョン2.6以前のサポート期間は終了しています。また、2.7や今回バージョンアップした3.0もそれぞれ2023年11月にOSSサポート期間が終了してしまいます。 現在のタイミングですと、3.1のリリースを待って対応でも良かったのですが、以下の理由からこのタイミングで3.0へのバージョンアップを実施しました。 新メンバーが既存プロジェクトに触れる良い機会であったため 3.1への対応をスムーズにするため javaxからjakartaパッケージに変更 Spring Boot 2.7までは、Spring Framework 5.3がベースでしたが、Spring Boot 3.0では、Spring Framework 6.0をベースとしています。 これにより、Java EEからJakarta EE9へ変更になっているため、パッケージの変更が必要になります。これは、パッケージ名を javax から jakarta に変更することで対応しました。 これと同時にJavaのベースバージョンも17となっているため、これより前のバージョンを使用している場合は、Javaのバージョンアップも同時に行う必要があります。 Spring MVCのURLマッチングの変更 Spring Framework 6.0では、URLの末尾のスラッシュにデフォルトで一致しなくなりました。 GET /hoge/ と GET /hoge は一致しなくなり、以下のようなコードでは、 GET /hoge/ はHTTP 404エラーを返すようになります。 @RestController public class HogeController { @GetMapping ( "/hoge" ) public String hoge() { return "Hoge" ; } } これらを一致させるためには以下の2つの方法があります。 1つ目は、以下の通り明示的に宣言することです。 @GetMapping("/hoge", "/hoge/") 2つ目は、Spring MVCのWebMvcConfigurerのconfigurePathMatchメソッドをオーバーライドすることで対応します。 @Configuration public class WebConfiguration implements WebMvcConfigurer { @Override public void configurePathMatch(PathMatchConfigurer configurer) { configurer.setUseTrailingSlashMatch( true ); } } setUseTrailingSlashMatchメソッドは非推奨になっているのでご注意ください。そのため、修正可能である場合は、呼び出し元のパスを統一する形に修正する方が良いと思います。 アクセスログの対応 Spring Boot 3.0以降では、 logback-access-spring-boot-starter が未対応のため、以下のように対応しています。 ライブラリの変更 - implementation "net.logstash.logback:logstash-logback-encoder:6.6" - implementation "net.rakugakibox.spring.boot:logback-access-spring-boot-starter:2.7.1" + implementation "net.logstash.logback:logstash-logback-encoder:7.3" + implementation "ch.qos.logback:logback-access:1.4.6" + implementation "org.codehaus.janino:janino:3.1.9" アプリケーションクラスに以下のBeanを追加 @Bean public WebServerFactoryCustomizer<ConfigurableTomcatWebServerFactory> webServerFactoryCustomizer() { var logbackValve = new LogbackValve(); logbackValve.setFilename( "sample-logback-access.xml" ); return (factory) -> factory.addEngineValves(logbackValve); } ライブラリの変更・バージョンアップ SpringFoxからSpringdoc-openapiへ変更 Spring Bootのバージョンアップと同時にSpringFoxからSpringdoc-openapiへの移行も行いました。SpringFoxがSpring Boot 3.0では動作しなくなってしまったため、これを機にSpringdoc-openapiへ移行しました。 対応内容は、依存ライブラリの変更です。 - implementation "io.springfox:springfox-boot-starter:3.0.0" + implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0" これに伴い、Controllerクラスで使用するアノテーションも変更になっています。 io.swagger.annotations 配下を使用していたものを io.swagger.v3.oas.annotations に変更しています。主なアノテーションの変更点をまとめたのが次の表です。 修正前アノテーション 修正後アノテーション @Api @Tag @ApiOperation @Operation @ApiResponse @ApiResponse @PostMapping @RequestMapping @ApiParam @Parameter 実際のコードは以下の通りです。 // 修正前 @Api (value = "Sample" , description = "the Sample API" ) public class SampleApiController { @ApiOperation ( value = "サンプル取得処理 " , nickname = "sampleRequests" , notes = "サンプル取得処理 " , response = SampleResult. class , tags={ "sampleRequests" , }) @ApiResponses (value = { @ApiResponse (code = 200 , message = "200 (OK)" , response = SampleResult. class ), @ApiResponse (code = 400 , message = "400 (Bad Request)" , response = BadRequest. class ) @ApiResponse (code = 500 , message = "500 (Internal Server Error)" , response = InternalServerError. class )}) @PostMapping ( value = "/sample" , produces = { "application/json" }, consumes = { "application/json" } ) public ResponseEntity<SampleResult> getSample( @ApiParam (value = "" ,required= true ) @Valid @RequestBody GetSampleRequests getSampleRequests, @ApiParam (value = "id" ,required= true ) @PathVariable ( "id" ) String id, @ApiParam (value = "header-id" ) @RequestHeader (value= "header-id" , required= false ) String headerId) { } // 修正後 @Tag (name = "Sample" , description = "the Sample API" ) public class SampleApiController { @Operation ( operationId = "sampleRequests" , summary = "サンプル取得処理 " , description = "サンプル取得処理 " , tags = { "sampleRequests" }, responses = { @ApiResponse (responseCode = "200" , description = "200 (OK)" , content = { @Content (mediaType = "application/json" , schema = @Schema (implementation = SampleResult. class )) }), @ApiResponse (responseCode = "400" , description = "400 (Bad Request)" , content = { @Content (mediaType = "application/json" , schema = @Schema (implementation = BadRequest. class )) }), @ApiResponse (responseCode = "500" , description = "500 (Internal Server Error)" , content = { @Content (mediaType = "application/json" , schema = @Schema (implementation = InternalServerError. class ))} } ) default ResponseEntity<SampleResult> getSample( @Parameter (name = "GetSampleRequests" , description = "" , required = true ) @Valid @RequestBody GetSampleRequests getSampleRequests, @Parameter (name = "id" , description = "id" , required = true , in = ParameterIn.PATH) @PathVariable ( "id" ) String id @Parameter (name = "header-id" , description = "Header Id" , in = ParameterIn.HEADER) @RequestHeader (value = "header-id" , required = false ) String headerId ) { } 次に、ライブラリの変更に伴いアプリケーションクラスの@EnableSwagger2のアノテーションを削除しています。 @SpringBootApplication - @EnableSwagger2 public class SampleApplication { public static void main(String[] args) { SpringApplication.run(SampleApplication.class, args); } } openapi-generatorのアップデート openapi-generatorもこの機会にアップデートをしています。今回のアップデートでは、 openapi.config に以下のような設定を追加・削除しています。 - "java8": true, + "useSpringBoot3": true, + "generatedConstructorWithRequiredArgs": false, generatedConstructorWithRequiredArgs を追加したのは、Lombokのアノテーションと競合してしまうのを防ぐためです。というのは、以下のように必須項目であるrequiredの定義をして自動生成したときにコンストラクタが自動生成されてしまうためです。 type : object properties : item_id : $ref : ./item_id.yaml required : - item_id Spock Frameworkのアップデート テストフレームワークのSpock Frameworkを使用しています。これは以下の通り、groovyのバージョンと共にSpock Frameworkのバージョンアップをすることで対応できました。 - testImplementation "org.codehaus.groovy:groovy-all:3.0.8" - testImplementation "org.spockframework:spock-core:2.0-groovy-3.0" - testImplementation "org.spockframework:spock-spring:2.0-groovy-3.0" - testImplementation "org.spockframework:spock-guice:2.0-groovy-3.0" + testImplementation "org.apache.groovy:groovy-all:4.0.11" + testImplementation "org.spockframework:spock-core:2.4-M1-groovy-4.0" + testImplementation "org.spockframework:spock-spring:2.4-M1-groovy-4.0" + testImplementation "org.spockframework:spock-guice:2.4-M1-groovy-4.0" まとめ Spring Bootの2.7から3.0へのバージョンアップに伴う変更内容としては、以下の通りです。 ベースとなるSpring Frameworkのバージョン変更によるパッケージの変更 Spring MVCのURLマッチングの変更 アクセスログのライブラリ変更 Spring Bootのバージョンアップをしたことで、SpringFoxが使えなくなってしまうということもありました。最初にも書きましたが、Spring Boot 2.7と3.0のOSSサポート期間が2023年11月です。そのため、Spring Boot 2.7以下を使用している場合は、早めに3.0以上にバージョンアップしておくのが良いと思いました。 最後に カート決済部では、仲間を募集しています。ご興味のある方は、こちらからご応募ください。 hrmos.co
こんにちは、バックエンドエンジニアの近です! 4/24〜4/26にかけてアトランタで開催されたRailsConf 2023にWEARバックエンドブロックから近・小山・高久の3人が参加しました。 去年はコロナの影響もあってオンラインの開催だったのですが、今年はオフラインでの開催となり、大勢が参加していて大盛況でした。 我々が開発・運営しているファッションコーディネートアプリ「 WEAR 」のバックエンドはRuby on Railsで開発しています。現在では、新機能の開発やリプレイスなど、チームメンバーの全員がRuby on Railsに関わっているため、今回RailsConfにて様々なセッションを聞けたことはとても有意義な経験でした。 RailsConfとは 1年に1回開催されるRuby on Railsに関する世界最大のカンファレンスとなります。( 公式サイト ) 2020〜2022年はコロナの影響でオンライン開催でしたが、2023年は4年ぶりのオフライン開催となりました。 また毎年開催地が変わり、今年はアメリカのアトランタで行われました。 カンファレンスの様子 以下がRailsConfのメイン会場でした。かなり広かったです。スピーカーの文字起こしもあったので、とてもありがたかったです。 会場はメイン会場に加えて、サブ会場が5箇所もあり、全体的にとても賑わっていました。 また、アメリカの様々なテック企業が参加していて、自分達も知っている所だとGitHub, Shopifyから、アメリカのベンチャー企業まで様々な人たちが参加していました。 発表の種類としてはRailsCoreの話や新しいgemの紹介、マイクロサービスについてなどの技術的な話からエンジニアチームの組織作りやメンターとメンティーの関係構築、ペアプロについての話がありました。更にはワークショップ形式でRailsのバージョンアップを皆で一緒にしたりなど、幅広く様々なセッションやワークショップが行われていました。 また、最終日には1人5分でLTをする時間があり、そこでは自分のエンジニア人生の話やRubyで作った便利ツールの紹介、OSSにコミットした話など、皆気軽にトークしていてとても面白かったです。 この記事では、その中から私たちが興味を持ったセッションをいくつか紹介したいと思います! セッション紹介 Exploring the Power of Turbo Streams & Action Cable バックエンドエンジニアの高久です。Kevin Liebholzさんの「Exploring the Power of Turbo Streams & Action Cable」についてご紹介します。 Rails 7ではよりリッチなフロントエンドを実現するためのHotwire、Turboといったライブラリがデフォルトでインストールされるようになりました。 そして、その機能の1つ「Turbo Streams」と既存の「Action Cable」を使ってリアルタイム通信を使ったWebページが簡単に実現できるようになりました。 Action CableとはRailsでWebSocketを利用したリアルタイム通信を実現するフレームワークです。Rails 5から実装されています。 また、Turbo Streamsとは <turbo-stream></turbo-stream> で囲まれたHTML要素をさまざまなソースをトリガーにして更新できる機能です。 Turbo StreamsとAction Cableを組み合わせることによって、WebSockets通信によるサーバ/クライアント間のリアルタイムでの通信&画面描画を簡単に実現できます。 このセッションでは「まるばつゲーム」のWebページ実装をデモとして、それらのツールを使ってどのように実装するかを紹介していました。 詳しいコードは以下のセッション資料に記載されていますので、気になる方は見てみてください。 speakerdeck.com An imposter's guide to growth in engineering 次も高久よりEbun Segunさんの「An imposter's guide to growth in engineering」のセッションを紹介します。 RailsConfでは技術系の話だけではなく、キャリア・成長に関するセッションが複数ありました。このセッションではインポスター症候群について概要や、成長との関わり、エンジニアに関連した症状、対処法について話されていました。 インポスター症候群とは「明らかな成功にもかかわらず、圧倒的に不十分だと自己を過小評価してしまう傾向」のことを指します。明らかな成功とは「客観的にみて評価されるべき成果を上げた」ということで、ジュニアレベルを卒業したエンジニアに多くみられるそうです。 「インポスター症候群」という名前を自分は聞いたことがなかったのですが、セッション中の挙手によるアンケートでは会場にいた人のほとんどが知っており、また多くの人が経験していると答えていました。世界的には一般的なようです。 症状として「自分は詐欺師のようだ」「自分が思うよりも周りから賢いと思われている」「みんなを失望させるのが怖い」「自分にはこんなことはできないと思う」というような発言をすることが挙げられます。またエンジニア特有の行動として、以下が挙げられます。 話さない、質問しない 話せば話すほど、自分がインポスターだと思われてしまうような気がするため 会議の場で話さなかったり、とりあえず知的な人の意見に同意してしまう 自分の仕事について話さなければいけない時、いつも緊張してしまう 成功するために他のことを探してしまう 自分が生産的だと思われたいため 目の前の難しいプロジェクトより、自分ができるバグ潰しに専念してしまう コードの完璧主義 じっくりと時間をかけてコードを完璧にする プルリクエストを出す前に、自分のコードにシミや傷がないことを確認する これらの行動は個人の成長の妨げになり得ます。しかし決して悪いものではなく、まだ成長の余白があることを示しています。 これらを解決するためにEbun Segunさんは「チームやプロジェクトにおける自分の存在意義を把握する」「自分がすでに知っていることの価値に気づく」が大事であると述べていました。セッションではさらに詳細な話をされていました。 ここで紹介されていた症状が自分にも当てはまることが多く、胸が痛いと共に良い気づきを得ることができ、自身の成長に繋げられそうなセッションだったと感じました。また国や文化に関係なく、みんなが起きていること、悩んでいることを知れることが海外カンファレンスのいいところであると実感しました。 Upgrading Rails: The Dual-Boot Way バックエンドエンジニアの小山です。 RailsConfはセッションだけではなくワークショップも充実していました。今回私はRailsアップグレードのプロセスを学ぶワークショップに参加してきました。 こちらのワークショップは参加者30名程で、講師が用意してくれたスライドの解説を聴きながら、クローンしたサンプルアプリケーションをRails 6.1から7.0にアップグレードするという内容でした。 next_railsというgemを導入し、Railsを複数バージョンで起動するDual Bootという手法を使い、Railsをアップグレードしました。アップグレードの過程で非推奨の警告を確認しRailsとgem依存関係を確認しながらパッチを当てていきました。 next_railsについて補足すると、このgemをインストールすることで、 next --init コマンドが使えるようになります。 このコマンドによりDual Boot用の Gemfile.next と Gemfile.next.lock が生成されます。 next bundle install を実行することで、新しいRailsバージョンに互換性のあるgemをインストールできます。 また、 next rails s でサーバーを起動すると、 Gemfile.next を使用してサーバーが起動されます。 next_railsの詳細は以下のリポジトリを参照してください。 https://github.com/fastruby/next_rails ワークショップの内容をかいつまんでご紹介します。 最初にクローンしたアプリケーションに対してgem next_railsをインストールしてDual Boot用の Gemfile を生成します。 # Add Gemfile group :development , :test do gem ' next_rails ' end $ bundle install $ next --init 次にnext_rails用の設定を Gemfile に追記します。 # Add Gemfile def next? File .basename( __FILE__ ) == " Gemfile.next " end if next? gem ' rails ' , ' ~> 7.0.4.3 ' else gem ' rails ' , ' 6.1.7 ' end railsバージョンをアップデートします。 $ next bundle update rails 今回のRails 6.1から7.0へのアップグレードではbundlerがgem "railties" で互換性のあるバージョンを見つけられずエラーを吐きました。 Bundler could not find compatible versions for gem "railties": In Gemfile.next: activeadmin (~> 2.10.1) was resolved to 2.10.1, which depends on railties (>= 6.0, < 6.2) rails (= 7.0.4.3) was resolved to 7.0.4.3, which depends on railties (= 7.0.4.3) activeadminのバージョンを指定することで解消されるため Gemfile に以下の修正を加えてアップデートしました。 # Fix Gemfile if next? gem ‘activeadmin‘ else gem ‘activeadmin ' , ‘~> 2.10.1’ end $ next bundle update rails activeadmin また、ZeitwerkはRails 6で導入され、Rails 7では必須になっています。Zeitwerkの互換性を確認するタスクを実行するとAPIという定数が存在しないとエラーが出力されます。 $ next bin/rails zeitwerk:check rails aborted! NameError: uninitialized constant API mount API::Base => '/api' ^^^ Did you mean? Api こちらは ActiveSupport::Inflector の語尾の活用機能を用いて略語を指定することでZeitwerkオートローダーの入力時のエラーを修正できます。 # Add to config/initializers/inflections.rb ActiveSupport :: Inflector .inflections( :en ) do |inflect| inflect.acronym " API " end 最後にデフォルトの各バージョンのRailsの設定を読み込むためにload_defaultsを修正します。この更新を忘れると非推奨の警告がでます。 # Fix Gemfile def next? $next_rails = File .basename( __FILE__ ) == " Gemfile.next " end # Fix config/application.rb if $next_rails config.load_defaults 7.0 else config.load_defaults 6.1 # or stay with 5.0 end これらの修正を加えることで非推奨の警告を解消しgemの依存関係を解消してDual BootでRailsを起動できるようになりました。 弊社のサービスであるWEARでは現在Rails 6.0を使用しており7.0へのアップグレードを予定しています。そのため、今回、ワークショップ形式で手を動かしながら7.0へのアップグレードを実践できたことはとても良い経験になりました。 ワークショップで使用した資料が共有されているので、もし興味がある方は手を動かして試してみてください。 docs.google.com Migrating Shopify's Core Rails Monolith to Trilogy バックエンドエンジニアの近です! 自分からはAdrianna Chang氏の「Migrating Shopify's Core Rails Monolith to Trilogy」というセッションの紹介をしたいと思います。 このセッションでは、TrilogyというMySQL用のクライアントライブラリをShopifyのRailsコアに適用するにあたっての追加機能や、デプロイ時に当たった問題とその解決法の紹介をしていました。 RailsとMySQLを繋げるクライアントライブラリとして有名なのはmysql2がありますが、新しいアダプタとしてTrilogyというものがあるのは今回初めて知りました。 Trilogyの特徴として、以下があるそうです。 2022年にGitHubによってOSS化されたMySQLクライアント 独自の低レベルネットワークプロトコル実装 テキスト プロトコルの最も頻繁に使用される以下をサポート ハンドシェイク パスワード認証 クエリ、ping、および終了コマンド 最小限の動的メモリ割り当て、メモリ効率の最適化 柔軟性、性能、組み込みやすさを追求したデザイン また、他のアダプタと比較したときのTrilogyに乗り換えるメリットとして、以下を挙げていました。 コンパイルに必要な依存関係が少ない libmysqlclient / libmariadbへの依存がなく、インストールがシンプル クライアントとサーバーのバージョンの不一致問題を解消 パケットを扱う際のデータのコピー回数を最小限に抑えられる Ruby VMのコンテキストで効率的に動作するよう設計 動的メモリ割り当ての意識的な使用 可能な限りノンブロッキング操作とI/Oコールバックを使用するようAPIが設計されている 確かに、インストール時にlibmysqlclient等の依存関係でエラーにハマったことのある人は多くいると思うので、この辺りが解消され、更にパフォーマンスの向上が期待できるのは嬉しいですね。 上に書いたメリットの中で、さらにShopifyがTrilogyに乗り換えたい強い理由として、以下が挙げられていました。 より良い開発体験 クエリパフォーマンスの高速化 強い保守性 これに加えて、Trilogyをコミュニティのスタンダードにしたいという思いもあるようです。 次に、MySQLがサポートしているマルチステートメント機能の話題になりました。 MySQLでは、セミコロンで区切られた複数のステートメントを含む文字列の実行をサポートしています。 これを、Trilogyを用いたRubyにて記述しようとすると、以下のようになります。 require ' trilogy ' client = Trilogy .new( host : ' 127.0.0.1 ' , port : 3306 , username : ' root ' , multi_statement : true , ) sql = <<- SQL DROP TABLE IF EXISTS users; CREATE TABLE users (name VARCHAR(255)); INSERT INTO users VALUES ('John'); SELECT * FROM users; SQL client.query(sql) マルチステートメントクエリは通常のクエリより高いパフォーマンスを発揮するため、本来上記のように書きたいところなのですが、当時、Trilogyはマルチステートメントの対応はされていませんでした。 Shopifyでは1000を超えるサンプルデータを持っていて、それらが更に100件のデータを持っていたりしているので、これらを効率的にDBへインサートするため、この機能を活用したいと考えていました。 mysql2アダプタでは既にこのマルチステートメントがサポートされているので、Trilogyでもサポートしたいとのことでした。 そして、 multi_statement: trueという構文をサポートするために、C拡張を用いて実装し、その紹介も行っていました。 static VALUE rb_trilogy_initialize (VALUE self, VALUE opts) // ← optsを追加 { struct trilogy_ctx *ctx = get_ctx (self); trilogy_sockopt_t connopt = { 0 }; trilogy_handshake_t handshake; VALUE val; Check_Type (opts, T_HASH); ... if ( RTEST ( rb_hash_aref (opts, ID2SYM (id_multi_statement)))) { // ← connopt.flags |= TRILOGY_CAPABILITIES_MULTI_STATEMENTS; // ← } ... } static VALUE rb_trilogy_more_results_exist (VALUE self) // ← { struct trilogy_ctx *ctx = get_open_ctx (self); if (ctx->conn.server_status & TRILOGY_SERVER_STATUS_MORE_RESULTS_EXISTS) { return Qtrue; } else { return Qfalse; } } static VALUE rb_trilogy_next_result (VALUE self) // ← { struct trilogy_ctx *ctx = get_open_ctx (self); if (!(ctx->conn.server_status & TRILOGY_SERVER_STATUS_MORE_RESULTS_EXISTS)) { return Qnil; 実際のPRはこちらです。 中身はC言語ですが、セッションで紹介している内容が実際に見られて面白いです。 https://github.com/github/trilogy/pull/57 https://github.com/github/trilogy/pull/35 他にも、当日のセッションではマルチステートメントの詳細な説明やTrilogy C APIとRubyの連携・実装の話など詳細に語っていて面白かったのですが、長くなるので触りだけの紹介でした。 次に、Trilogyを実際にShopifyの本番環境にデプロイした時の話になりました。 最初にCIをオールグリーンにするため、以下を修正していました。 まず、MySQLクライアントをMysql2からTrilogyに書き換え Trilogy用に、クライアントの小さなAPI変更に対応 Trilogy用のエラーハンドリング コードは全てMysql2::Errorになっていたため、それらをTrilogy::Errorに変更 もちろん、エラーメッセージも変わるのでテストなどの修正 思いの外、簡単に修正が済んだようです。 この辺りでShopifyのインフラ構成の紹介になりました。 Railsのコアアプリケーションはモジュラーモノリス型 ピーク時は1秒間に1400万クエリが実行される(!) DB周りのインフラ 水平パーティショニング MySQLインスタンスはGoogle Cloud上で動作し、Chefで管理されている ProxySQL クライアント接続数:10万 バックエンド接続数:2万 1秒間に1400万クエリは全く想像がつきません。 Trilogyのデプロイにあたり、まずは本番環境の1%で動作させたところ、スムーズに動作していたのですが、次に50%でデプロイしてみたところ、エラーレートが上昇しリバートすることになったそうです。 なにがあったのか? ProxySQLはバックエンド接続のプールを保持する クライアント機能が異なる場合、ProxySQLはCOM_CHANGE_USERを実行する必要があった mysql2とTrilogyの両方を用いる場合、ProxySQLではバックエンドを切り替える際に新しい接続オプションを設定するCOM_CHANGE_USERコマンドを実行する必要があった というような問題がおき、エラーレートが上昇したとのことでした。 加えて、ProxySQLのpod接続状況を確認したところ、mysql2ではCLIENT_MULTI_RESULTSが設定されているのに、Trilogyでは設定されていなかったのも原因だったそうです。 これらを修正し…再度、本番環境に100%デプロイしたところ、無事動作し、なんとMysql2に比べて22%もパフォーマンスが向上していました。 Request Time: Mysql2: Avg 3.46ms Trilogy: Avg 2.70ms -22% faster また、クエリタイムも17%ほど速くなっていたとのこと。 MySQL query time: Mysql2: Avg 1.49ms Trilogy: Avg 1.24 ms -17% faster DBのクライアントを変更しただけでリクエストタイム、クエリタイム共に高速化されているのは非常に凄いですね。特に、Shopifyのような大量のユーザーを抱えているサービスでの恩恵は大きそうです。 今まではGitHubのみが本番運用していたTrilogyですが、今回OSS化されたことによってShopifyのように今後導入するサービスが増えていきそうなので是非チェックしてみてください! 今回紹介したスライドは以下になります。 speakerdeck.com おまけ 今回参加した3人は海外カンファレンス初参加だったので、慣れない環境での生活や英語でのコミュニケーションに四苦八苦しながらの参加だったのですが、現地の人たちは皆優しく接してくれて、とても助かりました。 日本と違うなーと感じた部分はセッション中に拍手が起きたり、(面白いシーンで)笑いが多かったり、隣の席の人とフランクに喋っていたりと、アメリカンな一面が見えました。また全体を通してセッション終了後のQ&Aにて積極的に質問する姿も見られました。 セッション後にお菓子を食べながら参加者同士でコミュニケーションを取る時間がありました。英語に自信がなかったのでドキドキしていたのですが、最初に雑談した相手がなんと日本語を喋れて盛り上がったりなど、とても面白かったです(LinkedInを交換することに成功しました) ソーシャルコミュニケーションを取る様子 また、会場ではお昼ご飯が提供されていたのですが、日本に比べて全体的に味が濃かったです。開催がアトランタということもあり、南部の郷土料理である「グリッツ」を食べることもできました。 ランチはビュッフェ形式。とても美味しかったですが、味は濃いめでした。 ランチの様子 アメリカ南部の郷土料理「グリッツ」。ベーコンが入っている。おかゆのような食感で、味はコーンスープみたいな独特な感じでした。見た目では伝わりにくいですが美味しかったです! RailsConf公式でホテルが準備されており、格安で泊まることができました。 ホテルからカンファレンス会場へは専用通路で外に出ず行くことができます。出不精にとっては最高の環境です。 最終日のKeynoteを担当していたAaron Patterson氏と記念撮影もしていただきました! 来年のRailsConfの開催地の発表もありました。次回はデトロイトでの開催を予定しているそうです。 最後に ZOZOではセミナー・カンファレンスへの参加を支援する福利厚生があり、カンファレンス参加に関わる渡航費・宿泊費などは全て会社に負担してもらっています。ZOZOでは引き続きRubyエンジニアを募集しています。以下のリンクからぜひご応募ください。 corp.zozo.com
こんにちは、MA部の谷口( case-k )と @gachi-muchi-engineer です。 私達のチームではマーケティングオートメーションシステムの開発や運用をしています。ZOZOTOWNではマーケティングオートメーションによって、メールやPush、LINEなど各チャンネルに対して日々配信しています。配信方法は大きく2種類に分けられ、特定のユーザーセグメント向けの「マス配信」と、個別のユーザーに最適化された「パーソナライズ配信」があります。パーソナライズ配信基盤を社内ではリアルタイムマーケティングシステム「RTM」と呼んでいます。リアルタイムマーケティングシステムは随分と前に作られたこともあり、現在リアルタイムマーケティングシステム全体のリプレイスを進めています。本記事ではリアルタイムマーケティングシステムで用いられている、リアルタイムデータ連携基盤をリプレイスした事例をご紹介します。 既存のリアルタイムデータ連携システムの紹介 既存のリアルタイムデータ連携の仕組み なぜリプレイスをしたのか Windows Serverの運用負荷が高い リアルタイムマーケティングシステム全体のリプレイスに必要なデータ連携基盤が必要 リプレイス後の配信用リアルタイムデータ基盤 安全にリプレイスするための方針 変更前後のデータを取得する方法を検討 変更前後のデータを取得するクエリ 変更ログの取得 データ連携実績ログの取得 変更ログの集計(変更データの取得) データ連携実績ログの集計(変更前データの取得) データ連携実績ログに含まれていない変更前データの取得 変更前後のデータをマージ アーキテクチャ概要と処理の流れ 変更前後のデータ取得 データ連携実績テーブル メッセージブローカーへ連携 データ連携API(Analyzer) Appendix:データ連携API(Push/LINE配信基盤) 最終同期メッセージを書き込む 初回の全量データ連携 移行前後の評価 データの整合性を評価 データの欠損を評価 データの遅延時間を評価 監視設計 プロデューサの監視 コンシューマの監視 リプレイスによる改善点 リアルタイムマーケティングシステム全体のリプレイスに必要な基盤を構築 運用負荷の軽減 今後の課題 パフォーマンスの改善 初回全量データ連携処理の完全移行 まとめ 最後に 既存のリアルタイムデータ連携システムの紹介 既存のリアルタイムデータ連携システムについて紹介します。既存のリアルタイムデータ連携システムでは配信処理に必要なデータをリアルタイムにSQL Serverから取得しています。SQL Serverの変更データを検知して、必要な加工処理を施し、リアルタイムマーケティングシステムへ連携しています。連携されたデータはリアルタイムマーケティングシステムにキャッシュされ配信処理で使われます。ZOZO固有のユーザIDを配信用のトークンへ変換したり、配信のトリガーとしても用いられています。例えば在庫切れを起こしていた商品が入荷されたのをトリガーに配信する仕組みがあります。 以降、リアルタイムデータ連携基盤を「Tracker」、連携されたデータを用いて配信処理をしているアプリケーションを「Analyzer」と呼びます。TrackerとAnalyzerを含む基盤がリアルタイムマーケティングシステム「RTM」です。本記事ではTrackerをリプレイスした事例をご紹介します。 RTMについては以下の記事をご確認ください。 techblog.zozo.com 既存のリアルタイムデータ連携の仕組み リプレイス前のTrackerのデータ連携の仕組みについてご紹介します。 TrackerはJavaで書かれており、Windows Server上のクラスタにデプロイされていました。TrackerはSQL Serverで変更のあったデータを取得し、加工処理を施した上でデータを連携しています。 SQL Serverの変更データの取得にはChange Trackingと呼ばれるSQL Serverの機能を用いています。変更追跡を用いて60秒に1回SQL Serverへクエリを投げ、Analyzerで必要となる加工処理を施しています。加工されたデータは各テーブルごとに定義されたAnalyzerのエンドポイントへリクエストされます。リクエストされたデータはAnalyzerでキャッシュされています。 Trackerで定期的に投げている変更追跡クエリは以下のようになっています。 SELECT a.SYS_CHANGE_OPERATION as changetrack_type, a.SYS_CHANGE_VERSION as changetrack_ver, #{columns} FROM CHANGETABLE(CHANGES #{@tablename}, @前回更新したバージョン) AS a LEFT OUTER JOIN #{@tablename} ON a.#{@primary_key} = b.#{@primary_key} 変更追跡のバージョンは「SYS_CHANGE_VERSION」で取得でき、変更があるとインクリメントされます。最終同期した変更追跡のバージョン「@前回更新したバージョン」を渡すことで、渡したバージョン以降に変更のあったプライマリーキーを取得できます。取得したプライマリーキーを変更のあったテーブルと「LEFT JOIN」することで、変更後のデータを取得できます。変更追跡で取得できるのは変更後の最新のデータのみです。変更履歴の取得はできません。変更タイプには「SYS_CHANGE_OPERATION」には以下の3つの種類が存在します。どのような変更がSQL Serverで実施されたか確認できます。 変更タイプ 説明 I 新規登録 U 更新 D 削除 SQL Serverの変更追跡については以下の記事をご確認ください。 learn.microsoft.com また、Trackerでは変更前のデータも連携していました。Analyzerのキャッシュにはインメモリなデータストアを採用しており、KeyValue形式でデータを保存します。一部のキャッシュはKEYとしてSQL Serverのプライマリーキーではない、メンバーIDやEmailIDを用いています。データの削除や更新があった際これらのキャッシュに対して処理が必要でした。 変更追跡の仕組み上、取得できるのは変更のあったプライマリーキーと変更後のデータのみです。そこで、変更前のデータを取得するために、SQL Serverのトリガー機能を用いていました。トリガーとは、ストアドプロシージャに分類され、SQL Serverでイベントが発生したときに自動的に実行されます。今回は変更前のデータが必要であるため、トリガー機能の1つであるDMLトリガーを利用します。DMLトリガーはDMLイベントを介してデータを変更したときに実行されるトリガーです。DMLトリガーではdeletedとinsertedテーブルという2つの特別なテーブルが使用されます。この2つのテーブルはSQL Serverが自動で作成し、管理しています。 このテーブルの役割は以下の通りです。この2つのトリガーテーブルを用いて、Trackerでは変更前のデータを取得しています。 テーブル名 説明 deleted 「DELETE」または「UPDATE」で変更される前に、影響を受ける行のコピー inserted 「INSERT」または「UPDATE」の後に、新しいまたは変更された行のコピー 変更前のデータを取得する際は以下のようなトリガーを用いていました。物理削除された変更前のデータを取得する場合は直接deletedテーブルから取得するのではなく、データの整合性を担保するため別テーブルへ書き出したデータを利用していました。このテーブルからデータを取得することで、変更前のデータをAnalyzerに連携していました。 CREATE TRIGGER [Database].[SaveDeletedTable] ON [Database].[ Table ] AFTER INSERT , DELETE AS BEGIN -- SET NOCOUNT ON added to prevent extra result sets from -- interfering with SELECT statements. SET NOCOUNT ON ; -- delete unused primary key from DeletedTable DELETE FROM Database.DeletedTable WHERE primary_key IN ( SELECT primary_key FROM deleted UNION ALL SELECT primary_key FROM inserted); INSERT INTO dbo.DeletedTable SELECT * FROM deleted; END SQL Serverのトリガー機能の詳細は以下の記事をご確認ください。 learn.microsoft.com なぜリプレイスをしたのか なぜTrackerをリプレイスをしたのかご紹介します。 Windows Serverの運用負荷が高い TrackerはWindows Server上で運用されていました。元々社外で作られた基盤であったため、チーム内にWindows Serverの知見も少なく、インフラのコード化やデプロイの自動化が難しいところもありました。また、Windows Serverのライセンス費用も高額でした。運用負荷の高いWindows Serverから脱却し、SQL Serverも廃止したいと考えていました。 リアルタイムマーケティングシステム全体のリプレイスに必要なデータ連携基盤が必要 リアルタイムマーケティングシステムの全体のリプレイスを進める上で、Analyzer以外の各マイクロサービスへもリアルタイムにデータ連携できる仕組みが必要でした。汎用的な要件に対応できる、同じような仕組みを作りたいと考えていました。今回のタイミングで新しく汎用的な基盤を構築し、Trackerをリプレイスすることにしました。 リプレイス後の配信用リアルタイムデータ基盤 先に述べたような課題があるため、既存の配信用リアルタイムデータ基盤の課題をリプレイスしました。以降リプレイス後の配信用リアルタイムデータ基盤を「新Tracker」、リプレイス前の基盤を「旧Tracker」と呼びます。 安全にリプレイスするための方針 既に述べたように旧Trackerでは変更前のデータをトリガー機能を用いて取得していました。リプレイスを進めるにあたり、変更前のデータを取得する方法を検討する必要がありました。本来は変更前のデータをリアルタイムマーケティングシステム側のキャッシュからとる方が望ましいです。しかし、連携先のAnalyzerに大きな手を加えることは困難でした。Analyzerのデプロイには数時間かかります。冒頭で紹介したとおり、インメモリなデータストアなので、障害等があった場合メモリ上のデータが吹き飛ぶ懸念もあります。データのリカバリにも8〜9時間ほどがかかり、ロールバックさせるのは難しい状態でした。また、移行対象となるクエリも22テーブルほどあり、クエリにて複雑な加工処理を施していました。 そこで、Analyzer側には手を加えない方針でリプレイスを進めることにしました。もしリプレイス後に問題が発生しても、Analyzer側に手を加えていなければ旧Trackerに切り戻しが可能です。また、リプレイスに伴うデータ評価の点でも、旧Trackerと新Trackerで出力されるデータを揃えることでデータの評価が可能になります。 変更前のデータをリアルタイムマーケティングシステムのキャッシュから取るようにすることは、新Trackerへリプレイス後でも可能で容易になります。今回のリプレイスが完了した後、対応していくことにしました。 変更前後のデータを取得する方法を検討 新Trackerでは、BigQuery上に構築された全社共通のデータ基盤であるリアルタイムデータ基盤から変更データを取得しています。リアルタイムデータ基盤にすることで、SQL Serverから脱却し、ライセンス費用等のコストや運用負荷の軽減、パフォーマンス面での改善が期待できます。 リアルタイムデータ基盤は数年前に作られ、同じようにSQL Serverの変更データを変更追跡の機能を用いて、リアルタイムでBigQueryへデータ連携しています。旧Trackerは全社共通のリアルタイムデータ基盤ができる前からあったシステムのため、独自でSQL Serverの変更データを集めていました。今回のリプレイスのタイミングでリアルタイムデータ基盤からデータを取得することにしました。 リアルタイムデータ基盤の詳細は以下の記事をご確認ください。 techblog.zozo.com データソースをSQL Serverからリアルタイムデータ基盤にしたことで以下を考慮する必要がありました。 データの重複 データの順序 変更前データの取得方法 旧TrackerではSQL Serverの変更追跡を用いて、変更のあったデータを取得しているため、取得したデータの順序は保証されており、データの重複もありませんでした。 しかし、リアルタイムデータ基盤では運用しやすいよう「at-least-once」な設計になっています。データは重複し、遅延データも入るため順序は保証されていません。 また、旧TrackerではSQL Serverのトリガー機能を用いて変更前のデータを取得していました。リアルタイムデータ基盤へ移行したことで、データの整合性を担保しつつ変更前のデータを取得する方法の検討が必要になります。 変更前後のデータを取得するクエリ これらの要件を満たすために、リアルタイムデータ基盤からデータを取得する際にデータの重複排除と順序保証をしています。また、変更前のデータは配信基盤へ連携済みの実績テーブルから取得するようにしました。具体的にどのようなクエリを実行しているかご紹介します。 変更前後のデータを取得するクエリはテーブル関数として用意しています。次のようにタイムスタンプを渡すことで、渡したタイムスタンプ以降に変更のあった変更前後のデータを取得できるようにしています。 テーブル関数の使い方は次の通りです。 SELECT * FROM `< table ID>`( ' 2023-05-01 ' ) テーブル関数には以下の2種類用意しています。 変更後のデータのみ取得する関数 変更前後のデータを取得する関数 テーブル関数内で具体的にどのような処理をしているかご紹介します。 変更ログの取得 リアルタイムデータ基盤より、変更のあったデータを取得しています。取得したデータは順序保証されておらず、データの重複もあります。詳細は後述の「変更ログの集計(変更データの取得)」でご紹介しますが、順序を保証し、データの重複を排除するためにプライマリーキーが必要になります。対象テーブルのプライマリーキーをカラムとして作ります。クエリ内の「last_sync_time」はTIMESTAMP型で、テーブル関数から渡されるパラメータです。最終同期したデータの時刻を渡すことで、該当時刻より後に変更のあったデータを取得できます。 取得期間を3時間にしているのは、リアルタイムデータ基盤の遅延データに対応するためです。前述したとおり、リアルタイムデータ基盤では順序保証されていないため遅延データを考慮する必要があります。実際3時間も遅れることはないのですが、最終同期された時刻である「last_sync_time」に遅延時間を考慮して変更データを取得しています。遅延データを考慮しないと、変更ログの取得の際にパーティション外となりデータが欠損してしまいます。変更ログの取得に旧Trackerのように変更追跡のバージョンではなく、タイムスタンプを用いているのも順序保証されず遅延した際のデータ欠損を防ぐためです。 streaming AS ( SELECT *, CONCAT (${ join ( " , " ,primary_key)}) AS primary_key FROM `${project_changetracking}.${dataset_changetracking}.${table_changetracking}` WHERE bigquery_insert_time >= TIMESTAMP_SUB( CAST ( FORMAT_TIMESTAMP( " %Y-%m-%d " , TIMESTAMP_SUB(last_sync_time , INTERVAL 3 Hour) , " Asia/Tokyo " ) AS timestamp ) , INTERVAL 9 HOUR ) ) データ連携実績ログの取得 クエリで取得した変更前後のデータは実績テーブルに書き込まれます。以降「データ連携実績テーブル」と呼びます。データ連携実績テーブルの用途は後述しますが、データ連携実績テーブルに書き込まれたデータは書き込まれた順に各サービスへデータ連携されます。こうすることで、データ連携実績テーブルから変更前のデータを取得することで、Analyzerにキャッシュされているデータとの整合性をとることができます。 また、リアルタイムデータ基盤で取得したログから連携済みの実績を排除するためにも利用しています。この後の「変更前後のデータをマージ」にて説明します。 event_sync_logs AS ( SELECT realtime_message_unique_id, realtime_changetrack_ver, CONCAT (${ join ( " , " ,primary_key)}) AS primary_key,tracking_type FROM `${project}.${tracking_event_log_dataset}.${table_base}` WHEREå tracking_start_time >= TIMESTAMP_SUB( CAST ( FORMAT_TIMESTAMP( " %Y-%m-%d " , TIMESTAMP_SUB(last_sync_time , INTERVAL 36 Hour) , " Asia/Tokyo " ) AS timestamp ) , INTERVAL 9 HOUR ) ) 取得期間を36時間にしているのは、変更前のデータの取得に全社共通データ基盤の全量データを用いるためです。データ連携実績ログで取得できるデータの範囲に変更前のログが含まれているとは限りません。例えば最後に更新されたログが5日前の場合パーティションの範囲外となります。 全社共通データ基盤では日次のバッチ処理で、SQL Serverにあるテーブルを全件BigQueryへ連携しています。もし、データ連携実績ログの取得の際、取得期間を数時間にしてしまうと、全社共通データ基盤では日次のバッチ処理連携後に変更のあった一部のデータが欠損してしまいます。日次連携された時刻よりも前から取得することでデータの欠損を防ぐことができます。データ連携側の遅延も考慮して、36時間としています。 全社共通データ基盤については以下の記事をご確認ください。 techblog.zozo.com 変更ログの集計(変更データの取得) SQL Serverから共通基盤であるリアルタイムデータ連携基盤へのデータ連携には冒頭でご紹介したSQL Serverの変更追跡を用いています。テーブルのプライマリーキーと最新の変更追跡バージョンを集計し、変更履歴とJOINすることで最新の変更データを取得できます。この集計により、リアルタイムデータ基盤内のデータ重複を排除し、順序の保証もしています。変更後のデータのみ必要な場合は後述している変更前のデータを取得する処理は不要です。 streaming_latest_version AS ( SELECT primary_key, MAX (changetrack_ver) AS changetrack_ver_max, FROM streaming GROUP BY primary_key ), streaming_latest AS ( SELECT streaming.* FROM streaming INNER join streaming_latest_version ON streaming.primary_key = streaming_latest_version.primary_key AND streaming.changetrack_ver = streaming_latest_version.changetrack_ver_max ), データ連携実績ログの集計(変更前データの取得) データ連携実績ログより変更前のログを取得します。データ連携実績ログ内のデータを変更前のデータとして利用します。 streaming_before_latest_version AS ( SELECT primary_key, MAX (realtime_changetrack_ver) AS realtime_changetrack_ver_max FROM event_sync_logs WHERE primary_key IN ( SELECT primary_key FROM streaming_latest ) AND tracking_type = 0 GROUP BY primary_key ), streaming_before_latest AS ( SELECT a.* FROM streaming AS a INNER join streaming_before_latest_version AS b ON a.primary_key = b.primary_key AND a.changetrack_ver = b.realtime_changetrack_ver_max ), データ連携実績ログに含まれていない変更前データの取得 前述の「データ連携実績ログの取得」で述べたとおり、変更前のログがデータ連携実績ログに含まれていない場合があります。データ連携実績ログに変更のあったプライマリーキーの変更前データがない場合は日次の全量データから変更前のデータを取得しています。データの整合性の観点でも、全量データから取得した変更前のデータはAnalyzerにキャッシュされているデータとも一致するため問題ありません。 daily_before_latest AS ( SELECT streaming_latest_id.massage_unique_id, " ${dataset} " AS database_name, " ${table_base} " AS table_name, CAST ( NULL AS string) AS changetrack_type, CAST ( NULL AS int64) AS changetrack_ver, CAST ( NULL AS int64) AS changetrack_last_sync_ver, CAST ( NULL AS timestamp ) AS changetrack_start_time, CAST ( NULL AS timestamp ) AS bigquery_insert_time, streaming_latest_id.primary_key, ${ join ( " ,\n " ,columns)} FROM ( SELECT *, CONCAT (${ join ( " , " ,primary_key)}) AS primary_key FROM `${project_snapshot}.${dataset_snapshot}.${table_base}_20*` AS snapshot_table WHERE _TABLE_SUFFIX IN ( SUBSTR ( FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(last_sync_time, INTERVAL 1 day), " Asia/Tokyo " ), 3 ) ) ) AS snapshot_table INNER join ( SELECT massage_unique_id, primary_key FROM streaming_latest ) AS streaming_latest_id ON snapshot_table.primary_key = streaming_latest_id.primary_key WHERE snapshot_table.primary_key NOT IN ( SELECT primary_key FROM streaming_before_latest_version ) ) 変更前後のデータをマージ 変更後と変更前のデータをマージして、変更前後のデータを取得しています。変更前後のデータを識別できるよう「tracking_type」を付与しています。変更後は「0」変更前は「1」としてます。また、実績ログを用いて連携済みのデータは排除しています。重複排除にはメッセージ単位でユニークとなるメッセージID「realtime_message_unique_id」を利用しています。 SELECT massage_unique_id AS realtime_message_unique_id, 0 AS tracking_type, * FROM streaming_latest UNION ALL SELECT CONCAT (massage_unique_id, " 1 " ) AS realtime_message_unique_id, 1 AS tracking_type, * FROM streaming_before_latest UNION ALL SELECT CONCAT (massage_unique_id, " 2 " ) AS realtime_message_unique_id, 1 AS tracking_type, * FROM daily_before_latest) WHERE realtime_message_unique_id NOT IN ( SELECT realtime_message_unique_id FROM event_sync_logs ) このようなクエリを用いて、変更前後のデータを取得しています。 アーキテクチャ概要と処理の流れ 新Trackerのアーキテクチャ概要と処理の流れについてご紹介します。 アーキテクチャの全体は次の通りです。新Trackerではリアルタイムデータ連携基盤から変更前後のデータを取得して、メッセージブローカーにパブリッシュしています。メッセージブローカーへパブリッシュされたデータはAnalyzerを含む各サービス毎に作られたデータ連携用のAPIを用いて連携されます。以降各処理の流れの詳細をご紹介します。 変更前後のデータ取得 新Trackerではリアルタイムデータ基盤から変更後のデータを取得しています。変更前のデータは後述するデータ連携実績テーブルから取得しています。 新TrackerはGKE上にネームスペースを分けてデプロイしています。各サービスごとに同じテーブルでも必要となるETL処理が異なります。また、同じテーブル名でもDB単位でデータは異なります。そのため、各リソースはサービス単位でテーブルの識別ができるように分けています。 「サービス名 × データベース名 × テーブル名」 GKEのデプロイメントは以下のようになっています。サービスとしては「analyzer」と「zozo-notification-delivery」があり、同じテーブルでも別のリソースとしてデプロイされています。 kubectl get pod -n realtime-datapump app-analyzer-table1-db1 1 / 1 Running 9 ( 46h ago ) 25d app-analyzer-table2-db2 1 / 1 Running 8 ( 27h ago ) 25då app-analyzer-table3-db3 1 / 1 Running 9 ( 16h ago ) 25d ..... app-zozo-notification-delivery-table1-db1 1 / 1 Running 9 ( 2d4h ago ) 25d app-zozo-notification-delivery-table2-db2 1 / 1 Running 9 ( 13h ago ) 25d app-zozo-notification-delivery-table3-db3 1 / 1 Running 6 ( 46h ago ) 25dåå リソースごとに設定ファイルも分けています。デプロイする際に「サービス名 × データベース名 × テーブル名」を環境変数として渡し、環境変数に基づいて設定情報を取得しています。BigQueryやCloud Pub/Subのリソース情報を制御できるようにしてます。リカバリ等も考慮し、マイクロサービス単位でテーブル等リソースは分けて管理しています。 # analyzer [services.analyzer-db1-table1] gcp_project = "gcp_project" pubsub_topic_project = "pubsub_topic_project" message_reflesh_count = 50000 pubsub_topic = "<table1>" dataset_event_send_ids = "db1_tracking_event_send_ids" dataset_event_logs = "db1_tracking_event_logs" ... # zozo-notification-delivery [services.zozo-notification-delivery-db1-table1] gcp_project = "gcp_project" pubsub_topic_project = "pubsub_topic_project" message_reflesh_count = 50000 pubsub_topic = "<table1>" dataset_event_send_ids = "zozo_notification_delivery_<db1>_tracking_event_send_ids" dataset_event_logs = "zozo_notification_delivery_<db1>_tracking_event_logs" デプロイされた新Trackerはステートレスになっており、まず最終同期したメッセージに紐づく時刻を取得します。後述の「最終同期メッセージを書き込む」でご紹介しますが、サービスにパブリッシュされた最後のメッセージは別テーブルで管理されています。新TrackerのPod起動時に最終同期したメッセージの時刻を取得します。 最終同期の時刻を、先ほどご紹介したテーブル関数に渡し、変更前後のデータを取得します。各マイクロサービスで必要となるETL処理をしています。 データ連携実績テーブル 加工されたデータは別テーブルへ書き込まれます。書き込まれたデータは古い順から全て配信基盤側へ連携されます。加工されたデータを一度書き出す理由としては、冪等性の担保と重複排除によりパフォーマンスをあげるためです。 変更前のデータをデータ連携実績テーブルから取らないと、リトライされた場合、変更前のデータがキャッシュされているデータと一致しなくなります。同じプライマリーキーに対して、複数回の更新処理が走った場合を考慮すると、リアルタイムデータ基盤にある変更前のデータとAnalyzerでキャッシュされている変更前のデータが一致しなくなるためです。リアルタイムデータ基盤にある変更データを別テーブルへ書き出すことで、変更前データの整合性を担保しています。また、前述した「変更前後のデータを取得するクエリ」で述べたとおり、変更前後のデータを取得するにはタイムスタンプを用いています。リアルタイムデータ基盤からデータ欠損がないよう遅延データも考慮して、変更データを取得するため、すでに連携済みのデータも取得されてしまいます。データ連携実績テーブルを用いることで、連携済みのデータを除外し、パフォーマンスを向上させるためにも利用します。データ連携実績テーブルに連携されたデータはサービスへ連携されるため、障害時の原因調査にも役立ちます。 メッセージブローカーへ連携 データ連携実績テーブルへ書き込まれたデータは古いデータから順に取り出され、メッセージブローカーへパブリッシュされます。メッセージブローカーを挟むことで、非同期処理が可能となり耐障害性が向上します。もし、メッセージブローカー内のデータを処理しているコンシューマが障害を起こした場合でも、メッセージブローカーへデータをパブリッシュするプロデューサは影響されずに処理を継続できます。また、メッセージブローカーを挟むことで、各マイクロサービスへのデータ連携で必要なインタフェースを揃えることができるため、汎用性の高いシステムを構築できます。 データ連携実績テーブルからデータを取り出すクエリは以下のようになっています。「LastSyncTime」メッセージブローカーへの配信が成功した最後のメッセージに紐づく時刻が入ります。データ連携実績テーブルに連携済みで、まだメッセージブローカーへ配信できていないメッセージのみ抽出します。 SELECT * FROM `zozo-ma-realtime-datapump-{{.Env}}.{{.EventLogsDataset}}.{{.EventName}}` WHERE tracking_start_time > " {{.LastSyncTime}} " ORDER BY tracking_start_time ASC メッセージブローカーにはCloud Pub/Subを採用しています。Cloud Pub/Subで順序保証するには順序保証キー「OrderingKey」を用いる必要があります。SQL Serverのプライマリーキーを順序保証キーとして、パブリッシュしています。順序を保証するため、メッセージブローカーへのパブリッシュが1件でも失敗した場合、実績テーブル内の未連携データは全て再連携されます。 cloud.google.com Cloud Pub/Subへのパブリッシュ時に以下のように属性情報「Attributes」も渡しています。 publishResult := t.Publish(ctx, &pubsub.Message{ Data: [] byte (msg), Attributes: map [ string ] string { "event" : event.EventSourceName() + "-" + event.EventDatabaseName() + "-" + event.EventName(), "message_id" : event.MessageId(), "key" : event.RealtimeMessageKey(), "action" : event.Action(), }, OrderingKey: event.OrderId(), }) 属性情報の役割は以下の通りです。 属性 説明 event 各イベントを識別するために利用 message_id メッセージのユニーク値を識別するために利用 key SQL Serverのプライマリーキー action 変更のあったイベントタイプ「upsert」と「delete」 これらの属性情報に基づき、後述する配信系サービスへデータ連携を担うAPIで処理されています。 データ連携API(Analyzer) Cloud Pub/SubへパブリッシュされたデータはAnalyzerへデータ連携されます。Analyzerへのデータ連携APIにはCloud Dataflowを用いています。Cloud Pub/Subへパブリッシュ時に属性情報として渡した「event」を用いて、イベント名に基づきAnalyzerへのエンドポイントに対してリクエストします。AnalyzerはAWS環境にあるため、GCPからAWS環境へリクエストするために、ZOZO内の共通基盤であるShard VPCを用いています。 ShardVPCについては以下の記事をご確認ください。 techblog.zozo.com 当時Cloud RunやCloud Functionsを採用しなかったのは従量課金によるコストを抑えたかったためです。Cloud RunやCloud Functionsの比較的新しい料金体系である「Always on CPU」だと、リクエスト課金が発生しません。大量のデータを扱うログ収集基盤などではスケーリングが速く、コスト面の費用対効果も高いです。 cloud.google.com ただし、今回はShard VPCを利用するためCloud RunやCloud Functionsを使う場合はサーバレスVPCを利用する必要があります。サーバレスVPCは従量課金となってしまうため、運用実績もあり、費用帯効果の高いCloud Dataflowを採用しました。 cloud.google.com しかし、実際にCloud Runも運用してみてパフォーマンス面や最小ワーカー数の制御等、Cloud Dataflowよりも優れている点が多いように感じました。要件次第ではありますが、今後新規でストリーミング系のデータ連携をする場合は積極的にCloud Runを使いたいと思いました。 Appendix:データ連携API(Push/LINE配信基盤) 前述の「なぜリプレイスをしたのか」でご紹介したとおり、新TrackerはAnalyzer以外の各マイクロサービスへもリアルタイムにデータ連携できます。Appendixとして配信基盤へのデータ連携についても簡単にご紹介します。 リアルタイムマーケティングシステム全体のリプレイスに伴い、Analyzerの各チャンネルへの配信機能を配信基盤として切り出し、モジュール化しています。配信基盤は全社の共通基盤としてZOZO内部の他のシステムからも配信処理が実施できるように作られています。配信基盤の機能として、配信基盤へのリクエストに含まれているメンバーIDを用いて、通知設定の確認やPushやLINE配信に必要なトークンへの変換をしています。配信基盤で必要なデータを新Trackerを用いて連携しています。 配信基盤用のデータ連携APIにはCloud Runを採用しています。Cloud Runを採用したのは、Cloud Dataflowよりもスケーリングが速く、パフォーマンス面で優れていたためです。また、「Always on CPU」を使えば今後データ量が増えてもリクエスト課金による懸念はなくなります。配信基盤用のデータ連携APIはAWSと疎通することもないため、サーバレスVPCの費用を気にする必要もありません。 配信基盤のストレージにはGCPのサーバレスでNoSQLデータベースであるCloud FirestoreをDatastoreモードで採用してます。配信基盤に必要なデータをデータ連携APIを用いて、Cloud Firestoreにキャッシュしています。属性情報の「action」に基づいてデータの更新と削除をしています。配信基盤でもAnalyzer同様に十分なパフォーマンスがでるよう、Cloud FirestoreのKEYにメンバーIDを用いています。MA部以外のシステムからも配信できるようメンバーIDを用いて配信に必要なパーミッションなどの情報を取得するためです。Cloud FirestoreにキャッシュされたデータもAnalyzer同様、必要に応じ削除しています。例えばLINEの連携を解除した際にキャッシュされたデータを消す必要があります。 ただし、AnalyzerのようにメンバーIDなどの変更前のデータは必要ありません。以下のように属性情報の「key」で渡されたSQL Serverのプライマリーキーを用いて、対象のデータを抽出して削除をしています。 func (mdsrepository *EventCacheRepository) Delete(ctx context.Context, cacheLog entity.TableCatchLog) error { // delete datastore key from sql server primary key _, err := mdsrepository.client.RunInTransaction(ctx, func (tx *gcpdatastore.Transaction) error { query := gcpdatastore.NewQuery(mdsrepository.entityKind).Transaction(tx).FilterField(cacheLog.ToPrimaryKeyName(), "=" , cacheLog.ToPrimaryKey()) it := mdsrepository.client.Run(ctx, query) catchIterator := CatchIterator{ CatchName: cacheLog.ToCatchName(), Iterator: it, } for { catch, err := catchIterator.NextEvent() if err == iterator.Done { break } if err != nil { return err } key := gcpdatastore.NameKey(mdsrepository.entityKind, catch.ToKey(), nil ) if err := tx.Delete(key); err != nil { return err } } return nil }) if err != nil { mdsrepository.logger.Error( "Transaction Faile To Delete Entity" ) return err } return nil } 配信基盤のストレージの選定時にKEYではなく、クエリで十分なパフォーマンスが出るかも必要な要件でした。SQL Serverのプライマリーキーを用いてキャッシュの操作をするためです。結果整合性によりパフォーマンスで優れているCloud Firestoreは負荷検証の結果1億件を超えるデータでも高速に処理できることが確認できました。 cloud.google.com なお、バッチの洗い替えなど大量にデータを削除するには不向きなので注意が必要です。夜間であれば問題ありませんが、配信中などに実施するとクエリのレイテンシが悪化します。ドキュメントでも負荷検証等で十分なパフォーマンスがでるか検証することを勧めています。 cloud.google.com 配信基盤用のデータ連携クエリは、Analyzerとは異なり変更データ取得の際にBigQueryのリソースを多く消費することもありません。Analyzerのインメモリなデータストアで実現できるのかパフォーマンスの確認は必要となりますが、今後Analyzerにも同様の改修を入れたいと考えています。 最終同期メッセージを書き込む メッセージブローカーへのパブリッシュが全て成功した場合は最後にパブリッシュした、メッセージのメッセージIDとデータの取得開始時刻をBigQueryへ同期的に書き込みます。最終同期時刻を変更データ取得用に作られたテーブル関数へ渡し、変更のあったデータを取得しています。また、このメッセージIDを用いて、実績テーブルからデータ連携するデータを絞っています。冒頭で説明したSQL Serverの変更追跡バージョンと同じ役割を果たしています。障害発生時のリカバリもこの最終同期メッセージの時刻を巻き戻すことで、最終同期した時刻以降のデータを再連携可能です。 初回の全量データ連携 新Trackerで取得できるデータは変更データのみです。初回時のデータ連携やリカバリ時には全量データの連携が必要になります。以降「ローダーバッチ」と呼びます。ローダバッチではDigdagを用いて、BigQueryのクエリ実行結果をCloud Storageへdumpし、並列にCloud Pub/Subへパブリッシュしています。Cloud Pub/Subへパブリッシュされたデータは前述した各サービスごとに作られたデータ連携APIを用いてキャッシュされます。ストリーミング処理である新Trackerの差分連携、バッチ処理であるDigdagを用いた全量連携で使うデータ連携APIの共通化が可能です。バッチ処理とストリーミング処理の両方で同じロジックのメンテナンスをする必要がないため、運用負荷を軽減できます。また、新Trackerを用いて新しくデータ連携する場合の導入工数も削減できます。 なお、Cloud Pub/Subへのパブリッシュで十分なパフォーマンスがでない場合は、Cloud Pub/Subクライアントのバッチメッセージングの設定値を調整する必要があります。今回はmax_messagesをデフォルトの100から1000に変更し、max_latencyをデフォルトの10msから1sに変更しました。これにより、約1.7億件のデータをCloud Pub/Subへパブリッシュするのに10時間かかっても終わらなかったのが、約90分ほどで完了するようになりました。 設定値の詳しい説明は、以下の公式のドキュメントをご確認ください。 cloud.google.com Digdagについては以下の記事をご確認ください。 techblog.zozo.com 移行前後の評価 リプレイスにあたり以下の観点でデータの評価をしました。Analyzerに連携しているテーブルは約22テーブルほどあり、マスタテーブル等のJOIN等複雑な加工処理を実施しています。移行時の評価方法についてご紹介します。 データの整合性を評価 データの整合性を担保するため、旧Trackerと新Trackerのログを比較できるようにしました。旧TrackerのログはWindows Server内から取得し、新Trackerの方はCloud Loggingにログを書き出しました。両方の結果をハッシュ値で比較して、データの値が一致しているか調べました。言語仕様等でずれがあった場合は問題ないか確認していきました。評価の過程で旧Tracker側の問題、新Tracker側の問題両方見つかりました。修正が必要なテーブルはクエリを修正し、対応しました。 データの欠損を評価 次にデータの欠損を調べました。データ欠損の観点では旧Trackerで変更のあったプライマリーキーが新Trackerに含まれているか調べました。遅延を考慮し、ウィンドウ幅を1時間程度に調整して調べました。プライマリーキーの有無で調べたのは、データ連携の性質上、短い期間に複数回の更新が走った場合はプライマリーキーに紐づくデータが新旧で一致しなくなるからです。プライマリーキーであれば、旧Trackerにあるキーは新Trackerにないといけないため、データの欠損を調べることができます。データの欠損がないか確認し、問題がないことを確認しました。 データの遅延時間を評価 旧Trackerをベンチマークにデータの遅延時間を調べました。旧Trackerの遅延の調査は旧Trackerとリアルタイムデータ基盤のログをBigQuery上で突合して調べました。 新旧Trackerのデータ遅延だけではなく、リアルタイムデータ基盤側のデータ遅延も調べました。調べたところ旧Trackerでは最大で20分程度の遅延が発生していることが確認できました。一方で、新TrackerではBigQueryのコンピューティングリソースであるスロットを十分確保すると、遅くても数十秒ほどでクエリの完了が確認できました。しかし、数十テーブルの連携で十分なスロットを確保する場合は600スロットほど必要なことがわかりました。調査したところ主に変更前のデータ取得で多くのスロットを消費していることが分かりました。 運用ではコストを抑えるため100スロットに固定しています。100スロット固定だとパフォーマンスは遅くなりますが、旧Trackerのパフォーマンスは超えることができました。後述しますが、Analyzer側に修正を加えることで100スロット以内に、必要なパフォーマンスをだせる予定です。 監視設計 新Trackerの監視設計について紹介します。リアルタイムに差分データを取得しているアプリケーションを「プロデューサ」、メッセージブローカーのデータを処理するデータ連携APIを「コンシューマ」と呼びます。 プロデューサの監視 プロデューサの監視ではCloud Monitoringを活用して、データの遅延と正常に稼働しているか監視しています。変更前後のデータを取得する一連の処理が完了した際に、Cloud Loggingを用いて監視で用いるイベント情報を書き出しています。一定時間たってもイベントが書き込まれない場合はアラートを飛ばすようにしています。 コンシューマの監視 コンシューマの監視にはCloud Pub/Sub内のACKされていないデータを監視しています。コンシューマで障害が発生し、処理が完了しなかった場合はExponential Backoffでリトライするように作られています。リトライしても成功しない場合はCloud Pub/Sub内でACKされていないデータが増え続けます。Cloud Pub/Sub内のメトリクスである「oldest_unacked_message_age」を監視して、コンシューマの障害を検知できるようにしています。 リプレイスによる改善点 リプレイスしたことによる改善点をご紹介します。 リアルタイムマーケティングシステム全体のリプレイスに必要な基盤を構築 リアルタイムマーケティングシステム全体のリプレイスを進めていく上で、必要な基盤を構築できました。各マイクロサービスで必要なデータをリアルタイムに連携が可能となりました。また、初回の全量データ連携の仕組みも共通化できました。新規でサービスを追加する場合は、新しくクエリやトピック等追加することで容易にリアルタイムデータ連携が可能です。 運用負荷の軽減 旧Trackerからリプレイスできたことで、運用負荷の高かったWindows Serverから脱却できました。Windows Serverにデプロイされたクラスタの運用やSQL Server起因の障害がなくなりました。リプレイスに伴いデプロイも自動化でき安心して実施できるようになりました。また、半年間大きな障害なく運用もできています。 今後の課題 最後に今後の課題について紹介します。リプレイスは完了しましたが、まだいくつか改善の余地があります。 パフォーマンスの改善 今回Analyzerに手を加えない形で修正しました。しかし、変更前データを実績テーブルから取得することで多くのBigQueryのコンピューティングリソース(スロット)を消費しています。Analyzerはプライマリーキーではないものをキーにしてるキャッシュが多いためです。十分なスロットが確保できれば、遅くても数十秒以内でクエリは完了します。配信基盤のようにAnalyzerも変更前のデータをSQL Serverのプライマリーキーから取得するよう改修することで、コストやパフォーマンス面で改善が見込まれます。今後この基盤を用いてさらにデータ連携するサービスが増えていく予定なので、対応していきたいです。 初回全量データ連携処理の完全移行 前述した初回全量データ連携する仕組み(ローダバッチ)ですが、Analyzerの全量データ連携ではまだ利用できていません。ローダーバッチの仕組みを利用できているのは配信基盤(Push/LINE)用のデータ連携のみです。Analyzerでも全量データをロードするための仕組みがあり、インメモリ上のキャッシュが吹き飛んだ時などに用いています。 しかし、SQL Serverから全量データを取得するには時間もかかり、ロードには8〜9時間程度かかります。今回紹介したローダーバッチへ移行できると、BigQueryからデータを取得できるので、パフォーマンス面での改善が期待できます。DigdagにAnalyzer用のクエリを追加すればいいため、Analyzerやデータ連携APIには手を加えずにリプレイスできます。より短い時間でリカバリできれば、Analyzerを運用していく上で一番大きな不安も解消されるので、今後対応していきたいです。 まとめ 本記事ではリアルタイムマーケティングシステム全体のリプレイスに向け、配信用リアルタイムデータ連携基盤をリプレイスした事例をご紹介しました。 リプレイスに伴い、運用負荷の高いWindows Serverから脱却できました。今回のリプレイスで変更データの取得元をSQL Serverから全社共通の基盤であるリアルタイムデータ基盤に変更しました。データソースの変更に伴い、「データの重複」「データの順序」「変更前データの取得方法」を考慮した設計が必要でした。さらに、Analyzerの制約も考慮し、切り戻しや評価できるよう安全にリプレイスを進める必要がありました。 構築した新Trackerで連携できるデータは差分データのみなので、初回の全量データを連携するための仕組みが必要でした。運用負荷や導入工数を考慮し、ストリーミング処理とバッチ処理で同じデータ連携用のAPIを用いています。 今回のリプレイスに伴い、旧Trackerの抱えていた課題を解決できましたが、まだ課題は残っているので今後対応していきたいです。 最後に この記事を読んで、もしご興味をもたれた方は是非採用ページからお申し込みください。 https://hrmos.co/pages/zozo/jobs/0000196 hrmos.co
はじめに こんにちは、技術本部・MA部・MA開発1ブロックでマーケティングオートメーションのシステムを開発している長澤( @snagasawa_ )です。この記事ではパーソナライズ配信におけるルールベースの最適化を改善した事例を紹介します。 ZOZOTOWNでは、マーケティングオートメーションによってキャンペーンやセール情報などの配信を日々行なっています。配信はその対象によって2種類に大別でき、特定のユーザーセグメント向けの「マス配信」と、個別のユーザーに最適化された「パーソナライズ配信」があります。 この後者のパーソナライズ配信において、既存の最適化処理である課題を抱えていました。それは、特定の条件下でユーザーへ配信が行われずに機会損失が発生するというものでした。今回はこの課題の原因となっていた実装の依存関係を見直し、配信のKPIを改善した事例について紹介します。 ルールベースの最適化の課題 はじめに、パーソナライズ配信の最適化フローと今回改善した課題を紹介します。 最適化フローの概要は過去のテックブログ記事でも紹介していますので、配信システムのアーキテクチャや構成要素も合わせてこちらの記事でご確認ください(過去の記事で「リアルタイムマーケティングシステム」と呼んでいるものを、この記事では最適化の側面から「パーソナライズ配信」と呼んでいます)。 techblog.zozo.com この最適化フローでは、「チャネル」「時間」「通数」の3つの最適化を行なっています。 処理名 処理内容 チャネル最適化 メール・LINE・アプリPushの中で、ユーザーが最も反応しやすいチャネルを配信先に選択します。 時間最適化 ユーザーが反応しやすい時間帯に配信時間を調整します。 通数最適化 過剰な配信によってオプトアウトされないように、ユーザーごとに一定期間内の配信通数の上限を設け、達していた場合は配信しないように制御します。 課題が存在したのは、ひとつ目のチャネル最適化です。 処理の順序として、チャネル最適化後に通数最適化を行なっていたため、ユーザーの反応しやすいチャネルが選択されたものの、通数最適化で通数上限に達していた場合に配信されないという事象が発生していました。 具体例で言うと、あるユーザーの最適なチャネルとしてメールが選択されたものの、そのメールの通数上限に達していたために配信されなくなるという流れです。 これは通数最適化の目的からすれば意図した挙動だと思われるかもしれません。しかし、これには改善の余地があります。それは、最適化されたチャネルの「次の優先順位のチャネルでの配信」です。 先ほどの例であれば、メールの次に反応しやすいチャネルがLINEだった場合、そのチャネルで配信し直すという処理です。言われてみれば実装されて然るべきだと思われるような機能ですが、修正前までは未実装でした。 また、加えてもうひとつの課題がありました。最適化フローは過去の配信実績に基づいて行われるため、すでに配信実績のあるチャネルに偏りやすいという課題です。例えばあるキャンペーンが新しい配信チャネルに対応したり、ユーザーのチャネルの利用動向が変化したりしても、過去に最も利用されていたチャネルで配信されやすい傾向にありました。設定によりチャネルの優先度に補正をかけることも可能ですが、その都度補正を調整する手間がかかります。 もしも「次の優先順位のチャネルの配信」が実装されていれば、配信実績のあるチャネルで通数上限に達した場合でも、配信実績の少ないチャネルでの配信が期待できます。 最適化のフロー 先に改善の結論を言うと、通数上限チェックをチャネル最適化の処理内へ移行し、通数の上限到達済みチャネルを選択肢から除外するように修正しました。改善は至ってシンプルです。続いて、最適化フローを詳しく説明します。 最適化の前処理 最適化の前処理として、「イベント検知・キャンペーン判定・ユーザー抽出」の3つがあります。 処理名 処理内容 イベント検知 キャンペーンの条件となるイベントを検知します。 キャンペーン判定 検知されたイベントからキャンペーンを判定します。 ユーザー抽出 SQLでデータベースからキャンペーンの対象のユーザーIDを取得します。 イメージしやすい「お気に入り商品の値下げ通知」キャンペーンを例にします。 ある商品が値下げされると、データベースの商品テーブルの価格カラムが更新され、それをイベントとして配信システムにリクエストを送信します(イベント検知)。 配信システムはそのイベントの内容が「お気に入り商品の値下げ通知」キャンペーンの配信条件であることを判定し、キャンペーンの情報を生成します(キャンペーン判定)。 そのキャンペーン情報に含まれるセグメントのSQLを実行し、ユーザーIDを抽出します(ユーザー抽出)。 一連の流れで取得されたユーザーごとの情報は、JSONで最適化情報(Optimization Context)として後続の最適化処理に渡されて処理が移ります。 チャネル最適化 この処理でははじめに、キャンペーンごとに設定される「優先チャネル」でそのユーザーへの配信の可否をチェックし、可能であればその時点でチャネルが確定します。しかし、優先チャネルが未設定や配信不可の場合、配信候補のチャネルで配信実績のクリックログからユーザーの反応しやすさを判定してチャネルの優先度付けを行います。 上記の最適化を経て、優先度は以下の4パターンのいずれかの値を元に算出します。 番号 処理内容 ① 当該ユーザーの「キャンペーン×チャネル」のクリック率 ② 当該ユーザーのキャンペーンのクリック率と、「キャンペーン×チャネル」の全ユーザーのクリック率 ③ 当該ユーザーの他キャンペーンでのチャネルのクリック率と、キャンペーン全体のクリック率 ④ チャネルの全キャンペーンのクリック率で優先度計算 このように、基本的には配信対象ユーザーの配信実績やクリック率をもとに優先度を計算します。しかし、配信可否や配信実績の有無によっては、他のキャンペーン・チャネル・ユーザーのクリック率を利用します。 時間最適化 キャンペーンごとに設定される配信タイミングやチャネルの配信可能な時間帯などにもとづき、即時での配信、または指定時間での配信を予約します。配信予約の場合は、JBoss Data Grid(JDG)というインメモリの分散キャッシュデータストアに配信情報を保存し、指定の時間にそれを取り出して配信します。 www.redhat.com また、時間以外の判定材料として「おまとめ配信」という機能があります。キャンペーンの配信条件によっては都度配信が過剰な配信数になりかねないものがあり、そうしたキャンペーンは一定時間内の配信内容を一通にまとめて配信します。先ほどの例の「お気に入り商品の値下げ通知」であれば、仮にわずかな時間差でお気に入り商品の値下げが連続してもまとめて配信されます。 通数最適化 ここでは配信の重複と配信通数の上限をチェックをします。過去に同様の配信が済んでいたり、配信数が上限に達していたりした場合、配信をキャンセルすることにより過剰な配信を防ぎます。 チェック名 内容 チャネルの重複 同一キャンペーン・同一チャネルでの配信 コンテンツの重複 同一キャンペーン・同一コンテンツ(例えば「同じ商品の値引き通知」などの配信内容)」での配信 チャネルの通数上限 チャネル単位での通数上限 ここまでの最適化を経て配信処理に移ります。 最適化の改善 改めて今回の最適化の改善について説明します。 課題は、通数最適化の通数上限チェックが最適化全体のフローの最後に行われていたため、最適化チャネルが上限到達済みの場合に他のチャネルで配信されないことでした。そのため、この通数上限チェックを前倒ししてチャネル最適化の処理内で行うことにより、上限到達済みチャネルを最適化の対象から除外するように修正しました。 具体的には、元々チャネル最適化で「配信対象ユーザーにとって利用可能」でなおかつ「優先度が高い」チャネルから優先度付けを行っていたところに、上限到達チャネルの除外処理を移行しました。 KPIの改善 改善の結果はKPIに現れました。 こちらはある特定のキャンペーンにおける配信チャネルの比率のグラフです。6/19のリリースを境にPushチャネル(緑色)の比率が増加しています。これはメールやLINEで上限到達済みの場合に、チャネル最適化でPushチャネルが選択されるようになったためです。 続いて、配信数の積み上げグラフではいずれのチャネルも配信数が増加しています。チャネル最適化の時点で上限到達済みチャネルが選択されず、次の優先チャネルでの配信が試行されるようになったためと見られます。 こちらは全キャンペーンにおける配信除外(最適化による配信のキャンセル)の除外理由ごとの積み上げグラフです。「通数上限(日)」(青色)と「通数上限(期間)」(赤色)が6/20以降減少しており、「チャネル利用不可(チャネル選択時)」(緑色)が増加しています。こちらも、それまでの通数上限による配信除外がチャネル最適化内の処理に吸収され、その次の優先チャネルでの配信が試行されるようになったためです。 このように、これまで最適化の機会を損なわれていたチャネルで配信数の増加する改善結果が見られました。売上損失のみならず、ユーザーの購入機会の損失を防いで利便性向上に繋がりました。また、この期間内では現れていませんが、ユーザーの利用チャネルの傾向の変化に合わせて優先順位が計算されるようになりました。結果、パーソナライズの精度が高まりました。 まとめ 本記事ではZOZOTOWNのパーソナライズ配信におけるルールベースの最適化改善の事例を紹介しました。 今回の改善は現状の課題におけるごく一部に過ぎません。他の課題では以下のような例があり、真の目的である「ユーザーが本当にほしい通知だけの配信」の実現までには改善の余地が多く残されています。 配信トリガーの判定がシンプルすぎるために確度の低い配信が発生している 週間の通数上限はユーザーごとに可変である一方、日間の通数上限はチャネルごとに固定のため機会損失が生じている 配信システムの改修コストの高さが、この課題改善の障害のひとつとして存在しています。現在はこの問題に対処すべく、配信システムのリプレイスを予定しています。 techblog.zozo.com リプレイス完了の暁には、ルールベースから機械学習による最適化への移行を計画しています。 さいごに ZOZOでは一緒に楽しく働くエンジニアを絶賛募集中ですので、興味のある方は以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、CISO部の兵藤です。日々ZOZOの安全のためにSOC対応を行なっています。 本記事では、世間で横行しているフィッシング詐欺に関する情報を収集し、ZOZOを騙ったフィッシングを検知する取り組みについて紹介します。 目次 はじめに 目次 背景と概要 フィッシングハント - ドメイン編 ドッペルゲンガードメイン openSquat 構築 概要 特徴 運用 フィッシングハント - メール編 フィッシングメール収集源 フィッシングメール収集方法 Botによる監視 まとめ おわりに 背景と概要 フィッシング詐欺というと、特殊詐欺にあたるものの1つです。「メール」「SMS」などの媒体を介してユーザを本物とよく似せたフィッシングサイトに誘導し、個人情報やクレジットカード情報、IDパスワード情報を搾取する目的で行われることが多いです。 ZOZOではSNSやお客様からの情報を元に、フィッシング詐欺(フィッシングサイトやフィッシングメール)の対応を行なっていました。ですが、本対応だけではフィッシングの対応が後手となってしまい、被害拡大の可能性があります。 そこでCISO部ではフィッシングメール、フィッシングサイトになりうるドッペルゲンガードメインの収集(フィッシングハント)を行い、ZOZOの脅威になりうる情報を検知する基盤を構築しました。 フィッシング詐欺は事業を行なっている企業全てに関係する脅威だと思います。同じような取り組みを実施したいと考える皆様の参考になれば幸いです。 フィッシングハント - ドメイン編 ドッペルゲンガードメイン 攻撃者がフィッシングサイトを建てる際、似たようなサイトになるように努力をすることでしょう。その内の1つにはドメインも含まれており、コンテンツ改竄やホスティングサービスを使用しない場合は新規でドメインを登録する必要があります。 ドメインを取得する際には本物のドメインと類似したドッペルゲンガードメインを利用する場合があります。 本物のドメイン ドッペルゲンガードメイン zozo.jp zoz0(ゼロ).jp 上記のようなドッペルゲンガードメインが新規で作られていれば、その情報を収集するツールは多くあります。ZOZOではopenSquatというツールを使用し、ドッペルゲンガードメインを収集しています。 openSquat openSquatはドッペルゲンガードメインを収集するオープンソースのセキュリティツールです。公式サイトは こちら のリンクをご参照ください。 このopenSquatは1日1回新規ドメインリストを更新してくれます。そのドメインリストの中から、 keywords.txt で設定した本物のドメインに対するドッペルゲンガードメインを収集します。また、オプション( --phishing )によっては既知のフィッシングドメインからドッペルゲンガードメインを収集できます。 構築 概要 ZOZOでは1日1回、上記openSquatを実行する基盤をAWS上に構築しました。以下が概要図になります。 1日1回、EventBridgeを用いて起動命令を飛ばす。 起動命令をLambdaで処理し、NAT、ECSコンテナを作成。 コンテナでopenSquatを実行。 取得したドッペルゲンガードメインからurlscanを用いてレピュテーションとスクリーンショットを取得。 悪性スコアとスクリーンショットをSlackに通知。 上記全て完了すればNAT、ECSコンテナの削除を実施。 特徴 この基盤の特徴としてはLambdaではなく、コンテナ上でopenSquatを実行しているところです。 というのもopenSquatは起動するときにファイルを諸々作成することになるので、インメモリで実行されるLambdaでは相性が悪かったという経緯があります。openSquatの構造を変更せずに実装する場合では、コンテナでパッケージ化することが実装の近道でした。 また、 urlscan のAPI 1 を用いることで、ドッペルゲンガードメインのレピュテーションやスクリーンショットを自動取得することも特徴でしょう。この機能によりSlackを確認するだけでフィッシングサイトなのか、ある程度の判断が可能です。 urlscanを利用する上で注意すべき項目としては、スキャンにある程度待ち時間が存在することです。NWの状況によってはスキャンに時間がかかったり、できなかったりします。スキャンが終了するまでの間はレスポンスが404で返されます。そのような状況を踏まえて以下のようにスキャンの合間に time.sleep 関数を挟んでいます。 try : uuid = do_scan(domain) time.sleep( 40 ) #urlscan完了までの待ち時間 image = get_image(uuid) #自作関数 score = get_score(uuid) #自作関数 domain = domain.replace( "." , "[.]" ) #Defang処理 運用 現在、毎日この可愛いワンちゃんがお知らせをしてくれます。フィッシングサイトであれば一目で確認できます。 実際にフィッシングサイトを検知した様子 フィッシングハント - メール編 フィッシングメール収集源 突然ですが、ブログサイトにはメールを使った投稿機能があるのを皆さんご存知でしょうか? ブログ投稿用のメールアドレスを用意して、そのメールアドレスに届いたメールの内容がブログに投稿されるといった流れです。 このメールアドレスが何らかの理由で流出し、フィッシングメールが届くようになればそのフィッシングメールの内容がブログへ投稿されるようになります。このフィッシングメールが投稿されているブログを監視することでフィッシングメールの収集が可能です。 上記の仕組みについてはフィッシング詐欺ハンターの「にゃんたく」さんの記事 2 が参考になります。 フィッシングメール収集方法 ブログの情報はRSSを用いて収集が可能です。このRSSの情報を収集すればフィッシングメールを自動的に収集できるというわけです。 RSSのURLは各ブログページのHTMLを表示すれば確認できます。以下が記載例になります。※URLはZOZOのドメインを使用しています。 # feed階層配下 < link rel = "alternate" type = "application/rss+xml" title = "ZOZO - RSS" href = "https[:]//zozo.com/feeds/posts/default?alt=rss" /> # index.rdf形式 < link rel = "alternate" href = "http[:]//zozo.jp/index.rdf" type = "application/rss+xml" title = "RSS" /> # rss階層 < link rel = "alternate" type = "application/rss+xml" title = "RSS2.0" href = "https[:]//zozo.com/rss" /> 上記のRSSを用いて、SlackのChannelに投稿させることで、フィッシングメールを収集するChannelが出来上がります。 ZOZOではフィッシングメール情報をRSSを用いてSlackの1Channelに集約しています。以下がその模様です。 Botによる監視 上記のChannelには大量のフィッシングメールが届きます。このフィッシングメール全ての人力監視はリソースを考えると不可能です。 ZOZOではこのフィッシングメールを監視してくれるSlackBotを作成し、何かあればChannelの参加者にメンションを行う仕組みを導入しています。 Botは slack_bolt を使用し、Azure Web Appsで起動させています。簡易的なアプリの起動であれば即座に構築できるのでとても便利です。 slack_boltで監視するものは基本的にRSSで投稿される message イベントになります。これでフィッシングメールの内容がZOZOに関するものか判断します。 @ app.event ( "message" ) def event_message (client, event, say): content = event[ "text" ] 実際にZOZOを標的にしたフィッシングメールを検知した際には以下のようにメンションとスタンプでお知らせしてくれます。 まとめ フィッシングメール、フィッシングサイトになりうるドッペルゲンガードメインの収集(フィッシングハント)を行い、ZOZOの脅威となる情報を検知する基盤構築の取り組みを紹介しました。 意外と簡単に基盤が構築できると感じたのではないでしょうか? ZOZOではこれからもフィッシングメールやフィッシングドメインを能動的に収集し、検知することで少しでもフィッシングの被害に合う方達を無くすことを目的に活動していこうと考えています。 近年では、ホスティングサービスを利用したフィッシングサイトやSNSを利用したフィッシング、またWeb3技術のIPFSを利用したフィッシング 3 も観測されています。ドッペルゲンガードメインだけでは検知できないフィッシングサイトも上記の通り出現している傾向があるため、フィッシング詐欺への対策は更なる工夫と検知精度が必要です。そのためにも地道にフィッシング詐欺への対策を1つずつ実施し、脅威情報を少しでも多く収集し活用していくことが大切です。 本記事がフィッシング詐欺に対しこれから対策していく足掛かりになれば幸いです。 おわりに ZOZOでは、一緒に安全なサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクから是非ご応募ください! corp.zozo.com urlscanの APIドキュメント ↩ 不審なメールを収集できる(かもしれない)ポストブログについて書いてみた。 ↩ 注目の脅威:サイバー犯罪者がフィッシング攻撃やマルウェア攻撃にIPFSを採用 ↩
こんにちは、MA部の谷口( case-k )です。私達のチームでは配信システムの開発や運用をしています。 ZOZOでは配信システムを内製化しており、メルマガやPush通知、LINEメッセージ配信などを自社で実施しています。本記事では配信システムの障害対応の取り組みについてご紹介します。 現在の障害の発生頻度は週に数件程度ですが、1年ほど前までは連日障害が発生していました。障害のない日の方が珍しい状態で、ほぼ毎日数件の障害が発生していました。現在も週に数件程度は発生してますが、障害が丸一週間ない日もでてきました。1年ほど前と比べると月間の障害件数は70%〜90%減少しました。最近発生している障害もリリース起因やオペレーションミスによるものです。 本記事では障害が多かった理由やどのようにして改善していったのかご紹介します。同じように障害の対応に課題を抱えている方々の参考になると幸いです。 配信システム特有の障害と実施した対策 まず、配信システムにおける障害の性質をご紹介します。配信システムでは、主にスケジューラーを使用して配信処理を制御しています。アクセスログなどのイベントをトリガーにした配信もありますが、ほとんどはマイクロバッチを含むバッチ処理です。このため、ユーザ起因による新規の障害は少なく、ほとんどの障害は過去に一度発生したことがあるものになります。恒久対応をすることで、障害は解消されますが、対応しなければ問題は改善されません。 配信システムの障害・アラートの種類は、大きく3つの性質に分類できます。 障害の種類 事例 優先度 即時対応が必要かつ運用で対応できない障害 重複配信、秘密情報の漏洩等 超高い 即時対応が必要だが運用で対応できる障害 配信処理やデータ連携処理の中断 高い 通知のみで対応不要なアラート 配信処理に影響がないと即時断定できるアラート 低い 実際に発生した障害を例にご紹介します。 同じお客様への重複配信 まず最初にご紹介する障害はお客様へ重複配信してしまう障害です。配信処理が冪等になっておらず、リトライによって重複配信されてしまいました。 対処 まず配信が継続している場合は配信を止めます。次に重複配信してしまったお客様を特定します。並行してCSとも連携してお客様に対する謝罪文を用意し、お詫びのご連絡を入れます。 原因 重複配信が起きてしまうのは配信処理が冪等になっていなかったのが原因です。配信処理の前に配信済みかチェックするための処理は入っていましたが、不十分でした。同じワークフローを2度実行してしまうケースを想定しておらず重複配信されてしまいました。 対応 配信処理の前に配信済みかチェックするための処理を見直し改善しました。また、現在実装の不備で重複配信されてしまった場合は検知できるよう監視を整備しています。「即時対応が必要かつ運用で対応できない障害」はほとんど発生しませんが、発生した場合はこれまでも即日で再発防止策を導入してきました。 配信処理の中断 次にご紹介するのはメモリリークやスケーリングの失敗等様々な利用で配信処理が中断してしまう問題です。配信システムではメールやLINE、Push等様々なチャンネルに対して配信処理をしています。 しかし、様々な問題が原因で配信処理が中断されてしまい、手動での復旧が必要でした。理由は本当に様々で、配信処理に必要なワーカーのスケーリングに失敗したり、メモリのリーク、リトライの未実装、Cloud SQLのメンテナンス起因の障害、GKEのノード障害、Postgresのロックが解放されない問題など様々です。 これらの障害は長年放置されてきたこともあり、運用負荷を高めていました。また、障害対応には手動の対応が発生します。オペレーションミスによって、重複配信されてしまうことも過去にありました。 LINE配信のメモリリークに関する問題は以前以下の記事にも書いています。 techblog.zozo.com 対処 配信中断系の障害の多くは対応手順が決まっています。対応順に従い障害対応者が再配信できるよう対応しました。リカバリに時間がかかってしまうものは、関係者と連絡を取り合いながら対応してきました。 原因 配信処理が中断してしまう原因は様々ですが、根本的には障害に対し恒久対応を入れる習慣の欠如、優先度の低さが理由です。 対応 配信処理が中断されないように原因を特定したり、原因特定に至らないものは手動のオペレーションを自動化するなどして対応してきました。具体的にどのようにして、優先度をあげ、習慣を作ったのかは後述の「障害対応の体制を見直す」でご紹介します。 配信処理に影響がないと即時断定できるアラート 次に紹介するのは自動でリトライされるなどして、実際には配信処理への影響がないアラートです。配信システムでは、PushやLINE配信など、メッセージングキューを利用するシステムが多く存在しています。これらのシステムではリトライで成功する一時的なエラーも頻繁に発生していました。その他にもバッチ処理のSLAが超過している処理も数多くありました。発生しているものはアラートとして通知はされますが、確認のうえ対応不要として静観されているものがありました。 対処 電話はなるため深夜でも叩き起こされますが、配信処理に影響のないと判断し静観します。 原因 監視すべき対象やバッチ処理を作成した当時からSLAの見直しが行われてこなかったのが原因です。これらも根本的には障害に対し恒久対応を入れる習慣の欠如、優先度の低さが理由です。 対応 これらの障害に対しては、最終的なアウトプットに焦点を当てた監視を設定したり、障害を警告レベルに抑えて緊急対応が不要となるように恒久対応を入れてきました。また、バッチ処理のSLAを見直し、各種バッチのSLAの調整しました。さらに、要件に必要なSLAを超過してしまうバッチは、処理を見直すなどの根本対応を実施しました。具体的にどのようにし習慣を作ったのかは後述の「障害対応の体制を見直す」でご紹介します。 障害対応の課題 これまでも配信基盤チームでは障害対応をしてきました。当番制で運用しており、障害対応の当番は当番週に発生した障害の対応をし、週次で実施している障害振り返りのタイミングで発生障害をチームに共有していました。以降「アラート当番」と記載します。前述したとおり、即時対応が必要かつ運用で対応できない障害に対しては恒久対応を施してきました。しかし、即時対応が必要で、運用で対応できる障害や対応が不要な障害は長年放置されてきました。配信チームで抱えていた障害対応の課題についてご紹介します。 増え続ける障害 前述の通り、緊急性の高い障害に対しては即日で恒久対応してきました。しかし、緊急性が高いものでも、運用で対処可能な障害や対応が不要な障害は長年放置されてきました。配信システムの性質上、同じ障害が何度も発生することが多く、古いものでは数年前から週数回程度の頻度で発生している障害も存在しました。新規施策や新しい取り組みを実施することで、障害は増加し続け、結果として障害のない日が珍しい状況となっていました。 開発業務に集中することが難しい 運用で対処できるとは言っても、緊急性の高い障害には即時対応が必要です。開発などの他の業務を行っていても、障害対応を最優先しなければなりません。アラート当番ではなくても、対応に詳しい人が限られている場合、Slackのハドルに次々と集まり、本来集中すべき開発業務に専念できない状況が続いていました。 連日の対応による幸福度の低下 障害のない日が珍しい状況では、アラート当番週であれば深夜に連日起こされることも珍しくありませんでした。休日も基本的に障害が発生するため、待機当番は外出が困難になります。障害の発生頻度が低ければ、PCを持ち歩き、モバイルWi-Fi等で対応する方法を採用できます。しかし、障害が当たり前のように発生する状態では、外出を控えざるを得ません。その結果、生活体験が損なわれ、幸福度が低下してしまいます。なお、休日の2日間待機した場合は待機休暇が半日つきます。 対応者の偏りと成長機会の損失 障害の一次対応は、これまで二人で行われていました。二人で対応すると経験の多い一方が主導して対応することが多くなります。対応できる人が偏り、対応できない人はスキルが身につき難い状態となっていました。実施した経験がないため、いざやってみるとDB接続やSSH、権限不足等ですぐに対応できない状態になっていました。緊急の対応が必要になるため、熟練者の運用負荷はあがる一方で、経験のすくないメンバーは成長機会を得られない状態が続いていました。 お客様の体験を損なう 運用で対応できるとはいっても、対応に時間がかかってしまう障害もありました。配信効果を最大化できるタイミングで配信できなかったり、オペレーションミスによって、重複配信してしまうなどお客様の体験を損なってしまうこともありました。 恒久対応を施す習慣の欠如 これまでも週次の障害の振り返りを行う時間を設けていました。 前週のアラート当番が発生した障害をチームに共有することで、障害の理解度を測っていました。しかし、原因の調査深掘りや対応方針の策定、恒久対応をいれる責務はアラート当番から外されていました。簡単に対応できるものであれば、すぐにPull Requestを作り改善するのが理想的ですが、恒久対応をいれる習慣がなかったため放置されてきました。 障害対応の体制を見直す 前述したように、配信システムの性質上、恒久対応をすることで同じ障害が発生しなくなります。ここでは、恒久対応を実現するために実施した施策をご紹介します。 まず、障害振り返りの運用体制を見直しました。これまでも障害振り返りは週次で実施していました。 これまでのアラート当番の役割は以下の通りでした。 アラート当番は当番週に発生した障害の対応をすること 障害振り返りで発生した障害をチームに共有すること 新規の障害に対して手順書を作成すること 体制を見直す以前は、アラート当番週の障害対応と、チーム内に発生した障害を共有するまでを責務としていました。 改善後は恒久対応までをアラート当番の責務としました。数十分程度で恒久対応できる障害はアラート当番が実施することにしました。 アラート当番の責務として以下のような運用ルールを定めました。障害振り返り実施の流れについてはこの後ご説明します。 アラート振り返りの改善点・相談・連絡事項 まずアラート振り返りの冒頭で障害や体制自体の相談時間を設けています。障害対応の体制を継続的に改善できるようにするためです。 以下のように相談したい内容があれば書き出します。 アラートログの黙読 アラート当番が先週発生した障害をチーム内に共有します。先週発生した障害はスプレッドシートにまとめられています。スプレッドシートには障害ごとに発生した件数と手順書がまとめられています。チームメンバーは発生した障害を確認し、不明点等あればアラート当番に確認します。対応が不明確な障害はこのタイミングで議論します。 アラートタスクの確認 これまで障害タスクの管理が十分でなかったため、障害対応の体制を見直し、恒久対応を実施するようになりました。障害は、振り返り時にタスク化し、担当者と期日を設定して進捗を把握できるようにしています。軽微な障害は、アラート当番が恒久対応を担当しています。 時間がかかる恒久対応タスクについては、障害振り返りのタイミングで担当者を決定します。タスクの進捗はJIRAを用いて管理しています。 エスカレーションポリシーの見直し また、障害の一次対応は二人体制で行っていました。一時対応者の二人が対応できない場合は、全員にエスカレーションが行きます。 しかし、前述の通り一次対応者が二人体制だと、対応する人が偏りがちでした。熟練者と新人が組んだ場合、急ぎ対応が必要な障害対応では熟練者に頼る傾向があります。新人の育成に問題が生じていました。 連日のように障害が発生している状態では障害が同時多発的に発生するため、一人で対応することは困難です。しかし、障害がある程度落ち着いた時期には、一次対応者を一人にする方が望ましいと思います。一次対応者のペースで原因調査から、エスカレーションを含む対応の意思決定の経験を積めるからです。 配信チームでは、障害が落ち着いた時期に一次対応者を一人に変更しました。一次対応者が対応できない場合は、二次対応者に通知され、さらに対応できない場合は全員に障害が通知される体制をとっています。 一次対応者を一人にすることで、育成面と運用負荷の両面で改善されました。 障害対応の体制を見直した結果 かつて連日のように発生していた障害は、半年間で週に数件程度にまで抑えることができました。先日ついに7日間連続して障害のない日が続きました。 ちょうど1年前の月間の障害件数を見比べたものです。約70%減少しました。当時は1日あたり平均2〜3件障害が発生してる計算になります。また、当時は同じ障害が繰り返し発生していたことも分かります。 2022年4月に発生した障害:65件 2023年4月に発生した障害:9件 運用負荷が減った 運用負荷が軽減されました。体感できるレベルで減りました。以前は障害が発生する度にSlackへ集まる動きがありましたが、障害が減少し、開発業務にも注力できるようになりました。連日叩き起こされることもなくなりました。また、障害の一次対応も障害が減ったことで2人から1人になり、一次対応が必要なケースは半減しました。アラート当番の運用も楽になりました。 当たり前に恒久対応を入れる習慣が根付いた 障害対応者が発生した障害に対して自然とPull Requestを作り、恒久対応を入れる習慣がチーム内に根づきました。 軽めの障害だと、障害発生時に恒久対応のPull Requestをその場で作る動きも観測できるようになりました。感動です。 連日の対応がなくなり幸福度UP 連日のように障害対応をすることもなくなり、連日深夜に叩き起こされることもなくなりました。基本的には障害が発生しなくなったので、アラート当番でもPCは持ち歩き週末出かけられるようになりました。みんな幸せになった気がします。 対応者の偏りがなくなり、成長機会が増えた 一次対応者を一人にしたことで対応者が偏らなくなり、スキルの向上にも繋がっているように思います。障害に対して原因を調査し、対応できるようになっています。最初はいざ一人で対応するとSSHやDB接続に苦戦し、GCPリソースの権限が不足しているなどもありました。一次対応者が一人で対応できるようになることで、「対応できそう」から「対応できる」ようになったように思います。 障害対応の体制改善後の課題と解決策 障害の対応体制を見直した後に発生した課題とその解決策についてもご紹介します。 積まれていく未消化のタスク 障害の発生件数が安定しているときには、担当者を割り当てることで問題を解決可能です。しかし、連日障害が発生している状況下では、担当者だけでは対応が追いつきません。未消化の障害タスクが蓄積されてしまいます。 連日のように障害が発生している状態では、迅速な対応が重要となります。障害担当者に関係なく、高い発生頻度の障害に優先的に対処することが必要です。そうでなければ、障害タスクがたまり、対策自体が形骸化してしまうことになります。対応においてもできるだけ、即効性のある対応が必要です。このあたりは障害の発生頻度にもよると思います。クエリのパフォーマンスを改善しなくても要件を満たせる場合はSLAを緩めたり、OSSの修正に時間がかかる場合やOSSを使わないような判断もしてきました。 障害改善と運用改善が区別できていない これまで発生した障害に対して優先度は設定されていませんでした。障害の発生頻度が低い場合、優先度も低くなります。優先度が設定されていないと、未着手の障害対応タスクが蓄積されてしまいます。 そこで、発生した障害に優先度を設定することにしました。優先度が低い障害は「運用課題」として別途対応することになりました。優先度は、発生頻度が高いものや運用でのリカバリが困難なものに限定しました。障害に対して優先度をつけ、障害改善の優先度をあげ、運用改善を区別するようにしました。 発言者の偏り 前述した「アラートログの黙読」の際には、質問タイムが設けられています。この時間では、発生した障害に関する質問や対応策について話し合われます。以前は発言者が一部に偏っていたため、ルールの変更が行われました。現在の質問タイムでは、先週のアラート当番が質問をするようになっています。 習慣を作る上で大切だと思ったこと 体制を見直していく上で個人的に大切だと思ったことをご紹介します。 障害対応を優先的にすること 恒久的な障害対応を実施する際、人それぞれ障害の優先度が異なるため、説得が必要でした。実際に施策を進める中で、障害対応よりも開発業務に注力した方が良いという意見もありました。当初の賛否は、半々くらいでした。しかし、LINE配信のメモリリーク対応など多い時では週に3回程度発生し、リカバリも複数人で2時間程度使っていました。状況を整理し、チーム内で納得感を持って説得し、障害対応の優先度を高くしていく必要がありました。 障害を改善していく気持ち 常態的に障害が発生している場合は習慣だけだと捌ききれません。ある程度集中して、障害の発生頻度を抑える必要があります。連日のように障害が発生している状態では担当関係なく、開発業務と並行して週に2〜5つ程度のペースで恒久対応を入れていきました。障害を改善していく気持ちも大切です。 継続的に改善していくこと 新しい開発や施策の実施により、新規の障害も発生します。一定の抑制ができた後も、チーム内で継続的に改善する習慣が必要です。そうしないと、徐々にまた障害が増えていきます。恒久対応が当たり前になるよう、チームで取り組むことが望ましいです。継続的に施策を改善できるよう振り返りの時間を設けることも大切だと思います。 今後の課題 障害の対応体制を見直した結果、週数件程度にまで障害は減りました。発生頻度が高く、繰り返し発生する障害には何らかの恒久対応が入っています。障害が減ったことは嬉しいですが、今後の課題としては以下のようなものがあると思います。 オペレーション起因の障害 配信システムでは、配信セグメントの作成などの配信施策はマーケターによって行われています。一部の自動化できないオペレーションもあるため、オペレーションミスによる障害が発生しています。全ての改善はできませんが、CIでの確認や権限管理等で防げる部分もあるため、今後対応していけたらと思います。 障害対応の経験が積み難くなった 喜ばしい悩みではありますが、障害が減少したことで、障害対応の経験が積みにくくなったように感じます。恒久対応が施された障害の9割以上は手順が決まっているものでしたが、障害が発生するとログやコードを読み原因を調べる機会もありました。障害がすくなくなったことで経験を積み難くなったように思います。とはいえ、手順の決まっている障害で障害対応力を鍛えることは難しいため、このあたりは課題に感じます。昔半年間ほど2人で障害対応をしていた時期があり、障害対応力をかなり鍛えられましたが健康に悪いのでお勧めはできません。 最後に 配信基盤チームの障害対応の事例についてご紹介しました。一年前だと過酷な状態でしたが、今では障害も少なく健康的に働け、開発業務にも集中できるようになっています。 この記事を読んで、もしご興味をもたれた方は是非採用ページからお申し込みください。 corp.zozo.com
こんにちは。ML・データ部 データ基盤ブロックの塩崎です。最近はつちのこフェスタが4年ぶりに開催されたというニュース 1 でアフターコロナの訪れを感じています。 さて、データ基盤のためのデータ転送パイプライン構築といいますと、多くの方はMySQLなどのデータベースからのデータ連携を思い浮かべるかと思います。実際にシステムの保有する多くのデータはデータベースに保存されており、データベースからのデータ連携は大きな部分を占めます。当ブログでも数々の事例を紹介してきました。 しかし、それ以外にもデータを保有しているソースは数多く、それらからのデータ連携を作成する必要もあります。今回は日本の多くの企業で導入されているクラウドサービスであるkintoneからBigQueryへリアルタイムにデータ連携する事例を紹介します。 従来手法について 既にkintoneからBigQueryにデータ連携するソリューションは数多く、Googleで「kintone BigQuery」などのクエリで検索すると多くの記事が見つかります。しかし、それら既存の手法には以下の欠点があったため、今回は一から自作してみました。 日次などの頻度でのバッチ連携をしているので、データの反映にタイムラグがある 有料のパッケージソフトもしくはSaaSが必要になる 提案手法 今回構築したシステムのアーキテクチャ図を以下に示します。 まず、BigQueryのRemote Functions機能を使い、Cloud Functionsを外部関数として登録します。そして、Cloud FunctionsからkintoneのWeb APIを呼び出すことでデータを取得します。 Remote Functionsについて Remote Functions機能はCloud FunctionsもしくはCloud RunをBigQueryから呼び出すことができる機能です。 cloud.google.com BigQueryにはもともとユーザー定義関数(UDF)という似たような機能があり、SQLやJavaScriptで関数を定義できました。しかし、この機能は制約が多く、一部の処理は記述できないという問題点がありました。例えばXMLHttpRequestやfetchなどのネットワーク通信をともなう関数を呼び出すことは不可能でした。そのため、従来のUDFでkintoneのWeb APIを呼び出すことはできません。 cloud.google.com 一方でこのRemote Functions機能はCloud FunctionsやCloud Runの機能を活用できるので柔軟性が非常に高いです。Web APIの呼び出しができるのはもちろんのことですが、プログラミング言語やライブラリも柔軟に選択できます。今回の件ではランタイムのDockerイメージをカスタマイズできる柔軟性は不要でしたので、Cloud Functionsを使うことにしました。また、Cloud Functions上で動かす処理はシンプルなものなのでどの言語でも問題なく実装できますが、今回はビッグデータ系での利用者が多いPythonを使います。 kintone APIについて kintoneに登録されたデータはWeb画面から閲覧・更新できるだけでなく、Web APIを通して閲覧・更新を行えます。 cybozu.dev このREST APIを直接呼び出しても良いですが、有志が様々な言語でSDKを作成しているので今回はそれを使います。 cybozu developer networkでも紹介されているpykintoneというライブラリ を使用します。 github.com 実際に作ってみる では、ここからは実際にkintoneとBigQueryを連携するためのシステムを構築していきます。 Cloud Function まずは、以下のPythonコードでkintoneからデータを取得して、その結果を返却する関数を作成します。 import os import re import logging import yaml import json import pykintone import functions_framework import flask def read_yaml (path): envvar_matcher = re.compile( r'\${([^{^}]+)}' ) envvar_tag = '!envvar' def envvar_constructor (loader, node): value = loader.construct_scalar(node) matched = envvar_matcher.match(value) if matched is None : return value envvar_name = matched.group( 1 ) return os.environ[envvar_name] yaml.add_implicit_resolver(envvar_tag, envvar_matcher, None , yaml.SafeLoader) yaml.add_constructor(envvar_tag, envvar_constructor, yaml.SafeLoader) with open (path, 'rb' ) as f: return yaml.safe_load(f) def read_kintone_records (kintone_app, batch_size= 500 ): raw_records = [] offset = 0 while True : query = f "order by $id asc limit {batch_size} offset {offset}" logging.info(f "executing: {query}" ) result = kintone_app.select(query) if result.ok: raw_records.extend(result.records) logging.info(f "total count is {result.total_count}" ) logging.info(f "{len(raw_records)} rows fetched" ) offset += batch_size if result.total_count == len (raw_records): break else : logging.error(result.error) logging.error(result.detail) raise RuntimeError (f "Error while reading kintone records: {result.error}" ) break return [ {key:value[ 'value' ] for key, value in raw_record.items()} for raw_record in raw_records ] @ functions_framework.http def read_kintone (request): try : request_json = request.get_json() calls = request_json[ 'calls' ] if len (calls) != 1 : # 後述 raise RuntimeError ( "this function must be call in scalar subquery!" ) app_id = calls[ 0 ][ 0 ] kintone_app = pykintone.account.Account.loads(read_yaml( "account.yaml" )).app(app_id) kintone_records = read_kintone_records(kintone_app) return_value = [kintone_records] return flask.make_response(flask.jsonify({ "replies" : return_value})) except Exception as e: return flask.make_response(flask.jsonify( { "errorMessage" : str (e)}), 400 ) kintoneにアクセスするためにはAPIキーが必要ですので、以下のようなYAMLファイルも用意します。kintoneはアプリ毎にAPIキーが独立しているため、必要に応じてBigQueryと連携したいアプリのAPIキーをYAMLファイルに記載します。 domain : <kintoneのドメインから.cybozu.comを除いたサブドメ> apps : hoge_master : id : <アプリID> token : ${KINTONE_API_KEY_HOGE_MASTER} fuga_master : id : <アプリID> token : ${KINTONE_API_KEY_FUGA_MASTER} BigQueryとの間のデータの入出力の形式は以下のページを参考にしました。 cloud.google.com Remote Functionsの返り値の型は構造体型や配列型をとることはできず、スカラー型である必要があるという制約があります。そのため、JSON型を返却することで擬似的に複合型を返したかのような振る舞いをさせています。 また、以下のようなSQL呼び出しをすると、kintone APIをテーブルの行数と同じ数だけ呼び出してしまいkintoneのAPIレート制限に一瞬で達してしまいます。そのため、そのような呼び出しをした場合にはkintone APIを呼び出す前に関数を失敗させています。ソースコードの if len(calls) != 1: 部分でその条件分岐をしています。 SELECT read_kintone() FROM <大きなテーブル> その後、以下のシェルスクリプトでCloud Functionsにデプロイをします。Cloud Functionsには第一世代と第二世代の2種類がありますが、第二世代の方はリソース制限が緩和されており第一世代を選ぶモチベーションは少ないので第二世代を使います。なお、このコマンドを実行する前に以下の操作が必要です。 Cloud Functions用のサービスアカウントの作成 kintoneのAPIキーを格納するシークレットをSecret Managerで作成 サービスアカウントにシークレットの読み出しロール(roles/secretmanager.secretAccessor)の付与 PROJECT_ID = < プロジェクトID > PROJECT_NUMBER = $( gcloud projects list --filter= " PROJECT_ID: $PROJECT_ID " --format= " value(projectNumber) " ) gcloud functions deploy read_kintone \ --project= $PROJECT_ID \ --region=us-central1 \ --gen2 \ --runtime python39 \ --entry-point read_kintone \ --trigger-http \ --no-allow-unauthenticated \ --run-service-account read-kintone@ $PROJECT_ID .iam.gserviceaccount.com \ --set-secrets=KINTONE_API_KEY_HOGE_MASTER=projects/ $PROJECT_NUMBER /secrets/kintone_api_key_hoge_master:latest \ --set-secrets=KINTONE_API_KEY_FUGA_MASTER=projects/ $PROJECT_NUMBER /secrets/kintone_api_key_fuga_master:latest BQから読み出すための設定 次に先程の関数をBigQueryから呼び出すための設定をします。以下のterraformを反映するとBigQueryとCloud Functionsが接続されます。 resource " google_bigquery_connection " " cloud_resource " { connection_id = " cloud_resource " location = " US " description = " Connection for Cloud Resource " cloud_resource {} } data " google_cloud_run_service " " read_kintone " { name = " read-kintone " location = " us-central1 " } resource " google_cloud_run_service_iam_member " " read_kintone " { location = data.google_cloud_run_service.read_kintone.location service = data.google_cloud_run_service.read_kintone.name role = " roles/run.invoker " member = " serviceAccount:${google_bigquery_connection.cloud_resource.cloud_resource[0].service_account_id} " } 一番上で作成している google_bigquery_connection はCloud Resource Connectionです。これはBigQueryとCloud Function・Cloud Runなどを繋ぐためのリソースです。このConnectionはサービスアカウントを持ち、BigQueryからCloud Functionsを呼び出す時にはそのサービスアカウントを使います。 cloud.google.com そのため、Connectionのサービスアカウントに対してCloud Functionsを呼び出すロールを割り当てます。Cloud Functionsの第二世代は裏側でCloud Runが動いているため、 functions.invoker ロールではなく run.invoker ロールを割り当てる必要があります。 cloud.google.com 最後にBigQueryでCREATE FUNCTION文を実行してBigQuery上で関数を作成します。 ここまでの準備は最初に1回だけ行えば十分で、2回目以降は不要です。 CREATE FUNCTION `<プロジェクトID>.<データセットID>.`.remote_kintone() RETURNS JSON REMOTE WITH CONNECTION `<プロジェクトID>.US.<コネクション名>` OPTIONS ( endpoint = ' <Cloud FunctionsのエンドポイントURL> ' ) BQから呼んでみる この関数を呼び出すと以下のような非常に巨大なJSONが返されます。このままの形式ですと非常に扱いづらいため JSON_* 系の関数を使って扱い易い形式に変換します。 以下のSQLでJSONの中の各要素を取り出して通常のテーブルの列のように変換できます。巨大なJSONは構造体の配列という型をとっているので、まずJSON_QUERY_ARRAYで配列を分解し、JSON_VALUEで構造体の各要素を抜き出しています。 SELECT JSON_VALUE( row .company_code) AS company_code, JSON_VALUE( row .employee_code) AS employee_code, (省略) FROM UNNEST(( SELECT JSON_QUERY_ARRAY(`<プロジェクトID>.<データセットID>.read_kintone`( 1 ), " $ " ) )) AS row なお、このときにUNNEST関数の引数は必ず二重括弧で囲む必要があります。外側の括弧は関数呼び出し、内側の括弧はスカラーサブクエリという別々の役割を持つために一重括弧では不十分です。 cloud.google.com 実際にこの機能を運用に乗せるときには、一々 JSON_* 系関数を使うのではなく、上記のようなSELECT文をVIEWとして保存すると複雑な処理が隠蔽されて使いやすくなります。 まとめ BigQuery Remote Functions機能を使いCloud Functionsを呼び出すことで、kintoneのデータをBigQueryから取得できるようになりました。既に知られている手法と比較するとリアルタイムかつ安価であるというのが利点です。また、この方法を応用することでkintone以外のWeb APIとBigQueryを繋ぐことも可能です。 ZOZOでは、一緒に楽しく働く仲間を募集中です。ご興味のある方は下記採用ページをご覧ください! corp.zozo.com https://www.vill.higashishirakawa.gifu.jp/syoukai/gaiyo/tsuchinoko/tsuchinokofesta/ ↩
こんにちは。SRE部ECプラットフォーム基盤SREブロックの亀井です。 4月18日から4月21日にかけてKubeCon + CloudNativeCon Europe 2023(以下、KubeCon)が行われました。今回弊社からはZOZOTOWNのマイクロサービス基盤に関わるメンバー2名で参加しました。 本記事では現地の様子や弊社エンジニアが気になったセッションについてレポートしていきます。 目次 目次 KubeCon EU 2023の概要 参加メンバーによるセッション紹介 Flux Beyond Git: Harnessing the Power of OCI Unlocking the Potential of KEDA: New Features and Best Practices 最後に 番外編:現地の様子をお届け KubeCon EU 2023の概要 昨年10月にデトロイトで行われたKubeCon NAの様子については こちらの記事 をご覧ください。 今回参加してきたKubeConはオランダのアムステルダムで現地+オンラインのハイブリッド開催でした。10,000人以上が現地で参加しており、これはヨーロッパ開催のKubeCon史上最大であり、ヨーロッパで最大のオープンソースカンファレンスになるとのことでした。 この形式での開催は新型コロナウィルス感染症(COVID-19、以下コロナ)のパンデミック以降3度目の開催であり、ついにマスク着用が義務化ではなく推奨となりました。 KubeConではキーノートやセッション、LTなどを通してKubernetesに関する最新のアップデートの紹介や、実際にKubernetesを採用した企業の幅広い運用ノウハウを聞くことができます。以降では参加してきた社員がそれぞれ気になったセッションについて取り上げてご紹介します。 参加メンバーによるセッション紹介 Flux Beyond Git: Harnessing the Power of OCI SRE部ECプラットフォーム基盤SREブロックの巣立( @ksudate )です。 Weaveworks社のStefan ProdanとHidde BeydalsによるOCIに関するFluxの最新動向についてのセッションでした。 冒頭では、Fluxに関連するコントローラーやエコシステムの概要が紹介されました。その中で、CloudFormationのスタックをFluxで管理するAWS CloudFormation Template Sync Controller for Fluxは個人的にとても気になっています。 https://github.com/awslabs/aws-cloudformation-controller-for-flux 本題のOCI(Open Container Initiative)についてです。 従来のFluxでは、クラスター構成をGitから、コンテナイメージをイメージレジストリからPullしてくる必要がありました。 ( Flux Beyond Git: Harnessing the Power of OCI より引用) しかし、クラスタ構成とコンテナイメージの両方をコンテナレジストリを使って管理することでシンプルになります。 この構成を実現する方法として、OCIアーティファクトが紹介されました。 また、cuelangやjsonnetなどを利用している場合、Gitリポジトリには最終的なKubernetesマニフェストが含まれていない場合があります。 そのような場合、OCIであればCIで生成済みのマニフェストをOCIアーティファクトとして公開できます。 ( Flux Beyond Git: Harnessing the Power of OCI より引用) 実際にFluxでは、OCIRepositoryと呼ばれるCustom Resourceを利用する事でこれが可能になります。 OCIアーティファクトをイメージレジストリへPushするためのfluxcliのコマンドも用意されています。 flux push artifact flux pull artifact flux list artifacts その他にもGitと比較した場合のOCIのメリットが紹介されました。 例えば、OCIはAPIベースで操作できるのに対して、GitはAPIベースで操作できません。 APIベースでOCIアーティファクトを保存・取得・更新できるため、Gitに比べて扱いやすくなります。 ( Flux Beyond Git: Harnessing the Power of OCI より引用) また、OCIアーティファクトの検証方法についても、触れていました。 OCIではSigstore Cosignを利用しており、GitのOpenPGPと比べて管理が楽になります。 OCIの登場により、FluxはさらにGitをSSoTとして扱うことが可能になりました。 より詳しくは、公式ドキュメントも併せてご覧下さい。 Unlocking the Potential of KEDA: New Features and Best Practices 亀井です。 このセッションではKEDAプロジェクトについて概要と直近の変更点・ベストプラクティスが紹介されていました。 KEDA(Kubernetes-based Event Driven Autoscaling)はKubernetes上でイベント駆動(何かのイベントに応じて処理を行う仕組み)のオートスケールを実現するオープンソースプロジェクトです。 イベントソースを監視して、Horizontal Pod Autoscaler(以下、HPA)などの標準的なKubernetesコンポーネントを拡張しPodの数を動的に調整できます。サポートするスケーラーはAzure Functions、Apache Kafka、RabbitMQ、Azure Service Bus、NATS、AWS SQSなど60を超えます(ref. サポートしているスケーラー一覧 )。 下図がアーキテクチャです。ScaledObjectカスタムリソースで、スケーラーや条件といったトリガーとスケールの内容を定義します。KEDAの各コンポーネントがスケーラーを監視し、HPAなどのKubernetesコンポーネントと連携しPodの数を動的に調整します。 ( Unlocking the Potential of KEDA: New Features and Best Practices 資料9ページより引用) セッションでは下記の4つの直近の変更点が紹介されていました。 Architecture Changes 安定化のため外部スケーラーとの通信に関する構成 Certificate Management TLS1.3で暗号化されるコンポーネント間通信の証明書管理 Validation Webhooks ScaleObject Custom Resourceのvalidation Prometheus Metrics 特に気になった変更点は「Prometheus metrics」でPrometheusメトリクスを公開するようになったことです。スケーラーの状況やコンポーネントのエラー数といったメトリクスが取得可能です(ref. すべてのメトリクス )。本番サービスでKEDAを運用するハードルが下がる大きなアップデートだったのでは無いでしょうか。 また、次の3つのベストプラクティスが紹介されていました。 Polling Interval & Metrics Caching 外部スケーラーへのクエリ実行間隔の考え方とキャッシュ機能について HPA Scaling Behavior HPAのスケーリング動作を制御するオプションについて Kubernetes Metrics KEDA運用にあたって関連するKubernetes Metricsについて ZOZOTOWNでは、人気商品の販売やセールなどが定期的に行われております。そのタイミングでアクセスがスパイクするのですが、HPAに頼ったオートスケールでは間に合わないことが多々あります。事前にHPAのminReplicasを増やすなどしてPodのスケールを行っているのですが、都度作業しておりtoilになっています。 KEDAを使うことで、人気商品の販売やセールといった予定をイベントソースとしてスパイク前にオートスケールができそうに感じました。Kubernetesにおけるオートスケールの新たな選択肢として、今後も動向を注視し導入の検討を進めて行きたいと思います。 最後に 2名ともKubeConは2回目の参加、EUは初めての参加でした。EUはNAと比べ規模が小さいのではと思っていましたが全くそんなことはなくNA同様に数々のKubernetesやそれに関わるエコシステムに関する学びを得ることができました。 マスクの義務化が無くなったおかげか、コロナ前のKubeConのように各所で人々の笑顔や真剣な表情が見て取れるカンファレンスでした。 ZOZOでは一緒に働くエンジニアを募集していますので、興味のある方は以下リンクからぜひご応募ください。 hrmos.co 番外編:現地の様子をお届け 会場のRAI Amsterdam Convention Centreです。 周辺には飲食店もいくつかあり、アムステルダム中央駅からも比較的近かったのが良かったです。 続いては、恒例のKubeconで提供されるランチです。 いくつか種類があり、Vegan用のランチもありました。 ランチ会場はくつろげるスペースが多く提供されていたのでセッションの合間もこちらでゆっくり過ごせました。 そして、会場には荷物を預けるサービスもあったのでキャリーバッグを持ったまま、会場へ足を運ぶ人も多くいました。 また、今回は日本人の参加者も多く現地での交流会も行われ、とても楽しい時間を過ごす事ができました。 次回のKubecon EUはフランス パリで開催です。年々、参加者が増加しているので更なる盛り上がりに期待です! 以上、番外編でした。
こんにちは、MA部MA開発1ブロックの齋藤( @kyoppii13 )です。 ZOZOTOWNではユーザ行動に基づくキャンペーン配信を実施しています。この配信はリアルタイムマーケティングシステム(以降、RTM)と呼ばれるシステムによって実現しており、RTMでは配信トリガーや配信タイミングの最適化等にユーザの行動ログを利用しています。 この行動ログは、ユーザがZOZOTOWNのページへアクセスした際に、HTTPリクエストをRTMが直接受信する形で収集していました。しかし、RTMの既存のログ収集機能はシステム要件や運用などの課題を抱えていました。また、その一方で全社的にログを収集・蓄積する基盤も並行して運用されており、RTMはこのログ基盤を活用できていませんでした。そのため、RTMでもこの全社ログ収集基盤を利用することで既存の課題を解決しました。 本記事では、RTMにおける行動ログの活用方法と、全社ログ収集基盤への移行で考慮した点について紹介します。 RTMでの行動ログ活用方法 アクセスログ クリックログ メール開封ログ コンバージョンログ RTMでのログの取得フロー ログ取得までのフロー 従来のログ取得における課題 全社ログ収集基盤が存在しているにもかかわらずRTMでログを取得している 直接ログを集めている ログ取得時間がサーバでのログ検知日時になっている 配信処理とログ取得処理が密結合になっている 全社ログ収集基盤への移行 移行後のアーキテクチャ 2種類のログ日時 ログ連携頻度と連携方法の見直し アクセス/クリックログからのコンバージョンの取得 各ログイベントを抽出 新規セッションの判定 セッションごとにユニークなIDを付与 コンバージョン判定 今後の展望 まとめ さいごに RTMでの行動ログ活用方法 本章では、RTMでの行動ログ活用方法について紹介します。 まず、RTMがどのようなシステムかを説明します。RTMはユーザの行動や商品在庫の変化などをトリガーとして、ユーザごとにパーソナライズ配信をするシステムです。配信チャネルはLINE・メール・プッシュ通知があります。例えば、あるユーザがお気に入りしている商品が値下がりした場合に「あなたがお気に入りしている商品が値下がりしました」という訴求をします。 RTMで配信する場合、どのようなイベントが発生したときにどのような内容を訴求するかのルールを定義します。この定義をキャンペーンといいます。商品在庫などが変化した場合、RTMは定義されたルールに従いキャンペーン判定をします。ルールにマッチした場合、対象のユーザを抽出し、ユーザごとに配信内容(コンテンツ)を組み立てて配信をします。システム名にリアルタイムとついていますがリアルタイムな配信のみならず、配信時は最適化処理も実施し、ユーザごとに最適なチャネルや時間帯に配信をします。 イベントの検知から配信までの流れは以下のようになっています。 RTMの詳細については以下のテックブログをご参照ください。 techblog.zozo.com イベント検知・各種最適化・コンテンツ生成の処理ではユーザの行動ログを利用しています。行動ログは4種類で、アクセスログ・クリックログ・メール開封ログ・コンバージョンログがあります。これらのログは、配信時の最適化、コンテンツ生成、キャンペーン判定、キャンペーン分析で利用されています。また、これらのログはブラウザやネイティブアプリなどからRTMが直接取得しています。これらのログがどのようなログなのか、どのように利用しているかについて紹介します。 アクセスログ アクセスログはユーザのページ閲覧を表すログです。このログによって、どのユーザがどのページ(URL)にどのようなクライアント(アプリ・Web)でいつアクセスしたかが分かります。アクセスログをもとに、ユーザがアクセスしやすい時間帯や閲覧した商品などを判別します。この情報で配信を最適化し、ユーザごとに購入の可能性が高い商品情報を最適な時間に届けることができます。 クリックログ クリックログはRTMが配信したキャンペーンをクリックしたことを検知するためのログです。このログによって、どのユーザがどのキャンペーン経由でサイトへアクセスしたかが分かります。クリックログをもとに、ユーザがクリックしやすいチャネルを判別します。そして、最適化において、ユーザがクリックしやすい最適なチャネルへキャンペーンを送信できます。クリックログを分析し、クリックしやすいキャンペーンが分かれば、ニーズに合わせたキャンペーンを考えることもできます。また、クリック回数や日時を条件にクリックの可能性が高いユーザを抽出し配信もしています。 メール開封ログ メール開封ログによって、どのユーザがどのメールをいつ開封したのかが分かります。メールに開封ログ用の画像を埋め込むことで、開封時にこの画像が読み込まれるとRTMへリクエストされてメール開封を検知します。メール開封ログを分析し、開封しやすいメールがわかれば、開封しやすいメールの文言等をニーズに合わせて考えることができます。また、開封回数や日時を条件に開封の可能性が高いユーザを抽出し配信できます。 コンバージョンログ コンバージョンログは他のログとは違い、直接ユーザから取得しているわけではなく、アクセスログとクリックログをもとに判定します。ユーザがどのキャンペーン経由でサイトへアクセスし、注文完了まで至ったかを検知するためのログです。あるユーザの最後のクリックログ検知から一定の時間以内に注文完了ページのアクセスログを検知した場合、クリックしたキャンペーンでのコンバージョンとみなします。このログによって、キャンペーンのCVR測ることができキャンペーンのニーズがわかります。また、コンバージョンしやすいユーザを抽出し、配信も行っています。 RTMでのログの取得フロー ここまで紹介した各ログは以下のフローで収集していました。 ログ収集までのフローとログ到着後のフローに分けて説明します。 ログ取得までのフロー 最初にRTMからキャンペーンが配信されます。配信チャネルはLINE、メール、プッシュ通知です。ユーザがWeb・アプリ(iOS・Android)のどちらでサイトにアクセスしたかによってログ配信のフローが変わります。 まずアクセスログの場合、Webでは各ページでログ発火のためのビーコンが埋め込まれており、ページ表示時に発火しログを取得します。アプリはWebviewとネイティブのページが混在しています。Webviewの場合はWebと同様のフローです。 クリックログはアプリでプッシュ通知やディープリンクをクリックした際に発火しログを取得します。 メール開封ログは、メールを開いた際に画像ビーコンによってリクエストが送信されてログを取得します。 このように各ログはWeb、アプリ(iOS・Android)、メールから直接取得されるようになっていました。 次にRTMにログが到達した後の経路についてです。クライアントから送られるログにはユーザ情報とアクセス・クリックしたページ情報が含まれます。RTMに到着したログはまずメンバーIDをkeyとするキャッシュに保存されます。RTMはメインとなるアプリケーションがJBoss Data Grid(JDG)というインメモリな分散キャッシュデータストアを利用しており、高速な条件判定を実現しています。ログ到着時、対応するメンバーIDのキャッシュがなければキャッシュを新規作成、あれば既存のキャッシュを更新します。最適化の際にはこのキャッシュに含まれたデータを使用します。しかし、ログデータは分析などにも利用されるため配信実績としてテーブルにも保存しなければなりません。そこで、タイマーによって定期的にキャッシュデータを配信実績テーブルに書き込みます。配信実績テーブルのスキーマを以下に示します。 カラム名 説明 id 配信実績ID campaign_id キャンペーンID member_id 会員ID channel 配信チャネル delivery_dt 配信日時 open_dt メール開封日時 click_dt クリック日時 conversion_dt コンバージョン日時 ユーザへの配信ごとに実績が記録されます。そして、RTM DBに書き込まれたログは日次のバッチ処理で全社共通のDWH(BigQuery)に連携されます。このBigQueryに連携することで、配信実績を分析用途や他システムで利用できます。 従来のログ取得における課題 従来のログ取得における課題点は以下です。 全社ログ収集基盤が存在しているにもかかわらずRTMでログを取得している 直接ログを集めている ログ取得時間がサーバでのログ検知日時になっている 配信処理とログ取得処理が密結合になっている 全社ログ収集基盤が存在しているにもかかわらずRTMでログを取得している 1つ目に全社ログ収集基盤が存在しており、このシステムが取得しているログとRTMが直接取得しているログで重複しているものがありました。全社ログ収集基盤とはZOZOTOWNで発生するログを収集してBigQueryへと連携する基盤です。RTMは全社ログ基盤ができる前からあったシステムのため、独自でログを収集し利用していました。 全社ログ収集基盤の詳細については、以下スライドとテックブログをご参照ください。 speakerdeck.com techblog.zozo.com 直接ログを集めている 2つ目にRTMが直接ログを取得するために外向きのAPIを提供していました。そのため、不特定多数のアクセスがあり、bot等に対する対策がRTM独自で必要でした。また、セール実施時など大量のログが来る場合にはシステムの負荷が高まります。 ログ取得時間がサーバでのログ検知日時になっている 3つ目に各ログの取得日時がRTMのサーバへ到達した日時になっていました。ログの到着が遅延して検知が遅れた場合、実際のアクセスやクリック日時と異なってしまうという課題がありました。 配信処理とログ取得処理が密結合になっている 4つ目に配信処理とログ取得が同一のシステムで動作しており、密結合になっていました。ログ取得においてはバッファレイヤーがないため、アプリケーションのメンテナンス時にはログが欠損してしまうという課題がありました。また、将来的にこの配信基盤の移行を考えているため、先にログ収集の部分を切り出しておきたいと考えていました。 これらの課題は全社ログ収集基盤へ統一することで解決できるものでした。そのため、全社ログ収集基盤のログを利用することにしました。 全社ログ収集基盤への移行 前述の課題を解決するために、全社ログ収集基盤からログを取得するようにしました。その際に考慮した点を紹介します。 移行後のアーキテクチャ 移行後のアーキテクチャは以下の様になりました。 執筆時点では移行途中であり、移行が完了したものはクリックログとコンバージョンログです。アクセスログは全社ログ収集基盤とRTMで取得しており、メール開封ログはRTMでのみ取得している状態です。全社ログ収集基盤へ完全に移行した後はRTMへのログリクエストがなくなる予定です。 2種類のログ日時 全社ログ収集基盤で集めているアクセスログやクリックログといった行動ログは、クライアントでのログ送信日時と基盤でのログ検知日時の2つが日時データとして含まれています。ログ送信日時はクライアント側で付与されるパラメータのため、ログ到着が遅延しても、ログ送信日時に影響はありません。 このような全社ログ収集基盤の仕様によって、3つ目の課題であるログ取得時間がサーバでの検知日時になっているという課題を解決できます。 ログ連携頻度と連携方法の見直し RTMでリアルタイムに取得しているログは、アクセスログ・クリックログ・コンバージョンログの3つでした。この内、クリックログ・コンバージョンログはリアルタイムで集める必要のないことが調査の結果わかりました。そこで、バッチ処理によりクリックログ・コンバージョンログを全社ログ収集基盤から連携するようにしました。クリックログはそのまま連携すればよいものの、コンバージョンログはRTMでアクセスログとクリックログをもとに計算していたため、単純に全社ログ収集基盤から連携するだけでは実現できません。こちらについては次で詳しく説明します。 アクセス/クリックログからのコンバージョンの取得 既存のコンバージョン検知のロジックは以下です。 配信されたキャンペーンからサイトにアクセス。RTMがクリックログを検知し新規セッション開始。 ユーザがサイト内を回遊しアクセスログを一定時間内に検知した場合、オンライン状態とみなしセッションを更新。 セッションの開始/更新から一定時間内で購入ページでのアクセスログ(以降、購入ログ)を検知した場合、同一セッション内でのコンバージョンとみなす。 ログ連携頻度の見直しによって、リアルタイムでコンバージョンログは使用していないことが分かりました。したがって、RTMで判定しているコンバージョンを他で実施出来ればRTMの負荷を下げることができます。そのためこの処理はバッチ処理で実施することにしました。 コンバージョン判定のためには、アクセスログとクリックログ及び購入ログが必要です。これらのログは全社ログ収集基盤から取得できるログだったため、これらを利用しコンバージョンをバッチ処理で判定することにしました。 全社ログ収集基盤から取得できる各ログとスキーマについて説明します。 アクセスログにはどのユーザがいつどのページにアクセスしたかの情報が含まれています。アクセスログのスキーマを以下に示します。 カラム名 説明 uid ユーザID url アクセスしたページのURL client_timestamp クライアント側のログ送信日時 server_timestamp 全社ログ収集基盤でのログ検知日時 クリックログにはどのユーザがどのキャンペーン経由でサイトにアクセスしたかが含まれています。クリックログのスキーマを以下に示します。 カラム名 説明 uid ユーザID url クリックしたURL(キャンペーンIDをクエリパラメータに含む) client_timestamp クライアント側のログ送信日時 server_timestamp 全社ログ収集基盤でのログ検知日時 購入ログにはどのユーザがいつ購入したかが含まれています。購入ログのスキーマを以下に示します。 カラム名 説明 uid ユーザID order_id 購入ID order_timestamp 購入日時 これらのログにはセッションを識別するための情報であるセッションID等が含まれていません。したがって、バッチ処理ではこれらのログを利用してセッションIDを計算し、どのセッションでコンバージョンしたのかを判定します。 バッチ処理で実行されるアクセスログ、クリックログ、購入ログを利用したコンバージョン判定クエリは以下です。このクエリを実行することで、どのユーザがどのキャンペーン経由でいつコンバージョンしたのかがわかります。 WITH -- ①各ログイベントを抽出。各イベントを必要な期間抽出し、非正規化してUNIONで縦につなげる。 -- クリックイベント click_events AS ( SELECT uid , ' click ' AS event, client_timestamp AS event_timestamp, REGEXP_EXTRACT(url, r ' campaign_id=(\d+) ' ) AS campaign_id, NULL as order_id, server_timestamp FROM `zozo- log -platform.event_logs.click_log` ), WHERE -- 直近3日間のデータを取得する DATETIME (server_timestamp) >= DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' , INTERVAL -3 DAY) AND DATETIME (server_timestamp) < DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' ) AND REGEXP_EXTRACT(url, r ' campaign_id=(\d+) ' ) IS NOT NULL ), -- アクセスイベント access_events AS ( SELECT uid , ' access ' AS event, client_timestamp AS event_timestamp, CAST ( NULL AS string) AS campaign_id, NULL AS order_id, server_timestamp FROM `zozo- log -platform.event_logs.access_log` ), WHERE DATETIME (server_timestamp) >= DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' , INTERVAL -3 DAY) AND DATETIME (server_timestamp) < DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' ) -- 購入イベント order_events AS ( SELECT uid , ' order ' AS event, order_timestamp AS event_timestamp, CAST ( NULL AS string) AS campaign_id, order_id, NULL AS server_timestamp FROM `zozo- log -platform.event_logs.order_log` ), WHERE DATETIME (server_timestamp) >= DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' , INTERVAL -3 DAY) AND DATETIME (server_timestamp) < DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' ) -- すべてのイベント events AS ( SELECT * FROM click_events UNION ALL SELECT * FROM access_events UNION ALL SELECT * FROM order_events ), -- ②新規セッションの判定。イベント時間を昇順にみて、1つ前のイベントと比較し、新規セッションにフラグ(session_flag=1)を立てる。 event_and_session_flag AS ( SELECT uid , event, campaign_id, order_id, event_timestamp, -- イベントをuidごとにevent_timestampごとに昇順でならべて、LAG関数を利用し一個前のevent_timestampを取得 LAG(event_timestamp) OVER (PARTITION BY uid ORDER BY event_timestamp) AS previous_event_timestamp, -- 新規セッションにフラグ立て(session_flag=1) CAST ( -- 10分以上間隔が空いたアクセスは新規セッション DATETIME_DIFF(event_timestamp, IFNULL(LAG(event_timestamp) OVER (PARTITION BY uid ORDER BY event_timestamp), event_timestamp), MINUTE) > 10 OR -- クリックがあったら新規セッション event = ' click ' OR -- ユーザが商品を購入してから同一セッションで商品を購入した場合はコンバージョンの対象外とするため新規セッション LAG(event) OVER (PARTITION BY uid ORDER BY event_timestamp) = ' order ' AS INT ) AS session_flag FROM events ), -- ③セッションごとにユニークなIDを付与。session_flagとuidを利用しユニークなIDを付与。 session AS ( SELECT *, uid || ' _ ' || SUM (session_flag) OVER (PARTITION BY uid ORDER BY event_timestamp, session_flag DESC ROWS UNBOUNDED PRECEDING ) AS user_session FROM event_and_session_flag ORDER BY event_and_session_flag. uid , event_and_session_flag.event_timestamp, event_and_session_flag.previous_event_timestamp ), -- ④コンバージョン判定。click_sessionとorder_sessionのuser_sessionが同じ場合コンバージョン。 -- コンバージョン判定のためにsessionからクリックイベントのみを抽出 click_session AS ( SELECT * FROM session WHERE event = ' click ' ), -- コンバージョン判定のためにsessionから購入イベントのみを抽出 order_session AS ( SELECT * FROM session WHERE event = ' order ' ) SELECT click_session. uid , click_session.campaign_id, order_session.order_id, order_session.event_timestamp AS conversion_at FROM click_session INNER JOIN order_session ON click_session.user_session = order_session.user_session; このクエリの処理内容は以下です。 各ログイベントを抽出。 新規セッションの判定。 セッションごとにユニークなIDを付与。 コンバージョン判定。 このクエリを日次バッチ処理で実行します。このクエリについては 2022年のAdvent Calendarの記事 でも解説していますが、改めて各処理について解説します。 各ログイベントを抽出 最初にアクセス・クリック・購入のイベントデータが含まれるテーブルからログイベントを抽出します。イベントにはクライアント側の送信時間(client_timestamp)とログ基盤の検知時間(server_timestamp)の2種類のタイムスタンプが付与されています。この内、client_timestampをイベントの発生時間として扱います。WHERE句には取得するデータの期限を指定します。batch_start_timestampはバッチ処理時に、バッチ処理の開始時刻が設定されます。取得期間は過去1日分ではなく、数日分取得するようにしています。これは全社ログ収集基盤を利用する上で、遅延データの考慮をする必要があったためです。全社ログ収集基盤はクライアントからの行動ログ送信遅延などが原因で最大数日の遅延データが入ります。これを考慮し、バッチ実行時点から過去数日分のログを取得するようにしています。そして、各ログをUNION ALLで1つにまとめます。 新規セッションの判定 次に新規セッションの判定をします。前の処理で作成したテーブルをイベント発生時刻の昇順でみていき、1つ前のイベントと比較しながら新規セッションかを判断するフラグを立てていきます。この計算では LAG関数 を利用しています。LAG関数は前の行との比較に便利な関数です。LAG関数を用いて、直前のイベントからn分以上経っていたら新規セッションとします。また、イベントがクリックログの場合、直前のイベントが購入ログの場合はイベント間隔に限らず新規セッションとします。 セッションごとにユニークなIDを付与 次にセッションごとにユニークなIDを付与します。前の処理で計算したsession_flagとユーザごとにユニークなIDであるuidを組み合わせて、ユーザのセッションをユニークに判別できるID(user_session)を付与します。 コンバージョン判定 最後にコンバージョン判定をします。前の処理でユーザのセッションを判別するID(user_session)を付与しました。コンバージョンはどのキャンペーン経由をクリックし、購入まで至ったかを識別するものです。つまり、user_sessionが同じであるクリックイベントとコンバージョンイベントがあれば、そのクリックイベントをセッションの起点としてコンバージョンまで至ったと判断できます。そのため、クリックイベントとコンバージョンイベントをuser_sessionでJOINしています。この処理の結果、どのユーザがどのキャンペーン経由でいつコンバージョンしたかがわかります。 こうして得られたコンバージョンからコンバージョン日時をRTMのログ実績テーブルへ連携します。こうすることで、RTMで実行していたコンバージョン判定を別システムで実行できるようになりました。 今後の展望 クリックログとコンバージョンログは全社ログ収集基盤を利用してバッチでの連携をするようにしました。ただし、その他のログについては、パフォーマンステストにおいて要件を満たすことができなかったためまだ移行できていません。今後はすべてのログを全社ログ収集基盤からの連携にする予定です。 また、RTM自体をリプレイスするプロジェクトも進めています。今回述べたログの課題以外にも、施策の実施がビジネス側で完結せず、開発側に依存しているという課題があります。リプレイスによって、このような課題を解決する予定です。RTM自体が抱えている課題やリプレイス計画については以下のテックブログもあわせてご参照ください。 techblog.zozo.com まとめ リアルタイムマーケティングシステムの密結合なログ収集から全社ログ収集基盤への移行について紹介しました。ログの見直しによって要件を削減することで、移行の難易度とコストを低減できました。また、完全に移行はできていないものの、部分的なログ収集の疎結合化やセキュリティ向上というメリットを得られました。本記事が皆様の参考になりましたら幸いです。 さいごに ZOZOでは一緒にプロダクトを開発してくれるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください! corp.zozo.com
こんにちは、フロントエンド部の中島です。FAANSのiOSアプリの開発を行なっています。 FAANSの由来は「Fashion Advisors are Neighbors」です。「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」で2022年8月に正式ローンチしました。 はじめに FAANS iOSチームではAPI通信においてSwift Concurrencyを利用しています。Swiftに限らず並行処理を扱う場合には実装次第でデータ競合を起こす恐れがあるのに対して、Swiftではデータ競合を防ぐ仕組みとしてActorが導入されています。そして、Actor間で扱うデータがデータ競合を起こさない型であるかコンパイラでチェックされます。Swift 6ではこのデータ競合のチェックにより既存のコードでコンパイルできなくなる可能性があります。Xcode 14ではSwift 6までの間に段階的な移行ができるようにStrict Concurrency Checkingでコンパイラのチェックレベルを指定できるようになりました。本記事ではFAANS iOSチームで実施したStrict Concurrency Checkingの対応と、その過程で得られた知見について紹介します。 目次 はじめに 目次 Strict Concurrency Checkingについて Sendableについて Sendableチェックによる警告と解消方法 Case 1 : アクター隔離されたコンテキストにおいてアクター境界を超えてデータを取得する場合、取得データの型はSendableに準拠する必要がある 解決1:public structをSendableに準拠させる 解決2:@preconcurrencyアノテーションをimport文に付与する Case 2 : @Sendableが付与されたクロージャーのキャプチャ対象の型はSendableに準拠する必要がある 対応方法 まとめ さいごに Strict Concurrency Checkingについて Strict Concurrency CheckingはMinimal, Targeted, Completeの3つのレベルがあります。XcodeのBuild Settingsで設定が可能です。 Minimal, Targeted, Completeの定義は次の通りです。 Minimal: Swift Concurrencyの利用箇所で、明示的にSendableと書いている箇所でSendable制約とアクター隔離のチェックをします。 Targeted: Swift Concurrencyの利用箇所で、Minimalに加え、明示的にSendableと書いていない箇所でもSendable制約とアクター隔離のチェックをします。 Complete: Swift Concurrencyの利用に関係なく、モジュール全体を通してSendable制約とアクター隔離のチェックをします。 参考: https://developer.apple.com/documentation/xcode/build-settings-reference#Strict-Concurrency-Checking Minimalはデフォルト設定です。Sendableを書いていない場合はチェックされず今までと変わらず問題なくビルドできます。Targetedでは並行処理を使用している部分でチェックが入ります。Completeでは全ての箇所でチェックが入ります。レベルによってSendableに準拠しているかどうかのチェックの範囲が広がります。Completeではコンパイルエラーが多く問題の切り分けが難しかったため、FAANS iOSチームではTargetedに変更することから始めました。 Sendableについて Sendableは並行タスク間でデータ競合が起こらないよう、安全に共有できる型を表すプロトコルです。暗黙的にSendableに準拠するケースと、明示的にSendableを付与するケースがあります。 暗黙的にSendableに準拠するケース publicではないstruct, enumでSendableなプロパティのみを保持 Int, String, Dictionay, Arrayなど actor @MainActorを付与したclass 明示的にSendableを付与するケース class publicなstruct、enum classは次の条件を満たすことで準拠できます。 Sendableを明示的に付与 finalを付与 mutableであるvarを利用しない // mutableなvar nameを持つと警告が出る final class SendableClass : Sendable { var name = "FAANS" // ⚠️ Stored property 'name' of 'Sendable'-conforming class 'SendableClass' is mutable } // 条件を全て満たすのでSendableに準拠しており、警告が出ない final class SendableClass : Sendable { let name = "FAANS" } また、クロージャーに@Sendableを明示的に付与した場合、そのクロージャーでキャプチャする値はSendableに準拠する必要があります。Task.initなど、Sendableなクロージャーで定義されているケースもあります。 Sendableに準拠していないケースは次の通りです。 NSAttributeString等のSendableに準拠していないプロパティを保持する値型 mutableな値を持つ参照型(classなど) Strict Concurrency Checkingの設定により、アクター間でやり取りされるデータがSendableに準拠しているかどうかをコンパイラでチェックできます。 参考: https://developer.apple.com/documentation/swift/sendable Sendableチェックによる警告と解消方法 MinimalからTargetedに変更したことで発生した警告は主に次の2つです。ともにSendableのチェックですが警告発生パターンが異なるのでコード例と共に説明します。 Case 1 : アクター隔離されたコンテキストにおいてアクター境界を超えてデータを取得する場合、取得データの型はSendableに準拠する必要がある Case 2 : @Sendableが付与されたクロージャーのキャプチャ対象の型はSendableに準拠する必要がある Case 1 : アクター隔離されたコンテキストにおいてアクター境界を超えてデータを取得する場合、取得データの型はSendableに準拠する必要がある FAANSアプリはショップスタッフの情報である、StaffMemberをasync/awaitを利用して取得します。また、OSSライブラリを利用してAPIClientを実装しており、StaffMemberはpublicのstructになっています。 簡易的ではありますがViewModel経由でAPIClientの非同期関数を呼び出し、StaffMemberを取得する例で説明します。ViewModelで実行するviewDidLoad関数はviewとやり取りをするために@MainActorを付与してメインスレッドで実行しており、アクター隔離された関数になっています。APIClientの実装の詳細は省略しています。 具体的なコードは次の通りです。StaffMemberを取得するところで警告が出ています。 import SwiftUI struct ContentView : View { // ViewはViewModelを保持 @StateObject private var viewModel = ViewModel() var body : some View { Text(viewModel.staffMember?.name ?? "Loading" ) .onAppear() { // 画面表示時に実行 viewModel.viewDidLoad() } } } final class ViewModel : ObservableObject { // APIClientで非同期通信を行う private let apiClient = APIClient() @Published private ( set ) var staffMember : StaffMember? // アクター隔離(main actor-isolated)のメソッド @MainActor func viewDidLoad() { Task { // 次の警告が出る // Non-sendable type 'StaffMember' returned by call from actor-isolated context // to non-isolated instance method 'getStaffMember()' cannot cross actor boundary staffMember = await apiClient.getStaffMember() } } } // StaffMemberを非同期で取得するAPIClient final class APIClient { // アクター非隔離のメソッド func getStaffMember () async -> StaffMember { return StaffMember() } } // 外部コードであるためpublicがついている public struct StaffMember { var name = "FAANS staff" } 上記のコードで、Sendableに準拠していないStaffMember型がアクター境界を超えることはできないという警告が出ました。 // Non-sendable type 'StaffMember' returned by call from actor-isolated context // to non-isolated instance method 'getStaffMember()' cannot cross actor boundary アクター隔離されている状態(actor-isolated context)は密室な部屋にいる状態と例えることができます。部屋の中では自由にデータのやり取りが可能ですが部屋の出入りが必要な場合、つまりアクター境界を超える場合、データの型はSendableに準拠する必要があります。ViewModelのviewDidLoad関数は@MainActorが付与されてアクターに隔離されたコンテキストで関数が実行されます。APIClientのgetStaffMember関数はアクター境界の外で実行される関数です。 解決1:public structをSendableに準拠させる 値型であるstructは暗黙的にSendableに準拠します。publicがつく場合は明示的にSendableをつけないといけないので、外部コードでpublic structを利用している場合は警告が出てしまいます。解決するにはpublicなstructをSendableに準拠させる必要があります。次のようにSendableを付与することで警告をなくすことができます。 // Sendableをつける public struct StaffMember : Sendable { // プロパティはStringやInt等、Sendableに準拠したもの } 解決2:@preconcurrencyアノテーションをimport文に付与する 直接ファイルを編集できない場合は、外部コードのimport箇所で次のように@preconcurrencyを付与することで警告を出さないようにできます。FAANS iOSではこの対応を実施しました。 // Sendableチェックを無視することができる @preconcurrency import APIModels Case 2 : @Sendableが付与されたクロージャーのキャプチャ対象の型はSendableに準拠する必要がある Case 2はクロージャーのキャプチャ対象はSendableに準拠する必要があることについて考えます。Case 1では1つのリクエストでしたが、async letを利用して複数のタスクを並列で実行すると次の警告が出ます。 class ViewModel : ObservableObject { private let apiClient = APIClient() @Published private ( set ) var staffMember : StaffMember? @Published private ( set ) var shop : Shop? @MainActor func viewDidload() { Task { // async letでリクエストを並列で実行 async let staffRequest = self .apiClient.getStaffMember() async let shopRequest = self .apiClient.getShopInfo() self .staffMember = await staffRequest self .shop = await shopRequest } } final class APIClient { func getStaffMember () async -> StaffMember { return StaffMember() } // ショップ情報を取得 func getShopInfo () async -> Shop { return Shop() } } public struct StaffMember { var name = "FAANS staff" var age = 25 } public struct Shop { var name = "FAANS shop" } } async letの行で次の警告が出ます。 Capture of ' self ' with non - sendable type 'ViewModel' in a `@Sendable` closure Case 1では出ない警告がasync letを利用すると出るようになりました。Task {}のクロージャーはSendableに準拠する必要がありますが、キャプチャしたViewModelはSendableに準拠していないという警告です。Case 1で警告が出なかった理由は、Task {}のクロージャーは呼び出し元の実行コンテキストを引き継ぐ性質によるものです。 一方でCase 2に関しては、async letは子タスクを作成して実行元と異なるコンテキストで実行されます。また、async letの右辺は暗黙的な@Sendableのクロージャーのような振る舞いをするので、Sendableチェックがされることで警告が出ています。 参考: https://github.com/apple/swift-evolution/blob/main/proposals/0317-async-let.md#proposed-solution 動的な個数のタスクを並列で実行するwithTaskGroupもasync letと同様に子タスクを作成し、addTask関数がSendableなクロージャーなので警告が出ます。 対応方法 ViewModelはObservableObjectを継承したクラスでViewの値を監視する役割を持ち、staffMemberをmutableな値として保持しています。明示的にSendableを付与することでの解決が難しいです。Case 1では関数に@MainActorを付与していましたが、Case 2ではクラスのはじめに@MainActorをつけることで解決しました。@MainActorを付与したクラスは暗黙的にSendableに準拠するので警告をなくすことができます。 参考: https://developer.apple.com/documentation/swift/sendable#Sendable-Classes // @MainActorをクラスのはじめにつける @MainActor final class ViewModel : ObservableObject { ... } この対応によりself参照におけるSendableチェックの警告をなくすことができました。しかし、今度はapiClientがSendableに準拠していないという警告に変わります。 Non - sendable type 'APIClient' in asynchronous access to main actor - isolated property 'apiClient' cannot cross actor boundary ViewModelクラス全体をアクター隔離したのでViewModelが保持するapiClientもアクター隔離されます。しかし、async letを利用して異なるコンテキストで実行された結果、アクター境界を超えるのでapiClientがSendableに準拠する必要があります。今回の例ではAPIClientクラスはmutableな値を持たず、final classなのでSendableをつけることで解決ができます。 // Sendableをつける final class APIClient : Sendable { func getStaffMember () async -> StaffMember { return StaffMember() } func getShopInfo () async -> Shop { return Shop() } } 比較的簡単な例を示しましたが、APIClientがリクエストに必要なヘッダー等をmutableな値で持ち、classのままではSendableに準拠できない可能性があると思います。FAANS iOSではその箇所が外部コードになっており、@preconcurrencyをつけて解決していますが、直す場合はactorやstructを利用して解決する方法があると思います。今後ライブラリ側でどのような対応がされるかチェックしていきたいと思います。 まとめ Swift Concurrency CheckingをTargetedに設定した際、かなりの警告が出ましたが紐解いてみると同じパターンで警告が出ているだけで、芋づる式に解決できることが多かったです。同じ問題に遭遇した方や今後同様の対応をする方でこの記事が参考になれば幸いです。 さいごに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。カジュアル面談もお待ちしております。 corp.zozo.com
こんにちは、WEAR Webフロントエンドチームの吉田と大脇です。 現在 WEAR ではNext.jsでのリプレイスが進行中です。今回はリプレイスのデザイン面における課題と解決に向けて行った取り組みを紹介します。 リプレイスの経緯や技術選定については、弊社の藤井の記事をご覧ください。 techblog.zozo.com 10年の歴史のあるアプリケーションと向き合う リプレイスにおける課題 他部署との連携 デザイナーとエンジニアの歩み寄り ミーティングで話し合ったこと ミーティングを行って得た気づき デザインツールの変遷 未来のこと、これからやっていきたいこと デザイントークンの定義 デザイン周りの負荷軽減 技術面でやりたいこと Tailwind CSS Storybook 終わりに 10年の歴史のあるアプリケーションと向き合う WEARは今年で10年目となります。Webサービスとしては長期間に渡って運用されているのではないでしょうか。WEARは幾度かのデザインリニューアルや担当者の交代などの経緯があり今のデザインに至っています。デザインルールの整備やマスタデータの管理フローなども時代によって変化しており、現時点のルールは明文化されていません。そのため、現在の実装内容が正しいデザインとして扱われていました。 リプレイスにおける課題 今回のリプレイスでは、大きなデザインリニューアルは行わず、既存のデザインを踏襲していく形で進行しています。そのため、実装内容からデザインを読み解き、それを基に開発しており、品質を担保しながらスピード感をもって開発を進めることに課題を感じていました。 具体的には以下のような課題があります。 色 旧環境でも色定義をしていたが、定義外の色も多く使用されており、色を都度デザイナーに確認しながら実装していく必要がある 余白 入れ子の要素それぞれが余白を持っており、正確な余白を直感的に判断できない line-height を余白の調整に利用しており、正確な余白の算出が困難である コンポーネント 「角丸が1px小さい」など 似ているが微妙に違うUI が複数存在する UIの共通化を都度デザイナーに相談する形をとっているが、1つ1つは小さくても積み重なるとお互いにコストとなる これらの課題を解決できればよりスピード感を持って開発ができそうです。また、今後も継続してサービスを改善し成長させていくためにも、足場を固める必要があると考えました。そこでWebフロントエンドチーム内では「エンジニアとデザイナー間での共通言語としてデザインシステムを採用し、認識を合わせていくのはどうか」という話が上がりました。 こうしてWEARのデザイン開発効率を上げるための環境作りを目指した活動が始まりました。 他部署との連携 まずは、前章で挙げたような漠然としていた課題を整理しました。ホワイトボードツールを用い、改善したい項目を 重要度 と 難易度 の2軸でグラフ化しました。その中でも「重要度が高く、難易度が低い」ものを優先してデザイントークン化していくことにしました。 デザイナーとエンジニアの歩み寄り デザインシステムを作っていく上で当然デザインに関わることを決めるので、エンジニアのみで進めることは難しく、デザイナーと協力して進める必要がありました。また、WEARはWeb/iOS/Androidでサービスを展開しているため、モバイルエンジニアとも話し合う必要がありました。デザインシステムのチームがあるわけではないので、各チームから代表者を募り、ミーティングをしました。 ミーティングで話し合ったこと エンジニア側でデザインシステムがないことで困っていること デザインルールが明文化されていないことで起こる、エンジニアとデザイナー間での無駄なコストを下げるために必要なこと 各チームがデザインシステムに対して期待していること 過去にデザインシステムを作ったことがあるメンバーの経験に基づく「作成していく上で大変なこと」 ミーティングを行って得た気づき WebとiOS/Androidで困っているポイントが違う コンポーネント単位でデザインレビュー 1 を行えれば、再度同じ部分のデザインレビューが不要になる デザインシステムを仮に作り上げたとしても、それを既存のサイトやアプリに反映するだけでも大きな工数がかかる 初めから完成されたデザインシステムを目指さずとも、既存デザインのルールをまとめるだけでも価値がある 特に、既存デザインのルールをまとめるだけでも価値があるという意見に共感しました。WEARは既に10年運用されているサービスで、言語化していなかったとしても潜在的にデザインルールが存在していると思うので、そのルールを明文化するだけでも以下のメリットがあると考えました。 新しく入社したメンバーのオンボーディングに役立つ デザイナーとエンジニアの共通言語となる デザインシステムを作成するときに流用可能 よって、まずはデザイナーに現状をヒアリングして、既存デザインのルールをまとめることにしました。 デザインツールの変遷 既存デザインのルールをまとめていくにあたって、どのツールを使って形にしていくかという問題があります。候補は以下の3つがありました。 Adobe XD + Zeplin Figma Confluence WEARではデザインツールとしてAdobe XD、デザイン共有ツールとしてZeplinを利用していました。しかし、Webフロントエンドチームとしては以下のような理由からFigmaを利用したいと考えていました。 行間の指定( line-height )などWebとの親和性が高い デザインデータのプレビューがConfluenceなどのドキュメントツールで埋め込み可能 プラグインが豊富 コンポーネント化できる(これはXDでも可能) しかし、デザインツールを乗り換えるコストからFigmaの利用は断念し、Adobe XDまたはConfluenceを利用する方針になりました。そんな矢先、今年の1月ごろAdobeから「今後XDの積極的なアップデートは行わない」との発表が出ました。 結果、候補はFigmaとConfluenceに絞られました。FigmaとConfluenceを比較し、以下の意見が出ました。 視覚的なわかりやすさ Confluenceを使うと文字メインでデザインを説明することになる。それでは視覚的にわかりづらく、運用していくのも大変そう Figma上でデザインルールを起こせば視覚的にわかりやすい デザインシステムへの流用のしやすさ Confluenceでデザインルールを残しても、流用しづらい中途半端なデータとなってしまう Figmaならデザインデータをそのままデザインシステムにも流用できる よって、Figmaを利用して既存デザインのルールをまとめることになりました。 未来のこと、これからやっていきたいこと デザインルールをまとめる作業は分量が多く、すぐに終わることはないので、時間を見つけて引き続きやっていくことにしました。この章では、デザインルールのまとめ作業と並行して、これからやっていきたいことを紹介します。 デザイントークンの定義 他部署との連携 の章で書いたような「エンジニアとデザイナー間での無駄なコストを下げる」を実現するために、デザイントークンの定義をしたいです。例えば、文字サイズ、余白、角丸、シャドウなどのルールを明文化し、iOS/Androidでも共通のルールで運用することで、各プラットフォームごとのズレを減らすことができると考えています。 デザイン周りの負荷軽減 他部署との連携 の章で書いたようなデザイン周りのコスト削減のために、以下のような運用をしたいです。 似たようなデザインで再度デザイン作業が発生しないように抑止 Figma上でコンポーネント化し、コンポーネント単位での管理・デザインレビュー デザイナーが「このコンポーネントは既にレビューしたから今回はレビューしない」という判断ができるようになること デザイン作成・デザイン共有がFigmaで完結すること 仕様書の作成コスト削減 Confluenceで仕様書を作成する際に、デザインデータをプレビューで埋め込み可能 技術面でやりたいこと デザイン面だけでなく、実装面への反映と運用方法を検討しています。 Tailwind CSS WEARのWebサイトではTailwind CSSを利用しています。Tailwind CSSは tailwind.config.js にtheme 2 を記述し、決められたクラス名のみを利用して実装していきます。デザイントークンを決めるということは制限を持たせることでもあり、これはTailwind CSSとの親和性が高いと考えています。デザイントークンがまとまったらTailwind CSSのthemeを設定し、徐々にサイトへ反映していきたいです。 Storybook WEARのWebサイトの開発ではStorybookを利用しています。Storybookを用いることで、コンポーネント単位での表示の確認が可能です。今後デザインシステムのドキュメントを作成する場合、実際に動作するコンポーネントをドキュメントに載せることができ、新しく入社した社員のオンボーディングにも役立つと考えられます。 上記のようなTailwind CSSとStorybookの運用方法を改善をすることで、 リプレイスにおける課題 の節で書いたような「スピード感を持った開発」が実現できると考えています。 終わりに WEARは長い歴史を持つアプリケーションであり、デザイン関連の整備には時間がかかると想定されます。そこで、デザインシステムを導入する際には、開発の停滞を避けるため、取り組みやすいところから徐々に進めていこうと考えています。私たちは、デザインシステムを作成すること自体が目的ではなく、エンジニアとデザイナーが共通のルールに基づいて協力し、開発をスムーズに進めることを目指しています。なので、デザインシステムに関しては、 “共通認識のルールを作っていたら、いつの間にか副産物的にデザインシステムができた” という心持ちでやっていきたいです。 ZOZOではデザインシステムに興味のある方を募集しています。ぜひ、下記のリンクからご応募ください。 hrmos.co hrmos.co WEARにおいてデザインレビューとはステージング環境で実際に動いているものをデザイナーの観点でレビューしてもらう確認作業のこと。 ↩ スタイルを変数として定義できる機能。 ↩
こんにちは、技術本部SRE部ZOZOSREチームの斉藤です。普段はZOZOTOWNのオンプレミスとクラウドの構築・運用に携わっています。またDBREとしてZOZOTOWNのデータベース全般の運用・保守も兼務しております。 ZOZOTOWNではSQL Serverを中心とした各種DBMSが稼働しています。その中で、Amazon RDS for SQL Server(以下、RDS)を使用したデータベースが存在します。これらは、トラフィックの増減が激しいZOZOTOWNのサービスにおいて、オンデマンドでスケール可能なデータベースとして運用されています。 本記事では、クライアントであるEC2(以下、Webサーバー)とRDSの間にデータベースプロキシをnginx TCP Load Balancerで構築し、ロードバランシングを実現した事例を紹介します。参照系データベースのアクセスに関してロードバランシングの一例としてご参考になればと思います。また、詳細は後述していますが、Amazon RDS Proxyにはバランシング機能がありません。「無いものは自前で作る」というZOZOの文化にも触れていただけたら幸いです。 目次 目次 データベースプロキシを構築した背景と課題 ソリューション選定 Amazon RDS Proxy Elastic Load Balancing Network Load Balancer (NLB) nginx TCP Load Balancer nginx TCP Load Balancerを構築する Dockerコンテナでnginxインスタンスを作成 nginx.confを編集してロードバランサーとして設定 ヘルスチェックを設定 UNIXドメインソケットを設定 バランシングアルゴリズムの設定 keepalive(idle interval count)を設定 nginx TCP Load Balancerを動作検証する ロードバランサーの設定 実際に動作検証する リクエストを開始する 片方のRDSにテーブルロックをかける 両方のRDSにテーブルロックをかける リクエストを停止させる 本番環境へ実装する niginx TCP Load Balancerをコンテナで構築する Proxy設定をGitHubで管理する 本番環境へ実装後の負荷状況 まとめ おわりに データベースプロキシを構築した背景と課題 RDSは、Webサーバーからのリクエストを直接受けており、セール時などの高トラフィックが予想される日は、RDSとWebサーバーをスケールアウトしてシステムを増強させています。RDS 1インスタンスあたりのWebサーバー接続数を計算し、各Webサーバーに対して、接続先を変更する必要がありました。また、RDSの障害で接続ができなくなってしまった場合、問題の起きたインスタンスからの切り離しを手動で行う必要がありました。運用を効率化するためには、ヘルスチェックとロードバランシングの機能を持ったサーバーが必要と判断し、導入することにしました。先述した以外にもロードバランシング機能を持つサーバーの導入は、いくつかのメリットがあると考えました。 ロードバランシング機能を持つサーバーのメリット 運用の効率化 Webサーバーの接続先変更や切り離しにかかる運用コストが削減できる。 可用性の向上 RDS障害時の接続先変更を自動化することで、エラーを最小限に抑えて稼働させ続けられる。 コストの最適化 RDS障害時の接続先変更を自動化することで、RDSのフェイルオーバーは不要となり、マルチAZが廃止でき、RDSコストを1/2にできる。 マルチAZが廃止できる理由について補足させていただきます。AZ障害が発生した場合に備え、マルチAZ無効状態で各AZにRDSインスタンスを配置した状態にしておきます。障害発生時は、正常なAZ側のRDSインスタンスに自動で接続が切り替われば、サービス継続が可能となります。従来の手動切り替えによる対応が不要にできれば、障害時のサービス継続性が向上し、マルチAZも不要となるので通常時のコスト削減に繋がります。 ソリューション選定 Amazon RDS Proxy Amazon RDS Proxyにロードバランシングの機能が無いか調査しました。接続プーリングなどの魅力的な機能はあったもののロードバランシング機能はありませんでした。Amazon RDS Proxyのより詳細な情報は下記に記載されていますので興味のある方は Amazon RDS Proxy を参照してください。 Elastic Load Balancing Network Load Balancer (NLB) (2023/06/20追記)AWSのElastic Load Balancing Network Load Balancer(以下、NLB)も候補の1つとして調査しました。NLBはターゲットの指定にFQDNが使用できず、動的にIPアドレスが変更されてしまうRDSには対応できませんでした。RDSの動的なIPアドレス変更の詳細情報は下記に記載されていますので興味のある方は Amazon RDS DB インスタンスに割り当てられた IP アドレスについて を参照してください。 (追記ここまで) nginx TCP Load Balancer nginxのロードバランシング機能を調査してみるとHTTP、TCP、UDPでロードバランシングが実現でき、パッシブヘルスチェックとアクティブヘルスチェックを使用できました。 (2023/06/20追記)また、詳細は「 UNIXドメインソケットを設定 」に記載していますが、FQDNを指定した名前解決が可能でRDSの動的なIPアドレス変更に対応できることがわかりました。(追記ここまで) nginx TCP Load Balancerのより詳細な情報は下記に記載されていますので興味のある方は TCP and UDP Load Balancing を参照してください。 今回はnginx TCP Load Balancerを使用してデータベースプロキシを構築することにしました。 nginx TCP Load Balancerを構築する 以下の手順でnginx TCP Load Balancerを構築していきます。 Dockerコンテナでnginxインスタンスを作成 nginx.confを編集してロードバランサーとして設定 ヘルスチェックを設定 UNIXドメインソケットを設定 バランシングアルゴリズムの設定 keepalive(idle interval count)を設定 Dockerコンテナでnginxインスタンスを作成 Docker Hubで公開されている nginx Open Sourceイメージ を使用して、Dockerコンテナでnginxインスタンスを作成しました。 nginx.confを編集してロードバランサーとして設定 ロードバランサーを構成するにはstreamコンテキスト内にupstreamグループを作成します。 stream { resolver xxx.xxx.xxx.xxx valid=5s; error_log /dev/stderr info; upstream rds-tcp { least_conn; server unix:/var/run/rds_1a_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; server unix:/var/run/rds_1c_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; } server { listen 1433 so_keepalive=10m:1m: 10 reuseport; proxy_socket_keepalive on ; proxy_connect_timeout 15s; proxy_pass rds-tcp; } ヘルスチェックを設定 Webサーバーからのリクエストに対して、サーバーのレスポンスを監視するパッシブヘルスチェックを設定します。ヘルスチェックの設定はupstreamグループ内に記述したサーバーのパラメータで定義します。定義したパラメータの「fail_timeout」と「max_fails」がヘルスチェックの設定部分です。 upstream rds-tcp { server unix:/var/run/rds_1a_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; パラメータについては次の通りです。 fail_timeout サーバーとの通信試行がmax_failsで指定された回数失敗すると、fail_timeoutに設定された期間サーバーが利用できないと見なします。 max_fails サーバーとの通信試行の失敗回数を設定します。指定した回数リクエストに失敗するとfail_timeoutで設定された期間、サーバーが利用できないと見なします。 max_conns プロキシサーバーへの同時接続の最大数を制限します。デフォルト値の0は、無制限を意味します。 weight サーバーの重みを設定します。 UNIXドメインソケットを設定 無料版のnginxは起動時にしか名前解決がされません。RDS側の動的な変更に対応するため、UNIXドメインソケットを使用し、名前解決することにしました。UNIXドメインソケットの設定もstreamコンテキストに記述します。 (2023/06/20修正)nginxの名前解決は無料版と有料版で解決方法に違いがあります。UNIXドメインソケットを使用することで、無料版でも有料版と同等の動的な名前解決をできるようにしました。無料版のnginxは起動時にしか名前解決をしないことが課題でしたが、RDS側の動的なIPアドレス変更に対応できました。 (2023/06/20修正)streamコンテキストにresolverの設定を記述します。 stream { resolver xxx.xxx.xxx.xxx valid=5s; (2023/06/20修正)serverコンテキストにproxy先をset変数で定義します。 server { listen unix:/var/run/rds_1a_001.sock; set $rds_1a_001 " rds-1a-001.sample.ap-northeast-1.rds.amazonaws.com " ; proxy_pass $rds_1a_001: 1433 ; } バランシングアルゴリズムの設定 nginx TCP Load Balancerには3種類のバランシングアルゴリズムが用意されています。 Round Robin 振り分け先のサーバーへのリクエストを均等に振り分ける方式(デフォルト) Least Connections アクティブな接続数が最も少ないサーバーに振り分けられるような方式 hash 同じIPアドレスからのリクエストは、同じ振り分け先サーバーへ振り分ける方式 今回はLeast Connectionsを採用することにしました。バランシングアルゴリズムの設定もstreamコンテキストに記述します。 least_conn; server unix:/var/run/rds_1a_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; server unix:/var/run/rds_1c_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; keepalive(idle interval count)を設定 アクティブ接続を最大で10分間継続させ、1分間隔で10回アイドル状態になっていないかのチェックを行うようにします。keepaliveのパラメータはlistenソケットに設定します。 server { listen 1433 so_keepalive=10m:1m: 10 reuseport; proxy_socket_keepalive on ; proxy_connect_timeout 15s; proxy_pass rds-tcp; } nginx TCP Load Balancerを動作検証する RDSを2インスタンス用意し、Webサーバーから接続をバランシングしてみます。nginx TCP Load Balancerへ複数のWebサーバーからリクエストを投げ、RDS側で参照されるテーブルをロックします。リクエストタイムアウトが起きた場合、nginx TCP Load Balancerがどういった動作をするか確認します。 ロードバランサーの設定 バランシングアルゴリズム Least Connectionsを採用します。 ヘルスチェック 10秒間にリクエストが100failしたらRDSのダウンとみなすように設定します。 障害時、手動での接続切り替えに数十分ほど要していましたが、自動で検知と切り替えができれば数秒ほどで完了し、大幅に時間短縮できます。 keepalive(idle interval count) アクティブ接続を最大で10分間継続させ、1分間隔で10回アイドル状態になっていないかのチェックを行います。 1分間隔のアイドルチェックにより、該当コネクションを利用するリクエストがゼロになっても1分間はそのコネクションを維持させます。これにより、コネクションの作成/破棄が頻繁に行われることによる負荷を削減します。 upstream rds-tcp { least_conn; server unix:/var/run/rds_1a_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; server unix:/var/run/rds_1c_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; } server { listen 1433 so_keepalive=10m:1m: 10 reuseport; proxy_socket_keepalive on ; proxy_connect_timeout 15s; proxy_pass rds-tcp; } 実際に動作検証する いくつかのパターンで動作検証します。 通常の状態でリクエストを投げ、ロードバランシングが動作するか確認する 片方のRDSにテーブルロックをかけ、タイムアウトを発生させ、ヘルスチェックが動作するか確認する 両方のRDSにテーブルロックをかけ、タイムアウトを発生させ、動作確認する リクエストを停止させ、keepaliveが動作するか確認する リクエストを開始する 通常の状態でリクエストを投げ、ロードバランシングが動作するか確認しました。各Webサーバーのセッションが均等にバランシングされました。 片方のRDSにテーブルロックをかける 片方のRDSにテーブルロックをかけて、タイムアウトを発生させ、ヘルスチェックが動作するか確認します。一瞬パラっとエラーが出た後は、一定間隔でパラパラと30秒タイムアウトが発生しました。これは、nginxのパッシブヘルスチェックによって、RDSのステータスを定期的に確認するためです。アクティブヘルスチェックと違い、実際のリクエストを使ってヘルスチェックするため、想定通りの挙動です。 両方のRDSにテーブルロックをかける 全リクエストがタイムアウトしましたが、ロック解除するとエラーなくリクエストが流れました。 リクエストを停止させる Webサーバーからのリクエストを停止させてみます。停止直後はRDSへのコネクションが維持された状態になりました。 1分後にRDSへのコネクションは破棄されました。想定通りにkeepaliveが動作してくれました。 本番環境へ実装する 動作検証が成功したので、本番環境への実装を考えます。RDSとWebサーバーの柔軟なスケールアウトに対応する必要があるのと実装後の運用について考慮しました。 niginx TCP Load Balancerをコンテナで構築する Amazon EKSのkubernetesクラスターを用いて構築することで、Proxyの柔軟なスケールアウトを可能にしました。予期しない高負荷に備え、Horizontal Pod Autoscalerでのオートスケールを実現しました。 アーキテクチャ図 Proxy設定をGitHubで管理する 設定変更の運用は、ArgoCDを用いてGitOps化し、リソース変更や運用をGitHubの操作でできるようにしました。 本番環境へ実装後の負荷状況 ZOZOTOWNの年間を通して、トラフィック量がトップクラスに多い期間が年末年始に開催される冬セールです。2022-2023期の冬セールで増強した1300台以上のWebサーバーからのリクエストを6台のProxyで捌くことができました。セール開始時にCPU使用率が上昇しましたが、許容範囲内に収まり、全体を通して安定的に稼働してくれました。 冬セール時のCPU使用率 まとめ 本記事では、WebサーバーとRDSの間にデータベースプロキシをnginx TCP Load Balancerで構築した際の事例を紹介させていただきました。課題として挙げていた、WebサーバーのRDS接続設定に関する運用効率の向上やRDSのコスト最適化に繋がったと感じています。 アクティブなupstream serverが全ダウンした際に、設定しておいたSQL Serverにリクエストを流すことができるbackupオプションというものがあります。引き続き、nginx TCP Load Balancerをパワーアップさせることができるオプションを検証していきたいです。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
こんにちは、計測プラットフォーム開発本部システム部SREブロックの市橋です。2021年4月に新たに発足したチームで未経験ながらリーダーを任され、気づけば約2年が経過していました。これまでを振り返ってみると、まっさらな状態から安定したチームができてきたと感じています。今回は新米リーダーとして試行錯誤する中で、チーム状態を可視化して健全なチーム運営を目指した話を紹介します。 チーム状態の可視化を考えたきっかけ リーダーを任された当初、チーム運営上の課題が色々あるのは認識していましたが、どこから手をつけるべきかが自分の中で判然としませんでした。メンバーの時に一個人として感じていた課題も、チーム全体を俯瞰して見た時にどれから優先的に取り組むべきか自信を持って判断できませんでした。まるで大海原のど真ん中にいきなり放り出された感覚でした。 そんな悩みを抱えていた時、全社に導入されている Wevox のアンケート調査依頼が来ました。これはチームや組織の状態やメンバーのエンゲージメントを可視化するツールです。メンバーの時は流れ作業のようにこなしていましたが、このときは一筋の光明に見えました。このツールによってチームの状態を把握できることはもちろんですが、それ以上にチーム状態を客観的な数値として知ることの重要性に気づけたことが何よりの収穫でした。 考えてみれば、これまで経験してきたSRE業務でも何かしらのメトリクスをもとにシステムやプロセスの状態を把握して、改善の手を打ちます。チームを運営するにあたり、チーム状態を知る指標がなければ、打つべき手は見えてきません。それに気づいてからはチーム状態を客観的に示す指標集めと分析に注力しました。 チーム課題の把握 まず取り組むべきと考えたことは、チームの開発生産性を可視化することです。ここでの開発生産性は特定の期間内に遂行できる作業量の意味で使います。これを優先すべきと判断したのは、先のWevoxの調査でチームのストレス値が高いという課題を把握でき、その解決のために必要だと考えたためです。尚、ストレス値が高いことを課題として認識できたのは、他の項目が概ね全社平均を上回っていたのに対してこの項目だけが顕著に低かったためです。 一口にストレス値が高いと言ってもストレス要因は人それぞれ異なります。そのため、メンバーと1on1を実施して一人ひとりの考えを聞いて深堀りしていきました。その中で次の意見が主となっていることがわかりました。 特定のメンバーに業務が集中して負荷が高くなる。 無尽蔵に湧きでるチケットの対応に常に追われている感覚があり、終わった感がない。 当時はチームが少人数という事情とSREの差し込みタスクが多いという業務特性が相まって、Toil 1 に対応しながら中長期的に取り組む改善タスクにも対応していました。やりたいことはたくさんあってもToilに阻まれて思うように進捗が出せないため、それが焦りとなってストレスを増長させている状況でした。また、一部の知見が特定のメンバーに属人化しており、業務負荷が特定のメンバーに集中することも度々ありました。そのような空気感を感じつつも、他のメンバーの状況が見えづらいことでフォローしづらいということが起きていました。 スクラム導入 スクラムの狙い 上記の課題に対して、チーム運営のスタイルにスクラムの要素を取り入れることで改善に近づけると考えました。スクラムに期待したことは次のとおりです。 チームのベロシティを把握して適切な業務量に調整する 他のメンバーの状況を見える化してフォローしやすくする 振り返り頻度を上げて課題を把握する 弊チームのスクラムの流れは以下の図のとおりです。各セレモニーについては一般的なスクラムと概ね同じなので、個々の内容の紹介は割愛させていただきます。 スプリント期間は一週間に設定しています。前述の通り差し込み対応が多いため、スプリントの期間を短くすることでタスク整理の頻度を上げ、計画外のタスクをハンドリングしやすくする狙いがあります。また、緊急性が高くなければ無理にスプリント内で対応せず、次のスプリントに回す判断もしやすくなります。 ここからはスクラムの導入効果について見ていきます。 導入効果 チームのベロシティを把握して適切な業務量に調整する メンバーへのヒアリングから課題の一端は業務量にあることがわかったので、考えられる対策は無理なく対応できる適切な業務量に調整することです。そのためには、当然のことながら「無理なく対応できる適切な業務量」を把握できていることが前提となります。これを把握するために弊チームでは作成したチケット全てに対してプランニングポーカーを行い、チーム全員で見積もることにしました。 プランニングポーカーのツールには SlackのPoker Planner を利用しています。メンバー全員が使い慣れたツール上で見積もりから議論まで完結できるので、オーバーヘッドが少なく運用できています。流れは以下のようになります。 slackから/ppコマンドを実行し、プランニングポーカーを開始する 依頼されたメンバーはチケットを見積もり、ストーリーポイント(以下、spとする)を投票する 投票結果が表示される。見積もりの値で意見が割れたらスレッド内で議論して適切なspを設定する 対象のチケットに決定されたspを転記する プランニングポーカー時の基本的なルールは次の2点です。 見積もりの値がメンバー間で異なる際は必ず相談して決める 5pt以上の場合は原則としてチケットの分割粒度を見直す メンバー間で見積もりの値が合わない原因の多くは、チケットに記載されている情報(目的や背景)の不足や完了条件が曖昧なことで、このチケットで実現すべきことをイメージできないことで引き起こされます。それに対して時間を取って相談することで、全員が共通認識を持てるように徹底しました。 このルールをチームで運用して8スプリント程度を回したところで、おおよそ無理なく捌けるspを把握できました。スプリントプランニングで各メンバーにアサインされているチケットのspの合計値が適正範囲に収まっているかをチェックすることで、働きすぎを防止しています。以下はスプリントプランニングで実際に見ている画面です。もし基準値を超えていれば、落とせるタスクはないか調整するルールとしています。 他のメンバーの状況を見える化してフォローしやすくする 上記により、特定メンバーへの業務負荷の偏りを可視化でき、負荷の高いメンバーのタスク量を調整できるようになりました。 一方、弊部はZOZOが掲げる 戦略の3本柱 の1つとして、新規事業を打ち出す役割を担っています。そのため、新サービスの開発案件がしばしば入ってきます。このような案件は不確定要素が大きくなりがちで、スプリントの計画時点では想定できなかったタスクや依頼が突発的に急増することもあります。デイリースクラムで声を挙げられる場は用意しているものの、余裕がなかったり、ついタスクを捌くことに熱中してヘルプを要請することを忘れてしまいがちです。そのような状態でもスプリントの区切りでベロシティを確認することで第三者が変化に気づけます。実際に確認しているベロシティは以下の図の通りです。 運用方法としては、ベロシティの目標値の下限と上限を定め、その範囲から外れたときに要因を分析しています。 目標値の下限を下回っている場合は、開発効率が落ちている要因を分析します。メンバーが休暇を取得したといった一過性の理由であれば問題視しませんが、チケットの粒度が大きすぎたり、アンコントローラブルな問題が起きてタスクを完了できない場合はチケットの完了条件を見直します。 目標値の上限を上回っている場合も、単に開発生産性が上がったと捉えるのではなく、要因を分析する必要があります。もし特定メンバーの負荷が上がっていれば、他のメンバーに業務負荷を平準化するよう調整します。この場合は実労働時間にも反映されるため、セットで確認しています。 上述のプランニングポーカーで全員がチケットを見積もることで他のメンバーのタスクを把握でき、デイリースクラムでも進捗を共有するため、フォローしやすい体制が維持できています。 振り返り頻度を上げて課題を把握する スクラム導入前は振り返りの施策として月に一度のKPTAを実施していましたが、以下の課題から形骸化しがちになっていました。 月の初めに感じていた課題感を忘れる 改善アクションの確認周期が1か月のため、熱量が保てない スクラム導入直後はタスク遂行に使える可処分時間を減らしたくない気持ちが強く、振り返り頻度は現状維持としていました。しかし、部内で アジャイルなチームをつくる ふりかえりガイドブック の輪読会が行われたことで機運が高まったことに後押しされ、スプリントレトロスペクティブとして毎スプリント振り返りをすることにしました。頻度を上げたことで、チームやメンバーが直面している課題を温度感が高い状態で把握でき、毎週着実に改善に向かっている感覚を得られる利点があります。また、レトロスペクティブをスプリントの最終日においたことで、そのスプリント内で継続すべきことや課題を吐き出して清算するという終わった感を演出する効果もあります。 ユニークな点として、振り返りのアジェンダにはKPTAの他にDoya、Moyaという項目を用意しています。 Doyaはスプリント内で挙げた成果やアピールしたいことを共有するために利用します。KPTAのK(Keep)と似ていますが、Keepは継続したいことを共有するもので、単発の成果などは文脈に合わないため記入しづらいという課題感から作られました。デイリースクラムでもタスクの完了報告はしますが、ここに記入することで改めて他のメンバーが対応してくれたことを振り返ることができます。弊社にはZOZOエールというピアボーナス制度があるので、これと併用して感謝の気持ちを送り合い、称賛する文化を醸成してエンゲージメントを高める効果に期待しています。 Moyaはチームとして議論するほどではないものの共有しておきたい心のモヤモヤを吐き出すことを目的としています。これがあることで課題感を共有する敷居が下がり、より個人的な悩みや課題も抽出できる効果があります。ここに書かれた内容はレトロスペクティブでは深く取り上げず、1on1の会話のネタとして扱う形で活用しています。 スクラム導入後のWevoxスコア スクラム導入後のWevoxスコアは以下の通りです。 課題として挙げていたストレス反応の数値は59から87に改善しました。このことからスクラムの導入には一定の効果があったと考えています。 まとめ 今回はチーム課題との向き合い方の一例を紹介させていただきました。スクラム導入の成功事例のように書いていますが、チーム運営にも銀の弾丸はないので常に試行錯誤が必要だと考えています。気になった方もおられると思いますが、先のWevoxでは新たに別の課題が見えています。これは達成感というカテゴリで、原因としては新規案件のローンチが落ち着いたことや課題視していたデプロイパイプラインの改修が大方完了し、一息ついたタイミングだったことに起因していると認識しています。状況が変われば別の課題が出てくるので、チームは水物だなという所感を持つとともに学びになりました。 今回のアプローチで最も重要なことは、定量的な数値として客観的にチーム状態を把握し、異常が見られたときに定性的な意見を収集して要因を分析できる状態を作っておくことだと考えています。このアプローチはある程度応用が効くものと考えているので、これから直面する課題にも引き続き真摯に向き合って改善していきたいと考えています。 さいごに ZOZOでは一緒にサービスをより良い方向に改善して頂ける方を募集中です。計測プラットフォーム開発本部としては今後も新規サービスのローンチを予定しており、新規の案件に対応しながら既存サービスのグロースにも貢献できる環境が特徴です。 ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 手作業、繰り返される、自動化が可能、戦術的、長期的な価値がない、サービスの成長に比例して増加する、といった特徴を持つ作業です。( SREの原則に沿ったトイルの洗い出しとトラッキング より引用) ↩