TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

988

こんにちは。ブランドソリューション開発本部フロントエンド部の御立田です。フロントエンド部の部長とWEAR Androidのブロック長を兼任しており、普段は部署全体の管理・リスクマネジメントや、Android開発における設計などを行っております。 本記事では、運用改善によるチームパフォーマンス向上のための取り組みについてご紹介します。なお、フロントエンド部WEAR Androidブロックで実施した内容となっており、一部アプリ開発向けの施策ですのであらかじめご了承ください。 目次 目次 はじめに 生産性に対する課題感 改善結果 サイクルタイム平均値 スタッツ 数値分析 問題点の推測 問題点の認識  対応策 レビュー環境への対応策 レビュー会の開催 PR単位でビルドの共有 巨大なPRへの対応策 サブタスクで粒度を下げる 常にアップデートすることへの対応策 開発者リソースの再配分 PRテンプレートを充実させる 意味のある単位でコミットをまとめる 自動化できるものは自動化する まとめ はじめに ある時、本部長から「生産性を3倍にしてください」と通達がありました。いきなり3倍という数値を目の当たりにすると尻込みしますが、いま思えばこれが推進力の一端になったと思います。そしてより具体的な部の目標として、「1プルリクあたりの平均マージ・クローズ時間 24時間以内」を掲げ、改善の取り組みを開始しました。 生産性に対する課題感 Pull Request(以下PR)におけるレビューにとても時間がかかっていることはあきらかで、いかにPRにおけるレビューを効率よく行えるかが課題としてありました。しかしながら、生産性に課題があると感じていたものの、具体的にボトルネックがどこにあるか数値として把握していたわけではありません。 レビューをしなくてはいけないという意識はチーム内に浸透していたものの、案件の対応に追われまとまった時間が取れず、なかなかレビューに取り掛かれない。なぜそのような状態になってしまうのか、この状態から抜け出すにはどうしたらいいのか、が手探りな状態でした。幸運なことに弊社では Findy Teams が既に導入済みでしたので、こちらを駆使して数値の分析からはじめることにしました。 改善結果 まずは取り組みを説明する前に、結果をご覧ください。 (改善前:2021/10/01〜2021/11/30 改善後:2022/07/01〜2022/08/31) サイクルタイム平均値 スタッツ いかがでしょうか、目に見えて数値が改善されています。嬉しいことに「1プルリクあたりの平均マージ・クローズ時間 24時間以内」という目標に肉薄した数値まで改善しています。 数値分析 まず、Findy Teams上で直近2か月分(改善前:2021/10/01〜2021/11/30)の数値を見ることにしました。 問題点の推測 数値から以下の問題点が推測できます。 PR作成数が少なく、作成までに時間がかかっているのは、1つのPRでの変更が多いためではないか? レビュー完了までに時間がかかっているのは、変更が大きくレビュアーに負担が掛かっているからではないか? マージするまでに時間がかかっているのは、変更が大きいが故に指摘が多くなるためではないか? 次にチーム内でレビューに対しての問題点を話し合いました。 レビューに時間がかかるため、まとまった時間を取るのが難しい レビューするブランチを手元でビルドすると時間がかかる 複雑な仕様の場合、仕様を理解するまでに時間がかかる その結果、上記のような「レビュアーに優しくないPR」が作成されているのが問題ではないか、という意見があがりました。 問題点の認識 大切なのは「レビュアーに優しいPR」を作成すること、という目的をチームで共有し、現在は何がレビュアーにとって優しくないのかをまとめました。 レビュー環境が整っていないこと PRが巨大であること 日々発見される課題を認識し、常にアップデートする必要があること  対応策 次に問題点に沿って、行った取り組みをご紹介します。 レビュー環境への対応策 レビューする際に、開発作業とのスイッチングコストがかかっている状態でした。こちらをなるべくシームレスに行い、かつレビューすることへの心理的ハードルを下げることを目的としました。 レビュー会の開催 レビュー会と称して、毎日1時間チーム全員で集まる会を開くことにしました。Discordの音声チャンネルに集まり、その場で全員がレビューします。優先してほしいレビューの共有や、口頭での質問もそこで行います。 狙いとして、スケジュールに組み込むことでレビュー時間の確保。チームでレビューする強制力。また、その場で口頭相談できるコミュニケーションの向上があります。 チームからレビューのやりやすさが向上したとフィードバックがありましたし、数値としてもレビュー件数の向上が見られました。 PR単位でビルドの共有 WEAR は2013年にリリースされたアプリで、巨大なプロジェクトになっていることもあり、ビルド時間がかなりかかってしまっている状態でした。その中で、PRごとに各レビュアーが手元でビルドして確認する必要があり、すぐにレビューに取りかかれない問題があります。この問題を解消するため、弊社では他チームにアプリを共有する際に、DeployGateを利用していたのでそちらをうまく活用し解決しました。 PR作成後、GitHub ActionsでDeployGateへビルドをアップロードし、そのURLをPRのコメントへ記載することで環境別のビルドをすぐインストールできる状態にしました。また、PRがマージ、クローズされた際にそのリンクを無効とする処理も行っています。 この結果、毎回レビュー時にかかっていたビルド時間を0にすることが出来たため、各々のPCに負荷を掛けることなくレビューする環境が整いました。 巨大なPRへの対応策 巨大なPRの場合、それだけでレビューにかかるコストが大幅に増加し、必然的にまとまった時間が必要となります。粒度を下げることで、レビュアーを拘束する時間を必要最低限にすることを目的としました。 サブタスクで粒度を下げる 弊社では案件管理にJiraを採用しており、普段からなるべく小さい範囲で課題チケットを作成するように心掛けていましたが、ブランチ運用上難しいパターンがありました。 ブランチ運用は、main、developの2つが基本となります。基本的に2週間に1回の定期リリースをしており、開発フェーズではdevelopに各々対応したチケットをマージします。その後、テストフェーズでQAチームに共有してテスト、リリースの流れとなっています。 細分化するチケットは、あくまでもそれ単体でdevelopにマージして問題ない単位としているため、必然的に大きくなる傾向がありました。 例えば、定期リリース2週間の期間内に終わらない案件(もしくはリリーススケジュールが先のもの)が進行していた際に、以前までは作業ブランチにすべて完了するまでコミットする。そしてその作業ブランチをdevelopに向けてPR作成というフローでした。 この時の問題として対応内容が大きければ大きいほど差分が膨らみ、仕様の理解が遅くなり、結果レビュアーの負担が大きくなりレビューの精度が低下するというものでした。 こちらを解消するために、Jiraの サブタスク 機能を活用します。 1つのチケットをサブタスクとして実装者が細分化することで、より実戦的な粒度の小さいものに出来ます。実装者はそのタスクを完了させたら親のチケットに対してPRを作成します。最終は、親のチケットをdevelopに向けてPR作成します。その結果、実装者は普段の開発と同様一区切りついたタイミングでレビューをしてもらえ、フィードバックも早くなります。 また、細分化された作業が明らかになることでエンジニアの進捗管理にも役立ちました。 常にアップデートすることへの対応策 レビューでは非同期的なコミュニケーションが多くなりますが、仕組み化するための議論の場として、我々は同期的なコミュニケーションが大切だと考えました。週1回の実装相談会の実施、月1回のKPTの実施を定め、日々発見される課題をチーム全体の課題として認識しアップデートしていくことを目的としています。 細々とした課題が多くあったので、その中でも効果的だったものをいくつかご紹介します。 開発者リソースの再配分 Findy Teamsで数値を分析すると、ある時期あるメンバーのパフォーマンスの高低が見えてきました。こうして数値として見えることで、本人も気付かない部分を考察できます。なぜパフォーマンスが良かったのか、悪かったのかをメンバーと話し合うことで、数値と実感をすり合わせる作業をしました。 メンバーの得意な案件を担当にしたり、エンジニアリソースの再配置をすることで、開発メンバー数としては減ったものの数値は改善されてコストパフォーマンスの高い結果となりました。 また、 Findy Teams にはチームのフォローアップアラートをSlackで受け取る機能があり、その数値を元に仕事量の偏りがないよう調整しています。 PRテンプレートを充実させる 主に人によって違いが出ることを防ぎ、また書くものを迷わないようにするを目的としています。 一部抜粋し、以下に羅列します。 JIRAのチケットリンクを必須で記載 ブランチ名にもWEAR-****をprefixとして作成するルールにし、PRで作成したものに使用するブランチが一致するかをCIで自動的に確認 動画・画像キャプチャを必須で記載 ひと目で対応箇所の認識、動作の確認が容易になり認識のズレを減らす テスト内容をレビュアーが再現できるもので表現する 「正しいこと」「問題ないこと」などの曖昧な表現をやめる レビュアーがテストに記載されたものをなぞれば再現できる表現にする 本質的なコードのレビュー以外の確認項目を減らし、コードレビューを集中して行うことができるようになりました。 意味のある単位でコミットをまとめる コミットの単位を一時的なコミットではなく、そのコミット自体で1つの完結した意味あるものとするようにしました。 例えば、画面にボタンを追加するというPRがあるとして下記のようにコミットを積んでいきます。 ボタンのUI作成 クリックリスナーの設定 クリックされたときの処理を実行 ボタンの文言を定義 コードフォーマットをかける 上記のように、コミットを分けることでコミット単位でのレビューが可能となり、よりレビュアーの理解する速度向上に役立ちます。 (状況によりコミット単位は変動します。難しい場合、この限りではなく、臨機応変に対応としています) 自動化できるものは自動化する Slack上にPRがOPENされたら通知する PRが作成されたら自動でレビュアーをアサインする ブランチが並行稼動するため、マージ先のブランチを間違えないように PR Milestone Check を導入し、対応PRを管理する 人が管理する必要のないものを自動化して時間を削減したり、ヒューマンエラー対策を行いました。 まとめ 本記事ではレビューを通じてチームのパフォーマンス向上を目指した取り組みについてご紹介しました。 1つ1つはそこまで大変な対応策ではありませんが、確実に実施していくことで数値の改善を達成できました。部として目標を定め、それに対応する施策をチームの共通目標にし、目指すべきところへ全員が参加して貢献することが大切です。コードレビューを通じてコミュニケーションも活発になり、チーム一丸となって進んでいると実感しています。 我々の取り組みが少しでも参考となれば幸いです。 最後までご覧いただきありがとうございました。WEARでは一緒にサービスを作り上げてくれるAndroidエンジニアを募集中です。 hrmos.co その他、各種エンジニアも募集しております。ご興味のある方はぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ZOZOTOWN開発本部の松井とZOZO NEXTの 木下 です。9/10から9/12までの3日間、iOSDC Japan 2022が開催されました。ZOZOグループからは6名が登壇、20名以上が参加しました。またプラチナスポンサーとして協賛しました。 technote.zozo.com 今年のiOSDCは時代に即した形で、現地会場とオンライン配信によるハイブリッド開催でした。今回は、その両方を盛り上げるために行ったZOZOの取り組みをご紹介いたします。 はじめに 現地ブース 準備 展示内容 当日の様子 オンラインブース 準備 当日の様子 登壇内容の紹介 「PWAの今とこれから、iOSでの対応状況」 「20分間で振り返るIn-App Purchaseの歴史」 「あなたの知らないARの可能性を空間レベルで拡げるVPSの世界」 「Swift Concurrency時代のリアクティブプログラミングの基礎理解」 「全力疾走中でも使えるストップウォッチアプリを作る」 「目からビームでヴィランをやっつける 〜ARKitの知られざる並走機能〜」 CfPネタ出し会 & レビュー会 現地ブース 準備 5月上旬にスポンサー募集が開始され、社内でブース出展の是非を話し合いました。3年ぶりの現地会場であり、多くの来場者と活発にコミュニケーションをとれることを期待して、ブース出展を決定しました。スポンサーのノベルティや掲載物については、iOSエンジニアと広報・CTOブロックが連携して進めました。 展示内容 ブースについてはどういった展示内容にするかも含めて、エンジニアが主体的に進めていくこととなりました。最初にMiroでマインドマップを作成し、ブレインストーミングを行いました。久々のブース出展ということで、前回出展した 2019年の様子 を振り返りながらアイデアを深めました。 その後展示内容ごとに担当者を決め、2週間程度で準備をしました。実際に展示したのは以下です。 アンケートやプロダクト紹介が載ったMiroのボード(次の章で詳しく説明します) ZOZO独自の計測テクノロジー「ZOZOSUIT」や「ZOZOMAT」・「ZOZOGLASS」、ZOZOSUITの技術を活用した新事業「ZOZOFIT」 弊社のカルチャーや開発環境の紹介 当日の様子 そして当日できあがったブースがこちらです! ブースに来てくださった方へのノベルティや、ZOZOSUITの技術を活用した新事業、ボディマネジメントサービス「ZOZOFIT」のパッケージと専用アプリ画面の展示です。 トルソーに着せたZOZOSUITの存在感があり、会場から多くの視線を集めていました。 足の3D計測マット「ZOZOMAT」やフェイスカラー計測ツール「ZOZOGLASS」など、現在ZOZOTOWN内でご利用いただける計測テクノロジーもご紹介しました。 ブースではまずアンケートに答えていただき、そこからリモートワークに関する話に花が咲きました。また、ZOZOSUITについて興味を持ってくださった方には、旧ZOZOSUITからの改良点や今後の展望についてお話ししました。 会場全体はこのような雰囲気で、個性を生かしたブースが並んでいました。全てのブースを拝見しましたが、技術的なお話をうかがえたりデモアプリを実際に触れられたりなど、どのブースもとても魅力的でした。 ブース出展を通して、対面だからこそできる気軽なコミュニケーションのありがたみを改めて感じました。様々な方と、技術や会社についてお話ししたり、「Twitterでよく見ております!」という会話が生まれたりしました。ブースへ遊びに来てくださった方々、ありがとうございました。 オンラインブース 今年のiOSDCはオフラインとオンラインのハイブリッド開催でしたね。現地に来られない方もZOZOのブースを楽しめるようにと、今年はZOZO独自にオンラインブースも用意していました! iOSDCチャレンジで使われるトークンは、実はオンラインブースにも隠されていたんです。チャレンジした方、見つけられましたか? 準備 ZOZOでは、テックカンファレンスでのオンラインブースの用意は初めてでしたが、Miroを使って全体のデザインからコンテンツの細部までエンジニアがメインで作り上げました。 このオンラインブースはコースを進むようにして順番にコンテンツを見ていけるようなデザインにしています。また、リンクを開いた時にまずはなにをしたら良いのか、箱猫マックス(ZOZOの公式キャラクター)が教えてくれるようファーストビューを設定しました。このようにして、参加してくださったお客様が迷わない工夫をしています。 そして、オンラインブースでもできる限り現地ブースと同じような体験を作りたい! という思いがありました。そこで、オンライン上でもZOZOのiOSエンジニアに気軽に話を聞きにいけるスペースを用意しました。 当日の様子 オンラインブースのURLはTwitterやZOZO DEVELOPERS BLOG、現地ブースでの2次元コードで発信していました。オンラインブースには多くの人に来場いただき、アンケートは大盛り上がり! たくさんのご回答、ありがとうございました! また、Google Meetにもトークの合間を縫って話に来ていただきました。 ZOZOには「全国在宅勤務制度」があり、日本国内であればどこでも就業可能なため、現地への参加のしやすさは人それぞれです。オンライン参加でもトークを聞いたりiOSDCチャレンジに挑戦したりするだけでなく、Google Meetにて待機することで、イベントの臨場感を味わえました。また、オンライン参加のお客様に対しても現地ブースを擬似体験しているような機会を提供でき、双方にとってよい取り組みとなったように感じます。来年も引き続きオンラインでの開催が行われるようなので、さらに盛り上げていきたいですね! 登壇内容の紹介 今年ZOZOからは6名が登壇しました。昨年に引き続き、会社としての一体感を出すため、登壇者一覧のスライドを社内のデザイナーの方にお願いして作成し、発表で利用しました。 それぞれの登壇内容についてご紹介します。 「PWAの今とこれから、iOSでの対応状況」 木下 のレギュラートークです。 画像提供:iOSDC Japan 2022実行委員会。 ウェブアプリに、ネイティブアプリに近い機能を付加した、Progressive Web Apps (PWA)について発表しました。PWAの概要や歴史から始まり、iOSにおける対応状況やPWAを特徴づける機能、さらにPWAを採用する判断の助けとなるフローチャートなどをまとめました。 ハイブリッド開催ということで直接もしくはTwitter上などで、「PWAって結構色々できそう」という声や、「WebViewだけのアプリを作るならPWA良さそう」など色々と反応いただきました。とても嬉しかったので、また登壇できるように頑張ります。 fortee.jp 「20分間で振り返るIn-App Purchaseの歴史」 @inokinn のレギュラートークです。 13年以上の長きに渡る、In-App Purchaseの機能の歴史について発表させていただきました。現地会場やTwitterでは、感想や質問をいくつかいただくことができて非常に嬉しかったです。来年も現地参加するつもりなので、またお会いしましょう! fortee.jp 「あなたの知らないARの可能性を空間レベルで拡げるVPSの世界」 HEAVEN chan / ikkou のレギュラートークです。 Hi, I’m HEAVEN chan! 去年、一昨年はWebARについて喋りましたが、今年はVPSについて喋りました! 今はまだメインストリームの技術ではないものの、今回の発表をきっかけにして少しでも興味を持つ方が増えたら嬉しいです! ついにiOS 16もリリースされたので張り切ってやっていきましょう! fortee.jp 「Swift Concurrency時代のリアクティブプログラミングの基礎理解」 ばんじゅん のレギュラートークです。 画像提供:iOSDC Japan 2022実行委員会。 基礎シリーズということで、すぐに使えるとか役立つというものではなかったのですが、みなさん聴いてくれてありがとうございました。Swift Concurrencyとリアクティブプログラミングの例に限らず、たまには基礎に戻って理解を固めておくモチベーションに繋がれば良いなと思っています。並行計算というのはそもそも難しいものなので、今後も着実にやっていきましょう。 fortee.jp 「全力疾走中でも使えるストップウォッチアプリを作る」 Ogijun のレギュラートークです。 画像提供:iOSDC Japan 2022実行委員会。 みなさんが日常で使っているストップウォッチアプリの使いやすさを陸上競技の選手という特殊な目線で評価し、どんなインタラクションが最適であるかを検証しました。実際に自分が走りながら使った際の動画を見せることで、現状の不便さや作成したアプリの強みを皆様に伝えられたかと思います。他のセッションに比べて異質なテーマでしたが、実際にアプリをDLしてくださったり、フィードバックをいただけてとても嬉しく思います。また来年も登壇できるようこれからも頑張ります! fortee.jp 「目からビームでヴィランをやっつける 〜ARKitの知られざる並走機能〜」 ながいん のレギュラートークです。 画像提供:iOSDC Japan 2022実行委員会。 ARKitの一機能である、ワールドトラッキングとフェイストラッキングの並走機能にフォーカスしたトークで発表しました! 初登壇だったのですが、現地・オンラインから「おもしろい!」というリアクションをいただいて、今後のモチベーションに繋がりました。来年はトークだけでなく原稿にも挑戦したいです! fortee.jp CfPネタ出し会 & レビュー会 弊社では毎年、自由参加でiOSDCのCfPネタ出し会 & レビュー会をiOSエンジニア同士で行っています。今年も例年通り開催しました。詳細については昨年のブログで紹介しています。 techblog.zozo.com 今年は昨年と比べてプロポーザルの募集期間が短かったため、次のようなスケジュールで進めました。 5/12にCfPの募集が開始され、5/25に2時間程度のネタ出し&レビュー会を開催しました。会ではドキュメントに予めネタを書き出しておき、相互にレビューしました。またレビューを進める中で新たにネタを思いついた場合には書き足していくというのを行い、最終的に約25件のネタが集まりました。 6/1に技術顧問である岸川さんより、CfPを読む人にトークの期待値が伝わりやすくなっているかなど様々な観点から最終レビューをいただきました。 結果的に6件のトークが採択されました。ネタ出し&レビュー会はここ数年で定着すると共に、書き方に関するノウハウもたまってきています。来年以降も引き続き実施していこうと考えております。 また弊社ではカンファレンスへの参加は業務として扱われるため、iOSDCには休日出勤という形で参加しました。今年も内定者アルバイトの方が複数名イベントへ参加しましたが、社員と同様にチケット代は経費となり、イベントへの参加は業務時間として扱われています。 ZOZOでは、来年のiOSDC Japanへ一緒に参加してくださるエンジニアを募集しています。ご興味のある方はこちらからご応募ください。 hrmos.co hrmos.co
はじめに こんにちは。検索基盤部の倉澤です。 私たちは、ZOZOTOWNの検索機能の改善に取り組んでいます。ZOZOTOWNには、ユーザーが検索クエリを入力した際に、候補となるキーワードを表示するサジェスト機能があります。 今回はこのサジェスト機能の改善を効率的に評価する社内ツールを以下3点に焦点をあてて紹介します。 社内ツールの各機能 実務にて利用している場面 開発する際に採用したバックエンド技術 目次 はじめに 目次 背景 サジェスト評価ツールの機能 サジェスト候補の表示 評価 評価結果の集計表示 類似度算出 利用ケース バックエンドの技術 技術スタック アーキテクチャ まとめ 背景 ZOZOTOWNでは、サジェストの検索エンジンとしてElasticsearchを採用しています。 Elasticsearchからサジェスト機能が デフォルト で提供されていますが、日本語との相性を考慮し通常の検索クエリを使用して実装しています。 日本語との相性が悪いためサジェスト機能の実装が難しい理由は、Elasticsearch公式ブログの記事で詳しく言及されています。 www.elastic.co 私たちは、検索クエリに対するサジェスト候補の表示順序のロジックや表記揺れの改善に取り組んでいます。 サジェスト機能の改善を繰り返していくうちに、以下のような要望が出てきました。 新たなロジックで作成したサジェストのElasticsearchのインデックスと、既存のインデックスの検索結果にどのような変化があるのかを素早く確認したい A/Bテストを実施する前にオフラインの定性評価を実施したい 本ツールはこれらの要望に答えるため作成したものです。 サジェスト評価ツールの機能 上記の背景を踏まえて作成したツールの機能を紹介します。 サジェスト候補の表示 入力として、比較したい2つのElasticsearchのインデックス名、検索クエリを受け取ります。 検索ボタンを押下すると、検索クエリに対応するサジェスト候補がスコアの高い順に10件表示されます。 Elasticsearchでのクエリとドキュメントのマッチングに用いるスコアは、 デフォルトのBM25 をベースとしたものではなく独自に定義したスコアを用いています。 具体的には、過去のサジェスト候補に対するクリック率やクリック後の商品購入率などを元に計算した重みを使用しています。 また、出力されたサジェスト候補の違いをわかりやすくするため各順位毎に同じキーワードであればグレーアウトしています。 評価 入力した検索クエリに対して、どちらのインデックスが適切な結果を返しているのかを評価する機能を実装しています。また、適切と判断した理由も記録できます。 複数人が同じ2つのインデックスを確認するケースがあります。他の人の評価を元にどちらのインデックスが適切かを総合的に判断したい場合があるため、こちらの評価機能を提供しています。 インデックスを評価するにあたりどのサジェスト候補が適切ではなかったのかを記録するため、各キーワードの横にチェックボックスを用意しています。 評価結果の集計表示 複数人が同一のインデックスを評価するケースがあるので、評価した結果を集計しリアルタイムで表示する機能を提供しています。どのようなクエリを実行しどちらのインデックスが適切であったのかを表示しています。 各インデックス名の列には評価者が検索したクエリ単位に適切だと評価した回数が記録されています。 また、どちらのインデックスも適切だと評価した場合は「both_ok」、どちらのインデックスも適切ではないと評価した場合は「both_ng」の値が記録されます。 これらの集計結果を表示させることで、どのクエリでどのような評価をしたのかを素早く確認できるようになりました。 類似度算出 2つのインデックスから出力されたサジェスト候補を定量的に評価する機能として、類似度を算出し表示しています。 具体的には、「 A Similarity Measure for Indefinite Rankings 」(著:William Webber, Alistair Moffat and Justin Zobel)の論文で提案されている"Ranking-Biased Overlap(RBO)"という手法により類似度を算出しています。 この手法は類似度を0〜1の範囲で計算し、2つのリスト内の上位のキーワードが異なっていた場合に類似度を大きく減衰させるという特徴があります。 以下の理由からこちらの手法を採用しました。 ある検索クエリに対する2つのサジェスト候補のリストにどの程度の差があるのか知りたいという目的と合致している インデックスに対して加えた改修の影響が大きいのか小さいのかが直感的に理解できる Ranking-Biased Overlap(RBO)の実装は、 changyaochen/rbo のコードを参考にGo言語へ書き換えました。 package main import ( "fmt" "math" ) type State struct { p float64 depth int weight float64 agreement float64 AverageOverlap float64 sRunning map [ string ] struct {} tRunning map [ string ] struct {} } func NewState(s0, t0 string , p float64 ) *State { if 0.0 >= p || p > 1.0 { panic ( "p must be between (0, 1)" ) } weight := 1.0 agreement := 0.0 averageOverlap := 0.0 if p != 1.0 { weight = 1 - p } if s0 == t0 { agreement = 1.0 averageOverlap = weight } return &State{ p: p, depth: 1 , weight: weight, agreement: agreement, AverageOverlap: averageOverlap, sRunning: make ( map [ string ] struct {}), tRunning: make ( map [ string ] struct {}), } } func (s *State) Update(sd, td string ) { overlap := 0 if sd == td { overlap++ } if _, ok := s.tRunning[sd]; ok { overlap++ } if _, ok := s.sRunning[td]; ok { overlap++ } s.agreement = 1.0 * ((s.agreement * ( float64 (s.depth))) + float64 (overlap)) / ( float64 (s.depth) + 1.0 ) s.weight = ( 1 - s.p) * math.Pow(s.p, float64 (s.depth)) if s.p == 1.0 { s.AverageOverlap = (s.AverageOverlap* float64 (s.depth) + s.agreement) / ( float64 (s.depth) + 1.0 ) } else { s.AverageOverlap = s.AverageOverlap + s.weight*s.agreement } s.sRunning[sd] = struct {}{} s.tRunning[td] = struct {}{} s.depth++ } func (s *State) GetSimilarity() float64 { if 0.0 <= s.AverageOverlap && s.AverageOverlap <= 1.0 { return s.AverageOverlap } fmt.Println( "Value out of [0, 1] bound, will bound it" ) return math.Min( 1.0 , math.Max( 0.0 , s.AverageOverlap)) } func main() { // 類似度を算出する対象のランキングリスト s := [] string { "a" , "b" , "c" , "d" } t := [] string { "a" , "c" , "b" , "d" } state := NewState(s[ 0 ], t[ 0 ], 1.0 ) for i := 1 ; i < len (s); i++ { state.Update(s[i], t[i]) } similarity := state.GetSimilarity() fmt.Printf( "similarity: %g" , similarity) // similarity: 0.875 } 利用ケース 私たちは、サジェスト機能の改善を検証するためA/Bテストを実施していますが、ユーザーへの影響をできるだけ事前に把握するためオフラインでの定性・定量評価を実施しています。 本ツールはA/Bテストを実施する前に行うオフラインの定性評価時に利用します。以下は、A/Bテストを実施するまでのステップを簡易的にまとめた図です。 定性評価は以下の順序で行っています。 ランダムに抽出した検索回数が多いTOPクエリと少ないTAILクエリを評価者に割り振る 評価者は本ツールにて割り振られたクエリを実行する 出力されたサジェスト候補を元に2つのインデックスを評価する 全ての評価者からのフィードバックを元にA/Bテストへ進むのか、改善方針を見直すのかを判断する 評価者は開発前に策定した方針が適切に検索結果に現れているのか、またTOPクエリ及びTAILクエリに対する影響をチェックしています。 また、評価者の構成は基本的にチーム内のメンバーですが、過去には社内で評価者を募ったこともあります。 上図の改善サイクルの詳細や改善事例については過去の記事で紹介していますので、併せてご覧ください。 techblog.zozo.com バックエンドの技術 バックエンドで採用している技術スタックやアーキテクチャを紹介します。 技術スタック おおまかな技術スタックを以下の表にまとめました。 カテゴリー 名称 実行環境 Google App Engine データベース Google Cloud Datastore 開発言語 Go言語 httpパッケージ net/http routingパッケージ gorilla/mux Elasticsearchクライアントパッケージ olivere/elastic Go言語を採用した背景は、属人的な実装にならないようにシンプルな文法で記述でき、将来チーム内で開発する際にも実装のブレが少ない言語仕様だからです。 評価した検索クエリやインデックス名、サジェスト候補を格納するデータベースとしては、Google Cloud Datastoreを採用しています。 社内ツールということもあり、運用にかかるコストを下げるためやApp Engineとの相性の良さからGoogle Cloud Datastoreを採用しました。 アーキテクチャ プロダクトの設計として、ヘキサゴナルアーキテクチャを採用しています。アダプター層、ユースケース層、ドメイン層で責務を分け、依存関係の方向を担保し実装をしています。 ディレクトリ構成は以下の通りです。 . app ├── adapter │ ├── datastore │ ├── elasticsearch │ ├── http │ │ ├── controller │ │ └── middleware │ └── impl ├── domain │ ├── model │ └── repository └── usecase ディレクトリ名 概要 adapter/http/controller いわゆるコントローラ層。ユーザーからのデータを受け取り、処理をしたデータをユーザーへ返す実装。 adapter/http/middleware リクエストログの取得などAPI共通機能を実装。 adapter/impl interfaceで定義したメソッドの実装。 adapter/elasticsearch elasticsearchへの接続処理を実装。 adapter/datastore datastoreへの接続処理を実装。 domain/model アプリケーションに必要な構造体を定義。 domain/repository 依存性逆転のためinterfaceを定義。 usecase ユーザーからのインプットを元にAPIレスポンスに必要な情報を返す実装。 まとめ 本ツールにより、以下2つの要望を実現しました。 開発者が新規に作成したインデックスと既存のインデックスの検索結果を素早く比較する A/Bテスト前のオフライン評価の実施 今後の展望として、Elasticsearchのマッピングやクエリを変えて比較できるような機能を実装したいと思っています。 さいごに、ZOZOでは検索エンジニア・MLエンジニアを募集しています。検索機能の改善に興味のある方は、以下のリンクからご応募ください。 hrmos.co hrmos.co
はじめに こんにちは、マイグレーションチームの寺嶋です。 本記事では、ZOZOTOWNのマイクロサービスにおけるデータベースを参照したユニットテストの改善で得られた知見や工夫について紹介します。 背景と課題 ZOZOTOWNでは、数年前からリプレイスプロジェクトが実施されており、いくつものマイクロサービスが誕生しました。初期にJavaで作られたマイクロサービスのユニットテストが開発環境のデータベースを参照しており、テストで利用しているデータが更新・削除されてしまうとテストに失敗してしまうことが度々起きていました。また、接続しているデータベースがオンプレのSQL Serverを利用しており、CI上でユニットテストを実施できない状況でした。 そのため対象のユニットテストは次の問題を抱えていました。 ローカルPC上でしか実行できない 実データを利用しているので今日通ったテストが明日落ちる(可能性がある) このようなことから外部環境に依存しないユニットテストへ変更する必要がありました。 対象サービスの技術スタック 今回改善するマイクロサービスの技術スタックは次の通りです。 Java 11 Maven Spring Boot MyBatis SQL Server JUnit 4 ZOZOTOWNリプレイスプロジェクトでは全社技術スタックを統一しています。詳しくは下記の記事をご覧ください。 qiita.com 対応方法の検討 解決方法として次の方法を検討しました。 H2データベースを利用する Dockerコンテナのデータベースに接続する H2データベースはJVM上にて動作するデータベースでインストールを必要としません。JDBCのURLに ;MODE=MySQL といったオプションをつけることでH2データベースの挙動をMySQL、PostgreSQLなど切り替えることができます。もちろんSQL Serverモードもあり、 ;MODE=MSSQLServer を指定すればSQL Server風の挙動を再現させることが可能になります。ただ、 H2のドキュメント を確認していると、 ヒント句は破棄される という説明があり、SQLチューニングでヒント句を使用しているサービスなので、採用は見送ることになりました。 ということで、DockerコンテナでSQL Serverを起動させてテストする方法となり、見つけたのが Testcontainers でした。 Testcontainersとは Testcontainers はJUnitのテストをサポートするJavaライブラリです。一般的なデータベースやSelenuim、Dockerコンテナで実行できるものを軽量で使い捨て可能なインスタンスとして提供してくれます。Testcontainersを利用すると次の種類のテストが簡単に行えます。 データアクセスレイヤーテスト MySQL、PostgreSQL、Oracle Databaseなどのコンテナ化されたインスタンスを使用して、データアクセスレイヤーにコードの変更なくテストを実行できます。Dockerコンテナを利用するので複雑なセットアップも必要ありません。 アプリケーション統合テスト データベース、メッセージキュー、Webサーバなどの依存関係を使用してアプリケーションのテストを実行できます。 UI受け入れテスト 自動化されたUIテストを実施するためにSelenuimと互換性のあるコンテナを使用し、ブラウザの状態やバージョンを気にすることなくテストを実施できます。また、失敗したテストのみ動画を録画するなども行ってくれます。 今回は データアクセスレイヤーテスト を活用して、実データベースを参照しているテストを改善します。 開発環境のデータベースから切り離す pom.xml Testcontainersの依存関係をpom.xmlに追記していきます。今回はSQL Serverコンテナをユニットテスト時に起動しますので org.testcontainers.mssqlserver を追加しています。MySQLやPostgreSQL、Oracle Databaseを利用する場合は対象データベースのdependencyがありますので、環境に応じて指定してください。 <dependency> <groupId> org.testcontainers </groupId> <artifactId> testcontainers </artifactId> <version> 1.16.3 </version> <scope> test </scope> </dependency> <dependency> <groupId> org.testcontainers </groupId> <artifactId> mssqlserver </artifactId> <version> 1.16.3 </version> <scope> test </scope> </dependency> SQL Serverコンテナの起動 MSSQLServerContainer クラスがSQL Serverコンテナを管理するクラスとなっており、これを継承し新たに MyMSSQLContainer クラスを作っていきます。 public class MyMSSQLContainer extends MSSQLServerContainer<MyMSSQLContainer> { private static final String IMAGE_VERSION = "mcr.microsoft.com/azure-sql-edge:1.0.5" ; private static MyMSSQLContainer container; private MyMSSQLContainer() { // (1) super (DockerImageName.parse(IMAGE_VERSION) .asCompatibleSubstituteFor( "mcr.microsoft.com/mssql/server" )); } public static MyMSSQLContainer getInstance() { if (container == null ) { // (2) container = new MyMSSQLContainer() .waitingFor(Wait.forLogMessage( "*SQL Server is now ready for client connections*" , 1 )) .acceptLicense(); } return container; } @Override public void start() { super .start(); } @Override public void stop() {} } SQL Serverのコンテナは通常 mcr.microsoft.com/mssql/server イメージを利用すればよいのですが、M1 Macでは起動できません。M1 Mac上でも起動できるSQL Serverコンテナは mcr.microsoft.com/azure-sql-edge イメージになります。(1)で azure-sql-edge を指定し、 mcr.microsoft.com/mssql/server として振る舞うように設定しています。M1 Macをご利用の方はご注意ください。 本クラスはシングルトンでインスタンスを管理しています。複数のユニットテストで MyMSSQLContainer クラスのインスタンスを作成してしまうと、それぞれのテストでSQL Serverコンテナを起動してしまうため、実行単位で起動を促しています。また、(2)でインスタンスを作成する際にメソッドチェインで呼び出している acceptLicense メソッドはライセンス認証をしています。SQL ServerやIBM Db2で必要となります。詳しくは下記のページをご覧ください。 www.testcontainers.org 接続先を動的に変更する MyMSSQLContainer クラスでSQL Serverコンテナの起動準備ができました。ただ、見てもらえればわかるようにデータベースのIDやパスワード、ポートを指定していません。ID・パスワードは Testcontainers がデフォルトで設定しているものを利用し、ポートも指定しなければランダムで設定されます。パスワードは withPassword メソッド、ポートは withExposedPorts メソッドで設定できますが、ユニットテストなので固定化せず、デフォルト指定されるものを利用します。 public class AbstractDBTest { protected static MSSQLServerContainer<MyMSSQLContainer> sqlserver = MyMSSQLContainer.getInstance(); static { sqlserver.start(); } @DynamicPropertySource static void setup(DynamicPropertyRegistry registry) { registry.add( "spring.datasource.url" , sqlserver::getJdbcUrl); registry.add( "spring.datasource.username" , sqlserver::getUsername); registry.add( "spring.datasource.password" , sqlserver::getPassword); } } AbstractDBTest クラスはユニットテストクラスが継承する基底クラスとなります。Spring Bootでは こちら にもある通り @DynamicPropertySource を用いると容易に接続先を切り替えることができます。起動しているSQL Serverコンテナから接続に必要な情報を取得し環境変数に設定します。 テーブルとデータの復元 ここまでで、ユニットテスト起動時に必要な次の準備が完了しました。 SQL Serverコンテナの起動 接続先の動的な切り替え 次はSQLの動作確認に必要なテーブルとデータの復元になります。 テーブル、データの復元には Flyway を使っていきます。Flywayはデータベースのバージョン管理ツールで、DDLやDMLのSQLファイルをバージョン管理することで常に最新状態を保つことができます。pom.xmlにFlywayの依存関係を追加していきます。 <dependency> <groupId> org.flywaydb </groupId> <artifactId> flyway-core </artifactId> <version> 8.5.8 </version> <scope> test </scope> </dependency> <dependency> <groupId> org.flywaydb </groupId> <artifactId> flyway-sqlserver </artifactId> <version> 8.5.8 </version> <scope> test </scope> </dependency> テストで利用するDDLとDMLはtestディレクトリ配下の resources/db/migration に次のファイル名で配置します。 V1__CreateTable.sql V2__InitData.sql SQLファイルのネーミングルールは V{VERSION}_{DESCRIPTION}.sql になっており、詳細は次の通りです。 先頭文字は V から始める {VERSION} は実行される順番となり、小さい番号から実行される __ はバージョンと説明との区切り {DESCRIPTION} はバージョンの説明を記述する Spring Boot起動時に Flyway.migrate() が呼び出されるようにするため、 FlywayMigrationStrategy インタフェースの実装をBeanに登録します。 @Bean public FlywayMigrationStrategy cleanMigrateStrategy() { FlywayMigrationStrategy strategy = flyway -> { flyway.clean(); flyway.migrate(); }; return strategy; } あとは、既存のテストクラスで AbstractDBTest クラスを継承するとユニットテスト実行時にSQL Serverコンテナが起動し、テーブル・データの復元を行いテストを実行してくれます。テスト終了後にはSQL Serverコンテナは自動で終了してくれます。 まとめ Testcontainers を使ったユニットテストの改善・導入をご紹介しました。本対策をすることでCI上でもユニットテストの実行ができるようになり、機能追加や改修、リファクタリング時のリグレッションテストとして機能するようになりました。Dockerコンテナを利用することで速度の懸念もありましたが、SQL Serverの起動はそれほど遅くなく、テスト時間が伸びて待ちが発生するようなこともありませんでした。実データベースの参照がなくなりデータの状態に左右されず安定してユニットテストを実行できるようになったので、安心感という大きな恩恵を得ることができたと思います。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は次のリンクからぜひご応募ください。 hrmos.co
はじめに ブランドソリューション開発本部フロントエンド部FAANSの山田( @yshogo87 )です。 本投稿では、すでにXMLで書かれたレイアウトをJetpack Composeにリファクタリングした理由とその手順について紹介します。 リファクタリングする画面の問題点 FAANS では「コーデ閲覧数、送客数、売上数」を表示する画面があります。 この画面はUIの状態が一元管理されておらず状態がViewのみにしかないことで、機能の追加修正時に不具合を作り込みやすく、リリースまでに時間がかかるという問題がありました。 UIの状態変更が一元管理されていない この画面ではUIの状態変更が複数の異なるソースコードから行われていて、一元管理されていませんでした。UIの状態を変えている場所は大きく分けると2つです。 現在のUIの状態変更が別のUIから行われる この画面は1つのXMLファイルにViewPagerとRecyclerViewがあり、ViewPagerのグラフタップや横スワイプで下のRecyclerViewの情報も変わる仕様になっています。 この仕様のため、AdapterクラスにFragmentのBindingを渡して直接別のRecyclerViewの状態を変更する実装でした。 // Adapterクラス class CoordinateImpressionsGraphAdapter( private val binding: FragmentCoordinateImpressionsBinding, private val viewModel: CoordinateImpressionsActionDelegateImpl, diffCallBack: DiffCallBack = DiffCallBack() ) : RecyclerView.Adapter<CoordinateImpressionsGraphBindingHolder>() { override fun onBindViewHolder(holder: CoordinateImpressionsGraphBindingHolder, position: Int ) { ・ ・ ・ // グラフのタップイベント chart.setOnChartValueSelectedListener( object : OnChartValueSelectedListener { override fun onValueSelected(e: Entry , h: Highlight?) { // 別のRecyclerViewを直接更新している (binding.impressionsDetail.adapter as CoordinateImpressionDetailAdapter).submitList(initialBrandList) } override fun onNothingSelected() {} }) ・ ・ ・ } } このようにAdapterクラスから別のUIの状態を変更され、現在のUIの状態がViewにしか保持されていないことが問題でした。UIの状態がViewだけに存在すると、状態の把握・管理することが難しくなり実装に時間を要していました。 UIの状態変更が複数のLiveDataから行われる ViewModelからFragmentにイベントやUIの状態変更時にLiveDataを使っていました。これらのLiveDataは「UIの状態変更すること」と「イベントをFragmentに伝えること」の2つの役割があって区別されていませんでした。 class CoordinateImpressionsViewModel @Inject constructor ( private val coordinateImpressionsUseCase: CoordinateImpressionsUseCase ) : ViewModel() { ・ ・ ・ ・ private val _navigatePopStack = MutableLiveData<Event< Unit >>() val navigatePopStack: LiveData<Event< Unit >> get () = _navigatePopStack private val _coordinateImpressions = MutableLiveData<WearCoordinateImpressions>() val coordinateImpressions: LiveData<WearCoordinateImpressions> get () = _coordinateImpressions private val _hasError = MutableLiveData<ErrorType>() val hasError: LiveData<ErrorType> get () = _hasError private val _isLoading = MutableLiveData< Boolean >() val isLoading: LiveData< Boolean > get () = _isLoading private val _currentPosition = MutableLiveData< Int >() val currentPosition: LiveData< Int > get () = _currentPosition ・ ・ ・ ・ } UIの状態を変更するLiveDataが分かれているので、状態を変更する処理を追って確認しながら実装していく必要があるため、機能追加や仕様変更に時間を要していました。 UIの状態を一元管理するようにする 「コーデ閲覧数、送客数、売上数」を表示する画面はFAANSアプリのメイン機能であり、今後も機能追加されることが予想されることから以前より、リファクタリングを検討していました。PMチームなどにも現状の問題点を共有して、タスクを調整し時間をとってリファクタリングすることとなりました。 「現在のUIの状態変更が別のUIから行われること」と「複数のLiveDataからUIの状態が変更されること」の2点の問題を解決するためにUIの状態変更を一元管理するように修正します。 LiveDataを1つにし、Jetpack Composeに変更するリファクタリングを行う UIの状態変更を一元管理するために、データの流れを整理するようにします。また、「UIの状態変更が複数のLiveDataから行われる」という問題を解決するために、LiveDataは1つにするようにします。 そしてFAANS Androidアプリで以前よりJetpack Composeを導入しているため、UIもXMLファイルからJetpack Composeに変更していきます。 UIの状態を一元管理する リファクタリング方針の紹介 「UIの状態変更を一元管理」するために「単方向データフロー」の設計パターンを取り入れることにしました。データの流れを単方向にすることで明確にし、状態の把握・管理をしやすくしていきます。 UIの状態管理する2つのクラスを作成します。 State 画面の状態を保持する (例:プログレスのON/OFF、Userのデータなど) Action FragmentからのイベントをActionとしてViewModelに伝える (例:投稿ボタンをタップ、PullToRefreshイベントなど) LiveDataはStateの1つにします。また、今回の実装でLiveDataをStateFlowに変更しています。 UIはStateの更新を監視し、Stateの情報に従ってUIが構築するように実装していきます。そして、Stateの状態を変更したい場合はActionを使って変更をViewModelに伝えてViewModelの中でStateを更新します。このようにすることで、「UIはStateの変更だけを監視」と「ViewModelはStateを変更を行う」ようになり、UIの状態を一元管理できるようになります。 設計に沿ってリファクタリング 前節で紹介した設計に従ってリファクタリングしていきます。 下記は、「ViewModelはState更新」「UIはStateの購読」をするソースコードです。 ViewModelでは、APIリクエストの結果をStateに渡していてUIはリクエストの結果があれば画面に反映しています。そしてユーザーのタップのイベントは、Actionを使ってViewModelで受けとりStateを更新しています。 class SummaryViewModel( repository: HomeRepository, ) { private val _state = MutableStateFlow(State.Initial) val state: StateFlow<State> = _state init { fetchSummaryData() } fun dispatchAction(action: Action) { viewModelScope.launch { try { when (action) { is Action.UpdateSummary -> { _state.value = _state.copy( content = it ) } } } catch (e: Throwable ) { // エラー処理 } } } private fun fetchSummaryData() { viewModelScope.launch { _state.update { _state.value.copy(isLoading = true ) } when ( val result = repository.getSummaryData()) { is ResultWrapper.Sucess -> { _state.value = _state.copy( content = result.value ) } is ResultWrapper. Error -> { // エラー処理 } } _state.update { _state.value.copy(isLoading = false ) } } } } sealed class Action { object UpdateSummary : Action() } data class State( val userInfo: UserInfo? = null , val content: Content? = null , val isLoading: Boolean = false , ) { companion object { val Initial = State() } } Fragment側でStateを購読します。 class SummaryFragment : Fragment() { private val viewModel: SummaryViewModel by viewModels() private lateinit var binding: FragmentSummaryBinding private lateinit var pagerAdapter: GraphAdapter override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentSummaryBinding.inflate(inflater, container, false ) pagerAdapter = GraphAdapter() return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super .onViewCreated(view, savedInstanceState) lifecycleScope.launchWhenStarted { viewModel.state.collectLatest { state -> // Stateの情報に従ってUIの状態を変えていく setSummaryView(state) } } } private fun setSummaryView(summary: State) { if (summary.contentt != null ) { binding.containerGraph.post { val newList = pagerAdapter.summaryList.toMutableList() newList.add(summary.list) pagerAdapter.list = newList.toList() pagerAdapter.notifyItemRangeChanged(newList.size) } } binding.loadingPanel.isVisible = state.isLoading binding.graphLoadingPanel.isVisible = state.isLoading } 他のファイルにあるUIの状態変更処理は、Fragmentに集約してActionとしてViewModelに伝えるようにしていきます。 例えば、Adapterクラスにある状態変更処理は、 onTapGraphItem のようなコールバックをパラメータとして渡してFragmentで受け取り、 Action.UpdateSummary(data) としてViewModelに伝えています。 // Adapterの実装 class CoordinateImpressionsGraphRecyclerAdapter( private val onTapGraphItem: (CoordinateImpressionDetail) -> Unit , diffCallBack: DiffCallBack = DiffCallBack() ) : RecyclerView.Adapter<CoordinateImpressionsGraphBindingHolder>() { ・ ・ ・ private fun setUpGraphView(chart: BarChart, axis: BarChart, data: WearCoordinateImpressions) { ・ ・ ・ // グラフタップイベント chart.setOnChartValueSelectedListener( object : OnChartValueSelectedListener { override fun onValueSelected(e: Entry , h: Highlight?) { ・ ・ ・ // タップイベントをコールバックとして渡す onTapGraphItem(date.coordinateImpressionDetail) } override fun onNothingSelected() {} }) } ・ ・ ・ } // Fragmentの実装 class SummaryFragment : Fragment() { private val viewModel: SummaryViewModel by viewModels() private lateinit var binding: FragmentSummaryBinding private lateinit var pagerAdapter: GraphAdapter override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentSummaryBinding.inflate(inflater, container, false ) pagerAdapter = GraphAdapter() recyclerAdapter = SummaryAdapter( onTapGraphItem = { data -> // ViewModelを経由してStateの情報を更新する viewModel.dispatchAction(Action.UpdateSummary(data)) } ) return binding.root } ・ ・ ・ private fun inflateSummaryView(summary: Contents?) { if (summary != null ) { binding.containerGraph.post { val newList = adapter.summaryList.toMutableList() newList.add(summary.list) adapter.list = newList.toList() adapter.notifyItemRangeChanged(newList.size) binding.loadingPanel.visibility = View.GONE binding.graphLoadingPanel.visibility = View.GONE } } } } このようにUIの状態はすべてStateが保持していて、UIの状態変更はViewModelを経由してStateを更新するようにします。 そしてStateを購読している箇所で、Stateの状態に従ってUIを構築するように修正します。 以上で、UIの状態変更を一元管理するリファクタリングを行いました。 Stateの情報に従ってJetpack Composeでレイアウトを書いていく 状態の一元管理とJetpack Composeの相性が良い Stateの情報でUIの構築するリファクタリングで当初の「UIの状態変更を一元管理するようにする」という目的は達成できています。 ただ、Jetpack Composeでも単方向データフローパターンを推奨しています。 https://developer.android.com/jetpack/compose/architecture?hl=ja#udf このため、XMLで書かれた現在のレイアウトもJetpack Composeで書き換えもスムーズに行えます。 Jetpack Composeへの書き換え XMLでUIを作る場合、 findViewById() などを用いてUIウィジェットを取得し、 view.isVisible = true のように操作することでUIの状態を変更するのが一般的です。このように手動で操作すると、UIの更新を忘れがちでエラーが発生しやすくなります。Jetpack Composeは宣言的UIフレームワークであり、この手動でUIを操作する複雑さを回避できます。 XMLを用いた場合のサンプルコードでも手動でUIを操作している箇所があり、Jetpack Composeで書き換えることでこのような手動でのUIの操作をなくします。また、RecyclerViewによるリストもJetpack Composeではより少ないコード量で実現でき可読性が向上します。 class SummaryFragment : Fragment() { private val viewModel: SummaryViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { return ComposeView(requireContext()).apply { setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner), ) setContent { SummaryRoute() } } } } @Composable fun SummaryRoute( viewModel: SummaryViewModel = viewModel() ) { // Stateの購読を行う val lifecycleOwner = LocalLifecycleOwner.current val viewState by remember(viewModel.state, lifecycleOwner) { viewModel.state.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED) }.collectAsState(State.Initial) SummaryScreen( viewState = viewState, viewActionDispatcher = { action -> viewModel.dispatchAction(action) } ) } @Composable fun SummaryScreen( viewState: State, viewActionDispatcher: (Action) -> Unit = { _ -> }, ) { // Stateの情報に従ってUIを構築する Scaffold( backgroundColor = MaterialTheme.colors.surface, topBar = { TopAppBar( title = { Text( text = "ToolBar" , fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colors.onSurface ) }, ) } ) { // Stateの状態に従ってプログレスのON/OFFを切り替える if (viewState.isLoading) { Progress() } else { // Adapterクラスは必要なくリストにデータを渡すのみ Contents( userInfo = viewState.userInfo, content = viewState.contents ) } } } @Composable fun Contents( userInfo: User, contents: Contents ) { LazyColumn { ・ ・ ・ } } まとめ 今回、XMLで書かれたレイアウトをJetpack Composeで書き換えました。 リファクタリングによって実装者がUIの状態を管理しやすくなり、想定外の状態変更が起こりづらく機能追加や仕様変更が容易になりました。 また、Jetpack Composeを使ったことでStateの更新で自動的にUIを変更することができるので実装が楽になりました。ソースコードの量も削減でき、可読性を向上させることができました。 最後に ZOZOではAndroidエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。 hrmos.co
こんにちは、WEAR部バックエンドブロックの小山とSREブロックの繁谷です。 WEAR では日々システムの信頼性を向上させるため改善に取り組んでいます。今回はその中でもSLOに基づいた改善について紹介いたします。 WEARリプレイスの歩み WEARでは2019年から本格的にリプレイスを開始しましたが、当初は専属のSREはおらずインフラ構築など緊急度の高いものをバックエンドのエンジニアや、プロダクト横断のSREが担っていました。 WEARのSREとして活動に割ける時間も短かったためSLI(Service Level Indicator) 1 やSLO(Service Level Objective) 2 の指標もありませんでした。WEARにおけるリプレイスの変遷については こちらのスライド に詳しく載せられているため、ご興味のある方は是非ご覧ください。 WEARの組織における課題 WEARでは2021年4月に専属のSREが発足しましたが、それ以前は以下の課題を抱えていました。 SREはインフラ構築担当、バックエンドはアプリケーション開発担当と分断していた SLOの策定とそれの達成に向けた改善ができていなかったため、漠然とエラー解消やレイテンシ改善の対応をしていた SLOに基づいて、客観的に信頼性改善と機能開発のエンジニアリングリソースを配分できていなかった 本記事の本題である、WEARにおける従来の信頼性改善への取り組みについては以下で紹介します。 WEARバックエンドの改善の歩み 2020年にリプレイス環境ではじめてAPIがリリースされました。リプレイス環境のアプリケーションのモニタリングはDatadogとSentryで行っています。 当時、APIのレイテンシは50〜100ミリ秒のレスポンスを目標としていて、API実装者が責任を持ってモニタリングし目標を満たすよう改善に努めていました。5xxエラーについてはSentryで検知したらアラートをSlackに飛ばしてチーム全体でエラーの原因を調査していました。 リプレイスを進める中で、リプレイス前の旧環境で稼働しているバッチに起因するクエリタイムアウトが頻発し、バッチがサービス全体の可用性、レイテンシに影響を与えているということが分かりました。 バッチリプレイス定例 バックエンドではサービス全体に影響を与えているバッチにスコープを絞って、クエリタイムアウトの原因調査と改修またはリプレイス対応を共有するバッチリプレイス定例を隔週で行っていました。 当時、WEARのバックエンド組織は運用改善チームとサービス開発チームの2チームに分かれていて、バッチリプレイスは、主に運用改善チームがリソースを割いて対応していました。 調査と改善対応については、Datadogのメトリクスを追ったり、 こちら で紹介されているSQL Serverのブロッキング発生時の情報を基にDBアーキテクトと課題ベースで連携して対応を進めていました。 このように従来はバックエンド主体で改善に取り組んでいました。そして、WEAR専属のSREチームが発足してSLOが策定されたことで、徐々にチーム横断でSLOをベースとした信頼性改善への取り組みをはじめることになります。 WEARにおけるSRE SREチームができてからすぐにSREとしての動きができていたわけではなく、SREチーム内でも各々がSREへの認識に齟齬がある状態でした。そのため、SREの定義や責務などを SRE本 や各社の事例を参照しながら共通認識を持つことからはじめました。 そして今何ができていて何ができていないかを Google社の記事 を参考にしてチームで議論し、SLOがないため信頼性を監視できておらず行為主体性も発揮できていないことを確認しました。 WEARにSREが存在しないころの動きを踏襲していたため、依頼されて動くと言った受け身の姿勢になっていました。よって、SLOを策定して信頼性を監視し客観的に信頼性改善のためのエンジニアリングリソースの配分について提案できる、行為主体性のある組織となることを目標としました。 SLI, SLOの決定 目標が決まったのでまずはSLIやSLOを決めます。 SLIやSLOの決定はとにかく素早く運用に乗せることを意識しました。 Google社の発表 でも述べられているように、SLOがない状態よりシンプルなものでも運用できている状態が望ましいからです。 よって、本来であればCUJ(Critical User Journey) 3 を検討することから始めるべきですが、一般的にSLIとして用いられシステム全体のエラーレートとレイテンシをSLIとしました。そして運用しながら改善することにしました。 WEARにおけるSLO Google社の記事 や サイボウズ社の記事 を参考にとにかくシンプルなSLOから導入することにしました。 目標 可用性 1か月のリクエストのうち、最低でも99.5%の割合で503以外のレスポンスを返す レイテンシ 1か月のリクエストのうち最低でも50%は500ミリ秒以内にレスポンスを返し、99%は3000ミリ秒以内にレスポンスを返す APIの目標としてはかなり緩いものですが、適宜調整する前提で最初は達成できるラインを設定し改善効果を実感できることを目指しました。 SLOモニターのコード化 当初はSLOのモニターを CloudFormationのパブリック拡張機能 で Datadog社が提供している拡張 を使っていました。しかし、後に互換性の問題から拡張をアップデートしづらくなりTerraformに移行しました。 以下にAWSのALBにAPIサーバのターゲットが接続されている構成のTerraformのコード例を示します。 可用性 ALBで受けた全リクエストからALBで5xxを返したものとターゲットで5xxを返したものを引いてエラーレートを計算します。 resource "datadog_service_level_objective" "api_error_rate" { name = "API Error Rate" query { numerator = "sum:aws.applicationelb.request_count{name:api}.as_count() - sum:aws.applicationelb.httpcode_elb_5xx{name:api}.as_count() - sum:aws.applicationelb.httpcode_target_5xx{name:api}.as_count()" denominator = "sum:aws.applicationelb.request_count{name:api}.as_count()" } thresholds { target = "99.5" timeframe = "30d" } type = "metric" } レイテンシ 99th percentile, 50th percentileのレイテンシを監視するモニターを作成しSLOのリソースに紐づけます。 resource "datadog_service_level_objective" "api_latency" { monitor_ids = [ datadog_monitor.api_latency_p99.id, datadog_monitor.api_latency_p50.id ] name = "API Latency" thresholds { target = "99" timeframe = "30d" warning = "0" } type = "monitor" } resource "datadog_monitor" "api_latency_p99" { escalation_message = "" evaluation_delay = "900" include_tags = "true" locked = "false" message = "{{#is_alert}}{{name}}のレイテンシが{{threshold}}を超えました。{{/is_alert}}{{#is_recovery}}{{name}}のレイテンシが正常値に戻りました。{{/is_recovery}}" monitor_thresholds { critical = "3" } name = "Api Latency p99" new_group_delay = "0" new_host_delay = "300" no_data_timeframe = "0" notify_audit = "false" notify_no_data = "false" priority = "0" query = "avg(last_1h):avg:aws.applicationelb.target_response_time.p99{name:api} > 3.0" renotify_interval = "0" renotify_occurrences = "0" require_full_window = "true" tags = [] timeout_h = "0" type = "query alert" } resource "datadog_monitor" "api_latency_p50" { escalation_message = "" evaluation_delay = "900" include_tags = "true" locked = "false" message = "{{#is_alert}}{{name}}のレイテンシが{{threshold}}を超えました。{{/is_alert}}{{#is_recovery}}{{name}}のレイテンシが正常値に戻りました。{{/is_recovery}}" monitor_thresholds { critical = "0.5" } name = "Api Latency p50" new_group_delay = "0" new_host_delay = "300" no_data_timeframe = "0" notify_audit = "false" notify_no_data = "false" priority = "0" query = "avg(last_1h):avg:aws.applicationelb.target_response_time.p50{name:api} > 0.5" renotify_interval = "0" renotify_occurrences = "0" require_full_window = "true" tags = [] timeout_h = "0" type = "metric alert" } アラート アラートにはエラーバジェットの枯渇とバーンレートの上昇を設定します。エラーバジェットとバーンレートの説明はSRE本に記載されているため省略します。バーンレートのターゲットは SRE本の推奨値 の14.4を設定します。 エラーバジェットとバーンレートのアラートも同様にコード化します。 resource "datadog_monitor" "api_error_rate_error_budget" { name = "API Error Rate Error Budget Alert" type = "slo alert" message = <<EOT {{#is_alert}}APIのエラーレートにおけるエラーバジェットが枯渇しました。{{/is_alert}} {{#is_warning}}APIのエラーレートにおけるエラーバジェットの消化率が{{warn_threshold}}を超えました。{{/is_warning}} Notify @slack-$ { local.slack_channel_name } EOT query = <<EOT error_budget("$ { datadog_service_level_objective.api_error_rate.id } ").over("30d") > 100 EOT monitor_thresholds { critical = 100 warning = 70 } tags = [] } resource "datadog_monitor" "api_error_rate_burn_rate" { name = "API Error Rate Burn Rate Alert" type = "slo alert" message = <<EOT {{#is_alert}}<!here> APIのエラーレートにおけるバーンレートが{{threshold}}を超えました。{{/is_alert}} {{#is_recovery}}APIのエラーレートにおけるバーンレートが回復しました。{{/is_recovery}} Notify @slack-$ { local.datadog_slack_channel_name } EOT query = <<EOT burn_rate("$ { datadog_service_level_objective.api_error_rate.id } ").over("30d").long_window("6h").short_window("1h") > 14.4 EOT monitor_thresholds { critical = 14 . 4 } tags = [] } SLOの運用 次にSLOを運用して信頼性を改善するための会議を設計します。 会議体としては元々実施していたバッチリプレイス定例をリニューアルして、SLO定例として実施しています。従来はバックエンドチームのみでしたが、現在はSREチーム、Webフロントエンドチームも合流し、信頼性に関わるエンジニアが参加してSLOの達成率やパフォーマンスの低い箇所について議論しています。またこの場でSLOの見直しも行っています。 SLOが適切であればSLOのアラートが飛んだタイミングでデプロイを制御して改善に工数を割くのが理想的な運用と考えますが、運用しながら改善していく前提で、粗い状態で設定しているためこのようにしています。 具体的な会議の内容については エウレカ社のパフォーマンス定点観測会 の内容を参考にさせていただきました。SLOやAPM、CPUやメモリ、AWSのコストまで信頼性に関係するメトリクスを一覧で見られるDatadogのダッシュボードを作成します。会議ではそれを眺めながら改善点を議論します。改善点が挙がればIssueにおこして進捗を管理します。 Datadogダッシュボードの内容がこちらです。 SLO 左にSLOのモニターを配置し右はAPMでエンドポイントごとのエラーレートやレイテンシ内容を表示させています。エラーバジェットが枯渇したサービスについてAPMを見ながら原因の議論ができるようにしています。 DB Datadogの データベース モニタリング を用いてアラートよりも長期的な目線で信頼性に影響する動きがないかを確認しています。 リソース リクエスト数とCPU使用率、メモリ使用量を確認し、長期目線でリソースの使用状況が適切かを確認しています。リソースが枯渇しそうかや、逆に過剰にリソースを確保していないかも確認しています。 コスト コストの大きいサービス順に棒グラフで表示させ、長期的な目線でコスト状況に異変がないか確認しています。パフォーマンス定点観測会のスライドにもあるようにDatadogにメトリクスを集めて1枚のダッシュボードを眺めることで信頼性に関係する情報を得られるように設計しています。 SLOの運用で得られた効果 SLOを策定してから実際に半年間運用してみて、エンジニアに限らずビジネスのメンバーも含めてチーム横断で信頼性へ意識が向くようになってきており、効果が表れていると感じています。 WEARは フリマ機能 や コーディネート動画機能 をはじめとする新機能のリリースを積極的に行なっています。 一方で、リプレイス前の旧環境に依存する障害、負荷も課題となっており、これらの課題に対しても改善が必要です。機能開発とシステムの信頼性改善の両方がサービスにとって重要なため、リソース配分を適切に行い、最大化されることは組織にとってとても重要です。 SLOの導入により、サービスレベルを下回る影響度であれば改善を行い、そうでなければ過剰に改善にはリソースを割かず機能開発に注力するような意思決定ができるようになったので効果があったと言えるでしょう。 今後の課題 しかし、残された今後の課題もあります。現在のSLOのターゲット値は達成できるラインで設定しているため、目標を達成したからといってユーザーが満足する訳ではありません。 SLOの主眼は究極的には顧客体験の改善であるため、ユーザーが満足している状態を保つために、CUJの策定と改善、またユーザーのインサイトを反映させたSLOの閾値を追及していくことが今後の課題です。 BizDevOpsにむけて 弊社瀬尾の記事 にもありますが、我々はBizDevOpsを体現し組織として密に連携することを目指しています。我々はシステムの信頼性やSLOの状態はプロダクトの戦略において重要で、エンジニアだけが知っていれば良いということではないと考えています。実際にSLOで見ているエラーレートやレイテンシは CVR や 離脱率 に影響があるという調査も挙がっています。 よって、ビジネスのメンバーと一緒に確度の高い戦略を打ち出すために都度SLOのサマリをレポートしています。これは始めたばかりですがビジネスのメンバーからは好感触を得ているので今後もWEARをより良くしていけると確信しています。 おわりに WEARにおけるSLOを用いた信頼性改善の取り組みについて紹介しました。SLOに関心のある皆さんの参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com hrmos.co hrmos.co SLI:外部からシステムに対して期待される可用性に関して設定された目標(引用: https://newrelic.com/jp/topics/what-are-slos-slis-slas ) ↩ SLO:システムの可用性を特定するための主要な測定値およびメトリクス(引用: https://newrelic.com/jp/topics/what-are-slos-slis-slas ) ↩ CUJ:ユーザーが1つの目的を達成するために行うサービスとの一連のインタラクション(引用: https://cloud.google.com/architecture/defining-SLOs ) ↩
こんにちは!バックエンドチームマネージャーの @tsuwatch です! 2022/9/8〜10に三重県にて開催されたRubyKaigi 2022でプラチナスポンサーとして協賛し、スポンサーブースを出展しました。 technote.zozo.com technote.zozo.com 弊社からは WEAR を開発するバックエンドエンジニア、SRE、PdMなど合計10名ほどが現地で参加しました。 我々が運営しているファッションコーディネートアプリ「WEAR」のバックエンドはRuby on Railsで開発しています。2013年にVBScriptで作られたシステムですが、2020年くらいからVBScriptのシステムをコードフリーズし、リプレイスをはじめました。現在もリプレイスを進めながら、新規の機能もRubyでどんどん開発しています。 また弊社ではRubyコミッタのsonotsさんがいたり、顧問としてMatzさんにもご協力いただいており月に一度、Matzさんに何でも聞く会をやっていたり、積極的にRubyを活用しています。 今回もエンジニアによるセッションの紹介とブースでの取り組みについて紹介します。 エンジニアによるセッション紹介 弊社エンジニアによるセッションの紹介をします。 How fast really is Ruby 3.x? github.com 高久です。Fujimoto Seijiさんのセッション「How fast really is Ruby 3.x?」についてご紹介します。 このセッションでは、Rubyの過去バージョンと比較しRuby 3.xではどれくらい早くなったかを、実際のアプリケーションを用いて検証した結果を報告しています。検証対象のアプリケーションはFujimoto Seijiさんがコミッターを務めるFluentdです。 検証の背景について。まず前提としてRuby 3は「Ruby 3×3」という「Ruby 2.0の3倍早くする」ことを目指して開発が進められました。そして過去のRubyKaigiでも同様に実際のアプリケーションを使った検証の話がいくつかありました。しかし、どれもRailsアプリで作られたものでは3倍にならないという話でした。なぜならRailsアプリは基本的に、アプリケーションの多くの時間を占めているのがRubyの処理ではなく、DBなどの外部処理によるものだからです。Rubyを早くしたとしても、その他にかかる時間が大きいため、全体の処理時間の短縮化に大きく寄与するものではありませんでした。 そこで今回、多くの処理をRubyで行なっているFluentdを対象に測定してみたらどうなるか? というのがテーマとなっています。 検証は読み込ませるファイルごとに2パターン行なっています。 LTSVファイル nginxログファイル 検証した結果Ruby 1.9と比較しRuby 3.2(YJIT有効)は、LTSVファイルで約3.15倍、nginxログファイルで約2.5倍のスループットが出ることを確認できました。Ruby 1.9と3.2の比較にはなりますが、概ね「Ruby 3×3」は実現しているのではということが、Fujimoto Seijiさんが伝えたかった内容になります。 セッションでは、更に他の言語と比較してどうかを述べていました。気になる方は是非スライドをご確認ください。 コミッターさんたちがRubyをより良くするために開発をし、Rubyが日々進化していることを実感した発表でした! Make RuboCop super fast speakerdeck.com 小山です。RuboCopメンテナの@koicさんによる「Make RuboCop Super fast」の発表を紹介します。 RuboCopは2012年4月21日がファーストコミットで今年10周年を迎えました。RuboCopは現在1.36.0が最新バージョンでありますが、2.0系リリースに向けてマイルストーンを掲げており、この発表はそのマイルストーンのうちの1つであるRuboCop2×2に関する発表でした。RuboCop2×2はRuby 3×3で目指しているように、RuboCopの速度を1.0系と比較し、2倍を目指すというものです。 RuboCopはCaching、Multi-cores、Reduce unused requires、Daemonizeの4つのアプローチで高速化を図っています。 Cachingはかねてから提供されていて、1回検査したコードはデフォルトで ~/.cache/rubocop_cacheに保存しています。 Multi-coresは1.19からデフォルトで並列検査するようになり、1.32から並列でオートコレクションするようになりました。8 core CPUかつHyper-Threadingを使用して約1,300ファイルに対して直列実行と並列実行を比較した場合、直列実行は61秒で完了したのに対し、並列実行は10秒で完了したそうです。 Reduce unused requiresは --onlyオプションを付与したとしてもすべてのCopが読み込まれてしまう問題がありました。require 'rubocop' の改善により高速化を実現しており、セッションの本論で話されていたserverモードと一部関連があります。 Daemonize(serverモード)がセッションで一番厚くお話されていた内容になります。serverモードは #10706 で対応されて1.31から導入されています。使用することでrubocopコマンドを実行する度にプロセスを起動するのではなく、プロセスを常駐することでモジュールの読み込みが初回のみになります。Client/Serverモデルを採用して高速化を実現していたサードパーティ製のgem、rubocop-deamonを統合することで、RuboCopのserverモードは高速化されています。Client/Serverモデルとは、Server側の初回プロセスであらかじめモジュールを読み込んでおいて、Client側がすでにモジュールを読み込んでいるサーバーに接続するアプローチです。またrubocop-daemonをどのように統合したか、serverモードの設計、具体的な使用方法についてはセッション内で詳しく解説されていますので気になる方は是非スライドをご覧ください。 成果としては、moduleの読み込みを必要なもののみにし、serverモードを実装したことで850倍高速化されています。驚くべき成果です。 RubyのDX向上に、すぐに繋がると実感した素敵な発表でした。まだ不安定な挙動が残っているとの補足はありましたが、RuboCopのバージョンを上げて積極的に使っていきたいです! The Better RuboCop World to enjoy Ruby speakerdeck.com 三浦です。私からはOhba Yasukoさん(@nay3)によるセッション "The Better RuboCop World to enjoy Ruby" について紹介したいと思います。技術的なセッションからは少し視点を変えた、RuboCopとうまく付き合っていくにはどうしたら良いかを考える内容になります。 RuboCopはRubyの静的コード解析ツールの1つで、コーディング規約を守れていないコードを簡単に確認でき、自動で修正できる便利なツールです。CIで回して事前に修正しておくことでレビューの負担軽減にも繋がります。 ただこのRuboCopのルールは "状況に合わないこと" もあります。例えば "Naming/PredicateName" というルールは、has , is , have_ といった特定の接頭辞のメソッド名をチェックし、接頭辞を排除したメソッドを使うよう警告します。 # bad def is_child? end def has_child? end # good def child? end ただ child? にしてしまうと、どちらとも捉えられるような曖昧なメソッド名になってしまいます。 "is child" なことをチェックするメソッド "has child" なことをチェックするメソッド このようにRuboCopの中にはルールとして間違ってはいないけど状況によっては合わないルールがいくつか存在しています。 初心者〜初級者のエンジニアの場合、状況に合っていないルールなのかを判断するのは難しいです。そのためRuboCopの警告に忠実に従ってコーディングをしてしまい、その結果かえって読みにくいコードになってしまう場合があります。 レビューで指摘された軽微な修正でも直そうとするとRuboCopのルールに引っかかってしまい、複雑な実装になってしまったなんてこともよくあります。(私もありました、、、) このような状況に合わないルールで振り回されてしまうのは、開発速度を低下させる一因にもなります。 RuboCopの全てのルールは、無効にしたりデフォルトとは異なる方針に変更できます。また、特定のコードで特定のルールを無視できます。しかしこのルールの設定の判断はルールの妥当性を判断できる技術力と経験が必要です。初心者〜初級者のエンジニアにとってはこの判断はなかなか難しいものです。かといって経験者が1つ1つのルールを必要か毎回確認するのも大変です。 そこで提案されたのがルールを大きく2つのレベルに分けて考えることでした。 強制レベル:ほぼ100%の状況で適用しても問題がないようなルール 参考レベル:なるべく多くの改善ポイントに気付けるような理想的なルール この2つのレベルに合わせてrubocop.ymlの設定ファイル自体を分けておきます。強制レベルのルールは現状通りCIなどで警告を出し、修正を強制します。参考レベルのルールはCIを回しますが、参考情報として表示するだけに留めておきcommitの禁止やマージの禁止といった強制力は持たせないようにします。 このように参考レベルのルールを作っておくと全てのルールに従おうとして不自然なコードを作ることを防ぐことができるので良いのではということでした。Ruby初心者にとってのつまづきポイントなどもたくさん紹介しており、うなずきたくなるような共感できる内容でした。スライドのイメージ図は全て画像生成AIのMidjourneyを使って生成したものだそうで笑いも起こる楽しいセッションでした。 Implementing Object Shapes in CRuby @tsuwatch です。個人的におもしろそうだなと思っていたObject Shapesというオブジェクトのプロパティを表現する手法について書こうと思います。Object Shapesを導入することでインスタンス変数のを見つけるときのキャッシュヒット率の増加とランタイム時のチェックを減らすことができ、JITのパフォーマンスを向上させるというものです。また、この手法はTruffleRubyやV8で採用されているそうです。 詳細はチケットにあるのでご覧ください。 bugs.ruby-lang.org Object Shapesとは class Foo def initialize # Currently this instance is the root shape (ID 0) @a = 1 # Transitions to a new shape via edge @a (ID 1) @b = 2 # Transitions to a new shape via edge @b (ID 2) end end class Bar def initialize # Currently this instance is the root shape (ID 0) @a = 1 # Transitions to shape defined earlier via edge @a (ID 1) @c = 1 # Transitions to a new shape via edge @c (ID 3) end end foo = Foo .new # blue in the diagram bar = Bar .new # red in the diagram 例えばこういうコードが存在したときにObject Shapesは以下のようなツリー構造を構築します。 インスタンス変数の遷移をツリー状に構築することで、同じ遷移をするクラスはキャッシュを利用できます。別のクラスをnewしたときにも元のShapesの差分のみ作れば良いというわけです。 class Hoge def initialize # Currently this instance is the root shape (ID 0) @a = 1 # Transitions to the next shape via edge named @a @b = 2 # Transitions to next shape via edge named @b end end class Fuga < Hoge ; end hoge = Hoge .new fuga = Fuga .new 現在はクラスをキャッシュキーとして使用しており、その場合はこのコードではキャッシュヒットさせることはできません。Object Shapesを導入することでクラス依存しないキャッシュを実現し、キャッシュヒット率を上げることができます。 複雑そうですが、効果がありそうなパフォーマンスチューニングです。キャッシュの構造や実際にどれくらいパフォーマンスが改善するのか今後もウォッチしていこうと思います。 Method-based JIT compilation by transpiling to Julia speakerdeck.com 近 です。@KentaMurata氏のメソッドベースのJust-In-Timeコンパイルへの新しいアプローチとして、インフラストラクチャにJulia言語を使用した背景と仕組み、特徴についてのお話を紹介します。Numo::NArrayやRed Arrowを使えば大きな数値計算が出来つつありますが、MJITやYJITが利用できてもあまり高速ではないという問題があるとのことでした。 理由として、これらのJITコンパイラがRubyの全てのセマンティクスを保持するためです。Rubyでは全てのメソッドが再定義可能で、再定義されたメソッドは直ちにコード実行に影響を与えます。例えば、以下のようなループの途中でも、injectメソッドや+演算子(メソッド)が再定義されていないかの確認が毎回行われます。 s = ( 1 .. 10 ).inject { |a, x| a + x } 以上の特徴によって高速性が失われていましたが、数値計算の場合このRubyの動的性は数値計算アルゴリズムでは殆ど無意味なので、これを無視して計算を最適化できるほうが良いのでは? というのがこのセッションの議題になります。 しかし、現在ではこのような最適化を行うためには、アルゴリズムをCの拡張ライブラリに書き換える必要があります。これをせずに、高速化を行う手段として挙げられたのがJuliaでした。Juliaはデータ処理や数値計算に向いていて高速な言語という特徴があります。 ここで、比較としてPythonの世界での解決策であるNumbaというライブラリの紹介がありました。NumbaはCPython用のJITコンパイラであり、PythonとNumPyのコードの一部を高速な機械語に変換します。もう少し具体的にコンパイルの流れを書きます。 CPythonのバイトコードを解析 NumbaIRを生成して書き換え 型を推論 型付IRに書き換え 自動並列化の実行 LLVM IRの生成 ネイティブコードにコンパイル という流れで、CPythonを型付の中間表現に変換してネイティブコードを生成しています。 またNumbaには2つのモードがあります。オブジェクトモードというCPythonインタプリタのC APIを使用しCPythonの完全なセマンティクスを保持するモード。もう1つはnopythonモードというfloat64やnumpy配列などの特定のネイティブデータ型に特化した小さくて効率の良いネイティブコードを生成するモードです。ざっくり言うと処理をPython経由で行うか、CPUに直接命令するかの違いになります。 このNumbaのnopythonモードのようなものをRubyのJITコンパイラでも実現する手段として登場するのが、今回のセッションの本題であるJuliaでした。まず、RubyでNumbaライクなJITコンパイラを表現すると以下のようになります。 Rubyメソッド ASTの生成 最適化 バイトコードの生成 CRubyのバイトコード IRの生成 タイプ推論 最適化 型付けされたIR LLVM IRの生成 ネイティブコードにコンパイル これと同じようなことをやっているのが、Juliaになります(JuliaはNumbaともだいたい同じ機構が動いています) Juliaコード ASTの生成 Julia AST タイプ推論 IRの生成 Julia typed IR LLVM IRの生成 ネイティブコードにコンパイル そこで、RubyをJuliaへトランスパイルし、それ以降をJuliaのJITコンパイラに実行してもらって高速化します。 Rubyメソッド Juliaコード Julia AST Julia typed IR LLVM IRの生成 ネイティブコードにコンパイル Juliaは最適化されたネイティブコードを生成してくれるため、高速です。この辺りの解説はセッションのスライド図と発表が大変分かりやすく、面白かったので是非資料や動画をご覧ください。 次に、RubyからJuliaへのトランスパイル方法についての紹介がありました。トランスパイルには、yadriggyというRubyメソッドのASTを構築し、構文と型をチェックするgemを使用しているとのことでした。セッションでは具体的なコードを紹介していましたが、大雑把にいうとRubyコードからASTを構築し、それを使ってJuliaコードを生成しているようでした。 Rubyコード → Ruby AST → 型チェッカーでノードに対して型付け → Juliaコード これによって、Rubyの機械への命令を最適化させています。 またRubyとJuliaで実装の違うものがいくつかあり(例ではRangeを挙げていました)これらの対応をするには自分で変換コードを書く必要があるとのことでした。 これらの高速化の実験比較として、以下の計算をしていました。 マンデルブロ集合 モンテカルロ法によるπの近似 クイックソート 畳み込み ドット積 それぞれの結果は以下のようになっていました。RubyはおそらくYJITが有効。 マンデルブロ集合 Ruby:平均3.326ms Ruby to julia:平均171.667μs モンテカルロ法によるπの近似 Ruby:平均106.368ms Ruby to julia:平均8.851ms クイックソート Ruby:平均7.551ms Ruby to julia:平均1.937ms 畳み込み(結果が複雑なので省略) ドット積 Ruby (N=10000) 平均468.608μs Ruby to julia (N=10000) 平均3.759ms Ruby to julia (N=10000, T=Float64) 平均10.651μs これらのように、一部の結果を除いて超高速に計算することが可能になるようでした。 紹介は以上になります。発表が分かりやすく、深掘りしたくなるような興味深い内容でした!Rubyの高速化についてこういう方法もあるんだなと、とても学びが多かったです。RubyKaigiではこのような発表がいくつもあり、すごくワクワクしました。 スポンサー 今年も2019年に続いてスポンサーブースを出展しました。 こちらは今回のために作成したノベルティたち。すごくかわいいですね!来てくださった方にもとてもご好評いただきました。とてもこだわって作成したので嬉しかったです。 Tシャツもデザイナーさんにデザインしていただいた魂のこもったTシャツです!普段でも全然着られるのではないでしょうか?「WEAR」アプリをインストールしていただいてる方にお配りしていたのですが、1日目でほとんどなくなってしまいました。ありがとうございます!着てください! またブースでは、会期中の3日間『エンジニアのファッション事情を大調査!』と銘打ち、毎日異なるアンケートを取っていました。 服を買うなら? 実店舗 28票 ECサイト(ZOZOTOWN) 15票 ECサイト(ZOZOTOWN以外) 9票 その他 3票 まだまだ実店舗が多いですね!次はありがたいことにZOZOTOWNでした!ありがとうございます!その他の方はご家族やパートナーの方が買っているとの声もありました。 コーディネートはどうやって決める? 己のセンスを信じる 86票 雑誌やネット 56票 その他 45票 周りの人を見る 19票 己のセンスを信じる人が多かったです。エンジニアはやはり我が道を行くのでしょうか。 コーディネートのパターン数は? 1 ~ 5 43票 着るときに考える 41票 6 ~ 10 37票 10より多い 9票 票がわかれました。ちなみに僕は着るときに考える派です。でもそんなに服がないのでいつもだいたい同じ格好になってますね。 みなさんアンケートに回答していただきありがとうございました! 我々はファッションの悩みを解決することをミッションとして掲げています。みなさんが日々どのようにファッションと向き合っているのか、いろいろお話を聞くことができました。「WEAR」に要望をくださったり、使ったことがない人にご紹介できたりしました。ファッションへのモチベーションが高い人も、そこまで高くない人もそれぞれ悩みはあると思います。「WEAR」をこれからも良いサービスにしていきます! 最後に ZOZOではセミナー・カンファレンスへの参加を支援する福利厚生があり、カンファレンス参加に関わる渡航費・宿泊費などは全て会社に負担してもらっています。ZOZOでは引き続きRubyエンジニアを募集しています。以下のリンクからぜひご応募ください。 hrmos.co また、メドピアさん、ファインディさんと合同で9/27に「After RubyKaigi 2022」を開催します。ぜひご参加いただければと思います。 findy.connpass.com おまけ 楽しんでいる様子です。Wポーズ! PdMのお二人! 昔の仕事仲間や他社の方々、ユーザーさんと交流できて楽しそうでした。 Matzさんと! 全員集合したかったですね。 RubyKaigiではおいしいご飯が食べられます! アンドパッドさんの二進数足し算RTAで弊社のjeuxd1eauが5位!なんとPdMの方です!エンジニア、本部長も敗北しました。 待ちに待ったオフラインでのカンファレンスで久しぶりに良い刺激をもらいましたし、とても楽しかったです。来年は松本市でお会いしましょう!それではまた次回。
はじめに こんにちは、ML・データ部推薦基盤ブロックの宮本( @tm73rst )です。普段は主にZOZOTOWNのホーム画面や商品ページにおいて、データ活用やレコメンド改善のプロダクトマネジメントを行っております。 近年ビックデータ社会と言われる中、データドリブンという言葉をよく耳にします。ZOZOTOWNのホーム画面は、ホーム画面の各パーツごとにViewable Impression(以降、view-impと表記)を取得できるようになったことでデータドリブンな評価や意思決定が促進されました。 本記事では特にZOZO独自のview-impの設計とview-impを用いてどのようにホーム画面を改善しているかについて紹介します。データドリブンな施策の推進を検討している方に向けて、本記事が参考になれば幸いです。 本記事におけるViewable Impressionの定義 本記事ではホーム画面のview-impの定義を「 ホーム画面の各パーツに対して、ユーザーが実際に目に触れて認識した閲覧ログ 」とします。そのため、パーツの内容を把握できない瞬間的な表示や部分的な表示はview-impの発火と見なさず、表示時間や表示面積などの発火条件を満たしたときに発火と見なします。具体的な発火条件に関してはこの後のセクションで説明します。 はじめに 本記事におけるViewable Impressionの定義 背景 ZOZOTOWNホーム画面の運用について 課題 課題解決へのアプローチ データドリブンを実現するための取り組み モジュールのKPI設計 ホーム画面におけるKPI分解とモジュールの役割 モジュールのメインKPIの決定 Viewable Impressionの設計 発火範囲 記録間隔 再記録 KPIモニタリング用の定常レポートの作成 実装上の工夫 バッファリング&リトライの設計 ログの内製化 効果 良質なモジュールの生産効率の向上 モジュールの並び順改善 今後の展望 「良いモジュール」の分析と要因追求 ユーザーセグメントの分解 ホーム画面におけるパーソナライズ強化 おわりに 背景 ZOZOTOWNホーム画面の運用について ZOZOTOWNのホーム画面はバナーや検索機能をはじめとする多くのパーツで構成されています。中でもページの大半を占めるのが「モジュール」と呼ばれるパーツになります。モジュールは「タイトル、一覧ページ、コンテンツ」の3つのパーツで構成されています。なお、タイトルの上部にサブタイトルが付くモジュールや一覧ページがないモジュールなどもあります。 モジュールは多数ありますが訴求の種類で分類すると、現状は大きく以下の4つになります。 モジュール名 説明 導線モジュール 閲覧した商品を軸としたリターゲティング系モジュール 企画モジュール トレンド商品や企画商品など企画チームが考案したモジュール 広告モジュール 広告商品用モジュール レコメンドモジュール ユーザーごとにパーソナライズされたモジュール ホーム画面では上記のモジュールを組み合わせてユーザーにさまざまな商品を提示しています。また定期的にビジネスチームと連携しながら、モジュールの並び順を調整したり別のモジュールに差し替えたりといった運用をしています。 課題 モジュールの運用の理想的なサイクルは、実際に表示したモジュールを正しく評価し、その結果を次の運用に活用することだと考えています。しかし、 これまではファッションに関する市場調査や他社の事例を参考とする定性的な意思決定に基づいたモジュールの運用をしており、定量的な意思決定に基づいたモジュールの運用はできていませんでした 。もちろん最近のファッショントレンドや他社の取り組みを把握し、それを施策として反映することは重要です。とはいえ、新たなモジュールを次々に作ったとしてもそのモジュールを正しく評価できないとモジュールの良し悪しが判断できず、モジュールの改善に繋げることができません。 定量的な意思決定に基づいたモジュールの運用ができていなかった理由として以下の3つが考えられます。 良いモジュール・悪いモジュールを定量的に判断する基準がない モジュールを評価するためのデータが足りていない 定常的にモジュールに関する数値を確認できるレポートが存在しない 上記の課題を解決することでデータドリブンな評価や意思決定に繋がると考え、各課題に対して以下のアプローチをとりました。 課題解決へのアプローチ 前述した課題を解決するためのアプローチは以下の3つです。 モジュールの良し悪しを評価するKPIの設計 モジュールのKPIに必要なview-impの設計 KPIモニタリング用の定常レポートの作成 それぞれの取り組みについて具体的に紹介していきます。 データドリブンを実現するための取り組み モジュールのKPI設計 ホーム画面におけるKPI分解とモジュールの役割 ZOZOTOWN全体ではGMVをKGIとしており、またホーム画面では以下の3つをメインKPIとしています。 ホーム画面のメインKPI 説明 ホーム画面経由の受注金額 ホーム画面に表示されている商品をクリックしたセッション内での受注金額 ホーム画面ランディングセッション直帰率 ホーム画面の直帰セッション数÷ホーム画面ランディングセッション数 コンテンツクリックユーザーあたりホーム画面経由の受注金額 ホーム画面経由の受注金額÷コンテンツクリックユーザー数 ホーム画面のメインKPIをモジュールのメインKPIに分解したいのですが、モジュール単位で考える上での注意点が3つあります。 「ホーム画面ランディングセッション直帰率」などホーム画面に関するKPIをそのままモジュールの評価に使用した場合、並び順が異なるモジュール間での比較を正確に行えない 定期的にモジュールは変更されるため、収集できるデータ量の少ない「購入」や「カート追加」などの指標はサンプル数不足になる可能性がある コンテンツには記事やショップページなど商品以外のパターンも存在する このため、単純にホーム画面のメインKPIをモジュールのメインKPIに分解してしまうと、モジュールの評価を正確に行えない可能性があります。例えば「モジュール経由の受注金額」をメインKPIとすると、並び順の異なるモジュール間の比較ができなくなってしまいます。またサンプル数不足によって統計的な判断が行えない場合もあります。 そこで正確なモジュールの評価ができるKPIを設計するために、モジュールの役割を考えました。モジュールはホーム画面の大部分を占めており、コンテンツや一覧ページなどを通じて他のページへの導線が多数存在します。したがってモジュールの役割は「 ユーザーにモジュールを通じてZOZOTOWN内を回遊してもらい、探している商品や趣味嗜好に合う商品を見つけてもらうこと 」だと考えました。 モジュールのメインKPIの決定 前述した注意点と役割を踏まえて、モジュールのメインKPIを「 次ページ遷移率(以降、CTRと表記) 」としました。また、サブKPIとしてはコンテンツクリック数、「すべて見る」クリック数、カート投入数、モジュール経由の受注金額などを設定しています。 以下にメインKPIとサブKPIをまとめています。 種類 指標 メインKPI CTR サブKPI コンテンツクリック数 「すべて見る」クリック数 view-imp数 カート投入数 モジュール経由の受注金額 モジュール経由の注文商品数 コンテンツクリックあたりカート投入数 CTRの定義は以下です。 CTR = コンテンツクリック数 ÷ view-imp数 CTRはモジュールの組み合わせによる影響やポジションバイアスが存在すると想定できますが、それらを除けば単純に並び順が異なるモジュール同士を比較できる指標です。もちろん、モジュールの目的に応じてCTRではなく他の指標をメインKPIとする場合もありますが、ほとんどのモジュールはCTRで評価できます。CTRを高めることで商品ページや検索ページへの遷移が増え、ユーザーが商品の購入を検討する機会が増えます。間接的ではありますが、これは最終的にZOZOTOWN全体のKGIであるGMVに貢献できると考えています。 Viewable Impressionの設計 ここからはCTRを求める際に必要なview-impの具体的な仕様について紹介します。補足として、これ以降の話はZOZOTOWNアプリのみを対象(Web版は除外)とします。また、view-impはコンテンツやバナーなども考えられますが、本記事ではモジュールのview-impを指すことにします。 以下ではZOZOTOWNホーム画面におけるview-impの仕様を3つの項目に分けて紹介します。 発火範囲 記録間隔 再記録 発火範囲 view-impの発火範囲の条件は「 モジュールの高さが50%以上画面に表示されたとき 」としています。閾値を50%に設定している理由は以下です。 1モジュール全体の画面に占める表示領域の割合が大きいため ZOZOTOWNアプリではモジュールのコンテンツを2段組みで表示しており、上段・下段のいずれかを閲覧した場合でもモジュールを閲覧したものとして判定するため また、この条件は言い換えると「 モジュールの高さの中心が画面に表示されたとき 」と解釈できるため、実装上の複雑さを軽減している利点もあります。 以下の図はview-impの発火範囲の条件を満たしたパターン(上段3つ)と満たしていないパターン(下段2つ)の例を示しています。赤い枠の領域が画面に表示されているモジュールの領域を表しており、青いラインがモジュールの高さの50%の位置を表しています。青いラインが赤い枠内にある場合、発火範囲の条件を満たします。 発火条件を満たしているパターン 発火条件を満たしていないパターン 記録間隔 view-impは前述した発火範囲の条件に加えて、「 1秒以上モジュールが画面上に表示されていること 」も発火の条件としています。具体的には、 100ms間隔でモジュールの高さの中心が画面に表示されているか確認し、10回連続で観測できればview-impのログとして記録 します。記録間隔を100msとしている理由は、「デバイスでの計算処理の負荷を軽減するため」と「 人間の反応時間 (人間が事象を認知するまでの時間)は最小で100msであり、100msより短い間隔でデータを収集する必要はないため」です。この記録方法によって静止状態だけでなくスクロール中も集計できます。 再記録 再記録とは1度view-impのログとして記録されたモジュールが再度記録されることを指します。なお、view-impの発火範囲内であればスクロールしても再記録されません。再記録されるパターンは以下の4パターンです。 ログ発火後にモジュールの高さが50%未満となり、再度50%以上になった時 ホーム画面内でのモールタブ・性別タブの切り替え時 バックグラウンドからの復帰時 別画面への遷移からのページバック時 KPIモニタリング用の定常レポートの作成 モジュールの運用を改善していく上で、モジュールのKPIを定常的にモニタリングする必要があります。今回はモジュールのKPIをモニタリングする環境として、Google社が提供するクラウド型BIツールである データポータル を採用しました。データポータルは弊社でデータ基盤として使用しているBigQueryとの親和性が高く、ビジネスチームへの展開が容易といった点から、弊チームのモニタリング環境として利用されることが多いプロダクトです。 実装上の工夫 バッファリング&リトライの設計 アプリの通信とパフォーマンスを考慮し、リアルタイムでログを送信するのではなく、 バッファを利用することで送信回数を減らしました 。バッファの送信タイミングの仕様は以下です。 アプリの起動時に送信 120秒間隔で送信 ローカルに保持したログが100件到達時に送信 もし送信に失敗した場合は次回の送信時にリトライとして送信します。ただし、送信失敗時にログが100件以上の場合は120秒後の送信タイミングまで送信されません。 また 端末の容量を圧迫しないように蓄積するログのハードリミットを1000件としました 。ハードリミットに達した場合、1001件目以降のログは破棄され、その破棄件数をログとして記録しています。 ログの内製化 ここ最近で内製のデータ基盤が構築され、このデータ基盤を通じて社内の様々なデータがBigQueryに蓄積されています。この影響でログの内製化が加速し、今回紹介したモジュールのview-impはHOME画面において内製で実装した初めてのview-impでした。 今後の内製ログの拡大を見越して、他のview-impの実装で再利用できるように、ログ送信方法を汎用的に設計しました。また、デバッグ時に通知やトーストなどで送信したログを表示する仕組みを作成し、確認テストを行い易いようにしました。 効果 良質なモジュールの生産効率の向上 定常的にモジュールのKPIを確認できるようになったことで、モジュールの良し悪しを素早く判断できるようになりました。以下のテーブルは定常レポートにおけるモジュールごとのKPIの一部を表示しています。 このように定常レポートから日々指標を確認しながらモジュールの良し悪しを判断し、次のモジュールリリースの改善に努めています。また、データがたくさん集まってくると良いモジュール・悪いモジュールの傾向も掴みやすくなり、良質なモジュールの生産効率の向上が期待できます。 モジュールの並び順改善 ここではview-impによるCTRの導入によって、異なる並び順のモジュールを比較できるようになった一例を紹介します。以下のグラフはある特定期間におけるモジュールの並び順と商品クリック数・CTRの関係を図示した棒グラフです。両グラフとも横軸は「ホーム画面の上部から下部にかけてのモジュールの並び順」を表しています。縦軸はそれぞれ対象期間における「商品クリック数の合算値」と「商品クリック数の合算値とview-imp数の合算値から算出したCTR」を表しています。 CTRのグラフから、3番目のモジュールのCTRが他のモジュールと比較して低いことがわかります。3番目のモジュールのCTRが低いとわかれば、他のモジュールとの差し替えや後方への位置変更によって並び順を改善できます。商品クリック数でこの判断をすることは難しく、CTRを導入したことで正確な並び順の評価ができるようになりました。 今後の展望 「良いモジュール」の分析と要因追求 良いモジュールと言われるモジュールには必ずその理由があります。「タイトル・サブタイトルがユーザーの目を引くものだった」や「表示しているコンテンツがユーザーの趣味嗜好に合っていた」など様々な要因が考えられます。特にコンテンツに関しては横スクロールをしないと全てのコンテンツを確認できなかったり表示数が限られていたりするため、コンテンツの種類や並び順によってユーザーがモジュールに持つ印象に影響を与えることが予想されます。 ログの内製化の促進により、各コンテンツのview-impやクリック数も取得できるようになりました。そのため、今後はコンテンツに関わるデータも使用して良いモジュールの要因を探り、さらなるモジュールの改善に努めていきたいと考えています。 ユーザーセグメントの分解 良いモジュールと一口に言ってもユーザーのセグメントによって好まれるモジュールの傾向は変わります。一例として、最近の分析ではZOZOTOWNでの購入経験の有無で好まれるモジュールの傾向に違いがあることがわかってきました。購入経験のないユーザーは、タイトルやサブタイトルに「季節」関連の単語を含むモジュールやタイムセールモジュールを好む傾向があります。また、購入経験のあるユーザーはタイトルやサブタイトルに「新作」や「限定」などの単語を含むモジュールやクーポン関連のモジュールを好む傾向があります。 このようにユーザーのセグメントごとにモジュールの傾向が見つかれば、そのセグメントごとにモジュールを出し分けすることでよりユーザーに適したコンテンツを提供できます。ZOZOTOWNのホーム画面でもこれらを実現するために、今後は「セグメントごとのモジュールの傾向分析」と「セグメントごとにモジュールを出し分けできる機能開発」を進めていきたいと考えています。 ホーム画面におけるパーソナライズ強化 多種多様なデータが集まると、そのデータを最大限活用してホーム画面を良くしていきたいものです。ホーム画面の改善案の1つとして、パーソナライズ化があります。ホーム画面に訪れるユーザーは、探している商品や好みのブランド・ショップがそれぞれ異なるため、同じ商品を訴求するよりもユーザーによってパーソナライズするほうが良い推薦と言えるでしょう。特にホーム画面はサイトの顔でもあるため、ユーザーがホーム画面に訪れた際、そのユーザーの探している商品を正しく訴求できれば機会損失の防止に繋がると思います。 直近ではパーソナライズモジュールの作成に注力しており、第一弾として2022年4月にリリースしました。こちらのパーソナライズモジュールの詳細は以下の記事にまとまっているため、ご興味のある方はご覧ください。 techblog.zozo.com ただし、こちらのパーソナライズモジュールは開発スピードを優先してルールベースの簡易的なロジックを利用しています。また、開発当初はログの内製化が開始されたばかりで、view-impなどのログは存在していませんでした。そのため、現在のパーソナライズモジュールはまだまだ改善の余地がある状態です。この改善に向けて、view-impなどのモジュールに関するデータを活用した機械学習モデルのパーソナライズモジュールを絶賛開発中です。 ホーム画面におけるパーソナライズ化の道は果てしないですが、ユーザーが探している商品を1つでも多く見つけてもらえるようなホーム画面にしていきたいと考えています。 おわりに 本記事ではZOZOTOWNのホーム画面におけるデータドリブンな取り組みについて紹介しました。今後の展望に挙げたように、これからさらにデータの活用やレコメンドの改善を進めていきます。ZOZOではこのような取り組みを一緒に進めていただける仲間を募集中です。ご興味のある方は、以下のリンクから是非ご応募ください! hrmos.co hrmos.co また、カジュアル面談も随時実施中です。是非ご応募ください! hrmos.co
こんにちは、WEARバックエンドブロックの天春( @AmagA001 )です。バックエンドの運用・開発に携わっています。WEARはサービス開始から10年ほどの古いVBScriptを使った環境からRuby on Rails環境にシステムリプレイスを行なっています。本記事では、リプレイスの中でも既存環境が複雑で問題や課題が多くあったPUSH通知システムのリプレイスについてご紹介します。 目次 目次 PUSH通知システムとは リプレイス前のPUSH通知システム リプレイス前のPUSH通知システムの問題点 通知送信バッチのスケールアウトが出来ない 障害対応・運用が難しい状況 複数の開発言語による運用・改修コストが高い ステージング環境で通知確認ができない リプレイスの背景 リプレイス後のPUSH通知システム 非同期システム・EKS導入 既存システムの問題解決 バッチのスケールアウトが出来ない 障害対応・運用が難しい状況 複数の開発言語による改修コストが高い ステージング環境で通知確認ができない その他 ローカル開発環境 Sidekiqについて Sidekiqダッシュボードの再実行とデッド状態について Sidekiqキューについて ローカルDynamoDB環境 Dynamoid 現在の状況 今後の課題 最後に PUSH通知システムとは WEARアプリにPUSH通知を配信するために構築しているシステムのことを呼んでいます。PUSH通知は次の2種類が存在します。 1:1通知:一人のユーザーに対して1回だけ送る通知 1:N通知:同じ通知を同時に複数のユーザーに対して送る通知 リプレイス前のPUSH通知システム リプレイス前のPUSH通知システムはオンプレミスのMicrosoft SQL Server、.NET FrameworkとAWSのサービスで構成されています。AWSのサービスはEC2、DynamoDB、API Gateway、Lambda、SNS、SQSが使われています。開発言語はVBScript、C#、Golang、Pythonです。 1:1用通知処理バッチ(Golang)と1:N用通知処理バッチ(C#)がWindowsバッチサーバーのタスクスケジューラに登録されて定期的に通知配信API(Python)経由で通知を配信していました。通知サービスはAWSのSNS経由でAPNsとFCMを使っていました。 リプレイス前のPUSH通知システムの問題点 通知送信バッチのスケールアウトが出来ない 通知送信バッチはスケールアウトが考慮されてなく決まった時間に通知を送る仕組みになっていたので1:Nの通知の場合、通知が多い時は1日以上の遅延が発生している状態でした。 障害対応・運用が難しい状況 障害・エラーが発生した場合、開発当時の資料と開発メンバーの不在、必要なログデータの不足により原因特定・障害対応・運用に時間がかかりました。原因が判明して修正できたとしても影響範囲が特定できないこととテスト環境がないことも問題でした。 複数の開発言語による運用・改修コストが高い APIとバッチの改修のためにはVBScript、C#、Golang、Python、シェルスクリプトの修正が必要になるため、関連言語の学習コスト発生や経験者が必要になりました。 ステージング環境で通知確認ができない ステージング環境でバッチが動いてない状態だったので通知の改修や追加時にQAテストが出来ず、本番環境で動作確認するしかない状況でした。 リプレイスの背景 WEARサービスにコーディネート動画 1 やフリマ機能 2 の追加により新規通知を追加する必要がありました。既存システムを改修する方法もありましたが、既存システムが複雑すぎて障害・エラー発生時に原因調査・対応が難しい状況だったのでリプレイスを選びました。 リプレイス後のPUSH通知システム 非同期システム・EKS導入 Ruby on Rails環境では ActiveJob を使うことでキューイングライブラリを気にせずジョブの作成、キュー登録、実行が可能です。キューイングライブラリはジョブをキューに登録して非同期でジョブを実行できるライブラリのことです。Railsガイド 3 にも書かれているSidekiq、Resque、Delayed Jobを対象に検討しました。結果、Sidekiqがマルチスレッド対応で大量のジョブ処理に向いていることとメモリあたりのパフォーマンスがいいことでした。WEARには同時に200万回以上の大量通知が発生することもあるのでSidekiqを選定しました。チームメンバーにSidekiqの経験者がいたことも1つの理由でした。 既存配信バッチはMicrosoft SQL Serverのテーブルをキューとして利用してテーブルから通知対象を取得して通知配信後、通知一覧に必要なデータをDynamoDBに登録していました。リプレイス後はバッチをなくしてMicrosoft SQL Serverを使わずに非同期ジョブを利用して直接DynamoDBに通知一覧データを登録しています。 EKS の導入により拡張可能なシステムになったのも大きな変化です。通知配信サービスは Firebase Cloud Messaging(FCM) だけを使うようにしました。 既存システムの問題解決 バッチのスケールアウトが出来ない EKS導入により負荷が多い時の非同期ジョブ処理(Sidekiq)のスケールアウトが可能になりました。オートスケールは KubernetesのHPA(Horizontal Pod Autoscaler) を使っています。 障害対応・運用が難しい状況 エラー検知と障害検知についてはエラーログをSlackに通知することで解消しました。また、Sidekiqのダッシュボード機能によりエラー確認・ジョブ再実行が簡単にできるようになったので運用も楽になりました。 複数の開発言語による改修コストが高い C#・Golangのバッチ処理をやめてRuby on Railsに非同期システムを導入したことで開発言語はRubyだけになりました。 ステージング環境で通知確認ができない 非同期システムのステージング環境を構築したのでQA時に通知確認ができるようになりました。 その他 その他考慮したのは緊急度が高い通知は「critical」キューから配信、既存通知は「default」キューから配信しています。遅延が発生しても問題ない1:Nの通知は「multi」キューに分けることで緊急度・優先度が高い通知に遅延が起きないように考慮しています。これらのキューは処理の性質や負荷が異なるので、キュー単位でReplica数やリソース割り当てができるようにKubernetesのDeploymentを用意しました。Deploymentの定義には以下のようにSidekiq起動時キューを指定しています。 spec : serviceAccountName : sidekiq shareProcessNamespace : true containers : - name : sidekiq-critical imagePullPolicy : Always command : [ "bundle" , "exec" ] args :     - | sidekiq \ --verbose \ --queue critical \ --pidfile ./tmp/pids/sidekiq.pid lifecycle : preStop : exec : command : [ "/bin/bash" , "-c" , "SIDEKIQ_PID=$(ps aux | grep sidekiq | grep busy | awk '{ print $2 }'); kill -SIGTSTP $SIDEKIQ_PID" , ] EKSについての詳細は以前WEAR部SREチームから公開した記事を参考にしてください。 techblog.zozo.com ローカル開発環境 docker-compose を利用してローカル開発環境から Redis , Sidekiq , dynamodb-local , dynamodb-admin を使っています。 redis : image : "redis:6.0.16" ports : - "6379:6379" volumes : - "./db/redis:/data" sidekiq : depends_on : - redis links : - redis build : context : . args : - SIDEKIQ_PRO_CREDENTIALS=${SIDEKIQ_PRO_CREDENTIALS} dockerfile : dockerfiles/Dockerfile.app command : bundle exec sidekiq -C config/sidekiq.yml environment : REDIS_URL : redis://redis:6379 dynamodb-local : container_name : dynamodb-local image : amazon/dynamodb-local:1.17.0 user : root command : -jar DynamoDBLocal.jar -sharedDb -dbPath /data volumes : - "./db/dynamodb:/data" ports : - 8001:8000 networks : - dynamodb-local-network dynamodb-admin : container_name : dynamodb-admin image : aaronshaf/dynamodb-admin:latest environment : - DYNAMO_ENDPOINT=dynamodb-local:8000 ports : - 8002:8001 depends_on : - dynamodb-local networks : - dynamodb-local-network Sidekiqについて APMを導入したい場合Sidekiq Proを使う必要があるため、WEARではSidekiq Proを導入しました。データを失うことなくネットワークの問題に耐える機能とジョブのバッチ処理ができるのも導入した理由です。 Sidekiqにはキューの状態確認やジョブの管理できるダッシュボード機能があり、簡単にWebサイトへ追加できます。Railsにダッシュボードを追加したい場合は こちら を参考にしてください。追加する際にSidekiqへの管理画面のアクセス制限追加も忘れないでください。 Basic認証 と Devise を使った方法、 セッション を使う方法があります。本記事では詳細なコードは省略します。WEARでは管理サイトがあるので管理サイトのメニューに追加して使用しています。 Sidekiqダッシュボードの再実行とデッド状態について Sidekiqジョブは約21日間で25回、再試行します。その時間内にバグ修正をデプロイすると再試行され正常に処理されます。25回後にも再実行できないジョブについては手動の介入が必要になると想定してそのジョブはデッド状態になります。WEARではデッド状態の監視のため、以下のように再実行設定とデッド状態時にSlackへメッセージを送っています。 Sidekiq .configure_server do |config| config.redis = { url : redis_url } config.death_handlers << ->(job, ex) do message = " エラー: #{ ex.message } . " params = { channel : slack_channel, username : ' Sidekiq ' , attachments : [ { fallback : message, pretext : ' Job 再実行失敗 ' , color : ' #D00000 ' , title : " class: #{ job[ ' class ' ] } , job_id: #{ job[ ' jid ' ] }" , title_link : " /sidekiq/morgue " , fields : [ { title : ' detail ' , value : message } ] } ] } Slack :: Message .send( webhook_url : slack_webhook_url, params : params) end end Sidekiq .default_worker_options[ ' retry ' ] = 3 Sidekiqのエラー処理方法や再実行ルールの詳細については こちら を参考にしてください。 Sidekiqキューについて SidekiqはRedisで「default」と呼ばれる単一のキューを使用します。複数のキューを使用する場合は、Sidekiqコマンドの引数として指定するか、Sidekiq構成ファイルで設定できます。各キューは、オプションの重みを追加できて重みが2のキューは、重みが1のキューの2倍の頻度でチェックされます。以下の「- [キュー名、重み]」という書式がそれに該当します。 # config/sidekiq.yml :concurrency : 25 :pidfile : ./tmp/pids/sidekiq.pid :queues : - [critical, 3 ] - [default, 2 ] - [multi, 1 ] 以下のコマンドでSidekiqデーモンを起動できます。 $bundle exec sidekiq -C config/sidekiq.yml ジョブを実行する時に以下のようにキューを指定して非同期ジョブを実行しています。 class ExampleJob < ActiveJob :: Base # Defaultキュー設定 queue_as :default def perform (*args) # ジョブ実装 end end ExampleJobJob .set( queue : :default ).perform_later Sidekiqについて説明している YouTubeのチャンネル もあるので参考になると思います。 ローカルDynamoDB環境 ローカル環境でAWSのDynamoDBを接続したくなかったのでdynamodb-local, dynamodb-adminを使ってAWS環境と同じ環境で開発・テストができるようにしました。dynamodb-adminはローカル環境のサイトからDynamoDBに直接データ登録・編集・検索ができるので便利です。 Dynamoid Dynamoid はRuby on Railsで動作するDynamoDBのO/Rマッパーです。Dynamoidを使うとActiveRecordのようにモデルを定義してそのモデルに対するデータの読み書きができます。Gemfileにdynamoidを追加するだけで簡単にDynamoDBのCRUDが可能になるので便利です。 gem ' dynamoid ' class Dynamodb :: Example include Dynamoid :: Document table name : :example , key : :id field :id , :integer ... end Dynamoidのテスト設定は DynamoidのGitHubリポジトリ を参考にしてください。 現在の状況 WEARのPUSH通知システムリプレイスは2段階にフェーズを分割して実施しています。フェーズ1は既に完了しコーディネート動画やフリマ機能に必要だった新規通知については新しいシステムで問題なく運用中です。 フェーズ2は現在進行中で既存通知のリプレイスと1:N通知を改善する予定です。フェーズ2の話も次回のテックブログで書く予定なのでご参考になればと思います。 今後の課題 大量の1:N通知についての負荷検証・対応と非同期ジョブのバッチ処理が今度の課題になるかと思います。 FCMの複数のデバイスにメッセージ送信機能 と Sidekiqのバッチ を利用して大量の1:N通知の対応を検討しています。既存通知のリプレイスと不要になった既存通知システムの廃止作業も今後の課題です。 最後に 本記事ではPUSH通知リプレイスフェーズ1を紹介しました。個人的にRuby on Railsに非同期システムを導入した経験がなかったので導入の事例・必要なライブラリ・ツール、開発環境についてとても悩みました。同じ悩みを抱えている非同期システムの導入を検討している方や未経験の方の参考になれば幸いです。 WEARではまだリプレイスを必要とされる機能がたくさん残っています。サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co コーディネート動画 機能はアプリでコーディネート動画を投稿できる機能です。 ↩ フリマ機能 はPayPayフリマを連動してコーディネート着用アイテムをフリマに出品・購入できる機能です。 ↩ Railsガイド ↩
はじめに こんにちは、技術本部ML・データ部MLOpsブロックの鹿山( @Ash_Kayamin )です。先日、20個の開発環境APIを用意し、各APIをリクエストに応じて動的に起動できる仕組みをKnative Servingを用いて構築しました。 この記事ではKnative Servingを利用した背景と、利用方法、はまりどころ、利用によって得られたコスト削減効果についてご紹介します。なお、今回はKubernetesクラスタのバージョンとの互換性の都合でKnative v1.3.1 を利用しました。2022/9現在の最新バージョンは v1.7.1 になりますのでご注意ください。 目次 はじめに 目次 課題:20個の異なる開発環境APIを低コストで提供したい 解決策:Knative Servingを用いて、リクエストに応じて動的にAPIサーバーを起動する仕組みを導入する Google Cloud上でAPIコンテナを動的に起動する方法の比較 Cloud Run Cloud Run For Anthos Knative Serving Knative Servingとは Knative Servingの主要なコンポーネント Network layer Kourierの主要なコンポーネント Knative Servingを用いて実際にAPIを動かす 手順1. Knative Serving、KourierをGKEクラスタへインストールする 導入で躓いた点 Kustomizeを利用する場合はserving-crds.yamlを個別にapplyする必要はない KourierのカスタムコントローラーはNamespace: knative-servingに作成する必要がある LBからのヘルスチェックに成功させるために適切なポート、 パスを設定する必要がある 手順2. カスタムドメインを設定し、適切にルーティングされることを確認する 手順3. Knative/Serviceマニフェストを既存のDeploymentマニフェストから生成する 複数環境の構築で躓いた点 カスタムリソースに対するKustomizeのpatchStrategicMergeの挙動は自ら定義する必要がある (1)Knative ServingのCRDをapply済みのKubernetesクラスタから既存のOpenAPIスキーマJSONを取得する (2)Knative/Service関連部分のスキーマを編集する (3)(2)で作成したスキーマをKustomizeで用いる どれくらい費用を削減できているのか? 今後の展望/終わりに 課題:20個の異なる開発環境APIを低コストで提供したい ZOZOTOWNには20個の開発環境が存在し、それぞれが独立して開発できるように、20環境分の独立したAPIを提供する必要がありました。MLOpsブロックがGKEクラスタ上で提供するAPIも例外ではありません。しかしながら、単純に20環境を常に起動しておくとノードの費用が嵩んでしまいます。 解決策:Knative Servingを用いて、リクエストに応じて動的にAPIサーバーを起動する仕組みを導入する 今回の開発対象は開発環境のAPIであり、高いサービスレベルは求められていません。初回リクエストの処理に時間がかかっても問題なく、リクエスト数も少ないです。また、APIサーバーはコンテナ化されており、GKEクラスタ上で動いています。これらの前提から、コンテナ化されたAPIサーバーをリクエストに応じて動的に起動する仕組みを導入して、リクエストがない時のAPIサーバー費用を削減することを検討しました。 Google Cloud上でコンテナ化したAPIサーバーを動的に起動し、APIを提供する方法には、大きく分けて Cloud Run 、 Cloud Run For Anthos 、 GKEクラスタ上でKnative Servingを動かす の3通りがあります。次に、それぞれの概要・メリット・デメリットについてご説明します。 Google Cloud上でAPIコンテナを動的に起動する方法の比較 Cloud Run Google Cloudが提供するマネージドなコンテナサービスです。後述するKnative Servingをベースに作られています。用意したエンドポイントへのリクエストに応じて事前に定義したDocker ImageからAPIサーバーを起動してリクエストを処理し、レスポンスを返すことができます。 メリット Docker Imageを用意し、起動するAPIの定義・アクセス設定等を行えば即座に利用できる HTTPリクエスト以外にもさまざまなイベントをトリガーにコンテナを起動できる ゾーン障害への冗長性がデフォルトで備わっている コンテナ、Knative Serving関連のログ、メトリクスをCloud Logging、Cloud Monitoringで簡単に取得できる TerraformでCloud Runのインスタンスを定義・デプロイできる デメリット 既存のKubernetesマニフェストからTerraform定義を生成・更新しなくてはいけない 既存のGKEクラスタとはネットワーク構成が異なり、Shared VPCとのIngress、Egress通信を可能にするための構成が追加で必要となり複雑 Cloud Run For Anthos Google Cloudが提供するマネージドなKnativeサービスです。 Anthos を利用しているGKEクラスタ上にマネージド、かつサポート付きのKnativeを構築し、Knativeが提供する機能を全て利用可能です 1 。 メリット GKEクラスタ上で他のAPIと同じように扱える Kubernetesマニフェストを用いて管理できる 同じコマンドラインツールを用いて、確認・操作ができる 既存のAPIと同じネットワーク・権限を利用できる Knative周りの挙動・エラーについてGoogle Cloudからのサポートを受けられる デメリット 有料かつクラスタ設定の変更が必要なAnthosの利用が必須 Anthos Service Mesh (マネージドなIstio)の導入が必要 Knative Serving Kubernetesオペレーターの一種である Knative の一部分であるKnative ServingをGKEクラスタ上で動かします。Knative Servingの機能を用いて、用意したエンドポイントへのリクエストに応じて事前に作成したDocker ImageからAPIサーバーを起動してリクエストを処理し、レスポンスを返します。 メリット GKEクラスタ上で他のAPIと同じように扱える Kubernetesマニフェストを用いて管理できる 同じコマンドラインツールを用いて、確認・操作ができる 既存のAPIと同じネットワーク・権限を利用できる Knative ServiceのNetwork layerを自由に選択できる(Istio、Contour、Kourier) デメリット Knative Servingのインストール・運用・バージョンアップを自前で行わなくてはいけない Knative Servingのメトリクスを取得するためには、追加のセットアップをしてCloud Monitoringにメトリクスを送る等する必要がある 今回は次の理由からGKEクラスタ上でKnative Servingを動かす方針としました。 既存APIと同じGKEクラスタ上で動かすことによって、ネットワークや権限周りの構成、Kubernetesマニフェストを共通化して認知負荷を下げたい => Knative Serving,Cloud Run for Anthosの優先度が上がる 有料かつクラスタ設定の変更が必要なAnthosや、導入・運用コストが高いIstioを利用したい強い理由がない => Cloud Run for Anthosの優先度は下がる 提供する開発環境APIでは運用初期からの高いサービスレベルは求められていない => Cloud Run、Cloud Run for Anthosの高いサービスレベルは不要で、Knative Serving自前運用のリスクは許容できる Knative Servingとは Knavite Serving は Knative を構成するコンポーネントの1つです。KnativeはKubernetes上でのServerless、 Event drivenなアプリケーションの構築をサポートするKubernetesオペレーターです。Knativeは Serving 、 Eventing の2つのコンポーネントから構成されます。 ServingはKuberentes上でのServerless Containerの実現をサポートします。Serverless Containerとは何らかのイベント(HTTPリクエスト等)に応じてコンテナ化されたアプリケーションを0台の状態から起動、必要に応じて複数台にスケールし、処理を行う環境のことを指します。 ServingはServerless Container実現に必要なネットワーク周りの設定や処理、0台からのコンテナのオートスケール、コンテナのバージョン管理周りの処理を自動化してくれます。これによってユーザーは簡単にKubernetes上にServerless Containerを利用したサービスを実現できます。 EventingはKubernetes上でのHTTPリクエストを用いたイベントのPub/Subの実現をサポートします。Serving、Eventingの具体的な活用例については公式に 構成図付きのサンプル が用意されているのでそちらをご覧ください。 今回、HTTPリクエストを受けて、リクエストのホスト名に応じたAPIサーバーを0台の状態から起動、または複数台にスケールし、処理を行ってレスポンスを返す仕組みを実現するために、Knative Servingを利用しました。 以下の図は、Knative Servingを用いて実現した環境を図解したものです。 Knative Servingの主要なコンポーネント Knative Servingには代表的なカスタムリソースとしてService・Route・Configuration・Revision・Ingressが存在します。それぞれの役割を以下に示します。Serviceリソースを作成すると、その他のリソースはKnative Servingのカスタムコントローラーによって自動的に生成されます。Serviceリソースを通じて各種機能を利用するのが基本ですが、個別にConfiguration、 Route等を定義して挙動を制御することも可能です。 Service Servingでコンテナを起動し、コンテナへのルーティングを実現するために必要な要素を抽象化したカスタムリソース(以降Knative/Serviceと呼びます) Knative/Serviceが作成されると、KnativeのカスタムコントローラーがKnative/Serviceで定義された情報に従って、Configuration・Revision・Route・Ingressを作成します Configuration 最新のRevisionの定義を保持します Revision ある時点のConfigurationを記録するスナップショットであり、Configurationが新規作成・更新される場合にConfigurationで定義された情報から生成されます KnativeのカスタムコントローラーはRevisionで定義された情報に従って、Deployment等を作成します Route リクエストをどのRevisionから生成されるPodにルーティングするのかを管理します Routeから、後述するNetwork layerの設定を抽象化したカスタムリソース Ingress が生成されます Network layerには複数の実装の選択肢(Istio、Contour、Kourier)があり、どの実装で利用するIngressを生成するかをCofigMapで指定します Network layerでは生成されたIngressリソースをもとに、ルーティングを設定します Ingress Kubernetes標準のIngressリソースをKnative用に拡張したカスタムリソース(以降Knative/Ingressと呼びます) Network layerから参照され、Knative Servingでのリクエストのルーティングを実現するための情報を提供します Red Hatさんのブログ記事、 あらためてKnative入門!(Knative Servingやや発展編) で図付きのわかりやすい解説があるのでぜひこちらもご参照ください。 Network layer Knative Servingで、受けたリクエストのコンテナまでのルーティングを実現するのがNetwork layerになります。Network layerには Istio 、 Contour 、 Kourier の3つの選択肢があります。どれを選択した場合でもEnvoyを用いてルーティングを実現することには変わりはありません。Routeから生成されたKnative/Ingressの情報を元にNetwork layerがEnvoyコンテナを起動・設定・更新することで、ルーティングを実現します。 今回、Network layerにはKourierを選択しました。IstioやContourを利用する場合は、それらのカスタムリソース・カスタムコントローラーをKubernetesクラスタにインストールする必要があります。加えて、Knative/IngressからIstioやContourのIngressを生成するカスタムコントローラー( net-istio 、 net-contour )を動かす必要があります。 他方、KourierはKnative Servingのために開発されたIngress実装であり、カスタムリソースの定義は一切必要なく、カスタムコントローラーを動かすだけで良いです。Kourierのカスタムコントローラー net-kourier はKnative/Ingressから直接Envoyの設定を生成し、Envoyコンテナに設定を反映することでルーティングを実現します。Network layerにIstio、Contourを利用する場合と比較して、Kourierを用いる構成は非常にシンプルであり、必要十分な機能を備えていたことがこの選択をした理由です。 Kourierの主要なコンポーネント Kourierの主要なコンポーネントとその挙動を以下図に示します。 カスタムコントローラーである、 Pod: net-kourier-controller がKnative/Ingressに定義されたリクエストのルーティング情報を読み取り、そのルーティングを実現するためのEnvoyの設定を生成・保持・更新します。 Pod: net-kourier-controller が保持するEnvoyの設定は、 Pod: 3scale-kourier-gateway で起動するEnvoyコンテナからEnvoyのxDS APIを用いて随時読み取られる 2 ことで、Envoyコンテナでのルーティングが設定・変更されます。 ブログ記事、 Kourier: A lightweight Knative Serving ingress に図付きのわかりやすい解説があるのでこちらもぜひご参照ください。 Knative Servingを用いて実際にAPIを動かす ここからはGKEクラスタにKnative Servingを導入し、APIを動かすための具体的な設定と3つの手順についてご説明します。 公式のYAMLファイルを用いてインストールする手順 を参考に導入しました。 最終的に構築されるのは以下のシステムになります。 Namespace: knative-serving Knative Servingのカスタムコントローラー Pod: controller や、リクエストをキューイングしたり、リクエスト対象となるPodを起動・スケールさせるための Pod: activator ・ Pod: autoscaler といった、Knative Serving関連のリソースが作成されます。またKourierのカスタムコントローラー Pod: net-kourier-controller やConfigMap等もここに作成されます。 Namespace: kourier-system クラスタ外部からのリクエストを受け付けるためのロードバランサーをGKE Ingressを用いて作成するための Ingress: kourier-internal-ingress や、リクエストをホスト等の情報に基づき、あらかじめ設定したルールに基づいてルーティングを行うEnvoyコンテナを起動する Pod: 3scale-kourier-gateway 等を作成します。 Namespace: recommendation-module APIコンテナを起動する設定を記載した Knative/Service: dev1~dev20 等を作成します。 今回の説明に必要となる主要なリソースだけ記載しており、実際にはこの図に記載した以外のリソースも数多く作成される点にご注意ください。 Cloud DNSで名前解決を行い、内部ロードバランサーに到達したリクエストは、 Pod: 3scale-kourier-gateway でホスト情報に基づきルーティングされます。ルーティングされたリクエストは Pod: activator でキューイングされ、必要に応じて Pod: autoscaler によってリクエスト対象となるPodが起動、またはオートスケールしたのちに対象となるPodに到達します。リクエスト対象のPodが起動しているか、同時に処理しているリクエストの数は設定されている同時処理数上限を上回っているか等によってルーティングの経路は変わります。より詳細は 図付きの公式ドキュメント をご参照ください。 手順1. Knative Serving、KourierをGKEクラスタへインストールする MLOpsブロックではKustomizeを用いてKubernetesマニフェストを管理しています。今回は以下のようなディレクトリ構成でKnative Serving、Kourierを導入しました。 ./ ├── base │ └── knative-serving │ ├── knative-serving │ │ ├── configmap.yaml │ │ ├── deployment.yaml │ │ └── kustomization.yaml │ ├── kourier │ │ ├── deployment.yaml │ │ ├── ingress.yaml │ │ ├── kustomization.yaml │ │ └── service.yaml │ └── kustomization.yaml └── dev └── knative-serving └── kustomization.yaml ファイルの内容は以下になります。 base/knative-serving/knative-serving/kustomization.yaml Knative公式のマニフェストファイルを取得して、一部ConfigMapにパッチを当てて適用します apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization resources : - https://github.com/knative/serving/releases/download/knative-v1.3.2/serving-core.yaml patchesStrategicMerge : - configmap.yaml - deployment.yaml base/knative-serving/knative-serving/configmap.yaml いくつかのConfigMapにパッチを当てることでKnative Servingの設定を変更します config-features ではKnative/ServiceマニフェストのPod Specの特定の項目の利用を明示的に許可しています apiVersion : v1 kind : ConfigMap metadata : name : config-network Namespace : knative-serving data : ingress.class : kourier.ingress.networking.knative.dev # IngressにはKourierを利用することを設定 autocreate-cluster-domain-claims : "true" # 各Namespace でのサブドメインの自動生成、割り当てを許可 --- apiVersion : v1 kind : ConfigMap metadata : name : config-domain Namespace : knative-serving data : example.zozo.com : | # 特定のドメインへのリクエストをどのKnative/Serviceにマッピングするかを指定 selector : run : zozo-module-recommendations-api --- apiVersion : v1 kind : ConfigMap metadata : name : config-features Namespace : knative-serving data : kubernetes.podspec-affinity : "Allowed" # Knative/ServiceマニフェストのPod SpecでのAffinityの指定を許可 kubernetes.podspec-tolerations : "Allowed" kubernetes.podspec-fieldref : "Allowed" base/knative-serving/kourier/kustomization.yaml Knative公式のマニフェストファイルを取得して、一部Serviceにパッチを当てて適用します GKE Ingressで内部Load Balancer(以下LBと記述)を作成しルーティングできるようにするため、Ingressマニフェストの追加・Serviceへのパッチ当てをしています apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization resources : - https://github.com/knative/net-kourier/releases/download/knative-v1.3.0/kourier.yaml - ./ingress.yaml patchesStrategicMerge : - ./service.yaml - ./deployment.yaml base/knative-serving/kourier/service.yaml GKE Ingressで内部LBを作成しルーティングできるようにするため、デフォルトでは type: Loadbalancer となっているところを type: Nodeport に変更しています 既存の接続先ポートに加えて、ヘルスチェックのため9000番ポートへの接続も追加しています apiVersion : v1 kind : Service metadata : name : kourier Namespace : kourier-system annotations : cloud.google.com/neg : '{"ingress": true}' cloud.google.com/backend-config : '{"default": "kourier-backend-config"}' spec : type : NodePort # GKE Ingressを利用するため、LoadBalancerからNodePortに変更 ports : # LBからのヘルスチェックを行うため、kourier-gateway (envoy container) pod がデフォルトでlistenしているポートへのルーティングを設定 - name : http-port9000 port : 9000 protocol : TCP targetPort : 9000 base/knative-serving/kourier/ingress.yaml GKE Ingressを用いて内部LBを作成するためのマニフェスト LBのヘルスチェック先として、Nodeportで追加した9000番ポートの/readyを指定しています apiVersion : networking.k8s.io/v1 kind : Ingress metadata : name : kourier-internal-ingress Namespace : kourier-system annotations : kubernetes.io/ingress.regional-static-ip-name : "kourier" # 必要なIPアドレスは事前に割り当て kubernetes.io/ingress.class : "gce-internal" spec : defaultBackend : service : name : kourier port : number : 80 --- apiVersion : cloud.google.com/v1 kind : BackendConfig metadata : name : kourier-backend-config Namespace : kourier-system spec : healthCheck : # ヘルスチェック先のパスとポートを明示的に指定 type : HTTP requestPath : /ready port : 9000 導入で躓いた点 Knative Serving、KourierをGKEクラスタへインストールする際にいくつかつまづいた点があるのでご紹介します。 Kustomizeを利用する場合はserving-crds.yamlを個別にapplyする必要はない Knative ServingをYAMLからインストールする公式の手順では、 serving-crds.yaml をapplyしたのちに、 serving-core.yaml をapplyしています。ですが、Kustomizeを用いてapplyする場合は serving-crds.yaml のapplyは不要です。Custom Resource Difinition(以下CRD)は serving-crds.yaml , serving-core.yaml 両方に定義されています。2つをまとめてapplyしようとするとKustomizeが重複を検出してエラーになります。 issueのコメント にあるように、CRDが作成されていない段階でCustom Resource(以下CR)のマニフェストを処理しようとしてエラーになることを回避するために、公式ドキュメントでは順にapplyする手順が示されています。しかしながら、Kustomizeを用いる場合は順にapplyする必要はないため、 serving-core.yaml をapplyするだけで問題ありません。 Kourierのカスタムコントローラーは Namespace: knative-serving に作成する必要がある Knative Servingを動かすクラスタはマルチテナントクラスタになっており、通信の制御・権限の分割するためにリソースを作成するNamespaceを分けています。そのため、Kustomizeでリソースを作成する際にはNamespaceを明示的に指定しています。その一環で、Kourierのリソースを作成するNamespaceをKustomizeで明示的に kourier-system と指定していたところ、リクエストをルーティングできない問題が発生しました。 Knative Servingのマニフェストをapplyすると Namespace: knative-serving が作成され、Knative Serving関連のリソースはこのNamespaceに作成されます。そしてKourierのマニフェストをapplyすると Namespace: kourier-system が作成され、以下Kourier関連のリソースはこの2つのNamespaceに作成されます。(今回のエラーに関連するもののみ明示しています) Namespace: knative-serving Deployment: net-kourier-controller : Knative/IngressからEnvoy設定ファイルを生成したりするKourierのカスタムコントローラーのDeployment Service: net-kourier-controller : net-kourier-controller へルーティングするClusterIP Namespace: kourier-system Deployment: 3scale-kourier-gateway : Knative ServingでKourierを利用する場合に、リクエストのルーティングを担うEnvoyコンテナのDeployment ConfigMap: kourier-bootstrap : 3scale-kourier-gateway で起動するEnvoyコンテナの初期設定を含むConfigMap Kourierは kourier-system にEnvoyコンテナを起動する Deployment: 3scale-kourier-gateway を作成します。合わせて作成される ConfigMap: kourier-bootstrap にEnvoyの初期設定が定義されており、こちらがEnvoyコンテナ起動時にマウントされて利用されます。Envoyには外部から動的に設定を読み込んで反映する仕組みがあります。Knative Servingでのルーティングの設定変更にはxDS API(KourierではそのうちのgRPCを利用)を用いて外部から設定を取得する仕組みが用いられています。この設定取得先は ConfigMap: kourier-bootstrap で指定されています。 ConfigMap: kourier-bootstrap に記載の、Envoyの設定を取得する先 dynamic_resources として指定されている xds_clustrer の address の値には net-kourier-controller.knative-serving が指定されています。したがってEnvoyコンテナは Namespace: knative-serving の Service: net-kourier-controller で名前解決される先からEnvoyの設定を取得しようとします。 Kourierのマニフェストで作成されるリソースの作成先NamespaceをKustomizeで一律に kourier-system としてしまうと、 Namespace: knative-serving には Deployment: net-kourier-controller 、 Service: net-kourier-controller が作成されません。結果、Envoyコンテナでの設定取得先の名前解決に失敗してしまいます。そのため、何かしらのKnative/Serviceを作成しても、Knative/Serviceから作成されるPodへのルーティング設定はEnvoyコンテナに反映されず、リクエストの適切なルーティングができなくなっていました。 最終的にはデフォルトのKourierのマニフェスト通り、 Namespace: knative-serving に Deployment: net-kourier-controller 、 Service: net-kourier-controller を作成してエラーを解消しました。 Envoyコンテナのエラーログ抜粋 - gRPC周りの部分でエラーが発生していることが分かるのでこのエラーを起点に調査をしました。 [ 2022-07-14 07:19:22. 380 ][ 1 ][ warning ][ config ] [ bazel-out/k8-opt/bin/source/common/config/_virtual_includes/grpc_stream_lib/common/config/grpc_stream.h:63 ] Unable to establish new stream [ 2022-07-14 07:19:34. 345 ][ 1 ][ warning ][ config ] [ bazel-out/k8-opt/bin/source/common/config/_virtual_includes/grpc_stream_lib/common/config/grpc_stream.h:101 ] StreamAggregatedResources gRPC config stream closed: 14 , no healthy upstream ConfigMap: kourier-bootstrap のマニフェスト関連部分抜粋 apiVersion : v1 kind : ConfigMap metadata : name : kourier-bootstrap Namespace : kourier-system ~~~ data : envoy-bootstrap.yaml : | dynamic_resources : ads_config : transport_api_version : V3 api_type : GRPC rate_limit_settings : {} grpc_services : - envoy_grpc : { cluster_name : xds_cluster } cds_config : resource_api_version : V3 ads : {} lds_config : resource_api_version : V3 ads : {} ~~~ clusters : ~~~ - name : xds_cluster connect_timeout : 1s type : strict_dns load_assignment : cluster_name : xds_cluster endpoints : lb_endpoints : endpoint : address : socket_address : address : "net-kourier-controller.knative-serving" # 設定取得先のドメイン(${Service名}.${Namespace名})が指定されている port_value : 18000 http2_protocol_options : {} type : STRICT_DNS ~~~ LBからのヘルスチェックに成功させるために適切なポート、 パスを設定する必要がある Kourierの公式YAMLで作成される Service: kourier は type: Loadbalancer であり、作成されるTCP/UDPロードバランサーでは各ノードで起動するkube-proxyに対してヘルスチェックを行います。 一方、GKE Ingressを利用してHTTP(S)ロードバランサーを作成する場合は、 type: Nodeport なServiceで接続する先のPodに対してヘルスチェックを行うため、Podにヘルスチェックのエンドポイントを用意する必要があります。そのため、KourierでGKE Ingressを利用するためには Service: kourier を type: Nodeport に単純に変更するだけでは駄目で、Service接続先のEnvoyコンテナにヘルスチェックエンドポイントを用意する必要がありました。 Envoyにはヘルスチェックのエンドポイントとして GET /ready が存在し、 Deployment: 3scale-kourier-gateway のreadinessProbe ではこちらを利用しています。このエンドポイントへのリクエストにはヘッダー Host: internalkourier を付与する必要があります。しかしながら2022/9現在、BackendConfigを用いたGKE Ingressのヘルスチェック定義では、ヘルスチェックのHTTPリクエストにカスタムヘッダーを付与できません。GCLBに設定できるヘルスチェックの設定項目ではカスタムヘッダーを指定できますが、 BackendConfigからは設定できません 。そのため、別途カスタムヘッダーが不要なヘルスチェック用のエンドポイントをEnvoyコンテナに設定する必要があります。 Envoyコンテナの初期設定として利用される ConfigMap: kourier-bootstrap を調べると、9000番ポートでGETメソッドに対して /ready エンドポイントが公開されていました。そこで今回は Service: kourier にパッチを当てて type: Nodeport とした上で、9000番ポートを開けて、Backendconfigにコンテナの9000番ポートへのヘルスチェックを設定しました。 ConfigMap: kourier-bootstrapのマニフェスト関連部分抜粋 apiVersion : v1 kind : ConfigMap metadata : name : kourier-bootstrap namespace : kourier-system ~~~ data : envoy-bootstrap.yaml : | ~~~ static_resources : listeners : - name : stats_listener address : socket_address : address : 0.0.0.0 port_value : 9000 filter_chains : - filters : - name : envoy.filters.network.http_connection_manager typed_config : "@type" : type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix : stats_server http_filters : - name : envoy.filters.http.router route_config : virtual_hosts : - name : admin_interface domains : - "*" routes : # /ready を含む一部のadmin_interfaceを公開している - match : safe_regex : google_re2 : {} regex : '/(certs|stats(/prometheus)?|server_info|clusters|listeners|ready)?' headers : - name : ':method' exact_match : GET route : cluster : service_stats clusters : - name : service_stats connect_timeout : 0.250s type : static load_assignment : cluster_name : service_stats endpoints : lb_endpoints : endpoint : address : pipe : path : /tmp/envoy.admin ~~~ http2_protocol_options : {} type : STRICT_DNS admin : access_log_path : "/dev/stdout" address : pipe : path : /tmp/envoy.admin 手順2. カスタムドメインを設定し、適切にルーティングされることを確認する Knative Servingでは、Knative/Serviceで定義されるPodへ繋がるFQDNは以下のように定められます。 ${Routeのname}.${Knative/Routeを作成したNamespace}.${ConfigMap: config-domainで定義されたドメイン} 例えば以下のように Namespace: recommendation-module に Knative/Service: dev1 を作成すると Route: dev1 が自動で作成されます。合わせて ConfigMap: config-domain でドメインを指定することで、最終的に dev1.recommendation-module.example.zozo.com というドメインが Knative/Service: dev1 に割り当てられます。 Knative/Service: dev1 apiVersion : serving.knative.dev/v1 kind : Service metadata : name : dev1 namespace : recommendation-module labels : run : zozo-module-recommendations-api spec : template : ~~~ Knative/Service: dev1 の作成によって Route: dev1 が作成される ❯ kubectl get ksvc --namespace=recommendation-module NAME URL LATESTCREATED LATESTREADY READY REASON dev1 http://dev1.recommendation-module.example.zozo.com dev1-00002 dev1-00002 True ❯ kubectl get route --namespace=recommendation-module NAME URL READY REASON dev1 http://dev1.recommendation-module.example.zozo.com True ConfigMap: config-domain apiVersion : v1 kind : ConfigMap metadata : name : config-domain namespace : knative-serving data : # ラベル run: zozo-module-recommendations-apiを持つRouteにドメインexample.zozo.comへのリクエストを紐づける example.zozo.com : | selector : run : zozo-module-recommendations-api 基本は上記設定に従い、ホストヘッダーベースのルーティングをEnvoyコンテナで行います。Knative/Serviceマニフェストの spec.traffic.tag に値を設定することでFQDNのホスト部分をルーティング対象とするRevision毎に作り分けたり 3 、同じFQDNを利用しつつもリクエストに Knative-Serving-Tag ヘッダーを付与することでリクエスト先を分ける 4 ことも可能です。 ここまででKnative Servingで利用するFQDNが定まりました。次はそのFQDNで、 Deployment: 3scale-kourier-gateway のPod(Envoyコンテナ)をバックエンドに持つLBへリクエストが名前解決されるようにする必要があります。 今回はGKE Ingressを利用しているので、GKE Ingressで作成される内部LBのIPアドレスへ名前解決されるように、CloudDNSにワイルドカードAレコードを設定しました。具体的には *.recommendation-module.example.zozo.com. に対するAレコードを作成し、 Namespace: recommendation-module 以下に作成されるKnative/Serviceへのリクエストは全て内部LBのIPアドレスへ名前解決されるようにしました。こうすることで、作成したFQDNに対するリクエストはEnvoyコンテナを経由し、Envoyコンテナで各Knative/Serviceから生成されるPodへとホストヘッダーベースのルーティングが行われます。 この状態でリクエストを行うと以下のようにPodが起動しレスポンスが返されます。 リクエストは Pod: activator でキューイングされます。キューイング時に、リクエスト先のPodが起動していない・ 起動しているPod数xPod毎の並列リクエスト処理数設定 がリクエストに対して不足している場合は Pod: autoscaler がDeploymentのReplicasを更新することでルーティング先のPodを必要な台数起動します。 Podが起動したら、キューイングされていたリクエストがPodに送られます。オートスケーリングの挙動(どれくらいのリクエストが来たらPod数を増やす・どれくらいの間リクエストが来なければPod数を減らすか等)はKnative/Serviceで細かく設定できます 5 。Podを起動するのに十分なリソースを持ったノードがない場合は、GKEに設定しているノードプールのオートスケール機能でノードが追加されてからPodがスケジュールされて起動するため、ノード起動を待つ分だけレスポンスタイムは長くなります。 curlのログ $ curl -v " http://dev1.recommendation-module.example.zozo .com/api/v1/zozo/home-modules/?app=pc&mall=shoes&sex=all&member_id=30&ga_client_id=deviceId 30 " * Trying 10 . 96 . 97 .135:80... * Connected to dev1.recommendation-module.example.zozo.com ( 10 . 96 . 97 . 135 ) port 80 ( #0) > GET /api/v1/zozo/home-modules/? app =pc & mall =shoes & sex =all & member_id = 30 & ga_client_id =deviceId30 HTTP/ 1 . 1 > Host: dev1.recommendation-module.example.zozo.com > User-Agent: curl/ 7 . 78 .0-DEV > Accept: */* > * Mark bundle as not supporting multiuse < HTTP/ 1 . 1 200 OK < content-length: 14865 < content-type: text/plain ; charset =UTF -8 < date: Wed, 10 Aug 2022 04:28:59 GMT < x-envoy-upstream-service-time: 26199 < server: envoy < via: 1 . 1 google < ~~~ Deployment・Revision・Podの変化 # リクエスト前 ❯ kubectl get revision --namespace=recommendation-module NAME CONFIG NAME K8S SERVICE NAME GENERATION READY REASON ACTUAL REPLICAS DESIRED REPLICAS dev1-00002 dev1 2 True 0 0 ❯ kubectl get deploy --namespace=recommendation-module NAME READY UP-TO-DATE AVAILABLE AGE dev1-00002-deployment 0 / 0 0 0 5d21h # リクエスト直後 ❯ kubectl get revision --namespace=recommendation-module NAME CONFIG NAME K8S SERVICE NAME GENERATION READY REASON ACTUAL REPLICAS DESIRED REPLICAS dev1-00002 dev1 2 True 0 1 ❯ kubectl get deploy --namespace=recommendation-module NAME READY UP-TO-DATE AVAILABLE AGE dev1-00002-deployment 0 / 1 1 0 5d21h ❯ kubectl get pod --namespace=recommendation-module NAME READY STATUS RESTARTS AGE dev1-00002-deployment-65f8d57f4c-c8dns 1 / 2 Running 0 19s # レスポンスを返した直後 ❯ kubectl get revision --namespace=recommendation-module NAME CONFIG NAME K8S SERVICE NAME GENERATION READY REASON ACTUAL REPLICAS DESIRED REPLICAS dev1-00002 dev1 2 True 1 1 ❯ kubectl get pod --namespace=recommendation-module NAME READY STATUS RESTARTS AGE dev1-00002-deployment-65f8d57f4c-z6r66 2 / 2 Running 0 73s Envoyコンテナのログ [ 2022-08-10T04:28:33.808Z ] " GET /api/v1/zozo/home-modules/?app=pc&mall=shoes&sex=all&member_id=30&ga_client_id=deviceId30 HTTP/1.1 " 200 - 0 14865 26201 26199 " 10.96.66.8,10.96.97.135 " " curl/7.78.0-DEV " " d10aca2a-c215-418e-84eb-12c569dc2754 " " dev1.recommendation-module.example.zozo.com " " 10.96.68.3:8012 " Activatorコンテナのログ # リクエストを受けてスケールアウトさせる { " severity " : " INFO " , " timestamp " : " 2022-08-10T04:28:55.574545201Z " , " logger " : " activator " , " caller " : " net/throttler.go:318 " , " message " : " Set capacity to 2147483647 (backends: 1, index: 0/1) " , " commit " : " ac29233 " , " knative.dev/controller " : " activator " , " knative.dev/pod " : " activator-6c496d64d8-kskdk " , " knative.dev/key " : " recommendation-module/dev1-00002 " } # リクエストが来なくなったのでスケールインさせる { " severity " : " INFO " , " timestamp " : " 2022-08-10T04:30:03.613571203Z " , " logger " : " activator " , " caller " : " net/throttler.go:318 " , " message " : " Set capacity to 0 (backends: 0, index: 0/1) " , " commit " : " ac29233 " , " knative.dev/controller " : " activator " , " knative.dev/pod " : " activator-6c496d64d8-kskdk " , " knative.dev/key " : " recommendation-module/dev1-00002 " } 手順3. Knative/Serviceマニフェストを既存のDeploymentマニフェストから生成する Knative/Serviceマニフェストは既存の開発環境APIを定義するDeploymentマニフェストからスクリプトで生成するようにしました。該当Deploymentマニフェストに変更が加えられた際には、スクリプトを実行することでKnative/Serviceのマニフェストも更新し、変更を反映します。Deploymentに変更が入ったにも関わらず、生成しているKnative/Serviceのマニフェストに変更が反映されていない場合はCIでエラーになるようにし、反映忘れを防ぐようにしました。 Knative/Serviceマニフェストの生成方法ですが、基本的には、Deploymentの spec.template.spec (=PodSpec)の値をそのままKnative/Servingの spec.template.spec の値とするだけでよいです 6 , 7 。Deploymentで定義されている、 spec.selector や spec.strategy 等はKnative/Servingでは定義されていないので適宜除去する必要があります。PodSpecのいくつかの項目については ConfigMap: config-features で明示的に利用を許可する必要があります 8 。既存のDeploymentマニフェストでは、起動するノードを指定するために nodeAffinity ・ toleration を、Podが起動したノードのIPアドレスをKubernetes Downward API経由で取得するために fieldRef の利用しているのでこれらの利用を許可しました。 apiVersion : v1 kind : ConfigMap metadata : name : config-features namespace : knative-serving data : kubernetes.podspec-affinity : "Allowed" kubernetes.podspec-tolerations : "Allowed" kubernetes.podspec-fieldref : "Allowed" スクリプトで生成したKnative/Serviceマニフェスト knative-service-generated.yaml を元に以下のようなディレクトリ構成でdev1〜20環境を構成しました(関連するファイルのみ記載しています)。 ./ ├── base │ ├── zozo-module-recommendations-api │ │ └── deployment.yaml │ └── zozo-module-recommendations-api-knative │ ├── kustomization.yaml │ ├── custom-schema.json # 既存のKuberenetesクラスタのOpenAPIスキーマに、Knative/ServiceのOpenAPIスキーマを追記したjsonファイル │ └── knative-service-generated.yaml # zozo-module-recommendations-api/deployment.yaml からスクリプトで生成する └── dev └── zozo-module-recommendations-api-knative ├── kustomization.yaml # dev1~20 の kustomization.yaml を参照する ├── dev1 │ ├── knative-service.yaml │ └── kustomization.yaml # base の kustomizatio.yaml を参照する ├── dev2 │ ├── knative-service.yaml │ └── kustomization.yaml ~~~ └── dev20 ├── knative-service.yaml └── kustomization.yaml base/zozo-module-recommendations-api-knative/kustomization.yaml Knative/ServiceのOpenAPIスキーマ情報を追加で読み込み、patchStrategicMergeの挙動をカスタマイズしています(後述) apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : recommendation-module resources : - knative-service-generated.yaml # OpenAPIのスキーマJSONを指定することで、カスタムリソースであるKnative/ServiceのpatchStrategicMerge方法を指定する # ref. https://github.com/kubernetes-sigs/kustomize/blob/master/examples/customOpenAPIschema.md openapi : path : custom-schema.json dev/zozo-module-recommendations-api-knative/kustomization.yaml apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : recommendation-module resources : - ./dev1 - ./dev2 ~~~ - ./dev20 dev/zozo-module-recommendations-api-knative/dev1/kustomization.yaml JSON Patchでリソース名を環境毎に書きかえます apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : recommendation-module resources : - ../../../base/zozo-module-recommendations-api-knative patchesStrategicMerge : - knative-service.yaml # リソース名を変更するために、patchesJsonを利用する patchesJson6902 : - target : group : serving.knative.dev version : v1 kind : 'Service' name : 'zozo-module-recommendations-api' patch : |- - op : replace path : "/metadata/name" value : dev1 dev/zozo-module-recommendations-api-knative/dev1/knative-service.yaml 各環境毎に、image・環境変数にパッチを当てています。必要に応じて変更します。 apiVersion : serving.knative.dev/v1 kind : Service metadata : name : zozo-module-recommendations-api spec : template : spec : containers : - name : zozo-module-recommendations-api image : gcr.io/example:dev env : - name : GCS_BUCKET value : example-bucket ~~~ - name : ENABLE_SWAGGER value : "true" 複数環境の構築で躓いた点 カスタムリソースに対するKustomizeのpatchStrategicMergeの挙動は自ら定義する必要がある 従来、baseのマニフェストで環境共通の値を定義し、環境間で異なる一部の値(環境変数など)のみpatchStrategicMergeでパッチを当てて定義していました。Kubernetesネイティブなリソース(PodやDeployment等)に対するpatchStrategicMergeの挙動はKustomize内で定義されています。例えばPodの spec.containers[] は配列で値が定義されますが、nameが一致した配列要素のvalueのみを置換する挙動が定義されているため、patchStrategicMergeを用いて配列の値の追加・一部置き換えができます。 他方、カスタムリソースに対するpatchStragtegicMergeの挙動はKustomizeにはデフォルトでは定義されていません。カスタムリソースに定義されているフィールドの値の置換方法は自ら定義しKustomizeに設定する必要があります。挙動が定義されていない場合、JSON Patchの挙動となり、合致するフィールドの値をそのまま置き換えてしまいます。Knative/Serviceのマニフェスト内で spec.template.spec.containers[] の中身を環境毎に一部置き換えようとするとbase側で定義している spec.template.spec.containers[] 全体がパッチで定義している値で置き換えられてしまいました。 この問題に対してはKustomize側で対応方法が用意されています。KustomizeにカスタムリソースのAPIについてのOpenAPIのスキーマJSONファイルを渡すことで、mergeの挙動を指定できます 9 。 今回、具体的には以下3つのステップで理想とする挙動を実現しました。 (1)Knative ServingのCRDをapply済みのKubernetesクラスタから既存のOpenAPIスキーマJSONを取得する $ kustomize openapi fetch > custom-schema.json (2)Knative/Service関連部分のスキーマを編集する 取得したデフォルトのOpenAPIスキーマJSONでは RevisionSpec 以下のフィールドについての定義が省略されていること、 RevisionSpec はいくつかの独自のフィールドと PodSpec のインライン展開で構成されていることから以下のように考えました。 RevisionSpecの部分について明示的にスキーマを定義することでpatchStrategicMergeの挙動をカスタマイズする 既存のPodSpecに対する挙動を実現できれば良いので、PodSpecのスキーマをそのまま挿入すればよい ※ Kubebuilderのinlineアノテーションに相当する機能はOpenAPIにはないので、PodSpecのスキーマを参照するのではなく、コピー&ペーストで挿入しています custom-schema.json 修正前 ~~~ "dev.knative.serving.v1.Service" : { ~~~ "properties" : { ~~~ "spec" : { ~~~ "properties" : { "template" : { "description" : "Template holds the latest specification for the Revision to be stamped out." , "properties" : { "metadata" : { "x-kubernetes-preserve-unknown-fields" : true } , "spec" : { # RevisionSpec以下のスキーマが省略されている "description" : "RevisionSpec holds the desired state of the Revision (from the client)." , "required" : [ "containers" ] , "x-kubernetes-preserve-unknown-fields" : true } } , "type" : "object" } , "traffic" : { ~~~ custom-schema.json 修正後 ~~~ "dev.knative.serving.v1.Service" : { ~~~ "properties" : { ~~~ "spec" : { ~~~ "properties" : { "template" : { "description" : "Template holds the latest specification for the Revision to be stamped out." , "properties" : { "metadata" : { "x-kubernetes-preserve-unknown-fields" : true } , "spec" : { # RevisionSpec以下のスキーマを明示的に定義する "containerConcurrency" : { "format" : "int64" , "type" : "integer" } , "timeoutSeconds" : { "format" : "int64" , "type" : "integer" } , "responseStartTimeoutSeconds" : { "format" : "int64" , "type" : "integer" } , "idleTimeoutSeconds" : { "format" : "int64" , "type" : "integer" } , # 以下はPodSpec のスキーマからコピペしたもの "activeDeadlineSeconds" : { ~~~ "containers" : { "description" : "List of containers belonging to the pod. Containers cannot currently be added or removed. There must be at least one container in a Pod. Cannot be updated." , "items" : { "$ref" : "#/definitions/io.k8s.api.core.v1.Container" } , "type" : "array" , "x-kubernetes-patch-merge-key" : "name" , # 何をキーにマージをするかを指定 "x-kubernetes-patch-strategy" : "merge" # patchを当てる際の挙動を指定 } , "required" : [ "containers" ] , "type" : "object" } } , "type" : "object" } , "traffic" : { ~~~ (3)(2)で作成したスキーマをKustomizeで用いる base/zozo-module-recommendations-api-knative/kustomization.yaml に openapi ブロックを追加してスキーマを指定 apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : recommendation-module resources : - knative-service-generated.yaml # OpenAPIのスキーマJSONを指定することで、カスタムリソースであるKnative/ServiceのpatchStrategicMerge方法を指定する # ref. https://github.com/kubernetes-sigs/kustomize/blob/master/examples/customOpenAPIschema.md openapi : path : custom-schema.json どれくらい費用を削減できているのか? 今回の用途でKnative Serving関連のPodを起動するにあたっては、 e2-small($12.23/月) ノードが4台あれば十分でした。 APIで利用するノードは n1-standard4($97/月) であり、リクエストが来た時のみ起動します。追加で用意したdev1〜20環境へは、開発案件がある時にのみリクエストが来るため、大きく見積もっても全環境合計で1ヶ月に1インスタンス分の料金になります。シンプルに20環境分のAPIを立ち上げる場合と比べると約92%( ≒100-(12.23(e2-small)*4+97(n1-standard4))/(97(n1-standard4)*20(環境数))*100 )、年間換算で $21,417 の費用削減になりました。 もちろん、あまり利用されない大量の開発環境APIを常に起動しておくのは現実的ではないので、実際にはここまでの費用削減にはなりません。また、依頼ベースで開発環境APIを起動・停止するといった手間のかかる運用作業がないのは嬉しいポイントです。 今後の展望/終わりに 本記事では複数のAPIを集約するマルチテナントGKEクラスタ上に、Knative Servingを用いて、既存のAPIをServerless Containerとして手軽に提供する環境を構築する方法をご紹介しました。今後はこの方法を用いて他のAPIも必要に応じて20個の開発環境APIを用意し、ZOZOTOWNの開発をより高速に進められる環境を整えていきます。 ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com hrmos.co Cloud Run は理解した。Cloud Run for Anthos って何? ↩ Envoy公式Doc: Configuration Reference ↩ Knative公式Doc: Traffic management ↩ Knative公式Doc: Tag Header Based Routing ↩ Knative公式Doc: Autoscaling ↩ Knative公式Doc: Converting a Kubernetes Deployment to a Knative Service ↩ serving.knative.dev/v1 Service ↩ Knative公式Doc: Feature and extension flags ↩ Kustomize公式Doc: Using a Custom OpenAPI schema ↩
こんにちは、データ基盤の開発、運用をしていた谷口( case-k )です。最近は配信基盤の開発と運用をしています。 ZOZOではオンプレやクラウドにあるデータをBigQueryへ連携し、分析やシステムで活用しています。BigQueryに連携されたテーブルは共通データ基盤として全社的に利用されています。 共通データ基盤は随分前に作られたこともあり、様々な負債を抱えていました。負債を解消しようにも利用者が約300人以上おり、影響範囲が大きく改善したくても改善できずにいました。 本記事では旧データ基盤の課題や新データ基盤の紹介に加え、どのようにリプレイスを進めたかご紹介します。同じような課題を抱えている方や新しくデータ基盤を作ろうとしている方の参考になると嬉しいです。 データ基盤の紹介 旧データ基盤の紹介 旧データ基盤の課題 変更があっても更新されないデータ 性質の異なるテーブルを同じ命名規則で管理 秘密情報の判断が手動 テーブルの取得元が不適切 新データ基盤の紹介 データレイクの構築 データセットを分けて管理 秘密情報を適切に管理 適切なDBからテーブルを連携する AWSにあったETLをGCPに移行 ワークフロー設計 コストとパフォーマンスの評価(Embulk vs Dataflow) Dataflow導入Tips DataflowのQuotas Dataflow同時実行ジョブ数 Compute Engine Dataflowの開発言語 データ基盤のさらなる発展と機能拡充 旧データ基盤からのお引っ越し 関係者と協力して新旧テーブル対応表を作成 配信チーム(オンプレDWH管理) データ分析チーム 基幹DB管理チーム 新データ基盤移行に伴うデータの評価 利用者への全体周知 新データ基盤移行で問題になったこと 過去データの扱い 参照しているクエリが減らない データマートの管理者が不明 まとめ データ基盤の紹介 冒頭でご紹介したとおり、ZOZOではオンプレのSQL ServerにあるテーブルをBigQueryに連携して利用しています。 データ基盤は大きく分けて2種類あり、日次でデータ連携してるものとリアルタイムにデータ連携しているものがあります。日次データ基盤ではSQL Serverの全量データを転送しています。リアルタイムデータ基盤ではSQL Serverで変更のあった差分データをBigQueryへ連携しています。 日次の全量データとリアルタイムな差分データを組み合わせることで、リアルタイムにBigQuery上でSQL Serverのテーブルの状態を再現できます。今回はこれらのうち日次データ基盤(以下、旧データ基盤と呼びます)をリプレイスした事例をご紹介します。 リアルタイムタイムデータ基盤については以前書いた以下の記事をご確認ください。オンプレDWHの移行に伴い連携するテーブルが増えたため、現在はGKE上で運用しています。 techblog.zozo.com 旧データ基盤の紹介 リプレイス対象の旧データ基盤についてご紹介します。旧データ基盤では中間DBをハブとして多段に連携していました。まず、オンプレの基幹DB(SQL Server)で管理されているテーブルを中間DB(SQL Server)に書き込みます。次に、中間DBをハブとしてBigQueryに書き込みます。 実はBigQueryの他にもう1つのオンプレDWHにも中間DBからテーブルを連携していました。このオンプレDWHは配信チーム(MA:マーケティングオートメーション)で管理しているDWHになります。配信系の処理など一部のサービスではオンプレのDWHを活用していました。 テーブルの連携処理はAWS上にあるDigdag(ワークフローエンジン)とEmbulk(ETLツール)を用いていました。 現在はオンプレDWHと旧データ基盤は廃止済みです。オンプレDWHの廃止の詳細は以下の記事をご確認ください。 techblog.zozo.com 旧データ基盤の課題 ここからは旧データ基盤の課題をご紹介します。以下の図にあるように旧データ基盤では基幹DBから中間DB、中間DBからDWHに連携するまでに様々な加工処理を施していました。具体的にどのような加工処理を施していたのか、運用上どのような課題があったのかご紹介します。 変更があっても更新されないデータ 旧データ基盤では以下の図にあるようなテーブルの更新タイムスタンプを使った差分連携を採用していました。 タイムスタンプを用いて変更のあった差分データに絞ることで、サイズの大きいテーブルでも低コストで高速に連携できます。しかし、実際に運用してみると、テーブルに変更があっても更新タイムスタンプが適切に更新されないケースがありました。そのため、基幹のテーブルに更新があってもクエリの条件に合致せず、変更のあったデータがBigQueryに反映されませんでした。 また、基幹テーブルのレコードが物理削除された場合削除されたデータを取得できません。それゆえ、基幹DBで削除されたデータがBigQueryから削除されずに残り続けていました。その他にも物理削除されたデータを残す連携など様々な加工処理を施していました。 そのため、BigQueryにあるデータが基幹テーブルのコピーになっておらず、データ不整合が発生していました。 性質の異なるテーブルを同じ命名規則で管理 1つのデータセットで、性質の異なるテーブルを同じ命名規則で管理していました。 例えば日付サフィックスをつけるテーブルがあります。 <テーブル名>_yyyymmdd この命名のテーブルを見たユーザは、全てのテーブルが同じ性質と考えてしまいます。例えば、最初に日付サフィックスの付いたテーブルが日次の全量スナップショットと認識したら、他のテーブルも同様と考えるでしょう。しかし実際には以下3種類の異なる性質のテーブルになっていました。 日次の全量スナップショット 日次の差分テーブル 日次の物理削除データを含むテーブル このように、1つのデータセット内で異なる性質のテーブルを同じ命名規則で管理していたため、利用者を混乱させていました。 秘密情報の判断が手動 旧データ基盤では秘密情報の判断を手動でしていました。秘密情報は秘密情報の管理表で管理されています。利用者がテーブルの追加を依頼する際に秘密情報の管理表を確認し、データ基盤の管理者側でも秘密情報がないか確認する運用です。旧データ基盤では秘密情報はBigQueryに入れない運用をとっていたので、秘密情報の場合はマスク処理を施してBigQueryで管理していました。 ただし、利用者の申請ベースの運用だったので、店舗の電話番号など秘密情報ではないのにマスク処理が施されている場合もありました。これまで幸い問題は発生していませんでしたが、人間任せだと誤って秘密情報を漏洩させてしまう懸念がありました。 テーブルの取得元が不適切 基幹DBにはマスタテーブル以外にもレプリされたテーブルがあります。レプリされたテーブルは同じ名前で異なるデータベース内に管理されています。 BigQueryへ連携する際に基幹DBのどのデータベースから連携するか決める必要があります。これまで、テーブルの取得元DBは利用者がテーブルの追加を依頼する際に指定する運用をとっていました。その結果、テーブルの取得元としてマスタDBとレプリDB両方から連携されていました。 冒頭で述べたようにZOZOではリアルタイムデータ基盤も運用しています。リアルタイムデータ基盤では鮮度の高いデータを取得できるようマスタDBから変更のあったデータをBigQueryに連携しています。 リアルタイムデータ基盤ではリアルタイムな差分データと日次の全量データを組み合わせることで、基幹テーブルの最新の状態を作りだしています。旧データ基盤の取得元が不適切だとリアルタイムデータ基盤と旧データ基盤(日次全量)で参照元のDBが異なり、不整合が発生してしまいます。 テーブル追加の依頼者もこうした事情を把握していないため、取得元は不適切になりがちでカオスな状態となっていました。 新データ基盤の紹介 このように様々な辛みがあったので、データ基盤をリプレイスしました。 新データ基盤は以下のような構成になっています。緑の点線部分が今回リプレイスした箇所になります。日次の全量データが正しいデータになったので、リアルタイムデータとマージした全量データも正しいデータにできました。ここからは新データ基盤をご紹介します。 データレイクの構築 新データ基盤ではBigQuery上にデータレイクを構築しています。旧データ基盤にあった特定の用途に特化した加工処理は全て廃止し、全量転送しています。無加工のデータレイクを用意し、特定の用途に特化した処理はデータマートで対応するようにしました。 データセットを分けて管理 新データ基盤では取得元DBとテーブルの性質を考慮して、データセットを分けて管理しています。 旧データ基盤では取得元DBや性質の異なるテーブルを全て1つのデータセットで管理していたため、利用者の混乱を招いていました。そこで、新データ基盤ではBigQueryのデータセットを以下のような命名規則にすることで対応しました。 <データベース名>_<性質> 以下の図にあるデータセット名で取得元DBとデータの性質を考慮して、データセットを分けています。例えば取得元DBが「zozob」の日次全量テーブルは「zozob_daily」、リアルタイム全量テーブルは「zozob_realtime」となっています。 秘密情報を適切に管理 新データ基盤では秘密情報の分類マスタに基づいて、秘密情報の判断を自動化しています。方法としては、分類マスタに基づいた適切なポリシータグと秘密情報のマスクカラムの追加によって実現しています。これによって、手動での秘密情報の判断が不要になりました。 新データ基盤ではメルマガ配信等どうしても秘密情報が必要な案件にも対応できるよう、秘密情報をBigQueryにいれています。自動化したことで安全に秘密情報を管理できるようになりました。 詳細は以下の記事にまとめたのでご確認ください。 techblog.zozo.com 適切なDBからテーブルを連携する 新データ基盤では取得元DBの判定処理を自動化しています。 基幹DBの管理チームと連携し、判定に必要なマスタ情報を用意しました。判定に必要なマスタ情報を用いることで、適切な取得元DBをクエリを用いて自動で判定できるようにしました。利用者がテーブルを追加する際に取得元DBを指定する運用をやめたことで、誤って意図しない中身のテーブルを用いて分析することを防げるようになりました。 AWSにあったETLをGCPに移行 新データ基盤ではAWSにあったETLをGCPに移行しています。当時RedshiftをDWHとして使っていた名残で、AWS上に構築された環境を使いデータ連携を実施していました。今回のリプレイスでAWSにあったETLをGCPに移行しました。ETLツールにはGCPが提供するDataflowを採用しました。Dataflowは負荷に応じてオートスケールしてくれるため、データ連携に必要なコストとパフォーマンスを最適にできます。 旧データ基盤ではバッチ連携前にDigdagのワーカ(EC2)を増やし、Embulkを用いてテーブルを連携していました。この仕組みの問題点として、全てのテーブル連携が完了するまでワーカをスケールインできませんでした。 旧データ基盤は差分連携により、転送するデータ量が少なかったため大きな問題はありませんでしたが、全量転送にしたことで新データ基盤ではコストが飛躍的にあがりました。旧データ基盤と同じ方法で全量転送するようにした場合、Dataflowを使うよりも月間で数百万、年間だと数千万以上のコスト増となってしまいました。 Dataflowを用いることで、ワーカのスケールアウトが不要になり、約85%のコスト削減に繋がりました。コストパフォーマンスの評価は後述しています。 ワークフロー設計 続いてDatafowを前提にしたワークフロー設計についてご紹介します。全体の処理の流れは以下の通りです。 Dataflowを採用したことでパフォーマンス最適化されるようワークフローを改善しました。Dataflowにしたことで、非同期ジョブでのテーブル連携が可能となりました。まず、Digdagで連携対象のテーブル数分並列にDataflowジョブを非同期に実行します。全てのテーブル数分Dataflowジョブを実行した後、後続のタスクでDataflowジョブが失敗していないか以下のように待ち処理を入れています。 def jdbc_to_bigquery_wait (self): project_id_public = workflow_repository.WorkflowRepository().find(key= 'GCP_PROJECT_ID_PUBLIC' ) project_id_private = workflow_repository.WorkflowRepository().find(key= 'GCP_PROJECT_ID_PRIVATE' ) project_id_private_bucket = workflow_repository.WorkflowRepository().find(key= 'GCP_BUCKET_NAME' ) region = workflow_repository.WorkflowRepository().find(key= 'DATAFLOW_REGION' ) worker_service_account = workflow_repository.WorkflowRepository().find(key= 'DATAFLOW_WORKER_SERVICE_ACCOUNT' ) database = workflow_repository.WorkflowRepository().find(key= 'DATABASE' ) table_name = workflow_repository.WorkflowRepository().find(key= 'IMPORT_TABLE_NAME' ) local_path = f 'job-id-{database}-{table_name}.txt' gcs_path = f 'job-id/{database}/{table_name}.txt' job_id = gcp_gcs_repository.GcpGcsRepository(project_id_private,project_id_private_bucket).read_from_gcs(local_path=local_path,gcs_path=gcs_path).replace( '"' , '' ) dataflow_job = deploy_dataflow_job.DeployDataflowJob(project_id_public, region, worker_service_account) state, message = dataflow_job.wait_dataflow_job(job_id) if state == False : time.sleep( 60 ) self.jdbc_to_bigquery_wait_until_finish() ジョブが失敗した場合、テーブル単位で同期的にリトライができるようワークフローを設計しています。以下のように同期的なリトライにすることでジョブが失敗したテーブルのみリトライできるようになります。 def jdbc_to_bigquery_wait_until_finish (self): project_id_public = workflow_repository.WorkflowRepository().find(key= 'GCP_PROJECT_ID_PUBLIC' ) region = workflow_repository.WorkflowRepository().find(key= 'DATAFLOW_REGION' ) worker_service_account = workflow_repository.WorkflowRepository().find(key= 'DATAFLOW_WORKER_SERVICE_ACCOUNT' ) dataflow_job = deploy_dataflow_job.DeployDataflowJob(project_id_public, region, worker_service_account) job_id = self.jdbc_to_bigquery() state, message = dataflow_job.wait_dataflow_job(job_id) if state == False : raise Exception (f '{message}' ) コストとパフォーマンスの評価(Embulk vs Dataflow) Dataflowに移行するにあたり、EmbulkとDataflowでコストや、パフォーマンスを比較評価しました。 Dataflowの方が負荷に応じてオートスケールするため、Embulkより高速に連携できると想定していました。しかし、調査したところEmbulkの方がパフォーマンス面では優れていました。 Dataflowのパイプラインを確認したところ、データの読み込みで時間がかかっていることがわかりました。どうやらJDBCを用いてRDBからデータを取得する際は分散処理ができないようでした。データ取得後の後続処理では分散処理が可能でした。一方Embulkでは、データ読み込みから連携までCPUを複数コア活用できていました。 比較ではEmbulkの方がパフォーマンス優位だったものの、Dataflowでもパフォーマンス要件は満たせていたので、コスト最適化できるDataflowを採用しました。 また、Embulkを採用する場合は、テーブルによっては10倍以上のディスク容量が必要になることもわかりました。RDBからとってきたデータをJSONでダンプするため、70GBのテーブルだと700GBまで膨れあがります。ディスク容量が枯渇すると転送処理が失敗します。詳細は以下のissueをご確認ください。 github.com github.com 私たちの場合はDataflowでも要件を満たせたのでDataflowを採用しましたが、ETLツールを利用する際は参考にしてみてください。 Dataflowにしたことで、データ連携前にDigdagワーカのスケールアウトも不要になりました。ワーカのスケールアウトは大きな障害ポイントだったので運用負荷も軽減されました。 費用対効果は大きく、年間で数千万以上のコスト削減にも繋がりました。以下の図から4月以降大幅にコスト削減されたことが分かります。 Dataflow導入Tips 次にDataflow導入Tipsをご紹介できればと思います。同じようにDataflowの採用を検討してる方の参考になると幸いです。 DataflowのQuotas 私たちの場合連携するテーブルが多かったため、GCP側でDataflowのQuotasを引き上げてもらう必要がありました。DataflowのQuotasと必要な対応をご紹介します。 Dataflow同時実行ジョブ数 Dataflowの同時実行ジョブ数はデフォルトだとプロジェクトあたり25になっています。連携対象のテーブルは600テーブルほどあったので、今後のことも考え上限を1000に上げてもらいました。サポートケースを起票し用途を伝えれば引き上げてもらえます。引き上げてもらったQuotasはGCPコンソールの「IAM & admin」から「Quotas page」より確認できます。 cloud.google.com Compute Engine DataflowはCompute Engine上で動くため、Compute EngineのQuotasも引き上げる必要があります。ゾーンのN2_CPUSの上限は200だったので2000に引き上げました。この制限は「IAM & admin」から「Quotas page」よりすぐ引き上げられました。 2022-03-06 02:16:04.440 JSTStartup of the worker pool in zone asia-northeast1-b failed to bring up any of the desired 1 workers. QUOTA_EXCEEDED: Instance 'jdbc-to-bigquery-zozoold--03050914-6bui-harness-dvc9' creation failed: Quota 'N2_CPUS' exceeded. Limit: 200.0 in region asia-northeast1. Dataflowの開発言語 DataflowはJava、Python、Goをサポートしています。私たちはJavaを選びました。Python版を検証をしたところ、クエリの上書きができなかったり、余計な通信が走ったりとまだ本番で利用するには不十分だったからです。 詳しくは以下の記事にまとめました。 www.case-k.jp www.case-k.jp Java版はGCPでテンプレートを提供してくれています。しかし、テーブルの上書き(WRITE_TRUNCATE)ができず、タイムスタンプの加工処理が誤っていました。MySQLなど特定のデータソースに特化した加工処理も含まれていたので、カスタムテンプレートを作成しました。 https://github.com/GoogleCloudPlatform/DataflowTemplates/blob/main/src/main/java/com/google/cloud/teleport/templates/JdbcToBigQuery.java#L105 github.com github.com 以下のようなDataflowのパイプラインを作り、SQL ServerやMySQLからデータを連携しています。 pipeline .apply(JdbcIO.<TableRow>read() .withDataSourceConfiguration( JdbcIO.DataSourceConfiguration.create(driver_class_name, jdbc_url) .withUsername(username) .withPassword(password) ) .withQuery(query) .withCoder(TableRowJsonCoder.of()) .withRowMapper( new ResultSetToTableRow(options.getTimezone()))) 新しく追加されたカラム等反映できるよう、BigQueryのテーブルを上書きする場合はスキーマ情報が必要です。Digdagでデータ連携する際、対象テーブルのスキーマ情報を取得し、GCSにアップロードしています。GCSにアップロードされたテーブルのスキーマ情報を用いて、書き込み先のテーブルを全量置換しています。 .apply( "Write to BigQuery" , BigQueryIO.writeTableRows() .withoutValidation() .withCreateDisposition(BigQueryIO.Write.CreateDisposition.CREATE_IF_NEEDED) .withWriteDisposition(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE) .withCustomGcsTempLocation(options.getBigQueryLoadingTemporaryDirectory()) .withSchema( NestedValueProvider.of( options.getSchema(), new SerializableFunction<String, TableSchema>() { @Override public TableSchema apply(String jsonPath) { TableSchema tableSchema = new TableSchema(); List<TableFieldSchema> fields = new ArrayList<>(); SchemaParser schemaParser = new SchemaParser(); try { JSONArray bqSchemaJsonArray = schemaParser.parseSchema(jsonPath); for ( int i = 0 ; i < bqSchemaJsonArray.length(); i++) { JSONObject inputField = bqSchemaJsonArray.getJSONObject(i); TableFieldSchema field = new TableFieldSchema() .setName(inputField.getString(NAME)) .setType(inputField.getString(TYPE)); if (inputField.has(MODE)) { field.setMode(inputField.getString(MODE)); } fields.add(field); } tableSchema.setFields(fields); } catch (Exception e) { throw new RuntimeException(e); } return tableSchema; } })) .to(options.getOutputTable())); pipeline.run(); } データ基盤のさらなる発展と機能拡充 日次連携データが正しいデータになったことで、依存関係のあったリアルタイムデータ基盤も正しい状態を作り出せるようになりました。データを整備したことで、AI等様々なデータを活用した分野で活用できます。 リアルタイムデータ基盤の差分データと日次の全量データを組み合わせたタイムトラベル機能もその1つです。正しいデータ扱えるようになったことで、特定時刻のテーブルを再現できるようになりました。 詳しくは以下の記事にあるのでご確認ください。 techblog.zozo.com 旧データ基盤からのお引っ越し 新データ基盤の構築後はお引っ越しです。利用者に旧データ基盤を参照しているクエリを安全に書き換えてもらう必要があります。 旧データ基盤と新データ基盤で差分があまりないなら大きな問題にはなりません。しかし、数億件以上のレコードの乖離がある場合もあるため、注意が必要です。 安全に移行を進めるため、関係者と協力し、新旧データ基盤の差分や移行先をまとめたテーブル対応表を作りました。新旧テーブル対応表を作成したのち、複数回に分けて利用者に周知しました。 どのように引越し作業を進めたかご紹介できればと思います。 関係者と協力して新旧テーブル対応表を作成 クエリの書き換えをしてもらうために、利用者を特定し、新旧テーブルの差分をまとめた移行対応表を作成します。クエリを1ファイルずつ確認しながら差分を調べるなかなか泥臭いところです。 新旧テーブル対応表の作成するために、様々なチームとの協力が必要でした。各チームと協力し、最終的には以下のような新旧テーブル対応表を作り、利用者に展開しました。各チームとの取り組みについてご紹介します。 配信チーム(オンプレDWH管理) まずはオンプレDWHを廃止するため、オンプレのDWHを管理する配信チームと協力して移行を進めました。 元々配信チームとデータ基盤は同じチームだったこともあり、ワークフローが混在している状態でした。例えばオンプレDWHにあるデータマートの更新処理を両チームのワークフローから実施していました。 配信チームと持ち物を整理し、利用者とも相談しながら、移行に必要な機能や仕組みを作りました。先行してオンプレDWHの移行を進めたので、このフェーズで新データ基盤に必要な機能は一通り揃いました。 オンプレのDWHの移行の詳細は以下の記事をご確認ください。 techblog.zozo.com データ分析チーム 次にデータ分析チームです。データ分析チームでは旧データ基盤のテーブルを使い、データ分析やデータ分析に必要なデータマートを作成していました。 データマートはデータ分析チーム以外に事業部でも作られていました。データマートを構築するために参照してるテーブル先が変わるため、クエリの書き換えが必要になります。ビジネスサイドとも連携が必要なので、移行に伴うビジネスサイド側のディレクションはデータ分析チームに実施してもらいました。 データ分析チームとの定例は新データ基盤への移行完了後も継続して実施しています。これまでデータ分析チームの要望をヒアリングする機会がなかったので良い機会となりました。 基幹DB管理チーム 新データ基盤の移行にあたり、旧データ基盤の課題を解決するために基幹DB管理チームと協力して、マスタ情報等を整理しました。すでにご紹介したとおり旧データ基盤の負債を解消するためにマスタ情報の整理が必要でした。基幹DB管理チームと連携しマスタ情報を整理し、新データ基盤に必要な仕組みを導入していきました。また、データ欠損や重複を防ぐための仕組みも導入してもらいました。 詳細はテックブログでも公開してるのでご確認ください。 techblog.zozo.com techblog.zozo.com 新データ基盤移行に伴うデータの評価 新データ基盤で移行に必要なテーブルが準備できたあとはデータの評価が必要です。移行に伴いデータ欠損等発生している場合があります。 オンプレのDWH移行では移行前後のデータを評価できる仕組みを入れたことで、移行に伴う欠損を未然に防ぐことができました。配信ログ等並行運用が難しい場合は過去データだけでも評価できるよう仕組みを整えることで、過去データを移行する際に発生してログ欠損を未然に防ぐことができます。 また、データ分析チームにも協力してもらうことで移行に必要なデータが不足していないかを確認しました。様々な加工処理を施した影響で過去データが不足している場合もありました。利用者にクエリを書き換える前に評価することで、事故を未然に防げます。 利用者への全体周知 新データ基盤の評価が完了し、新旧テーブル対応表が作成できたあとは利用者全体への周知です。共通基盤の利用者300人ほどをSlackのチャンネルに招待し、3回に分けて説明会を開きました。 利用者はBigQueryから利用状況を確認し、サービスアカウントの管理者はKintoneやSlackの履歴から特定しました。周知を行い説明会やSlackにて利用者からの疑問に回答していきました。データ分析チームと事前に不明点は調べ、新旧テーブル対応表や資料にもまとめていたので、想定外の問い合わせはありませんでした。新旧データ基盤の差分や移行先等まとめていたのである程度スムーズ進みました。 新データ基盤移行で問題になったこと ここからは全体周知後、問題になったことをご紹介します。 過去データの扱い ある程度予想はしていましたが、想像以上に過去データを引き続き使いたいとの要望があがりました。過去データは様々な加工処理が施され、信用できないデータになっています。どうしても必要な場合は別のGCPプロジェクトへコピーして管理してもらおうと思っていました。しかし、利用者からの要望が多かったため、別のGCPプロジェクトにバックアップをとり別環境で引き続き利用できるようにしました。データは信用できなくても、過去データが使えなくなるデメリットよりはましとの判断です。長期的には破棄していければと思います。 参照しているクエリが減らない 新データ基盤への移行を社内で周知しても、移行期限までにクエリの書き換えは思うように進捗しませんでした。止むを得ず、一時的に旧データ基盤のテーブルをビューに置き換え、新旧データ基盤の差分を吸収する対応をしました。 また、チームによっては旧データ基盤を参照しているクエリを止めない判断をする場合もありました。BIツールから実行されているクエリ等不要なクエリの棚卸しに時間がかかるためです。 さらに、GCPのデフォルトサービスアカウントを複数のチームで共用している場合、安全に移行することが困難になります。同じサービスアカウントを使うと、BigQueryの実行ログから移行が完了しているか確認できないためです。 案件ごとに必要最低限の権限をもったサービスアカウントを作らないと安全に移行することは難しいです。前もってデフォルトサービスアカウントの撲滅等を優先し実施した方が良かったです。 期日に余裕があれば参照クエリをなくすことは可能ですが、期日が決まってる場合は完全に参照クエリをなくすことは難しいように思いました。 データマートの管理者が不明 誰が管理者なのか不明確なデータマートがありました。退職等でデータマートの管理者が不在の場合があります。管理者不在のデータマートも他のチームのクエリからは参照されている場合があり、移行にともない混乱がありました。利用する側もそうですが、データマートの管理者がだれなのか品質を担保するために把握できる仕組みが必要に思いました。 まとめ 本記事では古くなった共通データ基盤をリプレイスする事例をご紹介しました。実際にリプレイスしてみると記事では書ききれない泥臭さもありましたが、旧データ基盤の負債も解消できてよかったです。旧データ基盤の負債を解消するために、マスタ情報の整理や運用の見直し、進め方等様々なチームと連携し進めました。関係者のみなさんにも感謝です。 本記事を読んで、もしご興味をもたれた方は、是非採用ページからご応募ください。 hrmos.co 最近配信チームに異動したのでこちらも是非! hrmos.co
はじめに こんにちは。ZOZOTOWN開発本部フロントエンドの菊地( @hiro0218 )です。 現在、 ZOZOTOWN ではWebフロントエンド技術のリプレイスプロジェクトが進行しています 1 。本記事では、WebフロントエンドのリプレイスでCSS in JSの技術選定をした際の背景や課題についてご紹介します。 既存技術スタックの課題 リプレイス以前の環境は、Classic ASPのテンプレートエンジンに依存したUI実装が多く存在しており、新規開発や変更のタイミングで実装をReact + CSS Modulesへ改修しています。そのため、レガシーな実装とモダンな実装が共存した状態です。 こういった背景から、リプレイス以前のUI開発では以下のような課題がありました。 グローバルなCSSが多く、CSSの変更がどこへ影響するのか予測しづらい Classic ASPのテンプレートエンジンに依存したUI実装が多く存在しているため CSS Modulesの課題 近い将来、非推奨になる可能性がある css-loaderのCSS Modulesはメンテナンスモードになっており 2 、再リプレイスを視野に入れないで済むように活発な技術にしたい コンポーネント側でクラス名が間違っていてもエラーが発生しない .d.ts ファイルを自動生成してクラス名の補完やエラーが分かるようにはしていた スタイルの優先度に保証がない 管理コストがかかる CSSファイルとJSファイルが別なため ローカルスコープ(CSSクラス名を衝突させない)になる設定を外していた ページ単位でグローバルなCSSから上書きする必要があった 3 CSS設計を用いてクラス名が競合しないようにしていた CSS Modulesを利用していますが、レガシーな実装とモダンな実装の整合性を取るために、CSSのクラス名をローカルスコープに出来ていませんでした。いずれにしてもReactコンポーネントの再開発が必要になり、リプレイス時に過去の資産を完全に活かし切るのも難しい状況であったため、技術スタックの再考の余地はあると考えました。 リプレイス以前の環境における課題や背景については、「 ITCSS を採用して共同開発しやすい CSS 設計を ZOZOTOWN に導入した話 」でも触れております、興味のある方は併せてご確認ください。 techblog.zozo.com なぜCSS in JSを使うのか CSS in JSとは、コンポーネントに属するCSSをバンドルさせるためのアプローチです。CSS in JSを利用することで、CSSはコンポーネントに定義され、外部のCSSファイルに依存することなくコンポーネント単体で独立して動作させることができます。 既存技術スタックの課題でも挙げましたが、これまではグローバルなCSSを利用しており、CSSの定義を変更した際にどこへ影響があるか予測しづらいという課題がありました。CSS in JSを利用することで、CSSの変更による影響をコンポーネント内に留めることができます。 宣言的にUIを実装できるReactとの親和性も高いです。 CSS in JSの選定基準 CSS in JSのライブラリを選定する上で基準としたものは以下になります。 タグ付きテンプレートリテラル記法が使えること メンバーがキャッチアップしやすいことを前提として、通常のCSSの使用感を変えたくない オブジェクトスタイル記法だと既存実装を移植する際に難がある Sass(SCSS)記法のようなネストセレクタが使えること TypeScriptとの親和性があること 参考資料が多いこと メンテナンスが活発であること CSS in JSの選定候補 いくつものCSS in JSライブラリを確認しましたが、使用感など選定基準にマッチしなかったものも多く、最終的に以下のライブラリが選定候補となりました。 Linaria Styled Components Emotion Linaria(Zero-runtime CSS in JS)を検証する パフォーマンスの観点で考えるとZero-runtime CSS in JSは理想的です。その中でもStyled ComponentsやEmotionなどの先発ライブラリと同様の構文を備えているLinariaは有力な候補のひとつでした。 しかしながら、検証していくうちに以下のような課題が出てきました。 Linariaは動的なスタイルを使用した場合にCSS Custom Propertiesを出力するため、多用するとCSSが肥大化してしまう 動的なスタイルの値が undefined な場合に不要なプロパティが残ってしまう const Heading = styled.h1` background-color: ${({ bg }) => bg}; `; const Example = () => { return ( <> <Heading bg="red">Red Heading</Heading> <Heading>Heading</Heading> </> ); }; <!-- 出力後 --> <h1 class="tzzg9j5w" style="--tzzg9j5w-0:red;">Red Heading</h1> <h1 class="tzzg9j5w" style="--tzzg9j5w-0:undefined;">Heading</h1> <style> .tzzg9j5w { background-color: var(--tzzg9j5w-0); } </style> 同じコンポーネントをネストすると再利用されたCSS Custom Propertiesが上書きされていまい意図した動作をしない const Stack = styled.div` & > * + * { margin-top: ${({ spacing }) => spacing || "0"}; } `; const Example = () => { return ( <Stack spacing="1rem"> <Stack></Stack> <Stack></Stack> {/* ここの margin-top が 0 になってしまう */} </Stack> ); }; ネストされた2番目の Stack の margin-top は 1rem となることを期待するが、CSS Custom Propertiesの上書きによって 0 になってしまう - 現時点で他社の採用事例やドキュメントがまだ少ない ZOZOTOWNは複雑なアプリケーションな上に開発メンバーも多く、上記のような課題が発生してしまうと開発の足かせになってしまいかねないため、今回は導入を見送りました。 Styled ComponentsとEmotionを比較する 選定候補はStyled ComponentsもしくはEmotionの2つになりました。この両ライブラリの比較検討の際に行った比較方法を紹介します。 機能面の比較 以前はStyled Componentsに比べ、後発のEmotionの方が利用できる機能は多かったようです。ですが、調べてみると現状はStyled ComponentsもEmotionと同様の機能があるようでした。 ライブラリ タグ付きテンプレート オブジェクトスタイル グローバルスタイル ネストセレクタ Theme Provider TypeScriptサポート Server Side Rendering Styled Components ✅ ✅ ✅ ✅ ✅ ✅ ✅ Emotion ✅ ✅ ✅ ✅ ✅ ✅ ✅ 両ライブラリとも必要としていた機能はありました。 トレンドの比較 ライブラリのスター数(GitHub)は先発ライブラリに分がありますし、いくらスター数が多くても今は積極的に使われていない可能性もあります。今のダウンロード数を見てみれば、そういった可能性を排除できるため、 npm trends からダウンロード数の推移を確認してみたいと思います。 npmtrends.com Emotionは機能別にパッケージが分かれているため、Styled Componentsと同機能を持つ @emotion/styled と styled-components で比較しました。 npm trends - @emotion/styled vs styled-components 比較した結果、後発のEmotionが途中からStyled Componentsのダウンロード数を追い越して安定しているようでした。大きな差があるとは言えませんし、ライブラリの比較条件が異なっているため、npm trendsの比較だけでは、両者の優劣を判断できないと考えました。 次に、 The State of CSS 2021 を見てみます。 The State of CSS は、世界中の開発者へアンケートを実施し、その回答の集計結果を公開することで技術選定やトレンドを見つけることを目的としたサイトです。 2021.stateofcss.com その中から CSS-in-JS の項目を参考にユーザーの声を見てみたいと思います。 Styled ComponentsとEmotionを抜粋すると以下の通りです。 項目 Styled Components Emotion 満足度 (また使いたいvsもう使わない) 77% (4位 ⬇) 74% (6位 ⬇) 興味 (学びたいvs興味がない) 54% (5位 ⬇) 42% (10位 ⬇ ) 利用率 (また使いたい + もう使わないvs認知度) 52% (1位 ➡) 19% (4位 ➡) 認知度 (総数 - 聞いたことがない) 87% (1位 ➡) 49% (4位⬆) ユーザー数や満足度のいずれもStyled Componentsの方がEmotionを上回っている結果が見られました。 歴史の長さからStyled Componentsの利用率や認知度は非常に高いようです。後発のEmotionについてもStyled Componentsのすぐ後に登場しているため大きく状況は変わらないでしょう。両ライブラリの満足度や興味は下がっている一方で利用率や認知度は年を経ても大きく変化がないのは、新しいライブラリの台頭に押されているのだと推察できます。実際、既存ライブラリが上回ったわけではなく、新しいライブラリが上位に来ています。両ライブラリは、CSS in JSライブラリとして枯れつつある状況だという印象を受けました。 パフォーマンスの比較 両ライブラリのパフォーマンスを比較検証した記事を見ると「Emotionのパフォーマンスの方が高い」という結果が散見されました(Emotionは公式でも高パフォーマンスを謳っています 4 )。しかし、記事の検証時期が古くなっているものも多く、現在のバージョンでどちらのパフォーマンスが高いのかは判断しづらくもありました。 そういう背景から各ライブラリの最新バージョンでベンチマークを測定しました。ベンチマークは、ReactのUIコンポーネントライブラリである MUI (旧名:Material UI)が公開 5 しているものを利用して測定しました。 測定条件 ライブラリのバージョン @emotion/styled : v11.10.0 styled-components : v5.3.5 実行条件 5000個のコンポーネントを描画する <> {new Array(5000).fill().map(() => ( <Div>test case</Div> ))} </> 20回繰り返して計測 実行マシンのスペック MacBook Pro (2019) プロセッサ:2.6GHz 6-CORE i7 メモリ:32GB 2667 MHz DDR4 測定結果 ライブラリ 1 2 3 4 5 Styled Components (styled) 196.73 ±08.10ms 192.29 ±06.04ms 204.88 ±44.59ms 197.84 ±14.31ms 194.33 ±07.25ms Emotion (styled) 190.96 ±09.97ms 189.03 ±13.87ms 180.45 ±07.79ms 191.86 ±10.56ms 191.20 ±09.80ms 測定結果をみるとEmotionの方が良いパフォーマンスでした。様々な条件で確認したところ、特に描画するコンポーネント数が多い場合はEmotionの方が高パフォーマンスという結果が見られました。 near-zero runtimeを謳う Stitches というCSS in JSライブラリの ベンチマーク結果 にEmotionとStyled Componentsも比較対象として載っています。Stitchesに軍配が上がってはいますが、そちらでもStyled ComponentsよりEmotionの方が高パフォーマンスという結果が出ていました。 バンドルサイズの比較 Styled Componentsのパッケージは1つのパッケージになっていますが、Emotionはパッケージが用途別に分かれています。そのため、例えば @emotion/styled を使うか否かで当然バンドルサイズも変わります。そのため、単純なパッケージ同士のバンドルサイズの比較は難しいため、今回は利用するであろうパッケージを揃えて比較しました。 パッケージ バンドルサイズ Styled Components 46.07KB → 17.02KB (gzip) Emotion ( @emotion/react , @emotion/styled ) 43.78KB → 16.21KB (gzip) 単純なバンドルサイズでの比較では、Emotionの方が僅かにサイズが少ないようでした。 まとめ スタイリングを行うためのアプローチとしてはCSS in JS、ライブラリはEmotionを選択しました。 CSS in JSを選択した理由: スタイルがコンポーネントと紐づくため、関心の分離 6 が行われる ローカルスコープなCSSクラス名が自動生成されるため、コンポーネント同士が影響し合わない JavaScriptの変数・関数と統合できるため、CSSの変数や関数よりも、コンテキストに基づいた動的なスタイリングがしやすい Emotionを選択した理由: 機能要件を満たせていた パフォーマンスやバンドルサイズの観点でStyled Componentsよりも優れていた 著名なライブラリのため、利用率も高く情報のキャッチアップがしやすい 他社の技術記事でEmotionの採用事例をいくつか見られた ReactのUIライブラリ( MUI 、 Chakra UI など)が内部的にEmotionを利用しており、エッジケースの実装が参考になった 初めてCSS in JSに触れるメンバーでもEmotionを利用した開発は参入障壁にはならず、スムーズに開発を進めることができました。必要に応じてメンバー自らキャッチアップしながら進めていくことも出来ています。署名なライブラリのためキャッチアップしやすいことが功を奏していると感じています。 フロントエンドの技術スタック全般に言えることでもありますが、The State of CSSでの推移を見ても分かる通り、CSS in JSライブラリの移り変わりは早いです。技術選定は「どのようなスキルセットの開発メンバーが関与していくのか」「選定したライブラリが参入障壁にならないか」「ライブラリの情報はキャッチアップしやすいか」などを重視しました。開発メンバーにとっても納得感のある技術選定をすることの重要性を再認識できました。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co hrmos.co ZOZOTOWNリプレイスにおけるWebフロントエンドのこれから / The future of web frontend in ZOZOTOWN replacement - Speaker Deck ↩ webpack-contrib/css-loader: Interoperability across tools and support plain JS modules imports ↩ ITCSSを採用して共同開発しやすいCSS設計をZOZOTOWNに導入した話 - ZOZO TECH BLOG ↩ Emotion - Performance ↩ https://github.com/mui/material-ui/tree/master/benchmark/browser ↩ マークアップとロジックを別々のファイルに書いて人為的に技術を分離するのではなく、マークアップとロジックを両方含む疎結合の「コンポーネント」という単位を用いて関心を分離します。 ↩
はじめに こんにちは、計測プラットフォーム開発本部SREブロックの渡辺です。普段はZOZOMATやZOZOGLASSなどの計測技術に関わるシステムの開発、運用に携わっています。 先日私達のチームでは、EKS環境にArgo CDを導入し、デプロイパイプラインのリアーキテクトを行いました。 開発環境では、Argo CD Image Updater(以下、Image Updaterとする)を活用したスピーディなデプロイ設計をしました。詳しくは「EKS環境へArgo CD Image Updaterを導入し、デプロイ時間と管理コストを削減した話」を参照ください。 techblog.zozo.com 本記事では、Argo CD導入による本番環境のリリースフロー設計やタグ更新の仕組みなど工夫した点について紹介します。Argo CDを検討している方に向けて、少しでも参考になれば幸いです。 目次 はじめに 目次 Argo CD導入前の課題 Argo CD導入 Argo CDのProject設計 GitHubリポジトリ設計 ブランチ戦略 CI/CD設計 イメージタグの更新方法 Image Updaterの考慮すべき点と対応 ロールバック 導入前後の比較 まとめ 終わりに Argo CD導入前の課題 ここでは例としてZOZOMATにおけるArgo CD導入前のCI/CDアーキテクチャを下図に示します。 Argo CD導入前までは、以下の手順でリリースしていました。 CI アプリケーションの変更をmainブランチへ取り込む CircleCIのイメージビルド用のジョブが発火する skaffold buildを実行し、イメージをビルドしてECRにプッシュする ビルド時に生成されたイメージタグが記録されたjsonファイルをS3にアップロードする CD ビルド完了を待ち、CDをトリガーするスクリプトを実行する(引数でデプロイ先の環境を指定) CircleCIからCodePipelineのアクションプロバイダーであるS3にソースコードをアップロードする CodeBuildがソースとイメージタグファイルをS3から取得し、Skaffoldを使ってapplyする 旧CI/CDの問題点については、 先の記事 でも説明していますが、本番環境も同様に大きな課題はCD部分にありました。 開発環境ではスピーディなデプロイが求められましたが、本番環境では以下のようなデプロイ作業の不安定さがより課題感としてありました。 CIの完了確認やデプロイする環境はリリース担当者が判断する CDのトリガーをリリース担当者が手作業で行う 各プロダクトごと作業内容が異なる(ZOZOMATとZOZOGLASSなど) 問題発生時の調査範囲が広くなり、ロールバック判断が遅れる 横展開の工数が増える(0->1でCI/CDを構築する必要がある) 新規にジョインしたメンバーのキャッチアップコストが大きい Argo CD導入 上記に挙げたCDの課題を解決するため、Argo CDを導入することにしました。 Argo CDは、Kubernetes環境での GitOps を実現するためのCDツールです。Gitリポジトリで管理しているKubernetesマニフェストを監視して、Kubernetesクラスタに適用します。Gitを信頼できる唯一のソースとすることで、Kubernetesクラスタ内の状態を管理するものです。 GitOpsを実現するツールとして、Argo CDの他にもFluxやJenkins Xなどが挙げられます。この中でArgo CDを採用した大きな理由は、「Syncの状況が把握しやすい」「GUIの操作で特定のリソースだけをSyncできる」などGUIが優れている点になります。また、リリースはSREだけでなくバックエンドチームも担当するため、わかりやすいツールで運用することは上で重要なポイントだと判断しました。 それでは、私達がArgo CDを導入するにあたり検討した以下の内容について説明します。 Argo CDのProject設計 GitHubリポジトリ設計 ブランチ戦略 CI/CD設計 イメージタグの更新方法 Argo CDのProject設計 Argo CDにはProjectという概念があります。ProjectはApplicationをグループ化するものです(ApplicationはGitOpsするGitリポジトリの設定)。 Projectには主に以下の機能があります。詳しくは 公式ドキュメント を参照してください。 デプロイできるソース (GitHub) を制限する機能 デプロイする対象 (Clusterやnamespace) を制限する機能 デプロイできるObject(Kubernetesリソース)を制限する機能 操作権限を制限するRBACを提供する機能 今回、私達が厳密に制御したかったのはデプロイ対象のnamespaceでした。 これは、私達が単一のEKSクラスタの上で複数のサービスを運用するシングルクラスタ・マルチテナント構成を採用していることが大きな理由になります。シングルクラスタ・マルチテナント運用については、「EKSのマルチテナント化を踏まえたZOZOGLASSのシステム設計」を参照してください。 techblog.zozo.com 1つのクラスタにプロダクト毎namespaceを作成しているため、 あるプロダクトのリソースが別のプロダクトのnamespaceにデプロイできない制限を設ける必要がある のです。 そこで、上記のうち2番目の「デプロイする対象 (Clusterやnamespace) を制限する機能」に関して、Projectの機能を利用したnamespaceの制限について紹介します。 Argo CDは初期設定としてdefaultという名前のProjectが作成されてますが、こちらは制限なしのProjectです。全てのApplicationをdefault Projectで管理する問題として、どのApplicationからでも複数のnamespaceにデプロイできてしまうことが挙げられます。 例えば、ZOZOMATをzozomat namespace、ZOZOGLASSをzozoglass namespaceで管理している場合、ZOZOMATのApplicationからzozoglass namespaceにデプロイできる状態になってしまいます。 そこで私達は、これを防止すべく下図のように1namespace 1Projectで設計しました。 設計当初は1Application 1Projectでよりセキュアにする考えもあったのですが、この設計ではApplicationのグループ化を行えないデメリットがありました。 今後namespace内でマイクロサービス化する可能性があり、1つのnamespaceに複数のApplicationを設定することが予想されます。このため、1namespace 1Projectで管理することが望ましいと考えました。 ただ、今回はArgo CD導入初期として1namespace 1Projectで設計しましたが、今後プロダクトで共通利用するnamespaceを作成する可能性もあります。このため、変化する状況に合わせて柔軟に対応していきたいと思います。 GitHubリポジトリ設計 計測システム部では、バックエンドとSREは別のチームとして存在しています。以前はプロダクトごとにアプリケーションリポジトリ(以下、アプリリポジトリ)の中でKubernetesマニフェストを管理していました。 私達が1つのリポジトリで管理して実感した課題は以下のとおりです。 アプリケーションの変更を追跡しづらい コミット履歴にKubernetesマニフェストの変更がノイズとして入る ロールバックする際、アプリケーションは戻したいけどKubernetesマニフェストは戻したくない場合に面倒 Kubernetesマニフェストの変更でもCI(イメージビルド)が動く podの数を変えたいだけなのにアプリケーションのテストが走るストレス リポジトリ運用をインフラとバックエンド間で調整する必要がある ブランチ戦略を決める際に双方の要望を叶えようとするとリポジトリ設定やCI定義等が複雑化しがち アプリリポジトリに本番環境を変更する仕組みや権限を配置する必要がある Argo CDのベストプラクティス では、 アプリケーションのソースコードとKubernetesマニフェストを分けて管理することが推奨されています 。このため、プロダクトごと新たにKubernetesマニフェストを管理するリポジトリ(以下、Kubernetesリポジトリ)を作成しました。 チーム単位でリポジトリを分けたことで、上記のデメリットを解消できました。 ブランチ戦略 ここではKubernetesリポジトリのブランチ戦略を紹介します。 Argo CDは、Applicationの設定においてターゲットとしてリポジトリのブランチを指定できるため、環境ごとにブランチを分けることで1つのリポジトリで複数環境を構築できます。 そこで、私達はmainブランチをステージング環境、releaseブランチを本番環境として運用しています。デフォルトブランチをmainに設定し、PRの向き先をmainブランチへ指定します。 本番環境のターゲットとなるreleaseブランチはmainブランチを追従し、ステージング環境で動作確認してから本番環境に反映します。 余談ですが、アプリリポジトリと分けたことで、ブランチ戦略がバックエンドチームと競合しなくなりました。ブランチ戦略の決定権がSREチーム内にあるため、他チームとの調整が不要になり設定が楽になりました。 CI/CD設計 それでは、本記事のメインとなるArgo CD導入後の本番リリースフローについて説明します。 開発環境との大きな違いは、承認フローを設けているところです。 先の記事 で説明していますが、開発環境では作業効率を優先したためImage Updaterを活用した承認フローなしのデプロイを行っています。 しかし、 本番環境ではデプロイ時間の短さよりも安定性を求めているため、承認フローは必須条件です 。このため開発環境とは別の仕組みでリリースフローを検討する必要がありました。 通常、アプリケーションをデプロイするためには、アプリリポジトリでビルドしたイメージをKubernetesマニフェストに反映させる必要があります。 ここでは、Push型の方法としてアプリリポジトリのCIでKubernetesリポジトリを取り込み新しいタグに書き換え、変更コミットを含むブランチを作成する仕組みを構築することが一般的かと思います。 私達も当初はそのような仕組みを考えていました。しかし、開発環境で導入したImage Updaterに イメージリポジトリの変更を検知して、イメージを変更するコミットを含むブランチを自動作成する機能 があることを知りました。こちらはPull型の方法で、ECRなどをImage Updaterが監視して最新のイメージを検知した際、Push型同様Kubernetesリポジトリに変更コミットを含むブランチを作成するものです。 今回は、検証の意味も含めImage Updaterを活用した仕組みを構築しました。イメージタグ更新の仕組み下図に示します。 アプリリポジトリでビルドしたイメージがECRにプッシュされる Image Updaterが最新のイメージを検知 Image UpdaterからKubernetesリポジトリにタグ更新コミットつきブランチを作成 GitHub Actionsでブランチ作成をトリガーにmainブランチ向けリリースPRを作成 アプリリポジトリのCIはイメージをECRにプッシュするまでを責務とし、イメージを検知したImage UpdaterがKubernetesリポジトリに変更を通知するような構成になっています。Kubernetesリポジトリ側は、通知をトリガーにGitHub Actionsでタグを更新するPRを作成するというのが大まかな流れになります。 イメージタグの更新方法 それでは、上記の仕組みの構築方法について説明していきます。 まずは事前準備として、ステージング環境にImage Updaterをインストールし、Argo CDに設定しているApplicationのannotationsに以下の設定を追記します。 metadata : annotations : argocd-image-updater.argoproj.io/write-back-method : git argocd-image-updater.argoproj.io/git-branch : main:image-updater-{{.SHA256}} argocd-image-updater.argoproj.io/image-list : my-image=<AWS Account>.dkr.ecr.<Region>.amazonaws.com/<ECR Repo> argocd-image-updater.argoproj.io/my-image.update-strategy : latest argocd-image-updater.argoproj.io/my-image.ignore-tags : latest こちらの設定により、イメージを検知したImage Updaterが変更コミットつきブランチを作成します(この他にも柔軟な設定ができるので、詳しくは 公式ドキュメント を参照してください)。 なお、Image UpdaterはECRのイメージ情報を取得する権限が必要になります。ここでは詳しく説明しませんが、気になる方は 先の記事 を参照してください。 それでは、Image UpdaterがKubernetesリポジトリにどのようなアクションを行うのか見ていきましょう。 私達のチームでは環境差分を管理しやすくするためkustomizeを用いており、overlaysディレクトリの中で各環境のディレクトリを作成しマニフェストを管理しています。 最新のイメージを検知したImage Updaterがoverlays/stagingディレクトリにあるイメージ管理ファイルのタグを変更するコミットつきブランチを作成します。 イメージ管理ファイルは、 .argocd-source-<Application名>.yaml という名前で以下のような内容になります。 kustomize : images : - <AWS Account>.dkr.ecr.<Region>.amazonaws.com/<ECR Repo>:<Tag> この時点では、まだmainブランチのファイルを更新していないため、ステージング環境のPodが入れ替わることはありません。 次にGitHub ActionsでImage Updaterが作成したブランチをトリガーに、mainブランチ向けステージングリリースPRを作成します。 先ほど紹介したArgo CDの各Applicationのannotationsのうち、以下の設定によりimage-updaterから始まるブランチが作成されます。 argocd-image-updater.argoproj.io/git-branch: main:image-updater-{{.SHA256}} そして、GitHub Actionsのトリガーを以下のように設定することで、Image Updaterが作成したブランチのみをトリガーにアクションが実行されるようにします。 on : push : branches : - 'image-updater-**' GitHub Actionsでは、「overlays/productionディレクトリにある本番環境用イメージ管理ファイルをステージング同様の変更に更新する処理」や「PRを作成する処理」を実行します。なお、1つ目の処理をmainブランチに反映しても本番環境に反映されることはありません(ターゲットとなるreleaseブランチには変更が反映されていないため)。 mainブランチ反映後、ステージング環境のArgo CDが差分を検知しデプロイします。ステージング環境の動作確認に問題がなければ、GitHub Actionsで自動作成されたreleaseブランチ向け本番リリースPRをマージします。releaseブランチ反映後、本番環境のArgo CDが差分を検知しデプロイします。 ちなみに、本番環境にもImage Updaterをインストールしてステージング環境と同様の仕組みを構築できますが、以下の懸念点があるため私達は本番環境への導入は見送りました。 mainとreleaseブランチを独立して運用することで、ステージング環境で動作確認したイメージと同じものをデプロイする保証がなくなる 私達のチームではステージング環境と本番環境の差分をなるべく減らして運用しています。安定したリリースフローを構築するためには、ステージング環境で動作確認したバージョンを確実にリリースする必要があると考えています。このため、「releaseブランチはmainブランチを追従する」というブランチ戦略を崩す選択はできないのです。 なお、ステージング環境で動作確認したイメージが本番環境のECRに存在することを保証するため、GitHub Actionsでイメージタグを検証する処理を追加しています。このアクションは本番リリースPRが作成されたタイミングで発動し、失敗するとPRをマージできない仕組みになっています。 Image Updaterの考慮すべき点と対応 Image UpdaterとGitHub Actionsを組み合わせることで自動リリースPRが作成されますが、運用してみると工夫しなくてはいけない問題が出てきました。 当初、私達は下図のように自前管理している全てのイメージをImage Updaterの監視対象としていましたが、以下のような不都合が生じました。 Image Updaterの監視サイクルによって変更を検知しないイメージが出る場合もある 同じイメージにタグがついた場合、Image Updaterが検知しない まず、「Image Updaterの監視サイクルによって変更を検知しないイメージが出る場合もある」から説明します。 私達が管理しているCIでは各イメージを並列でビルドして、完了したイメージからECRにプッシュしています。 このため、Image UpdaterがECRの情報を取得するタイミングによっては、プッシュが完了したイメージもあればプッシュが完了していないイメージもあります。 Image Updaterは1つでもイメージを検知すればブランチを作成するため、自動作成されたPRを確認すると一部のイメージが更新されていないケースもありました。 時間の経過と共に全てのイメージのプッシュが完了するため、時間を空けると全てのイメージを更新するPRが作成されますが、複数のPRが存在することでオペレーションミスが発生することも考えられます。 次に「同じイメージにタグがついた場合、Image Updaterが検知しない」を説明します。 私達はCircleCIでSkaffoldを利用してDockerイメージをビルドしECRへプッシュしており、高速化のためブランチごとキャッシュを持っています。 自前管理しているイメージのうち一部は内容に変更がない場合もあり、ECRへプッシュする際にタグが既存のイメージに付与されることがあります。 Image Updaterには最新のイメージを検知するよう設定しているため、既存のイメージにタグ付けされた場合は検知されません。このケースは時間が解決する問題ではないため、イメージタグがバラバラになってしまいます(同じイメージを参照しているが保守性が失われる)。 そこで、上記2つの課題を解決するため、下図のようにアプリケーション本体のイメージだけを監視し、GitHub Actionsで他のイメージに同様のタグをつける処理を実装しました。 アプリケーションのイメージは最もビルドに時間がかかり、リリースのたびに内容が変更されるため新しいイメージとしてECRにプッシュされるため、単一の監視対象として適切でした。 自動作成されたPRを確認すると、最初のコミットはImage Updaterが検知したアプリケーション本体のイメージタグの更新です。 次のコミットは、GitHub Actionsで処理している 他のイメージタグをアプリケーション本体のイメージタグと統一させる 変更です。 最後のコミットは、GitHub Actionsで処理している 本番環境のタグ管理ファイルをステージングのタグ管理ファイルと同じ内容に書き換える 変更です。 なお、GitHub Actionsの処理は各プロダクトで共通しているので、差分をパラメータ化した共通アクションとすることで管理コストを抑えることができました。 共通化したアクションはこのような内容になります。 inputs : argocd-application : description : Argo CD Application Name required : true source-image : description : image watched by Image Updater required : true target-images-to-duplicate-image-tag : description : images apart from source-image. The format of item is `imageA,imageB,imageC` required : true runs : using : composite steps : - name : Update production file shell : bash run : | git config --global user.email "action@github.com" git config --global user.name "GitHub Action" STG_FILE="kubernetes/overlays/staging/${{ inputs.argocd-application }}.yaml" PRD_FILE="kubernetes/overlays/production/${{ inputs.argocd-application }}.yaml" # source-imageのタグを全イメージに反映する。 IMAGES=${{ inputs.target-images-to-duplicate-image-tag }} for image in ${IMAGES//,/ }; do grep -oE ".+${{ inputs.source-image }}:[0-9a-z\-]+$" $STG_FILE | sed "s/${{ inputs.source-image }}/$image/g" >> $STG_FILE done # ステージング用のイメージタグの変更を本番用のファイルにも反映させる sed -e 's/<ステージング環境のAWS Account>/<本番環境のAWS Account>/g' $STG_FILE > $PRD_FILE git add $STG_FILE git commit -m 'update image tags on staging' git add $PRD_FILE git commit -m 'update image tags on production' git push origin HEAD - name : Create PR to main branch uses : actions/github-script@v6 with : script : | const { repo, owner } = context.repo; const result = await github.rest.pulls.create({ title : 'イメージタグの更新' , owner, repo, head : '${{ github.ref_name }}' , base : 'main' , body : 'staging 環境と production 環境のイメージタグを更新する。\n - staging リリース: main ブランチにマージすると自動で staging 環境の Pod が入れ替わる。\n - production リリース: main から release ブランチの PR をマージすると自動で production 環境の Pod が入れ替わる。' }); 呼び出し側のアクションは以下のようになります。 on : push : branches : - 'image-updater-**' jobs : create-pr : runs-on : ubuntu-latest steps : - name : Checkout uses : actions/checkout@v3 - name : Checkout common actions repository uses : actions/checkout@v3 with : repository : <共通化リポジトリ> path : common token : <Personal Access Token> - name : Use the action in common actions repository uses : ./common/.github/actions/<共通アクション> with : argocd-application : <タグ管理ファイル名> source-image : <Image Updaterが監視するアプリケーションイメージ> target-images-to-duplicate-image-tag : <source-image以外のイメージ群> # The format of item is `imageA,imageB,imageC` ロールバック ロールバックについて、以前はスクリプトを実行してリリース前のタグを指定して再度デプロイしていました。 今回、デプロイのトリガーをPRで管理するようにしたため、本番リリースPRをリバートするだけでロールバック作業が完了します。 ロールバックが必要な状況下においては、素早くかつ正確な作業が求められるため、PRをリバートするだけの仕組みはとても理想的な形になりました。 また、アプリケーションとKubernetesの変更が1つのPRに混在するケースは稀なので、リバートするべきPRを選別する手間はほとんど発生しません。 なお、現在は一部プロダクトでArgo Rolloutsを導入し、デプロイ作業中に異常を検知したら自動ロールバックするといった仕組みを構築しました。 導入前後の比較 リアーキテクト前 リアーキテクト後 デプロイ時間 約8分 3分以内 CDに利用するツール CodePipeline, CodeBuild, Shell Script, Skaffold Argo CD, Argo CD Image Updater オペレーションミスの可能性 別環境へデプロイする可能性あり 別環境へデプロイする可能性なし 横展開のしやすさ × ◯ Argo CDを導入したことで様々な恩恵を受けましたが、中でも横展開しやすさが私達のプロダクトと非常にマッチしていると実感しました。 先ほど紹介したとおり、私達はシングルクラスタ・マルチテナント構成で運用しているため、一度Argo CDを導入すればApplicationリソースにプロダクトを追加するだけでCDの構築が完了します。私達の計測サービスは新規事業に関わる事が多い部署であり、0→1のサービス開発が多いため、ビジネススピードに対応できることは大きなメリットです。 また、各プロダクトごと異なっていたリリース手順を共通化できたことは、管理コストを抑えることに繋がります。複数のプロダクトを管理する状況下においては、マルチテナントとArgo CDの相性の良さを実感しました。 まとめ 今回、Argo CDを導入するにあたり、GitHubリポジトリやブランチ戦略、Argo CDのPropject設計、リリースタグ更新の仕組みなど、様々な設計を検討する必要がありました。試行錯誤しながら私達のチーム状況に合わせたCDリアーキテクトが実現できたと思います。 今回紹介したデプロイフローの中で特徴的なのが、Image UpdaterとGitHub Actionsを組み合わせた自動PR作成の仕組みになります。 実際に運用して感じたメリットは、仕組みを横展開しやすいことです。マルチテナントで複数のプロダクトを運用している私達は、Image Updaterで全プロダクトのイメージを監視し、リリースPRを共通アクションで作成することは大きなメリットだと実感しています。 一方でデメリットは、複数のイメージを管理する場合、Image Updaterの動作に合わせて処理をカスタマイズする必要があることです。このため、複数プロダクトを運用しない環境においては、アプリリポジトリのCIでタグを更新する仕組みを構築した方が管理しやすいかもしれません。 Image Updaterを活用するメリット・デメリットを考慮して、プロダクトに合ったタグ更新の仕組みを構築するといいと思います。 終わりに 計測プラットフォーム開発本部では、今後も ZOZOFIT 等の新しいサービスのローンチを予定しています。更にスピード感を持った開発が求められますが、このような課題に対して楽しんで取り組み、サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co
はじめに ZOZOTOWN開発本部 ZOZOTOWNアプリ部 Android2ブロックの高橋です。 ZOZOTOWN Androidチームでは、リファクタリングやビルド速度改善の取り組みを継続的に行なっています。本記事では、それらの取り組みの効果を可視化するために導入した、コードメトリクスやビルド時間計測の方法について紹介します。 はじめに ZOZOTOWN Androidチームにおけるリファクタリングやビルド速度改善の取り組み コードメトリクスの計測 メトリクス Cyclomatic Complexity(循環的複雑度) LOC(ファイルのコード行数) Author数 計測方法 Cyclomatic Complexityの計測方法 Java Kotlin LOCの計測方法 Author数の計測方法 ビルド時間の計測 計測方法 計測結果の可視化 コードメトリクスの計測結果 計測結果のパース 内製ダッシュボードでの表示 データポータルでの表示 ビルド時間の計測結果 計測結果のパース Gradle Profilerが出力するHTML データポータルでの表示 まとめ 最後に ZOZOTOWN Androidチームにおけるリファクタリングやビルド速度改善の取り組み ZOZOTOWN Androidチームでは以前から、長いビルド時間や保守性の低いコードによって、チームの生産性が低下していることが問題となっていました。 そこで、保守性の高いコードを実現するためのリファクタリングや、ビルドの高速化に取り組んできました。しかし、それらの取り組みと並行して新機能の実装や既存機能の改修なども行なわれていたため、実際の改善度合いを把握することが難しい状況になっていました。 上記のような状況を鑑み、リファクタリングの効果・進捗とビルド時間を計測できる仕組みを検討し、導入しました。 コードメトリクスの計測 リファクタリングの効果・進捗を管理するための方法として、コードメトリクス計測の仕組みを導入しました。 メトリクス 計測するメトリクスは下記の目的に対応するものを選定しました。 リファクタリングの効果が高いファイルの検出 リファクタリングの進捗管理 チームのリファクタリングへの意識向上 属人化しているコードの把握 コードメトリクス計測の導入目的に対応するメトリクスを検討した結果、下記のメトリクスを計測することになりました。 メトリクス 説明 Cyclomatic Complexity(循環的複雑度) メソッド単位でのコードの複雑度 LOC ファイルの行数 Author数 ファイルに対して変更を加えたメンバーの数 メトリクスの検討段階では、上記の他にも「構造複雑度」や「他ファイルからの被参照数」なども有効なメトリクスとして候補に挙がりました。しかし、それらのメトリクスは既存のツールでの計測が難しい、あるいはメトリクスそのものの理解が難しいなどの問題がありました。そこで、比較的スムーズに導入可能かつ理解が容易な「Cyclomatic Complexity」「LOC」「Author数」から計測を始めました。 Cyclomatic Complexity(循環的複雑度) Cyclomatic Complexityは、メソッドの複雑度を示すメトリクスです。大まかにはif文やfor文などの分岐やループによって数値が増えます。数値の目安には決められたものはありませんが、一般的には下表のように言われています。 数値 複雑度とバグの混入リスク 〜10 シンプルな構造でバグの混入のリスクは低い 11〜20 やや複雑で中程度のバグの混入リスクがある 21〜50 複雑でバグの混入リスクが高い 51〜 テスト不可能な状態でバグの混入リスクが非常に高い Cyclomatic Complexityを計測することで、バグの混入リスクが高いメソッドを検出できます。以上から、Cyclomatic Complexityは効果的なリファクタリングやリファクタリングの進捗管理に利用できると考え、計測対象としました。また、継続的にメトリクスを監視することで、チームのリファクタリングへの意識向上にも役立つと考えました。 LOC(ファイルのコード行数) LOCは1ファイルあたりのコード行数を示すメトリクスです。LOCはいくつかの種類があります。 名称 説明 physical LOC(物理LOC) 空行やコメントの行数を含む、テキストファイルとしての行数 logical LOC(論理LOC) 空行やコメントの行数を含まない、実際の処理が記述されている行数 ZOZOTOWN Androidチームでは、空行やコメントを除いた実際の処理部分のリファクタリングにメトリクスを活用するため、logical LOCを計測対象のメトリクスとしました。 LOCを定期的に計測することで、削除予定となっているファイルや既に巨大になっているファイルに対する変更(追加)を把握できます。以上から、LOCはCyclomatic Complexityと同様に効果的なリファクタリングやリファクタリングの進捗管理に利用できると考え、計測対象としました。また、チームのリファクタリングへの意識向上についてもCyclomatic Complexityと同様に、継続的なメトリクスの監視によって達成できると考えました。 Author数 Author数は、ファイルに変更を加えた人数を示すメトリクスです。ZOZOTOWN Androidチームでは全てのコードの変更に対してコードレビューを実施しています。しかし、Author数が1の場合、該当ファイルを直接変更した人が1人しかおらずコードが属人化している状態である可能性が示唆されます。 Author数を計測することで、属人化したコードの内、特に重要な処理が記述されたコードの詳細をチームで共有できます。コードの属人化を解消することで、チームメンバーの仕様・実装理解が促進され、生産性の向上が期待できると考えました。Author数は、コードではなくGitのコミットログを解析して計測するため、一般的なコードメトリクスの文脈とは異なります。しかし、Author数はコードメトリクス計測の目的である「属人化しているコードの把握」に対応する指標であるため、計測することを決定しました。 計測方法 各メトリクスはそれぞれ異なるツールを使用して計測しました。いずれのツールも、継続的なメトリクス計測を目的として、GitHub ActionsのWorkflowに組み込みました。 ここでは、各メトリクスの計測方法とGitHub Actionsへの組み込みについて紹介します。 Cyclomatic Complexityの計測方法 Cyclomatic Complexityの計測には、KotlinとJavaで異なるツールを使用しました。 Java Javaで記述されたコードのCyclomatic ComplexityはJava用の静的コード解析ツールである checkstyle/checkstyle を使用して計測しました。 checkstyleではCyclomatic Complexityのthreshold(許容最大値)がデフォルトでは3になっています。そこで、全てのメソッドのCyclomatic Complexityを検出するため、設定ファイルでthresholdを0に変更しました。 GitHub Actionsでcheckstyleを実行するJobは下記のようになります。 java-complexity : runs-on : ubuntu-latest steps : - uses : actions/checkout@v3 - name : Setup Java uses : actions/setup-java@v2 with : distribution : 'zulu' java-version : '11' - name : Install checkstyle run : curl -sSLO https://github.com/checkstyle/checkstyle/releases/download/checkstyle-10.1/checkstyle-10.1-all.jar - name : Run checkstyle run : find . -name "*.java" | xargs java -jar ./checkstyle-10.1-all.jar -f xml -c .github/checkstyle_rule.xml -o checkstyle_result.xml || true - name : Archive uses : actions/upload-artifact@v2 with : name : result path : checkstyle_result.xml このJobでは、checkstyleを実行し、出力結果を保存します。checkstyleは静的解析によって発見されたエラーの数がexitコードとなります。stepを正常終了させるため、ここではcheckstyleのexitコードを無視しています。出力結果はGitHub ActionsのArtifactsとして保存します。 レポートファイルは下記のようなXMLで出力されます。 <? xml version = "1.0" encoding = "UTF-8" ?> <checkstyle version = "10.1" > <file name = "/path/to/File.java" > <error line = "18" column = "5" severity = "error" message = "Cyclomatic Complexity is 1 (max allowed is 0)." source = "com.puppycrawl.tools.checkstyle.checks.metrics.CyclomaticComplexityCheck" /> <error line = "25" column = "5" severity = "error" message = "Cyclomatic Complexity is 1 (max allowed is 0)." source = "com.puppycrawl.tools.checkstyle.checks.metrics.CyclomaticComplexityCheck" /> </file> ... </checkstyle> file タグの name と error タグの line 、 column と message を見ることで、計測対象のファイルに含まれるメソッドのCyclomatic Complexityを確認できます。 Kotlin Kotlinで記述されたコードのCyclomatic Complexityは、Kotlin用の静的コード解析ツールである detekt/detekt というツールを使用して計測しました。detektはコマンドラインツールやGradle Pluginとして利用できます。 detektではCyclomatic Complexityのthresholdがデフォルトでは15になっています。そこで、checkstyleと同様に全てのメソッドのCyclomatic Complexityを検出するため、設定ファイルでthresholdを0に変更しました。 complexity : ComplexMethod : active : true threshold : 0 GitHub Actionsでdetektを実行するJobは下記のようになります。 kotlin-complexity : runs-on : ubuntu-latest steps : - uses : actions/checkout@v3 - name : Install detekt run : | curl -sSLO https://github.com/detekt/detekt/releases/download/v1.21.0/detekt-cli-1.21.0.zip unzip detekt-cli-1.21.0.zip - name : Run detekt run : ./detekt-cli-1.21.0/bin/detekt-cli -c .github/detekt-config.yml -r xml:detekt_result.xml || true - name : Archive uses : actions/upload-artifact@v2 with : name : result path : detekt_result.xml このJobでは、detektを実行し、出力結果を保存します。checkstyleと同様に、stepを正常終了させるため、exitコードを無視しています。出力結果はGitHub ActionsのArtifactsとして保存します。 レポートファイルは下記のようなXMLで出力されます。 <? xml version = "1.0" encoding = "UTF-8" ?> <checkstyle version = "4.3" > <file name = "/path/to/File1.kt" > <error line = "30" column = "9" severity = "warning" message = "The function foo appears to be too complex (1). Defined complexity threshold for methods is set to & apos ; 0 & apos ; " source = "detekt.ComplexMethod" /> </file> <file name = "/path/to/File2.kt" > <error line = "9" column = "27" severity = "warning" message = "The function bar appears to be too complex (2). Defined complexity threshold for methods is set to & apos ; 0 & apos ; " source = "detekt.ComplexMethod" /> </file> ... </checkstyle> detektもcheckstyleと同様に、 file タグと error タグを見ることで、計測対象のファイルに含まれるメソッドのCyclomatic Complexityを確認できます。 LOCの計測方法 LOCの計測はさまざまなプログラミング言語に対応したLOC計測ツールである、 AlDanial/cloc を使用しました。 GitHub Actionsでclocを実行するJobは下記のようになります。 lines-of-code : runs-on : ubuntu-latest steps : - uses : actions/checkout@v3 - name : Setup cloc run : sudo apt install cloc - name : Run cloc run : cloc ./ --by-file --exclude-dir=build --include-ext=java,kt --xml --out=cloc_result.xml - uses : actions/upload-artifact@v2 name : Archive with : name : result path : cloc_result.xml このJobでは、clocを実行し、出力結果を保存します。JavaとKotlin以外のファイルや、ビルド時に生成されるファイルは解析対象から除外するため、 exclude-dir と include-ext を設定しています。出力結果はLOCと同様に、GitHub ActionsのArtifactsとして保存します。 レポートファイルは下記のようなXMLで出力されます。 <? xml version = "1.0" encoding = "UTF-8" ?> <results> <header> <cloc_url> github.com/AlDanial/cloc </cloc_url> <cloc_version> 1.82 </cloc_version> ... </header> <files> <file name = "path/to/File1.java" blank = "493" comment = "275" code = "2315" language = "Java" /> <file name = "path/to/File2.java" blank = "262" comment = "213" code = "1841" language = "Java" /> <file name = "path/to/File3.java" blank = "210" comment = "117" code = "1646" language = "Java" /> ... </files> clocでは、 file タグを見ることで、空行の数とコメント行数、logical LOCを個別に確認できます。 Author数の計測方法 Author数の計測は iwata-n/git-analyze をベースとし、カスタマイズしたものを使用しました。git-analyzeはファイル毎のコミット数やAuthor数を計測するリポジトリマイニングのツールです。git-analyzeは対象となるプロジェクトの全てのファイルに対して計測処理が実行されます。そこで、計測対象のファイル拡張子を指定できるようカスタマイズしたものを作成し、JavaとKotlinファイルのみを計測対象としました。 GitHub Actionsでgit-analyzeを実行するJobは下記のようになります。 number-of-authors : runs-on : ubuntu-latest steps : - uses : actions/checkout@v3 with : fetch-depth : 0 - name : Run git-analyze run : | chmod u+x .github/git-analyze .github/git-analyze -branch=$TARGET_BRANCH -parse-file=git_analyze_result.json -ext=kt,java - name : Archive uses : actions/upload-artifact@v2 with : name : result path : git_analyze_result.json このJobでは、git-analyzeを実行し、出力結果を保存します。出力結果は、GitHub ActionsのArtifactsとして保存します。 レポートファイルは下記のようなJSONで出力されます。 [ { " Path ": " path/to/File.kt ", " Authors ": [ " Metrics Taro ", " Metrics Hanako " ] , " CommitHash ": [ " f068ff6311893bdbae010c9c43b25ee65f1ccb06 ", " ab70bc1da76f067a3f9eea97159280750d998941 " ] , " CreateBy ": " Metrics Taro " } , { " Path ": " path/to/File.kt ", " Authors ": [ " Metrics Taro " , ] , " CommitHash ": [ " f068ff6311893bdbae010c9c43b25ee65f1ccb06 " , ] , " CreateBy ": " Metrics Taro " } ] 任意のファイルのAuthor数は Authors 配列のサイズを調べることで確認できます。 ビルド時間の計測 ビルド速度改善の効果計測と予期しないビルド時間の悪化を検知するため、ビルド時間計測の仕組みを導入しました。 計測方法 コードメトリクスの計測と同様に、ビルド時間を計測する仕組みもGitHub ActionsのWorkflowに組み込みました。ただし、コードメトリクスとは異なる頻度で計測するため、コードメトリクス計測とは別のWorkflowを用意しました。 ビルド時間の計測には、 Square社が公開しているビルド時間計測に関する記事 を参考に、 gradle/gradle-profiler を使用しました。Gradle ProfilerはGradleを使用しているプロジェクトのビルドパフォーマンスを計測するツールです。Scenarioと呼ばれる設定を記述することで、ビルド時間やAndroid StudioのSync時間など、さまざまなパフォーマンスを計測できます。 Scenarioの設定はSquare社の記事と Android Developers を参考に、下記のようにしました。 build { tasks = [":app:assembleDebug"] gradle-args = ["--offline", "--no-build-cache"] show-build-cache-size = true warm-ups = 4 } このScenarioでは、ビルドキャッシュを使用しなかった場合のビルド時間を計測します。 gradle-args には、プロジェクトが依存しているライブラリの取得時間が計測結果に影響を及ぼすことを防ぐため、 --offline を設定しています。また、ビルドキャッシュによるビルド時間計測への影響を防ぐため、 --no-build-cache も設定しています。 GitHub ActionsでGradle Profilerを実行するJobは下記のようになります。 measure-build-time : runs-on : ubuntu-latest steps : - uses : actions/checkout@v3 - name : Copy CI gradle.properties run : mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name : Set up JDK 11 uses : actions/setup-java@v2 with : distribution : 'zulu' java-version : '11' - name : Prefetch Gradle Dependencies run : ./gradlew --no-daemon assembleDebug - name : Run gradle-profiler run : | curl -s "https://get.sdkman.io" | bash source "$HOME/.sdkman/bin/sdkman-init.sh" sdk install gradleprofiler 0.18.0 gradle-profiler --benchmark --scenario-file .github/performance.scenario build --gradle-user-home $HOME/.gradle - uses : actions/upload-artifact@v2 name : Archive with : name : result path : profile-out このJobではGradle Profilerのインストールと実行し出力結果の保存します。出力結果は、GitHub ActionsのArtifactsとして保存します。 レポートファイルはCSV形式で出力されます。また、後述するHTML形式のレポートも出力されます。 scenario build version Gradle 7.0.2 tasks :app:assembleDebug value total execution time warm-up build #1 267090 warm-up build #2 148813 warm-up build #3 135940 warm-up build #4 131386 measured build #1 131454 measured build #2 135238 measured build #3 141732 measured build #4 136364 measured build #5 139061 measured build #6 135794 measured build #7 138930 measured build #8 142453 measured build #9 141535 measured build #10 143400 各イテレーションでのビルド時間は、 total execution time 列で確認できます。 計測結果の可視化 コードメトリクスの計測結果 コードメトリクスは複数のツールを組み合わせて計測しているため、結果の一覧性がありません。そこで、計測結果を一覧で確認できるダッシュボードを作成しました。また、計測結果をBigQueryに保存し、GoogleデータポータルなどのBIツールでメトリクスの推移を継続的に監視できる仕組みを導入しました。 計測結果のパース ダッシュボードの作成とBigQueryへの計測結果の保存に際して、メトリクス計測ツールが出力するXMLやJSONファイルを1つのJSON Linesファイルに統合するスクリプトを作成しました。このスクリプトは各メトリクスの計測後にGitHub Actions上で実行されます。出力されるJSON Linesファイルは、GitHub ActionsのArtifactsとして保存されます。 作成したスクリプトによって出力されるJSONは下記のようになります。実際はJSON Linesで出力されますが、ここでは見やすさのためフォーマットしています。 { " path ": " path/to/File.kt ", " language ": " Kotlin ", " loc ": { " blank ": 12 , " comment ": 4 , " code ": 39 } , " methods ": [ { " line ": 17 , " complexity ": 1 } , { " line ": 24 , " complexity ": 1 } , ] , " numberOfCommits ": 4 , " numberOfAuthors ": 3 , " branch ": " code_metrics ", " commitHash ": " cca154177ed75807f716bc9594fa16cc9a8405da ", " date ": " 2022-08-19 20:51:11 Asia/Tokyo " } { " path ": " path/to/File2.java ", " language ": " Java ", " loc ": { " blank ": 12 , " comment ": 4 , " code ": 39 } , " methods ": [ { " line ": 9 , " complexity ": 1 } , { " line ": 18 , " complexity ": 10 } ] , " numberOfCommits ": 6 , " numberOfAuthors ": 2 , " branch ": " code_metrics ", " commitHash ": " cca154177ed75807f716bc9594fa16cc9a8405da ", " date ": " 2022-08-19 20:51:11 Asia/Tokyo " } ... 各Keyの説明は下表の通りです。 Key 説明 値の取得に使用するツール path 対象ファイルのパス cloc, checkstyle, detekt, git-analyze language 言語 計測結果のパースをするスクリプト loc LOC cloc loc.blank 空行の数 cloc loc.comment コメントの行数 cloc loc.code logical LOC cloc methods メソッドの情報を格納する配列 checkstyle, detekt methods.line 対象のメソッドが存在する行番号 checkstyle, detekt methods.complexity 対象のメソッドのCyclomatic Complexity checkstyle, detekt numberOfCommits コミット数 git-analyze numberOfAuthors Author数 git-analyze branch 計測を実施したブランチ名 GitHub Actions commitHash 計測時点のコミットのハッシュ値 GitHub Actions date メトリクス計測を実施した日付 計測結果のパースをするスクリプト 内製ダッシュボードでの表示 スクリプトによって1つのファイルに統合された計測データは、社内にホスティングされたダッシュボードで確認できます。統合された計測データをダッシュボードにアップロードすると、1回分の計測結果を一覧で確認できます。このダッシュボードによって、計測結果をBigQueryに保存しない場合でもコードメトリクスの確認が可能になります。 ダッシュボードには、各ファイルのパス、言語、LOCと各ファイルに含まれるメソッドの最大Cyclomatic Complexity、Author数、コミット数が表示されます。任意のファイルのコードメトリクスは、言語やフリーワード入力のフィルターによってアクセスできます。また、各メトリクスでのソートも可能です。 データポータルでの表示 コードメトリクスの計測は、GitHub Actionsのscheduleイベントトリガーによって定期的に実行できます。scheduleイベントトリガーによって計測されたコードメトリクスは、BigQueryに保存することで、その推移を確認できます。 BigQueryに保存されたコードメトリクスは、データポータルで可視化できます。下図はZOZOTOWN Androidチームで継続的にリファクタリングを行なっているファイルの、ある期間にリリースされたバージョン毎のlogical LOCの推移を表しています。 この図からは、1750行以上あったlogical LOCがリファクタリングの取り組みによって100行以上減ったことがわかります。 このように、コードメトリクスの計測結果の保存・表示にBigQueryとデータポータルを利用することで、リファクタリング状況の継続的な監視が可能になります。 ビルド時間の計測結果 ビルド時間の計測結果は、Gradle Profilerが出力するHTMLで確認できます。また、コードメトリクスの計測結果と同様にビルド時間の推移を確認するための仕組みとして、BigQueryとデータポータルを導入しました。 計測結果のパース ビルド時間の計測結果についても、計測結果をBigQueryに保存するため、Gradle Profilerが出力するCSVファイルをJSON Linesファイルへ変換するスクリプトを作成しました。 作成したスクリプトによって出力されるJSONは下記のようになります。 { " times ": [ 153358 , 148786 , 155962 , 168292 , 162758 , 173117 , 162664 , 160480 , 162743 , 166319 ] , " mean ": 161447.9 , " median ": 162703.5 , " min ": 148786 , " max ": 173117 , " branch ": " build_time ", " commitHash ": " cca154177ed75807f716bc9594fa16cc9a8405da ", " date ": " 2022-08-24 01:30:52 Asia/Tokyo " } 各Keyの説明は下表の通りです。 Key 説明 値の取得に使用するツール times ビルド時間の計測結果(ms)の配列 Gradle Profiler mean 計測結果の平均値(ms) Gradle Profiler median 計測結果の中央値(ms) Gradle Profiler min 計測結果の最小値(ms) Gradle Profiler max 計測結果の最大値(ms) Gradle Profiler branch 計測を実施したブランチ名 GitHub Actions commitHash 計測時点のコミットのハッシュ値 GitHub Actions date ビルド時間計測を実施した日付 計測結果のパースをするスクリプト Gradle Profilerが出力するHTML Gradle Profileが出力するHTMLには、イテレーション毎のビルド時間の計測結果や、その平均値などが表示されます。 このHTMLファイルで計測されたビルド時間の詳細を確認できます。 データポータルでの表示 ビルド時間の計測もコードメトリクスの計測と同様に、GitHub Actionsのscheduleイベントトリガーによって定期的に実行できます。scheduleイベントトリガーによって計測されたビルド時間は、BigQueryに保存することで、その推移を確認できます。 データポータルで可視化した、ある期間にリリースされたバージョン毎のビルド時間の推移は下図の通りです。 この図からは、特定のバージョンからビルド時間が大幅に増加したことがわかります。このように、指標の推移を可視化することで、ある時点からの指標の大幅な変化を検知できます。上図の例では、データポータルでの計測結果の確認後、Pull Request単位でのビルド時間の変化を計測し、ビルド時間の悪化原因が含まれるPull Requestを特定できました。 まとめ 本投稿では、ZOZOTOWN Androidにおけるコードメトリクスとビルド時間計測の取り組みを紹介しました。コードメトリクスによって示される数値は、必ずしも実際のコードの良し悪しを表していません。しかし、効率的なリファクタリングやリファクタリングの進捗管理に利用できます。今後は、効果的なリファクタリングのための、より有効なコードメトリクスの導入を進めていきたいと考えています。また、チームのリファクタリングへの意識向上のための、効果的なコードメトリクスの運用方法についても検討しようと考えています。ビルド時間計測については、増分ビルドの時間計測などのより開発時に近いシナリオでの計測を進めていきます。 最後に ZOZOではAndroidエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。 hrmos.co
はじめに こんにちは。SRE部の巣立( @ksudate )です。 ZOZOTOWNのマイクロサービス基盤では、GitHub Actionsを利用したCDパイプラインを構築しています。しかし、管理するマイクロサービスが増えるにつれて運用負荷が高まりつつありました。 本記事では、ZOZOTOWNのマイクロサービス基盤のCDパイプラインが抱える課題と、それらをFlux2でどのように解決したのかを紹介します。また、Flux2の導入にあたり工夫したポイントを紹介します。 目次 はじめに 目次 Flux2の導入背景 マイクロサービス基盤のCI/CDパイプラインが抱える課題とこれまでの対策 Flux2とは? Flux2によるGitOpsの実現 Flux2の導入で工夫したポイント Flux2の管理 GitRepositoryとKustomizationの管理 Flux2によるkustomize build 今後の展望 アプリケーションの自動更新 権限の最小化 さいごに Flux2の導入背景 マイクロサービス基盤のCI/CDパイプラインが抱える課題とこれまでの対策 マイクロサービス基盤では、アプリケーションのソースコードとKubernetesマニフェストを別々のリポジトリで管理しています。また、マニフェストを管理するリポジトリは複数のアプリケーションのマニフェストを含んでいます。 それぞれのリポジトリ上で稼働するCI/CDパイプラインは以下のような構成になっていました。 アプリケーションリポジトリではECRへImageがPushされます。PushされたImageをクラスタへ反映するにはインフラリポジトリのマニフェストを更新します。そのため、KubernetesクラスタへのデプロイはインフラリポジトリのGitHub Actionsから実行されます。GitHub ActionsによるCI/CDパイプラインで実行する kubectl diff と kubectl apply は全てのマイクロサービスに対して実行します。そのため、マイクロサービスが増えるとCI/CDが完了するまでの時間も増えていく構成となります。 CI/CDパイプラインではKubernetesクラスタへデプロイするJobだけでなくCloudFormationやTerraformを利用したデプロイを行うJobも同じワークフローで管理しています。そのため、CloudFormation・Terraformに対する変更の場合でも毎回 kubectl diff と kubectl apply が実行されます。 そこで paths-filter を利用することにしました。paths-filterを使うことでPull Requestに含まれる変更内容によって実行するJobを制御できるようになります。例えば、Kubernetesマニフェストに変更があった場合のみ kubectl diff と kubectl apply を実行するといったことが可能になります。 paths-filterによりCI/CDの高速化に成功したのですが、依然として kubectl diff と kubectl apply の長期化は解決しない状況でした。そのため、paths-filterを利用してマニフェストに変更があったマイクロサービスに対して kubectl diff と kubectl apply を実行する構成を考えました。しかし、この方法では条件分岐が増えることでワークフローが複雑になったり、マイクロサービスが増えるたびにJobが肥大化したりといくつか問題がありました。 そこで候補として上がったのが Flux2 です。 Flux2とは? Flux2はGitリポジトリで宣言された状態とクラスタの状態を同期するGitOpsツールの1つです。類似のOSSとして ArgoCD が挙げられます。Flux2では Kustomize で組み立てられたマニフェストをクラスタへ同期できます。そのため、Kustomizeを利用してマイクロサービスごとにマニフェストを生成し同期することが可能になります。 今回、ArgoCDではなくFlux2を採用したのは既にマイクロサービス基盤で利用している Flagger がFlux2と同じ Flux Project に所属しているため親和性が高いことを期待しました。Flaggerについては近日テックブログで公開する予定です。 Flux2によるGitOpsの実現 ここでは、Flux2のアーキテクチャと仕組みについて簡単に解説します。 Flux2はGitOps Toolkitと呼ばれるいくつかのコンポーネントにより動作します。 Source Controllerでは、Git、S3などからアーティファクトという形で外部ソースを取得します。アーティファクトはGitコミットのハッシュ値つまり、リビジョン番号を持ち、アーティファクトが更新されるとリビジョン番号も更新されます。 Kustomize Controllerでは、Source Controllerが取得したアーティファクトを元にクラスタへマニフェストを適用します。クラスタへ適用する間隔はCustom Resourceの設定により可能ですが、リビジョン番号が更新された場合は設定に関係なくクラスタへ適用されます。また、アーティファクトからマニフェストを生成する際には、 kustomize build を使用します。 その他Flux2が提供する機能について詳しくは公式ドキュメントをご覧ください。 fluxcd.io Flux2の同期の設定は、Custom Resourceにより設定できます。ここでは、GitRepositoryとKustomizationの2つのCustom Resourceについて簡単に紹介します。 GitRepositoryでは、Source ControllerがGitリポジトリからソースを取得する際の設定を書きます。これを各マイクロサービス毎に作成しています。同じNamespaceに複数のGitRepositoryを配置できるため、複数のマイクロサービスが配置されている場合でも問題なく動作します。 --- apiVersion : source.toolkit.fluxcd.io/v1beta2 kind : GitRepository metadata : name : flux-system namespace : flux-system spec : interval : 1m0s ref : branch : master secretRef : name : zozo-flux-system-secrets-202206141800 url : https://github.com/stefanprodan/podinfo KustomizationではKustomize Controllerがマニフェストをクラスタへ適用する際の設定を書きます。KustomizationもGitRepository同様にマイクロサービス毎に作成しています。なお、検証作業等のためにクラスタへの適用を一時的に停止したい場合は、 spec.suspend を true にします。 --- apiVersion : kustomize.toolkit.fluxcd.io/v1beta2 kind : Kustomization metadata : name : flux-system namespace : flux-system spec : interval : 1m0s path : ./k8s/dev/gitops-toolkit prune : false sourceRef : kind : GitRepository name : flux-system suspend : false ここで紹介したKustomizationは Kustomize で利用するKustomizationとは異なることに注意して下さい。 apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.toolkit.fluxcd.io/v1beta2 これらのCustom Resourceにより spec.interval で指定した時間毎にクラスタへの同期が行われます。また、Flux CLIを使って即時の同期も可能です。 flux reconcile kustomization flux-system --with-source 実際にFlux2導入後のマイクロサービス基盤のCI/CDパイプラインは以下のような構成になります。これまでの git push を契機としたデプロイ手法であるCIOpsから、ソースをpullして差分を適用するGitOpsへとデプロイ手法が変わりました。変更後のCI/CDパイプラインでは、Pull Requestが作成されるとGitHub Actionsにより kubectl diff が実行されます。Pull Requestがmergeされた後、GitRepositoryの設定によりFlux2がリポジトリの更新を検知するとクラスタへ反映します。 これまでのCI/CDパイプラインではマニフェストの適用が完了するまでに10分から15分ほどかかっていました。しかし、マイクロサービス毎のデプロイが可能となりマニフェスト適用までの時間を1分以内に短縮できました。 Flux2の導入で工夫したポイント ここでは、マイクロサービス基盤にFlux2を導入した際に工夫したポイントをいくつか紹介します。 Flux2の管理 Flux2もKubernetes上で動作するアプリケーションの1つです。そのため、Flux2本体(Source ControllerやKustomize Controllerなど)をFlux2自身で管理可能です。 初回導入時のみ手動でインストールを行い、それ以降はFlux2自身で管理するようにしています。 GitRepositoryとKustomizationの管理 マイクロサービス基盤では、マイクロサービスごとにGitRepositoryとKustomizationを作成しています。この2つもFlux2により同期を行なっています。このマイクロサービスごとに作成された同期設定を管理するGitRepositoryとKustomizationは初回導入時のみ手動でインストールを行い、それ以降はFlux2で管理するようにしています。 Flux2によるkustomize build マイクロサービス基盤ではCIパイプラインで kubectl diff を実行します。そこで利用するマニフェストは kustomize build を実行し生成しています。生成されたマニフェストがFlux2のKustomize Controllerが適用するマニフェストと異なっていては困ります。そのため、いくつかのオプションを付与しFlux2が使用するKustomizeと同じ動作を実現しています。 kustomize build --load-restrictor=LoadRestrictionsNone --reorder=legacy . \ | kubectl diff --server-side --force-conflicts -f - --load-restrictor=LoadRestrictionsNone では、 kustomization.yaml が配置されたディレクトリ外からファイルを読み込むことを許可します。 --reorder=legacy では、生成したマニフェストを出力する順番に関するオプションです。 legacy では、NamespaceとClusterRole/RoleBindingが最初に出力され、CRの前にCRD、最後にWebhookが出力されます。 詳しくはFlux2のFAQをご覧ください。 fluxcd.io 今後の展望 アプリケーションの自動更新 Flux2では、ECRなどのImageリポジトリからImageを取得し、Gitリポジトリへコミットする機能があります。この機能を利用し、さらなるデプロイ時間の短縮に取り組んでいきます。 権限の最小化 Flux2のようにクラスタ上でソースとの差分を適用するというGitOps方式により、GitHub ActionsのようなCDパイプライン側に秘匿情報を渡す必要がなくなりました。しかし、現状のCIパイプラインでは秘匿情報が必要です。今後はCDパイプラインのみならずCIパイプラインの改善も取り組んでいきます。 さいごに Flux2を導入することで既存のCDパイプラインが抱えていた課題を解決できました。Flux2を活用することで改善できる課題はまだまだあります。 引き続き、快適な開発・運用を実現すべく改善していきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co
こんにちは、SRE部の廣瀬です。 本記事では、ZOZOTOWNでカートに商品を入れる際に使われているデータベース群の内、SQL Server(以降、カートDBと呼ぶ)にフォーカスします。ZOZOTOWNでは数年前から、人気の商品(以降、加熱商品と呼ぶ)が発売された際、カートDBがボトルネックとなる問題を抱えています。様々な負荷軽減の取り組みを通じて状況は劇的に改善されていますが、未だに完璧な課題解決には至っていません。 そこで今回は、加熱商品の発売イベントにおける負荷軽減の取り組みを振り返ります。また、直近の取り組みとして、SQL ServerのCDCを用いた新たな負荷軽減の検証内容をご紹介します。 背景 - カートDBのボトルネックについて 加熱商品の発売イベントに関する対策について、最初に言及した記事としては以下が挙げられます。この記事では人気の福袋商品を加熱商品として紹介していますが、それ以外にも加熱商品は様々な種類のものが存在します。 techblog.zozo.com 上記記事を参考に、加熱商品の発売イベントにおけるカートDBのボトルネックについてご説明します。 ZOZOTOWNのカート投入の仕様 ZOZOTOWNでは、「カートに入れる」ボタンを押したタイミングで在庫が確保されます。つまり、カートに入った商品はそのまま注文完了まで進めば、確実に購入できます。ECサイトによっては、「カートに入れる」ボタンを押したタイミングでは在庫が確保されません。代わりに、注文完了後に順次在庫の確保処理を実行して、在庫が確保できない場合はキャンセルのお詫びメールを送信する仕様になっています。ZOZOTOWNのカート投入の仕様は現実世界でのショッピングの体験を再現しており、個人的に好きな仕様の1つです。本仕様により、「カートに入れる」ボタンを押したタイミングで以下のようなクエリ(以降、在庫更新クエリと呼ぶ)がカートDBに対して実行されます。 update 在庫テーブル set 在庫数 = 在庫数 - 1 where PK = *** SQL Serverの論理リソース競合について SQL Serverでは、データを更新する際に様々なリソースに対して排他制御をかけます。様々な排他制御の内、本記事で言及する「行ロック」と「ページラッチ」について簡単に説明します。 「行ロック」は、行(レコード)に対して読み書きする際に獲得する必要のある論理的なリソースです。この仕組みによって「1つのレコードを一度に更新できるのは、1つのクエリだけ」といったルールを実現できます。 「ページラッチ」は、複数のレコードを格納している8KBの物理領域に対して読み書きする際に獲得する必要のある論理的なリソースです。基本的には「行ロック」も「ページラッチ」も、同一リソースへの書き込み(以降、writeと呼ぶ)は競合し、同一リソースへの読み取り(以降、readと呼ぶ)は競合しません。 表にまとめると以下の通りです。 write/write read/write read/read 行ロック 競合する 競合する 競合しない ページラッチ 競合する 競合する 競合しない 競合が発生すると、片方のクエリはもう片方のクエリが「行ロック」や「ページラッチ」を解放するまで待たされることになります。つまり、論理リソースの競合が発生するということは該当クエリの実行時間の遅延につながるということです。 なお、SQL Serverのロックについては以下の記事で詳しくまとめていますので、良かったらご覧ください。 qiita.com 加熱商品の発売イベントにおけるDB論理リソース競合 通常時は、様々な商品がカートに投入されている状況のため、複数の在庫更新クエリが同時に同一リソースへ更新要求を出すことはほとんどありません。したがって、「行ロック」も「ページラッチ」も大幅なクエリ遅延につながるような競合は発生しません。 しかし、加熱商品の発売イベントでは、特定の人気商品に対して在庫更新クエリが集中します。このような状況下では大量の「行ロック」および「ページラッチ」競合が発生し、クエリの実行時間の大幅な遅延やクエリタイムアウトエラー多発に繋がってしまいます。ワーストケースでは、クエリの遅延によりワーカースレッドが枯渇して、カートDB全体のスループットが著しく下がるという障害が発生することもあります。 このリソース競合を図示すると以下のようになります。 カートDBのボトルネックまとめ ここまでの内容をあらためてまとめます。 ZOZOTOWNでは「カートに入れる」ボタンを押したタイミングで在庫が確保され、内部的には在庫更新クエリが発行されている 在庫更新クエリを実行するためには「行ロック」および「ページラッチ」という論理リソースを獲得する必要がある 加熱商品の発売イベントでは、特定の商品にカート投入要求が集中し、DB内部で「行ロック」「ページラッチ」関連の論理リソース競合が多発する 論理リソース競合多発によってクエリの処理時間が遅延しタイムアウトエラー多発や、ワーカースレッド枯渇によるDB全体のスループット激減という障害につながることもある このように、CPU負荷やディスク負荷の高騰といった物理リソース起因ではなく、SQL Server内部で獲得する必要のある論理リソース競合がボトルネックである点が特徴的となっています。 次は、カートDBのボトルネックに対するこれまでの対応策を振り返っていきます。 カートDBボトルネック対策の歴史 1.在庫分割による排他制御の分散 こちらの記事 で紹介しているように、加熱商品の論理在庫を分割することで、在庫更新クエリによる排他制御を分散させる案です。 この対応のイメージ図は以下の通りです。 この案を2018年に実装して以降、2015年から3年連続で障害が発生していた福袋発売イベントを無障害で乗り切れています。一方で、以下のような課題も抱えていました。 運用負荷が大きく、年に1回の福袋発売イベント時だけ発動していた 効果は限定的で、分割するメリットが無いほどの少ない在庫数に対しては適用できない 他にもクエリチューニングを実施する等の様々な対策を施してきましたが、限界を迎えていました。具体的には、対策を入れて上昇していくDBの処理能力を、加熱商品の発売イベント時のトラフィックがさらに上回るようになっていきました。 そこでSQL Serverのレイヤだけで対応するのではなく、ワークロードを加味した別DBの選定等、課題の根本的な解決を目指すことになりました。成果の第一弾として、2021年にカート決済機能リプレイスのPhase1がリリースされましたので、そちらをご紹介します。 2.キューイングシステムの導入によるキャパシティコントロール これまでのカートDBでは、在庫更新クエリ数が増えれば増えるほど、カートDBへのリクエスト数も増える状況になっていました。そこで、カート決済機能リプレイスのPhase1という位置づけで、カートDBの前段にキューイングシステムを設置しました。これにより、在庫更新クエリが増えても、キューイングシステムの後段に位置するカートDBへの更新リクエスト数を一定に保つことが可能となりました。 キューイングシステムの概略図は以下の通りです。 より詳しい情報は下記のテックブログ達にまとまっております。よろしければご覧ください。 techblog.zozo.com techblog.zozo.com techblog.zozo.com aws.amazon.com この対応を入れたことで、下図のようにレイテンシ、エラー率の両面で劇的な効果を上げることができました。 ここまで、カートDBボトルネック対策の歴史を振り返りました。ワークアラウンドな対応にとどまっていた「在庫分割による排他制御の分散」と比べて、「キューイングシステムの導入によるキャパシティコントロール」は、あらゆる加熱商品に有効な負荷軽減の取り組みとなりました。しかし、ここまでの対策を実施しても論理リソース競合によるクエリタイムアウトの多発という障害の発生を完全に無くすことはできませんでした。 次項では、現状のシステム構成でも障害が発生してしまう要因を説明します。 現在のカートDBが抱えるボトルネック 現在のカートDBのボトルネックに関するワークロードの概略図を以下に示します。 在庫テーブルに対しては、常にwriteとreadの両方のリクエストが発生しています。writeは基本的に在庫更新クエリのみであり、キューイングシステムの導入によってキャパシティをコントロール可能な状態になっています。一方で、readは様々なリクエストで発生し、各リクエスト毎に同時実行数も異なります。例えば秒間10000リクエストのreadクエリもあれば、秒間10リクエストのreadクエリもあります。また、大半のreadはキューを経由しないため、アクセス増加に伴って秒間リクエスト数も増加していきます。 readとwriteはページラッチの競合が発生するため、writeのリクエスト数を一定に保ったとしてもreadの数が増えるほどページラッチ競合の発生リスクは増加していきます。したがって、readの増加によるreadとwriteのページラッチ競合が現在のカートDBのボトルネックということになります。なお、上述の内容は以下の記事に記載されている方法で調査を行い特定しました。よろしければご覧ください。 techblog.zozo.com techblog.zozo.com 次項では、現状のボトルネックを踏まえた負荷軽減の取り組みについてご紹介します。 直近でのカートDB負荷軽減の取り組み 現状のボトルネックを踏まえて、システムを以下の構成に変更することで論理リソース競合を軽減できるのではと考えました。 コンセプトとしてはシンプルで、タイムラグが許容されるreadはリードレプリカにアクセスを向けることで、readとwriteの競合発生の軽減を期待するというものです。リードレプリカへのデータ同期方法として、トランザクションレプリケーションとCDCという2パターンを検討しました。 トランザクションレプリケーション とは、SQL Serverが提供するレプリケーションの仕組みです。 CDC(Change Data Capture) とは、SQL Serverが提供する、テーブルに対するレコードの更新を記録できる機能のことです。 両者のおおまかな比較を以下の表にまとめます。 CDC 同一サーバー内でのレプリケーション サーバーの追加管理 〇(不要) 〇(不要) リードレプリカの レコード更新 ◎(1セッションからの直列な更新&リクエスト回数が圧縮可能) 〇(1セッションからの直列な更新) 運用 △(カラム追加時などに運用が発生) 〇(不要) 実装 △(データ同期処理など自作が必要) 〇(楽) リスク △(予期せぬトラブルが発生する懸念) 〇(トラブルの知見が社内に豊富) CDCの方がレコードの更新情報をリードレプリカに適用する回数を圧縮できるため、競合の軽減という観点では優れています。しかし、レプリケーションの方が社内で実績もあり、同一リソースへ同時にwriteが複数発生する現状と比較すると競合の軽減も見込めます。したがって、まずはレプリケーションを使ったリードレプリカ案で負荷試験を実施しました。 負荷試験では「在庫が減るスピード」に着目しました。理由は、在庫がはけ切った場合は更新が行われなくなり、readとwriteの競合も解消するためです。「いかに速く在庫が減る状況をつくれるか」を重要視しました。それ以外にも、加熱商品の発売イベントでは論理リソース競合が多発したりワーカースレッドが枯渇したりする実情を踏まえて、以下の4項目を負荷試験のキーメトリクスとしました。 在庫が減るスピード ロック競合の平均待ち時間 ページラッチ競合の平均待ち時間 ワーカースレッド獲得の平均待ち時間 リードレプリカに向けるクエリの決定方法 在庫テーブルには様々な種類のreadクエリが実行されています。種類が膨大なため全てをリードレプリカに向けるのは実装コストが高くなります。また、タイムラグが許容できないreadクエリはリードレプリカに向けることができません。したがって、加熱商品の発売イベントにおいて実行回数が多いreadクエリの中から、タイムラグが許容できる上位数種類のクエリをリードレプリカに向けました。 なお、各クエリの実行回数は こちらの仕組み を用いて取得しました。 負荷試験の実施方法 まずはコンセプトをDBレイヤ単体で検証するために、開発環境で JdbcRunner というツールを使って試験を実施しました。その後、プロダクション環境ではDBレイヤ単体の試験ではなく、アプリ側の負荷状況の変化もみるために、実際のユーザートラフィックを再現する形で試験を実施しました。 試験の実施には弊社のエンジニアが公開した「Gatling Operator」というOSSツールを使用しました。詳しくは以下のテックブログをご覧ください。 techblog.zozo.com レプリケーションを使ったreadとwriteの分離 レプリケーションを使ってリードレプリカを作成する案の負荷試験の結果を以下に示します。図は在庫が減るスピードを示しています。 図の通り、レプリケーション案では在庫の減るスピードが逆に鈍化してしまいました。また、数分間レベルの大幅な同期遅延も発生しました。ロック競合、ページラッチ競合、ワーカースレッド獲得の各種平均待ち時間も上昇していました。 レプリケーションでは、マスタ側の同一レコードに100回updateが行われた場合、レプリカ側のレコードに対しても100回updateが行われます。ただし、リードレプリカに更新を適用するのはレプリケーションに関連するエージェント1プロセスのみであるため、更新処理が直列化されます。これだけでも大幅なページラッチ競合の軽減を期待していましたが、そうなりませんでした。 CDC案もあるため原因の深掘りは行いませんでしたが、レプリケーションを使ったリードレプリカ作成によるreadとwriteの分離では、状況は改善できないと結論づけました。 CDCを使ったreadとwriteの分離 まず、CDC(Change Data Capture)という機能を簡単に説明します。この機能はDB単位で有効化した後、テーブル単位で個別に有効化します。有効化した後でテーブルに更新を加えると、サイドテーブルへ変更の履歴が保存されます。サイドテーブルをSELECTすると以下のような結果が得られます。 「_$operation」カラムはどのような更新が行われたかを示します。 1:delete 2:insert 3/4:update 3が古い値 4が新しい値 「$update_mask」カラムは、どのカラムが更新されたかをマスク値として持っています。例えば画像内の「0x04」は3列目が更新されたことを示しています。 なお、同期の仕組みを実装する際は、サイドテーブルを直接SELECTすることは行わず、 cdc.fn_cdc_get_net_changes_capture_instance を使います。このシステム関数を利用すると、指定した期間の最終的なカラムの値だけを取得できます。例えば、特定の期間に特定の1レコードへ1000回updateが行われたとしても、最新の値だけが適用すべき変更として取得できます。これにより、リードモデルとして作成したテーブルへの更新回数の圧縮が期待でき、リードモデル側のreadとwriteの競合発生を最小限に抑えることが期待できます。 詳しい実装例は本記事の末尾のAppendixで紹介します。 CDCを使ってリードレプリカを作成する案の負荷試験の結果を以下に示します。図は在庫が減るスピードを示しています。 図の通り、在庫が減るスピードは9倍速と劇的に改善されました。また、同期間隔10秒に対して遅延時間は最大でも約15秒と安定していました。キーメトリクスとしていた各種待ち時間も下図の通り劇的に低減しました。 リクエスト数が多いreadクエリをリードレプリカに向けることでreadとwriteの競合発生を低減できました。また、競合による待ち時間が低減したことで、各クエリの処理速度が向上して在庫が減るスピードも大幅に高速化できました。 以上の結果から、CDCを用いたリードレプリカ案は、現在カートDBが抱えているボトルネックに対して非常に有効な手法であると結論づけました。 Limitation 今回の負荷試験では、CDCを使ったreadとwriteの分離でボトルネックの劇的な改善がみられました。 しかし、制限もいくつか存在します。まず第一に、ワークロードの性質の変化に弱い点が挙げられます。秒間リクエスト数が少なかったreadクエリの突発的なスパイクによって、別の箇所へボトルネックが移動する懸念があります。 次に、同期遅延の発生を許容しなければならない点が挙げられます。結果整合性が許容できないクエリの場合は、論理リソース競合の一因になっているとしても、リードレプリカにreadクエリを向けることはできません。 このように、アプリケーションが持つ性質次第では本手法は使えない可能性もあります。ですが、プロダクション環境でのワークロードを模した今回の負荷試験では非常に良い結果を得られました。 まとめ 本記事ではZOZOTOWNが長年抱えている、加熱商品の発売イベントにおけるDBボトルネック起因の障害と、その対策の歴史を振り返りました。また、直近の負荷軽減の取り組みとして、SQL ServerのCDCを用いたリードレプリカの作成によるreadとwriteの分離案を紹介しました。 この方法によって、ボトルネックとなっていた論理リソース競合を劇的に改善させ、在庫が減るスピードも既存の9倍速となる結果を負荷試験で達成できました。諸事情によりプロダクション環境への導入は見送ることになりましたが、提案手法のコンセプトが有効であることを実証できました。 今後の展望 カートDBは未だにボトルネックを抱えている状態です。今後の対応としては以下のような案が考えられます。 課題解決に適したDBを選定してワークロードをオフロードする カートDBへの更新リクエストだけではなく、参照処理なども含めキューイングさせる 加熱商品の発売時は抽選制にするなどアプリ側の仕様を変えることでワークロードの性質も変える 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com Appendix - CDCを用いた同期の仕組みの実装 1.DB単位でのCDCの有効化 以下のクエリを実行します。デッドロック等で失敗する可能性があり、その際即時ロールバックが確実に行われるよう、オプションも付けておくと安心です。 set xact_abort on set lock_timeout 500 EXEC sys.sp_cdc_enable_db GO 2.テーブル単位でのCDCの有効化 テーブル単位の有効化は、ロックを獲得できれば基本的に瞬時に完了します。しかし1つめのテーブルに対してCDCを有効化するタイミングでは、関連jobの作成等が行われるため、完了まで数秒かかります。また、その間は該当テーブルへのクエリはブロックされます。したがって、CDCを初めてテーブルに設定する場合は、全くアクセスの無いダミーテーブルに対して設定する方が安全です。ダミーテーブルは例えば以下のようなスキーマで作ります。 CREATE TABLE [dbo].[dummy_table]( [c1] [ int ] IDENTITY( 1 , 1 ) NOT FOR REPLICATION NOT NULL , [c2] [ int ] NOT NULL , CONSTRAINT [PK_dummy_table] PRIMARY KEY CLUSTERED ([c1] ASC )) ON [PRIMARY] GO 次に、ダミーテーブルに対してCDCを有効化します。数秒かかりますが、アクセスが無いためブロッキングは起きないはずです。 EXEC sys.sp_cdc_enable_table @source_schema = N' dbo ' , @source_name = N' dummy_table ' , @role_name = null , @filegroup_name = N' primary ' , @supports_net_changes = 1 GO そのあと、本当にCDCを設定したいテーブルに対してCDCを有効化します。こちらはアクセスが有るテーブルのため、ブロッキング多発に備えてオプションを設定しておきます。 set xact_abort on set lock_timeout 500 EXEC sys.sp_cdc_enable_table @source_schema = N' dbo ' , @source_name = N' target_table ' , @role_name = null , @filegroup_name = N' primary ' , @supports_net_changes = 1 GO 3.CDCの設定変更 CDC設定による変更履歴データは、期限が切れると自動でクリーンアップされます。期限はデフォルトで4320分(3日後)になっていますが、今回は長すぎると判断し1440分(1日後)に変更しました。 また、クリーンアップ処理時の変更履歴データの1回あたりの削除レコード数をデフォルトの5000から3000に変更しました。理由は、クリーンアップ処理中のCDCサイドテーブルへのロックエスカレーション発生を防止するためです。ロックエスカレーションが発生すると、CDCサイドテーブルへのinsertがブロックされ、リードレプリカ側へのデータ同期遅延の懸念があります。 この設定は以下のクエリで実施しました。 set xact_abort on set lock_timeout 500 go sys.sp_cdc_change_job @job_type =  ' cleanup ' , @retention =  1440 , @threshold =  3000 4.CDCを用いた同期の仕組みづくり CDCを用いてリードレプリカに継続的に変更データを同期するためには、仕組みを自作する必要があります。そのためのテーブルを作成します。 --ウォーターマークの管理テーブル CREATE TABLE [dbo].[CDCWaterMarks] (     [ID] [bigint] IDENTITY( 1 ,  1 ) NOT NULL ,     [WaterMark] [binary]( 10 ) NULL ,     [ModifiedAt] [datetime2] NOT NULL ,     CONSTRAINT [PK_CDCWaterMarks] PRIMARY KEY CLUSTERED     (         [ID] ASC     ) ON [PRIMARY] ) ON [PRIMARY] --1行だけinsertして、あとは都度updateしていく INSERT INTO CDCWaterMarks VALUES ( NULL , sysdatetime()) --同期ログを保存するテーブル CREATE TABLE [dbo].[CDCSyncLogs] (     [ID] [bigint] IDENTITY( 1 ,  1 ) NOT NULL ,     [LogMessage] [ varchar ]( 4000 ) NOT NULL ,     [CreatedAt] [datetime2] NOT NULL CONSTRAINT [DF_CDCSyncLogs_CreatedAt] DEFAULT (sysdatetime()),     CONSTRAINT [PK_CDCSyncLogs] PRIMARY KEY CLUSTERED     (         [CreatedAt] ASC     ) ON [PRIMARY] ) ON [PRIMARY] --同期の遅延時間を計測するためのテーブル CREATE TABLE [dbo].[CDCSyncLatencies] (     [ID] [bigint] IDENTITY( 1 ,  1 ) NOT NULL ,     [SyncLatency] INT NOT NULL ,     [CreatedAt] [datetime2] NOT NULL CONSTRAINT [DF_CDCSyncLatencies_CreatedAt] DEFAULT (sysdatetime()),     CONSTRAINT [PK_CDCSyncLatencies] PRIMARY KEY CLUSTERED     (         [CreatedAt] ASC     ) ON [PRIMARY] ) ON [PRIMARY] 次に、リードレプリカとなるテーブルを元テーブルと同じスキーマで作成します。 create table [dbo].[target_table_read_replica] ( [id] [bigint] ... [column1] [ int ] ... ... ) 続いて、リードレプリカに全件INSERTします。 insert into target_table_read_replica select * from target_table order by id その後、必要に応じて元テーブルと同じインデックスをリードレプリカにも作成します。次に、リードレプリカへの同期ジョブを作成します。以下のようなクエリをエージェントジョブのステップに登録して実行します。 以下のクエリを使うと10秒ごとにリードレプリカに対して変更履歴を同期します。 set transaction isolation level read uncommitted set lock_timeout  1000 set nocount on   declare @watermark varbinary( 10 ) declare @first_lsn varbinary( 10 ) declare @end_lsn varbinary( 10 ) declare @end_time  datetime declare @message  varchar ( 4000 ) declare @merge_count  int declare @delete_count  int   while ( 0 = 0 ) begin      begin  try           select @watermark = WaterMark from CDCWaterMarks with (nolock)            --@first_lsnをセット          if  @watermark  is not   null          begin              --管理テーブルに入っているlsnをインクリメント              set  @first_lsn = sys.fn_cdc_increment_lsn(@watermark)          end          else          begin              --初回は最小のlsnを指定              set  @first_lsn = sys.fn_cdc_get_min_lsn( ' dbo_target_table ' )          end            --@end_lsnをセット          set  @end_time = getdate()          set  @end_lsn = sys.fn_cdc_map_time_to_lsn( ' largest less than or equal ' , @end_time)            set xact_abort on          begin tran                --リードレプリカへ更新を反映(ins/upd)             merge target_table_read_replica as replica_table              using (select * from cdc.fn_cdc_get_net_changes_dbo_target_table(@first_lsn, @end_lsn,  ' all ' ) where __$operation in ( 2 , 4 )) AS master_table  --2:ins / 4:upd              on replica_table.id = master_table.id              when matched              then update              set                  replica_table.column1 = master_table.column1                 ,replica_table...              when not matched by target              then insert (id,column1,...)                   values (master_table.id,master_table.column1,...)             ;                set  @merge_count = @@rowcount                --リードレプリカへ更新を反映(del)              delete from target_table_read_replica              from cdc.fn_cdc_get_net_changes_dbo_target_table(@first_lsn, @end_lsn,  ' all ' ) as master_table              where                 master_table.id = target_table_read_replica.id              and master_table.__$operation  =  1                set  @delete_count = @@rowcount                --watermarkの更新             update CDCWaterMarks set WaterMark = @end_lsn                set  @message =  ' success : first_lsn: '  + CONVERT ( varchar ( 100 ), @first_lsn, 1 ) +  ' / last_lsn: '  + CONVERT ( varchar ( 100 ), @end_lsn, 1 ) +  ' / merge_count: '  + cast (@merge_count  AS varchar ( 100 )) +  ' / delete_count: '  + cast (@delete_count  AS varchar ( 100 ))               insert into CDCSyncLogs (LogMessage) values (@message)            commit tran                        --10秒ごとに同期させるためwait         waitfor delay  ' 00:00:10 '      end  try      begin  catch          if  @@trancount <>  0          begin              rollback          end            set  @message =  ' error : '  + cast (error_number() as varchar ( 100 ))+  ' : '  + error_message() +  ' : first_lsn: '  + CONVERT ( varchar ( 100 ), @first_lsn, 1 ) +  ' / last_lsn: '  + CONVERT ( varchar ( 100 ), @end_lsn, 1 )           insert into CDCSyncLogs (LogMessage) values (@message)            --10秒ごとに同期させるためwait         waitfor delay  ' 00:00:10 '      end  catch end 遅延時間を計測するために、以下のクエリ使ったエージェントジョブを別途作成して実行します。1秒ごとに同期の遅延時間を専用テーブルに格納していきます。 set transaction isolation level read uncommitted set lock_timeout 1000 set nocount on while ( 1 = 1 ) begin insert into CDCSyncLatencies (SyncLatency) select datediff(second, sys.fn_cdc_map_lsn_to_time(WaterMark), getdate()) as cdc_sync_delay_sec from CDCWaterMarks with (nolock) waitfor delay ' 00:00:01 ' end 5.CDCの無効化 無効化したい場合は、上記手順を逆にたどればOKです。まず同期用のエージェントジョブを停止します。次に、テーブル単位でCDCを無効化します。 set xact_abort on set lock_timeout 500 EXEC sys.sp_cdc_disable_table @source_schema = N' dbo ' , @source_name = N' target_table ' , @capture_instance = N' dbo_target_table ' GO EXEC sys.sp_cdc_disable_table @source_schema = N' dbo ' , @source_name = N' dummy_table ' , @capture_instance = N' dbo_dummy_table ' GO 最後にDB単位でCDCを無効化して完了です。 set xact_abort on set lock_timeout 500 EXEC sys.sp_cdc_disable_db GO
はじめに こんにちは、フロントエンド部WEARiOSブロックの西山です。 iOS 13から登場したCompositional Layoutsを使うことで、App Storeのような複雑なUIが簡単に実現できるようになりました。 登場前は、 UICollectionView in UICollectionView または、 UIStackView + UIScrollView in UICollectionView で頑張って実現していたところを UICollectionView 1つで実現できます。 一方で、登場前の方法では簡単に出来ていたカスタマイズをCompositional Layoutsで実現しようとすると難しくなるケースが存在しました。その1つに、横スクロールするセル全体にドロップシャドウを付ける方法が挙げられます。 WEARには次のようなUIが存在します。 このキャプチャ画像では少し分かりづらいかもしれませんが、セル全体にドロップシャドウが付いています。このUIをCompositional Layoutsで実現するのが一筋縄ではいかなかったので、WEARでの解決方法を紹介します。 環境 Xcode 13.4.1 Swift 5.6.1 一筋縄ではいかなかった理由 1. セルを覆うクラスが公開されていない Compositional Layouts登場前のWEARでは、 UIStackView + UIScrollView in UICollectionView で実現していました。 UIStackView をラップするような形でシャドウ用のViewを用意する方法を取っていたので比較的簡単に実現できていました。 Compositional Layoutsでも似たような方法を取れれば簡単に実現できますが、残念ながらCompositional Layoutsのセルをラップするクラスは公開されていませんでした。 2. セルにシャドウを付けると繋ぎ目からはみ出る セルのラップクラスにシャドウを付けることは適わなそうなので、セル1つ1つにシャドウを付ける方法を取りました。しかし、この方法ではセルとセルの繋ぎ目から前後どちらかのシャドウがはみ出してしまいうまくいきません。 解決方法 mask layer を利用する セルにシャドウを付ける方法を取りつつ mask layer を利用し、はみ出す部分を隠します。 mask layer の簡単なおさらいですが、 View の切り抜きや穴を開ける方法で語られることが多いと思います。 class ViewController : UIViewController { override func viewDidLoad () { super .viewDidLoad() view.backgroundColor = .darkGray // 丸のlayerをセンターに置く let maskLayer = CAShapeLayer() maskLayer.frame = view.bounds let width : CGFloat = 200 let height : CGFloat = 200 let point = CGPoint(x : view.center.x - width / 2 , y : view.center.y - height / 2 ) let rect = CGRect(origin : point , size : . init (width : width , height : height )) let path = UIBezierPath(roundedRect : rect , cornerRadius : width / 2 ) maskLayer.path = path.cgPath view.layer.mask = maskLayer } } 背景色 darkGray の View に丸の mask layer をセンターに置いたサンプルコードです。上記コードを実行すると次のようになります。 要するに mask layer と重なる部分が表示されるようになります。 mask layer を使用するための Position やりたいことは、初めのセルの右側、中間のセルの左右、最後のセルの左側を隠すことです。 そのため、どのポジションにいるのか判断できるように型を用意しています。 enum Position { case first case middle case last(isSingle : Bool ) } extension Position { init (index : Int , itemCount : Int ) { switch (index, itemCount) { case let (index, count) where (index + 1 ) == count : self = .last(isSingle : index == 0 ) case ( 0 , _) : self = .first default : self = .middle } } } ポジションに合わせて mask layer を定義します。 let width = bounds.width let height = bounds.height let maskSpace : CGFloat = 50 // 適切な値を指定 var maskLayer : CALayer? = CALayer() maskLayer?.backgroundColor = UIColor.black.cgColor switch position { case .first : // 上、左、下にシャドウが表示されるようlayerを被せる maskLayer?.frame = . init (x : - maskSpace, y : - maskSpace, width : width + maskSpace, height : height + maskSpace * 2 ) case .middle : // 上、下にシャドウが表示されるようlayerを被せる maskLayer?.frame = . init (x : 0 , y : - maskSpace, width : width , height : height + maskSpace * 2 ) case let .last(isSingle) : if isSingle { // 隠したくないのでlayerを削除 maskLayer = nil } else { // 上、下、右にシャドウが表示されるようlayerを被せる maskLayer?.frame = . init (x : 0 , y : - maskSpace, width : width + maskSpace, height : height + maskSpace * 2 ) } } WEARでは再利用できるよう ShadowView を用意しています。 class ShadowView : UIView { private var position : Position? override func draw (_ rect : CGRect ) { super .draw(rect) drawShadow() } func updateShadow ( for position : Position ) { self .position = position setNeedsDisplay() } private func drawShadow () { guard let position = position else { return } let width = bounds.width let height = bounds.height let maskSpace : CGFloat = 50 var maskLayer : CALayer? = CALayer() maskLayer?.backgroundColor = UIColor.black.cgColor switch position { case .first : // mask layerのコード省略 ... layer.shadowOffset = .zero layer.shadowColor = UIColor.black.cgColor layer.shadowRadius = 10.0 layer.shadowOpacity = 0.6 layer.mask = maskLayer } } この方法を取ることでセル全体へのシャドウをつけることが出来ました。 shadowPath で更に調整 セル全体へシャドウを適用した時点では、若干繋ぎ目が離れているところが気になります。 shadowPath を利用することで、もう少し繋がっている様に見せられます。 View よりも少し広めに shadowPath を引くことで繋ぎ目を可能な限り消します。 ShadowView の drawShadow に手を加えます。 // mask layerのコードを省略しています private func drawShadow () { let width = bounds.width let height = bounds.height ... let shadowSpace : CGFloat = 10 // 適切な値を指定 let shadowPath : UIBezierPath switch position { case .first : shadowPath = . init (rect : . init (x : 0 , y : 0 , width : width + shadowSpace, height : height )) ... case .middle : shadowPath = . init (rect : . init (x : - shadowSpace, y : 0 , width : width + shadowSpace * 2 , height : height )) ... case let .last(isSingle) : if isSingle { shadowPath = . init (rect : . init (x : 0 , y : 0 , width : width , height : height )) ... } else { shadowPath = . init (rect : . init (x : - shadowSpace, y : 0 , width : width + shadowSpace, height : height )) ... } } layer.shadowPath = shadowPath.cgPath ... 完璧とまではいきませんが、 shadowPath を利用することでより良くなったのではないでしょうか。 サンプルコードはわかりやすいようにシャドウを濃くしていましたが、実際はもう少し薄いので馴染んで見えます。 さいごに Compositional Layoutsでセル全体にドロップシャドウをつける方法を紹介しました。同じ様な悩みを抱えている方の参考になれば幸いです。以前は割と簡単だった実装もCompositional Layoutsでは難しいケースが他にもあるので、今後のアップデートで更にカスタマイズしやすくなることを期待しています。 WEARではiOSエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。 hrmos.co
こんにちは、技術本部SRE部ZOZO-SREブロックの 鈴木 です。普段はSREエンジニアとしてZOZOTOWNの裏で動いているオンプレミスとクラウドの構築・運用・保守に携わっています。 ZOZOTOWNのインフラは大半がIaC化されていますが昔からあるリソースに関してはその限りではありません。弊社で導入しているAkamaiもIaC化されていないリソースの1つでしたが、頻繁な更新などによって重複した設定が入っている箇所がある等、長く運用しているとどうしても陥ってしまう沼にハマっていました。本記事ではこの沼から抜け出そうと部分的にもIaC化を導入して問題を解決したことを、Akamaiのネットワークリストを例に紹介します。同様の問題を抱えていた方の参考になれば幸いです。 目次 目次 はじめに 課題 AkamaiのIaC化 既存環境のTerraform化 1. Terraformの設定 2. ネットワークリストのリソースIDを取得 3. インポート 4. コード化 コード化によって まとめ 最後に はじめに まずはじめに、本記事で登場するキーワードであるInfrastructure as Code(以下IaC)について簡単に説明します。 IaCとは、インフラ構成をコード化して、コードの内容を自動でプロビジョニングすることです。コード化することによってソフトウェア開発における便利なツールが使えるようになり、様々な恩恵を受けられます。 ZOZOTOWNにおいては大半のリソースがIaCによって管理されているものの、昔からあるリソース、そもそもIaC化にかかるコストが効果に見合わないものはIaC化されないという柔軟な運用になっています。弊社では数多くのSaaSを導入しており、今回テーマとなるAkamaiも費用対効果が見合わないものとして導入当時にIaC化されていなかったリソースです。 Akamaiは弊社で利用しているCDNサービスであり、様々な用途で利用されています。以下のブログでもAkamaiを利用した事例について書かれています。 techblog.zozo.com techblog.zozo.com Akamaiをいろいろなところで使うに従って設定情報は増えていきます。Akamaiの設定のうち、頻繁に変更するものとして「ネットワークリスト」があります。ネットワークリストはIPおよびGEOを単位としたリストを作り、ネットワークファイアウォールのブロック制御等を行うことができます。弊社では特定のIPのみ通信を通したいときなど、用意しておいたネットワークリストにIPを追加することで通信を許可できるように設定しています。 課題 Akamaiをしばらく運用するに従い、いくつか課題点がでてきました。 ネットワークリストの更新はAkamaiのコンソールから行っていました。ネットワークリストの更新が頻繁になるにつれ、このIPはなんのために追加したものなのか、いつ誰が追加したものなのかを追うのが困難になっていました。Akamaiのコンソール上にも設定変更の履歴が表示されるものの変更内容の詳細までは表示されません。それぞれのネットワークリストにはコメントをつけられるものの文字数制限があり、必要な情報すべてを書けませんでした。リストの内容の検索ができなかったため、内容の重複するようなリストがいくつも作成されるなど、次第に積み重ねによる使いにくさがどんどん増えていきました。 「設定の内容が可視化できていない」ことが私達の課題点でした。 これらの解決策としてパラメータシートのような資料を作り、設定を記録していくことが考えられますがメンテナンス性に難があります。そこで私達はAkamaiの設定情報をIaC化することで解決を図りました。今回のIaC化はひとまずネットワークリストのみに対象を絞ります。目標としては「ネットワークリストの設定変更の履歴を追える」「CI/CDを用いたデプロイが可能」の状態を目指し、AkamaiのすべてをIaCを目指すところまでは行わないことに決めました。 AkamaiのIaC化 AkamaiをIaC化する上で使えるツールとしては「Akamai API」「Akamai CLI」「Terraform」の3つがあります。 Akamai APIを用いることでAkamaiの提供する機能をAPIで操作できます。APIを直接利用して操作するため、コーディングが必要となりますが自由度が高く複雑なインフラも簡単に構築できます。 www.akamai.com Akamai CLIはAkamaiを操作するためのCLIツールです。GUIを使わずにAkamaiを操作でき、工夫次第でIaC化などにも有用なツールです。 www.akamai.com Terraformは代表的なIaCツールであり、Akamai社がTerraformの Providerを提供 しているため、ほとんどの設定がTerraform上で管理可能です。代表的なIaCツールであり、IaC化したコードのデプロイ等の機能だけでなく、インポートや実際の環境との差分検知など便利な機能が多数あります。 Akamai CLIを用いてTerraformの設定ファイルを生成も可能です。初期構築の際など複雑な設定をする際にはこちらの機能を利用するとお手軽にIaC化を進められます。弊社でもWEARチームがAkamai CLIからTerraformの設定ファイルを作成して適用する流れをProperty Managerの設定の際に利用しています。 techblog.zozo.com それぞれのツールについて簡単に比較したものが以下になります。 観点 Akamai API Akamai CLI Terraform 柔軟な設定 ◎ ◎ ○ 既存設定のインポート △ △ ◎ 導入スピード △ △ ○ 各種機能が揃っており、代表的なIaCツールでありメンバーも特別な学習をすることなく利用できる点に魅力を感じ、今回はTerraformを用いてIaC化を行いました。特にすでにあるAkamaiの設定を手軽にインポートできる機能はAkamaiをすでに長く利用していた私達の求めていたものでした。 既存環境のTerraform化 既存のAkamaiのネットワークリストをTerraformを用いてIaC化する方法を解説します。流れとしては以下になります。 Terraformの設定 ネットワークリストのリソースIDを取得 インポート コード化 1. Terraformの設定 TerraformからAkamaiの設定を操作できるように Authenticate the Akamai Provider に従い認証をします。クレデンシャル情報を作成し .edgerc を用意する方式でまずは行いました。 ❯ cat ~/.edgerc [ default ] host = akab-....luna.akamaiapis.net client_secret = pc... access_token = akab-... client_token = akab-... 2. ネットワークリストのリソースIDを取得 実際の環境で動いているネットワークリストのリソースIDを取得します。 terraform { required_providers { akamai = { source = "akamai/akamai" version = "1.10.0" } } } provider "akamai" { edgerc = "~/.edgerc" config_section = "default" } data "akamai_networklist_network_lists" "network_lists" { } output "network_lists_list" { value = data.akamai_networklist_network_lists.network_lists. list } ❯ terraform init ... ❯ terraform.sh plan Changes to Outputs: + network_lists_list = [ + " 1000_ZOZOOFFICE " , + " 2000_ZOZOSERVICEIPS " , ... 出力されたIDが実際の環境ですでに動いているリソースのIDであり、設定をインポートする際に利用します。 3. インポート すでに作られているネットワークリスト情報を入れる先として空のリソースを作成し、インポートコマンドを使うことでTerraformの管理下にリソースを置きます。 resource "akamai_networklist_network_list" "zozo_office_ip_list" {} ❯ terraform import akamai_networklist_network_list.api_zozo_com_zozo_platform_natgateway 1000_ZOZOOFFICE インポートした内容は state show コマンドを用いることで確認できます。 ❯ terraform state show akamai_networklist_network_list.zozo_office_ip_list # akamai_networklist_network_list.zozo_office_ip_list: resource " akamai_networklist_network_list " " zozo_office_ip_list " { description = " Office IP List " id = " 1000_ZOZOOFFICE " list = [ " a.a.a.a " , " b.b.b.b " , ... ] mode = " REPLACE " name = " ZOZO_OFIICE " network_list_id = " 11000_ZOZOOFFICE " sync_point = 1 type = " IP " uniqueid = " 1000_ZOZOOFFICE " } 4. コード化 Terraformの管理下にネットワークリストが入ったものの、まだコード上には反映されていません。この状態でTerraform applyしてしまうと設定が空になってしまうため、インポートした内容をもとにコードを修正します。 terraform state show を用いて表示された設定情報をもとに空で作っていたリソースを更新します。 resource "akamai_networklist_network_list" "zozo_office_ip_list" { description = "Office IP List" list = [ "a.a.a.a" , "b.b.b.b" , ... ] mode = "REPLACE" name = "ZOZO_OFIICE" type = "IP" } terraform plan を打って無事にNo Changeとなっていればインポート完了です。 内容を更新する際には、コードを更新した後に terraform apply することで設定を更新できます。 コード化によって 弊社ではコードの管理にGitHubを利用しています。AkamaiのネットワークリストをIaC化したことで、一般的なIaC化のメリットとなりますが以下の効果が得られました。 更新履歴が手軽に追える GitHubのコミット履歴をみることで誰がいつどこで何のために更新したのか見れるようになった 設定情報を手軽に確認できる 設定内容を確認するために今まではログインして等の手順が必要だったが、設定内容の書かれたリポジトリをPullするだけで最新の内容を確認できるようになった コード化されたことでコンソールからはできなかった検索ができるようになった 更新する設定のレビューをメンバーに依頼できる 一人で作業することがなくなりチーム内での情報共有が手軽になっただけでなく、更新内容を事前にしっかりメンバーと共有できているという事実が心理的な安全性を向上させてくれた 無事に「設定の内容が可視化できていない」という課題点をTerraformを用いて解決できました。設定情報が手軽に確認できるようになったことで重複のあったリストの整理や不要なリストの削除が進みました。削除作業の際にも履歴をコミットとして残せるため作業の途中経過を残すことができ、管理がしやすかったです。 まとめ どうしても古くから利用しているSaaSやインフラは煩雑、複雑になってしまいがちです。部分的なところからでもIaCを導入することで開発効率の向上に少しづつ繋げていけます。 今回はAkamaiのネットワークリストをTerraformを用いてIaC化することで、いままで感じていた不便だった点を解消できました。今回IaC化のツールとしてTerraformを選択し、AkamaiのTerraformモジュールが整備されていることもありとても手軽にIaC化ができました。AkamaiのTerraformモジュールは頻繁にアップデートされており、IaC化の作業をしている間にも頻繁にアップデートがされていました。 すべてをIaC化することも良いと思いますが、まずはIaC化する意味があるところから少しずつ進めてみてはいかがでしょうか。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co また、カジュアル面談も随時実施中です。「話を聞いてみたい」のような気軽な感じで大丈夫ですので、是非ご応募ください。 hrmos.co
はじめに こんにちは。カート決済部の林です。ZOZOTOWN内のカートや決済の機能開発、保守運用を担当しています。 過去に福袋販売イベントの負荷対策の記事を掲載しました。 techblog.zozo.com 上記の記事では、タイムアウトしたプロセスがロックを掴んだままになっていたことが原因で、大量のブロッキングが発生していました。詳細な負荷や対策の内容について知りたい方は、ぜひ上記の記事を読んでみてください。 こちらの原因を解決するために、 XACT_ABORT の設定を ON にすることが有効であると記載しています。 XACT_ABORT はトランザクション内でエラーが発生すると即座にロールバック+ロックの解放を指示できるオプションです。このオプションを ON にすることで、タイムアウトした時点でロックが解放され、ブロッキングが発生しなくなりました。 ただし、設定を ON に変えると一部動作が変わり、既存の処理が正常に動かなくなることがあります。弊社で ON にした際も一部のストアドプロシージャ(以下ストアド)が正常に動かなくなりました。その時に XACT_ABORT の動作について確認したので、その確認内容について本記事で紹介したいと思います。 XACT_ABORT の動作について知りたい方や、これから ON にしようとしている方などの参考になれば幸いです。 目次 はじめに 目次 XACT_ABORTの概要 動作確認の準備 トランザクション内での動作 XACT_ABORTがOFFの場合 XACT_ABORTがONの場合 ストアド内での動作 XACT_ABORTがOFFの場合 XACT_ABORTがONの場合 注意点 まとめ 最後に XACT_ABORTの概要 XACT_ABORT はSQL Serverのオプションの1つで、デフォルトでは OFF になっています。 ON/OFF それぞれの動作を以下の表に記載します。 設定 エラー時の動作 後続の処理 OFF 発生した処理のみがロールバック 実行される場合がある ON トランザクション全体が終了しロールバック 実行されない 実際に上記の動作についてテスト用のテーブルを作成し動作確認を行います。 動作確認の準備 実際に動作を確認するために、テスト用のテーブルと初期レコードを準備します。テーブル作成は以下のDDLを実行し、 table1 と table2 を作成します。このとき、動作確認時にエラーを起こしやすくするために、 table1 の col1 と table2 の col1 に外部キー制約を設定します。 CREATE TABLE table1 (col1 INT NOT NULL PRIMARY KEY); CREATE TABLE table2 (col1 INT NOT NULL REFERENCES table1(col1)); 初期のレコードとして以下のクエリを実行し table1 に4レコード INSERT します。 INSERT INTO table1 VALUES ( 1 ); INSERT INTO table1 VALUES ( 3 ); INSERT INTO table1 VALUES ( 4 ); INSERT INTO table1 VALUES ( 5 ); ここまでで準備完了になります。 トランザクション内での動作 XACT_ABORT がOFFの場合 動作確認のために以下のクエリを実行します。 SET XACT_ABORT OFF; GO BEGIN TRANSACTION; INSERT INTO table2 VALUES ( 1 ); INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる INSERT INTO table2 VALUES ( 3 ); COMMIT TRANSACTION; GO 1行目で XACT_ABORT を OFF に設定します。その後トランザクションを開始します。トランザクション内で table2 に3回の INSERT を行います。1回目と3回目の INSERT は正常に完了します。しかし、2回目の INSERT では table1 の col1 に2が入っているレコードが存在しないため、外部キー制約により失敗します。3回の INSERT が終わった後に COMMIT TRANSACTION を行います。 では、この時に table2 のレコードはどうなっているでしょうか。以下のクエリで見てみます。 SELECT * FROM table2 クエリの結果は以下になります。 2回目の INSERT でエラーになっているのですが、1,3回目の INSERT は反映されています。つまりは、 XACT_ABORT が OFF の場合には一部のクエリがエラーになってもそのまま処理が続けられます。なので、エラーハンドリングを自前で行う必要があります。 例えば今回の場合に、2回目の INSERT が失敗したら全てロールバックするには以下のように TRY-CATCH で囲います。 SET XACT_ABORT OFF; GO BEGIN TRY BEGIN TRANSACTION; INSERT INTO table2 VALUES ( 1 ); INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる INSERT INTO table2 VALUES ( 3 ); COMMIT TRANSACTION; END TRY BEGIN CATCH ROLLBACK TRANSACTION; END CATCH GO XACT_ABORT がONの場合 では次に ON の場合の動作を見ていきます。 OFF の動作確認時の table2 のレコードを以下のクエリで削除します。 DELETE table2 以下のクエリで動作確認をします。 SET XACT_ABORT ON ; GO BEGIN TRANSACTION; INSERT INTO table2 VALUES ( 1 ); INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる INSERT INTO table2 VALUES ( 3 ); COMMIT TRANSACTION; GO クエリの内容としては1行目で XACT_ABORT を ON にした以外は OFF の動作確認と同じです。このクエリを実行後に以下のクエリで table2 のレコードを見ます。 SELECT * FROM table2 取得できたレコードの件数は0件になります。つまりは、 XACT_ABORT が ON の場合にエラーが発生すると、その時点で処理が終わりロールバックされます。なので、 ON の場合に自前でロールバックを行う必要はありません。 ストアド内での動作 XACT_ABORT がOFFの場合 XACT_ABORT が OFF で動作するストアドを作成します。 CREATE PROCEDURE TEST_PROC AS SET XACT_ABORT OFF; BEGIN TRANSACTION; INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる IF @@ERROR = 0 BEGIN COMMIT TRANSACTION; RETURN 0 END ELSE BEGIN ROLLBACK TRANSACTION; RETURN 1 END GO table2 への INSERT の結果により返り値を変えています。本記事の環境では table1 の col1 に 2 が入っているレコードが存在しないため、外部キー制約によるエラーになります。そのため、返り値は1となりロールバックされます。 作成したストアドを以下のクエリで実行します。 DECLARE @ Return INT EXEC @ Return = TEST_PROC SELECT ' Return Value ' = @ Return GO すると以下の結果になります。 XACT_ABORT がONの場合 では次に XACT_ABORT の設定を ON に変えます。 ALTER PROCEDURE TEST_PROC AS SET XACT_ABORT ON ; BEGIN TRANSACTION; INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる IF @@ERROR = 0 BEGIN COMMIT TRANSACTION; RETURN 0 END ELSE BEGIN ROLLBACK TRANSACTION; RETURN 1 END GO 変更点は3行目の XACT_ABORT の設定を ON に切り替えたところのみです。変更後のストアドを以下のクエリで実行します。 DECLARE @ Return INT EXEC @ Return = TEST_PROC SELECT ' Return Value ' = @ Return GO Return Value が返ってこなくなります。これは XACT_ABORT の設定により処理が中断されたためです。返り値で1を返せるようにするには、 @@ERROR でハンドリングしていたところを TRY-CATCH にする必要があります。 TRY-CATCH に修正したストアドが以下になります。 ALTER PROCEDURE TEST_PROC AS SET XACT_ABORT ON ; BEGIN TRANSACTION; BEGIN TRY INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる END TRY BEGIN CATCH ROLLBACK TRANSACTION; RETURN 1 END CATCH COMMIT TRANSACTION; RETURN 0 GO 変更後のストアドを以下のクエリで実行します。 DECLARE @ Return INT EXEC @ Return = TEST_PROC SELECT ' Return Value ' = @ Return GO 結果は以下のようになり、 XACT_ABORT が OFF の時と同じように返り値が 1 になることが確認できます。 このように、 @@ERROR でハンドリングしている処理がある場合には修正が必要になります。 弊社では @@ERROR でハンドリングしているストアドがあり、 ON にするタイミングで @@ERROR のハンドリングから TRY-CATCH を使ったハンドリングへ修正しました。 注意点 本記事ではSQL Server Management Studioからクエリを実行して XACT_ABORT の動作を確認しています。そのため、アプリケーションでトランザクションを張った場合などは異なる動作をすることがあります。各自の実行環境で動作確認してから導入してください。 また、外部キー制約時のエラーの動作を中心に記載しました。この動作はエラーの重要度レベルによって変わることがあります。エラーレベルが異なる場合も各自で動作を確認してみてください。エラーレベルに関しては以下を参照してください。 docs.microsoft.com まとめ 本記事では XACT_ABORT の動作について紹介しました。紹介した動作について以下の表にまとめます。 設定 エラー時の動作 ロールバック処理の記述 ストアド内でのハンドリング方法 OFF 発生した処理のみがロールバック 全ての処理をロールバックしたい場合は処理を書く必要がある @@ERROR でのエラーハンドリングが可能 ON トランザクション全体が終了しロールバック 自動でロールバックされるので書く必要がない TRY-CATCH でのエラーハンドリングが可能 最後に カート決済部では負荷軽減の対策から、新機能開発、カート決済リプレイスなどを行っておりタスクが山積みの状態です。このような課題を一緒に進めていただける仲間を募集しています。ご興味のある方は以下のリンクから是非ご応募ください。 hrmos.co hrmos.co また、カジュアル面談も随時実施中です。是非ご応募ください。 hrmos.co
はじめに ZOZOMO部プロダクト開発ブロックの木目沢です。 ZOZOMO で提供しているZOZOTOWN上での「ブランド実店舗の在庫確認・在庫取り置き」APIの開発に携わっています。 今回は、開発当初から現在に至るまでのユニットテスト戦略についてお話しします。 意識してテストを書いていたのにカバレッジが低い問題 2021年11月にリリースされたブランド実店舗の在庫確認・在庫取り置きの機能ですが、開発当初のユニットテスト方針は以下のようなものでした。 モデルのユニットテストは必ず書く モデル以外の箇所は可能な範囲でユニットテストを書く 当時は実装のコードよりテストコードを先に書くといった文化はなく、レビューでテストの有無や内容を指摘する程度のものでした。 カバレッジも取っており、GitHub上では見える化していたものの、いつの間にか確認する機会も失われていきました。 もちろん、リリース前にはQAチームによるUIのテストも通り、十分なテストを経てリリースされています。しかし、当時のカバレッジは60%程度。カバレッジの数値というのは結果であってカバレッジの数値を上げることが目的ではないものの、今後安全に保守していくには心もとないものでした。 カバレッジは何%あるのが妥当か? 60%で心もとないと思ったのは、以前マーチン・ファウラー氏の テストカバレッジに関するブログ を読んだことがあったためです。ポイントを引用します。 思慮深くテストを実施すれば、テストカバレッジはおそらく80%台後半か90%台になるだろう。 カバレッジの数値が低い場合、たとえば50%以下の場合は、おそらく問題があるだろう。高いカバレッジの数値にはあまり意味はない。ダッシュボードの数字に意味がなくなる助けをするだけだ。 以下の質問に「はい」と答えられるならば、おそらくテストは十分だろう: 本番環境で発見されるバグはほとんどない。そして、 本番環境でバグを出すことを恐れてコードの変更をためらうことがない。 50%以下ということはありませんでしたが、それでも「本番環境でバグを出すことを恐れてコードの変更をためらうことがない」とは言えない状況でした。 TDDとTDD is deadへの誤解 カバレッジを上げる必要があると考え、まず思いついたのは「テスト駆動開発(以下TDD)」でした。テストを先に書けば自ずとカバレッジが上がると考えました(後述しますが、この考えは間違っています)。 一方で、同時に「TDD」に関して思い出したのは、 「TDD is dead. Long live testing.」 という言葉でした。2014年に発表されたRuby on Railsの作者としても有名なDavid Heinemeier Hansson氏のブログです。 インパクトのあるタイトルが界隈を賑わせましたが、タイトルだけで判断すると誤ります。そして実際誤っていました。TDDは意味がない。最終的にテストがあればよいのだと。 ブログの記事をよく読むと、以下のことが述べられています。 伝統的な意味でのユニットテストはほとんどしない。 テストファースト原理主義 ユニットテストやテストファーストという表現に「伝統的な意味での」という接頭辞や「原理主義」という接尾辞がありました。伝統的とか原理主義というのは、どのような意味なのか、以下抜粋します。 私は伝統的な意味でのユニットテストはほとんどしない。すべての依存関係をモックにし、何千というテストが数秒で終わるようなユニットテストのことだが。 テストファーストのユニットテストは、中間的オブジェクトや間接的で過剰に複雑な構造を生みがちだ。「遅い」ものをすべて避けようとするのがその理由で、データベースやファイルIOなどを避ける。ブラウザを使ってシステム全体をテストするのも避けようとする。 批判しているのは、モックを大量に使ってすべてをテストファーストで設計する手法であったり、データベースやE2Eテストも避けようとするやり方であったりします。 書籍「テスト駆動開発」 の付録では、TDDの歴史の流れから説明されていて、David氏がどのような経緯でこの考えにたどり着いたかが詳しく説明されていますのでご一読されることをおすすめします。 もう一度TDD。そしてTDDとは何か? 書籍「テスト駆動開発」 にきちんとTDDとはなにかということが記載されています。 開発者が設計の治具としてテストコードを同時に書きながら開発と改善を回していくというTDDの姿(KentBeck/テスト駆動開発より) これをもう一度見直そうと書籍を読み直しました。 そこで、気がついたのは以下のようなTDDにおけるテストと実装の流れでした。 テストを書く。実行すると実装前なのでエラーになる(RED) グリーンとなる実装を書く(GREEN) リファクタリングし、詳細に実装していったり、整えたりする(REFACTORING) 以下繰り返し つまりテストを最初に書くテストファーストだけがTDDのすべてではなく、実装し、テストを頼りにリファクタリングとしていく全体の流れこそがTDDです。 プロジェクトへのTDD導入 ここまで理解したところで、TDDをプロジェクトに導入しました。徐々にカバレッジも上がりましたが、あくまでTDDはタスクを実現するための設計手法であって、品質を保証するためのものではありません。テストを先に書けば自ずとカバレッジが上がると当初は考えましたが、TDDとカバレッジの関連は本来ありません。もちろん、TDDによって他のテストも書きやすくなり、結果的にカバレッジが上がっていくことはあると思います。 TDDを主にモデル層に導入したところ気がついたのはビジネスロジックが「モデルだけ」に集まることです。タスクを実現する実装コードをモデルだけで実現するように書いていくことで、他の層やSQLに書かれることがなくなりました。 この点がTDDにおいて重要なところになります。テストによって設計を駆動していく手法、まさに、Test Driven Developmentということです。 データベースのテストもAPIのテストもやる TDDを進めていくにつれて、データベース接続のテストや、APIのコントローラーのテストなどモデル以外の層のテストも書くようになっていきました。この辺りをモックにしてテストの実行時間を短くするという考えもあるかと思いますが、それこそまさにDavid氏が批判してきたところです。 現在では、データベースのテストもJavaであればDBUnitを使うことで、Spring BootやMyBatisと連携したテストが書けます。コントローラもSpring BootではWebMvcTestが容易に使えるなど、テストツールが充実しています。また、時間がかかるテストもCIで回せばそこまで大きな負担とはなりません。 ユニットテストに加えて、E2Eのテストも Karate などで容易に導入できます。弊チームでも導入し始めており、仕様の確認から実装後の確認までE2Eテストを活用しています。 こうして、プロジェクトではTDDやその他ツールを活用し、充分に成果があがるようになってきました。ここでさらに社内にもTDDを広めようと2つの活動を開始しました。 TDDを活用したライブコーディング会 「ブランド実店舗の在庫確認・在庫取り置き」APIの開発にあたり、開発当初から相談に乗っていただいた技術顧問の かとじゅんさん を交え、TDDを活用したライブコーディング会を定期的に開催しています。 もともとはドメイン駆動設計を実際のソースを書きながら学習していくことが主目的でした。それをTDDで実践したほうが理解しやすいと、かとじゅんさんからのご提案でこの形になりました。 社内においてもTDDをすぐに実践できるぐらいに広められる良い機会となりました。 TDD写経会 書籍「テスト駆動開発」 の付録にある通り、写経も試してみた結果、理解が深まったため、社内で写経会もはじめました。 当ブログ執筆時点(2022年08月16日)で20回開催され、都度みんなで理解を深めています。 テスト駆動開発は、実際に手を動かしてみないと理解が難しい技法です。本書も、読んだだけでは深い得心には至らないでしょう。しかし、テスト駆動開発の良さ、強みは手を動かせばわかります。なぜなら、TDDの本質は精神状態のコントロール、不安と自信の制御にあるからです。結果(書かれたコードとテストコード)ではなく、過程(思考プロセスとリファクタリング)に本質があります。(KentBeck/テスト駆動開発より) 書籍に書いてあることを理解するには、実際に手を動かして試してみることがTDDにおいては理解を深める近道で、書籍のサンプルコードは本当にゆっくりしたペースと手順で解説されています。 1行1行そのままサンプルコードを写して、テストを実行することで初めて気づくことが多いです。 以下の画像は、写経会で私が共有した感想になります。 TDDのやり方とコツ TDDのやり方は先程紹介したとおり、テストを書く。グリーンになるように実装を書く。リファクタリングをする、という流れを繰り返す単純なものです。しかし、単純だからこそ、うまく実行するコツが必要です。ここで書籍や、ライブコーディング会、写経会を経て掴んだコツを紹介したいと思います。 (1)テストを書く。実行すると実装前なのでエラーになる(RED) TDDにおけるテストとはタスクのことです。タスクを実現するようなテストをまず書くことでそれが設計となります。そのため、まず最初にすることはタスクの洗いだしです。タスクひとつひとつがテストとなり、テストの内容が設計となり、実装されていくイメージです。 テストメソッド名、つまりタスクは「日本語」で書いています。タスクをそのままテスト名にすることで、実装のイメージがつきやすくなりました。日本語なんてと思われる方も多いかと思いますが、文字コードの問題も起こらず使用できています。 例えば以下のようなイメージです。 public class StringObjectTest { @Test public void 2つの文字列を接続し結合した文字列を返す(){ StringObject str = new ReturnString( "str" ); str.concat( "concatString" ) Assertions.assertEquals( "strconcatString" , str.value); } } (2)グリーンとなる実装を書く(GREEN) まずは、最短でグリーンになるように書くことが大事です。データベースに接続するようなタスクであれば、とりあえずMapで代用しても問題ありません。文字列を返すようなメソッドであれば何もせず適当な文字列を返しても大丈夫です。このような実装を仮実装と言います。その後はGREENを維持しながら実装とリファクタリングを繰り返していくことで安心して本実装ができます。 先の例を実装すると以下のとおりです。本番用のコードとしてはありえないと思いますが、まずは最短でグリーンになる実装を書くのがコツです。 public class StringObject { String value = "str" + "concatString" ; public void concat(String str){ } } (3)リファクタリングし、詳細に実装していったり、整えたりする(REFACTORING) TDDで一番使われるリファクタリングのテクニックは「重複の除去」になります。そして、TDDでいう「重複」とは「テストコード」と「実装コード」間の重複や、「文字列や数値」の重複を含みます。例を挙げます。 public class StringObjectTest { @Test public void 2つの文字列を接続し結合した文字列を返す(){ StringObject str = new ReturnString( "str" ); str.concat( "concatString" ) Assertions.assertEquals( "strconcatString" , str.value); } } テストは上記のようなコードでした。それに対しての仮実装は以下のようなものでした。 public class StringObject { String value = "str" + "concatString" ; public void concat(String str){ } } この仮実装とテストを比較すると、重複しているのは「str」や「concatString」の文字列になるので、それを除去していきます。 以下はリファクタリングした例になります。 public class StringObject { String value = "str" ; public void concat(String str){ value = value + str } } これで、「concatString」が除去され、少しまともな実装コードになりました。この繰り返しできちんとした実装コードにしていくことができます。しかもテストが既にあるので、安心してリファクタリングが可能です。 まとめ〜いまさら? いまこそTDD KentBeck氏によって 書籍「テスト駆動開発」 が最初に出版されたのは2002年。それからちょうど20年が経ちました。今でもTDDのやり方に慣れない、導入できない、内容を誤解している方も多いのではないでしょうか。 いまこそTDDを見直してみてプロジェクトに導入してみてください。 今回は、弊チームでのユニットテスト戦略のテーマでお伝えしました。開発当初はモデルへのユニットテストのみを重視していました。しかし、TDDを見直すことで、仕様の策定からタスク化・設計・各層における実装のテストまでプロジェクトのサイクル全般をテストでカバーする開発スタイルへと変化しています。 ZOZOMO部では、TDDはもちろん、サーバーレスアーキテクチャやイベントソーシング、ドメイン駆動設計などを活用しサービスを成長させたい仲間を募集中です。ご興味ある方は こちら からぜひご応募ください! corp.zozo.com