TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

938

こんにちは。アーキテクト部の廣瀬です。 私は2021年7月に、Data Platformカテゴリにおいて Microsoft MVP を受賞しました。昨年に続き2度目の受賞です。これからも受賞し続けられるように引き続きがんばります。 弊社ではサービスの一部にSQL Serverを使用しています。以前テックブログで SQL Serverの障害調査フロー をご紹介しました。その中で 動的管理ビュー (Dynamic Management View:以下、DMV)と 拡張イベント の情報を保存(ロギング)しておき、障害調査に活用していることをご紹介しました。このロギングによって障害発生時の原因特定率が劇的に向上しています。具体的なトラブル解決事例を、以下のテックブログで紹介していますので、よろしければご覧ください。 techblog.zozo.com techblog.zozo.com techblog.zozo.com 本記事では、弊社で取り組んだSQL Serverロギングの仕組み作りについて詳しく紹介します。 SQL Serverロギングの有用性 パフォーマンスカウンターが提供するような数値ベースのメトリクスは、監視製品を導入すれば容易に可視化できます。そのため、クエリタイムアウト多発などの障害が発生した際に確認すれば、「CPU使用率が100%に張り付いていた」や「ワークスペースメモリの獲得待ちが多く発生していた」という事実が分かります。 しかし、これだけでは「どのクエリがそこまでの負荷増を招いたのか」「どのクエリがワークスペースメモリを逼迫させていたのか」といったクエリレベルでの原因特定には至りません。このようなパフォーマンスカウンターの値だけでは根本原因が特定できないケースに備え、他の情報も組み合わせて保存しておく必要があります。 例えば、「特定の日時において実行中だったクエリリスト」と「各クエリが消費したCPU時間と、獲得したワークスペースメモリサイズ」が後から確認できるとします。それが実現すればパフォーマンスカウンターの情報と組み合わせてクエリレベルでの根本原因を特定できます。原因が特定できれば、クエリチューニングなどの再発防止に繋がる具体的なアクションをとることが可能です。このように、障害発生時の原因特定率を向上させるためには、SQL Serverのロギングを如何に充実させるかが重要です。 ロギングの機能を備えた監視製品 監視製品の中には、例えば Spotlight のように、「現在実行中のクエリ」を約1分間ごとに保存してUI上で後追いできるものもあります。とても便利なのですが、以下の点で物足りなさがあります。 最短1分というインターバルが長い Spotlightでは、サーバーの過去の状況を最短1分単位で遡って確認できます。しかし、OLTPワークロードメインのWebサービスでは、クエリタイムアウト時間が数秒から数十秒であることがほとんどだと思います。5秒でタイムアウトするWebサービスで、現在実行中のクエリを1分ごとにロギングしても、クエリの情報がログに保存される可能性は低いです。したがって、タイムアウトした原因を後追いできる可能性も低いでしょう。 取得したい情報を拡充できない Spotlightでは、テーブルの統計情報がいつ更新されたのかを調べることができません。一方で、原因調査にあたっては、この情報が重要な手がかりになることが少なくありません。自前でロギングの仕組みを作っていれば簡単に取得情報の拡充が可能ですが、既成の監視製品を使う場合は、追加での情報取得が難しいことがあります。 以上がSQL Serverのロギングを充実させることの重要性と、ロギングの仕組みを内製化する動機です。ここからは、その仕組み作りについて説明します。SQL Serverが元々提供している情報を適切に蓄積することで、これを実現します。 ロギングの仕組み作り ロギングは、DMVと拡張イベントを元にしたクエリを使って実現します。それぞれ解説します。 DMVを元にしたログ 本記事で作成したロギングの仕組みでは、DMVから大半の情報を取得しています。DMVとはSQL Serverの状態情報が格納されたViewのことで、沢山種類があります。私の環境では、SQL Server2016で237種類、2019で275種類のDMVが用意されていました。DMVには以下のような特徴があります。 サーバースコープ(=どのDBでSELECTを実行しても結果が同じ)と、DBスコープ(=各DBによってSELECTの実行結果が変わる)の二種類がある 内部状態を管理しているViewなので、SELECTしか実行できない 更新はSQL Server内部で自動的に行われる SELECTするときは、スキーマ名(sys)をつける必要がある 例:select * from sys.dm_os_wait_stats 例えば、以下のクエリを実行することで「取得時点で、実行時間が1秒以上のクエリリスト」を取得できます。 select top 500 getdate() as collect_date ,der.session_id as spid ,der.blocking_session_id as blk_spid ,datediff(s, der.start_time, getdate()) as elapsed_sec ,db_name(der.database_id) as db_name ,des.host_name ,des.program_name ,der.status ,dest.text as command_text , replace ( replace ( replace (substring(dest.text, (der.statement_start_offset / 2 ) + 1 , (( case der.statement_end_offset when - 1 then datalength(dest.text) else der.statement_end_offset end - der.statement_start_offset) / 2 ) + 1 ), char ( 13 ), ' ' ), char ( 10 ), ' ' ), char ( 9 ), ' ' ) as current_running_stmt ,datediff(s, der.start_time, getdate()) as time_sec ,wait_resource ,wait_type ,last_wait_type ,der.wait_time as wait_time_ms ,der.open_transaction_count ,der.command ,der.percent_complete ,der.cpu_time ,( case der.transaction_isolation_level when 0 then ' Unspecified ' when 1 then ' ReadUncomitted ' when 2 then ' ReadCommitted ' when 3 then ' Repeatable ' when 4 then ' Serializable ' when 5 then ' Snapshot ' else cast (der.transaction_isolation_level as varchar ) end ) as transaction_isolation_level ,der.reads ,der.writes ,der.logical_reads ,der.query_hash ,der.query_plan_hash ,des.login_time ,des.login_name ,des.last_request_start_time ,des.last_request_end_time ,des.cpu_time as session_cpu_time ,des.memory_usage ,des.total_scheduled_time ,des.total_elapsed_time ,des.reads as session_reads ,des.writes as session_writes ,des.logical_reads as session_logical_reads ,der.scheduler_id ,der.dop ,deq.grant_time ,deq.granted_memory_kb ,deq.requested_memory_kb ,deq.required_memory_kb ,deq.used_memory_kb ,deq.max_used_memory_kb ,deq.query_cost ,deq.queue_id ,deq.wait_order from sys.dm_exec_requests der join sys.dm_exec_sessions des on des.session_id = der.session_id left join sys.dm_exec_query_memory_grants deq on deq.session_id = der.session_id outer apply sys.dm_exec_sql_text(der.sql_handle) as dest where des.is_user_process = 1 and datediff(s, der.start_time, getdate()) >= 1 order by datediff(s, der.start_time, getdate()) desc option (maxdop 1 ) 以下は、取得結果の例です。 この結果からは、waitforで1分待つクエリが2秒間実行中であることが分かります。ただし、あくまで「今現在の情報」なので、過去に遡って「2021/08/01 22:00時点で1秒間実行中だったクエリリスト」の後追いはできません。そこで専用のテーブルを作成し、1分間隔など定期的に実行結果をINSERTしておけば、特定日時のDBの状態を後追いできるようになります。 拡張イベントを元にしたログ 拡張イベントとは、SQL Serverに関する様々なイベントの発生を収集できる機能です。例えばログイン、ストアドプロシージャのリコンパイル、クエリの中断(abort)、ブロッキングの発生などです。弊社では主にブロッキングの検出に拡張イベントを使用しています。検出したブロッキングイベントはxmlフォーマットで保存されます。xmlをパースするクエリを書くことで、ブロッキングイベントのクエリベースでの解析も可能です。しかし、ブロッキングのイベント数が多い環境下ではクエリ実行に数分かかることも珍しくありません。そこで、DMVのロギングと同様に専用のテーブルを作成し、拡張イベントの解析クエリ実行結果を定期的に保存しています。これにより、ブロッキングに関する調査クエリの実行時間が劇的に短縮化され、調査スピードが向上しました。 以上がロギングの仕組みです。次に、弊社で実際に使用しているロギング用のクエリをご紹介します。 ロギング用クエリ(MITライセンス)のご紹介 こちら に、SQL Serverの情報取得用のクエリを公開しています。MITライセンスでどなたでもご自由にお使いいただけます。本記事で紹介したロギングについては 「sqlserver_logging」ディレクトリ にクエリをまとめています。使い方はREADMEをご覧ください。本記事では、工夫した点を説明します。 1. 情報ごとに適切な取得間隔を設定 リポジトリの「使用例」ディレクトリ に、各情報をどれだけの時間間隔で収集しているかをまとめています。基本的には1分間隔ですが、以下のようにいくつかのケースでは取得間隔を変更しています。 DBのデータサイズは1日ごとの推移が確認できれば十分と判断して1日間隔にする 拡張イベントのブロッキングイベントのパースクエリは実行時間が長くなるため、1時間に1回まとめてデータを保存する 「現在実行中のクエリ」はOLTPワークロードの後追い用に5秒間隔、バッチ処理の後追い用に1分間隔でそれぞれ取得する このように取得する情報の種類を適切に充実させるだけではなく、適切な間隔でロギングすることで欲しい情報を取得できる確率を向上させています。 2. 解析の精度向上 以前は こちらの記事 で紹介している方法で解析していました。この方法はcpu使用時間など、累積されていく値を2点間の差分をとって集計する、という思想に基づいた解析方法です。この方法でもある程度は正確にボトルネッククエリをリストアップできますが、以下のようなケースの内、クエリAとクエリBしか考慮できていませんでした。 Snapshot①と②の間隔が数分など短い場合や、DBサーバーのメモリに余裕があってほぼキャッシュアウトされない環境であればこれでも問題ありません。しかし、1時間や1日単位でボトルネック調査をしたい場合や、頻繁にキャッシュアウトされる環境下では精度低下の懸念があります。Snapshot間隔の間でキャッシュインとキャッシュアウトが起こったり、Snapshot②を取得する前にキャッシュアウトされることも十分考えられるためです。そこで、解析精度を向上させるために2点の情報を使うのではなく、抽出期間に存在する全てのSnapshotを利用する方針にしました。例えば、特定の1時間のCPUボトルネックを調査したい場合、Snapshot①とSnapshot②の間には約60個のSnapshotが存在します。 これらの情報も使うことで、今までの方法であれば見落としていたクエリCやクエリDも抽出できるようになり、解析精度が向上しました。 オーバーヘッドについて 取得する情報のサイズや詳細度と、サーバーにかける負荷(オーバーヘッド)はトレードオフの関係であることがほとんどです。DMVを使った定期的なクエリ実行も拡張イベントの取得も、オーバーヘッドはかかってきますが、弊社では許容できる範囲内と判断しました。ただし、環境ごとに状況は異なってきますので、取得する情報を増やした際は必ずパフォーマンスへの影響を確認しましょう。 推奨のアプローチは次のとおりです。 パフォーマンスカウンターの収集、可視化を有効にし、 パフォーマンスのベースラインを設定する DMVや拡張イベントを順次有効にする それぞれの設定前後でCPU、メモリ、I/Oなどのリソースの変化を確認することでオーバーヘッドが許容できるかを判断する また、 SQLServer:Batch Resp Statistics を使って実行時間とCPUに関するクエリ分布の変化を確認するのも有効です。このメトリックを使ったパフォーマンス影響の調査事例としては以下の記事がありますので、よろしければご覧ください。 techblog.zozo.com クエリストアとの使い分け SQL Server2016以降では、 クエリストア という機能が提供されています。この機能を有効にすることで、以下のような調査、対応が可能になります。 クエリプランの後退が発生しているクエリの特定とプランの修正 CPUや実行時間、I/Oなどリソース消費量の多いクエリの特定 クエリストアで取得可能な情報については、クエリストアを使用した方が簡単かつ高精度な情報を得ることができます。上記のようなトラブルシューティングではクエリストアを使用しつつ、以下のようなシナリオで内製ロギングの情報を使用する、というように使い分けると良いでしょう。 1. クエリストアの設定値よりも短い時間枠で調査したい クエリストアは、1時間など特定の時間枠ごとにパフォーマンス情報を蓄積していきます。この時間枠は短くもできますが、短くするほどクエリストアの保存データが増加していきます。したがって、通常は15分や30分、1時間などの時間枠で設定することが多いかと思います。このとき「特定の5分間で最もCPUを消費したクエリを特定したい」という場合は、内製ロギングの情報を使用することでより正確に後追いできる可能性があります。 2. クエリストアでは取得されない情報を確認したい 各インデックスごとのseek、scan、lookup回数などはクエリストアでは確認できません。したがってロギングの仕組みで保存した情報を活用します。 ロギングする情報の継続的な拡充 最初にリポジトリを公開した時点でも、収集したログが多種多様な障害の原因調査に役立っていました。しかし、原因が特定できないケースもあります。そのときは「どんな情報があれば原因を後追いできたか?」という疑問を出発点にして、取得する情報を拡充してきました。このように、ログを使った調査と取得情報の拡充というサイクルを回し続けることで、原因の特定に至る確率を向上させ続けています。 今後の展望 最後に、ロギングの仕組みの今後の展望について説明します。 仕組みの改善 現在はロギングの処理をSQL Serverのジョブとして各サーバーで実行し、各DBにログを直接保存しています。したがって以下のような課題を抱えています。 ロギングの仕組みの修正コストが高い ログ容量を気にする必要がある 多少の負荷増につながっている そこで、今後はログの収集基盤を別途構築し、ログ収集クエリや実行頻度などの修正コストも大幅に下げらえるよう仕組みを改善していきたいと考えています。 開発者にとって使いやすいログ解析の仕組みの提供 現在は「dm_exec_requests_dump」など元となったDMVの名前を意識したテーブル名にしています。また、できる限り様々な事象を後追いできるように保存しているカラムも多岐にわたります。SQL ServerのDMVに馴染みのある人には使いやすいと思うのですが、そうでない人たちにとっても使いやすい仕組みへと改善したいと考えています。例えばテーブル「dm_exec_requests_dump」を「requests_log」という名前のViewでラップし、カラムも必要最小限に限定した上で、使い方をドキュメントにまとめて共有会を開催するといったことを考えています。 まとめ 本記事では、障害時の調査などに活用できるSQL Serverロギングの仕組み作りについて説明しました。この仕組みによってSQL Server起因のトラブルの原因特定率が劇的に向上しました。弊社で使用しているロギング用のクエリをOSSとしてGitHubに公開しておりますので、よろしければお使いください。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに こんにちは。SRE部MLOpsチームの築山( @2kyym )です。 Infrastructure as Code(IaC)が一般的になり、またパブリッククラウドをフル活用したインフラ構築が当たり前となりつつあります。そんな中で、インフラの構成管理にTerraformを用いているチームも多いのではないでしょうか。本記事ではTerraformを用いたインフラ構成管理において避けては通れないTerraformやProviderのバージョンアップを自動化し、IaCの運用負荷を削減する方法をご紹介します。MLOpsチームでの運用を参考に、具体的な設定やハマりどころを交えつつ解説できればと思います。 目次 はじめに 目次 Terraformとは MLOpsチームにおけるTerraform運用の背景 Terraform管理の対象リソース Terraform運用において生じた課題 tfupdateの紹介と使い方 tfupdateを用いたTerraform本体とProviderのバージョン更新 自動化の方針 GHAワークフローにtfupdateを組み込む Branchのセットアップ tfupdateのセットアップ tfupdateの実行とCommit & Push Pull Requestの作成 複数ディレクトリに対して実行する Providerのバージョンを更新する 意外と多いハマりどころとその解決策 バージョンアップに合わせてTerraformの依存ロックファイルの更新も必要 GHAワークフロー定義ファイル自体にもTerraformのバージョン定義がある GHAワークフロー定義ファイルの書き換えにはPersonal Access Tokenの権限が必要 GHAワークフロー内で実行されるCommitのAuthorが「直近main branchにMergeした人」となってしまう Tips:Workflow Dispatchを使おう おわりに Terraformとは Terraform とは HashiCorp Inc. が開発しているIaCを実現するためのオープンソースツールです。GCPやAWSなどパブリッククラウドのインフラ構成を始め、各種SaaS上の設定も含めてリソースという単位でコードとして定義できます。例えば以下はGoogle Cloud Storageバケットのリソースを定義する例です。 resource " google_storage_bucket " " sample-bucket " { name = " sample-bucket " location = " asia " } このような定義を記述したファイル(以下tfファイル)を用意し、インフラ構成を適用するコマンドである terraform apply を実行することで、リソースの作成・変更・削除が行われます。 MLOpsチームにおけるTerraform運用の背景 まずTerraformのバージョンアップの自動化について述べる前に、MLOpsチームにおけるTerraform運用の背景をご紹介します。チームの状況を踏まえ、運用にどういった課題が生じていたのか説明します。 Terraform管理の対象リソース MLOpsチームの関わるプロジェクトでは基本的にGCP(Google Cloud Platform)を用いてインフラを構築しています。そしてGCPのリソースは、基本的にすべてTerraformを用いてGitHubでコード・バージョン管理をしています。また Datadog や Sentry 、 PagerDuty などSaaS上の設定に関しても同様にTerraformを用いてGitHub上で管理しています。 これらのtfファイルとKubernetesのマニフェストなどを合わせてインフラ用リポジトリとし、プロジェクトや機能ごとに存在しています。このリソース定義やGKEマニフェストは、それを変更するPull Request(以下PR)のMergeをトリガーにして、自動的にデプロイされるようになっています。この自動化にはGitHub Actionsのワークフロー(以下GHAワークフロー)を用いています。 Terraform運用において生じた課題 ここでMLOpsチームを取り巻く状況について少し説明します。 以前MLOps基盤に関して取り上げた記事でも説明したとおり、ここ1年でMLOpsチームの関わるプロジェクトや機能の数はどんどん増えています。それに伴ってインフラ用リポジトリも増えているという状況があります。 techblog.zozo.com そしてTerraform運用において避けては通れないのがTerraform自体のバージョンアップと、各種リソース定義を提供するProviderのバージョンアップです。 MLOpsチームでは月次でGKEやTerraformのバージョンアップを行う運用がされており、この作業はチームメンバーが手動で実行していました。この作業自体は各tfファイルやGHAワークフロー定義ファイル 1 に記述されているTerraformやProviderのバージョンを書き換えるという単純なものです。しかし、インフラ用リポジトリの数が増えるに従ってこのバージョンアップの運用負荷も大きくなるのが課題でした。バージョンアップの頻度を落とすという選択肢もありますが、古いバージョンを使用する期間が長くなると以下のようなリスクが発生します。 一度のバージョンアップで変更されるコード量が多くなるので、レビュー作業が大変 非推奨設定を推奨設定へ変更するために設けられたバージョンを飛ばしてしまい、非推奨設定が警告なしに使用不可となる そこで、自動化することによってこのようなリスクを減らしつつ運用負荷も削減したいと考えました。 tfupdateの紹介と使い方 今回の課題を解決するために、GitHubで公開されているオープンソースのCUIツールである tfupdate を使用しました。このツールの機能はシンプルで、tfファイルに記述されているTerraform/Provider/Moduleのバージョンを一括更新してくれるというものです。詳細な使い方についてはtfupdate開発者である minamijoyo さんの記事に譲りますが、この章では簡単なサンプルを紹介します。 qiita.com tfupdateを用いたTerraform本体とProviderのバージョン更新 例として、以下のバージョン定義を含むtfファイルを想定します。これらのバージョンは2021/08/01時点での最新バージョンです。 terraform { required_version = "1.0.3" required_providers { google = "3.77.0" google-beta = "3.77.0" } } まずTerraform本体のバージョンを最新版に更新するには、tfファイルを含むディレクトリの配下で以下のコマンドを実行します。 $ tfupdate terraform . もしくは以下のように、バージョンと更新対象を指定できます。 $ tfupdate terraform -v 1 . 0 . 5 main.tf コマンド実行後はTerraformのバージョンが2021/08/30時点での最新である 1.0.5 に更新されています。 terraform { required_version = "1.0.5" required_providers { google = "3.77.0" google-beta = "3.77.0" } } 次に、Providerのバージョンアップです。Providerを更新するには、同じディレクトリ配下で以下のコマンドを実行します。 $ tfupdate release latest -s tfregistryProvider hashicorp/google > 3 . 82 . 0 $ tfupdate release latest -s tfregistryProvider hashicorp/google-beta > 3 . 82 . 0 $ tfupdate provider google -v 3 . 82 . 0 . $ tfupdate provider google-beta -v 3 . 82 . 0 . Terraform本体に対するバージョンアップとは異なり、Providerのバージョンアップでは、バージョン番号の指定が必須です。各Providerの最新バージョンは、上のように latest の指定で取得できますので、それを用いてProviderをバージョンアップします。最終的に以下のように更新が完了しました。 terraform { required_version = "1.0.5" required_providers { google = "3.82.0" google-beta = "3.82.0" } } 自動化の方針 ここからが本題です。運用負荷が高くなりつつあるTerraformのバージョンアップを自動化するため、以下の方針を取りました。 先述したtfupdateとGHAを組み合わせてバージョンアップを自動化する GHAワークフローの実行頻度は、現状の更新頻度と合わせて月次とする PRを作成し、チームメンバーにレビューを依頼するところまでを責務とする PR毎に実行されるCI/CD用のGHAワークフローとは別に、独立したGHAワークフローを作成する方針としました。現状の更新運用に合わせ、Terraformのバージョン更新はPRのMerge毎ではなく月次で行うためです。 なお、バージョンアップによってSyntaxなどに変更が生じる場合でも、tfupdateは関知しません。tfupdateはあくまでもバージョンのみを意識します。Syntaxの不整合によってGHAワークフローでエラーが発生した際はチームメンバーが手動修正する運用です。 以下の章ではこの方針に沿ったGHAワークフローの実装を順を追って説明していきます。また、実装を始めてみるとハマりどころが多かったので、後半にそれらの解決策もまとめています。Terraformのバージョンアップに限らず、GHAを用いた自動化全般で必要なTipsも含みます。 GHAワークフローにtfupdateを組み込む この見出しでは実際のGHAワークフローを例に挙げながら、tfupdateとシェルスクリプトを用いたバージョンアップの自動化について説明します。後述するハマりどころを避けるため、意図的に記述を簡素化している部分があります。ご了承ください。 Branchのセットアップ 実際のGHAワークフロー定義ファイルを示しつつ、GHAワークフローの処理単位であるStep毎に説明していきます。まずはtfupdateのセットアップと、Branchの準備をしている部分です。 steps : - id : check-branch # NOTE : Shows lots of warnings because of https://github.com/octokit/request-action#warnings uses : octokit/request-action@v2.x with : route : GET /repos/:repository/git/ref/:ref repository : ${{ github.repository }} ref : heads/${{ env.TFUPDATE_BRANCH }} continue-on-error : true - uses : actions/checkout@v2 if : steps.check-branch.outputs.status == 200 with : ref : ${{ env.TFUPDATE_BRANCH }} - uses : actions/checkout@v2 if : steps.check-branch.outputs.status != 200 with : ref : ${{ env.TFUPDATE_BASE_BRANCH }} - name : Create tfupdate branch if not exist if : steps.check-branch.outputs.status != 200 run : | git branch ${{ env.TFUPDATE_BRANCH }} git branch --set-upstream-to=origin/${{ env.TFUPDATE_BASE_BRANCH }} ${{ env.TFUPDATE_BRANCH }} まず最初にtfupdateによる変更をCommitするBranch( env.TFUPDATE_BRANCH )を用意します。この時、前回の実行時に作られたtfupdateのPRがまだMergeされていないというケースをカバーするため、条件分岐を作ります。 TFUPDATE_BRANCH が既にある場合はCheckout 無い場合はBranchを切る(すなわち git checkout -b ) actions/checkout の最新版であるv2では、存在しないBranchを指定した場合にエラー( check-branch Stepのステータスが非200)を返します。この仕様を利用して、分岐します。 tfupdateのセットアップ 次にtfupdateコマンドを利用可能にするための処理を示します。 - name : Setup tfupdate from binary run : | set -o pipefail wget -P /tmp ${TFUPDATE_BINARY} basename ${TFUPDATE_BINARY} | xargs -I {} tar xvf /tmp/{} -C /tmp sudo mv /tmp/tfupdate /usr/local/bin sudo chmod +x /usr/local/bin/tfupdate tfupdateのセットアップ自体はシンプルで、リポジトリからバイナリを取得して /bin ディレクトリ配下に配置しているだけです。 当初はDocker Hubに公開されている tfupdateのImage を用い、tfupdateのセットアップを省略する予定でした。しかしこのImageではTerraformコマンドの実行に必要な hashicorp/setup-terraform プラグインを実行することができません。tfupdateのImageはalpineベースですが、 setup-terraform はalpineベースのImageでは実行がサポートされていないためです。そのため、上記のバイナリを取得して展開する方針に転換しました。 tfupdateの実行とCommit & Push - name : Get the commit sha before tfupdate id : before-sha run : | echo ::set-output name=sha::$(git log --pretty=%H | head -n1) - name : (${{ env.TF_KIND }}) tfupdate terraform . run : | cd ${TF_DIR} tfupdate terraform . - uses : EndBug/add-and-commit@v7 with : branch : ${{ env.TFUPDATE_BRANCH }} message : "Update terraform/${{ env.TF_KIND }}: tfupdate terraform ." - name : Get the commit sha after tfupdate id : after-sha run : | echo ::set-output name=sha::$(git log --pretty=%H | head -n1) ここでは、tfupdateコマンドを実行して、envに定義した TFUPDATE_BRANCH へ変更差分をCommitします。 set-output はワークフローコマンドの1つです。他のStepで利用する値を設定できます。この例では、tfupdateコマンドの実行前後でCommitハッシュ値を取得しています。変更差分がある時のみ後続のStepでPRを作成するためで、 set-output したCommitハッシュ値を後続のStepで参照し、分岐しています。 Pull Requestの作成 - uses : repo-sync/pull-request@v2 if : ${{ steps.before-sha.outputs.sha != steps.after-sha.outputs.sha }} with : source_branch : ${{ env.TFUPDATE_BRANCH }} destination_branch : ${{ env.TFUPDATE_BASE_BRANCH }} pr_title : Update Terraform Version & Terraform Providers Version pr_body : | **This pull request is created automatically by the CI: [ tfupdate ] (https://github.com/${{ github.repository }}/actions?query=workflow%3Atfupdate)** --- Run the below code to try this changes locally. \`\`\` cd path/to/${{ github.repository }} gh pr checkout ${{ env.TFUPDATE_BRANCH }} \`\`\` pr_label : tfupdate pr_allow_empty : false 先程Commitした TFUPDATE_BRANCH から TFUPDATE_BASE_BRANCH (通常はmain branch)に対してPRを作成するStepです。tfupdateの前後で取得したCommitハッシュ値を比較し、差分がある時のみ実行します。 このStepによって作成されたPRを、運用メンバーが確認してMergeするだけでバージョンアップ完了です。 さて、ここまでの記述で実現できるのはTerraform本体のバージョンアップです。実際には、加えて以下の考慮も必要です。 Terraformディレクトリは基本的に複数存在する 先述したようにGCPリソース以外もTerraformで管理しているため Terraform自体のバージョンだけでなく、使っているProviderのバージョンも更新が必要 google 、 sentry 、 datadog など これらについて次の見出しで述べていきます。 複数ディレクトリに対して実行する 以下にtfファイルを配置するディレクトリの構成を示します。GCPリソースに加え、SaaSごとのディレクトリが存在します。 └── terraform/ ├── gcp/ ├── monitoring/ └── sentry/ そして以下のYAMLが、複数ディレクトリに対して実行するための実際の定義です。 jobs : tfupdate-terraform : runs-on : ubuntu-latest strategy : fail-fast : false max-parallel : 1 matrix : tfupdate : - KIND : gcp - KIND : monitoring - KIND : sentry env : TF_KIND : ${{ matrix.tfupdate.KIND }} TF_DIR : ./terraform/${{ matrix.tfupdate.KIND }} steps : - id : check-branch # snip 複数ディレクトリに対するtfupdateの実行については、 Matrix Build という機能を利用します。 strategy.matrix に定義した変数によって単一Job定義内の変数を書き換え、複数のJobを作成します。ここで言うJobとはGHAワークフローにおける一連のStepの組み合わせを指し、GHAワークフローは複数のJobを直列もしくは並列に実行できます。 例えば、上のように tfupdate.KIND にディレクトリ名を複数定義すると、Job tfupdate-terraform は合計3回実行され、Jobや各Stepでそれらの値を参照できます。 tfupdate-terraform はBranchの存在確認からPRを作成するまでの一連をまとめたJobです。対象のディレクトリ名を除けば処理は同一なため、冗長なGHAワークフローの記述を避けることができます。 最終的に、各ディレクトリに対する変更全てを1つのPRにまとめます。Jobの中にPRを作成するStepを含むため、PR作成のタイミングが衝突しないように max-parallel は1に設定しています。このMatrix Buildで実行される3つのJobは上から順に直列実行されます。 Providerのバージョンを更新する Providerの更新をするための方針は以下の通りです。 複数ディレクトリ配下に存在する複数Providerのバージョンを更新するために、Matrix Buildにディレクトリ名とProvider名を定義する Provider更新の処理は、Terraform自体の更新とは別のJobに分離する バージョンアップ処理の重複を防ぐため 以下のYAMLが、実際の定義です。 tfupdate-provider : runs-on : ubuntu-latest needs : [ tfupdate-terraform ] strategy : fail-fast : false max-parallel : 1 matrix : tfupdate : - KIND : gcp PROVIDER : hashicorp/google - KIND : gcp PROVIDER : hashicorp/google-beta - KIND : monitoring PROVIDER : hashicorp/google - KIND : monitoring PROVIDER : hashicorp/google-beta - KIND : sentry PROVIDER : jianyuan/sentry env : TFUPDATE_PROVIDER : ${{ matrix.tfupdate.PROVIDER }} TF_KIND : ${{ matrix.tfupdate.KIND }} TF_DIR : ./terraform/${{ matrix.tfupdate.KIND }} ProviderをバージョンアップするJobは、先述のTerraformをバージョンアップするJobとは別のJobとして定義し、本体→Providerと直列で実行するようにしました。Jobをまとめてしまうと、Matrix BuildによってTerraform本体のアップデートが同じディレクトリ内で何度も実行されてしまうためです。上の例では gcp と monitoring が2回ずつ呼び出されるため、Jobが1つの場合は tfupdate terraform も複数回実行されていまいます。 Jobに含まれるStepは、以下のとおりです。 - name : Get the commit sha before tfupdate id : before-sha run : | echo ::set-output name=sha::$(git log --pretty=%H | head -n1) - name : (${{ env.TF_KIND }}) tfupdate release latest -s tfregistryProvider ${{ env.TFUPDATE_PROVIDER }} . id : get-latest-version run : | cd ${TF_DIR} echo ::set-output name=version::$(tfupdate release latest -s tfregistryProvider ${TFUPDATE_PROVIDER}) - name : (${{ env.TF_KIND }}) tfupdate provider ${{ env.TFUPDATE_PROVIDER }} . run : | cd ${TF_DIR} tfupdate provider ${TFUPDATE_PROVIDER} -v ${{ steps.get-latest-version.outputs.version }} . - uses : EndBug/add-and-commit@v7 with : branch : ${{ env.TFUPDATE_BRANCH }} message : "Update terraform/${{ env.TF_KIND }}: tfupdate provider ${{ env.TFUPDATE_PROVIDER }} ." - name : Get the commit sha after tfupdate id : after-sha run : | echo ::set-output name=sha::$(git log --pretty=%H | head -n1) 更新の実行前後にCommitハッシュ値の差分を取得しているのは、本体のアップデートと同様です。 冒頭で述べたように、Providerの更新はバージョンを必ず指定する必要があります。そのため tfupdate release latest を実行して取得したバージョンの値を ::set-output し、 tfupdate provider 実行時に参照する方法としました。なお TF_KIND 、 TFUPDATE_PROVIDER はMatrix Buildで定義したものをenvに渡した値です。 ここまでの記述で、tfファイルに記載されているTerraformとProviderのアップデート自動化ができました。 意外と多いハマりどころとその解決策 この見出しでは、GHAワークフローを実際の運用に乗せるにあたって出てきたハマりどころとその対処法について説明します。一見すると先のGHAワークフローを定期実行するだけで要件は満たせそうなのですが、運用する内に以下のような課題が分かってきました。 Terraformの依存ロックファイルもGitHubで管理しているため、バージョンアップに合わせてこちらの更新も必要だった GHAワークフロー定義ファイル自体にもTerraformのバージョン定義があり、tfupdateではこれを書き換えられない GHAワークフロー内で実行されるCommit Authorが「直近PRをMergeした人」となり、混乱を招く これらを順を追って説明してきます。 バージョンアップに合わせてTerraformの依存ロックファイルの更新も必要 Terraform v0.14以降、 .terraform.lock.hclという依存ロックファイル が導入されており、私達のチームではtfファイルだけでなくこちらもGitHubで管理しています。この依存ロックファイルは terraform init 実行時に自動生成されるもので、 公式ドキュメント でもGitHubなどでバージョン管理が推奨されています。 provider " registry.terraform.io/hashicorp/google " { version = " 3.77.0 " constraints = " 3.77.0 " hashes = [ ..., ] } 上記のようにProviderのバージョンも含むため、こちらもバージョンアップに合わせて更新が必要です。 - name : (${{ env.TF_KIND }}) Update .terraform.lock.hcl provider ${{ env.TFUPDATE_PROVIDER }}) if : ${{ steps.before-sha.outputs.sha != steps.after-sha.outputs.sha }} run : | cd ${TF_DIR} terraform init -upgrade terraform providers lock -platform linux_amd64 -platform darwin_amd64 - uses : EndBug/add-and-commit@v7 if : ${{ steps.before-sha.outputs.sha != steps.after-sha.outputs.sha }} with : branch : ${{ env.TFUPDATE_BRANCH }} message : "Update terraform/${{ env.TF_KIND }}/.terraform.lock.hcl: provider ${{ env.TFUPDATE_PROVIDER }})" 方法はシンプルで、Providerを更新するJobに上のStepを足すことで実現できます。 terraform init -upgrade を実行すると更新後のtfファイルに基づいて依存ロックファイルが再生成されます。 また terraform providers lock によって開発時にローカルで terraform init を行った際に依存ロックファイルが変更されてしまうことを防いでいます。 terraform init と依存ロックファイルの詳しい挙動については minamijoyoさんのスライド を参照ください。 GHAワークフロー定義ファイル自体にもTerraformのバージョン定義がある tfupdateコマンドを実行することで、tfファイルに記載されているTerraformとProviderのバージョン更新の自動化ができました。しかしインフラ用リポジトリでは、tfファイルだけでなくGHAワークフロー定義ファイルにも以下のようにTerraformのバージョン記述があります。 - uses : hashicorp/setup-terraform@v1 with : terraform_version : "1.0.3" PR作成時やMerge時に実行されるGHAワークフローで、インフラ構成を環境に適用するため terraform plan や terraform apply を実行しています。tfupdateではtfファイル内のバージョンアップのみをサポートしているため、GHAワークフロー定義ファイルにあるバージョン記述は書き換えることができません。そこで、以下のようなワンライナーを用意しました。 - name : Update terraform_version in GHA yamls if : ${{ steps.before-sha.outputs.sha != steps.after-sha.outputs.sha }} run : | sed -i -e "s|terraform_version: [0-9. \" ']\+|terraform_version: \" $(tfupdate release latest hashicorp/terraform) \" |g" .github/workflows/*.yaml このワンライナーでは .github/workflows/ 以下にあるGHAワークフロー定義ファイル setup-terraform でのバージョン指定を書き換えています。このStepを tfupdate terraform の後ろに置くことで、tfファイルの更新と同じタイミングでGHAワークフロー定義ファイルも更新します。 GHAワークフロー定義ファイルの書き換えにはPersonal Access Tokenの権限が必要 次の問題は、権限不足です。 当初はStep実行時のトークンとして secrets.GITHUB_TOKEN 2 を利用していました。このトークンはリポジトリ毎に自動生成されるもので、リポジトリに対して一定の権限が付与されており、GHAワークフロー内での認証に使用できます。 ところが、実際にワンライナーでGHAワークフロー定義ファイル setup-terraform の書き換えを行い、 GITHUB_TOKEN でCommit & Pushを行おうとすると以下のようなエラーが発生してしまいました。 ! [ remote rejected ] HEAD - > automated-tfupdate ( refusing to allow a GitHub App to create or update workflow`.github/workflows/main.yaml`without`workflows`permission ) ドキュメントに記載された権限内容 を確認すると、 GITHUB_TOKEN には workflows スコープの権限が付与されていません。表に記載されていないスコープの権限が必要であったり、他のリポジトリに対する権限を必要な場合は GITHUB_TOKEN でなくPersonal Access Token (以下PAT)が必要です。 チームメンバーのGitHub Accountに紐付いたPATも利用できますが、権限の変更が本人にしかできなかったり、退職時にExpireしたりと、管理上の問題があります。そのため、マシンアカウントを用いてチーム向けのBot GitHub Accountを作成することにしました。このBotアカウントにチームメンバーと同様の権限( workflows スコープを含む)を付与することで、GHAワークフロー定義ファイルの書き換えが可能となりました。 GHAワークフロー内で実行されるCommitのAuthorが「直近main branchにMergeした人」となってしまう 最後はTerraformのバージョン更新とは少し外れて、GHAを用いて自動でCommitやPRを作成する際にハマるポイントです。 tfupdateを実行するGHAワークフローで自動生成されたPRには、tfファイルや依存ロックファイルの更新など複数のCommitが含まれています。BotアカウントがCommit Authorになることを想定していましたが、実際は以下のように直近main branchにMergeした人がAuthorになってしまいました。 上の画像ではPR作成者はBotアカウントですが、Commit Authorは直近mainに対してMergeを行った自分のアカウントとなってしまっています。 この問題は、 add-and-commit Stepで以下のようにBotアカウントの登録情報を渡すことで解決します。 - uses : EndBug/add-and-commit@v7 with : branch : ${{ env.TFUPDATE_BRANCH }} message : "Update terraform/${{ env.TF_KIND }}: tfupdate terraform ." author_name : ${{ env.BOT_USERNAME }} author_email : ${{ env.BOT_EMAIL }} Tips:Workflow Dispatchを使おう 皆さん Workflow Dispatch はご存知でしょうか。 ドキュメント上では地味な記載があるだけの機能ですが、これを設定することでGitHubのUIから以下の画像のように特定のワークフローを手動実行できます。 今回のtfupdateのような、単発で実行したいケースがある自動化系のGHAワークフローにおいては特に有用です。通常のGHAワークフローでも、大きな変更を入れた後のデバッグ時に便利だったりと、使い勝手の良い機能です。設定に必要な記述も以下の通りとてもシンプルですので、気軽に導入してみてはいかがでしょうか。 on : workflow_dispatch : schedule : - cron : '0 0 1 * *' # monthly おわりに 本記事ではGHAとtfupdateを利用し、Terraformを用いたインフラ構成管理におけるTerraform周りのバージョンアップを自動化する方法をご紹介しました。 要件はシンプルなものでしたが、いざGHAワークフローを動かしてみると、Terraformに限らずGHAを用いた諸々の自動化で考慮が必要な点が多く、それらを含めたまとめ記事になりました。複数プロジェクトにおいてTerraformを積極的に使っており、バージョンアップを面倒に感じつつある、もしくは放置してしまっている方は是非参考にしていただければと思います! 最後まで読んでいただきありがとうございました。 ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! https://tech.zozo.com/recruit/ tech.zozo.com hrmos.co この文章ではGHAワークフロー定義ファイルとは .github/workflows 以下に配置するyamlファイルで、GHAワークフローの処理対象や処理内容を記載するものを指します。 ↩ ワークフローで認証する - GitHub Docs ↩
アバター
こんにちは、ZOZOテクノロジーズ技術戦略室の光野( @kotatsu360 )です。 ZOZOテクノロジーズでは、9/9に ZOZO Tech Meetup〜ZOZOTOWNアーキテクトナイト〜 を開催しました。 zozotech-inc.connpass.com このイベントでは、ZOZOTOWNの開発においてアーキテクトとして活躍しているメンバーから、「アーキテクチャ設計」にフォーカスして技術選定や設計手法、設計時の考え方などについて具体的な事例を交えながらお伝えしました。 登壇内容まとめ 弊社の社員4名が登壇しました。 これからのZOZOTOWNを支えるログ収集プラットフォームを設計した話 (SRE部 データ基盤 / 塩崎 健弘) ZOZOTOWNマイクロサービス基盤のService Meshアーキテクチャへの移行 (SRE部 ECプラットフォームSRE / 川﨑 庸市) ZOZOTOWNマイクロサービス化に向けたサービス粒度の話 (ECプラットフォーム部 / 高橋 智也) ZOZOTOWNのアーキテクトという役割を紹介します (アーキテクト部 アーキテクト / 岡 大勝) ZOZOTOWNのアーキテクトという役割を紹介します from Hiromasa Oka 最後に ZOZOテクノロジーズでは、プロダクト開発以外にも今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは。SRE部データ基盤チームの塩崎です。ZOZOテクノロジーズではGCPの管理を各プロジェクトのOwnerに任せていた時期が長く続いていましたが、今期から全社的なGCP管理者を立てることになりました。本記事では新米GCP管理者である僕が全社的なGCPの管理をする上で遭遇した事例を紹介します。時には泥臭い方法で、時にはプログラムの手を借りて自動化をし、数々の難題に対処しました。 GCPのリソース階層について 具体的な事例紹介の前に、GCPのリソース階層を説明します。多くのGCP利用者からは、プロジェクトが最上位のリソースであるように見えますが、実はそれ以上の階層が存在します。以下の図をご覧ください。図の通り、プロジェクトの上位リソースとしてFolder、Organizationという2つのリソースが存在します。 cloud.google.com Folderはプロジェクトの論理的なまとまりを作るもので、組織の階層構造を反映するのがベストプラクティスとされています。AWS Organizationsを使ったことがある方は、Organizational Unitに近いものだとお考えください。 OrganizationはGCPにおける最上位リソースです。Google Workspaceのドメインと対応しており、両者の間でアカウント情報などが連携されます。ZOZOテクノロジーズの場合は、親会社であるZOZOと共通のGoogle Workspaceドメインに所属しており、それが唯一のOrganizationです。 また、GCPの権限に関する重要な概念として、「継承」というものがあります。上位リソースに対して付与した権限が、自動的に下位リソースにも伝搬するというものです。つまり、最上位リソースであるOrganizationのAdministrator権限があれば、その配下の全てのGCPプロジェクトを操作できます。これ以降のGCP管理者としての業務は、Organization Administrator権限を使って行います。 MyFirstProject大量発生 最初に紹介する事例は、MyFirstProjectの大量発生です。Organization Administrator権限を入手した直後にプロジェクト一覧の画面を見て絶句しました。MyFirstProject、MyProject、QuickStartなどの名前のプロジェクトが約100個ありました。これらのプロジェクトはGCPのチュートリアルを行うためのもので、既に不要になっているものがほとんどでした。 対処 各プロジェクトの作成者に連絡をし、削除を依頼しました。チュートリアル用途がほとんどでしたので、一定の期間に返答がない場合は、GCP管理者側でプロジェクトを削除しました。なお、この当時はまだgcloudコマンドに慣れていなかったため、Webコンソールで1つ1つ、プロジェクトのOwnerを確認していました。 原因 この件の原因の1つは、GCPの公式チュートリアルの中にプロジェクトの作成が含まれていることです。チュートリアルの最後にはプロジェクトの削除について書かれていますが、実際には多くの人が削除を忘れていました。 cloud.google.com 再発防止 再発防止として、プロジェクト作成権限の見直しを行いました。GCPの初期状態では、Organizationに所属する全員がプロジェクト作成権限(Project Creatorロール)を持ちます。そのため、これを社内で数名のみに絞りました。 なお、この操作によってサポートケースを起票できなくなるという問題が発生してしまったので、同様の対応をする場合は以下の記事も参照ください。 qiita.com 退職者の権限が残っていた 次は、退職者の権限が残っていたという事例です。何気なく各プロジェクトのCloud IAM一覧を見ていたところ、懐かしい名前を発見しました。数ヶ月前に退職した人のアカウントでした。 GCPのアカウント情報はAzure ADとの間でSAML連携されており、退職のタイミングでAzure AD側が無効化されます。そのため、退職後もアクセスできていたということはないのですが、望ましい状態ではありません。 対処 MyFirstProject大量発生の対応と同様にWebコンソールで1つ1つ確認することは非現実的だと考え、退職者の洗い出しバッチを作成することにしました。 ZOZOとZOZOテクノロジーズの従業員情報はKintoneで保管されているので、まずはKintoneから退職者情報を取得します。KintoneのAPIクライアントとして以下のライブラリを使いました。 github.com Cloud IAMの情報はgcloudコマンドで取得しました。プロジェクトレベルの権限は以下のコマンドで取得可能です。コマンドの出力結果がYAMLなので、その出力結果をRubyで処理してKintoneから取得した結果と突き合わせを行いました。 cloud.google.com なお、上のコマンドではプロジェクトレベルの権限しかチェックされないことに注意する必要があります。つまり、BigQueryのデータセットや、GCSバケットなどのリソースレベルの権限は見逃されてしまいます。そのため、追加で以下のコマンドの結果と退職者情報の突き合わせを行いました。 cloud.google.com 以下のように、scopeをOrganization全体にすると、リソースレベルの権限も全て確認できます。 gcloud asset search-all-iam-policies --scope= ' organizations/<Organization ID> ' --query= ' policy:* ' 原因 退職者の権限削除が各GCPプロジェクトの管理者に任されており、運用レベルに「ムラ」が生じていたことが原因でした。退職者管理は、全GCPプロジェクトで共通に行うべき運用作業です。 再発防止 再発防止として、GCP全社管理者で一律して退職者権限を削除するバッチを作成しました。月次でこのバッチを実行し、前月の退職者権限を自動的に削除しています。 GCPのリソース階層(完全版) 最後の事例紹介をする前に、改めてGCPのリソース階層について触れます。実は、この記事の冒頭で説明したリソース階層には一部の情報が不足しています。それは課金系リソースに関する情報です。 cloud.google.com 課金系のリソースはプロジェクトやサーバーなどとは異なるリソースツリーを持っています。ここで重要なリソースはBilling AccountとPayments Profileの2種類です。これらは同時に作成されることが多いため、同一視されがちですが、厳密には異なるものです。 Billing Accountはプロジェクトで発生した料金の請求先アカウントです。プロジェクトと直接関係を持つのはBilling Accountであり、このリソースはGCPの中にあります。そのため、Organization Administratorの権限でOrganization内の全てのBilling Accountを操作できます。なお、Billing Accountは監査のため削除できません。 一方のPayments Profileは、GCP外のリソースです。全てのGoogleサービスの支払いを管理するリソースで、支払い方法(クレジットカード番号・銀行口座)や請求先住所・氏名などは、このPayments Profileによって管理されています。Billing Accountとは異なり、Payments Profileは削除可能です。 Billing Account大量発生 最後に紹介するのは、Billing Account大量発生です。全社のGCPの請求額を確認しようとCloud Billingの画面を確認したところ、数十個のBilling Accountを発見しました。各Billing AccountのAdministratorに確認をしたところ、MyFirstProjectを作成する際に間違ってBilling Accountを作成してしまったようです。また、この時にPayments Profileも作成してしまったようでした。この誤って作成されたPayments Profileには個人の住所・クレジットカード情報などが登録されていました。 Payments Profileは原則本人しか見えず、加えてクレジットカード情報はマスク化されているものの、組織のGCPで保持すべきでない情報です。なお、幸いにも全てのBilling Accountに紐づくプロジェクトは無料試用枠の中でのみ使われていたため、課金は発生していませんでした。 対処 Billing AccountとPayments Profileで対処法が異なるので、それぞれ説明します。 先の通り、Billing Accountは削除できないという特徴があります。そのため、誤って作成されたBilling Accountに対しては、以下を行いました。Billing AccountはGCP内部のリソースなので、これらの操作は全てOrganization Administrator権限で実行可能です。 作成者の権限削除 名前を【使用禁止XX】に変更 Billing Accountの閉鎖 cloud.google.com 次にPayments Profileを説明します。Payments Profileは削除可能ですが、GCP外のリソースのため、Organization Administrator権限をもってしても削除できません。そのため、1つ1つ、作成した本人から権限を委譲してもらいながら以下の手順に従って削除をおこないました。Payments Profileを削除することで、クレジットカード情報や住所氏名などの個人情報も削除されます。 support.google.com 原因 この原因はMyFirstProject大量発生と同様です。請求アカウントの作成権限が全員に与えられていたことが原因でした。 再発防止 再発防止のため、請求アカウントの作成権限をGCP全社管理者の数人に限定しました。プロジェクト作成権限を持っている数人と同じメンバーです。 まとめ 新米GCP管理者として遭遇した数々の事例を紹介しました。ここ数ヶ月でいくつもの負債を返済し、マイナスをゼロにするため奮闘しました。今期は守りの運用に終始してしまいましたが、来期はもっと攻めの運用をできればと思っています。 ZOZOテクノロジーズではGCPの運用に興味のある方、運用作業を自動化して楽したい方を募集中です。ご興味のある方のご応募お待ちしております。 hrmos.co
アバター
こんにちは、EC基盤本部SRE部ZOZOSREチームの石川です。 普段はZOZOTOWNのオンプレミスとクラウドの構築・運用に携わっています。 ZOZOTOWNには長い歴史がありますが、その中核を成すWebアプリケーションのアーキテクチャは、サービス開始当初から現在に至るまで大きく変わらず稼働しています。 一方で、インフラは少しずつ変わっています。高負荷となるセールやイベント時のスケールアウトするために、またハードウェアのライフサイクルに合わせる形で、物理サーバ → 仮想基盤 → クラウドと徐々に技術が変遷しています。 本記事では、クラウドへのスケールアウトを加速させるために、オンプレミスで稼働中のWebサーバをAmazon EC2(以下、EC2という)で動作させるまでの取り組みを紹介します。 スケールアウトに向けたZOZOTOWNの課題 ZOZOTOWNのWebサーバは、Windows Serverで稼働しています。 アプリケーションの大部分はClassicASPで成り立っており、これを短期間でモダンなプログラムにリプレースすることは困難を極めます。 ZOZOTOWNでは、現在リプレースプロジェクトが進んでいますが、アプリケーションの改修を待たずにスケールアウトしていく仕組みを模索していました。 VMware Cloud on AWS 一昨年よりオンプレミスからのクラウドへのスケールアウトとしてVMware Cloud on AWSを利用しています。 直接的なサーバ構成を意識せずにクラウドを活用していく一歩としての選択です。 L2延伸させることでAWS上にオンプレミスを疑似的に拡張でき、従来の負荷対策よりも柔軟な対応をすることが可能になりました。 より詳細な情報は下記に記載されていますので興味のある方はそちらを参照してください。 aws.amazon.com しかし、いくつかの課題が残っていました。 L2延伸部分やホストの構成をする必要があり、オンプレミスでの拡張からはかなり短縮になったとはいえ、まだまだ構築開始からサービス投入までの時間がかかります。 また、VMを稼働させるESXのホストの負荷状況を管理する必要もあります。 そこで、サービス投入までの時間の更なる短縮と、管理負荷を軽減するためにEC2を活用することにしました。 EC2にはAWS CloudFormationなど、インスタンスの設定をテンプレートで管理できるサービスもあり、より迅速にスケールできそうです。 EC2を活用するために 現在運用しているVMware Cloud on AWSと比較して異なる点は「L2延伸をしない」というところにあります。 通信自体はAWS Direct Connectを利用しての専用線ですが、今までの内部通信が別のセグメントを通じての通信となります。 そのためSplunkを利用して既存のWebサーバの通信先を洗い出し、Firewallの許可やルーティング設定を入れることでVMware Cloud on AWSと同様に通信できるようにしました。 Splunkについては、過去にも記事をまとめていますので、興味がある方はそちらを参照してください。 techblog.zozo.com techblog.zozo.com EC2 Windows Serverの自動構築 テンプレートの初期化 サーバがWindows Serverという都合上、SID(識別子)が重複してしまいます。そのため、初期化した状態でAMIにしておく必要があります。 初期化にはAmazon EC2が提供するEC2Configを利用し、Sysprepや管理者パスワードの初期化を実施します。 docs.aws.amazon.com EC2Configにはユーザーデータの実行という機能があり、設定していると起動する度に指定したPowerShellを実行してくれます。 docs.aws.amazon.com ユーザーデータのスクリプトはbase64でエンコード/デコードされます。 cat スクリプト名 | base64 なお、ユーザスクリプトの要注意ポイントが、マルチバイト文字の取り扱いです。 日本語があるとbase64デコードの際に文字化けしてうまく動作しないため、コメントやイーサネット名での絞り込みには注意してください。 ドメインの参加有無や設定する順序が重要なパラメータはユーザーデータのスクリプトで、それ以外の設定はAMIを再作成して対応します。 ユーザデータの実行による初期設定 前述したEC2Configのユーザーデータの実行にてPowerShellを実行して、Windows Serverの初期設定を実施していきます。 起動する度に実行されることがポイントで、設定状況を見つつ次に進むよう、スクリプトを組んでいます。 プログラムの主なフローを下記に記載します。 Windows Serverの初期設定は、ホスト名の変更やドメイン参加など再起動を伴う処理が多くあります。 インスタンスの開始と共にスクリプトが実行され、設定していきますが今どこまでが完了しているかわかりません。 そのため各工程ごとにタグを作成・変更することにより、初期設定の進行状況を確認できるよう工夫しました。 $InstanceID = Invoke-RestMethod -Uri http:// 169.254.169.254 /latest/ meta-data / instance-id aws ec2 create-tags --resources $InstanceID --tags $Tags EC2上の情報を取得、設定するのに必要なAWSコマンドをOS上で実行するには、事前にAWSのセッション情報を取得することが必要です。 aws sts get-session -token -- duration-seconds xxxx AWS CLIは暗黙的にSTSを呼び出しますが、この時にOS上の時刻(JST)がUTC判定されてしまい、取得したセッション情報が即Expireしてしまうことがありました。 そのためスクリプトの最初で、JSTへの変更と時刻同期を行った後、明示的にSTSを呼び出しています。 net start w32time Start-Sleep -s 10 w32tm /resync /rediscover w32tm /query /status またドメインの参加に必要なユーザ、パスワードなどは全てAWS Secrets Managerへ保管し、スクリプトで取り出して利用します。 $EncryptKeyFromSecretsManager = aws secretsmanager get-secret -value -- secret-id TEAMNAME/KEYNAME --query 'SecretString' --output text $EncryptKeyArray = $EncryptKeyFromSecretsManager .Split( "," ) 一例としてドメイン参加部分のスクリプトを紹介します。 } elseif (( Get-WmiObject Win32_ComputerSystem).Domain - eq "WORKGROUP" ) { Echo "---Set hostname has done.---" Echo "---WORKGROUP---" Echo "---join Domain---" $domain = aws ec2 describe-instances -- instance-ids $InstanceID --query 'Reservations[0].Instances[0].{InitialSetting:Tags[?Key==`domain`].Value|[0]}' --output text if ( $domain - eq "join" ){ Echo "---join Domain---" (中略) $credential = New-Object System.Management.Automation.PsCredential "USERNAME" , "PASSWORD" Add-Computer -DomainName DOMAINNAME -Credential $credential #Domain joined.setting has done,create domain-status Tag. aws ec2 create-tags --resources $InstanceID --tags "Key=domain-status,Value=joined" Stop-transcript Restart-computer ユーザーデータとしてこれらのPowerShellスクリプトが実行され、自動で初期設定が完了します。 タグハンドリング タグは初期設定の状況確認のみならず、別用途でも利用します。 予め対象のタグになって欲しい状態を登録しておき、サーバを再起動することでそれに応じたアクションをとるような仕様になっています。 例えば対象のマシンをドメインから外したいとします。 その場合はWebコンソールから、domainタグの値を "join" → "unjoin"にして再起動をします。そうするとサーバはドメインを抜けた状態で起動します。 以下は、スクリプトでの実装例です。通常の初期設定ルートの他に、タグによる分岐を実装しています。 Echo "---Domain Leave---" $domain = aws ec2 describe-instances -- instance-ids $InstanceID --query 'Reservations[0].Instances[0].{InitialSetting:Tags[?Key==`domain`].Value|[0]}' --output text if ( $domain - eq "unjoin" ){ Echo "---Domain Leave---" (中略) $credential = New-Object System.Management.Automation.PsCredential "USERNAME" , "PASSWORD" Remove-Computer -Credential $credential -Force #Domain leaved.setting has done,create domain-status Tag. aws ec2 create-tags --resources $InstanceID --tags "Key=domain-status,Value=unjoined" Stop-transcript Restart-computer アップデートなどで不要になったサーバを終了させる前に、便利な機能です。 CIOps化 PoC初期段階ではWebコンソールにて構築をしていました。 現在は前述したユーザーデータのスクリプトも含め、AWS CloudFormationにて管理しています。 このCloudFormationテンプレートはGitHub上で管理されます。 ユーザーデータもテンプレートに記載します。 Resources : (中略) # -------------------------------------------------------------# # WindowsServerLaunchTemplate # -------------------------------------------------------------# WindowsServerLaunchTemplate : Type : AWS::EC2::LaunchTemplate Properties : LaunchTemplateName : !Sub "${Env}-${InstanceBaseName}-launch-template" LaunchTemplateData : (中略) UserData : Fn::Base64 : !Sub - | <powershell> (ユーザーデータのPowerShellスクリプト) </powershell> <persist> true </persist> スケールアウトする際は、別途作成したツールを用いて、必要なだけの AWS::EC2::Instance リソースをテンプレートに追加します。 EC2インスタンスの設定はLaunch Templateで管理されていますが、負荷分散などのため、このLaunch Templateが複数存在します。 インスタンスとLaunch Templateの紐付けを簡略化するためにツールを用意しました。 生成されたテンプレートをPushし、MasterブランチへマージするとAWS CloudFormation変更セットの作成・実行され、インスタンス作成から数分で自動構築が完了します。 まとめ 本記事ではZOZOTOWNのWebサーバをEC2上にWindows Serverで自動構築するために、検討したことについて紹介しました。 今まではオンプレミス中心の構築・運用でしたが、これを機にクラウドへと領域を広げて新しい経験や知識を得ることができたと感じます。 今後はAuto Scalingの導入やサービス投入までの自動化を進めていく予定です。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは。SRE部DATA-SREチームの塩崎です。Software Design誌の2021年9月号に弊社でのBigQuery活用事例を寄稿しましたので、書店などで見かけた際は購入していただけますと嬉しいです。 gihyo.jp さて、BigQueryはコンピュートとストレージを分離することで高いスケーラビリティを達成しているData WareHouse(DWH)です。しかし、そのアーキテクチャを採用したがゆえに権限モデルが複雑化し、初心者にとって理解の難しい挙動をすることもあります。この記事ではBigQueryの権限モデルをコンピュートとストレージの分離という観点から紐解きます。 なお、記事中に記載している費用は全てUS Multi Regionにおけるものです。asia-northeast-1 Resion(東京)とは異なりますので、ご注意ください。 よくあるエラーとそこから湧く疑問 BigQueryを使い始めた人が高確率で遭遇する問題として、「BigQuery Data Viewerロールを割り当てたのにも関わらずSELECT文が実行できない」というものがあります。MySQLなどのRDBにおけるGRANT SELECT ON 〜と同じような感覚で権限を割り当てると発生しやすい問題です。 このケースでは、上記の権限に加えてBigQuery Job User権限の付与で問題なくSELECTが実行できます。ここで以下の疑問が浮かびます。 なぜ片方の権限だけではエラーになってしまうのか これらの権限はセットで使うことが必須なのだろうか 片方の権限のみで問題ないケースはどのような時だろうか cloud.google.com 以降ではBigQueryのアーキテクチャに触れながらこれらの疑問に答えていきます。 BigQueryのアーキテクチャについて BigQueryのアーキテクチャとして特徴的なものは、下図に示すコンピュートとストレージの分離です。それぞれが独立してスケールすることで、高いスケーラビリティが実現されています。 実は権限を考えるときにもそれらが分離されているということを念頭に置くと理解しやすいです。そのため、ここからはBigQueryの権限をストレージに関するものとコンピュートに関するものに分けて解説していきます。 cloud.google.com Dremel: A Decade of Interactive SQL Analysis at Web Scale ストレージに関する権限 まずはストレージに関する権限です。ストレージに関する読み出し権限があると、ストレージからデータを読み出してコンピュート部分に送ることができます。注意するところは、読みだしたデータを処理して返すのがコンピュート部分という点です。ストレージの権限だけではコンピュート部分を操作できずエラーになります。ストレージの権限のみを持っている場合の典型的なエラーメッセージは以下のものです。 Access Denied: Project XXX: User does not have bigquery.jobs.create permission in project XXX. さきほど例に挙げたBigQuery Data Viewerロール 1 はストレージに関する権限のみを持っているため、これ単独では権限不足のエラーになっていました。 コンピュートに関する権限 次がコンピュートに関する権限です。この権限があると、ストレージ部分から送られてきたデータを処理して、SELECT文の実行結果を作ることができます。前述したストレージに関する権限と同様に、この権限だけを持っていても権限不足のエラーになります。コンピュートの権限のみを持っている場合の典型的なエラーメッセージは以下のものです。 Access Denied: Table XXX: User does not have permission to query table XXX. BigQuery Job Userロール 2 が代表的なコンピュートに関する権限です。 課金との兼ね合い ここからは少し話題を変えて、課金モデルについて説明します。一見すると権限と課金は無関係なように見えますが、この後に説明するマルチテナント構成を考える上で、課金モデルを知っていると理解がスムーズになるためここで説明します。 ストレージに関する課金 まずはストレージに関する課金です。ストレージは従量課金制で、1GB毎、1か月毎に0.020USDの費用がかかります 3 。この費用はそのデータを保持しているプロジェクトが支払います 4 。 cloud.google.com コンピュートに関する課金 次にコンピュートに関する課金です。こちらも従量課金制で、1TBのデータをスキャンする毎に5USDの費用がかかります 5 。この費用はコンピュートリソースを保有しているプロジェクトが支払います。 文章だけですと、分かりづらいかもしれないので、具体例を出して説明します。 ここでは、プロジェクトAが保有しているデータに対してSELECTすることを考えます。クエリを実行するユーザーは、プロジェクトAのストレージの権限と、複数プロジェクト(A, B)のコンピュートに関する権限を保持しているとします。この時、プロジェクトA側のコンピュートリソースでクエリを実行した場合(図中1の経路)は、プロジェクトAが費用を負担します。同様に、図中2の経路でクエリを実行した場合は、プロジェクトBが費用を負担します。 なお、複数のプロジェクトのコンピュートに関する権限を保持している場合は、どちらのコンピュートリソースを利用するかを選択できます。bqコマンドで実行する場合は --project_id オプションで指定できます。Webコンソールからの実行の場合は、画面上部の青いバーでプロジェクトを指定できます。 先ほど、BigQuery Job Userロールが代表的なコンピュートに関する権限と説明しましたが、正確には言葉足らずな表現です。正しくは、その権限に加えて、コンピュート部分で発生した費用をそのプロジェクトに対して請求する権限を持ったロールです。この事が、以降の事例にて重要になります。 事例紹介 ここからは、BigQuery利用の拡大に伴う権限管理について、具体的な例を使って紹介します。 使い始め:シンプルに1プロジェクトを管理する まずは、一番シンプルに、プロジェクトが1つだけパターンです。BigQueryを使い始めた時点では、この構成になっていることが多いかと思います。この場合はBigQuery Data ViewerとBigQuery Job Userの両方のロールが必要です。 規模拡大:複数部署のBQ利用を管理会計する 次に紹介するのはプロジェクトが複数あるパターンです。BigQueryの利用者が多くなり、複数部署がBigQueryを使用するようになりました。この時、組織によっては部署毎のBigQuery利用費を分離して管理したいかもしれません。このパターンでは権限付与の方法がやや難しいため、注意が必要です。利用者には、「データを保持しているプロジェクトすべて」のBigQuery Data Viewerロールと、「クエリを実行するプロジェクト」のBigQuery Job Userロールの両方を付与します。 なお、図には載せませんでしたが、実際のケースでは部署横断的なプロジェクトも分離すると管理がしやすくなります。例えば、ETL用のプロジェクトや専用線・VPNなどのネットワークリソースをホスティングするプロジェクトなどがこれに該当します。 外部連携:社外へデータ提供する BigQueryの利用が更に進むと、社外に対してデータを提供することがあるかもしれません。社外に対するデータ提供も上記の複数部署パターンとほぼ同じです。重要なのは、自社管理のプロジェクトに対するBigQuery Data Viewerロールのみを付与し、BigQuery Job User権限は付与しないという部分です。 社外のユーザーに対して、自社管理のプロジェクトに対するBigQuery Job Userロールを付与しないのは、いわゆる「タダ乗り」を防止するためです。仮にBigQuery Job Userロールを付与してしまうと、以下の図に示すように、社外ユーザーの保持しているデータを自分たちのコンピュートリソースで処理できてしまいます。この場合のコンピュートに関する費用は自社側に請求されるため、「タダ乗り」となります。 厳格化:特定のデータセットのみの閲覧権限をつける 最後はアクセス権限の厳格化です。今まではプロジェクトレベルの権限を考えていましたが、BigQuery Data Viewerロールはリソースレベルでも付与できます。その場合も今までと同様に考えれば問題ありません。「閲覧をしたいデータセットのみ」にBigQuery Data Viewerロールを付与し、「プロジェクト全体」のBigQuery Job Userロールを付与すればクエリを実行できます。 まとめ BigQueryの権限について、ストレージとコンピュートの分離という観点から解説しました。一見すると不思議に見える権限セットも内部アーキテクチャから理解することで体系的に理解しやすくなります。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからご応募ください! tech.zozo.com より具体的にはbigquery.tables.getDataパーミッション ↩ より具体席にはbigquery.jobs.createパーミッション ↩ 90日間変更されていないデータはLongTerm Storageという区分に自動的に変更され、料金が半額の0.010USDになります。 ↩ より正確にはそのプロジェクトに紐付いている請求アカウントが支払います。 ↩ Reservation機能を使えば定額にもできます。 ↩
アバター
はじめに こんにちは。ブランドソリューション開発部プロダクト開発チームの木目沢とECプラットフォーム部カート決済チームの半澤です。 弊社では、ZOZOTOWNリプレイスプロジェクトや新サービスで、Amazon DynamoDBを活用することが増えてきました。そこで、AWS様から弊社向けに集中トレーニングという形でDynamoDB Immersion Daysというイベントを開催していただきました。 今回は、2021年7月6日、13日、14日の3日間に渡って開催された当イベントの様子をお伝えします。 7月6日のDay1及び、14日のDay3の様子をDay1のサブスピーカーとして参加した木目沢がお届けします。13日のDay2を同じくDay2にてサブスピーカーとして参加しました半澤がお届けします。 目次 はじめに 目次 Day1(2021年7月6日) Amazon DynamoDB Architecture & History Amazon DynamoDBの進化を振り返る データベースのスケーリング DynamoDB読み取りオペレーション 項目の分散(happy path) レプリケーション オンデマンドモード Amazon DynamoDB for Operations 今すぐ使えるAmazon DynamoDBのベストプラクティス集 適切なキャパシティを選択する 大きなアイテムを保存する方法 グローバルセカンダリインデックス(GSI)のスロットリングに注意 Time to Live(TTL) DynamoDB Streams on demand backup Global Tables Day2(2021年7月13日) NoSQL Design Patterns for DynamoDB SQL(リレーショナル)とNoSQL(非リレーショナル)の設計パターン Queryを使用した基礎的な探索方法 複合文字列ソートキーを使用して効率的に探索する GSIを追加して新しいアクセスパターンを実現する スパースなインデックスでコスト効率が高いスキャンを実現する カーディナリティの低いアイテムへの書き込みを分散する カーディナリティの高いパーティションキーを持つテーブル全体をGSIで効率的に探索する 読み込み時の負荷の偏りを軽減してスロットリングを回避する DynamoDBでOLAP処理を行う 大きなアイテムの扱い方 ネストされたJSONをクエリする方法 特定のインデックスを選択的にクエリする DynamoDBのトランザクション 階層データをドリルダウンで絞り込む デザインパターンを学んで Advanced Design Patterns For Amazon DynamoDB Day3(2021年7月14日) さいごに Day1(2021年7月6日) 1日目は2つのセッションが行われました。各セッションの模様を紹介します。 Amazon DynamoDB Architecture & History Amazon DynamoDBの進化を振り返る 最初のセッションでは、AWSソリューションアーキテクトの成田さんがメインスピーカーを、同じくソリューションアーキテクトの堤さんがサブスピーカーをご担当されました。 2004年にリレーショナルデータベース(以下RDB)の拡張性に関する課題が表面化し、その解決策として2007年にDynamoDBが誕生。2012年に一般への提供開始という歴史を説明いただきました。現在では、Amazon PrimeやAmazon Music、Amazon AlexaなどでもDynamoDBが使用されているということでした。 amazon.comを支えるために開発されたDBを、一般に提供してしまうそのポリシー、大変素晴らしいものと感じました。弊社でも最大限活用させていただきます。 その後は、DynamoDBの特徴を詳細に説明いただきました。個人的に特に印象深かったトピックスを紹介します。 データベースのスケーリング SQLでは縦方向のスケーリング、つまり容量やメモリの増幅がスケーリングの対象であったのに対し、DynamoDBは水平にスケーリング、多数のシャードにスケールアウトされます。自動でスケーリングされるため、使う際にはあまり意識することがないのですが、仕組みを知っておく必要はあると感じました。一昔前はオンプレRDBのスケーリングに苦労した記憶があります。DynamoDBではマシンさえあればいくらでもスケールでき、しかもそれをユーザー側は意識する必要がないのは単純にすごいことだと思いました。 DynamoDB読み取りオペレーション DynamoDBでは正確に0または1個の項目を返すGetItem、条件が指定できるQuery、テーブルのすべての項目を読み取るScanなどで項目を取得できます。NoSQLは検索しづらいイメージがありましたが、DynamoDBでは、一通り検索の仕組みが用意されています。 項目の分散(happy path) DynamoDBではパーテーションキーをハッシュ化し、効率的なアクセスのために、近傍のデータをパーテーションとして保存します。よく、CloudFormationの定義でKeyType: HASHとしていますが、ここで使用されるものだったのですね。 レプリケーション DynamoDBでは3つのアベイラビリティーゾーンにレプリケートされます。DynamoDBの高い稼働率の秘密がここにありました。DynamoDBは99.999%のSLAを保証しています。 オンデマンドモード 事前にキャパシティの予約をしなくても、読み取り、書き込みした分のみ課金されるモードです。弊社がDynamoDBを使用し始めたころにはなかったモードでした。当時DynamoDBへのアクセスを予測できない中、余裕を持ってキャパシティを確保していたためその分の料金がかかっていました。そんな中でオンデマンドモードを使用できるようになり劇的に料金を下げることができました。 他、グローバルセカンダリインデックス(GSI)やpoint-in-time recovery、On-demand backup、Global Tableなど多彩な機能が用意されています。これらを自前で用意するのは困難ですので、DynamoDBを活用しましょう。弊社では大いに活用させていただいております。 Amazon DynamoDB for Operations 今すぐ使えるAmazon DynamoDBのベストプラクティス集 続いて、DynamoDBのベストプラクティス集を一気にご説明いただきました。このセッションでは、AWSの堤さんに変わり、木目沢がサブスピーカーとして登壇しました。メインスピーカー成田さんの説明に沿って、質問や感想を行いました。 以下、個人的に特に印象深かったトピックスを紹介します。 適切なキャパシティを選択する DynamoDBでは予め必要なキャパシティを予約するプロビジョンドモードとオンデマンドモードがあります。予めアクセス数が予測される場合はプロビジョンドモード、できない場合はオンデマンドモードが推奨されます。また、状況によってモードを切り替えるような運用の仕方もあるそうです。私の担当プロジェクトでは、セールなどでアクセス数が大きく変動し予測しずらい状況であったためオンデマンドモードを活用しています。このモードの選択により使用料金が大きく変わるのでよく検討する必要があるでしょう。 大きなアイテムを保存する方法 項目の最大サイズは400KBでそれ以上の項目は追加できません。また、大きなサイズを書き込むにもキャパシティユニットをその分消費するため、その対策を説明いただきました。項目名を短縮したり、S3に保存しパスだけ持つなど工夫のしどころがあると感じました。 グローバルセカンダリインデックス(GSI)のスロットリングに注意 グローバルセカンダリインデックスには非同期にデータが書き込まれます。グローバルセカンダリインデックスにも十分なキャパシティがないとスロットリングされるので注意が必要です。グローバルセカンダリインデックスについては、キャパシティの消費による料金の問題がよく課題に上がっていましたが、スロットリングにまでは注意していなかった気がします。ここは要注意です。 Time to Live(TTL) 期限切れのItemを自動的に削除する機能です。ゼロコストでパフォーマンスへの影響もなく、アーカイブも取ってくれるので便利な機能です。ただし期限切れしてすぐ削除するものではないので注意は必要です。期限切れItemが削除されるまでの時間要件がない場合、かなり使える機能ではないでしょうか。 DynamoDB Streams DynamoDBのデータが更新されたイベントをStreamに流すことができます。弊社ではDynamoDB Streamsを活用し、Amazon Elasticsearch Serviceにデータを投入するなどで活用しています。また、最近ではAmazon Kinesis Data Streams for DynamoDBも使用できるようになりました。私の最近の担当プロジェクトではCQRSの構成でイベントをクエリ側にStreamとして流すためにこの機能を活用しています。 on demand backup DynamoDBでは、簡単にバックアップが取れるようになっています。point in time recoveryを活用し継続的にバックアップを取ることも可能です。弊社でDynamoDBを使用し始めた頃にはなかった機能でした。そのため、DynamoDB Streamsからデータを投入していたAmazon Elasticsearch Serviceが実質バックアップになっていました。現在ではDynamoDB本体でバックアップが取れるようになり、非常に便利になったと感じた機能です。 Global Tables 世界的に活用されるサービスであればGlobal Tablesを利用することをおすすめします。簡単にマルチリージョンのデータベース構成を取ることができます。この機能は初めて知った機能でした。私の担当プロジェクトは国内向けなので使用することはありませんが、世界的に展開していれば、アクセス元から近いリージョンのDynamoDBを利用できるようになります。 Day2(2021年7月13日) ここからは、半澤が2日目の様子や学んだ内容をご紹介します。 2日目も前半と後半の2部構成となっており、前半はソリューションアーキテクトの成田さんによる講義、後半はハンズオンを行いました。 NoSQL Design Patterns for DynamoDB 2日目のセッションでは、テーブルを設計する際にDynamoDBの機能を有効に使うためのデザインパターンを学びました。 DynamoDBの操作は、基本的にkey-valueのシンプルなkeyを使ったアイテム操作になります。しかし、デザインパターンを活用することでRDBやRDBライクな他のサービス・プロダクトで出来るような探索等をDynamoDBでも実現できます。また、DynamoDBの特性を生かした設計をする事により、スロットリングなどの問題を引き起こしにくくなります。 学んだ内容を順にご紹介します。 SQL(リレーショナル)とNoSQL(非リレーショナル)の設計パターン DynamoDBには、JOINという概念がありません。DynamoDBのテーブルを設計する際は、RDBのように正規化するのではなく、非正規化して1つのテーブルにまとめます。これにより複数テーブルに対してクエリやJOINを実行せず、必要なデータの取得が可能です。 規模に関係なく数ミリ秒台のパフォーマンスを実現するDynamoDBの利点を最大限活かすためには、アプリケーションのアクセスパターンをしっかり整理・理解して、データを適切に書き込む必要があります。 設計では以下を行います。 ユースケースの定義 アクセスパターンの特定 データモデリング アプリケーションタイプはOLTPなのか、OLAPなのか判断 データのライフサイクル(TTL、バックアップ/アーカイブなど)を決める プライマリキーの設計 インデックスの設計 Immersion Daysに前後して、初めてDynamoDBのテーブル設計をしましたが、AWSのドキュメントが充実しており大変参考になりました。なお、一度で設計を完了せず、コードを動かしたり机上の設計を元にボトルネックや非効率な探索などの問題を洗い出して、何度でも設計とレビューを繰り返しブラッシュアップできる体制を作ることが大事だそうです。 実際に自チームでモデリングを行なった際も、モデリングとレビューを何度も繰り返しました。ソリューションアーキテクトの方にも都度レビュー頂き安心して進めることができました。 Queryを使用した基礎的な探索方法 ソートキーやフィルター式、複合キーを使ったDynamoDBの探索機能であるQueryの効果をより引き出す基礎的なテクニックを学びました。 QueryとはSQLでいうSELECTのような探索機能です。DynamoDBのプライマリキーは、パーティションキー単体、もしくはパーティションキーとソートキーを組み合わせた複合プライマリキーがあります。パーティションキーは完全一致な指定のみ可能ですが、ソートキーは柔軟な条件指定が可能です。 Queryに使用できる主な機能は以下となります。 KeyConditionExpression パーティションキーとソートキーに対する検索条件を記述します。 パーティションキーは完全一致 = のみですが、ソートキーは完全一致 = 以外にも > >= < <= や between begins_with などの関数も使用可能です。 FilterExpression パーティションキー、ソートキー以外の要素で絞り込みを行う場合に使用します。 条件式はKeyConditionExpressionで使用可能なものに加え、 <> が使用可能です。 フィルター式はKeyConditionExpressionでの絞り込み後に適用され、消費されるリソースの削減には寄与しないのでご注意ください。 ScanIndexForward SQLでいうORDER BYです。デフォルトはASCとなります。 この他にもページネーションなど様々な機能をサポートしています。 以下のテーブルは、デバイスのログを保存する device_logs です。パーティションキーは device_id 、ソートキーは created_at 、プライマリキー以外の属性としてログレベル level を保持しています。 このテーブルから、特定デバイスのWARNINGレベルのログを降順に取得するQueryを構築してみます。 一般的なSQL SELECT * FROM device_logs WHERE device_id = 12345 AND level = ' WARNING ' ORDER BY created_at DESC ; DynamoDBのQuery aws dynamodb query \ --table-name device_logs \ --key-condition-expression " #device_id = :device_id " \ --filter-expression " #level = :level " \ --expression-attribute-names ' {"#device_id": "device_id", "#level": "level"} ' \ --expression-attribute-values ' {":device_id": {"N":"12345"}, ":level": {"S": "WARNING"} } ' \ --no-scan-index-forward ExpressionAttributeNamesは要素名、ExpressionAttributeValuesは条件値をパラメータ化するオプションです。パラメータ化により、DynamoDBの予約語 1 とのバッティングを回避できます。例えばフィルターに使用している level は予約語なので、 --filter-expression の中で level = :level と記述はできません。また、パラメータ化により何度も同じ条件を書かず1つのパラメータで賄える場合もあり、記述を簡略化できるという利点もあります。 上記の例で、データ量が少ない場合は問題なくデータが取得できます。しかし、例えばパーティションキーとソートキーで絞り込んだ結果が100万件で、更にフィルターで除外するアイテム数が99万件の場合は上記のQueryで問題が発生します。Queryのコストは、パーティションキーとソートキーで絞り込んだ結果で決定するため、多くのアイテムをフィルターで除外するのは非常に非効率でスロットリングを誘発する可能性が高くなります。 このQueryを効率化するためのテクニックを次に学びました。 複合文字列ソートキーを使用して効率的に探索する device_logsの構造を変更し、ソートキーに level と created_at を # で結合した文字列を保存します。 Queryの条件を一部修正します。 Before --key-condition-expression " #device_id = :device_id " \ --expression-attribute-names ' {"#device_id": "device_id", "#level": "level"} ' \ --expression-attribute-values ' {":device_id": {"N":"12345"}, ":level": {"S": "WARNING"} } ' \ After --key-condition-expression " #device_id = :device_id and begins_with(#level_with_created_at, :level) " \ --expression-attribute-names ' {"#device_id": "device_id", "#level_with_created_at": "level_with_created_at"} ' \ --expression-attribute-values ' {":device_id": {"N":"12345"}, ":level": {"S": "WARNING"} } ' \ ソートキーの前方一致での検索により、パーティションキーとソートキーで絞り込みが完結し、効率的な検索が可能となりました。 GSIを追加して新しいアクセスパターンを実現する 新しいアクセスパターンとして、あるオペレーターが対応した特定期間のログを検索したくなった場合の対応方法を学びました。パーティションキーにプライマリキー以外の属性である operator を指定したグローバルセカンダリーインデックス(GSI) 2 を追加します。これによりN:Nの関係を表現し、新しいアクセスパターンでの検索が可能となります。 上記のインデックスから特定のオペレーターの対応した特定期間のログを検索する場合は次のようになります。 aws dynamodb query \ --table-name device_logs \ --index-name GSI_operator_created_at \ --key-condition-expression " #operator = :operator and #created_at between :from and :to " \ --expression-attribute-names ' {"#operator": "operator", "#created_at": "created_at"} ' \ --expression-attribute-values ' {":operator": {"S":"MAX"}, ":from": {"S": "2020-02-02T00:00:00.000Z"}, ":to": {"S": "2020-02-02T00:00:10.000Z"} } ' \ --no-scan-index-forward --index-name でインデックス名、 --key-condition-expression でオペレーターを指定し、その中で between を使用し日時を範囲指定しています。 スパースなインデックスでコスト効率が高いスキャンを実現する 次に、大量のログの中から特別にエスカレーションされた数件を検索するようなパターンに有効なテクニックを学びました。 エスカレーションされたログに対して、ベーステーブルのプライマリキー以外の属性に escalated_to という要素を追加します。加えて escalated_to をパーティションキーに指定したGSIも作成します。なお、エスカレーションされていないアイテムには、 escalated_to はnullではなく要素自体が存在しません。下図は、上がベーステーブル、下がGSIです。 作成したGSIに存在するアイテムは、 escalated_to が存在するアイテムのみとなります。Scanを行なったとしても件数が少ないため、非常に効率的な探索が可能となります。このようなインデックスのことを、スパースなインデックス 3 と呼びます。 また、時間まで指定する用途がなく日付のみの指定に限られる場合は、 2021-01-01 のように時間情報を削除して格納することが推奨されます。検索の処理効率やレイテンシは変わりません。しかし、将来のテーブルサイズがTBレベルになるような場合では、話が変わってきます。予め不要な情報を削っておくことが、最終的なテーブルサイズに大きな影響を与える可能性があります。消費するキャパシティユニットの節約にもなるため、削れるバイト数は削っておくのがベストプラクティスです。 カーディナリティの低いアイテムへの書き込みを分散する パーティションキーのカーディナリティが低いアイテムに対する書き込みを、キー空間のセグメント化により分散する方法を学びました。 次のテーブルは大統領選挙の投票数のようなデータを管理するテーブルです。プライマリキーはパーティションキーの候補者 candidate で、プライマリキー以外の属性として投票数 count を持っています。候補者はAとBのみで、アイテム数が限られています。そして、想定される書き込み負荷に備えてテーブルには10万書き込みキャパシティーユニット(WCU)を設定し、投票数に応じて count をカウントアップします。 内部的には分散するためのリソースを用意し、10万WCUが出るテーブルになっています。しかし、この場合は特定のパーティションキーにのみ書き込みが集中するため、負荷分散されません。DynamoDBの単一アイテムへの書き込み上限は1,000WCUなのでスロットリングが発生してしまいます。 これを解決するには、まずパーティションキーに0-Nの文字列を結合したアイテムをN個作成しておきます。カウントアップする際は0-Nのアイテムへランダムに書き込むようアプリケーション側で制御します。結果を取得する際には0-Nのアイテムを直列、または並列にアプリケーションで取得して集計して結果を書き込みます。 書き込み時に必要なパーティション数を算出するには次の計算式を利用します。 1秒あたり100K WCUの書き込みを実現したい場合 100K * CEILING(ItemSize/1KB) / 1000 = 100 平均アイテムサイズ 4 を1KB(1WCUごとに書き込める上限値)で割る ここでは、平均アイテムサイズを仮に1KBとします 実現したい10万WCUの100Kをかける 1パーティションあたりの書き込みWCU上限の1000で割る 最低100アイテムあれば理論上分散され10万WCU出るという結果が出る アイテムサイズを計算をした上で、 --return-consumed-capacity オプションを付与してDynamoDBへ書き込みを行い、実際に消費したキャパシティユニットを確認すると確実です。また、書き込むパーティションが偏る可能性もあります。計算結果は最低値と考えてテストを実施し、偏りが出ないか検証が必要です。偏りが出てしまう場合は150、200と余裕を持った数を設定しておくと安全です。 カーディナリティの高いパーティションキーを持つテーブル全体をGSIで効率的に探索する 先ほどは書き込みの例でしたが、次はテーブル全体をクエリするためにキースペースを人工的にセグメント化する手法を学びました。 UUIDのように推測しづらくカーディナリティの高いパーティションキーを持つテーブルがあるとします。このテーブルの10年分のアイテムから直近4時間以内の登録アイテムを検索するパターンを考えます。パーティションキーは完全一致の指定が必要なため、最近登録されたUUIDリストを元にGetItemを数万回発行するか、10年分をScanしてフィルター条件で除外するという非効率な探索となります。 これを解決するためには、ベーステーブルのプライマリキー以外の属性にランダムな数値を持たせ、パーティションキーに指定したGSIを作成します。下図は、上がベーステーブル、下がGSIです。 GSI上では単純化された0-Nがパーティションキーとなるため、パーティションキーとソートキーの範囲指定で効率的な探索が可能になります。また、パーティションを分けることで、1つのパーティションに集中した際のスロットリング防止にもなります。 読み込み時の負荷の偏りを軽減してスロットリングを回避する 次は読み込み時の負荷を軽減する方法について学びました。例として商品情報の読み込みについて考えます。通常商品と人気商品やトップページに表示される商品のように、アイテム間で負荷が極端に偏る場合があります。DynamoDBは1アイテムの読み込みにつき、3,000読み込みキャパシティーユニット(RCU)を超えるとスロットリングが発生してしまいます。 この場合はAmazon DynamoDB Accelerator(DAX) 5 やElastiCache Redis 6 などのキャッシュを有効に使うことでスロットリングを回避します。 DAXはフルマネージド型の高可用性インメモリキャッシュです。DynamoDBに特化したサービスで、ライトスルー方式のキャッシュを使えるのが最大の利点です。DAXクライアントはDynamoDBと同じ書き込みオペレーションをサポートしており、クライアントを差し替えるだけでキャッシュとDynamoDBへの同時書き込みが可能となります。 DAXのキャッシュはGetItem時に使用される項目キャッシュと、Query、Scan時に使用されるクエリキャッシュで独立しています。どちらもDAX上にキャッシュが存在しない場合はDynamoDBへ問い合わせて結果をキャッシュ上に保存します。DAXクライアントを通したライトスルー方式の書き込みは、すべて項目キャッシュへ保存されます。クエリキャッシュは検索条件毎に保存され、項目キャッシュの変更が反映されないため、TTLを短く設定しておくのがベストプラクティスです。 DAXよりもRedisなど他のキャッシュが推奨されるのは以下のような場合です。 RedisのSorted SetsやPubSub、ストリーム等の他のキャッシュ特有の機能が必要な場合 QueryやScanのキャッシュインバリデーションが必要な場合 既にアプリケーションでキャッシュを使用しており、同居した方がコストを抑えられる場合 以上のように、要件によって適したサービスを使い分ける形となります。 DynamoDBでOLAP処理を行う Day1で、データ処理タイプには以下の2つがあり、DynamoDBはOLTPに向いていることを学びました。 OLAP(Online Analytical Processing) 複雑なクエリで大量のデータを元に分析する OLTP(Online Transaction Processing) 単純なクエリを高速に処理する DynamoDBはOLAP処理に適していませんが、必要となった場合の手段としてDynamoDB StreamsとExports to S3が用意されています。 DynamoDB StreamsはDynamoDBへ更新が入ったイベントデータをStreamから取得できるサービスです。Streamレコードには、書き込み・変更・削除の変更前と変更後のアイテム情報が格納されています。DynamoDB StreamsとLambdaを連携し、OLAP向けのクエリエンジンへ連携することでOLAP処理が可能となります。 また、2020年に新機能としてExports to S3がリリースされました。この機能を使用することにより、DynamoDBのデータをS3にエクスポート可能です。これにより、比較的簡単にAthenaなどへ連携しOLAP処理が可能となりました 7 。 DynamoDB Streamsは変更があった際に、ニアリアルタイムに処理してデータを連携できるので、直近のデータが必要な場合や多数の分析処理が常にあるようなケースにマッチします。しかし、1度限りのスポットな分析用途で特定の時点までのデータを必要とするケースでは、必要な時にS3へエクスポートして分析する事で、より低コストかつ簡単に連携できます。 要件に合わせて連携方法を使い分けましょう。 大きなアイテムの扱い方 サイズの大きなアイテムを扱う場合のテクニックを学びました。 下記はユーザがアップロードしたデータの処理状況を管理するためのテーブルです。パーティションキーは user_id 、ソートキーは status と created_on の複合キーです。プライマリキー以外の属性 document にサイズの大きなデータを格納しています。 1アイテムの平均サイズは256KBで、ユーザーは1度に最大50件のアイテムを一覧で取得したい場合を考えます。 この場合消費されるRCUの計算式は以下となります。 50 * 256KB * (1RCU / 4KB) * (1/2) = 1600RCU 4KBは1RCUで読み込めるデータサイズ上限 「結果整合性のある読み込み」を利用するため1/2をかける 計算結果から、1回の一覧取得に1,600RCU消費することがわかりました。もしも10人、100人が同時にアクセスするならば膨大なRCUが必要です。 この問題を解決するために、ベーステーブルの設計を少し変更します。新たに report_id をパーティションキーとして追加し、 user_id をプライマリキー以外の属性に変更します。そして一覧表示に使用するデータのサマリ summary を追加しました。 次に、ユーザーを指定して一覧を取得するためのGSIを作成します。パーティションキーは user_id 、ソートキーはステータスと追加日の複合キー status_with_created_on を指定します。そして、GSIのProjectionTypeに INCLUDE を指定し、GSIのプライマリキー以外の属性に report_id と summary を含めるよう設定しました。 GSIには document を持たないため、1アイテムあたりのサイズが削減されます。その結果、一覧表示に必要なRCUはたったの1RCUとなり、効率的な探索が可能となりました。また、一覧から詳細情報を取得する際はGSIから導き出した report_id を元にGetItemが可能です。 Queryは --select オプションで要素名を指定でき、SQLでカラム名を指定するのと同じように取得する要素の絞り込みが可能です。しかし、RCUは絞り込む前のアイテム全要素分を消費します。そのため、不要な要素はなるべく削除しアイテム自体を小さくしておくことが重要です。 また、Queryにかかるコストは前述の計算式のように、取得した全件のデータサイズを4KBで割ったものとなります。対象データ全てをGetItemで取得すると1回あたり1RCU(結果整合性のある読み込みの場合は0.5RCU)かかるため、Queryで一度に取得する方が効率的でコストも小さくなります。 ネストされたJSONをクエリする方法 次に、ネストされたJSONデータの特定の値を取得するためのテクニックを学びました。 例として、パーティションキー user_id に対して、以下のようなカート情報のJSONが格納されているケースを見ていきます。カート内の靴下の price を取得したい場合、アプリケーションにJSONデータを一度ロードし解析する必要があります。 { " cart_items ": [ { " item_name ": " 靴下 ", " item_id ": " 靴下ID ", " sku ": " 靴下SKU ", " quantity ": " 2 ", " price ": " 3,300 ", " category ": " レッグウェア ", " sub_category ": " ソックス/靴下 ", " added_at ": " 2021-08-01T00:00:00.000Z " } , { " item_name ": " お茶碗 ", " item_id ": " お茶碗ID ", " sku ": " お茶碗SKU ", " quantity ": " 1 ", " price ": " 5,500 ", " category ": " 食器/キッチン ", " sub_category ": " 食器 ", " added_at ": " 2021-08-01T00:10:00.000Z " } ] , " ship_to ": { " name ": " ZOZO MAX ", " address ": " 稲毛区緑町1-15-16 ", " city ": " 千葉市 ", " state ": " 千葉県 ", " postal_code ": " 263-0023 ", " phone ": " 04-1234-5678 " } } 必要な箇所がドキュメントの一部であっても、ドキュメント全体を毎回操作すると、消費コストが大きくなります。アイテムを要素ごとに垂直分割することで、容量とコストの削減になりパフォーマンスが向上します。 上記のJSONデータを分割するため、ソートキーで階層を表現するとこのようになります。これにより、ネストされた複雑な値に対する探索や書き込みが可能となりました。 # マックスさんのカートに入っている靴下の`price`を取得するQuery aws dynamodb query \ --table-name carts \ --key-condition-expression " #user_id = :user_id and #sort_key = :sort_value " \ --expression-attribute-names ' {"#user_id": "user_id", "#sort_key": "sort_key"} ' \ --expression-attribute-values ' {":user_id": {"S":"MAX"}, ":sort_value": {"S": "cart_items#price#靴下ID"} } ' # マックスさんのカートのアイテム一覧情報を取得するQuery aws dynamodb query \ --table-name carts \ --key-condition-expression " #user_id = :user_id and begins_with(#sort_key, :sort_value) " \ --expression-attribute-names ' {"#user_id": "user_id", "#sort_key": "sort_key"} ' \ --expression-attribute-values ' {":user_id": {"S":"MAX"}, ":sort_value": {"S": "cart_items"} } ' 更に、 sort_key をパーティションキー、 gsi_sk をソートキーに指定したGSIを作成します。 これにより、ユーザー全体から靴下をカートに入れているユーザーの特定や、特定地域へ発送するユーザーの特定が可能となりました。これはGSI Overloadingと呼ばれる手法で、あえてソートキーを曖昧にすることで、拡張性を担保した上で1つのGSIで複数のコンテキストによる探索が可能となります。 特定のインデックスを選択的にクエリする 次は前のテクニックとは逆に、GSIを検索条件毎に作成して特定のパーティションキーを静的逆引きするテクニックを学びました。 例として、クリスマスプレゼントに箱猫マックスの千葉県ご当地ステッカーを作り、以下の条件に当てはまるユーザーへプレゼントするという場合を考えます。 発送先住所を千葉県で登録している 12月が誕生月 12月に注文している ベーステーブルは user_id がパーティションキー、プライマリキー以外の属性には検索に必要な要素を持っています。そして、 user_id を逆引きするため、検索に必要な条件分のGSIを作成します。下図は上から順にベーステーブル、発送住所の都道府県GSI、誕生月GSIと最後の注文月GSIです。 これにより、GSIを探索してベーステーブルのパーティションキー特定が可能となります。設計を最適化していく上で、このようなテクニックが有効になる場合もあることを覚えておくと便利です。 DynamoDBのトランザクション DynamoDBはトランザクションをサポートしています。トランザクションは書き込みと読み込みの2種類あり、どちらも最大25のアクションが実行可能です。しかし、非正規化した形でモデリングを行いDynamoDBに最適化した方が効率的になることも多く、局所的に必要な場面での使用が推奨されています。 階層データをドリルダウンで絞り込む 例えば全国の店頭・ロッカー受取りサービスの受け取り場所を住所からドリルダウンで絞り込む場合、以下のように複合キーを用いた前方一致の検索にて実現可能です。 紀尾井町の受け取り場所一覧を検索する場合 aws dynamodb query \ --table-name pick_up_locations \ --key-condition-expression " #state = :state and begins_with(#location, :location) " \ --expression-attribute-names ' {"#state": "state", "#location": "location"} ' \ --expression-attribute-values ' {":state": {"S":"東京都"}, ":location": {"S": "千代田区#紀尾井町"}} ' デザインパターンを学んで 講義を通して、以下を学ぶことができました。 リレーショナルデータベースとの違い DynamoDBのテーブルを設計する時は、まずアクセスパターンを洗い出し逆算してモデリングを行うこと スロットリングなどの問題を回避し、DynamoDBの利点を最大限享受するために様々なデザインパターンが存在すること OLAP処理は他のOLAPに向いているサービスに連携することで、全体のアーキテクチャをスケールするということ トランザクションやExports to S3などの便利な新機能が用意されており、年々機能がアップデートされていること GSI Overloadingなどの高度なテクニックはNoSQLに慣れていないとなかなか出てこない発想だと思いますが、覚えておくことで今後役に立ちそうです。 成田さんからは最後に「複雑になればなるほど、オプティマイザやストレージエンジンの気持ちを考えてモデリングを行う必要があります。迷ったら是非SAをレビューに呼んでいただき、一緒にブラッシュアップしていきましょう!」とメッセージをいただきました。オプティマイザの気持ちを全て理解するのはまだ難しいですが、今回の講義を通して少しは理解できるようになった気がします。成田さん、ありがとうございました! Advanced Design Patterns For Amazon DynamoDB 講義後、1時間半のハンズオンワークショップが開催されました。解説は引き続き成田さんで、流れに沿ったデモをソリューションアーキテクトの馬(Ma)さんに画面共有しながら実演いただきました。参加者はイベント用アカウントでAWSのWebコンソールへログインし、馬さんのお手本を見ながら動作を確認しました。また、他にもソリューションアーキテクトの方が数名サポートとして参加されており、詰まった場合はすぐにサポートしてもらえる体制でした。 このワークショップの具体的な内容は、「 Hands-on Labs for Amazon DynamoDB :: Amazon DynamoDB Workshop & Labs 」にて公開されています。 以下は見出しの日本語訳です。興味を引くものがあれば、ぜひ挑戦ください。 Setup ラボ環境をセットアップ EC2のラボインスタンスに接続 DynamoDB Capacity Units and Partitioning DynamoDBテーブルを作成 サンプルデータを投入 実行時間を比較するため、スクリプトから大量データを登録する CloudWatchでメトリクスを見る テーブルのキャパシティを増やす 再度大量データを登録して前回の実行時間と比較する キャパシティの少ないGSIを作成し、大量データを投入してスロットリングを確認する Sequential and Parallel Table Scans 直列スキャンを実行して実行時間を確認する 並列スキャンを実行して直列スキャンと実行時間を比較する Global Secondary Index Write Sharding GSIを作成する シャーディングされたGSIからのステータスコードと日付で並べ替えられたデータを効率的に読み取る Global Secondary Index Key Overloading GSI Overloading用のemployeesテーブルを作成する 作成したテーブルにデータを投入する オーバーロードされた属性を持つGSIを使用して複数のアクセスパターンの探索を実現する Sparse Global Secondary Indexes employeesテーブルにis_managerをパーティションキーとしたGSIを追加する ベーステーブルをスキャンとフィルター条件でマネージャーを探索し、スキャンされたアイテム数、実行時間を確認する 作成したGSIをスキャンして、スキャンされたアイテム数と実行時間を比較する Composite Keys employeesテーブルのパーティションキーに「state#州」、ソートキーに「都市#部門」の複合キーを使用したGSIを作成する 州を指定してクエリを実行する 都市を指定してクエリを実行する 都市と特定の部門を指定してクエリを実行する Adjacency Lists InvoiceAndBillingテーブルを作成し、GSIを作成し、データをロードする コンソールからGSIをScanし、1つのテーブルに複数のエンティティタイプが存在することを確認する Invoice詳細をクエリする GSIを使用してCustomer詳細とInvoice詳細をクエリする Amazon DynamoDB Streams and AWS Lambda logfileテーブルのレプリカ用にlogfile_replicaテーブルを作成する IAMロールのポリシーを確認する Lambda関数を作成する DynamoDB Streamsを有効にする DynamoDB StreamsをLambda関数にマッピングする logfileテーブルにデータを入力し、logfile_replicaへのレプリケーションを確認する これまでの講義で学んだテクニックを、実際に手を動かして確認しました。手を動かしたことで理解が進み、業務で利用する自信が付きました。また、役に立つコマンドやGSI作成時にはどのくらい時間がかかるのかなど、ワークショップを通しての学びも多く、とても充実した時間でした。 成田さん、馬さん、サポートいただいたソリューションアーキテクトの皆様、ありがとうございました! Day3(2021年7月14日) 改めまして木目沢です。最終日のセッションはAWS石川さんにご担当いただきました。1日目、2日目で教えていただいたテクニックをすべて駆使して具体的なシナリオを元に設計、実装していくハンズオンです。 オンライン小売店のカートシステムと、銀行で定期支払いを管理するシステムの2シナリオが用意されており、石川さんが直接設計、実装していく様子を見ながら一緒に手を動かしました。 こうして手を動かし、構築することで、漠然と理解していた2日間の内容もかなり整理できました。 これらのシナリオも「 Design Challenges :: Amazon DynamoDB Workshop & Labs 」にて公開されていますので、ぜひ皆さんも挑戦してみてください。 さいごに コロナ渦ということもあり、オンラインでの開催となりましたが、DynamoDBの奥深いところまでご説明いただき、弊社の各サービスでの活用に大いに役に立つ内容でした。お忙しい中イベントを企画し、実現いただいたAWSの皆様、本当にありがとうございました。 ZOZOテクノロジーズでは、DynamoDBを始めとするAWSを活用し、サービスを成長させていく仲間を募集中です。ご興味ある方は こちら からぜひご応募ください! tech.zozo.com DynamoDB の予約語 - Amazon DynamoDB ↩ DynamoDB のグローバルセカンダリインデックスの使用 - Amazon DynamoDB ↩ スパースなインデックスの利用 - Amazon DynamoDB ↩ DynamoDB 項目のサイズと形式 - Amazon DynamoDB ↩ Amazon DynamoDB Accelerator (DAX) | AWS ↩ Amazon ElastiCache(インメモリキャッシングシステム)| AWS ↩ 新機能 – Amazon DynamoDB テーブルデータを Amazon S3 のデータレイクにエクスポート。コードの記述は不要 | Amazon Web Services ブログ ↩
アバター
こんにちは。ZOZOTOWN本部 ZOZOアプリ部 Androidチームの高橋です。ZOZOTOWN Androidチームでは、 Jetpack Compose を導入しました。 この取り組みは、つい先日、 Android Meetup【ZOZOテクノロジーズ × サイバーエージェント × GMOペパボ】 でもご紹介しています。 この記事は、上の資料を補完するものです。資料の内容に加えて、登壇ではお話できなかった技術的な補足をいたします。 Jetpack Composeとは 背景 Jetpack Compose導入時の課題 課題1. ZOZOTOWN Androidで採用されているUIの状態管理の方法がJetpack Composeに適していない Jetpack ComposeでのUIの更新 ZOZOTOWN AndroidのUI更新の流れ 1. Eventの発行 2. アプリケーションの状態更新 3. 更新差分の通知 4. UIの手動更新 課題2. 無秩序なComposable作成によるComposableの可読性・再利用性の低下 課題の解決 UIの状態管理の方法の見直し 見直し後のUI更新の流れ 1. Eventの発行 2. アプリケーションの状態更新 3. 画面全体の情報通知 4. UIの自動更新 Composable設計ルールの制定 Atomic Design Atoms Molecules Organisms Templates Pages 今後の課題 アプリケーションの状態管理 ViewModelの扱い Composable設計ルールの活用 まとめ 最後に Jetpack Composeとは Jetpack ComposeはGoogleからリリースされているUI実装のツールキットです。Jetpack ComposeではComposableアノテーションを付与した関数(以下Composableと呼ぶ)をKotlinのコード上に記述してUIを定義します。 Jetpack Composeの特徴は宣言的UIフレームワークであることです。宣言的UIとは、状態をUIに変換するという考え方です。これまで、UIの更新はViewに定義されているメソッドを明示的に呼び出して行うのが一般的な方法でしたが、Jetpack Composeでは、 再コンポーズ の仕組みによって自動的にUIが更新されます。 背景 ZOZOTOWN Androidには、商品の検索結果を表示する「検索画面」と、商品の詳細を表示する「商品画面」が存在します。 これらの画面は高頻度で機能追加や改修が行われており、UIの表示制御や状態管理が複雑化していました。また、UI実装の複雑化に伴って、UIの更新実装漏れなどによる不具合が度々発生していました。さらに、商品画面では巨大なレイアウトによるパフォーマンスの低下も問題となっていました。 ZOZOTOWN Androidチームでは、Jetpack Composeを導入することで複雑なUI実装が簡素化され、UIに関する不具合を抑えることができると考えました。また、パフォーマンスに関してもJetpack Composeの LazyColumnやLazyRow などを使用することで改善できると考えました。 以上の理由からZOZOTOWN AndroidチームではJetpack Composeの導入に取り組みました。 Jetpack Compose導入時の課題 検索画面や商品画面などの主要画面へのJetpack Composeの導入の前段階として、技術検証を実施しました。 技術検証は、プロダクトへの影響が少なく、以前から改修が検討されていたデバッグメニューのUI実装をJetpack Composeに置き換える形で行いました。 検証の結果、2つの課題が明らかになりました。 ZOZOTOWN Androidで採用されているUIの状態管理の方法がJetpack Composeに適していない 無秩序なComposable作成によるComposableの可読性・再利用性の低下 課題1. ZOZOTOWN Androidで採用されているUIの状態管理の方法がJetpack Composeに適していない 既存のZOZOTOWN Androidで採用されているUIの状態管理の方法では、Jetpack Composeを導入することが困難でした。これについて、既存のZOZOTOWN AndroidのUI更新フローと問題になった箇所を説明します。 Jetpack ComposeでのUIの更新 Jetpack ComposeではUI要素を階層的に表現します。 引用: https://developer.android.com/jetpack/compose/mental-model 各Composableに表示するデータは、最上位のComposableから伝搬されます。UIに表示するデータを受け取ったComposableは、再コンポーズの仕組みによってUIを自動で更新します。 ZOZOTOWN AndroidのUI更新の流れ 以下は既存のZOZOTOWN AndroidでのUI更新フローです。 それぞれのステップについて説明します。 1. Eventの発行 ZOZOTOWN Androidでは、ユーザーインタラクションなどによって発生するEventをViewEventというsealed classで定義しています。 sealed class ViewEvent { object ClickItem : ViewEvent() object ShopClick : ViewEvent() } ユーザーインタラクションが発生すると、FragmentからViewEventを発行します。 2. アプリケーションの状態更新 ViewModelはViewEventを受け取ると、API通信などの処理を行い、アプリケーションの状態を更新します。 ZOZOTOWN Androidでは、UIの状態をViewDataとViewStateという2つのクラスによって定義しています。アプリケーションの状態はこれらのクラスにマッピングされ、UIに通知されます。 data class ItemViewData( val name: String , val price: String , ) ViewDataはカスタムビュー毎に作成されるdata classで、UIに表示するデータを保持します。 sealed class ViewState { object Initial : ViewState() data class Initialized( val itemViewDataList: List <ItemViewData>, val shopViewData: ShopViewData, ) : ViewState() data class ItemSelected( val itemViewData: ItemViewData, ) : ViewState() } ViewStateは画面単位で作成されるsealed classです。subclassはViewDataを保持します。 API通信やユーザーインタラクションなどによってアプリケーションの状態が変化すると、ViewModelはViewStateを作成します。 3. 更新差分の通知 ViewStateはFragmentに対して以下のように通知されます。 class ItemViewModel : ViewModel() { private val _viewState = MutableStateFlow<ViewState>(ViewState.Initial) val viewState: StateFlow<ViewState> = _viewState ... fun onSelectItem(id: Int ) { ... _viewState.value = ViewState.ItemSelected(itemViewData) } ... } ViewModelは作成したViewStateをFlow/LiveDataによってFragmentに通知します。 通知されるViewStateは、それぞれの状態で 更新のあったViewDataのみを保持 しています。 4. UIの手動更新 FragmentはViewStateを受け取ると、更新が必要なViewを明示的に更新します。 Jetpack Composeを導入し、再コンポーズによるUIの差分更新を利用するためには、最上位のComposableが常に画面全体の表示データを受け取る必要があります。しかし、既存のViewModelでは更新差分のある表示データのみをUIに通知するため、そのままではJetpack Composeが導入出来ませんでした。 課題2. 無秩序なComposable作成によるComposableの可読性・再利用性の低下 巨大なComposableは、UI実装の可読性を低下させ開発効率の悪化を引き起こします。また、1つのComposableに多くのUI要素が定義されることでComposableの再利用性が低下します。 今後、チームでJetpack Composeを使用してUIを実装するためには、このような問題が発生することを防ぐ仕組みが必要でした。 課題の解決 UIの状態管理の方法の見直し ZOZOTOWN AndroidへJetpack Composeを導入するため、UIの状態管理の方法を見直し、Jetpack Composeを使用したリファレンス実装を作成しました。 見直しは chrisbanes/tivi を参考に行いました。 見直し後のUI更新の流れ 以下は見直し後のUI更新フローです。 それぞれのステップについて説明します。 1. Eventの発行 EventはComposableから以下のように発行されます。 @Composable fun ItemScreen(viewModel: SampleViewModel) { val lifecycleOwner = LocalLifecycleOwner.current val viewState by remember(viewModel.viewState, lifecycleOwner) { viewModel.viewState.flowWithLifecycle( lifecycleOwner.lifecycle, Lifecycle.State.STARTED, ) }.collectAsState(ViewState.Empty) // ViewEventを発行するlambdaを下層のComposableに伝搬する ItemScreen( viewState = viewState, onItemClick = { viewModel.dispatchViewEvent(ViewEvent.ItemClick) }, onShopClick = { viewModel.dispatchViewEvent(ViewEvent.ShopClick) } ) } @Composable fun ItemScreen( viewState: ViewState, onItemClick: () -> Unit , onShopClick: () -> Unit , ) { Column { Item(viewState.itemViewData, onItemClick) Shop(viewState.shopViewData, onShopClick) } } @Composable fun Item(viewData: ItemViewData, onClick: () -> Unit ) { // 伝搬されたlambdaをComposableのクリックリスナーから実行する Column(modifier = Modifier.clickable { onClick.invoke() }) { Text(viewData.name) Text(viewData.price) } } @Composable fun Shop(viewData: ShopViewData, onClick: () -> Unit ) { Text( modifier = Modifier.clickable { onClick.invoke() }, text = viewData.name, ) } ユーザーインタラクションなどによってEventが発生すると、その内容がlambdaを介して上位のComposableへと伝搬されます。 modifier = Modifier.clickable { onClick.invoke() } 最上位のComposableはViewEventをViewModelに発行します。ViewEventへの参照は最上位のComposableのみが持つため、下位のComposableはViewEventを意識することがなく、高い再利用性を維持することが可能になります。 2. アプリケーションの状態更新 アプリケーションの状態は以下のように更新されます。 class SampleViewModel : ViewModel() { private val itemState = MutableStateFlow(Item.Empty) private val shopState = MutableStateFlow(Shop.Empty) private val viewEvent = MutableSharedFlow<ViewEvent>() ... init { viewModelScope.launch { viewEvent.collect { when (it) { is ViewEvent.ItemClick -> { updateItem() } is ViewEvent.ShopClick -> { updateShop() } } } } } fun updateItem() { ... itemState.value = newItem } fun updateShop() { ... shopState.value = newShop } } アプリケーションの様々な状態はFlowで管理します。 ViewModelはViewEventを受け取ると、API通信などの処理を行い、Flowの値を更新します。 3. 画面全体の情報通知 ViewStateはComposableに対して以下のように通知されます。 data class ViewState( val itemViewDataList: ItemViewData, val shopViewData: ShopViewData, ) { companion object { val Empty = ViewState( itemViewDataList = ItemViewData.Empty, shopViewData = ShopViewData.Empty, ) } } 画面全体の表示データをUIに通知するため、ViewDataは1つのViewStateに集約されます。 ViewStateはViewModelで以下のように作成されます。 class ItemViewModel : ViewModel() { private val itemState = MutableStateFlow(Item.Empty) private val shopState = MutableStateFlow(Shop.Empty) val viewState = combine(itemState, shopState) { item, shop -> ViewState( item.mapToViewData(), shop.mapToViewData(), ) } } ViewModelではアプリケーションの状態更新によってFlowに値が流れると、combineメソッドでViewStateを作成し、UIに通知します。 4. UIの自動更新 ComposableでのUI更新は以下のように行われます。 @Composable fun ItemScreen(viewModel: ItemViewModel) { // ViewModelからViewStateを受け取る val lifecycleOwner = LocalLifecycleOwner.current val viewState by remember(viewModel.viewState, lifecycleOwner) { viewModel.viewState.flowWithLifecycle( lifecycleOwner.lifecycle, Lifecycle.State.STARTED, ) }.collectAsState(ViewState.Empty) ItemScreen(viewState) } @Composable fun ItemScreen(viewState: ViewState) { // 下層のComposableにUIの表示データを分配する Column { Item(viewState.itemViewData) Shop(viewState.shopViewData) } } @Composable fun Item(viewData: ItemViewData) { Column { Text(viewData.name) Text(viewData.price) } } @Composable fun Shop(viewData: ShopViewData) { Text(viewData.name) } ViewStateは最上位のComposableで受け取ります。 ComposableでのViewStateの受け取りには、lifecycle-runtime-ktx:2.4.0-alpha01で追加された flowWithLifecycle を使用しました。flowWithLifecycleを使用することで、アプリケーションがバックグラウンドにある状態でのFlowの収集を停止できます。 ViewDataが1つのViewStateに集約されたことで、最上位のComposableは常に画面全体の表示データを受け取ることが可能になりました。最上位のComposableはViewStateを受け取ると、下位のComposableにViewDataを分配します。UIの更新は、再コンポーズの仕組みによって自動で行われます。 Composable設計ルールの制定 チームでのJetpack Composeを使用した開発に向けて、無秩序なComposable作成を防ぐためのルールを制定しました。 Atomic Design Atomic Design は、UIの要素(コンポーネント)を6種類に分類して定義するデザイン手法です。 Atomic Designでは、コンポーネントを階層化して管理します。 上層のコンポーネントは下層のコンポーネントに依存できますが、下層のコンポーネントは上層のコンポーネントに依存できません。そうすることで、下層のコンポーネントに変更を加える際の影響から、上層のコンポーネントを守ることが可能になります。 UIの実装にAtomic Designを適用することで、UI要素を適切に分割することが可能になり、チームでの開発効率の向上が期待できます。また、Atomic DesignはReactなどのWebフロントの宣言的UIフレームワークと共に採用された実績も多くあります。以上の理由から、ZOZOTOWN Androidチームでは、Atomic DesignをベースとしたComposable設計ルールを制定しました。 以下に各層のコンポーネントの定義と検索画面への適用例を示します。 Atoms Atomsは機能的に分割できる最小単位のコンポーネントです。ZOZOTOWN Androidでは、Jetpack Composeによって提供されているTextやButton、RowなどのComposableをAtomsとして定義しました。また、独自に作成したComposableの内、吹き出しやアイコン等のそれ以上分割できないものについてもAtomsとして分類しました。 Molecules MoleculesはAtomsを組み合わせて作成するコンポーネントで、Atomsが持つ機能に意味や意図を与えます。ZOZOTOWN Androidでは、TextやRowなどを組み合わせて作成したComposableをMoleculesとして定義しました。 Organisms OrganismsはMoleculesや他のOrganismsと組み合わせて作成するコンポーネントです。Organismsは単体で明確な役割を持ちます。ZOZOTOWN Androidでは、AtomsやMolecules、他のOrganismsを組み合わせて作成したComposableをOrganismsとして定義しました。 Templates Templatesはページのレイアウトを定義するコンポーネントです。ZOZOTOWN Androidでは、画面全体のコンポーネントを保持し、各UI要素の表示に必要なViewDataを分配するComposableをTemplatesとして定義しました。 Pages Pagesは実際のデータをUIに反映するコンポーネントです。ZOZOTOWN Androidでは、ViewModelへの参照を持ち、UI上に表示するデータをTemplatesに渡す役割を持ったComposableをPagesと定義しました。また、PagesはViewModelに対してViewEventを発行する役割も担っています。 今後の課題 以上の取り組みによって、ZOZOTOWN AndroidでのJetpack Composeを使用した開発の方針を決めることができました。 しかし、今後さらに大規模な画面でJetpack Composeを使用するためには、加えて解決すべき課題があります。 アプリケーションの状態管理 新たな設計では、アプリケーションの状態がFlowで定義され、それを各画面のViewModelで管理しています。 しかし、検索画面などの主要画面では多くの状態が相互に影響するため、それら全てをViewModelで管理するとそれが肥大化します。この問題を解決するためには、ViewModel以下のレイヤーで適切にアプリケーションの状態を管理する仕組みが必要です。 今後は、主要画面へのJetpack Composeの導入に向けて、よりZOZOTOWN Androidに適したアプリケーションの状態管理の方法を検討する予定です。 ViewModelの扱い 新たな設計では、ViewStateの管理やEventの処理のためにAndroid Architecture Component(AAC)のViewModelを使用しています。しかし、AACのViewModelはComposableのライフサイクルと対応していません。プロダクトにJetpack Composeを本格的に導入し、FragmentからComposableへの置き換えを行うためにはこの問題を解決する必要があります 1 。 今後は、この問題の解決策についてAACのViewModelの使用廃止も視野に検討する予定です。 Composable設計ルールの活用 新たに検討したComposable設計ルールは、まだチームでの運用には至っていません。今後はチームでの運用を通して、よりZOZOTOWN Androidチームに適した形へとブラッシュアップする予定です。 また、Atomic DesignについてはComposable設計ルールだけでなく、デザイナー・エンジニア間の共通言語としての活用も検討する予定です。 まとめ 本記事では、ZOZOTOWN AndroidへのJetpack Compose導入時の課題と、その解決策についてご紹介しました。今後は、新たに検討した設計やルールを元に、より大規模な画面でJetpack Composeを使用した開発をしたいと考えています。 最後に ZOZOテクノロジーズではAndroidエンジニアを募集しています。ご興味のある方はこちらからご応募ください。 hrmos.co Compose (UI) beyond the UI (Part I): big changes | by Jordi Saumell | ProAndroidDev ↩
アバター
はじめに こんにちは。ブランドソリューション開発部の蔭山です。普段は Fulfillment by ZOZO (以下FBZ)というサービスを担当しています。FBZはZOZOTOWNの倉庫や物流システムをブランドさんの自社ECでご利用いただけるサービスです。 先日、FBZが稼働しているシステムについて、サービスを停止することなくマルチAZ化しました。サービスを停止せずにシングルAZ環境からマルチAZ環境へ切り替えるため、様々な調査や事前準備をした結果、無事故で切り替えることができました。この記事では、実際に対応した事例を交えながら、無停止で大規模なインフラ変更するために重要だったいくつかのポイントをご紹介します。 稼働中のサービスで、マルチAZ化といった大規模なインフラ構成変更を検討されている方の参考になれば幸いです。 マルチAZ化とは マルチAZ化とは、単一アベイラビリティゾーン(Availability Zone、以下AZ)内で構成されているAWSのリソースを、複数のAZにまたがるよう再構成することを指します。単一AZで動いている状態をシングルAZ、複数のAZで動いている状態をマルチAZと称します。複数のAZを使うことで、AZ単位での障害が発生した場合でもサービスを継続できるため、サービスの可用性向上に効果的です。FBZでは、東京リージョンの3AZにまたがるよう構成を変更しました。 マルチAZ化を実施した背景と課題 FBZは、LambdaやDynamoDBなどのフルマネージドサービスを用いたサーバーレスアーキテクチャで構成されたサービスです。構成の詳細についてご興味のある方は、以下の記事をご覧ください。 techblog.zozo.com AWSのフルマネージドサービスを最大限活用して構築されたFBZですが、サービス構築当初から一部のAWSリソースに関してはシングルAZにのみ存在しました。構築当時は求める可用性としてそれで十分でした。その後、特に大きな問題もなくサービスを拡大してきましたが、拡大するにつれシングルAZ構成の部分を単一障害点として問題視するようになりました。 FBZはZOZOTOWNを始め、ご利用いただいているブランドさまが管理されている多数のECシステムや基幹システムと密接に関わるサービスです。短時間でもサービスを停止してしまうと、数多くのブランドさまに影響が及んでしまいます。そのため、サービス停止なしでマルチAZ化を実施することにしました。 無停止で大規模なインフラ構成変更するために このような背景からFBZのマルチAZ化を進めることになったのですが、当時3つの課題がありました。 Lambda、DynamoDBなど、主要なリソースはServerless FrameworkもしくはCloudFormationで管理されていたが、FBZ全体をIaCで管理できていなかった VPCやElasticsearch Service、S3など 設定値の情報もまとまっていない どこがシングルAZで、どこが既にマルチAZなのか、AWSリソースの状態が完全に把握できていなかった 切り替え時に発生するAWSサービスごとのダウンタイムが把握できていなかった 以上の課題に対し、様々な調査や事前検証を経て、最終的には無事故でFBZ全体をマルチAZ化できました。特に力を入れた3つのポイントについて、実例を交えながらご紹介します。 ポイント1:サービスの現状を徹底的に可視化する 現状の設定や仕様を把握せずに、大規模なインフラ構成変更を進めることはできません。利用しているAWSサービスについて、現状どのような設定がなされているのか、AZ毎にどう配置されているのかなどを可視化することにしました。 Former2を利用してIaC化する まずは、FBZ全体のIaC化に着手します。AWSのコンソール画面を1つずつ調べる方法もありましたが、本件では Former2 というツールを利用しました。 Former2は、AWS上に存在するリソース情報からCloudFormationやTerraformなどのテンプレートを作成してくれるOSSです。 これを利用してVPCやElasticsearch ServiceなどのIaC化されていないリソースを一括してCloudFormationテンプレートへ出力できました。 また、テンプレート出力後は CloudFormationの既存リソースのインポート機能 を用いて、既存リソースをCloudFormationスタックに反映しました。 全リソースのCloudFormation管理が実現したことで、その設定値についてもコードで一覧できるようになりました。また、リソースをアップデートしやすい状態となりました。 公式ドキュメントからサービス仕様を把握する 完成したCloudFormationテンプレートを元に、各AWSサービスの公式ドキュメントを調査し、どのリソースをマルチAZ化すべきか対象のサービスを絞りました。最終的にFBZでは以下のリソースを変更することにしました。 VPC 1AZに集中しているサブネットを3AZに拡張する Lambda 関数の起動対象となるサブネットを変更 Elasticsearch Service ノードの追加 ポイント2:切り替えるタイミングでの挙動を事前に試す 調査の次に重要なのは、事前検証です。サービスを無停止で事故なくインフラ構成を変更するためには、事前に本番相当の検証環境で挙動を確かめておくことが大事なポイントです。どのように検証したのかを実例を通してご紹介します。 VPCの場合 FBZでは、既存のVPCリソースにはほとんど手を入れず、サブネットやNATゲートウェイ、ルートテーブル、VPCエンドポイントなどのリソースをそのまま別のAZにも作成することとしました。実際にはこのような構成で設計しました。 この時点で、既存リソースをCloudFormationスタックにインポートしていたため、構成変更にあたっては既存リソースへ影響を与えないよう注意深くテンプレートを記述しました。結果としては既存のリソースに影響はなく、新規リソースのみ作成できました。 Lambdaの場合 FBZでは、 Serverless Framework を利用してLambda関数やDynamoDBなどの設定を管理しています。LambdaのマルチAZ化においてはServerless Frameworkでの設定値を一部変更するだけで対応できました。 具体的にはserverless.yaml内の、provider.vpc.subnetIdsに今回追加したプライベートサブネットのIDを追加するのみとなります。以下にサンプルを記載します。 provider : name : aws (略) vpc : securityGroupIds : - sg-xxxxxxxx subnetIds : - subnet-xxxxxxxx1 - subnet-xxxxxxxx2 # 今回追加したプライベートサブネットID1 - subnet-xxxxxxxx3 # 今回追加したプライベートサブネットID2 また、設定変更時もLambda関数の実行が継続すること(呼び出しが瞬断しないこと、異常終了しないこと)を確かめるため、本番環境で実際に発生する量のトラフィックを流しつつ、Serverless Frameworkでリリースする検証しました。結果として同期呼び出し・非同期呼び出しともに、問題ありませんでした。サブネット設定の変更をしたその次の関数呼び出しからマルチAZにて関数が起動します。そのため、起動中の処理には影響が無いことがわかりました。 Elasticsearch Serviceの場合 Elasticsearch Serviceには、 Blue/Greenアップデートで設定を展開していく仕組み があります。これによってサービス停止することなくマルチAZ化できることがわかりました。 こちらもAWS Lambdaと同様に実際のトラフィック量を流しつつ設定変更時の挙動を検証しましたところ、エラーなく切り替えできることが確認できました。 ポイント3:サービス無停止を実現するための分割リリース設計 調査、検証ときて、最後に重要なのはリリース設計です。どのような順番であればサービスを停止せずに済むのか、また有事にロールバックできるのか、その一連の流れを設計することが大事なポイントです。以下のようにリリースを数回に分けて実施しました。 VPCリリース サービス全体に影響が出ていないか検証、数日間監視 Elasticsearch Serviceリリース Elasticsearch Serviceに関連する処理で影響が出ていないか検証、数日間監視 疎通検証用Lambdaを新しく追加したAZでリリース 疎通検証用Lambdaで各種リソースの疎通確認 FBZを構成しているLambdaをリリース サービス全体に影響が出ていないか検証・監視 一度に全部の構成を変更するのではなく、一部のみを変更し、検証・数日間監視するといったリリースフローとしたことにより事前検証で検知できなかった不測のエラーに備えました。結果としてはいずれも問題なくリリースできましたが、このように万が一に備えたリリースフローを組むこともサービス停止させないために大切です。 まとめ 現状の可視化・検証・リリース設計を重点的に実施することにより、サービスを停止することなく大規模なインフラ構成変更を実現しました。以上の3点は、決して特別なことではないものの、実際にそれらを丁寧に実施することでその重要性を改めて感じました。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは、コーポレートエンジニアリング部の竹田です。ZOZOテクノロジーズでは昨今の情勢を受け、日本全国どこに居住していても就業可能な 全国在宅勤務制度 を導入しています。また、ZOZOにおいてもアフターコロナを見据えた 週2出社・週3リモート というハイブリッドな働き方の導入を予定しています。 座席管理システム導入の経緯 そのような新しい働き方に対応すべく、本社屋である西千葉オフィスはフリーアドレスを導入しています。異なるワークスタイルを持つ2社の社員が利用するオフィスですので、それぞれの要望を満たす座席管理システムの導入が必要となりました。例えば、テクノロジーズ社員であれば好きなときに好きな座席(テクノロジーズ社員に割り当てられたエリア内において)を予約できる。ZOZO社員であれば当月の出社予定日と、その日に割り当てられた座席が事前に確認できるといった具合です。 重視したポイント システムの導入にあたって、利用者の目線と管理者の目線からそれぞれ以下のようなポイントを重視しました。 利用者の目線 SSOでログインしたい 直感的なUIで予約操作をしたい 誰がどこの座席にいるか把握したい 管理者の目線 利用者の要望を満たしたい アカウント管理を省力化したい APIでシステマチックに管理したい SSOでログインしたい 弊社ではAzure ADを認証基盤として利用しており、「マイ アプリ ポータル」から利用したいSaaSを選択してログインしています。 利用者は各SaaSの認証を意識することなくサービスを利用できるというメリットがあります。今回導入するシステムはSAML認証に対応しているというのが要件の1つとなります。 直感的なUIで予約操作をしたい 理想のイメージは、飛行機や映画館の座席予約システムです。建造物のアウトラインと座席配置が表示された画面上で、予約済み座席であれば灰色、空き座席であれば青色や緑色などで表現されてあれば、どこが空き座席なのかひと目でわかります。更に言えば、UIデザインがオシャレであると、なお良しです。 誰がどこの座席にいるか把握したい フリーアドレスだと誰がどこにいるのかわからない状態になります。先の直感的なUIに通ずるものがありますが、座席配置が表示された画面上で、座席利用者の名前がわかるとそれが解消されます。また、特定人物に絞って検索するとその人の座席位置がピンポイントでわかる機能もあると便利です。 利用者の要望を満たしたい 利用者にとって使いやすい、わかりやすいというのは管理者にとってもメリットになります。準備するマニュアルもシンプルになり、システム導入後のヘルプデスク対応に費やす時間が大きく減少するためです。 アカウント管理を省力化したい 我々が管理するシステムは多岐に渡り、システムごとにアカウントが存在します。社員の入社・退社の度にアカウントの追加や削除を対応するのは大変ですし、対応漏れが発生する可能性も考えられます。そのような労力を少しでも省くために、SCIMプロビジョニングに対応していることを特に重視しました。 APIでシステマチックに管理したい 将来的に、座席の管理や予約の管理を省力化するため、API連携可能なシステムであることが望ましいです。Webインタフェースからでは手間のかかる大量のオペレーションも、APIが利用できれば迅速かつ正確に対応できます。 システムの選定 以上のポイントを簡潔にまとめると以下のようになります。 わかりやすいUIである SAML認証に対応している SCIMプロビジョニングに対応している APIが用意されている これらを満たしたシステムがRobin Desksです。 Robinとは Robinは、米国 Robin Powered 社より提供されるオフィスの柔軟かつ効果的な利用を促進するプラットフォームです。Robinには大きく2つの機能があります。今回導入した座席管理を提供するRobin Desksと、会議室管理を提供するRobin Spacesです。弊社では、Robin Spacesをすでに導入しており、これもRobin Desksの導入に至った決め手の1つとなりました。 出典: Workplace management software for flexible offices | Robin プランの選定 Robinには以下のプランが用意されています。 Basic Pro Premier SAML認証およびSCIMプロビジョニングに対応しているプランはPremierのみであるため、今回は必然的にPremierを採用しました。その他にもPremierであればカスタムロールによる細やかな制御が可能であったり、専属のカスタマーサクセスマネージャーがついたりと、一定以上の組織であれば欲しい機能が提供されます。担当者やサポート窓口とのやり取りは基本的に英語となりますが、私のように英語が苦手であってもGoogle翻訳でなんとかなっています。 実運用に至るまで ここからはRobin Desksが実運用に至るまでに対応したことを記述します。Robin Desksについてのネット上の情報はRobin Spacesよりも圧倒的に少なく、日本語の情報に限っては2021/08/10時点でほぼ皆無でした。特に苦労したAzure ADにおけるRobinのSAML認証、およびSCIMプロビジョニング対応については、その詳細をここに残します。 SAML認証の設定 Robinの ヘルプセンター と、Microsoftの ドキュメント を参考に、以下のように設定を進めます。 Azure ADの「エンタープライズアプリケーション」で「すべてのアプリケーション」>「新しいアプリケーション」と遷移し、ギャラリーからRobinを追加する 「シングル サインオン」>「SAML」と遷移し、「フェデレーション メタデータXML」をダウンロードする Robinの管理画面で「Manage」>「Integrations」と遷移し、下方にある「SAML 2.0」の「Add」をクリックする 「Import IDP Metadata」からさきほどダウンロードしたXMLファイルをインポートすると、各項目が自動で入力される 「Advanced Options」を開いて「Encrypt Assertion」のチェックを外し、「Windows」のチェックをつける 「Save Configuration」で保存して完了 設定が完了したら、正常にSSOできるか確認してください。特にRobinの「Advanced Options」の設定がデフォルトのままだと、マイ アプリ ポータルからはサインイン可能ですが、 Robinのサインイン画面 からだと認証エラーとなりました。 SCIMプロビジョニングの設定 Robinの ヘルプセンター と、Microsoftの ドキュメント を参考に以下のように設定を進めます。 Robinの管理画面で「Manage」>「Integrations」と遷移し、「SCIM Provisioning」の「Manage」をクリックする 「Generate Token for SCIM」でトークンをコピーしておく さきほどAzure ADで追加したRobinアプリの「プロビジョニング」から「作業の開始」をクリックする プロビジョニングの画面で以下のように設定する プロビジョニングモード: 自動 テナントのURL: https://api.robinpowered.com/v1.0/scim-2 シークレット トークン: さきほどRobinの管理画面で生成したトークン マッピングを以下のように設定する ユーザーのマッピング設定 グループのマッピング設定 エラーの通知先メールアドレスとスコープを設定して完了 設定が完了したらユーザーやグループを割り当て、プロビジョニングの初期サイクルが完了するまで待機します。設定が正しければ、Robin側にユーザーアカウントやグループが作成されます。 フロアマップと座席の設定 Robin上に手持ちのフロアマップデータを直接反映はできず、Robin Powered社の担当者にオフィスの設計図や間取り図のデータを渡す必要があります。渡したデータを元にRobin Powered社のマップチームが専用のフロアマップデータを作成してくれます。 元となる間取り図データの例 出典: Converting floor plans to Robin Maps – Robin Help Center Robin用にコンバートされたデータの例 出典: Converting floor plans to Robin Maps – Robin Help Center データを渡してから数日後にオフィスのアウトラインを抽出したフロアマップがRobin上に反映されるので、このフロアマップに対して座席レイアウトを設定していきます。座席レイアウトは以下のような画面でドラッグ&ドロップによる直感的な操作が可能です。 座席のタイプには以下の3つを設定できますが、フリーアドレスの場合はHotもしくはHoteledを設定することになると思います。基本的にはHoteledでユーザーに自由に座席を選択・予約させて、一部固定席の場合はAssignedで管理者が利用者を割り当てるといった運用が良いのではないでしょうか。 タイプ 予約期間 事前予約できるか 誰が予約できるか チェックイン対応か Assigned 永続 できる 管理者もしくは委任者 対応 Hot 1日のみ 当日のみ 一般ユーザー 対応 Hoteled 1〜5日以上 できる 一般ユーザー 対応 APIの活用事例 RobinにはAPIが用意されています。今回はこのAPIを利用して、指定日に指定座席を一括で予約する事例をご紹介します。例えば、2週間後の水曜日にオフィス1Fのフロア半分をイベントで貸し切りたいといった依頼があったとします。そのような場合にWebインタフェースからポチポチと手作業で座席を確保するのは非効率です。管理画面上からCSVで座席を割り当てる機能があるものの、こちらの機能は座席タイプがAssignedでないと利用できません。今後、類似の依頼が発生することは明らかであったため、APIで解決に取り組みました。APIについてのドキュメントは以下に用意されています。 Getting Started APIリファレンス 座席を予約するAPIは こちら です。必須パラメーターとなる座席IDの取得方法と、リソース構成を説明します。 リソースの構成 リソースは大きい順から以下のようになっています。 OrganizationはLoactionを内包し、LocationはSpaceを内包しているイメージです。 Organizaition 一番大きなくくりで、契約の単位でもある。例えば会社や組織といったものが該当する。 " data ": { " id ":<account_id>, " is_organization ": true , " name ":" ZOZO GROUP ", " slug ":" <slug> ", " avatar ":" https://static.robinpowered.com/reimagine/images/***.png ", " created_at ":" 20YY-MM-DDTHH:MM:SS+0000 ", " updated_at ":" 20YY-MM-DDTHH:MM:SS+0000 " } cf. https://docs.robinpowered.com/reference#get-organization Location 物理的な場所のことで、住所で表現できるもの。例えばオフィスの入居するビルが該当する。 " data ": [ { " id ":<location_id>, " account_id ":<account_id>, " campus_id ":<campus_id>, " name ":" 西千葉オフィス ", " description ": NULL , " image ": NULL , " address ":" 日本、千葉県千葉市稲毛区緑町1丁目15 ", " latitude ": 35.6255103 , " longitude ": 140.0988645 , " time_zone ":" Asia/Tokyo ", " created_at ":" 20YY-MM-DDTHH:MM:SS+0000 ", " updated_at ":" 20YY-MM-DDTHH:MM:SS+0000 ", " working_hours ": [ ... ] } , { ... } ] cf. https://docs.robinpowered.com/reference#get-organization-locations Space 建物内の部屋やエリアのこと。レベル(階層)情報もここにある。例えば2Fの会議室Bや3Fの営業部エリアといったものが該当する。 " data ": [ { " id ":<space_id>, " location_id ":<location_id>, " level_id ":<level_id>, " name ":" 1F Area A ", " description ": NULL , " image ":" https://static.robinpowered.com/reimagine/images/***.png ", " discovery_radius ": 3.5 , " capacity ": NULL , " type ":" work ", " is_accessible ": false , " is_managed ": false , " is_disabled ": false , " updated_at ":" 20YY-MM-DDTHH:MM:SS+0000 ", " created_at ":" 20YY-MM-DDTHH:MM:SS+0000 ", " behaviors ": [ ... ] } , { ... } ] cf. https://docs.robinpowered.com/reference#get-location-spaces Zone / Desk ゾーン(フロアマップ上でPodと表現されている、座席グループのようなもの)と座席。 " data ": [ { " id ":<zone_id>, " space_id ":<space_id>, " name ":" Zone B ", " type ":" pod ", " created_at ":" 20YY-MM-DDTHH:MM:SS+0000 ", " updated_at ":" 20YY-MM-DDTHH:MM:SS+0000 " } , { ... } ] cf. https://docs.robinpowered.com/reference#spacesidzones " data ": [ { " id ":<desk_id>, " name ":" Desk 3 ", " space_id ":<space_id>, " zone_id ":<zone_id>, " is_reservable ": true , " is_disabled ": false , " disabled_at ": NULL , " created_at ":" 20YY-MM-DDTHH:MM:SS+0000 ", " updated_at ":" 20YY-MM-DDTHH:MM:SS+0000 " } , { ... } ] cf. https://docs.robinpowered.com/reference#spacesidseats 「株式会社ZOZO > 西千葉オフィス > 1F > 座席エリアA > ゾーンB > 座席No.3」というような情報をもって初めて座席を特定可能となります。そのため、座席IDを取得するためには、大きなくくりから順番にたどる必要があります。 対象となるオフィスのlocation_idを取得 取得したlocation_idを元に、対象となるエリアのspace_idを取得 取得したspace_idを元に、対象となる座席のdesk_idを取得 座席予約のAPI 座席IDを取得できたらようやく座席予約が可能となります。 こちら にあるように、以下パラメーターを指定します。 id: 予約する座席のID title: 予約のタイトル type: 予約のタイプ start: 予約の開始日時 date_time: ISO 8601形式の時刻表記 time_zone: タイムゾーン end: 予約の終了日時 date_time: ISO 8601形式の時刻表記 time_zone: タイムゾーン reservee: 被予約者 email : 被予約者のメールアドレス user_id : 被予約者のユーザーID reserver_id: 予約操作するユーザーのID 一例として、Pythonで日本時間の2021年8月15日午前9時30分から2021年8月18日午後8時まで座席を予約したい場合は、以下のようにPOSTします。 url = "https://api.robinpowered.com/v1.0/seats/" + <desk_id> + "/reservations" headers = { "Authorization" : "Access-Token " + <APIトークン>, "Content-Type" : "application/json" } payload = { "type" : "hoteled" , "start" :{ "date_time" : "2021-08-15T09:30:00+09:00" , "time_zone" : "Asia/Tokyo" }, "end" :{ "date_time" : "2021-08-18T20:00:00+09:00" , "time_zone" : "Asia/Tokyo" }, "reservee" :{ "user_id" :<被予約者のユーザーID> # emailかuser_idのいずれかを指定する必要がある }, "reserver_id" :<予約者のユーザーID> # reserveeと異なる場合は、reserverが代理で予約したような扱いとなる } response = requests.post(url, headers=headers, json=payload) 事前に座席IDと名前の一覧をリスト化し、フロアマップと並べるなどして実運用しやすいインタフェースを準備しておくと便利です。実際に、私達は以下のようなものを用意して運用しています。 その他の機能 Robinには他にも便利な機能があります。そのいくつかをご紹介します。 Slack App Robin公式のSlack Appが用意されており、会議室の空き状況や特定人物の座席などを問い合わせることができます。弊社ではSlackをスタンダードなコミュニケーションツールとして採用しており、ツールの切替が発生しないことは大きな利点です。 会議室の空き状況確認 特定人物の座席確認 座席の確認では、フロアマップも一緒に表示してくれるため、非常にわかりやすいです。 Analytics 会議室や座席の利用状況を可視化できます。特に昨今はソーシャルディスタンスが重要視されているため、オフィスの人口密度が視覚的にわかることで、感染予防など健康への応用も考えられます。 西千葉オフィス: 6月1日〜7月25日の座席の利用状況 このご時勢ですので、基本的にオフィス利用率は非常に低くなっています。いくつか微妙に利用率が上昇している日がありますが、これは先にご紹介した座席の一括予約で制御した日です。 まとめ 昨今の情勢に適応したオフィスの柔軟かつ効果的な利用を促進するプラットフォームであるRobin。その中でも座席管理のRobin Desksにフォーカスを当て、実運用に至るまでの準備やAPIを活用した座席予約の事例をご紹介しました。今後オフィスの利用が今よりも活発化することを見据えて、Robinのさらなる活用を模索したいと思います。 ZOZOテクノロジーズでは、一緒にスタッフや組織の課題をテクノロジーの力で解決してくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは。ZOZOTOWN部フロントエンドチームの菊地( @hiro0218 )です。 2021年3月、ZOZOTOWNは10年ぶりのリニューアルをしました。この記事では、そのリニューアルで再考したCSS設計について紹介します。 背景 今回のリニューアルでは、ウェブとアプリが部分的に共通のデザインになりました。 アプリ ウェブ このデザイン刷新には、CSSの大規模変更が必要です。チーム内で検討を重ね、最終的に、大きく書き換えるのであればコンポーネント駆動開発 1 ができるようにCSS設計を見直すべきという結論に至りました。 CSS設計で特別に考慮する点 現在、ZOZOTOWNのフロントエンドは、「Classic ASP」から「 React 」へのリプレイスを進めています。新規開発や変更のタイミングで、Classic ASPに依存した実装をReactへ改修します。 ただ、今回のリニューアルではClassic ASPをReactへリプレイスする時間的余裕がなく、見た目は共通でもClassic ASPとReactが混在する実装にせざるを得ませんでした。Classic ASP側にドラスティックな変更を入れず、かつシステム全体を通してはコンポーネント駆動開発を実現する、そのようなCSS設計が必要でした。 設計 体制 現在、ZOZOTOWNのWebフロントエンドは3チームに分かれています。全てのチームで共通の設計を採用できるよう、各チームから1人ずつCSSリーダーを選出し、合計3名で新たなCSS設計をしました。実装や運用に伴って発生する問題もCSSリーダーが取りまとめ、都度設計に反映、コードやルールに落とし込むという体制です。 要件 既存のCSSでも一部はコンポーネントごとに定義がまとめられています。しかしながら、仕様追加の積み重ねによってカスケードが多重化され、CSSファイルからその状態を把握することが難しくなっていました。 一方、今回のリニューアルプロジェクトはスケジュールが逼迫しており、チームを横断して多数のメンバーが同時期に実装することが必要でした。これらを踏まえ、設計の要件を下記のように定めました。 作業分担しやすいこと 複数のメンバーが同時に作業をしても競合しづらい 保守性が高いこと リプレイス中のClassic ASPとReactで共通のルールが採用できる CSS全体を一貫したルールで記述できる 導入コストが低いこと Classic ASPの変更を最小限にできること CSSには様々な設計手法がありますが、以上の要件を満たしつつ現状のZOZOTOWNにマッチする設計手法は、「ITCSS」という判断に至りました。 ITCSSとは ITCSSは、 CSS Wizardy の Harry Roberts 氏が提唱したCSSの詳細度を管理する設計思想です。 「Inverted Triangle CSS (逆三角形のCSS)」の略で、設定の詳細度順に階層化して記述します。7つのレイヤーが定義されており、この記述が逆三角形として可視化されます。 ITCSSのレイヤー Settings Tools Generic Base Objects Components Trumps 「CSSプリプロセッサなどで利用する変数や設定」であればSettings、「OOCSSの概念に基づいた定義」であればObjects、というように各レイヤーの役割が決まっています。 なお、ITCSSのレイヤーは、必要に応じて追加・削除することも許容されます。 CSSプリプロセッサを利用していない場合、SettingsやToolsなどのレイヤーを削除 OOCSSを使用していなければObjectsレイヤーを削除 テーマ性が必要であればThemeレイヤーを追加 といった調整も可能です。今回のリニューアルプロジェクトでは標準のITCSSに加えて、カスタムレイヤーを加えました。詳細は後述します。 各レイヤーは次の性質を持っています。 下位レイヤー(図の下側)ほど詳細度が上がる 上位レイヤーが下位レイヤーを上書きしない この性質は、複数人での同時作業を助けます。 複数の作業者が同時に作業しながら破綻を避けるには、定義や分割粒度を設計者以外でも同じように行えることが重要です。それはITCSS以外の設計手法を用いても可能ですが、ITCSSの場合は各定義の責務を理解しやすいのが大きなメリットだと感じました。 例えばAtomic Designは、デザイナー・エンジニア間で浸透した設計思想であるため、共通の思想で各インタフェースの責務を分割できるのが大きなメリットです。ただ、デザイナー同士でも責務分割の粒度が異なるケースもあります。また、デザイナーとエンジニアでも言語や仕様などの都合によって、責務分割の粒度が大きく異なることも少なくありません。さらにはエンジニア同士の責務分割の粒度が異なってしまうことも当然あり、そうなるとソースコードの混沌は避けられません。 デザインの意図を正確にソースコード(CSS)に落とし込むことができればCSSの破綻は防げるのですが、デザイナーと実装者が同じでない限り、例に上げたように伝達の過程で歪みは生じやすくなります。その点、ITCSSでは定義の責務が明確で個人の判断に任せる部分が少なく、一貫した設計が可能です。 コンポーネントの命名規則 ITCSSでは、コンポーネントごとの命名規則が定められていません。私達はMindBEMding(BEM)に接頭辞を組み合わせることにしました。 MindBEMding(BEM) MindBEMding は、BEMから派生した命名規則です。こちらもITCSSと同様にHarry Roberts氏が提唱しています。 下記の命名パターンで構成します。 名称 説明 .Block 親要素 / 独立した要素 .Block__Element Blockに紐付いた要素 / Block内でいくつも存在できる .Block--Modifier .Block__Element--Modifier バリエーションや状態を変化させるときに指定する 命名は基本的なBEMに準じていれば良しとしています。ただし、 .Block__Element__Element という命名パターンだけは、構造が複雑になり見通しも悪くなることが明らかであったため採用していません。 BEMを利用した命名のメリットに「クラス名からクラスが持っている役割が分かりやすくなる」という点がありますが、さらにITCSSとの組み合わせによって役割と責務が一見して分かりやすい構造になります。共通の命名規則が決まっていることで実装とレビューが円滑にできるようになりました。 接頭辞 今回のリニューアルは、広範囲に渡る大規模な改修ですが、それでもCSSを全て置き換えるわけではありません。そのため、命名によっては既存CSSと競合する可能性があります。そこで競合しないように接頭辞を整備しました。 ITCSSのレイヤーに応じた接頭辞を付与します。これにより接頭辞を一目見ただけで役割を把握できます。また、レイヤー同士の名前衝突も避けられます。次の表は、接頭辞の例です。 レイヤー名 接頭辞 Objects .o- Components .c- Trumps(Utility) .u- 既存のCSSに接頭辞が基本的に付いていなかったこともあり、副次的なメリットとして、ソースコードを一見して新旧コードが分かりやすくなりました。 ITCSSと命名規則(MindBEMding + 接頭辞)の組み合わせで実現できること 以下のような設計が実現できます。 ITCSS レイヤーに沿うだけで詳細度が管理できるため、破綻しにくく、保守しやすいコードを書ける レイヤーが分かれているため、複数メンバーが同時に開発しても競合しづらい 管理方法が分かりやすいため、設計者以外のメンバーでも定義のズレがなく、分割粒度が揃いやすい MindBEMding + 接頭辞 クラス名を一見して定義のもつ役割が分かりやすい 命名のブレや迷いが少なくなる ITCSSのレイヤーに基づく接頭辞を使うことで、コンポーネント同士で命名の衝突が少なくなる 元より接頭辞がない既存CSSとは衝突しない 詳細 最終的に次のように設計しました。 ITCSSディレクトリ構造例 style ├── Settings │ ├── _colors.css │ ├── _variables.css │ └── ... ├── Tools │ ├── _animation.css │ ├── _mixins.css │ └── ... ├── Generic │ ├── _font.css │ ├── _reset.css │ └── ... ├── Base │ ├── _global.css │ └── ... ├── Layouts │ ├── _grid.css │ └── ... ├── Objects │ ├── _form.css │ └── ... ├── Vendor │ ├── _swiper.css │ └── ... ├── Components │ ├── _breadcrumbs.css │ ├── _button.css │ └── ... ├── Model │ ├── _pagination.css │ └── ... ├── Site │ ├── _drawer.css │ ├── _header.css │ ├── _footer.css │ └── ... ├── Pages │ ├── _home.css │ ├── _goods.css │ ├── _cart.css │ └── ... └── Trumps ├── _text.css └── ... CSS命名例 /* Objects */ .o-scroll {} .o-scroll__container {} /* Components */ .c-catalog {} .c-catalog-header {} .c-catalog-body {} .c-catalog-body__title {} /* Model */ .m-catalog-scroll {} .m-catalog-scroll__item {} 順を追って説明します。 レイヤー構造 ZOZOTOWNでは多くのページを管理する必要があり、責務をより明確にするにはデフォルトのレイヤーだけでは足りなかったため、独自のレイヤーを追加しました。 下記のようなレイヤー構成にしています(太字のものが追加したレイヤーです)。 # レイヤー 役割 追加レイヤーの役割 1 Settings CSSプリプロセッサなどで利用する変数や設定 〃 2 Tools CSSプリプロセッサで利用する mixin や function などの定義 〃 3 Generic リセットスタイルや固有のリセットスタイル定義 〃 4 Base 素のHTML 要素のスタイル定義 〃 5 Layouts - ページ間で共通の大きなレイアウト定義 6 Objects OOCSSの概念に基づいた定義 〃 7 Vendor - 外部ライブラリから提供される固有のスタイルを定義 8 Components 再利用可能なコンポーネント(UIパーツ)を定義 〃 9 Model - コンポーネント同士の組み合わせやコンポーネントの粒度に満たない汎用的なUIの定義 10 Site - サイトを横断的に利用されるUIの定義 11 Pages - ページ固有の定義や上位のレイヤーの定義を上書きするような定義 12 Trumps(Utility) ヘルパー・ユーティリティ系の汎用スタイルを定義 〃 各レイヤーの利用方法の詳細は、以下の通りです(各種コードはサンプルです)。 Settings このレイヤーには、CSSプリプロセッサなどで利用する変数や設定を配置します。 CSS Custom Propertiesの定義もこのレイヤーで行いました。 $font-family: -apple-system , BlinkMacSystemFont , "Segoe UI" , Roboto , "Helvetica Neue" , Arial , sans-serif , "Apple Color Emoji" , "Segoe UI Emoji" , "Segoe UI Symbol" , "Noto Color Emoji" !default; $color-ui: #bada55 ; $spacing-unit: 10px; Tools このレイヤーには、CSSプリプロセッサで利用する mixin や function などの定義を配置します。CSSプリプロセッサを利用していない場合は不要かもしれません。 @function str-replace($string , $search , $replace: "" ) { $index: str-index($string , $search); @if $index { @return str-slice($string, 1 , $index - 1 ) + $replace + str-replace( str-slice($string, $index + str-length($search)), $search, $replace ); } @return $string; } @mixin font-brand() { font-family : "UI Font" , sans-serif ; font-weight : 400 ; } Generic このレイヤーには、リセットスタイルや固有のリセットスタイル定義を配置します(低詳細度で広範囲に当たる定義)。 @import "reset.css" ; * , * :: before , * :: after { -webkit- box-sizing : border-box ; -moz- box-sizing : border-box ; box-sizing : border-box ; } Base このレイヤーには、素のHTML要素のスタイル定義を配置します。クラスセレクターなどは使用せず、 a , h1…6 , ul…li などの要素セレクターのみで構成します。 ul { list-style : square outside ; } Layouts このレイヤーは独自に追加したレイヤーです。 このレイヤーには、ページ間で共通の大きなレイアウト定義(グリッドなど)を配置します。余白や幅など装飾を持たないスタイルを定義します。 .l-main { margin : 0 auto ; width : 100% ; } Objects このレイヤーには、OOCSS(Object Oriented CSS)の概念に基づいた定義を配置します。Layoutsレイヤーとの違いとしては、ページ内で繰り返し使えるものを想定しています。余白や幅など装飾を持たないスタイルを定義します。 .o-ui-list { margin : 0 ; padding : 0 ; list-style : none ; } .o-ui-list__item { padding : $spacing-unit; } Vendor このレイヤーは独自に追加したレイヤーです。 このレイヤーには、外部ライブラリから提供される固有のスタイルを読み込み、外部ライブラリが定義しているスタイルを上書きするための定義を配置します。 @import 'swiper/swiper-bundle.css' ; .swiper-module { overflow : hidden ; } Components このレイヤーには、再利用可能なコンポーネント(UIパーツ)を定義します。コンポーネントにマージンは持たせず、ObjectsやModelレイヤーとの組み合わせで余白は再現します。 .c-products-list { @include font -brand(); border-top : 1px solid $color-ui; } .c-products-list__item { border-bottom : 1px solid $color-ui; } Model このレイヤーは独自に追加したレイヤーです。 このレイヤーには、コンポーネント同士の組み合わせやコンポーネントの粒度に満たない汎用的なUIの定義を配置しています。 コンポーネント同士を組み合わせたい場面はよくあるものの、保守性の観点から、同レイヤー同士の組み合わせをルールで禁止しています。一方、組み合わせの定義を繰り返すと冗長になってしまうため、このレイヤーを設けています。中間層のレイヤーを導入したことにより、可読性の面だけではなく、詳細度の複雑さを和らげる効果もありました。 下記は、年月日のフォームの例です。 c-input の組み合わせで特殊なフォームを再現しています。このフォームは情報登録・編集画面の限られた共通パーツであったため、Modelレイヤーに定義をします。 .m-input-decoration-birth { display : flex ; @each $date-name, $date- label in (year: "年" , month: "月" , day: "日" ) { & __ # { $date-name } { display : flex ; flex : 1 ; align-items : center ; &::after { content : $date-label; width : 24px ; text-align : center ; } } } } < div class = "m-input-decoration-birth" > < div class = "m-input-decoration-birth__year" > < div class = "c-input" > < input type = "text" class = "c-input__text" > </ div > </ div > < div class = "m-input-decoration-birth__month" > < div class = "c-input" > < input type = "text" class = "c-input__text" > </ div > </ div > < div class = "m-input-decoration-birth__day" > < div class = "c-input" > < input type = "text" class = "c-input__text" > </ div > </ div > </ div > Site このレイヤーは独自に追加したレイヤーです。 このレイヤーには、サイトを横断的に利用されるUI(グローバルヘッダーやグローバルフッターなど)の定義を配置しています。コンポーネントとほぼ役割は変わりませんが、ページ内で一度登場するようなものをこのレイヤーにまとめています。 .s-header { display : flex ; align-items : stretch ; justify-content : space-between ; margin : 0 auto ; } Pages このレイヤーは独自に追加したレイヤーです。 このレイヤーには、ページ固有の定義や上位のレイヤーの定義を上書きするような定義を配置します。 これまでのレイヤーで定義したコンポーネントやレイアウトスタイルの組み合わせで完結しないような、ページ固有のUIパーツやコンポーネントのバリエーションを再現するのに使います。 Pagesレイヤーではカスケード用のクラスを用意して、トップレベルのHTML要素にクラス付与して実装を定義します。また、Pages固有の定義については、カスケード用のクラスを継承した名称で定義します。 .p-product-detail { background-color : $bg-color- gray ; .o-form-group { margin-bottom : 16px ; } .c-button { border : none ; } } .p-product-detail-heading { font-size : 28px ; margin-bottom : 8px ; } < body class = "p-product-detail" > < header class = "l-header" > < div class = "s-header" ></ div > </ header > < main class = "l-main" > < h1 class = "p-product-detail-heading" > heading </ h1 > < div class = "o-form-group" > < input type = "text" class = "c-input" /> < button type = "button" class = "c-button" > button </ button > </ div > </ main > < footer class = "l-footer" > < div class = "s-footer" ></ div > </ footer > </ body > Trumps(Utility) このレイヤーには、ヘルパー・ユーティリティ系の汎用スタイルを定義します。これまでのレイヤーよりもスコープが最も狭くなるよう1つのDOMだけに影響させるような定義をします。 本来は「Trumps(切り札)」という名称ですが、あまり馴染みのある表現ではなかったので、ZOZOTOWNでは「Utility」という名称で運用しています。 .u-text-color { color : $color- text -important !important ; } しかしながら、このTrumpsレイヤーは、本当に切り札として使うのが最適だと考えています。詳細度を管理する上での問題もありますが、汎用クラスはその実装から「どのような状態になるのか」を理解しづらいためです。例えば「色を変えたい」のか「セール価格として強調したい」のかをCSSの実装だけで汲み取るのは困難で、HTMLと前後の文脈を照らし合わせる必要があります。こういう理由から、汎用クラスは後のリファクタリグの際に実装を紐解いていく必要があり、使い方によっては負債になりがちな要素となります。 まとめ 以上が今回のリニューアルで再考したCSS設計の思想とルールです。 詳細度の管理のためにITCSSを、既存実装に影響を与えないようにクラス命名規則にMindBEMdingと接頭辞を採用しました。これらの導入によって、CSSの詳細度の管理が容易となり、設計に造詣が深くないメンバーでもITCSSのルールに則るだけで破綻しにくいCSS実装が可能となりました。まだまだリプレイスの途中ということもあり、過去の資産と共存するために記載以外でも拡張している箇所があります。しかし、ITCSSの柔軟性のおかげで追加仕様もその枠組で吸収できています。 なお、本記事では触れませんでしたが、ZOZOTOWNのCSSを取り巻く状況は以下のようになっています。 モジュールバンドラは webpack を利用 CSSプリプロセッサは PostCSS を利用 Classic ASPとReactが生成するHTMLは、同じグローバルCSSによってスタイリングされる React CSS ModulesをCSSクラス名の型チェックのために利用 CSSクラス名に付与されるsuffix指定は無効化[^webpack] [^webpack]: webpackの css-loader の options に modules.localIdentName: '[local]' を設定します。 CSS Modulesが生成する一意なCSSクラス名を排除しているものの、ITCSSと命名規則によってCSSクラス名に一意性が保たれるため、クラス名の衝突という点では全く支障ありませんでした。 CSS ModulesはCSSクラスの出力順序がJavaScript側の参照順に依存しているため、JavaScriptの変更でカスケード順が変わると予期せぬスタイル崩れが起こる恐れもあります。ZOZOTOWNではページによって表現パターンをカスケードして変えることが多く、クラス名をCSS Modulesに任せるよりも、CSS設計でクラス名を決定する方が安全に長期運用ができると判断しました。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからご応募ください! corp.zozo.com コンポーネント駆動開発とは、UI開発をコンポーネントから始めて、コンポーネントを徐々に組み合わせて最終的にページを作り上げていくボトムアップな開発プロセスのことです。コンポーネントを分離して構築するため、開発と設計の並列化による効率性の向上などのメリットが見込まれます。 ↩
アバター
こんにちは。EC基盤本部 検索基盤部 検索基盤チームの有村( @paki0o )です。 みなさん、Elasticsearchのマッピングはどこまで厳密に管理されているでしょうか。 弊社では以前のテックブログでご紹介した通り、一部を除き Explicit Mapping にてデータを管理しています。 techblog.zozo.com 設定している項目は、フィールド名・タイプ・適用するアナライザなど一般的な項目であり、詳細まで詰め切れているとは言い切れない状況でした。今回、マッピング設定の変更がパフォーマンスに与える影響を検証しましたので、その内容についてご紹介いたします。 背景と課題 マッピングの設定について index doc_values enabled 3項目の比較 検証 前準備 比較項目 検証結果 平日での比較結果 休日での比較結果 考察 まとめ 背景と課題 ZOZOTOWNの商品情報インデックスは数百万件のドキュメント、100以上のフィールドから構成されています。その中には、おすすめ順で並び替えるためのアイテム特徴量情報や人気順情報を表すフィールドなど、特定の用途でのみ利用するフィールドも含まれています。 またオペレーションの面では、週次で全件洗い替えを行うバッチや数時間毎に人気順情報を更新するバッチ、アイテム特徴量情報を更新するバッチなど、大規模な更新がかなり多いシステムとなっています。 ElasticsearchはUpdateクエリによるパーシャルアップデートをサポートしており、先のアイテム特徴量更新バッチでも利用しています。ただ、パーシャルアップデートは ドキュメント にもある通り、更新されたドキュメント全体のリインデキシングが走ります。人気順更新バッチやアイテム特徴量更新バッチは、全商品データをパーシャルアップデートするため、弊社のシステムは1日に複数回も全件洗い替えと同等の負荷がかかっている状況となっています。 また、ビジネス要件の実現や様々な検索改善への取り組みの一環で、日々新規のフィールドを追加しており今後も継続すると考えられます。現に今年度の頭には、これまで100項目ほどであったフィールド数を、150項目ほどまで増やす改修がありました。このようなフィールド数増加によるインデキシングパフォーマンスの低下は大きな課題です。今後も同規模の改修が検討されており、設定レベルでインデキシングを効率化できないか検証しました。 マッピングの設定について Elasticsearchのマッピングでは、フィールドごとに指定できるパラメータがバージョン7.13時点で27項目存在しています。 www.elastic.co 今回は、この中でもデータをどのように扱うかを指定する以下の3つのパラメータを設定し、パフォーマンスにどのような影響があるか検証しました。 index doc_values enabled index index は文字通り、該当のフィールドをインデキシングさせるかを指定するためのパラメータです。インデキシングを無効化したフィールドは検索対象への指定ができなくなるため、検索が不要なフィールドに設定します。デフォルトは true で、明示的に false を指定することで、インデキシングを無効化できます。 設定例は以下の通りです。 PUT /sample_index { " mappings ": { " properties ": { " sample_field ": { " type ": " integer ", " index ": false } } } } doc_values doc_values は各フィールドの doc_values を保存するかを指定するパラメータです。 doc_values はソートや集計クエリ、scriptクエリなどフィールド単位の処理が必要とされる際に利用される列指向なデータです。デフォルトは true で、明示的に false を指定することで、 doc_values 形式での保存を無効化できます。 設定例は以下の通りです。 PUT /sample_index { " mappings ": { " properties ": { " sample_field ": { " type ": " integer ", " doc_values ": false } } } } enabled enabled は入力されたデータを有効化するかを指定するパラメータです。これを無効にすると、インデキシングやその他の形式を含めてデータがストアされません。検索やソートなどの対象として一切利用できなくなりますが、例えばタイムスタンプやUUIDのような_sourceで確認さえできれば問題ないフィールドに対しては有効な設定です。デフォルトは true で、明示的に false を指定することで、フィールド全体を無効化できます。 注意点として、 enabled を false に設定する際には、フィールドの型をObject型に指定する必要があります。 設定例は以下の通りです。 PUT /sample_index { " mappings ": { " properties ": { " sample_field ": { " type ": " object ", " enabled ": false } } } } 3項目の比較 ここまで紹介した3項目について、それぞれ単体で指定した際にどのような用途でフィールドが利用できるのか・できないのかを以下の表でまとめました。 用途 index=false doc_values=false enabled=false 検索 × 〇 × script 〇 × × ソート 〇 × × 集計 〇 × × _source 〇 〇 〇 弊社でもソート・scriptだけに用いるフィールド、検索だけに利用できれば問題ないフィールドなど、その用途ごとに最適化の余地がありました。必要最低限の項目のみを設定し、どの程度インデキシング処理が変わるのか検証しました。 検証 前準備 各フィールドごとに適切なパラメータを設定する前準備として、現状の各フィールドと用途を洗い出し、必要な項目を整理しました。単純にクエリログから抽出可能であればスクリプトで処理可能でしたが、プラグインなどの諸事情によりクエリログからすべての情報を得ることが難しかったため、スプレッドシートにまとめる形を取りました。 まとめた結果、現状と理想形の設定差分は以下の通りとなりました。カッコ内の数字はそれぞれ全体のうちの割合を示しています。 項目 検証前の設定数 検証後の設定数 変化分 フィールド数 159 159 - index=false 65(40.8%) 79(49.6%) +14 (+8.8%) doc_values=false 5(3.1%) 55(34.6%) +50 (+31.5%) enabled=false 4(2.5%) 14(8.8%) +14 (+6.3%) 比較項目 変更前後のパフォーマンス計測はElasticsearchのMonitoring機能を用いて行いました。Monitoring機能で取れるメトリクスは非常に豊富なため、今回はその中からインデキシング関連のメトリクスに対象を絞り、以下の項目について比較検証を行いました。 項目名 内容 store.size_in_bytes インデックスサイズ (byte) segments.count セグメント数 segments.index_writer_memory_in_bytes Index Writerの使用メモリ (byte) indexing.index_time_in_millis / indexing.index_total インデキシング速度 (ms/document) なお取得タイミングによって値に差が出るため、それぞれ変更を適用する前後、同曜日の1日の平均値で比較しました。またECサイトの特性上、イベント事やセールなどの要因によりリクエストの傾向が異なるため、平日と休日からそれぞれ1日ずつ比較しています。 検索速度についても同様に計測を試みましたが、対象期間に検索クエリを変更するABテストを実施しており、正確な計測が難しいとの判断から今回は対象外としています。 検証結果 検証結果は以下の表の通りです。表中の「インデックスサイズ」と「Index Writerの使用メモリ」は、見やすいように単位を調整しています。 平日での比較結果 項目 適用前(平日) 適用後(平日) 変化分 インデックスサイズ (GB) 15.327 14.788 -3.5% セグメント数 37.862 35.764 -5.5% Index Writerの使用メモリ (MB) 48.106 44.199 -8.1% インデキシング速度 (ms/document) 1.210 1.138 -5.9% 休日での比較結果 項目 適用前(休日) 適用後(休日) 変化分 インデックスサイズ (GB) 15.650 14.773 -5.6% セグメント数 37.250 35.339 -5.1% Index Writerの使用メモリ (MB) 51.367 42.370 -17.5% インデキシング速度 (ms/document) 1.120 1.082 -3.4% 考察 今回計測したすべてのメトリクスにおいて改善している状態が確認できました。特に、Index Writerの使用メモリは変更を加えたフィールド数の割合と近い値で減少しており、適切な設定の重要さが伺えます。 一方で、今回の改修により一部フィールドが検索、もしくはソートや集計に利用できなくなったのも事実です。設定を戻し、インデキシングし直すことで再度有効化も可能ですが、データサイズによっては即座にできるものではありません。このあたり小回りを優先するかパフォーマンスを優先するかは要件に依存する部分かとは思いますが、弊チームではパフォーマンスを優先し、今回の改修を採用しました。 まとめ 本記事では、Elasticsearchへの継続的なフィールド数追加の要望に対応するため、設定の深堀りと見直しを行いました。取り組みの結果、パラメータを用途に応じて最適化することでインデックスのサイズやクラスタへの負荷、インデキシングにかかる時間を低減できることがわかりました。 今回は検索を主な用途とするクラスタで検証しましたが、例えばロギング用途のようなログの閲覧と集約さえできれば問題ないようなユースケースでは更に改善も期待できると思いますので、是非お試しください。 最後に、ZOZOテクノロジーズでは、検索機能を開発・改善していきたいエンジニアを全国から募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
アバター
こんにちは、ZOZOテクノロジーズ 技術戦略室の池田( @ikenyal )です。 ZOZOテクノロジーズでは、7/28に ZOZO Tech Meetup〜マイクロサービス化に取り組む、16年目のZOZOTOWN〜 を開催しました。 zozotech-inc.connpass.com 本イベントでは、ZOZOテクノロジーズが進めてきたリプレイスプロジェクトの中で、特に「マイクロサービス化」にフォーカスし、各担当者からお伝えしました。 登壇内容 まとめ 弊社の社員5名が登壇しました。 ZOZOTOWN(16歳)の悩みをSREが赤裸々に語る (SRE部 ECプラットフォームSRE / 髙塚 大暉) Backends For Frontends(BFF)をプロダクションレディするまでの取り組み (SRE部 ECプラットフォームSRE / 三神 拓哉) ZOZOTOWNトップページの裏側 (ECプラットフォーム部 カート決済 / 高橋 和太郎) ZOZOTOWN 検索機能のマイクロサービス化への取り組みについて (検索基盤部 検索基盤チーム / 可児 友裕) リプレイスを通して実現した、より高度なサービス改善 (検索基盤部 検索基盤チーム / 有村 和真) 最後に ZOZOテクノロジーズでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに こんにちは、EC基盤本部・MA部・MA基盤チームでマーケティングオートメーションのシステムを開発している長澤( @snagasawa_ )です。この記事では、社内で運用しているLINEメッセージ配信基盤の課題を、アーキテクチャ改善によって解決した話をご紹介します。 当時、LINEメッセージ配信基盤では、配信処理を担っていたApp Engineで2つの課題を抱えていました。「メモリ不足による配信処理の中断」と「リクエストタイムアウト後の意図しない処理の継続」です。一時はスケールアップによるメモリ増強を検討しましたが、後者の課題を解決できないためアーキテクチャの変更に着手しました。 結果として、App Engineが担っていた処理をBigQuery・Cloud Storage・Dataflow Batch Jobに置き換えることにより、この2つの課題を解決しました。加えて、配信対象ユーザーの増加にも対応しました。この記事が類似するシステムを開発されているエンジニアの方にとって参考になれば幸いです。 はじめに LINEメッセージ配信基盤とは アーキテクチャの改善に取り組んだ背景 改善前のアーキテクチャ 課題1: メモリ不足による配信処理の中断 課題2: リクエストタイムアウト後の意図しない処理の継続 改善後のアーキテクチャ BigQueryへのメッセージ出力 Cloud Storageへのファイル出力 Dataflow Batch Jobによるメッセージのpublish Cloud Pub/Sub & App Engine(フロー制御) Cloud Pub/Sub & Cloud Functions(配信) 各GCPサービスのボトルネック BigQuery Cloud Storage Dataflow Cloud Pub/Sub Cloud Functions 今後の改善 まとめ さいごに LINEメッセージ配信基盤とは はじめにLINEメッセージ配信基盤を説明します。 LINEメッセージ配信基盤とは、我々が「LINE Friendship Manager(以下、LFM)」と呼んでいるLINEユーザー向けのメッセージ配信システムです。 ZOZOTOWNではLINEの企業公式アカウントを運用しています。エンドユーザーはこの公式アカウントをLINE友だちに追加することで、ZOZOTOWNのキャンペーン情報やお気に入りアイテムの値下げ情報などをLINE上で受け取ることができます。 LFMはLINE社が提供するMessaging APIを利用して、LINE友だちにメッセージやリッチメニューのバッチ配信を予約配信できます 1 。 LFMの詳細は採用イベントにて発表した登壇資料があります。こちらを参考にしてください。 アーキテクチャの改善に取り組んだ背景 ZOZOTOWNではLINEを重要なユーザーコミュニケーションのチャネルと位置づけて、戦略的なマーケティング施策を展開しています。 www.linebiz.com その甲斐もあって、昨今のZOZOTOWN LINE公式アカウントでは年々LINE友だちが増加しています。具体的には、累計LINE友だち数(配信対象外のブロックユーザーを含む)が2021年4月に1,000万ユーザーを突破し、2018年年初の約1.4倍になりました。 LINE友だち数が増えれば、LFMで扱うユーザー数もまた増えます。そのため、LFMでは冒頭の課題を解決しつつ、今後の配信対象ユーザー数増加に耐えうるスケーラビリティを備えるようアーキテクチャ改善に着手しました。 改善前のアーキテクチャ LFM改善前のメッセージ配信方法を紹介します。 前提として、配信に必要なデータはそれぞれ以下の通り格納されています。 Cloud SQL 配信開始の予約時間 配信対象ユーザーを抽出する条件 メッセージのコンテンツ情報(画像のURLやクリック後の遷移先URLなど) BigQuery 配信対象のユーザーIDリスト 処理の流れは以下の通りです。 (1) App Engine Cron Jobで予約時間になった配信処理を開始 (2) Cloud SQLからメッセージのコンテンツを取得 (3) BigQueryからユーザーIDのリストを取得 (4) ユーザーIDとメッセージの組み合わせをCloud Pub/Subにpublish (5)〜(8) Cloud FunctionsでLINE Messaging APIにリクエスト 参考までに、以下のコードがこの (2)~(4) の実装を単純化したものになります。 require ' google/cloud/bigquery ' require ' google/cloud/pubsub ' require ' json ' # ユーザーIDのリストを取得 bigquery = Google :: Cloud :: BigQuery .new sql = ' SELECT user_id FROM `project_id.dataset_id.users` ' user_ids = bigquery.query(sql) # メッセージを生成 content_id = 1 content = Content .find(content_id) messages = user_ids.map do | user_id | { user_id : user_id, messages : [ { type : ' image ' , url : content.image_url, size : ' full ' , action : { type : ' uri ' , uri : content.action_url, }, } ] } end # メッセージをCloud Pub/Subにpublish pubsub = Google :: Cloud :: Pubsub .new topic_name = ' topic_name ' topic = pubsub.topic(topic_name) topic.publish do | batch | messages.each do | message | batch.publish( JSON .dump(message)) end end この実装はMVPとしてファーストリリース当初の要件を問題なく満たすものでしたが、運用中に先述の課題が発覚しました。 課題1: メモリ不足による配信処理の中断 1つ目の「メモリ不足による配信処理の中断」は、上記の (2)〜(4) の処理を行なっていたApp EngineのFlexible Environment(以下、FE)で発生しました 2 。 下のグラフは、実際に配信が中断した直前1週間のメモリ使用量推移です。数日間稼働しているFEのインスタンスのメモリ使用量が右肩上がりに増えていき、グラフの最後では メモリ不足による502エラー によって配信が中断されて2GiBほど減少しています。このようにメモリリークによって消費メモリが肥大しているため、スケールアップしたとしても502エラーの再発が懸念されました。 課題2: リクエストタイムアウト後の意図しない処理の継続 それでもまだメモリ不足だけであればスケールアップを検討する余地もありましたが、別の課題が存在しました。それが2つ目の「リクエストタイムアウト後の意図しない処理の継続」です。 FEには リクエストの最大タイムアウトが60分 という制約があります。一方で、LFMでは時折このタイムアウトを超過しても配信処理が継続していることに気がつきました。調査の結果、これはバックグラウンドスレッドによるものでした。 App Engineのドキュメント にもある通り、FEではバックグラウンドスレッド・バックグラウンドプロセスが動作します。 LFMのコード上ではバックグラウンド処理を行っていませんでしたが、使用していたCloud Pub/Subのクライアントライブラリで 内部的にスレッドを生成 していました。この影響で、タイムアウトになると504エラーのレスポンスが返ってくるものの、バックグラウンドで配信処理は継続するという現象が発生していました。 開発チームとしてもこれは事後的に発覚したものであり、実装当初からは想定外の挙動でした。また、 バックグラウンド処理自体、推奨されていません 。そのため、このバックグラウンド処理を避けるべくFE以外の選択肢を検討し始めました。 まず、同じApp Engineの中で、タイムアウト上限が24時間であるStandard EnvironmentのBasic Scaling・Manual Scalingが候補となりました。しかし、インスタンスクラスの最大メモリが2048MBとスペック不足であり断念しました。 cloud.google.com 以上の理由からApp Engine以外での解決に取り組みました。 改善後のアーキテクチャ 以下が改善後の配信方法です。 BigQueryへのメッセージ出力 1ステップ目はBigQueryへのメッセージ出力です。変更前の配信開始時の処理では、BigQueryから取得したユーザーIDのリストと、Cloud SQLから取得したメッセージのコンテンツを組み合わせてメッセージを生成していました。しかし、変更後はユーザーIDとメッセージのコンテンツを組み合わせた「クエリ」を生成し、その実行結果をBigQueryの別のテーブルに非構造化データとして出力します。 具体的には以下のような再帰メソッドを使うことで、Rubyの連想配列をBigQueryのStructの文字列に変換し、その結果をクエリに組み込んで非同期のクエリジョブを実行します。 def hash_to_bigquery_struct_string (hash, parent_key : nil ) array_of_hash_to_bigquery_struct_string = proc do | array_of_hash , key | array_of_hash .map { | hash | hash_to_bigquery_struct_string(hash) } .join( " , \n" ) .then { | str | " [ \n#{ str }\n ] AS #{ key }" } end case hash when Array then array_of_hash_to_bigquery_struct_string.call(hash, parent_key) when Hash hash .map { | key , value | case value when Array then array_of_hash_to_bigquery_struct_string.call(value, key) when Hash then hash_to_bigquery_struct_string(value, parent_key : key) else "#{ value.is_a?( String ) ? " ' #{ value } ' " : value } AS #{ key }" end } .join( " , \n" ) .then { | str | " STRUCT( #{ str }#{ parent_key ? "\n ) AS #{ parent_key }" : ' ) '}" } else raise ( ArgumentError , "#{ hash } is neither hash nor array of hash. " ) end end message_contents = [ { type : ' image ' , url : ' https://cdn.sample.jp/images/example_01.png ' , size : ' full ' , action : { type : ' uri ' , uri : ' https://zozo.jp/sale/ ' , } } ] hash_to_bigquery_struct_string(message_contents, parent_key : ' messages ' ) # => # [ # STRUCT('image' AS type, # 'https://cdn.sample.jp/images/example_01.png' AS url, # 'full' AS size, # STRUCT('uri' AS type, # 'https://zozo.jp/sale/' AS uri # ) AS action) # ] AS messages CREATE TABLE dataset_id.table_id OPTIONS ( expiration_timestamp=TIMESTAMP " 2022-01-01 00:00:00 " ) AS ( WITH segments AS ( SELECT ' 1111 ' AS user_id UNION ALL SELECT ' 1112 ' AS user_id ) SELECT user_id, [ STRUCT( ' image ' AS type , ' https://cdn.sample.jp/images/example_01.png ' AS url, ' full ' AS size , STRUCT( ' uri ' AS type , ' https://zozo.jp/sale/ ' AS uri ) AS action) ] AS messages FROM segments ) 上のクエリ実行すると次のテーブルが作成されます。 これによってApp Engineのメモリ消費を抑制しつつ、LINE Messaging APIに渡すリクエストボディとほぼ同様の構造のままデータをテーブルに出力できます。実際にこの変更によって Standard Environmentの「B8」インスタンスクラス でも事足りるようになりました。 配信後のテーブルは不要なため、クエリのオプションに expiration_timestamp を指定します。これで自動的にテーブルが削除され、ストレージの課金コストを削減できます。 cloud.google.com Cloud Storageへのファイル出力 2ステップ目では、BigQuery APIによって前の手順で出力したテーブルをJSONファイルとしてCloud Storageに出力します。 cloud.google.com 注意点としては、Cloud Storageへの単一ファイルの出力には最大1GBという上限があります。このため、 ワイルドカードURLを指定 することで自動的に複数ファイルに分割して出力されるようにします。 このJSONファイルも配信後は不要なため、 バケットを作成する際に ライフサイクルルールを指定 します。これで自動的にファイルが削除され、 ストレージの課金コストを削減できます。GCPのリソースはTerraformで管理しており、以下のように記述することでバケットにライフサイクルルールを適用できます。 resource " google_storage_bucket " " pubsub-message " { name = " ${var.project}-pubsub-message " location = " US " force_destroy = true lifecycle_rule { action { type = " Delete " } condition { age = 1 } } } Dataflow Batch Jobによるメッセージのpublish 3ステップ目では、Cloud StorageのJSONファイルを読み込み、Pub/Sub TopicへpublishするDataflow Batch Jobを実行します。このJobもCloud Storageへのファイル出力後にRailsから起動します。 Dataflow Batch Jobは処理対象のデータ量に合わせてワーカーとインスタンスをオートスケールさせるため、大量のデータも高速に処理できます。今回はGoogle Cloudが提供するテンプレートの「 Cloud Storage Text to Pub/Sub (Batch) 」を利用します。 テンプレートの利用により、Dataflowの実装が不要になります。 今回のケースでは、RubyのGCPクライアントライブラリ google-cloud-ruby でAPIがサポートされていなかったため、 google-api-ruby-client と google-auth-library-ruby を利用して実装しました。 require ' googleauth ' require ' google/apis/dataflow_v1b3 ' DataflowV1b3 = Google :: Apis :: DataflowV1b3 class Dataflow def launch_gcs_to_pubsub_template ( project_id :, job_name :, input_file_pattern :, output_topic :) service.launch_project_template( project_id, DataflowV1b3 :: LaunchTemplateParameters .new( job_name : job_name, parameters : { inputFilePattern : input_file_pattern, outputTopic : " projects/ #{ project_id } /topics/ #{ output_topic }" , }, environment : DataflowV1b3 :: RuntimeEnvironment .new( temp_location : " gs:// #{ project_id } -dataflow-template/temp " , ), ), gcs_path : ' gs://dataflow-templates/latest/GCS_Text_to_Cloud_PubSub ' , ) end private def service @service ||= DataflowV1b3 :: DataflowService .new.then do | service | service.authorization = credentials service end end def credentials credentials = Google :: Auth :: ServiceAccountCredentials .make_creds( scope : [ DataflowV1b3 :: AUTH_COMPUTE ]) credentials.fetch_access_token! credentials end end Cloud Pub/Sub & App Engine(フロー制御) 4ステップ目では、App EngineとCloud Pub/Subによるフロー制御をしています。この実装はオプションであり、要件次第では不要です。今回はLINE Messaging APIのレート制限を回避するために実装しています。 LINE Messaging APIには 特定のエンドポイントを除いて「2000リクエスト/秒」という制限 があり、超過するとエラーが返る仕様になっています。 Dataflowは大量データを高速に処理可能ですが、それゆえにフロー制御をしないスループットだとこのレート制限を超過してしまいます。そのため、今回は2種類のPub/Sub Topicを用意してあります。ひとつはフロー制御用で、もうひとつはCloud Functionsの配信トリガー用です。 フロー制御用のTopicにメッセージがpublishされると、App Engineからメッセージをsubscribeし、1秒ごとに配信用Topicへpublishします。この処理はスループットが一定に保たれるため、配信メッセージ数が増加してもスケーリングは不要になります。なお、別のアプローチとして、Dataflowのパラメータによるワーカー数の固定も試しました。これはJavaのStackOverflowErrorが頻発した事、毎秒制御のほうがより確実であった事からあえなく断念しました。 この処理はApp EngineのCron Jobによって1分間隔で実行しています。 注意点として、このCron Jobは開始時に前の処理を終了していないとその処理がスキップされてしまうため、 次の開始時間を超えないようにレスポンスを返す必要があります。 cloud.google.com Cloud Pub/Sub & Cloud Functions(配信) 最後の5ステップ目に、Pub/SubトリガーのCloud FunctionsがLINE Messaing APIにリクエストを送信し、エンドユーザーのLINE上でメッセージが表示されます。 Cloud FunctionsもPub/Sub Topicのトラフィックによってインスタンスがスケールし、並列処理によって大量メッセージを高速に処理できます。参考値として、フロー制御をしない場合、約500万ユーザーへの配信が35分ほどで完了します。この時間にはBigQueryへのテーブル出力やDataflow Jobの起動による10分程度のオーバーヘッドを含みます。 ここまでの一連の流れによって、先の課題に対応しつつ、GCPのリソースを活用したスケーラブルな配信処理が可能になります。 各GCPサービスのボトルネック さて、スケーラブルになったとして現実的にはどこまでスケール可能なのでしょうか。ここまで読んでいただいた方も気になる部分かと思います。結論としては、スケール上限はGCPの使い方に準じます。今回使用している各GCPサービスにおけるボトルネックについて紹介します。 BigQuery BigQueryはよくペタバイト級のデータウェアハウスとして謳われますが、クエリの内容によってはそのスケールメリットを享受できず、ボトルネックになる可能性があります。 下記記事によると、ORDER BY句や分析関数、番号付関数などはすべてのデータを対象にシングルノード上で処理が行われるという性質上、メモリ上限に引っかかりやすいとのことです。 Operations that need to see all the data in the resulting table at once have to operate on a single node. Un-partitioned window functions like RANK() OVER() or ROW_NUMBER() OVER() will operate on a single node. Another operation that is mentioned to face a lot of problems is the ORDER BY clause. It is an operation that requires a single node to see all data, so it may hit the memory limit of a single. medium.com 公式ドキュメントにおいても、クエリパフォーマンスの最大化について以下のような説明があります。 When you use an ORDER BY clause, it should appear only in the outermost query. Placing an ORDER BY clause in the middle of a query greatly impacts performance unless it is being used in a window (analytic) function. Another technique for ordering your query is to push complex operations, such as regular expressions and mathematical functions to the end of the query. Again, this technique allows the data to be pruned as much as possible before the complex operations are performed. cloud.google.com LFMでもABテストのグループ分けのために分析関数を使用しており、ユーザーID数が1,400万に達すると以下のメモリ超過エラーに引っかかることが判明しています。直近では問題ありませんが、将来的には適切なバッチサイズに分割してクエリを実行するように修正する必要があります。 Resources exceeded during query execution: The query could not be executed in the allotted memory. Cloud Storage Cloud Storageへの出力は先述の通り、ワイルドカードURLにより複数ファイルに分割する必要があります。 しかし、速度的には十分に高速であるため、将来的にボトルネックになりうる懸念はほとんどありません。データ量によって出力完了までの時間は多少増減しますが、参考値として500万ユーザーを対象する配信でも1分程度で完了します。 Dataflow Dataflowはオートスケールに加えて、パラメータでCompute Engineのマシンタイプを変更可能なため、ボトルネックになりうる懸念は少ないです。しかし、ジョブの同時実行数はプロジェクトにつき上限「25個」と少ないため要注意です。これはソフトリミットのため、サポートへの問い合わせにより上限緩和できます。 cloud.google.com Cloud Pub/Sub Cloud Pub/Subは リージョンごとのスループットに上限 が設けられているため、メッセージサイズおよびメッセージ数が大きい場合には注意が必要です。 Pub/Subで割り当て可能なリージョンは大規模と小規模の2種類があり、スループットの上限はこの種類によって大きく差があります。例えばLFMのようにPub/SubトリガーのCloud Functionsを利用している場合、pushサブスクライバーのスループットに上限があります。大規模リージョンでは「8,400,000 KB(140 MB/秒)」であるのに対し、小規模リージョンでは「1,200,000 KB(20 MB/秒)」と7分の1しかありません。このため、同時間帯に複数の配信がされたり、メッセージサイズが大きいユースケースには注意が必要です。 前提条件によりますが、Pub/Subのリージョンはリクエストを送信したクライアントのリージョンにルーティングされることがあります。LFMのケースであれば、フロー制御のApp Engineインスタンスのリージョンが配信用Pub/Subのリージョンになります。したがって、Pub/Subのリージョンと合わせてリクエストを送信するクライアントのリージョンにも留意する必要があります。 現在のLFMは小規模リージョンに存在しており、配信によっては上限近くのスループットに達するため、いずれは大規模リージョンへ移行する予定です。 cloud.google.com Cloud Functions Cloud FunctionsはPub/Subトリガーで実行される場合、バックグラウンド関数のみに適用される割り当てが存在するため要注意です。 特にLFMのようなフロー制御をしない場合、パフォーマンス要件によっては関数ごとの最大同時呼び出し回数がボトルネックになりうる懸念があります。LFMではフロー制御に加えて トリガーのPub/Sub TopicとCloud Functionsを並列化している ため、この呼び出し回数がボトルネックにはなりません。 cloud.google.com 今後の改善 今後の改善案として検討しているのは、Dataflow SQL Jobの採用です。Dataflow SQL Jobは内部的にBigQuery Storage Read APIを利用します。これによりCloud Storageへの出力を介さずにBigQueryから直接Pub/Sub Topicにメッセージをpublishできます。 現在はまだDataflow SQLクエリの実行方法がCloud Consoleか gcloud コマンドの2択しか存在しないため、クライアントライブラリでのサポート開始後に本格的に採用検討します。 cloud.google.com まとめ 本記事では、GCPのアーキテクチャ改善によって、既存の課題を解決しつつ更なるスケーラビリティを実現したLINEメッセージ配信基盤について紹介しました。今後もDataflow SQL Jobの採用検討などにより、さらなる改善を取り入れていきたいと考えています。 さいごに ZOZOテクノロジーズではGCPのアーキテクチャ改善やマーケティングに関連するプロダクトの開発に関心のあるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください! tech.zozo.com 一部のバッチ配信とリアルタイム配信は別システムでも配信しています。詳しくは 別記事 を参照ください。 ↩ FEを使っていた理由は、その当時使っていたRubyのバージョンにStandard Environmentが未対応だったため。 ↩
アバター
こんにちは。計測プラットフォーム本部バックエンド部SREチームの市橋です。 私たちのチームではZOZOSUIT、ZOZOMAT、ZOZOGLASSといった計測技術に関わるシステムの開発、運用を担当しています。現在のZOZOMATとZOZOGLASSは、どちらも独立したEKSクラスタ上で動いていますが、ZOZOGLASSの環境を構築する際に将来のマルチテナント化を踏まえ大きく設計を見直しました。今回は、この設計見直し時に考慮した点を紹介します。 ZOZOGLASSとは ZOZOGLASSは顔の情報を計測し、イエローベースとブルーベースの2タイプ、及び春夏秋冬の4タイプの組み合わせからなるパーソナルカラーを診断するサービスです。計測した顔の情報から肌の色に近いファンデーションを推薦します。2021年7月時点で、ZOZOGLASSが推薦するコスメアイテムはファンデーションのみですが、今後はファンデーション以外のコスメアイテムも追加される予定です。ZOZOGLASSは無料で予約可能ですので、ご興味のある方は こちら からご予約ください。ちなみに私のパーソナルカラー診断の結果はブルーベースの冬でした。 EKSマルチテナントの構想 マルチテナント化を検討した背景 ZOZOGLASSでマルチテナント化を検討した背景について触れる前に、マルチテナントについて整理します。マルチテナント(シングルクラスタ)とは単一のEKSクラスタの上で複数のサービスを運用することを指します。比較対象となる構成としてマルチクラスタ・シングルテナントがあり、これらを図示すると以下のようになります。 両者を比較するとそれぞれ次のような特徴があると考えています。 構成 マルチクラスタ・シングルテナント シングルクラスタ・マルチテナント 説明 費用 △ ○ マルチクラスタ・シングルテナントの場合、管理しているクラスタ数分の課金、及びクラスタ管理用リソースのためのマシンリソース分の料金が追加で必要になる。 運用コスト × ○ マルチクラスタ・シングルテナントの場合、管理しているクラスタ数分のアップデート作業が必要になる。 また、クラスタ設定の構成管理とその自動化(IaC、CI/CDなど)、監視・観測設定もクラスタ分必要になる。 クラスタ障害時の影響範囲 ○ △ シングルクラスタ・マルチテナントでクラスタ障害が発生した場合、クラスタ内で運用しているサービス全てに影響が及ぶ。 権限管理の容易性 ○ △ シングルクラスタ・マルチテナントの場合、テナントごとに権限を分離するための考慮や設定が必要になる。 両者の選択については様々な記事が公開されており、それによると組織構造やプロダクトの成熟度も考慮すべき事項だと感じました。弊チームは複数のサービスを1つの開発チームと1つのクラスタ管理チームで運用する体制になっています。このような体制であるにも関わらず、下図のようにサービス毎にEKSクラスタが存在していました。 この場合、運用する上で冗長な部分が多くありました。具体例を1つ挙げると、EKSクラスタのアップデート対応です。ZOZOMATの時点で、非本番環境を含めると多数のクラスタが存在しており、アップデート対応に苦しめられました。ZOZOGLASSを開発するにあたり、ZOZOMATの設計を踏襲すると、負荷が更に増えてしまいます。 以上のことから、クラスタの管理コスト削減を目指し、ZOZOGLASSでは将来のマルチテナント化を前提とした設計を進めることにしました。マルチテナント化後の体制は下図のイメージになります。 また、今後も新規サービスが計画されており、その開発時にもクラスタの作成やそれに付随する諸作業を省略できると考えました。 マルチテナント化は次のように進めます。 マルチテナント化前提にZOZOGLASSを設計し、共通基盤としてのEKSクラスタ measurement-platform で運用(本記事の主題) ZOZOMATを measurement-platform へ移行(2021年7月時点で実施中) 移行のイメージは以下の図の通りです。 ZOZOMATのクラスタ移行については、現在実施中ということもあり深く触れません。本記事ではEKSでマルチテナントを実現するために、考慮した事について紹介します。 設計時の考慮事項 マルチテナント化を進めるにあたっては、 AWS公式ブログの記事 を参考にして考慮事項を1つずつ確認しました。この記事ではテナントを以下の3つの観点から分離することが重要であると述べています。 コンピューティングの分離 ネットワークの分離 ストレージの分離 私たちの環境ではKubernetesのストレージ機構を利用していないため、コンピューティング、ネットワークの観点を中心に確認しました。その他、Kubernetesマニフェストをどのリポジトリで管理するかについても重要なポイントだと感じたため、少しアレンジして以下の観点で確認しました。 コンピューティングの分離 ネットワークの分離 Kubernetesマニフェストの管理リポジトリの分離 コンピューティングの分離 マルチテナント化に向けた1つ目の観点として、コンピューティングの分離を考えます。先のブログ記事では次のことを担保すべきとしています。 権限が分離できること マシンリソースの競合を回避できること 私たちはコンピューティングタイプとしてFargateを採用しています。Fargateを利用することでこの2つの要件を比較的容易に解決できました。 権限分離の観点 KubernetesはRBACと呼ばれるロールベースのアクセス制御の機構を持ち、これを利用して誰が何を実行できるかを決定します。これにはクラスタ全体で共通利用できるClusterRoleと、単一のnamespaceでのみ有効なRoleがあります。テナントごとに利用する権限を設定する場合にはRoleを利用するため、namespaceをテナントごとに作成しておいた方が権限を分離しやすくなります。そのため、今回はテナントごとに1つのnamespaceを作成しました。この後にも度々namespaceが登場しますが、分離度を高める上で重要な概念のため、どのような単位でnamespaceを分けるか予めよく考える必要があります。 podがAWSサービスにアクセスするにはRBACとAWSのIAMを統合して利用します。この際、権限の分離度を高めるためにはIAM roles for service accounts(以下、IRSA)を利用することが望ましいです。これはpodに付与されているServiceAccountsにIAMRoleを紐づけるもので、これによりpod内のコンテナからAWSリソースの操作が可能になります。 コンピューティングタイプがEC2の場合は、podがEC2インスタンスに付与されているIAMロールを利用できます。そのため、1インスタンスで動作する全podのAWSサービスへのアクセス権限をひとまとめにして、1つのIAMロールにポリシーを詰め込んで使うこともできます。シングルテナントの場合はこれで問題ないケースもありますが、マルチテナントの場合は他のテナントで使っているAWSリソースの操作も許可されてしまうため、権限を制限しておくことで安全性が増します。なお、Fargateの場合はインスタンスの権限を利用できないため、IRSAの利用が必須となります。そのため、必然的にpodごとに権限の分離度を高める機会を得られることになります。IRSAの設定は以下の流れで行います。 STEP1 OIDCプロバイダーの作成 まず、podからIAMRoleを利用する際に必要となるOIDCプロバイダーを作成します。私たちが構築した当時はCloudFormationが対応しておらず、eksctlも機能不足 1 により利用できなかったため、Webコンソールから作成しました。 現在は両方対応しており、手元で試せてはいませんがCloudFormationは AWS::IAM::OIDCProvider リソースを使って、eksctlであれば以下のコマンドで作成できるはずです。 $ eksctl utils associate-iam-oidc-provider --cluster < cluster_name > --approve STEP2 IRSA用のIAMRoleの作成 次にIAMRoleを作成します。以下のYAMLは、IRSA用IAMロールを作成するCloudFormationテンプレートのサンプルです。IRSAを利用するために、AssumeRolePolicyDocumentへSTEP1で作成したOIDCプロバイダと信頼関係を結ぶ設定を記述します。 IAMRole : Type : 'AWS::IAM::Role' Properties : RoleName : 'irsa-for-api' AssumeRolePolicyDocument : !Sub | { "Version" : "2012-10-17" , "Statement" : [ { "Effect" : "Allow" , "Principal" : { "Federated" : "arn:aws:iam::${AWS::AccountId}:oidc-provider/${EKSOIDCProvider}" } , "Action" : "sts:AssumeRoleWithWebIdentity" , "Condition" : { "StringEquals" : { "${EKSOIDCProvider}:sub" : "system:serviceaccount:${Namespace}:api" } } } ] } Path : '/' Policies : - PolicyDocument : Statement : - Effect : 'Allow' Action : 's3:PutObject' Resource : - !Sub '${S3Bucket.Arn}/*' PolicyName : 'irsa-for-api-s3' ${EKSOIDCProvider}には以下のコマンドで取得できるURLのうち、idの部分を指定します。 $ aws eks describe-cluster --name < cluster_name > --query " cluster.identity.oidc.issuer " --output text STEP3 ServiceAccountの作成 最後に、podに割り当てるServiceAccountを作成します。annotationsにSTEP2で作成したIAMRoleのArnを指定します。 apiVersion : v1 kind : ServiceAccount metadata : name : serviceaccount-api annotations : eks.amazonaws.com/role-arn : arn:aws:iam::000000000000:role/irsa-for-api ここで生成されたServiceAccountをpodに付与することで、IAMRoleのポリシーに含まれるAWSリソースへのアクセス権限を得られます。 マシンリソースの競合の回避 「コンピューティングの分離」の最後はマシンリソースの競合について考えます。これを回避する方法にはpodの割当リソースに制限をかけるか、配置するノードを分けるか、2つの選択肢があります。 前者は リソースクォータ を利用することで実現可能です。リソースクォータはnamespaceごとに定義できるため、これを利用することでテナント間のマシンリソースの奪い合いを制限できます。 後者はコンピューティングタイプをEC2、Fargateのどちらで運用しているかで対応方法が変わります。EC2の場合、テナントごとにノードグループを作成して nodeSelectorsやnodeAffinity を使って、どのノードグループにpodを配置するかを制御することでリソースの競合を回避できます。Fargateの場合はそもそもVM環境が分離された状態でpodが実行され、podの要求リソースに応じたマシンリソースを持ったノードが自動で割り当てられます。そのため、特に意識することなくマシンリソースの競合を回避できます。細かい設定をせずにマシンリソースを分離できる点はFargateを利用するメリットの1つかと思います。 ネットワークの分離 マルチテナント化に向けた2つ目の観点として、ネットワークの分離を考えます。デフォルトではクラスタ内であればnamespace越しの通信が許可されています。もし、これを許容できない要件があれば、NetworkPolicyを利用することで制限できます。ただし、NetworkPolicyが利用できるのはコンピューティングタイプがEC2の場合のみで、Fargateを利用する場合はこれを利用できません。代替案としては、AppMeshの機能で通信を制御する方法が推奨されています。詳しく調べられていないのですが、podの全通信にEnvoyが介在するような形になり、Egressのフィルタリングでnamespace越しの通信を遮断するような形になると考えています。それぞれ図示すると以下のようになります。 最終的にAppMeshの導入については、導入工数とシステムへの影響を鑑みて充分にPoCを行った上で導入可否を決定した方が良いと考え、ZOZOGLASSのリリース時点では導入を見送りました。リリース時点ではシングルテナントの構成であり、ZOZOMATアプリケーションを集約してマルチテナント構成になるまでに対策を講じるという判断です。その後、どうすべきかあれこれ考えているうちに、 Fargate podにセキュリティグループを設定可能 になるリリースが2021年6月に発表されました。これによりFargateでも上図のNetworkPolicyのような制御が可能になると考えています。容易に導入できるメリットから、これを最有力候補として検討しています。ちょうどいいタイミングでリリースされて助かりました。 Kubernetesマニフェストの管理リポジトリの分離 マルチテナント化に向けた3つ目、最後の観点として、Kubernetesマニフェストの管理リポジトリの分離を考えます。ここでの内容はマルチクラスタ・シングルテナントを前提としていたZOZOMATを、マルチテナント型に作り替えることを考えた時に感じたやりにくさを踏まえたものになります。そのため、まずはZOZOMATでのマニフェスト管理を説明します。 ZOZOMATでは全てのマニフェストファイルをアプリケーション用のリポジトリで管理していました。これにはaws-authやmetrics-server、ClusterRoleのようなクラスタ内に1つあればよいリソースも含みます。シングルテナントであればアプリケーションとクラスタの設定が集約されるため、シンプルな管理が可能です。 一方、マルチテナントで運用する場合、この設計は設定変更の足かせとなります。別サービスの事情でクラスタ管理用のリソースへ変更を加える際、ZOZOMATのアプリケーションリポジトリにも手を加える必要があります。以上のことから、クラスタ管理用リソースとサービス固有のリソースで、リポジトリを分けることにしました。ingressとaws-loadbalancer-controllerを例に説明します。 aws-loadbalancer-controllerは、ingressリソースが作成されたことを検知してELBを作成するリソースです。aws-load-balancer-controllerはクラスタ内に1つあればよく、ingressはELBが必要なテナントごとに作成します。下図のイメージになります。 この場合、aws-loadbalancer-controllerはクラスタ管理用リポジトリ、ingressはサービス固有のアプリケーションリポジトリでマニフェストファイルを管理するようにしました。こうすることで、同じような構成でテナントを増やす際、既存のマニフェストファイルをテンプレートとして再利用しやすくなりました。 まとめ 以上の考慮事項を踏まえてマルチテナントを前提としたEKSクラスタを構築し、無事にZOZOGLASSの計測システムをリリースできました。マルチテナント化のネクストアクションは、ZOZOMATリソースを共通基盤へ移行することで、目下この作業に取り組んでいます。 今後リリースを予定している ZOZOSUIT 2 や ZOZOMAT for Hands はパートナー企業様と協業で進めるサービスとなっています。これらのサービスは信頼性やアクセス管理の面において、より厳格な要件を求められることが予想されます。今回得られた知見を生かして開発に取り組み、要件を満たした上でいち早くサービス提供することに尽力していきます。 私たちはこのような課題に対して楽しんで取り組み、一緒にサービスを作り上げてくれる方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 tech.zozo.com 当時は、AWS SSOから生成される認証情報を使ってeksctlを実行できませんでした。こちらの issue で報告されており、 2021年5月ごろ対応が完了したようです。エラーが出た場合はeksctlのバージョンを上げることで回避できる可能性があります。 ↩
アバター
こんにちは、SRE部の谷口( case-k )です。私たちのチームではデータ基盤の開発や運用をしています。1年ほど前からBigQueryのコストパフォーマンス改善を目的にFlex Slotsを導入しています。 本記事ではFlex Slotsの導入効果や運用における注意点、ワークフロー設計についてご紹介します。BigQueryのコストやパフォーマンスで課題を抱えているチームや管理業務を行っている方の参考になれば幸いです。 BigQuery Reservationsとは コミットメント 予約 割り当て なぜFlex Slotsを使う必要があるのか Flex Slotsを用いたコストパフォーマンス改善設計 管理プロジェクトの作成 月次コミットメントの活用 Flex Slotsの活用とワークフロー設計 ワークフロー タスク 「コミットメントの購入」タスク 「コミットメントの削除」タスク 「予約」タスク 運用におけるFlex Slotsの注意点とワークフロー設計 運用上の注意点2選 コミットメント購入時は冪等にする オンデマンド料金モデルに自動で切り替える ワークフロー ラッパータスク コミットメント コミットメントの購入 コミットメントの削除 予約 予約の作成 割り当て 割り当ての作成 割り当ての削除 オンデマンド料金モデルへ切り替え タスク コミットメント コミットメントの購入 コミットメントの削除 コミットメントの取得 コミットメントの状態取得 予約 予約の作成 割り当て 割り当ての作成 割り当ての削除 割り当ての取得 Flex Slotsの導入効果 パフォーマンス面の効果 コスト面の効果 Flex Slotsのメリット・デメリット メリット パフォーマンスが改善される コストを削減できる デメリット リトライしても購入できない場合がある オンデマンド料金モデル切り替えに伴いコストが発生する ワークフローが煩雑化する 今後の活用 おわりに BigQuery Reservationsとは Flex Slotsを紹介する前に、まずBigQueryの費用を管理するプラットフォームであるBigQuery Reservationsをご紹介します。 BigQuery ReservationsとはBigQueryの費用や組織・プロジェクトのワークロードを管理するプラットフォームです。 BigQueryの料金モデルには「オンデマンド料金モデル」と「定額料金モデル」の2種類あります。オンデマンド料金モデルはBigQueryのクエリスキャン量に基づいた料金モデルです。一方、定額料金モデルの場合は事前にBigQueryのコンピューティングリソースであるスロットを購入する料金モデルです。 デフォルトではオンデマンド料金モデルが適用されます。オンデマンド料金モデルでは、1プロジェクトあたり2000スロットまで保証されますが、2000スロット以上は保証されていません。そのため、BigQuery全体で空きがあれば2000以上も使えますが、なければ使えません。つまり、データ量が多くなるにつれ、BigQuery Reservationsを使って定額料金モデルにする方が、コストメリットやパフォーマンスの恩恵を受けやすいと言えます。 Maximum concurrent slots per project for on-demand pricing — 2,000 引用: Quotas and limits  |  BigQuery  |  Google Cloud BigQuery Reservationsを使って定額料金モデルにするには「コミットメント」「予約」「割り当て」が必要です。この操作をすることで、スロットの購入からプロジェクトへの割り当てが可能となります。 各操作の関係は以下のようになっています。 コミットメントでスロットを購入し、予約でプロジェクトに対して割り当てるスロットを決める 予約したスロットに対して割り当てを行い、プロジェクトを紐付ける 紐付いたプロジェクトで、予約で確保したスロットを利用できるようになる 引用: Workload management using Reservations  |  BigQuery  |  Google Cloud 続いて、各操作の詳細を説明します。 コミットメント コミットメントではBigQueryのコンピューティングリソースであるスロットを購入します。スロットの購入には次の3つの方法があります。 月次コミットメント 30日単位でスロットを購入 購入後30日間はキャンセルできない 年次コミットメント 365日単位でスロットを購入 購入後365日間はキャンセルできない Flex Slots 60秒単位でスロットを購入 購入から60秒後にはキャンセル可能なため、より柔軟にスロットを購入できる スロットは100スロットから購入でき、100スロット単位で増やせます。なお、オンデマンド料金モデルは1プロジェクトあたり、2000スロットまでは保証されます。一方、定額料金モデルで2000スロット以下にするとパフォーマンスが落ちる可能性もあります。 BigQueryの利用状況によっては、2000スロット以上必要とすることはなく、その分コストを抑えたい場合もあるかと思います。その場合には、Cloud MonitoringやInformation Schemaを用いてBigQueryのスロット利用状況を確認することで、利用状況に応じた最適なスロット数を決めることができます。 Estimating how many slots to purchase 引用: Workload management using Reservations  |  BigQuery  |  Google Cloud The minimum commitment size is 100 slots, 引用: Reservations details and limitations  |  BigQuery  |  Google Cloud Note: On-demand pricing gives you access to 2,000 slots per Google Cloud project. With flat-rate pricing, you can commit to fewer than 2000 slots, but your queries might be less performant, depending on your workload demands. 引用: Introduction to Reservations  |  BigQuery  |  Google Cloud 予約 購入したコミットメントはバケットに対して割り当てられます。この操作を「予約」と呼びます。予約の操作では、どのくらいスロットを割り当てるか決めます。これをすることにより、購入したスロットをプロジェクトや組織に適用できるようになります。 例えば、2000スロット購入している場合、1000スロットを予約すれば、割り当てられたプロジェクトで1000スロットまで使えます。2000スロット全ての予約も可能です。なお、購入したスロット数以上の予約はできません。 割り当て 予約したスロットに対してGCPプロジェクトなどのリソースを割り当てます。この操作を「割り当て」と呼びます。リソースには組織、フォルダ、プロジェクトを割り当てることができます。企業で利用する場合に当てはめると、組織には会社名、フォルダには部署名、プロジェクトには部署で管理するGCPプロジェクトが該当します。なお、このリソースは継承関係を持ち、プロジェクトはフォルダの割り当てを継承し、フォルダは「組織」の割り当てを継承しています。この「割り当て」の有無で料金モデルが決まります。もし、オンデマンド料金モデルに戻したい場合は割り当てを削除します。 なぜFlex Slotsを使う必要があるのか 次にどうしてFlex Slotsを使う必要があったのか、弊社のデータ基盤が抱えていた課題を紹介します。 弊社でもデータの利活用が進んでおり、全てのデータをBigQueryに集める方針があります。集められたデータはデータ分析やML案件など幅広く用いられています。データの利活用が進むにつれ、BigQueryのデータ量やそれを扱う利用者も増加しました。データ量や利用者、BigQueryを使う案件も増えたことで「パフォーマンス」や「コスト」の課題が見えてきました。 パフォーマンスの課題 データマートなどのバッチ処理の集計が課題になっていた データ量が少ない時は必要な時間までにバッチ処理の集計を終えることができていたが、データ量や必要なデータマートが増えるにつれ、必要な時間までに集計を終えることができなくなった オンデマンド料金モデルではプロジェクトあたり2000スロットまでしか保証されないため、安定したパフォーマンスを得るためにはもっとスロットが必要だった コストの課題 クエリ実行に伴うコストも上がっていた データの利活用が進むにつれ、データ量に加えてBigQueryの利用者も増え、オンデマンド料金モデルなので当然費用も上がった 利用者のBigQueryのリテラシーも人それぞれなので、あまり詳しくない人でもコストを意識せずに使えるように管理する必要があった これらの課題を解決するためにFlex Slotsを導入しました。先に述べたようにFlex Slotsを用いることで、より柔軟にスロットを購入できます。60秒単位で購入のキャンセルができるため、重いバッチ処理など一時的に多くのスロットを使う場合に有効です。 従来の定額料金モデルには、年次コミットメントと月次コミットメントの2種類しかありませんでした。月次コミットメントを使えばコストを抑えることができますが、スロットを多く使うバッチ処理の集計を時間内に終えることができません。一方、月次コミットメントでバッチ集計に必要な量のスロットを購入すれば、集計を時間内に終えることができますが、コスト面でのデメリットが大きくなります。定額料金モデルが2種類しかなかった頃は、私たちのユースケースとは相性が良くありませんでした。 そこに、スロットの購入方法として新たにFlex Slotsが導入され、より柔軟なスロットの購入が可能となりました。スロットをあまり必要としないアドホックなクエリなどは、月次コミットメントでコストを抑えます。一方でバッチ処理の集計など多くのスロットが必要な時は、バッチ集計中のみFlex Slotsで必要なスロットを追加で購入し、割り当てることができます。Flex Slotsを導入することでパフォーマンス、コスト面の課題を解決することが可能になりました。 Flex Slotsを用いたコストパフォーマンス改善設計 私たちは月次コミットメント2000スロット、データマート集計前にFlex Slots 7000スロットを購入しています。データマート集計時は月次コミットメントで購入した2000スロットとFlex Slotsにて購入した7000スロット、合計9000スロットを割り当てます。本章では、管理プロジェクトの作成からFlex Slotsを用いたワークフロー設計まで、弊社の活用事例をご紹介します。 Icons made by irasutoya from www.irasutoya.com 管理プロジェクトの作成 まず、BigQuery Reservations用の管理プロジェクトを作成します。既存のプロジェクトとは分けて管理用のプロジェクトを作ります。 そして、この管理プロジェクトを用いて、組織内の各プロジェクトにあるBigQueryのスロットを管理できるようにします。なお、管理プロジェクトではBigQuery Reservations APIを有効化する必要があります。 弊社では複数のGCPプロジェクトが存在しますが、現在BigQuery Reservationsを用いて定額モデルを採用しているのはデータ基盤のプロジェクトのみです。そして、権限は管理用のプロジェクトと割り当てプロジェクト両方で必要な点に注意しましょう。 Icons made by irasutoya from www.irasutoya.com Project-to-reservation assignment requires that you grant permission in both the administration project and the assignee projects. We recommend that you grant administrators the bigquery.resourceAdmin role at the organization or folder level. 引用: Reservations details and limitations  |  BigQuery  |  Google Cloud 月次コミットメントの活用 月次コミットメントを用いて、スロットの購入から予約、プロジェクトに対する割り当てまでの流れを説明します。Flex Slotsと違い、一度操作するだけで良いものなので、GCPコンソールから設定します。 Icons made by irasutoya from www.irasutoya.com 最初にスロットの購入をします。GCPコンソールから、月次コミットメントを2000スロット購入します。 次に予約です。先ほど購入した2000スロットを全て予約します。予約では手持ちのスロットのうち何スロット割り当てるか決めます。弊社の場合、データ基盤用のGCPプロジェクト1つのみが対象なので、購入したスロット全てを予約しています。 最後に割り当てです。予約から割り当てを作り、スロットを割り当てるプロジェクトを選択します。そして、割り当てが完了すると、スロットを割り当てたプロジェクトはオンデマンド料金モデルから定額料金モデルに切り替わります。その結果、割り当て完了後には、弊社の環境の場合はデータ基盤のプロジェクトで2000スロットまで使うことができるようになります。 Flex Slotsの活用とワークフロー設計 コストパフォーマンスを改善させるために、Flex Slotsを用いてバッチ処理の前にスロットを購入し、完了後にスロットを破棄させるようにします。この制御はワークフローエンジンであるDigdagを用います。ここでは、Digdagのワークフローや実行してるタスクもご紹介します。 ワークフロー Digdagで実行しているワークフローを紹介します。Digdagではコンテナイメージ内部でオペレータのタスクを実行できます。それを利用するために、GCPのサービスアカウントなどの秘密情報をDigdag Secretに登録し、タスク実行時に環境変数として渡しています。 タスクの流れを順に説明します。まず、バッチ処理の実行前にFlex Slotsで7000スロット購入します。次に、月次コミットメントで購入した2000スロットと合わせて9000スロット割り当てます。なお、割り当ては既に月次コミットメント適用時に作成済みなので、再度作る必要はありません。 Icons made by irasutoya from www.irasutoya.com +bigquery_flex_slots_up: +bigquery_flex_slots_commitment : _retry: limit: 5 interval: 10 interval_type: exponential _env: GCP_CREDENTIAL: ${secret :gcp.credential } _export: docker: image: ${docker_cloudsdk.image} pull_always: ${docker_cloudsdk.pull_always} sh > : tasks/bigquery_flex_slots_purchase_commitment.sh 7000 +bigquery_flex_slots_reservation : _retry: 3 _env: GCP_CREDENTIAL: ${secret :gcp.credential } _export: docker: image: ${docker_cloudsdk.image} pull_always: ${docker_cloudsdk.pull_always} sh > : tasks/bigquery_flex_slots_reservation.sh batch 9000 続いて、購入完了後にワークフローエンジンでバッチ処理を実行します。バッチ処理完了後は再度予約で2000スロットに戻してから、Flex Slotsで購入した7000スロットを削除します。なお、2000スロットへ戻す前にFlex Slotsを削除してしまうと、予約で割り当てたスロットを満たすことができなくなり、エラーが発生します。 Icons made by irasutoya from www.irasutoya.com +bigquery_flex_slots_down: +bigquery_flex_slots_reservation : _retry: 3 _env: GCP_CREDENTIAL: ${secret :gcp.credential } _export: docker: image: ${docker_cloudsdk.image} pull_always: ${docker_cloudsdk.pull_always} sh > : tasks/bigquery_flex_slots_reservation.sh batch 2000 +bigquery_flex_slots_removement : _retry: 3 _env: GCP_CREDENTIAL: ${secret :gcp.credential } _export: docker: image: ${docker_cloudsdk.image} pull_always: ${docker_cloudsdk.pull_always} sh > : tasks/bigquery_flex_slots_remove_commitment.sh タスク コミットメント、予約、割り当て実行しているタスクをご紹介します。 「コミットメントの購入」タスク コミットメントの購入は以下のスクリプト bigquery_flex_slots_purchase_commitment.sh で行います。当時はbqコマンドしかサポートされていなかったため、bqコマンドを使っています。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID slots = $1 # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc bq mk --project_id= ${admin_project_id} --location=US --capacity_commitment --plan=FLEX --slots= ${slots} そして、スロットの購入に成功すると以下のようなログが出力されます。stateがACTIVEであれば、問題なく購入できていることを示しています。 name slotCount plan renewalPlan state commitmentStartTime commitmentEndTime --------------------------------------------- ----------- ------ ------------- -------- ----------------------------- ----------------------------- admin-gcp-project:US. 12697877815420638341 7000 FLEX ACTIVE 2021-07-06T01:03:56.570385Z 2021-07-06T01:04:56.570385Z この状態でGCPコンソールを確認すると、月次コミットメントに加え、購入したFlex Slotsが表示されていることを確認できます。 「コミットメントの削除」タスク コミットメントの削除は以下のスクリプト bigquery_flex_slots_remove_commitment.sh で行います。なお、削除対象のコミットメントIDを指定する必要があります。そのため、Flex SlotsのコミットメントIDを取得し、そのIDを利用して対象のコミットメントを削除します。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc capacity_commitment_id = $( bq ls --capacity_commitment --location US --format prettyjson --project_id= ${admin_project_id} | jq ' map(select(.["plan"] | startswith("FLEX"))) | .[] | .name | split("/") | .[5] '| sed ' s/"//g ' ) # removement bq rm --project_id= ${admin_project_id} --location=US --capacity_commitment ${admin_project_id} :US. ${capacity_commitment_id} 「予約」タスク 予約は以下のスクリプト bigquery_flex_slots_reservation.sh で行います。予約により、プロジェクトに対し割り当てるスロットを確保します。ここでは、月次コミットメント2000とFlex Slots 7000、合計9000スロットを予約します。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID reservation = $1 assignment_project_slot = $2 # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc # reservation bq update --project_id= ${admin_project_id} --location=US --slots= ${assignment_project_slot} --reservation ${reservation} そして、予約に成功すると以下のようなログが出力されます。 name slotCapacity ignoreIdleSlots creationTime updateTime ------------------------------ -------------- ----------------- ----------------------------- ----------------------------- admin-gcp-project:US.batch 9000 False 2020-06-01T01:48:43.480961Z 2021-07-06T01:06:55.357921Z また、GCPコンソールの予約を確認すると2000スロットから9000スロットに更新されていることも確認できます。 運用におけるFlex Slotsの注意点とワークフロー設計 上記の運用を実際に行ってみると、Flex Slotsを購入できないことが1〜2か月に1度程度発生します。スロットが購入できない場合、そのままでは月次コミットメントで購入した2000スロットを用いてバッチ処理の集計をすることになります。 本来9000スロットを必要とするバッチ処理の集計を2000スロットで実施することになり、完了していなければいけない想定の時間までに集計を終えることができません。そのため、Flex Slotsを購入できなかった場合には、割り当てを削除し、定額料金モデルからオンデマンド料金モデルに切り替える必要があります。 それらを考慮し、以下のワークフロー設計で運用をしています。 運用上の注意点2選 前述のフロー設計で運用していく場合にも注意点が存在するので、2つ紹介します。どちらもFlex Slotsが購入できない場合に必要となる対応です。 コミットメント購入時は冪等にする コミットメント購入時には冪等性を意識する必要があります。 実際に次のようなエラーで購入できない場合があります。 BigQuery error in mk operation: Failed to create capacity commitment in '' : The service is currently unavailable. このエラーの場合、リトライすることで購入できます。リトライによりFlex Slotsを2回購入してしまうと費用がその分加算されてしまうので、以下のスクリプト例のように、既に購入したコミットメントを削除して冪等性を意識した処理にします。なお、PENDING状態でもしばらくするとACTIVE状態になるため、スロット分の費用が発生します。 #!/bin/bash slots = $1 bash tasks/bigquery_flex_slots_remove_commitment_wrapper.sh bash tasks/bigquery_flex_slots_purchase_commitment.sh $slots オンデマンド料金モデルに自動で切り替える Flex Slotsで購入したコミットメントの状態がPENDING状態の場合、次の購入が長時間できない状態になることが多いです。Exponential Backoffでリトライしても購入できません。そのため、後続処理が遅延しないように定額料金モデルからオンデマンド料金モデルへ切り替える必要があります。公式ドキュメントでもそのような対応を勧めています。 発生頻度や遅延の影響を考慮すると、自動切り替えが可能なワークフロー設計にする必要があります。それには、割り当てを削除することで切り替え可能な設計にできます。月次コミットメントは削除できませんが、PENDING状態のFlex Slotsは削除できるので、割り当てと一緒に削除します。なお、PENDING状態の場合には費用は請求されませんが、ACTIVE状態になると請求されてしまいます。 Slots are subject to available capacity. When you purchase slots and BigQuery allocates them, then the Status column shows a check mark. If BigQuery can't allocate the requested slots immediately, then the Status column remains pending. You might have to wait several hours for the slots to become available. If you need access to slots sooner, try the following: If a slot commitment fails or takes a long time to complete, consider using on-demand pricing temporarily. With this solution, you might need to run critical queries on a different project that's not assigned to any reservations, or you might need to remove the project assignment altogether. 引用: Working with Reservations  |  BigQuery  |  Google Cloud ワークフロー 運用上の注意点で述べたように、Flex Slotsを購入できなかった場合には、オンデマンド料金モデルに切り替える必要があります。冒頭で紹介したワークフローと違い、各タスクで冪等性を考慮したり、コミットメントの状態を確認し、オンデマンド料金モデルに切り替えられるよう設計し直しています。実行してるタスクはラッパータスクとして後ほどご紹介します。 +bigquery_flex_slots_up: +bigquery_flex_slots_commitment : _retry: limit: 5 interval: 10 interval_type: exponential _env: GCP_CREDENTIAL: ${secret :gcp.credential } _export: docker: image: ${docker_cloudsdk.image} pull_always: ${docker_cloudsdk.pull_always} sh > : tasks/bigquery_flex_slots_purchase_commitment_wrapper.sh 7000 +bigquery_flex_slots_verify_for_ondemand_planning : _retry: 3 _env: GCP_CREDENTIAL: ${secret :gcp.credential } _export: docker: image: ${docker_cloudsdk.image} pull_always: ${docker_cloudsdk.pull_always} sh > : tasks/bigquery_flex_slots_verify_for_ondemand_planning.sh +bigquery_flex_slots_reservation : _retry: 3 _env: GCP_CREDENTIAL: ${secret :gcp.credential } _export: docker: image: ${docker_cloudsdk.image} pull_always: ${docker_cloudsdk.pull_always} sh > : tasks/bigquery_flex_slots_reservation.sh batch 9000 これにより、オンデマンド料金モデルへ切り替えた場合、割り当ては削除されるようになります。バッチ処理完了後に割り当ての有無を確認し、割り当てがなければ割り当てを作る点が、前述のタスクとの違いです。 +bigquery_flex_slots_down: +bigquery_flex_slots_reservation : _retry: 3 _env: GCP_CREDENTIAL: ${secret :gcp.credential } _export: docker: image: ${docker_cloudsdk.image} pull_always: ${docker_cloudsdk.pull_always} sh > : tasks/bigquery_flex_slots_reservation.sh batch 2000 +bigquery_flex_slots_removement : _retry: 3 _env: GCP_CREDENTIAL: ${secret :gcp.credential } _export: docker: image: ${docker_cloudsdk.image} pull_always: ${docker_cloudsdk.pull_always} sh > : tasks/bigquery_flex_slots_remove_commitment_wrapper.sh +bigquery_flex_slots_reserve_assignment : _retry: 3 _env: GCP_CREDENTIAL: ${secret :gcp.credential } _export: docker: image: ${docker_cloudsdk.image} pull_always: ${docker_cloudsdk.pull_always} sh > : tasks/bigquery_flex_slots_reserve_assignment_wrapper.sh ラッパータスク 次に、ワークフローから実行しているタスクを紹介します。冪等性やエラーのハンドリングをする必要があるため、ラッパータスクを用意しています。なお、ラッパータスクから呼び出しているタスクは後述します。 コミットメント 最初に、コミットメントのラッパータスクをご紹介します。 コミットメントの購入 コミットメントの購入時に、以下のエラーが出力される場合もあります。このエラーがでた場合、購入したコミットメントはPENDING状態になります。 BigQuery error in mk operation: Failed to create capacity commitment in '' : The service is currently unavailable. PENDING状態になった場合、しばらくするとACTIVEになり請求対象となります。そのため、リトライの際は対象のコミットメントを削除してから再購入しています。 Flex Slotsを運用してみたところ、Flex Slotsでコミットメントを購入できないケースが2パターン見つかりました。 1つ目は、コミットメント購入時に上記のエラーが出力されて購入できない場合です。この場合は、Exponential Backoffなどでリトライすることで購入できます。 2つ目は、コミットメント購入時にエラーは出力されないものの、購入したコミットメントの状態がPENDINGになる場合です。 コミットメントの購入に失敗することは、前述の通り1〜2か月に1度程度発生し、ほとんどが上記の2つ目のパターンです。2つ目の場合、Exponential Backoffなどでリトライしても購入できないため、即時オンデマンド料金モデルへ切り替える必要があります。 上記の内容を、以下のスクリプト bigquery_flex_slots_purchase_commitment_wrapper.sh で行います。 #!/bin/bash slots = $1 bash tasks/bigquery_flex_slots_remove_commitment_wrapper.sh bash tasks/bigquery_flex_slots_purchase_commitment.sh $slots コミットメントの削除 コミットメントを削除するタスクです。まず、オンデマンド料金モデルへの切り替えを考慮し、割り当ての有無を確認します。その後、割り当ての確認ができたらFlex SlotsのコミットメントIDを取得して、対象のコミットメントIDを削除しています。 上記の内容を、以下のスクリプト bigquery_flex_slots_remove_commitment_wrapper.sh で行います。 #!/bin/bash # removement assignment = $( bash tasks/bigquery_flex_slots_assignment_status.sh ) if [ -n " $assignment " ]; then capacity_commitment_id = $( bash tasks/bigquery_flex_slots_fetch_commitment.sh ) if [ -n " $capacity_commitment_id " ]; then bash tasks/bigquery_flex_slots_remove_commitment.sh $capacity_commitment_id fi fi なお、コミットメントを削除する際に、以下のエラーが出力される場合もあります。一見、コミットメントの削除に失敗していそうですが、実際には削除できています。そのため、リトライした際に削除済みであるか、コミットメントの有無を確認しています。 BigQuery error in rm operation: Failed to delete capacity commitment ' admin-gcp-project:US.11812766842974244240 ' : The service is currently unavailable. 予約 次に予約のタスクをご紹介します。 予約の作成 このタスクではFlex Slotsで購入した7000スロットと月次コミットメントを合わせた、合計9000スロットをデータ基盤用のプロジェクトへ割り当てるために利用しています。 上記の内容を、以下のスクリプト bigquery_flex_slots_reservation.sh で行います。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID reservation = $1 assignment_project_slot = $2 # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc # reservation assignment = $( bash tasks/bigquery_flex_slots_assignment_status.sh ) if [ -n " $assignment " ]; then bq update --project_id= ${admin_project_id} --location=US --slots= ${assignment_project_slot} --reservation ${reservation} fi 割り当て 定額料金モデルからオンデマンド料金モデルに切り替えるには、作成した割り当てを削除する必要があります。割り当てがあるか確認し、もしなければ割り当てを作ります。 割り当ての作成 このタスクはワークフローでオンデマンド料金モデルに切り替えたのち、バッチ集計完了後に実行しています。割り当てがない場合は割り当てを作ります。 上記の内容を、以下のスクリプト bigquery_flex_slots_reserve_assignment_wrapper.sh で行います。 #!/bin/bash # reservation_assignment assignment = $( bash tasks/bigquery_flex_slots_assignment_status.sh ) if [ -z " $assignment " ]; then bash tasks/bigquery_flex_slots_reserve_assignment.sh bash tasks/bigquery_flex_slots_alert_notice.sh 2 fi 割り当ての削除 割り当てを削除するタスクです。具体的には、作成した割り当てを取得して削除します。これは、定額料金からオンデマンド料金モデルへ切り替えるために利用しています。 上記の内容を、以下のスクリプト bigquery_flex_slots_remove_assignment_wrapper.sh で行います。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID assignment = $( bash tasks/bigquery_flex_slots_fetch_assignment.sh ) bash tasks/bigquery_flex_slots_remove_assignment.sh $assignment オンデマンド料金モデルへ切り替え Flex Slotsで購入したコミットメントの状態を確認してPENDING状態だった場合、即時にオンデマンド料金モデルへ切り替えています。Flex SlotsはPENDING状態であれば請求されませんが、しばらくするとACTIVE状態になり、請求対象になります。そのため、購入したスロットをまず削除します。 その後、割り当てを削除し、定額料金モデルからオンデマンド料金モデルへ切り替えます。前述の通り、この場合はExponential Backoffなどでリトライしても購入できないため、即時オンデマンド料金モデルに切り替えています。しかし、オンデマンド料金モデルの場合、確実に遅延します。遅延の原因調査をやりやすくするために、料金モデルを切り替えた際には通知を飛ばしています。 上記の内容を、以下のスクリプト bigquery_flex_slots_verify_for_ondemand_planning.sh で行います。 #!/bin/bash # verification flex_slots_status = $( bash tasks/bigquery_flex_slots_fetch_commitment_status.sh ) if [ $flex_slots_status = "PENDING" ]; then # change plan from flex slots to ondemand bash tasks/bigquery_flex_slots_remove_commitment_wrapper.sh bash tasks/bigquery_flex_slots_remove_assignment_wrapper.sh bash tasks/bigquery_flex_slots_alert_notice.sh 1 fi その結果、購入したスロットを見るとPENDING状態であることが分かります。 Capacity commitment admin-gcp-project:US. name slotCount plan renewalPlan state commitmentStartTime commitmentEndTime -------------------------------------------- ----------- ------ ------------- --------- --------------------- ------------------- admin-gcp-project:US. 9638566938320457134 7000 FLEX PENDING タスク 次にラッパータスクから実行しているタスクを紹介します。 コミットメント コミットメントの購入、削除、取得、状態確認をするスクリプトをご紹介します。 コミットメントの購入 まずはコミットメントを購入するスクリプトです。その際に、コミットメントでは購入したいスロット数を指定します。弊社では前述の通り7000スロット購入しています。 上記の内容を、以下のスクリプト bigquery_flex_slots_purchase_commitment.sh で行います。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID slots = $1 # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc bq mk --project_id= ${admin_project_id} --location=US --capacity_commitment --plan=FLEX --slots= ${slots} コミットメントの削除 次に、コミットメントを削除するスクリプトです。購入したコミットメントは削除しないかぎり請求されるため、バッチ処理が終わったら、購入したコミットメントを即時削除する必要があります。 上記の内容を、以下のスクリプト bigquery_flex_slots_remove_commitment.sh で行います。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID capacity_commitment_id = $1 # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc # removement bq rm --project_id= ${admin_project_id} --location=US --capacity_commitment ${admin_project_id} :US. ${capacity_commitment_id} コミットメントの取得 コミットメントを取得するスクリプトです。コミットメントを削除する際にはコミットメントIDを取得する必要があるため、その用途で利用するものです。 上記の内容を、以下のスクリプト bigquery_flex_slots_fetch_commitment.sh で行います。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc bq ls --capacity_commitment --location US --format prettyjson --project_id= ${admin_project_id} | jq ' map(select(.["plan"] | startswith("FLEX"))) | .[] | .name | split("/") | .[5] ' | sed ' s/"//g ' コミットメントの状態取得 コミットメントの状態を確認するスクリプトです。PENDING状態の場合に、コミットメントと割り当てを削除し、定額料金モデルからオンデマンド料金モデルへ切り替えるために利用します。 上記の内容を、以下のスクリプト bigquery_flex_slots_fetch_commitment_status.sh で行います。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc bq ls --capacity_commitment --location US --format prettyjson --project_id= ${admin_project_id} | jq ' map(select(.["plan"] | startswith("FLEX"))) | .[] | .state | split("/") | .[0] ' | sed ' s/"//g ' 予約 次に予約を作成するスクリプトをご紹介します。 予約の作成 予約を作成するスクリプトです。Flex Slotsで購入した7000スロットと月次コミットメントを合わせた、合計9000スロットをデータ基盤用のプロジェクトへ割り当てるために利用しています。 上記の内容を、以下のスクリプト bigquery_flex_slots_reservation.sh で行います。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID reservation = $1 assignment_project_slot = $2 # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc # reservation assignment = $( bash tasks/bigquery_flex_slots_assignment_status.sh ) if [ -n " $assignment " ]; then bq update --project_id= ${admin_project_id} --location=US --slots= ${assignment_project_slot} --reservation ${reservation} fi 割り当て 次に割り当ての作成、削除、取得、作成有無を確認するスクリプトをご紹介します。 割り当ての作成 割り当てを作成するスクリプトです。割り当てを作成することにより、BigQueryの料金モデルがオンデマンド料金モデルから定額料金モデルに切り替わります。 上記の内容を、以下のスクリプト bigquery_flex_slots_reserve_assignment.sh で行います。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc # reservation_assignment bq mk --reservation_assignment --project_id= ${admin_project_id} --assignee_id=assignee-gcp-project --location=US --assignee_type=PROJECT --job_type=QUERY --reservation_id= ${admin_project_id} :US.batch 割り当ての削除 割り当てを削除するスクリプトです。割り当てを削除することにより、BigQueryの料金モデルが定額料金からオンデマンド料金モデルへ切り替わります。 上記の内容を、以下のスクリプト bigquery_flex_slots_remove_assignment.sh で行います。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID assignment = $1 # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc # removement bq rm --project_id= ${admin_project_id} --location=US --reservation_assignment $assignment 割り当ての取得 割り当てたリソース名を取得するスクリプトです。オンデマンド料金モデルへ切り替える際に、リソース名を取得して削除するために利用しています。 上記の内容を、以下のスクリプト bigquery_flex_slots_fetch_assignment.sh で行います。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc bq ls --project_id= ${admin_project_id} --location=US --reservation_assignment --format prettyjson | jq ' map(select(.["assignee"] | startswith("projects/assignee-gcp-project"))) | .[] | .name ' | sed ' s/projects//g ' | sed ' s/locations/:/g ' | sed ' s/reservations/./g ' | sed ' s/assignments/./g ' | sed ' s/\///g ' | sed ' s/"//g ' 割り当ての有無を確認するために、以下のスクリプト bigquery_flex_slots_assignment_status.sh も利用しています。 #!/bin/bash admin_project_id = $ADMIN_PROJECT_ID # authorization echo $GCP_CREDENTIAL > GCP_CREDENTIAL.json gcloud auth activate-service-account --key-file=GCP_CREDENTIAL.json export BIGQUERYRC =/root/.bigqueryrc # flex slots status bq ls --project_id= ${admin_project_id} --location=US --reservation_assignment | sed ' s/No reservation assignments found.//g ' Flex Slotsの導入効果 本章では、Flex Slotsの導入効果をご紹介します。 パフォーマンス面の効果 Flex Slots導入により、2〜6時間ほどのパフォーマンス改善が実現されました。スロットを購入できた場合、1.5時間前後でバッチ処理は完了します。スロットを購入できなかった場合は、即時オンデマンド料金モデルへ切り替えています。オンデマンド料金モデルだと、改善された分のパフォーマンスが得られないため、スロットを購入できた場合と比べ2〜6時間ほど遅延します。 コスト面の効果 現時点で毎月数十万円ほどのコストメリットを得られています。 オンデマンド料金モデルでは1TBあたり日本円にして500円ほどで、毎月1TBまでは無料で利用できます。なお、クエリで処理しているバイト数はBigQueryのInformation Schemaより確認できます。 SELECT SUM (total_bytes_processed) /( 1024 * 1024 * 1024 * 1024 ) AS total_terabyte_processed FROM `assignee-gcp-project`.`region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT WHERE creation_time BETWEEN ' 2021-06-01 00:00:00.000 UTC ' AND ' 2021-06-30 23:59:59.000 UTC ' cloud.google.com Flex Slotsのメリット・デメリット Flex Slotsを導入して実感したメリットとデメリットをご紹介します。 メリット Flex Slotsを利用するメリットは以下の通りです。 パフォーマンスが改善される Flex Slots導入により、オンデマンド料金モデルに比べて格段に集計時間が早くなりました。3〜6時間かかっていた集計が1.5時間程度で終わります。 BigQueryのオンデマンド料金モデルのままでパフォーマンス要件を満たせない場合、Flex Slotsを導入して得られるメリットは大きいでしょう。 コストを削減できる Flex Slotsの導入効果で述べたように、弊社の利用状況だと月々数十万円ほどのコスト削減を実現できています。なお、既に述べたようにコストメリットはBigQueryの利用状況にも依存します。コストとパフォーマンスはトレードオフなので、Cloud MonitoringやInformation Schemaを使い、適切なスロット数を割り当てることでコスト削減が可能です。 デメリット Flex Slotsを利用するデメリットは以下の通りです。どれもスロットを購入できないことに起因するものです。 リトライしても購入できない場合がある Flex Slotsが購入できない場合、そのまま数時間にわたり購入できないことが多いです。月次コミットメントだと購入したスロット数以上のスロットは割り当てられないため、オンデマンド料金モデルに切り替えています。発生頻度は前述の通り1〜2か月に1度程度ですが、最長だと5日間連続で購入できないことも過去に発生しました。発生頻度が低いとは言えないため、手動ではなく自動で切り替わるようにしておかないと、運用負荷が大きくなります。 Slots are subject to available capacity. When you purchase slots and BigQuery allocates them, then the Status column shows a check mark. If BigQuery can't allocate the requested slots immediately, then the Status column remains pending. You might have to wait several hours for the slots to become available. If you need access to slots sooner, try the following: 引用: Working with Reservations  |  BigQuery  |  Google Cloud オンデマンド料金モデル切り替えに伴いコストが発生する 定額料金モデルからオンデマンド料金モデルへ切り替える場合、割り当てとFlex Slotsで購入したコミットメントを削除します。 しかし、Flex Slotsのコミットメントは削除できますが、月次コミットメントは購入から30日間は削除できません。そのため、切り替えに伴ってオンデマンド料金に加え、月次コミットメント分の費用が余分に発生します。 ワークフローが煩雑化する Flex Slotsが購入できない場合を考慮したワークフロー設計にする必要があるため、それに伴ってコードも煩雑化します。サポート状況などの確認が必要ですが、必要に応じてOSSにPull Requestを投げつつ、Pythonのクライアントライブラリに置き換えたいと考えています。 googleapis.dev 今後の活用 現在は、社内でもデータ基盤用のプロジェクトのみ、定額料金で運用しています。今後は全社展開を行い、コスト・パフォーマンス改善を全社規模で行いたいと考えています。 社内では、機械学習の活用に伴いBigQueryの利活用が進んでおり、推論のバッチやBigQuery MLなど、Flex Slotsを使ってパフォーマンス面で解決できる部分は多く存在しています。さらに、検索系の案件など、全社のBigQuery費用の上位に該当するプロジェクトもあります。これらのプロジェクトでコスト、パフォーマンスの両面で最適化を行えば、その効果は大きなものになるでしょう。 また、現時点ではBigQueryのジョブユーザ管理ができていません。BigQueryはストレージとコンピューティングリソースが分離されており、IAMもBigQuery閲覧権限とBigQueryジョブユーザと別れています。クエリ実行に伴う請求はこのBigQueryジョブユーザ権限を付与したプロジェクトに対して行われます。下図の場合、プロジェクトBでジョブユーザを管理し、プロジェクトAのテーブルやビューを参照できます。 引用: BigQuery for data warehouse practitioners  |  Cloud Architecture Center しかし、現時点では社内でジョブユーザの管理をうまくできていないため、重複管理やオンデマンド料金モデルのプロジェクトに対しても付与されています。ユーザに紐付くアドホックなクエリなど、定額料金モデルを適用したプロジェクトで管理できるとBigQueryのコストを下げることができるので、この点は今後の課題としています。 おわりに Flex Slotsを導入することで、BigQueryのコストやパフォーマンスの改善が可能であること、その運用上の注意点をご紹介しました。Flex Slotsの購入に失敗することを考慮したワークフロー設計が必要になるなどの煩雑さはありますが、欠点を大きく上回る利点があります。今後もFlex Slotsを積極的に使っていく予定です。 データの利活用を促進する際に、本記事が同じような課題を抱えている方の参考にれば幸いです。 私たちのチームの業務内容は、以下の記事で紹介しているのでご覧ください。今回紹介した内容以外にも、ログ収集基盤の開発など、データ基盤に関する業務全般を行っています。 https://it-career.blm.co.jp/interviews/zozotechnologies-it-taniguchi-interview it-career.blm.co.jp ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! https://tech.zozo.com/recruit/ tech.zozo.com
アバター
はじめに こんにちは、ZOZOアプリ部でZOZOTOWN iOSアプリを開発している小松です( @tosh_3 )。 気づけば、ZOZOテクノロジーズに新卒入社して1年が過ぎていました。オフィスの近くに引っ越したのですが、オフィスに出社する前に、オフィスが移転しました。 さて突然ですが、最近ZOZOTOWNに大きな変化があったことをみなさんお気づきでしょうか。2021年3月18日よりZOZOTOWN全体が大幅リニューアルされ、コスメモールがオープンされるなどの大きな変化がありました。アプリも7.0.0とメジャーバージョンの更新を行い、ほとんど全ての画面が新デザインになりました。 そこで、本記事ではHome画面のリニューアルを担当した私が、そこで使用した技術とその背景について触れながら、ZOZOTOWN iOSアプリのHome画面リニューアルの裏側をお伝えします。 ZOZOTOWNアプリの新旧デザイン比較 ZOZOTOWNのHome画面がどのくらい変わったのか、リニューアル前後のUIを比較してみましょう。 旧デザイン 新デザイン 黒ベースから白ベースへと変化し、よりモダンなデザインになっています。今まで、1つのページで構成されていたデザインは「すべて」「シューズ」「コスメ」と3つのタブの構成になりました。 Home画面のリアーキテクチャ この改修をするにあたり、まずHome画面の再設計をするか否かを考えました。仮に再設計せず、既存のHome画面に新しい機能を追加していく場合、後述するHome画面が抱える潜在的な課題に遭遇する可能性が高くなります。開発の終盤で大きな変更を求められる課題に直面すると、スケジュール的にも場当たりな対応になりがちです。今までのZOZOTOWNアプリ開発の中でも似たようなシチュエーションが何度かありました。 また、再設計という選択肢を取った場合、QAチームによるフルリグレッションテストが必要になります。そのため、なかなか手軽に再設計をする判断をすることは難しいです。しかし、今回のケースでは再設計の有無に関わらず、どちららの選択をしてもQAではフルリグレッションテストを行う予定でした。以上の観点も踏まえると、再設計を行うには絶好の機会だったとも言えます。そのため、既存の課題の多くを救うためにも、再設計するという選択肢を取りました。 既存のHome画面が抱えていた課題 まず、既存のHome画面が抱えていた課題を紹介します。 課題1. 改修すると様々な機能が影響範囲になる ViewControllerが必要以上にたくさんの責務を担っており、改修に対して影響範囲が大きくなってしまう可能性が高かった 課題2. Conflictの温床だった Storyboardメインの開発が行われており、現在のiOSチーム構成(8人体制)の元では、Conflictが多発する原因になっていた HomeViewControllerは数あるVCの中でも改修が多い対象であり、実際にプロジェクト内全てのVCの中で3番目にマージされた数が多かったです。 既存の課題解決へのアプローチ 前述の課題を解決するために、それぞれ以下のアプローチを取ることにしました。 課題1. 改修すると様々な機能が影響範囲になる 3タブ構成になることを踏まえて、HomeViewControllerの役割を再定義する 疎結合かつ役割が明確になるようにクラスを定義する 課題2. Conflictの温床だった Storyboardベースからコードベースへと移行する 次に、疎結合とStoryboardの使用について説明していきます。 HomeViewControllerの整理 まず、既存のHomeViewControllerが持っていた役割を整理しました。 Home画面コンテンツの管理 Home画面コンテンツのAPI通信 Google Analytics(以下、GA)に関する機能 アプリのライフサイクルに関する機能 Home画面のライフサイクルに関する機能 これを見る限り、今までのHomeViewControllerはHome画面としての機能だけではなく、一番最初の画面としての機能も持っていました。結果として、HomeViewControllerは1400行以上にも及ぶFatViewControllerと化していました。 また、3タブ構成(すべて、シューズ、コスメ)へ変更すると、HomeViewControllerはさらに肥大化することが明らかです。そこで、HomeViewControllerの役割を再定義し、新しく各TabのViewControllerとLoggerクラスを作成しました。 それぞれの役割は以下の通りです。 HomeViewController Home画面のタブの管理 アプリのライフサイクルに関する機能 Home画面のライフサイクルに関する機能 TabViewController Home画面に存在する3つのタブ(すべて、シューズ、コスメ)をそれぞれ管理するViewController。 Home画面コンテンツの管理 Home画面コンテンツのAPI通信 Logger GAに関する機能 以上のように、それぞれのクラスに明確な役割を持たせることで、3タブ構成に変更しても今までのコード量と同等で実装できます。 Storyboardを使用することは悪なのか? StoryboardはConflictが多発する原因となっていましたが、「Storyboardを使用することはそんなに悪いことなのか?」と言われるとそうとは思いません。コードベースでUIを書くこと、StoryboardでUIを構築していくことそれぞれに良さがあります。 ZOZOTOWN iOSチームは現在8人体制で開発を行っている 今後もチームのスケールアップを行う可能性が高い このような環境では、Storyboardを使用するよりも、コードベースでUIを構築していくことの方が確実に向いていました。 他の画面では、Storyboardで多数のConflictが発生し、その度に手間がかかるということが多々発生している状態でした。ZOZOTOWNは10年以上の歴史を持つアプリなので、恐らくStoryboardで開発するのが向いていた時代もあったが今は向いていない、ただそれだけのことです。今回の改修では今までHome画面全体だけではなくUICollectionViewCellまでStoryboardで構成されていたものを、Home画面のHeader部分を除き全てコードベースへと変更することに成功しました。 Home画面のリインプリメンテーション 本章では、Home画面の再設計をどのように行ったのかを紹介します。 今回のリニューアルで実装したこと Home再設計にあたり、以下の項目に挑戦しました。 Sandboxの作成 さまざまなパターンのHome画面をすぐに確認できるため、たくさんの試行錯誤が可能に CompositionalLayoutの採用 新デザインに柔軟に対応するために、CompositionalLayoutを採用 適切なComponentへと切り出すことに成功 Storyboardの削除 Storyboardベースで設計されていた画面をコードベースへと移行 HomeViewControllerの疎結合化 GA用のクラスを別に切り出すなど、責任過多を解消 1400行から600行に 本当は全ての内容を紹介をしたいのですが、本記事では特にCompositionalLayoutについて取り上げます。 CompositionalLayoutの採用 大幅なデザイン変更を行うということは、同時にHome画面で使用している技術を刷新する機会でもあります。また、チームとしても既存の部分とうまく結合できるのであれば、積極的に新しい技術に挑戦していく方針があります。そこで、ComopositionalLayoutを使用してみることにしました。CompositionalLayoutはWWDC19で紹介された機能です。続くWWDC20でも新たにCollectionViewListが強化されるなど、Appleとしてもここ数年はCollectionViewに力を入れていると感じていたので、是非機会があれば導入してみたいと考えていました。 なお、CompositionalLayoutとは、CollectionViewのレイアウト方法の1つであり、App Storeのようにセクションごとに異なるレイアウトを簡単に組むことのできる技術です。 引用: Layouts | Apple Developer Documentation App Storeのようなカルーセルでも、FlowLayoutのカスタマイズや、中に別のCollectionViewを置くことなく容易にレイアウトを組めます。 さて、ここまでの説明を読みながら気になっていた方もいると思いますが、CompositionalLayoutが使用可能なのはiOS 13以上です。ZOZOTOWN iOSアプリは原則として3つの最新バージョンをサポートしており、当時サポートしていたOSはiOS 12、iOS 13、iOS 14でした。そのため、iOS 12でCompositionalLayoutをどのようにして使用するか、という問題に直面しました。 調べてみると、iOS 12でもCompositionalLayoutを使用可能にする、 IBPCompositionalLayout というライブラリがありました。そして、下記3点の理由からこのライブラリの導入を決めました。 ライブラリの作成者が弊社の技術顧問である 岸川克己さん なので、何か困ったことがあった際にはいつでも相談できる iOS 13以降では、純正のCompositionalLayoutを使用しており、iOS 12を切るタイミングではほとんど労力なく切り替えることができる 弊社のWEARチームでも使用しているライブラリであり、社内での利用実績がある このライブラリをZOZOTOWNで使ってみると、contentInsetAdjustmentBehaviorの値によってはうまく動かないパターンが見つかりました。原因を特定できたので、修正のPull Requestを出したため、現在では解消されています。ちなみに、このPull Requestで、初めてOSSにコントリビューションする実績をあげることができました。 github.com CompositionalLayoutは、まだ新しい技術ということもあり、慣れるまではどのようにレイアウトを組むのが正解なのかわかりませんでした。そこで、技術検証を十分に行い、実装を進める上で疑問が生じた際には技術顧問の岸川克己さんにも相談に乗っていただきました。 実際に使用してみて感じた、CompositionalLayoutのメリット・デメリットを下記に挙げます。 メリット App Storeのようなセクションごとに異なるレイアウトを組み合わせることが簡単な記述で実装できる カルーセルの動きもサポートされている カルーセル用のスクロールビューを自前で置く必要がない デメリット セクション内のスクロールビューの制御が必要になることにより、実装が難しくなる ライブラリを使用しない限り、iOS 12以前のOSをサポートできない OSによって挙動が若干異なり、一部OSでのみ発生するバグが存在する 個人的には、 今回、新たしい技術に挑戦してみて、確かに難易度が高い部分もありましたが、とても便利な機能だと感じました。是非このようなレイアウトを組む際には、検討してみるといいでしょう。 リザルト 課題とその解決法、そして技術的なアプローチを紹介してきました。それらを利用し、冒頭で紹介したZOZOTOWNが本質的に抱えていた課題をどのように解決したのか、一度まとめておきます。 課題1. 改修すると様々な機能が影響範囲になる 再設計する際に、疎結合にすることを意識し、役割ごとに明確なクラス分岐を行った 1400行以上あったHomeViewControllerは役割が明確になり、600行ほどへ 課題2. Conflictの温床だった Storyboardの使用箇所を大幅に減らし、大部分をコードベースの設計へと変更した その結果、Storyboardの制限に縛られることなくコード上で柔軟な分岐をすることが可能になった 今回の修正により、今後何か改修する際には、より少ない労力、かつ小さい影響範囲で対応できるようになりました。 リキャップ 再設計方法やその考え方に決して正解はありません。しかし、今回の再設計を通じ 疎結合にすること 、 積極的に新しい技術を検討していくこと の重要性を再認識しました。また、技術選定をしていく中で、 チームの状況とこれからを考える という視点を持つ重要性にも新しく気づくことができました。 最後に ZOZOテクノロジーズでは、一緒にモダンなサービス作りをしてくれる方を募集しています。ご興味のある方は、以下のリンクから是非ご応募ください! tech.zozo.com
アバター
こんにちは、ZOZOTOWN部フロントエンドチームの高橋( @anaheim0894 )です。 Chrome 92から 「SharedArrayBuffer」の仕様が変更 されます。それに伴い、 ZOZOTOWN の対応方針と解決策をご紹介いたします。そもそも「SharedArrayBuffer」が何のことなのか分からず困っている方も多いかと思います。本記事で紹介するZOZOTOWNの取り組みが対応時に皆様の参考になれば幸いです。 取り組みのきっかけ 2021年3月、Google Search Consoleに以下メッセージが送られてきました。 Googleの公式アナウンス によると、「Chrome 92からはcross-origin isolation(クロスオリジン分離)が構成されていないと正常に動作しなくなる」と書かれていました。 つまりSharedArrayBufferを引き続き使うには、他サイトのリソースをzozo.jp内で読み込むために明示的に許可する必要があります。 現状では、Chrome以外に既にこの仕様になっているものもあります。 Firefox バージョン76でこの仕様になっている Firefoxの最新バージョンは89(2021/06/29現在) Android版 Chrome バージョン88でこの仕様になっている しかし、上記環境におけるZOZOTOWNの不具合報告は特に受けていません。 なお、サーチコンソールのメッセージには「Chrome 91から」と記載がありましたが、その後の Google Developersのアナウンス により、対応時期が「Chrome 92(2021/07/20リリース)から」に延期されました。 SharedArrayBufferに関する詳細は「海外SEO情報ブログ」にまとまっているので、併せてご覧ください。 www.suzukikenichi.com SharedArrayBufferの概要 SharedArrayBuffer(以下、SAB)は以下のようにまとめられる技術です。 Webサイトのスレッド間でメモリ空間を共有するためのJavaScriptのオブジェクト Web Worker(バックグラウンド処理)間でメモリを共用利用するための技術 詳細は、MDN Web Docsをご覧ください。 developer.mozilla.org 調査・検証 調査・確認として、以下の5点を実施しました。本章ではそれぞれの内容を紹介します。 「SharedArrayBuffer」でソースコード全検索 cross-origin isolation(クロスオリジン分離)の確認 テスト環境でクロスオリジン分離状態のテスト ReportingObserverでSAB使用箇所を特定 有識者への相談 1.「SharedArrayBuffer」でソースコード全検索 「SharedArrayBuffer」という文字列でZOZOTOWNのソースコードを全検索をしました。 その結果、「ヒット無し」で使われている箇所がないことを確認できました。 2. cross-origin isolation(クロスオリジン分離)の確認 そもそも、ZOZOTOWNがクロスオリジン分離状態になっているかどうかを確認します。Chromeの検証ツールを利用し、Consoleで「self.crossOriginIsolated」を叩くことで確認できます。 その結果、「false」の表示が返ってきました。つまり、「クロスオリジン分離状態にはなっていない」と解釈できます。 3. テスト環境でクロスオリジン分離状態のテスト 前述の確認でクロスオリジン分離状態になっていないことは確認できました。そこで、テスト環境で意図的にクロスオリジン分離状態を作りました。HTTPヘッダーに以下の値を追加すると、クロスオリジン分離状態にできます。 Cross-Origin-Embedder-Policy: require-corp Cross-Origin-Opener-Policy: same-origin すると、zozo.jp以外からの読み込みファイルは全てブロックされた状態、具体的には画像などが全て表示されない状態になります。 同様の現象になることを防ぐには、サードパーティ側(zozo.jp以外のドメイン側)のHTTPヘッダーにCORS(Cross-Origin Resource Sharing)か、CORP(Cross-Origin-Resource-Policy)のどちらかを付与してもらう必要があります。 access-control-allow-origin: * cross-origin-resource-policy: cross-origin このヘッダーの追加により、zozo.jp内で読み込み可能となります。 しかし、zozo.jp以外の読み込みドメインの数を確認したところ、かなりの量がありました。そのため、それぞれの企業にこのヘッダーを追加していただくのは非現実的だと判断しました。 4. ReportingObserverでSAB使用箇所を特定 サーチコンソールで「SABが使用されている」と検知されているため、使用箇所を特定する必要があります。 JavaScriptの ReportingObserver を使用すると以下の情報を取得できます。 type(レポートの種類:deprecation/intervention/crash) url(対象ページ) body(詳細情報) id(レポートID) message(Console上の警告テキスト) lineNumber(何行目) columnNumber(何列目) sourceFile(対象ファイル) anticipatedRemoval(現在のブラウザから削除されることが予想される日付) SABの場合、Typeが「 deprecation 」になり、上記の項目を取得できます。なお、「 intervention 」や「 crash 」の場合、Bodyの中身で取得できる項目が異なるのでご注意ください。 SABを使用している箇所がある場合、Chromeの検証ツールで以下の警告が出ます。 [Deprecation] SharedArrayBuffer will require cross-origin isolation as of M91, around May 2021. See https://**/ for more details. つまり、共通で呼び出されるJavaScript内にReportingを仕込めば、Warningを検知して使用箇所を特定できます。 関係する部内のチームが連携し、レポートの実装をしました。 フロントエンドチーム ReportingObserver の実装 バックエンドチーム フロントエンドから呼び出すエンドポイントの実装 SREチーム Splunkにデータ集積 実装方法の詳細は、下記のGoogleの公式ドキュメントをご参照ください。 developers.google.com また、ReportingObserverを入れた状態のZOZOTOWNでレポートログを取得したところ以下の事が分かりました。 発生したページ TOP/検索結果/商品詳細/カートなど 対象のJavaScriptファイル 「chrome-extension://」から始まるJavaScript 上記の内容を含んだログが取得できました。発生したページは主要ページではあるものの規則性が無く、対象のJavaScriptファイルは全てChrome拡張機能で使用されているJavaScriptでした。 その結果、サーチコンソールで検知されたのは特定のユーザーが利用しているChrome拡張機能で使用されたJavaScriptにSABが使用されており、その警告が検知されたのではないかと予想がつきました。 5. 有識者への相談 インターネット上の情報だけでは判断・理解しきれない内容もあったので、本件に詳しい有識者の方へのヒアリングを試みました。 弊社社員の人脈から、本件に詳しい 「Eiji Kitamura / えーじ」 さんとつながることができました。 えーじさんはGoogleの公式ドキュメントとしてSABの取り組みを執筆されており、気になる点を相談するにはまさに理想的な方にお話を伺うことができました。 以下の内容が、えーじさんに相談することで得られた知見です。 ファーストパーティとしてSABを利用していないことが確認できれば特に心配する必要はない 全ソースコードで「SharedArrayBuffer」という文字列が検索でヒットしなければ、サードパーティ製ライブラリを含め、ファーストパーティにもSABが使用されていないという判断で問題ない 既に仕様に組み込まれているFirefoxなどで、問題なく動いていればほぼ問題ない Chrome Canary(開発者向けChrome) を利用すると、Chrome 92で予定されているSABがcross-origin isolationを必須としている仕様なので、現時点でテストが可能である Chrome拡張機能のJavaScriptでログが取れた場合、Content Scriptにてサイトに挿入されたものであれば、サーチコンソール検知対象に含まれている可能性が高い サードパーティでiframeを使用している場合、その箇所はReportingObserverを使用してもログに上がってこないので実際にテストを実施する必要がある サーチコンソールに届いたメッセージは、このケースに該当する場合にも送られている React 17.0.2未満のバージョンではSABが利用されていているが、下位バージョンでのProduction buildファイルに検索して出てこないのであれば心配はいらない サードパーティ側のHTTPヘッダーにCORS/CORPのどちらかを付与してもらうのが難しい場合に対応するため、CORSやCORPに対応していなくてもクロスオリジンのリソースを読み込めるよう、ブラウザがCookieを取り除いて(盗まれて困る情報がない状態で)読み込む COEPモードの追加 が進められている COEPモードの追加が実装されるまでにSABを利用したいページがある場合は、 Origin Trial に登録することで、Chrome 92以降でもこれまで通りSABを使い続けることができる ただし、この対応策は時限的対応なので、Chrome 96以降はcross-origin isolation対応しなければSABが使えなくなる Chrome 96までの予定だが、上記を含むいくつかの改善点が実装されるまでは 延長される予定 ZOZOTOWNの対応内容 上記の調査・確認結果より、現状のZOZOTOWNを以下のように分析しました。 「SharedArrayBuffer」の文字列検索にヒットしていないため、ファーストパーティにSABは使用されていない Chrome拡張機能のJavaScript、もしくはサードパーティのJavaScriptでSABが使用されているのが原因でサーチコンソールに検知された可能性が高い Firefoxで不具合報告がないので問題ない Chrome Canaryでテストの実施が可能である その上で、以下の2点を実施することで、サービスに影響が出ないという結論を出しました。 Chrome Canaryや最新版Firefoxで、主要機能・決済までのフロー・サードパーティのJavaScript使用箇所をメインとした網羅的テストを実施する Reportingの実装・リリースを行い、Warningが出ている箇所を特定する 特定箇所が検出された場合、テストの実施と個別に対応する ZOZOTOWNで取得したログでは全てChrome拡張機能のJavaScriptが対象だったため、恐らくこれが原因と思われる 最後に サーチコンソールにメッセージが届き、はじめは戸惑いました。しかし、えーじさんの協力も得ることができ、自信を持って解決策を見出すことができました。 Chromeは、今後もよりセキュアなブラウザにするためのアップデートが続いていくでしょう。その際にも、今回のような調査を行い、そこで得られた知見のアウトプットを続けていきます。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに こんにちは、検索基盤部 検索基盤チームの可児( @KanixT )です。以前は通勤に片道2時間ほどかかっていましたが、フルリモートワークの環境になり空いた時間で生後4か月の娘の子育てに奮闘中です。 本記事では、検索基盤チームが取り組んだZOZOTOWN検索機能のマイクロサービス化の事例・工夫点を紹介します。これから検索機能のマイクロサービス化にチャレンジする方の参考になれば幸いです。 目次 はじめに 目次 背景と課題 検索機能に特化したマイクロサービスの構築 どのように構築したか 構築時にやったこと APIの実装 静的解析 ヘルスチェックの実装 複数エンドポイントのヘルスチェック 単一エンドポイントのヘルスチェック 各種バージョンアップ Java Swagger OSSのライセンスチェック 外部サービス リリース リリース時にやったこと 得られた効果 開発速度の向上 検索機能に特化したマイクロサービスの開発 まとめ おわりに 背景と課題 ZOZOTOWNでは、ASPからJavaへのリプレイスプロジェクトが数年前より実施されており、これまで多くのAPIを改善・改修してきました。一方、そのリプレイスされた環境には、1つのマイクロサービスに非常に多数のAPIが存在している状態にもなっていました。検索基盤チームが管理する検索APIも、このマイクロサービス(以下、既存マイクロサービス)の中にありました。 既存マイクロサービスは別チームが主管のため、機能追加や改修の際は別チームにレビュー・リリース依頼をしていました。 そのため、改修した内容のマージや、リリースのタイミング等を検索基盤チームが自由にハンドリングできない状態でした。また、把握していないAPIや共通処理等も多数ある状況故に、開発難度が高くなってしまうという課題もありました。 既存マイクロサービスはSQL ServerとElasticsearchを参照しており、1つのマイクロサービスとしては責任が大きく、障害発生時は複数チームが原因特定に動く状態でした。そのため、「リクエスト数が非常に多い検索機能に特化したElasticsearchのみを参照するマイクロサービス」を構築したいという思いがありました。 検索機能に特化したマイクロサービスの構築 検索基盤チームが主管である検索APIのみのマイクロサービスを構築することで、別チームへの依頼事項は無くなり、精通したAPIの開発に集中できます。そのため、開発・改修・リリースに掛かるサイクルの短縮が見込まれます。また、チャレンジングな実装の場合でも、スムーズな意思決定が可能となると考えました。 そこで既存マイクロサービスから検索APIを切り出し、検索機能に特化したマイクロサービスを構築するに際し、下記の目的を定めました。 開発速度を向上させる 検索機能に特化したマイクロサービスを開発する バックエンドはElasticsearchのみとする 既存マイクロサービスから検索APIを切り出すイメージは下図の通りです。この図はZOZOTOWNのシステム概要図であり、青色の部分が今回構築した検索機能のマイクロサービスです。なお、詳細は一部省略しています。 既存マイクロサービス 検索機能のマイクロサービス実装後 検索APIで利用する技術スタックは以下の表の通りです。 技術スタック 言語 Java フレームワーク Spring Boot データベース Elasticsearch どのように構築したか 分割する方針はいろいろと考えられますが、下記の2案で検討しました。 既存マイクロサービスのリポジトリをコピーする 既存のリポジトリを丸々コピーした別のマイクロサービスを構築し、検索機能のリクエストのみを受け付け、不要なAPIは後々消す方式。 メリット リポジトリをコピーするため少ない作業量で短期間の本番リリースが可能 デメリット 不要なAPIのコードが丸々残る SQL Serverの参照が残る 古くなっているライブラリ等もそのまま残る 既存マイクロサービスから検索APIのみを切り出す 検索APIのコードのみをコピー・リファクタリングし、別のマイクロサービスを構築する方式。 メリット 検索APIのみのマイクロサービスが構築できる Elasticsearchのみの参照にできる コードのコピー・リファクタリングのタイミングで各種のバージョンアップが可能 デメリット 1のパターンより作業量が多い 検討の結果、2. の方針を採用し、検索機能に特化したマイクロサービスを構築することにしました。 選定の主な理由は、2. は 1. より実装コストがかかりますが、このタイミングで不要なAPIを取り除いたマイクロサービスを構築することで負債を抱えずに今後の検索機能の開発に集中できると考えたためです。また、このタイミングで、各種バージョンアップや不要なライブラリを削除することでアプリケーション全体の整理整頓ができるメリットもありました。 構築時にやったこと APIの実装 既存マイクロサービスを分割して切り出す対象となるAPIは全部で4本でした。 一からすべてのコードを書き直す余裕はなかったため、既存マイクロサービスの検索APIのコードを移植し、必要に応じて部分的に再実装しました。その際、ユニットテストが十分に書いてあったおかげで安心して移植と再実装ができました。ユニットテストを書くことは安定した品質につながり、コードを変更する作業が非常に容易になると再認識できる良い経験でした。 静的解析 コードの静的解析ツールとして SonarCloud を利用し、コードの状態を可視化しています。チームの取り組みとして、ユニットテストガイドラインを作成し、テストカバレッジを毎週確認することで、コードの品質を保つようにしています。 また、GitHubリポジトリへのPull Request単位でユニットテストのカバレッジが確認できるため、レビュー依頼時には開発者自身でテスト不足がないかを確認してもらうようにしています。 なお、現在のカバレッジは86%です。 この程度のカバレッジになると、実装時にはユニットテストを書くことが当たり前になっているため、「テストを書くこと」が浸透していると実感できます。 1 ヘルスチェックの実装 アプリケーションのヘルスチェックに Spring Boot Actuator のElasticsearch用ヘルスチェックの利用を検討しました。Actuatorは単一のElasticsearchエンドポイントのみに対応しており、複数エンドポイントで運用している弊社には対応できないため独自のヘルスチェックを実装しました。 複数エンドポイントのヘルスチェック ヘルスチェックの独自実装の方法は非常に単純で、各Elasticsearchエンドポイントに対してindexの存在有無を確認するAPIを実装しました。indexの確認方法は次の通りです。 boolean exists = client.indices().exists(request, RequestOptions.DEFAULT); Elasticsearchの Index Exists API より引用 単一エンドポイントのヘルスチェック 複数エンドポイントに比べ、単一のElasticsearchヘルスチェックを行う場合はさらに簡単で、実装は不要で設定のみで実現できます。 まず、 pom.xml に依存関係を追加します。なお、以下に示すXMLはビルドツールにMavenを利用している場合の例です。 <dependencies> <dependency> <groupId> org.springframework.boot </groupId> <artifactId> spring-boot-starter-actuator </artifactId> </dependency> </dependencies> 次に、Elasticsearchに対するヘルスチェックを有効にします。 Spring Boot Actuatorの公式ドキュメント も併せてご覧ください。 management : health : elasticsearch : enabled : true Java(Spring Boot)とElasticsearchを組み合わせたアプリケーションの運用ノウハウにご興味ある方は、こちらの記事も是非ご覧ください。 techblog.zozo.com 各種バージョンアップ 検索機能のマイクロサービス化の際に行った大きなバージョンアップ作業は、JavaとSwaggerのバージョンアップです。なおバージョンアップ後のバージョンは非公表とさせていただきます。 対象 バージョンアップ前 Java 8 Swagger 2.x Java バージョンアップに伴うコード修正はなく、スムーズにバージョンアップできました。しかし、GCをCMSからG1へ変更したため負荷テストとメモリサイズのチューニングを実施しました。 Swagger Swagger 2.xは古くなっていたため、RESTful APIの標準規格と言われるOpen APIへ変更しました。また、今までは非常に大きな1つのyamlファイルに定義が集約されており、開発し辛い状況でした。そのため、yamlファイルを分割し開発をやり易くしました。 OSSのライセンスチェック アプリケーション内では様々なライブラリを利用しているため、OSSライセンスのチェックを行いました。ここでは、そのチェック方法を紹介します。 具体的には、Spring Bootの依存関係からライセンスの一覧を作成し、各ライブラリのライセンスが社内のOSS利用ガイドラインを順守しているかを目視で確認していきました。なお、OSS利用ガイドラインに関する情報は以下の記事に書かれています。 techblog.zozo.com ライセンス一覧は下記コマンドで出力できます。 $ mvn license:add-third-party -D license.excludedScopes=test $ cat ./target/generated-sources/license/THIRD-PARTY.txt | sort > license.txt 参考: License Maven Plugin license:add-third-party 依存関係も把握しておきたい場合、下記コマンドで出力できます。 $ mvn dependency:tree > dependency_tree.txt 外部サービス 運用・監視には下記の外部サービスを活用しています。どのサービスも運用・監視にはなくてはならないサービスです。個別の説明は省きますので、各社のWebページをご参照ください。 Datadog マイクロサービスのモニタリングとアラート検知 Sentry エラー通知 SonarCloud コードの静的解析 PagerDuty インシデントのオンコール通知 リリース すでに本番稼働しているAPIなので、リクエスト先の切り替えは慎重に実施しました。当然のことですが、通常の開発案件も平行で動いているため、それらの開発案件のリリースの合間をみて検索機能のマイクロサービスをリリースしていきました。 リリース時にやったこと 既存マイクロサービスの検索APIと新APIでの比較テスト 同一のリクエストをそれぞれのAPIへリクエストし、同等の結果が得られることを確認する 既存マイクロサービスの開発案件の差分取込 毎週担当者を決めて差分をウォッチし、検索機能に関係する差分がある場合は内容を確認してコードの差分を取り込む カナリアリリース 検索APIは非常に大量のリクエストを受けるため、1度に全リクエストの切り替えず、カナリアリリースで段階的に切替える カナリアリリースについてご興味ある方は、こちらの記事も是非ご覧ください。 techblog.zozo.com 得られた効果 検索機能に特化したマイクロサービスを構築することで、前述の下記の目的が達成できたかを検証してみます。 開発速度の向上 検索機能に特化したマイクロサービスの開発 開発速度の向上 定量的な測定ができていないため、定性的な評価になってしまいますが、自チームでハンドリングできるマイクロサービスは意思決定が早く、開発作業のスピードは確実に上がっていると感じています。 機能の開発だけでは無く、開発がやり易くなるような改善やリファクタリングもチームメンバーが自発的に実施しているため、チームの気持ちのこもったマイクロサービスへと着々と進化しています。 検索機能に特化したマイクロサービスの開発 本番リリース後はプログラム起因による障害は無く、ZOZOTOWNの検索機能のリクエストを日々安定して処理できています。データベースはSQL Serverを参照することは無く、Elasticsearchのみを参照しており、パフォーマンスとアーキテクチャの両面で想定通りのマイクロサービスが構築できました。 なお、既存マイクロサービスの一部APIでは、まだElasticsearchを参照しているため、完全に目的を達成したとは言えないところが残念ではあります。 まとめ 本記事では、ZOZOTOWNで本番稼働している肥大化したマイクロサービスから検索APIを切り出し、検索機能に特化したマイクロサービスを構築した事例を紹介しました。肥大化したマイクロサービスや役割が多いマイクロサービスをシンプルな形にすることで受けられる恩恵は十分にあると思いました。 ZOZOTOWNにおける検索機能は「ZOZOTOWN利用者が欲しい商品を見つける」ための重要な機能でかつ、リクエスト数も膨大です。今回ご紹介したようにシステムの改修を柔軟に対応できる形へ切り出せた事で、今では更なる検索速度や精度を改善に取り組む環境が整いました。このような検索基盤を開発する経験は個人的にも非常に良い経験でした。 おわりに ZOZOテクノロジーズでは、検索機能を開発・改善していきたいエンジニアを全国から募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co ユニットテストのガイドラインを作成いただいた木目沢さん、ありがとうございました! ↩
アバター
ZOZO研究所 の清水です。弊社の 社会人ドクター制度 を活用しながら、「社内外に蓄積されているデータからビジネスへの活用が可能な知見を獲得するための技術」の研究開発に取り組んでいます。 弊社の社会人ドクター制度に関しては、以下の記事をご覧ください。 technote.zozo.com 私が現在取り組んでいるテーマの1つに、「機械学習が導き出した意思決定の理由の可視化」があります。この分野は「Explainable Artificial Intelligence(XAI)」と呼ばれ、近年注目を集めています。 図.XAIに関連する文献数の推移(引用: https://arxiv.org/abs/1910.10045 ) その中でも今回はユーザに対するアイテムの推薦問題に焦点を当て、「なぜこのユーザに対して、このアイテムが推薦されたのか?」という推薦理由の可視化が可能なモデルを紹介します。 本記事の概要 機械学習から得られた意思決定の理由を明確にすることの必要性が増している 「XAI」と呼ばれる研究領域が注目されている Attentionを用いて推薦理由を可視化・解釈可能な、Knowledge Graph Attention Networkという手法を紹介する ZOZOTOWNに蓄積されているデータにKnowledge Graph Attention Networkを適用してみた結果の一部を紹介する 目次 本記事の概要 目次 背景 機械学習が抱える解釈性の課題 Explainable Artificial Intelligence Explainable Recommendation model-agnostic approach model-intrinsic approach Attentionを用いた意思決定の理由の解釈 Knowledge Graph Attention Network 概要 モデル構造と学習 CKG Embedding Layer Attentive Embedding Propagation Layer Prediction Layer 学習の仕組み(まとめ) 入出力 損失関数 実験 実験条件 推薦理由の可視化の例 推薦精度に関する評価 関連手法 終わりに 背景 機械学習が抱える解釈性の課題 近年、機械学習が人間の意思決定を支援したり、代替するような場面が徐々に増えてきています。弊社のサービスにおいても、例えばZOZOTOWNでユーザに推薦するアイテムの選定をする場面など、あらゆる場面で機械学習が活用されています。 機械学習によるアイテムの推薦では、蓄積された過去の購買履歴データなどの情報から、「このユーザにこのアイテムを推薦すべき」という情報を得ます。そして、得られた情報に基づいて、アイテムの推薦が行われています。以下では、この一連を仕組みを「推薦システム」と呼びます。なお、推薦システムの内部で利用されている技術に関しては、過去の記事で紹介しているので、併せてご覧ください。 techblog.zozo.com しかし、この推薦システムを含む、機械学習を用いた意思決定システムは「なぜそのような決定に至ったのか」という意思決定の理由については不明瞭である場合が多いです。不明瞭なままでは良くないとされる場面の分かりやすい具体例としては、自動運転の技術を搭載した自動車に関するものが挙げられます。自動運転の技術を搭載した自動車が事故を起こした場合、事故を起こした際の意思決定の理由が解釈できなければ、改善点を考察することや責任の所在を明らかにすることは困難です。そして、この問題が自動運転の技術を実用化する上での1つのハードルとなっていることは間違いないでしょう。 なお、このような機械学習が抱える解釈性の課題から、消費者庁が発表した AI利活用ガイドライン における「AI利活用原則(AI利用者が留意すべき事項)」には「透明性の原則」が含まれています。 Explainable Artificial Intelligence この問題に対して、「なぜそのような決定に至ったのか」を説明するための研究分野が存在します。この分野はExplainable Artificial Intelligence(XAI)と呼ばれ、特に中身が複雑なディープラーニング技術の実応用が話題となっている現代において注目を集めています。 下図はディープラーニングやアンサンブル学習を用いるような精度の高いモデルほど、モデルの解釈性は低くなるというトレードオフの関係を表しています。 図.モデルの解釈性と精度のトレードオフについて記述した図(引用: https://arxiv.org/abs/1910.10045 ) 図における「高い精度であるが解釈性は低い」と位置付けされているモデルが、近年多くの分野で大きな成果を発揮しています。このことを背景とし、モデルを解釈しようとする当分野も注目を集めています。 この分野の研究に関する情報は、以下の資料にまとまっています。 私のブックマーク「説明可能AI」(Explainable AI) Explainable Artificial Intelligence (XAI): Concepts, Taxonomies, Opportunities and Challenges toward Responsible AI Interpretable Machine Learning 図.XAIのイメージ(引用: https://www.darpa.mil/program/explainable-artificial-intelligence ) Explainable Recommendation 説明可能な推薦システムに関する研究も多数発表されており、 Explainable Recommendation: A Survey and New Perspectives にはそれらが体系的にまとまっています。対象問題が推薦システムであるため、機械学習によって「なぜこのユーザに、このアイテムが推薦されたのか」という推薦理由を把握することを目的としています。 前述の文献 などにおいてEplainable Recommendationは、説得力・有効性・ユーザ満足度などを向上させるのに役立つとされています。そして、実際に様々な企業からこの分野に関連する研究成果が発表されています。 以下はその一例です。 Google Transparent, Scrutable and Explainable User Models for Personalized Recommendation Spotify Explore, Exploit, Explain: Personalizing Explainable Recommendations with Bandits また、以下の事例が実際にサービス化されている分かりやすい例です。 Facebook(app) Why Am I Seeing This? We Have an Answer for You このEplainable Recommendationのアプローチの仕方は、大きく分けて以下の2種類です。 model-agnostic approach(=post-hoc approach) 推薦モデルとは別に推薦理由を解釈(説明)するためのモデルを学習する方法 model-intrinsic approach 何らかの工夫により、予め解釈可能な推薦モデルを学習する方法 model-agnostic approach model-agnostic approachでは、まず推薦モデルを学習させ、その次に推薦の理由を説明するためのモデルの学習を別途行います。 このアプローチでは、事後の学習によって推薦理由の解釈を得るため、推薦モデルから直接理由が得られている訳ではありません。故に「本当に意思決定用のモデルを正確に説明できている(理由が正確に表現できている)」という保証はありません。また、そこに対して様々な工夫もされていますが、今回は詳しく扱いません。 しかし、この方法を用いると意思決定モデル自体を、どれだけ複雑にしても問題にならないというメリットがあります。 また、人間の意思決定メカニズムは以下のステップで行われる場合もあります。 まず直感的な意思決定を行う その意思決定に対して、後から理由付けを行う この意思決定のパターンを再現しているという意味では面白いアプローチです。具体的には、「とあるアイテムに一目惚れして購入を決意した後に、なぜこのアイテムが気に入ったのかを後から考える」ような流れを再現していると言えます。 このアプローチの関連研究として以下のものが挙げられます。 Explanation Mining: Post Hoc Interpretability of Latent Factor Models for Recommendation Systems Posthoc interpretability of learning to rank models using secondary training data EXS: Explainable search using local model agnostic interpretability A Reinforcement Learning Framework for Explainable Recommendation Incorporating interpretability into latent factor models via fast influence analysis Explore, exploit, and explain: personalizing explainable recommendations with bandits model-intrinsic approach 前述のmodel-agnostic approachと比較し、model-intrinsic approachは、意思決定の理由を推薦モデルから直接獲得できる点が異なります。 こちらのアプローチでは、最初から合理的な理由に基づいて意思決定を行うような状態の再現を目標としています。具体的には、どのポイント(ブランドや値段など)をどのくらい重要視するのかなどを考慮しながら、最終的にそのアイテムを購入するかを決定する流れを再現しています。この状況において、他者から「なぜそれにしたの?」と質問された場合に回答する理由は、後付けしたものではなく、購入に至った正確な理由であるはずです。 こちらのアプローチの難しい点としては、このモデルの出力が直接推薦に活用されるため、説明可能性を担保しながらも高い推薦精度を実現する必要があることです。また、闇雲に活用したい全ての補助情報をモデルに学習させることは推薦モデル自体の精度の低下や計算時間の増加を招くため、どの補助情報を活用するかについても精査する必要があります。 なお、関連研究を本記事の末尾にいくつか挙げているので、興味のある方はそちらをご覧ください。 Attentionを用いた意思決定の理由の解釈 model-intrinsic approachの中でも、近年注目されているAttentionなどを用いることで高い推薦精度を保ちながら、意思決定の理由を直接的に解釈可能とする方法があります。この方法を用いることで、分析者は「なぜこのユーザにこのアイテムを推薦したか」という理由を、いくつかの要素とその寄与の大きさに分けて把握することが可能になります。「いくつかの要素」の部分は入力データとした情報に含まれる要素(ユーザ・アイテム・それらの補助情報など)となり、「寄与の大きさ」の部分はAttentionで表現します。 この類の手法は近年脚光を浴びており、多くの手法が提案されています。その中でも今回は Knowledge Graph Attention Network (KGAT)を次章で紹介します。KGATはAttentionを用いて、どの繋がりを重視するかを考慮しながら、グラフ構造のデータを学習する Graph Attention Networks (GATs)をベースとした推薦モデルの一種です。 図.グラフ構造のイメージ Knowledge Graph Attention Network 概要 Wangら は、ユーザとアイテムの2部グラフと、アイテムとアイテムの補助情報からなる知識グラフを使ったGATsベースの推薦モデルを提案しました。アイテムの補助情報とは、ZOZOTOWNのデータで言えば、ショップやブランドなどのアイテムに付随する情報です。 このモデルはGraph Neural Networksと呼ばれるモデル群において、関係性の学習にAttentionを採用したGATsに、補助情報(知識グラフ)を取り入れたモデルとして位置付けられます。ディープラーニングにより特徴量を自動的に獲得するend-to-end方式の学習を実現することで、複雑な顧客の嗜好やネットワーク構造の学習を可能にしています。 また、補助情報を用いることで、購買履歴データが十分に蓄積されていないユーザに対しても精度の高い推薦を実現します。さらに、得られたAttentionを分析することで推薦の根拠を示すことができるため、このモデルはXAIの分野においてはmodel-intrinsic approachに位置付けられます。 構築された推薦システムの出力の根拠を人間が解釈可能な形で示してくれるため、実際のマーケティングにおけるデータに応用することで強力な成果を発揮することが期待できます。 モデル構造と学習 KGATは3つのレイヤーを通して学習を遂行します。 それぞれのレイヤーの概要を以下で解説します。 CKG Embedding Layer Attentive Embedding Propagation Layer Prediction Layer 本記事では、より多くの方に概要だけでも理解していただけるよう、数式を記載せずに言葉で解説をしていきます。それが逆に分かりにくい方は、 原著の論文 と照らし合わせながら読んでいただけると幸いです。 KGATのモデルの全体像は以下の図の通りです。 図.KGATの構造のイメージ (引用: https://arxiv.org/abs/1905.07854 ) CKG Embedding Layer ユーザとアイテムの2部グラフと、アイテムやユーザの補助情報からなる知識グラフを併せた「協調知識グラフ」の構造を保持した(各ノード・エッジに対する)埋め込み表現を獲得します。 学習の際は、 TransR というグラフ埋め込みの手法を用いて各Tripletをベクトル化し、グラフ上に存在するTripletと存在しないTripletの差を最大化するようにパラメータを更新します。なお、Tripletとは、先頭ノード・ エッジ・末尾ノードの3点セットのことを指します。 図.Tripletの例 これにより、「ユーザ」と「ユーザが購入したアイテム」、「ユーザ」と「ユーザの補助情報」、「アイテム」と「アイテムの補助情報」のノード同士が埋め込み空間上で近くに配置されるように埋め込み表現が学習されていきます。 図.CKG Embedding Layerのイメージ Attentive Embedding Propagation Layer 各ノードやエッジに対する埋め込み表現をもとに、各Tripletに対して重要度を算出します。そして、この重要度をもとにどの関係性を重視するかを考慮しながら、Prediction Layerにおいて購買確率の算出に利用するための埋め込み表現を、各アイテムとユーザに対して算出します。 この層は、 層の構造を有しており、 次近傍( 個先の隣接したノード)までの関係性を考慮可能です。周辺ノードの埋め込み表現を各Tripletの重要度で重み付けした平均値を算出し、周辺ノードの特徴を集約することで新しい埋め込み表現を獲得します。そして、このレイヤーから得られた各アイテムとユーザに対する新たな埋め込み表現を、次のPrediction Layerにおいて購買確率の算出に利用します。 つまり、推薦において重要と判断される 次近傍の関係性に従って、より洗練された、各ユーザとアイテムに関する購買確率の計算に用いるための埋め込み表現を獲得します。 図.Attentive Embedding Propagation Layerのイメージ Prediction Layer Attentive Embedding Propagation Layerから得られた各ユーザと各アイテムに対する埋め込み表現をもとに、各ユーザとアイテムのペアについて、購買確率を算出します。そして、実際に購買が発生したユーザとアイテムのペアに対して計算されるスコア(購買確率)と、発生していないペアに対して計算されるスコアの差が大きくなるように学習を遂行します。 図.Prediction Layerのイメージ 学習の仕組み(まとめ) 長めの説明となってしまいましたが、学習の仕組みを簡単にまとめると、以下のようになります。 入出力 入力 購買履歴データと、アイテムやユーザの補助情報を併せたデータ 出力 各ユーザが各アイテムを購入する確率 損失関数 Pairwise Ranking損失 埋め込み空間上におけるノードの位置関係が入力データの構造に則っているかに関する損失 Bayesian Personalized Ranking損失 埋め込み表現などから算出した購買確率がユーザの行動を再現できているかに関する損失 上記の双方を考慮して学習を遂行。 実験 ここまで紹介してきたKGATを、ZOZOTOWNに蓄積されている購買履歴データと各ユーザとアイテムの補助情報に適用し、得られた結果を用いて実際に推薦理由の可視化を行ってみた例を紹介します。さらに、推薦精度の評価実験を行い、KGATが推薦精度の面でどの程度有効であるのかを確認した結果を紹介します。 実験条件 今回の実験では、2020年2月〜2021年1月の1年間の購買回数が5回以上60回未満のユーザからランダムにサンプリングを行い、抽出された購買履歴データを利用します。 補助情報にはアイテムのブランド・ショップ・カテゴリ・価格帯、ユーザの年代・性別・お気に入りブランドやショップなどの全17種類のデータを用いました。購買履歴データは約30万件、補助情報は約70万件です。 こちらの文献 の実験条件を参考に、各グラフの埋め込み表現の次元数は64、Attentive Embedding Propagation Layerは[64・32・16]次元の3層としました。また、確認する精度指標も同様の決め方で、TopN精度(Recall・NDCG)としています。 比較手法は補助情報を用いない手法のベースラインとしてBPRMF、同じく補助情報を含んだグラフ構造を学習する手法であるCKEとCFKGとしています。 BPRMF Bayesian Personalize Ranking Matrix Factorization CKE Collaborative Knowledge base Embedding CFKG Collaborative Filtering over Knowledge Graph 推薦理由の可視化の例 まず、記事の前半でメイントピックとして解説してきた「推薦理由の可視化」についてです。KGATの出力結果をそのまま用いることで、各ユーザに対して「なぜそのアイテムが推薦されたか」を容易に説明できます。今回はどのような形で説明可能になったのかを紹介するために1つだけ例を紹介します。 以下の図は、実際に得られた結果の中から抽出してきた例で、「ユーザ に対してアイテム が推薦されている」状況を表現しています。ユーザ から、実際に推薦されたアイテム までのノードとエッジを辿り、それらの重要度を確認することで推薦理由を把握できます。 図.推薦理由の可視化マップの例 このグラフを見ると、ユーザ に対してアイテム が推薦された理由は、以下の点であることが分かります。 同ブランド のアイテム を過去に購入していること ブランド をお気に入り登録していること アイテム と同じショップ のアイテムであること アイテム と同じタイプ のアイテムであること また、重要度(エッジ上に記載されている数値)を確認することで、それぞれがどの程度推薦に寄与しているのかを定量的に把握できます。 この結果を活用し、ユーザ にアイテム を推薦する際、アイテム がブランド のアイテムであることを強調するなどの施策が容易に考えられます。また、推薦するアイテムと併せて、単純に推薦理由とスコアを並べて表示する施策も考えられます。これにより、ユーザは「だからこのアイテムを良いと感じるのか」「だからあまり良いと思わないアイテムが推薦されたのか」のように、納得感を持って買い物を楽しめるかもしれません。 今回は結果の活用事例として推薦理由の可視化のみを紹介しました。しかし、実際には他にも各ノード(アイテム・ユーザ・補助情報)やエッジに対して埋め込み表現が得られているので、これらを分析することも施策立案の一助となります。実際に適用して得られた結果を多角的に分析した結果、改めてとても汎用性の高いモデルだなと感じています。 推薦精度に関する評価 以下に示す結果の表を見ると、KGATは同様の補助情報を用いる他の手法と比較して、精度面でも有効なモデルであることが分かります。特にBPRMFよりも精度が高いことから、補助情報を活用することの有効性を示唆しています。 また、CKEやCFKGよりも精度が高いことから、KGATが上述した学習アルゴリズムを通して補助情報を含んだグラフ構造を上手に学習できていることが考えられます。 表.評価実験の結果比較 また、下図より、他の手法と比較して学習データ内にまだ多くの購買履歴データが蓄積されていないユーザ群に対しても、ある程度頑健な推薦ができていることが分かります。つまり、コールドスタート問題にも対応できていると言えます。 図.学習データに含まれる購買履歴数で層別した各ユーザ群に対する推薦の精度 推薦精度に関する評価実験の結果をまとめると、以下のことが分かります。 KGATが他の類似したモデルと比較して、高い推薦精度が期待できるモデルであること 過去にZOZOTOWNであまり商品を購入していないユーザに対しても、ユーザやアイテムの補助情報を上手に学習し、効果的な推薦ができていること 関連手法 XAIの文脈でKGATを紹介しましたが、Attentionを活用した解釈可能な推薦システムの研究としては以下のものも挙げられます。 Interpretable Convolutional Neural Networks with Dual Local and Global Attention for Review Rating Prediction A Context-Aware User-Item Representation Learning for Item Recommendation Neural attentional rating regression with review-level explanations Sequential Recommendation with User Memory Networks また、知識グラフの学習をベースとする解釈可能な推薦システムの研究としては、以下などが挙げられます。 Explainable entity-based recommendations with knowledge graphs Improving sequential recommendation with knowledge-enhanced memory networks Learning Heterogeneous Knowledge Base Embeddings for Explainable Recommendation Reinforcement knowledge graph reasoning for explainable recommendation 終わりに 今回はXAIの文脈で、KGATの紹介をしました。この分野は現在非常にアツく、様々な方法が提案されているので、将来的にはそれらを網羅的に紹介する記事も執筆したいと思います。最後までお読みいただき、ありがとうございました! ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター