TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

938

こんにちは、MA部の谷口( case-k )です。私達のチームでは配信システムの開発や運用をしています。 ZOZOでは配信システムを内製化しており、メルマガやPush通知、LINEメッセージ配信などを自社で実施しています。本記事では配信システムの障害対応の取り組みについてご紹介します。 現在の障害の発生頻度は週に数件程度ですが、1年ほど前までは連日障害が発生していました。障害のない日の方が珍しい状態で、ほぼ毎日数件の障害が発生していました。現在も週に数件程度は発生してますが、障害が丸一週間ない日もでてきました。1年ほど前と比べると月間の障害件数は70%〜90%減少しました。最近発生している障害もリリース起因やオペレーションミスによるものです。 本記事では障害が多かった理由やどのようにして改善していったのかご紹介します。同じように障害の対応に課題を抱えている方々の参考になると幸いです。 配信システム特有の障害と実施した対策 まず、配信システムにおける障害の性質をご紹介します。配信システムでは、主にスケジューラーを使用して配信処理を制御しています。アクセスログなどのイベントをトリガーにした配信もありますが、ほとんどはマイクロバッチを含むバッチ処理です。このため、ユーザ起因による新規の障害は少なく、ほとんどの障害は過去に一度発生したことがあるものになります。恒久対応をすることで、障害は解消されますが、対応しなければ問題は改善されません。 配信システムの障害・アラートの種類は、大きく3つの性質に分類できます。 障害の種類 事例 優先度 即時対応が必要かつ運用で対応できない障害 重複配信、秘密情報の漏洩等 超高い 即時対応が必要だが運用で対応できる障害 配信処理やデータ連携処理の中断 高い 通知のみで対応不要なアラート 配信処理に影響がないと即時断定できるアラート 低い 実際に発生した障害を例にご紹介します。 同じお客様への重複配信 まず最初にご紹介する障害はお客様へ重複配信してしまう障害です。配信処理が冪等になっておらず、リトライによって重複配信されてしまいました。 対処 まず配信が継続している場合は配信を止めます。次に重複配信してしまったお客様を特定します。並行してCSとも連携してお客様に対する謝罪文を用意し、お詫びのご連絡を入れます。 原因 重複配信が起きてしまうのは配信処理が冪等になっていなかったのが原因です。配信処理の前に配信済みかチェックするための処理は入っていましたが、不十分でした。同じワークフローを2度実行してしまうケースを想定しておらず重複配信されてしまいました。 対応 配信処理の前に配信済みかチェックするための処理を見直し改善しました。また、現在実装の不備で重複配信されてしまった場合は検知できるよう監視を整備しています。「即時対応が必要かつ運用で対応できない障害」はほとんど発生しませんが、発生した場合はこれまでも即日で再発防止策を導入してきました。 配信処理の中断 次にご紹介するのはメモリリークやスケーリングの失敗等様々な利用で配信処理が中断してしまう問題です。配信システムではメールやLINE、Push等様々なチャンネルに対して配信処理をしています。 しかし、様々な問題が原因で配信処理が中断されてしまい、手動での復旧が必要でした。理由は本当に様々で、配信処理に必要なワーカーのスケーリングに失敗したり、メモリのリーク、リトライの未実装、Cloud SQLのメンテナンス起因の障害、GKEのノード障害、Postgresのロックが解放されない問題など様々です。 これらの障害は長年放置されてきたこともあり、運用負荷を高めていました。また、障害対応には手動の対応が発生します。オペレーションミスによって、重複配信されてしまうことも過去にありました。 LINE配信のメモリリークに関する問題は以前以下の記事にも書いています。 techblog.zozo.com 対処 配信中断系の障害の多くは対応手順が決まっています。対応順に従い障害対応者が再配信できるよう対応しました。リカバリに時間がかかってしまうものは、関係者と連絡を取り合いながら対応してきました。 原因 配信処理が中断してしまう原因は様々ですが、根本的には障害に対し恒久対応を入れる習慣の欠如、優先度の低さが理由です。 対応 配信処理が中断されないように原因を特定したり、原因特定に至らないものは手動のオペレーションを自動化するなどして対応してきました。具体的にどのようにして、優先度をあげ、習慣を作ったのかは後述の「障害対応の体制を見直す」でご紹介します。 配信処理に影響がないと即時断定できるアラート 次に紹介するのは自動でリトライされるなどして、実際には配信処理への影響がないアラートです。配信システムでは、PushやLINE配信など、メッセージングキューを利用するシステムが多く存在しています。これらのシステムではリトライで成功する一時的なエラーも頻繁に発生していました。その他にもバッチ処理のSLAが超過している処理も数多くありました。発生しているものはアラートとして通知はされますが、確認のうえ対応不要として静観されているものがありました。 対処 電話はなるため深夜でも叩き起こされますが、配信処理に影響のないと判断し静観します。 原因 監視すべき対象やバッチ処理を作成した当時からSLAの見直しが行われてこなかったのが原因です。これらも根本的には障害に対し恒久対応を入れる習慣の欠如、優先度の低さが理由です。 対応 これらの障害に対しては、最終的なアウトプットに焦点を当てた監視を設定したり、障害を警告レベルに抑えて緊急対応が不要となるように恒久対応を入れてきました。また、バッチ処理のSLAを見直し、各種バッチのSLAの調整しました。さらに、要件に必要なSLAを超過してしまうバッチは、処理を見直すなどの根本対応を実施しました。具体的にどのようにし習慣を作ったのかは後述の「障害対応の体制を見直す」でご紹介します。 障害対応の課題 これまでも配信基盤チームでは障害対応をしてきました。当番制で運用しており、障害対応の当番は当番週に発生した障害の対応をし、週次で実施している障害振り返りのタイミングで発生障害をチームに共有していました。以降「アラート当番」と記載します。前述したとおり、即時対応が必要かつ運用で対応できない障害に対しては恒久対応を施してきました。しかし、即時対応が必要で、運用で対応できる障害や対応が不要な障害は長年放置されてきました。配信チームで抱えていた障害対応の課題についてご紹介します。 増え続ける障害 前述の通り、緊急性の高い障害に対しては即日で恒久対応してきました。しかし、緊急性が高いものでも、運用で対処可能な障害や対応が不要な障害は長年放置されてきました。配信システムの性質上、同じ障害が何度も発生することが多く、古いものでは数年前から週数回程度の頻度で発生している障害も存在しました。新規施策や新しい取り組みを実施することで、障害は増加し続け、結果として障害のない日が珍しい状況となっていました。 開発業務に集中することが難しい 運用で対処できるとは言っても、緊急性の高い障害には即時対応が必要です。開発などの他の業務を行っていても、障害対応を最優先しなければなりません。アラート当番ではなくても、対応に詳しい人が限られている場合、Slackのハドルに次々と集まり、本来集中すべき開発業務に専念できない状況が続いていました。 連日の対応による幸福度の低下 障害のない日が珍しい状況では、アラート当番週であれば深夜に連日起こされることも珍しくありませんでした。休日も基本的に障害が発生するため、待機当番は外出が困難になります。障害の発生頻度が低ければ、PCを持ち歩き、モバイルWi-Fi等で対応する方法を採用できます。しかし、障害が当たり前のように発生する状態では、外出を控えざるを得ません。その結果、生活体験が損なわれ、幸福度が低下してしまいます。なお、休日の2日間待機した場合は待機休暇が半日つきます。 対応者の偏りと成長機会の損失 障害の一次対応は、これまで二人で行われていました。二人で対応すると経験の多い一方が主導して対応することが多くなります。対応できる人が偏り、対応できない人はスキルが身につき難い状態となっていました。実施した経験がないため、いざやってみるとDB接続やSSH、権限不足等ですぐに対応できない状態になっていました。緊急の対応が必要になるため、熟練者の運用負荷はあがる一方で、経験のすくないメンバーは成長機会を得られない状態が続いていました。 お客様の体験を損なう 運用で対応できるとはいっても、対応に時間がかかってしまう障害もありました。配信効果を最大化できるタイミングで配信できなかったり、オペレーションミスによって、重複配信してしまうなどお客様の体験を損なってしまうこともありました。 恒久対応を施す習慣の欠如 これまでも週次の障害の振り返りを行う時間を設けていました。 前週のアラート当番が発生した障害をチームに共有することで、障害の理解度を測っていました。しかし、原因の調査深掘りや対応方針の策定、恒久対応をいれる責務はアラート当番から外されていました。簡単に対応できるものであれば、すぐにPull Requestを作り改善するのが理想的ですが、恒久対応をいれる習慣がなかったため放置されてきました。 障害対応の体制を見直す 前述したように、配信システムの性質上、恒久対応をすることで同じ障害が発生しなくなります。ここでは、恒久対応を実現するために実施した施策をご紹介します。 まず、障害振り返りの運用体制を見直しました。これまでも障害振り返りは週次で実施していました。 これまでのアラート当番の役割は以下の通りでした。 アラート当番は当番週に発生した障害の対応をすること 障害振り返りで発生した障害をチームに共有すること 新規の障害に対して手順書を作成すること 体制を見直す以前は、アラート当番週の障害対応と、チーム内に発生した障害を共有するまでを責務としていました。 改善後は恒久対応までをアラート当番の責務としました。数十分程度で恒久対応できる障害はアラート当番が実施することにしました。 アラート当番の責務として以下のような運用ルールを定めました。障害振り返り実施の流れについてはこの後ご説明します。 アラート振り返りの改善点・相談・連絡事項 まずアラート振り返りの冒頭で障害や体制自体の相談時間を設けています。障害対応の体制を継続的に改善できるようにするためです。 以下のように相談したい内容があれば書き出します。 アラートログの黙読 アラート当番が先週発生した障害をチーム内に共有します。先週発生した障害はスプレッドシートにまとめられています。スプレッドシートには障害ごとに発生した件数と手順書がまとめられています。チームメンバーは発生した障害を確認し、不明点等あればアラート当番に確認します。対応が不明確な障害はこのタイミングで議論します。 アラートタスクの確認 これまで障害タスクの管理が十分でなかったため、障害対応の体制を見直し、恒久対応を実施するようになりました。障害は、振り返り時にタスク化し、担当者と期日を設定して進捗を把握できるようにしています。軽微な障害は、アラート当番が恒久対応を担当しています。 時間がかかる恒久対応タスクについては、障害振り返りのタイミングで担当者を決定します。タスクの進捗はJIRAを用いて管理しています。 エスカレーションポリシーの見直し また、障害の一次対応は二人体制で行っていました。一時対応者の二人が対応できない場合は、全員にエスカレーションが行きます。 しかし、前述の通り一次対応者が二人体制だと、対応する人が偏りがちでした。熟練者と新人が組んだ場合、急ぎ対応が必要な障害対応では熟練者に頼る傾向があります。新人の育成に問題が生じていました。 連日のように障害が発生している状態では障害が同時多発的に発生するため、一人で対応することは困難です。しかし、障害がある程度落ち着いた時期には、一次対応者を一人にする方が望ましいと思います。一次対応者のペースで原因調査から、エスカレーションを含む対応の意思決定の経験を積めるからです。 配信チームでは、障害が落ち着いた時期に一次対応者を一人に変更しました。一次対応者が対応できない場合は、二次対応者に通知され、さらに対応できない場合は全員に障害が通知される体制をとっています。 一次対応者を一人にすることで、育成面と運用負荷の両面で改善されました。 障害対応の体制を見直した結果 かつて連日のように発生していた障害は、半年間で週に数件程度にまで抑えることができました。先日ついに7日間連続して障害のない日が続きました。 ちょうど1年前の月間の障害件数を見比べたものです。約70%減少しました。当時は1日あたり平均2〜3件障害が発生してる計算になります。また、当時は同じ障害が繰り返し発生していたことも分かります。 2022年4月に発生した障害:65件 2023年4月に発生した障害:9件 運用負荷が減った 運用負荷が軽減されました。体感できるレベルで減りました。以前は障害が発生する度にSlackへ集まる動きがありましたが、障害が減少し、開発業務にも注力できるようになりました。連日叩き起こされることもなくなりました。また、障害の一次対応も障害が減ったことで2人から1人になり、一次対応が必要なケースは半減しました。アラート当番の運用も楽になりました。 当たり前に恒久対応を入れる習慣が根付いた 障害対応者が発生した障害に対して自然とPull Requestを作り、恒久対応を入れる習慣がチーム内に根づきました。 軽めの障害だと、障害発生時に恒久対応のPull Requestをその場で作る動きも観測できるようになりました。感動です。 連日の対応がなくなり幸福度UP 連日のように障害対応をすることもなくなり、連日深夜に叩き起こされることもなくなりました。基本的には障害が発生しなくなったので、アラート当番でもPCは持ち歩き週末出かけられるようになりました。みんな幸せになった気がします。 対応者の偏りがなくなり、成長機会が増えた 一次対応者を一人にしたことで対応者が偏らなくなり、スキルの向上にも繋がっているように思います。障害に対して原因を調査し、対応できるようになっています。最初はいざ一人で対応するとSSHやDB接続に苦戦し、GCPリソースの権限が不足しているなどもありました。一次対応者が一人で対応できるようになることで、「対応できそう」から「対応できる」ようになったように思います。 障害対応の体制改善後の課題と解決策 障害の対応体制を見直した後に発生した課題とその解決策についてもご紹介します。 積まれていく未消化のタスク 障害の発生件数が安定しているときには、担当者を割り当てることで問題を解決可能です。しかし、連日障害が発生している状況下では、担当者だけでは対応が追いつきません。未消化の障害タスクが蓄積されてしまいます。 連日のように障害が発生している状態では、迅速な対応が重要となります。障害担当者に関係なく、高い発生頻度の障害に優先的に対処することが必要です。そうでなければ、障害タスクがたまり、対策自体が形骸化してしまうことになります。対応においてもできるだけ、即効性のある対応が必要です。このあたりは障害の発生頻度にもよると思います。クエリのパフォーマンスを改善しなくても要件を満たせる場合はSLAを緩めたり、OSSの修正に時間がかかる場合やOSSを使わないような判断もしてきました。 障害改善と運用改善が区別できていない これまで発生した障害に対して優先度は設定されていませんでした。障害の発生頻度が低い場合、優先度も低くなります。優先度が設定されていないと、未着手の障害対応タスクが蓄積されてしまいます。 そこで、発生した障害に優先度を設定することにしました。優先度が低い障害は「運用課題」として別途対応することになりました。優先度は、発生頻度が高いものや運用でのリカバリが困難なものに限定しました。障害に対して優先度をつけ、障害改善の優先度をあげ、運用改善を区別するようにしました。 発言者の偏り 前述した「アラートログの黙読」の際には、質問タイムが設けられています。この時間では、発生した障害に関する質問や対応策について話し合われます。以前は発言者が一部に偏っていたため、ルールの変更が行われました。現在の質問タイムでは、先週のアラート当番が質問をするようになっています。 習慣を作る上で大切だと思ったこと 体制を見直していく上で個人的に大切だと思ったことをご紹介します。 障害対応を優先的にすること 恒久的な障害対応を実施する際、人それぞれ障害の優先度が異なるため、説得が必要でした。実際に施策を進める中で、障害対応よりも開発業務に注力した方が良いという意見もありました。当初の賛否は、半々くらいでした。しかし、LINE配信のメモリリーク対応など多い時では週に3回程度発生し、リカバリも複数人で2時間程度使っていました。状況を整理し、チーム内で納得感を持って説得し、障害対応の優先度を高くしていく必要がありました。 障害を改善していく気持ち 常態的に障害が発生している場合は習慣だけだと捌ききれません。ある程度集中して、障害の発生頻度を抑える必要があります。連日のように障害が発生している状態では担当関係なく、開発業務と並行して週に2〜5つ程度のペースで恒久対応を入れていきました。障害を改善していく気持ちも大切です。 継続的に改善していくこと 新しい開発や施策の実施により、新規の障害も発生します。一定の抑制ができた後も、チーム内で継続的に改善する習慣が必要です。そうしないと、徐々にまた障害が増えていきます。恒久対応が当たり前になるよう、チームで取り組むことが望ましいです。継続的に施策を改善できるよう振り返りの時間を設けることも大切だと思います。 今後の課題 障害の対応体制を見直した結果、週数件程度にまで障害は減りました。発生頻度が高く、繰り返し発生する障害には何らかの恒久対応が入っています。障害が減ったことは嬉しいですが、今後の課題としては以下のようなものがあると思います。 オペレーション起因の障害 配信システムでは、配信セグメントの作成などの配信施策はマーケターによって行われています。一部の自動化できないオペレーションもあるため、オペレーションミスによる障害が発生しています。全ての改善はできませんが、CIでの確認や権限管理等で防げる部分もあるため、今後対応していけたらと思います。 障害対応の経験が積み難くなった 喜ばしい悩みではありますが、障害が減少したことで、障害対応の経験が積みにくくなったように感じます。恒久対応が施された障害の9割以上は手順が決まっているものでしたが、障害が発生するとログやコードを読み原因を調べる機会もありました。障害がすくなくなったことで経験を積み難くなったように思います。とはいえ、手順の決まっている障害で障害対応力を鍛えることは難しいため、このあたりは課題に感じます。昔半年間ほど2人で障害対応をしていた時期があり、障害対応力をかなり鍛えられましたが健康に悪いのでお勧めはできません。 最後に 配信基盤チームの障害対応の事例についてご紹介しました。一年前だと過酷な状態でしたが、今では障害も少なく健康的に働け、開発業務にも集中できるようになっています。 この記事を読んで、もしご興味をもたれた方は是非採用ページからお申し込みください。 corp.zozo.com
アバター
こんにちは。ML・データ部 データ基盤ブロックの塩崎です。最近はつちのこフェスタが4年ぶりに開催されたというニュース 1 でアフターコロナの訪れを感じています。 さて、データ基盤のためのデータ転送パイプライン構築といいますと、多くの方はMySQLなどのデータベースからのデータ連携を思い浮かべるかと思います。実際にシステムの保有する多くのデータはデータベースに保存されており、データベースからのデータ連携は大きな部分を占めます。当ブログでも数々の事例を紹介してきました。 しかし、それ以外にもデータを保有しているソースは数多く、それらからのデータ連携を作成する必要もあります。今回は日本の多くの企業で導入されているクラウドサービスであるkintoneからBigQueryへリアルタイムにデータ連携する事例を紹介します。 従来手法について 既にkintoneからBigQueryにデータ連携するソリューションは数多く、Googleで「kintone BigQuery」などのクエリで検索すると多くの記事が見つかります。しかし、それら既存の手法には以下の欠点があったため、今回は一から自作してみました。 日次などの頻度でのバッチ連携をしているので、データの反映にタイムラグがある 有料のパッケージソフトもしくはSaaSが必要になる 提案手法 今回構築したシステムのアーキテクチャ図を以下に示します。 まず、BigQueryのRemote Functions機能を使い、Cloud Functionsを外部関数として登録します。そして、Cloud FunctionsからkintoneのWeb APIを呼び出すことでデータを取得します。 Remote Functionsについて Remote Functions機能はCloud FunctionsもしくはCloud RunをBigQueryから呼び出すことができる機能です。 cloud.google.com BigQueryにはもともとユーザー定義関数(UDF)という似たような機能があり、SQLやJavaScriptで関数を定義できました。しかし、この機能は制約が多く、一部の処理は記述できないという問題点がありました。例えばXMLHttpRequestやfetchなどのネットワーク通信をともなう関数を呼び出すことは不可能でした。そのため、従来のUDFでkintoneのWeb APIを呼び出すことはできません。 cloud.google.com 一方でこのRemote Functions機能はCloud FunctionsやCloud Runの機能を活用できるので柔軟性が非常に高いです。Web APIの呼び出しができるのはもちろんのことですが、プログラミング言語やライブラリも柔軟に選択できます。今回の件ではランタイムのDockerイメージをカスタマイズできる柔軟性は不要でしたので、Cloud Functionsを使うことにしました。また、Cloud Functions上で動かす処理はシンプルなものなのでどの言語でも問題なく実装できますが、今回はビッグデータ系での利用者が多いPythonを使います。 kintone APIについて kintoneに登録されたデータはWeb画面から閲覧・更新できるだけでなく、Web APIを通して閲覧・更新を行えます。 cybozu.dev このREST APIを直接呼び出しても良いですが、有志が様々な言語でSDKを作成しているので今回はそれを使います。 cybozu developer networkでも紹介されているpykintoneというライブラリ を使用します。 github.com 実際に作ってみる では、ここからは実際にkintoneとBigQueryを連携するためのシステムを構築していきます。 Cloud Function まずは、以下のPythonコードでkintoneからデータを取得して、その結果を返却する関数を作成します。 import os import re import logging import yaml import json import pykintone import functions_framework import flask def read_yaml (path): envvar_matcher = re.compile( r'\${([^{^}]+)}' ) envvar_tag = '!envvar' def envvar_constructor (loader, node): value = loader.construct_scalar(node) matched = envvar_matcher.match(value) if matched is None : return value envvar_name = matched.group( 1 ) return os.environ[envvar_name] yaml.add_implicit_resolver(envvar_tag, envvar_matcher, None , yaml.SafeLoader) yaml.add_constructor(envvar_tag, envvar_constructor, yaml.SafeLoader) with open (path, 'rb' ) as f: return yaml.safe_load(f) def read_kintone_records (kintone_app, batch_size= 500 ): raw_records = [] offset = 0 while True : query = f "order by $id asc limit {batch_size} offset {offset}" logging.info(f "executing: {query}" ) result = kintone_app.select(query) if result.ok: raw_records.extend(result.records) logging.info(f "total count is {result.total_count}" ) logging.info(f "{len(raw_records)} rows fetched" ) offset += batch_size if result.total_count == len (raw_records): break else : logging.error(result.error) logging.error(result.detail) raise RuntimeError (f "Error while reading kintone records: {result.error}" ) break return [ {key:value[ 'value' ] for key, value in raw_record.items()} for raw_record in raw_records ] @ functions_framework.http def read_kintone (request): try : request_json = request.get_json() calls = request_json[ 'calls' ] if len (calls) != 1 : # 後述 raise RuntimeError ( "this function must be call in scalar subquery!" ) app_id = calls[ 0 ][ 0 ] kintone_app = pykintone.account.Account.loads(read_yaml( "account.yaml" )).app(app_id) kintone_records = read_kintone_records(kintone_app) return_value = [kintone_records] return flask.make_response(flask.jsonify({ "replies" : return_value})) except Exception as e: return flask.make_response(flask.jsonify( { "errorMessage" : str (e)}), 400 ) kintoneにアクセスするためにはAPIキーが必要ですので、以下のようなYAMLファイルも用意します。kintoneはアプリ毎にAPIキーが独立しているため、必要に応じてBigQueryと連携したいアプリのAPIキーをYAMLファイルに記載します。 domain : <kintoneのドメインから.cybozu.comを除いたサブドメ> apps : hoge_master : id : <アプリID> token : ${KINTONE_API_KEY_HOGE_MASTER} fuga_master : id : <アプリID> token : ${KINTONE_API_KEY_FUGA_MASTER} BigQueryとの間のデータの入出力の形式は以下のページを参考にしました。 cloud.google.com Remote Functionsの返り値の型は構造体型や配列型をとることはできず、スカラー型である必要があるという制約があります。そのため、JSON型を返却することで擬似的に複合型を返したかのような振る舞いをさせています。 また、以下のようなSQL呼び出しをすると、kintone APIをテーブルの行数と同じ数だけ呼び出してしまいkintoneのAPIレート制限に一瞬で達してしまいます。そのため、そのような呼び出しをした場合にはkintone APIを呼び出す前に関数を失敗させています。ソースコードの if len(calls) != 1: 部分でその条件分岐をしています。 SELECT read_kintone() FROM <大きなテーブル> その後、以下のシェルスクリプトでCloud Functionsにデプロイをします。Cloud Functionsには第一世代と第二世代の2種類がありますが、第二世代の方はリソース制限が緩和されており第一世代を選ぶモチベーションは少ないので第二世代を使います。なお、このコマンドを実行する前に以下の操作が必要です。 Cloud Functions用のサービスアカウントの作成 kintoneのAPIキーを格納するシークレットをSecret Managerで作成 サービスアカウントにシークレットの読み出しロール(roles/secretmanager.secretAccessor)の付与 PROJECT_ID = < プロジェクトID > PROJECT_NUMBER = $( gcloud projects list --filter= " PROJECT_ID: $PROJECT_ID " --format= " value(projectNumber) " ) gcloud functions deploy read_kintone \ --project= $PROJECT_ID \ --region=us-central1 \ --gen2 \ --runtime python39 \ --entry-point read_kintone \ --trigger-http \ --no-allow-unauthenticated \ --run-service-account read-kintone@ $PROJECT_ID .iam.gserviceaccount.com \ --set-secrets=KINTONE_API_KEY_HOGE_MASTER=projects/ $PROJECT_NUMBER /secrets/kintone_api_key_hoge_master:latest \ --set-secrets=KINTONE_API_KEY_FUGA_MASTER=projects/ $PROJECT_NUMBER /secrets/kintone_api_key_fuga_master:latest BQから読み出すための設定 次に先程の関数をBigQueryから呼び出すための設定をします。以下のterraformを反映するとBigQueryとCloud Functionsが接続されます。 resource " google_bigquery_connection " " cloud_resource " { connection_id = " cloud_resource " location = " US " description = " Connection for Cloud Resource " cloud_resource {} } data " google_cloud_run_service " " read_kintone " { name = " read-kintone " location = " us-central1 " } resource " google_cloud_run_service_iam_member " " read_kintone " { location = data.google_cloud_run_service.read_kintone.location service = data.google_cloud_run_service.read_kintone.name role = " roles/run.invoker " member = " serviceAccount:${google_bigquery_connection.cloud_resource.cloud_resource[0].service_account_id} " } 一番上で作成している google_bigquery_connection はCloud Resource Connectionです。これはBigQueryとCloud Function・Cloud Runなどを繋ぐためのリソースです。このConnectionはサービスアカウントを持ち、BigQueryからCloud Functionsを呼び出す時にはそのサービスアカウントを使います。 cloud.google.com そのため、Connectionのサービスアカウントに対してCloud Functionsを呼び出すロールを割り当てます。Cloud Functionsの第二世代は裏側でCloud Runが動いているため、 functions.invoker ロールではなく run.invoker ロールを割り当てる必要があります。 cloud.google.com 最後にBigQueryでCREATE FUNCTION文を実行してBigQuery上で関数を作成します。 ここまでの準備は最初に1回だけ行えば十分で、2回目以降は不要です。 CREATE FUNCTION `<プロジェクトID>.<データセットID>.`.remote_kintone() RETURNS JSON REMOTE WITH CONNECTION `<プロジェクトID>.US.<コネクション名>` OPTIONS ( endpoint = ' <Cloud FunctionsのエンドポイントURL> ' ) BQから呼んでみる この関数を呼び出すと以下のような非常に巨大なJSONが返されます。このままの形式ですと非常に扱いづらいため JSON_* 系の関数を使って扱い易い形式に変換します。 以下のSQLでJSONの中の各要素を取り出して通常のテーブルの列のように変換できます。巨大なJSONは構造体の配列という型をとっているので、まずJSON_QUERY_ARRAYで配列を分解し、JSON_VALUEで構造体の各要素を抜き出しています。 SELECT JSON_VALUE( row .company_code) AS company_code, JSON_VALUE( row .employee_code) AS employee_code, (省略) FROM UNNEST(( SELECT JSON_QUERY_ARRAY(`<プロジェクトID>.<データセットID>.read_kintone`( 1 ), " $ " ) )) AS row なお、このときにUNNEST関数の引数は必ず二重括弧で囲む必要があります。外側の括弧は関数呼び出し、内側の括弧はスカラーサブクエリという別々の役割を持つために一重括弧では不十分です。 cloud.google.com 実際にこの機能を運用に乗せるときには、一々 JSON_* 系関数を使うのではなく、上記のようなSELECT文をVIEWとして保存すると複雑な処理が隠蔽されて使いやすくなります。 まとめ BigQuery Remote Functions機能を使いCloud Functionsを呼び出すことで、kintoneのデータをBigQueryから取得できるようになりました。既に知られている手法と比較するとリアルタイムかつ安価であるというのが利点です。また、この方法を応用することでkintone以外のWeb APIとBigQueryを繋ぐことも可能です。 ZOZOでは、一緒に楽しく働く仲間を募集中です。ご興味のある方は下記採用ページをご覧ください! corp.zozo.com https://www.vill.higashishirakawa.gifu.jp/syoukai/gaiyo/tsuchinoko/tsuchinokofesta/ ↩
アバター
こんにちは。SRE部ECプラットフォーム基盤SREブロックの亀井です。 4月18日から4月21日にかけてKubeCon + CloudNativeCon Europe 2023(以下、KubeCon)が行われました。今回弊社からはZOZOTOWNのマイクロサービス基盤に関わるメンバー2名で参加しました。 本記事では現地の様子や弊社エンジニアが気になったセッションについてレポートしていきます。 目次 目次 KubeCon EU 2023の概要 参加メンバーによるセッション紹介 Flux Beyond Git: Harnessing the Power of OCI Unlocking the Potential of KEDA: New Features and Best Practices 最後に 番外編:現地の様子をお届け KubeCon EU 2023の概要 昨年10月にデトロイトで行われたKubeCon NAの様子については こちらの記事 をご覧ください。 今回参加してきたKubeConはオランダのアムステルダムで現地+オンラインのハイブリッド開催でした。10,000人以上が現地で参加しており、これはヨーロッパ開催のKubeCon史上最大であり、ヨーロッパで最大のオープンソースカンファレンスになるとのことでした。 この形式での開催は新型コロナウィルス感染症(COVID-19、以下コロナ)のパンデミック以降3度目の開催であり、ついにマスク着用が義務化ではなく推奨となりました。 KubeConではキーノートやセッション、LTなどを通してKubernetesに関する最新のアップデートの紹介や、実際にKubernetesを採用した企業の幅広い運用ノウハウを聞くことができます。以降では参加してきた社員がそれぞれ気になったセッションについて取り上げてご紹介します。 参加メンバーによるセッション紹介 Flux Beyond Git: Harnessing the Power of OCI SRE部ECプラットフォーム基盤SREブロックの巣立( @ksudate )です。 Weaveworks社のStefan ProdanとHidde BeydalsによるOCIに関するFluxの最新動向についてのセッションでした。 冒頭では、Fluxに関連するコントローラーやエコシステムの概要が紹介されました。その中で、CloudFormationのスタックをFluxで管理するAWS CloudFormation Template Sync Controller for Fluxは個人的にとても気になっています。 https://github.com/awslabs/aws-cloudformation-controller-for-flux 本題のOCI(Open Container Initiative)についてです。 従来のFluxでは、クラスター構成をGitから、コンテナイメージをイメージレジストリからPullしてくる必要がありました。 ( Flux Beyond Git: Harnessing the Power of OCI より引用) しかし、クラスタ構成とコンテナイメージの両方をコンテナレジストリを使って管理することでシンプルになります。 この構成を実現する方法として、OCIアーティファクトが紹介されました。 また、cuelangやjsonnetなどを利用している場合、Gitリポジトリには最終的なKubernetesマニフェストが含まれていない場合があります。 そのような場合、OCIであればCIで生成済みのマニフェストをOCIアーティファクトとして公開できます。 ( Flux Beyond Git: Harnessing the Power of OCI より引用) 実際にFluxでは、OCIRepositoryと呼ばれるCustom Resourceを利用する事でこれが可能になります。 OCIアーティファクトをイメージレジストリへPushするためのfluxcliのコマンドも用意されています。 flux push artifact flux pull artifact flux list artifacts その他にもGitと比較した場合のOCIのメリットが紹介されました。 例えば、OCIはAPIベースで操作できるのに対して、GitはAPIベースで操作できません。 APIベースでOCIアーティファクトを保存・取得・更新できるため、Gitに比べて扱いやすくなります。 ( Flux Beyond Git: Harnessing the Power of OCI より引用) また、OCIアーティファクトの検証方法についても、触れていました。 OCIではSigstore Cosignを利用しており、GitのOpenPGPと比べて管理が楽になります。 OCIの登場により、FluxはさらにGitをSSoTとして扱うことが可能になりました。 より詳しくは、公式ドキュメントも併せてご覧下さい。 Unlocking the Potential of KEDA: New Features and Best Practices 亀井です。 このセッションではKEDAプロジェクトについて概要と直近の変更点・ベストプラクティスが紹介されていました。 KEDA(Kubernetes-based Event Driven Autoscaling)はKubernetes上でイベント駆動(何かのイベントに応じて処理を行う仕組み)のオートスケールを実現するオープンソースプロジェクトです。 イベントソースを監視して、Horizontal Pod Autoscaler(以下、HPA)などの標準的なKubernetesコンポーネントを拡張しPodの数を動的に調整できます。サポートするスケーラーはAzure Functions、Apache Kafka、RabbitMQ、Azure Service Bus、NATS、AWS SQSなど60を超えます(ref. サポートしているスケーラー一覧 )。 下図がアーキテクチャです。ScaledObjectカスタムリソースで、スケーラーや条件といったトリガーとスケールの内容を定義します。KEDAの各コンポーネントがスケーラーを監視し、HPAなどのKubernetesコンポーネントと連携しPodの数を動的に調整します。 ( Unlocking the Potential of KEDA: New Features and Best Practices 資料9ページより引用) セッションでは下記の4つの直近の変更点が紹介されていました。 Architecture Changes 安定化のため外部スケーラーとの通信に関する構成 Certificate Management TLS1.3で暗号化されるコンポーネント間通信の証明書管理 Validation Webhooks ScaleObject Custom Resourceのvalidation Prometheus Metrics 特に気になった変更点は「Prometheus metrics」でPrometheusメトリクスを公開するようになったことです。スケーラーの状況やコンポーネントのエラー数といったメトリクスが取得可能です(ref. すべてのメトリクス )。本番サービスでKEDAを運用するハードルが下がる大きなアップデートだったのでは無いでしょうか。 また、次の3つのベストプラクティスが紹介されていました。 Polling Interval & Metrics Caching 外部スケーラーへのクエリ実行間隔の考え方とキャッシュ機能について HPA Scaling Behavior HPAのスケーリング動作を制御するオプションについて Kubernetes Metrics KEDA運用にあたって関連するKubernetes Metricsについて ZOZOTOWNでは、人気商品の販売やセールなどが定期的に行われております。そのタイミングでアクセスがスパイクするのですが、HPAに頼ったオートスケールでは間に合わないことが多々あります。事前にHPAのminReplicasを増やすなどしてPodのスケールを行っているのですが、都度作業しておりtoilになっています。 KEDAを使うことで、人気商品の販売やセールといった予定をイベントソースとしてスパイク前にオートスケールができそうに感じました。Kubernetesにおけるオートスケールの新たな選択肢として、今後も動向を注視し導入の検討を進めて行きたいと思います。 最後に 2名ともKubeConは2回目の参加、EUは初めての参加でした。EUはNAと比べ規模が小さいのではと思っていましたが全くそんなことはなくNA同様に数々のKubernetesやそれに関わるエコシステムに関する学びを得ることができました。 マスクの義務化が無くなったおかげか、コロナ前のKubeConのように各所で人々の笑顔や真剣な表情が見て取れるカンファレンスでした。 ZOZOでは一緒に働くエンジニアを募集していますので、興味のある方は以下リンクからぜひご応募ください。 hrmos.co 番外編:現地の様子をお届け 会場のRAI Amsterdam Convention Centreです。 周辺には飲食店もいくつかあり、アムステルダム中央駅からも比較的近かったのが良かったです。 続いては、恒例のKubeconで提供されるランチです。 いくつか種類があり、Vegan用のランチもありました。 ランチ会場はくつろげるスペースが多く提供されていたのでセッションの合間もこちらでゆっくり過ごせました。 そして、会場には荷物を預けるサービスもあったのでキャリーバッグを持ったまま、会場へ足を運ぶ人も多くいました。 また、今回は日本人の参加者も多く現地での交流会も行われ、とても楽しい時間を過ごす事ができました。 次回のKubecon EUはフランス パリで開催です。年々、参加者が増加しているので更なる盛り上がりに期待です! 以上、番外編でした。
アバター
こんにちは、MA部MA開発1ブロックの齋藤( @kyoppii13 )です。 ZOZOTOWNではユーザ行動に基づくキャンペーン配信を実施しています。この配信はリアルタイムマーケティングシステム(以降、RTM)と呼ばれるシステムによって実現しており、RTMでは配信トリガーや配信タイミングの最適化等にユーザの行動ログを利用しています。 この行動ログは、ユーザがZOZOTOWNのページへアクセスした際に、HTTPリクエストをRTMが直接受信する形で収集していました。しかし、RTMの既存のログ収集機能はシステム要件や運用などの課題を抱えていました。また、その一方で全社的にログを収集・蓄積する基盤も並行して運用されており、RTMはこのログ基盤を活用できていませんでした。そのため、RTMでもこの全社ログ収集基盤を利用することで既存の課題を解決しました。 本記事では、RTMにおける行動ログの活用方法と、全社ログ収集基盤への移行で考慮した点について紹介します。 RTMでの行動ログ活用方法 アクセスログ クリックログ メール開封ログ コンバージョンログ RTMでのログの取得フロー ログ取得までのフロー 従来のログ取得における課題 全社ログ収集基盤が存在しているにもかかわらずRTMでログを取得している 直接ログを集めている ログ取得時間がサーバでのログ検知日時になっている 配信処理とログ取得処理が密結合になっている 全社ログ収集基盤への移行 移行後のアーキテクチャ 2種類のログ日時 ログ連携頻度と連携方法の見直し アクセス/クリックログからのコンバージョンの取得 各ログイベントを抽出 新規セッションの判定 セッションごとにユニークなIDを付与 コンバージョン判定 今後の展望 まとめ さいごに RTMでの行動ログ活用方法 本章では、RTMでの行動ログ活用方法について紹介します。 まず、RTMがどのようなシステムかを説明します。RTMはユーザの行動や商品在庫の変化などをトリガーとして、ユーザごとにパーソナライズ配信をするシステムです。配信チャネルはLINE・メール・プッシュ通知があります。例えば、あるユーザがお気に入りしている商品が値下がりした場合に「あなたがお気に入りしている商品が値下がりしました」という訴求をします。 RTMで配信する場合、どのようなイベントが発生したときにどのような内容を訴求するかのルールを定義します。この定義をキャンペーンといいます。商品在庫などが変化した場合、RTMは定義されたルールに従いキャンペーン判定をします。ルールにマッチした場合、対象のユーザを抽出し、ユーザごとに配信内容(コンテンツ)を組み立てて配信をします。システム名にリアルタイムとついていますがリアルタイムな配信のみならず、配信時は最適化処理も実施し、ユーザごとに最適なチャネルや時間帯に配信をします。 イベントの検知から配信までの流れは以下のようになっています。 RTMの詳細については以下のテックブログをご参照ください。 techblog.zozo.com イベント検知・各種最適化・コンテンツ生成の処理ではユーザの行動ログを利用しています。行動ログは4種類で、アクセスログ・クリックログ・メール開封ログ・コンバージョンログがあります。これらのログは、配信時の最適化、コンテンツ生成、キャンペーン判定、キャンペーン分析で利用されています。また、これらのログはブラウザやネイティブアプリなどからRTMが直接取得しています。これらのログがどのようなログなのか、どのように利用しているかについて紹介します。 アクセスログ アクセスログはユーザのページ閲覧を表すログです。このログによって、どのユーザがどのページ(URL)にどのようなクライアント(アプリ・Web)でいつアクセスしたかが分かります。アクセスログをもとに、ユーザがアクセスしやすい時間帯や閲覧した商品などを判別します。この情報で配信を最適化し、ユーザごとに購入の可能性が高い商品情報を最適な時間に届けることができます。 クリックログ クリックログはRTMが配信したキャンペーンをクリックしたことを検知するためのログです。このログによって、どのユーザがどのキャンペーン経由でサイトへアクセスしたかが分かります。クリックログをもとに、ユーザがクリックしやすいチャネルを判別します。そして、最適化において、ユーザがクリックしやすい最適なチャネルへキャンペーンを送信できます。クリックログを分析し、クリックしやすいキャンペーンが分かれば、ニーズに合わせたキャンペーンを考えることもできます。また、クリック回数や日時を条件にクリックの可能性が高いユーザを抽出し配信もしています。 メール開封ログ メール開封ログによって、どのユーザがどのメールをいつ開封したのかが分かります。メールに開封ログ用の画像を埋め込むことで、開封時にこの画像が読み込まれるとRTMへリクエストされてメール開封を検知します。メール開封ログを分析し、開封しやすいメールがわかれば、開封しやすいメールの文言等をニーズに合わせて考えることができます。また、開封回数や日時を条件に開封の可能性が高いユーザを抽出し配信できます。 コンバージョンログ コンバージョンログは他のログとは違い、直接ユーザから取得しているわけではなく、アクセスログとクリックログをもとに判定します。ユーザがどのキャンペーン経由でサイトへアクセスし、注文完了まで至ったかを検知するためのログです。あるユーザの最後のクリックログ検知から一定の時間以内に注文完了ページのアクセスログを検知した場合、クリックしたキャンペーンでのコンバージョンとみなします。このログによって、キャンペーンのCVR測ることができキャンペーンのニーズがわかります。また、コンバージョンしやすいユーザを抽出し、配信も行っています。 RTMでのログの取得フロー ここまで紹介した各ログは以下のフローで収集していました。 ログ収集までのフローとログ到着後のフローに分けて説明します。 ログ取得までのフロー 最初にRTMからキャンペーンが配信されます。配信チャネルはLINE、メール、プッシュ通知です。ユーザがWeb・アプリ(iOS・Android)のどちらでサイトにアクセスしたかによってログ配信のフローが変わります。 まずアクセスログの場合、Webでは各ページでログ発火のためのビーコンが埋め込まれており、ページ表示時に発火しログを取得します。アプリはWebviewとネイティブのページが混在しています。Webviewの場合はWebと同様のフローです。 クリックログはアプリでプッシュ通知やディープリンクをクリックした際に発火しログを取得します。 メール開封ログは、メールを開いた際に画像ビーコンによってリクエストが送信されてログを取得します。 このように各ログはWeb、アプリ(iOS・Android)、メールから直接取得されるようになっていました。 次にRTMにログが到達した後の経路についてです。クライアントから送られるログにはユーザ情報とアクセス・クリックしたページ情報が含まれます。RTMに到着したログはまずメンバーIDをkeyとするキャッシュに保存されます。RTMはメインとなるアプリケーションがJBoss Data Grid(JDG)というインメモリな分散キャッシュデータストアを利用しており、高速な条件判定を実現しています。ログ到着時、対応するメンバーIDのキャッシュがなければキャッシュを新規作成、あれば既存のキャッシュを更新します。最適化の際にはこのキャッシュに含まれたデータを使用します。しかし、ログデータは分析などにも利用されるため配信実績としてテーブルにも保存しなければなりません。そこで、タイマーによって定期的にキャッシュデータを配信実績テーブルに書き込みます。配信実績テーブルのスキーマを以下に示します。 カラム名 説明 id 配信実績ID campaign_id キャンペーンID member_id 会員ID channel 配信チャネル delivery_dt 配信日時 open_dt メール開封日時 click_dt クリック日時 conversion_dt コンバージョン日時 ユーザへの配信ごとに実績が記録されます。そして、RTM DBに書き込まれたログは日次のバッチ処理で全社共通のDWH(BigQuery)に連携されます。このBigQueryに連携することで、配信実績を分析用途や他システムで利用できます。 従来のログ取得における課題 従来のログ取得における課題点は以下です。 全社ログ収集基盤が存在しているにもかかわらずRTMでログを取得している 直接ログを集めている ログ取得時間がサーバでのログ検知日時になっている 配信処理とログ取得処理が密結合になっている 全社ログ収集基盤が存在しているにもかかわらずRTMでログを取得している 1つ目に全社ログ収集基盤が存在しており、このシステムが取得しているログとRTMが直接取得しているログで重複しているものがありました。全社ログ収集基盤とはZOZOTOWNで発生するログを収集してBigQueryへと連携する基盤です。RTMは全社ログ基盤ができる前からあったシステムのため、独自でログを収集し利用していました。 全社ログ収集基盤の詳細については、以下スライドとテックブログをご参照ください。 speakerdeck.com techblog.zozo.com 直接ログを集めている 2つ目にRTMが直接ログを取得するために外向きのAPIを提供していました。そのため、不特定多数のアクセスがあり、bot等に対する対策がRTM独自で必要でした。また、セール実施時など大量のログが来る場合にはシステムの負荷が高まります。 ログ取得時間がサーバでのログ検知日時になっている 3つ目に各ログの取得日時がRTMのサーバへ到達した日時になっていました。ログの到着が遅延して検知が遅れた場合、実際のアクセスやクリック日時と異なってしまうという課題がありました。 配信処理とログ取得処理が密結合になっている 4つ目に配信処理とログ取得が同一のシステムで動作しており、密結合になっていました。ログ取得においてはバッファレイヤーがないため、アプリケーションのメンテナンス時にはログが欠損してしまうという課題がありました。また、将来的にこの配信基盤の移行を考えているため、先にログ収集の部分を切り出しておきたいと考えていました。 これらの課題は全社ログ収集基盤へ統一することで解決できるものでした。そのため、全社ログ収集基盤のログを利用することにしました。 全社ログ収集基盤への移行 前述の課題を解決するために、全社ログ収集基盤からログを取得するようにしました。その際に考慮した点を紹介します。 移行後のアーキテクチャ 移行後のアーキテクチャは以下の様になりました。 執筆時点では移行途中であり、移行が完了したものはクリックログとコンバージョンログです。アクセスログは全社ログ収集基盤とRTMで取得しており、メール開封ログはRTMでのみ取得している状態です。全社ログ収集基盤へ完全に移行した後はRTMへのログリクエストがなくなる予定です。 2種類のログ日時 全社ログ収集基盤で集めているアクセスログやクリックログといった行動ログは、クライアントでのログ送信日時と基盤でのログ検知日時の2つが日時データとして含まれています。ログ送信日時はクライアント側で付与されるパラメータのため、ログ到着が遅延しても、ログ送信日時に影響はありません。 このような全社ログ収集基盤の仕様によって、3つ目の課題であるログ取得時間がサーバでの検知日時になっているという課題を解決できます。 ログ連携頻度と連携方法の見直し RTMでリアルタイムに取得しているログは、アクセスログ・クリックログ・コンバージョンログの3つでした。この内、クリックログ・コンバージョンログはリアルタイムで集める必要のないことが調査の結果わかりました。そこで、バッチ処理によりクリックログ・コンバージョンログを全社ログ収集基盤から連携するようにしました。クリックログはそのまま連携すればよいものの、コンバージョンログはRTMでアクセスログとクリックログをもとに計算していたため、単純に全社ログ収集基盤から連携するだけでは実現できません。こちらについては次で詳しく説明します。 アクセス/クリックログからのコンバージョンの取得 既存のコンバージョン検知のロジックは以下です。 配信されたキャンペーンからサイトにアクセス。RTMがクリックログを検知し新規セッション開始。 ユーザがサイト内を回遊しアクセスログを一定時間内に検知した場合、オンライン状態とみなしセッションを更新。 セッションの開始/更新から一定時間内で購入ページでのアクセスログ(以降、購入ログ)を検知した場合、同一セッション内でのコンバージョンとみなす。 ログ連携頻度の見直しによって、リアルタイムでコンバージョンログは使用していないことが分かりました。したがって、RTMで判定しているコンバージョンを他で実施出来ればRTMの負荷を下げることができます。そのためこの処理はバッチ処理で実施することにしました。 コンバージョン判定のためには、アクセスログとクリックログ及び購入ログが必要です。これらのログは全社ログ収集基盤から取得できるログだったため、これらを利用しコンバージョンをバッチ処理で判定することにしました。 全社ログ収集基盤から取得できる各ログとスキーマについて説明します。 アクセスログにはどのユーザがいつどのページにアクセスしたかの情報が含まれています。アクセスログのスキーマを以下に示します。 カラム名 説明 uid ユーザID url アクセスしたページのURL client_timestamp クライアント側のログ送信日時 server_timestamp 全社ログ収集基盤でのログ検知日時 クリックログにはどのユーザがどのキャンペーン経由でサイトにアクセスしたかが含まれています。クリックログのスキーマを以下に示します。 カラム名 説明 uid ユーザID url クリックしたURL(キャンペーンIDをクエリパラメータに含む) client_timestamp クライアント側のログ送信日時 server_timestamp 全社ログ収集基盤でのログ検知日時 購入ログにはどのユーザがいつ購入したかが含まれています。購入ログのスキーマを以下に示します。 カラム名 説明 uid ユーザID order_id 購入ID order_timestamp 購入日時 これらのログにはセッションを識別するための情報であるセッションID等が含まれていません。したがって、バッチ処理ではこれらのログを利用してセッションIDを計算し、どのセッションでコンバージョンしたのかを判定します。 バッチ処理で実行されるアクセスログ、クリックログ、購入ログを利用したコンバージョン判定クエリは以下です。このクエリを実行することで、どのユーザがどのキャンペーン経由でいつコンバージョンしたのかがわかります。 WITH -- ①各ログイベントを抽出。各イベントを必要な期間抽出し、非正規化してUNIONで縦につなげる。 -- クリックイベント click_events AS ( SELECT uid , ' click ' AS event, client_timestamp AS event_timestamp, REGEXP_EXTRACT(url, r ' campaign_id=(\d+) ' ) AS campaign_id, NULL as order_id, server_timestamp FROM `zozo- log -platform.event_logs.click_log` ), WHERE -- 直近3日間のデータを取得する DATETIME (server_timestamp) >= DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' , INTERVAL -3 DAY) AND DATETIME (server_timestamp) < DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' ) AND REGEXP_EXTRACT(url, r ' campaign_id=(\d+) ' ) IS NOT NULL ), -- アクセスイベント access_events AS ( SELECT uid , ' access ' AS event, client_timestamp AS event_timestamp, CAST ( NULL AS string) AS campaign_id, NULL AS order_id, server_timestamp FROM `zozo- log -platform.event_logs.access_log` ), WHERE DATETIME (server_timestamp) >= DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' , INTERVAL -3 DAY) AND DATETIME (server_timestamp) < DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' ) -- 購入イベント order_events AS ( SELECT uid , ' order ' AS event, order_timestamp AS event_timestamp, CAST ( NULL AS string) AS campaign_id, order_id, NULL AS server_timestamp FROM `zozo- log -platform.event_logs.order_log` ), WHERE DATETIME (server_timestamp) >= DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' , INTERVAL -3 DAY) AND DATETIME (server_timestamp) < DATETIME_ADD( DATETIME ' {{batch_start_timestamp}} ' ) -- すべてのイベント events AS ( SELECT * FROM click_events UNION ALL SELECT * FROM access_events UNION ALL SELECT * FROM order_events ), -- ②新規セッションの判定。イベント時間を昇順にみて、1つ前のイベントと比較し、新規セッションにフラグ(session_flag=1)を立てる。 event_and_session_flag AS ( SELECT uid , event, campaign_id, order_id, event_timestamp, -- イベントをuidごとにevent_timestampごとに昇順でならべて、LAG関数を利用し一個前のevent_timestampを取得 LAG(event_timestamp) OVER (PARTITION BY uid ORDER BY event_timestamp) AS previous_event_timestamp, -- 新規セッションにフラグ立て(session_flag=1) CAST ( -- 10分以上間隔が空いたアクセスは新規セッション DATETIME_DIFF(event_timestamp, IFNULL(LAG(event_timestamp) OVER (PARTITION BY uid ORDER BY event_timestamp), event_timestamp), MINUTE) > 10 OR -- クリックがあったら新規セッション event = ' click ' OR -- ユーザが商品を購入してから同一セッションで商品を購入した場合はコンバージョンの対象外とするため新規セッション LAG(event) OVER (PARTITION BY uid ORDER BY event_timestamp) = ' order ' AS INT ) AS session_flag FROM events ), -- ③セッションごとにユニークなIDを付与。session_flagとuidを利用しユニークなIDを付与。 session AS ( SELECT *, uid || ' _ ' || SUM (session_flag) OVER (PARTITION BY uid ORDER BY event_timestamp, session_flag DESC ROWS UNBOUNDED PRECEDING ) AS user_session FROM event_and_session_flag ORDER BY event_and_session_flag. uid , event_and_session_flag.event_timestamp, event_and_session_flag.previous_event_timestamp ), -- ④コンバージョン判定。click_sessionとorder_sessionのuser_sessionが同じ場合コンバージョン。 -- コンバージョン判定のためにsessionからクリックイベントのみを抽出 click_session AS ( SELECT * FROM session WHERE event = ' click ' ), -- コンバージョン判定のためにsessionから購入イベントのみを抽出 order_session AS ( SELECT * FROM session WHERE event = ' order ' ) SELECT click_session. uid , click_session.campaign_id, order_session.order_id, order_session.event_timestamp AS conversion_at FROM click_session INNER JOIN order_session ON click_session.user_session = order_session.user_session; このクエリの処理内容は以下です。 各ログイベントを抽出。 新規セッションの判定。 セッションごとにユニークなIDを付与。 コンバージョン判定。 このクエリを日次バッチ処理で実行します。このクエリについては 2022年のAdvent Calendarの記事 でも解説していますが、改めて各処理について解説します。 各ログイベントを抽出 最初にアクセス・クリック・購入のイベントデータが含まれるテーブルからログイベントを抽出します。イベントにはクライアント側の送信時間(client_timestamp)とログ基盤の検知時間(server_timestamp)の2種類のタイムスタンプが付与されています。この内、client_timestampをイベントの発生時間として扱います。WHERE句には取得するデータの期限を指定します。batch_start_timestampはバッチ処理時に、バッチ処理の開始時刻が設定されます。取得期間は過去1日分ではなく、数日分取得するようにしています。これは全社ログ収集基盤を利用する上で、遅延データの考慮をする必要があったためです。全社ログ収集基盤はクライアントからの行動ログ送信遅延などが原因で最大数日の遅延データが入ります。これを考慮し、バッチ実行時点から過去数日分のログを取得するようにしています。そして、各ログをUNION ALLで1つにまとめます。 新規セッションの判定 次に新規セッションの判定をします。前の処理で作成したテーブルをイベント発生時刻の昇順でみていき、1つ前のイベントと比較しながら新規セッションかを判断するフラグを立てていきます。この計算では LAG関数 を利用しています。LAG関数は前の行との比較に便利な関数です。LAG関数を用いて、直前のイベントからn分以上経っていたら新規セッションとします。また、イベントがクリックログの場合、直前のイベントが購入ログの場合はイベント間隔に限らず新規セッションとします。 セッションごとにユニークなIDを付与 次にセッションごとにユニークなIDを付与します。前の処理で計算したsession_flagとユーザごとにユニークなIDであるuidを組み合わせて、ユーザのセッションをユニークに判別できるID(user_session)を付与します。 コンバージョン判定 最後にコンバージョン判定をします。前の処理でユーザのセッションを判別するID(user_session)を付与しました。コンバージョンはどのキャンペーン経由をクリックし、購入まで至ったかを識別するものです。つまり、user_sessionが同じであるクリックイベントとコンバージョンイベントがあれば、そのクリックイベントをセッションの起点としてコンバージョンまで至ったと判断できます。そのため、クリックイベントとコンバージョンイベントをuser_sessionでJOINしています。この処理の結果、どのユーザがどのキャンペーン経由でいつコンバージョンしたかがわかります。 こうして得られたコンバージョンからコンバージョン日時をRTMのログ実績テーブルへ連携します。こうすることで、RTMで実行していたコンバージョン判定を別システムで実行できるようになりました。 今後の展望 クリックログとコンバージョンログは全社ログ収集基盤を利用してバッチでの連携をするようにしました。ただし、その他のログについては、パフォーマンステストにおいて要件を満たすことができなかったためまだ移行できていません。今後はすべてのログを全社ログ収集基盤からの連携にする予定です。 また、RTM自体をリプレイスするプロジェクトも進めています。今回述べたログの課題以外にも、施策の実施がビジネス側で完結せず、開発側に依存しているという課題があります。リプレイスによって、このような課題を解決する予定です。RTM自体が抱えている課題やリプレイス計画については以下のテックブログもあわせてご参照ください。 techblog.zozo.com まとめ リアルタイムマーケティングシステムの密結合なログ収集から全社ログ収集基盤への移行について紹介しました。ログの見直しによって要件を削減することで、移行の難易度とコストを低減できました。また、完全に移行はできていないものの、部分的なログ収集の疎結合化やセキュリティ向上というメリットを得られました。本記事が皆様の参考になりましたら幸いです。 さいごに ZOZOでは一緒にプロダクトを開発してくれるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、フロントエンド部の中島です。FAANSのiOSアプリの開発を行なっています。 FAANSの由来は「Fashion Advisors are Neighbors」です。「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」で2022年8月に正式ローンチしました。 はじめに FAANS iOSチームではAPI通信においてSwift Concurrencyを利用しています。Swiftに限らず並行処理を扱う場合には実装次第でデータ競合を起こす恐れがあるのに対して、Swiftではデータ競合を防ぐ仕組みとしてActorが導入されています。そして、Actor間で扱うデータがデータ競合を起こさない型であるかコンパイラでチェックされます。Swift 6ではこのデータ競合のチェックにより既存のコードでコンパイルできなくなる可能性があります。Xcode 14ではSwift 6までの間に段階的な移行ができるようにStrict Concurrency Checkingでコンパイラのチェックレベルを指定できるようになりました。本記事ではFAANS iOSチームで実施したStrict Concurrency Checkingの対応と、その過程で得られた知見について紹介します。 目次 はじめに 目次 Strict Concurrency Checkingについて Sendableについて Sendableチェックによる警告と解消方法 Case 1 : アクター隔離されたコンテキストにおいてアクター境界を超えてデータを取得する場合、取得データの型はSendableに準拠する必要がある 解決1:public structをSendableに準拠させる 解決2:@preconcurrencyアノテーションをimport文に付与する Case 2 : @Sendableが付与されたクロージャーのキャプチャ対象の型はSendableに準拠する必要がある 対応方法 まとめ さいごに Strict Concurrency Checkingについて Strict Concurrency CheckingはMinimal, Targeted, Completeの3つのレベルがあります。XcodeのBuild Settingsで設定が可能です。 Minimal, Targeted, Completeの定義は次の通りです。 Minimal: Swift Concurrencyの利用箇所で、明示的にSendableと書いている箇所でSendable制約とアクター隔離のチェックをします。 Targeted: Swift Concurrencyの利用箇所で、Minimalに加え、明示的にSendableと書いていない箇所でもSendable制約とアクター隔離のチェックをします。 Complete: Swift Concurrencyの利用に関係なく、モジュール全体を通してSendable制約とアクター隔離のチェックをします。 参考: https://developer.apple.com/documentation/xcode/build-settings-reference#Strict-Concurrency-Checking Minimalはデフォルト設定です。Sendableを書いていない場合はチェックされず今までと変わらず問題なくビルドできます。Targetedでは並行処理を使用している部分でチェックが入ります。Completeでは全ての箇所でチェックが入ります。レベルによってSendableに準拠しているかどうかのチェックの範囲が広がります。Completeではコンパイルエラーが多く問題の切り分けが難しかったため、FAANS iOSチームではTargetedに変更することから始めました。 Sendableについて Sendableは並行タスク間でデータ競合が起こらないよう、安全に共有できる型を表すプロトコルです。暗黙的にSendableに準拠するケースと、明示的にSendableを付与するケースがあります。 暗黙的にSendableに準拠するケース publicではないstruct, enumでSendableなプロパティのみを保持 Int, String, Dictionay, Arrayなど actor @MainActorを付与したclass 明示的にSendableを付与するケース class publicなstruct、enum classは次の条件を満たすことで準拠できます。 Sendableを明示的に付与 finalを付与 mutableであるvarを利用しない // mutableなvar nameを持つと警告が出る final class SendableClass : Sendable { var name = "FAANS" // ⚠️ Stored property 'name' of 'Sendable'-conforming class 'SendableClass' is mutable } // 条件を全て満たすのでSendableに準拠しており、警告が出ない final class SendableClass : Sendable { let name = "FAANS" } また、クロージャーに@Sendableを明示的に付与した場合、そのクロージャーでキャプチャする値はSendableに準拠する必要があります。Task.initなど、Sendableなクロージャーで定義されているケースもあります。 Sendableに準拠していないケースは次の通りです。 NSAttributeString等のSendableに準拠していないプロパティを保持する値型 mutableな値を持つ参照型(classなど) Strict Concurrency Checkingの設定により、アクター間でやり取りされるデータがSendableに準拠しているかどうかをコンパイラでチェックできます。 参考: https://developer.apple.com/documentation/swift/sendable Sendableチェックによる警告と解消方法 MinimalからTargetedに変更したことで発生した警告は主に次の2つです。ともにSendableのチェックですが警告発生パターンが異なるのでコード例と共に説明します。 Case 1 : アクター隔離されたコンテキストにおいてアクター境界を超えてデータを取得する場合、取得データの型はSendableに準拠する必要がある Case 2 : @Sendableが付与されたクロージャーのキャプチャ対象の型はSendableに準拠する必要がある Case 1 : アクター隔離されたコンテキストにおいてアクター境界を超えてデータを取得する場合、取得データの型はSendableに準拠する必要がある FAANSアプリはショップスタッフの情報である、StaffMemberをasync/awaitを利用して取得します。また、OSSライブラリを利用してAPIClientを実装しており、StaffMemberはpublicのstructになっています。 簡易的ではありますがViewModel経由でAPIClientの非同期関数を呼び出し、StaffMemberを取得する例で説明します。ViewModelで実行するviewDidLoad関数はviewとやり取りをするために@MainActorを付与してメインスレッドで実行しており、アクター隔離された関数になっています。APIClientの実装の詳細は省略しています。 具体的なコードは次の通りです。StaffMemberを取得するところで警告が出ています。 import SwiftUI struct ContentView : View { // ViewはViewModelを保持 @StateObject private var viewModel = ViewModel() var body : some View { Text(viewModel.staffMember?.name ?? "Loading" ) .onAppear() { // 画面表示時に実行 viewModel.viewDidLoad() } } } final class ViewModel : ObservableObject { // APIClientで非同期通信を行う private let apiClient = APIClient() @Published private ( set ) var staffMember : StaffMember? // アクター隔離(main actor-isolated)のメソッド @MainActor func viewDidLoad() { Task { // 次の警告が出る // Non-sendable type 'StaffMember' returned by call from actor-isolated context // to non-isolated instance method 'getStaffMember()' cannot cross actor boundary staffMember = await apiClient.getStaffMember() } } } // StaffMemberを非同期で取得するAPIClient final class APIClient { // アクター非隔離のメソッド func getStaffMember () async -> StaffMember { return StaffMember() } } // 外部コードであるためpublicがついている public struct StaffMember { var name = "FAANS staff" } 上記のコードで、Sendableに準拠していないStaffMember型がアクター境界を超えることはできないという警告が出ました。 // Non-sendable type 'StaffMember' returned by call from actor-isolated context // to non-isolated instance method 'getStaffMember()' cannot cross actor boundary アクター隔離されている状態(actor-isolated context)は密室な部屋にいる状態と例えることができます。部屋の中では自由にデータのやり取りが可能ですが部屋の出入りが必要な場合、つまりアクター境界を超える場合、データの型はSendableに準拠する必要があります。ViewModelのviewDidLoad関数は@MainActorが付与されてアクターに隔離されたコンテキストで関数が実行されます。APIClientのgetStaffMember関数はアクター境界の外で実行される関数です。 解決1:public structをSendableに準拠させる 値型であるstructは暗黙的にSendableに準拠します。publicがつく場合は明示的にSendableをつけないといけないので、外部コードでpublic structを利用している場合は警告が出てしまいます。解決するにはpublicなstructをSendableに準拠させる必要があります。次のようにSendableを付与することで警告をなくすことができます。 // Sendableをつける public struct StaffMember : Sendable { // プロパティはStringやInt等、Sendableに準拠したもの } 解決2:@preconcurrencyアノテーションをimport文に付与する 直接ファイルを編集できない場合は、外部コードのimport箇所で次のように@preconcurrencyを付与することで警告を出さないようにできます。FAANS iOSではこの対応を実施しました。 // Sendableチェックを無視することができる @preconcurrency import APIModels Case 2 : @Sendableが付与されたクロージャーのキャプチャ対象の型はSendableに準拠する必要がある Case 2はクロージャーのキャプチャ対象はSendableに準拠する必要があることについて考えます。Case 1では1つのリクエストでしたが、async letを利用して複数のタスクを並列で実行すると次の警告が出ます。 class ViewModel : ObservableObject { private let apiClient = APIClient() @Published private ( set ) var staffMember : StaffMember? @Published private ( set ) var shop : Shop? @MainActor func viewDidload() { Task { // async letでリクエストを並列で実行 async let staffRequest = self .apiClient.getStaffMember() async let shopRequest = self .apiClient.getShopInfo() self .staffMember = await staffRequest self .shop = await shopRequest } } final class APIClient { func getStaffMember () async -> StaffMember { return StaffMember() } // ショップ情報を取得 func getShopInfo () async -> Shop { return Shop() } } public struct StaffMember { var name = "FAANS staff" var age = 25 } public struct Shop { var name = "FAANS shop" } } async letの行で次の警告が出ます。 Capture of ' self ' with non - sendable type 'ViewModel' in a `@Sendable` closure Case 1では出ない警告がasync letを利用すると出るようになりました。Task {}のクロージャーはSendableに準拠する必要がありますが、キャプチャしたViewModelはSendableに準拠していないという警告です。Case 1で警告が出なかった理由は、Task {}のクロージャーは呼び出し元の実行コンテキストを引き継ぐ性質によるものです。 一方でCase 2に関しては、async letは子タスクを作成して実行元と異なるコンテキストで実行されます。また、async letの右辺は暗黙的な@Sendableのクロージャーのような振る舞いをするので、Sendableチェックがされることで警告が出ています。 参考: https://github.com/apple/swift-evolution/blob/main/proposals/0317-async-let.md#proposed-solution 動的な個数のタスクを並列で実行するwithTaskGroupもasync letと同様に子タスクを作成し、addTask関数がSendableなクロージャーなので警告が出ます。 対応方法 ViewModelはObservableObjectを継承したクラスでViewの値を監視する役割を持ち、staffMemberをmutableな値として保持しています。明示的にSendableを付与することでの解決が難しいです。Case 1では関数に@MainActorを付与していましたが、Case 2ではクラスのはじめに@MainActorをつけることで解決しました。@MainActorを付与したクラスは暗黙的にSendableに準拠するので警告をなくすことができます。 参考: https://developer.apple.com/documentation/swift/sendable#Sendable-Classes // @MainActorをクラスのはじめにつける @MainActor final class ViewModel : ObservableObject { ... } この対応によりself参照におけるSendableチェックの警告をなくすことができました。しかし、今度はapiClientがSendableに準拠していないという警告に変わります。 Non - sendable type 'APIClient' in asynchronous access to main actor - isolated property 'apiClient' cannot cross actor boundary ViewModelクラス全体をアクター隔離したのでViewModelが保持するapiClientもアクター隔離されます。しかし、async letを利用して異なるコンテキストで実行された結果、アクター境界を超えるのでapiClientがSendableに準拠する必要があります。今回の例ではAPIClientクラスはmutableな値を持たず、final classなのでSendableをつけることで解決ができます。 // Sendableをつける final class APIClient : Sendable { func getStaffMember () async -> StaffMember { return StaffMember() } func getShopInfo () async -> Shop { return Shop() } } 比較的簡単な例を示しましたが、APIClientがリクエストに必要なヘッダー等をmutableな値で持ち、classのままではSendableに準拠できない可能性があると思います。FAANS iOSではその箇所が外部コードになっており、@preconcurrencyをつけて解決していますが、直す場合はactorやstructを利用して解決する方法があると思います。今後ライブラリ側でどのような対応がされるかチェックしていきたいと思います。 まとめ Swift Concurrency CheckingをTargetedに設定した際、かなりの警告が出ましたが紐解いてみると同じパターンで警告が出ているだけで、芋づる式に解決できることが多かったです。同じ問題に遭遇した方や今後同様の対応をする方でこの記事が参考になれば幸いです。 さいごに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。カジュアル面談もお待ちしております。 corp.zozo.com
アバター
こんにちは、WEAR Webフロントエンドチームの吉田と大脇です。 現在 WEAR ではNext.jsでのリプレイスが進行中です。今回はリプレイスのデザイン面における課題と解決に向けて行った取り組みを紹介します。 リプレイスの経緯や技術選定については、弊社の藤井の記事をご覧ください。 techblog.zozo.com 10年の歴史のあるアプリケーションと向き合う リプレイスにおける課題 他部署との連携 デザイナーとエンジニアの歩み寄り ミーティングで話し合ったこと ミーティングを行って得た気づき デザインツールの変遷 未来のこと、これからやっていきたいこと デザイントークンの定義 デザイン周りの負荷軽減 技術面でやりたいこと Tailwind CSS Storybook 終わりに 10年の歴史のあるアプリケーションと向き合う WEARは今年で10年目となります。Webサービスとしては長期間に渡って運用されているのではないでしょうか。WEARは幾度かのデザインリニューアルや担当者の交代などの経緯があり今のデザインに至っています。デザインルールの整備やマスタデータの管理フローなども時代によって変化しており、現時点のルールは明文化されていません。そのため、現在の実装内容が正しいデザインとして扱われていました。 リプレイスにおける課題 今回のリプレイスでは、大きなデザインリニューアルは行わず、既存のデザインを踏襲していく形で進行しています。そのため、実装内容からデザインを読み解き、それを基に開発しており、品質を担保しながらスピード感をもって開発を進めることに課題を感じていました。 具体的には以下のような課題があります。 色 旧環境でも色定義をしていたが、定義外の色も多く使用されており、色を都度デザイナーに確認しながら実装していく必要がある 余白 入れ子の要素それぞれが余白を持っており、正確な余白を直感的に判断できない line-height を余白の調整に利用しており、正確な余白の算出が困難である コンポーネント 「角丸が1px小さい」など 似ているが微妙に違うUI が複数存在する UIの共通化を都度デザイナーに相談する形をとっているが、1つ1つは小さくても積み重なるとお互いにコストとなる これらの課題を解決できればよりスピード感を持って開発ができそうです。また、今後も継続してサービスを改善し成長させていくためにも、足場を固める必要があると考えました。そこでWebフロントエンドチーム内では「エンジニアとデザイナー間での共通言語としてデザインシステムを採用し、認識を合わせていくのはどうか」という話が上がりました。 こうしてWEARのデザイン開発効率を上げるための環境作りを目指した活動が始まりました。 他部署との連携 まずは、前章で挙げたような漠然としていた課題を整理しました。ホワイトボードツールを用い、改善したい項目を 重要度 と 難易度 の2軸でグラフ化しました。その中でも「重要度が高く、難易度が低い」ものを優先してデザイントークン化していくことにしました。 デザイナーとエンジニアの歩み寄り デザインシステムを作っていく上で当然デザインに関わることを決めるので、エンジニアのみで進めることは難しく、デザイナーと協力して進める必要がありました。また、WEARはWeb/iOS/Androidでサービスを展開しているため、モバイルエンジニアとも話し合う必要がありました。デザインシステムのチームがあるわけではないので、各チームから代表者を募り、ミーティングをしました。 ミーティングで話し合ったこと エンジニア側でデザインシステムがないことで困っていること デザインルールが明文化されていないことで起こる、エンジニアとデザイナー間での無駄なコストを下げるために必要なこと 各チームがデザインシステムに対して期待していること 過去にデザインシステムを作ったことがあるメンバーの経験に基づく「作成していく上で大変なこと」 ミーティングを行って得た気づき WebとiOS/Androidで困っているポイントが違う コンポーネント単位でデザインレビュー 1 を行えれば、再度同じ部分のデザインレビューが不要になる デザインシステムを仮に作り上げたとしても、それを既存のサイトやアプリに反映するだけでも大きな工数がかかる 初めから完成されたデザインシステムを目指さずとも、既存デザインのルールをまとめるだけでも価値がある 特に、既存デザインのルールをまとめるだけでも価値があるという意見に共感しました。WEARは既に10年運用されているサービスで、言語化していなかったとしても潜在的にデザインルールが存在していると思うので、そのルールを明文化するだけでも以下のメリットがあると考えました。 新しく入社したメンバーのオンボーディングに役立つ デザイナーとエンジニアの共通言語となる デザインシステムを作成するときに流用可能 よって、まずはデザイナーに現状をヒアリングして、既存デザインのルールをまとめることにしました。 デザインツールの変遷 既存デザインのルールをまとめていくにあたって、どのツールを使って形にしていくかという問題があります。候補は以下の3つがありました。 Adobe XD + Zeplin Figma Confluence WEARではデザインツールとしてAdobe XD、デザイン共有ツールとしてZeplinを利用していました。しかし、Webフロントエンドチームとしては以下のような理由からFigmaを利用したいと考えていました。 行間の指定( line-height )などWebとの親和性が高い デザインデータのプレビューがConfluenceなどのドキュメントツールで埋め込み可能 プラグインが豊富 コンポーネント化できる(これはXDでも可能) しかし、デザインツールを乗り換えるコストからFigmaの利用は断念し、Adobe XDまたはConfluenceを利用する方針になりました。そんな矢先、今年の1月ごろAdobeから「今後XDの積極的なアップデートは行わない」との発表が出ました。 結果、候補はFigmaとConfluenceに絞られました。FigmaとConfluenceを比較し、以下の意見が出ました。 視覚的なわかりやすさ Confluenceを使うと文字メインでデザインを説明することになる。それでは視覚的にわかりづらく、運用していくのも大変そう Figma上でデザインルールを起こせば視覚的にわかりやすい デザインシステムへの流用のしやすさ Confluenceでデザインルールを残しても、流用しづらい中途半端なデータとなってしまう Figmaならデザインデータをそのままデザインシステムにも流用できる よって、Figmaを利用して既存デザインのルールをまとめることになりました。 未来のこと、これからやっていきたいこと デザインルールをまとめる作業は分量が多く、すぐに終わることはないので、時間を見つけて引き続きやっていくことにしました。この章では、デザインルールのまとめ作業と並行して、これからやっていきたいことを紹介します。 デザイントークンの定義 他部署との連携 の章で書いたような「エンジニアとデザイナー間での無駄なコストを下げる」を実現するために、デザイントークンの定義をしたいです。例えば、文字サイズ、余白、角丸、シャドウなどのルールを明文化し、iOS/Androidでも共通のルールで運用することで、各プラットフォームごとのズレを減らすことができると考えています。 デザイン周りの負荷軽減 他部署との連携 の章で書いたようなデザイン周りのコスト削減のために、以下のような運用をしたいです。 似たようなデザインで再度デザイン作業が発生しないように抑止 Figma上でコンポーネント化し、コンポーネント単位での管理・デザインレビュー デザイナーが「このコンポーネントは既にレビューしたから今回はレビューしない」という判断ができるようになること デザイン作成・デザイン共有がFigmaで完結すること 仕様書の作成コスト削減 Confluenceで仕様書を作成する際に、デザインデータをプレビューで埋め込み可能 技術面でやりたいこと デザイン面だけでなく、実装面への反映と運用方法を検討しています。 Tailwind CSS WEARのWebサイトではTailwind CSSを利用しています。Tailwind CSSは tailwind.config.js にtheme 2 を記述し、決められたクラス名のみを利用して実装していきます。デザイントークンを決めるということは制限を持たせることでもあり、これはTailwind CSSとの親和性が高いと考えています。デザイントークンがまとまったらTailwind CSSのthemeを設定し、徐々にサイトへ反映していきたいです。 Storybook WEARのWebサイトの開発ではStorybookを利用しています。Storybookを用いることで、コンポーネント単位での表示の確認が可能です。今後デザインシステムのドキュメントを作成する場合、実際に動作するコンポーネントをドキュメントに載せることができ、新しく入社した社員のオンボーディングにも役立つと考えられます。 上記のようなTailwind CSSとStorybookの運用方法を改善をすることで、 リプレイスにおける課題 の節で書いたような「スピード感を持った開発」が実現できると考えています。 終わりに WEARは長い歴史を持つアプリケーションであり、デザイン関連の整備には時間がかかると想定されます。そこで、デザインシステムを導入する際には、開発の停滞を避けるため、取り組みやすいところから徐々に進めていこうと考えています。私たちは、デザインシステムを作成すること自体が目的ではなく、エンジニアとデザイナーが共通のルールに基づいて協力し、開発をスムーズに進めることを目指しています。なので、デザインシステムに関しては、 “共通認識のルールを作っていたら、いつの間にか副産物的にデザインシステムができた” という心持ちでやっていきたいです。 ZOZOではデザインシステムに興味のある方を募集しています。ぜひ、下記のリンクからご応募ください。 hrmos.co hrmos.co WEARにおいてデザインレビューとはステージング環境で実際に動いているものをデザイナーの観点でレビューしてもらう確認作業のこと。 ↩ スタイルを変数として定義できる機能。 ↩
アバター
こんにちは、技術本部SRE部ZOZOSREチームの斉藤です。普段はZOZOTOWNのオンプレミスとクラウドの構築・運用に携わっています。またDBREとしてZOZOTOWNのデータベース全般の運用・保守も兼務しております。 ZOZOTOWNではSQL Serverを中心とした各種DBMSが稼働しています。その中で、Amazon RDS for SQL Server(以下、RDS)を使用したデータベースが存在します。これらは、トラフィックの増減が激しいZOZOTOWNのサービスにおいて、オンデマンドでスケール可能なデータベースとして運用されています。 本記事では、クライアントであるEC2(以下、Webサーバー)とRDSの間にデータベースプロキシをnginx TCP Load Balancerで構築し、ロードバランシングを実現した事例を紹介します。参照系データベースのアクセスに関してロードバランシングの一例としてご参考になればと思います。また、詳細は後述していますが、Amazon RDS Proxyにはバランシング機能がありません。「無いものは自前で作る」というZOZOの文化にも触れていただけたら幸いです。 目次 目次 データベースプロキシを構築した背景と課題 ソリューション選定 Amazon RDS Proxy Elastic Load Balancing Network Load Balancer (NLB) nginx TCP Load Balancer nginx TCP Load Balancerを構築する Dockerコンテナでnginxインスタンスを作成 nginx.confを編集してロードバランサーとして設定 ヘルスチェックを設定 UNIXドメインソケットを設定 バランシングアルゴリズムの設定 keepalive(idle interval count)を設定 nginx TCP Load Balancerを動作検証する ロードバランサーの設定 実際に動作検証する リクエストを開始する 片方のRDSにテーブルロックをかける 両方のRDSにテーブルロックをかける リクエストを停止させる 本番環境へ実装する niginx TCP Load Balancerをコンテナで構築する Proxy設定をGitHubで管理する 本番環境へ実装後の負荷状況 まとめ おわりに データベースプロキシを構築した背景と課題 RDSは、Webサーバーからのリクエストを直接受けており、セール時などの高トラフィックが予想される日は、RDSとWebサーバーをスケールアウトしてシステムを増強させています。RDS 1インスタンスあたりのWebサーバー接続数を計算し、各Webサーバーに対して、接続先を変更する必要がありました。また、RDSの障害で接続ができなくなってしまった場合、問題の起きたインスタンスからの切り離しを手動で行う必要がありました。運用を効率化するためには、ヘルスチェックとロードバランシングの機能を持ったサーバーが必要と判断し、導入することにしました。先述した以外にもロードバランシング機能を持つサーバーの導入は、いくつかのメリットがあると考えました。 ロードバランシング機能を持つサーバーのメリット 運用の効率化 Webサーバーの接続先変更や切り離しにかかる運用コストが削減できる。 可用性の向上 RDS障害時の接続先変更を自動化することで、エラーを最小限に抑えて稼働させ続けられる。 コストの最適化 RDS障害時の接続先変更を自動化することで、RDSのフェイルオーバーは不要となり、マルチAZが廃止でき、RDSコストを1/2にできる。 マルチAZが廃止できる理由について補足させていただきます。AZ障害が発生した場合に備え、マルチAZ無効状態で各AZにRDSインスタンスを配置した状態にしておきます。障害発生時は、正常なAZ側のRDSインスタンスに自動で接続が切り替われば、サービス継続が可能となります。従来の手動切り替えによる対応が不要にできれば、障害時のサービス継続性が向上し、マルチAZも不要となるので通常時のコスト削減に繋がります。 ソリューション選定 Amazon RDS Proxy Amazon RDS Proxyにロードバランシングの機能が無いか調査しました。接続プーリングなどの魅力的な機能はあったもののロードバランシング機能はありませんでした。Amazon RDS Proxyのより詳細な情報は下記に記載されていますので興味のある方は Amazon RDS Proxy を参照してください。 Elastic Load Balancing Network Load Balancer (NLB) (2023/06/20追記)AWSのElastic Load Balancing Network Load Balancer(以下、NLB)も候補の1つとして調査しました。NLBはターゲットの指定にFQDNが使用できず、動的にIPアドレスが変更されてしまうRDSには対応できませんでした。RDSの動的なIPアドレス変更の詳細情報は下記に記載されていますので興味のある方は Amazon RDS DB インスタンスに割り当てられた IP アドレスについて を参照してください。 (追記ここまで) nginx TCP Load Balancer nginxのロードバランシング機能を調査してみるとHTTP、TCP、UDPでロードバランシングが実現でき、パッシブヘルスチェックとアクティブヘルスチェックを使用できました。 (2023/06/20追記)また、詳細は「 UNIXドメインソケットを設定 」に記載していますが、FQDNを指定した名前解決が可能でRDSの動的なIPアドレス変更に対応できることがわかりました。(追記ここまで) nginx TCP Load Balancerのより詳細な情報は下記に記載されていますので興味のある方は TCP and UDP Load Balancing を参照してください。 今回はnginx TCP Load Balancerを使用してデータベースプロキシを構築することにしました。 nginx TCP Load Balancerを構築する 以下の手順でnginx TCP Load Balancerを構築していきます。 Dockerコンテナでnginxインスタンスを作成 nginx.confを編集してロードバランサーとして設定 ヘルスチェックを設定 UNIXドメインソケットを設定 バランシングアルゴリズムの設定 keepalive(idle interval count)を設定 Dockerコンテナでnginxインスタンスを作成 Docker Hubで公開されている nginx Open Sourceイメージ を使用して、Dockerコンテナでnginxインスタンスを作成しました。 nginx.confを編集してロードバランサーとして設定 ロードバランサーを構成するにはstreamコンテキスト内にupstreamグループを作成します。 stream { resolver xxx.xxx.xxx.xxx valid=5s; error_log /dev/stderr info; upstream rds-tcp { least_conn; server unix:/var/run/rds_1a_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; server unix:/var/run/rds_1c_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; } server { listen 1433 so_keepalive=10m:1m: 10 reuseport; proxy_socket_keepalive on ; proxy_connect_timeout 15s; proxy_pass rds-tcp; } ヘルスチェックを設定 Webサーバーからのリクエストに対して、サーバーのレスポンスを監視するパッシブヘルスチェックを設定します。ヘルスチェックの設定はupstreamグループ内に記述したサーバーのパラメータで定義します。定義したパラメータの「fail_timeout」と「max_fails」がヘルスチェックの設定部分です。 upstream rds-tcp { server unix:/var/run/rds_1a_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; パラメータについては次の通りです。 fail_timeout サーバーとの通信試行がmax_failsで指定された回数失敗すると、fail_timeoutに設定された期間サーバーが利用できないと見なします。 max_fails サーバーとの通信試行の失敗回数を設定します。指定した回数リクエストに失敗するとfail_timeoutで設定された期間、サーバーが利用できないと見なします。 max_conns プロキシサーバーへの同時接続の最大数を制限します。デフォルト値の0は、無制限を意味します。 weight サーバーの重みを設定します。 UNIXドメインソケットを設定 無料版のnginxは起動時にしか名前解決がされません。RDS側の動的な変更に対応するため、UNIXドメインソケットを使用し、名前解決することにしました。UNIXドメインソケットの設定もstreamコンテキストに記述します。 (2023/06/20修正)nginxの名前解決は無料版と有料版で解決方法に違いがあります。UNIXドメインソケットを使用することで、無料版でも有料版と同等の動的な名前解決をできるようにしました。無料版のnginxは起動時にしか名前解決をしないことが課題でしたが、RDS側の動的なIPアドレス変更に対応できました。 (2023/06/20修正)streamコンテキストにresolverの設定を記述します。 stream { resolver xxx.xxx.xxx.xxx valid=5s; (2023/06/20修正)serverコンテキストにproxy先をset変数で定義します。 server { listen unix:/var/run/rds_1a_001.sock; set $rds_1a_001 " rds-1a-001.sample.ap-northeast-1.rds.amazonaws.com " ; proxy_pass $rds_1a_001: 1433 ; } バランシングアルゴリズムの設定 nginx TCP Load Balancerには3種類のバランシングアルゴリズムが用意されています。 Round Robin 振り分け先のサーバーへのリクエストを均等に振り分ける方式(デフォルト) Least Connections アクティブな接続数が最も少ないサーバーに振り分けられるような方式 hash 同じIPアドレスからのリクエストは、同じ振り分け先サーバーへ振り分ける方式 今回はLeast Connectionsを採用することにしました。バランシングアルゴリズムの設定もstreamコンテキストに記述します。 least_conn; server unix:/var/run/rds_1a_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; server unix:/var/run/rds_1c_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; keepalive(idle interval count)を設定 アクティブ接続を最大で10分間継続させ、1分間隔で10回アイドル状態になっていないかのチェックを行うようにします。keepaliveのパラメータはlistenソケットに設定します。 server { listen 1433 so_keepalive=10m:1m: 10 reuseport; proxy_socket_keepalive on ; proxy_connect_timeout 15s; proxy_pass rds-tcp; } nginx TCP Load Balancerを動作検証する RDSを2インスタンス用意し、Webサーバーから接続をバランシングしてみます。nginx TCP Load Balancerへ複数のWebサーバーからリクエストを投げ、RDS側で参照されるテーブルをロックします。リクエストタイムアウトが起きた場合、nginx TCP Load Balancerがどういった動作をするか確認します。 ロードバランサーの設定 バランシングアルゴリズム Least Connectionsを採用します。 ヘルスチェック 10秒間にリクエストが100failしたらRDSのダウンとみなすように設定します。 障害時、手動での接続切り替えに数十分ほど要していましたが、自動で検知と切り替えができれば数秒ほどで完了し、大幅に時間短縮できます。 keepalive(idle interval count) アクティブ接続を最大で10分間継続させ、1分間隔で10回アイドル状態になっていないかのチェックを行います。 1分間隔のアイドルチェックにより、該当コネクションを利用するリクエストがゼロになっても1分間はそのコネクションを維持させます。これにより、コネクションの作成/破棄が頻繁に行われることによる負荷を削減します。 upstream rds-tcp { least_conn; server unix:/var/run/rds_1a_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; server unix:/var/run/rds_1c_001.sock fail_timeout=10s max_fails= 100 max_conns= 0 weight= 1 ; } server { listen 1433 so_keepalive=10m:1m: 10 reuseport; proxy_socket_keepalive on ; proxy_connect_timeout 15s; proxy_pass rds-tcp; } 実際に動作検証する いくつかのパターンで動作検証します。 通常の状態でリクエストを投げ、ロードバランシングが動作するか確認する 片方のRDSにテーブルロックをかけ、タイムアウトを発生させ、ヘルスチェックが動作するか確認する 両方のRDSにテーブルロックをかけ、タイムアウトを発生させ、動作確認する リクエストを停止させ、keepaliveが動作するか確認する リクエストを開始する 通常の状態でリクエストを投げ、ロードバランシングが動作するか確認しました。各Webサーバーのセッションが均等にバランシングされました。 片方のRDSにテーブルロックをかける 片方のRDSにテーブルロックをかけて、タイムアウトを発生させ、ヘルスチェックが動作するか確認します。一瞬パラっとエラーが出た後は、一定間隔でパラパラと30秒タイムアウトが発生しました。これは、nginxのパッシブヘルスチェックによって、RDSのステータスを定期的に確認するためです。アクティブヘルスチェックと違い、実際のリクエストを使ってヘルスチェックするため、想定通りの挙動です。 両方のRDSにテーブルロックをかける 全リクエストがタイムアウトしましたが、ロック解除するとエラーなくリクエストが流れました。 リクエストを停止させる Webサーバーからのリクエストを停止させてみます。停止直後はRDSへのコネクションが維持された状態になりました。 1分後にRDSへのコネクションは破棄されました。想定通りにkeepaliveが動作してくれました。 本番環境へ実装する 動作検証が成功したので、本番環境への実装を考えます。RDSとWebサーバーの柔軟なスケールアウトに対応する必要があるのと実装後の運用について考慮しました。 niginx TCP Load Balancerをコンテナで構築する Amazon EKSのkubernetesクラスターを用いて構築することで、Proxyの柔軟なスケールアウトを可能にしました。予期しない高負荷に備え、Horizontal Pod Autoscalerでのオートスケールを実現しました。 アーキテクチャ図 Proxy設定をGitHubで管理する 設定変更の運用は、ArgoCDを用いてGitOps化し、リソース変更や運用をGitHubの操作でできるようにしました。 本番環境へ実装後の負荷状況 ZOZOTOWNの年間を通して、トラフィック量がトップクラスに多い期間が年末年始に開催される冬セールです。2022-2023期の冬セールで増強した1300台以上のWebサーバーからのリクエストを6台のProxyで捌くことができました。セール開始時にCPU使用率が上昇しましたが、許容範囲内に収まり、全体を通して安定的に稼働してくれました。 冬セール時のCPU使用率 まとめ 本記事では、WebサーバーとRDSの間にデータベースプロキシをnginx TCP Load Balancerで構築した際の事例を紹介させていただきました。課題として挙げていた、WebサーバーのRDS接続設定に関する運用効率の向上やRDSのコスト最適化に繋がったと感じています。 アクティブなupstream serverが全ダウンした際に、設定しておいたSQL Serverにリクエストを流すことができるbackupオプションというものがあります。引き続き、nginx TCP Load Balancerをパワーアップさせることができるオプションを検証していきたいです。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、計測プラットフォーム開発本部システム部SREブロックの市橋です。2021年4月に新たに発足したチームで未経験ながらリーダーを任され、気づけば約2年が経過していました。これまでを振り返ってみると、まっさらな状態から安定したチームができてきたと感じています。今回は新米リーダーとして試行錯誤する中で、チーム状態を可視化して健全なチーム運営を目指した話を紹介します。 チーム状態の可視化を考えたきっかけ リーダーを任された当初、チーム運営上の課題が色々あるのは認識していましたが、どこから手をつけるべきかが自分の中で判然としませんでした。メンバーの時に一個人として感じていた課題も、チーム全体を俯瞰して見た時にどれから優先的に取り組むべきか自信を持って判断できませんでした。まるで大海原のど真ん中にいきなり放り出された感覚でした。 そんな悩みを抱えていた時、全社に導入されている Wevox のアンケート調査依頼が来ました。これはチームや組織の状態やメンバーのエンゲージメントを可視化するツールです。メンバーの時は流れ作業のようにこなしていましたが、このときは一筋の光明に見えました。このツールによってチームの状態を把握できることはもちろんですが、それ以上にチーム状態を客観的な数値として知ることの重要性に気づけたことが何よりの収穫でした。 考えてみれば、これまで経験してきたSRE業務でも何かしらのメトリクスをもとにシステムやプロセスの状態を把握して、改善の手を打ちます。チームを運営するにあたり、チーム状態を知る指標がなければ、打つべき手は見えてきません。それに気づいてからはチーム状態を客観的に示す指標集めと分析に注力しました。 チーム課題の把握 まず取り組むべきと考えたことは、チームの開発生産性を可視化することです。ここでの開発生産性は特定の期間内に遂行できる作業量の意味で使います。これを優先すべきと判断したのは、先のWevoxの調査でチームのストレス値が高いという課題を把握でき、その解決のために必要だと考えたためです。尚、ストレス値が高いことを課題として認識できたのは、他の項目が概ね全社平均を上回っていたのに対してこの項目だけが顕著に低かったためです。 一口にストレス値が高いと言ってもストレス要因は人それぞれ異なります。そのため、メンバーと1on1を実施して一人ひとりの考えを聞いて深堀りしていきました。その中で次の意見が主となっていることがわかりました。 特定のメンバーに業務が集中して負荷が高くなる。 無尽蔵に湧きでるチケットの対応に常に追われている感覚があり、終わった感がない。 当時はチームが少人数という事情とSREの差し込みタスクが多いという業務特性が相まって、Toil 1 に対応しながら中長期的に取り組む改善タスクにも対応していました。やりたいことはたくさんあってもToilに阻まれて思うように進捗が出せないため、それが焦りとなってストレスを増長させている状況でした。また、一部の知見が特定のメンバーに属人化しており、業務負荷が特定のメンバーに集中することも度々ありました。そのような空気感を感じつつも、他のメンバーの状況が見えづらいことでフォローしづらいということが起きていました。 スクラム導入 スクラムの狙い 上記の課題に対して、チーム運営のスタイルにスクラムの要素を取り入れることで改善に近づけると考えました。スクラムに期待したことは次のとおりです。 チームのベロシティを把握して適切な業務量に調整する 他のメンバーの状況を見える化してフォローしやすくする 振り返り頻度を上げて課題を把握する 弊チームのスクラムの流れは以下の図のとおりです。各セレモニーについては一般的なスクラムと概ね同じなので、個々の内容の紹介は割愛させていただきます。 スプリント期間は一週間に設定しています。前述の通り差し込み対応が多いため、スプリントの期間を短くすることでタスク整理の頻度を上げ、計画外のタスクをハンドリングしやすくする狙いがあります。また、緊急性が高くなければ無理にスプリント内で対応せず、次のスプリントに回す判断もしやすくなります。 ここからはスクラムの導入効果について見ていきます。 導入効果 チームのベロシティを把握して適切な業務量に調整する メンバーへのヒアリングから課題の一端は業務量にあることがわかったので、考えられる対策は無理なく対応できる適切な業務量に調整することです。そのためには、当然のことながら「無理なく対応できる適切な業務量」を把握できていることが前提となります。これを把握するために弊チームでは作成したチケット全てに対してプランニングポーカーを行い、チーム全員で見積もることにしました。 プランニングポーカーのツールには SlackのPoker Planner を利用しています。メンバー全員が使い慣れたツール上で見積もりから議論まで完結できるので、オーバーヘッドが少なく運用できています。流れは以下のようになります。 slackから/ppコマンドを実行し、プランニングポーカーを開始する 依頼されたメンバーはチケットを見積もり、ストーリーポイント(以下、spとする)を投票する 投票結果が表示される。見積もりの値で意見が割れたらスレッド内で議論して適切なspを設定する 対象のチケットに決定されたspを転記する プランニングポーカー時の基本的なルールは次の2点です。 見積もりの値がメンバー間で異なる際は必ず相談して決める 5pt以上の場合は原則としてチケットの分割粒度を見直す メンバー間で見積もりの値が合わない原因の多くは、チケットに記載されている情報(目的や背景)の不足や完了条件が曖昧なことで、このチケットで実現すべきことをイメージできないことで引き起こされます。それに対して時間を取って相談することで、全員が共通認識を持てるように徹底しました。 このルールをチームで運用して8スプリント程度を回したところで、おおよそ無理なく捌けるspを把握できました。スプリントプランニングで各メンバーにアサインされているチケットのspの合計値が適正範囲に収まっているかをチェックすることで、働きすぎを防止しています。以下はスプリントプランニングで実際に見ている画面です。もし基準値を超えていれば、落とせるタスクはないか調整するルールとしています。 他のメンバーの状況を見える化してフォローしやすくする 上記により、特定メンバーへの業務負荷の偏りを可視化でき、負荷の高いメンバーのタスク量を調整できるようになりました。 一方、弊部はZOZOが掲げる 戦略の3本柱 の1つとして、新規事業を打ち出す役割を担っています。そのため、新サービスの開発案件がしばしば入ってきます。このような案件は不確定要素が大きくなりがちで、スプリントの計画時点では想定できなかったタスクや依頼が突発的に急増することもあります。デイリースクラムで声を挙げられる場は用意しているものの、余裕がなかったり、ついタスクを捌くことに熱中してヘルプを要請することを忘れてしまいがちです。そのような状態でもスプリントの区切りでベロシティを確認することで第三者が変化に気づけます。実際に確認しているベロシティは以下の図の通りです。 運用方法としては、ベロシティの目標値の下限と上限を定め、その範囲から外れたときに要因を分析しています。 目標値の下限を下回っている場合は、開発効率が落ちている要因を分析します。メンバーが休暇を取得したといった一過性の理由であれば問題視しませんが、チケットの粒度が大きすぎたり、アンコントローラブルな問題が起きてタスクを完了できない場合はチケットの完了条件を見直します。 目標値の上限を上回っている場合も、単に開発生産性が上がったと捉えるのではなく、要因を分析する必要があります。もし特定メンバーの負荷が上がっていれば、他のメンバーに業務負荷を平準化するよう調整します。この場合は実労働時間にも反映されるため、セットで確認しています。 上述のプランニングポーカーで全員がチケットを見積もることで他のメンバーのタスクを把握でき、デイリースクラムでも進捗を共有するため、フォローしやすい体制が維持できています。 振り返り頻度を上げて課題を把握する スクラム導入前は振り返りの施策として月に一度のKPTAを実施していましたが、以下の課題から形骸化しがちになっていました。 月の初めに感じていた課題感を忘れる 改善アクションの確認周期が1か月のため、熱量が保てない スクラム導入直後はタスク遂行に使える可処分時間を減らしたくない気持ちが強く、振り返り頻度は現状維持としていました。しかし、部内で アジャイルなチームをつくる ふりかえりガイドブック の輪読会が行われたことで機運が高まったことに後押しされ、スプリントレトロスペクティブとして毎スプリント振り返りをすることにしました。頻度を上げたことで、チームやメンバーが直面している課題を温度感が高い状態で把握でき、毎週着実に改善に向かっている感覚を得られる利点があります。また、レトロスペクティブをスプリントの最終日においたことで、そのスプリント内で継続すべきことや課題を吐き出して清算するという終わった感を演出する効果もあります。 ユニークな点として、振り返りのアジェンダにはKPTAの他にDoya、Moyaという項目を用意しています。 Doyaはスプリント内で挙げた成果やアピールしたいことを共有するために利用します。KPTAのK(Keep)と似ていますが、Keepは継続したいことを共有するもので、単発の成果などは文脈に合わないため記入しづらいという課題感から作られました。デイリースクラムでもタスクの完了報告はしますが、ここに記入することで改めて他のメンバーが対応してくれたことを振り返ることができます。弊社にはZOZOエールというピアボーナス制度があるので、これと併用して感謝の気持ちを送り合い、称賛する文化を醸成してエンゲージメントを高める効果に期待しています。 Moyaはチームとして議論するほどではないものの共有しておきたい心のモヤモヤを吐き出すことを目的としています。これがあることで課題感を共有する敷居が下がり、より個人的な悩みや課題も抽出できる効果があります。ここに書かれた内容はレトロスペクティブでは深く取り上げず、1on1の会話のネタとして扱う形で活用しています。 スクラム導入後のWevoxスコア スクラム導入後のWevoxスコアは以下の通りです。 課題として挙げていたストレス反応の数値は59から87に改善しました。このことからスクラムの導入には一定の効果があったと考えています。 まとめ 今回はチーム課題との向き合い方の一例を紹介させていただきました。スクラム導入の成功事例のように書いていますが、チーム運営にも銀の弾丸はないので常に試行錯誤が必要だと考えています。気になった方もおられると思いますが、先のWevoxでは新たに別の課題が見えています。これは達成感というカテゴリで、原因としては新規案件のローンチが落ち着いたことや課題視していたデプロイパイプラインの改修が大方完了し、一息ついたタイミングだったことに起因していると認識しています。状況が変われば別の課題が出てくるので、チームは水物だなという所感を持つとともに学びになりました。 今回のアプローチで最も重要なことは、定量的な数値として客観的にチーム状態を把握し、異常が見られたときに定性的な意見を収集して要因を分析できる状態を作っておくことだと考えています。このアプローチはある程度応用が効くものと考えているので、これから直面する課題にも引き続き真摯に向き合って改善していきたいと考えています。 さいごに ZOZOでは一緒にサービスをより良い方向に改善して頂ける方を募集中です。計測プラットフォーム開発本部としては今後も新規サービスのローンチを予定しており、新規の案件に対応しながら既存サービスのグロースにも貢献できる環境が特徴です。 ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 手作業、繰り返される、自動化が可能、戦術的、長期的な価値がない、サービスの成長に比例して増加する、といった特徴を持つ作業です。( SREの原則に沿ったトイルの洗い出しとトラッキング より引用) ↩
アバター
はじめに こんにちは。基幹システム本部・物流開発部の岡本です。普段はZOZO基幹システムのリプレイスを担当しています。 ZOZOではさらなる成長のため、 様々なリプレイスプロジェクト が進行しており、これまでにZOZOTOWNやWEARなどのプロダクトにおける多くのリプレイス事例を公開してきました。本記事では、2022年8月より本格始動したZOZO基幹システムリプレイスの第一弾である ZOZOの物流拠点「ZOZOBASE」を支える「発送システムリプレイス」 を紹介します。「発送システムリプレイス」は設計を終えた開発段階で、リリースに向けて進行中です。本記事を皮切りに今後も継続的に発信を続けていくので、是非ご注目ください。 現状の「発送システム」は、Classic ASPのトランザクションスクリプトで実装された大規模なモノリス構成のシステムの一部であり、「障害リスク」と「開発速度の低下」に課題を抱えています。本リプレイスでは「障害リスク」の課題解決のために既存のモノリスから発送機能のみを切り出して、既存のモノリスと疎結合な発送マイクロサービスへと移行します。また「開発速度の低下」の課題解決のために発送マイクロサービスはJavaのドメインモデルパターンで実装します。 本記事では、「発送システムリプレイス」での様々な工夫のうち、「データベース分割の工夫」と「ドメインモデルがビジネス上の関心事の表現に専念するための工夫」の2つを中心に紹介します。この事例が我々と同じく大規模なモノリス構成の基幹システムの開発・運用を課題に感じており、リプレイスを検討している方々の一助となれば幸いです。 目次 はじめに 目次 ZOZOBASE 発送システム 発送システムの課題 システム障害リスクの増大 ビジネスロジックの複雑化に伴う機能追加の労力の増大 トランザクションスクリプト ドメインモデル 発送システムリプレイス 障害の分離 結果整合性を用いたデータベースの分割 システム間の同期通信 システム間の非同期通信 複雑なビジネスロジックの整理 コマンドとクエリの分離 境界づけられたコンテキストの分離 モジュラーモノリス レイヤードアーキテクチャの導入 パッケージ構成 発送システムリプレイスの進め方 まとめ おわりに ZOZOBASE 「ZOZOBASE」は、ZOZOの保有する物流拠点の名称です。現時点で4つの「ZOZOBASE」が稼働しており、2023年8月には自動化を推進した 「ZOZOBASEつくば3」の稼働開始 も控えています。 また「ZOZOBASE」では、 荷受け・検品・採寸・撮影・入出庫・発送・返品対応などの業務 が内製したClassic ASP製のWebアプリケーションで行われており、現在も機能追加が頻繁に行われています。 発送システム 本記事では、 発送機能を提供するシステムを「発送システム」と呼びます。 具体的に発送機能とは「ZOZOBASE内の商品をピッキングし、注文通りに梱包してお客様宛てに発送する」機能を指します。また、ZOZO基幹システムが提供する機能のうち、発送機能を除いたその他の機能をまとめて基幹機能と呼びます。下図は「発送システム」を含めた現状のZOZO基幹システムの全体を簡易的に表現したものです。 ZOZO基幹システムは、BO(Back Office)とBO2(Back Office 2)というClassic ASPのWebアプリケーション(以降、BO・BO2と表記する)で構成されます。BOはPC用Webアプリケーション、BO2はZOZOBASE内のハンディ端末用のWebアプリケーションで、どちらも基幹DBと通信します。それぞれが提供する主な機能は以下の通りです。 BO ZOZOTOWNへの出品やセールの設定などを管理するためのEC管理機能 ブランド様への月次の精算書発行やZOZO経理部へ売上や手数料などを連携するための経理機能 お客様対応のためのカスタマーサポート機能 ブランド様から送られてきた商品を保管するための入荷機能 お客様へと商品を発送するための発送機能 etc BO2 入荷業務のひとつである棚入れのための機能 発送業務のひとつであるピッキングのための機能 etc 発送システムはBOとBO2の一部であり、発送システムの開発および運用は専任のチームが担当しています。 基幹機能とも一部結合していますが、基本的には発送システムは基幹機能とは疎結合なモジュールとして実装されています。 発送システムの課題 ここからは、発送システムの抱える2つの大きな課題について詳しく説明します。 システム障害リスクの増大 1つめの課題は発送システムの障害リスクの増大です。先に示した通り、発送システムはBO・BO2という巨大でモノリシックなアプリケーションの一部です。アーキテクチャ上、様々な機能と密結合しています。そのため、下図のようにDB障害やWebサーバーの障害など、発送システムとは直接関係がなくとも、基幹機能を提供するシステム起因の障害に巻き込まれる可能性を否定できません。例えば、経理機能の利用が月初に急増することが原因で、発送機能のパフォーマンスが著しく低下するなどの事象が発生する可能性もあります。 ZOZOTOWNでは、注文が入ったその日のうちに商品を発送する 即日配送 などのサービスをエンドユーザーに提供しています。発送機能の障害は社内ユーザーだけでなく、ZOZOTOWNのエンドユーザーのサービス体験を損なう大きなリスクとなります。そのため、基幹機能を提供するシステムに起因する障害の発送システムへの影響を避け、発送業務が停止しないようにする必要があります。したがって、下図のように 発送機能の責務を持つ発送マイクロサービスを既存のモノリスと疎結合な形で実装し障害を分離する必要がある と判断しました。 ビジネスロジックの複雑化に伴う機能追加の労力の増大 2つめの課題はビジネスロジックの複雑化に伴う機能追加の労力の増大です。 「ZOZOBASEつくば3」の新規稼働などを控えるZOZOにおいて、発送システムの重要性と寄せられる期待がますます高まっており、このギャップが大きな課題となっています。 トランザクションスクリプト 発送システムの含まれるBOとBO2は トランザクションスクリプト で実装されています。具体的には、下図のように、WebアプリケーションのUIから要求を受けたコントローラー内でSQL文字列を動的に組み立てビジネスロジックを構築しています 1 。 以下は、Patterns of Enterprise Application Architecture(以降、PofEAAと表記する)でのトランザクションスクリプトについての記述です。 ビジネスロジックが複雑になるにつれて、優れた設計を維持するのは大変になる。特に注意すべきことは、トランザクション間での重複の問題である この記述と同様の問題が発送システムでも発生しています。具体的にはユースケース間で共通のロジックを持つことができないために、同様のロジックが複数のユースケースに書かれています。新たな機能を追加する際には、既存機能への全ての影響を把握したうえで適切な変更を加える必要があります。しかし、Classic ASPをサポートする高機能なIDEも存在しません。そのため、既存機能への影響調査はgrepコマンドと目視で行われており、これが業務時間の多くを占めてしまっている現状があります。 ドメインモデル これまで述べたような課題を解決するための方法として、PofEAAでは、データとプロセスが一体化した ドメインモデル を用いてビジネスロジックを構築するパターンがあげられています。ドメインモデルパターンでは、下図のようにドメインモデルでビジネスロジックをカプセル化して凝集度を高めることができます。ユースケースはドメインモデルを使い処理を組み立てる役割を担い、トランザクションスクリプトの時のように、ユースケース間でのロジックの重複を避けることができます。 また、下図はPofEAA内でトランザクションスクリプトとドメインモデルについて比較している図を抜粋したものです。ここでは両者の比較に焦点を当てるため、テーブルモジュールとの比較に関する表現を省略しています。 この図はドメインロジックの複雑性が赤丸で示した臨界点を超えた場合、ドメインモデルを用いるとトランザクションスクリプトよりも機能追加の労力が小さくなるとことを表しています。発送システムは10年以上改修を続けて成長し続けてきたシステムであり、この臨界点をゆうに超えているため、本リプレイスでは ドメインモデルへの移行が必要 と判断しました。 発送システムリプレイス これまでに説明した課題の解決のために、本リプレイスでは以下のアーキテクチャを採用します。 左の緑の部分はオンプレミスで稼働している既存のモノリスで、橙の線で囲った部分が本リプレイスで新たにクラウドに構築するシステムです。 クラウドに構築する新システムのうち、右の赤の部分は発送マイクロサービスです。Javaによるドメインモデルパターンでモノリスとは疎結合に実装します。中央の青の部分はモノリスのDBに接続する新たなシステムです。これはレガシーである既存のモノリスに大きく手を加えることなくリプレイスを進めるため、既存のモノリスと発送マイクロサービスを接続するためのモジュールとしてJavaで実装します。この部分は既存のモノリスとDBを共有しているためモノリスの一部と捉えます。 以降、具体的な課題の解決方法と設計時に考慮した点を踏まえてアーキテクチャについて詳細に説明します。 障害の分離 まず、1つめの課題であげた障害の分離の実現について説明します。課題の説明の中で述べた通り、本リプレイスでは、 発送システムをモノリスと疎結合なマイクロサービスに移行することで障害を分離します。 learn.microsoft.com 上記のリンク内で説明されているように、マイクロサービスアーキテクチャにはさまざまなメリットとデメリットがあります。本リプレイスでは、特に障害の分離とデータの分離に焦点を当て、 「既存のモノリスに障害が発生しても発送マイクロサービスが問題なく稼働し発送業務を遂行できる状態」 の実現を主な目的として設計します。 結果整合性を用いたデータベースの分割 目的を達成するためにはデータベースの分割が不可欠です。ここでは、モノリスと疎結合な発送マイクロサービスを実現するために行ったデータベースの分割について説明します。下図は基幹DBのテーブルを使用(参照・更新)している機能に着目して整理したものです。 全てのテーブルは以下のいずれかに分類できます。 基幹機能のみが使用するテーブル 基幹機能と発送機能どちらも使用するテーブル 発送機能のみが使用するテーブル モノリスは基幹機能の責務を持ち、発送マイクロサービスは発送機能の責務を持つため、 1 はモノリスに残して、 3 は発送マイクロサービスへとテーブルを完全移行することが比較的容易です。しかし 2 は双方で使用するためどちらか一方に所有権を持たせつつ、他方でも利用可能にする必要があります。 本リプレイスでは、「既存のモノリスに障害が発生しても発送マイクロサービスが問題なく稼働し発送業務を遂行できる状態」を目的とするため同期的なシステム通信を無くすことが重要です。そこで、「常に非同期通信による結果整合性を許容できるか?」「非常時にも同期通信による整合性は必要か?」という2つの観点で改めて整理しました。 結果整合性を許容できない 結果整合性を許容できる 非常時に整合性が必要である 2-A 2-C 非常時には整合性が必要ない 2-B 2-A のようなテーブルが存在する場合には、既存のモノリスシステムが停止すると発送マイクロサービスも停止せざるを得ず障害の分離は実現できません 。結果として本リプレイスにおいてはそのようなデータは存在せず、障害の分離が可能である確信を得ることができました。以降では、 2-B および 2-C のデータを双方で利用するために、どのようにシステム間通信するか具体的に説明します。 システム間の同期通信 下図のような同期的なシステム間通信をすることで、 2-B のテーブルをモノリスと発送サービスの双方で利用します。データはモノリスに残して、発送マイクロサービスはモノリスの提供するREST APIを同期通信で利用します。ただし、非常時には整合性を保つ必要はないため、通信を諦めて発送マイクロサービス側のデータのみで発送機能を進行します。 2-B のテーブルの具体例として「搬送備品 2 管理テーブル」があげられます。モノリスの基幹機能と発送マイクロサービスの発送機能は物理的に同じ搬送備品を使用します。そのため、基本的には結果整合性ではなく同期的な整合性が求められます。しかし、 モノリスでの大規模な障害発生などの非常時では、搬送備品の管理より発送業務の進行を優先し、発送マイクロサービスはモノリスがレスポンス不能な場合にも処理を続行します 。 システム間の非同期通信 2-C のテーブルは所有権(書き込みの権限)をどちらが持つべきか検討した上で、モノリスおよび発送マイクロサービスいずれか一方に所有権を移行します。そして、下図のようにコンシューマとプロデューサーを用意して、非同期通信することで結果整合性を担保して双方で利用します。 2-C のテーブルのうち、モノリスが所有権を持つものの具体例としては商品や配送先などの情報を持つ「発送依頼テーブル」などがあげられます。また、発送マイクロサービスが所有権を持つものの具体例としては発送マイクロサービス内で梱包作業が完了したことを示すデータなどを持つ「梱包完了テーブル」などがあげられます。「発送依頼」はモノリスで更新され、発送マイクロサービスへ非同期的なシステム間通信で反映されます。反対に「梱包完了」は発送マイクロサービスで更新され、モノリスへ非同期的なシステム間通信で反映されます。このとき、モノリスと発送マイクロサービスで同じ構造のテーブルを持つ必要はなく、各々が適切な形でデータを保有できます。 複雑なビジネスロジックの整理 次に、2つ目の課題にあげたドメインモデルパターンへの移行について紹介します。これにより、開発速度を落とすことなく、複雑なビジネスロジックを持つシステムを進化させていくことを目指します。 2つ目の課題の説明で述べた通り、ドメインモデルパターンではドメインモデルに重要なビジネスロジックを凝集することが求められます。しかし、明確な指針を設定せずにコードを書いていくとドメインモデルに様々な関心事が紛れ込んでしまい焦点のぼやけたドメインモデルとなり、その真価を発揮できません。以降では発送マイクロサービスで実施した、ドメインモデルがビジネス上の関心事の表現に専念するための工夫を紹介します。 コマンドとクエリの分離 一般的にコマンド(書き込み側)とクエリ(読み取り側)ではモデルに求められる性質が大きく異なります。 コマンド - ユースケースに依存せず、ドメインモデルの持つビジネスルールで一貫性をもちデータを更新したい。 クエリ - ユースケースに特化した柔軟なモデルでデータを取得したい。 性質の異なる両者を同じモデルの関心事として混在させると、モデルが複雑化してしまいます。そこで発送マイクロサービスではドメインモデルをシンプルに保つために、下図のようにコマンドとクエリでモデルを分離します。 これにより、単一のモデルの関心事を絞ることができ、 最も重要であるコマンドのビジネスルールをそのままコードで表現できます。 具体的には、コマンド側ではドメイン駆動設計の集約の単位で更新し、クエリ側ではコマンドモデルで更新したDBのデータに対してクエリモデルを利用して比較的自由に取得します。また、クエリモデルとしてOpenAPI定義をもとに OpenAPI Generator で自動生成したモデルを利用しています。 さらに、用途に合わせてDBも分離する CQRSパターン を採用することで、柔軟なクエリの実現や処理効率の向上が期待できます。ただし、今回は以下の2つの理由からDBの分離までは行いません。 コマンドで更新したデータをクエリ側に即時反映したい 全体のアーキテクチャ構成をできるだけシンプルに保ちたい ZOZOの店舗在庫連携サービスではAWSのマネージドサービスを活用してDBの分離まで行っています。発送システムリプレイスにおいても参考になる点が多かったのでぜひご覧下さい。 techblog.zozo.com 境界づけられたコンテキストの分離 発送マイクロサービスでは、ドメイン駆動設計の境界づけられたコンテキストという考え方を取り入れて下図のようにドメインモデルをさらに分離します。 具体的には、発送マイクロサービスを「ピッキング」「梱包」という境界づけられたコンテキストで分離します。それにより、コンテキストに依存して異なる意味を持ちうる概念も別々のモデルとして簡潔に表現できます。たとえば、同じ「商品」でもピッキングコンテキストでは「A階の棚BのC段目に保管されている商品」、梱包コンテキストでは「割れ物包装が必要な商品」として別々の道 3 を選択します。 もちろん、1つのユースケースで複数の境界づけられたコンテキストのデータを更新したい場合もあります。発送マイクロサービスでは境界づけられたコンテキスト間を疎結合にするため、1つのユースケースでは1つのコンテキストのデータのみを更新します。そして、別コンテキストのデータはイベント駆動の結果整合性で更新します。具体的な更新イメージは下図の通りです。 任意のユースケースにおいては(1)のように、1つのコンテキストに対してのみデータ更新します。すると、(2)のように発送DBの特定のテーブルをChange Data Capture(以降、CDCと表記する)するイベントプロデューサーが、変更を検知してブローカーにイベントを投入します。次に、ブローカーに対してポーリングを行う、別のコンテキストのイベントコンシューマーが(3)・(4)のようにデータを更新します。 モジュラーモノリス 境界づけられたコンテキストという考え方は、マイクロサービスの境界を考える上で用いられることも多いです。しかし本リプレイスでは、以下の2点を考慮し、発送機能に関連する 複数の境界づけられたコンテキストに関するモジュールが同居するモジュラーモノリス のような形で発送マイクロサービスを構成します。 複数のマイクロサービスを作るコストが高い 現時点では発送マイクロサービス内のコンテキスト境界の完璧な見極めが難しい また、モジュラーモノリスとすることで、 どうしてもコンテキスト間での結果整合性を許容できないケースではRDBのトランザクションを利用する という選択肢を残すことができます。コンテキスト毎にマイクロサービス化した場合、同様のケースは分散トランザクションの問題となり、 sagaパターン などの実装が必要となります。両者では実装や運用にかかるコストが大きく異なるため、現時点では選択肢を残しておけることが大きなメリットであると感じています。ただしコンテキストをまたいだRDBトランザクションの利用は、将来的なDBの分離の難易度を大きく上げる決断であるため、そのようなケースに直面した場合にはトレードオフを見極めて慎重に決断する予定です。 レイヤードアーキテクチャの導入 これまで説明したように、コマンドとクエリを分離し、さらに境界づけられたコンテキストでモデルを分離することでモデルの関心事を絞ることができます。次に、ビジネスロジックを表現するドメインモデルの息づく場所であるドメイン層が他の層の関心事や技術的な関心事と混同することを避けるためにレイヤードアーキテクチャを導入します。 赤い矢印は 依存性逆転の原則(Dependency Inversion Principle) を表します。具体的には、ドメイン層が定義したインタフェースをインフラストラクチャ層で実装します。すると「ドメイン層→インフラストラクチャ層」の依存関係を「インフラストラクチャ層→ドメイン層」に逆転でき、ドメイン層から他の層への依存を無くせます。これにより、UIなどのプレゼンテーション層の関心事や、DBなどのインフラストラクチャ層の関心事に振り回されることなく、開発者はドメイン層でビジネスロジックを表現することに専念できます。 パッケージ構成 これまで紹介したような様々な工夫をした結果、発送マイクロサービスのリポジトリのパッケージ構成は以下のようになっています。 . ├── command/ │ ├── picking/ │ │ ├── application/ │ │ │ └── usecase │ │ ├── domain/ │ │ │ └── model │ │ ├── infrastructure │ │ └── presentation/ │ │ └── openapi/ │ │ └── generated/ │ │ └── model │ └── packing/ │ ├── application/ │ │ └── usecase │ ├── domain/ │ │ └── model │ ├── infrastructure │ └── presentation/ │ └── openapi/ │ └── generated/ │ └── model └── query/ ├── picking/ │ ├── application/ │ │ └── usecase │ ├── infrastructure │ ├── openapi/ │ │ └── generated/ │ │ └── model │ └── presentation └── packing/ ├── application/ │ └── usecase ├── infrastructure ├── openapi/ │ └── generated/ │ └── model └── presentation 発送マイクロサービスのリポジトリでは、 Gradle のマルチプロジェクト機能を活用してパッケージ間の依存関係を厳密に制御しており、開発メンバーが増えても依存関係をクリーンに保つことができています。 発送システムリプレイスの進め方 これまで本リプレイスにおける、リプレイス前後のアーキテクチャを詳細に説明してきました。ここでは具体的なリプレイスの進め方を説明します。 これまでのZOZOTOWNのリプレイスでは、 ストラングラーパターン が多く採用されています。具体的には、ストラングラーファサードとして 自社開発したAPI Gateway を用いて同一のURLに対するリクエストをレガシーとモダンに振り分けることで段階的なリプレイスを行っています。以下は本リプレイスで行う段階的なリプレイスを示した図です。 本リプレイスでは、ユーザーがアクセスするURLも異なる別のアプリケーションを作り、レガシーとモダンの2つのアプリケーションが並行稼働し同一の業務を双方で実行可能な状態にします。そのうえで、上図のように現場作業者の多大なる協力のもと運用をコントロールすることでストラングラーファサードのような役割を担います。そして、1日の発送業務のうちの1%の作業者はモダンシステムで作業をしてもらい、動作の確認ができたら次の日は5%とする。といったように比率を徐々に変えていくことで段階的にリプレイスを進めます。また、モダンシステムで作業エラーやバグが発生した場合でも、現場作業者の協力のもとレガシーシステムに切り替えての作業の続行が可能な状態としています。 本リプレイスは移行が完全に完了して初めて受けられる恩恵が多く、移行期間は負担が大きいです。そのため、移行の検証期間での多少のバグは許容しつつも、完全移行までの期間を最小化することを目指します。 これは本リプレイスの対象が社内向けのアプリケーションであり、社内のユーザーがリプレイスに対して理解し協力が得られているという条件が揃ったからこそ採用できる手法だと考えています。 リプレイスを無事に成功させ障害の分離と開発速度の向上を実現することでより、現場やビジネスに貢献できるように開発メンバー一同で真摯に取り組んでいます。 現在は、一点のみのご注文商品を発送する「単数発送」機能を段階的にリプレイスすることを目指して開発に取り組んでいます。続けて、「単数発送」以外の「複数発送」などの残りの発送機能もリプレイスしていく予定です。 まとめ ZOZOの「発送システム」は、大規模なモノリス構成のシステムの一部であり、「障害リスク」と「開発速度の低下」に課題を抱えています。そこで、これらの課題を解決するため、以下を目的としてリプレイスを進めています。 マイクロサービス化による障害分離 開発速度の向上のためのドメインモデルパターンへの移行 本リプレイスは進行段階であり、これからリリースを控えています。リリース後に実際に得られた成果や発生した問題についても別の記事で改めて紹介する予定です。また、本記事ではCDCやイベント駆動の分散アーキテクチャを実現するための技術選定や実装の詳細について触れることができなかったため、そちらも今後別の記事で紹介しようと考えています。 おわりに ZOZOではZOZOTOWNやWEARのようなエンドユーザーが直接触れるシステムだけでなく、本記事で紹介したような基幹システムの開発・運用・リプレイスも行っています。基幹システムのリプレイスを進めていく仲間や、既存のシステムの開発・運用を担ってくれる仲間を随時募集しています。また、ZOZOの基幹システムのリプレイスプロジェクトについては ZOZO DEVELOPERS BLOG 内の こちらのインタビュー記事 でも紹介しています。 ZOZOの基幹システムの開発・リプレイスに興味を持ってくださった方は、以下のリンクからぜひご応募して下さい。 hrmos.co hrmos.co 実際には、DBのストアドプロシージャを呼び出すことで一部のロジックは共通利用しています。これはビジネスロジックの置き場所が散らばっているという別の課題を生み出しています。 ↩ 「ZOZOBASE」内で商品の移動に使用するランドリーカートなどの備品の総称です。 ↩ エリック・エヴァンスのドメイン駆動設計にて提唱されている概念。「境界づけられたコンテキストを他とは一切つながりを持たないものと宣言し、開発者がその小さいスコープ内で、シンプルで特化した解決策を見つけられるようにすること」を意味する。 ↩
アバター
こんにちは、WEARバックエンドブロックの天春です。バックエンドの運用・開発に携わっています。本記事では、以前公開した WEARにおけるプッシュ通知システムのリプレイス のフェーズ2を終え、旧環境のプッシュ通知システムのリプレイスを完了したのでシステム構成や移行手順をご紹介します。 目次 目次 1:Nのプッシュ通知システム リプレイス前の1:Nのプッシュ通知システム リプレイス前のシステム構成 問題点 リプレイス後の1:Nのプッシュ通知システム リプレイス後のシステム構成 1:Nキュー(Sidekiqダッシュボード) 負荷テスト 目標 対象 事前準備 負荷テスト実施 負荷テスト結果 負荷テスト実施後の改善内容 大量の通知の遅延を減らす 同時実行数の調整 500件単位でFCM通知配信 1:N通知配信の親ジョブ 500件単位でFCM配信を行う1:N通知配信の子ジョブ 500件単位でDynamoDBに書き込み Datadog APM導入 APM設定 現在の状況 今後の課題 最後に 1:Nのプッシュ通知システム WEARには2種類の通知が存在しており、それをフェーズ1(1:1通知リプレイス)とフェーズ2(1:N通知リプレイス)に分けてリプレイスを行いました。今回フェーズ2のリプレイスを完了し、全てのリプレイスを完了しました。 1人のユーザーに送る通知(フェーズ1) 複数のユーザーに送る通知(フェーズ2) リプレイス前の1:Nのプッシュ通知システム Windowsサーバー上で稼働する1:N通知処理バッチがタスクスケジューラに登録されて定期的に通知配信API経由で通知を配信していました。通知サービスはAWSのSNS経由でAppleプッシュ通知サービス(APNs)とFirebase Cloud Messaging(FCM)を使っていました。 リプレイス前のシステム構成 問題点 既存の1:N通知はバッチで配信していたのですが、以下のような問題がありました。 cronジョブで一定量ずつ処理するため、大量の通知が発生した場合に遅延する場合がある 1:N通知対象を取得するバッチ・配信処理API・配信バッチ・通知データを修正するLambdaなどで問題が発生したとき複数言語で開発された実装の理解・修正・影響範囲を調査するのに時間がかかる 再試行のためリモートデスクトップ経由でバッチを手動実行する必要がある エラーログの不足でエラーを検知しても原因把握まで時間がかかる リプレイス後の1:Nのプッシュ通知システム リプレイス後はAmazon Elastic Kubernetes Service (EKS) 導入により負荷が多い時の非同期処理(Sidekiq)のスケールアウトが可能になりました。1:N通知専用のキュー(multi)は2つのPod 1 で構成されています。各Podにはスレッドを10に設定したSidekiqのプロセスが動いています。プロセスが2つ動いているので20件のジョブを同時に実行できる環境になりました。それに加えて1つのジョブが500件単位で配信・通知履歴書き込みを行っているので1万件の通知を同時に配信できるようになっています。 Sidekiq導入でダッシュボードから遅延の状況をリアルタイムでわかるようになった。 EKS化されているのでPodを増やすことで遅延の調整も可能になった。 エラーが発生した場合Slackの通知からSidekiqダッシュボードに遷移できるのでエラー確認・再試行が簡単になった。 APM導入でリアルタイムなアプリケーションの性能監視ができるようになった。 リプレイス後のシステム構成 Amazon ElastiCache for Redisにenqueueした通知データをSidekiqプロセスがdequeueしてFCM経由で通知配信し、DynamoDBに通知履歴を保存します。 1:Nキュー(Sidekiqダッシュボード) 負荷テスト 1:1通知と違って1:N通知の場合は大量の通知が同時に発生するため、システムの負荷を事前に把握して負荷に耐えられるシステム設定をしました。 目標 WEARの既存通知の配信状況を把握して想定以上の通知が発生しても問題ないシステムを構築する。 対象 通知配信に必要なAmazon ElastiCache for Redis、DynamoDB、Sidekiqサーバーに対して負荷検証を行いました。 事前準備 WEARの既存通知の配信と今後増える通知を想定した同時実行数を設定 負荷の調整が可能な負荷テスト用API(通知の種類と同時配信数が指定可能) 負荷テスト実施 負荷テスト用のAPIを実行して目標とする件数の通知が同時に発生した時のシステムのCPU・メモリ負荷を検証しました。 Amazon ElastiCache for Redis:キューに紐づくジョブを処理するためのSidekiq WorkerのPodに割り当てるメモリ・CPUを確定 DynamoDB: WEARのDynamoDBの読み取り・書き込みキャパシティモード はプロビジョンドキャパシティモード。 オンデマンドモードの方が費用削減になるケース もある Sidekiq:SidekiqのダッシュボードとDatadogを利用して遅延・処理時間・負荷をモニタリング 負荷テスト結果 負荷テストの結果、メモリ・CPUはかなり余裕がある状態でしたがメモリリークは発生していることがわかりました。 2023/4/4修正:負荷テストの結果、メモリ・CPUはかなり余裕がある状態でしたがメモリの膨張は発生していることがわかりました。 負荷テスト実施後の改善内容 メモリリークについて、 Sidekiqの公式サイト に対応方法が記載されていたので対応しました。 2023/4/4修正:メモリの膨張について、 Sidekiqの公式サイト に対応方法が記載されていたので対応しました。 クエリキャッシュ削除 読み取りを正しく実行した場合でもActiveRecordクエリキャッシュはクエリ結果を余計に保存することでメモリの膨張を引き起こす可能性があります。 Sidekiqの公式サイト からRails 5.0以降クエリキャッシュはSidekiqワーカーを含むバックグラウンドジョブに対してデフォルトで有効になっていることがわかりました。 大量のメモリを使用している場合はクエリキャッシュを無効にするか手動でクエリキャッシュをクリアしたら改善されるとのことでした。 WEARではクエリキャッシュをクリアしました。 以下は Sidekiqの公式サイト のサンプルです。 # クエリキャッシュ無効 ActiveRecord :: Base .uncached do User .find_each { |u| u.something } end # クエリキャッシュクリア User .find_in_batches.each do |users| users.each { |u| u.something } ActiveRecord :: Base .connection.clear_query_cache end メモリ断片化対応 Sidekiqの公式サイト に以下の内容が記載されていたので MALLOC_ARENA_MAX=2 を追加しました。 Linux環境のRubyはデフォルトのglibc実装を使用してすべてのメモリを割り当てるのでメモリ断片化が非常に起こしやすく、肥大化につながる可能性があります。最も簡単なメモリ断片化の対応方法は、Rubyプロセスの環境にMALLOC_ARENA_MAX=2を追加することです。 大量の通知の遅延を減らす FCMの通知配信とDynamoDBの書き込み処理をまとまった単位で処理することで、通知の負荷と遅延を減らしました。ここからは具体的にどのような処理を行ったのか紹介します。 同時実行数の調整 Sidekiqは起動時に以下のようにキューと並列実行数の設定が可能です。 sidekiq --verbose --queue multi --concurrency 10 並列実行数は、データベースの connection pool 数を超えないように設定する必要があります。 ActiveRecord::ConnectionTimeoutError : could not obtain a connection from the pool within 5.000 seconds (waited 5.009 seconds); all pooled connections were in use 500件単位でFCM通知配信 FCMの複数のデバイスにメッセージを送信する機能 を利用してSidekiqの1つのジョブが500件のFCM通知を配信するようにしました。 1:N通知配信の親ジョブ class ParentMultiPushNotification < ApplicationJob # 省略 def perform (member_ids) # 省略 member_ids.each_slice( 500 ) do |member_ids_group| ChildMultiPushNotification .set( queue : :multi ).perform_later(member_ids_group) end end end 500件単位でFCM配信を行う1:N通知配信の子ジョブ FCMの batch APIのリクエストボディに boundary名 区切りの500件の messages:send APIのリクエストを追加して実行することで500件単位のFCM配信ができました。詳細内容は FCMの複数のデバイスにメッセージを送信する機能 を参考にしてください。 class ChildMultiPushNotification < ApplicationJob # 省略 def perform (to_member_ids) # 省略 bearer_token = ' bear_token ' request_body = '' # payloadsには500件のpayloadが保存されている payloads.each do |payload| request_body += << REQUEST_BODY --boundary名 Content-Type: application/http Content-Transfer-Encoding: binary Authorization: Bearer #{ bearer_token } POST https://fcm.googleapis.com/v1/projects/ #{ project_id } /messages:send Content-Type: application/json accept: application/json #{ payload } REQUEST_BODY end request_body += ' --boundary名-- ' Faraday .new( ' https://fcm.googleapis.com/batch ' ).connection.post( ' batch ' ) do |request| request.headers[ ' Content-Type ' ] = ' multipart/mixed; boundary=boundary名 ' request.body = request_body end end end 500件単位でDynamoDBに書き込み FCMと同じく Dynamoidのimportメソッド を使って500件単位でDynamoDBに書き込みを実行することでDynamoDBの接続を減らしました。 Datadog APM導入 Sidekiq Pro からDatadogのAPMが使えます。WEARではSidekiq Proを使っているので DatadogのAPM を導入してリアルタイムなアプリケーションの性能監視ができるようになリました。 APM設定 config/initializers/datagog_tracer.rb c.tracing.instrument :sidekiq , service_name : ' service_name ' config/initializers/sidekiq.rb require ' datadog/statsd ' Sidekiq :: Pro .dogstatsd = -> { Datadog :: Statsd .new( ' localhost ' , 8125 , namespace : ' sidekiq ' ) } Sidekiq .configure_server do |config| config.server_middleware do |chain| require ' sidekiq/middleware/server/statsd ' chain.add Sidekiq :: Middleware :: Server :: Statsd end end 現在の状況 想定した件数以上の通知が発生したら遅延は発生しますが、サービス上緊急性がない通知も存在するので、費用を考慮した許容できる範囲で運用しています。またWEARでは緊急度が高い通知は専用の「critical」キューから配信しています。遅延が発生しても問題ない1:Nの通知は「multi」キューに分けることで緊急度・優先度が高い通知に遅延が起きないように考慮しています。 今後の課題 Sidekiqが大量の通知を処理した後にメモリの数値が高い状態のままになるケースがある。 旧システムのバッチ停止、関連API・AWSリソースの廃止。 最後に 複数の開発言語で開発された複雑なレガシー通知システムをすべてRubyを使った非同期システムにリプレイスするまで1年程度かかりました。特に問題なくリリースできたので嬉しく思います。 特にSidekiqの導入によってリアルタイムで非同期ジョブの状態が確認できて再試行も簡単にできるので運用しやすくなりました。Sidekiqを検討しているなら Sidekiq Pro がおすすめです。Datadog APM以外に Sidekiq::Batch も使えるので並行実行するすべてのジョブが終了したときにコールバック処理が可能です。 本記事ではWEARにおけるプッシュ通知システムを全て完了した話でした。大量のFCM通知配信・DynamoDB書き込み処理・非同期システムの導入を検討している方や未経験の方の参考になれば幸いです。WEARではサービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 corp.zozo.com Podは、Kubernetes内で作成・管理できるコンピューティングの最小のデプロイ可能なユニットです。 ↩
アバター
はじめに こんにちは。計測プラットフォーム開発本部バックエンドチームの岡山です。普段はZOZOMATやZOZOGLASSなどの計測技術に関わるシステムの開発、運用に携わっています。去年の夏にZOZOFITというサービスを北米向けにローンチし、そのシステムも同様に開発、運用に携わっています。 本記事では、ZOZOFITの認証フローで実行されるScala実装のAWS Lambda関数が抱えていたパフォーマンス課題と、その課題の解決に至るまでの取り組みについてご紹介します。 目次 はじめに 目次 ZOZOFITとは ZOZOFITが利用する認証サービス カスタム認証フローとは パフォーマンスに関する課題 カスタム認証フローにおけるボトルネックの特定 Lambda関数のボトルネック調査 Lambda関数のメモリ設定最適化 パフォーマンス改善結果 終わりに ZOZOFITとは ZOZOFITは2022年に発表した体型管理を目的としたフィットネスアプリです。ZOZOSUITの計測技術を利用したサービスであり、2023年3月時点では、体型計測および身体3Dモデルのデータ・体脂肪率の表示機能を提供しています。 ZOZOFITが利用する認証サービス ZOZOFITでは以下の理由により、認証処理の実装にAmazon Cognitoを利用しています。 認証・認可の機構を時間をかけて実装するよりもコアとなる機能の実装に時間を使いたかった 普段から利用しているAWSの他サービスとの親和性が高かった また、サービス要件に対応するためAmazon Cognitoが提供しているカスタム認証フローを利用しました。 カスタム認証フローとは Amazon Cognitoではユーザーを認証するフローが複数用意されています。カスタム認証フローはその中の1つのフローです。カスタム認証フローを利用することで、AWS Lambdaトリガーを利用し認証フローをカスタマイズ可能です。 カスタム認証フローの詳細についてはAmazon Cognitoのドキュメントをご覧ください。 docs.aws.amazon.com パフォーマンスに関する課題 私たちのチームではDatadogを利用して定期的にレイテンシを振り返っており、その活動の中でサインアップに関連するエンドポイントのパフォーマンスが悪いことを知りました。原因の深掘りのためにAPMを利用して調査しました。結果として、サインアップ時に利用される処理のエンドポイントの大半が特定の処理に偏っていることが分かりました。下記は実際のトレースの一例です。Amazon CognitoのInitiateAuth APIの処理がdurationの大半を占めています。 問題となっているエンドポイントの詳細について説明します。下図は簡略化したZOZOFITの認証フローの図で、赤色で示す箇所がパフォーマンスに課題のあるエンドポイントの処理です。まず初めにユーザーはZOZOFITを利用するためにEメールアドレスを入力し認証を開始します。そして、APIサーバーはクライアントからの呼び出しを受けて、Amazon CognitoのInitiateAuth APIを呼び出します。Amazon CognitoはInitiateAuth APIへのリクエストを受けて、2つのAWS Lambda関数を同期的に呼び出します。APIサーバーはLambdaの処理が完了したのを受けてクライアントにレスポンスを返します。 このフローの特徴として、Create Auth Challenge Lambdaの実行時に、AWS SDKを使用してAmazon SESで確認コードを含んだEメールをユーザーへ同期的に送信しています。 上記のエンドポイントは90パーセンタイルでも 4640.8ms のレスポンスタイムでした。私たちのチームでは、ZOZOFITのローンチ前にEメールを同期的に送信する意思決定をしていました。しかし、そのことを加味しても想定外の数値であり、改善する必要があると考えました。 認証フローの図を見ると分かるようにAmazon CognitoのInitiate Auth APIをリクエストすると、2つのAWS Lambda関数が実行されます。この時Amazon CognitoはLambda関数を同期的に呼び出します。よって、これらLambda関数の実行時間がエンドポイントのパフォーマンスに直接影響を与えていると仮説を立てて調査を進めました。 カスタム認証フローにおけるボトルネックの特定 次に、問題となっていたAWS Lambda関数のパフォーマンス調査をしました。 問題となっていたエンドポイントで実行されるAWS Lambda関数は2つあったため、問題の切り分けのためにDatadogを利用して関数の実行時間の確認をしました。 2つのAWS Lambda関数の実行時間の平均を比較すると、Create Auth Challenge Lambdaはもう1つのLambda関数より 2092ms 長いことが判明しました。 Lambda関数のボトルネック調査 次に、Create Auth Challenge Lambda関数をトレースし、処理のボトルネックを明らかにすることを考えました。このLambda関数はScalaで実装しており、GraalVM Native Imageで実行ファイルにコンパイルし、Lambdaのカスタムランタイム上で関数を実行しています。今回はそのような状況に適したトレーシングツールを発見できず、処理時間をログ出力する方法でボトルネックを探りました。 Lambda関数をScalaで実装した背景は、チームで2つ以上の言語を維持運用するほどのチーム規模もないため、普段使い慣れているScalaを選択したことにあります。 背景の詳細については、以下記事の計測システム部児島が記載した内容を参考にしてください。 AWS re:Invent 2022 参加レポート(ラスベガスの写真と厳選したセッション情報をお届けします!) 調査した結果、ボトルネックとなっていた処理はAWS SDKを使いAmazon SESで確認コードを含んだEメールをユーザーへ送信する処理でした。この処理に必要な時間はAWS Lambda関数全体の実行時間の 約91.5% を占めていました。このLambda関数のメイン処理は上述のAWS SDKを使ったEメール送信であったため、この調査結果は想定範囲内でした。 次に、cold startとwarm startでの実行時間の差分を調査しました。一般的にcold start無し(いわゆるwarm start)の場合、Lambdaの実行環境やAWS SDKクライアントなどが再利用されるため実行時間の短縮を期待できます。下記の図は調査結果を表した図となります。 warm startの場合に処理時間が平均1921ms短縮されることを確認しました。 上記の調査結果から、実際のボトルネックはAWS SDKクライアントの初期化処理などのEメールを送信するための準備処理であると仮説を立てることが出来ました。 ここまでの調査結果を踏まえ、以下の解決策が考えられました。 Lambda関数がwarm startで実行される状態を増やす Lambda関数がcold startで実行される場合でも高速に動作するよう修正 (1)の具体的な解決策としては、 Provisioned Concurrency の設定があります。ZOZOFITはローンチされたばかりのサービスでユーザー数がまだ多くないため事前にLambda関数の実行予約をするのはコストに釣り合わないと判断しました。また、 Lambda SnapStart 有効化による効果についても実際にパフォーマンスを調査・検討しました。下図はSnapStartの有無とGraalVM Native Imageを利用した場合における関数の処理時間の比較です。今回の調査においては、既存実装であるGraalVM Native Imageを利用した方法の方が良いパフォーマンスであったため、SnapStartの有効化を選択しませんでした。 最終的に、私たちは(2)を選択し、Lambda関数に設定されているメモリの最適化を実施しました。設定メモリの最適化はLambda関数のパフォーマンス改善策として知られており、コードの修正も発生しないことから小さく始めることができます。 Lambda関数のメモリ設定最適化 AWS Lambdaでは、設定されるメモリの量によって、Lambda関数で使用できる仮想CPUの量が決まります。メモリを追加することで、それに比例して使用可能な全体的な計算能力が向上します。 詳細は、 AWSの公式ブログ記事 を参考にしてください。 このLambda関数に設定されていたメモリは128MBでした。この値が設定されていた理由は、ZOZOFITローンチ時、このLambda関数のレスポンス性能はミッションクリティカルでなく、実際にパフォーマンス課題等が見られた際に調整する想定があったためです。 当初、どれほどのメモリを設定すれば効率よくLambda関数の実行時間を減らすことができるかが不明でした。ですので、Lambda関数の設定メモリを変更し、実行時間やコストの変化を調査しました。元々設定されていた128MBからスタートし、256, 512, 1024MBと順に設定メモリを変更しLambda関数を実行しました。 下図が設定メモリ別の課金時間と月額の利用料金です。この図を参考にしながらチームで議論し、Lambda関数の設定メモリに1024MBを指定する決定をしました。理由は2点あり、1024MBで課金時間の減少幅が落ち着いていること、月額の利用料金も約$0.02と許容できる額だったためです。 パフォーマンス改善結果 最後に、Create Auth Challenge Lambdaと認証開始エンドポイントのパフォーマンスの変化についてまとめました。どちらも90パーセンタイルのレスポンスタイムにて比較をしています。また、Create Auth Challenge Lambdaはcold start有の場合の実行時間を示しています。 上図からわかる通り、最終的にCreate Auth Challenge Lambdaの実行時間は 約76.2% 削減され、認証開始エンドポイントのレスポンスタイムは 約44.5% 削減されました。 メモリ設定の最適化後、再度Create Auth Challenge Lambda関数のボトルネック処理を調査しました。最適化前と同様に、AWS SDKを使い、Amazon SESでユーザーにEメールを送信する処理がボトルネックであることを確認しました。これはある意味想定通りで、メモリ設定の最適化によってLambda関数の処理全体のパフォーマンスが向上した結果と考えることができます。 今回はメモリ設定の見直しにより、Lambda関数がcold start時でも高速に動作するよう修正しました。しかし、今後のチーム状況によってはGraalVM Native Imageを使わず、Javaランタイム上でLambda関数を実行したくなる可能性もあると思います。そのような時は、再度Lambda SnapStartの有効化を検討したり、パフォーマンスを保ちながら、チームで運用しやすくなる工夫を取り入れていきたいです。 終わりに ZOZOFITの認証フローで実行されるScala実装のAWS Lambda関数のパフォーマンス改善についてご紹介しました。ZOZOFITは ZOZO New Zealand との協業のプロジェクトであり言語の壁もあるので簡単なプロジェクトではないですが、 チームでADRを残す取り組み やプロジェクト全体の改善も進めています。 計測プラットフォーム部バックエンドチームでは、 ZOZOFIT のように、日本国内に限らず新しいサービスを開発していくバックエンドエンジニアを募集しています。 ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
アバター
はじめに こんにちは。ML・データ部MLOpsブロックの築山( @2kyym )です。 MLOpsブロックでは2022年の上期から Argo CD の導入に着手しました。本記事ではArgo CDの導入を検討した背景から導入のメリット、また導入における公式マニフェストへの変更点や、運用において必須である認証や権限管理など、具体的な手順についてご紹介します。少しでもArgo CDの導入を検討している方の助けになれば幸いです。 またArgo CDを導入するきっかけとなった、複数運用していたKubernetesクラスタを1つに集約するマルチテナントクラスタへの移行についても触れます。マルチテナントクラスタの設計や具体的な移行作業については述べると長くなってしまうため、詳細については改めて別の記事にてご紹介できればと思います。 Argo CDについては、昨年の計測SREブロックの記事でも触れられていますので是非こちらもご参照ください。 techblog.zozo.com 本記事ではArgo CD自体や導入のメリットについては簡単な紹介にとどめ、導入時の細かい作業や、導入後チーム運用に乗せるため必要な作業を中心として説明します。 また、本記事ではSSO(Single Sign-On)ログインと権限管理に弊社で利用している Azure Active Directory (Azure AD)を使用することを前提とします。しかしArgo CDではAzure AD以外にもOktaなど別の認証基盤も数多くサポートしており、大まかな手順は変わらないはずです。また、出来る限りAzure ADに特化した記述を避けて説明します。 目次 はじめに 目次 背景 従来の課題 MLOpsマルチテナントクラスタについて Argo CDについて Argo CD導入によるデプロイフローの変化 Argo CDの導入メリット 導入手順 公式マニフェストにパッチを当てる IAP認証とSSOログインについて IAP認証の導入 SSOログインの導入 きめ細かい権限管理について 補足: Secret管理のためのExternal Secrets Operatorの導入 おわりに 背景 まずArgo CDについて述べる前に、背景としてMLOpsブロックにおけるインフラの運用課題と、それを解決するためのマルチテナントクラスタについて説明します。 なお前提として、MLOpsブロックではパブリッククラウドとしてGoogle Cloudを使用しており、インフラには Google Kubernetes Engine (GKE)を使用しています。 従来の課題 MLOpsブロックでは、 ZOZOTOWN や WEAR に機械学習系の機能(推薦、検索、etc...)を提供するサービスを幅広く開発・運用しています。これらサービスのAPI群や一部のバッチは先述の通りGKEクラスタ上にデプロイされています。 また、これらのワークロードは従来、全て1つのクラスタにデプロイされていたわけではありません。推薦や検索、類似画像検索などといった大まかなくくりでGoogle Cloudプロジェクト自体が分かれており、そのため各プロジェクトごとのGKEクラスタへ別々にデプロイされていました。 特に類似画像検索とWEAR向けの機能はサービスあたり1つのGoogle Cloudプロジェクト、すなわちGKEクラスタで運用していました。そのため、サービス数が増えるにしたがってMLOpsブロックで管理するクラスタ数がどんどん増えていきました。 管理するGKEクラスタの増加に伴い、開発運用において次の課題が生まれました。 定期的に実施する必要があるKubernetesのバージョンアップ業務の工数が増加する 新規サービスのインフラを構築する際に、新規のGKEクラスタを構築しなければならない手間が発生する TerraformでGKE関連の冗長な記述が増加する、また共通の変更を加える際にはそれら全てに対して変更を加える手間が発生する 1点目については、GKE Autopilotなどのマネージドサービスを活用すればバージョンアップの自動化が可能ですが、MLOpsブロックでは安定性の観点からバージョンアップを手動で実施しています。そのため、クラスタの数が増加するとこちらの工数も単調増加してしまいます。 2点目については、上記の運用ですとサービスごとにGKEクラスタを分けているため、軽量なAPIが1つのみ必要な場合であっても新規のクラスタを構築する必要があります。 3点目については、我々はGoogle Cloud上のリソースを管理するために Terraform を利用していますが、サービス別にクラスタを構築するとほぼ同じ内容のTerraformリソースが様々なリポジトリに記述されてしまっていました。 MLOpsマルチテナントクラスタについて ここまで述べた開発運用における課題を解決するために、MLOpsブロックで管理するサービスのKubernetesワークロードを1つのGKEクラスタに集約する方針となりました。 マルチテナントクラスタの設計や移行の具体的な手順の検討については、詳細に説明するとそれだけで別の記事が書けてしまうボリュームとなるため、またの機会にぜひお伝えできればと思います。 ここでは簡単に触れるだけに留めますが、サービスごとにNamespaceとノードプールを分けて全てのサービスを1つのクラスタにデプロイするという点以外は、基本的に移行前と同じ構成で動作するように移行を進めました。 もちろん、開発運用・Staging環境・QA環境・本番環境はGoogle Cloudプロジェクト自体を分けて別のクラスタを用意しています。 このマルチテナントクラスタへの移行によって、先程述べた運用課題についてはある程度解決する見通しが立ちました。一方で、以前より感じていた次の運用課題は未だに残っていました。 運用しているサービスの数が非常に多く、監視は適切に行っているものの、それらのデプロイ状況やHealthyかどうかの状況を一覧する術がない 特定のサービスに紐づくKubernetesリソース(Deployment, Ingress, Service Account, Secretなど)の状況を確認する術がない GitHub Actions (GHA)のJob内でkubectl applyを実行することでデプロイを行っており、デプロイ状況がリポジトリのDesired Stateからズレた際に検知・修復できない マルチテナントクラスタへの移行に着手するタイミングで、こういった課題を解決するためにMLOpsブロックではArgo CDの導入を検討することにしました。 以下の章では、Argo CDについてごく簡単な紹介をした後に、導入して感じたメリットと具体的な導入手順について述べます。また、Argo CDでのデプロイを運用に乗せるにあたって必須になるであろう認証と権限管理についても出来る限り具体的に説明します。 Argo CDについて まずはArgo CDについて簡単にご紹介します。Argo CDはKubernetes向けのCD(Continuous Delivery)ツールです。 仕組みとしては、Kubernetesクラスタ上のArgo CDが管理対象のサービス(Argo CDではApplicationという単位で管理)のマニフェストが存在するリポジトリを監視します。そしてリポジトリにおいてマニフェストの変更を検知すると、その状態に合わせて自動で同期するようデプロイが行われます。 また、管理対象のApplicationごとに同期の対象(Desired State)とするリポジトリとブランチを設定できます。そのため、例えばStaging環境のArgo CDではmainブランチを、本番環境のArgo CDではreleaseブランチをターゲットとすることで環境ごとのデプロイが実施できます。 Argo CDの公式ドキュメントでは宣言的(declarative)であることがアピールされています。具体的には、Argo CD自体の設定はもちろん、Argo CDによって管理するアプリケーションの設定まで全てをKubernetesのカスタムリソースとしてマニフェストに記載できます。 以下に、Argo CDによるデプロイフローの簡単な概念図を Cloud Native Computing Foundation(CNCF)のブログ記事 より引用します。 Argo CD導入によるデプロイフローの変化 従来のGHAによるデプロイと比較して、Argo CDの導入によってデプロイフローがどう変化したか、ここで簡単に図を交えて説明します。図の左側は従来のGHAによるデプロイを、右側はArgo CDによるデプロイを示しています。 これまではGHAのJobから、つまりクラスタの外部から kubectl apply コマンドによってデプロイを行っていました。デプロイのタイミングはPull Requestがmain/qa/releaseブランチにマージされた際であり、それ以外のタイミングでは実施されません。 Argo CDの導入後は、クラスタ内部のArgo CD Controllerが同期の対象(Desired State)とするブランチを監視し、差分を検知したタイミングでデプロイが実施されます。例えばPull Requestがマージされた際はDesired Stateに差分が発生するのでデプロイが実施されます。後述する設定によって、何らかの問題や手動変更によってDesired Stateからズレた状態になってしまった際も自動デプロイを行うことが可能です。 Argo CDの導入メリット 導入のきっかけは前章で述べた通り、マルチテナントクラスタへ移行するのに合わせて、運用課題を解決するツールを導入できればタイミングが良いという部分が大きいです。それに加えて、動作検証にあたって感じたメリットを、前章で述べた運用課題の裏返しになりますが以下に列挙します。 GUIが使いやすく、一覧性も高いため、数多の運用サービスのデプロイ状況やHealthyかどうかが簡単に確認できる 特定のサービス、すなわちApplicationに紐づくすべてのKubernetesリソースとその状況が簡単に確認できる 通知(Argo CD Notifications)や自動修復(Self Healing)機能によって、デプロイ状況がDesired Stateから乖離してしまった場合のアクションが取れる 自動カナリアリリースのためにArgo Rolloutsの導入を検討していたが、同じArgo FamilyであるArgo CDを導入することでスマートに管理できる まず1点目と2点目についてですが、マルチテナントクラスタへ集約することで、GKEのコンソールやkubectlコマンドによるサービス状況の確認がそもそも以前よりも行いやすくなっていました。Argo CDを導入したことで、それに加えて運用する全てのサービス、すなわちApplicationのデプロイ状況と、それぞれに紐づくDeployment以外のKubernetesリソース状況も簡単に確認できるようになりました。 3点目については、GHAによるデプロイではカバーできなかったDesired Stateから乖離した場合の対応が可能となりました。開発過程での動作確認を頻繁に行わない本番環境においては、自動修復(Self Healing)機能を有効にしています。こうすることで定期的にデプロイ状況とDesired Stateを照合し、差分が生じた場合は自動で再同期を行えます。 最後に4点目について、今回の記事では深く触れませんが、自動カナリアリリース実現のために導入を検討していたArgo Rolloutsのカスタムリソースをスマートに取り扱えるという利点もありました。どちらもArgo Family内のツールであるため互換性が高く、例えば自動カナリアリリースにおける切り戻し(Abort)といったアクションがArgo CDのGUIやCLIを通して簡単に実施が可能です。 このような利点を踏まえて動作検証を進め、マルチテナントクラスタへの移行と同時にArgo CDの導入を実施することになりました。 導入手順 ここからは具体的なArgo CDの導入手順について説明します。 導入にあたって全ての事柄について説明すると記事が長くなってしまうため、公式ドキュメントを一見して分かりやすい部分に関しては説明を省きます。具体的にはApplicationやAppProjectといった基本的なカスタムリソースの概念やそのマニフェストの書き方については、特筆すべき点がないため本記事では述べません。本章では次の内容について述べます。 マルチテナントクラスタに導入し、運用に乗せるにあたって公式マニフェストにパッチを当てた(カスタマイズした)内容 セキュアかつ使いやすいArgo CD環境を用意するための認証とSSO(Single Sign-On)の導入について MLOpsブロック以外のチームのメンバーにArgo CDを使ってもらう際のきめ細かい権限管理について 次の節ではそれぞれの内容について、背景から導入手順までを実際のコードに則って説明します。 公式マニフェストにパッチを当てる 導入チームのインフラ運用次第ではありますが、公式マニフェストを導入しただけでは動作せず、一部マニフェストにパッチを当てる必要が出てくるケースが多いと考えます。今回の導入にあたっては公式マニフェストに幾つかパッチを当てる必要があったため、出来る限り実際のコードに則って説明を進めます。 我々の運用では、公式のマニフェストに含まれる次の3つのマニフェストにパッチを当てています。なお、IAP認証やSSOログイン、権限管理に関連したパッチについては後述します。 Deployment, StatefulSet群が定義されている argocd-repo-server-deploy.yaml Argo CDのKubernetesワークロードが、マルチテナントクラスタのArgo CD専用のノードプールで起動するようにするためのパッチ 各ワークロードごとに適切なResource Limitsを設定するためのパッチ ConfigMap群が定義されている argocd-cm.yaml デプロイ(同期)の結果をSlackに通知するためのパッチ 本番環境のみ、事故防止のためにWeb UIの見た目をカスタマイズするためのパッチ デフォルトで有効になっている、管理者向けのIDとパスワードによるArgo CDへのログインを無効化するためのパッチ (後述)IAP認証、SSOログイン、権限管理に関連するパッチ Service群が定義されている argocd-server-service.yaml argocd-server をGoogle CloudのBackend Serviceと紐付けるためのパッチ ここでは argocd-repo-server-deploy.yaml と argocd-cm.yaml にフォーカスし、実際のパッチの例を示しつつ説明します。 まず、 argocd-repo-server-deploy.yaml に対して適用するパッチを以下に示します。ここではマニフェストの例として argocd-applicationset-controller を挙げています。 apiVersion : apps/v1 kind : Deployment metadata : name : argocd-applicationset-controller spec : template : spec : containers : - name : argocd-applicationset-controller resources : requests : cpu : 100m memory : 150Mi limits : cpu : 500m memory : 300Mi affinity : nodeAffinity : requiredDuringSchedulingIgnoredDuringExecution : nodeSelectorTerms : - matchExpressions : - key : cloud.google.com/gke-nodepool operator : In values : - "argocd-xxxx-v1" tolerations : - key : "dedicated" operator : "Equal" value : "argocd-xxxx-v1" effect : "NoSchedule" リソースの割り当てに関しては特筆することはありませんが、ワークロードごとに適切な値を設定しています。 所望のノードへのスケジューリングに関しては、 nodeAffinity と tolerations の両方を設定しています。MLOpsブロックではサービスごとにノードプールを分けて運用しており、Argo CDに関しても専用のノードプールを設けています。また、各ノードプールには不要なワークロードがスケジューリングされないよう、 NoSchedule taintを付与しています。そのため、Argo CD向けのノードで起動させるために nodeAffinity だけでなくノードプール名をvalueに指定した tolerations が必要です。 次に、 argocd-cm.yaml に対して適用するパッチを以下に示します。こちらには argocd-cm , argocd-rbac-cm , argocd-notifications-cm といった複数のConfigMapマニフェストが定義されています。本来同じファイルに記載しますが、ここでは分かりやすさのため分けて記載します。 先述の通りIAP認証、SSOログイン、権限管理に関しては後述するため、ここでは該当する部分の記述を省いています。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-cm data : admin.enabled : "false" ui.cssurl : "./custom/my-styles.css" my-styles.css : | .nav-bar { background : red; } まずは argocd-cm です。 admin.enabled をfalseに指定することで、デフォルトで発行される管理者向けのIDとパスワードによるログインが無効化されます。この設定によって、Argo CDのWeb UIにおけるログイン画面でもSSOによるログインしか表示されなくなります。 同じくカスタムCSSを使用するための設定も記述しています。本番環境のArgo CDを操作する際、本番環境だと認識せずに意図しない操作をすることを防ぐため、UI上のサイドバーを警告色にするようカスタマイズしています。 なお my-styles.css に関しては argocd-repo-server-deploy.yaml で別途 /shared/app/custom にVolumeをマウントしています。 次に示す画像は1枚目が開発環境のUIであり、2枚目が本番環境のUIです。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-rbac-cm data : policy.default : role:readonly argocd-rbac-cm では、ロールが割り当てられていないユーザーがログインした際に、読み取り権限のみを持つロールをデフォルトで割り当てる設定をしています。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-notifications-cm data : service.slack : | token : $slack_token config.yaml : | context : argocdUrl : <REPLACE_THIS_FIELD> template.app-sync-failed : | message : | <!channel> {{ if eq .serviceType "slack" }} :exclamation:{{end}} The sync operation of application {{ .app.metadata.name }} has failed at {{ .app.status.operationState.finishedAt }} with the following error: {{ .app.status.operationState.message }} Sync operation details are available at : {{ .context.argocdUrl }} /applications/{{.app.metadata.name}}?operation= true . slack : attachments : | [{ "title" : "{{ .app.metadata.name}}" , "title_link" : "{{.context.argocdUrl}}/applications/{{.app.metadata.name}}" , "color" : "#E96D76" , "fields" : [ { "title" : "Sync Status" , "value" : "{{.app.status.sync.status}}" , "short" : true } , { "title" : "Repository" , "value" : "{{.app.spec.source.repoURL}}" , "short" : true } {{ range $index, $c : = .app.status.conditions }} {{ if not $index }} , {{ end }} {{ if $index }} , {{ end }} { "title" : "{{$c.type}}" , "value" : "{{$c.message}}" , "short" : true } {{ end }} ] }] deliveryPolicy : Post groupingKey : "" notifyBroadcast : false trigger.on-sync-failed : | - description : Application syncing has failed send : - app-sync-failed when : app.status.operationState.phase in [ 'Error' , 'Failed' ] argocd-notifications-cm は、Argo CDの公式プラグインである Argo CD Notifications に関するConfigMapです、この例では失敗時にSlack通知を行う記述について示します。 まず token はArgo CDの通知をするSlackアプリケーションのOAuthトークンであり、秘匿情報のためSecretを介して値を渡します。 argocdUrl はSlack通知のメッセージに表示されるArgo CDのURLであり、環境ごとに値が違うためパッチによって上書きしています。 template.app-sync-failed と trigger.on-sync-failed は、同期の失敗時に通知するメッセージのテンプレートと、その発火条件の設定です。 app-sync-failed を含む基本的な状態の通知に関しては、 Argo CD Notificationsのマニフェストにはじめから定義 されています。そのため本来であればパッチを定義する必要はないのですが、失敗時はSlackメンションを付けて通知をしたいため、少々冗長さを許容してこのような方法を取っています。 IAP認証とSSOログインについて Argo CDのWeb UIにアクセス出来るように、今回導入したArgo CDは外部公開されており、認証は必須です。 これまでは、外部公開するサービスにはCloud ArmorによるIP制限を設け、社内ネットワークやVPNを通してのみアクセスできるようにしていたケースが多くありました。しかしこの運用はVPNによる通信速度の低下といった課題もあり開発体験が悪かったため、今回はIP制限を設けず、Google CloudのCloud IAPによってIAP認証を設ける方針としました。 また、IAP認証を通過したのちにArgo CDへログインできるユーザーをMLOpsブロックのメンバーを始めとする関係メンバーのみに絞るため、SSOログインを導入しました。 Argo CDはOktaやMicrosoft Azure Active Directory(Azure AD)を始めとした様々な認証基盤によるSSOログインをサポートしています。今回は弊社で社内向けアプリのログイン全般に利用しているAzure ADを利用して、関係メンバーのみがログインできる方針としました。 なお、Argo CDではIDとパスワードによるログイン運用も可能ですが、上記の方針だとよりセキュアかつ利用が簡単なため、管理者であってもIDとパスワードによるログインはできないようにしています。 この章ではIAP認証とSSOログインの導入について説明しますが、Azure ADに特化した内容については流れを把握するための簡単な説明だけに済ませます。 IAP認証の導入 この節では、IAP認証とSSOログインの導入について実際のマニフェストを交えつつ簡単に説明します。 まずIAP認証によって、MLOpsブロックの関係者に限らず社内アカウントを所持するメンバーを認証します。続くArgo CDのログインで関係者のみ権限を分けて認証します。 次はTerraformでArgo CDのBackend Service(Kubernetes Ingressをデプロイすると作成される)に対して、社内アカウントのアクセス権を付与している例です。 resource "google_iap_web_backend_service_iam_binding" "argo-cd-iap-iam-binding" { project = local.project web_backend_service = data.google_compute_backend_service.argo-cd-backend-service.name role = "roles/iap.httpsResourceAccessor" members = [ "domain:zozo.com" , ] } また、Cloud IAPによる認証をするため、前もってGoogle Cloud側でOAuth 2.0クライアントを作成する必要があります。次はTerraformによるOAuthクライアントのリソース定義の例です。 # Since the iap brand already existed, refer the brand name to create iap client: `gcloud alpha iap oauth-brands list` resource "google_iap_client" "argo-cd-iap-client" { display_name = "argo-cd-iap-client" brand = "projects/$ { local.project_number } /brands/$ { local.project_number } " } OAuthクライアントを作成後、IAP認証に利用するクライアントIDとクライアントSecretを取得してGoogle Secret Managerに別途保管しておきます。 そしてArgo CDのWeb UIを司る argocd-server のServiceに紐づく、GKEのカスタムリソースであるBackendConfigに次の設定を適用します。 apiVersion : cloud.google.com/v1 kind : BackendConfig metadata : name : argocd-backend-config spec : ... iap : enabled : true oauthclientCredentials : secretName : argocd-secret ここで oauthclientCredentials として渡している argocd-secret という名前のSecretリソースに、先程作成したOAuthクライアントのIDとSecretの値を含めています。 上記の設定によりIAP認証を導入し、社内アカウントを持つメンバーの認証を行えるようになりました。 SSOログインの導入 続いて、SSOログインに関する設定について説明します。先述の通り、弊社では社内向けアプリの認証基盤としてAzure ADを利用しているため、今回のArgo CDへのSSOログインにおいてもこちらを用いる方針としました。 SSOログインを行うため、まずはAzure AD側でArgo CDと紐づくSAMLアプリケーションを用意する必要があります。アプリケーションの追加は、Argo CDの導入を済ませた上でコーポレートエンジニアリングチームにエンドポイントを伝えて依頼しました。Azure ADでなくOktaなどの別の認証基盤を利用しているケースでも、大体同じ流れになるかと思います。 またSAMLアプリケーションの作成を依頼する際、後述するきめ細かい権限管理のため、適切な粒度でのユーザーグループの作成も同時にお願いしました。ユーザーグループごとの権限はArgo CD側で細かく調整できるため、一旦は権限を付与する可能性のあるチームの単位(検索系、推薦系など)でユーザーグループを分けておきます。 結果として、環境ごとのSAMLアプリケーションと、それに紐づく7つのユーザーグループが用意できました。 認証基盤側の準備が完了した後は、 argocd-cm に次のパッチを当てることでSSOログインを有効化します。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-cm data : url : https://${endpoint} dex.config : | logger : level : debug format : json connectors : - type : saml id : saml name : saml config : entityIssuer : https://${endpoint}api/dex/callback ssoURL : https://${ssoUrl} caData : $argocd_dex_saml_ca redirectURI : https://${endpoint}/api/dex/callback usernameAttr : email emailAttr : email groupsAttr : Group こちらの設定も利用する認証基盤によって多少変化しますので、ここでは参考程度にお伝えします。 endpoint は構築したArgo CDのエンドポイントであり、 ssoUrl はAzure ADアプリケーション側で発行されたログインURLです。 caData はSAML証明書であり、秘匿情報のためOAuthクライアントの情報と同様に argocd-secret というSecretに含めて渡しています。 これでIAP認証とSSOログインの両方を導入し、利用者にとってログインが簡単でかつセキュアな環境を整えることができました。 きめ細かい権限管理について ここまでの作業によって、管理者であるMLOpsブロックのメンバーを含む利用者が、簡単かつセキュアにArgo CDを利用できるようになりました。この節では利用者ごとにきめ細かい権限管理をする方法について説明します。 Argo CDでは、全てのリソースに対する全権限を持つ admin ロールと、全てのリソースに対する読み取り権限を持つ readonly ロールが組み込まれています。また、デフォルトでは readonly ロールが割り当てられます。 しかし先述の通りMLOpsブロックが管理するサービスの領域は広いため、利用するメンバーによってどのArgo CDリソースの権限を付与するかを切り分けたいケースがあります。例えば管理者であるMLOpsブロックのメンバーには全リソースに対する権限を付与し、検索系のメンバーには検索に関連したApplicationに対する権限のみを付与したい、といったケースです。 スムーズな開発のため開発環境では編集権限を付与し、本番環境では読み取り権限のみを付与できると嬉しいですし、管理者でも先述の admin ロールよりも権限を絞る、といった柔軟な権限設定が望まれます。 Argo CDでは管理者が定義したカスタムユーザーまたはSSO構成ごとに、Argo CDリソースの権限レベルを細かく設定したカスタムロールを割り当てる、ロールベースアクセス制御(RBAC)が可能です。 Azure ADをはじめとした認証基盤側で、先述の通り割り当てたいロールごとに検索系メンバー、推薦系メンバーといったユーザーグループを用意しました。これらは後ほど解説するArgo CDカスタムロールにそれぞれ紐付けることができ、きめ細かい権限管理が行えます。 RBACはArgo CDに含まれるConfigMapのマニフェストである argocd-rbac-cm にパッチを当てることによって設定できます。参考までに、Staging環境における検索系チーム向けのロール割り当ての例を示します。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-rbac-cm data : # Azure AD groups ref. https://portal.azure.com/... policy.csv : | p, role:search-member, applications, get, search/*, allow p, role:search-member, clusters, get, *, allow p, role:search-member, repositories, get, *, allow p, role:search-member, projects, get, search, allow g, "${azure_ad_search_group_object_id_stg}" , role:search-member search-member がロール名にあたるもので、最終行でこれをユーザーグループの識別子と紐付けています。 ここで azure_ad_search_group_object_id_stg はAzure ADユーザーグループのオブジェクトID(一意なIDであるため伏せています)です。なお、SSO構成によって紐付けに用いる識別子は変わります。開発環境ではなくStaging環境のため、メンバーには検索系のApplicationのみに対する読み取り権限と、その他リソースの読み取り権限を付与しています。 また、以下に同じくStaging環境におけるMLOpsブロックメンバー向けのロール割り当ての例を示します。本来上記と同じパッチに記述するものですが、ここでは分かりやすさのために分けて記述します。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-rbac-cm data : policy.csv : | p, role:org-admin, applications, *, */* , allow p, role:org-admin, clusters, get, *, allow p, role:org-admin, repositories, get, *, allow p, role:org-admin, projects, get, *, allow g, "${azure_ad_admin_group_object_id_stg}" , role:org-admin 検索系チーム向けのロールと比較すると分かるように、全てのApplicationに対する全ての操作権限を付与しています。一方で、Application以外のリソースに変更を加える際は基本的にマニフェストの修正をしてPRレビューを通すといったフローで行うため、その他リソースに関しては読み取り権限のみを付与しています。 このように、SSO構成と紐付いたRBACを設定することで、利用チーム別かつデプロイ環境別にきめ細かく安全な権限管理ができます。 補足: Secret管理のためのExternal Secrets Operatorの導入 本題からはずれますが、マルチテナントクラスタへの移行とArgo CDの導入において避けては通れなかった External Secrets Operator の導入について、ここで参考までに述べます。 MLOpsブロックでの従来の運用では、Kubernetes Secret管理とデプロイに Berglas というGoogle製のオープンソースCLIツールを利用していました。このCLIツールは用途がKubernetesに限られないもので、コマンドを実行することで対象のSecret値の実体化が行えます。バックエンド(Secretの保管先)としてGCS、Google Secret Managerが利用できます。 従来の運用におけるSecretのデプロイフローを下の図に示します。 Argo CD導入前のため、GHAからSecretリソースのデプロイを行う前提です。まずBerglasコマンドを実行するスクリプトを介して、Google Secret Managerを参照してSecretの実体化を行い、その後クラスタへのデプロイを実施しています。 GHAのみならず、動作確認などローカル環境からの手動デプロイにおいても同じフローで実施しており、各メンバーのローカル環境でBerglasによるSecretの実体化を行う手間がありました。また開発メンバーにはGoogle Secret Managerに対するAccessorロールを付与する必要があり、詳細は省きますがマニフェストの構成が直感的でなく分かりづらいという問題もありました。 上記のような課題感があったため、Secret保管先としてはGoogle Secret Managerを引き続き利用しつつ、Berglasの利用を撤廃できる方針を検討していました。 Argo CDは先述の通りDesired Stateとするブランチに対して同期を行いますが、リポジトリ上にはもちろん秘匿情報は保管できません。また、Argo CD側からBerglasによってSecret値を実体化させる術もありません。そのため通常の同期ではSecretの値を実体化させてデプロイすることが出来ず、Berglasの利用を継続したままArgo CDを導入するのは難しいという点もありました。 そこで検討した結果候補に挙がったのが、Argo CDの公式プラグインである Argo CD Vault Plugin (AVP)と、External Secrets Operatorの2つです。 AVPを用いた場合、Argo CDがバックエンドであるSecretの保管先を参照し、Secretリソース中のプレースホルダを上書きすることでSecretの実体化を行います。 バックエンドとしてGoogle Secret Managerを利用でき、かつArgo CDプラグインとして動作するため、運用するKubernetesワークロードが増えないという利点もあります。使い方もKubernetes Secretリソースにアノテーションを追加するだけでよくシンプルで、初めはBerglasからこちらに移行することを検討しました。 しかしあくまでもArgo CDのプラグインであるため、動作確認などを目的とする手動デプロイといった、Argo CDを介さないデプロイではSecretの値を実体化させることができず、運用が難しいという結論に至りました。 続いて検討したのがExternal Secrets Operatorです。こちらの運用ではマルチテナントクラスタ上にデプロイされたExternal Secrets Operatorが、Google Secret Managerの参照とSecretの実体化の役割を担います。ユーザーがExternalSecretというカスタムリソースを作成すると、External Secrets OperatorがExternalSecretに紐付くKubernetes Secretリソースを作成します。 次にExternal Secrets Operatorの概念図を 公式ドキュメント より引用します。 デプロイ時にクラスタ内部からバックエンドを参照してSecretの実体化を行うという点で、使い勝手の面ではAVPと大きく変わりません。 メリットとしてはArgo CDとは独立であるため、ExternalSecretリソースをArgo CDを介さずデプロイした場合であってもSecretの実体化を行える点が挙げられます。運用するワークロードが増えるというデメリットはありますが、使い勝手を考えこちらを導入することにしました。 Berglasの利用が撤廃できたため、デプロイ前にSecretを実体化させる手間がなくなりました。また、Google Secret Managerの権限をメンバーに付与する必要がなくなり、マニフェストの構成もシンプルになるなど、多くのメリットが得られました。 おわりに 最後まで読んでいただきありがとうございました。 MLOpsブロックでは多数のサービスを開発運用する上での課題を解決するために、マルチテナントクラスタへの移行とArgo CDの導入を実施し、運用において多くのメリットを得ることができました。 また本記事では、Argo CDを導入し運用していくにあたってほぼ必須となる、認証機構の導入やメンバーごとの権限管理をきめ細かく行う方法についても説明しました。本記事が皆様のお役に立てば幸いです。 最後になりますが、ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co corp.zozo.com
アバター
はじめに こんにちは、技術本部SRE部ZOZOSREチームの堀口です。普段はZOZOTOWNのオンプレミスとクラウドの構築・運用に携わっています。またDBREとしてZOZOTOWNのデータベース全般の運用・保守も兼務しております。 ZOZOTOWNではSQL Serverを中心とした各種DBMSが稼働しています。その中でZOZOTOWNサービスの根幹となるいわゆる基幹データベース(以下、基幹DBと呼ぶ)を5年ぶりにリプレースしました。 基幹DB群は、商品情報、在庫情報、注文情報、会員様情報、ブランド様情報、配送管理、キャンペーン情報、分析系情報などZOZOTOWNサービスにおけるほぼ全ての情報を管理しているものとなります。 リプレースのモチベーションは5年のハードウェア(以下、HWと呼ぶ)保守期限終了およびSQL ServerのEnd Of Life(以下、EOLと呼ぶ)を迎えるため、HWの更改/SQL Server、OSのバージョンアップが必要となったことです。 本記事では、どのようにこれら基幹DBリプレースを推進したか、ZOZOTOWNサービスを引き続き快適に利用して頂けるよう案件を推進したかという内容を紹介させて頂きます。 目次 はじめに 目次 基幹DBのリプレース方針 機器選定 A社 or B社? 機器スペック選定 OS/SQL Server/各種ミドルウェアのバージョン選定 切替計画 SQL Server2012と2019のクエリ互換性 基幹DBの理解 設計・構築 テスト アプリケーションテスト 性能テスト 対象クエリの抽出方針 負荷ツールの選定 性能テスト結果 障害テスト 運用テスト 品質管理テスト 切替リハーサル 切替リハーサル結果 本番切替 終わりに 基幹DBのリプレース方針 基幹DB群はZOZOTOWN創業時から存在すること、サービスにおける絶大な影響を持つことから、いかに安全かつ革新的にこのリプレース案件を成功させるかをずっと考えていました。 その結果、以下の方針でリプレースすることにしました。 改善したいところはあるものの、手を出さずにオンプレミスサーバ⇒オンプレミスサーバへの単純リプレースとする ベンダー依存の体制から脱却する 各種連携システム(主に人)と完璧に情報を共有し「漏れ」をなくす 1つ目の単純リプレースとした理由は「安全にリプレースする」ことを最優先としたためです。改善したかった所としては以下のものがありましたが、本プロジェクトのスコープからは除外しました。 不要オブジェクトやデータ連携ルートの削除、既存オブジェクトの適切なDBへの移動 オンプレミスサーバに存在する理由のないテーブルや処理は「Amazon RDS」,「Amazon Aurora」,「Google Cloud BigQuery」などに部分移行 2つ目のベンダー依存に関しては、前例と実績を重視するあまり機器選定からリリースへ至るまで(言い方は少々悪いですが)ベンダー任せにしてしまっているところがありました。機器選定時、適切な検証と選択を行なわず、また運用に関しても自社エンジニアでできることはほとんどなくベンダーに頼り切りとなっている体質があったため、今回はこれも是正したいと強く思っていました。 3つ目に、基幹DBと連携するサーバやシステムが多数あるため、リリース時には複数システムで切替作業が必要となります。この方針決めの時点では全容は把握できていませんでしたが、日々のDBREとしての活動の中で容易に想像できました。 以降、上記方針に基づいてあらゆる事を判断していくこととなります。 機器選定 対象となるHW機器は大きく分けて以下の3つです。 サーバ製品 ストレージ製品 スイッチ製品 また選定した機器と委託するベンダーとは関連があり、選定した機器に対してそれを得意とするベンダーを構築担当としてお願いすることになります。そのため、 機器選定=ベンダー選定である という事を念頭に置いて選定を行なっていくことになります。 機器選定においては、A社とB社の製品を比較・検討していくことにしました。まず実施したことはストレージ製品の性能比較です。性能比較にあたっては、以下の方針を立てました。 同一条件で同一処理を実行し、ストレージ単体の秒間スループット、IOPS、レイテンシを比較する 同様にSQL Server経由での性能を比較する 読み込み/書き込み、ブロックサイズ(8K、64K、256K)、スレッド数(1、32)、sequential/randomの全ての組み合わせパターンを比較する SQL Serverを経由したselect/insert/update/deleteのパターンを比較する 製品間の性能差を計測するものであり本番環境の性能を保証するレベルのテストではない(≒限界値テストは行わない) この検証をするための検証環境は各ベンダーに用意して頂きました。 ディスク単体の性能計測はMicrosoft社(以下、MS社と呼ぶ)製のベンチマークツールである「DiskSpd」を使用しました。これは弊社内でノウハウがあったことと、オプションが多数あり細かい動作を指定できること、outputが見やすいことから採用しました。 DISKSPD を使用してワークロード ストレージのパフォーマンスをテストする を参考に実行したDISKSPDコマンド例は次のとおりです。 Diskspd.exe -c1G -b8K -t1 -o1 -L -h -si -d120 T:\test.dat c1G:テスト用ファイルサイズ1GB b8K:ブロックサイズ8KB t1:同時スレッド数1 o1:キューデプス1 L:レイテンシー統計出力 h:ソフトウェア/ハードウェアキャッシュを無効化する si:シーケンシャルアクセス d120:120秒間実行 T:\test.dat:テストファイル名 上記のコマンドを実行するとこのように細かく性能値を出力してくれます(少々長いです)。 Diskspd結果 Command Line: Diskspd.exe -c1G -b8K -t1 -o1 -L -h -si -d120 T:\test.dat Input parameters: timespan: 1 ------------- duration: 120s warm up time: 5s cool down time: 0s measuring latency random seed: 0 path: 'T:\test.dat' think time: 0ms burst size: 0 software cache disabled hardware write cache disabled, writethrough on performing read test block size: 8KiB using interlocked sequential I/O (stride: 8KiB) number of outstanding I/O operations per thread: 1 threads per file: 1 IO priority: normal System information: computer name: PE640-SP01 start time: 2021/12/13 12:26:03 UTC Results for timespan 1: ******************************************************************************* actual test time: 120.01s thread count: 1 proc count: 32 CPU | Usage | User | Kernel | Idle ------------------------------------------- 0| 20.71%| 0.65%| 20.06%| 79.29% 1| 0.03%| 0.01%| 0.01%| 99.97% 2| 0.01%| 0.00%| 0.01%| 99.99% 3| 0.03%| 0.00%| 0.03%| 99.97% 4| 0.01%| 0.01%| 0.00%| 99.99% 5| 0.74%| 0.34%| 0.40%| 99.26% 6| 0.14%| 0.05%| 0.09%| 99.86% 7| 0.18%| 0.08%| 0.10%| 99.82% 8| 0.13%| 0.07%| 0.07%| 99.87% 9| 0.04%| 0.03%| 0.01%| 99.96% 10| 0.12%| 0.09%| 0.03%| 99.88% 11| 0.08%| 0.04%| 0.04%| 99.92% 12| 0.13%| 0.12%| 0.01%| 99.87% 13| 0.00%| 0.00%| 0.00%| 100.00% 14| 0.33%| 0.10%| 0.22%| 99.67% 15| 0.03%| 0.00%| 0.03%| 99.97% 16| 1.76%| 0.00%| 1.76%| 98.24% 17| 0.04%| 0.03%| 0.01%| 99.96% 18| 0.03%| 0.00%| 0.03%| 99.97% 19| 0.00%| 0.00%| 0.00%| 100.00% 20| 0.04%| 0.03%| 0.01%| 99.96% 21| 0.04%| 0.03%| 0.01%| 99.96% 22| 0.03%| 0.01%| 0.01%| 99.97% 23| 0.00%| 0.00%| 0.00%| 100.00% 24| 1.05%| 0.00%| 1.05%| 98.95% 25| 0.00%| 0.00%| 0.00%| 100.00% 26| 0.01%| 0.00%| 0.01%| 99.99% 27| 0.00%| 0.00%| 0.00%| 100.00% 28| 0.07%| 0.05%| 0.01%| 99.93% 29| 0.00%| 0.00%| 0.00%| 100.00% 30| 0.00%| 0.00%| 0.00%| 100.00% 31| 0.05%| 0.03%| 0.03%| 99.95% ------------------------------------------- avg.| 0.81%| 0.05%| 0.75%| 99.19% Total IO thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | LatStdDev | file ----------------------------------------------------------------------------------------------------- 0 | 6798966784 | 829952 | 54.03 | 6915.95 | 0.144 | 0.115 | T:\test.dat (1GiB) ----------------------------------------------------------------------------------------------------- total: 6798966784 | 829952 | 54.03 | 6915.95 | 0.144 | 0.115 Read IO thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | LatStdDev | file ----------------------------------------------------------------------------------------------------- 0 | 6798966784 | 829952 | 54.03 | 6915.95 | 0.144 | 0.115 | T:\test.dat (1GiB) ----------------------------------------------------------------------------------------------------- total: 6798966784 | 829952 | 54.03 | 6915.95 | 0.144 | 0.115 Write IO thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | LatStdDev | file ----------------------------------------------------------------------------------------------------- 0 | 0 | 0 | 0.00 | 0.00 | 0.000 | N/A | T:\test.dat (1GiB) ----------------------------------------------------------------------------------------------------- total: 0 | 0 | 0.00 | 0.00 | 0.000 | N/A total: %-ile | Read (ms) | Write (ms) | Total (ms) ---------------------------------------------- min | 0.111 | N/A | 0.111 25th | 0.131 | N/A | 0.131 50th | 0.137 | N/A | 0.137 75th | 0.147 | N/A | 0.147 90th | 0.161 | N/A | 0.161 95th | 0.185 | N/A | 0.185 99th | 0.226 | N/A | 0.226 3-nines | 0.478 | N/A | 0.478 4-nines | 0.960 | N/A | 0.960 5-nines | 2.050 | N/A | 2.050 6-nines | 100.823 | N/A | 100.823 7-nines | 100.823 | N/A | 100.823 8-nines | 100.823 | N/A | 100.823 9-nines | 100.823 | N/A | 100.823 max | 100.823 | N/A | 100.823 これらの結果から重要なメトリクスを抽出し、一覧化し比較しました。 結果より、 ストレージへの純粋なアクセス性能においては、ほぼ全てのパターンでA社の方がB社より優秀 ということがわかりました。 次にSQL Serverを経由したアクセス性能を検証しました。 対象クエリについてはOLTP/バッチ処理を想定した本番環境に実際に発行されている「select/update/insert/delete」文の中から重いと判断したものを抽出しました。それをテスト用クエリとしシングル/並列スレッドでSSMSから実行しました。 また検証用サーバ機は調達の問題により両社でCPUのスペック差が発生していたため、以下の設定をSQL Serverに行うことで条件を統一しました。 SQL Serverが使用するCPUについて、A社のCPUのコア数をB社の32コアに合わせ32コアへ縮小する 関係マスクを自動→固定に変更(両社とも) 観察すべきメトリクスは色々ありますが、レスポンス時間、リソース使用状況を取得するため、以下の取得設定を事前に仕込み、性能測定に使用しました。 クエリストア パフォーマンスモニタ DMV情報 またオプティマイザによって作成される実行プランに違いが出てしまうと純粋な比較とならないため、実行プランが同じであることは項目毎に確認していきました。テスト結果はDiskSpdと同様に一覧化し比較しました。結果的としては、DiskSpdの結果と同様にA社製品の方が性能的に”上”という結果となりました。 レスポンス リソース状況 A社 or B社? 上で書いた通り機器選定=ベンダー選定でもあり、システム構築や5年後までを見据えた保守サポートなど長いお付き合いとなります。また当然ですがコストについても比較しなければ決定するには不十分です。基幹DBのワークロードにおける性能比較はA社の方に分がありましたが、果たしてその結果だけで評価してよいものか考えました。 全体の評価には、以下の評価項目について両社を比較し合計点の大きい方を採択するという方針を決めました。 評価項目 分類 信頼性 企業信用力 資質・能力 取り纏めSE能力 提案力 適合性1 製品 性能 適合性2 提案内容 業務の理解度 業界知識 業務知識 実現性のある提案か 実現性 日程・体制 将来性 拡張/保守 経済性 見積金額 なるべく公平な評価を目指した結果このような点数制になりました。これでも主観が入ってしまうことは否めませんが、最終的にはこの点数を根拠として、本リプレース案件はA社製品を採択することになりました。 5年前のリプレース以前からZOZOTOWNが採用してきた基幹DBのHWベンダーを今回の基幹DBリプレースから一新することになりました。 機器スペック選定 基幹DBはオンプレミスなサーバ群のため当然ですがクラウド環境のように容易なスペック変更はできません。そのためこの時点でスペック不足とならないよう慎重にCPU/メモリ/ディスクサイズを決定する必要があります。選定の方針としては以下の2つです。 現行で稼働する基幹DBのスペックとリソース使用率を基準に将来的な成長率を考慮し決定する 多少のオーバースペックは許容する ここについては、HWベンダーのアセスメントサービスを利用して最終的なスペックを提案して頂き決定しました。 OS/SQL Server/各種ミドルウェアのバージョン選定 基本方針は以下の通りです。 原則最新バージョンで構成する SQL Serverについては連携するシステムとの互換性に注意する SQL Serverのバージョンは最新である2019を採用することを軸に検討しました。また互換性レベルも150として現行の100から大幅に引き上げることにしました。 基幹DBは各種システム(サーバ)とのデータ連携にSQL Serverのトランザクションレプリケーション機能を利用してます。パブリッシャー側とサブスクライバー側のSQL Serverバージョンは仕様上、互換性の制約があり3世代以上のバージョンの隔たりは許容されていません。このため最新のSQL Server2019にバージョンアップするDBとSQL Server2016を採用せざるを得ないDBが混在することになりました。このバージョン互換性問題は後述するシステム切替方式にも影響することになります。 OS/ミドルウェアについては特段問題なかったので最新バージョンを選択しました。 切替計画 切替計画を立てる際、一番に考えたのはZOZOTOWNサービスの無停止での切替ができないかでした。現行DBサーバの横に新DBサーバ群を構築しテストし、切替当日クライアント側で向き先を一気に切り替える方式はほぼ決定していましたが、以下の理由で無停止での切替は断念しました。 新旧サーバ間のデータ同期にDBリストアを行う必要があるが、その後レプリケーションを再作成する必要があり、一時的にテーブルのDROP/CREATEが走ること クライアント側が点で切り変わらない限り一部のクライアントは新DB、一部のクライアントは旧DBを更新することになりデータの不整合が発生すること そこで以下の全体方針を立てました。 切り替え当日は、レプリケーションの張り直し作業が必要となるため、数時間のZOZOTOWNのサービス停止が必要となる 1日のサービス停止時間の削減および切り戻しの容易さを考慮し、切り替えは2段階で行う(以降、フェーズ1、フェーズ2と呼ぶ) フェーズ1およびフェーズ2の間の過渡期ではSQL Server2019(FrontDB/BackDB)とSQL Server2012(ReportDB/BatchDB)が両立しサービスすることになる SQL Server2019と2012はレプリケーションの互換性がないため、互換性を保つため一時的に2016のSQL Serverを中継用に挟むことにより対応する ZOZOTOWNを停止することは避けられないとしても、停止時間が9時間になるのと18時間になるのとではZOZOTOWNユーザーへの影響度が変わってきます。また一度に変更する範囲を狭めることで問題が発生した際の切り分けや対応範囲が限定され解決しやすくなるという理由から、FrontDB/BackDBの切替とReporDB/BatchDBの切替を別日になるよう日程を2つに分けて切替を行うことにしました。 本番環境の状態遷移を図にしてみます。上段が現行DB、下段が新DBとなります。矢印はレプリケーションの線になります。 FrontDB、BackDBだけ切り替えた状態がフェーズ1完了時となります。この時、SQL Serverのバージョン互換性の問題で新FrontDB/新BackDBと旧ReportDB/旧BatchDBとの間で直接レプリケーションを張ることができません。このため新ReportDB上に中間となるバージョンを持った中継用SQL Serverインスタンスを暫定的に挟むことでバージョン互換性の問題をクリアしました。 フェーズ2完了時点では中間インスタンスを削除し、新DBのみで稼働することになります。 レプリケーションの互換性の詳細はSQL Serverマニュアル( レプリケーションの下位互換性 ) をご参照ください。 フェーズ1完了時は、「SQL Server 2019」→「SQL Server 2016」→「SQL Server 2012」という構成でデータ連携することにしました。 SQL Server2012と2019のクエリ互換性 バージョンアップにおける既存クエリの互換性のチェックについてはMS社が提供する「Data Migration Assistant(DMA)」を利用しました。このサービスの詳細は弊社テックブログ( Data Migration Assistant )を参照頂くとして、簡単に言うとクエリを渡すと異なるSQL Serverのバージョン間で互換性があるか、つまり動くかをチェックしてくれるものです。 本案件では、下記の両方についてDMAを利用してチェックをかけました。 ストアドプロシージャなどの静的クエリ アプリケーション側で動的に発行されるアドホックなクエリ 影響のあるクエリは以下の3つのタイプという結果になりました。 内容 説明 Unqualified Join(s) detected JOIN句の書き方が正しくない LEFT OUTER JOINなどのようにJOIN句を省略せずに書く 悪い例) select * from table1, table2 where table1.col1 = table2.col1 SET ROWCOUNT used in the context of DML statements such as INSERT,UPDATE,or DELETE INSERT,UPDATE,DELETE文においてROWCOUNTで行を絞る機能が無効となる。代わりにTOP XXXを使う必要がある 悪い例) SET ROWCOUNT 100 UPDATE~ ORDER BY specifies integer ordinal ORDER BY句に指定するカラムを列番号で指定しない。代わりにカラム名で指定する 悪い例) order by 1 こちらについては、クライアント処理を担当する各チーム(以下、業務チームと呼ぶ)に確認/修正を依頼しました。 基幹DBの理解 私がこの案件のPMを任された時、基幹DB群に対する理解度は半人前(勉強中)な状態でした。それまではクラウドベースの商品系APIのインフラ(とデータベース)の運用/保守担当だったので基幹DB群については知見があまりない中で、いかにリプレースを成功させるかをずっと考えていました。 DBに限らず1つのシステムの運用を理解するには、「いつ」「だれが」「どこで」「なんのために」「どのように」「何する」といういわゆる5W1Hを全てのオブジェクトに対して整理することだと考えています。データベースに関していえば上記を全テーブルに対して整理することです。 それが理想ではあるものの1つのDBで1000テーブル近くのテーブルを所有する基幹DBにおいて全てを整理するには時間が足りません。まずはデータベース視点で、基幹DBへ接続して来ているクライアント達についてDB単位で整理することから始めました。 基幹DBを用途別に整理すると、以下の6種類のデータベースに分かれます。 DB名 用途 FrontDB セール情報 ショップニュース 会員情報 クーポン情報 ファッションまとめ 注文情報 メンバー情報 ポイント履歴 在庫情報 BackDB 商品系情報 ショップ、ブランド系情報 拠点情報 メルマガ ReportDB 分析系情報 BatchDB 人気順情報 検索系 コーディネイト系 ReadonlyDB 商品詳細情報 DmsDB データ連携中継用 これらに対してアクセスしているサーバやユーザをSQL ServerのDMVから情報を取得しました。 --クエリ1 select host_name,login_name, count (*) AS [num of sessions] from dm_exec_requests_dump_per_several_seconds with (nolock) group by host_name,login_name order by host_name,login_name 上記の「dm_exec_requests_dump_per_several_seconds」というテーブルは我々が後からワークロードを都合よく確認できるよう定期的に(5秒間隔)DMV情報をサマリしたものを貯めておくワークテーブルです。 このクエリを実行すると、10日分のクライアント別/ユーザ別のアクセス状況、おおよそのセッション数が分かります。 この結果を元に、下記項目を一覧化し整理していくことから基幹DBの使われ方や運用を理解していきました。 アクセス元のユーザ名毎のクライアント処理 アクセス元サーバ群を機能別にカテゴリ化 アクセス元の担当部署 設計・構築 設計については冒頭で書いた通り、本案件は単純移行を方針としているため原則、現行踏襲で設計する、HWのスペックアップなど差分に関わる箇所においては設計変更するという方針ですすめました。構築においても同様です。こちらは外部ベンダー主導で作業して頂き、弊社でレビューする形式にしてすすめました。 テスト 本案件で実施した各種テストとテスト観点について以下に整理します。 テスト名 観点 アプリケーションテスト ・SQL Serverバージョンの違いによるクエリの正常性確認 ・DB接続切替箇所の特定・手順・網羅性の確認 性能テスト ・SQL Serverバージョンアップに伴うオプティマイザのバージョンアップにより現行よりも劣化した実行プランが生成されないこと ・クエリ処理時間が現行と同等、または改善すること ・リソース使用量に異常性がないか 障害テスト ・基幹DBを構成する一連のシステム内で障害が発生した場合にきちんとリカバリができサービス再開ができること ・期待したアラートが発報され障害時に運用担当者が気づける仕組みができていること 運用テスト ・各種運用手順書の妥当性 品質管理テスト ・各種URLを発行し想定通りの結果となること(ノンリグレッションテスト) アプリケーションテスト アプリケーションテストは、言うまでもなくバージョン差異に影響することなくクエリが正常に動作することです。クライアント処理をテストするための環境として動作させるため、本番サーバからコピーしたほぼ全てのを各種テスト用クライアントをVMゲストとして起動させました。 もう1つDBへの接続箇所の洗い出しという観点がありました。ここではテストの段階で間違えて本番DBにアクセスした場合のリスクと切替漏れに気付ける仕組みが必要です。テスト用クライアントは現行DB群にアクセスできないようWindowsFirewallの機能で本番DB宛てのアウトバウンド通信を全て拒否するような設定を入れました。 性能テスト 性能テストについては、あるべき論と限られた環境の中でどこまでやるのかという点において非常に悩みました。リプレースする基幹DBに求められる最大のミッションは、 ZOZOTOWNで最も負荷のかかる大規模セール時のリクエストを捌けること となります。 さらにブレークダウンしていくと、性能テストとして担保したいことは概ね以下となります。 項番 指標 目標値 (1) 最大秒間バッチリクエスト数を捌けること XXXXXバッチ/sec (2) 同時実行性が担保できること ワーカースレッド数XXXX程度 (3) 応答時間が現行と同等もしくは向上すること 95パーセンタイルでX ms以下 (4) HWリソース不足が発生しないこと CPU使用率XX%以下 さて、上記をテストするには各DBサーバに対するクエリの種類やリクエスト数を本番サービス並みに発生させる必要がありますが、テスト用クライアントの台数が圧倒的に少ないため断念しました。 そこで性能テストとして実施可能な範囲で方針として以下に決定しました。 項番 方針 (1) DB単体での性能を計測する (2) 本番環境のクエリを一定のルールで抽出し性能を測定する (3) HWリソースの消費量が異常値とならないことを確認する (1)については、クライアントの性能に依存するURL経由での性能ではなくDBサーバ単体の性能を測定するということです。(2)については”問題となりうるクエリ”を抽出しその性能を測定することとしました。現状の環境とスケジュール感、工数を鑑みるとできることは少なく最低限の確認とならざるを得ないという感想です。 対象クエリの抽出方針 テストで使用するクエリは以下の条件で選定、抽出しました。 項番 方針 (1) OLTP/バッチの2パターン (2) 対象コマンドは、select/insert/update/delete (3) bulk insert(bcp処理) (4) リンクサーバ経由のクエリも含ませる(分散トランザクションの性能確認) (3)、(4)については基幹DBのワークロード特性から必要なものとなります。(1)のOLTP/バッチの判断基準は以下としました。 OLTP : 実行回数が1時間に1000回以上 バッチ : 実行回数が1時間に360回以下※最短実行間隔が5秒として5秒×12回×60分=360回 かつ負荷が高いものの抽出基準として以下を設定しました。 処理 抽出基準 OLTP 実行回数の多いもの(execution countが多いもの) 1時間のトータルの経過時間が多いもの(total elapsed time、total worker time) workspacememoryが大きいもの バッチ 処理時間、CPU時間が多いもの workspacememoryが大きいもの 負荷ツールの選定 負荷をかけるツールはMS社の「OStress」を使用しました。 設定がシンプルであること、並列度、回数を指定するのに細かい設定が不要なことから選定しました。対抗馬はJMeterでしたがこちらは設定が複雑であるとの理由から却下しました。 ostress.exeコマンド例 ostress.exe -Sホスト名 -Uユーザ名 -Pパスワード -dDB名 -q -M6200 -r8000 -n100 -oD:\work\seino\2-1-1 -iD:\work\seino\2-1-1.sql 気を付けたのは、目標の負荷を掛けるためにオプションn(実行時のコネクション数)、r(1コネクションあたりの実行回数)を調整しつつ実行する必要があるということでした。 また複数のコネクションを並列で実行する場合、1つのクエリファイルが同時に実行されるためinsert文でキー重複が発生するのを避ける必要があります。このような場合はキー項目の値にsessionidと紐づいた値を設定するようにクエリを書き換えることで重複を排除しました。これは複数セッションから同一行へアクセスするのと別々の行へアクセスするのとでは性能が大きく変わってきてしまうことからも大事なことです。 サンプルとしては以下のようなクエリとなります。 declare @変数A int = @@SPID insert into テーブルA(キーカラムA,カラムB)values(@変数A,0); insert into テーブルA(キーカラムA,カラムB)values(@変数A + 1,0); insert into テーブルA(キーカラムA,カラムB)values(@変数A + 2,0); ・ ・ update テーブルA set カラムB = 0 where キーカラムA = @変数A; update テーブルA set カラムB = 0 where キーカラムA = @変数A + 1; update テーブルA set カラムB = 0 where キーカラムA = @変数A + 2; ・ ・ ・ あと細かいですが、OStressの仕様でオプションと値の間にスペースを入れてはいけません。 性能テスト結果 それぞれの観点と結果をまとめると以下の通りで、結論としては問題なしでした。ただしクエリパターンの全網羅はできていないため本番サービス後に非効率な実行プランが生成された結果、応答時間の長くなるクエリが出てくる懸念は残りますが、それらは個別でチューニングしていく判断をしました。 観点 結果 SQL Serverバージョンアップに伴うオプティマイザのバージョンアップにより、現行よりも劣化した実行プランが生成されないこと 現行と異なるプランが作成されたものがあったが、劣化したプランとは言えないため問題なしとする。 クエリ処理時間が現行と同等、または改善すること 1項目だけ想定よりも遅くなる事象が発生したが現行側のサンプルがパラメータの値により速度の幅があり、今回のテストではその範囲内で収まっているため問題なし HWリソースの使用量が現行と同等、または改善すること CPU、メモリ、ストレージについて余裕あり(無風なので当然だが異常値がなければ良いという判断) 障害テスト 障害テストは、可用性が担保されているかだけでなく監視を通じて期待したアラートが発報されるかを確認するテストです。こちらは今回のリプレース対象サーバだけでなくWebサーバの挙動も影響してきますので、Webサーバに対しテストURLを常時発行している状態で障害を発生させる形で実施しました。確認ポイントとしては、サービス断の有無とサービス断が発生した場合の復旧時間の計測、監視設定の妥当性となります。 なお設計としては多重障害は救済対象外とし、あくまでSingle Point Of Failure(SPOF)を排除するのが方針となります。障害パターンとしてはHWの物理故障からミドルウェア障害までの範囲を対象としました。 カテゴリ 障害パターン HW障害 サーバ機(CPU) サーバ機(メモリ) サーバ機(内蔵ディスク) サーバ機(NIC) サーバ機(電源) ストレージ(コントローラ部分障害) ストレージ(コントローラノード障害) ストレージ(ディスク) SANスイッチ OS障害 OS停止 リソース不足 DB障害 アクセス不可 クエリ遅延 レプリケーション遅延 データ損失 NW障害 スイッチ(筐体) スイッチ(LAG) スイッチ(ポート) スイッチ(電源) DRサイト障害 ログ配布失敗 サーバ機(CPU) サーバ機(メモリ) サーバ機(内蔵ディスク) サーバ機(NIC) サーバ機(電源) ミドルウェア障害 WSFCプロセス障害 VSRプロセス障害 ALogプロセス障害 SCOMプロセス障害 AD障害 認証不可 運用テスト 運用テストは、システム運用中に発生しうる運用作業を手順化し弊社エンジニアが実施できる状態となっているか確認することを目的としてます。このため弊社エンジニアがテストを実施し、手順書をブラッシュアップしていく作業となります。範囲としては従来ベンダーに依頼していた作業を弊社エンジニアが実施できるようになるというリプレースの大方針の1つ「ベンダー依存の体制から脱却する」を実現するためのアクションになります。 品質管理テスト これはブラックボックスなノンリグレッションテストであり、ZOZOTOWNの品質を一定のレベルで保つために行うものとなります。データベースから見るとクエリバリエーションの網羅性が高いので、 ”全く返ってこないクエリ” を発見するのに非常に有用なものとなりました。 切替リハーサル リハーサルは2フェーズでの切替方式に合わせ、フェーズ1、フェーズ2でそれぞれ行いました。 リハーサルの目的としては以下を策定しました。 目的 詳細 手順検証 計画・準備した移行手順が実用に耐えるか 時間計測 時間内に移行処理、手順を完了できるか 作業の慣らし 事前演習することでの作業効率化 当たり前ですが、リハーサルでやったことをブラッシュアップしたものが本番切替になるのでこのタイミングで本番切替の詳細を詰め計画しなければなりません。本番切替については以下を決定しました。 項番 方針 (1) フェーズ1とフェーズ2の二段階で移行作業を行う※前述 (2) 移行作業中はZOZOTOWNのサービスを停止する(フェーズ1/フェーズ2それぞれ9時間を想定) (3) 移行当日の作業をできる限り最小化するため事前に移行可能なものは移行前日までに実施しておく (4) コンテンジェンシープランを準備する (5) 本番移行を実施する人がリハーサルを実施する (5)についてはリハーサルの方針として追加しました。上で書いた「作業の慣らし」が必要なためです。 切替方針として死守しなければならなかったのは、(2)の移行当日のZOZOTOWNの停止時間を守るということでした。これは売り上げに直結するというのはもちろんの事、切替方式の工夫次第で長くも短くもできるものという理由からです。 データ移行の方式は、まず移行当日までの作業SQL Serverの機能で現行DB→新DBへの完全リストアを行います。その後発生した更新は定期的なログ適用にて随時追いつきをかけていきます。そして移行当日はDBアクセスを完全に停止することで静止点を作り、最終ログ適用から静止点までの更新分のみを適用することで完全に同期させる方式で行いました。この作業後に、トランザクションレプリケーションの再同期およびインデックス作成という時間のかかる作業が必要になります。 作業手順のステップとしては487ステップ以上、関連チーム10チーム以上といった膨大な作業内容を9時間以内で収めるといったミッションとなりました。そして初回のリハーサルの結果は、”全然ダメ”でした。 切替リハーサル結果 ”全然ダメ”だった主な理由は、作業時間が完全にオーバーしてしまったためです。机上計算および検証により見積もっていたものの、リハーサルを実施してみると時間が全然足りませんでした。 対策案としては3つ。 案 内容 案1 ZOZOサイト停止時間を延ばす →例えばサイト停止9時間を12時間に 案2 切り替えフェーズをDB毎に複数回に分ける →2段階切替を4段階切替に 案3 今の手順を工夫して効率化、当初どおり9時間 案1/案2は共に売上げへの悪影響が避けられません。案3しか選択肢はありませんでした。各担当者に時間短縮のための仕組み改善を依頼し、並行作業できるところは極限まで並列化し、スクリプト化や半自動化で作業効率を上げるなど時間短縮をとにかく優先するよう作業内容を改善しました。 極限までの並列化により切替全体のタスクが複雑化してしまったものの、各作業チームの努力の甲斐あって再リハーサルでは予定時間に切替を終えることが可能だと分かりました。これはリハーサルの成果です。なおフェーズ2のリハーサルはフェーズ1のフィードバックを取り込むことで問題なく完了しました。 本番切替 さて本番リリースではサイト告知等、リハーサルではやれなかったタスクが入ることになります。作業時間帯もリハーサルは日中でしたが、本番切替は夜間のアクセスが少ない時間帯で行いました。 この辺の(一見影響なさそうな)違いに足元を救われて失敗するということを過去に何度も見てきているのでリハーサルやそれまでのテストで実施できなかった箇所が不安材料でした。結果的には、フェーズ1は予定時間より15分程度の遅れ、フェーズ2は予定時間内に完了しました。 トラブルについては、切替作業中に発生したものとサービス再開した後に発生したものを合わせると39件発生しました。内容的には一部のクエリの処理遅延、リハーサルでテストできなかったいう”出るべくして出た”トラブルと、リハーサルでの確認漏れやテストすべきだったのにできなかったものも多数ありこれは反省すべきことでした。 終わりに 現在、ZOZOTOWNはリプレースした基幹DB群で安定したサービスを提供しております。しかし本番リリース後、安定稼働するまでにはおよそ2~3ヵ月の日数がかかりました。リリース後発生したトラブル群とそれへの対処内容や新旧DB間での性能比較については、ここでは長くなってしまうのでまた別の機会にお話することとします。 何はともあれ、この案件に関わってくれた多くのチーム/担当者、そして機器選定からリリース、運用まで携わってくれたベンダーの方達の助けによってこの大型案件を終えることができたことに感謝します。 ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
はじめに こんにちは。ZOZO DevRelブロックの @wiroha です。3/23にJavaのオンラインイベント「 ZOZO Tech Meetup〜Java活用事例紹介〜 」を開催しました。ZOZOの開発において「Java」にフォーカスした技術選定や設計手法、設計時の考え方などを紹介するイベントです。 登壇内容まとめ 弊社から次の4名が登壇しました。 ZOZOTOWNの商品の閲覧を支えるJava (技術本部 ECプラットフォーム部 / 藤本 拓也) ZOZOTOWNのカート決済システムのリプレイス〜歩みとこれから〜 (技術本部 カート決済部 / 高橋 和太郎) Spring Boot+Redis Cache 〜検索APIにキャッシュを導入、実装時の工夫や効果〜 (技術本部 検索基盤部 / 佐藤 由弥) ZOZOTOWNの裏側に迫る!Javaで作られたBFFの開発事例を紹介 (ZOZOTOWN開発本部 ZOZOTOWNWEB部 / 小川 雄太郎) 当日の発表はYouTubeのアーカイブで視聴可能です。 www.youtube.com ZOZOTOWNの商品の閲覧を支えるJava 藤本よりこれまでと今後の商品基盤を紹介 speakerdeck.com YouTube 2:51〜 ZOZOではたくさんのリプレイスが進んでおり、Javaで動いているシステムが多数あります。VBScriptからのマイクロサービス化はダイナミックな変化です。課題を随時Issueにしていき週に1回解消する会を行っているそうです。積んだままにしない工夫をきちんと続けるのは当たり前のようで大変なことだと思います。 今後構築していく商品基盤では複数の機能を持っていたAPIを責務ごとに分割していくとのことで、メンテナンスが容易になりそうですね。ArchUnitというライブラリの紹介があり、依存関係をチェックできるのが便利そうでした。今後どうなったかもまたお伝えしたいとのことで楽しみです。 ZOZOTOWNのカート決済システムのリプレイス〜歩みとこれから〜 高橋よりカートという高負荷になりやすいシステムの話 speakerdeck.com YouTube 19:27〜 カートはセールや販売開始時といった高負荷イベントへの対応が必要です。Cart Queuing Systemを入れることでキャパシティコントロールができるようになりました。現在はAPIからSQL Serverのストアドプロシージャを呼び出すための技術選定をしています。4つの検証対象ライブラリで平均レイテンシやコード量、DB接続回数を比較していました。 事業案件を進めるチームでは業務でJavaに触れる機会が少ないため、外部講師によるJava講習会をしたり、チーム内での勉強会も週に2回程度実施したりと、学習が活発であるのはとても良いと思いました。 Spring Boot+Redis Cache 〜検索APIにキャッシュを導入、実装時の工夫や効果〜 佐藤よりキャッシュ導入の効果を紹介 speakerdeck.com YouTube 36:54〜 検索システムは検索と聞いて思い浮かべる検索フォームだけではなくランキングやサジェスト、類似画像検索や商品の並び順なども担っているそうです。Amazon ElastiCache for Redisを追加することでElasticsearchを経由しなくなり高速に検索結果を返却できるようになりました。さまざまな工夫も含めると90%以上のレイテンシが改善されたそうです。 gzip圧縮でキャッシュデータを圧縮する工夫をしたところメモリ使用率が1/3に、ネットワーク通信量が1/7に減少したのは大きな効果ですね。実践した効果を具体的な数字で知れるのは参考にしやすくて助かります。実装時の工夫の詳細として紹介していたブログ記事はこちらですので合わせてご覧いただくとさらに理解が進みます。 techblog.zozo.com ZOZOTOWNの裏側に迫る!Javaで作られたBFFの開発事例を紹介 小川よりリプレイスの説明 speakerdeck.com YouTube 55:50〜 私は以前までAndroidエンジニアだったのでBFF(Backends For Frontends)の話は興味深く聞いてしまいました。BFFはクライアントとバックエンドの中間に位置し、UIやユースケースに合わせてデータの加工をおこなう中間層のことです。 リプレイスについて3段階で説明され、徐々に保守性や開発効率が上がっているのがわかります。BFFの導入によりクライアント側の実装が簡素化され、通信量も削減、マイクロサービス側の変更に影響を受けずにBFF側で修正を完結でき開発効率の向上も見込めるそうです。モバイルアプリはリリースするのに審査が必要であったり過去のバージョンが残ったりとコントロールしづらい面があるので、BFFで吸収してもらえるのはありがたいと感じました。 カジュアル面談もできるそうなので、お話してみたい方はぜひこちらからご応募ください! hrmos.co 最後に 質疑応答の時間にはメリットだけでなくデメリットはあるのかなど、さらに深く知ることができました。それぞれ書き切れなかった内容がたくさんあるので、ぜひ資料やYouTubeのアーカイブをご覧ください! ZOZOではJavaを活用し、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co
アバター
こんにちは。WEAR Webフロントエンドチームの冨川 ( @ssssota ) です。 私たちのチームでは普段 WEAR のWebフロントエンド全般の開発から運用までを行なっています。また、あと半年ほどで10年になるVBScript+jQuery環境からNext.js/React環境へのリプレイスを進めています。 リプレイスの詳細は弊チームの藤井が書いた記事をご覧ください。 techblog.zozo.com 本記事では、WEARのWebリプレイス環境における自動テストの構成について紹介します。自動テストの構成を悩んでいる方の決断の一助になれば幸いです。 はじめに 前提 構成の決定と判断 QAチームによるE2Eテスト Playwrightによるビジュアルリグレッションテスト Vitestによる小さなテスト その他検討したテスト おわりに はじめに 先に結論を述べますが、WEARのWebフロントエンドリプレイス環境における自動テストは以下の構成としました。 「ビジュアルリグレッションテストを主軸とし、小さなテスト 1 を適宜拡充する」 自動テストは、いかに開発速度を向上させられるかに着目して考えました。 前提 この決定をするにあたり判断材料となったいくつかの前提条件を述べます。 割ける工数 現在のWEAR Webフロントエンドチームは4名で機能開発やリプレイス、運用改善などが行われています。リプレイスに注力できるのは、時によって変動はあるもののおよそ2名前後です。いくらでも時間をかけられるわけではないのでスピード感も鍵となります。 QAチームによるテスト 本記事では、自動テストの構成について紹介していますが、その構成の根底にはQAチームのエンドツーエンドな自動化されたシナリオテストと手動による探索的テストがあります。最低限の品質はここで担保されるということ、また、テストの重複などによるロスを減らすことも鍵になります。 インタラクションの量 WEARのWeb版は、コーディネートの投稿機能などは持っていません。ページにもよりますが、テストするべきインタラクションはかなり限定されています。 構成の決定と判断 今回は、以上のような条件から「規模(カバー領域)」と「コスト」にフォーカスし構成を決定しました。 要素を簡易的な図にしたものがこちらです。 それぞれの要素について解説します。 QAチームによるE2Eテスト 前提の紹介でも述べていますが、WEARにはQAチームが存在し、機能リリース時などに開発チームと連携しながらテストを行なっています。 機能リリース時とはいえ探索的なテストも行われるため、高い確度で品質が保証されます。QAチームの存在により「リグレッションをいかに防ぎ、 開発速度を上げられるか 」という観点で構成を考えるに至りました。 Playwrightによるビジュアルリグレッションテスト 低いコストで広い範囲をカバーできるという点からPlaywrightを用いたページ単位のビジュアルリグレッションテストを採用しました。 QAチームによるテストが行われるタイミング以外でも細かなリリースは行われるためにデザイン崩れが発生していないことを保証するために行われます。前提でも述べた通り、WEARのWeb版はユーザーによるインタラクションが限定されているためにページのスクリーンショットに差分がなければ挙動としてもある程度の保証がされるという判断をしています。 Playwrightによるビジュアルリグレッションテストのコードの一例を示します。 import { test , expect } from '@playwright/test' ; test ( 'Visual Regression Test sample' , async ( { page } ) => { await page. goto( '/some/target/page' ); await expect ( page ) .toHaveScreenshot ( { fullPage: true } ); } ); Playwrightの設定を済ませた上で上記の様なコードだけで1つのページのスクリーンショットが撮影、差分チェックできます。 Playwrightのスクリーンショット撮影時は自動的にアニメーションも無効になる(opt-inで有効にできます)ので不安定さを回避できますし、必要に応じてボタンクリックなども利用できます。 実際の運用としては、このテストではAPIはMSWを用いたモックデータが利用されネットワークの不安定さを極力減らしつつ行っています。また、Pull Requestではデフォルトでこのテストは実行されず、特定のラベルを付与するか、マージしたタイミングで実行されるようにしています。 Vitestによる小さなテスト 簡単なロジック、UIなどの小さなテストにはVitestを採用しています。 こちらは、Playwrightとは対照的にすべてのPull Requestで実行されます。 JestではなくVitestが採用されているのは、多くの場合で設定量が少なく済む点、トランスパイルが高速な点です。Jestでもtransformerを設定することでトランスパイルの速度を改善できますが、設定にさまざまな検討の余地が生まれてしまいます。 Pull Requestで実行されるテストは静的解析の他には、この小さなテストが主になります。ビジュアルリグレッションテストがPull Requestでは実行されないこともあり、Pull RequestでのCIの実行時間は平均で2分に収まっています。 その他検討したテスト 直近では採用しなかったものの利用していた、もしくは利用を検討したテストを紹介します。 Cypress 以前はComponent Testを利用していたがCypressの書き方が独特な点、他テストと比較してFlakyさをハンドルしづらい点など開発コストが高いためフェードアウト Playwright Component Testing Cypress Component Testの移行先として検証していたがExperimentalということもありここにコストを掛けるのは時期尚早と判断 Chromatic (Visual Regression Test) 導入はしているものの毎PRテストするかなど運用に課題 StorybookのStoryをテストでき開発コストは低く恩恵を受けやすいため、今後、運用体制を整える予定 おわりに WEARのWebリプレイス環境における自動テストの構成を紹介しました。開発速度の向上を主目的に「ビジュアルリグレッションテストと小さなテスト」を利用する構成です。 自動テストは一朝一夕で設計・構築できるものではありません。今回紹介した様に、 プロダクトの性質 、 開発体制 、 開発フェーズ 、 自動テストの目的および、その時点でのゴール などを明確にした上で中・長期的に向き合うことが大事だと思います。 WEARではこのようなサービスの品質改善に興味があり、一緒に作り上げて行ける方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 小さなテストという名称を用いているのは、ユニットテストという単語の曖昧性を避けるためです。和田卓人氏が執筆された記事「 テストサイズ ~自動テストとCIにフィットする明確なテスト分類基準~ 」よりSmallテストを本記事では小さなテストとしています。 ↩
アバター
はじめに こんにちは、計測プラットフォーム開発本部アプリ部の中岡、永井、東原です。私たちのチームではZOZOMAT、ZOZOGLASSといった既存の計測機能の改善と、新規計測アプリの研究開発を担当しています。 その新規計測アプリとして、ZOZOFITというボディーマネジメントサービスを2022年の夏に米国でローンチしました。この記事では、ZOZOFITのiOSアプリを新規開発するにあたって、どのような技術要素を取り入れたかについてご紹介します。 目次 はじめに 目次 ZOZOFITとは 計測機能とその実装・統合 計測機能について 計測機能の実装・統合について iOSアプリの技術要素 使用技術 対応OS UIフレームワーク CI/CD パッケージ管理 その他ツール アーキテクチャ プロジェクト構成 今後の課題 おわりに ZOZOFITとは ZOZOFITは、ZOZOグループのZOZO Apparel USA, Inc.が提供するボディーマネジメントサービスであり米国でサービス提供をしています。 初代ZOZOSUITよりも大幅に計測精度を向上させたZOZOSUITを着用し、専用のスマートフォンアプリを利用することで手軽に3Dボディースキャンを行い、計測データをトラッキングできます。 計測可能な箇所は、肩幅、胸囲、腕周囲、ウエスト周囲、ヒップ周囲、太もも周囲、ふくらはぎ周囲の7箇所であり、体脂肪率も測定されます。計測データは下の画像のようにアプリ上で確認でき、過去のデータとの比較やグラフ表示が可能です。また、目標の管理機能によりそれぞれの計測部位について目標を設定でき、達成状況を確認できます。 図1:ZOZOFIT iOSアプリ 開発についてはグループ会社である ZOZO New Zealand と協業して進めています。プロジェクト全体の話は下記の記事がありますので、ぜひご覧ください。 technote.zozo.com 計測機能とその実装・統合 計測プラットフォーム開発本部アプリ部の永井です。ここでは、ZOZOFITの主要機能である計測機能の詳細と、その計測機能がiOSアプリへどのように実装・統合されているかについて説明します。 計測機能について まずは、計測機能がどのようなものかをユーザー視点で説明していきます。 計測は、スーツを着用したユーザーの全身をスマートフォンの背面カメラで360度撮影することによって行われます。以下の図は、アプリ内のチュートリアルで使われているものです。 図2:計測方法 このように計測時、ユーザーはスマートフォンをスタンドに立てかけ、そこから2mほど離れた場所に立つ必要があります。そして、その場で体を0時から12時まで回転させながら、合計12枚の写真を撮影します。 計測の最中、ユーザーには背面カメラが向けられていて画面を見ることができないため、計測を進めるための案内はすべて音声により行われます。 そうして案内に沿って計測を完了させると、アプリ上で全身の3Dモデルと計測データを見ることできます。以下の図は実際のアプリ画面です。 図3:計測結果 このように自身の身体の気になる部位について、いろいろな角度から3Dモデルを見たり、計測データの変化をグラフで追ったりできます。 計測機能の実装・統合について 計測機能の実装・統合の説明にあたって、先に計測機能のアルゴリズムを紹介します。詳細は伏せますが、簡略化すると以下のようになります。 ZOZOSUITを着用したユーザーを360度、12枚の写真として撮影 ユーザーとスマートフォンとの距離やユーザーの身体の回転具合などに問題がないかをチェックする 撮影した写真を画像処理 スーツ全体に施されたドットマーカーのパターン認識 ユーザーの身体のシルエット検出 画像処理の結果から3Dモデルを生成 生成された3Dモデルから各部位の計測データが得られる このアルゴリズムは ZOZO New Zealand が開発したC++ライブラリによって提供されており、その中で、OpenCVやMediaPipeのような画像処理・機械学習のライブラリが使われています。MediaPipeはソースコードが公開されており、利用したい機能をZOZOFIT向けにカスタマイズできることから採用に至りました。 また、計測結果の3Dモデル描画は、WebGL(Three.js)で実装されたものをWeb Viewで表示する仕組みとなっており、iOSアプリでは WKWebView が使われています。 計測機能がこのような実装となっている大きな理由は、クロスプラットフォームのためです。ZOZOFITはAndroid・iOSの2つのプラットフォームでアプリを展開しており、主要機能である計測機能については両プラットフォームで共通のものを提供することが重要でした。そのためネイティブとは切り離された、両プラットフォームに対応する技術を用いて実装されています。 ZOZOFITに限らず、これまでのZOZOMATやZOZOGLASSといった計測プロダクトでもクロスプラットフォームは大きな関心事でした。これまでの計測プロダクトについては下記の記事がありますので、興味があればぜひご覧ください。 techblog.zozo.com techblog.zozo.com さて、以下の図は、計測機能がどのように統合されているかを示したものです。 図4:計測機能の統合 iOSアプリのリポジトリ内で、サブモジュールとして計測ライブラリと3D Model Viewerのリポジトリを参照しています。計測ライブラリについては、CMakeコマンドによりXcodeプロジェクトを生成することで、ワークスペース内で利用できるようにしています。 iOSアプリの技術要素 計測プラットフォーム開発本部アプリ部の 中岡 です。ここではZOZOFIT iOSの技術要素について説明します。 使用技術 2023年3月時点では以下のような技術構成となっています。 開発言語:Swift 5.7 対応OS:iOS 15~ UIフレームワーク:SwiftUI(一部UIKit) CI/CD:Bitrise パッケージ管理:Swift Package Manager ライブラリ: Factory 、 Charts 、 SwiftGen 、 SwiftLint 、 swift-snapshot-testing など。 その他ツール:Figma、TestFlight、Firebase 対応OS 基本的に最新バージョンから1つ前のメジャーバージョンまでをサポートする方針となっています。開発当初はiOS 16がリリースされていなかったのですが、ZOZOFITがリリースされる2022年8月にはiOS 16がリリースされているということもあり開発当初からiOS 15~で開発していました。 また、開発体験に関してはiOS 14をサポートするより向上はしましたが、本アプリ開発においてそこまで大きく変わったという印象はありませんでした。 UIフレームワーク 基本的にSwiftUIをベースに開発していますが、一部実装が困難な部分はUIKitを使用しています。具体的には以下の図のように最前面にローディング画面を表示することがSwiftUIだけでは困難でした。そのためUIKitの UIWindow を使用して最前面に表示しています。 図5:ローディング表示時のView階層 CI/CD CI/CDにはBitriseを使用しており、PRが作られた際に自動でテストを実行するワークフローとApp Store Connectにアップロードするワークフローがあります。また、これらの設定の bitrise.yml は同リポジトリで管理しています。 パッケージ管理 パッケージの管理は全てSwift Package Managerで行なっています。また、SwiftLintやSwiftGenといったツールもプラグイン機能を活用しバージョン管理をしています。 その他ツール デザイン、テスト用アプリの配布はそれぞれFigma、TestFlightを使用しています。また、FirebaseはCrashlytics、Analytics、Dynamic Linksを使用するために導入しています。Analyticsの導入については下記の記事を公開しているので興味がある方はこちらをご覧ください。 techblog.zozo.com アーキテクチャ 基本的に以下のようなView、Config、Managerという構成をとっています。 View いわゆる見た目の部分です。 SwiftUI.View で書かれており、ユーザからのイベントをConfigに渡します。 Config MVVMでいうViewModelのような役割です。 ObservableObject プロトコルに準拠したオブジェクトでViewの状態管理や受け取った値をManagerに渡します。 Manager MVVMでいうModelのような役割です。アプリのビジネスロジック部分で計測アルゴリズムの実行や、計測データの管理などを行なっています。 プロジェクト構成 ZOZOFIT iOSのプロジェクト構成は以下の図のようになっています。コアロジックや共通コンポーネント、カスタムModifier等をパッケージ化しそれらをXcodeプロジェクトから呼び出しています。 図6:ZOZOFIT iOSの依存関係 今後の課題 ローンチからまだ1年足らずということもあり、現状のZOZOFIT iOSにはいくつかの課題が残っています。 まずは、テストが行いづらいという点が挙げられます。現在一部のManagerが @Published プロパティを持っており ObservableObject としてViewから参照されています。Mock化するためにこれらのProtocolを定義したいのですが、SwiftのProtocolでは @Published プロパティを定義できずコンパイルエラーとなってしまいます。そのためこれらのManagerに依存しているViewやConfigのテストが行いづらくなってしまっています。加えて、ZOZOFITはプロダクトの特性上、カメラやセンサデータを使用するため計測機能をデバッグするには実機で動かす必要があり時間と手間がかかるといった課題もあります。これについては、カメラを使わず事前に用意した画像を読み込むようにすることで改善できます。 ZOZO New Zealand の開発チームがSDKを作成する際にそのような機能を持つアプリを用意していたので、その機能をZOZOFITアプリにも取り込めると良いなと思っています。 また、計測機能の統合方法についても改善の余地があります。現在はCMakeによって、C++で書かれた計測ライブラリのXcodeプロジェクトを生成し、ワークスペース内で統合するというアプローチをとっています。しかし、理想的には計測ライブラリをXCFramework化して、統合することがよりシンプルで望ましいと考えています。 効率的に開発をするためにも今後チームで話し合いこれらの課題は解決していきたいです。 おわりに ZOZOFITのiOSアプリについてその全体像をご紹介しました。アプリは昨年リリースされたばかりであり、より多くのユーザーに使っていただくために改善や新機能の追加を行なっています。例えば現在は機能開発に加えて、データをより活用できるようにGoogle Analyticsの測定箇所を見直したり分析レポートの作成に取り組んでいます。ZOZOFITは ZOZO New Zealand との協業のプロジェクトであり関係者が多く言語の壁もあるので簡単なプロジェクトではありませんが、プロジェクト全体の改善も行いながら進めているところです。 これからのグロースを目指し、海外チームと協業しながらSwiftUIを利用してiOSアプリ開発をしていく、ということに少しでも興味のある方は以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに ZOZOTOWN開発本部の武井と申します。ZOZOTOWNのフロントエンドリプレイスプロジェクトを主に担当しております。 ZOZO DEVELOPERS BLOG でも「 ZOZOのリプレイスプロジェクトで得られる唯一無二の経験。大規模サービスを進化させるやりがいとは 」というインタビュー記事を掲載しておりますので、もしよろしければこちらも併せてご覧ください。 さて、本題です。現在ZOZOTOWNではオンプレミスかつ、モノリスだった既存システムをマイクロサービスAPIに責務を分割したり、インフラをクラウドに移行したりしています。しかし、いわゆるWebのUIを構築するためのシステムは現在も既存システムに新機能開発や機能改修を行なっており、リプレイスに着手できていませんでした。 そこで、まずホーム画面から段階的にリプレイスすべく設計・開発を昨年から行ない、無事リリースできました。 ZOZOTOWN のソースファイルを見ると Next.js で提供されていることがフロントエンドエンジニアの方にはお分かりいただけると思います。 本記事では、ホーム画面のリプレイスをどのようなシステム構成で実現したのかの事例と、Next.jsのアプリケーションをプロダクションレディにするナレッジや設定内容の一部などをご紹介します。 目次 はじめに 目次 背景 セッションオフロード カナリアリリース フロントエンドリプレイスPhase1 システム構成 Next.jsのアプリケーションをプロダクションレディにするナレッジ 要件を実現するためのレンダリング選定 Next.jsの性能試験でレンダリングとスループットの関係性を調査 CDNキャッシュを用いた最適化 URLに対して複数のキャッシュを作成する カスタムサーバーでルーティングのカスタマイズとロギングの実現 Sentryでのエラーログ集積とソースマップのアップロード ソースマップアップロードを可能にするDockerイメージ作成 効果と今後の課題 まとめ 背景 ZOZOTOWNのリプレイスは開発効率を上げる、運用コストを下げる、人材獲得の強化を目的として掲げています。その手段としてAPIのマイクロサービス化をしています。これと同時並行でフロントエンドに新フレームワークを導入しリプレイスする計画がありました。 過去の弊社瀬尾の発表資料 では下記の図で示され、数年前から検討はされていました。 歴史が長く、アクセス数の多いサービスを、稼働させたままリプレイスするのは一筋縄ではいきません。さらに、我々は既存のサービスの成長を止めずに、リプレイスする方針で取り組んでいます。こうしたリプレイスを実現するベースを構築する必要がありました。ベースの構築にどのような困難があったのか、セッションオフロードとカナリアリリースを例にあげて紹介します。 セッションオフロード サービスを止めないリプレイスを実現するために、 ストラングラーパターン というレガシーシステムを徐々にモダナイズするためのアーキテクチャパターンを採用しています。具体的にはリプレイスしたパスへのリクエストはモダンシステムに、置き換え前のパスはレガシーシステムにパスルーティングします。最終的には、リプレイス前のシステムへのパスルーティングはなくなり、リプレイス完了となります。 フロントエンドのHTMLの配信においても前述したパスルーティングを用いて、パスごとに段階的なリリースを計画していました。しかし、このパスルーティングを実現できない事情が既存システムにはありました。既存システムはIISのユーザーセッション機能を利用しており、セッションがWebサーバーに紐づいています。つまり、ユーザーはセッションが続いている限り以前接続したサーバーに接続されます。いわゆるスティッキーセッションです。これでは、パスルーティングを機能させることができません。この問題を解消するために、セッション情報をAmazon ElastiCache for Redisにオフロードする取り組みなどが必要でした。セッションをオフロードすることで、サーバーとセッションが分離でき、ストラングラーパターンによる置き換えが可能になりました。 セッションオフロードの詳細は、杉山の記事をご参照ください。 techblog.zozo.com カナリアリリース ZOZOTOWNはUIや機能改修によってビジネス指標に大きく影響が生じるサイトです。これを考慮し、UIや機能要件はリプレイス前と可能な限り互換性を維持し、挙動は変えないという方針を定めています。リリース前に念入りにQAテストを実施しますが、リリースでの不具合の発生や他システムに影響を及ぼすリスクは存在します。このリスクを軽減するために、一部のユーザーだけに絞り新システムを提供し、段階的にリリースするカナリアリリースを実施しています。このカナリアリリースについてはAkamai Application Load Balancerの加重ルーティングという機能を利用して実現しています。 加重ルーティングの詳細は、秋田の記事をご参照ください。 techblog.zozo.com これらの例以外にも、リプレイスサービスを構築するために CI/CD戦略 、 BFF API 、 サービスメッシュ 、 プログレッシブデリバリー などの施策を実施しベースが整ってきました。こうした背景から満を持してフロントエンドのリプレイスが始動しました。 リプレイスは、全く別のものに一気に刷新するのではなく、このようにサービスを構築するためのベースを構築し、それぞれの機能ごとにマイルストーンを設定し段階的に置き換えていくことが有効です。 フロントエンドリプレイスPhase1 リプレイスをどのページから着手するか検討した結果、ホーム画面 1 を選択しました。理由は下記の通りです。 ホーム画面で利用しているAPIは、大部分がBFF(Backend For Frontend)から提供されており、レガシーシステムへの依存が比較的少ない アクセス数や機能が多く、開発や運用のナレッジを蓄積しやすい サービスの象徴的ページであり、開発のモチベーションが湧きやすい さらにリプレイスの付加価値としてSPA(Single Page Application)化することでページ遷移を高速にし、UXの向上を考えました。具体的にはホーム画面では、下記の図のような商品のカテゴリーや性別を切り替えるタブUIがあります。 このタブUIでのページ遷移時にページ全体を読み込まず、商品データだけを動的に切り替えるSPAを実装しました。 次にフロントエンドフレームワークについてです。フレームワークはNext.jsを採用しました。選定理由は下記の通りです。 Reactベースのフレームワーク 2 ゼロコンフィグで開発を始められる ページごとのレンダリング手法を柔軟に切り替えるができる 数々のパフォーマンス最適化など新機能が毎年リリースされており、とてもアクティブに開発されている 利用している開発者が多く 3 、コミュニティーが盛んでWebに情報が多い HeadlessCMSと相性が良い 4 Next.js以外の新たに導入したライブラリを紹介します。 ライブラリ名 説明 Emotion CSSinJSライブラリ SWR データ取得とそれに関連する操作を提供する React Hooksライブラリ MSW APIモッキングライブラリ Recoil 状態管理ライブラリ openapi-typescript-code-generator OpenAPI定義からクライアントコードを生成するライブラリ それぞれの選定意図については記事の本題ではないため紹介のみとします。Emotionの選定意図は、菊地の記事をご覧ください。 techblog.zozo.com これらの新フレームワークや新技術の導入とインフラを構築することで、フロントエンドリプレイスの礎を作るマイルストーンを社内ではフロントエンドリプレイスPhase1と呼んでいます。以降も複数のマイルストーンをおき、2024年を目処にフロントエンドのリプレイスをすべて完了させる計画です。 システム構成 リプレイスに際して構築したシステムは下記のような構成です。 まずCDNを経由してユーザーのブラウザにコンテンツが配信されます。弊社ではCDNにAkamaiを採用しております。このAkamaiでは、(1)キャッシュ、パスルーティングとあるように、コンテンツのキャッシュや、ユーザーのリクエストパスから適切なサービスにルーティングをするパスルーティングなどを行なっています。具体的にはホーム画面のパスをリプレイス後のシステムへ、ホーム画面以外のパスは既存システムにリクエストをルーティングしています。 次にリプレイス後のパブリッククラウドのシステムですが、AWS上に構築しており、コンテナアプリ基盤にマネージドKubernetesサービスであるEKSを採用しています。また、複数サービスを単一Kubernetesクラスタで稼働させる、いわゆるマルチテナントクラスタ方式です。このクラスタにマイクロサービス群と、BFF API、そして今回新設したNext.jsのSSRを実行するサーバーが稼働しています。 最後に(2)データ取得、セッション共有とあるように、リプレイス後のシステムと既存オンプレミスのIISサーバーとセッションデータ共有や、まだリプレイスが完了していないデータストアからデータ取得を可能にしています。これによりあらゆる機能要件を満たすことができます。 なお、実際には認証サービスや、APIルーティングを行うAPI Gatewayなどのサービスとも通信していますが、ここでは省略しております。 以上がシステムの全体像です。 Next.jsのアプリケーションをプロダクションレディにするナレッジ Next.jsの機能はシンプルなため、Reactを使ったプログラミングに習熟していれば、スムーズに開発を進めることができました。Web上に開発に関するナレッジが多く集積されている点が大きな要因と思われます。一方で、Next.jsのアプリケーションのサーバー負荷への考慮、ロギングやエラーハンドリングなどのプロダクションレディにするための情報がWeb上に少ないように感じました。なのでここからはそれらのナレッジについて紹介したいと思います。 要件を実現するためのレンダリング選定 Next.jsのアプリケーションにおいて、SSR(Server Side Rendering)するか否かというのはとても重要な決断です。 アプリケーションの性質や要件によれば、SSRせずSG(Static Generation)やCSR(Client Side Rendering)も可能です。その場合は静的ファイルを配信するのみとなりインフラの管理コストは低くなります。 一方、SSRする場合はNode.jsの実行環境を必要とするため、アプリケーションを監視するエンジニアのオンコール体制の構築、サーバーコスト、パフォーマンス的な懸念等々の管理コストが発生してしまいます。可能であればSSRしたくはありませんが、下記のような機能要件やSEOを考慮してSSRすることは不可避でした。 メタタグにブランド数、商品名、OGP画像などの動的データを含めたい ファーストビューに表示されるUIはローディングなど挟まず表示したい セールやキャンペーンの開始や終了のタイミングに合わせて時限式に切り替わるUIを提供したい 3の要件についてクライアントサイドのJavaScriptで、時限式に切り替わる実装をする選択もあります。しかし、クライアントのJavaScriptはソースファイルが公開されます。そのため将来のキャンペーンやセール情報が露見してしまう可能性があります。したがって、クライアントではなくSSRするという結論になりました。 Next.jsの性能試験でレンダリングと スループットの関係性を調査 GoやJavaのAPIサーバーは運用実績があり、性質や運用についてのノウハウがあります。一方でNode.jsを運用するのは初めての試みでした。加えてNode.jsはシングルスレッドのランタイム環境という特性があります。そのため、CPUバウンドなタスクを実行する場合、サーバー処理をブロックしてしまいパフォーマンス低下の可能性があります。具体的には、SSRの処理がCPUバウンドな処理で知られており 5 、この事象が起きてしまえばインフラコストが高くついてしまうことや、パフォーマンス要件を満たせない懸念があると考えました。本番にリリースしてからパフォーマンス要件が満たせないことになれば問題ですので、Next.jsアプリケーションの性能試験を実施しました。 性能試験は、 Gatling Operator というツールを用いて、本番に近いインフラやサーバーをセットアップし、リクエストを送りその結果をモニタリングして計測します。パフォーマンス要件の基準は Lighthouse の TTFB の基準値 を参考に 600ms 以内とし、この状態で秒間どの程度のリクエストを捌けるかスループットも計測します。SSRするコンポーネントの規模によって、パフォーマンスやスループットの目処をつけておきたかったため3パターン実装しました。スペックのcore数が2core以上なのはNext.jsアプリケーション以外にもサービスメッシュとして istio proxy を実行しているためです。 結果は下記のとおりです。 項目 レスポンスタイム (95percentile) スループット (req/sec)  スペック 高負荷 ネストが深く子要素が多いコンポーネント 364ms 5 req/sec CPU: 3core メモリ: 5GiB 中負荷 ネストの深さ子要素数がホームと同程度のコンポーネント 169ms 30 req/sec CPU: 2core メモリ: 5GiB 低負荷 Next.js の初期設定のまま 61ms 60req/sec CPU: 2core メモリ: 5GiB 高負荷の場合はパフォーマンス基準を安定的に満たし、スループットは 5req/sec と効率が悪い結果となりました。やはり前述した通り、CPUバウンドなSSRになってしまうとインフラのコストパフォーマンスは悪くなりそうです。しかし、中程度の負荷であれば、スループットも性能はまずまずという結果も得られました。この結果から、負荷を考慮したSSR実装をすることに加え、負荷増加が考えられるリリースをする際には負荷試験を行ない事前に検知するなど対策すればスケーラブルに運用できるという判断をしました。以上の性能試験から、SSRという選択肢ありきで安心して開発に着手できました。 CDNキャッシュを用いた最適化 性能試験の結果から、Node.jsをスケーラブルに運用できることが分かりました。しかしながら、性能は常に最適な状態に保つことが望ましいため、CDNでキャッシュを使用することでパフォーマンスを向上し、コスト削減を実現できます。HTMLをSSRした結果を一定期間CDNでキャッシュすることにより、オリジンサーバー上でNode.jsサーバーの負荷を大幅に軽減できます。ただし、パフォーマンス要件を満たすためにキャッシュを有効にできない場合もあります。ホーム画面の要件に関してはキャッシュが可能であるため、キャッシュを有効にしています。具体的には、下記のようにレスポンスヘッダーの Cache-Control を使用して、キャッシュの保持期間を制御できます。 Cache-Control: s-maxage=seconds 検証の際には、時間の文字列をHTMLに埋め込んでおくと、キャッシュできているか検証しやすいので、実装しておくのがおすすめです。Next.jsでは下記のように書けます。 import { GetServerSideProps, InferGetServerSidePropsType } from 'next' export default function Index ( { time } : InferGetServerSidePropsType < typeof getServerSideProps >) { return ( <script type= "application/json" data-type= "cacheTimeDisplay" data-time= { time } /> ) } export const getServerSideProps: GetServerSideProps < { time : string } > = async ( { res , } ) => { const second = '10' res.setHeader( 'Cache-Control' , `s-maxage= ${ second } ` ) return { props : { time : new Date (). toISOString (), } } } このようにしてレスポンスヘッダーをページごとに異なる時間を設定することで、キャッシュを最適化していくことができます。ただし、CDNキャッシュを利用する際には注意が必要です。特に、SSRするHTMLには個人を特定できるようなパーソナルな情報を含めないようにすることが重要です。ユーザーごとに異なるパーソナル情報はAPIから取得し、クライアントサイドでレンダリングするようにする必要があります。パーソナル情報をSSRしてしまうと、CDNキャッシュを利用できなくなってしまうためです。もし誤ってパーソナル情報をキャッシュさせてしまった場合、重大な情報漏えいが起こる可能性もあるため、キャッシュを用いる際には慎重に実装する必要があります。 URLに対して複数のキャッシュを作成する 通常、1つのURLに対して1つのキャッシュが作成されますが、 Cache ID Modification という機能を使うことで、複数のキャッシュを1つのURLに対して作成できます。例えば、ホーム画面にはカルーセルバナーがあり、このバナーは指定なし、レディース、メンズ、キッズの4つのパターンがあります。 これをSSRするには、4つのキャッシュを作成する必要があります。これを実現するために、Cache ID Modification機能を使用しています。Cache IDは、CDNの管理画面で設定でき、Cookieやリクエストヘッダーなどを指定できます。この場合、Cookieに性別を保存し、このCookieをCache IDに設定しました。これにより、4つの異なるキャッシュが作成され、適切なカルーセルバナーがSSRされます。 カスタムサーバーでルーティングのカスタマイズと ロギングの実現 zozo.jpのホーム画面はデスクトップ向けには https://zozo.jp/ 、モバイルでサイト https://zozo.jp/sp/ とURLが異なります。そのため、モバイルデバイスで https://zozo.jp/ にアクセスした場合は、 https://zozo.jp/sp/ にリダイレクトされるような実装が入っています。例えばこのようなルーティングをカスタマイズしたい場合に利用できるのが カスタムサーバー という機能です。この機能を使えばNode.jsサーバーのモジュールとしてNext.jsを利用できます。Node.jsの組み込みモジュールでも実装は可能ですが、Webフレームワークの Fastify を利用しました。理由はパフォーマンスの良さ、TypeScriptとの相性、ロギングのしやすさなどです。 Fastifyを利用する場合Next.jsカスタムサーバーは下記のように書けます。 import Next from 'next' import Fastify from 'fastify' import type { FastifyPluginCallback } from 'fastify' type Option = { isDev : boolean } const isDev = NODE_ENV !== 'production' const app = Fastify( { /** * Dev の際は Next.js のサーバーの起動までのタイムアウトを設定します。 * */ pluginTimeout : isDev ? 120_000 : 0 , } ) export const nextJsCustomServerPlugin: FastifyPluginCallback < Option > = async ( serve , option , done ) => { const app = Next( { dev : option.isDev } ) const handle = app.getRequestHandler() await app.prepare(). catch (( err ) => { serve. log . error ( 'error' , err) done(err) } ) serve.all( '/*' , async ( req , reply ) => { await handle(req.raw, reply.raw) reply.sent = true } ) serve.setNotFoundHandler( async ( req , reply ) => { await app.render404(req.raw, reply.raw) reply.sent = true } ) done() } app. register (nextJsCustomServerPlugin, { isDev } ) app.listen(PORT, HOST, () => { app. log . info ( `started server` ) } ) Fastifyには Hooks というAPIがあり、リクエストからレスポンスまでのライフサイクルイベントをフックにして処理を実行できます。前述したリダイレクトの実装などはリクエストをフックにして下記のように書けます。 app.addHook( 'onRequest' , ( req , reply , done ) => { const isNextAssetsPath = req. url . startsWith ( '/_next/' ) const isSpPath = req. url . startsWith ( `/sp/` ) const isMobileDevice = req. headers [ 'user-agent' ]?.includes( 'Mobile' ) && !req. headers [ 'user-agent' ]?.includes( 'iPad' ) if (isNextAssetsPath || isPublicPath) { done() } else if (isMobileDevice && !isSpPath) { reply.redirect( 302 , path. join ( '/sp' , req. url )) } else if (!isMobileDevice && isSpPath) { reply.redirect( 302 , req. url . slice ( '/sp' . length )) } else { done() } } ) 次にロギングについて紹介します。弊社はサーバーアクセスログを JSON Lines という形式で標準出力しています。JSON形式であれば jq などのツールを用いてデータ加工や集計を簡単に扱うことができるためです。このJSON形式のログの標準出力には pino というライブラリを用いています。pinoはFastifyとの相性は抜群で、使い方はloggerにpinoを指定するだけです。下記はリクエストの latency などの情報をアクセスログに出力する例です。 import Fastify from 'fastify' import pino, { LoggerOptions } from 'pino' // ログ出力も下記のようにオプションを使って柔軟にカスタマイズ可能 const pinoOptions: LoggerOptions = { timestamp : () => `,"timestamp":" ${ new Date ( Date . now ()). toISOString () } "` , formatters : { level ( label ) { const severity = label. toUpperCase () return { severity } } , } , } const app = Fastify( { logger : pino(pinoOptions) } ) app.addHook( 'onResponse' , ( req , reply , done ) => { const latency = reply.getResponseTime() / 1000 req. log . info ( { latency } ) done() } ) 下記のようにJSONが出力されます。 { " severity ":" INFO "," timestamp ": " 2023-03-09T09:27:48.963Z "," latency ":" 0.2675443229675293 " } Sentryでのエラーログ集積とソースマップのアップロード アプリケーションのエラートラッキングツールにはSentryを利用しています。Sentryはnextjs向けのSDKとして @sentry/nextjs を提供しており、このSDKを利用して実装できます。カスタムサーバーを使っているため、カスタムサーバー向けに @sentry/node も併用して利用します。また、どの環境で起きたエラーか特定をしやすくするために、 enviroment 変数に カスタムサーバー 、 Next.jsのサーバーサイド 、 Next.jsのクライアントサイド の3つの環境の値を設定しています。 const dsn = process .env.NEXT_PUBLIC_SENTRY_DSN if (dsn) { SentryNode.init( { dsn , environment : '[CUSTOM SERVER]' // next.js の SSR のエラーの場合は [NEXTJS SERVER] CSR の場合は[NEXTJS BROWSER]と指定する } ) } 次に、ソースマップについてです。ソースマップファイルをSentryにアップロードすることで、Next.jsでビルドしたJavaScriptコードのエラーではなく、ビルド前のソースコードでエラーの該当箇所を示してくれる機能があります。詳しくは Source Maps をご覧ください。この機能を活用しないと、エラーの原因を突き止めるのは困難です。活用するためには、next.jsのコンフィグファイルを編集し、 next build の際にソースマップファイルをアップロードする必要があります。下記が next.config.js 設定例です。 const { withSentryConfig } = require( '@sentry/nextjs' ) const buildConfig = { sentry : { hideSourceMaps : !!isProdOrStg, // 環境によってはソースマップの参照をソースコードに含めない。 widenClientFileUpload : true , // next build の際に Sentry にクライアントのソースマップファイルをアップロードするフラグ } , } const sentryWebpackPluginOptions = { silent : false , dryRun : (isLocal || isGitHubAction) ? true : false , // ソースマップのアップロードの有無のフラグ、環境によっては、アップロードを実行しない。例えば、ローカル環境や、GitHub Action などでは実行しないように設定ができる release : process .env.BUILD_ID ?? undefined , org : '<ORG NAME>' , authToken : process .env.SENTRY_AUTH_TOKEN, project : '<PROJECT NAME>' , debug : false , } module .exports = withSentryConfig(buildConfig, sentryWebpackPluginOptions) 注意点としては、ソースマップをユーザーに公開したくない場合は hideSourceMaps を有効にすることです。ソースマップから開発コードの復元が可能でセキュリティーの観点からこれを避けたかったため、本番環境ではこの設定を有効にしています。逆に開発環境ではデバッグを効率的に行うため、 hideSourceMaps を無効にしてソースマップ機能を有効にしています。 ソースマップアップロードを可能にするDockerイメージ作成 前述の通りNext.jsのSSRサーバーはKubernetes上で稼働します。そのためには、Next.jsのDockerコンテナアプリケーションをビルドする必要があります。Next.jsのドキュメントに Dockerfileのサンプル が提示されていますので、これをベースに作成します。加えて、 こちらのベストプラクティス集 も参考にしました。Next.jsのドキュメントだと node-alpine のイメージが使われていますが、 node-slim 6 をベースに構築しました。下記がDockerfileの一部です。 # <version>は任意のversionを当てはめてください。 FROM node:<version>-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN apt-get update RUN npm ci FROM node:<version>-slim AS builder RUN apt-get update WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # ビルド際に環境変数をアプリケーションが参照できるようにする ARG NEXT_PUBLIC_SENTRY_DSN ENV NEXT_PUBLIC_SENTRY_DSN $NEXT_PUBLIC_SENTRY_DSN ARG SENTRY_AUTH_TOKEN ENV SENTRY_AUTH_TOKEN $SENTRY_AUTH_TOKEN RUN npm run build # ソースマップをSentryにアップロードすれば不要なため.map拡張子のファイルを削除するnode.jsスクリプトを実行 RUN node script/removeSourceMap.js # devDependencies のファイルを削除して軽量化 RUN npm prune --production FROM node:<version>-slim AS runner # 略... Next.jsのDockerfileの特徴的な点は、イメージ作成時にCSS、JavaScriptなどのアセットファイルのビルドを実行することです。そのため、アプリケーションのサーバーの起動時ではなく、イメージ作成時に必要な環境変数を設定する必要があります。これを実現するために、ARG、ENV命令を使用します。例では NEXT_PUBLIC_SENTRY_DSN 、 SENTRY_AUTH_TOKEN を記述してます。注意点としては、環境変数をブラウザに公開する場合の名称です。 Next.jsでは NEXT_PUBLIC_ というプレフィックス をつければ公開される環境変数になります。例では、 SENTRY_AUTH_TOKEN はSentryにソースマップをアップロードするためにSDKで必要な値のため、公開は不要のためプレフィックスはつけません。 また、ソースマップについても注意が必要です。前述の通り、Next.jsの設定ファイルに hideSourceMaps:true と設定することで、ソースマップファイルへの参照は消されますが、ソースマップファイル自体は残り続けます。そのため、ファイル自体も削除する必要があります。以下は、Node.jsスクリプトを使用して .map 拡張子のファイルを削除する例です。 const fs = require ( 'fs' ) const path = require ( 'path' ) const FileType = { file : 'file' , directory : 'directory' , unknown : 'unknown' , sourceMap : '.map' , } const getFileType = ( path ) => { try { const stat = fs . statSync ( path ) if ( stat . isFile ()) { return FileType . file } if ( stat . isDirectory ()) { return FileType . directory } return FileType . unknown } catch ( e ) { return FileType . unknown } } const getFileList = ( dirPath ) => { const ret = [] const paths = fs . readdirSync ( dirPath ) paths . forEach (( p ) => { const filePath = path . resolve ( dirPath , p ) if ( getFileType ( filePath ) === FileType . file ) { ret . push ( filePath ) } if ( getFileType ( filePath ) === FileType . directory ) { ret . push ( ... getFileList ( filePath )) } return }) return ret } const sourceMapFileList = getFileList ( './.next/static' ) . filter ( ( p ) => path . extname ( p ) === FileType . sourceMap ) sourceMapFileList . forEach (( filePath ) => { fs . unlink ( filePath , ( err ) => { if ( err ) throw err }) }) 最後に npm prune を実行して、不要なファイルを削除しDockerイメージの軽量化を図ります。これによって node_module の中にある TypeScript やライブラリの型定義など、ビルド以降は利用しないライブラリのファイルを削除します。 ナレッジの紹介は以上です。 効果と今後の課題 新システムのリリースは、リクエストの1%、20%、50%、100%と徐々にルーティングさせるカナリアリリースによって、各段階で検証し、不具合がないことを確認できました。これにより、インフラやフロントエンドの技術的なベースの構築、Next.jsの開発ナレッジの獲得が達成されました。また、ホーム画面ではSPA化によって、タブの切り替えなどのユーザー体験が向上しました。しかし、課題として残る点もあります。1点目はパフォーマンスです。CDNでキャッシュされることで、 TTFB の値は 200ms(95percentile) 以内でレスポンスできています。この数値は、600msという遅いと判断される基準よりもかなり余裕がある数値です。一方で、 Web Vitals の他の基準である、FCP、LCP、CLSなどの数値はまだまだ改善の余地があります。現在は、レンダリングの最適化や画像サイズの最適化などについて検討中です。2点目は、コスト最適化です。ホーム画面はSSRのキャッシュが要件的に可能でしたが、不可能なページも今後発生する可能性があります。この場合は、CDNでのSSRを実現できないか検討しています。 まとめ 本記事では、ZOZOTOWNのホーム画面のリプレイスをどのようなシステム構成で段階的に実現したのかの事例を紹介しました。加えて、最適なレンダリング選択やCDNでのキャッシュ、カスタムサーバー、Sentryへのソースマップのアップロードなどについて説明しました。いずれもNext.jsのアプリケーションをプロダクションレディにするナレッジです。 歴史が長く、アクセス数の多いサービスを段階的にリプレイスするためには、事前にマイルストーンを設定し、インフラ的なベースの構築を進行していくことが重要です。さらに、こうしたインフラのベースの仕組みや意図についてアプリケーション開発者が理解し、Next.jsなどのフレームワーク固有の設定ナレッジを蓄積していくことで運用が可能になります。これまでフロントエンド開発では活用していなかったNext.jsはもちろんCDNやNode.js、Kubernetesなどを用いる術を今回得ました。これらのツールを活用し、より高い品質のサービスを提供していきたい考えです。 ZOZOでは、そんなサービスを一緒に作り上げてくれる方を募集中です。ご興味のある方は、下記のリンクからぜひご応募ください。 corp.zozo.com ホーム画面とは具体的には https://zozo.jp/ 、 https://zozo.jp/shoes/ 、 https://zozo.jp/cosme/ の画面をさします。 ↩ 既にZOZOTOWNはReactを用いて開発しており、開発者のノウハウも蓄積されていたため。 ↩ state of js 2022 のデータを見ても近年利用率1位を維持しています。 ↩ ZOZOではmicroCMSを活用 していますが、技術スタックの相性の良さから、これまで以上にmicroCMSを使って効率的に開発できるようになりました。 ↩ Server Rendering vs Static Rendering ↩ node-slimは、Debian Linuxをベースとした軽量なDockerイメージです。これは、Alpine Linuxをベースとしたnode-alpineよりもわずかにイメージサイズは大きいです。しかし、Debianベースの方が広範なツールやライブラリを利用でき、汎用性が高いためnode-slimを採用しています。 ↩
アバター
はじめに こんにちは、計測プラットフォーム開発本部SREブロックの近藤です。普段はZOZOMATやZOZOGLASS、ZOZOFITなどの計測技術に関わるシステムの開発、運用に携わっています。 計測プラットフォーム開発本部では、複数のプロダクトを開発運用していますが、リリース作業はプロダクト単位で行っています。プロダクトによってローンチから数年経過し安定傾向のものもあれば、ローンチしたばかりで機能開発が盛んなものもある状態です。 複数のプロダクトを管理する上では当然の状況ですが、プロダクト単位でリリース作業手順が異なり、手順そのものにも課題がある状態でした。 本記事では、リリース作業で課題となっていた部分の紹介と、それぞれの課題に対する対応策についてご紹介します。 目次 はじめに 目次 現状 課題と対応方針 リリース作業の自動化 リリース作業の自動化をする上での必須条件の確認 自動化が必要な箇所の洗い出し 自動化対応 リリースアナウンス 負荷試験の結果確認 CIが通過した場合、PRのマージ BotUserが作成するPRの自動マージを有効化 ImageUpdaterが作成するPRの自動マージを有効化 負荷試験が成功した際に自動マージを有効化する リリース粒度のミニマム化 リリース手順、ブランチ戦略の統一 振り返り 導入効果 導入後に顕在化した課題 終わりに 現状 これまであった課題をお話しする前に、現状のプロダクト毎のデプロイ方法、ブランチ戦略、リリース頻度をご紹介します。 プロダクト名 デプロイ方法 ブランチ戦略 リリース頻度 ZOZOMAT(2020/02ローンチ) ArgoCD 1 Git Flow 隔週に一度の定期リリース ZOZOGLASS(2021/03ローンチ) ArgoCD Git Flow 隔週に一度の定期リリース ZOZOFIT(2022/08ローンチ) ArgoCD 2 GitHub Flow mainが更新されたらリリース 上記の3つのプロダクトで、デプロイ方法は統一されていますが、ブランチ戦略が異なるため、リリース手順が微妙に異なる状態でした。 ブランチ戦略は昨年から新しいプロダクトはGitHub Flow、それ以前はGit Flowを採用していました。 Git Flowを採用していた背景として、昔は動作確認を手動で行っており、都度のPR単位でリリースすると動作確認の工数がとても高くなる状況でした。このため、リリースを定期作業とすることで作業工数を抑えていた経緯があります。 昨年からデプロイパイプラインのリアーキテクトやArgo Rolloutsの導入なども進み、現状は動作確認やロールバックが自動化されている状態でした。このため、新しいプロダクトではGitHub Flowを採用しています。リリース手順に関しては、GitHubのIssue Templateで管理しており、リリースのタイミングで担当者がIssueを起票していました。 Git FlowとGitHub Flowのブランチ戦略で大きな違いは、GitHub Flowはmainが常にリリース可能な状態であることです。 Git Flow GitHub Flow 弊チームで採用していたGit Flowですが、アプリケーションリポジトリではいくつかオリジナルのものに変更を加えてあります。特徴はmainブランチとreleaseブランチという2つのプライマリブランチを保持している点です。developブランチはありません。通常の開発は、releaseブランチからfeatureブランチを作成して行います。リリース作業時はreleaseブランチをmainブランチにmergeし、mainブランチをリリースします。 課題と対応方針 このような状況の中で、課題は大きく3つありました。なるべくシンプルに、なるべく楽にしたい、という理想ベースでそれぞれの課題への対応方針を定めました。 No. 課題 方針 1 手動によるリリースのため、人の工数が取られる リリース作業の自動化 2 複数の変更が一度にリリースされるため、パフォーマンスの変更要因の特定が困難 リリース粒度のミニマム化 3 リリース手順やブランチ戦略がプロダクト毎に異なり、認知負荷が高い リリース手順、ブランチ戦略の統一 リリース作業の自動化 リリース作業の自動化については、導入する上での必須条件を整理し、対応が必要な箇所の洗い出しを行いました。 リリース作業の自動化をする上での必須条件の確認 条件 対応済み PRのマージが自動化されていること 動作確認が自動化されていること ✅ 問題が発生した場合、自動でロールバックが行われること ✅ 補足すると、動作確認はスクリプトで自動化されており、ロールバックはArgo Rolloutsによって自動化されていました。Argo Rolloutsに関しては、カナリアリリースについてのブログ記事が公開されているので、気になる方はこちらの記事をご参照ください。 techblog.zozo.com 自動化が必要な箇所の洗い出し リリース作業の流れを簡略化して図にすると以下のようになります。自動化前の状態で人が行っていた作業は、図のオレンジ色の部分になります。 自動化前 自動化後 人が行っていた作業は、PRのマージと、Slackへのアナウンス、負荷試験の結果確認のみでした。なお、ここでいうPRのマージは、releaseブランチをmainブランチにマージするという作業です。個別の修正はreleaseブランチにマージする時点でレビューを受けており、実質CIが通過していることを確認だけしていた状態です。 自動化対応 自動化が必要な作業の洗い出しが終わったので、次は各作業の自動化を行いました。 リリースアナウンス Argo Rolloutsでは、ロールアウトの開始終了を通知できます。各Subscribe可能なイベントについては 公式サイト を参照ください。この仕組みを利用することで、Kubernetesマニフェストを数行変更するだけで、通知の自動化ができました。 もともと開発チーム向けに通知はしていたのですが、ロールアウトの開始と終了をこれまでリリースアナウンスを行っていたSlackチャンネルにも通知するようにしました。通知は複数のチャンネルに飛ばすことができ、その場合は ; で繋ぎます。 # ZOZOGLASSのAPIサーバー用のRolloutの設定 apiVersion : argoproj.io/v1alpha1 kind : Rollout metadata : name : api-server-rollout annotations : notifications.argoproj.io/subscribe.on-rollout-aborted.slack : rollout_notification notifications.argoproj.io/subscribe.on-rollout-completed.slack : rollout_notification;zozoglass_release notifications.argoproj.io/subscribe.on-rollout-step-completed.slack : rollout_notification notifications.argoproj.io/subscribe.on-rollout-updated.slack : zozoglass_release notifications.argoproj.io/subscribe.on-analysis-run-failed.slack : rollout_notification 負荷試験の結果確認 リリース時にパフォーマンス上問題がないか確認するため、負荷試験を実行し、Gatlingが生成したレポートを人が目視で確認していました。 Gatlingでは、負荷試験の結果を評価するAssertionsが提供されています。Assertionsについての詳しい設定方法については 公式サイト を参照ください。Assertionsが提供する responseTime.percentile(99) を利用することで、レスポンスタイムの99パーセンタイルの期待値を設定できます。同様に failedRequests.percent を利用することで、エラー率の期待値を設定できます。期待値を満たさなかった場合、Gatlingのテストは失敗となります。 導入は既に目標値が定まっていたので、既存のGatlingのコードを数行変更することで、結果確認の自動化ができました。 setUp( scenarioMeasure .inject(rampUsers(LoadTestMeasureUsers.toInt).during(LoadTestSeconds.toInt)), scenarioBrowseCosmeticsRecommendations .inject(constantUsersPerSec(GetFaceColorRps.toInt).during(LoadTestSeconds.toInt).randomized) ).protocols(httpProtocol) // Then // 今回追加したAssertions部分 .assertions( forAll.responseTime.percentile( 99 ).lt(ResponceTimeThreshold.toInt), forAll.failedRequests.percent.lte(FailedRateThreshold.toDouble) ) CIが通過した場合、PRのマージ PRの自動マージに関しては、自動でマージすること自体は簡単でしたが、いくつか例外を考慮する必要がありました。今回PRの自動マージを行いたいリポジトリは、アプリケーションのコードを管理するリポジトリとKubernetesマニフェストを管理するリポジトリの2つがありました。 計測プラットフォームでは、ArgoCDを利用することでGitOpsに準拠した形でのデプロイパイプラインを採用しています。ArgoCDを利用したデプロイパイプラインについては、ArgoCDの導入についてのブログ記事が公開されているので、気になる方はこちらの記事をご参照ください。 techblog.zozo.com それぞれのリポジトリで作成されるPR、自動マージをブロックしたい条件を整理した結果を以下の表にまとめます。 リポジトリ PRの種別 自動マージをブロックしたい条件 アプリケーションリポジトリ ・BotUserが作るライブラリ更新のPR ・人が作る機能追加/BugFixのPR ・releaseブランチからmainブランチへのPR ・CIが失敗したPR ・BotUser以外が作ったPR Kubernetesマニフェストリポジトリ ・ImageUpdaterが作るアプリケーションImage更新のPR ・人が作るKubernetesマニフェストに対する更新PR ・mainブランチからreleaseブランチへのPR ・CIが失敗したPR ・人のコミットが入ったPR ・負荷試験が失敗した場合のreleaseブランチへのPR BotUserが作成するPRとImageUpdaterが作成するPRに関しては、自動マージするために以下のようなGitHub Actionsを追加し、CIが通過した場合は自動マージするようにしました。なお、自動マージは GitHubから提供されている自動マージ機能 を利用しています。この機能により、ブランチプロテクションルールを守りつつ自動マージを容易に実装できました。自動マージの利用にはリポジトリ側で設定を有効化する必要があるので、設定時には公式の設定手順を参照ください。 BotUserが作成するPRの自動マージを有効化 name : BotUser Auto Merge on : pull_request : branches : - 'main' permissions : pull-requests : write contents : write jobs : auto-merge : runs-on : ubuntu-latest if : ${{ github.actor == 'bot-user' }} environment : name : ${{ inputs.env }} steps : - name : Checkout uses : actions/checkout@v3 - name : Approve PR shell : bash run : gh pr review "$PR_URL" --approve env : PR_URL : ${{ github.event.pull_request.html_url }} GH_TOKEN : ${{ secrets.GITHUB_TOKEN }} # BotUserが作るPRにapproveするので、GitHubActionのTOKENを指定している - name : Auto Merge PR shell : bash run : gh pr merge --auto --merge "$PR_URL" env : PR_URL : ${{ github.event.pull_request.html_url }} GH_TOKEN : ${{ secrets.GITHUB_TOKEN }} ImageUpdaterが作成するPRの自動マージを有効化 name : Create Image Updater PR and Auto Merge inputs : argocd-application : description : ArgoCD Application Name required : true application-repo : description : Application Repository of GitHub required : true source-image : description : image watched by Image Updater. e.x. api-server required : true target-images-to-duplicate-image-tag : description : images apart from api-server. The format of item is `imageA,imageB,imageC` required : true bot-user-pat : required : true runs : using : composite steps : - name : Update production file id : create-commit shell : bash run : | git config --global user.email "action@github.com" git config --global user.name "GitHub Action" STG_FILE="kubernetes/overlays/staging/.argocd-source-${{ inputs.argocd-application }}.yaml" PRD_FILE="kubernetes/overlays/production/.argocd-source-${{ inputs.argocd-application }}.yaml" # 次のステップで変数を利用する COMMIT_HASH=$(grep -oE "${{ inputs.source-image }}:[0-9a-z\-]+$" $STG_FILE | sed "s/${{ inputs.source-image }}://g" ) echo "COMMIT_HASH=${COMMIT_HASH}" >> $GITHUB_OUTPUT # 更新されたapi-serverのタグを全イメージに反映する。 IMAGES=${{ inputs.target-images-to-duplicate-image-tag }} for image in ${IMAGES//,/ }; do grep -oE ".+${{ inputs.source-image }}:[0-9a-z\-]+$" $STG_FILE | sed "s/${{ inputs.source-image }}/$image/g" >> $STG_FILE done # ステージング用のイメージタグの変更を本番用のファイルにも反映させる sed -e 's/${STG_AWS_ACCOUNT_ID}/${PRD_AWS_ACCOUNT_ID}/g' $STG_FILE > $PRD_FILE git add $STG_FILE git commit -m 'update image tags on staging' git add $PRD_FILE git commit -m 'update image tags on production' git status git push origin HEAD - name : Create PR to main branch id : create-pr uses : actions/github-script@v6 with : script : | const { COMMIT_HASH } = process.env const { repo, owner } = context.repo; const result = await github.rest.pulls.create({ title : '[Image Updater] イメージタグの更新' , owner, repo, head : '${{ github.ref_name }}' , base : 'main' , body : `## アプリケーションの変更内容\nhttps://github.com/st-tech/${{ inputs.application-repo }}/commit/${COMMIT_HASH}\n## 反映方法\n - staging リリース: main ブランチにマージすると自動で staging 環境の Pod が入れ替わる。\n - production リリース: main から release ブランチの PR をマージすると自動で production 環境の Pod が入れ替わる。` }); process.env.PR_URL = result.data.html_url env : COMMIT_HASH : ${{ steps.create-commit.outputs.COMMIT_HASH }} #[ NOTE ] GH_TOKENでBotUserのpatを指定している理由はsecrets.GITHUB_TOKENだとPR作成者とapprove者が同一になってしまい、Approveできないため - name : Approve PR shell : bash run : gh pr review "$PR_URL" --approve env : PR_URL : ${{ steps.create-pr.outputs.PR_URL }} GH_TOKEN : ${{ inputs.bot-user-pat }} - name : Auto Merge PR shell : bash run : gh pr merge --auto --merge "$PR_URL" env : PR_URL : ${{ steps.create-pr.outputs.PR_URL }} GH_TOKEN : ${{ inputs.bot-user-pat }} 例外として、Kubernetesマニフェストのreleaseブランチに対する自動マージは人のコミットが含まれない、かつ、負荷試験が成功した場合のみ有効化する必要がありました。このケースだけGitHub Actionsではなく、負荷試験の成功後にスクリプトで自動マージするようにしました。 負荷試験が成功した際に自動マージを有効化する #!/usr/bin/env bash # GH_TOKENはJobの環境変数で設定、BASE(release)とHEAD(main)は動作確認を手軽に行うために環境変数から取得する pr_url = `gh search prs --state open --repo st-tech/ ${GIT_REPOSITORY} --base ${GIT_BASE_BRANCH} --head ${GIT_HEAD_BRANCH} --sort created --limit 1 --json url | jq -r . [] .url` # check only argocd-image-updater or GitHub Action and BotUser gh pr view --repo st-tech/ ${GIT_REPOSITORY} $pr_url --json commits --jq ' .commits[].authors[] |select(.name|test("(argocd-image-updater|GitHub Action|bot-user)")|not) ' | grep email contain_human_commit = $? if [ $contain_human_commit == 0 ]; then message = " <!here>[ " ${PRODUCT_NAME} " ] Stopped Auto Merge ${pr_url} " echo $message | ./slack.sh exit 0 fi # approved & auto-merge enabled gh pr review $pr_url --approve --repo st-tech/ ${GIT_REPOSITORY} approved = $? gh pr merge --auto --merge $pr_url --repo st-tech/ ${GIT_REPOSITORY} auto_merge = $? if [ $approved == 0 ] && [ $auto_merge == 0 ]; then message = " <!here>[ " ${PRODUCT_NAME} " ] Merged Release PR ${pr_url} " else message = " <!here>[ " ${PRODUCT_NAME} " ] Failed Auto Merge ${pr_url} " fi リリース粒度のミニマム化 リリース作業の自動化対応が完了した後、アプリケーションリポジトリのブランチ戦略をGitHub Flowに統一しました。リリース作業の自動化とブランチ戦略の変更により、1PR毎にリリースが行われる様になりました。 リリース手順、ブランチ戦略の統一 リリース作業を自動化したことで手順は統一され、ブランチ戦略もGitHub Flowに統一されました。 振り返り リリース作業の自動化対応とブランチ戦略を変更することで、最終的には以下のような状態になりました。 プロダクト名 デプロイ方法 ブランチ戦略 リリース頻度 ZOZOMAT ArgoCD GitHub Flow 随時 ZOZOGLASS ArgoCD GitHub Flow 随時 ZOZOFIT ArgoCD GitHub Flow 随時 結果、GitHub Flow with GitOpsとタイトルで挙げた状態に辿り着きました。 導入効果 導入後に元々の課題が解決されたか確認してみたところ、以下のような結果になりました。 リリース粒度 1リリースに要する作業時間 リリース頻度 変更前 5-10のPRをまとめてリリース 2時間 1-2/月 変更後 PR単位でリリース 5分 15-20/月 まず、リリース粒度に関しては、PR単位でのリリースとなりました。課題だった複数の変更が同時にリリースされ、パフォーマンスの変更要因の特定が困難な状況は解消されました。 1リリースに要する作業時間は、これまで2時間かかっていたものがPRのマージだけになったので5分に短縮されました(なお、PRのレビュー時間はリリース作業とは別として扱っています)。リリース頻度は課題ではなかったですが、こちらも大きな変化があったので記載しておきます。これまで1-2/月だったものが15-20/月になっており、7倍以上に増えました。 導入後に顕在化した課題 負荷試験の失敗 導入直後にストックされていたPRが短時間に連続でマージされ、負荷試験を立て続けて行う状態が発生しました。これにより想定以上の負荷がかかる状態となり、負荷試験の結果が目標値を下回りました。結果として、自動リリースが失敗しました。 この課題に関しては、切り替え直後の一時的な問題だと判断し、恒久対応は行っていない状態です。開発ペースが上がると顕在化する問題のため、負荷試験の排他制御などの対応を将来的には入れる可能性があります。 祝祭日にも自動リリースされてしまう ライブラリ更新系のPRは自動作成の曜日は平日のみとすることで、極力人がいる時間帯にリリースが発生するようにしていました。 ただ、祝祭日は考慮していなかったので、どうしようかという議論が導入後の祝日を迎えてされました。 結果として、問題があった場合は検知されリリースが止まる、万が一リリースされてしまっても自動でロールバックされるため、祝祭日は考慮しない形となりました。 終わりに 今回のご紹介させていただいたGitHub Flow with GitOpsの導入は、これまでの改善があってこそできた形です。日々の改善に助けられた形で、リリース作業の自動化に踏み切れたので、継続して改善を続けてくれたチームメンバーへの感謝が凄まじかったです。 GitHub Flowはより早く価値を届ける事が強く求められる、更新が活発なプロダクトこそ恩恵を得られる印象が強いです。 今回の改善では、小さい粒度でリリースすることやリリース速度の高速化は、プロダクトのフェーズに関わらず得られる恩恵があると再確認できました。 計測プラットフォーム開発本部では、今回紹介させていただいたように、新規サービスの開発だけでなく既存サービスの改善も日々行っています。このような環境を楽しみ、サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 corp.zozo.com Git Flowは、Gitのブランチ戦略の1つです。mainブランチとは別にプライマリブランチを保持することで、リリースタイミングを柔軟にコントロール可能とします。Git Flowに関しての詳細は、原著である A successful Git branching model を参照ください。 ↩ GitHub Flowは、GitHub社が採用しているGitのブランチ戦略です。GitHub Flowに関しての詳細は、 公式のガイド を参照ください。 ↩
アバター
はじめに こんにちは。計測プラットフォーム開発本部バックエンドチームの佐次田です。普段はZOZOMATやZOZOGLASSなどの計測技術に関わるシステムの開発、運用に携わっています。去年の夏に、ZOZOFITというサービスを北米向けにローンチしました。 本記事では、ZOZOFITのローンチまでに遭遇した意思決定における課題と、ADRというドキュメンテーション手法を用いた解決までの取り組みについて紹介します。 目次 はじめに 目次 計測プラットフォーム開発本部 バックエンドチームとは ZOZOFITとは 開発中に直面した課題 過去の背景が分からず決断しにくい 意思決定の結論が追いにくい 意思決定の認識合わせに時間がかかる ADRの導入 ADRとは 展開 ADRのフォーマット 使用ツール チームへの展開 ADRの一例 振り返り 課題はどう解決されたのか メリット デメリット 最後に 計測プラットフォーム開発本部 バックエンドチームとは 計測プラットフォームバックエンドチームは、ZOZOGLASS/ZOZOMAT/ZOZOSUITによって採集される計測データにまつわるバックエンド開発を担うチームです。アプリやブラウザに対するクライアントAPIや、ZOZOTOWN内部のマイクロサービスのAPIにおいて、徹底的に低レイテンシにこだわりを持つことと、高可用性を保つことを目指しています。 ZOZOFITとは ZOZOFITは2022年に発表した体型管理を目的としたフィットネスアプリです。ZOZOSUITの計測技術を利用したサービスであり、2023年3月時点では、体型計測および身体3Dモデルのデータ・体脂肪率の表示機能を提供しています。 開発中に直面した課題 ZOZOFITの開発を進めるにあたり先んじてアーキテクチャを選定する必要がありました。選定基準として、過去に運用実績があるZOZOSUITやZOZOMATなどの知見は重要なものでした。実際に私たちのチームではスピードと品質を担保するために過去実績があるアーキテクチャを採用することに決め改善する必要があるものは新しいチャレンジを行うことに決めました。その後、開発を進めるにあたり以下の課題に直面しました。 過去の背景が分からず決断しにくい 私たちのチームはメンバーの半数が新しく参画したメンバーであり、過去の決定はまばらに文書化されている状態でした。メンバー内でも過去の意思決定を把握できているメンバーが偏っている状態であり、新規メンバーは決定の背後にある動機を理解できていない状態でした。新規事業においてはスピード感も重要となるため、ローンチまでに過去の決定をキャッチアップするための同期的なコミュニケーションも取りにくい状態でした。そのため、新規メンバーは新しい意思決定に踏み込みにくい状態となっていました。 意思決定の結論が追いにくい ZOZOFITにおける意思決定はアジャイルに何度も行われ、意思決定の機会は数多くありました。しかし、過去の決定はSlackでの会話やGitHubでのコミュニケーションなどに点在しており、最終的な結論を理解するためには多くの議論を遡って見ていく必要がありました。議論が追いきれなかった場合は再度同じ提案をする必要があり、何度も同じテーマについて会話する状況となっていました。 意思決定の認識合わせに時間がかかる 私たちのチームはフルリモートでの業務を主としており、海外チームと共に開発を進めています。そのため非同期でのコミュニケーションを重要視しており、SlackやGitHub等のツールを使って積極的に会話をしています。 ZOZOFITにおける海外チームとの協業についてはブログ記事が公開されていますので、気になる方はこちらの記事をご参照ください。 technote.zozo.com より良い決定のためには、背後にある動機や判断材料が重要となります。しかし、背景などの情報が伝わっていない状態で会話を進めていたため、情報を集めるために各チームと都度コミュニケーションが発生していました。また、非同期でのコミュニケーションはレスポンスがすぐに返ってくるとは限らないため、別作業とコミュニケーションでのスイッチングコストも課題となっていました。 ADRの導入 私たちは上記の課題を解決するためにADRという手法を導入することにしました。 ADRとは ADRとはArchitecture Decision Recordの略称であり重要なアーキテクチャの意志決定を、背景、結果と共に記録したドキュメントです。 Micheal Nygard氏は「 DOCUMENTING ARCHITECTURE DECISIONS 」においてADRの思想について言及しており、意思決定の理由と背景を追跡することは難しいため記録しておくべきだと述べています。 One of the hardest things to track during the life of a project is the motivation behind certain decisions. A new person coming on to a project may be perplexed, baffled, delighted, or infuriated by some past decision. Without understanding the rationale or consequences, this person has only two choices: Blindly accept the decision. Blindly change it. プロジェクトの中で最も追跡が困難なことの1つは、ある決定の背後にある動機である。 プロジェクトに新しく参加した人は、過去の決定に戸惑い、困惑し、喜び、あるいは激怒するかもしれません。 このような場合、その理由や結果を理解できないまま、以下のどちらかを選択することになります。 決定を盲目的に受け入れる やみくもに変更する 実際に私たちのチームにおいても決定の背景や動機を読み取れない問題が起きており、導入することで課題解決に繋がると考えました。 しかし、ADRは決定のドキュメントを記録していく手法であり、 Agile Manifesto においては包括的なドキュメントより動くソフトウェアを重視すると記載されています。その問題については、以下のように述べています。 Agile methods are not opposed to documentation, only to valueless documentation. Documents that assist the team itself can have value, but only if they are kept up to date. Large documents are never kept up to date. Small, modular documents have at least a chance at being updated. アジャイルな手法は文書化に反対しているわけではなく、価値のない文書に反対しているだけである。 チーム自身を支援する文書は価値を持つことができますが、それは最新の状態に保たれている場合に限られます。 大きなドキュメントは決して最新の状態に保たれません。小さく、モジュール化された文書であれば、少なくとも更新される可能性があります。 ドキュメントを小さい単位で記録していくことで、より扱いやすく色々な人にとって価値のあるものとなります。ADRは仕様書のように最新の状態に更新し続ける必要はなく意思決定のログとして扱えるため、後追いで見た時に読み手が解釈しやすく、価値があるドキュメントとなります。 上記を踏まえて、以下の項目を意識してADRの導入を行うこととしました。 意思決定の背景・動機が残っている状態となること ドキュメントは小さな粒度で残していくこと ADRは仕様書ではなく、決定のログとして残していくこと 展開 ADRのフォーマットは展開のしやすさを考慮してシンプルなものを採用することにしました。テンプレートは数多く公開されていますが、私たちのチームでは下記のテンプレートを参考にすることにしました。 https://github.com/joelparkerhenderson/architecture-decision-record/blob/main/templates/decision-record-template-by-michael-nygard/index.md ADRのフォーマット 実際の運用では上記テンプレートの一部解釈を変更して運用しています。使用しているテンプレートは以下となります。 YYYY/MM/DD ADR Title # Status # Situation # Context # Material for decision # Decision # Consequences セクションごとの詳細は以下となります。 Status 意思決定のステータス proposed/accepted/rejected/deprecated/ この項目があることで、ドキュメントの状態が一目で分かるようになります ステータスの更新は忘れがちなので注意が必要です Situation 決定時のビジネスにおける制約や会社、チームの状況 重要な決定ほど外的要因に左右されるため、決定に作用した状況を記載します 意思決定の状況があることでテクノロジー以外の部分で決定に作用した内容を把握できるようになります Context 決定が必要となった背後にある動機や理由 決定内容は記録が残っていなかったとしても最新のプロダクトからある程度把握が可能ですが、決定の背後にある動機や理由は記録がなければ読み取ることは非常に困難です 読み手に対して何故この意思決定が必要となったのかを伝える上でとても重要な項目です Material for decision 決定時に参照した材料 決定の材料を記載します。意思決定をスムーズに行うために必要な項目です Decision 最終的な決定内容と理由 最終的な決定を簡潔に記述します 決定に至った背景・理由も記述することで、後追いで見た時により価値が高いドキュメントとなります Consequences 最終的な結果 意思決定が及ぼす結果を記載します 決定時に結果を記入できるものは記録し、振り返りが必要なものに対しては後追いで記述します 使用ツール 私たちのチームではドキュメントツールとしてConfluenceを採用しており、ADRの記録先としてもConfluenceを採用することに決めました。対抗馬としてGitHubを採用するかどうか迷いましたが、以下の理由から見送ることとしました。 Confluenceの方がよりカジュアルにドキュメント作成が可能 開発者以外の方からも気軽にアクセス可能な状態としたかった 一方でGitHubを使用することで、ソースコードと同列にADRを管理できるようになります。この決定についてはどちらが最適か結論が出ていないため後ほど振り返る予定です。 チームへの展開 チーム展開時にはADRを残していきたい旨をADR形式でチームに展開し相談しました。私たちのチームではADRを記録する対象をアーキテクチャのみに絞らず、チームで残すべきと判断した意思決定は全て記録するようにしました。これにより、アーキテクチャに関する内容やチーム内のルールなど、多くのカテゴリのADRが記録されることになり煩雑になることも懸念しましたが、運用がスムーズにいくまでは考えることを少なくするため全ての決定を記録する体制を取っています。また、過去の意思決定も思い出しを含めて可能な限りADRとして起票するようにしました。ZOZOFITは新規事業なため、開発に携わっている私たちが記録しなければ後で参加したメンバーが困ると判断したためです。起票に時間はかかりましたが、これにより過去の殆どの意思決定がADRとして残っている状況を作りました。 ADRの一例 ADRの一例を紹介します。ZOZOFITの開発を進める中で私たちはIDaaSとしてCognitoを採用することに決めました。しかし、メールアドレスの変更処理において意図していない挙動となることが判明したため以下のADRを記録しています。 # 2022/05/16 Cognitoのメールアドレス変更の処理を自作する # Status - proposed/ **accepted**/ rejected/ deprecated/ superseded # Situation - ZOZOFITの開発初期のタイミング - IDaaSとしてCognitoを採用しており、Cognitoを使ったPoC実装が完了している - チームメンバーにCognitoについて詳しいメンバーはいない - ZOZOFITのリリースタイミングの目処は決まっている # Context - 2022年5月の時点においてCognitoを使用する場合に「変更後のメールアドレスが未検証の状態でも、Cognitoのユーザーのメールアドレスが更新されてしまう」問題が起きることが判明した - これによって以下の問題が発生する - ユーザーはリフレッシュトークンの有効期限が切れた場合、変更前のメールアドレスが使えない状態となり、ログインできなくなる - ユーザーは他人のメールアドレスを自身のメールアドレスとしてZOZOFITに登録できてしまう - 上記は許容できないため、対応策を決定する必要がある # Material for decision - 問題が発生するまでの操作フロー - ユーザーはZOZOFITのアプリ上でメールアドレスの変更を行う - サーバーはCognitoのUpdateUserAttributesのAPIを呼び出しメールアドレスを更新する - Cognitoはメール未検証状態でユーザーのメールアドレスを更新する - このタイミングで過去のメールアドレスと書き変わる - 以下の記事において、この問題について言及されています - https://kohei1116.hateblo.jp/entry/2020/02/16/aws-cognito#1-UpdateUserAttributes - https://zenn.dev/dove/articles/78ecf08b51ee0c - 対応策としては2つ考えられそうです - Cognitoのメールアドレス変更の処理を自作する - Cognitoにおけるユーザーのメールアドレスの検証処理を自作し、ZOZOFITサーバー側の責務とする - 別のIDaaSを利用する - 別のサービスの調査を行い、同様の事象が発生するかどうか確認する - 起きないのであれば、メールアドレス変更処理を自作した場合と比較し、どちらを選択するか決めた方が良さそう # Decision - Cognitoのメールアドレス変更の処理を自作する - 新規でAPIを書く必要があるが、既にCognitoを用いての開発が進んでおり別のIDaaSを選択するのに比べて工数が低いと判断 # Consequence - 結果として - メールアドレス変更処理を行うAPIを自作する - 認証周りの実装をCognitoの外に持つことになるが許容する - 2023/03 - ADRに対して振り返りを行いました - 背景にある事象はCognitoにおいて解決されており、メールアドレス変更処理を自作する必要はない状況となりました - メールアドレス変更処理フローを変更するかどうかについては後続のADRで起票します - 参考 - https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-settings-email-phone-verification.html 上記のADRについては後日振り返りを行い、2023年3月時点で問題が起きないことが分かっています。 過去の意思決定を記録しておくことで振り返りの実施をスムーズに行えるようになり、次の意思決定に繋げることができました。 振り返り 結果としては、ZOZOFITにおいて約40件のADRが記録されています。 課題はどう解決されたのか 過去の背景が分からず決断しにくい ADRを残すことで、新規の意思決定が行いやすい状態となりました 最も効果があった部分は、意思決定に対して振り返りを行うことが容易となった点です 当時のタイミングでは最適だったとしてもタイミングによっては選択肢が変わることもあると思います。ADRを残すことで過去の知見を再利用しながら新しい意思決定に繋げることが可能となりました 実際にZOZOFITローンチ前の意思決定に対して振り返りを行い、新しいアクションが生まれました 何度も同じテーマを議論する あれ、これ何でしたっけ?となった時に記録が残っていることで同じことを議論する回数が減りました 意思決定の認識合わせに時間がかかる ADRを運用していく中で認識合わせのスピードが上がりました 意思決定のフローを事前にADRをproposedな状態で記入した後に議論する運用とすることで、必要な背景・材料が揃った状態で会話を開始できるようになり、認識合わせに必要な時間が削減されました メリット 導入してみて感じたメリットをまとめると以下となります。 透明性の向上 チーム内における情報量がフラットとなった チーム外からも気軽にドキュメントを参照できるようになり、APIやアーキテクチャ選定の思想や理由を伝えやすくなった 集約性の向上 過去の情報を遡るときはADRを参照すればOKとなった 可読性の向上 議論が提案状態なのか、結論が出ているのか一目で判断できるようになった ADRはフォーマットに沿っており結果の把握が行いやすくなった デメリット デメリットではないですが、ADRを記録する対象には向き不向きがあると感じました。例えば仕様書のようなものは最新の状態に合わせるために何度も更新が必要なためADRとして扱うには不向きだと感じました。また、運用面においても文書化は意識しないと行われないため、導入当初は旗振り役が意識して啓蒙活動をしなければ、浸透までは行き着きにくいと感じました。 最後に ADRを導入することで過去にローンチした計測技術・アーキテクチャの知見を活かし、新しい価値を素早く生み出すように努めています。ADRを導入することで過去の意思決定を気軽に参照できるようになり、会話が発生し、学びを得る機会が増えたと感じています。 計測プラットフォーム部バックエンドチームでは、 ZOZOFIT のように、日本国内に限らず新しいサービスを開発していくバックエンドエンジニアを募集しています。 ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
アバター
はじめに こんにちは。SRE部ECプラットフォーム基盤SREブロックの石田です。 本記事では、Aurora Serverless v2を本番導入するにあたってどのような検討をし、どのように導入していったか、また導入後に得られた効果について紹介します。 はじめに Aurora Serverless とは 背景 比較検討 比較内容 方針の決定 アーキテクチャ 導入 1. Aurora Serverless v2を手動で構築 2. AWS CloudFormationでProvisioned型Aurora MySQLバージョン3を再構築 3. AWS CloudFormationでAurora Serverless v2に移行 4. 負荷試験・障害試験 負荷試験 障害試験 導入により得られた効果 柔軟なスケーリング インフラコスト 最後に Aurora Serverless とは Aurora Serverless とは、Amazon AuroraにおいてオンデマンドのAuto Scaling設定が行えるデータベースです。アプリケーションニーズに応じて自動的に起動・停止が可能で、データベース容量を管理することなく、スケールアップ・スケールダウンが可能です。 Aurora Serverlessのデータベースの容量は、ACU(Aurora Capacity Unit)という単位が用いられています。1ACUあたり約2GiBのメモリと対応するCPU、ネットワークが組み合わされています。 また、Aurora Serverlessには、Aurora Serverless v1とAurora Serverless v2が存在します。v1とv2の比較は 公式ドキュメント をご確認ください。 背景 ZOZOTOWNではシステムリプレイスを進めており、2022年の春に会員基盤のクラウド化・マイクロサービス化をはじめました。これまでデータベースにはSQL Serverを使用していましたが、リプレイス後はMySQLを使うことが決まっていました。 私たちは2022年6月頃にAWS側で用意するMySQLデータベースの選定を始め、下記のような背景がありAurora Serverless v2の採用を検討しました。 コミュニティMySQL 5.7のEOL は2023年10月であり、EOL対応が必要になるため新規構築のタイミングでMySQL 8.0を採用したい セールなどの高負荷時に備えて手動でスケールアップするといった運用負荷をできる限りなくしたい 比較検討 検討を進める上では、Provisioned型のAurora MySQLバージョン3とProvisioned型のAurora MySQLバージョン2を比較して検討しました。 比較内容 選定ポイントとなる項目に対する比較表が下記の通りです。 Aurora Serverless v1は、マルチAZに対応していないことやスケーリング速度がv2より遅いことが懸念としてあったため、比較対象からは除外しています。 本検討は2022年6月27日時点の内容になります。 Aurora Serverless v2 Provisioned型Aurora MySQLバージョン3 Provisioned型Aurora MySQLバージョン2 MySQL 8.0互換である ○ ○ × LTSバージョンに対応している × × ○ AWS CloudFormationによるIaC化ができる ×(検討時) ○ ○ アプリケーションのレイテンシー目標を達成できる 要検証 ○ ○ スケールアップ作業が不要 ○ × × インフラコストが抑えられる ユースケースによる ユースケースによる ユースケースによる それぞれの比較内容について説明します。 MySQL 8.0互換である MySQL 8.0互換はEOLまで期間があり直近でのバージョンアップ作業は不要です。Provisioned型Aurora MySQLバージョン2はMySQL 5.7互換のため、EOLを迎える前にバージョンアップ作業が必要になります。 LTSバージョンに対応している LTSバージョンに対応していない場合、強制アップデートの回数は多くなり、アップデート通知からの猶予期間も短い可能性があります。 2022年2月27日現在でもAurora Serverless v2及びProvisioned型Aurora MySQLバージョン3はLTSバージョンに対応していません。 AWS CloudFormationによるIaC化ができる 弊チームではIaCによる環境構築を徹底しており、AWSリソースであれば、可能な限りAWS CloudFormationにて管理できるものを選択しています。 検討時点ではAurora Serverless v2はAWS CloudFormationには対応していませんでしたが、2022年10月5日に サポートを開始 したことを発表しました。 アプリケーションのレイテンシー目標を達成できる Provisioned型Aurora MySQLは運用実績があったため、レイテンシー目標を達成できる見込みがありました。Aurora Serverless v2は運用実績がなく、レイテンシー目標を達成できるかどうかは検証が必要です。 スケールアップ作業が不要 Aurora Serverless v2はオートスケールするため、セールなどの高負荷時にスケールアップ作業は不要です。Provisioned型Aurora MySQLはスケールアップする作業が発生します。 インフラコストが抑えられる Provisioned型Aurora MySQLはある程度の商用負荷を想定して大きめのスペックを用意する必要があります。Aurora Serverless v2は検討時点では通常時やピーク時のリクエスト数に対するスペックは不確定なため、比較してインフラコストが抑えられるかはまだ不明の状況でした。 方針の決定 ZOZOTOWNの会員基盤がAurora Serverless v2を採用する上での懸念は下記のようなものでした。 LTSバージョンに対応していないこと 検討時点ではAWS CloudFormationによるIaC化ができないこと レイテンシー目標を達成できるか インフラコストを抑えられるか これらを踏まえ、懸念について判断ポイントを設け、方針を決定しました。 1つ目のLTSバージョンに対応していないことについては、深夜帯にメンテナンスを設け、数十秒のサービス断であれば問題ないと判断しました。 2つ目のIaC化については、パフォーマンスを評価する負荷試験の実施前までにAWS CloudFormationがサポートされれば良いと考えました。そのため、開発期間中は手動で構築したAurora Serverless v2を活用することとしました。 3つ目のレイテンシーについては、負荷試験にてデータベースの観点でレイテンシー目標を達成できなければ、Provisioned型Aurora MySQLバージョン3に切り替えることとしました。 4つ目のコストについては、負荷試験後に概算し許容できるかを判断することとしました。 アーキテクチャ ZOZOTOWN会員基盤の大まかなアーキテクチャは下図の通りです。 アプリケーションはEKSで稼働し、Aurora Serverless v2はWriter Instance 1台、Reader Instance 1台のマルチAZ構成となります。 導入 方針で決めたことをもとに、導入に向けて対応したことや考慮した点を順に説明します。 Aurora Serverless v2を手動で構築 AWS CloudFormationでProvisioned型Aurora MySQLバージョン3を再構築 AWS CloudFormationでAurora Serverless v2に移行 負荷試験・障害試験 1. Aurora Serverless v2を手動で構築 方針決定後の時点でもAurora Serverless v2はAWS CloudFormationに対応していなかったため、 公式ドキュメント を参考に手動で構築しました。 2. AWS CloudFormationでProvisioned型Aurora MySQLバージョン3を再構築 負荷試験が始まる前、Aurora Serverless v2はAWS CloudFormationにまだ対応していませんでした。そのため、方針決定時の判断ポイントに従ってAWS CloudFormationでProvisioned型Aurora MySQLバージョン3を再構築しました。 この時、アプリケーション開発中でありデータを保持する必要があったため、新規で構築して切り替えるといったことができませんでした。そのため、mysqldumpでバックアップを取得し、AWS CloudFormationで構築したProvisioned型Aurora MySQLバージョン3にリストアを実施しました。 3. AWS CloudFormationでAurora Serverless v2に移行 Provisioned型Aurora MySQLバージョン3を再構築した同じタイミングで、 Aurora Serverless v2がAWS CloudFormationのサポートを開始 しました。負荷試験が始まる直前でしたが、このタイミングを逃したくないと考え、AWS CloudFormationでAurora Serverless v2へ移行することにしました。 基本的な移行の流れは、 公式ドキュメント に従ってAWS CloudFormationで作業していきます。Aurora Serverless v2への移行はフェイルオーバーで切り替えが必要なため、一時的に接続できなくなりますが、再構築は不要でアプリケーション開発には影響なく移行できました。 Aurora Serverless v2をAWS CloudFormationで定義する際の注意点としては、Aurora Serverless v1とは異なるパラメータがあることです。例えば、 ServerlessV2ScalingConfiguration などです。 AWS::RDS::DBCluster の「Amazon Aurora Serverless v2 DBクラスターの作成」をよく確認する必要があります。 4. 負荷試験・障害試験 移行したAurora Serverless v2について、負荷試験・障害試験の実施内容と結果を説明します。 負荷試験 データベース観点で負荷試験を実施することで、アプリケーションのレイテンシー目標を達成できるか確認しました。 具体的には、Aurora Serverless v2のMax ACUは64に固定し、Min ACUを2から16まで変動させ、Min ACUによってレイテンシーがどのように変化するのか確認します。商用を想定したリクエストを5分間流し続けることで負荷をかけます。 負荷をかけるツールは弊チームで開発したOSSツールであるGatling Operatorを使用します。詳細は以前TECH BLOGに公開された下記記事をご参照ください。 techblog.zozo.com 負荷試験の結果は下記グラフの通りです。 これより、Min ACUが小さいとレイテンシーは高くなり、徐々に大きくしていくとレイテンシーは低くなりますが、大きくしすぎてもレイテンシーはそれほど変わらないことがわかります。レイテンシー目標としてはMin ACUが3で達成できたため、この値を採用しています。 障害試験 障害試験では、アプリケーションにリクエストを流しながらフェイルオーバーした際のサービス断時間や、ACU変更時にサービス断が発生するか確認しました。 試験結果は以下の通りです。 確認項目 サービス断 フェイルオーバー 最大40秒 Min ACU変更 なし Max ACU変更(再起動時) 最大10秒 Max ACUの変更について補足すると、ACUの反映は即時ですが変更に伴うデータベースへの同時接続数などのパラメータを反映させるためには別途再起動が必要です。Reader Instanceのエンドポイントは現状使用していないため、Writer Instanceの再起動時のみ最大10秒のサービス断となりました。 運用としては1つずつInstanceを再起動可能ですが、再起動が含まれるフェイルオーバーで実施することにしています。これは、既存のProvisioned型Aurora MySQLではフェイルオーバーで実施しており、手順を統一させたいことが背景としてあります。緊急を要するようであればその時に応じて各Instanceの再起動を検討することとしています。 データベースへの同時接続数について触れましたが、Max ACUにより同時接続数が異なるため、考慮が必要です。その他含めACUのチューニングの詳細については公式ドキュメントの Aurora Serverless v2でのパフォーマンスとスケーリング をご確認ください。 導入により得られた効果 Aurora Serverless v2を導入することにより得られた効果について説明します。 柔軟なスケーリング 負荷試験・障害試験の完了後、オンプレミス環境のデータベースからAurora Serverless v2へのデータ移行を実施しました。 データ移行時のACUの推移は下記グラフの通りです。 青色の線がWriter Instance、紫色の線がReader Instanceです。 Aurora Serverless v2は約14ACUまでスケーリングし、エラーなくデータ移行を完了させることができました。データ移行完了後は即座に縮退されていました。 このように柔軟なオートスケーリングにより、データ移行時のスケールアップやスケールダウンを実施する工数を削減できました。 インフラコスト Aurora Serverless v2と、Provisioned型Aurora MySQLのインフラコストを比較し、どのような効果が得られたかを説明します。 まず、Aurora Serverless v2における3か月間のACUの大まかな推移は下記グラフの通りです。 商用環境ではスケールアップはほぼしない状況でした。一方で開発環境は必要な時にはスケールアップし、使用していない時には最小0.5ACUとなっており、柔軟にスケーリングしていることがわかります。 次に、3か月間使用したインスタンス利用料金の1か月平均をAurora Serverless v2とProvisioned型Aurora MySQLで比較した表は下記の通りです。 Aurora Serverless v2 Provisioned型Aurora MySQL 商用環境 USD 893 USD 781 開発環境 USD 1,400 USD 1,265 合計 USD 2,293 USD 2,046 Provisioned型Aurora MySQLはAurora Serverless v2でMaxにスケールしていたACUをインスタンスタイプに換算して試算しています。インスタンス利用料金の詳細は 公式ドキュメント をご確認ください。 以上を単純に比較すると、現在の会員基盤の利用状況ではAurora Serverless v2よりProvisioned型Aurora MySQLの方がインフラコストは抑えられたという結果でした。 ただ、Provisioned型Aurora MySQLの運用時は、スケールアップなどの運用コストを抑えるために余裕をもったインスタンスタイプを用意しています。このことを考慮すると、インフラコストにあまり有意な差はなかったと判断しています。 今後、会員基盤では追加機能の開発や既存機能のリプレイスが計画されています。その際に、都度スケールアップを行うような運用コストが抑えられているという点ではポジティブに捉えています。 最後に 本記事では、Aurora Serverless v2を本番導入するにあたってどのような検討をし、どのように導入していったか、また導入後に得られた効果について紹介しました。 Aurora Serverless v2導入時には手動構築からAWS CloudFormation管理に移行したことやACUのチューニングには苦労しましたが、無事に本番導入できました。 Aurora Serverless v2を導入することにより、データ移行の高負荷時には柔軟にスケーリングし、手動でスケールアップするといった運用負荷をなくすことができました。またインフラコストについては、総括すると会員基盤の現状のユースケースでは、Provisioned型Aurora MySQLと比較してあまり有意な差はありませんでした。しかし、運用コストが抑えられているという点ではポジティブに捉えました。 今後の会員基盤においては、追加機能の開発や既存機能のリプレイスに伴い、最適な基盤を模索していきたいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
アバター