TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

939

こんにちは、ZOZOテクノロジーズSREチームリーダー兼組織開発チーム所属の指原( @sashihara_jp )です。 この記事では2019年12月から全11回開催してきた「マネジメント勉強会」を通じて分かってきたZOZOテクノロジーズの組織課題と、これから取り組もうとしているその解決方法を紹介します。 ZOZOテクノロジーズの社員構成 マネジメント勉強会とは 立ち上げまでの道のり 運営メンバーの勧誘 経営層への企画提案 勉強会の命名 1年間で実施したテーマ 第1回 各チームで実施しているチームビルディング施策の共有 第2回 書籍「1on1マネジメント」を読んだ上で内容について議論 第9回 採用面接で質問している内容について意図と効果共有 マネジメント勉強会を通じて分かってきたZOZOテクノロジーズの現状 1.組織の急拡大による弊害 2.現場のコンフリクト 3.マネジメントと人材育成 組織開発チームの立ち上げについて 1.組織の急拡大による弊害への対応 2.現場のコンフリクトへの対応 3.マネジメントと人材育成への対応 今後の展望 マネジメント勉強会のリニューアル 組織開発チームが目指す組織 最後に ZOZOテクノロジーズの社員構成 まず、弊社について簡単に説明します。弊社は株式会社ZOZOの100%子会社であり、親会社のZOZOと子会社のZOZOテクノロジーズで主な役割が異なっています。ZOZOにはZOZOTOWNを運営するために必要なブランド営業・マーケティング・カスタマーサポート・物流まわりなどのスタッフが在籍しています。 一方、ZOZOテクノロジーズにはシステム開発をするために必要なスタッフ、エンジニア・デザイナー・リサーチャーなどが在籍しています。2020年2月時点ではZOZOテクノロジーズには約400名の従業員が在籍し、そのうちエンジニアは約350名ほどを占めています。 マネジメント勉強会とは そんなエンジニアの比率が高い会社の中で、マネジメント勉強会を立ち上げた経緯から説明します。 まず、ある法則を紹介します。米国の人事コンサルタント会社ロミンガー社の調査によると、ビジネスにおいて人は70%を仕事上の経験、20%を同僚からの助言やフィードバック、10%を研修などのトレーニングから学ぶと言われています。これは「7・2・1の法則」とも呼ばれ、企業研修などでもよく引用されています。 わたし自身、弊社に入社してから3年ほどSREチームのマネージャーとしてマネジメントをしてきてこの「7・2・1の法則」を実感してきました。仕事上の経験は当然積むのでそこからの学びはもっとも多く、マネジメント関連の書籍もたくさん読んで可能な限り自学してきました。ただ、それだけでは自分の成長速度に限界を感じていたのも事実でした。 そこで自分自身の成長速度を加速させるために上記法則の「同僚からの助言やフィードバック」を強化したいと考え、そのための仕組みを他のマネージャーたちと一緒に作りたいと思い立ちました。わたしが成長速度に物足りなさを感じていたのと同様に、弊社の他のマネージャーもそれぞれ悩みや、それに対する解決方法を持っていて、お互いに共有しあうことで成長したいと感じているのではと思ったからです。 また、マネジメント勉強会の立ち上げを検討し始めた2019年10月はZOZOグループが買収され前代表の前澤が退任した直後でした。強力なトップダウン型リーダーがいなくなったタイミングだったので、自分たちも変わらなければという意識が全社的に強まっていた時期でもあり、会社としてもマネージャー層のマネジメントスキル向上や横の情報共有を強化することが重要であるのではという考えもありました。 立ち上げまでの道のり そのような考えからマネジメント勉強会の立ち上げを思い立ち、下記のようなステップで初回の開催を計画していきました。 運営メンバーの勧誘 経営層への企画提案 勉強会の命名 運営メンバーの勧誘 まず、マネジメント勉強会を立ち上げるにあたって最初に考えたのが「1人で運営するべきではない」ということでした。複数人の運営メンバーを擁立することで下記のようなメリットがあります。 運営にかかる工数を役割分担することで分散できる 運営メンバーで相談しながら運営することで会のクオリティを向上できる 開催の継続力が高まる そこで以前から社内でマネジメントに関して関心が強かった荒井( @arara_jp )と鶴見( @_tsurumiii )に声をかけて、運営チームが確立されたことでマネジメント勉強会の立ち上げを決めました。 経営層への企画提案 次にマネジメント勉強会の立ち上げについて経営層に企画の共有をしました。 弊社はエンジニアが多い会社なので技術的な勉強会は社内で日常的に行われており、勉強会の開催自体はよくあることなので通常であれば経営層に許可を取る必要はありません。しかしマネジメント勉強会については組織横断型を想定しており、マネジメント層のスキル向上など経営課題にも関連する内容だったので企画について事前共有をしました。 結果、経営層からは応援するという前向きな意見をもらうと同時に、勉強会の中で出てきた「マネージャー層が感じている課題感や制度設計に関する要望」などについて教えてほしいという要望をもらい現在まで開催毎に経営層へのフィードバック会を実施しています。 勉強会の命名 この会は経営層から言われて業務命令で立ち上げたわけでもなく、人事が研修として公式な業務で行っているわけでもなく、一般のチームリーダーが自主的に必要性を感じて周囲に声をかけて始めた試みです。 そこでこの勉強会を立ち上げるに伴いもっとも意識したのは「ネーミングを間違えない」ということでした。具体的には「マネジメントのことを誰かに教えてやるという上から目線を感じない名前」にしようということです。これは設立理由にも記述したように、わたし自身が周りのマネージャー陣と一緒に「マネージャーとして成長していきたい」という想いから始まっているので運営メンバーは参加者を教育するような立場ではなく、あくまで参加者と同じ目線でいたいということです。ネーミングが上から目線になることで、多くのマネージャー陣の共感を得られず成果を挙げられないまますぐ終わるというようなことだけは避けたいと思っていました。 当初、勉強会の目的の1つにジョブマネジメントだけでなくピープルマネジメントの重要性を広めたいという気持ちもあったので「ピープルマネジメント研究会」みたいな案もありました。しかし、これだと一部の意識高い人だけが参加するというような印象を受けて参加ハードルが高いだろうと運営メンバーで話し合い、立ち上げ当初のネーミングとしては「新人マネージャー勉強会」に落ち着きました。 今の「マネジメント勉強会」とは異なる名前ですが、これは経験の浅いマネージャーも参加しやすいようにハードルを下げたいという意図がありました。また、運営メンバー陣もマネジメント歴がそれほど長いわけではないので「皆一緒なんですよ」というスタンスを強調したものでした。この名前は数回開催したあとに、逆に経験のあるマネージャーが参加しづらいのではという意見があり現在の「マネジメント勉強会」に変更しています。 1年間で実施したテーマ マネジメント勉強会は現在に至るまでの約1年間をかけ、合計11回開催してきました。各回の参加人数はテーマによってまちまちですが10名程度から最大で50名程度となっています。累計では全管理職の約8割がいずれかの回に参加しています。 これまでに下記のテーマを選定し開催してきました。 第1回 各チームで実施しているチームビルディング施策の共有 第2回 書籍「1on1マネジメント」を読んだ上で内容について議論 第3回 外部講師を招いての1on1勉強会 第4回 評価についての悩みや疑問を共有する会 第5回 他チームリーダーへの質問・相談会 第6回 マネージャーのキャリアプランについて考える会 第7回 外部講師を招いてのピープルマネジメント勉強会 第8回 コロナ禍でのチームマネジメント方法の工夫を共有する会 第9回 採用面接で質問している内容について意図と効果共有 第10回 新人事制度についての質問、フィードバック会 第11回 書籍「1on1ミーティング」を読んだ上での内容について議論 内容については各マネージャーが抱えている課題や悩みについて共有することで他のマネージャーから解決案のヒントを得るという形式や、会社の新制度導入タイミングに合わせた企画が多いです。また、全員で同じ書籍を読んで読書会のような方法をとったり、外部講師を呼んで講演会のような形式をとったりと参加メンバーが飽きないような工夫もしてきました。 いくつかテーマの紹介とそれぞれの効果を簡単に紹介します。 第1回 各チームで実施しているチームビルディング施策の共有 第1回では各チームで実施しているチームビルディング施策について紹介し合いました。たとえば下記のような施策が紹介されました。 人生(モチベーション)グラフ 過去のモチベーションが上がった出来事、下がった出来事を人生グラフにして開示しあうことでお互いのパーソナリティを知る。 チーム内ZOZOエール 会社で採用しているUniposというピアボーナス制度を活用し、日頃の感謝やバリューを体現した行動を賞賛しあう時間を毎週の定例の中に作る。 なお、チーム内ZOZOエールについては、以下の記事で紹介しています。 techblog.zozo.com 他にもたくさんの施策がありましたが、他のチームがやって成功している事例を取り入れることができ有意義な内容となりました。コロナ禍になる前でしたが、当時からリモートワークが制度として導入されていたこともあり、人間関係を円滑にするための工夫をそれぞれのチームが実施していることが印象的でした。 第2回 書籍「1on1マネジメント」を読んだ上で内容について議論 第2回はピープルマネジメントについての書籍「1on1マネジメント」を参加者で事前に読んできて内容について当日議論するという内容でした。事前に課題図書を作ることで参加ハードルは上がってしまうのですが、共通の書籍を読んでくることで目線のすり合わせができた状態で1つのテーマについて話すことができとても好評でした。 この書籍を参加者全員が読むことでピープルマネジメントの大切さについてインストールされたマネージャーが増えたことは会社として価値のあったことだと思います。 第9回 採用面接で質問している内容について意図と効果共有 第9回では採用をテーマにして議論しました。面接の際にどのような質問をすると候補者の本音が引き出せやすいかなどの知見の共有です。また、応募者が現在の採用ページを見るとこの部分で迷うのではないかという意見や、こんな面接官トレーニングをしたらいいのではという要望も出てきたので後日、採用人事へフィードバックする会も実施しました。 この会でも参加したマネージャーから多くの意見や悩みが出てきて、今まで会社として拾い上げることができていなかった声を拾えた意義ある会となりました。 マネジメント勉強会を通じて分かってきたZOZOテクノロジーズの現状 このようにマネジメント勉強会を開催していく中で、会社の局所的な課題やマネージャーが持つ個々の悩みについては横の情報共有をすることで、ある程度は解消することができました。しかし、マネジメント勉強会だけでは解決が難しい下記のような3つの大きな課題もZOZOテクノロジーズにはあることが分かってきました。 1.組織の急拡大による弊害 ZOZOテクノロジーズはこの3年間で社員数が200人から450人に急増してきました。この急激な規模拡大の影響からZOZOグループ全体で目指している上位戦略が現場に伝わりづらくなってしまい、やりがいや達成感を感じづらくなっているという問題が起きているようでした。また、規模が大きくなることで隣の部署やチームが何をしているのかよく分からないという関心の希薄化も生まれていました。 2.現場のコンフリクト ZOZOテクノロジーズはグループ内にあった7社が合併してできた会社であるため、さまざまな文化が混ざりあった状態です。それぞれの会社で大切にしていた価値観が時にぶつかり合うこともありました。また、文化が融合し、それまでのどの文化とも違う「新しいZOZOらしさ」が生まれましたが、その変化の速さに追いつけない社員も出てきました。 technote.zozo.com 3.マネジメントと人材育成 そして現場のマネージャー層がもっとも困っていたのは自身のマネジメントスキル向上についてでした。各々のチームの中で各リーダーがさまざまな工夫をしているものの、会社としてスキルや役割の標準化、マネージャー育成支援が十分にできているわけではなかったことからマネジメントスキルの属人化が進んでいました。また、現場の社員もキャリアパスや育成についての明確な指針がないことで不安を感じているという状態でした。 上記の3つの大きな課題はグループ会社が合併する前まではそれほど大きな問題にはなっていませんでした。それは会社の規模がまだ小さく、合併もしていない1つのみの会社だったので文化も単一、縦と横のつながりが強固で、やりがいと達成感を感じやすい環境だったからです。しかし、グループ会社が合併し、文化が混ざりあった状態で下図のような変化が起きてきました。 出典: 1on1ミーティング「対話の質」が組織の強さを決める この図では横軸が「上司・同僚との関わり具合」、縦軸が「ストレッチ経験の量」を表しています。どちらも高い状態だと「成長実感職場」となります。現在のZOZOテクノロジーズは上述のような歴史的背景から「上司・同僚の関わり具合」が徐々に薄れてきており、左上の「挑戦させすぎ職場」となっている可能性が高いです。 組織開発チームの立ち上げについて このような3つの大きな組織課題と「上司・同僚の関わり具合」が減っていることによる「挑戦させすぎ職場」になっている状態を脱却するため、新たに「組織開発」をテーマにした新チームを立ち上げることにしました。組織開発に詳しい専門家を他社からリーダーとしてスカウトし、わたし自身もメンバーとしてこのチームに参加しています。組織開発チームでは具体的に上記課題を以下のように解決していくことを目指しています。 1.組織の急拡大による弊害への対応 上位戦略の伝達のしづらさについては2020年10月から開始された新評価制度により全社目標、部門目標、個人目標の連携を強めています。自分の仕事がどのように上位戦略に結びついているか実感しやすくなることを目的としています。また、その連携を強めるための手法として週1回30分の1on1を必須として全社導入を始めています。隣のチームが何をしているか分からないという問題については階層別研修やコミュニケーション施策を強めることで解消を目指します。 2.現場のコンフリクトへの対応 新しいZOZOらしさへの適応についても2020年10月に制定された新バリューの浸透施策により共通の価値観を醸成すると共に、同じ目標に向かっていけるよう目標管理制度を導入しています。また、個別の組織課題についても組織開発チームとして支援をします。 3.マネジメントと人材育成への対応 マネジメントスキルについても会社として役割の明文化、スキルの標準化を目指し各種研修、支援を開始します。また、現場社員についても等級定義やキャリアパスを明示的に提示し、キャリア関連施策を実施します。 今後の展望 マネジメント勉強会のリニューアル これまでマネジメント勉強会は初期運営メンバーが、その都度必要だと思うテーマについて検討して実施してきました。今後は上述の会社全体の課題から逆算したゴール設定をし、戦略的に他の社内施策や勉強会とも連携調整しながら進めていく必要があると考えています。運営メンバーも追加募集を始めており、より会社に貢献するような会へと進化させていきます。 組織開発チームが目指す組織 発足した組織開発チームでは「創造性を解き放つ人と組織をつくる」をミッションとし、上記のような必要な施策や制度推進に取り組むことで、組織の課題解決をしていきたいと考えています。そしてグループのビジョンである「世界中をカッコよく、世界中に笑顔を。」の実現を目指します。 最後に まだまだたくさん課題のある会社ですが、スキル・マインド共に高い魅力的な社員が多く、組織課題をうまく解決していくことで飛躍するポテンシャルが非常に高い組織です。やるべき方向性は明確で進化の真っ最中です。 一緒にサービスを作り上げてくれる方はもちろん、さまざまな職種でZOZOテクノロジーズをいい組織にしてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
計測プラットフォーム部バックエンドチームの鈴木です。 この記事では、Akka gRPCを利用しているScalaアプリケーションのZOZOMATに対してKamonを通じてAPMを導入した際に得られた知見、うまくいかなかった内容やその対応策を紹介します。 Akkaとは 最初にAkkaについて簡単に紹介します。Akkaは、JVM上で並行および分散アプリケーションの構築を容易にするツールキットとランタイムです。 Actorモデルの実装であるAkka Actorsを中心とし、Akka StreamsやAkka HTTP、Akka Clusterなど様々なツールが提供されています。詳しくは 公式ドキュメント や Akka実践バイブル を読むことで深く理解できます。書籍で紹介されているAkkaのAPIは、今では古いものとなっていますが、Akkaの楽しさを知るにはとても良い本です。 私たちが開発しているZOZOMATではAkka Actors、Akka HTTP、Akka Cluster、Akka gRPCなどを利用しており、Akkaのツールセットの恩恵を非常に受けています。 APMとは アプリケーションを構築していくために、APMの導入も必要になってきます。 APMはApplication Performance Management、つまりアプリケーション性能管理の略称です。アプリケーション性能管理のツールとして私たちはDatadogを利用することにしました。 APMの機能を利用するためのJavaエージェントがDatadogから提供されています。しかし、私たちのアプリケーションは以前からKamonを利用していたので、DatadogのJavaエージェントは利用せずにKamonからDatadogに連携してAPMを利用することにしました。 Kamonを利用した上でも、分散トレーシングとしてDatadogは利用可能です。なお、私たちのアプリケーションでは1つのアプリケーション内でのトレーシングに留まっているので、この記事では単に「トレーシング」と呼称します。 Kamonとは 上述のKamonについて紹介します。 KamonはJVM上で動くアプリケーションのモニタリングツールであり、JVMのメトリクス取得や本記事でも紹介するトレーシングを行うことができます。 Kamonは収集ツールのレポート先としてDatadogやNew Relic、Kamonが提供しているKamon APMなどを選択可能です。 私たちはトレーシングより以前にメトリクス取得のためにKamonを導入していて、Datadogをレポート先として利用していました。 本記事で利用する「Span」とはトレーシングの文脈において1つのアプリケーション操作を表します。「DBへ問合せる」「HTTPリクエストを送る」「計算する」など考えられます。 基本的にはSpanの作成は自由にでき、粒度はアプリケーション実装者が自由に決められます。 KamonにおけるSpanの詳しい説明は以下のページに丁寧な記述があります。 kamon.io 公式ドキュメントを参考に導入してみる Akka HTTPをJSONサーバとして利用、またはPlay Frameworkを利用している場合、公式ドキュメントに沿って進めることで簡単にアプリケーションに導入できます。 kamon.io kamon.io kamon.io KamonがInstrumentationを提供しているので、それに従って進めれば良いのです。Instrumentationに関しての補足は後述します。 これに従えば、ソースコードの修正はほぼ必要ありません。簡単ですね。 しかし、動作確認をする際に1つポイントがありました。 Kamonのデフォルトのサンプリングロジックでは、アプリケーション起動直後のアクセスは、そのアクセス量に関係なくサンプリングされません。 Kamonのコントリビュータによる Issue でも言及されていますが、開発時などに動作確認したいときには、 application.conf に下記のような記述を追加してサンプリングのロジックを変更することをお勧めします。常にサンプリングが行われるロジックが選択されるので、動作確認時の混乱が減ります。 kamon.trace.sampler=always 動かしてみたがSpanがDatadogに送信されない チュートリアルを参考にしながら、私たちが開発しているZOZOMATのアプリケーションへ導入してみましたが、Spanはいつまで経っても反映されませんでした。 リクエストに紐づくSpanが作成されていることはログを出力することで確認できましたが、そのSpanがDatadogに送信されることはありませんでした。 調査を進めると、私たちのアプリケーションがAkka gRPCを利用していることが原因でした。 では、なぜSpanがDatadogに送信されなかった原因を解説する前に「なぜソースコードの変更なしでトレーシングが実現できるのか」を説明していきます。 なぜソースコードの変更なしでトレーシングが実現できるのか ずばり Kamonから「Instrumentation」が提供されているから です。 この「Instrumentation」は Byte Buddy を利用して実装されているモジュールです。Byte BuddyはJavaのバイトコードを操作して既存のクラスを変更できるライブラリです。 KamonのInstrumentationはByte Buddyを利用してAkkaやJDBCの実装を拡張しています。そのため、Akka Actorsがメッセージを送信するときや、JDBCがSQLを実行するときにKamonのAPIを呼び出すように拡張されています。 実際にByte Buddyを利用してJDBCが拡張されている処理は以下で確認できます。 github.com 拡張されたコードの処理は、巡り巡って StatementMonitor のようなKamon自身を呼び出す処理に到達します。 なぜZOZOMATではチュートリアルにそって導入できなかったのか Akka HTTPのInstrumentationが提供されているのに、なぜ私たちのアプリケーションはチュートリアルに沿って導入できなかったのか。 それはAkka HTTP用のInstrumentationが Directiveを利用した際にKamonのAPIが呼びだされる ようになっていたからでした。 Akka HTTPのDirectiveは簡単に言うとHTTPルーティングを記述するためのクラスです。Akka gRPCはDirectiveを呼び出す代わりに、Akka HTTPを拡張してgRPCを受け付けるライブラリなため、KamonのSpanを送信するために必要なAPIが呼ばれていなかったのです。 1 また、私たちのアプリケーションではSpanは送信されないものの、リクエストを受信した時にSpanの作成はされていました。これは、Akka HttpのInstrumentationではAkka gRPCを利用した場合でもAkka Httpの内部で利用されるクラスに拡張がされていたからでした。 Akka gRPCでもKamonのSpanが送信されるようにする Akka gRPCでもKamonのSpanが送信されるようにする方法を見つけました。 APIからSpanを送信するために必要な takeSamplingDecision メソッドを呼ぶようにする。 この対応をすることで、SpanはDatadogに連携されるようになりました。これで解決。 かと思ったら、ここに来て新しい問題に気付きました。 Akka gRPCからのリクエストを処理する部分で例外が発生した場合にSpanが送信されていませんでした。ここまでご覧頂いている方なら察しがつくかと思います。 原因は、ここでも同様に takeSamplingDecision を呼ぶ必要がある、という点でした。 Spanの範囲内で処理に失敗したことを宣言する fail メソッドは、Akka HTTPのInstrumentationではDirectiveモジュール内で呼び出されていました。そのため、Akka gRPCで利用されるモジュールでは takeSamplingDecision が呼び出されませんでした。 Akka gRPCで生成されるクラスには、エラーハンドリングにおいて共通の処理を記述できる場所が存在します。 github.com github.com この共通処理に fail メソッドを呼ぶ実装を加えて一件落着しました。 以下が、ここまでに言及してきた修正を加えた簡単なソースコードの例です。 XXXServicePowerApiHandler と XXXServiceImpl はAkka gRPCがprotoファイルから生成されるクラスと、そのクラス内の処理を実装するクラスを仮定したものです。 object Main extends App { implicit val actorSystem: ActorSystem = ActorSystem() val handler: HttpRequest => Future[HttpResponse] = { request => Kamon.currentSpan().takeSamplingDecision() XXXServicePowerApiHandler( XXXServiceImpl, { actorSystem => throwable => Kamon.currentSpan().fail(throwable) GrpcExceptionHandler.defaultMapper(actorSystem)(throwable) } )(actorSystem)(request) } Http().bindAndHandleAsync( handler, interface = "0.0.0.0" ) } APMの導入を経て得られたもの 上述の対応により、トレースが正しく動作するようになり、APMの導入を無事に完了できました。レイテンシ増加の疑いのあったソースコードの処理時間を計測できたり、突然発生したエラーの原因調査のための材料が増えました。 導入ができただけでなく、試行錯誤をする過程でKamonについての理解が深められ、Kamonのソースコードを読むだけでなく、バグを発見して 簡単なPull Request を送ることもできました。 なお、Pull Requestの作成は、過去のテックブログの記事にある OSSへの貢献 - Issueから始めるチーム活動 の一環として実施できました。 techblog.zozo.com 今回、Akka gRPCとKamonのインテグレーションの実現のためにアプリケーション側にコードを追加しました。しかし、他のライブラリのようにInstrumentationで実装できるとカッコいいですよね。今後取り組んでいこうと思います。 最後に 計測プラットフォーム部バックエンドチームでは、ZOZOMATをはじめとする計測技術でよりオンラインでの購入体験を向上させたいバックエンドエンジニアを募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! www.wantedly.com Spanには種類がいくつかあり、Akka HTTPがInstrumentationによって作成するSpanは takeSamplingDecision を呼び出す必要があるSpanでした。 ↩
アバター
こんにちは。福岡研究所の岩本( @odiak_ )です。 みなさん、Kotlinのコルーチンを使っていますか? 私は、最近久しぶりにAndroidのコードを触る機会があり(3年ぶりくらいでしょうか)、以前から存在は知っていたものの詳しく知らなかったコルーチンを少し使ってみました。まずドキュメントを読んでみたのですが、よくデザインされているなと感じました。今回は使っていませんが、ChannelやFlowなども良さそうです。 この記事では、Kotlinのコルーチンを支える言語機能の1つである、suspend修飾子付き関数の動きをバイトコードから読み解いていきます。 対象読者としては、KotlinをAndroidアプリの開発やサーバーサイドで使用していて、言語処理系の挙動にも興味がある方を想定しています。 コルーチンの紹介 ご存知ではない方のために、Kotlinのコルーチンについて簡単に紹介しておきます。 コルーチンは、軽量スレッドのようなものです。コルーチンを起動すると、それはどこかのスレッドで実行されます。デフォルトの動作ではコルーチン毎にスレッドを起動することはなく、プールされたスレッドで実行されるので、大量のコルーチンを一度に起動しても問題ありません。コルーチンは、次のように使います。 import kotlinx.coroutines.* fun main(args: Array<String>) { GlobalScope.launch { println( "hello" ) delay( 1000L ) println( "world" ) } Thread.sleep( 1200L ) } ここでは単純に1つのコルーチンを立ち上げて、helloと表示した1秒後にworldと表示しています。より高度な使い方としては、いくつかのコルーチンを立ち上げて並行して何か計算したり、コルーチン同士で通信し合いながら処理を行ったりといったことも可能です。 詳しくは、 Kotlinのドキュメント を読んでみてください。 suspend修飾子付き関数 このようなコルーチンの機能の背景にいる登場人物としては大きく分けて、Kotlinの言語自体に備わっている基本的な機能と、コルーチンのライブラリ(kotlinx.coroutines)の2つがあります。前者の言語自体の機能のうち、suspend修飾子付き関数(以降、suspend関数と呼びます)は特に特徴的なものです。この記事では、suspend関数について深く掘り下げていきます。 先ほどの例で挙げたコードでは、関数 delay はsuspend関数です。また、 GlobalScope.launch に渡しているラムダ式も、明示されてはいませんがsuspend関数です。 suspend関数では、非同期的な処理をまるで同期的な処理のように呼び出すことができます。suspend関数を使わない場合、非同期的な処理を呼び出すにはコールバック関数などを渡す必要がありました。例えば次のように。 fun getPost(id: String, callback: (Content) -> Unit ) { /* ... */ } fun decodeContent(content: String, callback: (String) -> Unit ) { /* ... */ } fun getContent(id: String, callback: (String) -> Unit ) { getPost(id) { post -> decodeContent(post.content) { content -> callback(content) } } } コールバックを使うと、ネストが深くなってしまいコードが読みづらくなる上に、条件的に処理を呼び出すなどの複雑なコードが書きづらくなります。これを、コルーチンを使って書くと次のように、まるで同期的な処理のように書くことができます。 suspend fun getPost(id: String): Post { /* ... */ } suspend fun decodeContent(content: String): String { /* ... */ } suspend fun getPostContent(id: String): String { val post = getPost(id) val content = decodeContent(post.content) callback(content) } suspend関数を呼び出すと、呼び出した側の処理は一旦そこで中断されます。呼び出された関数の処理が終わると、呼び出した側の処理が再開されます。 先ほど関数 delay がsuspend関数であると書きましたが、 delay を呼び出した場合も同じように一度処理が中断され、指定した時間が経過してから処理が再開されます。注意したいのは、 Thread.sleep が処理をブロックするのとは違い、 delay のようなsuspend関数は処理をブロックはしないということです。 解説している動画を見てみたが、腑に落ちない 使っていて、ふと疑問が頭に浮かびました。こんな魔法のようなものがどうやって動いているんだろう、と。実行されるのはJVMの上だし、Javaにはこんな機能ありません。 そこで、そういった内部の話を解説しているというYouTube動画を見てみました。 KotlinConf 2017 - Deep Dive into Coroutines on JVM by Roman Elizarov この動画の前半で、suspend関数はコンパイルされると継続渡しスタイルに変換されて、しかもその継続はステートマシン的なもので表現されるので効率的だよというような話をしています。 最初に見たときは、大まかには理解できたものの、どこか腑に落ちない感覚がありました。 そこで、suspendの付いた関数を使ったコードをJVM向けにコンパイルして、そのバイトコードを見てみることにしました。 以下で行っていることは、先ほどの動画で話されている内容を実際に手を動かして確認してみた、という部分が多いです。 ただ、私はそのステップを踏むことで理解が大幅に進みましたし、その過程は非常に楽しいものでした。 バイトコードを読んでみる Kotlinのソースコードをコンパイルするといくつかのクラスファイルができます。それをjavapコマンド(JDKに付属しています)でダンプしてみます。 今回は、次のようなソースコードを使いました。 package net.odiak.kotlin_coroutines_experiment import kotlinx.coroutines.* suspend fun s1(): Int { println( "hello" ) delay( 1000L ) println( "world" ) return 42 } fun main(args: Array<String>) { runBlocking { println(s1()) } } こちらをコンパイルすると、 AppKt , AppKt$s1$1 , AppKt$main$1 という3つのクラスができます。 この記事では、そのうち AppKt と AppKt$s1$1 の2つを見てみます。それぞれを、 javap -c AppKt のように -c オプション付きで表示してみます。すると、次のようになります。 (長くなるので AppKt$main$1 を省略しましたが、読者の皆さんにはぜひ自身でコンパイルして結果を確かめてみていただきたいです) public final class net.odiak.kotlin_coroutines_experiment.AppKt { public static final java.lang.Object s1(kotlin.coroutines.Continuation<? super java.lang.Integer>); Code: 0: aload_0 1: instanceof #11 // class net/odiak/kotlin_coroutines_experiment/AppKt$s1$1 4: ifeq 39 7: aload_0 8: checkcast #11 // class net/odiak/kotlin_coroutines_experiment/AppKt$s1$1 11: astore 4 13: aload 4 15: getfield #15 // Field net/odiak/kotlin_coroutines_experiment/AppKt$s1$1.label:I 18: ldc #16 // int -2147483648 20: iand 21: ifeq 39 24: aload 4 26: dup 27: getfield #15 // Field net/odiak/kotlin_coroutines_experiment/AppKt$s1$1.label:I 30: ldc #16 // int -2147483648 32: isub 33: putfield #15 // Field net/odiak/kotlin_coroutines_experiment/AppKt$s1$1.label:I 36: goto 49 39: new #11 // class net/odiak/kotlin_coroutines_experiment/AppKt$s1$1 42: dup 43: aload_0 44: invokespecial #20 // Method net/odiak/kotlin_coroutines_experiment/AppKt$s1$1."<init>":(Lkotlin/coroutines/Continuation;)V 47: astore 4 49: aload 4 51: getfield #24 // Field net/odiak/kotlin_coroutines_experiment/AppKt$s1$1.result:Ljava/lang/Object; 54: astore_3 55: invokestatic #30 // Method kotlin/coroutines/intrinsics/IntrinsicsKt.getCOROUTINE_SUSPENDED:()Ljava/lang/Object; 58: astore 5 60: aload 4 62: getfield #15 // Field net/odiak/kotlin_coroutines_experiment/AppKt$s1$1.label:I 65: tableswitch { // 0 to 1 0: 88 1: 127 default: 151 } 88: aload_3 89: invokestatic #36 // Method kotlin/ResultKt.throwOnFailure:(Ljava/lang/Object;)V 92: ldc #38 // String hello 94: astore_1 95: iconst_0 96: istore_2 97: getstatic #44 // Field java/lang/System.out:Ljava/io/PrintStream; 100: aload_1 101: invokevirtual #49 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V 104: ldc2_w #50 // long 1000l 107: aload 4 109: aload 4 111: iconst_1 112: putfield #15 // Field net/odiak/kotlin_coroutines_experiment/AppKt$s1$1.label:I 115: invokestatic #57 // Method kotlinx/coroutines/DelayKt.delay:(JLkotlin/coroutines/Continuation;)Ljava/lang/Object; 118: dup 119: aload 5 121: if_acmpne 132 124: aload 5 126: areturn 127: aload_3 128: invokestatic #36 // Method kotlin/ResultKt.throwOnFailure:(Ljava/lang/Object;)V 131: aload_3 132: pop 133: ldc #59 // String world 135: astore_1 136: iconst_0 137: istore_2 138: getstatic #44 // Field java/lang/System.out:Ljava/io/PrintStream; 141: aload_1 142: invokevirtual #49 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V 145: bipush 42 147: invokestatic #65 // Method kotlin/coroutines/jvm/internal/Boxing.boxInt:(I)Ljava/lang/Integer; 150: areturn 151: new #67 // class java/lang/IllegalStateException 154: dup 155: ldc #69 // String call to 'resume' before 'invoke' with coroutine 157: invokespecial #72 // Method java/lang/IllegalStateException."<init>":(Ljava/lang/String;)V 160: athrow public static final void main(java.lang.String[]); Code: 0: aload_0 1: ldc #81 // String args 3: invokestatic #87 // Method kotlin/jvm/internal/Intrinsics.checkNotNullParameter:(Ljava/lang/Object;Ljava/lang/String;)V 6: aconst_null 7: new #89 // class net/odiak/kotlin_coroutines_experiment/AppKt$main$1 10: dup 11: aconst_null 12: invokespecial #90 // Method net/odiak/kotlin_coroutines_experiment/AppKt$main$1."<init>":(Lkotlin/coroutines/Continuation;)V 15: checkcast #92 // class kotlin/jvm/functions/Function2 18: iconst_1 19: aconst_null 20: invokestatic #98 // Method kotlinx/coroutines/BuildersKt.runBlocking$default:(Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Ljava/lang/Object; 23: pop 24: return } final class net.odiak.kotlin_coroutines_experiment.AppKt$s1$1 extends kotlin.coroutines.jvm.internal.ContinuationImpl { java.lang.Object result; int label; public final java.lang.Object invokeSuspend(java.lang.Object); Code: 0: aload_0 1: aload_1 2: putfield #12 // Field result:Ljava/lang/Object; 5: aload_0 6: aload_0 7: getfield #16 // Field label:I 10: ldc #17 // int -2147483648 12: ior 13: putfield #16 // Field label:I 16: aload_0 17: invokestatic #23 // Method net/odiak/kotlin_coroutines_experiment/AppKt.s1:(Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 20: areturn net.odiak.kotlin_coroutines_experiment.AppKt$s1$1(kotlin.coroutines.Continuation); Code: 0: aload_0 1: aload_1 2: invokespecial #30 // Method kotlin/coroutines/jvm/internal/ContinuationImpl."<init>":(Lkotlin/coroutines/Continuation;)V 5: return } 初めて見る方は何が何やら…という感じだと思いますが、コメントにクラス名やメソッド名が書いてあるのでなんとなく読めるのではと思います。 JVMはスタックマシンなので、命令を呼ぶことでスタックに値を入れたり出したりします。 それぞれの命令がどのような意味かは、リファレンスや本で必要なところだけ見てください。 まず、名前からも推測できますが、3つのクラスがどのようなものかを紹介します。 AppKt には、App.ktに含まれるトップレベル関数が、staticメソッドとして定義されている AppKt$s1$1 は、関数 s1 専用の継続クラス 継続クラスとは、ここでは kotlin.coroutine.Continuation インタフェースを実装したクラスを指す 簡単に言うと、suspend関数の途中から続きを実行するために用いられるコールバックのようなもの 2つのフィールドを持っている 戻り値のオブジェクトを保持する result どこに戻るべきかを表す label AppKt$main$1 は、関数 main 専用の継続クラス兼、 runBlocking に渡すラムダ関数 AppKt$main$1 とは継承している継続クラスが少し違うが、大きな差はない まずは、 AppKt.s1 を見ていきます。動画でも紹介されていた通り、引数に継続オブジェクトが追加されています。 また、戻り値は Integer ではなく Object になっています。戻り値がObjectなのは、メソッドの処理がまだ終わっていない場合に COROUTINE_SUSPENDED という特殊なオブジェクトを返すためです。 0-2行目: 第1引数が AppKt$s1$1 のインスタンスではない場合は、39行目にジャンプします。 7-11行目: 第1引数を AppKt$s1$1 にキャストして変数に入れます。この変数を cont と呼ぶことにしましょう。 13-33行目: 今回はあまり関係ないですが、 cont のフィールド label の最上位ビットを見て、フラグ操作をしています。最上位ビットが1の場合は、 AppKt$s1$1.invokeSuspend から呼ばれた場合です。その場合、最上位ビットを0にして cont の label に代入し、49行目にジャンプします。最上位ビットが0の場合は、39行目にジャンプします(このケースは、再帰呼び出しの場合です。なので今回は関係ありません) 39-47行目: AppKt$s1$1 のインスタンスを新しく作り、ローカル変数 cont に入れます。コンストラクタの引数は、 s1 の第1引数である継続オブジェクトです。 (簡単にいうと、引数に渡された継続オブジェクトをs1用の継続クラスでラップしているということです) 49-54行目: cont のフィールド result を変数に代入します。 result と呼ぶことにしましょう。 55-58行目: Kotlinが定義している COROUTINE_SUSPENDED というオブジェクトを取得し、変数に代入します。 62-65行目: cont の label を読み、その値を元に処理を分岐します。 0の場合:88行目へ 1の場合:127行目へ それ以外:151行目へ( IllegalStateException を投げるだけ) 88-89行目: cont.result が例外を表現している場合は例外を投げる関数 throwOnFailure (Kotlinの Result というinlineクラスのメソッド)を呼びます。ただし、 result はこの時は初期値(null)のままなので、呼び出す意味はあまりないと思われます。 92-101行目: println("hello") を呼び出します。 111-112行目: cont.label に1を設定します。 104-115行目(スタックの関係で行数が前後しています): 関数 delay を呼び出します。引数は、1000Lと cont です。 118-126行目: delay の戻り値が COROUTINE_SUSPENDED なら、同じ値をreturnしてs1から抜けます。そうでない場合は、132行目にジャンプします。 127-128行目( label が1の場合はここにジャンプしてくる): 先ほどと同じく throwOnFailure を呼び出します。つまり、 delay の実行が失敗していないかをチェックするわけです。 132-142行目: println("world") を呼び出します。 145-150行目: 42という数値をreturnして s1 から抜けます。 151行目以降: IllegalStateException を投げているだけです。基本的にはここを通りません。 次に、 AppKt$s1$1.invokeSuspend のコードを読んでみますが、その前に invokeSuspend の立ち位置を理解しておきましょう。 invokeSuspend は、その祖先クラス( ContinuationImpl , BaseContinuationImpl )または Continuation インタフェースを見るとその役割が分かる ContinuationImpl や BaseContinuationImpl の実装は こちら Continuation の定義は こちら まず、 Continuation インタフェースは resumeWith というメソッドを持っており、このメソッドは名前の通り中断した処理を再開する 例えば、delay関数に継続オブジェクトを渡した場合、一定時間が経つとその継続オブジェクトの resumeWith が呼ばれる BaseContinuationImpl における resumeWith の実装では、自身の invokeSuspend メソッドを呼び出す そこで COROUTINE_SUSPENDED が返ってきたらメソッドは終了 それ以外の値が返ってきた場合、内部に持っている継続オブジェクトへと関心を移す s1 のコードで見たように、 BaseContinuationImpl は他の継続オブジェクトをラップする それが同様に BaseContinuationImpl であれば、また invokeSuspend を呼んで、同じことを繰り返す その他の Continuation であれば、 resumeWith を呼び出して終了 はい、 invokeSuspend が BaseContinuationImpl における重要なメソッドであることが分かったところで、 AppKt$s1$1.invokeSuspend を読んでいきましょう。と言っても、大変短いです。 0-2行目: 第1引数をフィールド result に入れます。 6-13行目: フィールド label の値を取り出し、最上位ビットを1にして代入します。これは先ほども見たように、 s1 が s1 自身から再帰呼び出しとして呼び出されたのか、 invokeSuspend から呼び出されたのかを区別するためのフラグです。 16-20行目: 自身(this)を引数にして s1 を呼び出し、その戻り値をreturnします。 コードを読んで分かったことのまとめ 関数 s1 を中心にいろいろ読んでみましたが、ここで少しまとめておきましょう。 suspend関数 suspend fun s1() -> Int をコンパイルすると、少しシグネチャが変わった関数 fun s1(Continuation<Int>) -> Any? と関数 s1 のための継続クラス AppKt$s1$1 ができる 継続クラスには、待ち合わせていた処理の戻り値を保持する result と、 s1 内で処理を再開する位置を表す label という2つのフィールドがある 継続クラスには、 invokeSuspend というメソッドが定義されており、それは中断されていた関数 s1 の処理を再開するときに呼ばれる コンパイルされた関数 s1 の動作について 引数に指定された継続オブジェクトは、 AppKt$s1$1 の invokeSuspend から呼び出された場合を除いて継続クラス AppKt$s1$1 でラップされる AppKt$s1$1 の label によって、コード内の指定の位置にジャンプする label が0の場合(初期状態)は始めから "hello"と出力する label を1に設定する 継続オブジェクトを引数に含めて delay 関数を呼ぶ delay 関数は COROUTINE_SUSPENDED を返すので一旦処理を中断し、 COROUTINE_SUSPENDED をreturnして s1 を抜ける label が1の場合は途中から delay 関数の実行が失敗していた場合は、例外を投げて終了する "world"と出力する 42をreturnして s1 を抜ける これを踏まえて、コンパイルされた s1 を実行する流れをざっくりとまとめてみます。 何らかの Continuation インタフェースを実装したオブジェクト(継続オブジェクト)を用意 その継続オブジェクトを引数にして s1 を呼び出す 継続オブジェクトを AppKt$s1$1 でラップする "hello"を出力する ラップした継続オブジェクトの label を1にする ラップした継続オブジェクトを引数に含めて delay を呼び出す delay は COROUTINE_SUSPENDED を返すので、 s1 も同じ値を返して終了 delay に指定した時間が経つ(あるいは実行が失敗する)と、何者かによりラップした継続オブジェクトの resumeWith が呼ばれる resumeWith が同オブジェクトの invokeSuspend を呼び出す invokeSuspend が同オブジェクトを引数に入れて s1 を呼び出す 今度は継続オブジェクトをラップせずにそのまま使う なぜなら invokeSuspend が s1 を呼ぶとき、 label にフラグを立てているから なお、 s1 でフラグは戻される 最初に呼ばれた時とは label の値が変わっているため、続きから処理が行われる delay の実行が失敗していた場合は、例外を投げて終了 "world"を出力する 42を返して s1 が終了 なお、戻り値は BaseContinuationImpl における resumeWith の実装によって、最初に s1 へ渡された継続オブジェクトへと渡される いかがでしたか? suspend関数がコンパイルされて、同期処理のように書かれたコードがコールバック渡しのように変換されている様子が少しでもお分かりいただけたでしょうか? この説明ではいろいろなものを省略したので、例えば次のような疑問を抱くかもしれません。 最初の継続オブジェクトはどこで作られるの? delay 関数がコールバックを呼ぶ仕組みはどうなっているの? s1 関数にループや再帰呼び出しが含まれていた場合はどうなるの? コードを読んだり同じようにコンパイルしてみたりすれば分かりますが、おまけとして少し触れておきます。 最初の継続オブジェクトはどこで作られるのか これはkotlinx.couroutinesライブラリの方を読むと何となく分かります。最初の継続オブジェクトは、 launch や runBlocking などの普通の関数からsuspend関数を呼び出すような関数の中で作られています。 あまり詳しくは読んでいませんが、継続オブジェクトなどいろいろな物を用意して、スレッドに実行させたり処理を待ち合わせたりしているようです。 delay 関数がコールバックを呼ぶ仕組み これも軽く読んだ程度ですが、イベントループのような物を使っています。 suspend関数にループや再帰呼び出しが含まれていた場合 ループの場合も、大きくは変わりません。JVMの世界では、ループも単にジャンプを含んだコードになるだけです。 ただし、関数が再び呼び出された際に、ループなどで使用している変数を復元する必要が出てきます。 そこで、継続クラスにフィールドが追加されて、変化する可能性のあるローカル変数をそこに保存しておきます。再び呼び出された際は、適切な場所にジャンプされ、そこで変数を復元することで処理を再開できます。 再帰の場合も特別なことはありません。再帰呼び出しが行われると、継続オブジェクトが同じクラスでどんどんラップされていきます。 resumeWith がネストした継続オブジェクトを辿ってくれるので、他のsuspend関数を呼び出す場合と全く同じです。 おわりに 「Kotlinのsuspend関数がバイトコードレベルでどう動いているのか?」という素朴な問いに答えるため、いろいろと調べてみました。 調査するにあたって、コンパイルしたバイトコードを読んだり、関連するライブラリのコードを読んだりしました。やや大変でしたが、程よい難しさで、ソースコード読みの練習としても良かった気がします。 (余談ですが、今回kotlinx.coroutinesなどのコードをGitHub上で読んでいました。今思うと、IDEなど使えばもっと楽だったのでは、という気もします) 今回、「継続」という概念について初めて知りました。まだ雰囲気しか分かっていないので、個人的にもう少し掘り下げてみたいです。 最後までご覧いただきありがとうございました。 ZOZOテクノロジーズでは、各種エンジニアを採用中です。ご興味のある方は以下のリンクからご応募ください。 tech.zozo.com
アバター
こんにちは、ZOZOTOWN部フロントエンドチームの高橋( @anaheim0894 )です。 2020年5月からZOZOTOWN部では、「UI/UX改善プロジェクト」を立ち上げ、小さなUI改善を進めるチームを発足しました。 そこでこのプロジェクトの紹介をしながら、その工夫したポイントをお伝えします。新しいプロジェクトを立ち上げる際の参考になれば幸いです。 なぜプロジェクトを立ち上げたか 現在の ZOZOTOWN のWebサイトには大きく以下の2つの問題点が存在していました。 いろいろな機能、仕様、デザイン、宣伝などが盛り込まれ、見づらくなる 大規模案件や新機能に時間を取られ、サイト細部のアップデートが滞ることによりUXが低下する 直近のZOZOTOWN開発チームは、ZOZOSUITやZOZOMATのような大規模案件を中心に開発へ取り組んできました。 これまでも「改善を自分達で考えて実装する」をチームで意識し、それがチームの強みでもありました。しかし、大規模案件の対応をしていると、優先度的にもなかなか小規模な改善案件に取り組むことができない状況でした。 その結果、大規模案件に多くのメンバーの工数を割り振る必要があるため、チームの強みである「改善を自分達で考えて実装する」という経験が少なくなってきていることに危機感を感じました。 そこで、「改善を自分達で考えて実装しリリースする」にフォーカスしたボトムアップのプロジェクトを目指した「UI/UX改善プロジェクト」を発足させました。 プロジェクトの目的 このプロジェクトは、以下の3点を達成できる組織になることを目指しています。 1:小規模な改修のPDCAを回せる体制作り 「『ここをこうしたらいいのに』と思っていた小規模な案件を実施し、短期間でリリースする。その結果、UI/UXブランディング向上につながる」という目的を掲げました。 1案件がなるべく大規模な開発へ膨れ上がらないよう、1案件あたりの開発期間を約2〜3週間程度に収めることを目標とし、PDCAを高速に回せる体制としました。 また、上記の開発期間の短縮化のために、今回はアプリの改善は除外してWebサイト改善に特化させました。アプリに関しては体制が整ったら、このプロジェクトでも取り組み方を検討する予定です。 2:改善を止めない仕組み作り これまでも多くの改善案件が提案され、実際にリリースされているものも数多くあります。しかし改善案件を考えても、大規模案件によってその改善案件の優先度が低くなり、結局その案件がストップしてしまうことも多々ありました。 そこで、関係部署のマネージャー陣にプロジェクトの目的や意義を説明し、今回のこのプロジェクトでやろうとしている小規模な改善案件に一定時間を確保することを合意してもらいました。プロジェクトで上げた案件は、大規模案件と並べて全体での優先度付けはせず、確保できた時間内でプロジェクト独自に優先度付けをして進めていくことにしました。 とはいえ大規模案件も並行して進めなければいけないため、1日の10〜20%程の時間を今回のUI/UXプロジェクトに割り当てることで、大規模案件を進めつつ小規模な改善案件も止めない仕組み作りを行いました。 3:改善案を自分で考えられるメンバーの育成 案件の進め方以外にこのプロジェクトの目的として「教育」があげられます。 事業の大きな方向性を決める大規模案件では、複数の意思決定者の議論を経てある程度イメージが具体化された状態で開発に取り掛かることが多いです。もちろん大規模案件の中でもボトムアップで進む案件も多くあります。 そのため自分で機能やUI/UXを考える機会が減っていき、結果的に改善案を自分で考えられるメンバーが育たない懸念がありました。 このプロジェクトを通じて、自分達で考えて提案していき、自己成長のきっかけにすることを目的としました。 プロジェクト体制 プロジェクト立ち上げのタイミングということもありますが、小規模な改善案件をなるべく短期間でPDCAを回せるようにプロジェクトに参加する人数は最小限に絞りました。 プロジェクト責任者 デザイナー フロントエンドエンジニア バックエンドエンジニア データアナリスト プロジェクト進行管理者 上記の役割で1名ずつ部署内公募で募りました。 部署内公募のメリット 今回は、特に改善案件に取り組みたい気持ちが強い人を集めたかったため、部署内公募を利用しました。 最終的に自分で考える力をつけ発揮してもらうため、責任者や各リーダーから指名するよりも、自ら手を挙げてもらうことでモチベーションの高いメンバーを募ることができます。さらに公募への立候補の際に課題を提出してもらうので、さらにその効果が高くなりました。 改善案の考え方と作り方 「 ユーザビリティテスト 」を行い、その結果を元に改善案件を考えていきました。 ユーザビリティテストとは、Webサイトを実際に被験者のユーザーに使っていただき、その様子を観察するテスト手法です。経験や勘に基づく主観的なWebサイト評価ではなく「どこが良くて、どこが悪くて、どこをどう改善すべきか」を客観的に探ることができます。 ユーザビリティテストは社内でも知見が少なかったため、実際のユーザーではなく開発者以外の別部署の社員を被験者として実験的に実施しました。 UI/UXの改善は数値化が難しく、改善案を考えてもそれが本当にユーザーのためになっているのかが判断しにくいケースが多くあります。開発者だけで改善案を考えていってしまうとユーザー視点を忘れてしまい、改善案に偏りがでてしまいます。ユーザーが実際に使っている様子を観察し、どこで躓いているのか迷っているのかを明確にし、改善案を考えていく方針にしました。 プロジェクトによってリリースできた案件紹介 プロジェクトでは2020年上期の改善テーマを「ログイン/新規会員登録ページ」にしました。それにより実際にリリースされた案件の一部を開発者視点でご紹介します。 フォームのパスワード表示/非表示切り替え機能の追加 フォームにパスワードを入力した際、入力したパスワードの文字列を確認できるよう、目のアイコンをタップすることで表示/非表示の切り替えを可能にしました。 入力されたパスワードはセキュリティの観点から、基本的にマスク状態にするのが良いです。しかし、入力したパスワードが長い場合、どんなパスワードを入力したのか分からなくなります。 そこで目のアイコンを付け、表示/非表示の切り替えを可能とすることでUX向上に繋がります。 実装方法 非表示時 < input type = "password" > 表示時 < input type = "text" > アイコンをタップしたときに、JavaScriptを用いてInputのtype属性を変更するだけで実装できます。 注意点としてMicrosoft Edge、Internet Explorer 11ではブラウザ標準機能として実装されているため、独自実装する場合、この2つについて除外する必要があります。 郵便番号のハイフンを有無どちらでも入力可能に改修 新規会員登録フォームの必須項目として郵便番号の入力がありますが、ハイフンなしでの登録から「ハイフンあり・なしどちらでもOK」としました。 ユーザーによって、郵便番号というのはハイフンありで慣れている方もいれば、なしで慣れている方もいます。 実装方法 ハイフンが入った状態の値が送信された場合、受け取ったデータからハイフンを取り除く処理を追加しています。ちょっとした一手間を加えるだけですが、UXが向上する重要な処理です。 PCの必須項目を分かりやすくする改修 PCのUIでは、必須ラベルが元々グレーになっていましたが、赤色に変更しました。 この対応により、必須項目が分かりやすくなり、入力漏れを防ぐことができます。多くのサイトの必須ラベルは、赤色になっている場合が多いです。世の中のベーシックUIに合わせることは、普段使っているUIや慣れているUIと同じになるため、UX向上に繋がります。 ブラウザや端末に保存してある情報の自動補完ができるよう改修 HTMLのautocomplete属性を正しく設定することで、あらかじめブラウザや端末に保存してある情報を自動補完できます。 ZOZOTOWNでは新規会員登録画面の郵便番号とメールアドレスのinput要素にautocomplete属性を付与しました。 実装方法 郵便番号 < input autocomplete= "postal-code" > メールアドレス < input autocomplete= "email" > この属性を付与するだけで自動補完の実現が可能です。 postal-code、emailの他にもautocompleteで様々な指定が可能です。詳しくは以下のサイトにまとまっていますので、ぜひ参考にしてみてください。 developer.mozilla.org SPサイトドロワーメニューのログイン導線改善 ブラウザ版のスマートフォン向けUIにおいて、ドロワーメニュー内のログイン、新規会員登録への導線を見直しました。 Beforeでは、ログインしようとした場合、ログインページに到達するまでに3ステップ必要でした。 Before 1:ドロワーメニューを開く 2:ログインメニューを開く 3:各IDでログインページに遷移 また、ドロワーを開いてもその画面内には新規会員登録への導線が無く、そこからログインメニューを開かないと導線が表示されない状況でした。 Afterでは、「ログイン・新規会員登録」と表記を変更。開閉メニューを撤廃し、ログインページに遷移してからアクションする遷移に変更しました。 After 1:ドロワーメニューを開く 2:ログインページに遷移 これにより、ログインページに到達するまでのステップが減り、2ステップで到達することが可能になるとともに、新規会員登録への導線も分かりやすくできました。 利用頻度が高い項目の場合、無駄なステップを極力減らすことでUX向上に繋げられます。 まとめ ZOZOTOWNのUIは、様々なECサイトのお手本となる存在だと私は考えています。 サービス開発は派手な施策や新機能に着目されることが多く、既存機能や既存UIの細かい改善への取り組みは後回しになってしまうことも多いかと思います。こういった地道な取り組みはサービス運営にとても大切であり、必ず数年後大きな結果に結びつきます。このプロジェクトを通し、細かいUI改善をコツコツと積み重ねた結果、ZOZOTOWNのUIブランディングに繋がると考えています。 Webの世界では良いUIを真似することはよくあることです。 今後もZOZOTOWNのUIは様々なサイトから真似される存在であり続け、そのUIを生み出し続けることがファッションEC全体を盛り上げることだと考えています。 リアル店舗と比較して、ECサイトは会員登録やログインなど、本来洋服を購入する行動には不必要なことをしなければなりません。この「不必要なこと」を見つめ直し、極限まで改善していくことにより、「洋服に出会い購入する」体験をより良くしていきたいと思います。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方はもちろん、このようなUI/UX改善の取り組みに興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは、MA基盤チームの田島です。ZOZOTOWNでは、ユーザコミュニケーションの手段としてLINE、MAIL、アプリへのPUSH通知を利用しユーザへのお知らせを実現しています。 その中でも、現在ユーザへのコミュニケーション強化の一環としてアプリPUSH通知(以降、PUSH通知)の強化をしようと考えています。ZOZOTOWNのPUSH通知は今まで、とある外部SaaS(本記事で出てくるSaaSはすべてこの外部SaaSを表します)を利用していました。しかし、PUSH通知チャネルの強化をする上で、利用していたSaaSでは要件を満たせない部分がありました。そこでPUSH通知のためのツールとしてFirebase Cloud Messaging(FCM)に移行しました。 本記事ではPUSH配信基盤の紹介並びに、移行時に行ったことを紹介します。PUSH配信基盤の構成や、PUSH配信ツール移行時の参考にしていただければ幸いです。 目次 目次 FCM移行前のPUSH配信システム 1. 初回アプリ起動時にiOSでAPNsトークンをAndoridではFCMトークンを取得 2. APNs/FCMトークンをPUSH配信用SaaSに連携し、SaaSのPUSH配信用のトークンを取得 3. 取得したPUSH配信用トークンをZOZOTOWNのAPIに連携しユーザと紐付けた形で保存 4. 保存したトークンを定期的にIIASというMA用のDWHに連携 5. IIASで配信対象ユーザを抽出 6. アプリケーションからSaaSに配信を依頼 7. PUSH配信用SaaSがAPNs/FCMへ配信リクエストしユーザにPUSH通知が届く 移行のきっかけ 2つの配信基盤 バッチ配信基盤 リアルタイム配信基盤 リアルタイム配信基盤でのPUSH通知 移行前に利用していたPUSH配信用SaaSの特徴 移行先の選定 大量リクエストに対応できる iOS/Androidにおいて統一したインタフェースで配信が可能 最新機能への追従が早い iOS/Androidへの依存ライブラリが少ない 配信結果をBigQueryにエクスポートが可能 Firebase移行後の構成 1. 初回アプリ起動時にiOS/AndroidそれぞれがFirebaseからFCMトークンを取得 2. 取得したFCMトークンをZOZOTOWNのAPIに連携しユーザと紐付けた形で保存 3. 保存したFCMトークンを定期的にIIASに連携 4. IIASでセグメントを抽出し配信対象を作成 5. 抽出したセグメントを配信情報と一緒にBigQueryへ連携 6. アプリケーションからFCMへ配信リクエストしユーザにPUSH通知が届く 移行手順 移行の要件 既存のPUSH通知を止めることなくシームレスに移行する FCMで利用するFirebaseプロジェクトをiOS/Androidで統一する 移行手順 1. iOSからPUSH配信用SaaSでの配信、並びにFCM両方で配信 2. iOS/AndroidでFCMのみ配信できるようにしバージョンアップ済みユーザ全員へFCMで配信 3. FCMのみで配信 今後の展望 既存の課題 トークンの連携 2つのDWH 2つのワークフローエンジン 構想 まとめ 終わりに FCM移行前のPUSH配信システム はじめに、FCM移行前のPUSH配信システムがどのようになっていたかを紹介します。配信は以下のような流れで行われます。 初回アプリ起動時にiOSでAPNsトークンをAndoridではFCMトークンを取得 APNs/FCMトークンをPUSH配信用SaaSに連携し、SaaSのPUSH配信用のトークンを取得 取得したPUSH配信用トークンをZOZOTOWNのAPIに連携しユーザと紐付けた形で保存 保存したトークンを定期的にIIASというMA用のDWHに連携 IIASで配信対象ユーザを抽出 アプリケーションからPUSH配信用SaaSに配信を依頼 PUSH配信用SaaSがAPNs/FCMへ配信リクエストしユーザにPUSH通知が届く この流れについてそれぞれ詳しく説明します。 1. 初回アプリ起動時にiOSでAPNsトークンをAndoridではFCMトークンを取得 ユーザがiOS/Androidアプリをインストールし最初にアプリケーションを起動するとiOSはAPNsトークンを、AndroidはFCMトークンを取得します。iOSに関して詳細には、アプリを起動し通知許諾ダイアログからユーザが許諾をしたタイミングでAPNsへデバイスを登録しAPNsトークンを取得します。PUSH配信用のSaaSは配信にAPNs/FCMを利用しており、それぞれのトークン連携が必要となります。 2. APNs/FCMトークンをPUSH配信用SaaSに連携し、SaaSのPUSH配信用のトークンを取得 続いて取得したAPNs/FCMトークンをPUSH配信用SaaSへ連携します。APNs/FCMトークンを連携するとそのトークンに対応するPUSH配信用SaaSでの配信用のトークンが取得できます。 最終的にはこの配信用トークンを利用しPUSH配信用SaaSへPUSH配信のリクエストをすることで、ユーザへPUSH配信を送ることができます。 3. 取得したPUSH配信用トークンをZOZOTOWNのAPIに連携しユーザと紐付けた形で保存 続いて取得したPUSH配信用SaaSのPUSH配信用トークンを保存します。ZOZOTOWNのAPIを経由しDBに保存されます。このとき、どのユーザのトークンであるかがわかるようにユーザIDと紐付けた形でトークンを保存します。 4. 保存したトークンを定期的にIIASというMA用のDWHに連携 ZOZOTOWNの全ユーザおよびセグメント(一部の配信対象)向け配信をする際には、IIASを利用して配信用トークンを含めた配信対象ユーザを一括抽出します。そのため、保存した配信用トークンをIIASへ連携する必要があります。 5. IIASで配信対象ユーザを抽出 前述の通り、データ連携が完了し配信のタイミングになると配信対象ユーザをセグメントとして抽出します。セグメントごとに配信文言や、通知タップ時の遷移先URL、それらに紐付いたトークンのリストなどを抽出します。 6. アプリケーションからSaaSに配信を依頼 続いて先程作成したリストを元に、PUSH配信用SaaSに対し配信依頼をします。このとき、配信文言やPUSH通知タップ時の遷移先URLなどと共に、トークンのリストを渡すことでまとめて配信依頼ができます。 7. PUSH配信用SaaSがAPNs/FCMへ配信リクエストしユーザにPUSH通知が届く 最後に配信依頼を受けたPUSH配信用SaaSがAPNs/FCMに配信リクエストすることで、実際にユーザへPUSH通知が届きます。 移行のきっかけ 上記のような仕組みでZOZOTOWNのPUSH配信は送られていました。冒頭で触れたように今回PUSH配信チャネルの強化をする上で利用していたSaaSの利用を諦める必要がありました。そのきっかけについて紹介します。 2つの配信基盤 きっかけを説明する上でZOZOTOWNの配信基盤についての前提知識が必要になります。ZOZOTOWNには配信の仕組みとして、大きく分けて2つの基盤が存在します。1つ目がバッチ配信基盤で、もう1つがリアルタイム配信基盤です。 バッチ配信基盤 バッチ配信基盤は先程の「FCM移行前のPUSH配信システム」で紹介したようにIIASから定期的にセグメント抽出し、抽出されたユーザに対して配信を送るといった仕組みです。バッチ配信での対象チャネルとしてはMAIL・LINE・PUSH通知・サイト内お知らせの4つの配信チャネルがあります。 リアルタイム配信基盤 リアルタイム配信基盤はその名の通りリアルタイムに配信をするための基盤です。何がリアルタイムかというと、ユーザ行動や商品情報などの変更イベントが発生するとそれを起点としてリアルタイムにユーザへの配信します。 例えばある商品の価格が下がったとします。その際にその商品をお気に入り登録しているユーザに対し、リアルタイムに「あなたのお気に入り商品が値下がりしました」といった通知をします。またリアルタイムに配信するだけでなく、ユーザごとに時間最適化を行い、ユーザがよく参照する時間に配信するといったこともしています。このリアルタイム配信基盤の配信チャネルは現状MAIL・LINEの2つです。 リアルタイム配信基盤についてより詳しく知りたい場合は以下の記事をご参照ください。 techblog.zozo.com リアルタイム配信基盤でのPUSH通知 前述の通り、リアルタイム配信基盤ではPUSH通知での配信はされていません。今回PUSH通知チャネルの強化としてリアルタイム配信基盤でのPUSH通知を追加することになりました。しかしリアルタイム配信の特性上、既存のPUSH配信用SaaSでは実現できないことがありました。 移行前に利用していたPUSH配信用SaaSの特徴 移行前に利用していたPUSH配信用SaaSは同一内容の大量配信を得意としており、一括で同じメッセージをたくさんのユーザに送ることが簡単にできるようになっていました。その理由としては以下の特性が挙げられます。 100,000ユーザに対して同一メッセージを送りたい場合1回のAPIリクエストで配信が可能 よって、例えば1,000,000人に同じメッセージの配信をしたい場合はたったの10回APIリクエストをするだけで配信が可能となっており、アプリケーションの実装がかなり楽にできるようになっていました。 しかし、今回やりたいリアルタイム配信ではユーザごとにパーソナライズされたメッセージを送る必要があります。そのため例えば1,000,000人に対してPUSH通知を送る場合、最大1,000,000回APIリクエストする必要があります。現状のリアルタイム配信基盤で配信している秒間リクエスト数を考えると、これまで利用していたPUSH配信用SaaSではそのリクエストをさばき切れないことがわかりました。そこで、既存のPUSH配信用SaaSの利用を諦め別のツールを利用することを決心しました。 また、これまで利用していたPUSH配信用SaaSではただPUSH通知を配信するだけではなく、PUSH配信周辺のマーケティングのためのサービスも充実していました。しかし、ZOZOTOWNのマーケティングのビジネスロジックは内製しているため、有効活用できていませんでした。 移行先の選定 移行後はFirebase Cloud Messaging(FCM)を利用することにしました。移行先をFCMにした理由としては以下の特徴が挙げられます。 大量リクエストに対応できる iOS/Androidにおいて統一したインタフェースで配信が可能 最新機能への追従が早い iOS/Androidへの依存ライブラリが少ない 配信結果をBigQueryにエクスポートが可能 以上について1つずつ説明します。 大量リクエストに対応できる 今回の移行の最大の目的としてリアルタイム配信基盤からのPUSH配信の実現があります。そのため、リアルタイム配信基盤からの配信リクエストを十分にさばけないといけません。具体的な数値は明示できませんが、リアルタイム配信基盤からの現状のリクエスト数に対し、その数倍のリクエストが発生してもFCMでは問題ないことがわかりました。 iOS/Androidにおいて統一したインタフェースで配信が可能 PUSH配信を実装しようと思った際には、iOSは直接APNsを利用し、AndroidはFCMを利用するということが考えられるでしょう。しかしiOS/Androidそれぞれで配信の仕組みを変えた場合、複雑さが増すのと同時に実装すべきものが2種類に増えて実装コストが膨らみ、ユーザへの価値提供のタイミングが遅れると判断しました。そこで統一したインタフェースで配信可能なFCMを採用しました。 最新機能への追従が早い iOS/AndroidのOSにてPUSH通知関連の新機能が実装された場合、活用できそうな機能であればいち早くサービスに利用したいです。そのためPUSH配信に利用するツール側がその機能に早急に追従してくれるものを選ぶ必要があります。AndroidにおいてはFCMがもともと配信ツールとして推奨されています。また、iOSに関してもオプションで直接APNsのパラメータを渡すことができるため問題ないと判断しました。 iOS/Androidへの依存ライブラリが少ない もともとiOS/AndroidではFirebaseを利用していました。そのため、依存ライブラリやそのバージョン等の問題で導入できないという障壁がありませんでした。 配信結果をBigQueryにエクスポートが可能 弊社では全社のデータ分析基盤として、BigQueryを利用しています。そして、FCMでは配信実績をBigQeuryに直接エクスポートが可能です。そのため、最終的な配信実績を集計する場合わざわざ外部からBigQueryへデータ連携をし直すという手間が省けます。FCMからBigQueryへのエクスポート手順に関しては以下の記事をご参照ください。 qiita.com 以上のことからFCMが最も移行先として相応しいと判断しました。 Firebase移行後の構成 最終的にはリアルタイム配信基盤でPUSH通知を送るという目的があります。ただし既存のバッチ配信の仕組みが存在するため、まずはバッチ配信の処理をFCMへ移行しました。 移行手順の紹介の前にまずは、移行後の最終的な構成について紹介します。現状としてはあくまで第一段階として、配信ツールをFCMへの移行をしただけなので課題は様々残っています。それら課題に関しては後ほど今後の展望として紹介します。以下が移行後の配信の流れです。 初回アプリ起動時にiOS/AndroidそれぞれがFirebaseからFCMトークンを取得 取得したFCMトークンをZOZOTOWNのAPIに連携しユーザと紐付けた形で保存 保存したFCMトークンを定期的にIIASに連携 IIASでセグメントを抽出し配信対象を作成 抽出したセグメントを配信情報と一緒にBigQueryへ連携 アプリケーションからFCMへ配信リクエストしユーザにPUSH通知が届く 以上の流れについてそれぞれ詳しく説明します。 1. 初回アプリ起動時にiOS/AndroidそれぞれがFirebaseからFCMトークンを取得 ユーザがiOS/Androidアプリをインストールし最初にアプリケーションを起動するとFirebaseからFCMトークンを取得します。iOSに関して詳細には、アプリを起動し通知許諾ダイアログからユーザが許諾をしたタイミングでAPNsへデバイスを登録しAPNsトークンを取得します。そして取得したAPNsトークンをFCMに渡すことでFCMトークンを取得します。 2. 取得したFCMトークンをZOZOTOWNのAPIに連携しユーザと紐付けた形で保存 移行前はPUSH配信用SaaSの配信用トークンをZOZOTOWNのAPIに連携し保存していました。移行後は直接FCMを利用するためFCMトークンをユーザと紐付けた形でDBへ保存します。 3. 保存したFCMトークンを定期的にIIASに連携 次にPUSH配信用SaaSの配信用トークンをIIASへ連携していたように、FCMトークンをIIASへ連携します。 4. IIASでセグメントを抽出し配信対象を作成 移行前と同じようにIIASで配信対象ユーザを抽出します。このときFCMトークンを含んだリストを抽出します。 5. 抽出したセグメントを配信情報と一緒にBigQueryへ連携 次にIIASで抽出したトークンリストを配信文言等の情報と一緒にBigQueryへ連携します。BigQueryへわざわざ連携する理由としてはいくつかあります。ZOZOTOWNではDWHとしてBigQueryを利用しています。しかしMAでは以前から分析基盤としてオンプレミス環境でIIAS( 正確には前世代機であるNetezzaおよびPureDataのリプレイスを重ねてきた )を利用していました。この環境を今後BigQueryへ移行する計画があり、その際にアプリケーションのロジックを変えないために今からBigQueryを中心とした配信アプリケーションを作成しました。 また、移行先の選定理由で紹介したようにFCMでの配信実績はBigQueryに直接エクスポートが可能です。そのためBigQueryに配信のための情報が溜まっているとあとから解析しやすいという理由もあります。 今の段階でIIASでのセグメント抽出を廃止しなかった理由としては、変更すべき範囲が大きすぎてしまい移行期間がかなり伸びてしまうため、IIASの利用廃止は後回しにしました。 6. アプリケーションからFCMへ配信リクエストしユーザにPUSH通知が届く 最後にBigQueryのデータを利用しアプリケーションからFCMへ直接リクエストすることでユーザへPUSH通知が届きます。このときPUSH配信用SaaS利用時は同一メッセージについて同時にリクエストできるユーザ数が100,000件でした。それに対し、FCMでは1,000件ずつしか配信できないため、高速に配信するためには工夫が必要となりました。それについては機会があれば別の記事で紹介いたします。 移行手順 続いてどのように配信ツールを移行したかについて紹介します。 移行の要件 移行に際して、以下の要件を満たす必要がありました。 既存のPUSH通知を止めることなくシームレスに移行する FCMで利用するFirebaseプロジェクトをiOS/Androidで統一する それぞれについて紹介します。 既存のPUSH通知を止めることなくシームレスに移行する ツールの移行に伴って、移行を理由にPUSH通知での配信を止めることはユーザへの価値提供の機会を損なうことになってしまいます。そのため、ツールの移行を今まで通りの配信が担保された状態で行う必要がありました。 FCMで利用するFirebaseプロジェクトをiOS/Androidで統一する 移行前の仕組みでも紹介した通り、外部のPUSH配信用SaaSではAndroidの配信でFCMを間接的に利用していました。Androidにおいては基本的にはiOSと同じ共通のFirebaseプロジェクトを利用していましたが、PUSH配信用SaaSで利用するFCMのみ別のプロジェクトを利用していました。これは以前よりAndroidの実装を複雑にする要因となっていました。今回FCMへ移行するに当たり、Firebaseプロジェクトを統一することで配信処理がシンプルになるなど、バックエンドの構成的にも統一することによるメリットがありました。 移行手順 配信の移行は以下の3つの手順に分けました。 iOSからPUSH配信用SaaSでの配信、並びにFCM両方で配信 iOS/AndroidでFCMのみ配信できるようにしバージョンアップ済みユーザ全員へFCMで配信 FCMのみで配信 1. iOSからPUSH配信用SaaSでの配信、並びにFCM両方で配信 はじめに今まで利用していたPUSH配信用SaaSとFCMで同時並行に配信できる仕組みを実現します。これにより、iOS/Androidの実装と配信側の実装をぞれぞれのタイミングで進めることが可能となります。また、FCMでの配信になにか問題が発生した場合、既存のPUSH配信用SaaSでの配信に戻せばいいため安全に移行できます。アプリと配信側の実装や切り替えを一斉に行うと、すべてのキャンペーン配信を同時に移行しなければなりません。しかし今回実施するようにどちらからも配信できるような環境を用意することで、一部の配信のみをFCM側で配信し、徐々にFCMでの配信に移行していくことができます。 この段階では、iOSのみ既存のPUSH配信用SaaSとFCMどちらでも配信可能な状態にしました。Androidではこのフェーズを挟みませんでした。それはFCMのGCPプロジェクトの移行が関係しています。前述の通り、Androidでは既存のPUSH配信用SaaSで利用するFCMのためだけに専用のGCPプロジェクトを利用していました。そして今回の移行段階で、FCMにおいてもiOSと共通のGCPプロジェクトに移行させます。そのため既存のPUSH配信用SaaSとFCMでの配信をどちらも行おうとすると、2つのGCPプロジェクトのFCMを利用するため実装がかなり複雑になってしまいます。 2. iOS/AndroidでFCMのみ配信できるようにしバージョンアップ済みユーザ全員へFCMで配信 最初のフェーズで、すべてのキャンペーン配信がFCM経由で成功したことを確認できたら次のフェーズに移ります。 続いてiOS/AndroidでFCMからの配信しか受け取らないバージョンをリリースします。このタイミングでは、バージョンアップ済みのユーザにはFCMで、バージョンアップ前のユーザには既存のPUSH配信用SaaSで配信します。 3. FCMのみで配信 最後にほとんどのユーザがFCM対応バージョンに移行しきったタイミングで既存のPUSH配信用SaaSからの配信を止めます。これによってFCMでの配信に100%切り替わります。このとき、事前にバージョンアップしないとPUSH通知が停止する旨をアプリ上にてお知らせすることでユーザにバージョンアップを促すことができました。 以上のように既存のPUSH配信用SaaSからFCMへシームレスに移行できました。全体として2,3か月をかけての移行となりました。 今後の展望 移行後の構成を再掲します。 以上の構成では、FCMへの移行は完了していますが、課題がいくつか残っています。ここでは、それらについて説明し、最終的な構想を紹介します。 既存の課題 トークンの連携 2つのDWH 2つのワークフローエンジン トークンの連携 前述の通り、PUSH配信用のFCMトークンは最初にZOZOTOWNのDBに保存され定期的に配信用のDWHに連携されています。これは、MAというマーケティング向けのシステムがZOZOTOWNと切り離されているためです。これにより、データ連携という工程が発生し、リアルタイムな配信を難しくしています。しかしFCMトークンはMAでのみ利用するもののため、ZOZOTOWNのDBに保存するメリットはほとんどありません。 2つのDWH ZOZOTOWNのDWHとしてはBigQuery、配信用のDWHとしてはIIASを利用していると紹介しました。そして最終的にはBigQueryにDWHを統一させたいため、IIASで行っている処理をBigQueryに移管します。またそのために必要なデータもすべてBigQueryに移行する必要があります。 2つのワークフローエンジン 最終的にPUSH配信はアプリケーションからFCMにリクエストすることで行われます。また、元々の配信もアプリケーションからPUSH配信用SaaSにリクエストすることで行われていました。そして、それらはワークフローツールを介して起動しています。元々、PUSH配信用SaaSで配信していたアプリケーションはJP1というワークフローツールから起動し、オンプレミス環境で動作しています。また、JP1ではスケジューリングの機能を利用し定期的にバッチ配信をしています。新しく作成したFCMで配信するアプリケーションはDigdagというワークフローツールから起動されAWS上で動作しています。そして現状では、Digdagではスケジューリングの機能を使わずJP1からキックする形で利用しています。以下がアプリケーションの流れです。 移行前 移行後 Digdagから直接アプリケーションを起動しない理由としては、セグメントの抽出処理はまだJP1側のアプリケーションで行っているからです。今回そこもスコープが大きくなるため移行を後回しにしました。そのため最終的には以下のようなDigdagを中心としたアプリケーションにすることを目指しています。 構想 以上の課題をなくし、最終的には以下のようなシステムを構想しています。 まず、前述の課題を解決しDWHをBigQuery、ワークフローツールをDigdagにします。そして、トークンの取得をZOZOTOWNのDBから切り離し、MAとして管理しているDBに直接保存して利用します。それに加えてリアルタイム配信ができるように、専用のAPIを用意しリアルタイム配信が可能となるような状態を目指します。 まとめ 今回、外部のPUSH配信用SaaSからFCMへ配信ツールを移行した流れを紹介しながら、ZOZOTOWNでのPUSH配信基盤について紹介しました。移行において、PUSH通知を止めないよう安全に移行できました。この移行事例や配信基盤が事例として少しでもお役に立てれば幸いです。 終わりに 本記事で紹介したようにZOZOTOWNでは、多くのユーザへ高速かつ正確にコンテンツを配信するための基盤を開発運用しています。まだまだ発展の途中ですので、ご興味のある方は以下のリンクからご応募ください。 https://hrmos.co/pages/zozo/jobs/0000016 hrmos.co
アバター
こんにちは。ZOZOテクノロジーズZOZOTOWN部 検索チーム 兼 ECプラットフォーム部 検索基盤チームの有村( @paki0o )です。 ZOZOTOWNではこれまで度々紹介してきた通り、検索エンジンとしてElasticsearchを利用しています。リクエスト元のサーバーサイドのアプリケーションはJava(Spring Boot)で書かれており、クライアントにはHigh Level Rest Client(以下、HLRC)を使用しています。 www.elastic.co techblog.zozo.com HLRCを実際にプロダクション環境で運用していく中で、サービスのSLAを満たすために安定稼働させるための設定や、効率的に通信するための設定などを細かく指定しました。現在の設定にたどり着くまで、ドキュメント上で表現されていなかったり機能が用意されていなかったり等様々な苦労があったので、まだ道半ばですが現時点で辿り着いた設定内容についてご紹介いたします。同じくHLRCを利用されている方の参考になれば幸いです。 タイムアウト値の設定 コネクション数の設定 通信のgzip圧縮対応 インデキシングバッチでのトラフィック検証 検証結果 適用前 適用後 検索リクエストでのCPU負荷検証 検証結果 HLRCのシングルトンインスタンスのエラーハンドリング・再作成 実装 まとめ 最後に タイムアウト値の設定 公式ドキュメント にも記載のある通り、HLRCでは3種のタイムアウト値が設定可能です。なお、この値はHLRC固有のものではなく、内部で使用しているApache HttpClient共通の設定項目です。(ref : RequestConfig ) 設定項目 説明 デフォルト値 connection request timeout コネクション取得時のタイムアウト -1(undefined) connect timeout コネクション確立時のタイムアウト 1000ms socket timeout ソケット通信の監視用タイムアウト値 30000ms HLRCにてこの設定を上書きするためには、setRequestConfigCallbackにてRequestConfigを上書きします。 RestHighLevelClient client = new RestHighLevelClient( RestClient.builder( new HttpHost(host, port, "https" )) .setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder .setSocketTimeout(socketTimeout) .setConnectTimeout(connectTimeout) .setConnectionRequestTimeout(connectionRequestTimeout))); 公式のドキュメントには一部タイムアウト値の設定方法に関する記述はありましたが、 connection request timeout に関する記述は無く、またデフォルト値の設定もありませんでした。そのため、実際の運用では connection request timeout にも独自の適切な値を入れ運用しています。 コネクション数の設定 コネクション数も同様に、クライアント生成時にオプションとして設定可能です。こちらもHLRC固有のものではなく、内部で使用しているApache HttpClient共通の設定項目です。(ref : ドキュメント ) 設定項目 説明 デフォルト値 max conn per route IP、 ポート単位の最大接続数 10 max conn total 最大接続数 30 RestHighLevelClient client = new RestHighLevelClient( RestClient.builder( new HttpHost(host, port, "https" )) .setHttpClientConfigCallback(httpAsyncClientBuilder -> httpAsyncClientBuilder .setMaxConnPerRoute(maxConnPerRoute) .setMaxConnTotal(maxConnTotal))); リリース後しばらくはデフォルトの設定で運用していました。しかし実運用中、クラウド障害に起因したElasticsearchのレスポンス速度低下が発生した際、受け付けたリクエストがコネクション取得待ちで詰まる現象が発生しました。そのため現在はコネクション数に任意の値を追加し、合わせてクエリタイムアウト設定値も見直して運用しており、設定後同様の挙動は見られていません。 なお、ここまでで紹介したタイムアウト・コネクション数のHLRCにおけるデフォルト値は、 org.elasticsearch.client.RestClientBuilder に記載されています。 通信のgzip圧縮対応 弊社が検索を 全面的にElaticsearchへ移行 した2020年4月時点(Elasticsearch v7.6.2)では、HLRCがgzip圧縮に対応していませんでした。もう少し具体的な説明をすると、リクエストの圧縮には対応しておらず、レスポンスの圧縮は RequestOptions を用いヘッダへ Accept-Encoding: gzip を付与する必要がありました。しかし、Low Level Rest Clientが圧縮されたレスポンスの解凍に対応していなかったため、エラーが発生しており利用を断念していました。 そこからしばらくIssueをウォッチしていたところ、8月リリースのv7.9.0でレスポンスの解凍が、12月リリースのv7.10.1でリクエストの圧縮がサポートされていました。またv7.10.1では、クライアント生成時のオプションに指定することで、必要最低限の設定でリクエスト・レスポンスの圧縮が可能となりました。 github.com github.com 弊社ではインデキシングバッチと検索APIがそれぞれ独立したシステムで動いており、その両方がHLRCを利用していますが、それぞれリクエストの特徴が異なります。 インデキシングバッチ 検索API リクエスト量 < 5req/sec > 100req/sec リクエストサイズ > 10MB/req < 5KB/req そのため、今回はそれぞれの特徴に合った検証となるよう、インデキシングバッチではトラフィック検証を、検索では受け側であるElasticsearchのCPU負荷検証を行いました。 なお、本検証は一般的なgzip圧縮による効果の検証であり、Elasticsearchに限られたものではない点にご注意ください。 インデキシングバッチでのトラフィック検証 インデキシングバッチ側では、上述の通りトラフィックがどの程度減少するのかを確認しました。 gzip圧縮適用のためHLRC生成時に指定する設定は以下の通りです。 RestHighLevelClient client = return new RestHighLevelClient( RestClient.builder( new HttpHost(host, port, Constants.HTTP_SCHEME)) .setCompressionEnabled( true ) 上記の設定により以下2点が有効化されます。 Request Bodyの圧縮 Request Headerへの Accept-Encoding: gzip の付与 検証結果 以下、圧縮前後の比較検証です。なお、インデキシングのリクエストはbulkで行っており、1リクエスト辺りのサイズは圧縮前で約1MBです。 平均 最大 改善率 1.5MiB/sec 3MiB/sec - 100KiB/sec 210KiB/sec 93% 適用前 適用後 インデキシングバッチはApp Engine上で稼働しており、トラフィックに基づいた課金が発生しています。この改善によって送信量が格段に下がり、バッチの運用にかかる費用を1カ月当たり2~3万円ほど下げられました。 検索リクエストでのCPU負荷検証 一般的に、圧縮されたリクエストで通信するケースでは、圧縮側・解凍側それぞれ追加のCPUコストが発生します。これはElasticsearchでも当てはまることで、無圧縮状態の通信より必要となるリソースの増加が考えられます。 そのため、実際の検索リクエストをサンプリングしたものを用いて、CPU負荷がどの程度上昇するか検証しました。 検証はElastic Cloud上に構築した専用クラスタで行い、Coordinating Nodeを1台、Data/Master Eligible Nodeを3台用意しました。 検証結果 以下が実際にリクエスト数を増やした際のCoordinating NodeのCPU負荷遷移です。 上図の通り、gzip圧縮ありのケースの方が、無しのケースに比べてCPU負荷が5%~10%増しでした。この上昇幅を致命的とみるかどうかはケースバイケースですが、あくまでも1ノードに対して負荷をかけた場合の値であり、本番同等のスケールでは致命的なレベルでないと判断しました。 HLRCのシングルトンインスタンスのエラーハンドリング・再作成 Spring BootでHLRCを利用する際、DIコンテナに登録しシングルトンなオブジェクトとして使うことは一般的な利用方法かと思いますが、そのエラーハンドリング周りに一癖あり苦労させられました。 具体的にはHLRC固有の問題ではなく、さらにそのバックエンドで用いられているLow Level Rest Clientが抱えている問題であり、Issueとしても挙げられています。内部で利用しているHttpClientのステータスが使用不可( I/O reactor status:STOPPED )な状態になった際、そこからのリカバリ策が現状用意されていません。 github.com ここで解決策として以下の2つの手段が挙げられていましたが、直感的にも確実にリカバリが可能なことから弊チームでは後者を採用することとしました。 HttpClientに対して 独自のコールバック を定義し、I/O reactorを再作成する インスタンス自体を一旦破棄し、再作成する 実装 手元で事象を再現したところ、利用不可な状態になるケースではその直前に ConnectionClosedException の発生が確認できたため、この例外を捕捉した場合だけ再作成することとしました。 @Repository public class ElasticsearchAdapter { @Autowired private EsConfig esConfig; private RestHighLevelClient esClient; @Autowired public void setEsClient(RestHighLevelClient esClient) { this .esClient = esClient; } public SearchResponse search(SearchRequest searchRequest) { SearchResponse searchResponse = null ; try { searchResponse = esClient.search(searchRequest, RequestOptions.DEFAULT); } catch (Exception exception) { reCreateClientOnError(exception, esClient); } return searchResponse; } private void reCreateClientOnError(Exception e, RestHighLevelClient client) { if (e instanceof ConnectionClosedException) { try { client.close(); this .setEsClient(esConfig.esClientReCreate()); } catch (Exception ex) { throw new Exception(); } } } } @Component @Configuration public class EsConfig { @Value( "${spring.elasticsearch.es-username}" ) private String esUsername; @Value( "${spring.elasticsearch.es-password}" ) private String esPassword; @Value( "${spring.elasticsearch.es-host}" ) private String esHost; @Value( "${spring.elasticsearch.es-port}" ) private Integer esPort; private final Integer socketTimeout = xxxx; private final Integer connectTimeout = xxxx; private final Integer connectionRequestTimeout = xxxx; private final Integer maxConnPerRoute = xxxx; private final Integer maxConnTotal = xxxx; @Bean(name = "esClient" , destroyMethod = "close" ) RestHighLevelClient esClient() { return getEsClient(); } public RestHighLevelClient esClientReCreate() { return getEsClient(); } private RestHighLevelClient getEsClient() { final CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); credentialsProvider.setCredentials( AuthScope.ANY, new UsernamePasswordCredentials(esUsername, esPassword)); RestHighLevelClient client = new RestHighLevelClient( RestClient.builder( new HttpHost(esHost, esPort, "https" )) .setHttpClientConfigCallback(httpAsyncClientBuilder -> httpAsyncClientBuilder .setDefaultCredentialsProvider(credentialsProvider) .setMaxConnPerRoute(maxConnPerRoute) .setMaxConnTotal(maxConnTotal)) .setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder .setSocketTimeout(socketTimeout) .setConnectTimeout(connectTimeout) .setConnectionRequestTimeout(connectionRequestTimeout)) .setCompressionEnabled( true ) ); return client; } } ElasticsearchAdapter リポジトリに登録された esClient オブジェクトを再作成することで、利用不可なクライアントを破棄・上書きしています。 この設定を適用して以降、利用不可なクライアントが生き残りエラーとなるケースに遭遇することは無くなりました。ただ、実装の素直さで言うと前者の実装方法の方が綺麗であることは間違いないため、機会があればそちらも検証予定です。 まとめ 本記事では、High Level Rest Clientのプロダクション環境での運用で得たノウハウについてまとめました。設定項目が多いだけに安定稼働までは苦労がありましたが、各種リクエストが全てラップされており、多くの恩恵を受けているので今後も利用を続けていきたいです。 余談ですが、クライアントの名称がドキュメント上では High Level Rest Client 、パッケージ上では RestHighLevelClient と語順に揺らぎがあり記事にする上で辛かったです。 最後に ZOZOテクノロジーズでは、このようなちょっとした改善・チューニングが好きなエンジニアはもちろん、これからの検索を更に改善していきたいエンジニアを募集しています。全国フルリモートでの採用もあるので、ご興味のある方は以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは。SRE部の横田・秋田です。普段はZOZOTOWNのリプレイスや運用に携わっています。 私たちは2020年11月13日から14日にかけてフル・オンラインで開催された Hardening 2020 H3DX に参加しました。本記事では、過去にオフライン開催のHardening Projectに参加経験のある秋田と、今回が初のHardening Project参加となった横田の体験を振り返り、「サービスを守る訓練」の重要性を再確認してみます。 Hardening 2020 H3DXについて WASForum Hardening Project により開催されたイベントです。 Hardening 2020 H3DXは技術競技会であるHardening Dayが1日、全参加チームの施策発表などを聴講形式で進行するSoftening Dayが1日という、合計2日の2部構成で開催されました。 それぞれ、以下のような特徴があります。 Hardening Day 各チームに同じ条件で守るべきEコマースサイトなどのシステム環境(20台程度のサーバー)が提供される 競技中に次々と発生するサイバー攻撃やインシデントに対してチームで速やかに対応する マーケットプレイスからセキュリティ機器の購入や人員の増援なども各チームの判断で行える 技術面だけでは無くビジネスの売上、顧客対応など様々な観点から得点を競い合う 競技時間は8時間、振り返りや順位発表などは2日目のSoftening Dayで実施される Softening Day 全チームがHardening開催当日までの取り組みや、実際に行った当日の施策などを発表 運営側の攻撃チームなどからの解説、振り返り 優勝チームや協賛社が選ぶMVPチームなどの結果発表 過去の開催は沖縄などの地域で競技が行われていましたが、昨今の状況もあり今回はフル・オンラインでの開催となりました。 体験レポート 開催1か月前から当日までの動きを紹介します。 開催前日までの動き 開催の約1か月前に当日のチームメンバーが発表されました。 チーム名やチーム内での役職(CEOなどいくつかの役職が必須指定される)を期日までに運営へ報告する必要がありますが、基本的には各チームの裁量で必要に応じてMTGや事前準備などを実施します。 私たち2名は参加申し込み時に希望した通りの同じチームでの参加となり、合計10名のチーム編成となりました。ただし、必ず希望が通るわけではないようです。 多くのメンバーがほぼ面識の無い状態から始まりましたが、私たちはチーム発表当日に他のチームメンバーがコミュニケーションの場を提供してくれました。 開催当日までは以下のツールでコミュニケーションを行い徐々に打ち解けていくことができました。 Discord によるテキストチャットコミュニケーション Zoom やDiscordによるボイスチャンネルを活用したオンラインコミュニケーション 過去のHardening Project経験者のリードや過去のSoftening Dayの YouTube映像 を参考に以下のような事前準備が行われました。 スキルマップシートを作成してチームメンバーの経験などを相互に把握する 当日の役割分担を決める 役職以外にもLinux担当/Windows担当/カスタマーサポート対応担当 当日の連携手段を決める マーケットプレイスで何を購入するのかを決める 稟議書など必要になる可能性がある文面の雛形を作成する 当日利用できそうなスクリプトなどを各自用意する Googleドライブ を活用しチーム内の資料やツールを共有管理する タスク管理のツールを検討する際には Trello や Miro などが案として上がりましたが、ほとんどのメンバーがTrelloやMiroといったツールを利用したことがありませんでした。そこで、チームメンバー全員が利用経験のあるGoogleスプレッドシートを活用することにしました。 Hardening Day前日 前日に発表される資料があるので、その資料の読み込みと当日のタスク対応順序や連携方法などの詳細についての最終確認をチームで行いました。各担当ごとの事前に決めたタスクの内容をいくつか紹介します。 Linux担当 各Linuxサーバーにてアカウントの初期パスワード変更 各CMSの初期パスワード変更 Webアプリケーションのバックアップ crontabの確認 Windows担当 ローカルアカウントの初期パスワード変更 タスクスケジューラの確認 サービスの確認 Windowsファイアウォールの確認 カスタマーサポート対応担当 メールの送受信確認 マーケットプレイス用稟議書の準備 在庫の確認 Webページの正常性の確認 Hardening Day当日 いよいよ当日となりました。例年の開催では現地会場に参加者全員が集まり競技をしますが、Hardening 2020 H3DXはフル・オンラインでの開催のためDiscordで集合が確認できたチームからZoom会場へと向います。 オープニングを経て、実際に競技が始まります。今回は競技環境への接続は Apache Guacamole というツールを介して行いました。 次々に仕掛けられる攻撃 競技開始後から衛るべきサイトや様々な環境に対して次々に攻撃が仕掛けられてきます。 具体的にどのような攻撃が発生していたかについては、今後の参加者の楽しみを奪ってしまうことになるため割愛しますが、とにかく次々と問題が発生します。 あらかじめ決めておいた作業を実施することや、発生している問題に対して対応を進めることも重要なのですが、状況を共有しあうことがHardeningにとっては非常に大切です。 やらなくてはいけないことは技術的なことだけではない システムに対して手を加えていくことは当然必要なのですが、Hardening Dayはその他にもやらなければいけないことが多々存在しました。全てを記載できないのですが、例えば以下のような内容です。 発生してしまったインシデントに対する各方面への報告 メールを利用した顧客対応 サイトの売り上げ向上につなげるミッション 事前にある程度役割を決めていても、その担当者だけでは手が回らなくなってしまうこともありました。その際にはチームメンバーとコミュニケーションを取り、優先順位をつけて対応します。時には早急な復旧を不要と判断してシステムを放置する、という決断も行いました。 リモートならではのチーム内の工夫 オフライン開催では近くにチームメンバーがいますが、今回はリモート開催です。情報共有を円滑にするために今回チームで以下のような工夫をしました。 各役割ごとにDiscordのボイスチャンネルを作成しメンバーは自分の役割のボイスチャンネルに常駐する 10人全員が1つのボイスチャンネルに入っていると、なかなか全員で会話することが難しくなるため、今回は事前に決めておいた役割ごとに数人程度のボイスチャンネルを用意しました。 各役割ごとのテキストチャットチャンネルも作成して作業記録はそこに残す テキストチャットを追えば今どのような流れで対応がされているのかを後から参加したメンバーも確認できます。 これらの工夫をしていたため、当日はコミュニケーションについては特に不自由さを感じませんでした。 8時間という競技時間中、迫りくる攻撃やその他の対応により全員かなり疲れていましたが、離脱者も出ずになんとかHardening Dayを終えることができました。 Softening Day 前述の通り、前日の振り返りを行い、各チームがその内容を発表しました。 そして、表彰セッションです。 チームの結果は 総合優勝には手が届きませんでしたが技術点と対応点が1位でした。 この結果は、チームの全員がそれぞれの役割を果たしてくれておかげだと思います。しかし、売り上げが伴わなかったことはとても残念なことだったので、次回以降の教訓として活かしていければと思います。 Hardening 2020 H3DXに参加して 今回、横田はHardening Projectに初参加、秋田はオンライン形式には初参加でした。 普段はZOZOTOWNのSREエンジニアとして業務を行っていますが、Hardeningを通じて経営的な判断をすることの難しさや、実際の顧客対応の大変さなどが擬似的とはいえ体験できたことはとても良かったです。様々な問題に対して行ったアクション全てが正解だったとは言えませんが、競技だからこそ恐れずに失敗することができたとも言えます。 また競技中にチームで行っていたコミュニケーション手段の工夫などは実際に業務の中でトラブルが発生した際にも活かせるシーンが非常に多いと感じました。 コミュニケーションツールなどのインシデント対応をする際の環境やフローを整備する重要性、それらを全員が合意して認識していることの重要性を改めて認識しました。また、実際のインシデントが発生する前に訓練によって擬似的に体験しながら自分が行うべき行動を考えたり、チームで役割分担の再確認をする重要性も感じました。今後も積極的にHardening Projectに参加していきたいと思います。皆さんも是非Hardening Projectに参加し、起こりうるインシデントに備えましょう! ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは。SRE部の塩崎です。七味唐辛子の粉末を7種類に分類するという趣味を発展させて、おっとっとを新口動物と旧口動物に分類するという趣味を最近発明しました。 BigQueryは非常にパワフルなData WareHouse(DWH) SaaSであり、大容量のデータを一瞬で分析できます。しかし、課金額がスキャンしたデータ量に比例するという特徴があるため、意図せずに大量のデータをスキャンしてしまい大金を溶かしてしまうことを懸念する人もいます。 qiita.com そのため、課金額が大きすぎるクエリを発見した際にSlackへ通知する仕組みを作りました。GCP Organization内の全プロジェクトで実行されたBigQueryの監査ログをリアルタイムにチェックすることによってこの仕組みは実現されています。本記事では作成したシステムを紹介します。 なお、本記事は以下のQiita記事に着想を得たものであり、 @irotoris さんにはこの場を借りてお礼申し上げます。 qiita.com 最初は元記事で公開されているソースコードをそのまま使用することを考えていたのですが、ライセンス表記がなかったため独自で実装し直し、我々のユースケースにおいて不足する機能を付け足しました。 BigQueryのコストを抑える方法 最初にBigQueryのコスト削減に有効な機能の紹介と、それらだけでは不十分であり、監査ログのリアルタイムスキャン機能を作成した理由について説明します。 Custom Quotas まず、BigQueryのコストを抑える方法として、プロジェクトレベル・ユーザーレベルにQuotaを割り当てる機能があります。 cloud.google.com この機能を使えば、特定のユーザーが高額なクエリを実行しすぎた際に、それ以上のクエリの実行を停止できます。 しかし、ユーザー毎にQuotaの上限を変えたい場合は、そのユーザーが属するプロジェクトを変える必要があります。プロジェクトを適切に分けてある場合はこの機能の導入がしやすいですが、そうでない場合は事故を防ぐために相当大きめのQuotaとして設定する必要が出てきてしまいます。特に落ちてはいけない大事なバッチが実行されるプロジェクトとアドホックなクエリが実行されるプロジェクトの分離は必須です。そうしない場合はアドホックなクエリによってQuotaが全部使い果たされてしまい、大事なバッチがusageQuotaExceededエラーになってしまいます。 我々の環境ではこれらの分離がまだまだ不十分であったため、この機能の導入は一旦見送りました。分離が十分にできた時点で導入を再度検討することを考えております。 Reservations 次に紹介するのがReservations機能です。 cloud.google.com この機能を使うと、スキャンしたデータ量によらず毎月固定の金額が課金されます。どれだけの計算量が必要なのかというのをSlotという単位であらかじめコミットします。このSlotはCPUを仮想化した概念で1Slotが1CPUに相当する量です。 cloud.google.com このReservations機能を使うことで、「お値段定額クエリ実行し放題プラン」になると勘違いしやすいですが、実際は異なります。 実際は「お値段定額『Slotの枠内で』クエリ実行し放題プラン」です。購入したSlot数を超えたパフォーマンスが出ることはないため、Slotを大量に消費するクエリが同時に実行された場合は、クエリの実行時間が伸びるという結果になります。Slotはプロジェクト毎でしか割り当てできないので、大事な集計バッチとアドホッククエリのプロジェクトを変えないと、集計バッチのSLAを担保できません。 我々の環境ではプロジェクトごとにReservations機能の有効・無効を切り替え、コストを最適化することを試みています。定常的にクエリが実行されているプロジェクトではこの機能を有効化しコストの予測可能性を高めます。 一方でクエリの実行頻度が低いプロジェクトやクエリの実行頻度が一定でないプロジェクトでは従来の料金プランと最低60秒の枠でSlotを購入できるFlex Slotsを利用しようとしています。 いずれのプロジェクトに対しても、スキャン量が多すぎるクエリや、Slot使用量が多すぎるクエリを発見しアラートを上げる仕組みが必要です。 そのため、BigQueryの課金ログをリアルタイムにチェックし、スキャン量やSlot使用量などが多すぎるクエリを通知する仕組みを作成しました。 インフラ構成 今回構築したシステムのインフラ構成図を以下に示します。 GCPのマネージドサービスを活用した、イベントドリブンかつサーバレスな構成です。 BigQueryの監査ログはデフォルトではONになっているため、Cloud Loggingに送られています。Organization内の全プロジェクトから送られてくる監査ログをAggregated sinks機能で集約し、Cloud Pub/Subの1つのTopicに集めます。 その後、Cloud Pub/SubのPush SubscriptionでCloud Runを起動します。HTTPリクエストのBodyの中に監査ログが含まれているため、Cloud Run上に実装したアプリケーションでクエリがリソースを使いすぎていないかどうかをチェックし、Slackに通知します。 ここからは各サービスの連携方法をTerraformのtfファイルを交えながら説明していきます。 Cloud Logging → Cloud Pub/Sub 一番最初にCloud Pub/SubのTopicを作成します。これについては特に詳しい説明は不要かと思います。 resource " google_pubsub_topic " " bq-police " { name = " bq-police " } 次に先程作成したTopicに対して、Cloud LoggingからpublishするためのLog Sinkを作成します。Organization内の全てのログを出力するために、Aggregated sinks機能を使っています。このSinkを作成するためにはOrganizationの logging.configWriter を付与したアカウントで terraform apply をする必要があります。 cloud.google.com org_id パラメーターは、 gcloud organizations list コマンドを実行すると確認できます。filterで指定しているクエリは以下のページを参考にして作成しました。古いタイプの課金ログ(AuditData)と新しいタイプの課金ログ(BigQueryAuditMetadata)の2種類があり、新しい側の利用が推奨されている点に注意が必要です。 cloud.google.com resource " google_logging_organization_sink " " bq-police-org " { name = " bq-police-org " destination = " pubsub.googleapis.com/${google_pubsub_topic.bq-police.id} " org_id = " 123456789012 " # 各自の環境で変える include_children = true filter = <<- EOT protoPayload.metadata." @type " = " type.googleapis.com/google.cloud.audit.BigQueryAuditMetadata " protoPayload.metadata.jobChange.job.jobConfig.type = " QUERY " EOT } そして、Cloud Loggingのサービスアカウント(writer_identity)にTopicへのpublish権限を付与します。Log Sinkはそれぞれ固有のサービスアカウントで実行されており、そのサービスアカウントに対する書き込み権限の付与が必要です。 cloud.google.com resource " google_pubsub_topic_iam_member " " bq-police-org " { project = google_pubsub_topic.bq - police.project topic = google_pubsub_topic.bq - police.name role = " roles/pubsub.publisher " member = google_logging_organization_sink.bq - police - org.writer_identity } Cloud Pub/Sub → Cloud Run 次にCloud Pub/SubとCloud Runの連携について説明します。 まずは、Cloud Runのサービスを作成します。アラートを発報するための閾値( THRESHOLD_* )やアラート対象から除外するユーザー( EXEMPTED_USERS )などを環境変数で設定できるようにしています。また、Slackに通知するためのWeb hookのURLは後述するBerglasで設定できるようにしています。 resource " google_service_account " " bq-police-cloud-run " { account_id = " bq-police-cloud-run " display_name = " BQ Police(Cloud Run) " } resource " google_cloud_run_service " " bq-police " { name = " bq-police " location = " us-central1 " template { spec { containers { image = " gcr.io/プロジェクトID/bq-police:latest " resources { limits = { cpu = " 1000m " memory = " 256Mi " } } env { name = " TZ " value = " Asia/Tokyo " } env { name = " THRESHOLD_TOTAL_BILLED_BYTES " value = " 0 " } env { name = " THRESHOLD_TOTAL_SLOT_MS " value = " 0 " } env { name = " EXEMPTED_USERS " value = "" } env { name = " SLACK_WEBHOOK_URL " value = " sm://プロジェクトID/slack_webhook_url " } } container_concurrency = 10 service_account_name = google_service_account.bq - police - cloud - run.email } metadata { annotations = { " autoscaling.knative.dev/maxScale " = " 10 " " client.knative.dev/user-image " = " gcr.io/プロジェクトID/bq-police:latest " " run.googleapis.com/client-name " = " terraform " } } } autogenerate_revision_name = true } Cloud Pub/SubからCloud Runを呼び出すために、Push Subscriptionを作成します。今回作成したCloud Runサービスは呼び出すための認証が必要なため、Pushする際にHTTPヘッダーへ認証情報を埋め込む設定をします。このSubscription専用のサービスアカウントを作成し、serviceAccountTokenCreatorのロールを与えます。これにより、このサービスアカウントは自身のOIDCトークンを取得できるようになります。 push_config で生成されたOIDCトークンをHTTPヘッダーに埋め込むように設定します。 cloud.google.com なお、このOIDCトークンを埋め込んだリクエストを生成するという方式はCloud Pub/Sub以外のプロダクトでも活用できます。以下の記事で詳しく解説されています。 medium.com そして、Cloud Pub/SubのサービスアカウントにCloud Runサービスの run.invoker ロールを付与することで、この認証済みリクエストに対する認可をします。 resource " google_service_account " " bq-police-pubsub " { account_id = " bq-police-pubsub " display_name = " BQ Police(Cloud Pub/Sub) " } resource " google_project_iam_member " " bq-police-pubsub " { member = " serviceAccount:${google_service_account.bq-police-pubsub.email} " role = " roles/iam.serviceAccountTokenCreator " } resource " google_pubsub_subscription " " bq-police " { name = " bq-police " topic = google_pubsub_topic.bq - police.name push_config { push_endpoint = google_cloud_run_service.bq - police.status [ 0 ] .url oidc_token { service_account_email = google_service_account.bq - police - pubsub.email } } } resource " google_cloud_run_service_iam_member " " bq-police-pubsub " { service = google_cloud_run_service.bq - police.name location = google_cloud_run_service.bq - police.location role = " roles/run.invoker " member = " serviceAccount:${google_service_account.bq-police-pubsub.email} " } アプリケーション 今回はアプリケーションの実行基盤にCloud Runを使っています。そのため、App Engine(Standard Environment)やCloud Functionsと比較して使用できる言語・フレームワークの自由度が高いです。使い慣れているという理由で、Ruby + Sinatraを使って実装してみました。特別なことはしていないので、詳しい説明は省略します。 require ' json ' require ' yaml ' require ' erb ' require ' sinatra ' require ' faraday ' THRESHOLD = { total_billed_bytes : ENV .fetch( ' THRESHOLD_TOTAL_BILLED_BYTES ' , ' 0 ' ).to_i, total_slot_ms : ENV .fetch( ' THRESHOLD_TOTAL_SLOT_MS ' , ' 0 ' ).to_i, } EXEMPTED_USERS = ENV .fetch( ' EXEMPTED_USERS ' , '' ).split( ' , ' ) WEBHOOK_URL = ENV [ ' SLACK_WEBHOOK_URL ' ] class AuditLog attr_reader :project_id , :total_billed_bytes , :total_slot_ms , :principal_email , :start_time , :end_time , :query def self . from_pubsub_format (data) # Cloud Pub/Subから送られてくるBigQueryの監査ログはBase64でエンコードされたJSON self .new( JSON .load( Base64 .decode64(data[ ' message ' ][ ' data ' ]))) end def initialize (log) # Ref: https://cloud.google.com/bigquery/docs/reference/auditlogs/rest/Shared.Types/BigQueryAuditMetadata @project_id = log[ ' resource ' ][ ' labels ' ][ ' project_id ' ] @principal_email = log[ ' protoPayload ' ][ ' authenticationInfo ' ][ ' principalEmail ' ] @total_billed_bytes = log[ ' protoPayload ' ][ ' metadata ' ][ ' jobChange ' ][ ' job ' ][ ' jobStats ' ][ ' queryStats ' ][ ' totalBilledBytes ' ].to_i @total_slot_ms = log[ ' protoPayload ' ][ ' metadata ' ][ ' jobChange ' ][ ' job ' ][ ' jobStats ' ][ ' totalSlotMs ' ].to_i @start_time = Time .parse(log[ ' protoPayload ' ][ ' metadata ' ][ ' jobChange ' ][ ' job ' ][ ' jobStats ' ][ ' startTime ' ]) @end_time = Time .parse(log[ ' protoPayload ' ][ ' metadata ' ][ ' jobChange ' ][ ' job ' ][ ' jobStats ' ][ ' endTime ' ]) @query = log[ ' protoPayload ' ][ ' metadata ' ][ ' jobChange ' ][ ' job ' ][ ' jobConfig ' ][ ' queryConfig ' ][ ' query ' ] end def duration end_time - start_time end def total_billed_gb total_billed_bytes.to_f / 1024 / 1024 / 1024 end def cost # Ref: https://cloud.google.com/bigquery/pricing/#on_demand_pricing total_billed_gb.to_f / 1024 * 5 end def total_slot_s total_slot_ms.to_f / 1000 end end class BqPolice < Sinatra :: Base def alert? (audit_log, threshold, exempted_users) if exempted_users.include?(audit_log.principal_email) return false end if audit_log.total_billed_bytes >= threshold[ :total_billed_bytes ] || audit_log.total_slot_ms >= threshold[ :total_slot_ms ] true else false end end def format_message (audit_log) YAML .load( ERB .new( File .read( ' slack_message.yml.erb ' )).result_with_hash( audit_log : audit_log)) end def post_to_slack (webhook_url, message) Faraday .post(webhook_url, JSON .dump(message), ' Content-Type ' => ' application/json ' ) end post ' / ' do audit_log = AuditLog .from_pubsub_format( JSON .load(request.body.read)) if alert?(audit_log, THRESHOLD , EXEMPTED_USERS ) message = format_message(audit_log) post_to_slack( WEBHOOK_URL , message) end status 200 end end 上記のRubyコードで参照している slack_message.yml.erb を以下に示します。 username : 'BigQuery Police' icon_emoji : ':cop:' channel : '#投稿したいチャンネル' attachments : - text : "BigQuery cost alert" fallback : "BigQuery cost alert" color : 'danger' fields : - title : 'User' value : <%= audit_log.principal_email %> short : true - title : 'Project' value : <%= audit_log.project_id %> short : true - title : 'Billed bytes (GB)' value : <%= audit_log.total_billed_gb.round(2) %> short : true - title : 'Cost (USD)' value : <%= audit_log.cost.round(2) %> short : true - title : 'Start time' value : <%= audit_log.start_time.getlocal.to_s %> short : true - title : 'End time' value : <%= audit_log.end_time.getlocal.to_s %> short : true - title : 'Duration (sec)' value : <%= audit_log.duration.round(2) %> short : true - title : 'Slot time (sec)' value : <%= audit_log.total_slot_s.round(2) %> short : true - title : 'Query' value : |- ``` <%= audit_log.query.slice(0, 3000).gsub("\n", " \n " ) %> ``` short : false このアプリケーションを動かすためのDockerイメージを作成します。SlackのWeb hook URLはSecret Managerに格納されており、それをBerglas経由で取得しています。そのため、Berglasのバイナリをコンテナ内に入れ、Berglas経由でPumaを起動しています。Pumaの設定ファイルやGemfileは省略します。 FROM ruby:2.7-slim COPY --from=us-docker.pkg.dev/berglas/berglas/berglas:latest /bin/berglas /bin/berglas RUN apt-get -qq update && \ apt-get -qq -y install build-essential --fix-missing --no-install-recommends WORKDIR /usr/src/app COPY Gemfile Gemfile.lock ./ ENV BUNDLE_FROZEN=true RUN gem install bundler && bundle config set --local without ' test ' && bundle install COPY . ./ CMD [ " /bin/berglas ", " exec ", " -- ", " /usr/local/bundle/bin/puma ", " -C ", " puma.rb " ] Cloud RunがBerglas経由でSecret ManagerからSlack Web hook URLを取得できるように、以下のコマンドを実行しておきます。 berglas create sm://プロジェクト名/slack_webhook_url " SlackのWeb hook URL " berglas grant sm://プロジェクト名/slack_webhook_url --member serviceAccount:Cloud Runのサービスアカウント まとめ BigQueryの監査ログをリアルタイムにCloud Runで処理することによって、BigQueryで高額なクエリを実行されたときにいち早く気づくことができるようになりました。スキャン量に対するアラート条件だけでなく使用したSlot数に対する条件を指定できるようにし、オンデマンド課金のプロジェクトでもFlat rate課金のプロジェクトでも使用できました。 なお、上図は意図的に閾値を厳しくした時の通知であり、実際にはこのレベルのクエリでアラートを発報させてはいません。 ZOZOテクノロジーズでは多数の社員から使われるデータ基盤のデータガバナンスを高められる人材を募集しています。ご興味のある方は以下のリンクからご応募ください。 https://hrmos.co/pages/zozo/jobs/0000017 hrmos.co
アバター
こんにちは、ZOZOテクノロジーズ CTO室の池田( @ikenyal )です。 2020年のZOZOテクノロジーズのテックブログは、本記事で100本目に到達しました。1年間の公開記事数としては、過去最多であり、百の大台に乗れたことを嬉しく思います。 それを記念し、この記事ではテックブログに力を入れる理由であったり、どのような運用をしているか・何に気をつけているのかについて触れたいと思います。スケジュールとマイルストーンの仕組み化、公開日時を調整する際のポイント、記事の編集・校正時のポイント、日々の執筆者トレーニングについてもご紹介します。 techblog.zozo.com なぜテックブログに力を入れるのか 「テックブログを頑張ろう」というフレーズは採用を強化していきたい多くの企業で耳にするものだと思います。 では、なぜテックブログにコストをかけてまで注力するのでしょうか。ZOZOテクノロジーズにおいても、今年その根幹に立ち返り、再度議論をしました。 テックブログの目的に立ち返る 私の所属するCTO室では技術広報も職務領域です。そのため、テックブログに関してもその目的の明確化を行い、KPIを定めた運用を続け、エンジニアに対してそれらを伝えていく責任があります。 なお、 弊社のテックブログ は、現在のZOZOテクノロジーズ(当時のスタートトゥデイテクノロジーズ)に吸収合併された一社であるVASILYのテックブログが前身です。 techblog.zozo.com VASILY時代より、現在のCTO今村が先頭に立ち、テックブログを継続していました。その当時からテックブログを実施する目的は変わっていません。その目的に関しては今村の以下の記事で社内向け説明ページのキャプチャを添えて触れられています。 note.com 目的からやらないといけないことを細分化する しかし、大きな目的が定められていたとしても、それだけでは四半期や半年、1年単位で何をやるべきかが明確になりません。 そこで、CTO室と 広報 で連携し、日々の技術広報のKPIを定められるよう、直近から3年間で技術広報が目指す先、つまり技術広報戦略を定めました。 まず、「最終的に目指す先」を定め、そこから「それを実現するためには何が必要なのか」を順に考えていきます。「何をどの順に達成しないといけないのか」のステップを明確にしていくのです。 これをすることにより、テックブログをやることによって最終的に何を目指していて、それに向けて今は何をやらないといけない段階なのかが可視化できます。そして、それを用いることで社員へ明確な説明ができると同時に納得をしてもらえる材料になります。実際に今年、この技術広報の背景をCTO室通信(毎月CTO室が公開している社内報)で解説し、社員から「技術広報やテックブログの背景を知ることができて良かった」という意見も得られ好評でした。 では、弊社の場合、どのようにステップを分解していったのかを紹介します。 ZOZOの経営戦略に「MORE FASHION x FASHION TECH」が掲げられています。 corp.zozo.com このZOZOグループの大きな目標を技術の力で実現させることが、我々ZOZOテクノロジーズという企業が果たさねばならない最終目標です。 次に、その「MORE FASHION x FASHION TECH」が実現されるために必要な状況を考えます。「世の中により価値のあるプロダクトを届ける」ということが、必要だと考えました。利用していただくための「価値あるプロダクト」がなければ、世界中の皆様に何も届けることができない、という考え方です。 さて、そのようなプロダクトを届けるためには何が必要でしょうか。何か構想があってもそれを作り上げるエンジニアが不在では形になりません。つまり、素晴らしいエンジニアが会社に集結し、会社全体の技術力の向上が必要でしょう。 そして、次に素晴らしいエンジニアが集まる会社になるために何をすべきかを考えます。エンジニアが入社したくなるような「会社のファン」を増やすために、認知度向上のための技術発信が必要でしょう。いわゆる、技術ブランディングに力を入れる必要があるのです。弊社もここに注力をしている状況です。 このように、最終的なゴールから直近で達成しなければならないポイントを明確にすることにより、「なぜやるのか」「どのような意味があるのか」が自然と明確になります。 ここまでの流れをまとめると、以下のようなステップに分解できました。 直近から3年間の目指す先を明確化する 直近の目標である「発信によって認知を広め、会社のファンを増やす」ための方針を定めます。詳細は割愛しますが、弊社では3カ年計画を定め、2020年度・2021年度・2022年度で、それぞれどのような状況を目指すのかを具体的に定めました。それらを元に、テックブログ、外部登壇や自社主催のイベント開催など、それぞれの技術広報における施策のKPIを定め、CTO室と広報の定例で数値を追うようにしました。 具体的なKPIを定めることにより、並行する多くの施策をバランス良く継続できます。 テックブログの業務だけを行う専任者がいることは稀であり、エンジニアが他の業務と掛け持ちで運営していることが多いかと思います。そのような状況だと「気づいたらやらなくなっていた」「忙しくて放置状態になっていた」という状況が起こりがちでしょう。そのような状況が発生しかけたとしても、定例で数値を確認することになるので、早期のリカバリが可能になり、継続的な施策として定着させる手助けになるでしょう。 どのような運用をしているのか ここでは、テックブログを日々どのように運用しているのか、その一部をご紹介します。この運用に関しては、CTO室の設立後、仕組み化を大きく導入できた領域です。 弊社のテックブログの記事はすべてCTO室のレビューを必須としており、私が担当しています。執筆開始前の概要確認から、執筆後の編集・校正をすべて行っています。他の業務をこなしつつも、100本の記事をレビューしてきました。まるで編集長ですね。 なぜそこまでチェックをしているのかと言うと、前述の「技術ブランディングのため」というのが答えです。テックブログは個人の作業メモや備忘録ではありません。企業の公式な発信媒体であり、それらの記事が読者の皆様に価値を与えないと意味がありません。「価値」とは、チュートリアルに載っていない細かい部分の新しい知見の共有であったり、新規性のある独自に工夫をした手法・アーキテクチャ、ZOZOTOWNの大規模トラフィックにいて得られる知見、などです。我々の発信よって、それを参考にした社外のエンジニアのスキル向上につなげ、業界全体・社会への還元につながれば幸いです。 また、記事の事前チェックにより、間違った内容だけでなく、社外秘の情報や数値が含まれていないか、いわゆる炎上につながるような不適切な表現がないか、を確認する役割も大きいです。それらが一度でも外部発信に含まれてしまうと、ブランディングに影響を与え、それまでの苦労が一瞬で水の泡となりかねません。 スケジュールとマイルストーンの仕組み化 まず、CTO室が四半期(3か月)ごとに、エンジニアが所属する部に記事の募集をかけます。この段階で、「どのチームが、どの週に記事を公開するか」までを確定させます。これを四半期が始まる1か月程前に確実に行うことにより、期初から計画的なペースで記事を公開できるようになります。そして、慌ただしくなりがちな期初の執筆者に対しても早めに調整・リマインドをできる環境を作っています。 その際に、 前述の今村の記事 でも紹介しているようなマイルストーンを定義し、チェックボックス形式でその進捗を管理しています。それぞれのマイルストーンに対し、記事公開の何日前までに完了しなければいけないのかの期日も定め、その期日を過ぎてもチェックボックスに動きがない執筆者に対してリマインドと状況確認を行います。 リマインドの期日には多少の余裕を持たせているため、ここで問題が発生してもリカバリが可能です。場合によっては、その次週公開予定の執筆者と公開日の調整を行うことも可能です。 この早めの遅延検知により、「実は期日ぎりぎりで未着手でした」という事態を防止します。期日ぎりぎりでの執筆になると、執筆者自身も辛くなりますし、運営側もレビュー時間の不足やスケジュールの破綻が起こって辛い状況になります。場合によっては、期日の兼ね合いで不完全な状況での記事公開をせざるを得ないということにもなりかねません。誰も得をせず、お互いに不幸な状況になってしまいます。そのような状況に陥ると、テックブログに対して全員が後ろ向きになり、施策が停滞してしまう可能性が高くなります。 ここからは、運営者として気をつけているポイントをいくつか紹介します。 公開日時を調整する際のポイント 1つ目は公開日時を調整する際のポイントです。 弊社のテックブログも以前は更新頻度が一定ではなく、停滞する時期もありました。そこから定期的な発信を続けたところ、テックブログ自体のアクティブユーザー数が維持・増加することが見えてきました。そのため、1日にまとめて記事を公開するのではなく 1日1本ずつ継続して公開する ようにしています。同じ週の担当になった執筆者の中で、進捗状況を考慮しながら公開日が別になるように割り振っています。 さらに、公開時間も調整しています。特に情報解禁の時間であったり、プレスリリースに合わせたりする必要がなければ、基本的に 午前11時の公開 をしています。これは、Google Analyticsの曜日時間帯別PVのヒートマップから、1番読まれる時間帯の開始時刻に合わせての設定です。平日と休日でも読まれ方に差があるので、土日祝日の公開は基本的に避けています。なお、土日祝日を避けるのは、万が一、記事の内容に修正が必要になった場合、迅速な対応が取りにくくなるリスクを避ける意味もあります。 記事の編集・校正時のポイント 2つ目に記事の編集・校正時のポイントです。 固有名詞の表記を正しく書く ことは当たり前のことですが、指摘する回数が非常に多いものです。例えば、「Slack」を「slack」と表記されるパターンをよく目にします。Slackの場合、ロゴの文字は小文字のsなのですが、固有名詞としては大文字のSで始まるSlackが正しい表記です。記事で固有名詞を使う際には、記憶やロゴの見た目で判断せず、公式サイトで今一度確認をしましょう。GitHubをGithubとするパターン、YouTubeをYoutubeとするパターンも非常に多いです。 また、我々はZホールディングスグループなので、ヤフーとも親戚関係です。そのため、以前よりもヤフーの名称を社員が利用する機会が増えました。Yahoo!Japan、Yahoo!JAPAN、Yahoo!JAPAN、Yahoo・Yahoo!など多種多様な表記をされることがあるのですが、これらは正しい表記ではありません。会社としては「ヤフー」「ヤフー株式会社」、サービスとしては「Yahoo! JAPAN」と書くのが正しい表記です。年賀状やメールを出す際に相手の名前の漢字に気をつけますよね。会社名もそれと同じです、 正しい表記で記載するのはマナー だと考えています。 他には、 表記揺れ も修正対象です。記事内で表記揺れがあったり、同じ事象を指す用語が別の呼び方になっていると、読み手が理解しにくくなるため、統一をします。ここで気をつけているのは、 テックブログ内でルールをがちがちにはしていない ところです。「ください」はひらがなで、「出来る」はひらがなで、などしても良いのですが、そこまではしていません。執筆者はそれぞれ別なので、 どの表記を採用するのかは執筆者ごとの個性として捉え、記事内での統一に留める ようにしています。ただし、その個性も、正しい表記を利用していることが大前提です。 加えて、テックブログなので、「!」の多用や、「〜な気がします」「〜かもしれません」などの曖昧な表記があれば修正をしています。記号類は冒頭や末尾の挨拶部分であれば良いのですが、テックブログとして真面目に発信している本文内では適さないと判断しています。また、曖昧な表現で断言しないことも同様に適しません。自信のない内容や、断言しきれない「個人的の見解」ばかりだと、テックブログとして読者が参考にして良いのか判断に困ってしまいます。執筆の際には自信を持って言い切れるように、調査や準備をしておく必要があります。 なお、textlintによる自動校正も導入していますが、最後は人による確認が必要でしょう。 日々の執筆者トレーニング そもそも、テックブログを書く行為はある程度のスキルや経験がどうしても必要になってしまいます。そのため、テックブログをいきなり書く自信がないエンジニアには、アドベントカレンダーでその練習をしてもらったりしています。2020年のアドベントカレンダーでは、12月の25日間で合計100本の記事を発信できました。 qiita.com qiita.com qiita.com qiita.com どのような効果があったのか それでは、実際に継続的な発信をすることによって、どのような効果があったのかを紹介します。 前述の直近の目標である「発信によって認知を広め、会社のファンを増やす」を計測する方法は色々とあり、ブランディング調査などもその一環として行っています。しかし、それらは毎日実施することはできず、定期的な計測になってしまいます。 そのため、日々の動きを確認できる、Google Analyticsでのアクティブユーザーの推移を一つの指標としています。 今回はその推移を紹介します。 これは2020年のアクティブユーザー数の推移を示したグラフです。 そして、次に示すのが2020年のテックブログの公開記事数です。 統計的に有意かどうかまでは分析していませんが、直感的には記事公開が多くなればアクティブユーザーが増えているように見受けられます。 また、2020年に入り、採用面接時にテックブログについて言及してもらえる回数も増えてきました。 2020年の人気記事紹介 2020年に公開した記事の中から、閲覧数が多い記事5位を紹介します。なお、実際の閲覧数ランキングでは、2020年の記事以外の過去の記事も多く入っています。有益な記事を公開すれば、何年も閲覧され続けることが分かります。 techblog.zozo.com techblog.zozo.com techblog.zozo.com techblog.zozo.com techblog.zozo.com 最後に ZOZOテクノロジーズでは、プロダクト開発以外にも、イベントの開催やテックブログなど、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! https://tech.zozo.com/recruit/ tech.zozo.com
アバター
はじめに こんにちは。2020年5月に入社しましたMA基盤チームの辻岡です。 MA基盤チームでは、マーケティングに関わる様々なプロダクトやシステムの施策開発・運用を行っています。その中の1つにリアルタイムマーケティングシステムというものがあります。 これまでこのシステムには検証環境が存在しませんでした。そこで、検証環境を新たに作る事でシステムの開発や運用の効率化並びに品質の担保に貢献した事について紹介します。 また、検証フェーズの効率化手段としてDigdagを利用したデータ転送機能は使ってみると想像以上に便利だったので、実装方法について詳しく紹介します。効率化手段の1つとして参考にして頂けたら幸いです。 目次 はじめに 目次 リアルタイムマーケティングシステムの概要 リアルタイムマーケティングシステム バッチ配信システム analyzer バッチ配信システムの処理の流れ ユーザ抽出・コンテンツ生成の実装方法 ユーザ抽出の実装例 仕様 実装方法 実運用での課題 コンテンツ生成のSQL文は長く複雑 ローカルでの検証が困難 検証環境が導入される前 既存改修を検証する方法は「本番に影響を与えないように気をつけながら試行錯誤」のみ 修正と確認のリードタイムが長くなる 監視担当に余計な負荷をかける 検証環境を導入した後の効率問題 デプロイの自動化 データ転送の自動化 データ転送機能の実装方法 本番環境と検証環境間の接続について digファイル構成 PostgreSQLのデータ転送方法 SQL Serverのデータ転送方法 IDENTITY設定の壁 bcpを使ったデータ転送 ymlファイルを利用したデータ定義 検証データの定義を保持するドキュメントとしての有用性 改善した検証環境で検証を行うメリット 本番稼働しているものを改修する際の検証が楽になる 修正と確認のリードタイムが短くなる 余計な監視通知を抑制できる 気付きが多くなる まとめ おわりに リアルタイムマーケティングシステムの概要 これから紹介するシステムについて、いくつかの登場人物を説明します。 リアルタイムマーケティングシステム バッチ配信システム analyzer リアルタイムマーケティングシステム 以下の判定(最適化)を行った上で対象のお客様(ユーザ)に対しておすすめの商品(コンテンツ)を配信しているのがリアルタイムマーケティングシステムです。 よく見るチャネル(LINE、メール等)に配信する よく見る時間帯に配信する n日に1通の頻度で配信する 分かりやすいように具体例を1つあげます。ランキング急上昇中のアイテムのうち、おすすめ商品を紹介したい場合、お客様に合わせた適切なチャネル・時間帯・頻度を判定して送る事で、よりコンテンツを見て頂けるようになります。以下が実際のメールでの配信例です。 リアルタイムマーケティングシステムという名前ですが、リアルタイムの配信だけでなくバッチでの配信も行っています。今回はそのバッチ配信システムのキャンペーン実装について取り上げます。 バッチ配信システム 特定条件のユーザを特定時間に抽出し、最適化した上でコンテンツを配信する仕組みをバッチ配信システムと呼んでいます。 analyzer 最適化を高速で行うシステムのコアとなるアプリケーションをanalyzerと呼んでいます。 これはJBoss Enterprise Application Platform(JBoss EAP)上で動いており、関連機能も複数利用しています。例えば、Excelでルール判定を行うためのDecision Managerを最適化に利用し、分散キャッシュストアであるJBoss Data Grid(JDG)を処理の高速化のために利用しています。 バッチ配信システムの処理の流れ バッチ配信は以下のような流れで行われます。 データの流れは以下のようになっています。 処理の中でSQL ServerとPostgreSQLの2種類のDBを利用しています。処理の説明前にこの2つの用途について記載します。 SQL Serverは、ZOZOTOWNの各DBをレプリケーションしたDBです。ユーザ、商品、注文情報等ZOZOTOWNに関するデータを格納しています。 PostgreSQLは、リアルタイムマーケティングシステム専用のDBです。後続処理で利用するデータ、JDGに格納した実績や集計結果、BigQueryやDWH等外部で生成したデータを格納しています。外部データの連携にはDigdagを利用しています。 では処理の流れを説明していきます。 ユーザ抽出は、配信したい対象のユーザのリストを取得する処理です。リアルタイムマーケティングシステムではMyBatisを採用しているため、MyBatisのMapper XMLを利用してSQL Server経由でデータ抽出を行うためのSQL文を追加していきます。データ抽出後、結果を対象ユーザテーブル(PostgreSQL)に格納します。後続処理がこのデータを読み込みます。 最適化設定はanalyzer上で行われます。実装はDecision Managerを利用しているためExcelに設定値を追記します。今回の利用用途では、恩恵はあまり受けないので詳細は割愛します。この機能により、最適な時間帯に配信対象テーブル(PostgreSQL)へ対象ユーザIDや配信キャンペーンID等を書き込み、コンテンツ生成を行う処理がこのデータを読み込みます。 コンテンツ生成は、対象ユーザに配信コンテンツを表示・配信するための情報を取得します。例えば氏名、メールアドレス、商品名、画像名、金額等が該当します。配信対象テーブルを読み込み、最適化設定で判定したチャネルに合わせて必要情報を取得します。メールの場合はユーザ抽出と同様、Mapper XML経由でデータ抽出を行い、結果をcsvファイル出力します。LINEの場合はanalyzerから直接SQL文を実行し、結果をjsonに格納します。 配信処理は、ユーザにコンテンツをチャネル別に配信する処理です。メールの場合は、出力したcsvをMPSE(MailPublisher Smart Edition)というメール配信サービスにAPIで送っています。送ったcsvの値をMPSE側に予めセットしたデザインテンプレート(html, txt)へ差込む事で配信します。LINEの場合は、analyzerで格納したjsonをLINEのAPIリクエストに持たせてアクセスする事で配信します。 このシステムの全体的な仕組みの詳細については 先のブログ をご参照ください。 techblog.zozo.com このうちMapper XMLを使ったユーザ抽出とメール配信時のコンテンツ生成のSQL文実装は、検証の効率化による恩恵を大きく受けました。イメージがつくよう、詳しく実装方法を説明します。 ユーザ抽出・コンテンツ生成の実装方法 架空のTシャツ訴求キャンペーンのユーザ抽出の実装方法を例に説明します。ユーザ抽出とコンテンツ生成の基本構成は同じで、SQL文だけが異なります。 ユーザ抽出の実装例 仕様 前日のTシャツのアクセスが100回以上の会員が対象。 データの抽出方法について説明しておきます。 以下の2テーブルを利用します。 access_aggregation at PostgreSQL user at SQL Server 抽出方法の流れは、まずaccess_aggregationテーブルで、 category が tshirt 、 access が100以上の userid のリストを抽出します。このあと紹介する方法を使って、SQL Serverから該当のPostgreSQLのデータを抽出しtempテーブル( #aggregation )に格納します。そして、userテーブルから退会フラグ( isleave )が0かつ #aggregation に該当する id を抽出します。 このようなテーブル構成イメージです。 実装方法 以下のようなリソースのファイル構成の状態で、キャンペーンのユーザ抽出用のSQL文を記載するxmlを追加します。 resources └──mybatis ├──mappers │ ├──common.xml #共通ファイル │ └──campaign_tshirt.xml #新規追加 └──config.xml #共通ファイル 以下のようなSQLにより、データを抽出します。 common.xml <sql id = "create_tmp_aggregation" > <![ CDATA [ CREATE TABLE #aggregation ( userid INT NOT NULL PRIMARY KEY (userid) ); ]]> </sql> <sql id = "aggregation" > <include refid = "create_tmp_aggregation" > <![ CDATA [ INSERT INTO #aggregation ( userid ) SELECT userid FROM openquery(rds, ' SELECT userid FROM access_aggregation WHERE category = ''tshirt'' AND access >= 100 AND date = current_date + interval ''-1DAY'' GROUP BY userid ') LFD; ]]> </sql> campaign_tshirt.xml <? xml version = "1.0" encoding = "UTF-8" ?> <! DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd" > <mapper namespace = "jp.zozo.marketing.realtime.campaign" >     <include refid = "aggregation" > <select id = "campaign_tshirt" resultType = "map" > SELECT id FROM user INNER JOIN #aggregation ON user.id = userid WHERE isleave = 0 </select> </mapper> PostgreSQLのデータは、SQL ServerのSQL文から リンクサーバ という他DBにアクセスできるデータベースエンジンを経由し取得します。 リンクサーバを経由したデータ抽出は全て common.xml というファイルに記載します。Tシャツキャンペーンのmapper用xml( campaign_tshirt.xml )に userid 抽出のために、SQL ServerのSQL文を記載します。 あとは campaign_tshirt.xml を、アプリケーションが読み込むよう設定されている config.yml に追記します。 config.xml <!-- 既存 --> <mapper url = "../mappers/common.xml" /> <!-- 新規追加 --> <mapper url = "../mappers/campaign_tshirt.xml" /> これにより、アプリケーションがTシャツキャンペーンの対象者を抽出し、対象データテーブル(PostgreSQL)にinsertする事で後続処理に続きます。メールコンテンツ生成も同様の方法でSQL文を生成し、csvを出力して後続処理に続きます。 実装はこれで完了です。実装の流れは一見単純なものですが、実運用では以下の課題が潜んでいます。 実運用での課題 ここでは、実運用で発生した課題を2つ紹介します。 コンテンツ生成のSQL文は長く複雑 コンテンツ生成をするためには、複雑で長いSQL文を作成する事が殆どです。 取得できるデータとSQL文を駆使してコンテンツに必要なデータを抽出するため、仕様の複雑化による影響を受けやすい事が原因です。例えば以下のようなSQL文が挙げられます。 出力カラム数200個 カラムを組み合わせた加減乗除の計算式 caseごとに分岐されたデータ加工 条件判定後の番号(row_number等)振り直し 入れ子状態のサブクエリ(サブクエリのサブクエリのサブクエリ) 説明のために実際のものを簡素化し、DB情報等も変えていますが、以下のようなイメージです。 <!-- 対象ユーザの商品情報 --> <sql id = "item_info" > <![ CDATA [ CREATE TABLE #ITEM_INFO ( MEMBER_ID INT NOT NULL ,ITEMID INT NOT NULL ,IMAGENAME VARCHAR(50) NOT NULL ,ITEMNAME NVARCHAR(100) NOT NULL ,BRANDNAME NVARCHAR(100) NOT NULL ,PRICE NVARCHAR(15) NOT NULL ,DISCOUNT NVARCHAR(10) ,DISCOUNTCOLOR VARCHAR(10) ,COUPONCOLOR NVARCHAR(10) ,COUPONPOINT NVARCHAR(15) ,CMTST NVARCHAR(10) ,CMTEND NVARCHAR(10) ,RANK SMALLINT NOT NULL PRIMARY KEY (MEMBER_ID, RANK) ); INSERT INTO #ITEM_INFO SELECT MEMBER_ID ,ITEMID ,IMAGENAME ,CASE WHEN LEN(ITEMNAME) <= 36 THEN ITEMNAME ELSE LEFT(ITEMNAME, 34) + '...' END AS ITEMNAME --商品名 ,CASE WHEN LEN(BRANDNAME) <= 15 THEN TBNAME ELSE LEFT(BRANDNAME, 14) + '...' END AS BRANDNAME --ブランド名 LTRIM(RTRIM(FORMAT(ROUND((PRICE * TAXRATE), 0), N'#,0'))) AS PRICE ,CONVERT(VARCHAR(3), FLOOR((1.0 - (SALEPRICE * TAXRATE) / (SALEPRICE * TAXRATE)) * 100)) + '%OFF' AS DISCOUNT --割引率 ,CASE WHEN DISCOUNTFLAG = 1 THEN 'discounted' ELSE NULL END AS DISCOUNTCOLOR --価格の文字色 ,COUPONCOLOR ,COOUPONPOINT ,CASE WHEN COOUPONPOINT IS NULL THEN '<!--' ELSE NULL END AS CMTST --クーポンがない場合コメントアウト ,CASE WHEN COOUPONPOINT IS NULL THEN '-->' ELSE NULL END AS CMTEND --クーポンがない場合コメントアウト ,ROW_NUMBER() OVER (PARTITION BY MEMBER_ID ORDER BY RANK) AS RANK --条件判定後に掲載順を振り直す FROM #TARGET_MEMBER_ITEM AS MODEL --PostgreSQLで抽出した配信対象xモデル化データx外部連携データ (作成SQL文略) INNER JOIN ITEM ON MODEL.ITEMID = ITEM.ITEMID INNER JOIN SHOP ON ITEM.SHOPID = SHOP.SHOPID ・ ・ ・ LEFT OUTER JOIN #EXCLUDED_ITEM AS EXCLUDED ON ITEM.ITEMID = EXCLUDED.ITEMID --掲載対象外の商品情報(作成SQL文略) LEFT OUTER JOIN #COUPON_INFO AS COUPON ON SHOP.SHOP_ID = COUPON.SHOP_ID --クーポン情報(作成SQL文略) WHERE EXCLUDED.ITEMID IS NULL --対象外の商品を除外する ・ ・ ) AS BASE WHERE RANK <= 18; ]]> </sql> <!-- 対象商品をデザインテンプレート差込のためメンバーごとに横変換 --> <sql id = "main_info" > <![ CDATA [ SELECT /* カラム名はデザインテンプレート依存 */ /* ユーザ情報 */ EMAIL ,NAME ,MEMBER_ID ,TEMPLATEID --デザインテンプレートID /* メインコンテンツ情報 */ /* 商品1 */ ,MITEMID01 ,MITEMNAME01 ,MIMAGENAME01 ,MBRANDNAME01 ,MPRICE01 ,MDISCOUNTRATE01 ,MDISCOUNT01 ,MCOUPONCOLOR01 ,MCOUPONPOINT01 ,MCMTST01 ,MCMTEND01 /*商品2*/ ,MITEMDETAILID02 ,MITEMID02 ・ ・ ・ ,MCMTST18 ,MCMTEND18 /* MAT対象者はコメントアウト */ ,CASE WHEN MAT_MEMBER_ID IS NOT NULL THEN '<!--' ELSE NULL END AS COMMENDSTART ,CASE WHEN MAT_MEMBER_ID IS NOT NULL THEN '-->' ELSE NULL END AS COMMENDEND ,DATEPART(year, CURRENT_TIMESTAMP) AS CURRENTYEAR ,DATEPART(month, CURRENT_TIMESTAMP) AS CURRENTMONTH ,DATEPART(day, CURRENT_TIMESTAMP) AS CURRENTDAY ,DATEPART(hour, CURRENT_TIMESTAMP) AS CURRENTHOUR ,DATEPART(minute, CURRENT_TIMESTAMP) AS CURRENTMINUTE FROM #TARGET_MEMBER AS TARGET --MODELから生成した対象者xMAT対象情報(作成SQL文略) INNER JOIN( SELECT MEMBER_ID ,ITEMID AS MITEMID01 ・ ・ ・ ,CMTEND AS MCMTEND01 FROM #ITEM_INFO WHERE RANK = 1 ) AS ITEM01 ON TARGET.MEMBER_ID = ITEM01.MEMBER_ID ・ ・ ・ INNER JOIN( SELECT MEMBER_ID ,ITEMDETAILID AS MITEMDETAILID18 ・ ・ ・ FROM #ITEM_INFO WHERE RANK=18 )AS ITEM18 ON ITEM17.MEMBER_ID = ITEM18.MEMBER_ID ]]> </sql> <!-- PC用コンテンツ --> <select id = "pc_select" parameterType = "map" resultType = "java.util.LinkedHashMap" > <include refid = "target_member_item" /> <include refid = "target_member" /> <include refid = "excluded_item" /> <include refid = "coupon_info" /> <include refid = "item_info" /> <include refid = "main_info" /> <![ CDATA [ SELECT /* main_infoでselectした項目全部 */ EMAIL ・ ・ ・ /* PC版のみバナー情報 */ ,HEADERBANNER ,HEADERURL ,FOOTERFILENAME01 ,FOOTERURL01 ・ ・ ・ ,FOOTERURL06 FROM ( <include refid="main_info" /> ) AS MAININFO CROSS JOIN BANNER_INFO; ]]> <!-- セッションに残らないようにtmpテーブル削除 --> DROP #TARGET_MEMBER_ITEM; DROP #TARGET_MEMBER; DROP #EXCLUDED_ITEM; DROP #ITEM_INFO; DROP #COUPON_INFO; </select> <!-- モバイル用コンテンツ --> <select id = "mobile_select" parameterType = "map" resultType = "java.util.LinkedHashMap" > <include refid = "target_member_item" /> <include refid = "target_member" /> <include refid = "excluded_item" /> <include refid = "coupon_info" /> <include refid = "item_info" /> <include refid = "main_info" /> <!-- セッションに残らないようにtmpテーブル削除 --> DROP #TARGET_MEMBER_ITEM; DROP #TARGET_MEMBER; DROP #EXCLUDED_ITEM; DROP #ITEM_INFO; DROP #COUPON_INFO; </select> 慣れてしまえば一発で想定SQL文を書く事も可能ですが、慣れていないとSQL文からは実行結果の推測すら難しいでしょう。 ローカルでの検証が困難 ユーザ抽出の実装例で、リンクサーバを経由してPostgreSQLからデータを抽出する工程があったのを覚えていますか。リンクサーバの存在がローカル環境での検証を困難にした理由の1つです。 リンクサーバ経由の接続のためにOLE DBというデータソースアクセス機能を利用する必要があります。そのため、OLE DBで接続を行うための「OLE DBプロバイダー」が必要でした。Windows環境であればプロバイダーも存在しますが、開発者の大半がmacOS/Linux環境を利用しており、対応プロバイダーが見つからなかったため断念しました。 よって、ローカルでバッチ配信システムを動かしてもリンクサーバ経由の接続ができないため、ローカルでは処理を通貫して確かめる事はできない状態でした。 検証環境が導入される前 新しい人の増加や組織が変化する中、実装頻度が増え仕様はより複雑化していました。しかし先の通りローカル環境もなく、本番環境でしか検証ができない状況でした。当時の私のように慣れていない人の場合、以下のような状況に陥りました。 既存改修を検証する方法は「本番に影響を与えないように気をつけながら試行錯誤」のみ 既にリリースされたキャンペーンを修正する必要がある場合、本番稼働中のそのキャンペーンに影響しないよう、影響を与えない範囲で試行錯誤を少しずつして確認を行っていました。確認方法を検討するだけで長い時間を要する事もありました。 修正と確認のリードタイムが長くなる 入念に目視確認を行った後に本番リリースし検証。リリース後の検証を動かして何か見つかれば修正。このようなリリースを何度も繰り返します。動作確認してない、かつ本番リリースなので、自然と確認時間も長くなります。私は入社当初、何度もこのような本番リリースを繰り返しました。 監視担当に余計な負荷をかける 本番環境では監視設定があり、エラーはSlackの本番監視用チャネルに通知する仕組みが入っています。今回の検証時のエラーも例外ではありません。これは他の監視の妨げにもなりかねず、余計な監視アラート通知は皆に負荷をかける行為です。繰り返しの本番リリースに加えて、この状況は私の作業効率を下げていきました。 これらの課題を解決するため、AWS上にリアルタイムマーケティングシステムを動かす検証環境を作りました。同時に、検証環境でバッチ配信システムの検証も可能にしました。しかし、問題も発生しましたので、その問題について説明します。 検証環境を導入した後の効率問題 検証環境を導入し、本番環境に依存して余分に増えていた作業時間は短縮されました。しかし、検証環境で検証をするための準備に時間がかかるという問題が残っていました。 これを解決するために下記2点を行いました。 デプロイの自動化 データ転送の自動化 デプロイの自動化 当初、検証環境でのデプロイは全て手動で行っていましたが、検証用ブランチ(staging)にマージ後、CircleCIが自動で検証環境にファイルをコピーするよう、デプロイ手順を自動化しました。 データ転送の自動化 検証環境により、処理の流れを確認できるようになりましたが、処理をするためのデータが入っていないと想定結果を返すかどうかの確認ができません。当時はこの検証環境のデータ不備により、本番環境で気付いた後に、再検証を行うという検証環境の導入前と同様の状況になりました。 その後、都度本番から検証に必要なデータを取得してinsertするようにしました。利用するテーブルやケースが多いためデータの準備にとても時間がかかりました。 そこで、Digdagを使ったデータ転送機能を追加しました。 Digdagを使ったデータ転送については、やってみると効率化だけでなく、品質担保にも有用でした。 これらの改善により、検証からリリースまでのフローは以下のように効率改善されました。 Digdagを選択した理由は、既に他のデータ連携の実装で多用していたため知見もあり、比較的導入しやすかった事が主な理由でした。しかし実際に使ってみると、検証ケースが書かれたドキュメントとして残せたり、追加改修の時に流用しやすかったりと利点が多くありました。そのため、結果的にこの選択は正しいものでした。 Digdagを使ったデータ転送について、以下で実装方法と共に説明していきます。 データ転送機能の実装方法 PostgreSQLとSQL Serverの本番データを検証環境に転送する機能の実装方法について説明します。 SQL Serverではテーブル定義による壁があり、定番のEmbulkではなく別の方法を使っています。同じ壁にぶつかった方の参考になればと思います。 なお、環境変数やsecretsの設定方法、利用しているDockerイメージの詳細説明についてはトピックから外れるため一部省略しています。予めご了承ください。 また、本番環境に個人情報や情報区分の高いものがある場合は取り扱いに注意してください。 本番環境と検証環境間の接続について 環境を跨いだVPC間の接続では AWS Transit Gateway を利用しています。Transit Gatewayはルーターのような役割で、VPCを接続(Attachment)し、通信させたいサーバの接続情報をルートテーブルに設定する事でVPC間の通信を実現します。セキュリティグループの制御により単一方向のみ接続を許可しています。 digファイル構成 キャンペーンごとにどのデータ定義をしたか判別しやすくするため、専用の実行digファイル( TestDataxxxx.dig )とデータ定義の設定( config/xxxx )を用意します。キャンペーンに依存しない共通処理はsub、taskディレクトリに保持しています。 config データ定義に関する情報を記した設定ファイル群 sub 実行digファイルの中で実行するサブタスク群 tasks サブタスクの中で実行するスクリプト群 以下のような構成で管理しています。中のファイルについては後述します。 ProductionToStaging ├── TestDataCampaignA_postgres.dig ├── TestDataCampaignA_sqlserver.dig ├── config │ └── campaignA │ ├── postgres │ │ └── ranking.yml.liquid │ └── sqlserver │ └── target.yml ├── sub │ ├── build_query_parameter.dig │ ├── copy_sqlserver.dig │ ├── postgresql_environment.dig │ ├── prepare_environment.dig #環境変数の設定 │ ├── run_embulk.dig │ └── secrets.dig #secrets設定 └── tasks ├── bcp_copy.sh └── query_parameter.rb PostgreSQLのデータ転送方法 PostgreSQL同士でのデータ転送にはEmbulkを利用しました。input、output共にPostgreSQL用のJDBCプラグインを利用します。 embulk-input-postgresql embulk-output-postgresql Embulkは異なるDBやストレージ間でデータ転送できる事が特徴ですが、今回のようなPostgreSQL同士のデータ転送でも利用できます。以下の例のように、設定をliquidというテンプレートエンジンを使ってinput( in: )とoutput( out: )情報を記載する事で実現します。 target.yml.liquid in : type : postgresql host : {{ production_postgres_hostname }} port : {{ production_postgres_port }} user : {{ production_posrgres_user }} password : {{ production_postgres_password }} database : {{ database }} query : #人気ランキング上位3位 SELECT user_id ,item_id ,rank FROM ranking WHERE rank <= 3 out : type : postgresql host : {{ staging_postgres_hostname }} port : {{ staging_postgres_port }} user : {{ staging_postgres_user }} password : {{ staging_postgres_password }} database : {{ database }} table : ranking mode : truncate_insert 上記のようにinputに記載の query で転送データを定義しています。 digファイルに対象テーブル名(=liquidファイル名)をymlの配列型式で指定し、Embulkを実行します。検証データが増えた際にテーブルが増やせるようにするためです。 TestDataCampaignA_postgres.dig timezone : Asia/Tokyo +prepare_environment : !include : sub/prepare_environment.dig _export : wf : name : Transfer production data to staging for testing campaignA campaign_id : campaignA target_table_names : - ranking +prod_to_staging_postgres : for_each> : target_table_name : ${target_table_names} _parallel : false _do : +psql_to_psql : !include : sub/run_embulk.dig run_embulk.dig _export : docker : image : ${embulk_docker_image} pull_always : true sh> : embulk -J-Duser.timezone=Asia/Tokyo run -b /var/lib/embulk config/${campaign_id}/postgres/${target_table_name}.yml.liquid SQL Serverのデータ転送方法 Embulkを利用したかったのですが、以下のような壁がありました。 IDENTITY設定の壁 SQL ServerではIDENTITY設定されたカラムが存在するテーブルを転送する必要がありました。何も設定しない場合、insert時にエラーとなります。 例えば以下のようなテーブルをinsertした場合を考えてみます。 # IDENTITY設定されたテーブル作成 create table target_user(id integer identity primary key, name varchar ( 100 )); # データinsert insert into target_user(id,name) values ( 1 , ' username ' ); すると、以下のようなエラーメッセージがでます。 [S0001][544] Cannot insert explicit value for identity column in table 'target_user' when IDENTITY_INSERT is set to OFF. IDENTITY_INSERT設定がOFFの場合、IDENTITY設定のカラムの値は自動付与するので設定できないというエラーです。例だと、id=1の指定を外せばinsertができます。 今回はIDENTITY設定をされたカラムの値もそのまま転送したかったので、以下のようなステートメントを実行して、IDENTITY設定カラムに値を指定できるように設定し直す必要がありました。 SET IDENTITY_INSERT target_user ON ; 同一セッション内で SET IDENTITY_INSERT を実行する方法が見つからなかったため、Embulk利用を断念しました。そのため、SQL Serverのデータはbcp(bulk copy program)を利用して転送する事にしました。 bcpを使ったデータ転送 bcp は一括でデータをコピーするユーティリティです。インポート時に -E オプションをつける事で、IDENTITY設定カラムでも値を割り当てずにデータファイルの値を利用するため、転送が可能になります。 さらにデフォルトではトリガーを実行しないため、ヒントオプション -h に FIRE_TRIGGES 引数を指定する事でインポート時にinsertトリガーを実行します。 インポート前に重複データを削除する際にはsqlcmdを利用しています。sqlcmdはコマンドラインからSQL文を実行できるユーティリティです。 以下のようなシェルを用意する事で転送を実現できました。 bcp_copy.sh #!/usr/bin/env bash set -e BIN_PATH="/opt/mssql-tools/bin" # 対象テーブル・条件のデータをエクスポート ${BIN_PATH}/bcp "SELECT * FROM rtm.dbo.${table_name} WHERE ${where}" queryout data.dat -N -S ${sqlserver_host},${sqlserver_port} -d rtm -U digdag -P ${secret_db_sqlserver_password} # 重複エラーにならないよう、同条件のデータを削除 ${BIN_PATH}/sqlcmd -S ${staging_sqlserver_host},${staging_sqlserver_port} -d rtm -U ${staging_sqlserver_user} -P ${secret_db_sqlserver_stg_password} -Q "DELETE FROM rtm.dbo.${table_name} WHERE ${where}" # エクスポートしたデータをインポート ${BIN_PATH}/bcp rtm.dbo.${table_name} in data.dat -E -N -S ${staging_sqlserver_host},${staging_sqlserver_port} -U ${staging_sqlserver_user} -P ${secret_db_sqlserver_stg_password} -h FIRE_TRIGGERS copy_sqlserver.dig _export : table_name : ${condition.table_name} where : ${condition.where} docker : image : ${mssql-tools_image} sh> : tasks/bcp_copy.sh ymlファイルを利用したデータ定義 bcpのシェルに記載の ${table_name} と ${where} に入る情報は、ymlファイル内に以下のようなjson形式でキャンペーンのケース毎にテーブル、条件を持たせています。 target.yml - { table_name : User, where : user_id = 1111111 } # 担当者のuser_id - { table_name : Item, where : 'item_id in (10000, 100001, 100002)' } # ランキングで利用する商品 SQL Serverデータ転送の場合、このtarget.ymlに検証データを定義しています。 ymlに記載した情報はRubyで以下のようにjson情報をパラメータとして渡します。 query_parameter.rb require ' yaml ' module Tasks class QueryParameter def load # digファイルで定義したcampaign_idパラメータ campaign_id = Digdag .env.params.fetch( " campaign_id " , 0 ) # データの定義ファイルを読み込む File .open( " config/ #{ campaign_id } /sqlserver/target.yml " , " r " ) do |f| params = YAML .load(f, symbolize_names : true ) # 検証データの条件を後続タスク(bcp)で利用するため、Digdagのstoreパラメータとして格納 Digdag .env.store( " sqlserver_conditions " .to_sym => params) end end end end query_parameter.dig _export : docker : image : ruby:2.6.1 rb> : Tasks::QueryParameter.load require : 'tasks/query_parameter' これらをdigファイルに記載してデータ転送を実現します。 TestDataCampaignA_sqlserver.dig _export : wf : name : Transfer production data to staging for testing campaignA campaign_id : campaignA +prod_to_staging : +load_sqlserver_params : !include : sub/query_paramter.dig +loop_sqlserver : for_each> : condition : ${sqlserver_conditions} _do : +run_sqlserver_copy : !include : sub/copy_sqlserver.dig 以上がSQL Serverのデータ転送方法です。 これらのデータ転送機能を運用する中で以下の利点がありました。 検証データの定義を保持するドキュメントとしての有用性 データ転送機能で実装したクエリは、検証データの定義を保持するドキュメントとしても有用でした。 同じキャンペーン運用中に何か追加改修が必要になった場合は、同一データまたは追加の検証ケースを追記して検証ができるため、追加改修時の効率化も行えます。 私たちの場合は現在と未来の実装を正確に素早く行うための手段として、Digdagのデータ転送機能は適した手段でした。 改善した検証環境で検証を行うメリット 今回の経験により、整備された検証環境で検証を行うメリットとして以下が挙げられます。 本番稼働しているものを改修する際の検証が楽になる 修正と確認のリードタイムが短くなる 余計な監視通知を抑制できる 気付きが多くなる 本番稼働しているものを改修する際の検証が楽になる 検証環境で本番環境と同様の検証ができるようになった結果、既に動いてるキャンペーンに影響しないように検証するための試行錯誤を本番環境でする必要はなくなりました。 修正と確認のリードタイムが短くなる 事前に検証環境で検証ができるようになった結果、事前検証が可能になりリリース時の確認点を減らすことができました。検証をするのは検証環境なので本番に影響を与える事なく、迅速に修正・確認を実施できます。 余計な監視通知を抑制できる 監視担当に余計な負荷をかける行為からも脱却する事ができるため、全体の効率化にもつながります。 気付きが多くなる 作業効率が良くなり作業時間に余裕が生まれる事で本来考えるべき事へ集中できるようになります。実際に、検証環境ができてから仕様の過不足や必要な検証ケースについて担当者とやり取りをする機会が増えました。そのためキャンペーンの品質担保へも貢献できていると言えます。 以上のように、整備された検証環境の存在によって品質と効率を上げる事ができました。 まとめ 成功まで何度も失敗を繰り返すことのできる検証環境は、システムに慣れている人と慣れていない人の差をカバーする手段として大きく機能しました。 検証環境を利用した後の効率低下のリスクについては、CircleCIやDigdagを利用して効率化を上げる事でカバーできました。本番リリース特有の確認時間が減り、余計な監視通知もなくなるため、全体効率は上がっていると言えます。 さらにDigdagによるデータ転送自動化は、効率化だけでなく品質担保にも繋がりました。 皆さんも様々な方法で品質と効率のバランスを保つために試行錯誤していると思います。私たちのこういった運用改善が少しでも皆さんのお役に立てば幸いです。 おわりに 私たちは他にも多くのマーケティングに関わるシステムを開発・運用しています。今回のような環境整備も含めて、既存システムの品質と効率のバランスを保ちながら、新しい施策のための新規機能やリプレイスを検討しています。 興味を持たれた方は採用ページ等をチェックしてみてください! https://hrmos.co/pages/zozo/jobs/0000016 hrmos.co
アバター
こんにちは、ZOZOテクノロジーズ CTO室の池田( @ikenyal )です。 エンジニアが12月に思い浮かべるキーワードは何でしょう。「アドベントカレンダー」ですね。 弊社も毎年アドベントカレンダーに参加しており、今年も記事100本の公開を完走しましたので、概要をお伝えします。 ZOZOテクノロジーズ Advent Calendar 2020 今年は合計4個のカレンダーを実施したため、12/1-25の期間に合計100本の記事を公開しました。 qiita.com qiita.com qiita.com qiita.com 実施概要 アドベントカレンダーは任意参加で実施しています。 アドベントカレンダーはエンジニアのアウトプットの練習に適したイベントです。弊社ではテックブログをアウトプットの主軸に置いていますが、「テックブログを書く自信が無い」「テックブログに書くにはネタが小粒」のような場合に、アドベントカレンダーは良い機会になります。 なお、テックブログ上に記事を公開してアドベントカレンダーとして登録した記事も3本ありました。 人気があった記事 はてなブックマークのブックマーク数が上位だったものを紹介します。 12/4の #3 の記事、 takewell による「 2020 年の瀬の JS ビルド&バンドルツールの検討 」 zenn.dev webpack (シェア 76%)を代表とする ESNext (ECMAScript Next Generation) なコードをレガシーブラウザにビルドしたり、コードを単一ファイルにバンドルしたりするツールが数多く存在します。(以降、ビルド&バンドルなどの事前変換処理をプリプロセスと表記します。) これらプリプロセスツールに関して ”アプリには webpack、ライブラリには rollup.js” という常套句があります。しかし、この常套句は本当に妥当なのでしょうか? また、今年は新たなるプロプロセスツールとして snowpack や Rome などが登場しました。こうしたツールの登場があっても、この常套句は今尚通用するのでしょうか? 本稿では、これらの疑問と 2020 年年末時点のプロプロセスツールを調べ、将来性や使い分けについて、それぞれ検討します。 12/5の #3 の記事、 cozima による「 OSSへの貢献 - Issueから始めるチーム活動 」 techblog.zozo.com この記事では、今年4月に社内で策定されたOSSポリシーに基づいて、チームでOSSに貢献する活動に取り組んだ話を紹介します。 12/13の #3 の記事、 takewell による「 WebAssembly の利用シナリオを調べる 」 zenn.dev wasm でできるようになること or これまでネイティブでしか実用性の観点から実現できなかったことが wasm を使ってブラウザでも実現できるようになることはなんなのか調べてみました。 本稿はそれらのまとめと、使いどころがあるかもしれないと考えた 3 つの wasm ライブラリについて紹介します。 12/25の #2 の記事、 sonots による「 ZOZO プラットフォームSREとコロナ禍におけるチームリーディング術 」 sonots.medium.com 約一年前の2020年1月に SRE Next 2020 にて ZOZO MLOps のチームリーディングとSRE (Engineering) というタイトルで私のチームリーディング術についての発表を行いました。それから約一年が経とうとしているので、一年の振り返りの意味も含めて、アップデートしたいと思います。 12/25の #3 の記事、 kyuns による「 ZOZOテクノロジーズの2020年の振り返りと現状 」 techblog.zozo.com 毎年アドベントカレンダーの25日目にZOZOテクノロジーズの1年を僕がまとめて記事にする、というのがアドベントカレンダーの恒例となっていたのですが、すでに今年は上記noteにて、色々な取り組みを紹介させていただいたので、今回この記事では上記では紹介できなかった2020年の変化にフォーカスして、プロジェクトの進捗や組織の変化についてお伝えしたいと思います。 過去のアドベントカレンダー ZOZOテクノロジーズでは、2019年・2018年もアドベントカレンダーに参加しています。 ZOZOテクノロジーズ Advent Calendar 2019 qiita.com qiita.com qiita.com qiita.com qiita.com ZOZOテクノロジーズ Advent Calendar 2018 qiita.com qiita.com qiita.com 最後に ZOZOテクノロジーズでは、プロダクト開発以外にも、今回のような外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに こんにちは。SRE部BtoBチームの田村です。BtoBチームが担当してるサービスには、クラウドで構成されているFBZ、オンプレ環境で稼働しているブランド様の自社ECシステム支援事業もあります。その自社ECシステムでは、バッチ処理が多数稼働しています(執筆時点でバッチ214本)。 バッチ処理のスケジュールはWindowsのタスクスケジューラで管理しており、定期メンテナンスや新規サービスリリースの際に運用者が手作業でタスクスケジューラの設定を更新していました。 手作業を伴い、繰り返される要素もあったため、我々のチーム内で「バッチの運用」はトイルの1つとして認識されていました。また、 前回の記事 と同様に、設定戻し漏れのリスク、作業工程の履歴を別帳票で管理する煩雑な運用があり、万全とは言えない状態でした。そこで、前回の記事をヒントにトイル撲滅運動としてタスクスケジューラ設定の自動反映の仕組みを構築したので、その方法について共有します。 目指す姿 今回目指す、GitHub ActionsによるWindowsタスクスケジューラ自動反映のフローは以下の通りです。 開発者によるPull Request作成 承認者によるレビューとマージ実行 Pull Requestマージイベントによる発火 セルフホストランナーでワークフロー実行 Windowsタスクスケジューラへの反映 実現するために対応したこと 目指す姿を実現するために以下のことを対応しました。 セルフホストランナーの導入 セルフホストランナーにschtasksコマンド実行権限を付与 ワークフロー設定 タスクスケジューラ自動反映スクリプト作成 セルフホストランナーの導入 GitHub Actionsワークフローを独自環境で実行できるセルフホストランナーを導入します。今回は、Windowsタスクスケジューラ設定を反映する必要のある、開発環境と本番環境のサーバーに導入しました。 導入手順は、公式サイトの セルフホストランナーの追加 をご確認ください。 なお、「パブリックリポジトリ」でのセルフホストランナーの利用は推奨されておりません。必ず公式の情報を確認のうえ、十分に検証したうえでご利用ください。 カスタムラベルの登録 セルフホストランナーに対してカスタムラベルを設定することで、開発環境と本番環境どちらで実行させるかを指定できます。開発環境では dev というカスタムラベルを付け、ワークフローで以下のように設定すると開発環境のランナーで実行できます。追加手順は、公式サイトの セルフホストランナーとのラベルの利用 をご確認ください。 セルフホストランナーにschtasksコマンド実行権限を付与 セルフホストランナー導入直後はschtasksコマンドの実行権限がないため、下記手順で実行権限を付与します。 セルフホストランナー登録時に作成したactions-runnerディレクトリのプロパティを確認 セキュリティタブに追加されている GITHUB_ActionsRunner_hoge のユーザーを確認 C:\Windows\System32\Tasks に GITHUB_ActionsRunner_hoge のフルアクセス権限を付与 ワークフロー設定 ワークフローの内容は以下の通りです。 特定ブランチに対するPull Requestイベントかつ、特定ファイルに変更があった場合のみ実行 追加・変更されたファイル差分を取得 反映コマンド確認用のスクリプト実行 Pull Requestがマージされたら自動反映スクリプトを実行 下記の設定は、ワークフローファイルのサンプルの一部です。 name : Apply task scheduler (dev_server) # 特定ブランチに対するPull Requestイベント、特定ファイルの変更のみ実行 on : pull_request : types : [ opened, synchronize, closed ] branches : - 'hoge_branch' paths : - 'hoge_path' jobs : schtasks : # 開発環境ホストランナー runs-on : [ self-hosted, windows, x64, dev ] steps : - name : Checkout uses : actions/checkout@v2 # git diff時の文字化け解消 - name : Git config quotepath run : git config --local core.quotepath false # Pull Request baseブランチフェッチ - name : Fetch base_sha run : git fetch origin ${{ github.event.pull_request.base.sha }} # 差分ファイルリストをdiff.txtへ出力 - name : Output diff.txt run : git diff ${{ github.event.pull_request.base.sha }} --diff-filter=ACMR --name-only > diff.txt # 反映コマンドの確認(マージ前チェック用:schtasksコマンドの出力) - name : Check command run : tasks/check.ps1 # マージされたら、反映スクリプト実行 - name : Apply if : github.event.pull_request.merged == true run : tasks/apply.ps1 タスクスケジューラ自動反映スクリプト作成 必要なschtasksコマンドを生成して実行する「自動反映スクリプト」はPowerShellで書きました。同リポジトリ内に反映スクリプトファイルを置き、ワークフローから実行するようにしています。反映スクリプトの処理の流れは以下の通りです。 必要なファイル差分リストの取得 反映コマンドの作成 反映コマンドの実行 以下は、実際にGitHub Actionsが実行された結果です。 運用について BtoBチームではリリース可能なメンバーは少数に限定しており、リリース内容のレビューを実施する実質的な承認者をリリーサーと呼んでいます。リリーサーのみに自動反映スクリプトの実行権限を与えるため、特定ブランチへのマージ権限をリリーサーに限定しました。また、開発者はそのブランチに向けてPull Requestを作成してもらうことにしました。 リリーサーがPull Requestのレビューとマージをすることでタスクスケジューラ反映の自動化を実現しています。これにより、本番環境への反映が必ず承認者を経由し実施される安全運用となっています。 まとめ 今回の対応によって冒頭で申し上げたような計画的なタスクスケジューラ設定についてはある程度自動化できました。ただ、緊急時においては対応スピードの観点で要件を満たさないため、どうしても手作業での設定変更は残ってしまいます。ベターな対応について運用面含め検討は続きます。 また、我々のプロダクトではリリースを含め、まだまだ手作業を必要とするものが残っています。今回の取り組みで、手作業部分を解消し自動反映後の監視の仕組み強化など、本来注力したいことに対しエンジニアの労力を割くいい流れができました。 すべてを自動化することはできませんが、手作業を伴って繰り返されるタスクについて着目し、定期的に仕組み化を検討するトイル撲滅運動を継続していきます。 さいごに ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは、ZOZOテクノロジーズ CTOの今村( @kyuns )です。この記事はZOZOテクノロジーズ Advent Calendar 2020 #3の25日目の記事になります。今年はZOZOテクノロジーズとして4つのアドベントカレンダー、全100個の記事がありますので、ぜひご覧ください。ちなみに前日の記事は @sashihara_jp の「 コロナ禍の中のリモートワークでの弊社各チームのマネジメントの工夫について 」でした。 CTOとしての2年半の取り組みに関しては、先日公開した記事でも紹介していますので、そちらもご覧ください。 note.com ちなみに、毎年アドベントカレンダーの25日目にZOZOテクノロジーズの1年を僕がまとめて記事にする、というのがアドベントカレンダーの恒例となっていたのですが、すでに今年は上記noteにて、色々な取り組みを紹介させていただいたので、今回この記事では上記では紹介できなかった2020年の変化にフォーカスして、プロジェクトの進捗や組織の変化についてお伝えしたいと思います。 会社の状況 ヤフーとのPMI(Post Merger Integration) 昨年の 2019年まとめブログ でも紹介したように、Zホールディングス(以降、ZHD)によるTOBが成立し、ZOZOはZHDの子会社となりました。 また、今までZOZOの代表を務めていた前澤が引退、澤田社長の新体制になり新しいZOZOの歴史がはじまった2020年でした。買収の大きな目的にも両社でシナジーを生んでいくということが、重要となっていたため、2020年はZHD・ヤフーとZOZOとのシナジーを少しずつ模索していく1年でした。 ヤフーとの取り組み PayPayモール ZOZOTOWN 2019年12月には、PayPayモールの中にZOZOTOWNがオープンしました。 一見、「同じものが買えるのになぜPayPayモールに出店?」と思うかもしれませんが、ヤフーとZOZOTOWNでは、そもそも存在するユーザー層が違うため、我々としては新しいユーザーへのリーチが望めます。プロジェクト自体もヤフーと初の共同プロジェクトということもあり、お互いに尽力してかなり短い期間で実現することができ、両社の仲が深まった案件でした。こちらも順調に売上が伸びてきています。 PayPay連携 ZOZOTOWNでPayPay決済が利用できるようになりました。導入直後から決済手段として選ばれており、PayPayの人気の高さが伺えます。 検索連携 ZOZOTOWNの大規模なデータや行動ログを活用して、キーワード検索時のサジェストや検索結果の改善を進めました。 こちらの取り組みの一環として、ヤフーと共同で機械学習を用いた検索結果の改善も進めています。 また、ヤフーと共同でZOZOTOWNの商品をヤフーの検索結果に表示する新たな施策も開始され、リリースされました。 その結果、ヤフーからZOZOTOWNの導線がより良い形となりました。 エンジニアリングリソース支援 合流したからにはお互いのエンジニアの交流やリソース支援を柔軟に行っていきたいと考えています。 ヤフーには特に検索周りで非常にノウハウを持っているエンジニアがいます。現在ヤフーから数名のエンジニアに出向してもらい、共同でZOZOTOWNの検索エンジンの改善を行っています。我々も検索特化のエンジニアなどが少ない現状ですので、このようなスキル特化型人材のリソース支援は非常にありがたく、双方にとって非常に良い取り組みとなっています。 新型コロナウイルスの影響 今年は新型コロナウイルスの影響が非常に大きい1年でした。 ファッションブランドさんや店鋪が大打撃を受ける中、我々としても、「なんとしてでもサイトの運営を続けなければならない」という思いのもと、安全に業務が行えるように3月からオフィスを閉鎖し、フルリモート勤務への体制へと切り替えました。 幸い2年前から東京オリンピックを見据えて、リモートの準備を整えていたおかげで、最初の頃は若干の混乱がありましたが、1,2ヶ月経った頃にはZOZOとZOZOテクノロジーズともにリモートでの勤務ができる状態になりました。 プロジェクト紹介 2020年にもZOZOが展開する各プロジェクトで色々な進展がありました。現在進行中のプロジェクトの進捗をいくつか紹介したいと思います。 ZOZOTOWNリプレイス ZOZOTOWNリプレイスに関しては、2020年1月にプロジェクトを仕切り直し、新しく開発ロードマップを引き直しました。振り返ってみると、ロードマップ通りの進捗ができた1年だったと思います。 主な成果 VMware Cloud on AWSによるスケーラブルなインフラの実現 検索エンジンのSQL Server脱却、全面Elasticsearch化による検索速度劇的改善 マルチクラウド廃止によるコスト削減 ID認証基盤リプレイスによる安定稼働 API Gateway化 APIガイドライン策定 Infrastructure as Code、CI/CD環境整備 詳しくはこちらの記事をご覧ください。 speakerdeck.com 基幹システム ZOZOBASEにおけるオペレーションや、基幹システム周りの作業効率化についても改善を繰り返しました。 例えばヤマトさんとデータ連携を行い、配送ステータスが可視化して見えるようになりました。 他にも、新しい倉庫の稼働の対応や、自動包装機の導入、PayPay決済導入など、非常に多くの改修改善を行った1年でした。 また、こちらのシステム自体もZOZOTOWNと同様、ZOZOTOWNのリリース当初から利用されています。そのため、リプレイスにも着手して、VBScript / SQL Server / IISの仕組みをクラウドに持っていったり、別のデータベースや、別の言語に置き換えるような取り組みも開始できています。 MA(マーケティングオートメーション) ユーザーコミュニケーション周りも色々な内製化が進んだ1年でした。 ZOZOTOWNやWEARのプッシュ通知の仕組みは、以前は3rdパーティ製のものを利用していたのですが、それらを内製化、FCMを利用したものに置き換えました。これにより、より正確なプッシュ通知のデータを取得することができるようになりました。 また、ZOZOTOWNのLINEアカウントを運用するためのツールをLINE Official Account Managerから新しく作った内製化ツールに置き換え、より柔軟に、リッチにユーザーコミュニケーションを実現できるようになりました。 また、機械学習によるスコアリングを活用し、新規出店したブランドの効率的な顧客コミュニケーション施策を行いました。GoogleのCloud AutoML Tablesを活用することで、機械学習モデル作成の大部分を自動化し、非エンジニアでも検証したいターゲット変数を設定してモデルを作成できる仕組みを整えました。 www.tsuhanshimbun.com ZOZOMAT 今年3月、昨年から予約を受け付けていたZOZOMATをリリースしました。 「ZOZOSUITの次は何か」と期待されていたものですが、次に我々がターゲットとしたのは足の計測でした。 こちらは自分の足のサイズを正確に計測できる仕組みとなっており、計測後、自分の足にフィットする靴を探すことができるようになっています。 また、ZOZOMAT対応シューズの型数も現在ではリリース時よりも10倍以上に増えており、今後も対応型は増えていく予定です。 ZOZOMATの良さはやはりその計測精度だと思います。精度に関してのクレームはほとんどないぐらいまで誤差無く測れる状態となっています。ZOZOSUITのときの開発プロセスの失敗などを活かし、計測チームの頑張りのおかげで、かなりクオリティの高いものができていると思います。 こちらもすでに100万人以上の方が計測を行ってくれています。 ZOZOSUIT 2 先日の決算発表にて、 ZOZOSUIT 2を発表 いたしました。 「ZOZOSUITって無くなったんじゃないの!?」と世間では思われていたと思います。しかしながら実は水面下でZOZOSUIT Ver.2の開発を進めていました。そして、前回よりもはるかに計測精度の高いZOZOSUITを完成させました。 今回我々はいきなりユーザーに配布するのではなく、まずは我々と一緒になってこのZOZOSUIT 2の可能性を模索してくれるパートナーを募集することにしました。一般に出回るかどうかはまだわかりませんが、さらなる計測精度の向上、そして新しい分野への応用ということにチャレンジしていきたいと思います。 LP の最後には「ZOZOXXXXX COMING SOON」という文字が見えますが、果たしてこれは何でしょうね...! YOUR BRAND PROJECT / D2C 10月には「 YOUR BRAND PROJECT by ZOZO 」というインフルエンサーの方々と共同でファッションブランドを立ち上げる新規事業を開始しました。ブランドの立ち上げにおける商品の企画や製造、生産、販売、物流、カスタマーサポートなどの運営を全面的に我々が支援するプロジェクトとなっています。 例えば丸山礼さんのブランドなどは、わずか5分で全商品が完売するなど、好調な出だしを見せています。 BtoB事業 今年の4月には 「Fulfillment by ZOZO」 (以降、FBZ)というブランド様向けのBtoB事業を行っていた子会社のアラタナをZOZOとZOZOテクノロジーズへと吸収合併しました。 FBZはZOZOTOWN出店企業の自社ECのフルフィルメント支援サービスで、自社EC運営のための撮影・採寸・梱包・配送などの各種フルフィルメント業務をZOZOTOWNの物流センター「ZOZOBASE」が受託し、設備投資や人件費、在庫保管料などの負担なしに、自社ECの運営が可能なサービスとなっています。 今年もクライアント数が増え、取扱高は過去最高を更新し続けており、現在ではラルフローレンさんやUNITED ARROWSさんなど、50以上のブランド様のサイトの運営業績好調で、取扱高は過去最高を更新し続けています。 ZOZO研究所 WEARのデータを使った取り組み WEARの大規模データを使った取り組みを進めました。4月には 髪型別コーデ検索機能 をラボページにてリリース。機械学習を用いて投稿写真内の髪型を使って投稿を検索できるようにしました。 ZOZOならではの研究成果だなと思いますし、AIを用いた画像検索だけでなく、九州工業大と共同研究しているような基礎技術も用いられています。詳しい制作秘話は 研究所メンバーのインタビュー記事 をご覧ください。 また、定期的に 調査リリース を掲載。WEARの大規模データを活用して流行の変遷をデータから読み解いていきました。 慶應義塾大学との共同研究 慶應義塾大学と 「ファッションに特化したIoTノード開発および グラフィカルユーザーインターフェースの設計に関する共同研究」 を開始しました。こちらの研究では主に以下の2つのことを目指します。 ファッションアイテムの見た目に溶け込む各種 IoTノードの開発 ファッションアイテムにセンサーやアクチュエータなどの機能を実装した小型デバイスの開発を行います。これらの開発したデバイス間のデータのやりとりや充電方法、配線のあり方について、ユーザーの服飾習慣に調和するソリューションをデザインと技術の両面より探索します。 デザイナーやユーザーが手軽に開発できる設計環境の開発 ウェアラブルプロダクトの開発者以外でも、これらの機能を手軽に実装できるソフトウェアのプロトタイプや、本システムを通じて作成したプロダクトのユースケース探索などを行います。具体的には、提供するプラットフォームを用いてデザイナーやユーザーが簡単に開発することができ、自身の希望に寄り添ったカスタマイズが可能となるデバイスの設計を目標とします。 ECCV採択 今年は研究成果として、ZOZO研究所の斎藤侑輝、中村拓磨、共同研究者で和歌山大学講師である八谷大岳氏、統計数理研究所・総合研究大学院大学教授 福水健次氏(斎藤の博士課程指導教員)の書いた「Exchangeable Deep Neural Networks for Set-to-Set Matching and Learning」という論文がECCVに採択されました。 こちらは以前から研究を続けてきた集合マッチングをテーマとしており、今後のZOZOTOWNの推薦アルゴリズムにも活かされる機会が出てくるでしょう。 詳しいアルゴリズムはブログにて解説しています。 techblog.zozo.com Open Bandit Data & Pipelineの公開 8月には、Open Bandit Dataとして、ZOZOTOWN上での実際の推薦アルゴリズムから取得された 2,800万件超のファッション推薦データを公開しました。 また、データだけでなく、オフライン検証を行うのにも役立つPipelineも同様に公開しました。 本取り組みに関する研究成果として、トップ国際会議のワークショップ(ICML、RecSys、NeuIPS)を含む国内外の多くの場で発表しています。 このように、サービスにおける実際のデータを公開していくような取り組みも始めています。 GitHub - st-tech/zr-obp: Open Bandit Pipeline: a python library for bandit algorithms and off-policy evaluation 画像検索 2019年にリリースされた「画像検索機能」では、2020年7月に新たに「財布」で画像検索機能が使えるようになりました。 今後も、水着・浴衣など画像検索対応カテゴリーの拡充を行なっていくと同時に、より良い検索体験の提供のために精度や使い勝手の向上を重ねていきます。 コーポレートエンジニアリング 今回新型コロナウイルスの影響により、最も忙しかった部門だと思います。社員全員がリモートワークできるように、様々な仕組みを整えました。 詳しくは note の方をご覧ください。 Azure ADを中心とした認証基盤の構築 VPNの仕組み刷新+ゼロトラストベースのアクセスの仕組みの構築 オンプレファイルサーバーの廃止 MDM、EDRの刷新、CASBの構築 各種ルールの制定 組織の変化 2020年は組織においても、大きな変化がありました。 リプレイスを見据えた組織へ ZOZOTOWNリプレイスを進めていく上で、どのような組織体制がベストなのかを考えた結果、実現したいシステムアーキテクチャに即した形で組織を本部に分けました。エンジニアの数も300名を超え、1つの開発部にまとめるのは限界だったので、丁度よいタイミングでした。 技術推進室の設置 ZOZOTOWNリプレイスを進めていくにあたって、非常に多くのことを決めていく必要性があります。そこで、技術開発本部の中に技術推進室を作り、ZOZOTOWNリプレイスに関する様々なことをとりまとめるようにしました。 全社横断して仕様を決めないといけない部分や、方向性を統一しないといけないようなものの調整をこのチームが担っています。 例えばAPIを開発するときのガイドラインやデータベース設計のガイドライン、APIとしての、SLAやSLOを確認できるダッシュボードの設定や、個人情報へアクセスする際のフローの整備などチーム横断でのとりまとめなどを、CTO室との連携を行い、スムーズに各チームが開発できるような調整を行っています。 CISO室およびZOZOグループリスクマネジメント委員会の設置 昨年ZOZO CSIRT組織を立ち上げて社内のセキュリティリスクの洗い出しやインシデント対応などを取りまとめてきましたが、ZHD傘下となったこともあり、更にセキュリティやリスクマネジメントを強化していく必要性が出てきました。 システムにおけるセキュリティリスクだけではなく、ERMの観点などからも対応していく必要があったので、CISO室を設置し、ZOZOグループリスクマネジメント委員会を発足しました。会社におけるリスクというのは多岐にわたります。それぞれ分科会という形で、各分野ごとに対応をしていく体制を整えました。 今年を振り返ってみて 2020年は1年のうち3/4がリモートでの仕事でしたが、生産性を著しく落とすこと無く業務ができた1年でした。新型コロナウイルスの影響を大きく受けましたが、業績自体はオンラインショッピングの需要の高まりの影響も受け、好調を維持できています。我々の働き方自体も大きくアップデートされた1年でした。 来年には新しく西千葉オフィスができたりしますが、オンラインとオフラインをうまく使い分けながら、また世間をあっと驚かせるような「想像のナナメウエ」の取り組みをしていきたいと思います。 ZOZOテクノロジーズもこの1年で大きくファッションテックカンパニーへと変化を遂げれたと思います。そんな変化が激しいZOZOテクノロジーズ を一緒に盛り上げてくれる仲間も絶賛募集中です。 この記事を読んで、ZOZOテクノロジーズに応募してみたいと思ったエンジニアの方は下記の採用ページからぜひご応募ください。 その際に「テックブログみました」と書いていただけると幸いです。 tech.zozo.com
アバター
こんにちは、CTO室兼SRE部テックリードの光野(kotatsu360)です。AWS・ウィスキー・葉巻が好きです。 普段はAWSのアカウントが複数ある状況(マルチアカウント環境)において、セキュリティや品質の維持をどのように行うかについて取り組んでいます。色々と資料も公開しているので、よろしければご覧ください。 speakerdeck.com さて、本記事では AWS re:Invent 2020 を取り上げます。今年もre:Inventに合わせ大量のリリース・アップデートが行われました。リリースは180を超え、楽しくもキャッチアップに奔走しております。この大量のリリースの中から、弊社の状況を鑑みて特に目を引いたものをピックアップします。 AWS re:Invent 2020 今年のアツいアップデート5選 VPC Reachability Analyzer S3で強い書き込み後の読み込み整合性がサポート Babelfish for Aurora PostgreSQL Amazon ECR Public AWS Systems Manager Fleet Manager まとめ We are hiring AWS re:Invent 2020 簡単にAWS re:Inventについて触れたいと思います。AWS re:InventはAWSが主催する、一年で最大のイベントです。 今年で9回目を迎える「AWS re:Invent」は、 AWSのクラウドサービスに関わる技術的なセミナー・ハンズオンセッションなど、 2,500を超えるセッション(2019年実績)を提供しており、お客様が主体的に体験できる、学習機会が豊富なグローバルカンファレンスです。 AWS re:Invent 今回はオンラインで無料開催!|AWS 例年、この開催直前より既存サービスのアップデートが増え、期間中は基調講演を中心に新規サービスの公開が繰り返されます。AWSに関わるエンジニアとしては目が逃せません。これまでは一貫してアメリカはラスベガスのホテルを借りてのイベントでしたが、今年はオンライン開催となりました。 re:Invent 2019は1週間のイベントでしたが、今年はオンラインということもあってか11月30日 ~ 12月18日(+ 1月12日 ~ 14日)と3週間に渡って開催されました。2021年1月にも3日間の追加開催が決まっています。 なお、ZOZOテクノロジーズは2018、2019と現地にてイベント参加を行い、2019では現地から参加レポートを公開しております。 techblog.zozo.com 今年のアツいアップデート5選 まさかのMacインスタンス提供 1 から始まったre:Invent 2020ですが、ここでは特に私個人としてアツいアップデートを紹介したいと思います。 記事の量・新鮮さでいえば期間中ほぼリアルタイムで更新をされ続けているクラスメソッドさんの Developers.IO が何よりも圧倒的です 2 。ここでは、ZOZOテクノロジーズという組織にとってそれはどのような課題を解決するのか、どのような意味を持つのかについて着目してまとめています。日常業務で、マルチアカウント管理に携わっているということもあり、コンプラや運用系サービスが多めです。予めご了承ください。 VPC Reachability Analyzer aws.amazon.com 1つ目はVPC Reachability Analyzerです。VPC内のリソース同士を指定して、その間が到達可能かを検証するためのサービスです。以下の画像はVPCを2つ用意し、VPC Peeringから一方のVPCに存在するEC2インスタンスまでの疎通を確認するものです。Network ACLやSecurity Group、ENIといった複数の要素を経てEC2インスタンスへ到達しています。経路上のどこかに問題があり到達できない場合、それをエラーとして表示してくれます。 弊社は、現在クラウド移行の真っ最中であり、AWS Direct Connectによるデータセンターとの閉域網接続を多用しています。一部ではAWS Transit Gatewayの利用も始まり、オンプレミスとAWS間のネットワークの到達性を確保するのが課題となっています。これまで検証用インスタンスを立ててtracerouteによる地道な確認をしていた部分も、これによってどこからどこまでなら到達するのか、その検証が簡易になることを期待しています 3 。 また、AWSらしくサービス間連携による新しい自動化についても期待が持てます。CloudWatch EventsからLambdaを経由すれば定期分析からの疎通性監視が可能となります。深夜帯に動くバッチがあるが、日中の何気ない操作で疎通が切れており、深夜の実行時に判明する。そんな問題を日中帯に検知することも可能になるやもしれません。 S3で強い書き込み後の読み込み整合性がサポート aws.amazon.com 2つ目はS3から。御存知の通り、S3では新規オブジェクトのPUTに対しては書き込み後の読み込み整合性、オブジェクトの更新・削除については結果整合性を提供してきました。そのため、オブジェクトを更新する場合、その直後のGETでは新旧2つのオブジェクトが混在する可能性を許容する必要がありました。 (上記ブログエントリより引用) しかし、今回のアップデートでこの制約が取り払われ、更新・削除についても強い書き込み後の読み込み整合性が保証されることとなります。ブログエントリには、DELETEに関する記述がありませんが公式ドキュメントにはDELETEでも強い整合性が保証されると記載されています。 Amazon S3 provides strong read-after-write consistency for PUTs and DELETEs of objects in your Amazon S3 bucket in all AWS Regions. This applies to both writes to new objects as well as PUTs that overwrite existing objects and DELETEs. In addition, read operations on Amazon S3 Select, Amazon S3 Access Control Lists, Amazon S3 Object Tags, and object metadata (e.g. HEAD object) are strongly consistent. Introduction to Amazon S3 - Amazon Simple Storage Service 弊社でもログや分析用データの保存先として必ずS3が登場します。上書きをするケースはそれほど多くないものの、アプリケーション設計においてしばしば見逃されがちな部分で手戻りの原因ともなっていました。昔からのS3ユーザとして驚くべきリリースであると同時に今後S3を利用する開発者に対してより優しいサービスになったと考えています。 Babelfish for Aurora PostgreSQL aws.amazon.com 3つ目はAndy JassyのKeynoteで発表された、Babelfish for Aurora PostgreSQLです。SQL Serverに対するクエリのみならず、トリガ・ストアドプロシージャや関数など含めて全て透過的に変換するレイヤーを提供するものです。現在はプレビュー版のため、利用にはリクエストの許可を待つ必要があります。 ( Babelfish for Aurora PostgreSQL (Preview) | Amazon Web Services より引用) Babelfish adds an endpoint to PostgreSQL that understands the SQL Server wire protocol Tabular Data Stream (TDS), as well as commonly used T-SQL commands used by SQL Server. Support for T-SQL includes elements such as the SQL dialect, cursors, catalog views, data types, triggers, stored procedures, and functions. With Babelfish enabled, you don’t have to swap out database drivers or take on the significant effort of rewriting and verifying all of your applications’ database requests. Want more PostgreSQL? You just might like Babelfish | AWS Open Source Blog AWS Database Migration Serviceとは異なり、あくまでもPostgreSQLのエンドポイントとして振る舞うというのが非常に興味深いプロダクトです。 弊社が提供する ZOZOTOWN や WEAR は、いずれもSQL ServerをDBとして利用しています。またストアドプロシージャを多用し、ビジネスロジックの多くはDB上に存在します。これはZOZOTOWNができた当時主流な設計でしたが、現在ではスケーラビリティの観点からあまり採用されない形かと思います。AWSへはリファクタリングを伴うリプレイスが行われており、そこではAmazon Aurora MySQL/PostgreSQLといったOSS互換のDBエンジンを採用しています。オンプレミスに存在するSQL Serverを将来どうするかについてはまだ検討のさなかですが、切り替えコストの小さい選択肢が増えることについて、歓迎しています。GAまで目が話せません。 Amazon ECR Public aws.amazon.com 4つ目は、コンテナイメージをパブリックに共有できるECR Publicです。11月頭にDocker Hubのrate limitsに対応するためのブログポストがありましたが、そちらでも告知されていました 4 。 ECR Publicは、Docker Hub以外の選択肢が誕生すること以上に、これによってAWSで利用されているエージェントやツールのコンテナイメージが一箇所に集約されたことを歓迎しています。弊社では可能な限りマネージドサービスを採用することで、管理範囲を狭める努力をしています。概ね問題ありませんが、LambdaのランタイムやFargateで配置されるFluent Bitが実際どのようなものか、確認に時間をかけることがありました。 これらは Amazon ECR Public Gallery として、コンテナイメージが公開されており、開発効率の向上に期待できます。 AWS Systems Manager Fleet Manager aws.amazon.com 最後は、AWS Systems Manager Fleet Managerです。SSM Agentを通じて集約した情報をOSの垣根を越えて一元的に閲覧できるサービスになります。 As described in the documentation, managed instances includes those running Windows, Linux, and macOS operating systems, in both the AWS Cloud and on-premises. Fleet Manager gives you an aggregated view of your compute instances regardless of where they exist. New – AWS Systems Manager Fleet Manager | AWS News Blog SSM Agentのドキュメントにはまだmac1インスタンスへのインストール方法が見当たりませんが、ブログエントリを見るにMacの場合でも管理できるようです。いくつかインスタンスを追加した状態が次の画像です。 詳細を見ていくと、これまでのマネージドインスタンスやインベントリといった機能を集約した新しいUIが表示されます。 またユーザ管理が可能になりました! 弊社では、先の通りDBにSQL Serverを利用しており、そこにアクセスするアプリケーションはWindows Serverの上で動作しています。リファクタリングされLinuxベースになるアプリケーションがある一方で、負荷分散のため既存アプリケーションをそのままクラウドリフトする計画も動いています。その場合、多種多様なOSがAWS上に混在するので、OS横断で俯瞰できる機能にはとても期待しています。 なお、当初ユーザが作れると聞き「これはSSH管理が変わるか!」と思いましたが、現時点ではユーザとパスワードの設定まででした。また、CLI 5 を見る限りまだAPIは公開されておらず、あくまでもWeb UIから見えるという機能になっています。今後、様々なアップデートがあるはずなので、継続的に追っていきます。 まとめ re:Inventのリリースから、独断と偏見で注目のリリースを5つ抜粋してご紹介しました。ご存じないリリースがあった場合、それらを知るきっかけになれば何よりです。 日々の構築・運用作業を助けるVPC Reachability Analyzer 着実に進歩を遂げるS3 DBエンジンの選択に新しいアプローチを提案するBabelfish for Aurora PostgreSQL コンテナ界隈に新しいパブリックレジストリを提供し、オフィシャルコンテナイメージが集約されるAmazon ECR Public EC2インスタンスに新しい可視性を提供するAWS Systems Manager Fleet Manager 新しいサービスをリリースしつつも、既存サービスの着実な改善を続けるAWS。エンジニアとして、また一ファンとして、キャッチアップと業務改善に取り組む所存です。 2021年1月の追加開催も今から楽しみでなりません! We are hiring ZOZOテクノロジーズCTO室では、世の中の情報を常に取り込み組織を変えていくことに興味がある方を探しています! 是非、以下のリンクからご応募ください! https://hrmos.co/pages/zozo/jobs/0000040 hrmos.co New – Use Amazon EC2 Mac Instances to Build & Test macOS, iOS, iPadOS, tvOS, and watchOS Apps | AWS News Blog ↩ 開催期間中および本記事の執筆でも大変お世話になっています。 AWS re:Invent 2020 の記事一覧 | Developers.IO ↩ 将来といわず、本記事の検証環境作成で早速役に立ちました・・・! ↩ Advice for customers dealing with Docker Hub rate limits, and a Coming Soon announcement | Containers ↩ ssm — AWS CLI 2.1.14 Command Reference ↩
アバター
はじめに こんにちは。ECプラットフォーム部のAPI基盤チームに所属している籏野 @gold_kou と申します。普段は、GoでAPI GatewayやID基盤(認証マイクロサービス)の開発をしています。 ZOZOテクノロジーズでは、2020年11月5日に ZOZO Technologies Meetup〜ZOZOTOWNシステムリプレイスの裏側〜 を開催しました。その中で発表された API Gatewayによるマイクロサービスへのアクセス制御 に関して、当日話せなかった内容も含めて、API Gatewayについてこの記事で網羅的にまとめました。 API Gatewayやマイクロサービスに興味ある方、「API Gateway」という言葉は知っているけど中身はよく分からないという方向けの記事なので、読んでいただけると幸いです。 はじめに ZOZOTOWNのリプレイス マイクロサービス化の目的 ストラングラーパターン API Gateway概要 API Gatewayとは マイクロサービス化による問題(API Gatewayを導入しない場合) API Gateway導入による問題 API Gatewayの自社開発 自社開発をする理由 技術スタック データストア API Gatewayの機能と設定 リバースプロキシ ルーティング ターゲットとターゲットグループ ルーティングの設定 加重ルーティング APIクライアントトークン認証 単体での認証は弱い トークンの管理 IP許可レンジ リトライ リトライ条件 リトライ先 Exponential Backoff And Jitter タイムアウト メンバー認証 トレースIDの付与 開発で工夫したこと コンフィグファイルのスキーマ検証と仕様書作成 リクエスト中断時の処理 テスト 開発用パラメータの導入 ローカル動作検証でのマイクロサービスのモック テストコード中のマイクロサービスのモック シンプルなモック タイムアウトのモック 分析・監視 Athena CloudWatch Alarm Datadog APM スパンの開始 スパンタグの付与 Sentry Sentryへのエラー情報送信 秘匿情報を取り除く PagerDuty API Gatewayの現状とこれから We are hiring ZOZOTOWNのリプレイス ZOZOTOWNがこれからも成長を続けるために、開発効率・運用性・拡張性・柔軟性・回復性の確保を見据えて、技術面や環境面でも刷新するためのリプレイスを進めています。 マイクロサービス化の目的 ZOZOTOWNの開発では、レガシシステムのリプレイスに伴い、モノリシックな開発からマイクロサービス開発への移行を推進しています。ただし、マイクロサービス化はあくまで手段であり、それ自体は我々の目的ではありません。マイクロサービス化の過程において、健全な開発組織の文化醸成を行い、最終的には組織全体のパフォーマンスの向上を目的としています。 ストラングラーパターン ZOZOTOWNは最初にリリースされてから15年以上が経過しています。大規模なZOZOTOWNを一度に全てリプレイスするのは困難です。 そこで、 ストラングラーパターン を採用しています。ストラングラーパターンは、レガシシステムを徐々に新しいシステムに置き換えて移行する方法です。今回ご紹介するAPI Gatewayがストラングラーファサードの役割を担っています。 API Gateway概要 そもそものAPI Gatewayについて説明します。 API Gatewayとは API Gatewayとは、クライアントとAPI群の間に設置される、APIリクエストを各アプリケーション(マイクロサービスおよびレガシアプリケーション)へルーティングするアプリケーションです。 以下は、API Gatewayを使用した、ZOZOTOWNのマイクロサービス化の一例を示した図です。 マイクロサービス化による問題(API Gatewayを導入しない場合) マイクロサービス化自体は、API Gatewayやサービスメッシュを導入せずとも可能です。しかしながら、下記の問題を抱える可能性があります。 マイクロサービス側で同じような処理が複数箇所で実装される 認証/認可 クライアント側で同じようなリクエスト制御が複数箇所で実装される リトライ タイムアウト クライアントとマイクロサービス間のネットワークラウンドトリップによりレスポンス速度が低下する マイクロサービス側の変更がクライアント側に影響しやすい マイクロサービスを外部公開することになる トレーサビリティが低下する API Gatewayを導入することで上記の問題を解決できます。 参考: API ゲートウェイ パターンと、クライアントからマイクロサービスへの直接通信との比較 API Gateway導入による問題 一方で、API Gateway導入による問題もあります。 可用性低下 単一障害点の増加 性能低下 API Gateway通過時のルーティング処理コスト 通信回数の増加の可能性(1APIリクエストのみの場合) スケーリングが間に合わない場合はAPI Gatewayがボトルネックになる可能性 コスト増加 開発する場合は開発コスト 既存サービスを利用する場合はそのサービスの学習コスト 運用コスト インフラコスト 参考: API ゲートウェイ パターンの欠点 API Gatewayの自社開発 ZOZOTOWNの開発では、API Gatewayを自社開発しています。 自社開発をする理由 「API Gateway」といえば、 Amazon API Gateway やOSSの Kong が有名です。しかしながら、今回はマイクロサービス化の開発中に発生する、様々な要求に柔軟に素早く対応するため、自分たちで開発することにしました。 例えば、自分たちが必要としているリトライやタイムアウトの細かい制御機能は、少なくとも導入検討時においては既存のものでは実現が難しそうでした。カスタムのプラグインなどを開発すれば要件を満たせますが、Lua/C++/Lambdaなどを駆使してスピード感を持って開発できるエンジニアがチームにいませんでした。 また、API Gateway導入時点ではID基盤側の要件が定まりきっておらず、多くの変更が発生しても柔軟に対応できる必要がありました。ID基盤は認証マイクロサービスで、ID基盤が発行したトークンの検証などをAPI Gatewayで処理しています。 加えて、開発当初、API Gatewayは全てのAPIリクエスト(マイクロサービス間も含む)がAPI Gatewayを経由することを想定していました。したがって、リクエスト量に応じた従量課金のサービスは避けたかったという理由もありました。 技術スタック Go/Docker/AWS(EKSなど)/GitHub Actionsなどを使っています。 言語は実行速度や学習コストの低さなどから、Goを選択しました。 そして、API Gatewayはマイクロサービスと同様に、コンテナとしてEKS上に構築しています。 また、GitHub Actionsでは、以下のような処理を自動化しています。 テスト Dockerイメージの脆弱性診断 ECRへのイメージプッシュ デプロイ コンフィグ関連のドキュメント作成 データストア 現状、API Gatewayにデータストアは持たせていません。例えば、認証用に使用している公開鍵はPod上のオンメモリに存在しています。これは可用性や性能を意識しているためなのですが、今後どうなるかは未定です。 追加機能として、スロットリング機能の実装を検討しています。その際に、レートリミットを管理する必要があります。複数Podを考慮すると何かしらのデータストア(ElastiCacheなど)は必要になる可能性があります。 API Gatewayの機能と設定 API Gatewayに実装した機能とその設定方法について説明します。 リバースプロキシ API Gatewayの最も基本的な機能の1つとして、クライアントから来たHTTPリクエストをマイクロサービスへ転送する、リバースプロキシ機能があります。 大まかな処理の流れは以下です。 HTTPサーバを起動し、リクエストを受け付ける Goの標準パッケージ net/http の Server 型の ListenAndServe メソッドを使用 受け付けたリクエスト内容から転送先を確定する 同時に、リクエスト内容(パス、ヘッダなど)を一部加工 転送先のマイクロサービスにHTTPリクエストする Goの標準パッケージ net/http の Client 型の Do メソッドを使用 HTTPレスポンスをAPIクライアントへ返す 個人的な話ですが、最初は「リバースプロキシ機能」の開発と言われてもピンと来なかったです。しかしながら、開発していくうちに、「そうか。実体は単なるHTTPサーバとHTTPクライアントなんだな」と理解して、スッキリしました。当たり前と言えば当たり前なのですが。 ルーティング ターゲットとターゲットグループ ターゲットとターゲットグループはルーティングにおいて重要な概念です。 ターゲットは転送先の接続情報(ホストとポート)です。 ターゲットグループは、転送先であるターゲットをまとめた単位です。ターゲットグループ内ではレガシなモノリスシステムと新規のマイクロサービスを混在させるようなこともできます。 ターゲットとターゲットグループの設定には、 target_groups.yml という名前のYAMLファイルを用意します。YAMLファイル上で設定値が指定されていないものに関しては、ハードコーディングされたdefault値が適用されます。 以下は具体例です。TargetGroupAというターゲットグループの中に、target1.example.comとtarget2.example.comの2つのターゲットを指定しています。 TargetGroupA : targets : - host : target1.example.com port : 8080 - host : target2.example.com port : 8081 ルーティングの設定 routes.yml という名前のYAMLファイルを用意します。 ルーティングする送信元と送信先の情報を定義します。もしリクエスト情報が、定義された送信元情報に一致しなければ404を返します。 以下は具体例です。HTTPリクエストのパスが正規表現で ^/sample/(.+)$ に一致した場合、転送先のパスをGoの regexp.ReplaceAllString を使って、 /$1 に置き換えます。正規表現マッチした部分がURLのリライトの対象となるため、例えば /sample/hoge というパスでリクエストがきていた場合は、 /hoge に置き換えられます。 TargetGroupAに指定されたターゲットに対してラウンドロビンで転送先のターゲットを決定します。 - from : path : ^/sample/(.+)$ to : destinations : - target_group : TargetGroupA path : /$1 加重ルーティング target_groups.yml および routes.yml にて重み(weight)を設定できます。 target_groups.yml で指定する重みはターゲットに対する重みで、 routes.yml で指定する重みはターゲットグループに対する重みです。転送先の比重をコントロールすることで、加重ルーティングおよびカナリアリリースを実現できます。 例えば、 target_groups.yml でTargetGroupA内のtarget1.example.comに80%で、target2.example.comに20%の比重で振り分ける、重み付きラウンドロビンの指定が可能です。 TargetGroupA : targets : - host : target1.example.com port : 8080 weight : 4 - host : target2.example.com port : 8081 weight : 1 もし、重みを指定しない場合あるいは全てに同じ重みを指定した場合は、一般的なラウンドロビンになります。つまり、各ターゲットへ順に振り分けられます。 TargetGroupA : targets : - host : target1.example.com port : 8080 weight : 1 - host : target2.example.com port : 8081 weight : 1 APIクライアントトークン認証 APIクライアントトークンは、クライアントタイプ毎に用意したトークンです。以下のように、 api_client_tokens.yml という名前のYAMLファイルに、クライアントタイプとトークンの組み合わせを設定します。 APIクライアントトークン認証では、そのYAMLファイルの値とAPIクライアントトークン用の独自ヘッダに格納された値を比較します。 SampleClient : - abcde12345 また、 api_client_tokens.yml で定義したクライアントタイプを routes.yml の clients に指定します。 - from : path : ^/sample/(.+)$ clients : - SampleClient 単体での認証は弱い Don't rely on API keys as your only means of authentication and authorization for your APIs. Creating and using usage plans with API keys とあるように、これ単体では強い認証を提供することはありません。しかし、無いよりもあった方がセキュリティは強いです。 また、APIキー本来の目的は、トークンごとにアクセス量を制限することです。今後は、このトークンを利用して、クライアントタイプ毎のスロットリング機能の実装を考えています。 トークンの管理 実際のトークンの値は、YAMLファイルでなく、 AWS Secrets Manager で管理しています。これにより、GitHub上に秘匿情報を載せないようにしています。 AWS Secrets Managerは、SREチームのみが管理可能な状態です。管理人数をできるだけ限定することで、可能な限りセキュリティを向上しています。 IP許可レンジ ip_range_groups.yml という名前のYAMLファイルを用意します。ルーティングの送信元として許可するIP情報を指定します。 以下は具体例です。 SampleIPRange : - 127.0.0.1/32 ip_range_groups.yml で定義したIPレンジグループ名を routes.yml の ip_range_groups に指定することで、ルーティング毎に許可するAPIクライアントを指定できます。 - from : path : ^/sample/(.+)$ ip_range_groups : - SampleIPRange リトライ どのようなシステムであっても、なんらかの原因でリクエストが失敗する可能性はあります。例えば、転送先マイクロサービスの一時的なエラー、通信問題、タイムアウトなどです。その失敗をAPIクライアントへそのまま返さずに、API Gatewayとマイクロサービス間でリトライする機能です。最大の試行回数を3に設定していた場合に、1回目と2回目のAPIリクエストに失敗しても、3回目で成功すればAPIクライアントには200 OKが返ります。 リトライ条件 とはいえ、全てのAPIリクエストの失敗をリトライさせると非効率です。例えば、リクエストパラメータに不備がある場合には何度リトライさせたところで失敗になるため、待ち時間やコンピュータリソースの無駄になってしまいます。 そこで、 target_groups.yml でターゲットグループ毎にリトライ条件 retry_cases を設定できるようにしています。 HTTPレスポンスの条件がリトライ条件に一致した場合は、合計の試行回数が max_try_count の値を超えない範囲でリトライする作りにしています。 max_try_count の設定を省略した場合は、 targets で指定したターゲットの数になります。 加えて、 retry_non_idempotent により、冪等でないHTTPメソッド(POST, PATCH)に対してもリトライするかどうかを指定できます。設定を省略した場合は、falseです。 TargetGroupA : targets : - host : target1.example.com port : 8080 - host : target2.example.com port : 8081 max_try_count : 3 retry_cases : [ "server_error" , "timeout" ] retry_non_idempotent : true リトライ先 どのターゲットにリトライするかは target_groups.yml の retry_to で指定します。省略した場合は、 target_groups.yml のリストにしたがって次のターゲットにリトライします。最後のターゲットの場合は最初のターゲットになります。 TargetGroupAB : targets : - host : target-a-1.example.com port : 8080 retry_to : target-b-2.example.com - host : target-a-2.example.com port : 8080 retry_to : target-b-1.example.com - host : target-b-1.example.com port : 8080 retry_to : target-a-2.example.com - host : target-b-2.example.com port : 8080 retry_to : target-a-1.example.com Exponential Backoff And Jitter リトライする場合に、全て即時リトライとしてしまうと、リクエストの多重度が高くなってパフォーマンスの劣化に繋がります。 そこで、 Exponential Backoff And Jitter のFull Jitterというアルゴリズムを採用しています。これは、即時リトライせずに、ランダム性のある待ち時間を経てリトライする方法です。リトライする前に 0 ~ ベースインターバル * 2^試行回数 ミリ秒スリープします。つまり、リトライ回数が増えるたびにスリープ時間は長くなる可能性が高まります。また、スリープの上限(最大インターバル)を設定することもできます。上限のデフォルトはベースインターバルの10倍としています。 target_groups.yml でベースインターバル retry_base_interval と最大インターバル retry_max_interval の指定が可能です。 TargetGroupA : targets : - host : target1.example.com port : 8080 - host : target2.example.com port : 8081 retry_base_interval : 50 retry_max_interval : 500 実装の話でいうと、このようなスリープ関数を用意して、リトライ直前でこの関数を呼び出しています。 func SleepExponentialBackoffAndJitter(tryCount int , baseInterval time.Duration, maxInterval time.Duration) { interval := baseInterval * time.Duration(math.Pow( 2 , float64 (tryCount))) if interval > maxInterval { interval = maxInterval } interval = time.Duration(mathRand.Float64() * float64 (interval)) time.Sleep(interval) } タイムアウト API Gatewayのタイムアウトに関しては target_groups.yml で設定します。 TargetGroupA : targets : - host : target1.example.com port : 8080 connect_timeout : 50 read_timeout : 3000 idle_conn_timeout : 90000 - host : target2.example.com port : 8081 connect_timeout : 40 read_timeout : 2000 idle_conn_timeout : 80000 connect_timeout : 50 read_timeout : 3000 idle_conn_timeout : 90000 max_idle_conns_per_host : 2 connect_timeout は、1リクエストあたりのTCPコネクション確立までの間のタイムアウト値(ミリ秒単位)です。 read_timeout は、1リクエストあたりのリクエスト開始からレスポンスボディを読み込み終わるまでの間のタイムアウト値(ミリ秒単位)です。 idle_conn_timeout は、データが送受信されなかった場合にコネクションを維持する時間(ミリ秒単位)です。指定された時間内にデータが送受信されなかった場合、コネクションを閉じます。 max_idle_conns_per_host は、1ホストあたりに保持するアイドル状態のコネクションの最大数です。 Goの net/httpのClient を生成する時に、これらの値を利用して設定します。 また、これらの値は、 ターゲットの設定>ターゲットグループの設定>デフォルト設定 の順で優先付けされています。 メンバー認証 メンバー認証は、以下の図の流れのような、ID基盤が発行したIDトークンを利用したBearer認証です。 セキュリティ面の考慮から、本記事では詳細については割愛します。 トレースIDの付与 分散トレーシングを実現するために、API GatewayではトレースIDを発行し、リクエストヘッダに付与しています。 分散トレーシングとは、その名の通り、分散システムにおけるリクエストを追跡することです。マイクロサービスのように複数のサービスで構成される場合に、APIリクエストが複数のマイクロサービスにまたがると、トレーサビリティーの低下が懸念されます。分散トレーシングは、障害や遅延が発生した際に、どこに原因があるのかを速く正確に確認するのに役立ちます。 開発で工夫したこと コンフィグファイルのスキーマ検証と仕様書作成 以下の4項目をYAMLファイルから設定できるようにしています。API Gatewayの起動時には、これらの設定が必要です。 ターゲットとターゲットグループ ルーティング IP許可レンジ APIクライアントトークン 各YAMLファイルのスキーマ検証や仕様書作成は自動化されています。 別の記事 で詳細をまとめていますので、よろしければご覧ください。 リクエスト中断時の処理 特に、ネイティブアプリからのリクエストに関しては、ネットワークトラブルなどによる予期せぬリクエスト中断が起こり得ます。 もし、API Gatewayの処理中にクライアント側からの接続が切れた場合は、HTTPのステータスコードが460のエラーを返すように実装しています。なぜ460かというと、ALBの 仕様 に合わせているためです。 当然ながら、このエラー自体はクライアントまで返ることはないので、事実上ログ用途になっています。 テスト 開発用パラメータの導入 リクエストの時刻を改変して、動作を検証するために開発用パラメータを用意しました。 RFC 3339 の形式でヘッダに格納して使用します。例えば、IDトークンの有効期限切れの時刻を待たずに有効期限切れの動作を検証する目的として使用されます。 アプリケーションコード側では、Goの time.Now を都度呼び出す代わりに、引数で渡される context を活用してリクエスト時刻を扱っています。 開発用パラメータが指定された場合のみ、リクエスト時刻は任意の時刻に上書きされます。本番環境やステージング環境でこのヘッダが格納された場合は、無視されます。 ローカル動作検証でのマイクロサービスのモック Prism を使用して、マイクロサービスのモックを動作させています。 ローカルでAPI Gatewayの動作検証をするのに便利です。 例えば、下記のようなOpenAPIのYAMLファイルを用意します。 openapi : "3.0.0" info : version : 0.0.1 title : search service mock servers : - url : http://localhost:4011 description : local api mock server. paths : /api/v1/goods : get : responses : 200 : description : return some response. content : application/json : schema : type : object properties : status : type : string example : success docker-compose.yml では volumes で上記のYAMLファイルを指定して、 mock コマンドを指定します。 services : search : image : stoplight/prism:3 command : mock -h 0.0.0.0 /search-mock.yml volumes : - ./search-mock.yml:/search-mock.yml テストコード中のマイクロサービスのモック テストコード内におけるマイクロサービスのモックにはnginxを利用しています。 シンプルなモック test.conf にモックの定義をします。例えば、 search:4010 の /api/v1/goods にリクエストが来たら200を返します。 server { listen 4010; server_name search; location = /api/v1/goods { add_header Content-Type application/json; return 200 '{"status":"success"}' ; } } docker-compose.test.yml で test.conf をnginxコンテナの /etc/nginx/conf.d/default.conf に配置します。 services : mock : image : nginx:mainline-alpine volumes : - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf - ./docker/nginx/test.conf:/etc/nginx/conf.d/default.conf networks : backend : aliases : - search networks : backend : Goのテストコード側ではこのような target_groups.yml を定義します。 test : targets : - host : search port : 4010 上記の通り、以下を一致させる必要があります。 test.conf の server_name の値 docker-compose.test.yml の aliases の値 target_groups.yml のホスト(ターゲットID) 以上より、マイクロサービスのレスポンスをモック化して、テストコードを実装しています。 タイムアウトのモック さらに、 njs を利用して、スリープ処理するJavaScriptファイルを配置し、マイクロサービスへのリクエストタイムアウトに関する異常系テストをしています。njsは、nginxの内部で使用可能なJavaScriptのサブセットです。 下記のような sleep.js を用意しています。 function sleep(r) { setTimeout(function() { r.return(200, ""); }, 10000) } export default {sleep}; 下記の test.conf では js_import して、 slow:80 の / にリクエストがきたら sleep を実行するにしています。 js_import js/sleep.js; server { listen 80; server_name slow; location / { js_content sleep.sleep; } } docker-compose.test.yml はこちらです。 volumes で sleep.js を指定しています。 aliases に slow を指定しています。 services : mock : image : nginx:mainline-alpine volumes : - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf - ./docker/nginx/test.conf:/etc/nginx/conf.d/default.conf - ./docker/nginx/sleep.js:/etc/nginx/js/sleep.js networks : backend : aliases : - slow networks : backend : 分析・監視 API Gatewayの分析と監視について、ツール毎に説明します。 Athena ログの分析には Athena を使用しています。 ALBとアプリケーションのログはS3に保管しており、そのデータをクエリでSQL検索できます。例えば、マイクロサービスで付与されているトレースIDを検索条件に、エラー状況を確認できます。 Athenaはクエリでスキャンされるデータ量によって課金されます(2020年12月現在の東京リージョンで1GBあたり約0.5円)。したがって、必ずクエリのWHERE句で日付などを指定するように、社内でルール化されています。念の為、各Workgroupには「スキャンできるデータ量の上限」が設定されています。 CloudWatch Alarm 通知条件の判定には CloudWatch Alarm を使用しています。監視対象のメトリクスが閾値を超した場合に、SlackやPagerDutyで通知します。 Datadog APM 分散トレーシングの監視ツールには、 Datadog APM を使用しています。 APMはApplication Performance Monitoringの略称です。従来のアプリケーション監視では、プロセス監視や外形監視などが一般的でしたが、APMではパフォーマンスも監視対象にしています。 API Gatewayだけでなく、Datadog APM側でもトレースIDを発行しています。Datadog APMで発行したトレースIDにより、下図のように、コンソール上でそれぞれの処理が紐づいて表示されます。コンソールではAPI Gatewayとマイクロサービスのレイテンシーやエラー情報、インフラ情報、メトリクスなどを確認できます。加えて、SQLはクエリ単位までドリルダウンして確認できます。 スパンの開始 スパン は、計測単位です。スパン同士は相互にネストできるため、親子関係にできます。スパンを設定することで、ドリルダウンでの可視化を可能にしています。 リバースプロキシ処理内において、マイクロサービスへHTTPリクエストする直前にスパンを開始しています。 opts := []ddtrace.StartSpanOption{ tracer.SpanType(ext.SpanTypeHTTP), tracer.ResourceName( "url" ), tracer.Tag(ext.HTTPMethod, "method" ), tracer.Tag(ext.HTTPURL, "url" ), } span, _ := tracer.StartSpanFromContext(r.Context(), "transfer-request" , opts...) defer func () { span.Finish(tracer.WithError(e)) }() スパンタグの付与 スパンタグ は、スパンに付与するkey-value形式のタグです。以下のスパンタグを設定しています。 トレースID クライアントタイプ マイクロサービスのHTTPレスポンスコード 例えば、転送したマイクロサービスからのHTTPステータスを、スパンタグにセットする実装は次の通りです。 span.SetTag(ext.HTTPCode, response.StatusCode) Sentry アプリケーションのエラー監視には Sentry を使っています。 エラー情報だけでなくクライアント情報も詳しく表示され、Slackと連携したエラー通知も可能です。 Sentryへのエラー情報送信 このようなSentryにエラー情報を送信する関数を用意しています。 func SendSentry(r *http.Request, e error ) { hub := sentry.CurrentHub() if sentry.HasHubOnContext(r.Context()) { hub = sentry.GetHubFromContext(r.Context()) } hub.CaptureException(e) } エラー処理ではこの関数を呼び出しています。 if e != nil { lib.SendSentry(r, e) } 秘匿情報を取り除く ヘッダやボディなどのエラー送信内容には、トークンなどの秘匿情報が含まれます。Sentry側で、それらの秘匿情報を取り除けます。 Using before-send in the SDKs to scrub any data before it is sent is the recommended scrubbing approach Scrubbing Sensitive Data for Go | Sentry Documentation しかしながら、上記の通り、秘匿情報はSentryへの送信時点でアプリケーション側にて取り除いておくことが推奨されています。秘匿情報が送信前に取り除かれていることが管理画面上でわかるように、 XXXXX の値でマスクしています。 PagerDuty オンコールシステムには PagerDuty を使用しています。Slack通知しているものの中でより緊急度が高いものに関しては、PagerDutyからコールが来ます。 今のところサービスの監視担当は週次の交代制になっていて、SREチームとバックエンドチームから1人ずつ当番が割り当てられています。 API Gatewayの現状とこれから 現状、ある程度の機能を有したAPI Gatewayをリリースしています。これにより、ストラングラーパターンで進める準備(土台)ができました。 現在は、一日に約数億回のAPIリクエストがAPI Gatewayを経由しています。しかしながら、まだZOZOTOWNのAPIが全てAPI Gateway経由に置き換わったわけではありません。今後、さらにAPI Gatewayを通るリクエストが増えていくでしょう。例えば、 ZOZOSUIT や ZOZOMAT などの計測サービス、 ZOZOUSED 、 Fulfillment by ZOZO のAPIなどです。 ただし、マイクロサービス間のAPIリクエストに関しては、サービスメッシュ(Envoy)の導入も検討しています。理由は、「API Gatewayの負荷軽減」とAPIクライアント毎に配布している「クライアントトークン管理の手間を削減する」ためです。 We are hiring ZOZOTOWNのマイクロサービス化はまだ始まったばかりです。今後は、API GatewayやID基盤の追加開発に加えて、新たなマイクロサービスの開発も目白押しです。そのためのエンジニアが足りていない状況です。 ご興味のある方は、以下のリンクからぜひご応募ください。お待ちしております。 hrmos.co
アバター
はじめまして、ZOZO研究所福岡の家富です。画像検索システムのインフラ、機械学習まわりを担当しています。 今回は画像検索システムでお世話になっているAnnoyについてじっくり紹介したいと思います。 目次 目次 Annoyについて 近傍探索について Annoyのソースコードを読むときのポイント AnnoyIndexというクラスのインスタンスを作る インストール過程について PythonのC/C++拡張 Annoyの実装 1. add_item 2. build 3. get_nns_by_vector 4. build再考 他に問題となる点について CPU依存部分 ディスクかメモリか まとめ さいごに Annoyについて Annoyは、SpotifyによるPython近傍探索ライブラリです。 github.com 弊社のテックブログでも以前に取り上げています。 techblog.zozo.com 今回は実装について紹介していきたいと思います。 近傍探索について 近傍探索が一般的に満たすべき機能は以下の通りです。 まずベクトルの集合 V を用意します。以降「登録ベクトル群」と呼び、この集合に含まれるベクトルは「登録ベクトル」と呼びます。 次に検索ターゲットのベクトル a と個数 k を指定します。なお、 a は V に含まれていなくて良いです。 そして、このとき集合 V から k 個のベクトルを a から近い順に取ってくるというのが近傍探索の処理です。 とても単純に計算するならば a との距離を各 V の要素ごとに計算し、ソートすれば良いです。しかし、それだと V に含まれるベクトルの個数 n に比例して計算時間がかかってしまいます。 そのため、この計算を速くするというのが近傍探索ライブラリの役割です。だいたい log(n) のオーダー程度になることが望まれていると考えて良いです。 Annoyの性能に関しては、他の近傍ライブラリと比べて特別速いといったことはありません。しかし、メインとなるのは annoylib.h の1500行と annoymodule.cc の700行程度でコード量が少なく、他ライブラリに対する依存もないため、OSS入門として取り扱いやすいものとなっています。 Annoyのソースコードを読むときのポイント ソースコードを読む際に、どこから読めばいいか迷うと思います。一般的には、わかるところから、もしくは興味あるところからになると思います。Annoyの場合、私は README にある以下の実行サンプルをスタート地点としました。 from annoy import AnnoyIndex import random f = 40 t = AnnoyIndex(f, 'angular' ) # Length of item vector that will be indexed for i in range ( 1000 ): v = [random.gauss( 0 , 1 ) for z in range (f)] t.add_item(i, v) t.build( 10 ) # 10 trees t.save( 'test.ann' ) u = AnnoyIndex(f, 'angular' ) u.load( 'test.ann' ) # super fast, will just mmap the file print (u.get_nns_by_item( 0 , 1000 )) # will find the 1000 nearest neighbors 参考: Python code example | spotify/annoy このコードがやっていることは、以下の通りです。 AnnoyIndex というクラスのインスタンスを作る。 ベクトル( f =40次元)を1000個ランダムに抽出し、「登録ベクトル群」として入れて、検索高速化のための構造(インデックス)を作る。 作成したインデックスを test.ann というファイル名で保存。 新しいインスタンスを作成し、作成したファイルからインデックスを読み込む。 読み込んだインデックスを利用して、0番目のベクトル(最初のベクトル)と近いもの順に並べたベクトル群を出力。 このコードの各実行部分がソースコードのどこにあたるのかを見ていけば良いかなという発想で読んでいきました。 AnnoyIndexというクラスのインスタンスを作る インスタンス生成では、以下のコードにおいて定義されるAnnoyIndexを呼んでいます。 from .annoylib import Annoy as AnnoyIndex 参考: __init__.py | spotify/annoy .annoylib がどこからきているかというと、以下のコードからきています。 github.com このコードを読んでいくには、もう1つPythonのC/C++拡張についての理解が必要です。そのために、まずAnnoyの「インストール過程について」紹介し、その次に「PythonのC/C++拡張」を紹介します。 インストール過程について Annoyは通常、 pip を使って以下のコマンドでインストールします。 pip install annoy これはソースコードを手動でダウンロードして、以下のコマンドを走らせることと同様です。 python setup.py install setup.py での主要部分は以下の部分です。 ... setup(name= 'annoy' , version= '1.17.0' , description= 'Approximate Nearest Neighbors in C++/Python optimized for memory usage and loading/saving to disk.' , packages=[ 'annoy' ], ext_modules=[ Extension( 'annoy.annoylib' , [ 'src/annoymodule.cc' ], depends=[ 'src/annoylib.h' , 'src/kissrandom.h' , 'src/mman.h' ], extra_compile_args=extra_compile_args, extra_link_args=extra_link_args, ) ], ... 参考: setup.py#L75-L86 | spotify/annoy packages=['annoy'] の部分はカレントディレクトリの annoy というディレクトリをそのままモジュールとして使うことを意味しています。このディレクトリに先程の __init__.py があります。 ext_modules の部分は annoy ディレクトリ以下に annoylib を作ることを意味しています。ライブラリの具体的形式はOS環境により変わりますが setup 関数がよしなに処理してくれます。 このときに使うソースが src/annoymodule.cc です。 depends の部分はビルドする際に、 src/annoylib.h 、 src/kisssrandom.h 、 src/mman.h を必要としていることを示しています。この設定により、OS環境に応じたC/C++コンパイラとそのコンパイル、リンクオプションを自動で設定して、ライブラリを作成してくれます。 上記のようにメインのソースは src/annoymodule.cc であり、このソースは「PythonのC/C++拡張」によって書かれているため、次に「PythonのC/C++拡張」について紹介します。 PythonのC/C++拡張 C/C++のコードをPythonから使えるようにする仕組みは大きく分けると、以下の2種類があります。 1つ目がctypesを使う方法です。 docs.python.org 2つ目は、PythonのC/C++拡張を使う方法です。 docs.python.org 他にはSWIGを使うという方法がありますが、これは上記のPythonのC/C++拡張のためのインタフェース部分を作成するツールです。 ja.dbpedia.org 重要な違いはインタフェースを整える部分をPython側でやるか、C/C++側でやるかの違いです。すでにC/C++のライブラリがある場合はctypesを使ってPython側で調整してやるという方法が簡易です。Pythonライブラリとして公開したいが、ランタイム速度を求める場合などはPythonのC/C++拡張を使い、C/C++側で調整する方法が速度的に有利だと考えられます。 Annoyは後者のPythonのC/C++拡張を使う方法を採用しており、以下のようにモジュールを公開しています。 #if PY_MAJOR_VERSION >= 3 PyMODINIT_FUNC PyInit_annoylib ( void ) { return create_module (); // it should return moudule object in py3 } #else PyMODINIT_FUNC initannoylib ( void ) { create_module (); } #endif 参考: annoymodule.cc#L627-L635 | spotify/annoy Pythonのバージョンが3なのか、2なのかで公開方法が多少異なりますが、 2系統は開発が終了している ため、3の方法を確認しておけば問題ありません。 公開するモジュールは create_module において、以下で作成したオブジェクトを返すようにします。 PyObject *m; ... m = PyModule_Create (&moduledef); 参考: annoymodule.cc#L608-L616 | spotify/annoy Annoyの場合、重要なのは Annoy クラスを登録するところであり、以下の部分です。 PyModule_AddObject (m, "Annoy" , (PyObject *)&PyAnnoyType); 参考: annoymodule.cc#L623 | spotify/annoy ここで annoy モジュールの Annoy クラスとして PyAnnoyType で設定したものをモジュールに加えています。 PyAnnoyType を定義する上で重要な点は以下の通りです。 クラスインスタンスのメモリ容量の登録: sizeof(py_annoy) インスタンスのデストラクタの登録: (destructor)py_an_dealloc クラスのメソッドの登録: AnnoyMethods クラスのメンバーの登録: py_annoy_members インスタンスの初期化関数(下記のメモリ確保関数の後に呼ばれる。Pythonの __init__ の呼ばれるタイミング)の登録: (initproc)py_an_init インスタンスのメモリ確保関数の登録: py_an_new 上記の py_an_new まわりはPythonオブジェクトからC/C++用のデータを取り出す場合、どのように実装すればいいか、とても参考になります。 Annoyは Annoy クラスを管理するモジュールになっているため、動作を見る上で一番注意すべきところは py_an_new です。なお、 py_an_init ではチェックのみです。 距離構造を指定するパラメータ metric については、画像検索システムでは angular を使っているので、ここからは metric=angular と指定されているとしてコードを見ていきます。 py_an_new では以下のようにインスタンスを生成して、 ptr メンバーに設定されていることがわかります。 new AnnoyIndex< int32_t , float , Angular, Kiss64Random, AnnoyIndexThreadedBuildPolicy>(self->f); 参考: annoymodule.cc#L153 | spotify/annoy AnnoyのPythonのメソッドと、C/C++コードのメソッドの対応はAnnoyMethodsを見ることで対処できるので、以降はC/C++コード部分である src/annoylib.h を読んでいきます。 なお、C++のtemplateライブラリとして実装しているので、ヘッダーファイルが実装になっています。しかし、そこまで型を抽象化しているメリットは特にないように思います。 Annoyの実装 以降、引き続き metric の設定として angular が指定されているとしてコードを読んでいきます。 他の metric の場合、さらにヒューリスティックな要素が大きく、コードを読む際にわかりにくいこともあるため、対象を絞りました。一通り読む際も、まずは metric を固定して一読した方が良いです。 アルゴリズムを理解する上で重要となる AnnoyIndex クラスのメソッドは以下の3つです。 add_item : 検索対象となるベクトルの登録 build : 登録されたベクトルに対する検索を高速化するための構造の作成 get_nns_by_vector : 登録されたベクトルに対する検索 C++コードにおいても、メソッド名はPythonインタフェースと同名のメソッドとなっています。 上記のメソッドを紹介する前に、 AnnoyIndex のメンバーを簡単に説明しておきます。 const int _f; // 登録するベクトルの次元(e.x. 512) size_t _s; // nodesの要素のバイトサイズ S _n_items; // 「登録ベクトル群」に登録されているベクトルの個数 void * _nodes; // Nodeインスタンスの配列で、buildで作られるインデックス構造は、このNodeからなるtreeの集合 S _n_nodes; // 実際に登録しているNodeの個数 S _nodes_size; // _nodesが確保している配列の長さで、_n_nodesよりも一般に大きく取られる。この辺りはメモリを気にするC/C++特有のメンバー vector<S> _roots; // buildで作られる複数のtreeの各ルートを保持した配列 S _K; // ひとつのNodeに何個のIDを保持できるかを表した数。末端の1つ上のNodeに対して重要な使われ方をする int _seed; // 乱数のシード。 buildにおいて乱数が使われるので必要となる bool _loaded; // ファイルからロードされたかどうか bool _verbose; // 出力モードフラグ。trueなら出力が丁寧だが量が多くなる int _fd; // ファイルからインデックスをロードする際に使われる bool _on_disk; // _nodesをディスクにおく場合はtrue bool _built; // buildが呼ばれる前か呼ばれた後かを表すフラグ 参考: annoylib.h#L869-L882 | spotify/annoy なお、登録ベクトルの個数 _n_items と検索に使う Node の個数 _n_nodes の違いは重要です。 アルゴリズムとしては Node のメンバーも重要なので、buildの説明の際に行います。 1. add_item ここでは、まず、登録ベクトルに対応する Node インスタンスを作成を行います。IDの割り振りと、ベクトル値の登録をします。 次に、 _nodes メンバーに、先程作成した Node インスタンスを追加します。このとき、 _nodes に割り当てているメモリが足りない場合は確保し直します。 buildのフェーズにおいては Node として、ベクトルに対応しないものを登録していきます。そのため Node は登録ベクトルに対応するものと、しないものを見分けるためのメンバーが必要になりますが、その役割を n_descendants が行います。この値が1のときは登録ベクトルであるということになります。それ以外の値に関しては次の「2. build」で説明します。 2. build 目的は以下のデータ構造を作ることになります。「3. get_nns_by_vector」でデータ構造の詳細と使われ方を説明した後、「4. build再考」にて実装の説明をします。 上図のようなtreeの構造についてまず説明します。 Node は大きく、3つに分けられます。 末端 Node 「登録ベクトル群」に属するベクトルと対応します。 末端の1つ上の Node 末端 Node のIDの配列を保持します。 その他の Node children として子 Node を2つ保持します。この2つの子 Node のどちらかを辿っていけば、登録したベクトルに行き着くようにtreeを作成します。この Node でのベクトル v は、ある登録したベクトルが子 Node のどちらにあるかを示すために使用します。ベクトル x が x・v<=0 のとき、 children[0] , x・v>0 のとき、 children[1] の方から辿れるように Node を構成します。 探索中に辿っている Node が上記3つの Node のどれにあたるのか識別する必要がありますが、それは n_descendants という Node のメンバーを見ることで判断しています。 n_descendants は、その Node 以下にたどり着く登録ベクトルが何個あるのかを表しています。 n_descendants=1 ならば、末端 Node n_descendants<=_K ならば、末端の1つ上の Node _K の決め方が特殊なので「4. build再考」にて説明します それ以外ならば、中間の Node 上記の判断を行っているコードは、以下の部分がわかりやすいです。 if (nd->n_descendants == 1 && i < _n_items) { nns. push_back (i); } else if (nd->n_descendants <= _K) { const S* dst = nd->children; nns. insert (nns. end (), dst, &dst[nd->n_descendants]); } else { T margin = D:: margin (nd, v, _f); q. push ( make_pair (D:: pq_distance (d, margin, 1 ), static_cast<S>(nd->children[ 1 ]))); q. push ( make_pair (D:: pq_distance (d, margin, 0 ), static_cast<S>(nd->children[ 0 ]))); } 参考: annoylib.h#L1365-L1374 | spotify/annoy 3. get_nns_by_vector 実際には、さらに「2. build」で説明したようなtreeを複数個作り保持します。なぜ複数必要なのかは後で説明します。 このように複数のtreeが構成された状態で検索アルゴリズム本体である _get_all_nns が呼ばれます。 検索ターゲットのベクトル a に対して、各treeをどのように探索していくかを見ていきます。末端に到達するまでは各中間の Node においてどちらの children の Node を辿るべきかを判断する必要があります。 ここで a・v の値を計算し、 a・v>0 だったら children[1] 、 a・v<=0 だったら children[0] を探索していきます。 これは children を構成する際に、登録するベクトル x に対して x・v>0 だったら children[1] 、 x・v<=0 だったら children[0] の方に入れていくように構成するからです。具体的な構成方法は、「4. build再考」で紹介します。 厳密に計算するならば、探索ターゲットのベクトル a と「登録ベクトル群」に属するベクトル x の内積を調べる必要があります。しかし、それでは計算量が多くなるので代わりに v・x>0 となる代表ベクトル v との内積 a・v>0 かどうかを使って、近似的に近くになるだろうベクトル群を探します。「 a・v>0 、 x・v>0 、ならば、まあだいたい a・x>0 だろう」という感覚です。 v を基準として反対側よりも同じ側にあるベクトル群から探した方が近いものを見つけられるだろうという捉え方もできます。 「登録ベクトル群」の個数が n のとき、treeがうまく作られていれば、理想的にtreeのルートから辿る Node の個数は log(n) となるため、高速に検索できるということです。 ここで複数のtreeを用意する理由を考えます。 1つのtreeとした場合、ある Node の v との内積が0となるところ(以降、「際」と呼ぶ)の近くに検索ターゲットのベクトル a がある場合、問題が生じます。このようなベクトル a に対して、 children のどちら側のベクトル群もそれぞれ、 a と近いベクトルを含む事態が生じるためです。 このような「際にあるベクトル」は一般的にはいくらでも存在します。各 Node の v を基準とした場合に、検索ターゲットのベクトル a が「際」に近いベクトル(内積が0に近い)となる可能性を低くするため複数のtreeが必要になります。そのため、buildのパラメータ q は、大きければその分精度向上しますが、その分検索速度が落ちます。 では実際に探索するコードを見ていきます。 std::priority_queue<pair<T, S> > q; if (search_k == - 1 ) { search_k = n * _roots. size (); } for ( size_t i = 0 ; i < _roots. size (); i++) { q. push ( make_pair (Distance::template pq_initial_value<T>(), _roots[i])); } std::vector<S> nns; while (nns. size () < ( size_t )search_k && !q. empty ()) { const pair<T, S>& top = q. top (); T d = top.first; S i = top.second; Node* nd = _get (i); q. pop (); if (nd->n_descendants == 1 && i < _n_items) { nns. push_back (i); } else if (nd->n_descendants <= _K) { const S* dst = nd->children; nns. insert (nns. end (), dst, &dst[nd->n_descendants]); } else { T margin = D:: margin (nd, v, _f); q. push ( make_pair (D:: pq_distance (d, margin, 1 ), static_cast<S>(nd->children[ 1 ]))); q. push ( make_pair (D:: pq_distance (d, margin, 0 ), static_cast<S>(nd->children[ 0 ]))); } } 参考: annoylib.h#L1348-L1375 | spotify/annoy なお、 std::priority_queue の top メソッドはデフォルトでは一番大きい値のものを返します。 各treeにおいては、ルートからその Node までに通るすべての Node で「際」に最も近いもの(最も内積の値が小さいもの)を q に入れます。コード上ではこの値を margin (「際」からの距離)と呼んでいます。 q.top でこの値の「一番大きなもの」を取り出すという操作をしていますが、これは「際」から最も遠いものを選んでくることに対応します。同時に内積が負になるもの(つまり検索対象のベクトル a と反対側にあるもの)も取り除いています。 各treeの内部においては一番小さいものをとり、tree同士の比較では一番大きいものを取り出すというところで、混乱しやすいので注意が必要です。 できるだけ「際」に近い Node は辿らず、「際」から遠い Node を辿っていった方が近傍探索の精度は高くなるという発想です。 このように各treeから、より近いベクトルが含まれてそうな「末端の1つ上の Node 」を取り出し、それ以下の Node を nns に入れます。ここで nns に入れられる Node はそれぞれ「登録ベクトル群」に属するベクトルと対応します。あとは nns に入れられたベクトルと a の距離を求めて、ソートして返すだけです。この部分は std::partial_sort で実装されており、一般的にはヒープソートで実装されているようです。 github.com よって、返すベクトルの個数を M とした場合、 nns に対する操作のオーダーは M・log(M) と考えられます。 4. build再考 「3. get_nns_by_vector」で述べたようなアルゴリズムを実行するためにtreeを構成する必要があります。ここで重要なのは、登録されたベクトルを分けるためのベクトル v です。このときtreeの高さを抑えるため、ひいては検索速度をあげるため、「登録ベクトル群」をおおよそ半分に分けるベクトル v が望まれます。 また複数treeを作った際に、それぞれのtreeができるだけランダムな方が望ましいです。そうすることで、検索ターゲットのベクトルがすべてのtreeにおいて「際」になるようなケースを確率的に減らすことができます。 Annoyはrandom projectionを使ってtreeを構成し、そのtreeで検索するアルゴリズムです。一般的に近傍探索アルゴリズムにおいて、random projectionの取り方、treeの実装方法は多岐に渡ります。 arxiv.org ここではAnnoyの実装を述べます。Annoyでのrandom projectionに相当する、 v の取り方は create_split という関数で実装しています(参考: annoylib.h#L486-L493 | spotify/annoy )。 さらにこの中の two_means という関数がメインです。 template<typename T, typename Random, typename Distance, typename Node> inline void two_means ( const vector<Node*>& nodes, int f, Random& random, bool cosine, Node* p, Node* q) { /* This algorithm is a huge heuristic. Empirically it works really well, but I can't motivate it well. The basic idea is to keep two centroids and assign points to either one of them. We weight each centroid by the number of points assigned to it, so to balance it. */ static int iteration_steps = 200 ; size_t count = nodes. size (); size_t i = random. index (count); size_t j = random. index (count- 1 ); j += (j >= i); // ensure that i != j Distance::template copy_node<T, Node>(p, nodes[i], f); Distance::template copy_node<T, Node>(q, nodes[j], f); if (cosine) { Distance::template normalize<T, Node>(p, f); Distance::template normalize<T, Node>(q, f); } Distance:: init_node (p, f); Distance:: init_node (q, f); int ic = 1 , jc = 1 ; for ( int l = 0 ; l < iteration_steps; l++) { size_t k = random. index (count); T di = ic * Distance:: distance (p, nodes[k], f), dj = jc * Distance:: distance (q, nodes[k], f); T norm = cosine ? get_norm (nodes[k]->v, f) : 1 ; if (!(norm > T ( 0 ))) { continue ; } if (di < dj) { for ( int z = 0 ; z < f; z++) p->v[z] = (p->v[z] * ic + nodes[k]->v[z] / norm) / (ic + 1 ); Distance:: init_node (p, f); ic++; } else if (dj < di) { for ( int z = 0 ; z < f; z++) q->v[z] = (q->v[z] * jc + nodes[k]->v[z] / norm) / (jc + 1 ); Distance:: init_node (q, f); jc++; } } } 参考: annoylib.h#L364-L407 | spotify/annoy 引数の nodes は分ける対象となるベクトル群を表しています。まず、2つのベクトルを nodes からランダムに取り出して p 、 q にセットします。次に200個のベクトルをランダムに取り出し、 p 、 q の近い方に足して、足した方を平均化します。 これは以下の操作に相当します。 nodes から200個のベクトルをランダムに取り出しk-meansで2つのグループに分ける。 それぞれのグループの中心ベクトルを p 、 q とする。 ここは厳密なアルゴリズムではなく、ヒューリスティックに分けています。一般的にk-meansアルゴリズムはヒューリスティックなものです。 このように p 、 q を求めて、その差分を v として設定しています。確率的に nodes に属していたベクトルを内積で2分するようなベクトルになっていると考えられます。 上記を踏まえてbuildを読んでいきます。 thread_build というメソッドがメインになっています。元々はbuildによる実装でしたが、Annoyが1.17.0より並列コードを追加したため、 thread_build というメソッドになりました。1.16.3のバージョンのコードと比較してみるとわかりやすいです。 thread_build_policy というものを用いてthreadが共有するデータ構造に対してロックをかけながら実行するコードになっています。最初に読む場合、これらは無視して進むと読みやすいです。実際のところ、具象クラスの AnnoyIndexSingleThreadedBuildPolicy の実装の場合、lockメソッドは何もしていません。 では引き続き thread_build を読んでいきます。 thread_build は indices というローカル変数に「登録ベクトル群」を詰め込んで _make_tree を呼びます。 この _make_tree がtree構造作成のメインになります。 create_split によって区分けするためのベクトルを作り、再帰的に indices を2つのグループに分ける Node を作っていきます。 なお、正確にはtree構造が偏ってしまい、うまく分けられない場合がときおり生じます。そのための処理も入っています。 ここで「末端の1つ上の Node 」に関して _K について実装を説明します。使われている部分として以下のif文があります。 if (indices. size () <= ( size_t )_K && (!is_root || ( size_t )_n_items <= ( size_t )_K || indices. size () == 1 )) { ... 参考: annoylib.h#L1248 | spotify/annoy 残りの indices の個数が少ない場合、「末端の1つ上の Node 」を作成するときに処理される部分です。 _K 以下というところがポイントで、この _K は以下で定義されています。 _K = (S) ((( size_t ) (_s - offsetof (Node, children))) / sizeof (S)); // Max number of descendants to fit into node 参考: annoylib.h#L889 | spotify/annoy かなりアドホックですが、 _K は以下の式の値を計算しています。 (Node構造体のchildren以下のメンバーが保持するバイト数) / (Sのバイト数) これは children 以下のメモリ領域にSのインスタンスを何個保持できるかを表しています。この値を使うことで、残りの Node のIDを children 以下のメモリ領域に保持できるかを判断しています。 そして可能な場合は実際に Node のIDの配列を children 以下にコピーし、「末端の1つ上の Node 」を作り出します。 Node クラスの children[0] 、 children[1] 、 v の領域を配列として使っていて、メモリ破壊的な使い方をしています。しかし、一応、C++の std::vector はメモリ上に連続的に配置しなければならないという規約があるので大丈夫なようです。 www.open-std.org 以上、コードを読む際にひっかかりやすそうな部分を紹介しました。 他に問題となる点について CPU依存部分 バージョン1.16.0以降、Annoyは内積計算の部分でAVX512命令があるCPUにおいてはAVX512命令を使うようになっています。 template<> inline float dot< float >( const float * x, const float *y, int f) { float result = 0 ; if (f > 7 ) { __m256 d = _mm256_setzero_ps (); for (; f > 7 ; f -= 8 ) { d = _mm256_add_ps (d, _mm256_mul_ps ( _mm256_loadu_ps (x), _mm256_loadu_ps (y))); x += 8 ; y += 8 ; } // Sum all floats in dot register. result += hsum256_ps_avx (d); } // Don't forget the remaining values. for (; f > 0 ; f--) { result += *x * *y; x++; y++; } return result; } 参考: annoylib.h#L210-L230 | spotify/annoy これによって高速にはなるのですが、ビルド環境と実行環境において使用しているCPUに差が出るようなコンテナ運用などをしている場合、動かないことがあるので注意が必要です。 なお、AVX512ではないですが、AVX2に対しても同様の問題がありました。 github.com 実際に、画像検索システムではGitHub Actionsでビルドを行っているのですが、以下のような問題が生じました。 ビルド時にはAVX512命令をもっているCPUを割り当てられ、実行環境においてはAVX512命令がないCPUだったので、セグメンテーションフォルトとなることがありました。幸いテスト環境なので運用に支障はありませんでした。 解決方法としては以下の2つが考えられます。 使用するAnnoyのバージョンを落とす ビルド時に環境変数ANNOY_COMPILER_ARGSをコンパイルオプションとして指定する AVX512を抑制するgccの最適化オプションを調べるのは難しく、画像検索システムにおいては現状使用するAnnoyのバージョンを1.15.2としています。 ディスクかメモリか ファイルにセーブしたデータをロードするコードは mmap を使ったコードになっています。これはdisk cacheを使っているのでメモリから追い出される可能性があります。また、経験上、disk cache自体の速度が不安定という問題があります。 弊社瀬尾の以下の記事に問題となった部分の詳細と対応が述べられています。 docs.google.com まとめ Annoyは依存ライブラリがなく、コード量もそこまで多くないため非常にとっつきやすいコードとなっています。そのため、数値計算のOSSの入門として、とてもおすすめです。 また、その他の近傍探索ライブラリのコードを読む際にも、基準とするには良いコードです。 さいごに ZOZOテクノロジーズでは、ZOZO研究所のMLエンジニアも募集しております。 hrmos.co
アバター
コーポレートエンジニアリング部ITサービスチームの高橋です。コーポレートエンジニアリング部ではスタッフや組織の課題をテクノロジーの力で解決するということをビジョンに掲げています。その中でも私が所属するITサービスチームでは、ZOZOグループ全体の生産性を上げるため、部門や組織の課題をテクノロジーの力で解決に導く役割を担っています。クラウドベースのツールを活用し「攻めの戦略」を重視し、社内の活性化を目指しています。 その一環として、パスワード管理ツール 1Password を全社導入しました。導入に至った背景、製品選定で重視した点、及び実際の運用を紹介します。なお、弊社の環境は社員の大半がエンジニアで、社員数は400人規模です。 いきなり余談ですが、先日政府がPPAP(パスワード付きzipファイルをメール添付し別途パスワードを送信する意)を廃止する方針であると表明しました。2017年にはパスワードの定期的な変更は非推奨とされましたし、徐々にですが日本も古い慣習がなくなりつつあるように思います。 さて、本題です。 パスワード管理ツールの必要性 パスワード管理の基本は、強固なパスワードを作成し使いまわしせず、なるべく漏洩しないようにすることが挙げられると思います。ありがちなものとしては、以下のような方法があります。 付箋や紙に書いて管理 PCのメモ帳で管理 Excelで管理 ブラウザに保存 ですがセキュリティや管理・運用のしやすさを考えると、上記の方法よりも専門ツールであるパスワード管理ツールを利用する方が優れています。 「パスワードなんてブラウザに保存できるからそれで事足りる」と思う方もいらっしゃると思います。しかし会社としてパスワード管理の基盤がないと、チームごとに管理方法が違ったりパスワードの共有に平文が用いられてしまったり様々なリスクが生じます。 パスワード管理ツールは、便利なだけではなくそういった問題を解決できるので、利用者側、管理者側ともに非常に有益なものと言えます。 パスワード管理ツールの選定 パスワード管理ツールは色々あります。 1Password LastPass パスワードマネージャー Keeper True Key Dashlane Bitwarden ざっと調査しただけで、上記が挙げられます。 上記の全てを比較したわけではありませんが、どれも基本的な機能としては大差ありません。例えば下記のような機能があります。 複雑なパスワードの自動生成 ID・パスワードの自動入力 パスワードの強度や使い回しのチェック 多要素認証 ID・パスワードの共有 強度の高いパスワードを生成でき、利用者は自身のマスタパスワードだけを覚えれば他のパスワードを覚える必要がなく、保存された情報は暗号化され安全に共有できます。もちろんパスワード以外のセンシティブな情報も保存できます。パスワード管理ツールはそのような機能を備えたツールです。 1Passwordの優位性 弊社では主に以下の点で、1Passwordを採用するに至りました。 Secret Keyの仕組みがある 1Passwordには マスタパスワード に加えて Secret Key があり、たとえマスタパスワードが漏洩したとしても、Secret Keyを知らなければアクセスできません。マスタパスワードはデバイス上のデータを保護し、Secret Keyはデバイスからデータを保護してくれるとのことで、この二段構えの構成は安心できます。 グループ単位で管理できる ビジネスプラン以上ではユーザグループを作成できます。グループにユーザを追加し、グループを保管庫(Vault)に紐付けることで権限付与が可能です。 CLI(コマンドライン)ツールがある 1Passwordには コマンドラインツール があります。コマンドラインツールに対応していることは、運用の自動化を考慮する上で重要な要素と捉えています。 例えば以下のようなことができます。 # ユーザ招待 op create user < メールアドレス > < 氏名 > # ユーザの停止と再開 op ( suspend | reactivate ) < user > # ユーザ削除 op delete user < user > # 一覧取得 op list ( users | groups | vaults | items | documents | templates ) [ --vault < vault > | --group < group >] レポーティング(パスワード漏洩チェック)機能がある 1Passwordにはドメイン侵害レポートがあります。自社が管理するドメインを登録しておくと、漏洩に巻き込まれたアドレスを見つけることができます。このレポートを元にしてパスワードの変更をユーザへ促すことができます。 導入にあたっての課題 課題は大きく3つありました。 プランの検討 SSO(シングルサインオン)が可能か プロビジョニングが可能か プランの検討 1Passwordのビジネス向けプラン は3つあります。 Teams Business Enteprise 結論から言うと弊社は Businessプラン を選択しました。 Teamsプランでは、詳細な権限管理ができないため、全社的に導入するとなると機能不足でした。 Businessプランでは、より詳細な権限管理からログ管理やレポート閲覧まで豊富な機能を備えているため、SaaS製品としての機能が十分であると判断しました。また、Azure Active Directory、Okta、OneLoginと連携できるのもこのプラン以上になっています。弊社としては、グループで管理できることが運用上大きなメリットでした。ユーザ単位で権限管理をするのは運用が煩雑になると思います。 Entepriseプランでは、上記の機能に加えて専用窓口を設けてくれたり、導入にあたりトレーニングを受けられるなどのメリットがあるそうです。ですが弊社ではそこまでのサポートは必要なく、Businessプランで利用できる機能さえあれば十分でした。 SSO(シングルサインオン)が可能か 弊社のシステム選定基準では、基本的にSSOが利用できるシステムを選定しています。しかし、 1Passwordの仕様上SSOは不可 でした。SSOできないことは利用者目線に立つとある程度の不便さはあります。ですが1Passwordの認証の堅牢性の土台となっているSecret Keyの有用性とのバランスを考慮して、SSO不可であることを許容しました。 プロビジョニングが可能か プロビジョニングを行うためには 1Password SCIM bridge を構成する必要があります。 Google Cloud Platform Marketplace Docker, Kubernetes or Terraformで構築 SCIM bridgeサーバを構築するために、主要なクラウドサービスにおいて試算を行いました。しかし、現状ではコストメリットが無さそうだったためプロビジョニングの導入は一旦見送りました。会社の規模拡大に合わせ、再度検討したいと思っています。プロビジョニングの代わりに、前述のコマンドラインツールを活用し運用することにしました。 実際の運用 全社導入前に一部の部署で1Passwordを先行利用していたのですが、その時はグループを利用しておらずユーザを保管庫に直接割り当てる運用をしていました。しかしこれでは統一性もなく管理が煩雑だったため、グループベースで管理するように運用を変更しました。ユーザからの利用申請も、kintoneを用いたワークフローで管理し、保管庫とグループの一覧はスプレッドシートにて管理することにしました。 スプレッドシートで管理した理由は2つあります。 1つはグループや保管庫、グループ内メンバーの一覧と、グループがどの保管庫と紐付いているかをユーザが確認できるようにするためです。ワークフロー申請時にどのグループの権限を変更するかなどを記載してもらう際に必要な情報だからです。 もう1つは各保管庫の運用管理者を把握し、ワークフローにおける承認ルートにその保管庫の運用管理者を入れるためです。1Passwordの管理者からでは、各保管庫が実際にどういった使われ方をしているのか分かりません。そのためメンバー追加などの依頼時に各保管庫の運用管理者の承認を確実に得た状態で、管理作業を行っています。 導入効果 パスワード管理の理想的な運用基盤を構築できたことが大きな効果でした。人に依存した運用ルールで安全にパスワードを管理することは限界があります。パスワード管理ツールを用いることで、半強制的にガイドラインに沿った運用へ切り替えることができました。また冒頭で記載した通り、パスワードを平文で保存することはセキュリティリスクになります。そのためパスワードを暗号化できるパスワード管理ツールは、セキュリティの監査に対する解決策の1つとしても有効です。 分かりやすい効果としては以下のようなものがありました。 共有アカウントのパスワードを安全に共有できる 様々なパスワードを覚える必要がなくなり、パスワードジェネレータによって強力なパスワードの生成が容易になりました。例えば自分が共有しているパスワードを変更したとしても、1Password上のパスワードさえ更新されていれば、他の人に新しいパスワードを都度共有し直す必要はありません。利用者は自分のマスタパスワードだけを覚えていればよく、パスワードが変更されたことを知らずともログインできるからです。 また、セキュアにID・パスワードの共有が可能になり、閲覧権限の範囲をコントロールし易くなりました。例えば範囲がチームをまたぐような場合でも、専用のグループを作って該当者を入れてそのグループに保管庫の閲覧権限を割り当ててあげればよいわけです。 多要素認証のワンタイムパスワードの代替 さらに便利だと思ったのは、多要素認証で使用するワンタイムパスワードを1Password上に保存できることです。Authenticator系のアプリと同じように秘密鍵を1Passwordに保存することで、1Password上にワンタイムパスワードが表示されるようになります。 通常、多要素認証ではSMS(ショートメッセージサービス)やAuthenticator系のアプリでワンタイムパスワード(認証コード)を得るため必ずモバイル端末が必要になってしまいます。多要素認証を1Password上に保存すれば端末に縛られない運用が可能になります。 具体的な手順を解説します。 まずは設定したいシステムの設定画面で、多要素認証の追加(もしくは変更)を実行し、その手順の中で秘密鍵を取得 Authenticator系のアプリで読み取るためのQRコードが発行される画面などで、秘密鍵を表示できる箇所があると思いますので調べてみてください。※各システムによって異なります 秘密鍵を入手したら1Passwordのアイテム編集に移動 1Passwordのアイテム編集画面でラベルの欄にある…(三点リーダー)アイコンを選択 ワンタイムパスワードを選択 ワンタイムパスワードの欄に、先程入手した秘密鍵を貼り付けて保存 以上の手順でワンタイムパスワードが表示されるようになりました。元の秘密鍵を入手した画面(手順1)に戻り、6で表示されているワンタイムパスワードを入力して認証し作業は完了です。 まとめ・残課題 実際に導入してみて、パスワード管理ツールに慣れていないユーザからはいまいちよく分からないツールだと思われてしまう印象がありました。そのためマニュアルとは別に使い方を解説する動画を制作し、ユーザがより理解しやすいように工夫しました。 パスワード管理ツールは入れて終わるツールではありません。例えばパスワードをブラウザへ保存してるユーザに対して1Passwordへの移行を促す必要があります。また、ドメイン侵害レポートをチェックし、漏洩したパスワードを使用しているユーザにパスワードの変更を呼びかけることも重要です。活用方法や正しいパスワードの管理方法などは都度啓蒙していく必要があると感じています。 最後に ZOZOテクノロジーズではコーポレートエンジニアリング部のメンバーを募集しております。 https://hrmos.co/pages/zozo/jobs/0000083 hrmos.co
アバター
はじめに こんにちは。ZOZO研究所の shikajiro です。主にZOZO研究所のバックエンド全般を担当しています。 先日のテックブログ ZOZOTOWN「おすすめアイテム」を支える推薦システム基盤 をご覧いただけたでしょうか。ZOZO研究所と連携するMLOpsチームのTJこと田島が執筆した記事なので是非御覧ください。 techblog.zozo.com この 推薦システム基盤の推薦アルゴリズム を研究開発する際に利用した 実験基盤 の開発メンバーとして参加し、そこでAI PlatformやKubeflowを活用して効率的なML開発を試みました。今回はこの実験基盤の開発を紹介したいとおもいます。 また、推薦基盤チームのてらちゃんこと寺崎が執筆した AI Platform Pipelines (Kubeflow Pipelines)による機械学習パイプラインの構築と本番導入 はKubeflowの基本を知る上で大変参考になりますので、合わせて御覧ください。Kubeflowの説明についてはこちらの記事が充実していますので、本記事では省略しています。 techblog.zozo.com さらに、私が半年前に執筆した 近似最近傍探索Indexを作るワークフロー を読んでいると少しだけ楽しさが増すのでぜひご覧ください。 techblog.zozo.com 目次 はじめに 目次 AI Platformの導入 Kubeflowを独自構築 ビルドフローの安定化 パラメータを外部ファイル化 TyperでのCLIによる簡単な実行 検証高速化のためのスキップ処理 失敗談と感想 パイプラインの無理な流用でエラーが頻出する Composerと比べてどうだった? まとめ さいごに AI Platformの導入 プロダクション環境の推薦基盤はGCP上で動いているため、ZOZO研究所による推薦アルゴリズムの研究開発もGCP上で行いました。 ZOZO研究所の研究開発メンバーは普段AWS環境での開発に慣れており、GCPで同等のサービスを模索しました。AI Platformである程度の開発効率を見込めそうなので、AI Platform Pipelinesを使って開発を進めました。 当時はAI PlatformやKubeflowに慣れているメンバーが居なかったため、試行錯誤しながら実験基盤の開発体験の向上を行っていきました。 Kubeflowを独自構築 開発当初のAI Platform PipelinesのKubeflowはversion 0.2と古く、何度か実験を動かすと原因不明のエラーで止まったり、実験結果が表示されない事もあり大変不安定でした。 AI Platform PipelinesではブラウザでKubeflow Pipelinesの構築ができます。しかし、バージョンの追従はGCP次第であり、自由に選択できません(執筆時点ではブラウザからKubeflow Pipelines 1.0が構築可能)。 そこで、AI Platform Pipelines運用を一旦諦め、当時最新のKubeflow Pipelines 1.0をGKEに独自に構築することで安定化させました(執筆時点ではKubeflow Pipelines 1.2が最新)。 その後、Kubeflow Pipelines 1.1がリリースされたのでインストールを試みたのですが、 当時のドキュメントの完成度が高くなかった こともあり、うまくいかず一旦諦めました。 ビルドフローの安定化 当初、開発者のローカルマシンでビルドしたDockerコンテナをGCRにアップロードしてパイプラインを実行していました。 latestタグを使ってしまうと予期せぬコンテナが使われてしまったり、かと言って都度異なるtagを指定するとパイプラインの中の実装を変える必要があったりと、なかなか手間がかかっていました。さらに、開発者のローカルマシンのCPUがDockerビルドにより消費してしまい、開発がし辛い状況でした。 そこで、DockerのビルドはすべてCloud Buildに変更しました。これによりローカルマシンのCPUを使うことはなくなり、一貫したビルドフローを使うことができるので、安定した開発を行うことができるようになりました。 この流れはこちらの記事を参考にしています。 cloud.google.com パラメータを外部ファイル化 開発時は開発者の任意のタイミングで何度も何度も学習と予測を行います。Kubeflow Pipelinesは一度作成したパイプラインをWeb画面からパラメータを変えて何度も実行できることが長所の1つです。 しかし、多くのパラメータがあるパイプラインの場合、実験の度にパラメータをすべて入力するのは少々手間です。開発者からは「気軽にコマンドラインから実験したい」と要望があったので、パラメータをyamlで管理してパイプラインにyamlを渡すことで、開発者のマシンから気軽に実行できるようになりました。 ファイルで管理することにより、設定したパラメータにコメントを残したり、Git管理できたり、入力ミスを減らすことができました。 settings.yamlの例 # project 全体の設定値 project_id: hello-zozo webhook: https://hooks.slack.com/services/hogehoge # Kubeflow Pipelines の設定値 experiment_name: Default run_name_base: example gcr_image: gcr.io/{project_id}/example:{tag} # 学習や検証・予測などで使うパラメータ number: 100 TyperでのCLIによる簡単な実行 パイプラインを実行するPythonコードに Typer を導入しました。TyperはPythonのCLIアプリケーションを簡単に作れるライブラリです。代表的なものにargparseやClickがありますが、Typerはさらに使いやすくされたものです。 普段の実験は以下のコマンドで実行しています。 # python <パイプラインを実行するTyper実装> run <パイプラインを定義したPythonファイルがあるディレクトリ> python pipeline.py run helloworld 複数のyamlファイルを指定し、連続して実験を行うこともできます。 python pipeline.py run helloworld --settings hoge.yaml --settings fuga.yaml 前回ビルドしたDockerをそのまま利用する場合、tag名を指定してCloud Buildをスキップすることもできます。 python pipeline.py run helloworld --tag 'docker-tag-name' 検証高速化のためのスキップ処理 パイプラインには学習パートと検証パートがあります。学習部分は前回実行して生成されたモデルを使って、検証部分だけ実装を変更して実行したい要望がありました。 Kubeflow Pipelinesには任意の位置から実行する機能はありません。新しくパイプラインを作ってはじめから実行する必要があります。 そこで、パイプラインの中にスキップフラグによる条件分岐する仕組みを追加し、生成済みのモデルを使って検証パートだけを行える仕組みを作りました。 パイプラインの実装はConditionで分岐させました。スキップする時、しない時両方のパイプラインの流れを定義することで、スキップを実現させています。 @dsl.pipeline(name="hello", description="hello world pipeline") def pipeline(skip_build: bool, ...): first = dsl.ContainerOp(...) last = dsl.ContainerOp(...) with dsl.Condition(skip_build == False, name="build"): # とても重たい処理 build = dsl.ContainerOp(...) build.after(first) last.after(build) with dsl.Condition(skip_build == True, name="skip-build"): last.after(first) パイプライン管理画面のGraphを見ると、スキップされていることが分かります。 失敗談と感想 みんな大好き失敗談を紹介します。 パイプラインの無理な流用でエラーが頻出する パイプラインの実行処理高速化、管理の利便性向上のため、次の改善を行いました。 既にパイプラインがある場合はそれを使い、パイプラインコードやパラメータに追加削除が合った場合はパイプラインの新しいバージョンを作って実行する この対応が原因でパイプライン実行時にエラーが頻出し、MLエンジニアが研究開発し辛い状況を作ってしまいました。 理由の説明の前に、Kubeflow Pipelinesで使われる3つの要素について説明します。 項目 必須 説明 Run 必須 最小実行単位。実行したワークフローをRunと表現する。 Experiment 必須 Runを束ねる存在。Pipelineを指定するか、同等のyamlを直接指定して動かす。Experimentの粒度はチーム内で取り決めるのが良さそう(アルゴリズム単位、日付単位、パラメータ単位など)。 Pipeline 任意 DAGの事。パラメータを指定すればすぐ動かせる。バージョン管理可能。繰り返し実行などする場合はPipelineが必要。 Run、Experiment、Pipelineの関係がちょっとややこしいため、注意が必要です。 Run 実行の最小単位です。Pipelineの1つの実行はRunで表されます。 Experiment Runを分かりやすく分類するために名前をつけるものです。複数のRunを束ねることができます。Runには必ずExperimentが必要です。指定しない場合はDefaultになります。1つのExperimentに異なるPipelineのRunをまとめても構いません。ディレクトリに近い感覚です。 Pipeline ここが曲者です。PipelineはDAGを定義したものになります。DAGを定義したソースコードを登録すれば、Pipelineとして一覧に表示されます。Pipelineを選びExperimentを指定して実行すれば、Runが新たに生成され実行されます。しかし、ワークフローを定義したPythonコードがあれば、Pipelineを登録していなくても実行できます。 「Pipelineが無くてもPipelineが実行できます」 何を言ってるか分からないと思いますが、Kubeflow PipelinesでのRunの実行にPipelineを登録しておく必要は無いのです。ソースコードがあれば直接実行できます。 Pipelines SDKを使って実行する場合、パイプラインとソースコードを同時に指定して実行できてしまいます。その時エラーにはならず、パイプラインが謎の挙動をするため気づくまで解決が困難になります。 これらが原因でしばらくの間、パイプラインの実行がややこしく、エラーが起きがちになっていました。現在はPipelineを登録せず、ExperimentとRunだけを使って実験を行うようにし、ある程度安定したらPipelineとして登録するようにしています。 Composerと比べてどうだった? ワークフローエンジンは他にAirflow(Composer)やDigdagがあります。それぞれ触ってみた私なりの感想を書いてみたいと思います。 その前に、MLにおいてワークフローはどうあるべきかを定義した Manifest for ML in production を紹介します。 Reproducible 9ヶ月前に学習したモデルが全く同じ環境で、同じデータで再学習でき、ほぼ同じ(数%以内の差)の精度を得られるべきである Accountable 本番で稼働しているどのモデルも、作成時のパラメータと学習データ、更に生データまでトレースできるべきである Collaborative 他の同僚の作ったモデルを本人に聞くことなく改善でき、非同期で改善とコードやデータのマージができるべきである Continuous 手動での作業0でモデルはデプロイできるべき。統計的にモニタリングできるべき https://docs.google.com/presentation/d/17RWqPH8nIpwG-jID_UeZBCaQKoz4LVk1MLULrZdyNCs/edit#slide=id.g6ad50e93e5_0_59 docs.google.com docs.google.com ただワークフローを動かすのではなく、関連したデータを正しく管理できるかがMLのワークフローエンジンに求められます。 Airflow GCPではComposerという名前でマネージドサービスになっており、世界的に人気があるワークフローエンジンです。 画像検索の裏側 でもComposerを使っています。少し前まではComposerで動いているAirflowのバージョンがかなり古く不安定でしたが、今は割と落ち着いています。Airflowの仕組み上、「ワークフロー実行中にDAGファイルを更新すると途中からは更新した内容で動き出してしまう」ようになっています。これはインタラクティブに開発できて自由度が高いといえば聞こえは良いですが、過去に動いたワークフローがどのバージョンのDAGで動いたか全く分からず保証も無いため、運用管理コストがとても高くなります。 Digdag GCPではマネージドサービスがなく、自前で構築する必要があります。Airflowと違ってDAGがきちんと管理されるので実行したワークフローがどのDAGで動いたのか、パラメータはなんだったのかが明白でとても扱いやすいです。しかし、最初に書きましたが自前で構築する必要があるため、初期構築・運用管理が大変になります。 Kubeflow AI PlatformとしてマネージドサービスがありDigdagと同じようにDAGがパイプラインとして管理されているので、実行したワークフローに使ったDAGやパラメータが一目瞭然です。実行がすべてk8sのpodとして動くのでCPU/GPU・メモリなどのリソース管理、スケールしやすいのもとても良いです。まだ1.0になったばかりで新機能が続々と追加されており、技術を追っていくのが大変ですが、MLの開発効率には大きく寄与しそうです。 Manifest for ML in production をふまえると、Airflowでは実現できないことが分かります。Digdagでもできそうですが、MLに特化したKubeflowはパラメータ管理に秀でておりManifestを実現するための第一の選択肢になりそうです。ただ、そもそも目的とするものが違うため「Kubeflowは良い、Airflowはだめだ」ということではないです。それぞれにメリット・デメリットがあるので見極める必要があります。 まとめ このような実験基盤の改善を経て、研究開発を日々行い、 推薦システム基盤の推薦アルゴリズム はできあがっていっています。 「Kubeflow Pipelinesを導入すれば全部うまくいく!」ということはなく、チームメンバーで使いやすいように日々改善を行い、安定させていく事が重要になります。まだまだKubeflow Pipelinesの力を最大限発揮できていませんが、今後はより安定したワークフローエンジンとして動かせるよう、改善していきたいと思います。 さいごに ZOZOテクノロジーズではZOZO研究所のMLエンジニア、バックエンドエンジニアのメンバーを募集しております。 https://hrmos.co/pages/zozo/jobs/0000029 hrmos.co hrmos.co
アバター
こんにちは、ECプラットフォーム部の濱砂とSRE部の杉山、柴田です。普段はZOZOTOWNのリプレイスや運用に携わっています。 ZOZOTOWNでは、アプリケーションレイヤーで使用しているキャッシュストアをAmazon ElastiCache(以下、ElastiCache)にリプレイスしました。本記事では、リプレイスに至った背景や方法、発生した課題などについてご紹介します。 プロジェクトの概要 キャッシュストアのリプレイスとは ZOZOTOWNでは、現在 システムリプレイス を進めています。その中で、Web(IIS)サーバーのメモリ領域に保持しているセッション情報を外部メモリストアにオフロードするプロジェクト(以下、セッションオフロード)があります。キャッシュストアのリプレイスはセッションオフロードのフェーズ1でターゲットとしていました。 後続のフェーズに本丸であるセッションオフロードや、Cookieやセッションに保持している情報のデバイス間共有などがあります。 リプレイス前のキャッシュストア キャッシュストアとクライアント側のライブラリは、パートナー企業様にご提供頂いている製品を使用しており、VBScriptからそのライブラリを呼び出してキャッシュの機能を実現していました。 キャッシュするデータの種類は主にDBから取得したレコードセットや参照系APIのレスポンスなどがあります。シンプルな構成で、長年運用してきたこともあり、非常に高速で安定したシステムでした。 リプレイスした背景 では、なぜ安定していたシステムをリプレイスする必要があったのかについてご紹介します。 運用コストの削減 リプレイス前は、自前でキャッシュストアを構築、運用していました。現時点で大きな課題ではありませんでしたが、今後サービスの規模が大きくなり、マイクロサービス化も進むことで運用するキャッシュストアの数や規模が増え、運用コストも増え続けていくことが予想されました。 そこで、フルマネージドサービスであるAmazon ElastiCacheやSaaS、PaaSなどのクラウドのサービスを活用して、運用の負担を抑えられるようにしました。 一般的な製品、技術への移行 先述の通り、リプレイス前は、パートナー企業様にご提供いただいているキャッシュストアを使用していました。安定して運用ができていましたが、一般的な製品や技術に移行できると、以下のような理由で今後のリプレイスが進めやすくなると考えていました。 VBScriptから別の言語に移行しやすい サードパーティ製の便利なツールやサービスと連携しやすい 新規参入メンバーがキャッチアップしやすい そこで、これを機に一般的な言語やツールでも扱えて、性能や機能面で要件を満たしていたRedisを採用することにしました。 セッションオフロードの検証 セッションオフロードが実現できると、以下のようなメリットがあり、今後のリプレイスがより進めやすくなります。 スティッキーセッションを外せるため、Webサーバーをクラウドに移行しやすくなる Webサーバーがステートレスになるため、Webサーバーの増減やリリースの作業が自動化しやすくなる セッションを各Webサーバーで共有できるようになるため、カート内の商品など、デバイス間で共有できていない情報を共有できるようになる しかし、実現するためには規模も大きく、ログイン認証や購入などのZOZOTOWNにとって重要な機能と密接に関わっているため、大きなリスクが伴います。 そこで、比較的規模も小さく、セッションよりも移行もしやすいキャッシュのElastiCacheへのオフロードを先に行い、セッションオフロードを実現できるかの検証も兼ねて、開発や運用の体制を整えることにしました。 これらの背景により、キャッシュストアのリプレイスを進めることにしました。 リプレイス後の構成 キャッシュストア キャッシュストアはElastiCacheにリプレイスしました。キャッシュエンジンはRedisを使用しています。構成は、後述する課題があったため、1つのRedisクラスターで各シャードにレプリカノードを持たせるような標準的な構成ではありません。2つのRedisクラスターを使用して、各シャードにプライマリノードを1つだけ持たせる構成にしています。なお、Redisクラスターはシャードを増やしてスケールアウトできるようにクラスターモードを有効にしています。 フェイルオーバー機能の課題 ElastiCacheにはフェイルオーバー機能があります。当初はその機能を使う予定で検証を行っていましたが、課題が2つありました。 1. 標準のフェイルオーバー機能では、ZOZOTOWNが必要とするSLOを満たせない SLOの確認をするために、検証を実施しました。 プライマリノード=1、レプリカノード=1の状態で、手動でフェイルオーバーをトリガーしてみました。裏でRedisのヘルスチェックシステムを動かして、フェイルオーバーしたシャードが使用可能になるまでの時間を計測したところ、弊社がZOZOTOWNに求めるSLOは満たせない結果となりました。 検証結果 ZOZOTOWNが求めるSLO 30秒~3分 30秒以内 2. シャード追加が終わらない事象の発生 サービスインした状態でシャード追加を実施した際に、スロットの移動が終わらず、プロセスが完了しないという事象が発生しました。その際は、サポートにご対応頂きましたが、復旧まで12時間程かかりました。 バックアップから新しいRedisクラスターをリストアして切り替える方法もありますが、データの整合性を保てないことや、復旧するまでに時間がかかってしまうという課題がありました。 以上の2つの課題を回避するために、Redisクラスターやノードに障害が発生しても、別のRedisクラスターにデータの整合性を保ちつつ、迅速に切り替える仕組みを導入することにしました。 Redisクラスターのフェイルオーバー 2つのRedisクラスターのうち、一方に障害が発生した場合は独自で構築したフェイルオーバーシステムでアプリケーション側の接続先を切り替えるようにしました。 正常時 PrimaryとSecondaryのRedisクラスターを稼働 Readは、Primaryにリクエスト、失敗時はSecondaryへリトライ Writeは、PrimaryとSecondaryにダブルライト(処理時間を抑えるために非同期で実行) Primary障害時 Primaryに障害が発生した場合は切り離す SecondaryをPrimaryに昇格させてサービスを継続 Secondary障害時は、Secondaryの切り離しを行い、フェイルオーバーは行わない 障害復旧時 障害から復旧したクラスターはSecondaryとしてサービスイン キャッシュサービスのため、数分程度でPrimaryとSecondaryのデータ整合性は回復 フェイルオーバーシステム フェイルオーバーシステムを構成する要素と利用している技術について紹介します。 スケジューラー 弊社で運用実績のあるKubernetes CronJobをスケジューラーとして利用しています。スケジュールやリトライ回数、多重起動の制御などをKubernetesのyamlファイルで管理して、Argo CDのAutoSyncを利用したGitOpsでのリリースを実現しています。 定期実行ジョブ ヘルスチェックを実行するジョブはBash+Pythonで作成しコンテナ化しています。15秒間隔で後述するヘルスチェックAPIをキックして、レスポンスをDatadogにカスタムメトリクスとして送信しています。インターバルは、SLOに合わせて設定可能となっています。 ヘルスチェックAPI JavaのMicroservices FrameworkであるSpring Bootを使用して、RedisクラスターのHealthCheck APIを作成しています。このAPIは、リクエストがあるとRedisクラスターの全てのノードの状態を確認して、クラスターの状態を管理情報用の冗長化したRedisに保存しています。また、Datadogへメトリクスを送信するために、Jobにヘルスチェックの結果も返すようにしています。 接続情報取得API JavaのMicroservices FrameworkであるSpring Bootを使用しています。保存している管理情報を元にRedisクラスターの接続先情報を返します。このレスポンスをもとに、Webサーバーの設定が切り替わります。 監視・アラート発報 Datadog Integrationsで、RedisクラスターのCPUやコネクションなどのメトリクスを監視しています。フェイルオーバーシステムからのカスタムメトリクス用いて、クラスターの死活、ノードの死活、フェイルオーバーシステムの死活、接続情報の監視など、全体の監視を行っています。 このフェイルオーバーシステムを導入したことで、Redisクラスターに障害が発生しても15~30秒程度でフェイルオーバーが完了するようになりました。 また、クライアント側の接続先情報も2秒程度で自動更新されるようになっており、弊社のSLOの基準を満たすことができています。 クライアントのライブラリ選定 既存のアプリケーションはVBScriptで書かれていますが、VBScriptからReidsクラスターを容易に扱えて、導入できそうなライブラリはありませんでした。また、別のライブラリに置き換えようとすると、呼び出す関数や使い方も変わってしまうため、影響範囲が大きくなるという課題がありました。 そこで、今回は現行で使用していたライブラリにRedisの機能を追加することにしました。このようにしたことで、改修範囲は限定的となり、大幅にコストを抑えることができました。両クラスターのデータを同期させておく必要があるため、アプリケーション側で両クラスターに書き込みを行うようにしています。 なお、ライブラリの入れ替えが必要なWebサーバーは数百台程あり、それらに対してリリース作業を行う必要がありました。従来の運用では、ロードバランサーからの切り離し、ライブラリの配布、レジストリの操作などの作業は手動で行っていたため、ミスも起こりやすく、時間もかかるという課題がありました。 そこで、今回からはAnsibleを用いてコード化、自動化しました。そうすることで、レビューがしやすくなってミスが起こりにくくなり、1日かかっていた作業も1時間程に短縮できました。このように、システムだけではなく、運用や開発体制についても同時にリプレイスを行ってきました。 現在の状況 このような方法でリプレイスを行ってきましたが、現在の状況は以下の通りです。 セールなどの高負荷な状況でもスケールアウトすることで安定稼働できている マネージドサービスやクラウドサービスを活用するようになったことでスケールしやすくなった 一般的な技術に置き換わったことで今後のリプレイスが進めやすくなった ElastiCacheへのリプレイスの知見やノウハウが得られ、開発や運用の体制も整ったため、セッションオフロードを進めやすくなった なお、全体のスケジュールは以下の通りです。去年の11月からスタートして今年の9月にフェーズ1のリリースが全て完了しています。チームのメンバーは数名で、いくつかのプロジェクトを兼務している状況で進めていました。 今後の課題 今のところ大きな課題は発生していませんが、今後、運用や次のフェーズを行っていくうえで解決していきたい課題がいくつかあります。今回はその一部を紹介します。 自動スケール 現状、シャードの追加や削除は手動で行っています。サイトの負荷状況に合わせて頻繁に行う作業なので、今後スケジュールや負荷状況をトリガーに自動化したいと考えています。 データ不整合の解消 2つのRedisクラスターで運用しているため、一方への書き込みに失敗すると、データの不整合が発生します。キャッシュの場合はオリジナルからも取得できるため、大きな影響はありませんが、セッションの場合は処理が中断したり、不具合が生じてしまうケースが多くあります。そのため、現在対策方法を検討しています。 ホットキーの解消 ホットキーが発生すると一部のノードにリクエストが集中してしまい、負荷を分散することが難しくなります。また、一部のノードだけスケールアップすることはできないため、全ノードのリソースを増やす必要があり、余計なコストがかかります。そのため、クライアント側のリファクタリングや、キーの複製などで対策をしたいと考えています。 まとめ ZOZOTOWNで使用しているキャッシュストアをどのようにリプレイスして、どのような課題があったかについて紹介しました。ElastiCacheへのリプレイスや使用を検討されている方の参考になれば幸いです。今後は課題への取り組みと、今回得た技術やノウハウを活かして、引き続きセッションオフロードを進めていきます。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれるエンジニアを募集しています。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
アバター
はじめに こんにちは、計測プラットフォーム部バックエンドチームのリーダー、児島( @cozima0210 )です。この記事では、今年4月に社内で策定されたOSSポリシーに基づいて、チームでOSSに貢献する活動に取り組んだ話を紹介します。社内のOSSポリシーが策定された経緯については、 こちら の記事をご覧ください。 なお、これは ZOZOテクノロジーズ Advent Calender 2020 #3 の5日目の記事です。 背景 私たちのチームでは、ZOZOSUIT/ZOZOMATから生成されるデータ及びそれを元とする計算データを高速に扱うため、様々なライブラリの使用を試みてきました。それらの中には、調査や試用の段階で不具合を発見したライブラリがありました。しかし、プロダクトの開発及び運用の過程では、そうした不具合の根本原因を探る時間を持つことは難しいものでした。そのため、代替ライブラリの選択を検討したり、使用する側でワークアラウンドの検討をすることにより、その問題を回避する状況が続いていました。 もちろん、その不具合がIssuesとして既出であるかの確認はしていました。ただ、それが認知されていない問題であったとしても、コミュニティに対する関わり方としてはそれ以上のことは何もできていませんでした。というのは、OSSに対する貢献が趣味の範囲であり、業務時間を使ってやるものではないという暗黙の了解があるように感じていたためです。しかし、こうした状況に対し、今年の4月に弊社ではOSSポリシーが策定されました。これにより、会社全体にOSSへの貢献に対するポジティブな機運の高まりを感じられるようになりました。 OSSにPRを送る会 チームの事情として、社内にOSSポリシーが策定された4月頃は、2月にZOZOMATをローンチした直後の時期でした。そして、ZOZOMATのアプリケーションが安定的に稼働するようになり、次のプロダクト開発に備える時期と重なっていました。そこで、これまでに累積的に認知していたライブラリの不具合に対し、チームとして取り組む活動をすることにしました。 具体的には、週に2時間チームで集まって、その不具合の調査をする時間を持つようにしました。まず、その不具合を初めて確認した時点からは時間も経過していたため、依然その不具合が発生する状況かどうかの確認をしました。そのため、改めてOSSのIssuesを網羅的にチェックし、不具合の再現に取り組みました。その不具合が再現可能であることを確認した上で、不具合を発生させている箇所を特定するためのコードリーディングをチームで行いました。そして、その問題が自分たちで修正可能であればPull Request(以降、PR)を送る、それが難しい場合でも少なくともIssueを立てることを目指しました。 こうした活動の中から、実際にIssueを送り、最終的にそのOSSでPRが作成されマージされるまでに至った事例を紹介します。 MessagePackを扱うためのライブラリ ZOZOSUITの開発を始める際、大容量な3Dメッシュデータをサーバーで扱わなければいけない要件がありました。これを扱うにあたり、JSONで扱うにはサイズが大きくなりすぎるため、これをいかに小さく扱うことができるかを調べていました。その時に、シリアライズ及びデシリアライズする方式として、MessagePackの調査をしました。私たちのチームでは、バックエンドの開発言語にScalaを採用しており、候補としては msgpack-java と msgpack4z の2つがありました。そして、Scalaでのライブラリの選定を行う場合、 jmh を使用し、簡易的なベンチマークを取得していました。そのため、以下のようなサンプルクラスのシリアライズとデシリアライズのパフォーマンス比較を行いました。 package jmh case class Mesh( faces: Seq[(Int, Int, Int)], vertices: Seq[(BigDecimal, BigDecimal, BigDecimal)] ) ライブラリの比較と見つかった不具合 まず、以下のコードでシリアライズのパフォーマンスを比較しました。今回の記事用に、Meshのデータサイズを実際よりも小さくしていますが、facesの要素数は約8,000、verticesの要素数は約4,000です。 build.sbt ... scalaVersion := "2.13.4" , libraryDependencies ++= Seq( "io.circe" %% "circe-generic" % "0.13.0" , "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.12.0" , "org.msgpack" % "jackson-dataformat-msgpack" % "0.8.20" , "com.github.xuwei-k" %% "msgpack4z-circe" % "0.12.0" , "com.github.xuwei-k" %% "msgpack4z-native" % "0.3.6" ) ... MessagePackSerializerBenchmark.scala package jmh import java.util.concurrent.TimeUnit import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.fasterxml.jackson.module.scala.experimental.ScalaObjectMapper import io.circe.generic.auto._ import io.circe.syntax._ import msgpack4z.{CirceMsgpack, CirceUnpackOptions, MsgOutBuffer} import org.msgpack.jackson.dataformat.MessagePackFactory import org.openjdk.jmh.annotations.{Benchmark, BenchmarkMode, Mode, OutputTimeUnit, Scope, State} @State(Scope.Thread) class MessagePackSerializerBenchmark { val mapper = new ObjectMapper( new MessagePackFactory) with ScalaObjectMapper mapper.registerModule(DefaultScalaModule) val codec = CirceMsgpack.jsonCodec(CirceUnpackOptions.default) val mesh = Mesh( Seq( ( 1 , 2 , 3 ), ( 4 , 5 , 6 ), ( 7 , 8 , 9 ) ), Seq( (BigDecimal( 1.11111 ), BigDecimal( 2.22222 ), BigDecimal( 3.33333 )), (BigDecimal( 4.44444 ), BigDecimal( 5.55555 ), BigDecimal( 6.66666 )), (BigDecimal( 7.77777 ), BigDecimal( 8.88888 ), BigDecimal( 9.99999 )) ) ) @Benchmark @BenchmarkMode( Array (Mode.Throughput, Mode.AverageTime)) @OutputTimeUnit(TimeUnit.MILLISECONDS) def byJackson(): Array [Byte] = mapper.writeValueAsBytes(mesh) @Benchmark @BenchmarkMode( Array (Mode.Throughput, Mode.AverageTime)) @OutputTimeUnit(TimeUnit.MILLISECONDS) def byMsgpack4z(): Array [Byte] = { val packer = MsgOutBuffer.create() codec.toBytes(mesh.asJson, packer) } } 結果は、以下のようになりました。 > jmh/jmh:run -i 20 -wi 20 -f 1 .MessagePackSerializerBenchmark. ... [ info ] Benchmark Mode Cnt Score Error Units [ info ] MessagePackSerializerBenchmark.byJackson thrpt 20 94 . 949 ± 4 . 638 ops/ms [ info ] MessagePackSerializerBenchmark.byMsgpack4z thrpt 20 167 . 041 ± 35 . 359 ops/ms [ info ] MessagePackSerializerBenchmark.byJackson avgt 20 0 . 017 ± 0 . 007 ms/op [ info ] MessagePackSerializerBenchmark.byMsgpack4z avgt 20 0 . 006 ± 0 . 001 ms/op この結果から、シリアライズにおいてはmsgpack4zのスコアが優れていることがわかりました。 続いて、以下のコードでデシリアライズのパフォーマンスを比較しました。 MessagePackDeserializerBenchmark.scala package jmh import java.util.concurrent.TimeUnit import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.fasterxml.jackson.module.scala.experimental.ScalaObjectMapper import io.circe.generic.auto._ import io.circe.syntax._ import msgpack4z.{CirceMsgpack, CirceUnpackOptions, MsgInBuffer, MsgOutBuffer} import org.msgpack.jackson.dataformat.MessagePackFactory import org.openjdk.jmh.annotations.{Benchmark, BenchmarkMode, Mode, OutputTimeUnit, Scope, State} @State(Scope.Thread) class MessagePackDeserializerBenchmark { val mapper = new ObjectMapper(new MessagePackFactory) with ScalaObjectMapper mapper.registerModule(DefaultScalaModule) val codec = CirceMsgpack.jsonCodec(CirceUnpackOptions.default) val mesh = Mesh( Seq( (1, 2, 3), (4, 5, 6), (7, 8, 9) ), Seq( (BigDecimal(1.11111), BigDecimal(2.22222), BigDecimal(3.33333)), (BigDecimal(4.44444), BigDecimal(5.55555), BigDecimal(6.66666)), (BigDecimal(7.77777), BigDecimal(8.88888), BigDecimal(9.99999)) ) ) val bytes: Array[Byte] = codec.toBytes(mesh.asJson, MsgOutBuffer.create()) @Benchmark @BenchmarkMode(Array(Mode.Throughput, Mode.AverageTime)) @OutputTimeUnit(TimeUnit.MILLISECONDS) def byJackson(): Unit = mapper.readValue[Mesh](bytes) @Benchmark @BenchmarkMode(Array(Mode.Throughput, Mode.AverageTime)) @OutputTimeUnit(TimeUnit.MILLISECONDS) def byMsgpack4z(): Unit = { val unpacker = MsgInBuffer(bytes) codec.unpack(unpacker) } } しかし、以下のようなログが出力されて、デシリアライズのパフォーマンスは比較できませんでした。 > jmh/jmh:run -i 20 -wi 20 -f1 .MessagePackDeserializerBenchmark. ... [info] # Run progress: 0.00% complete, ETA 00:08:00 [info] # Fork: 1 of 3 [info] # Warmup Iteration 1: <failure> [info] com.fasterxml.jackson.databind.JsonMappingException: Invalid type=DOUBLE (through reference chain: jmh.Mesh["vertices"]->com.fasterxml.jackson.module.scala.deser.GenericFactoryDeserializerResolver$BuilderWrapper[0]) [info] at com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:390) [info] at com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:361) [info] at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer._deserializeFromArray(CollectionDeserializer.java:363) [info] at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:244) [info] at com.fasterxml.jackson.module.scala.deser.GenericFactoryDeserializerResolver$Deserializer.deserialize(GenericFactoryDeserializerResolver.scala:82) [info] at com.fasterxml.jackson.databind.deser.SettableBeanProperty.deserialize(SettableBeanProperty.java:542) [info] at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeWithErrorWrapping(BeanDeserializer.java:565) [info] at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:449) [info] at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1390) [info] at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:362) [info] at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:195) [info] at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:322) [info] at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4591) [info] at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3641) [info] at com.fasterxml.jackson.module.scala.ScalaObjectMapper.readValue(ScalaObjectMapper.scala:203) [info] at com.fasterxml.jackson.module.scala.ScalaObjectMapper.readValue$(ScalaObjectMapper.scala:202) [info] at jmh.MessagePackDeserializerBenchmark$$anon$1.readValue(MessagePackDeserializerBenchmark.scala:17) [info] at jmh.MessagePackDeserializerBenchmark.byJackson(MessagePackDeserializerBenchmark.scala:40) [info] at jmh.generated.MessagePackDeserializerBenchmark_byJackson_jmhTest.byJackson_thrpt_jmhStub(MessagePackDeserializerBenchmark_byJackson_jmhTest.java:119) [info] at jmh.generated.MessagePackDeserializerBenchmark_byJackson_jmhTest.byJackson_Throughput(MessagePackDeserializerBenchmark_byJackson_jmhTest.java:83) [info] at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) [info] at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) [info] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) [info] at java.lang.reflect.Method.invoke(Method.java:498) [info] at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:453) [info] at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:437) [info] at java.util.concurrent.FutureTask.run(FutureTask.java:266) [info] at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) [info] at java.util.concurrent.FutureTask.run(FutureTask.java:266) [info] at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) [info] at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) [info] at java.lang.Thread.run(Thread.java:823) [info] Caused by: java.lang.IllegalStateException: Invalid type=DOUBLE [info] at org.msgpack.jackson.dataformat.MessagePackParser.getText(MessagePackParser.java:387) [info] at com.fasterxml.jackson.module.scala.deser.BigNumberDeserializer.deserialize(ScalaNumberDeserializersModule.scala:20) [info] at com.fasterxml.jackson.module.scala.deser.TupleDeserializer.$anonfun$deserialize$1(TupleDeserializerModule.scala:48) [info] at com.fasterxml.jackson.module.scala.deser.TupleDeserializer$$Lambda$139/00000000249C9820.apply(Unknown Source) [info] at scala.collection.immutable.Vector1.map(Vector.scala:1872) [info] at scala.collection.immutable.Vector1.map(Vector.scala:375) [info] at com.fasterxml.jackson.module.scala.deser.TupleDeserializer.deserialize(TupleDeserializerModule.scala:45) [info] at com.fasterxml.jackson.module.scala.deser.TupleDeserializer.deserialize(TupleDeserializerModule.scala:10) [info] at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer._deserializeFromArray(CollectionDeserializer.java:347) [info] ... 29 more 具体的には、msgpack-javaでscala.math.BigDecimalをデシリアライズする時に、エラーが発生していました。このエラーを確認した上で、当時としてはmsgpack4zを採用可能として、msgpack-javaについては、課題を認知しながらも放置した状態としていました。 不具合に関するIssues調査 今回、この不具合が変わらず発生している状況を確認した上で、コミュニティに認知されたIssuesとなっているか再確認しました。具体的には、IssuesをBigDecimalというキーワードと、今回発生したエラーメッセージの中からJsonMappingExceptionというキーワードの検索をしました。しかし、当該の問題が取り上げられていることは確認できませんでした。 不具合の修正方法 ここで、改めてコード上で問題となる箇所の特定と、考えられる修正方法の検討を開始しました。主には、例外を吐く直前ではどのような処理コードを通り、どうあれば正常に動作するか調査をしました。具体的な例外を吐く箇所は、BigDecimalのデシリアライズ処理で呼ばれているgetTextというインタフェースの実装内部で、数値を文字列化する想定がされていないことが原因でした。今回の修正としてはインタフェースを提供するライブラリ(jackson-module-scala)と具象実装を提供するライブラリ(msgpack-java)の組み合わせで発生する問題であったため、どちらにも修正パッチを作ることが考えられました。そのため、具体的な修正をする場合の提案実装をしたブランチを自身のforkしたプロジェクト上で作り、それぞれにIssuesを送りました。 Issuesを送る、PR作成、そしてマージ それぞれに送ったIssuesが以下です。 github.com github.com 送ったIssuesのうちjackson-module-scalaの方には、その日のうちにコメントが付きました。 https://github.com/FasterXML/jackson-module-scala/issues/458#issuecomment-664845092 これはいただいたコメントを読んでから気づいたことでしたが、私の提案した修正により、既存のユニットテストを壊してしまっていました。そのため、この方法はjackson-module-scalaを既に使用しているユーザーに影響を与えるため、受け入れられないという内容でした。このコメントをもらってから、この問題はmsgpack-javaの方の修正が取り込まれなければいけないことが見えてきました。ただ、今回の問題がScala側のライブラリとの組み合わせの問題であるために、Java側のライブラリの修正をすることが許容されるかという懸念が湧いてきました。それからしばらくして、Java側のライブラリの作者からもIssueに対するコメントがついていました。 https://github.com/msgpack/msgpack-java/issues/526#issuecomment-668080211 内容としては、BigDecimalでそんな問題は発生しないというものでしたが、これは私の説明が不足していたことで認識の齟齬を発生させていたためでした。改めて、この問題がjava.math.BigDecimalではなく、scala.math.BigDecimalで発生する問題であることを伝えました。 https://github.com/msgpack/msgpack-java/issues/526#issuecomment-668664372 そのコメントをした後で、jackson-module-scalaのコントリビューターから私の修正をフォローするコメントをいただきました。 https://github.com/msgpack/msgpack-java/issues/526#issuecomment-668736359 これについては、msgpack-java側で今回の修正をするモチベーションが低く、このIssueがスルーされてしまう状況をサポートしてくれたものだと感じました。こうしたライブラリのコントリビューター間で、協調をしてくれたことは、とてもありがたい体験でした。 結果、msgpack-javaの方で、以下のPRが作成されました。 https://github.com/msgpack/msgpack-java/pull/527 私の提案した実装についてのユニットテストも追加されたものとなっており、コミュニティの中でのレビューも通り、無事このPRがmasterに取り込まれ、私のIssueもクローズされました。 その後、この修正についてのリリースを出して欲しいというコメントがつけられました。 https://github.com/msgpack/msgpack-java/issues/526#issuecomment-691268437 そして、 0.8.21 のバージョンで修正リリースがされました。 この修正バージョンを使って、MessagePackのデシリアライズのパフォーマンス比較もできるようになりました。 > jmh/jmh:run -i 20 -wi 20 -f1 .MessagePackDeserializerBenchmark. ... [info] Benchmark Mode Cnt Score Error Units [info] MessagePackDeserializerBenchmark.byJackson thrpt 20 108.086 ± 5.613 ops/ms [info] MessagePackDeserializerBenchmark.byMsgpack4z thrpt 20 154.674 ± 7.616 ops/ms [info] MessagePackDeserializerBenchmark.byJackson avgt 20 0.014 ± 0.006 ms/op [info] MessagePackDeserializerBenchmark.byMsgpack4z avgt 20 0.009 ± 0.001 ms/op スコアとしては、msgpack4zがシリアライズとデシリアライズのどちらのケースにおいても、優れていることがわかります。しかし、依存するライブラリの親和性を理由にmsgpack-javaを採用したいケースも考えられるため、同様の不具合に悩まされる人にとっての有効な修正として大きな成果に繋がったと思います。 まとめ 今回の取り組みを振り返り、OSSに貢献するということをチームとして取り組むことができたことは、とてもいい体験でした。また、この取り組みは「OSSにPRを送る会」として開始しましたが、現在では「OSSに貢献する会」と改名しました。その意図として、今回挙げた事例のように、私たちはPRを送ったわけではありません。また、OSSのコミュニティに関わりを持つことは、敷居の高さを感じることも少なくなく、そこにPRを送るということになれば尚更です。しかし、単にIssueを送ることからでも、OSSになんらか貢献するという気持ちを普段OSSを利用する者として継続的に持つということの大切さをメンバー間で再認識できました。 こうして、3か月ほどで累積されていた不具合はIssuesとして全て報告済みとなりました。週に2時間の取り組みも、月に2時間へと頻度は下がりました。しかし、現在もメンバーが興味のあるライブラリのIssuesの中から自身が取り組めるものを探したり、それをきっかけにOSSのコードを読む時間として継続しています。 さいごに 計測プラットフォーム部バックエンドチームでは、ZOZOMATをはじめとする計測技術でよりオンラインでの購入体験を向上させたいバックエンドエンジニアを募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! www.wantedly.com
アバター