TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

938

はじめに こんにちは、計測プラットフォーム開発本部SREブロックの近藤です。普段はZOZOMATやZOZOGLASSなどの計測技術に関わるシステムの開発、運用に携わっています。 今年の夏に、ZOZOFITというサービスがローンチされました。このサービスは米国での展開を行い、日本ではあまり目にすることのないサービス名称だと思います。 ZOZOFITをローンチするにあたり、私達のチームではアーキテクチャを設計し、システム構築をすることになりました。 本記事では、ZOZOFITの開発時に遭遇した課題と対応方法について紹介します。 目次 はじめに 目次 ZOZOFITについて システム構成 レイテンシを考慮したAWSのリージョンの選定 認証機構の構築 開発中の課題 米国でSMSの送信時に必要な申請 SMSの送信検証 振り返り 終わりに ZOZOFITについて ZOZOFITは、ZOZOSUITの計測技術を利用したサービスです。2022/11時点では、体型計測および身体3Dモデルのデータ・体脂肪率の表示機能を提供しています。 ZOZOFITの大きな特徴は、ZOZOMATやZOZOGLASSと異なり、ZOZOTOWNから完全に独立したサービスであることです。 システム構成 ここでは、ZOZOFITのシステム構成を紹介します。以下にネイティブアプリから呼び出される部分にフォーカスした、システム構成図を記載します。 図1 : ZOZOFIT システム構成図 ZOZOFITは既存の計測系サービスと同じく、AWSのEKSを採用しています。また、その他技術スタック部分も既存サービスと揃えていますが、米国向けかつZOZOTOWNから独立したサービスというZOZOFIT独自の特徴を持っています。 この特徴により、システム構成も既存とは異なる部分が生まれました。次節以降で、これらの異なる部分が生まれた背景について説明します。 既存の計測系サービスのシステム構成については、ZOZOMATのシステム構成のブログ記事が公開されているので、気になる方はこちらの記事をご参照ください。 techblog.zozo.com レイテンシを考慮したAWSのリージョンの選定 計測プラットフォームでは、シングルクラスタ・マルチテナントの構成を前提としたEKSを運用しています。こちらの運用方針については、チームメンバーのブログ記事も公開されているので、気になる方はこちらの記事をご参照ください。 techblog.zozo.com ZOZOFITは米国向けのサービスですが、別のリージョンにクラスタを構築すると運用方針からは外れることになります。このため当初はGlobalAcceleratorを利用し、日本国内のリージョンにシステムを構築することも検討していました。 日本国内にシステムを構築するにあたり、性能評価の前段階として、データの越境移転について法務に確認を取りました。 結論として、米国内で最も厳しいカリフォルニア州の個人情報保護法と照らし合わせ、サービス構築時点では問題ないことがわかりました。 法律的な面での懸念事項がクリアできたので、次はシステム面での懸念事項の検証に入りました。 日本国内に構築した場合における最大の懸念はレイテンシです。物理的な距離が遠くなれば、レイテンシに影響が出るのは避けられない問題です。 私達は実際にどの程度の影響が出るか、レイテンシに関して日本国内のリージョンと米国内のリージョンで比較検証を行いました。比較検証する米国内のリージョンは、ビジネスサイド側から顧客のターゲット層をヒアリングし、オレゴン(us-west-2)を利用することになりました。 速度検証には、AWS Global Accelerator 速度比較ツールを利用しました。 docs.aws.amazon.com speedtest.globalaccelerator.aws 通信サイズ ap-northeast-1(東京) us-west-2(オレゴン) 50KB 100KB 1M 2M 図2 : LosAngelsからの速度検証の結果 検証の結果、GlobalAcceleratorによる改善効果は大きいものの、日本国内のリージョンを利用した場合、米国内のリージョンを利用した場合と比較すると、おおよそ1.5倍のレイテンシとなることがわかりました。この結果を受け、サービスにおけるレイテンシへの影響を考慮し、米国のリージョンにシステムを構築することになりました。比較検証を通して、通信における物理的な距離はレイテンシに大きな影響を与えることが改めてわかりました。また、オーバヘッドの具体的な数値を出すことでリージョンをスムーズに決められました。 認証機構の構築 ZOZOFITは、ZOZOTOWNから独立したサービスのため、ユーザーの認証機構を新規に構築する必要がありました。今回の認証機構ではシステム要件として多要素認証が求められており、私達はCognitoとAuth0のどちらを採用するかについて検討しました。 どちらも認証機構としての要件を満たしますが、ZOZOFITの開発は速度を求められていたこともあり、AWS内で完結でき導入・検証が即時にできるCognitoを採用することにしました。 開発中の課題 上述したように、ZOZOFITは米国のリージョンにシステムを構築することになりました。ここでは開発中に遭遇した課題の中でも、普段利用しない日本以外のリージョンを利用したことに起因した課題と対応方法について紹介します。 米国でSMSの送信時に必要な申請 認証機構の構築を進める中で、米国でCognitoからSMSを送信するには送信元IDの取得が必要であるとわかりました。送信元IDは、SMSの送信者が誰であるかを示すIDであり、国によって送信元IDとなり得るものが異なります。 今回は、米国で送信元IDとなりえる3種類(ShortCode、10DLC、TollFreeNumber)の中から、選択することになりました。なお、AWSでは、Pinpointというサービスで送信元IDを取得できます。 特徴 ShortCode TollFreeNumber 10DLC フォーマット 5-6桁 10桁 10桁 事前審査 必要 不要 必要 発行にかかる予想日数(審査にかかる日数は含まない) 12週間 即時 1週間 スループット 100/秒(有料で上限を上げることが可能) 3/秒 最大100/秒(後述する審査結果により変動) 必須キーワード Opt-in, opt-out, and HELP STOP, UNSTOP(オプトアウトおよびオプトインメッセージの変更不可) Opt-in, opt-out, and HELP 図3 : 各送信元IDの特徴 docs.aws.amazon.com ZOZOFITの送信元IDの要件は、簡単にまとめると以下の3点でした。 2週間以内に利用を開始したい サービスの初期フェーズのため、秒間の最大リクエスト数は30を想定 サインアップ・サインインに利用する経路で、キャリア都合でのブロックは回避したい 各送信元IDを比較し、要件にマッチした10DLCを利用することにしました。以下で、AWSのPinpointで10DLCを送信元IDとして利用する流れを説明します。 企業情報の登録 企業情報の審査 キャンペーンの作成 10DLCの番号取得 正確には2を行わなずとも10DLCの番号は取得できますが、送信数に制約がかかります。このため、商用環境での利用に向けて事前にこれらの申請作業を行いました。 具体的に、送信数に関する制約は以下の図の通りです。 審査を受けなかった場合は75msg/min、2000/dayの上限となり、ZOZOFITの商用環境で利用するには厳しい数値でした。 図4 : 10DLCにおける送信数の制約 (図は AWS公式ドキュメント からの引用) なお、開発用途では、送信元IDとしてTollFreeNumberを利用する方針としています。 これは開発用途では送信数が限定的であることに加え、10DLCの取得時に登録する企業情報が同一だった場合、複数アカウントでQuotaが共有されるためです。 計測プラットフォームでは環境単位でAWSアカウントを分離しています。このため、複数の環境で送信元IDとして10DLCを利用した場合、本番環境に他環境での作業が影響してしまう危険があります。この問題を回避する為、本番と検証の設定に差分が生まれることを許容しました。 SMSの送信検証 認証機構の構築が進む中で実際にSMSの送信検証を行う際に、開発者の手元にある携帯電話を利用したところ、SMSが届かない問題に遭遇しました。調査を進める中で、SMSの配信失敗のエラーログを有効化したところ、以下のエラーメッセージがログに出力されていることがわかりました。 { " numberOfMessageParts ": 1 , " destination ": " xxxxxxxxxxxxx ", " priceInUSD ": 0.00831 , " smsType ": " Transactional ", " providerResponse ": " Phone carrier is currently unreachable/unavailable ", " dwellTimeMs ": 72 , " dwellTimeMsUntilDeviceAck ": 1526 } エラーメッセージから、送信先のキャリアにSMSがそもそも届かないのではという懸念が生まれました。この問題に対して、米国の電話番号で検証し、原因が送信側か受信側かを切り分けをしようと考えました。この調査の為、米国の電話番号を取得することになりました。 取得する電話番号は、SMSの受信ができ、開発者が容易にメッセージを確認できる必要がありました。 これらの要件を満たす、 Twilio とPinpointの2つから選択することにしました。 結論としては、新規でアカウントの発行管理が不要、かつ、コード管理がしやすいPinpointを利用することにしました。 図5 : SMSのメッセージの送信経路 取得した電話番号で検証した際、各所でのログ出力は以下の結果となりました。 調査対象 日本の携帯電話 米国の携帯電話 Pinpointで取得した電話番号 httpサーバログ ○ ○ ○ アプリケーションサーバログ ○ ○ ○ CloudTrail(CognitoのAPIの呼び出し履歴) ○ ○ ○ SMSの配信エラーログ ○ ✖️ ✖️ SMSの配信ログ ✖️ ✖️ ✖️ 調査の結果は、米国の電話番号であってもSMSが届かない状態でした。この結果から、Cognito側の設定もしくはアプリケーションの実装の問題である可能性が高いという判断になりました。 最終的には、Cognito側の設定である auto verifyの設定 が電話番号に対して有効になっておらず、SMSの送信が行われていないことが原因でした。日本の携帯電話の番号を登録した場合はエラーメッセージが出力されていたので、SMSの送信そのものは行われていると判断していました。調査の過程で、このエラーメッセージはSMSの送信段階で出力されているのではなく、CognitoのUserPoolのデータが更新された際に行われる内部的な処理の中で出力されていることがわかりました。 なお、このトラブルを解決する過程でPinpointによって取得した番号をSNSTopicに紐づけられることがわかりました。これは検証用途でSMSのメッセージを取得する方法として活用できました。 図6 : SMSメッセージのメールへの転送経路 振り返り 今回、ZOZOFITの開発では既存サービスと異なる部分があり、チームとして新しい知見獲得の機会を得られました。反面、知見のない問題に遭遇しましたが、チーム全体で前向きに対応を進めることで解決出来ました。 リージョンの選定方法やSMS送信時の制約、ユーザの認証基盤をゼロから構築したことも、開発チームにとって大きな経験となりました。 最後に付け加えると、ZOZOFITは2022年の夏にローンチするという大きな目標がありました。この大きな目標に向け、チームとして知見の低い課題に対する対応が様々な場所で求められましたが、地道に検証を行い、結果を調査することで着実に前に進められました。この結果、大きなトラブルなくZOZOFITをローンチできました。 終わりに 計測プラットフォーム開発本部では、今回紹介させていただいた ZOZOFIT のように、日本国内に限らず新しいサービスを開発していく予定です。 今回、ZOZOFITの開発チーム構成には触れませんでしたが、 ZOZO New Zealand 、 ZOZO Apparel USA 、ZOZOの3つの組織での共同開発となっています。技術力以外に英語によるコミュニケーション能力も求められますが、このような環境を楽しみ、サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co
アバター
はじめに ZOZOMO部プロダクト開発ブロックの木目沢です。 11/25にZOZOとChatworkさん合同でCQRSをテーマにした CQRS Meetup【Chatwork × ZOZO】 を開催しました。 zozotech-inc.connpass.com 開催の経緯 ZOZOMO部プロダクト開発ブロックでは、CQRSアーキテクチャの採用に際し、ZOZOの技術顧問であるかとじゅんさん(加藤潤一さん/ @j5ik2o )にアドバイスをいただいてきました。今回採用したCQRSアーキテクチャをもっと広く知っていただこうと、かとじゅんさん所属のChatwork社と共同でイベントを開催しました。 登壇内容まとめ 本イベントでは、ZOZOエンジニアの岡元と、かとじゅんさんに登壇いただきました。さらに、CQRSに対して一家言ある両者によるパネルディスカッションを行いました。 当日の様子はYouTubeのアーカイブをご覧ください。 両者の発表資料はSpeaker Deckに公開されています。 店舗在庫連携のCQRSを支えるメッセージング周りの技術(株式会社ZOZOブランドソリューション開発本部ZOZOMO部/岡元政大) 弊チーム岡元の登壇では、 ZOZOTOWN上での「ブランド実店舗の在庫確認・在庫取り置き」機能 で採用されているCQRSアーキテクチャの詳細を共有しました。 OutBoxパターンとCDCパターンを組み合わせたアーキテクチャになっており、どのような経緯でこのアーキテクチャを採用したか、どんな利点があるかなどを説明しました。 合わせてテックブログ DynamoDBによるOutboxパターンとCDCを用いたCQRSアーキテクチャの実装〜ZOZOMOでの取り組み もぜひ御覧ください。 techblog.zozo.com AWSデータベースブログの記事「Amazon DynamoDBによるCQRSイベントストアの構築」を勝手に読み解く(Chatwork株式会社/加藤潤一) かとじゅんさんの登壇では、AWSデータベースブログの 「Amazon DynamoDBを使ったCQRSイベントストアの構築」 という記事を紹介いただきました。元のブログ記事のCQRSのアーキテクチャの説明を補足する形で詳細に解説いただきました。 パネルディスカッション パネルディスカッションではお互いの登壇の感想戦、及び視聴者の皆様の質問にお答えする形で話していただきました。 今回の発表のどちらを選択するかの判断基準として、コマンド側のDynamoDBのトランザクションを許容できるかが論点となっていました。お二人の結論としては、トランザクションを許容できる場合は岡元の登壇内容のような実装の方法、できない場合はかとじゅんさんに解説いただいたアーキテクチャで実装できそうだということでした。 どちらもCQRSアーキテクチャを実装するための特別なソフトウェアを利用することなく実現できます。そして、AWSマネージドなサービスのみを利用して実現できることも紹介できました。敷居が高いと思われているCQRSアーキテクチャが広がるきっかけを作れたのではないでしょうか。 最後に ZOZOでは、プロダクト開発以外にも今回のようなイベントの開催など外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、最近気になるニュースはサザエの学名が数年前に初めて命名されたこと 1 な、MLデータ部データ基盤ブロックの塩崎です。BigQueryのストレージに関する新料金プランが先日発表されたので、その検証をしました。我々の環境では年間で数千万円という費用削減を達成できることが分かりましたので、BigQueryに多くのデータを蓄積している会社は是非お試しください。 ストレージ費用の悩み データ基盤を長期間運用していると、データ量の増加が問題になることがしばしばあります。特にユーザーの行動ログやスタースキーマにおけるファクト系テーブルなどはデータがどんどん蓄積されます。古いデータを削除することでデータ量の増加を緩和できますが、それでもサービスの成長に伴いデータ量は増加する傾向になります。 BigQueryはコンピューティングとストレージが高度に分離されているので、初期のAmazon Redshiftのようなストレージを増やすためにCPUも増やす必要はありません。ストレージのみを独立して拡張でき、柔軟なキャパシティプランニングを行えます。これによってある程度は悩みが軽減されましたが、データ量が増えるに従って増加するストレージコストは依然としてデータ基盤運用者の悩みの種になり続けています。先日発表されたBigQueryストレージに関する新料金プランに切り替ることで、データを削減せずにコストを削減できる可能性がでたので、検証しました。 従来の料金プランの紹介 まずは、従来からある料金プランを解説します。これ以降の解説はUS Multi Regionを対象にして行いますので、Tokyo Regionなどの他リージョンでBigQueryを利用されている方は一部の数字が異なります。ご注意ください。 従来の料金プランではlogical storageという「非圧縮」状態のデータ量に応じた費用が発生します。この費用は1GBのデータを1か月保管する毎に0.02USDもしくは0.01USDです。データを格納した直後はActive logical storageというタイプで課金され単価が0.02USDです。また、90日間テーブル・パーティションに対する変更処理を行わなかった場合は、Long-term logical storageに自動的に移行されます。Long-term Storageは単価がActive Storageの半額である0.01USDになります。 cloud.google.com データ量はデータ型毎に決まり、例えばINT64型ならば8byte、STRING型ならば2byte + UTF-8でエンコードした時のbyte数になります。 cloud.google.com BigQueryのWeb UIではテーブルを選択した後にDETAILSタブをクリックした後の画面で確認ができます。例えば以下のテーブルでは、Active logical bytesは6.23TB、Long term logical bytesは958.09GBであることが分かります。 それぞれに単価をかけ合わせることで、従来のプランでの費用が分かります。 5.3 * 1024 * 0.02 + 958.09 * 0.01 = 118 USD 新料金プランの紹介 次に新料金プランを解説します。 新料金プランではphysical storageという「圧縮済」状態のデータ量に応じた費用が発生します。この費用は1GBのデータを1か月保管する毎に0.04USDもしくは0.02USDです。データを格納した直後はActive physical storageというタイプで課金され単価が0.04USDです。また、90日間変更をしなかった場合はLong-term physical storageへ移行され0.02USDになります。1GBあたりの単価が従来のものと比べて倍になっていることが分かります。 単価の上昇効果と圧縮によるデータ量の削減効果の両方が作用するので、圧縮によってどの程度データ量が削減されるか次第でどちらが安いのかが変わります。例えば、圧縮でデータ量が1/2になる場合は、データ量削減効果と単価の上昇が打ち消し合い費用は等しくなります。また、圧縮でデータ量が1/10になる場合は圧縮による効果の方が大きいので、新料金プランの方が安価になります。 physical storageに関する情報もlogical storageと同じ画面で確認できます。例えば先程と同じテーブルでは、Active physical bytesは503.19GB、Long term physical bytesは59.89GBであることが分かります。 それぞれに単価をかけ合わせると、新プランでの費用がわかります。 503.19 * 0.04 + 59.89 * 0.02 = 21.3 USD 従来のプランでの費用は118USDでしたので、このテーブルは新プランの方が安価です。 また、新プランの注意点としては、タイムトラベル用のデータに対する費用の発生が挙げられます。クエリを実行するときにテーブル名の後ろに FOR SYSTEM_TIME AS OF <特定の日時> という指定をすると、7日以内であれば任意時点のテーブル情報を取得できます。 cloud.google.com この機能を実現するためにBigQueryは更新・削除されたデータをTime travel Storageに移動されています。従来のプランはこのTime travel Storageに対する費用がかかりませんが、新プランではActive Storageと同じ1GBあたり0.04USDかかります。そのため、更新・削除を頻繁に行うテーブルはその分の費用がプラスされる点に注意が必要です。なお、BigQueryのWeb UIから確認できるActive physical bytesにはこのTime travel Storageの容量も含まれています。 Active physical bytes - Time travel physical byte という計算で、純粋なActive physical Storageの容量を計算できます。 新旧の比較 では、ここで一旦新旧プランの比較を以下の表にまとめます。 従来のプラン 新プラン Active Storage費用(USD / GB month) 0.02 0.04 Long term Storage費用(USD / GB month) 0.01 0.02 データの圧縮 無し 有り Time travel Storageに対する費用 無し 有り どちらが安くなるのかは一概に決められず、保存されているデータの性質に依存します。圧縮率が高く、更新削除を頻繁に行わないデータは新プランに移行した方が安くなる傾向にあることが分かります。 データセット毎の費用比較 ここからはプラン変更でどの程度安くなる・高くなるのかをデータセット毎に集計していきます。 以下のクエリを実行するとデータセット毎に saving_cost が計算されます。この値がプラスの場合は新プランに切り替えたほうが安く、マイナスの場合は従来のプランの方が安いです。クエリを実行するためには、Organizationレベルでの roles/bigquery.metadataViewer ロールが必要です。 with existing_tables as ( select table_catalog as project_id, table_schema as dataset_name, table_name from ( # 対象のGCPプロジェクトを列挙 select * from `プロジェクトID1`.`region-us`.INFORMATION_SCHEMA.TABLES union all select * from `プロジェクトID2`.`region-us`.INFORMATION_SCHEMA.TABLES union all ... select * from `プロジェクトIDn`.`region-us`.INFORMATION_SCHEMA.TABLES ) ), existing_table_storage_by_organization as ( select ts.* from `region-us`.INFORMATION_SCHEMA.TABLE_STORAGE_BY_ORGANIZATION as ts join existing_tables as et # TABLE_STORAGE_BY_ORGANIZATIONは削除済みテーブルの情報も返すのでTABLESとinner joinする on ts.project_id = et.project_id and ts.table_schema = et.dataset_name and ts.table_name = et.table_name ), all_schamata_options as ( # 対象のGCPプロジェクトを列挙 select * from `プロジェクトID1`.`region-us`.INFORMATION_SCHEMA.SCHEMATA_OPTIONS union all select * from `プロジェクトID2`.`region-us`.INFORMATION_SCHEMA.SCHEMATA_OPTIONS union all ... select * from `プロジェクトIDn`.`region-us`.INFORMATION_SCHEMA.SCHEMATA_OPTIONS ), datasets as ( select distinct catalog_name as project_id, schema_name as dataset_name from all_schamata_options ), storage_options_sub as ( select catalog_name as project_id, schema_name as dataset_name, option_value as storage_billing_model, from all_schamata_options where option_name = ' storage_billing_model ' ), storage_options as ( select d.*, # SCHEMATA_OPTIONSはEntity Attribute Valueしているテーブルで、storage_billing_modelが存在しない場合もあるため ifnull(so.storage_billing_model, ' LOGICAL ' ) as storage_billing_model from datasets as d left join storage_options_sub as so on d.project_id = so.project_id AND d.dataset_name = so.dataset_name ), cost_by_dataset as ( select project_id, dataset_name, # US以外のリージョンを対象にする場合は修正 active_logical_bytes / pow( 2 , 30 ) * 0 . 02 + long_term_logical_bytes / pow( 2 , 30 ) * 0 . 01 as logical_cost, active_physical_bytes / pow( 2 , 30 ) * 0 . 04 + long_term_physical_bytes / pow( 2 , 30 ) * 0 . 02 as physical_cost, time_travel_physical_bytes / pow( 2 , 30 ) * 0 . 04 as time_travel_cost, from ( select project_id, table_schema as dataset_name, sum (active_logical_bytes) as active_logical_bytes, sum (long_term_logical_bytes) as long_term_logical_bytes, sum (active_physical_bytes) as active_physical_bytes, sum (long_term_physical_bytes) as long_term_physical_bytes, sum (time_travel_physical_bytes) as time_travel_physical_bytes, from existing_table_storage_by_organization group by project_id, table_schema ) where long_term_logical_bytes + long_term_logical_bytes + long_term_physical_bytes + long_term_physical_bytes > 1 order by logical_cost desc ) select c.*, c.logical_cost - c.physical_cost as saving_cost, s.storage_billing_model, from cost_by_dataset as c left join storage_options as s on c.project_id = s.project_id and c.dataset_name = s.dataset_name このクエリをZOZOのデータ基盤で実行したところ、ほとんど全てのデータセットでPhysical Storageの方が安いという結果が得られました。また、Logical Storageの方が安いという結果になったデータセットを更に調査したところ、その要因はTime Travel Storageの費用によるものであることも分かりました。純粋なテーブルデータだけの費用ではPhysical Storageの方が安価でした。そのため、後述するTime Travel Windowを調整することで費用削減が可能であることを示唆しています。 Physical Storage課金への切り替え Physical Storageへの切り替え方法を説明します。 どちらの料金プランを使用するのかはデータセット単位で指定でき、以下のbqコマンドでPhysical Storageによる課金に切り替えることができます。ただし、この作業は一方通行であり、一旦Physical Storageに切り替えたデータセットをLogical Storageに戻すことが不可能な点に注意が必要です。また、Physical Storageによる課金プランは2022年10月27日時点では、まだPreview段階です。そのため、本番環境のデータに対してこのコマンドを実行することには慎重になる必要もあります。 2024-01-02 追記 Phycical Storage機能は2023年7月5日に一般公開(GA)段階に移行しました。また、Physical StorageからLogical Storageに戻すこともできるようになりました。ただし、一旦変更すると14日間は元に戻せないので、依然として切り替えには慎重になる必要があります。 追記ここまで bq update -d --storage_billing_model = PHYSICAL < プロジェクトID > : < データセット名 > cloud.google.com Time Travel Windowの調整による費用削減 最後にさらなる費用削減のためにTime Travel Windowを調整する方法を紹介します。 Physical StorageにはTime Travel用のデータも含まれているため、このデータ量を減らすことで費用削減できます。Time Travelで遡ることのできる期間(Time Travel Window)を短くすると、それに伴ってデータ量も減ります。 以下のbqコマンドでTime Travel Windowをデフォルト値である168時間(7日)より短くできます。ただし、Time Travel Windowを短くすると、もしものときにデータ復旧が不可能になるリスクを抱えることになります。費用のみを気にして無闇に小さな値にすることは避けましょう。 bq update -d --max_time_travel_hours=<遡ることのできる時間> <プロジェクトID>:<データセット名> また、Logical Storage課金のデータセットはTime Travel Storageに対する費用はゼロなので、デフォルト値から変更するメリットはないです。 cloud.google.com まとめ BigQueryの新料金プランであるPhysical Storageについて解説をし、切り替えることで費用を削減できるかどうかを確認する方法を紹介しました。データの特性にもよりますが、大きく費用を削減できる可能性のあるプランですので、データ基盤に多くのデータを蓄積している企業は試す価値があるかと思います。 ZOZOでは、一緒に楽しく働く仲間を募集中です。ご興味のある方は下記採用ページをご覧ください! corp.zozo.com 驚愕の新種! その名は「サザエ」 〜 250年にわたる壮大な伝言ゲーム 〜 ↩
アバター
こんにちは。計測システム部SREブロックの西郷です。 10月24日から10月28日にかけてKubeCon + CloudNativeCon North America 2022(以下、KubeCon)が行われました。今回弊社からはWEARやZOZOTOWNのマイクロサービス基盤、計測システムに関わるメンバー7名で参加しました。 本記事では現地の様子や弊社エンジニアが気になったセッションについてレポートしていきます。 目次 目次 3年ぶりにアメリカでの現地開催となったKubeCon現況 参加メンバーによるセッション紹介 Istio Today and Tomorrow: Sidecars and Beyond Cloud Governance With Infrastructure As Code (IaC) With Kyverno And Crossplane - Dolis Sharma, Nirmata “Why Can’t Kubernetes Devs Just Add This New Feature? Seems So Easy!” - Understanding the Feature Lifecycle In Kubernetes - Ricardo Katz, VMware & Carlos Panato, Chainguard Security In the Cloud With Falco: Overview And Project Updates - Jason Dellaluce & Luca Guerra, Sysdig Migrating From Single-Node Kubernetes Control Plane To HA In Production - Cong Yue & David Oppenheimer, Databricks When the Logs Just Don’t Cut It: Root-Causing Incidents Without Re-Deploying Prod Cloud-Native WebAssembly: Containerization On the Edge - Michael Yuan, Second State 最後に 番外編:現地の様子をお届け 3年ぶりにアメリカでの現地開催となったKubeCon現況 今回参加してきたKubeConはアメリカのデトロイトで現地+オンラインのハイブリッド開催でした。この形式での開催は、今年の5月にEUで開催されたのに続き、新型コロナウイルス感染症(COVID-19、以下コロナ)のパンデミック以降2度目となっています。3年ぶりのアメリカでの現地開催ということもあり、久しぶりに対面で会う人たちとの再会を懐かしんでいる参加者が多かったように思います。 コロナが完全に収まっていないこともあり、会場内では飲食時以外マスクの着用が義務となっていました。また、以下写真のような缶バッジやシールによって積極的に話しかけて良いかや握手などの接触を許容しているかなどの意思表示ができるよう、感染対策に配慮しつつも参加者の意志を尊重できる工夫がされていました。 KubeConのスケジュールとしては、3日間にわたってキーノートやセッションを見ることができます。また、KubeCon開催前の2日間は同じ会場で共同開催となるEnvoyやKnative、eBPFなどKubernetesやクラウド周りのトピックのカンファレンスも開催されていました。 KubeConではキーノートやセッション、LTなどを通してKubernetesに関する最新のアップデートの紹介や、実際にKubernetesを採用した企業の幅広い運用ノウハウを聞くことができます。以降では参加してきた社員がそれぞれ気になったセッションについて取り上げてご紹介します。 最後に現地の様子もまとめたので、そちらもお楽しみに! 参加メンバーによるセッション紹介 Istio Today and Tomorrow: Sidecars and Beyond SRE部ECプラットフォーム基盤SREブロックの巣立です。 Solo.ioのLin SunとGoogleのMitch ConnrsによるIstioの新しいデータプレーンモデルであるAmbient Meshについてのセッションでした。 Istio Ambient Meshは2022年9月に公開されたばかりです。これまでのIstioではアプリケーションコンテナと一緒に配置されるサイドカーモデルを採用していましたが、Ambient Meshではサイドカーを必要としません。サイドカーモデルでは、サイドカーのアップグレード時にアプリケーションの再起動が必要になったり、アプリケーションとサイドカー間の起動・終了シーケンスが複雑になったりといくつかの課題があります。 また、サイドカーではIstioの基本的な暗号から高度なL7ポリシーまで全てを実装しています。そのためL7の処理を必要としない場合でも、サイドカーコンテナを維持するため過剰なリソースが必要となります。そこでAmbient Meshでは、トラフィックのルーティングを処理するsecure overlay layerとL7 processing layerの2つのレイヤーに分割しました。レイヤーを分割したことでユーザは必要に応じてL7の処理を利用するかどうかをワークロードのニーズによって使い分けることができるようになります。 Ambient Meshを利用することで、コンピュータコストの削減や運用・アップグレードなどのオペレーションの負荷の削減など様々な恩恵を受けることができます。 ( link より引用) また、Ambient Meshやサイドカーで実行されるワークロードは共存可能で、ユーザーはワークロードのニーズに基づいて使用することが可能になります。 ( link より引用) こちらから実際にIstio Admbient Meshを試すことができます。 istio.io また、会場ではLin Sun&Christian Posta執筆のIstio Ambient Explainedが配布されており自分もゲットできました! Ambient Meshは2022年11月時点では本番環境での利用は推奨されていません。2023年の本番環境への移行に向け、現在も活発に開発が行われています。弊社もIstioを利用していますが、サイドカーのアップグレードの度にアプリケーションを再起動させるのはとても面倒に感じていました。Ambient Meshを導入できればアプリケーションの再起動も不要になり運用負荷の軽減が期待できます。今後の動向にも注目していきたいと思います。 Cloud Governance With Infrastructure As Code (IaC) With Kyverno And Crossplane - Dolis Sharma, Nirmata SRE部ECプラットフォーム基盤SREブロックの織田です。 このセッションでは、KyvernoとCrossplaneを用いてIaCでクラウドを管理する方法が紹介されました。 Crossplaneは、クラウドネイティブなコントロールプレーンを構築するフレームワークです。Kubernetesクラスターを拡張することで複数ベンダー(AWS,GCP,Azure)のインフラストラクチャやマネージドサービスのオーケストレーションをサポートします。 Crossplaneを使うことでKubernetes上からAWS EC2やS3などを管理できます。具体的にはDeploymentやJobのようにyamlでmanifestを作成し、applyするだけなので、容易に取り入れることができます。 手元で試したい場合は、 Getting Started で簡単に試すことができます。 Kubernetesにデプロイすることによって、Kubernetesの機能であるReconcileによりAWSのインフラストラクチャやマネージドサービスが定義された状態に維持されます。また、Kubernetesで管理することによって、容易にGitOpsにも対応できることも良い点だと思います。 次にKyvernoでCrossplaneを使って作成したリソースを制限する方法についてです。Kyvernoは、Kubernetesのためのポリシーエンジンです。アドミッションコントロールやバックグラウンドスキャンを利用して、設定のvalidate,mutate,generateを行います。 KyvernoのポリシーはKubernetesリソースであり、yamlで記述するため新しい言語の学習コストは不要です。 Kyvernoを使うことによって、Crossplaneのmanifestに対して制限をかけられます。例えば開発環境ではt2.mediumのような小さなインスタンスのみ構築できるようにする、RDSの特定バージョンより前のバージョンは利用させないようにする等ができるのではと思います。 KubeConでの多くのセッションを通じて、Crossplaneが様々な企業で使われていると感じました。 弊チームではAWSリソースのIaCツールであるCloudFormationを利用しています。そのためCrossplaneに置き換えるかは非常に悩ましいのですが、ReconcileやGitOpsが活用できると考えると乗り換えを再考しても良さそうだと思いました。 “Why Can’t Kubernetes Devs Just Add This New Feature? Seems So Easy!” - Understanding the Feature Lifecycle In Kubernetes - Ricardo Katz, VMware & Carlos Panato, Chainguard SRE部ECプラットフォームサービスSREブロックの出川です。 こちらのセッションは、Kubernetesに新しい機能が提案されてGAになるまで(または途中でreject/rollbackされるまで)にメンテナやコントリビュータの間で何が起こっているかを、具体例をもとに示したセッションです。 機能追加は本当に大変で常に様々な議論が必要になること、また共に議論をしたり実装する人が足りていないために機能追加には時間を要することが述べられていました。 ここで紹介されていた議論の観点として以下が挙げられていました。 Questions to be answered(抜粋): The feature solves a wide problem or is this too niche? (解決するのは広い範囲の問題か、ニッチすぎる問題か?) Does the feature bring (or solve) a security concern? (セキュリティ上の懸念をもたらす/解決するか?) Does the feature bring (or solve) a performance concern? (パフォーマンスに関する懸念をもたらす/解決するか?) Is the feature a breaking change? (破壊的変更か?) Breaking changes in GA are not allowed! (GAに破壊的変更が入ることは許可されていない) Was this feature discussed before? What are the conclusions of previous discussions? (以前議論されたことがあるか? 以前の議論の結論は?) このような観点での議論が、開発フェーズのあらゆるシーンで行われます。下図のように、SIG (Special Interest Groups) との議論が行われます。そしてKEP (Kubernetes Enhancement Proposal) を書くことによるコミュニティ全体への提案が行われます。その後Alpha, Beta, GAそれぞれの前段階で度重なる議論が行われることになります。 ( link より引用) 例えばkubectlコマンドの出力に色を付けたいという素朴なfeature requestがあります。 https://github.com/kubernetes/kubectl/issues/524 「kubectlに色を付けるべきか?」を考えるだけでも、「workaroundはないか、kubecolorではダメなのか」「出力に依存するスクリプトは影響を受けるか」「任意のカラーテーマを実装できるのか」「色盲の人々のアクセスビリティは変わるか」「これを長期的にメンテナンスしていくモチベーションはあるか」など、様々な観点での議論が行われます。 2018年に立てられたこのissueは、2022年11月現在まだクローズに至っていません。 ( link より引用) 他にもNetwork Policyにフィールドを追加してport rangeを指定できるようにする、kubercファイルによる設定(PR)、Ephemeral containers(PR)における例も紹介されていました。 また、どんどん新しいアイデアを上げて欲しい、コードを書くだけがcontributeではない(どんなcontributionも歓迎する)ということも強調されていました。 このセッションは自分にとって、単純にKubernetesとその周辺の開発でどのような議論がなされているかがよくわかり、非常に興味深いセッションでした。Kubernetesのような、既に多くのEnterpriseレベルの本番環境に採用されるほどのOSSはかくあるべきだと感じました。 KubeConはメンテナ等のKubernetesコミュニティの人々が直接会する場所であり、このようなセッションはコミュニティがどのようなことを考えているかが感じられます。KubeConのような場が初めてな自分にとってよい機会となりました。 参考: Kubernetes SIGs https://github.com/kubernetes-sigs Kubernetes Enhancement Proposals (KEPs) https://github.com/kubernetes/enhancements/blob/master/keps/README.md Security In the Cloud With Falco: Overview And Project Updates - Jason Dellaluce & Luca Guerra, Sysdig 計測システム部SREブロックの西郷です。 このセッションではコンテナとKubernetes環境下における脅威検知のデファクトスタンダードであるFalcoについて、直近のアップデートや今後予定されているアップデートが紹介されていました。 今回はその中からいくつかピックアップしたいと思います。 直近のアップデートからは、2022/9/15に発表された gVisorのサポート についてです。 gVisorはGoogleが開発している低レベルコンテナレイヤで、GKEやAppEngine等で利用されています。仕組みとしてはホストのカーネルとコンテナを切り離し、サンドボックス化したコンテナでセキュアにアプリケーションを実行します。この分離により、カーネルモジュールもしくはeBPFでイベントを収集するFalcoはgVisorのイベントを検知できないという課題がありました。今回のサポートによりそれが解消され、例えばGKE SandboxのgVisorを有効にした環境に対してもFalcoの利用が可能になりました。バージョン0.32.1から対応しているとのことなので、詳しくはFalcoの以下ドキュメントを参照ください。 falco.org その他にもバージョン0.33からは同一インスタンス内で複数のイベントソースが処理できるようになったこと等が紹介されていました。これまでは複数のイベントソースからのイベントに対応するには、各イベントソースごとに複数のFalcoインスタンスをデプロイする必要がありました。 ( link より引用) 今回のアップデートにより、よりFalcoが使いやすくなったのではないでしょうか。 また、Upcomingなものとしては新しいeBPF Probeについての話がありました。eBPFの最先端機能を妥協することなく活用した新しいeBPF Probeの開発が進められており、いずれは従来のものと置き換えることが想定されているようです。現時点では80ほどのシステムコールに対応しており、従来のものより安全で高速化するだろうとのことでした。 計測システム部ではこれまでに静的解析の機構として、AquaSecurityによるkube-bench等の導入を進めてきました。まだFalcoは導入できていないのですが、ゆくゆくは動的解析の機構として導入したいと考えているので、今後も動向を注視していきたいと思います。 Migrating From Single-Node Kubernetes Control Plane To HA In Production - Cong Yue & David Oppenheimer, Databricks ブランドソリューション開発本部SREブロックの和田です。 今回取り上げるのは、Databricks社によるKubernetesコントロールプレーンを、単一構成からHA構成へ移行した際のノウハウを紹介するセッションです。Databricks社は2016年にKubernetesを採用しました。当時コントロールプレーンのHA構成は一般的でありませんでした。単一構成の場合、可用性に問題があることはもちろん、Kubernetesクラスタのバージョンアップ等の運用においてロールバックが困難であり運用上の課題があります。そこで、単一のVMで稼働するコントロールプレーンのスナップショットを取ることで複数台のコントロールプレーンを構築し、ロードバランサーを介してHA構成を実現しています。 ( link より引用) この構成により一部のコントロールプレーンに問題が生じた際に、該当のノードへのリクエストを避けることができます。移行時には増設したVMに対してetcdのデータをリストアし、Multi-nodeのetcdクラスターを設定した後にapi-serverの更新を受け付けるようにしています。従ってロードバランサーやetcdを増設するだけではなく、更新するタイミングを気にする必要があり、本セッションでは具体的な手順が紹介されていました。移行後のコントロールプレーンの操作は自動化して、問題が発生した場合はロールバックする仕組みを用いています。 一口にKubernetesと言っても導入時期やシステムの規模によって、直面する課題は様々だと実感しました。今回のようにKubernetesを初期段階で採用した企業の運用ノウハウが聞けるのもKubeConの魅力の1つです。このような運用に関するセッションは「Reliability + Operational Continuity」というカテゴリで確認できます。 When the Logs Just Don’t Cut It: Root-Causing Incidents Without Re-Deploying Prod ブランドソリューション開発本部SREブロックの繁谷です。 こちらのセッションではbpftraceとPixieを用いて、デプロイなしでGoアプリケーションをロギングする様子が紹介されています。 本番環境などデバッガを展開できない環境でのインシデントの調査はログが頼りですが、ログに出ていない箇所のデータを取得する場合はログを出すためのデプロイが必要です。しかし、そのような環境へのデプロイは時間がかかったりリスクがあるなどの問題があり調査に時間がかかってしまいます。そこで紹介されているのが、eBPFをベースとしたbpftraceを用いたPixieによるアプリケーションのロギングです。 eBPFはいくつかの条件を満たすプログラムをカーネル上に動かす仕組みで、カーネルの機能を安全かつ効率的に拡張できます。 ebpf.io bpftrace はkprobesやuprobesなど、カーネルやユーザ空間の様々な箇所にbpftrace Languageで書いた処理を指し込むことができます。 Pixie はbpftraceを独自の言語で使いやすく拡張しつつその結果をビジュアライズしてくれるツールです。bpftraceによるデータの取得に関してGoアプリケーションのロギングもサポートしています。 実際にPixieを動かしてみました。Macの場合は下記のドキュメントに沿ってminikubeをローカルで用意し、そこにアプリケーションをapplyしてPixieへデプロイするだけです。 docs.pixielabs.ai Goアプリケーションのロギングについて以下のデモを確認します。eの近似値を計算する computeE の引数 iterations をデプロイなしでロギングします。 docs.pixielabs.ai github.com デモアプリを立ち上げた状態ではPixieのテーブルに何も表示されませんが、APIを叩いた後に再度ロギングしてみると以下のように引数 iterations の値が得られます。 Pixieのアプリケーションの動的なロギングはGoのみの対応ですが下記の記事によるとC++やRustなども可能とあるので今後の展開に期待できそうです。 blog.px.dev bpftraceのベースであるeBPFについてコミュニティも注目している様子が伺え、KubeCon + CloudNativeConの様々なセッションで取り上げられていました。 先に書かれているFalcoや超満員だったセッションの「 100Gbit/S Clusters With Cilium: Building Tomorrow's Networking Data Plane 」で扱っているCiliumもeBPFが使われています。 今回イベントへ参加してeBPFの学習やコミュニティへの貢献が大変有意義なものになることが実感できました。 Cloud-Native WebAssembly: Containerization On the Edge - Michael Yuan, Second State 計測システム部SREブロックの纐纈です。 今回のKubeConの前半に行われたLTの中で、WebAssembly(以下、WASM)のランタイムであるWasmEdge用のDapr SDKを使ったマイクロサービスアプリケーションが紹介されていました。それを見てWASMとクラウド周りの現状が気になったので事前に調べた上で、こちらのセッションを見に行ってきました。 このセッションでは、LinuxコンテナとWASMの比較から始まり、現在WASMが対応している言語やライブラリ、DockerやKubernetes上で実際に動くデモなどが紹介されました。 WASMはイメージサイズの小ささや立ち上がりの早さ、ネイティブ実行に近いパフォーマンス、デフォルトで高いセキュリティを持つ仕様、言語に依存しないなどの特徴があります。 今回KubeConの隣接イベントであるWASMConで、Technical PreviewとしてDocker上にWASMの実行環境が統合される、という発表がありました。 www.docker.com これによって、AWSやGCPなど他のクラウド上のDocker実行環境でも動かせるようになりました。今までもWASMのアプリケーションはWASMの実行基盤が用意されているwasmCloudというクラウドで動かすことができたのですが、よりWASMの本番利用がしやすくなったと思われます。 またWASM用のDaprのSDKが開発されたことにより、LinuxコンテナとDaprを使用して現在運用されているサービスの中で補完的にWASMのマイクロサービスを使用できるようになりました。 現状WASMは既存のLinuxコンテナの環境を置き換えるものではなく、補完的に利用できる技術だと言えそうです。 最後に メンバー全員初めてのKubeCon参加だったのですが、プロジェクトのメンテナから直接話が聞けたり質問できたりする機会であることを体感でき、非常に貴重な経験になりました。 カンファレンス期間中よく耳にしたフレーズですが、OSSはコミッターなしで成り立ちません。利用する技術を自分達で作ることができるのはOSSの醍醐味であり、普段使っている技術をただ使うのではなく、貢献していくことが大切であることを再認識しました。今回のKubeConで得た知識を業務に活かしつつ、還元していけたらと思います。 ZOZOでは一緒に働くエンジニアを募集していますので、興味のある方は以下リンクからぜひご応募ください。 hrmos.co 番外編:現地の様子をお届け 会場横を流れるデトロイト川、川の向こうにはカナダが見えます。カンファレンス期間中は早朝にRiver Walkのセッションも行われていました。 ランチは毎日メニューが異なるのですが、何より温かいものが出ることに驚きました。これは1日目のカレーのようなものと、ツナサラダ。美味しかったです。 Keynote会場。 会場のHuntington Place外観。 CNCFストアではTシャツやマスク、ぬいぐるみなどの様々なグッズが販売されていました。 最後は参加メンバー全員で撮った集合写真。お疲れ様でした!
アバター
はじめに こんにちは。計測プラットフォーム開発本部SREブロックの纐纈です。今年の4月に入社し、ZOZOMATやZOZOGLASSの運用改善に取り組んでいます。また、今年の夏US向けにZOZOFITをリリースしましたが、そちらの機能追加にも今後関わっていく予定です。 計測システムでは最近Argo Rolloutsを導入してカナリアリリース、自動ロールバックを実現しました。本記事では、その具体的な導入方法と効果についてお伝えします。 目次 はじめに 目次 Argo Rollouts導入前のリリースの問題 カナリアリリースの導入 導入後の効果 ツールの選定 Argo Rolloutsについて DeploymentからRolloutへの移行 1. 既存のDeploymentを参照するRolloutリソースを作成して、Podを立ち上げる 2. HPAの対象をDeploymentからRolloutに変更する 3. Deploymentのspec.replicasを0にする Datadogとの連携と注意点 ALBとの連携と注意点 マルチテナントにおける横展開について まとめ 終わりに Argo Rollouts導入前のリリースの問題 Argo Rollouts導入前までは、以下の手順でロールバックしていました。 リリース担当者が異常を検知 リリースPRをリバートする リバートPRをマージする Argo CDが変更を検知し、アプリケーションを旧バージョンに戻す 私達のチームではArgoCDを導入しており、KubernetesリソースをGitHubで管理しています。このため、ロールバックは、アプリケーションのイメージタグを変更するリリースPRをリバートすることで完了します。しかしながら、旧バージョンのPodが起動し、トラフィックが流れるまでの間、ユーザー影響は避けられません。 こうした理由からユーザ影響を最小限に抑えるため、リリース戦略を見直す必要がありました。 上記の課題を解決するため、弊チームではカナリアリリースを導入することにしました。 カナリアリリースの導入 まずカナリアリリースについて簡単に説明します。カナリアリリースは、リリースの手法の1つです。初めからリリースするバージョンを全体に公開するのではなく、公開する対象を一部のユーザーだけに絞りつつテストを行い、定められた基準を満たした場合のみ全体に公開するリリース方法のことを指します。これによって、ユーザーのトラフィックありきのテストを本番環境で行いつつ、障害発生時にもユーザーへの影響を最小限にしながら自動ロールバックを実現できるようになります。 また段階的なリリース方法としてカナリアリリースとよく比較されるのが、Blue/Greenデプロイです。Blue/Greenではなくカナリアリリースを選択した理由は、実際にユーザートラフィックが流れる点にあります。カナリアリリースでは実際にユーザートラフィックが流れている状態でテストを行うため、Podの立ち上げ時にはわからない問題も対応できます。計測システムでは、ZOZOTOWNのAPIサーバーと接続して認証するため、実際にトラフィックを流してのテストを行う必要がありました。 導入後の効果 Argo Rolloutsを導入してカナリアリリースを実現してからは、より安心安全にリリースできるようになりました。リリース担当者の心理的負荷が減っただけでなく、エラー検知した際もPodが瞬時に入れ替わるため、障害による影響範囲を最小限に抑えられます。これによって、本番リリース直後のアラートに怯える必要性が大幅に緩和されました。 また副次的な効果として、今回ステージング環境にも同様にArgo Rolloutsを入れたため、エラーを伴う変更が本番環境でリリースするまでに発見できるという効果もありました。ステージング環境ではユーザーのトラフィックは流れていませんが、暖機運転と共に動作確認用の処理がPod起動時に走るため、エラーがあればこの動作確認で発見できます。 この時点でエラーを伴う変更を検知して自動ロールバックが走ったため、ユーザー影響を出す前にステージング環境で食い止めることができました。 カナリアリリースでの1つデメリットとして、Podが徐々に入れ替わって段階的にテストを行うため、全体的なリリース時間が伸びてしまうという特徴があります。例えばリリース後に動作確認やチェックを行う場合、リリース担当者が全てのPodが入れ替わるまで待つ必要があり、これはリリース担当者のリリースに費やす時間的制約を増やしてしまいます。そのため、できる限りリリース後の作業は自動化できると、よりカナリアリリースの恩恵を受けられるようになるかと思います。 ツールの選定 カナリアリリースの特徴や効果については一通り説明できたと思うので、なぜ今回Argo Rolloutsを選んだのかについて説明していきます。カナリアリリースは通常のKubernetesリソースであるDeploymentではサポートされておらず、外部ツールを使う必要があります。カナリアリリースをサポートする外部ツールはArgo Rollouts以外にも、 Spinnaker や Flagger のようなものがあります。Flaggerについては社内の他チームで採用され、ブログ記事も公開されているので、気になる方はこちらの記事をご参照ください。 techblog.zozo.com 今回計測システム部でArgo Rolloutsを選んだ理由は3つあります。 まず、計測システムでは既にArgoCDの導入が済んでおりArgo Rolloutsとの親和性が高かったこと。これはArgoCDのWeb UI上でArgo Rolloutsの操作ができる、Slack通知用のトークンなどを共通して使えるなどの利点があります。 次に、Argo Rollouts以外のツールで必要となるIstioのようなサービスメッシュがまだ導入されていないこと。現在計測システムでは、サービスメッシュは使われておりません。そのため、カナリアリリースのためにサービスメッシュを導入すると、工数も増えるため導入のハードルが上がりました。 最後に、必要最小限の機能であるDatadogやCloudWatchの連携ができること。今回考慮したツールの中ではどれもサポートされていましたが、もしArgo Rolloutsがサポートしていなければ、選定されることはありませんでした。 Argo Rolloutsについて Argo RolloutsはBlue/Greenやカナリアリリースのようなプログレッシブデリバリーをサポートしている、Kubernetesコントローラーです。CRDs(Custom Resource Definitions)でもあります。監視ツールと連携させて自動ロールバックや段階的リリースを行なったり、Ingressコントローラーやサービスメッシュと連携させて限定公開時のトラフィック制御ができます。 連携できる監視ツールはDatadog、CloudWatch、Prometheusなどがあります。事前に定めた基準から外れたメトリクスを検知した場合、自動でロールバック、基準内だった場合は段階的にリリースします。また、このリリースの段階やテスト項目などはKubernetesのマニフェストで定めることができます。 限定公開時のトラフィックの制御については、ALB IngressコントローラーやIstio、nginxなどと連携できます。連携する場合パーセント単位で制御できますが、連携しない場合でもPod数単位での段階的な切り替えが可能です。 カナリアリリース用にArgo Rolloutsで必要なリソースは、RolloutとAnalysis Templateの2つです。 RolloutはArgo Rolloutsのカスタムリソースで、Deploymentリソースの項目に追加してカナリアリリースやBlue/Greenデプロイ用のフィールドが追加されています。DeploymentのExtensionだと考えてもらえれば良いです。Deploymentに含まれているフィールドに関しては対応されている他、カナリアリリースやBlue/Greenデプロイの細かい設定ができます。また、既存のDeploymentを参照して設定を読み込むこともできます。 Analysis Templateでは、Datadogなどの監視ツールで取得するメトリクスとテスト内容が記述できます。テスト内容は、頻度や取得したメトリクスのテスト条件、ロールバックまでの許容エラー数などが記載できます。 DeploymentからRolloutへの移行 Argo Rolloutsを導入すると、PodをRolloutリソースで管理することになります。新規プロジェクトで導入するのであれば、最初からDeploymentではなくRolloutリソースを作成すれば良いです。一方、既存のプロジェクトで導入する場合はDeploymentリソースから移行する必要があります。 今回弊チームでは、DeploymentからRolloutへ障害なく移行できたのでその方法を説明していきたいと思います。 Argo Rollouts導入前の計測システムでは、DeploymentでPodの設定、HPA(Horizontal Pod Autoscaler)でPod数を管理していました。 既存のDeploymentを参照するRolloutリソースを作成して、Podを立ち上げる HPAの対象をDeploymentからRolloutに変更する Deploymentの spec.replicas を0にする 以上のステップに切り分けてリリースすることで、移行は完了します。詳しく見ると、以下のようになります。 1. 既存のDeploymentを参照するRolloutリソースを作成して、Podを立ち上げる まず、Rolloutリソースを用意します。この時点ではまだDeploymentからのPodは立ち上げたままにしているため、DeploymentとRollout両方からPodが立ち上がっている状態です。 Rolloutでは、このようにDeploymentを参照することでDeploymentの設定を読み込むことができます。Deploymentのイメージタグなどが変更された際、Rolloutが変更を検知して、デプロイを行います。 apiVersion : argoproj.io/v1alpha1 kind : Rollout ... spec : workloadRef : apiVersion : apps/v1 kind : Deployment name : <name for Deployment> 2. HPAの対象をDeploymentからRolloutに変更する 次に、オートスケールを利用してる場合は、オートスケールの対象をRolloutリソースに変更します。 apiVersion : autoscaling/v2beta2 kind : HorizontalPodAutoscaler ... spec : scaleTargetRef : apiVersion : argoproj.io/v1alpha1 kind : Rollout name : <name for Rollout> 3. Deploymentのspec.replicasを0にする 無事RolloutからPodが立ち上がることを確認したら、DeploymentからのPodを落とします。これは、Deploymentのreplicasを0に設定することで実現できます。また、revisionHistoryLimitを0にすることによって、明示的な設定がない場合デフォルトで10個保持されてしまうreplicasetを削除できます。 apiVersion : apps/v1 kind : Deployment ... spec : replicas : 0 revisionHistoryLimit : 0 今回移行した方法以外にも、以下の方法でもDeploymentからRolloutへの移行はできます。 新規でRolloutリソースを作成して、Podを立ち上げる。この時点でPodが倍になる。 Deploymentの spec.replicas を0にする。この時点でRolloutに紐づいたPodだけになる。 問題なければDeploymentリソースを削除する。 どちらの方法を取るかはこの方法だとDeploymentを完全に削除できる一方、Rolloutの中にDeploymentのテンプレートを入れるためRolloutの中身が煩雑になってしまいます。既に動いているDeploymentがあるのであれば、そちらを参照する方がそれぞれのテンプレートがシンプルになり管理しやすいかなと思います。どちらの方法でも移行の手間は変わらないです。 Datadogとの連携と注意点 今回計測システムでは、Datadogと連携させてメトリクスを取得しています。Datadogとの連携はシークレットを登録するだけで可能です。 apiVersion : v1 kind : Secret metadata : name : datadog type : Opaque data : address : https://api.datadoghq.com api-key : <datadog-api-key> app-key : <datadog-app-key> Argo Rollouts内のコードを読むと、Datadogとの連携部分では メトリクスのAPI を叩いていることがわかります。なので、Datadogのログなどは通常取得できません。しかし、多くの場合ログからエラーなどを取得したいところだと思います。幸いなことにDatadogには、ログのクエリをメトリクスとして保存する機能があります。これを利用すると、Argo RolloutsからもDatadogのログを参照できます。 docs.datadoghq.com ALBとの連携と注意点 Argo RolloutsはALBとの連携をサポートしているため、今回計測システムで使用しているALB Ingress Controllerとの連携を検討しました。しかし、最終的にトラフィック制御はしないことにしました。 Argo Rolloutsではロードバランサーやサービスメッシュなどと連携せずに、Pod数単位でユーザートラフィックを分散させるという選択肢があります。この場合先述した通り、ユーザーのトラフィックをパーセンテージ単位でリリースバージョンに流すのではなく、純粋にPod数単位での分散となります。例えば新しくリリースするバージョンのPodを1つ、リリース前のバージョンのPodが3つ立てている場合、25%のトラフィックがリリースバージョンに流れることとなります。 本来であればALBと連携させてリリースするバージョンにはユーザートラフィックをパーセンテージ単位で絞って、障害時のユーザー影響を極小化したいところなのですが、1箇所対応できない点がありました。 現状Argo Rolloutsの仕様として、RolloutとALB Ingressは1対1である必要があり、ALB Ingressが複数ある場合は想定されていません。ALB Ingressが複数ある場合、RolloutもALBの数に合わせて作成する必要があります。そうなるとパスごとにデプロイのタイミングを考える必要があり、ロールバックが発生する場合も考慮するとバージョンがパスによって異なるといった問題が起きます。運用上Podの管理が大変になることもあり、今回ALBとの連携は断念しました。 計測システムが複数のALB Ingressを使っているのは、クライアントによって接続するプロトコルが異なり、1つのALB Ingressで複数のプロトコルには紐付けられないためです。具体的には、ネイティブアプリからはgRPC、ZOZOTOWNサーバーからはRESTが使用されており、IPによってリスナーを分けています。また、RESTでの通信も計測システム内のEnvoyでgRPCに変換されるのですが、gRPCを使用している詳しい背景については以下の記事をご参照ください。 techblog.zozo.com マルチテナントにおける横展開について マルチテナント構成を取っている場合、Argo Rolloutsを他のプロダクトに導入するのは非常に簡単です。計測システムでは、ZOZOMATやZOZOGLASSを同じクラスタ内で運用するマルチテナント構成をとっています。そのため、ZOZOMATに次いでZOZOGLASSでもArgo Rolloutsを導入しましたが、少ない工数で横展開できました。 マルチテナント構成に関する詳しい背景などは、こちらの記事をご参照ください。 techblog.zozo.com 1つ目のプロダクトに導入する際は、事前にArgo Rolloutsリソース自体のインストールやSlack通知の設定、Datadogなど連携するツールの設定などを行う必要があります。ですが、2つ目以降は、構成が変わらない場合RolloutとAnalysisTemplateのみの追加で導入が可能です。ここはマルチテナント構成を取っている弊チームでは大きな利点となりました。 まとめ Kubernetes上でカナリアリリースを実現するために、Argo Rolloutsは有用で楽に導入ができるツールでした。 カナリアリリースができるようになり、弊チームではより安全に定期リリースを行えるようなり、リリース担当者の負担も軽くなりました。 本記事がArgo Rolloutsの導入やカナリアリリースを検討している方への手助けになれば幸いです。 終わりに 計測プラットフォーム開発本部では、今夏に新しいサービスである ZOZOFIT をUSにてローンチしました。更にスピード感を持った開発が求められますが、このような課題に対して楽しんで取り組み、サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co
アバター
はじめに こんにちは、SRE部ECプラットフォーム基盤SREブロックの織田です。普段は主にZOZOTOWNのリプレイスやインフラを改善、運用しています。 本記事では、Secret管理コンポーネントであるKubernetes External Secrets(以降、KESと表記)の非推奨を受けて、どのような対応を実施したのか紹介します。 目次 はじめに 目次 なぜSecret管理コンポーネントを利用するのか? Kubernetes External Secrets(KES)について KESとは? KESを使ったKubernetes構成 KESの非推奨について 移行先の選定 優先事項の決定 移行先の検討、比較 移行先の決定 External Secrets Operator(ESO)について ESOのアーキテクチャ 移行における注意点や変更点 移行の実施 移行方法 工夫したポイント 最後に なぜSecret管理コンポーネントを利用するのか? Secret管理コンポーネントとは、KubernetesのSecretを管理する機能を持つソフトウェアのことです。 Kubernetesを運用する上でSecret管理コンポーネントを利用しない場合は、Secret manifestをKubernetesにapplyしSecretを作成することになるかと思います。KubernetesのSecretは、データをbase64エンコードしているだけなので、誰でもbase64デコードができ、データを確認できます。そのため、Secret manifestのような基本的に秘匿情報を含むファイルは、GitHubのようなバージョン管理システムにコミットするべきではありません。 コミットを避ける方法の1つとして、Secret管理コンポーネントの利用が挙げられます。そうすることでAWS Secrets ManagerやAzure Key VaultのようなSecret Management System(以降、SMSと表記)に登録された秘匿情報をKubernetesが取得し、Secretに注入できます。その結果、Secret manifestをバージョン管理システムにコミットせず、安全にデータを扱うことが可能です。 弊チームではこれまでSecret管理コンポーネントとしてKESを使用していました。 Kubernetes External Secrets(KES)について KESとは? KubernetesのSecretリソースを管理するオペレーターです。弊チームでは外部のSMSからデータを取得し、取得したデータをKubernetesのSecretへ注入するために利用しています。 KESを使ったKubernetes構成 弊チームではどのような構成でKESを利用していたのか紹介します。下図がKESを採用していたおおよそのKubernetesの構成図です。 KESがExternalSecretの内容をもとにAWS Secrets Managerに登録されているデータを取得し、Secretを作成します。 Application(Deployment,Cronjob,etc.)は、ExternalSecretが作成したSecretを参照したり、volumeとしてマウントします。 このようにSMSから取得したデータをKubernetesで利用することによってSecret manifestをコミットせずに済むため、よりセキュアに扱うことが可能です。 KESの非推奨について 2021/11/03に KES は 非推奨化 が発表され、現在はアーカイブされています。 KESが非推奨になった背景としては、アクティブなメンテナが存在していなかったため、プロジェクト内の問題を引き起こしている技術的負債が解消されず、メンテナンスされていないことだと KES is deprecated, migrate to ESO! で言及されています。 発表の内容を見るとOSSをメンテナンスすることはモチベーションの維持や管理の面においてとてもハードだと感じるとともに、今まで利用させていただき感謝しかありません。 ただ弊チームのKubernetes Clusterでは、全SecretをKESで生成しているため、なにかしらの対応をとる必要がありました。 移行先の選定 ここからは移行先コンポーネントの選定について具体的に説明します。 優先事項の決定 まずは移行先を決めるにあたって、比較する項目を決め以下の4つを優先事項としました。 Kubernetes manifestやkustomizeの構成に変更が少ないこと Secret更新時の運用に変更が少ないこと 移行コストが少ないこと KESで利用している機能、または同等の機能が使えること ある程度アクティブに活動があるOSSであること 現状、KESに不満はないため、kustomize構成やSecret運用に大きな変更を加えず、移行コストを抑えたいと考えました。 KESで利用している機能は、Secretの更新タイミングをコントロールできるもの(自動更新のOFF)です。自動更新をONにしているとSMSを更新後、Secretも自動更新されてしまうため、リリースタイミングをコントロールしたいと考え、自動更新をOFFにしています。Secretを更新してもPodを再起動しなければ更新後の値を読み取りませんが、タイミング悪くPodが再起動されてしまった場合意図せず読み込んでしまうため、このような対応をしています。 移行先の検討、比較 次に移行先の検討です。アクティブに開発されていそうな以下3つのコンポーネントを検討し、比較しました。 External Secrets Operator Secrets Store CSI Driver Sealed Secrets External Secrets Operator(以降、ESOと表記)は、KESを開発していたオーガナイゼーション(External Secrets)が開発しています。JavaScriptで書かれていたKESをGoでリファクタリングしたコンポーネントです。ExternlSecretのapiVersionは変更になっていますが、ExternalSecretがSecretを作成するということは変わっていないため、Kubernetes manifestの変更は少なく済みそうです。KESの 非推奨issue でもESOへの移行を勧められているのとKESからESOへの 移行ツール (以降、移行ツールと表記)が用意されています。そのため、移行コストについても少なく済みそうだと考えました。 Secets Store CSI Driverは(以降、CSI Driverと表記)、 Kubernetes SIGs が開発しています。名前の通り、 Container Storage Interface(CSI) を利用し、シークレットやキー、証明書をPodにVolumeとしてマウントします。また、マウントしたものをSecretに同期したり、複数のSecretオブジェクトを1つのVolumeとしてマウントするなど様々な機能があります。Podにシークレットやキー、証明書をVolumeとしてマウントした上でSecretに同期する必要があることを考えると、少なくともKubernetes manifestへのインパクトは大きそうです。 Sealed Secretsは、 Bitnami が開発していて、"全Kubernetes configをGitで管理できるようにする" ことを目的に作成されました。SecretをSealed Secretsに暗号化させ、Kubernetes manifestを生成します。Kubernetesで動作するコントローラのみが復号化でき、原作者でさえもSealedSecretからオリジナルのSecretを取得できません。そのため、Public repositoryにコミットしても安全に保管できると言われています。 1 この段階で移行先がほぼ決まっている感じはありますが、候補とした3つを比較しました。 2 比較項目\コンポーネント ESO CSI Driver Sealed Secrets Kubernetes manifest変更の少なさ ◎ ✗ △ 移行コストの少なさ ◎ ✗ ○ Secret運用への影響の少なさ ◎ ✗ ○ 累計コミット数 2011 1062 996 自動更新OFF/ON機能 ○ - △ 移行先の決定 比較の結果、移行先は優先事項を全て満たしていたESOに決定しました。比較表を見ると一目瞭然ですが、不採用としたコンポーネントごとの理由としては以下になります。 CSI Driverは、ほかのコンポーネントと比べKubernetes manifestに大きな変更が必要で、それに伴い移行コストや運用変更コストも多そうだと判断したため Sealed Secretsは、SMSを利用できないこと、暗号化した秘匿情報をバージョン管理システムにコミットするのは可能であれば避けたかったため 移行コストやKubernetes manifestの変更コストを優先事項に挙げるとKESの後継であるESOを選ぶのは必然だったのかと思いました。 External Secrets Operator(ESO)について 移行先を決めたのでESOについて見ていきたいと思います。 ESOは、Kubernetesオペレータで外部のSMSから情報を読み取り、その値をKubernetes Secretに自動的に注入します。目的は、SMSとKubernetes Secretを同期させることです。 ESOのアーキテクチャ 全体像としては、下図のようになっています。 Kubernetesをカスタムリソース(CR)で拡張し、ExternalSecretにSecretの保存場所と同期方法を定義することで、外部のSMSからデータを取得しSecretにデータを注入します。参照しているSMSが変更された場合、Kubernetes Clusterの状態を確認し、それに応じてSecretを更新します。 より詳細な情報については、 External Secrets Operatorの公式ドキュメント をご確認ください。 移行における注意点や変更点 移行方法の紹介の前にKESからESOへの移行において、把握しておくべき変更点や注意点を確認します。 API追加に伴うアーキテクチャの変更 Kind:ExternalSecret のapiVersion変更 KESからESOへ移行する際に注意すること 1つ目はAPI追加に伴うアーキテクチャの変更です。KESとESOはカスタムAPIの数が異なり、以下の表の様になっています。 コンポーネント API KES ExternalSecret ESO ExternalSecret, SecretStore, ClusterSecretStore, ClusterExternalSecret KESでは、前述の通りExternalSecretの設定をもとにSMSからデータを取得し、Secretに注入します。ESOでは、新たにSecretStore,ClusterSecretStore,ClusterExternalSecretが追加され、基本的なアーキテクチャとしては下図のようになっています。 ESOは、SecretStoreまたはClusterSecretStoreに格納された情報を使い、外部のSMSに接続しExternalSecretの設定をもとにデータを取得します。無事、SMSとExternalSecretが同期できていれば、取得したデータを注入したSecretが作成されます。というようにSMSとExternalSecretの間にSecretStoreが追加されました。SecretStoreはnamespaceスコープで、ClusterSecretStoreはclusterスコープです。ExternalSecretとClusterExternalSecretも同様です。そのため、移行する際には新しくSecretStoreを作成する必要があります。 2つ目は、 Kind:ExternalSecret のapiVersion変更についてです。KESでは kubernetes-client.io/v1 でしたが、ESOでは external-secrets.io/v1beta1 に変更されています。apiVersionが変わりそれに伴いスキーマも変更になっているため、例えばKESではDeploymentで定義していたデータ取得のポーリング間隔をESOではExternalSecretで定義します。他にも変更になった点があるため、事前に確認が必要です。 ESOへ移行するにあたってどのような変更をしたらよいかについては、 公式ドキュメントのExternalSecretのページ をご確認ください。 3つ目は、KESからESOへ移行する際の注意点です。 Upgrade from KES to ESO でKESとESOではいくつか非互換性があるため、かなり厄介だと述べられています。移行ツールは、非互換性に関してほぼカバーされているとのことですが、移行ツールでは、SecretStoresがKESのService Accountを指定するようになっているため、移行後にKESをアンインストールする予定がある場合は、調整する必要があるとのことです。 移行の実施 ここからは、実際にどのように移行したか紹介します。 移行方法 まずは、移行方法についてです。前提として、今回の移行ではExternalSecret、Secretを既存の名前とは異なる名前で再作成しています。そのため、既存Secret名をそのまま利用したい場合は、以下の手順ではエラーになってしまいますのでご注意ください。 まずは、ESOのドキュメントを見てアーキテクチャを理解しながら移行手順を考えました。幸いなことに移行ツールが用意されていたため、テスト環境で移行ツールを実行したり、 手動移行 を参考にし、移行手順を作成しました。 次に、考えた移行手順を検証し、システムに影響がなく可能な限り作業量の少ない方法を試行錯誤しながら移行手順をブラッシュアップしました。システムに影響を与えないことは必須だと思いますが、弊チームの場合、Secretの切り替え作業が1回では済まないためできるだけ作業量を減らせるよう努力しました。 それらを踏まえた移行手順が以下になります。 ESO(CRD,Deployment,ServiceAccount,etc.)をデプロイ SecretStore,ExternalSecret(ESO)を作成 ExternalSecret(KES)を削除 KES(CRD,Deployment,ServiceAccount,etc.)の削除 各手順について少しずつ触れたいと思います。 ESO(CRD,Deployment,ServiceAccount,etc.)をデプロイでは、カスタムAPIリソース以外をデプロイしました。CRDがKubernetesに登録されていない状態でExternalSecretなどのカスタムAPIリソースを作成しようとするとエラーになるためです。 SecretStore,ExternalSecret(ESO)を作成では、CRD作成後、カスタムAPIリソースをデプロイしました。このタイミングでSecretStore、ExternalSecret、Secretが作成されます。また、KESのExternalSecretをもとに作成されているSecretを参照しているリソース(Deployment,CronJob,etc.)はここでESOのものに切り替えました。KESとESOでは、ExternalSecretのapiVersionが異なるため、作成時の重複についてあまり気にする必要はありません 3 が、より安全に行いたい場合は、KES Deploymentのreplicasを0にしておくことで予期せぬExternalSecretの再作成を防ぐことができます。 ExternalSecret(KES)を削除では、手順2で切り替え、参照されなくったExternalSecretを削除しました。 KES(CRD,Deployment,ServiceAccount,etc.)の削除では、不要になったKES関連のリソースの削除しました。 より安全な移行が求められる場合は、 Manual Migrationで示されている手順 を実施することで解決ができるかと思います。 工夫したポイント 最後になりますが、非推奨の対応で工夫したポイントを2つ紹介したいと思います。 1つ目は、移行手順の策定において公式が用意していた移行ツールを参考にし、安全な移行方法を決定しました。移行用ツールが用意されているのでそちらを利用すればよさそうではあるのですが、以下の理由で利用しませんでした。 移行ツールを利用するには、ツールを修正する必要があった 移行ツールで生成したmanifestを変更するより、現状のmanifestを変更したほうが作業量を少なく済ませられた Upgrade from KES to ESO で言われている通り、移行ツールを実行しても手作業が発生するため(手順のみ参考にさせていただいた) 2つ目に、ESOのリソースとKESのリソースでnameやlabelsなどにおいて名前を被らせないようにしました。どちらもデフォルトの状態で利用しようとするといろいろなリソース名(Deployment,ServiceAccount,etc.)がバッティングしてしまいます。 例えば、ServiceAccountのリソース名はどちらも external-secrets です。ESOをデプロイするタイミングでKESのSAが更新されてしまい、KESをアンデプロイするタイミングでESOも利用しているSAが削除されてしまいます。そのため、KESが利用している名前とはかぶらないように調整し、ESOをデプロイしました。 最後に 本記事では、KESの非推奨対応についてどのようなことを実施したか、具体的に紹介しました。結果として、KESの非推奨対応を行い、ESOに移行することでSecretを管理する重要なコンポーネントで問題が起きる前に対応できました。 また、ESOを調べることで新機能やアーキテクチャの変化によって、Secretをよりセキュアに扱えることや現状抱えているkustomizeのSecret構成を改善できることがわかりました。しかし、今回の対応ではそこまで踏み込んだ対応できていないため、次のステップではそれらの対応を実施すると共にSREとしてトイルを削減したいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co https://github.com/bitnami-labs/sealed-secrets#sealed-secrets-for-kubernetes ↩ 2022年10月20日現在 ↩ 前提として、KESとESOのExtenalSecretとSecretは別名で作成すること ↩
アバター
こんにちは! 計測プラットフォーム本部でiOS/Androidアプリ開発をしている寺田( @tama_Ud )です。 2022年10月5日から7日にかけて、「 DroidKaigi 2022 」が開催されましたね! ZOZOはGOLD SPONSORとして協賛し、オフライン会場にてスポンサーブースの出展をしました。 technote.zozo.com 今回、自分は現地参加が叶いましたので、オフライン参加レポートをお送りします。 目次 目次 ZOZOエンジニアが1名登壇しました ZOZOスポンサーブースの紹介 CfSネタ出し会とレビュー会を実施しました After DroidKaigiを開催しました セミナー・カンファレンス参加支援制度 最後に ZOZOエンジニアが1名登壇しました ZOZOTOWNのAndroidエンジニアを務める鈴木( @s1u2z1u3ki )が、Android Vitalsについて登壇しました! 2022年3月に公開された Google Play Developer Reporting API の解説や、弊社でのクラッシュ・ANR指標の維持・改善について、実例を交えて紹介しています。 speakerdeck.com ZOZOスポンサーブースの紹介 前述の通り、ZOZOはGOLD SPONSORとして協賛し、オフライン会場にてスポンサーブースを出展しました。 また、ノベルティとして「ZOZO特製温泉タオル」を配布しました。オフライン会場入口でノベルティ入りのサコッシュとともに受け取っていただいたかと思います。 #DroidKaigi の会場にお越しいただいている方は入口でサコッシュを受け取りましたか? ZOZOのノベルティとして白地にカラーのZOZO特製温泉タオル♨が入っています。 雨に濡れた身体を拭くも良し、一日の疲れを温泉や銭湯で癒やすときに使うも良し、ぜひ活用ください✨ pic.twitter.com/Kj9Ate6Z05 — ZOZO Developers (@zozotech) October 5, 2022 さらに、ZOZOGLASSとZOZOMATに加え、その場でZOZOGLASSの計測を試していただいた方にZOZOオリジナルTシャツをプレゼントしていました! 他にもステッカーやボールペンなどノベルティが盛りだくさんでした。 ZOZO社員が交代制でブースアテンドをしていましたが、会場は大賑わいでしたね! Androidエンジニアの熱量を肌で感じられました。ZOZOブースにも連日たくさんの方にお越しいただきました。エンジニアトークに花が咲いたり、ZOZOGLASSでパーソナルカラーを計測していただいたりと、ZOZOをもっと知ってもらうきっかけになったかと思います。お越しいただいた皆様、この場をお借りしてお礼申し上げます! CfSネタ出し会とレビュー会を実施しました ZOZOTOWNアプリ部の岩田( @iwata_n )です。ZOZOTOWNに関わるAndroidエンジニアは、週1回集まって技術的な活動をする時間を設けています。それを活用して、CfSに提出するネタ出し会をメンバーみんなで実施しました。 まずは下図のようなマンダラチャートを使用して、ネタの発散から行いました。すべてを埋めることは難しいのですが、ウケそうな話や、実際に自分たちも聞いてみたい話などをざっくばらんに書き出しました。 次に、マインドマップを使ってネタの内容を整理し、具体的なネタに落とし込みました。 ある程度ネタを整理したところで、それぞれのネタに担当者を決めます。チームで相談しながら原稿を作成しました。また、DroidKaigi公式の相談会に参加したメンバーが、より良い応募内容について以下のような情報を収集してくれました。 共感者を増やす 現状の問題に言及する 例:「こういう問題、よくあるじゃないですか?」 発表によって、誰の何が解決するかを具体的に示す なぜその技術・テーマが大事なのかを説明する 例:「Vitalsはなぜ重要なのか?」-> ビジネス指標に影響するから 話すことの概要・アジェンダを具体的に列挙しておく 話す側と聴く側のギャップを防げる これらの知見を活かし、さらに内容をブラッシュアップしました。原稿はGoogle Docs上で作成しました。メンバー全員がコメントを書ける状態に設定することで、活発な議論ができました。 最終的に12件のCfSを提出し、1件が採択されました。出した件数に対して採択の件数がいまいち奮いませんでしたが、ZOZOとしては過去一番の応募件数になりました。今後、さらに採択数を増やせる伸びしろを確認できたと思います。 After DroidKaigiを開催しました フロントエンド部 FAANSにてテックリードをしている堀江 ( @Horie1024 ) です。 昨年に引き続き今年もLINE、ヤフー、ZOZOの3社合同でAfter DroidKaigiをオンラインで開催しました!コードレビューチャレンジの問題解説(LINE)、Androidアクセシビリティに関する取り組み(ヤフー)、LiveOpsの活用(ZOZO)について各社LTがありどれも興味深い内容でした。 ZOZOの下川が発表したLiveOpsの活用に関するスライドはこちらをご覧ください。 speakerdeck.com その後、パネラーの方々とDroidKaigiをメインテーマとしてパネルディスカッションを行いました。Miroのボードを共有しながら、小テーマに沿ってトークを進めました。面白いと思ったセッションを複数挙げられる方もおられ、ボードは付箋でいっぱいになりました。 オフライン参加されたパネラーの皆さんも、対面コミュニケーションが取れることをメリットとして語っておられた印象で、改めてオフライン開催の良さを感じました。After DroidKaigiも引き続き開催していきたいです。 見逃した方はこちらで録画を配信しています! www.youtube.com セミナー・カンファレンス参加支援制度 ZOZOにはエンジニアのイベント参加をサポートする制度があります。 corp.zozo.com ZOZOはGOLD SPONSORとして協賛しているのでスポンサーチケットがありますが、チケット費用をはじめとし、以下の補助が受けられます。 希望するエンジニアは業務として参加 チケット費用、交通費は経費精算、遠方の場合は出張扱い可 (イベントが休日開催の場合)休日出勤扱いで参加 今年は実際に遠方から参加したAndroidエンジニアもおり、私自身もチケット費用の個人負担はありませんでした。これらの制度があることで、イベントに参加しやすい環境が整っていると思います。 最後に 現地で参加された方も、オンラインで参加された方も、お疲れさまでした! また、スタッフの方々もイベント終了までしっかりサポートして頂きありがとうございました。Androidエンジニアにとって久々のオフラインイベントで、多くの方々と交流でき、みなさん満足度が高かったのではないでしょうか? ZOZOでは、Androidエンジニアをはじめ、一緒に楽しく働く仲間を募集中です。ご興味のある方は下記採用ページをご覧ください! hrmos.co
アバター
はじめに こんにちは。ブランドソリューション開発本部 バックエンド部 SREの笹沢( @sasamuku )です。 ZOZOではショップスタッフの販売サポートツール「FAANS」を2022年8月に正式リリースしました。FAANSはアパレルのショップスタッフ様を支援する様々な機能を提供しています。例えば、ZOZOTOWN上で実店舗の在庫取り置きができる機能や、コーディネート投稿の機能などがあります。投稿されたコーディネートはZOZOTOWNやWEAR、Yahoo!ショッピングに連携が可能で、今後はブランド様のECサイトとも連携できる予定です。これによりお客様のコーディネート選びをサポートし購買体験をより充実したものにします。機能の詳細に関しましては下記プレスリリースをご覧ください。 corp.zozo.com 今回はFAANSで採用しているワークフローエンジン「 Argo Workflows 」について、その採用理由や構成例をご紹介します。ワークフローエンジンの採用を検討している方、Kubernetesネイティブなワークフローエンジンに興味をお持ちの方の参考になれば幸いです。 なお本稿は単体でもお読みいただけますが、過去の記事をご覧いただくとFAANSの技術変遷をよりご理解いただけます。 techblog.zozo.com techblog.zozo.com 目次 はじめに 目次 ワークフローエンジン選定の過程 ワークフローエンジン導入の背景 代表的なワークフローエンジンの紹介 Kubernetesネイティブなワークフローエンジン Kubernetesネイティブとは ワークフロー定義をマニフェストで管理できる ワークフローエンジンの移行性が高い リソース利用効率が高い Argo Workflowsの選定理由 Argo Workflowsの構成 Argo Workflowsのコアコンセプト 全体構成 ワークフロー実行の流れ マニフェストの分散管理 Workflowの構成 Webコンソールの紹介 ログイン画面 Workflow一覧 Workflow詳細 WorkflowTemplate一覧 WorkflowTemplate詳細 CronWorkflow一覧 CronWorkflow詳細 まとめ おわりに ワークフローエンジン選定の過程 本章では、Kubernetesネイティブなワークフローエンジンに対する考察を交えながら、Argo Workflowsの導入を決めた過程をご説明します。 ワークフローエンジン導入の背景 FAANSが提供する機能の1つに「成果確認」があります。これはショップスタッフ様が投稿したコーディネートに対する閲覧数や経由売上といった指標を成果として可視化するものです。この機能の裏側ではそれらのデータを集計するワークフローが必要となりますが、実装当時のFAANSにはワークフローを実行する基盤はまだありませんでした。 どのツールでワークフローを実行するか検討するために、まずFAANSに求められる最低限の要件を書き出しました。それが下記になります。 スケジュール実行 リトライ機構 タスク間依存関係の表現 Webコンソールでの手動実行やログの確認 1 スケジュール実行やリトライ機構が備わっていることは大前提です。加えて、複雑なワークフローの構成にはタスク間の依存関係を表現できなければなりません。タスク間の依存関係とは、例えば「タスクAとBの完了を待ってからCを実行する」というものです。さらに開発者体験も重要です。ログの確認やワークフローの再実行はWebコンソールでできると便利です。 よって、これらの要件を満たすワークフローエンジンを選定する必要がありました。 代表的なワークフローエンジンの紹介 まずは、フラットな視点で代表的なワークフローエンジンを調査しました。スター数が1kを超えるワークフローエンジンをいくつか抜粋すると以下のようなものがあります 2 。()内は2022/10時点のスター数になります。 Luigi (16k) Apache Airflow (27.7k) Tekton Pipelines (7.4k) Argo Workflows (11.8k) Digdag (1.2k) それぞれの特徴を簡単に見ていきます。 LuigiとApache AirflowはPythonでワークフローを記述します。これらは機械学習での利用が多いのですが、より一般的なユースケースにも利用可能です。Pythonによるツール独自の記法も含むため慣れていない場合は一定の学習が必要になります。 Tekton PipelinesとArgo WorkflowsはKubernetesネイティブなワークフローエンジンであり、Kubernetesマニフェストを使ってワークフローを記述します。Kubernetesの利用が前提となるため、運用基盤が整っていない場合は導入の障壁が高いです。 Digdagはdigファイルと呼ばれる独自ファイルにワークフローを定義します。記法はYAMLに近くシンプルな表現が可能です。ZOZOでは採用しているプロダクトが多いためノウハウは蓄積されています。 Kubernetesネイティブなワークフローエンジン FAANSでは「Kubernetesネイティブなワークフローエンジン」を採用する方針にしました。FAANSはKubernetesを採用しており、エコシステムの1つとして迎え入れる基盤が既にありました。そして、後述の「Kubernetesネイティブであることの利点の大きさ」が決め手となりました。 Kubernetesネイティブとは ここでは「Kubernetesネイティブ」を「Kubernetesで動かすことを前提に設計された」と定義しています。つまりコンテナでのタスク実行を前提としたワークフローエンジンと言えます。では、Kubernetesネイティブであると何がよいのでしょうか。次のような利点があると考えます。 ワークフロー定義をマニフェストで管理できる ワークフローエンジンの移行性が高い リソース利用効率が高い それでは1点ずつ詳解していきます。 ワークフロー定義をマニフェストで管理できる ワークフロー定義をマニフェストで管理できるということは、DeploymentやIngressといった既存のKubernetesリソースと同じ方法でワークフロー定義を扱えるということです。ここでの「ワークフロー定義」は「どのようなタスクをどのような順番で実行するかの決まりごと」という抽象的な意味合いで捉えてください。 次の図はワークフローがKubernetes上で実行されるまでの流れを示しています。 【1】のステップではワークフロー定義をマニフェストに落とし込みます。【2】のステップではマニフェストをkube-apiserver経由でクラスタに登録します。そして【3】のステップで実際にPod上で処理が実行されます。実際にはカスタムリソースの作成もありますがここでは省略しています。 このようにワークフロー定義をマニフェストとして管理できることで次のメリットがあります。 GitOpsのプラクティスを適用 Control loopによる制御 データストアとしてetcdを使用 一点ずつ補足していきます。 GitOps とはGitを一元的なソースとしてKubernetesリソースを管理する手法です 3 。ワークフロー定義をマニフェストとして管理することでGitOpsのプラクティスを適用できます。これによりKubernetes上のワークフローが自動的にGitの状態と一致するようになります。 続いて、 Control loop についてです。Control loopはKubernetesにおける更新ロジックです。現実状態をマニフェストに記載された理想状態へ収束させることができます。これを実現するのがControllerです。ApplyされたワークフローはControllerによって監視されます。そして、ControllerがControl loopを回すことでワークフローが常に理想状態と一致するように更新されます。 最後にデータストアについてです。ワークフロー定義をマニフェストで管理できるということは、これらの保存のためにデータストアを別途用意する必要がありません。なぜならマニフェストは etcd に保存されるからです。etcdはKubernetesクラスタ内部のコンポーネントで、クラスタに登録される全ての情報が保存されます。別途データベースを構成するとEOLや脆弱性対応に運用負荷がかかるため、このメリットは大きいと感じます。ただし、ログの永続化にはロギングサービスやデータベースが必要となります。あくまでワークフロー定義を保存する上で別途データストアは不要ということですのでご注意ください。 以上から、ワークフロー定義をマニフェストで管理することにより、Kubernetesの既存の仕組みをパワフルに活用したワークフローの運用が可能だと分かります。 ワークフローエンジンの移行性が高い Kubernetesネイティブなワークフローエンジンではワークフロー内の各タスクはコンテナで実行されます。どのタスクをどのような順番で実行するかはマニフェストで記述されますが、タスク内でどのような処理を実行するのかという関心事はコンテナに寄せられます。これによりワークフローを利用するシーンにおいても、コンテナ一般のメリットである「可搬性」を享受できます。これには具体的に次のようなメリットがあります。 ローカルで簡単な動作検証ができる ワークフローエンジンの移行性が高い 2点目は特に強力です。タスクを独自仕様や特定言語で定義する場合と比較し、コンテナでパッケージングされたタスクは再利用可能です。つまり、コンテナをサポートする他ワークフローエンジンへの移行が比較的容易です。加えて、規模縮小によりワークフローエンジンを廃止したとしても Kubernetes Job / Kubernetes CronJob で動作させることが可能です。 リソース利用効率が高い Kubernetesネイティブなワークフローエンジンはリソース利用効率が高く、相性のよいマネージドサービスと組み合わせることでコストパフォーマンスを上げることができます。 ここでの「リソース利用効率」とは、アサインされたコンピューティングリソースに対して、実質的に利用されるリソースの割合を意味しています。ワークフローで必要となるリソースとアサインされるリソースに過不足がないほど、リソース利用効率の高い状態となります。ワークフローという処理形態では、必要なときに必要な分だけリソースを確保できればよいため、要求に応じてアサイン量を調整できることがより重要となります。 この点において、Kubernetesネイティブなワークフローエンジンは優秀です。なぜなら、タスクをPodとして切り出して実行できるからです。Kubernetesのマネージドサービスが提供する機能を利用すれば、Podの需要に合わせてNodeをスケールできます 4 。さらに、Pod単位でスケール可能なサービス 5 を利用すればさらに効率が高まります。このように、Kubernetesネイティブなワークフローエンジンをクラウド環境のマネージドサービスで動作させることにより、リソース利用効率ひいてはコストパフォーマンスを高めることができます。 Argo Workflowsの選定理由 以上から、選定候補として残ったのがKubernetesネイティブなワークフローエンジンであるTekton PipelinesとArgo Workflowsの2つでした。最終的にArgo Workflowsを選択しましたが、どちらもFAANSにおける最低限の要件は満たしていました。 その上でArgo Workflowsを選んだ理由は、既に Argo CD を運用しており親和性があったからです。具体的には Argo CD Dexと連携可能 であり認証のための設定を1から構築する手間が省けるという点です。 Argo Workflowsの構成 本章ではArgo Workflowsをどのような構成で利用しているかを解説していきます。Argo Workflowsの基本的な概念からFAANSでの構成例まで説明しています。 Argo Workflowsのコアコンセプト まずはじめにArgo Workflowsの コアコセプト をご紹介します。主なカスタムリソースとして Workflow 、 WorkflowTemplate 、 CronWorkflow があります。それぞれの概要は下表の通りです。 カスタムリソース 説明 Workflow 最も基本的なリソース。ワークフローの定義と実行ステータスを持つ。 WorkflowTemplate 頻繁に利用するワークフローをテンプレートとして定義するリソース。 CronWorkflow スケジュール実行したいワークフローを定義するリソース。 全体構成 全体観を掴んでいただくためにマニフェスト作成からワークフロー実行までの構成を下図に示します。なお、Kubernetes環境には GKE Autopilot を利用しています。 ワークフロー実行の流れ 基本的な流れは前述の通りです。ワークフロー定義を落とし込んだマニフェストをKubernetesクラスタに登録し、Podでタスクが実行されます。ワークフローのデプロイにはArgo CDを用いています。Argo CDはGitOpsを実現するCI/CDツールです。Argo Workflowsにおけるワークフロー定義はマニフェストとして作成されるため、GitOpsのプラクティスを適用できます。これによりKubernetesの実状態をGitHubのソースへ自動的に追従させることができます。 マニフェストの分散管理 アプリケーションリポジトリを監視する Argo CD Application の存在に違和感を感じた方もいらっしゃるかと思います。FAANSでは従来、Kubernetesのマニフェストは全てインフラリポジトリで管理していました。しかし、開発チームが運用するワークフローに限り、それらのマニフェストをアプリケーションリポジトリに切り出して管理しています。これには開発サイドの裁量でワークフローを管理できるため開発効率がよいという利点があります。 Twelve-Factor App にある コードベース の思想から逸脱しているようにも見えますが、現時点で大きな課題は生じておらずメリットの方が大きいと感じています。 Workflowの構成 FAANSでのWorkflowの構成例をご紹介します。主に利用するカスタムリソースは WorkflowTemplate と CronWorkflow の2つです。 WorkflowTemplate に再利用可能なワークフローをまとめておき、それを CronWorkflow から呼び出すという構成を基本としています。これによりマニフェストの責任範囲が明確になり運用管理がしやすくなります。 文章だけでは分かりにくいのでサンプルマニフェストを基に説明していきます。 下記の WorkflowTemplate は 公式サンプル をFAANSでの利用形態に近づけたものです。 templates フィールドは大きく2つのテンプレート種別に分かれています。便宜上、本稿では部品テンプレートと製品テンプレートと呼称します。この例では、 whalesay-template が部品テンプレート、 main と main-on-monday が製品テンプレートになります。製品テンプレートは部品テンプレートを1以上利用して目的の処理を実現します。このような構成にすることでテンプレートをDRYに保ちます。 apiVersion : argoproj.io/v1alpha1 kind : WorkflowTemplate metadata : name : workflow-template spec : templates : - name : whalesay-template # 部品テンプレート inputs : parameters : - name : message container : image : docker/whalesay command : [ cowsay ] args : [ "{{inputs.parameters.message}}" ] - name : main # 製品テンプレート dag : tasks : - name : inner-A template : whalesay-template # 部品テンプレートを使用 arguments : parameters : - name : message value : hello world # パラメータ注入 - name : inner-B dependencies : [ inner-A ] template : whalesay-template arguments : parameters : - name : message value : hello again - name : main-on-monday # 製品テンプレート dag : tasks : - name : inner-A template : whalesay-template arguments : parameters : - name : message value : hello monday 続いて、 CronWorkflow のサンプルを見ていきます。こちらは上の WorkflowTemplate の製品テンプレートである main-on-monday を呼び出し、月曜日のみ実行されるスケジュールを組んでいます。 CronWorkflow には、どの製品テンプレートをいつ呼び出すのか、履歴をどのくらい保持するのかといった関心だけを詰め込むようにしています。 apiVersion : argoproj.io/v1alpha1 kind : CronWorkflow metadata : name : cron-workflow-on-monday spec : schedule : "0 6 * * 1" # 毎週月曜午前6時に実行 timezone : "Asia/Tokyo" startingDeadlineSeconds : 0 concurrencyPolicy : "Allow" successfulJobsHistoryLimit : 4 failedJobsHistoryLimit : 4 suspend : false workflowSpec : entrypoint : main templates : - name : main steps : - - name : run templateRef : name : workflow-template template : main-on-monday # 製品テンプレートを呼び出し 以上のような構成にすることで、 WorkflowTemplate と CronWorkflow の責務の違いが明確になり、開発や運用がしやすくなります。なお、ワークフローの内容や数量によって適切な構成は異なりますのでご留意ください。 Webコンソールの紹介 最後にArgo WorkflowsのWebコンソールをご紹介します。 ログイン画面 Webコンソールにアクセスするとログイン画面が表示され、一番左のログインボタンを押下するとSSOでログインできます。SSOを実現するために Argo CD Dexとの連携 および Azure ADでのSAML認証 を利用しています。また独自ドメインによるHTTPSでのアクセスを GoogleマネージドSSL証明書 で可能にしています。 Workflow一覧 実行された Workflow の一覧を確認できます。表示する件数 6 や保持期間 7 を、成功・失敗の場合に分けて柔軟に設定できます。 Workflow詳細 一覧画面から項目を押下すると実行結果の詳細を確認できます。タスク間の依存関係と成否がグラフィカルに表示されます。 失敗すると次のような表示になります。タスクを押下すると詳細が表示され、実行後のPodを保持していれば MAIN LOGS よりログを確認できます。なお最後尾の成功しているタスクはSlack通知です。 WorkflowTemplate一覧 クラスタに登録された WorkflowTemplate の一覧を確認できます。 WorkflowTemplate詳細 一覧画面から任意の項目を押下すると詳細を確認できます。 SUBMIT ボタンを押下することでWebコンソールからもWorkflowの実行が可能です。マニフェストの編集も可能であるため検証に役立ちます。 CronWorkflow一覧 クラスタに登録された CronWorkflow の一覧を確認できます。 WorkflowTemplate と異なり SCHEDULE や NEXT RUN が表示されています。 CronWorkflow詳細 一覧画面から任意の項目を押下すると詳細を確認できます。 WorkflowTemplate 同様に手動による実行や、マニフェストの編集が可能です。 まとめ Kubernetesネイティブなワークフローエンジンの特徴とFAANSにおけるArgo Workflowsの構成をご紹介しました。ワークフロー定義をマニフェストで管理できる点は非常に強力ですし、移行性やリソース利用効率の高さも魅力的です。既にクラウド環境でKubernetesを運用しており、かつ、ワークフローエンジンの導入を検討されている方にとっては有力な候補となります。本稿が検討の一助となれば幸いです。 おわりに 最後に、ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com ここでの「ログの確認」とはPod内の永続化されていないログをWebコンソールから参照することを指します。 ↩ 他にも Kubeflow Pipelines や Prefect などあります。 ↩ GitOpsの詳細は CircleCI社のブログ が参考になりますのでご参照ください。 ↩ 例えばGoogle Cloud Platformであれば cluster autoscaler や node auto-provisioning が該当します。 ↩ Pod単位でスケール可能なサービスには Amazon EKS on AWS Fargate や GKE Autopilot があります。 ↩ CronWorkflowでは オプション を使ってWorkflowの保持件数を設定できます。 ↩ ttlStrategy を利用してWorkflow毎に保持期間を設定できます。 ↩
アバター
はじめに こんにちは。SRE部ECプラットフォーム基盤SREブロックの大澤と立花です。 本記事ではマイクロサービスのカナリアリリースに関して私達が抱えていた課題と、それをFlaggerによるプログレッシブデリバリー導入でどのように改善したのかを紹介します。 ZOZOTOWNのマイクロサービス基盤におけるカナリアリリース手段の変遷については以下のテックブログで紹介しておりますので気になった方はご参照ください。現在はIstio VirtualServiceの加重ルーティングを用いたカナリアリリースに一本化しております。 techblog.zozo.com techblog.zozo.com 目次 はじめに 目次 カナリアリリースの運用課題 解決手段としてのプログレッシブデリバリー Flaggerとは? Flaggerによるプログレッシブデリバリーの進み方 Flagger導入時の検討ポイント (1) サービスを瞬断させることなくFlaggerを導入する (2) Datadogのメトリクスを利用してカナリア分析する (3) リリース開始・完了をSlackに通知する プログレッシブデリバリー導入の効果 (1) リリース対応稼働の削減 (2) リリースミスによる障害範囲の最小化 (3) カナリア用リソースのコスト削減 最後に カナリアリリースの運用課題 カナリアリリースすることで、新しいアプリケーションにバグがあった場合でもユーザへの影響を最小限に抑えられます。一方で、通常のリリースと比較して運用負荷が大きくなるデメリットも存在します。 こちらの図はカナリアリリースの進行を示したものです。 以下を繰り返すことでカナリア用Pod(以下、Canary Podとする)への加重を増やしていきます。 n%デプロイ Canary Podのreplicas拡張・加重増加の設定変更 分析 Canary Podに流れたリクエストが問題なく処理されているか、レイテンシが悪化していないか判断 問題がない場合、n%デプロイを進行する 問題があった場合、旧バージョンにロールバックする 従来のカナリアリリースでは、これらのサイクルを人が担うことになります。Canary Podへの加重変更プルリクエスト作成、レビュー、CI/CDの待ち時間、エラー発生やレイテンシ悪化の監視といったことが加重変更の都度発生します。このため、カナリアリリースだけでリリース担当者の稼働を半日費やしてしまうことも珍しくなく負担が非常に大きい状態でした。 また、私達のチームではメインのリソースとカナリアのリソースでKubernetesのDeploymentのマニフェストを分けて管理していました。これら2つのマニフェストを日頃からメンテナンスすることになりますので、変更やレビュー対象の増加に繋がり、設定漏れといった人的ミスのリスク要因になりがちです。 解決手段としてのプログレッシブデリバリー 私達のチームが管理する検索APIはリリース頻度が比較的高く、カナリアリリースによる時間的拘束が顕著でしたので改善を目指すことにしました。 そこで挙がったのがプログレッシブデリバリーです。 プログレッシブデリバリーはリリースプロセスを自動化するために、メトリクスの取得・分析を行い、問題がなければデプロイの進行、基準を満たさなければロールバック、といった判断と実行を備えた仕組みです。 このプログレッシブデリバリー用ツールには、Argo Rollouts、Flagger、Spinnakerなどがありますが、私達のチームではFlaggerを採用することにしました。 Flaggerとは? Flagger とはプログレッシブデリバリーを実現するKubernetes Operatorです。 私達のマイクロサービス基盤で採用しているツール(Istio, Datadog, Slack)の場合ですと、Flaggerはカナリアリリースを自動で進めるために次のように連携します。 加重変更 : Istio VirtualServiceの設定変更 スケールアウト/イン : DeploymentをオートスケールするためのHorizontalPodAutoscalerの設定変更 メトリクス取得 : Datadogにクエリ発行 通知・アラート : Slackに通知 連携可能なツールについては 公式ドキュメントのIntroduction で紹介されております。 用途 連携可能なツール トラフィックルーティング Istio, App Mesh, Linkerd, Kuma, Open Service Mesh, Contour, Gloo, nginx, Skipper, Traefik リリース分析のためのメトリクス取得 Datadog, Prometheus, InfluxDB, New Relic, CloudWatch, Stackdriver, Graphite 通知、アラート Slack, MS Teams, Discord, Rocket Flaggerを採用した理由については以下の通りです。 Istioとの連携がサポートされていること FlaggerはIstioを活用したプログレッシブデリバリーをサポートしており、自動でVirtualServiceの加重を変更できます。マイクロサービス基盤はIstio導入済みで、カナリアリリースの際には元々手動でVirtualServiceを変更していました。加重変更の手段は今までと変わらないため、導入イメージが湧きやすいことは重要なポイントでした。 Datadog、Slackとの連携がサポートされていること FlaggerはDatadogのメトリクス取得をサポートしています。私達のチームでは監視は主にDatadogを利用しており、カナリアリリースの進行・ロールバック判断もDatadogを活用していました。人が目視でチェックしていたグラフのメトリクスをFlaggerからも同じように利用できることは重要なポイントでした。 モダンなツールであれば通知手段としてSlackが提供されていることは一般的かもしれませんが、リリースの成否通知をSlack連携できることは必須要件でした。 Flaggerによるプログレッシブデリバリーの進み方 Flaggerがどのようにプログレッシブデリバリーを進めていくかをご説明します。便宜上、旧バージョンをv1、新バージョンをv2として記載します。またわかりやすくするために加重変更は100%まで行う設定とし、Serviceなどのリソースは記載を省略しています。 まずリリース前の状態です。v1のPrimary Podのみが存在し、勿論加重はPrimary Podに100%となっております。 対象マイクロサービスのDeploymentが更新されてrevisionが上がったことをFlaggerが検知するとプログレッシブデリバリーが開始され、 Progressingフェーズ に入ります。このフェーズではまずCanary Podがv2としてPrimary Podと同じスケールで生成されます。v2のCanary Podが生成された後、メトリクスを取得し分析しながらCanary Podへの加重を徐々に増やしていきます。 加重切り替えが順調に進みCanary Podへの加重が100%になると、 Promotingフェーズ に入ります。このフェーズではPrimary Podがv2へアップデートされます。アップデート完了後、加重をCanary PodからPrimary Podに徐々に戻していきます。 加重が完全にPrimary Podに戻ると、 Finalisingフェーズ に入ります。このフェーズでは不要になったCanary Podを停止するためにスケールが0に変更されます。 Canary Podのスケールが0になると、 Succeededフェーズ になりプログレッシブデリバリーが完了となります。 Flagger導入時の検討ポイント Flaggerを検索APIに導入するにあたり、基本機能の検証や日々のリリース業務に落とし込めるか検討しました。その中でも円滑な導入・運用のためにSREチームとして特に注力した3点について紹介します。 (1) サービスを瞬断させることなくFlaggerを導入する 最初にIstioサービスメッシュ内でのFlaggerの挙動を説明します。sample-appというDeploymentが存在する状態でカスタムリソースであるCanaryを適用すると、Flaggerは次の3つの動きをします。 Flaggerは app ラベルが sample-app と一致するDeployment(またはDaemonSet)をCanary適用のターゲットにする。ターゲットになった既存Deploymentはreplicasが0へ更新される。 ターゲットの既存Deploymentを元に新しいDeploymentが作成される。このDeploymentは name と app ラベルが sample-app-primary になる。 Flaggerによるプログレッシブデリバリーの進み方 で説明したPrimary Podはここで作成されたDeploymentが対応している。 新しいDeployment用のService、VirtualService、DestinationRule、HorizontalPodAutoscalerがCanary定義に従い新規に作成される。 詳細な挙動については 公式ドキュメントのHow it works をご覧ください。 ここでCanaryを稼働中の検索APIへ適用する障害となったのが、Flaggerがターケットとするラベル名でした。Flaggerはデフォルトでは app ラベルを使用します。そして、Flagger導入前の検索APIは以下のような通信経路を構築していました。 ここで重要なのは、ServiceとDeploymentの紐付けに app ラベルを使用していた点です。この状態の検索APIへCanaryを適用すると次の問題が発生します。 既存Deploymentはreplicasが0へ更新されているため通信できない。 新たに作成されたDeploymentは app ラベルが zozo-search-api-primary へ更新されているため、既存Serviceの app ラベルとの不一致で通信できない。 このようなCanary適用後の振る舞いにより、デフォルト設定のままFlaggerを導入すると検索APIと通信できなくなる事象が発覚しました。 通信経路を確保しつつFlaggerを導入するためには、新たに作成されるDeploymentの app ラベルは更新を避ける必要があります。幸いなことにFlaggerはCanary適用のターゲットラベルを変更するオプションを提供しています。下記は検索APIにおいて自動的にラベルの書き換えが行われても影響を受けない run へ変更した時のコード事例です。 selector-labels を設定することでターゲットになるラベルを変更できます。 - name: flagger image: ghcr.io/fluxcd/flagger:1.11.0 imagePullPolicy: IfNotPresent args: - -log-level=info - -include-label-prefix=app.kubernetes.io - -mesh-provider=istio - -selector-labels=run 既存Serivceは app ラベルを元に通信する。 新たに作成されたServiceは run ラベルを元に通信する。 このように既存の通信経路を確保する方法でFlagger導入を進めました。安定稼働していることを確認した後、検索APIを呼び出す経路を新たな通信経路へと切り替えFlagger導入は完了となりました。 (2) Datadogのメトリクスを利用してカナリア分析する FlaggerはカスタムリソースMetricTemplateを使用することで、Datadogなど様々な外部サービスをカナリア分析に利用できます。加えてMetricTemplateは、Datadogのメトリクスを作成するクエリをそのまま利用できます。 以下は99パーセンタイルのレイテンシが1sを超えたことを閾値とする場合の設定例です。 apiVersion: flagger.app/v1beta1 kind: MetricTemplate metadata: name: zozo-search-api-99tile-latency spec: provider: type: datadog address: https://api.datadoghq.com secretRef: name: zozo-search-api-datadog-secrets-20211228101017 query: | p99:trace.servlet.request{env:prd,service:zozo-search-api} このテンプレートをCanaryのanalysisから参照します。 analysis: metrics: - name: "99tile-latency" templateRef: name: zozo-search-api-99tile-latency thresholdRange: max: 1 このように、クエリを自由に記述できるため様々な指標をカナリア分析に利用できます。 (3) リリース開始・完了をSlackに通知する プログレッシブデリバリーにより自動でカナリア分析可能となったら、人間がカナリアリリース全体の状況を注視してる時間も可能な限り削減したいと考えます。そこでSREチームではFlaggerのイベントをSlackに通知し、カナリアリリースの開始・終了を検知しています。 プログレッシブデリバリー導入の効果 プログレッシブデリバリー導入により得られた効果を3点紹介します。 (1) リリース対応稼働の削減 冒頭でカナリアリリースの辛みとして記載した以下の対応を、プルリクエスト1つでFlaggerが実行するようになりました。 n%デプロイ Canary Podのreplicas拡張・加重増加の設定変更 分析 Canary Podに流れたリクエストが問題なく処理されているか、レイテンシが悪化していないか判断 問題がない場合、n%デプロイを進行する 問題があった場合、旧バージョンにロールバックする Flaggerはn%デプロイ進行と同時に分析も実施し、エラー増加やレイテンシ悪化が発生していれば自動でロールバックを行ってくれます。 分析時のメトリクス取得は、これまで監視に利用していたDatadogダッシュボードのクエリを移植しております。これにより元々人間が判断していたのと同じロジックでFlaggerが判断できますので、リリース中の監視もFlaggerに任せています。別作業しながらSlackの成否通知を待っていれば良いため作業時間の捻出にも寄与できております。 また、2022年4月に実施したElasticsearchのアップグレードに伴うクラスタ切り替え時には、Elasticsearchの負荷を監視しながら加重を少しづつ上げたいニーズがありました。段階的な加重増加をFlaggerに任せられたおかげで、私達は負荷の監視に注力できました。 (2) リリースミスによる障害範囲の最小化 検索APIリリース時に、とある理由で新アプリケーションにエラーが発生してしまったことがありました。これは当時の検索APIのリクエスト・エラー数のグラフとSlackの様子になります。 カナリアリリース開始後10%リリースの時点で、Flaggerがメトリクス分析にてエラーを検知し自動でロールバックしてくれたため障害影響を最小限に抑えられました。 (3) カナリア用リソースのコスト削減 Flagger導入前は、カナリアリリースのためにリリース時以外でもCanary Podを最低1つは起動しておく必要がありました。オートスケーリングに利用しているHorizontalPodAutoscalerの仕様上、minReplicasを0にできないためです。 Flagger導入後は、Canary Podが Progressingフェーズ から Finalisingフェーズ の間しか起動しないため、コスト削減にも寄与できております。 また、カナリア用のマニフェストファイルも不要になりましたので、構成管理としてもDRYな状態を保てるようになりました。 最後に Flaggerによるプログレッシブデリバリーを導入しカナリアリリースを自動化したことで、リリース作業の削減・障害範囲の最小化・コスト削減といった多くのメリットを享受できました。 ただし、これまでカナリアリリース不要と判断していた軽微な修正でも必ずカナリアリリースになるため、リリース時間が延びてしまうケースもありました。 1 しかし、万が一の障害発生時に障害範囲を最小限にし自動ロールバックも備えた仕組みがあることは、リリースに対する心理的なハードルを下げ開発サイクルを高速化することに繋がっていると感じております。 引き続き、快適な開発・運用を実現すべく改善していきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co skipAnalysis をtrueにすることで通常のリリースに変更することも可能ですが、開発環境以外では変更しないようにしております。 ↩
アバター
はじめに こんにちは。ECプラットフォーム部の北原です。普段はZOZOTOWNのバックエンドの開発、運用に携わっており、現在は会員機能を司るマイクロサービスの開発を進めています。 今回はZOZOTOWNのGo言語におけるマイクロサービス開発の共通規約を守るための取り組みを紹介します。 マイクロサービス開発の課題 ZOZOTOWNでは複数のマイクロサービスでGo言語を使っています。マイクロサービスではトレース、ヘッダー処理、認証関連などの機能をサービス毎に持つことはよくあると思います。一方で、マイクロサービス開発ではサービス毎に別のチームが開発することもよくあるため、実装者による認識の齟齬、漏れなどで同一機能の実装に差異が生じてしまうかもしれないという課題があります。 開発の当初から共通機能の管理への課題感はあり、ZOZOTOWNのGo言語におけるマイクロサービス開発の共通規約を守るため、標準的な機能を盛り込んだ開発テンプレートを作り課題に取り組んでいます。 開発テンプレート 開発テンプレートの共通規約の実装、共通機能の例、構成を紹介します。 共通規約の実装例 開発テンプレートはバックエンドの共通規約の実装、共通ライブラリ、ドメインロジックのサンプルコードを開発プロジェクトとして展開できるようにしたものとなります。 バックエンドの共通規約の実装例として次のようなものがあります。 トレース ヘッダー処理 認証 それぞれ紹介していきます。 トレース マイクロサービスのログやトレースではいくつかのサービスを使っており次のような住み分けをしています。 機能・サービス 用途 アクセスログ 障害調査やユーザからの問い合わせ対応 Datadog トレース分析、アラート検知 Sentry 未知のエラー検知、エラーの管理 例として、アクセスログの実装を抜粋したものとなります。まず、アクセス時にロガーを呼び出すミドルウェア(HTTPハンドラにおけるミドルウェアパターン)を、次にロガーの実装を示します。共通の項目を定義しサービス毎にズレがないよう共通としています。 TraceID を持っていますが、Datadogでは全てのトレースを保持できないため、Datadogで破棄されたトレースの調査用に保持しています。 アクセスログコード抜粋 func (mw loggingMiddlewareImpl) LoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc( func (w http.ResponseWriter, r *http.Request) { accessLog := &httpLogger.AccessLog{ Method: r.Method, Host: r.Host, Path: r.URL.Path, Query: r.URL.RawQuery, RequestSize: r.ContentLength, UserAgent: header.GetUserAgent(r), ... TraceID: r.Header.Get(constant.HeaderKeyZozoTraceID), UID: r.Header.Get(constant.HeaderKeyZozoUID), } ctx := setAccessLog(r.Context(), accessLog) sw := &StatusResponseWriter{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(sw, r.WithContext(ctx)) accessLog.Status = sw.status requestedAt, err := GetRequestedAt(ctx) if err != nil { lib.LogError(ctx, err) } else { accessLog.Latency = time.Since(requestedAt).Seconds() } mw.accessLogger.Log(accessLog) }) } type loggingMiddlewareImpl struct { accessLogger httpLogger.AccessLogger } httpLogger.AccessLogger では現在は zap.Logger を利用しています。ユーザからの問い合わせ調査など、サービスを横断した調査を円滑にするためサービス間で出力される情報が揃っていることが求められます。フォーマットや日時の精度など、機能が提供されることでサービス横断的な品質担保が容易になります。 ロガーコード抜粋 type AccessLogger interface { Log(accessLog *AccessLog) } type accessLoggerImpl struct { *zap.Logger } func (l accessLoggerImpl) Log(a *AccessLog) { l.Info( zap.Int( "status" , a.Status), zap.String( "method" , a.Method), zap.String( "host" , a.Host), zap.String( "path" , a.Path), ... ) } ヘッダー処理 APIではエンドポイントに送る値としてユーザの入力パラメータでない値はヘッダーでやりとりすることが多いと思われます。User Agentなどのユーザ情報や、認証情報、APIの呼び出し元の情報など必要なヘッダー情報はマイクロサービス間でも持ち回る共通規約となっています。 APIの処理中で別のマイクロサービスのAPIを呼び出す際も同等のヘッダーを付加する必要があるためContextで持ち回っています。これもマイクロサービスとして共通の実装にすることで抜け漏れのない機能として提供できます。 ヘッダー処理のミドルウェアコード抜粋 func RequestMiddleware(next http.Handler) http.Handler { return http.HandlerFunc( func (w http.ResponseWriter, r *http.Request) { ctx := r.Context() if userAgent := r.Header.Get(constant.HeaderKeyForwardedUserAgent); userAgent != "" { ctx = setForwardedUserAgent(ctx, userAgent) } if userIP := r.Header.Get(constant.HeaderKeyUserIP); userIP != "" { ctx = setUserIPAddress(ctx, userIP) } if traceID := r.Header.Get(constant.HeaderKeyZozoTraceID); traceID != "" { ctx = SetTraceID(ctx, traceID) } if uid := r.Header.Get(constant.HeaderKeyZozoUID); uid != "" { ctx = setUID(ctx, uid) } if xForwardedFor := r.Header.Get(constant.HeaderKeyXForwardedFor); xForwardedFor != "" { ctx = setXForwardedFor(ctx, xForwardedFor) } if r.RemoteAddr != "" { ctx = setRemoteAddress(ctx, r.RemoteAddr) } next.ServeHTTP(w, r.WithContext(ctx)) }) } 別のマイクロサービスのAPI呼び出しHTTPクライアントのヘッダー追加コード抜粋 func setHeaders(ctx context.Context, request *http.Request) error { if encodedInternalIDToken, err := middleware.GetEncodedInternalIDToken(ctx); err == nil { request.Header.Set(constant.HeaderKeyZozoInternalIDToken, encodedInternalIDToken) } if ipAddress, err := middleware.GetUserIPAddress(ctx); err == nil { request.Header.Set(constant.HeaderKeyUserIP, ipAddress) } if userAgent, err := middleware.GetForwardedUserAgent(ctx); err == nil { request.Header.Set(constant.HeaderKeyForwardedUserAgent, userAgent) } if traceID, err := middleware.GetTraceID(ctx); err == nil { request.Header.Set(constant.HeaderKeyZozoTraceID, traceID) } if uid, err := middleware.GetUID(ctx); err == nil { request.Header.Set(constant.HeaderKeyZozoUID, uid) } if apiClient, err := middleware.GetAPIClient(ctx); err == nil { request.Header.Set(constant.HeaderKeyAPIClient, apiClient) } if xForwardedFor, err := middleware.GetXForwardedFor(ctx); err == nil { if remoteAddr, err := middleware.GetRemoteAddress(ctx); err == nil { host, _, e := net.SplitHostPort(remoteAddr) if e != nil { return xerrors.Errorf( "split remote address: %v" , e) } request.Header.Set(constant.HeaderKeyXForwardedFor, xForwardedFor+ ", " +host) } } return nil } 実装自体は非常にシンプルなものですが、シンプルであっても個別に実装せずヘッダーを伝播させる規約を守るためコピー用のコードを用意しています。 認証 認証はバックエンドの前段にあるAPI Gatewayで行われており、認証が成功した場合にはバックエンドへの通信のヘッダーに認証されたことを表すトークンが付与されます。 ヘッダーで渡されるトークンからユーザの情報を取得するためデコード処理を行っていますが、デコードの仕様や取得した値のバリデーションなど実装の差異がないよう共通処理としています。 認証ユーザ情報取得コード抜粋 func InternalIDTokenMiddleware(next http.Handler) http.Handler { return http.HandlerFunc( func (w http.ResponseWriter, r *http.Request) { token := r.Header.Get(constant.HeaderKeyZozoInternalIDToken) internalIDToken, err := model.DecodeInternalIDToken(token) if err != nil { writeRespInvalidInternalIDToken(w) return } ctx := setInternalIDToken(r.Context(), internalIDToken) ... next.ServeHTTP(w, r.WithContext(ctx)) }) } API Gatewayで認証されたユーザ情報をDecodeInternalIDTokenで復元しContextにセットします。ミドルウェアのパッケージ外で値の変更ができないようレイヤーに閉じた実装として提供しています。 共通機能の例 バックエンドやAPIとしてよく利用され共通化できる機能もテンプレートとして提供しています。 ヘルスチェックのように各サービスで実装する機能は重複実装しないようテンプレートに含めています。 ヘルスチェックコード抜粋 type clientOptions struct { healthCheckReadiness func () error } func NewHealthCheckControllerWithReadiness(readiness func () error ) func (*http.Request, RequestParameters) ([] byte , error ) { options.healthCheckReadiness = readiness return healthCheckController } func healthCheckController(r *http.Request, _ RequestParameters) ([] byte , error ) { switch r.Method { case http.MethodGet: switch r.URL.String() { case "/health/liveness" : return healthCheckLiveness() case "/health/readiness" : return healthCheckReadiness() default : return nil , view.ErrNotFound } default : return nil , view.ErrNotFound } } func healthCheckLiveness() ([] byte , error ) { return [] byte ( "{}" ), nil } func healthCheckReadiness() ([] byte , error ) { if err := options.healthCheckReadiness(); err != nil { return nil , view.ErrServiceUnavailable } return [] byte ( "{}" ), nil } HTTPサーバーのrouter設定コード抜粋 func init{ ... opt := controller.ClientOptions{HealthCheckReadiness: mysql.Readiness} healthCheckController := controller.NewHealthCheckControllerWithReadiness(opt) routes := map [ string ] func (*http.Request, controller.RequestParameters) ([] byte , error ){ "/health/liveness" : healthCheckController, "/health/readiness" : healthCheckController, } for route, controller := range routes { Router.Handle(route, buildHandler(controller)) } ... } MySQLヘルスチェックコード抜粋 func Readiness() error { db, err := mysql.NewDB() if err != nil { return err } defer db.Close() err = db.Ping() if err != nil { return err } return nil } マイクロサービス作成の度に再実装するコストをかけないようにバックエンドの共通規約を守る機能やプロジェクトでよく利用される機能群をテンプレートにまとめています。 開発テンプレートの構成 テンプレートの責務はバックエンドの共通規約を守る、再利用性の向上、開発の立ち上げスピード向上にあると考えています。 要素としては、業務共通的なミドルウェア、共通ライブラリ、ドメインロジックのサンプルコードとなります。 ヘキサゴナルアーキテクチャをベースに、アダプター・アプリケーション・ドメインのレイヤーで責務を分離するようなアーキテクチャを設計しました。 アダプターレイヤーで外部依存の機能を実装し、ドメインレイヤーに業務のルールを整理、アプリケーションレイヤーでドメインを利用したロジックを実装するようなシンプルな切り分けになっています。 社内のGo言語のプロジェクトは何かしらのレイヤードアーキテクチャを採用していることが多いため、ヘキサゴナルアーキテクチャであればギャップが少なく導入しやすいと考えています。またアプリケーションレイヤーにある程度の選択肢を持たせるなどテンプレートの設計としてはマッチしていると考え選定しています。 起動するとデフォルトでHTTPサーバーが立ち上がり、ある程度必要なものが用意されているため、各ドメインロジックの実装に注力できます。 その他の技術要素 他にはlinterやテストのヘルパー類、Docker、DatadogとSentryなどテンプレートを利用することでバックエンドとしての標準機能や品質のベースが整うような構成となっています。 DockerやDatadog、SentryなどSREチームによって標準化されているものは、テンプレートとして利用しやすいようヘルパーなどを用意する形になっています。 ライブラリやディレクトリ構成を合わせることで、別のプロジェクトを参照する際の認知負荷を下げられることを期待しています。 開発テンプレートの課題とSDK化 テンプレートでの開発を進める中で運用課題となりそうなポイントが出てきました。 当初はサービス毎にテンプレートのリポジトリをコピーする方式でしたが、プロジェクト開始時のバージョンのコードがコピーされ、テンプレートの変更をサービス側で取り込む運用を想定していました。 しかし利用プロジェクトが増えるとそれぞれに反映してもらう手間、実装タイミング、プロジェクト毎に反映の有無が別れるなど運用の手間(負荷)が懸念されました。 そこでライブラリとして必要なパーツを利用できるようSDK化を進めることにしました。 SDK (Software Development Kit) として、テンプレートの機能をライブラリとして切り出しリポジトリをインポートして利用できる形にしました。 変更がインポートできるようになることで、テンプレートで懸念されたアップデートの運用の手間や取り込みのタイミングなどある程度払拭できるようになると考えました。 SDK化することで機能のつながりがわかりづらくなったり、インポートの手間が増えたりと障壁がゼロなわけではありません。 そこで、テンプレートの位置付けをSDKの利用方法を示したサンプルアプリケーションと改めることでSDKの理解が進むようにし、各サービスに展開しやすくしています。 プライベートリポジトリの利用 SDKとして切り出されたライブラリはGitHubのプライベートリポジトリで管理されています。プライベートリポジトリのアクセスはいくつか方法がありますが、インポートする際の設定で検討した内容となります。 Go言語でのプライベートリポジトリの取得に関してはこちらを参照してください。 https://go.dev/ref/mod#private-modules プライベートリポジトリのモジュールを go.mod に追加する場合は GOPRIVATE にリポジトリを指定し go get します。 GOPRIVATE を設定することで、 GONOPROXY , GONOSUMDB の対象となります。 Direct access to private modulesより引用 The GOPROXY variable does not need to be changed in this situation. It defaults to https://proxy.golang.org,direct, which instructs the go command to attempt to download modules from https://proxy.golang.org first, then fall back to a direct connection if that proxy responds with 404 (Not Found) or 410 (Gone). The GOPRIVATE setting instructs the go command not to connect to a proxy or to the checksum database for modules starting with corp.example.com. ローカルの開発ではSSHの鍵認証を使ってGitHubのプライベートリポジトリにアクセスできる環境が整っている場合が多いと思いますが、その場合SSH経由で接続できます。 Macでの例となりますが<アカウント>、<リポジトリ>はそれぞれの環境の値が入ります。 git config --global url."ssh://git@github.com/<アカウント>".insteadOf "https://github.com/<アカウント>" go env -w GOPRIVATE=github.com/<アカウント>/<リポジトリ> go get -u github.com/<アカウント>/<リポジトリ> .gitconfig [url "ssh://git@github.com/"] insteadOf = https://github.com/ GOPRIVATE はGo言語の path.Match で一致する形式で記述できるため次のような設定もできます。 go env -w GOPRIVATE=github.com/<アカウント>/* 開発での自動テストやlintを実施するため、ローカルでDocker Composeを利用して、コンテナを起動する手順があり次のような設定ファイルを追加しています。 Dockerfile でプライベートリポジトリにアクセスする際はGitHubのpersonal access token (PAT) を利用しています。 PATを docker-compose.yml の secrets に設定し --mount=type=secret して取得しています。 Dockerfile FROM golang:1.18.0-bullseye as debugger ... RUN --mount=type=secret,id=personal_access_token git config --global url."https://$(cat /run/secrets/personal_access_token):x-oauth-basic@github.com/".insteadOf "https://github.com/" ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOPRIVATE=github.com/<アカウント>/* COPY go.mod go.sum ./ RUN go mod download ... docker-compose.yml services: app: build: context: . target: debugger secrets: - personal_access_token environment: ... secrets: personal_access_token: file: personal_access_token.txt personal_access_token.txt ghp_zzz.. <personal_access_token> PATの使用は極力減らしたいと考えており課題感は残るものの、このようにプライベートリポジトリを利用できるよう設定しております。 コードの管理としてはバージョン管理もポイントとしてありますが、可能な限り後方互換性を持たせ、最新のバージョンを取得する方式を維持していきたいと考えています。 これから マイクロサービス化の開発はまだまだ残っているので、チームで課題を検討する際にSDKが1つの手段となるよう柔軟に対応していければと考えています。 またSDKを広げることで開発チームのDRYから組織的なDRYにつなげ、より価値のある課題に取り組めればと考えています。 最後に ZOZOTOWNのマイクロサービス化はまだ始まったばかりです。新たなマイクロサービスの開発も目白押しです。ご興味のある方は、以下のリンクからぜひご応募ください。お待ちしています。 corp.zozo.com hrmos.co
アバター
こんにちは。ブランドソリューション開発本部フロントエンド部の御立田です。フロントエンド部の部長とWEAR Androidのブロック長を兼任しており、普段は部署全体の管理・リスクマネジメントや、Android開発における設計などを行っております。 本記事では、運用改善によるチームパフォーマンス向上のための取り組みについてご紹介します。なお、フロントエンド部WEAR Androidブロックで実施した内容となっており、一部アプリ開発向けの施策ですのであらかじめご了承ください。 目次 目次 はじめに 生産性に対する課題感 改善結果 サイクルタイム平均値 スタッツ 数値分析 問題点の推測 問題点の認識  対応策 レビュー環境への対応策 レビュー会の開催 PR単位でビルドの共有 巨大なPRへの対応策 サブタスクで粒度を下げる 常にアップデートすることへの対応策 開発者リソースの再配分 PRテンプレートを充実させる 意味のある単位でコミットをまとめる 自動化できるものは自動化する まとめ はじめに ある時、本部長から「生産性を3倍にしてください」と通達がありました。いきなり3倍という数値を目の当たりにすると尻込みしますが、いま思えばこれが推進力の一端になったと思います。そしてより具体的な部の目標として、「1プルリクあたりの平均マージ・クローズ時間 24時間以内」を掲げ、改善の取り組みを開始しました。 生産性に対する課題感 Pull Request(以下PR)におけるレビューにとても時間がかかっていることはあきらかで、いかにPRにおけるレビューを効率よく行えるかが課題としてありました。しかしながら、生産性に課題があると感じていたものの、具体的にボトルネックがどこにあるか数値として把握していたわけではありません。 レビューをしなくてはいけないという意識はチーム内に浸透していたものの、案件の対応に追われまとまった時間が取れず、なかなかレビューに取り掛かれない。なぜそのような状態になってしまうのか、この状態から抜け出すにはどうしたらいいのか、が手探りな状態でした。幸運なことに弊社では Findy Teams が既に導入済みでしたので、こちらを駆使して数値の分析からはじめることにしました。 改善結果 まずは取り組みを説明する前に、結果をご覧ください。 (改善前:2021/10/01〜2021/11/30 改善後:2022/07/01〜2022/08/31) サイクルタイム平均値 スタッツ いかがでしょうか、目に見えて数値が改善されています。嬉しいことに「1プルリクあたりの平均マージ・クローズ時間 24時間以内」という目標に肉薄した数値まで改善しています。 数値分析 まず、Findy Teams上で直近2か月分(改善前:2021/10/01〜2021/11/30)の数値を見ることにしました。 問題点の推測 数値から以下の問題点が推測できます。 PR作成数が少なく、作成までに時間がかかっているのは、1つのPRでの変更が多いためではないか? レビュー完了までに時間がかかっているのは、変更が大きくレビュアーに負担が掛かっているからではないか? マージするまでに時間がかかっているのは、変更が大きいが故に指摘が多くなるためではないか? 次にチーム内でレビューに対しての問題点を話し合いました。 レビューに時間がかかるため、まとまった時間を取るのが難しい レビューするブランチを手元でビルドすると時間がかかる 複雑な仕様の場合、仕様を理解するまでに時間がかかる その結果、上記のような「レビュアーに優しくないPR」が作成されているのが問題ではないか、という意見があがりました。 問題点の認識 大切なのは「レビュアーに優しいPR」を作成すること、という目的をチームで共有し、現在は何がレビュアーにとって優しくないのかをまとめました。 レビュー環境が整っていないこと PRが巨大であること 日々発見される課題を認識し、常にアップデートする必要があること  対応策 次に問題点に沿って、行った取り組みをご紹介します。 レビュー環境への対応策 レビューする際に、開発作業とのスイッチングコストがかかっている状態でした。こちらをなるべくシームレスに行い、かつレビューすることへの心理的ハードルを下げることを目的としました。 レビュー会の開催 レビュー会と称して、毎日1時間チーム全員で集まる会を開くことにしました。Discordの音声チャンネルに集まり、その場で全員がレビューします。優先してほしいレビューの共有や、口頭での質問もそこで行います。 狙いとして、スケジュールに組み込むことでレビュー時間の確保。チームでレビューする強制力。また、その場で口頭相談できるコミュニケーションの向上があります。 チームからレビューのやりやすさが向上したとフィードバックがありましたし、数値としてもレビュー件数の向上が見られました。 PR単位でビルドの共有 WEAR は2013年にリリースされたアプリで、巨大なプロジェクトになっていることもあり、ビルド時間がかなりかかってしまっている状態でした。その中で、PRごとに各レビュアーが手元でビルドして確認する必要があり、すぐにレビューに取りかかれない問題があります。この問題を解消するため、弊社では他チームにアプリを共有する際に、DeployGateを利用していたのでそちらをうまく活用し解決しました。 PR作成後、GitHub ActionsでDeployGateへビルドをアップロードし、そのURLをPRのコメントへ記載することで環境別のビルドをすぐインストールできる状態にしました。また、PRがマージ、クローズされた際にそのリンクを無効とする処理も行っています。 この結果、毎回レビュー時にかかっていたビルド時間を0にすることが出来たため、各々のPCに負荷を掛けることなくレビューする環境が整いました。 巨大なPRへの対応策 巨大なPRの場合、それだけでレビューにかかるコストが大幅に増加し、必然的にまとまった時間が必要となります。粒度を下げることで、レビュアーを拘束する時間を必要最低限にすることを目的としました。 サブタスクで粒度を下げる 弊社では案件管理にJiraを採用しており、普段からなるべく小さい範囲で課題チケットを作成するように心掛けていましたが、ブランチ運用上難しいパターンがありました。 ブランチ運用は、main、developの2つが基本となります。基本的に2週間に1回の定期リリースをしており、開発フェーズではdevelopに各々対応したチケットをマージします。その後、テストフェーズでQAチームに共有してテスト、リリースの流れとなっています。 細分化するチケットは、あくまでもそれ単体でdevelopにマージして問題ない単位としているため、必然的に大きくなる傾向がありました。 例えば、定期リリース2週間の期間内に終わらない案件(もしくはリリーススケジュールが先のもの)が進行していた際に、以前までは作業ブランチにすべて完了するまでコミットする。そしてその作業ブランチをdevelopに向けてPR作成というフローでした。 この時の問題として対応内容が大きければ大きいほど差分が膨らみ、仕様の理解が遅くなり、結果レビュアーの負担が大きくなりレビューの精度が低下するというものでした。 こちらを解消するために、Jiraの サブタスク 機能を活用します。 1つのチケットをサブタスクとして実装者が細分化することで、より実戦的な粒度の小さいものに出来ます。実装者はそのタスクを完了させたら親のチケットに対してPRを作成します。最終は、親のチケットをdevelopに向けてPR作成します。その結果、実装者は普段の開発と同様一区切りついたタイミングでレビューをしてもらえ、フィードバックも早くなります。 また、細分化された作業が明らかになることでエンジニアの進捗管理にも役立ちました。 常にアップデートすることへの対応策 レビューでは非同期的なコミュニケーションが多くなりますが、仕組み化するための議論の場として、我々は同期的なコミュニケーションが大切だと考えました。週1回の実装相談会の実施、月1回のKPTの実施を定め、日々発見される課題をチーム全体の課題として認識しアップデートしていくことを目的としています。 細々とした課題が多くあったので、その中でも効果的だったものをいくつかご紹介します。 開発者リソースの再配分 Findy Teamsで数値を分析すると、ある時期あるメンバーのパフォーマンスの高低が見えてきました。こうして数値として見えることで、本人も気付かない部分を考察できます。なぜパフォーマンスが良かったのか、悪かったのかをメンバーと話し合うことで、数値と実感をすり合わせる作業をしました。 メンバーの得意な案件を担当にしたり、エンジニアリソースの再配置をすることで、開発メンバー数としては減ったものの数値は改善されてコストパフォーマンスの高い結果となりました。 また、 Findy Teams にはチームのフォローアップアラートをSlackで受け取る機能があり、その数値を元に仕事量の偏りがないよう調整しています。 PRテンプレートを充実させる 主に人によって違いが出ることを防ぎ、また書くものを迷わないようにするを目的としています。 一部抜粋し、以下に羅列します。 JIRAのチケットリンクを必須で記載 ブランチ名にもWEAR-****をprefixとして作成するルールにし、PRで作成したものに使用するブランチが一致するかをCIで自動的に確認 動画・画像キャプチャを必須で記載 ひと目で対応箇所の認識、動作の確認が容易になり認識のズレを減らす テスト内容をレビュアーが再現できるもので表現する 「正しいこと」「問題ないこと」などの曖昧な表現をやめる レビュアーがテストに記載されたものをなぞれば再現できる表現にする 本質的なコードのレビュー以外の確認項目を減らし、コードレビューを集中して行うことができるようになりました。 意味のある単位でコミットをまとめる コミットの単位を一時的なコミットではなく、そのコミット自体で1つの完結した意味あるものとするようにしました。 例えば、画面にボタンを追加するというPRがあるとして下記のようにコミットを積んでいきます。 ボタンのUI作成 クリックリスナーの設定 クリックされたときの処理を実行 ボタンの文言を定義 コードフォーマットをかける 上記のように、コミットを分けることでコミット単位でのレビューが可能となり、よりレビュアーの理解する速度向上に役立ちます。 (状況によりコミット単位は変動します。難しい場合、この限りではなく、臨機応変に対応としています) 自動化できるものは自動化する Slack上にPRがOPENされたら通知する PRが作成されたら自動でレビュアーをアサインする ブランチが並行稼動するため、マージ先のブランチを間違えないように PR Milestone Check を導入し、対応PRを管理する 人が管理する必要のないものを自動化して時間を削減したり、ヒューマンエラー対策を行いました。 まとめ 本記事ではレビューを通じてチームのパフォーマンス向上を目指した取り組みについてご紹介しました。 1つ1つはそこまで大変な対応策ではありませんが、確実に実施していくことで数値の改善を達成できました。部として目標を定め、それに対応する施策をチームの共通目標にし、目指すべきところへ全員が参加して貢献することが大切です。コードレビューを通じてコミュニケーションも活発になり、チーム一丸となって進んでいると実感しています。 我々の取り組みが少しでも参考となれば幸いです。 最後までご覧いただきありがとうございました。WEARでは一緒にサービスを作り上げてくれるAndroidエンジニアを募集中です。 hrmos.co その他、各種エンジニアも募集しております。ご興味のある方はぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは、ZOZOTOWN開発本部の松井とZOZO NEXTの 木下 です。9/10から9/12までの3日間、iOSDC Japan 2022が開催されました。ZOZOグループからは6名が登壇、20名以上が参加しました。またプラチナスポンサーとして協賛しました。 technote.zozo.com 今年のiOSDCは時代に即した形で、現地会場とオンライン配信によるハイブリッド開催でした。今回は、その両方を盛り上げるために行ったZOZOの取り組みをご紹介いたします。 はじめに 現地ブース 準備 展示内容 当日の様子 オンラインブース 準備 当日の様子 登壇内容の紹介 「PWAの今とこれから、iOSでの対応状況」 「20分間で振り返るIn-App Purchaseの歴史」 「あなたの知らないARの可能性を空間レベルで拡げるVPSの世界」 「Swift Concurrency時代のリアクティブプログラミングの基礎理解」 「全力疾走中でも使えるストップウォッチアプリを作る」 「目からビームでヴィランをやっつける 〜ARKitの知られざる並走機能〜」 CfPネタ出し会 & レビュー会 現地ブース 準備 5月上旬にスポンサー募集が開始され、社内でブース出展の是非を話し合いました。3年ぶりの現地会場であり、多くの来場者と活発にコミュニケーションをとれることを期待して、ブース出展を決定しました。スポンサーのノベルティや掲載物については、iOSエンジニアと広報・CTOブロックが連携して進めました。 展示内容 ブースについてはどういった展示内容にするかも含めて、エンジニアが主体的に進めていくこととなりました。最初にMiroでマインドマップを作成し、ブレインストーミングを行いました。久々のブース出展ということで、前回出展した 2019年の様子 を振り返りながらアイデアを深めました。 その後展示内容ごとに担当者を決め、2週間程度で準備をしました。実際に展示したのは以下です。 アンケートやプロダクト紹介が載ったMiroのボード(次の章で詳しく説明します) ZOZO独自の計測テクノロジー「ZOZOSUIT」や「ZOZOMAT」・「ZOZOGLASS」、ZOZOSUITの技術を活用した新事業「ZOZOFIT」 弊社のカルチャーや開発環境の紹介 当日の様子 そして当日できあがったブースがこちらです! ブースに来てくださった方へのノベルティや、ZOZOSUITの技術を活用した新事業、ボディマネジメントサービス「ZOZOFIT」のパッケージと専用アプリ画面の展示です。 トルソーに着せたZOZOSUITの存在感があり、会場から多くの視線を集めていました。 足の3D計測マット「ZOZOMAT」やフェイスカラー計測ツール「ZOZOGLASS」など、現在ZOZOTOWN内でご利用いただける計測テクノロジーもご紹介しました。 ブースではまずアンケートに答えていただき、そこからリモートワークに関する話に花が咲きました。また、ZOZOSUITについて興味を持ってくださった方には、旧ZOZOSUITからの改良点や今後の展望についてお話ししました。 会場全体はこのような雰囲気で、個性を生かしたブースが並んでいました。全てのブースを拝見しましたが、技術的なお話をうかがえたりデモアプリを実際に触れられたりなど、どのブースもとても魅力的でした。 ブース出展を通して、対面だからこそできる気軽なコミュニケーションのありがたみを改めて感じました。様々な方と、技術や会社についてお話ししたり、「Twitterでよく見ております!」という会話が生まれたりしました。ブースへ遊びに来てくださった方々、ありがとうございました。 オンラインブース 今年のiOSDCはオフラインとオンラインのハイブリッド開催でしたね。現地に来られない方もZOZOのブースを楽しめるようにと、今年はZOZO独自にオンラインブースも用意していました! iOSDCチャレンジで使われるトークンは、実はオンラインブースにも隠されていたんです。チャレンジした方、見つけられましたか? 準備 ZOZOでは、テックカンファレンスでのオンラインブースの用意は初めてでしたが、Miroを使って全体のデザインからコンテンツの細部までエンジニアがメインで作り上げました。 このオンラインブースはコースを進むようにして順番にコンテンツを見ていけるようなデザインにしています。また、リンクを開いた時にまずはなにをしたら良いのか、箱猫マックス(ZOZOの公式キャラクター)が教えてくれるようファーストビューを設定しました。このようにして、参加してくださったお客様が迷わない工夫をしています。 そして、オンラインブースでもできる限り現地ブースと同じような体験を作りたい! という思いがありました。そこで、オンライン上でもZOZOのiOSエンジニアに気軽に話を聞きにいけるスペースを用意しました。 当日の様子 オンラインブースのURLはTwitterやZOZO DEVELOPERS BLOG、現地ブースでの2次元コードで発信していました。オンラインブースには多くの人に来場いただき、アンケートは大盛り上がり! たくさんのご回答、ありがとうございました! また、Google Meetにもトークの合間を縫って話に来ていただきました。 ZOZOには「全国在宅勤務制度」があり、日本国内であればどこでも就業可能なため、現地への参加のしやすさは人それぞれです。オンライン参加でもトークを聞いたりiOSDCチャレンジに挑戦したりするだけでなく、Google Meetにて待機することで、イベントの臨場感を味わえました。また、オンライン参加のお客様に対しても現地ブースを擬似体験しているような機会を提供でき、双方にとってよい取り組みとなったように感じます。来年も引き続きオンラインでの開催が行われるようなので、さらに盛り上げていきたいですね! 登壇内容の紹介 今年ZOZOからは6名が登壇しました。昨年に引き続き、会社としての一体感を出すため、登壇者一覧のスライドを社内のデザイナーの方にお願いして作成し、発表で利用しました。 それぞれの登壇内容についてご紹介します。 「PWAの今とこれから、iOSでの対応状況」 木下 のレギュラートークです。 画像提供:iOSDC Japan 2022実行委員会。 ウェブアプリに、ネイティブアプリに近い機能を付加した、Progressive Web Apps (PWA)について発表しました。PWAの概要や歴史から始まり、iOSにおける対応状況やPWAを特徴づける機能、さらにPWAを採用する判断の助けとなるフローチャートなどをまとめました。 ハイブリッド開催ということで直接もしくはTwitter上などで、「PWAって結構色々できそう」という声や、「WebViewだけのアプリを作るならPWA良さそう」など色々と反応いただきました。とても嬉しかったので、また登壇できるように頑張ります。 fortee.jp 「20分間で振り返るIn-App Purchaseの歴史」 @inokinn のレギュラートークです。 13年以上の長きに渡る、In-App Purchaseの機能の歴史について発表させていただきました。現地会場やTwitterでは、感想や質問をいくつかいただくことができて非常に嬉しかったです。来年も現地参加するつもりなので、またお会いしましょう! fortee.jp 「あなたの知らないARの可能性を空間レベルで拡げるVPSの世界」 HEAVEN chan / ikkou のレギュラートークです。 Hi, I’m HEAVEN chan! 去年、一昨年はWebARについて喋りましたが、今年はVPSについて喋りました! 今はまだメインストリームの技術ではないものの、今回の発表をきっかけにして少しでも興味を持つ方が増えたら嬉しいです! ついにiOS 16もリリースされたので張り切ってやっていきましょう! fortee.jp 「Swift Concurrency時代のリアクティブプログラミングの基礎理解」 ばんじゅん のレギュラートークです。 画像提供:iOSDC Japan 2022実行委員会。 基礎シリーズということで、すぐに使えるとか役立つというものではなかったのですが、みなさん聴いてくれてありがとうございました。Swift Concurrencyとリアクティブプログラミングの例に限らず、たまには基礎に戻って理解を固めておくモチベーションに繋がれば良いなと思っています。並行計算というのはそもそも難しいものなので、今後も着実にやっていきましょう。 fortee.jp 「全力疾走中でも使えるストップウォッチアプリを作る」 Ogijun のレギュラートークです。 画像提供:iOSDC Japan 2022実行委員会。 みなさんが日常で使っているストップウォッチアプリの使いやすさを陸上競技の選手という特殊な目線で評価し、どんなインタラクションが最適であるかを検証しました。実際に自分が走りながら使った際の動画を見せることで、現状の不便さや作成したアプリの強みを皆様に伝えられたかと思います。他のセッションに比べて異質なテーマでしたが、実際にアプリをDLしてくださったり、フィードバックをいただけてとても嬉しく思います。また来年も登壇できるようこれからも頑張ります! fortee.jp 「目からビームでヴィランをやっつける 〜ARKitの知られざる並走機能〜」 ながいん のレギュラートークです。 画像提供:iOSDC Japan 2022実行委員会。 ARKitの一機能である、ワールドトラッキングとフェイストラッキングの並走機能にフォーカスしたトークで発表しました! 初登壇だったのですが、現地・オンラインから「おもしろい!」というリアクションをいただいて、今後のモチベーションに繋がりました。来年はトークだけでなく原稿にも挑戦したいです! fortee.jp CfPネタ出し会 & レビュー会 弊社では毎年、自由参加でiOSDCのCfPネタ出し会 & レビュー会をiOSエンジニア同士で行っています。今年も例年通り開催しました。詳細については昨年のブログで紹介しています。 techblog.zozo.com 今年は昨年と比べてプロポーザルの募集期間が短かったため、次のようなスケジュールで進めました。 5/12にCfPの募集が開始され、5/25に2時間程度のネタ出し&レビュー会を開催しました。会ではドキュメントに予めネタを書き出しておき、相互にレビューしました。またレビューを進める中で新たにネタを思いついた場合には書き足していくというのを行い、最終的に約25件のネタが集まりました。 6/1に技術顧問である岸川さんより、CfPを読む人にトークの期待値が伝わりやすくなっているかなど様々な観点から最終レビューをいただきました。 結果的に6件のトークが採択されました。ネタ出し&レビュー会はここ数年で定着すると共に、書き方に関するノウハウもたまってきています。来年以降も引き続き実施していこうと考えております。 また弊社ではカンファレンスへの参加は業務として扱われるため、iOSDCには休日出勤という形で参加しました。今年も内定者アルバイトの方が複数名イベントへ参加しましたが、社員と同様にチケット代は経費となり、イベントへの参加は業務時間として扱われています。 ZOZOでは、来年のiOSDC Japanへ一緒に参加してくださるエンジニアを募集しています。ご興味のある方はこちらからご応募ください。 hrmos.co hrmos.co
アバター
はじめに こんにちは。検索基盤部の倉澤です。 私たちは、ZOZOTOWNの検索機能の改善に取り組んでいます。ZOZOTOWNには、ユーザーが検索クエリを入力した際に、候補となるキーワードを表示するサジェスト機能があります。 今回はこのサジェスト機能の改善を効率的に評価する社内ツールを以下3点に焦点をあてて紹介します。 社内ツールの各機能 実務にて利用している場面 開発する際に採用したバックエンド技術 目次 はじめに 目次 背景 サジェスト評価ツールの機能 サジェスト候補の表示 評価 評価結果の集計表示 類似度算出 利用ケース バックエンドの技術 技術スタック アーキテクチャ まとめ 背景 ZOZOTOWNでは、サジェストの検索エンジンとしてElasticsearchを採用しています。 Elasticsearchからサジェスト機能が デフォルト で提供されていますが、日本語との相性を考慮し通常の検索クエリを使用して実装しています。 日本語との相性が悪いためサジェスト機能の実装が難しい理由は、Elasticsearch公式ブログの記事で詳しく言及されています。 www.elastic.co 私たちは、検索クエリに対するサジェスト候補の表示順序のロジックや表記揺れの改善に取り組んでいます。 サジェスト機能の改善を繰り返していくうちに、以下のような要望が出てきました。 新たなロジックで作成したサジェストのElasticsearchのインデックスと、既存のインデックスの検索結果にどのような変化があるのかを素早く確認したい A/Bテストを実施する前にオフラインの定性評価を実施したい 本ツールはこれらの要望に答えるため作成したものです。 サジェスト評価ツールの機能 上記の背景を踏まえて作成したツールの機能を紹介します。 サジェスト候補の表示 入力として、比較したい2つのElasticsearchのインデックス名、検索クエリを受け取ります。 検索ボタンを押下すると、検索クエリに対応するサジェスト候補がスコアの高い順に10件表示されます。 Elasticsearchでのクエリとドキュメントのマッチングに用いるスコアは、 デフォルトのBM25 をベースとしたものではなく独自に定義したスコアを用いています。 具体的には、過去のサジェスト候補に対するクリック率やクリック後の商品購入率などを元に計算した重みを使用しています。 また、出力されたサジェスト候補の違いをわかりやすくするため各順位毎に同じキーワードであればグレーアウトしています。 評価 入力した検索クエリに対して、どちらのインデックスが適切な結果を返しているのかを評価する機能を実装しています。また、適切と判断した理由も記録できます。 複数人が同じ2つのインデックスを確認するケースがあります。他の人の評価を元にどちらのインデックスが適切かを総合的に判断したい場合があるため、こちらの評価機能を提供しています。 インデックスを評価するにあたりどのサジェスト候補が適切ではなかったのかを記録するため、各キーワードの横にチェックボックスを用意しています。 評価結果の集計表示 複数人が同一のインデックスを評価するケースがあるので、評価した結果を集計しリアルタイムで表示する機能を提供しています。どのようなクエリを実行しどちらのインデックスが適切であったのかを表示しています。 各インデックス名の列には評価者が検索したクエリ単位に適切だと評価した回数が記録されています。 また、どちらのインデックスも適切だと評価した場合は「both_ok」、どちらのインデックスも適切ではないと評価した場合は「both_ng」の値が記録されます。 これらの集計結果を表示させることで、どのクエリでどのような評価をしたのかを素早く確認できるようになりました。 類似度算出 2つのインデックスから出力されたサジェスト候補を定量的に評価する機能として、類似度を算出し表示しています。 具体的には、「 A Similarity Measure for Indefinite Rankings 」(著:William Webber, Alistair Moffat and Justin Zobel)の論文で提案されている"Ranking-Biased Overlap(RBO)"という手法により類似度を算出しています。 この手法は類似度を0〜1の範囲で計算し、2つのリスト内の上位のキーワードが異なっていた場合に類似度を大きく減衰させるという特徴があります。 以下の理由からこちらの手法を採用しました。 ある検索クエリに対する2つのサジェスト候補のリストにどの程度の差があるのか知りたいという目的と合致している インデックスに対して加えた改修の影響が大きいのか小さいのかが直感的に理解できる Ranking-Biased Overlap(RBO)の実装は、 changyaochen/rbo のコードを参考にGo言語へ書き換えました。 package main import ( "fmt" "math" ) type State struct { p float64 depth int weight float64 agreement float64 AverageOverlap float64 sRunning map [ string ] struct {} tRunning map [ string ] struct {} } func NewState(s0, t0 string , p float64 ) *State { if 0.0 >= p || p > 1.0 { panic ( "p must be between (0, 1)" ) } weight := 1.0 agreement := 0.0 averageOverlap := 0.0 if p != 1.0 { weight = 1 - p } if s0 == t0 { agreement = 1.0 averageOverlap = weight } return &State{ p: p, depth: 1 , weight: weight, agreement: agreement, AverageOverlap: averageOverlap, sRunning: make ( map [ string ] struct {}), tRunning: make ( map [ string ] struct {}), } } func (s *State) Update(sd, td string ) { overlap := 0 if sd == td { overlap++ } if _, ok := s.tRunning[sd]; ok { overlap++ } if _, ok := s.sRunning[td]; ok { overlap++ } s.agreement = 1.0 * ((s.agreement * ( float64 (s.depth))) + float64 (overlap)) / ( float64 (s.depth) + 1.0 ) s.weight = ( 1 - s.p) * math.Pow(s.p, float64 (s.depth)) if s.p == 1.0 { s.AverageOverlap = (s.AverageOverlap* float64 (s.depth) + s.agreement) / ( float64 (s.depth) + 1.0 ) } else { s.AverageOverlap = s.AverageOverlap + s.weight*s.agreement } s.sRunning[sd] = struct {}{} s.tRunning[td] = struct {}{} s.depth++ } func (s *State) GetSimilarity() float64 { if 0.0 <= s.AverageOverlap && s.AverageOverlap <= 1.0 { return s.AverageOverlap } fmt.Println( "Value out of [0, 1] bound, will bound it" ) return math.Min( 1.0 , math.Max( 0.0 , s.AverageOverlap)) } func main() { // 類似度を算出する対象のランキングリスト s := [] string { "a" , "b" , "c" , "d" } t := [] string { "a" , "c" , "b" , "d" } state := NewState(s[ 0 ], t[ 0 ], 1.0 ) for i := 1 ; i < len (s); i++ { state.Update(s[i], t[i]) } similarity := state.GetSimilarity() fmt.Printf( "similarity: %g" , similarity) // similarity: 0.875 } 利用ケース 私たちは、サジェスト機能の改善を検証するためA/Bテストを実施していますが、ユーザーへの影響をできるだけ事前に把握するためオフラインでの定性・定量評価を実施しています。 本ツールはA/Bテストを実施する前に行うオフラインの定性評価時に利用します。以下は、A/Bテストを実施するまでのステップを簡易的にまとめた図です。 定性評価は以下の順序で行っています。 ランダムに抽出した検索回数が多いTOPクエリと少ないTAILクエリを評価者に割り振る 評価者は本ツールにて割り振られたクエリを実行する 出力されたサジェスト候補を元に2つのインデックスを評価する 全ての評価者からのフィードバックを元にA/Bテストへ進むのか、改善方針を見直すのかを判断する 評価者は開発前に策定した方針が適切に検索結果に現れているのか、またTOPクエリ及びTAILクエリに対する影響をチェックしています。 また、評価者の構成は基本的にチーム内のメンバーですが、過去には社内で評価者を募ったこともあります。 上図の改善サイクルの詳細や改善事例については過去の記事で紹介していますので、併せてご覧ください。 techblog.zozo.com バックエンドの技術 バックエンドで採用している技術スタックやアーキテクチャを紹介します。 技術スタック おおまかな技術スタックを以下の表にまとめました。 カテゴリー 名称 実行環境 Google App Engine データベース Google Cloud Datastore 開発言語 Go言語 httpパッケージ net/http routingパッケージ gorilla/mux Elasticsearchクライアントパッケージ olivere/elastic Go言語を採用した背景は、属人的な実装にならないようにシンプルな文法で記述でき、将来チーム内で開発する際にも実装のブレが少ない言語仕様だからです。 評価した検索クエリやインデックス名、サジェスト候補を格納するデータベースとしては、Google Cloud Datastoreを採用しています。 社内ツールということもあり、運用にかかるコストを下げるためやApp Engineとの相性の良さからGoogle Cloud Datastoreを採用しました。 アーキテクチャ プロダクトの設計として、ヘキサゴナルアーキテクチャを採用しています。アダプター層、ユースケース層、ドメイン層で責務を分け、依存関係の方向を担保し実装をしています。 ディレクトリ構成は以下の通りです。 . app ├── adapter │ ├── datastore │ ├── elasticsearch │ ├── http │ │ ├── controller │ │ └── middleware │ └── impl ├── domain │ ├── model │ └── repository └── usecase ディレクトリ名 概要 adapter/http/controller いわゆるコントローラ層。ユーザーからのデータを受け取り、処理をしたデータをユーザーへ返す実装。 adapter/http/middleware リクエストログの取得などAPI共通機能を実装。 adapter/impl interfaceで定義したメソッドの実装。 adapter/elasticsearch elasticsearchへの接続処理を実装。 adapter/datastore datastoreへの接続処理を実装。 domain/model アプリケーションに必要な構造体を定義。 domain/repository 依存性逆転のためinterfaceを定義。 usecase ユーザーからのインプットを元にAPIレスポンスに必要な情報を返す実装。 まとめ 本ツールにより、以下2つの要望を実現しました。 開発者が新規に作成したインデックスと既存のインデックスの検索結果を素早く比較する A/Bテスト前のオフライン評価の実施 今後の展望として、Elasticsearchのマッピングやクエリを変えて比較できるような機能を実装したいと思っています。 さいごに、ZOZOでは検索エンジニア・MLエンジニアを募集しています。検索機能の改善に興味のある方は、以下のリンクからご応募ください。 hrmos.co hrmos.co
アバター
はじめに こんにちは、マイグレーションチームの寺嶋です。 本記事では、ZOZOTOWNのマイクロサービスにおけるデータベースを参照したユニットテストの改善で得られた知見や工夫について紹介します。 背景と課題 ZOZOTOWNでは、数年前からリプレイスプロジェクトが実施されており、いくつものマイクロサービスが誕生しました。初期にJavaで作られたマイクロサービスのユニットテストが開発環境のデータベースを参照しており、テストで利用しているデータが更新・削除されてしまうとテストに失敗してしまうことが度々起きていました。また、接続しているデータベースがオンプレのSQL Serverを利用しており、CI上でユニットテストを実施できない状況でした。 そのため対象のユニットテストは次の問題を抱えていました。 ローカルPC上でしか実行できない 実データを利用しているので今日通ったテストが明日落ちる(可能性がある) このようなことから外部環境に依存しないユニットテストへ変更する必要がありました。 対象サービスの技術スタック 今回改善するマイクロサービスの技術スタックは次の通りです。 Java 11 Maven Spring Boot MyBatis SQL Server JUnit 4 ZOZOTOWNリプレイスプロジェクトでは全社技術スタックを統一しています。詳しくは下記の記事をご覧ください。 qiita.com 対応方法の検討 解決方法として次の方法を検討しました。 H2データベースを利用する Dockerコンテナのデータベースに接続する H2データベースはJVM上にて動作するデータベースでインストールを必要としません。JDBCのURLに ;MODE=MySQL といったオプションをつけることでH2データベースの挙動をMySQL、PostgreSQLなど切り替えることができます。もちろんSQL Serverモードもあり、 ;MODE=MSSQLServer を指定すればSQL Server風の挙動を再現させることが可能になります。ただ、 H2のドキュメント を確認していると、 ヒント句は破棄される という説明があり、SQLチューニングでヒント句を使用しているサービスなので、採用は見送ることになりました。 ということで、DockerコンテナでSQL Serverを起動させてテストする方法となり、見つけたのが Testcontainers でした。 Testcontainersとは Testcontainers はJUnitのテストをサポートするJavaライブラリです。一般的なデータベースやSelenuim、Dockerコンテナで実行できるものを軽量で使い捨て可能なインスタンスとして提供してくれます。Testcontainersを利用すると次の種類のテストが簡単に行えます。 データアクセスレイヤーテスト MySQL、PostgreSQL、Oracle Databaseなどのコンテナ化されたインスタンスを使用して、データアクセスレイヤーにコードの変更なくテストを実行できます。Dockerコンテナを利用するので複雑なセットアップも必要ありません。 アプリケーション統合テスト データベース、メッセージキュー、Webサーバなどの依存関係を使用してアプリケーションのテストを実行できます。 UI受け入れテスト 自動化されたUIテストを実施するためにSelenuimと互換性のあるコンテナを使用し、ブラウザの状態やバージョンを気にすることなくテストを実施できます。また、失敗したテストのみ動画を録画するなども行ってくれます。 今回は データアクセスレイヤーテスト を活用して、実データベースを参照しているテストを改善します。 開発環境のデータベースから切り離す pom.xml Testcontainersの依存関係をpom.xmlに追記していきます。今回はSQL Serverコンテナをユニットテスト時に起動しますので org.testcontainers.mssqlserver を追加しています。MySQLやPostgreSQL、Oracle Databaseを利用する場合は対象データベースのdependencyがありますので、環境に応じて指定してください。 <dependency> <groupId> org.testcontainers </groupId> <artifactId> testcontainers </artifactId> <version> 1.16.3 </version> <scope> test </scope> </dependency> <dependency> <groupId> org.testcontainers </groupId> <artifactId> mssqlserver </artifactId> <version> 1.16.3 </version> <scope> test </scope> </dependency> SQL Serverコンテナの起動 MSSQLServerContainer クラスがSQL Serverコンテナを管理するクラスとなっており、これを継承し新たに MyMSSQLContainer クラスを作っていきます。 public class MyMSSQLContainer extends MSSQLServerContainer<MyMSSQLContainer> { private static final String IMAGE_VERSION = "mcr.microsoft.com/azure-sql-edge:1.0.5" ; private static MyMSSQLContainer container; private MyMSSQLContainer() { // (1) super (DockerImageName.parse(IMAGE_VERSION) .asCompatibleSubstituteFor( "mcr.microsoft.com/mssql/server" )); } public static MyMSSQLContainer getInstance() { if (container == null ) { // (2) container = new MyMSSQLContainer() .waitingFor(Wait.forLogMessage( "*SQL Server is now ready for client connections*" , 1 )) .acceptLicense(); } return container; } @Override public void start() { super .start(); } @Override public void stop() {} } SQL Serverのコンテナは通常 mcr.microsoft.com/mssql/server イメージを利用すればよいのですが、M1 Macでは起動できません。M1 Mac上でも起動できるSQL Serverコンテナは mcr.microsoft.com/azure-sql-edge イメージになります。(1)で azure-sql-edge を指定し、 mcr.microsoft.com/mssql/server として振る舞うように設定しています。M1 Macをご利用の方はご注意ください。 本クラスはシングルトンでインスタンスを管理しています。複数のユニットテストで MyMSSQLContainer クラスのインスタンスを作成してしまうと、それぞれのテストでSQL Serverコンテナを起動してしまうため、実行単位で起動を促しています。また、(2)でインスタンスを作成する際にメソッドチェインで呼び出している acceptLicense メソッドはライセンス認証をしています。SQL ServerやIBM Db2で必要となります。詳しくは下記のページをご覧ください。 www.testcontainers.org 接続先を動的に変更する MyMSSQLContainer クラスでSQL Serverコンテナの起動準備ができました。ただ、見てもらえればわかるようにデータベースのIDやパスワード、ポートを指定していません。ID・パスワードは Testcontainers がデフォルトで設定しているものを利用し、ポートも指定しなければランダムで設定されます。パスワードは withPassword メソッド、ポートは withExposedPorts メソッドで設定できますが、ユニットテストなので固定化せず、デフォルト指定されるものを利用します。 public class AbstractDBTest { protected static MSSQLServerContainer<MyMSSQLContainer> sqlserver = MyMSSQLContainer.getInstance(); static { sqlserver.start(); } @DynamicPropertySource static void setup(DynamicPropertyRegistry registry) { registry.add( "spring.datasource.url" , sqlserver::getJdbcUrl); registry.add( "spring.datasource.username" , sqlserver::getUsername); registry.add( "spring.datasource.password" , sqlserver::getPassword); } } AbstractDBTest クラスはユニットテストクラスが継承する基底クラスとなります。Spring Bootでは こちら にもある通り @DynamicPropertySource を用いると容易に接続先を切り替えることができます。起動しているSQL Serverコンテナから接続に必要な情報を取得し環境変数に設定します。 テーブルとデータの復元 ここまでで、ユニットテスト起動時に必要な次の準備が完了しました。 SQL Serverコンテナの起動 接続先の動的な切り替え 次はSQLの動作確認に必要なテーブルとデータの復元になります。 テーブル、データの復元には Flyway を使っていきます。Flywayはデータベースのバージョン管理ツールで、DDLやDMLのSQLファイルをバージョン管理することで常に最新状態を保つことができます。pom.xmlにFlywayの依存関係を追加していきます。 <dependency> <groupId> org.flywaydb </groupId> <artifactId> flyway-core </artifactId> <version> 8.5.8 </version> <scope> test </scope> </dependency> <dependency> <groupId> org.flywaydb </groupId> <artifactId> flyway-sqlserver </artifactId> <version> 8.5.8 </version> <scope> test </scope> </dependency> テストで利用するDDLとDMLはtestディレクトリ配下の resources/db/migration に次のファイル名で配置します。 V1__CreateTable.sql V2__InitData.sql SQLファイルのネーミングルールは V{VERSION}_{DESCRIPTION}.sql になっており、詳細は次の通りです。 先頭文字は V から始める {VERSION} は実行される順番となり、小さい番号から実行される __ はバージョンと説明との区切り {DESCRIPTION} はバージョンの説明を記述する Spring Boot起動時に Flyway.migrate() が呼び出されるようにするため、 FlywayMigrationStrategy インタフェースの実装をBeanに登録します。 @Bean public FlywayMigrationStrategy cleanMigrateStrategy() { FlywayMigrationStrategy strategy = flyway -> { flyway.clean(); flyway.migrate(); }; return strategy; } あとは、既存のテストクラスで AbstractDBTest クラスを継承するとユニットテスト実行時にSQL Serverコンテナが起動し、テーブル・データの復元を行いテストを実行してくれます。テスト終了後にはSQL Serverコンテナは自動で終了してくれます。 まとめ Testcontainers を使ったユニットテストの改善・導入をご紹介しました。本対策をすることでCI上でもユニットテストの実行ができるようになり、機能追加や改修、リファクタリング時のリグレッションテストとして機能するようになりました。Dockerコンテナを利用することで速度の懸念もありましたが、SQL Serverの起動はそれほど遅くなく、テスト時間が伸びて待ちが発生するようなこともありませんでした。実データベースの参照がなくなりデータの状態に左右されず安定してユニットテストを実行できるようになったので、安心感という大きな恩恵を得ることができたと思います。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は次のリンクからぜひご応募ください。 hrmos.co
アバター
はじめに ブランドソリューション開発本部フロントエンド部FAANSの山田( @yshogo87 )です。 本投稿では、すでにXMLで書かれたレイアウトをJetpack Composeにリファクタリングした理由とその手順について紹介します。 リファクタリングする画面の問題点 FAANS では「コーデ閲覧数、送客数、売上数」を表示する画面があります。 この画面はUIの状態が一元管理されておらず状態がViewのみにしかないことで、機能の追加修正時に不具合を作り込みやすく、リリースまでに時間がかかるという問題がありました。 UIの状態変更が一元管理されていない この画面ではUIの状態変更が複数の異なるソースコードから行われていて、一元管理されていませんでした。UIの状態を変えている場所は大きく分けると2つです。 現在のUIの状態変更が別のUIから行われる この画面は1つのXMLファイルにViewPagerとRecyclerViewがあり、ViewPagerのグラフタップや横スワイプで下のRecyclerViewの情報も変わる仕様になっています。 この仕様のため、AdapterクラスにFragmentのBindingを渡して直接別のRecyclerViewの状態を変更する実装でした。 // Adapterクラス class CoordinateImpressionsGraphAdapter( private val binding: FragmentCoordinateImpressionsBinding, private val viewModel: CoordinateImpressionsActionDelegateImpl, diffCallBack: DiffCallBack = DiffCallBack() ) : RecyclerView.Adapter<CoordinateImpressionsGraphBindingHolder>() { override fun onBindViewHolder(holder: CoordinateImpressionsGraphBindingHolder, position: Int ) { ・ ・ ・ // グラフのタップイベント chart.setOnChartValueSelectedListener( object : OnChartValueSelectedListener { override fun onValueSelected(e: Entry , h: Highlight?) { // 別のRecyclerViewを直接更新している (binding.impressionsDetail.adapter as CoordinateImpressionDetailAdapter).submitList(initialBrandList) } override fun onNothingSelected() {} }) ・ ・ ・ } } このようにAdapterクラスから別のUIの状態を変更され、現在のUIの状態がViewにしか保持されていないことが問題でした。UIの状態がViewだけに存在すると、状態の把握・管理することが難しくなり実装に時間を要していました。 UIの状態変更が複数のLiveDataから行われる ViewModelからFragmentにイベントやUIの状態変更時にLiveDataを使っていました。これらのLiveDataは「UIの状態変更すること」と「イベントをFragmentに伝えること」の2つの役割があって区別されていませんでした。 class CoordinateImpressionsViewModel @Inject constructor ( private val coordinateImpressionsUseCase: CoordinateImpressionsUseCase ) : ViewModel() { ・ ・ ・ ・ private val _navigatePopStack = MutableLiveData<Event< Unit >>() val navigatePopStack: LiveData<Event< Unit >> get () = _navigatePopStack private val _coordinateImpressions = MutableLiveData<WearCoordinateImpressions>() val coordinateImpressions: LiveData<WearCoordinateImpressions> get () = _coordinateImpressions private val _hasError = MutableLiveData<ErrorType>() val hasError: LiveData<ErrorType> get () = _hasError private val _isLoading = MutableLiveData< Boolean >() val isLoading: LiveData< Boolean > get () = _isLoading private val _currentPosition = MutableLiveData< Int >() val currentPosition: LiveData< Int > get () = _currentPosition ・ ・ ・ ・ } UIの状態を変更するLiveDataが分かれているので、状態を変更する処理を追って確認しながら実装していく必要があるため、機能追加や仕様変更に時間を要していました。 UIの状態を一元管理するようにする 「コーデ閲覧数、送客数、売上数」を表示する画面はFAANSアプリのメイン機能であり、今後も機能追加されることが予想されることから以前より、リファクタリングを検討していました。PMチームなどにも現状の問題点を共有して、タスクを調整し時間をとってリファクタリングすることとなりました。 「現在のUIの状態変更が別のUIから行われること」と「複数のLiveDataからUIの状態が変更されること」の2点の問題を解決するためにUIの状態変更を一元管理するように修正します。 LiveDataを1つにし、Jetpack Composeに変更するリファクタリングを行う UIの状態変更を一元管理するために、データの流れを整理するようにします。また、「UIの状態変更が複数のLiveDataから行われる」という問題を解決するために、LiveDataは1つにするようにします。 そしてFAANS Androidアプリで以前よりJetpack Composeを導入しているため、UIもXMLファイルからJetpack Composeに変更していきます。 UIの状態を一元管理する リファクタリング方針の紹介 「UIの状態変更を一元管理」するために「単方向データフロー」の設計パターンを取り入れることにしました。データの流れを単方向にすることで明確にし、状態の把握・管理をしやすくしていきます。 UIの状態管理する2つのクラスを作成します。 State 画面の状態を保持する (例:プログレスのON/OFF、Userのデータなど) Action FragmentからのイベントをActionとしてViewModelに伝える (例:投稿ボタンをタップ、PullToRefreshイベントなど) LiveDataはStateの1つにします。また、今回の実装でLiveDataをStateFlowに変更しています。 UIはStateの更新を監視し、Stateの情報に従ってUIが構築するように実装していきます。そして、Stateの状態を変更したい場合はActionを使って変更をViewModelに伝えてViewModelの中でStateを更新します。このようにすることで、「UIはStateの変更だけを監視」と「ViewModelはStateを変更を行う」ようになり、UIの状態を一元管理できるようになります。 設計に沿ってリファクタリング 前節で紹介した設計に従ってリファクタリングしていきます。 下記は、「ViewModelはState更新」「UIはStateの購読」をするソースコードです。 ViewModelでは、APIリクエストの結果をStateに渡していてUIはリクエストの結果があれば画面に反映しています。そしてユーザーのタップのイベントは、Actionを使ってViewModelで受けとりStateを更新しています。 class SummaryViewModel( repository: HomeRepository, ) { private val _state = MutableStateFlow(State.Initial) val state: StateFlow<State> = _state init { fetchSummaryData() } fun dispatchAction(action: Action) { viewModelScope.launch { try { when (action) { is Action.UpdateSummary -> { _state.value = _state.copy( content = it ) } } } catch (e: Throwable ) { // エラー処理 } } } private fun fetchSummaryData() { viewModelScope.launch { _state.update { _state.value.copy(isLoading = true ) } when ( val result = repository.getSummaryData()) { is ResultWrapper.Sucess -> { _state.value = _state.copy( content = result.value ) } is ResultWrapper. Error -> { // エラー処理 } } _state.update { _state.value.copy(isLoading = false ) } } } } sealed class Action { object UpdateSummary : Action() } data class State( val userInfo: UserInfo? = null , val content: Content? = null , val isLoading: Boolean = false , ) { companion object { val Initial = State() } } Fragment側でStateを購読します。 class SummaryFragment : Fragment() { private val viewModel: SummaryViewModel by viewModels() private lateinit var binding: FragmentSummaryBinding private lateinit var pagerAdapter: GraphAdapter override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentSummaryBinding.inflate(inflater, container, false ) pagerAdapter = GraphAdapter() return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super .onViewCreated(view, savedInstanceState) lifecycleScope.launchWhenStarted { viewModel.state.collectLatest { state -> // Stateの情報に従ってUIの状態を変えていく setSummaryView(state) } } } private fun setSummaryView(summary: State) { if (summary.contentt != null ) { binding.containerGraph.post { val newList = pagerAdapter.summaryList.toMutableList() newList.add(summary.list) pagerAdapter.list = newList.toList() pagerAdapter.notifyItemRangeChanged(newList.size) } } binding.loadingPanel.isVisible = state.isLoading binding.graphLoadingPanel.isVisible = state.isLoading } 他のファイルにあるUIの状態変更処理は、Fragmentに集約してActionとしてViewModelに伝えるようにしていきます。 例えば、Adapterクラスにある状態変更処理は、 onTapGraphItem のようなコールバックをパラメータとして渡してFragmentで受け取り、 Action.UpdateSummary(data) としてViewModelに伝えています。 // Adapterの実装 class CoordinateImpressionsGraphRecyclerAdapter( private val onTapGraphItem: (CoordinateImpressionDetail) -> Unit , diffCallBack: DiffCallBack = DiffCallBack() ) : RecyclerView.Adapter<CoordinateImpressionsGraphBindingHolder>() { ・ ・ ・ private fun setUpGraphView(chart: BarChart, axis: BarChart, data: WearCoordinateImpressions) { ・ ・ ・ // グラフタップイベント chart.setOnChartValueSelectedListener( object : OnChartValueSelectedListener { override fun onValueSelected(e: Entry , h: Highlight?) { ・ ・ ・ // タップイベントをコールバックとして渡す onTapGraphItem(date.coordinateImpressionDetail) } override fun onNothingSelected() {} }) } ・ ・ ・ } // Fragmentの実装 class SummaryFragment : Fragment() { private val viewModel: SummaryViewModel by viewModels() private lateinit var binding: FragmentSummaryBinding private lateinit var pagerAdapter: GraphAdapter override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentSummaryBinding.inflate(inflater, container, false ) pagerAdapter = GraphAdapter() recyclerAdapter = SummaryAdapter( onTapGraphItem = { data -> // ViewModelを経由してStateの情報を更新する viewModel.dispatchAction(Action.UpdateSummary(data)) } ) return binding.root } ・ ・ ・ private fun inflateSummaryView(summary: Contents?) { if (summary != null ) { binding.containerGraph.post { val newList = adapter.summaryList.toMutableList() newList.add(summary.list) adapter.list = newList.toList() adapter.notifyItemRangeChanged(newList.size) binding.loadingPanel.visibility = View.GONE binding.graphLoadingPanel.visibility = View.GONE } } } } このようにUIの状態はすべてStateが保持していて、UIの状態変更はViewModelを経由してStateを更新するようにします。 そしてStateを購読している箇所で、Stateの状態に従ってUIを構築するように修正します。 以上で、UIの状態変更を一元管理するリファクタリングを行いました。 Stateの情報に従ってJetpack Composeでレイアウトを書いていく 状態の一元管理とJetpack Composeの相性が良い Stateの情報でUIの構築するリファクタリングで当初の「UIの状態変更を一元管理するようにする」という目的は達成できています。 ただ、Jetpack Composeでも単方向データフローパターンを推奨しています。 https://developer.android.com/jetpack/compose/architecture?hl=ja#udf このため、XMLで書かれた現在のレイアウトもJetpack Composeで書き換えもスムーズに行えます。 Jetpack Composeへの書き換え XMLでUIを作る場合、 findViewById() などを用いてUIウィジェットを取得し、 view.isVisible = true のように操作することでUIの状態を変更するのが一般的です。このように手動で操作すると、UIの更新を忘れがちでエラーが発生しやすくなります。Jetpack Composeは宣言的UIフレームワークであり、この手動でUIを操作する複雑さを回避できます。 XMLを用いた場合のサンプルコードでも手動でUIを操作している箇所があり、Jetpack Composeで書き換えることでこのような手動でのUIの操作をなくします。また、RecyclerViewによるリストもJetpack Composeではより少ないコード量で実現でき可読性が向上します。 class SummaryFragment : Fragment() { private val viewModel: SummaryViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { return ComposeView(requireContext()).apply { setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner), ) setContent { SummaryRoute() } } } } @Composable fun SummaryRoute( viewModel: SummaryViewModel = viewModel() ) { // Stateの購読を行う val lifecycleOwner = LocalLifecycleOwner.current val viewState by remember(viewModel.state, lifecycleOwner) { viewModel.state.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED) }.collectAsState(State.Initial) SummaryScreen( viewState = viewState, viewActionDispatcher = { action -> viewModel.dispatchAction(action) } ) } @Composable fun SummaryScreen( viewState: State, viewActionDispatcher: (Action) -> Unit = { _ -> }, ) { // Stateの情報に従ってUIを構築する Scaffold( backgroundColor = MaterialTheme.colors.surface, topBar = { TopAppBar( title = { Text( text = "ToolBar" , fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colors.onSurface ) }, ) } ) { // Stateの状態に従ってプログレスのON/OFFを切り替える if (viewState.isLoading) { Progress() } else { // Adapterクラスは必要なくリストにデータを渡すのみ Contents( userInfo = viewState.userInfo, content = viewState.contents ) } } } @Composable fun Contents( userInfo: User, contents: Contents ) { LazyColumn { ・ ・ ・ } } まとめ 今回、XMLで書かれたレイアウトをJetpack Composeで書き換えました。 リファクタリングによって実装者がUIの状態を管理しやすくなり、想定外の状態変更が起こりづらく機能追加や仕様変更が容易になりました。 また、Jetpack Composeを使ったことでStateの更新で自動的にUIを変更することができるので実装が楽になりました。ソースコードの量も削減でき、可読性を向上させることができました。 最後に ZOZOではAndroidエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。 hrmos.co
アバター
こんにちは、WEAR部バックエンドブロックの小山とSREブロックの繁谷です。 WEAR では日々システムの信頼性を向上させるため改善に取り組んでいます。今回はその中でもSLOに基づいた改善について紹介いたします。 WEARリプレイスの歩み WEARでは2019年から本格的にリプレイスを開始しましたが、当初は専属のSREはおらずインフラ構築など緊急度の高いものをバックエンドのエンジニアや、プロダクト横断のSREが担っていました。 WEARのSREとして活動に割ける時間も短かったためSLI(Service Level Indicator) 1 やSLO(Service Level Objective) 2 の指標もありませんでした。WEARにおけるリプレイスの変遷については こちらのスライド に詳しく載せられているため、ご興味のある方は是非ご覧ください。 WEARの組織における課題 WEARでは2021年4月に専属のSREが発足しましたが、それ以前は以下の課題を抱えていました。 SREはインフラ構築担当、バックエンドはアプリケーション開発担当と分断していた SLOの策定とそれの達成に向けた改善ができていなかったため、漠然とエラー解消やレイテンシ改善の対応をしていた SLOに基づいて、客観的に信頼性改善と機能開発のエンジニアリングリソースを配分できていなかった 本記事の本題である、WEARにおける従来の信頼性改善への取り組みについては以下で紹介します。 WEARバックエンドの改善の歩み 2020年にリプレイス環境ではじめてAPIがリリースされました。リプレイス環境のアプリケーションのモニタリングはDatadogとSentryで行っています。 当時、APIのレイテンシは50〜100ミリ秒のレスポンスを目標としていて、API実装者が責任を持ってモニタリングし目標を満たすよう改善に努めていました。5xxエラーについてはSentryで検知したらアラートをSlackに飛ばしてチーム全体でエラーの原因を調査していました。 リプレイスを進める中で、リプレイス前の旧環境で稼働しているバッチに起因するクエリタイムアウトが頻発し、バッチがサービス全体の可用性、レイテンシに影響を与えているということが分かりました。 バッチリプレイス定例 バックエンドではサービス全体に影響を与えているバッチにスコープを絞って、クエリタイムアウトの原因調査と改修またはリプレイス対応を共有するバッチリプレイス定例を隔週で行っていました。 当時、WEARのバックエンド組織は運用改善チームとサービス開発チームの2チームに分かれていて、バッチリプレイスは、主に運用改善チームがリソースを割いて対応していました。 調査と改善対応については、Datadogのメトリクスを追ったり、 こちら で紹介されているSQL Serverのブロッキング発生時の情報を基にDBアーキテクトと課題ベースで連携して対応を進めていました。 このように従来はバックエンド主体で改善に取り組んでいました。そして、WEAR専属のSREチームが発足してSLOが策定されたことで、徐々にチーム横断でSLOをベースとした信頼性改善への取り組みをはじめることになります。 WEARにおけるSRE SREチームができてからすぐにSREとしての動きができていたわけではなく、SREチーム内でも各々がSREへの認識に齟齬がある状態でした。そのため、SREの定義や責務などを SRE本 や各社の事例を参照しながら共通認識を持つことからはじめました。 そして今何ができていて何ができていないかを Google社の記事 を参考にしてチームで議論し、SLOがないため信頼性を監視できておらず行為主体性も発揮できていないことを確認しました。 WEARにSREが存在しないころの動きを踏襲していたため、依頼されて動くと言った受け身の姿勢になっていました。よって、SLOを策定して信頼性を監視し客観的に信頼性改善のためのエンジニアリングリソースの配分について提案できる、行為主体性のある組織となることを目標としました。 SLI, SLOの決定 目標が決まったのでまずはSLIやSLOを決めます。 SLIやSLOの決定はとにかく素早く運用に乗せることを意識しました。 Google社の発表 でも述べられているように、SLOがない状態よりシンプルなものでも運用できている状態が望ましいからです。 よって、本来であればCUJ(Critical User Journey) 3 を検討することから始めるべきですが、一般的にSLIとして用いられシステム全体のエラーレートとレイテンシをSLIとしました。そして運用しながら改善することにしました。 WEARにおけるSLO Google社の記事 や サイボウズ社の記事 を参考にとにかくシンプルなSLOから導入することにしました。 目標 可用性 1か月のリクエストのうち、最低でも99.5%の割合で503以外のレスポンスを返す レイテンシ 1か月のリクエストのうち最低でも50%は500ミリ秒以内にレスポンスを返し、99%は3000ミリ秒以内にレスポンスを返す APIの目標としてはかなり緩いものですが、適宜調整する前提で最初は達成できるラインを設定し改善効果を実感できることを目指しました。 SLOモニターのコード化 当初はSLOのモニターを CloudFormationのパブリック拡張機能 で Datadog社が提供している拡張 を使っていました。しかし、後に互換性の問題から拡張をアップデートしづらくなりTerraformに移行しました。 以下にAWSのALBにAPIサーバのターゲットが接続されている構成のTerraformのコード例を示します。 可用性 ALBで受けた全リクエストからALBで5xxを返したものとターゲットで5xxを返したものを引いてエラーレートを計算します。 resource "datadog_service_level_objective" "api_error_rate" { name = "API Error Rate" query { numerator = "sum:aws.applicationelb.request_count{name:api}.as_count() - sum:aws.applicationelb.httpcode_elb_5xx{name:api}.as_count() - sum:aws.applicationelb.httpcode_target_5xx{name:api}.as_count()" denominator = "sum:aws.applicationelb.request_count{name:api}.as_count()" } thresholds { target = "99.5" timeframe = "30d" } type = "metric" } レイテンシ 99th percentile, 50th percentileのレイテンシを監視するモニターを作成しSLOのリソースに紐づけます。 resource "datadog_service_level_objective" "api_latency" { monitor_ids = [ datadog_monitor.api_latency_p99.id, datadog_monitor.api_latency_p50.id ] name = "API Latency" thresholds { target = "99" timeframe = "30d" warning = "0" } type = "monitor" } resource "datadog_monitor" "api_latency_p99" { escalation_message = "" evaluation_delay = "900" include_tags = "true" locked = "false" message = "{{#is_alert}}{{name}}のレイテンシが{{threshold}}を超えました。{{/is_alert}}{{#is_recovery}}{{name}}のレイテンシが正常値に戻りました。{{/is_recovery}}" monitor_thresholds { critical = "3" } name = "Api Latency p99" new_group_delay = "0" new_host_delay = "300" no_data_timeframe = "0" notify_audit = "false" notify_no_data = "false" priority = "0" query = "avg(last_1h):avg:aws.applicationelb.target_response_time.p99{name:api} > 3.0" renotify_interval = "0" renotify_occurrences = "0" require_full_window = "true" tags = [] timeout_h = "0" type = "query alert" } resource "datadog_monitor" "api_latency_p50" { escalation_message = "" evaluation_delay = "900" include_tags = "true" locked = "false" message = "{{#is_alert}}{{name}}のレイテンシが{{threshold}}を超えました。{{/is_alert}}{{#is_recovery}}{{name}}のレイテンシが正常値に戻りました。{{/is_recovery}}" monitor_thresholds { critical = "0.5" } name = "Api Latency p50" new_group_delay = "0" new_host_delay = "300" no_data_timeframe = "0" notify_audit = "false" notify_no_data = "false" priority = "0" query = "avg(last_1h):avg:aws.applicationelb.target_response_time.p50{name:api} > 0.5" renotify_interval = "0" renotify_occurrences = "0" require_full_window = "true" tags = [] timeout_h = "0" type = "metric alert" } アラート アラートにはエラーバジェットの枯渇とバーンレートの上昇を設定します。エラーバジェットとバーンレートの説明はSRE本に記載されているため省略します。バーンレートのターゲットは SRE本の推奨値 の14.4を設定します。 エラーバジェットとバーンレートのアラートも同様にコード化します。 resource "datadog_monitor" "api_error_rate_error_budget" { name = "API Error Rate Error Budget Alert" type = "slo alert" message = <<EOT {{#is_alert}}APIのエラーレートにおけるエラーバジェットが枯渇しました。{{/is_alert}} {{#is_warning}}APIのエラーレートにおけるエラーバジェットの消化率が{{warn_threshold}}を超えました。{{/is_warning}} Notify @slack-$ { local.slack_channel_name } EOT query = <<EOT error_budget("$ { datadog_service_level_objective.api_error_rate.id } ").over("30d") > 100 EOT monitor_thresholds { critical = 100 warning = 70 } tags = [] } resource "datadog_monitor" "api_error_rate_burn_rate" { name = "API Error Rate Burn Rate Alert" type = "slo alert" message = <<EOT {{#is_alert}}<!here> APIのエラーレートにおけるバーンレートが{{threshold}}を超えました。{{/is_alert}} {{#is_recovery}}APIのエラーレートにおけるバーンレートが回復しました。{{/is_recovery}} Notify @slack-$ { local.datadog_slack_channel_name } EOT query = <<EOT burn_rate("$ { datadog_service_level_objective.api_error_rate.id } ").over("30d").long_window("6h").short_window("1h") > 14.4 EOT monitor_thresholds { critical = 14 . 4 } tags = [] } SLOの運用 次にSLOを運用して信頼性を改善するための会議を設計します。 会議体としては元々実施していたバッチリプレイス定例をリニューアルして、SLO定例として実施しています。従来はバックエンドチームのみでしたが、現在はSREチーム、Webフロントエンドチームも合流し、信頼性に関わるエンジニアが参加してSLOの達成率やパフォーマンスの低い箇所について議論しています。またこの場でSLOの見直しも行っています。 SLOが適切であればSLOのアラートが飛んだタイミングでデプロイを制御して改善に工数を割くのが理想的な運用と考えますが、運用しながら改善していく前提で、粗い状態で設定しているためこのようにしています。 具体的な会議の内容については エウレカ社のパフォーマンス定点観測会 の内容を参考にさせていただきました。SLOやAPM、CPUやメモリ、AWSのコストまで信頼性に関係するメトリクスを一覧で見られるDatadogのダッシュボードを作成します。会議ではそれを眺めながら改善点を議論します。改善点が挙がればIssueにおこして進捗を管理します。 Datadogダッシュボードの内容がこちらです。 SLO 左にSLOのモニターを配置し右はAPMでエンドポイントごとのエラーレートやレイテンシ内容を表示させています。エラーバジェットが枯渇したサービスについてAPMを見ながら原因の議論ができるようにしています。 DB Datadogの データベース モニタリング を用いてアラートよりも長期的な目線で信頼性に影響する動きがないかを確認しています。 リソース リクエスト数とCPU使用率、メモリ使用量を確認し、長期目線でリソースの使用状況が適切かを確認しています。リソースが枯渇しそうかや、逆に過剰にリソースを確保していないかも確認しています。 コスト コストの大きいサービス順に棒グラフで表示させ、長期的な目線でコスト状況に異変がないか確認しています。パフォーマンス定点観測会のスライドにもあるようにDatadogにメトリクスを集めて1枚のダッシュボードを眺めることで信頼性に関係する情報を得られるように設計しています。 SLOの運用で得られた効果 SLOを策定してから実際に半年間運用してみて、エンジニアに限らずビジネスのメンバーも含めてチーム横断で信頼性へ意識が向くようになってきており、効果が表れていると感じています。 WEARは フリマ機能 や コーディネート動画機能 をはじめとする新機能のリリースを積極的に行なっています。 一方で、リプレイス前の旧環境に依存する障害、負荷も課題となっており、これらの課題に対しても改善が必要です。機能開発とシステムの信頼性改善の両方がサービスにとって重要なため、リソース配分を適切に行い、最大化されることは組織にとってとても重要です。 SLOの導入により、サービスレベルを下回る影響度であれば改善を行い、そうでなければ過剰に改善にはリソースを割かず機能開発に注力するような意思決定ができるようになったので効果があったと言えるでしょう。 今後の課題 しかし、残された今後の課題もあります。現在のSLOのターゲット値は達成できるラインで設定しているため、目標を達成したからといってユーザーが満足する訳ではありません。 SLOの主眼は究極的には顧客体験の改善であるため、ユーザーが満足している状態を保つために、CUJの策定と改善、またユーザーのインサイトを反映させたSLOの閾値を追及していくことが今後の課題です。 BizDevOpsにむけて 弊社瀬尾の記事 にもありますが、我々はBizDevOpsを体現し組織として密に連携することを目指しています。我々はシステムの信頼性やSLOの状態はプロダクトの戦略において重要で、エンジニアだけが知っていれば良いということではないと考えています。実際にSLOで見ているエラーレートやレイテンシは CVR や 離脱率 に影響があるという調査も挙がっています。 よって、ビジネスのメンバーと一緒に確度の高い戦略を打ち出すために都度SLOのサマリをレポートしています。これは始めたばかりですがビジネスのメンバーからは好感触を得ているので今後もWEARをより良くしていけると確信しています。 おわりに WEARにおけるSLOを用いた信頼性改善の取り組みについて紹介しました。SLOに関心のある皆さんの参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com hrmos.co hrmos.co SLI:外部からシステムに対して期待される可用性に関して設定された目標(引用: https://newrelic.com/jp/topics/what-are-slos-slis-slas ) ↩ SLO:システムの可用性を特定するための主要な測定値およびメトリクス(引用: https://newrelic.com/jp/topics/what-are-slos-slis-slas ) ↩ CUJ:ユーザーが1つの目的を達成するために行うサービスとの一連のインタラクション(引用: https://cloud.google.com/architecture/defining-SLOs ) ↩
アバター
こんにちは!バックエンドチームマネージャーの @tsuwatch です! 2022/9/8〜10に三重県にて開催されたRubyKaigi 2022でプラチナスポンサーとして協賛し、スポンサーブースを出展しました。 technote.zozo.com technote.zozo.com 弊社からは WEAR を開発するバックエンドエンジニア、SRE、PdMなど合計10名ほどが現地で参加しました。 我々が運営しているファッションコーディネートアプリ「WEAR」のバックエンドはRuby on Railsで開発しています。2013年にVBScriptで作られたシステムですが、2020年くらいからVBScriptのシステムをコードフリーズし、リプレイスをはじめました。現在もリプレイスを進めながら、新規の機能もRubyでどんどん開発しています。 また弊社ではRubyコミッタのsonotsさんがいたり、顧問としてMatzさんにもご協力いただいており月に一度、Matzさんに何でも聞く会をやっていたり、積極的にRubyを活用しています。 今回もエンジニアによるセッションの紹介とブースでの取り組みについて紹介します。 エンジニアによるセッション紹介 弊社エンジニアによるセッションの紹介をします。 How fast really is Ruby 3.x? github.com 高久です。Fujimoto Seijiさんのセッション「How fast really is Ruby 3.x?」についてご紹介します。 このセッションでは、Rubyの過去バージョンと比較しRuby 3.xではどれくらい早くなったかを、実際のアプリケーションを用いて検証した結果を報告しています。検証対象のアプリケーションはFujimoto Seijiさんがコミッターを務めるFluentdです。 検証の背景について。まず前提としてRuby 3は「Ruby 3×3」という「Ruby 2.0の3倍早くする」ことを目指して開発が進められました。そして過去のRubyKaigiでも同様に実際のアプリケーションを使った検証の話がいくつかありました。しかし、どれもRailsアプリで作られたものでは3倍にならないという話でした。なぜならRailsアプリは基本的に、アプリケーションの多くの時間を占めているのがRubyの処理ではなく、DBなどの外部処理によるものだからです。Rubyを早くしたとしても、その他にかかる時間が大きいため、全体の処理時間の短縮化に大きく寄与するものではありませんでした。 そこで今回、多くの処理をRubyで行なっているFluentdを対象に測定してみたらどうなるか? というのがテーマとなっています。 検証は読み込ませるファイルごとに2パターン行なっています。 LTSVファイル nginxログファイル 検証した結果Ruby 1.9と比較しRuby 3.2(YJIT有効)は、LTSVファイルで約3.15倍、nginxログファイルで約2.5倍のスループットが出ることを確認できました。Ruby 1.9と3.2の比較にはなりますが、概ね「Ruby 3×3」は実現しているのではということが、Fujimoto Seijiさんが伝えたかった内容になります。 セッションでは、更に他の言語と比較してどうかを述べていました。気になる方は是非スライドをご確認ください。 コミッターさんたちがRubyをより良くするために開発をし、Rubyが日々進化していることを実感した発表でした! Make RuboCop super fast speakerdeck.com 小山です。RuboCopメンテナの@koicさんによる「Make RuboCop Super fast」の発表を紹介します。 RuboCopは2012年4月21日がファーストコミットで今年10周年を迎えました。RuboCopは現在1.36.0が最新バージョンでありますが、2.0系リリースに向けてマイルストーンを掲げており、この発表はそのマイルストーンのうちの1つであるRuboCop2×2に関する発表でした。RuboCop2×2はRuby 3×3で目指しているように、RuboCopの速度を1.0系と比較し、2倍を目指すというものです。 RuboCopはCaching、Multi-cores、Reduce unused requires、Daemonizeの4つのアプローチで高速化を図っています。 Cachingはかねてから提供されていて、1回検査したコードはデフォルトで ~/.cache/rubocop_cacheに保存しています。 Multi-coresは1.19からデフォルトで並列検査するようになり、1.32から並列でオートコレクションするようになりました。8 core CPUかつHyper-Threadingを使用して約1,300ファイルに対して直列実行と並列実行を比較した場合、直列実行は61秒で完了したのに対し、並列実行は10秒で完了したそうです。 Reduce unused requiresは --onlyオプションを付与したとしてもすべてのCopが読み込まれてしまう問題がありました。require 'rubocop' の改善により高速化を実現しており、セッションの本論で話されていたserverモードと一部関連があります。 Daemonize(serverモード)がセッションで一番厚くお話されていた内容になります。serverモードは #10706 で対応されて1.31から導入されています。使用することでrubocopコマンドを実行する度にプロセスを起動するのではなく、プロセスを常駐することでモジュールの読み込みが初回のみになります。Client/Serverモデルを採用して高速化を実現していたサードパーティ製のgem、rubocop-deamonを統合することで、RuboCopのserverモードは高速化されています。Client/Serverモデルとは、Server側の初回プロセスであらかじめモジュールを読み込んでおいて、Client側がすでにモジュールを読み込んでいるサーバーに接続するアプローチです。またrubocop-daemonをどのように統合したか、serverモードの設計、具体的な使用方法についてはセッション内で詳しく解説されていますので気になる方は是非スライドをご覧ください。 成果としては、moduleの読み込みを必要なもののみにし、serverモードを実装したことで850倍高速化されています。驚くべき成果です。 RubyのDX向上に、すぐに繋がると実感した素敵な発表でした。まだ不安定な挙動が残っているとの補足はありましたが、RuboCopのバージョンを上げて積極的に使っていきたいです! The Better RuboCop World to enjoy Ruby speakerdeck.com 三浦です。私からはOhba Yasukoさん(@nay3)によるセッション "The Better RuboCop World to enjoy Ruby" について紹介したいと思います。技術的なセッションからは少し視点を変えた、RuboCopとうまく付き合っていくにはどうしたら良いかを考える内容になります。 RuboCopはRubyの静的コード解析ツールの1つで、コーディング規約を守れていないコードを簡単に確認でき、自動で修正できる便利なツールです。CIで回して事前に修正しておくことでレビューの負担軽減にも繋がります。 ただこのRuboCopのルールは "状況に合わないこと" もあります。例えば "Naming/PredicateName" というルールは、has , is , have_ といった特定の接頭辞のメソッド名をチェックし、接頭辞を排除したメソッドを使うよう警告します。 # bad def is_child? end def has_child? end # good def child? end ただ child? にしてしまうと、どちらとも捉えられるような曖昧なメソッド名になってしまいます。 "is child" なことをチェックするメソッド "has child" なことをチェックするメソッド このようにRuboCopの中にはルールとして間違ってはいないけど状況によっては合わないルールがいくつか存在しています。 初心者〜初級者のエンジニアの場合、状況に合っていないルールなのかを判断するのは難しいです。そのためRuboCopの警告に忠実に従ってコーディングをしてしまい、その結果かえって読みにくいコードになってしまう場合があります。 レビューで指摘された軽微な修正でも直そうとするとRuboCopのルールに引っかかってしまい、複雑な実装になってしまったなんてこともよくあります。(私もありました、、、) このような状況に合わないルールで振り回されてしまうのは、開発速度を低下させる一因にもなります。 RuboCopの全てのルールは、無効にしたりデフォルトとは異なる方針に変更できます。また、特定のコードで特定のルールを無視できます。しかしこのルールの設定の判断はルールの妥当性を判断できる技術力と経験が必要です。初心者〜初級者のエンジニアにとってはこの判断はなかなか難しいものです。かといって経験者が1つ1つのルールを必要か毎回確認するのも大変です。 そこで提案されたのがルールを大きく2つのレベルに分けて考えることでした。 強制レベル:ほぼ100%の状況で適用しても問題がないようなルール 参考レベル:なるべく多くの改善ポイントに気付けるような理想的なルール この2つのレベルに合わせてrubocop.ymlの設定ファイル自体を分けておきます。強制レベルのルールは現状通りCIなどで警告を出し、修正を強制します。参考レベルのルールはCIを回しますが、参考情報として表示するだけに留めておきcommitの禁止やマージの禁止といった強制力は持たせないようにします。 このように参考レベルのルールを作っておくと全てのルールに従おうとして不自然なコードを作ることを防ぐことができるので良いのではということでした。Ruby初心者にとってのつまづきポイントなどもたくさん紹介しており、うなずきたくなるような共感できる内容でした。スライドのイメージ図は全て画像生成AIのMidjourneyを使って生成したものだそうで笑いも起こる楽しいセッションでした。 Implementing Object Shapes in CRuby @tsuwatch です。個人的におもしろそうだなと思っていたObject Shapesというオブジェクトのプロパティを表現する手法について書こうと思います。Object Shapesを導入することでインスタンス変数のを見つけるときのキャッシュヒット率の増加とランタイム時のチェックを減らすことができ、JITのパフォーマンスを向上させるというものです。また、この手法はTruffleRubyやV8で採用されているそうです。 詳細はチケットにあるのでご覧ください。 bugs.ruby-lang.org Object Shapesとは class Foo def initialize # Currently this instance is the root shape (ID 0) @a = 1 # Transitions to a new shape via edge @a (ID 1) @b = 2 # Transitions to a new shape via edge @b (ID 2) end end class Bar def initialize # Currently this instance is the root shape (ID 0) @a = 1 # Transitions to shape defined earlier via edge @a (ID 1) @c = 1 # Transitions to a new shape via edge @c (ID 3) end end foo = Foo .new # blue in the diagram bar = Bar .new # red in the diagram 例えばこういうコードが存在したときにObject Shapesは以下のようなツリー構造を構築します。 インスタンス変数の遷移をツリー状に構築することで、同じ遷移をするクラスはキャッシュを利用できます。別のクラスをnewしたときにも元のShapesの差分のみ作れば良いというわけです。 class Hoge def initialize # Currently this instance is the root shape (ID 0) @a = 1 # Transitions to the next shape via edge named @a @b = 2 # Transitions to next shape via edge named @b end end class Fuga < Hoge ; end hoge = Hoge .new fuga = Fuga .new 現在はクラスをキャッシュキーとして使用しており、その場合はこのコードではキャッシュヒットさせることはできません。Object Shapesを導入することでクラス依存しないキャッシュを実現し、キャッシュヒット率を上げることができます。 複雑そうですが、効果がありそうなパフォーマンスチューニングです。キャッシュの構造や実際にどれくらいパフォーマンスが改善するのか今後もウォッチしていこうと思います。 Method-based JIT compilation by transpiling to Julia speakerdeck.com 近 です。@KentaMurata氏のメソッドベースのJust-In-Timeコンパイルへの新しいアプローチとして、インフラストラクチャにJulia言語を使用した背景と仕組み、特徴についてのお話を紹介します。Numo::NArrayやRed Arrowを使えば大きな数値計算が出来つつありますが、MJITやYJITが利用できてもあまり高速ではないという問題があるとのことでした。 理由として、これらのJITコンパイラがRubyの全てのセマンティクスを保持するためです。Rubyでは全てのメソッドが再定義可能で、再定義されたメソッドは直ちにコード実行に影響を与えます。例えば、以下のようなループの途中でも、injectメソッドや+演算子(メソッド)が再定義されていないかの確認が毎回行われます。 s = ( 1 .. 10 ).inject { |a, x| a + x } 以上の特徴によって高速性が失われていましたが、数値計算の場合このRubyの動的性は数値計算アルゴリズムでは殆ど無意味なので、これを無視して計算を最適化できるほうが良いのでは? というのがこのセッションの議題になります。 しかし、現在ではこのような最適化を行うためには、アルゴリズムをCの拡張ライブラリに書き換える必要があります。これをせずに、高速化を行う手段として挙げられたのがJuliaでした。Juliaはデータ処理や数値計算に向いていて高速な言語という特徴があります。 ここで、比較としてPythonの世界での解決策であるNumbaというライブラリの紹介がありました。NumbaはCPython用のJITコンパイラであり、PythonとNumPyのコードの一部を高速な機械語に変換します。もう少し具体的にコンパイルの流れを書きます。 CPythonのバイトコードを解析 NumbaIRを生成して書き換え 型を推論 型付IRに書き換え 自動並列化の実行 LLVM IRの生成 ネイティブコードにコンパイル という流れで、CPythonを型付の中間表現に変換してネイティブコードを生成しています。 またNumbaには2つのモードがあります。オブジェクトモードというCPythonインタプリタのC APIを使用しCPythonの完全なセマンティクスを保持するモード。もう1つはnopythonモードというfloat64やnumpy配列などの特定のネイティブデータ型に特化した小さくて効率の良いネイティブコードを生成するモードです。ざっくり言うと処理をPython経由で行うか、CPUに直接命令するかの違いになります。 このNumbaのnopythonモードのようなものをRubyのJITコンパイラでも実現する手段として登場するのが、今回のセッションの本題であるJuliaでした。まず、RubyでNumbaライクなJITコンパイラを表現すると以下のようになります。 Rubyメソッド ASTの生成 最適化 バイトコードの生成 CRubyのバイトコード IRの生成 タイプ推論 最適化 型付けされたIR LLVM IRの生成 ネイティブコードにコンパイル これと同じようなことをやっているのが、Juliaになります(JuliaはNumbaともだいたい同じ機構が動いています) Juliaコード ASTの生成 Julia AST タイプ推論 IRの生成 Julia typed IR LLVM IRの生成 ネイティブコードにコンパイル そこで、RubyをJuliaへトランスパイルし、それ以降をJuliaのJITコンパイラに実行してもらって高速化します。 Rubyメソッド Juliaコード Julia AST Julia typed IR LLVM IRの生成 ネイティブコードにコンパイル Juliaは最適化されたネイティブコードを生成してくれるため、高速です。この辺りの解説はセッションのスライド図と発表が大変分かりやすく、面白かったので是非資料や動画をご覧ください。 次に、RubyからJuliaへのトランスパイル方法についての紹介がありました。トランスパイルには、yadriggyというRubyメソッドのASTを構築し、構文と型をチェックするgemを使用しているとのことでした。セッションでは具体的なコードを紹介していましたが、大雑把にいうとRubyコードからASTを構築し、それを使ってJuliaコードを生成しているようでした。 Rubyコード → Ruby AST → 型チェッカーでノードに対して型付け → Juliaコード これによって、Rubyの機械への命令を最適化させています。 またRubyとJuliaで実装の違うものがいくつかあり(例ではRangeを挙げていました)これらの対応をするには自分で変換コードを書く必要があるとのことでした。 これらの高速化の実験比較として、以下の計算をしていました。 マンデルブロ集合 モンテカルロ法によるπの近似 クイックソート 畳み込み ドット積 それぞれの結果は以下のようになっていました。RubyはおそらくYJITが有効。 マンデルブロ集合 Ruby:平均3.326ms Ruby to julia:平均171.667μs モンテカルロ法によるπの近似 Ruby:平均106.368ms Ruby to julia:平均8.851ms クイックソート Ruby:平均7.551ms Ruby to julia:平均1.937ms 畳み込み(結果が複雑なので省略) ドット積 Ruby (N=10000) 平均468.608μs Ruby to julia (N=10000) 平均3.759ms Ruby to julia (N=10000, T=Float64) 平均10.651μs これらのように、一部の結果を除いて超高速に計算することが可能になるようでした。 紹介は以上になります。発表が分かりやすく、深掘りしたくなるような興味深い内容でした!Rubyの高速化についてこういう方法もあるんだなと、とても学びが多かったです。RubyKaigiではこのような発表がいくつもあり、すごくワクワクしました。 スポンサー 今年も2019年に続いてスポンサーブースを出展しました。 こちらは今回のために作成したノベルティたち。すごくかわいいですね!来てくださった方にもとてもご好評いただきました。とてもこだわって作成したので嬉しかったです。 Tシャツもデザイナーさんにデザインしていただいた魂のこもったTシャツです!普段でも全然着られるのではないでしょうか?「WEAR」アプリをインストールしていただいてる方にお配りしていたのですが、1日目でほとんどなくなってしまいました。ありがとうございます!着てください! またブースでは、会期中の3日間『エンジニアのファッション事情を大調査!』と銘打ち、毎日異なるアンケートを取っていました。 服を買うなら? 実店舗 28票 ECサイト(ZOZOTOWN) 15票 ECサイト(ZOZOTOWN以外) 9票 その他 3票 まだまだ実店舗が多いですね!次はありがたいことにZOZOTOWNでした!ありがとうございます!その他の方はご家族やパートナーの方が買っているとの声もありました。 コーディネートはどうやって決める? 己のセンスを信じる 86票 雑誌やネット 56票 その他 45票 周りの人を見る 19票 己のセンスを信じる人が多かったです。エンジニアはやはり我が道を行くのでしょうか。 コーディネートのパターン数は? 1 ~ 5 43票 着るときに考える 41票 6 ~ 10 37票 10より多い 9票 票がわかれました。ちなみに僕は着るときに考える派です。でもそんなに服がないのでいつもだいたい同じ格好になってますね。 みなさんアンケートに回答していただきありがとうございました! 我々はファッションの悩みを解決することをミッションとして掲げています。みなさんが日々どのようにファッションと向き合っているのか、いろいろお話を聞くことができました。「WEAR」に要望をくださったり、使ったことがない人にご紹介できたりしました。ファッションへのモチベーションが高い人も、そこまで高くない人もそれぞれ悩みはあると思います。「WEAR」をこれからも良いサービスにしていきます! 最後に ZOZOではセミナー・カンファレンスへの参加を支援する福利厚生があり、カンファレンス参加に関わる渡航費・宿泊費などは全て会社に負担してもらっています。ZOZOでは引き続きRubyエンジニアを募集しています。以下のリンクからぜひご応募ください。 hrmos.co また、メドピアさん、ファインディさんと合同で9/27に「After RubyKaigi 2022」を開催します。ぜひご参加いただければと思います。 findy.connpass.com おまけ 楽しんでいる様子です。Wポーズ! PdMのお二人! 昔の仕事仲間や他社の方々、ユーザーさんと交流できて楽しそうでした。 Matzさんと! 全員集合したかったですね。 RubyKaigiではおいしいご飯が食べられます! アンドパッドさんの二進数足し算RTAで弊社のjeuxd1eauが5位!なんとPdMの方です!エンジニア、本部長も敗北しました。 待ちに待ったオフラインでのカンファレンスで久しぶりに良い刺激をもらいましたし、とても楽しかったです。来年は松本市でお会いしましょう!それではまた次回。
アバター
はじめに こんにちは、ML・データ部推薦基盤ブロックの宮本( @tm73rst )です。普段は主にZOZOTOWNのホーム画面や商品ページにおいて、データ活用やレコメンド改善のプロダクトマネジメントを行っております。 近年ビックデータ社会と言われる中、データドリブンという言葉をよく耳にします。ZOZOTOWNのホーム画面は、ホーム画面の各パーツごとにViewable Impression(以降、view-impと表記)を取得できるようになったことでデータドリブンな評価や意思決定が促進されました。 本記事では特にZOZO独自のview-impの設計とview-impを用いてどのようにホーム画面を改善しているかについて紹介します。データドリブンな施策の推進を検討している方に向けて、本記事が参考になれば幸いです。 本記事におけるViewable Impressionの定義 本記事ではホーム画面のview-impの定義を「 ホーム画面の各パーツに対して、ユーザーが実際に目に触れて認識した閲覧ログ 」とします。そのため、パーツの内容を把握できない瞬間的な表示や部分的な表示はview-impの発火と見なさず、表示時間や表示面積などの発火条件を満たしたときに発火と見なします。具体的な発火条件に関してはこの後のセクションで説明します。 はじめに 本記事におけるViewable Impressionの定義 背景 ZOZOTOWNホーム画面の運用について 課題 課題解決へのアプローチ データドリブンを実現するための取り組み モジュールのKPI設計 ホーム画面におけるKPI分解とモジュールの役割 モジュールのメインKPIの決定 Viewable Impressionの設計 発火範囲 記録間隔 再記録 KPIモニタリング用の定常レポートの作成 実装上の工夫 バッファリング&リトライの設計 ログの内製化 効果 良質なモジュールの生産効率の向上 モジュールの並び順改善 今後の展望 「良いモジュール」の分析と要因追求 ユーザーセグメントの分解 ホーム画面におけるパーソナライズ強化 おわりに 背景 ZOZOTOWNホーム画面の運用について ZOZOTOWNのホーム画面はバナーや検索機能をはじめとする多くのパーツで構成されています。中でもページの大半を占めるのが「モジュール」と呼ばれるパーツになります。モジュールは「タイトル、一覧ページ、コンテンツ」の3つのパーツで構成されています。なお、タイトルの上部にサブタイトルが付くモジュールや一覧ページがないモジュールなどもあります。 モジュールは多数ありますが訴求の種類で分類すると、現状は大きく以下の4つになります。 モジュール名 説明 導線モジュール 閲覧した商品を軸としたリターゲティング系モジュール 企画モジュール トレンド商品や企画商品など企画チームが考案したモジュール 広告モジュール 広告商品用モジュール レコメンドモジュール ユーザーごとにパーソナライズされたモジュール ホーム画面では上記のモジュールを組み合わせてユーザーにさまざまな商品を提示しています。また定期的にビジネスチームと連携しながら、モジュールの並び順を調整したり別のモジュールに差し替えたりといった運用をしています。 課題 モジュールの運用の理想的なサイクルは、実際に表示したモジュールを正しく評価し、その結果を次の運用に活用することだと考えています。しかし、 これまではファッションに関する市場調査や他社の事例を参考とする定性的な意思決定に基づいたモジュールの運用をしており、定量的な意思決定に基づいたモジュールの運用はできていませんでした 。もちろん最近のファッショントレンドや他社の取り組みを把握し、それを施策として反映することは重要です。とはいえ、新たなモジュールを次々に作ったとしてもそのモジュールを正しく評価できないとモジュールの良し悪しが判断できず、モジュールの改善に繋げることができません。 定量的な意思決定に基づいたモジュールの運用ができていなかった理由として以下の3つが考えられます。 良いモジュール・悪いモジュールを定量的に判断する基準がない モジュールを評価するためのデータが足りていない 定常的にモジュールに関する数値を確認できるレポートが存在しない 上記の課題を解決することでデータドリブンな評価や意思決定に繋がると考え、各課題に対して以下のアプローチをとりました。 課題解決へのアプローチ 前述した課題を解決するためのアプローチは以下の3つです。 モジュールの良し悪しを評価するKPIの設計 モジュールのKPIに必要なview-impの設計 KPIモニタリング用の定常レポートの作成 それぞれの取り組みについて具体的に紹介していきます。 データドリブンを実現するための取り組み モジュールのKPI設計 ホーム画面におけるKPI分解とモジュールの役割 ZOZOTOWN全体ではGMVをKGIとしており、またホーム画面では以下の3つをメインKPIとしています。 ホーム画面のメインKPI 説明 ホーム画面経由の受注金額 ホーム画面に表示されている商品をクリックしたセッション内での受注金額 ホーム画面ランディングセッション直帰率 ホーム画面の直帰セッション数÷ホーム画面ランディングセッション数 コンテンツクリックユーザーあたりホーム画面経由の受注金額 ホーム画面経由の受注金額÷コンテンツクリックユーザー数 ホーム画面のメインKPIをモジュールのメインKPIに分解したいのですが、モジュール単位で考える上での注意点が3つあります。 「ホーム画面ランディングセッション直帰率」などホーム画面に関するKPIをそのままモジュールの評価に使用した場合、並び順が異なるモジュール間での比較を正確に行えない 定期的にモジュールは変更されるため、収集できるデータ量の少ない「購入」や「カート追加」などの指標はサンプル数不足になる可能性がある コンテンツには記事やショップページなど商品以外のパターンも存在する このため、単純にホーム画面のメインKPIをモジュールのメインKPIに分解してしまうと、モジュールの評価を正確に行えない可能性があります。例えば「モジュール経由の受注金額」をメインKPIとすると、並び順の異なるモジュール間の比較ができなくなってしまいます。またサンプル数不足によって統計的な判断が行えない場合もあります。 そこで正確なモジュールの評価ができるKPIを設計するために、モジュールの役割を考えました。モジュールはホーム画面の大部分を占めており、コンテンツや一覧ページなどを通じて他のページへの導線が多数存在します。したがってモジュールの役割は「 ユーザーにモジュールを通じてZOZOTOWN内を回遊してもらい、探している商品や趣味嗜好に合う商品を見つけてもらうこと 」だと考えました。 モジュールのメインKPIの決定 前述した注意点と役割を踏まえて、モジュールのメインKPIを「 次ページ遷移率(以降、CTRと表記) 」としました。また、サブKPIとしてはコンテンツクリック数、「すべて見る」クリック数、カート投入数、モジュール経由の受注金額などを設定しています。 以下にメインKPIとサブKPIをまとめています。 種類 指標 メインKPI CTR サブKPI コンテンツクリック数 「すべて見る」クリック数 view-imp数 カート投入数 モジュール経由の受注金額 モジュール経由の注文商品数 コンテンツクリックあたりカート投入数 CTRの定義は以下です。 CTR = コンテンツクリック数 ÷ view-imp数 CTRはモジュールの組み合わせによる影響やポジションバイアスが存在すると想定できますが、それらを除けば単純に並び順が異なるモジュール同士を比較できる指標です。もちろん、モジュールの目的に応じてCTRではなく他の指標をメインKPIとする場合もありますが、ほとんどのモジュールはCTRで評価できます。CTRを高めることで商品ページや検索ページへの遷移が増え、ユーザーが商品の購入を検討する機会が増えます。間接的ではありますが、これは最終的にZOZOTOWN全体のKGIであるGMVに貢献できると考えています。 Viewable Impressionの設計 ここからはCTRを求める際に必要なview-impの具体的な仕様について紹介します。補足として、これ以降の話はZOZOTOWNアプリのみを対象(Web版は除外)とします。また、view-impはコンテンツやバナーなども考えられますが、本記事ではモジュールのview-impを指すことにします。 以下ではZOZOTOWNホーム画面におけるview-impの仕様を3つの項目に分けて紹介します。 発火範囲 記録間隔 再記録 発火範囲 view-impの発火範囲の条件は「 モジュールの高さが50%以上画面に表示されたとき 」としています。閾値を50%に設定している理由は以下です。 1モジュール全体の画面に占める表示領域の割合が大きいため ZOZOTOWNアプリではモジュールのコンテンツを2段組みで表示しており、上段・下段のいずれかを閲覧した場合でもモジュールを閲覧したものとして判定するため また、この条件は言い換えると「 モジュールの高さの中心が画面に表示されたとき 」と解釈できるため、実装上の複雑さを軽減している利点もあります。 以下の図はview-impの発火範囲の条件を満たしたパターン(上段3つ)と満たしていないパターン(下段2つ)の例を示しています。赤い枠の領域が画面に表示されているモジュールの領域を表しており、青いラインがモジュールの高さの50%の位置を表しています。青いラインが赤い枠内にある場合、発火範囲の条件を満たします。 発火条件を満たしているパターン 発火条件を満たしていないパターン 記録間隔 view-impは前述した発火範囲の条件に加えて、「 1秒以上モジュールが画面上に表示されていること 」も発火の条件としています。具体的には、 100ms間隔でモジュールの高さの中心が画面に表示されているか確認し、10回連続で観測できればview-impのログとして記録 します。記録間隔を100msとしている理由は、「デバイスでの計算処理の負荷を軽減するため」と「 人間の反応時間 (人間が事象を認知するまでの時間)は最小で100msであり、100msより短い間隔でデータを収集する必要はないため」です。この記録方法によって静止状態だけでなくスクロール中も集計できます。 再記録 再記録とは1度view-impのログとして記録されたモジュールが再度記録されることを指します。なお、view-impの発火範囲内であればスクロールしても再記録されません。再記録されるパターンは以下の4パターンです。 ログ発火後にモジュールの高さが50%未満となり、再度50%以上になった時 ホーム画面内でのモールタブ・性別タブの切り替え時 バックグラウンドからの復帰時 別画面への遷移からのページバック時 KPIモニタリング用の定常レポートの作成 モジュールの運用を改善していく上で、モジュールのKPIを定常的にモニタリングする必要があります。今回はモジュールのKPIをモニタリングする環境として、Google社が提供するクラウド型BIツールである データポータル を採用しました。データポータルは弊社でデータ基盤として使用しているBigQueryとの親和性が高く、ビジネスチームへの展開が容易といった点から、弊チームのモニタリング環境として利用されることが多いプロダクトです。 実装上の工夫 バッファリング&リトライの設計 アプリの通信とパフォーマンスを考慮し、リアルタイムでログを送信するのではなく、 バッファを利用することで送信回数を減らしました 。バッファの送信タイミングの仕様は以下です。 アプリの起動時に送信 120秒間隔で送信 ローカルに保持したログが100件到達時に送信 もし送信に失敗した場合は次回の送信時にリトライとして送信します。ただし、送信失敗時にログが100件以上の場合は120秒後の送信タイミングまで送信されません。 また 端末の容量を圧迫しないように蓄積するログのハードリミットを1000件としました 。ハードリミットに達した場合、1001件目以降のログは破棄され、その破棄件数をログとして記録しています。 ログの内製化 ここ最近で内製のデータ基盤が構築され、このデータ基盤を通じて社内の様々なデータがBigQueryに蓄積されています。この影響でログの内製化が加速し、今回紹介したモジュールのview-impはHOME画面において内製で実装した初めてのview-impでした。 今後の内製ログの拡大を見越して、他のview-impの実装で再利用できるように、ログ送信方法を汎用的に設計しました。また、デバッグ時に通知やトーストなどで送信したログを表示する仕組みを作成し、確認テストを行い易いようにしました。 効果 良質なモジュールの生産効率の向上 定常的にモジュールのKPIを確認できるようになったことで、モジュールの良し悪しを素早く判断できるようになりました。以下のテーブルは定常レポートにおけるモジュールごとのKPIの一部を表示しています。 このように定常レポートから日々指標を確認しながらモジュールの良し悪しを判断し、次のモジュールリリースの改善に努めています。また、データがたくさん集まってくると良いモジュール・悪いモジュールの傾向も掴みやすくなり、良質なモジュールの生産効率の向上が期待できます。 モジュールの並び順改善 ここではview-impによるCTRの導入によって、異なる並び順のモジュールを比較できるようになった一例を紹介します。以下のグラフはある特定期間におけるモジュールの並び順と商品クリック数・CTRの関係を図示した棒グラフです。両グラフとも横軸は「ホーム画面の上部から下部にかけてのモジュールの並び順」を表しています。縦軸はそれぞれ対象期間における「商品クリック数の合算値」と「商品クリック数の合算値とview-imp数の合算値から算出したCTR」を表しています。 CTRのグラフから、3番目のモジュールのCTRが他のモジュールと比較して低いことがわかります。3番目のモジュールのCTRが低いとわかれば、他のモジュールとの差し替えや後方への位置変更によって並び順を改善できます。商品クリック数でこの判断をすることは難しく、CTRを導入したことで正確な並び順の評価ができるようになりました。 今後の展望 「良いモジュール」の分析と要因追求 良いモジュールと言われるモジュールには必ずその理由があります。「タイトル・サブタイトルがユーザーの目を引くものだった」や「表示しているコンテンツがユーザーの趣味嗜好に合っていた」など様々な要因が考えられます。特にコンテンツに関しては横スクロールをしないと全てのコンテンツを確認できなかったり表示数が限られていたりするため、コンテンツの種類や並び順によってユーザーがモジュールに持つ印象に影響を与えることが予想されます。 ログの内製化の促進により、各コンテンツのview-impやクリック数も取得できるようになりました。そのため、今後はコンテンツに関わるデータも使用して良いモジュールの要因を探り、さらなるモジュールの改善に努めていきたいと考えています。 ユーザーセグメントの分解 良いモジュールと一口に言ってもユーザーのセグメントによって好まれるモジュールの傾向は変わります。一例として、最近の分析ではZOZOTOWNでの購入経験の有無で好まれるモジュールの傾向に違いがあることがわかってきました。購入経験のないユーザーは、タイトルやサブタイトルに「季節」関連の単語を含むモジュールやタイムセールモジュールを好む傾向があります。また、購入経験のあるユーザーはタイトルやサブタイトルに「新作」や「限定」などの単語を含むモジュールやクーポン関連のモジュールを好む傾向があります。 このようにユーザーのセグメントごとにモジュールの傾向が見つかれば、そのセグメントごとにモジュールを出し分けすることでよりユーザーに適したコンテンツを提供できます。ZOZOTOWNのホーム画面でもこれらを実現するために、今後は「セグメントごとのモジュールの傾向分析」と「セグメントごとにモジュールを出し分けできる機能開発」を進めていきたいと考えています。 ホーム画面におけるパーソナライズ強化 多種多様なデータが集まると、そのデータを最大限活用してホーム画面を良くしていきたいものです。ホーム画面の改善案の1つとして、パーソナライズ化があります。ホーム画面に訪れるユーザーは、探している商品や好みのブランド・ショップがそれぞれ異なるため、同じ商品を訴求するよりもユーザーによってパーソナライズするほうが良い推薦と言えるでしょう。特にホーム画面はサイトの顔でもあるため、ユーザーがホーム画面に訪れた際、そのユーザーの探している商品を正しく訴求できれば機会損失の防止に繋がると思います。 直近ではパーソナライズモジュールの作成に注力しており、第一弾として2022年4月にリリースしました。こちらのパーソナライズモジュールの詳細は以下の記事にまとまっているため、ご興味のある方はご覧ください。 techblog.zozo.com ただし、こちらのパーソナライズモジュールは開発スピードを優先してルールベースの簡易的なロジックを利用しています。また、開発当初はログの内製化が開始されたばかりで、view-impなどのログは存在していませんでした。そのため、現在のパーソナライズモジュールはまだまだ改善の余地がある状態です。この改善に向けて、view-impなどのモジュールに関するデータを活用した機械学習モデルのパーソナライズモジュールを絶賛開発中です。 ホーム画面におけるパーソナライズ化の道は果てしないですが、ユーザーが探している商品を1つでも多く見つけてもらえるようなホーム画面にしていきたいと考えています。 おわりに 本記事ではZOZOTOWNのホーム画面におけるデータドリブンな取り組みについて紹介しました。今後の展望に挙げたように、これからさらにデータの活用やレコメンドの改善を進めていきます。ZOZOではこのような取り組みを一緒に進めていただける仲間を募集中です。ご興味のある方は、以下のリンクから是非ご応募ください! hrmos.co hrmos.co また、カジュアル面談も随時実施中です。是非ご応募ください! hrmos.co
アバター
こんにちは、WEARバックエンドブロックの天春( @AmagA001 )です。バックエンドの運用・開発に携わっています。WEARはサービス開始から10年ほどの古いVBScriptを使った環境からRuby on Rails環境にシステムリプレイスを行なっています。本記事では、リプレイスの中でも既存環境が複雑で問題や課題が多くあったPUSH通知システムのリプレイスについてご紹介します。 目次 目次 PUSH通知システムとは リプレイス前のPUSH通知システム リプレイス前のPUSH通知システムの問題点 通知送信バッチのスケールアウトが出来ない 障害対応・運用が難しい状況 複数の開発言語による運用・改修コストが高い ステージング環境で通知確認ができない リプレイスの背景 リプレイス後のPUSH通知システム 非同期システム・EKS導入 既存システムの問題解決 バッチのスケールアウトが出来ない 障害対応・運用が難しい状況 複数の開発言語による改修コストが高い ステージング環境で通知確認ができない その他 ローカル開発環境 Sidekiqについて Sidekiqダッシュボードの再実行とデッド状態について Sidekiqキューについて ローカルDynamoDB環境 Dynamoid 現在の状況 今後の課題 最後に PUSH通知システムとは WEARアプリにPUSH通知を配信するために構築しているシステムのことを呼んでいます。PUSH通知は次の2種類が存在します。 1:1通知:一人のユーザーに対して1回だけ送る通知 1:N通知:同じ通知を同時に複数のユーザーに対して送る通知 リプレイス前のPUSH通知システム リプレイス前のPUSH通知システムはオンプレミスのMicrosoft SQL Server、.NET FrameworkとAWSのサービスで構成されています。AWSのサービスはEC2、DynamoDB、API Gateway、Lambda、SNS、SQSが使われています。開発言語はVBScript、C#、Golang、Pythonです。 1:1用通知処理バッチ(Golang)と1:N用通知処理バッチ(C#)がWindowsバッチサーバーのタスクスケジューラに登録されて定期的に通知配信API(Python)経由で通知を配信していました。通知サービスはAWSのSNS経由でAPNsとFCMを使っていました。 リプレイス前のPUSH通知システムの問題点 通知送信バッチのスケールアウトが出来ない 通知送信バッチはスケールアウトが考慮されてなく決まった時間に通知を送る仕組みになっていたので1:Nの通知の場合、通知が多い時は1日以上の遅延が発生している状態でした。 障害対応・運用が難しい状況 障害・エラーが発生した場合、開発当時の資料と開発メンバーの不在、必要なログデータの不足により原因特定・障害対応・運用に時間がかかりました。原因が判明して修正できたとしても影響範囲が特定できないこととテスト環境がないことも問題でした。 複数の開発言語による運用・改修コストが高い APIとバッチの改修のためにはVBScript、C#、Golang、Python、シェルスクリプトの修正が必要になるため、関連言語の学習コスト発生や経験者が必要になりました。 ステージング環境で通知確認ができない ステージング環境でバッチが動いてない状態だったので通知の改修や追加時にQAテストが出来ず、本番環境で動作確認するしかない状況でした。 リプレイスの背景 WEARサービスにコーディネート動画 1 やフリマ機能 2 の追加により新規通知を追加する必要がありました。既存システムを改修する方法もありましたが、既存システムが複雑すぎて障害・エラー発生時に原因調査・対応が難しい状況だったのでリプレイスを選びました。 リプレイス後のPUSH通知システム 非同期システム・EKS導入 Ruby on Rails環境では ActiveJob を使うことでキューイングライブラリを気にせずジョブの作成、キュー登録、実行が可能です。キューイングライブラリはジョブをキューに登録して非同期でジョブを実行できるライブラリのことです。Railsガイド 3 にも書かれているSidekiq、Resque、Delayed Jobを対象に検討しました。結果、Sidekiqがマルチスレッド対応で大量のジョブ処理に向いていることとメモリあたりのパフォーマンスがいいことでした。WEARには同時に200万回以上の大量通知が発生することもあるのでSidekiqを選定しました。チームメンバーにSidekiqの経験者がいたことも1つの理由でした。 既存配信バッチはMicrosoft SQL Serverのテーブルをキューとして利用してテーブルから通知対象を取得して通知配信後、通知一覧に必要なデータをDynamoDBに登録していました。リプレイス後はバッチをなくしてMicrosoft SQL Serverを使わずに非同期ジョブを利用して直接DynamoDBに通知一覧データを登録しています。 EKS の導入により拡張可能なシステムになったのも大きな変化です。通知配信サービスは Firebase Cloud Messaging(FCM) だけを使うようにしました。 既存システムの問題解決 バッチのスケールアウトが出来ない EKS導入により負荷が多い時の非同期ジョブ処理(Sidekiq)のスケールアウトが可能になりました。オートスケールは KubernetesのHPA(Horizontal Pod Autoscaler) を使っています。 障害対応・運用が難しい状況 エラー検知と障害検知についてはエラーログをSlackに通知することで解消しました。また、Sidekiqのダッシュボード機能によりエラー確認・ジョブ再実行が簡単にできるようになったので運用も楽になりました。 複数の開発言語による改修コストが高い C#・Golangのバッチ処理をやめてRuby on Railsに非同期システムを導入したことで開発言語はRubyだけになりました。 ステージング環境で通知確認ができない 非同期システムのステージング環境を構築したのでQA時に通知確認ができるようになりました。 その他 その他考慮したのは緊急度が高い通知は「critical」キューから配信、既存通知は「default」キューから配信しています。遅延が発生しても問題ない1:Nの通知は「multi」キューに分けることで緊急度・優先度が高い通知に遅延が起きないように考慮しています。これらのキューは処理の性質や負荷が異なるので、キュー単位でReplica数やリソース割り当てができるようにKubernetesのDeploymentを用意しました。Deploymentの定義には以下のようにSidekiq起動時キューを指定しています。 spec : serviceAccountName : sidekiq shareProcessNamespace : true containers : - name : sidekiq-critical imagePullPolicy : Always command : [ "bundle" , "exec" ] args :     - | sidekiq \ --verbose \ --queue critical \ --pidfile ./tmp/pids/sidekiq.pid lifecycle : preStop : exec : command : [ "/bin/bash" , "-c" , "SIDEKIQ_PID=$(ps aux | grep sidekiq | grep busy | awk '{ print $2 }'); kill -SIGTSTP $SIDEKIQ_PID" , ] EKSについての詳細は以前WEAR部SREチームから公開した記事を参考にしてください。 techblog.zozo.com ローカル開発環境 docker-compose を利用してローカル開発環境から Redis , Sidekiq , dynamodb-local , dynamodb-admin を使っています。 redis : image : "redis:6.0.16" ports : - "6379:6379" volumes : - "./db/redis:/data" sidekiq : depends_on : - redis links : - redis build : context : . args : - SIDEKIQ_PRO_CREDENTIALS=${SIDEKIQ_PRO_CREDENTIALS} dockerfile : dockerfiles/Dockerfile.app command : bundle exec sidekiq -C config/sidekiq.yml environment : REDIS_URL : redis://redis:6379 dynamodb-local : container_name : dynamodb-local image : amazon/dynamodb-local:1.17.0 user : root command : -jar DynamoDBLocal.jar -sharedDb -dbPath /data volumes : - "./db/dynamodb:/data" ports : - 8001:8000 networks : - dynamodb-local-network dynamodb-admin : container_name : dynamodb-admin image : aaronshaf/dynamodb-admin:latest environment : - DYNAMO_ENDPOINT=dynamodb-local:8000 ports : - 8002:8001 depends_on : - dynamodb-local networks : - dynamodb-local-network Sidekiqについて APMを導入したい場合Sidekiq Proを使う必要があるため、WEARではSidekiq Proを導入しました。データを失うことなくネットワークの問題に耐える機能とジョブのバッチ処理ができるのも導入した理由です。 Sidekiqにはキューの状態確認やジョブの管理できるダッシュボード機能があり、簡単にWebサイトへ追加できます。Railsにダッシュボードを追加したい場合は こちら を参考にしてください。追加する際にSidekiqへの管理画面のアクセス制限追加も忘れないでください。 Basic認証 と Devise を使った方法、 セッション を使う方法があります。本記事では詳細なコードは省略します。WEARでは管理サイトがあるので管理サイトのメニューに追加して使用しています。 Sidekiqダッシュボードの再実行とデッド状態について Sidekiqジョブは約21日間で25回、再試行します。その時間内にバグ修正をデプロイすると再試行され正常に処理されます。25回後にも再実行できないジョブについては手動の介入が必要になると想定してそのジョブはデッド状態になります。WEARではデッド状態の監視のため、以下のように再実行設定とデッド状態時にSlackへメッセージを送っています。 Sidekiq .configure_server do |config| config.redis = { url : redis_url } config.death_handlers << ->(job, ex) do message = " エラー: #{ ex.message } . " params = { channel : slack_channel, username : ' Sidekiq ' , attachments : [ { fallback : message, pretext : ' Job 再実行失敗 ' , color : ' #D00000 ' , title : " class: #{ job[ ' class ' ] } , job_id: #{ job[ ' jid ' ] }" , title_link : " /sidekiq/morgue " , fields : [ { title : ' detail ' , value : message } ] } ] } Slack :: Message .send( webhook_url : slack_webhook_url, params : params) end end Sidekiq .default_worker_options[ ' retry ' ] = 3 Sidekiqのエラー処理方法や再実行ルールの詳細については こちら を参考にしてください。 Sidekiqキューについて SidekiqはRedisで「default」と呼ばれる単一のキューを使用します。複数のキューを使用する場合は、Sidekiqコマンドの引数として指定するか、Sidekiq構成ファイルで設定できます。各キューは、オプションの重みを追加できて重みが2のキューは、重みが1のキューの2倍の頻度でチェックされます。以下の「- [キュー名、重み]」という書式がそれに該当します。 # config/sidekiq.yml :concurrency : 25 :pidfile : ./tmp/pids/sidekiq.pid :queues : - [critical, 3 ] - [default, 2 ] - [multi, 1 ] 以下のコマンドでSidekiqデーモンを起動できます。 $bundle exec sidekiq -C config/sidekiq.yml ジョブを実行する時に以下のようにキューを指定して非同期ジョブを実行しています。 class ExampleJob < ActiveJob :: Base # Defaultキュー設定 queue_as :default def perform (*args) # ジョブ実装 end end ExampleJobJob .set( queue : :default ).perform_later Sidekiqについて説明している YouTubeのチャンネル もあるので参考になると思います。 ローカルDynamoDB環境 ローカル環境でAWSのDynamoDBを接続したくなかったのでdynamodb-local, dynamodb-adminを使ってAWS環境と同じ環境で開発・テストができるようにしました。dynamodb-adminはローカル環境のサイトからDynamoDBに直接データ登録・編集・検索ができるので便利です。 Dynamoid Dynamoid はRuby on Railsで動作するDynamoDBのO/Rマッパーです。Dynamoidを使うとActiveRecordのようにモデルを定義してそのモデルに対するデータの読み書きができます。Gemfileにdynamoidを追加するだけで簡単にDynamoDBのCRUDが可能になるので便利です。 gem ' dynamoid ' class Dynamodb :: Example include Dynamoid :: Document table name : :example , key : :id field :id , :integer ... end Dynamoidのテスト設定は DynamoidのGitHubリポジトリ を参考にしてください。 現在の状況 WEARのPUSH通知システムリプレイスは2段階にフェーズを分割して実施しています。フェーズ1は既に完了しコーディネート動画やフリマ機能に必要だった新規通知については新しいシステムで問題なく運用中です。 フェーズ2は現在進行中で既存通知のリプレイスと1:N通知を改善する予定です。フェーズ2の話も次回のテックブログで書く予定なのでご参考になればと思います。 今後の課題 大量の1:N通知についての負荷検証・対応と非同期ジョブのバッチ処理が今度の課題になるかと思います。 FCMの複数のデバイスにメッセージ送信機能 と Sidekiqのバッチ を利用して大量の1:N通知の対応を検討しています。既存通知のリプレイスと不要になった既存通知システムの廃止作業も今後の課題です。 最後に 本記事ではPUSH通知リプレイスフェーズ1を紹介しました。個人的にRuby on Railsに非同期システムを導入した経験がなかったので導入の事例・必要なライブラリ・ツール、開発環境についてとても悩みました。同じ悩みを抱えている非同期システムの導入を検討している方や未経験の方の参考になれば幸いです。 WEARではまだリプレイスを必要とされる機能がたくさん残っています。サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co コーディネート動画 機能はアプリでコーディネート動画を投稿できる機能です。 ↩ フリマ機能 はPayPayフリマを連動してコーディネート着用アイテムをフリマに出品・購入できる機能です。 ↩ Railsガイド ↩
アバター
はじめに こんにちは、技術本部ML・データ部MLOpsブロックの鹿山( @Ash_Kayamin )です。先日、20個の開発環境APIを用意し、各APIをリクエストに応じて動的に起動できる仕組みをKnative Servingを用いて構築しました。 この記事ではKnative Servingを利用した背景と、利用方法、はまりどころ、利用によって得られたコスト削減効果についてご紹介します。なお、今回はKubernetesクラスタのバージョンとの互換性の都合でKnative v1.3.1 を利用しました。2022/9現在の最新バージョンは v1.7.1 になりますのでご注意ください。 目次 はじめに 目次 課題:20個の異なる開発環境APIを低コストで提供したい 解決策:Knative Servingを用いて、リクエストに応じて動的にAPIサーバーを起動する仕組みを導入する Google Cloud上でAPIコンテナを動的に起動する方法の比較 Cloud Run Cloud Run For Anthos Knative Serving Knative Servingとは Knative Servingの主要なコンポーネント Network layer Kourierの主要なコンポーネント Knative Servingを用いて実際にAPIを動かす 手順1. Knative Serving、KourierをGKEクラスタへインストールする 導入で躓いた点 Kustomizeを利用する場合はserving-crds.yamlを個別にapplyする必要はない KourierのカスタムコントローラーはNamespace: knative-servingに作成する必要がある LBからのヘルスチェックに成功させるために適切なポート、 パスを設定する必要がある 手順2. カスタムドメインを設定し、適切にルーティングされることを確認する 手順3. Knative/Serviceマニフェストを既存のDeploymentマニフェストから生成する 複数環境の構築で躓いた点 カスタムリソースに対するKustomizeのpatchStrategicMergeの挙動は自ら定義する必要がある (1)Knative ServingのCRDをapply済みのKubernetesクラスタから既存のOpenAPIスキーマJSONを取得する (2)Knative/Service関連部分のスキーマを編集する (3)(2)で作成したスキーマをKustomizeで用いる どれくらい費用を削減できているのか? 今後の展望/終わりに 課題:20個の異なる開発環境APIを低コストで提供したい ZOZOTOWNには20個の開発環境が存在し、それぞれが独立して開発できるように、20環境分の独立したAPIを提供する必要がありました。MLOpsブロックがGKEクラスタ上で提供するAPIも例外ではありません。しかしながら、単純に20環境を常に起動しておくとノードの費用が嵩んでしまいます。 解決策:Knative Servingを用いて、リクエストに応じて動的にAPIサーバーを起動する仕組みを導入する 今回の開発対象は開発環境のAPIであり、高いサービスレベルは求められていません。初回リクエストの処理に時間がかかっても問題なく、リクエスト数も少ないです。また、APIサーバーはコンテナ化されており、GKEクラスタ上で動いています。これらの前提から、コンテナ化されたAPIサーバーをリクエストに応じて動的に起動する仕組みを導入して、リクエストがない時のAPIサーバー費用を削減することを検討しました。 Google Cloud上でコンテナ化したAPIサーバーを動的に起動し、APIを提供する方法には、大きく分けて Cloud Run 、 Cloud Run For Anthos 、 GKEクラスタ上でKnative Servingを動かす の3通りがあります。次に、それぞれの概要・メリット・デメリットについてご説明します。 Google Cloud上でAPIコンテナを動的に起動する方法の比較 Cloud Run Google Cloudが提供するマネージドなコンテナサービスです。後述するKnative Servingをベースに作られています。用意したエンドポイントへのリクエストに応じて事前に定義したDocker ImageからAPIサーバーを起動してリクエストを処理し、レスポンスを返すことができます。 メリット Docker Imageを用意し、起動するAPIの定義・アクセス設定等を行えば即座に利用できる HTTPリクエスト以外にもさまざまなイベントをトリガーにコンテナを起動できる ゾーン障害への冗長性がデフォルトで備わっている コンテナ、Knative Serving関連のログ、メトリクスをCloud Logging、Cloud Monitoringで簡単に取得できる TerraformでCloud Runのインスタンスを定義・デプロイできる デメリット 既存のKubernetesマニフェストからTerraform定義を生成・更新しなくてはいけない 既存のGKEクラスタとはネットワーク構成が異なり、Shared VPCとのIngress、Egress通信を可能にするための構成が追加で必要となり複雑 Cloud Run For Anthos Google Cloudが提供するマネージドなKnativeサービスです。 Anthos を利用しているGKEクラスタ上にマネージド、かつサポート付きのKnativeを構築し、Knativeが提供する機能を全て利用可能です 1 。 メリット GKEクラスタ上で他のAPIと同じように扱える Kubernetesマニフェストを用いて管理できる 同じコマンドラインツールを用いて、確認・操作ができる 既存のAPIと同じネットワーク・権限を利用できる Knative周りの挙動・エラーについてGoogle Cloudからのサポートを受けられる デメリット 有料かつクラスタ設定の変更が必要なAnthosの利用が必須 Anthos Service Mesh (マネージドなIstio)の導入が必要 Knative Serving Kubernetesオペレーターの一種である Knative の一部分であるKnative ServingをGKEクラスタ上で動かします。Knative Servingの機能を用いて、用意したエンドポイントへのリクエストに応じて事前に作成したDocker ImageからAPIサーバーを起動してリクエストを処理し、レスポンスを返します。 メリット GKEクラスタ上で他のAPIと同じように扱える Kubernetesマニフェストを用いて管理できる 同じコマンドラインツールを用いて、確認・操作ができる 既存のAPIと同じネットワーク・権限を利用できる Knative ServiceのNetwork layerを自由に選択できる(Istio、Contour、Kourier) デメリット Knative Servingのインストール・運用・バージョンアップを自前で行わなくてはいけない Knative Servingのメトリクスを取得するためには、追加のセットアップをしてCloud Monitoringにメトリクスを送る等する必要がある 今回は次の理由からGKEクラスタ上でKnative Servingを動かす方針としました。 既存APIと同じGKEクラスタ上で動かすことによって、ネットワークや権限周りの構成、Kubernetesマニフェストを共通化して認知負荷を下げたい => Knative Serving,Cloud Run for Anthosの優先度が上がる 有料かつクラスタ設定の変更が必要なAnthosや、導入・運用コストが高いIstioを利用したい強い理由がない => Cloud Run for Anthosの優先度は下がる 提供する開発環境APIでは運用初期からの高いサービスレベルは求められていない => Cloud Run、Cloud Run for Anthosの高いサービスレベルは不要で、Knative Serving自前運用のリスクは許容できる Knative Servingとは Knavite Serving は Knative を構成するコンポーネントの1つです。KnativeはKubernetes上でのServerless、 Event drivenなアプリケーションの構築をサポートするKubernetesオペレーターです。Knativeは Serving 、 Eventing の2つのコンポーネントから構成されます。 ServingはKuberentes上でのServerless Containerの実現をサポートします。Serverless Containerとは何らかのイベント(HTTPリクエスト等)に応じてコンテナ化されたアプリケーションを0台の状態から起動、必要に応じて複数台にスケールし、処理を行う環境のことを指します。 ServingはServerless Container実現に必要なネットワーク周りの設定や処理、0台からのコンテナのオートスケール、コンテナのバージョン管理周りの処理を自動化してくれます。これによってユーザーは簡単にKubernetes上にServerless Containerを利用したサービスを実現できます。 EventingはKubernetes上でのHTTPリクエストを用いたイベントのPub/Subの実現をサポートします。Serving、Eventingの具体的な活用例については公式に 構成図付きのサンプル が用意されているのでそちらをご覧ください。 今回、HTTPリクエストを受けて、リクエストのホスト名に応じたAPIサーバーを0台の状態から起動、または複数台にスケールし、処理を行ってレスポンスを返す仕組みを実現するために、Knative Servingを利用しました。 以下の図は、Knative Servingを用いて実現した環境を図解したものです。 Knative Servingの主要なコンポーネント Knative Servingには代表的なカスタムリソースとしてService・Route・Configuration・Revision・Ingressが存在します。それぞれの役割を以下に示します。Serviceリソースを作成すると、その他のリソースはKnative Servingのカスタムコントローラーによって自動的に生成されます。Serviceリソースを通じて各種機能を利用するのが基本ですが、個別にConfiguration、 Route等を定義して挙動を制御することも可能です。 Service Servingでコンテナを起動し、コンテナへのルーティングを実現するために必要な要素を抽象化したカスタムリソース(以降Knative/Serviceと呼びます) Knative/Serviceが作成されると、KnativeのカスタムコントローラーがKnative/Serviceで定義された情報に従って、Configuration・Revision・Route・Ingressを作成します Configuration 最新のRevisionの定義を保持します Revision ある時点のConfigurationを記録するスナップショットであり、Configurationが新規作成・更新される場合にConfigurationで定義された情報から生成されます KnativeのカスタムコントローラーはRevisionで定義された情報に従って、Deployment等を作成します Route リクエストをどのRevisionから生成されるPodにルーティングするのかを管理します Routeから、後述するNetwork layerの設定を抽象化したカスタムリソース Ingress が生成されます Network layerには複数の実装の選択肢(Istio、Contour、Kourier)があり、どの実装で利用するIngressを生成するかをCofigMapで指定します Network layerでは生成されたIngressリソースをもとに、ルーティングを設定します Ingress Kubernetes標準のIngressリソースをKnative用に拡張したカスタムリソース(以降Knative/Ingressと呼びます) Network layerから参照され、Knative Servingでのリクエストのルーティングを実現するための情報を提供します Red Hatさんのブログ記事、 あらためてKnative入門!(Knative Servingやや発展編) で図付きのわかりやすい解説があるのでぜひこちらもご参照ください。 Network layer Knative Servingで、受けたリクエストのコンテナまでのルーティングを実現するのがNetwork layerになります。Network layerには Istio 、 Contour 、 Kourier の3つの選択肢があります。どれを選択した場合でもEnvoyを用いてルーティングを実現することには変わりはありません。Routeから生成されたKnative/Ingressの情報を元にNetwork layerがEnvoyコンテナを起動・設定・更新することで、ルーティングを実現します。 今回、Network layerにはKourierを選択しました。IstioやContourを利用する場合は、それらのカスタムリソース・カスタムコントローラーをKubernetesクラスタにインストールする必要があります。加えて、Knative/IngressからIstioやContourのIngressを生成するカスタムコントローラー( net-istio 、 net-contour )を動かす必要があります。 他方、KourierはKnative Servingのために開発されたIngress実装であり、カスタムリソースの定義は一切必要なく、カスタムコントローラーを動かすだけで良いです。Kourierのカスタムコントローラー net-kourier はKnative/Ingressから直接Envoyの設定を生成し、Envoyコンテナに設定を反映することでルーティングを実現します。Network layerにIstio、Contourを利用する場合と比較して、Kourierを用いる構成は非常にシンプルであり、必要十分な機能を備えていたことがこの選択をした理由です。 Kourierの主要なコンポーネント Kourierの主要なコンポーネントとその挙動を以下図に示します。 カスタムコントローラーである、 Pod: net-kourier-controller がKnative/Ingressに定義されたリクエストのルーティング情報を読み取り、そのルーティングを実現するためのEnvoyの設定を生成・保持・更新します。 Pod: net-kourier-controller が保持するEnvoyの設定は、 Pod: 3scale-kourier-gateway で起動するEnvoyコンテナからEnvoyのxDS APIを用いて随時読み取られる 2 ことで、Envoyコンテナでのルーティングが設定・変更されます。 ブログ記事、 Kourier: A lightweight Knative Serving ingress に図付きのわかりやすい解説があるのでこちらもぜひご参照ください。 Knative Servingを用いて実際にAPIを動かす ここからはGKEクラスタにKnative Servingを導入し、APIを動かすための具体的な設定と3つの手順についてご説明します。 公式のYAMLファイルを用いてインストールする手順 を参考に導入しました。 最終的に構築されるのは以下のシステムになります。 Namespace: knative-serving Knative Servingのカスタムコントローラー Pod: controller や、リクエストをキューイングしたり、リクエスト対象となるPodを起動・スケールさせるための Pod: activator ・ Pod: autoscaler といった、Knative Serving関連のリソースが作成されます。またKourierのカスタムコントローラー Pod: net-kourier-controller やConfigMap等もここに作成されます。 Namespace: kourier-system クラスタ外部からのリクエストを受け付けるためのロードバランサーをGKE Ingressを用いて作成するための Ingress: kourier-internal-ingress や、リクエストをホスト等の情報に基づき、あらかじめ設定したルールに基づいてルーティングを行うEnvoyコンテナを起動する Pod: 3scale-kourier-gateway 等を作成します。 Namespace: recommendation-module APIコンテナを起動する設定を記載した Knative/Service: dev1~dev20 等を作成します。 今回の説明に必要となる主要なリソースだけ記載しており、実際にはこの図に記載した以外のリソースも数多く作成される点にご注意ください。 Cloud DNSで名前解決を行い、内部ロードバランサーに到達したリクエストは、 Pod: 3scale-kourier-gateway でホスト情報に基づきルーティングされます。ルーティングされたリクエストは Pod: activator でキューイングされ、必要に応じて Pod: autoscaler によってリクエスト対象となるPodが起動、またはオートスケールしたのちに対象となるPodに到達します。リクエスト対象のPodが起動しているか、同時に処理しているリクエストの数は設定されている同時処理数上限を上回っているか等によってルーティングの経路は変わります。より詳細は 図付きの公式ドキュメント をご参照ください。 手順1. Knative Serving、KourierをGKEクラスタへインストールする MLOpsブロックではKustomizeを用いてKubernetesマニフェストを管理しています。今回は以下のようなディレクトリ構成でKnative Serving、Kourierを導入しました。 ./ ├── base │ └── knative-serving │ ├── knative-serving │ │ ├── configmap.yaml │ │ ├── deployment.yaml │ │ └── kustomization.yaml │ ├── kourier │ │ ├── deployment.yaml │ │ ├── ingress.yaml │ │ ├── kustomization.yaml │ │ └── service.yaml │ └── kustomization.yaml └── dev └── knative-serving └── kustomization.yaml ファイルの内容は以下になります。 base/knative-serving/knative-serving/kustomization.yaml Knative公式のマニフェストファイルを取得して、一部ConfigMapにパッチを当てて適用します apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization resources : - https://github.com/knative/serving/releases/download/knative-v1.3.2/serving-core.yaml patchesStrategicMerge : - configmap.yaml - deployment.yaml base/knative-serving/knative-serving/configmap.yaml いくつかのConfigMapにパッチを当てることでKnative Servingの設定を変更します config-features ではKnative/ServiceマニフェストのPod Specの特定の項目の利用を明示的に許可しています apiVersion : v1 kind : ConfigMap metadata : name : config-network Namespace : knative-serving data : ingress.class : kourier.ingress.networking.knative.dev # IngressにはKourierを利用することを設定 autocreate-cluster-domain-claims : "true" # 各Namespace でのサブドメインの自動生成、割り当てを許可 --- apiVersion : v1 kind : ConfigMap metadata : name : config-domain Namespace : knative-serving data : example.zozo.com : | # 特定のドメインへのリクエストをどのKnative/Serviceにマッピングするかを指定 selector : run : zozo-module-recommendations-api --- apiVersion : v1 kind : ConfigMap metadata : name : config-features Namespace : knative-serving data : kubernetes.podspec-affinity : "Allowed" # Knative/ServiceマニフェストのPod SpecでのAffinityの指定を許可 kubernetes.podspec-tolerations : "Allowed" kubernetes.podspec-fieldref : "Allowed" base/knative-serving/kourier/kustomization.yaml Knative公式のマニフェストファイルを取得して、一部Serviceにパッチを当てて適用します GKE Ingressで内部Load Balancer(以下LBと記述)を作成しルーティングできるようにするため、Ingressマニフェストの追加・Serviceへのパッチ当てをしています apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization resources : - https://github.com/knative/net-kourier/releases/download/knative-v1.3.0/kourier.yaml - ./ingress.yaml patchesStrategicMerge : - ./service.yaml - ./deployment.yaml base/knative-serving/kourier/service.yaml GKE Ingressで内部LBを作成しルーティングできるようにするため、デフォルトでは type: Loadbalancer となっているところを type: Nodeport に変更しています 既存の接続先ポートに加えて、ヘルスチェックのため9000番ポートへの接続も追加しています apiVersion : v1 kind : Service metadata : name : kourier Namespace : kourier-system annotations : cloud.google.com/neg : '{"ingress": true}' cloud.google.com/backend-config : '{"default": "kourier-backend-config"}' spec : type : NodePort # GKE Ingressを利用するため、LoadBalancerからNodePortに変更 ports : # LBからのヘルスチェックを行うため、kourier-gateway (envoy container) pod がデフォルトでlistenしているポートへのルーティングを設定 - name : http-port9000 port : 9000 protocol : TCP targetPort : 9000 base/knative-serving/kourier/ingress.yaml GKE Ingressを用いて内部LBを作成するためのマニフェスト LBのヘルスチェック先として、Nodeportで追加した9000番ポートの/readyを指定しています apiVersion : networking.k8s.io/v1 kind : Ingress metadata : name : kourier-internal-ingress Namespace : kourier-system annotations : kubernetes.io/ingress.regional-static-ip-name : "kourier" # 必要なIPアドレスは事前に割り当て kubernetes.io/ingress.class : "gce-internal" spec : defaultBackend : service : name : kourier port : number : 80 --- apiVersion : cloud.google.com/v1 kind : BackendConfig metadata : name : kourier-backend-config Namespace : kourier-system spec : healthCheck : # ヘルスチェック先のパスとポートを明示的に指定 type : HTTP requestPath : /ready port : 9000 導入で躓いた点 Knative Serving、KourierをGKEクラスタへインストールする際にいくつかつまづいた点があるのでご紹介します。 Kustomizeを利用する場合はserving-crds.yamlを個別にapplyする必要はない Knative ServingをYAMLからインストールする公式の手順では、 serving-crds.yaml をapplyしたのちに、 serving-core.yaml をapplyしています。ですが、Kustomizeを用いてapplyする場合は serving-crds.yaml のapplyは不要です。Custom Resource Difinition(以下CRD)は serving-crds.yaml , serving-core.yaml 両方に定義されています。2つをまとめてapplyしようとするとKustomizeが重複を検出してエラーになります。 issueのコメント にあるように、CRDが作成されていない段階でCustom Resource(以下CR)のマニフェストを処理しようとしてエラーになることを回避するために、公式ドキュメントでは順にapplyする手順が示されています。しかしながら、Kustomizeを用いる場合は順にapplyする必要はないため、 serving-core.yaml をapplyするだけで問題ありません。 Kourierのカスタムコントローラーは Namespace: knative-serving に作成する必要がある Knative Servingを動かすクラスタはマルチテナントクラスタになっており、通信の制御・権限の分割するためにリソースを作成するNamespaceを分けています。そのため、Kustomizeでリソースを作成する際にはNamespaceを明示的に指定しています。その一環で、Kourierのリソースを作成するNamespaceをKustomizeで明示的に kourier-system と指定していたところ、リクエストをルーティングできない問題が発生しました。 Knative Servingのマニフェストをapplyすると Namespace: knative-serving が作成され、Knative Serving関連のリソースはこのNamespaceに作成されます。そしてKourierのマニフェストをapplyすると Namespace: kourier-system が作成され、以下Kourier関連のリソースはこの2つのNamespaceに作成されます。(今回のエラーに関連するもののみ明示しています) Namespace: knative-serving Deployment: net-kourier-controller : Knative/IngressからEnvoy設定ファイルを生成したりするKourierのカスタムコントローラーのDeployment Service: net-kourier-controller : net-kourier-controller へルーティングするClusterIP Namespace: kourier-system Deployment: 3scale-kourier-gateway : Knative ServingでKourierを利用する場合に、リクエストのルーティングを担うEnvoyコンテナのDeployment ConfigMap: kourier-bootstrap : 3scale-kourier-gateway で起動するEnvoyコンテナの初期設定を含むConfigMap Kourierは kourier-system にEnvoyコンテナを起動する Deployment: 3scale-kourier-gateway を作成します。合わせて作成される ConfigMap: kourier-bootstrap にEnvoyの初期設定が定義されており、こちらがEnvoyコンテナ起動時にマウントされて利用されます。Envoyには外部から動的に設定を読み込んで反映する仕組みがあります。Knative Servingでのルーティングの設定変更にはxDS API(KourierではそのうちのgRPCを利用)を用いて外部から設定を取得する仕組みが用いられています。この設定取得先は ConfigMap: kourier-bootstrap で指定されています。 ConfigMap: kourier-bootstrap に記載の、Envoyの設定を取得する先 dynamic_resources として指定されている xds_clustrer の address の値には net-kourier-controller.knative-serving が指定されています。したがってEnvoyコンテナは Namespace: knative-serving の Service: net-kourier-controller で名前解決される先からEnvoyの設定を取得しようとします。 Kourierのマニフェストで作成されるリソースの作成先NamespaceをKustomizeで一律に kourier-system としてしまうと、 Namespace: knative-serving には Deployment: net-kourier-controller 、 Service: net-kourier-controller が作成されません。結果、Envoyコンテナでの設定取得先の名前解決に失敗してしまいます。そのため、何かしらのKnative/Serviceを作成しても、Knative/Serviceから作成されるPodへのルーティング設定はEnvoyコンテナに反映されず、リクエストの適切なルーティングができなくなっていました。 最終的にはデフォルトのKourierのマニフェスト通り、 Namespace: knative-serving に Deployment: net-kourier-controller 、 Service: net-kourier-controller を作成してエラーを解消しました。 Envoyコンテナのエラーログ抜粋 - gRPC周りの部分でエラーが発生していることが分かるのでこのエラーを起点に調査をしました。 [ 2022-07-14 07:19:22. 380 ][ 1 ][ warning ][ config ] [ bazel-out/k8-opt/bin/source/common/config/_virtual_includes/grpc_stream_lib/common/config/grpc_stream.h:63 ] Unable to establish new stream [ 2022-07-14 07:19:34. 345 ][ 1 ][ warning ][ config ] [ bazel-out/k8-opt/bin/source/common/config/_virtual_includes/grpc_stream_lib/common/config/grpc_stream.h:101 ] StreamAggregatedResources gRPC config stream closed: 14 , no healthy upstream ConfigMap: kourier-bootstrap のマニフェスト関連部分抜粋 apiVersion : v1 kind : ConfigMap metadata : name : kourier-bootstrap Namespace : kourier-system ~~~ data : envoy-bootstrap.yaml : | dynamic_resources : ads_config : transport_api_version : V3 api_type : GRPC rate_limit_settings : {} grpc_services : - envoy_grpc : { cluster_name : xds_cluster } cds_config : resource_api_version : V3 ads : {} lds_config : resource_api_version : V3 ads : {} ~~~ clusters : ~~~ - name : xds_cluster connect_timeout : 1s type : strict_dns load_assignment : cluster_name : xds_cluster endpoints : lb_endpoints : endpoint : address : socket_address : address : "net-kourier-controller.knative-serving" # 設定取得先のドメイン(${Service名}.${Namespace名})が指定されている port_value : 18000 http2_protocol_options : {} type : STRICT_DNS ~~~ LBからのヘルスチェックに成功させるために適切なポート、 パスを設定する必要がある Kourierの公式YAMLで作成される Service: kourier は type: Loadbalancer であり、作成されるTCP/UDPロードバランサーでは各ノードで起動するkube-proxyに対してヘルスチェックを行います。 一方、GKE Ingressを利用してHTTP(S)ロードバランサーを作成する場合は、 type: Nodeport なServiceで接続する先のPodに対してヘルスチェックを行うため、Podにヘルスチェックのエンドポイントを用意する必要があります。そのため、KourierでGKE Ingressを利用するためには Service: kourier を type: Nodeport に単純に変更するだけでは駄目で、Service接続先のEnvoyコンテナにヘルスチェックエンドポイントを用意する必要がありました。 Envoyにはヘルスチェックのエンドポイントとして GET /ready が存在し、 Deployment: 3scale-kourier-gateway のreadinessProbe ではこちらを利用しています。このエンドポイントへのリクエストにはヘッダー Host: internalkourier を付与する必要があります。しかしながら2022/9現在、BackendConfigを用いたGKE Ingressのヘルスチェック定義では、ヘルスチェックのHTTPリクエストにカスタムヘッダーを付与できません。GCLBに設定できるヘルスチェックの設定項目ではカスタムヘッダーを指定できますが、 BackendConfigからは設定できません 。そのため、別途カスタムヘッダーが不要なヘルスチェック用のエンドポイントをEnvoyコンテナに設定する必要があります。 Envoyコンテナの初期設定として利用される ConfigMap: kourier-bootstrap を調べると、9000番ポートでGETメソッドに対して /ready エンドポイントが公開されていました。そこで今回は Service: kourier にパッチを当てて type: Nodeport とした上で、9000番ポートを開けて、Backendconfigにコンテナの9000番ポートへのヘルスチェックを設定しました。 ConfigMap: kourier-bootstrapのマニフェスト関連部分抜粋 apiVersion : v1 kind : ConfigMap metadata : name : kourier-bootstrap namespace : kourier-system ~~~ data : envoy-bootstrap.yaml : | ~~~ static_resources : listeners : - name : stats_listener address : socket_address : address : 0.0.0.0 port_value : 9000 filter_chains : - filters : - name : envoy.filters.network.http_connection_manager typed_config : "@type" : type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix : stats_server http_filters : - name : envoy.filters.http.router route_config : virtual_hosts : - name : admin_interface domains : - "*" routes : # /ready を含む一部のadmin_interfaceを公開している - match : safe_regex : google_re2 : {} regex : '/(certs|stats(/prometheus)?|server_info|clusters|listeners|ready)?' headers : - name : ':method' exact_match : GET route : cluster : service_stats clusters : - name : service_stats connect_timeout : 0.250s type : static load_assignment : cluster_name : service_stats endpoints : lb_endpoints : endpoint : address : pipe : path : /tmp/envoy.admin ~~~ http2_protocol_options : {} type : STRICT_DNS admin : access_log_path : "/dev/stdout" address : pipe : path : /tmp/envoy.admin 手順2. カスタムドメインを設定し、適切にルーティングされることを確認する Knative Servingでは、Knative/Serviceで定義されるPodへ繋がるFQDNは以下のように定められます。 ${Routeのname}.${Knative/Routeを作成したNamespace}.${ConfigMap: config-domainで定義されたドメイン} 例えば以下のように Namespace: recommendation-module に Knative/Service: dev1 を作成すると Route: dev1 が自動で作成されます。合わせて ConfigMap: config-domain でドメインを指定することで、最終的に dev1.recommendation-module.example.zozo.com というドメインが Knative/Service: dev1 に割り当てられます。 Knative/Service: dev1 apiVersion : serving.knative.dev/v1 kind : Service metadata : name : dev1 namespace : recommendation-module labels : run : zozo-module-recommendations-api spec : template : ~~~ Knative/Service: dev1 の作成によって Route: dev1 が作成される ❯ kubectl get ksvc --namespace=recommendation-module NAME URL LATESTCREATED LATESTREADY READY REASON dev1 http://dev1.recommendation-module.example.zozo.com dev1-00002 dev1-00002 True ❯ kubectl get route --namespace=recommendation-module NAME URL READY REASON dev1 http://dev1.recommendation-module.example.zozo.com True ConfigMap: config-domain apiVersion : v1 kind : ConfigMap metadata : name : config-domain namespace : knative-serving data : # ラベル run: zozo-module-recommendations-apiを持つRouteにドメインexample.zozo.comへのリクエストを紐づける example.zozo.com : | selector : run : zozo-module-recommendations-api 基本は上記設定に従い、ホストヘッダーベースのルーティングをEnvoyコンテナで行います。Knative/Serviceマニフェストの spec.traffic.tag に値を設定することでFQDNのホスト部分をルーティング対象とするRevision毎に作り分けたり 3 、同じFQDNを利用しつつもリクエストに Knative-Serving-Tag ヘッダーを付与することでリクエスト先を分ける 4 ことも可能です。 ここまででKnative Servingで利用するFQDNが定まりました。次はそのFQDNで、 Deployment: 3scale-kourier-gateway のPod(Envoyコンテナ)をバックエンドに持つLBへリクエストが名前解決されるようにする必要があります。 今回はGKE Ingressを利用しているので、GKE Ingressで作成される内部LBのIPアドレスへ名前解決されるように、CloudDNSにワイルドカードAレコードを設定しました。具体的には *.recommendation-module.example.zozo.com. に対するAレコードを作成し、 Namespace: recommendation-module 以下に作成されるKnative/Serviceへのリクエストは全て内部LBのIPアドレスへ名前解決されるようにしました。こうすることで、作成したFQDNに対するリクエストはEnvoyコンテナを経由し、Envoyコンテナで各Knative/Serviceから生成されるPodへとホストヘッダーベースのルーティングが行われます。 この状態でリクエストを行うと以下のようにPodが起動しレスポンスが返されます。 リクエストは Pod: activator でキューイングされます。キューイング時に、リクエスト先のPodが起動していない・ 起動しているPod数xPod毎の並列リクエスト処理数設定 がリクエストに対して不足している場合は Pod: autoscaler がDeploymentのReplicasを更新することでルーティング先のPodを必要な台数起動します。 Podが起動したら、キューイングされていたリクエストがPodに送られます。オートスケーリングの挙動(どれくらいのリクエストが来たらPod数を増やす・どれくらいの間リクエストが来なければPod数を減らすか等)はKnative/Serviceで細かく設定できます 5 。Podを起動するのに十分なリソースを持ったノードがない場合は、GKEに設定しているノードプールのオートスケール機能でノードが追加されてからPodがスケジュールされて起動するため、ノード起動を待つ分だけレスポンスタイムは長くなります。 curlのログ $ curl -v " http://dev1.recommendation-module.example.zozo .com/api/v1/zozo/home-modules/?app=pc&mall=shoes&sex=all&member_id=30&ga_client_id=deviceId 30 " * Trying 10 . 96 . 97 .135:80... * Connected to dev1.recommendation-module.example.zozo.com ( 10 . 96 . 97 . 135 ) port 80 ( #0) > GET /api/v1/zozo/home-modules/? app =pc & mall =shoes & sex =all & member_id = 30 & ga_client_id =deviceId30 HTTP/ 1 . 1 > Host: dev1.recommendation-module.example.zozo.com > User-Agent: curl/ 7 . 78 .0-DEV > Accept: */* > * Mark bundle as not supporting multiuse < HTTP/ 1 . 1 200 OK < content-length: 14865 < content-type: text/plain ; charset =UTF -8 < date: Wed, 10 Aug 2022 04:28:59 GMT < x-envoy-upstream-service-time: 26199 < server: envoy < via: 1 . 1 google < ~~~ Deployment・Revision・Podの変化 # リクエスト前 ❯ kubectl get revision --namespace=recommendation-module NAME CONFIG NAME K8S SERVICE NAME GENERATION READY REASON ACTUAL REPLICAS DESIRED REPLICAS dev1-00002 dev1 2 True 0 0 ❯ kubectl get deploy --namespace=recommendation-module NAME READY UP-TO-DATE AVAILABLE AGE dev1-00002-deployment 0 / 0 0 0 5d21h # リクエスト直後 ❯ kubectl get revision --namespace=recommendation-module NAME CONFIG NAME K8S SERVICE NAME GENERATION READY REASON ACTUAL REPLICAS DESIRED REPLICAS dev1-00002 dev1 2 True 0 1 ❯ kubectl get deploy --namespace=recommendation-module NAME READY UP-TO-DATE AVAILABLE AGE dev1-00002-deployment 0 / 1 1 0 5d21h ❯ kubectl get pod --namespace=recommendation-module NAME READY STATUS RESTARTS AGE dev1-00002-deployment-65f8d57f4c-c8dns 1 / 2 Running 0 19s # レスポンスを返した直後 ❯ kubectl get revision --namespace=recommendation-module NAME CONFIG NAME K8S SERVICE NAME GENERATION READY REASON ACTUAL REPLICAS DESIRED REPLICAS dev1-00002 dev1 2 True 1 1 ❯ kubectl get pod --namespace=recommendation-module NAME READY STATUS RESTARTS AGE dev1-00002-deployment-65f8d57f4c-z6r66 2 / 2 Running 0 73s Envoyコンテナのログ [ 2022-08-10T04:28:33.808Z ] " GET /api/v1/zozo/home-modules/?app=pc&mall=shoes&sex=all&member_id=30&ga_client_id=deviceId30 HTTP/1.1 " 200 - 0 14865 26201 26199 " 10.96.66.8,10.96.97.135 " " curl/7.78.0-DEV " " d10aca2a-c215-418e-84eb-12c569dc2754 " " dev1.recommendation-module.example.zozo.com " " 10.96.68.3:8012 " Activatorコンテナのログ # リクエストを受けてスケールアウトさせる { " severity " : " INFO " , " timestamp " : " 2022-08-10T04:28:55.574545201Z " , " logger " : " activator " , " caller " : " net/throttler.go:318 " , " message " : " Set capacity to 2147483647 (backends: 1, index: 0/1) " , " commit " : " ac29233 " , " knative.dev/controller " : " activator " , " knative.dev/pod " : " activator-6c496d64d8-kskdk " , " knative.dev/key " : " recommendation-module/dev1-00002 " } # リクエストが来なくなったのでスケールインさせる { " severity " : " INFO " , " timestamp " : " 2022-08-10T04:30:03.613571203Z " , " logger " : " activator " , " caller " : " net/throttler.go:318 " , " message " : " Set capacity to 0 (backends: 0, index: 0/1) " , " commit " : " ac29233 " , " knative.dev/controller " : " activator " , " knative.dev/pod " : " activator-6c496d64d8-kskdk " , " knative.dev/key " : " recommendation-module/dev1-00002 " } 手順3. Knative/Serviceマニフェストを既存のDeploymentマニフェストから生成する Knative/Serviceマニフェストは既存の開発環境APIを定義するDeploymentマニフェストからスクリプトで生成するようにしました。該当Deploymentマニフェストに変更が加えられた際には、スクリプトを実行することでKnative/Serviceのマニフェストも更新し、変更を反映します。Deploymentに変更が入ったにも関わらず、生成しているKnative/Serviceのマニフェストに変更が反映されていない場合はCIでエラーになるようにし、反映忘れを防ぐようにしました。 Knative/Serviceマニフェストの生成方法ですが、基本的には、Deploymentの spec.template.spec (=PodSpec)の値をそのままKnative/Servingの spec.template.spec の値とするだけでよいです 6 , 7 。Deploymentで定義されている、 spec.selector や spec.strategy 等はKnative/Servingでは定義されていないので適宜除去する必要があります。PodSpecのいくつかの項目については ConfigMap: config-features で明示的に利用を許可する必要があります 8 。既存のDeploymentマニフェストでは、起動するノードを指定するために nodeAffinity ・ toleration を、Podが起動したノードのIPアドレスをKubernetes Downward API経由で取得するために fieldRef の利用しているのでこれらの利用を許可しました。 apiVersion : v1 kind : ConfigMap metadata : name : config-features namespace : knative-serving data : kubernetes.podspec-affinity : "Allowed" kubernetes.podspec-tolerations : "Allowed" kubernetes.podspec-fieldref : "Allowed" スクリプトで生成したKnative/Serviceマニフェスト knative-service-generated.yaml を元に以下のようなディレクトリ構成でdev1〜20環境を構成しました(関連するファイルのみ記載しています)。 ./ ├── base │ ├── zozo-module-recommendations-api │ │ └── deployment.yaml │ └── zozo-module-recommendations-api-knative │ ├── kustomization.yaml │ ├── custom-schema.json # 既存のKuberenetesクラスタのOpenAPIスキーマに、Knative/ServiceのOpenAPIスキーマを追記したjsonファイル │ └── knative-service-generated.yaml # zozo-module-recommendations-api/deployment.yaml からスクリプトで生成する └── dev └── zozo-module-recommendations-api-knative ├── kustomization.yaml # dev1~20 の kustomization.yaml を参照する ├── dev1 │ ├── knative-service.yaml │ └── kustomization.yaml # base の kustomizatio.yaml を参照する ├── dev2 │ ├── knative-service.yaml │ └── kustomization.yaml ~~~ └── dev20 ├── knative-service.yaml └── kustomization.yaml base/zozo-module-recommendations-api-knative/kustomization.yaml Knative/ServiceのOpenAPIスキーマ情報を追加で読み込み、patchStrategicMergeの挙動をカスタマイズしています(後述) apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : recommendation-module resources : - knative-service-generated.yaml # OpenAPIのスキーマJSONを指定することで、カスタムリソースであるKnative/ServiceのpatchStrategicMerge方法を指定する # ref. https://github.com/kubernetes-sigs/kustomize/blob/master/examples/customOpenAPIschema.md openapi : path : custom-schema.json dev/zozo-module-recommendations-api-knative/kustomization.yaml apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : recommendation-module resources : - ./dev1 - ./dev2 ~~~ - ./dev20 dev/zozo-module-recommendations-api-knative/dev1/kustomization.yaml JSON Patchでリソース名を環境毎に書きかえます apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : recommendation-module resources : - ../../../base/zozo-module-recommendations-api-knative patchesStrategicMerge : - knative-service.yaml # リソース名を変更するために、patchesJsonを利用する patchesJson6902 : - target : group : serving.knative.dev version : v1 kind : 'Service' name : 'zozo-module-recommendations-api' patch : |- - op : replace path : "/metadata/name" value : dev1 dev/zozo-module-recommendations-api-knative/dev1/knative-service.yaml 各環境毎に、image・環境変数にパッチを当てています。必要に応じて変更します。 apiVersion : serving.knative.dev/v1 kind : Service metadata : name : zozo-module-recommendations-api spec : template : spec : containers : - name : zozo-module-recommendations-api image : gcr.io/example:dev env : - name : GCS_BUCKET value : example-bucket ~~~ - name : ENABLE_SWAGGER value : "true" 複数環境の構築で躓いた点 カスタムリソースに対するKustomizeのpatchStrategicMergeの挙動は自ら定義する必要がある 従来、baseのマニフェストで環境共通の値を定義し、環境間で異なる一部の値(環境変数など)のみpatchStrategicMergeでパッチを当てて定義していました。Kubernetesネイティブなリソース(PodやDeployment等)に対するpatchStrategicMergeの挙動はKustomize内で定義されています。例えばPodの spec.containers[] は配列で値が定義されますが、nameが一致した配列要素のvalueのみを置換する挙動が定義されているため、patchStrategicMergeを用いて配列の値の追加・一部置き換えができます。 他方、カスタムリソースに対するpatchStragtegicMergeの挙動はKustomizeにはデフォルトでは定義されていません。カスタムリソースに定義されているフィールドの値の置換方法は自ら定義しKustomizeに設定する必要があります。挙動が定義されていない場合、JSON Patchの挙動となり、合致するフィールドの値をそのまま置き換えてしまいます。Knative/Serviceのマニフェスト内で spec.template.spec.containers[] の中身を環境毎に一部置き換えようとするとbase側で定義している spec.template.spec.containers[] 全体がパッチで定義している値で置き換えられてしまいました。 この問題に対してはKustomize側で対応方法が用意されています。KustomizeにカスタムリソースのAPIについてのOpenAPIのスキーマJSONファイルを渡すことで、mergeの挙動を指定できます 9 。 今回、具体的には以下3つのステップで理想とする挙動を実現しました。 (1)Knative ServingのCRDをapply済みのKubernetesクラスタから既存のOpenAPIスキーマJSONを取得する $ kustomize openapi fetch > custom-schema.json (2)Knative/Service関連部分のスキーマを編集する 取得したデフォルトのOpenAPIスキーマJSONでは RevisionSpec 以下のフィールドについての定義が省略されていること、 RevisionSpec はいくつかの独自のフィールドと PodSpec のインライン展開で構成されていることから以下のように考えました。 RevisionSpecの部分について明示的にスキーマを定義することでpatchStrategicMergeの挙動をカスタマイズする 既存のPodSpecに対する挙動を実現できれば良いので、PodSpecのスキーマをそのまま挿入すればよい ※ Kubebuilderのinlineアノテーションに相当する機能はOpenAPIにはないので、PodSpecのスキーマを参照するのではなく、コピー&ペーストで挿入しています custom-schema.json 修正前 ~~~ "dev.knative.serving.v1.Service" : { ~~~ "properties" : { ~~~ "spec" : { ~~~ "properties" : { "template" : { "description" : "Template holds the latest specification for the Revision to be stamped out." , "properties" : { "metadata" : { "x-kubernetes-preserve-unknown-fields" : true } , "spec" : { # RevisionSpec以下のスキーマが省略されている "description" : "RevisionSpec holds the desired state of the Revision (from the client)." , "required" : [ "containers" ] , "x-kubernetes-preserve-unknown-fields" : true } } , "type" : "object" } , "traffic" : { ~~~ custom-schema.json 修正後 ~~~ "dev.knative.serving.v1.Service" : { ~~~ "properties" : { ~~~ "spec" : { ~~~ "properties" : { "template" : { "description" : "Template holds the latest specification for the Revision to be stamped out." , "properties" : { "metadata" : { "x-kubernetes-preserve-unknown-fields" : true } , "spec" : { # RevisionSpec以下のスキーマを明示的に定義する "containerConcurrency" : { "format" : "int64" , "type" : "integer" } , "timeoutSeconds" : { "format" : "int64" , "type" : "integer" } , "responseStartTimeoutSeconds" : { "format" : "int64" , "type" : "integer" } , "idleTimeoutSeconds" : { "format" : "int64" , "type" : "integer" } , # 以下はPodSpec のスキーマからコピペしたもの "activeDeadlineSeconds" : { ~~~ "containers" : { "description" : "List of containers belonging to the pod. Containers cannot currently be added or removed. There must be at least one container in a Pod. Cannot be updated." , "items" : { "$ref" : "#/definitions/io.k8s.api.core.v1.Container" } , "type" : "array" , "x-kubernetes-patch-merge-key" : "name" , # 何をキーにマージをするかを指定 "x-kubernetes-patch-strategy" : "merge" # patchを当てる際の挙動を指定 } , "required" : [ "containers" ] , "type" : "object" } } , "type" : "object" } , "traffic" : { ~~~ (3)(2)で作成したスキーマをKustomizeで用いる base/zozo-module-recommendations-api-knative/kustomization.yaml に openapi ブロックを追加してスキーマを指定 apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : recommendation-module resources : - knative-service-generated.yaml # OpenAPIのスキーマJSONを指定することで、カスタムリソースであるKnative/ServiceのpatchStrategicMerge方法を指定する # ref. https://github.com/kubernetes-sigs/kustomize/blob/master/examples/customOpenAPIschema.md openapi : path : custom-schema.json どれくらい費用を削減できているのか? 今回の用途でKnative Serving関連のPodを起動するにあたっては、 e2-small($12.23/月) ノードが4台あれば十分でした。 APIで利用するノードは n1-standard4($97/月) であり、リクエストが来た時のみ起動します。追加で用意したdev1〜20環境へは、開発案件がある時にのみリクエストが来るため、大きく見積もっても全環境合計で1ヶ月に1インスタンス分の料金になります。シンプルに20環境分のAPIを立ち上げる場合と比べると約92%( ≒100-(12.23(e2-small)*4+97(n1-standard4))/(97(n1-standard4)*20(環境数))*100 )、年間換算で $21,417 の費用削減になりました。 もちろん、あまり利用されない大量の開発環境APIを常に起動しておくのは現実的ではないので、実際にはここまでの費用削減にはなりません。また、依頼ベースで開発環境APIを起動・停止するといった手間のかかる運用作業がないのは嬉しいポイントです。 今後の展望/終わりに 本記事では複数のAPIを集約するマルチテナントGKEクラスタ上に、Knative Servingを用いて、既存のAPIをServerless Containerとして手軽に提供する環境を構築する方法をご紹介しました。今後はこの方法を用いて他のAPIも必要に応じて20個の開発環境APIを用意し、ZOZOTOWNの開発をより高速に進められる環境を整えていきます。 ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com hrmos.co Cloud Run は理解した。Cloud Run for Anthos って何? ↩ Envoy公式Doc: Configuration Reference ↩ Knative公式Doc: Traffic management ↩ Knative公式Doc: Tag Header Based Routing ↩ Knative公式Doc: Autoscaling ↩ Knative公式Doc: Converting a Kubernetes Deployment to a Knative Service ↩ serving.knative.dev/v1 Service ↩ Knative公式Doc: Feature and extension flags ↩ Kustomize公式Doc: Using a Custom OpenAPI schema ↩
アバター