TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

939

はじめに こんにちは。ZOZO Researchの千代です。 ZOZO Researchでは類似アイテム検索やおすすめアイテムのレコメンドといった機能開発の他に、様々な技術を用いたバックエンド業務の効率化にも取り組んでいます。 ZOZOTOWNのカスタマーサポートで実施しているワークフォースマネジメント(以下WFM)もその1つです。WFMで必要となるタスク割当て問題を数理最適化問題の一種である混合整数最適化問題として定式化し、最適なタスク割当てを計算しています。 この記事では、カスタマーサポートのWFMでの利用を例に、混合整数最適化でスケジューリング問題を定式化するテクニックについて説明します。 目次 はじめに 目次 ワークフォースマネジメントとは スケジューリング問題とは この記事の問題設定 スケジューリング問題を解くためのアプローチ 数理最適化問題とは 混合整数最適化問題とは 混合整数最適化問題でスケジューリング問題を扱うテクニック 準備 処理能力に関する制約 タスクの最低継続時間 タスク開始変数の導入 最低継続時間制約の表現 複数の目的関数への対応 多目的最適化 2段階最適化 まとめ おわりに ワークフォースマネジメントとは ワークフォースマネジメントとは、人的資源を適切に配置し、より効率的で高いパフォーマンスを発揮することを目指す取り組み全般を表す言葉です。 ZOZOTOWNのカスタマーサポートでは、サービスレベル向上のためのWFMの一環として、時間帯別の問い合わせ数の予測に基づいてスタッフのタスク割当てを求めるスケジューリング問題を解いています。 例えば朝は電話 1 による問い合わせが多いので、電話の対応に当たるスタッフを増やす、といった対応を計算により実施しています。 スケジューリング問題とは スケジューリング問題とは、人や機械といったリソースに対して仕事などのスケジュールを割当てる問題を指す言葉で、具体的な問題としてはシフトスケジューリング問題やジョブショップ・スケジューリング問題などが挙げられます。 この記事では以下のような問題を想定して説明していますが、使っているテクニックはスケジューリング問題全般で利用可能なものです。説明用の簡略化したモデルになっているため、実務で使っているモデルとは詳細が異なります。 この記事の問題設定 各スタッフの1日の時間帯ごとのタスクの割当てを考える問題です。 タスクとは各種チャネルの問い合わせへの対応やその他の個人タスク、休憩のいずれかを表します。 時間は15分のタイムスロットに区切って考えます。 以下のデータは入力として与えられるとします。 その日の出勤者および出勤時間帯(早番、遅番など) 各時間帯、各チャネルの問い合わせ数予測 各スタッフの実施可能なタスク 問い合わせ対応のタスクについては、各スタッフのタスクに対する処理能力 スケジューリング問題を解くためのアプローチ スケジューリング問題は多くの場合NP困難となるので、従来は、 重み付き制約充足問題として定式化し、CPソルバーを使って解く方法 タブーサーチなどメタヒューリスティクスアルゴリズムを使って解を求める方法 といった近似解法を用いたアプローチが一般的でした。しかし近年アルゴリズムやハードウェアの進歩により、「混合整数最適化問題として定式化し、厳密最適解やそれに準ずる解を求める方法」も選択肢として一般的になってきました。 本記事でも、混合整数最適化問題として扱うアプローチを紹介します。 数理最適化問題とは 今回使用する混合整数最適化問題は、数理最適化問題の1つのカテゴリです。混合整数最適化問題について説明する前に、まず数理最適化問題について簡単に紹介します。 数理最適化問題とは、条件を満たす候補の中から目的に対して最適なものを数学的に見つける問題です。数理最適化問題は次の3つの要素でできています。 決定変数:意思決定や制御の対象で、値を決めたいもの 目的関数:決定変数が目的に対して良いか悪いかを判断するための関数 制約条件:候補となる決定変数が満たす必要のある条件 制約条件を満たした上で、目的関数を最小化または最大化する決定変数を見つける問題が数理最適化問題です。 混合整数最適化問題とは 混合整数最適化問題とは数理最適化問題の中で、整数値を取る変数を含むもので、英語ではMixed Integer Programming (MIP)やMixed Integer Optimization (MIO)と呼ばれています。 整数変数を使うことで、割当てや順序のような組合せ的な問題が表現できるようになります。 今回は、混合整数最適化問題のなかでも目的関数と制約式に線形のものだけを含んだ、混合整数線形最適化問題のみを扱います。最近では線形でない制約式や目的関数の問題も解けるようになってきていますが、まだまだ解ける問題の規模は限定的で、混合整数最適化問題といえば多くの場合は混合整数線形最適化問題を指すのが一般的です。 混合整数(線形)最適化問題は例えばPythonのpulpというモデリングライブラリを使うと簡単に実装できます。問題を解くためには最適化ソルバーというソフトウェアが必要ですが、pulpをインストールするとCbcというOSSの最適化ソルバーが一緒にインストールされるため、そのまま計算を実行できます。 github.com projects.coin-or.org 混合整数最適化問題でスケジューリング問題を扱うテクニック ここからはWFMでのタスク割当て問題を例に、スケジューリング問題を混合整数最適化問題として定式化する際のテクニックをいくつか紹介していきます。 数式を使って説明していきますが、前述の通り変数の一次式しか登場しないので意味するところがわかれば非常に簡単です。また説明の簡潔さのため、一部厳密性を欠いた表現をしている箇所があります。ご了承ください。 以下では最適化問題の決定変数は小文字で、入力として与える定数は大文字で表すこととします。 準備 まず準備として、タスクの割当てを表現する変数を以下のように用意します。 : スタッフ が時刻 にタスク を行う時1、それ以外で0をとる0-1整数変数 各スタッフは勤務時間中には同時に1つのタスクを実行し、出勤前や退勤後はタスクを何も実行できません。これは以下のような制約式で表現できます。ただし、前述の通り休憩時間も1つのタスクとして扱っています。 ( が の勤務時間内の場合) ( が の勤務時間外の場合) この変数 を使って各種制約を表現していきます。 処理能力に関する制約 問い合わせ数の予測に対して、すべての問い合わせをまかなう制約を考えます。これはある時刻に稼働している全スタッフの、その問い合わせに対応するタスクの処理能力の合計が、予測された問い合わせ数を上回っているという制約で表現できます。 スタッフ のタスク についての処理能力を 、時刻 のタスク の業務量の予測を とすると、次のような式になります。 (全ての について) この制約式は、全ての時間帯で必ずリソースが問い合わせ予測を上回ることを求めています。しかし現実的には、問い合わせ量の瞬間的な増大などあらゆる事態に対応できるようなリソースを常に確保しておくことは困難です。 上記の制約式のままでは全ての時間帯、問い合わせチャネルで予測量を上回るリソースを用意していなければ最適化問題が実行不可能となり、モデルとして使い勝手がよくありません。そのためこの制約を変更します。新しく0以上の実数値を取る変数として、 : 時刻 にタスク で不足する処理能力を表すペナルティ変数 を用意します。この変数を使って上の制約を以下のように変更します。 (全ての について) また制約式の変更だけでなく、ペナルティ変数の総和 2 を最小化するように目的関数を変更します。これによりもし全ての問い合わせに対応できない状況であっても、対応できない問い合わせの量を最小化する問題として計算でき、常に解が得られるようになります。 常に解が得られることで、例えば対応できない問い合わせの数が多すぎる時に、事前にリソースの調整をするといった対応を行えます。 タスクの最低継続時間 実際のオペレーションでは、割当てられたタスクが頻繁に変わるような運用は好ましくありません。そのため、各タスクを開始したら最低45分間は同じタスクを継続するといった制約を追加したい場合があります。このようなタスクの最低継続時間制約を実現する方法を紹介します。 タスク開始変数の導入 スタッフのタスクの継続時間をはかるために、次のような変数を新たに追加します。 : スタッフ が時刻 にタスク を開始したら1、そうでない時0 変数 と の関係は、一例を挙げると以下の図のようになります。 図のように、 は が0で、 が1のときだけ1をとり、他は0をとる変数です。この関係は以下のような制約を追加することで表現できます。 制約1: 3 制約2: 制約3: この制約によって表される の値の候補を書き出すと次の表のようになり、タスク開始変数が表現できていることがわかります。 最低継続時間制約の表現 前節で定義したタスク開始変数を用いれば、タスクの最低継続時間の制約を簡単に表現できます。例えば一度タスクを開始したら最低3スロット以上継続するという制約を表したい場合は次のようになります。 またこのテクニックを応用することで、例えば「1回目の休憩と2回目の休憩の間は2時間以上の間隔をあける」といった制約も表現できます。 複数の目的関数への対応 WFMのタスク割当てモデルでは、処理能力不足のペナルティを最小化することを目的関数としていました。しかし新型コロナウイルス感染症の影響で、休憩室が密になることを回避するために、同時に休憩を取る人数をできるだけ少なくしたいという要望がでてきました。 同時に休憩を取る人数をできるだけ少なくするという問題は、同時に休憩を取る人数の1日の中での最大値を最小化する問題として表現できます。 多目的最適化 「処理能力不足のペナルティ最小化」と「同時休憩人数最小化」のような複数の目的関数を持った最適化問題は多目的最適化問題と呼ばれています。 多目的最適化問題に対する対処としては、重みをかけて足し合わせ1つの目的関数にする方法がシンプルで一般的ですが、今回のように単位の全く異なる値同士の時は重みのパラメータを決めるのがなかなか難しいです。 ここでは別の方法として2段階最適化というものを紹介します。 2段階最適化 2段階最適化は、優先順位の明確な2つの目的関数について、段階的に最適化問題を解いて最適解を求める方法です。タスク割当ての例で、「同時休憩人数最小化」が第1優先、「処理能力不足のペナルティ」が第2優先という設定を仮定して方法を説明します。 「同時休憩人数最小化」のために、同時休憩人数の最大値を最小化する問題として最適化問題を解きます。この時「処理能力不足のペナルティ最小化」については考慮しません。 1.で求められた最適値(最小な同時休憩人数の最大値)を使って、同時休憩人数がその値以下になるという制約を追加し、「処理能力不足のペナルティ最小化」を目的関数として再度最適化問題を解きます。 この順番で問題を解くことによって、同時に休憩を取る人数を最小にした上で、処理能力不足のペナルティを極力小さくできます。 まとめ この記事ではカスタマーサポートでのタスク割当問題を例に、混合整数最適化問題でスケジューリング問題を扱うときのテクニックとして、 処理能力不足のペナルティ最小化 タスク最低継続時間制約 2段階最適化 を紹介しました。 混合整数最適化問題は不等式制約や整数変数をうまく使うことで、様々な種類の問題を定式化することが可能です。 おわりに ZOZOテクノロジーズでは技術の力で事業に貢献してくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com 新型コロナウイルス感染症の影響で、ZOZOTOWNカスタマーサポートの電話窓口は現在受付停止しています。 ↩ ペナルティという値の性質上2乗和をとりたくなる時がしばしばありますが、混合整数線形最適化では2乗和は扱えません。区分線形関数を使うと線形モデルのままで近似できますが、変数の数が増えモデルは複雑になります。 ↩ 目的関数や他の制約次第では制約1だけで表現できる場合もあります。例えばタスクの切り替え回数(タスク開始変数の和)の最小化など、 を最小化するような項が目的関数に入っているときは、制約1だけで十分です。 ↩
アバター
こんにちは。ZOZOテクノロジーズZOZOTOWN部 検索チーム 兼 ECプラットフォーム部 検索基盤チームの有村です。 ZOZOTOWNでは社内勉強会が盛んに行われており、部単位・役職単位・チーム単位・有志の集まりなど様々な単位、様々なテーマで日々開催されています。本記事では今年度上期を通して行ったZOZOTOWN部バックエンドの勉強会を振り返り、より参加者のモチベーションをあげるために設定した目標値や達成のために行ったことについて紹介致します。 この記事は ZOZOテクノロジーズ Advent Calender 2020 #2 の1日目の記事です。 ZOZOTOWN部バックエンド勉強会の歴史 現在ZOZOTOWN部バックエンドで主催している勉強会は2018年度から開催され始めたもので、今年度で3年目の会となります。その会では、これまでは各チームで所有しているノウハウを共有するためのLT大会や、そのノウハウを社内で留めずアウトプットを行うため、記事の投稿を行っていました。アウトプットに関しては、特にテーマに決まりは無く、業務で取り組んだ案件から学んだことや前職での経験、個人的に勉強していることなど思い思いのテーマで行っています。 本勉強会では、現在以下の3点の理由から執筆した記事のアウトプット先としてQiitaを利用しています。 アカウント開設・Organizationへの紐づけが容易である テックブログに比べて、幅広い内容でライトに投稿できる 記事へのフィードバックがLGTM、コメントといった形でもらえるため、モチベーションにつながる 3年目である今年度もアウトプットを文化として根付かせ、さらに強化していくためにQiitaへの投稿を継続しています。 qiita.com コロナ禍での勉強会運営 本記事で紹介した勉強会ですが、開催年月を重ねるごとに参加者が増え、現在ではZOZOTOWN部バックエンドの5チーム計30人以上が参加する規模の勉強会となりました。勉強会自体も昨年度までは全てオフラインで開催されていましたが、昨今のコロナ事情により弊社では原則的に在宅勤務となっており、オンラインでの開催が必須な状況となりました。 弊社ではオンラインのミーティングツールとして、主にGoogle MeetとWebex Teamsを利用しています。両方のツールに一長一短ありましたが、ディスカッションを行うにあたりグループ単位での作業が発生することから、チームの下にグループの概念を持つことができるWebex Teamsを採用しました。 Google Meet Webex Teams インストール 不要 必要 体感パフォーマンス 〇 △ ディスカッションに特化した機能(ブレイクアウトルーム) × △(2020年夏のアップデートで登場、チームとスペースで代用可) 社内での利用浸透率(4月時点) △ 〇 アウトプットを強化するための取り組み 上述した通り、今年度の取り組みは昨年度から継続してアウトプット強化を目的としていますが、実際に書くだけでなくそれをサポートするようなステップを設けています。 昨年度から行ってきた具体的な取り組みとして、全員で記事にする内容の発案・深堀をするネタ出し会、公開前に内部で限定公開状態で意見をしあうレビュー会があります。 今年度からは上記に加えて、新たにスパイ会という取り組みを開始した上で、より求められる記事を書くには何が重要なのかを調査しました。参加人数が多いため、5〜6人毎のグループを作成し、基本的にディスカッションはその単位で行っています。 スパイ会 アウトプットをする上でのモチベーションの拠り所は個人差があると思いますが、公開したものに対する反響が気になるという点は多くの方に共通しているかと思います。今年度の勉強会ではこの反響を数値で観測し、 反響の数字が大きい記事 = 求められている記事 と仮定した上で、いかにしてその数値を伸ばしていくかを調査(スパイ)しました。 調査する上でやはり参考になるのは既に公開された上で反響のあった記事達です。今回利用したQiita上ではLGTMがわかりやすく反響の指標として採用できそうであったため、この数字が大きい記事や小さい記事にはそれぞれどのような共通点があるかディスカッションを行いました。 また、 Organization に紐づくアカウントより公開された記事は一定の質が担保されているであろう、という仮定のもと調査対象を限定しました。 各グループで調査を進めた後は、以下のような形式でまとめ共有を行いました。 調査結果 調査の結果、LGTMが多くついている記事の多くには共通点があり、またその逆に少ない記事の多くにも共通点がありました。 LGTMが多い記事の共通点 タイトルがキャッチーである 見出しが適切に設定してあり、知りたい情報に手早くアクセスできる 実行に必要なコマンドやスクリーンショットが添付してあり、再現性が高い 新鮮なネタへのキャッチアップが早い LGTMが少ない記事の共通点 記事の意図が組み切れないほどの短いメモ書きである タイトルと内容のミスマッチ、タイトルから内容が推測できない 適切なチャプター分けがされておらず、冗長である ボリュームが極端に多すぎる 書き出してみると当たり前に思えることが大半ですが、LGTMが少ない記事の共通点には、いざ自分で書こうとする際にもおろそかになりがちな点や、やりがちな点が多く挙がっている様子が見て取れます。 一方、LGTMが多い記事の共通点の多くは、どんな内容の記事でも意識することで改善可能な点が大半となっており、読者を想定して記事を書くことの重要性を改めて確認できました。 ネタ出し会 ネタ出し会の大まかな流れは以下の通りです。 アウトプットしてみたいお題を、これまで経験したことや気になっている技術などから数個引き出してみる グループ内で気になるお題を掘り下げ、1つにお題を絞る お題について深堀し、概要レベルまで落とし込む 勉強会の参加者は先述した通り30名ほどで、その中でもアウトプットの経験に関してメンバー間でばらつきがあるため、そのばらつきを吸収・サポートする意味で1.の手順を踏んでいます。 また、上記のスパイ会で得た知見を基に、お互いのアウトプットをどのようにしてブラッシュアップするかについても議論するため3.の場を設けました。 レビュー会 上記のネタ出し会が終わった後、各々執筆した記事を持ち寄りレビュー会を行います。とはいえ全員が全員の記事をレビューするのは現実的でないため、執筆者1人に対して1人レビュアーをアサインし、レビュー会の前に事前確認を行います。 全体で行うレビュー会では書いた記事をもとに執筆者が発表を行い、その内容に対してレビュアーが中心となって質問やコメントを行います。Organizationに紐づく記事を公開することになるため、このレビュー会を通して誤った情報の発信を未然に防ぐ役割も担っています。 このレビュー会が終了した後に、各執筆者は記事を公開します。 半期勉強会を運営行った結果 上述した施策を交えつつ、バックエンドメンバー全員の記事公開が無事完了しましたので、昨年度行われていた勉強会との比較を行いました。 昨年度 今年度 記事本数 21 26 平均LGTM数 16.96 40.67 記事本数が増えたことに関しては単純に参加メンバーが年々増加していることもありますが、LGTM数の推移からより求められる記事を公開できている事がわかります。また、それを示すように毎週トレンドに掲載され、社内Slackでも話題となっていました。 勉強会終了後のアンケート結果 半期に渡る勉強会の終了後にとったアンケート結果の一部を紹介します。 Q. アウトプットに対するハードルは下がりましたか? Q. 取り組みの中で最も役に立ったと感じたパートはどれですか? Q. 今回の勉強会を通して新たに得た知見は何ですか? 外部への情報公開って大事だなと改めて感じました タイトルが曖昧だったり、自分の備忘録的なものは伸びにくい タイトルや目次をわかりやすくすることの大事さ Q. 次回以降に改善してほしい点は何ですか? 執筆後、記事の反応を見て「これはやってよかった」「また反応が微妙だった」などを共有して残す作業があっても良かった スパイ会が記事の深堀りから議論まで20分で行ったが時間が足りなかった マネジメント系の記事を書きたい人はQiitaではなくnoteとかでもありだと思った 大人数だと発言しづらそう アンケート結果から、オンラインかつ大人数の勉強会ならではの課題点や、メンバーのアウトプットしたい内容がガイドラインによって制限されるケースなどが見えてきました。一方、今回から新たに行った取り組みであるスパイ会や、技術的なアウトプットを行うことについてはかなり前向きな意見が多かったです。実際に、普段の開発において新たな知見を得た際、自然とアウトプットに持っていこうとする会話を見かけることが増えました。 以前、弊社CTOの今村が公開した記事にもある通り、アウトプットには会社・個人双方に様々なメリットをもたらします。まだまだ発展途上な勉強会ですが、これからもメンバーのアウトプットを最大化できるよう、より改善を加えながら引き続き開催していきます。 techblog.zozo.com 最後に 本記事ではZOZOTOWN部で行われている勉強会の事例と、その中で行われている取り組みについて紹介しました。 最後に、ZOZOテクノロジーズでは本取り組みを行っているZOZOTOWNのバックエンドエンジニア含め、様々な職種で募集を行っています。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは。ZOZOテクノロジーズSRE部の西郷です。普段はAWSを用いてマルチサイズプラットフォーム事業(以降MSPと記載します)のシステム構築や運用に携わっています。 このMSPのシステムではRDBにAmazon Aurora PostgreSQLを採用しています。DBを含むネットワークは全てCloudFormationで管理しており、変更は原則テンプレート修正にて行っています。 さて、このAmazon Auroraは定期的なバージョンアップが発生します。この対応についてもテンプレートを更新して行うのですが、組み合わせの悪い部分があり、都度対応を検討してきました。 その問題について、CloudFormationのResource Importを用いることできれいに解決できたため、事例としてご紹介します。 MSPとその生産を支えるインフラ まずは MSP について少し触れておきます。MSPは ZOZOTOWN 上で展開しているサービスです。欲しい商品を選び、身長と体重を選択すると、体型にあったサイズをレコメンドします。対象商品はZOZOTOWNに出店いただいているブランド様と共同で企画・生産を行っています。 MSPの生産を支える取り組みについては、以下のテックブログで詳しく取り上げられていますので、ぜひ御覧ください。 techblog.zozo.com techblog.zozo.com techblog.zozo.com 弊チームで構築・運用しているシステムでは主に発注・生産部分を支える機能を提供し、AWS上はこのような構成になっています。 受発注情報の取得や登録、生産ステータスや納品データの登録といった機能を提供しており、Auroraにはこれらに関する重要なデータが保管されています。 CloudFormationで管理するAuroraのバージョンアップ上の課題 冒頭でも述べた通り、定期的にAWSからバージョンアップがアナウンスされるのですが、その際の対応方法は次の2択です。 定められた期限内に運用者が任意のタイミングで行う 対応を行わず期限後のメンテナンスウィンドウで行われる自動更新に任せる しかし、以下のような課題から任意のタイミングで行うのが一般的かと思います。 DBエンジンのバージョンアップはDBインスタンスの一時的な停止を伴うものである 適用されているパッチにより、アプリケーションで予期せぬ不具合に遭遇する可能性がある 弊チームでは、まずCloudFormationからそのままバージョンアップを行うことを検討しました。ですが、その場合DBクラスタとインスタンスが再作成されてしまい、DB内のデータが失われてしまいます。 具体的にはテンプレート上で EngineVersion というプロパティの値を変更しスタックを更新することになるのですが、 公式ドキュメント を確認すると、 Update requires: Replacement と記載されています。 RDSDBCluster : Type : 'AWS::RDS::DBCluster' Properties : # --------- omit Engine : 'aurora-postgresql' EngineVersion : '10.7' #ここを変更する # --------- omit Update requires は、AWSリソースに変更を加えた際にどのような更新が行われるのかを示すものです。 Replacement はリソースを再作成して古いリソースと置き換える、いわゆる置換が発生する更新方法です。MSP対応商品の生産や納品に関わる重要なデータが保管されているため、この方法でバージョンアップすることはできません。 従来のバージョンアップ手法の課題と今回実現したかったこと この悩みに対して弊チームではこれまで以下の方法によるバージョンアップを検討・実施してきました。 アプローチ メリット デメリット A.Webコンソールからバージョンアップを行う 手順がシンプル、作業時間が短い テンプレートで定義しているDBエンジンバージョンと実際のDBエンジンバージョンが一致しない B.スナップショットを利用してスタックで新しいバージョンのDBクラスタ&インスタンスを新規作成する スタックで認識しているDBエンジンバージョンと実際のDBエンジンバージョンが一致する DBクラスタのエンドポイントが変わる、データの整合性を取るためにアプリケーションを停止しスナップショットを取る必要があるので作業時間が長くなる CloudFormationで全てのAWSリソースを管理している環境においてはテンプレート上の不一致の方が許容しがたい部分だったため、前回はB案で対応を行いました。 とはいえB案の場合はDBクラスタのエンドポイントが変わることになり、できることなら既存のDBクラスタを維持したままバージョンアップできないかと考えていました。 そのため、今回のバージョンアップで実現したかったことをまとめると以下の要件にまとまりました。 既存のDBクラスタとインスタンスを維持したままバージョンアップしたい(エンドポイントも変わらない) テンプレートで定義しているDBエンジンバージョンと実際のDBエンジンバージョンが一致する状態にしたい CloudFormationのResource Import そこで利用したのが Resource Import です。 2019年11月にリリースされた機能で、WebコンソールやCLIから作成されたAWSリソースをスタックに取り込むことができます。新規スタックとしてAWSリソースをインポートすることも可能ですが、既存スタックへのインポートも可能です。 さて、このインポートを行う際はテンプレートにDeletionPolicy属性の記述が必要です。これはCloudFormationのリソース属性の1つで、スタックが削除される際にそのスタックで管理されているAWSリソースの扱いを定義するもので、インポートを行う際は保持する(Retain)、という指定が必要になります。 そのため、手動で作成されたAWSリソースをスタックに取り込む、という使い方はもちろんなのですが、以下のようなシーンへの活用も可能です。 1つのテンプレートで管理していたが、状況が変わりテンプレートを分割したい テンプレートから行うと Replace 扱いになってしまってできない変更をWebコンソールから行い、テンプレートとの定義差分をなくしたい いずれも一度AWSリソースをスタックから削除し、既存or新規のスタックに取り込むことで実現できます。 今回はテンプレートから行うと Replace 扱いになってしまってできないDBエンジンのバージョンアップをWebコンソールから行い、その場合発生するテンプレート上の不一致をResource Importで解消できることから、これを使ってバージョンアップを行うに至りました。 実際に行ったバージョンアップ手順 今回行った作業を図にするとこのような流れになります。 また、変更を加えていくテンプレートは以下のようなものです。 AWSTemplateFormatVersion : 2010-09-09 Resources : RDSDBCluster : Type : 'AWS::RDS::DBCluster' DeletionPolicy : 'Delete' Properties : # --------- omit Engine : 'aurora-postgresql' EngineVersion : '10.7' # --------- omit RDSDBInstance : Type : 'AWS::RDS::DBInstance' DeletionPolicy : 'Snapshot' Properties : # --------- omit AllowMajorVersionUpgrade : false AutoMinorVersionUpgrade : false DBClusterIdentifier : !Ref RDSDBClusterApplication DBInstanceClass : 'db.r4.large' Engine : 'aurora-postgresql' # --------- omit SSMParameterDBClusterEndpoint : Type : 'AWS::SSM::Parameter' Properties : Name : '/postgres_host' Type : 'String' Value : !GetAtt RDSDBCluster.Endpoint.Address 以降は実際に本番環境で行った手順についてまとめていきます。なお、ここで記述する作業はあらかじめ接続するサーバを全て停止、バージョンアップ対象のDBインスタンスのスナップショットを取得した上で行っています。 STEP1:バージョンアップ対象のDBクラスタ、インスタンスに対してDeletionPolicy属性をRetainで設定する まずはDBクラスタとインスタンスをスタックから削除した際にDBクラスタとインスタンスがAWS上に残るようにする必要があります。 テンプレートにて先述のDeletionPolicy属性を Retain にします。 AWSTemplateFormatVersion : 2010-09-09 Resources : RDSDBCluster : Type : 'AWS::RDS::DBCluster' DeletionPolicy : 'Retain' #[STEP1]Retainで指定する Properties : # --------- omit Engine : 'aurora-postgresql' EngineVersion : '10.7' # --------- omit RDSDBInstance : Type : 'AWS::RDS::DBInstance' DeletionPolicy : 'Retain' #[STEP1]Retainで指定する Properties : # --------- omit AllowMajorVersionUpgrade : false AutoMinorVersionUpgrade : false DBClusterIdentifier : !Ref RDSDBClusterApplication DBInstanceClass : 'db.r4.large' Engine : 'aurora-postgresql' # --------- omit このテンプレートで変更セットを作成して差分を確認するのですが、DeletionPolicy属性の変更は差分として検出されません。そのため変更セットを実行し、イベントでバージョンアップ対象のDBクラスタとインスタンスが UPDATE_COMPLETE と記録されることを確認しました。 STEP2:別のリソースから参照している箇所を変更する このままDBクラスタを削除するとDBクラスタのエンドポイントを参照している箇所は参照先のリソースがなくなるため、スタックの更新に失敗します。 そのため、以下のようにコメントアウトする等何らかの形で参照しないようテンプレートを変更します。 AWSTemplateFormatVersion : 2010-09-09 Resources : # --------- omit #[STEP2]参照しないようにする # SSMParameterDBClusterEndpoint: # Type: 'AWS::SSM::Parameter' # Properties: # Name: '/postgres_host' # Type: 'String' # Value: !GetAtt RDSDBCluster.Endpoint.Address STEP3:スタックからバージョンアップ対象のDBクラスタ、インスタンスを削除する この作業でDBクラスタとインスタンスをスタックの管理下から外します。 バージョンアップ対象のDBクラスタとインスタンスの記述をコメントアウトしたテンプレートで変更セットを作成し、反映します。 AWSTemplateFormatVersion : 2010-09-09 Resources : #[STEP3]DBクラスタとインスタンスを削除する # RDSDBCluster: # Type: 'AWS::RDS::DBCluster' # DeletionPolicy: 'Retain' #[STEP1]Retainで指定する # Properties: # # --------- omit # Engine: 'aurora-postgresql' # EngineVersion: '10.7' # # --------- omit # RDSDBInstance: # Type: 'AWS::RDS::DBInstance' # DeletionPolicy: 'Retain' #[STEP1]Retainで指定する # Properties: # # --------- omit # AllowMajorVersionUpgrade: false # AutoMinorVersionUpgrade: false # DBClusterIdentifier: !Ref RDSDBClusterApplication # DBInstanceClass: 'db.r4.large' # Engine: 'aurora-postgresql' # # --------- omit #[STEP2]参照しないようにする # SSMParameterDBClusterEndpoint: # Type: 'AWS::SSM::Parameter' # Properties: # Name: '/postgres_host' # Type: 'String' # Value: !GetAtt RDSDBCluster.Endpoint.Address 注意点としては、変更セットの差分にはDBクラスタとインスタンスが Remove というアクションで検知されることが挙げられます。 実際に更新を行うとイベント上は DELETE_SKIPPED と記録され、DBクラスタとインスタンスは削除されずに残ります。 DeletionPolicy属性が Retain で設定されていない場合、ここでDBクラスタとインスタンスが実際に削除されてしまうため、注意深く行う必要があります。 現在のCloudFormationには、論理ID毎の設定済みDeletionPolicyを確認する方法がありません。反映済みテンプレートを目視確認することは可能ですが、それだけでは不安です。我々は本番環境と同じテンプレートから作られた事前環境を持っているので、そこで入念に動作を確認しました。 STEP4:WebコンソールからDBエンジンのバージョンをアップデートする スタックの管理外になったところで、対象のDBクラスタを選択し、希望のエンジンバージョンにアップデートします。 当然ながら、変更のスケジューリングは「今すぐ」を選択して変更を行い、DBクラスタのステータスが 利用可能 になることを確認しました。 STEP5:バージョンアップしたDBクラスタとインスタンスをResource Importでスタックに取り込む バージョンアップしたDBクラスタとインスタンスを再度スタック管理下に置くため、テンプレートを以下のように変更します。DBのエンジンバージョンはこのタイミングでアップデートしたものに変更しておきます。 AWSTemplateFormatVersion : 2010-09-09 Resources : #[STEP4]DBクラスタとインスタンスのコメントアウトを戻す RDSDBCluster : Type : 'AWS::RDS::DBCluster' DeletionPolicy : 'Retain' #[STEP1]Retainで指定する Properties : # --------- omit Engine : 'aurora-postgresql' EngineVersion : '10.13' #[STEP4]アップグレードしたバージョンを指定する # --------- omit RDSDBInstance : Type : 'AWS::RDS::DBInstance' DeletionPolicy : 'Retain' #[STEP1]Retainで指定する Properties : # --------- omit AllowMajorVersionUpgrade : false AutoMinorVersionUpgrade : false DBClusterIdentifier : !Ref RDSDBClusterApplication DBInstanceClass : 'db.r4.large' Engine : 'aurora-postgresql' # --------- omit #[STEP2]参照しないようにする # SSMParameterDBClusterEndpoint: # Type: 'AWS::SSM::Parameter' # Properties: # Name: '/postgres_host' # Type: 'String' # Value: !GetAtt RDSDBCluster.Endpoint.Address 今回は既存のスタックにインポートしたかったため、 該当スタック > スタックアクション > スタックへのリソースのインポート で操作を行いました。以下のようにDBクラスタとインスタンスの識別子を指定することでインポートが可能です。 この際、テンプレートにインポート対象のDBクラスタやインスタンス以外の変更があると以下のようなエラーになります。 Update, create or delete operations cannot be executed during import operations. そのため、STEP2で行った変更を元に戻す作業は次のSTEPで対応しました。 STEP6:参照している箇所を戻す テンプレートを以下のように修正の上、変更セットを作成し、反映を行って参照する状態に戻します。 AWSTemplateFormatVersion : 2010-09-09 Resources : # --------- omit #[STEP6]STEP2でコメントアウトしていたのを戻す SSMParameterDBClusterEndpoint : Type : 'AWS::SSM::Parameter' Properties : Name : '/postgres_host' Type : 'String' Value : !GetAtt RDSDBCluster.Endpoint.Address スタックの更新が UPDATE_COMPLETE になることを確認した上でAuroraに接続するサーバを起動し、動作確認を行いました。 Resource Importの良さと利用する際の注意点 Resource Importを利用することでDBクラスタのリソースを維持したままテンプレートと実際のDBエンジンバージョンの不一致を解消できました。テンプレートからそのまま行うと置換が必要になってしまう変更に対して、置換を回避できるアプローチがあることは、CloudFormationでAWSリソースを管理する環境において非常に有用だと感じました。 一方、今回のようにResource Importを利用する際の注意点だと感じたことは以下の点です。 インポートと同時に新規作成や更新、削除といった変更を行うことはできない 変更を加えたい場合はインポート後にスタックを更新する 削除対象を参照している箇所がある場合は事前にテンプレートを編集し依存関係を解消しておく必要がある Import対象のリソースによっては、依存が多く修正範囲が広がる場合がある 全てのAWSリソースをImportできるわけではないため意図通りにImportできるか事前によく確認する必要がある DeletionPolicy属性の設定・スタックから削除・取り込みを実際に試してみるのが望ましい Importできるリソースは 公式ドキュメント に記載されている Resource Importを利用する際の注意点というわけではないのですが、DeletionPolicy属性の変更は変更セット作成時に差分として検知されません。こちらも頭の片隅においておくと良いかと思います。 まとめ CloudFormationでAWSリソースを管理していると、置換が発生する変更を行いたい、テンプレートを分割したいといったシーンは比較的よくある悩みかと思います。このような"やりたいけどできなかった"ことを解決できたことは非常に有益であり、今後もより運用しやすい環境になっていくのではないか、という期待が持てました。 ZOZOテクノロジーズでは、ZOZOMATやWEAR、MSPといった事業をテクノロジーで支えるさまざまな職種を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに こんにちは。WEAR部の鈴木( @zukkey59 )です。 普段は、 「ファッションコーディネートアプリ WEAR」 のAndroidアプリを担当しています。 実は最近、コツコツとやっていたリプレイスがおわり、AndroidアプリのBottomNavigation化がリリースされました! 今回は、ドロワーメニューからBottomNavigationへリプレイスした際に悩んだFragmentの状態保存について、紹介します。 背景 今までのWEARのAndroidアプリは、iOSアプリと異なりドロワーメニューという古いUIのままだったため、BottomNavigationでの実装を行うことにしました。 実装を進めていると、BottomNavigationの項目の切り替えを行うことでリストのデータやスクロールの位置が保存されない現象に遭遇しました。 BottomNavigationの項目切り替えでもデータやスクロールの位置の状態が保存される 要件を満たすために、BottomNavigationで実現可能か調査することにしました。 調査した結果、2020年11月時点では、WEARで使用しているNavigationライブラリにはマルチバックスタックの仕組みが存在しないとissue trackerに記載されており、BottomNavigationの項目を切り替えた際、Fragment自体が作り直されることが原因で状態保存されていないと判明しました。 参考: Support multiple back stacks for Bottom tab navigation 公式サンプルの Navigation Extensions が要件を満たすことができるため、その導入を解決策としました。 まずは公式サンプルの中でやっていることをざっくりとまとめて、状態保存について紹介します。 公式サンプルの実装を読む サンプルの中でやっていることを大きくまとめると、5つのステップに分けられます。 BottomNavigationの項目ごとにNavHostFragmentの存在チェックを行い、初めての場合は作成する BottomNavigationで選択状態に応じて、attachとdetachを行う バック時の挙動を修正する 再選択時の挙動をBottomNavigationのリスナーに合わせて実装する DeepLinkの挙動を追加する 状態保存に関してこれらの中でも特に重要なのが、次の2点です。 ActivityがFragmentManagerにBottomNavigationの項目ごとにFragmentを追加し、Fragmentの状態を保持する点 選択状態に応じてattachとdetachを繰り返すようにするという点 まず、 Navigation Extensions のobtainNavHostFragmentをみていきましょう。 private fun obtainNavHostFragment( fragmentManager: FragmentManager, fragmentTag: String, navGraphId: Int , containerId: Int ): NavHostFragment { // 指定のfragmentTagを持つFragmentがあるかをチェック val existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment? existingFragment?.let { return it } // ない場合は新しくNavHostFragmentを作成 val navHostFragment = NavHostFragment.create(navGraphId) // FragmentManagerに追加 fragmentManager.beginTransaction() .add(containerId, navHostFragment, fragmentTag) .commitNow() return navHostFragment } ここではまずActivityが持つFragmentManager内に、BottomNavigationの項目ごとに追加されているFragmentTagがあるかチェックを行います。ない場合は新しくNavHostFragmentを追加します。 次に、setupWithNavControllerのコードをみていきましょう。以降、ソースコード中の「...」は省略を意味します。 fun BottomNavigationView.setupWithNavController( navGraphIds: List< Int >, fragmentManager: FragmentManager, containerId: Int , intent: Intent ): LiveData<NavController> { ... navGraphIds.forEachIndexed { index, navGraphId -> ... if ( this .selectedItemId == graphId) { selectedNavController.value = navHostFragment.navController attachNavHostFragment(fragmentManager, navHostFragment, index == 0 ) } else { detachNavHostFragment(fragmentManager, navHostFragment) } } ... } BottomNavigationで選択された項目のIdとnavigationGraphのIdの比較によってattachとdetachを行っています。 attachNavHostFragmentとdetachNavHostFragmentの実装についてもみていきましょう。 private fun attachNavHostFragment( fragmentManager: FragmentManager, navHostFragment: NavHostFragment, isPrimaryNavFragment: Boolean ) { fragmentManager.beginTransaction() .attach(navHostFragment) .apply { ... } .commitNow() } private fun detachNavHostFragment( fragmentManager: FragmentManager, navHostFragment: NavHostFragment ) { fragmentManager.beginTransaction() .detach(navHostFragment) .commitNow() } attachNavHostFragmentでは、 attach という関数を呼ぶことで、前にUIからdetachされたFragmentを再度attachし、ビュー階層が再作成されて表示されます。 detachNavHostFragmentでは、 detach という関数を呼ぶことで、指定されたFragmentをUIから切り離し、バックスタックに配置された時と同じ状態にし、ビュー階層は破棄されます。 attachとdetachをする時の Fragmentのライフサイクル の流れは、バックスタックに置いた状態と同じになるため、onCreateViewからonDestroyViewまで呼ばれることになります。 つまり、状態保存を実現するため内部的にやっていることは次の通りです。 インスタンスを最初に作成したあとに、同じインスタンスのUIの状態を保存してバックスタックに置いた状態にし、Viewの再生成から破棄までを実行する これが状態保存を行うための基本的な考え方になります。 プロダクトに当てはめた時にいくつか出てきた課題 単純な遷移で、特に通信を元にしない表示だけをするのであれば、このままでも問題ありません。 しかし、実際のプロダクトに当てはめた際には、いくつか満たしたい仕様があります。 具体的には、WEARでは下記の仕様が満たさなければなりません。 PagerのTabLayoutタブを再選択した時に一番上までスクロールを行う Pagerが持つFragment全て、タイミングに応じて全更新を行う BottomNavigation化で、仕様を満たすために次の課題が出てきました。 初期化処理、イベントのobserveなどのタイミングについての考慮すること 密結合になっているクラスを疎にして、役割を明確にすること 1.に関しては、BottomNavigation化前は状態保存の仕様がなかったため、タイミングを考慮する必要はありませんでした。しかし、BottomNavigation化後は、Viewの生成時、最後に発行されたイベントの値がobserveのタイミングで即時に流れてくるような場合、切り替えのたびにobserve処理が実行されてしまい、イベントが流れるという意図しない挙動が発生しました。 2.に関しては、密結合になったクラスが多数存在し、役割が曖昧になっていました。例えばViewを作り直す場合、それが原因でAPIを呼び出してデータを取得する処理が密になっているため、1.のタイミングの考慮だけでは解決できませんでした。 これらの課題を解決するために、次のような対応を行いました。 公式サンプルの考え方を元に、状態保存を実現するための対応方針 まずはじめに、密結合になっているクラスを分離するために、アーキテクチャの導入を行い役割を明確にすることを行いました。 今回リプレイスしたことで、BottomNavigationの各FragmentとメインのActivityは次のように変わりました。 リプレイス後の現状のWEAR Androidのそれぞれの役割については次の通りです。 View(etc: Activity / Fragment) Viewに関わる操作を担います。AdapterやViewHolderもこちらに入ります。 UIの操作を受けて、ViewModelにイベントを流す役割を持ちます。 ViewModel Viewのデータを保持し、UIから受け取ったイベントに応じて、UIに必要な情報をLiveDataで渡します。 データ処理のビジネスロジックを含んでいます。 UseCase アプリケーション固有のビジネスロジックを書く場所としています。 Repository DBアクセスとAPI通信を担い、データの変換を行う役割を持ちます。 現状のWEARに最適なアーキテクチャは何かをチーム内で相談して決め、それを導入することでViewとビジネスロジックの分離を行うことができ、最終的に密結合の問題を解決することができました。 次に、初期化処理やイベントのobserveのタイミングについて説明します。 「公式サンプルの実装を読む」で説明した状態保存の根本的な考え方は、attachとdetachによってUIの状態が保存され、onCreateViewからonDestroyViewまでが呼ばれるということでした。 つまり、初回のみ実行したい初期化処理やイベントのobserveは、このライフサイクルの流れの中に入れてはいけません。 ライフサイクルがonCreateViewの前で、かつバックグラウンドでのプロセスキルなどの対応を考えた時に、savedInstanceStateを利用できる条件を満たすのはonCreateとなります。 そのため、WEARではonCreateにて初回に実行したい処理を記載するようにしました。 class HogeFragment : BaseDaggerFragment() { override fun onCreate(savedInstanceState: Bundle?) { super .onCreate(savedInstanceState) // 初期化処理を記載する。バックキル時の対応などはこちらに記載 // 最初の一回だけ行いたい、親Fragmentからのイベントの通知をobserveする (requireParentFragment() as FugaFragment).viewModel.behaviorLive.observe(requireParentFragment(), Observer { behavior -> ... }) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super .onViewCreated(view, savedInstanceState) // Viewの構築、下タブを切り替えた時に行ってほしい処理をこちらに記載 } } これらの対応を行ったことで、下タブ切り替え時の状態保存を実現することができました。 また、WEARのタイムライン画面には独自ヘッダーの切り替えでも状態保存を行うという仕様が存在し、こちらも今まで説明した考え方を応用して実装しました。 公式サンプルの考え方を元に、独自ヘッダーで応用する BottomNavigation化に伴い、新しく独自のヘッダーを作成することになりました。今まで説明した考え方を応用して実装した際のポイントをまとめて紹介します。 リプレイスしたタイムラインの画面が次に示すものです。 独自ヘッダーで実装する際のポイントは大きく5つのステップに分けられます。 親FragmentのインスタンスがonAttachされた際に、子Fragmentのインスタンスを作成する ヘッダーの選択情報をActivity側のViewModelで保持する View生成時、最初にattachされるFragmentをセットする ヘッダー切り替え時、選択状態に応じてattachとdetachを行う 子Fragment側で、最初に処理したいことをonCreateに記載する まずはじめに、親FragmentであるTimelineAdminFragmentに子Fragmentのインスタンスを保持する必要があるため、onCreateにてリストで持たせます。 // タイムラインの親Fragment class TimelineAdminFragment : BaseDaggerFragment() { private lateinit var childFragments: MutableList<Fragment> override fun onCreate(savedInstanceState: Bundle?) { super .onCreate(savedInstanceState) // タイムラインにて保持したいFragmentをリストで持つ childFragments = mutableListOf( TimelineColumnFragment.newInstance(ONE), TimelineColumnFragment.newInstance(TWO), NewsFragment.newInstance(), NewSnapFragment.newInstance(ALL) ) } } 次に、子Fragmentにて独自ヘッダーを持つ場合はその選択状態を保持する必要があるため、Activity側のViewModelにてヘッダー情報を保持します。 // Activity側のViewModel class MainViewModel( private val application: WEARApplication, private val mainUseCase: MainUseCase, private val accountUseCase: AccountUseCase ) : AndroidViewModel(application) { ... // LiveDataで保持する val timelineHeaderDataLive: MutableLiveData<Event<TimelineHeaderData>> = MutableLiveData() ... } // ヘッダーの情報を保持するクラス data class TimelineHeaderData( val timelineTypes: List<TimelineType>, val followTypes: List<FollowType>, val categoryTypes: List<CategoryType>, ... ) 子Fragment側で、ヘッダーを持っているので、切り替えた際にActivityのViewModelのLiveDataにpostValueします。 View生成時、最初にattachされるFragmentをセットします。その際、Activity側でヘッダー情報を保持したので、そちらの情報を元にセットします。 Navigation Extensions の実装を参考にして、タイムラインの親Fragmentに反映させました。 // タイムラインの親Fragment class TimelineAdminFragment : BaseDaggerFragment() { // ヘッダーで切り替えた時にfragmentTagを保持しておくために用意 private val graphIdToTagMap = SparseArray<String>() ... // View生成時(onViewCreated)に最初にattachするFragmentをセットする private fun setUpPrimaryFragment(type: TimelineType, followType: FollowType) { childFragments.forEachIndexed { index, fragment -> val fragmentTag = getFragmentTag(index) val obtainFragment = obtainFragment(fragmentTag, index) val selectedIndex = getSelectedFragmentIndex(type, followType) val mapKey = getLayoutResourceId(fragment) + index graphIdToTagMap[mapKey] = fragmentTag if (index == selectedIndex) { attachFragment(obtainFragment) } else { detachFragment(obtainFragment) } } } ... // BottomNavigationの時と同様にFragmentがすでに存在しているかチェックを行い、されていなければ追加する private fun obtainFragment( fragmentTag: String, childFragmentsIndex: Int ): Fragment { val existingFragment = childFragmentManager.findFragmentByTag(fragmentTag) existingFragment?.let { return it } val fragment = childFragments[childFragmentsIndex] childFragmentManager.beginTransaction() .add(R.id.timeLineAdminFragmentContainer, fragment, fragmentTag) .commitNow() return fragment } // BottomNavigationの時と同様にFragmentのタグを取得する private fun getFragmentTag(index: Int ) = "timeline# $index " // BottomNavigationの時とは異なり、navigationではなくlayoutのResourceIdを取得する private fun getLayoutResourceId(childFragment: Fragment) = when (childFragment) { is TimelineColumnFragment -> R.layout.fragment_timeline_column is NewsFragment -> R.layout.fragment_timeline_news is NewSnapFragment -> R.layout.fragment_new_snap else -> throw IllegalArgumentException( "Not found such a fragment." ) } // typeによって親Fragmentで保持している子Fragmentのどのindexかを取得する private fun getSelectedFragmentIndex(type: TimelineType, followType: FollowType): Int { return when { type == FOLLOW && followType == ONE -> TIMELINE_COLUMN_ONE_INDEX type == FOLLOW && followType == TWO -> TIMELINE_COLUMN_TWO_INDEX type == NEWS -> NEWS_INDEX type == SNAP -> NEW_SNAP_INDEX else -> TIMELINE_COLUMN_TWO_INDEX } } } ヘッダー切り替え時、選択状態に応じてattachとdetachを行います。ヘッダーの「フォロー中」、「ニュース」、「新着」の項目はRecyclerViewになっており、選択されたindexに応じてattachとdetachを行います。 class TimelineAdminFragment : BaseDaggerFragment() { ... private fun switchFragment(type: TimelineType, followType: FollowType, categoryType: CategoryType, shouldChangeCategory: Boolean = false ) { ... val selectedIndex = getSelectedFragmentIndex(type, followType) val mapKey = getLayoutResourceId(childFragments[selectedIndex]) + selectedIndex val newlySelectedItemTag = graphIdToTagMap[mapKey] ?: return // 最初に全てのFragmentをdetachする childFragments.forEach { detachFragment(it) } // 新着のサブ項目である性別の選択の時は、状態を保存しない仕様があるため分岐がある if (shouldChangeCategory) { // 新着サブ項目の性別選択時に指定のFragmentを破棄して作り直すことをしているが、本筋と逸れるため省略 } else { // 選択した項目のFragmentをattachするようにしている val selectedFragment = childFragmentManager.findFragmentByTag(newlySelectedItemTag) ?: throw IllegalArgumentException( "There is no such Fragment. Please review the process." ) attachFragment(selectedFragment) } } ... } ここで行っていることはBottomNavigationの実装と同様です。選択したindexからFragmentManagerに保持しているFragmentを取得しattachする流れになります。 子Fragment側で最初に処理したいことをonCreateに記載すれば完成です。 BottomNavigationの時と異なるのは、ヘッダー情報を保持する点と、fragmentTagのIdが変わっている点です。 根本的な考え方は同じなため、BottomNavigation以外で、 Navigation Extensions の考え方を用いれば容易にどのようなViewでも状態保存の実現が可能です。 まとめ BottomNavigationに限らず状態保存を実現するために、重要なことは4つです。 密結合になっている実装の場合、まず切り離すことを考える インスタンスを最初に一度作成し、attachとdetachで切り替えるようにする 最初の1回だけやりたい処理は、attachとdetachが行われる側のFragmentのonCreateに記載する 独自のViewで実装する場合は、indexなどの情報を生存期間が長い方のActivity側のViewModelで保持するようにする さいごに 今回紹介した事例で、BottomNavigationの方は Navigation Extensions の実装が、Navigationでマルチバックスタックが対応されるまでの解決方法になると思いますが、内部の実装を掘り下げて考え方を学ぶと、他のViewであっても利用することができ、状態保存をしたい時には役立つと思います。 他にもやり方があったり、もっとこうしたほうがいいというアドバイスがございましたら、私のTwitterアカウント @zukkey59 までご連絡をいただけますと嬉しいです! さいごに、ZOZOテクノロジーズでは、一緒にモダンなサービス作りをしてくれる方を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは。WEARバックエンドエンジニアの id:takanamito です。先日リリースしたWEARの新プッシュ通知基盤の紹介をしようと思います。 新プッシュ通知基盤開発の背景と目的 WEAR では既にiOS/Androidアプリに向けたプッシュ通知配信基盤が存在していました。 しかし、かなり昔につくられた基盤ということで運用にコストがかかったり、必要な機能が足りていなかったりします。 例えば、ユーザー全体にプッシュ通知を送りたい場合に以下のような問題が存在しました。 ログイン済みユーザーにしかプッシュ通知を送信できない プッシュ通知の送信開始から完了までに半日以上かかる 配信サーバーのスケールに手作業が発生する 1.についてはWEAR開発当初、はじめてプッシュ通知を導入するきっかけとなったキャンペーンが存在したものの、そのキャンペーンの対象がWEARアカウントを持っている人だったために、このような仕様でプッシュ通知の機能が作られてしまい、今まで運用が続いていたことが理由のようでした。今回の新基盤開発のモチベーションの1つに「未ログインユーザーも含めたプッシュ通知配信」の実現が挙げられます。 2.と3.については、詳しいアーキテクチャの説明は割愛しますが、1.の経緯で作られた機能のため、現在のように月間1000万ユーザーを抱えることを想定せず作られていたようです。 また、配信時に以下のような運用が実際になされていました。 DBから配信対象デバイスリストを抽出 手元の開発用マシンでバッチを実行し、配信サーバーに対して1件ずつHTTPで配信リクエスト 配信サーバーからAPNs/FCMに対して1件ずつ配信リクエスト この配信サーバーはオートスケール設定などされておらず、台数を増やしたい場合は秘伝の手作業によって作成されたAWS EC2のAMIを使って配信サーバーを手動で増やすという作業が必要でした。 このように既存の仕組みは配信数が増えるほどコスト(時間とお金)がかかるものであり、この状態でさらに送信対象が増える「未ログインユーザーも含めたプッシュ通知配信」を行うことは非現実的だろうという判断から、プッシュ通知基盤を作り直すことにしました。 新プッシュ通知基盤の要件 新しい基盤を設計する際には以下のような要件を意識していました。 WEARユーザーが増えた場合にも短時間で配信が可能であること 低価格で配信が可能であること 配信サーバーの運用をしなくてもよいこと 結果、Googleが提供するFirebase Cloud Messaging(FCM)を採用することにしました。 以下のポイントが決め手になりました。 ZOZOTOWNで先行して採用されており、配信速度の実績が十分高速だった topic の機能により、API経由の1リクエストで多数のデバイスへの配信が実現可能である 無料 である 我々が配信サーバーを保守運用する必要がない WEARにおけるプッシュ通知の種類 WEARにはいくつかの種類のプッシュ通知が存在します。 全体プッシュ通知:WEARユーザー全員が対象の通知 ユーザー指定プッシュ通知: WEARISTA 向けのお知らせなど、特定のセグメントのユーザーが対象の通知 ユーザーのアクションによるプッシュ通知:「フォローされました」「フォロー中のユーザーがコーデを投稿しました」など、WEARユーザーのアクションをきっかけに配信される通知 今回の新基盤開発では初期段階のスコープはWEARユーザー全体に対する一斉通知を対象としており、ユーザーのアクションによって配信されるプッシュ通知は後から改修をすることにしています。 配信フロー 全体お知らせプッシュ通知は以下のようなフローでFCMのtopicを利用して配信しています。 プッシュ通知シーケンス図 非同期処理を含むシーケンス図なのでいびつですが、流れはイメージしていただけるかと思います。 実装 WEARは以前のテックブログでも紹介したように、Railsにリプレイスをしている最中です。 techblog.zozo.com そのため今回作る新基盤では、プッシュ通知配信機能を持った管理画面をRailsで実装しました。 FCMコンソールにはプッシュ通知を配信できる機能が存在しますが、WEARのネイティブアプリで既に実装されたプッシュ通知payloadと互換性をもった形で配信ができなかったため、APIを通じた配信の仕組みを作っています。 サーバーからFCMの機能を利用するとなると Firebase Admin SDK を使いたくなるのですが、残念なことにRuby SDKは提供されていません。また、gemもいくつか存在したのでその実装方法を確認したのですが、私が確認した範囲ではFCM HTTP v1 APIに対応する新しい認証方式を採用しているgemがまだ存在せず、今回は自分で実装することにしました。 Railsのlib以下にFCM用の実装を置いてます。現状はtopicを使った配信をしているため Fcm::Topic クラスを実装していますが、今後別の配信方式を採用する場合はここに実装が増えていく予定です。 以下にサンプルコードを置いておきます。設定値などは適宜読みかえてください。 require ' googleauth ' module Fcm class BadRequestError < StandardError ; end class UnauthorizedError < StandardError ; end class ForbiddenError < StandardError ; end class UnregisteredError < StandardError ; end class QuotaExceededError < StandardError ; end class InternalServerError < StandardError ; end class ServiceUnavailableError < StandardError ; end class Topic class << self def send_notification (payload) response = Fcm :: Client .connection.post( " /v1/projects/ #{ project_id } /messages:send " , payload) # Errors: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode case response.status when 400 raise Fcm :: BadRequestError , response.body[ :error ][ :message ] when 401 raise Fcm :: UnauthorizedError , response.body[ :error ][ :message ] when 403 raise Fcm :: ForbiddenError , response.body[ :error ][ :message ] when 404 raise Fcm :: UnregisteredError , response.body[ :error ][ :message ] when 429 raise Fcm :: QuotaExceededError , response.body[ :error ][ :message ] when 500 raise Fcm :: InternalServerError , response.body[ :error ][ :message ] when 503 raise Fcm :: ServiceUnavailableError , response.body[ :error ][ :message ] else response end end end end module Client class << self def connection Faraday .new(base_url) do |builder| builder.request :oauth2 , bearer_token, token_type : :bearer builder.request :json builder.response :json , parser_options : { symbolize_names : true }, content_type : ' application/json ' builder.adapter Faraday .default_adapter end end private def bearer_token authorizer = Google :: Auth :: ServiceAccountCredentials .make_creds( json_key_io : StringIO .new(fcm_key.to_json), scope : ' https://www.googleapis.com/auth/firebase.messaging ' ) response = authorizer.fetch_access_token! response[ ' access_token ' ] end end end end FCM運用方針 Rubyクライアント以外にもFCMを使ったプッシュ通知に関して、いくつか検討した項目があります。 メッセージのカスタマイズについて WEARアプリには既にプッシュ通知の仕組みが実装されています。そのため今回の新プッシュ通知基盤を導入するにあたり過去のバージョンのクライアントアプリと互換性を考慮する必要がありました。 FCMでは送信するメッセージをカスタマイズする機能が存在しますが、細かなカスタマイズはAPIを通じてでしか行えず、FCMコンソールを使ったプッシュ通知配信ではメッセージの表現の幅に制約がありました。 参考: FCM メッセージについて  |  Firebase 既存のクライアントアプリの実装を活かしたかったので、FCMコンソールからの配信を諦め、すべて内製のツールを通じて配信することにしました。 ただし、FCMコンソールでは利用可能な「スケジュール配信」などの仕組みを自分で用意する必要があるため、配信予約をしたい場合は注意が必要です。 topic設計について 先述の通り、今回実装した全体プッシュ通知にはFCMのtopic機能を使っています。サーバーから1リクエストで大量のプッシュ通知配信ができて非常に便利な機能ですが「API経由でtopicの一覧が取得できない」という制約が存在します。 そのためWEARではtopicの命名規則をルール化して運用することにしました。 具体的には以下のようなルールです。 wear-${environment}-${language}-${os}-all WEARは複数環境(本番、開発、QAなど)、複数言語、複数プラットフォームが存在するサービスなので、topic名からそれぞれ識別できるようにした設計です。 全体プッシュ通知用のtopicが増えることは稀なので、命名規則に基づいたtopic名をRailsの設定ファイルで管理して運用しています。 topic削除について topic自体の作成上限はありませんが、1つのアプリインスタンス(FCMトークンと同義だと解釈しています)を登録できるtopicの上限が2000件となっています。 さらに困ったことにtopicを削除するインタフェースが用意されていません。topicを削除したい場合は登録されたアプリインスタンスを全て登録解除する必要があるようです。 1 つのアプリ インスタンスを登録できるのは、2,000 トピックまでです。 参考: iOS でトピックにメッセージを送信する  |  Firebase そのため、一度作成したtopicを削除するにはFCMトークンが必須です。一度でもtopic登録されたFCMトークンはDBで永続化し、いつかtopicを削除したくなった際に対応できるようにしています。 まとめ WEARにおけるFCM導入事例を紹介しました。topicを使ったプッシュ通知配信においてAPIが不足している印象ですが、やはりプッシュ通知配信サーバーの運用から脱却できるなど、利点の方が上回っていると判断し導入しました。 今回は全体プッシュ通知について事例をご紹介しましたが、まだ全てのプッシュ通知を新基盤に移せたわけではありません。引き続き、地道な改善を続け高速にプッシュ通知を届けたり、コストの削減をしていく必要があると考えています。 技術的な基盤改善でサービスをより良くすることに興味がある方は、以下のリンクからぜひお声がけください。 hrmos.co
アバター
こんにちは、ZOZOテクノロジーズ CTO室の池田( @ikenyal )です。 ZOZOテクノロジーズでは、11/18に ZOZO Technologies Meetup〜ZOZOが提供するEC支援サービスの裏側〜 を開催しました。 zozotech-inc.connpass.com 本イベントでは、ZOZOが運営する自社EC支援サービス「Fulfillment by ZOZO(FBZ)」の概要や技術、運用に関して各担当者からお伝えしました。 登壇内容 まとめ 弊社の社員3名が登壇しました。 FBZのサービス概要 〜 大規模物流プラットフォームZOZOBASEの解放 (BtoB開発本部 BtoB開発企画 / 大野 公嗣) サーバーレスなAPIサービスの全容 (BtoB開発本部 BtoB開発 / 杉田 尚弥) AWSフルマネージドサービスにおける監視と運用 (SRE部 BtoBチーム / 蔭山 雄介) 最後に ZOZOテクノロジーズでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに BtoB開発部の木目沢です。 Fulfillment by ZOZO (以下FBZ)で提供しているAPIの開発に携わっております。 FBZに関しては以前、 物流支援サービスを支えるAWSサーバーレスアーキテクチャ戦略 で、AWSサーバーレスアーキテクチャ関連のサービスをどのように活用しているかをご説明しました。 techblog.zozo.com 今回は、サーバーレスアーキテクチャの特徴、設計するうえで課題だった点、そしてそれら課題をどのように克服していったかご紹介します。 イベント駆動と分散処理 FBZではAWSサーバーレスアーキテクチャ関連のサービスを採用しています。その大きな特徴は「イベント駆動」であるという点です。 Lambdaはイベント駆動のサービス 例えば、API GatewayへのリクエストやDynamoDB、S3などへのデータのプッシュイベントをトリガーにLambdaを起動します。起動されたLambdaは関数として処理を実行、そのアウトプットをトリガーに別のLambdaが動いていくという仕組みです。 Lambdaのトリガーとして利用できるAWSのサービスは こちら を見ていただくとわかるように、現時点で20サービス以上あります。 このように、多くのインプット元やアウトプット先があれば、例えば下図のように分散環境を活かしたサービスが自由に構築可能になります。 Lambdaを使った分散環境の例 例では、CloudWatch イベントから起動したLambdaが全商品を取得しています。取得後、商品の更新を行うLambdaが各商品ごとに起動され処理していきます。その結果をSQSに追加すると、また別のLambdaが起動し通知処理を行うという流れです。 分散処理を行うことで全体の処理時間の短縮やバグ発生時の原因切り分けがしやすくなるなど、多くの利点を得ることができます。 設計面での課題 一方、このようなイベント駆動や分散処理のアーキテクチャはソースコードの可読性を下げる要素が2つあります。 多くのAWSサービスへのアクセス 1つの処理で複数Lambdaの実行 多くのAWSサービスへのアクセス 多くのAWSサービスを利用するため、各サービスへのアクセスロジックとビジネスロジックが混在してしまうため、処理が複雑になりソースコードが読みづらくなります。 1つの処理で複数Lambdaの実行 1つの処理で複数のLambdaが実行されるため、Lambdaの処理からビジネスロジックを確認しづらくなります。例えば、商品の更新処理という1つの処理の中で、取得・更新・通知と3つの機能が実行されます。そのため「商品の更新処理」の流れについて3つのLambdaを追う必要があります。 AWSサービス処理とビジネスロジックの徹底分離 この課題を解決するため、以下の2点の考え方が必要でした。 各AWSサービスへのアクセスロジックとビジネスロジックを分離すること 各Lambdaから共通して参照できるようにビジネスロジックを集中した場所に定義すること そこで私達が当初の設計から取り入れたのがドメイン駆動設計(DDD)です。 ドメイン駆動設計ではドメインモデルをAWSの他のサービスと分離するために、いくつかのアーキテクチャが提案されています。私達が採用したのはレイヤアーキテクチャです。 レイヤアーキテクチャ ヘキサゴナルアーキテクチャやクリーンアーキテクチャなど他のアーキテクチャも提案されています。しかし、私達はドメインモデルを分離することだけに集中し、最もわかりやすいレイヤアーキテクチャを採用しました。 分離したモデル層はモジュール化し、他の層を参照できないようにしました。これにより、FBZのモデル層に各AWSサービスへのアクセスロジックが入り込めない設計にできました。 FBZのフォルダ構成例 この設計により、アウトプット先のストレージを変えたい、またはSQSではなくSNSを使いたいなどの要望もinfrastructures層だけを変更すればよくなりました。そして、商品の更新ルールを変えたいなどビジネスロジックを変更したい場合はmodels層の商品モデルだけを修正すればよくなりました。 このようにイベント駆動、分散処理に対する問題点をドメイン駆動設計を採用することで解決してきました。 新しい問題点 サービスの成長に伴い、新しい問題も出てきました。一部ではありますが、2点ご紹介します。 モデルのモノリス化 モデル以外にビジネスロジックが書かれてしまう モデルのモノリス化 私達は各Lambda関数が共通のモデルを見ることでソースコードが追いにくいという問題を解決してきました。しかし、FBZが成長するに連れLambda関数も増えていき、現在では数百単位の関数になっています。このレベルまでLambda関数の数が増えると、今度はモデルのモノリス化が深刻になってきました。 例えば、注文の業務では注文モデルを見ます。同じモデルを発送や返品・交換でも見ることになると、それぞれのロジックが注文モデルに集まってくる状態になります。その結果、複数のLambda関数から同じモデルを見ることで容易に理解できるというメリットから、モデルが大きすぎて逆に理解しづらくなるという新しい問題が発生しています。 大きくなりすぎたモデルを理解しやすい単位に分割することが必要になってきています。 モデル以外にビジネスロジックが書かれてしまう メンバーの増減や至急の要件などをこなしていくうちに、ビジネスロジックがモデルではなくアプリケーション層やハンドラーに書かれていってしまう問題も発生しています。 Lambda関数によってはモデルを一度も使わずに複雑なビジネスロジックを実装しており、さらにそこが何度も修正が入るような重要なロジックであることも少なくありません。修正が入るタイミング、リファクタリングできるタイミングでこういった箇所をモデルに移行していくということも意識していかなくてはいけません。 複雑なビジネス要件だからこそ活きる設計 今回はサーバーレスアーキテクチャを用いたサービス開発の中で生じた課題と、その課題に対しドメイン駆動設計を用いて対応してきた内容をご紹介しました。苦労している点は現在もありますが、約50に及ぶECサイトで利用されるAPIサービスをチームで開発してきたという意味で一定の成果を上げています。サーバーレスアーキテクチャを採用する事例も最近では増えてきたと思いますのでぜひ参考にしていただければ幸いです。 最後に、今回の経験をもとにドメイン駆動設計を採用する上で重要だと感じた点を共有します。 ドメイン駆動設計ではモデルを分離したその先、モデル自体をどのように構築していくかが重要になってきます。サーバーレスアーキテクチャはそれ自体複雑なものですが、もっと複雑で理解が難しいのはユーザーの活動やビジネスに関するロジックです。 FBZも単に在庫を連携するだけのサービスではありません。商品はもちろん、在庫が変動する要素である注文や配送の管理も必要となるため、ビジネスロジックは非常に複雑です。さらにサービスの成長に伴い、このビジネスロジックに多くの変更が入ります。複雑なビジネスロジックをどのようにモデルに表現するかで、修正や追加の難易度が変わります。それがさらなるサービスの成長に影響していきます。 そのため、モデルを改善し続けていくことがサービスを成長させていく上でなにより大切になってきます。私達は引き続き改善を続けていくことで、今後もFBZを成長させていきたいと考えています。 BtoB開発部では、サーバーレスアーキテクチャやドメイン駆動設計などテクノロジーを活用しサービスを成長させたい仲間を募集中です。ご興味ある方は こちら からぜひご応募ください! tech.zozo.com
アバター
ZOZOテクノロジーズ推薦基盤チームの寺崎( @f6wbl6 )です。ZOZOでは現在、米Yale大学の経営大学院マーケティング学科准教授である上武康亮氏と「顧客コミュニケーションの最適化」をテーマに共同研究を進めています。 推薦基盤チームでは上武氏のチームで構築した最適化アルゴリズムを本番環境で運用していくための機械学習基盤(以下、ML基盤)の設計と実装を行っています。本記事ではML基盤の足掛かりとして用いた AI Platform Pipelines ( Kubeflow Pipelines ) の概要とAI Platform Pipelinesの本番導入に際して検討したことをご紹介し、これからKubeflow Pipelinesを導入しようと考えている方のお役に立てればと思います。記事の最後には、推薦基盤チームで目指すMLプロダクト管理基盤の全体像について簡単にご紹介します。 上武氏との共同研究のより詳しい内容については弊社のニュース記事を参照ください。 corp.zozo.com 案件概要 推薦基盤チームで抱えていた課題 Kubeflow Kubeflow Pipelines Kubeflow Pipelinesの運用環境 AI Platform Pipelines Pipelineの設計・実装で意識したこと・ハマったこと Pipeline内で日時情報を扱う Slack通知 ノードプールによるリソースと権限の分離 CI/CDの実装 今後の展望 ML基盤として目指す姿 おわりに 参考 案件概要 Yale大学との共同研究に関して、推薦基盤チームで担当する業務の要件概要は以下の通りです。 毎日決まった時間にモデルによる予測を実行する(バッチ実行) モデルに入力する特徴量はBigQuery上の複数のテーブルから取得し、所定の前処理を加える モデルはpickle形式の学習済みモデルを提供していただき、当面の間はモデルの再学習を行わない 予測結果はBigQueryに出力する 具体的な入出力について詳細を書くことはできませんが、入力としてZOZOTOWNユーザーの属性や回遊情報を使い、出力としてユーザーごとに最適なコンテンツを得る最適化問題と考えるのが良いかと思います。一般的に予測モデルはデプロイして終わりではなく継続的に学習・検証とモデル更新を繰り返しますが、今回は共同研究における実験という側面があり、実験期間中は再学習を行わず運用することになりました。 今回運用するモデルはオンライン予測しない + モデルの再学習も行わないため、機械学習モデルの運用としては比較的負荷の少ないケースと言えます。この機械学習モデルの運用方法を検討するにあたり、まず私たちのチームで抱えていた機械学習モデルの運用上の課題について見ていきます。 推薦基盤チームで抱えていた課題 推薦基盤チームではZOZOTOWNの推薦システム全般の構築・運用を担当しており、様々なアルゴリズムが本番環境で動いています。推薦アルゴリズムは弊チームで構築したものだけでなく分析本部やMA部で構築したものもあり、他チームから本番導入を依頼されるようなケースが少なくありません。案件ごとに様々な形でモデルの実装・運用を行っていく中で、以下のような要求に耐え得るML基盤が求められていました。 運用中の予測モデル(ワークフロー)を一元管理できること モデル構築の際に環境構築が容易であること 実験段階からプロダクションへの移行が容易であること 車輪の再発明をしないような仕組みであること(= 似たようなモデル開発をしない) モデルサービングが可能であること 機械学習モデルを本番環境で運用するにあたってこうした課題はよく直面するものと思います。特に私たちのチームでは様々なチームからモデルの実装・運用を依頼されるため、今後管理すべきモデルが増えていく状況の中で「 運用中の予測モデルを一元管理できること 」は最初に対処したい課題でした。仮にモデルごとに管理環境が異なっていた場合、モデル導入に関与した担当者でしかメンテナンスができないという状況にも繋がりモデル管理が属人的になってしまいます。 また推薦基盤チームでもモデルを作ることはあるため、推薦モデルを増やしていく上で「 実験段階からプロダクションへの移行が容易であること 」も重要な項目でした。 こうした課題を背景に、推薦基盤チームではMLOps全体に渡ってカバーしている Kubeflow を導入することにしました。 Kubeflow Kubeflow はモデルの作成・学習・検証、ワークフロー構築、モデルサービングといったMLOpsに関するワークロードをKubernetes上で実行するためのオープンソースツールキットです。要するに、MLプロジェクトで必要となるツールの全部盛りです。 元はGoogle社内で使われていた Tensorflow Extended というML基盤があり、より汎用的に使えるML基盤を目指してオープンソース化した姿がKubeflowというプロジェクトになったようです。2020年11月現在v1.1が最新バージョンですが公式ドキュメントが追いついていない部分が多いため、実際に利用する際にはv1.0からキャッチアップしていくのが良いと思われます。 公式サイトによると、Kubeflowコミュニティの目指すゴールは以下であると述べられています。 Our goal is to make scaling machine learning (ML) models and deploying them to production as simple as possible, by letting Kubernetes do what it’s great at: ・Easy, repeatable, portable deployments on a diverse infrastructure (for example, experimenting on a laptop, then moving to an on-premises cluster or to the cloud) ・Deploying and managing loosely-coupled microservices ・Scaling based on demand 特に"Easy, repeatable, portable..."の項目に関してはドキュメントの中で頻繁に出てくることから、MLプロジェクトで陥りがちな「 開発環境と本番環境の整合性を取るための雑務を取り除く 」という思想が前面に出ているように思います。 こうした思想から、KubeflowにはMLプロジェクトで取り組むタスクをEnd to Endで行えるような要素が盛り込まれています。 MLプロジェクトでのタスク Kubeflowでの機能名 モデル構築・実験 Jupyter Notebooks モデルの学習 TensorFlow Training, PyTorch Training, ... ハイパーパラメータ調整 Katib 特徴量管理 Feast ワークフロー構成 Kubeflow Pipelines モデルサービング KFServing , Seldon Core Serving, ... Jupyter Notebooksによるモデル構築・実験からKFServingによるオンライン予測のエンドポイント作成まで、MLプロジェクトのタスクをEnd to Endで行うことができます。各タスクに最適化されたツールは既に様々な場所で運用されていますが、それらを1つのツールでまとめられるのがKubeflowの強みでしょう。 再三になりますが今回の要件としてモデルの構築・学習・オンライン予測は対象外であるため、Kubeflowの機能のうち Kubeflow Pipelines のみを利用することにしました。 Kubeflow Pipelines Kubeflow Pipelinesは機械学習ワークフローを管理するためのツールで、類似ツールだとAirflowがあります。確かに今回の要件を満たすワークフローを作るだけであればAirflowで事足りるのですが、ここで推薦基盤チームにて抱えていた課題を振り返ります。 実験段階からプロダクションへの移行が容易であること 車輪の再発明をしないような仕組みであること 目の前のタスクを潰していくことを優先的に進めるとこうした課題の解決は後回しになっていき、やがて課題は大きく積み重なって後続のエンジニアの負債となります。モデル構築からパイプライン実装までを分離しない基盤であり、かつ一度作ったワークフローの構成要素を再利用する基盤を作ることの第一歩としてKubeflow Pipelinesを利用することにしました。 Kubeflow Pipelinesではコンテナ単位で機能を開発し、それを繋げて一連の処理を行うワークフロー(DAG)を構成します。ここで作ったコンテナは Component と呼ばれ、それを繋げたものを Pipeline と呼びます。 Componentはコンテナ化されているので、一度作ったComponentは様々な環境で使い回すことができます。汎用的なComponentはKubeflow PipelinesのGitHubリポジトリから利用できるため、どのようなComponentが提供されているかはそちらを参照ください。 github.com 例えば機械学習モデルで予測を行うワークフローは大まかには以下のステップに分解されます。 データ収集 前処理 推論 予測結果を返却 このワークフローをKubeflow Pipelinesで構築すると以下のようなDAGになります。 Kubeflow Pipelinesの運用環境 Kubeflow PipelinesはKubeflowの機能の1つなので、Kubernetes環境があればKubeflowをインストールして利用できます。GCPでKubeflow Pipelinesを利用するには以下の2つの方法があります。 GKEインスタンスを立てて自前でKubeflowをインストールする GCPのマネージドサービスであるAI Platform Pipelinesを使う GKEインスタンスにKubeflowをインストールしてセルフマネージすることで常にKubeflowの最新版をキャッチアップし続けられるというメリットがあります。しかし今回は環境構築と管理の手間を考え、マネージドなAI Platform Pipelinesを利用することにしました。 AI Platform Pipelines AI Platform PipelinesはGCPにおけるKubeflow Pipelinesのマネージドサービスであり、自分で一から環境構築することなくKubeflow Pipelinesを利用できます。GUIでの操作だけでGKEクラスタ作成からAI Platform Pipelinesインスタンスの立ち上げまでが自動的に行われます。なおGKEクラスタについては予め作成しておいたクラスタを指定することもできますが、 同一クラスタに対して複数のインスタンスを立ててはいけない ようです。 以下の図ではインスタンスが2つ存在していますが、実際にはkubeflow-pipelines-2のデプロイに失敗しています。 AI Platform Pipelinesは2020年3月よりベータ版がリリースされており、7月頃まではKubeflowのサポートバージョンがv0.5で止まっていましたが11月現在ではv1.0までサポートされています。v0.5まではKubeflow自体がツールとして成熟していなかったために挙動が不安定になることも多かったようですが、v1.0で運用している現状で変わった動きはあまり確認されていません。 Pipelineの設計・実装で意識したこと・ハマったこと 以下ではAI Platform Pipelines(+ Kubeflow Pipelines)を本番運用するにあたって意識したこと・ハマったことなどを紹介します。Kubeflow Pipelinesに関して「まずは使ってみた」という記事が多い中、実装や実運用面での知見は現状少ないので、これから本番導入を検討している方の一助になれば幸いです。 Pipeline内で日時情報を扱う 定期実行するバッチの場合、実行結果のログや成果物は日付や時間ごとでパーティションを切って保存することが一般的かと思います。例えばComponentの実行結果をあるGCSのバケットに保存する場合、以下のように日付を取得して全てのComponentに保存先を渡すことが考えられます。 # pipeline_1.py from datetime import datetime from kfp import dsl from kfp import components as comp from kfp.components import func_to_container_op @ func_to_container_op def thanks (message: str , gcs_output_path: str ): from myutils import upload_to_gcs # GCSにファイルをアップロードする関数 upload_to_gcs(message, gcs_output_path) @ dsl.pipeline def pipeline (text: str ): """ 毎日thanks.txtをGCSに出力する """ today = datetime.today().strftime( '%Y%m%d' ) thanks_path = f 'gs://my-gcp-project/my-bucket/{today}/thanks.txt' print (f '{thanks_path=}' ) thanks_task = thanks(message= 'byebye' , gcs_output_path=thanks_path) Pipelineにする必要性は全くないサンプルですがご了承ください。 一見正しく動きそうなプログラムですが、実際にはここで取得した today はこのpipeline_1.pyがコンパイルされた日付で固定されています。つまり今日(2020/11/13)にpipeline_1.pyをコンパイルして毎日実行した場合、print文の出力は以下のようになります。 # 2020/11/13に実行した結果 thanks_path = ' gs://my-gcp-project/my-bucket/20201113/thanks.txt ' # 2020/11/14に実行した結果 thanks_path = ' gs://my-gcp-project/my-bucket/20201113/thanks.txt ' # 2020/11/15に実行した結果 thanks_path = ' gs://my-gcp-project/my-bucket/20201113/thanks.txt ' ... today がコンパイルされた日(2020/11/13)で固定されているため、thanks.txtは常に gs://my-gcp-project/my-bucket/20201113 へ出力されることになります。ローカルでデバッグしながら開発していると常にPipelineをコンパイルしながら作業することになるため、この挙動に気付きにくいかもしれません(実際私はデブロイして定期実行の動作を確認している時に初めて気付きました)。 想定した挙動を得るためには以下のようにComponent内で時刻を取得する必要があります。Componentは実行の度にコンテナとして立ち上げられるため、コンテナが起動したタイミングの日付が得られるという算段です。 # pipeline_1_fix.py from kfp import dsl from kfp import components as comp from kfp.components import func_to_container_op from myutils import upload_to_gcs # GCSにファイルをアップロードする関数 @ func_to_container_op def thanks (message: str , gcs_output_path: str ): from myutils import upload_to_gcs # GCSにファイルをアップロードする関数 from datetime import datetime # 追加 today = datetime.today().strftime( '%Y%m%d' ) # 追加 thanks_path = f '{gcs_output_path}/{today}/thanks.txt' # 追加 print (f '{thanks_path=}' ) # 追加 upload_to_gcs(message, thanks_path) @ dsl.pipeline def pipeline (text: str ): """ 毎日thanks.txtをGCSに出力する """ gcs_path = f 'gs://my-gcp-project/my-bucket' thanks_task = thanks(message= 'byebye' , gcs_output_path=gcs_path) ただこの方法では「日付を取得して保存先として使う」という業務ロジックをComponentに含めるため、Component化することのメリットが失われることになります。またComponentの中を見ないとファイルの出力先がわからないという問題もあります。 ここで、Kubeflow Pipelinesのワークフロージョブエンジンとして使われているArgoでは Workflow Variables というPipeline内の様々なメタデータを参照できる変数があります。 例えば現在の処理を実行しているGKEのPod名を以下のように取得できます。 print ( "Current pod name: {}" .format({{ pod.name }})) 同様に、 workflow.creationTimestamp というvariableを使えば現在時刻をstringで取得できるため、この時刻から日付を抽出すれば解決! # pipeline_2.py ... @ func_to_container_op def thanks (message: str , gcs_output_path: str ): from myutils import upload_to_gcs # GCSにファイルをアップロードする関数 upload_to_gcs(message, gcs_output_path) @ dsl.pipeline def pipeline (text: str ): """ 毎日thanks.txtをGCSに出力する """ today = '{{workflow.creationTimestamp}}' # '2020-11-14 01:51:55 +0000 UTC' ymd = today.split( ' ' )[ 0 ] # '2020-11-14' が得られる想定 thanks_path = f 'gs://my-gcp-project/my-bucket/{ymd}/thanks.txt' print (f '{thanks_path=}' ) thanks_task = thanks(message= 'byebye' , gcs_output_path=thanks_path) と思いきや、このprint文の出力は以下のようになります。 thanks_path =gs://my-gcp-project/my-bucket/ {{ workflow.creationTimestamp }} /thanks.txt これはArgoによるWorkflow Variablesの置換がComponentの内部に入るタイミングで実行されるためです。 上記の例ではComponent内で gs://my-gcp-project/my-bucket/2020-11-14 01:51:55 +0000 UTC/thanks.txt と置換され、想定した挙動にはなりません。正しくは以下のように、あらかじめ年月日だけの形にしておく必要があります。 # pipeline_2_fix.py ... @ func_to_container_op def thanks (message: str , gcs_output_path: str ): from myutils import upload_to_gcs # GCSにファイルをアップロードする関数 print (f '{gcs_output_path=}' ) # 追加 upload_to_gcs(message, gcs_output_path) @ dsl.pipeline def pipeline (text: str ): """ 毎日thanks.txtをGCSに出力する """ today = '{{workflow.creationTimestamp.Y}}{{workflow.creationTimestamp.m}}{{workflow.creationTimestamp.d}}' # 変更 thanks_path = f 'gs://my-gcp-project/my-bucket/{today}/thanks.txt' print (f '{thanks_path=}' ) thanks_task = thanks(message= 'byebye' , gcs_output_path=thanks_path) ここで、各print文の出力は以下のようになります。 thanks_path = ' gs://my-gcp-project/my-bucket/{{workflow.creationTimestamp.Y}}{{workflow.creationTimestamp.m}}{{workflow.creationTimestamp.d}}/thanks.txt ' gcs_output_path = ' gs://my-gcp-project/my-bucket/20201114/thanks.txt ' 実際の運用ではComponentの出力先の初期化を行うためのComponentを作り、その内部で必要な日時を生成するようにしました。 # pipeline.py from typing import NamedTuple from kfp import dsl from kfp.components import func_to_container_op @ func_to_container_op def initialize (timestamp: str ) -> NamedTuple( 'Outputs' , [( 't_jst' , str ), ( 't_ymd' , str ), ( 't_hour' , str )]): from datetime import datetime, timezone, timedelta shift_hours = 9 # Create and shift timestamp JST = timezone(timedelta(hours=shift_hours), 'JST' ) jst_dt = datetime.strptime(timestamp, '%Y-%m-%d %H:00:00' ).astimezone(JST) t_jst = jst_dt.strftime( '%Y-%m-%d %H:00:00' ) t_ymd = f '{jst_dt.year:04}{jst_dt.month:02}{jst_dt.day:02}' t_h = f '{jst_dt.hour:02}' return (t_jst, t_ymd, t_h) @ dsl.pipeline (name= 'Prediction Pipeline' ) def pipeline (GCS_OUTPUT_DIR): init_task = ( initialize( timestamp= '{{workflow.creationTimestamp.Y}}-{{workflow.creationTimestamp.m}}-{{workflow.creationTimestamp.d}} {{workflow.creationTimestamp.H}}:00:00' ) .set_display_name( 'Initialize' ) ) # Define gcs path gcs_hourly_output_dir = f '{GCS_OUTPUT_DIR}/{init_task.outputs["t_ymd"]}/{init_task.outputs["t_hour"]}' ... 上記の例はタイムゾーンの変換をするためにinitializeというComponentを設けています。この例では datetime ライブラリを使うのと変わりありませんが、Argoの機能としてタイムゾーンの指定ができるようになればよりスマートにPipeline内で日時情報を扱うことができるでしょう。 https://argoproj.github.io/argo/variables/ argoproj.github.io Slack通知 定期実行しているPipelineが正常に稼働していることを監視するために、Kubeflow Pipelines SDK (kfp)の kfp.dsl.ExitHandler クラス(以下 ExitHandler )を利用しています。 ExitHandler はwithブロックから抜け出す際に実行するComponentを指定するものです。 https://kubeflow-pipelines.readthedocs.io/en/latest/source/kfp.dsl.html#kfp.dsl.ExitHandler kubeflow-pipelines.readthedocs.io 以下のようにSlack通知用のComponentを定義して ExitHandler の実行Componentに指定することで、Pipelineが異常終了した際にSlackで通知を飛ばします。ここではSlack通知のメッセージにRun名やKubeflow PipelinesのURLを記載するために、Workflow VariablesをComponentの引数として指定しています。 # pipeline.py @ dsl.pipeline () def pipeline (): with dsl.ExitHandler(exit_op=slack_notification_op( slack_webhook_url= "<<SLACK_URL>>" , status= "{{workflow.status}}" , job_name= "{{workflow.name}}" , pipelines_url= "<<KUBEFLOW_URL>>" + "/#/runs/details/" + "{{workflow.uid}}" ) ): init_task = ( initialize( timestamp= '{{workflow.creationTimestamp.Y}}-{{workflow.creationTimestamp.m}}-{{workflow.creationTimestamp.d}} {{workflow.creationTimestamp.H}}:00:00' ) .set_display_name( 'Initialize' ) ) ... ただしこの方法はPipelineの実行可否を通知するものであり、Component単位で通知ログを残すことはできません。こうしたログをSlackに通知するには、2020年11月現在ではComponent内部にSlack通知用の関数を埋め込むか、ログに何らかのタグを埋め込んでCloud LoggingとCloud Monitoringで拾うしか方法がないと思われます。 もし何か他にベストプラクティスをご存知の方がいらっしゃいましたら是非ご連絡をお願いします。 ノードプールによるリソースと権限の分離 AI Platform Pipelinesではインスタンス作成時に自動的にGKEクラスタも作成されると前述しましたが、予め用意しておいたGKEクラスタを指定することもできます。自動的に作成されるGKEクラスタには 123456789-compute@developer.gserviceaccount.com のようなCompute Engineのデフォルトサービスアカウント名が割り当てられ保守性に欠けるため、事前にサービスアカウントを作成しGKEクラスタに割り当てておくのが吉と言えます。 今回の案件で使用する特徴量にはユーザーの属性情報や回遊行動を用いるため、様々なBigQueryのテーブルを参照することになります。GKEクラスタと紐づけたサービスアカウントに必要な権限を全て付与しても良いのですが案件ごとに参照するテーブルやGCSのバケットは変わり得るため、権限は案件ごとに分けたいという思いがありました。 そこで今回は案件ごとに使用するノードプールを分けて、ノードプールに対してサービスアカウントを紐づける方法を取るようにしました。Kubeflow PipelinesではComponentごとに使用するノードを指定できるため、ハイメモリインスタンスを使う場合やGPUを使う時にも専用のノードプールに切り替えることができます。 init_task = ( initialize( timestamp= '{{workflow.creationTimestamp.Y}}-{{workflow.creationTimestamp.m}}-{{workflow.creationTimestamp.d}} {{workflow.creationTimestamp.H}}:00:00' ) .set_display_name( 'Initialize' ) .add_node_selector_constraint( 'cloud.google.com/gke-nodepool' , 'high-memory-pool' ) ) add_node_selector_constraint の第一引数にはノードラベルを指定し第二引数にはノード名を指定します。 cloud.google.com https://kubeflow-pipelines.readthedocs.io/en/latest/source/kfp.dsl.html#kfp.dsl.ContainerOp.add_node_selector_constraint kubeflow-pipelines.readthedocs.io Componentの実行に使うノードプールはノード数を0に指定してオートスケーリングするように設定することで、リソースを使わない時に余計なノードプールが残らないようにしました。 なお、以下のようにComponentが利用するノードプールのリソースサイズを指定して垂直スケールさせることもできます。権限管理を考えなければこちらの方がスマートにマシンリソースをスケールさせることができるでしょう。 # Using Large memory preprocess_task = preprocess_op(csv_files=csv_file_path).set_memory_request( "60G" ) https://kubeflow-pipelines.readthedocs.io/en/latest/source/kfp.dsl.html#kfp.dsl.Sidecar.set_memory_request kubeflow-pipelines.readthedocs.io CI/CDの実装 Kubeflow Pipelinesを使った継続的な開発を進めるために検討しなければいけないのがCI/CDの実現方法です。 Google Cloudの公式ドキュメントには Kubeflow Pipelinesに対するCI/CDワークフローのユースケース例 が提示されています。こちらのCI/CDワークフローではCloud Source RepositoriesやGitHubリポジトリに対してCloud Buildでビルドトリガーを設定し、ブランチにcommitやmergeが発生した時にCloud Buildで構築したワークフローを実行するというアーキテクチャになっています。 github.com 一方、推薦基盤チームでの製作物はGitHub ActionsでCI/CDを構築・管理する土壌ができていたため、今回もGitHub Actionsで完結させたいという思いがありました。 そこで調査を進めた結果、同様の課題を抱えた先人が上に示したワークフローをGitHub Actionsで作ってくれていました。 github.com こちらのGitHub Actionsを使うことでコンパイルされたpipeline.yamlファイルをKubeflow Pipelinesにデプロイできます。しかし、今回の要件として定期実行する必要があり、このGitHub ActionsではKubeflow Pipelinesの Recurring Run (cron実行機能)を利用することができませんでした。 追加機能としてPull Requestを出しても良かったのですがログを見る限りではあまりメンテナンスがされていないようだったので、必要な機能を参照しつつRecurring Run機能を追加実装することとしました。 実装内容としては単純で、以下のように recurring_flag というフラグを設けてフラグが立っている時にRecurring Runを登録するようにしました。 # github_actions.py def run_pipeline_func (client: kfp.Client, pipeline_name: str , pipeline_id: str , pipeline_paramters_path: dict , recurring_flag: bool = False , cron_exp: str = '' ): ... if recurring_flag: client.create_recurring_run(experiment_id=experiment_id, job_name=job_name, params=pipeline_params, pipeline_id=pipeline_id, cron_expression=cron_exp) client.run_pipeline(experiment_id=experiment_id, job_name=job_name, params=pipeline_params, pipeline_id=pipeline_id) ... このGitHub Actionsを用いて、最終的に以下のようなワークフローとなりました。 今回作成したGitHub ActionsをFork元にマージするか否かはまだ決めかねていますが、また別のテックブログ記事で使い方を含めて公開したいと思います! 今後の展望 今回はKubeflow導入の足掛かりとして、Kubeflow PipelinesのマネージドサービスであるAI Platform Pipelinesを用いたバッチ実行の予測パイプラインを構築しました。一方で、以下のように利用・検討しきれていないことも多々あります。 モデルの再学習を伴うPipelineの運用 :AI Platform Training + Vizier 学習済みモデルのオンラインサービング :AI Platform Prediction or GKEで自前ホスティング Feature Storeによる特徴量の管理 : Feast AI Platform Pipelinesインスタンスの管理単位 特にAI Platform Pipelinesインスタンスの管理については悩ましいものがあります。今後様々なMLモデルが増えるにつれて、GKEクラスタとAI Platform Pipelinesインスタンスはどのように立てていくのが良いのかという問題が生じます。 AI Platform Pipelines1つにつきGKEクラスタ1つを紐づけるように 公式ドキュメントには記載されている ため、AI Platform Pipelinesインスタンスを立てるにつれてGKEクラスタも増え、コストの増加に繋がってしまいます。またGCPプロジェクトごとにインスタンスを立てるとなると、コストだけでなく管理面でも複雑になり得ます。 この問題の対応については現在SREチームと共に、GKEクラスタに対して自前でKubeflow環境を構築することも視野に入れながら模索中という段階です。 ML基盤として目指す姿 最後に、推薦基盤チームで目指すMLプロダクト管理基盤の全体像について簡単に述べておきます。まだ構想段階ではありますが、大まかには以下のようなアーキテクチャを考えています。 基本的にはGCPのマネージドサービスを積極的に取り入れていくことを考えています。 この中でも特にポイントとなるのは今回ご紹介したワークフロー実行基盤であるKubeflow Pipelinesに加え、特徴量の管理基盤である Feast 、そして成果物を一元管理する AI Hub でしょう。 FeastはKubeflowにてα版でサポートされているFeature Storeです。 www.featurestore.org Feature Storeはモデル構築・運用で使用している特徴量を管理するための基盤の総称で、2017年にUberで使われている Michelangelo というML基盤の紹介記事が初出のようです。 2019 Slides - Michelangelo Palette: A Feature Engineering Platform at Uber from Karthik Murugesan www.slideshare.net モデルを作成する際に必要となる特徴量はFeature Storeから取得し、モデルサービング時にも同様にFeature Storeから同様の特徴量を使用することでモデル構築とサーブ時で使用する特徴量の齟齬を解消できます。AI Platformでも近いうちにFeature Store機能が追加されていくようなので、今後のML基盤の構成要素としてスタンダードなものになっていきそうです。 AI Hub はプロジェクトでの成果物(モデル、Pipeline、Componentなど)や分析結果(ノートブック、クエリなど)といったアセットを一元管理し再利用できるようにする大きな箱、というイメージです。AI Hubでは社内で利用するプライベートなPipelineや学習済みモデルの管理に加えて、パブリックなPipelineやNotebookを探すこともできます。また、登録するアセットの種類やカテゴリ、データ種別でラベル付けをすることで必要なアセットを容易に検索できます。 プロジェクトの成果物や分析結果、各種アルゴリズムの検証結果などをAI Hubで統合管理し組織のナレッジベース化することで、推薦基盤チームが抱えている課題の一つである「 車輪の再発明をしないような仕組み 」を実現できると考えています。 一方、現状AI Platformの各種サービスはベータ版での提供のものが多いため本番環境への導入を慎重に検討しつつ、ZOZOTOWNで利用されるMLモデルの管理・運用基盤の構築を進めていきます! おわりに 本記事ではKubeflow PipelinesのマネージドサービスであるAI Platform Pipelinesの紹介と本番環境への導入に際して直面した問題について述べました。推薦基盤チームではZOZOTOWNで運用する推薦システムをより良くできるように、日々新しい技術のキャッチアップをして様々な可能性を模索していきたいと考えています。 ZOZOテクノロジーズではZOZOTOWNの推薦システム構築・運用に興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! https://tech.zozo.com/recruit/mid-career/ tech.zozo.com 参考 Cloud Composerで組む機械学習パイプライン MLSE 機械学習基盤 本番適用と運用の事例・知見共有会 Kubeflow Pipelinesで日本語テキスト分類の実験管理 Machine Learning Pipelines with Kubeflow How to carry out CI/CD in Machine Learning (“MLOps”) using Kubeflow ML pipelines (#3) ML Feature Stores: A Casual Tour Michelangelo Palette: A Feature Engineering Platform at Uber Feature Store: The Missing Data Layer in ML Pipelines? Feast: feature store for Machine Learning pachyderm/kfdata Prototype implementation of KFData Proposal Introducing AI Hub and Kubeflow Pipelines: Making AI simpler, faster, and more useful for businesses
アバター
はじめに こんにちは。SRE部MLOpsチームの田島( @tap1ma )です。 現在、ZOZOTOWNの「おすすめアイテム」に使われていたアイテム推薦ロジックを刷新するプロジェクトを進めています。既に一部のユーザに向けて新しいアイテム推薦ロジックを使った「おすすめアイテム」の配信を開始しています。その刷新に伴い推薦システムのインフラ基盤から新しく構築したので、本記事ではその基盤について解説したいと思います。 目次 はじめに 目次 「おすすめアイテム」とは 新しい推薦ロジック Recommendations AIを用いた推薦ロジック ZOZO研究所によって独自で開発された推薦ロジック 新しい推薦システム 推薦システムの処理の流れ システム構成 新しい推薦システムで工夫したポイント Bigtableのパフォーマンス改善 アイテム推薦APIのPodの安全停止 ZOZO研究所APIのキャッシュ戦略 推薦ロジックのモデル更新時のワークフロー 推薦ロジックのモデル更新時のワークフローで工夫したポイント Cloud Runの選定 Cloud Pub/Subのat-least-once配信の考慮 Cloud Monitoringを使用したGKE Jobの監視設定 JobのPodステータスがFailedになった時のアラート設定 長時間経過してもJobのPodステータスがSucceededにならない時のアラート設定 まとめ 最後に 「おすすめアイテム」とは この記事で扱う「おすすめアイテム」とは、ZOZOTOWNで取り扱っている各アイテムの詳細ページ内にある「おすすめアイテム」枠のことです。アイテム詳細ページのアイテムやそのページを閲覧しているユーザに合わせて、おすすめのアイテムを複数表示しています。 アイテムの詳細ページは、アイテムの詳細な説明やサイズ毎の在庫状況、商品画像といった情報を含み、ユーザがアイテム購入時に必ず通る重要なページです。そして、アイテム詳細ページ内に設置された「おすすめアイテム」枠もまたZOZOTOWNの重要な要素の1つです。 しかし、これまで使われていた推薦ロジックは10年以上前に開発されたもので、ストアドプロシージャとしてオンプレミスのSQL Serverに保存されているなど非常にレガシー化したシステムの上で動いていました。そのため、大きな技術的負債となっていました。 この度、より高性能な推薦ロジックの導入とそのためのシステムをインフラ基盤から新しく構築することで、推薦ロジックの性能向上と推薦システムの技術的負債の回収を同時に実現できました。 新しい推薦ロジック 推薦ロジックの刷新に際し、以下の2種類の推薦ロジックを開発しました。 Recommendations AIを用いた推薦ロジック ZOZO研究所によって独自で開発された推薦ロジック 2種類の推薦ロジックの開発を並行で行い、互いに性能を競わせながら、より高性能な推薦ロジックの実現を目指して日々開発に取り組んでいます。 Recommendations AIを用いた推薦ロジック Recommdendations AI はECサイトに特化し、ユーザにパーソナライズされた商品の推薦システムを機械学習の高度な知識を必要とせずに簡単に構築できるGCPのフルマネージドサービス(本稿執筆時点でベータ版)です。 推薦ロジックのモデル構築に必要なデータを入力することで推薦ロジックの機械学習モデルの構築から、そのモデルを使って商品の推薦結果を返す推論用のWeb APIのサービングまで自動で行ってくれます。 また、データの入力に対してリアルタイムでモデルを更新できること、推論用のWeb APIがスケーラブルであることといった特徴を持ちます。 詳しくは こちらの資料 をご覧ください。 ZOZO研究所によって独自で開発された推薦ロジック 弊社が有する研究機関「 ZOZO研究所 」によって独自で開発された推薦ロジックです。 現在は、 ランダムウォーク のアルゴリズムをベースとし、高速な推論速度、アイテムのカテゴリ分布の調整が可能(=推薦アイテムの多様性を制御できる)、などの特徴を持つ推薦ロジックとなっています。 手法の詳細は本記事では割愛しますが、現在もより高性能な推薦ロジックの実現を目指して様々な手法を用いた開発が進められています。 新しい推薦システム 本章では、ユーザがアイテムの詳細ページへアクセスした際に詳細ページの「おすすめアイテム」枠に最適なアイテムを選出する新しい推薦システムについて詳しく解説します。 推薦システムの処理の流れ 新しい推薦ロジックを使った推薦システムはGCP上でVPCから新規で構築しました。 以下が新しい推薦システムのシステム構成の概略図です。 推薦システムはAWS上に存在するZOZOTOWNのバックエンドAPIからアクセスされ、推薦結果のアイテム情報をリストで返します。AWS→GCPのプライベート接続には、AWSでは Direct Connect 、GCPでは Dedicated Interconnect という専用線サービスを使用しオンプレ経由での専用線接続を行うことで高可用・低レイテンシーな通信を実現しています。 ZOZOTOWNバックエンドAPIからきたリクエストを推薦システムが処理して推薦結果となるアイテムのリストを返すまでの流れを、図の番号に沿って説明します。なお、登場するコンポーネントの説明は後述します。 ユーザがアイテムの詳細ページを開いた時に非同期でZOZOTOWNバックエンドAPIはアイテム推薦API宛にGETリクエストを投げます。 Internal Load BalancerはZOZOTOWNバックエンドAPIからのリクエストをアイテム推薦APIに振り分けます。 アイテム推薦APIはまずRecommendations AI APIまたはZOZO研究所APIに対してリクエストを投げて、「おすすめアイテム」枠に表示すべきアイテムIDのリストを取得します。 アイテム推薦APIは3.で取得したアイテムIDのリストに対してRedisにアクセスし、キャッシュヒットした場合は、取得したアイテムの詳細情報を推薦結果のアイテムIDのリストに付加します。 Redisアクセス時にキャッシュヒットしなかった場合は、Bigtableへアクセスし取得したアイテムの詳細情報を推薦結果のアイテムIDのリストに付加します。 Bigtableから取得したアイテムの詳細情報をRedisにキャッシュさせた後、推薦結果のアイテムIDのリストに付加します。 推薦結果のアイテム情報のリストをZOZOTOWNバックエンドAPIへ返します。 システム構成 以下、図の各コンポーネントを解説します。 Internal Load Balancer ZOZOTOWNのバックエンドAPIからのアクセスを捌くL7ロードバランサーです。今回GKE(Google Kubernetes Engine)クラスタを新規で構築し、後述する「アイテム推薦API」及び「ZOZO研究所API」のサーバーをGKEクラスタのPod上で稼働させています。このGKEクラスタはプライベートネットワーク内に閉じているため、GKEクラスタの受け口にL7内部負荷分散を立てる必要がありました。GKEのバージョン1.16.5-gke.10からGCPのL7内部負荷分散に対応した Ingress for Internal HTTP(S) Load Balancing が使用できるようになったので、今回初めて採用しました。 アイテム推薦API アイテム推薦APIはZOZOTOWNバックエンドAPIからInternal Load Balancer経由で届いたリクエストに対して、最終的な推薦結果となるアイテムをリストで返すAPIサーバーです。ZOZOTOWNバックエンドAPIから届くリクエストのパラメータには閲覧しているアイテムの情報とユーザの情報、推薦結果として取得したいアイテムの件数が含まれています。Java製フレームワーク Spring Boot を用いて作られており、GKEのPod上で稼働しています。 ZOZO研究所API ZOZO研究所が開発した推薦ロジックを用いた推論用のAPIサーバです。アイテム推薦APIからきたリクエストに対してそのアイテムの詳細ページの「おすすめアイテム」枠に最適なアイテムを推論し、アイテムIDのリストを返します。Python製フレームワークのFlaskを用いて作られており、GKEのPod上で稼働しています。 Recommendations AI API GCPのRecommendations AI上で構築したアイテム推薦の推薦ロジックの機械学習モデルによる推論を行うWeb APIです。ZOZO研究所API同様、アイテム推薦APIからきたリクエストに対して推論したアイテムIDのリストを返します。 アイテムデータベース ZOZOTOWNの最新のアイテム情報のデータが格納されているデータベースで、GCPのフルマネージドな大規模分散データベースであるCloud Bigtableを使用しています。Cloud Pub/Sub経由で送られてくるアイテムのデータの更新情報がリアルタイムで反映されるので、常に最新のアイテムデータが格納されています。 アイテム情報キャッシュ GCPのフルマネージドなRedisサービスを利用しています。Bigtableへの負荷軽減のためにアイテム推薦APIがBigtableから取得したアイテムの情報は一定期間Redisにキャッシュさせています。 新しい推薦システムで工夫したポイント 以下、工夫した点についていくつか紹介します。 Bigtableのパフォーマンス改善 ZOZOTOWNの最新のアイテムの情報を格納しておくデータベースとして以下の要件から高スループットかつ低レイテンシーなGCPの Cloud Bigtable を採用しました。 大量の書き込みに耐えられる 高速な応答性能を持つ推薦システムを実現できる しかし、実際にアイテム推薦APIからBigtableへアクセスしてみると期待通りの応答性能が出なかったため、パフォーマンスチューニングを行い応答性能を改善しました。ここでは、実際に行ったチューニング方法について説明します。 前提として、アイテム推薦APIは以下のような実装となっていました。 アイテム推薦APIではJava製のBigtableクライアントライブラリ bigtable-hbase-2.x のバージョン1.12.0を使用しています。 アイテム推薦APIからBigtableへのアクセスは全て Multi-Get という参照系の操作です。 調査したところ、上記のBigtableクライアントライブラリによるBigtableへのアクセスエラー発生時のリトライ処理は以下のような挙動をしていることが分かりました。 BIGTABLE_RPC_TIMEOUT_MS_KEY で設定されたタイムアウト時間(ミリ秒)を迎えるまでリトライ処理を繰り返す。 参照系処理の場合はタイムアウト後に MAX_SCAN_TIMEOUT_RETRIES の回数だけ更にリトライ処理が走る。なお、ドキュメントに記載は見当たりませんが、実装を確認するとscanだけではなくgetの処理においてもMAX_SCAN_TIMEOUT_RETRIESの回数分リトライ処理が走ることが分かっています。 今回、アイテム推薦システムのタイムアウト時間の要件は5秒であったため、元々の設定であった設定値Aとタイムアウト時間を減らしてその分タイムアウト後のリトライ回数を増やした設定値B、2つの設定値でアイテム推薦APIに負荷をかけてBigtableへのアクセス時の応答性能を測定しました。 実験の結果、設定値Bの応答速度は設定値Aに比べて99パーセンタイル値の比較で平均約29%速いことが分かりました。つまり、応答が想定時間で返ってこない場合はそのまま待つよりも再度リクエストを投げた方がより速く応答を返しやすいようです。 以上の結果を踏まえてBの設定値となるように更新し、パフォーマンスを改善できました。 アイテム推薦APIのPodの安全停止 アイテム推薦APIのデプロイ時にPodがローリングアップデートされた際、Ingress for Internal HTTP(S) Load Balancing(以下、Ingress)でステータスコード503のエラーが出る事象が発生しました。調査したところ、原因はGKEのIngressの コネクションドレインのタイムアウト時間 がデフォルトの0秒であったためコネクションドレインが機能せずリクエスト処理の途中でコネクションが切られてしまっていたからでした。そこで、適切なコネクションドレインのタイムアウト時間を設定したのですが、ここではその設定の際に考えたことについて説明します。 PodがIngressから登録解除されて停止する際にPodでは preStop フックの実行処理が、Ingressではコネクションドレインの処理が同時に非同期で実行されます。そして、PodではpreStopフックの実行終了後にコンテナのルートプロセスに対してSIGTERMが送られ、サーバーがGraceful Shutdownされます。実際に計測してみるとアイテム推薦APIのpreStopフックの処理が始まって10〜15秒後にコネクションドレインの処理が始まっていることが分かりました。そのため、まずはpreStopフックでは15秒のsleep処理を走らすことで、PodがIngressから登録解除されてコネクションドレイン処理開始する前にサーバーのGraceful Shutdownが始まらないように調整しました。また、サーバーのGraceful Shutdownに要する時間は約20秒だったので、この場合のコネクションドレインのタイムアウト時間はPodのpreStopフックの開始から正常にGraceful Shutdownされるまでの時間である(15秒+20秒=)35秒以上に設定すべきであることが分かります。実際には少し余裕を持ってコネクションドレインのタイムアウト時間として45秒を設定しました。 ZOZO研究所APIのキャッシュ戦略 ZOZO研究所APIに対して負荷試験を行ったところ、クエリとなるアイテムIDによって応答速度に大きなばらつきがあり、CPUスパイクも頻繁に起こしていました。 調査したところ、以下のことが判明しました。 推薦ロジックのアルゴリズムの性質上、アイテム詳細ページのアクセス数が多いアイテムほど推論の計算コストが高くなるため、推論速度が遅い。 アイテム詳細ページのアクセス数はアイテム毎に大きな偏りがある。 上記の特性を踏まえて、アクセス数上位のアイテムに関しては推論結果をキャッシュするようにしました。ZOZO研究所APIのPod起動時に推論を行い、サーバーのメモリ上に推論結果を展開しています。キャッシュしたアイテムの数は総アイテム数のわずか0.2%ですが、システムパフォーマンスが大幅に向上し、観測されていたCPUスパイクも起きなくなり安定化できました。 推薦ロジックのモデル更新時のワークフロー ZOZO研究所製の推薦ロジックに使用するモデルの更新は毎日1回行われています。 ここでは、そのモデル更新時のワークフローを解説したいと思います。以下が、そのワークフローの概略図です。 Cloud Composer とBigQueryで日次集計された最新のアイテムデータを読み込み、Pythonスクリプトによって推薦ロジックのモデルファイルを生成します。この日次集計処理は今回のプロジェクト以前から運用されていて、かつ、異なるGCPプロジェクトで存在していました。そのため集計完了の通知をCloud Pub/Subで受け取るようにし、本プロジェクトのモデルファイル生成Jobを実行するトリガーとしています。Cloud RunはCloud Pub/Subからメッセージを受け取りGKEのJobを実行するトリガーとしてのみ利用しています。なお、Cloud Run・GKEのJobの選定に関しては後述します。 モデル更新時の流れを図の番号に対応する手順で説明します。 Cloud Composerの日次集計で最新のアイテムデータをBigQueryに保存後、集計完了を意味するメッセージをCloud Pub/Subにパブリッシュします。 Cloud Pub/SubはCloud Composerから飛んできたメッセージをトリガーにCloud Runのエンドポイントを叩きます。 Cloud Runのエンドポイントが叩かれるとCloud Runではモデル作成用のPythonスクリプトを実行するGKEのJobをアイテム推薦APIと同じGKEクラスタ上で作成します。Cloud RunではGoで書かれたAPIサーバが動いており、そのAPIサーバのエンドポイントが叩かれるとモデル作成用のPythonスクリプトを実行するJobをアイテム推薦APIと同じGKEクラスタ上に作成します。Goの選定理由はKubernetes APIアクセス時に使用するGo言語用のKubernetesクライアントライブラリ client-go が他の言語用のクライアントライブラリに比べて開発が活発で継続的にメンテナンスされることが期待できるためです。 GKEのJobではモデル構築用のPythonスクリプトを実行してBigQueryから最新のアイテムデータを読み込んでモデルファイルを作成し、Cloud Storageにアップロードします。 GKEのJobはCloud Storageにモデルファイルをアップロード後、最後にZOZO研究所APIのPodを kubectl rollout restart コマンドによって再起動させて処理が終了します。 ZOZO研究所APIのPodは再起動時に新しいモデルファイルをCloud Storageからダウンロードし、推論時にそのモデルを使用するようになります。 推薦ロジックのモデル更新時のワークフローで工夫したポイント 以下、推薦ロジックのモデル更新時のワークフローで工夫した点についていくつか紹介します。 Cloud Runの選定 前述の通り、既存のCloud Composerとは別のGCPプロジェクトでモデル更新のジョブを実行する必要がありました。そこで、Cloud Composerの日次集計のワークフローの最後にCloud Pub/Subへ通知を送り、その通知をトリガーに別のGCPプロジェクトでモデル更新のジョブを実行する設計としました。 1日1回Cloud Pub/SubからくるPOSTリクエストをトリガーにワークロードを実行する用途として、リクエストが実際に処理されている時間のみ課金が発生する以下の3つのサーバレスソリューションを検討しました。 App Engine(スタンダード環境) Cloud Functions Cloud Run ただし、現在弊チームでは Anthos 環境を運用していないので、Cloud Runの場合は Cloud Run for Anthos on Google Cloud ではなくフルマネージド版のCloud Runのみに限ります。 また、これらのメモリの上限値は以下の通りです。 App Engine(スタンダード環境) Cloud Functions Cloud Run(フルマネージド) メモリ上限 2Gi 2Gi 4Gi モデル更新ジョブのメモリ消費量は上記のどのソリューションにおいてもそのメモリ上限値を超えてしまうので、モデル更新処理をそれだけで完結することはできません。そこで、モデル更新ジョブをアイテム推薦APIのPodなどが稼働しているGKEクラスタ内のハイメモリなインスタンス上でKubernetes Jobとして実行することにし、そのJobの作成処理を上記のサーバレスソリューションのいずれかで実行することにしました。理想を言うと、Cloud Pub/Subへの通知をトリガーとして直接GKEのJobを作成できるようなソリューションがあったら嬉しいですね。 検討の末、他の2つに比べてワークロードのランタイムに縛りがなく、Dockerfileで管理できる開発のしやすさの点で優れたCloud Runをチームで今回初めて採用しました。また、Cloud Runでは、Cloud Pub/Subからきたリクエストの トークン認証処理 を組み込みでサポートしているので、簡単な設定でCloud Pub/SubとCloud Run間を安全に通信することができます。GCPの公式ドキュメントには記載が見当たりませんでしたが、Cloud RunとCloud Pub/Subが異なるGCPプロジェクトに存在しているケースでもトークン認証処理を利用することができます。便利。 Cloud Pub/Subのat-least-once配信の考慮 Cloud Pub/Subはat-least-once配信方式を採用している ため、同一のメッセージが複数回配信される可能性があります。そのため、たとえCloud ComposerからCloud Pub/Subへのメッセージのパブリッシュは1日1件であっても、そのメッセージが複数回配信されてCloud Runのエンドポイントが1日に複数回叩かれてしまう場合を考慮しないといけません。 まず、Cloud Runで日に複数回GKEのJobの作成処理が実行される可能性があるため、GKEのJobの処理が冪等である必要があります。今回GKEのJobで実行するモデル作成処理は冪等であるため、この要件は満たしていました。 また、Cloud RunによるGKEのリソースへの操作が並列で行われる可能性も考慮する必要があります。Cloud Runの処理ではGKEのJobを作成するためにKubernetes APIを使ってGKEのリソースを操作します。実際の処理ではJobの作成処理だけでなく、昨日分のJobの設定をクリーンアップしたりJob作成時に立ち上がったPodの情報を取得したりと、Cloud Runのエンドポイントへのリクエスト毎に複数回Kubernetes APIへのアクセスが発生します。もしCloud Pub/Subからほぼ同時に複数のリクエストがきた場合、これらのKubernetes APIへの操作が並列で実行されるため、Jobの作成処理が同時に2度実行されてしまい片方の処理がエラーになるといった問題に繋がる可能性があります。一方で、Kubernetes APIを使った操作は非同期なこともあり、このようなエッジケースにも対応した並行性制御を実装するのはなかなか大変です。そこで、複数リクエストが同時にきてもCloud Runではリクエストを並列では処理しないようにすることでGKEのリソースへの一連の操作を排他制御するようにしました。具体的には、Cloud Runではコンテナあたりの最大同時リクエスト数とスケールアウト時の最大コンテナインスタンス数を簡単に設定できるので、どちらも最大値を1とすることで複数リクエストを並列で処理しないようにしました。 Cloud Monitoringを使用したGKE Jobの監視設定 GKEのJobの監視を導入時にいくつか躓いた点があるので、それらの点も踏まえてどのような設定をしたのかをここでは説明したいと思います。 以下の2種類の監視が現在設定されています。 JobのPodステータスがFailedになった時のアラート設定 長時間経ってもJobのPodステータスがSucceededにならない時のアラート設定 JobのPodステータスがFailedになった時のアラート設定 当初はJobが異常終了した時、すなわち、Jobが作成したPodのステータスがFailedとなった時にアラートが鳴るようにCloud Monitoringで監視を導入しようと試みました。しかし、Cloud MonitoringではPodのFailedステータスを直接カウントしアラートのトリガーとするような設定方法はサポートされておりませんでした。そこで、代わりにPod内のコンテナの再起動回数であるrestart countをトリガーに使用できることを利用し、Pod内のコンテナの異常終了後の再起動処理が発生( restart count > 0 )したらアラートが鳴るように設定しました。ただし、このやり方は以下の点において理想的な監視とは言えない妥協策です。 JobのrestartPolicyがOnFailureに設定されている場合においてのみ使える方法である Podの失敗ステータスを直接監視できておらず、間接的な監視となってしまっている もっと適切な方法ご存知の方いましたら教えてください! 長時間経過してもJobのPodステータスがSucceededにならない時のアラート設定 Jobが失敗した時の監視だけでは以下のような異常時のケースを拾うことができません。 そもそもJobが実行されていない場合 Jobの処理途中になんらかの理由でスタックしている場合 上記のケースが発生した際にアラートが飛ぶように、日次で実行されるJobがその日のうちに正常終了していない時にアラートがなるような監視も導入しました。しかし、Cloud MonitoringではPodのFailedステータスと同様Succeededステータスを直接カウントしアラートのトリガーとするような設定方法もサポートされておりませんでした。また、Cloud Monitoringでは24時間周期の監視もサポートされていませんでした。そこで、妥協策として以下の手順で監視を設定しました。 Jobの処理の最後にJobの成功を意味するログをコンテナログとして出力するようにする。 GKEのCronJobで毎日定時に直近24時間でCloud Loggingへ出力されたJobのコンテナログからJobの成功を意味するログを検索する。もし成功ログが見つからなかった場合は、エラーログをCronJobのコンテナログに吐くようにする。 Cloud MonitoringでCronJobが吐いたエラーログをトリガーとしてアラートを鳴らすように設定する。 このやり方もPodの成功ステータスを直接監視できていない複雑な設計となってしまっています。もっと適切な方法ご存知の方いましたら教えてください!(2回目) まとめ 本記事ではZOZOTOWNの「おすすめアイテム」枠に使われている新しい推薦システム基盤のアーキテクチャについて解説しました。私が今年入社して最初に取り組んだプロジェクトでしたが、ネットワーク設計から任せてもらい、個人的に思い入れの深いプロジェクトです。ユーザの皆さんが気に入るアイテムをより簡単に見つけやすくできるように引き続き改善に取り組んでいきます。 最後に SRE部MLOpsチームでは、データや機械学習を用いてサービスを成長させたいエンジニアを募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! www.wantedly.com
アバター
こんにちは、ZOZOテクノロジーズ CTO室の池田( @ikenyal )です。 ZOZOテクノロジーズでは、11/5に ZOZO Technologies Meetup〜ZOZOTOWNシステムリプレイスの裏側〜 を開催しました。 zozotech-inc.connpass.com 本イベントでは、ZOZOテクノロジーズがどのようにリプレイスを進めているかをお伝えするイベントで、AWS・Kubernetes・GitHub Actions・Go・ElastiCacheなどをどのように活用しているかをお伝えしました。 登壇内容 まとめ 弊社の社員4名が登壇しました。 ZOZOTOWNリプレイス2020 (SRE部 ECプラットフォーム 瀬尾 直利 / @sonots ) ZOZOTOWNリプレイスにおけるSREの取り組み (SRE部 ECプラットフォーム 高塚 大暉) API Gatewayによるマイクロサービスへのアクセス制御 (ECプラットフォーム部 API基盤チーム 竹中 達志) ZOZOTOWNにおけるキャッシュストアのAWSへのリプレイス (ECプラットフォーム部 マイグレーションチーム 濱砂 晶) 最後に ZOZOテクノロジーズでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに ZOZO研究所ディレクターの松谷です。 ZOZO研究所では、イェール大学の成田悠輔氏、東京工業大学の齋藤優太氏らとの共同プロジェクトとして機械学習に基づいて作られた意思決定の性能をオフライン評価するためのOff-Policy Evaluation(OPE)に関する共同研究とバンディットアルゴリズムの社会実装に取り組んでいます( 共同研究に関するプレスリリース )。また取り組みの一環としてOPEの研究に適した大規模データセット( Open Bandit Dataset )とOSS( Open Bandit Pipeline )を公開しています。これらのオープンリソースの詳細は、 こちらのブログ記事 にまとめています。 techblog.zozo.com 本記事では、ZOZO研究所で社会実装を行ったバンディットアルゴリズムを活用した推薦システムの構成について解説します。バンディットアルゴリズムを用いた推薦システムの構成と実際にどのように活用されたかについて、また公開されたデータセットがどのようなシステムで収集されたか、みなさまの参考になれば幸いです。 本システムの設計・開発と本記事のシステム概要解説については研究所アドバイザーの粟飯原が担当しています。 バンディットアルゴリズムとは まず本プロジェクトのフォーカスであるバンディットアルゴリズムについて解説します。 アルゴリズムについて 環境に対する不完全な事前知識を活用して行動し、環境を観測してデータを集めながら最適な行動を発見する(探索と活用)アルゴリズムがバンディットアルゴリズムです。システム自身が試行錯誤しながら最適なシステム制御を実現する機械学習手法である強化学習の中で、代表的なアルゴリズムのひとつです。 バンディットアルゴリズムが扱う問題設定は「複数の選択肢または介入からできるだけ良いものを選択したい」というものです。例えば、推薦する商品Aと商品Bのどちらがより顧客にクリックされるか、新薬Aと新薬Bのどちらがある病気を効果的に治癒するのかというような状況です。この問題の難しさは、良い選択肢をできるだけ多く選択したいという活用と、とるべき選択肢が何であるかをできるだけ正確に知りたいという探索のトレードオフがあることによります。 このような状況で選択するためによく用いられるのは、A/Bテストと呼ばれる手法です。AとBの2つの介入をランダムに割り当ててどちらが平均的に優れているのかを統計的仮説検定に基づいて選択します。しかし典型的なA/Bテストでは純粋な探索を一定期間行ってから純粋な活用を行うため、無駄な探索をしてしまったり誤った活用をしてしまうリスクがあります。 バンディットアルゴリズムは過去の経験に基づく予測の活用と探索のトレードオフをデータから最適化し、累積報酬(例えばクリック数)を最大化するように意思決定を行います。 Web広告配信や推薦システムでよく利用されており、またトップ棋士に勝ち越したことで有名なAlphaGo(アルファ碁)にもその技術が応用されています。 Multi-Armed Bandit Algorithm 強化学習において状態が変化しない最も単純な設定です。アーム(選択肢)を引くとスロットマシンがある確率に基づいて報酬が得られるという設定のもと、行動の主体であるエージェントは腕を引くという行動だけ行います。これは、例えば商品アイテムの推薦においては実際に推薦結果として提示する商品アイテムの選択になります。 Contextual Bandit Algorithm バンディットアルゴリズムは拡張によりユーザーの属性に合わせパーソナライズを扱うことが可能です。ユーザー属性/履歴などの埋め込みベクトルを用い、ユーザーそれぞれに最適な行動(何を表示するか)を決定します。どのようにパーソナライズするかは、コンテキストの設計により調整が可能です。実際の応用としては、Spotifyホーム画面のパーソナライズやNetflixのアートワークパーソナライズなど、推薦に力を入れているサービスにおいて利用されている例が挙げられます。 ユースケース 例1)良いクリエイティブの選択 バンディットアルゴリズムは複数ある商品画像のうちどのクリエイティブを表示するかなどにも利用されています。よりクリックされやすいクリエィティブに自動で寄せるなど、無駄なインプレッションを減らしつつKPIの向上を目指すことが可能です。 例2)良い推薦アイテムの選択 バンディットアルゴリズムはEコマースなどの推薦において、対象アイテムを絞り込んだ後のリランキングなどに使用されることもあります。通常のランキング集計よりも新しいアイテムなどの追加にも早く対応でき、機会損失を減らしつつコンバージョンなどKPIの改善を目指すことが可能となります。 ZOZOTOWNにおけるバンディットアルゴリズムを用いた推薦 ZOZO研究所では、多腕バンディットアルゴリズム(Multi-Armed Bandit Algorithm)を用い、数あるファッションアイテムの中からユーザーごとに適したアイテムを推薦するシステムを開発し、ZOZOTOWNのトップページにおいて実際に配信を実施しました。 図1:バンディットを用いたZOZOTOWNにおけるファッションアイテムの推薦 トップページ来訪ユーザーに対してRandomまたはBernoulli Thompson Sampling(BernoulliTS)という2種類の意思決定policyを振り分けて適用しています。 ユーザーの属性に合わせた商品の推薦 コンテキストを合わせて選択するように拡張することで(Contextual Bandit Algorithm)、ユーザーの属性に合わせた商品のパーソナライズ推薦も可能です。ZOZOTOWNではユーザーひとりひとりに、より価値のあるサイト上での発見・経験を提供するべく、推薦や検索結果のパーソナライズをすすめています。本プロジェクトの理論部分の成果でもあるオフライン評価の手法(OPE)を用いることで、どのような特徴量を利用すればよいのか効率的に提案することが可能となります。ZOZO研究所では開発したパイプラインを用いて、効率よくどのようなパーソナライズを実際のサービスで用いるべきかを評価・比較して提案につなげています。 本共同研究プロジェクトの取り組み ZOZO研究所では、 機械学習による予測値などに基づいて作られる意思決定policyの性能を評価する 手法であるOPEに関しての研究を進めています。 機械学習は予測のための技術として広く利用されていますが、実際の応用場面に目を向けてみると、予測値をそのまま使うのではなく予測値に基づいて何かしらの意思決定を行うことが目的である場合が多くあります。 例えばクリック率の予測値に基づいてユーザーごとにどのアイテムを推薦すべきか選択する場合、予測そのものよりも、それに基づいて作られる 推薦や広告配信などの意思決定 が重要です。従って、評価自体もクリック率の予測精度よりも最終的な意思決定policy自体の性能を直接評価する方か適切と言えます。 意思決定policyの性能評価において、実際にサービスへ実装しKPIの挙動を確認するオンライン実験には大きな実装コストやユーザー体験の毀損・KPIへのマイナス影響など大きなリスクを伴うため、オフラインで同様に性能を評価する手法が模索されてきました。 この、新たな意思決定policyの性能を過去の蓄積データを用いて推定する問題のことをOPEと呼びます。 NetflixやSpotify、Criteoなどの研究所がこぞってトップ国際会議でOPEに関する論文を発表しており、特にテック企業から大きな注目を集めています。 正確なOPEは多くの実務的メリットをもたらします。例えば現行の推薦ロジックとは異なる新たな推薦ロジック候補がもたらすKPIの値を既にあるデータを用いて見積もることができます。ハイパーパラメータや機械学習アルゴリズムの組み合わせを変えることによって多数生成される候補のうち、どれをオンライン実験に回すべきなのかを事前に絞り込むこともできます。これにより、実装コストやリスクを抑えつつ、より効率的なビジネス・サービス改善が可能となります。 本共同研究プロジェクトではOPEの実証研究と実サービスへの組み込みを目的とし、バンディットアルゴリズムをZOZOTOWNにおけるファッションアイテム推薦枠に実装しました。これにより、A/Bテストを必要としない低コストな継続的サービス改善のための評価フレームワーク構築を目指しています。 次の章では、そのバンディットシステムの構成について解説します。 バンディットシステムの構成 本プロジェクトの理論的な新規性とデータ・評価パイプライン公開についての詳しい解説は こちらの記事 に任せるとして、本研究を進めるにあたりZOZO研究所で開発を進めてきた配信とログ集計基盤について解説します。 本記事で解説するシステムの導入により、ZOZOTOWN上で実際のサービスを改善しつつオフライン評価手法の構築を進めることが可能となりました。 早速本システムの構成の概観から説明します。 図2:Overview インフラとしてGCPを利用しており、配信・ログ収集・バッチ系などはGKE上で動かしています。 内部の通信にはgRPCを用いており、gRPCのロードバランシングや振り分け制御を行うためにIstioを有効にしています。 ユーザーの画面に推薦対象が表示された場合や、クリックした場合、購買があった場合などはトラッキングサーバーにイベントログを送るようにしており、それらの情報を元に配信パラメータを更新します。現状JavaScriptの表示・イベント送信のSDKを用意しており、Web面への配信をしています。ログ周りはBigQueryに保存して、Contextual Banditのパラメータの学習を行うストリーミング処理系としてCloud Dataflowを使っています。 以下それぞれのコンポーネント毎に簡単に説明していきます。 Gatewayサーバー アプリやブラウザなどのクライアントからのHTTPリクエストを受け取とって、gRPCを喋る推薦サーバーにリクエストをプロキシするサーバーです。Istio Ingress GatewayによるgRPC<->httpブリッジの利用も考えましたが、GKE Ingressの裏にGoで書いたGatewayサーバーをおいています。配信対象やA/Bテスト時の振り分けなどを全てIstioの機能で実現できるような構成も可能であったと考えていますが、以下の2つの理由で独自のGatewayを用意することにしました。 GKE IngressにTLS終端を任せられる 振り分けの条件などを柔軟に構成できる Gatewayサーバー側では、リクエストを元にA/Bテストの振り分けのフラグに用いるヘッダ(Request metadata)を付与して推薦サーバーにリクエストを送るようにしています。 推薦サーバー 推薦サーバーは、GCS上に配置された配信データ(配信対象・配信アルゴリズム・コンテキスト情報・配信パラメータの組)のパスを環境変数で指定してデプロイされます。定期的にファイルの更新をチェックしては配信データに更新があるとデータの取得を行って内部のデータを更新しています。推薦サーバーはGoで実装されており、計算部分はOpenBLASをBLASバックエンドにしたgonumを用いています。そして、ユーザーのコンテキスト情報はCloud Datastoreに配置してgRPCリクエスト毎に取得しています。 推薦サーバーは、それぞれ配信データ毎にgRPCサーバーとしてDeploymentを作成しています。その上で同一の配信対象のDeploymentはIstioのVirtual Serivceとして1つにまとめて、A/Bテストなどが行えるようになっています。Virual ServiceのHttpMatchRequestを用いてどのDeploymentにリクエストを送るかの振り分けを行っています。 上記の設定を行った配信対象毎のVirtual Serviceのイメージは以下のようなものになります。 apiVersion : v1 kind : Service metadata : name : DELIVERYTYPE namespace : bandit-api labels : app : DELIVERYTYPE spec : ports : - port : 3000 targetPort : 3000 protocol : TCP name : grpc-DELIVERYTYPE selector : app : DELIVERYTYPE --- apiVersion : networking.istio.io/v1alpha3 kind : DestinationRule metadata : name : DELIVERYTYPE namespace : bandit-api spec : host : DELIVERYTYPE trafficPolicy : loadBalancer : simple : RANDOM subsets : - name : contexuala labels : deliveryname : contexuala - name : contextualb labels : deliveryname : contexuala - name : random labels : deliveryname : random --- apiVersion : networking.istio.io/v1alpha3 kind : VirtualService metadata : name : DELIVERYTYPE namespace : bandit-api spec : hosts : - "msp" http : - match : - headers : some-header : exact : X route : - destination : host : DELIVERYTYPE subset : random - match : - headers : some-header : exact : Y route : - destination : host : DELIVERYTYPE subset : contexualb - match : route : - destination : host : DELIVERYTYPE subset : contexuala DestinationRule にはsubsets以下に配信を振り分けるDeploymentの名前をdeliverynameに列挙します。 VirtualService のHttpMatchRequestルールに、Gatewayサーバが付与するA/Bテスト用のヘッダの条件を記載します。条件にマッチしたリクエストが設定されたsubsetと対応するDeploymentにルーティングされます。 このようにIstioの機能を利用することでリクエストの振り分けが実現でき、推薦サーバーのコードを変更することなく複数のA/Bテストを実施することが可能になります。 トラッキングサーバー 推薦サーバーが返却したアイテム毎にユニークなID(以下、配信ID)を付与しており、それに紐づく形で以下のような詳細情報を記録しています。以下の項目は一部の項目例です。 配信対象 配信名 配信データのバージョン(配信時に使用したパラメータなどは全てGCS上にバージョニングして保存している) 配信アイテムのID 予測したclick確率 表示あたりの予測収益 アイテムに関するメタデータ 予測時に用いたユーザーのコンテキスト情報 このIDはimpression・click・conversion・その他の関連するユーザの行動がある度にトラッキングサーバーへ送信されます。送信された配信IDを元に、紐付けて記録されている詳細情報と合わせてCloud Pub/Subへ送信されCloud Dataflowを介してBigQuery上に記録されます。 集計・配信データ構築 BigQueryからの集計や、配信データの構築はk8sのCronJobとして実行しています。コンテキストの情報を用いない通常のバンディットにおいては、CTRやCVRなどの計算はBigQueryのログを定期的に集計してパラメータを更新しています。 Contextual Banditの学習をCloud DataflowのStreaming処理でどのように行っているのかについては、次の章で細かく解説します。 Dataflowを用いたContextual Banditパラメータの学習 Contextual Banditの学習はログの突き合わせが必要になるので、ログを一度DWHに保存した後で定時バッチにて実行されることが多いと思われます。tracker上でのオンライン学習も考えられますが、clickがなかったimpressionログをどう扱うのかや、複数台で学習したパラメータの混合など考慮しないといけないことが出てきます。 そこで本システムではStreaming処理系を用いて学習モデルパラメータの更新間隔を早められるのではと考え、Cloud Dataflowを用いた半オンラインでの学習を試みました。 CTRの予測をLogistic Thompson Samplingで行うため、Cloud Dataflowを用いて文献 1 にあるラプラス近似を用いたBayesian Logistic Regressionモデルを学習します。 同様の枠組みでCVRの予測なども可能ではありますが、CVは遅れて来ることも多いためストリーミングでの学習は現状CTRのみ行っています。 シリアライズされたcontextの情報を含むimpressionとclickのログをtrackerからCloud Pub/Subへ書き込み、それをDatadlowで読み込む構成になっています。 Streamingの全体の流れとしては以下のようになります。 配信ID(以下コードではBidId)をキーにSession Windowを掛けてGroup Byしてまとめる clickのみのログ(セッションの時間内にclickが届かなかった物)をフィルタリング まとめたclickありなしのログを更に配信アイテムのIDと配信モデル名のtupleをキーにSession Windowを掛けて更新に十分なログが到達するまで保持 配信アイテムのIDと配信モデル名でセッションにまとめたログの中で、ログが含まれない空のセッションを除去 シリアライズされたコンテキストの情報をデシリアライズ MiniBatchでセッション内のログを用いてパラメータを更新して保存 以下のコードのように上記の処理をpipelineとしてつなげています。 def run (argv): mbu_options = MiniBatchUpdateOption(argv, streaming= True , save_main_session= True ) logging.getLogger().setLevel(mbu_options.log_level.get()) with beam.Pipeline(options=mbu_options) as pipeline: log = pipeline | 'Read log' >> ReadPubSubAndUnmarshalJson(mbu_options.log_subscription) (log | 'GroupBy ID' >> GroupByBidID(mbu_options.session_gap) | 'Filter invalid value' >> beam.Filter(filter_invalid_value) | 'GroupBy item and model' >> GroupByItemAndModel(mbu_options.wait_count, mbu_options.wait_process_time) | 'Filter zero window' >> beam.Filter(filter_zero_window) | 'Deserialize context' >> beam.ParDo(ContextDeserializer()) | 'Mini batch update' >> beam.ParDo( MiniBatchUpdater(mbu_options.output_path, mbu_options.batch_size))) MiniBatchUpdater の中で、GCS上に保存されている前回までの学習結果の取得と、出力されたWindow分のデータの更新とGCSへの保存を行っています。 MiniBatchUpdater の詳細は割愛しますが、以下で、半オンラインでの学習の肝となるSession Windowをどのように適用しているか解説します。 GroupByBidID 一定時間内に到達した同一配信IDのimpressionログとclickログをSession Windowを用いてまとめます。 以下のPythonコードはclickとimpressionをSession Windowを用いてまとめるためのTransformのコードです。同一Window内にimpressionとclickのログがある物を「clickあり」、impressionのみの物を「clickなし」として扱います。clickのみのログは後段の処理で取り除いています。DataflowのSession Windowでは、同一キーのログがギャップ期間以内に到達した場合、同じセッションとしてまとめられます。 cloud.google.com 以下のコードではsession_gapという形でギャップ時間を渡しています。 class GroupByBidID (beam.PTransform): def __init__ (self, session_gap): beam.PTransform.__init__(self) self._session_gap = session_gap.get() self._mandatory_fields = (BID_ID,) def add_timestamp (self, value): """ Add timestamp in order to groupby bid id using session window. """ return beam.window.TimestampedValue(value, datetime.timestamp(datetime.now())) def filter_invalid_value (self, value): return all (field in value for field in self._mandatory_fields) def expand (self, pcoll): return (pcoll | 'Filter invalid value' >> beam.Filter(self.filter_invalid_value) | 'Add key' >> beam.Map( lambda elem: (elem[BID_ID], elem)) | 'Session window' >> beam.WindowInto( window.Sessions(self._session_gap), accumulation_mode=AccumulationMode.DISCARDING) | 'Groupby bid id' >> beam.GroupByKey()) impressionとclickの突き合わせにSession Windowを用いていることから実際のCTRよりは少なく見積もられてしまうことになります。現状はimpressionとclickの突き合わせのギャップ時間は10分とっており、本システムの1日分のログから計算したところ98%は突き合わせができているようです。 GroupByItemAndModel 学習済みのパラメータ自体はメモリ上に保持しているわけではなくGCS上に保存しています。1ログ毎にパラメータの取得と保存を行うのは効率が悪いため、一定量のログが溜まった後、GCSから取得して学習を行います。impressionのログとclickのログをSession Windowでまとめた後は配信アイテムID・配信種別・配信名毎にグルーピングを行います。その後、再度Session Windowを適用して、指定したサイズ以上のチャンクにまとめます。 clickとimpressionがまとめられた後は、腕毎に目的とするbatch size分のログが貯まるまで、再度Session Windowに掛けられます。流量が少なく、指定したサイズ分溜まるまで非常に時間がかかる場合もあり得えます。以下のコードのようにtriggerの設定で、AfterAnyを利用して複数のトリガーを設定して、一定期間内にデータがたまらなかった場合も後段へ流すようにしています。 class GroupByItemAndModel (beam.PTransform): def __init__ (self, wait_count, wait_process_time): beam.PTransform.__init__(self) self._wait_count = wait_count.get() self._wait_process_time = wait_process_time.get() self._mandatory_fields = (ITEM_ID, MODEL_NAME, TARGET, CONTEXT, ACTION) def map_item_model (self, value): key = (value[ 1 ][ 0 ][ITEM_ID], value[ 1 ][ 0 ][MODEL_NAME], value[ 1 ][ 0 ][TARGET]) action_list = [v[ACTION] for v in value[ 1 ] if ACTION in v] # CASE both imp and click exist: 1 # CASE otherwise: -1 # logging.info(action_list) feature = 1 if IMP in action_list and CLICK in action_list else - 1 return (key, [feature, value[ 1 ][ 0 ][CONTEXT]]) def filter_invalid_value (self, value): for val in value[ 1 ]: if not all (field in val for field in self._mandatory_fields): return False return True def expand (self, pcoll): return (pcoll | 'Filter invalid value' >> beam.Filter(self.filter_invalid_value) | 'Map item id and model' >> beam.Map(self.map_item_model) | 'Window into' >> beam.WindowInto( window.Sessions(self._wait_process_time* 60 ), trigger=Repeatedly(AfterAny(AfterWatermark(), AfterCount(self._wait_count))), accumulation_mode=AccumulationMode.DISCARDING) | 'Groupby item id and model' >> beam.GroupByKey()) triggerとしてAfterCountを利用した場合、指定した数ちょうどのチャンクが出力されるわけではなくそれをオーバーしたものが来る点に注意が必要です。 終わりに 本プロジェクトに関する公開データセットとOSSについて 本記事で紹介したバンディットアルゴリズムのシステムを実装した際に収集したデータを、先日 Open Bandit Dataset として一般公開しました。この公開データは合計2600万以上のログを含む大規模なものであり、それぞれのデータは特徴量・方策によって選択されたファッションアイテム・過去の方策による行動選択確率・クリック有無ラベルによって構成されます。これらの特徴により、OPEの正確さを現実的かつ再現可能な方法で評価でき、非常に学術的な価値の高いデータとなっています。 また開発したオフライン評価フレームワーク Open Bandit Pipeline をOSSとして併せて公開しています。このパイプラインにより、研究者はOPEの部分の実装に集中して他の手法との性能比較を行うことができるようになります。 公開データセットとパイプラインに関して、詳しくは こちら のブログ記事をご覧ください。 解説記事・寄稿・学術論文 本取り組みに関連して、以下の雑誌に寄稿しています。 人工知能学会誌2020年7月号 すべての機械学習はA/B テストである また、オープンリソースの特徴やその活用方法などを以下の学術論文としてまとめ、公開しています。 Yuta Saito, Shunsuke Aihara, Megumi Matsutani, Yusuke Narita. A Large-scale Open Dataset for Bandit Algorithms. 公開データセットに関する詳細な記述など、ご興味ある方はぜひチェックしてみてください。 国内外での研究発表 本取り組みに関する研究成果を、トップ国際会議のワークショップを含む国内外の多くの場で発表しています。上に併せまして、ご興味ある方はぜひチェックしてみてください。 ICML 2020 Workshop on Real World Experiment Design and Active Learning (RealML2020) RecSys 2020 Workshop on Bandit and Reinforcement Learning from User Interactions (REVEAL2020) NeurIPS 2020 Workshop on Offline Reinforcement Learning NeurIPS 2020 Workshop on Consequential Decision Making in Dynamic Environments CounterFactual Machine Learning (CFML)勉強会#5 IBIS2020 第五回統計・機械学習シンポジウム 招待講演 メディア 本取り組みに関する成果を以下のメディアなどに取り上げていただきました(抜粋)。 ダイヤモンド・オンライン FASHIONSNAP.COM IoT NEWS ECのミカタ 流通ニュース ITmedia NEWS ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com Chapelle, Olivier and Li, Lihong. An Empirical Evaluation of Thompson Sampling. In Advances in Neural Information Processing Systems 24, 2011. ↩
アバター
はじめに こんにちは。SRE部BtoBチームの蔭山です。 Fulfillment by ZOZO (以下FBZ)で提供しているAPIシステムの運用及び監視を担当しております。 FBZではAWS Lambdaを主軸としてAWSが提供しているフルマネージドサービスのみを利用するサーバーレスアーキテクチャを採用し、構築・運用してきました。今回は実際にどのようにサーバーレスアーキテクチャを活用してサービスを構築・運用・監視しているかご紹介します。 これからサーバーレスアーキテクチャを活用してサービスを構築されようとしている方の参考になれば幸いです。 なぜサーバーレスを採用したのか FBZはZOZOTOWNとブランド様が運営されている自社ECサイト間でリアルタイムに在庫情報を連携し、ZOZOTOWNと自社ECサイトでの在庫の一元管理を実現するAPIサービスです。そのため、マスタであるZOZOTOWNの在庫情報を如何に素早く自社ECサイトへ連携できるかが重要な要素の1つです。 ZOZOTOWNではZOZOWEEKをはじめとしたセールやイベントが1年を通して開催されています。その最大トラフィック量は他のアパレルECサイトを見廻しても群を抜いているものになります。このようなイベントで発生する商品や在庫などの最新情報をブランド様が管理する自社ECへリアルタイムで連携していくには、どのようなトラフィックでもスケーラブルに対応できる堅牢なシステムが必要となります。実際にFBZをローンチする上では以下のポイントをクリアしておく必要がありました。 ZOZOTOWN・自社ECサイトのセール時の急激なトラフィック増加にも耐え、問題なくサービスを稼働できること バッチによる大量データ更新・連携を問題なく、かつ高速に完了できること 上記のポイントを低コストでクリアするために、当時浸透し始めていたFaaSであるAWS Lambdaを主要とするサーバーレスアーキテクチャを採用しました。 システム構成 冒頭でもご紹介しましたが、FBZ APIはAWSが提供しているフルマネージドサービスのみで構成しています。実際には以下のようなサービスを利用しています。 Lambda API Gateway S3 DynamoDB Elasticsearch Service SQS SES Cognito CloudFormation CloudWatchメトリクス CloudWatch Logs X-Ray CloudFront AWS WAF Amazon VPC 上記に挙げたようなAWSのフルマネージドサービスを最大限活用しながら、約500ものLambda関数を持つサービスを運用しています。実際にどのように活用しているかはこのあと詳しく解説していきます。 イベントドリブンなアーキテクチャ 基本となるアーキテクチャとしてイベントドリブンなアーキテクチャを採用しています。イベントドリブンなアーキテクチャについて、実際にFBZで稼働している商品情報の連携を元に説明します。 Lambdaを起動しZOZOTOWNから取得した商品情報をS3バケットに保存 S3バケット保存によるイベントからLambdaが起動し、ECカートシステムが取り込みやすいよう商品情報をパースしてDynamoDBのテーブルへ保存 DynamoDBテーブルへの登録イベントからLambdaが起動し、Elasticsearchへ商品情報を保存 このようにイベントの数珠つなぎによってAWS上の各データストアへ商品情報が登録されます。ECカートシステムが公開されているFBZのAPIを利用し自社ECシステムへ最新の商品情報が連携される仕組みです。 セキュリティ FBZ APIは自社ECサイトをご利用いただいたお客様の配送情報も取り扱っているため、お客様の大切な個人情報を漏洩させないようセキュリティ面は厳重な対策がされている必要があります。実際にはAWS WAFを利用してAPIへの接続管理やDDoS攻撃を代表とするサイバー攻撃対策を行っています。またCognitoでのユーザー認証やAPIのエンドポイント単位での認可を実現しています。 また、DynamoDBやElasticsearch Serviceに含まれる本番環境の個人情報を開発者が必要ない時に閲覧できないよう、以下の制限対策も実施しています。 DynamoDBのテーブルにAWSコンソール画面やAWS CLIからアクセスできないようにIAMポリシーによるテーブル単位での閲覧・編集制限 Elasticsearch Serviceのドキュメントに外部から参照できないようにPrivateサブネットへの配置 Kibanaへ必要ない人が閲覧できないようにCognitoによるユーザー認証 アプリケーション管理 Serverlessアプリケーションの管理フレームワークとして Serverless Framework を利用しています。テンプレート上に必要なAWSサービスのリソース定義を記載し、Serverless Frameworkを介してリリースすることによってAWS上に定義されたリソースを展開する仕組みとなっています。 同様のServerlessアプリケーションの管理ツールとしてはAWSが提供している SAM がありますが、SAMと比べて以下の点で優れていると考え現在も利用しています。 Serverless Frameworkコミュニティの活発さ Serverless Frameworkの拡張が可能なPluginの公開数の豊富さ(執筆時点で 1000以上ものPluginが公開されています ) また各アプリケーションが利用するようなAWS VPCなどの環境単位での共通リソースは、別途CloudFormationでテンプレート管理しています。これによりほぼすべての構成・設定値をコードで管理している状態となっています。 またCI/CDの環境としてCodeBuildを利用しています。アプリケーションの静的解析やカバレッジ計測のような開発支援からアプリケーション・インフラのリリース、APIのE2Eテストに至るまですべてCodeBuild上で実行しています。 サービス監視 サービスを運営していくにあたって、システムの監視は切っても切り離せないものです。もちろんFBZのようにフルマネージドサービスのみで構成したサービスでも同様となります。実際にFBZでは下記のようなサービス監視を実施しています。 CloudWatchメトリクスによる異常値検知 Lambda/API Gatewayから出力されたログを解析し、PagerDutyやDatadogのような外部サービスへ連携 AWSの各サービスではCloudWatchメトリクスへ稼働状況が収集されています。収集されたデータから異常値が検知された場合、Slackへ通知されます。その通知を受け運用担当者が対応するフローとなっています。 ログ解析によるサービス監視については過去に記事を公開しているのでぜひ御覧ください! techblog.zozo.com サーバーレスでメリットに感じたこと FBZのシステム構成の一部を紹介してきましたが、ここからはこのようなフルマネージドサービスのみで構成されたサービスを実際に運用して感じたメリット・デメリットについてご紹介します。 まず、サーバーレスのメリットだと感じている点を代表して3点紹介します。 スケーラブルなサービスの実現 データリカバリの容易さ サービス開発への集中 スケーラブルなサービスの実現 サーバーレスアーキテクチャを採用した理由として挙げていましたが、結果としてZOZOTOWNの様々なイベントに対してスケーラブルなサービスを構築できた点です。過去にZOZOTOWNでのセール開催時に大量の商品・在庫情報が差分として上がってくるケースがありました。そのような同時に発生する膨大なデータ連携でも自社ECサイトへリアルタイムに連携することが可能なシステムとなっています。 データリカバリの容易さ イベントドリブンなアーキテクチャでLambda関数を分割しているため、有事の際のデータリカバリが容易な点もメリットだと考えています。もしZOZOTOWNからの商品データ取得に何かしらの理由で失敗した場合でも、指定のS3バケットへ再取得したデータを配置することでその後の自社ECシステムへの連携まで自動的にリカバリされます。また非同期起動のLambda関数は処理が失敗した場合に間隔を開けて自動でリトライする仕組みがあります。ネットワーク起因の一時的な問題などもリトライによって自動で解決してくれる点はサーバーレスならではだと感じています。 サービス開発への集中 サーバーなどのインフラ管理をAWSが管理するマネージドサービスを利用することでなくし、サービスに携わるエンジニアがビジネスロジックの開発に集中できる点です。実際にFBZではインフラ面を管理する専任メンバーはおらず、開発エンジニア全員でマネージドサービスの設定管理やサービス開発を行う環境となっています。 サーバーレスでデメリットに感じたこと ここまでサーバーレスでのメリットを挙げてきましたが、もちろんメリットだけでなくデメリットも存在しています。ここでも代表して3点紹介します。 Lambdaの実行時間の制限 RDBを利用しづらいアーキテクチャ ミドルウェアアップデートへの強制的な追従 Lambdaの実行時間の制限 現在、Lambdaは1処理あたり最大15分まで処理を行うことができます。APIサービスとしての利用ではまず問題になることはないのですが、連携ファイル生成など時間がかかる処理はデータ量によっては15分では終わらないケースもあります。アプリケーション側の処理ロジックを見直して解決できることもありますが、それで解決できるケースはごく一部でした。FBZでも実際に15分以上かかる処理もあり、その場合はAWS BatchやStep Functionsといった別マネージドサービスで実行するように実装しています。Lambdaに限らず、各マネージドサービスそれぞれに上限値は存在しますので制限値を確認しながら設計を進めていくことが大切なポイントだと思います。 RDBを利用しづらいアーキテクチャ 一般的なWebサービスですとMySQLやPostgreSQL、SQL Serverに代表されるようなRDBをデータストアとして利用されているケースが多いかと思います。LambdaのようなFaaSでは水平スケールを得意としているため、LambdaからRDBへの接続はコネクションを枯渇してしまうためアンチパターンとされています。データを検索する場面ではDynamoDBだと細やかな要件を満たすことが困難だったこともありElasticsearchを利用し対応しています。ただ、販売成績など売上データを集計する場面においてはElasticsearchでもFBZ要件を満たすことが困難であり、ロジックの開発が必要でした。RDBであればクエリで実現可能な部分に対し、アプリケーション側で対応するために開発コスト増となる場面もありました。 ですが、最近だとLambdaのようなFaaSでも利用しやすいようAWSでのRDS Proxyのような仕組みもありますので今からサービスを構成していくにはRDBの採用も視野に入れやすいかと思います。 ミドルウェアアップデートへの強制的な追従 マネージドサービスの利点としても良く挙げられますが、クラウドベンダー側でミドルウェアのアップデートは自動的に行われます。そのため、サービス開発を進めながら自動アップデートによってサービスダウンしないよう適宜アプリケーション側もアップデート対応を進めなければならない点はデメリットでもあると感じています。実際に過去Lambdaでとあるランタイムのリリースが廃止され、緊急でバージョンアップ・リリース対応したケースもありました。こういった事象が発生しないようにするためにも、常日頃から情報のキャッチアップが必要なのはもちろんのこと、AWSから来る事前通知の連絡先と現場への展開フローの整備をおすすめします。 まとめ 以上、サーバーレスアーキテクチャでどのようにサービスを構築し運用・監視しているか、またサーバーレスアーキテクチャならではのメリット・デメリットついて紹介しました。 ZOZOテクノロジーズでは、AWSのマネージドサービスを最大限利用しながらBtoB事業のさらなる拡大に取り組んでいただける仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは、WEAR部運用改善チームの佐野です。 私たちのチームでは、WEARの日々の運用業務を安全かつ効率的に行えるよう改善をしています。今回は、年初から行っていた不要APIの削除作業についてご紹介します。 背景 残念なことに長い間WEARでは不要になったAPIが放置されてしまっており、どのAPIが実際に使用されているものなのかが分かりにくい状態になっていました。WEARのAPIはWeb・iOS/Androidアプリ・バッチ・社内ツールから参照されているのですが、使用されているのかが明確でないAPIが多数残されていることにより、以下のような問題がありました。 リプレイスや脆弱性診断の対象箇所の洗い出しの際に余計なコストが掛かる 運用業務において何かを調査をする際に、使用されていないAPIがあることで不要なコードも増え、調査がしにくい 実際に、他部署からの問い合わせの調査でとある処理を追っていたら、思っていたAPIではなく実際に使われているのは似たような名前をした別のAPIだった…というような事もありました。 上記の問題を解消するために、不要なエンドポイントや関連ファイルを削除し、現在使用されているエンドポイントのみを残して整理することにしました。 体制・スケジュール 削除対応は、バックエンドエンジニア4名で実施しました。 まず削除候補のAPIの調査を行い、削除対象となったものについては以下の流れで関連ファイルの削除とアクセス監視を行いました。 新たに削除するエンドポイントを毎週1人あたり約3つずつ追加していき、全ての削除が完了するまで約8か月かかりました。 削除する上でのリスク 現在使用されているAPIを誤って削除してしまった場合サービスに影響が出てしまうため、削除前の確認と削除後の監視を慎重に行う必要がありました。確認ポイントについては抜け漏れが無いように後述する一覧表を元にチェックリストを作成して管理しました。 事前調査について APIを削除するにあたり、以下の通り事前の確認を行っていきました。 1. 削除候補API一覧の作成 まず、以下の2つの観点から削除候補のAPIを洗い出してスプレッドシートの一覧表を作成しました。 アプリチームにヒアリングした、iOS, Androidアプリから参照していないAPI Swaggerに記載されていないAPI 作成した管理表には削除対象のエンドポイントであるか、対象である場合は現在のステータスがひと目でわかるように項目を設けて、一連の削除作業を通して使用しました。 2. アクセス有無の確認 担当者ごとに、 Splunk を用いてアクセスログの分析を行いました。 直近2か月で対象のAPIへリクエストが飛んできているかを基準としてアクセスの確認を行いました。なぜ期間を2か月に指定したかというと、月次で動いているバッチ処理からのアクセスを検知するためです。 3. アプリ以外からの参照を確認 前述した通り、WEARのAPIはアプリ以外にもWeb・バッチ・社内ツールから参照されています。まずはアプリ以外の全てのリポジトリ内で削除候補のAPIのエンドポイント名でgrepして、他のリソースから呼び出されていないことを確認しました。 4. アプリからの参照を確認 アプリからの参照有無が確認できていないエンドポイントについて、APIのエンドポイントを定義しているクラスで参照されていないことを確認しました。その後、アプリチームへの最終確認を行い、確認が取れたものを削除対象と判断しました。 削除手順について 上記の確認の結果、削除対象となったエンドポイントは以下の流れで削除していきました。 ブランチの運用 前述した通り、ルーティングの削除→3日間のアクセス監視→アプリケーションコードの削除→3日間のアクセス監視という流れで進めるため、ブランチを分けて作業していきました。毎週火曜日をリリース日と定め、監視期間中に次のリリースの準備を進めていました。 総行数の確認 WEARではこうした不要コードの削除も成果として称える文化があります。そのため、対象のエンドポイントを削除することで何行分のコードを削除できたかを最後にカウントできるように、以下のGitコマンドで削除前後のリポジトリ内の総行数を取得し一覧表に記録しました。 git ls-files | xargs -n1 git --no-pager blame -w | wc -l ルーティングの削除 対象のルーティングの定義を削除した後、 Postman 等を使ってAPIを呼び出して、HTTPステータス404が返却されることを確認しました。 アプリケーションコードの削除 ルーティングから呼び出していたControllerの処理を削除しました。Controllerクラスに紐づくModelクラスも不要になるかを確認し、他から参照されていなければ全て削除しました。 また、WEARのリプレイス前の環境ではなんと ストアドプロシージャ が多用されているのですが、削除した処理でしか使用されていないものは追々削除していけるように一覧表に記録しておきました。 Swaggerの定義削除 削除したAPIに関する記載を全て削除しました。 Splunkダッシュボードでのアクセス監視 ルーティング、アプリケーションコードの削除後にはそれぞれ3日間の監視期間を設けました。事前のアクセス確認と同様に、ここでもSplunkを使用しました。Splunkにはダッシュボードという機能があり、プルダウンから対象のAPIを選択してアクセスの有無がひと目で分かる状態にしていました。 アクセスがない場合 アクセスがある場合 作業中の問題点 Splunkの検索クエリ改善 Splunkを用いたアクセス確認を行う際に、結果が出るまでに数時間かかるという問題がありました。当初は検索期間を短い期間に区切って検索したり、前日の退勤時にバックグラウンドでログの検索を開始して翌日の朝に確認したりしていました。 しかし、あまりにも調査に時間がかかってしまうためクエリの改善を行いました。 改善前のクエリ * uri_path="/api/hoge*" sourcetype="ms:iis:auto" host IN(some-wear-host) source="path/to/log" 改善後のクエリ * sourcetype="ms:iis:auto" host IN(some-wear-host) source="path/to/log" | search uri_path="/api/hoge*" OR uri_path="/api/fuga*"… | stats count as request_count by uri_path 改善した内容は以下の通りです。 Bot用のログは参照しないように検索対象のログを絞る 期間がパフォーマンスに影響するため、1か月分×2並列で動かす 1エンドポイントずつ検索していたものをORで条件を繋げて複数検索にする 最低限の情報だけ返すように、uri_path毎のリクエストカウントを出力する これらの改善をした結果、1つのエンドポイントのログを確認するのにかかる時間を3分の1以下に短縮できました。 さらに、バックグラウンド実行を選択して終了時にメール通知を受け取るようにすることで、ノンストレスで調査を進めることができました。 APIの誤削除について アクセス有無の確認を徹底して行っていましたが、とあるバッチの処理で呼び出されているAPIを誤って消してしまうことがありました。なぜ起きてしまったかと言うと、それらのファイルがGit管理されていないという落とし穴があったためです…。 アクセスログから使用されているAPIだと分かったはずですが、特定のIPアドレスからの大量のアクセスを不正なものと判断してしまっていました。実際は不正アクセスではなく、AWSで固定IPを付与したホストからのアクセスであったことが後の調査で判明しました。 これらを踏まえ、他にもGit管理されていないファイルが存在しないか確認し、全てのファイルを管理下に置いたうえで再調査を行いました。 また、2か月間全くアクセスのないAPIを削除対象とするように改めました。 まとめ 最終的に削除したAPIは201件(全体の36.7%)、行数は52,392行(全体の8.8%)でした。 通常業務と並行して作業を行っていたため長期戦にはなりましたが、不要なAPIを全て削除したことで、より効率の良い安全な運用ができる状態に近づけることができました。また、削除したAPIでしか使用されていなかったストアドプロシージャも順次整理していく予定です。 さいごに ZOZOテクノロジーズでは、一緒に安全かつ効率的にサービスを作り上げてくれる方を募集中です。 つい後回しにされがちな技術的負債の返済作業を、現場が提案して優先順位をあげて取り組める環境があることはWEAR部の強みだと考えています。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは。ECプラットフォーム部の廣瀬です。 先日公開したテックブログ 「データベースの秘密情報取扱いルールに関する取り組みのご紹介」 では、データベースに保存している秘密情報の取扱いルールについてご紹介しました。そこでは、秘密情報の取扱いフローの策定として、次の5つのフローの整備を行いました。 新しく追加されるデータの取扱い 既存データで秘密情報に該当する項目の洗い出し 秘密情報にアクセスできるアカウントの制限 権限のないアカウントからのアクセス制限 権限保持者の大幅な削減による運用負荷増への対処 techblog.zozo.com 本記事では、これらのフローで策定された内容をSQL Serverで実装する場合の、具体的な対応内容について紹介します。 1. 秘密情報カラムへのアクセスの制限 秘密情報カラムへのアクセスを制限するためには、以下の2つの要件をSQL Serverの機能で実現させる必要があります。 秘密情報カラムにアクセスできるアカウントの制限 秘密情報カラムのマスク化 SQL Severには 動的なデータマスキング という機能があります。この機能を使用することで、権限を制限したアカウントが該当のデータにアクセスした場合は、自動的にデータをマスクした状態で返すことができます。 上:権限があるアカウントでアクセス / 下:権限がないアカウントでアクセス 権限が制限されたアカウントでは自動的にデータがマスクされ、秘密情報を保護でき、前述の要件を満たせます。しかし、この機能に関してはSQL Server 2016以降でしか使用できません。弊社ではSQL Server 2016以降の環境もありますが、それより前のバージョンも利用しています。そのため、全環境でこの機能を利用することはできません。 そこで、動的なデータマスキングの代替案として次のような対応を行いました。 ロールの活用 秘密情報カラムに対するSELECT権限のはく奪 権限のないアカウントで秘密情報を参照できなくするために、カラム単位でSELECT権限をはく奪(DENY) 秘密情報をSELECTしているVIEWの参照権限をはく奪(DENY) 動的なデータマスキングを使用しない秘密情報カラムのマスク化 1. ロールの活用 権限のはく奪については、各ログイン/ユーザーに対して個別に設定を行うのではなく、 SQL Serverのユーザー定義ロール を活用し、秘密情報へのアクセスを制限するロールを作成しています。作成したロールに対して秘密情報カラムのSELECT権限をはく奪し、そのロールにログイン/ユーザーを参加させます。こうすることで、複数のユーザーに対する秘密情報へのアクセス制限を効率的に実施できます。 策定したルールの「3. 秘密情報にアクセスできるアカウントの制限」では、秘密情報が閲覧不可能なアカウントと閲覧可能なアカウントの2種類を発行していると述べました。これらの権限の設定にもロールを活用し、柔軟に設定を管理できるようにしています。 2. 秘密情報カラムに対するSELECT権限のはく奪 1. 権限のないアカウントで秘密情報を参照できなくするために、カラム単位でSELECT権限をはく奪(DENY) SQL Serverのテーブルのアクセス権は、カラム単位で制御できます。なお、SQL Serverのアクセス権の設定の詳細については、 権限の階層 や Microsoft SQL Server Permissions Posters をご参照ください。 カラム単位でSELECT権限をはく奪(DENY)する場合、次のクエリを実行します。 DENY SELECT ON OBJECT::テーブル名(列名) TO ユーザー名 権限のはく奪を行うと、アクセス権のないユーザーで該当カラムを取得するSELECTを実行した際、エラーが発生します。 2. 秘密情報をSELECTしているVIEWの参照権限をはく奪(DENY) 権限をはく奪したテーブルをVIEW経由で参照している場合の考慮も必要です。VIEWの中ではく奪したカラムをSELECTしている際には、VIEWに対してSELECT権限を持っていると、ベーステーブルで権限がはく奪されていてもSELECTができてしまいます。 そのため、VIEW経由でもアクセスを制限する場合には、「SELECTをはく奪したカラムを参照しているVIEW」に対しても権限をはく奪する必要があります。この設定を行うために、次の2つの情報を組み合わせます。 1. SELECTをDENYしたカラムのリスト作成 データベースのオブジェクトに設定している権限については、 sys.database_permissions から取得できます。この情報から、権限の制御を行うロールに設定されているDENYの情報を取得し、「どのテーブルのどのカラムに対してアクセスが制限されているか」のリストを作成します。 2. VIEWが参照しているテーブルのカラムのリスト作成 VIEWが参照しているテーブルとカラムは sql_dependencies / sys.sql_expression_dependencies / sys.dm_sql_referenced_entities のような、依存関係を管理しているシステムVIEWから確認できます。この情報から、「VIEWで参照しているテーブルとカラム」のリストを作成します。 これらの情報を組み合わせることで、「DENYしたカラムを参照しているVIEW」を把握できます。この情報を基にしてVIEWに対してもDENYを設定することで、VIEWに秘密情報を含むカラムが使用されている場合でもアクセスの制限が可能となります。 3. 動的なデータマスキングを使用しない秘密情報カラムのマスク化 ここまでの内容で「秘密情報へのアクセスの制限」を実現できました。秘密情報へのアクセスを制限できたのであれば、「データへのアクセス制限については、これで完了なのでは」と思われるかもしれません。しかし、ここまでの作業で完了としてしまうとSELECTをDENYしたテーブルの参照時に、 SELECT * FROM テーブル名 というようなクエリを実行した際にエラーとなってしまいます。ルールの策定時に「エンジニアの運用負荷をできるだけ上げずに、秘密情報の閲覧可能者をできるだけ限定することが重要だと考えています」と述べました。ここで、「*」による検索ができない状態で、秘密情報カラムを含むテーブルのデータ調査を行う場合に必要な作業について考えてみます。 SELECTがDENYされているカラムにアクセスした場合、エラーメッセージにアクセス拒否されたカラム名が出力されます。このとき、エンジニアは以下の2つの作業を実施します。 「*」ではなく、テーブルの全カラムのリストを使用してSELECTを実行 エラーメッセージに出力されたカラムをSELECTのリストから除外してクエリを実行 これでは、エンジニアの運用負荷が増加してしまいます。動的なデータマスキングが使用できる環境であれば、カラムのアクセス制御はDENYではなくMASKとなるため、「*」による検索が可能です。ただ、この機能を使用できない環境が存在しているため、今回はそれ以外の方法で実現する必要があります。 そこで今回は「各テーブルに対応したVIEWを作成し、テーブルに秘密情報カラムが存在する場合は、該当のカラムをマスクする」という方法を採用しました。サンプルの情報を使用して、基本的な実装方法を説明します。 CREATE TABLE [Membership] ( [MemberID] [int] IDENTITY( 1 , 1 ) NOT NULL , [FirstName] [ varchar ]( 100 ) NULL , [LastName] [ varchar ]( 100 ) NULL , [Phone] [ varchar ]( 12 ) NULL , [Email] [ varchar ]( 100 ) NULL , PRIMARY KEY CLUSTERED ([MemberID] ASC ) ) INSERT Membership (FirstName, LastName, Phone, Email) VALUES ( ' Roberto ' , ' Tamburello ' , ' 555.123.4567 ' , ' RTamburello@contoso.com ' ), ( ' Janice ' , ' Galvin ' , ' 555.123.4568 ' , ' JGalvin@contoso.com.co ' ), ( ' Zheng ' , ' Mu ' , ' 555.123.4569 ' , ' ZMu@contoso.net ' ) DENY SELECT ON OBJECT::MemberShip(Email) TO TestUser Membershipというテーブルを作成し、TestUserはEmailカラムへのSELECT権限をはく奪しています。そのため、TestUserで次のクエリを実行すると、エラーが発生します。 SELECT * FROM Membership エラーメッセージを元にクエリを修正する必要があり、このままではエンジニアの運用負荷が増加します。そこで、Membershipテーブルに対応したVIEWの作成を行います。 CREATE VIEW V_Membership AS SELECT [MemberID], [FirstName], [LastName], [Phone], ' xxxx@xxxx.com ' AS [Email] FROM [Membership] GO GRANT SELECT ON OBJECT::V_Membership TO TestUser このVIEWでは、秘密情報カラムについては、マスクした状態の固定値が返されます。実際にVIEWを検索すると次のような情報が取得されます。 SELECT * FROM V_Membership この方法では、ベースとなるテーブルの代わりにVIEWを検索する必要があります。ただこの方法であれば、「各テーブルに秘密情報カラムが存在しているか」を意識することなくクエリを書けます。このようなVIEWを秘密情報カラムの存在有無に関わらず、全テーブルに対して作成しています。そしてデータの確認はテーブルを直接SELECTするのではなく、VIEWを使用するというルールにしています。これにより、動的なデータマスキング機能に近い体験をエンジニアへ提供しています。 なお、この対応で作成したVIEWについては、後述のメンテナンスによって再作成される場合があります。そのため、VIEWが一時的にDROPされる可能性を考慮しなくてはなりません。もしアプリケーションがこのVIEWを参照していると一時的なエラー発生は避けられません。そのため、今回の対応で作成したVIEWは「エンジニアがデータを確認するためにのみ使用しアプリケーションでは使用しない」というルールで運用しています。 2. 秘密情報のメンテナンス 秘密情報に該当するデータは、サービスの成長に合わせて追加/削除される可能性があります。このような秘密情報の変化に対応するため、マスクされたVIEWのメンテナンスを自動で実施しています。秘密情報の設定状況が変化した場合、データ参照用のVIEWにも変化の内容を反映させる必要があります。単純な実装としては、定期的な全VIEWの再作成が考えられます。ただ、今回ご紹介する実装では変更が発生するVIEWを最小限に抑えるため、設定の変更が必要なVIEWのみ再作成を行っています。 設定が変化し、再作成が必要となるのは次のようなケースが考えられます。 テーブルの定義変更(カラム追加) 最新の状態をVIEWに反映 テーブルの作成/削除 参照用のVIEWの作成/削除 テーブル内の秘密情報カラム(カラムのDENY)の増減 新しくDENYが設定されたカラムをマスク化 DENYが取り消し(REVOKE)されたカラムを実データ化 1. テーブルの定義変更(カラム追加) SQL Serverでは、テーブルに変更が行われると sys.objects の「modify_date」が変更されるので、この値を使用して直近でテーブルに対して変更が行われたかを確認しています。 2. テーブルの作成/削除 VIEWのメンテナンスを自動化するためには、テーブルの新規作成/削除にも対応する必要があります。新規に作成されたテーブルがあれば対応するVIEWを作成し、テーブルが削除されたのであれば、該当するVIEWを削除します。 この判断については、以下の2種類の比較により実施できます。 テーブルは存在するがVIEWは存在しない VIEWは存在するがテーブルは存在しない このような比較については セット演算子 を使用することで実現できます。テーブル/VIEWの一覧については sys.objects から取得でるので、この情報とセット演算子を利用することで、テーブル/VIEWの存在の不一致を検出できます。次の例では、VIEWは存在するがテーブルは存在しないデータを取得しています。 3. テーブル内の秘密情報カラム(カラムのDENY)の増減 新しく追加されたカラムが秘密情報に該当する場合(DENY)と、今まで秘密情報としていたカラムが秘密情報ではなくなった場合(REVOKE)は、VIEWのマスクの状態に反映する必要があります。前述のとおり、データベースのオブジェクトに設定している権限については、 sys.database_permissions から、VIEWが参照しているテーブルとカラムは sql_dependencies / sys.sql_expression_dependencies / sys.dm_sql_referenced_entities から取得できます。 これらの情報から、以下2点のリストを作成し、両者の比較を行うことでDENY設定の変化を検知しています。 テーブルのカラムに対するDENY設定状況 VIEWのカラムのマスク化の設定状況 以上3つの処理で変更が検知されたテーブルにのみVIEWの再作成を行うことで、VIEWの差分更新を実現しています。 また、データベース上の秘密情報カラムを管理するための仕組みづくりも進めています。秘密情報の管理用テーブルに秘密情報と判断したカラムを登録することで、アクセス制限に使用しているロールに自動的にDENYの設定が行われ、それがVIEWの設定にも反映されるような実装となる予定です。 3. リンクサーバー経由のアクセスの考慮 一般的な環境であれば、ここまでの内容で秘密情報のアクセス制限が完了すると思われます。弊社では複数のSQL Serverを組み合わせて利用するため リンクサーバー を使用している環境があります。リンクサーバーを使用している場合は、リンクサーバー経由でのアクセス制限についても考慮する必要があり、実施した対応についてご紹介します。 1. 設定方法 次の画像は、リンクサーバーの接続を作成する際に設定するセキュリティ設定です。 リンクサーバーを設定する際は、この画像のような設定で接続を作成された方もいらっしゃると思います。「上記一覧で定義されていないログインの接続方法」として「このセキュリティコンテキストを使用する」に、リンクサーバーで接続する先のログインの情報を入力しています。 このような設定が行われていると、リンクサーバー経由で別のサーバーにアクセスした場合、「リモート ログイン」に指定したログインの権限で接続されます。 そのため設定しているログインの権限によっては、「自分で接続した場合は秘密情報にアクセスできないが、リンクサーバー経由ならアクセスできる」という状態になり得ます。 リンクサーバー経由でアクセスした場合も秘密情報へのアクセスを適切に制限するために「ローカル サーバーのログインとリモート サーバーのログインのマッピング」機能を活用しました。リンクサーバー経由のアクセスを制限するログインについては、マッピングに次のような設定を行っています。 「ローカルログイン」には、エンジニアがSQL Serverにアクセスする際のログインを設定します。「リモート ユーザー」には、接続先のSQL Serverには存在しないログインを指定します。これにより、マッピングに設定されたログインは存在しないログインにマッピングされるため、リンクサーバー経由のデータアクセスができません。この仕組みによって特定のログインに対するリンクサーバー経由のアクセス制限を実現しています。この方法は、既存のリンクサーバーに対してアクセスを制限する必要がある場合に、影響を抑えつつ制限をかけたいときに有効です。 2. 注意点 アプリケーションから発行するクエリもリンクサーバーを使用している場合は注意が必要です。「アプリケーションから発行するクエリでは秘密情報カラムのSELECTを許可したいが、個人が手動で秘密情報カラムをSELECTするのは制限したい」といった要求がある場合、以下の実装も考えられます。 アプリケーションからSQL Serverに接続するための専用ログインを作成し、秘密情報カラムへのアクセス制限は許可 開発者専用のログインを作成し、秘密情報カラムへのアクセス権限をはく奪(DENY) リンクサーバーの「上記一覧で定義されていないログインの接続方法」として「ログインの現在のセキュリティ コンテキストを使用する」にチェック この場合、確かにやりたいことは実現できるのですが、「意図しない結果が返ってくる」リスクがあるため解説します。 リンクサーバーに接続するクエリを実行する際、「sp_columns_100_rowset」というシステムストアドプロシージャが事前に実行されることがあります。実行される条件としては、クエリの初回実行時など、コンパイルして実行プランを生成する必要がある場合です。このストアドプロシージャは「接続先ログインの権限で取得可能なカラムリスト」を取得します。そして「取得可能なカラムリスト」は実行プランに反映されキャッシュされます。キャッシュされた実行プランは異なるログインであっても再利用されるため、以下の挙動になる場合があります。 秘密情報カラムへのアクセスが制限されたログインで、リンクサーバーを使ったSELECTクエリを実行し、プランがキャッシュされる アプリケーションから同一のクエリが実行された際、キャッシュ済みのプラン(秘密情報カラムがレコードセットから除外されるプラン)で実行される 本来は取得されるべき秘密情報カラムがアプリケーションで取得できない このように意図しない結果が返ってくる可能性があります。そのため、「秘密情報カラムへのアクセス権限を持っているログイン」と「制限されているログイン」の両者が同一のリンクサーバーを使用できる状況は避けた方が安心です。 まとめ 先日公開したテックブログ 「データベースの秘密情報取扱いルールに関する取り組みのご紹介」 でご紹介した内容をSQL Serverで実装する場合の、具体的な対応内容について紹介しました。特に、バージョンの制約で動的なデータマスキングが使用できない環境下においても、開発者の利便性低下を最小限に抑えながら秘密情報カラムをマスク化する方法について説明しました。本記事の内容がSQL Serverのセキュリティ向上を目指す方の参考になれば幸いです。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちはプラットフォームSREの亀井と 三神 です。 先日開催されました CloudNative Days Tokyo 2020 にて私達が取り組んできたID基盤リプレイスプロジェクトについて登壇してきました! ID基盤リプレイスプロジェクトはモノリスな環境をリプレイスするプロジェクトの1つであり、マイクロサービス化とそれに伴うメンバーの教育について挑戦した案件ですので是非とも御覧ください。 CNDTについて CloudNative Days Tokyo(以下、CNDT)は CloudNative Days における東京開催のイベントです。CloudNative Days実行委員会の方々が運営しているイベントで東京の他にも大阪や福岡等でも実績のある大規模な技術カンファレンスとなります。CloudNative Daysの開催目的は公式サイトにて以下のように紹介されています。 CloudNative Daysはコミュニティ、企業、技術者が一堂に会し、クラウドネイティブムーブメントを牽引することを目的としたテックカンファレンスです。 ​最新の活用事例や先進的なアーキテクチャを学べるのはもちろん、ナレッジの共有やディスカッションの場を通じて登壇者と参加者、参加者同士の繋がりを深め、初心者から熟練者までが共に成長できる機会を提供します。 ​皆様がクラウドネイティブ技術を適切に選択し、活用し、次のステップに進む手助けになることを願っています。 ​クラウドネイティブで、未来を共に創造しましょう。 ​ クラウドネイティブ上級者だけではなく初心者も対象とした間口の広いイベントなので、私達が得た知見を発表する良い機会と考えて参加しました。 2019年のCNDTは虎ノ門ヒルズフォーラムを会場として開催したのですが、今年は新型コロナウィルスの影響もありオンラインのみの開催となりました。スピーカーの我々は初の登壇かつ慣れないオンラインでの対応となりましたが、とてもスムーズな進行で無事に終えることができました。実行委員会の方々にはこの場で御礼を申し上げます。 オンライン登壇について 実行委員会の方より登壇方法については下記3つの方法を提案して頂きました。 自宅/自社オフィスからのリアルタイム配信 事前収録した動画を配信 配信会場からリアルタイム配信 今回私達は『事前収録した動画を配信』を選択しました。 事前収録しておけば当日に何らかのアクシデント(システム障害や体調不良等)があったとしても対応ができると考えたからです。 緊張して噛んでも編集でごまかせるとか考えてないです。 ​ 事前収録でも参加者からの質問を受け付けられるように Slido やTwitterのハッシュタグも用意されていて不自由を感じる事もなく、安心して対応ができました。 ZOZOにおけるID基盤のk8sへのリプレイスとセキュリティの取り組み こちらのセッションではZOZOTOWNのリプレイスプロジェクト全体と、ID基盤について亀井と同チームリーダーの瀬尾(a.k.a. sonots )にてお話しさせて頂きました。 動画リンクはこちらです。 event.cloudnativedays.jp アジェンダは下記の通りです。 ZOZOTOWNリプレイスの全体感 ID基盤の概要 更新系ワークロードのリプレイス方法 AWS(Amazon Web Services)/EKS(Elastic Kubernetes Service)で実施したセキュリティへの取り組み 課題と今後 我々のチームで進めているZOZOTOWNリプレイスとそれを加速させるための新アーキテクチャと新体制についての紹介、ID基盤リプレイスでの取り組みについて話しております。 サービスリプレイスを検討している方や、AWS/EKSを使いセキュリティ要件の高いインフラ構築を検討している方に少しでも参考になれば幸いです。 またセッションでは触れなかったCI/CD手法については、以前本ブログで川崎からご紹介しておりますのでご興味ある方はご一読ください。 techblog.zozo.com ​ Cloud Native Onboarding ~実践で身につけるモダンインフラの基礎~ こちらのセッションはオンボーディングについての内容です。三神と元同僚の inductor 氏にて登壇しました。 動画リンクはこちらです。 event.cloudnativedays.jp メンティーである三神、そしてメンターであるinductor氏が約3か月のオンボーディングについてそれぞれの視点で振り返った内容です。三神のパートでは『新しい事を学ぶ』という観点で自身が意識した事や改めて重要だと思う点をお話しました。 オンプレミス環境を運用してきた方がクラウドネイティブ環境について学ぶ事は今後も増えると思います。私達のチームにも10月より新メンバーが入り、クラウドネイティブな技術についてオンボーディング中です。この発表を見てオンボーディングを取り入れてもらい、新しいことを学ぶ人のお役に立てればとてもうれしく思います。 ここでは、セッションであまり触れる事ができなかった1on1について少し補足します。 私達のチームではKPTとは別の週にチームリーダーとの1on1を全メンバーがセッティングされています。チームリーダーから質問を受けるのではなく、メンバー側が聞きたいことや相談したい事を準備する形式で行っています。1on1の準備として自身の課題を整理して明文化する時間を定期的に取る事で早期解決をする事ができてとても役に立ちます。特にオンボーディング中は不慣れな業務の中で悩むことが多いので1on1として相談時間が確保されているのでは安心感がありました。 リモートワークによる働き方が一般化している中で、1on1は業務において重要な時間になります。円滑に業務を進めるための施策として、試して頂ければ幸いです。 おわりに CNDTでID基盤チームが半年間取り組んできた挑戦を技術面とチームビルディング面の両方から発表する事ができました。ID基盤での取り組みは今後のリプレイスプロジェクトの例として、とてもいい形で進められたと思っています。 弊社ではクラウド環境へのリプレイスプロジェクトがまだまだ道半ばとなっております。ご興味ある方是非一緒に進めていきましょう。以下のリンクからご応募ください! tech.zozo.com
アバター
こんにちは。SRE部MA基盤チームの川津です。 私たちのチームでは今年サービスを終了した「IQON」の10TBを超える大規模データをBigQueryからS3へ移行しました。本記事ではデータ移行を行った際に検討したこと、実際にどのようにデータ移行を行ったかを紹介します。 データ移行の経緯 IQONは2020年4月6日をもってサービスを終了しました。そのIQONではデータ分析にBigQueryを利用していましたが、Amazon Web Services(AWS)上にもIQONに関するリソースが存在します。そのため、IQONはGCPとAWSの2つのクラウドで運用していました。 しかし、サービス終了に伴いGCP・AWSどちらかにリソースを統一する必要が出てきました。統一する意図としては、終了したサービスが利用する取引先を減らし、請求対応などの事務的なコストを減らしたい意図がありました。そのためGCPとAWSの両方にあったデータをどちらか片方に寄せて、もう片方を解約することにしました。 解約するためにGCP・AWSどちらがIQONのリソースを多く利用しているのか確認を行いました。その結果、AWSではクローズ告知ページ用Webサーバーやドメインの管理を行っており、GCP側はBigQueryのみリソースを使用していました。移行の手間を考えた結果、AWSではなくGCPのリソースを消すことにしました。 以上の経緯からBigQueryのデータをAWS上に移行を行うことに決めました。 AWSにデータを移行するにあたり以下の要件を満たす必要がありました。 データにアクセスする頻度は年に数回程度なので、維持費を抑えたい データ量が多く、ローカルにダウンロードしてクライアントPCで検索すると時間がかかるため、BigQueryのようにSQLを使いクラウド上でデータ検索をしたい 以上の要件よりデータを保存するリソースとしてS3を選択しました。S3には S3の料金表 にある通り、低頻度アクセス向けの料金プランがあり、データの取り出しに料金がかかる代わりにストレージの料金を抑えるプランがあります。 またAmazon Athenaを利用することでS3に保存したデータに対してSQLを利用して検索できます。Amazon Athenaは Amazon Athenaの概要 にある通り、CSV、JSON、ORC、Parquetなどのデータフォーマットに対応しています。加えて圧縮されたファイル形式に対してもSQLが実行でき、データを確認できます。現在は ドキュメント に記載のあるSnappy、zlib、LZO、gzip、bzip2形式がサポートされています。 事前準備 アーキテクチャの選定 まずBigQueryからS3へ移行するにあたりどのような方法があるか調べることにしました。 アーキテクチャを選定する際、下記の項目を考慮しました。 費用をなるべく抑える 導入コストをなるべく小さくする データの欠損が起こらないようにする 以上を踏まえて検討をした結果、以下のようなアーキテクチャを採用しました。 下記の手順で作業を行います。 bq extractコマンドを用いてGCSへテーブルデータを転送する gsutil rsyncコマンドを利用しGCSからS3へ転送する GCSからS3へ移行できたか確認する ポイントは gsutil rsync コマンドを使いS3へデータを移行する点です。 gsutil コマンドはGoogle Cloud Storage(GCS)へアクセスできるコマンドラインツールで、Google Cloud PlatformがOSSとして公開されています。そして、gsutil rsyncコマンドはgsutilコマンドで提供されているコマンドの1つです。 gsutil rsyncコマンドはバケット間の同期ができる機能であり、この機能はGCP間のバケットの同期だけでなく、S3とGCS間のバケットの同期もサポートしています。また、オプションに -m をつけることにより並列でデータを同期することもできます。 gsutil rsyncコマンドを使う際には気をつける点があります。 gsutil rsyncの注意点 にも記載があるので引用して紹介します。 Note 2: If you are synchronizing a large amount of data between clouds you might consider setting up a Google Compute Engine account and running gsutil there. Since cross-provider gsutil data transfers flow through the machine where gsutil is running, doing this can make your transfer run significantly faster than running gsutil on your local workstation. この注釈の文脈から、gustil rsyncコマンドを利用する際、クラウド間の転送を行うと一度コマンドを実行した環境内にデータ転送される仕組みであることがわかります。そのため、転送するデータのサイズが大きい場合には、手元のPC環境から転送する際に注意する必要があると言えます。 IQONのデータサイズをBigQueryの information schema から取得したところ、およそ10TBのデータ量がありました。データ量的に手元のPCで転送を行うのは難しいのでAWS環境のEC2インスタンスを利用し転送を行うことにしました。GCPではなくAWS環境を利用した理由は、会社としてAWSを活用しており、契約面においてEC2の利用料金を抑えることができるからです。 AWSのアーキテクチャの選定 次に、gsutil rsync実行のためのEC2インスタンスを用意するためのインフラ構成について考えます。 まずGCS→EC2、EC2→S3の2つの経路に分けてどのような構成にするか考えました。 前半の経路:GCS→EC2 GCS→EC2の経路に関して考えられる経路としては下記の経路が考えられます。 GCS → Internet → Internet Gateway → EC2(public subnet) GCS → Internet → Internet Gateway → NAT Gateway → EC2(private subnet) 1つ目の経路はEC2をpublic subnetに配置する構成です。 この構成にかかる費用は GCPネットワーク料金 を元に算出しています。 EC2をpublic subnetに置く場合、グローバルIPを直接割り当てることができるのでインターネットと通信を行うことができます。しかし不特定多数のサーバーから通信を行うことが可能になるため、EC2のセキュリティグループの設定に気をつける必要があります。 2つ目の経路はEC2をprivate subnetに配置する構成です。 こちらの構成では、 GCPネットワーク料金 と NAT Gateway料金 を参考にして費用を参考にしています。 private subnetにEC2を配置することで外部のトラフィックを遮断できます。しかし、EC2単体だとGCSへ通信を行えないのでNAT Gatewayを配置してGCSへ通信を行うことができるようにしています。NAT Gatewayを配置することで外向きの通信を行うことができます。そしてprivate subnetにEC2を配置しているので内向きの通信を遮断できます。よって外部からの通信を必要とする攻撃を防ぐことができます。 ここで、10TBのデータを転送すると仮定してGCS→EC2の経路でかかる費用を算出してみます。 1つ目の経路を図と照らし合わせるとGCS → Internet Gateway間で料金が発生します。GCS → Internet Gateway間の料金は GCPネットワーク料金表 より0.11(USD/GB)なので1つ目の経路は約1100USDかかることがわかります。 2つ目の経路を図と照らし合わせるとGCS → Internet Gateway、NAT Gatewayで料金が発生します。NAT Gatewayの通信は NAT Gatewayの料金表 から0.062(USD/GB)と確認できるので約600USDかかることになります。1つ目の経路で計算したGCS → Internet Gateway間の料金と合計すると約1700USDです。 後半の経路:EC2→S3 次にEC2→S3の経路です。こちらは下記の経路が考えられます。 EC2(public subnet) → Internet Gateway → S3 EC2(public subnet or private subnet) → VPC Endpoint → S3 EC2(private subnet) → NAT Gateway → Internet Gateway → S3 1つ目の経路としてはInternet Gatewayを通る経路です。 この構成にかかる費用は AWSネットワーク料金 を元に算出しています。 2つ目の経路はVPC Endpointを経由する経路です。こちらはEC2をpublic subnetかprivate subnetに置く2つの方法が存在します。通信経路の部分はこの2つに相違点がありますが、後述する料金に関しては同じなのでまとめて考えます。 この構成にかかる費用は VPC Endpoint料金 を元に算出しています。 VPC Endpointを利用することで料金を抑えながらEC2 → S3の経路を内部の通信で完結できます。VPC EndpointはInterface EndpointとGateway Endpointの2種類存在します。今回はAmazon S3で利用可能なGateway Endpointを利用しています。 料金に関しては Gateway Endpointの料金説明 に記載のある通り追加料金なしで利用できます。しかし、用途次第ではVPC Endpointの料金とは別に、通常のAWSデータ転送料金が発生します。今回の用途の場合、 AWSのデータ転送料金 に説明があるので引用します。 Data transferred between Amazon S3, Amazon Glacier, Amazon DynamoDB, Amazon SES, Amazon SQS, Amazon Kinesis, Amazon ECR, Amazon SNS or Amazon SimpleDB and Amazon EC2 instances in the same AWS Region is free. つまり同一リージョンにあるEC2とS3間の通信は無料です。今回はEC2のリージョンとS3のリージョンを同じにしているので0USDで利用できます。 3つ目の通信はNAT Gatewayを経由し、Internet Gatewayを経てS3へ通信する経路です。 この構成にかかる費用は NAT Gateway料金 と AWSネットワーク料金 を元に算出しています。 3つ目の経路に使っているNAT GatewayはGCS→EC2に用いたNAT Gatewayと同じ用途で使っています。 EC2→S3の部分でも10TBを転送すると仮定してEC2→S3の経路までの費用を算出してみます。 1つ目の費用を図から辿るとEC2 → Internet Gatewayの間で料金が発生します。そのため AWSネットワーク料金 によると0.114(USD/GB)発生することになります。合計すると約1200USD発生することがわかります。 2つ目はVPC Endpointを経由してS3と通信します。 Gateway Endpointの料金説明 からEC2がpublic subnet、private subnetに配置しても0USDであることがわかります。 3つ目の費用はNAT Gateway、NAT Gateway → Internet Gatewayで発生していることがわかります。 NAT Gatewayの料金表 からNAT Gatewayを通る際0.062(USD/GB)発生し、AWSネットワーク料金も別途発生するので AWSネットワーク料金表 から0.114(USD/GB)かかることがわかります。合計すると約1700USDです。 経路全体:GCS→S3 単純に考えるとGCS→EC2とEC2→S3の経路の組み合わせで6通り考えられますがpublic subnet or private subnetの条件で以下の4つの経路の組み合わせに絞られます。 GCS → Internet → Internet Gateway → NAT Gateway → EC2(private subnet) → VPC Endpoint → S3 GCS → Internet → Internet Gateway → NAT Gateway → EC2(private subnet) → NAT Gateway → Internet Gateway → S3 GCS → Internet → Internet Gateway → EC2(public subnet) → VPC Endpoint → S3 GCS → Internet → Internet Gateway → EC2(public subnet) → Internet Gateway → S3 各経路で発生する費用をGCS→EC2、EC2→S3の2つの経路に分けて計算した費用を組み合わせると下記の通りです。 1700USD 3400USD 1100USD 2300USD 最後にNAT GatewayとVPC Endpointの有無での組み合わせを表にまとめます。 VPC Endpointあり VPC Endpointなし Private Subnet, NAT Gatewayあり 料金:1700USD セキュリティ:内向きの通信を遮断できる 通信経路: 1つ目 料金:3400USD セキュリティ:内向きの通信を遮断できる 通信経路: 2つ目 Public Subnet, NAT Gatewayなし 料金:1100USD セキュリティ:アクセス元をより厳格に管理する必要がある 通信経路: 3つ目 料金:2300USD セキュリティ:アクセス元をより厳格に管理する必要がある 通信経路: 4つ目 上記の表より、1番コストが低いのは左下の項目です。懸念点はセキュリティなのですが、今回用意したEC2インスタンスはgsutil rsyncを実行するだけで、内向きの通信はオペレーション用のSSHしかありません。public subnetに置く際、アクセス元を限定したSSHだけを許可して露出を最低限にしました。 これらの考察から、コストが一番安く、セキュリティも設定をしっかりすれば担保できる3つ目の経路の構成を採用することにしました。最終的な構成は下記の図の通りです。 移行手順 今回移行するBigQueryのデータはテーブルの数とテーブルサイズが大きいので、スレッドプールを作ってJOBを効率的に処理するためRubyを用いて自動化しています。 データ転送:BigQuery → GCS まずgsutil rsyncを扱うにはBigQueryに存在するデータをGCSに移行する必要があります。GoogleはRubyに対してBigQueryのSDKを提供しており、 extract_job メソッドを使うことによって対象のテーブルをGCSに転送できます。extract_jobを使う際にポイントが2つあります。 1つ目のポイントとして、extract_jobメソッドは転送するデータの圧縮形式が指定できる点です。圧縮形式はCSVであれはgzip形式がサポートされています。今回のIQONのデータは10TB以上あることがわかっています。そのためファイルを圧縮して転送できれば先程計算したデータ転送の料金を抑えることができます。また最終的にAmazon Athenaを利用する際もgzip形式でクエリを実行することが可能です。しかしgzipでどれだけ料金が抑えられるかわからないので、いくつかのファイルをgzipで圧縮し確認しました。適当にCSVのファイルを5ファイルほど用意しgzipで圧縮しました。下記の表が圧縮結果です。 圧縮前(Byte) 圧縮後(Byte) 圧縮率(%) 81920 7168 92 11264 2048 82 1266989 49177 62 23552 6843 71 57344 11787 80 gzipに圧縮するとおよそ70〜80%ほど圧縮できました。そのため、10TBのデータも7〜8割ほど圧縮できると予想できます。結論として、データ移行の料金は転送したデータの量に比例するので7〜8割ほどgzipで料金コストを削減できることがわかりました。他にgzipで圧縮した際に起こるデメリットはAmazon Athenaでクエリを実行する際、gzipを解凍する必要があるので速度低下が考えられます。しかしgzipにすることでクエリを実行する際のデータ量を削減できるのでAmazon Athenaの利用料金を抑えることができます。クエリを実行する頻度として年1〜2回程度実行する程度なのでS3の利用料金を抑える利点やAmazon Athenaの利用料金を抑える点を考慮するとgzipで圧縮するメリットが大きいのでgzipで転送しました。 2つ目のポイントはextract_jobを用いてファイルを転送する際、転送するファイルを分割する必要がある点です。分割する必要があるファイルの条件はサイズが1GB以上あるファイルです。そのため転送する元データのサイズが1GB以上の場合は別名を付けてファイルを分割する必要があります。 公式ドキュメント によるとワイルドカードで指定ができます。今回は下記のようなURIで分割を行いました。 定義するURI gs://hoge/file-*.csv 出力されるファイル名 gs://hoge/file-000000000000.csv gs://hoge/file-000000000001.csv gs://hoge/file-000000000002.csv . . . 実際に移行で利用したコードを以下に示します。これをEC2上でバックグラウンド実行しました。 require " google/cloud/bigquery " require " google/cloud/storage " require " logger " require " parallel " def import project_id = "" bigquery = Google :: Cloud :: Bigquery .new( project : project_id) storage = Google :: Cloud :: Storage .new( project : project_id) bucket_name = "" bucket = storage.bucket( "" ) bq_table_name = [] bigquery.datasets.all.each do |dataset| # 100並列で転送を行う Parallel .map(dataset.tables.all, in_threads : 100 ) do |table| if (table.bytes_count / ( 1024.0 * 1024.0 * 1024.0 )) < 1 import_gcs(table, bucket_name, dataset.dataset_id, " -*.csv.gz " , " CSV " ) else import_gcs(table, bucket_name, dataset.dataset_id, " -*.csv.gz " , " CSV " ) end end end end def import_gcs (table, bucket_name, dataset_name, extend , extension) log = Logger .new( " log.txt " ) uri = " gs:// #{ bucket_name } / #{ dataset_name } / #{ table.table_id } / #{ table.table_id }#{ extend }" extract_job = table.extract_job uri, compression : " GZIP " , format : extension do |config| config.location = " US " end extract_job.wait_until_done! if extract_job.failed? log.debug( "#{ table.table_id } failed " ) log.debug( "#{ extract_job.error }" ) end return extract_job.failed? end import() 実装のポイントは Parallel を用いて並列で転送を行っている点です。最初は並列に行わず直列で処理を行っていたのですが1日経っても終わりませんでした。CloudWatchでEC2のメトリクスを確認するとネットワークの帯域やCPU使用率は余裕がありそうでした。BigQuery → GCSの転送自体はGCP側で行っているので100並列で様子を見ながら転送を行いました。その結果、半日かからず終了させることができました。 また、念のためRubyのloggerで簡単なログを取っています。extract_jobの戻り値の failed? でJobの成功、失敗を確認できます。最初はログを取っておらず、途中でプログラムが落ちた際どのテーブルで失敗したのかがわからず原因を突き止めるのに苦労しました。結論としては、特にJobが失敗したログは発生しませんでした。 最終的にgzipで転送した結果、元のデータサイズと比較すると7〜8割ほどデータを圧縮できました。さらに、料金に関しても7〜8割コストを削減できました。 データ転送:GCS → S3 GCS→S3に関してはgsutil rsyncコマンドを使い転送を行いました。 S3のディレクトリは下記の構成にしました。 ├── BigQueryのdataset名 │   ├── BigQueryのtable名 │   ├── (BigQueryのtable名).csv.gz gsutil rsyncで転送する際は、ルートprefixからgsutil rsyncを行うとエラーが出た際に始点が最初からになってしまうので、今回はdatasetのprefix毎に分けて転送します。 転送には時間がかかるので、ログを残す点やバックグラウンドで動かす点などに注意し、下記のコードを実装しました。 require " google/cloud/bigquery " require " google/cloud/storage " require " parallel " require " logger " def gcs_to_s3 project_id = "" storage = Google :: Cloud :: Storage .new( project : project_id) log = Logger .new( " log.txt " ) bucket = storage.bucket( "" ) s3_bucket_name = "" gcs_bucket_name = "" directory_name = bucket.files( delimiter : " / " ) directory_name.prefixes.each do |directory| log.debug( " start #{ directory }" ) success = system( " gsutil -m rsync -r gs:// #{ gcs_bucket_name } / #{ directory.gsub( " / " , "" ) } s3:// #{ s3_bucket_name } / #{ directory.gsub( " / " , "" ) }" ) if not success log.debug( " failed #{ directory }" ) next end log.debug( " success #{ directory }" ) end end gcs_to_s3() 上記のプログラムで想定通りにgsutil rsync側で転送が行われているか確認を行いました。CloudWatchでEC2のメトリクスを確認するとCPU使用率が飽和している状態でした。CPU使用率が飽和している場合の対策としてEC2インスタンスのインスタンスタイプを上げたり、EC2インスタンスを複数作成して処理を分散する対策が考えられます。しかし、S3とGCPバケットの総データ量を都度確認しファイルの転送速度を確認すると対策をするほど遅くなかったのでこのままの状態で転送を行いました。 ファイルの確認作業 最後の作業として、GCSからS3にデータを転送する際、欠損が起きていないか確認を行います。 確認する項目は以下の通りです。 ファイルの存在確認 GCSとS3のチェックサム検証 GCSとS3のサイズ比較 上記の各項目の確認方法について説明します。 ファイルの存在確認 GCSとS3にあるファイルの存在確認をするためにはコンソール上で確認する方法があります。しかし、ファイル数が数千ファイル存在するのでファイルを1つずつ確認するためには時間と労力が必要です。 そのため、GCS、S3に対象のprefixが存在するか比較し存在の有無を確認します。GCSに存在するファイルはすでにBigQueryから全て転送できていることが確認できているのでGCSのprefixを起点としてS3のprefixを確認します。 object メソッドの戻り値の exits? メソッドで対象のファイルが存在するか確認できます。 GCSとS3のチェックサム検証 チェックサムを確認することによってGCSから送られてきたファイルはGCSと同一のファイルであるか確認できます。hash値の確認に関しては手元に対象のファイルをダウンロードして確認する方法でも可能ですが、こちらも時間と労力が必要です。hash値はGCS、AWSのSDKを使用して確認可能なので各環境のSDKを使用し確認します。 S3に関しては Aws::S3::Object クラスの etag メソッドで確認できます。 GCPでは Google::Cloud::Storage::File クラスの md5 メソッドで確認できます。こちらはbase64でエンコードされた値が返ってくるのでmd5で比較するために一度デコードして比較します。デコードした値はbinaryなのでunpackを行う必要があります。 GCSとS3のサイズ比較 GCSとS3のサイズ比較は gsutil du コマンドを用いることで確認できます。下記のようなコマンドを入力するとbyte表記でバケットの合計サイズを確認できます。 # GCSのバケットの容量の確認する場合 $ gsutil du -s gs://bucket_name 123456 # S3のバケットの容量の確認する場合 $ gsutil du -s s3://bucket_name 123456 GCSとS3のサイズ比較に関してはバケット単位での比較なのでコマンドを複数回実行すれば確認できます。しかし、ファイルの存在確認とチェックサムの検証はファイル単位なので下記のスクリプトを利用して確認します。 require " google/cloud/storage " require " aws-sdk " require " parallel " require " logger " require " google/cloud/bigquery " require " digest/md5 " require " base64 " def check_file project_id = " iqon-data-mining " storage = Google :: Cloud :: Storage .new( project : project_id) resource = Aws :: S3 :: Resource .new( region : " ap-northeast-1 " ) log = Logger .new( " log.txt " ) s3 = resource.bucket( " iqon-backup " ) bucket = storage.bucket( " export-s3-failed " ) files = bucket.files() Parallel .map(files.all, in_threads : 100 ) do |obj| dataset_name = obj.name.sub( /\/.+/ , "" ) file_name = obj.name.sub( /.+\// , "" ) directory_name = file_name.sub( / - \d+. csv .+/ , "" ).sub( /. csv .+/ , "" ) s3_directory = "#{ dataset_name } / #{ directory_name } / #{ file_name }" s3object = s3.object(obj.name) if (s3object.exists?) # gcsのetagとs3のetagを比較 if (s3object.etag.gsub( "\"" , "" ) == Base64 .decode64(obj.md5).unpack( " H* " )[ 0 ]) puts( " checked " ) else log.debug( " file etag validation failed at gcs: #{ obj.name } /s3: #{ s3_directory }" ) end else log.debug( " file not found gcs: #{ obj.name } /s3: #{ s3_directory }" ) end end end check_file() 今回、上記のプログラムで確認作業を行った際、GCSとS3のファイルのhash値が合わない問題に遭遇しました。原因としては、GCS → S3へ転送済みのテーブルに対してBigQuery → GCSへファイル転送を再び行いGCSのファイルを上書きしてしまったことでした。BigQuery → GCSへ転送する場合、CSVの行の順序が保証されていません。そのためBigQuery → GCSへ転送するたびにhash値が変わってしまいます。結局S3に保存されているhash値が一致しないファイルを削除し、削除したファイルをGCSから再転送を行いました。 最後にAmazon Athenaを使ってS3に転送完了したファイルに対してクエリを実行してみました。結果としてgzipで圧縮されたファイルでも問題なく中身を確認できました。 まとめ BigQueryからS3に移行するまでの手順を紹介しました。S3へデータ移行が完了したのでGCP側のリソースを削除できました。今回のデータ移行は転送するデータ量がかなり多く、転送完了するまで数日かかりました。また移行するデータ量が多い場合は転送時に発生する料金も多く発生し、選定するアーキテクチャによって料金も大きく変わることがわかりました。そのため、たった1回のデータ転送でもデータを移行する前に移行でかかる時間と料金を見積もることが重要になると感じました。 移行が完了した後、継続的にS3の料金が発生します。現在S3の料金プランはS3標準プランに設定していますが徐々にプランを変更し最終的にS3 Glacier Deep Archiveプランへ移行する予定です。気をつけるべき点としてAmazon AthenaがS3にアクセスできるプランはストレージタイプがスタンダードかスタンダードIAであることがあげられます。S3 Glacierプランまで変更するとAmazon Athenaでアクセスするにはファイルをrestoreする必要があります。そのため今後Amazon Athenaでクエリを打つ必要がなくなったタイミングでS3 Glacier Deep Archiveプランへ移行します。 aws.amazon.com 料金を比較するとBigQueryの場合 長期保存プラン は0.010(USD/GB)に対してS3の S3 Glacier Deep Archive プランは0.002(USD/GB)です。そのためS3 Glacier Deep Archiveプランに移行すると月およそ82USD削減できます。 MA基盤チームではデータ転送に関わる業務が多く、他のチームと連携しながら仕事をすることがあります。今回のタスクをこなすことで他部署と関わりながらデータの転送方法について知ることができました。 最後に ZOZOテクノロジーズではより良いサービスを提供するための基盤作りを開発したい仲間を募集中です。以下のリンクからご応募ください。 https://tech.zozo.com/recruit/ tech.zozo.com
アバター
はじめに MSP技術推進部の基幹化推進チームの池田( @ikeponsu )です。 私達のチームでは、 マルチサイズプラットフォーム事業(MSP) におけるデジタルトランスフォーメーション(DX)の取り組みを行っています。その取り組みの1つに、ケアラベル作成自動化システムの開発・導入があります。 このケアラベル作成という業務ですが、元々は人の手で1つずつ行われていたものでした。以前書いた「 Go言語でケアラベル発行の自動化 」の記事の中ではプロトタイプの紹介をしましたが、今回は実際にプロダクトで使われる様になったシステムの構成や、どの様に導入を行ったかといった内容を紹介します。 techblog.zozo.com 弊社のケアラベル 以前書いた記事でも少し紹介しましたが、ここではそもそも弊社のケアラベルがどういったものなのか、説明していきたいと思います。 ケアラベルとは上記の様な、繊維製品になくてはならない品質表示のことを指します。ケアラベルは、家庭用品品質表示法の下に適切で明確な表示が義務づけられています。 ケアラベル表記は画像の上から順に、以下の項目で構成されています。 サイズ表記(サイズごとに表記が異なる) 洗濯表記(品番ごとに表記が異なる) 素材混率の表記(使用する生地ごとに異なる) 生産国表記(品番ごとに表記が異なる) 会社表記(固定) 2次元バーコード(製品番号ごとに異なる) 裏面:付記用語(使用する生地ごとに異なる) サイズ、品番、製品番号の関係性をTシャツを例に説明すると、以下の様に表せます。 Tシャツ(品番) ├── ブラック(カラー = 生地) │ ├── XS(サイズ) │ │ ├── 製造番号1(製造番号) │ │ └── 製造番号2(製造番号) │ ├── S(サイズ) │ │ ├── 製造番号1(製造番号) │ │ ├── 製造番号2(製造番号) │ │ └── 製造番号3(製造番号) │ ├── M(サイズ) │ │ └── 製造番号1(製造番号) │ └── L(サイズ) │ └── 製造番号1(製造番号) └── ホワイト(カラー = 生地) ├── XS(サイズ) │ └── 製造番号1(製造番号) ├── S(サイズ) │ ├── 製造番号1(製造番号) │ └── 製造番号2(製造番号) ├── M(サイズ) │ └── 製造番号1(製造番号) │ ├── 製造番号2(製造番号) │ └── 製造番号3(製造番号) └── L(サイズ) └── 製造番号1(製造番号) デザイナー業務の自動化 ケアラベルのデザイン作成は、元々デザイナーがAdobe Illustratorを使い手作業で行っていました。下図は実際に行われていた作業フローです。 品番ごとにAdobe Illustratorを使って洗濯表示を並べた画像を作成していた 品番・カラーごとにAdobe Illustratorを使って素材表記を書き込んだ画像を作成していた 上記の作業に加え関係各社、各部署が最終決定したデータを家庭用品品質表示法に基づいて正確にデザインに落とし込まなければなりません。しかしデータ自体揃うのが生産直前になることも珍しくないため、短期的に負荷が集中し、差し戻し等も多発していました。 更に、1シーズンで数十品番を展開していたため単純に作業負荷も高く、ケアラベル用の画像作成を専属とするアルバイトを探そうとしていた程でした。 そのため、まずはこれらの手間を最短で減らすことを目的として、下図の部分を半自動化することになりました。 自動化を行う上で条件となったのが以下の項目です。 業務で使用しているExcelのテンプレートをインプットとすること 業務の都合上、Windows PC上で動作すること デザイナー指定のフォントで描画できること ケアラベルのデザイン自体がデザイナーの求める要件(見た目の良し悪しなど)を満たすこと これらの条件を満たすために作成したのが下図の構成のシステムです。 デスクトップアプリとしてGUIを操作しながら使用し、入力したExcelファイルを元にデザインされたPNGが出力されるという仕様です。入力されたExcel内の簡単な書き間違え等も検出できる様になっています。 アプリ自体はC#(WPF)で作成しており、フロントエンドからバックエンドまで同じ技術要素で作ることができています。技術の選定理由としては、システムを使用する現場の動作環境に合っていたことと、フォントの細かい設定を行うことのできるライブラリを含んでいたことが挙げらます。 このシステムを作成したことで、デザイナー業務とそれに関連して発生していた業務を自動化することに成功しました。 印刷用のレイアウトファイル作成業務の自動化 次に自動化の対象としたのが、印刷用のレイアウトファイル作成業務です。 半自動化によって画像は自動化できたものの、入力される画像の高さに合わせてレイアウトファイルを作る作業はエンジニアが行っていました。 ケアラベルを印刷する際に使用するプリンターは専用のものを使っており、専用のプリンターで印刷する場合には、下画像の様にPNGファイルからレイアウトファイルを作る必要がありました。 CSVで画像や指定するテキストを挿入できる プリンター専用のデザインアプリでレイアウトファイル、レイアウトにCSVをマッピングさせる設定ファイルを作る必要がある この頃は1シーズンで数百品番を展開していたため、上記で述べた作業を行うには作業負荷が高く、スケールアップしない作業になっていました。 また、生産管理の業務全体でkintoneを利用し始めたため、業務フローに変更がありました。この影響で、kintoneでケアラベルデザイン作成用のExcelファイルを管理しながらPC上でケアラベルデザインを作成、作成したデザインを更にkintone上で管理するといった二度手間が発生していました。 これらの手間を無くし、ケアラベル作成業務を完全に自動化することにしました。 完全自動化を行う上で条件となったのが以下の項目です。 データのやりとりはkintoneを介して行うこと ケアラベルの生成を自動で定期的に実行すること デザイナー指定のフォントで描画できること ケアラベルのデザイン自体がデザイナーの求める要件(見た目の良し悪しなど)を満たすこと ケアラベルの印刷ファイルが印刷する上での要件(文字が潰れないなど)を満たすこと これらの条件を満たすために作成したのが下図の構成のシステムです。 kintone上で入力された条件を読み取り、当てはまるレコードのケアラベルのデザイン、印刷用ファイルを定期実行で作成します。Excelの内容やkintoneの項目に不備があった場合は、Slackで担当者に修正依頼を行います。 レイアウトファイルの作成については、レイアウトファイル自体を解析し、エンジニアが手作業で行っていた手順をプログラムで再現しました。 アプリ自体はPythonで作成しています。技術の選定理由としては、ケアラベルのレイアウトファイルを作成できたことと、フォントの細かい設定を行うことのできるライブラリを含んでいたことが挙げらます。 このシステムを作成したことで、生産管理の業務とそれに関連して発生していた業務を自動化することに成功しました。 自動化での課題点 「デザイナー業務の自動化」と「印刷用ファイル作成業務の自動化」を行う上でいくつかの課題点がありました。 まず、デザイン上の課題です。 上記画像で確認できる通り、デザインする上で次の様な問題が発生し、それらに対しプログラムで調整を行いました。 文字のかすれ、つぶれ 英字と和字を並べて描画した際上下にずれて見える 左右の余白が対象に見えない 混率表記のパーセンテージが数字によって左右にずれて見える 次にシステム要件をまとめる上での課題です。 ケアラベル作成業務には様々な部署、担当者が関わっており、要件や要望も様々でした。自動化をする上では各担当者の作業を把握し、整理する必要がありました。 勿論全てが要望通りにシステム化できるわけではないので、オペレーションを変えなければいけない部分等はコミュニケーションを重ね、要件をすり合わせていきました。 その結果、各担当者の協力もあり、別々で進んでいた業務フローを1つにまとめることができました。 自動化の効果について まず「デザイナー業務の自動化」では、1品番あたり大体1時間程の作業時間がかかっていました。デザインの差し戻し等があった場合は、更に時間がかかります。これを自動化しようとしていた当時は、数十品番行っていました。 自動化後は上記のデザイナー業務が代替され、1品番あたり1時間+αかかっていた作業時間が削減できました。 次に「印刷用のレイアウトファイル作成業務の自動化」では、品質管理の担当者とエンジニアの作業で、1品番大体30分程の作業時間がかかっていました。作業ミス等で差し戻し等があった場合は、更に時間がかかります。これを自動化しようとしていた当時は、数百品番行っていました。 自動化後は上記の品質管理の担当者の作業削減、エンジニアの業務が代替され、1品番あたり30分+αかかっていた作業時間が削減できました。 おわりに 本記事ではMSP技術推進部の取り組みの1つのケアラベル完全自動化のアプローチと効果について紹介しました。 ZOZOテクノロジーズでは、ZOZOTOWNやWEARのサービスをはじめ、事業を支えるさまざまな職種を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
アバター
こんにちは、ジャポニカ学習帳の表紙に昆虫が戻って来た 1 ことに喜んでいる、SRE部エンジニアの塩崎です。 先日、有名な投稿型メディアプラットフォームで投稿者のIPアドレスが漏洩するという事象が発生しました。我々ZOZOテクノロジーズが開発・運用しているWEARも、ユーザー投稿型のサービスであるという意味では同様であり、もしかしたら投稿者のIPアドレスを漏洩しているかもしれません。 本記事ではWEARがIPアドレス漏洩をしていないかどうかをクローリングで調査する手法、及びその結果問題がなかったということをお知らせします。 WEARで行われているセキュリティ対策 WEARで行われているセキュリティ対策の一部についても簡単に説明します。WEARでは専門家による定期的なセキュリティ診断を行い、そのレポートに基づいたよりセキュアになるための修正を継続的に行っております。 また、リリースされるコードはチーム内でコードレビューを行い、機能要件のみならずセキュリティの担保やコードの可読性などの非機能要件に関する議論も活発です。 さらに、他社と合同で行っているセキュリティ演習に積極的に参加している開発メンバーもおります。 このようにユーザーさんに安心してWEARを使って頂くために様々な対策を行っております。 とはいえ、厳重な対策を行っていてもミスを完全にゼロにすることは難しいです。今回は既に行っている対策とは少し毛色の異なる、クローリングという、より直接的な方法でIPアドレス漏洩の有無を確認してみました。 Scrapy まず、今回の調査で使用したクローリング用のフレームワークであるScrapyを紹介します。ScrapyはScrapinghub社によって開発されているOSSです。クローリングの処理をPythonで書くため自由度が高く、また同社によって開発されているヘッドレスブラウザのSplashとの統合も容易などの特徴を持っています。作成したクローラーは自前のインフラで運用することも、同社の運用しているPaaSであるScrapy Cloud上で動かすこともできます。 scrapy.org 特徴 続いてScrapyの特徴を紹介します。 CSSセレクターやXPathで要素を抽出 HTMLから要素を抽出するための機能であるCSSセレクターやXPathが搭載されています。また、抽出された要素に対して正規表現でテキスト処理を行うことも容易です。 docs.scrapy.org < html > < head > < base href = 'http://example.com/' /> < title > Example website </ title > </ head > < body > < div id = 'images' > < a href = 'image1.html' > Name: My image 1 < br />< img src = 'image1_thumb.jpg' /></ a > < a href = 'image2.html' > Name: My image 2 < br />< img src = 'image2_thumb.jpg' /></ a > < a href = 'image3.html' > Name: My image 3 < br />< img src = 'image3_thumb.jpg' /></ a > < a href = 'image4.html' > Name: My image 4 < br />< img src = 'image4_thumb.jpg' /></ a > < a href = 'image5.html' > Name: My image 5 < br />< img src = 'image5_thumb.jpg' /></ a > </ div > </ body > </ html > 上記のHTMLソースに対して、XPathと正規表現を用いてパースを行った例を以下に示します。 >>> response.xpath( '//a[contains(@href, "image")]/text()' ).re( r'Name:\s*(.*)' ) [ 'My image 1' , 'My image 2' , 'My image 3' , 'My image 4' , 'My image 5' ] 対話的なインタフェース IPythonのようなREPLが標準で搭載されており、CSSやXPathの検証を対話的に行えます。クローラーの実行中に scrapy.shell.inspect_response 関数を呼び出すことで、そこにブレークポイントを仕込みREPLを起動することもできます。Rubyでの開発中に binding.pry でREPLを起動できることに似ています。 docs.scrapy.org 数多くのデータフォーマット、数多くのストレージに対応 JSON・CSV・XMLなどの数多くのデータフォーマットに対応し、またローカルファイル・FTP・Amazon S3などのストレージに対応しています。 docs.scrapy.org 文字コード自動判定 UTF-8以外の文字コードにも対応できます。日本で使われているEUC-JPやShift JISにも対応できます。 Middlewareなどを使ってプラグイン的に処理を拡張可能 以下のページにScrapyのアーキテクチャ、及び処理の流れが書かれています。ENGINE SPIDER間、ENGINE DOWNLOADER間にMIDDLEWAREという紺色のコンポーネントがあります。これはスクレイピングやダウンロードの処理の前後に特定の処理を挟むことができる機能です。RubyのRack Middlewareを使ったことがある人ならばそのイメージが湧きやすいと思います。 docs.scrapy.org 例えば以下のような機能を提供するMiddlewareが標準で搭載されており、必要に応じで組み込むことができます。 リダイレクト時にCookieを保持する Basic認証を行う 同一URLに対するレスポンスをキャッシュする WEARをクローリングしてみた ここからは、実際にScrapyを使ってWEARをクローリングしてみます。皆さんがクローリングする際には、自分自身で運営しているサイトか許可を得たサイトでのみ行ってください。 Scrapyのインストール まずはScrapyのインストールを行います。Scrapyは多くのPythonのライブラリと同様にpipでインストールできます。 pip install scrapy 人によっては環境分離ツールとしてPipenvやpoetryを使いたい方もいるかと思いますので、そこはご自由にどうぞ。 プロジェクト作成 インストール後、最初に行うことはプロジェクトの作成です。これによってクローリングに必要な多くのファイルが自動生成されます。Ruby on Railsにおける rails new に相当するものです。 scrapy startproject wear_crawler スパイダー作成 次にスパイダーの作成を行います。Scrapyでは特定のサイトをクローリングするための方法を定義するためのクラスをスパイダーと呼んでいます。スパイダーの中にページのパース処理や、次のページを辿る処理などを記述します。以下のコマンドを実行することでひな形が生成されるので、それを元に処理を記述していきます。 scrapy genspider wear wear.jp いきなりですが、完成したものがこちらになりますので、これを使って説明していきます。 import scrapy import re class WearSpider (scrapy.Spider): name = 'wear' start_urls = [ 'https://wear.jp/' ] allowed_domains = [ 'wear.jp’' ] def parse (self, response): ip_addresses = re.findall( r'[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' , response.text) yield { 'url' : response.url, 'ip_addresses' : ip_addresses, } urls = response.xpath( '//a/@href' ) yield from response.follow_all(urls, callback=self.parse) WearSpiderの先頭部分でいくつかのクラス変数の初期化を行っています。 nameはこのスパイダーの名前を表しており、コマンドラインからクローリング処理を行う時にスパイダーを指定するために使用します。 start_urlsはクローリング処理の起点となるURLのリストです。起点となるURLがDBに入っている場合などの動的な処理をしたい場合は start_requests メソッドを代わりに実装し、その中で複雑な処理を記述することもできます。 allowed_domainsはクローリング対象のドメインを表し、ここで指定したドメイン以外へのリクエストは自動的にスキップされます。リンクを辿る時に毎回チェックしても良いのですが、それは煩雑なためここで指定しています。 parseメソッドはクローリング処理を書く場所です。最初に response.text で取得できるページのHTMLソースから正規表現でIPアドレスと疑わしき文字列を取得しています。厳密にはこの正規表現ではIPアドレス以外の文字列も抽出してしまいます。ですが、False Negativeが増えることはないので、ここでは厳密性よりも分かりやすさを重視しています。そして、その結果をyieldを使いScrapy側に返しています。yieldに辞書型のオブジェクトを渡すことで、このオブジェクトがScrapyのデータ保存コンポーネントに渡されます。そこでシリアライズが行われ、CSV・JSONLなどのフォーマットに変換後、ファイルとして保存されます。 その後、XPathを使ってHTMLソースコード中の全てのaタグのhref属性を取得しています。その結果に対してresponse.follow_allを呼び出すことで、これらのリンクの全てを辿るジェネレーターを生成し、yield fromでそれの委譲を行っています。 HTTPリクエストが完了するとcallbackで指定したメソッドが呼ばれます。ここで自分自身であるself.parseメソッドを指定しているため、再帰的に処理が行われます。この時urlsにはwear.jp以外のドメインへのリンクも含まれますが、allowed_domainsにwear.jpのみを指定しているため、それらへのリクエストは自動的に排除されます。 yield fromに親しみのない方のために、動作がイメージしやすい同等の処理をするコードも以下に示します。 for url in urls: yield response.follow(url, callback=self.parse) yieldを使っていたりcallbackを指定したりしているため、カンの良い方はお気づきかと思いますが、HTTPリクエストは非同期的に行われています。内部的にはTwistedを使ったI/O多重化を行っています。そのため、スパイダーの内部でブロッキングI/Oを呼び出すと、パフォーマンスが低下するので注意が必要です。 twistedmatrix.com クローリングの実行 では、クローリングを実行してみます。以下のコマンドを実行するとクローリングを行い、その結果をresult.jlにJSONL形式で出力します。 scrapy crawl wear --output=result.jl ログを確認すると、WEARのトップページからリンクを辿り、それらの中にIPアドレスらしき文字列が含まれているかどうかをチェックしていることが分かります。今回のクローラーはWEARの全ページを辿るため、処理が完了するまで非常に時間がかかります。そのため、適当なタイミングで Ctrl-C を押して処理を止めましょう。 クローラーの改善 とりあえず動くものができましたが、いくつか改良してみようと思います。 幅優先探索 Scrapyのデフォルト設定ではクロール予定のURLをLIFOのスタックに積み、深さ優先で探索を行います。より色々な種類のページをクロールするために、これを幅優先探索に切り替えます。DEPTH_PRIORITYで階層の浅いページのクローリングを優先するとともに、クロール予定のURLを格納するデータ構造をLIFOからFIFOに切り替えています。 DEPTH_PRIORITY = 1 SCHEDULER_DISK_QUEUE = 'scrapy.squeues.PickleFifoDiskQueue' SCHEDULER_MEMORY_QUEUE = 'scrapy.squeues.FifoMemoryQueue' docs.scrapy.org リクエスト頻度の自動調整 次にリクエスト頻度の自動調整を行います。Scrapyにデフォルトで搭載されているAutoThrottle Extensionを使うことで、リクエスト頻度を動的に変えることができます。このExtensionは目標の並列度とダウンロードにかかった時間から最適なリクエスト頻度を計算して、クローリング中に動的にリクエスト頻度を変更します。 AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 5 AUTOTHROTTLE_MAX_DELAY = 60 AUTOTHROTTLE_TARGET_CONCURRENCY = 1 AUTOTHROTTLE_DEBUG = True docs.scrapy.org 結果 改良後のクローラーを起動し数時間放置すると、チェックしたURLとそこに含まれるIPアドレスと思われる文字列のリストがJSONL形式で溜まっていきます。数万URLのスキャンが完了したタイミングで一旦クローラーをストップさせて結果を確認します。 その結果、いくつかのページで正規表現にマッチする文字列が見つかりましたが、どれも投稿者のIPアドレスではありませんでした。 cat result.jl | grep -v ' \[\] ' 例えばとあるコーデ一覧のページから ***.***.1.2 という文字列(一部伏せ字)が発見されました。念のためにHTMLソースを確認したところ、画像のalt属性に ***.****.1.2 という文字列が見つかりました。このalt属性はユーザー名やアイテム名などから自動生成されるものであり、更に調査したところユーザーが自分自身のニックネームとして ***.***.1.2 を設定していることが分かりました。 他にもいくつかのページでIPアドレスらしき文字列が見つかりましたが、いずれも上記の例と同様なケースでした。 まとめ WEARのHTMLソースコード中にIPアドレスが含まれていないことをクローリングで確認しました。日頃から行っている、脆弱性診断やコードレビューとは少し違った方法でIPアドレスが漏洩していないことを直接的に確認できました。 ZOZOテクノロジーズでは、他社でセキュリティインシデントが発生した時に、それを対岸の火事と捉えずに自分たちのシステムを内省できる人材を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com ジャポニカ学習帳 50周年記念 昆虫シリーズ ↩
アバター
はじめに こんにちは、SRE部MLOpsチームの児玉( @dama_yu )です。この記事では、ZOZOTOWNのおすすめ順を支える検索パーソナライズ基盤について紹介します。 ZOZOTOWNのおすすめ順について ZOZOTOWNにおいて検索機能は非常に重要な機能の1つで、売上のうち多くの割合が検索経由です。ZOZOTOWNでは、検索結果の並び順として、おすすめ順、人気順、新着順など複数あり、現在おすすめ順がデフォルトになっています。 元々は人気順がデフォルトだったのですが、ユーザの嗜好に合わない商品まで検索結果に並んでしまうという課題がありました。そこで、この課題へのアプローチとしてユーザの行動履歴や属性を元にパーソナライズされた順番で検索結果を並べた、おすすめ順を新規追加することになりました。 この施策の結果、検索結果経由の商品CTRが向上しました。ユーザが求めている商品が並ぶようになったのではないかと考えています。 この記事では、この施策で構築されたおすすめ順のための検索パーソナライズ基盤のアーキテクチャや設計上のポイントについて、説明していきます。 検索パーソナライズ基盤のアーキテクチャ この章では、検索パーソナライズ基盤の全体感を見た後に、API・インデクシング基盤についてそれぞれアーキテクチャの詳細、設計段階での検証ポイントを説明します。 アーキテクチャ概観 検索パーソナライズAPIはマイクロサービスとして構築されており、ZOZOTOWNのバックエンドAPIから参照される構成になっています。 パーソナライズAPIはユーザ情報を元に、Elasticsearchのクエリ構築に必要なパラメータを生成します。そのパラメータを使用して構築したクエリを元に、ZOZOTOWNのバックエンドAPIがElasticsearchにアクセスします。 Elasticsearchにはインデクシングバッチで商品情報を登録しています。 アーキテクチャ詳細(API) ここでは、APIのアーキテクチャについて説明します。 先ほどの説明では省略していましたが、ZOZOTOWNのバックエンドAPIはAWS上に、検索パーソナライズAPIはGCP上にそれぞれ構築されています。このAWS-GCP間のレイテンシを軽減するためAWSは Direct Connect 、GCPは Dedicated Interconnect という専用線サービスを利用してオンプレ経由でアクセスするようにしています。パーソナライズロジック内で参照する必要のあるユーザ情報(年齢や性別、お気に入りに追加したショップやブランドなど)については、1日1回の頻度でDataflowを用いてBigQueryからCloud Bigtableに書き込んでいます。BigQueryにデータを連携している基盤についての詳細は、以前 ZOZOTOWNを支えるリアルタイムデータ連携基盤 というタイトルで紹介しているので参照してみてください。 techblog.zozo.com パーソナライズAPIはユーザIDをキーにして、パーソナライズロジックに必要な変数の値と係数の組み合わせのリストをJSONのレスポンスとして返します。そのパラメータと検索ワードを使用して構築したクエリを元に、ZOZOTOWNのバックエンドAPIがElasticsearchにアクセスします。パーソナライズAPIはリクエストが来たタイミングでキャッシュされていない場合、Cloud Bigtableに保存されているユーザ情報はMemorystoreにキャッシュされることで、APIのレスポンスを高速化しています。 アーキテクチャ詳細(商品情報インデクシング) 次に、商品情報のインデクシングについて説明します。 ZOZOTOWNの商品情報を保存しているSQL Serverのレプリケーションから、 Qlik Replicate を用いて、Kafka(マネージドサービスとしてConfluentを使用)-> Dataflow経由で、BigQueryに商品情報の更新差分を保存しています。その更新差分とBigQueryの商品情報のマスタデータをJOINし、App Engine上に構築したバッチで1日1回、Elasticsearchに商品情報インデックスを作成しています。 アーキテクチャ設計の検証 検索パーソナライズ基盤のアーキテクチャ検討段階において、最初の設計からいくつか変更した点がありました。ここでは、API基盤、ユーザ情報更新バッチ、商品情報インデックス作成バッチ、それぞれについて設計の変更点を説明します。 API基盤 今回、AWSから専用線経由でアクセスするため、内部IPでサービスを提供する必要性がありました。 そのため、外部IPしか利用できないGAE(Google App Engine)はNGとなり、GKE(Google Kubernetes Engine)またはCloud Runが候補として上がりました。GCPのフルマネージドなサーバレスコンテナプラットフォーム、Cloud Runは弊チームでまだ採用事例がなく、実際に構築して検証を行いました。 検証の結果、Cloud Runは以下のような挙動をすることがわかりました。 リクエストが来た時に始めてコンテナが起動し、何リクエストか捌くと停止する Javaコンテナ(主にJVM)の起動が遅く、我々の場合20秒以上かかる 結果として、初回もしくは複数リクエストのうち1回の、コンテナが起動するタイミングでユーザにレスポンスを返すまで20秒以上かかってしまう これにより、今回はCloud Runの採用は止め、GKEを使用することにしました。 なお、現在ではこの「Cold Start問題」は 解消しているようです 。 ユーザ情報更新バッチの定期実行 当初Dataflowで構築したユーザ情報更新バッチはCloud Scheduler + Cloud Functionsで定期実行をする予定でしたが、プロジェクトのCI/CDで用いていた、GitHub Actionsでの定期実行に切り替えました。 GitHub Actionsのon schedule機能を用いることで、シンプルなyamlファイル定義のみで定期実行の設定が可能になりました。 Elasticsearchインデックス作成バッチ 元々、Elasticsaerchのインデックス作成バッチは、以下の理由でDataflowを採用する予定でした。 GCPのサービスでBigQuery -> ElasticsearchのETLを楽に開発したい 運用の手軽なサーバレスが良い しかしDataflowのApache Beamが設計当時Elasticsearch 7系のVersionに対応していなかったので、代わりに、GAE上で、インデックス作成のためのバッチを動かしています。2020年10月現在では、Apache BeamのElasticsearch IOは Elasticsearch 7系をサポート していますが、Elasticsearch 8系が使えるようになった場合でも再度同じ状況になることが考えられます。クライアントライブラリの制約で使えなくなる基盤よりは、自由にライブラリをインストールできるGAEのほうが、今後も柔軟性が高く優位性があると判断しました。 なお、現在ではβではありますが、Dataflowでも Dockerfileを指定できるようになった とのことなので、この制約は弱まった可能性があります。次の案件では、そちらも使えないか検討してみたいと考えています。 GAEについては、メモリ制限の都合でStandard Environmentではなく、Flexible Environmentを採用しました。 Elastic Cloudについて 弊社では、Elasticsearchのマネージドサービスとして、Elastic社が提供するElastic Cloudを用いています。マネージドサービスなので運用の負担が小さい、かつElastic社の公式サポートが利用できるという理由でElastic Cloudを選定しました。ここでは、Elastic Cloudの監視・運用方法について紹介します。 Elastic Cloudの監視 Elastic Cloudの監視には、Datadog(dashboard, montior)を使用しています。Datadogの設定は、Terraformを使ってIaC化しています。メトリクスは、Datadog Agentから送ることができないものについては、GKE上に構築したCronJobバッチで定期的にメトリクス送信を行っています。 Datadog Dashboardは各メトリクスの状況を一覧で見るのに使用しています。主に、query/secやCPU使用率を見ることが多いです。 Datadog monitorは、しきい値アラートの設定に使用します。 Elsatic Cloudの運用について 運用上の作業で最も多いのは、ElasticsearchのNode数変更です。セールなどのキャンペーンごとに、過去の負荷傾向からNode数を見積もっています。基本的にはキャンペーン前日までに運用担当がNode数変更を行います。 当初は手動で、Elastic Cloudコンソールからノード数を変更していたのですが、現在は ecctl というCLIツールがElastic社から公開されており、GitHub Actionsを用いたCI/CDでノード数を変更できる仕組みを構築しています。 つい先日Elastic Cloudの terraform-provider が公開されたので、そちらも使えないかまた検討してみたいと考えています。 さいごに 今回は、ZOZOTOWNおすすめ順のための検索パーソナライズ基盤について紹介しました。おすすめ順はロジック面・システム面どちらもまだまだ改善の余地が残されていて事業インパクトも大きいため、重要度の高いプロジェクトの1つとして、引き続き絶賛改善中です。 SRE部MLOpsチームでは、データや機械学習を用いてサービスを成長させたいエンジニアを募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! www.wantedly.com
アバター
はじめに こんにちは。SRE部USED基幹インフラの丸山です。 ZOZOUSED では2018年、当時社名がクラウンジュエルからZOZOUSEDに変更になるころからAWSの利用を開始致しました。当時はオンプレミス環境が多く、AWSの導入については画期的ではありましたが苦労も多かったとも聞いています。上記AWSで構築した環境について、前任者の異動に伴い私をはじめ他のメンバーで引き継ぐ事になりました。 当時私共はいわゆる情シスとして社内及び倉庫内のオンプレミス環境のインフラを管理する業務が多く、AWSについてはほぼ初心者の状態でした。そのため書籍をあさったり、Webの記事を検索したりと手探りで運用や改善を行っておりました。今回はAWS初心者だった私がタイトルの案件を通じてAWSのいろいろな機能に触れる経験ができたお話をしたいと思います。主にAWS初心者の方などにお役にたてばと考えております。 実装の背景 前任者から引き継いだAWS環境は大きく2つあり、そのうち1つの環境についてはまた別の部署で管理をしておりました。そちらに関してはほぼインフラ担当者が不在の状況で、私共から見てもブラックボックスの状況に近いものがありました。あらかじめ分かっている状況は以下の通りでした。 毎週必ずEC2、RDSなどインフラに負荷が急激にかかる時間帯があるが、サービスの提供においては問題が発生したことはない 上記の時間帯においてもEC2及びRDSのスケールアウトが発生するほど負荷が上昇したことは過去に一度も発生したことがない ある日、このAWS環境で「毎週必ず負荷が急激にあがる時間帯」において急激にアプリケーションが大量のエラーを出力し、 サービスを提供できないような状態になってしまった との連絡を担当の開発者から受けました。そのトラブルは、応急処置にて一旦は解消したもののその時点では根本的な原因究明には至りませんでした。 当時全く原因が分からない状況で、あらゆる方向から調査を行いたいとの強い要望がありました。開発担当者からも 「原因の切り分けのため一時的にEC2の台数を拡張してテストをしたい」 という要望を受け、今回の案件の環境を構築することとなりました。 構成で特徴的だったのはサービスの仕様上 「全てのEC2がElastic IP(以降、EIPと呼びます)をアタッチされている必要がある」 という点でした。 「NATゲートウェイを使えばいいのでは?」と考える方も多いと思いますが、その点に関しては後述させて頂きます。 元々の構成 依頼の構成 要件定義 開発担当と相談して、より詳細な要件を詰めました。 必須項目 10台のEC2は全てEIPを持つ必要がある 業務提携先のオンプレミスサーバと連携しており相手先FWに穴をあける必要がある 現状のEC2はパブリックサブネットに配置してありこの構成を変えたくない 当時の構成であり現在は異なる 開発側の任意のタイミングでEC2を増やしたり減らしたりしたい 問題・課題点 このようなケースでは本来NATゲートウェイがベストプラクティスです。EC2にEIPを一つずつ持たせるのは費用の面から見ても無駄遣いのようにみえました。 また、そもそもEC2がパブリックサブネット配置という要件がセキュアでないように感じました。なお、現在は改善しています。 そして、EC2 Auto ScalingのもととなるAMIがかなり古いことが分かりました。またスケールアウト時にデプロイするようなこともしていないため、このままでは元々存在するEC2とスケールアウトしたEC2で機能差が発生することが判明しました。 検討結果 まずNATゲートウェイを試してみるために、EC2もプライベートサブネットに設置してみます。 本件とは直接関係ありませんがリリースの都度、Auto Scalingで使用しているAMIと差分が発生してしまう問題に関してはリリースのタイミングを把握し、リリースの都度AMIを作成します。あくまで暫定対応です。 以下のような構成にできればEIPが一つで済みます。 NATゲートウェイを試してNGとなった理由 早速NATゲートウェイの構築に着手しました。構築自体はそれほど難しくなく以下のサイトなどを参考にすぐに完了しました。 docs.aws.amazon.com NATゲートウェイを利用すればEIPはそもそも1個で済みますし、料金、業務提携先のNWの設定の手間暇などの面からもいい事ばかりと考えていました。またEC2もプライベートサブネットに置き換えセキュアにする事ができ、色々な問題を解決したつもりでした。しかし担当者と相談していく中でいくつか問題があることが分かりました。 まず、NATゲートウェイを利用したアプリケーションの動作確認がそもそも何故か失敗しました。またこの問題や理由についてアプローチしている時間が開発側に当時ありませんでした。単純にトラブルシュートで使いたいだけなので大きなインフラ構成の改修はまた別のタイミングで行えば良く、 同様の構成を早く用意することが優先されました。 また、NATゲートウェイを利用するとEC2のサブネットが必然的にプライベートになってしまう点も問題として出てきました。 そのため直接SSHでログインして作業が必要となる際に、運用上困る場合があるとの意見を頂きました。 当時はこれが問題となっていましたが、後述する通り、この問題は類似したことがマネジメントコンソールよりできることが分かりました。 再検討 上記の状況を踏まえて、以下のような方法で進めようと再考しました。 EC2 Auto Scaling時にEIPを割り当てる手法が無いかの確認をする サブネットは一旦パブリックのままにし、インフラ構成の改修はまた別の機会に行うこととする 開発担当者が好きなタイミングでEC2増やしたり減らしたりしたいというリクエストについてはマネジメントコンソールから手動Auto Scalingしてもらう AMIについては引き続きリリースの都度作成する NATゲートウェイ環境(プライベートサブネット)でも直接EC2にアクセスすることは可能 少し余談となりますがプライベートサブネットのEC2についても踏み台サーバなどを利用せず、直接アクセスする方法もあるので紹介したいと思います。 System Manager の Session Manager という機能です。 詳しくは以下URLなど参考にして頂ければと思います。 docs.aws.amazon.com 該当のインスタンスにマネジメントコンソールから目的のインスタンスに接続できます。便利ですね。ちなみに接続時には ssm-user という独自のユーザーが使用されるようです。 以下が実際に対象のLinuxのインスタンスにSession Managerを使用して接続してみた画面です。 EC2 Auto Scaling時にEIPを割り当てる方法の調査開始 インターネット上の情報を探してみたところ、EIPをEC2に割り当てるシェルスクリプトのサンプルを紹介しているサイトは幾つかありました。そのシェルスクリプトをAWSのどの機能を利用してEC2 Auto Scaling時に実行しているかを紹介しているサイトはこの記事を執筆している時点では少ないように感じました。 実現に向けて AWSの機能としては提供されていないので自前で作り込む必要があることが分かりました。AWS CLIを利用してシェルスクリプトを頑張って作成すればEIPをEC2に割り当てる事はできそうなので、「Auto Scaling時にどのようにしてそのシェルスクリプトを実行するか」という方法を検討していきます。 AMIの作成 元となるAMIは、前回作成時からどのような変更があったか分からないため新しく作成することにします。リリースが発生する度にAMIを作成するような運用は効率が悪いのですが、こちらに関しては後述させて頂きます。 さて、AMIの作成に関しては「再起動」が前提となっています。前任者から引き継いだ手順書には「再起動は絶対にするな」とありましたのでこれは実は意外でした。今回は再起動が可能な環境だったので問題ありませんでしたが、どうしても再起動できない場合には「再起動しない」というオプションもあるので、そちらを利用する方法もあります。しかしこの方法で作成したイメージのファイルシステムの完全性は保証できないということなので、できるだけ再起動を伴ったほうがいいでしょう。AWSでもこの方法は推奨していないようです。 つまずきポイント1:CloudTrailによるAMI作成元EC2の追跡 AMIの作成時に困ったことが起きました。普段EC2は2台で運用しているのですが、AMIの取得元がその2台のどちらかなのか、もしくは全く違うEC2から作成したものかが分かりません。マネジメントコンソールから必死に探しますが証跡を見つけることができません。ここで今回役にたったのが CloudTrail です。 docs.aws.amazon.com CloudTrailの記録から、どのインスタンスからAMIを作成したか突き止めることができました。過去90日分のイベント履歴であれば画面からも追跡できます。AMIのIDから検索する場合は「リソース名」から検索をかけて下さい。 今回はAMI IDからリソース名で追跡し、無事にCreateImageのイベントを特定できました。 CreateImageをクリックすると作成元のEC2のインスタンスIDが分かります。 つまずきポイント2:Service Quotas この機能を実行するにはEIPがそもそも足りません。事前にまとまった数のEIPを発行しておく必要があることと、現状何個EIPを保持しているか確認する必要がありました。 しかし、 「今いくつEIPを持っていて、いくつ使っているのだろう?」 という疑問の解決方法が分かりませんでした。 この問題は Service Quotas という便利な機能で解決しました。以下のような機能があります。 EIPの総数の確認 クォーターの設定 EIP以外の値の設定や確認 上限緩和申請 aws.amazon.com 実装 いよいよシェルスクリプトの実装です。しかしインターネット上で提供されているシェルスクリプトのサンプルがそのまま自分たちの環境で動くとは限りません。まず手動でAMIから起動したEIPを割り振っていないEC2にてインターネット上にある割当スクリプトを持ってきて実行してみます。見事に失敗しました。 しかし何度か修正しているうちにEC2上から直接実行する形ではEIPを割り当てることが何とかできるようになりました。これをAuto Scaling時に動かすことができればいいわけです。 つまずきポイント3:ユーザーデータ さてここが肝の部分です。このシェルスクリプトをどこで設定して動かすのか。OSはLinuxなのでAMI作成時に「/etc/rc.local」に上記シェルスクリプトを設定して作成するという方法も考えました。 その方法の調査を続けたところマネジメントコンソールのEC2を起動する高度な設定の中に 「ユーザーデータ」 という項目があり、この中にシェルスクリプトを直接埋め込めることが可能なことが分かりました。高度な設定はそもそも触ったことがなく画面の1番下に隠れるようにあったので全然知りませんでした。また、それがAMIから起動するときも同様に使用できることが分かりました。EC2上ではシェルスクリプトがうまく動いたので次はAMIを起動する際にユーザーデータ内に先程のシェルスクリプトを埋め込んで上手く動くか検証します。 ユーザーデータ内には以下の様に通常のシェルスクリプトを作成するのと同様に記入可能です。なお、実際に使用したシェルスクリプトは実行できない環境もあるようなので今回は割愛させて頂きます。 #!/bin/bash # 変数も使用できます eip_groups = " eipalloc-XXXXXXXXXXXXX eipalloc-XXXXXXXXXXXXX ...etc " # AWS CLI が使用できる環境であればAWSコマンドも実行可能 aws ec2 associate-address ~ .....etc 以下がその画面です。 AMIを起動した際にプールしてあるEIPを見事に関連付けることができました。 AMIからの検証に成功しました。いよいよEC2 Auto Scalingに対して設定します。 つまずきポイント4:起動設定と起動テンプレート EC2の画面から「Auto Scaling グループを作成する」という部分をクリックすると以下のような画面が表示されます。特に赤枠内の「起動テンプレート」という部分が気になりました。前任者は「起動設定」から全て作成していて手順としてもチーム内で確立されていたのですが、「起動テンプレート」がそもそもデフォルトになっており、違いが気になったので少し調べてみました。 AWS公式サイトには以下のように記載がありました。 docs.aws.amazon.com どうやら 起動設定の後継の機能が「起動テンプレート」 に該当するようです。どちらにも「ユーザーデータ」内にシェルスクリプトを設定することができます。AWS推奨ということもあり、今回は起動テンプレートで挑戦してみることにしました。起動テンプレートには以下のような特徴があるようです。 既に存在する起動設定は全て起動テンプレートに置き換える(コピー)事が可能 起動テンプレートはバージョン管理が可能 起動設定と比較すると設定項目が多く、より細かい設定が可能 起動テンプレートの作成 では実際に「EC2」→「起動テンプレート」より起動テンプレートを作ってみます。 画面1番下の「高度な詳細」を展開しないとシェルスクリプトを設定する「ユーザーデータ」が表示されないのでご注意下さい。 沢山の項目がありますが全てを入力する必要はありません。今回私が使ったのは以下の項目だけでした。 AMI インスタンスタイプ VPC セキュリティグループ ストレージ ユーザーデータ また今回検証する中で何度か起動テンプレートを作り直したのですが、以下のようにバージョン管理されていることが分かります。 Auto Scaling グループへの設定 作成した起動テンプレートをAuto Scaling グループへ設定します。まず起動設定から起動テンプレートに変更します。 画面右上の「起動テンプレートに切り替える」をクリックします。 起動テンプレートに切り替わります。ここでは「dev_test」という名称で起動テンプレートを作り、バージョン管理をしています。私の場合は各バージョンでユーザーデータ内に記述するシェルスクリプトの内容などを変更したりしていました。 デフォルト値を変更していなかったので常に「Latest」を選択していました。 Auto Scalingのテスト 実際にAuto Scaleのテストをしてみます。手動Auto Scalingの設定をします。「Auto Scaling グループ」→「予定されたアクション」の画面からです。 10台をEIP付きでAuto Scalingさせる想定です。 10台のEC2が起動して、EIPがアタッチされていることが分かります。 総括 上記手動によるEC2 Auto Scaling方法を開発担当者に簡単に説明し、原因追求に着手します。その後、苦労の甲斐あってトラブルを引き起こすバグの特定・修正に至りました。 しかしそれ以上に今回「EC2 Auto Scaling時にEIPを割り当てる」という作業を行う中でAuto Scalingの設定以外にも今回取り上げたような技術や仕様を学ぶことができ、紆余曲折ありましたが決して費やした時間は無駄ではありませんでした。 NATゲートウェイ Service Quotas ユーザーデータ CloudTrail 起動設定と起動テンプレート Session Manager なお、Session Managerについては今回の作業の中で本格的に使用したわけではないのですが今後使用して行く予定です。 私的には「ユーザーデータ」をその後多用しています。例えばgitでソース管理している場合ですとスケールアウトするときにユーザーデータに 「git pull~」 のように書いておくだけで最新のリソースが反映されるので毎回AMIを作成することもなく、いろんな場面で重宝しています。またyumコマンドなども使えるので簡単なミドルウェアの構成管理としても利用できそうです。当時リリースの度にAMIを毎回作成して、起動テンプレートに設定して、ロードバランサーに反映するような作業を行っていたころが懐かしいです。 最新のリソースを反映するには書籍などから「CodeDeploy」、構成管理するには「OpsWorks」しかないという思い込みがあったので、ユーザーデータだけで運用がこんなに楽になるとは思ってもいませんでした。 さいごに SRE部USED基幹インフラでは、本案件後から今回学習したユーザーデータ、CloudTrail、起動テンプレート etc.を利用してより安定して効率のよいインフラのカスタマイズ、ならびに新しい技術のキャッチアップを心がけています。今後、多くのサーバをクラウドに移行する予定もありチューニングや見直しを引き続き実施し、さらなる効率化・安定化を目指しています ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募下さい! tech.zozo.com
アバター