TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

939

こんにちは! ZOZOテクノロジーズの中坊( e_tyubo )です。 概要 私が所属しているマーケティングオートメーション(以下MA)を担当するチームでは、ユーザ毎にパーソナライズされた情報をメールやアプリのPush通知で配信しています。その際に利用するZOZOTOWNやWEARのデータは我々が管理する専用のデータベースに集約されています。このデータベースには日々のユーザの行動ログが記録されるため、必然的にデータ量は大きいものになります。このような巨大なデータを管理することに特化したデータベースはデータウェアハウス(以下DWH)と呼ばれていて、実績の可視化やマーケティングに活用するための分析等の用途に使われます。 MAチームでは配信内容をユーザ単位で最適化するためにDWHを利用していて、最近まではIBM PureData System for Analytics(以下PureData)を利用していました。しかし、PureDataの保守が終了してしまうため別のデータベースに移行する必要がありました。我々は互換性を考慮し、後継機であるIBM Integrated Analytics System(以下IIAS)へ移行することにしました。 PureDataとIIASはどちらもIBM製品であるため高い互換性がありますが、一部の機能については挙動の違いもあり個別に対応が必要となりました。本記事では新しく導入したIIASの特徴と、移行の際にぶつかった問題とその解決方法について紹介します。 IIASの特徴 最初に、IIASがどのようなデータベースなのかを紹介します。 IIASはデータベースそのものを指すのではなく、サーバ筐体や周辺ツールを含めたパッケージ製品の名称です。 公式ドキュメント に書かれている通り、Db2 Warehouseという製品を中核とし、様々な周辺ツールが提供されています。IIASはクラウドサービスではなく、計算機本体を購入し管理する必要がある、いわゆるオンプレミスなデータベースです。よく利用されるBigQueryの様なフルマネージドなDWHとは異なり、サーバ本体を自社で保有する必要があります。 Db2 Warehouse 以前まで利用していたPureDataはPostgreSQLベースでしたが、IIASは先程も述べた通り、Db2 Warehouseという製品を利用しています。Db2はIBMが開発したデータベースで、1983年にリリースされた歴史の長いRDBMSです。元々はWebアプリケーション等の裏側でリアルタイムな処理を主として利用されていたデータベースですが、改良が重ねられ、DWHとしての機能も果たすようになっていきました。 Db2 Warehouseもまた、データベースだけを指す言葉ではなく、データベースエンジンと周辺機能をDocker上で動作させるために必要な機能を提供する製品の名称です。 公式ドキュメント の図にも書かれている通り、GUIでデータベースの操作ができるWebコンソールやログインユーザの管理用のLDAPが標準で搭載されており、セットアップ後は様々な機能が利用可能になります。特徴的な点としてDb2 Warehouseの機能はDocker Imageで提供されている点が挙げられます。無料版も存在していて、Docker Hub 1 か、IBM Cloud Container Registryから入手が可能です。開発用の環境を自前で構築する際には利用が可能ですので、導入前の検討材料や本番で実施しにくいテストの実行環境として活用ができます。 複数のノードで分散処理 IIASには物理サーバが複数台含まれており、それぞれのサーバを使って1つのデータベースを動かします。RDBMSではしばしばシャーディングと呼ばれる方法でデータを分割をして負荷分散させることがありますが、Db2 Warehouseは標準でそのような仕組みを備えています。 また、Db2 Warehouseでは物理的なサーバ単位だけでなく、サーバの中に論理ノードという単位でデータを分けて保持できます。これはあるサーバの中でさらに論理的にデータを分割し、保持する仕組みです。Db2 Warehouseではノード毎に保持しているデータを、ノード毎に処理することで効率的にSQLを実行する仕組みを備えています。この場合CPU、メモリ、ストレージは同一サーバ上のものを共有することになります。しかし、利用可能なCPUのコア数が論理ノード数に対して十分な数がある場合はノード単位で同時に処理をすることで処理効率の改善が期待できます。 下記のようなSELECTステートメントを例に考えてみましょう。 SELECT * FROM SampleTable SampleTableというテーブルから全てのカラムをSELECTする単純なクエリです。1つのテーブルですが、Db2 Warehouseの仕組み上データは複数サーバに分散されているため、取得経路は下図のようになります。 この図は物理サーバが2台あり、その中でさらに論理ノードに分かれているケースを表しています。図のように全ての論理ノードからデータを集めて、全て取得し終わったら要求した全てのデータを返します。このようにDb2 Warehouseはそれぞれのノードで処理を並列実行することでクエリのパフォーマンスを効率化しています。 分散キー 先ほどデータが各ノードに分散配置されると書きましたが、分散させるには分ける基準となる情報が必要です。そのため、Db2 Warehouseには分散キーという属性をカラムに指定できます。 分散の方式にはハッシュ分散とランダム分散の2種類があり、分散キーの作成時にどちらを利用するか指定できます。ランダム分散は適切なキーが存在しない場合にDb2 Warehouseが自動で分散キーを決定する方式ですが、利用するケースがなかったため説明は割愛します。 ハッシュ分散は、カラムの値を用いて分散させる方式です。例えば会員情報を管理するためのMemberというテーブルがあった場合、分散キーは下記のように指定できます。memberIdはユニークな値のみ保持することとします。 CREATE TABLE Member ( memberId INTEGER , name VARCHAR ( 50 ) ) DISTRIBUTE BY HASH(memberId) ORGANIZE BY COLUMN ハッシュ分散は、指定したキーの値と格納先ノードを対応させるマップを作成し、それに沿ってデータを配置します。下記の画像はノードが4つ存在する場合に作成されるマップのイメージ図です。 値の重複がある場合、データは同じノードに格納されるため、均一化のためにはカーディナリティ(値のばらつき具合)が高い列を選択する必要があります。今回のケースではmemberIdがユニークであるため、均一にデータが配置されます。 次に分散キーの効果的な利用方法について説明します。並列処理を効率的に実行するためには各ノードで検索条件にマッチするデータ量を均一化することが必要です。もし特定ノードにデータが偏っていた場合、そのノードで実行されるデータ取得処理が終わるまで全体の処理を終えることができません。 JOINを例に考えてみます。例えば、下記のような会員のお気に入り商品の情報を管理するFavoriteProductテーブルがあるとします。 CREATE TABLE FavoriteProduct ( memberId INTEGER , productId INTEGER ) DISTRIBUTE BY HASH(memberId, productId) ORGANIZE BY COLUMN 下記のクエリでお気に入り情報に紐づく会員情報を取得するケースを考えましょう。 SELECT productId ,name FROM FavoriteProduct INNER JOIN Member ON Member.memberId = FavoriteProduct.memberId この場合FavoriteProductが保持しているmemberIdを使ってMemberテーブルを検索するため、分散キーを利用することになります。MemberテーブルのmemberIdはユニークなキーであり、均一に分散されているため十分効率的にSELECTを実行できると考えられます。 このように、検索条件に対していかにデータを均等に分散させるかがパフォーマンスを考慮する上で重要になります。 つまり分散キーを選択する際には、下記の条件を満たしているケースが望ましいです。 カーディナリティが高い列を指定する 検索条件に指定される列を指定する 必ずしもこの条件を満たす必要は無いですが、分散キーを選択する際の基準として考慮しています。 列指向と行指向 Db2の特徴として列指向と行指向を両方サポートしているという点が挙げられます。行指向はレコード単位でデータを保持し、列指向はカラム毎にデータを保持する方式の事を言います。 MySQLやPostgreSQL、SQL Server等のRDBMSはテーブルの中から特定のレコードを取得する事に重きを置いているため、行毎にデータを保持しておくことが効果的です。しかし、Db2 WarehouseのようなDWHは大量のデータを保持する事を想定しており、列単位にデータを保持することで大量データの中から特定の列のみを使った処理を効率的に実行できます。 Db2 Warehouseは列単位でデータを保持することにより、特に下記の利点があるようです。 不要列を読み込む必要がないため、メモリを効率的に利用できる 列の中に同じデータが含まれているケースが多いため、データ圧縮率が高い IIASで利用する際にはデフォルトで列指向なテーブルが作成されますが、明示的に行指向テーブルを作成することも可能です。 CREATE TABLE Member ( memberId INTEGER , name VARCHAR ( 50 ) ) DISTRIBUTE BY HASH(memberId) ORGANIZE BY ROW 上記のように ORGANIZE BY ROW を指定すると行指向テーブルが作成されます。DWHの用途を考えると列指向なテーブルを使うことの方が多いと思いますが、必要に応じて使い分けることができます。 データスキッピング 一般的なRDBMSでは検索を効率的に実施するためにindexを利用しますが、列指向で保持しているデータについては同じ方式では有効に検索できません。そこでindexではなくデータスキッピングという仕組みを用いてカラムの中のデータ検索を効率化しています。 データスキッピングとは、一定のデータ件数毎に各列が持つデータの最大値と最小値をメタデータとして保持し、レコードが存在する範囲を絞り込みやすくする機能です。 例えばMemberテーブルに登録時刻を保持するregistDtというカラムがある場合を考えます。 CREATE TABLE Member ( memberId INTEGER , name VARCHAR ( 50 ), registDt DATETIME ) DISTRIBUTE BY HASH(memberId) ORGANIZE BY COLUMN 下記のWHERE句を用いて2020年4月に登録した会員をSELECTします。 WHERE registDt >= ' 2020-04-01 00:00:00 ' AND registDt < ' 2020-05-01 00:00:00 ' この時データスキッピングの機能により、対象レコードを効果的に絞り込むことが可能です。 上図のように、メタデータを使って2020年4月1日より古いデータと2020年5月1日より新しいデータが存在する範囲を不要だと判断できるため、処理が効率化されます。 Webコンソール IIASをセットアップすると、Db2 Warehouse専用のWebコンソールが利用可能になります。様々な機能が利用可能ですが、特に頻繁に利用する機能は下記の通りです。 クエリの実行 テーブルやViewの定義の確認 実行中クエリの確認(実行計画も参照可能) ユーザの管理 ディスク、CPU、メモリの利用状況の確認 Mac用に標準ツールが提供されていないこともあり、Webコンソールからクエリを実行することが多いです。 PureDataからIIASに移行する際の注意点 次はPureDataからIIASへ移行する際に課題となったポイントをまとめます。 冒頭でも述べた通り、PureDataとDb2 Warehouseは高い互換性がありますが、一部挙動に相違が見られます。ここでは移行に当たって書き換えや、対応が必要となった機能の一部を紹介します。 FROM句は必須 PureDataで現在時刻を取得する時は、FROM句を指定せず下記のクエリで実現可能です。 SELECT CURRENT_DATE しかし、Db2 Warehouseでは明示的に指定が必須です。 SELECT CURRENT_DATE FROM SYSIBM.SYSDUMMY1 そのため単純に値の中身を確認するような場合には、上記のように、SYSIBMスキーマに用意されたダミーテーブルを利用します。 もしくは、 VALUES を使うことでも同様の結果が得られます。 VALUES ( CURRENT_DATE ) LENGTHとCHARACTER_LENGTH PureDataでは文字列に対してLENGTH関数を使うと文字数が返ってきましたが、Db2 Warehouseでは挙動が異なります。Db2 Warehouse内で扱う文字列の文字コードがUTF-8となっている前提で考えます。 SELECT LENGTH ( ' Hello ' ) FROM SYSIBM.SYSDUMMY1 SELECT LENGTH ( ' こんにちは ' ) FROM SYSIBM.SYSDUMMY1 どちらも文字数で見ると5ですが、結果は下記の通りです。 5 15 これはDb2 WarehouseがLENGTHによって文字数ではなく、文字列の合計バイト数を返していることが原因です。UTF-8の文字列ではアルファベットは1バイト、ひらがなは3バイトで表現されます。 単純な文字数をカウントしたい時は、CHARACTER_LENGTHを使って書くことで期待した結果を得ることができます。 SELECT CHARACTER_LENGTH( ' Hello ' ) FROM SYSIBM.SYSDUMMY1 SELECT CHARACTER_LENGTH( ' こんにちは ' ) FROM SYSIBM.SYSDUMMY1 ウィンドウ関数の中でrandomが使えない 我々のチームで管理しているクエリの中にはウィンドウ関数を利用しているものが多数ありますが、Db2 Warehouseにはその中でrandom関数を利用できないという制約があります。例えば、Memberテーブルが住んでいる都道府県のidを保持しているとしましょう。 CREATE TABLE Member ( memberId INTEGER , prefectureId INTEGER , name VARCHAR ( 50 ) ) DISTRIBUTE BY HASH(memberId) ORGANIZE BY COLUMN この時、下記のように都道府県ごとにランダムな値を割り振るクエリは利用できません。 SELECT memberId ,RANDOM() OVER(PARTITION BY prefectureId) FROM Member random関数を利用する場合はウィンドウ関数の中での利用を回避する必要があります。 下記のようにサブクエリで割り振ったランダムな値を基準にROW_NUMBERを振り直すことで、無作為に選ばれた連番をSELECTすることが可能です。 SELECT memberId ,ROW_NUMBER() OVER(PARTITION BY prefectureId ORDER BY randomNum) AS randomRowNum FROM ( SELECT memberId ,prefectureId ,RANDOM() AS randomNum FROM Member ) AS A NULLの順序が異なる NULLを含むカラムを並び替える際に、NULLが先頭に来るか末尾に来るかはデータベースによって異なります。PureDataとIIASの並び順は表の通りです。 DB ASC時のNULLの順序 DESC時のNULLの順序 PureData 先頭 末尾 IIAS 末尾 先頭 例えば何かしらの値でスコアリングして、大きい順に並べた結果を先頭から特定件数を抽出するようなクエリでNULLがヒットするようになってしまう可能性があります。 ORDER BY ASC のケースで対応が必要な箇所はありませんでしたが、上記のようなケースが存在する場合は ORDER BY DESC を利用している箇所で修正が必要です。 この件の対処法をサポートに問い合わせたところ、システム全体でNULLの並び順を指定する設定はないとのことでした。そのため、 ORDER BY DESC を使っている箇所で NULLS LAST を指定することで対処しました。 ORDER BY DESC NULLS LAST 上記のように指定することで対処が可能です。対応としてはクエリ自体をNULLを利用しない方式に組み替えることも考えられますが、影響範囲と改修コストの兼ね合いで一括置換できる方式を選択しました。 SORTHEAP DWHは巨大なデータを扱うため、メモリの利用量も多くなります。特にSORTHEAPと呼ばれるメモリは、Db2 Warehouseがクエリを実行する際にJOINやソートの時に一時的にデータを格納するために使われており、パフォーマンスに大きく関わります。PostgreSQLで言う所のwork_memのようなものです。 この値を超えるデータ量を扱うと、メモリに収まらないためディスク書き込みによるパフォーマンス低下を招く可能性があります。また、オプティマイザがメモリ消費の少ない実行計画を選択することで実行時間が長くなる可能性もあります。処理に十分なメモリが割り当てられていないとパフォーマンスが顕著に低下することがあるためSORTHEAPの設定値は調整を検討する価値があります。 ただし、SORTHEAPの値はあくまで1つのステートメント単位で利用できるメモリ量であり、同時接続数が増えれば増えるほど全体として利用するメモリの量は増えてしまいます。そのためDb2 Warehouseではシステム全体でSORTHEAPとして利用できるメモリの総量をSHEAPTHRES_SHRによって規定しています。全ての実行中クエリがSORTHEAPを最大まで利用している場合、理論的に同時接続できる最大数はSHEAPTHRES_SHR/SORTHEAPによって決まります。SHEAPTHRES_SHRを超えてメモリを利用することはできないため、SORTHEAPに割り当てるメモリの量には注意が必要です。 まとめ 本記事ではIIASの特徴とPureDataからIIASへ移行する際に考慮すべきポイントをまとめました。 MAチームではMA基盤上のアプリケーションの開発・運用だけでなく、データ連携の仕組みの開発・運用も行なっており、ビッグデータを活用したデータ基盤の改善に取り組んでいます。目立たない分野ですが非常にサービスへの影響は大きく、挑戦のしがいがあるチームです。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com Docker Hubで提供されるDocker Imageについては2020年3月31日以降はメンテナンスが終了しており、非推奨となりました。 ↩
アバター
はじめに こんにちは。SRE部USED基幹インフラの先崎です。 ZOZOUSEDは2016年、当時の株式会社ZOZOUSED システム部のインフラチームにて、基幹のデータベース(以下DB)をMySQLからMicrosoft SQL Server(以下MS SQL)に移行しました。 移行してから今日まで、データロストなどの大きなトラブルは起きておりません。そのため、当時の変更理由から選定時に検討した内容、その際に発生した課題の解決方法を簡単に紹介させていただきます。MySQLからMS SQLへ移行を検討しているどなたかのお役に立てられたら幸いです。 MySQLからの移行検討 当時、自動切替(フェイルオーバー)を実現しようとすると費用が大きくかかってしまうため、手動切替(スイッチオーバー)の形をとっていたことが最大の要因でした。 MySQLの正常時イメージ図 MySQLの障害時イメージ図 まず、当時のMySQLの構成と、前述以外の問題点についてご紹介します。 移行前の構成 構成 ライセンス費用がかからないもので構成していました。 CentOS 2台 MariaDB(Galera Cluster冗長化) 問題・課題点 スペック起因の可能性もありますが、当時課題として感じていた点は以下の通りです。 リスナーなどはなかったため、DBの切替時はDNSレコード変更かプログラムで見ているDBのIPやホスト名を手動変更しないといけなかった バックアップ・リストアに非常に時間がかかっていた レプリケーション(同期)の遅延があり、時々整合性が取れていないことがあった 良い点 反対に、MySQLを運用していてメリットに感じた点もご紹介します。 安価 セカンダリDBもReadDBとして使用可能 phpMyAdminでスロークエリ、ログ取得など監視可能 テーブル単位のバックアップ・リストアが可能 移行後の必須要件 上記の状況を踏まえて、移行後の必須要件を以下の2点に設定しました。 2台以上での冗長化かつ障害時の自動切替が可能であること 監査的の観点から、個人情報の入ったデータへのアクセスを監視できること この観点で製品選定を実施しましたので、その際の比較した内容をご紹介します。 比較選定のための調査 オンプレ、AWS、Azure、GCPの各サービスを比較検討することにしました。 オンプレミスサーバー「MS SQLのAlways On構成」 構成 必要条件を満たす構成を検討しました。Enterpriseエディションは予算的に選択できませんでした。 Windows Sever 2016 Standard + MS SQL Server 2016 Standard Always On 可用性グループでの冗長化 問題・課題点 上記構成にてMySQLと比較した場合に生じた課題点は以下の通りです。 StandardエディションでのAlways Onのため、冗長化のセカンダリDBがReadもできず、分析・開発に使えない テーブル単位でのバックアップやリストアができない スロークエリなどの監視をどうするか検討が必要 クライアント数が多くてCALライセンスでは非常に高額になってしまうため、CPUコアライセンスを選択せざるを得ず、さらにCPUコア数でも金額が変わるため、CPUコアを少ないもので抑える対策が必要 オンプレのため、老朽化対策のため数年後にはリプレイスが必要 監査のクエリ取得の検討が必要 良い点 検証時にMS SQLの良いと感じた点は以下の通りです。 冗長化がMS SQL任せでよく、レプリケーションの遅延もほぼ気にならない Always Onのリスナーがあるので、切り替えダウンタイムがほぼない Microsoft SQL Server Management StudioでGUI操作が容易 AWS「Aurora or RDS(MariaDB)」 既存MySQLを踏襲し、MariaDBで検討しました。 問題・課題点 以下のような、クラウド環境特有の課題がネックとなりました。 社内ネットワークとAWSネットワークの連携は複雑になりがちだが、Direct Connectなどを使うと高額 個人情報をクラウドに預けるというセキュリティ面の懸念(2016年当時) 監査のクエリ取得の検討が必要 AWS経験者不足(2016年当時) 良い点 AWS検討時に良いと感じた点をご紹介します。 高可用性、基本的にはブラウザからのGUIベースで設定が可能 MySQLからMariaDBへの移行を想定のため、データ変換はそこまで大変ではない オートスケールやインスタンスタイプの変更など柔軟に可能 MS Azure「Azure SQL Database」 早々に費用面がネックになり、調査はあまり行いませんでしたが、簡単に課題点、良い点をご紹介します 問題・課題点 当時のマネジメントパネルが非常に使いづらく、わかりにくかった DTUなども検討したが、想定よりも高額だった 良い点 高可用性、基本的にブラウザからのGUIベースで設定が可能 GCP「Google Cloud SQL」 当時、情報が少なく、カスタマイズもあまりできない状況だったので早々に断念しました。 以上の検討内容を元に、選定の際に重要視していた点をまとめたのが以下の表です。 検討結果とMS SQLの選定理由 可用性 フェイルオーバー コスト バックアップ 監査対応 オンプレ 〇 実装容易 〇 〇 〇 AWS ◎ 実装可能 △ ◎ × Azure ◎ 実装可能 × ◎ 不明 GCP 〇 不明 不明 不明 不明 以上の調査結果を踏まえて、MS SQLを選定することにしました。理由について以下で説明します。 MS SQLの選定理由 細かい理由は他にもありますが、下記6点が大きな選定理由となりました。 AWSとイニシャル・ランニングコストで比較計算したが、オンプレのMS SQL構成は数年以内にペイできてしまう結果となった 当時のZOZOUSED内では、AWSやAzureの導入実績がなかったため、コアシステムの構築・運用が不安視された Always Onによる冗長化が簡単で高性能であり、リリースまでが短期間で済む想定だった Webサーバーはオンプレに置いておく想定だったため、DBをクラウドにしてしまうと流れるデータのセキュリティや欠損率を考慮しなないといけなくなる クラウドでのクエリ監査対応が、当時の環境では実現が難しかった 構築当時「ZOZOUSEDのお客様の重要な個人情報」をクラウドに置くことに対して社内での懸念があった Always On正常時のイメージ プライマリ障害時のイメージ(自動切換え) 以上のことから、MS SQLを導入しました。その結果フェイルオーバーなど、MySQL運用時に抱えていた課題を解決できました。ただし、導入~運用フェーズにおいていくつか別の課題に直面したので、それらをどう解決したかご紹介します。 MS SQLで生じた課題と解決方法 1. 冗長化のためのセカンダリDBがReadもできず、分析・開発に使えない 分析チームからは「AM5:00に同期される前日のデータを使った解析をしなければならない」という要件がありましたが、本番DBは個人情報があること、また分析の高負荷を容認できないことから直接の本番DBへのアクセスは不可としていました。 さらに、StandardエディションでのAlways Onのため、冗長化のセカンダリDBがReadもできません。 しかしながら、移行のおかげでバックアップおよびリストアにかかる時間がMySQLに比べて飛躍的に短くなりました。そのため、同期完了後にバックアップし、そのバックアップデータを別サーバーにリストアするスクリプトを作成することで、前日のデータが入った開発用DBを始業開始までに用意することが可能になりました。これにより、上記の要件を解決することができました。700GB程度のDBがバックアップ開始からリストア完了まで3時間程度で完了できています。 2. テーブル単位でのバックアップやリストアができない 業務上、稀にバックアップから復元したいデータが出てきてしまうということがありました。MySQLであれば、バックアップからテーブル単位などでリストアできました。MS SQLではそれができないのですが、MS SQLへ切り替えた結果、フルバックアップのリストアが1時間程度で済むようになったため、フルリストアしたものからテーブル単位などでデータを移動することが可能なため、この制約は問題にはなりませんでした。 3. 個人情報の入ったデータへのアクセスを監視しなければならない 今まで通り、監査要件として個人情報の入ったテーブルへのアクセス履歴を追えるようにする必要があったため、MS SQLの監査機能を利用し、ファイルにクエリを吐き出し、そのファイルをDBサーバーにインサートすることでこの要件を満たすことができました。実際に設定した監査の例をご紹介します。 MS SQL監査設定の例 まず、SQL Server Auditを使用してサーバーの監査オブジェクトを作成します。 こちらでは、吐き出すログファイルの保存先や容量、クエリ遅延秒数などを設定しています。 USE [master] CREATE SERVER AUDIT [インスタンスの監査名称] TO FILE ( FILEPATH = N'ファイルを吐き出すパス' ,MAXSIZE = 200 MB ,MAX_ROLLOVER_FILES = 2147483647 ,RESERVE_DISK_SPACE = OFF ) WITH ( QUEUE_DELAY = 1000 ,ON_FAILURE = CONTINUE ,AUDIT_GUID = '****************' ) ALTER SERVER AUDIT [サーバーの監査名称] WITH (STATE = ON) GO 続いて、データベース監査の仕様を作成します。 監査要件から、すべてのテーブルに対する「DELETE、INSERT、UPDATE」と特定のテーブルに対しての「SELECT」を取得するように設定しています。 USE [データベース名] CREATE DATABASE AUDIT SPECIFICATION [作成するDB監査の名称] FOR SERVER AUDIT [サーバーの監査名称] ADD (DELETE ON DATABASE::[データベース名] BY [dbo]), ADD (INSERT ON DATABASE::[データベース名] BY [dbo]), ADD (UPDATE ON DATABASE::[データベース名] BY [dbo]), ADD (SELECT ON OBJECT::[dbo].[テーブル名] BY [dbo]), ADD (SELECT ON OBJECT::[dbo].[テーブル名] BY [ユーザー名]) WITH (STATE = ON) GO 4. スロークエリなどの監視をどうするか検討が必要だった SolarWinds社のDPA(Database Performance Analyzer)を導入することで解決しました。2016年当時、国内実績はあまりなかったようですが、試用してみて有用だと判断し導入しました。トラブルシューティング時の調査、インデックス不足、日次業務の変化把握などに役立っています。 Database Performance Analyzerの画面 トップ画面 何が要因で時間がかかっているのかわかる 日毎のクエリ実行時間が可視化 時間毎のクエリ実行時間が可視化 5. Always Onのリスナー(ADへのコンピュータアカウント)が自動で作成されない Webに公開されている構築手順やブログを頼りに構築検証していましたが、なぜか手順通りにいきませんでした。Always Onの設定時、AD上にリスナー用のコンピュータアカウントを事前に作成し、アカウントのセキュリティに対して使用するクラスターアカウントのフルコントロールのアクセス権を付与しなければならないということがありました。 6. フェイルオーバー後にユーザーがログインできなくなる 包含データベースの有効化はしていましたが、ユーザーが包含ユーザーになっていませんでした。 そのため、サーバーユーザーとデータベースユーザーの紐づけが切れてしまっていました。 包含データベース設定 + 包含ユーザーを作成することで、フェイルオーバーしても包含ユーザーでアクセスできるようになりました。 包含データベースの有効化 包含データベースに設定 7. 包含ユーザーだとbulkのコマンドが使えない サーバーにユーザーを作成し、bulkadminのロールに属する必要がありました。 8. 上記bulkユーザーがフェイルオーバー後にログインできなくなる 包含ユーザーではないため、フェイルオーバー時にサーバーユーザーと包含ユーザーの紐づけが切れてしまうことが発覚しました。フェイルオーバー後に手動で以下のコマンドを実行し、サーバーユーザーと包含ユーザーの紐づけを修正することで解決しています。 USE [データベース名]; ALTER USER [包含ユーザー名] WITH LOGIN = [サーバーユーザー名]; 9. トランザクションログが肥大化する データベースの復旧モデルは完全復旧モデルを選択しました。完全復旧モデルの場合は、トランザクションログはログファイルにどんどん蓄積されてしまい、何も対応しないとディスクが枯渇し、書き込み操作が一切できなくなってしまう恐れがあります。1日1回の完全バックアップに加えて、メンテナンスプランで15分ごとにトランザクションログをバックアップすることでこまめにログの切り捨てを行うことでこの懸念点を解消しました。 10. ファイアウォールによりAlways Onの同期が停止する Windowsファイアウォールでアクセスを絞る案件がありました。その際、レプリケーションに使われているポート(デフォルトは5022)に気づかず同期を止めてしまいました。Windowsファイアウォールにそのポートを開けて復旧しました。 これは、バックアップデータが非常に増えたことから発覚しました。データベースのログファイルが肥大化しており、トランザクションログが退避されなくなったことによるものと推測しています。 さいごに SRE部USED基幹インフラでは、導入後からOS、DB、NWでチューニングを続けています。今期、Webサーバーをクラウドに移行する予定もありチューニングや見直しを引き続き実施し、さらなる高速化・安定化を目指しています。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは、WEAR部運用改善チームの三谷です。 僕たちのチームのミッションは、WEARの運用においてエンジニアが行なっている作業内容を見直し、本来注力すべきサービス開発に取り組める時間を増やせるよう、運用を改善することです。時にはシステムを開発して自動化をしたり、時にはその業務自体が本当に必要なのかを考えて業務フローを整えたりしています。 そんな僕たちが、昨今の新型コロナウィルスの流行によってリモートワークがメインとなった状態で、メンバー間でペアプロをしたいと思った時に採用した方法がとても良かったので紹介したいと思います。 はじめに 新型コロナウィルスの世界的な流行を受け、弊社では2月半ばからリモートワークが推奨となり、3月末には全員が強制的に在宅勤務となりました。WEARプロダクトのエンジニアも2月より、各自自宅で業務を行なっています。 リモートワークになって懸念されたのが、メンバー間で十分なコミュニケーションが取れなくなることでした。特に、オフィスで自然と行われていた雑談レベルの会話が、リモートワークではできなくなってしまうことが問題と思われました。というのも、これまでは何気ない雑談から問題に気づいたり、問題が発生したタイミングで担当者に直接話しかけて素早く対処できたということが多々ありました。 また、業務の合間にメンバーと世間話をして、お互いの性格や価値観を知るということもできました。Slackなどのテキストチャットでコミュニケーションをとることもできますが、いくらタイピングが速くてもこういった雑談レベルのコミュニケーションを全てSlackで行うのは不可能と思われます。 個人的には、リモートワークでのテキストチャットによって、以下の点でコミュニケーションが低下することを懸念していました。 テキストを打つのが面倒で必要以上に発言しなくなる点 相手の状況がわからず、質問するタイミングが難しくなる点 急ぎの用であっても、相手が見てくれないと返事が来ない点 周りの状況がわからず、雑談を投げかけにくい点(雑談を投げかけても反応がないとソワソワしますよね) この他にも、リモートワークによる懸念はたくさんあると思います。 以降は、これらの懸念を解消するために行った対策を紹介します。 Discordでオフィスにいるかのように話しかける場を作る 上記の背景があり、弊社ではリモートワークで気軽にメンバーに話しかけることができるようにDiscordを導入しました。 Discordとは Discordは、もともとゲーマー同士が音声で会話をしながらゲームを進めるためのボイスコミュニケーションツールでした。サーバー(コミュニティ)を作成してチャンネルを用意することで、チャンネルにいるユーザーでボイスチャットができます。その他にも、テキストチャット機能やビデオ通話、画面共有もでき、とても便利なツールです。 弊社では、サーバーをプライベート設定することで使用を社内のメンバーに限定し、チームや個人のチャンネルを作成して様々な会話が行えるプラットフォームとして活用しています。 Discordについての詳細は、HPをご覧ください。 discord.com どこが便利? 前述しましたが、Discordではチャンネルに入ることにより、チャンネル内のユーザーとボイスチャットができます。チャンネルはいくつも作成できるので、自分のチャンネルを作成している人もいます。 僕のチームでは、基本的に始業後はDiscordにログインしてチームもしくは自分のチャンネルに入り、マイクのみミュートの状態にしています。そうしておくと、僕に話しかけたい人は同じチャンネルに入ってきて「みっきー!」と話しかけることで、僕には相手の呼びかけが聞こえます。そして、マイクのミュートを解除するだけで会話を開始できます。 上図では、マイクをミュートにして自分のチャンネルにいる状態です。その他にも、数名が集まってミーティングをしていたり、僕と同じように個人のチャンネルで待機している人がいるのも一目でわかります。個人のチャンネルに一人でいる人は誰とも話していないことがわかるので、気軽に話しかけられます。また、誰かと話しているきに、チャンネルにふらっと他の人が入ってきて雑談が盛り上がるということもよくあります。 このように、Discordを利用すると雑談レベルの会話がリモートワークでも気軽に実現できます。これはとても画期的なことだと僕は思います! 最近では、WEARプロダクトのみんなが一日中Discordにログインしているので、オフィスで働いているときと同じ感覚で効率よく会話ができています。 しかし、いくつか課題も残っています。Discordに常駐することは強制ではないので、ログインしていない人に話かけたい場合はSlackなどで呼びかけて返答を待つ必要があります。 また、どこかのチャンネルで複数人が会話している様子を伺えても、雑談をしているのか込み入った話をしているのか判断ができないのでチャンネルに入りづらい時もあります。 例えばDiscord常駐を強制にしたり、雑談は「雑談チャンネル」で行うなどルールを決めると、もっと気軽に話しかけられる環境が作れて快適になりそうです。 Discord × Visual Studio CodeのLive Shareプラグインを用いたリモートワークでのペアプロの試み ここで本題のリーモートワークでのペアプロについて紹介します。ペアプロには、先ほど紹介したDiscordとVisual Studio Code(以下、VSCodeと表記します)のLive Shareプラグインを組み合わせて使うのがオススメです! Live Shareプラグインとは Live Shareプラグインは、簡単に言うとVSCode上で複数人が同じファイルをリアルタイム編集できるVSCodeの拡張機能です。ホスト側が招待リンクをゲスト側に渡すことで、ゲスト側がVSCode上でホスト側のファイルを編集できます。ファイルの他にも、ターミナルやローカルサーバーも共有できます。 marketplace.visualstudio.com 細かい使い方は公式情報をご覧ください。 visualstudio.microsoft.com Live Shareの機能を使ってみて個人的に驚いたのは、ゲスト側がホスト側のローカルファイルを触ることができることです。ホスト側のVSCodeのセッションにゲスト側がログインすることで、ホスト側のVSCodeで開かれているフォルダのファイルにアクセスできるので、ローカルファイルであっても共同編集できます。ソースコードを一旦GitHubへ上げ、プルリクを作成してレビューしてもらうなどの作業に比べると、ソースコードの共有と確認が格段にスムーズです。 Discordで話しかけて、Live Shareでペアプロをするととても効率的! ここまでの結論として、リモートワークでは常にDiscordにログインした状態でいるのがオススメです。そして、ペアプロをしたいときは相手のいるチャンネルに行って「今ペアプロできますか?」と話しかけて、Live Shareを始める。こうすることでオフィスにいる時と比べても遜色ないほど、スムーズにペアプロを行うことができます。 さらに、Discordの画面共有機能も組み合わせると、ホスト側のローカル環境のプログラムの動作を一緒に確認しながら進められてとても便利です。僕たちのチームではこの方法を取り入れることで、リモートペアプロをしながらでも効率よくソースコードだけでなく表示の確認もできるようになりました。 DiscordとLive Shareプラグインのテキストチャット/ボイスチャットの比較 実は、Live Shareにもテキストチャットとボイスチャット機能があります。それぞれ、Live Share ChatとLive Share Audioという拡張機能をインストールすることで利用できます。尚、近日中にこれらの機能を統合したLive Shareがプレビュー公開される予定です。 詳しくは以下の記事をご覧ください。こちらの記事では、VSCodeでのテキストチャットとボイスチャットを使ったデモも確認できます。 devblogs.microsoft.com 実際に使ってみた感想 実際に使ってみた感想としては、テキストチャットについてはVSCodeアプリケーションから離れなくてもテキストが送れるので便利に感じました。しかし、現状ではテキストしか送れず、スクリーンショットやテキスト以外のファイルを送って説明するなどができないので少し歯がゆく感じました。その点では、他のテキストチャットアプリの方が快適です。 また、ボイスチャットも試したのですが音声が聞こえない状態になってしまい、その不具合は解消できませんでした。 何れにしても、Discordで相手に呼びかけてペアプロを始めるというフローが快適だと思うので、今の業務利用の仕方では、あえてLive Shareのボイスチャットは使わないかなと思いました。テキストチャットに関しては、ペアプロ中にURLなどちょっとしたテキストを送りたいときに便利なので使えそうです。 まとめ リモートワークにおけるペアプロの方法を紹介してきました。これまでの内容をまとめると、僕たちのベストプラクティスはDiscordでマイクをミュートにして常駐しておき、気軽に話しかけてLive Shareでペアプロをするという方法です。 この方法で、お互いに離れたところでリモートワークをしていても、話しかけてから10秒ほどでペアプロを開始することが可能になります。オフィスにいる時と同じくらいスムーズですよね! リモートワークでのコミュニケーションやペアプロの方法に困っている方は、是非、この記事の内容を参考にしていただけると幸いです。また、この他にもリモートワークを快適に行う方法があれば教えていただけると嬉しいです。 さいごに 業務以外でも僕たちのチームでは、リモート環境でもチーム内コミュニケーション促進を狙えるゲームとして人狼ゲームをやってたりします。人狼ゲームはコミュニケーションがとれるだけでなく、その人の人間性や意外な一面を垣間見ることができるので、発足して間もない僕のチームにとってはとても実りがありました。 今回のリモートワークのように突然業務をする環境が変わった時、それまでとは違って不便になることは当然出てきます。そんな中で、新しい仕組みを作って快適にしていくということは、エンジニアが得意にしていることだと思います。環境の変化に負けず、新しい楽しみを見つけていきましょう! ZOZOテクノロジーズでは、一緒に楽しく働いてくれる方を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
アバター
ZOZOテクノロジーズ SRE部の川崎( @yokawasa )です。ZOZOTOWNのアーキテクチャをマイクロサービスで再設計してリプレイス化を推進するチームに所属しております。 本記事では、このZOZOTOWNのマイクロサービスプロジェクトで実践している継続的インテグレーション/継続的デリバリー(以下、CI/CD)についてご紹介します。 はじめに まずはじめに、本記事に登場する中心的なキーワードであるCI/CDと、Infrastructure as Code(以下、IaC)について簡単に説明します。 IaCとは、インフラ構成をコード化して、そのプロビジョニングを自動化する手法です。コード化されたファイルはコードリポジトリで管理することが多く、また、IaCを実現するためのツールやサービスの利用が不可欠になります。 CI/CDは、その名の通り、CI(継続的インテグレーション)とCD(継続的デリバリー)の組み合わせです。CIはコードの変更を起点にコードの静的分析、ビルド、テスト、成果物の生成などの実行を自動化する手法です。一方、CDはCIで検証・テストされたコードや成果物を目的の環境に自動でデプロイする手法です。IaCと同様に、CI/CDを実現するためのツールやサービスの利用が不可欠です。 CI/CDの導入は組織全体のパフォーマンス向上のため CI/CDを導入する目的として、運用負荷の軽減や、自動化されたテストやデリバリによる品質の向上などが一般的に言われていることかと思います。もちろんこれらはCI/CDを導入する理由の1つではありますが、あまり強いものではありません。我々が考えるCI/CDを導入する真の目的は組織全体のパフォーマンス向上です。 CI/CDを導入することで、本来人間が行う必要のない作業は自動化され、結果的に空いた時間で本質的な作業に取り組むことができます。たとえば、開発者であればアプリ開発、SREであればサイトの信頼性向上といった本質的な作業に時間を割くことができます。これらのことはメンバーのモチベーション向上と、組織全体の生産性向上につながるものと考えています。 また、CI/CDパイプラインに落とし込むためには、作業の手順化とIaC化が必要になります。IaC化の恩恵は非常に大きく、変更履歴が管理できるようになり、属人的になりがちな運用・開発業務を非属人化しスケールさせることができるようになります。結果、組織全体のパフォーマンス向上が期待できます。 CI/CDを起点としたサービス環境の構築・更新 本プロジェクトにおいて、CI/CDはサービス環境の構築・更新の起点となる、非常に重要なプロセスであると捉えています。CI/CDの基本方針は以下の通りです。 可能な限りサービス環境の構成はIaC化する サービス環境の構築・更新はCI/CDパイプラインから行うことを基本とする もちろんIaC化、CI/CD化が技術的難しい、もしくはコスト的に見合わない場合は例外的にやらないものの、手動実行のための手順の文書化を徹底する 継続的な更新ループ CI/CDを中心としたサービス環境の更新ループの基本的な流れを紹介します。 先述の通り、CI/CDを起点としたサービス環境の構築・更新を基本方針としています。これをコード作成・変更から、CI/CDパイプラインからのサービス環境へのデプロイ、監視や試験結果によるフィードバックを元にさらにコード更新されるまでの一連のループを表したのが次の図です。 開発・検証、ステージング、本番環境など、ネットワークやKubernetesクラスタレベルで分離された複数環境を用意 GitHubでPull Request(以下、PR)を投げたら、自動的にテストを実行 PR上でレビューが完了するとソースコードがmasterブランチへマージされ、CI/CD経由で開発・検証とステージング環境に自動デプロイ リリースはreleaseブランチ管理で、releaseブランチにPRを投げてレビュアーによる承認、ソースコードのマージを経て、CI/CD経由で本番環境に自動デプロイ サービス環境は常に自動監視され、異常が検知されると関係者にアラート通知 ステージング環境で結合試験、負荷試験を実施し、本番想定の環境で機能要件・非機能要件の確認実施 この更新ループのゴールは、局所的ではなく全体として機能するよう継続的にアップデートできることです。そのため、開発・検証環境やステージング環境など、分離された環境でのアプリやインフラの変更点による影響の洗い出しはとても重要なプロセスになります。 また、上述の通り、デプロイ前のチェック機構として必ずレビュープロセスを通るようになっています。以下、レビュープロセスのポイントとフロー図になります。 PRにおいてマージ前に必ずレビュープロセスを通るように、 ブランチの保護機能 でレビュー必須を有効化している ソースコードの品質のみならず運用的な観点でコードの変更点による運用プロセスへの影響もレビューする 必要に応じて関連文書も含めてレビューする CI/CDパイプラインの紹介 マイクロサービスプロジェクトで導入しているCI/CDパイプラインを簡単に紹介します。 CI/CDを起点として構築・更新しているインフラやプラットフォームサービスのパイプラインをインフラCI/CDとして、またコンテナーアプリのパイプラインをアプリCI/CDとして紹介します。 なお、本プロジェクトではクラウド基盤にAWSを採用しており、アプリはコンテナーベース、コンテナーアプリの基盤プラットフォームにはマネージドKubernetesサービスであるEKSを採用しています。 また、AWS外のサービスでは、APMにDatadog、障害通知にPagerDutyを採用しています。CI/CDパイプラインは、インフラ・アプリ共にGitHub Actionsを活用して構築しています。 インフラCI/CD ネットワークやストレージを始め、IAM、監視、ログ解析サービスなどAWSで利用しているほぼすべてのリソースをAWS CloudFormation(以下、CFn)を活用してIaCを実施しています。CFnが対応しているリソースをCFnテンプレートに落とし、これをGitHubでコード管理します。CI/CDパイプラインで、CFnのChange Setの仕組みを使ってリソースの変更点を検証し、サービス環境にデプロイします。 また、コンテナーアプリはEKSにデプロイします。アプリのデプロイに必要なKubernetesリソースはすべてKubernetes YAMLファイルに落とし、これをGitHubでコード管理します。CI/CDパイプラインでYAMLの差分チェックを行い、EKSにデプロイします。 さらに、AWS外のDatadogやPagerDutyなどの利用サービスについても、Terraformを活用してIaCを実施します。これらの構成定義をTerraform構成ファイルに落として、GitHubでコード管理します。CI/CDパイプラインでterraform plan/applyコマンドを実行しサービス環境にデプロイします。 各CI/CDの利用IaCツールとパイプラインの内容を表したものが下図です。 アプリCI/CD 先述の通り、EKSにデプロイするためのコンテナーアプリをビルド、静的分析して最終的にECR(AWSのマネージドコンテナーレジストリサービス)にPUSHするところまでをCI/CDで行います。構築するコンテナーの構成をDockerfileに記述し、インフラCI/CDと同様にアプリのコードを含めてGitHubでコード管理します。 なお、ここでは内容については紹介しませんが、データ生成用の定期実行ジョブやDBのマイグレーション処理についてもCI/CDで実行している例もあります。 以上、簡単ではありましたが、CI/CDパイプラインの活用事例を紹介しました。 DevとOpsの責任分界点 DevとOpsは弊社組織でいうと、開発者(Dev)と運用管理を行うSRE(Ops)になります。マイクロサービスプロジェクトにおいては、その名の通り、開発者は基盤となるアプリケーションの開発を、SREはマイクロサービスの信頼性を向上させるための運用・開発作業を主に行います。 ただし、DevとOpsの間に責任分界点が明確に分けられているかと言えばそうではありません。基本的な精神としてソフトウェアをスピーディかつ安定的にデリバリすることが中心にあり、そのためには双方が協力し、それぞれのドメインを越えることは多々あります。つまり分界点は良い意味であいまいです。 たとえば、アプリケーションコードにSREが手を入れることもありますし、アプリのCI/CDパイプラインをSREが作成することもあります。また、開発者がKubernetes YAMLファイルに手を入れることもあります。 まとめ 本記事ではZOZOTOWNのマイクロサービスプロジェクトで実践しているCI/CDについてご紹介しました。 徹底したIaC化とCI/CDを起点としたサービス更新の取り組みがご理解いただけたのではないでしょうか。 なお、今回のトピックはCI/CDでしたが、マイクロサービスプロジェクトにおいては数多くのおもしろい技術的取り組みやチャレンジがあります。これらについては他のメンバーがどこかで共有してくれるはずですのでご期待いただければと思います。 登壇資料や関連サイト 著者のボスにあたる、 そのっつ さんが Infra Study Meetup#1 にて、リモートワークとIaCやCI/CDとの相性の良さについて発表しました。その時のスライドが こちら です。 docs.google.com ZOZOテクノロジーズは、 技術書典 応援祭 にて、有志で制作した技術同人誌【ZOZO TECH BOOK VOL.1】の頒布を行いました。著者は同誌において「第3章 速習GitHub Actions 〜 明日からの充実GitHub自動化ライフのための凝縮ポイント 〜」というタイトルで、GitHub Actionsについての記事を執筆しました。現在も引き続き BOOTHにて頒布中 ですのでご覧いただけたら幸いです。 zozotechnologies.booth.pm さらに、関連イベントとして、4/28と4/30の二日間、頒布をしている ZOZO TECH BOOK VOL.1 の解説会をオンラインで実施しました。著者は、そこで同記事に関する解説を行いました。そこでの発表スライドもよろしかったらご参照ください。 さいごに ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
アバター
こんにちは。ZOZOテクノロジーズの廣瀬です。 弊社ではサービスの一部にSQL Serverを使用しています。先日、「普段は数10ミリ秒で実行完了するクエリが、たまに5秒間実行され続けて最終的にタイムアウトするので調査して欲しい」という依頼を受けました。調査方法を整理して最終的に原因の特定とタイムアウト発生の防止まで実現できたので、一連の流れとハマった点、今回のようなケースでの調査に使える汎用的な調査手法をご紹介したいと思います。 SQL Server以外のRDBMSをお使いの方にも、「SQL Serverではこんな情報がとれるのか。MySQLだったら〇〇でとれる情報だな」というように比較しながら読んでいただけると嬉しいです。 初めに浮かんだ仮説 依頼を受けて最初に思い浮かんだのは、「ブロッキングが起きているのでは」という仮説でした。そこで、 拡張イベント を使ってブロッキングを検出できる状態にしました。取得するように設定したイベントは以下の4つです。 blocked process report(ブロッキングが一定時間続いた場合に発生) attention(アプリから受け取った途中終了の要求に応じてクエリを停止したときに発生) sql_batch_completed(クエリの実行完了時に発生) RPC_completed(クエリの実行完了時に発生) sql_batch_completedとRPC_completedの違いについては、sql_batch_completedはクエリがTransact-SQLバッチとして実行されたときに発生します。一方で、RPC_completedはクエリがリモート プロシージャ呼び出しとして実行されたときに発生します。blocked process reportについては こちら に詳しく解説されています。今回はプログラム側で設定するタイムアウト値が5秒であるため、blocked process thresholdには3秒を設定しました。また、sql_batch_completedとRPC_completedについてはResult=Abort(異常終了)でフィルタを設定しました。Result=Abortでフィルタしたsql_batch_completedとRPC_completedイベントが起きた同一時間帯でblocked process reportイベントが発生していれば、ブロッキングが原因でタイムアウトした可能性があります。また、blocked process reportイベントの中身を見ることで、Result=Abortで終了したクエリがブロッキングされていたかどうかを特定できます。 調査した結果、Result=Abortでフィルタしたsql_batch_completedとRPC_completedイベントが起きた同一時間帯で、blocked process reportイベントは起きていませんでした。したがって、今回のタイムアウトの原因はブロッキングではないことが分かりました。 次の調査に移るための情報取得 ブロッキングではないことが分かったので、次の仮説を立てようと、考えられる原因について考えました。しかし、「いつもは高速なクエリが突然遅くなる事象」の原因としては考えられるパターンが複数存在します。そのため、次のステップとして、いきなり仮説を立てるのではなく、次の調査に移るためにどういった情報を取得すれば良いかを考えました。 タイムアウトしたクエリが5秒間実行され続けるとき、何らかの「待ち状態」になっていると考えられます。ブロッキングは数ある待ち状態の一つでしかなく、どういった待ち状態であったかを後追いできる仕組みを作ることで調査に必要な情報が取得できるのではと考えました。 そこで、実行中のリクエストを取得できる DMV である、 dm_exec_requests の実行結果を1秒ごとにテーブルにダンプする仕組みを作りました。このテーブルには、実行中クエリの現在の待ち状態と前回の待ち状態が「wait_type」、「last_wait_type」としてそれぞれ格納されています。そのため、1秒ごとにこの情報をダンプしておくことで、タイムアウトにいたるまでの待ち状態の遷移を後から確認することができます。 まず、ダンプするためのテーブルを以下のクエリで作成します。 select getdate() ,a.session_id ,a.request_id ,a.start_time ,a.status ,a.command ,a.database_id ,a.blocking_session_id ,a.wait_type ,a.wait_time ,a.last_wait_type ,a.wait_resource ,a.open_transaction_count ,a.open_resultset_count ,a.transaction_id ,a.cpu_time ,a.total_elapsed_time ,a.scheduler_id ,a.reads ,a.writes ,a.logical_reads ,a.text_size ,a.transaction_isolation_level ,a.lock_timeout ,a.deadlock_priority ,a.row_count ,a.prev_error ,a.nest_level ,a.granted_query_memory ,a.executing_managed_code ,b.login_time ,b.host_name ,b.program_name ,b.client_interface_name ,b.login_name ,b.memory_usage ,b.total_scheduled_time ,b.last_request_start_time ,b.last_request_end_time into dm_exec_requests_dump from sys.dm_exec_requests a with (nolock) join sys.dm_exec_sessions b with (nolock) on a.session_id = b.session_id where 1 = 1 次に、以下のクエリをジョブなどで実行します。dm_exec_requestsをSELECTした結果を、無限ループで1秒ごとにテーブルにINSERTしていきます。 set nocount on while ( 1 = 1 ) begin insert into dm_exec_requests_dump select getdate() ,a.session_id ,a.request_id ,a.start_time ,a.status ,a.command ,a.database_id ,a.blocking_session_id ,a.wait_type ,a.wait_time ,a.last_wait_type ,a.wait_resource ,a.open_transaction_count ,a.open_resultset_count ,a.transaction_id ,a.cpu_time ,a.total_elapsed_time ,a.scheduler_id ,a.reads ,a.writes ,a.logical_reads ,a.text_size ,a.transaction_isolation_level ,a.lock_timeout ,a.deadlock_priority ,a.row_count ,a.prev_error ,a.nest_level ,a.granted_query_memory ,a.executing_managed_code ,b.login_time ,b.host_name ,b.program_name ,b.client_interface_name ,b.login_name ,b.memory_usage ,b.total_scheduled_time ,b.last_request_start_time ,b.last_request_end_time from sys.dm_exec_requests a with (nolock) join sys.dm_exec_sessions b with (nolock) on a.session_id = b.session_id where a.session_id > 50 and datediff(s, a.start_time, GETDATE()) >= 1 waitfor delay ' 00:00:01 ' end あとは、タイムアウトしたクエリのセッションIDとタイムアウト時間帯でテーブルをフィルタします。タイムアウトしたクエリのセッションIDは、Result=Abortでフィルタしたsql_batch_completedかRPC_completedイベントを確認することで取得できます。 select top 100 datediff(ms, start_time, collect_date) as duration_ms ,* from dm_exec_requests_dump with (nolock) where collect_date between ' 2020-04-10 11:59 ' and ' 2020-04-10 12:10 ' and session_id in ( 559 ) order by collect_date タイムアウトしたクエリは上図のように、常にPAGEIOLATCH_SHで待っている状態でした。ここで、PAGEIOLATCH_SHという待ち状態について説明します。SQL Serverでは、必ずメモリからデータを読むのですが、以下のいずれかの流れをとります。 メモリに欲しいデータがある場合:メモリから直接読む メモリに欲しいデータがない場合:ディスクからメモリに読み、その後メモリから直接読む PAGEIOLATCH_SHは「ディスクからメモリに読む」ときに発生する待ちです。そのため、物理読み取りによる待ち時間が起因して普段よりもクエリ実行に時間がかかっている状況だったといえます。ブロッキングのように他のリクエストと競合しているわけではありませんでした。 実行プランが突発的におかしくなり、通常よりも大量のIOを発生させている可能性を考え、タイムアウトしたクエリの実行プランのプロパティを確認してみました。その結果、「十分な数のプランが見つかりました」という理由でクエリの最適化が途中で終わっていました。最適化が途中終了する程度にシンプルなクエリなので、実行プランが突発的におかしくなるわけでもなさそうでした。 別の可能性として、バッチ処理等で大量にデータを読み取る処理が発生したタイミングで、メモリからデータがキャッシュアウトされたことで通常時よりも多くのデータのディスク読み取りが発生する状況を考えました。そこで、[SET STATISTICS IO ON]をつけてタイムアウトしたクエリ実行し、各テーブルの読み取り状況を確認しました。 多少物理IOは発生していますが、通常時は10ミリ秒程度で実行完了します。そのため、仮にキャッシュアウトされたことで全データが物理読み取りになったとしても、5秒もかかるものなのだろうかと疑問に思いました。 以上を踏まえると、キャッシュアウトによる物理ディスク読み取り量が増えたことが原因というよりは、別のクエリが大量のデータを読み取ることで急激な物理ディスク負荷がかかり、ディスクキューの数値が上昇し、1IOあたりの物理読み取りの時間が伸びているのでは、と考えました。 タイムアウトしたときのdm_exec_requestsのダンプ結果を再掲します。上図から、wait_timeが毎回数10ミリ秒になっていることが分かります。これは普段の傾向なのか、タイムアウト時の傾向なのか判断するために、今までの累計値(≒通常時)から、物理ディスク読み取り時の平均待ち時間を確認しました。 select * ,wait_time_ms / waiting_tasks_count as avg_wait_time_ms from sys.dm_os_wait_stats where wait_type like ' %pageiolatch% ' and waiting_tasks_count > 0 PAGEIOLATCH_SHの平均待ち時間(avg_wait_time_ms)は2ミリ秒でした。このことから、タイムアウトが発生するタイミングでは、1IOあたりの待ち時間が通常時よりも大幅に長くなっていることが分かりました。ディスクまわりのメトリクスを採取することで、このあたりの情報をより詳細に確認できます。したがって、次のステップとしてperfmonで採取したメトリクスを解析してみました。 perfmonで取得したメトリクス解析 20:05:24ごろタイムアウトしたクエリがあったので、その時間帯付近に注目して各メトリクスを確認しました。 メモリ内のデータ保持予測期間を示す「SQL Server:Buffer Manager\Page life expectancy」の値が定期的に大幅に減少しています。これはデータのキャッシュアウトが定期的に発生していると読み取ることができますが、ある程度規則性があり、タイムアウト発生時だけ特異な形状というわけではありませんでした。 「PAGEIOLATCH_SH」待ちがエラー発生の時間帯で突出して高いことが分かります。タイムアウトしていないクエリも、一時的に実行時間が伸びている可能性がありそうです。 ディスクキューも確認しました。データファイルが格納されているドライブのディスクキューが突出して高くなっていました。タイムアウト時間帯以外もスパイクはみられました。 秒間のディスク読み取り量についても、データファイルが格納されているドライブが突出して高く、タイムアウトが起きた時間帯では高い値のまま一定期間推移しているように見受けられます。この値がDBサーバーの物理ディスク性能の上限値である可能性が高そうです。 物理読み取りサイズと、ディスクキューの発生状況の関係を見るため重ねてみると、両者には相関関係がありそうです。 物理読み取りサイズとPage IO Latch Waitsにも関係があるようでした。「大量にディスク読み取りを行うプロセスが1つ以上存在し、ディスク性能上限に達するほどの読み取りを行っている状況下で、物理ディスクにアクセスが必要なクエリが実行されることでPage IO Latch Waitsがスパイクする」と解釈しました。 perfmonのメトリクス解析結果まとめ 定期的に物理ディスク読み取り性能の上限に達しており、そのタイミングで物理読み取りが必要なクエリが出てくると「Page IO Latch Waits」が発生するようです。この時点で、「普段は数10ミリ秒で実行完了するクエリが、たまに5秒間実行され続けて最終的にタイムアウトする」原因としては、そのクエリ自身にはなんの落ち度もないことが分かりました。かわりに、別の何らかのクエリがディスク性能の上限に達するほどの大量の物理読み取りを行っているため、他のクエリがわずかな物理読み取りを行う際でも通常時よりかなり時間がかかってしまい、場合によってはタイムアウトに達することもある、という状況でした。 最後に、実際に大量の物理読み取りを発生させているクエリを特定すれば、実際に対策を実施できる可能性があります。 大量の物理読み取りをおこなっているクエリを特定する タイムアウトが発生した時間帯における物理読み取り量が多い順にsession_idを並び替えるクエリを以下の通り作成しました。 declare @start_time datetime, @end_time datetime set @start_time = ' 2020-04-14 20:05:10 ' set @end_time = ' 2020-04-14 20:05:40 ' declare @total_reads bigint set nocount on --session_idごとの物理読み取りページ数推移 select (a.reads - b.reads) as reads_diff ,a.* from ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where collect_date between @start_time and @end_time ) a join ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where collect_date between @start_time and @end_time ) b on a.session_id = b.session_id and a.start_time = b.start_time and a. rownum -1 = b. rownum where (a.reads - b.reads) > 0 order by a.session_id, a.collect_date --総物理読み取り数を取得 select @total_reads = sum ((a.reads - b.reads)) from ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where collect_date between @start_time and @end_time ) a join ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where collect_date between @start_time and @end_time ) b on a.session_id = b.session_id and a.start_time = b.start_time and a. rownum -1 = b. rownum where (a.reads - b.reads) > 0 --総論理読み取り数が多い順にリクエストを並べる select a.session_id, a.start_time, sum ((a.reads - b.reads)) as reads_diff, sum ((a.reads - b.reads)) * 100 / @total_reads as percentage from ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where collect_date between @start_time and @end_time ) a join ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where collect_date between @start_time and @end_time ) b on a.session_id = b.session_id and a.start_time = b.start_time and a. rownum -1 = b. rownum where (a.reads - b.reads) > 0 group by a.session_id, a.start_time order by sum ((a.reads - b.reads)) desc このクエリの実行結果です。該当の時間帯で最も総物理読み取りサイズが大きいセッションでも29541ページ(約230MB)しか読み取っていないという結果になりました。この程度のディスク負荷では、性能上限には達しない印象で、作成したクエリが間違っていそうです。そこで、次に「タイムアウト発生直前の20:05ごろに開始したクエリの中で、総物理読み取り数が多い順」で並び替えてみたところ、以下のクエリをみつけました。 ①と④に着目すると、クエリがタイムアウトした時間帯(2020/04/14 20:05:24ごろ)では、物理読み取りページ数(reads)がカウントアップされておらず、ずっと0になっていました。そのため、最初に作成したクエリにはヒットしていませんでした。そして、タイムアウトとは時間的には関係がない時間帯(図中②)において、いきなり「reaeds」が5358249(約41GB)まで跳ね上がっていました。 今回のDBサーバーのディスク性能を考慮すると、数秒で41GBを読み取ることは不可能です。したがって、「readsは常に読み取ったサイズをカウントアップするわけではなく、あるタイミングで一気に累積読み取り数を計上することがある」といえます。この仕様を知らなかったのでクエリの書き方を間違い、結果として根本原因のクエリを見つけることができない状態になっていました。readsが計上されない条件ですが、少なくともlast_wait_type=CXPACKET(③)である場合、つまり並列処理している間はreadsに限らず、writesなど複数項目がカウントアップされないようです。 以上の調査を踏まえ、以下のクエリで大量の物理読み取りを行っているクエリを特定することができました。 declare @start_time datetime, @end_time datetime set @start_time = ' 2020-04-14 20:04 ' set @end_time = ' 2020-04-14 20:06 ' declare @total_reads bigint set nocount on --session_idごとの物理読み取りページ数推移 select (a.reads - b.reads) as reads_diff ,a.* from ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where start_time between @start_time and @end_time ) a join ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where start_time between @start_time and @end_time ) b on a.session_id = b.session_id and a.start_time = b.start_time and a. rownum -1 = b. rownum where (a.reads - b.reads) > 0 order by a.session_id, a.collect_date --総物理読み取り数を取得 select @total_reads = sum ((a.reads - b.reads)) from ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where start_time between @start_time and @end_time ) a join ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where start_time between @start_time and @end_time ) b on a.session_id = b.session_id and a.start_time = b.start_time and a. rownum -1 = b. rownum where (a.reads - b.reads) > 0 --総論理読み取り数が多い順にリクエストを並べる select a.session_id, a.start_time, sum ((a.reads - b.reads)) as reads_diff, sum ((a.reads - b.reads)) * 100 / @total_reads as percentage from ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where start_time between @start_time and @end_time ) a join ( select row_number() over(partition by session_id, start_time order by collect_date) as rownum ,* from dm_exec_requests_dump with (nolock) where start_time between @start_time and @end_time ) b on a.session_id = b.session_id and a.start_time = b.start_time and a. rownum -1 = b. rownum where (a.reads - b.reads) > 0 group by a.session_id, a.start_time order by sum ((a.reads - b.reads)) desc 修正版のクエリの実行結果です。修正前は、「タイムアウト発生タイミングでreads=0だけどディスクに高負荷をかけているセッションが存在する場合がある」ということを考慮せずに作成したクエリでした。一方で、修正版ではそれを考慮したうえで作成しているため、こちらのクエリの方が信頼できる結果を得られています。 原因と解決方法まとめ 断続的なタイムアウトの原因 ディスク性能限界まで達するほどの物理読み取りが数秒~数10秒継続した状態において、物理読み取りが必要なクエリが実行されたとき、通常よりも物理読み取りに時間がかかり、タイムアウトに達することもある状況になっていました。 解決方法 理論的には、性能限界まで物理読み取りを発生させなければ大丈夫です。そのためには、大量の物理読み取りを行っているクエリを特定し、特定結果によって以下のAかBいずれかの場合に応じた対応をとれば良いはずです。 A:複数のクエリが同タイミングで実行されることで合算値として性能限界まで読み取る場合 各クエリをチューニングするか、定期的な周期で実行しているクエリがある場合は実行タイミングをずらす。 B:単一のクエリにより性能限界まで読み取る場合 物理読み取り削減という観点でのチューニング(圧縮・クエリチューニング・インデックス等)を実施する。 今回特定した結果はBに該当し、シンプルにインデックスを張ることで該当クエリのIOを劇的に削減できました。結果的にこのクエリ起因でのクエリタイムアウト発生を防ぐことができるようになりました。 クエリタイムアウト発生時の原因調査方法 今回紹介した調査手法を、以下の3ステップとしてまとめます。 現在実行中のクエリリストを取得できるDMVであるdm_exec_requestsのSELECT結果を定期的にダンプしておく タイムアウトが発生した場合に、該当のクエリがどういった待ち状態で遷移していたかを確認する 待ち状態の種類に応じてさらに調査していく ステップ3については、待ち状態によって手順が多岐にわたりますが、ステップ1と2については調査の初期段階として汎用的に使っていただけると思います。 補足 今回調査したサーバーでは使えませんでしたが、SQL Server 2017以降ではクエリストアを使って各クエリの待ち事象を取得することができるようになっています。そのため、今回ご紹介した調査手法をクエリストアだけで完結させることができます。なお、クエリストア自体は2016から提供されていますが、待ち事象が取得できるようになったのは2017からです。 参考: クエリストアを利用した待ち事象の取得 まとめ 調査依頼を受けたときに最初に思いついた原因はブロッキングでしたが、結果としてまったく違う原因でタイムアウトが起きていました。原因調査では、仮設を立てる前に可能な限り必要な情報を集めることが重要だと感じました。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは、ECプラットフォーム部の権守です。普段はZOZOTOWNのリプレイスに関わるID基盤とAPI Gatewayの開発を行っています。 ID基盤やAPI Gatewayの中身についてもいずれ紹介したいと思いますが、本記事では、ID基盤のAPI開発で取り入れているGo言語におけるOpenAPIを使ったレスポンス検証について紹介します。 OpenAPIを使ったレスポンス検証 OpenAPI Specification (以下、OpenAPIと表記します)はREST APIのためのプログラミング言語に依存しない標準的なインタフェース記述言語です。OpenAPIについては以前に こちら の記事でも取り上げましたので、合わせて読んでいただければと思います。 弊社では、新規で開発するAPIについてはOpenAPIを用いて仕様書を作成しており、ID基盤もそうして社内にAPI仕様書を提供しています。 OpenAPIは非常に素晴らしいものですが、実装側が仕様書通りのレスポンスを担保できなければ台無しになってしまいます。以前、 こちら の記事では同様の問題について、Rubyにおいてシリアライザを自動生成することで解決を試みた事例を紹介しました。ID基盤はGoを用いて開発しており、同じ方法が取れなかったため、実装したAPIが仕様に沿ったレスポンスを返せているかどうかのテストコードを簡単に書けるようにしました。 実装にあたり、 kin-openapi というパッケージを利用しました。OpenAPIに関するパッケージは様々ありますが、OpenAPIの最新バージョンである3系に対応しており、レスポンスを検証する機能を備えているものはkin-openapi以外見つけられませんでした。 次に、kin-openapiを利用したテスト方法について例を用いながら紹介します。 kin-openapiの使用例 まず、次のような仕様のAPIを実装することを考えます。 # openapi.yaml openapi : "3.0.3" info : title : api example version : 1.0.0 paths : /users/{id} : get : parameters : - name : "id" in : "path" required : true schema : type : integer responses : "200" : description : ユーザー情報 content : application/json : schema : $ref : "#/components/schemas/user" components : schemas : user : type : object required : - id - nickname properties : id : type : integer example : 1 nickname : type : string example : ゾゾ age : type : integer example : 22 ユーザー情報を返すだけのシンプルなAPIです。次にこれを満たすAPIを実装します。ここでは説明の簡単化のためレスポンスをモックとします。 // main.go package main import ( "net/http" "validate-response-sample/router" ) func main() { http.ListenAndServe( ":8080" , router.Router) } 標準のhttpパッケージを用いてWebサーバーを立ち上げます。ルーティングは次のrouter.goで定義したRouterを用います。 // router.go package router import ( "net/http" "github.com/gorilla/mux" ) var Router = func () *mux.Router { r := mux.NewRouter().StrictSlash( true ) r.HandleFunc( "/users/{id}" , func (w http.ResponseWriter, r *http.Request) { w.Header().Set( "Content-Type" , "application/json" ) w.Write([] byte ( `{"id": 3, "nickname": "マックス", "age": 18}` )) }) return r }() httpパッケージにはパスパラメータを扱う実装は含まれていないので、ここでは Gorilla のルーターを使っています。 モックを返すAPIを実装できたので、次に本題のテストコードについて説明します。 // main_test.go package main_test import ( "net/http" "testing" testingHelper "validate-response-sample/testing" ) func TestUserRequest(t *testing.T) { req, e := http.NewRequest(http.MethodGet, "/users/3" , nil ) if e != nil { panic (e) } e = testingHelper.TestRequest(req) if e != nil { t.Error(e) } } ユーザーAPIへのリクエストを作成し、レスポンス検証のために用意したヘルパー関数であるTestRequestへ渡します。TestRequest関数は実際にHTTPリクエストを行い、受け取ったレスポンスが期待するレスポンス形式に沿っていないとエラーを返します。TestRequest関数の具体的な実装は以下になります。 // helpers.go package testing import ( "context" "io/ioutil" "net/http" "net/http/httptest" "net/url" "validate-response-sample/router" "github.com/getkin/kin-openapi/openapi3filter" ) func TestRequest(request *http.Request) error { ts := httptest.NewServer(router.Router) defer ts.Close() openAPIRouter := openapi3filter.NewRouter().WithSwaggerFromFile( "openapi.yaml" ) route, pathParams, e := openAPIRouter.FindRoute(request.Method, request.URL) if e != nil { return e } u, _ := url.Parse(ts.URL) request.URL.Scheme = u.Scheme request.URL.Host = u.Host response, e := http.DefaultClient.Do(request) if e != nil { return e } defer response.Body.Close() body, e := ioutil.ReadAll(response.Body) if e != nil { return e } requestValidationInput := &openapi3filter.RequestValidationInput{ Request: request, PathParams: pathParams, Route: route, } responseValidationInput := &openapi3filter.ResponseValidationInput{ RequestValidationInput: requestValidationInput, Status: response.StatusCode, Header: response.Header, } responseValidationInput.SetBodyBytes(body) return openapi3filter.ValidateResponse(context.TODO(), responseValidationInput) } 次の手順で処理を行っています。 テスト用サーバーを立ち上げ openapi.yamlの読み込みとリクエストに該当する記述の探索 受け取ったリクエストのテストサーバー用のURLへの書き換え 実際のリクエストとレスポンスの受け取り 返ってきたレスポンスとopenapi.yamlに記述されたレスポンス形式が一致するかの確認 これでOpenAPIを用いて作成した仕様書と実装が食い違うことを防ぐテストを簡単に書けるようになりました。 しかし、運用しているとある問題に遭遇しました。その問題と対応した方法について紹介していきます。 OpenAPIの拡張とその対応 OpenAPIではステータスコード毎に1つのレスポンスしか記載できません。しかし、実際には同じステータスコードに対して複数のレスポンス形式があることは珍しくありません。 例えば、ユーザー情報のGETリクエストにおいて本人にしか返さない項目があると、同じ 200 のステータスであっても本人からのリクエストかどうかでレスポンス形式が異なります。また別例として銀行口座からお金を引き出すAPIの例を考えると、同じ 403 のステータスでも本人以外の処理によるエラーと残高不足によるエラーではレスポンス形式は異なると考えられます。 そこで、 Responses Object に対して x-ステータスコード-タイトル といった形式のフィールドを追加して複数のレスポンスを表現することにしました。OpenAPIでは x- を接頭辞につけたフィールドを定義することで仕様を拡張することが許されています。具体的には次のように書きます。 # openapi.yaml openapi : "3.0.3" info : title : api example version : 1.0.0 paths : /users/{id} : get : parameters : - name : "id" in : "path" required : true schema : type : integer responses : "x-200-Self" : description : 自分のユーザー情報 content : application/json : schema : $ref : "#/components/schemas/self_user" "x-200-Other" : description : 他人のユーザー情報 content : application/json : schema : $ref : "#/components/schemas/other_user" components : schemas : self_user : type : object required : - id - name - nickname properties : id : type : integer example : 1 name : type : string example : 像造太郎 nickname : type : string example : ゾゾ birthday : type : string format : date example : 1998-05-21 other_user : type : object required : - id - nickname properties : id : type : integer example : 1 nickname : type : string example : ゾゾ age : type : integer example : 22 こうすることでOpenAPIの文法に準拠したまま1つのステータスコードに対して複数のレスポンス形式を記載できるようになりました。 しかし、これは独自拡張のため、このままでは導入したテストが動作しません。そこで、次のようにヘルパー関数を実装し直しました。 // helpers.go package testing import ( "bytes" "encoding/json" "errors" "fmt" "io/ioutil" "mime" "net/http" "net/http/httptest" "net/url" "regexp" "strconv" "validate-response-sample/router" "github.com/getkin/kin-openapi/openapi3filter" ) func TestRequest(request *http.Request, responseKey string ) error { ts := httptest.NewServer(router.Router) defer ts.Close() openAPIRouter := openapi3filter.NewRouter().WithSwaggerFromFile( "openapi.yaml" ) route, _, e := openAPIRouter.FindRoute(request.Method, request.URL) if e != nil { return e } u, _ := url.Parse(ts.URL) request.URL.Scheme = u.Scheme request.URL.Host = u.Host response, e := http.DefaultClient.Do(request) if e != nil { return e } defer response.Body.Close() e = validateResponse(response, route, responseKey) if e != nil { e = fmt.Errorf( "%v %v, %v: %v" , request.Method, request.URL.Path, responseKey, e.Error()) } return e } func validateResponse(response *http.Response, route *openapi3filter.Route, key string ) error { // validate status // ... // find expected response // ... // validate headers // ... // validate body // ... } TestRequest関数の大きな変更点は2つあります。まず、http.Request以外にレスポンスのキーを受け取ることです。元の実装ではレスポンスのステータスコードから期待するレスポンス形式が自動的にわかりましたが、拡張したことによってステータスコードからは一意に決まらなくなりました。そのため、期待するレスポンスのキーを指定する必要があります。 次に、kin-openapiが提供するvalidate関数の代わりに独自実装したvalidate関数を呼び出すことです。validate関数の中身について少しずつ説明していきます。 まずはステータスコードの検証部分です。 status := response.StatusCode var expectedStatus int re := regexp.MustCompile( "^[0-9]{3}$" ) if re.MatchString(key) { i, _ := strconv.Atoi(key) expectedStatus = i } else { re := regexp.MustCompile( "^x-([0-9]{3})-.+$" ) s := re.ReplaceAllString(key, "$1" ) if s == "" { return errors.New( "illegal response key" ) } i, _ := strconv.Atoi(s) expectedStatus = i } if status != expectedStatus { return fmt.Errorf( "want %v status, but got %v status" , expectedStatus, status) } レスポンスのキーとして従来通りのステータスコードを指定する場合と、拡張したキーを指定する場合を正規表現を用いて分岐しています。拡張したキーに関しては正規表現を用いてステータスコードの抽出も行っています。 OpenAPIではレスポンスのキーとして default や200系を示す 2XX などを設定できますが、厳密な仕様とするためにここではそれらを禁止しています。 次に、レスポンス仕様の取得部分です。 responses := route.Operation.Responses responseRef := responses[key] if responseRef == nil { return errors.New( "the response key is not documented" ) } expectedResponse := responseRef.Value if expectedResponse == nil { return errors.New( "reference of response has not been resolved" ) } 引数として受け取っているrouteは、リクエスト内容から該当する仕様を探したものです。そこから、レスポンス仕様を取り出します。 OpenAPI 3では $ref という表記を使うことで、componentsに記載した内容を参照できます。kin-openapiでは、この $ref を扱うために参照用の構造体を挟んで、実体はValueフィールドに持っています。参照を解決できていない場合にはValueフィールドの値がnilになるため、そのチェックをしています。 次はレスポンスヘッダーの検証です。 for k, v := range expectedResponse.Headers { h := response.Header.Get(k) expectedHeader := v.Value if expectedHeader == nil { return errors.New( "reference of header has not been resolved" ) } if expectedHeader.Schema == nil { return fmt.Errorf( "header schema of %v is not documented" , k) } expectedHeaderSchema := expectedHeader.Schema.Value if expectedHeaderSchema == nil { return errors.New( "reference of schema has not been resolved" ) } if e := expectedHeaderSchema.VisitJSON(h); e != nil { return e } } レスポンス仕様に記載されたヘッダーが実際のレスポンスに含まれているかを検証します。各ヘッダーのSchema Objectを取得し、VisitJSONメソッドに実際の値を渡すことでOpenAPI上のスキーマに沿っているかを検証できます。実際のレスポンスヘッダーの値はキーを元に取得します。 最後にレスポンスボディの検証です。 body, e := ioutil.ReadAll(response.Body) if e != nil { return e } content := expectedResponse.Content if len (content) == 0 { if string (body) == "" { return nil } return errors.New( "content of the key is not documented" ) } mediaType, _, e := mime.ParseMediaType(response.Header.Get( "Content-Type" )) if e != nil { return e } mediaTypeObject := content.Get(mediaType) if mediaTypeObject == nil { return errors.New( "unmatched Content-Type" ) } if mediaTypeObject.Schema == nil { return errors.New( "content schema of the key is not documented" ) } bodySchema := mediaTypeObject.Schema.Value if bodySchema == nil { return errors.New( "reference of schema has not been resolved" ) } var decodedBody interface {} if e = json.NewDecoder(bytes.NewBuffer(body)).Decode(&decodedBody); e != nil { return e } return bodySchema.VisitJSON(decodedBody) まず、レスポンスのボディを全て読み込んで変数に格納します。ボディが空の場合はそれが期待通りかどうかのチェックとしてContentフィールドも空であることを確認します。ContentフィールドはMedia Type毎にレスポンスボディのスキーマを持ちますが、レスポンスボディがないAPIであればContentフィールドは空になります。この場合はこれ以上検証するものがないので処理を終了します。 次に、ボディが空でない場合はレスポンスのContent-TypeヘッダーからMedia Typeを判別します。判別にはmimeパッケージのParseMediaType関数を用いています。ParseMediaTypeはContent-Typeヘッダーに含まれるパラメータも取得できますが、ここでは使わないのでMedia Typeだけを変数に格納しています。 Media Typeがわかったので、その値を用いて該当するレスポンスボディのスキーマを取得します。ここでもヘッダーの検証で用いたVisitJSONメソッドを使いますが、ヘッダーの値がただの文字列なのに対し、レスポンスボディはJSON文字列なので事前にデコードをしています。 これらによってレスポンスのキーを拡張した仕様書を使った場合でもレスポンスの検証ができるようになりました。 まとめ GoにおいてOpenAPIを使ったレスポンス検証を行うためにkin-openapiパッケージを使った方法を紹介しました。また、OpenAPIの表現力では足りないレスポンス表現についての拡張とそれに対応した検証方法を紹介しました。 Goで開発をしていてAPI仕様書と実装の食い違いに頭を抱えている方はぜひ試してみてください。 最後に、ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに こんにちは。ZOZO研究所の shikajiro です。主に研究所のバックエンド全般を担当しています。ZOZOでは2019年夏にAI技術を活用した「類似アイテム検索機能」をリリースしました。商品画像に似た別の商品を検索する機能で、 画像検索 と言った方が分かりやすいかもしれません。MLの開発にはChainer, CuPy, TensorFlow, GPU, TPU, Annoy、バックエンドの開発にはGCP, Kubernetes, Docker, Flask, Terraform, Airflowなど様々な技術を活用しています。今回は私が担当した「近似最近傍探索Indexを作るワークフロー」のお話です。 corp.zozo.com 目次 はじめに 目次 画像検索の全体像説明 Workflow Develop Application 推論APIの流れ 近似最近傍探索とAnnoy 近似最近傍探索Indexを作る ワークフローツールの説明 デメリット 画像検索のワークフローの全体像 日々のバージョンアップ 差分更新 特徴量抽出のキャッシュ 新しい特徴量抽出モデルへの変更 これから 失敗談 画像ダウンロードしまくって負荷をかけてしまう 開発期間の見積もりが難しい 課題 特定サービスが止まるとフローも止まる データはいつも同じではない ML部分のワークフロー化 まとめ 登壇資料や関連サイト さいごに 画像検索の全体像説明 クラウドはGCPを採用しています。分析基盤にBigQueryを使っていること、KubernetesのマネージドサービスであるGKEが安定していること、TPUを活用していることなどが採用の理由です。図の上から簡単に紹介します。 techblog.zozo.com Workflow ワークフローであるComposerは毎日販売中のおよそ300万の商品画像から特徴量抽出を行いIndexを作成しています。今回のお話のメインはここですが、もう少し全体像の説明を続けます。 Develop アプリケーションのコードはGitHub, CI/CDはCircleCIで管理しており、モデルはGCSに、アプリケーションはDockerとしてGCRに登録します。デプロイはCircleCIやComposerを使っています。なお、ML部分のCI/CDはまだ完全には実現できてません。 Application ユーザーが指定した画像から似た商品画像を返すAPIのことを 推論API と呼びます。Kubernetesで構成しており、後で紹介するマイクロサービスが連携して動作しています。 推論APIの流れ Composerの説明をする前に、特徴量Indexの動きを知るために推論APIの流れを紹介します。API、物体検出、特徴量抽出、近似最近傍探索、それぞれをマイクロサービスとして動かしています。商品情報検索だけはk8s外の外部サービスです。 ユーザーが画像検索に画像を送ると画像の中に写っている服などのアイテムを検出します。これが 物体検出 です。ここでトップスやシューズなどに分類します。判別した画像のままでは計算に適さないので、検出した部位を多次元ベクトルの特徴量に変換します。この特徴量で距離や類似度などの計量を計算できるようにします。具体的には512次元のfloatの配列になります。これが 特徴量抽出 です。過去テックブログでも紹介していますのでぜひご覧ください。 techblog.zozo.com techblog.zozo.com 特徴量から予め準備していたZOZOTOWNの約300万画像のIndexを使って、似ている商品画像を高速に探します。これが 近似最近傍探索 です。近似最近傍探索については東京大学の松井先生の資料が大変分かりやすいので、興味がある方は御覧ください。 speakerdeck.com 近似最近傍探索の段階では似ている商品の画像までしか分かっていません。最新の商品情報を取得するため、ZOZOTOWNの商品データベースに問い合わせてデータの整合性を保ちます。この部分は画像検索とは直接関係ないですが、実際のサービスでは大事な部分ですので紹介しました。 近似最近傍探索とAnnoy 上でも少し触れましたが、ZOZOTOWNが販売する約300万商品画像の中から最も似ている数十〜数百商品を高速に検索する必要があります。特徴量は多次元ベクトルであり対象商品も大量なため、普通に計算するととんでもない計算時間になってしまいます。ここで利用するのが近似最近傍探索です。これを実装した代表的なPythonのライブラリにSpotifyが開発しているAnnoy, Facebookが開発しているFaissなどがあります。画像検索の開発時に主にこの2つを検討し、Annoyを採用しました。理由は以下の2つです。 実装が容易であること 十分に高速であること もっと速度が必要ならばFaissとGPUの組み合わせを検討していましたが、AnnoyとCPUの組み合わせで速度・精度ともに十分だったため、現状はFaissにする予定はありません。 github.com 近似最近傍探索Indexを作る Annoyを使って近似最近傍探索を行うには、予め検索対象である約300万画像の特徴量を抽出して Index を作っておく必要があります。ビルド処理自体は数行のコードで実現できるのですが、画像を準備するのがなかなか大変です。以下の手順で作成しています。 BigQueryから現在販売中の画像情報を取得する 商品画像をZOZOTOWNストレージから取得する 画像から特徴量を抽出する 特徴量からAnnoyのBuildを行う IndexをGCSに保存する 推論APIで利用する これらをバッチプログラムを書いて実装することも可能ですが、上記それぞれで必要なCPUリソースもバラバラで、特に特徴量抽出は多くの計算資源を必要とします。エラー時のリトライ、Slackなどへの通知、ロギングの仕組みなどを考えるととても2から作るのは大変です。 そこで利用したのがAirflowなどに代表されるワークフローツールです。 ワークフローツールの説明 上でも触れましたが、バッチ処理を自前で書くとエラー処理、リトライ、ログ、通知処理など実装・運用コストが高いです。この辺りの面倒を見れくれるワークフローツールを検討しました。Airflow, Digdagなどがありますが、GCPのAirflowマネージドサービスであるComposerを採用しました。マネージドサービスだったのが一番の理由です。 AirflowなどのワークフローはDAG(Directed Acyclic Graph, 有向非巡回グラフ)という概念でタスク同士の依存関係を定義しています(詳しくは公式サイトに委ねます)。上記の1〜6の流れを簡潔に書けるようになり、途中からの実行などが容易になります。 airflow.apache.org デメリット ComposerのAirflowバージョンはGCPが管理しているため、最新のAirflowより遅れています。そのため、解決されていないバグや対応していないGKEオペレーションなどがあり、使い勝手・保守性に問題がありました。最近はバージョンでは追従できており、安定して稼働しています。 画像検索のワークフローの全体像 ざっと流れを紹介します。BigQueryから現在販売中の商品の画像URLをすべてダウンロードし、Filestoreに保存します。実際は前日までの画像がすでに存在するので、前日までの分と本日分の差分だけをダウンロードしています。 準備した画像を元に物体検出、特徴量抽出、近似最近傍探索Indexのビルドを行うのですが、大変重い処理なうえ、GPUも使うのでComposerのインスタンスで実行することはできません。そのため、別途GKEのクラスターを準備し、重い計算部分はそちらで計算しています。トップス、ボトムスなどのカテゴリ単位でPodを並列で動かし高速化しています。それでもまだ300万画像すべてを計算すると全体で24時間以上かかるので、日々改善を行っています。 特徴量は一部キャッシュとして保存しており、推論API側で利用しています。ZOZOTOWNの画像で画像検索する場合はこのキャッシュを流用できるため、推論APIのGPU資源を軽減させています。 ビルドしたIndexはモデルと同じGCSバケットに保存します。推論APIのGKEクラスターに対して近似最近傍探索PodのRollingUpdateを指示し、新しいIndexで動作する近似最近傍探索Podを起動します。ユーザーは更新された事に気が付かないまま、新しい結果を得ることができます。 エラーが起きた場合はSlackに通知しており、開発者がいつでも対応できるようにしています。 日々のバージョンアップ ワークフローは日々改善を行っています。リリース後に行った改善を紹介します。 差分更新 当初、早急にリリースする事を優先しており、ワークフローを簡素化していたため毎日販売中の画像300万画像すべての特徴量を計算しIndexを作成していました。これでは大変時間がかかりコストも高いので「昨日と比較して新しく追加された画像だけ計算する」ように変更しました。コストは大幅に下がり、数時間で計算が終わるようになりました。 特徴量抽出のキャッシュ 開発当初はユーザーが画像をPostして、その画像から似た商品を返却する予定で開発をしていました。ですが、ZOZOTOWNにある商品と似た商品を返却した方が良さそうとのことで、ZOZOTOWNの画像を推論するようになりました。その場合、ZOZOTOWNに既にある商品画像はIndexを作成する際に特徴量抽出を一度行っているため、キャッシュに使えることが分かりRedisを追加しました。これにより、推論APIで物体検出と特徴量抽出を行わなくてもよい状況が増えたので、GPU負荷が大幅に下がりコストが削減できました。 ※一部簡略化しています。すべてのリクエストでキャッシュを利用してるわけではありません。 新しい特徴量抽出モデルへの変更 現在トップスを始めとした8つのカテゴリに対応しており、日々新たなカテゴリに追加するため物体検出、特徴量抽出のモデルを開発しています。そこで特徴量抽出のモデルを更新する際にいくつかの問題が分かってきました。 新たなカテゴリに対応すると、今までのカテゴリの精度を劣化させる可能性がある 新たなカテゴリの学習のために今までのカテゴリ分も学習する必要があり、大幅に時間がかかる ここで、カテゴリ毎に特徴量を抽出するようにモデルを作り変える決断をしました。さらに、学習を高速化をするためTPU x TensorFlowへの変更も行いました。モデルが大きく変わったため特徴量は現版(v1)と新版(v2)でまったく異なります。v2でIndexを1から作り直すのに数日かかり、その間もv1でAPIは稼働し続けないといけないため、v1, v2のIndex作成ワークフローを並列で行う必要があります。今回は推論APIのPod、キャッシュストレージなどをv2用に予め作り、v2のDAGがIndexを作り終わったタイミングで、推論APIのPodの向き先をv2に切り替えるという対応を行いました。 ※一部簡略化しています。 これから 今後も機能追加、精度向上、コスト削減など様々な施策を実施していく予定です。ご期待ください。 失敗談 みんな大好き失敗談を紹介します。 画像ダウンロードしまくって負荷をかけてしまう 300万近くの画像はZOZOTOWNの画像サーバーから取得しています。300万画像を1つずつ取得していたら数週間かかるので、CloudFunctionsを使って無尽蔵に並列化して取得しました。案の定、サーバーに負荷をかけて怒られました。今は負荷をかけない程度の並列処理を実行しています。 開発期間の見積もりが難しい 実装自体は大したことないなと思っても、動作検証に大変時間がかかるため、実装に実質3日、検証に1か月みたいな事が平気で起きたりします。開発を効率化するためのスクリプト作り、安定した基盤づくり、可能な限り高速でタスクが完了するための高速化の工夫などがとても大事になります。 課題 これから解決していかなくてはならない課題たちです。 特定サービスが止まるとフローも止まる 例えばネットワークなどの都合で「特定のPythonパッケージがダウンロードできない」などが発生するとワークフローが止まってしまい、Indexを構築できません。これはクラウド時代には仕方がない部分ですが、ワークフロー中に外部依存する処理を減らしていく必要があります。 データはいつも同じではない 昨日まで動いていたのに動かなくなることが当たり前のようにあります。自分たちのコードのバグ、Airflowのバグ、商品画像数が増えたことでタイムアウトが発生した、依存している外部サービスに変更があった、など様々な要因があります。いつでも柔軟に対応できるよう、日々改善していくことが大事です。 ML部分のワークフロー化 現在Kubeflowを検証中で、モデルの精度が上がったら自動でデプロイされるような仕組みを検討しています。 まとめ 当初ワークフローの開発はそこまで大したこと無いだろうと考えていたのですが、API開発の10倍(個人の感想)くらい困難なものでした。これから新たに機械学習プロジェクトでワークフローを作る方は開発の初期段階からざっくり作り始めることをおすすめします。 私が携わった次期プロジェクトではこの知見が活き、迅速に開発・リリースができました。 lab.wear.jp このワークフローは一人で作ったわけではありません。研究所の研究者、開発者、特にMLOpsチームの協力があり安定したワークフローに成熟していきました。今後もZOZOテクノロジーズのメンバーで様々なサービスを提供していきますので、今後とも宜しくお願いします。 登壇資料や関連サイト techblog.yahoo.co.jp techblog.zozo.com さいごに ZOZOテクノロジーズでは福岡研究所のMLエンジニア、バックエンドエンジニア、MLOpsチーム(東京)のメンバーを募集しております。 https://hrmos.co/pages/zozo/jobs/0000029 hrmos.co hrmos.co hrmos.co
アバター
ZOZOMAT とは何でしょうか?オンラインで靴を購入する際に、サイズが合わないという問題を解決する仕組みです。1台のスマートフォンと紙製のZOZOMATだけで、正確に足のサイズを測れます。足をスキャンすると、高精度の3Dモデルが生成されます。最適なサイズの靴も表示されるので、すぐに靴を購入できます。 こんにちは!ZOZOテクノロジーズの @kapsy1312 です。ZOZOMATプロジェクトの一員として、スキャン結果を3D空間に表示するビューの開発を担当しました。プロトタイプでは、Appleの標準3Dフレームワーク、SceneKitを使用していました。しかし、全く同じ機能とデザインをAndroidで再現するにはコストがかかるため、さらに適切なソリューションを検討しました。 この記事では、スキャン結果の3DビューをAndroidとiOSデバイス向けに開発した際の課題と解決策を説明します。プラットフォーム依存のシーングラフフレームワークではなく、C++とOpenGLを選択した理由も説明します。付属の サンプルプロジェクト も用意してあり、後半で解説します。 スキャン結果の3Dビュー このようなスキャン結果の3Dビューには、次の設計要件がありました。 iOS 10以上とAndroid 5以上のサポート 遠近感が出ないように、足のメッシュを正投影で描画 足の寸法を表示するラベルとその測定値をビルボード(常にカメラの方向を向く動き)で描画 測定した箇所を正確に示す寸法線 アンビエントオクルージョン効果 カメラの初期位置を正確に指定 フェードと回転の初期アニメーション ピンチイン・ピンチアウト、ドラッグによる回転、ダブルタップによるカメラのリセット さらに、いくつかの実装上の要件がありました。 片足ずつスキャンするので、描画時に両方の足のメッシュを連結する(並べる)必要がある 描画前にメッシュのデータを90度回転し、大きさを調整しなければならない 足のメッシュは、OBJファイルと独自バイナリの両方の形式を読み込む必要がある 検討した選択肢 時間が限られていたため、iOSのプロトタイプにはAppleの SceneKit を選択しました。そのおかげでプロトタイプの開発は間に合わせることができました。しかし製品版としてリリースするには、SceneKitの様々な制限を回避する必要があり、さらに全く同じデザインをAndroidで再現するコストもかかることが分かりました。 そこで、次の点を考慮して、いくつかのソリューションを調査しました。 設計要件を満たす柔軟性は不可欠 3Dビューはプラットフォーム間で同一に見えるべき 設計の変更要求への対応が容易 検討した選択肢は次のとおりです。 SceneKit / Sceneform SceneKit には次の利点がありました。 重要な3D機能が揃っており、迅速なプロトタイピングが可能 アンチエイリアシング、ビルボード機能、フレームタイミングも用意されている 提供されているユーザータッチ入力とカメラコントロールはとても便利 ただし、これにはいくつかの制限もありました。 メッシュの3D変換、ラベルのビルボード、カメラの動作はAndroidで完全に複製する必要がある デフォルトのカメラ機能にバグ:ダブルタップしてリセットしてもアニメーションが思い通りに動かない メッシュを読み込んでから表示するまでに時間がかかる Swiftを使用して座標、ベクトル、行列の操作をするのは面倒 OBJファイルからのメッシュの読み込みは遅く、扱いも面倒 アンビエントオクルージョン効果が綺麗ではなくて、バグもある プロトタイプではSCNViewのdefaultCameraControllerを使っていたが、iOS 10では使用できないので、アニメーションのコードを書き直す必要があった これらの問題を解決するためには、美しくないハックと試行錯誤が必要です。また、望ましい結果が得られるまでフレームワークの動作を検証し続ける必要もあります。例えば、いくつかのサンプルプロジェクトを試してみないと、アンビエントオクルージョンの問題はバグなのか確認できませんでした。 詳細を調べたところ、Androidの Sceneform は、同様の制限が含まれているように見えました。これは、フレームワークに依存する際の根本的な問題と考えられます。フレームワークは提供者側が意図したとおりに使用されると役に立ちます。しかし、提供者側が意図していない使い方の場合には、回避策は自分で一から実装するよりコストが高くなる可能性もあります。 クロスプラットフォーム共有のC++とOpenGL クロスプラットフォーム共有のC++とOpenGLは、ゲーム業界でよく使われている開発のアプローチです。プラットフォーム固有の機能は抽象化されており、プラットフォーム自体はその抽象化された関数等に準拠しています。プラットフォームは抽象化コードを経由して共通のC++コードベースを呼び出したり、コールバックされたりします。コードのほとんどをC++に移植できる場合にはクロスプラットフォームの仕組みは非常に有効です。 iOSとAndroid(NDK)の両方にOpenGL ESのCヘッダーがあるため、直接レンダリング関数を呼び出せます。ただし、プラットフォーム固有のレンダラー(Metalなど)を呼び出すと、さらに多くの作業が必要となります。 クロスプラットフォームコードは、同じ機能の重複を避けられます。さらに、設計要件を実装するのに十分な細かい制御ができます。 C++を使用すると、他にも多くの利点があります。 メッシュの読み込みがより効率的になる SIMD機能も簡単に使える CPUのキャッシュに優しい処理も可能 メモリ効率の良い構造体で行列とベクトルを扱える ポインター操作は簡単で一貫している パブリックドメインのヘッダーのみのライブラリ( stb など)を活用できる ただし、次の欠点もあると考えられます。 柔軟性は最も高いが、作業量が増加する 多くの機能は自分で実装する必要がある 他のソリューションと比較して学習コストが高い C++には不必要な機能があるため、コードガイドラインが不可欠 経験の浅い開発者がミスを犯しやすい シェーダーコードのデバッグが難しい Unity Unity はクロスプラットフォームのゲーム開発システムです。ソフトウェア自体は大きいのですが、携帯端末から高性能のゲーム用PC及びコンソールまで、幅広い複数の環境の開発が可能です。 ゲーム開発には向いていますが、ZOZOMATの開発要件を考えるとほんの一部しか必要ではありません。リアルタイムの物理シミュレーション、パーティクルシステム、VR、スクリプト、アニメーション等の必要性はゼロです。ZOZOMATでのユースケースが単純であることを考えると、学習コストがかなり高くなります。 Webアプリケーション three.jsなどのシーングラフフレームワークを使用して、ネイティブのWebビューに3Dを表示するという方法です。 JavaScriptのWebアプリケーションを使用すると、次の利点があります。 単一のコードベースが可能になる JavaScript自体は割と簡単 three.jsだと、3D知識がなくても開発ができる Webアプリケーションなので、変更する場合は再ビルド、再リリースが不必要 しかし、いくつか難点もありそうでした。 JavaScript側にメッシュデータを渡すにはASCIIの文字列でデータ量が多くなって、メッシュを表示するまで時間がかかる メッシュバイナリの読み込みには、C++インタフェースが必要 JavaScriptは高水準言語であり、パフォーマンスの問題を引き起こす可能性がある 実はこの方法は、ZOZOSUITのスキャン結果の3Dビューに使用しました。クロスプラットフォームを実現できましたが、パフォーマンスと品質の問題がありました。読み込み時間が遅く、iOSアプリのクラッシュバグが発生する場合もありました。原因は、あるiOSバージョンのUIWebViewのWebGL実装にあったので、その原因にたどり着くのも非常に困難でした。同じ状態を繰り返さないように、このWebアプリケーションの方法は避けた方がいいと考えました。 「クロスプラットフォーム共有のC++とOpenGL」を選択 いろいろな選択肢を検討した結果、完璧な解決策は存在しないと気がつきました。すべてのオプションには利点と欠点があり、その多くは開発が始まらないと気づかないと思います。 その上で、クロスプラットフォーム共有のC++とOpenGLが最善のアプローチであると判断しました。 SceneKitとSceneformをそれぞれ実装すると、C++で共有するよりも開発コストが高くなりそうです。C++とOpenGL ESだとコードが1か所で管理でき、設計要件を満たすための好ましくないハックも必要なくなります。 テストアプリを作成し、設計要件が最小コストで実現できることを確認しました。さらに、より迅速に3Dビューの開発サイクルを繰り返せるように、macOS版も開発しました。iOS版よりアプリケーションのビルドの待ち時間が大幅に削減できました。 Metalを採用すべきかの検討 AndroidではOpenGL ES、iOSではMetalを使用すると、両方をサポートする抽象化レイヤーがさらに必要になります。OpenGL ESはiOSおよびmacOSでは非推奨になっていますが、今回はコストと共通化の観点からOpenGL ESを使用しました。 パフォーマンスを追求しているアプリを開発する場合はMetalをサポートする抽象化レイヤーはオススメです。将来的に実装する計画はありますが、特に共通のシェーダー言語がないことを考えると、最初のバージョンとしてはコストが高かったです。 ZOZOMATのクロスプラットフォーム設計 ここまででクロスプラットフォームソリューションを選んだ理由を説明したので、次は実装の詳細について説明します。前述したように、iOS、macOS、Androidの サンプルプロジェクト があるので、プロジェクトの内容を詳しく解説していきます。 各プラットフォームには、独自のプラットフォームレイヤーが必要です。これは、プラットフォームと互換性のある言語で書かれています。C言語と対話できる外部関数インタフェース(Foreign Function Interface/FFI)をサポートする必要があります。Swift(iOS)やKotlin(Android JNI)はFFI機能を持つのですが、iOSやmacOS環境ではよりC言語と互換性が高いObjective-Cのほうが好ましいです。 処理の流れを解説します。 プラットフォーム抽象化レイヤー(platform abstraction layer)は、ヘッダーファイルにインタフェースを定義します。プログラムが正しく動作するためにプラットフォームレイヤー(platform layer)は抽象化レイヤーのインタフェースに従わなければなりません。 プラットフォームレイヤーは特有のフレームワーク(例えばNSFileManager、AAssetManager等)を内部的に使用し、プラットフォーム抽象化レイヤーのインタフェースに準拠します。 プラットフォームに依存しないレイヤー(platform independent layer)は、共有のC++が含まれています。このコードはプラットフォーム抽象化レイヤー(platform abstraction layer)の抽象化された関数なども従わなければなりません。 コードの大部分は、プラットフォーム依存しないレイヤー(platform independent layer)に収まる必要があります。そうでなければ、クロスプラットフォーム設計を選ぶ理由はほとんどありません。ZOZOMATとサンプルプロジェクトの場合、3Dのデータ変換とOpenGL ESレンダリングの呼び出しはすべて、プラットフォームに依存しません。 なお、プラットフォームごとにFFIを設定する方法の解説はこの記事の目的ではないので割愛します。iOSの場合は非常に簡単です。Android環境のJNIとNDKという機構の設定には Android NDK environment のドキュメントが参考になります。 クロスプラットフォーム関数の例 これは、 サンプルプロジェクト に掲載している、クロスプラットフォームのinitコールバック関数の基本的な例です。このコードの例は、より分かりやすくするために簡略化しています。 // ztr_platform_abstraction_layer.h typedef struct ztr_platform_api_t { platform_open_file *openFile; } ztr_platform_api_t; #define ZTR_INIT (name) void name (ztr_platform_api_t *platform) ZTR_INIT (ztrInit); // RenderView.m(iOS) #import "ztr_platform_abstraction_layer.h" static ztr_platform_api_t g_platform; - ( void ) setup { g_platform.openFile = openFile; ztrInit (&g_platform); } // RenderLib_ndk.cpp (Kotlin側から呼ぶAndroid JNIインタフェース) #import "ztr_platform_abstraction_layer.h" static ztr_platform_api_t g_platform; extern "C" JNIEXPORT void JNICALL Java_com_zozo_ztr_1android_RenderLib_init (JNIEnv* env, void *reserved, jobject assetManager) { g_platform.openFile = openFile; ztrInit (&g_platform); } // ztr_platform_independent_layer.cpp #import "ztr_platform_abstraction_layer.h" static ztr_platform_api_t *g_platform; ZTR_INIT (ztrInit) { // プラットフォームAPIのポインターを保存する g_platform = platform; // ここへプラットフォームに依存しない初期化コードを書く // ... // ... // ... } 共有のC++コードで初期化関数を実行したい場合は ztr_platform_abstraction_layer.h の初期化関数のシグネチャー ztrInit() が定義されているので、各プラットフォームで必要に応じてこの関数を呼び出し、必要なデータを渡すと、プログラムが正しく起動されます。 iOSの場合は RenderView.m の setup 関数から呼び出されます。Androidの場合は RenderLib の init 関数から呼び出されます。厳密に言うと、AndroidはJNIインタフェースを通じて RenderLib_ndk.cpp の中間関数を呼び出す、という流れです。この時、プラットフォームへのポインターも保存します。 プリプロセッサマクロを使用して関数のシグネチャを定義するという考え方は、 Handmade Hero からのものであることに注意してください。これは、抽象化レイヤー(platform abstraction layer)関数のシグネチャの引数の重複を回避するためです。これを使わないと各プラットフォームレイヤーにシグネチャーを複製しないといけません。引数の順番、数等を変更する場合も各プラットフォームの変更が必要です。プリプロセッサマクロを使用したら1つの変更で済むのでとても便利です。 プラットフォーム機能の呼び出し ztr_platform_independent_layer.cc がiOSとAndroidのプラットフォームレイヤー(platform layer)に存在するファイルを開く例を紹介します。 // ztr_platform_abstraction_layer.h #define PLATFORM_OPEN_FILE (name) ztr_file_t name ( const char *fileName) typedef PLATFORM_OPEN_FILE (platform_open_file); // RenderView.m(iOS) PLATFORM_OPEN_FILE (openFile) { ztr_file_t result = {}; NSArray *components = [[NSString stringWithUTF8String:fileName] componentsSeparatedByString :@ "." ]; if (components.count == 2 ) { NSString *fileNameBase = [NSString stringWithFormat:@ "res/%@" , components[ 0 ]]; NSURL *fileUrl = [[NSBundle mainBundle] URLForResource:fileNameBase withExtension :components[ 1 ]]; NSFileManager *manager = [NSFileManager defaultManager]; NSData *data = [manager contentsAtPath:fileUrl.path]; if (data) { result.data = ( void *) data.bytes; result.dataSize = ( unsigned int ) data.length; } } return (result); } // RenderLib_ndk.cpp(Android JNI interface) PLATFORM_OPEN_FILE (openFile) { assert (fileName != NULL ); ztr_file_t result = {}; AAsset *asset = AAssetManager_open (asset_manager, fileName, AASSET_MODE_STREAMING); assert (asset != NULL ); if (asset != NULL ) { result.data = ( void *) AAsset_getBuffer (asset); result.dataSize = AAsset_getLength (asset); } return (result); } // ztr_platform_independent_layer.cc static mesh_t * loadObj ( const char *fileName) { ztr_file_t file = g_platform-> openFile (fileName); if (file.data != NULL ) { // OBJファイル内容を読み込む } } プラットフォームに依存しないレイヤー、 ztr_platform_independent_layer.cc の loadObj() 関数では、3Dメッシュを読み込むためOBJファイルを開かなければなりません。ファイル名は認識していますが、ファイルを開くのはプラットフォーム自体に任せるしかありません。iOSとAndroidは異なる方法でファイルにアクセスするため、それぞれのプラットフォームレイヤーに依存する必要があります。 iOSアプリ内のファイルは、アプリケーションの内部ディレクトリ構造の特定の場所に存在するバンドルというものに収まっています。Androidの場合は、APK(基本的にはzipファイル)からファイルを抽出する必要があります。 ztr_platform_independent_layer.cc は各プラットフォームのファイルを開く方法は何も知らず、むしろ、知るべきではありません。 RenderView.m や RenderLib_ndk.cpp は ztr_platform_abstraction_layer.h で定義しているインタフェースに従い、 ztr_file_t の構造体を返しさえすれば、プラットフォーム間の差を無くすことができます。 これは、グローバル構造体 g_platform が使用される場所です。ファイルを開く関数を呼び出すには、プラットフォームへの参照が必要です。 g_platform は初期化関数で保存されています。 クロスプラットフォームOpenGL ES // ztr_platform_abstraction_layer.h typedef struct ztr_hid_t { float mouseX; float mouseY; int mouseDown; int mouseTransition; int doubleTap; int pinchZoomActive; int pinchZoomTransition; float pinchZoomScale; } ztr_hid_t; // MARK: Platform callback functions #define ZTR_INIT (name) void name (ztr_platform_api_t *platform) ZTR_INIT (ztrInit); #define ZTR_DRAW (name) void name (ztr_mem_t *mem, ztr_hid_t hid) ZTR_DRAW (ztrDraw); #define ZTR_RESIZE (name) void name (ztr_platform_api_t *platform, \ int w, int h) ZTR_RESIZE (ztrResize); クロスプラットフォームOpenGL ESを実装するのに、3つのコールバック関数を抽象化する必要があります。初期化関数、描画関数(各フレームごとに呼ばれる)、および画面のサイズを変更する関数です。 各プラットフォームのOpenGL ESセットアップについてはここでは説明しないので、興味のある方はぜひ、 サンプルプロジェクト を参考にしてください。 iOSの描画関数は次のようになります。 // RenderView.m(iOS) - ( void ) drawView { [[self openGLContext] makeCurrentContext]; CGLLockContext ([[self openGLContext] CGLContextObj]); ztrDraw ( 0 , g_hid); g_hid.mouseTransition = 0 ; CGLFlushDrawable ([[self openGLContext] CGLContextObj]); CGLUnlockContext ([[self openGLContext] CGLContextObj]); } プラットフォームレイヤー、 RenderView.m が特有のOpenGL ESメソッドを呼び出してから、抽象化されてる関数 ztrDraw を呼び出します。ユーザーのタッチ操作データも渡します。 Androidの場合、セットアップはより複雑ですが、最終的には RenderView クラスが RenderLib.draw() を呼び出します。 // RenderView.kt inner class Renderer( val assetManager: AssetManager) : GLSurfaceView.Renderer { var view: RenderView? = null var onSurfaceCreatedClosure: ((view: RenderView) -> Unit )? = null override fun onDrawFrame(gl: GL10) { RenderLib.draw( this @RenderView._mouseDown, this @RenderView._mouseDownUp, this @RenderView._mouseX, this @RenderView._mouseY ) } } RenderLib.draw() はJNIという機構を経由して、 Java_com_zozo_ztr_1android_RenderLib_draw のC++関数で、 ztrDraw を呼び出します。いくら文句を言ってもAndroidのJNIとはそういうものです。 // RenderLib_ndk.cpp(Android JNIインタフェース) extern "C" JNIEXPORT void JNICALL Java_com_zozo_ztr_1android_RenderLib_draw (JNIEnv* env, jobject obj, jint mouseDown, jint mouseDownUp, jint x, jint y) { hid.mouseDown = static_cast< int > (mouseDown); hid.mouseTransition = static_cast< int > (mouseDownUp); hid.mouseX = static_cast< int > (x); hid.mouseY = static_cast< int > (-y); ztrDraw ( 0 , hid); hid.mouseTransition = 0 ; } GLSurfaceViewやJNIなしでOpenGL ESを呼び出すことも可能であり、そのようなアプローチはより綺麗でパフォーマンスも高くすることができます。 クロスプラットフォームのレンダリング お馴染みのStanford Bunnyをレンダリングするための処理を紹介します。 初期化関数は以下のようになります。 // ztr_platform_independent_layer.cc ZTR_INIT (ztrInit) { g_platform = platform; g_scene.shaderCount = 0 ; // OpenGL ESを設定する GLint m_viewport[ 4 ]; glGetIntegerv (GL_VIEWPORT, m_viewport); glEnable (GL_CULL_FACE); glEnable (GL_BLEND); glBlendEquationSeparate (GL_FUNC_ADD,GL_FUNC_ADD); glBlendFuncSeparate (GL_ONE, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); // シェーダープログラムをテキストファイルから読み込む assert (g_scene.shaderCount < MAX_SHADERS); g_scene.objectShader = g_scene.shaders + g_scene.shaderCount++; g_scene.objectShader->program = LoadShaders (shadingVersion, ( char *) "shaders/object_vert.glsl" , ( char *) "shaders/object_frag.glsl" ); g_scene.objectShader->elementType = GL_TRIANGLES; glUseProgram (g_scene.objectShader->program); // 一度、シェーダーの色を設定する GLint objectColorLoc = glGetUniformLocation (g_scene.objectShader->program, "objectColor" ); glUniform3f (objectColorLoc, 255.f / 255.99f , 174.f / 255.99f , 82.f / 255.99f ); GL_CHECK_ERROR (); // すべての構造体の初期値を設定する関数を呼び出す InitScene (&g_scene); InitCam (&g_scene.camera); InitMouse (&g_scene.mouse); // Stanford Bunny メッシュを読み込む // loadObj関数はメッシュのVAO、VBO、EBO、バッファを初期化する // GPU上に頂点とインデックスのデータを保存する領域を確保している mesh_t *bunnyMesh = loadObj ( "bunny_vn.obj" ); float S = 1.f ; bunnyMesh->S = HMM_Scale ( HMM_Vec3 (S, S, S)); bunnyMesh->R = HMM_Rotate ( 0.f , HMM_Vec3 ( 1 , 0 , 0 )); bunnyMesh->T = HMM_Translate ( HMM_Vec3 ( 0 , 0 , 0 )); bunnyMesh->shader = g_scene.objectShader; g_scene.ready = 1 ; g_scene.animatingIntroFade = 1 ; } loadObj 関数の内容は以下のようになります。 // ztr_platform_independent_layer.cc static mesh_t * loadObj ( const char *fileName) { // (省略した)tinyobj_parse_obj で頂点データを読み込む // ... // ... // ... // VAO、VBO、EBO、を初期化する glGenVertexArrays ( 1 , &mesh->VAO); glGenBuffers ( 1 , &mesh->VBO); glGenBuffers ( 1 , &mesh->EBO); // VAOを紐づけるとVBOとEBOを設定できる glBindVertexArray (mesh->VAO); // VBOバッファーメッシュの頂点を割り当てる glBindBuffer (GL_ARRAY_BUFFER, mesh->VBO); glBufferData (GL_ARRAY_BUFFER, mesh->verticesCount* sizeof (vertex_t), mesh->vertices, GL_STATIC_DRAW); // EBOバッファーメッシュのインデックスを割り当てる glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, mesh->EBO); glBufferData (GL_ELEMENT_ARRAY_BUFFER, mesh->indicesCount* sizeof (GLushort), mesh->indices, GL_STATIC_DRAW); // メモリ上の頂点構造体(vertex_t)のポジションの位置を指定する glEnableVertexAttribArray ( 0 ); glVertexAttribPointer ( 0 , 3 , GL_FLOAT, GL_FALSE, sizeof (vertex_t), (GLvoid *) offsetof (vertex_t, position)); // メモリ上の頂点構造体(vertex_t)のノーマルの位置を指定する glEnableVertexAttribArray ( 1 ); glVertexAttribPointer ( 1 , 3 , GL_FLOAT, GL_FALSE, sizeof (vertex_t), (GLvoid *) offsetof (vertex_t, normal)); // glBindVertexArray に0を指定するとVAOが解放される glBindVertexArray ( 0 ); } 各フレームごとに呼び出されている描画関数は以下のようになります。 // ztr_platform_independent_layer.cc ZTR_DRAW (ztrDraw) { // 白色で塗りつぶす glClearColor ( 1.f , 1.f , 1.f , 1.f ); glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); if (g_scene.ready) { camera_t *cam = &g_scene.camera; mouse_t *mouse = &g_scene.mouse; // (省略)タッチ操作の変化でカメラの位置を計算する // ... // ... // ... // 射影行列とビュー行列を作成する float ratio = ( float ) g_scene.screenDims.Y/( float ) g_scene.screenDims.X; float orth = cam->orthScale; hmm_mat4 proj = HMM_Orthographic (-orth, orth, -ratio*orth, ratio*orth, CAM_NEAR, CAM_FAR); hmm_mat4 view = HMM_LookAt (cam->pos, CAM_LOOKAT_CENTER, CAM_LOOKAT_UP); // 射影行列とビュー行列をシェーダープログラムに渡す for ( int i= 0 ; i<g_scene.shaderCount ; i++) { shader_t *shader = g_scene.shaders + i; glUseProgram (shader->program); GLuint viewMatrixLoc = glGetUniformLocation (shader->program, "view" ); glUniformMatrix4fv (viewMatrixLoc, 1 , GL_FALSE, &view.Elements[ 0 ][ 0 ]); GL_CHECK_ERROR (); GLuint projMatrixLoc = glGetUniformLocation (shader->program, "projection" ); glUniformMatrix4fv (projMatrixLoc, 1 , GL_FALSE, &proj.Elements[ 0 ][ 0 ]); GL_CHECK_ERROR (); } // メッシュの位置、回転情報をシェーダープログラムに渡す for ( int i= 0 ; i<g_scene.meshCount ; i++) { mesh_t *mesh = g_scene.meshes + i; shader_t *shader = mesh->shader; glUseProgram (shader->program); mesh->model = mesh->T*mesh->R*mesh->S; GLuint rotateMatrixLoc = glGetUniformLocation (shader->program, "rotate" ); glUniformMatrix4fv (rotateMatrixLoc, 1 , GL_FALSE, &mesh->R.Elements[ 0 ][ 0 ]); GLuint modelMatrixLoc = glGetUniformLocation (shader->program, "model" ); glUniformMatrix4fv (modelMatrixLoc, 1 , GL_FALSE, &mesh->model.Elements[ 0 ][ 0 ]); // VAOを紐づける glBindVertexArray (mesh->VAO); GL_CHECK_ERROR (); // シェーダープログラムを経由して、三角形を描く glDrawElements (shader->elementType, mesh->indicesCount, GL_UNSIGNED_SHORT, 0 ); GL_CHECK_ERROR (); glBindVertexArray ( 0 ); GL_CHECK_ERROR (); } // 懐中電灯の効果のためカメラの位置をシェーダープログラムに渡す glUseProgram (g_scene.objectShader->program); GL_CHECK_ERROR (); hmm_vec3 lightPos = HMM_Vec3 ( 1 , 1 , 2 ); GLint lightPosLoc = glGetUniformLocation (g_scene.objectShader->program, "lightPos" ); glUniform3f (lightPosLoc, lightPos[ 0 ], lightPos[ 1 ], lightPos[ 2 ]); GL_CHECK_ERROR (); } } ここではOpenGLについてほんの僅かしか説明しておらず、シェーダーすら紹介していません。3Dラスタグラフィックスの基本については、いくつかの分かりやすいチュートリアル等が存在しているので、そちらを参考にしてください。 もう1つ、解説しておかないと分かりづらい箇所があります。 glDrawElements() のようなOpenGL ES関数はどこから来ているのでしょうか?それらもプラットフォームに抽象化されるべきではないか、と思われる方もいるかもしれません。 厳密に言えば、そうすべきです。ただし、どちらのプラットフォームでも同じであり、パフォーマンスの面で考えると直接呼び出した方が楽です。たとえばAppleのMetalレンダリングAPIを使用する場合は、レンダラーの抽象化も必要となります。 まとめ ZOZOMATを発表してからしばらく時間が経ち、クロスプラットフォームの共有C++とOpenGL ESを使用したことは正解だと思いました。 サンプルプロジェクト のように統一感を保つことができ、設計要件を満たして、開発コストも節約できました。 しかし、クロスプラットフォームコードは決して万能なソリューションではありません。いくら利点があっても、すべてのプロジェクトで実装することが賢明であるとは限りません。3Dの表示方法が簡単な場合や、迅速にプロトタイプが必要な場合は、SceneKitやSceneformで十分だと思います。 プラットフォーム抽象化レイヤーに追加された関数は、プラットフォームでの実装コストが発生します。コストが高まると、収穫逓減点を超える可能性があります。 プロジェクトで複数のプラットフォームのサポートが必要で、コードの大部分を共有できることが明確である場合は、少し時間をかけてクロスプラットフォームソリューションを実装する価値があると思います。 ZOZOテクノロジーズでは、iOSエンジニアを募集しています。興味のある方はこちらからご応募ください! corp.zozo.com
アバター
はじめに こんにちは。MSP技術推進部の松藤です。本記事では弊社が展開する マルチサイズプラットフォーム 事業(MSP)におけるデジタルトランスフォーメーション(DX)の取り組みについて紹介します。 目次 はじめに 目次 マルチサイズプラットフォーム(MSP)とは なぜDXが必要なのか MSP技術推進部の取り組み ケアラベル自動化 検寸データ連携 検品データ連携 進捗データ連携 自動検寸 おわりに マルチサイズプラットフォーム(MSP)とは 低身長や高身長の方も、サイズ選びに悩まず購入できるサービスです。ZOZOTOWN上の MSP対象商品 を選んで、身長と体重を設定すると、あなたに合ったアパレルのサイズをレコメンドします。これによって届いた商品を着てみたらモデルさんのイメージと違った、膝丈ワンピースのはずがロングになった、逆に短すぎてミニサイズになったなどのサイズにまつわるペインポイントの解消を目指しています。 もともとは弊社のプライベートブランドとしてZOZOSUITとセットで始めた事業でした。そこで培ったサイズに特化した服作りの経験をベースに現在はZOZOTOWNに出店いただいているブランド様と一緒に商品を企画・生産・販売させていただいております。 なぜDXが必要なのか ここから本題ですが、MSPにおけるアパレル生産になぜDXが必要なのか、という点です。MSPでは、下図のようにブランドや商社、生産工場、物の移動を担うフォワーダー、運送会社など複数の企業が携わってお客様の元へ商品をお届けしています。川の流れのように上から1つずつ工程を流していき、発生するリクエストや課題をクリアしながら、上から下へバトンをつないでいきます。 エンジニアのみなさんは、工程ごとに担当や企業が異なるウォーターフォールモデルの開発を想像してもらえればイメージが湧くかと思います。 ただ実際には上図のようにシンプルな川の流れではなく、下図のように製品ごとに別々の流れがあり、各工程のタイミングは異なるケースが多いです。連続したウォータフォールを抜け漏れなく、バトンをつないで1つの製品にする。そして川の流れの中で、発生するトラブルや変更に対処しながら川の流れを整える。なんとなくこの図でアパレル生産の複雑性が理解できるのではないでしょうか。 こういった複雑な工程の中で、すべてを完璧にこなすのは非常に難易度が高く、製品や関係者が増えることでコミュニケーションコストや重複作業が多く発生しているというのが現状でした。 そこでMSP事業では、数多くのDX施策を実行して作業コスト削減を進めています。たとえば製品ごとのデータ管理を個別のExcelからkintoneへ寄せて集中管理したり、業務フローをすべて書き出して標準化やマニュアル化を図ったり、進行状況をリアルタイムでわかるようにするなどです。 その中でも我々のMSP技術推進部がどんなDX施策をやっているかご紹介します。 MSP技術推進部の取り組み MSP技術推進部では、各アパレル生産のエキスパートがそれぞれ集中すべき業務に集中できるように大小含めてさまざまな仕組みの構築をしています。我々は主に生産ドメインに特化した形で、生産データの連携や書類の自動作成、データの可視化に取り組み、MSP事業をテック面で推進しています。 システムやツールはほぼフルスクラッチで開発をしていて、業務対応した柔軟なシステム構成を目指しています。テクニカルには、Go言語、Python、Kotlin、gRPC、OpenAPI、Docker、PostgreSQL、AWS、Alibaba Cloudが主なアセットです。 この先は過去行った施策をいくつか紹介します。 ケアラベル自動化 MSPの商品には統一規格のケアラベルが取り付けられています。 ケアラベルの特徴として、生地によって洗濯表記(画像上段)や生地の混率(画像中上段)が異なり、製品・生地毎に表示内容を変更しなければなりません。自動化する以前は、弊社デザイナーがPhotoshopを使って一種類ずつレイアウトする必要がありました。一種類を作成することは大きな作業ではないのですが、アパレルの生産サイクルの都合上、同じ時期に作業が集中するため、デザイナーの負荷が一気に上がる状態でした。これを解決すべく、ケアラベルの自動生成にトライしています。 これに関しては以前弊チームの @ikeponsu が会社のブログを書いてくれました。詳しくはブログをご参照ください。 techblog.zozo.com このブログから約1年で開発が進み、現在はkintoneのデータを拾って自動的にケアラベル用のデータを作成できる仕組みとなりました。これにより人が介在するのはケアラベルの表示内容を決定する工程のみとなり、より専門的な業務へフォーカスできるようになっています。 検寸データ連携 MSPの製品において、検寸は非常に重要な工程の1つです。とくに製品のサイズを売りにしている我々の製品は、通常のアパレル標準の基準よりも厳しい基準を工場にお願いしております。しかし検寸する側からすると、MSPの製品は展開サイズが多いことから、チェックするパターン数が多く、検寸作業に多くの時間を割いていました。その課題を解決するべく、Bluetooh搭載の電子メジャーとAndroidアプリを連結して、検寸作業の効率化を行っています。ケアラベルへ付属するQRコードにあらかじめ検寸が必要な箇所と仕上がりの基準値、合格基準を埋め込んでおくことによりQRコードでオペレーターへ指示内容を表示できるようになりました。 以前までは結果を紙に書いたり、結果と基準の目視確認が必要だったのですが、これによりオペレーターは指示された計測箇所を電子メジャーで計測するだけで入力と合否判定を自動で行えるようになりました。検寸でNGになった製品にはシールプリンターでシールを出力し、工場内で情報共有ができるようにしています。 検品データ連携 MSPでは製品に不良がないか出荷前に全量検品を行っています。しかし不良が出た場合の言語化が難しく、物理的な製品を扱うため現物を郵送したり、現地とのリアルタイムなやりとりで詳細を聞くなどでコミュニケーションに多くの時間を要していました。その課題を解決するべき、商品の状態を記録するAndroidアプリの開発を行っています。 使い方はシンプルでケアラベルへ付属するQRコードを読み取り、不良があればアプリ内で該当の不良を選択。その後不良箇所を選んで、状態がわかるようにスマホのカメラで撮影します。この作業で検品の記録ができる他、製品と不良の場所と状況がすべて紐づくためデータを見るだけで何が起きているのか判断しやすくなりました。 現場では、製品と登録したデータが紐づくようにシールプリンターで下図のようなシールを出力し、製品に貼っています。 この検品データ連携と検寸データ連携によって、下図のような不良報告のレポート作成も自動化ができるようになり、管理コストがぐっと下がりました。 進捗データ連携 工場での生産は各工程の中でもっとも長い時間をかける工程の1つです。とくにお客様から注文を受けてから1点ずつ生産をする受注生産においては、生産遅れが発生するとお客様と注文時に約束したお届け日でお届けできないリスクが生じます。そういったリスクを早期に発見するため、工場ではケアラベルへ付属するQRコードとAndroidアプリを使って個体ごとに進捗管理をしていただいています。 これによって正常に進行しているものと、遅延が発生しているものを瞬時に区別でき、遅延しているものに絞ってアクションができるようになりました。これまでは進捗確認を都度電話で行っていたため確認する側される側共に負荷になっていたのですが、遅延として上がってきたものだけを対処すればよくなり双方の負荷が少なくなっています。 自動検寸 上記で挙げた検寸データ連携の施策以前に、産業用カメラとZOZOSUITで培った画像処理技術を使って、メジャー不要な計測システムにもトライしています。ZOZOSUITで使っているようなマーカーを両手に持ち産業用カメラで撮影すると、マーカー間の距離を自動計算し結果と画像をサーバへ送信するソフトウェアとハードウェアの開発を行いました。 社内では画期的な仕組みだということで、すぐに取引先と試験導入が決まったのですが、結論からいくとこれは導入見送りになってしまいました。 大きな要因としては以下です。 設備導入コストが高すぎたこと 既存業務から大きく外れすぎたこと メジャーを使わずに計測する点やカメラを主軸にすることで、従来の計測スタイルと大きく異なった点が既存業務から大きく外れてしまいました。結果、我々の想定よりも生産性向上に寄与しなかったことで、設備導入コストと見合わなくなってしまったところが大きかったです。 ただし、ここで得た経験はのちのDX施策の考慮ポイントとして生きています。業務の親和性を考え電子メジャーを採用したり、導入・調達コストが低いAndroidを採用することで導入のしやすさが向上しました。とりわけDX施策においてはその分野のエキスパートとじっくり話をし、作業を紐解き、既存業務と親和性を考えた施策に落とし込むことが重要だと感じています。 今回の施策は見送りになってしまいましたが、自動検寸の確立はアパレル産業の夢なので引き続き追いかけていきます。 おわりに 本記事ではMSP技術推進部の取り組みについて紹介しました。上記の施策以外にもグレーディングの自動化を目指した研究やシステムフレンドリーなマーキング技術の開発、業務フロー内に存在する手作業のシステム化によってさらなる効率化を狙っています。1つ1つは派手な施策ではありませんが、小さな施策もコツコツ積み重ねることで知識のフレームワークになり、さらに積み重ねていくことでプラットフォームになります。MSP技術推進部では、各エキスパートのみなさんが集中すべき作業に集中し、よりよいものを作れるような全体最適なプラットフォーム作りを目指して開発を進めていきます。 ZOZOテクノロジーズでは、ZOZOTOWNやWEARのサービスをはじめ、事業を支えるさまざまな職種を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com ※「QRコード」は、株式会社デンソーウェーブの登録商標です。
アバター
こんにちは、基幹システム部BASEチームの横山です。 突然ですが、ちょうど1年程前に行われた ZOZOバイト革命 は覚えていますでしょうか?物流倉庫「ZOZOBASE」で一緒に働いてくれる仲間の2000人募集や、基本時給のUP等で少しだけ話題になりましたね。 今回は、そんなZOZOBASEの人材を管理する上で一助となる作業実績の集計自動化について紹介します。 はじめに ZOZOTOWNは独自の物流倉庫を保有しており、ブランド様からの商品入荷や保管、お客様への発送まで独自のシステムを用いて行っています。 一口に入荷から発送までと書きましたが、その中には多種多様な作業があり、現場で働く多くの方々に支えられております。このような物流倉庫の戦略を考える上で実績管理や人材管理はとても重要になります。 そんな実績の集計ですが、少し前までは人手で行われていました。それを自動化する際、どのような要件で設計・開発を行ったか、データ収集・集計・可視化はどのように行ったかを紹介します。社内で実績集計の自動化を行う際のお役に立てれば幸いです。 開発要件 開発の目的 ZOZOBASEでは倉庫全体の目標管理や、各作業者への教育・指導に生産性や単価といった指標が用いられています。しかし、その算出はこれまでデータ収集から集計作業まで人手で行われてきました。それでは集計自体が大変であったり算出ミスの可能性などいくつも問題があるため、データ収集・集計・表示を自動で行うことを目的として開発が始まりました。 物流に関する処理は自社システムを用いていますが、これまでの積み重ねで肥大化したシステムに手を加えるとなると、その改修範囲は膨大なものになってしまいます。今回は運用開始までの時間をなるべく短縮するため、既存のシステムへの改修を最小限にする形で行いました。 実績の指標 先述の通り、ZOZOBASEにおける実績には、生産性と単価という2つの指標が用いられています。今回の開発においても上記2つの指標を可視化することを目的としています。生産性は単位時間あたりどのくらいの商品を処理できたか、単価は商品一品あたりにどれくらいの人件費がかかっていたかを表しています。 生産性 = 処理量 / 作業時間 単価 = 人件費 / 処理量 ZOZOBASE内の作業 ZOZOBASEで行われている作業ですが、細かく分けると全部で100種類程あります。全ては書ききれないので、今回は入荷に関する作業を3つご紹介します。 バーコード検品/入荷処理 データ連携をしているブランド様からお預かりした商品に欠品等がないか確認しながら、タグをスキャナーで読み取り、商品管理シールを張る作業 荷受け/社内納品書作成 データ連携をしていないブランド様からお預かりした荷物の納品書をもとに商品情報を打ち込んでいく作業 通常検品/検品 荷受けで作成された商品情報をもとに欠品等がないか確認しつつ、商品名や色・サイズを確認しながら対応する商品管理シールを張る作業 他にも、トラック対応や段ボールの運搬等、入荷に関する作業だけでもその種類は多岐に渡ります。入荷以外の作業については 求人ページ に記載があるので、是非ご覧ください。 実績表示の粒度 上記で紹介した作業を見ると、 バーコード検品は商品タグを読みこむだけで対応する商品管理シールが発行される 通常検品は商品名・色・サイズを確認して対応する管理シールを探さなくてはならない 荷受けの社内納品書作成は上記2つと全く異なった作業 といった具合に、作業によって一商品の処理にかかる時間が異なることを感じられると思います。 つまり、異なる作業の生産性や単価を一概に比較することはできません。そのため、各作業単位で実績を可視化する必要があります。しかしながら、先述の通りZOZOBASEで行われている作業は多岐に渡るため、全ての作業実績を順に見るわけにはいきません。そこで、各作業を以下の3つの粒度で表示を行うことにしました。 ブロック 発送や入荷等の物流機能を一口で表す単位 セクション ブロック内の作業を種類ごとにまとめた単位で、入荷ブロックにはバーコード検品、荷受け、通常検品の3つのセクションがある アクティビティ 実作業の単位で、通常検品セクションを例に挙げると、検品作業、シール出し作業、搬送作業等がある 個人の生産性 ZOZOBASEでは、各作業者への指導材料としても生産性の値を用いています。そのため、個人の生産性も算出する必要があります。単価については、時給の違いがダイレクトに表れてしまうため、個人単価の算出は行わずに生産性のみ算出します。 千葉県習志野拠点と茨城県つくば拠点 ZOZOBASEは千葉県習志野と、茨城県つくばの2か所に拠点を構えています。拠点毎に比較を行うことを考え、どこの拠点で行われた作業なのかといったデータも保持しておく必要があります。 作業者の雇用形態 ZOZOBASEで働く作業者には様々な雇用形態があり、雇用形態毎に時給が変わります。アルバイトからアルバイトリーダーへの昇進といったように雇用形態が変化することもあります。 集計のスパン 実績の集計は毎日行われるようにします。 データの準備 実績算出に必要なデータ ここまでの内容から、集計・可視化を行うにあたり必要なデータとその条件は以下のように考えられます。 必要なデータ 処理量 作業時間 人件費 条件 各作業毎のデータであること 人件費算出・個人生産性の算出を考え、作業者一人一人を識別できること 人件費算出のため雇用形態に関する情報も含むこと 集計は日毎に行うこと 処理量データの取得 ZOZOBASEで行われる作業は自社システムを用いて行っているため、そのデータの流れから作業者の処理量を取得します。作業によってはシステムを介さないものもありますが、その場合は他作業の処理量から類推したり、日の終わりに入力する欄を設けることで補います。 作業時間と人件費の取得 自社システムを用いて作業を行っているとはいえ、「ある作業者が何時にどの作業を始めて、何時には別の作業に移った」といったデータは持っていませんでした。 また、処理量データと同様に、システムを介さない作業の作業時間を取得する必要があります。 そこで打刻システムを作成し、始業や終業、他の作業に切り替えるタイミングで必ず打刻してもらうことで、各作業に従事している時間を個人毎に取得できるようにしました。このデータは日に1万件近く集まります。 打刻システムの導入 新たに作成した打刻システムですが、この打刻という行為に時間をとられてしまっては元も子もありません。そこで、作業者が用いるハンディで打刻できるようにしたり、入館証と個人を紐づけ入館証をタッチするだけで打刻できるようにしています。 人件費は打刻データから休憩時間等を考慮した後、各作業者の雇用形態に紐づく時給をかけることで求めることができます。 以上のことを踏まえて、打刻システムで収集するデータは以下のようにしました。 アクティビティID 作業者ID 作業開始時間 作業終了時間 拠点ID 雇用形態ID 集計 処理量のデータはシステムの至るところに散在しており、打刻データは毎日膨大な量が集まります。これらのデータを表示の都度集計していてはロードに時間がかかってしまうため、日毎・作業毎に集計を行い集計データのみを集めたテーブルに保持しておきます。 実績の可視化 実績推移と生データ 可視化に際して、まず月次や日別での実績の推移をグラフで確認できる画面の作成を行いました。 次に、実績の指標である生産性・単価やそれらを算出する元となる人件費・総作業時間を表示する画面を作成し、それぞれの機能において表示期間・拠点・表示粒度の切り替えが行えるようにしました。 利用例としては、実績推移を表示する画面で実績が落ち込んでいる箇所をブロック→セクション→アクティビティの順に確認し、実績が落ち込んでいるアクティビティを特定する。 特定したアクティビティの生データとその期間に行った施策等を照らし合わせ、今後の施策を考える等が想定されます。 ※画像はダミーデータを表示したもので実際の実績とは関係ありません 個人生産性の確認 個人の生産性については教育・指導に用いるため、必要なのはブロックやセクション等の単位ではなくアクティビティ単位の表示のみです。そのため、生産性の平均値や総処理量、総作業時間の月次推移と日毎の処理量・作業時間の表示を1つのページにまとめて表示しています。 ※画像はダミーデータを表示したもので実際の実績とは関係ありません 打刻状況の確認 打刻システムを導入したことで、副次的に、現在どのような人がどのような作業をどれくらいの時間行っているのかを確認する機能を実装できました。この機能を用いることで現場管理をリアルタイムで行うことができます。 運用開始後に出てきた問題点とその解決方法 ここまで開発を終え、いざ運用を開始したところ、打刻システムの利用に関する部分で以下の問題点が出てきました。 誤った打刻がされてしまう(異なる作業、異なる拠点等) 打刻自体を忘れられてしまう 作業時間や人件費を算出する上で打刻データは必ず正確でなくてはならないのですが、上記問題の影響で正確なデータを扱えない期間がありました。 作業者の視点で考えると、いきなり打刻システムが導入されてよくわからないうちに運用が始まるもんだから開発者側が意図しない動きももちろんしますよね。それに人間だから忘れることもあります。 これらを解決するためにいくつかの改良を行いました。 打刻システムをもっと使いやすく 運用開始した初期は、拠点や雇用形態等の情報を作業者に選択してもらい打刻する仕組みだったため、誤打刻が頻発していました。そこで、拠点に関する情報はIPアドレスやCookieなどを利用して半自動で選択がされるように、雇用形態についてはDBの作業者を管理するテーブルで一元管理し自動入力されるようにしました。 また作業の選択については、物流システム上のメニューと打刻されている作業の紐づけを行い、メニューと紐づく作業で打刻されていなかったら該当作業の打刻画面に遷移するようにしました。 本来だったら自動で打刻されるようにしたかったのですが、1つのメニューに複数の作業が紐づく場合が多かったため自動打刻は断念しました。 集計値の修正をできるように 集計された後でも打刻データと集計データの修正を行えるように以下のことを行いました。 打刻データに修正フラグを持たせる 修正フラグの有無を検知し該当部分のみ集計し直すバッチを作成し毎日夜間に実行する 修正対象の打刻データと関連のある打刻データ(同拠点、同アクティビティ、同雇用形態)に対して修正フラグを立て、夜間実行のバッチに処理させる形です。 勤怠管理システムとの同期 各アクティビティの正確な作業時間を測定するため打刻システムを導入しましたが、労務で管理している勤怠システムもあります。 労務使用の勤怠管理システムでは勤務の開始時間と終了時間のみ記録されていますが、給与計算に用いられるため、人件費を算出する上ではこちらの値を使用したほうが正しい値が出せます。 ただ、そのデータが正式に利用できる形になるまで数日かかっていたため、運用を開始した初期は勤怠管理システムの情報は使えないでいましたが、集計後のデータ修正を可能にしたため同期が可能になりました。 現在では毎日の打刻データの中で各作業者の最初の開始時間と最後の終了時間のみ、勤怠管理システムの打刻時間に修正することでより正確なデータを用いて集計を行っています。 おわりに ZOZOTOWNを物流の面で支えるZOZOBASEにおける実績集計の自動化について紹介しました。既存システムになるべく改修を加えない形で実績を自動集計する一例として考えていただければと思います。 大部分が手探りの状態での開発でしたが、ZOZOBASEで働く方々と意見を出し合いながら今の形になりました。 まだまだ改善の余地はありますが、人手で行われていた集計を自動化することによって、現場の方々の負担は大分取り除かれているようです。 このように、ZOZOテクノロジーズではBASEをはじめとした様々な事業部と常に連携し、お互いが意見を出し合いながら開発を行っています。 ZOZOのサービスに愛着を持ち、より良いものにしていきたいという意思のある仲間を募集していますのでご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
ZOZOテクノロジーズSRE部の市橋です。普段は主にAWSを用いて複数プロダクトのシステム構築、運用に携わっています。今回は2020年2月にリリースされたZOZOMATについて、システム構成と開発時に直面した課題、その課題を解決するために工夫した点について紹介します。 ZOZOMATではEKSやgRPCを新規に採用しており、これによって仕様の変更に強くなる、通信のオーバーヘッドを削減できるなど様々なメリットを享受できました。しかし導入時に一筋縄ではいかないことがあったため、今回苦戦した点についてご紹介できればと思います。 ZOZOMATとは お客様の足を3Dで計測するために開発された計測用マットです。ZOZOMATでの計測情報をもとに、靴の推奨サイズを参照するなどのサービスをご利用いただくことが可能です。ご興味のある方は こちら をご確認ください。 ZOZOMATのシステム構成 システムの全体構成は以下のようになります。今回は構成図内のユーザートラフィックを処理するNLB、EKS周りが話の中心となります。この後説明していきます。 ZOZOMATシステムはAWS上に構築しています。システム監視にはDatadogを利用しており、Slackにインテグレーションして通知を行っています。 クライアントはネイティブアプリケーション(ZOZOTOWNアプリ)、ZOZOTOWNサーバーの2つで、上記の構成図内の上部に位置するものが該当します。通信方式は前者がHTTP/2(gRPC)、後者がHTTP/1.1(REST)となっています。 それぞれの役割をまとめると以下のようになります。 ZOZOTOWNアプリでは計測した時の足の画像データを元に各部位の計測値と3Dモデリングのデータを生成し、ZOZOMATシステムのデータベースに保存します。ZOZOTOWNサーバーからは靴の推奨サイズを参照する際にリクエストされ、保存されている計測情報を元に推奨サイズを計算し、ユーザーに結果を表示します。 前述の通り、アプリケーション実行基盤としてAWSのフルマネージド型のKubernetesサービスであるEKSを採用しました。EKSのワーカーノードはワーカーノードタイプと起動タイプの組み合わせから選択できます。 table { border-collapse: collapse; width: auto; } th { border: solid 1px #666666; color: #000000; background-color: #FFFFFF; text-align: left; } td { border: solid 1px #666666; color: #000000; background-color: #ffffff; text-align: left; } thead th { background-color: #FFFFFF; } ワーカーノードタイプ 起動タイプ 特徴 セルフマネージド型 EC2 EC2インスタンスを自前で作成、管理する必要がある。 AutoScalingグループを自前で設定、管理する必要がある。 EC2インスタンスをEKSクラスターに参加させるために満たさなければならない要件が多い。 マネージド型 EC2 EC2インスタンスを自前で管理する必要がある。 EC2インスタンスをEKSクラスターに参加させるための設定が省略できる。 AutoScalingグループを設定することなく、水平スケーリングが可能。 マネージド型 Fargate EC2インスタンスの管理が不要。 EC2がプロビジョニングされないため柔軟なキャパシティ管理が可能。 NLBは2020年5月時点で未サポート。 今回は管理コストを削減することを目的として、マネージド型ワーカーノード、EC2起動タイプを選択しました。インスタンス管理が不要になるメリットを享受したくFargateの利用も検討したのですが、NLBに対応していない点で今回のシステム要件を満たすことができないことから採用を見送りました。NLBが必要な理由については後述します。 EKS内のpodに着目すると以下のようになります。 前述の通り、ZOZOMATシステムではgRPCとREST、両方のリクエストを受け付けられる必要があります。そのため、EnvoyのgRPC-JSON transcoder機能により、REST形式のAPIリクエストをgRPCに変換する処理を行っています。 アプリケーションはユーザーリクエストを受け付け、計測結果の登録やS3の署名付きURLの発行等を担うものと、計測後に表示される足形診断や靴のサイズ推奨値を計算する機械学習系のものに大別されます。前者はScala、後者はPythonで書かれています。上記のアプリケーションはそれぞれリソース、スケール要件が異なるため、それぞれ別のノードグループに配置しています。metrics-serverやexternal-dnsなどAPI処理以外の用途のpodについても、他のpodと比べて必要リソースや可用性レベルが下がるため別のノードグループに配置しました。 直面した課題 ここまではZOZOMATのシステム構成について説明しました。ここからはこの構成で開発を進める中で直面した課題についてみていきます。 1. 秘密情報の取り扱いについて まず、秘密情報の取り扱いについてです。Kubernetesで秘密情報を扱う場合は、Secretリソースを利用します。公式からの抜粋となりますが、利用方法は以下のようになります。 $ echo -n ' 1f2d1e2e67df ' | base64 MWYyZDFlMmU2N2Rm apiVersion : v1 kind : Secret metadata : name : mysecret type : Opaque data : username : YWRtaW4= password : MWYyZDFlMmU2N2Rm 秘密情報として管理したい値をbase64エンコードしてマニフェストファイルに設定し、kubectl applyコマンドにより適用することで利用可能になります。しかし、この例は秘密情報をbase64エンコードしているだけでデコードも容易なため、このマニフェストファイルをGitHub等で構成管理してしまうと安全とは言えません。 この問題に対して、大きく2つの方法で対応しました。 1つは、initContainersを利用する方法です。この方法ではSecretを利用しません。initContainersは本来稼働させたいコンテナが起動する前に起動し、事前処理を行わせることができます。役割を全うしたらinitContainersはTerminateするため、余分なリソースを必要とすることなく運用できます。 以下は証明書情報をSecretsManagerから取得するときの例になります。 apiVersion : apps/v1 kind : Deployment metadata : labels : run : nginx name : nginx spec : replicas : 1 selector : matchLabels : run : nginx strategy : rollingUpdate : maxSurge : 1 maxUnavailable : 0 type : RollingUpdate template : metadata : labels : run : nginx spec : initContainers : - name : set-cert image : infrastructureascode/aws-cli command : [ "sh" , "-c" ] args : - | aws secretsmanager get-secret-value --secret-id ssl/certificate --region ap-northeast-1 --query "SecretString" --output text > /tmp/server.pem aws secretsmanager get-secret-value --secret-id ssl/privatekey --region ap-northeast-1 --query "SecretString" --output text > /tmp/server.key volumeMounts : - mountPath : /tmp/ name : cert containers : - image : nginx ports : - name : https containerPort : 443 name : nginx volumeMounts : - mountPath : /tmp/ name : cert volumes : - name : cert emptyDir : {} まず、initContainersからawscliを使ってSecretsManagerから証明書情報を取得し、マウントしたvolumeに保存します。その後、稼働させたいコンテナから証明書情報が保存されたvolumeをマウントすることで、証明書を利用することが可能になります。 もう1つの方法としては、GoDaddy社製の kubernetes-external-secrets というツールを使用する方法です。このツールを利用するとSecretsManager、またはSystemsManagerから値を取得し、その値をKubernetesのSecretリソースに格納できます。インストール方法は公式ページの記載の通り、以下のコマンドを実行してマニフェストファイルを取得し、kubectl applyによって適用することで利用可能になります。 $ git clone https://github.com/godaddy/kubernetes-external-secrets $ helm template -f charts/kubernetes-external-secrets/values.yaml --output-dir ./output_dir ./charts/kubernetes-external-secrets/ インストールが完了したら、任意の値をSecretsManagerから取り込むマニフェストを作成することで利用可能となります。以下にSecretsManagerからDBの接続情報を取得し、環境変数に設定する例を示します。前提としてSecretsManagerには以下のような形で格納されていることとします。 table { border-collapse: collapse; width: auto; } th { border: solid 1px #666666; color: #000000; background-color: #FFFFFF; } td { border: solid 1px #666666; color: #000000; background-color: #FFFFFF; } シークレット名 シークレット値(Key) シークレット値(Value) db/connect_info username hogehoge password fugafuga マニフェストのサンプルとしては以下のようになります。 apiVersion : kubernetes-client.io/v1 kind : ExternalSecret metadata : name : external-secret-db-info spec : backendType : secretsManager data : - key : db/connect_info property : username name : db-username - key : db/connect_info property : password name : db-password kubernetes-external-secretはkindに ExternalSecret を指定することで利用できます。今回はSecretsManagerを利用するので、backendTypeに secretsManager を指定します。 data の設定項目の意味はそれぞれ以下のようになります。 table { border-collapse: collapse; width: auto; } th { border: solid 1px #666666; color: #000000; background-color: #FFFFFF; } td { border: solid 1px #666666; color: #000000; background-color: #FFFFFF; } 設定項目 説明 key SectetsManagerから取得したいシークレット名を指定 property SectetsManagerから取得したいシークレット値のKeyを指定 name KubernetesのSecretとして設定する名前を指定 次にSecretを利用する側のマニフェストを以下に示します。ポイントとしては、 secretKeyRef のnameにはexternal-secretsで設定した .metadata.name (external-secret-db-info)、keyには .spec.data.name (db-username、またはdb-password)を指定します。 apiVersion : apps/v1 kind : Deployment metadata : name : api-deployment spec : replicas : 1 selector : matchLabels : app : api strategy : rollingUpdate : maxSurge : 1 maxUnavailable : 0 type : RollingUpdate template : metadata : labels : app : api spec : containers : - name : api image : api-sample imagePullPolicy : IfNotPresent env : - name : DB_USERNAME valueFrom : secretKeyRef : name : external-secret-db-info key : db-username - name : DB_PASSWORD valueFrom : secretKeyRef : name : external-secret-db-info key : db-password これらの方法を使うことで、安全に秘密情報を管理することが可能になります。 2. NLBがALPNに対応していない件について ZOZOMATシステムではgRPCを利用して通信しています。gRPCはHTTP/2を利用した通信となるため、ロードバランサーがHTTP/2に対応している、もしくはL4(TCP)ロードバランサーである必要があります。Application Load Balancerは、フロントエンド(リスナー)はHTTP/2に対応しているものの、バックエンド(ターゲット)はHTTP/1.1にしか対応していません。そのため、ロードバランサーの背後で稼働するgRPCアプリケーションにリクエストを転送できません。AWSで利用できるHTTP/2の通信に対応したロードバランサーはNetwork Load Balancer(以下、NLB)、Classic Load Balancer(以下、CLB)となります。CLBはEC2-Classicネットワーク内に構築されたシステムの場合に使う必要があるのですが、それ以外の場合は性能上の理由から採用する理由はないため、実質NLB一択となります。 しかし、NLBでTLS終端しようとした際、ALPN(Application-Layer Protocol Negotiation)に未対応である点が問題となりました。ALPNはTLSの拡張で、同じTCPまたはUDPポートで複数のアプリケーションプロトコルがサポートされている場合にTLSのコネクション内で使用されるプロトコルをネゴシエートするものです。HTTP/2でTLSを利用する場合はこのALPNが前提となっており、gRPCクライアントとNLB間で通信に失敗するという事象が発生しました。 この問題に対して、NLBのリスナーをTCPモードに設定し、NLB配下に置かれているEnvoyにTLS終端の役割を担わせることで対応しました。この方法を取る場合は、ACMが利用できないためSSL/TLS証明書を自前で購入、管理する必要があるという注意点があります。 まとめると以下のようになります。 本章の見出し、文中にNLBがALPNに対応していないと書いていますが、本記事の執筆中に対応したようです。ただし、現時点ではHTTP/2通信のTLS終端はできないようです。着実に対応が進んでいることは感じ取れるので、NLBでHTTP/2通信のTLS終端に対応する日を心待ちにしたいと思います。 Network Load Balancer now supports TLS ALPN Policies 3. 特定APIエンドポイントへのアクセス元IPアドレス制限について 次にアクセス元のIP制限についてみていきます。システム構成を見て頂くと分かる通り、ZOZOMATシステムではネイティブアプリケーションからの通信以外に、ZOZOTOWNサーバーとのAPI連携を行っています。ZOZOTOWNサーバーから実行されるAPIについてはアクセス元のIPアドレスを特定できるため、制限をかける必要があります。 よくある構成の例として、ALBを利用する場合はALBのセキュリティグループにアクセス元のIPアドレスのみ許可する設定を行うことで制限をかけることが可能です。しかし、今回はNLBを利用するためセキュリティグループを設定できません。今回の構成において、アクセス元のIP制限がかけられるネットワーク内のノードと、APIエンドポイント単位の制限の可否についてまとめると下記のようになります。 table { border-collapse: collapse; width: auto; } th { border: solid 1px #666666; color: #000000; background-color: #FFFFFF; } td { border: solid 1px #666666; color: #000000; background-color: #FFFFFF; } 構成ノード APIエンドポイント単位の制限可否 特徴 AWS Network ACL × ステートレスなため、戻りのトラフィックも考慮する必要がある。 通常、AWSを利用する上ではあまり意識しないNTP等もルールを追加する必要がある。 ワーカーノード セキュリティグループ × - Kubernetes NetworkPolicy × - Envoy HTTP filters ○ Lua拡張を利用することでAPIエンドポイント毎のIP制限を設定することが可能。 上記からAPIエンドポイント毎にIP制限をかけることができるEnvoyのHTTP Filtersの機構を利用しました。これを実現するには、EKSがクライアントのIPアドレスを取得できるようにする必要があります。まず、EnvoyのServiceリソースを記載しているマニフェストに externalTrafficPolicy: Local の設定をします。これを設定することでクライアントのIPアドレスをx-forwarded-forヘッダから取得することが可能になります。 マニフェストの例を以下に示します。 apiVersion : v1 kind : Service metadata : name : envoy annotations : service.beta.kubernetes.io/aws-load-balancer-type : "nlb" service.beta.kubernetes.io/aws-load-balancer-internal : "false" spec : type : LoadBalancer selector : app : envoy ports : - name : https protocol : TCP port : 443 targetPort : 443 externalTrafficPolicy : Local なお、この設定値はデフォルトだと cluster が設定されています。この設定の場合、ワーカーノードにリクエストが到達した後に別のワーカーノードにもリクエストを転送することが可能になり、podの負荷を均等に保つことができます。しかしこれを実現するために各ワーカーノード上で稼働するkube-proxyが送信元、送信先IPアドレスを書き換える必要があり、その結果クライアントのIPアドレスを取得できなくなります。 続いてEnvoyの設定をみていきます。以下のものがEnvoyのConfigMapになります。 apiVersion : v1 kind : ConfigMap metadata : name : envoy-conf data : envoy.yaml : | static_resources : listeners : - address : socket_address : address : 0.0.0.0 port_value : 443 filter_chains : - filters : - name : envoy.http_connection_manager config : access_log : - name : envoy.file_access_log config : path : "/dev/stdout" codec_type : AUTO stat_prefix : ingress_http use_remote_address : true route_config : name : local_route virtual_hosts : - name : http domains : - "*" routes : - name : api match : prefix : "/" route : cluster : api timeout : 60s retry_policy : retry_on : "connect-failure" num_retries : 3 http_filters : - name : envoy.lua typed_config : "@type" : type.googleapis.com/envoy.config.filter.http.lua.v2.Lua inline_code : | function envoy_on_request(request_handle) local request_path = request_handle:headers():get(":path") if string.match(request_path, "/hogehoge/%w" ) then local ip_whitelist = os.getenv('IP_WHITELIST') local source_ip = string.gsub(request_handle:headers():get("x-forwarded-for"), "%." , "%%." ) if not(string.match(ip_whitelist, source_ip)) then request_handle:respond({[":status"] = "404" }) end end end - name : envoy.router config : {} tls_context : common_tls_context : alpn_protocols : - "h2,http/1.1" tls_certificates : - certificate_chain : filename : "/tmp/server.pem" private_key : filename : "/tmp/server.key" clusters : - name : api connect_timeout : 5s type : STRICT_DNS dns_lookup_family : V4_ONLY lb_policy : ROUND_ROBIN drain_connections_on_host_removal : true http2_protocol_options : {} load_assignment : cluster_name : api endpoints : - lb_endpoints : - endpoint : address : socket_address : address : api-service.default.svc.cluster.local port_value : 8080 health_checks : timeout : 2s interval : 3s unhealthy_threshold : 2 healthy_threshold : 2 grpc_health_check : {} admin : access_log_path : "/dev/stdout" address : socket_address : address : 127.0.0.1 port_value : 8090 Envoy側でもクライアントのIPアドレスを扱うために use_remote_address: true を設定する必要があります。今回は特定エンドポイントに対してIP制限をかける必要があるのですが、Envoyに備わっている機能だけでは実現できないため、Luaを使って機能拡張する必要があります。以下がLua拡張の抜粋部分になります。 http_filters : - name : envoy.lua typed_config : "@type" : type.googleapis.com/envoy.config.filter.http.lua.v2.Lua inline_code : | function envoy_on_request(request_handle) local request_path = request_handle:headers():get(":path") if string.match(request_path, "/hogehoge/%w" ) then local ip_whitelist = os.getenv('IP_WHITELIST') local source_ip = string.gsub(request_handle:headers():get("x-forwarded-for"), "%." , "%%." ) if not(string.match(ip_whitelist, source_ip)) then request_handle:respond({[":status"] = "404" }) end end end ファンクションに envoy_on_request を指定することでリクエストを受け付けた際に任意の処理を実行できます。レスポンス時に処理させたい場合は envoy_on_response を指定します。処理の内容としてはヘッダからリクエストパスを取得し、任意のパスであればIPアドレスの検査を行います。IPアドレスがホワイトリストに含まれていなければEnvoyが404を返し、バックエンドへの転送は行われなくなります。上記の設定を行うことでエンドポイントごとのアクセス元のIPアドレス制限を設定できます。 まとめ 今回はZOZOMATシステムの構成と開発時に苦労した点を紹介しました。これからEKSの導入、またはAWS上でgRPC通信を考えている方に何か1つでも参考になる点があれば幸いです。リリースはできたもののまだまだ改善点はあるので、1つずつ改善してより良いサービスに成長させていきたいです。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 https://tech.zozo.com/recruit/ tech.zozo.com
アバター
はじめに こんにちは、計測プラットフォーム部バックエンドチーム、テックリードの児島( @cozima0210 )です。この記事では、ZOZOSUITとZOZOMATの違いにより生じたバックエンド開発における課題と、その解決のためにCQRSアーキテクチャを採用した経緯、そしてその実践について紹介します。 ZOZOSUITとは ZOZOSUIT は、2017年に発表した全身の計測を目的としたツールです。現在も計測機能は提供されていますが、新規の販売は終了しています。現在、ZOZOSUITの計測データは、マルチサイズ商品の開発に活かされています。 ZOZOMATとは ZOZOMAT は、2019年に発表した足の計測を目的としたツールです。足の計測データから、足型診断や推奨サイズの提案に活用されています。今年の2月にリリースし、ZOZOSUITに続く計測技術として、とても注目をいただきました。 計測プラットフォーム部とは 計測プラットフォーム部は、これら計測技術の活用を通して、「計測データと推奨サイズの精度向上により、お客様にオンラインでも最高の購買体験を提供する」ことをミッションとしています。 バックエンドチームのミッション 計測プラットフォーム部の中で、私の所属するバックエンドチームは計測プラットフォーム基盤を支えるために、高速で安定したシステムづくりを目指しています。 バックエンドチームの技術戦略 Scala 私たちバックエンドチームは、プログラミング言語に、Scalaを採用しています。Scalaは強力な型システムに支えられた堅牢なシステム作りを容易にしてくれます。また、DDDとも相性が良く、ビジネスの変化に柔軟に対応するためのツールとして大きく貢献してくれます。 DDD DDDとは「Domain Driven Design」の頭字語で、Eric Evans氏が2003年に考案した設計手法です。日本国内でも「ドメイン駆動設計」として知られており、多くの開発チームに採用されています。この定義については様々な説明がありますが、「現実世界の問題に立ち向かうため、ソフトウェアによって対象の抽象化を図り、その解決を容易にすることを目指す」ための方法論であると理解しています。私たちバックエンドチームでも、このモチベーションからDDDを設計手法に採用しています。 ZOZOSUITからZOZOMATへ ZOZOMATのシステムアーキテクチャを検討し始めた時期は、2019年の4月頃でした。開発の初期段階で抱いた印象としては、計測フローに若干の違いはあるものの、ZOZOSUITのシステムからそれほど大きな変更は必要ないと感じました。しかし、設計を進めていく中で、その印象は次第に覆されました。まず始めに、ZOZOSUITとZOZOMATの比較を通して、システムを設計する上で考慮する必要のあった違いについて解説します。 計測フロー ZOZOSUITでは、ユーザーがカメラを固定した状態で、その前で時計回りに一周します。その間に、12回の撮影が自動で行われ、計測が完了します。一方、ZOZOMATではユーザー自身が左右の足それぞれ、6枚の画像を撮影します。 計測結果の表示画面 それぞれの計測フローを終えた後、ユーザーに計測結果の画面が表示されます。どちらにおいても、3Dの立体図が表示され、各計測値が表示されます。 ZOZOSUITによる計測結果の表示画面 ZOZOMATによる計測結果の表示画面 計測のデータモデル 詳細を割愛していますが、下図はZOZOSUITの計測データモデルです。 ZOZOSUITの計測データモデル 一方、ZOZOMATでは、計測値と3Dデータを左右に分けて、それぞれ管理するようになりました。 ZOZOMATの計測データモデル 計測におけるデータフロー ZOZOSUITでも、ZOZOMATでも、計測値と3Dデータはクライアントアプリで生成されます。ZOZOSUITでは、計測後にデータをサーバーにPOSTしますが、データがサーバーに送信されるタイミングは1回のみでした。一方、ZOZOMATでは左右の足それぞれの計測後にデータをサーバーにPOSTする必要がありました。そのため、データがサーバーに送信されるタイミングは必ず2回以上あります。これについては、一度にまとめることも考えられましたが、計測が失敗した場合の考慮が必要でした。計測が失敗した原因の調査には、その計測中のデータが必要でした。そのため、計測が失敗を繰り返した時に、一度に送信するデータ量が、とても大きくなる懸念がありました。これらの理由から、データ生成の都度、サーバーにデータを送信することとなりました。 計測データの状態管理 ZOZOSUITでは「計測完了」のみが状態として存在し、とてもシンプルでした。 ZOZOSUITの状態遷移 一方、ZOZOMATでは、以下の4つの状態管理が必要になりました。 ZOZOMATの状態遷移 ユビキタス言語 DDDでは、設計において対象を的確に表す命名が、特に重要であると考えられますが、その命名された用語のことをユビキタス言語と呼びます。ZOZOSUITでは、計測そのものを指す用語として、Measurementが採用されました。しかし、ZOZOMATでは全体の計測と左右の足それぞれの計測を分けて命名する必要がありました。そこで、全体の計測をSession、左右の足それぞれの計測をScanと命名することが採用されました。 その他の表示画面 ZOZOSUITでは、計測データを元にする画面として、計測結果の表示画面と推奨サイズの表示画面がありました。一方、ZOZOMATでもその2つの画面がありましたが、それに加え足型診断の表示画面が必要になりました。 推奨サイズの表示画面 足型診断の表示画面 ZOZOMATでの課題 これらの違いによって発生した課題を解説していきます。 計測データを単一のデータとして表現できなくなった ZOZOSUITの計測データは、メタデータに対し計測値と3Dデータを、一対一のデータとして表現できました。しかし、ZOZOMATでは1つのメタデータに対し左右それぞれの計測値と3Dデータで構成されるため、一対多の構造をとる必要がありました。これにより、参照系の処理時に結合が必要となり、多くのことを考慮する必要がありました。 データベースでの結合 データベースで結合を検討する場合、DynamoDBのようなNoSQLでは、そもそも結合のためのAPIは存在しません。また結合がサポートされているRDBMSであっても、データサイズが大きくなるに連れて、3つのテーブルを結合することはパフォーマンスを悪化させます。 アプリケーションでの結合 アプリケーションで結合を検討する場合、3つのクエリの発行が必要になります。これは単一のデータソースから単一の行を取る場合と比較して、参照系の処理を複雑にし、パフォーマンスも悪化させます。 データの状態管理が複雑になった ZOZOSUITでは、バックエンドが扱う状態として、「計測完了」があるのみでした。一方、ZOZOMATでは計測の開始時に左右の計測データの親データを生成し、「計測開始済」の状態に移ります。その後、子データとなる左右の計測データが生成され、それぞれの「計測完了」に状態が遷移します。そして最後に、計測データの検証が行われ、「全体の計測完了」に状態が移ります。このように管理する状態が増えたため、更新系の処理も複雑度を増しました。 ドメインの関心ごとが増えた ZOZOSUITでの主な関心ごとは、計測結果の表示と推奨サイズの表示でした。計測結果の表示は、計測値の入出力のみで対応可能でした。そして、推奨サイズの表示については、機械学習の推論サーバーへのAPIリクエストが必要でした。これはZOZOMATにおいても共通であり、この記事では詳細について取り上げません。一方、ZOZOMATではこれに加え、足型診断というコンテキスト、つまり新たな関心ごとが追加されました。これにより、さらにドメインモデルを柔軟に保つための検討が必要でした。 CQRSによる解決アプローチ CQRSとは CQRSは、「Command and Query Responsibility Segregation」の頭字語で、Greg Young氏が2010年に考案したデザインパターンです。源流には、Bertrand Meyer氏が1997年に提唱したCQS、「Command-Query Separation」という原則があります。これらは端的に説明すると、「更新系(Command)と参照系(Query)の分離」を勧める考え方です。 システム構成図 ZOZOSUITとZOZOMATの実際のシステム構成図です。 ZOZOSUITのシステム構成図 ZOZOMATのシステム構成図 参照系をシンプルに CQRSを採用することにより、参照系のための最適化ができました。具体的には、先述の状態管理にある「全体の計測完了」に遷移した計測データに対し、メタデータと計測が成功した左右の計測データを結合したリードモデルを構築します。これを実現するため、DynamoDB StreamsをトリガーにLambdaを起動させます。そして、その処理の中で「全体の計測完了」に状態遷移した計測データに対し、計測結果の表示画面用に最適化されたリードモデルをAuroraに永続化します。これにより、計測結果の画面表示に必要な処理から結合をなくし、1回のデータアクセスで処理を完結させることができました。 最適化されたリードモデル イベントソーシングパターン ZOZOSUITでは、単一の状態のみを扱うため、一度生成されたデータを、ユーザーアクションによって更新することはありませんでした。一方、ZOZOMATでは先述の通り、4つの状態管理が必要でした。この事情から、更新系のデータモデルにイベントソーシングパターンを採用しました。 イベントソーシングパターンとは イベントソーシングパターンは、一般的にCQRSパターンとともに採用されます。ドメインのイベントをジャーナルとしてスタックし、そのジャーナルの再生により現在の状態を取得するデザインパターンです。 状態管理に対する課題の解決 ZOZOSUITでは、リリース後に計測値の補正を行う処理が必要になったことがありました。この時、私たちは履歴を管理する独自の仕組みを実装しましたが、それはとても煩雑なものでした。一方、ZOZOMATでは計測フロー中の状態管理もありましたが、もし同様のデータ補正処理が必要になったとしても、履歴管理の保守性と追跡可能性が担保されるようになりました。代表的な履歴を遡るユースケースとしては、カスタマーサービスの問い合わせ対応やデータ分析による行動解析がありますが、備えとして欠かすことができないものであることは必至でした。 ドメインの関心ごとを分離 ZOZOMATで追加された足型診断の表示画面用データは、更新系のシナリオの中では、扱われる必要のないものでした。それは、計測値のデータから計算を要する導出項目であったためです。そのため、更新系の処理の中で、この計算結果を永続化することは、以下の懸念を発生させていました。 集中すべきコンテキスト(計測結果の保存)の処理に、別のコンテキスト(足型診断の計算)の処理が入り込み、アプリケーションコードが複雑になる 計測結果の保存のみが行われるべき処理に、足型診断の計算も加わることによって、レイテンシにインパクトを与える可能性がある 一方で、参照系の処理中に都度計算をさせることも、以下の懸念を発生させました。 参照系の処理をシンプルに保つことを難しくする 計算コストによるレイテンシ低下を招く これらを解決するために、Lambdaの処理するリードモデルに、以下のような足型診断の計算結果を含めることにしました。これによって、すべての懸念は解決されました。 より最適化されたリードモデル さらに、将来において、足型診断に新しい項目が追加されることを想像してみます。例として、ZOZOMATにより、外板母子の傾向分析が可能になるとします。こうした拡張を検討する場合でも、本来参照系の処理でしか必要のないデータにより、更新系の処理に改修を発生させることは望ましくありません。もちろん、DynamoDBのようなNoSQLを採用していれば、RDBMSのテーブルにカラムを追加するような懸念もないかもしれません。しかし、このような関心ごとの分離によってアプリケーションの保守性が向上する効果は、とても大きなものだと実感できました。 CQRSの実践、その後 ドメインモデルの洗練 ZOZOSUITでも、DDDによるアプリケーションコードの関心ごとの分離や、レイヤー化が意識されていました。しかし、ZOZOMATで採用したCQRSによる参照系と更新系の二極化は、ドメインモデルが参照系について意識することがなくなりました。そして、更新系のみに集中することで、より深いドメインモデルに洗練させることを可能にしました。 洗練されたドメインモデル 複雑さ システムの複雑さの観点で言えば、単一のデータストアのみを採用したアーキテクチャと比較した場合、複雑さは増したと思います。しかし、それぞれのデータストアに独立した耐障害性を獲得できたことを鑑みても、十分なトレードオフができたと思います。 メッセージングの管理 メッセージングについては、私たちが採用したDynamoDB Streamsとその周辺に用意されているエコシステムによって、多くのことが解決されます。DynamoDBへのデータ更新をトリガーにLambdaを起動し、参照系のためのリードモデルを永続化する。この一連の中で、キューの管理、エラーの管理、リトライの管理、こうした仕組みのすべてがマネージドなものとして提供されます。これらによって、私たちはメッセージングに関する多くの懸念から解放され、アプリケーションの開発に集中できています。 結果整合性の問題 一貫性に付随する課題に対し、私たちの今回のユースケースはとてもシンプルでした。それは、計測が完了した後で、参照系のデータに一切の変更が発生しないことに起因します。これは、参照系データが読まれるタイミングでの遅延さえ許容されれば、一貫性の問題は発生しないことを意味します。この観点において、結果整合性の問題によって、私たちを悩ますものはありませんでした。 将来の課題解決 参照系と更新系のAPI Count これは、参照系と更新系のエンドポイントから、それぞれの呼び出し回数の推移を表したグラフです。上の実線が参照系で、下の実線が更新系です。多くのWebサービスに共通した性質かもしれませんが、ピークタイムを比較した時に、10倍以上の差がつく状態でした。 将来的なZOZOMATのシステム構成図 現在、参照系と更新系のサーバーは同居させた構成をとっていますが、将来的にこの図のように独立した構成をとることにより、以下の課題解決をもたらしてくれると思っています。 スケールの分離 参照系と更新系が独立することにより、それぞれのアクセスパターンに応じたスケールの戦略が選択可能となります。これにより、より細やかな弾力性が獲得できます。 サイジングの分離 参照系と更新系が独立することにより、それぞれのワークロードに適したサイジングが可能になります。参照系と更新系で必要なシステムリソースが異なる場合、より適切なリソース配置が可能になります。 耐障害性の分離 参照系と更新系が独立することにより、それぞれが疎になる箇所を拡げられます。先述のような参照系と更新系のデータストアに依存する耐障害性のみならず、より独立した耐障害性を獲得できます。 さいごに 今回は、ZOZOMATのバックエンドで採用したCQRSアーキテクチャについて紹介しました。初めてCQRSに取り組む機会として、規模的に小さなマイクロサービスから始められたことは、とてもいい機会だったと思います。CQRSは、提唱から今年でちょうど10年目の節目を迎え、特別に新しいものではありません。一方、その採用のために、これまでは様々な技術的障壁があったように思います。しかし、現在のAWSをはじめとするマネージドサービスにより、CQRSを実践するための周辺環境は十分に整っているように思います。 計測プラットフォーム部は、社内のPoC要素の高い新規事業を扱うことが多い部署という事情があります。一般的に、アーキテクチャおよび技術の選定は、様々な事情(安定性、コスト、構築までのスピード等)を考慮する必要があると思います。しかし、私たちバックエンドチームは様々な技術の先行事例を社内のナレッジとして残すことを使命に、多くの技術的挑戦に取り組んでいます。 計測プラットフォーム部バックエンドチームでは、ZOZOMATでより精度の高いサイズを推奨するバックエンドエンジニアを募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! www.wantedly.com
アバター
※AMP表示の場合、数式が正しく表示されません。数式を確認する場合は 通常表示版 をご覧ください ZOZO Researchの斎藤です。私たちはファッションコーディネートの推薦や生成の基礎として、深層集合マッチングという技術を研究しています。本記事では、深層集合マッチングを理解する上で必要な諸概念の説明と、ファッションデータを使った実験結果について紹介します。対象読者としては、機械学習系のエンジニアや学生を想定しています。 集合マッチングとは ある集合が与えられたとき、その集合にもっともマッチする集合を解の候補から選ぶという問題を考えます。 例えば コーディネートを画像集合として捉えると 、あるコーディネートの一部分(部分コーデと呼びます)に対して合う部分コーデを選択するという問題設定を考えることができます。 図: ある部分コーデ(左)にマッチする部分コーデを候補(右)の中から1つ選ぶ このような問題を集合マッチングといいます。もちろん部分コーデではなく、後述するように複数のコーディネートを含めた集合を扱うことも可能です。 その他の応用例としても、ファッションとはまったく異なる分野で使えることが分かっており、例えば監視カメラ向けのタスクであるGroup Re-identificationと呼ばれる集団人物のマッチングにも適用できます(本記事では割愛します)。集合マッチングは将来性・拡張性の高いタスクといえそうです。 ここで、2つの部分コーデが合う(マッチする)かどうかを判定するには、部分コーデを構成するアイテム同士の調和性を調べることが重要になります。 しかし、どういったアイテム同士がどのようにマッチして結果的にコーディネートとして調和しているのかは、人間にとっても未知な場合が多いため、判別モデルや特徴量を人手で設計することは困難です。 そこで、強力な特徴学習(深層学習)の仕組みが必要になると考えられます。 また、集合は要素を入れ替えても不変なデータ構造であり、データの並べ方を考えなくてよいという性質を持っています。例えばコーディネートも同様に、アイテム同士を入れ替えても同じコーディネートです。 言い換えれば集合マッチングのタスクでは、このような集合データを取り扱うために、集合の性質を保つことができるモデルを構築しなければならないという難しさがあります。 コーディネートはもちろんのこと、集合を深層学習によってマッチさせる研究はこれまでにあまりなく、本記事ではこの技術を深層集合マッチングと呼びたいと思います。 部分コーデの教師データ作成 2つの部分コーデが合うかどうかを調べるモデルのパラメータを教師あり学習によって獲得することを考えます。どの部分コーデ同士がマッチするかをおおまかに人手でタグ付けすることは可能ですが、労力の問題から網羅的な教師データを用意できません。 そこで、 集合の再構成問題 を考えます。集合(部分コーデではなく完全なコーディネート)が与えられたとして、これを適当に2分割して共通部分のない部分集合(部分コーデ)を2つ作るとします。 図: コーディネートから対応する部分コーデを2つ作成 このときモデルを通して、同じ集合から作られた2つの部分集合(部分コーデ)を正しく選べるか? を解くことにします。 もともと1つのコーディネートであったなら、そこから得られる部分コーデ同士は合うはずですから、そのペアを正例として扱います。 また、他のコーディネートを構成していた部分コーデ同士や、ランダムに選んだアイテムの集合は合わないと仮定して、そのような部分コーデの組み合わせは負例と考えることができます。 このようにして、アイテム同士がどのように合う/合わないかの情報は与えずに、部分コーデが“合う”という教師データを作ります。そして、アイテム同士が“合う”ときちんと認識することを通して、正しい部分コーデを選択できるモデルを特徴学習によって獲得することを狙います。 弊社では、IQONという日本の女性向けコーディネート投稿サービスを展開しておりましたので、こちらのデータを研究に用いました。 深層集合マッチング 深層集合マッチングは、特徴抽出レイヤーとマッチングレイヤーで構成されます。特徴抽出レイヤーをCross-Set Feature Transformation (CSeFT)、マッチングレイヤーをCross-Similarity (CS) 関数と呼びます。 例えば集合のペア が入力されたとき、マッチする度合いをCS(CSeFT( ))によって計算します。 特徴抽出してからマッチングスコアを計算するという順番でモデルが構成されます。 ここで、集合マッチングに必要なモデルの条件は、前述したように 集合内の要素を入れ替えても不変 2つの集合を入れ替えても不変 であることです。不変というのは、モデルの最終出力が変わらないということを意味します。後述するように、CSeFTとCS関数の合成関数はこの性質を満たすことが分かっています。 さらに、今回の集合マッチングでは異なる種類のアイテムを含む集合をマッチさせる必要があるため、元々まったく違う特徴ベクトル同士をマッチさせようとすることになりますから、簡単ではありません。マッチする集合同士ではよりマッチする特徴量を抽出し、そうでない場合はマッチしないと判定するに足る特徴量を抽出する必要があります。そのためには、何がマッチするかを集合間での相互作用(インタラクション)を通して抽出する枠組みが必要です。提案手法では、特徴抽出やマッチングの過程に集合間のインタラクションを導入して、表現力の高い特徴量を抽出できるようにしています。 特徴抽出 集合データといえども、その実体はコンピュータのハードウェア上では順番をもって格納されています。このデータの列を、集合の性質を保つように扱ったうえでインタラクションを考慮した特徴抽出をする必要があります。 特徴抽出器であるCross-Set Feature Transformation (CSeFT) レイヤーは、以下の式で構成されます。 ここで、 は 番目のCSeFTレイヤーの処理によって得られた特徴ベクトルの列として表現されます(集合 の要素の特徴ベクトルを適当に並べたもの)。 は各画像に対して独立に適用した畳み込みニューラルネットワークから得られる特徴ベクトルの集合を指します。CSeFTは関数 によって構成されており、 は学習パラメータで2つの の間でweight-sharingされています。なお、関数 は入力の第一引数の集合の要素の順番を保ったまま特徴抽出を行い、第二引数の集合の要素の順番には影響されないとします。これは第一引数に関しては置換同変、第二引数に関しては置換不変な性質を持っているといいます。この性質があれば、CSeFTは2つの集合内の要素に対して置換同変な関数となり、後述するように集合マッチングの条件を満たすことになります。 私たちは関数 として以下の類似特徴ベースの変換を提案しています。ここでは例として、 番目のCSeFTレイヤーから抽出された集合 の 個目の要素の特徴量 を に変換しています。 ここで、 、 は線形関数で 、 はReLU、 です。 類似特徴ベース変換によって、似ている特徴ベクトル同士は似るように、似ていない特徴ベクトルはそのままになるように特徴写像を行います。 さらに、 multihead と呼ばれる構造を用いて、複数パターンの関数 からの出力を用いることで、提案手法では精度を向上させています。 マッチング 前項で抽出した2つの特徴ベクトルの集合がマッチするかどうかを調べるために、本研究では以下のCross-Similarity (CS) 関数を用います。 ここでは、線形写像された2つの集合の各要素の間で非負の類似度の平均値を計算しています。さらにmultihead構造のように、CS関数の出力値を複数パターン計算してconcatenationしたのち、全結合層によって実数に変換して最終的なスコアとします。 なお、CSeFTは置換同変な関数、CS関数は置換不変な関数なので、それらの合成関数は集合内の要素について置換不変な性質を持ちます。また、集合を入れ替えても出力は不変です。これにより、集合マッチングの条件を満たすことが分かっています。 -Pair-Set損失 提案手法では集合のペアに対してスコアを計算しますが、個別のペアに対して別々に負例を用意すると、大きな計算コスト(前処理)がかかります。そこで計算量が大きくなる問題を解くために、 -Pair-Set損失を提案します。 -Pair-Set損失では、正例となる 個の集合ペア を用意して、それぞれの正例ペアに対してその他のペアから 個の負例を作成することを考えます。つまり、ある集合 に対してマッチする可能性のある 個の候補 を用意します。このとき真の解は で、他の候補は負例として考えます。この方法を用いることで、例えばTriplet損失よりも効率的に学習が可能になることが分かっています。 図: -Pair-Set損失を計算するペアの例 実験 ここでは例として冒頭に述べたようにコーディネートのマッチングを行います。 可視化 まず、提案手法で学習した結果をテストデータの集合を用いて可視化してみます。関数 は入力の集合ペアの各要素の間でインタラクションの重みを計算していることを利用します。具体的には、関数 に関する式の の部分は、集合 の 番目の要素に対する の要素 からの重みといえそうです。また同様に、集合 の要素への の要素からの重みも確認できます。この重みの値を正規化して可視化したものが下記の図です。 図: 正しい集合ペアに対するマッチング結果(インタラクション)の例 図: 誤った集合ペアに対するマッチング結果(インタラクション)の例 正しい集合ペアに関してはインタラクションの重みが多数得られており、誤った集合ペアに関してはスパースな結果になっていることが分かります。ここで、重みの値が0のときはインタラクションを示す矢印を表示していません。このようにインタラクションがスパースになると、最終的なマッチング度合いを示すスコアが低く現れる傾向になり、逆に強くインタラクションが複数得られるとスコアが高くなりうると考えられます。 定量評価 以降ではIQONのデータセットを用いて定量評価した結果を示します。提案手法を評価するタスクは (1) 部分コーデマッチングと (2) 複合コーデマッチングの2つです。 比較手法 比較手法としてSet TransformerとBERTを導入し、集合マッチング向けに拡張して用いることにします。 Set Transformer は近年提案されたstate-of-the-artの集合関数で、 BERT は言語タスクのstate-of-the-artです。Set Transformerの拡張では、集合ごとにSet Transformerによって特徴ベクトルを1つ抽出し、それらの内積を2つの集合のマッチ度合いとして定義します。BERTの拡張では、BERTの入力に2つの集合の和集合を用います。比較のためにBERTのpre-trainingは用いず、また個別のtoken embeddingは用いずに、segment embeddingのみ導入して内部的に2つの集合を区別します。Set Transformerの拡張では集合マッチングに必要な置換不変性は満たしますが、特徴抽出の過程で集合間のインタラクションは提供されません。BERTの拡張では集合間のインタラクションは提供されますが、置換不変ではないという性質があります。 表: 比較手法の特徴 手法 置換不変性 インタラクション Set Transformer   BERT   Cross-Affinity (ours)     (1) 部分コーデマッチング 冒頭で述べたように、1つのコーディネートを分割して正しい部分コーデのペアを作成します。このとき、正しくない部分コーデの候補をランダムに3つ用意したとき、提案手法は正しい部分コーデを選べるか? を調べました。なお、負例のアイテムは正例のアイテムと同じカテゴリになるように制約を加えました(例えばトップスとボトムス)。この制約を実装するために、本実験ではTriplet損失を用いました。 図: 部分コーデマッチングの定量評価結果 Set Transformer、BERT、提案手法の精度はそれぞれ39.2、50.5、60.2%でした。この結果から、提案手法が大きく勝っていることが分かります。 (2) 複合コーデマッチング 複合コーデマッチングでは、より複雑なデータに対応できるか調べるために、ランダムに選んだ4つの部分コーデの和集合(複合コーデ)をマッチングします。ランダムに選ぶので、複合コーデは様々なファッションスタイルの乱雑な混合を表現しています。こちらでは -Pair-Set損失を用いました。 図: ランダムに選んだ複数のコーディネートから複合コーデを2つ作成 このような集合でもうまくマッチングできれば、どのように複合的な趣向を備えているユーザに対しても対応できるモデルが原理的には学習できると考えられます。実験結果は以下の通りです。 図: 複合コーデマッチングの定量評価結果 Set Transformer、BERT、提案手法の精度はそれぞれ65.3、66.1、75.9%でした。提案手法が大幅に勝っていることが分かります。部分コーデを使用した際よりも全体的に精度が高くなっているのは興味深く、色々な考察が可能です。例えば集合に含まれるアイテムの数がある程度多くなれば、より識別に効くアイテムを含む確率が高まるため、精度向上を期待できます。しかしながら、ノイズのようなアイテムも同時に増えるはずであり、一概にはいえません。今後より深い検討が必要と考えられます。 結論 深層集合マッチングを提案し、ファッションデータで実験を行いました。近接するタスクでのstate-of-the-artの手法と比較し、提案手法は大幅に精度が高いことが確認できました。また可視化を通して、コーディネートの部分同士が“合う”とは何なのかを、結果的にモデルが学習しているという示唆を得ました。今後、私たちはこのモデルを継続的に発展させていく予定です。 謝辞 本記事に含まれる研究は、和歌山大学の八谷大岳先生、筆者の博士課程での指導教員である統計数理研究所の福水健次先生、弊所の中村拓磨氏の協力のもと行われました。 さいごに 本記事の内容はGroup Re-identificationへの応用も含めてMIRU2020で発表する予定です。ご興味のある方は口頭発表(ショート)をチェックしていただき、ぜひディスカッションしましょう! また、ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに こんにちは。SRE部BtoBチームの田村です。BtoBチームにてECサイトの購入テストや会員登録等のテストを行う際には、これまでSeleniumを利用して毎日LinuxのChrome環境にて実行しておりました。しかしながらフロントエンドが変更された場合に、ソースコードの調整をしたりサーバー保守対応も必要で、運用コストを割かれることもしばしばありました。テストにおける自動化やテスト品質の向上及び運用コストの削減を目的として、今回AutifyというE2E自動テストツールを導入しました。 BtoBチームのE2Eテスト BtoBチームのE2Eテストは、Seleniumを用いて会員登録や購入テストを毎日実行しており、Slackにテスト結果を通知しています。エラー時には、サーバーに入ってログ閲覧し問題ないかを確認していました。そして、新しいテストパターン追加の要望があった場合にはソースコードを追加する必要があり、1パターンあたりに数時間の工数が発生して大きな運用コストが割かれていました。 このように運用コストが割かれておりましたが、Autifyを調査してみるとダッシュボード上でエラー内容を即座に確認可能とのことでした。さらにはSelenium IDEのようなレコーディングをするだけでテストパターンが作成可能でソースコードの調整が不要となり、運用コスト削減を見込めたということもありAutifyを導入しました。 Autify 特徴 コードを書かずにテストシナリオを作成・修正可能 複数OSでテスト実行可能 特定シナリオの定期実行が可能 クラウドで実行されるため、実行環境として実機を用意する必要はない AIによりUI変化を検知するためソースコード調整が不要 BASIC認証設定も可能 チャットで気軽に問い合わせ可能 複数OSでテスト実行可能という点が素晴らしく、さらにAIによりUI変化を検知してメンテナンス工数を削減してくれるところも良い点です。困ったことがあればAutifyのダッシュボード上からチャットで気軽に問い合わせすることも可能です。 シナリオ作成手順 シナリオ作成開始 開始URLを指定すると新規ブラウザウィンドウが立ち上がり、シナリオ作成が開始されます。試しに、 Seleniumに準備されているテストサイト を用いてシナリオ作成を行ってみます。 レコーディング 自動でレコーデイングされるので、シナリオパターンに沿って操作します。イメージとしてはSelenium IDEのように、レコーディングされる感じです。 検証 検証したいテキスト等があれば、画面右下にあるAutifyツールのチェックボックスアイコンで設定できます。デベロッパーツールのインスペクトモードのように要素を選択可能で、要素によって検証項目が変動しますが、大枠は下記のようなチェックが可能でした。 タイトルチェック URLチェック テキストチェック 要素表示チェック オンオフチェック 存在チェック シナリオ編集画面 微調整したい箇所は編集可能となってます。入力テキストの内容などを変更することが可能です。また、特定の操作までローカルブラウザで自動実行し、新規操作を追加することも可能です。Selenium IDEの場合は、ファイル共有などする必要があったのですが、Autifyではダッシュボード上にて最新シナリオ状態を確認できる点が便利です。 シナリオ実行結果 ステップごとに前回との比較ができ、わかりやすくなってます。前回からの見た目の変更点をすぐに確認可能となっています。 マルチデバイスで実行可能 複数OSでテスト実行が可能となっています。 SameSiteデフォルト変更の影響確認に利用 Chrome 80のSameSiteデフォルト変更の対応を行う際に役立った実例を紹介します。 iOS 12におけるSafariの場合は、 WebKit Bugzilla (Bug 198181) に記載ある通り、SameSite=None指定はStrictと扱われてしまうので、特定条件のみSameSite指定を外す対応が必要でした。 影響を確認するために、AutifyにてiOS 12の端末を指定しテストすることで、修正の確認が即座にできました。通常であれば実機にて手動テストを実行していたところですが、Autify導入していたおかげで自動テストが可能でした。 まとめ 良かった点 複数プラットフォームにて即座にテスト可能 シナリオファイルを共有する必要がなく、ウェブで全て完結している 不明点、改善要望があれば、チャットですぐ問い合わせが可能 気になった点 細かい挙動の調整ができない 例えば、お届け予定日をコンボボックスで選択する場合、実行日によって動的にラベル内容が更新されるようなサイトの場合です。Autifyの場合は、ラベル名で選択しているので、レコーディング時に選択した日付が無くなった場合にエラーとなってしまいます。このような場合は、インデックス選択できれば解決できそうなので、現在は改善要望中です。 度々アップデートが行われており、改善されていくかと思います。 さいごに ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
ZOZOテクノロジーズでVRやARといったXR領域の利活用を推進しているWEAR部の諸星( @ikkou )です。 弊社に限った話ではありませんがCOVID-19の影響により、今までのようなオンサイトでのイベントをなかなか実施し難い状況が続いています。 例えば先日の『#技術書典 頒布本「ZOZO TECH BOOK」解説会』は弊社として初のオンラインイベントとなりました。 techblog.zozo.com いわゆる「勉強会」系のイベントもそうですが、オフィス見学といった社内の雰囲気を知るために重要なイベントも同様に実施できない状況です。 そこで先日、これもまた弊社としては初の試みとなる『バーチャルオフィス見学会』を実施することにしました。 zozotech-inc.connpass.com 『バーチャルオフィス見学会』に先駆けて、clusterに『ZOZOテクノロジーズ バーチャルオフィス』を公開しています。 cluster.mu 私はこのバーチャルオフィス作りの技術・運用まわりで携わったので、本記事ではその背景を紹介します。 手段の選定 プラットフォームの選定 ワールド作り Cluster Creator Kitの導入 clusterに最適化したシェーダーへの変更 VRChat向けシステムの削除 歩き回ることを想定したコライダーの削除と追加 植物の削除 バーチャルならではの「遊び」 エントランスのZOZOMATと箱猫マックス 持ち上げられるZOZO箱 リアルアバターの利用 プラットフォーム選定の補足 VRChatを選択しなかった理由 Mozilla Hubsを選択しなかった理由 まとめ 最後に 手段の選定 バーチャルオフィス見学を実施するにあたり、まずどのような手段で実施するか検討しました。大別すると次の2つになります。 1つ目はフォトリアルな全天球(360度)の写真や動画を使った方法です。 例えば国立科学博物館による「 おうちで体験!かはくVR 」は全天球写真を用いたウォークスルー型のバーチャル展示です。Matterportという特殊なカメラを使用して撮影しています。 www.kahaku.go.jp 2つ目は3D CGを使った方法です。 例えば「 Grani VR Office Tour 」はレーザースキャンを用いて実際のオフィスと同等の空間を3D CGで再現したバーチャルオフィスツアーです。 grani.jp それぞれにPros/Consがあり、個別の詳細は省きますが、今回は後者の3D CGで再現する方法を採りました。 これはオフィスの全天球写真が存在せず、しかし撮影のため外出自粛が求められる期間中にオフィスへの移動を避けたかったこと、そして後述する3D CGのアセットが既に存在していたことが理由です。 プラットフォームの選定 手段が決まった後、『バーチャルオフィス見学会』を実施するプラットフォームを選定しました。 具体的にはVRChat, cluster, Mozilla Hubsの3つの候補に絞りました。その上で、今回はマルチプラットフォーム対応のバーチャルSNSである「cluster」に青山オフィスを模した空間を再現する形を採りました。 cluster.mu clusterは誰もが自由に使える公式のイベント会場の他に、ユーザー独自の会場を作れるワールド機能があります。 つい最近ではグループ会社でもあるヤフーの『オープンコラボレーションスペース「LODGE」』が『バーチャルLODGE』として一足早くclusterのワールドとして公開されました。 note.com 今回はこのワールド機能で「バーチャルオフィス」を作成することにしました。 ワールド作り プラットフォームとしてclusterを選択しましたがclusterが提供するのは好きなイベント会場を作り、そこに集まってイベントを開催することで、会場そのものは自分自身で作る必要があります。 幸いなことに弊社では2019年に実施した社員総会で、VRChatに最適化した「バーチャルオフィス」を作成していました。今回はこのワールドをcluster向けに修正する形で対応しました。 techblog.zozo.com 修正点は次の通りです。空間そのものは出来上がっていたので、大きく手を入れる必要はありませんでした。 Cluster Creator Kitの導入 公式SDKとして提供されているCluster Creator Kitを導入し、もともと存在していたスクリーンと差し替える形で Standard Main Screen View と新たにコメントを表示する Standard Comment Screen View その他、最低限必要なオブジェクトを追加しています。 github.com 難しいポイントはないので、ドキュメント通りに設定すれば問題ありません。 VRChat向けの「バーチャルオフィス」では、エントランスから入って直ぐ目の前にある円会議室の中にのみスクリーンが用意されていました。しかし、数十人が1度に参加する可能性があるオフィス見学という特性を考慮してcluster向けの「バーチャルオフィス」では円会議室を出たところにもスクリーンを設置しています。 あわせて円会議室内のスクリーンは見やすさを考慮して「現実世界」よりも数割大きめに設置しています。 clusterに最適化したシェーダーへの変更 clusterはWindows, Mac, Android, iOSというマルチプラットフォームで動作する性質上、そのすべてのプラットフォームで期待する動作を求めるためにはジオメトリシェーダーが使えません。 VRChat向けの「バーチャルオフィス」では、円会議室を構成するガラス部分をはじめとする複数箇所でNGとなるシェーダーが使われていました。 ガラスの表現には、モバイルプラットフォームでも使えるシェーダーを選択しました。このシェーダーは、UnityのAssetStoreで無料でダウンロードできるのですが、擬似的な屈折表現なども行える優れたものでした。金属やガラスなどに物体が反射する表現には、リフレクションプローブを使用しており、計算負荷を抑えてながら品質の向上を目指しました。 https://techblog.zozo.com/entry/compass2019ss より引用 見栄えはとても良いのですが、様々な環境からオフィス見学を実現できるよう、今回はUnity標準のStandardシェーダーに変更しました。 VRChat向けに設定されていたシェーダー Unity標準のStandardシェーダー Standardシェーダーでも特徴的なガラスの曲面は再現できています。 VRChat向けシステムの削除 VRChat向けの「バーチャルオフィス」は文字通りVRChatで動作させることを意図しているので、VRChat向けの機能がいくつか内包されていました。これらは不要なので削除しました。clusterのワールドは不要なアセットを削除した方がアップロードもプレイ時のダウンロードも早くなります。 歩き回ることを想定したコライダーの削除と追加 VRChat向けの「バーチャルオフィス」は前述の通り「社員総会」で利用することを意識していたこともあってか、オフィス内を歩き回る「オフィス見学」用途としては成り立たない箇所がいくつかありました。そういった箇所は透明な壁となるコライダーを削除あるいは追加しました。 植物の削除 現実世界の青山オフィスには実に100鉢以上の緑が生い茂っています。これらをバーチャル世界でそのまま再現すると、少し歩きにくく感じてしまいます。その対策として青山オフィスの雰囲気を損なわない範囲で植物を削除しました。 オンサイトでのオフィス見学が再開した際には、ぜひ「バーチャルオフィス」との差分をその目で確かめてもらいたいです。 バーチャルならではの「遊び」 せっかくの「バーチャル」なので、現実世界の「青山オフィス」とは異なるちょっとしたアイテムを用意しました。 エントランスのZOZOMATと箱猫マックス 乗っかったところで実際に足のサイズは測れませんが、エントランスの左側足元に「ZOZOMAT」を設置しました。また、同エントランス右側にはZOZOTOWNの公式キャラクターである「箱猫マックス」を設置しました。 左: ZOZOMAT 右: ZOZOTOWN公式キャラクター「箱猫マックス」 持ち上げられるZOZO箱 箱猫マックスの身体を流用してオフィス内の複数箇所に「ZOZO箱」を複数設置しました。ZOZOTOWNを利用したことがある方ならお馴染みのあの黒い段ボール箱です。 Reference: https://note.com/zoooom/n/n06d63160f4bb 単純に置いてあるだけでは面白みに欠けるので、持ち上げたり積み上げられるようにしました。 ZOZO箱を持ち上げている様子 これはCluster Creator Kitで用意されている Grabbable Item コンポーネントを使うだけで簡単に実装できます。あわせて必要な Item , RigidBody , Movable Item 各コンポーネントも一緒に設定されるので、別途必要になるのは Item コンポーネントの名前を変えて、コライダーを追加するだけです。 Grabbable Itemの設定 ちなみに本記事の公開時点ではアイテムのリスポーン(初期位置に戻る仕組み)が実装されていません。そのため、1度動かされた「ZOZO箱」を「掃除」する手間を省くために公開版のワールドには含まれていません。 リアルアバターの利用 clusterの世界での見た目となるアバターは、原則として全ユーザー共通のものが用意され、顔の部分のみアカウントにアイコンとして設定している画像が表示される仕組みになっています。 標準アバターは顔部分にアイコンに設定した画像が表示される それとは別に、VRMという国産のVR向け3Dアバターファイルフォーマットのアバターを用意することで、自分が使いたいアバターを使用できます。 vrm.dev 今回は一部の社員に限りますが、フルボディ3Dスキャンした身体をclusterに最適化したVRMデータとすることで、いわゆる「リアルアバター」としてオフィス見学の旗振り役を務めました。 仕様に沿ったVRM形式のリアルアバターを設定した様子 リアルアバターを制作する手法は色々とありますが、今回は奇しくも1月時点でリアルアバター株式会社さんに撮影してもらっていたデータを活用しています。 www.real-avatar.com clusterでのVRMアバターは32,000ポリゴン制限があるので、UnityのMesh Simplifyで手早くポリゴン数を削減しています。 プラットフォーム選定の補足 補足にはなりますが、冒頭のプラットフォーム選定で挙げたVRChatとMozilla Hubsを選択しなかった理由を記載します。 VRChatを選択しなかった理由 今回は国産のVR SNSであるclusterを選択しましたが、実はVR SNSは全世界で100以上存在しています。その中でも日本国内のVRが好きな方々の中で特に有名なのが「 VRChat 」です。 つい最近では株式会社ウィゴーさんや、株式会社三越伊勢丹ホールディングスさんが企業として参加したバーチャルマーケット4のプラットフォームもこのVRChatです。 www.wwdjapan.com 前述の通り既存の「バーチャルオフィス」はVRChat向けに作られていたので、このVRChatを使えばcluster向けの修正も必要ありませんでした。 しかし、VRChatは一定以上のスペックを持ったWindows PCを必要とすること、そしてプライベートなワールドは運営と事前にフレンド登録が必要なことから選択しませんでした。 弊社がVR領域を中心とした事業を展開しているのであれば、VRChatを選択することもやぶさかではありません。しかし、今回は幅広い職種・職域の方が「来社」できることを考慮し、スマートフォンを含むマルチプラットフォームに対応しているclusterを選択した形になります。 Mozilla Hubsを選択しなかった理由 「MoziLla Hubs」はアプリのインストールを必要とせず、ブラウザだけで体験できる、いわゆるWebXR技術を活用したプラットフォームです。私自身はXR領域の中でも特にWebXRを推しているので、この Mozilla Hubs もリリース当初から強く推しています。 Mozilla Hubsでは「 Spoke 」というウェブアプリケーションを通してclusterやVRChatのように自分独自のバーチャル空間を構築できます。 SpokeにはVRChat向けのアセットであるFBXファイルをそのまま持ち込めないので、UniGLTFでSpokeでも扱えるGLBファイルにエクスポートしたものをインポートする必要がありました。しかし、Spokeに持っていくだけであれば大きな手間はかかりませんでした。 Spokeで円会議室を設置した様子 clusterやVRChatと違い、Mozilla Hubsは専用アプリのインストールを必要とせず、Windows, Mac, Android, iOSのどれでも普段使っているブラウザから気軽にアクセスできます そんな良いこと尽くしのMozilla Hubsですが、求める「バーチャルオフィス」を再現するにはアセットの容量制限(推奨は12MBで上限は128MBのところGLBファイルの容量だけで600MB超え)が厳しく、今回は諦めました。 公開を見合わせたMozilla Hubs版のバーチャル青山オフィス 緑とデスクを取り除いた形で、オフィスそのものと特徴的な円会議室だけであれば再現できたので、何らの形で使える機会を伺っています。 まとめ バーチャルSNSであるclusterをプラットフォームとして、VRChat向けワールドを改変したcluster向けのワールドで、『バーチャルオフィス見学会』を実施するまでの取り組みを紹介しました。 今回はVRChat向けの素材が手元にあったため、アイデア出しから実施までのスパンを短くできました。もしも手元になかった場合はゼロイチで「バーチャルオフィス」を作る必要があったので、高い再現性を求める場合は相応の工数が発生していたはずです。 5月25日を以て全都道府県で緊急事態宣言は解除されましたが、いわゆるアフターコロナ/ウィズコロナの世界ではオンサイトではなくオンライン、バーチャルで物事を実施する機会が増えていくと考えています。そのような世界で、こういった形でバーチャルオフィスを作るという手段もあるということが伝わりますと幸いです。 最後に ZOZOテクノロジーズでは今後も「オンライン配信」や『バーチャルオフィス見学会』など今まで取り組んでいなかったことにも積極的に取り組んでいきます。 一緒にサービスを作り上げてくれる方だけではなく、エンジニアの技術力向上や外部発信に興味のある方も募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com また、再掲になりますが、6月9日の『バーチャルオフィス見学会』も受付中です! zozotech-inc.connpass.com 現場からは以上です!
アバター
こんにちは、基幹システム部メンテナンスチームの矢野です。 今回は僕のチームで行っている毎日勉強会について書いていきたいと思います。 新しいインプットの機会創出 組織内の技術力のベースアップ施策 社内コミュニケーション このようなことを考えている方の参考になればと思います。 経緯 まず毎日勉強会というものが形作られた経緯ですが、チームまたは部署のために下記の3つの事柄の質を向上できないかとぼんやり頭の中で考えていたことがきっかけでした。 自部署のブランディング(必要とされる部署になる、自分たちの見解を持つ) 部署内コミュニケーション向上(朝礼など) 部署のメンバーがエンジニアとして渡り歩いていくための手助け(心理的安全性) まず1つ目は自部署のブランディングです。 ブランディングと書くとちょっとお高い感じに聞こえちゃいますが、要は社内で必要とされるよう部署の価値を上げていきたいということです。「ZOZOの基幹のことは基幹システム部に任せておけば大丈夫」という認識を社内に一層持ってもらいたい、そういった社内の信頼を上げるためにはどのようなことをしていけばいいか、どのような組織に成長させたいかを考えていました。 2つ目がコミュニケーションの向上です。 僕が所属しているメンテナンスチームは、2019年5月から発足した比較的新しいチームです。今まで別々のチームに所属していた4人が集まり、緊急施策で優先度が下がった案件やリファクタリングなどのビジネスにはかかわらないが、必要なシステム改修を行うチームとしてスタートしました。 全く別々のチームで業務を行っていたメンバーが集まったため、発足当初はコミュニケーションがうまく図れず4人で黙々と作業をこなす日々でした。チームリーダーとしては「これはもうちょっと雑談とかできるような雰囲気にしたほうがいいな」と感じ毎朝5分間の雑談タイムを始めました。ランダムでネタを提供してくれるアプリを使いながら毎日やっていたため、ネタには困りませんでした。しかし、3か月もするとマンネリ化してきますし、メンバーの距離も縮まってきたため朝の雑談タイムは一旦ストップしました。 でもこの朝の雑談タイムがきっかけで、チーム全員で何か1つのことをやるという時間は、別の形で続けていきたいなと思うようになりました。 朝礼は自分の中で永遠のテーマですね。マンネリ化しない有意義な朝礼。これが思いついたらまたテックブログ書きますね。 3つ目に部署のメンバーがエンジニアとして渡り歩いていくための手助けです。 ZOZOの基幹システムはすでに安定した環境が出来上がっているので技術的にはその環境内での改修が主になります。ビジネス部門や倉庫の要望を改修し形にしていきますがそうなるとどうしても業務だけでは新しい技術への接点が少なくなります。メンバーには、エンジニアとしての視野を少しずつでも広げていってもらいたい。そのためにはエンジニアとしてのアンテナの張り方や新しい技術に触れる機会をこちら側から提供できれば成長の支援になるのではないか。そんな思いもありました。自分たちは基幹システムを改修する人ではなく、あらゆる問題をシステムで解決する集団という風に意識を一段階アップできれば先に書いた他部署からの信頼にも寄与できるのではないかと思っています。 枠組み・やること では、上記のような考えを形にするにはどうすればいいんだろう?? 3つの軸で考えてみました。 絶対やりたくないこと 期待すること 現状(現実) 絶対やりたくないこと まず1つ目の軸ですが、これらを達成する手段として絶対にやりたくないことを考えてみたところ2つありました。 強制はイヤだ 途中離脱はイヤだ 真っ先に思ったのは強制的に何かをやらせることだけは避けたいということです。自主性を重んじた枠組みを作ることがこの枠組み自体を最大限有意義に活用でき、活用していくことで得られる成長やコミュニケーションにおいて都合よく働くと思ったからです。 中学生の時の実体験として「ギターを弾けるようになりたい」と思ったその時のモチベーション・初期衝動がギターを弾けるようになった一番の理由だと確信しています。 このことからも自主的に何かをやりたいと思った時が一番吸収する時期であることは間違いないです。 またやるからには途中で離脱できるような環境は作りたくないと思いました。ただ「途中離脱できない環境を作る」と考えると「絶対やりたくないこと」で書いた強制力が顔を出してきそうだったので、途中でやめられないようにレールを敷くのではなく、途中でやめてしまう理由を排除していって結果的に途中離脱がなくなるようにするといったイメージで考えを組み立てていくようにしました。 期待すること 2つ目の軸でこの枠組みに期待することは3つありました。 新しい技術に対するハードルを下げておきたい チームをまたいだ交流の活性化につなげたい 書籍購入補助制度を有効活用したい 先に書いたように現状ZOZOの基幹システムの開発は一定の技術を覚えればあとはビジネス部門や倉庫と話を詰めて案件を進めて行くことができます。ですが、今後モダンな環境へとリプレースする時期が必ずやってきます。そうなったとき、いきなりメンバーに使ったことのない技術を使って開発しろとなるとそれこそ効率的な開発などできませんし、その技術を習得するまでに時間もかかってしまい最悪案件を進められない時間が出てくる可能性もあります。経緯のところで記載した自部署のブランディングも保てなくなるかもしれません。なので、まずは新しい技術に対するハードルを下げておけるようなものにしたいと考えました。 次はチームをまたいだ交流を盛んにしたいというものです。弊社では、チームが組織の一番小さい単位になります。このチームという単位で業務を遂行する場面がほとんどなのでチーム内の交流は自ずと図れるのですが、その1つ上の「部」という単位での交流もさらに活性化させられたらいいなと思いました。最終的には部という枠も取っ払って社内の誰もが交流できる場を提供できたら最高ですね。 最後は書籍購入補助制度の有効活用です。弊社には書籍購入補助制度というものがあります。これは購入した書籍をレビューして経費申請すれば全額を精算できる制度です。会社の経費で購入した書籍は絶対に無駄にしたくないので(自腹で買った本であれば自分の好きにすればいいですが、経費はみんなが頑張って稼いだお金ですから無駄にしたくないですね)買ったからには最大限有効活用できる方法を見出してこの制度をさらに有意義に使えないかと考えました。ともあれ書籍を最後まで読むって達成感得られますよね。 現状(現実) 3つ目の軸には現状(現実)と書きましたが、これは実際に行われている勉強会などスキルアップするための機会についての問題点を考えてみました。 社内勉強会などで自分の興味が有るものが開催されるとは限らない(実体験) 講師がいる勉強会はわかったつもりになって終わることも結構ある(実体験) スケジュール調整できずに参加しなくなるとそのまま離脱してしまう(実体験) 興味があってもなかなか勉強に着手できる時間がとれない(実体験) 本を買って一人で勉強しても最後まで続かない(実体験) 全部実体験です。 こう見るとやっぱり自主性って成長にとって最大の栄養素なんだなと思えてきます。自主性をうまく成長へのモチベーションに変えられるような枠組みが作れれば上記のような問題も解決できるのではと思いました。 そして、ここから導き出した1つの答えがこちらです。 「 勉強したい人には勉強する時間を毎日1時間与える 」 これにより前述した問題点や希望することの中で解決できることがいくつかあります。 問題点・期待すること 解決理由 強制はイヤだ 勉強したいと思った人が使える時間なので強制ではなく完全自主性 新しい技術に対するハードルを下げておきたい 新しい技術も含め興味のある分野を習得できる時間として使える 社内勉強会などはあるが、自分の興味が有るものが開催されるとは限らない 興味を持った段階でそれについて学習できる時間が得られる 興味があってもなかなか勉強に着手できる時間が取れなそう 上長と相談の上、業務調整ができれば時間も確保できそう、というか時間を確保するために調整するというメリハリがつけられそう ただ、これだけではこの時間に何をやればいいか迷いそうなのでもう少し決めごとを作りました。 追加で考慮したいこと 途中離脱はイヤだ 書籍購入補助制度を有効活用したい 講師がいる勉強会はわかったつもりになって終わることも結構ある スケジュール調整できずに参加しなくなるとそのまま離脱してしまう 本を買って一人で勉強しても最後まで続かない チームをまたいだ交流の活性化につなげたい この辺りを何とか枠組みに追加できないかと考え、出てきた答えがこちらです。 「1冊の本を興味のあるメンバーを募って最後までやる」 これで追加で考慮したいことがカバーできそうです。 問題点・期待すること 解決理由 途中離脱はイヤだ 一冊の本を最後までやりきるという枠組みを作ることにより途中離脱を無くします。(終わらせる期限をつけないことがポイント) 書籍購入補助制度を有効活用したい 書籍を一冊最後までやりきるということを枠組みとします。 講師がいる勉強会はわかったつもりになって終わることも結構ある 興味がある人が集まって同レベルで勉強していくことで全員に当事者意識が生まれる。 スケジュール調整できずに参加しなくなるとそのまま離脱してしまう あえて期限を設けないので離脱する要因が減ります(後述します) 本を買って一人で勉強しても最後まで続かない 複数人でやることで補いあいながら最後まで進められる(複数人いることで見えない抑止効果もあるかも) チームをまたいだ交流の活性化につなげたい 勉強したい本が見つかった人はこの本の内容に興味がある人を誘います。賛同者をチーム外からも集められるので交流の場が広がります。 できた! 上記をまとめるとこんな枠組みが出来上がりました 勉強したい本が見つかった人は同じ興味を持った人を募り(最大4人)毎日1時間全員でその本を最後まで勉強する ここで補足です この枠組がなぜ「毎日」なのか、なぜ「4人」なのかというところです。(補足だけど重要なポイント) 参加型の勉強会を見てきて運営が難しそうなだと思ったところがあります。 問題点 理由 参加者の続けるモチベーション 回数を追うごとに参加者が減っていく 参加者の当事者意識 参加しているという事実だけでわかった気になってしまっている。(自分はこの手の人間です) 1回の欠席がフェードアウトのきっかけになる 講義を一度欠席するとだんだん内容がわからなくなり途中離脱を助長させる これって大勢を集めてやるからこそ生じる問題点ではないかと考えました。 講義の開催者側としては受講生は大勢いるので一人が休んだくらいでは講義を止められない。受講者側の心理としては大勢だと自分ひとりが休んだくらいでは講義は止まらない、最悪自分はついていけなくなるかもしれないが他の受講者に迷惑をかけることはないので欠席することへのハードルが下がる。一回休むと講義は進むので理解に遅れが生じ途中離脱へ・・・ この辺りは「毎日やる」「最大4人での開催」というルールを定めることでいい方向に持っていけそうでした。 なぜ毎日なのか まず前提としてこの枠組みは有休や急なミーティング等で参加できなくなるのはOKとしています。勉強会のことは気にせず有休をとったり緊急案件の対応してもらいたいです。一人が参加できなくなった場合はその日の会はスキップしますが翌日また全員が集まった日に続きをやればいいのです。毎日といっているのに矛盾していますが毎日参加者が全員集まれる日に開催するというラフな感じです。 スキップOKが前提なので例えば開催日を毎日ではなく週1回にすると次の会が次週になってしまいます。1週開くと進みが遅く途中離脱を助長させそうなので開催は基本毎日です。ここが「毎日」とした理由です。 このような枠組みなので前述しましたが期限は設けていません。誰も離脱することなく最後まで終わらせるため、できる日は毎日書籍が終わるまで全員参加で行うというルールです。 なぜ4人なのか 有休や急用での会のスキップを参加者に切り出しやすい最大人数は4人位と想定しています。講師なしで進めるので個人個人が当事者意識を持てる最大人数も4人と想定しています。みんなで協力しあえる最大人数も4人と想定しています。失敗しても恥ずかしくない最大人数も4人と想定しています。完全に感覚値なのですが皆さんも4人と5人では5人の方が上記のバランスが崩れそうな感じがしませんか? こうして出来上がった毎日勉強会の仕組みを利用して僕たちメンテナンスチームでは現在3冊目の本を絶賛勉強中です。 コロナ禍で全員在宅勤務ですが毎日14時にテレビ会議をつないでDockerの勉強をしています。進め方は扱う書籍によって変わってくるので枠組みの中には入れていません。適宜集まったメンバーで進め方を考えて勉強していけばいいと思います。演習やハンズオンが多くある本であればみんなでやりながら全員が同じようにできるまで進めるでもいいですし読み進めるような書籍であれば章や区切りやすいところまで読み進めまとめでディスカッションという形式でもいいと思います。そこは自由です。 4人のうちだれか一人でも置いてけぼりにならないようにというところにだけ気を付けて理解が早い人はフォローして進めることが大事だと思います。 会社や組織によって毎日1時間の時間を割くということは調整が難しい場面も出てくるかもしれません。上長や周囲と相談し業務調整の上、毎日1時間の勉強時間が取れればなるべく毎日4人がそろった日にみんなの興味のある書籍を進めるという気軽な気持ちで始めてもらえるといいと思います。 まとめると 毎日勉強会を開催すると参加者全員が興味のある書籍を当事者意識をもって最後まで読み切ることができます。 これにより、参加者は勉強したことはもちろんコミュニケーションの向上などが得られます。管理者としてはメンバーの学習機会の創出ができます。 またこの枠組みは一冊の本を最後までやりきることを目的としていてそれを達成できるようにスキップOK、毎日やる、参加者は4人、期限を設けないなど手軽に始められるようにも考慮されているのでぜひ調整がついたら一回やってみてもらいたいです。 今後社内に広がって所々で自然発生的に行われる文化になれば最高だなと思っています。 いかにシームレスに始められそして参加者がドライブしやすいインプット支援の方法を考えたら、毎日勉強会という1つの方法にたどり着いたというお話でした。 あとがき 上記のことが全部ひとりでできちゃう人はもちろんたくさんいると思います。自分はHowToやメソッドを考えるプロではないので実体験などから自分だったらこうやればストレスなく始められるな、こうやれば途中で諦めることなく続けられるなという目線で組み立てていきました。 仕組みを作る上で、はみ出さずにゴールまで導きたいときにはレールを敷くのではなく、なぜはみ出すのかを考えその理由を1つずつ排除していくことで自ずとはみ出さずにゴールまで行きつくという考え方があることに気付けたのもいい経験になりました。 この毎日勉強会の枠組みもなるべくレールを敷かないようにしていますのでフレキシブルな対応に頼っているところは多々あります。ルールとして決めるところは決める、でもそれ以外のところは当事者同士でフレキシブルに対応という形が取れれば管理しなくても自発的に動いていく仕組みができていくのかなと思いました。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは、ZOZOTOWN部でAndroidエンジニア/TechLeadをしている堀江( @Horie1024 )です。本投稿では、ZOZOTOWNのAndroidチームで行っている「Codelab会」についてご紹介します。 Codelab会とは? Googleが公開している Codelabs は、AndroidだけでなくGCP、TensorFlow、Firebase、Flutter、Augmented Reality等の様々なトピックをカバーする、チュートリアル形式でまとめられた教育コンテンツです。 Androidに関するCodelab も多く公開されています。「Codelab会」は、ZOZOTOWN Androidチーム全員でCodelabに取り組む勉強会として2019年の7月からはじめました。 ZOZOTOWN Androidチームの課題 私がZOZOTOWN Androidチームに加わったのは2019年の4月で、ZOZOTOWN AndroidチームではAndroid開発を始めてからまだ日が浅いメンバーが多く「チームのAndroid開発に関する知識の底上げ」が課題でした。これはTechLeadとしてチームに加わった自分の課題でもあります。チームメンバーのAndroid開発に関するスキルアップサポートをどう行っていくか?を考える中で思いついたものの一つがCodelabの活用です。 Codelabでは、実際にAndroidアプリを作成するプロセスを通して手を動かしながら学習を進めることができ、Android開発に関する知識を身につけるのに非常に効果的です。個人としても新しい技術のキャッチアップにCodelabを活用していて、その効果を実感しています。一方でただCodelabをチームメンバーに勧めるだけではスキルアップサポートとして十分では無いと感じていました。 チームでCodelabを進めるCodelab会 「Codelab会」はチーム全員でCodelabに取り組む勉強会です。これまでに次のようなCodelabに取り組んできました。 Use Kotlin Coroutines in your Android App Android Room with a View - Kotlin Notification Channels and Badges (Kotlin) *1 Jetpack Compose basics Using Dagger in your Android app - Kotlin Background Work with WorkManager - Kotlin MDC-101 Android: Material Components (MDC) Basics (Kotlin) MDC-102 Android: Material Structure and Layout (Kotlin) MDC-103 Android: Material Theming with Color, Elevation and Type (Kotlin) チームでCodelabに取り組むことで次のようなメリットがあります。 定期的にCodelabに取り組む習慣ができる チーム全員の知識レベルを揃えられる Codelabの内容についてより理解を深められる 定期的にCodelabに取り組む習慣ができる 個人でCodelabに取り組むと習慣化するまで続かない場合があるでしょう。私個人の経験でもそうで、業務外で時間を取ってCodelabを進めるのはハードルが高く感じられる人が多いと思います。Codelab会では、業務時間内でスケジュールを確保して取り組むため、個人で取り組む場合より習慣化しやすいメリットがあります。現在、Codelab会は基本的に週一回の頻度で行っていてZOZOTOWN Androidチームの勉強会として定着しています *2 。 チーム内の知識レベルを揃えられる チーム全員が同じCodelabに取り組むことで、チーム内の知識レベルを揃えることができます。このメリットが役立った例として、「 Use Kotlin Coroutines in your Android App 」に取り組んだ例があげられます。この例では、Kotlin Coroutinesの基礎知識、Android開発においてどう使うのかという点でチーム全員の認識を揃えることでCoroutinesのZOZOTOWN Androidアプリへの導入が比較的スムーズに進められました。 codelabs.developers.google.com Codelabの内容についてより理解を深められる Codelabは全編英語であり、内容が高度な場合もあるため、理解するのに時間がかかることがよくあります。このことは、Codelabの途中で挫折する大きな要因となります。 Codelab会では、Codelabを進める中で理解できない点がある場合でも参加メンバーが相互にフォローしながら進めることでその場で相談できる状況を作り、内容についての理解を深めることができます。特にZOZOTOWN AndroidチームではAndroid開発を始めてからまだ日が浅いメンバーが多い状況だったので効果的でした。 Codelab会の実施方法 ここでは、Codelab会をどのように実施しているかについて解説します。Codelab会を実施するまでの流れは次の通りです。 取り組むCodelabの決定 進行役の決定と事前準備 参加メンバーの事前準備 Codelab会の実施と振り返り 取り組むCodelabの決定 最初に行うことは、Codelab会で取り組むCodelabを決定することです。Codelabを選ぶ基準は次のようにしています。 チームメンバーが興味のある内容であること チームの技術的な方向性にマッチする内容であること チームメンバーが興味のある内容であること 選択するCodelabは、チームメンバーが興味のある内容が望ましいです。ZOZOTOWN Androidチームでは候補は私が出す場合もありますが、やってみたいCodelabがあれば提案してもらうようチームメンバーにお願いしています。複数の候補がある場合、次のように投票で決めてしまうこともあります。 チームの技術的な方向性にマッチする内容であること 選択するCodelabがチームの技術的な方向性にマッチする内容であることも重要です。ZOZOTOWN Androidチームでは、チームの人数が増えてきたことからよりスケールする開発体制を目指す一環として、Jetpackの「 Guide to app architecture 」で紹介されているアーキテクチャをベースにした新しいアーキテクチャを採用し、リアーキテクチャを進めています。 リアーキテクチャを進めるにあたり、Codelab会では、チーム全体で新しいアーキテクチャについて理解を深める目的でLiveDataやViewModelといったLifecycleコンポーネント、Room、Kotlin Coroutines、Daggerを扱うCodelabを選択しています。 Use Kotlin Coroutines in your Android App Android Room with a View - Kotlin Using Dagger in your Android app Kotlin Coroutines Flowについても導入を検討していますが、導入前にCodelab会で次のCodelabを進める予定です。 https://codelabs.developers.google.com/codelabs/advanced-kotlin-coroutines/#0 codelabs.developers.google.com 進行役の決定と事前準備 Codelab会当日に会の進行を担当する進行役を決めます。進行役は、最初のうちはAndroid開発に十分な経験がある人が務めるのが良いでしょう。ZOZOTOWN Androidチームでは、私が進行役を務めています。 進行役が事前に行う準備は次の通りです。 スケジュールの調整 選択したCodelabの予習 スケジュールの調整 Codelab会のスケジュールを決め参加メンバーの予定を確保します。選択したCodelabを最後まで進めるのに複数回Codelab会を行う場合が多いため短いスパンで開催するのが望ましいです。 ZOZOTOWN Androidチームでは、初回から2回目の開催まで一ヶ月空いてしまい、参加メンバーからは前回の内容を忘れてしまうので期間を空けないで進めたいという要望を多く貰いました。そのため現在では週1回1時間で行っています。これより多くの時間を確保しようとすると疲れますし、チーム全員の予定を調整するのが難しくなるため1時間としています。 Codelab会のメリット でもお伝えした習慣化にもつながるので定期的な予定としてスケジューリングするのが良いでしょう。 選択したCodelabの予習 Codelab会の進行は、進行役が実際にCodelabを進める形で行います。会の進行を滞りなく行うのは進行役の重要な役割です。進行中に発生する問題として次のようなものがあげられます。 内容の理解に時間がかかりスムーズに進行できない サンプルプロジェクトがビルドできない 進行役がCodelab通りの結果にならず進行が止まる これらの問題について、選択したCodelabを事前に進めておくことで問題を事前に察知し回避できます *3 。 内容の理解に時間がかかりスムーズに進行できない 初見でCodelabの内容を理解して解説するのは難しく、内容の理解に時間がかかりスムーズに進行できない可能性が高いです。そのため、Codelabの内容を解説できるレベルまで理解できるよう、事前の予習を進める中でわからないと感じた点は調べ説明できるように準備しておきます。これにはCodelabで登場するサンプルコードへの理解も含みます。 サンプルプロジェクトがビルドできない 殆どありませんが、いざCodelabを始めてみるとサンプルプロジェクトをビルドできない場合があります。サンプルプロジェクトが依存するAndroid Plugin for Gradleやライブラリのバージョンを上げるなどするとビルドが失敗する場合があるので事前に確認しておくことは有用です。 「 Jetpack Compose basics 」のような開発中のツールやAPIを使用するCodelabは、特に事前に確認しておくことをオススメします。Codelab会で取り上げた際には、ビルドがうまくできない参加メンバーがいましたが、事前に確認しておいた結果サポートすることができました。 進行役がCodelab通りの結果にならず進行が止まる 進行役は、実際にCodelabを進めていきますが、Codelabで示されている結果と手元で実行した結果が異なると進行を止める結果となってしまいます。「 Background Work with WorkManager - Kotlin 」では、チェインしたWorkRequestが返す結果がCodelabで示されるものと手元で実行したものとで異なってしまい、その原因を探るために20分ほど時間を無駄にしてしまいました。事前にCodelabを進め、各チャプター終了後にコミットしtagを打つかブランチを切っておくとこの問題を防ぐことができます。 実際にコードを書きながら進めた方が進行しやすい場合もあるので、基本的にはコードを書きながら進め、進行が止まったら用意しておいたtagやブランチを活用すると良いでしょう。 参加メンバーの事前準備 Codelab会への参加メンバーは、選択したCodelabについてサンプルプロジェクトのCloneと初回のビルドを事前に行っておきます。これにより、会の時間を有効に活用できます。また、進行役は、Codelab会の前日や当日に参加メンバーへリマインドをすると親切です。 参加メンバーも事前にCodelabを進め内容把握しておくとより理解が深まるかもしれませんが、参加メンバーの負担が大きくなります。負担が大きくなると会を継続していくのが難しくなるため、現在特にルールを定めていません。 Codelab会の実施と振り返り Codelab会は次のような流れで実施します。 進行役が自分のPCの画面を参加メンバーに共有する 進行役がCodelabを実際に進める 不明点があるメンバーがいないか適宜確認しフォローする 終了後フィードバックを貰う 進行役が自分のPCの画面を参加メンバーに共有する 現在ZOZOテクノロジーズでは、原則リモートワークが義務付けられているため、チームメンバー全員がリモートワークをしている状況でCodelab会を進めています。 ZOZOテクノロジーズでは、 ビデオ会議システムとしてCisco Webex Teamsを利用できる ためCodelab会でも利用していますが、それ以外でもSlackのCallやZoom、Meetといった画面共有が可能なツールであれば問題ありません。全員がリモートでCodelab会をやってみた感想ですが、物理的な会議室を確保する必要が無いのは楽ですし、特に問題なく開催できています。 進行役がCodelabを実際に進める 進行役はCodelabを実際に進めていきます。この時、Codelabの内容を解説しながら進めていくのがコツです。内容プラスアルファの部分をどの程度解説するかですが、進行役が予習で詰まった箇所や理解が曖昧で調べた箇所などを進めながら解説しています。Codelabの内容から逸れる場合もありますが、参加したメンバーからは好評でした。 例えば、「 Use Kotlin Coroutines in your Android App 」を取り上げた際には、Codelabの内容に加え、Kotlinの文法やテストコードを書く際の留意点など併せて解説しています。 不明点があるメンバーがいないか適宜確認しフォローする Codelab会を進めていく中で、Codelabの内容に不明点があるメンバーがいないか適宜確認します。もし不明点があれば、進行役または他の参加メンバーがフォローするようにします。確認するタイミングはCodelabの難易度によって変えます。比較的簡単なチャプターであればそのチャプターの終了時、逆に難しければチャプターの各セクションごとに確認するようにしています。 例を上げると、「 Using Dagger in your Android app 」では、Dagger自体が理解する難易度が高いこともあり、セクションごとに確認を行いました。Codelabの内容について不明点を残したままにしてしまうとチーム全員にとって良くないので適切なフォローは大切です。 終了後フィードバックを貰う フィードバックを貰うことは非常に重要です。Codelab会終了後、できる限り早く参加メンバーにフィードバックを依頼し、コメントを貰うようにします。フィードバックを改善に活かすことで、より良いCodelab会の運営に繋げられます。 次のスクリーンショットは、2回目のCodelab会のフィードバックコメントです。このフィードバックを受け、次回のCodelab会からコードを書きながら説明する際にはゆっくりと、進行が滞らないよう進行役が事前にCodelabを進めておく運用にしました。 Codelab会を行った結果 Codelab会について参加したチームメンバーにアンケートを取り、次の項目について答えて貰いました。 Codelab会は業務に役立っているか? やって良かったCodelabは? 今後もCodelab会をやっていきたいか? Codelab会は業務に役立っているか? 全員から役立っていると回答を貰いました。業務時間内で定期的に時間を確保して行うことで、新しい技術・知識のキャッチアップに繋がっています。 チームメンバーからの回答をいくつか紹介します。 役に立っていると思います。 理由: 1)使いたいライブラリーと機能について調べる時間がないときに、Codelab会で触ってみるチャンス、 2)既に使用するライブラリーと機能の違う使い方を試すチャンス。 役立ってます! 理由: 時間的な問題で休日でしかCodelabをすることが出来なかったが、ZOZOTOWNの開発に携わるメンバーと行うことによって業務に組み込む前提で話が出来るのがかなり良いと思っております! 役立ってます! 理由: Codelabは一人でやっているとだんだん飽きてきてやらなくなるので、みんなでやると集中してできるし知識の幅も広がっていいなと思いました 役立ってます。 理由: Coroutine,Daggerは業務で使用するが、個人的には学習のハードルが高いのでなかなか取り掛かりにくいですがCodelab会として時間をとって学習すること、しっかりとした知識を持ったホーリーさんが解説するので噛み砕いて説明してもらえて挫折しないで最後までとりくめるから。 やって良かったCodelabは? DaggerとKotlin CoroutinesについてのCodelab、「 Using Dagger in your Android app - Kotlin 」と Use Kotlin Coroutines in your Android App をあげる意見が殆どでした。これは、実際の業務で使用している影響が大きいと考えられます。 また、Material Componentsシリーズ(MDC-101、MDC-102、MDC-103、MDC-104)をあげてくれたメンバーもいました。ZOZOTOWN Androidアプリ全体へのMaterial Designの適応はまだですが、社内でエンジニア、デザイナーを交えたMaterial Design勉強会を進めていて、今後順次適応していきたいと考えています。 チームメンバーからの回答をいくつか紹介します。 Dagger、MaterialDesign 理由: 個人的にDaggerについてとてもよかった。Daggerってpowerfullだけど複雑なツールですね、知識を広げるのは重要です。 最近やっているMaterialDesignについても毎回楽しみにしています。Materialライブラリーをバージョンアップとてもやりたい〜 Coroutine, Dagger 理由: ZOZOTOWN内に組み込む前段として、全員で進められたという点が良かったなと感じました。Codelabを行なったことによって理解力も高まってZOZOMATの開発でも実際に使うことが出来たのでとても良かったです! Coroutine、Dagger 理由: ZOZOTOWNの実装に必要かつドキュメントを見てもよく分からなかったためとても助かりました Coroutine、Dagger、MaterialDesign 理由: Coroutine、Daggerに関してはZOZOでの使用頻度が高まっているので業務を進める上で必要になってきているので。 MaterialDesignに関しては単純に楽しみながら進められているので。 今後もCodelab会をやっていきたいか? 全員から続けたいという回答を貰いました。また、次に取り組んでみたいCodelab( Learn advanced coroutines with Kotlin Flow and LiveData )をあげてくれたり、新しい技術について学び業務に取り入れていきたいとも回答を貰えたり、チームとして新しい技術を積極的に業務に取り入れていく姿勢に近づけられたとも感じています。 チームメンバーからの回答をいくつか紹介します。 是非続けたい。特にこれ: https://codelabs.developers.google.com/codelabs/advanced-kotlin-coroutines/#0 ぜひ! 理由: 古い歴史があるZOZOTOWNのアプリだからこそ、古き良きを重んじるだけでなく機能のブラッシュアップを進めるのではなく最新技術を盛り込んでいけるよう技術の底上げを行なっていきたいですね! やりたい 理由: ZOZOTOWNをどんどんリファクタリングしていくためにも知識を幅を広げるためにもこういう取り組みはやっていきたいです。 やりたい 理由: 常に新しい技術に触れる機会を持って取り入れることで、取り入れた方がいい技術であればみんなで触れて精査できるので。 Codelab会は「チームのAndroid開発に関する知識の底上げ」に繋がっているのか? アンケート結果からも分かるよう、Codelab会はチームへポジティブな影響を与えていますし、今後も続けることで「チームのAndroid開発に関する知識の底上げ」に繋がっていくと感じています。 チームとして学習した知識を活用し、ビジネス的な要求に答えつつ、ユーザーさんがより便利にZOZOTOWNアプリを利用できるよう普段の業務に取り組んでいきたいと思います。 まとめ 本投稿では、ZOZOTOWN Androidチームで取り組んでいる「Codelab会」について紹介しました。ZOZOTOWN AndroidチームではCodelab会を行うことで、チームのAndroid開発に関する知識の底上げに繋がっていると感じています。 一方で改善したい点もあり、特に進行役の担当が私に固定されてしまっている点は早急に改善したいです。参加メンバーで持ち回りで進行役を務めるなど改善をはかっていきたいと思います。 最後に、ZOZOテクノロジーズではAndroidエンジニアを募集しています。ご興味のある方はこちらからご応募ください。 hrmos.co *1 : 現在Deprecatedになっています。代替で推奨されるCodelabはこちらです。 https://codelabs.developers.google.com/codelabs/advanced-android-kotlin-training-notifications/index.html#0 *2 : 時期によっては案件の都合上実施できていないことがありました。。ただ、チーム内で忙しくても少しずつやった方が良かったという意見がでています。 *3 : こちらも業務時間で行っています。
アバター
こんにちは、WEAR部の繁谷です。 普段はバックエンドのエンジニアとしてWEARの開発を行っています。 ZOZOテクノロジーズは4月7日に「 髪型別コーデ検索 」をリリースしました。 プレスリリースは是非 こちら を御覧ください。 髪型別コーデ検索のフロントエンドはSPA(Single Page Application)でつくられており、こちらの開発を行った際に意識した設計について紹介します。 はじめに 髪型別コーデ検索は、 ZOZO研究所 の福岡チームが研究・開発したAIを活用し髪型からコーディネートを検索するAPIを利用して、SPAのWebサービスとして提供しています。 こちらは髪型別コーデ検索のアーキテクチャを簡単に示したものです。 今回私は研究所が提供するAPI以外のエンジニアリングに関する部分である、バックエンドのAPI開発、フロントエンドの開発、それらのインフラ構築を担当しました。 その中でもフロントエンドの開発は、SPAでの開発経験者がチーム内に私を含めて誰もいない状態から、一人で基礎の設計を行いその後開発メンバーを追加して素早くリリースすることを目指しました。 この過程で、どのように技術選定や設計、実装を行ったかを紹介します。 技術選定 髪型別コーデ検索のフロントエンド開発において、どのように技術選定を行ったかを説明します。 注力すべきことは何か 技術選定を行う前に、まず開発において注力すべきことを3つ定めました。 素早いリリース 技術的な挑戦 低い学習コスト 素早いリリース 今回の開発はWEARのユーザの皆様により良い検索体験を提供するために、研究所が開発した髪型別コーデ検索のAPIを効果検証することを目的としています。 そのため、適切に選択と集中をした上で素早くリリースし、PDCAを回すことを重視しています。 よって、3か月程度の開発期間で素早くリリースすることを目標においた上で、実装する内容を取捨選択していくことにしました。 技術的な挑戦 素早くリリースする必要がある一方、同時にエンジニアとして挑戦をする姿勢も我々にとって重要であるため、何らかの新しい技術の習得への挑戦を行うことにしました。 挑戦する内容を決めるにあたっては、当時のチーム内ではSPAでのフロントエンド開発の知見は全く無かったため、これらの技術の習得からリリースまでを開発期間内で行うこととしました。 低い学習コスト 技術的な挑戦は行いつつも、きちんとチームメンバーを巻き込み、素早く開発をスケールさせ、全体の開発スピードを上げる必要がありました。 SPA未経験のメンバーでも素早く開発に入れるよう、学習コストが低い技術選定を行い、適切な設計で開発環境を整備することを意識しました。 実際の検討内容 以上の注力する点を踏まえてフロントエンドの技術選定を行っていきました。 JavaScript vs. TypeScript TypeScriptは、昨今のフロントエンド開発ではデファクトスタンダードのようなものである認識であり、JavaScriptが書けるメンバーであれば学習コストは高くないため素直に採用しています。 React vs. Vue.js 当時の個人的な印象であり、コントリビュータの方々の認識と異なる可能性はありますが、 「簡単に使える」ことを意識しているVue.jsよりも設計を意識した「堅い」イメージのReactを選択しました。 今回は技術的な挑戦による技術力の向上を目的としており、設計力の向上につながると感じたためです。 React Hooks vs. Redux Reduxに関して優位性があった機能は、現在はほとんどがReactでも実現可能な認識です。 非同期処理は、 独自フック を使うことで同様のことができます。 バケツリレーと呼ばれる、親コンポーネントから子へのpropsの受け渡しは、 useContext によって解決できます。 Reducerによる状態管理は、 useReducer を使うことができます。 このように、以前はReduxでしか提供されていなかった機能も、今はReactで提供されています。 ただその中でも、 Redux DevTools によって状態の履歴を確認できることが、開発効率を向上させる上で効果的であったためReduxを選定することにしました。 また、学習コストが高いRedux middlewareは一切使用しないという方針をとっています。 SPA or SSR(Server Side Rendering) チームとしてReactによるSPAの経験もないため、いきなりSSRで開発するのは、学習コストが高いと判断しSPAを選んでいます。 また、WEARから直接アクセスを流すため、SEOを重視しないということもあります。 同様の理由でPWAも意識しない判断をしています。 SPAの設計 React、Reduxを用いたSPAにおいて、今回の開発でどのように設計したか説明します。 全体を通してSPA開発において特に悩みやすいポイントを、SPA未経験のメンバーが悩まず素早くコンポーネントを量産できるような設計を意識しています。 ディレクトリ構成 React、Reduxを用いたSPAではディレクトリ構成パターンが多くありますが、今回の開発は Ducksパターン を用いています。 これは、ReduxにおけるactionTypes, actions, reducerを、下記のツリーのように1つのファイルに書く非常にシンプルなパターンです。 ├── src │   ├── modules │   │   ├── coordinate.ts │   │   └── hairstyle.ts ファイルの中身は以下のように、actionTypes, actions, reducerをまとめて書きます。 // State const initialState = { hairstyles: null , } // Action const SHOW_HAIRSTYLES = 'SHOW_HAIRSTYLES' // ActionCreators export const showHairstyles = ( hairstyles: Hairstyles ) => { return { type : SHOW_HAIRSTYLES , payload: { hairstyles: hairstyles , } } } // Reducer const hairstyle = ( state = initialState , action: any ) => { switch ( action. type) { case SHOW_HAIRSTYLES: return { ...state , hairstyles: action.payload.hairstyles } default : return state } } Ducksパターンは、actionTypes, actions, reducerを1つのファイルに記述するため、そのファイルが肥大化し得ます。 それを解決するためにDucksパターンから派生した、 Re-Ducksパターン があります。 ただ、今回は比較的に小規模なプロジェクトで素早く実装したいため、よりシンプルなDucksパターンを選定しました。 結果的には、特に不都合はなく素早く実装ができたため、今回の開発においては有効なパターンでした。 コンポーネント設計 Reactにおいて、各コンポーネントをどの粒度で切り分け、どのようにコンポーネントツリーを組み上げるかということは大きな関心ごとでした。 このコンポーネント設計においてよく使われるのが、UI設計におけるメンタルモデルの Atomic Design です。 UIを Atoms Molecules Organisms Templates Pages という単位で分割して設計する考え方で、今回の開発でも部分的に取り入れています。 まず、今回の開発では、Atomic Designを取り入れるにあたって以下のルールを設定しました。 再利用するコンポーネントに対してのみAtomic Designを適用する。 Atomic Designはコンポーネントを設計するにあたって良い指標を示してくれます。 ただ、全てのコンポーネントに対して適用しようとした場合、設計コストが高く開発スピードは下がってしまいます。 よって、今回の開発では最初はAtomic Designを強く意識せず実装し、コンポーネントを再利用したくなった時にAtomic Designを適用するルールとしました。 そうすることで、開発スピードを落とさずに本当に再利用が必要なコンポーネントに対して、Atomic Designによる再利用性の向上のメリットを得られました。 具体的にどうやってコンポーネントを実装していくかを、髪型別コーデ検索の髪型の一覧を表示する部分を例として説明します。 まず、初期実装では、ページ毎にコンポーネントを切り出します。 このコンポーネントは react-router-dom などのルーターで読み込まれます。 // pages/Home.tsx const Home: React.FC = () => { return ( <> < Header / > < Hairstyles / > < Footer / > < / > ) } ページコンポーネントの配下のコンポーネントは、設計を意識しつつも自由に実装します。 // Hairstyles.tsx const Hairstyles: React.FC = () => { const hairstyleEntities = useSelector (( state: any ) => state.hairstyle.hairstyles ) const hairstyles = hairstyleEntities.map (( hairstyleEntity: HairstyleEntity ) => < div class= "hairstyle" > < img src = { hairstyleEntity.imgUrl } / > < p > { hairstyleEntity.name } < /p > < /div > ) return ( <> { hairstyles } < / > ) } 次に、上記のコードの <div class="hairstyle"> のところを再利用したくなった場合、これをコンポーネントに切り出しAtomic Designを適用していきます。 この際にAtomic Designの各要素を以下のように定義し、コンポーネントに分けていくようにします。 ※ 独自の定義で実際のAtomic Designの解釈とずれるところがあります。 Atoms : HTMLタグ1つと、それにスタイルを当てるタグでのみ構成され、状態を持たない Molecules : 高々数個のAtomsから構成され、1ページ内で複数回あらわれる可能性があり、他のコンポーネントとグループとしてまとまった状態で使用される Organisms : Molecules、Organismsから構成され、同じコンポーネントは1ページ内では高々1回しかあらわれない Templates : 再利用性が低いため使用しない Pages : ルータから参照され、基本的には再利用されない このように定義を決めることで、どのようにコンポーネントに落としていくかがイメージしやすくなります。 では、実際にAtomic Designを適用してみます。 まず、Atomic Designの適用対象の Hairstyles.tsx をOrganismsに分類します。 // organisms/Hairstyles.tsx const Hairstyles: React.FC = () => { const hairstyleEntities = useSelector (( state: any ) => state.hairstyle.hairstyles ) const hairstyles = hairstyleEntities.map (( hairstyleEntity: HairstyleEntity ) => < Hairstyle key = { hairstyleEntity. id } hairstyle = { hairstyleEntity } / > ) return ( <> { hairstyles } < / > ) } 再利用したい部分をMoleculesとしてコンポーネントに切り出します。 // molecules/Hairstyle.tsx const Hairstyle: React.FC < Props > = ( props ) => { return ( < div class= "hairstyle" > < Img imgUrl = { props.hairstyleEntity.imgUrl } / > < Text > { props.hairstyleEntity.name } < / Text > < /div > ) } imgタグやpタグはAtomsとしてコンポーネントに切り出します。 // atoms/Img.tsx const Img: React.FC < Props > = ( props ) => { return ( < img src = { props.imgUrl } / > ) } // atoms/Text.tsx const Text : React.FC < Props > = ( props ) => { return ( < p > { props.children } < /p > ) } いかがでしょうか。非常に簡単な例ではありますが素直にAtomic Designに落とし込むことができ、コンポーネントは再利用できそうなイメージができたと思います。 非同期処理 次に非同期処理です。 React、Reduxでは、非同期処理の実装の仕方も様々な方法があります。 前述の通り、今回の開発はRedux middlewareを使用しません。 redux-thunk や redux-saga といった、メジャーなRedux middlewareは使用せずReactの独自フックを用いて非同期処理を実装しています。 髪型の一覧を表示する例で独自フックを用いて非同期処理を行う実装をすると以下のようになります。 // organism/Hairstyles.tsx const Hairstyles: React.FC < Props > = ( props ) => { // 独自フック const [ loading , error ] = useGetHairstyles () const hairstyleEntities = useSelector (( state: any ) => state.hairstyle.hairstyles ) if ( error ) { return < Error / > } if ( loading ) { return < Loading / > } const hairstyles = hairstyleEntities.map (( hairstyleEntity: HairstyleEntity ) => < Hairstyle key = { hairstyleEntity. id } hairstyle = { hairstyleEntity } / > ) return ( < div class= "hairstyles" > { hairstyles } < /div > ) } // hooks/useGetHairstyles.ts export const useGetHairstyles = () => { const dispatch = useDispatch () const [ loading , setLoading ] = useState ( false ) const [ error , setError ] = useState < string | null >( null ) useEffect (() => { const getHairstyles = async () => { setLoading ( true ) // 非同期処理 const [ hairstyles , err ] = await HairstyleRepository.getHairstyles () if ( err ) { setError ( err.message ) } else { setError ( null ) dispatch ( showHairstyles ( hairstyles )) } setLoading ( false ) } getHairstyles () } , [ dispatch ] ) return [ loading , error ] } useGetHairstyles が独自フックとなります。 Reactの useEffect を用いてコンポーネントのマウント時に非同期処理を実行します。 独自フックから非同期処理の状態を呼び出し元のコンポーネントに返すことで、非同期処理の状態に応じたコンポーネントの出し分けを実装しています。 非同期処理のためのAPIクライアントはRepositoryパターンを用いて設計しているのと、エラーハンドリングにも工夫をしているため、ここについて詳しく説明します。 Repositoryパターン Reactにおいて、APIリクエストをする場合は axios などのライブラリを使用することが多いと思います。 この時、コンポーネントからデータソースへのアクセスロジックを切り離し隠蔽した上で、コンポーネントからこれらの実装を意識しなくても良い設計とすべきです。 このような設計において有効なデザインパターンがRepositoryパターンです。 実際に、Repositoryパターンを使用して、APIクライアントを実装してみます。 まずは、axiosを用いてAPIリクエストのコネクションを張る処理を共通化するクラスを作成します。 APIリクエストの設定値に関する処理はこのクラスに集約します。 // models/repository.ts export class Repository { private static connection () : AxiosInstance { return axios.create ( { baseURL: 'https://example.com' , timeout: 500 , headers: { 'Content-Type' : 'application/json' } } ) } public static async get < T >( path: string , config?: AxiosRequestConfig ) : Promise < T > { const connection = this .connection () const response: AxiosResponse = await connection.get < T >( path , config ) return response.data } } 次に、上記のクラスを用いて得たAPIのレスポンスデータを、それぞれのエンティティに落とし込むクラスを用意します。 以下は、髪型のリストデータをAPIで得てエンティティに落とし込む例です。 // models/hairstyle/repository.ts export const HairstyleRepository: IRepository = { async getHairstyles () : Promise < HairstyleEntities > { const hairstyles = await Repository.get < HairstylesSchema >( '/v1/hairstyles' ) const hairstyleEntities = hairstyles.map (( hairstyle: HairstyleSchema ) => new HairstyleEntity ( hairstyle ) ) return hairstyleEntities } } Repository.get する際にGenericsでレスポンスデータの型を指定することで、レスポンスデータがJSONであってもきちんと型チェックを行います。 // models/hairstyle/schema.ts export type HairstylesSchema = { hairstyles: HairstyleSchema [] } export type HairstyleSchema = { id: string name: string image_url: string } そして、エンティティは以下のようになります。 // models/hairstyle/entity.ts export class HairstyleEntity { id: string name: string imageUrl: string constructor( args: HairstyleSchema ) { this . id = args. id this .name = args.name this .imageUrl = args.image_url } } これで HairstyleRepository.getHairstyles() によって、コンポーネントからデータソースへのアクセスロジックを意識せずに、APIによるデータの取得ができるようになりました。 ただ、この実装はレスポンスのエラーハンドリングができていません。 次は、このエラーハンドリングについて見ていきます。 エラーハンドリング エラーハンドリングにおいても、APIリクエストの実装者からはシンプルになるように設計を行います。 エラーを扱う時に実装者からはHTTPであることや、サーバエラーなのか、クライアントエラーなのか、といったことは意識しなくても良いことを目指しました。 実際の実装を見ていきます。 axios.get はレスポンスエラーの場合は例外を投げるため、 axios.get の例外をcatchしエラー用のオブジェクトでくるんで Repository.get の2つ目の返り値で返すようにします。 // models/repository.ts export class Repository { public static async get < T >( path: string , config?: AxiosRequestConfig ) : Promise < [ T , null ] | [ null , RpcError ] > { const connection = this .connection () try { const response: AxiosResponse = await connection.get < T >( path , config ) return [ response.data , null ] } catch ( error ) { if ( error.response ) { return [ null , RpcError.buildFromHttpResponse ( error.code , error.response.data.display_message ) ] } else { return [ null , RpcError.buildCancelled () ] } } } } この時気をつけるべきことは error.response の中身です。 axiosはHTTPで表現できるサーバエラーの場合は、catchした error.response でステータスコードなどを取得できます。 ただし、タイムアウトなどのクライアントエラーは error.response がnullとなります。 この error.response がnullの場合も、きちんとオブジェクトで表現して実装者は意識しないようにしたいです。 よって、このサーバエラーとクライアントエラーを1つのオブジェクトで表現するために、RPCのようにエラーを表現してみました。 gRPCのエラーコードを参考に、HTTPのステータスコードをgRPCのエラーコードに置き換え、クライアントエラーはgRPCでいうところのCANCELLEDとして扱うようにしました。 参考 : https://github.com/googleapis/googleapis/blob/2433bd50656264a2ef9f684bf646fb4d250d39ff/google/rpc/code.proto 以下が実際のコードです。HTTPとgRPCの対応は今回のサーバが返すステータスコードのみ表現しています。 // models/rpcError.ts enum RpcCode { OK , CANCELLED , UNKNOWN , INVALID_ARGUMENT , DEADLINE_EXCEEDED , NOT_FOUND , ALREADY_EXISTS , PERMISSION_DENIED , RESOURCE_EXHAUSTED , FAILED_PRECONDITION , ABORTED , OUT_OF_RANGE , UNIMPLEMENTED , INTERNAL , UNAVAILABLE , DATA_LOSS , UNAUTHENTICATED , } export class RpcError { constructor(public code: RpcCode , public message: string ) { } static buildCancelled ( message?: string ) { return new RpcError ( RpcCode.CANCELLED , message || '予期せぬエラーが発生しました。時間を置いてもう一度お試しください。' ) } static buildFromHttpResponse (status : number , message: string ) { let code: RpcCode switch (status) { case 500 : code = RpcCode.UNKNOWN break case 400 : code = RpcCode.INVALID_ARGUMENT break case 504 : code = RpcCode.DEADLINE_EXCEEDED break case 404 : code = RpcCode.NOT_FOUND break case 409 : code = RpcCode.ALREADY_EXISTS break case 403 : code = RpcCode.PERMISSION_DENIED break case 401 : code = RpcCode.UNAUTHENTICATED break case 429 : code = RpcCode.RESOURCE_EXHAUSTED break case 503 : code = RpcCode.UNAVAILABLE break default : code = RpcCode.UNKNOWN } return new RpcError ( code , message ) } } これによって、エラーを扱う実装者からはエラーコードとエラーメッセージだけを意識すれば良いようになりました。 以上がSPAの設計で注力した点です。 これらによって、どこにコードを書けばいいのか、どのようにコンポーネントを組み上げればいいのか、非同期処理はどう実装すべきか、といった悩みが減りSPA未経験者もスムーズに開発に迎え入れることができたと思います。 おわりに 髪型別コーデ検索におけるSPA開発の技術選定や設計について紹介しました。 SPAに興味がある皆さんの参考になれば幸いです。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
アバター
こんにちは、ZOZOテクノロジーズ CTO室の池田( @ikenyal )です。 ZOZOテクノロジーズでは、 技術書典 応援祭 にて、有志で制作した技術同人誌【 ZOZO TECH BOOK VOL.1 】の頒布を行いました。現在も引き続き BOOTHにて頒布中です 。 zozotechnologies.booth.pm それに伴い、4/28と4/30の二日間、頒布をしている ZOZO TECH BOOK VOL.1 の解説会をオンラインで実施しましたので、そのレポートをお届けします。また、今回のイベントが弊社でも在宅勤務状態での初のオンラインイベントだったので、その配信の裏側も少しだけお伝えします。 zozotech-inc.connpass.com zozotech-inc.connpass.com 【オンライン】#技術書典 頒布本「ZOZO TECH BOOK」解説会 Vol.1 まとめ Vol.1は4/28に実施し、以下の章の解説を行いました。 zozotech-inc.connpass.com 第1章 ZOZOテクノロジーズの2019年の振り返りと現状 (今村 雅幸 / @kyuns & 池田 健人 / @ikenyal ) 第3章 速習GitHub Actions 〜 明日からの充実GitHub自動化ライフのための凝縮ポイント 〜 (川崎 庸市 / @yokawasa ) 第7章 はじめての本番デプロイ (光野 達朗 / @kotatsu360 ) 【オンライン】#技術書典 頒布本「ZOZO TECH BOOK」解説会 Vol.2 まとめ Vol.2は4/30に実施し、以下の章の解説を行いました。 zozotech-inc.connpass.com 第4章 Miro SDK入門 (堀江 亮介 / @Horie1024 ) 第5章 iOSアプリのクラッシュレポート、もう少し詳しく! (元 政燮) 休憩トーク〜ZOZO TECH BOOK表紙デザインの裏話〜 (MOZZY) 第6章 GoのCLIツールで服作りの業務効率化 (手塚 ⻯太 / @tzone99 ) 第2章 WebXRの現状確認 2020 Spring (諸星 一行 / @ikkou ) オンライン配信の試み 現在、ZOZOテクノロジーズでは在宅勤務を行っております。そのため、今回のイベントも登壇者含め全員が自宅より配信を行いました。弊社でも在宅勤務状態での初のオンラインイベントだったので、その配信の裏側も少しだけお伝えします。 ツールの選定 配信に使えるツールは数多く提供されています。その中で、今回の配信ではWebex EventsとYouTube Liveの併用を採用しました。なお、ZOZOテクノロジーズではWebexを標準ツールとしています。 techblog.zozo.com ツール選定時の流れを一部紹介します。 最初の大きな選択として、参加者による音声での質問など双方向性を重視するかどうかです。 この部分を重視する場合、 Zoomビデオウェビナー や Webex Events に会話可能な状態で参加してもらうことになるでしょう。参加者が不特定多数な場合は、発言者のコントロールを気をつけないと収拾がつかなくなる可能性もあるので注意が必要です。 今回はこの部分は重視しないため、次のステップとしてYouTube Liveの検討をしました。会社としてもオンラインイベントは初の試みであり、少しでも多くの人に届けたい思いがあります。社内で標準化しているWebexでは、ツールに馴染みのない方々もいらっしゃるでしょう。YouTubeであれば、多くの人が馴染みがあると考えたためYouTube Liveを使った配信をすることにしました。配信後にアーカイブとしてそのままYouTubeに公開もできます。 YouTube Liveで配信する際に、画面レイアウトを凝るかどうかを考えます。ZoomビデオウェビナーやWebex Eventsの標準の見え方で問題なければそのままツールを利用で良いでしょう。しかし、レイアウトを凝りたい場合には任意のテレカンツールをOBS(Open Broadcaster Software)使ってイベント用の画面を作って配信すると良いでしょう。今回のイベントではこの方式を選択しました。 このようなフレームを追加してOBSで配信を行いました。OBSはWindowsやMacで動作するOSSですが、最低限、GPUレンダリング可能な WindowsのPCを推奨します。 配信時に使う画像アセットは画像解像度 1920x1080(16:9比率)にて、以下のものをあらかじめ用意しておきます。 YouTube Live 配信開始前/配信後に表示する画像 登壇者の切り替え時などに流す幕間の画像 登壇者ごとの発表タイトルなどが含まれたテンプレート画像 画面の切り替えや登壇者の話し始めるタイミングなど、リハーサルで関係者全員が流れを確認しておく必要があるので、事前確認は必ず行いましょう。 最後に ZOZOテクノロジーズでは、プロダクト開発以外にも、今回のような技術書典への参加やイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに BtoB開発部の増田です。 2020年4月1日より、株式会社アラタナからZOZOグループへの吸収統合を経て、ZOZOテクノロジーズ/BtoB開発部として新たなキャリアをスタートすることになりました。これまで同様、九州・宮崎にオフィスを構えており、現在約30名のエンジニアで開発を行っています。ZOZOグループの成長に貢献できるような拠点拡大を目指していきますので、よろしくお願いします! 今回、アラタナ時代にたくさんのブランドさまとともに歩んできたプロジェクト経験をもとに、プロジェクト管理の要点をまとめてみました。大規模なプロジェクトを管理していくためのヒントになれば幸いです。 プロジェクトの特徴 BtoB事業について ZOZOグループでは、メイン事業となるZOZOTOWNのサービス開発に加えて、ZOZOTOWN出店ブランドさまの自社ECシステムの開発支援、運用支援を行うBtoB事業を展開してきました。現在では、ZOZOTOWNと自社ECとで在庫を一元管理するための物流支援サービス 「Fulfillment by ZOZO」 を中心としたサービス開発を通して、ブランドさまの支援を行っています。 BtoB事業の大規模プロジェクト BtoB事業では、大小さまざまなプロジェクトを推し進めてきました。特に下記のような大規模プロジェクトは管理や進行の難易度が高くなります。ですが難易度が高い分、多くの気付きや知見、経験を得ることができます。今回は、BtoB事業で直面した下記のような大規模プロジェクトに焦点を当てて、特に気をつけているポイントをまとめてみます。 期間が1年以上の長期間に及ぶ 社内の複数部署だけでなく社外の多数のベンダーとも連携する システム改修の影響がブランドさまの特定部署だけでなく全体横断的に影響がある プロジェクト管理の要点 要点(1)全体像の整理 プロジェクト初期の段階で全体像をうまく整理できるかどうかは、その後の進行に大きく影響します。 特に、下記の3点は、プロジェクトの全体地図に相当するものです。プロジェクトの全体イメージを関係者全体に共有できるように、簡潔な形で可視化しておきます。プロジェクトに関与していないメンバーでも概要を把握できるような、わかりやすい説明資料を目指して作成します。 目的、納期、予算、要件一覧などをまとめた「プロジェクト定義書」 関係するシステム要素の相関関係を網羅的にまとめた「システム俯瞰図」 関連各社、各部門のキーパーソンをまとめた「プロジェクト体制表」 プロジェクト定義書 プロジェクトの目的をはじめとして、納期、予算や要件一覧などプロジェクトの大前提となることをまとめます。特にプロジェクトの目的は、進行過程でそもそも論が浮上したときに、原点に立ち戻るための指針です。プロジェクトを通して何を達成すべきかを正しく理解して、全体に共有しておく必要があります。 ✕✕✕ツールの導入 ◯年◯月までのECシステムリプレース のような表現だと目的の本質が伝わりません。 各セール企画の効果測定を高精度化し販促強化するために、✕✕✕ツールを導入しデータ分析の粒度を詳細化する 複雑化したEC運用を効率化するために、◯年◯月までにECシステムをリプレースし単一システムでのEC運用を可能にする のように具体的な目的を表現しておきます。 システム俯瞰図 プロジェクトに関連する登場人物(システム構成要素)を俯瞰的に配置します。 要素の洗い出しのためには、ブランドさまや関連ベンダー各社へのヒアリングを何度も重ねることになります。一見、プロジェクトとは無関係に思える構成要素が登場しても、ヒアリング過程で話題に上がったものは念のため俯瞰図には落とし込んでおきます。そうすることで、ヒアリングでの見落としを後の設計フェーズで発見しやすくなります。 俯瞰図を整理していく過程では、ブランドさまの運用の歴史的背景や将来設計を知ることができたり、ベンダーから提供されるサービスの仕様や開発スタイルを学ぶ機会があったりします。こうした社外との接点を多く持てるのは、BtoBプロジェクトの魅力のひとつです。 プロジェクト体制表 プロジェクトをともにする各社の担当者、キーパーソンを一覧化しておきます。 このとき、整理の区分を会社や組織の単位で分けてしまうと、体制表というよりも連絡表のような性質のものになってしまいます。互いに関連し合う担当者がわかりやすいように、プロジェクトを分割するテーマ単位で整理するようにします。そのうえで、各テーマの進捗管理や状況報告を担当する責任者を明確にしておきます。 要点(2)スケジュールの管理 プロジェクトの成否をもっとも左右するスケジュール管理。 唐突かつ抽象的に問われる「順調?」に対して、簡潔にズバッと回答できる状態を常に作っておきたいと、心から思います。BtoBの大規模プロジェクトでは、下記3つの粒度でスケジュールを管理しています。 長期スケジュールの全体像を月単位の粒度で俯瞰する「大日程表」 ブランドさま含む関連各社の計画を週単位の粒度で確認する「中日程表」 ブランドさま含む関連各社のタスクと進捗を1日単位で確認する「詳細日程表」 大日程表 プロジェクト全体のスケジュールを、月単位の粒度で表現したものです。 プロジェクトの初期段階で、各社のスケジュール感を可視化したり、開発スケジュールを詳細化するためのたたき台として利用します。細かなタスクの内容よりも、各月でプロジェクトはどんな作業をするフェーズになるのかを整理するための俯瞰資料になります。 中日程表 プロジェクトの関連各社の計画を、週単位の粒度で表現したものです。 各社のタスクをある程度細分化し、各社がどの時期にどんな作業をする計画になるかを可視化します。他社タスクとの関連や依存関係もここで表現しておき、着手に必要な先行タスクはどんなものがあるのかを把握できるようにします。 また、プロジェクトの進行フェーズに入ると、事前に予期できなかった課題が顕在化してきます。場合によっては計画の軌道修正が必要になることもあります。軌道修正によって後続の計画にどんな影響が発生するか、軌道修正の影響はリカバリー可能なものなのかを判断する際に、中日程表は有効です。 詳細日程表 プロジェクトの関連各社の計画を、もっとも細かい粒度に整理し1日単位の粒度で表現したものです。 進捗状況を実質的に評価するのは、詳細日程表(WBS/ガントチャート)です。各社の足並みを中日程表で俯瞰し、タスクレベルでの進捗が先行気味なのか遅延気味なのかをガントチャートで追跡します。 長期的なプロジェクトのタスクを事前にすべてリストアップしておくのは実質的には不可能です。ですが、中日程表に基づいて想定されるタスクを事前に細かく整理しておくことは、計画リスクの事前検知に有効です。各社と協調しながら、スケジュールを整理するとともに、各社動向の具体的な内容を詳細に理解していきます。 大規模プロジェクトでは、膨大なタスク量に圧倒される局面もあります。ですが、各社一体となって着実に実績を積み上げ目的を達成できた瞬間は、大きな達成感を実感できます。こうした日常では得難い達成感を多くの関係者と共有できるのも、BtoBプロジェクトの魅力のひとつです。 要点(3)プロジェクトチームのコミュニケーション 最後に、プロジェクトメンバーのコミュニケーションについてです。 大規模プロジェクトではミーティング参加者も多くなりやすいので、議論や合意形成が簡単に進まないこともあります。特に最近ではリモート形式でのミーティングが中心なったことで難易度が上がっており、対面で直接会話する以上にコミュニケーションでの想像力が要求されます。 いま伝えたことはちゃんと伝わったか いま伝えたことに何を感じているのか 質問の背景にはどんな意図や懸念があるのか いま議論になっている課題の本質はどんなことか など、相手の状況を想像しながら対話に配慮する必要があります。 定例的なミーティングでも事務的・義務的な場にするのではなく、人同士の大切なコミュニケーションの場と捉え、丁寧なコミュニケーションを関係者全員に対して心がけておくのが重要です。 感謝は最大限に伝える お願い事では必ず背景まで丁寧に伝える どんな質問にも真摯に答える そうしたことを積み重ねながら苦楽をともにしていくことで、プロジェクト終了後にも継続して交流を続けられるような協力関係を築くことができるのは、BtoBプロジェクトの魅力のひとつです。 まとめ 以上、BtoB事業の大規模プロジェクトにおける管理観点を簡単にまとめました。 全体像にしても、スケジュールにしても、全体概要から部分詳細化の流れでの情報整理を意識しています。開発を分担していただく関連各社が、システムの全体像や開発スケジュールの全体方針を把握した上で各社領域の開発を進めていけるような情報共有を行っています。 ZOZOテクノロジーズでは、BtoB事業の拡大に取り組んでいただけるエンジニアを絶賛募集中です。ブランドさまと近いプロジェクトに従事したい方、ブランドさまの課題や要望を一緒に考えながら開発に取り組みたい方、九州・宮崎でお仕事したい方など、ご興味ある方は こちら からぜひご応募ください! tech.zozo.com
アバター