TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

988

はじめに こんにちは、ZOZOTOWN開発本部でZOZOTOWN iOSアプリを開発している松井です。 ZOZOでは、お客様がファッションを心から楽しむことができるよう、日々さまざまな新規案件に取り組んでいます。最近では、ZOZOTOWNの全体的な大幅リニューアルや、 ZOZOGLASS のリリースなどが記憶に新しいのではないでしょうか。まだ試していない方はぜひ使ってみてください! 新規の開発案件だけではなく、お客様が使いやすいアプリをリリースし続けていくために取り組むべきことがあります。それは既存のアプリを見直し、お客様の声に耳を傾けたり、数値を見たりしてより良くしていく活動です。 その活動の一環として、毎週行われるアプリ部の定例で誰でも改善提案をできる時間がとられています。提案書のフォーマットも用意されており、社員の意見を積極的に取り入れようとする仕組みが整っています。そのため、社員の意見をスピーディに上へあげることが可能です。多くの社員がZOZOTOWNの改善に取り組んでいる中で、iOSチームでも「改善できるところを見つけ、提案・開発・計測をしていくプロジェクト」を実践しています。 本記事では、そのプロジェクトにおいて限られたリソースの中でどのように課題を見極め改善提案を行っているか、その工夫点と成果をお伝えします。 ボトムアップで改善提案を行う理由 いきなりですが、エンジニアが売上貢献するにはどんな道があるでしょうか。まず最初に思いつくのは、会社の企画部署や上層部が意思決定した新規開発をガンガン進めるという道です。しかし、それ以外にもエンジニアが売上貢献できる道はありそうです。 大きなインパクトを与える新規案件の開発だけに集中してしまうと、お客様がアプリを使っていて感じる不満や改善点が取りこぼされていきます。しかし、iOSチームという小さなスコープで取り組むことによって、そうした小さな部分にも目を向けることができます。わたしたちは数値データだけでなく、お客様からの問い合わせの声にもひとつひとつ目を通しています。そこから課題を見つけ、改善提案をすることでお客様により良い体験を提供するよう努めています。そのような小さな改善の積み上げによってお客様の体験を向上させることは、ZOZOTOWNに愛着を持って長く使い続けてもらうことに繋がり、結果として売上にも貢献すると考えています。 小さな改善への取り組み方 実際に、上記の流れで取り組んでいます。 課題を明確にし、原因を分析、データを元に仮説を立てて、提案する。 特に目新しいことはしていないことに気付くでしょう。流れとしては一般的ですが、その中でも「課題の深掘り」と「調査結果を元に仮説を立てる」に特に注力しています。 課題の深掘り 課題が発見されたら、すぐに解決策のアイデアを出したくなります。本記事に興味のある読者の方は、特に改善というキーワードに感度が高く、賛同いただける方も多いでしょう。普段アプリを使っていて「ここ改善したいな」と思うところは容易にいくつも思いつくのではないでしょうか。わたしたちもそうでした。しかし、それを片っ端から改善していく訳にもいきません。わたしたちは「改善提案専門部隊」ではありません。他の案件も並行で進めながら、サービスに寄与する効果をあげる必要があります。課題を思いついたときに、それが本質的な課題なのか深掘ることで、限られたリソースで最大の効果を発揮できる選択が可能になります。 1つ具体例を交えてお話します。「お気に入り画面にカートボタンを置く」という改善提案についてです。なお、この施策は既にリリースされ、数値の改善も見られたため、詳細は事例紹介として後述します。 改善前は、お気に入り画面で、特定の商品の値段部分をタップするとその商品をカートに投入できました。しかし、「値段をタップしたら商品がカートに投入される」という挙動は、お客様が想定することは難しいでしょう。多くのお客様は、その挙動に気付くことができません。「お気に入り画面から直接カートに入れる機能が気付きにくい」という課題が出てきたことで、「じゃあ、気付かれるようにしよう!」というアプローチのアイデアを出したくなります。しかし、そのアプローチは本当に正しいのでしょうか。もしかしたら、そのアプローチが間違っていることもあるでしょう。実はこの機能自体に需要がなく、そもそも無くしても良いものかもしれません。その場合、気付かれるようにしたところでユーザー体験は向上しません。改善案のリリース後、確度高く結果を得るためにも、実装前に一度思いついた課題を深掘ることを仕組み化することが重要です。 そこで、「そもそもこの機能に気付いて使っているお客様はどれくらいいるのか」「機能自体、本当に必要なのか」「どの画面からのカート投入率が高いのか」を、Google Analyticsを使って集計しました。必要な数値が収集できていない場合は、A/Bテストを実施するのもひとつの手です。その結果、想定よりも使っているお客様が多く、必要な機能だということがわかりました。しかし、そもそも気付きにくいという課題は依然として残るので、「わかりやすくする」ことで必要なお客様にもっと使っていただけるであろうという仮説のもと、「カートに入れる」と明記する提案をしました。 なお、冒頭で「改善提案をすることで売上貢献する」という話をしました。「この改善は他の画面からのカート投入率を奪うものであって、本質的にはカート投入率が増える改善ではないのでは」と思った方もいるかもしれません。この点は、ユーザビリティを良くしていくことにより、「ZOZOTOWNは使いやすいからまた使おう」と思ってもらえる機会を増やしていき、結果として売上拡大に貢献できるような仕組みです。 調査結果を元に仮説を立てる 「仮説を立てる」ことにこだわることで、説得力が高まり、アイデアが案件として採用されやすくなります。また、仮説を立てられるかどうかによって、取り組むべき課題のフィルター効果が期待できます。仮説を立てようとすると、その仮説を担保するだけの根拠が必要になります。そのため、提案に説得力が生まれ、仮説を検証するための有効な課題解決策がおのずと湧いてきます。逆に仮説が立てられないのであれば、設定している課題が間違っている可能性があります。その場合は、課題の深掘りサイクルに戻り、本質的な課題は何であったかを見直します。改善提案だけに時間を割ける環境ではないことがほとんどだと思うので、少ない時間で最大限の改善をするためにも、「仮説を立てることができるか」という視点を大事にしています。 チーム内レビュー 提案書を作成したあとに、iOSチーム内でレビューの時間を必ず設けています。この段階で根拠が足りていなかった点や他に解決できそうなアイデアがないか、など意見をもらっています。上に提案を持っていく前に複数人の目を通すことで、新たな気付きを得られることも多く、より筋の通った改善提案にブラッシュアップできます。 取り組みの流れは以上です。エンジニアからも売上に貢献することは充分に可能ですし、エンジニアの視点だからこそ気付く部分や、出てくるアイデアもあります。改善提案を行う上で、少しでも参考になる部分があれば幸いです。 事例紹介 さいごに、上記の取り組みを全て改善提案のフローに適用し、リリースまで行った案件をご紹介します。他にもいくつもの提案がありますが、本記事では2つ具体例として挙げます。 お気に入り画面にカートボタンを置くことでカート投入率が向上 カートボタンの設置に伴い、値段部分のタップでカートに入れられる導線は削除しています。お気に入り画面からのカート投入率の計測によって、検証をしました。その結果、リリース直後から数値が向上したことから、この機能の存在に気付き、使っていただけるお客様が増えたと言えます。 ホーム画面のモジュールを2段表示にすることで商品詳細画面への遷移率が向上 ホーム画面から商品詳細画面への遷移率を向上させるための改善です。 商品詳細画面への遷移率を上げるということは、お客様に商品のお気に入りや、カートへ入れてもらう機会を増やすことに直結します。例えば実店舗だとしても、まず服を手にとって見てもらうことが大事になってきますよね。こちらはA/Bテストでの検証の結果、正式にリリースすることとなりました。Androidアプリでは先行して既にリリースされており、iOSアプリも現在開発中です。 # おわりに 本記事では、iOSエンジニアが主体的に行っている改善提案のフローをご紹介しました。こうした改善にも興味あるよという方は、ぜひ面談してみませんか。ZOZOでは、これからもこうした活動を通してお客様により良い体験を提供し、売上の最大化を目指すことでサービスにとってプラスとなるよう努めていきます。 ZOZOではiOSエンジニアを大募集中ですのでご興味のある方はこちらからご応募ください。 hrmos.co
はじめに こんにちは、SRE部 ECプラットフォーム基盤SREブロックの亀井です。 ZOZOTOWNのマイクロサービスプラットフォーム基盤(以下、プラットフォーム基盤)ではサービス間通信におけるトラフィック制御・カナリアリリース実装のため、 Istio によるサービスメッシュを導入しました。現在は初期段階としてBFF機能を司るZOZO Aggregation APIとその通信先サービス間へ部分的に導入しています。 ZOZO Aggregation APIについては、以前に三神が紹介しているので、そちらの記事をご参照ください。 techblog.zozo.com その後、Istioによる一貫したトラフィック制御・カナリアリリース実装を目的とし、プラットフォーム基盤全体へサービスメッシュを拡大しました。本記事ではその取り組みを紹介します。 なお、本記事はプロダクション運用中サービスのサービスメッシュ移行という運用目線の内容です。Istioの概要や選定理由などサービスメッシュ導入の背景にご興味がある方は、以前川崎が執筆した記事をご参照ください。 techblog.zozo.com はじめに サービスメッシュ導入後の課題 プロダクション運用中サービスのサービスメッシュ化方針 ZOZO API GatewayとIstioの責務整理と機能分担 段階的な移行 ZOZO API Gatewayサービスメッシュ化における考慮点 ZOZOTOWNへの導入効果 今後の課題 k8sクラスタを跨ぐIstioサービスメッシュの拡大 カナリアリリースの自動化 さいごに サービスメッシュ導入後の課題 ZOZO Aggregation APIと通信先サービスが部分的にサービスメッシュ化された状態を下図に示します。 「ZOZO Aggregation API → サービス」間はサービスメッシュ化され、Istioによるトラフィック制御・カナリアリリースが実装されました。しかし、プラットフォーム基盤全体ではサービスメッシュの導入は部分的であり、下図の様にサービスによってトラフィック制御・カナリアリリース手法に差異が生まれていました。サービスによって「設定が異なる」または「複数の設定を持つ」状態となっており、運用負荷が高く、二重にリトライが行われるなどの設定不備によるミスが起きやすい状況にありました。 この状況の解消に向け、プラットフォーム基盤全体へサービスメッシュを拡大し、Istioによる一貫したトラフィック制御・カナリアリリース実装の展開を進めました。 プロダクション運用中サービスのサービスメッシュ化方針 プロダクション運用中サービスのサービスメッシュ化では、大きく以下の2点を実施しました。 ZOZO API GatewayとIstioの責務整理と機能分担 段階的な移行 以降で、具体的な内容を順に説明していきます。 ZOZO API GatewayとIstioの責務整理と機能分担 ZOZOTOWNは ストラングラーパターン でレガシシステムの段階的なリプレイスを行っています。ZOZO API Gatewayは、この中でストラングラーファサードという役割を担っており、ルーティングや認証、トラフィック制御などの機能を持つリバースプロキシとして動作しています。なお、ZOZO API Gatewayは、独自要件に対し柔軟に対応出来るよう独自実装しています。 詳細は、旗野の記事をご参照ください。 techblog.zozo.com 一方、 Istio はトラフィック制御、セキュリティ、可観測性の機能を持ちます。つまり、ZOZO API GatewayとIstioでタイムアウト・リトライなどのトラフィック制御機能が重複しています。そこで、ZOZO API GatewayとIstioの責務を明確にし、重複する機能を分担する必要がありました。 まず、下図の様に責務を整理しました。 (画像が小さい場合は 拡大 してご覧ください) そして、下図の様に機能を分担しました。 このような責務整理と機能分担の結果、プラットフォーム基盤全体に対し、サービスメッシュの拡大を滞りなく進める事が出来ました。 段階的な移行 ZOZOTOWNを停止させずにサービスメッシュへ移行するため、下記の様に段階的な移行方針を取りました。 優先度の高いサービスから段階的にサービスメッシュ化 ZOZO API Gateway その他マイクロサービス 無停止を前提としたサービス単位でカナリアリリース 一斉にプラットフォーム基盤全体をサービスメッシュ化せず、優先度の高いサービスから下図の様に10%、100%とカナリアリリースし、無停止で移行しました。 ZOZO API Gatewayサービスメッシュ化における考慮点 ZOZO API Gatewayは責務整理と機能分担の他にも考慮した点があります。みなさまの参考になるであろう、大きな考慮点なので、その内容をご紹介します。 「ALB → ZOZO API Gateway」のトラフィックはサービスメッシュ外から中への通信(Ingress Traffic)です。Ingress Trafficにおいても、サービスメッシュ間のトラフィック同様にIstioによる一貫したトラフィック制御が求められていました。 そこで、 IngressGateway を使う事で上記の課題を解決しました。サービスメッシュの境界にIngressGateway(実態はistio-proxy)を追加する事で、Ingress TrafficもIstioによるトラフィック制御が可能となります。 なお、k8sマニフェストは下記の通りです。 IstioOperator にてIngressGatewayコンポーネントを作成し、ZOZO API Gateway用のIstioカスタムリソースを設定します。 apiVersion : install.istio.io/v1alpha1 kind : IstioOperator metadata : namespace : istio-system name : istio-control-plane spec : components : ingressGateways : # IngressGatewayコンポーネントを追加 - name : ingressgateway --- apiVersion : networking.istio.io/v1alpha3 kind : Gateway metadata : name : gateway spec : selector : istio : ingressgateway servers : - hosts : - zozo-api-gateway.example.com port : name : http number : 80 protocol : HTTP --- apiVersion : networking.istio.io/v1alpha3 kind : VirtualService metadata : name : virtualservice spec : gateways : - gateway hosts : - zozo-api-gateway.example.com http : - route : - destination : host : zozo-api-gateway.ns.svc.cluster.local subset : primary weight : 100 - destination : host : zozo-api-gateway.ns.svc.cluster.local subset : canary weight : 0 timeout : 10s --- apiVersion : networking.istio.io/v1alpha3 kind : DestinationRule metadata : name : destinationrule spec : host : zozo-api-gateway.ns.svc.cluster.local subsets : - name : primary labels : version : primary - name : canary labels : version : canary Amazon EKS上にIngressGatewayをデプロイすると、デフォルトではClassic Load Balancer(CLB)が作成され、サービスが外部に公開されます。しかし、ZOZOTOWNではセキュリティ要件により、AWS WAFのアタッチされたApplication Load Balancer(ALB)を使っています。そのため、サービスメッシュ化も同様のセキュリティレベルを保つため、下図の様にIngressGatewayはCLBで公開せず、既存のALB配下で公開する構成にしました。 そして、CLBはセキュリティホールとなり得るため、削除しています。下記の様にIstioOperatorのIngressGatewayコンポーネントを設定する事でCLBを作成しない事が可能です。 apiVersion : install.istio.io/v1alpha1 kind : IstioOperator metadata : namespace : istio-system name : istio-control-plane spec : components : ingressGateways : - name : ingressgateway k8s : service : type : NodePort # CLBを作成しない ZOZOTOWNへの導入効果 ZOZO API GatewayとIstioの責務整理と機能分担を行い、サービス単位での段階的な移行をしました。その結果、ZOZOTOWNを停止することなく、下図の様にプラットフォーム基盤全体をサービスメッシュ化することが出来ました。 そして、プラットフォーム基盤全体がサービスメッシュ化された事で下記の様な事が可能となっています。 一貫したトラフィック制御 カナリアリリース手法の統一 基盤全体でのIstio活用 2つ目に挙げた「カナリアリリース手法の統一」は、ZOZO API Gatewayの場合、下図の様に変更されIstioによる加重ルーティングを用いてカナリアリリースが可能になりました。 次に、Istio Virtual Service、Destination Ruleリソースのマニフェスト設定例を紹介します。 まず、Destination Ruleでsubsetにprimary、canaryを登録します。合わせて、Virtual Serviceのroute部分に先程のsubsetを指定し宛先を登録します。そして、 weight を更新してクラスタに適応すると、istiodにより自動的にistio-proxyのconfigが更新され、ZOZO API Gatewayへのトラフィック加重率が変更されます。 apiVersion : networking.istio.io/v1alpha3 kind : VirtualService metadata : name : virtualservice spec : hosts : - zozo-api-gateway.example.com gateways : - ingressgateway http : - route : - destination : host : zozo-api-gateway.ns.svc.cluster.local subset : primary weight : 90 - destination : host : zozo-api-gateway.ns.svc.cluster.local subset : canary weight : 10 retries : attempts : 1 perTryTimeout : 3s retryOn : 5xx timeout : 6s --- apiVersion : networking.istio.io/v1alpha3 kind : DestinationRule metadata : name : destinationrule spec : host : zozo-api-gateway.ns.svc.cluster.local subsets : - name : primary labels : version : zozo-api-gateway - name : canary labels : version : zozo-api-gateway-canary 以上の流れで、プラットフォーム基盤全体のカナリアリリース手法が上記に統一されました。 また、基盤全体でIstioの活用も行っており、直近ではサーキットブレーカーを導入しマイクロサービスの連鎖障害に備える取り組みを行いました。詳細は大澤の記事で解説しているので、併せてご参照ください。 techblog.zozo.com 今後の課題 さらなる改善のため、大きく下記2つの課題に取り組んでいく予定です。 k8sクラスタを跨ぐIstioサービスメッシュの拡大 カナリアリリースの自動化 k8sクラスタを跨ぐIstioサービスメッシュの拡大 ECプラットフォーム基盤SREブロックでは、認証サービス基盤というもう1つの基盤・k8sクラスタが存在します。個人情報などのセキュリティ要件の高い情報を取り扱うサービスが稼働する基盤です。プラットフォーム基盤から認証サービス基盤間の通信は現状サービスメッシュ化出来ておらず、下図の様にZOZO API Gatewayによるトラフィック制御が行われています。 k8sクラスタを跨ぐサービスメッシュの構築を今後の課題としています。 カナリアリリースの自動化 プラットフォーム基盤全体のサービスメッシュ化により、障害を軽減し無停止で進行するカナリアリリース手法が統一されました。しかし、カナリアリリースの進行における判断コストや加重ルーティングを進行、もしくは切り戻す設定変更コストは依然高い状況にあります。一方、カナリアリリース手法が統一されたことで、判断の自動化・設定変更の自動化がしやすくなりました。そこで、Progressive Deliveryの導入など更なるリリーススピードの向上、運用負荷の削減も今後の課題としています。 さいごに ZOZOでは、一緒にサービスを作り上げてくれるSREエンジニアを募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 https://hrmos.co/pages/zozo/jobs/0000010 hrmos.co
こんにちは、ZOZOTOWN開発本部の名取( @ahiru___z )と計測プラットフォーム開発本部の寺田( @tama_Ud )です。先日、9/17から9/19までの3日間に渡って iOSDC Japan 2021 が開催されました。例年通り素晴らしい発表が盛り沢山でしたね! iosdc.jp 昨年に引き続きオンライン開催となりましたが、Discordを使ったAsk the Speakerやニコニコ動画の弾幕などリアルタイム性のあるコミュニケーション手段が今年も充実しており非常に楽しく学びの多い3日間となりました。今年はアンカンファレンスという新しい取り組みもあり、例年以上に楽しいイベントだったと実感しています。 弊社は今年もスポンサーとして協賛し、7名のエンジニアがスピーカーとして登壇、2名のエンジニアが原稿を寄稿いたしました。本記事ではiOSDC Japan 2021で登壇・寄稿した弊社エンジニアの発表内容を、登壇者・寄稿者のコメントを添えてご紹介します。 登壇内容の紹介 「A Swift Stack Overflow」 @kapsy1312 のレギュラートークです。 スタックメモリの基本とスタックオーバーフロー現象に関する解説です。特に、 Swiftのスタックオーバーフローとその回避仕方を紹介しています。 スタックメモリは自動的に管理され、Swiftは主にヒープに割り当てられたオブジェクトを使用するため、ほとんどのプログラマはスタックメモリに関する深い知識が必要ではありません。しかし、C言語のコードと連携する場合には、スタックメモリの落とし穴がいくつか存在しているので、学ぶ価値があります。 登壇では、スタックとは何かを説明し、clangとswiftcが出力したスタックの簡単な例の紹介、Swiftでの悪いスタック使用例の紹介、スタックの問題をデバッグして回避する方法を解説しています。 fortee.jp 「iOSアプリ開発に入門して、いきなりUnity as a Libraryに挑戦してわかったこと。」 @i_kinopee のレギュラートークです。 Unity as a Libraryを活用し、3Dシミュレーションを組み込んだiOSアプリ開発に挑戦した際の内容です。 今回iOSアプリ開発自体も初挑戦であったため、学習方法や初心者でもUnity as a Libraryに挑戦可能であることをお伝えします。実際には、先人のおかげでUnity as a Libraryに関する日本語記事が充実していることもあり、大きな問題もなく実装を進められています。 ARを開発する際にも便利なUnity as a Library、ぜひ皆さんも試してみてはいかがでしょうか。 fortee.jp 「iOSではじめるWebAR 2021」 @ikkou のレギュラートークです。 昨年に引き続き、iOSにおけるWebARの最新動向を駆け足20分で紹介しました。 今年はARや関連するLiDARに触れるトークが去年よりも多かった印象ですが、WebでARというテーマは今年もまだまだニッチでした。だからこそ、日本国内のiOSエンジニアが多く集まるiOSDC Japanという場でそれを伝える意義があると思っていますし、実際に伝える場を持てて良かったです。 当日ご覧いただけなかった方は、是非スライドを覗いてWebにおけるARの現状を知ってもらえると幸いです。 fortee.jp 「未知のファイル形式をCodableで読み書きするのに役立つテクニック 『Apple Watchの文字盤ファイル』」 @banjun のレギュラートークです。 Apple Watchの文字盤ファイルを題材に、未知のファイルを解析してCodableとNSFileWrapperでMacアプリのビューアーを作っていき、そのなかで出てきた普通とは言えないCodableの対処テクニックを紹介しました。 Apple Watchユーザーではない人も聴きに来てくれたようです。Ask the Speakerでは普段使っている文字盤のタイプを教えてもらったりしましたが、やはり写真・インフォグラフ・Siriあたりが人気のようでした。Appleの言う多彩な文字盤の良さを見つけるためにも、他の文字盤を使っている人の話も聞いてみたいと思いました。 fortee.jp 「再現ができない?特定ができない?ZOZOTOWNアプリのトップクラッシュに立ち向かった話」 @chichilam86 のLTです。 メモリ不足によるクラッシュは直接の原因がログやスタックトレースに現れないので再現が容易でなく、特定困難です。そのため、リニューアル後のZOZOTOWNアプリでのトップクラッシュに対して、メモリ不足の仮説と原因の解析から検証までの流れを紹介しました。 同じ悩みをお持ちの方の参考になれば幸いです。 fortee.jp 「あなたの知らないSafariのExperimental Featuresの世界」 @ikkou のLTです。 わりとマニアックだと思っているSafariの設定、しかも普段使う分には触る必要のないExperimental Featuresについて5分でお伝えしました。 どちらかと言うとLT芸ではなく、ガチで時間いっぱいお伝えする内容でしたが、反応を伺っていると「知らなかった!」の声もあり、少なからず伝えたいことを届けられて良かったです。 fortee.jp 「作ってわかる!LiDARによるカメラの暗所オートフォーカス機能」 @tama_Ud のレギュラートークです。 2020年3月発売のiPadから搭載されたLiDARスキャナですが、AR領域で特に注目を浴びていますね。しかし、今回はARでなくLiDARのAF機能補助について焦点を当ててお話しています。 AF機能をLiDARを使って実装してみることで、LiDARを使ったアプリ開発への理解が深まれば幸いです。 fortee.jp 「SceneKitを使ってアプリのクオリティを劇的に上げる」 @ahiru___z のレギュラートークです。 UIKitだけでは実現が難しいリッチな表現を、SceneKitを使って実装する方法を紹介しました。 SceneKitは3Dコンテンツを扱うアプリ開発でのみ使用するFrameworkと思われがちですが、実際にはそんなことはありません。UIKitとの親和性は高く、使い方や概念を適切に理解することで非常に強力な武器となり得ます。私自身、個人開発でよく使用するFrameworkの1つでもあります。 興味を持った方、ぜひSceneKitを触ってみてください。 fortee.jp 「ほんの一瞬だけでもConcurrencyの計算理論に触れてみませんか?」 @banjun のLTです。 並行計算のモデルのひとつであるCCSについて、その入口を紹介しました。 おそらく事前知識のある人がほとんどいない分野だったのではないかと思いますが、その分、多くの方に概念の存在だけでも知ってもらえたら幸いです。今年のLTは、大学の講義よりも、さらに多くの人に並行計算を伝えられる最強の場だったのかもしれません。 誰も来ないことも覚悟していたAsk the Speakerですが、このLTのきっかけや参考文献の話をしたり、「コルーチンとインターリーブは似ているのでは?」「π計算との違いは?」など、ディープな話もでき、このネタでLTしておいて良かったと感じました。 fortee.jp 寄稿内容の紹介 「誰も知らないASO(App Store Optimization)の話」 @ahiru___z の寄稿です。 ASOに関する原稿を寄稿しました。 個人開発のアプリで累計200万DL以上を達成しました。その過程で行った自身の取り組みを中心に、まずは手軽に誰でも始めることができる基本的な手法をまとめています。ASOは一朝一夕に効果が現れるものではありませんが地道な取り組みによってある程度の効果を得ることは間違いなく可能です。 応用的なテクニックはまた別の機会にまとめたいと思います。 拡大 「CodableでJSONのNullを出力するためのTips」 hirotakanの寄稿です。 Codable + Property WrapperのTipsを2ページの原稿で紹介しました。 実務の参考や、Codableの理解に繋がれば幸いです。今回初めての寄稿でしたが、2ページは他の募集に比べるとハードルが低いので、チャレンジしてみたいけどなかなか踏み出せない方におすすめです。 拡大 CfPネタ出し会 & レビュー会 弊社では毎年自由参加でiOSDC JapanのCfPネタ出し会 & レビュー会をiOSエンジニア同士で行っています。 昨年はネタ表を利用したCfPネタの整理 を実施していましたが、今年はDiscord上でネタ出し会を行いました。既にネタがある人は発表してコメントをもらい、何を発表しようか迷っている人は参加者との会話の中でネタを引き出してもらうスタイルで進行しました。6月の段階でネタ出し会を行ったため、早い段階でネタを固めることができました。 技術顧問の岸川さんには例年CfP採択後のレビュー会に参加していただいていました。しかし、今年はCfP採択前に行うレビュー会の段階で参加していただき、CfPの書き方はもちろん、どうすれば自分の発表したい内容がより適切かつ魅力的に伝わるのかなどを教えていただきました。その結果、例年以上に実りの多いレビュー会となりました。 弊社は複数の事業ドメインを有しているため、一括りに「iOSエンジニア」と言ってもそれぞれ強い分野や興味のある分野が異なります。そのためレビュー会では様々な分野の話を聞くことができてとても楽しかったです。 少し余談となりますが、最近では岸川さんとの1on1も積極的に行っており、エンジニアとしてスキルアップしていく環境が十分に整備されています。 また、弊社ではカンファレンスへの参加は業務として扱われるため、iOSDC Japanには休日出勤という形で参加しました。今年は内定者アルバイトの方も複数名イベントへ参加しましたが、社員と同様にチケット代は経費となりイベントへの参加は業務時間として扱われています。 ZOZOではiOSエンジニアを大募集中ですのでご興味のある方はこちらからご応募ください。 hrmos.co After iOSDC Japan 2021を今年も開催 iOSDC Japanのアツい3日間が過ぎ、レポート記事を書いたので今年のiOSDC Japanは終了! ……ではありません。 今年もラップアップイベントにあたるAfter iOSDC Japan 2021をZOZO、Mobility Technologies、Sansanの3社の合同で開催します。 各社の社員によるLT、パネルディスカッションを行いますので、興味のある方はぜひご参加ください。 こんな方におすすめです。 iOSに関わるソフトウェアエンジニア iOSDCを一緒に振り返りたい方 iOSDCには参加しなかったけど、情報が知りたいという方 なお、本イベントにはZOZOの技術顧問でもある岸川さんも登壇します。 iOSDC Japan 2021に参加した方もそうでない方も、みんなで振り返りましょう。イベント申し込みは以下のページからお願いします。 zozotech-inc.connpass.com
こんにちは、ZOZO CTOブロックの池田( @ikenyal )です。 本日、10/1に株式会社ZOZOテクノロジーズは組織再編が行われ、株式会社ZOZO及び株式会社ZOZO NEXTとして再始動します。それに伴い、ご愛読いただいていた「ZOZO Technologies TECH BLOG」も「ZOZO TECH BLOG」に名称変更をします。 発信内容はこれまでと変わらず、ZOZOのエンジニアが有益な技術情報をお届けしますので、引き続きご愛読よろしくお願いします。
はじめに こんにちは。メディアプラットフォーム本部 WEAR部 WEAR-SREの笹沢( @sasamuku )です。 ZOZOが新しく展開する「FAANS」というショップスタッフ向けアプリをクローズドβ版としてテスト運用しています。本アプリは、 WEAR と連携したコーディネート投稿や、その成果を可視化する機能などをショップスタッフの皆さんに提供するtoBのソリューションです。現在、正式リリースに向け開発を進めています。 そして、FAANSのAPIはCloud Runと呼ばれるサーバレスなコンテナ実行基盤で稼働しています。本記事では、FAANSの実行基盤としてCloud Runを選定した理由や、構築・運用するためにSREとして取り組んだことをご紹介します。 Cloud Runを選んだ理由 まず、クラウドサービスはGCPを選択しています。FAANSでは開発速度の向上と運用負荷の軽減のため、認証やメッセージング、Webホスティングの機能にFirebaseを採用することにしました。そのため、クラウドサービスとしてもGCPを選択することが開発やコスト管理の面で最も妥当な判断でした。 FAANSの実行基盤に求められる要件には、大きく以下のものがあります。そのため、これらを満たすサービスをGCPの中から選定しました。 管理が容易なサーバレスプラットフォームであること Goのバージョン1.16をサポートしていること まず、「1.」を満たすサービスとして、Google App Engine, Cloud Functions, Cloud Run, GKEが挙げられました。さらに、2021年6月の選定時点で「2.」を満たせるものに絞ると、Cloud RunとGKEの2つが選択肢に残りました。 Cloud Runは 一部の制約 を満たせば、任意のプログラミング言語をサポートできます。また、オートスケールや従量課金、ミドルウェア管理が不要な点などのマネージドサービスとしての一般的な利点も備えています。 一方のGKEには、スケールやCI/CDの細やかな設定ができるという魅力がありました。しかし、リリースまでの期間やSREチームの規模を踏まえ、マニュフェストファイルなしで即座に利用開始できるフルマネージド版のCloud Runを選択することにしました。なお、以降で「Cloud Run」と呼称するものはフルマネージド版を指します。 実際にCloud Runを利用してみると、そのシンプルさに驚きました。サービスを作成しコンテナをデプロイするだけで、URLの発行と証明書取得が自動で行われ、ものの数分でHTTPS通信を開始できます。 しかし、Cloud Run単体ではWAFを導入できない、Datadog APMを設定できないなどの制約事項もありますので事前調査が大切です。 サービスを運用していくための取り組み 次に、Cloud Runでサービス運用していく上で行っている、SREとしての取り組みをいくつかご紹介します。Cloud Run特有の課題とその対応についても触れているので、Cloud Runでこれからサービスを公開したい方の参考になれば幸いです。 アーキテクチャ概観 アーキテクチャの概観は、一部検証フェーズの構成も含まれますが下図の通りです。 アプリケーションは全てCloud Runで稼働しています。処理時間の長い一部のデータベース更新は、レスポンス時間短縮のためにCloud Tasksへオフロードしています。また、社内の別システムからのイベントを取得するために、Cloud Pub/Subを用意して疎結合になるよう連携しています。 IaCへの取り組み FAANSではクラウドサービスにGCP、監視にDatadog、オンコール通知にPagerDutyを利用しており、それらのほぼ全てをTerraformで管理しています。これにより、共通の手続きで異なるサービスの構成を管理できるようにしています。その他にも、変更管理やコードレビューなどのIaCで一般的なメリットも享受しています。 Terraformの公式ドキュメント は簡潔で、すぐ実践できます。しかし、トラブルシューティング関連の記載が少なく、問題発生時にはこのドキュメントだけで対応することが難しいという一面もあります。その点に関しては、既に利用している第三者の情報を参考にして解決できることもあるので、本記事もそのような有益な情報になるよう、密かに期待しています。 Cloud Runにおいても、構築段階でうまくいかない場面が多々ありました。ここでは、抜粋したtfファイルを元に、特に注意しておくべき点をお伝えします。 resource "google_cloud_run_service" "default" { provider = google-beta # secret key を扱うため (2021/9/16時点) name = "cloudrun-srv-${var.env}" location = "asia-northeast1" template { spec { containers { image = var.docker_image env { name = "STAGE" value = var.env } env { name = "KEY" value_from { secret_key_ref { name = google_secret_manager_secret.key.secret_id key = "latest" } } } } service_account_name = google_service_account.default.email } metadata { annotations = { "run.googleapis.com/vpc-access-connector" = "${google_vpc_access_connector.serverless.name}" "run.googleapis.com/vpc-access-egress" = "all-traffic" "autoscaling.knative.dev/maxScale" = "100" } } } metadata { annotations = { generated-by = "magic-modules" "run.googleapis.com/launch-stage" = "BETA" "run.googleapis.com/ingress" = "all" } } autogenerate_revision_name = true traffic { percent = 100 latest_revision = true } lifecycle { ignore_changes = [ template [ 0 ] .metadata [ 0 ] .annotations [ "run.googleapis.com/client-version" ] , template [ 0 ] .metadata [ 0 ] .annotations [ "client.knative.dev/user-image" ] , template [ 0 ] .metadata [ 0 ] .annotations [ "run.googleapis.com/client-name" ] , template [ 0 ] .metadata [ 0 ] .annotations [ "run.googleapis.com/sandbox" ] , metadata [ 0 ] .annotations [ "client.knative.dev/user-image" ] , metadata [ 0 ] .annotations [ "run.googleapis.com/client-name" ] , metadata [ 0 ] .annotations [ "run.googleapis.com/client-version" ] ] } } まず、 autogenerate_revision_name フィールドを true に設定することは、ほぼ必須です。これは、リビジョン名をTerraformで管理せず、GCPで発行させるための設定です。 false あるいは設定されていない状態だと、同一のリビジョン名が発行されてコンフリクトが発生し、リビジョンが作成できません。詳細は こちら で詳しく説明されています。 また、 lifecycle ブロックを利用して特定のannotationに対する更新を無視するよう設定しています。Terraform管理外からリビジョンを作成した場合、例えばgcloudコマンドで作成した場合に、一部のannotationが自動的に変更または作成されます。すると、実際の状態とtfstateとの差分が生じるため、 terraform plan の出力が煩雑になるという問題が生じます。なるべく意味のある変更差分のみを表示させたいと考えたため、今回はこのような対策を施しました。しかし、重要なannotationに対して適用しないよう注意が必要です。例えば、Cloud Runサービスと後述するサーバレスVPCアクセスコネクタとの紐付けはannotationを使って指定しています。 その他の内容は、 公式ドキュメント を参照ください。 監視への取り組み 私達のチームでは主な監視ツールとしてDatadogを利用していますが、監視においてもCloud Run特有の課題がありましたので、対応策を含めご紹介します。 その課題とは、「Cloud RunはDatadog APMをサポートしていないこと」でした。なお、最新のサポート状況は こちら をご確認ください。 Datadog APMはライブラリを組み込んでAgentを構成することで、アプリケーションからインフラまでの一貫した監視を可能にするツールです。アプリケーションエラーやレイテンシはもちろん、リクエスト処理状況をクエリ単位で可視化できるなど豊富な機能を提供していたため、チームでは積極的に活用し障害対応や改善業務に役立てていました。 Datadog APMに非対応な点は残念でしたが、リクエスト数やエラー数などの基本的なメトリクスはDatadog Integrationsで取得できていました。さらに、アプリケーションエラーはSentryで取得できていたため、直近で大きな問題にはならないと判断し、他の方法でDatadog APMが提供する指標を補完できないか検討しました。 その結果、エンドポイント毎のレイテンシはCloud RunのリクエストログをDatadogに転送し、それをメトリクス化することで可視化できると分かりました。今後のサービス拡大に向けSLI/SLOを策定したい背景もあり、エンドポイント毎のレイテンシはぜひ取得したいというモチベーションがありました。なお、サービス全体のレイテンシだと特定のリクエストにおけるレスポンス遅延を検知できない可能性があるので注意が必要です。 以下では、Cloud Runのエンドポイント毎のレイテンシをDatadogでメトリクス化する手順をご説明します。 Cloud Runはリクエストログを自動的にCloud Loggingに転送しています。リクエストログには、エンドポイントのパスやレイテンシが格納されているため、Datadogでメトリクス化することによりダッシュボードでの閲覧が可能となります。 まず、下図のようにCloud Pub/Subを使ってDatadogにリクエストログをPushします。 Datadogでログからメトリクスを作成する流れは次の通りです。 リクエストログ内 latency フィールドをnumberとして パース 詳細は 拙稿 をご参照ください パースされた latency を 定量的ファセット に登録 新しい ログベースメトリクス を作成 最終的に完成したダッシュボードが下図です。作成したメトリクスを利用し、図内の赤枠で示している通り、エンドポイント毎のレイテンシを表示させています。 CI/CDへの取り組み CI/CDにはGitHub Actionsを使用しています。コード管理、レビュー、マージ、CI/CDといったコードのライフサイクル全てをGitHubで完結できる点が非常に便利です。また、クラウドベンダー公式のActionも公開されており、今後ますます充実していくことが期待されます。 FAANSのアプリケーションは、下図のような流れでデプロイをしています。 コンテナデプロイの前に、FirestoreのIndex作成と初期データ投入をします。Cloud Runのデプロイについては こちら よりワークフローの詳細をご確認ください。 そして、GCPやDatadogのプロビジョニングにもGitHub Actionsを利用しています。Pull Requestを作成すると terraform plan の出力結果がConversationタブに表示されます。これにより、コードレビュー時にplan結果を確認でき、より安全にapplyを実行できます。 GitHub Actionsにおける terraform plan の表示方法は こちら を参照ください。 外部向きIPアドレスの固定 最後に、Cloud Runにおける外部向きIPアドレスの固定方法をご紹介します。 FAANSはAWSに構築された社内システムと通信する必要がありましたが、クラウドが異なるためピアリング接続によるプライベート通信はできませんでした。専用線での通信はコストや障害点の多さから構成が難しかったため、IPアドレス制限を設けてインターネット経由で接続する構成を選択しました。 Cloud Runの外部向きIPアドレスは動的であるため、下図のようなサーバレスVPCアクセスコネクタを用いた構成を取り、予約済みの静的アドレスを使えるようにしました。 VPC内のリソースは以下のtfファイルで定義しています。 # vpc resource "google_compute_network" "vpc-network" { name = "vpc-${var.env}" mtu = 1460 auto_create_subnetworks = false } # subnet for serverless vpc access connector resource "google_compute_subnetwork" "serverless" { name = "subnetwork-serverless-${var.env}" ip_cidr_range = "10.124.0.0/28" # VPCコネクタの制限により/28を指定 region = "asia-northeast1" network = google_compute_network.vpc-network.id } # vpc connector resource "google_vpc_access_connector" "serverless" { provider = google-beta # subnet と紐付けるため (2021/9/16時点) name = "vpc-connector-${var.env}" subnet { name = google_compute_subnetwork.serverless.name } region = google_compute_subnetwork.serverless.region } # cloud router resource "google_compute_router" "serverless" { name = "router-serverless-${var.env}" network = google_compute_network.vpc-network.name region = google_compute_subnetwork.serverless.region } # ip address resource "google_compute_address" "serverless" { count = 1 name = "ip-serverless-${var.env}" address_type = "EXTERNAL" region = google_compute_subnetwork.serverless.region } # nat resource "google_compute_router_nat" "serverless" { name = "nat-serverless-${var.env}" router = google_compute_router.serverless.name region = google_compute_subnetwork.serverless.region nat_ip_allocate_option = "MANUAL_ONLY" nat_ips = google_compute_address.serverless.*.self_link source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS" subnetwork { name = google_compute_subnetwork.serverless.id source_ip_ranges_to_nat = [ "ALL_IP_RANGES" ] } } Cloud Runに対しては、外部向きトラフィックの全てをサーバレスVPCアクセスコネクタ経由でルーティングするように設定します。 gcloudコマンドを利用する場合と、Terraformを利用する場合のそれぞれの設定方法はこちらです。 gcloud run deploy SERVICE_NAME \ --image=IMAGE_URL \ --vpc-connector=CONNECTOR_NAME \ --vpc-egress=all-traffic resource "google_cloud_run_service" "run" { template { metadata { annotations = { "run.googleapis.com/vpc-access-connector" = "${google_vpc_access_connector.serverless.name}" "run.googleapis.com/vpc-access-egress" = "all-traffic" } ~中略~ } ~中略~ } ~中略~ } なお、コマンドラインから構成する手順は こちら をご参照ください。 まとめ Cloud Runでサービスを構築・運用する際のSREとしての取り組みをご紹介しました。FAANSはまだテスト運用を開始してから日も浅く、取り組めていない課題も存在します。今後は、パフォーマンスチューニング、WAF導入、SLO/SLI策定などを視野に入れつつ、ユーザが快適に利用できるサービス作りに貢献していきたいです。 さいごに ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
はじめに こんにちは。SRE部 ECプラットフォームSREチームの大澤です。 先日、SREチームにてBFF機能を司る「ZOZO Aggregation API」の導入について紹介しました。 techblog.zozo.com BFFは複数のバックエンドと通信するアーキテクチャであるため、通信先のバックエンド障害に大きな影響を受けてしまいます。そのため、ZOZO Aggregation APIでは、各バックエンド間の通信障害をIstioによるタイムアウトとリトライ制御で可用性を担保していました。 今回は、新たにIstioサーキットブレーカーを導入することで、さらなる安定性・回復性の向上を果たした取り組みを紹介します。 サーキットブレーカーとは サーキットブレーカーとは、あるサービスの障害を検知した場合には通信を遮断、その後サービスの復旧を検知すると通信を復旧させる仕組みです。 複数のマイクロサービスが連動するサービスの場合、一部のマイクロサービスの障害が連鎖的な障害に繋がるカスケード障害を発生させる可能性があります。 以下はカスケード障害の例です。 Service C が応答不能となると、 Servie B は Service C からのレスポンスを待ち続けるため、不安定な状態となります。この状況が続くと Service B が応答不能となり、連動する Service A へと障害が連鎖します。 サーキットブレーカーは、このようなマイクロサービスアーキテクチャ特有の課題に対するデザインパターンの1つです。 以下の図は、先程の例にサーキットブレーカーを導入した場合の流れです。 Service C で発生した障害を検知すると Servie B はリクエストを遮断します。リクエストを遮断することでレスポンスを待ち続ける状況やスレッドプールの枯渇を防ぎ、 Service B と連動する Service A を保護します。 サーキットブレーカーパターンの詳細については こちら を参照ください。 docs.microsoft.com ここで紹介した例は非常にシンプルなカスケード障害の場合ですが、ZOZOTOWNのプラットフォーム基盤はマイクロサービスアーキテクチャを採用しており、より複雑なサービス間連携が発生しています。そのため、連鎖による大規模な障害に発展しないよう、カスケード障害への対策の必要性が増していました。 既存のタイムアウト・リトライ制御の問題点 ZOZO Aggregation APIでは、Istioによるタイムアウト・リトライ制御設定を通信先のバックエンド毎に入れています。設定したタイムアウト・リトライ試行内でバックエンドからレスポンスが得られない場合には、それ以外のバックエンドから取得できたモジュールのみでレスポンスし、サービスを継続しています。 以下の図は、リトライでサービスを救える場合の処理の流れの例です。商品詳細API呼出処理は、リトライを含めて130msで完了しています。 この様に、すぐに復旧が見込まれる様な一時的なネットワークの瞬断などの不具合であれば、リトライ機能により適切にサービスを救うことができます。 しかし、バックエンドとの通信のエラー状況によってはタイムアウト・リトライ制御が必ずしも適切に働くわけではありません。通信先のバックエンドが不安定になって直ちにエラーが返ってこない場合、Istioによるタイムアウトまでバックエンドからのレスポンスを待つことになります。 以下の例は、Istioで10sのタイムアウト、かつ1回のリトライを設定していた場合です。最終的に商品詳細API呼出処理は20s待つことになります。ZOZO Aggregation APIとしては、該当のAPI以外のバックエンドから取得したモジュールで正常ステータスを返却できます。ただし商品詳細APIの回復までレイテンシーは増加し続けてしまいます。 この様なレイテンシーの増加を防ぐために、異常なバックエンドをサービスアウトし、ZOZO Aggregation APIからリクエストしない状態にするのが理想的です。 以下の例はサーキットブレーカーを導入した場合に期待される動作例です。商品詳細APIに障害が発生している場合、API呼出を行わずに処理を完了できます。 この様に障害を検知し、リクエストを遮断するサーキットブレーカーは有効な手段です。 サーキットブレーカーの導入方法 サーキットブレーカーを導入するには、大きく分けて以下の2つのアプローチが考えられます。 各マイクロサービスにサーキットブレーカーが実装されたライブラリを組み込むアプローチ Istioやnginxのサービスメッシュなど、ネットワーク機能として導入するアプローチ 弊社は後者のアプローチを採用しました。なぜIstioサービスメッシュによる導入を選択したのか、どのようにZOZO Aggregation APIにサーキットブレーカーを導入していったのかを本章で紹介します。 Istioサーキットブレーカーを導入した理由 サーキットブレーカーが実装されたライブラリを各マイクロサービスに組み込んでいく場合、以下の課題がありました。 マイクロサービスへの組込やアップグレードの際に、アプリケーション開発者とSRE間でコミュニケーションが多く発生し、コミュニケーションコストが増加する マイクロサービス毎に異なるアーキテクチャー・言語を採用しているため、ライブラリ・組込方法も異なり一貫性の担保が困難になる こういった点を考慮し、SREチームでは以下の理由でIstioサービスメッシュによるアプローチを選択しました。 アプリケーションコードを変更する必要がなく、インフラコードの改修のみでサーキットブレーカーの機能追加が実現でき、かつサービスメッシュ全体で一貫した制御が可能 既にマイクロサービスプラットフォーム基盤にIstioサービスメッシュを活用していたので、サーキットブレーカー導入の敷居が低い Istioサーキットブレーカーは、外れ値検出(エラー検出)だけではなく、接続要求(接続数上限など)によるサーキットブレーカーも提供しており機能要件に適していた Istioサーキットブレーカーの組込 Istioサーキットブレーカーの設定項目の理解は、サーキットブレーカー自体の振る舞いを把握しているとより容易になります。 そのため、まずはサーキットブレーカーパターンの動作原理を説明します。 サーキットブレーカーは動作原理として以下の状態を持ちます。 Closed 遮断機がOFFの状態 リモートのサービスにリクエストを要求可能となる リクエストが失敗した場合、エラー数をカウントし、エラー数が閾値に達するとOpen状態へと移行する Open 遮断機がONの状態 リモートのサービスへのリクエストは直ちに失敗となる Open状態へ遷移した時間をカウントし、時間経過カウントが閾値に達するとHalf Open状態へ移行する Half Open 障害が解決したか確認する状態 リモートのサービスに少数の限られたリクエストを要求可能となる リクエストが成功した場合にはエラーカウントをリセットしClosed状態へ、リクエストが失敗した場合にはOpen状態へと移行する また、外れ値検出によるIstioサーキットブレーカーの組込は、カスタムリソースであるDestinationRuleへOutlierDetectionを設定することで実現できます。 以下のサンプルコードは、外れ値検出による基本的なサーキットブレーカーを組込む場合の例です。 apiVersion : networking.Istio.io/v1beta1 kind : DestinationRule metadata : name : test-api spec : host : test-api.test-api.svc.cluster.local trafficPolicy : outlierDetection : consecutive5xxErrors : 10 interval : 10s baseEjectionTime : 1m OutlierDetectionの設定項目は以下の通りです。 設定項目 説明 consecutive5xxErrors Open状態に遷移する5xxエラー閾値 interval 5xxエラー検出の間隔 baseEjectionTime Open状態からHalf Open状態に移行する時間 Istioサーキットブレーカーには、上記以外にも様々な設定値が存在します。詳細はDestinationRuleの 公式リファレンス をご参照ください。 istio.io 上記のサンプルの設定では「10秒間で10回の5xxエラーを検知すると、1分間Open状態とするサーキットブレーカー」として動作します。 よって、ZOZO Aggregation APIへのサーキットブレーカー組込は、既存のDestinationRuleにOutlierDetectionを設定するのみです。ただし、サーキットブレーカーを適切に稼働させるためにはバックエンドをOpen状態へ遷移させるための閾値を決定する必要があります。 閾値の決定 閾値を決めるには、以下の2つのアプローチがあります。 クライアント側のサービス要件で閾値を決める Service A は1sに、1回のエラー発生で Service D へのリクエスト前に遮断したい Service B は1sに、2回のエラー発生で Service D へのリクエスト前に遮断したい Service C は1sに、3回のエラー発生で Service D へのリクエスト前に遮断したい リモート側のサービス要件で閾値を決める Service D は1sに、4回のエラー発生で受付けるリクエストを遮断したい 以下に示すのは、クライアント側の要件で閾値を設定する場合の例です。DestinationRuleのサンプル同様に、どのサービスからのリクエストであるのかを個別に定義する必要があります。 apiVersion : networking.Istio.io/v1beta1 kind : DestinationRule metadata : name : service-d-api spec : host : service-d-api.service-d-api.svc.cluster.local # Service AからService Dへの設定 - name : service-a-api-to-service-d-api trafficPolicy : outlierDetection : consecutive5xxErrors : 1 interval : 1s baseEjectionTime : 1m # Service BからService Dへの設定 - name : service-b-api-to-service-d-api trafficPolicy : outlierDetection : consecutive5xxErrors : 2 interval : 1s baseEjectionTime : 1m # Service CからService Dへの設定 - name : service-c-api-to-service-d-api trafficPolicy : outlierDetection : consecutive5xxErrors : 3 interval : 1s baseEjectionTime : 1m SREチームではZOZOTOWNのセールなどのイベントに合わせて随時Pod数を調節しています。仮に Service D の管理者がPod数を2倍にした場合、 Service A〜C の管理者は個別にエラー閾値を調節しなければならず、運用が複雑になります。また、本記事では省略していますが、VirtualServiceにも同様に、どのサービスからのどのサービスへのルーティングであるか個別に定義する必要があり、複雑さがさらに増します。 そのため、SREチームではリモート側のサービス要件で閾値を決定する方法を採用しました。 サーキットブレーカー導入の効果 安定性・回復性向上のために導入したサーキットブレーカーですが、そのような機能が実際に使われることなく安定してサービスが運用されることが望ましいです。 幸いにもサーキットブレーカー導入後、実際の障害によってサーキットブレーカーが発動されたことはありません。そのため、今回は開発環境に用意したmockアプリで、擬似的に障害状態を再現した事例を紹介します。 以下の図は、サーキットブレーカー導入前のアプリケーショントレーシングの結果です。バックエンドサービスAPIのタイムアウトに影響を受け、ZOZO Aggregation APIのレイテンシーが増加していることが確認できます。 一方、以下の図は、サーキットブレーカー導入後のアプリケーショントレーシングの結果です。サーキットブレーカーによりバックエンドサービスAPIへのリクエストが直ちに遮断されていることが確認できます。また、サーキットブレーカー導入前にはバックエンドサービスAPIのパフォーマンス劣化の影響を受け、9.08sで返却していたレスポンスタイムが136msへ改善していることも確認できます。 サーキットブレーカー導入後の課題 サーキットブレーカーを導入したことにより回復性は高まりました。しかし、バックエンドが障害から復旧したと判断する時間設定によっては、サービス復旧までにタイムラグが生じてしまいます。ユーザ体験を損なわないためにも、「全てのモジュール情報が揃った正しいレスポンス」をタイムラグなく返却することが重要です。障害のパターンは様々なため、運用しながら最適値を見極めていく必要があります。 また、現状はサーキットブレーカーによって通信がOpen状態へ移行したことを検出しておらず、バックエンド自体のサービス稼働状況で通信状況が問題ないか判断しています。もし、通信がOpen状態に移行したことを検知できれば早期にサービス稼働状況が危険な状態であることを発見できるため、サーキットブレーカー検出も今後導入していく予定です。 まとめ 本記事では、Istioサーキットブレーカー導入の事例を紹介しました。本記事により、サーキットブレーカーの有効性、Istioサービスメッシュ環境下であれば簡単に導入可能であることをご理解いただけたら幸いです。また新たな知見が得られた際には、紹介したいと思います。 さいごに ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
はじめまして、ECプラットフォーム部 API基盤チームに2021年新卒入社した山添です。普段はAPI GatewayやID基盤の開発に携わっています。 データベースを運用していると、ビジネスロジックの変更やクエリ最適化のためにデータベーススキーマを変更することがあります。その際にデータベースマイグレーションツールを使うことで、運用の過程で変更されるスキーマの管理を楽にできます。 しかし、データベースマイグレーションツールであるsqldefが便利なのですが、弊社で使われているSQL Serverには対応していませんでした。そのため、何かしらの対策が必要でした。 本記事では、それらに関連した以下の内容を紹介します。 データベースマイグレーションツールとしてsqldefを採用していること sqldefでSQL Serverサポートをするためにコントリビュートしていること sqldefの開発のために必要な基礎知識と具体的な実装について 目次 目次 前提知識 データベースマイグレーション データベースマイグレーションツール Flyway sqldef sqldefを採用した背景 sqldefと言語アプリケーション sqldefの処理の流れ 言語アプリケーションの基礎要素 字句解析器 構文解析器 抽象構文木 sqldefの実装 マイグレーション処理の流れ sqldefがサポートする構文の追加 テストの追加 抽象構文木の改修 yaccファイルの改修 adapterの改修 generatorの改修 おわりに 前提知識 はじめに、本記事で扱うデータベースマイグレーションとマイグレーションツールについて紹介します。ご存じの方は、 sqldefを採用した背景 の章からご覧ください。 データベースマイグレーション アプリケーションの変更に伴い、データベースのスキーマ情報の変更を必要とする場合があります。例えば以下のケースです。 新機能の実装に必要となるカラムを追加したい ビジネスロジックの変更に伴いカラムの制約を変更したい パフォーマンスチューニングのためにインデックスを追加したい データベースに保存されているデータを保持したままスキーマ情報を変更することをデータベースマイグレーションと呼びます。そのデータベースマイグレーションにより、仕様変更や機能追加でスキーマ情報の変更が発生しても柔軟に適応できます。しかし、環境ごとのスキーマ管理や更新のためのDDL文を実行する手間など、新たな課題が浮上します。 データベースマイグレーションツール 上記の課題を解決する手段の1つとして、データベースマイグレーションツールがあります。データベースマイグレーションツールは、スキーマ情報の変更に伴うDDL文の実行を管理するためのツールです。 多くのデータベースマイグレーションツールは、現時点と最新のスキーマ情報の差分から実行が必要なDDL文だけを実行してくれます。そのため、開発環境や本番環境ごとにスキーマ情報を管理し、更新に必要なDDL文を1つ1つ手動で実行するという手間を省くことができます。 また、データベースマイグレーションツールは、アプリケーションフレームワークに組み込まれているものから独立したツールとして実行可能なものなど、様々な選択肢が存在します。本記事では現在弊社で採用しているFlyway、sqldefの2つを紹介します。この2つはどちらも独立したツールとして実行可能です。 Flyway FlywayはJava製のデータベースマイグレーションツールです。Apache License 2.0で配布されているコミュニティ版と有償のチーム版が存在しており、サポートや機能に違いがあります。 flywaydb.org Flywayの特徴は、利用における選択肢の多さです。更新操作の記述はDDL文またはJavaコードが選択できます。実行方法はコマンドラインクライアント、Java APIやMaven plugin、Gradle pluginが用意されています。特にマイグレーション可能なデータベースの数は群を抜いています。詳しくは こちら をご覧ください。 また、Flywayはファイル名でスキーマ情報のバージョン管理を行い、必要な更新操作を検出して実行します。そのため、現在のデータベースを更新するために「どのDDL文を実行する必要があるのか」を考える必要がありません。データベースマイグレーションに必要な作業は、データベースを任意のバージョンから次のバージョンに移行するためのDDL文を定義するだけです。 例えば、以下のDDL文で定義したテーブルがあるとします。 -- ファイル名:V1_20210901_create_users.sql CREATE TABLE users ( id BIGINT PRIMARY KEY, name VARCHAR ( 20 ) ) このテーブルに email カラムを追加したい場合は、以下の様なDDL文を新たなマイグレーションファイルに定義してFlywayを実行します。そうすることで、Flywayが未実行のマイグレーションファイルを検出し実行してくれます。 -- ファイル名:V1_20210903_add_email.sql ALTER TABLE users ADD email VARCHAR ( 100 ) NOT NULL また、コミュニティ版とチーム版の一番大きな違いは、 各種データベースのサポート期間 です。コミュニティ版ではデータベースの特定のバージョンがリリースされてから5年がサポート対象期間ですが、チーム版ではサポート期間が10年になります。例えば、データベースにSQL Server 2017を利用している場合、コミュニティ版のサポート期間は2022年までです。 sqldef sqldefはGo言語製のデータベースマイグレーションツールで、MITライセンスで配布されています。 github.com Go言語製のツールなので、ビルドしたバイナリから実行できます。2021年9月時点でサポートしているデータベースはMySQL、PostgreSQL、SQLite3、SQL Serverです。各データベースごとにサポートしている機能が異なるので、詳しくは こちら をご覧ください。 sqldefの特徴は、更新操作の定義が必要ない点です。多くのデータベースマイグレーションツールでは、 ALTER TABLE ... などの更新操作を定義し、データベースのマイグレーションとバージョン管理を実現します。しかし、sqldefでは最新のスキーマ情報を定義したDDL文さえあれば、自動的に必要な更新操作を生成し実行してくれます。バージョン管理はGitなど他のツールに任せることで、更新操作を実行する度にファイルが増えていき管理が煩雑になるのを防ぐことができます。 Flywayの例と同様に、以下の定義をしたテーブルがあるとします。 CREATE TABLE users ( id BIGINT PRIMARY KEY, name VARCHAR ( 20 ) ) このテーブルに email カラムを追加したい場合は、上記のファイルを以下の様に編集します。 CREATE TABLE users ( id BIGINT PRIMARY KEY, name VARCHAR ( 20 ), email VARCHAR ( 100 ) NOT NULL ) このDDL文を使ってsqldefを実行することで、自動で ALTER TABLE users ADD email ... 文を生成し、実行してくれます。 sqldefを利用する際には、Flywayと比較してサポートしているデータベースが少ない点に注意が必要です。また、基本的なDDL構文はサポートされていますが、サポートされていない構文を使いたい場合は適宜修正が必要です。 sqldefを採用した背景 チーム内で採用するマイグレーションツールを検討した結果、以下の3点からsqldefを高く評価しました。 マイグレーションファイルの管理から解放される バイナリだけで動かせるため、Flywayと比較して実行が容易である 1 機能がシンプルなため、自分たちの手でメンテナンスが可能である ところが、弊社ではMySQLとSQL Serverをメインで使っていますが、マイグレーションツールを検討していた2021年6月時点でsqldefはSQL Serverに対応していませんでした。それでも、sqldefは魅力的であり、最終的に自分たちでsqldefにコントリビュートしてSQL Server対応をしていくという決断を下しました。 その後、コントリビュートの成果として、2021年9月時点でSQL Serverでも以下のDDL文がsqldefによって生成できるよう対応が進みました。 Table: CREATE TABLE, DROP TABLE Column: ADD COLUMN, DROP COLUMN, DROP CONSTRAINT Index: ADD INDEX, DROP INDEX Primary key: ADD PRIMARY KEY, DROP PRIMARY KEY VIEW: CREATE VIEW, DROP VIEW github.com 以降の章では、SQL Serverサポートを進める上で得られたsqldefの開発に必要な知識から実装までの流れを紹介します。 sqldefと言語アプリケーション 本章では、sqldefがどの様にマイグレーションを実現しているのか、全体像とそれを実現するための基礎知識を紹介します。sqldefの全体像から内部実装を知ることで、現在サポートされていないSQL構文を管理したくなった際に、自分でサポート構文の追加をしたりbugfixをするのに役立ちます。 sqldefの処理の流れ sqldefでデータベースマイグレーションを実行すると、以下の順序で処理が実行されます。 既存のデータベースからDDL文を書き出す 1.で書き出されたDDL文と新しいDDL文を構文解析し、それぞれの抽象構文木を生成する 2つの抽象構文木を比較し、実行すべき更新操作のDDL文を生成する 生成されたDDL文を実行する 上記の処理を図にまとめると以下の様になります。 この様に、文字列を構文解析して何らかの処理を実行するツールを「言語アプリケーション」と呼びます 2 。代表的な言語アプリケーションにはインタプリタやコンパイラがあげられます。 言語アプリケーションは汎用性が高いツールです。言語アプリケーションの開発手法を学ぶことで、デバッグツールや静的解析ツール、言語翻訳ツールなど開発・運用効率を向上するツールの開発ができる様になります。次の節では、sqldefの実装に必要な言語アプリケーションの基礎要素の知識を紹介します。 言語アプリケーションの基礎要素 この節では言語アプリケーションの開発に必要な基礎要素として字句解析器、構文解析器、抽象構文木を説明します。これらの基礎要素は数ある言語アプリケーションに共通して使われる要素であり、インタプリタやコンパイラの実装にも必要な要素です。sqldefでも中心に添えられている要素なので、これらの仕組みを知ることがsqldefの実装の理解に役立ちます。 字句解析器 字句解析器は、与えられた文字列を事前に定義したトークンの配列に変換する字句解析と呼ばれる処理をします。 そして、字句解析器はlexer、tokenizer、scannerなど様々な呼び方をされることがありますが、sqldefではtokenizerとして実装されています。トークンとは、解析する対象言語の文法中で1つの単位として扱うことができるものを指します。字句解析器は識別子(テーブル名など)や整数値の様に意味値を持つトークンに対しては、トークン型に加えて値の情報も出力します。DDL文の場合、トークンは文字列を空白によって分割した単語単位で表現されます。例えば、 CREATE や TABLE の他、 ( や識別子、整数値が1つのトークンとして定義され、トークン型が識別子や整数値の場合は、「 users 」や「 10 」の様な値も加えて出力します。 以下の様なDDL文を字句解析した場合を例にあげます。 CREATE TABLE users (name CHAR ( 10 )) この場合、字句解析器には以下の様なトークン列を出力されることが期待されます。 [ CREATE, TABLE, IDENTIFIER("users"), LPAREN, IDENTIFIER("name"), CHAR, LPAREN, INTEGER(10), RPAREN, RPAREN, SEMICOLON ] sqldefでは goyacc というパーサージェネレータが使われています。パーサージェネレータについては後述しますが、構文解析器を自動生成するためのツールです。パーサージェネレータにgoyaccを使う場合、goyaccに定められた仕様で字句解析器を実装しなければなりません。 pkg.go.dev goyaccで使える字句解析器は以下のインタフェースで定義されています。 type yyLexer interface { Lex(lval *yySymType) int Error(e string ) } Lex が字句解析の処理ですが、引数として受け取った lval にトークンの意味値を入れ、トークンの種類を int 型で返す様に実装します。このインタフェースを満たす様に字句解析器を実装した場合、上記のDDL文を入力した際の lval と戻り値は以下の様に遷移することが期待されます。 type tokenType int const ( CREATE tokenType = iota TABLE CHAR IDENT // 識別子 NUMBER LPAREN RPAREN SEMICOLON ) var tokens = [] struct { tokenType tokenType // Lex()の戻り値 lval string }{ {CREATE, "create" }, {TABLE, "table" }, {IDENT, "users" }, {LPAREN, "(" }, {IDENT, "name" }, {CHAR, "char" }, {LPAREN, "(" }, {NUMBER, "10" }, {RPAREN, ")" }, {RPAREN, ")" }, {SEMICOLON, ";" }, } iota はGo言語特有の記法で、定数に対して整数の連番を振ってくれます。goyaccを使う場合、goyaccがトークンタイプの整数値を定数として生成してくれます。上記の様に、事前に定義したトークンタイプを見つけて入力文字列を分割していくのが字句解析器の役割です。そして、字句解析器から出力されたトークン列が構文解析器に入力されます。 構文解析器 構文解析器は入力データを受け取り、何かしらのデータ構造を出力する処理をします。入力データは上述した字句解析器から出力されるトークン列です。出力するデータ構造には、構文解析木や抽象構文木など、後段の解析処理に適切なデータ構造を選択します。テキストを入力として受け取り、何かしらのデータ構造を出力するという意味では、多くの人が馴染み深いであろうJSONパーサーと考え方は同じです。ただし、言語アプリケーションで利用するのに適したデータ構造を出力するという点が異なります。 構文解析器は言語アプリケーションの中で重要な役割を担いますが、パーサージェネレータと呼ばれるツールを使って自動生成できます。そのため、言語アプリケーションを開発する場合は学習目的の場合を除いて、パーサージェネレータを使うのが良いでしょう。パーサージェネレータとして有名なツールにはyaccやbison、ANTLRがあります。sqldefはパーサージェネレータにyaccのGo実装であるgoyaccを採用しています。 goyaccは本家yaccと同じ様にバッカスナウア(BNF)記法に似た構文規則を与えることで、コンパイル可能なGo言語のコードを出力します。goyaccの入門には こちらの記事 が非常に参考になります。 抽象構文木 構文解析器の出力から得られるデータ構造を中間表現と呼びます。中間表現の中でも言語アプリケーションに良く用いられるデータ構造が抽象構文木です。 抽象構文木は入力列から不要な字句を省き、重要な字句の文法上の関連を記録したデータ構造です。抽象構文木を構築することで入力列の走査が容易になり、構文解析器の後段に置く処理を簡潔にできます。 なお、抽象構文木に含める情報は開発したいアプリケーションによって都度選択する必要がありますが、sqldefの場合はDDLの変更を検知するための情報が必要です。例えば、sqldefではchar型の文字列長の変更を検知して更新操作がされる様に実装されているので、 char(n) の文字列長を示す n も抽象構文木の情報に含める必要があります。 実際に以下の様なDDL文が与えられた場合を例にあげます。 CREATE TABLE users ( id INT, name CHAR ( 20 ) ) sqldefの構文解析器が構築する抽象構文木は以下の様になります。 テーブル名や型情報、文字列長など必要な情報だけが抽出され、DDL文を木構造で表現できていることが分かります。 この抽象構文木からインタプリタやコンパイラでは言語変換や評価をしたり、静的解析ツールでは木を走査して特定の文字列を見つけたりしています。 次章ではsqldefが抽象構文木をどの様に使ってデータベースマイグレーションを実現するのかを紹介します。 sqldefの実装 本章ではsqldefの実装について、マイグレーション処理の流れとサポートする構文の追加方法を例に紹介します。 マイグレーション処理の流れ DDL文を解析するために必要な抽象構文木は、実際の実装を見ると理解が容易になります。そのため、以下にGo言語のstructでDDL文のデータ構造の実装例を示します。 type DDL interface { Statement() } type CreateTable struct { table Table } func (c *CreateTable) Statement() {} type Table struct { name string columns []Column indexes []Index foreignKeys []ForeignKey } type Column struct { name string typeName string notNull * bool length int keyOption ColumnKeyOption } type ColumnKeyOption int const ( ColumnKeyNone ColumnKeyOption = iota ColumnKeyPrimary ColumnKeyUnique ) 上記は sqldefの実装 から一部を抜粋したものです。テーブルは、テーブル名の他にカラムやインデックス、外部キーの情報をリスト構造で保持しています。カラムは、カラム名や型情報の他に制約などカラムを表現するために必要な情報を保持します。カラムの中にデフォルト制約やチェック制約の構造体が埋め込まれており、DDLが木構造で表現されていることが分かります。 入力を抽象構文木にするまでの処理は、どの言語アプリケーションにも大方共通しますが、抽象構文木をどう使うかが肝になってきます。sqldefの場合、新DDL文の抽象構文木と既存データベースから出力される旧DDL文の抽象構文木の2つを比較し、更新用のDDL文を生成します。 例として、テーブル定義が変更された際に、どの様な処理が行われるかを見てみます。 旧テーブル、新テーブルとして以下のDDLが定義されているとします。旧テーブルと新テーブルの差分は、idカラムのデータ型の変更とnameカラムの追加です。 -- 旧テーブル CREATE TABLE users ( id INT PRIMARY KEY ) -- 新テーブル CREATE TABLE users ( id BIGINT PRIMARY KEY, name CHAR ( 20 ) ) この場合、sqldefの構文解析器から出力される抽象構文木は以下の様になります。 // 旧テーブルの抽象構文木 var currentTables = []Table{ { name: "users" , columns: []Column{ { name: "id" , typeName: "int" , keyOption: ColumnKeyPrimary, }, }, }, } // 新DDLの抽象構文木 var desiredDDLs = []DDL{ &CreateTable{ table: Table{ name: "users" , columns: []Column{ { name: "id" , typeName: "bigint" , keyOption: ColumnKeyPrimary, }, { name: "name" , typeName: "char" , length: 20 , }, }, }, }, } 上記の2つの抽象構文木を元にsqldefは schema/generator.go にある処理で更新DDL文を生成します。 sqldefがテーブルを比較し、更新DDL文を生成する処理を簡略化したコードで表すと以下の様に実装できます。 type Generator struct { mode GeneratorMode currentTables []*Table } func (g *Generator) generateDDLs(desiredDDLs []DDL) [] string { ddls := [] string {} for _, ddl := range desiredDDLs { // 旧テーブルの取得 currentTable := findTableByName(g.currentTables, desired.table.name) if currentTable != nil { tableDDLs := g.generateDDLsForCreateTable(*currentTable, *desired) ddls = append (ddls, tableDDLs...) } else { ddls = append (ddls, "テーブルの追加処理" ) } } return ddls } func (g *Generator) generateDDLsForCreateTable(currentTable Table, desired CreateTable) [] string { ddls := [] string {} for i, desiredColumn := range desired.table.columns { currentColumn := findColumnByName(currentTable.columns, desiredColumn.name) if currentColumn == nil { ddls = append (ddls, "カラムの追加処理" ) } else { // データ型のチェック if !g.haveSameDataType(*currentColumn, desiredColumn) { ddls = append (ddls, "データ型の変更処理" ) } // デフォルト制約のチェック if !areSameDefaultValue(currentColumn.defaultDef, desiredColumn.defaultDef) { if desiredColumn.defaultDef == nil { ddls = append (ddls, "デフォルト制約の削除処理" ) } else { ddls = append (ddls, "デフォルト制約の追加処理" ) } } // primary key, check制約, ...などのチェックとDDL生成 } } return ddls } DDLの生成処理は、テーブルやカラムの存在チェックや等価判定を駆使して愚直に実装されています。 上記のusersテーブルの定義の場合、以下の順に処理が実行されるでしょう。 findTableByName() が呼ばれ、 currentTable に既存のusersテーブルが入る currentTable != nil が真になり、 generateDDLsForCreateTable() が呼ばれる 既存テーブルからid列を探す id列のデータ型が変更されているので、 !haveSameDataType() が真になり、 ddls にデータ型の変更処理が追加される 既存テーブルからname列を探す currentColumn == nil が真になるので、 ddls にカラムの追加処理が追加される 実際には ddls に追加する処理は、Generator構造体のmodeにしたがって条件分岐し、各DB間の構文の差を吸収しています。例えばカラムの追加の場合、MySQLでは ALTER TABLE ... ADD ... ですが、SQL Serverでは ALTER TABLE ... ADD COLUMN ... の様な差分です。 また、テーブルやカラムの他にも外部キー制約やインデックスの比較、更新処理が定義されています。sqldefがどの様なDDL文を生成できるのか気になる方は、 schema/generator.go をご覧ください。新たにサポートしたい構文が出てきた時もここに処理を追加していきます。 sqldefがサポートする構文の追加 最後に、sqldefがサポートする構文を追加したい場合の追加手順を、SQL Serverで NOT FOR REPLICATION オプションを実際に追加対応した際の手順を例に紹介します。 SQL Serverでは制約に NOT FOR REPLICATION オプションを指定することで、レプリケーションエージェントによるテーブル操作時に制約を無視させることができます。sqldefで、その NOT FOR REPLICATION オプションをサポートするために取る手順は以下の通りです。 NOT FOR REPLICATION オプションを使えることが確認できるテストを追加する 抽象構文木に NOT FOR REPLICATION オプションの情報を追加する NOT FOR REPLICATION オプションを構文解析できる様にyaccファイルを改修する 既存データベースのDDL抽出部分で NOT FOR REPLICATION オプションも抽出できる様にadapterを改修する DDL生成部分であるgeneratorを改修する テストの追加 実装を開始する前に、まずは自分が追加しようとする処理の期待する動作を テストコードに書き起こします 。今回のプルリクエストでは、以下の2点を確認するテストコードを追加しています。 IDENTITYカラムとCHECK制約に NOT FOR REPLICATION オプションを使えること 新たに NOT FOR REPLICATION オプションを追加した際に適切な更新DDLが実行されること sqldefにはテストのためのヘルパーメソッドが用意されており、簡潔にテストコードを書くことができます。 assertApplyOutput() を使うことで、定義したDDL文をsqldefに与えた際の出力と期待する出力の比較テストができます。 抽象構文木の改修 次に抽象構文木で NOT FOR REPLICATION オプションの情報を保持できるよう改修をします。 sqldefは元々 Vitessの構文解析器 を拡張して開発されたという背景があります。そのため、sqldefにはVitess用の抽象構文木( sqlparser/ast.go )とsqldef用の抽象構文木( schema/ast.go )が存在します。goyaccで生成された構文解析器( sqlparser/parser.go )は、まず sqlparser/ast.go で定義されるデータ構造を出力します。その後、 schema/parser.go を使い、 schema/ast.go で定義されるデータ構造に変換します。以上のsqldef用の抽象構文木ができるまでの流れを以下の図にまとめました。 この流れがあるため、sqldefの抽象構文木に改修を加える際には、次の3ファイルの改修が必要です。 sqlparser/ast.go ( 今回の改修 ) schema/parser.go ( 今回の改修 ) schema/ast.go ( 今回の改修 ) 今回のプルリクエストでは抽象構文木の対象ノードのstructに NOT FOR REPLICATION の情報を保持するためのフィールドを追加しています。 schema/parser.go には抽象構文木の変換処理を追加しています。 yaccファイルの改修 テストコードに新しい構文を追加すると、テスト実行時に syntax error が発生するはずです。これは、構文解析器が新しく追加した構文(今回の場合は NOT FOR REPLICATION オプションの構文)を解析する手段を持たないため発生します。 構文解析器に新しい構文規則を追加するためにはyaccファイル( sqlparser/parser.y )を修正します。 今回の変更 では、新しいトークン( REPLICATION )の追加とカラムや制約を定義する構文内で NOT FOR REPLICATION オプションを読むための規則の追加をしています。 さらに、コールバックには上述した改修で追加した NOT FOR REPLICATION の情報を保持するためのフィールドに値を代入するための変更をしています。yaccファイルの修正が終わったらgoyaccコマンドを使って sqlparser/parser.go を生成します。 goyaccは構文解析器を生成する際に shift/reduce conflict や reduce/reduce conflict を発生させる場合があります。具体的には、新しく追加した規則が他の規則と重複してしまった際に発生します。conflictが発生してしまった場合は、既存の規則の中で流用できるものを探してみたり、省略記法を使えない様にするなどの対応で解決する場合があります。conflictに関して詳しくは「速習yacc 3 」をご覧ください。 adapterの改修 sqldefでは既存データベースから取得できる旧DDLと入力される新DDLを比較して更新DDLを生成します。 既存データベースから旧DDLを取得するための実装は adapter/ 配下にある各データベース用のパッケージに実装されています。例えば、MySQLでは SHOW CREATE TABLE 構文などを使って既存データベースのDDL文を取得できます。しかしSQL Serverにはその様な構文がないため、システムテーブルの情報を使ってDDL文を生成しています。 今回比較したいのは旧DDL文と新DDL文の NOT FOR REPLICATION の値です。そのため sys.check_constraints などのシステムテーブルから is_not_for_replication 列の値を読み込み、DDL文に追加する様に 改修 しています。 generatorの改修 抽象構文木とadapterの改修ができたら、最後に更新DDLの生成処理を改修します。 1つ目のステップで追加したテストの TestMssqldefCreateTableAddNotForReplication() を見れば、今回generatorに期待する動作が確認できます。期待する動作は、カラムと制約の NOT FOR REPLICATION オプションを確認し、旧テーブルと新テーブルに差分があれば更新DDL文として ALTER TABLE ... を生成することです。 IDENTITY要素の生成処理 を追うと分かりやすいですが、generatorは初めに areSameIdentityDefinition() で旧DDL文と新DDL文のIDENTITY要素を比較します。そして2つのDDL文に差分があった場合、カラムの削除とカラムの追加処理を更新DDL文のリストに追加しています。 この様に2つのDDL文の要素を簡単に比較できるのも、構文解析器を使ってDDL文を構造化したことの恩恵です。 以上がsqldefへのサポート構文追加の一例です。全ての変更がこのパターンに則しているわけではありませんが、各コンポーネントの役割を把握することが開発する際の手助けになるかと思います。 おわりに 本記事ではsqldefへの機能実装と言語アプリケーションの実装に必要な基礎知識をご紹介しました。 普段利用するツールの実装を理解することは、自分自身がそのツールをメンテナンスできる様になる点で有意義です。本記事が少しでもsqldefのユーザー増加に貢献し、開発がさらに活発になることを願っています。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com Flywayにも コマンドライツール がありますが、Java製のツールなので実行にはJVMが必要です。 ↩ Terence Parr, 「言語実装パターン コンパイラ技術によるテキスト処理から言語実装まで」, 2011。 ↩ 第9章 速習yacc ↩
はじめに ZOZOTOWN本部 ZOZOアプリ部 AndroidチームでZOZOTOWNのAndroidアプリを開発している鈴木です。 本投稿は、ZOZOTOWN AndroidアプリのHome画面に存在する「商品モジュール」実装中に発生したパフォーマンスの低下をPerfettoというツールを用いて特定・改善した事例を紹介します。 はじめに Home画面の「商品モジュール」について 発生した問題 調査方法について 調査ツールの選定 Perfettoとは? Perfetto UIを用いたAndroidアプリのトレースの流れ 1. 新しいトレースの設定・実行画面を表示 2. トレースに使用する端末を設定 3. トレースの設定 4. トレースコマンドの確認 5. トレースの実行 6. トレース結果の可視化 問題の調査 トレース内容 Perfettoのトレース設定 実行結果 問題の改善 改善方針の検討 1. 商品セルのinflate時間を短くする 2. 商品セルのinflate回数を減らす 改善方針の決定 改善方法 結果 パフォーマンスの比較方法 体感での比較 商品セルのinflateの平均時間の比較 まとめ おわりに Home画面の「商品モジュール」について Home画面は以下の画像のように、アプリを開いた際、最初に表示される画面のことを指します。商品情報やクーポン情報などを表示することで、ユーザに対して新しい発見の提供や、好みの商品への導線を提供しています。 Home画面の内、商品をある括りによって集めたものが「商品モジュール」です。例えば、マルチサイズアイテムと呼ばれるユーザの体型にあった商品を集めた商品モジュールや、最近チェックした商品を集めた商品モジュールなどがあります。 この商品モジュールは、2021/3/18のZOZOTOWNのリニューアルにおいて、以下の画像のようにデザインがリニューアルされ現在のデザインとなっています。 より多くの商品を閲覧できるように、横スクロールが新しく導入されるなど大幅にデザインがアップデートされました。 発生した問題 この新・商品モジュールを実装中に、Home画面の表示速度が遅いという問題が発生しました。 具体的には、商品の詳細画面からHome画面に戻る際、Home画面の表示に時間がかかるという問題です。 以下の動画が実際の例です。Home画面に戻る際、コンテンツ表示に時間がかかり、真っ白な画面が長く表示されています。 このパフォーマンスの低下は、ユーザ体験や売り上げに大きな影響が出ると考え、改善に向けて取り組むことにしました。 調査方法について 商品モジュールの実装において変更した点は基本的にレイアウトのみであったため、レイアウト周りにパフォーマンス低下の原因があると予測をたてて調査することにしました。 調査ツールの選定 リリース日との兼ね合いで、調査にはあまり時間をかけられません。短時間でボトルネックを特定する必要がありました。 商品モジュール内の商品1つ分のレイアウト(以下、商品セルと呼ぶ)はレイアウト構造が複雑です。変更箇所全てに対して計測用コードを実装するような方法は工数が大きくなるため、選択肢から除外して調査しました。 ツールを調査していると、システムトレースを用いることで、アプリのCPU使用率やスレッドの情報を記録できることがわかりました。 Android Developersの システムトレースの概要 によると、Androidのシステムトレースの方法には4種類オプションがあります。 Android StudioのCPU Profiler System Tracingアプリ Systraceコマンドラインツール Perfettoコマンドラインツール この4手法を、「短時間でトレース可能か」「トレース結果を短時間で確認可能か」という2つの軸で評価しました。 1の「Android StudioのCPU Profiler」は、トレースと結果の可視化がAndroid Studio内で完結するという方法です。実際に使用した結果、当時の筆者の実行環境では、トレース中やトレース結果の確認中にアプリやAndroid Studioの動作が重くなるという問題がありました 1 。この理由から、短時間でトレースが難しいと判断し、CPU Profilerを用いる方法は候補から除外しました。 2と3については、トレースを端末上で開始し、キャプチャしたデータをPCに移動させて可視化するという方法です。データをPCに移動して、前述のProfilerや後述のPerfetto UIなどで読み込まなければならないという点で、短時間での結果の確認が難しいと判断し候補から除外しました。 4の「Perfettoコマンドラインツール」は Perfetto UI からトレースの設定、Perfettoコマンドの実行、可視化が一貫してできる方法です。前述の3種類と比較して、最も短時間でボトルネックの特定が可能であったため、Perfettoを使用するという判断をしました。 Perfettoとは? Android 10で導入されたトレースツールであり、 Android Debug Bridge(ADB) を介して、パフォーマンス情報を収集できます。 パフォーマンス情報とは、CPU・メモリの使用率や各プロセスの情報等のことを指します。 さらに、 Perfetto UI と呼ばれるGUIツールを使用することで、トレースの設定、記録、可視化を一貫して行うことが可能です。 Perfetto UIを用いたAndroidアプリのトレースの流れ Perfetto UIを用いたトレースの基本的な流れ 2 は、以下の通りです。 1. 新しいトレースの設定・実行画面を表示 Perfetto UI にアクセスし、ページのサイドバーから「Record new trace」をクリックします。クリック後、以下のような画面が表示され、新しいトレースの設定・実行ができます。 2. トレースに使用する端末を設定 次に、トレースで使用するAndroid端末を設定します。「Add ADB Device」のボタンをクリックして、PCに接続されている端末を選択します。 3. トレースの設定 トレースの設定は、以下の画像のようにトレースしたい項目を選択していきます。 4. トレースコマンドの確認 設定完了後に「Recording command」の項目をクリックすると、設定した内容が adb コマンドとして表示されます。 5. トレースの実行 最後に「Start Recording」をクリックします。そうすることで、 4. トレースコマンドの確認 の項目で表示されたadbコマンドが端末上で実行されます。 6. トレース結果の可視化 トレースが終了すると、設定内容にもとづいてトレース結果が可視化されます。 問題の調査 実際にPerfettoを用いて調査した内容について述べます。 トレース内容 トレースは、実際に問題があった区間の内容としました。 具体的には、下図の通り、商品の詳細画面からHome画面のコンテンツが表示される区間としました。 Perfettoのトレース設定 Perfetto UI上で行ったトレース設定について説明します。 Perfeto UI 上で「Record new trace」を選択し、「Recording settings」において以下の画像のようにそれぞれ設定をしました。 ①の「Recording mode」では、バッファへ書き込まれるトレースデータの記録方式を設定します。今回は「Stop when full」を選択しました。これはトレースデータがバッファ上限を超えた際、書き込みをストップするという方式です。今回は後述のバッファサイズを十分確保できるためこの方式としました。 ②の「In-memory buffer size」では、トレースデータを書き込むためのバッファサイズを設定します。試しに計測をした結果、Perfetto UIが設定したデフォルト値「64MB」で問題なくトレース可能であったため「64MB」としました。 ③の「Max duration」では、トレースの時間を指定します。今回は、商品の詳細画面からHome画面のコンテンツが表示される間のトレースになるため、5(s)としました。 次に、「Android apps & svcs」を選択し、以下の画像のように設定しました。 ④の「Atrace userspace anntaions」を有効にし、⑤の「View System」を選択しました。これにより、Linuxカーネルに組み込まれている ftrace と呼ばれるトレース機能を使用して、レイアウト生成処理のイベントをキャプチャできるようになります。 設定が完了したら「Recording command」でトレース用のコマンドを取得します。 ⑥において、設定した①〜⑤の内容がadbコマンドとして出力されました。 下記が出力されたコマンドです 3 。 adb shell perfetto \ -c - --txt \ -o /data/misc/perfetto-traces/trace \ <<EOF buffers: { size_kb: 63488 fill_policy: DISCARD } buffers: { size_kb: 2048 fill_policy: DISCARD } data_sources: { config { name: "linux.ftrace" ftrace_config { ftrace_events: "ftrace/print" atrace_categories: "view" } } } duration_ms: 5000 EOF ⑦においてトレースする端末を選択し、⑧の「Start Recording」からトレースを開始します。 実行結果 トレース結果の内、①で示した区間が、商品セルをinflateしている箇所です。 inflateの内訳を見ると、②で示している区間において比較的時間がかかっていることがわかりました。 以下が②の部分を拡大した画像となっており、②はMaterialCardViewのinflate処理であることがわかりました。 さらに、以下のように、Home画面で表示される商品セルの数だけ(50以上)inflateが発生していることがわかりました。 問題の改善 トレース結果より、下記2つの改善方針の案を考えました。 商品セルのinflate時間を短くする 商品セルのinflate回数を減らす 改善方針の検討 どちらの方法も、一定の改善見込みがありました。 1. 商品セルのinflate時間を短くする Androidアプリでは、下記2つの計算後に、レイアウトの描画が行われます。 measureパス layoutパス measureパスでは、View, ViewGroupのサイズ計算が行われます。各View, ViewGroupが onMeasure() メソッドにより自身のサイズを申告することで、Viewツリーの全ノードの幅と高さを決定します。 layoutパスでは、View, ViewGroupの配置座標の計算が行われます。measureパスにより決定した各ノードのサイズ情報と親ノードの特徴(例えば、LinearLayoutでは子ノードを一列に並べるなど)をもとに、Viewツリーの全ノードの座標を決定します。 2つの計算が終了すると、各View, ViewGroupが draw() メソッドを呼び出し、レイアウトを描画していきます。 さて、以下のトレース結果からもわかるように、inflateはmeasureパスにおいて実行されています。 つまり、inflateの速度はmeasureパスの速度にも影響し、最終的にはレイアウトの描画にも影響します。 inflate時間の短縮に取り組むことで、パフォーマンスの改善が期待できます。 2. 商品セルのinflate回数を減らす 実行結果 の通り、Home画面で表示される商品セルの数だけinflateが実行されていました。 画面内に表示されていない商品セルについても、Home画面表示の際にinflateされていることから、この回数を減らすことでパフォーマンスの改善が期待できます。 改善方針の決定 今回はスケジュールの都合から、短時間で実装可能な方針のみ着手することとしました。 1.「商品セルのinflate時間を短くする」は、短時間で実装可能と判断しました。 商品セルのinflateにおいて一番時間がかかっているMaterialCardViewの使用を中止できる見込みが立ったためです。 2.「商品セルのinflate回数を減らす」は、短時間での実装は難しいと判断しました。 2のためには、以下のようにRecyclerView in RecyclerViewの構造にレイアウトを組み直す必要がありました。この構造にすることで、初回のレイアウト描画時はファーストビューに必要なコンテンツのみ描画処理され、inflate回数を減らすことができます。 レイアウト構造変更に伴う修正、RecyclerView in RecyclerViewを実現可能なライブラリの使用を検討・調査するなど、実装コストが高いと判断しました。 上記より、 1.「商品セルのinflate時間を短くする」 のみを着手することにしました。 改善方法 MaterialCardViewは、新・商品モジュールにおける商品セルの角丸を実装するために追加されており、セルのRootのViewとなっていました。 実装されていたコードのサンプルは下記の通りです。 <!-- レイアウトファイル --> <com . google . android . material . card . MaterialCardView android : id = "@+id/listItem" : > <!-- 商品情報(商品画像, 値段, ブランド名, etc.) --> : </com . google . android . material . card . MaterialCardView> // 角丸付与のロジック // 商品セル右側の上下 private fun setRoundCornersOnRightSides() { val CORNER_ROUND_DP = 10L val corner_round_px: Float = dpToPx(CORNER_ROUND_DP) binding.listItem.shapeAppearanceModel = ShapeAppearanceModel() .toBuilder() .setAllCornerSizes( 0F ) .setTopRightCorner(CornerFamily.ROUNDED, corner_round_px) .setBottomRightCorner(CornerFamily.ROUNDED, corner_round_px) .build() } : // 商品セル左側の上下 private fun setRoundCornersOnLeftSides() { : // 商品セルの上下左右 : MaterialCardViewを用いると、 ShapeAppearanceModel を用いて簡単に角丸を実装できます。 しかし、パフォーマンスを犠牲にしてまで利用するものではないため、FrameLayoutのbackgroundに角丸のdrawableを当てる方法で代替しました。 実装コードのサンプルは下記の通りです。 <!-- 商品セルのレイアウト --> <FrameLayout android : id = "@+id/listItem" : > <!-- 商品情報(商品画像, 値段, ブランド名, etc.) --> : </FrameLayout> // 角丸付与のロジック // 商品セル右側の上下 private fun setRoundCornersOnRightSides(context: Context) { binding.listItem.run { background = ContextCompat.getDrawable(context, R.drawable.bg_corner_right) foreground = ContextCompat.getDrawable(context, R.drawable.fg_corner_right) } } : // 商品セル左側の上下 private fun setRoundCornersOnLeftSides(context: Context) { : // 商品セルの上下左右 : 結果 パフォーマンスの比較方法 パフォーマンスが改善できているか確認するため、下記項目を改善方針の実装前後で比較しました。 端末を操作したときの体感での比較 商品セルのinflateの平均時間の比較 体感での比較は、実際にアプリを操作したときの動作比較です。 inflateの平均時間は、Perfettoを用いて3回トレースした際の、商品セルのinflate平均時間を比較しています。 体感での比較 チームで実際に操作して検証したところ、「微妙な差ではあるものの、対応後の方が速いように思う」という評価が集まりました。 商品セルのinflateの平均時間の比較 定量的にも評価します。 対応前 トレース(回目) 平均inflate時間(ms) 1 10.50 2 10.40 3 10.78 対応後 トレース(回目) 平均inflate時間(ms) 1 8.59 2 8.68 3 8.23 対応前はトレース3回の平均は10.56ms、対応後は8.5msでした。 1つ1つはたった2msと劇的ではないものの、商品セルの描画全体では 2ms × 50セル = 100ms ほど改善される結果となりました。 まとめ 本投稿ではPerfettoと呼ばれるツールを用いて、実装中に感じたパフォーマンスの低下を特定・改善した事例について紹介しました。 対応後の実装では、体感で劇的な改善とはなりませんでしたが、全体で100msほど表示速度が改善されました。 加えて、現在は 問題の改善 で述べた「商品セルのinflate回数を減らす」改善も実施し、RecyclerView in RecyclerViewのレイアウト構造となりました。これにより、更にパフォーマンスが改善されつつあります。 また、当時は開発環境の問題から諦めたAndroid StudioのCPU Profilerも、現在の筆者の開発環境では快適に動作するようになりました。これの使用も検討中です。 おわりに ZOZOテクノロジーズでは、Androidエンジニアを募集しています。 まずは、以下のリンクから、お気軽にカジュアル面談にご応募ください! hrmos.co 現在(2021/8/30時点)は問題なく確認できています。 ↩ Quickstart: Record traces on Android - Perfetto Tracing Docs ↩ コード内の各フィールドについては、 公式ドキュメント で詳しく説明されています。 ↩
はじめに こんにちは。マイグレーションチームの藤本です。 ZOZOTOWNはオンプレミスとクラウドのハイブリッドで動いており、その内、オンプレミス側のアプリケーションはClassic ASPとストアドプロシージャの組み合わせで実装されています。 私たちのチームでは、そのClassic ASPとストアドプロシージャの廃止を目標に、まずは参照系の処理をWeb APIで置き換える作業をしています。この記事では、 Karate を使って参照系の処理を置き換えるWeb API(以後、 参照系API )のE2Eテストを実現するための取り組みについてご紹介します。 全体に影響する修正とテストの必要性 フレームワークのバージョンアップ 冒頭の通り、参照系APIは商品やショップの情報を取得すると言ったZOZOTOWNでも最も古い部類に入る機能たちを、まずはAs−IsでそのままWeb API化することを目的とするものです。そのため、いわゆる「REST API」とは異なり、1つ1つのWeb APIが様々な機能を提供しています。同じエントリポイントであっても、その時のパラメーターによって挙動が全く異なるため、網羅的な動作確認が重要です。 この参照系APIは少し古いバージョンのSpring Bootで実装されており、ある時バージョンアップが必要となりました。 バージョンアップ時のテスト Spring Bootは各バージョンごとに マイグレーションガイド を作成してくれているので、手順通りに進めればそれほど躓くことなくバージョンアップができます。しかしながら、影響調査の過程で対象から漏れたり勘違いしたり、あるいはまだ修正されていないバグにぶつかってしまう可能性は十分にあります。Spring Bootのようなフレームワークのバージョンアップの場合には、できるだけ広い範囲をテストするべきです。 参照系Web APIで準備しているテストと課題 参照系APIは作成されたときからユニットテストが作られており、現在も機能の改修を続けながらControllerやService単位でテストもあわせて修正されています。 一方で、当時E2Eテストは不十分でした。主な役割の1つである商品やショップを探す機能は、エンドポイントやパラメーターの組み合わせのパターンが多く、この網羅が難しい状況でした。 このような状況でも、ある程度の網羅性を確保しつつE2Eテストを実施するために、Karateをテストツールとして採用しました。 Karateの準備 Karateを選択した理由 Karateはテスト自動化ツールのひとつで、MockサーバーやUIテストの機能も備えています。 github.com Karateを選択した大きな理由はJavaで作られていることです。参照系APIの開発はJavaで行っており、すでにチームメンバーのPCにも実行環境が整っています。 Karateの実行方法はいくつか用意されていますが、JARファイルを使ったスタンドアローンな実行方法を選択することで、簡単に実行できます。 JARファイルを取得する javaコマンドで実行する 今回はこの方法を採用しました。 テストを実行するための準備 本格的なパターンを作成する前に、まずは簡単なテストが実行できるのか試しました。 Spring Boot Actuatorを導入済みだったので、ヘルスチェックのエンドポイントにアクセスするテストシナリオを作成しました。ファイル名はtest.featureです。 Feature: Test Background: * def baseUrl = ' http://localhost:8080 ' Scenario: test Given url baseUrl + ' /actuator/health ' When method get Then status 200 /actuator/healthにGETでアクセスして、HTTPスタータスが200であればテストOKという簡単な内容です。 実行するときはKarateのJARファイルと先程のテストシナリオのファイルを指定します。 java -jar karate.jar test .feature 成功すると次のように結果が表示されます。 06:36:22. 721 [ main ] DEBUG com.intuit.karate - request: 1 > GET http://localhost:8080/actuator/health 1 > Host: localhost:8080 1 > Connection: Keep-Alive 1 > User-Agent: Apache-HttpClient/ 4 . 5 . 13 ( Java/ 11 . 0 . 12 ) 1 > Accept-Encoding: gzip,deflate 06:36:22. 875 [ main ] DEBUG com.intuit.karate - response time in milliseconds: 149 1 < 200 1 < Content-Type: application/vnd.spring-boot.actuator.v3+json 1 < Transfer-Encoding: chunked 1 < Date: XXX, XX Sep 2021 06:36:22 GMT 1 < Keep-Alive: timeout = 60 1 < Connection: keep-alive { " status " : " UP " , " groups " : [" liveness " , " readiness "] } --------------------------------------------------------- feature: test .feature scenarios: 1 | passed: 1 | failed: 0 | time: 0 . 6267 --------------------------------------------------------- 06:36:23. 834 [ main ] INFO com.intuit.karate.Suite - <<pass >> feature 1 of 1 (0 remaining) features/local/test.feature Karate version: 1.1.0 ====================================================== elapsed: 2.80 | threads: 1 | thread time: 0.63 features: 1 | skipped: 0 | efficiency: 0.22 scenarios: 1 | passed: 1 | failed: 0 ====================================================== HTML report: (paste into browser to view) | Karate version: 1.1.0 file:///target/karate-reports/karate-summary.html =================================================================== curlコマンドにverboseオプションをつけて実行したときと似た内容に加えて、実行にかかった時間とテスト結果のサマリが出力されます。出力の最終行にもある通り、テスト結果のサマリはレポートファイルとしても出力されます。これでKarateの準備はOKです。 テストパターンの準備 Karateの準備ができたら次はテストパターンの準備です。テストシナリオをできるだけ本番に近づけるため、Splunkのログを利用することにしました。ログからリクエスト内容を取得することで、実際の使われ方に近いテストを作成します。なお、ZOZOTOWNでのSplunk利用については、以下の記事をご覧ください。 techblog.zozo.com Splunkでテストパターン作成 抽出したサーチ文の例がこちらです。 uri_path = " /api/* " http_method = " GET " | strcat uri_path " ? " uri_query uri | strcat " Given url baseUrl + ' " uri " '| " given | strcat given + " When method get| " when | strcat when + " Then status 200|| " then | table then | rex field =then mode =sed " s/ \| / \n /g " URLから取得できるパスとクエリ、KarateのDSLを文字列として結合して、最後に改行を置換しています。このサーチ文で検索した結果をCSVとしてダウンロードするだけで、ほぼテストシナリオは完成です。具体的には以下のような結果が得られます。 Given url baseUrl + ' /actuator/health ' When method get Then status 200 この結果に、リクエスト先などの先頭行を加えると、前述のtest.featureと同じものが完成です。 Feature: Test Background: * def baseUrl = ' http://localhost:8080 ' Scenario: test Given url baseUrl + ' /actuator/health ' When method get Then status 200 Karateは非常にシンプルなDSLを持っており、このようにアクセスログからそのままテストシナリオを作ることが可能です。参照系APIのようにパラメーターの組み合わせや動作が複雑なWeb APIを扱う場合、とても便利な特徴です。 なお、この例で作成しているテストシナリオは、HTTPステータスが200であることしか確認していません。状況に応じてレスポンス内容のAssertionを追加ください。 なお、今回Karateを導入するにあたって、サーチ文による検索を1か月という期間で絞りました。そのため、数ヶ月に1回のアクセスやレアなパラメーターの組み合わせは含まれていない可能性があります。想定されるパターンを隈なく探し出すことにコストを掛けるよりも、まずは仕組みを作って実行できる環境を整えることを優先しました。これは参照系APIに、マスタデータを更新したり決済したりといった、クリティカルな機能が含まれていないからこその判断です。 クリティカルな機能が含まれるWeb APIの場合は、純粋に全パターンを網羅するテストを実施するほうが望ましいでしょう。 作成したテストパターンを使う 作成したテストパターンでテストを実行します。実行自体は前述と同様で、テストシナリオとして指定するファイルを変更するだけです。参照系APIは非公開APIのため、URLはダミーです。 Feature: Test api Background: * def baseUrl = ' http://localhost:8080/api ' Scenario: Pass Through Test Given url baseUrl + ' /v1/xxxxx/yyyyy ' When method get Then status 200 Given url baseUrl + ' /v1/xxxxx/yyyyy/?id=hoge ' When method get Then status 200 ...省略... java -jar karate.jar PassThroughTest.feature ...省略... 04:00:43. 703 [ main ] DEBUG com.intuit.karate - response time in milliseconds: 53 67 < 200 67 < Content-Type: application/json 67 < Transfer-Encoding: chunked 67 < XXX, XX Sep 2021 04:00:43 GMT 67 < Keep-Alive: timeout = 60 67 < Connection: keep-alive { " result " : [] } --------------------------------------------------------- feature: features/local/PassThroughTest.feature scenarios: 1 | passed: 1 | failed: 0 | time: 2 . 4704 --------------------------------------------------------- 04:00:46. 181 [ main ] INFO com.intuit.karate.Suite - <<pass >> feature 1 of 1 (0 remaining) features/local/PassThroughTest.feature Karate version: 1.1.0 ====================================================== elapsed: 6.30 | threads: 1 | thread time: 2.47 features: 1 | skipped: 0 | efficiency: 0.39 scenarios: 1 | passed: 1 | failed: 0 ====================================================== HTML report: (paste into browser to view) | Karate version: 1.1.0 file:///target/karate-reports/karate-summary.html =================================================================== 現時点で67件のテストを実行して、およそ7秒前後の実行時間になっています。これからパターンを増やしていくと実行時間も伸びていきますが、テストが苦になるほどの時間はかからない見込みです。 まとめ Karateを使ったWeb APIのテストを実現するための取り組みについてご紹介しました。ライブラリのバージョンアップといった全体に影響する変更も、テストを実行できる環境があることで、これまでよりも安心して行えるようになりました。ただ、今回作成したテストシナリオもまだまだ万全ではなく、今後はテストパターンや確認する内容を増やしていく必要があります。引き続き安定してサービスを提供できるよう、ZOZOTOWNの改善を進めていきます。 さいごに ZOZOTOWNのリプレイスはこれからも続きます。機能の追加とパフォーマンスの維持、安定稼働を両立しながら進めなければなりません。 ZOZOテクノロジーズでは、一緒にサービスを成長させていく仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co
はじめに こんにちは、EC基盤本部・MA部・MA基盤チームでマーケティングオートメーションのシステムを開発している長澤( @snagasawa_ )です。この記事では、CypressによるE2EテストをVue.jsプロジェクトへ導入した取り組みについて、実際の画面を交えてご紹介します。このE2Eテストによって、複雑な入力フォームを自動でテストできるようになり、修正後のバグを検知しやすくなりました。E2Eテストの導入を検討されている方の参考になれば幸いです。 Vue.jsプロジェクトの技術スタック 今回Cypressを導入したプロジェクトの主な技術スタックは以下の通りです。 Vue.js TypeScript Vuetify Open API 導入背景 E2Eテスト導入の理由は、複雑な入力フォームを動作保証するためです。 我々のチームでは、Line Friendship Manager(以下、LFM)という名前のLINEメッセージ配信ツールを開発・運用しています。詳細は割愛しますが、任意のユーザーセグメントに対してLINEメッセージを配信できる社内向けのマーケティングツールです。このプロダクトでは以下のような複雑な入力フォームが複数画面に渡って存在しています。 入力された値によって動的にフィールドの種類や数が変わる フィールドの中にフィールドが存在する入れ子構造になっている 使用するユーザーが限られた管理画面ということもあり、開発リソースは最小限でした。開発メンバーにはデザイナーもフロントエンドを専門とするエンジニアもおらず、1・2名のエンジニアのみでサーバーサイドとインフラを兼任してフロントエンドを開発していました。そのため、UIもCSSは最低限の実装のみで、基本的にはVuetifyのコンポーネントを組み合わせて開発していました。 一方で、上に挙げたような画面の複雑さがあり、修正の度に入力フォームの正しい挙動を担保する必要がありました。E2Eテスト導入までは手動で動作確認を行なっていましたが、検証パターンの抜け漏れが懸念でした。このため、コンポーネント単位のユニットテストよりも、コンポーネントの状態を組み合わせた複雑な画面変化のパターンを網羅しうるE2Eテストが必要でした。 Cypressを採用した理由 E2EテストのフレームワークはCypress以外にも複数存在しますが、Cypressを選択した理由は次の2つからです。 GUIテストランナー カスタムコマンド機能 1つ目のCypressのテストランナーは、ブラウザ上で画面のレンダリングとテストコードによるDOM操作をリアルタイムに再生する機能です。 docs.cypress.io docs.cypress.io 具体的には以下のような操作が可能で、デバッグがしやすくなります。 テスト実行中にDOM操作の再生を中断 テストが失敗した時の画面のレンダリングの確認 クリックした要素の取得やDeveloper ToolsのConsole Logへの出力 テスト実行後に指定のDOM操作まで巻き戻し このデバッグのしやすさが、今回のプロダクトのようにフォームが複雑であっても、意図しない画面状態を視認する一助になってくれるだろうという期待がありました。 2つ目はカスタムコマンド機能です。Cypressで事前定義されているコマンドを組み合わせて、独自のDOM操作を定義し再利用することで、効率的にテストを実装できます。後ほど実際に使用しているカスタムコマンドをご紹介します。 docs.cypress.io CypressのVue.js + TypeScript環境構築 ここからは実際の画面をもとに、導入からテスト実装の流れを説明します。 はじめにCypressをインストールし、 cypress open を実行します。そうするとデフォルトのディレクトリとファイルが作成され、テストランナーが起動します。TypeScriptの場合は、tsconfig.jsonを追加し、各種ファイルの拡張子を変更します。デフォルトのファイルを変更した場合は、cypress.jsonでもパスを修正します。 $ npm install cypress --save-dev $ npx cypress open $ tree ./cypress cypress ├── fixtures │ └── example.json ├── integration ├── plugins │ └── index.js ├── screenshots ├── support │ ├── commands.js │ └── index.js └── videos $ mv cypress/plugins/index.js cypress/plugins/index.ts $ mv cypress/support/index.js cypress/support/index.ts $ mv cypress/support/commands.js cypress/support/commands.ts cypress/tsconfig.json { " extends ": " ../tsconfig.json ", " compilerOptions ": { " types ": [ " cypress " ] } , " include ": [ " ../node_modules/cypress ", " ./**/*.ts " ] } cypress.json { " baseUrl ": " http://localhost:8082 ", " pluginsFile ": " cypress/plugins/index.ts ", " supportFile ": " cypress/support/index.ts ", " video ": false , " screenshotOnRunFailure ": false } 補足すると、Vue.jsプロジェクトではCypressをvue-cliのプラグインとしてもインストールできます。しかし、プラグインはバージョンが本家よりも古いためオススメしません。記事公開の時点ではCypressの最新バージョンは v8.3.0 ですが、プラグインでは v7.1.x となっています。Cypressはリリースが早く、次々と新しい機能が追加されるため、新しいものを使うほうが便利です。ただ、バグも多いので新規機能の追加直後の利用はご注意ください。 テスト実装の流れ 環境構築が終わったら実際にテストを実装していきます。 前述のLFMから、配信対象となるユーザーのセグメントを登録する「セグメント画面」をテストコードの題材にします。下の画像が画面のキャプチャです。 このセグメント画面で、条件を組み合わせてセグメントのユーザー数を確認・登録すると、その条件に基づきLINEメッセージが配信されます。例えば、「性別: 男性」「年齢: 20歳以上」「ZOZOカードを利用している」といった条件を指定できます。Google Analyticsをご存知の方であれば、セグメントビルダーを思い出していただければイメージしやすいかと思います。 このように複数の条件を組み合わせて登録する画面のため、フィールドの数が増減したり、条件の指標次第でフィールドが動的に切り替わる仕様となっています。また、これらのフィールドの状態をチェックしてボタンの活性・非活性を制御する必要もあります。 画面操作の主な流れは以下の通りです。 条件を指定する ターゲットを抽出してユーザー数を確認する 登録する これに沿ったテストの実装の流れは次の通りです。 画面にアクセスした時のリクエストのレスポンスをスタブにする テストデータを生成するFactoryを定義する カスタムコマンドを定義し、DOM操作のコマンドを書く 操作完了時の画面の遷移をチェックする interceptによるレスポンスのスタブ まずはじめに、画面からリクエストされるAPIのスタブを行います。 cy.visit() で画面へアクセスする際に発生するAPIリクエストのレスポンスを cy.intercept() でスタブにします。 cy.intercept() はリクエストのメソッド・URL・レスポンスを引数に渡すことで、それにマッチしたリクエストへ任意のレスポンスを返すことができます。 docs.cypress.io 今回は「ターゲット抽出」と「登録」の2つのリクエストで指定したレスポンスを返し、それ以外のリクエストは空のレスポンスを返すようにします。下はサンプルコードですが、第3引数に渡しているテストデータの segment 変数については後述します。 beforeEach (() => { // APIはデフォルトで空のレスポンスを返す cy.intercept ( /api/ , [] ); // ターゲット抽出APIと登録APIにアクセスするとsegmentを返す cy.intercept ( 'POST' , '/api/segments/count' , segment ) . as( 'countTarget' ); cy.intercept ( 'POST' , '/api/segments' , segment ) . as( 'saveSegment' ); // セグメント画面へアクセス cy.visit ( `${Cypress.config().baseUrl}/#/segments/new` ); } ); スタブしたリクエストは .as() でエイリアスを指定できます。エイリアスは cy.wait() でそのリクエストが完了するまで自動待機する時などに利用できます。 テストデータの生成 続いてテストで利用するテストデータを生成します。先ほどの segment 変数はスタブしたAPIのレスポンスであり、セグメントの条件やターゲットを抽出した結果のユーザー数を保持するオブジェクトが格納されます。今回はこのオブジェクトをテストデータのFactoryによって生成します。 cy.intercept() のレスポンスには、オブジェクトや cy.fixture() で生成したテストデータなどを渡すことができます。簡単な方法では、単に固定のJSONファイルを cy.intercept() の fixture オプションに渡すことも可能です。 しかし、LFMではOpen APIの定義からフロントエンドの型定義を生成しているため、テストデータもこの型定義と一致させるようにFactoryを定義しています。これによって、Open APIのスキーマ変更後にプロダクトコードのみを修正してFactoryの修正を忘れた場合は、テスト実行前のビルドが型定義のエラーによって異常終了するようにしています。 import * as faker from 'faker' ; // Open APIで生成した型定義 import { Segment , SegmentCondtion , SegmentTargetCount } from '@/services/api/types/segment' ; // Factoryの型定義 interface Factory < T > { create ( params?: Partial < T >) : T ; createList ( num: number ) : T [] ; } // Factoryの定義 const segmentFactory: Factory < Segment > = { create ( params ) { return { id: faker.random. number (), name: `セグメント${faker.random.number()}` , status : SegmentStatus.FIXED.value , segmentConditions: [ segmentConditionFactory.create () ] , segmentTargetCount: segmentTargetCountFactory.create (), createdTime: new Date (), updatedTime: new Date (), ...params , } ; } , createList ( num: number ) { return [ ... Array ( num ) ] .map ( n => this .create ()); } , } ; // segmentに含まれるオブジェクト用のFactory const segmentConditionFactory: Factory < SegmentCondition > = { /* 省略 */ } ; const segmentTargetCountFactory: Factory < SegmentTargetCount > = { /* 省略 */ } ; // テストデータの生成 const segment = segmentFactory.create (); カスタムコマンドによるDOM操作 最後にDOM操作とアサーションを追加していき、テストコードを完成させます。 テストコードを書いていると同じような実装が頻繁に登場します。特にVuetifyのようなUIコンポーネントライブラリを利用している場合は尚更です。そこでテストコードの実装をDRYにしたい場合、カスタムコマンドを定義して再利用するという方法があります。 公式ドキュメントでは、 要素のセレクターのベストプラクティス として data-* をコンポーネントの属性に追加し、CSSやJavaScriptの変更の影響を受けないように記述することが推奨されています。しかし、UIコンポーネントライブラリを利用している場合は、コンポーネントごとにカスタムコマンドを作ることでDOM操作をDRYに書けます。そのため、必ずしもこのプラクティスを厳守せずに、適宜カスタムコマンドを組み合わせて使っています。 実際に利用しているカスタムコマンドの一部です。このように小さなDOM操作を組み合わせてカスタムコマンドを定義しています。 cypress/support/commands.ts // 頻出する操作をカスタムコマンドとして定義 // 操作:ボタン要素の特定・テキストエリアの特定・ボタンを押す・テキストエリアを埋める Cypress.Commands.add ( 'getVBtn' , ( content: string ) => { cy.get ( '.v-btn' ) .contains ( 'span' , content ) . parent (); } ); Cypress.Commands.add ( 'getVTextField' , ( label: string ) => cy.contains ( 'label' , label ) .next ( 'input' ) ); Cypress.Commands.add ( 'clickVBtn' , ( content: string ) => cy.getVBtn ( content ) .click ( { force: true } ) ); Cypress.Commands.add ( 'fillInVTextField' , ( label: string , value: string | number ) => { return cy .getVTextField ( label ) .clear () . type( `${value}` ); } ); // カスタムコマンドの型定義 // これを行わないと、cyの後のメソッド呼び出しでエラーになる declare namespace Cypress { interface Chainable < Subject > { getVBtn: ( content: string ) => Chainable < Subject >; getVTextField: ( label: string ) => Chainable < Subject >; clickVBtn: ( content: string ) => Chainable < Subject >; fillInVTextField: ( label: string , value: string | number ) => Chainable < Subject >; } } カスタムコマンドを定義したら、実際にDOM操作のコードを書いていきます。下のコードは、セグメント画面の名前入力から条件指定までの操作をコード化したものです。 const fillOutForm = () => { // ① 名前の入力 cy.fillInVTextField ( '名前' , 'セグメント01' ); // ② 「条件を追加する」ボタンの押下 cy.clickVBtn ( '条件を追加する' ); // 条件のフィールドが1行追加されたことを確認 cy.get ( '[data-cy=segment-condition-field]' ) .should ( 'have.length' , 1 ); // ③ 条件の指標の選択 selectSegmentConditionDimension ( '年齢' ); // ④ 条件の値の入力 inputSegmentConditionValue ( 20 ); // ⑤ 条件の演算子の選択 selectSegmentConditionOperator ( '以上である' ); } ; const selectSegmentConditionDimension = ( text: string , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-dimension]' ) .eq ( index ) . parent () .click (); cy.contains ( text ) .click (); } ; const inputSegmentConditionValue = ( value: string | number , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-value]' ) .eq ( index ) . type( `${value} {enter}` ); } ; const selectSegmentConditionOperator = ( text: string , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-operator]' ) .eq ( index ) . parent () .click (); cy.contains ( text ) .click (); } ; このように要素の取得と、それに対する操作やアサーションの記述を繰り返していきます。 続いて、条件指定後の登録成功時の画面遷移までのコードです。 describe ( 'フォーム入力から登録ボタンを押すまで' , () => { context ( 'response status: 200' , () => { it ( 'スナックバーが表示され、セグメント一覧画面に遷移する' , () => { // ①〜⑤ フォーム入力 fillOutForm (); // ⑥ 「ターゲット数抽出」ボタンの押下 cy.getVBtn ( 'ターゲット数抽出' ) .should ( 'not.be.disabled' ) .click (); // ターゲット抽出APIのレスポンス待機 cy.wait ( '@countTarget' , { timeout: 5000 } ); // ⑦ 抽出結果の表示確認 cy.get ( '[data-cy=segment-target-count__target-count]' ) .contains ( segmentTargetCount.targetCount ); cy.get ( '[data-cy=segment-target-count__segmented-at]' ) .contains ( formatDate ( segmentTargetCount.segmentedAt ) ); // ⑧ 登録ボタンの押下 cy.getVBtn ( '登録' ) .should ( 'not.be.disabled' ) .click (); // 登録APIのレスポンス待機 cy.wait ( '@saveSegment' , { timeout: 5000 } ); // 登録成功メッセージの表示確認 cy.get ( '.v-snack__content' ) .should ( 'include.text' , '登録しました' ); // 登録成功時の一覧画面遷移の確認 cy.url ( { timeout: 5000 } ) .should ( location => expect ( location ) .to.include ( '/#/segments?tab=' ) ); } ); } ); } ); これまでのコードをまとめた最終的なテストコードは以下の通りです。 import { Operator } from '@/enum/segment' ; import { formatDate } from '@/services/formatter' ; import { segmentConditionFactory , segmentFactory , } from '@/factories' ; // テストデータの生成 const segmentCondition = segmentConditionFactory.create ( { dimension: '年齢' , value: 20 , operator: Operator.GREATER_THAN_OR_EQUAL_TO.value , } ); const segment = segmentFactory.create ( { name: 'セグメント01' , segmentConditions: [ segmentCondition ] , } ); const segmentTargetCount = segment.segmentTargetCount ; const fillOutForm = () => { // ① 名前の入力 cy.fillInVTextField ( '名前' , 'セグメント01' ); // ② 「条件を追加する」ボタンの押下 cy.clickVBtn ( '条件を追加する' ); // 条件のフィールドが1行追加されたことを確認 cy.get ( '[data-cy=segment-condition-field]' ) .should ( 'have.length' , 1 ); // ③ 条件の指標の選択 selectSegmentConditionDimension ( '年齢' ); // ④ 条件の値の入力 inputSegmentConditionValue ( 20 ); // ⑤ 条件の演算子の選択 selectSegmentConditionOperator ( '以上である' ); } ; const selectSegmentConditionDimension = ( text: string , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-dimension]' ) .eq ( index ) . parent () .click (); cy.contains ( text ) .click (); } ; const inputSegmentConditionValue = ( value: string | number , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-value]' ) .eq ( index ) . type( `${value} {enter}` ); } ; const selectSegmentConditionOperator = ( text: string , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-operator]' ) .eq ( index ) . parent () .click (); cy.contains ( text ) .click (); } ; // レスポンスのスタブと画面へのアクセス beforeEach (() => { cy.intercept ( /api/ , [] ); cy.intercept ( 'POST' , '/api/segments/count' , segment ) . as( 'countTarget' ); cy.intercept ( 'POST' , '/api/segments' , segment ) . as( 'saveSegment' ); cy.visit ( `${Cypress.config().baseUrl}/#/segments/new` ); } ); describe ( 'フォーム入力から登録ボタンを押すまで' , () => { context ( 'response status: 200' , () => { it ( 'スナックバーが表示され、セグメント一覧画面に遷移する' , () => { // ①〜⑤ フォーム入力 fillOutForm (); // ⑥ 「ターゲット数抽出」ボタンの押下 cy.getVBtn ( 'ターゲット数抽出' ) .should ( 'not.be.disabled' ) .click (); // ターゲット抽出APIのレスポンス待機 cy.wait ( '@countTarget' , { timeout: 5000 } ); // ⑦ 抽出結果の表示確認 cy.get ( '[data-cy=segment-target-count__target-count]' ) .contains ( segmentTargetCount.targetCount ); cy.get ( '[data-cy=segment-target-count__segmented-at]' ) .contains ( formatDate ( segmentTargetCount.segmentedAt ) ); // ⑧ 登録ボタンの押下 cy.getVBtn ( '登録' ) .should ( 'not.be.disabled' ) .click (); // 登録APIのレスポンス待機 cy.wait ( '@saveSegment' , { timeout: 5000 } ); // 登録成功メッセージの表示確認 cy.get ( '.v-snack__content' ) .should ( 'include.text' , '登録しました' ); // 登録成功時の一覧画面遷移の確認 cy.url ( { timeout: 5000 } ) .should ( location => expect ( location ) .to.include ( '/#/segments?tab=' ) ); } ); } ); } ); 上記のテストコードではカスタムコマンドがシンプルなボタン操作やフィールド入力のみのため、もしかするとその恩恵が分かりづらいかもしれません。しかし、テスト対象のコンポーネントの数が増え、同じ操作が繰り返される場合にはそのメリットを実感できます。もう少し複雑な例では、TimePickerやDatePicker用のカスタムコマンドも定義しました。このような複数のステップに渡る操作の場合はカスタムコマンド化によるメリットがより大きくなります。 Cypress.Commands.add ( 'clickOnVDatePicker' , ( label: string , date: string ) => { cy.getVTextField ( label ) .click ( { force: true } ); cy.get ( '.v-menu__content.menuable__content__active > .v-picker--date' ) .first () .within (() => { cy.contains ( '.v-btn' , date ) .click ( { force: true } ); cy.clickVBtn ( 'OK' ); } ); } ); Cypress.Commands.add ( 'clickOnVTimePicker' , ( label: string , hour: string , minute: string ) => { cy.getVTextField ( label ) .click ( { force: true } ); cy.get ( '.v-menu__content.menuable__content__active > .v-picker--time' ) .first () .within (() => { cy.contains ( '.v-time-picker-clock__item' , hour ) .click ( { force: true } ); cy.contains ( '.v-time-picker-clock__item' , minute ) .click ( { force: true , } ); cy.clickVBtn ( 'OK' ); } ); } ); E2Eテストによる効果 テスト実装後はCircleCIに設定を追加し、GitHubのPRにコミットがプッシュされるたびにテストが実行されるようにしました。これによって手動確認していた検証パターンの一部を代替し、当初の懸念であった抜け漏れの発生する可能性を低減できました。実際にテストを導入した画面では、その後バグが1件も発生していません。また、手動での確認時間を削減できた分、以前より開発サイクルが早くなりました。 まとめ Vuetifyを利用したVue.jsプロジェクトへ、CypressによるE2Eテストを導入する取り組みについてご紹介しました。また、Cypressのテストランナーやカスタムコマンドによる開発効率化についても紹介しました。効率的に画面開発の保守性を高める必要に迫られた時にはCypressの導入検討をオススメします。 さいごに ZOZOテクノロジーズでは一緒にプロダクトを開発してくれるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください! tech.zozo.com
こんにちは。アーキテクト部の廣瀬です。 私は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