TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

938

はじめに こんにちは。計測プラットフォーム開発本部バックエンドチームの佐次田です。普段はZOZOMATやZOZOMETRYなどの計測技術に関わるシステムの開発、運用に携わっています。 本記事では計測システムにおける計測データの管理方法を進化させた点についてご紹介します。 目次 はじめに 目次 計測プラットフォーム開発本部 バックエンドチームとは ユビキタス言語 3Dデータ 計測箇所 計測値 ZOZOMETRYとは ZOZOMETRYのコア機能 計測箇所のカスタマイズに関する課題 計測処理をサーバーが担当する解決アプローチ システム構成図 既存プロダクトのシステム構成 ZOZOMETRYにおけるシステム構成 システム構成の変更点 計測セットを元に計測箇所をカスタマイズする 計測セットのドメインモデル 計測シーケンスの概要 3Dデータと計測値の保存先を分離する 3DデータをAPIサーバーで管理する課題 ペイロードのサイズが大きい GC(Garbage Collection)によるパフォーマンスの低下 署名付きURLによる解決アプローチ 署名付きURLとは APIサーバーを経由せずに3Dデータのやり取りを可能とする 署名付きURLを利用した3Dデータのアップロード APIサーバーを経由する場合 署名付きURLを利用した場合 署名付きURLを利用した3Dデータのダウンロード APIサーバーを経由する場合 署名付きURLを利用した場合 署名付きURLを利用したメリット まとめ 最後に 計測プラットフォーム開発本部 バックエンドチームとは 計測プラットフォーム開発本部バックエンドチームは、ZOZOMAT、ZOZOGLASS、ZOZOSUITによって採集される計測データにまつわるバックエンド開発を担うチームです。アプリやブラウザに対するクライアントAPIや、ZOZOTOWN内部のマイクロサービスのAPIにおいて、徹底的に低レイテンシにこだわりを持つことと、高可用性を保つことを目指しています。 ユビキタス言語 本記事を読む上で必要なユビキタス言語は以下の通りです。 3Dデータ 3Dデータは、被計測者の体型などを3次元で表現するデータです。ZOZOSUITやZOZOMATなどで共通の計測技術を利用して生成されるデータであり、計測時点での身体情報の3D表示を可能とします。ZOZOSUITの場合は被計測者の体型を、ZOZOMATの場合は被計測者の足型を3Dで表現します。 計測箇所 計測箇所は計測の対象となる各部位のことを指します。ZOZOSUITの場合は腕周りや首周り、ZOZOMATの場合は足幅や足長など複数箇所を計測箇所として定義しています。 計測値 計測箇所ごとに計測された数値データを計測値として扱います。 ZOZOMETRYとは ZOZOMETRY とは、事業者の採寸業務を効率化し、採寸が必要な服の売上拡大やコスト削減に貢献するサービスです。多様な業種のビジネスニーズに応えられるよう、計測箇所を柔軟に選択できます。ファッションに限らず、フィットネスや医療、スポーツなど幅広い業種に対応できるように設計されています。 corp.zozo.com ZOZOMETRYのコア機能 ZOZOMETRYのコア機能として、身体計測を実現する機能と、計測データの管理機能があります。身体計測の機能は計測技術により手軽に高精度な計測を実現する機能であり、直近のリリースによりZOZOSUITの着用無しでも計測が可能となりました。計測データの管理機能は計測データの保存、参照、エクスポートなどを行う機能で、計測データの管理を効率化し、より重要な業務に集中できるように設計されています。しかし、身体計測の機能を顧客企業向けに提供するには計測箇所の柔軟なカスタマイズが必要であり、これまでの構成では難しい問題がありました。初めに既存プロダクトにおいてどのような構成で身体計測の機能を提供していたかと、ZOZOMETRYにおいて計測箇所をカスタマイズするために必要だった構成についてご紹介します。 計測箇所のカスタマイズに関する課題 ZOZOMETRYではビジネスニーズに合わせて顧客企業ごとに計測箇所の柔軟なカスタマイズを行えることが重要でした。既存プロダクトでは固定の計測箇所を定義していたため、顧客企業ごとの要望に対応することが難しい状況でした。例えば、ウエストという計測箇所は顧客企業によってはウエストを腰骨の位置から何センチ上で定義しているなど細かな差異があり、1つのウエストという定義では個別の要求に対応できない課題がありました。 計測処理はアプリ内に内包されているSDKにより行われており、内部的には身体をスキャンする処理と計測値を算出する処理が行われています。この構成の場合、計測箇所を追加・編集するにはアプリの更新が必要でした。 計測処理をサーバーが担当する解決アプローチ 計測箇所をカスタマイズ可能とするため、3Dデータの生成と計測値を算出する責務をアプリとサーバーで分離するアプローチを取りました。具体的な方法は割愛しますが、計測値の算出処理をサーバー側に移動することで、計測箇所の追加や更新によるアプリの更新が不要になる想定でした。 システム構成図 既存プロダクトとZOZOMETRYでのシステム構成図の比較は以下の通りです。 既存プロダクトのシステム構成 ZOZOMETRYにおけるシステム構成 システム構成の変更点 アプリ内で行っていた計測値の算出処理をサーバーが担当するように変更しました。また、3Dデータと計測値の保存先はユースケースに合わせて分離する構成を取りました。 計測セットを元に計測箇所をカスタマイズする 計測セットは先述のカスタマイズした計測箇所をさらに顧客企業ごとに必要な計測箇所のまとまりとして定義する機能であり、これにより計測ごとに必要な計測箇所を顧客企業が自由に編集できるようにする機能です。ドメインモデルとしては、以下のような構成となっています。 計測セットのドメインモデル 計測シーケンスの概要 サーバーは計測セットに含まれる計測箇所を元に計測値の算出処理を行います。このアプローチにより、顧客機能のビジネスニーズに合わせた計測処理の提供が可能となりました。 3Dデータと計測値の保存先を分離する 既存プロダクトでは3Dデータと計測値を単一の保存先に格納、管理していましたが、3Dデータと計測値はそれぞれ異なる特性を持っており、それぞれの特性に合わせた最適な保存先を検討する必要がありました。3Dデータは不変である特性を持ち、一度作成されると変更されることがありません。扱いとしてはファイルや画像などと同様に管理することが良いと考え、管理にはAWSが提供するS3を利用しました。S3については多くの記事で紹介されているためここでは割愛しますがパフォーマンスとセキュリティ、可用性の面で優れており、3Dデータの管理に適していると考えました。計測値は3Dデータとは異なり、計測データを管理するコア機能において参照されるユースケースが多くあります。計測を一覧表示する機能や、計測データをCSVエクスポートする機能など、計測値を参照し結合して返す必要がありました。そのため、計測値の管理にはリレーショナルデータベースであるRDSを採用することにしました。 3DデータをAPIサーバーで管理する課題 3Dデータの管理にS3を利用することを決定しましたが、3DデータのS3へのアップロード、ダウンロードをAPIサーバーを経由して行うことに課題がありました。 ペイロードのサイズが大きい 3Dデータはサイズが大きいため、被計測者に計測データを返す際のパフォーマンスが課題でした。ZOZOMATについては3Dデータの送信に対して通信プロトコルを変更するアプローチで対応しました。詳細については下記の記事をご参照ください。 techblog.zozo.com 通信プロトコルを変更することで一定の効果を得ましたが、API経由のスキャンデータのアップロードに時間がかかるなどサイズが大きいことによる課題は残り続けていました。 GC(Garbage Collection)によるパフォーマンスの低下 私たちバックエンドチームではプログラミング言語にScalaを採用しています。技術戦略については下記の記事をご参照ください。 techblog.zozo.com 既存プロダクトでは3Dデータを参照するたびにAPIサーバーを経由してデータを取得します。もちろん、計測データを専用に扱うサーバーを別で建てるように分離することも検討しましたが、初期の設計では立ち上げ期という事情も働き、モノリシックな構成でローンチしています。ScalaはJVM上で動作するためメモリの解放時にGCが発生します。サイズの大きな3Dデータをメモリに繰り返しロードすることでGCも頻発し、パフォーマンスに影響を与える問題がありました。 署名付きURLによる解決アプローチ 署名付きURLとは 署名付きURL はAWS S3バケットに対して一時的なアクセス権限を付与するURLです。これにより、特定のオブジェクトに対して指定された時間内に限り、読み取りまたは書き込みの操作が可能です。 APIサーバーを経由せずに3Dデータのやり取りを可能とする 3Dデータはサイズが大きいためAPIサーバーを経由せずにクライアントとデータのやり取りをすることが重要となりました。署名付きURLを利用することで、APIサーバーに負荷をかけずセキュアにS3へのアップロードを実現できます。 署名付きURLを利用した3Dデータのアップロード APIサーバーを経由する場合 署名付きURLを利用した場合 クライアントとサーバーのやり取りは署名付きURLの発行手続きのみとなり、3DデータのアップロードはクライアントとS3の間で直接行われます。このアプローチによりAPIサーバーの負荷を軽減し、APIサーバーのスケーリングに依存せずデータのやり取りが可能となりました。 署名付きURLを利用した3Dデータのダウンロード APIサーバーを経由する場合 署名付きURLを利用した場合 アップロードと同様に、クライアントとサーバーのやり取りは署名付きURLの発行手続きのみとなり、3DデータのダウンロードはクライアントとS3の間で直接行われます。ダウンロードの場合は署名付きURLをstatus code 302で返し、ダウンロード用のリンクにリダイレクトするようにクライアントに返します。これにより、ブラウザが自動的にリダイレクトを処理するため、クライアント側での実装負担を削減できます。 署名付きURLを利用したメリット 署名付きURLを利用することで以下のメリットがありました。 パフォーマンスの向上 3DデータをAPIサーバーを経由せずにやり取りすることで、APIサーバーの負荷が軽減されました コストの削減 APIサーバーを経由せずに3Dデータがやり取りできるため、サーバーリソースの節約が可能となりました APIサーバーの実装をシンプルに 署名付きURLを利用することでAPIサーバーの実装がシンプルになり、開発やメンテナンスが容易になりました 一方で、署名付きURLを用いたデメリットとして、クライアントでは1回のアップロード操作に対して、署名付きURLの発行リクエストと実際のデータアップロードリクエストの2回のリクエストを処理する必要があります。署名付きURLを多く使用する場合はクライアントの処理が煩雑になるため、実装に注意が必要です。 まとめ ビジネスニーズに合わせて計測処理をサーバーで管理することで、計測箇所の追加や変更のニーズに対応可能となりました。3Dデータと計測値を分離し、それぞれの特性に応じた最適な管理方法を検討しました。また、クライアントとサーバー間の3Dデータの受け渡しに署名付きURLを活用することでAPIサーバーの負荷を軽減しました。 ZOZOMETRYはローンチ後も引き続き機能追加をしており、計測データの管理方法について検討を続けていきたいと考えています。 最後に 計測プラットフォーム開発本部バックエンドチームでは、グローバルに計測技術を開発していくバックエンドエンジニアを募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
アバター
はじめに こんにちは、ZOZOTOWN企画開発部・企画フロントエンド1ブロックの ゾイ です。 ZOZOTOWNトップでは、セール訴求や新作アイテム訴求、未出店ブランドの期間限定ポップアップ、著名人コラボなどの企画イベントが毎日何かしら打ち出されています。私はそのプラットフォームとなる企画LPをメインに実装するチームに在籍しています。 今まで実装した企画LPをアーカイブするプロジェクトにも参加しているので、チームの特性や企画LPの詳細は下記の記事をご覧いただければと思います! techblog.zozo.com 目次 はじめに 目次 背景・課題 仕様・技術選定 1. 型の定義と入力データのバリデーション 2. 入力フォームの実装 3. URLの生成 ユーザーの反応 終わりに 背景・課題 ZOZOTOWNではカルセールバナーなど、アプリとWeb共通で表示させるコンテンツにはURLを分ける必要があります。 例えば、レディーズのトップス商品一覧がある検索結果に遷移したい場合、Webでは以下のようなURLを指定する必要があります。 https://zozo.jp/women-category/tops/ 対して、アプリでは以下のようなスキームを指定する必要があります。 zozotown://index/search?view=result&p_cutyid=2&p_tycid=101 上記のようなURLは施策担当の方が下記の方法で作成し、カルセールバナーやLP内の遷移先に利用することが一般的なフローです。 Web:検索結果のページで絞り込み条件を手動で入れて発行する アプリ:既存のアプリスキーム生成ツールで作る 今まではそれぞれ違う方法でURLを作成していたため、下記のような問題がありました。 WebのURLでの絞り込み条件とアプリのURLでの絞り込み条件が一致しない URL生成に慣れてない部署やチームは作り方に迷うため、PMの負担が高まる 課題を解決するためPMと相談した結果、アプリとWeb用のURLを同時に生成できる「URL生成ツール」を実装することになりました。 仕様・技術選定 検索ページ用のURL生成 商品詳細ページ用のURL生成 アプリ上でLPを確認できるQRコード生成 外部URL遷移用のアプリスキーム生成 上記の機能をミニマムで盛り込み、デザイナー・PM・事業部・エンジニア等、企画に関わる多方面のスタッフにとって嬉しいツールを目指します。今回の記事では主に「1. 検索ページ用のURL生成」の実装方法について紹介したいと思います。 アプリのようにサクサク動くPWA的なサイトにしたかったため、全ての処理がCSRで実現できることを意識しました。そのため、 Next.js 、 MUI 、 Emotion 、 zod などを採用しました。 Googleなどで利用されておりUI的に一番親近感もあってEmotionとも相性が良いため、MUIを採用しました。また、検索ページ用のURL生成には入力フォームが多いため、ユーザーが誤って間違えた値を入力できないよう、エラー検知用にzodを採用しました。 他にも qrcode.react や encoding-japanese などのようなヘルパーパッケージを導入しました。 1. 型の定義と入力データのバリデーション まず、検索ページで利用しているパラメーターを調査し、許可されない値の入力を防ぐ対応を入れます。 下記は「価格(から)」が必ず「価格(まで)」より小さい数字にさせる対応の例です。 const priceSchema = z . object ({ priceFrom : z . union ([ z . literal ( '' ) , z . number () . min ( 0 )]) , priceTo : z . union ([ z . literal ( '' ) , z . number () . min ( 0 )]) , }) . refine (( schema ) => { return ( schema . priceFrom === '' || schema . priceTo === '' || ( schema . priceFrom && schema . priceTo && schema . priceFrom < schema . priceTo ) ) } , 'PriceFrom must be less than PriceTo' ) 上記のような制限をすべて含めて、以下のような型をzodで定義します。 export const schema = z . object ({ // ...省略 gender : z . number () , couponFilter : z . boolean () , discountRate : z . number () . min ( 1 ) . max ( 100 ) . optional () , }) . merge ( priceSchema . innerType ()) 2. 入力フォームの実装 次に、入力フォームを実装します。CSRでURLを生成できるよう、入力フォームの状態をReactのContextを共有する形で実装していきます。入力フォームにはMUIの AutoComplete 、 FormControl 、 TextField を採用しました。特に AutoComplete はアルファベット順のソートや、検索機能もあるためブランド一覧の表示にとても便利です。 入力フォームは以下を考慮して実装しました。 onChange時(ユーザーが何かを入力した時)に、Contextを更新する 入力項目に誤りがあったらエラーUIにさせる ショップなど、Webでは名前、アプリではIDを利用する項目が多いため、データ属性に両方指定する 同時に絞り込みきない項目や必要な項目がない場合、非アクティブ状態にする 以下は「価格(から)」のフォームの実装例です。 export const PriceFrom = () => { // コンテキスト const { searchParamsValue , setSearchParamsValue } = useContext ( SearchContext ) const { priceFrom , ... rest } = searchParamsValue const [ hasError , setHasError ] = useState ( false ) // ユーザーが入力したら値を確認し、問題なかったらコンテキストを更新する const handleChange = ( e: React . ChangeEvent < HTMLInputElement > ) => { const input = parseInt ( e . target . value ) const result = priceSchema . safeParse ({ priceFrom : input , priceTo : searchParamsValue . priceTo , }) if ( ! result . success ) setHasError ( true ) else { setHasError ( false ) setSearchParamsValue ({ priceFrom : input , ... rest }) } } return ( < TextField label = { hasError ? `¥ ${ searchParamsValue . priceTo } 以下の数字を入力してください` : '価格(から)' } type = "number" variant = "standard" onChange = { handleChange } autoComplete = "off" error = { hasError } sx = {{ width : 170 }} InputProps = {{ startAdornment : < InputAdornment position = "start" > ¥ </ InputAdornment > , }} /> ) } 3. URLの生成 最後に、URLを生成します。 まず、アプリとWebによって違う値を利用する項目があるため値を変換する対応を入れます。例えば、キーワード絞り込みはアプリではUTF-8でエンコードされた値を、WebではShift_JISにエンコードされた値を利用します。 export const transformValues = ( searchParameters: SearchParameters , platform: 'web' | 'app' , ) : TransformedValues => { const isApp = platform === 'app' const { keyword } = searchParameters const transformedValues: TransformedValues = { // ...省略 keyword : isApp ? encodeURI ( String ( keyword )) : encodeToShitJis ( String ( keyword )) , } return transformedValues } 次に、アプリとWebのURLを生成する関数を実装します。アプリのURLは全てのパラメーターを繋ぎこむだけで済みますが、Webの場合はZOZOTOWNのドメイン知識が必要なため、もう少し複雑な処理が入っています。そのため、今回の記事では一般的に利用できる、アプリのURLを生成する関数だけ紹介したいと思います。 export const composeAppUrl = ( value: SearchParameters ) => { if ( value === DEFAULT_SEARCH_PARAMS ) return '検索条件を入力してください' // コンテキストの値をアプリ用に変換する const transformedValues = transformValues ( value , 'app' ) const params = { ... value , ... transformedValues , } // queryStringの作成 const queryString = Object . entries ( params ) . reduce (( acc , [ key , val ]) => { if ( ! val ) return acc return acc } , [] as string []) . filter ( Boolean ) . join ( '&' ) // queryStringとpathを繋ぐ return appendQueryStringToPath ( ZOZOTOWN_APP_URL . search , queryString ) } 上記で生成したURLを表示させたら完成です! ユーザーの反応 URL生成ツールを利用していただいている、事業部やPMの方々から以下のようなご意見をいただきました! 複数人で同時に使用できるようになった アプリスキームだけではなくPC/SPのURLも確認できるのがありがたい プルダウン式で項目を選べるのがとても助かるし使いやすい 既存のアプリスキーム作成ツールでは、条件を入力し作成ボタンを押した後、数秒間待つ時間があるのに対し、ZOZO URL GENERATORでは作成結果が即時反映されて嬉しい 各項目のショップIDやブランドIDなどIDを逐一調べず抽出でき工数削減につながった URL生成ツールを通して様々な部署の工数を削減できてよかったと思います。 終わりに 本記事ではURL生成ツールを紹介しました。URL生成ツールの導入によってサイトバナーの管理プロセスや、LPの開発フローを改善できて良かったと思います。 株式会社ZOZOでは、アイデア次第でこんなふうに自由度の高い開発を経験できる環境が整っています! ご興味のある方はぜひ、ご応募お待ちしております! corp.zozo.com
アバター
はじめに こんにちは、WEARバックエンド部バックエンドブロックの塩足です。普段は弊社サービスであるWEARのバックエンド開発・保守を担当しています。 WEARのバックエンドでは、これまで自動テスト環境としてCircleCIを使用していましたが、運用保守の改善を目的にGitHub Actionsへ移行しました。 今回は、GitHub Actionsへ移行する際に取り組んだ以下の3点について紹介します。 効率的にテストを分割してテストを並列実行する方法 失敗したテストのみを再実行する仕組みの構築 GitHubのCheck annotationsを活用して、失敗したテスト情報を表示 また、最後に今回行ったテストカバレッジのレポーティングとGitHub Pagesでのホスティングの方法について紹介します。 目次 はじめに 目次 背景 なぜ自動テスト環境をCircleCIからGitHub Actionsに移行したのか テストカバレッジのレポーティングやGitHub Pagesでのホスティングがシンプルに実現可能 octocovの導入が容易 ワークフローをトリガーする豊富なイベント 全社的にGitHub Actionsを推奨 課題 1. CircleCIのタイミングベースでテスト分割する仕組みがGitHub Actionsにない 2. CircleCIの失敗したテストのみを再実行する機能がGitHub Actionsにない 3. GitHub Actionsで失敗したテスト詳細情報へのアクセシビリティが低い 課題解決の方法 1. r7kamura/split-tests-by-timingsアクションを使用したタイミングベースのテスト分割 Download all test results for default branchステップ Split tests by timingsステップ 2. RSpecの--only-failuresオプションで失敗したテストのみ再実行 Download previous test resultステップ Place previous test resultステップ spec/examples.txt JUnit XMLファイル カバレッジデータ Re-run rspec only failuresステップ 3. JUnit XMLファイルとreviewdogを用いて、GitHubのCheck annotationsで失敗したテスト情報を表示 テストカバレッジを活用できる環境整備の方法 octocovを使用してテストカバレッジをPull Requestにレポートする テストカバレッジの結果をGitHub Pagesでホスティングする 今後の展望 Flaky testを検出する機能がない 追加・変更した実装コードのソースコードカバレッジ まとめ 背景 前述の通り、WEARのバックエンドではこれまで自動テスト環境としてCircleCIを使用していました。 なぜ自動テスト環境をCircleCIからGitHub Actionsに移行したのか では、なぜ自動テスト環境をCircleCIからGitHub Actionsに移行したのか、その理由を説明します。 テストカバレッジのレポーティングやGitHub Pagesでのホスティングがシンプルに実現可能 octocov の導入が容易 octocov はコードメトリクスを収集するツールキットです。 octocov には前回のテストカバレッジと比較するdiff機能があります。この機能を使うにはデータストレージが必要です。 CircleCIで利用する際は、外部ストレージを利用する必要があります。一方、GitHub Actionsでは、 octocov がGitHub Actionsアーティファクトをデータストレージとしてサポートしています。また、 k1LoW/octocov-action@v1 アクションが用意されているため、簡単に導入できます。 ワークフローをトリガーする豊富なイベント これまで、CircleCIでは push をトリガーにワークフローを実行していました。しかし、 octocov を利用するには pull_request をトリガーにワークフローを実行する必要があります。 pull_request をトリガーにすると、Pull Requestを作成するまでテスト結果を確認できないため、開発生産性が落ちてしまいます。 そこで、CircleCIで push と pull_request の両方をトリガーする方法を検討しました。しかしながら、両方をトリガーするにはGitHub ActionsからCircleCIのAPIでトリガーする必要があり、全体のワークフローが複雑になってしまいます。一方、GitHub Actionsであれば柔軟なトリガーが可能なため、シンプルに解決できます。 全社的にGitHub Actionsを推奨 全社的に新規のプロジェクトに関しては、基本的にGitHub Actionsの利用を推奨しています。そのため、今後はGitHub Actionsに揃えることで、ナレッジの共有やメンテナンスが容易になると考えました。 上記2つの理由から、自動テスト環境をCircleCIからGitHub Actionsへ移行することを決定しました。 課題 CircleCIからGitHub Actionsに移行するにあたって、以下の3つの課題がありました。 CircleCIのタイミングベースでテスト分割する仕組みがGitHub Actionsにない CircleCIの失敗したテストのみを再実行する機能がGitHub Actionsにない GitHub Actionsで失敗したテスト詳細情報へのアクセシビリティが低い 1. CircleCIのタイミングベースでテスト分割する仕組みがGitHub Actionsにない CircleCIは標準で タイミングデータに基づいたテストの分割 が可能です。GitHub Actionsには同様の機能が備わっていないので、別途仕組みを考える必要があります。 2. CircleCIの失敗したテストのみを再実行する機能がGitHub Actionsにない CircleCIには標準で 失敗したテストのみを再実行 する機能を提供しています。GitHub Actionsには失敗したテストのみ再実行する機能がないため、Flaky test等でテストが失敗した際、全テストを実行する必要があり開発生産性に影響します。 3. GitHub Actionsで失敗したテスト詳細情報へのアクセシビリティが低い CircleCIはJob詳細画面のTESTSタブで失敗したテストを一覧で閲覧できます。 一方、GitHub Actionsには失敗したテストを一覧で閲覧する機能はありません。並列で実行して複数のtestジョブでテストが失敗している場合、失敗しているtestジョブの数だけログを確認する必要があります。 課題解決の方法 まずは以下のようなTestワークフロー( .github/workflows/test.yml )を用意し、これを拡張することで3つの課題を解決していきます。 name : Test on : push : concurrency : group : ${{ github.workflow }}-${{ github.ref }} cancel-in-progress : true env : TEST_JOB_PARALLEL_COUNT : 2 RAILS_ENV : test defaults : run : shell : bash jobs : test : runs-on : ubuntu-latest timeout-minutes : 20 permissions : contents : read actions : read strategy : fail-fast : false matrix : group_index : [ '0,1' , '2,3' ] steps : - name : Checkout uses : actions/checkout@v4 - name : Setup Ruby uses : ruby/setup-ruby@v1 with : bundler-cache : true - name : Setup DB run : bundle exec rails "parallel:setup[`nproc`]" - name : Run rspec in parallel run : bundle exec parallel_rspec -n $((${TEST_JOB_PARALLEL_COUNT} * `nproc`)) --only-group ${{ matrix.group_index }} - name : Upload test result if : ${{ success() || failure() }} uses : actions/upload-artifact@v4 with : name : test-result-${{ matrix.group_index }} path : | test_results/ coverage/.resultset*.json include-hidden-files : true if-no-files-found : ignore このワークフローは matrix strategy を使用して2つ( TEST_JOB_PARALLEL_COUNT )のtestジョブを並列実行します。さらに、各testジョブは parallel_rspec で2つ(= nproc から取得したCPU数)のrspecプロセスを実行します。テストは parallel_rspec によってファイルサイズをベースに4つ(= testジョブ並列数 × CPU数)のグループに分割され、各testジョブで2グループずつ実行されます。テスト結果は test-result-${{ matrix.group_index }} という名前でアーティファクトに保存します。 testジョブのワークフローのフローチャートは以下のようになります。 1. r7kamura/split-tests-by-timings アクションを使用したタイミングベースのテスト分割 課題1を解決するための、タイミングベースのテスト分割について説明します。 parallel_rspec のオプション --group-by に runtime を指定することで、タイミングベースでテスト分割できます。しかし、今回はより汎用的に利用できる r7kamura/split-tests-by-timings アクションを採用しました。 r7kamura/split-tests-by-timings アクションはJUnit XMLファイルを元にタイミングベースでテスト分割するアクションです。つまり、過去のテスト結果からJUnit XMLファイルを抽出し、 r7kamura/split-tests-by-timings アクションに渡す必要があります。 タイミングベースでテスト分割するために、testジョブを以下のように修正します。 @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - group_index: ['0,1', '2,3'] + test_job_index: [0, 1] steps: - name: Checkout uses: actions/checkout@v4 @@ -35,13 +35,30 @@ jobs: bundler-cache: true - name: Setup DB run: bundle exec rails "parallel:setup[`nproc`]" + - name: Download all test results for default branch + uses: dawidd6/action-download-artifact@v6 + with: + name: test-result-* + name_is_regexp: true + path: ${{ runner.temp }}/default-branch-test-results + branch: ${{ github.event.repository.default_branch }} + workflow_conclusion: success + if_no_artifact_found: warn + - name: Split tests by timings + uses: r7kamura/split-tests-by-timings@v0 + id: split-tests + with: + reports: ${{ runner.temp }}/default-branch-test-results/**/test_results + glob: spec/**/*_spec.rb + index: ${{ matrix.test_job_index }} + total: ${{ env.TEST_JOB_PARALLEL_COUNT }} - name: Run rspec in parallel - run: bundle exec parallel_rspec -n $((${TEST_JOB_PARALLEL_COUNT} * `nproc`)) --only-group ${{ matrix.group_index }} + run: bundle exec parallel_rspec -n `nproc` ${{ steps.split-tests.outputs.paths }} - name: Upload test result if: ${{ success() || failure() }} uses: actions/upload-artifact@v4 with: - name: test-result-${{ matrix.group_index }} + name: test-result-${{ matrix.test_job_index }} path: | test_results/ coverage/.resultset*.json testジョブにおいて以下の点線で囲った2つのステップが追加されます。 追加した2つのステップについて説明します。 Download all test results for default branch ステップ このステップではデフォルトブランチで成功している最新のテスト結果をダウンロードします。テストは複数のジョブで分散して実行されるため、その全てのテスト結果を取得する必要があります。 公式で用意されている actions/download-artifact アクションでは、ブランチやワークフロー実行のステータス等を指定してダウンロードできません。そこで、より柔軟に指定できる dawidd6/action-download-artifact アクションを使用することにしました。 dawidd6/action-download-artifact アクションの各入力パラメータについては以下の表で説明します。 キー 値 name ダウンロードするアーティファクト名のパターンを指定します。 name_is_regexp nameで正規表現を利用できるように、 true を設定します。 path ダウンロードするファイルパスを指定します。 branch ブランチでアーティファクトを検索します。今回はデフォルトブランチを指定します。 workflow_conclusion ワークフローのステータスでアーティファクトを検索します。今回は成功しているワークフローのみに絞り込むため、 success を指定します。 if_no_artifact_found アーティファクトが見つからない場合の挙動を定義します。 初回実行を考慮してアーティファクトが存在しない場合でも動作するように warn を指定しています。 Split tests by timings ステップ r7kamura/split-tests-by-timings アクションはJUnit XMLファイルを元にタイミングベースでテストを分割します。 r7kamura/split-tests-by-timings アクションの各入力パラメータについては以下の表で説明します。 キー 値 reports JUnit XMLのファイルパスを指定します。 指定したパスにある全てのXMLファイルからタイミングデータを取得します。 glob テストファイルのglobパターンを指定します。 index 分割したグループのインデックスを指定します。 total 分割するグループ数を指定します。 今回はtestジョブの数だけグループを作成します。 分割されたファイルパスのリストは ${{ steps.split-tests.outputs.paths }} でアクセスできます。 Split tests by timings ステップで分割したファイルパスのリストを Run rspec in parallel ステップで使用することで、タイミングベースのテスト分割ができます。タイミングベースのテスト分割によりtestジョブは約30秒高速化しました。 2. RSpecの --only-failures オプションで失敗したテストのみ再実行 課題2を解決するため、失敗したテストのみを再実行する方法を説明します。 CircleCIでは circleci tests run を使用することで、失敗したテストのみ再実行できましたが、GitHub Actionsにそういった機能は用意されていません。一方で、RSpecには --only-failures オプションが用意されています。 --only-failures オプションは、実行されるテストをフィルタリングして、前回実行時に失敗したテストだけが実行されるようにします。 失敗したテストのみを再実行するために、testジョブを以下のように修正します。 @@ -33,9 +33,35 @@ jobs: uses: ruby/setup-ruby@v1 with: bundler-cache: true + - name: Download previous test result + uses: actions/download-artifact@v4 + with: + pattern: test-result-${{ matrix.test_job_index }} + path: ${{ runner.temp }} + - name: Place previous test result + id: previous-test-result + env: + TEST_RESULT_DIR: ${{ runner.temp }}/test-result-${{ matrix.test_job_index }} + run: | + if [ -f ${TEST_RESULT_DIR}/spec/examples.txt ]; then + mv ${TEST_RESULT_DIR}/spec/examples.txt spec/examples.txt + echo "failed-tests-only=true" >> $GITHUB_OUTPUT + fi + suffix="_`date +%s`" + mkdir -p test_results coverage + if [ -e ${TEST_RESULT_DIR}/test_results ]; then + mv ${TEST_RESULT_DIR}/test_results/* test_results/ + find test_results -type f -name "*.xml" | sed "p;s/.xml/${suffix}.xml/" | xargs -n2 mv + bundle exec rails runner "Dir['test_results/**/*.xml'].each { |path| File.write(path, Nokogiri(File.read(path)).tap { _1.css('testcase:has(failure)').remove }.to_s) }" + fi + if [ -e ${TEST_RESULT_DIR}/coverage ]; then + mv ${TEST_RESULT_DIR}/coverage/.resultset*.json coverage/ + find coverage -type f -name "*.json" | sed "p;s/.json/${suffix}.json/" | xargs -n2 mv + fi - name: Setup DB run: bundle exec rails "parallel:setup[`nproc`]" - name: Download all test results for default branch + if: ${{ !steps.previous-test-result.outputs.failed-tests-only }} uses: dawidd6/action-download-artifact@v6 with: name: test-result-* @@ -45,6 +71,7 @@ jobs: workflow_conclusion: success if_no_artifact_found: warn - name: Split tests by timings + if: ${{ !steps.previous-test-result.outputs.failed-tests-only }} uses: r7kamura/split-tests-by-timings@v0 id: split-tests with: @@ -53,7 +80,11 @@ jobs: index: ${{ matrix.test_job_index }} total: ${{ env.TEST_JOB_PARALLEL_COUNT }} - name: Run rspec in parallel + if: ${{ !steps.previous-test-result.outputs.failed-tests-only }} run: bundle exec parallel_rspec -n `nproc` ${{ steps.split-tests.outputs.paths }} + - name: Re-run rspec only failures + if: ${{ steps.previous-test-result.outputs.failed-tests-only }} + run: bundle exec rspec --only-failures - name: Upload test result if: ${{ success() || failure() }} uses: actions/upload-artifact@v4 @@ -61,6 +92,7 @@ jobs: name: test-result-${{ matrix.test_job_index }} path: | test_results/ + spec/examples.txt coverage/.resultset*.json include-hidden-files: true if-no-files-found: ignore testジョブにおいて以下の点線で囲った3つのステップが追加されます。 追加した3つのステップについて説明します。 Download previous test result ステップ actions/download-artifact アクションで前回のテスト結果のアーティファクトをダウンロードします。 GitHub Actionsのジョブの実行はいくつか方法がありますが、通常はイベントトリガーで実行されます。また、再実行はGitHub Actionsの実行詳細ページにある以下のボタンから「Re-run all jobs」と「Re-run failed jobs」を選択して実行できます。 このジョブの実行方法の違いによりアーティファクト取得の挙動が変化します。 実行方法 アーティファクト取得の挙動 イベントトリガー アーティファクトがまだ存在しないため、取得できません。 Re-run all jobs すべてのジョブのアーティファクトがリセットされます。 つまり、挙動としては イベントトリガー と同じになります。 Re-run failed jobs 成功したジョブのアーティファクトは変更されず、そのまま保存されます。 失敗したジョブのアーティファクトは 再利用 でき、再実行後にそのジョブの新しいアーティファクトで 上書き できます。 Re-run failed jobs の場合、失敗したジョブのアーティファクトは再利用できるため、前回のテスト結果を利用できることになります。 Place previous test result ステップ Download previous test result ステップで前回のテスト結果をダウンロードできた場合、以下の3つのファイルを適切に配置する必要があります。 spec/examples.txt JUnit XMLファイル カバレッジデータ それでは1つずつ説明します。 spec/examples.txt spec/examples.txt は、RSpecの実行結果に関する情報を記録するためのファイルです。このファイルには、前回のRSpec実行時にどのテストが成功したか、失敗したかなどの情報が保存されます。 また、 --only-failures オプションを使用するには以下のように、 spec/examples.txt を spec/spec_helper.rb に設定する必要があります。 RSpec .configure do |config| config.example_status_persistence_file_path = ' spec/examples.txt ' end JUnit XMLファイル JUnit XMLファイルは以下の2箇所で利用します。 r7kamura/split-tests-by-timings アクションを使用したタイミングベースのテスト分割 後述するGitHubのCheck annotationsで失敗したテスト結果を表示 bundle exec rspec --only-failures を実行すると、JUnit XMLのファイルは前回失敗したテストだけの結果に上書きされます。タイミングベースでテスト分割する際に、全体のテスト結果が必要になるため、別の名前にリネームして残しておく必要があります。 当然、このファイルには前回失敗したテスト結果が含まれています。前回失敗したテストは再実行されるため、前回失敗したテスト結果だけ事前に削除しておく必要があります。 path = ' test_results/rspec.xml ' File .write(path, Nokogiri( File .read(path)).tap { _1.css( ' testcase:has(failure) ' ).remove }.to_s) カバレッジデータ このファイルはテストカバレッジをレポートする際に利用します。このファイルもJUnit XMLファイルと同様に bundle exec rspec --only-failures を実行すると、上書きされてしまいます。そのため、カバレッジデータも別のファイル名にリネームして残しておく必要があります。 前回のテスト結果がある場合は ${{ steps.previous-test-result.outputs.failed-tests-only }} に true が設定され、後続する処理で利用されます。 Re-run rspec only failures ステップ 前回のテスト結果がある場合のみ bundle exec rspec --only-failures を実行します。 これで Re-run failed jobs から再実行した場合に、失敗したテストのみを再実行する方法が実現できました。 3. JUnit XMLファイルとreviewdogを用いて、GitHubのCheck annotationsで失敗したテスト情報を表示 課題3を解決するために、GitHubのCheck annotationsで失敗したテスト情報を表示する方法について説明します。 reviewdog は Reviewdog Diagnostic Format(RDFormat) という独自のフォーマットを利用して任意のlinterと連携できます。 JUnit XMLのデータは、エラーが発生したテストのファイルパス、テスト名(full_description)、エラーメッセージを保持しています。 reviewdog を使用し、JUnit XMLのデータだけでエラー情報を表示すると以下のようになります。 対象ファイルの1行目にエラー情報が全て表示されるため、どのテストの情報なのか分かりにくくなってしまいます。 そこで、各テスト名の行番号を取得してエラー情報を見やすくする方法を検討しました。今回は RSpec::Core::ExampleGroup オブジェクトの metadata から行番号を取得する方法を採用しました。 以下のファイル( scripts/generate_rspec_reviewdog_json.rb )はJUnit XMLファイルからRDFormatファイルを生成するスクリプトとなります。 require ' nokogiri ' $LOAD_PATH .unshift ' spec ' require ' rails_helper ' # RSpecのExampleGroupを再帰的に辿り、全てのExampleを取得する # @param example_group [RSpec::Core::ExampleGroup] # @return [Array<RSpec::Core::Example>] def collect_all_examples (example_group) example_group.examples + example_group.children.flat_map { collect_all_examples(_1) } end # ExampleGroupからfull_descriptionとline_numberの対応関係を生成する # @param example_group [RSpec::Core::ExampleGroup] # @return [Hash{String => Integer}] def map_description_to_line_number (example_group) collect_all_examples(example_group) .each_with_object({}) { |example, obj| obj[example.metadata[ :full_description ]] = example.metadata[ :line_number ] } end # キャッシュを利用して、ファイルパスに対応するfull_descriptionとline_numberの対応関係を生成する # @param path [String] # @return [Proc] def cached_description_to_line_number cache = {} ->(path) { cache[path] ||= begin example_group = eval ( File .read(path)) # rubocop:disable Security/Eval map_description_to_line_number(example_group) end } end # JUnit XMLファイルからRDFormatのデータを生成する # @param junit_xml_file_path [String] # @return [Array<Hash>] def parse_junit_failures (junit_xml_file_path) description_mapper = cached_description_to_line_number Nokogiri( File .open(junit_xml_file_path)).css( ' testsuite testcase failure ' ).map do |failure_elem| elem = failure_elem.parent path = elem.attr( ' file ' ) description_to_line = description_mapper.call(path) { message : failure_elem.text, location : { path : path, range : { start : { line : description_to_line[elem.attr( ' name ' )] } } } } end end File .open( ENV .fetch( ' REVIEWDOG_JSON_FILE_PATH ' ), ' w ' ) do |f| Dir [ ENV .fetch( ' JUNIT_XML_FILE_PATH_PATTERN ' )].each do |junit_xml_file_path| rows = parse_junit_failures(junit_xml_file_path) f.puts(rows.map(& :to_json ).join( "\n" )) if rows.present? end end 上記スクリプトを利用して、TestワークフローにGitHubのCheck annotationsで失敗したテスト情報を表示する report-failed-tests ジョブを追加しました。 report-failed-tests : needs : test runs-on : ubuntu-latest timeout-minutes : 5 continue-on-error : true permissions : contents : read pull-requests : write if : ${{ success() || failure() }} env : REVIEWDOG_JSON_FILE_NAME : rspec_reviewdog.jsonl steps : - name : Checkout uses : actions/checkout@v4 - name : Setup Ruby uses : ruby/setup-ruby@v1 with : bundler-cache : true - name : Setup reviewdog uses : reviewdog/action-setup@v1 with : reviewdog_version : v0.20.0 - name : Download all test results uses : actions/download-artifact@v4 with : pattern : test-result-* path : ${{ runner.temp }}/test-results - name : Generate RSspec reviewdog json env : JUNIT_XML_FILE_PATH_PATTERN : ${{ runner.temp }}/test-results/**/test_results/*.xml REVIEWDOG_JSON_FILE_PATH : ${{ runner.temp }}/${{ env.REVIEWDOG_JSON_FILE_NAME }} run : bundle exec ruby scripts/generate_rspec_reviewdog_json.rb - name : Run rspec reviewdog env : REVIEWDOG_GITHUB_API_TOKEN : ${{ github.token }} REVIEWDOG_JSON_FILE_PATH : ${{ runner.temp }}/${{ env.REVIEWDOG_JSON_FILE_NAME }} run : | cat $REVIEWDOG_JSON_FILE_PATH | reviewdog -f=rdjsonl -reporter=github-check report-failed-tests ジョブでは scripts/generate_rspec_reviewdog_json.rb を実行してRDFormatのファイルを作成します。作成されたRDFormatファイルは reviewdog に渡されて、 reviewdog 内でGitHub APIの Update a check run を実行します。 report-failed-tests ジョブを実行した結果、以下のように各テストの行にエラー情報が表示されるようになりました。 テストカバレッジを活用できる環境整備の方法 最後に、テストカバレッジを活用できる環境整備の方法について説明します。 octocov を使用してテストカバレッジをPull Requestにレポートする まずアプリケーションに以下の .octocov.yml ファイルを用意します。 coverage : paths : - coverage/.resultset.json acceptable : 60% codeToTestRatio : acceptable : 1:1.2 code : - "app/**/*.rb" - "lib/**/*.rb" test : - "spec/**/*_spec.rb" diff : datastores : - artifact://${GITHUB_REPOSITORY} comment : if : is_pull_request && !is_default_branch hideFooterLink : false deletePrevious : true report : if : is_default_branch datastores : - artifact://${GITHUB_REPOSITORY} .octocov.yml ファイルは以下のような設定になっています。 テストカバレッジは60%未満の時、exist status 1で終了する コードとテストの割合が 1 : 1.2 未満の時、exist status 1で終了する デフォルトブランチとのテストカバレッジの差分を表示する コメントはデフォルトブランチ以外のPull Requestの場合に行う 次にTestワークフローに以下の report-coverage ジョブを追加します。 report-coverage : needs : test runs-on : ubuntu-latest timeout-minutes : 5 permissions : contents : read pull-requests : write if : ${{ success() || failure() }} steps : - name : Checkout uses : actions/checkout@v4 - name : Setup Ruby uses : ruby/setup-ruby@v1 with : bundler-cache : true - name : Download all test results uses : actions/download-artifact@v4 with : pattern : test-result-* path : ${{ runner.temp }}/test-results - name : Aggregate all coverage resultsets run : bundle exec rails runner "require 'simplecov'; SimpleCov.collate(Dir['${{ runner.temp }}/test-results/**/coverage/.resultset*.json'], 'rails')" - name : Report coverage by octocov uses : k1LoW/octocov-action@v1 - name : Upload coverage uses : actions/upload-artifact@v4 with : name : coverage path : coverage include-hidden-files : true report-coverage ジョブはテスト結果をダウンロードし、複数 test ジョブで生成されたカバレッジデータを SimpleCov.collate で集計します。集計したカバレッジデータを k1LoW/octocov-action@v1 に与えることでPull Requestにテストカバレッジをレポートすることが出来ます。 しかし、この方法ではテストカバレッジをレポートできないケースがあります。Testワークフローは push をトリガーに実行されます。つまり、このTestワークフローの report-coverage ジョブが実行している時点でPull Requestが存在しない場合、テストカバレッジをレポートできません。そこで、 pull_request をトリガーに実行する Report coverageワークフロー ( .github/workflows/report-coverage.yml )を用意します。 name : Report coverage on : pull_request : types : opened concurrency : group : ${{ github.workflow }}-${{ github.ref }} cancel-in-progress : true env : TEST_WORKFLOW_FILE_NAME : test.yml COVERAGE_ARTIFACT_NAME : coverage defaults : run : shell : bash jobs : report-coverage : runs-on : ubuntu-latest timeout-minutes : 5 permissions : contents : read pull-requests : write actions : read env : GH_TOKEN : ${{ github.token }} steps : - name : Checkout uses : actions/checkout@v4 - name : Get run_id of test workflow id : get-run-id run : | gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/${GITHUB_REPOSITORY}/actions/workflows/${TEST_WORKFLOW_FILE_NAME}/runs?head_sha=${{ github.event.pull_request.head.sha }}&status=completed" | \ jq '.workflow_runs | sort_by(.id)[] | select(.conclusion == "success" or .conclusion == "failure") | .id' | \ jq -sr '"test-run-id=\(last)"' >> $GITHUB_OUTPUT - name : Download coverage if : ${{ steps.get-run-id.outputs.test-run-id != 'null' }} uses : actions/download-artifact@v4 with : name : ${{ env.COVERAGE_ARTIFACT_NAME }} path : coverage run-id : ${{ steps.get-run-id.outputs.test-run-id }} github-token : ${{ github.token }} - name : Coverage Report by octocov if : ${{ hashFiles('coverage/.resultset.json') }} uses : k1LoW/octocov-action@v1 Report coverageワークフローの report-coverage ジョブはTestワークフローの report-coverage ジョブで保存したcoverageアーティファクトを利用します。ただし、Report coverageワークフローがトリガーされた時点でTestワークフローのcoverageアーティファクトが存在しない場合は処理をスキップします。 このように、 push と pull_request の両方のトリガーを利用することで、テストカバレッジをPull Requestにレポートできました。 テストカバレッジの結果をGitHub Pagesでホスティングする 続いて、テストカバレッジの結果をGitHub Pagesでホスティングする方法を説明します。 Testワークフローに以下の build-github-pages ジョブと deploy-github-pages ジョブを追加します。どちらのジョブもデフォルトブランチの場合のみ実行します。 build-github-pages : needs : report-coverage runs-on : ubuntu-latest timeout-minutes : 5 if : ${{ format('refs/heads/{ 0 }', github.event.repository.default_branch) == github.ref }} steps : - name : Download coverage uses : actions/download-artifact@v4 with : name : coverage path : coverage - name : Upload pages artifact uses : actions/upload-pages-artifact@v3 with : path : coverage deploy-github-pages : needs : build-github-pages runs-on : ubuntu-latest timeout-minutes : 5 if : ${{ format('refs/heads/{ 0 }', github.event.repository.default_branch) == github.ref }} permissions : pages : write id-token : write environment : name : github-pages url : ${{ steps.deployment.outputs.page_url }} steps : - name : Deploy to GitHub Pages id : deployment uses : actions/deploy-pages@v4 build-github-pages ジョブは actions/upload-pages-artifact@v3 アクションでcoverageをアーカイブします。そして、アーカイブしたファイルを github-pages というアーティファクト名でアップロードします。 deploy-github-pages ジョブは actions/deploy-pages@v4 アクションで github-pages アーティファクトをGitHub Pagesにデプロイします。 これでテストカバレッジを活用できる環境整備ができました。 今後の展望 以上の取り組みによって、テストカバレッジを活用できる環境を整えつつ、CircleCIからGitHub Actionsへ移行出来ました。 しかし、まだ改善余地があり、以下のような課題があります。 Flaky testを検出する機能がない CircleCIにはFlaky testを検出できる テスト インサイト機能 があります。GitHub ActionsにはFlaky testを検出できる機能がないため、別途用意する必要があります。 追加・変更した実装コードのソースコードカバレッジ テストカバレッジの結果をGitHub Pagesでホスティングすることで、デフォルトブランチのテストカバレッジをソースファイル単位で確認できるようになりました。しかし、Pull Requestのテストカバレッジを確認するにはアーティファクトからダウンロードする必要があるため、Pull Request上で確認するには仕組みを考える必要があります。 今後は、これらの課題を解決する方法を検討したいと思います。 まとめ 本記事ではRailsアプリケーションの自動テスト環境をCircleCIからGitHub Actionsへ移行した際に発生した課題とその解決方法に関して紹介しました。Railsアプリケーションの自動テスト環境をCircleCIからGitHub Actionsへ移行を検討している方がいれば、ぜひ参考にしてみてください。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは、データシステム部MLOpsブロックの薄田( @udus122 )です。 この記事ではFour Keysなどの指標を活用して、定量的な根拠に基づきチームの開発生産性を改善する考え方とふりかえり手法を紹介します。 Four Keysとはデプロイ頻度、変更のリードタイム、変更障害率、平均修復時間の4つの指標からなるソフトウェアデリバリーや開発生産性の指標です。 Four Keysなど開発生産性の指標を計測し、定期的にふりかえっているけれど、なかなか具体的な改善につながらない。 そんな悩みはないでしょうか? 実際に私たちのチームで抱えていた開発生産性の改善に関する課題と解決策を紹介します。皆さんのチームで開発生産性を改善する際のご参考になれば幸いです。 目次 はじめに 目次 開発生産性の改善に取り組んだ背景 チームの改善に取り組む上での課題 Four Keysの考え方に対する理解不足 指標に含まれるノイズ ステージング環境を含めた変更障害の集計定義 変更障害の修正以外のrevert活用 幅広い業務内容による指標の上振れ ふりかえりプロセスから「要因の把握」が漏れていた チームの改善サイクルを回すために行った工夫 Four Keysの考え方についてチーム内で認識を合わせた 指標の定義と集計対象を明確化し外れ値を除外した ブランチ名でふりかえるべき失敗と些細なミスを区別した ふりかえりの準備で外れ値を除外する運用を行った 「プルリク深掘り」という手法でふりかえりを実施し、改善に繋がりやすい工夫をした 役割分担 タイムテーブル 進行の流れ 前回のふりかえり Four Keys指標の確認と変更障害プルリクの深掘り サイクルタイム分析の確認と個別のプルリクの深掘り 改善アクションの優先順位の投票 ネクストアクションの明確化 ふりかえりから生まれた改善施策と効果 まとめ 開発生産性の改善に取り組んだ背景 MLOpsブロックがFour Keysを活用した開発生産性の改善に取り組んだ背景ときっかけを紹介します。 MLOpsブロックは ZOZOTOWN や WEAR の推薦、検索といった機械学習系のマイクロサービスを開発・運用するチームです。MLプロダクトを世に出すために必要となるモデル作成以外の全てのエンジニアリングを担当しています。API開発からインフラの設計構築、CI/CDパイプラインの構築、負荷試験、アラート対応、実験基盤の整備など業務は多岐に渡ります。 MLOpsブロックでは週に1度業務のふりかえりを実施しています。ふりかえりはKPTの形式で実施していました。KPTは「Keep、Problem、Try」の3要素を検討するふりかえりのフレームワークです。 ZOZOでは開発生産性の指標を可視化するため全社的に Findy Team+ を導入しています。Findy Team+は GitHub Pull Request(以下プルリク)のステータスの変化などから開発生産性の指標を集計・可視化してくれるツールです。MLOpsブロックでもFindy Team+を使ってFour Keysなど開発生産性の指標を確認できる状態でした。 従来はふりかえりを始める前にDevOps分析(Four Keys)を確認する流れでFindy Team+を活用していました。 しかし指標を観測してもこれを活用した具体的な開発生産性の改善は滞っていました。 そんな中、2024年度の全社目標で生産性の向上が掲げられました。これをきっかけに改めて開発生産性の指標を活用して、チームの開発生産性を改善する取り組みを始めました。 チームの改善に取り組む上での課題 MLOpsブロックの開発生産性の改善における課題は、観測した指標を具体的なチームの改善行動に活かせていないことでした。 特に開発生産性の指標が変化する要因を把握できていないことが大きな問題でした。 これまでの経験から、開発生産性の指標を活用したチーム改善のプロセスは次の流れで進めるのが理想だと考えています。 開発生産性の指標の変化を観測する 指標の変化要因となった事実を特定する 事実から横展開可能な成功や改善すべき失敗を発見する 成功や失敗から具体的な改善点を決め、実行する 改善行動の結果、指標が変化したかどうかを確認する(ステップ1に戻る) 開発生産性の指標を活用してチームを改善するには、ステップ2で変化要因となった事実を把握することが重要です。ステップ3での成功や失敗原因の分析はステップ2で変化の要因となった事実を起点に考えます。以前のMLOpsブロックで行っていたように指標を見て良かった点や悪かった点を考えるだけでは不十分です。 実際MLOpsブロックでもこの「指標が変化した要因の把握」がうまくいっていませんでした。原因は大きく3つありました。 Four Keysの考え方に対する理解不足 指標に含まれるノイズ ふりかえりプロセスでの「要因の把握」の漏れ 次にそれぞれの課題について詳細を説明します。 Four Keysの考え方に対する理解不足 Four Keysの可視化はFindy Team+を利用することで実現できていました。しかしその背後にある考え方に対する理解度は浅く、指標の背後にある原因を考えたり、指標の変化から改善策を検討したりする材料が少ない状態でした。 Four Keysは単独でみるとソフトウェアデリバリーのパフォーマンスを示す指標です。チームの生産性を示す指標ではありません。Four Keysはチームのデリバリーパフォーマンスを結果としてのみ表します。そのため変化の要因やその他の影響については、Four Keysの指標のみでは判断できません。 変化の要因やその他の影響を無視すると、例えば目先のデリバリーを優先して長期的なコードの保守性を疎かにしてしまうリスクがあります。デプロイ頻度・変更リードタイムの改善を重視し過ぎて他のことが疎かになってしまうと本末転倒です。 Four Keysを提唱した DORA の研究では、背後にあるチームのCapability 1 とセットで分析し、Four Keysが生産性などの組織全体のOutcome 2 と関連があることを示しています。つまり、Four Keysを開発生産性の指標として扱う場合は単独で見るのではなく、背後に存在するCapabilityとセットで見る必要があります。 以下が最新版(2024年9月23日時点)のDORA Core Modelです( DORA | Research より引用)。 こちらの図からチームのCapability、Performance、Outcomeの関係が読み取れます。チームのCapabilityがFour Keysなどのパフォーマンス指標を説明し、パフォーマンス指標から組織のOutcomeを予測できることを示しています。大元の出発点としてチームのCapabilityがあり、Four Keysを変化させる要因であることが読み取れます。 このことから改善すべき対象はチームのCapabilityであり、Four KeysはCapabilityの改善結果を確認するための指標であるとわかります。 以前はこの考え方が念頭になかったため、指標を見ても何を改善すればよいか分からず具体的な改善施策につなげることができていませんでした。 指標に含まれるノイズ 指標に含まれるノイズが原因で、指標の変化からチームのCapabilityの改善につながる事実を抽出することが困難という問題もありました。 ここで言うノイズ 3 とは、チームのCapability以外に由来する指標の変化です。指標の集計定義や集計の仕組みの不完全さに由来するリードタイムの外れ値や例外的な変更障害などが該当します。 指標の変化からチームのCapabilityを改善するには、指標の変化がチームのCapabilityに紐づいていることが大切です。しかしこのようなノイズが指標に含まれることでチームのCapabilityに関わる事実が見えにくくなっていました。 MLOpsブロックの指標におけるノイズの原因は次の3つでした。 ステージング環境を含めた変更障害の集計定義 変更障害の切り戻し以外のRevert活用 幅広い業務内容による指標の上振れ 順番に説明します。 ステージング環境を含めた変更障害の集計定義 変更障害率の一般的な定義は「デプロイが原因で本番環境で障害が発生する割合(%)」です。cf. エリート DevOps チームであることを Four Keys プロジェクトで確認する Findy Team+の定義では不具合を含む変更が本番環境にデプロイされた時、変更障害としてカウントします。逆に言えば不具合を含む変更であっても本番環境へデプロイされていなければ変更障害としてカウントしません。 一方で私たちのチームでは本番環境の前段のステージング環境への反映においても、不具合が発生した場合は変更障害としてカウントしています。我々の変更障害の定義は「本番/ステージング環境において変更をデプロイにより意図しない不具合が発生すること」です。 なぜ通常よりも厳しい定義を採用しているのか説明します。 MLOpsブロックでは次のブランチ戦略を採用しています。 メインブランチ( main )の内容がステージング環境に反映される リリースブランチ( release )の内容が本番環境に反映される 開発作業は、メインブランチからフィーチャーブランチ(ブランチ名は任意)を作成する メインブランチに変更が加わる度に、その内容をリリースブランチに反映するリリースプルリクが自動で作成・更新される リリースする際は、メインブランチから発行されるリリースプルリクをマージする 本番環境に対する変更は常にリリースプルリクを経由して反映されます。リリースプルリクのブランチ名も常に固定です。しかしFindy Team+はブランチ名から変更障害を判定する仕組みになっているため、本番環境に対する変更が通常のリリースなのか変更障害を修復するものなのか区別できません。そのため本番環境における変更障害を集計対象に含めることができませんでした。 またリリースプルリクはメインブランチに変更が反映されると自動で作成されます。リリースプルリクにはこれからステージング環境で検証する変更も含まれます。そのためタイミングによっては検証中の変更を他の人が誤って本番リリースしてしまう危険もあります。このように、ブランチの運用ルール上ステージング環境に反映されている内容は常に本番環境にも反映される可能性がありました。そのためチームの理想はステージング環境にも変更障害がないことでした。 MLOpsブロックの業務にはCI/CD整備やインフラ構築などが含まれます。問題があると本番環境への適用前にリリースが失敗する、または問題があってもユーザー影響のある障害にはつながらない作業も少なくありません。ステージング環境での変更障害も集計に含めることで、これらの作業で発生した問題も指標に反映されてふりかえりが可能になるメリットもありました。 まとめると次の3つの理由から集計の定義にステージング環境を含める判断をしました。 Findy Team+の集計の仕組みとリリースプルリクの運用ルールの兼ね合いに問題があったため ステージング環境の変更はそのまま本番環境への影響につながるリスクがあるため ステージング環境における問題をふりかえりの俎上にのせるため 一方でこの定義変更により、本番環境に影響がなかった些細なミスも集計対象に含まれ、ノイズとなっていました。ステージング環境で発見できた小さな失敗(タイプミスなど)まで含めてふりかえりで会話し対策を検討しているとキリがありません。ふりかえりの時間は有限です。 変更障害の修正以外のrevert活用 Findy Team+では変更障害の発生を判定する際に直接的な判定は難しいため、変更障害の修正プルリクの有無で判定します。MLOpsブロックでは名前が"hotfix"または"revert"から始まるブランチを変更障害の修正プルリクのブランチ名としています。これに一致するブランチがメインブランチにマージされたことを持って変更障害の発生を集計していました。 2種類のブランチ名は、次の2つの方法で作られた修正プルリクを変更障害として集計するために使い分けています。 "hotfix": 手動で発行された変更障害の修正プルリクを判定する "revert": GitHubのPull Request revert機能 を利用した変更障害の修正プルリクを判定する Findy Team+での集計でノイズとなったのは"revert"のプルリクでした。 GitHubのPull Request revert機能で作られる"revert"プルリクの変更内容は大きく分けて2種類ありました。 変更障害の修復 一時的な設定変更などの切り戻し 前者は変更障害ですが、後者は変更障害として含めたくありません。一時的な設定変更などを切り戻す際にrevert機能を使わないことでこの問題は回避可能です。しかし、GitHubのPull Request revert機能は便利です。GitHubのUIからワンクリックで修正できる変更を手作業で行うのは無駄が多くミスのリスクも高まります。そのため他の方法で課題を解決する必要がありました。 幅広い業務内容による指標の上振れ MLOpsブロックの業務は多岐に渡るためプルリクの種類も様々です。中には簡単な設定変更や権限付与などリードタイムが極端に短く済むプルリクも多くあります。 それらはデプロイ頻度やリードタイムの指標を底上げし、結果として全体の開発生産性スコアも業界のベンチマークと比較して上振れていました。 見た目上のスコアが良くても、チームに課題がないのかというとそうではありません。これまで指標のスコアが見かけ上高くなっていたことにより、課題のあるプルリクを見逃してしまうケースがありました。 ふりかえりプロセスから「要因の把握」が漏れていた 従来のふりかえりプロセスには、指標の変化のみを見て要因となった事実を確認するステップがないという問題がありました。 従来のふりかえりの流れでは最初にFindy Team+のDevOps分析(Four Keys)を確認し、すぐにKeepとProblemの検討を始めていました。 この流れでは、指標を見てもなぜそのように変化したのか曖昧なまま改善点を出すことになります。結果として、チームの改善までつなげづらい状況となっていました。 下の図は従来のプロセスで進めたある日のふりかえりのKPTです。各人のKPTがバラバラに配置されており、具体的な問題ではなく個人のアイデアがベースとなってふりかえりが進んでいることが見て取れます。 このように指標の変化の要因が曖昧になることで起こる問題は次の通りです。 ふりかえりの主語がチームではなく個人に寄ってしまい、チームの改善に繋がるアイデアが出づらい 他のメンバーが課題の具体的な内容や大きさを把握しづらく、改善の優先度を決めにくい 問題の重要性に関わらず直近起きた問題の方が議論に上がりやすい チームの改善サイクルを回すために行った工夫 本章ではチームの開発生産性の改善に取り組む上で存在していた課題に対して、どのような解決策を取ったのか紹介します。 MLOpsブロックで開発生産性の指標を活用したチームの改善がうまくいかなかったのは、次の3つの原因から指標が変化した要因を把握できていなかったためでした。 Four Keysの考え方に対する理解不足 指標に含まれるノイズ ふりかえりプロセスから「要因の把握」が漏れていた これらの課題を解決するために、次の3つの対策を実施しました。 Four Keysの考え方についてチーム内で認識を合わせた 指標の定義と集計対象を明確化し外れ値を除外した 「プルリク深掘り」という手法でふりかえりを実施し、改善に繋がりやすい工夫をした それぞれ具体的にどのようなことを行ったのか説明します。 Four Keysの考え方についてチーム内で認識を合わせた 指標を見るだけで終わってしまっていた要因の1つは、チームのCapabilityとセットでFour Keys指標を活用できていなかったことでした。Four KeysをチームのCapabilityに絡めて評価する考え方がチーム内に浸透していなかったため、指標を見てもどの部分を改善すべきか手がかりがなく具体的な改善施策につなげられていませんでした。 対策としてFour Keysの指標と背後にあるチームのCapabilityの関係についてチーム内で認識合わせを行いました。またFour Keysのふりかえりの際に単に指標のみを評価するのではなく、チームのCapabilityの課題について合わせて議論しました。 この考えのきっかけやチームで会話をする材料として、以下の資料がとても参考になりました。 speakerdeck.com Four Keysの考え方を共有したことで、次の共通認識を得ることができました。 全体の指標の数値が基準より高くても問題が隠れている場合がある 指標のスコアを変化とその要因と合わせて把握し、それらを起点にチームのCapabilityの問題を検討することが大切 指標の定義と集計対象を明確化し外れ値を除外した 続いて、指標に含まれるノイズの問題をどのように解決したか説明します。 変更障害の数値がばらつく原因は3つありました。 ステージング環境を含めた変更障害の集計定義 変更障害の切り戻し以外のrevert活用 幅広い業務内容により、指標が上振れする Four Keys指標の考え方を共有し前回からの変化に着目すること、後述する「プルリク深掘り」の手法で変化要因を深掘ることで指標の上振れにより課題を見逃す問題は解決できました。 残る2つの課題をどのように解決したか紹介します。 ブランチ名でふりかえるべき失敗と些細なミスを区別した 変更障害の定義にステージング環境を含めることによる弊害は、本番環境に影響がなかった些細なミスまで集計対象になってしまい、ふりかえりの時間を無駄にしてしまうことでした。 この問題を解決するために、ブランチの命名ルールを分けることにしました。Findy Team+はブランチ名を元に集計対象をフィルタリングできます。MLOpsブロックではふりかえる必要がない些細なミスの修正には"fix"で始まるブランチ名を、変更障害の修正としてふりかえるべきプルリクには"hotfix"で始まるブランチ名を使用しました。 些細なミスの基準については全てのプルリクを分類できるほど明確化できておらず迷うケースは多少存在します。迷った場合に"fix"を付与すると問題を見逃すリスクがあります。そのため迷う場合や繰り返し同じ問題が発生する場合は積極的に"hotfix"のブランチ名を付与し議論の俎上に載せることを推奨しています。 ふりかえりの準備で外れ値を除外する運用を行った 前述の通り、GitHubのPull Request revert機能を利用することで本来は変更障害ではない変更が変更障害として集計され、ふりかえりのノイズとなってしまう問題がありました。 設定の切り戻しなど変更障害の修正ではない例外的なrevertプルリクには特定のラベルを付与して集計対象から除外することにしました。これにはFindy Team+のラベルフィルター機能を使っています。 一方で上記のようなブランチやラベルの運用は人に依存するためこれだけでは課題が残ります。 ブランチやラベルの運用は明確な基準を設けることが難しく個々人の判断では多少のブレが出ます。ブレを最小限に抑えるためふりかえりの前日にふりかえりのファシリテーションの担当者が集計されたプルリクを簡単に確認し、除外すべきプルリクがあるかどうかをチェックしています。 手間にはなりますが5分程度実施することで個々人の判断によるブレを防止し、ふりかえりの質を向上させています。 「プルリク深掘り」という手法でふりかえりを実施し、改善に繋がりやすい工夫をした 続いてふりかえりの具体的な進め方についてご説明します。 従来のふりかえりの問題点は、指標の変化を見てその要因となった事実を確認するステップがないことでした。これにより指標の観測から具体的なチームの改善に繋げづらくなっていました。 この課題を改善するために、指標の観測からチームの改善に繋げやすい「プルリク深掘り」という手法を考えました。 具体的には指標の変化を見てその原因となったプルリクについて深掘りをするふりかえり手法です。 プルリク深掘りの進め方について説明します。プルリク深掘りは、次のような役割分担とタイムテーブルで進めます。ツールは Miro を使っており、Miroの画面を共有しながら進めています。私たちのチームでは隔週でプルリク深掘りを実施しています。 役割分担 ファシリテーター: 1名 全体の進行を担当する 書記: 1名 ふりかえりの中で出た気づき・アイデアなどをMiro上の付箋にメモとして残す アイデア出し: 残りの全員 指標の変化やプルリクを見て、課題や改善点のアイデア出しをする タイムテーブル 時間 タイトル 概要 5分 前回のふりかえり 前回出たネクストアクションを見て、改善できているか確認する 15分 DevOps分析の確認と変更障害プルリクの深掘り ・指標の変化を確認する ・障害対応のプルリクを1つ1つ確認し、気付きをMiroにメモする ・プルリクの担当者にプルリクの概要や背景を説明してもらう ・口頭で出たものは書記役がメモし、残りのメンバーも気づいたことは積極的にメモする 15分 サイクルタイム分析の確認と個別のプルリクの深掘り ・サイクルタイムが長いプルリクを全員で見ながら、気付きや改善点をメモしていく ・プルリクの担当者にプルリクの概要や背景を説明してもらう ・口頭で出たものは書記役がメモし、残りのメンバーも気づいたことは積極的にメモする 5分 優先順位の投票 Miroにメモしたアイデアに対して投票を行い、改善アクションを検討する優先順位をつける 10分 アクションを明確化する 投票数の多いものから順に具体的なネクストアクションに落とし込み、担当者を決める 進行の流れ 前回のふりかえり プルリク深掘りは前回のネクストアクションのふりかえりから始まります。前回のネクストアクションが適切に実行されているか確認し、実行漏れがあればリマインドします。 Four Keys指標の確認と変更障害プルリクの深掘り 次にFindy Team+の「DevOps分析」を参照し、チームのFour Keys指標を確認します。 1つずつ指標を確認して改善、悪化といった変化を確認します。変更障害率のタブを確認し変更障害を修正したプルリクを一覧し、1つずつ深掘ります。 次の流れでプルリクを深掘ります。 プルリクを開き、プルリクの作成者に変更の概要を説明してもらう 他のメンバーは気になる点があれば随時質問し、課題点や改善点があれば発言するかMiroに書く 書記はメンバーの発言を都度メモする メモを整理する際のコツは、トピックごとに樹形図形式でポイントを羅列することです。改善の優先順位を決める際に、分かりやすくなります。 サイクルタイム分析の確認と個別のプルリクの深掘り サイクルタイム分析はプルリクのリードタイムを細分化する機能です。 サイクルタイム分析についてもDevOps分析と同様にまず全体の指標を見ます。次にリードタイムが長いなど他と比較して目立ったプルリクを個別で深掘ります。 以上でプルリクの深掘りは終わりです。 改善アクションの優先順位の投票 次に深掘り中に出てきたアイデアについて、 Miroの投票機能 を用いてチーム全員で投票します。 トピックや関連する発言などが書かれた全ての付箋を対象にして、1人3票でネクストアクションとしての優先度が高いと思う付箋に投票します。 得票数が高い順に詳細な議論をして、後述するネクストアクションの明確化の材料にします。 投票が終わった後で付箋をまとめて得票数トピックごとの合計で優先順位を決めます。 ネクストアクションの明確化 最後に、得票数の多い付箋から順に詳細について議論します。 この議論は課題を改善するために実施するネクストアクションを明確化することを目的としています。この時の議論のやり方は課題によって様々です。 大事なポイントは、時間内にネクストアクションの明確化と担当者の決定までをやり切ることです。ネクストアクションは、「あえて何もしない」や「改めて時間を取って話す」といったものでも問題ありません。 課題が明確な場合はすぐにネクストアクションのブレストへと移りますし、まだ課題が抽象的な場合は、改めて何が問題なのか明確にするところから始めるケースもあります。 後からふりかえりやすいようにネクストアクションの付箋の色を変えて完了です。 ふりかえりから生まれた改善施策と効果 このふりかえり手法の導入によって、滞り気味だった開発生産性の改善サイクルが再び回り始めました。 ふりかえりから生まれた開発生産性の改善施策の例は次の通りです。 他チームが関わるプルリクのレビューやマージのルールの明確化 共通のプルリクテンプレートを用意 ブランチ名からのカテゴリラベルを自動付与する仕組みを作成 これらの改善成果として、リードタイムやアウトプット数に関する主要な指標を改善できました。オープンからマージまでの時間を約半分に短縮しながら、プルリク作成数を増やせていることが分かります。 一方で変更障害率は維持できています。これはふりかえりの内容が指標の改善ではなく、Capabilityの改善にチームの意識が向いた結果です。 実際ふりかえりから生まれた改善施策はプルリクに関わるものだけではありません。トイルの削減やコミュニケーション改善、ヒヤリハット予防など幅広い施策につながっています。実際の例は次の通りです。 社内問合せの対応ルールを明確化 業務を効率化するツールの作成 バッチ処理の失敗アラートにチームメンションを付与 他チームとのコミュニケーションの場作り 今後は現状維持だった変更障害率を重点的に改善予定です。またもう1つのパフォーマンス指標である信頼性(SLOs)もふりかえりに組み込むことで多角的な視点から改善できるプロセスも検討しています。 まとめ 本記事ではチームの開発生産性を高めるために行ったふりかえり手法とその考え方について紹介しました。 ポイントはただ指標の変化を見るのではなく、その変化要因となった事実を把握しチームのCapabilityと紐づけて改善を考えることです。そのコツはノイズとなる外れ値を取り除き、ふりかえりの中でプルリクを深堀ることでした。 Four Keys指標を使ったチームの開発生産性を向上させようと考えている方は、是非参考にしてみてください。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co Capability:コードの保守性や自動テストなど技術的なものから仕事のプロセスや組織文化に関するものまで、チームの能力や機能のこと ↩ Outcome:事業の収益性や生産性、仕事の満足度などの組織全体にとって望ましい成果のこと ↩ 一般的には指標のバラツキ(分散)を指しますが、ここでは指標に含まれるバイアス(偏り)も含めてノイズと呼んでいます ↩
アバター
こんにちは、DevRelブロックの ikkou です。2024年9月11日から13日の3日間にわたり「 DroidKaigi 2024 」が開催されました。ZOZOはゴールドスポンサーとして協賛し、12日と13日の2日間にわたりスポンサーブースを出展しました。 technote.zozo.com 本記事では「Androidエンジニアの視点」でZOZOから登壇したセッションと気になったセッションの紹介、そして「DevRelの視点」で協賛ブースの様子と各社のブースコーデのまとめをお伝えします。 登壇内容の紹介 Jetpack Compose Modifier徹底解説 2024年最新版!Android開発で役立つ生成AI徹底比較 パネルトーク 〜Androidエンジニアのキャリアとスキルアップ〜 Androidエンジニアが気になったセッションの紹介 仕組みから理解する!Composeプレビューを様々なバリエーションでスクリーンショットテストしよう WebADBを使用したAndroid専用端末化への自動キッティング手法 Android StudioのGeminiでコーディングの生産性を高める 魅力的な3つのポイント Geminiの誕生背景 セキュリティ機能:.aiexcludeファイル デモプロジェクト:Compose Pokedexer 特に注目したGeminiの機能 今後の機能追加予定 まとめ Jetpack Compose Modifier徹底解説 Debugging: All You Need to Know KSPの導入・移行を前向きに検討しよう! 1. KSPとkaptの詳細な比較 2. 混在した時のビルド時間の比較 3. K2との関係性 ZOZOブースの紹介 DroidKaigi 2024協賛企業のブースコーデまとめ おわりに 登壇内容の紹介 今年のDroidKaigiではセッションに2名が採択され、パネルトークに1名が登壇しました。会場で発表されたセッションとパネルトークについて紹介します。 DroidKaigi 2024で登壇したZOZOスタッフ Jetpack Compose Modifier徹底解説 昨年に続きJetpack Composeをテーマとした内容を話す直前のゐろは( @wiroha ) DevRelブロックのゐろは( @wiroha )は昨年の『 よく見るあのUIをJetpack Composeで実装する方法〇選 』に続き、今年もJetpack Composeをテーマとした内容で登壇しました。今回はピックアップした全47種のModifierをコードとアニメーションを用いてわかりやすく説明しました。 講演のアーカイブは公開され、発表資料はアニメーションを確認できるようGoogle スライドで公開しています。当日見逃した方はもちろん、会場で目にした方も改めて見ると発見があるかもしれません。 www.youtube.com docs.google.com ゐろはからのコメント 昨年に続き、今年もJetpack Composeのお話をさせていただきました。Ask the Speakerなどで質問・感想をいただいて交流できるのがとても楽しかったです! みんなでAndroidのコミュニティを盛り上げていきたいです! 2024年最新版!Android開発で役立つ生成AI徹底比較 生成AIをテーマとして共同登壇したゐろは( @wiroha )にしみー( @nishimy432 ) ZOZOTOWN開発1部 Android1ブロックのにしみー( @nishimy432 )とゐろはは生成AIをテーマとして共同登壇しました。今回はChatGPT、GitHub Copilot、そしてGeminiの3つについて、Android開発ならではのシナリオを交えながら比較して紹介しました。 話題のテーマということもあってか登壇後のAsk the Speakerも盛り上がっていました。こちらも講演のアーカイブと発表資料をぜひご覧ください。 www.youtube.com docs.google.com にしみーからのコメント 今年はAndroid開発における生成AIツールの実践的な使い方についてお話しさせていただきました。発表を通じて、開発のヒントや新たな視点を提供できていれば嬉しいです! ゐろはからのコメント Android開発を前提にしつつも他の技術分野にも活用できる内容だったため、幅広い方々が聞いてくださっていました! 満席で入れなかった人もいたそうで、スライドや動画を見ていただけると嬉しいです。 パネルトーク 〜Androidエンジニアのキャリアとスキルアップ〜 中央がZOZOでAndroidエンジニアのテックリードを務めるいわたん( @iwata_n ) 会場とライブ配信のハイブリッド形式で開催されたパネルトークにAndroidエンジニアのテックリードを務めるいわたん( @iwata_n )が登壇しました。「Androidエンジニアのキャリアとスキルアップ」をテーマとして、3名のパネラーが様々な質問に答える形で実施されました。 Jellyfishで開催中のパネルトークの様子です! Androidアプリ開発者としてのキャリアやスキルアップの話題について色々質問していきます! #DroidKaigi pic.twitter.com/6VGjTU6gai — DroidKaigi (@DroidKaigi) 2024年9月12日 x.com いわたんからのコメント パネルトーク楽しかったです! 2017年から運営に関わってますが、実はDroidKaigi初めての登壇でした。誰かのためになるような話が出来ていたら幸いです。 Androidエンジニアが気になったセッションの紹介 ZOZOのAndroidエンジニアが気になったセッションをいくつか紹介します。 仕組みから理解する!Composeプレビューを様々なバリエーションでスクリーンショットテストしよう ZOZOTOWN開発1部 Android2ブロックの高橋です。Sumio Toyama (sumio_tym)さんの『 仕組みから理解する!Composeプレビューを様々なバリエーションでスクリーンショットテストしよう 』を紹介します。 このセッションはJetpack ComposeのPreview関数を活用したVRT(Visual Regression Test)の概要と導入方法を紹介するという内容でした。VRTを導入するためのステップや、それぞれのステップで使用するツールの使い方が詳細に説明されており、VRTを基礎から学べるセッションでした。また、このセッションでは様々なバリエーションでのVRT/スクリーンショットテストを実施するための実践的な手法も紹介されており、見落としがちなパターンのリグレッション検知にも大変役立ちそうな内容でした。 では、セッションの中から個人的に重要だと感じた点をいくつか紹介します。 まず、セッション中における「スクリーンショットテスト」と「VRT」という用語の定義についてです。どちらもスクリーンショットを使用したUIのテストですが、スクリーンショットテストはスクリーンショットを撮影して保存するまでを自動化します。一方、VRTでは画像の撮影と保存に加え、過去に撮影したスクリーンショットとの比較までを自動化します。どちらのテストについても最終的なスクリーンショットおよび差分の確認は人間が行います。 次に、Preview関数を収集する方法についてです。Preview関数を使用したスクリーンショットを撮影するには、プロジェクト内からPreview関数を収集する必要があります。その際に利用できるのが Showkase です。ShowkaseはAirbnb社が公開しているオープンソースのライブラリで、プロジェクト内にあるJetpack ComposeのUI要素を整理する機能を提供しています。Showkaseではプロジェクト内のPreview関数を収集するAPIが提供されているため、VRT/スクリーンショットテストにも活用できます。しかし、 @Preview アノテーションに渡すことができる設定値は部分的にしか取得できません。そのため、Preview関数で設定された値を使用して様々なバリエーションのスクリーンショットを撮影するには代替となるツールを選択する必要があるようでした。 最後に、スクリーンショットの撮影方法についてです。スクリーンショットの撮影には、 ComposeTestRule と Robolectric 、 Roborazzi を使用します。ComposeTestRuleはJUnitのTestRuleで、Composableのテスト環境を構築するために使用します。RobolectricはAndroid端末やエミュレータを使用することなく、Androidフレームワークに依存したコードをテストするためのフレームワークです。Robolectricを使用することで開発用PCやCI環境のJVM上でテストを実行できるため、インストゥルメンテーションテストより高速にテストの結果を得ることができます。RoborazziはRobolectricを使用してVRT/スクリーンショットテストをするためのライブラリです。これらのツールと前述のShowkaseを組み合わせることで、様々なバリエーションスクリーンショットを撮影できます。セッションではツールの具体的な導入方法がコードと合わせて解説されているため容易に導入できそうでした。 Android端末にはフォントサイズやダークモードなどの様々な設定があり、普段の開発の中ですべてのバリエーションの動作を確認することは難しいです。このセッションを参考にVRTやスクリーンショットテストを導入すれば多くのバリエーションのUIを効率的に確認できるため、アプリの品質向上・維持に役立ちそうだと感じました。 WebADBを使用したAndroid専用端末化への自動キッティング手法 ZOZOTOWN開発1部 Android2ブロックの高橋です。Hisamoto Kunimineさんの『 WebADBを使用したAndroid専用端末化への自動キッティング手法 』を紹介します。 このセッションはADBをブラウザ上から使用できるようにし、非エンジニアでも簡単に利用できるキッティングやデバッグ用のツールを作成する方法を紹介するという内容でした。ADBはAndroidアプリの開発やデバッグにおいて非常に便利なツールです。しかし、環境構築やコマンドでの操作が必要であり、非エンジニアが使用するにはハードルが高いものでした。このセッションで紹介されている方法を用いれば、Androidエンジニア以外でもWebアプリを介してADBを簡単に使用できます。 このセッションでは「任意のAPKをインストールし、端末のフォントサイズを2段階大きくする」という操作を例として、adbコマンドを実行するWebアプリの作成方法を説明していました。 まずは、実行する必要があるadbコマンドを整理します。端末のフォントサイズを変更するには、「APKをインストールする」「フォントサイズの設定画面を開く」「フォントサイズを大きくするボタンを2回タップする」という操作が必要になります。これを実現するには下記のコマンドを使用します。 adb install [APKファイル] APKをインストールする adb shell am start -a android.settings.ACCESSIBILITY_SETTINGS ユーザー補助の設定画面を開く adb shell input touchscreen tap [タップするX座標] [タップするY座標] 画面をタップする これらのコマンドを組み合わせることで端末を目的の状態にできます。しかし、タップ操作のコマンドは瞬時に実行されるため画面遷移を伴うタップ操作をすると、画面遷移の完了前に後続のタップ操作が実行されてしまいます。そのため、 sleep などのコマンドを活用して操作のタイミングを制御する必要がある点に注意が必要のようでした。また、セッションではadbコマンドでの座標指定ではない方法で端末をタップ操作する方法も紹介されていました。 次に、adbコマンドを実行できるWebアプリを作成します。ブラウザからadbコマンドを実行するには、 Tango というADBクライアントが利用できます。Tangoを使用して前述に作成したadbコマンドを実行することで、Webアプリを介して端末を目的の状態にできます。 セッションではadbコマンドのスクリプトを作成する際の注意点やTangoの使用方法がコードや動画を交えて紹介されており、実際にツールを作成するイメージが湧く内容となっていました。 今後デバッグツールを作成する際にはこのセッションの内容を参考にしたいと思います。 Android StudioのGeminiでコーディングの生産性を高める ZOZOTOWN開発1部 Android1ブロックの高田です。私が聴講したAdarsh FernandoさんとChris Sincoさんによるセッション『 Android StudioのGeminiでコーディングの生産性を高める 』についてご紹介します。 まず最初に、以下ではGoogle AIであるGeminiを「Gemini」、Geminiとの質問履歴や文脈を「コンテキスト」、Geminiへの質問内容や質問の行動自体を「プロンプト」と記載します。 セッション全体を通して、Android StudioのGeminiを活用し、Android開発のライフサイクル全体で生産性を向上させる手法が、ライブデモを交えて発表されました。 魅力的な3つのポイント 個人的に特に魅力的だったポイントは以下の3つです。 Android Studio内でシームレスに利用可能:GeminiはAndroid Studioという既存のIDE上で簡単にセットアップでき、違和感なく使用できます 既存のプロジェクトに追加する形で使用:Geminiは既存のプロジェクトに上乗せしてサポートする形で使えます。これにより、開発フローを中断せずに導入が可能です 無料で使用可能:Geminiの基本的な機能は無料で利用できる点も大きなメリットです Geminiの誕生背景 セッションの冒頭では、Geminiが開発された背景として以下の3つのテーマが掲げられていました。 開発者がユーザー体験の向上につながる創造的な側面に集中できるようにする 高品質なコードをサポートし、より健全なエコシステムを構築する 最新技術を活用しやすくする セキュリティ機能:.aiexcludeファイル Gemini使用時のセキュリティ対策として、 .aiexclude ファイルが紹介されました。AIツールを使用する際にはプライバシーやセキュリティ面が気になるところですが、このファイルを使用することで、Geminiに読み取らせたくないファイルやディレクトリを制御できます。設定は .gitignore ファイルと同様の文法で記述できるため、機密情報を柔軟に保護できる点が非常に重要です。 デモプロジェクト:Compose Pokedexer セッションのデモ部分では、 PokeAPIを使用した初代ポケモン図鑑のプロジェクト をベースに、Geminiを活用してお気に入りフィルタリング機能を追加する手順がライブコーディング形式で紹介されました。 特に注目したGeminiの機能 Custom Code Transformation コードの特定部分を選択し、そのままGeminiに分析させることができます。プロンプトを与えることで、さらに精度の高い変更を提案してもらえます 変数の一括リネーム機能 コード全体の文脈を考慮し、適切な変数名を提案してくれます。また、どの変数に適用するか選択も可能です Unitテストのシナリオ自動生成 Unitテストのシナリオを自動生成してくれる機能もあります Crashlyticsサポート CrashlyticsとVitalsのデータに基づいて、Geminiがバグやクラッシュの原因と修正方法を提案します 今後の機能追加予定 今後、Multiple Chat SessionsやPrompt Libraryといった機能も開発予定と紹介されました。これにより、複数のコンテキストを持つプロンプトを同時に管理したり、プロンプトをセットで保存したりできるようになる予定です。 まとめ セッション内でも指摘がありましたが、Geminiはまだ開発途中であり、改善の余地があります。例えば、コールバック引数の渡し漏れが発生することや、部分的に提案されたコードを手動でコピー&ペーストする必要がある場合もあります。しかし、今後のアップデートでこれらの問題も解消されていくと期待されています。 Android開発では、複雑なUIやライフサイクル管理など、多くの課題に直面します。しかし、Geminiの活用によって、それらの課題が解決される未来を期待できます。セッションを通じて、私自身も今後Geminiを積極的に活用し、開発効率をさらに向上させていきたいと感じました。 Jetpack Compose Modifier徹底解説 ZOZOTOWN開発2部 Androidブロックの勝木です。wirohaさんの『 Jetpack Compose Modifier徹底解説 』を紹介します。 私は、約2年前にプログラミングのことが一切わからない状態で全く異なる業種からAndroidエンジニアの道を進み始めました。幸いなことに毎日教えていただきながら現在ではJetpack Composeも少しずつ学んでいます。そんな中、今回wirohaさんのセッションは私にとって非常に魅力的な内容だったため紹介したいと思います。 このセッションでは、基本的なCompose Modifierから普段使わないようなCompose Modifierまで約50種類のCompose ModifierをZOZOのプロダクトであるZOZOTOWN、WEAR、FAANSを交えて紹介するという内容でした。 まず、プロダクト内ではどのようなCompose Modifierが多く使われているかの集計がされていました。多く使用されているCompose Modifierの中でカテゴリごとにピックアップし、ひとつずつどういった挙動なのか画像やアニメーションを用いて視覚的にも非常にわかりやすい内容でした。また、カテゴリはaction、alignment、border、drawing、padding、pointer、semantics、size、testに分けられて説明されていたため「このCompose Modifierはこういう時に使えそうかも」とイメージが湧きやすかったです。 中には使用回数が少ないけれど「個人的に好きで」や「面白かったので紹介した」というマイナーなCompose Modifierの紹介もあり、聴講していてどんどん興味が湧いてくるような内容でした。一部抜粋するとtransformableやbasicMarqueeなどが紹介されており、transformableはユーザのジェスチャー(ピンチイン/アウト、回転など)に応じてComposableのサイズや回転を変更できるようになるもので「こんなModifierがあるんだ!」、と見ていて面白いCompose Modifierでした。またbasicMarqueeに関しては、幅が広すぎて利用可能なスペースに収まらないテキストの場合、文字が流れるような動きのあるもので視覚的にお洒落で、使える機会があればぜひ活用してみたいと思うCompose Modifierでした。 最後にプロダクトの1位〜54位のModifier関数の使用回数総合ランキングが紹介されていました。1位〜3位のpadding、height、fillMaxWidthにおいては1000回以上と圧倒的に使用回数が多かったり、逆に1回しか使用されていないCompose Modifierもいくつかありました。1回しか使用されていないCompose Modifierは一体どういった箇所で使用されているのか、またどういった要素のものなのか個人的に深掘りしたくなりました。 wirohaさんの『Jetpack Compose Modifier徹底解説』は、初学者から熟練者まで幅広く活用できる内容だったと思います。今まさに初学者である自身がより知識を深めたいと思う内容に当てはまっていたため、収穫の多いセッションでした。今後こちらを参考にしながら様々なCompose Modifierを活用していきたいと思います。 Debugging: All You Need to Know ZOZOTOWN開発2部 Androidブロックの小林です。Jumpei Matsudaさんの『 Debugging: All You Need to Know 』を紹介します。 このセッションは、Debugスキルを広く学べる内容となっています。Debugスキルは、不具合の原因特定や修正、問い合わせ対応はもちろん、新機能の開発時にコードの挙動を確認する際にも用いる汎用的なスキルです。このセッションでは、Android Studioの機能やDebug用オプションを使うテクニックが説明されており、Androidエンジニアから見て取っ付きやすく、かつデバッグ手法や効率的なデバッグ方法が学べる内容でした。 セッションの中から、個人的に驚いたポイントを2つ紹介します。 1つ目は、ブレークポイントの様々なオプションです。ブレークポイントには、ロギングや条件付きブレークポイントと言ったオプションがあります。ロギングはブレークポイントが通過したらメッセージを表示できます。ログの形式はいくつかのパターンがあり、ブレークポイントのヒット表示、スタックトレース、式の評価と結果のログがあります。また、条件付きブレークポイントは、アプリの中断を頻発させたくない時に活用できます。条件は、評価式、通過数、ブレークポイント間の依存などがあります。評価式の場合、評価式がtrueを返すときだけブレークポイントを機能させます。通過数は指定した回数分、通過した時にブレークポイントを起動できます。ブレークポイント間の依存は、別のブレークポイントがすでに機能していることを起動条件にできます。このような、ブレークポイントのオプションを活用することでアプリの処理を追いかける時に効率的なデバッグが可能になります。 2つ目は、特定のスレッドだけを中断できる機能です。例えば、Coroutineのデバッグ時に活用できます。CoroutineNameクラスとCoroutineデバッグモードを使うと良いです。Coroutineデバッグモードが有効の時、Coroutineごとにユニークな名前が振られます。suspend functionでも問題なく動きますが、最適化によってsuspend function内の変数が解放されてしまうことがあります。その場合は、-Xdebugオプションをコンパイラに渡すことで、デバッグ時のみ最適化をオフにできます。最適化を外すとメモリリークのリスクがあるので、デバッグ時のみこのオプションを使うように気を付ける必要があります。特定のスレッドを追いかけたいといった時に便利に活用できそうです。 今まではブレークポイントを使っていたものの、普段使わない機能ばかりでした。このセッションで得たデバッグ手法はすぐに使える内容でもあり、デバッグスキルの向上に役立つ内容でした。 KSPの導入・移行を前向きに検討しよう! FAANS部 フロントエンドブロックの田中とZOZOTOWN開発1部 Android1ブロックの愛川です。shxun6934さんの『 KSPの導入・移行を前向きに検討しよう! 』を紹介します。 このセッションでは、KSPとkaptの違いや移行時の注意点、KSP2の使用法が紹介されています。田中が所属するFAANSでもKSPへ移行しているものの、まさしく「導入・移行・実装したことあるけど、あんまりわかってない」状態だったため、とても興味をひくセッションでした。 このセッションのおすすめポイントは以下の3つです。 1. KSPとkaptの詳細な比較 KSPとkaptの位置付けから特徴、さらにはコード生成の仕組みまで詳細に説明されていました。まず、両者の位置付けと特徴について、Compiler PluginやAnnotation Processorから順を追って説明されており、各要素の関係性が分かりやすく図示されていました。また、普段Androidネイティブの開発がメインなのであまり意識していなかったのですが、KSPはKotlin Multiplatformでも使えると知り、KSPの有用性に驚きました。kaptのコード生成を紹介するパートでは、コードサンプルを使用して生成時の流れが説明されており、スタブやmetadataといった普段の開発で中々意識できない点まで知ることができました。 2. 混在した時のビルド時間の比較 KSPを使用した場合にどのくらい早くなるのか、また混在している時にどう影響が出るのかを数字で簡潔に示してくれていました。混在している場合はビルド時間が逆に延びてしまうため、一気に移行する or モジュール単位で移行・導入するのがおすすめとのことでした。 3. K2との関係性 最後に、K2 Compiler上で動くKSP2の説明があり、特に、エントリポイントを別プログラムから呼べるという話には衝撃を受けました。セッションの中では、実際に単体テストを実行するまでの一通りの流れが紹介されており、自分でも試してみたくなりました。 自身が所属するプロジェクトでもKSPへの移行を少しずつ進めているものの、KSPがどういったものなのかを理解し切れないまま作業するタイミングが多々ありました。このセッションを参考に、KSPの理解を深めながら移行作業を進められればと思います。 ZOZOブースの紹介 会期中はAndroidエンジニアを中心として10名以上のZOZOスタッフが入れ替わりながらブースに立っていました。DroidKaigi 2024でもiOSDC Japan 2024と同じように、ZOZOブースでは今年5月にリニューアルした「 WEAR by ZOZO 」の「 ファッションジャンル診断 」をメインコンテンツとして展示していました。 DroidKaigi 2024でもメインコンテンツとして展示していたWEAR by ZOZOのファッションジャンル診断 この「ファッションジャンル診断」は、WEARに投稿されている好みのコーディネートを5枚以上選ぶことで、AIがファッションジャンルを診断し、おすすめのコーデを教えてくれる機能です。 多くの方がブースに立ち寄ってファッションジャンル診断を試しました。 ブースに訪れた方々には、お手元の、またはデモ用に用意したAndroid端末で「ファッションジャンル診断」を体験してもらいました。iOSDC Japan 2024でもそうでしたが、興味を持って体験していただく方が多く、とても嬉しい気持ちになりました。この「ファッションジャンル診断」は、WEARを初めてインストールした方も起動直後にユーザー登録の必要もなく試せるため、今回のようなブース出展との相性が良かったと考えています。 ファッションジャンル診断の診断結果にあわせて1枚1枚ステッカーをお渡ししました。 ブースで「ファッションジャンル診断」を体験または診断結果を見せてくれた方には、その診断結果にあわせた診断結果ステッカーをお渡ししました。ステッカーの種類は全部で144種類ありますが、全種類のステッカーを用意していることに驚かれている方もいました。各ジャンルのステッカー枚数は均一ではなく、一般的な傾向として出やすいシンプルやラフを多めに用意しています。その他のジャンルはZOZOエンジニアの傾向と先行するiOSDC Japan 2024の傾向を加味して足りなくなりそうな分を少し多めに用意していました。 Fold端末をお持ちの方が目立ちました。 ブースに立っていると様々なAndroid端末を目にしました。まだ見かける機会がそう多くないGoogle Pixel 9 Pro FoldやGalaxy Z Fold6などのFold端末も、ここDroidKaigi 2024の会場では持っている方が目立ちました。サービスの特性上、積極的にFold端末への対応を進めている企業もあり、学びがありました。 箱猫マックスくんのステッカー ノベルティはiOSDC Japan 2024と同様のものを配布しました。特にZOZOTOWN公式キャラクターである箱猫マックスくんのステッカーはDroidKaigi 2024でも人気で、一部のアイテムは会期中に在庫切れとなりました。これを機に箱猫マックスくんのことを知った方は、ぜひ絶賛配信中のLINEスタンプ「 箱猫マックス Vol.4からVol.9 」もご覧ください! 色“縁”ぴつ また、デザイナー発案の「“一合一会”米」「“失敗を水に流す”トイレットペーパー」に続く「洒落の効いたアイテム」である「色“縁”ぴつ」も特定の条件を満たした方にお渡ししていました。来年はきっと別のZOZOならではのノベルティが登場していることでしょう! 改めてDroidKaigi 2024でZOZOブースに訪れていただいた皆様ありがとうございました! DroidKaigi 2024協賛企業のブースコーデまとめ あっすーです。DroidKaigi 2024の協賛企業ブースを回りながら、各社のコーデを撮影しました!( iOSDC Japan 2024で撮影した協賛企業のコーディネートはこちら ) 先日協賛したiOSDC Japan 2024では拝見しなかった企業をメインに注目しました。DroidKaigiならではのデザインが目立つ今回も、ファッションテック企業ZOZOの視点でお届けします。 株式会社ヤプリさん / ヤプリブルーが映えるボトムスと一緒に。 株式会社MIXIさん / エンジニアと企業ロゴの2種。 サイボウズ株式会社さん / 製品キャラクターがAndroidチーム用デザインに。 AndroidアプリのデザイナーさんがDroidKaigiのためにデザインした そうです。 LINE Digital Frontier株式会社さん / 過去制作したDroidKaigi用デザインとのこと。 株式会社U-NEXTさん / 全体的に黒でまとめており企画が目立っていました。 株式会社フォトラクションさん / 企業ミッションとキャラをアロハシャツに込めて。 WED株式会社さん / キャラクターコラボが背景にも映えるONEポイント。 フラー株式会社さん / ノベルティにも企業ロゴを添えて。 本田技研工業株式会社さん / 一際目立つ展示にHonda Redを添えて。 FlutterKaigiさん / お揃いのパーカーでカンファレンスの宣伝。 協賛ブースというとブースの出し物や装飾に目がいきがちですが、各社コーディネートを含めて工夫を凝らしているのがわかりますね! お忙しい中ご協力いただいたブースの皆さん、本当にありがとうございました! おわりに ZOZOから参加した一部メンバーで撮影した集合写真 ZOZOは毎年DroidKaigiに協賛し、ブースを出展していますが、多くの方との交流を通して今年も有意義な時間を過ごせました。実行委員会の皆さんに感謝しつつ、来年もまた素敵な時間を過ごせることを楽しみにしています! ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com また、会期中は混雑していることも多く、じっくりとお話しする時間が取れなかったので、もう少し詳しく話を聞きたい! という方はカジュアル面談も受け付けています。 hrmos.co それではまた来年のDroidKaigiでお会いしましょう! 現場からは以上です!
アバター
はじめに 技術評論社様より発刊されている Software Design の2024年5月号より「レガシーシステム攻略のプロセス」と題した全8回の連載が始まりました。 ZOZOTOWNリプレイスが順調に進む中始まった「カート決済機能」のリプレイス。第5回では、始動の経緯と、システムの安定稼働につながる大きな改善をもたらしたキューイングシステムの導入について解説します。 目次 はじめに 目次 ZOZOTOWNカート決済リプレイスの始動 体制変更 カート決済サービスの将来像 ジレンマと優先順位 スケールアップ戦略だけでは防ぎきれなくなった障害 キューイングシステムの導入 Kinesis Data Streamsの採用理由 First In First Outでのキャパシティ Kinesis Client Libraryを利用した開発工数の削減 本システムのポイント 過熱商品専用のKDSを作成 既存システムへの改修は最小に 過熱商品への運用を補助するしくみ 導入効果 残った課題とその解決方法 ページラッチ競合によるDB障害 在庫情報のDynamoDB化 スコープは欲張らず最小限に 異種DB移行による脱トランザクション 効果 キューイングシステム自体のランニングコスト Rate Limitの導入 導入効果 まとめ ZOZOTOWNカート決済リプレイスの始動 体制変更 2021年4月、ZOZOTOWNカート決済リプレイスが始まりました。ZOZOTOWNリプレイス開始からこの時点で4年が経過しており、専属のSREやバックエンドチームのメンバーは複数のプロジェクトを経験して、新しい技術や環境に馴染みノウハウも溜まってきたころです。ここからさらにリプレイスを加速するため、リプレイス専属バックエンドチームから3名がカート決済チームへ異動しました。 カート決済チームはZOZOTOWNのカート投入処理から注文データ作成、決済処理を開発・運用しているバックエンドチームです。カート決済機能の既存コードは約11万ステップ、ストアドプロシージャが250本、画面は105画面あります。さらに長年積み上げてきた機能改修により、仕様はもちろんコードは一度や二度見ただけでは到底理解できないほど複雑になっていました。これをもともと4名のエンジニアで開発・運用していましたが、メンバーの入れ替わりなどで歴史や仕様をすべて把握しているメンバーはおらず、都度調査しながら追加改修をしている状況でした。 カート決済リプレイス開始前は、リプレイス専属チームで一部機能を実装したマイクロサービスを新規に作成し、実際に運用するチームへ移譲していく形で進めていました(図1左)。移譲後も技術サポートは続けますが、複数プロジェクトを同時にサポートしていく難しさや、チームが分かれていることによる連携の取りにくさ、ドメイン知識が浅く将来を見越した適切なアーキテクチャ設計が難しいことなどが課題でした。 図1 リプレイスを加速するための体制変更 前述のとおりカート決済機能をリプレイスするには膨大なドメイン知識や運用を考慮したうえでの設計、状況に応じた柔軟な対応が必要です。まずは組織変更し(図1右)、リプレイスメンバーがカート決済機能のドメイン知識を習得する必要がありました。 カート決済サービスの将来像 ZOZOTOWNリプレイスを開始する前のZOZOTOWNはオンプレミスで動く1つのモノリシックなシステムでした。使用していた言語はVBScriptでWebサーバはIIS、データベースはSQL Serverです。SQLを実行する際にはVBScriptからSQL Server上のストアドプロシージャを呼び出しており、ストアドプロシージャにも数千行レベルのビジネスロジックが多数存在しています。また複数のデータベース間をリンクサーバで接続し、ストアドプロシージャ内で分散トランザクションを使用している箇所もあります(図2左)。 これをカート・注文・決済という機能単位でマイクロサービスへ切り出し、Amazon Elastic Kubernetes Service(以下、EKS)上で動くJava Spring Bootのアプリケーションにリプレイスすることになりました(図2右)。また、データベースも各サービスに適したものを選定する想定です。 図2 リプレイス前後のアーキテクチャ ジレンマと優先順位 将来的なアーキテクチャを思い描きつつも、2021年当時のカート決済機能はある致命的な問題を抱えていました。それは、人気商品販売などのイベント時にカート投入リクエストが爆発的に増加し、頻繁にデータベース障害が発生することです。ZOZOではこのようにアクセスがスパイクする商品を「過熱商品」と呼んでいます。多くは転売目的のBOTによるもので、メンバーは過熱商品の販売日時を日々調査し、負荷対策や障害発生時の対応のため休日もリアルタイムで監視を行っていました。このように日々の運用負荷が高く、事業案件はもちろんリプレイス案件にも集中できない状況が続いていました。 まずはこの問題を解決するため、直近7ヵ月後の大規模過熱イベントであった11月の福袋販売までにキャパシティコントロール可能なアーキテクチャに変更することを目標としました。 スケールアップ戦略だけでは防ぎきれなくなった障害 一般的に、ECサイトの多くは注文確定時に在庫を引き当てます。しかしこの仕様の場合、自分のカートに入れておいた商品がいつの間にか他の人に買われ、売り切れてしまうということがあり得ます。 一方ZOZOTOWNのカート投入機能には[カートに入れる]ボタンを押した時点で在庫を引き当て有効期限まで確保するという特徴があります。これは「ZOZOTOWNではお客様にリアル店舗のように安心してお買い物を楽しんでほしい」という思いから作られたためです。 ZOZOTOWNで[カートに入れる]ボタンを押すと、次の流れで処理が実行されます。 在庫テーブルの在庫数を減算する カートテーブルにレコードを登録する 在庫テーブルとカートテーブルは、SQL Serverのカート用データベース(以下、カートDB)にあります。障害の原因は1の在庫数の減算処理にありました。実際には次のようなクエリを実行しています。 UPDATE 在庫テーブル SET 在庫数 = 在庫数 - 1 WHERE PK = ? 過熱商品の販売開始直後には、在庫テーブルの同一レコードへ大量の更新リクエストが集中し、ロック競合が発生します。その結果、後続クエリの滞留によりクエリタイムアウトが多発します。最悪の場合はワーカースレッドが枯渇し、カートDBへ接続できない状況になります。カートDBに接続できなくなるとサイト全体がエラーになるため、状況によってはカートDBをフェイルオーバーする必要がありました。それまではカートDBの物理的なスケールアップを繰り返してしのいでいましたが、すでに性能の伸びが頭打ちになり限界が来ていました。 この問題を根本的に解決するためには、まずカートDBを柔軟にスケールアウト/スケールイン可能なAmazon DynamoDB(以下、DynamoDB)にリプレイスすることが有効だと考えました。しかしカートDBはZOZOTOWN以外のサービスや基幹システムからも接続があります。この時点でカート決済のリプレイスは開始からすでに2ヵ月が経過し、6月に差し掛かっていました。11月の福袋販売までにリリースし安定稼働まで持っていくには影響範囲が大き過ぎて現実的ではないというのが結論でした。 このような背景から、アーキテクチャの変更や影響範囲を最小限に留めつつ効果的な一手を打つ必要がありました。さまざまな案を検討した結果、ボトルネックとなっている更新処理の前にキューを挟むことでリクエストのキャパシティコントロールを実現する方向になりました。 キューイングシステムの導入 前述した背景により、Amazon Kinesis Data Streams(以下、KDS)を利用して次の処理を行うキューイングシステムを構築しました(図3)。 図3 キューイングシステムの概要 queuing-apiが商品タイプを判別 商品タイプに応じて異なるKDSにレコード投入、以後ポーリング処理でカート状態管理用DynamoDBを確認 Workerは定期的にKDSへポーリング、レコードが存在すれば後続処理を実施、カートDBのUpdateが完了した時点で状態管理用DynamoDBにその旨を記録 queuing-apiが状態管理用DynamoDBから処理終了のStatusを見てClientにレスポンス どのような経緯を経てこのようなシステムになったか、そして本システムのポイントを本節では解説していきます。 Kinesis Data Streamsの採用理由 ZOZOTOWNリプレイスは技術スタック選定でAWSを採用しています。本キューイングシステムもAmazon EKS上で稼働するアプリケーションを前提として利用サービスの検討を開始しました。キューイングシステムの候補として検討したサービスはAmazon Simple Queue Service(以下、SQS)とKDSですが、KDSを採用した理由は2つあります。 First In First Outでのキャパシティ 前述した「ZOZOTOWNではお客様にリアル店舗のように安心してお買い物を楽しんでほしい」という考えのもと、構築するキューイングシステムも「カート投入機能は同一商品に限り順序性を担保したい」というFirst In First Out(以下、FIFO)の要件がありました。いずれのサービスもFIFO機能が提供されているという面では要件を満たしていたのですが、検討を開始した2021年、SQSのFIFOキューにはAPIメソッドごとに300Req/secのAPIコール制限がありました。ZOZOTOWNのカート投入リクエスト数を考慮するとキャパシティ観点でSQSのAPIコール制限が懸念されました。これを防ぐためにサイトの成長に応じてSQSを増加させていく運用なども考えられましたが、この場合FIFOの要件を満たせなくなってしまいます。また、SQSの増加に追従するロジックをアプリケーション側で実装する必要があります。 一方KDSはシャードあたりのキャパシティという考え方になり、1シャードあたり1,000レコード/secまたは1MB/secという制限がありました。シャード数はKDSの設定変更のみで調整可能なため、キャパシティ変更のオペレーションと実装を考えた際にKDSにメリットがあると判断しました。 余談ですが、SQSは2021年5月末にFIFOキューの高スループットモードを提供しており、一度に複数のメッセージを発行する仕様にすれば300tpsを超える性能を発揮することも可能です。2023年10月には処理可能なトランザクション数が9,000tpsまで増加しています。コスト面ではSQSが優位になるため、何に優先度を置くかによっては選択が変わっていたかもしれません。 Kinesis Client Libraryを利用した開発工数の削減 もう1つの大きな決め手は、KDSとレコード処理ロジック(Worker)を仲介するKinesis Client Library *1 (以下、KCL)が提供されていたことです。 KCLでは、何らかの事情により特定のWorkerのパフォーマンスが低下または停止してしまうような事態に陥った場合に異なるWorkerがそのレコードを処理するためのフェイルオーバー機能が提供されています。Workerの状態管理のためLeaseTableとしてのDynamoDBの構築・管理は必要になりますが、自身でKCLのフェイルオーバー機能を実装する時間をカットできるのは本質的なカート決済機能のリプレイスを進めるうえで大きなメリットと判断しました。 本システムのポイント 過熱商品専用のKDSを作成 KDSの採用理由にFIFO要件がある一方、KDSでシャード数を変更することによりキャパシティが変更可能になることを挙げました。しかし前述したような過熱商品の存在はここでも避けられない課題となりました。すべての商品ごとにシャードを用意できれば特定の商品によるカート投入リクエストのスパイクによる影響範囲はその商品だけになります。しかし、これはコストの関係から断念しセール時などでもユーザー体験を損なわないレイテンシで処理できる量のシャード数を用意しました。過熱商品については過去にアクセスがスパイクした商品や明らかにアクセス数の多い商品をDynamoDBに登録し、登録済みの商品であれば異なるKDSにレコードを登録する仕様にしました。 この仕様を入れることで過熱商品の販売に伴いその他多くの商品のカート投入に影響が出てしまうことを防げるようになりました。 既存システムへの改修は最小に 本来であればクライアントから定期的にポーリング処理を行う形が最も望ましいのですが、カート投入機能はブラウザのほか、ネイティブアプリにも存在しています。ZOZOTOWNのアプリは下位バージョンについても一定期間のサポート期間を設けており、直近のバージョンに対する強制アップデートはさまざまな部門との調整連携が必要になります。福袋販売時期を加味するとネイティブアプリのバージョンアップは現実的ではありませんでした。 そこでエンキュー後の状態管理(ポーリング)もqueuing-apiに持たせることでネイティブアプリ側の改修は必要とせず、VBScriptの改修も最小限に抑え速やかに本システムのリリースにつなげられました。前段となるVBScriptとqueuing-apiは結果的に後続処理完了まで待つという動きになりますが、本キューイングシステムの最大の目的はDBへの流量をコントロールすることであり、フロントエンド側の改修範囲を最小限に留めスピード感を持って開発を進めるためには、この形がベストと判断しました。 過熱商品への運用を補助するしくみ 前述のように過熱商品専用のKDSを作成したものの、事前にアクセススパイクが予測できる商品もあれば、予測できずスパイクする事態もかなりの頻度で発生することが考えられました。 そこで図4のような過熱商品運用を補助するしくみを構築しました。本システムが稼働するEKS上ではログ収集の機構としてfluentd-firehoseを採用しており、コンテナのアクセスログを収集してAmazon Simple Storage Service(以下、S3)にログとして保存するしくみがあらかじめ構築されていました。またそれをAWS Athena(以下、Athena)を使い分析するという運用を行っています。そのしくみを流用してCronなどの定期実行でAthenaから一定時間内に大量にカート投入されている商品がないかをログから判断し、あれば過熱商品判定用DynamoDBにItem登録するしくみを実装しました。当初はこの定期ジョブをGitHub Actionsを使い実現していたのですが、多くのアプリケーションのCI/CDにGitHub Actionsが使われていることからたびたび実行待機状態になってしまう問題があり、意図した実行間隔で動作できないという課題が生まれたことから現在では自社でコントロール可能なEKS基盤上で動作するArgo Workflows上での定期実行を採用しています。 図4 過熱商品への運用を補助するしくみ 導入効果 本システムを当初ターゲットとしていた福袋販売イベント前にリリースできたこともあり、無事にイベントを乗り越えられました。リリースまでは過熱商品販売のたびに更新の競合に伴うエラーが大量に発生していましたが、更新の競合を激減させることに成功し福袋販売時以外にも障害件数も減らせました。また運用を補助するしくみがあることで過熱商品販売に関する人的工数も大幅に抑えられています。 残った課題とその解決方法 本キューイングシステムだけでは対処しきれなくなった課題が2つがありました。本節では、それらの課題と解決方法を紹介します。 ページラッチ競合によるDB障害 SQL Serverにはデータ格納領域であるページの一貫性を保つための排他制御のしくみとしてページラッチという概念があります *2 。キューイングシステムの導入により同一レコード更新の競合に伴う障害発生件数は低減できましたが、今度はページ単位の読み取りと書き込みの競合によるSQL Serverのワーカースレッド枯渇に伴う障害が課題となりました(図5)。 図5 ページ単位の競合とレコード単位の競合 在庫情報のDynamoDB化 カート投入処理のボトルネックを根本解決するために実施したのが、キューイングシステム導入前に検討していたDynamoDBへの移行です。DynamoDB上に在庫情報を持ったテーブル(在庫数テーブル)を作成し、VBScriptからJava API経由で操作します(図6)。 図6 在庫サービスの変更(矢印の太さはリクエスト量を表します) DynamoDBはフルマネージドなサーバレスNoSQLデータベースです。データは常に3つのアベイラビリティゾーンに保持され、内部的には複数のパーティションに分散されています。RDBではないためJOINを使用するような複雑なクエリで大量データを分析するOLAP処理には向いていませんが、単純なクエリを用いた書き込みや読み込みを行うOLTP処理を数ミリ秒で安定して大量に処理できるのが特徴です。また、書き込みと読み込みのスループットが相互に依存しないため、SQL Serverで課題となっていたページラッチのような競合は発生しません。 課金体系では従量課金型のオンデマンドモードを使用することで、事前に負荷の予測が難しい場合や偏りがある場合でも負荷に合わせて柔軟にスループットをスケール可能です。また、特定のパーティションに負荷が集中した場合に自動でパーティションを分割して追加キャパシティを割り当てるAdaptive capacity機能がデフォルトで有効になっています。チーム内にも知見があることから、移行先はDynamoDBが最適と判断しました。 スコープは欲張らず最小限に 今回の移行対象は在庫テーブル内の在庫数のみです。在庫テーブルには金額や販売日時なども存在するため、多くのストアドプロシージャから参照・更新されます。また、ZOZOTOWN以外のサービスや基幹システムからも使用されているため、在庫テーブルをすべて移行すると影響範囲があまりにも大きく、リリースまで数年かかってしまいます。 カート決済リプレイスの方針として、リリーススパンは半年程度にすることが定められています。ビッグバンリリースによるさまざまなリスクを下げるほか、リリースまでの期間が長くなるとエンジニアが成果を感じにくく、モチベーション低下にもつながります。また、スパンを短くすることで世の中の技術動向や事業の成長に合わせて方針転換しやすいというのも大きな理由でした。 異種DB移行による脱トランザクション 既存のカート投入処理は、在庫テーブルの更新とカートテーブルの登録を1つのトランザクションで処理し整合性を担保していました。リプレイス後はSQL ServerとDynamoDBの異なるデータベースの更新となり、トランザクションは使用できません。そのため処理を分解して次のように変更しました。 在庫サービスのAPIを呼び出して在庫数を減算する 減算に成功した場合、カートテーブルにレコードを登録する 2の登録処理が失敗した場合、在庫数は減算されたままとなりデータの不整合が発生します。そのため、在庫数更新履歴を常にチェックして補正するバッチを作成し、結果整合性を担保しています。在庫数更新履歴はDynamoDBのKDS連携機能を使用しており、項目の変更前後のキャプチャが取得可能です。そのキャプチャを、KCLを使用したWorkerが取得し、Aurora MySQLの在庫数更新履歴テーブルへレコードを登録するしくみとなっています(図7)。 図7 DynamoDBの在庫数更新履歴をAurora MySQLへ連携 効果 在庫サービスへの移行後は、以前の5倍以上のカート投入リクエストを受けてもカートDB内で競合や負荷の上昇は見られていません。また、DynamoDBも適切なリトライ処理をアプリケーション側で実装していることもあり、とくに大きな問題もなく安定して稼働しています。 キューイングシステム自体のランニングコスト 前述したように本キューイングシステムではエンキュー後の状態管理をqueuing-apiが担っています。過熱商品の販売に伴い、デキュー待ちのレコードが増えると必然的にqueuing-api自体のリソース消費量も上昇します。負荷状況に応じてオートスケールするシステムではあるものの、突発的なスパイクには対応できないケースが多々見受けられます。いつ大量アクセスが発生するかわからない状況では、常にある程度潤沢なリソースを本システムで稼働させ続ける必要があり、ランニングコスト面で改善の余地がありました。 Rate Limitの導入 ランニングコストの問題は、Rate Limitを導入し、1商品あたりの秒間カート投入リクエスト数に上限値を定めることで解決しました。 ZOZOTOWNリプレイスではインフラ基盤としてEKSを活用し、EKS内でサービスメッシュとしてIstioを採用しています。IstioにはRate Limit機能が提供されており、特定のエンドポイントやHeaderのValue単位などさまざまな細かい条件でリクエスト数を制限できます。 今回のケースでは、カート投入リクエストにおいて商品単位でリクエスト数を制限する機能を次の流れで実現しました(図8)。 図8 Istio Rate Limitの概要図 呼び出し元のVBScriptから商品情報を識別できる情報とともにカート投入リクエストが行われる Envoy Filter *3 にて条件にマッチした場合、該当のマイクロサービスに到達する前にIstio Rate Limitにトラフィックをルーティング 制限値に達しているかを判定し、達していなければそのまま本来のリクエスト先となるマイクロサービスにトラフィックをルーティング 制限値に達している場合Istio Rate Limitは該当のトラフィックをマイクロサービスにルーティングすることなくStatus:429を応答 呼び出し元のVBScriptでは制限値に達したレスポンスを受けた場合は後続処理を行わずユーザーに適切な文言のみ返す 導入効果 Istio Rate Limitを活用することで過剰なアクセスはqueuing-apiに到達する前に遮断できる状態になり、キューイングシステム自体の負荷状況の改善とランニングコストの低下を実現しました。 Rate Limitは上限を超えたリクエストはすべて遮断する仕様です。そのため、あまりに厳しい制限値ではユーザー体験を低下させるリスクが存在します。前述したキューイングシステムやDynamoDBの導入により本質的にカート投入機能の性能限界が上がったことで、ユーザー体験を損なわない制限値でのRate Limit導入ができたと考えています。 まとめ 長いZOZOTOWNの歴史の中でもとくにトラブルの多かったカート投入機能ですが、クラウドネイティブな技術を活用していくことでキャパシティコントロール可能な状態を作り出し、システムの安定稼働率は大幅に向上しました。結果としてビジネスロジックやフロントエンド部分の脱VBScript化という本質的なリプレイスプロジェクトに注力できています。 今後のカート決済機能のリプレイスについてもぜひご期待いただければと思います。 本記事は、技術本部 カート決済部 カート決済技術戦略ブロック ブロック長の半澤 詩織と同 SRE部 カート決済SREブロック ブロック長の横田 工によって執筆されました。 本記事の初出は、 Software Design 2024年9月号 連載「レガシーシステム攻略のプロセス」の第5回「キャパシティコントロール可能なカートシステム」です。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com *1 : https://docs.aws.amazon.com/ja_jp/streams/latest/dev/shared-throughput-kcl-consumers.html *2 : https://learn.microsoft.com/ja-jp/sql/relational-databases/diagnose-resolve-latch-contention?view=sql-server-ver16#what-is-sql-server-latch-contention *3 : https://istio.io/latest/docs/reference/config/networking/envoy-filter/
アバター
はじめに こんにちは! WEARバックエンド部バックエンドブロックの小島( @KojimaNaoyuki )です。普段は弊社サービスであるWEARのバックエンド開発・保守を担当しています。 10周年を迎えた WEAR は2024年5月9日に大規模な アプリリニューアル を行いました。アプリリニューアルに伴い負荷試験を行ったので、本記事ではどのように負荷試験を実施したか事例をご紹介します。 記事は計画編と実施編の2部構成で、本記事は後編の実施編です。前編の計画編は「 WEARアプリリニューアルにおける負荷試験事例(計画編) 」で公開していますので、まだ閲覧していない方はぜひご覧ください。 techblog.zozo.com 目次 はじめに 目次 背景 負荷試験の要件 使用した負荷試験ツール 負荷試験シナリオ作成 コード例 解説 ファイル構成について 仮想ユーザー(VU)について executorについて coefficientRateについて rateとtimeUnitについて 発生した課題とその解決策 リソースの作成と削除を実施するAPIの負荷試験の問題 大量のシナリオ作成の問題 負荷試験実施 発生した課題とその解決策 負荷試験中にロックが発生してしまう問題 十分なmaxVUsを指定していても想定のRPSにリクエスト数が届かない問題 まとめ おわりに 背景 負荷試験を実施する背景については、 計画編 をご覧ください。 負荷試験の要件 計画編にて設定した負荷試験を実施するためには、以下の要件を満たす必要があります。 約100個のAPIに対して、一斉に負荷をかけられること それぞれのエンドポイントにかける負荷を変更できること 指定の期間で負荷をかけられること 本番環境で実施できること 次章からはこれらの負荷試験の要件を実現するために実施した事例と、その時に発生した課題とその解決策を記載します。 使用した負荷試験ツール k6 というツールを利用して負荷試験を実施しました。 今回の負荷試験に採用した理由は、WEARでは現在チーム内で共通の負荷試験基盤としてk6環境が整備されており、普段から利用しているためです。また、今回の負荷試験の要件もk6を利用することで十分に満たすことができると判断しました。 チームの負荷試験基盤としてk6を導入した経緯などについては、「 WEARにおけるKubernetesネイティブな負荷試験基盤の導入とその効果 」の記事にまとまっていますのでご興味あればご覧ください。 負荷試験シナリオ作成 初めに今回の負荷試験で使用したシナリオコード例を以下に記載します。その後に解説します。 コード例 main.js import { get_v1_users , get_v1_articles , // ...省略 } from './exec_functions.js' export { get_v1_users , get_v1_articles , // ...省略 } const duration = '1s' const coefficientRate = 1 const maxVus = 50 export const options = { summaryTrendStats : [ 'avg' , 'min' , 'med' , 'max' , 'p(95)' , 'p(99)' , 'p(99.99)' , 'count' ] , scenarios : { get_v1_users : { executor : 'constant-arrival-rate' , duration : duration , rate : Math . ceil ( coefficientRate * 10 ) , timeUnit : '1s' , preAllocatedVUs : 1 , maxVUs : maxVus , exec : 'get_v1_users' } , get_v1_articles : { // ...省略 } // ...省略 } , } exec_functions.js import http from 'k6/http' import { check } from 'k6' import { getMemberUserNameRandomly , // ...省略 } from './dynamic_parameters.js' export const get_v1_users = () => { const headers = { /* ...省略... */ } let response = http . get ( `https://example.com/v1/users` , { headers : headers } , { tags : { my_custom_tag : get_v1_users } }) let result = check ( response , { "status is 200" : ( r ) => r . status === 200 }) if ( result === false ) { console . warn ( `status: ${ response . status } \turl: ${ response . url } ` ) } } export const get_v1_articles = () => { // ...省略 } // ...省略 dynamic_parameters.js const getRandomly = ( array ) => { const randomIndex = Math . floor ( Math . random () * array . length ) ; return array [ randomIndex ] ; } export const getMemberUserNameRandomly = () => { const testUserNames = [ 'hoge1' , 'hoge2' , 'hoge3' , 'hoge4' , 'hoge5' , /* ...省略... */ ] return getRandomly ( testUserNames ) } // ...省略 解説 今回の負荷試験では約100個のAPIに対して、一斉に負荷をかける必要があり、それぞれのエンドポイントにかける負荷を調節できることや負荷をかける期間を指定できる必要があります。 そこで、基本的に1シナリオには1エンドポイントを呼び出し、それら複数のシナリオをまとめて実行することにしました。そして、指定期間に指定の負荷(RPS)をかける必要があったため、executorは constant-arrival-rate を利用しました。executorについての詳細は後述しています。また、動的にパラメータの値を変更したい箇所があったため、リソース毎に関数化し、それらのパラメータはファイルを分けて管理しました。 ファイル構成について シナリオで実行する関数をまとめたexec_functions.jsと動的にしたいパラメータデータをまとめたdynamic_parameters.jsとシナリオを記述するmain.jsを作成しました。 今回の負荷試験は本番環境で実施していたため、実施する時間帯によって負荷を変える必要があり、複数のシナリオを作成する必要がありました。その際、実行する関数は共通で再利用できるため、exec_functions.jsに切り出して共通化しました。 動的にパラメータの値を変更したい箇所は、リソース毎に関数化してdynamic_parameters.jsにまとめ、それらを適宜exec_functions.jsの関数から呼び出すようにしました。 仮想ユーザー(VU)について 仮想ユーザー(VU)という概念があります。VUは、シナリオを実行するための仮想的なユーザーです。VUは任意の数を用意でき、VU数を増やすことで並列にリクエストを送信できる数を増やせます。 VU数の算出方法については、後述の「executorについて」で説明します。 executorについて executorとは、VUがシナリオを実行する方法を制御するものです。詳しくは Executors | Grafana k6 documentation をご覧ください。executorには様々な種類が存在し、実現したい負荷試験の要件によって適切なexecutorを選択する必要があります。今回の負荷試験では constant-arrival-rate を利用しました。 constant-arrival-rate は利用可能なVUが存在する限り、指定した期間に指定したレートで繰り返しシナリオを実行し続けます。そのため、今回の「指定負荷(RPS)を指定期間かけ続ける」という要件に適切と考えて利用しました。 注意点として、1回のシナリオの実行時間と指定したrate, timeUnit次第で、想定の負荷をかけるために必要な数のVUが増減するため適切なmaxVUsを指定する必要があります。もしもVUの数が枯渇すると想定の負荷がかからなくなります。今回の負荷試験では、k6のログに出力される使用しているVU数とk6実行環境のリソース使用量を確認しつつ適切な値を設定しました。 coefficientRateについて シナリオのrate定義に Math.ceil(coefficientRate * 10) としている部分があります。これにより、いきなり負荷試験で確認したい想定負荷量の100%の負荷をかけるのではなく、係数をかけて段階的に負荷をかけられます。 rateとtimeUnitについて 今回の負荷試験ではRPSの単位で負荷をかける必要があったため、1秒間に何回繰り返すかを指定する必要がありました。そのため、timeUnitを1sに設定し、rateに繰り返す回数を設定しました。 発生した課題とその解決策 リソースの作成と削除を実施するAPIの負荷試験の問題 今回は本番環境で負荷試験を実施していたため、負荷試験で作成したテストデータはできるだけ残らないように削除する必要がありました。こちらは、リソースの作成と削除を1つのシナリオに順番で記載することで、負荷試験で発生したデータを本番環境に残すことなく実施できました。 実際のユーザーに紐付くデータとしては作成できないため、テスト用のアカウントを本番環境に用意し、そのアカウントで負荷試験を実施しました。 大量のシナリオ作成の問題 今回の負荷試験ではシナリオの総数は200程度と大量だったため、手作業で作成することが困難でした。そこで、計画段階にエンドポイント名や負荷量などの情報はGoogleスプレットシートに記載していたためそこからシナリオを生成するGoogle Apps Scriptを作成し、大部分を自動生成しました。 自動生成するにあたって、シナリオで実行する関数の命名は一意にする必要があったため、以下のルールで実施しました。 httpメソッド + apiのパス 例: GET v1/articles → get_v1_articles 負荷試験実施 シナリオは本番環境の現行負荷を考慮して適切な負荷量になるように、時間帯毎に負荷量を調節したシナリオを複数用意して実施しました。 初めから想定される100%の負荷量で試験を実施するのではなく、都度結果を確認しながら10%→30%→50%→100%と負荷量を段階的に上げて実施しました。 また、負荷試験の実施中はエラーなどを常に監視し、ユーザ影響が出た場合にすぐに負荷を中止できるように準備しておきました。 発生した課題とその解決策 負荷試験中にロックが発生してしまう問題 負荷試験を実施したところ、デッドロックやロック起因のDBタイムアウトによるAPIエラーが多数発生してしまいました。監視体制を整えていたため、すぐに負荷試験を中断し、ロック原因の調査をしました。 ロックが発生したリソースとブロッカーとなるAPIを調査した結果、負荷試験シナリオの問題であることが分かりました。具体的には、とある親リソースに紐づいている子リソースをまとめて取得するAPIと、その子リソースを削除するAPIが同時に実行されるシナリオになっており、リソースの競合が発生していたためでした。 取得するAPIと削除するAPIとでリソースが競合しないように、それぞれパラメータに渡すリソースを調節することで解決しました。 十分なmaxVUsを指定していても想定のRPSにリクエスト数が届かない問題 負荷試験を実施中に、十分なmaxVUsを指定していたにもかかわらず、予想したRPSに実際のリクエスト数が到達しない状況になることもありました。 原因としては、k6を実行していたコンピューターの性能が不足しており、maxVUsまでVUの数を増やすことができていなかったことが原因でした。 そのため、k6を実行するコンピューターの数を増やすことで解決しました。 まとめ 負荷試験では、想定した負荷量をかけることができ、問題なく本番環境で負荷試験を実施できました。そして、APIのレイテンシやCPU使用率などの問題を未然に発見でき、リリース前に改善できました。これは負荷試験を実施しなければ発見することが難しかったことと思われるため、負荷試験の成果と言えます。 一方で、今回の負荷試験には実際のユーザーへの影響が出てしまう危険性や、工数面などデメリットも存在しました。しかし、今回の負荷試験は安全なWEARリニューアルリリースを実現するために必要な作業であったと考えています。 おわりに WEARアプリのリニューアルにおける負荷試験の実施事例についてご紹介しました。シナリオ作成時には、負荷試験を計画する時に設定した要件を満たすことを、負荷試験を実施する時には、安全で正確な負荷試験を実施することが求められます。WEARでの事例が参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは。会員基盤ブロックの小原田です。弊社では、ZOZOTOWNという大規模なモノリシックシステムをマイクロサービスへリプレイスする取り組みを進めています。私が所属する会員基盤ブロックでは、ZOZOTOWNの会員情報を扱うマイクロサービスの開発と運用を担当しています。 会員基盤ブロックでは、開発生産性の向上を目指し、約半年間にわたり様々な取り組みを行ってきました。本記事では、会員基盤ブロックが開発生産性の向上のために実施してきた施策と、その成果について紹介します。 目次 はじめに 目次 開発生産性の向上に関する取り組みの背景 Findy Team+を用いた調査 会員基盤ブロックが抱える開発生産性の課題 サイクルタイム短縮の取り組み PRの変更行数を100行以下にする レビューを優先する仕組みの整備 ブレスト会の実施 品質向上についての取り組み Goガイドラインの策定 テストカバレッジレポートの導入 半年間の取り組みの結果 取り組みに関するメンバーからのフィードバック ポジティブだったこと ネガティブだったこと まとめ 開発生産性の向上に関する取り組みの背景 弊社では、全社的に開発生産性の向上に注力しており、各部署やチームでさまざまな取り組みを進めています。その中でも、私たちの部署は「品質を維持しながら効率を高める」ことを目標に掲げ、各チームに対して開発生産性の可視化とそのサポートツールである Findy Team+ の導入を推進しました。各チームは、Findy Team+が提供する指標をもとに、開発生産性の向上に取り組んでいます。 こうした背景を受けて、会員基盤ブロックでも開発生産性の向上に向けた取り組みを進めることになりました。私たちはFindy Team+の指標や機能を用いて開発生産性の課題を調査するところから始めました。 Findy Team+を用いた調査 会員基盤ブロックでは、チーム状況を正しく把握することを目的とした「ふりかえり会」を週に一度実施しました。このふりかえり会では主に以下のようなFindy Team+が提供する指標を用いて、これまでのチームの動き方がどのような状態であったかを確認していました。 DevOps分析 チームサマリ サイクルタイム分析 レビュー分析 会員基盤ブロックが抱える開発生産性の課題 毎週のふりかえり会を通してわかったことは、会員基盤ブロックでは主にPRのレビュープロセスに課題が存在することでした。以下の図は、Findy Team+の「サイクルタイム分析」を用いて可視化した、2024年3月の会員基盤ブロックのサイクルタイムです。 図1. 2024年3月のサイクルタイム ここでのサイクルタイムとは、ある機能の実装(コミット)を開始してから、PRを作成し、それがマージされるまでにかかる時間を指します。Findy Team+のサイクルタイム分析では、サイクルタイムの平均時間の他に以下の4つの項目を確認できます。 コミットが開始されてからPRがオープンされるまでの平均時間 PRがオープンされてから初めてレビューされるまでの平均時間 PRのレビューが開始されてから初めてアプルーブされるまでの平均時間 PRがアプルーブされてからマージされるまでの平均時間 会員基盤ブロックでは、サイクルタイムの平均値が212時間と、かなりの時間を要していました。特に、PRがレビューされてからアプルーブされるまでの平均時間は108時間であり、サイクルタイムの大半を占めています。 レビューからアプルーブされるまでの時間が長くなる要因としては様々なものが考えられますが、代表的なものとしてPRの規模が挙げられます。PRの規模が大きいと、PRの確認に時間がかかり結果としてサイクルタイムが伸びます。実際、会員基盤ブロックでもこの傾向がみられました。次の図は2023年12月の変更行数の平均とレビューからアプルーブまでの時間のグラフです。棒グラフが変更行数の平均を表し、赤色の折れ線グラフがレビューからアプルーブまでの平均時間を表します。変更行数が多くなるにつれてレビューからアプルーブまでの時間が伸びていることが分かります。 図2. 2023年12月のレビューサマリ また、図1や図2をみるとオープンからレビューまでの時間は短いことがわかります。しかし、会員基盤ブロックでは一部のPRのレビューが後回しにされ、長時間レビューされないといった問題も見られました。次の図は2024年3月における、PRがオープンされてからレビューされるまでの時間をヒストグラムで表したものです。横軸はオープンされてからレビューされるまでの時間、縦軸はPRの数を表します。図から分かるように、100時間以上レビューされていないPRが存在します。このような場合、レビューよりも自分の作業を優先することや優先度の低いPRは後回しにし続けるといったレビュー意識に関する問題があると考えられます。 図3. 2024年3月における、PRがオープンされてからレビューされるまでの時間とPR数のヒストグラム サイクルタイム短縮の取り組み 上記のような課題を解決するために、Findy Team+の「KPTふりかえり」を用いることにしました。以下の図はKPTふりかえりの様子です。私たちはFindy Team+の指標や分析から、それぞれ良かった点や改善したい点などを挙げ、今後行うべき取り組みをチームで出し合いました。 図4. KPTふりかえりの様子 このKPTふりかえりを通して実際に行なってきた取り組みのうち、効果的であった取り組みをいくつか紹介します。 PRの変更行数を100行以下にする 会員基盤ブロックではPRの粒度が大きくなると、レビューからアプルーブまでの平均時間が長くなる傾向にありました。そこでPRのレビューを効率的に行うために、PRの粒度を小さくする取り組みを実施しました。実のところ会員基盤ブロックでも以前からこの取り組みはされており、実際「変更ファイル数が5〜8を超える場合はPRを分けることを検討する」というルールが存在していました。今回、新たに「ファイル数」ではなく「変更行数」に着目し、1PRあたりの変更行数を100行以内に抑えることを目標に設定しました。 図2を見ると取り組み前の変更行数の平均は300行を超えているため、1PRあたり100行という目標は厳しいものと考えられます。実際、会員基盤ブロックではこの目標を完全に達成できていません。しかし、各メンバーはできる限り100行程度の変更行数でPRを出すよう努力しています。例えば、リポジトリを作成する際には、最初にインタフェースのみ定義したPRを作成するなど、スコープを狭める工夫をしています。 この取り組みの結果、以前よりもサイクルタイムが改善しました。取り組み前後に実装したAPIの中から、機能的に類似した4つのAPIを取り出し、サイクルタイムを比較した図を次に示します。棒グラフの各要素はPRごとのサイクルタイムを示しており、その合計、つまり棒グラフ全体が1つのAPIを実装するのにかかったサイクルタイムを表しています。変更行数を抑える取り組みの結果、API実装にかかる合計のサイクルタイムが減少していることが読み取れます。 図5. 取り組み前後のサイクルタイムの比較 レビューを優先する仕組みの整備 レビューを優先させるために、チーム全員で積極的にPRを確認する機会を設けました。会員基盤ブロックでは、毎朝、各自のタスクを確認する「朝会」を行っていますが、この朝会で、現在出ているPRやレビューコメントの確認も行うようにしました。さらに、アプルーブされていないPRがある場合には、毎朝Slackで通知される仕組みも整備しました。これらの施策により、PRが長期間レビューされない状況を改善できました。 次の図はこの取り組みを開始した後の2024年6月における、PRがオープンされてからレビューされるまでの時間をヒストグラムで表したものです。図3ではレビューされるまでに100時間を超えるようなPRがありましたが、取り組みを開始してからは最大でも25時間未満でレビューを開始できました。 図6. 2024年6月における、PRがオープンされてからレビューされるまでの時間とPR数のヒストグラム ブレスト会の実施 会員基盤ブロックではKPTふりかえりを通じてさまざまな取り組みを行ってきましたが、「レビューからアプルーブまでの平均時間」の改善が頭打ちしていました。次の図は2024年5月のサイクルタイム分析です。レビューからアプルーブまでの時間が42.3時間になっており、図1の場合の108.4時間と比べて大きく改善しています。Findy Team+のサイクルタイム分析にはそれぞれの項目に評価が設けられており、上から「Elite」「High」「Medium」「Low」と設定されています。図7のレビューからアプルーブまでの時間の評価をみると、最低評価のLowとなっており、まだ十分に改善できていない状態でした。 図7. 2024年5月のサイクルタイム分析 そこで、レビューからアプルーブまでの時間が長くなっている原因を特定するために、チームメンバーとブレスト会を実施しました。ブレスト会では、最近出されたPRのうちサイクルタイムが長かったものを参考にし、以下のテーマについて図8のように付箋に意見を書き出し、アイデアを出し合いました。 レビューに時間がかかる要因は何か? 時間がかかる原因はどこにあるのか? その対策案は何か? 図8. ブレスト会の様子 ブレスト会の結果、レビューに時間がかかる理由として、「なぜそのPRが必要かメンバーの理解度が一致していない」ことや「設計時に見落とした考慮漏れにより、実装時に手戻りが発生する」ことが挙げられました。それに対する対策として、本実装の前に必要最低限の実装(PoC)を行い、その内容について口頭での説明会を実施することにしました。PoC共有会を行うことで、なぜその対応が必要なのかについてチーム内で共有し、実装方針について事前に合意を得ることができるようになりました。 さらに、PoCの取り組みは別の課題も解決しました。会員基盤ブロックではPRの変更行数を少なくする取り組みを行っていました。しかし、これにより「PRが小さすぎて全体の変更内容が把握できない」という問題が発生していました。例えば、ある関数を実装する際にその関数の利用方法が不明瞭なため、レビューが難しくなるといった問題です。PoC共有会では実装したい機能全体をチームに共有するため、細かい単位でのPRであってもその機能の利用方法についてレビュアが理解できるようになり、スムーズなレビューが可能となりました。 品質向上についての取り組み 私たちの部署内では開発生産性の向上に加え、「品質を維持しながら効率を高める」ことも目標として挙げられています。会員基盤ブロックでは以前からCIによるLintチェックなどは導入していますが、追加で次のような品質向上につながる取り組みを行いました。 Goガイドラインの策定 会員基盤ブロックでは、Goを使用してマイクロサービスの開発をしています。ZOZOではバックエンドの開発言語としてJavaまたはGoの利用を推奨していることもあり、会員基盤ブロックが中心となって全社的に利用できるGoガイドラインを策定しています。ガイドラインには、コーディング規約、パフォーマンス向上のためのベストプラクティス、テストの書き方などが含まれています。現在、ガイドラインは策定中ですが、これにより実装速度の向上、レビュー時間の短縮、そして品質の向上が期待できます。 テストカバレッジレポートの導入 品質を維持するための取り組みとして、図9のようなテストカバレッジレポートの導入も行いました。PRを出す際に、mainブランチと比較して全体のテストカバレッジの上昇率を確認できます。また、全体ではなく、変更があったファイル単位でのテストカバレッジの上昇率も確認可能です。テストカバレッジレポートの導入により、テストの不足部分を一目で把握できるようになりました。 図9. テストカバレッジレポート 半年間の取り組みの結果 会員基盤ブロックでは、約半年間にわたって開発生産性の向上に取り組んできました。ここではその取り組みの結果について紹介します。 まず、サイクルタイムが大きく改善しました。次の図は2月から8月まで約半年間のサイクルタイムを示しています。棒グラフの長さはサイクルタイムの合計平均値を表しています。取り組みを始める前の2024年2月や3月には、サイクルタイムがほとんど100時間を超えており、その中には300時間を超えるケースも見られました。しかし、取り組みを始めた4月以降、サイクルタイムは徐々に短縮され始めました。7月のある週には150時間を超えることもありましたが、それ以外の週はほぼ30〜40時間程度に改善しています。 図10. 2024年2月から8月までのサイクルタイム分析 同様の傾向はレビューサマリでも確認できます。以下の図は2月から8月までのレビューサマリです。7月に若干の上昇が見られますが、オープンからレビューまでの平均時間およびレビューからアプルーブまでの平均時間は減少傾向にあります。また、変更行数の平均も以前は400行を超えるものがありましたが、現在はおおよそ100〜150行程度に収まっています。 図11. 平均変更行数および平均レビュー時間の推移 取り組みに関するメンバーからのフィードバック ここまで紹介してきた開発生産性の向上の取り組みについて、メンバーからのフィードバックをアンケートで収集しました。ここでは、その中からいくつかの意見を紹介します。 ポジティブだったこと レビューを優先する取り組みやPRの粒度を小さくする取り組みが効果的だったという意見が多く寄せられました。具体的には、レビュー速度が向上したことで開発者体験が改善されたという意見がありました。また、PRの粒度を小さくすることでレビューに対する心理的負担が軽減し、レビュー時間も短縮されたと感じる人が多かったようです。さらに、PoCの導入についても好印象を持つ人も多く、事前に実装イメージを共有することで、PRレビューがスムーズに進んだという意見がありました。 また、Findy Team+は様々な指標やKPTふりかえりなどの機能を提供するため、Findy Team+だけで開発生産性を向上できたことが良かったという意見がありました。特にふりかえり会を行うことにポジティブな意見が多数ありました。ふりかえり会を通じて現在の課題を確認し、チーム全体で改善意識が高まったとの声がありました。他にもPRのサイクルタイムが可視化されたことで、時間短縮に向けた取り組みが促進されたという意見もありました。さらに、Findy Team+のスコアの向上がモチベーションとなり、継続的な改善への意欲が向上したという意見もありました。 ネガティブだったこと 一方で、品質への配慮が不十分であったとの懸念もありました。例えば、開発速度や時間短縮にフォーカスするあまり、十分なレビューが行われていないという懸念や、テストカバレッジレポートのような品質を数値化して注視する仕組みが必要であるという意見がありました。また、Findy Team+のスコア向上を意識した動き方に疑問を持つ声もありました。例えば、夕方にPRをオープンするとレビュー担当者がすでに退勤しているため、PRのオープンを翌日に行うといった動き方です。 品質への配慮が不十分であるとの懸念に対しては、策定したGoガイドラインに基づいたLinterを用意するなど、コードの一貫性と品質を向上させる仕組みをさらに整備していく予定です。Findy Team+のスコア向上を意識した動き方に関しては、スコア向上を目指す際にどのような行動が適切かをチームで定義し、実質的な開発生産性の向上につながる取り組みを進めていきたいと考えています。 まとめ 会員基盤ブロックが約半年間にわたって実施してきた開発生産性の向上の取り組みについて紹介しました。これらの取り組みにより、サイクルタイムは平均200時間を超えていたものが、平均41.5時間程度まで短縮されました。また、サイクルタイムの短縮だけではなく、テストカバレッジレポートの仕組みの構築やGoガイドラインの策定など、品質向上に関する取り組みも行えました。 しかし、Findy Team+のスコアはまだ十分に高いとは言えず、改善の余地があります。特に、レビューからアプルーブまでの時間は他チームと比べて依然として時間がかかっています。今後も定期的なふりかえり会やKPTを続け、さらに開発生産性の向上を目指していきたいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは。データシステム部/推薦基盤ブロックの佐藤 ( @rayuron ) です。私たちはZOZOTOWNのパーソナライズを実現する推薦システムを開発・運用しています。推薦システムごとにKPIを策定していますが、データの欠損やリリース時の不具合によってKPIが意図しない値を取ることがあるため定常的に確認する必要があり、これをKPIのモニタリングと呼んでいます。 先日、 推薦システムの実績をLookerでモニタリングする というテックブログで推薦システムのKPIをモニタリングする方法を紹介しましたが、運用していく中でいくつかの課題が見えてきました。本記事では、より効率的かつ効果的なKPIのモニタリングを実現するための取り組みについて詳しくご紹介します。 はじめに 改善の背景と課題 背景 課題 トレンドを考慮した異常検知が不可能 モニタリングの設定が面倒 アラート対応フローが不明確 サマリの定期配信が形骸化 課題解決のアプローチ 異常検知の自動化 KPI集計パイプラインの構築 モニタリングパイプラインの構築 Prophetの採用 設定の簡素化 アラート対応フローの整備 ダッシュボードを見る会の運用 ダッシュボードを見る会の進め方 効果 動的な閾値で異常値を検知できるようになった モニタリングの設定が簡単になった アラート対応の属人化の解消 異常値の取りこぼしの削減 課題と展望 偽陰性の防止 異常検知モデルの精度のモニタリング アラートの原因分析の自動化 おわりに 改善の背景と課題 背景 推薦基盤ブロックでは、ZOZOTOWNのトップページやメール配信などにおいて、ユーザーに最適なアイテムを推薦するシステムを開発・運用しています。弊チームで運用している推薦システムの具体例は以下の記事を参照してください。 techblog.zozo.com cloud.google.com techblog.zozo.com 元々、KPIのモニタリングはLookerを用いて各指標の変化を確認するか、Looker Studioで作成されたダッシュボードはあるが定常的にモニタリングされていない、というどちらかの状態でした。 課題 冒頭で紹介した 推薦システムの実績をLookerでモニタリングする で挙げた様に、既存のモニタリングシステムには以下のような課題がありました。 トレンドを考慮した異常検知が不可能 モニタリングの設定が面倒 アラート対応フローが不明確 サマリの定期配信が形骸化 トレンドを考慮した異常検知が不可能 これまで運用されていた異常検知は、固定の閾値でKPIの異常を判定していました。固定の閾値では、時系列のトレンドを捉えきれず、KPIの異常値を正しく検知できませんでした。例えばZOZOTOWNではZOZOWEEKなどのセールイベントが定期的に開催されているため、こういったイベントによるKPIの変化を考慮した異常検知が必要でした。 モニタリングの設定が面倒 以下で示す様にモニタリングのための設定はYAMLファイルを使って管理していましたが、指標ごとに閾値を設定する必要があり非常に手間がかかっていました。閾値設定は担当者の主観的な判断に基づいて行われることが多く、客観的な根拠に基づいた設定が難しいという問題もありました。 - lookml_dashboard_id : kpi_monitoring_shoes::shoes_recommendation alerts : - lookml_link_id : visit/open cron : "0 10 * * *" name : mail_result.visit_per_open # <- 指標ごとの設定が面倒 lower : 0.1 # <- 下限閾値の設定が面倒 upper : 0.5 # <- 上限閾値の設定が面倒 - lookml_link_id : recommend_generation cron : "0 10 * * *" name : generation # <- 指標ごとの設定が面倒 lower : 0.01 # <- 下限閾値の設定が面倒 upper : 0.1 # <- 上限閾値の設定が面倒 field_name : member.age_tier field_value : "19 to 22" ... アラート対応フローが不明確 KPIの異常値を検知するアラートが発生した場合、担当者はアラートがなぜ引き起こされたのかを確認し適切な対応をする必要があります。しかし明確な手順が定まっていないため、対応者がシステムを作った人に限られてしまうという属人化の問題がありました。 サマリの定期配信が形骸化 メール配信数やクリック率などの数値が羅列されたサマリをSlackで定期配信していましたが、次第に形骸化し最終的にはほとんど誰も目を通さなくなってしまいました。 課題解決のアプローチ これらの課題を解決するため、以下のアプローチを採用しました。 異常検知の自動化 アラート対応フローの整備 ダッシュボードを見る会の運用 それぞれのアプローチについて詳しく説明します。 異常検知の自動化 KPI集計パイプラインの構築 これまでKPIの集計はスプレッドシートやBigQueryのスケジュール実行で行っていましたが、Vertex AI Pipelinesへ移行しました。弊社ではVertex AI Pipelinesの実行・スケジュール登録・CI/CD・実行監視等をテンプレート化した、GitHubのテンプレートリポジトリを運用しているため、このリポジトリを利用することで簡単にVertex AI Pipelinesの実行環境を導入できます。KPI集計のジョブは以下のように構成されています。 パイプラインではKPIの集計に加えて、後続のモニタリングのジョブのためにデータを整形します。集計されたKPIは以下の形式でBigQueryに保存されます。 日付 CTR CVR 2024-01-01 0.2 0.3 2024-01-02 0.3 0.4 2024-01-03 0.4 0.3 モニタリングパイプラインの構築 KPI集計と同じくVertex AI Pipelinesを使用して異常検知システムを構築しました。KPIをモニタリングするパイプラインは1日1回、KPI集計が完了した後に実行されます。以下のようなパイプラインが構築されています。 異常検知には、次のセクションで説明する Prophet と呼ばれるライブラリを使用し、検知した指標名やグラフと一緒にSlackメッセージを通知します。 Prophetの採用 私たちは、異常検知を自動化するためにProphetを採用しました。Prophetは単一の時系列データでトレンドを考慮した予測と 不確定区間 の計算ができるため、これを異常検知に利用します。具体的にはKPI集計パイプラインで保存されたBigQueryのテーブルにおいて、最新の日付の不確定区間を計算し、測定された値が不確定区間の外側にある場合、異常値として検知します。 キャンペーンやセールなどのイベント情報をモデルに組み込むことで、イベントの影響を考慮した上で異常検知ができます。また、イベント情報をProphetに与えることで、イベントを考慮した異常検知が可能になります。以下は イベント情報をProphetに与える例 です。 playoffs = pd.DataFrame({ 'holiday' : 'playoff' , 'ds' : pd.to_datetime([ '2008-01-13' , '2009-01-03' , '2010-01-16' , '2010-01-24' , '2010-02-07' , '2011-01-08' , '2013-01-12' , '2014-01-12' , '2014-01-19' , '2014-02-02' , '2015-01-11' , '2016-01-17' , '2016-01-24' , '2016-02-07' ]), 'lower_window' : 0 , 'upper_window' : 1 , }) superbowls = pd.DataFrame({ 'holiday' : 'superbowl' , 'ds' : pd.to_datetime([ '2010-02-07' , '2014-02-02' , '2016-02-07' ]), 'lower_window' : 0 , 'upper_window' : 1 , }) holidays = pd.concat((playoffs, superbowls)) m = Prophet(holidays=holidays) 設定の簡素化 Prophetを使用することで動的な閾値を設定できるようになります。さらに、これまでは指標単位でモニタリングの設定を記述していましたが、テーブル単位で記述することによって設定が簡素化されました。具体的には以下のようなYAMLファイルで記述される設定を使用します。 kpi_monitoring_ma_personalize_shoes_parameters : monitor_table_id : 'project_id.dataset_id.table_id' date_field : send_date freq : D expected_interval_days : 2 plot_periods : 7 start_date : '2023-01-01' exclusion_dates : - '2024-06-26' exclusion_fields : - campaign_id dashboard_link : https://lookerstudio.google.com/dashboard interval_width : 0.99 各項目の説明は以下の通りです。 パラメータ 説明 monitor_table_id モニタリング対象のBigQueryのテーブルID date_field 日付列名 freq モニタリングを実施する頻度 expected_interval_days 意図した集計間隔 plot_periods 通知時にx軸にプロットする期間 start_date モニタリングを開始する日付 exclusion_dates モニタリング対象から除外する日付のリスト exclusion_fields モニタリング対象から除外する列名のリスト dashboard_link ダッシュボードのリンク interval_width 不確定区間の幅 アラート対応フローの整備 アラート対応フローを整備することで、対応の属人化を解消しました。このワークフローはアラートが発生した場合、担当者がどのような手順で対応すればいいのかを明確に示すものです。以下のようなアラート対応のワークフローを設定しました。以下で具体的に説明します。 アラートが発生した時、パイプラインは以下のような画像を含むSlackメッセージを通知します。 通知を受け取った後は具体的な原因を調査します。以下の表に示す3つの原因に分類し、それぞれの原因に対する対応方針を明確にしました。 原因 対応 データ データの欠損や品質の低下が原因と考えられる場合、データ連携の確認やデータ品質の改善を試みます。 モデル モデルの誤動作やパフォーマンスの低下が原因と考えられる場合、モデルのチューニングを行います。 アラートの設定 アラートの設定に誤りがある場合や指標の分散が大きく予測が不確実な場合は、アラート設定の見直しを行います。 異常値をProphetの学習データから除外するため、異常値として捉えた日付を exclusion_dates として設定に追加します。これまでのアラートの対応内容はドキュメント化し次回同様のアラートが発生した際、参照できるようにします。 ダッシュボードを見る会の運用 これまでご紹介した異常値の自動検知の取りこぼしを防ぐため、チーム全員でダッシュボードを定期的に確認するミーティングを運用しています。先述の通り、元々行っていたサマリの定期配信は強制力のある方法ではなかったため形骸化しました。そこでSlackでのサマリの定期配信は廃止し、週1回ダッシュボードを見る会を開催することで積極的にKPIの状況を把握する体制を構築しました。 ダッシュボードを見る会の進め方 ダッシュボードを見る会は毎週1回20分のミーティングで以下の手順に従って進行します。 ファシリテーターを中心にダッシュボードを見て、先週から ±N% 以上の変化率か、グラフの形状が大きく変わったかを確認 大きな変化がある場合、祝日、イベント、障害が発生していないか等原因を調査 時間内に原因解明が出来なかった場合は、ファシリテーターが問題をチケット化し調査タスクをバックログへ積む 具体的には以下のようなダッシュボードをモニタリングしています。 効果 今回の取り組みによって、以下の効果が得られました。 動的な閾値で異常値を検知できるようになった Prophetを用いることで季節変動やイベントの影響を考慮した動的な閾値を設定できるようになりました。これにより誤検知を減らし、真の異常値を検知できるようになりました。 モニタリングの設定が簡単になった これまで指標ごとに閾値を設けモニタリングを行っていましたが、閾値を自動で決めることやテーブル単位で設定項目をまとめることで設定が簡素化されました。 アラート対応の属人化の解消 アラート対応フローを整備しワークフローに従って対応することで、担当者の知識や経験に依存せず誰でも対応できるようになりました。 異常値の取りこぼしの削減 ダッシュボードを見る会を通してKPIのトレンドや全体的な傾向を把握できるようになりました。これにより自動検知での異常値の取りこぼしを削減しました。6月中旬から9月中旬まで運用した中で、データの欠損や集計定義の異常等に関する5つの異常値を検知しました。 課題と展望 偽陰性の防止 現在どこまでを異常値として捉えるかをinterval_widthというパラメータで調整しています。KPIモニタリングシステムを運用してみるまでどこまでを異常値として捉えるかは運用者であっても不明な場合が多いためこのパラメータには一定の初期値を設定します。一方でKPIモニタリングシステムは異常値でないと判定したが、実際には異常値であった場合を検知できないというシステム特有の問題があります。定期的にinterval_widthを調整することやダッシュボードを見る会での議論を通じて偽陰性を防止する取り組みを行うつもりです。 異常検知モデルの精度のモニタリング KPIをモニタリングする異常検知モデルの精度が低下していることに気づくためには、異常検知モデルのメトリクスをモニタリングする必要があります。また、この際にはモニタリングの連鎖という問題に上手く対処する必要があると考えています。 アラートの原因分析の自動化 KPIのモニタリングシステムを運用していて一番時間がかかるのは、人によるアラートの原因分析です。データの欠損やメールの配信数制限には指標の変化にパターンが存在するので、アラートの変化に対応して原因の候補を自動で提案する仕組みを構築することで、アラートの原因分析の時間を短縮できると考えています。 おわりに 本記事では推薦システムのKPIのモニタリング自動化と運用体制の整備についてご紹介しました。今後もより効率的で効果的なモニタリングシステムの構築を目指して、改善を続けていきます。 ZOZOでは一緒にサービスを作り上げてくれる方を募集しています。ご興味がある方は以下のリンクから是非ご応募ください! corp.zozo.com
アバター
はじめに こんにちは。株式会社ZOZOのSRE部プラットフォームSREチームに所属している はっちー と申します。 本記事では、Kubernetesクラスター上にモックリソースをサクッと構築する「モック構築ツール」を紹介します。ZOZOの事例をもとにした説明となりますが、Kubernetesクラスター上での負荷試験やフロントエンド開発などの効率化において広く一般的に活用できるツールのため、OSSとして公開しています。GitHubリポジトリは以下です。 github.com 本ツールは、私個人のOSSとして管理しています。ZOZOでは、社員がOSS活動しやすいように、「業務時間中に指示があって書いたソフトウェアでも著作権譲渡の許諾によって個人のものにできる」というOSSポリシーがあります。ありがたいです。 techblog.zozo.com 目次 はじめに 目次 モック構築ツールとは 開発のきっかけ ZOZOでモック構築ツールが役立つ場面 負荷試験で依存先マイクロサービスに多くの負荷がかかる場面 stg環境の競合回避 APIクライアント側の開発 使い方 前提(ツール実行側) Step1. OpenAPI仕様書のコピー Step2. AWSとKubernetesの接続設定 Step3. パラメーター設定 Step4. モックリソースの構築 Step5. (任意)レイテンシーの設定 Step6. (任意)テストリクエスト Step7. 負荷試験 Step8. モックリソースの削除 実装紹介 なぜGoで実装したか ディレクトリ構成 Goコード以外 Goコード パラメーター処理と初期化処理 main関数 DockerイメージとECRの構築と削除 Kubernetesリソースの構築と削除 Istioリソースの構築と削除 今後の展望 追加開発 使ってもらう まとめ We are hiring モック構築ツールとは モック構築ツールは、Kubernetesクラスター上でマイクロサービスのモックリソースを構築し、負荷試験やフロントエンド開発などを効率化するツールです。モックは、APIなどの処理を模倣するものです。OSSの Prism を利用しており、モックサーバーは OpenAPI で定義された仕様書における正常系のexample値でレスポンスを返します。本ツールは、単にPrismのPodをKubernetesクラスター上に構築するだけではありません。 Amazon Elastic Container Registry (以降、ECR)や一連のKubernetesリソースをコマンド一発で構築します(事前作業あり)。詳細は後述します。また、モックは1つのマイクロサービスだけでなく、複数構築できます。 開発のきっかけ あるマイクロサービスの負荷試験で大きい負荷をかけた際に、依存先となるマイクロサービスにさらに依存するマイクロサービスや外部システムへ大量にリクエストが飛んでしまい、問題となったことがきっかけです。自チームの管轄マイクロサービスであれば影響範囲は予測できますが、他チームの管轄マイクロサービスとなると、さらにその先の影響範囲を把握するのは困難です。また、負荷試験のたびに各SREチームへ影響範囲を確認しあうのも現実的ではありません。 そこで、負荷試験時にモックが欲しいという考えになりました。しかし、Kubernetesクラスター上にマイクロサービスのモックを用意するのは、面倒な作業です。加えて、負荷試験は開発完了後のリリース日が迫った段階で実施されるケースがほとんどで、試験期間は限られています。したがって、なるべく工数をかけずに、誰でも均一的な方法で手軽にマイクロサービスのモックを用意する仕組みが社内に必要だと感じました。そこで、モック構築ツールを開発することにしました。 ZOZOでモック構築ツールが役立つ場面 負荷試験で依存先マイクロサービスに多くの負荷がかかる場面 きっかけとなった場面になりますが、もう少し詳細に説明します。 前提として、ZOZOでは負荷試験を検証環境(以降、stg環境)で行います。stg環境では、すべてのマイクロサービスが本番環境(以降、prd環境)と同じスペックのPodで動作しています。しかし、インフラコストの観点から、Pod数はprd環境よりも大幅に少ないです。また、負荷試験の対象となるマイクロサービスのPod数は1で固定しており、オートスケーリングもしないようにしています。 負荷試験では、試験対象のマイクロサービスが短時間に大量のリクエストを実行します。試験対象のマイクロサービスが別のマイクロサービスに依存している場合、実行するAPIによってはそちらにもリクエストが流れます。流れるリクエスト量が多くない場合は、stg環境で起動している、依存先のマイクロサービスのPodにそのままリクエストが流れても支障はないです。一方、流れるリクエスト量が多い場合は、リクエストを捌ききれなくなり、負荷試験に支障がでます。依存マイクロサービスはオートスケーリングしますが、スケーリングが完了するまでに多少の時間はかかるため、正確に性能を計測できません。したがって、依存するマイクロサービスを事前にスケールアップする必要があります。 しかしながら、マイクロサービスの数が多いと事前スケーリングの対象が多くて作業が大変ですし、自チームの管轄外マイクロサービスに関しては、その担当SREチームとの調整コストが発生します。また、「1PodあたりCPU使用率50%で捌ける限界のスループットを計測する」という試験項目があるため、試験前に負荷の程度を見積もって伝えるのが難しいです。少しずつ増強依頼となると調整が大変ですし、大雑把に増強しすぎるとムダなインフラコストが発生してしまいます。さらに、ZOZOでない外部のシステムに依存している場合は、より調整が困難になります。このようなケースでモックは非常に役立ちます。 stg環境の競合回避 ZOZOでは1つのstg環境で複数のSREチームが複数のプロジェクトに関する負荷試験や障害試験を実施しています。したがって、しばしばstg環境では複数の試験タイミングが重複してしまいます。もし、同時に試験を実施してしまうと、お互いの試験が影響してしまい、正しく試験ができません。現状は担当者間の調整で回避していますが、負荷試験ではモック構築ツールでモックを用意するようになれば、調整コストを削減できます。 APIクライアント側の開発 マイクロサービスのOpenAPI仕様設計が完了すれば、Kubernetesクラスター上にモックを構築できます。したがって、フロントエンドなどのAPIクライアント側はそのマイクロサービスの開発完了を待つ必要なく、クラウド環境上で自分たちの開発や動作確認を進めることができます。 使い方 前提(ツール実行側) AWSとKubernetesの認証情報が設定済み 以下がインストール済み Go v1.22.5での動作は確認済み aws-cli 完全に aws-sdk-go へ移行できていないため kubectl VirtualServiceやテストリクエストで利用 必須でない Docker イメージビルドなどをするため Step1. OpenAPI仕様書のコピー openapi.yaml にOpenAPIの仕様をコピー&ペーストします。 ZOZOでは、ほぼすべてのマイクロサービスのAPIはOpenAPIで定義されています。社内では GiHub Pages 上で公開されています。 Step2. AWSとKubernetesの接続設定 リソースを構築するAWSとKubernetesの接続設定をします。たとえば、 awsp と kubie を使っている場合は以下です。 awsp < your_profile > kubie ctx < your_context > Step3. パラメーター設定 params.go でパラメーターを設定します。現状は、ハードコーディングで設定する必要があります。ツール利用者は最低限、以下のパラメーター設定が必要です。 microserviceName モックにするマイクロサービスの名前 e.g. zozo-member-api microserviceNamespace モックにするマイクロサービスのネームスペース e.g. zozo-member Step4. モックリソースの構築 以下のコマンドを実行します。 make run-create コマンド一発で、以下のリソースがAWSとKubernetesクラスター上に構築されます。 AWS ECR Kubernetes Namespace Deployment Service VirtualService Step5. (任意)レイテンシーの設定 この手順は任意です。 現状のままですと、モックは即座にAPIレスポンスを返します。これでは負荷試験のモックとしては不十分な場合があります。なぜならば、実際のAPIではDBアクセスなどのさまざまな処理をした後にレスポンスを返すため、レイテンシーが少なからず発生するからです。負荷試験用のモックとしては、このレイテンシーも考慮してAPIレスポンスを返すようにした方がよいです。そこで、Istioの Fault Injection 機能を利用して、固定時間の遅延を発生させるようにします。具体的には、ツールで構築されたVirtualServiceリソースをeditして、 spec.http.fault.delay.fixedDelay にレイテンシーの値を設定します。 kubectl edit VirtualService -n example-namespace example-vs apiVersion : networking.istio.io/v1alpha3 kind : VirtualService metadata : name : example-vs spec : hosts : - example-service.example-namespace.svc.cluster.local http : - name : example1 match : - uri : prefix : /example1/ method : exact : GET fault : delay : percentage : value : 100.0 fixedDelay : 0.1s # here route : - destination : host : example-service.example-namespace.svc.cluster.local - name : default route : - destination : host : example-service.example-namespace.svc.cluster.local 当然ながらレイテンシーはAPIごとに異なるため、負荷試験のシナリオ中で実行されるAPIごとに上記の設定をします。すでにprd環境でリリース済みのAPIであれば、prd環境のデータを分析して実際のレイテンシーを設定します。ZOZOでは、DatadogからAPIごとのp95レイテンシーの値を簡単に確認できるようになっているため、それを利用します。 正直なところ、この設定作業は負担が大きいので、一部自動化する予定です。なお、試験的に問題なければレイテンシーを設定しなくても構いませんし、全API一律でdefaultセクションにレイテンシーを設定しても構いません。 Step6. (任意)テストリクエスト この手順は任意です。 モックリソースが構築できたら、テストリクエストをします。たとえば、Kubernetesクラスター内に適当なPodを起動して、モックに対してcurlでAPIリクエストを実行します。OpenAPIの仕様に即したレスポンスが返ることを確認できます。 kubectl run tmp- $( date " +%Y%m%d-%H%M%S " ) -hacchi --image yauritux/busybox-curl:latest -n api-gateway --annotations =" sidecar.istio.io/inject=true " --rm -it -- sh -c " curl -v -m 10 http://zozo-member-api-prism-mock.zozo-member-prism-mock.svc.cluster.local/internal/members/1 " ... * Request completely sent off < HTTP/ 1 . 1 200 OK < access-control-allow-origin: * < access-control-allow-headers: * < access-control-allow-credentials: true < access-control-expose-headers: * < content-type: application/json < content-length: 467 < date: Mon, 22 Jul 2024 10:09:48 GMT < x-envoy-upstream-service-time: 6 < server: envoy < * Connection #0 to host zozo-member-api-prism-mock.zozo-member-prism-mock.svc.cluster.local left intact { " id " :1, " email " : " taro.tanaka@zozo.com " , " zozo_id " : " tanaka " , " has_password " :false, " last_name " : " 田中 " , " first_name " : " 太郎 " , " last_name_kana " : " タナカ " , " first_name_kana " : " タロウ " , " gender_id " :1, " birthday " : " 2004-12-15 " , " zipcode " : " 1020094 " , " prefecture_id " :1, " address " : " 千代田区紀尾井町1-3 " , " address_building " : " 東京ガーデンテラス紀尾井町 紀尾井タワー " , " phone " : " 0120-55-0697 " , " zozo_employee_id " : " 1 " , " registered_at " : " 2004-12-15T12:00:00+00:00 " } pod " tmp-20240722-190922-hacchi " deleted Step7. 負荷試験 接続先情報をモックのServiceに切り替えて、負荷試験を実施します。 Step8. モックリソースの削除 負荷試験が完了したら、モックリソースをすべて削除します。 make run-delete 以上で、使い方の説明は終了です。 実装紹介 なぜGoで実装したか 本ツールはGoで実装しました。Goを選択した理由は以下の通りです。 社内推奨プログラミング言語の1つのため。 開発者である私がもっとも使い慣れた言語であるため。 AWSやKubernetesのライブラリが豊富であり、社内でも使用実績があったため。 なお、社内のインフラ関連のツールはほとんどがシェルスクリプトで実装されているため、シェルスクリプトでの実装も選択肢にありました。しかし、上記の理由に加えて、YAMLファイルを読み取る機能を開発するにあたって、主観ではGoの方が書きやすそうと感じました。また、シェルスクリプトだと利用者に yq をインストールしてもらう必要がありそうなため、それを避けました。 ディレクトリ構成 まず、全体像としてディレクトリ構成を示します。 とくに、特別な点はありません。そこまで複雑なツールではないため、今のところはmainパッケージのみにしています。Goファイルは、 main.go 、 ecr.go 、 k8s.go 、 istio.go 、 params.go 、 action_type.go 、 lib.go の7つです。その他には、 Makefile 、 openapi.yaml 、 Dockerfile.prism があります。 Goコード以外 説明のしやすさから、Goコード以外の説明から始めます。 簡単にツールの実行ができるように Makefile を用意しています。基本的には、 make run-create (モックリソースの構築)および make run-delete (モックリソースの削除)を実行します。実行時に作成されるワンバイナリは残さないようにしています。また、開発用に make deps (依存モジュールのダウンロード)も用意しています。 BINARY_NAME =prism-mock GO =go build: $(GO) build -o $(BINARY_NAME) . run-create: build ./ $(BINARY_NAME) -action create $(MAKE) clean run-delete: build ./ $(BINARY_NAME) -action delete $(MAKE) clean clean: $(GO) clean rm -f $(BINARY_NAME) deps: $(GO) mod download モック対象マイクロサービスのAPI仕様を openapi.yaml にコピー&ペーストします。Prismはこのファイルを元にモックサーバーを起動します。 Prismの Dockerfile.prism は以下の通りです。イメージは stoplight/prism:5.8.2 を指定しています。 openapi.yaml をCOPYします。mockコマンドでPrismを起動します。 FROM stoplight/prism:5.8.2 COPY ./openapi.yaml /app/openapi.yaml CMD ["mock", "-h", "0.0.0.0", "-p", "80", "/app/openapi.yaml"] Goコード パラメーター処理と初期化処理 パラメーターの管理は、 params.go で処理しています。設定可能なパラメーターは以下です。 microserviceName=zozo-aggregation-api の場合、構築されるリソース名は zozo-aggregation-api-prism-mock となります。 Parameter Name Description default required microserviceName モックにするマイクロサービス名 "" Yes microserviceNamespace モックにするマイクロサービスのネームスペース名 "" Yes prismMockSuffix リソース名のサフィックス "-prism-mock" Yes prismPort Prismコンテナーのポート番号 "80" Yes prismCPU PrismコンテナーのCPUリクエスト "1" Yes prismMemory Prismコンテナーのメモリリクエスト "1Gi" Yes istioProxyCPU istioサイドカーコンテナーのCPUリクエスト "500m" Yes istioProxyMemory istioサイドカーコンテナーのメモリリクエスト "512Mi" Yes timeout ツールの実行タイムアウト時間 10 * time.Minute Yes ecrTagEnv ECRのCostEnvタグの値 "stg" No main.go のinit関数で以下の初期化処理を行います。 openapi.yaml のデータ取得と空チェック パラメーターのバリデーションチェック コマンドライン引数(アクションパラメーター)の取得 AWSとKubernetesの設定 リソース名の作成 main.goのコードを見る var ( action actionType awsConfig aws.Config awsAccountID string kubeConfig *restclient.Config resourceName string namespaceName string ) func init() { //empty check for openapi.yaml data, err := os.ReadFile( "openapi.yaml" ) if err != nil { panic (err) } if len (data) == 0 { panic ( "openapi.yaml is empty" ) } // validation parameters err = validateParams() if err != nil { panic (err) } // action parameter var actionStr string flag.StringVar(&actionStr, "action" , "create" , "create or delete(default: create)" ) flag.Parse() parsedAction, err := validateActionType(actionStr) if err != nil { panic (err) } action = parsedAction // AWS config awsConfig, err = config.LoadDefaultConfig(context.Background()) if err != nil { panic (fmt.Errorf( "failed load AWS config: %v" , err)) } // get AWS account ID stsClient := sts.NewFromConfig(awsConfig) result, err := stsClient.GetCallerIdentity(context.Background(), &sts.GetCallerIdentityInput{}) if err != nil { panic (fmt.Errorf( "failed to get caller identity: %v" , err)) } awsAccountID = *result.Account // kube config kubeconfigPath := clientcmd.NewDefaultPathOptions().GetDefaultFilename() kubeConfig, err = clientcmd.BuildConfigFromFlags( "" , kubeconfigPath) if err != nil { panic (fmt.Errorf( "failed to build kubeconfig: %v" , err)) } // resource name resourceName = microserviceName + prismMockSuffix namespaceName = microserviceNamespace + prismMockSuffix } main関数 main.go のmain関数では、リソース構築と削除に関するさまざまな処理を呼び出しています。リソース構築の場合は、buildAndPushECR関数とcreateK8sResources関数とcreateIstioResources関数を順に実行します。リソース削除の場合は、deleteIstioResources関数とdeleteK8sResources関数とdeleteECR関数を順に実行します。いずれも、エラーが発生した場合はpanicします。contextパッケージでタイムアウト設定しています。 main.goのコードを見る func main() { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if action == create { err := buildAndPushECR(ctx) if err != nil { panic (err) } err = createK8sResources(ctx) if err != nil { panic (err) } err = createIstioResources(ctx) if err != nil { panic (err) } log.Println( "[INFO] All resources for prism mock are created successfully" ) } else if action == delete { err := deleteIstioResources(ctx) if err != nil { panic (err) } err = deleteK8sResources(ctx) if err != nil { panic (err) } err = deleteECR(ctx) if err != nil { panic (err) } log.Println( "[INFO] All resources for prism mock are deleted successfully" ) } } main関数の処理の流れを図にすると以下の通りです。 DockerイメージとECRの構築と削除 ecr.go ではDockerイメージのビルドやECRの構築と削除をします。buildAndPushECR関数は、Dockerイメージをビルドして、ECRを構築してプッシュします。deleteECR関数では、ECRを削除します。なお、現状はECRログインの箇所で aws-cli を実行してしまっているので、 aws-sdk-go を使って改修する予定です。また、ECRだけでなく他のコンテナレジストリにも対応する予定です。 すでに同名のリソースが存在する状態で構築リクエストをするとWARNログを出力しますがエラーにはなりません。同様に、指定リソースが存在しない状態で削除リクエストをするとWARNログを出力しますがエラーにはなりません。これは、 ecr.go 、 k8s.go 、 istio.go のすべてで同じ仕様です。 ecr.goのコードを見る func buildAndPushECR(ctx context.Context) error { // build Docker image imageTag := microserviceName + ":latest" cmd := exec.Command( "docker" , "build" , "-f" , "Dockerfile.prism" , "-t" , imageTag, "." ) if err := cmd.Run(); err != nil { return fmt.Errorf( "failed to build docker image: %v" , err) } log.Println( "[INFO] Docker image is built successfully" ) // create ECR repository ecrClient := ecr.NewFromConfig(awsConfig) repositoryName := resourceName input := &ecr.CreateRepositoryInput{ RepositoryName: aws.String(repositoryName), Tags: []types.Tag{ { Key: aws.String( "CostEnv" ), Value: aws.String(ecrTagEnv), }, { Key: aws.String( "CostService" ), Value: aws.String(microserviceName), }, }, } _, err := ecrClient.CreateRepository(ctx, input) if err != nil { var ecrExistsException *types.RepositoryAlreadyExistsException if !errors.As(err, &ecrExistsException) { return fmt.Errorf( "failed to create ECR repository: %v" , err) } log.Println( "[WARN] The ECR already exists" ) } else { log.Println( "[INFO] ECR is created successfully" ) } // tag Docker image for ECR ecrImageTag := fmt.Sprintf( "%s.dkr.ecr.%s.amazonaws.com/%s:latest" , awsAccountID, awsConfig.Region, repositoryName) cmdTag := exec.Command( "docker" , "tag" , imageTag, ecrImageTag) if err := cmdTag.Run(); err != nil { return fmt.Errorf( "failed to tag image: %v" , err) } log.Println( "[INFO] Docker image tagged successfully" ) // login to ECR loginCommand := fmt.Sprintf( "aws ecr get-login-password --region %s | docker login --username AWS --password-stdin %s.dkr.ecr.%s.amazonaws.com" , awsConfig.Region, awsAccountID, awsConfig.Region) cmdLogin := exec.Command( "bash" , "-c" , loginCommand) if err := cmdLogin.Run(); err != nil { return fmt.Errorf( "failed to log in ECR: %v" , err) } log.Println( "[INFO] Logged in ECR successfully" ) // push image to ECR cmdPush := exec.Command( "docker" , "push" , ecrImageTag) if err := cmdPush.Run(); err != nil { return fmt.Errorf( "failed to push image to ECR: %v" , err) } log.Println( "[INFO] Docker image is pushed to ECR successfully" ) return nil } func deleteECR(ctx context.Context) error { // Delete ECR ecrClient := ecr.NewFromConfig(awsConfig) repositoryName := resourceName input := &ecr.DeleteRepositoryInput{ RepositoryName: aws.String(repositoryName), Force: true , // Force delete to remove all images } _, err := ecrClient.DeleteRepository(ctx, input) if err != nil { var ecrNotFoundException *types.RepositoryNotFoundException if !errors.As(err, &ecrNotFoundException) { return fmt.Errorf( "failed to delete ECR: %v" , err) } log.Println( "[WARN] The ECR is not found" ) } else { log.Println( "[INFO] ECR is deleted successfully" ) } return nil } Kubernetesリソースの構築と削除 k8s.go ではKubernetesリソースの構築と削除をします。 kubernetes/client-go を使用しています。createK8sResources関数では、対象KubernetesクラスターのIstioバージョンを取得し、Namespace、Deployment、Serviceを構築します。deleteK8sResources関数では、Service、Deployment、Namespaceを削除します。 Istioバージョンの取得は、 istio-system ネームスペース上で動作する app: istiod ラベルの付いたPod(istiod)の istio.io/rev ラベル値から取得します。このラベル値は 1-21-4 などのハイフン繋ぎのものになります。この時、もしKubernetesクラスター上でIstioのバージョンアップグレード作業中であれば、2つのバージョンのPodが存在します。そこで、それらのうち最新バージョンを返すgetLatestVersion関数を実装しました。getLatestVersion関数は、x-y-z形式のバージョンリストを受け取り、parseVersion関数でx-y-z形式のバージョンを数値のスライスに変換します。そして、compareVersions関数でメジャーバージョンから順に大小比較し、最終的に新しい方のバージョンを返します。このように、本ツールはIstioのバージョンアップグレード作業中も問題なく動作するよう工夫しています。なお、取得したIstioバージョンは、Namespaceの istio.io/rev ラベルに使用します。バージョン情報をうまく取得できなかった場合はエラーにせず、空で処理を続行します。 Deploymentでは、PodにはIstioのサイドカーを注入しています。メインコンテナーのイメージは構築したECRのものを指定しています。 k8s.goのコードを見る func createK8sResources(ctx context.Context) error { // create clientset using kubeconfig clientset, err := kubernetes.NewForConfig(kubeConfig) // ... // get the latest istio version from istiod pod considering during upgrade podList, err := clientset.CoreV1().Pods( "istio-system" ).List(ctx, metav1.ListOptions{ LabelSelector: "app=istiod" , }) //... hyphenedVersions := [] string {} for _, item := range podList.Items { hyphenedVersions = append (hyphenedVersions, item.ObjectMeta.Labels[ "istio.io/rev" ]) } latestVersion, err := getLatestVersion(hyphenedVersions) // ... // Namespace namespace := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceName, Labels: map [ string ] string { "istio.io/rev" : latestVersion, }, }, } _, err = clientset.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{}) //... // Deployment deployment := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, }, Spec: appsv1.DeploymentSpec{ Replicas: int32Ptr( 1 ), Selector: &metav1.LabelSelector{ MatchLabels: map [ string ] string { "app" : resourceName, }, }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: map [ string ] string { "app" : resourceName, }, Annotations: map [ string ] string { "sidecar.istio.io/inject" : "true" , "sidecar.istio.io/proxyCPULimit" : istioProxyCPU, "sidecar.istio.io/proxyMemoryLimit" : istioProxyMemory, "traffic.sidecar.istio.io/includeOutboundIPRanges" : "*" , "proxy.istio.io/config" : `{ "terminationDrainDuration": "30s" }` , }, }, Spec: corev1.PodSpec{ Containers: []corev1.Container{ { Name: resourceName, Image: fmt.Sprintf( "%s.dkr.ecr.%s.amazonaws.com/%s" , awsAccountID, awsConfig.Region, resourceName), Ports: []corev1.ContainerPort{ { ContainerPort: int32 (prismPort), }, }, Resources: corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse(prismCPU), corev1.ResourceMemory: resource.MustParse(prismMemory), }, }, }, }, }, }, }, } _, err = clientset.AppsV1().Deployments(namespaceName).Create(ctx, deployment, metav1.CreateOptions{}) //... // Service service := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, }, Spec: corev1.ServiceSpec{ Selector: map [ string ] string { "app" : resourceName, }, Ports: []corev1.ServicePort{ { Protocol: corev1.ProtocolTCP, Port: 80 , TargetPort: intstr.FromInt( 80 ), }, }, Type: corev1.ServiceTypeClusterIP, }, } _, err = clientset.CoreV1().Services(namespaceName).Create(ctx, service, metav1.CreateOptions{}) //... return nil } func deleteK8sResources(ctx context.Context) error { // create clientset using kubeconfig clientset, err := kubernetes.NewForConfig(kubeConfig) //... // Service err = clientset.CoreV1().Services(namespaceName).Delete(ctx, resourceName, metav1.DeleteOptions{}) //... // Deployment err = clientset.AppsV1().Deployments(namespaceName).Delete(ctx, resourceName, metav1.DeleteOptions{}) //... // Namespace err = clientset.CoreV1().Namespaces().Delete(ctx, namespaceName, metav1.DeleteOptions{}) //... return nil } func getLatestVersion(versions [] string ) ( string , error ) { if len (versions) == 0 { return "" , fmt.Errorf( "no versions provided" ) } // init max with the zero index element maxVersion := versions[ 0 ] maxVersionParts, err := parseVersion(maxVersion) if err != nil { return "" , err } // compare all versions for _, version := range versions[ 1 :] { versionParts, err := parseVersion(version) if err != nil { return "" , err } if compareVersions(versionParts, maxVersionParts) > 0 { maxVersion = version maxVersionParts = versionParts } } return maxVersion, nil } func parseVersion(version string ) ([] int , error ) { // convert "x-y-z" to [x, y, z] parts := strings.Split(version, "-" ) if len (parts) != 3 { return nil , fmt.Errorf( "invalid version format: %s" , version) } intParts := make ([] int , len (parts)) for i, part := range parts { num, err := strconv.Atoi(part) if err != nil { return nil , fmt.Errorf( "invalid number in version: %s" , part) } intParts[i] = num } return intParts, nil } func compareVersions(v1, v2 [] int ) int { // return 1 if v1 > v2, -1 if v1 < v2, 0 if v1 == v2 for i := 0 ; i < len (v1); i++ { // if just one part is greater, the version is greater if v1[i] > v2[i] { return 1 } else if v1[i] < v2[i] { return - 1 } } // if all parts are equal, the versions are equal return 0 } Istioリソースの構築と削除 istio.go ではIstioリソースの構築と削除をします。 istio/client-go を使用しています。createIstioResources関数では、VirtualServiceを構築します。deleteIstioResources関数では、VirtualServiceを削除します。 VirtualServiceの設定は、 /example1/ のGETリクエストに対して100%の確率で100msの遅延を発生させるものです。また、 /example1/ 以外のリクエストに対しては遅延を設定していません。この設定は、サンプルのようなもので、VirtualServiceを構築してから手動で編集することを想定しています。将来的には、configファイルでAPIのパス、HTTPメソッド、遅延時間などを設定できるようにし、その設定を元にVirtualServiceを自動で構築するように改修する予定です。 istio.goのコードを見る func createIstioResources(ctx context.Context) error { // Istio clientset istioClient, err := versioned.NewForConfig(kubeConfig) //... // VirtualService virtualService := &v1alpha3.VirtualService{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, }, Spec: networkingv1alpha3.VirtualService{ Hosts: [] string { resourceName + "." + namespaceName + ".svc.cluster.local" , }, Http: []*networkingv1alpha3.HTTPRoute{ { Name: "example1" , Match: []*networkingv1alpha3.HTTPMatchRequest{ { Uri: &networkingv1alpha3.StringMatch{ MatchType: &networkingv1alpha3.StringMatch_Prefix{ Prefix: "/example1/" , }, }, Method: &networkingv1alpha3.StringMatch{ MatchType: &networkingv1alpha3.StringMatch_Exact{ Exact: "GET" , }, }, }, }, Fault: &networkingv1alpha3.HTTPFaultInjection{ Delay: &networkingv1alpha3.HTTPFaultInjection_Delay{ Percentage: &networkingv1alpha3.Percent{ Value: 100.0 , }, HttpDelayType: &networkingv1alpha3.HTTPFaultInjection_Delay_FixedDelay{ FixedDelay: &duration.Duration{Nanos: int32 ( 100000000 )}, // 100ms }, }, }, Route: []*networkingv1alpha3.HTTPRouteDestination{ { Destination: &networkingv1alpha3.Destination{ Host: resourceName + "." + namespaceName + ".svc.cluster.local" , }, }, }, }, { Name: "default" , Route: []*networkingv1alpha3.HTTPRouteDestination{ { Destination: &networkingv1alpha3.Destination{ Host: resourceName + "." + namespaceName + ".svc.cluster.local" , }, }, }, }, }, }, } _, err = istioClient.NetworkingV1alpha3().VirtualServices(namespaceName).Create(ctx, virtualService, metav1.CreateOptions{}) //... return nil } func deleteIstioResources(ctx context.Context) error { // Istio clientset istioClient, err := versioned.NewForConfig(kubeConfig) //... err = istioClient.NetworkingV1alpha3().VirtualServices(namespaceName).Delete(ctx, resourceName, metav1.DeleteOptions{}) //... return nil } 今後の展望 追加開発 現時点で、以下の追加開発を予定しています。 パラメーターをハードコーディング( params.go )以外の方法で設定できるようにする。 ECRログインで、 aws-sdk-go を使用して、 aws-cli をプログラム中で使わないようにする。 ECR以外のコンテナレジストリにも対応する。 ECRリソースタグ(CostEnvとCostService)の付与はZOZO特有なのでオプションにする。 PodのAffinity設定を可能にする。 spec.affinity.nodeAffinity のmatchExpressionsのkey/value設定ができるようにする。 Istioのサイドカーコンテナーインジェクションをオプションにする(不要な場合もあるため)。 IstioのVirtualServiceの設定をconfigファイルで設定できるようにし、その設定を元にVirtualServiceを構築する。 レイテンシーの設定値をDatadogから自動で取得するようにする。 Query Time Seriesを利用できそう。 Goのクライアント もありそう。 openapi.yaml からAPIパスとHTTPメソッドの情報を抽出して、Datadogのクエリに利用する想定。 パスパラメーターの取り扱いも考慮する必要がある。 もう少し要調査。 CIを追加する。 テストコードを追加する。 golangci-lint を追加する。 使ってもらう 社内外含め、色々な負荷試験や開発で利用いただき、使用実績を積み重ねたいです。また、その中で得られたフィードバックから、必要に応じてissue化して、機能追加やバグ修正をします。 まとめ 本記事では、Kubernetesクラスター上にPrismを使って、マイクロサービスのOpenAPI仕様をそのまま返すモックリソースをサクッと構築する「モック構築ツール」を紹介しました。Goのソースコードを読んだ方はお気づきかと思いますが、モック構築ツール自体の実装は難しくありません。AWSリソースやKubernetesリソースの構築と削除をしているだけです。強いて言えば、Istioの最新バージョン取得のロジックが少し複雑かもしれない程度です。シンプルで理解しやすいツールですので、よろしければぜひ使ってみてください。また、少しでも良いなと思っていただいたら、 GitHubリポジトリ にスターをいただけるととても嬉しいです。ほぼはじめての自作OSSなので少し緊張しています。 なお、本ツールの中核であるPrismは素晴らしいOSSです。もし、Prismの活用がまだでしたら、ローカルの動作確認や結合試験などにも便利ですのでぜひ使ってみてください。 We are hiring ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co corp.zozo.com
アバター
こんにちは、MA部MA開発ブロックの @gachi-muchi-engineer です。 私の所属するMA部で7月に開発合宿を実施しました。一般的に、開発合宿は開発者が集まって新しいサービスや機能を開発しますが、今回の開発合宿では開発しない開発合宿という形で実施しました。本記事では、今回行った開発合宿の目的と結果を紹介します。 開発合宿をおこなうことになった経緯 MA部ではシステム運用での課題がありました。その課題の原因を紐解くと人的な要因(システム理解や意識の問題)が大きく関わっていることがわかりました。そのため、メンバー全員が集まり、課題に取り組むことで、システム理解を深め、意識を統一することを目的に開発合宿を実施することにしました。 システム運用での課題とは MA部では日々の業務の中でアラート対応業務を行っています。アラート対応は、システムエラーなどの異常を検知し、ユーザー影響が出る前に対応する重要な業務です。 週ごとのアラート当番制を採用しておりメンバー二人体制でアラート対応業務を行っています。 以前大幅にアラートを減らすことに成功しましたが、その後もシステム構成の変更や新しいシステムの追加やシステムの利用状況が変わったことにより、またアラートが増えてきました。 以下はアラートを減らしたときの記事です。 techblog.zozo.com 状況としてサンプリングした月のアラートの発生回数は、月に244回で1日平均8件発生しています。MTTRは約3時間弱です。アラート当番だけでなく、該当システムに詳しいメンバーもアラート対応に参加するケースがあるため、チーム全体のアラート対応時間が長くなります。これは、日々の作業時間を奪うだけでなく、夜間対応等で、メンバーの生産性も下げてしまっています。 このような状況になっている要因は、アラートが発生した場合、一次対応後に根本対応ができていない状態が続いていたことです。 なぜ根本対応ができていないのか、その原因を調べると以下のような課題が浮かび上がりました。 【人的課題】メンバーのシステム理解・把握が不十分 MA部では、15名弱のメンバーで運用しています。対応メンバーの在籍年数を見ると40%強が1年未満で75%のメンバーが3年未満になっています。 MA部では、3つの部署(それぞれブロックと読んでいます)が合わさって部を形成しています。ブロックごとにシステムが分かれているわけではないので、メンバーがすべてのシステムをある程度把握し、アラート対応業務を行う必要があります。 そのためシステムの数が多く、在籍年数が若いメンバーはシステム理解が十分にできているとはいえない状況でした。結果として、根本の原因調査等に時間がかかってしまう場合や原因を調査しきれないケースが見られました。 以下は過去に紹介したMA部が管轄しているシステムの一部です。 techblog.zozo.com techblog.zozo.com techblog.zozo.com techblog.zozo.com techblog.zozo.com 【人的課題】マインドセットが統一されていない MA部は、アラート当番によりすべてのメンバーがアラート対応をする環境となっています。ですが、アラートや監視に関して部内でしっかりとマインドセットが整えられていないと感じていました。具体的には、人によって一次対応で行うべき内容や、アラートの重要度を判断する基準が異なっていました。 結果として、監視やアラートに対して、部内のメンバーの意識が揃っていないので、アラート当番のルールの徹底がおろそかになってしまいます。 また、弊社の環境としてフルリモートのため、コミュニケーション面でも以下の課題がありました。 実際に会ったことがないメンバーとのコミュニケーションが取りづらい チームを跨ぐとコミュニケーションを取る機会が少ない そのため、当番で一緒になってもお互いに一歩引いてコミュニケーションがうまく取れていないケースがありました。 【システム課題】監視が不適切なケース 監視を設定した当初から、システムの状況に合わせて見直しや調整ができておらず、適切な監視になっていないものがありました。そのため、過剰に反応してしまうケースや、検知タイミングが遅れてしまっているケースが見られました。 結果として、確認作業が増えて開発の時間が削られたり、重大なインシデントにつながってしまうことがありました。 課題のまとめ システムの利用状況、構成の変更やメンバーの入れ替わりにより、アラート対応において課題が発生していることがわかりました。特にこの数年でメンバーの増員が多かったため、人的課題(育成やナレッジ蓄積、意識の統一など)が顕著になっていることがわかりました。 課題への対策 それぞれの課題に対して、以下のような対策を検討しました。 メンバーのシステム理解を深める アラートや監視に関してスキル・知識の拡充とマインドセットを整える 実際に会って会話・作業をすることで、コミュニケーションの活性化を図る これらの対策を効率良く行う方法を検討し今回は、開発合宿を実施することにしました。開発合宿と書いていますが、日々の業務を離れメンバー全員が集まり課題に対して集中的に取り組むことで、効率よく課題解決を図れると考えました。 また、開発合宿を実施しただけでは、中長期的に同じ状況に陥ってしまう可能性があると考えられました。 そのため、アラート当番が終わった後にテックリードによるアラート対応時のレビューをする仕組みを取り入れることにしました。 これの目的は、アラート対応のスキルアップを図ると共に、根本対応が行われない状況に陥らないような継続的な仕組みを作ることです。 開発合宿の内容と準備 今回の開発合宿は、1泊2日で実施することにしました。作業時間から逆算すると合宿中に課題を複数設定するのは現実的でなかったので、メインの課題をシステム理解に設定しました。得意分野が異なるメンバーを集めた4、5人程度のチームを3つ作り、お互いに教え合いながらシステムレポートを作成してもらうことで、システムの理解を深めてもらうようにしました。マインドセットの統一に関しては、合宿前にシステム運用に関しての図書を読んでもらい、合宿の最初にレクリエーション要素も含めたコンテンツとしてテスト形式で理解度を確認することにしました。 以下は、合宿のスケジュールです。 課題の準備について システム理解 依存度の高いシステムをグループに分け、そのシステムグループごとにチームを割り振り、システムレポートを作成してもらうようにしました。システムレポートは、事前にまとめてほしいポイントをまとめたテンプレートを用意し、資料を作るコストを減らして、システム理解に集中してもらうようにしました。 今回テンプレートに含めた理解してほしいポイントは以下の通りです。 システムの目的 このシステムの目的や背景など、なんのために存在するのかを書いてください。 システムアーキテクチャ ポイントに関しては以下のとおりです。 アプリケーションの動きについて(図で表現できると良し) データの流れについて(図で表現できると良し) システムやアプリケーションの特徴や気をつけるべきポイント 外部システム/他システムとどのようにつながっているか? 外部システムがどのような状況になるとどのような影響があるか? モジュール モジュールに関してそれぞれ簡単にまとめて、特徴や概要をまとめてください。 システム監視・サービス監視・メトリクスについて 現状行われているシステム監視・サービス監視についてまとめてください。それぞれにその監視が入っている意図やその監視がなるとどういうことになるのかを分かる範囲でまとめてください。 メトリクスをどうやって確認するか、特徴的なメトリクスなどを分かる範囲でまとめてください。 システム課題/対策 足りてない監視、監視のチューニング、頻発アラートの根本改善案などを分かる範囲でまとめてください。 以下は、システムレポートのテンプレートの一部です。 チーム分けでは、テックリードやリーダー陣は、チームには組み込まず、いつでも質問を受け付けられるようにしました。 これはメンバーの理解度を底上げしたかったためです。各チームには、メンバー同士で議論しながら資料を作成してもらいました。メンバー編成としては、システムグループごとに複雑なところであれば、経験者を混ぜたり、今後の中長期的に携わっていってもらうメンバーを配置したりするなどしました。また、メンバーのコミュニケーションの相性も考えながら、編成をしました。 チームには、班長というチームの進捗管理などを行ってもらう役割を設けました。 マインドセットの統一 今回は システム運用アンチパターン を合宿までに読み込んでもらい、当日にテストを実施しました。こちらの技術書の選定とテスト作成・実施解説に関しては、テックリードにお願いしました。本に書かれいている一般的な問題から、MA部や、弊社特有の問題まで様々な問題を用意してもらいました。 以下は、テストの一部です。 テスト形式は、Yes/No形式で問題毎に正解の発表と解説をしました。こうすることで、全員が理解しながら正答を確認し一喜一憂するようにしたかったためです。また、成績優秀者には、表彰状を用意し、1日目の夜に表彰式を行いました。 実施にあたっての準備 課題以外では実施場所、食事の手配、社内調整や申請系のフォローアップなども行いました。 開催場所の候補はいくつかあったのですが、今回の合宿は おんやど恵 様で実施しました。開発合宿に必要な準備をサポートしてくれる合宿プランで予約し、宿の皆様の丁寧なサポートにより昼食のお弁当の手配や各種設備のレンタルなどもスムーズに進めることができました。 それ以外にも、社内の申請系の調整を行い参加メンバーが合宿前後で行わないといけない申請のまとめやフォローアップの準備をしました。 準備まとめ 合宿の準備としては、やってもらう課題の設定とボリューム感がオーバーワークにならないように注意しながら、いろいろなレベルのメンバーがいることを想定して設計・準備をしました。運営側として携わることは初めてだったので、過去に参加したことがあるメンバーや、テックリードに協力を仰ぎながら進めていきました。 実際に実施した合宿の様子 1日目 まず全員揃うか心配でしたが、無事に集まりました。合宿の最初に、合宿の目的やスケジュール、ルールや注意事項を説明しました。特に羽目を外しすぎないように、合宿の目的を忘れないように注意を促しました。 その後、システム運用アンチパターンに関する理解度テストを実施しました。ある程度場が盛り上がったところで、昼食を食べながら、システム理解の課題に取り組んでもらいました。最初のコンテンツでレクリエーション的な要素を交えながら行ったあとに、メインの課題に取り組んでもらう流れは緊張もほぐれ、とても良かったと思います。 定期的に各チームに足を運びながら困っていること、詰まっていることはないか、まったく進捗していないチームがないかを確認しました。どのチームもコミュニケーションを取りながら、進めていたので、運営側としてはホッとしておりました。 1日目の最後には、各チームから中間発表してもらいました。大きく方針が間違っていないかなどをチェックするとともに、いくつかアドバイスや、追加で調査してほしいところなどを伝えて1日目の終了としました。 2日目 2日目は、朝から引き続きシステム理解の課題に取り組んでもらいました。1日目の中間発表から、アドバイスを受けていたチームは、アドバイスを元に進めていきました。 1日目と同じく、定期的に各チームに足を運びながら、進捗を確認や困っていることがないかなどを確認しました。 ここで、資料を作ることがゴールになりだしていると感じられたためリーダー陣と相談し、最終発表では資料の投影はなし、口頭のみで説明してもらうことにしました。これは、システムを理解できていたら、資料がなくても説明できるはずだという考えからでした。 最終発表では、1チーム30分で全チームが発表しました。質疑応答では、システム特有の仕様や、歴史的な背景から課題になっているところなどを理解しているか確認しました。リーダー陣以外からも各チーム間でも質疑応答が行われ、議論が行われていました。 最終発表が終わった後に、MA部の部長から合宿でのMVPを選出してもらい、表彰式を行いました。その後、各自解散の運びとなりました。 合宿の結果 合宿後にアンケートを実施して、今回の合宿の目的が達成できたかを調査しました。 全体的にポジティブな意見が多かったです。システム理解に関しては、全員がシステムの理解を深められたと回答してくれました。 今後のアラート対応において、システム理解が深まることで、アラート対応にかかる時間が短縮されることが期待できるという意見もありました。 マインドセットの統一に関しても、アンケートの回答では、アラートや監視に関して、意識が統一されたと回答してくれました。テストは比較的簡単だったようなので、次回の機会があれば、もっと難易度を上げてもいいかもしれないとテックリードからのフィードバックもありました。 特に年次が浅いメンバーは実際にメンバーに会う機会も多くなかったので、コミュニケーションの強化や知見があまりないシステムに関して理解を深めるいい機会にしてもらえたと思います。 総じて、なかなか日々の業務の中でまとまった時間を確保して取り組むことが難しいシステム理解という課題に対して、開発合宿を通して集中して理解を深めることで、効率よく対策できたと感じました。 合宿での課題 進め方/ゴールに関して システムレポートを作ることがゴールになってしまっていたケースがありました。今回は、そこを起動修正するために最後の発表を資料なしという形を取りましたが、最初からここを想定しておくべきでした。 また、なかなかリーダー陣に質問が飛んでこないことも課題だと感じました。もっと定期的に確認しに行く頻度を上げる、リーダー陣にも質問を促すようにするなどの工夫が必要だと感じました。 チーム編成/役割 リーダー陣の役割を明確にしていませんでした。その結果質問を待っているだけになってしまい、ワークしていたとは言いづらい状況だったと思いました。次回はリーダー陣の役割を明確して、よりワークするようにしたいと思いました。 また、チーム編成はうまくできたと思いますが、班長の役割を明確にしなかったため、任命しただけで終わってしまったと感じました。もっと具体的な動きなどを伝えてチームがワークするような動きを取ってもらえるような準備をするべきだったと思いました。 最終発表/質疑応答 満足行くまで調査しきれなかったチームや、質疑応答でチーム間での議論が活発に行われなかったなどの課題もありました。次回は、作業時間の工夫や最終発表での各メンバー1つ以上質問をするようにするなどの工夫が必要だと感じました。 今回の課題への効果 開発合宿の実施後のアラート対応の状況は以下のような変化が現れました。 まず、MTTRが大幅に改善されました。システム理解が進んだことで、アラート対応にかかる時間が短縮されました。 次に、インシデントの総数も減りました。テックリードによるアラート対応のレビューにより、根本対応がしっかりと実施される仕組みがワークしているため減少させられました。 これは、合宿でのシステム理解の深まりや、マインドセットの統一の効果とテックリードによるアラート対応のレビューの効果があったためと考えられます。 まとめ 今回運営側として初めて開発合宿を企画・運営しましたが、大変学びが多かったです。 特に、普段の業務を離れてメンバー全員で1つの課題に集中して取り組む機会を作ることは、とてもいい手段で、非常に効果的なやり方だと思いました。また、準備が非常に大切なことも痛感しました。今回はたまたま準備がある程度できていたため、メンバー全員が集中できる環境の準備はできたと思いますが、もしできていなかった場合はグダグダになってしまっていたかもしれません。もし次の機会があれば、もっと準備に時間をかけるようにしたいと思いました。 開発合宿の成果という点では、今回のケースでは定量的なものはなかなか出しづらいものでもありました。そこは、今回の合宿を実施しただけで終わらせないようにフォローアップを行っていこうと思いました。 今回実施して感じた課題などは、今後実施する機会があれば開発合宿の設計として活かしていきたいと思いました。 最後に ZOZOでは一緒にプロダクトを開発してくれるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください! corp.zozo.com
アバター
はじめに こんにちは、ZOZOTOWN開発1部iOSブロックの なんしー です。普段はZOZOTOWN iOSアプリの新機能の開発や既存画面のリファクタリングなどを担当しています。 ZOZOTOWN iOSアプリは2010年11月にサービスを開始して以来、ZOZOSUITやZOZOGLASSをはじめ、様々な新機能を提供してきました。最近では、購入した商品に対してレビューが投稿できる「アイテムレビュー」という機能も追加されました。 新機能が追加されていく一方、以下のようなレガシーな部分が残り続けてしまっていることが課題となってきています。 Objective-Cで書かれたコードが残ってしまっていること API通信等のビジネスロジックにあたる部分がViewController内部に書かれており、Fat ViewControllerになってしまっていること 今回はZOZOTOWN iOSアプリに残るFat ViewControllerを取り上げ、どのように解消を図っているのかをご紹介します。 目次 はじめに 目次 商品詳細画面が抱える課題 リファクタリングを進めるために 大規模なリファクタリングのための工数確保 新たなアーキテクチャの採用 チームとしてのリファクタリングの進め方 アーキテクチャレビュー リファクタリングを複数のStepに分割 おわりに 商品詳細画面が抱える課題 ZOZOTOWN iOSアプリにはさまざまな画面が存在しています。その中でも商品の詳細情報を確認できる画面(以下、商品詳細画面)は、改修の頻度が高く、CVRにも直結する重要な画面の1つです。 そんな重要な画面であるにもかかわらず、商品詳細画面は以下のような課題を抱えていました。 API通信など、ビジネスロジックにあたる実装がViewController内に書かれていてる 責務が分割できておらず、複数人で同時に開発できる設計になっていない ユニットテストが存在しておらず、かつ書ける設計になっていない コード行数は2,500行を超えており、可読性が悪い 商品詳細画面はいわゆるFat ViewControllerになってしまっており、ViewControllerが抱えるべきではない責務が多々入り込んでしまっている状況でした。 そんな状況の中、以下のような話が挙がりました。 複数のプロジェクトで商品詳細画面に変更を加える計画が浮上した ZOZOTOWN開発本部では「開発生産性の向上 1 」が重要な目標の1つとして掲げられた Fat ViewControllerになってしまっている状況では、複数人が同時に開発することは難しい状況にありました。また、メンテナンス性や可読性の悪いコードベースになってしまっているため、開発生産性の向上を図る上での障壁となっていました。今後も商品詳細画面にはさまざまな改修が想定されるため、大規模なリファクタリングの検討を始めました。 リファクタリングを進めるために 商品詳細画面をリファクタリングするにあたり、以下を目標にリファクタリングを進めることにしました。 責務が分割できており、複数人が同時に開発できる状態であること ユニットテストが書ける状態であること 新規機能を追加するとなった際、今までよりも小さい工数で実装できること これらを達成するため、次のようなアプローチでリファクタリングを進めていきました。 大規模なリファクタリングのための工数確保 複数のプロジェクトが並行で進むこと、「開発生産性の向上」が目標として掲げられていることに鑑み、大規模なリファクタリングを決断しました。そのため、まずは大規模なリファクタリングを行うための工数を確保するところから始めました。 プロジェクトの合間の時間を使用してリファクタリングを進めることも検討しましたが、リファクタリングの完了までに時間がかかりすぎてしまう懸念がありました。そのため、リファクタリング自体をプロジェクト化して進められないかを検討しました。 ZOZOTOWN開発本部では、時間がかかったり、プロダクト品質向上につながったりする改善タスクはプロジェクト化し、工数を確保して進めるという文化があります。そのため、今回の商品詳細リファクタリングも同様にプロジェクト化し、まとまった工数を確保しつつ進めることになりました。 こうして、大規模なリファクタリングを進めるための工数を確保できました。 新たなアーキテクチャの採用 ZOZOTOWN iOSチームではMVVM(Model-View-ViewModel)アーキテクチャを採用しています。しかし、この構成では1画面での機能が増えた際、ViewModelの責務が肥大化してしまい、Fat ViewModelになってしまうという課題がありました。今回のリファクタリング対象である商品詳細画面は特に機能が多い画面であるため、Fat ViewModelとなる懸念が挙がりました。 そこで、 Android Architecture Components を参考に、Domain Layer(UseCase)を導入することにしました。Domain Layerを設けることでViewModelからビジネスロジックを分離でき、Fat ViewModelになってしまうことを避けることができました。 最終的なclass構成と依存関係は以下のようになります。 UseCaseを導入した結果、ビジネスロジックをViewModelから切り離すことができ、ViewModelは画面の状態管理の責務を担うようにできました。その結果、ViewModelの肥大化を防ぎ、コードの保守性が大幅に向上しました。 チームとしてのリファクタリングの進め方 ここまではリファクタリングを進めるための前段階の話をご紹介しました。ここからは、ZOZOTOWN iOSアプリチームでのリファクタリングの進め方についてお話しします。 以下のようなステップに分け、チームで認識のすり合わせをしながらリファクタリングを進めました。 アーキテクチャレビューの実施 リファクタリングを複数のStepを分割 Stepごとにリファクタリングを実施 StepごとにQA、リリース アーキテクチャレビュー ZOZOTOWN iOSチームでは、新規で画面を作る際にアーキテクチャレビューという会を行なっています。アーキテクチャレビューでは、「基本設計を実現するために必要なアーキテクチャを把握し、それをチーム内でどう開発すべきかを合意すること」を目的としています。実装前に擦り合わせをしておくことで、実装時の手戻りを減らすだけでなく、Pull Requestをレビューする際の負担軽減も期待できます。 アーキテクチャレビューでレビューしている内容は以下の2点です。 レイヤー間の値の受け渡し レイヤー内の責務分割 レイヤー間の値の受け渡しでは、各レイヤー間でどのような値が、どのようなインタフェースで実現されているか、レイヤーを跨ぐ際に値がどう変換されるかをレビューします。アーキテクチャレビューでは、各レイヤーのProtocolをもとにレビューをします。 /// APIからのレスポンスをパースするためのModel struct GoodsDetailResponse : Decodable {} /// 商品の詳細情報を表現する、View用のModel struct GoodsDetail {} protocol GoodsDetailAPIClientProtocol { func getGoodsDetail ( id : Int , completion : @escaping (( Result < GoodsDetailResponse , Error >) -> Void ) ) } protocol GoodsDetailUseCaseProtocol { func fetchGoodsDetail ( id : Int , completion : @escaping (( Result < GoodsDetail , Error >) -> Void ) ) } protocol GoodsDetailViewModelInputsProtocol { func viewDidLoad () } protocol GoodsDetailViewModelOutputsProtocol { var goodsDetailAnyPublisher : AnyPublisher < GoodsDetail , Never > } 上記のようなProtocolをもとにアーキテクチャレビューを実施します。このProtocolに沿って実装すると、成果物となるコードもある程度予想でき、設計時と実装時の認識のズレを減らすことができるのではと期待しています。 リファクタリングを複数のStepに分割 商品詳細画面は抱える機能も多く、変更量はとても多くなることが予想されました。一気にリファクタリングした場合、不具合を発生させるリスクやQAでの見落としが懸念されました。そのため、今回のリファクタリングでは複数のStepに分割し、段階的なリリースをする方針で進めました。 商品詳細画面リファクタリングのStep分割は、APIの呼び出し単位で分割しました。APIの呼び出し単位でStepを分割することで、Pull Requestのレビューがしやすいといったメリットもありました。また、Stepを細かく分割していることで、急遽優先度の高いプロジェクトが入ってきた時でも柔軟に対応できました。 おわりに 本記事では、Fat ViewControllerになってしまっている商品詳細画面をリファクタリングする話をご紹介しました。リファクタリングを始めるタイミングで設定した目標通り、責務は適切な粒度で分割され、ユニットテストも書かれている状態になりました。まだ比較はできていませんが、新機能を追加するとなった場合も今までより少ない工数で実装できる見込みです。 商品詳細画面だけでなく、ZOZOTOWN iOSアプリにはまだまだレガシーなコードが残っています。今後も負債と向き合いつつ、より良いコードを目指し改善を進めていく予定です。 ZOZOでは、一緒にサービスを作り上げてくれる仲間、レガシーなコードを書き換えていく仲間を募集中です。ご興味がある方は、以下のリンクからぜひご応募ください! hrmos.co corp.zozo.com ZOZOTOWNにおける開発生産性の向上に関する取り組みに関しては、「 ZOZOTOWNにおける開発生産性向上に関する取り組み 」のスライドをご覧ください。 ↩
アバター
こんにちは、DevRelブロックの ikkou です。2024年8月22日の夕方から24日の3日間にわたり「iOSDC Japan 2024」が開催されました。ZOZOは昨年同様プラチナスポンサーとして協賛し、スポンサーブースを出展しました。 technote.zozo.com 本記事では、前半は「iOSエンジニアの視点」から、ZOZOから登壇したセッションとiOSエンジニアが気になったセッションを紹介します。そして後半は「DevRelの視点」から、ZOZOの協賛ブースの様子と各社のブースコーデのまとめを写真多めでお伝えします。 登壇内容の紹介 LT: 全力の跳躍を捉える計測アプリを作る ポスターセッション: Haptic Feedbackでクセになるユーザー体験を提供しよう! ZOZOのiOSエンジニアが気になったセッションの紹介 Server-Driven UI入門: 画面のStateを直接受け取るアプローチ by nade 例: タップルの検索機能 watchOS 最前線 〜現代のApple Watch向けアプリの作り方〜 by kouki_dan Accessibility for Swift Charts 〜 by Mika Ito 健康第一!MetricKitで始めるアプリの健康診断 Apple Siliconを最大限に活用する方法 ZOZOブースの紹介 協賛企業ブースのコーデまとめ スポンサーブースA(1F) スポンサーブースB(1F) スポンサーブースC (2F展示ルーム) スポンサーブースD (1Fコミュニティスペース) Tシャツスポンサー アフターイベントを開催します おわりに 登壇内容の紹介 今年のiOSDCではLTに1名、ポスターセッションに1名、パンフレット寄稿に3名が採択されました。会場で発表されたLTとポスターセッションについて紹介します。 iOSDC Japan 2024の公募に採択されたZOZOスタッフ LT: 全力の跳躍を捉える計測アプリを作る 連続登壇3年目にしてLTの大トリを務めたおぎじゅん( @juginon ) speakerdeck.com 新卒3年目にして連続登壇3年目となるiOSエンジニアのおぎじゅんは、昨年・一昨年の「疾走」から「跳躍」にテーマを変えてトークを披露しました。手拍子まじりで進められるLTはおぎじゅんならではの非常に勢いがあるものだったのではないでしょうか。 このLTの背景や苦労話は個人ブログに詳しくまとめられているので、あわせてご覧ください。 ogijunchang.hatenablog.com おぎじゅんからのコメント 今年は大トリということでプレッシャーを感じていましたが、全力で楽しく発表できました! 皆さんの頭の中に少しでも残るLTになっていたら幸いです。 ポスターセッション: Haptic Feedbackでクセになるユーザー体験を提供しよう! ポスターセッション中のイッセー( @15531b ) speakerdeck.com 新卒1年目のiOSエンジニアであるイッセーは、Haptic Feedbackに関するポスターセッションを行いました。期間中、各日1時間をコアタイムとしてポスターの前に立ち、来場者とのコミュニケーションを楽しんでいました。Haptic Feedbackはその特性上、実際に触ってみることで理解が深まるため、iPhone実機に触れてもらいながら説明できるポスターセッションは非常に有効な手法でした。 iPhone実機でHaptic Feedbackを体験してもらっている様子 イッセーからのコメント 今年4月に新卒入社し、初めて登壇の機会をいただきました。参加者の皆様とポスターを見ながらお話しする中で、Haptic Feedbackに関する知見をさらに深めることができました。足を運んでいただいた皆様、ありがとうございました。 ZOZOのiOSエンジニアが気になったセッションの紹介 Server-Driven UI入門: 画面のStateを直接受け取るアプローチ by nade ZOZOTOWN開発本部iOSブロック1内定者アルバイトのだーはまです。nadeさんの「 Server-Driven UI入門: 画面のStateを直接受け取るアプローチ 」が個人的にかなり良かったので紹介します! Server-Driven UI (以下:SDUI) を初めて耳にする方もいると思います。SDUIとは、事前に定義されたUIコンポーネントのLayout/State/Actionをサーバーからレスポンスとして返すというコンセプトをもとにした開発設計です。クライアント側で実装していたUIロジックを(クライアントではなく)サーバーに押し込むような設計となっており、SDUIを導入することで開発工数やクライアントからのリクエスト数などを削減でき、開発スピードの向上が見込めます。 トークの構成は3部制(入門、番外、応用)となっていて、SDUIの基礎から実践までを網羅的に学べます。特に、応用編で述べられていた既存アプリ(タップル)へSDUIを導入する話は、SDUI導入を検討しているエンジニアにとっては必見です。 下記は個人的に重要だと感じたトーク内容をまとめています。 nadeさんは、SDUIのコンセプトを3つの参考記事から紐解いています。 WWDC 2010:Building a Server-Driven User Experience Spotify 2016:Backend-driven native UI AirBnB2021:A Deep Dive into Airbnb’s Server-Driven UI System WWDC 2010は、サーバーからクライアントへUI Componentのプロパティごと返却することで開発の柔軟性をあげようという思想のもと発表されたようです。 上記3つの記事から導かれたSDUIのコンセプトは、”事前に定義されたUIコンポーネントのプロパティを含めてサーバーから返却すること”です。 以下に示すようUIコンポーネントのデータが含まれたJSONを、クライアントはサーバーから受け取ります。また、swiftではCodableによってJSONをstructへ簡単に変換できます。これを用いてJSONデータをSwiftUI.view(Struct)へ変換しUIを構築します。 { "screens" : [ { "id": "ROOT", "layout": { "wide": {}, # landscape mode "compact": { # portrait mode "type": "SingleColumnLayout", # レイアウトの種類 "main": { "type": "MultipleSectionsPlacement", "sectionDetails": [ # 画面要素のsectionIdのみ { "sectionId": "hero_section" }, { "sectionId": "title_section" } ] } } } } ] } { "sections" : [ { "id": "toolbar_section", "sectionComponentType": "TOOLBAR", "section": { "type": "ToolbarSection", "nav_button": { "onClickAction": { # タップ時のアクション "type": "NavigateBack", "screenId": "previous_screen_id" } } } }, { "id": "hero_section", "sectionComponentType": "HERO", "section": { "type": "HeroSection", "images": [ # HeroSectionコンポーネントのState、typeごとに型が違う "api.hoge.com/..." ] } } ] } メリットとデメリットは以下の通りです。 メリット 画面構成を変更するたびにアプリの審査やリリースする必要がなくなる iOS,Androidで開発している場合、これまでは画面に関するロジックを2つ(iOS,Android用)書く必要があったが、統一可能となる トーク中、nadeさんからモバイル(iOS,Android)の開発において同じロジックをOS毎に分けて書いているのは、DRY原則(Don't repeat yourself=繰り返しを避けろ)になるのではと話されていて、モバイル開発の問題をついていて面白いなと思いました。 サーバーへのリクエスト数を減らせる これまでは、1画面を作るのに画面を組み立てるために”プロパティを得るため複数回のリクエスト”を送っていました。しかし、SDUIではサーバーからプロパティ込みでレスポンスがあるので、複数回リクエストする必要がなくなり、リクエスト数を減らせます。 デメリット 事前のUIコンポーネント定義や基盤構築が大変 クライアントーバックエンドのスキルを併せ持つ人材が必要 BFFを書けるクライアントエンジニア + UIのStateを適切に考えられるバックエンドエンジニア 属人性が高くなる システムの境界を引く位置が既存の開発手法と異なるというSDUIの性質を応用して、nadeさんは境界の位置を調整した軽量SDUIを提案しています。軽量SDUIは、全てのUIロジックをサーバーに移行するのではなくシステム運用可能性の高いものだけを限定して譲渡する設計です。 例: タップルの検索機能 layoutはクライアントが担当し、state,actionをバックエンドが担当する。そして、SwiftUI.viewにStateを定義し、そのままバックエンドから受け取る。つまり、layout/state/action全てをバックエンドに渡すのではなく、特定のstate/actionのみを渡す、これが軽量化されたSDUI、軽量SDUIです。FatVIewではなく状態管理が適切に行われているプロジェクトであれば、SDUIの導入も比較的容易だと思います。 KMPと似ていてロジックを共通化して開発効率をあげようという設計、面白いです。 watchOS 最前線 〜現代のApple Watch向けアプリの作り方〜 by kouki_dan ZOZOTOWN開発本部iOSブロック1のだーはまです。kouki_danさんの「 watchOS 最前線 〜現代のApple Watch向けアプリの作り方〜 」がApple Watch開発のキャッチアップとしてとても参考になったので紹介します。 本トークでは、Apple Watch(以下:Watch)アプリ実装に関するTipsが紹介されています。トークを聞くことで、Watchアプリの実装イメージが具体化され、開発してみようと(重い腰)を上げられるようになるはずです。kouki_danさんが個人で開発している2つのアプリを例として話が進むため、実践的で分かりやすいトークでした。 トークを聞き、個人的に納得したのがiPhoneとWatchではUXが異なるという点です。iPhoneは操作を画面と指で行うのに対して、Watchは画面と指に加えてDigital Crownも用います。また、画面サイズも小さいです。そのため、ユーザーがアプリを使う場面や操作方法が異なり、Watch独自のユースケースを考える必要があります(だーはまの声:何かZOZOでもWatchアプリ作れないかな…)。 WatchアプリはSwiftUIと実装コードが非常に似ているため、SwiftUI触ったことある人なら比較的簡単に実装できると思います。しかも、iPhoneに比べて画面サイズが小さいため、実装工数も少ないです。 下記で、Watchアプリ実装のTipsを紹介していきます。 TabView : タブ表示が可能となる モディファイア.tabViewStyle(.verticalPage) によって、Digital Crownのスクロール方向を指定可能です。実装をみてわかるように、VStack, Image, TextなどSwiftUIをそのまま流用できる箇所が多くあります。 // MARK : - TabView TabView { VStack { Image(systemName : "globe" ) .imageScale(.large) .foregroundStyle(.tint) Text( "Tab1" ) } VStack { Image(systemName : "globe" ) .imageScale(.large) .foregroundStyle(.tint) Text( "Tab2" ) } List( 0 ..< 20 ) { Text( "Item \( $0 ) " ) } } .tabViewStyle(.verticalPage) TimelineView : 時間経過によって変化するViewの描画に使う contextが時間経過とともに変化していき、 Circle() を再描画させます。 TimelineView(.animation) { context in let elapsedTime = context.date.timeIntervalSince(start) ZStack { Circle() .stroke(Color.gray, lineWidth : 20 ) Circle() .trim(from : 0 , to : elapsedTime / 60 ) .stroke(Color.green, lineWidth : 20 ) .rotationEffect(.degrees( - 90 )) Text(String(format : "%.2f" , max( 0 , 60 - elapsedTime))) .monospacedDigit() .font(.title) } } .difitalCrownRotation(_:) : デジタルクラウンの回転状況を検知可能 デフォルトでは回転方向が縦になっています。 Text( " \( rotation, specifier : "%.1f" ) " ) .focusable() .digitalCrownRotation( $rotation ) .scenePadding(_:) : paddingの指定が可能となる。 Watchは画面が小さく、また角丸になっているため、paddingが重要になってきます。この .scenePadding(_:) を指定しないとpaddingがなくなり、コンポーネントが見切れてしまう可能性あります。 トーク内では、上記以外の実装についても触れられています。気になる方はスライドやアーカイブをチェックしてみてください。 Watchアプリの実装について網羅的に知れるとても有意義なトークでした! iOSDCではトーク後5分間のディスカッションの時間が設けられています。僕が実際質問したことを書いて、このトークの紹介を終わりにしたいと思います。 Q. iPhoneとWatchではUXがかなり異なると思うが、参考にするべきサイトやアプリはありますか? A. Appleが出しているWatchアプリ 今後Watchアプリを開発しデザイナーと話す際、思い出して参考にしたいと思います。 Accessibility for Swift Charts 〜 by Mika Ito FAANS部フロントエンドブロックiOSエンジニアの ましょー です。私は、Mikaさんの「 Accessibility for Swift Charts 」についてご紹介します! このセッションは、目の見えない、見えにくい方のために、Swift Chartsで作成されたグラフのデータをVoiceOverやAudio Graphsを用いて音で表現してみたという内容でした。日経平均株価の折れ線グラフを音で表現したり、グラフを音にしたりしてみると音楽になっていたりと、とても面白く、惹きつけられるセッションでした。 まず、このセッションで利用されているSwift Charts、VoiceOver、Audio Graphsについて紹介します。Swift ChartsはSwiftUIを用いて折れ線グラフ、散布図などのグラフを作成できるフレームワークです。また、VoiceOverとは、グラフのデータにカーソルを合わせると値を読み上げてくれる機能です。歩数計だと「〇〇年〇月 歩数 1日平均〇〇歩」のように読み上げてくれるようです。Audio Graphsはグラフのデータを音声に変換する機能です。こちらはVoiceOverとは異なり、文章ではなく音の高低でデータを表現します。 このセッションではSwift Chartsで作成されたデータをVoiceOverやAudio Graphsでいかに読み上げるかについて言及しています。前提としてSwift ChartsはVoiceOverやAudio Graphsを自動サポートしているようです。例えば、以下のようなプログラムでグラフを生成したとします。 struct Climate : Identifiable { var id : UUID = . init () var month : Int var precipitation : Double } var body : some View { Chart { ForEach(data, id : \.id) { item in BarMark( x : .value( "Month" , item.month), y : .value( "Precipitation" , item.precipitation) ) } } .chartXAxisLabel( "月" ) .chartYAxisLabel( "降水量" ) ... } 上記のプログラムでは、月ごとの降水量を棒グラフで表示しています。この棒グラフをVoiceOverやAudio Graphsで読み上げてみると、以下のような問題が発生します。 単位が読み上げられないため、何の値かわかりにくい(VoiceOver) 降水量はDoubleなのに小数点以下が読み上げられていない(VoiceOver) 例えば7月のデータをタップした際に「7to8」のように読み上げられてしまう(VoiceOver) 「X軸はMonthです、Y軸はPrecipitationです」のように各軸が英語で説明される(Audio Graphs) これらの問題を解決するためにMikaさんは次のような変更をプログラムに加えていました。 var body : some View { Chart { ForEach(data, id : \.id) { item in BarMark( x : .value( "月" , item.month), y : .value( "降水量" , item.precipitation) ) .accessibilityLabel( " \( item.month ) 月" ) .accessibilityValue( " \( String(format : "%.1f" , item.precipitation) ) ミリメートル" ) } } .chartXAxisLabel( "月" ) .chartYAxisLabel( "降水量" ) ... } 上記のように、単位の追加やラベルを日本語に修正するなどの変更を加えることで先述の問題点を解決でき、意図した読み上げを行えるようです。音を用いてデータを伝える場合には、データがどのように読み上げられるか、聞き手に正しく伝わるかを考慮して、プログラムを作成する必要があると感じました。 本セッションを聞いて、データを音で表現することにとても興味が湧きましたし、たくさんの可能性を感じました! 私の開発しているFAANSでも、コーデの売上や送客数などをグラフで表現しているため、アクセシビリティ対応の一環としてデータの読み上げ機能を実装してみたいと思います! 健康第一!MetricKitで始めるアプリの健康診断 ZOZOTOWN開発本部 iOSブロックの @tsuzuki817 です! 自分からは nekowen さんの「 健康第一!MetricKitで始めるアプリの健康診断 」をご紹介いたします! iOSアプリのパフォーマンス改善の指標は以下の4つです。 応答性 タップやジェスチャーに対してアプリがどのくらいの速度で応答するか アプリの起動時間 ユーザーがアプリアイコンをタップしてから起動するまでの時間 メモリの使用量 アプリがデバイスのメモリを利用している使用量 バッテリー効率 デバイスのバッテリーの持ちを良くする またパフォーマンス情報の収集の方法として3つ挙げられておりました。 Xcode Organaizer 設定不要(XcodeのOrganizerで確認できる) 基本的なパフォーマンスデータが可視化できる TestFlightでは集計されない MetricKit 要実装(比較的簡単) 収取したデータをCrashlyticsなどに集約できる カスタムイベントの追加が可能 Firebase Perfomance Monitoringなどの外部サービス SDKの導入だけで自動的に収集される カスタムイベントの追跡が可能 詳細なパフォーマンスデータは取れない それぞれに長所・短所があるので各々のプロダクトに見合った計測方法の検討が必要です。 パフォーマンス情報で見ておくべき観点は以下の3つです。 アプリの起動時間が伸びていないか アプリがシステムによって終了されていないか アプリがフリーズしていないか アプリの起動時間は今回のiOSDCの他のセッションでも度々挙げられており、さまざまなシーンでアプリが普及している現代ではかなり重要度が上がっていると思います。 MetricKitの情報はFirebaseのCrashlyticsに一緒に送ることができます。実例でCrashlytics、TestFlightにクラッシュログが落ちていないクラッシュをMetricKitを使って事前に検知する仕組みを導入しており、目に見えにくいかつ他ツールではハンドリングが難しいクラッシュの把握に役立つことがわかりました。 アプリも人間も健康第一だと思います。アプリの健康診断の項目を適切に考え、より良いアプリを作り続けていくための仕組みづくりを整えていきたいと思わせてくれる素晴らしいセッションでした! Apple Siliconを最大限に活用する方法 ZOZOTOWN開発本部 iOSブロックの @tsuzuki817 です! 自分から @EXCode013 さんの「 Apple Siliconを最大限に活用する方法 」をご紹介いたします! Apple Siliconのスマートフォン、PC、スマートウォッチを使っているのにApple Siliconのパワーを最大限に活用しないのは勿体無いと思いセッションを聞かせていただきました! CoreMLを使うことでApple SiliconのNeural Engineのパワーを引き出すことができるそうです。CoreMLと聞くと一見難しそうでしたが、CoreMLを動かす上位のフレームワークである以下のようなフレームワークを使うことでも良いとのこと! Vision Neural Language Speech Sound Analysis CoreMLでモデルを作る前にこちらのフレームワークで事足りるか調査すると良さそうですね! また、AccelerateフレームワークはApple Siliconのパワーを引き出しつつ簡単に使いやすいよう抽象化されたAPIになっており既存の処理でも高速化できそうだなと思えました。その中でも一番使っていて効果が出そうだなと思ったのは vImage を使った画像処理です。 実際にセッション中にデモをしていただいたのですが、画像処理の代表と言っても過言ではないCIFilterと vImage を使って画像にブラーをかける処理を行いました。 vImage はCIFilterを使ったブラー処理よりも1.5倍ほど処理が速く、大量にCIFilterをかける処理を書いているケースなどではさらに効力を発揮するのではないかと思いました。 Ask The Speakerで vImage を使った画像処理のコードを読ませてもらったのですが、Metalのような複雑な処理は特にいらずに書かれており学習コストはそこまで高くないと感じました。デモに使ったコードはそのうち公開してくれるらしいので楽しみにしています! ZOZOブースの紹介 会期中は15名以上のZOZOスタッフが入れ替わりながらブースに立っていました。今回、ZOZOブースでは今年5月にリニューアルした「 WEAR by ZOZO 」の「 ファッションジャンル診断 」をメインコンテンツとして展示していました。 今回のメインコンテンツだったWEAR by ZOZOのファッションジャンル診断 この「ファッションジャンル診断」は、WEARに投稿されている好みのコーディネートを5枚以上選ぶことで、AIがファッションジャンルを診断し、おすすめのコーデを教えてくれる機能です。 「ファッションジャンル診断」を試している様子 ブースに訪れた方々には、お手元のiPhoneまたデモ用のiPhoneでこの「ファッションジャンル診断」を体験してもらいました。 どのジャンルでしたか? この「ファッションジャンル診断」はWEARでいつでも体験できる機能ですが、ブースで体験または診断結果を見せてくれた方には、その診断結果にあわせた診断結果ステッカーをお渡ししました。ステッカーの種類は全部で144種類! 名札に入れてもらったり、お手持ちのiPhoneに貼ってもらったり、皆さんそれぞれの使い方で楽しんでいました。 ちょっと麗しいシンプル もっとも多かったジャンルは「リラックスしがちなシンプル」で、次点に「少々スッキリしたラフ」そして「少々スッキリしたストリート」と続きました。また、当初の予想に反して意外と多かったのは「少々アクティブなシンプル」と「少々スッキリしたアウトドア・スポーツ」でした。ちなみに私は「リラックスしがちなシンプル」です! 箱猫マックスくんのステッカー その他、昨年制作したZOZOTOWNのキャラクターである「箱猫マックスくん」のステッカーは新作を交えて配布しました。このステッカーはLINEスタンプで「 箱猫マックス Vol.6 」として配信しているものです。エンジニア間のコミュニケーションに使いやすいスタンプが揃っているので、ぜひ使ってみてください! 色“縁”ぴつ また、デザイナー発案の「洒落の効いたアイテム」として「“一合一会”米」や「“失敗を水に流す”トイレットペーパー」に続き、今年は参加者の皆さんとの「縁」を結ぶ「色“縁”ぴつ」を作成しました。こちらは特定の条件を満たした方にお渡ししていました。 今年もとても多くの方にブースを来訪していただき、ZOZOの取り組みやサービスに興味を持っていただけたようで、とても嬉しく思います。また、ZOZOスタッフも多くの方とお話しでき、楽しい時間を過ごすことができました。会期の直前に「 ZOZOのiOSエンジニアに興味をお持ちの方へ 」を公開したこともあり、プロダクトごとの技術スタックの違いなどもご説明できて良かったです。来年もZOZOブースで皆さんとお会いできることを楽しみにしています! 協賛企業ブースのコーデまとめ あっすーです。iOSDC Japan 2024の協賛企業ブースを回ってきましたので、各ブースのコーデをお送りします! 各社の雰囲気に合わせたデザイン・着こなしは、やはりZOZOとしても気になるポイント。当日の会場の様子を思い出しながらご覧ください。 スポンサーブースA(1F) A1:楽天グループ株式会社さん A2:STORES 株式会社さん / 背面ロゴは会社メンバーで書いたそうです。 A3:サイボウズ株式会社さん / Tシャツの代わりにお揃いサコッシュを作成したそうです。 A4:チームラボ株式会社さん A5:LINEヤフー株式会社さん A6:ウォンテッドリー株式会社さん A7:株式会社ディー・エヌ・エーさん / トークンはネームストラップで隠れる位置に。 A9:ZOZO / 表面はZOZOのコーポレートロゴに裏面は西千葉本社の住所です。 A10:スパイダープラス株式会社さん A11:株式会社サイバーエージェントさん A12:株式会社MagicPodさん A13:株式会社メルカリさん / 右のシャツはユニフォームを意識して制作されたそうです。 A14:フェンリル株式会社さん / シルクスクリーン印刷だそうです。 A15:GO株式会社さん / 写真に映っていない黒も含めて全4色展開だそうです。 A16:株式会社アイリッジさん A17:株式会社Flatt Securityさん A18:株式会社ゆめみさん スポンサーブースB(1F) B1:Forkwellさん B2:株式会社マネーフォワードさん B3:転職ドラフトさん スポンサーブースC (2F展示ルーム) C1:Sansan株式会社さん C2:株式会社タイミーさん C3:合同会社DMM.comさん C4:株式会社メドレーさん / あえてLサイズで大きめに着用しているそうです。 C5:ROLLCAKE株式会社さん / 今回唯一のワッペンデザイン。会社のイベントで作られたそうです。 C6:株式会社アンドパッドさん C7:株式会社カサレアルさん C8:株式会社kubell(旧Chatwork株式会社)さん / 社名変更が伝わるように。 C9:RIZAPグループ株式会社さん C10:ピクシブ株式会社さん スポンサーブースD (1Fコミュニティスペース) D1:株式会社ビットキーさん D2:株式会社ガラパゴスさん D3:クックパッド株式会社さん D4:株式会社プログリットさん D5:株式会社リクルートさん D6:株式会社キャリアデザインセンターさん D7:KINTOテクノロジーズ株式会社さん / KINTO = 車のイメージを持ってもらえるよう頑張っているそうです。 D8:newmo株式会社さん D9:株式会社ココナラさん Tシャツスポンサー WED株式会社さん .images-row {width:860px !important;} こうやって並べてみると各社の雰囲気がわかりますね。色の傾向としてはやはり黒と白が多かったです。お忙しい中ご協力いただいたブースの皆さん、本当にありがとうございました! アフターイベントを開催します 締めの前に告知です! iOSDC Japanは参加レポート記事を書く #iwillblog 文化がありますが、それと同じくらいアフターイベント文化も活発です。観測する今年は限り9つのiOSDC関連アフターイベントが催されます。私たちもそのひとつとしてマネーフォワードさんと非公式の合同イベント「iOSDC Japan 2024 After Talk」を9月10日(火)19時より開催いたします。 zozotech-inc.connpass.com ZOZOからはLTに登壇したおぎじゅん、ポスターセッションに登壇したイッセーが登壇する他、協賛やブース運営に関してDevRel文脈のパネルディスカッションも予定しています。YouTubeを視聴するオンライン形式となりますので、connpassに参加登録の上、お気軽にご視聴ください! わいわいしましょう! おわりに ZOZOから参加した一部メンバーで撮影した集合写真 ZOZOは毎年iOSDC Japanに協賛し、ブースを出展していますが、多くの方との交流を通して今年も最高の3日間を過ごせました。実行委員会の皆さんに感謝しつつ、来年もまた素敵な時間を過ごせることを楽しみにしています! ZOZOでは、来年のiOSDC Japanを一緒に盛り上げるエンジニアを募集しています。ご興味のある方はこちらからご応募ください。 hrmos.co また、会期中は混雑していることも多く、じっくりとお話しする時間が取れなかったので、もう少し詳しく話を聞きたい! という方はカジュアル面談も受け付けています。 hrmos.co それではまた来年のiOSDC Japanでお会いしましょう! 現場からは以上です!
アバター
はじめに 技術評論社様より発刊されている Software Design の2024年5月号より「レガシーシステム攻略のプロセス」と題した全8回の連載が始まりました。 ZOZOTOWNリプレイスプロジェクトで採用したマイクロサービス化のアプローチでは、安全かつ整合性のとれたデータ移行が必須となりました。第4回では、このマスタDBの移行について紹介します。 目次 はじめに 目次 はじめに マスタDB移行 マスタDB移行について 要件と課題 テーブル構成を再設計したうえでデータ移行を実施する ダウンタイムなしでデータ移行を実施する 方針 異なるDBおよびデータスキーマ間で移行を実施するためEmbulkを使用する ダブルライトをリリースし、データ移行中に発生するDBへの書き込みを両DBにアトミックに実施する データを一時DBに格納し、一時DBから移行先DBにデータを移行する BulkloadとBackfillを複数回実施する データ移行の手順 1. ダブルライト処理の実装 2. データの移行の実施(1回目のBulkload&Backfill) 1回目のBulkload 1回目のBackfill 3. 削除データの対応(2回目のBulkload&Backfill) Column: Backfillの効率化 データ移行の実施 DB移行の実施 移行後に発生した問題 不整合の解消手順と実施 おわりに はじめに はじめまして。株式会社ZOZO技術本部ECプラットフォーム部の渋谷と裵です。 ZOZOTOWNは運営開始から10年以上の間オンプレミス環境で構築されたシステムを、アーキテクチャを変えずに拡大してきました。レガシーなシステムは、スケーラビリティや保守コストの問題など多くの課題が存在しています。それらを解決すべく弊社は2017年からZOZOTOWNのマイクロサービス化を進めています。 第3回 でもお伝えしたとおり、ZOZOTOWNリプレイスプロジェクトは、ストラングラーフィグパターンを採用したマイクロサービス化を進めています(図1)。ストラングラーフィグパターンとは、古いシステムの機能を徐々に新しいマイクロサービスに移行し、最終的にはすべての機能が新しいシステムに置き換えられた段階で、旧システムを停止するという戦略です。段階的にシステムを移行していくには、旧システムからマイクロサービスが使用するデータを安全に移行し、リプレイスプロジェクトが完了するまでの期間、新旧システムが整合性を保ちながら共存させる必要があります。 図1 ストラングラーフィグパターンによるマイクロサービス化戦略 そのため第4回では、安全かつ新旧システムで整合性を保証したうえでマスタDBの移行を行った方法を紹介します。 マスタDB移行 マスタDBの移行の説明に入る前に、本記事で使用する各用語について定義しておきます。 用語 定義 移行元DB 移行対象のデータが格納されているSQL Serverのテーブル 移行先DB 恒常的な本番運用を想定しているMySQLのテーブル 一時DB 移行元DBからダンプしたデータを格納する一時的なMySQLのテーブル 削除用一時DB 移行元DBと移行先DBの不整合を解消するために、削除対象となるデータを格納する一時的なMySQLのテーブル Bulkload 移行元DBから一時DBにデータをロードすること Backfill 一時DBと移行先DBの差異を埋める処理のこと td:nth-child(2) { text-align:left; } マスタDB移行について 今回のテーマである「DBの移行」は、オンプレ環境で使用していたSQL Serverから、マイクロサービスアーキテクチャに合わせたクラウド環境のAurora MySQLへ移行することを指します。 ZOZOTOWNは、各マイクロサービスが専用のDBを持っており、DB移行の際にはそれぞれのマイクロサービスが使用するデータを、オンプレ環境のSQL Serverからコピーする必要があります。しかし、ただSQL ServerからAurora MySQLへデータをコピーするだけでは済みません。レガシーな設計をモダンに再構築することや、「事業を止めない」というZOZOTOWNリプレイスプロジェクトのポリシーに則し、ダウンタイムなしでデータ移行を実施する必要がありました。 ここでは、ユーザーに影響を与えず安全にデータ移行を実施するための要件、およびそれに伴う課題や実際に採用した移行戦略について紹介します。 要件と課題 データ移行を実施するにあたって、次のような要件を満たす必要がありました。 テーブル構成を再設計したうえでデータ移行を実施する ダウンタイムなしでデータ移行を実施する テーブル構成を再設計したうえでデータ移行を実施する 冒頭でも言及しましたが、DB移行はただ単純にデータをコピーするだけでなく、既存のレガシーな設計をモダンなものに再設計することも重要な目的の1つです。SQL Serverに存在する主要なテーブルは2006年前後に設計されたものが多く、長い歴史を経たことで最適とは言えない状態にありました。 マスタDBの移行に伴い、移行元DBに存在していた不要カラムの整理、データ型やテーブル間の関係の見直しをするためにテーブル構成を再設計する必要がありました。また、オンプレ側とクラウド側でDBの種類やテーブルスキーマが異なり、レプリケーション等の手法を用いてデータ移行を実施できないため、スキーマ変更の実現手段を検討する必要もありました。 ダウンタイムなしでデータ移行を実施する ZOZOTOWNは年間1,100万人以上(2024年3月時点)の方々にご利用いただいています。アクセスが非常に多く、サービスを停止することによる機会損失が大きいため、複雑なプロセスを経てもダウンタイムなしでデータ移行を実施する必要がありました。 サービスを停止せずにデータ移行を実施するということは、データ移行実施中も移行元のDBに変更が頻繁に発生します。データ移行が完了したときは、当然ですがオンプレ側とクラウド側でデータの不整合が発生していないことが要求されるため、データ移行中に発生した変更も含めて完全に移行先DBに反映されている必要がありました。 方針 先に記載した要件と課題に対して、次の方針を立てました。 異なるDBおよびデータスキーマ間で移行を実施するためにEmbulkを使用する ダブルライトをリリースし、データ移行中に発生するDBへの書き込みを両DBにアトミックに実施する データを一時DBに格納し、一時DBから移行先DBにデータを移行する BulkloadとBackfillを複数回実施する 異なるDBおよびデータスキーマ間で移行を実施するためEmbulkを使用する Embulk は、異なるデータベース間でのデータ移行を容易にするオープンソースのETL *1 ツールです。並列処理をサポートしているため、大量のデータを効率的に移行できます。 Embulkを採用することで、SQL Server とAurora MySQLのような異なるDBおよびデータスキーマ間でのデータ移行を効率的かつ正確に行えます。 ダブルライトをリリースし、データ移行中に発生するDBへの書き込みを両DBにアトミックに実施する データ移行実施中に正しくデータが書き込まれるように、先にダブルライト処理を実装しました。ダブルライト処理とは、DBへのINSERT/UPDATE/DELETEの書き込み処理が発生した際に、移行元DBおよび移行先DBの両DBに書き込むことを指します。 ただ、ダブルライトするそれぞれのDBは、異なるDBインスタンス・異なるDB管理システムです。オンプレへの更新リクエストとクラウドへの連携の両方が成功した時点で更新をコミットする必要があります。そのため、オンプレで成功していてもクラウド側で失敗したらオンプレ側をロールバックするように、オンプレ側でトランザクションを開始する形でアプリケーションコードを実装しました。 このようにアプリケーションレベルでトランザクションを管理することにより、データ移行中のDBへの書き込みを移行元と移行先のそれぞれ異なるDB間でアトミックに実施できるようになりました。 データを一時DBに格納し、一時DBから移行先DBにデータを移行する 次の2つの要因で、移行先DBにEmubulkで直接ロードするのではなくクラウド側の一時DBを挟む方法を採用しました。 1つは、移行元DBと移行先DBはそれぞれ別のチームが管理しており、一度自分たちの管理するクラウド側にデータを持ってきたほうが操作しやすいためです。 もう1つは、データ移行実行中のデータ読み込み(クエリ実行)と該当データの書き込みの間でレコード削除が実行されると、本来削除されるはずのレコードが後から挿入されることになり差異が発生するにもかかわらず、Embulkではこれらの条件を判別するなどの細かい制御ができないためです。 自作ツールを使用して一時DBから移行先DBへデータを格納することで、SQLだけでは実装が困難な変換が可能になりました。また、ダブルライトの影響を受けることなくダウンタイムなしかつデータ整合性を確保しつつデータ移行を実施できます。 BulkloadとBackfillを複数回実施する ダブルライトが実装されていたとしても、その後のデータ移行で同様にデータ不整合が発生する可能性があるため、BulkloadとBackfillを複数回実施することにしました。 データ移行の手順 前節でも説明したとおり、データ移行実施中にもダブルライトは実行され続けており、その差異を埋めるためにBackfillを複数回実行します。本節ではレコードを一意に特定できるサロゲートキーを持つテーブルを前提として紹介していきます。 今回のDB移行は次のステップで行いました。 ダブルライト処理の実装 データ移行の実施(1回目のBulkload&Backfill) 削除データの対応(2回目のBulkload&Backfill) 1. ダブルライト処理の実装 SQL Server の移行元DBでINSERT/UPDATE/DELETEが発生した際、MySQLの移行先DBでも同様の処理を行うように実装します。この時点で移行先DBは空の状態なので、存在しないデータをUPDATEしないように移行先DBではUPSERT(データが存在すればUPDATE、しなければINSERT)する必要があります。 2. データの移行の実施(1回目のBulkload&Backfill) 1回目のBulkload Embulkで1回目のBulkloadを行い、移行元DBのデータをMySQLの一時DBに転送します。Embulkでは転送元・転送先DBの情報や、転送対象のデータを抽出するためのクエリをリスト1のように指定します。 in : type : sqlserver host: ' {{env.SQLSERVER_HOST}} ' port: ' {{env.SQLSERVER_PORT}} ' user : ' {{env.SQLSERVER_USER}} ' password: ' {{env.SQLSERVER_PASSWORD}} ' database: ' {{env.SQLSERVER_DB}} ' query: ¦ SELECT sqlserver_id as mysql_id, sqlserver_name as mysql_name, sqlserver_password as mysql_password FROM sqlserver_table out : type : mysql host: ' {{env.MYSQL_HOST}} ' user : ' {{env.MYSQL_USER}} ' password: ' {{env.MYSQL_PASSWORD}} ' database: ' {{env.MYSQL_DB}} ' table : mysql_table リスト1 1回目のBulkload inフィールドで移行元DBを、outフィールドで一時DBを定義します。移行元DBと移行先DBでカラム名が異なるので、対応するカラムをas句で変換しています。outフィールドではcolumn_optionsを使用すればカラムごとの制約も設定できますが、今回は移行先DBがテーブル定義に基づいてすでに作成されており、一時DBからのBackfill時に違反検知が可能なので使用しませんでした。 1回目のBackfill 次に、一時DBのデータをダブルライト中の移行先DBにBackfillします。ここで、BulklaodしてからBackfillするまでの間に、移行先DBにダブルライトで先にINSERTされるデータを想定し、Duplicate entryエラーを処理する必要があります。 ほかにも、Backfillする際には保存するデータを正しく取捨選択する必要があります。次のケースを考えてみます。 Aさんの情報が移行先DBにダブルライトでINSERTされる Bulkloadを実行する Backfillする前にダブルライトで移行先DB上のAさんのデータがUPDATEされる Backfillを実行し、Duplicate entryエラーが発生する この場合、ダブルライトでUPDATEされたほうが正の会員データなので、Duplicate entryエラーが発生した古い会員データは破棄する必要があります。 Backfillが完了したら、想定どおりBackfillされているかを確認するために一時DBと移行先DBの会員データを1つずつ突合して整合性を確認します。ダブルライトが続いている移行先DBは一時DBの会員を包含しているので、一時DBの会員がすべて移行先DBに存在するかを確認することになります。 ダブルライト中に1回目のBulkload&Backfillを実行した際、データは図2のように遷移していきます。最後の移行元DBと移行先DBのデータを比較すると、この段階ではまだデータが一致していない可能性があることがわかります。 図2 1回目のBulkload&Backfill実行時のデータ遷移の例 3. 削除データの対応(2回目のBulkload&Backfill) 図2のように1回目のBulkloadを行った後、移行元DBで会員の退会が発生したケースを考えてみます。 移行元DBの会員は削除されますが、Bulkloadを行った時点では存在していた会員なので、1回目のBackfillにより移行元DB上で存在しない会員を移行先DBにINSERTすることになり不整合が生じてしまいます。これを解消するためには、一時DBの作成と同時に削除対象の会員を保存する削除用一時DBを作成し、DELETEした会員を削除用一時DBにINSERTした後、Backfill完了後に削除用一時DBの会員を移行先DBからDELETEする、といった手法が考えられます。 今回移行元DBでは退会した会員をDBからDELETEする物理削除ではなく、退会したことをフラグとしてカラムで管理する論理削除を採用していたので、この情報を基に削除対象会員を抽出しました。 2回目のBulkloadの実行内容の例はリスト2のとおりです。移行元DBの削除フラグを基に削除対象会員を取得しています。また、Bulkloadされた後の会員が削除対象なので、FIRST_BULKLOAD_START_AT以降の会員を取得するようにしています。 in : type : sqlserver host: ' {{env.SQLSERVER_HOST}} ' port: ' {{env.SQLSERVER_PORT}} ' user : ' {{env.SQLSERVER_USER}} ' password: ' {{env.SQLSERVER_PASSWORD}} ' database: ' {{env.SQLSERVER_DB}} ' query: ¦ SELECT DISTINCT sqlserver_id as mysql_deleted_id FROM sqlserver_table WHERE sqlserver_delete_flag = 1 AND sqlserver_deleted_at >= ' {{env.FIRST_BULKLOAD_START_AT}} ' out : type : mysql host: ' {{env.MYSQL_HOST}} ' port: ' {{env.MYSQL_PORT}} ' user : ' {{env.MYSQL_USER}} ' password: ' {{env.MYSQL_PASSWORD}} ' database: ' {{env.MYSQL_DB}} ' table : mysql_deleted_table リスト2 2回目のBulkload 2回目のBackfillでは、移行先DBから削除用一時DBの会員をDELETEします。その後、1回目と同様に想定通り会員が削除されているか、削除用一時DBの会員が移行先DBに含まれていないかを検証します。 ダブルライトしながら2回目のBulkloadとBackfillを行った場合、データは図3のように遷移します。1回目のBulkload&Backfillで発生していたデータの不一致が解消されていることがわかります。 図3 2回目のBulkload&Backfill実行時のデータ遷移の例 Column: Backfillの効率化 Backfillを行う際、1レコードずつINSERTするのは非常に時間がかかります。そのため、リストAのように1回の実行で大量のデータをまとめて取り込む(BulkInsert)ようにしました。 INSERT INTO mysql_table(name, email, password) VALUES ( ' 鈴木太郎 ' , ' tanaka@example.com ' , ' xxxx ' ), ( ' 佐藤花子 ' , ' suzuki@example.com ' , ' xxxx ' ), ( ' 田中一郎 ' , ' tanaka@example.com ' , ' xxxx ' ); リストA BulkInsertの例 しかし、これだけだとDuplicate entryエラー発生時、どのレコードが原因だったのかを特定できません。そのため、BulkInsert中にいずれかがDuplicate entryエラーになった場合は、1件ずつINSERTしなおすように実装する必要があります。 今回Bulkloadするレコード数の単位は1,000件としました。これは、BulkInsert中はテーブルロックがかかってしまうため件数を多過ぎないようにしたいという意図と、少な過ぎると速度向上が期待できなくなるという懸念を加味して経験則から設定された値です。 データ移行の実施 DB移行の実施 いきなり本番DBで移行を試みるわけにはいかないので、先に検証用の環境で素振りを行います。素振りの結果、メールアドレスと旧ID(メールアドレス以前にログインIDとして使用していたもの)を管理するそれぞれのカラムで次の問題が発生しました。 移行元DBでは重複を許可していたが、移行先DBではユニークキーとして定義されている 文字コードが移行元DBと移行先DBで異なるため、全角文字が「???」になってしまう データの重複に関しては、移行元DBで最後にアクセスした日時を保存しているので、メールアドレスを管理するカラム・旧IDを管理するカラムともに、一番新しいデータを正として移行するようにしました(同じメールアドレス・旧IDを使用しているが利用者は別の会員がいた場合、カスタマーサポートでの問い合わせで対応するようにしました)。 文字コードに関しては、カラムごとに別の対応を取りました。 メールアドレスを管理しているカラムの場合、重複時の対応と同様に最終アクセス日時が新しいデータを正として移行しました。一方旧IDを管理しているカラムの場合、全角文字を含むカラムはNULLとして保存するようにしました。この方針は、今後の新規会員は旧IDで登録されないこと、旧IDがNULLになることでログインできなくなる会員がごく少数なのでカスタマーサポートへの問い合わせで十分対応できることを考慮して至った結論です。 上記に対応して検証環境で正常に移行できることを確認した後、本番環境でもおおむね想定通りデータ移行を完了できました。約2千万件のデータ移行を完了するまでのBulkload&Backfillの所要時間は、それぞれ1回目が23分と16分、2回目が24秒と9秒でした。 移行後に発生した問題 DBの移行が完了して一件落着かと思いましたが、移行してからしばらく経過した後に新たな問題が発生しました。ZOZOTOWNではPayPayに連携して決済を行えるのですが、この方法で決済ができないというお問い合わせが多発したのです。 調査の結果、原因はPayPay連携解除時のダブルライトができていなかったことでした。 本来であれば、連携解除した際にオンプレ側のDBで解除処理をした後でマイクロサービス側のDBでも解除処理をする必要があります。しかし、その考慮が漏れていたためマイクロサービス側に連携解除済みの会員が残ることでデータ不整合が発生し、その会員が再連携したタイミングで既に連携データが存在するというエラーが返っていたのです。 不整合データ数が少なければ手動やスクリプトでの対応を考えたのですが、想定よりもはるかに多くのデータに不整合が生じていたので、マスタDBと同様にオンプレDBからデータ移行を行うことにしました。 不整合の解消手順と実施 基本的にはマスタDB移行の手順と同じですが、より正確に移行したことを確認するために今回は1、2回目のBulkload&Backfillを完了した後で3回目のBulkloadを行い、抽出したデータとマスタDBのデータを突合しました。 こちらも先に検証環境で素振りを行いました。結果として計4回の素振りを行いましたが、検証環境では次のようにオンプレやクラウドDBのPayPay連携会員テーブルが本番では想定していない状態で運用されていたので、この原因調査や対応を行う必要がありました。 ZOZOTOWN会員テーブルへの外部参照を持っているPayPay連携会員テーブルのカラムに、ZOZOTOWN会員テーブルに存在しないレコードが登録されていた オンプレDBに重複した会員が登録されており、Backfill時に重複エラーが発生した 検証環境での動作確認を終えた後、本番環境で本番データの移行を行いました。一部想定外のデータが存在していたり、移行作業中に想定外の操作をした会員が存在していたりしたので多少エラーは発生したものの、手動でこれらを対応しつつ、無事PayPay連携で決済できない問題を解消できました。 おわりに 連載第4回では、ZOZOTOWNリプレイスに伴うデータ移行について紹介しました。 アクセス数が非常に多い大規模なシステムを、サービス停止することなくリプレイス対象の機能ごとにデータ移行をする必要があるうえに、レガシーなテクノロジーからモダンなテクノロジーへの移行を行うという要求がある中で、手間はかけつつも安全にデータ移行を成功させることができました。 また、本記事で紹介した戦略を用いて、今後ほかのマイクロサービス化プロジェクトにおいても迅速かつ安全にデータ移行を成功させることができると考えています。 大規模なシステムからマイクロサービス化への移行を検討している方々の参考になれば幸いです。 本記事は、技術本部 ECプラットフォーム部 ID基盤ブロックの渋谷 宥仁、裵 城柱によって執筆されました。 本記事の初出は、 Software Design 2024年8月号 連載「レガシーシステム攻略のプロセス」の第4回「ZOZOTOWNリプレイスにおけるマスタDBの移行」です。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com *1 : Extract Transform Loadの頭文字を取ったもので、データを転送元から抽出し、適切なフォーマットに変換し、転送先へ出力する処理のこと。
アバター
はじめに こんにちは。基幹システム本部・物流開発部の上原です。昨年度に中途入社しまして、現在はZOZO基幹システムのリプレイスを担当しています。前職では、SESエンジニアとしてリプレイスプロジェクトに上流工程から参画し、大規模なシステムの言語リプレイスを経験してきました。さて私の紹介はこの辺りにして本題に入ります。 基幹システムリプレイスは既に進行しており、本年度には発送領域の機能を発送マイクロサービスとして切り出してリリースしました。それに続いて、入荷領域の機能をマイクロサービス化ではなくモジュラーモノリスに移行するリプレイスも進んでおり、こちらは細かく区切った単位でリリースをしています。 本記事では、自動テストによる「等価比較」を本番環境で実施しながら言語リプレイスを進めた事例を紹介します。この事例では、「言語間での処理の等価性を保証し、安心・安全にリプレイスをする」ということを目的としています。この事例が大規模なシステムの言語リプレイスの一助となれば幸いです。また、この事例の前段として先日行われたMeetUpにて入荷リプレイス自体の要件などを説明しています。 speakerdeck.com このスライドを見てから本記事を読むと更に理解が深まるかもしれませんので、よろしければぜひ。 目次 はじめに 目次 基幹リプレイスの方針 モジュラーモノリス基盤に移行するには 既存システムと並行開発を進めるために なぜ並行開発をする必要があるのか 既存ファイルの変更通知 段階的リプレイスとフェーズについて フェーズ1の定義 フェーズ2の定義 等価比較について 等価比較の仕組みの導入 等価の定義 等価比較の種類と概要 取得系の場合 更新系の場合 等価比較の実装 取得系の実装イメージ 等価比較APIの実装イメージ 更新系の実装イメージ 等価比較バッチの実装イメージ 等価比較のON/OFF更新用の画面を作成 Slack通知の導入 等価比較のメリデメ まとめ 基幹リプレイスの方針 既存の基幹システムは、モノリシックかつレガシーな技術で稼働しています。異なる領域の機能が同居するモノリシックな構成であるが故に、ある領域における障害が全体に影響を与えてしまうという課題が存在します。その状況を打破するべく、昨年、発送機能のリプレイスを開始しました。 発送機能は、その他の機能との障害分離が必須要件であることに加え、その他の機能との結びつきも比較的弱いという状態でした。そのため発送リプレイスは、言語の置き換え・アーキテクチャの刷新・DB分離を実現した発送マイクロサービスとして計画的なビッグバンリリースをしました。言い換えるとマイクロサービス化によって発送機能は完全に独立したモジュールになりました。 ただ、最初からマイクロサービス化できるかどうかには条件があります。 対象機能が独立して開発・運用できるか データの分割ができるか この条件が満たせない限りは、モジュール化の難易度がグッと上がります。この難易度を差し引いてもメリットがあれば、マイクロサービスを目指します。そうでなければ、モノリスのまま段階的にモジュール性を高めていき、いわゆるモジュラーモノリスを目指すことを基幹リプレイスの方針としました。 今回紹介する入荷リプレイスの事例は、発送リプレイスほど障害分離の優先度が高くなく、モノリスの他の機能と結びつきが強い領域です。そのため、マイクロサービス化のメリットが少ないと判断し、この領域は、モジュラーモノリスを目指して段階的にリリースをすることにしました。まずは基盤を移行して、完全に独立したモジュールにできるかを開発しながら検討していく方式です。 モジュラーモノリス基盤に移行するには モジュラーモノリス基盤 1 への移行では様々な観点の変更が必要です。その1つとしてVBScriptからJavaへの言語リプレイスがあります。言語リプレイスには、処理の等価性を保証するという大きな壁があり、等価性を保証するには、以下2つの方法があると私は考えています。 移行前と後の言語をそれぞれ調査して、処理単位で等価か人間の目で判断する方法 機械的に何らかの方法でテストをして、等価性を保証する方法 今回は、後者の方法を取って等価性を保証するようにしました。そこにいくつかの工夫を加えて、モジュラーモノリスへの基盤移行を安心・安全に進めました。本題の等価比較の話の前に、並行開発と段階リリースについて、次章で詳しく説明していきます。 既存システムと並行開発を進めるために なぜ並行開発をする必要があるのか 今回のモジュラーモノリス基盤移行では、緊急度の高い改修は移行を待たず反映させたいので、既存の開発を完全には止めず必要に応じて並行開発を行います。 既存ファイルの変更通知 前述した通り、既存開発と並行開発しているので、リプレイス予定のコードであっても緊急度の高い場合は変更されます。それとは別に意図せずリプレイス予定のコードが変更されることも考えられます。リプレイス側ではそれら全ての変更を検知し、取り込む必要があります。なので、対象コードが更新されたらSlack通知するようにしました。 対象コードに入った変更をファイル単位で通知しているので、内容を確認し、自分たちの実装に影響があるかどうかを確認します。その上で要対応ならスタンプをつける運用にしました。スタンプをつけた上で対応方針や取り込み時期などをSlackのスレッドに記載する対応をします。そして、そのスレッドを元に後日取り込みを行います。 段階的リプレイスとフェーズについて モジュラーモノリス基盤移行では、段階的にリプレイスをするためにフェーズ分けをしました。フェーズ1〜3までを検討しており、フェーズ2まで確実にする予定です。 入荷領域には、複数の実作業があり、この作業単位での開発をしました。作業単位毎に独立してフェーズが進行しています。いきなり入荷領域全てをリプレイスしているわけではありません。これを段階的リプレイスと呼んでいます。フェーズごとに区切りがあるので、この先のフェーズに進むか再度検討もできます。 次に、各フェーズの定義を説明するのですが、今回はフェーズ2まで紹介します。 フェーズ1の定義 既存システムでは、VBScript内にビューとビジネスロジックが混在しています。これもまた処理の複雑さを生んでしまっている原因の1つです。本来ビューは画面表示に関わることのみ考えればよく、ビジネスロジックは画面の処理について考える必要がありません。しかし、既存システムでは、それぞれの責務がはっきりとしておらず、相互依存しています。なのでフェーズ1では、主にビューとビジネスロジックの分離を目指します。 そのために以下の手順が完了するとフェーズ1を完了とします。 VBScriptで実装しているビジネスロジックをJavaで実装したWeb API(以降、JavaAPIと呼ぶ)に移行する VBScriptからの基幹DBへのアクセスを無くす 具体的には、JavaAPIは、既に存在しているモジュラーモノリスへ実装し、基幹DBアクセスできるようにします。これを図示すると以下の通りです。 左側は既存システムの状態を表しており、右側はフェーズ1完了後を表しています。後述しますが、置き換え後のJavaAPIの処理は置き換え前のVBScriptの処理と等価比較をしており、等価と判断されたのちにJavaAPIのみを呼び出すように切り替えます。 フェーズ2の定義 フェーズ1でビューとビジネスロジックの分離は完了しました。次は、フロントエンドのアプリケーションをJavaで実装し、 脱VBScript を目指します。これでリプレイス対象をモノリスから切り離すことができます。ここまでの作業が完了するとフェーズ2が完了です。 図示すると以下の通りです。 先ほどと同じように左側はフェーズが進む前の状態、つまり、フェーズ1の状態です。右の図はフェーズ2完了後を表しています。フェーズ2は、モノリスからビューを切り離し、入荷用フロントエンドとして独立させます。 次の章から本題の等価比較についての説明に入ります。 等価比較について 等価比較の仕組みの導入 フェーズ1を進めるために等価比較の仕組みを導入しました。仕組みについて語る前に、まずは等価比較の概念について説明します。 言語リプレイスは、リファクタリングをすることである と私は考えています。 書籍リファクタリング では、以下のようにリファクタリングについて定義されています。 リファクタリングとは、ソフトウェアの外部の振る舞いを保ったままで、内部の構造を改善していく作業を指します。 言語リプレイスも同様に振る舞いを変えずに詳細を変える作業だと思っています。つまり、言語リプレイスに1番重要なのは、振る舞いが変わっていないことを確認することです。では何が必要でしょうか。機械的に振る舞いが変わっていないことを判断できるテストが必要です。これを自動でテストし、判断するのが等価比較の仕組みです。自動テストには、inputとoutputが必要です。 今回は、本番環境のユーザ入力とシステムの出力を使用しました。そのため、検証期間中はユーザがシステムをいつも通り利用するだけで、開発者は振る舞いが変わっていないかどうかの確認ができます。概要についての説明は以上です。次は、等価比較の定義について説明します。 等価の定義 ここでは、等価比較の仕組みにおける等価の定義を説明します。以下を満たす場合、等価であると定義します。 画面に表示される内容が一致している DBなどの外部システムの状態が一致している これを機械的に判断するために、以下の指標を立てました。 HTMLテンプレートとそこに埋め込む変数の値が一致している SQLなどの外部システムへのコマンドが一致している 上記の指標を満たしている場合、対応する等価の定義を満たしていると考えます。 では、ここから等価比較の詳細についてお話ししていこうと思います。 等価比較の種類と概要 等価比較は、取得系と更新系に分けて考えます。取得系の等価比較は、Javaで実装したWeb APIを使用します。以降、これを等価比較APIと記載します。更新系の等価比較では、等価比較APIに加えてJavaで実装した定期実行バッチを使用します。以降、これを等価比較バッチと記載します。等価比較バッチは、SQLなどの外部システムへのコマンドの履歴を比較します。等価比較APIは、指標1を満たすか、等価比較バッチは、指標2を満たすかそれぞれ検証します。 取得系の場合 取得系の等価比較は、以下の流れで行います。 開発環境で比較して、エラーや不等価にならないかを確認する 本番環境で比較して、エラーや不等価にならないかを確認する リクエストを比較用ファサードで受け取ります。比較用ファサード内では、旧実装の処理と新実装の呼び出し処理が書かれており、それぞれの処理を行ったのち処理結果をオブジェクトに格納します。そして両方のオブジェクトを等価比較APIに渡し、等価か判断します。 更新系の場合 比較の流れと比較用ファサードについては取得系と同じです。前述した通り、等価比較では本番環境を使用しているので新旧どちらも更新処理をしてしまうことはできません。なので、新実装から先に実行し、新実装のみ処理の最後でコミットせずにロールバックします。処理の中で実際に発行されたSQL文を履歴として残し、その新旧処理の履歴を等価比較バッチで比較します。ここからさらに取得系、更新系それぞれの詳細な実装の話に進みます。 等価比較の実装 取得系の実装イメージ Set User = GetUser () If User = Null Then FrameWorkObject . ProcessTemplate ( "ErrorTemplate" ) Else FrameWorkObject ( "userId" ) = User ( "userId" ) FrameWorkObject ( "userName" ) = User ( "userName" ) FrameWorkObject . ProcessTemplate ( "UserTemplate" ) End If 比較前の実装はこちらです。これはVBScriptの擬似的なコードです。ユーザを取得して、取得成功すればIDとNameを埋め込んだユーザ画面を表示し、失敗の場合はエラー画面を表示します。 '比較用ファサード Function Facade () ' 旧処理の結果を格納するオブジェクト Dim BeforeObject ' 新処理の結果を格納するオブジェクト Dim AfterObject Set User = GetUser () If User = Null Then FrameWorkObject . ProcessTemplate ( "ErrorTemplate" ) BeforeObject . template ( "ErrorTemplate" ) Else FrameWorkObject ( "userId" ) = User ( "userId" ) BeforeObject ( "userId" ) = User ( "userId" ) FrameWorkObject ( "userName" ) = User ( "userName" ) BeforeObject ( "userName" ) = User ( "userName" ) FrameWorkObject . ProcessTemplate ( "UserTemplate" ) BeforeObject . template ( "UserTemplate" ) End If 'ここで新処理を実行し結果をAfterObjectに格納 Set NewUser = Replace_GetUser () If NewUser = Null Then AfterObject . template ( "ErrorTemplate" ) Else AfterObject ( "userId" ) = NewUser ( "userId" ) AfterObject ( "userName" ) = NewUser ( "userName" ) AfterObject . template ( "UserTemplate" ) End If ' 等価比較APIにリクエスト Call ExecuteComparison ( "GET" , "/user/id" , BeforeObject , AfterObject ) End Function 比較後の実装はこちらです。新旧の処理結果を格納するオブジェクトを用意し、それぞれの処理結果を格納します。そして、新旧の処理結果が格納されたオブジェクトと呼び出したエンドポイント名を等価比較APIに渡し、等価か判断します。また、既存の処理は変更していないので、ユーザに影響はありません。等価比較の期間後に旧処理とファサードを削除し、新実装に切り替えるだけで良い実装になっています。 等価比較APIの実装イメージ 等価比較APIの処理は、旧処理のJSONオブジェクト、新処理のJSONオブジェクトをそれぞれ受け取って、JSONオブジェクトが等価か判定するという方法を取ることにしました。本番環境では、結果を保存する処理によるオーバーヘッドを無くすため、等価の場合、結果を残さないようにしました。それ以外の環境は、全ての結果を残します。 public Result execute(UseCaseInput input) { final var isEqual = isEqual(input.beforeParameter(), input.afterParameter()); final Map<String, String> env = System.getenv(); if (isSaveTarget(env.get( "APP_ENV" ), isEqual)) storeLogs.save( input.endpoint(), input.method(), isEqual, toSaveFormat(input.beforeParameter()), toSaveFormat(input.afterParameter())); if (Boolean.TRUE.equals(isEqual)) { return Result.success(); } else { return Result.failure(); } } /** 正規化を行った後、比較処理を行う */ Boolean isEqual(Parameter beforeParameter, Parameter afterParameter) {} 以下がJSONのリクエストイメージです。この場合は、新処理の結果が空なので、等価比較の結果は不等価です。 { " endpoint ": " /user/id ", " before ": { " object ": { " userId ": " 1 ", " userName ": " ZOZO太郎 " } , " template ": { " userTemplate " } } , " after ": { " object ": {} , " template ": {} } } 処理結果で不等価な場合は、以下のようにDBに保存されます。 さらに比較期間中はAPI呼び出しのタイムアウト時間を1秒に設定して、本番にできるだけ影響を与えないようにしています。こちらの設定は比較完了後、通常のタイムアウト時間に戻します。 更新系の実装イメージ 更新系において指標2を検証する実装イメージについて説明します。 '比較用ファサード Function Facade () ' VBScriptでUUIDを発行 Dim UUID: UUID = GenerateUUID () ' APIを呼び出す(ロールバックされる) UpdateUser ( UUID ) ' SQLを実行&SQL文をオブジェクトに一時保存する Dim SQL SQL = "UPDATE user SET name = 'ZOZO太郎' WHERE id = 1" cmd . execute ( SQL ) ExecutionHistory . SetCommand ( SQL ) ' 実行履歴としてSQLを残す Call SaveLogs ( UUID , ExecutionHistory . toJsonString (), "user/id" ) End Function VBScriptでUUIDを発行し、APIを呼び出し、SQL文を発行します。そして、履歴としてSQL文を残します。サンプルコードには記載がありませんが、実際は呼び出したAPIでも同じようにSQL文を履歴として残しています。 等価比較バッチの実装イメージ public void execute() { // エンドポイント毎にまとめた比較待ちの実行履歴を取得する final var compareReadyCmdExecLogsEachEndpoint = cmdExecLogsQueryDataSource.getCompareReadyCommandExecutionLogsEachEndpoint(); // 比較を実行してエンドポイント毎の比較結果を得る final var compareResultEachEndpoint = compareReadyCmdExecLogsEachEndpoint.stream() .map(logsByEndpoint -> compareCmdExecLogsByEndpoint.execute(logsByEndpoint)) .toList(); // 比較結果をUUID毎に保存する saveCmdExecLogsComparisonResults.execute(compareResultEachEndpoint); // 実行履歴を比較済みに更新する final var comparedUuidList = toDistinctUuidList(compareReadyCmdExecLogsEachEndpoint); cmdExecLogsUpdater.updateToCompleteStatus(comparedUuidList); // 結果が空でない場合は比較結果をSlackに送信する if (!compareResultEachEndpoint.isEmpty()) sendCmdExecLogsComparisonReport.execute(compareResultEachEndpoint); } 等価比較バッチは定期実行なので、複数のエンドポイントの履歴が残っている可能性があります。そのため、処理の冒頭でエンドポイント毎にまとめた比較待ちの実行履歴を取得しています。また、前述した通り、VBScriptとJavaAPIは別々に履歴を残していますが、両者が揃うタイミングで比較待ちステータスになるように工夫して片方だけが拾われないように工夫しています。 実装の説明はここまでになります。次は、等価比較をするにあたって工夫したことについて説明します。 等価比較のON/OFF更新用の画面を作成 等価比較の検証は実行頻度と連動しています。実行頻度が1以上の場合、等価比較の検証が有効になります。実行頻度は等価比較の頻度を示し、例えば3に設定すると、対象処理の3回に1回等価比較をします。この頻度を調節しながら段階的に高めることで、等価比較を活用した段階的リリースを実現しています。開発者が容易に等価比較を実施できるように専用の画面、API、テーブルを作成し、検索、登録、更新機能を実装しています。また、チーム単位やエンドポイント単位での検索機能を追加し、意図しないAPIへの等価比較を防止しています。 Slack通知の導入 前述の通り、等価比較の結果が不等価だった場合、Slackに通知をするようにしました。 等価の時は見なくても良いですが、不等価の場合は、見逃したくないのでメンションします。メンションが来たら、等価にならなかった処理の調査をし、修正、リリースを繰り返します。 等価のときはSlack通知にメンションがついていない 不等価のときはSlack通知にメンションがついている 最後に、等価比較のメリット・デメリットについて説明します。 等価比較のメリデメ 等価比較には、以下のようなメリット・デメリットがあります。 メリット 本番環境でテストができるので安心して処理の切り替えができる テストエビデンスの用意に手間がかからない デメリット 等価比較を実施すると処理量が増えるため、本番環境に高負荷がかかってしまうことがある メリットは多いのですが、デメリットもあるため、等価比較をする際には注意が必要です。ただし、デメリットに挙げた負荷に関しては実行頻度を調整することで軽減可能です。 まとめ 今回、「本番環境で自動テストをする等価比較を活用した言語リプレイス」について説明しました。等価比較は、リプレイスを進める上で非常に重要な要素です。等価比較の仕組みを導入することで機械的に処理の等価性を判断できます。この結果を活用することで安心・安全に段階的なリプレイスを進めることができます。今後も等価比較を活用して、モジュラーモノリス基盤移行を進めていきます。 現在もなお、フェーズ1とフェーズ2を並行して進めています。入荷全体をフェーズ2まで進めることは確定していますが、フェーズ3以降のマイクロサービス化を進めるかどうかはまだわかりません。ただ、領域を分けたことでDBの分割ができるかもしれないという希望も見えたし、イベントストーミングなどを活用し、マイクロサービス化できるかどうかを検討し続けています。今後もモジュラーモノリス基盤移行に関する情報発信をしていくので、チェックして頂けると嬉しく思います。ありがとうございました。 ZOZOの基幹システムの開発・リプレイスに興味を持ってくださった方は、以下のリンクからぜひご応募ください。 https://hrmos.co/pages/zozo/jobs/1809846973241688190 hrmos.co 基盤の移行先はリンクの記事の図にある新モノリスです。 ↩
アバター
はじめに こんにちは。株式会社ZOZOのSRE部プラットフォームSREチームに所属している はっちー と申します。 本記事では、Kubernetesクラスター上で自動カナリアリリース機能を提供するFlaggerが導入済みのマイクロサービスにおいて、手動カナリアリリースを実施する方法について紹介します。一見、矛盾するように思えるかもしれません。しかし、時にはそのような要件も発生することがあります。また、手動カナリアリリースで運用している状態からFlaggerの導入を検討している場合、導入後も念のために現行の手動カナリアリリースができるのか、という点は気になるかと思います。すでにFlaggerを導入している、これからの導入を検討している、という方の参考になりましたら幸いです。 目次 はじめに 目次 前提知識(Flagger) Manual Gatingの基本 Manual Gatingとは Manual Gatingが必要な背景 Manual Gatingを実現する要素 Webhooks loadtester Webhooks + loadtester Manual Gatingによる手動カナリアリリース手順 正常系 Step1. CanaryリソースのWebhooksの設定を追加する Step2. Deploymentの変更 Step3. トラフィック進行 Step4. Webhooksの設定削除 ロールバック 注意点 Step1. ロールバックする Step2. ロールバックを終了する Step3. Deploymentの変更を戻す 運用の工夫 周知 Manual Gating中に他のリリースをブロックする仕組み Reusable Workflowの実装 Reusable Workflowの利用 loadtesterのエンドポイントを叩く操作をスクリプト化する 自動ロールバックの発生を防ぐ 原因 対応案 案1 案2 案3 NGな方法 まとめ We are hiring 前提知識(Flagger) Flaggerは、Progressive Deliveryのツールです。Progressive Deliveryは、カナリアリリースやA/Bテスト、Blue-Green Deploymentなどの手法を組み合わせて、リスクを最小限に抑えながらリリースを進める手法です。Flaggerを使うことで、カナリアリリースを自動化できます。より詳細については、Flaggerの 公式ドキュメント や以下の弊社テックブログなどを参照してください。 techblog.zozo.com 本記事は、Flaggerの基本知識がある前提での説明となります。 Manual Gatingの基本 Manual Gatingによる手動カナリアリリースを実現するために必要な基本知識を説明します。 Manual Gatingとは Manual Gating とは、Flaggerで手動カナリアリリースをすることです。なお、「Manual Gating」という機能がFlaggerで特別に用意されているわけではありません。Flaggerのカスタムリソースである、CanaryリソースのWebhooksなどを利用して実現します。 Manual Gatingが必要な背景 FlaggerのProgressive Deliveryにより、カナリアリリースの進行におけるメトリクスの確認や判断、加重率の変更作業、切り戻し作業などを自動化し、リリースにおける工数を削減できます。自動化できるものをわざわざ手動で行う背景は何でしょうか。それは、より慎重にリリースをしたいケースがあるからです。たとえばZOZOの場合、 内製しているzozo-api-gateway の一部のリリースにおいて、以下のケースでManual Gatingを利用しています。 1週間程度の長い期間をかけて手動カナリアリリースをしたいケース。 とくにリクエスト数が多い週末にはN%で留めて様子を見ておき、週明けにリリースを進めたい。 Flaggerの自動カナリアリリースでは、進行が速すぎるかつ、このような柔軟な進行を実現できない。 FlaggerのMetricTemplateリソースによる外部ツール(Datadogなど)のメトリクスを利用した機械的判断では不十分なケース。 たとえば、パーソナライズされた検索結果を返すような機能のリリースの場合、単純なHTTPステータスのエラー率では判断できない。誤ったパーソナライズでの検索結果でも200レスポンスであるため。人の目で丁寧に動作確認する必要がある。 なお、zozo-api-gatewayにそのような検索機能のビジネスロジックが実装されているわけではない。カナリアリリースを導入できないレガシーシステムへのルーティングもzozo-api-gatewayが担っている。zozo-api-gatewayのルーティングコンフィグを変更したうえで、zozo-api-gateway自体のカナリアリリースをして、そのレガシーシステムのカナリアリリースしている。 Manual Gatingを実現する要素 Manual Gatingは、CanaryリソースのWebhooksとloadtesterのいくつかのエンドポイントを組み合わせて、実現します。 Webhooks Webhooksは、Flaggerのanalysisを拡張する機能です。各Webhookはそれぞれの実行タイミングで実行され、CanaryリソースはそのWebhookの応答ステータスコード(成功なら2xx)からカナリアリリースが失敗しているかを判断します。詳細は Webhookのページ をご確認ください。 以下のように、Canaryリソースの spec.analysis.webhooks[].type でWebhookの種類を設定します。 spec : analysis : webhooks : - name : "gate" type : confirm-rollout url : http://flagger-loadtester.test/gate/halt loadtester loadtesterは、gateエンドポイントを通じてリリースの進行を制御するPodです。gateエンドポイントの一覧は以下です。 /gate/check カナリアリリースの進行を確認する。 gateがopenであれば approved true として、カナリアリリースを進行する。closeであれば approved false として、進行しない。 /gate/open gateをopenにする。 /gate/closeが実行されない限り、gateはopenのまま。 /gate/close gateをcloseにする。 /gate/openが実行されない限り、gateはcloseのまま。 /gate/approve 常にHTTPステータスコード202を返す。 トラフィックの進行を進めるために使用されるが、今回は使用しない(理由は後述)。 /gate/halt 常にHTTPステータスコード403を返す。 トラフィックの進行を停止するために使用されるが、今回は使用しない(理由は後述)。 Webhooks + loadtester Webhooksとloadtesterを組み合わせてManual Gatingを行う方法を2つ紹介します。 1つ目は、 type: confirm-rollout のurlを /gate/halt にしておき、カナリアリリースを進行する際に /gate/approve へ変更する方法です。一時停止するたびに /gate/halt へ戻します。 YAMLファイルには以下の設定を追加します。 spec : analysis : webhooks : - name : "gate" type : confirm-rollout url : http://flagger-loadtester.test/gate/halt # 進行させる場合は/gate/approveにする これは、公式ドキュメントで紹介されている方法です。しかしながら、この方法は実際の運用には難しいと個人的に考えています。理由は、手動でkubectlのapplyコマンドを実行する必要があるからです。N%で一時停止したい場合に、タイミングがシビアなので、CIでの反映は難しいです。本番環境で手動applyをする運用は緊急時以外にしたくないですし、タイミングが間に合わず、加重率が意図せずに進んでしまう可能性が容易に発生しうると考えています。したがって、今回は /gate/approve と /gate/halt を使用しません。 2つ目は、定期的に /gate/check して、リリースを進行するタイミングになったら手動で /gate/open することで、1つ進行したら自動ですぐに /gate/close する方法です。基本的にgateはcloseにしておきます。checkしてopenだったらすぐにcloseする理由は、openのままだと次以降のcheckでもカナリアリリースが進行し続けてしまうからです。意図せず進行してしまわないように、自動でcloseするのは要件として必須としました。図にまとめると次の通りです。 一時Podを起動してloadtesterの /gate/open を手動で叩きます(1)。Canaryリソースが定期的にloadtesterの /gate/check を叩きます。もし、gateがopenになっていれば、VirtualServiceのweightを操作してカナリアリリースを進行させます。closeであれば何もしません(2、2')。Canaryリソースが /gate/close を叩きます(3)。 YAMLファイルには以下の設定を追加します。なお、通知がノイジーになるため、muteAlertをtrueにしています。 - name : confirm-traffic-increase-gate-check type : confirm-traffic-increase url : http://flagger-loadtester.istio-system/gate/check muteAlert : true - name : confirm-traffic-increase-gate-close type : confirm-traffic-increase url : http://flagger-loadtester.istio-system/gate/close muteAlert : true type: confirm-traffic-increase でurlを /gate/check にしているので、トラフィック進行判断の直前にcheckされます。つまり、 spec.analysis.interval で設定した間隔でcheckされます。進行させたいタイミングになったら手動で /gate/open を叩いてopenにします。そうすると、次のcheckでopenであることが確認され、カナリアリリースが進行されます。進行した場合は、自動で即座に /gate/close されます。同じ type: confirm-traffic-increase で設定されているため、 gate/check の後に /gate/close が実行されるためです。 また、ロールバック用に以下の設定もします。 /rollback/check はgateエンドポイントと同様で、openであればロールバックをして、closeであればロールバックしません。ロールバックの通知はノイジーでないので、muteAlertをtrueにしません。 - name : rollback type : rollback url : http://flagger-loadtester.istio-system/rollback/check 今回は、この2つ目の方法でManual Gatingします。 Manual Gatingによる手動カナリアリリース手順 今回は、zozo-api-gatewayを例に、実際のリリース手順を紹介します。 正常系 Manual Gatingにより、0%から100%まで手動カナリアリリースを進行する手順です。 Step1. CanaryリソースのWebhooksの設定を追加する 以下の spec.analysis.webhooks の設定をCanaryリソースに追加します。 spec : analysis : webhooks : - name : confirm-traffic-increase-gate-check type : confirm-traffic-increase url : http://flagger-loadtester.istio-system/gate/check muteAlert : true - name : confirm-traffic-increase-gate-close type : confirm-traffic-increase url : http://flagger-loadtester.istio-system/gate/close muteAlert : true - name : rollback type : rollback url : http://flagger-loadtester.istio-system/rollback/check この時点では、CanaryリソースのSTATUSに変化ありません。 Step2. Deploymentの変更 たとえば、Containerのイメージを変更します。 この変更により、CanaryリソースのSTATUSがProgressingになりますが、WEIGHTは0%のままです。つまり、期待通り、カナリアリリースが自動で進行せずに止まっています。 loadtesterのPodで以下のようなログ(抜粋)を確認できます。 approved false になっています。 {"level":"info","ts":"2024-04-24T06:19:28.806Z","caller":"loadtester/server.go:79","msg":"api-gateway.api-gateway gate check: approved false"} Step3. トラフィック進行 Kubernetesクラスター内に一時的なPodを起動して、loadtesterの /gate/open を叩きます。この一時的なPodはcurlの実行が完了すると自動で削除されます。適切なnamespaceで起動してください。curlのBodyにはCanaryリソースの名前とnamespaceを指定します。 kubectl run tmp-$(date "+%Y%m%d-%H%M%S")-<your_name> --image alpine:latest -n <tmp_pod_namespace> --rm -it -- sh -c "apk add --no-cache curl; curl -v -m 10 -d '{\"name\": \"<canary_name>\",\"namespace\":\"<canary_namespace>\"}' http://flagger-loadtester.istio-system:80/gate/open" curlが成功したら、CanaryリソースのWEIGHTが増えることを確認します。今回の例ですと、10%進行しています。この値は spec.analysis.stepWeight で決定されます。また、 stepWeights で変動的な値にもできます。 loadtesterのPodで以下のようなログ(抜粋)を確認できます。 gate opened -> approved true -> gate closed になっています。この後、 approved false に戻ります。 {"level":"info","ts":"2024-04-25T05:51:38.011Z","caller":"loadtester/server.go:110","msg":"api-gateway.api-gateway gate opened"} {"level":"info","ts":"2024-04-25T05:52:29.329Z","caller":"loadtester/server.go:79","msg":"api-gateway.api-gateway gate check: approved true"} {"level":"info","ts":"2024-04-25T05:52:29.333Z","caller":"loadtester/server.go:141","msg":"api-gateway.api-gateway gate closed"} 目標のN%になるまで上記のcurlを実行する作業を複数回実施し、進行させます。 spec.analysis.maxWeight を50%にしている場合、50%まで進行させてからもう1回 /gate/open を叩くと、100%までカナリアリリースが進行します。CanaryリソースのSTATUSが Promoting -> Finalizing -> Succeeded と遷移します。 Step4. Webhooksの設定削除 次回のリリースではManual Gatingを利用しない場合、Step1のWebhooksの設定を削除します。続けて、Manual Gatingによる手動カナリアリリースする場合は、そのままで結構です。 ロールバック Manual Gatingによるカナリアリリース進行の途中で、リリース作業をする人(以下、リリーサー)の判断により手動ロールバックする場合の手順です。Flaggerのanalysisにより自動でロールバックされる場合の話ではありません。したがって、正常系手順のStep3内で実行することを想定しています。 注意点 ロールバックをするとWEIGHTは0%に戻ってしまいます。たとえば、10%まで進行していたとして、20%に進行した段階でリリーサー判断によりロールバックをしたとすると、10%に戻るのではなく0%に戻ります。もし10%に戻したい場合は、再度、正常系の手順2の「マイクロサービスの変更」からやり直しです。 Step1. ロールバックする /rollback/open を叩きます。 kubectl run tmp-$(date "+%Y%m%d-%H%M%S")-<your_name> --image alpine:latest -n <tmp_pod_namespace> --rm -it -- sh -c "apk add --no-cache curl; curl -v -m 5 -d '{\"name\": \"<canary_name>\",\"namespace\":\"<canary_namespace>\"}' http://flagger-loadtester.istio-system:80/rollback/open" CanaryのSTATUSがFailedになり、WEIGHTが0%になります。 loadtesterで以下のようなログ(抜粋)を確認できます。 rollback opened -> approved true になっています。 {"level":"info","ts":"2024-04-24T09:56:27.253Z","caller":"loadtester/server.go:207","msg":"rollback.api-gateway.api-gateway rollback opened"} {"level":"info","ts":"2024-04-24T09:56:28.802Z","caller":"loadtester/server.go:177","msg":"rollback.api-gateway.api-gateway rollback check: approved true"} Step2. ロールバックを終了する /rollback/close を叩きます。 kubectl run tmp-$(date "+%Y%m%d-%H%M%S")-<your_name> --image alpine:latest -n <tmp_pod_namespace> --rm -it -- sh -c "apk add --no-cache curl; curl -v -m 5 -d '{\"name\": \"<canary_name>\",\"namespace\":\"<canary_namespace>\"}' http://flagger-loadtester.istio-system:80/rollback/close" loadtesterで以下のようなログ(抜粋)を確認できます。 rollback closed になっています。 {"level":"info","ts":"2024-05-21T08:07:56.017Z","caller":"loadtester/server.go:237","msg":"rollback.api-gateway.api-gateway rollback closed"} Step3. Deploymentの変更を戻す Deploymentの変更を戻します。 運用の工夫 上記の手順を、より安全で効率的に実施するための運用面で工夫した点を紹介します。 周知 Manual Gatingによるカナリアリリース期間中は、そのマイクロサービスに関する他のリリースはできません。もしカナリアリリース期間中に対象マイクロサービスの新しい変更をリリースしてしまうと、その新しい変更で0%から振り出しに戻ってしまいます。したがって、その期間は他のリリース作業はストップとなることを、関係各所へ事前に連絡しておくルールとしています。 Manual Gating中に他のリリースをブロックする仕組み 上記の周知による方法では限界があります。そこで、仕組みで問題が発生しにくいようにしています。具体的には、Manual Gating中に対象マイクロサービスの変更Pull Request(以下、PR)が作成されると、CIでエラーとなるようにしています。具体的には、あるマイクロサービスがManual Gating中であるかを返すGitHub Actionsの Reusable Workflow を実装し、それを使うようにしています。 Reusable Workflowの実装 以下は、そのReusable Workflowです。どのマイクロサービスでも利用できるように、Reusable Workflowとして汎用的に実装しています。 # This workflow checks if the microservice is under the process of manual gating by Flagger. name : check-under-manual-gating-flagger on : workflow_call : inputs : ... cluster_name : description : EKS cluster name required : true type : string canary_name : description : service name required : true type : string namespace : description : namespace required : true type : string ... jobs : check-under-manual-gating-flagger : runs-on : ubuntu-latest steps : ... - name : check if under manual gating process run : | aws eks --region "$AWS_REGION" update-kubeconfig --name ${{ inputs.cluster_name}} WEBHOOKS=$(kubectl get canary ${{ inputs.canary_name }} -n ${{ inputs.namespace }} -o json | jq '.spec.analysis.webhooks' ) if echo "$WEBHOOKS" | jq 'map(select(.type == "confirm-traffic-increase" and .url == "http://flagger-loadtester.istio-system/gate/check")) | length' | grep -q 0; then echo "Not under manual gating process" exit 0 else echo "Under manual gating process" exit 1 fi kubectl get canary の結果を確認しています。typeが confirm-traffic-increase かつ、urlが /gate/check であれば、1でexitします。1の場合はManual Gating中という意味です。 Reusable Workflowの利用 上記で定義したReusable Workflowを利用して、以下の2つの条件を満たす場合はエラーとするjobをマイクロサービスごとに用意します。ここではzozo-api-gatewayを例にします。 PRに対象のマイクロサービスの変更含まれる 対象のマイクロサービスがManual Gating中である k8s-block-update-under-manual-gating-api-gateway : uses : st-tech/zozo-platform-shared-infra/.github/workflows/check-under-manual-gating-flagger.yaml@v147 if : ${{ contains(needs.k8s-directory-changes.outputs.changed_dir, 'api-gateway' ) }} needs : - set-env - k8s-directory-changes with : kubectl-version : ${{ needs.set-env.outputs.kubectl_version }} oidc_role_arn : arn:aws:iam::xxx:role/zozo-platform-infra-gha cluster_name : prd-zozo-platform canary_name : api-gateway namespace : api-gateway 1つ目の条件に関しては tj-actions/changed-files を利用した別のjob( k8s-directory-changes )を利用しています。これはFlaggerのManual Gatingとは趣旨がずれますので詳細は割愛します。興味がございましたら ついに最強のCI/CDが完成した 〜巨大リポジトリで各チームが独立して・安全に・高速にリリースする〜 をご一読ください。 エラー時は、以下のようになります。 万が一、CIでエラーになっているにもかかわらずPRをマージしてしまった場合は、そのPRをRevertし、元のN%まで /gate/open を叩いて戻します。 なお、この方法ですとマイクロサービスの数が多いとjobの数も多くなり、きつくなりそうです。しかし、今のところはManual Gatingを利用するマイクロサービスがzozo-api-gatewayしか社内に存在しないため、問題になっていません。増えてきたら、より汎用的な実装にした方が良さそうです。 loadtesterのエンドポイントを叩く操作をスクリプト化する 上記の正常系の手順には、本番環境でPodを立てる手順が含まれます。しかしながら、本番環境のKubernetesクラスター上でPodを手動により作成する作業には、人為的ミスのリスクが伴います。そのリスクを少しでも軽減するために、操作を補助する以下のシェルスクリプトを開発しました。 #!/usr/bin/env bash [[ -n $DEBUG ]] && set -x set -e # Required config: # - config/global_config.sh # - config/k8s.sh SCRIPTS_DIR = " $( cd " $( dirname " $0 " ) "; pwd ) " . " ${SCRIPTS_DIR} /config/global_config.sh " function usage_exit() { echo " Usage: $0 -e prd -o zozo-api-gateway-ops -c api-gateway -n api-gateway [-g|-r] -a open " 1 >&2 echo " Options: " 1 >&2 echo " -e Env of EKS cluster(dev-onprem, dev, stg, prd) " 1 >&2 echo " -o Ops namespace such as zozo-api-gateway-ops " 1 >&2 echo " -c Canary resource name such as api-gateway " 1 >&2 echo " -n Namespace of Canary resource such as api-gateway " 1 >&2 echo " -g Gate " 1 >&2 echo " -r Rollback " 1 >&2 echo " -a Action of service(open, close) " 1 >&2 echo " NOTE: AWS Profile requires Power User or higher permissions " 1 >&2 echo " Prerequisites: " 1 >&2 echo " AWS Credentials you need to connect each env: " 1 >&2 echo " dev-onprem - ${AWS_ACCOUNT_ALIAS_DEV} " 1 >&2 echo " dev - ${AWS_ACCOUNT_ALIAS_DEV} " 1 >&2 echo " stg - ${AWS_ACCOUNT_ALIAS_STG} " 1 >&2 echo " prd - ${AWS_ACCOUNT_ALIAS_PRD} " 1 >&2 exit 1 } readonly REGION = " ap-northeast-1 " while getopts e:o:c:n:gra:h OPT do case $OPT in e ) ENV = $OPTARG ;; o ) OPS_NAMESPACE = $OPTARG ;; c ) CANARY_NAME = $OPTARG ;; n ) CANARY_NAMESPACE = $OPTARG ;; g ) GATE = 1 ;; r ) ROLLBACK = 1 ;; a ) ACTION = $OPTARG ;; h ) usage_exit ;; * ) usage_exit ;; esac done # validation for args if [[ -z " ${ENV} " ]] || [[ -z " ${OPS_NAMESPACE} " ]] || [[ -z " ${CANARY_NAME} " ]] || [[ -z " ${CANARY_NAMESPACE} " ]] || [[ -z " ${ACTION} " ]] ; then echo " ERROR: required arguments are missing " 1 >& 2 usage_exit fi case " ${ENV} " in dev-onprem|dev|stg|prd ) ;; * ) echo " ERROR: -e ${ENV} is invalid " 1 >&2 usage_exit ;; esac if [[ -z " ${GATE} " ]] && [[ -z " ${ROLLBACK} " ]] ; then echo " ERROR: Either -g or -r option is required. " 1 >& 2 usage_exit fi if [[ -n " ${GATE} " ]] && [[ -n " ${ROLLBACK} " ]] ; then echo " ERROR: Both -g and -r options are not able to pass at the same time. " 1 >& 2 usage_exit fi if [[ -n " ${GATE} " ]] ; then if [ " ${ACTION} " = "close" ]; then echo " ERROR: Request /gate/close is not expected in this script. " 1 >& 2 usage_exit fi RESOURCE = " gate " else RESOURCE = " rollback " fi # run bastion pod and curl echo "" echo " You're requesting to the loadtester pod by / ${RESOURCE} / ${ACTION} " . echo "" . " ${SCRIPTS_DIR} /config/k8s.sh " iam_username = " $( aws sts get-caller-identity --query Arn --output text | awk -F " . " ' {print $NF} ' ) " aws eks --region " ${REGION} " update-kubeconfig --name " ${CLUSTER_NAME} " exec kubectl run " tmp-manual-gating- $( date " +%Y%m%d-%H%M%S " ) - ${iam_username} " --image = alpine:latest -n " ${OPS_NAMESPACE} " --rm -it --restart = Never -- \ /bin/sh -c " apk add --no-cache curl > /dev/null 2>&1 ; \ curl -v -m 10 -d '{ \" name \" : \" ${CANARY_NAME} \" , \" namespace \" : \" ${CANARY_NAMESPACE} \" }' http://flagger-loadtester.istio-system:80/ ${RESOURCE} / ${ACTION} " 一見するとコード量が多く見えますが、実際には最後のawsコマンドのみが重要です。Podを起動してcurlをインストールし、loadtesterのエンドポイントを引数の情報に応じて実行します。他の処理は、usageの説明であったり、引数を取得してvalidationしているだけです。 使い方は以下の通りです。 # /gate/open ./scripts/flagger_loadtester.sh -e prd -o zozo-api-gateway-ops -c api-gateway -n api-gateway -g -a open # /rollback/open ./scripts/flagger_loadtester.sh -e prd -o zozo-api-gateway-ops -c api-gateway -n api-gateway -r -a open # /rollback/close ./scripts/flagger_loadtester.sh -e prd -o zozo-api-gateway-ops -c api-gateway -n api-gateway -r -a close 自動ロールバックの発生を防ぐ Manual Gatingによる手動カナリアリリース期間中に意図せず、自動ロールバックされてしまう事象が発生しましたので、その対応です。 原因 ロールバックされた原因は、Datadogへの通信が一定数(Canaryリソースの spec.analysis.threshold の値)以上エラーになったことでした。弊社の場合、analysisのメトリクス取得で、MetricTemplateリソースにより1分の間隔( spec.analysis.interval )でDatadogのクエリを叩いています。この通信はEKSクラスターとDatadog間のインターネット経由のため、数日間にわたるカナリアリリース期間中に通信エラーが複数回発生することは想像に難くありません。実際に、今回のケースでは約1週間の間に3回発生しました。 なお、エラー発生時のloadtesterのログは以下のようになります。 {"stream":"stderr","logtag":"F","message":"{\"level\":\"error\",\"ts\":\"2024-07-03T18:25:59.976Z\",\"caller\":\"controller/events.go:39\",\"msg\":\"Metric query failed for error-count: request failed:(省略) 対応案 案1 Manual Gatingを実施する際は、Canaryリソースの spec.analysis.threshold を極端に大きな値に設定する案です。極端に大きな値とは、 spec.analysis.interval が1分の場合かつ、10日間の期間でリリースする場合には、14400です。内訳は、 60m * 24h * 10days = 14400 です。 問題発生時はこの方法を最初に思いつき、暫定対応として採用しました。その後、恒久対応として採用されました。 なお、この案に限りませんが、Flaggerによるanalysisとロールバックは期待できなくなります。つまり、Manual Gatingによる手動カナリアリリース期間中には、Datadogのメトリクスを人間が確認し、適切なアラートを設定することが必要になります。ただし、これはFlagger導入前の手動カナリアリリースの時と同じ運用ですので、Flaggerを導入しても手動カナリアリリースをするのであれば、受け入れられるものかと思います。 案2 案1と同じような方法として、Canaryリソースの spec.analysis.metrics.thresholdRange.max を極端に大きな値にするという案もあります。しかし、あえて案1から変更するメリットはないため、選択しませんでした。 案3 Canaryリソースの spec.analysis.metrics を設定しない案です。この方法でも問題ありません。Canaryリソースの設定もシンプルになります。しかし、同じく、あえて案1から変更するメリットはないため、選択しませんでした。 NGな方法 意外かもしれませんが、Canaryリソースの spec.skipAnalysis をtrueにする方法はNGです。なぜならば、そもそもManual Gatingにならず、リリースを開始すると自動で100%まで進行してしまうからです。 まとめ 本記事では、Flaggerを導入したマイクロサービスにつきましても、手動カナリアリリースができることを紹介しました。方法は、CanaryリソースのWebhooksの設定とloadtesterのgateエンドポイントを利用することです。さらに、運用面における工夫も詳細に紹介しました。 普段はFlaggerによる自動カナリアリリースを利用し、必要に応じて手動カナリアリリースすることで、より安全かつ柔軟にリリースを進めることができます。もし、Flaggerの導入を検討している、運用で困っている、といった場合は参考にしていただけますと幸いです。 We are hiring ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co corp.zozo.com
アバター
はじめに こんにちは、 ZOZOMO店舗在庫取り置き というサービスの開発を担当している、ZOZOMO部OMOブロックの木目沢です。 皆様のチームでは定期的に振り返りをしていますか? 弊ブロックでは ZOZOMO店舗在庫取り置き サービスをスクラムで開発しています。スプリント期間は1週間で、スプリントの終わりには毎週振り返り(スクラムの用語では「スプリントレトロスペクティブ」)をしています。 今回はなぜ振り返りが欠かせないか、毎週振り返りを行ってきた成果や数々のプラクティスやワークと共に紹介します。 目次 はじめに 目次 なぜスクラムでは振り返りが必要なのか? 振り返りが続かない・活かされない理由 チームとしての振り返りになっていない チームとしての場ができていない 振り返りのプラクティスやチームのワークを紹介 KPT as ART チームコンピテンシーマトリックス デリゲーションポーカー おわりに なぜスクラムでは振り返りが必要なのか? スクラムは複雑な問題に適応するためのフレームワークでアジャイルにおける開発プロセスの1つです。 スクラムには振り返り(スプリントレトロスペクティブ)がプロセスの一部として組み込まれています。現時点で最新である 2020年のスクラムガイド には以下のように振り返り(スプリントレトロスペクティブ)が説明されています。 スプリントレトロスペクティブの⽬的は、品質と効果を⾼める⽅法を計画することである。 スクラムチームは、個⼈、相互作⽤、プロセス、ツール、完成の定義に関して、今回のスプリントがどのように進んだかを検査する。多くの場合、検査する要素は作業領域によって異なる。スクラムチームを迷わせた仮説があれば特定し、その真因を探求する。スクラムチームは、スプリント中に何がうまくいったか、どのような問題が発⽣したか、そしてそれらの問題がどのように解決されたか(または解決されなかったか)について話し合う。 (中略) スプリントレトロスペクティブをもってスプリントは終了する。 スプリントの終わりに必ず振り返りを行い、うまくいったこと、いかなかったことをチームで話し合います。 スプリントの終わりに振り返りを行う理由はメンバーの認知負荷の問題によるものです。複雑な状況に適応しているということは、もともとそれだけメンバーの認知負荷が高い状況にあります。振り返りの間隔が長くなるとチームのメンバーの認知負荷はさらに高まり、有効な振り返りがしづらい状況になります。1か月前のできごと、半年前のできごとを覚えているでしょうか。起きたことを記録していたとしてもそれらを掘り起こして思い出すのも大変困難です。このような状況で、開発をもっとうまくやろう、プロダクトをもっとうまくやろうと振り返ることは難しいのです。 一方で、ウォーターフォールのような予測計画型のプロセスでは、フェーズごとに異なる作業をするため、短期間での振り返りが効果的に活用されないことがあります。とはいえ、プロジェクトを成功させるため、次のプロジェクトで活用するためにも、適切なタイミングでの振り返りが重要です。 振り返りが続かない・活かされない理由 振り返りを実施していたがうまくいかずにやめてしまった、または頻度を減らしてしまったというようなチームも多いと思います。ここではうまくいかない振り返りのやり方とその理由・対処法をいくつか紹介したいと思います。 チームとしての振り返りになっていない 振り返りを行う際に、個々のメンバーの感想が中心になってしまうことがあります。しかし、これではチーム全体の成長や学びに繋がりにくくなります。個人の振り返りも大切ですが、組織として目指すべきは、チーム全体としての学習と成長です。 なぜチームなのか? ということを理解することが重要です。 ソフトウェアを作ることが容易になった現代において、ソフトウェアの適用分野が広がり、ソフトウェアに求める要求が複雑化してきたという背景が影響しています。さらに、ソフトウェアを作ることは容易になりましたが、実装技術やアーキテクチャは非常に複雑化しているということです。iOSやAndroidといったスマホアプリ・AWSなどのクラウド・マイクロサービス、CQRS・CI/CD・DevOpsなどそれぞれに専門家が必要なくらい技術領域が広がっています。 ビジネス的・技術的に複雑化している今の時代は一人で大きなことを成し遂げるのが困難な時代と言えます。 そんななかでチームにおける個々人はいわゆる「 群盲象を評す 」状態です。各メンバーは同じ事象を経験しているにもかかわらず人により見ている観点が違うため、それを主張・共有しあってはじめて今起きていることを把握できるのです。 振り返りも個人の振り返りだけではなく、それぞれがチームを評してこそチームの現状が見えてくるというものです。 チームとしての場ができていない 場とはなにか? 先ほどの「群盲象を評す」のお話を例に説明します。 この話には数人の暗闇の中の男達が登場する。男達は、それぞれゾウの鼻や牙など別々の一部分だけを触り、その感想について語り合う。しかし触った部位により感想が異なり、それぞれが自分が正しいと主張して対立が深まる。しかし何らかの理由でそれが同じ物の別の部分であると気づき、対立が解消する、というもの。( Wikipedia より要約) まず 「主張して対立」 する。その後に 「何かきっかけ」 があって別の部分だと気づいて対立が解消するのです。 つまりチームがまず「主張し合える」環境にないといけないことがわかります。 この点をダニエル・キムは Organizing for Learning という書籍で「組織の成功エンジン」という図を用いて説明しています。 (出典:ダニエル・キム著 Organizing for Learning : Strategies for Knowledge Creation and Enduring Change ) うまくいっていない組織ではしばしば、経営陣と現場、部署間、上司・部下、あるいはチーム内で犯人探しをしたり、必死に自己正当化したりすることがあります。つまり、「関係性の質」が低い状態です。関係性の質が低いと共有や共働が起こらず「思考の質」が狭まります。そうすると、無秩序でバラバラの行動になり「行動の質」が下がる。最終的に結果が出ず、失敗からの学びもないため「結果の質」が下がります。そして結果が出ないため、さらに「関係の質」が下がるという負のループが起こります。 この負のループを打破するために必要なのが 「場の質」 です。チームメンバーが話し合う場を高め、関係性の質を改善すると負のループだった組織の成功エンジンは正のループに変わっていきます。 よく 「心理的安全性」 と言ったりしますが、心理的安全性がチームに必要な理由はこの点になります。 もちろん犯人探しをしたり、自己正当化したりといった上記の例は極端な例ではあります。程度の差はありますが、以下のような状況も関係性の質が下がっている状態と言えるでしょう。 雑談が少ない 仕様に関する会話はするが、アーキテクチャに関する議論やプロセスに関する議論がしづらい チームを超えた提案や事業に関する提案・議論がしづらい 関係性の質が現時点でどの程度高いのか、低いのかをチェックしてみるとよいでしょう。 場の質を高めるためには、ワークショップが適しています。ワークショップでは全員が参加し、手を動かすことが前提となるため必ずメンバー全員の話を聞くことになります。そのため、お互いの主張を言い合える良い場になりやすいです。 たとえば、スキルマップをみんなで作るとか、 ドラッガー風エクササイズ でお互いの期待を共有するとか、 パーソナルマップ でお互いを知るなど色々なワークがあるので参考にしてみてください。ZOZOMO部で行ったスキルマップや自分が作成したパーソナルマップを画像で紹介します。 ZOZOMO部で行ったスキルマップ パーソナルマップ。作って共有するのではなく、見せてチームメンバーに質問してもらうのが肝です。 場を作るのも、ワークショップをするのも、振り返りも準備を含め時間をわざわざ取る必要があります。設計や実装する時間以外にそこそこの時間が必要なわけですが、組織の成功エンジンは ループ になっているので、関係性の質が上がれば上がるほど結果の質がさらに上がる構造になっています。つまり場の質、関係性の質を改善することに対する時間の投資は簡単にペイできるということです。 場の質・関係性の質を改善し、チームの振り返りが「群盲象を評す」でいうところの「何かきっかけ」になると良いチームになっていくと思います。 振り返りのプラクティスやチームのワークを紹介 最後に、ZOZOMO部で行った振り返りのプラクティスやワークをいくつか紹介します。 ZOZOMO部では普段KPT(Keep/Probrem/Tryを洗い出す)を使って振り返りをしています。 毎回同じ手法で振り返りを行うのもループが回ってよいですが、やはり飽きてしまっていつの間にか同じ意見しか出なかったり、個人の感想しか出なくなったりします。刺激を入れるためにときには別の振り返りをしてみるというのも継続するコツでもあります。 KPT as ART 実施したのが年末だったので1年を振り返ってみるというのも兼ねてとにかくアウトプットしまくろうというものでした。枠を埋めるまでKeep/Probrem/Tryを書きまくるというプラクティスです。最後には、来年に向けて抱負となりそうなTryを選択して終了しました。これはどちらかというと場をつくるためのワークに近いですね。 チームコンピテンシーマトリックス こちらは「自分たちのチームに必要な能力」は何かというのを可視化して、チームとしてそれらの能力をどのくらい有しているかを確認し合うワークです。将来的に身につけたいというのも確認し、将来的なチームの成長も見越した計画を立てるために行いました。 デリゲーションポーカー たとえば上司や部下といった上下関係間、PO・スクラムマスタ・開発チームといった役割間においてやり取りが必要なケースを洗い出します。各やり取りの場面において「指示する」ことが必要なのか、「説得」することが必要なのか、「相談」すればよいのかなど、必要なコミュニケーションの程度を確認し合うワークです。それぞれの場面に対して、どのコミュニケーションが必要なのか一斉に投票してもらうと意外に合わないことが多いです。ここで認識合わせをすることでよりワーク後、円滑なコミュニケーションが期待できます。 おわりに 今回はチームになぜ振り返りが必要なのか? そして、ZOZOMO部で実施した振り返りやワークを紹介してきました。ぜひ参考にしてみてください。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co corp.zozo.com 最後までご覧いただきありがとうございました!
アバター
はじめに こんにちは、WEARフロントエンド部Webブロックの 新 です。普段は WEAR のWebサイトのリプレイス開発を担当しています。リプレイスを進める中で、不具合やリプレイス前後での変化にいち早く気づくため、Lighthouse CIによる日々の記録を可視化し定期的に通知する仕組みを作りました。本記事ではその取り組みについて詳しくご紹介します。 目次 はじめに 目次 背景・課題 Lighthouse CI とは やったこと 1. GitHub Actionsでページ毎にLighthouse CIを実行し、結果をGoogleスプレッドシートに保存 2. GoogleスプレッドシートをデータソースとしてLooker Studioでグラフ化 3. Looker StudioからGoogle Apps Scriptを使用するアカウントへメール配信 4. Google Apps ScriptでLooker Studioからの最新メールを取得し、添付されている画像をSlackへ転送 おわりに 背景・課題 WEARのWebフロントエンドチームでは、10年来のVBScriptの環境からNext.jsへのリプレイスを進めています。WEARのWebサイトはオーガニック検索からの流入で閲覧してくださるユーザーがとても多いため、リプレイスを行う動機として開発生産性の向上の他にSEOの改善もありました。詳細は下記の記事をご覧ください。 techblog.zozo.com SEO改善のため、Lighthouseによる計測を継続的に行うべきと判断し、Lighthouse CIによるスコアの蓄積及び比較をすることにしました。また、リプレイス後環境のリグレッションに気づくためにも、日々のLighthouseスコアを可視化するべきだと考えました。 Lighthouse CI とは まず、 Lighthouse はGoogleが提供するWebページの品質を測定するツールで、 Lighthouse CI はそのLighthouseによる測定結果を継続的に取得するための支援ツールです。 Lighthouse CIには、Lighthouse CI CLIとLighthouse CI Serverというパッケージがあります。 Lighthouse CI Server はLighthouse CI CLIの実行結果を、蓄積、可視化まで一括して行うことができるパッケージです。しかし、今回はLighthouse CI Serverを用いずLooker Studioによる可視化を採用しました。Lighthouse CI Serverはセルフホスティングを前提としており、今回の用途ではリグレッションに気づくことができれば十分なのでコスト的な面で採用しませんでした。 やったこと 課題を解決するため「定期的に、Lighthouseによる解析・スコアの収集・グラフ化・Slackへ通知する」という仕組みを作りました。その仕組みの流れを図で表したものがこちらです。 GitHub Actionsでページ毎にLighthouse CIを実行し、結果をGoogleスプレッドシートに保存 GoogleスプレッドシートをデータソースとしてLooker Studioでグラフ化 Looker StudioからGoogle Apps Scriptを使用するアカウントへメール配信 Google Apps ScriptでLooker Studioからの最新メールを取得し、添付されている画像をSlackへ転送 1. GitHub Actionsでページ毎にLighthouse CIを実行し、結果をGoogleスプレッドシートに保存 まず、GitHub ActionsでLighthouse CIを実行すると、各ページの計測結果は .lighthouseci ディレクトリにJSON形式で格納されます。次に、格納されたJSONを読み込みGoogleスプレッドシートへ保存するスクリプトを実行します。 .lighthouseci ディレクトリに格納された計測結果は以下のようなコードで取得できます。 import { readdir, readFile } from "fs/promises" ; import { join } from "path" ; /** * 指定ディレクトリ内のlhr jsonファイルのパス一覧を取得 */ const listLhrJson = ( dir : string ): Promise < string []> => readdir(dir, { withFileTypes : true } ). then (( entries ) => entries . filter (( e ) => e.isFile() && e. name . match ( /^lhr-\w+\.json$/ )) . map (( e ) => join(dir, e. name )) ); const jsonPaths = await listLhrJson( ".lighthouseciディレクトリのパス" ); const resultTexts = await Promise . all ( jsonPaths. map (( p ) => readFile(p, "utf8" )) ); const results = resultTexts. map (( resultText ) => JSON . parse (resultText)); 上の results は以下のようなページごとの配列になっています。 [ { lighthouseVersion : "12.1.0" , requestedUrl : "https://wear.jp/" , // ... categories : { performance : { // ... score : 0.82 , } , accessibility : { // ... score : 0.75 , } , "best-practices" : { // ... score : 0.78 , } , seo : { // ... score : 0.85 , } , } , // ... } , // ... ] ; 上記の取得した結果を次のように加工します。 [ [ "https://wear.jp/" , { performance : 51 , accessibility : 73 , seo : 83 , "best-practices" : 59 , } , ] , // ... ] ; 加工した結果を次の関数に渡し、Googleスプレッドシートに保存します。 import { AuthClient } from "google-auth-library" ; import { GoogleSpreadsheet } from "google-spreadsheet" ; const storeToSpreadsheet = async ( sheetId : string , auth : AuthClient , urlAvgMap : [ string , Record < string , number > ] [] ) => { const doc = new GoogleSpreadsheet(sheetId, auth); const date = new Date (). toISOString (); await doc.loadInfo(); const firstSheet = doc.sheetsByIndex[ 0 ]; await firstSheet.loadHeaderRow(); const rows = urlAvgMap. map (( [url , avgs] ) => ( { url , date , ...avgs, } )); await firstSheet.addRows(rows); } ; 2. GoogleスプレッドシートをデータソースとしてLooker Studioでグラフ化 Lighthouseのスコアを保存したGoogleスプレッドシートをデータソースとして、Looker Studioでグラフ化していきます。Looker Studioでのデータの連携やレポートの詳しい作成手順は下記記事をご覧ください。 techblog.zozo.com 実際に蓄積したデータをLooker Studioでグラフ化したものの一部がこちらです。下のメンバー詳細はちょうどリプレイスを行ったばかりのページなため、SEOやパフォーマンスなどが改善されているのがわかると思います。 パフォーマンスなどのスコアにバラつきがあるのは、GitHub Actionsが実行される環境の違いです。GitHubホステッドランナーを利用しているため、割り当てられるインスタンスのスペックも様々でスコアにバラつきが出てしまっています。私たちの用途では、Lighthouse CIから計測したグラフを異常な変化の検知に用いているため、多少のバラつきは大きな問題ではありません。 3. Looker StudioからGoogle Apps Scriptを使用するアカウントへメール配信 Looker Studioでグラフ化した結果の画像をSlackへ転送するため、メール配信の設定をします。Looker Studioから画像を取得するためのAPIは無くメール配信のみのため、メール経由でSlackに転送します。そのためにLooker Studioから通知したいページや通知頻度を設定します。 4. Google Apps ScriptでLooker Studioからの最新メールを取得し、添付されている画像をSlackへ転送 Looker Studioから配信されたメールの画像を抜き取り、Slackへ転送します。Google Apps Scriptで書いた次のコードは、Looker Studioから配信されたメールの画像データを抜き取る関数の例です。メールは設定したタイトルをもとに取得します。 function getPhotosData () { const reportTitle = "Lighthouse 週次レポート" ; const gmailThread = GmailApp . search ( `from:looker-studio-noreply@google.com subject: ${ reportTitle } ` ) ; const messages = gmailThread [ 0 ] . getMessages () ; const attachments = messages [ 0 ] . getAttachments ({ includeInlineImages : true , includeAttachments : false , }) ; return attachments . map (( attachment ) => { return { blob : attachment . getAs ( attachment . getContentType ()) , name : attachment . getName () , contentType : attachment . getContentType () , } ; }) ; } 次に、取得した画像データをSlackに転送するコードの例です。 getFileUploadUrl で画像データからアップロード用のURLを取得し、取得したURLにPOSTリクエストを送信してファイルをアップロードします。 completeFileUpload に getFileUploadUrl から受け取ったファイルIDを渡してアップロード処理を完了させます。 // ファイルアップロード用URLを取得する function getFileUploadUrl ( filename , length ) { const options = { method : "get" , headers : { Authorization : "Bearer " + PropertiesService . getScriptProperties () . getProperty ( "SLACK_TOKEN" ) , } , payload : { filename : filename , length : length . toString () , } , } ; const response = UrlFetchApp . fetch ( "https://slack.com/api/files.getUploadURLExternal" , options ) ; const responseBody = response . getContentText () ; const data = JSON . parse ( responseBody ) ; if ( ! data . ok ) { throw new Error ( data ) ; } return [ data . upload_url , data . file_id ] ; } // ファイルアップロード処理を完了する function completeFileUpload ( fileId ) { const options = { method : "post" , headers : { Authorization : "Bearer " + PropertiesService . getScriptProperties () . getProperty ( "SLACK_TOKEN" ) , } , contentType : "application/json" , payload : JSON . stringify ({ files : [{ id : fileId }] , channel_id : CHANNEL_ID , initial_comment : `< ${ LookerStudioReportURL } | ${ WEAR Lighthouseレポート } >` }) , } ; const response = UrlFetchApp . fetch ( "https://slack.com/api/files.completeUploadExternal" , options ) ; const responseBody = response . getContentText () ; const data = JSON . parse ( responseBody ) ; if ( ! data . ok ) { throw new Error ( data ) ; } } // ファイルをアップロードする function uploadFile ( photoData ) { const [ fileUploadUrl , fileId ] = getFileUploadUrl ( photoData . name , photoData . blob . getBytes () . length ) ; const options = { method : "post" , payload : photoData . blob , } ; const response = UrlFetchApp . fetch ( fileUploadUrl , options ) ; completeFileUpload ( fileId ) ; } function main () { const photos = getPhotosData () ; photos . forEach (( photo ) => { uploadFile ( photo ) ; }) ; } 最後に、上記のコードを実行させるため、Google Apps Scriptで main 関数の実行タイミングを設定します。実行タイミングはLooker Studioのメール配信で設定した時刻より後に設定する必要があります。 実行されると以下のようにSlackの指定したチャンネルで通知されるようになります。 おわりに 本記事では「定期的に、Lighthouseによる解析・スコアの収集・グラフ化・Slackへ通知する」仕組みについて紹介しました。この仕組みを導入にしたことによって、リプレイス後環境のリグレッションにいち早く気づくことができるようになりました。現在はGitHub Actions上でLighthouse CIを実行していますが、AWS EC2のスポットインスタンスを利用するなど、コストの削減やスコア変動の改善にも取り組みたいです。日々のLighthouseのスコアの定点観測を検討している方がいれば、ぜひ参考にしてみてください。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは! WEARバックエンド部バックエンドブロックの高久です。普段は弊社サービスであるWEARのバックエンド開発・保守を担当しています。 10周年を迎えた WEAR は2024年5月9日に大規模な アプリリニューアル を行いました。アプリリニューアルに伴い負荷試験を行ったので、本記事ではどのように負荷試験を計画したか事例をご紹介します。 記事は計画編と実施編の2部構成で、本記事は前編の計画編となります。後編の実施編は近日、公開予定です。 目次 はじめに 目次 背景 計画の重要性 計画の策定 目的の整理 目標値の設定 スループット レイテンシ 試験方針 試験環境・データ 対象範囲 取得情報・確認観点の整理 負荷シナリオ 実施方法 リスク リリースしてみたらユーザ数が予想を超えてしまい性能問題が発生する 試験対象外とした箇所に性能問題が発生する 本番環境での試験時に性能問題が発生しユーザ影響が出る まとめ 背景 前述の通りWEARは2024年5月9日に大規模なアプリリニューアルを実施しました。このリニューアルでは、アプリのほぼすべての画面が刷新され、新たにAIを活用したファッションジャンル診断や、ユーザの閲覧履歴をもとにしたレコメンド機能、ARによるメイク試着機能などが追加されました。リリース後には、会員登録時にポイントがもらえるキャンペーンやWeb広告の出稿により、新規ユーザのさらなる増加が見込まれていました。 このような複数の機能追加・改修やユーザ数の増加に伴い懸念されるのが性能問題です。例えば、新たに作成した機能のクエリに性能問題があってアプリの動作が遅くなったり、ユーザ数の増加によってサーバリソースが不足し、最悪の場合にはシステムダウンしたりする可能性もあります。リリース後にこうした性能問題が発生しないよう、事前に負荷試験を行い、性能品質を担保する必要がありました。 計画の重要性 一般的に負荷試験は本番運用に先立って性能問題が発生しないことを事前に保証することを目的としています。そのため理想的な負荷試験は、本番環境と全く同じ条件、すなわち負荷、データ量、シナリオ、環境などを本番と同様に再現し、性能に問題がないことを確認することです。しかし現実的にそれを実現することはほぼ不可能だと考えています。リリース後にどの機能をどれくらいの人数が利用するかを完璧に予測することは難しかったり、全てのリクエストパターンを網羅するには膨大なシナリオ作成が必要で、多大な工数がかかったりするためです。 理想にできるだけ近づけることは重要ですが、未来の予測は困難で、工数を無限にかけるのも現実的ではありません。そのため現状の環境、人的リソース、期間を考慮し、いかに効率的に性能品質を保証できるかを事前に計画しておくことが重要です。理想に達しなかった部分については、リスクとしてどのように対処するかを計画段階で検討することも必要です。 計画の策定 どのように計画したか、以下のポイントに沿ってご紹介していきます。 目的の整理 目標値の設定 試験方針 試験環境・データ 対象範囲 取得情報・確認観点の整理 負荷シナリオ 実施方法 リスク 目的の整理 まず負荷試験の目的を整理しました。WEARでは以下の点を負荷試験の目的としました。 性能要件の担保 リリース後に予想されるピーク負荷において、目標とするスループットやレイテンシが達成されているかを確認します。 目標に達しない場合は、達成できるように改善します。 他にも負荷試験ではシステムの限界点の測定やボトルネックの特定、サーバスペックや台数の適正化(サイジング)を目的とすることもあります。しかし今回は負荷試験に割ける期間が限られていたため「性能要件の担保」のみに焦点を当てました。 目標値の設定 目標値(≒性能要件)として「スループット」と「レイテンシ」の2つを定めました。 スループット 現行のピーク負荷量の1.5倍を目標としました。 リリース後1年の中で最も負荷がかかると予想されたのは、リリースから3か月後に予定されているキャンペーン期間で、このタイミングで予想されるユーザー数が現行ピーク負荷の1.3倍でした。この値に安全率を考慮して1.5倍としました。 負荷量の増加要因としてはキャンペーンやプロモーションによる一時的な急増と、サービスの成長に伴うユーザー数の継続的な増加が考えられました。これらを踏まえプロダクトオーナーにヒアリングしながら、最も負荷が大きくなるタイミングを決定しました。 レイテンシ APIの処理時間について、一次目標を500ms以下、二次目標を1秒以下と設定しました。 測定対象としては、本来クライアント側の処理時間も含めたエンドツーエンドで計測するのが理想です。しかしクライアントのレイテンシ計測方法が確立しておらず、計測には多大な工数がかかると判断したため、今回はAPIの処理時間のみを測定対象としました。クライアント側の処理時間に関しては特別な試験は行わず、関係者(開発者、PM、QA、デザイナー)がアプリを使用する中で「遅い」と感じた箇所など体感ベースで改善する方針としました。 秒数の目標値については、元々WEARで設定されていたSLO(サービスレベル目標)を基に定めました。SLOについては以前 テックブログ で紹介していますので、ご興味があればご覧ください。 基本的には一次目標である500msを目指しました。もし500msを超えてしまった場合は、エンドポイントの重要度(リクエスト数など)や処理内容(重い処理かなど)に応じて、二次目標を基準に測定を続けるか、500ms以下に改善するかを都度判断しました。 試験方針 初めにAPI単体の性能試験を実施し、その後システム全体でリクエストされる想定のAPIに対して一斉に負荷をかけて本番運用を模擬した負荷試験を実施しました。 API単体の性能試験では、単発でAPIを実行しレイテンシが目標値を下回っているかを確認します。もし目標に達していなければチューニングを行います。このAPI単体の性能試験を先に行う理由は、試験の効率化のためです。いきなり負荷試験を実施しても良いのですが、仮に目標未達のAPIがあった場合その都度、負荷試験を実施してチューニングと再測定を繰り返すと調整や工数が増加します。また全てのAPIを負荷シナリオに含めることは工数的に難しい場合が多いため、リクエスト頻度が低いなどの優先度が低いAPIについては、負荷試験を行わずAPI単体の性能試験のみで最低限の品質を担保します。 試験種別 確認観点 担保できる品質 API単体の性能試験 単発でリクエストを送った際に、レイテンシが目標を満たしているか確認 ・遅い処理がないこと(例: N+1、無駄なループ、非効率なクエリ・実行計画、外部システム) 負荷試験 複数のAPIに対して複合的な負荷をかけた際に、レイテンシやスループットが目標を満たしているか確認 ・サーバリソースが十分であること(例: CPU、メモリ、ディスク) ・DBロックが頻発しないこと ・コネクション数(例: DBのコネクションプールなど)が十分であること など 試験環境・データ 試験環境は本番運用中の本番環境、データも本番環境のものを使用しました。 今回APIを提供するサーバはリニューアルせず、既存のサーバにAPIを追加する形でした。そのため本番環境で負荷試験を行うと、現行のWEARユーザに影響を与えるリスクがありました。 WEARにはステージング環境と本番環境の2種類があります。ユーザへの影響を考慮すると、できればステージング環境で試験を実施したかったのですが、以下の理由により本番環境での実施を決定しました。 DBスペックの違い ステージング環境のDBスペックは本番環境よりも低い状態でした。このため、ステージング環境で負荷試験を行った場合、レイテンシが悪化し性能問題が発生する可能性が高くなります。仮に性能問題が発生した場合、その原因がシステム本体の問題なのか、環境要因によるものなのかを切り分ける必要が生じ、追加の工数と時間がかかることが予想されました。 データの量と質の違い ステージング環境では、時間的制約から本番環境と同等のデータ量や質を揃えることが難しい状況でした。特にSQLの実行計画や処理時間はデータの量と質に大きく依存しており、その違いはパフォーマンス測定の正確性に直接影響を与えます。例えばデータボリュームの少ない環境で負荷試験を行うと、データ取得にかかる時間が早くなって本番環境よりもいい結果になってしまうこともあるなど、本番稼働時に予期しない遅延や問題のリスクがあります。 試験の正確性とリスクを天秤にかけ、今回は本番環境で試験を実施することにいたしました。 対象範囲 試験の対象範囲を事前に以下のように決定しました。 観点 試験対象内(例) 試験対象外(例) インフラ APIサーバ、DB、外部システムの一部、Elasticsearch クライアント、外部システムの一部、Push通知基盤、メール送信基盤 API(単体の性能試験) 新規API、既存API(改修あり)、新規実装したバッチ 既存API・バッチ(改修なし)  API(負荷試験) 想定リクエスト量がNrps以上のAPI 想定リクエスト量がNrps以下のAPI、シナリオ作成が難しいAPI  試験範囲を広げれば、その分負荷試験にかかる工数も増加します。今回の負荷試験では限られた時間の中で実施する必要があったため、リニューアルによって懸念がある箇所や負荷量増加が予測される部分を優先的に試験対象として設定しました。 取得情報・確認観点の整理 負荷試験中の問題調査や未然の問題検知を目的として、事前にSREチームと共に取得するメトリクスを整理しました。要件であるスループットやレイテンシに加え、サーバリソース(CPU、メモリなど)、エラー、DBロック状況といった、特に負荷がかかった際に問題が発生しやすいポイントを中心に取得しました。 一部のメトリクスには達成基準を設け、負荷試験中にその基準を満たしているかを確認しました。 例: 観点 取得方法 達成基準 CPU使用率 datadog 70%以下であること メモリ datadog 現行と同等程度であること 試験実施時には、Datadog上にすべてのメトリクスをひとまとめに表示できるダッシュボードを作成し、リアルタイムで監視と結果取得をしました。 負荷シナリオ リリースから3か月後のピーク時に予想されるAPIとその負荷量について検討しました。計画段階では、以下のようにAPIと負荷量の一覧を整理しました。また、今回は運用中の本番環境を利用するため、算出したピーク負荷量をそのままかけてしまうと既存負荷分が余分にかかってしまう状況でした。そのため既存負荷分を差し引いた負荷をかけられるように既存負荷量も時間帯ごとに整理しておきました。 例: API 想定ピーク負荷量(rps) 7~19時の現在負荷量 21時の現在負荷量 4時の現在負荷量 コーデ詳細取得API 100 60 80 30 ユーザ情報取得API 50 20 40 10 コーデ投稿API 10 5 6 3 今回、負荷量予測が難しかったです。これまでの機能単体リリースであれば現行システムの負荷量を基におおよその予測はできました。しかし今回は画面や機能の大幅に変更によって、リニューアル前後で負荷量の傾向は大きく変わる可能性があり、予測は難しい状況でした。 以下に負荷量予測の算出方法を簡単に説明します。 現行アクセスログの分析 : 現行システムの直近1年間のピーク秒を特定し、そのピーク秒を含む5秒間のAPIごとのアクセス数を取得します。このデータを基にリニューアル後の負荷量を予測します。 リニューアル後の予測 : 新しい画面や機能のアクセス傾向を予測します。例えば「新A画面は旧B画面に似ているので、B画面のリクエスト数と同程度だろう」や「この機能は初回の起動以外ほとんど使われないだろうから、初回起動のリクエスト数と同等だろう」といった形で予測を立てます。予測の精度を高めるには時間がかかるため、重要な画面に関しては詳細に、その他の画面は感覚で決めました。 API一覧の整理 : 各画面や機能ごとに呼び出されるAPIの一覧を整理します。HTTP通信トレースツール(Charles)を使用し、画面ごとに呼び出されるAPIを抽出しました。 負荷量の算出 : 1、2、3の情報を基に、各APIの負荷量を算出します。 試験対象の絞り込み : 効率化のためにリクエスト量が0.7rps以下のAPIは試験対象から外しました。また外したAPI分の負荷量を、試験対象APIの負荷量に追加しました。 この方法で負荷シナリオを整理しました。負荷掛けツールでの実行ファイルへの落とし込み方法については、実施編で説明する予定です。 実施方法 本番環境のサーバに対して、k6というツールを使用して負荷をかけて測定しました。詳細については、実施編でお伝えする予定です。 リスク 事前に負荷試験を実施する際とリリース後に起こり得るリスクを洗い出し、それぞれに対する対処案を整理しました。以下のようなリスクが考えられました。 リリースしてみたらユーザ数が予想を超えてしまい性能問題が発生する 対策 想定ピーク負荷量に安全率を持たせ、予想を少し超えても問題がないことを確認しておく。 ユーザ数の増加が見込まれるキャンペーンやテレビでのサービス紹介などのイベント時には、監視体制を整え、問題発生時に迅速に対応できるようにする。 リリース後は負荷量を定期的にモニタリングし、傾向や問題の有無をチェックして未然に問題を防ぐ。 システムの限界時にボトルネックとなりそうな箇所を事前に推測し対策案を検討しておくことで、万が一システム限界が訪れた場合、素早く対策できるようにする。 試験対象外とした箇所に性能問題が発生する 対策 影響が大きい利用頻度の高い箇所や、新規に開発または改修した性能問題の発生が懸念される箇所は試験対象に含めて、問題発生の影響や可能性を下げる。 リリース後定期的にモニタリングを行い、問題を発見できるようにする。 本番環境での試験時に性能問題が発生しユーザ影響が出る 対策 負荷量を段階的に増やし(10%→30%→50%→100%)、都度結果を確認し負荷を増やしても問題がないかを確認することで、問題発生の可能性を下げる。 ユーザ数が少ない夜間帯に試験を実施し、万が一問題が発生した場合の影響を最小限に抑える。 試験中はエラーを常に監視し、ユーザ影響が出た場合にすぐに負荷を中止できるように準備しておく。 最悪の場合にシステム停止の可能性もあるため、事前に関係者と情報連携の方法を共有し、問題発生時にスムーズに対応できるようにする。 まとめ WEARアプリのリニューアルにおける負荷試験の計画についてご紹介しました。負荷試験の方法は、サービスの特性や環境、利用できる期間や人的リソースによって大きく変わるため、プロダクトに最適な試験を実施するための計画が重要です。WEARでの事例が参考になれば幸いです。なお、本計画に基づいて実施した結果は、後編の「実施編」で紹介しますので公開をお待ちください。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
こんにちは、ZOZOTOWN開発本部でZOZOTOWN iOSの開発を担当している らぷらぷ です。 2024年8月現在、ZOZOTOWN iOSチームは正社員と業務委託の方をあわせて全14人で構成されてます。 組織上、ZOZOTOWNのiOSチームは開発1部と開発2部の2チームに分かれています。しかし、リリースバージョンの計画・開発フロー・技術課題・その他チームに関わる課題意識はiOSチームメンバー全員で共有しながら改善を進めています。 そのような共有・議論がここ数ヶ月、週次イベントとして固定化してきたので、各イベントの振り返りも兼ねて皆さまにZOZOTOWNのiOSチームを支えるチーム運用の数々を紹介します。 1つのバージョンがリリースされるまで まずはこちらの図をご覧ください。 ざっくりですが開発期間を省いてQA準備からリリースまでの流れを描きました。今年から毎週金曜に定期的にQA配布する流れになりました。 週次イベントは「リリース系」と「チーム活動改善系」に分けられます。 リリース系 バージョン計画会 ぽちぽち会 QAビルド配布 App Store申請 チーム活動改善系 開発生産性MTG Rethink! Confluenceリファクタリング会 今週の振り返り スタンドアップ 毎日スタンドアップミーティングを30分設けてます。図中金曜日の「今週の振り返り」はスタンドアップミーティングの中でやってます。 ちなみにこのSlack AppはZapierで実装しました。毎週火曜日は1時間枠の定例なので、その曜日だけはConfluenceの議事録を取得してSlackに投稿するよう分岐してます。 リリース系 バージョン計画会 担当する案件を持つメンバー(以下案件オーナー)とともにリリースバージョンを整理し、スケジュール進行およびリスクを定点チェックします。 各案件オーナーは主に以下の情報を整理します。 案件情報(Confluence、Jira) 開発時期 QA時期 App Storeに掲載するリリースノートの有無 App Storeへのリリース日が固定か 固定の場合、バージョンに同梱される他案件のスケジュールも当案件に優先される リリースノート・リリース日に影響する要素の有無 スケジュール決定・進行にまつわるTODO・リスク 各リリースバージョンにはバージョンマネージャーが決まっており、バージョンマネージャーは上記の案件オーナーと協力して以下の情報を整理します。 各イベント開催日 ぽちぽち会 QAビルド配布 App Store申請 毎週金曜日のQAビルド配布という動きが決まるまでは、リリース日から逆算してバージョンをどう調整するかパズルのように組み合わせるのが大変でした。 加えて案件以外の小さなbugfixを入れるタイミングがルール化されてなかったので、「この案件に相乗りしてもいいのか、でもそれはQA負荷高いのかな?」と思考することも多かったです。 今では定期リリース枠に何が入るかを整理しているので、スケジュール立ては以前ほど考えることが少なくなっています。その結果、開発からリリースまでのスケジュール立てに対する認識が全員揃いやすく、小さな改善も定期的にリリースできるようになりました。 ぽちぽち会 リリース対象の機能・修正をQAビルド配布前にチェックする会です。 Gatherで集まってJiraの内容を確認しつつ実機・シミュレーターで“ぽちぽち”やっています。このタイミングでバグを見つけることもあり、見つけ次第修正してからQAチームに配布しています。 例えば「この動作って想定外ですか?」といった仕様確認や、「このテキストをこう変えるとお客さんに伝わりやすいかも?」「このタップエリアはもっと広くした方が使いやすいね」などのフィードバックが出てきます。 この会に力を掛け過ぎるとQAチームのチェックと重複することもあり、お互いにどう品質をカバーしあうと良いか模索中です。 QAビルド配布・申請・リリース QAチームにチェックをお願いし、問題なければApp Storeへ申請します。 このルールが言語化されて他チームに伝えやすくなってから、スケジュール立てが楽になりました。そうとはいえ、このドキュメントは他チームに伝え続けないといけないので、参照されやすさをどうConfluence上またはSlackで実現したらいいかなと考えてます。 チーム活動改善系 開発生産性MTG コードベースの技術的な課題を除くチームの課題に対して議論する場です。 技術的課題を外すという意味で「開発生産性(仮)」と付けてましたが、よい候補が見つからずこのままの名前になってます。案件オーナーとしての開発マネジメント、PRレビュー、メンバー間の情報格差など、チームがより成熟するのに必要な課題を整理してアクションを設計してます。 Rethink! 技術的な課題をチームメンバーで定期的に見直す会です。技術的負債と感じてること、Appleが提示している技術への所感や適応への方針、自分だけしか分かってないかもと不安になることなど、お題は多岐に渡ります。 Rethink!で話したことはアーカイブとしてConfluenceに残し、過去の意思決定資料として参照します。 開発生産性MTGとRethink!は「立ち止まって振り返って分析する」イベントで、ファスト&スローでいうところのスローにあたります。案件開発中の迅速な意思決定(ファスト)だけで方針の方向性を埋めないようにしてます。 Confluenceリファクタリング会 iOSチームのドキュメントはConfluenceにまとまっていますが、時が経つにつれてドキュメント(知識・運用)も風化していきます。この会は、メンバーの入れ替わりや運用の改善などチームの状況に応じてドキュメントを絶えず改善するための会です。 主に以下の内容を話しています。 最近追加したドキュメント 最近削除したドキュメント 今後欲しいドキュメント 書きたい 可能なら誰かに書いてほしい ちなみにiOSチームのConfluenceは以下のような階層リストになっています。 背景 コードを読んでも理解できないドメインの説明 設計方針 ルール 実装例 開発・デバッグ方法 運用ガイド メモ 背景・ルール以外 書き始める場所に困った時ここから書き始める アーカイブ 過去の経緯・議事録など、最新情報ではないが資料として残したいもの 今でこそ、このような階層リストになってますが、それまでは欲しい情報を辿るのに難しく整理しづらい状態でした。この構造に決めるまでの議論を開発生産性MTGで進めていました。 今週の振り返り 毎週金曜日はスタンドアップ中にチーム振り返りをしてます。うまくできたこと、思うようにいかなかったこと、来週の不安などをシェアしています。 また、業務委託の方含め全員が揃う木曜日は「今週の凄い人達を褒める」ことを習慣化しています。導入時はチームメンバー内の褒め合いになり、褒め合い慣れてない独特の空気がちょっとおもしろかったです。今では他のチームの褒めも積極的にやっていこう、というのがZOZOTOWN iOSチームのテーマになってます。 おわりに ZOZOTOWNのiOSチームの開発、雰囲気、文化を維持して改善するための運用を紹介しました。 ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co corp.zozo.com
アバター